object_processing.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540
  1. import copy
  2. import base64
  3. import datetime
  4. import hashlib
  5. import itertools
  6. import json
  7. import logging
  8. import os
  9. import queue
  10. import threading
  11. import time
  12. from collections import Counter, defaultdict
  13. from statistics import mean, median
  14. from typing import Callable, Dict
  15. import cv2
  16. import matplotlib.pyplot as plt
  17. import numpy as np
  18. from frigate.config import FrigateConfig, CameraConfig
  19. from frigate.const import RECORD_DIR, CLIPS_DIR, CACHE_DIR
  20. from frigate.edgetpu import load_labels
  21. from frigate.util import SharedMemoryFrameManager, draw_box_with_label, calculate_region
  22. logger = logging.getLogger(__name__)
  23. PATH_TO_LABELS = '/labelmap.txt'
  24. LABELS = load_labels(PATH_TO_LABELS)
  25. cmap = plt.cm.get_cmap('tab10', len(LABELS.keys()))
  26. COLOR_MAP = {}
  27. for key, val in LABELS.items():
  28. COLOR_MAP[val] = tuple(int(round(255 * c)) for c in cmap(key)[:3])
  29. def on_edge(box, frame_shape):
  30. if (
  31. box[0] == 0 or
  32. box[1] == 0 or
  33. box[2] == frame_shape[1]-1 or
  34. box[3] == frame_shape[0]-1
  35. ):
  36. return True
  37. def is_better_thumbnail(current_thumb, new_obj, frame_shape) -> bool:
  38. # larger is better
  39. # cutoff images are less ideal, but they should also be smaller?
  40. # better scores are obviously better too
  41. # if the new_thumb is on an edge, and the current thumb is not
  42. if on_edge(new_obj['box'], frame_shape) and not on_edge(current_thumb['box'], frame_shape):
  43. return False
  44. # if the score is better by more than 5%
  45. if new_obj['score'] > current_thumb['score']+.05:
  46. return True
  47. # if the area is 10% larger
  48. if new_obj['area'] > current_thumb['area']*1.1:
  49. return True
  50. return False
  51. class TrackedObject():
  52. def __init__(self, camera, camera_config: CameraConfig, frame_cache, obj_data):
  53. self.obj_data = obj_data
  54. self.camera = camera
  55. self.camera_config = camera_config
  56. self.frame_cache = frame_cache
  57. self.current_zones = []
  58. self.entered_zones = set()
  59. self.false_positive = True
  60. self.top_score = self.computed_score = 0.0
  61. self.thumbnail_data = None
  62. self.frame = None
  63. self.previous = self.to_dict()
  64. # start the score history
  65. self.score_history = [self.obj_data['score']]
  66. def _is_false_positive(self):
  67. # once a true positive, always a true positive
  68. if not self.false_positive:
  69. return False
  70. threshold = self.camera_config.objects.filters[self.obj_data['label']].threshold
  71. if self.computed_score < threshold:
  72. return True
  73. return False
  74. def compute_score(self):
  75. scores = self.score_history[:]
  76. # pad with zeros if you dont have at least 3 scores
  77. if len(scores) < 3:
  78. scores += [0.0]*(3 - len(scores))
  79. return median(scores)
  80. def update(self, current_frame_time, obj_data):
  81. significant_update = False
  82. self.obj_data.update(obj_data)
  83. # if the object is not in the current frame, add a 0.0 to the score history
  84. if self.obj_data['frame_time'] != current_frame_time:
  85. self.score_history.append(0.0)
  86. else:
  87. self.score_history.append(self.obj_data['score'])
  88. # only keep the last 10 scores
  89. if len(self.score_history) > 10:
  90. self.score_history = self.score_history[-10:]
  91. # calculate if this is a false positive
  92. self.computed_score = self.compute_score()
  93. if self.computed_score > self.top_score:
  94. self.top_score = self.computed_score
  95. self.false_positive = self._is_false_positive()
  96. if not self.false_positive:
  97. # determine if this frame is a better thumbnail
  98. if (
  99. self.thumbnail_data is None
  100. or is_better_thumbnail(self.thumbnail_data, self.obj_data, self.camera_config.frame_shape)
  101. ):
  102. self.thumbnail_data = {
  103. 'frame_time': self.obj_data['frame_time'],
  104. 'box': self.obj_data['box'],
  105. 'area': self.obj_data['area'],
  106. 'region': self.obj_data['region'],
  107. 'score': self.obj_data['score']
  108. }
  109. significant_update = True
  110. # check zones
  111. current_zones = []
  112. bottom_center = (self.obj_data['centroid'][0], self.obj_data['box'][3])
  113. # check each zone
  114. for name, zone in self.camera_config.zones.items():
  115. contour = zone.contour
  116. # check if the object is in the zone
  117. if (cv2.pointPolygonTest(contour, bottom_center, False) >= 0):
  118. # if the object passed the filters once, dont apply again
  119. if name in self.current_zones or not zone_filtered(self, zone.filters):
  120. current_zones.append(name)
  121. self.entered_zones.add(name)
  122. # if the zones changed, signal an update
  123. if not self.false_positive and set(self.current_zones) != set(current_zones):
  124. significant_update = True
  125. self.current_zones = current_zones
  126. return significant_update
  127. def to_dict(self, include_thumbnail: bool = False):
  128. return {
  129. 'id': self.obj_data['id'],
  130. 'camera': self.camera,
  131. 'frame_time': self.obj_data['frame_time'],
  132. 'label': self.obj_data['label'],
  133. 'top_score': self.top_score,
  134. 'false_positive': self.false_positive,
  135. 'start_time': self.obj_data['start_time'],
  136. 'end_time': self.obj_data.get('end_time', None),
  137. 'score': self.obj_data['score'],
  138. 'box': self.obj_data['box'],
  139. 'area': self.obj_data['area'],
  140. 'region': self.obj_data['region'],
  141. 'current_zones': self.current_zones.copy(),
  142. 'entered_zones': list(self.entered_zones).copy(),
  143. 'thumbnail': base64.b64encode(self.get_thumbnail()).decode('utf-8') if include_thumbnail else None
  144. }
  145. if not self.thumbnail_data['frame_time'] in self.frame_cache:
  146. logger.error(f"Unable to create thumbnail for {self.obj_data['id']}")
  147. logger.error(f"Looking for frame_time of {self.thumbnail_data['frame_time']}")
  148. logger.error(f"Thumbnail frames: {','.join([str(k) for k in self.frame_cache.keys()])}")
  149. ret, jpg = cv2.imencode('.jpg', np.zeros((175,175,3), np.uint8))
  150. jpg_bytes = self.get_jpg_bytes(timestamp=False, bounding_box=False, crop=True, height=175)
  151. if jpg_bytes:
  152. return jpg_bytes
  153. else:
  154. ret, jpg = cv2.imencode('.jpg', np.zeros((175,175,3), np.uint8))
  155. return jpg.tobytes()
  156. def get_jpg_bytes(self, timestamp=False, bounding_box=False, crop=False, height=None):
  157. best_frame = cv2.cvtColor(self.frame_cache[self.thumbnail_data['frame_time']], cv2.COLOR_YUV2BGR_I420)
  158. if bounding_box:
  159. thickness = 2
  160. color = COLOR_MAP[self.obj_data['label']]
  161. # draw the bounding boxes on the frame
  162. box = self.thumbnail_data['box']
  163. draw_box_with_label(best_frame, box[0], box[1], box[2], box[3], self.obj_data['label'], f"{int(self.thumbnail_data['score']*100)}% {int(self.thumbnail_data['area'])}", thickness=thickness, color=color)
  164. if crop:
  165. box = self.thumbnail_data['box']
  166. region = calculate_region(best_frame.shape, box[0], box[1], box[2], box[3], 1.1)
  167. best_frame = best_frame[region[1]:region[3], region[0]:region[2]]
  168. if height:
  169. width = int(height*best_frame.shape[1]/best_frame.shape[0])
  170. best_frame = cv2.resize(best_frame, dsize=(width, height), interpolation=cv2.INTER_AREA)
  171. if timestamp:
  172. time_to_show = datetime.datetime.fromtimestamp(self.thumbnail_data['frame_time']).strftime("%m/%d/%Y %H:%M:%S")
  173. size = cv2.getTextSize(time_to_show, cv2.FONT_HERSHEY_SIMPLEX, fontScale=1, thickness=2)
  174. text_width = size[0][0]
  175. desired_size = max(150, 0.33*best_frame.shape[1])
  176. font_scale = desired_size/text_width
  177. cv2.putText(best_frame, time_to_show, (5, best_frame.shape[0]-7), cv2.FONT_HERSHEY_SIMPLEX,
  178. fontScale=font_scale, color=(255, 255, 255), thickness=2)
  179. ret, jpg = cv2.imencode('.jpg', best_frame)
  180. if ret:
  181. return jpg.tobytes()
  182. else:
  183. return None
  184. def zone_filtered(obj: TrackedObject, object_config):
  185. object_name = obj.obj_data['label']
  186. if object_name in object_config:
  187. obj_settings = object_config[object_name]
  188. # if the min area is larger than the
  189. # detected object, don't add it to detected objects
  190. if obj_settings.min_area > obj.obj_data['area']:
  191. return True
  192. # if the detected object is larger than the
  193. # max area, don't add it to detected objects
  194. if obj_settings.max_area < obj.obj_data['area']:
  195. return True
  196. # if the score is lower than the threshold, skip
  197. if obj_settings.threshold > obj.computed_score:
  198. return True
  199. return False
  200. # Maintains the state of a camera
  201. class CameraState():
  202. def __init__(self, name, config, frame_manager):
  203. self.name = name
  204. self.config = config
  205. self.camera_config = config.cameras[name]
  206. self.frame_manager = frame_manager
  207. self.best_objects: Dict[str, TrackedObject] = {}
  208. self.object_counts = defaultdict(lambda: 0)
  209. self.tracked_objects: Dict[str, TrackedObject] = {}
  210. self.frame_cache = {}
  211. self.zone_objects = defaultdict(lambda: [])
  212. self._current_frame = np.zeros(self.camera_config.frame_shape_yuv, np.uint8)
  213. self.current_frame_lock = threading.Lock()
  214. self.current_frame_time = 0.0
  215. self.previous_frame_id = None
  216. self.callbacks = defaultdict(lambda: [])
  217. def get_current_frame(self, draw_options={}):
  218. with self.current_frame_lock:
  219. frame_copy = np.copy(self._current_frame)
  220. frame_time = self.current_frame_time
  221. tracked_objects = {k: v.to_dict() for k,v in self.tracked_objects.items()}
  222. motion_boxes = self.motion_boxes.copy()
  223. regions = self.regions.copy()
  224. frame_copy = cv2.cvtColor(frame_copy, cv2.COLOR_YUV2BGR_I420)
  225. # draw on the frame
  226. if draw_options.get('bounding_boxes'):
  227. # draw the bounding boxes on the frame
  228. for obj in tracked_objects.values():
  229. thickness = 2
  230. color = COLOR_MAP[obj['label']]
  231. if obj['frame_time'] != frame_time:
  232. thickness = 1
  233. color = (255,0,0)
  234. # draw the bounding boxes on the frame
  235. box = obj['box']
  236. draw_box_with_label(frame_copy, box[0], box[1], box[2], box[3], obj['label'], f"{int(obj['score']*100)}% {int(obj['area'])}", thickness=thickness, color=color)
  237. if draw_options.get('regions'):
  238. for region in regions:
  239. cv2.rectangle(frame_copy, (region[0], region[1]), (region[2], region[3]), (0,255,0), 2)
  240. if draw_options.get('zones'):
  241. for name, zone in self.camera_config.zones.items():
  242. thickness = 8 if any([name in obj['current_zones'] for obj in tracked_objects.values()]) else 2
  243. cv2.drawContours(frame_copy, [zone.contour], -1, zone.color, thickness)
  244. if draw_options.get('mask'):
  245. mask_overlay = np.where(self.camera_config.mask==[0])
  246. frame_copy[mask_overlay] = [0,0,0]
  247. if draw_options.get('motion_boxes'):
  248. for m_box in motion_boxes:
  249. cv2.rectangle(frame_copy, (m_box[0], m_box[1]), (m_box[2], m_box[3]), (0,0,255), 2)
  250. if draw_options.get('timestamp'):
  251. time_to_show = datetime.datetime.fromtimestamp(frame_time).strftime("%m/%d/%Y %H:%M:%S")
  252. cv2.putText(frame_copy, time_to_show, (10, 30), cv2.FONT_HERSHEY_SIMPLEX, fontScale=.8, color=(255, 255, 255), thickness=2)
  253. return frame_copy
  254. def finished(self, obj_id):
  255. del self.tracked_objects[obj_id]
  256. def on(self, event_type: str, callback: Callable[[Dict], None]):
  257. self.callbacks[event_type].append(callback)
  258. def update(self, frame_time, current_detections, motion_boxes, regions):
  259. self.current_frame_time = frame_time
  260. self.motion_boxes = motion_boxes
  261. self.regions = regions
  262. # get the new frame
  263. frame_id = f"{self.name}{frame_time}"
  264. current_frame = self.frame_manager.get(frame_id, self.camera_config.frame_shape_yuv)
  265. current_ids = current_detections.keys()
  266. previous_ids = self.tracked_objects.keys()
  267. removed_ids = list(set(previous_ids).difference(current_ids))
  268. new_ids = list(set(current_ids).difference(previous_ids))
  269. updated_ids = list(set(current_ids).intersection(previous_ids))
  270. for id in new_ids:
  271. new_obj = self.tracked_objects[id] = TrackedObject(self.name, self.camera_config, self.frame_cache, current_detections[id])
  272. # call event handlers
  273. for c in self.callbacks['start']:
  274. c(self.name, new_obj, frame_time)
  275. for id in updated_ids:
  276. updated_obj = self.tracked_objects[id]
  277. significant_update = updated_obj.update(frame_time, current_detections[id])
  278. if significant_update:
  279. # ensure this frame is stored in the cache
  280. if updated_obj.thumbnail_data['frame_time'] == frame_time and frame_time not in self.frame_cache:
  281. self.frame_cache[frame_time] = np.copy(current_frame)
  282. # call event handlers
  283. for c in self.callbacks['update']:
  284. c(self.name, updated_obj, frame_time)
  285. for id in removed_ids:
  286. # publish events to mqtt
  287. removed_obj = self.tracked_objects[id]
  288. if not 'end_time' in removed_obj.obj_data:
  289. removed_obj.obj_data['end_time'] = frame_time
  290. for c in self.callbacks['end']:
  291. c(self.name, removed_obj, frame_time)
  292. # TODO: can i switch to looking this up and only changing when an event ends?
  293. # maintain best objects
  294. for obj in self.tracked_objects.values():
  295. object_type = obj.obj_data['label']
  296. # if the object's thumbnail is not from the current frame
  297. if obj.false_positive or obj.thumbnail_data['frame_time'] != self.current_frame_time:
  298. continue
  299. if object_type in self.best_objects:
  300. current_best = self.best_objects[object_type]
  301. now = datetime.datetime.now().timestamp()
  302. # if the object is a higher score than the current best score
  303. # or the current object is older than desired, use the new object
  304. if (is_better_thumbnail(current_best.thumbnail_data, obj.thumbnail_data, self.camera_config.frame_shape)
  305. or (now - current_best.thumbnail_data['frame_time']) > self.camera_config.best_image_timeout):
  306. self.best_objects[object_type] = obj
  307. for c in self.callbacks['snapshot']:
  308. c(self.name, self.best_objects[object_type], frame_time)
  309. else:
  310. self.best_objects[object_type] = obj
  311. for c in self.callbacks['snapshot']:
  312. c(self.name, self.best_objects[object_type], frame_time)
  313. # update overall camera state for each object type
  314. obj_counter = Counter()
  315. for obj in self.tracked_objects.values():
  316. if not obj.false_positive:
  317. obj_counter[obj.obj_data['label']] += 1
  318. # report on detected objects
  319. for obj_name, count in obj_counter.items():
  320. if count != self.object_counts[obj_name]:
  321. self.object_counts[obj_name] = count
  322. for c in self.callbacks['object_status']:
  323. c(self.name, obj_name, count)
  324. # expire any objects that are >0 and no longer detected
  325. expired_objects = [obj_name for obj_name, count in self.object_counts.items() if count > 0 and not obj_name in obj_counter]
  326. for obj_name in expired_objects:
  327. self.object_counts[obj_name] = 0
  328. for c in self.callbacks['object_status']:
  329. c(self.name, obj_name, 0)
  330. for c in self.callbacks['snapshot']:
  331. c(self.name, self.best_objects[obj_name], frame_time)
  332. # cleanup thumbnail frame cache
  333. current_thumb_frames = set([obj.thumbnail_data['frame_time'] for obj in self.tracked_objects.values() if not obj.false_positive])
  334. current_best_frames = set([obj.thumbnail_data['frame_time'] for obj in self.best_objects.values()])
  335. thumb_frames_to_delete = [t for t in self.frame_cache.keys() if not t in current_thumb_frames and not t in current_best_frames]
  336. for t in thumb_frames_to_delete:
  337. del self.frame_cache[t]
  338. with self.current_frame_lock:
  339. self._current_frame = current_frame
  340. if not self.previous_frame_id is None:
  341. self.frame_manager.delete(self.previous_frame_id)
  342. self.previous_frame_id = frame_id
  343. class TrackedObjectProcessor(threading.Thread):
  344. def __init__(self, config: FrigateConfig, client, topic_prefix, tracked_objects_queue, event_queue, event_processed_queue, stop_event):
  345. threading.Thread.__init__(self)
  346. self.name = "detected_frames_processor"
  347. self.config = config
  348. self.client = client
  349. self.topic_prefix = topic_prefix
  350. self.tracked_objects_queue = tracked_objects_queue
  351. self.event_queue = event_queue
  352. self.event_processed_queue = event_processed_queue
  353. self.stop_event = stop_event
  354. self.camera_states: Dict[str, CameraState] = {}
  355. self.frame_manager = SharedMemoryFrameManager()
  356. def start(camera, obj: TrackedObject, current_frame_time):
  357. self.event_queue.put(('start', camera, obj.to_dict()))
  358. def update(camera, obj: TrackedObject, current_frame_time):
  359. after = obj.to_dict()
  360. message = { 'before': obj.previous, 'after': after }
  361. self.client.publish(f"{self.topic_prefix}/events", json.dumps(message), retain=False)
  362. obj.previous = after
  363. def end(camera, obj: TrackedObject, current_frame_time):
  364. snapshot_config = self.config.cameras[camera].snapshots
  365. if not obj.false_positive:
  366. message = { 'before': obj.previous, 'after': obj.to_dict() }
  367. self.client.publish(f"{self.topic_prefix}/events", json.dumps(message), retain=False)
  368. # write snapshot to disk if enabled
  369. if snapshot_config.enabled:
  370. jpg_bytes = obj.get_jpg_bytes(
  371. timestamp=snapshot_config.timestamp,
  372. bounding_box=snapshot_config.bounding_box,
  373. crop=snapshot_config.crop,
  374. height=snapshot_config.height
  375. )
  376. with open(os.path.join(CLIPS_DIR, f"{camera}-{obj.obj_data['id']}.jpg"), 'wb') as j:
  377. j.write(jpg_bytes)
  378. self.event_queue.put(('end', camera, obj.to_dict(include_thumbnail=True)))
  379. def snapshot(camera, obj: TrackedObject, current_frame_time):
  380. mqtt_config = self.config.cameras[camera].mqtt
  381. if mqtt_config.enabled:
  382. jpg_bytes = obj.get_jpg_bytes(
  383. timestamp=mqtt_config.timestamp,
  384. bounding_box=mqtt_config.bounding_box,
  385. crop=mqtt_config.crop,
  386. height=mqtt_config.height
  387. )
  388. self.client.publish(f"{self.topic_prefix}/{camera}/{obj.obj_data['label']}/snapshot", jpg_bytes, retain=True)
  389. def object_status(camera, object_name, status):
  390. self.client.publish(f"{self.topic_prefix}/{camera}/{object_name}", status, retain=False)
  391. for camera in self.config.cameras.keys():
  392. camera_state = CameraState(camera, self.config, self.frame_manager)
  393. camera_state.on('start', start)
  394. camera_state.on('update', update)
  395. camera_state.on('end', end)
  396. camera_state.on('snapshot', snapshot)
  397. camera_state.on('object_status', object_status)
  398. self.camera_states[camera] = camera_state
  399. # {
  400. # 'zone_name': {
  401. # 'person': {
  402. # 'camera_1': 2,
  403. # 'camera_2': 1
  404. # }
  405. # }
  406. # }
  407. self.zone_data = defaultdict(lambda: defaultdict(lambda: {}))
  408. def get_best(self, camera, label):
  409. # TODO: need a lock here
  410. camera_state = self.camera_states[camera]
  411. if label in camera_state.best_objects:
  412. best_obj = camera_state.best_objects[label]
  413. best = best_obj.thumbnail_data.copy()
  414. best['frame'] = camera_state.frame_cache.get(best_obj.thumbnail_data['frame_time'])
  415. return best
  416. else:
  417. return {}
  418. def get_current_frame(self, camera, draw_options={}):
  419. return self.camera_states[camera].get_current_frame(draw_options)
  420. def run(self):
  421. while True:
  422. if self.stop_event.is_set():
  423. logger.info(f"Exiting object processor...")
  424. break
  425. try:
  426. camera, frame_time, current_tracked_objects, motion_boxes, regions = self.tracked_objects_queue.get(True, 10)
  427. except queue.Empty:
  428. continue
  429. camera_state = self.camera_states[camera]
  430. camera_state.update(frame_time, current_tracked_objects, motion_boxes, regions)
  431. # update zone counts for each label
  432. # for each zone in the current camera
  433. for zone in self.config.cameras[camera].zones.keys():
  434. # count labels for the camera in the zone
  435. obj_counter = Counter()
  436. for obj in camera_state.tracked_objects.values():
  437. if zone in obj.current_zones and not obj.false_positive:
  438. obj_counter[obj.obj_data['label']] += 1
  439. # update counts and publish status
  440. for label in set(list(self.zone_data[zone].keys()) + list(obj_counter.keys())):
  441. # if we have previously published a count for this zone/label
  442. zone_label = self.zone_data[zone][label]
  443. if camera in zone_label:
  444. current_count = sum(zone_label.values())
  445. zone_label[camera] = obj_counter[label] if label in obj_counter else 0
  446. new_count = sum(zone_label.values())
  447. if new_count != current_count:
  448. self.client.publish(f"{self.topic_prefix}/{zone}/{label}", new_count, retain=False)
  449. # if this is a new zone/label combo for this camera
  450. else:
  451. if label in obj_counter:
  452. zone_label[camera] = obj_counter[label]
  453. self.client.publish(f"{self.topic_prefix}/{zone}/{label}", obj_counter[label], retain=False)
  454. # cleanup event finished queue
  455. while not self.event_processed_queue.empty():
  456. event_id, camera = self.event_processed_queue.get()
  457. self.camera_states[camera].finished(event_id)