diff --git a/docs/docs/configuration/birdseye.md b/docs/docs/configuration/birdseye.md index 6471bf4e3..8edf50583 100644 --- a/docs/docs/configuration/birdseye.md +++ b/docs/docs/configuration/birdseye.md @@ -1,6 +1,8 @@ # Birdseye -Birdseye allows a heads-up view of your cameras to see what is going on around your property / space without having to watch all cameras that may have nothing happening. Birdseye allows specific modes that intelligently show and disappear based on what you care about. +Birdseye allows a heads-up view of your cameras to see what is going on around your property / space without having to watch all cameras that may have nothing happening. Birdseye allows specific modes that intelligently show and disappear based on what you care about. + +## Birdseye Behavior ### Birdseye Modes @@ -34,6 +36,29 @@ cameras: enabled: False ``` +### Birdseye Inactivity + +By default birdseye shows all cameras that have had the configured activity in the last 30 seconds, this can be configured: + +```yaml +birdseye: + enabled: True + inactivity_threshold: 15 +``` + +## Birdseye Layout + +### Birdseye Dimensions + +The resolution and aspect ratio of birdseye can be configured. Resolution will increase the quality but does not affect the layout. Changing the aspect ratio of birdseye does affect how cameras are laid out. + +```yaml +birdseye: + enabled: True + width: 1280 + height: 720 +``` + ### Sorting cameras in the Birdseye view It is possible to override the order of cameras that are being shown in the Birdseye view. @@ -55,3 +80,27 @@ cameras: ``` *Note*: Cameras are sorted by default using their name to ensure a constant view inside Birdseye. + +### Birdseye Cameras + +It is possible to limit the number of cameras shown on birdseye at one time. When this is enabled, birdseye will show the cameras with most recent activity. There is a cooldown to ensure that cameras do not switch too frequently. + +For example, this can be configured to only show the most recently active camera. + +```yaml +birdseye: + enabled: True + layout: + max_cameras: 1 +``` + +### Birdseye Scaling + +By default birdseye tries to fit 2 cameras in each row and then double in size until a suitable layout is found. The scaling can be configured with a value between 1.0 and 5.0 depending on use case. + +```yaml +birdseye: + enabled: True + layout: + scaling_factor: 3.0 +``` diff --git a/docs/docs/configuration/reference.md b/docs/docs/configuration/reference.md index d500060a7..816bfd456 100644 --- a/docs/docs/configuration/reference.md +++ b/docs/docs/configuration/reference.md @@ -145,6 +145,14 @@ birdseye: # motion - cameras are included if motion was detected in the last 30 seconds # continuous - all cameras are included always mode: objects + # Optional: Threshold for camera activity to stop showing camera (default: shown below) + inactivity_threshold: 30 + # Optional: Configure the birdseye layout + layout: + # Optional: Scaling factor for the layout calculator (default: shown below) + scaling_factor: 2.0 + # Optional: Maximum number of cameras to show at one time, showing the most recent (default: show all cameras) + max_cameras: 1 # Optional: ffmpeg configuration # More information about presets at https://docs.frigate.video/configuration/ffmpeg_presets diff --git a/frigate/config.py b/frigate/config.py index 6760ea5e6..2e8b25700 100644 --- a/frigate/config.py +++ b/frigate/config.py @@ -528,6 +528,13 @@ class BirdseyeModeEnum(str, Enum): return list(cls)[index] +class BirdseyeLayoutConfig(FrigateBaseModel): + scaling_factor: float = Field( + default=2.0, title="Birdseye Scaling Factor", ge=1.0, le=5.0 + ) + max_cameras: Optional[int] = Field(default=None, title="Max cameras") + + class BirdseyeConfig(FrigateBaseModel): enabled: bool = Field(default=True, title="Enable birdseye view.") restream: bool = Field(default=False, title="Restream birdseye via RTSP.") @@ -539,9 +546,15 @@ class BirdseyeConfig(FrigateBaseModel): ge=1, le=31, ) + inactivity_threshold: int = Field( + default=30, title="Birdseye Inactivity Threshold", gt=0 + ) mode: BirdseyeModeEnum = Field( default=BirdseyeModeEnum.objects, title="Tracking mode." ) + layout: BirdseyeLayoutConfig = Field( + default_factory=BirdseyeLayoutConfig, title="Birdseye Layout Config" + ) # uses BaseModel because some global attributes are not available at the camera level diff --git a/frigate/output.py b/frigate/output.py index a70e5a804..465c07786 100644 --- a/frigate/output.py +++ b/frigate/output.py @@ -41,11 +41,13 @@ def get_standard_aspect_ratio(width: int, height: int) -> tuple[int, int]: (16, 9), (9, 16), (20, 10), + (16, 3), # max wide camera (16, 6), # reolink duo 2 (32, 9), # panoramic cameras (12, 9), (9, 12), (22, 15), # Amcrest, NTSC DVT + (1, 1), # fisheye ] # aspects are scaled to have common relative size known_aspects_ratios = list( map(lambda aspect: aspect[0] / aspect[1], known_aspects) @@ -74,7 +76,13 @@ def get_canvas_shape(width: int, height: int) -> tuple[int, int]: class Canvas: - def __init__(self, canvas_width: int, canvas_height: int) -> None: + def __init__( + self, + canvas_width: int, + canvas_height: int, + scaling_factor: int, + ) -> None: + self.scaling_factor = scaling_factor gcd = math.gcd(canvas_width, canvas_height) self.aspect = get_standard_aspect_ratio( (canvas_width / gcd), (canvas_height / gcd) @@ -88,7 +96,7 @@ class Canvas: return (self.aspect[0] * coefficient, self.aspect[1] * coefficient) def get_coefficient(self, camera_count: int) -> int: - return self.coefficient_cache.get(camera_count, 2) + return self.coefficient_cache.get(camera_count, self.scaling_factor) def set_coefficient(self, camera_count: int, coefficient: int) -> None: self.coefficient_cache[camera_count] = coefficient @@ -276,9 +284,13 @@ class BirdsEyeFrameManager: self.frame_shape = (height, width) self.yuv_shape = (height * 3 // 2, width) self.frame = np.ndarray(self.yuv_shape, dtype=np.uint8) - self.canvas = Canvas(width, height) + self.canvas = Canvas(width, height, config.birdseye.layout.scaling_factor) self.stop_event = stop_event self.camera_metrics = camera_metrics + self.inactivity_threshold = config.birdseye.inactivity_threshold + + if config.birdseye.layout.max_cameras: + self.last_refresh_time = 0 # initialize the frame as black and with the Frigate logo self.blank_frame = np.zeros(self.yuv_shape, np.uint8) @@ -384,16 +396,39 @@ class BirdsEyeFrameManager: def update_frame(self): """Update to a new frame for birdseye.""" - # determine how many cameras are tracking objects within the last 30 seconds - active_cameras = set( + # determine how many cameras are tracking objects within the last inactivity_threshold seconds + active_cameras: set[str] = set( [ cam for cam, cam_data in self.cameras.items() if cam_data["last_active_frame"] > 0 - and cam_data["current_frame"] - cam_data["last_active_frame"] < 30 + and cam_data["current_frame"] - cam_data["last_active_frame"] + < self.inactivity_threshold ] ) + max_cameras = self.config.birdseye.layout.max_cameras + max_camera_refresh = False + 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 + active_cameras = self.active_cameras + else: + limited_active_cameras = sorted( + active_cameras, + key=lambda active_camera: ( + self.cameras[active_camera]["current_frame"] + - self.cameras[active_camera]["last_active_frame"] + ), + ) + active_cameras = limited_active_cameras[ + : self.config.birdseye.layout.max_cameras + ] + max_camera_refresh = True + self.last_refresh_time = now + # if there are no active cameras if len(active_cameras) == 0: # if the layout is already cleared @@ -407,7 +442,18 @@ class BirdsEyeFrameManager: return True # check if we need to reset the layout because there is a different number of cameras - reset_layout = len(self.active_cameras) - len(active_cameras) != 0 + if len(self.active_cameras) - len(active_cameras) == 0: + if ( + len(self.active_cameras) == 1 + and self.active_cameras[0] == active_cameras[0] + ): + reset_layout = True + elif max_camera_refresh: + reset_layout = True + else: + reset_layout = False + else: + reset_layout = True # reset the layout if it needs to be different if reset_layout: @@ -431,17 +477,23 @@ class BirdsEyeFrameManager: camera = active_cameras_to_add[0] camera_dims = self.cameras[camera]["dimensions"].copy() scaled_width = int(self.canvas.height * camera_dims[0] / camera_dims[1]) - coefficient = ( - 1 - if scaled_width <= self.canvas.width - else self.canvas.width / scaled_width - ) + + # center camera view in canvas and ensure that it fits + if scaled_width < self.canvas.width: + coefficient = 1 + x_offset = int((self.canvas.width - scaled_width) / 2) + else: + coefficient = self.canvas.width / scaled_width + x_offset = int( + (self.canvas.width - (scaled_width * coefficient)) / 2 + ) + self.camera_layout = [ [ ( camera, ( - 0, + x_offset, 0, int(scaled_width * coefficient), int(self.canvas.height * coefficient), @@ -485,7 +537,11 @@ class BirdsEyeFrameManager: return True - def calculate_layout(self, cameras_to_add: list[str], coefficient) -> tuple[any]: + def calculate_layout( + self, + cameras_to_add: list[str], + coefficient: float, + ) -> tuple[any]: """Calculate the optimal layout for 2+ cameras.""" def map_layout(camera_layout: list[list[any]], row_height: int):