From f1d1748fb362d5f4bfc61e2f821d96ce87368f11 Mon Sep 17 00:00:00 2001 From: Niranjan Date: Fri, 9 Oct 2026 18:16:16 +0530 Subject: [PATCH] Fix Birdseye rapid camera switching when max_cameras is set (#24591) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix Birdseye rapid camera switching in single-camera view (#10845) Add a configurable min_camera_hold (default 5s) to prevent Birdseye from rapidly flipping between cameras when multiple have simultaneous activity. Also fix the max_cameras cooldown which was bypassed whenever more cameras were active than the configured limit. Co-Authored-By: Claude Opus 4.6 * Remove min_camera_hold config, use hardcoded CAMERA_HOLD_SECONDS constant Replace the configurable min_camera_hold field with a module-level CAMERA_HOLD_SECONDS = 5 constant to keep behavior simple and avoid adding config complexity. Co-Authored-By: Claude Opus 4.6 * Fix max_cameras hold blocking count changes, add count-increase test The >= guard could prevent cameras from appearing when the displayed count was below max_cameras. Now the hold only applies when the limited count matches the currently displayed count, so count changes are always immediate. Adds a dedicated test for this case. Co-Authored-By: Claude Opus 4.6 * Apply reviewer feedback: simplify max_cameras hold, remove single-camera hold - Use hawkeye217's suggested condition for max_cameras cooldown: only hold when currently showing exactly max_cameras and there are at least max_cameras active - Remove single-camera hold entirely — without max_cameras set, the hold only triggers when one camera expires as another activates, keeping the inactive camera visible unnecessarily - Remove last_layout_change_time (no longer used) - Remove single-camera hold tests (feature removed) Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- frigate/output/birdseye.py | 10 ++++- frigate/test/test_birdseye.py | 74 +++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 2 deletions(-) diff --git a/frigate/output/birdseye.py b/frigate/output/birdseye.py index 877f5133ca..4dfbb74172 100644 --- a/frigate/output/birdseye.py +++ b/frigate/output/birdseye.py @@ -30,6 +30,9 @@ from frigate.util.image import ( logger = logging.getLogger(__name__) +# Minimum seconds to hold the current camera before switching to a different one +CAMERA_HOLD_SECONDS = 5 + @dataclass(frozen=True, slots=True) class BirdseyeActivity: @@ -496,8 +499,11 @@ class BirdsEyeFrameManager: if max_cameras: now = datetime.datetime.now().timestamp() - if len(active_cameras) == max_cameras and now - self.last_refresh_time < 10: - # don't refresh cameras too often + if ( + len(self.active_cameras) == max_cameras + and len(active_cameras) >= max_cameras + and now - self.last_refresh_time < CAMERA_HOLD_SECONDS + ): active_cameras = self.active_cameras else: limited_active_cameras = sorted( diff --git a/frigate/test/test_birdseye.py b/frigate/test/test_birdseye.py index 372ed5a8a1..12b685afae 100644 --- a/frigate/test/test_birdseye.py +++ b/frigate/test/test_birdseye.py @@ -500,6 +500,80 @@ class TestBirdseyeLiveActivity(unittest.TestCase): assert self.manager.active_cameras == {"front"} +class TestBirdseyeCameraHold(unittest.TestCase): + """Test that CAMERA_HOLD_SECONDS prevents rapid camera switching.""" + + def setUp(self): + config = { + "mqtt": {"enabled": False}, + "birdseye": { + "enabled": True, + "modes": ["motion"], + "inactivity_threshold": 30, + }, + "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") + }, + } + self.config = FrigateConfig(**config) + self.manager = BirdsEyeFrameManager(self.config, mp.Event()) + + for camera_data in self.manager.cameras.values(): + camera_data["current_frame"] = None + camera_data["current_frame_time"] = 100.0 + camera_data["last_active_frame"] = 0.0 + camera_data["live_active"] = False + + def test_max_cameras_cooldown_applies_when_more_active_than_max(self): + """The max_cameras cooldown should apply even when more cameras are active than max.""" + self.config.birdseye.layout.max_cameras = 1 + self.manager.cameras["front"]["last_active_frame"] = 95.0 + self.manager.cameras["front"]["current_frame_time"] = 100.0 + self.manager.update_frame() + assert "front" in self.manager.active_cameras + + self.manager.cameras["back"]["last_active_frame"] = 99.0 + self.manager.cameras["back"]["current_frame_time"] = 100.0 + self.manager.update_frame() + + assert "front" in self.manager.active_cameras + + def test_camera_count_change_ignores_hold(self): + """Adding a camera (count change) should not be blocked by the hold period.""" + self.manager.cameras["front"]["last_active_frame"] = 95.0 + self.manager.update_frame() + assert self.manager.active_cameras == {"front"} + + self.manager.cameras["back"]["last_active_frame"] = 99.0 + self.manager.cameras["back"]["current_frame_time"] = 100.0 + self.manager.cameras["front"]["last_active_frame"] = 95.0 + self.manager.update_frame() + + assert self.manager.active_cameras == {"front", "back"} + + def test_max_cameras_count_increase_not_blocked_by_hold(self): + """With max_cameras=2 showing 1, a second active camera must appear immediately.""" + self.config.birdseye.layout.max_cameras = 2 + self.manager.cameras["front"]["last_active_frame"] = 95.0 + self.manager.cameras["front"]["current_frame_time"] = 100.0 + self.manager.update_frame() + assert self.manager.active_cameras == {"front"} + + self.manager.cameras["back"]["last_active_frame"] = 99.0 + self.manager.cameras["back"]["current_frame_time"] = 100.0 + self.manager.update_frame() + + assert self.manager.active_cameras == {"front", "back"} + + class TestBirdseyeModePayload(unittest.TestCase): """Test the MQTT payload contract for Birdseye activity modes."""