From 54a7c5015ebc3c44eab4817023d1e9541edf4075 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Fri, 17 Apr 2026 17:18:04 -0500 Subject: [PATCH 1/2] fix birdseye layout calculation replace the two pass layout with a single pass pixel space algorithm --- frigate/output/birdseye.py | 186 +++++++++++++++++-------------------- 1 file changed, 83 insertions(+), 103 deletions(-) diff --git a/frigate/output/birdseye.py b/frigate/output/birdseye.py index 8b0fea6d7..21e63df44 100644 --- a/frigate/output/birdseye.py +++ b/frigate/output/birdseye.py @@ -590,112 +590,92 @@ class BirdsEyeFrameManager: ) -> Optional[list[list[Any]]]: """Calculate the optimal layout for 2+ cameras.""" - def map_layout( - camera_layout: list[list[Any]], row_height: int - ) -> tuple[int, int, Optional[list[list[Any]]]]: - """Map the calculated layout.""" - candidate_layout = [] - starting_x = 0 - x = 0 - max_width = 0 - y = 0 + def find_available_x( + current_x: int, + width: int, + reserved_ranges: list[tuple[int, int]], + max_width: int, + ) -> Optional[int]: + """Find the first horizontal slot that does not collide with reservations.""" + x = current_x - for row in camera_layout: - final_row = [] - max_width = max(max_width, x) - x = starting_x - for cameras in row: - camera_dims = self.cameras[cameras[0]]["dimensions"].copy() - camera_aspect = cameras[1] + for reserved_start, reserved_end in sorted(reserved_ranges): + if x >= reserved_end: + continue - if camera_dims[1] > camera_dims[0]: - scaled_height = int(row_height * 2) - scaled_width = int(scaled_height * camera_aspect) - starting_x = scaled_width - else: - scaled_height = row_height - scaled_width = int(scaled_height * camera_aspect) + if x + width <= reserved_start: + return x - # layout is too large - if ( - x + scaled_width > self.canvas.width - or y + scaled_height > self.canvas.height - ): - return x + scaled_width, y + scaled_height, None + x = max(x, reserved_end) - final_row.append((cameras[0], (x, y, scaled_width, scaled_height))) - x += scaled_width + if x + width <= max_width: + return x - y += row_height - candidate_layout.append(final_row) - - if max_width == 0: - max_width = x - - return max_width, y, candidate_layout - - canvas_aspect_x, canvas_aspect_y = self.canvas.get_aspect(coefficient) - camera_layout: list[list[Any]] = [] - camera_layout.append([]) - starting_x = 0 - x = starting_x - y = 0 - y_i = 0 - max_y = 0 - for camera in cameras_to_add: - camera_dims = self.cameras[camera]["dimensions"].copy() - camera_aspect_x, camera_aspect_y = self.canvas.get_camera_aspect( - camera, camera_dims[0], camera_dims[1] - ) - - if camera_dims[1] > camera_dims[0]: - portrait = True - else: - portrait = False - - if (x + camera_aspect_x) <= canvas_aspect_x: - # insert if camera can fit on current row - camera_layout[y_i].append( - ( - camera, - camera_aspect_x / camera_aspect_y, - ) - ) - - if portrait: - starting_x = camera_aspect_x - else: - max_y = max( - max_y, - camera_aspect_y, - ) - - x += camera_aspect_x - else: - # move on to the next row and insert - y += max_y - y_i += 1 - camera_layout.append([]) - x = starting_x - - if x + camera_aspect_x > canvas_aspect_x: - return None - - camera_layout[y_i].append( - ( - camera, - camera_aspect_x / camera_aspect_y, - ) - ) - x += camera_aspect_x - - if y + max_y > canvas_aspect_y: return None - row_height = int(self.canvas.height / coefficient) - total_width, total_height, standard_candidate_layout = map_layout( - camera_layout, row_height - ) + def map_layout(row_height: int) -> tuple[int, int, Optional[list[list[Any]]]]: + """Lay out cameras row by row while reserving portrait spans for the next row.""" + candidate_layout: list[list[Any]] = [] + reserved_ranges: dict[int, list[tuple[int, int]]] = {} + current_row: list[Any] = [] + row_index = 0 + row_y = 0 + row_x = 0 + max_width = 0 + max_height = 0 + + for camera in cameras_to_add: + camera_dims = self.cameras[camera]["dimensions"].copy() + camera_aspect_x, camera_aspect_y = self.canvas.get_camera_aspect( + camera, camera_dims[0], camera_dims[1] + ) + portrait = camera_dims[1] > camera_dims[0] + scaled_height = row_height * 2 if portrait else row_height + scaled_width = int(scaled_height * (camera_aspect_x / camera_aspect_y)) + + while True: + x = find_available_x( + row_x, + scaled_width, + reserved_ranges.get(row_index, []), + self.canvas.width, + ) + + if x is not None and row_y + scaled_height <= self.canvas.height: + current_row.append( + (camera, (x, row_y, scaled_width, scaled_height)) + ) + row_x = x + scaled_width + max_width = max(max_width, row_x) + max_height = max(max_height, row_y + scaled_height) + + if portrait: + reserved_ranges.setdefault(row_index + 1, []).append( + (x, row_x) + ) + + break + + if current_row: + candidate_layout.append(current_row) + current_row = [] + + row_index += 1 + row_y = row_index * row_height + row_x = 0 + + if row_y + scaled_height > self.canvas.height: + overflow_width = max(max_width, scaled_width) + overflow_height = row_y + scaled_height + return overflow_width, overflow_height, None + + if current_row: + candidate_layout.append(current_row) + + return max_width, max_height, candidate_layout + + row_height = max(1, int(self.canvas.height / coefficient)) + total_width, total_height, standard_candidate_layout = map_layout(row_height) if not standard_candidate_layout: # if standard layout didn't work @@ -704,9 +684,9 @@ class BirdsEyeFrameManager: total_width / self.canvas.width, total_height / self.canvas.height, ) - row_height = int(row_height / scale_down_percent) + row_height = max(1, int(row_height / scale_down_percent)) total_width, total_height, standard_candidate_layout = map_layout( - camera_layout, row_height + row_height ) if not standard_candidate_layout: @@ -720,8 +700,8 @@ class BirdsEyeFrameManager: 1 / (total_width / self.canvas.width), 1 / (total_height / self.canvas.height), ) - row_height = int(row_height * scale_up_percent) - _, _, scaled_layout = map_layout(camera_layout, row_height) + row_height = max(1, int(row_height * scale_up_percent)) + _, _, scaled_layout = map_layout(row_height) if scaled_layout: return scaled_layout From b5a360be3907739e89457e253c4feb08fa1db447 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Fri, 17 Apr 2026 17:18:11 -0500 Subject: [PATCH 2/2] add test --- frigate/test/test_birdseye.py | 158 +++++++++++++++++++++++++++++++++- 1 file changed, 156 insertions(+), 2 deletions(-) diff --git a/frigate/test/test_birdseye.py b/frigate/test/test_birdseye.py index 33683f5c4..a84e4c594 100644 --- a/frigate/test/test_birdseye.py +++ b/frigate/test/test_birdseye.py @@ -1,11 +1,64 @@ -"""Test camera user and password cleanup.""" +"""Tests for Birdseye canvas sizing and layout behavior.""" import unittest +from multiprocessing import Event -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): + def _build_manager( + self, camera_dimensions: dict[str, tuple[int, int]] + ) -> BirdsEyeFrameManager: + config = { + "mqtt": {"host": "mqtt"}, + "birdseye": {"width": 1280, "height": 720}, + "cameras": {}, + } + + for order, (camera, dimensions) in enumerate( + camera_dimensions.items(), start=1 + ): + config["cameras"][camera] = { + "ffmpeg": { + "inputs": [ + { + "path": f"rtsp://10.0.0.1:554/{camera}", + "roles": ["detect"], + } + ] + }, + "detect": { + "width": dimensions[0], + "height": dimensions[1], + "fps": 5, + }, + "birdseye": {"order": order}, + } + + return BirdsEyeFrameManager(FrigateConfig(**config), Event()) + + def _assert_no_overlaps( + self, layout: list[list[tuple[str, tuple[int, int, int, int]]]] + ): + rectangles = [position for row in layout for _, position in row] + + for index, rect in enumerate(rectangles): + x1, y1, width1, height1 = rect + for other in rectangles[index + 1 :]: + x2, y2, width2, height2 = other + overlap = ( + x1 < x2 + width2 + and x2 < x1 + width1 + and y1 < y2 + height2 + and y2 < y1 + height1 + ) + self.assertFalse( + overlap, + msg=f"Overlapping rectangles found: {rect} and {other}", + ) + def test_16x9(self): """Test 16x9 aspect ratio works as expected for birdseye.""" width = 1280 @@ -45,3 +98,104 @@ 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 + + def test_portrait_camera_does_not_overlap_next_row(self): + """Portrait cameras should reserve their real horizontal position on the next row.""" + manager = self._build_manager( + { + "cam_a": (1280, 720), + "cam_p": (360, 640), + "cam_b": (1280, 720), + "cam_c": (640, 480), + } + ) + + layout = manager.calculate_layout(["cam_a", "cam_p", "cam_b", "cam_c"], 3) + + self.assertIsNotNone(layout) + assert layout is not None + self._assert_no_overlaps(layout) + + cam_c = [ + position for row in layout for camera, position in row if camera == "cam_c" + ][0] + self.assertEqual(cam_c[0], 0) + + def test_portrait_reservation_only_applies_to_next_row(self): + """Portrait reservations should not push later rows after the span ends.""" + manager = self._build_manager( + { + "cam_a": (1280, 720), + "cam_p": (360, 640), + "cam_b": (1280, 720), + "cam_c": (1280, 720), + "cam_d": (1280, 720), + "cam_e": (1280, 720), + } + ) + + layout = manager.calculate_layout( + ["cam_a", "cam_p", "cam_b", "cam_c", "cam_d", "cam_e"], + 3, + ) + + self.assertIsNotNone(layout) + assert layout is not None + self._assert_no_overlaps(layout) + + cam_e = [ + position for row in layout for camera, position in row if camera == "cam_e" + ][0] + self.assertEqual(cam_e[0], 0) + + def test_multiple_portraits_reserve_distinct_ranges(self): + """Multiple portrait cameras in one row should reserve separate spans below them.""" + manager = self._build_manager( + { + "cam_a": (640, 480), + "cam_p1": (360, 640), + "cam_p2": (360, 640), + "cam_b": (640, 480), + "cam_c": (1280, 720), + "cam_d": (640, 480), + } + ) + + layout = manager.calculate_layout( + ["cam_a", "cam_p1", "cam_p2", "cam_b", "cam_c", "cam_d"], + 4, + ) + + self.assertIsNotNone(layout) + assert layout is not None + self._assert_no_overlaps(layout) + + def test_two_landscapes_then_portrait_then_two_landscapes(self): + """A portrait after two landscapes should reserve only its own tail span.""" + manager = self._build_manager( + { + "cam_a": (1280, 720), + "cam_b": (1280, 720), + "cam_p": (360, 640), + "cam_c": (1280, 720), + "cam_d": (1280, 720), + } + ) + + layout = manager.calculate_layout( + ["cam_a", "cam_b", "cam_p", "cam_c", "cam_d"], + 3, + ) + + self.assertIsNotNone(layout) + assert layout is not None + self._assert_no_overlaps(layout) + + cam_c = [ + position for row in layout for camera, position in row if camera == "cam_c" + ][0] + cam_d = [ + position for row in layout for camera, position in row if camera == "cam_d" + ][0] + self.assertEqual(cam_c[0], 0) + self.assertEqual(cam_d[0], cam_c[0] + cam_c[2])