Skip to content

Commit

Permalink
Merge pull request #70 from xmartlabs/Occupancy-SendNotifications
Browse files Browse the repository at this point in the history
Occupancy Notifications
  • Loading branch information
renzodgc authored Nov 10, 2020
2 parents 4596c4e + 65645e1 commit e20c095
Show file tree
Hide file tree
Showing 13 changed files with 240 additions and 10 deletions.
2 changes: 2 additions & 0 deletions config-coral.ini
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ DashboardURL = http://0.0.0.0:8000
ScreenshotsDirectory = /repo/data/processor/static/screenshots
EnableSlackNotifications = no
SlackChannel = lanthorn-notifications
; OccupancyAlertsMinInterval time is measured in seconds (if interval < 0 then no occupancy alerts are triggered)
OccupancyAlertsMinInterval = 180

[API]
Host = 0.0.0.0
Expand Down
2 changes: 2 additions & 0 deletions config-jetson.ini
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ DashboardURL = http://0.0.0.0:8000
ScreenshotsDirectory = /repo/data/processor/static/screenshots
EnableSlackNotifications = no
SlackChannel = lanthorn-notifications
; OccupancyAlertsMinInterval time is measured in seconds (if interval < 0 then no occupancy alerts are triggered)
OccupancyAlertsMinInterval = 180

[API]
Host = 0.0.0.0
Expand Down
2 changes: 2 additions & 0 deletions config-x86-openvino.ini
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ DashboardURL = http://0.0.0.0:8000
ScreenshotsDirectory = /repo/data/processor/static/screenshots
EnableSlackNotifications = no
SlackChannel = lanthorn-notifications
; OccupancyAlertsMinInterval time is measured in seconds (if interval < 0 then no occupancy alerts are triggered)
OccupancyAlertsMinInterval = 180

[Area_0]
Id = area0
Expand Down
2 changes: 2 additions & 0 deletions config-x86.ini
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ DashboardURL = http://0.0.0.0:8000
ScreenshotsDirectory = /repo/data/processor/static/screenshots
EnableSlackNotifications = no
SlackChannel = lanthorn-notifications
; OccupancyAlertsMinInterval time is measured in seconds (if interval < 0 then no occupancy alerts are triggered)
OccupancyAlertsMinInterval = 180

[Area_0]
Id = area0
Expand Down
76 changes: 76 additions & 0 deletions libs/area_reporting.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import os
import time
import logging
import csv
from datetime import date, datetime
from collections import deque
from .utils.mailing import MailService
from .notifications.slack_notifications import SlackService

logger = logging.getLogger(__name__)


class AreaReporting:

def __init__(self, config, area):
self.processing_alerts = False
self.config = config
self.area = area

self.occupancy_sleep_time_interval = float(self.config.get_section_dict("App")["OccupancyAlertsMinInterval"])
self.log_dir = self.config.get_section_dict("Logger")["LogDirectory"]
self.idle_time = float(self.config.get_section_dict('Logger')['TimeInterval'])
self.area_id = self.area['id']
self.area_name = self.area['name']
self.occupancy_threshold = self.area['occupancy_threshold']
self.should_send_email_notifications = self.area['should_send_email_notifications']
self.should_send_slack_notifications = self.area['should_send_slack_notifications']
self.cameras = [camera for camera in self.config.get_video_sources() if camera['id'] in self.area['cameras']]
for camera in self.cameras:
camera['file_path'] = os.path.join(self.log_dir, camera['id'], "objects_log")
camera['last_processed_time'] = time.time()

self.mail_service = MailService(config)
self.slack_service = SlackService(config)

def process_area(self):
# Sleep for a while so cameras start processing
time.sleep(30)

