object_processing.py 21 KB

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