Compare commits

...
Author SHA1 Message Date
f1d1748fb3 Fix Birdseye rapid camera switching when max_cameras is set (#24591)
CI / AMD64 Build (push) Waiting to run
CI / AMD64 Smoke Test (push) Blocked by required conditions
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-09 06:46:16 -06:00
2 changed files with 82 additions and 2 deletions
+8 -2
View File
@@ -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(
+74
View File
@@ -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."""