self.processing_alerts = True
logger.info(f'Enabled processing alerts for - {self.area_id}: {self.area_name} with {len(self.cameras)} cameras')
while self.processing_alerts:
camera_file_paths = [os.path.join(camera['file_path'], str(date.today()) + ".csv") for camera in self.cameras]
if not all(list(map(os.path.isfile, camera_file_paths))):
# Wait before csv for this day are created
logger.info(f'Area reporting on - {self.area_id}: {self.area_name} is waiting for reports to be created')
time.sleep(5)

occupancy = 0
for camera in self.cameras:
with open(os.path.join(camera['file_path'], str(date.today()) + ".csv"), 'r') as log:
last_log = deque(csv.DictReader(log), 1)[0]
log_time = datetime.strptime(last_log['Timestamp'], "%Y-%m-%d %H:%M:%S")
# TODO: If the TimeInterval of the Logger is more than 30 seconds this would have to be revised.
if (datetime.now() - log_time).total_seconds() < 30:
occupancy += int(last_log['DetectedObjects'])
else:
logger.warn(f"Logs aren't being updated for camera {camera['id']} - {camera['name']}")

if occupancy > self.occupancy_threshold:
# Trigger alerts
if self.should_send_email_notifications:
self.mail_service.send_occupancy_notification(self.area, occupancy)
if self.should_send_slack_notifications:
self.slack_service.occupancy_alert(self.area, occupancy)
# Sleep until the cooldown of the alert
time.sleep(self.occupancy_sleep_time_interval)
else:
# Sleep until new data is logged
time.sleep(self.idle_time)

self.stop_process_area()

def stop_process_area(self):
logger.info(f'Disabled processing alerts for area - {self.area_id}: {self.area_name}')
self.processing_alerts = False
40 changes: 40 additions & 0 deletions libs/area_threading.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import os
from threading import Thread
from libs.area_reporting import AreaReporting as AreaEngine
import logging

logger = logging.getLogger(__name__)


def run_area_processing(config, pipe, areas):
pid = os.getpid()
logger.info(f"[{pid}] taking on notifications for {len(areas)} areas")
threads = []
for area in areas:
engine = AreaThread(config, area)
engine.start()
threads.append(engine)

# Wait for a signal to die
pipe.recv()
logger.info(f"[{pid}] will stop area alerts and die")
for t in threads:
t.stop()

logger.info(f"[{pid}] Goodbye!")


class AreaThread(Thread):
def __init__(self, config, area):
Thread.__init__(self)
self.engine = None
self.config = config
self.area = area

def run(self):
self.engine = AreaEngine(self.config, self.area)
self.engine.process_area()

def stop(self):
self.engine.stop_process_area()
self.join()
2 changes: 1 addition & 1 deletion libs/config_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ def get_areas(self):
if 'Cameras' in section and section['Cameras'].strip() != "":
area['cameras'] = section['Cameras'].split(',')

if area['notify_every_minutes'] > 0 and (area['violation_threshold'] > 0 or area['occupancy_threshold'] > 0):
if (area['notify_every_minutes'] > 0 and area['violation_threshold'] > 0) or area['occupancy_threshold'] > 0:
area['should_send_email_notifications'] = 'emails' in area
area['should_send_slack_notifications'] = bool(self.config['App']['SlackChannel'] and
self.config.getboolean('App', 'EnableSlackNotifications'))
Expand Down
4 changes: 2 additions & 2 deletions libs/distancing.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,8 @@ def __init__(self, config, source, live_feed_enabled=True):
logger.info(f"Falling back using {self.default_dist_method}")
self.dist_method = self.default_dist_method

self.screenshot_period = float(
self.config.get_section_dict("App")["ScreenshotPeriod"]) * 60 # config.ini uses minutes as unit
# config.ini uses minutes as unit
self.screenshot_period = float(self.config.get_section_dict("App")["ScreenshotPeriod"]) * 60
self.bucket_screenshots = config.get_section_dict("App")["ScreenshotS3Bucket"]
self.uploader = S3Uploader(self.config)
self.screenshot_path = os.path.join(self.config.get_section_dict("App")["ScreenshotsDirectory"], self.camera_id)
Expand Down
9 changes: 8 additions & 1 deletion libs/notifications/slack_notifications.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,14 @@ def violation_report(self, entity_info, number):
msg = f"We found {number} violations in {entity_id}: {entity_name} ({entity_type})"
self.post_message_to_channel(msg, self.channel)

