Make Birdseye clickable (#18628)

* keep track of layout changes and publish on change

* websocket hook

* clickable overlay div to navigate to full camera view
This commit is contained in:
Josh Hawkins
2025-08-16 10:20:33 -05:00
committed by Blake Blackshear
parent 937459be47
commit 40ab7d6c38
6 changed files with 175 additions and 22 deletions
+47 -19
View File
@@ -15,8 +15,9 @@ from typing import Any, Optional
import cv2
import numpy as np
from frigate.comms.inter_process import InterProcessRequestor
from frigate.config import BirdseyeModeEnum, FfmpegConfig, FrigateConfig
from frigate.const import BASE_DIR, BIRDSEYE_PIPE, INSTALL_DIR
from frigate.const import BASE_DIR, BIRDSEYE_PIPE, INSTALL_DIR, UPDATE_BIRDSEYE_LAYOUT
from frigate.util.image import (
SharedMemoryFrameManager,
copy_yuv_to_position,
@@ -380,10 +381,24 @@ class BirdsEyeFrameManager:
if mode == BirdseyeModeEnum.objects and object_box_count > 0:
return True
def update_frame(self, frame: Optional[np.ndarray] = None) -> bool:
def get_camera_coordinates(self) -> dict[str, dict[str, int]]:
"""Return the coordinates of each camera in the current layout."""
coordinates = {}
for row in self.camera_layout:
for position in row:
camera_name, (x, y, width, height) = position
coordinates[camera_name] = {
"x": x,
"y": y,
"width": width,
"height": height,
}
return coordinates
def update_frame(self, frame: Optional[np.ndarray] = None) -> tuple[bool, bool]:
"""
Update birdseye, optionally with a new frame.
When no frame is passed, check the layout and update for any disabled cameras.
Returns (frame_changed, layout_changed) to indicate if the frame or layout changed.
"""
# determine how many cameras are tracking objects within the last inactivity_threshold seconds
@@ -421,19 +436,21 @@ class BirdsEyeFrameManager:
max_camera_refresh = True
self.last_refresh_time = now
# Track if the frame changes
# Track if the frame or layout changes
frame_changed = False
layout_changed = False
# If no active cameras and layout is already empty, no update needed
if len(active_cameras) == 0:
# if the layout is already cleared
if len(self.camera_layout) == 0:
return False
return False, False
# if the layout needs to be cleared
self.camera_layout = []
self.active_cameras = set()
self.clear_frame()
frame_changed = True
layout_changed = True
else:
# Determine if layout needs resetting
if len(self.active_cameras) - len(active_cameras) == 0:
@@ -453,7 +470,7 @@ class BirdsEyeFrameManager:
logger.debug("Resetting Birdseye layout...")
self.clear_frame()
self.active_cameras = 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(
@@ -503,7 +520,7 @@ class BirdsEyeFrameManager:
# decrease scaling coefficient until height of all cameras can fit into the birdseye canvas
while calculating:
if self.stop_event.is_set():
return
return frame_changed, layout_changed
layout_candidate = self.calculate_layout(
active_cameras_to_add, coefficient
@@ -517,7 +534,7 @@ class BirdsEyeFrameManager:
logger.error(
"Error finding appropriate birdseye layout"
)
return
return frame_changed, layout_changed
calculating = False
self.canvas.set_coefficient(len(active_cameras), coefficient)
@@ -535,7 +552,7 @@ class BirdsEyeFrameManager:
if frame is not None: # Frame presence indicates a potential change
frame_changed = True
return frame_changed
return frame_changed, layout_changed
def calculate_layout(
self,
@@ -687,7 +704,11 @@ class BirdsEyeFrameManager:
motion_count: int,
frame_time: float,
frame: np.ndarray,
) -> bool:
) -> tuple[bool, bool]:
"""
Update birdseye for a specific camera with new frame data.
Returns (frame_changed, layout_changed) to indicate if the frame or layout changed.
"""
# don't process if birdseye is disabled for this camera
camera_config = self.config.cameras[camera]
force_update = False
@@ -700,7 +721,7 @@ class BirdsEyeFrameManager:
self.cameras[camera]["last_active_frame"] = 0
force_update = True
else:
return False
return False, False
# update the last active frame for the camera
self.cameras[camera]["current_frame"] = frame.copy()
@@ -712,21 +733,22 @@ class BirdsEyeFrameManager:
# limit output to 10 fps
if not force_update and (now - self.last_output_time) < 1 / 10:
return False
return False, False
try:
updated_frame = self.update_frame(frame)
frame_changed, layout_changed = self.update_frame(frame)
except Exception:
updated_frame = False
frame_changed, layout_changed = False, False
self.active_cameras = []
self.camera_layout = []
print(traceback.format_exc())
# if the frame was updated or the fps is too low, send frame
if force_update or updated_frame or (now - self.last_output_time) > 1:
if force_update or frame_changed or (now - self.last_output_time) > 1:
self.last_output_time = now
return True
return False
return True, layout_changed
return False, layout_changed
class Birdseye:
@@ -755,6 +777,7 @@ class Birdseye:
self.birdseye_manager = BirdsEyeFrameManager(config, stop_event)
self.frame_manager = SharedMemoryFrameManager()
self.stop_event = stop_event
self.requestor = InterProcessRequestor()
if config.birdseye.restream:
self.birdseye_buffer = self.frame_manager.create(
@@ -789,15 +812,20 @@ class Birdseye:
frame_time: float,
frame: np.ndarray,
) -> None:
if self.birdseye_manager.update(
frame_changed, frame_layout_changed = self.birdseye_manager.update(
camera,
len([o for o in current_tracked_objects if not o["stationary"]]),
len(motion_boxes),
frame_time,
frame,
):
)
if frame_changed:
self.__send_new_frame()
if frame_layout_changed:
coordinates = self.birdseye_manager.get_camera_coordinates()
self.requestor.send_data(UPDATE_BIRDSEYE_LAYOUT, coordinates)
def stop(self) -> None:
self.converter.join()
self.broadcaster.join()