fix birdseye reordering widget in settings

cameras are only sorted while the layout is built, so a config change that reorders them needs a fresh layout even when the active set is unchanged, which is always the case in continuous mode
This commit is contained in:
Josh Hawkins
2026-07-15 07:09:01 -05:00
parent a8eca68438
commit 0e2e95714f
3 changed files with 89 additions and 9 deletions
+18 -8
View File
@@ -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
+70 -1
View File
@@ -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"]
@@ -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();