video.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611
  1. import datetime
  2. import itertools
  3. import logging
  4. import multiprocessing as mp
  5. import queue
  6. import subprocess as sp
  7. import signal
  8. import threading
  9. import time
  10. from collections import defaultdict
  11. from setproctitle import setproctitle
  12. from typing import Dict, List
  13. from cv2 import cv2
  14. import numpy as np
  15. from frigate.config import CameraConfig
  16. from frigate.edgetpu import RemoteObjectDetector
  17. from frigate.log import LogPipe
  18. from frigate.motion import MotionDetector
  19. from frigate.objects import ObjectTracker
  20. from frigate.util import (
  21. EventsPerSecond,
  22. FrameManager,
  23. SharedMemoryFrameManager,
  24. calculate_region,
  25. clipped,
  26. listen,
  27. yuv_region_2_rgb,
  28. )
  29. logger = logging.getLogger(__name__)
  30. def filtered(obj, objects_to_track, object_filters):
  31. object_name = obj[0]
  32. if not object_name in objects_to_track:
  33. return True
  34. if object_name in object_filters:
  35. obj_settings = object_filters[object_name]
  36. # if the min area is larger than the
  37. # detected object, don't add it to detected objects
  38. if obj_settings.min_area > obj[3]:
  39. return True
  40. # if the detected object is larger than the
  41. # max area, don't add it to detected objects
  42. if obj_settings.max_area < obj[3]:
  43. return True
  44. # if the score is lower than the min_score, skip
  45. if obj_settings.min_score > obj[1]:
  46. return True
  47. if not obj_settings.mask is None:
  48. # compute the coordinates of the object and make sure
  49. # the location isnt outside the bounds of the image (can happen from rounding)
  50. y_location = min(int(obj[2][3]), len(obj_settings.mask) - 1)
  51. x_location = min(
  52. int((obj[2][2] - obj[2][0]) / 2.0) + obj[2][0],
  53. len(obj_settings.mask[0]) - 1,
  54. )
  55. # if the object is in a masked location, don't add it to detected objects
  56. if obj_settings.mask[y_location][x_location] == 0:
  57. return True
  58. return False
  59. def create_tensor_input(frame, model_shape, region):
  60. cropped_frame = yuv_region_2_rgb(frame, region)
  61. # Resize to 300x300 if needed
  62. if cropped_frame.shape != (model_shape[0], model_shape[1], 3):
  63. cropped_frame = cv2.resize(
  64. cropped_frame, dsize=model_shape, interpolation=cv2.INTER_LINEAR
  65. )
  66. # Expand dimensions since the model expects images to have shape: [1, height, width, 3]
  67. return np.expand_dims(cropped_frame, axis=0)
  68. def stop_ffmpeg(ffmpeg_process, logger):
  69. logger.info("Terminating the existing ffmpeg process...")
  70. ffmpeg_process.terminate()
  71. try:
  72. logger.info("Waiting for ffmpeg to exit gracefully...")
  73. ffmpeg_process.communicate(timeout=30)
  74. except sp.TimeoutExpired:
  75. logger.info("FFmpeg didnt exit. Force killing...")
  76. ffmpeg_process.kill()
  77. ffmpeg_process.communicate()
  78. ffmpeg_process = None
  79. def start_or_restart_ffmpeg(
  80. ffmpeg_cmd, logger, logpipe: LogPipe, frame_size=None, ffmpeg_process=None
  81. ):
  82. if ffmpeg_process is not None:
  83. stop_ffmpeg(ffmpeg_process, logger)
  84. if frame_size is None:
  85. process = sp.Popen(
  86. ffmpeg_cmd,
  87. stdout=sp.DEVNULL,
  88. stderr=logpipe,
  89. stdin=sp.DEVNULL,
  90. start_new_session=True,
  91. )
  92. else:
  93. process = sp.Popen(
  94. ffmpeg_cmd,
  95. stdout=sp.PIPE,
  96. stderr=logpipe,
  97. stdin=sp.DEVNULL,
  98. bufsize=frame_size * 10,
  99. start_new_session=True,
  100. )
  101. return process
  102. def capture_frames(
  103. ffmpeg_process,
  104. camera_name,
  105. frame_shape,
  106. frame_manager: FrameManager,
  107. frame_queue,
  108. fps: mp.Value,
  109. skipped_fps: mp.Value,
  110. current_frame: mp.Value,
  111. ):
  112. frame_size = frame_shape[0] * frame_shape[1]
  113. frame_rate = EventsPerSecond()
  114. frame_rate.start()
  115. skipped_eps = EventsPerSecond()
  116. skipped_eps.start()
  117. while True:
  118. fps.value = frame_rate.eps()
  119. skipped_fps = skipped_eps.eps()
  120. current_frame.value = datetime.datetime.now().timestamp()
  121. frame_name = f"{camera_name}{current_frame.value}"
  122. frame_buffer = frame_manager.create(frame_name, frame_size)
  123. try:
  124. frame_buffer[:] = ffmpeg_process.stdout.read(frame_size)
  125. except Exception as e:
  126. logger.info(f"{camera_name}: ffmpeg sent a broken frame. {e}")
  127. if ffmpeg_process.poll() != None:
  128. logger.info(
  129. f"{camera_name}: ffmpeg process is not running. exiting capture thread..."
  130. )
  131. frame_manager.delete(frame_name)
  132. break
  133. continue
  134. frame_rate.update()
  135. # if the queue is full, skip this frame
  136. if frame_queue.full():
  137. skipped_eps.update()
  138. frame_manager.delete(frame_name)
  139. continue
  140. # close the frame
  141. frame_manager.close(frame_name)
  142. # add to the queue
  143. frame_queue.put(current_frame.value)
  144. class CameraWatchdog(threading.Thread):
  145. def __init__(
  146. self, camera_name, config, frame_queue, camera_fps, ffmpeg_pid, stop_event
  147. ):
  148. threading.Thread.__init__(self)
  149. self.logger = logging.getLogger(f"watchdog.{camera_name}")
  150. self.camera_name = camera_name
  151. self.config = config
  152. self.capture_thread = None
  153. self.ffmpeg_detect_process = None
  154. self.logpipe = LogPipe(f"ffmpeg.{self.camera_name}.detect", logging.ERROR)
  155. self.ffmpeg_other_processes = []
  156. self.camera_fps = camera_fps
  157. self.ffmpeg_pid = ffmpeg_pid
  158. self.frame_queue = frame_queue
  159. self.frame_shape = self.config.frame_shape_yuv
  160. self.frame_size = self.frame_shape[0] * self.frame_shape[1]
  161. self.stop_event = stop_event
  162. def run(self):
  163. self.start_ffmpeg_detect()
  164. for c in self.config.ffmpeg_cmds:
  165. if "detect" in c["roles"]:
  166. continue
  167. logpipe = LogPipe(
  168. f"ffmpeg.{self.camera_name}.{'_'.join(sorted(c['roles']))}",
  169. logging.ERROR,
  170. )
  171. self.ffmpeg_other_processes.append(
  172. {
  173. "cmd": c["cmd"],
  174. "logpipe": logpipe,
  175. "process": start_or_restart_ffmpeg(c["cmd"], self.logger, logpipe),
  176. }
  177. )
  178. time.sleep(10)
  179. while not self.stop_event.wait(10):
  180. now = datetime.datetime.now().timestamp()
  181. if not self.capture_thread.is_alive():
  182. self.logger.error(
  183. f"FFMPEG process crashed unexpectedly for {self.camera_name}."
  184. )
  185. self.logger.error(
  186. "The following ffmpeg logs include the last 100 lines prior to exit."
  187. )
  188. self.logger.error("You may have invalid args defined for this camera.")
  189. self.logpipe.dump()
  190. self.start_ffmpeg_detect()
  191. elif now - self.capture_thread.current_frame.value > 20:
  192. self.logger.info(
  193. f"No frames received from {self.camera_name} in 20 seconds. Exiting ffmpeg..."
  194. )
  195. self.ffmpeg_detect_process.terminate()
  196. try:
  197. self.logger.info("Waiting for ffmpeg to exit gracefully...")
  198. self.ffmpeg_detect_process.communicate(timeout=30)
  199. except sp.TimeoutExpired:
  200. self.logger.info("FFmpeg didnt exit. Force killing...")
  201. self.ffmpeg_detect_process.kill()
  202. self.ffmpeg_detect_process.communicate()
  203. for p in self.ffmpeg_other_processes:
  204. poll = p["process"].poll()
  205. if poll is None:
  206. continue
  207. p["logpipe"].dump()
  208. p["process"] = start_or_restart_ffmpeg(
  209. p["cmd"], self.logger, p["logpipe"], ffmpeg_process=p["process"]
  210. )
  211. stop_ffmpeg(self.ffmpeg_detect_process, self.logger)
  212. for p in self.ffmpeg_other_processes:
  213. stop_ffmpeg(p["process"], self.logger)
  214. p["logpipe"].close()
  215. self.logpipe.close()
  216. def start_ffmpeg_detect(self):
  217. ffmpeg_cmd = [
  218. c["cmd"] for c in self.config.ffmpeg_cmds if "detect" in c["roles"]
  219. ][0]
  220. self.ffmpeg_detect_process = start_or_restart_ffmpeg(
  221. ffmpeg_cmd, self.logger, self.logpipe, self.frame_size
  222. )
  223. self.ffmpeg_pid.value = self.ffmpeg_detect_process.pid
  224. self.capture_thread = CameraCapture(
  225. self.camera_name,
  226. self.ffmpeg_detect_process,
  227. self.frame_shape,
  228. self.frame_queue,
  229. self.camera_fps,
  230. )
  231. self.capture_thread.start()
  232. class CameraCapture(threading.Thread):
  233. def __init__(self, camera_name, ffmpeg_process, frame_shape, frame_queue, fps):
  234. threading.Thread.__init__(self)
  235. self.name = f"capture:{camera_name}"
  236. self.camera_name = camera_name
  237. self.frame_shape = frame_shape
  238. self.frame_queue = frame_queue
  239. self.fps = fps
  240. self.skipped_fps = EventsPerSecond()
  241. self.frame_manager = SharedMemoryFrameManager()
  242. self.ffmpeg_process = ffmpeg_process
  243. self.current_frame = mp.Value("d", 0.0)
  244. self.last_frame = 0
  245. def run(self):
  246. self.skipped_fps.start()
  247. capture_frames(
  248. self.ffmpeg_process,
  249. self.camera_name,
  250. self.frame_shape,
  251. self.frame_manager,
  252. self.frame_queue,
  253. self.fps,
  254. self.skipped_fps,
  255. self.current_frame,
  256. )
  257. def capture_camera(name, config: CameraConfig, process_info):
  258. stop_event = mp.Event()
  259. def receiveSignal(signalNumber, frame):
  260. stop_event.set()
  261. signal.signal(signal.SIGTERM, receiveSignal)
  262. signal.signal(signal.SIGINT, receiveSignal)
  263. frame_queue = process_info["frame_queue"]
  264. camera_watchdog = CameraWatchdog(
  265. name,
  266. config,
  267. frame_queue,
  268. process_info["camera_fps"],
  269. process_info["ffmpeg_pid"],
  270. stop_event,
  271. )
  272. camera_watchdog.start()
  273. camera_watchdog.join()
  274. def track_camera(
  275. name,
  276. config: CameraConfig,
  277. model_shape,
  278. labelmap,
  279. detection_queue,
  280. result_connection,
  281. detected_objects_queue,
  282. process_info,
  283. ):
  284. stop_event = mp.Event()
  285. def receiveSignal(signalNumber, frame):
  286. stop_event.set()
  287. signal.signal(signal.SIGTERM, receiveSignal)
  288. signal.signal(signal.SIGINT, receiveSignal)
  289. threading.current_thread().name = f"process:{name}"
  290. setproctitle(f"frigate.process:{name}")
  291. listen()
  292. frame_queue = process_info["frame_queue"]
  293. detection_enabled = process_info["detection_enabled"]
  294. frame_shape = config.frame_shape
  295. objects_to_track = config.objects.track
  296. object_filters = config.objects.filters
  297. motion_detector = MotionDetector(frame_shape, config.motion)
  298. object_detector = RemoteObjectDetector(
  299. name, labelmap, detection_queue, result_connection, model_shape
  300. )
  301. object_tracker = ObjectTracker(config.detect)
  302. frame_manager = SharedMemoryFrameManager()
  303. process_frames(
  304. name,
  305. frame_queue,
  306. frame_shape,
  307. model_shape,
  308. frame_manager,
  309. motion_detector,
  310. object_detector,
  311. object_tracker,
  312. detected_objects_queue,
  313. process_info,
  314. objects_to_track,
  315. object_filters,
  316. detection_enabled,
  317. stop_event,
  318. )
  319. logger.info(f"{name}: exiting subprocess")
  320. def reduce_boxes(boxes):
  321. if len(boxes) == 0:
  322. return []
  323. reduced_boxes = cv2.groupRectangles(
  324. [list(b) for b in itertools.chain(boxes, boxes)], 1, 0.2
  325. )[0]
  326. return [tuple(b) for b in reduced_boxes]
  327. # modified from https://stackoverflow.com/a/40795835
  328. def intersects_any(box_a, boxes):
  329. for box in boxes:
  330. if (
  331. box_a[2] < box[0]
  332. or box_a[0] > box[2]
  333. or box_a[1] > box[3]
  334. or box_a[3] < box[1]
  335. ):
  336. continue
  337. return True
  338. def detect(
  339. object_detector, frame, model_shape, region, objects_to_track, object_filters
  340. ):
  341. tensor_input = create_tensor_input(frame, model_shape, region)
  342. detections = []
  343. region_detections = object_detector.detect(tensor_input)
  344. for d in region_detections:
  345. box = d[2]
  346. size = region[2] - region[0]
  347. x_min = int((box[1] * size) + region[0])
  348. y_min = int((box[0] * size) + region[1])
  349. x_max = int((box[3] * size) + region[0])
  350. y_max = int((box[2] * size) + region[1])
  351. det = (
  352. d[0],
  353. d[1],
  354. (x_min, y_min, x_max, y_max),
  355. (x_max - x_min) * (y_max - y_min),
  356. region,
  357. )
  358. # apply object filters
  359. if filtered(det, objects_to_track, object_filters):
  360. continue
  361. detections.append(det)
  362. return detections
  363. def process_frames(
  364. camera_name: str,
  365. frame_queue: mp.Queue,
  366. frame_shape,
  367. model_shape,
  368. frame_manager: FrameManager,
  369. motion_detector: MotionDetector,
  370. object_detector: RemoteObjectDetector,
  371. object_tracker: ObjectTracker,
  372. detected_objects_queue: mp.Queue,
  373. process_info: Dict,
  374. objects_to_track: List[str],
  375. object_filters,
  376. detection_enabled: mp.Value,
  377. stop_event,
  378. exit_on_empty: bool = False,
  379. ):
  380. fps = process_info["process_fps"]
  381. detection_fps = process_info["detection_fps"]
  382. current_frame_time = process_info["detection_frame"]
  383. fps_tracker = EventsPerSecond()
  384. fps_tracker.start()
  385. while not stop_event.is_set():
  386. if exit_on_empty and frame_queue.empty():
  387. logger.info(f"Exiting track_objects...")
  388. break
  389. try:
  390. frame_time = frame_queue.get(True, 10)
  391. except queue.Empty:
  392. continue
  393. current_frame_time.value = frame_time
  394. frame = frame_manager.get(
  395. f"{camera_name}{frame_time}", (frame_shape[0] * 3 // 2, frame_shape[1])
  396. )
  397. if frame is None:
  398. logger.info(f"{camera_name}: frame {frame_time} is not in memory store.")
  399. continue
  400. if not detection_enabled.value:
  401. fps.value = fps_tracker.eps()
  402. object_tracker.match_and_update(frame_time, [])
  403. detected_objects_queue.put(
  404. (camera_name, frame_time, object_tracker.tracked_objects, [], [])
  405. )
  406. detection_fps.value = object_detector.fps.eps()
  407. frame_manager.close(f"{camera_name}{frame_time}")
  408. continue
  409. # look for motion
  410. motion_boxes = motion_detector.detect(frame)
  411. # only get the tracked object boxes that intersect with motion
  412. tracked_object_boxes = [
  413. obj["box"]
  414. for obj in object_tracker.tracked_objects.values()
  415. if intersects_any(obj["box"], motion_boxes)
  416. ]
  417. # combine motion boxes with known locations of existing objects
  418. combined_boxes = reduce_boxes(motion_boxes + tracked_object_boxes)
  419. # compute regions
  420. regions = [
  421. calculate_region(frame_shape, a[0], a[1], a[2], a[3], 1.2)
  422. for a in combined_boxes
  423. ]
  424. # combine overlapping regions
  425. combined_regions = reduce_boxes(regions)
  426. # re-compute regions
  427. regions = [
  428. calculate_region(frame_shape, a[0], a[1], a[2], a[3], 1.0)
  429. for a in combined_regions
  430. ]
  431. # resize regions and detect
  432. detections = []
  433. for region in regions:
  434. detections.extend(
  435. detect(
  436. object_detector,
  437. frame,
  438. model_shape,
  439. region,
  440. objects_to_track,
  441. object_filters,
  442. )
  443. )
  444. #########
  445. # merge objects, check for clipped objects and look again up to 4 times
  446. #########
  447. refining = True
  448. refine_count = 0
  449. while refining and refine_count < 4:
  450. refining = False
  451. # group by name
  452. detected_object_groups = defaultdict(lambda: [])
  453. for detection in detections:
  454. detected_object_groups[detection[0]].append(detection)
  455. selected_objects = []
  456. for group in detected_object_groups.values():
  457. # apply non-maxima suppression to suppress weak, overlapping bounding boxes
  458. boxes = [
  459. (o[2][0], o[2][1], o[2][2] - o[2][0], o[2][3] - o[2][1])
  460. for o in group
  461. ]
  462. confidences = [o[1] for o in group]
  463. idxs = cv2.dnn.NMSBoxes(boxes, confidences, 0.5, 0.4)
  464. for index in idxs:
  465. obj = group[index[0]]
  466. if clipped(obj, frame_shape):
  467. box = obj[2]
  468. # calculate a new region that will hopefully get the entire object
  469. region = calculate_region(
  470. frame_shape, box[0], box[1], box[2], box[3]
  471. )
  472. regions.append(region)
  473. selected_objects.extend(
  474. detect(
  475. object_detector,
  476. frame,
  477. model_shape,
  478. region,
  479. objects_to_track,
  480. object_filters,
  481. )
  482. )
  483. refining = True
  484. else:
  485. selected_objects.append(obj)
  486. # set the detections list to only include top, complete objects
  487. # and new detections
  488. detections = selected_objects
  489. if refining:
  490. refine_count += 1
  491. # Limit to the detections overlapping with motion areas
  492. # to avoid picking up stationary background objects
  493. detections_with_motion = [
  494. d for d in detections if intersects_any(d[2], motion_boxes)
  495. ]
  496. # now that we have refined our detections, we need to track objects
  497. object_tracker.match_and_update(frame_time, detections_with_motion)
  498. # add to the queue if not full
  499. if detected_objects_queue.full():
  500. frame_manager.delete(f"{camera_name}{frame_time}")
  501. continue
  502. else:
  503. fps_tracker.update()
  504. fps.value = fps_tracker.eps()
  505. detected_objects_queue.put(
  506. (
  507. camera_name,
  508. frame_time,
  509. object_tracker.tracked_objects,
  510. motion_boxes,
  511. regions,
  512. )
  513. )
  514. detection_fps.value = object_detector.fps.eps()
  515. frame_manager.close(f"{camera_name}{frame_time}")