Keep frame shared memory mapped (#24644)

* keep frame shm open and skip overwritten frames

Every reader closed and reopened a frame's shared memory segment on each frame, so each process paid a page fault on every page of a fresh mapping (about 760 for a 1080p frame) per frame. Readers now keep segments mapped. Capture stamps each slot's frame time in a per-camera `{camera}_frame_times` segment, and `get()` returns None when the slot no longer holds the requested frame, reopening once by name so a mapping left over from a same-size camera recreate heals itself. Capture also reuses a slot when the frame queue is full, since no reader was told about it.

* verify the frame for embeddings object updates

Tracked object events only carried the object's own frame_time, which stays at its last detection when the object isn't detected in a frame, so embeddings couldn't check the slot it read and could crop face or plate data from a frame that had already been overwritten. Events now carry the time of the frame they name, and embeddings passes it to `get()`.

* log when a frame slot was overwritten before it was read

A lapped read and a missing segment ended up as the same debug line at every caller, so there was no way to tell a slow reader from a missing frame.

* bring the FrameManager interface in line with its implementation

The abstract get() still had a timeout_ms signature from an older implementation and frame_times() wasn't declared, though capture and detect are typed against the interface. get() also didn't say how long the array it returns stays valid.

* drop a camera's cached frame mappings when it is removed or recreated

A reader that kept its mappings open stayed on a removed camera's segments, which pinned their shared memory until restart, and after a same-size recreate the ledger reopen only refreshed the slot being read while the other cached slots still returned the old camera's pixels. Readers now drop every cached slot for a camera on its remove update, and get() does the same when a ledger mismatch turns out to be a replaced ledger rather than a lapped slot.

* match a camera's frame names exactly in close_camera

The prefix match on `{camera}_frame` also caught another camera whose name starts with it, so removing `cam` dropped the cached ring of a camera named `cam_frame`.
This commit is contained in:
Josh Hawkins
2026-10-12 10:21:08 -06:00
committed by GitHub
parent bc06b8c753
commit ff9eb8f184
16 changed files with 472 additions and 67 deletions
+4
View File
@@ -169,6 +169,10 @@ class CameraMaintainer(threading.Thread):
frame_size = config.frame_shape_yuv[0] * config.frame_shape_yuv[1] frame_size = config.frame_shape_yuv[0] * config.frame_shape_yuv[1]
self.frame_manager.create(f"{config.name}_frame{i}", frame_size) self.frame_manager.create(f"{config.name}_frame{i}", frame_size)
# a leftover ledger from an unclean exit may be the wrong length
self.frame_manager.delete(f"{config.name}_frame_times")
self.frame_manager.create(f"{config.name}_frame_times", max(count, 1) * 8)
capture_process = CameraCapture( capture_process = CameraCapture(
config, config,
count, count,
+5 -8
View File
@@ -349,7 +349,7 @@ class CameraState:
return return
current_frame = self.frame_manager.get( current_frame = self.frame_manager.get(
frame_name, self.camera_config.frame_shape_yuv frame_name, self.camera_config.frame_shape_yuv, frame_time
) )
tracked_objects = self.tracked_objects.copy() tracked_objects = self.tracked_objects.copy()
@@ -404,7 +404,7 @@ class CameraState:
self.send_mqtt_snapshot(new_obj, object_type) self.send_mqtt_snapshot(new_obj, object_type)
for c in self.callbacks["start"]: for c in self.callbacks["start"]:
c(self.name, new_obj, frame_name) c(self.name, new_obj, frame_name, frame_time)
for id in updated_ids: for id in updated_ids:
updated_obj = tracked_objects[id] updated_obj = tracked_objects[id]
@@ -416,7 +416,7 @@ class CameraState:
if autotracker_update or significant_update: if autotracker_update or significant_update:
for c in self.callbacks["autotrack"]: for c in self.callbacks["autotrack"]:
c(self.name, updated_obj, frame_name) c(self.name, updated_obj, frame_name, frame_time)
if thumb_update and current_frame is not None: if thumb_update and current_frame is not None:
# ensure this frame is stored in the cache # ensure this frame is stored in the cache
@@ -466,7 +466,7 @@ class CameraState:
): ):
# call event handlers # call event handlers
for c in self.callbacks["update"]: for c in self.callbacks["update"]:
c(self.name, updated_obj, frame_name) c(self.name, updated_obj, frame_name, frame_time)
updated_obj.last_published = frame_time updated_obj.last_published = frame_time
# send MQTT snapshot when object first enters a required zone, # send MQTT snapshot when object first enters a required zone,
@@ -488,7 +488,7 @@ class CameraState:
removed_obj.obj_data["end_time"] = frame_time removed_obj.obj_data["end_time"] = frame_time
logger.debug(f"{self.name}: end callback for object {id}") logger.debug(f"{self.name}: end callback for object {id}")
for c in self.callbacks["end"]: for c in self.callbacks["end"]:
c(self.name, removed_obj, frame_name) c(self.name, removed_obj, frame_name, frame_time)
# TODO: can i switch to looking this up and only changing when an event ends? # TODO: can i switch to looking this up and only changing when an event ends?
# maintain best objects # maintain best objects
@@ -603,9 +603,6 @@ class CameraState:
self.current_frame_time = frame_time self.current_frame_time = frame_time
self._current_frame = np.copy(current_frame) self._current_frame = np.copy(current_frame)
if self.previous_frame_id is not None:
self.frame_manager.close(self.previous_frame_id)
self.previous_frame_id = frame_name self.previous_frame_id = frame_name
def send_mqtt_snapshot(self, new_obj: TrackedObject, object_type: str) -> None: def send_mqtt_snapshot(self, new_obj: TrackedObject, object_type: str) -> None:
+18 -2
View File
@@ -8,7 +8,16 @@ from .zmq_proxy import Publisher, Subscriber
class EventUpdatePublisher( class EventUpdatePublisher(
Publisher[tuple[EventTypeEnum, EventStateEnum, str | None, str, dict[str, Any]]] Publisher[
tuple[
EventTypeEnum,
EventStateEnum,
str | None,
str,
dict[str, Any],
float | None,
]
]
): ):
"""Publishes events (objects, audio, manual).""" """Publishes events (objects, audio, manual)."""
@@ -19,7 +28,14 @@ class EventUpdatePublisher(
def publish( def publish(
self, self,
payload: tuple[EventTypeEnum, EventStateEnum, str | None, str, dict[str, Any]], payload: tuple[
EventTypeEnum,
EventStateEnum,
str | None,
str,
dict[str, Any],
float | None,
],
sub_topic: str = "", sub_topic: str = "",
) -> None: ) -> None:
super().publish(payload, sub_topic) super().publish(payload, sub_topic)
+7 -8
View File
@@ -332,6 +332,9 @@ class EmbeddingMaintainer(threading.Thread):
"""Apply camera config updates and register newly enabled processors.""" """Apply camera config updates and register newly enabled processors."""
updated_topics = self.config_updater.check_for_updates() updated_topics = self.config_updater.check_for_updates()
for camera in updated_topics.get("remove", []):
self.frame_manager.close_camera(camera)
if updated_topics.keys() & GENAI_UPDATE_TOPICS: if updated_topics.keys() & GENAI_UPDATE_TOPICS:
self._sync_genai_processors() self._sync_genai_processors()
@@ -517,7 +520,7 @@ class EmbeddingMaintainer(threading.Thread):
if update is None: if update is None:
return return
source_type, event_type, camera, frame_name, data = update source_type, event_type, camera, frame_name, data, frame_time = update
logger.debug( logger.debug(
f"Received update - source_type: {source_type}, camera: {camera}, data label: {data.get('label') if data else 'None'}" f"Received update - source_type: {source_type}, camera: {camera}, data label: {data.get('label') if data else 'None'}"
@@ -546,7 +549,7 @@ class EmbeddingMaintainer(threading.Thread):
# Create our own thumbnail based on the bounding box and the frame time # Create our own thumbnail based on the bounding box and the frame time
try: try:
yuv_frame = self.frame_manager.get( yuv_frame = self.frame_manager.get(
frame_name, camera_config.frame_shape_yuv frame_name, camera_config.frame_shape_yuv, frame_time
) )
except FileNotFoundError: except FileNotFoundError:
logger.debug(f"Frame {frame_name} not found for camera {camera}") logger.debug(f"Frame {frame_name} not found for camera {camera}")
@@ -583,8 +586,6 @@ class EmbeddingMaintainer(threading.Thread):
PostProcessDataEnum.tracked_object, PostProcessDataEnum.tracked_object,
) )
self.frame_manager.close(frame_name)
def _process_finalized(self) -> None: def _process_finalized(self) -> None:
"""Process the end of an event.""" """Process the end of an event."""
while True: while True:
@@ -793,7 +794,7 @@ class EmbeddingMaintainer(threading.Thread):
if topic is None: if topic is None:
return return
camera, frame_name, _, _, motion_boxes, _ = data camera, frame_name, frame_time, _, motion_boxes, _ = data
if not camera or camera not in self.config.cameras: if not camera or camera not in self.config.cameras:
return return
@@ -817,7 +818,7 @@ class EmbeddingMaintainer(threading.Thread):
try: try:
yuv_frame = self.frame_manager.get( yuv_frame = self.frame_manager.get(
frame_name, camera_config.frame_shape_yuv frame_name, camera_config.frame_shape_yuv, frame_time
) )
except FileNotFoundError: except FileNotFoundError:
pass pass
@@ -841,8 +842,6 @@ class EmbeddingMaintainer(threading.Thread):
{"camera": camera, "motion": motion_boxes}, yuv_frame {"camera": camera, "motion": motion_boxes}, yuv_frame
) )
self.frame_manager.close(frame_name)
def _process_deferred_results(self) -> None: def _process_deferred_results(self) -> None:
"""Drain results from deferred processors and perform IPC side-effects.""" """Drain results from deferred processors and perform IPC side-effects."""
for processor in self.realtime_processors: for processor in self.realtime_processors:
+1 -1
View File
@@ -94,7 +94,7 @@ class EventProcessor(threading.Thread):
if update == None: if update == None:
continue continue
source_type, event_type, camera, _, event_data = update source_type, event_type, camera, _, event_data, _ = update
logger.debug( logger.debug(
f"Event received: {source_type} {event_type} {camera} {event_data['id']}" f"Event received: {source_type} {event_type} {camera} {event_data['id']}"
-5
View File
@@ -170,7 +170,6 @@ class DetectorRunner(FrigateProcess):
mono_start = time.monotonic() mono_start = time.monotonic()
detections = object_detector.detect_raw(input_frame) detections = object_detector.detect_raw(input_frame)
duration = time.monotonic() - mono_start duration = time.monotonic() - mono_start
frame_manager.close(connection_id)
if connection_id not in self.outputs: if connection_id not in self.outputs:
self.create_output_shm(connection_id) self.create_output_shm(connection_id)
@@ -259,10 +258,6 @@ class AsyncDetectorRunner(FrigateProcess):
ts = self.send_times.popleft() ts = self.send_times.popleft()
duration = time.perf_counter() - ts duration = time.perf_counter() - ts
# release input buffer
assert self._frame_manager is not None
self._frame_manager.close(connection_id)
if connection_id not in self.outputs: if connection_id not in self.outputs:
self.create_output_shm(connection_id) self.create_output_shm(connection_id)
+6 -18
View File
@@ -145,6 +145,7 @@ class OutputProcess(FrigateProcess):
CameraConfigUpdateEnum.birdseye, CameraConfigUpdateEnum.birdseye,
CameraConfigUpdateEnum.enabled, CameraConfigUpdateEnum.enabled,
CameraConfigUpdateEnum.record, CameraConfigUpdateEnum.record,
CameraConfigUpdateEnum.remove,
], ],
) )
birdseye_config_subscriber = ConfigSubscriber("config/birdseye", exact=True) birdseye_config_subscriber = ConfigSubscriber("config/birdseye", exact=True)
@@ -208,6 +209,10 @@ class OutputProcess(FrigateProcess):
birdseye, birdseye,
) )
if CameraConfigUpdateEnum.remove in updates:
for camera in updates["remove"]:
frame_manager.close_camera(camera)
_result = detection_subscriber.check_for_update(timeout=1) _result = detection_subscriber.check_for_update(timeout=1)
if _result is None: if _result is None:
continue continue
@@ -241,7 +246,7 @@ class OutputProcess(FrigateProcess):
continue continue
frame = frame_manager.get( frame = frame_manager.get(
frame_name, self.config.cameras[camera].frame_shape_yuv frame_name, self.config.cameras[camera].frame_shape_yuv, frame_time
) )
if frame is None: if frame is None:
@@ -296,8 +301,6 @@ class OutputProcess(FrigateProcess):
frame, frame,
) )
frame_manager.close(frame_name)
move_preview_frames("clips") move_preview_frames("clips")
while True: while True:
@@ -309,21 +312,6 @@ class OutputProcess(FrigateProcess):
if not topic or data is None: if not topic or data is None:
break break
(
camera,
frame_name,
frame_time,
current_tracked_objects,
motion_boxes,
regions,
) = data
camera_config = self.config.cameras.get(camera)
if camera_config is not None:
frame_manager.get(frame_name, camera_config.frame_shape_yuv)
frame_manager.close(frame_name)
detection_subscriber.stop() detection_subscriber.stop()
for jsmpeg in jsmpeg_cameras.values(): for jsmpeg in jsmpeg_cameras.values():
+1 -3
View File
@@ -124,7 +124,7 @@ class PtzMotionEstimator:
) )
yuv_frame = self.frame_manager.get( yuv_frame = self.frame_manager.get(
frame_name, self.camera_config.frame_shape_yuv frame_name, self.camera_config.frame_shape_yuv, frame_time
) )
if yuv_frame is None: if yuv_frame is None:
@@ -181,8 +181,6 @@ class PtzMotionEstimator:
except Exception: except Exception:
pass pass
self.frame_manager.close(frame_name)
return self.coord_transformations return self.coord_transformations
+5 -8
View File
@@ -504,6 +504,7 @@ class ReviewSegmentMaintainer(threading.Thread):
def _handle_camera_removed(self, camera: str) -> None: def _handle_camera_removed(self, camera: str) -> None:
"""Close out a deleted camera's segment so a reused name cannot inherit it.""" """Close out a deleted camera's segment so a reused name cannot inherit it."""
self.forcibly_end_segment(camera) self.forcibly_end_segment(camera)
self.frame_manager.close_camera(camera)
self.indefinite_events.pop(camera, None) self.indefinite_events.pop(camera, None)
self.recent_classification_state_changes.pop(camera, None) self.recent_classification_state_changes.pop(camera, None)
@@ -544,7 +545,7 @@ class ReviewSegmentMaintainer(threading.Thread):
try: try:
yuv_frame = self.frame_manager.get( yuv_frame = self.frame_manager.get(
frame_name, camera_config.frame_shape_yuv frame_name, camera_config.frame_shape_yuv, frame_time
) )
except FileNotFoundError: except FileNotFoundError:
return return
@@ -554,7 +555,6 @@ class ReviewSegmentMaintainer(threading.Thread):
return return
pending.update_frame(camera_config, yuv_frame, objects) pending.update_frame(camera_config, yuv_frame, objects)
self.frame_manager.close(frame_name)
def update_existing_segment( def update_existing_segment(
self, self,
@@ -653,7 +653,7 @@ class ReviewSegmentMaintainer(threading.Thread):
try: try:
if should_update_image: if should_update_image:
yuv_frame = self.frame_manager.get( yuv_frame = self.frame_manager.get(
frame_name, camera_config.frame_shape_yuv frame_name, camera_config.frame_shape_yuv, frame_time
) )
if yuv_frame is None: if yuv_frame is None:
@@ -669,7 +669,6 @@ class ReviewSegmentMaintainer(threading.Thread):
activity.get_all_objects(), activity.get_all_objects(),
prev_data, prev_data,
) )
self.frame_manager.close(frame_name)
except FileNotFoundError: except FileNotFoundError:
return return
@@ -677,7 +676,7 @@ class ReviewSegmentMaintainer(threading.Thread):
if not segment.has_frame: if not segment.has_frame:
try: try:
yuv_frame = self.frame_manager.get( yuv_frame = self.frame_manager.get(
frame_name, camera_config.frame_shape_yuv frame_name, camera_config.frame_shape_yuv, frame_time
) )
if yuv_frame is None: if yuv_frame is None:
@@ -685,7 +684,6 @@ class ReviewSegmentMaintainer(threading.Thread):
return return
segment.save_full_frame(camera_config, yuv_frame) segment.save_full_frame(camera_config, yuv_frame)
self.frame_manager.close(frame_name)
self._publish_segment_update( self._publish_segment_update(
segment, camera_config, None, [], prev_data segment, camera_config, None, [], prev_data
) )
@@ -770,7 +768,7 @@ class ReviewSegmentMaintainer(threading.Thread):
try: try:
yuv_frame = self.frame_manager.get( yuv_frame = self.frame_manager.get(
frame_name, camera_config.frame_shape_yuv frame_name, camera_config.frame_shape_yuv, frame_time
) )
if yuv_frame is None: if yuv_frame is None:
@@ -780,7 +778,6 @@ class ReviewSegmentMaintainer(threading.Thread):
new_segment.update_frame( new_segment.update_frame(
camera_config, yuv_frame, activity.get_all_objects() camera_config, yuv_frame, activity.get_all_objects()
) )
self.frame_manager.close(frame_name)
self._publish_segment_start(new_segment) self._publish_segment_start(new_segment)
except FileNotFoundError: except FileNotFoundError:
return return
+2
View File
@@ -183,10 +183,12 @@ class TestReviewMaintainerRemoval(unittest.TestCase):
"deleted_cam": [{"model": "gate", "from": "a", "to": "b", "timestamp": 1.0}] "deleted_cam": [{"model": "gate", "from": "a", "to": "b", "timestamp": 1.0}]
} }
maintainer.forcibly_end_segment = MagicMock() maintainer.forcibly_end_segment = MagicMock()
maintainer.frame_manager = MagicMock()
maintainer._handle_camera_removed("deleted_cam") maintainer._handle_camera_removed("deleted_cam")
maintainer.forcibly_end_segment.assert_called_once_with("deleted_cam") maintainer.forcibly_end_segment.assert_called_once_with("deleted_cam")
maintainer.frame_manager.close_camera.assert_called_once_with("deleted_cam")
self.assertNotIn("deleted_cam", maintainer.indefinite_events) self.assertNotIn("deleted_cam", maintainer.indefinite_events)
self.assertNotIn("deleted_cam", maintainer.recent_classification_state_changes) self.assertNotIn("deleted_cam", maintainer.recent_classification_state_changes)
+2 -2
View File
@@ -74,8 +74,8 @@ class ReviewFlowTestCase(unittest.TestCase):
maintainer.config_subscriber.check_for_updates.return_value = {} maintainer.config_subscriber.check_for_updates.return_value = {}
maintainer.detection_subscriber = MagicMock() maintainer.detection_subscriber = MagicMock()
maintainer.frame_manager = MagicMock() maintainer.frame_manager = MagicMock()
maintainer.frame_manager.get.side_effect = lambda _name, shape: np.zeros( maintainer.frame_manager.get.side_effect = lambda _name, shape, _t=None: (
shape, np.uint8 np.zeros(shape, np.uint8)
) )
return maintainer return maintainer
@@ -6,13 +6,20 @@ in-process cache (e.g. TrackedObjectProcessor) still holds a ref to
the old, smaller segment. the old, smaller segment.
""" """
import queue
import threading
import unittest import unittest
from multiprocessing import Event, Value
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import patch from unittest.mock import MagicMock, patch
import numpy as np import numpy as np
from frigate.embeddings.maintainer import EmbeddingMaintainer
from frigate.events.types import EventStateEnum, EventTypeEnum
from frigate.track.object_processing import TrackedObjectProcessor
from frigate.util.image import SharedMemoryFrameManager from frigate.util.image import SharedMemoryFrameManager
from frigate.video.ffmpeg import capture_frames
def _fake_shm(size: int) -> SimpleNamespace: def _fake_shm(size: int) -> SimpleNamespace:
@@ -152,5 +159,319 @@ class TestSharedMemoryFrameManagerGetRecreatesLargerSegment(unittest.TestCase):
self.assertEqual(arr_large.shape, (1620, 1920)) self.assertEqual(arr_large.shape, (1620, 1920))
def _segments(**sizes: int) -> dict[str, SimpleNamespace]:
return {name: _fake_shm(size) for name, size in sizes.items()}
def _open_from(segments: dict[str, SimpleNamespace]):
def open_segment(name: str, **_kwargs):
if name not in segments:
raise FileNotFoundError(name)
return segments[name]
return open_segment
class TestSharedMemoryFrameManagerFrameTimes(unittest.TestCase):
def test_frame_times_is_float64_view_over_ledger_segment(self) -> None:
manager = SharedMemoryFrameManager()
segments = _segments(cam_frame_times=8 * 3)
with patch(
"frigate.util.image.UntrackedSharedMemory",
side_effect=_open_from(segments),
):
times = manager.frame_times("cam")
self.assertEqual(times.dtype, np.float64)
self.assertEqual(times.shape, (3,))
times[1] = 12.5
self.assertEqual(
np.frombuffer(segments["cam_frame_times"].buf, dtype=np.float64)[1], 12.5
)
def test_frame_times_returns_none_when_ledger_missing(self) -> None:
manager = SharedMemoryFrameManager()
with patch(
"frigate.util.image.UntrackedSharedMemory", side_effect=FileNotFoundError
):
self.assertIsNone(manager.frame_times("cam"))
def test_get_returns_frame_when_ledger_matches(self) -> None:
manager = SharedMemoryFrameManager()
segments = _segments(cam_frame0=2_500, cam_frame_times=8 * 2)
np.frombuffer(segments["cam_frame_times"].buf, dtype=np.float64)[0] = 100.25
with patch(
"frigate.util.image.UntrackedSharedMemory",
side_effect=_open_from(segments),
):
arr = manager.get("cam_frame0", (50, 50), frame_time=100.25)
self.assertIsNotNone(arr)
self.assertEqual(arr.shape, (50, 50))
def test_get_returns_none_when_slot_was_overwritten(self) -> None:
manager = SharedMemoryFrameManager()
segments = _segments(cam_frame0=2_500, cam_frame_times=8 * 2)
np.frombuffer(segments["cam_frame_times"].buf, dtype=np.float64)[0] = 200.0
with (
patch(
"frigate.util.image.UntrackedSharedMemory",
side_effect=_open_from(segments),
),
self.assertLogs("frigate.util.image", "DEBUG") as logs,
):
arr = manager.get("cam_frame0", (50, 50), frame_time=100.25)
self.assertIsNone(arr)
self.assertIn(
"cam_frame0 no longer holds the frame from 100.25", logs.output[0]
)
def test_get_returns_none_when_slot_index_is_beyond_ledger(self) -> None:
manager = SharedMemoryFrameManager()
segments = _segments(cam_frame5=2_500, cam_frame_times=8 * 2)
with patch(
"frigate.util.image.UntrackedSharedMemory",
side_effect=_open_from(segments),
):
arr = manager.get("cam_frame5", (50, 50), frame_time=100.25)
self.assertIsNone(arr)
def test_get_reopens_every_cached_slot_when_ledger_is_stale(self) -> None:
"""A camera recreated at the same resolution gets new segments under
the old names. A long-lived reader still mapped to the unlinked ledger
sees a mismatch, which must drop every cached slot of that camera, not
only the one being read, or later reads return the old pixels."""
manager = SharedMemoryFrameManager()
stale = _segments(cam_frame0=2_500, cam_frame1=2_500, cam_frame_times=8 * 2)
manager.shm_store.update(stale)
current = _segments(cam_frame0=2_500, cam_frame1=2_500, cam_frame_times=8 * 2)
current["cam_frame1"].buf[0] = 7
np.frombuffer(current["cam_frame_times"].buf, dtype=np.float64)[:] = (
100.25,
100.5,
)
with patch(
"frigate.util.image.UntrackedSharedMemory",
side_effect=_open_from(current),
):
first = manager.get("cam_frame0", (50, 50), frame_time=100.25)
second = manager.get("cam_frame1", (50, 50), frame_time=100.5)
self.assertIsNotNone(first)
self.assertEqual(second[0, 0], 7)
self.assertIs(manager.shm_store["cam_frame1"], current["cam_frame1"])
def test_get_keeps_cached_slots_when_only_lapped(self) -> None:
manager = SharedMemoryFrameManager()
segments = _segments(cam_frame0=2_500, cam_frame1=2_500, cam_frame_times=8 * 2)
manager.shm_store.update(segments)
np.frombuffer(segments["cam_frame_times"].buf, dtype=np.float64)[0] = 200.0
with patch(
"frigate.util.image.UntrackedSharedMemory",
side_effect=_open_from(segments),
):
arr = manager.get("cam_frame0", (50, 50), frame_time=100.25)
self.assertIsNone(arr)
self.assertIs(manager.shm_store["cam_frame1"], segments["cam_frame1"])
def test_close_camera_drops_only_that_camera(self) -> None:
manager = SharedMemoryFrameManager()
manager.shm_store.update(
_segments(cam_frame0=1, cam_frame1=1, cam_frame_times=8, other_frame0=1)
)
# a camera named cam_frame shares the cam_frame prefix
manager.shm_store.update(
{"cam_frame_frame0": _fake_shm(1), "cam_frame_frame_times": _fake_shm(8)}
)
manager.close_camera("cam")
self.assertEqual(
set(manager.shm_store),
{"other_frame0", "cam_frame_frame0", "cam_frame_frame_times"},
)
def test_get_without_frame_time_skips_ledger(self) -> None:
manager = SharedMemoryFrameManager()
segments = _segments(cam_frame0=2_500)
with patch(
"frigate.util.image.UntrackedSharedMemory",
side_effect=_open_from(segments),
):
arr = manager.get("cam_frame0", (50, 50))
self.assertIsNotNone(arr)
self.assertNotIn("cam_frame_times", manager.shm_store)
class TestCaptureFrames(unittest.TestCase):
"""capture_frames stamps the ledger and only advances the slot index
once a frame has been handed to the queue."""
def _run(self, frames: list[bytes], put_results: list[Exception | None]):
frame_size = 4
slots = {f"cam_frame{i}": bytearray(frame_size) for i in range(3)}
times = np.zeros(3, dtype=np.float64)
frame_manager = MagicMock()
frame_manager.write.side_effect = lambda name: memoryview(slots[name])
frame_manager.frame_times.return_value = times
stop_event = Event()
reads = iter(frames)
self.times_during_read: list[np.ndarray] = []
def readinto(buffer):
self.times_during_read.append(times.copy())
try:
frame = next(reads)
except StopIteration:
stop_event.set()
return 0
buffer[:] = frame
return len(frame)
ffmpeg_process = MagicMock()
ffmpeg_process.stdout.readinto.side_effect = readinto
ffmpeg_process.poll.return_value = None
frame_queue = MagicMock()
frame_queue.put.side_effect = put_results
config = SimpleNamespace(name="cam", enabled=True)
capture_frames(
ffmpeg_process,
config,
3,
0,
(2, 2),
frame_manager,
frame_queue,
Value("d", 0.0),
Value("d", 0.0),
Value("d", 0.0),
stop_event,
)
return slots, times, [c.args[0] for c in frame_queue.put.call_args_list]
def test_advances_slot_and_stamps_ledger_on_success(self) -> None:
slots, times, puts = self._run([b"aaaa", b"bbbb"], [None, None])
self.assertEqual([name for name, _ in puts], ["cam_frame0", "cam_frame1"])
self.assertEqual(bytes(slots["cam_frame0"]), b"aaaa")
self.assertEqual(bytes(slots["cam_frame1"]), b"bbbb")
self.assertEqual(times[0], puts[0][1])
self.assertEqual(times[1], puts[1][1])
self.assertGreater(times[0], 0)
def test_reuses_slot_when_queue_is_full(self) -> None:
slots, times, puts = self._run(
[b"aaaa", b"bbbb", b"cccc"], [None, queue.Full(), None]
)
self.assertEqual(
[name for name, _ in puts], ["cam_frame0", "cam_frame1", "cam_frame1"]
)
self.assertEqual(bytes(slots["cam_frame1"]), b"cccc")
self.assertEqual(bytes(slots["cam_frame2"]), bytes(4))
self.assertEqual(times[1], puts[2][1])
self.assertEqual(times[2], 0)
def test_slot_never_matches_while_it_is_being_filled(self) -> None:
_, _, puts = self._run([b"aaaa", b"bbbb"], [queue.Full(), None])
# the second read refills slot 0, which the first frame had stamped
self.assertEqual([name for name, _ in puts], ["cam_frame0", "cam_frame0"])
self.assertEqual(self.times_during_read[1][0], 0)
class TestTrackedObjectEventsCarryFrameTime(unittest.TestCase):
"""Tracked object events carry the time of the frame they name, which can
differ from the object's own frame_time when it wasn't detected in that
frame."""
def _callbacks(self) -> tuple[dict, MagicMock]:
processor = TrackedObjectProcessor.__new__(TrackedObjectProcessor)
processor.config = SimpleNamespace(cameras={"cam": SimpleNamespace(name="cam")})
processor.event_sender = MagicMock()
processor.dispatcher = MagicMock()
processor.ptz_autotracker_thread = MagicMock()
processor.camera_states = {}
processor.camera_states_lock = threading.Lock()
processor.frame_manager = MagicMock()
processor.should_save_snapshot = MagicMock(return_value=False)
processor.should_retain_recording = MagicMock(return_value=False)
callbacks: dict = {}
with patch("frigate.track.object_processing.CameraState") as state_cls:
state_cls.return_value.on.side_effect = lambda name, fn: callbacks.update(
{name: fn}
)
processor.create_camera_state("cam")
return callbacks, processor.event_sender
def test_start_update_end_publish_frame_time(self) -> None:
callbacks, sender = self._callbacks()
obj = MagicMock()
obj.to_dict.return_value = {"id": "1", "frame_time": 10.0}
obj.obj_data = {"frame_time": 10.0}
obj.previous = {"false_positive": True}
obj.false_positive = True
callbacks["start"]("cam", obj, "cam_frame3", 12.5)
callbacks["update"]("cam", obj, "cam_frame4", 13.0)
callbacks["end"]("cam", obj, "cam_frame5", 13.5)
payloads = [c.args[0] for c in sender.publish.call_args_list]
self.assertEqual(
[(p[1], p[3], p[5]) for p in payloads],
[
(EventStateEnum.start, "cam_frame3", 12.5),
(EventStateEnum.update, "cam_frame4", 13.0),
(EventStateEnum.end, "cam_frame5", 13.5),
],
)
class TestEmbeddingsObjectUpdateVerifiesFrame(unittest.TestCase):
def test_object_update_reads_frame_at_event_frame_time(self) -> None:
maintainer = EmbeddingMaintainer.__new__(EmbeddingMaintainer)
maintainer.config = SimpleNamespace(
cameras={"cam": SimpleNamespace(frame_shape_yuv=(6, 4))},
semantic_search=SimpleNamespace(enabled=False),
)
maintainer.event_subscriber = MagicMock()
maintainer.event_subscriber.check_for_update.return_value = (
EventTypeEnum.tracked_object,
EventStateEnum.update,
"cam",
"cam_frame4",
{"id": "1", "label": "person", "frame_time": 10.0},
13.0,
)
maintainer.realtime_processors = [MagicMock()]
maintainer.post_processors = []
maintainer.frame_manager = MagicMock()
maintainer._process_updates()
maintainer.frame_manager.get.assert_called_once_with("cam_frame4", (6, 4), 13.0)
maintainer.frame_manager.close.assert_not_called()
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+21 -5
View File
@@ -118,7 +118,9 @@ class TrackedObjectProcessor(threading.Thread):
def create_camera_state(self, camera: str) -> None: def create_camera_state(self, camera: str) -> None:
"""Creates a new camera state.""" """Creates a new camera state."""
def start(camera: str, obj: TrackedObject, frame_name: str) -> None: def start(
camera: str, obj: TrackedObject, frame_name: str, frame_time: float
) -> None:
self.event_sender.publish( self.event_sender.publish(
( (
EventTypeEnum.tracked_object, EventTypeEnum.tracked_object,
@@ -126,10 +128,13 @@ class TrackedObjectProcessor(threading.Thread):
camera, camera,
frame_name, frame_name,
obj.to_dict(), obj.to_dict(),
frame_time,
) )
) )
def update(camera: str, obj: TrackedObject, frame_name: str) -> None: def update(
camera: str, obj: TrackedObject, frame_name: str, frame_time: float
) -> None:
obj.has_snapshot = self.should_save_snapshot( obj.has_snapshot = self.should_save_snapshot(
camera_state.camera_config, obj camera_state.camera_config, obj
) )
@@ -149,13 +154,18 @@ class TrackedObjectProcessor(threading.Thread):
camera, camera,
frame_name, frame_name,
obj.to_dict(), obj.to_dict(),
frame_time,
) )
) )
def autotrack(camera: str, obj: TrackedObject, frame_name: str) -> None: def autotrack(
camera: str, obj: TrackedObject, frame_name: str, frame_time: float
) -> None:
self.ptz_autotracker_thread.autotrack_object(camera, obj) self.ptz_autotracker_thread.autotrack_object(camera, obj)
def end(camera: str, obj: TrackedObject, frame_name: str) -> None: def end(
camera: str, obj: TrackedObject, frame_name: str, frame_time: float
) -> None:
# populate has_snapshot # populate has_snapshot
obj.has_snapshot = self.should_save_snapshot( obj.has_snapshot = self.should_save_snapshot(
camera_state.camera_config, obj camera_state.camera_config, obj
@@ -186,6 +196,7 @@ class TrackedObjectProcessor(threading.Thread):
camera, camera,
frame_name, frame_name,
obj.to_dict(), obj.to_dict(),
frame_time,
) )
) )
@@ -599,6 +610,7 @@ class TrackedObjectProcessor(threading.Thread):
"type": source_type, "type": source_type,
"draw": draw, "draw": draw,
}, },
None,
) )
) )
@@ -664,6 +676,7 @@ class TrackedObjectProcessor(threading.Thread):
"recognized_license_plate": plate, "recognized_license_plate": plate,
"recognized_license_plate_score": score, "recognized_license_plate_score": score,
}, },
None,
) )
) )
@@ -692,6 +705,7 @@ class TrackedObjectProcessor(threading.Thread):
None, None,
"", "",
{"id": event_id, "end_time": end_time}, {"id": event_id, "end_time": end_time},
None,
) )
) )
@@ -713,13 +727,14 @@ class TrackedObjectProcessor(threading.Thread):
def force_end_all_events(self, camera: str, camera_state: CameraState) -> None: def force_end_all_events(self, camera: str, camera_state: CameraState) -> None:
"""Ends all active events on camera when disabling.""" """Ends all active events on camera when disabling."""
last_frame_name = camera_state.previous_frame_id last_frame_name = camera_state.previous_frame_id
last_frame_time = camera_state.current_frame_time
for obj_id, obj in list(camera_state.tracked_objects.items()): for obj_id, obj in list(camera_state.tracked_objects.items()):
if "end_time" not in obj.obj_data: if "end_time" not in obj.obj_data:
logger.debug(f"Camera {camera} disabled, ending active event {obj_id}") logger.debug(f"Camera {camera} disabled, ending active event {obj_id}")
obj.obj_data["end_time"] = datetime.datetime.now().timestamp() obj.obj_data["end_time"] = datetime.datetime.now().timestamp()
# end callbacks # end callbacks
for callback in camera_state.callbacks["end"]: for callback in camera_state.callbacks["end"]:
callback(camera, obj, last_frame_name) callback(camera, obj, last_frame_name, last_frame_time)
# camera activity callbacks # camera activity callbacks
for callback in camera_state.callbacks["camera_activity"]: for callback in camera_state.callbacks["camera_activity"]:
@@ -748,6 +763,7 @@ class TrackedObjectProcessor(threading.Thread):
continue continue
camera_state.shutdown() camera_state.shutdown()
self.frame_manager.close_camera(camera)
with self.camera_states_lock: with self.camera_states_lock:
self.camera_states.pop(camera) self.camera_states.pop(camera)
+69 -2
View File
@@ -990,13 +990,23 @@ class FrameManager(ABC):
pass pass
@abstractmethod @abstractmethod
def get(self, name: str, timeout_ms: int = 0): def get(
self, name: str, shape: tuple[int, ...], frame_time: float | None = None
) -> np.ndarray | None:
pass
@abstractmethod
def frame_times(self, camera: str) -> np.ndarray | None:
pass pass
@abstractmethod @abstractmethod
def close(self, name: str): def close(self, name: str):
pass pass
@abstractmethod
def close_camera(self, camera: str) -> None:
pass
@abstractmethod @abstractmethod
def delete(self, name: str): def delete(self, name: str):
pass pass
@@ -1079,7 +1089,56 @@ class SharedMemoryFrameManager(FrameManager):
logger.info(f"the file {name} not found") logger.info(f"the file {name} not found")
return None return None
def get(self, name: str, shape) -> np.ndarray | None: def frame_times(self, camera: str) -> np.ndarray | None:
"""Per-slot write times for a camera's frame ring, stamped by capture."""
buf = self.write(f"{camera}_frame_times")
if buf is None:
return None
return np.ndarray((len(buf) // 8,), dtype=np.float64, buffer=buf)
def _holds(self, name: str, frame_time: float) -> bool:
camera, _, index = name.rpartition("_frame")
times = self.frame_times(camera)
return (
times is not None
and index.isdigit()
and int(index) < len(times)
and times[int(index)] == frame_time
)
def get(
self, name: str, shape: tuple[int, ...], frame_time: float | None = None
) -> np.ndarray | None:
"""Map a frame slot as an array without copying it.
The array points into shared memory, so it is only valid until the next
close(), delete(), or get() of the same name, and capture overwrites its
pixels once the ring wraps back to this slot. Copy it to keep it longer.
Args:
name: Slot name, such as front_door_frame3
shape: Array shape, which must match the slot's size
frame_time: Time of the frame the caller was told is in the slot
Returns:
The frame, or None when the slot is missing, the wrong size, or holds
a different frame than frame_time
"""
if frame_time is not None and not self._holds(name, frame_time):
# a ledger left over from a same-size recreate of the camera looks
# the same as a lapped slot, so reopen it by name once
camera = name.rpartition("_frame")[0]
self.close(f"{camera}_frame_times")
if not self._holds(name, frame_time):
logger.debug("%s no longer holds the frame from %s", name, frame_time)
return None
# the ledger was replaced, so every cached slot maps the old segments
self.close_camera(camera)
try: try:
required = int(np.prod(shape)) required = int(np.prod(shape))
shm = self.shm_store.get(name) shm = self.shm_store.get(name)
@@ -1110,6 +1169,14 @@ class SharedMemoryFrameManager(FrameManager):
self.shm_store[name].close() self.shm_store[name].close()
del self.shm_store[name] del self.shm_store[name]
def close_camera(self, camera: str) -> None:
"""Drop every cached mapping for a camera's frame ring and ledger."""
for name in list(self.shm_store):
owner, _, suffix = name.rpartition("_frame")
if owner == camera and (suffix.isdigit() or suffix == "_times"):
self.close(name)
def delete(self, name: str): def delete(self, name: str):
if name in self.shm_store: if name in self.shm_store:
self.shm_store[name].close() self.shm_store[name].close()
-2
View File
@@ -607,7 +607,6 @@ def process_frames(
) )
# add to the queue if not full # add to the queue if not full
if detected_objects_queue.full(): if detected_objects_queue.full():
frame_manager.close(frame_name)
dropped_frames.dropped(time.monotonic()) dropped_frames.dropped(time.monotonic())
continue continue
else: else:
@@ -624,7 +623,6 @@ def process_frames(
) )
) )
camera_metrics.detection_fps.value = object_detector.fps.eps() camera_metrics.detection_fps.value = object_detector.fps.eps()
frame_manager.close(frame_name)
motion_detector.stop() motion_detector.stop()
requestor.stop() requestor.stop()
+9 -2
View File
@@ -64,6 +64,7 @@ def capture_frames(
frame_rate.start() frame_rate.start()
skipped_eps = EventsPerSecond() skipped_eps = EventsPerSecond()
skipped_eps.start() skipped_eps.start()
frame_times = frame_manager.frame_times(config.name)
while not stop_event.is_set(): while not stop_event.is_set():
# CameraWatchdog applies enabled updates onto this same CameraConfig # CameraWatchdog applies enabled updates onto this same CameraConfig
@@ -78,6 +79,11 @@ def capture_frames(
current_frame.value = datetime.now().timestamp() current_frame.value = datetime.now().timestamp()
frame_name = f"{config.name}_frame{frame_index}" frame_name = f"{config.name}_frame{frame_index}"
frame_buffer = frame_manager.write(frame_name) frame_buffer = frame_manager.write(frame_name)
# readinto fills the slot for the whole pipe wait, so readers must not
# match it until the frame is complete
frame_times[frame_index] = 0
try: try:
if ffmpeg_process.stdout.readinto(frame_buffer) != frame_size: if ffmpeg_process.stdout.readinto(frame_buffer) != frame_size:
raise EOFError("incomplete frame") raise EOFError("incomplete frame")
@@ -96,16 +102,17 @@ def capture_frames(
continue continue
frame_times[frame_index] = current_frame.value
frame_rate.update() frame_rate.update()
# don't lock the queue to check, just try since it should rarely be full # don't lock the queue to check, just try since it should rarely be full
try: try:
# add to the queue # add to the queue
frame_queue.put((frame_name, current_frame.value), False) frame_queue.put((frame_name, current_frame.value), False)
frame_manager.close(frame_name)
except queue.Full: except queue.Full:
# if the queue is full, skip this frame # nobody was told about this slot, so the next frame reuses it
skipped_eps.update() skipped_eps.update()
continue
frame_index = 0 if frame_index == shm_frame_count - 1 else frame_index + 1 frame_index = 0 if frame_index == shm_frame_count - 1 else frame_index + 1