object_processing.py 21 KB

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