def daily_report(self, entity_info, number,):
def daily_report(self, entity_info, number):
entity_id, entity_type, entity_name = entity_info['id'], entity_info['type'], entity_info['name']
msg = f"Yesterday we found {number} violations in {entity_id}: {entity_name} ({entity_type})."
self.post_message_to_channel(msg, self.channel)

def occupancy_alert(self, entity_info, number):
entity_id, entity_type = entity_info['id'], entity_info['type']
entity_name, entity_threshold = entity_info['name'], entity_info['occupancy_threshold']
msg = f"Occupancy threshold was exceeded in {entity_type} {entity_id}: {entity_name}." \
f"We found {number} people out of a capacity of {entity_threshold}."
self.post_message_to_channel(msg, self.channel)
28 changes: 23 additions & 5 deletions libs/processor_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from queue import Empty
import schedule
from libs.engine_threading import run_video_processing
from libs.area_threading import run_area_processing
from libs.utils.notifications import run_check_violations

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -63,11 +64,12 @@ def _setup_scheduled_tasks(self):
should_send_slack_notifications = area['should_send_slack_notifications']
if should_send_email_notifications or should_send_slack_notifications:
interval = area['notify_every_minutes']
threshold = area['violation_threshold']
schedule.every(interval).minutes.do(
run_check_violations, threshold, self.config, area, interval,
should_send_email_notifications, should_send_slack_notifications
).tag("notification-task")
violation_threshold = area['violation_threshold']
if violation_threshold > 0:
schedule.every(interval).minutes.do(
run_check_violations, violation_threshold, self.config, area, interval,
should_send_email_notifications, should_send_slack_notifications
).tag("notification-task")
else:
logger.info(f"should not send notification for camera {area['id']}")

Expand Down Expand Up @@ -131,6 +133,22 @@ def _start_processing(self):
p = mp.Process(target=run_video_processing, args=(self.config, recv_conn, p_src))
p.start()
engines.append((send_conn, p))

# Set up occupancy alerts
areas_to_notify = [
area for area in self.config.get_areas() if area['occupancy_threshold'] > 0 and area['cameras'] and (
area['should_send_email_notifications'] or area['should_send_slack_notifications']
)
]
if areas_to_notify and float(self.config.get_section_dict('App')['OccupancyAlertsMinInterval']) >= 0:
logger.info(f'Spinning up area alert threads for {len(areas_to_notify)} areas')
recv_conn, send_conn = mp.Pipe(False)
p = mp.Process(target=run_area_processing, args=(self.config, recv_conn, areas_to_notify))
p.start()
engines.append((send_conn, p))
else:
logger.info('Area occupancy alerts are disabled for all areas')

self._engines = engines

def _stop_processing(self):
Expand Down
65 changes: 65 additions & 0 deletions libs/utils/mail_occupancy_notification.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<!DOCTYPE html>

<head>

</head>

