diff --git a/frigate/api/app.py b/frigate/api/app.py index cc5adc6f65..179c7fb90a 100644 --- a/frigate/api/app.py +++ b/frigate/api/app.py @@ -750,6 +750,33 @@ def _config_set_in_memory(request: Request, body: AppConfigSetBody) -> JSONRespo settings, ) + # detect resize also republishes motion + objects so other + # processes pick up the rebuilt masks, and fires refresh so + # the camera maintainer recycles the camera process to pick + # up the new ffmpeg cmd / SHM sizing + if field == "detect": + cam_cfg = config.cameras.get(camera) + if cam_cfg is not None: + if cam_cfg.motion is not None: + request.app.config_publisher.publish_update( + CameraConfigUpdateTopic( + CameraConfigUpdateEnum.motion, camera + ), + cam_cfg.motion, + ) + request.app.config_publisher.publish_update( + CameraConfigUpdateTopic( + CameraConfigUpdateEnum.objects, camera + ), + cam_cfg.objects, + ) + request.app.config_publisher.publish_update( + CameraConfigUpdateTopic( + CameraConfigUpdateEnum.refresh, camera + ), + cam_cfg, + ) + return JSONResponse( content={"success": True, "message": "Config applied in-memory"}, status_code=200, diff --git a/frigate/camera/maintainer.py b/frigate/camera/maintainer.py index c4ddc51e89..ea8df7bff0 100644 --- a/frigate/camera/maintainer.py +++ b/frigate/camera/maintainer.py @@ -14,6 +14,7 @@ from frigate.config.camera.updater import ( CameraConfigUpdateEnum, CameraConfigUpdateSubscriber, ) +from frigate.const import REPLAY_CAMERA_PREFIX from frigate.models import Regions from frigate.util.builtin import empty_and_close_queue from frigate.util.image import SharedMemoryFrameManager, UntrackedSharedMemory @@ -50,6 +51,7 @@ class CameraMaintainer(threading.Thread): [ CameraConfigUpdateEnum.add, CameraConfigUpdateEnum.remove, + CameraConfigUpdateEnum.refresh, ], ) self.shm_count = self.__calculate_shm_frame_count() @@ -202,6 +204,25 @@ class CameraMaintainer(threading.Thread): capture_process.terminate() capture_process.join() + def __unlink_camera_frame_slots(self, camera: str) -> None: + """Drop the camera's per-frame YUV SHM segments from this + process's frame_manager and unlink them at the OS level. + + Safe to call after the camera's capture/processor subprocesses + have been joined — they no longer hold mappings, so unlink frees + the segments immediately. Other long-lived processes that opened + these slots will continue using their existing mappings until + they call frame_manager.get with a shape that no longer fits + (the get path drops and reopens stale refs). + """ + prefix = f"{camera}_frame" + names = [n for n in list(self.frame_manager.shm_store) if n.startswith(prefix)] + for name in names: + try: + self.frame_manager.delete(name) + except Exception as exc: + logger.debug("Could not unlink SHM %s: %s", name, exc) + def __stop_camera_process(self, camera: str) -> None: camera_process = self.camera_processes.get(camera) if camera_process is not None: @@ -253,12 +274,45 @@ class CameraMaintainer(threading.Thread): for camera in updated_cameras: self.__stop_camera_capture_process(camera) self.__stop_camera_process(camera) + self.__unlink_camera_frame_slots(camera) self.capture_processes.pop(camera, None) self.camera_processes.pop(camera, None) self.camera_stop_events.pop(camera, None) self.region_grids.pop(camera, None) self.camera_metrics.pop(camera, None) self.ptz_metrics.pop(camera, None) + elif update_type == CameraConfigUpdateEnum.refresh.name: + # Recycle replay cameras so detect width/height/fps + # propagate through ffmpeg args, SHM sizing, and the + # region grid. Regular cameras detect change still + # requires a full restart. + for camera in updated_cameras: + if not camera.startswith(REPLAY_CAMERA_PREFIX): + continue + + new_config = self.update_subscriber.camera_configs.get(camera) + if new_config is None: + # remove arrived in the same batch + continue + + if ( + camera not in self.camera_processes + and camera not in self.capture_processes + ): + continue + + # rebuild ffmpeg cmds on the shared config so the + # new subprocesses spawn with current args + new_config.recreate_ffmpeg_cmds() + + self.__stop_camera_capture_process(camera) + self.__stop_camera_process(camera) + self.__unlink_camera_frame_slots(camera) + self.capture_processes.pop(camera, None) + self.camera_processes.pop(camera, None) + + self.__start_camera_processor(camera, new_config, runtime=True) + self.__start_camera_capture(camera, new_config, runtime=True) # ensure the capture processes are done for camera in self.capture_processes.keys(): diff --git a/frigate/camera/state.py b/frigate/camera/state.py index 8d0b586022..f35a3eaa56 100644 --- a/frigate/camera/state.py +++ b/frigate/camera/state.py @@ -45,6 +45,7 @@ class CameraState: self.frame_cache: dict[float, dict[str, Any]] = {} self.zone_objects: defaultdict[str, list[Any]] = defaultdict(list) self._current_frame = np.zeros(self.camera_config.frame_shape_yuv, np.uint8) + self._last_frame_shape: tuple[int, int] = self.camera_config.frame_shape_yuv self.current_frame_lock = threading.Lock() self.current_frame_time = 0.0 self.motion_boxes: list[tuple[int, int, int, int]] = [] @@ -303,6 +304,42 @@ class CameraState: def on(self, event_type: str, callback: Callable[..., Any]) -> None: self.callbacks[event_type].append(callback) + def _discard_stale_resolution_state( + self, current_detections: dict[str, dict[str, Any]] + ) -> bool: + """Drop tracked state when the camera's detect resolution has + changed, and signal the caller to skip this batch if it contains + out-of-bounds boxes from the pre-recycle detect process. + + Returns True when the batch should be skipped entirely. + """ + # detect resolution changed — drop tracked state so old-grid + # boxes don't leak through end-callbacks + current_shape = self.camera_config.frame_shape_yuv + if current_shape != self._last_frame_shape: + logger.debug( + f"{self.name}: detect resolution changed {self._last_frame_shape} -> {current_shape}, dropping tracked state" + ) + with self.current_frame_lock: + self.tracked_objects.clear() + self.motion_boxes = [] + self.regions = [] + self._last_frame_shape = current_shape + + # drop in-flight batches from the pre-recycle detect process + # whose boxes exceed the current detect resolution + detect = self.camera_config.detect + if detect.width is not None and detect.height is not None: + for obj in current_detections.values(): + box = obj.get("box") + if box and (box[2] > detect.width or box[3] > detect.height): + logger.debug( + f"{self.name}: dropping stale-resolution detection batch (box {box} exceeds {detect.width}x{detect.height})" + ) + return True + + return False + def update( self, frame_name: str, @@ -311,6 +348,9 @@ class CameraState: motion_boxes: list[tuple[int, int, int, int]], regions: list[tuple[int, int, int, int]], ) -> None: + if self._discard_stale_resolution_state(current_detections): + return + current_frame = self.frame_manager.get( frame_name, self.camera_config.frame_shape_yuv ) @@ -332,14 +372,18 @@ class CameraState: current_detections[id], ) - # add initial frame to frame cache - logger.debug( - f"{self.name}: New object, adding {frame_time} to frame cache for {id}" - ) - self.frame_cache[frame_time] = { - "frame": np.copy(current_frame), # type: ignore[arg-type] - "object_id": id, - } + # Skip caching when the frame buffer isn't readable — e.g. + # frame_manager.get returned None because the SHM segment was + # unlinked or hasn't been recreated yet during a camera + # add/remove cycle. + if current_frame is not None: + logger.debug( + f"{self.name}: New object, adding {frame_time} to frame cache for {id}" + ) + self.frame_cache[frame_time] = { + "frame": np.copy(current_frame), + "object_id": id, + } # save initial thumbnail data and best object thumbnail_data = { diff --git a/frigate/config/camera/updater.py b/frigate/config/camera/updater.py index 95092da08b..b475f42157 100644 --- a/frigate/config/camera/updater.py +++ b/frigate/config/camera/updater.py @@ -26,6 +26,7 @@ class CameraConfigUpdateEnum(str, Enum): object_genai = "object_genai" onvif = "onvif" record = "record" + refresh = "refresh" # signals the camera maintainer to recycle the camera process remove = "remove" # for removing a camera review = "review" review_genai = "review_genai" @@ -84,8 +85,8 @@ class CameraConfigUpdateSubscriber: self, camera: str, update_type: CameraConfigUpdateEnum, updated_config: Any ) -> None: if update_type == CameraConfigUpdateEnum.add: - self.config.cameras[camera] = updated_config - self.camera_configs[camera] = updated_config + shared = self.config.cameras.setdefault(camera, updated_config) + self.camera_configs[camera] = shared return elif update_type == CameraConfigUpdateEnum.remove: self.config.cameras.pop(camera, None) diff --git a/frigate/test/test_camera_maintainer.py b/frigate/test/test_camera_maintainer.py new file mode 100644 index 0000000000..c03d965784 --- /dev/null +++ b/frigate/test/test_camera_maintainer.py @@ -0,0 +1,79 @@ +"""Tests for CameraMaintainer SHM cleanup on camera remove. + +Regression coverage for the case where a camera is removed and then a +new camera is added with the same name. Without unlinking the per-frame +YUV SHM slots, the maintainer's frame_manager.create call hits +FileExistsError and falls back to reopening the existing segment at the +*old* size, which the new ffmpeg process then writes mismatched-size +frames into. +""" + +import unittest +from unittest.mock import MagicMock, patch + +from frigate.camera.maintainer import CameraMaintainer + + +class TestMaintainerUnlinkFrameSlotsOnRemove(unittest.TestCase): + def _make_maintainer(self) -> CameraMaintainer: + """Build a maintainer without invoking __init__ (avoids needing real + FrigateConfig, queues, multiprocessing manager, etc.). We're only + exercising the SHM-cleanup helper, so the surrounding init is + irrelevant.""" + maintainer = CameraMaintainer.__new__(CameraMaintainer) + maintainer.frame_manager = MagicMock() + return maintainer + + def test_unlinks_only_segments_with_matching_prefix(self) -> None: + maintainer = self._make_maintainer() + maintainer.frame_manager.shm_store = { + "front_frame0": object(), + "front_frame1": object(), + "front_frame2": object(), + # Different camera; must not be touched. + "side_frame0": object(), + # Detector input/output buffers are sized by the model and + # cached by the long-lived DetectorRunner — must not be + # touched even when their owning camera is removed. + "front": object(), + "out-front": object(), + } + + # __name-mangled access from outside the class. + maintainer._CameraMaintainer__unlink_camera_frame_slots("front") + + deleted = [c.args[0] for c in maintainer.frame_manager.delete.call_args_list] + self.assertEqual( + sorted(deleted), + ["front_frame0", "front_frame1", "front_frame2"], + ) + + def test_handles_camera_with_no_slots(self) -> None: + """Cameras that were removed before any frame slot was ever + created (e.g. cancelled during preparing_clip) should be a no-op.""" + maintainer = self._make_maintainer() + maintainer.frame_manager.shm_store = {"other_frame0": object()} + + maintainer._CameraMaintainer__unlink_camera_frame_slots("front") + + maintainer.frame_manager.delete.assert_not_called() + + def test_swallows_delete_errors(self) -> None: + """Unlink failures shouldn't abort the remove loop — best-effort.""" + maintainer = self._make_maintainer() + maintainer.frame_manager.shm_store = { + "front_frame0": object(), + "front_frame1": object(), + } + maintainer.frame_manager.delete.side_effect = OSError("simulated") + + # Both slots are attempted; the OSError on the first doesn't + # prevent the second from being tried. + with patch("frigate.camera.maintainer.logger"): + maintainer._CameraMaintainer__unlink_camera_frame_slots("front") + + self.assertEqual(maintainer.frame_manager.delete.call_count, 2) + + +if __name__ == "__main__": + unittest.main() diff --git a/frigate/test/test_shared_memory_frame_manager.py b/frigate/test/test_shared_memory_frame_manager.py new file mode 100644 index 0000000000..63c96f732d --- /dev/null +++ b/frigate/test/test_shared_memory_frame_manager.py @@ -0,0 +1,156 @@ +"""Tests for SharedMemoryFrameManager cache invalidation. + +Covers the case where a SHM segment is unlinked and recreated at a +different size across a camera add/remove cycle while a long-lived +in-process cache (e.g. TrackedObjectProcessor) still holds a ref to +the old, smaller segment. +""" + +import unittest +from types import SimpleNamespace +from unittest.mock import patch + +import numpy as np + +from frigate.util.image import SharedMemoryFrameManager + + +def _fake_shm(size: int) -> SimpleNamespace: + """A minimal stand-in for UntrackedSharedMemory with .size and .buf.""" + return SimpleNamespace(size=size, buf=bytearray(size), close=lambda: None) + + +class TestSharedMemoryFrameManagerGet(unittest.TestCase): + def test_get_reopens_when_cached_segment_is_smaller_than_shape(self) -> None: + """A cached ref to an older smaller segment must be dropped and the + current (correctly sized) segment reopened. Without this, np.ndarray + would raise "buffer is too small for requested array" when the + in-memory cache pointed at an old SHM after a same-name resize.""" + manager = SharedMemoryFrameManager() + + small = _fake_shm(size=100) + current = _fake_shm(size=2_500) + manager.shm_store["cam_frame0"] = small + + with patch("frigate.util.image.UntrackedSharedMemory", return_value=current): + arr = manager.get("cam_frame0", (50, 50)) + + self.assertIsNotNone(arr) + self.assertEqual(arr.shape, (50, 50)) + self.assertIs(manager.shm_store["cam_frame0"], current) + + def test_get_reopens_when_cached_segment_is_larger_than_shape(self) -> None: + """Symmetric to the smaller-cache case: when detect resolution drops, + the SHM is unlinked and recreated at a smaller size. A cached ref to + the old, larger segment still satisfies any size check but points at + an orphaned inode whose stale bytes get reinterpreted at the new + shape — producing miscolored, distorted YUV frames downstream. Drop + the cache so we reopen by name and bind to the current segment.""" + manager = SharedMemoryFrameManager() + + old_large = _fake_shm(size=10_000) + current = _fake_shm(size=2_500) + manager.shm_store["cam_frame0"] = old_large + + with patch("frigate.util.image.UntrackedSharedMemory", return_value=current): + arr = manager.get("cam_frame0", (50, 50)) + + self.assertIsNotNone(arr) + self.assertEqual(arr.shape, (50, 50)) + self.assertIs(manager.shm_store["cam_frame0"], current) + + def test_get_keeps_cached_segment_when_size_matches(self) -> None: + """Don't pay the reopen cost when the cached ref is the right size.""" + manager = SharedMemoryFrameManager() + + cached = _fake_shm(size=2_500) + manager.shm_store["cam_frame0"] = cached + + with patch("frigate.util.image.UntrackedSharedMemory") as untracked_shm_cls: + arr = manager.get("cam_frame0", (50, 50)) + untracked_shm_cls.assert_not_called() + + self.assertIsNotNone(arr) + self.assertIs(manager.shm_store["cam_frame0"], cached) + + def test_get_opens_fresh_when_no_cache_entry(self) -> None: + manager = SharedMemoryFrameManager() + fresh = _fake_shm(size=2_500) + + with patch("frigate.util.image.UntrackedSharedMemory", return_value=fresh): + arr = manager.get("cam_frame0", (50, 50)) + + self.assertIsNotNone(arr) + self.assertIs(manager.shm_store["cam_frame0"], fresh) + + def test_get_returns_none_when_segment_missing(self) -> None: + manager = SharedMemoryFrameManager() + + with patch( + "frigate.util.image.UntrackedSharedMemory", + side_effect=FileNotFoundError, + ): + arr = manager.get("cam_frame0", (50, 50)) + + self.assertIsNone(arr) + + def test_get_returns_none_when_reopened_segment_is_still_too_small(self) -> None: + """Race during a same-name SHM recreate: cache is stale, we reopen + by name, but the maintainer hasn't allocated the new segment yet — + the reopened ref is also too small. Skip the frame (return None) + rather than crash on np.ndarray.""" + manager = SharedMemoryFrameManager() + + small_cached = _fake_shm(size=100) + still_small_after_reopen = _fake_shm(size=100) + manager.shm_store["cam_frame0"] = small_cached + + with patch( + "frigate.util.image.UntrackedSharedMemory", + return_value=still_small_after_reopen, + ): + arr = manager.get("cam_frame0", (50, 50)) + + self.assertIsNone(arr) + # Don't cache the too-small reopened ref — next call will re-open + # once the maintainer has finished recreating the segment. + self.assertNotIn("cam_frame0", manager.shm_store) + + def test_get_handles_n_dimensional_shape(self) -> None: + """np.prod must be used (not raw multiplication) for tuple shapes.""" + manager = SharedMemoryFrameManager() + # YUV-shaped frame: (height * 3/2, width) for 1920x1080 = 3,110,400 + big_enough = _fake_shm(size=3_110_400) + manager.shm_store["cam_frame0"] = big_enough + + with patch("frigate.util.image.UntrackedSharedMemory") as untracked_shm_cls: + arr = manager.get("cam_frame0", (1620, 1920)) + untracked_shm_cls.assert_not_called() + + self.assertIsNotNone(arr) + self.assertEqual(arr.shape, (1620, 1920)) + + +class TestSharedMemoryFrameManagerGetRecreatesLargerSegment(unittest.TestCase): + """End-to-end-style: simulates the full unlink-and-recreate cycle.""" + + def test_segment_grows_then_get_succeeds(self) -> None: + manager = SharedMemoryFrameManager() + + # Phase 1: existing camera at 320x240 YUV — 320 * 240 * 1.5 = 115_200 + small = _fake_shm(size=115_200) + manager.shm_store["cam_frame0"] = small + arr_small = np.ndarray((360, 320), dtype=np.uint8, buffer=small.buf) + self.assertEqual(arr_small.shape, (360, 320)) + + # Phase 2: restart at 1920x1080 — new SHM segment, larger size. + large = _fake_shm(size=3_110_400) + with patch("frigate.util.image.UntrackedSharedMemory", return_value=large): + arr_large = manager.get("cam_frame0", (1620, 1920)) + + self.assertIsNotNone(arr_large) + self.assertEqual(arr_large.shape, (1620, 1920)) + + +if __name__ == "__main__": + unittest.main() diff --git a/frigate/util/config.py b/frigate/util/config.py index e6e3f09666..5e5d2a0fc8 100644 --- a/frigate/util/config.py +++ b/frigate/util/config.py @@ -788,6 +788,34 @@ def apply_section_update(camera_config, section: str, update: dict) -> Optional[ ) camera_config.objects = new_objects + elif section == "detect": + # apply detect first so frame_shape reflects the new resolution + # before we rebuild mask-dependent runtime configs below + merged = deep_merge(current.model_dump(), update, override=True) + camera_config.detect = current.__class__.model_validate(merged) + + new_frame_shape = camera_config.frame_shape + + # rebuild motion's rasterized_mask at the new frame_shape + if camera_config.motion is not None: + camera_config.motion = RuntimeMotionConfig( + frame_shape=new_frame_shape, + **camera_config.motion.model_dump(exclude_unset=True), + ) + + # rebuild per-object filter masks at the new frame_shape + for obj_name, filt in camera_config.objects.filters.items(): + merged_mask = dict(filt.mask) + if camera_config.objects.mask: + for gid, gmask in camera_config.objects.mask.items(): + merged_mask[f"global_{gid}"] = gmask + + camera_config.objects.filters[obj_name] = RuntimeFilterConfig( + frame_shape=new_frame_shape, + mask=merged_mask, + **filt.model_dump(exclude_unset=True, exclude={"mask", "raw_mask"}), + ) + else: merged = deep_merge(current.model_dump(), update, override=True) setattr(camera_config, section, current.__class__.model_validate(merged)) diff --git a/frigate/util/image.py b/frigate/util/image.py index 2d2133c6b8..d2832d97a0 100644 --- a/frigate/util/image.py +++ b/frigate/util/image.py @@ -1089,10 +1089,25 @@ class SharedMemoryFrameManager(FrameManager): def get(self, name: str, shape) -> Optional[np.ndarray]: try: - if name in self.shm_store: - shm = self.shm_store[name] - else: + required = int(np.prod(shape)) + shm = self.shm_store.get(name) + if shm is not None and shm.size != required: + # stale cached ref from a same-name recreate — drop and reopen + try: + shm.close() + except Exception: + pass + self.shm_store.pop(name, None) + shm = None + if shm is None: shm = UntrackedSharedMemory(name=name) + if shm.size != required: + # mid-recreate: OS segment doesn't match shape yet; skip + try: + shm.close() + except Exception: + pass + return None self.shm_store[name] = shm return np.ndarray(shape, dtype=np.uint8, buffer=shm.buf) except FileNotFoundError: diff --git a/web/src/components/config-form/section-configs/detect.ts b/web/src/components/config-form/section-configs/detect.ts index 5bbd219822..964a802d3f 100644 --- a/web/src/components/config-form/section-configs/detect.ts +++ b/web/src/components/config-form/section-configs/detect.ts @@ -72,6 +72,25 @@ const detect: SectionConfigOverrides = { "max_disappeared", ], }, + replay: { + restartRequired: [], + fieldOrder: ["width", "height", "fps"], + fieldGroups: { + resolution: ["width", "height", "fps"], + }, + hiddenFields: [ + "enabled", + "enabled_in_config", + "min_initialized", + "max_disappeared", + "annotation_offset", + "stationary", + "interval", + "threshold", + "max_frames", + ], + advancedFields: [], + }, }; export default detect; diff --git a/web/src/components/config-form/sections/BaseSection.tsx b/web/src/components/config-form/sections/BaseSection.tsx index 5bacd2d80c..4fbdf76625 100644 --- a/web/src/components/config-form/sections/BaseSection.tsx +++ b/web/src/components/config-form/sections/BaseSection.tsx @@ -1253,7 +1253,12 @@ export function ConfigSection({
- {title} + + {title} + {showOverrideIndicator && effectiveLevel === "camera" && (profileOverridesSection || isOverridden) && diff --git a/web/src/pages/Replay.tsx b/web/src/pages/Replay.tsx index b2253130bc..a775ee4312 100644 --- a/web/src/pages/Replay.tsx +++ b/web/src/pages/Replay.tsx @@ -354,6 +354,18 @@ export default function Replay() {
) : (
+