object_processing.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400
  1. import json
  2. import hashlib
  3. import datetime
  4. import time
  5. import copy
  6. import cv2
  7. import threading
  8. import queue
  9. import copy
  10. import numpy as np
  11. from collections import Counter, defaultdict
  12. import itertools
  13. import matplotlib.pyplot as plt
  14. from frigate.util import draw_box_with_label, SharedMemoryFrameManager
  15. from frigate.edgetpu import load_labels
  16. from frigate.config import CameraConfig
  17. from typing import Callable, Dict
  18. from statistics import mean, median
  19. PATH_TO_LABELS = '/labelmap.txt'
  20. LABELS = load_labels(PATH_TO_LABELS)
  21. cmap = plt.cm.get_cmap('tab10', len(LABELS.keys()))
  22. COLOR_MAP = {}
  23. for key, val in LABELS.items():
  24. COLOR_MAP[val] = tuple(int(round(255 * c)) for c in cmap(key)[:3])
  25. def zone_filtered(obj, object_config):
  26. object_name = obj['label']
  27. if object_name in object_config:
  28. obj_settings = object_config[object_name]
  29. # if the min area is larger than the
  30. # detected object, don't add it to detected objects
  31. if obj_settings.min_area > obj['area']:
  32. return True
  33. # if the detected object is larger than the
  34. # max area, don't add it to detected objects
  35. if obj_settings.max_area < obj['area']:
  36. return True
  37. # if the score is lower than the threshold, skip
  38. if obj_settings.threshold > obj['computed_score']:
  39. return True
  40. return False
  41. # Maintains the state of a camera
  42. class CameraState():
  43. def __init__(self, name, config, frame_manager):
  44. self.name = name
  45. self.config = config
  46. self.frame_manager = frame_manager
  47. self.best_objects = {}
  48. self.object_status = defaultdict(lambda: 'OFF')
  49. self.tracked_objects = {}
  50. self.zone_objects = defaultdict(lambda: [])
  51. self._current_frame = np.zeros(self.config.frame_shape_yuv, np.uint8)
  52. self.current_frame_lock = threading.Lock()
  53. self.current_frame_time = 0.0
  54. self.previous_frame_id = None
  55. self.callbacks = defaultdict(lambda: [])
  56. def get_current_frame(self, draw=False):
  57. with self.current_frame_lock:
  58. frame_copy = np.copy(self._current_frame)
  59. frame_time = self.current_frame_time
  60. tracked_objects = copy.deepcopy(self.tracked_objects)
  61. frame_copy = cv2.cvtColor(frame_copy, cv2.COLOR_YUV2BGR_I420)
  62. # draw on the frame
  63. if draw:
  64. # draw the bounding boxes on the frame
  65. for obj in tracked_objects.values():
  66. thickness = 2
  67. color = COLOR_MAP[obj['label']]
  68. if obj['frame_time'] != frame_time:
  69. thickness = 1
  70. color = (255,0,0)
  71. # draw the bounding boxes on the frame
  72. box = obj['box']
  73. 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)
  74. # draw the regions on the frame
  75. region = obj['region']
  76. cv2.rectangle(frame_copy, (region[0], region[1]), (region[2], region[3]), (0,255,0), 1)
  77. if self.config.snapshots.show_timestamp:
  78. time_to_show = datetime.datetime.fromtimestamp(frame_time).strftime("%m/%d/%Y %H:%M:%S")
  79. cv2.putText(frame_copy, time_to_show, (10, 30), cv2.FONT_HERSHEY_SIMPLEX, fontScale=.8, color=(255, 255, 255), thickness=2)
  80. if self.config.snapshots.draw_zones:
  81. for name, zone in self.config.zones.items():
  82. thickness = 8 if any([name in obj['zones'] for obj in tracked_objects.values()]) else 2
  83. cv2.drawContours(frame_copy, [zone.contour], -1, zone.color, thickness)
  84. return frame_copy
  85. def false_positive(self, obj):
  86. # once a true positive, always a true positive
  87. if not obj.get('false_positive', True):
  88. return False
  89. threshold = self.config.objects.filters[obj['label']].threshold
  90. if obj['computed_score'] < threshold:
  91. return True
  92. return False
  93. def compute_score(self, obj):
  94. scores = obj['score_history'][:]
  95. # pad with zeros if you dont have at least 3 scores
  96. if len(scores) < 3:
  97. scores += [0.0]*(3 - len(scores))
  98. return median(scores)
  99. def on(self, event_type: str, callback: Callable[[Dict], None]):
  100. self.callbacks[event_type].append(callback)
  101. def update(self, frame_time, tracked_objects):
  102. self.current_frame_time = frame_time
  103. # get the new frame and delete the old frame
  104. frame_id = f"{self.name}{frame_time}"
  105. current_frame = self.frame_manager.get(frame_id, self.config.frame_shape_yuv)
  106. current_ids = tracked_objects.keys()
  107. previous_ids = self.tracked_objects.keys()
  108. removed_ids = list(set(previous_ids).difference(current_ids))
  109. new_ids = list(set(current_ids).difference(previous_ids))
  110. updated_ids = list(set(current_ids).intersection(previous_ids))
  111. for id in new_ids:
  112. self.tracked_objects[id] = tracked_objects[id]
  113. self.tracked_objects[id]['zones'] = []
  114. self.tracked_objects[id]['entered_zones'] = set()
  115. # start the score history
  116. self.tracked_objects[id]['score_history'] = [self.tracked_objects[id]['score']]
  117. # calculate if this is a false positive
  118. self.tracked_objects[id]['computed_score'] = self.compute_score(self.tracked_objects[id])
  119. self.tracked_objects[id]['top_score'] = self.tracked_objects[id]['computed_score']
  120. self.tracked_objects[id]['false_positive'] = self.false_positive(self.tracked_objects[id])
  121. # call event handlers
  122. for c in self.callbacks['start']:
  123. c(self.name, tracked_objects[id])
  124. for id in updated_ids:
  125. self.tracked_objects[id].update(tracked_objects[id])
  126. # if the object is not in the current frame, add a 0.0 to the score history
  127. if self.tracked_objects[id]['frame_time'] != self.current_frame_time:
  128. self.tracked_objects[id]['score_history'].append(0.0)
  129. else:
  130. self.tracked_objects[id]['score_history'].append(self.tracked_objects[id]['score'])
  131. # only keep the last 10 scores
  132. if len(self.tracked_objects[id]['score_history']) > 10:
  133. self.tracked_objects[id]['score_history'] = self.tracked_objects[id]['score_history'][-10:]
  134. # calculate if this is a false positive
  135. computed_score = self.compute_score(self.tracked_objects[id])
  136. self.tracked_objects[id]['computed_score'] = computed_score
  137. if computed_score > self.tracked_objects[id]['top_score']:
  138. self.tracked_objects[id]['top_score'] = computed_score
  139. self.tracked_objects[id]['false_positive'] = self.false_positive(self.tracked_objects[id])
  140. # call event handlers
  141. for c in self.callbacks['update']:
  142. c(self.name, self.tracked_objects[id])
  143. for id in removed_ids:
  144. # publish events to mqtt
  145. self.tracked_objects[id]['end_time'] = frame_time
  146. for c in self.callbacks['end']:
  147. c(self.name, self.tracked_objects[id])
  148. del self.tracked_objects[id]
  149. # check to see if the objects are in any zones
  150. for obj in self.tracked_objects.values():
  151. current_zones = []
  152. bottom_center = (obj['centroid'][0], obj['box'][3])
  153. # check each zone
  154. for name, zone in self.config.zones.items():
  155. contour = zone.contour
  156. # check if the object is in the zone
  157. if (cv2.pointPolygonTest(contour, bottom_center, False) >= 0):
  158. # if the object passed the filters once, dont apply again
  159. if name in obj.get('zones', []) or not zone_filtered(obj, zone.filters):
  160. current_zones.append(name)
  161. obj['entered_zones'].add(name)
  162. obj['zones'] = current_zones
  163. # maintain best objects
  164. for obj in self.tracked_objects.values():
  165. object_type = obj['label']
  166. # if the object wasn't seen on the current frame, skip it
  167. if obj['frame_time'] != self.current_frame_time or obj['false_positive']:
  168. continue
  169. obj_copy = copy.deepcopy(obj)
  170. if object_type in self.best_objects:
  171. current_best = self.best_objects[object_type]
  172. now = datetime.datetime.now().timestamp()
  173. # if the object is a higher score than the current best score
  174. # or the current object is older than desired, use the new object
  175. if obj_copy['score'] > current_best['score'] or (now - current_best['frame_time']) > self.config.best_image_timeout:
  176. obj_copy['frame'] = np.copy(current_frame)
  177. self.best_objects[object_type] = obj_copy
  178. for c in self.callbacks['snapshot']:
  179. c(self.name, self.best_objects[object_type])
  180. else:
  181. obj_copy['frame'] = np.copy(current_frame)
  182. self.best_objects[object_type] = obj_copy
  183. for c in self.callbacks['snapshot']:
  184. c(self.name, self.best_objects[object_type])
  185. # update overall camera state for each object type
  186. obj_counter = Counter()
  187. for obj in self.tracked_objects.values():
  188. if not obj['false_positive']:
  189. obj_counter[obj['label']] += 1
  190. # report on detected objects
  191. for obj_name, count in obj_counter.items():
  192. new_status = 'ON' if count > 0 else 'OFF'
  193. if new_status != self.object_status[obj_name]:
  194. self.object_status[obj_name] = new_status
  195. for c in self.callbacks['object_status']:
  196. c(self.name, obj_name, new_status)
  197. # expire any objects that are ON and no longer detected
  198. expired_objects = [obj_name for obj_name, status in self.object_status.items() if status == 'ON' and not obj_name in obj_counter]
  199. for obj_name in expired_objects:
  200. self.object_status[obj_name] = 'OFF'
  201. for c in self.callbacks['object_status']:
  202. c(self.name, obj_name, 'OFF')
  203. for c in self.callbacks['snapshot']:
  204. c(self.name, self.best_objects[obj_name])
  205. with self.current_frame_lock:
  206. self._current_frame = current_frame
  207. if not self.previous_frame_id is None:
  208. self.frame_manager.delete(self.previous_frame_id)
  209. self.previous_frame_id = frame_id
  210. class TrackedObjectProcessor(threading.Thread):
  211. def __init__(self, camera_config: Dict[str, CameraConfig], client, topic_prefix, tracked_objects_queue, event_queue, stop_event):
  212. threading.Thread.__init__(self)
  213. self.camera_config = camera_config
  214. self.client = client
  215. self.topic_prefix = topic_prefix
  216. self.tracked_objects_queue = tracked_objects_queue
  217. self.event_queue = event_queue
  218. self.stop_event = stop_event
  219. self.camera_states: Dict[str, CameraState] = {}
  220. self.frame_manager = SharedMemoryFrameManager()
  221. def start(camera, obj):
  222. # publish events to mqtt
  223. event_data = {
  224. 'id': obj['id'],
  225. 'label': obj['label'],
  226. 'camera': camera,
  227. 'start_time': obj['start_time'],
  228. 'top_score': obj['top_score'],
  229. 'false_positive': obj['false_positive'],
  230. 'zones': list(obj['entered_zones'])
  231. }
  232. self.client.publish(f"{self.topic_prefix}/{camera}/events/start", json.dumps(event_data), retain=False)
  233. self.event_queue.put(('start', camera, obj))
  234. def update(camera, obj):
  235. pass
  236. def end(camera, obj):
  237. event_data = {
  238. 'id': obj['id'],
  239. 'label': obj['label'],
  240. 'camera': camera,
  241. 'start_time': obj['start_time'],
  242. 'end_time': obj['end_time'],
  243. 'top_score': obj['top_score'],
  244. 'false_positive': obj['false_positive'],
  245. 'zones': list(obj['entered_zones'])
  246. }
  247. self.client.publish(f"{self.topic_prefix}/{camera}/events/end", json.dumps(event_data), retain=False)
  248. self.event_queue.put(('end', camera, obj))
  249. def snapshot(camera, obj):
  250. if not 'frame' in obj:
  251. return
  252. best_frame = cv2.cvtColor(obj['frame'], cv2.COLOR_YUV2BGR_I420)
  253. if self.camera_config[camera].snapshots.draw_bounding_boxes:
  254. thickness = 2
  255. color = COLOR_MAP[obj['label']]
  256. box = obj['box']
  257. draw_box_with_label(best_frame, box[0], box[1], box[2], box[3], obj['label'], f"{int(obj['score']*100)}% {int(obj['area'])}", thickness=thickness, color=color)
  258. mqtt_config = self.camera_config[camera].mqtt
  259. if mqtt_config.crop_to_region:
  260. region = obj['region']
  261. best_frame = best_frame[region[1]:region[3], region[0]:region[2]]
  262. if mqtt_config.snapshot_height:
  263. height = mqtt_config.snapshot_height
  264. width = int(height*best_frame.shape[1]/best_frame.shape[0])
  265. best_frame = cv2.resize(best_frame, dsize=(width, height), interpolation=cv2.INTER_AREA)
  266. if self.camera_config[camera].snapshots.show_timestamp:
  267. time_to_show = datetime.datetime.fromtimestamp(obj['frame_time']).strftime("%m/%d/%Y %H:%M:%S")
  268. size = cv2.getTextSize(time_to_show, cv2.FONT_HERSHEY_SIMPLEX, fontScale=1, thickness=2)
  269. text_width = size[0][0]
  270. text_height = size[0][1]
  271. desired_size = max(200, 0.33*best_frame.shape[1])
  272. font_scale = desired_size/text_width
  273. cv2.putText(best_frame, time_to_show, (5, best_frame.shape[0]-7), cv2.FONT_HERSHEY_SIMPLEX, fontScale=font_scale, color=(255, 255, 255), thickness=2)
  274. ret, jpg = cv2.imencode('.jpg', best_frame)
  275. if ret:
  276. jpg_bytes = jpg.tobytes()
  277. self.client.publish(f"{self.topic_prefix}/{camera}/{obj['label']}/snapshot", jpg_bytes, retain=True)
  278. def object_status(camera, object_name, status):
  279. self.client.publish(f"{self.topic_prefix}/{camera}/{object_name}", status, retain=False)
  280. for camera in self.camera_config.keys():
  281. camera_state = CameraState(camera, self.camera_config[camera], self.frame_manager)
  282. camera_state.on('start', start)
  283. camera_state.on('update', update)
  284. camera_state.on('end', end)
  285. camera_state.on('snapshot', snapshot)
  286. camera_state.on('object_status', object_status)
  287. self.camera_states[camera] = camera_state
  288. self.camera_data = defaultdict(lambda: {
  289. 'best_objects': {},
  290. 'object_status': defaultdict(lambda: defaultdict(lambda: 'OFF')),
  291. 'tracked_objects': {},
  292. 'current_frame': np.zeros((720,1280,3), np.uint8),
  293. 'current_frame_time': 0.0,
  294. 'object_id': None
  295. })
  296. # {
  297. # 'zone_name': {
  298. # 'person': ['camera_1', 'camera_2']
  299. # }
  300. # }
  301. self.zone_data = defaultdict(lambda: defaultdict(lambda: set()))
  302. def get_best(self, camera, label):
  303. best_objects = self.camera_states[camera].best_objects
  304. if label in best_objects:
  305. return best_objects[label]
  306. else:
  307. return {}
  308. def get_current_frame(self, camera, draw=False):
  309. return self.camera_states[camera].get_current_frame(draw)
  310. def run(self):
  311. while True:
  312. if self.stop_event.is_set():
  313. print(f"Exiting object processor...")
  314. break
  315. try:
  316. camera, frame_time, current_tracked_objects = self.tracked_objects_queue.get(True, 10)
  317. except queue.Empty:
  318. continue
  319. camera_state = self.camera_states[camera]
  320. camera_state.update(frame_time, current_tracked_objects)
  321. # update zone status for each label
  322. for zone in camera_state.config.zones.keys():
  323. # get labels for current camera and all labels in current zone
  324. labels_for_camera = set([obj['label'] for obj in camera_state.tracked_objects.values() if zone in obj['zones'] and not obj['false_positive']])
  325. labels_to_check = labels_for_camera | set(self.zone_data[zone].keys())
  326. # for each label in zone
  327. for label in labels_to_check:
  328. camera_list = self.zone_data[zone][label]
  329. # remove or add the camera to the list for the current label
  330. previous_state = len(camera_list) > 0
  331. if label in labels_for_camera:
  332. camera_list.add(camera_state.name)
  333. elif camera_state.name in camera_list:
  334. camera_list.remove(camera_state.name)
  335. new_state = len(camera_list) > 0
  336. # if the value is changing, send over MQTT
  337. if previous_state == False and new_state == True:
  338. self.client.publish(f"{self.topic_prefix}/{zone}/{label}", 'ON', retain=False)
  339. elif previous_state == True and new_state == False:
  340. self.client.publish(f"{self.topic_prefix}/{zone}/{label}", 'OFF', retain=False)