object_processing.py 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836
  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 (
  22. SharedMemoryFrameManager,
  23. draw_box_with_label,
  24. draw_timestamp,
  25. calculate_region,
  26. )
  27. logger = logging.getLogger(__name__)
  28. PATH_TO_LABELS = "/labelmap.txt"
  29. LABELS = load_labels(PATH_TO_LABELS)
  30. cmap = plt.cm.get_cmap("tab10", len(LABELS.keys()))
  31. COLOR_MAP = {}
  32. for key, val in LABELS.items():
  33. COLOR_MAP[val] = tuple(int(round(255 * c)) for c in cmap(key)[:3])
  34. def on_edge(box, frame_shape):
  35. if (
  36. box[0] == 0
  37. or box[1] == 0
  38. or box[2] == frame_shape[1] - 1
  39. or box[3] == frame_shape[0] - 1
  40. ):
  41. return True
  42. def is_better_thumbnail(current_thumb, new_obj, frame_shape) -> bool:
  43. # larger is better
  44. # cutoff images are less ideal, but they should also be smaller?
  45. # better scores are obviously better too
  46. # if the new_thumb is on an edge, and the current thumb is not
  47. if on_edge(new_obj["box"], frame_shape) and not on_edge(
  48. current_thumb["box"], frame_shape
  49. ):
  50. return False
  51. # if the score is better by more than 5%
  52. if new_obj["score"] > current_thumb["score"] + 0.05:
  53. return True
  54. # if the area is 10% larger
  55. if new_obj["area"] > current_thumb["area"] * 1.1:
  56. return True
  57. return False
  58. class TrackedObject:
  59. def __init__(self, camera, camera_config: CameraConfig, frame_cache, obj_data):
  60. self.obj_data = obj_data
  61. self.camera = camera
  62. self.camera_config = camera_config
  63. self.frame_cache = frame_cache
  64. self.current_zones = []
  65. self.entered_zones = set()
  66. self.false_positive = True
  67. self.top_score = self.computed_score = 0.0
  68. self.thumbnail_data = None
  69. self.last_updated = 0
  70. self.last_published = 0
  71. self.frame = None
  72. self.previous = self.to_dict()
  73. # start the score history
  74. self.score_history = [self.obj_data["score"]]
  75. def _is_false_positive(self):
  76. # once a true positive, always a true positive
  77. if not self.false_positive:
  78. return False
  79. threshold = self.camera_config.objects.filters[self.obj_data["label"]].threshold
  80. return self.computed_score < threshold
  81. def compute_score(self):
  82. scores = self.score_history[:]
  83. # pad with zeros if you dont have at least 3 scores
  84. if len(scores) < 3:
  85. scores += [0.0] * (3 - len(scores))
  86. return median(scores)
  87. def update(self, current_frame_time, obj_data):
  88. significant_update = False
  89. zone_change = False
  90. self.obj_data.update(obj_data)
  91. # if the object is not in the current frame, add a 0.0 to the score history
  92. if self.obj_data["frame_time"] != current_frame_time:
  93. self.score_history.append(0.0)
  94. else:
  95. self.score_history.append(self.obj_data["score"])
  96. # only keep the last 10 scores
  97. if len(self.score_history) > 10:
  98. self.score_history = self.score_history[-10:]
  99. # calculate if this is a false positive
  100. self.computed_score = self.compute_score()
  101. if self.computed_score > self.top_score:
  102. self.top_score = self.computed_score
  103. self.false_positive = self._is_false_positive()
  104. if not self.false_positive:
  105. # determine if this frame is a better thumbnail
  106. if self.thumbnail_data is None or is_better_thumbnail(
  107. self.thumbnail_data, self.obj_data, self.camera_config.frame_shape
  108. ):
  109. self.thumbnail_data = {
  110. "frame_time": self.obj_data["frame_time"],
  111. "box": self.obj_data["box"],
  112. "area": self.obj_data["area"],
  113. "region": self.obj_data["region"],
  114. "score": self.obj_data["score"],
  115. }
  116. significant_update = True
  117. # check zones
  118. current_zones = []
  119. bottom_center = (self.obj_data["centroid"][0], self.obj_data["box"][3])
  120. # check each zone
  121. for name, zone in self.camera_config.zones.items():
  122. # if the zone is not for this object type, skip
  123. if len(zone.objects) > 0 and not self.obj_data["label"] in zone.objects:
  124. continue
  125. contour = zone.contour
  126. # check if the object is in the zone
  127. if cv2.pointPolygonTest(contour, bottom_center, False) >= 0:
  128. # if the object passed the filters once, dont apply again
  129. if name in self.current_zones or not zone_filtered(self, zone.filters):
  130. current_zones.append(name)
  131. self.entered_zones.add(name)
  132. # if the zones changed, signal an update
  133. if not self.false_positive and set(self.current_zones) != set(current_zones):
  134. zone_change = True
  135. self.current_zones = current_zones
  136. return (significant_update, zone_change)
  137. def to_dict(self, include_thumbnail: bool = False):
  138. snapshot_time = (
  139. self.thumbnail_data["frame_time"]
  140. if not self.thumbnail_data is None
  141. else 0.0
  142. )
  143. event = {
  144. "id": self.obj_data["id"],
  145. "camera": self.camera,
  146. "frame_time": self.obj_data["frame_time"],
  147. "snapshot_time": snapshot_time,
  148. "label": self.obj_data["label"],
  149. "top_score": self.top_score,
  150. "false_positive": self.false_positive,
  151. "start_time": self.obj_data["start_time"],
  152. "end_time": self.obj_data.get("end_time", None),
  153. "score": self.obj_data["score"],
  154. "box": self.obj_data["box"],
  155. "area": self.obj_data["area"],
  156. "region": self.obj_data["region"],
  157. "current_zones": self.current_zones.copy(),
  158. "entered_zones": list(self.entered_zones).copy(),
  159. }
  160. if include_thumbnail:
  161. event["thumbnail"] = base64.b64encode(self.get_thumbnail()).decode("utf-8")
  162. return event
  163. def get_thumbnail(self):
  164. if (
  165. self.thumbnail_data is None
  166. or self.thumbnail_data["frame_time"] not in self.frame_cache
  167. ):
  168. ret, jpg = cv2.imencode(".jpg", np.zeros((175, 175, 3), np.uint8))
  169. jpg_bytes = self.get_jpg_bytes(
  170. timestamp=False, bounding_box=False, crop=True, height=175
  171. )
  172. if jpg_bytes:
  173. return jpg_bytes
  174. else:
  175. ret, jpg = cv2.imencode(".jpg", np.zeros((175, 175, 3), np.uint8))
  176. return jpg.tobytes()
  177. def get_clean_png(self):
  178. if self.thumbnail_data is None:
  179. return None
  180. try:
  181. best_frame = cv2.cvtColor(
  182. self.frame_cache[self.thumbnail_data["frame_time"]],
  183. cv2.COLOR_YUV2BGR_I420,
  184. )
  185. except KeyError:
  186. logger.warning(
  187. f"Unable to create clean png because frame {self.thumbnail_data['frame_time']} is not in the cache"
  188. )
  189. return None
  190. ret, png = cv2.imencode(".png", best_frame)
  191. if ret:
  192. return png.tobytes()
  193. else:
  194. return None
  195. def get_jpg_bytes(
  196. self, timestamp=False, bounding_box=False, crop=False, height=None, quality=70
  197. ):
  198. if self.thumbnail_data is None:
  199. return None
  200. try:
  201. best_frame = cv2.cvtColor(
  202. self.frame_cache[self.thumbnail_data["frame_time"]],
  203. cv2.COLOR_YUV2BGR_I420,
  204. )
  205. except KeyError:
  206. logger.warning(
  207. f"Unable to create jpg because frame {self.thumbnail_data['frame_time']} is not in the cache"
  208. )
  209. return None
  210. if bounding_box:
  211. thickness = 2
  212. color = COLOR_MAP[self.obj_data["label"]]
  213. # draw the bounding boxes on the frame
  214. box = self.thumbnail_data["box"]
  215. draw_box_with_label(
  216. best_frame,
  217. box[0],
  218. box[1],
  219. box[2],
  220. box[3],
  221. self.obj_data["label"],
  222. f"{int(self.thumbnail_data['score']*100)}% {int(self.thumbnail_data['area'])}",
  223. thickness=thickness,
  224. color=color,
  225. )
  226. if crop:
  227. box = self.thumbnail_data["box"]
  228. region = calculate_region(
  229. best_frame.shape, box[0], box[1], box[2], box[3], 1.1
  230. )
  231. best_frame = best_frame[region[1] : region[3], region[0] : region[2]]
  232. if height:
  233. width = int(height * best_frame.shape[1] / best_frame.shape[0])
  234. best_frame = cv2.resize(
  235. best_frame, dsize=(width, height), interpolation=cv2.INTER_AREA
  236. )
  237. if timestamp:
  238. color = self.camera_config.timestamp_style.color
  239. draw_timestamp(
  240. best_frame,
  241. self.thumbnail_data["frame_time"],
  242. self.camera_config.timestamp_style.format,
  243. font_effect=self.camera_config.timestamp_style.effect,
  244. font_scale=self.camera_config.timestamp_style.scale,
  245. font_thickness=self.camera_config.timestamp_style.thickness,
  246. font_color=(color.red, color.green, color.blue),
  247. position=self.camera_config.timestamp_style.position,
  248. )
  249. ret, jpg = cv2.imencode(
  250. ".jpg", best_frame, [int(cv2.IMWRITE_JPEG_QUALITY), quality]
  251. )
  252. if ret:
  253. return jpg.tobytes()
  254. else:
  255. return None
  256. def zone_filtered(obj: TrackedObject, object_config):
  257. object_name = obj.obj_data["label"]
  258. if object_name in object_config:
  259. obj_settings = object_config[object_name]
  260. # if the min area is larger than the
  261. # detected object, don't add it to detected objects
  262. if obj_settings.min_area > obj.obj_data["area"]:
  263. return True
  264. # if the detected object is larger than the
  265. # max area, don't add it to detected objects
  266. if obj_settings.max_area < obj.obj_data["area"]:
  267. return True
  268. # if the score is lower than the threshold, skip
  269. if obj_settings.threshold > obj.computed_score:
  270. return True
  271. return False
  272. # Maintains the state of a camera
  273. class CameraState:
  274. def __init__(
  275. self, name, config: FrigateConfig, frame_manager: SharedMemoryFrameManager
  276. ):
  277. self.name = name
  278. self.config = config
  279. self.camera_config = config.cameras[name]
  280. self.frame_manager = frame_manager
  281. self.best_objects: Dict[str, TrackedObject] = {}
  282. self.object_counts = defaultdict(int)
  283. self.tracked_objects: Dict[str, TrackedObject] = {}
  284. self.frame_cache = {}
  285. self.zone_objects = defaultdict(list)
  286. self._current_frame = np.zeros(self.camera_config.frame_shape_yuv, np.uint8)
  287. self.current_frame_lock = threading.Lock()
  288. self.current_frame_time = 0.0
  289. self.motion_boxes = []
  290. self.regions = []
  291. self.previous_frame_id = None
  292. self.callbacks = defaultdict(list)
  293. def get_current_frame(self, draw_options={}):
  294. with self.current_frame_lock:
  295. frame_copy = np.copy(self._current_frame)
  296. frame_time = self.current_frame_time
  297. tracked_objects = {k: v.to_dict() for k, v in self.tracked_objects.items()}
  298. motion_boxes = self.motion_boxes.copy()
  299. regions = self.regions.copy()
  300. frame_copy = cv2.cvtColor(frame_copy, cv2.COLOR_YUV2BGR_I420)
  301. # draw on the frame
  302. if draw_options.get("bounding_boxes"):
  303. # draw the bounding boxes on the frame
  304. for obj in tracked_objects.values():
  305. if obj["frame_time"] == frame_time:
  306. thickness = 2
  307. color = COLOR_MAP[obj["label"]]
  308. else:
  309. thickness = 1
  310. color = (255, 0, 0)
  311. # draw the bounding boxes on the frame
  312. box = obj["box"]
  313. draw_box_with_label(
  314. frame_copy,
  315. box[0],
  316. box[1],
  317. box[2],
  318. box[3],
  319. obj["label"],
  320. f"{obj['score']:.0%} {int(obj['area'])}",
  321. thickness=thickness,
  322. color=color,
  323. )
  324. if draw_options.get("regions"):
  325. for region in regions:
  326. cv2.rectangle(
  327. frame_copy,
  328. (region[0], region[1]),
  329. (region[2], region[3]),
  330. (0, 255, 0),
  331. 2,
  332. )
  333. if draw_options.get("zones"):
  334. for name, zone in self.camera_config.zones.items():
  335. thickness = (
  336. 8
  337. if any(
  338. name in obj["current_zones"] for obj in tracked_objects.values()
  339. )
  340. else 2
  341. )
  342. cv2.drawContours(frame_copy, [zone.contour], -1, zone.color, thickness)
  343. if draw_options.get("mask"):
  344. mask_overlay = np.where(self.camera_config.motion.mask == [0])
  345. frame_copy[mask_overlay] = [0, 0, 0]
  346. if draw_options.get("motion_boxes"):
  347. for m_box in motion_boxes:
  348. cv2.rectangle(
  349. frame_copy,
  350. (m_box[0], m_box[1]),
  351. (m_box[2], m_box[3]),
  352. (0, 0, 255),
  353. 2,
  354. )
  355. if draw_options.get("timestamp"):
  356. color = self.camera_config.timestamp_style.color
  357. draw_timestamp(
  358. frame_copy,
  359. frame_time,
  360. self.camera_config.timestamp_style.format,
  361. font_effect=self.camera_config.timestamp_style.effect,
  362. font_scale=self.camera_config.timestamp_style.scale,
  363. font_thickness=self.camera_config.timestamp_style.thickness,
  364. font_color=(color.red, color.green, color.blue),
  365. position=self.camera_config.timestamp_style.position,
  366. )
  367. return frame_copy
  368. def finished(self, obj_id):
  369. del self.tracked_objects[obj_id]
  370. def on(self, event_type: str, callback: Callable[[Dict], None]):
  371. self.callbacks[event_type].append(callback)
  372. def update(self, frame_time, current_detections, motion_boxes, regions):
  373. # get the new frame
  374. frame_id = f"{self.name}{frame_time}"
  375. current_frame = self.frame_manager.get(
  376. frame_id, self.camera_config.frame_shape_yuv
  377. )
  378. tracked_objects = self.tracked_objects.copy()
  379. current_ids = set(current_detections.keys())
  380. previous_ids = set(tracked_objects.keys())
  381. removed_ids = previous_ids.difference(current_ids)
  382. new_ids = current_ids.difference(previous_ids)
  383. updated_ids = current_ids.intersection(previous_ids)
  384. for id in new_ids:
  385. new_obj = tracked_objects[id] = TrackedObject(
  386. self.name, self.camera_config, self.frame_cache, current_detections[id]
  387. )
  388. # call event handlers
  389. for c in self.callbacks["start"]:
  390. c(self.name, new_obj, frame_time)
  391. for id in updated_ids:
  392. updated_obj = tracked_objects[id]
  393. significant_update, zone_change = updated_obj.update(
  394. frame_time, current_detections[id]
  395. )
  396. if significant_update:
  397. # ensure this frame is stored in the cache
  398. if (
  399. updated_obj.thumbnail_data["frame_time"] == frame_time
  400. and frame_time not in self.frame_cache
  401. ):
  402. self.frame_cache[frame_time] = np.copy(current_frame)
  403. updated_obj.last_updated = frame_time
  404. # if it has been more than 5 seconds since the last publish
  405. # and the last update is greater than the last publish or
  406. # the object has changed zones
  407. if (
  408. frame_time - updated_obj.last_published > 5
  409. and updated_obj.last_updated > updated_obj.last_published
  410. ) or zone_change:
  411. # call event handlers
  412. for c in self.callbacks["update"]:
  413. c(self.name, updated_obj, frame_time)
  414. updated_obj.last_published = frame_time
  415. for id in removed_ids:
  416. # publish events to mqtt
  417. removed_obj = tracked_objects[id]
  418. if not "end_time" in removed_obj.obj_data:
  419. removed_obj.obj_data["end_time"] = frame_time
  420. for c in self.callbacks["end"]:
  421. c(self.name, removed_obj, frame_time)
  422. # TODO: can i switch to looking this up and only changing when an event ends?
  423. # maintain best objects
  424. for obj in tracked_objects.values():
  425. object_type = obj.obj_data["label"]
  426. # if the object's thumbnail is not from the current frame
  427. if obj.false_positive or obj.thumbnail_data["frame_time"] != frame_time:
  428. continue
  429. if object_type in self.best_objects:
  430. current_best = self.best_objects[object_type]
  431. now = datetime.datetime.now().timestamp()
  432. # if the object is a higher score than the current best score
  433. # or the current object is older than desired, use the new object
  434. if (
  435. is_better_thumbnail(
  436. current_best.thumbnail_data,
  437. obj.thumbnail_data,
  438. self.camera_config.frame_shape,
  439. )
  440. or (now - current_best.thumbnail_data["frame_time"])
  441. > self.camera_config.best_image_timeout
  442. ):
  443. self.best_objects[object_type] = obj
  444. for c in self.callbacks["snapshot"]:
  445. c(self.name, self.best_objects[object_type], frame_time)
  446. else:
  447. self.best_objects[object_type] = obj
  448. for c in self.callbacks["snapshot"]:
  449. c(self.name, self.best_objects[object_type], frame_time)
  450. # update overall camera state for each object type
  451. obj_counter = Counter(
  452. obj.obj_data["label"]
  453. for obj in tracked_objects.values()
  454. if not obj.false_positive
  455. )
  456. # report on detected objects
  457. for obj_name, count in obj_counter.items():
  458. if count != self.object_counts[obj_name]:
  459. self.object_counts[obj_name] = count
  460. for c in self.callbacks["object_status"]:
  461. c(self.name, obj_name, count)
  462. # expire any objects that are >0 and no longer detected
  463. expired_objects = [
  464. obj_name
  465. for obj_name, count in self.object_counts.items()
  466. if count > 0 and obj_name not in obj_counter
  467. ]
  468. for obj_name in expired_objects:
  469. self.object_counts[obj_name] = 0
  470. for c in self.callbacks["object_status"]:
  471. c(self.name, obj_name, 0)
  472. for c in self.callbacks["snapshot"]:
  473. c(self.name, self.best_objects[obj_name], frame_time)
  474. # cleanup thumbnail frame cache
  475. current_thumb_frames = {
  476. obj.thumbnail_data["frame_time"]
  477. for obj in tracked_objects.values()
  478. if not obj.false_positive
  479. }
  480. current_best_frames = {
  481. obj.thumbnail_data["frame_time"] for obj in self.best_objects.values()
  482. }
  483. thumb_frames_to_delete = [
  484. t
  485. for t in self.frame_cache.keys()
  486. if t not in current_thumb_frames and t not in current_best_frames
  487. ]
  488. for t in thumb_frames_to_delete:
  489. del self.frame_cache[t]
  490. with self.current_frame_lock:
  491. self.tracked_objects = tracked_objects
  492. self.current_frame_time = frame_time
  493. self.motion_boxes = motion_boxes
  494. self.regions = regions
  495. self._current_frame = current_frame
  496. if self.previous_frame_id is not None:
  497. self.frame_manager.close(self.previous_frame_id)
  498. self.previous_frame_id = frame_id
  499. class TrackedObjectProcessor(threading.Thread):
  500. def __init__(
  501. self,
  502. config: FrigateConfig,
  503. client,
  504. topic_prefix,
  505. tracked_objects_queue,
  506. event_queue,
  507. event_processed_queue,
  508. video_output_queue,
  509. stop_event,
  510. ):
  511. threading.Thread.__init__(self)
  512. self.name = "detected_frames_processor"
  513. self.config = config
  514. self.client = client
  515. self.topic_prefix = topic_prefix
  516. self.tracked_objects_queue = tracked_objects_queue
  517. self.event_queue = event_queue
  518. self.event_processed_queue = event_processed_queue
  519. self.video_output_queue = video_output_queue
  520. self.stop_event = stop_event
  521. self.camera_states: Dict[str, CameraState] = {}
  522. self.frame_manager = SharedMemoryFrameManager()
  523. def start(camera, obj: TrackedObject, current_frame_time):
  524. self.event_queue.put(("start", camera, obj.to_dict()))
  525. def update(camera, obj: TrackedObject, current_frame_time):
  526. after = obj.to_dict()
  527. message = {
  528. "before": obj.previous,
  529. "after": after,
  530. "type": "new" if obj.previous["false_positive"] else "update",
  531. }
  532. self.client.publish(
  533. f"{self.topic_prefix}/events", json.dumps(message), retain=False
  534. )
  535. obj.previous = after
  536. def end(camera, obj: TrackedObject, current_frame_time):
  537. snapshot_config = self.config.cameras[camera].snapshots
  538. event_data = obj.to_dict(include_thumbnail=True)
  539. event_data["has_snapshot"] = False
  540. if not obj.false_positive:
  541. message = {
  542. "before": obj.previous,
  543. "after": obj.to_dict(),
  544. "type": "end",
  545. }
  546. self.client.publish(
  547. f"{self.topic_prefix}/events", json.dumps(message), retain=False
  548. )
  549. # write snapshot to disk if enabled
  550. if snapshot_config.enabled and self.should_save_snapshot(camera, obj):
  551. jpg_bytes = obj.get_jpg_bytes(
  552. timestamp=snapshot_config.timestamp,
  553. bounding_box=snapshot_config.bounding_box,
  554. crop=snapshot_config.crop,
  555. height=snapshot_config.height,
  556. quality=snapshot_config.quality,
  557. )
  558. if jpg_bytes is None:
  559. logger.warning(
  560. f"Unable to save snapshot for {obj.obj_data['id']}."
  561. )
  562. else:
  563. with open(
  564. os.path.join(
  565. CLIPS_DIR, f"{camera}-{obj.obj_data['id']}.jpg"
  566. ),
  567. "wb",
  568. ) as j:
  569. j.write(jpg_bytes)
  570. event_data["has_snapshot"] = True
  571. # write clean snapshot if enabled
  572. if snapshot_config.clean_copy:
  573. png_bytes = obj.get_clean_png()
  574. if png_bytes is None:
  575. logger.warning(
  576. f"Unable to save clean snapshot for {obj.obj_data['id']}."
  577. )
  578. else:
  579. with open(
  580. os.path.join(
  581. CLIPS_DIR,
  582. f"{camera}-{obj.obj_data['id']}-clean.png",
  583. ),
  584. "wb",
  585. ) as p:
  586. p.write(png_bytes)
  587. self.event_queue.put(("end", camera, event_data))
  588. def snapshot(camera, obj: TrackedObject, current_frame_time):
  589. mqtt_config = self.config.cameras[camera].mqtt
  590. if mqtt_config.enabled and self.should_mqtt_snapshot(camera, obj):
  591. jpg_bytes = obj.get_jpg_bytes(
  592. timestamp=mqtt_config.timestamp,
  593. bounding_box=mqtt_config.bounding_box,
  594. crop=mqtt_config.crop,
  595. height=mqtt_config.height,
  596. quality=mqtt_config.quality,
  597. )
  598. if jpg_bytes is None:
  599. logger.warning(
  600. f"Unable to send mqtt snapshot for {obj.obj_data['id']}."
  601. )
  602. else:
  603. self.client.publish(
  604. f"{self.topic_prefix}/{camera}/{obj.obj_data['label']}/snapshot",
  605. jpg_bytes,
  606. retain=True,
  607. )
  608. def object_status(camera, object_name, status):
  609. self.client.publish(
  610. f"{self.topic_prefix}/{camera}/{object_name}", status, retain=False
  611. )
  612. for camera in self.config.cameras.keys():
  613. camera_state = CameraState(camera, self.config, self.frame_manager)
  614. camera_state.on("start", start)
  615. camera_state.on("update", update)
  616. camera_state.on("end", end)
  617. camera_state.on("snapshot", snapshot)
  618. camera_state.on("object_status", object_status)
  619. self.camera_states[camera] = camera_state
  620. # {
  621. # 'zone_name': {
  622. # 'person': {
  623. # 'camera_1': 2,
  624. # 'camera_2': 1
  625. # }
  626. # }
  627. # }
  628. self.zone_data = defaultdict(lambda: defaultdict(dict))
  629. def should_save_snapshot(self, camera, obj: TrackedObject):
  630. # if there are required zones and there is no overlap
  631. required_zones = self.config.cameras[camera].snapshots.required_zones
  632. if len(required_zones) > 0 and not obj.entered_zones & set(required_zones):
  633. logger.debug(
  634. f"Not creating snapshot for {obj.obj_data['id']} because it did not enter required zones"
  635. )
  636. return False
  637. return True
  638. def should_mqtt_snapshot(self, camera, obj: TrackedObject):
  639. # if there are required zones and there is no overlap
  640. required_zones = self.config.cameras[camera].mqtt.required_zones
  641. if len(required_zones) > 0 and not obj.entered_zones & set(required_zones):
  642. logger.debug(
  643. f"Not sending mqtt for {obj.obj_data['id']} because it did not enter required zones"
  644. )
  645. return False
  646. return True
  647. def get_best(self, camera, label):
  648. # TODO: need a lock here
  649. camera_state = self.camera_states[camera]
  650. if label in camera_state.best_objects:
  651. best_obj = camera_state.best_objects[label]
  652. best = best_obj.thumbnail_data.copy()
  653. best["frame"] = camera_state.frame_cache.get(
  654. best_obj.thumbnail_data["frame_time"]
  655. )
  656. return best
  657. else:
  658. return {}
  659. def get_current_frame(self, camera, draw_options={}):
  660. return self.camera_states[camera].get_current_frame(draw_options)
  661. def run(self):
  662. while not self.stop_event.is_set():
  663. try:
  664. (
  665. camera,
  666. frame_time,
  667. current_tracked_objects,
  668. motion_boxes,
  669. regions,
  670. ) = self.tracked_objects_queue.get(True, 10)
  671. except queue.Empty:
  672. continue
  673. camera_state = self.camera_states[camera]
  674. camera_state.update(
  675. frame_time, current_tracked_objects, motion_boxes, regions
  676. )
  677. self.video_output_queue.put(
  678. (
  679. camera,
  680. frame_time,
  681. current_tracked_objects,
  682. motion_boxes,
  683. regions,
  684. )
  685. )
  686. # update zone counts for each label
  687. # for each zone in the current camera
  688. for zone in self.config.cameras[camera].zones.keys():
  689. # count labels for the camera in the zone
  690. obj_counter = Counter(
  691. obj.obj_data["label"]
  692. for obj in camera_state.tracked_objects.values()
  693. if zone in obj.current_zones and not obj.false_positive
  694. )
  695. # update counts and publish status
  696. for label in set(self.zone_data[zone].keys()) | set(obj_counter.keys()):
  697. # if we have previously published a count for this zone/label
  698. zone_label = self.zone_data[zone][label]
  699. if camera in zone_label:
  700. current_count = sum(zone_label.values())
  701. zone_label[camera] = (
  702. obj_counter[label] if label in obj_counter else 0
  703. )
  704. new_count = sum(zone_label.values())
  705. if new_count != current_count:
  706. self.client.publish(
  707. f"{self.topic_prefix}/{zone}/{label}",
  708. new_count,
  709. retain=False,
  710. )
  711. # if this is a new zone/label combo for this camera
  712. else:
  713. if label in obj_counter:
  714. zone_label[camera] = obj_counter[label]
  715. self.client.publish(
  716. f"{self.topic_prefix}/{zone}/{label}",
  717. obj_counter[label],
  718. retain=False,
  719. )
  720. # cleanup event finished queue
  721. while not self.event_processed_queue.empty():
  722. event_id, camera, clip_created = self.event_processed_queue.get()
  723. if clip_created:
  724. obj = self.camera_states[camera].tracked_objects[event_id]
  725. message = {
  726. "before": obj.previous,
  727. "after": obj.to_dict(),
  728. "type": "clip_ready",
  729. }
  730. self.client.publish(
  731. f"{self.topic_prefix}/events", json.dumps(message), retain=False
  732. )
  733. self.camera_states[camera].finished(event_id)
  734. logger.info(f"Exiting object processor...")