object_processing.py 34 KB

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