<body>
<table border="0" cellpadding="0" cellspacing="0" width="100%"
style="min-width:100%;border-collapse:collapse;font-family: WorkSans-Regular_;">
<tbody>
<tr>
<td valign="top" style="padding:9px">
<table align="left" width="100%" border="0" cellpadding="0" cellspacing="0"
style="min-width:100%;border-collapse:collapse">
<tbody>
<tr>
<td valign="top"
style="padding-right:9px;padding-left:9px;padding-top:0;padding-bottom:0">
<img align="left" alt=""
src="https://uploads-ssl.webflow.com/5f242545e7c52d78d56665aa/5f513656ab6ea7fb684aba6d_logo-horizontal%402x.png"
width="208.76000000000002"
style="max-width:614px;padding-bottom:0;display:inline!important;vertical-align:bottom;border:0;height:auto;outline:none;text-decoration:none"
class="m_5283153553260840980mcnRetinaImage CToWUd">
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<table border="0" cellpadding="0" cellspacing="0" width="100%"
style="min-width:100%;border-collapse:collapse">
<tbody>
<tr>
<td valign="top" style="padding-top:9px">
<table align="left" border="0" cellpadding="0" cellspacing="0"
style="max-width:100%;min-width:100%;border-collapse:collapse" width="100%"
class="m_5283153553260840980mcnTextContentContainer">
<tbody>
<tr>
<td valign="top" class="m_5283153553260840980mcnTextContent"
style="padding: 0 18px 9px;word-break:break-word;color:#080e2a;font-family:Helvetica;font-size:16px;line-height:150%;text-align:left">
<h1 style="display:block;margin:0;padding:0;color:#080e2a;font-size:26px;line-height:125%;letter-spacing:normal;text-align:left">
Occupancy threshold was exceeded in <strong>{entity_type} {entity_id}: {entity_name}</strong>.
</h1>
<h3 style="display:block;margin:0;padding:0;color:#080e2a;font-size:26px;line-height:125%;letter-spacing:normal;text-align:left">
We found <strong>{num_occupancy}</strong> people out of a capacity of <strong>{entity_threshold}</strong>.
</h3>
</td>
</tr>
<tr>
<td valign="top" class="m_5283153553260840980mcnTextContent"
style="padding: 0 18px 9px;font-family:Helvetica;color:#080e2a;font-size: 20px;font-weight: normal">
<p>You can see more information or adjust this settings on:</p>
<a href="{url}" style="text-decoration: #080e2a underline;cursor: pointer">{url}</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</body>
15 changes: 15 additions & 0 deletions libs/utils/mailing.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,18 @@ def send_daily_report(self, entity_info, num_violations, hours_sumary):
html_string = html_string.replace('{url}', f'{frontend_url}/dashboard?source=email')
subject = f"[Lanthorn] Daily Report on {entity_type}: {entity_info['name']}"
self.send_email_notification(entity_info, subject, html_string)

def send_occupancy_notification(self, entity_info, num_occupancy):
entity_id, entity_type = entity_info['id'], entity_info['type']
entity_name, entity_threshold = entity_info['name'], entity_info['occupancy_threshold']
frontend_url = self.config.get_section_dict("App")["DashboardURL"]
with codecs.open('libs/utils/mail_occupancy_notification.html', 'r') as f:
html_string = f.read()
html_string = html_string.replace('{num_occupancy}', str(num_occupancy))
html_string = html_string.replace('{entity_id}', entity_id)
html_string = html_string.replace('{entity_type}', entity_type)
html_string = html_string.replace('{entity_name}', entity_name)
html_string = html_string.replace('{entity_threshold}', str(entity_threshold))
html_string = html_string.replace('{url}', f'{frontend_url}/dashboard?source=email')
subject = f"[Lanthorn] Occupancy Alert on {entity_name} ({entity_type})"
self.send_email_notification(entity_info, subject, html_string)
3 changes: 2 additions & 1 deletion libs/utils/notifications.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,5 +48,6 @@ def check_violations(entity_type, threshold, config, entity_info, interval, shou

def run_check_violations(threshold, config, entity_info, interval, should_send_email, should_send_slack):
entity_type = entity_info['type']
job_thread = Thread(target=check_violations, args=[entity_type, threshold, config, entity_info, interval, should_send_email, should_send_slack])
job_thread = Thread(target=check_violations,
args=[entity_type, threshold, config, entity_info, interval, should_send_email, should_send_slack])
job_thread.start()

0 comments on commit e20c095

Please sign in to comment.