diff --git a/frigate/output/birdseye.py b/frigate/output/birdseye.py index cd29dfe949..a38669cbf9 100644 --- a/frigate/output/birdseye.py +++ b/frigate/output/birdseye.py @@ -335,6 +335,7 @@ class BirdsEyeFrameManager: self.camera_layout: list[Any] = [] self.active_cameras: set[str] = set() + self.layout_camera_order: list[str] = [] self.last_output_time = 0.0 def add_camera(self, cam: str) -> None: @@ -372,6 +373,13 @@ class BirdsEyeFrameManager: if cam in self.cameras: del self.cameras[cam] + def sort_cameras(self, cameras: set[str]) -> list[str]: + """Sort cameras by birdseye order, falling back to name when tied.""" + return sorted( + cameras, + key=lambda camera: (self.config.cameras[camera].birdseye.order, camera), + ) + def clear_frame(self) -> None: logger.debug("Clearing the birdseye frame") self.frame[:] = self.blank_frame @@ -482,6 +490,7 @@ class BirdsEyeFrameManager: # if the layout needs to be cleared self.camera_layout = [] self.active_cameras = set() + self.layout_camera_order = [] self.clear_frame() frame_changed = True layout_changed = True @@ -500,21 +509,21 @@ class BirdsEyeFrameManager: else: reset_layout = True + sorted_active_cameras = self.sort_cameras(active_cameras) + + if not reset_layout and sorted_active_cameras != self.layout_camera_order: + logger.debug("Birdseye camera order changed") + reset_layout = True + if reset_layout: logger.debug("Resetting Birdseye layout...") self.clear_frame() self.active_cameras = active_cameras + self.layout_camera_order = sorted_active_cameras layout_changed = True # Layout is changing due to reset # this also converts added_cameras from a set to a list since we need # to pop elements in order - active_cameras_to_add = sorted( - active_cameras, - # sort cameras by order and by name if the order is the same - key=lambda active_camera: ( - self.config.cameras[active_camera].birdseye.order, - active_camera, - ), - ) + active_cameras_to_add = sorted_active_cameras if len(active_cameras) == 1: # show single camera as fullscreen camera = active_cameras_to_add[0] @@ -780,6 +789,7 @@ class BirdsEyeFrameManager: frame_changed, layout_changed = False, False self.active_cameras = set() self.camera_layout = [] + self.layout_camera_order = [] print(traceback.format_exc()) # if the frame was updated or the fps is too low, send frame diff --git a/frigate/test/test_birdseye.py b/frigate/test/test_birdseye.py index 33683f5c4b..bd70e37efe 100644 --- a/frigate/test/test_birdseye.py +++ b/frigate/test/test_birdseye.py @@ -1,8 +1,10 @@ """Test camera user and password cleanup.""" +import multiprocessing as mp import unittest -from frigate.output.birdseye import get_canvas_shape +from frigate.config import FrigateConfig +from frigate.output.birdseye import BirdsEyeFrameManager, get_canvas_shape class TestBirdseye(unittest.TestCase): @@ -45,3 +47,70 @@ class TestBirdseye(unittest.TestCase): canvas_width, canvas_height = get_canvas_shape(width, height) assert canvas_width == width # width will be the same assert canvas_height != height + + +class TestBirdseyeCameraOrder(unittest.TestCase): + """Test that birdseye reacts to camera order changes without a restart.""" + + def setUp(self): + config = { + "mqtt": {"enabled": False}, + "birdseye": {"enabled": True, "mode": "continuous"}, + "cameras": { + camera: { + "ffmpeg": { + "inputs": [ + {"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]} + ] + }, + "detect": {"height": 1080, "width": 1920, "fps": 5}, + } + for camera in ("back", "front", "side") + }, + } + self.config = FrigateConfig(**config) + self.manager = BirdsEyeFrameManager(self.config, mp.Event()) + + # mark every camera as continuously active with no frame to draw, which + # exercises the layout without needing real yuv frames + for camera_data in self.manager.cameras.values(): + camera_data["current_frame"] = None + camera_data["current_frame_time"] = 1.0 + camera_data["last_active_frame"] = 1.0 + + def layout_order(self) -> list[str]: + """Return the cameras in the order the current layout renders them.""" + return [position[0] for row in self.manager.camera_layout for position in row] + + def test_layout_uses_configured_order(self): + """Test the layout is sorted by order, then by name when tied.""" + self.config.cameras["side"].birdseye.order = 0 + self.config.cameras["back"].birdseye.order = 10 + self.config.cameras["front"].birdseye.order = 20 + + self.manager.update_frame() + + assert self.layout_order() == ["side", "back", "front"] + + def test_order_change_rebuilds_layout(self): + """Test a reorder relayouts even though the active cameras are unchanged.""" + self.manager.update_frame() + assert self.layout_order() == ["back", "front", "side"] + + # a stable active set means only an order change can reset the layout, + # which is what a settings reorder publishes to this process + self.config.cameras["side"].birdseye.order = -10 + + _, layout_changed = self.manager.update_frame() + + assert layout_changed + assert self.layout_order() == ["side", "back", "front"] + + def test_unchanged_order_keeps_layout(self): + """Test a repeat update with no order change doesn't reset the layout.""" + self.manager.update_frame() + + _, layout_changed = self.manager.update_frame() + + assert not layout_changed + assert self.layout_order() == ["back", "front", "side"] diff --git a/web/src/components/config-form/sectionExtras/BirdseyeCameraReorder.tsx b/web/src/components/config-form/sectionExtras/BirdseyeCameraReorder.tsx index b481e9a86f..f448310230 100644 --- a/web/src/components/config-form/sectionExtras/BirdseyeCameraReorder.tsx +++ b/web/src/components/config-form/sectionExtras/BirdseyeCameraReorder.tsx @@ -91,6 +91,7 @@ export default function BirdseyeCameraReorder({ try { await axios.put("config/set", { requires_restart: 0, + update_topic: "config/cameras/*/birdseye", config_data: { cameras: cameraUpdates }, }); await updateConfig();