Files
frigate/frigate/output/birdseye.py
T

911 lines
31 KiB
Python
Raw Normal View History

"""Handle outputting birdseye frames via jsmpeg and go2rtc."""
2021-06-04 07:09:16 -05:00
import datetime
2021-06-13 12:32:12 -05:00
import glob
2021-06-11 19:08:15 -05:00
import logging
2023-06-16 06:35:36 -06:00
import math
2022-12-31 07:54:10 -07:00
import os
import queue
2021-05-30 06:45:37 -05:00
import subprocess as sp
import threading
2023-06-12 04:06:02 -06:00
import traceback
2026-03-25 12:53:19 -06:00
from multiprocessing.synchronize import Event as MpEvent
2026-07-06 09:28:02 -08:00
from typing import Any
2021-05-30 06:45:37 -05:00
2021-06-11 19:08:15 -05:00
import cv2
2021-06-04 07:09:16 -05:00
import numpy as np
2021-05-30 06:45:37 -05:00
2025-06-08 13:06:17 -05:00
from frigate.comms.inter_process import InterProcessRequestor
2024-09-13 14:14:51 -06:00
from frigate.config import BirdseyeModeEnum, FfmpegConfig, FrigateConfig
2025-06-08 13:06:17 -05:00
from frigate.const import BASE_DIR, BIRDSEYE_PIPE, INSTALL_DIR, UPDATE_BIRDSEYE_LAYOUT
2026-04-23 13:27:49 -05:00
from frigate.output.ws_auth import ws_has_camera_access
2023-07-06 08:28:50 -06:00
from frigate.util.image import (
SharedMemoryFrameManager,
copy_yuv_to_position,
get_yuv_crop,
)
2021-06-11 19:08:15 -05:00
logger = logging.getLogger(__name__)
def get_standard_aspect_ratio(width: int, height: int) -> tuple[int, int]:
"""Ensure that only standard aspect ratios are used."""
2024-05-20 15:37:56 +02:00
# it is important that all ratios have the same scale
known_aspects = [
(16, 9),
(9, 16),
2023-10-26 16:23:39 -06:00
(20, 10),
2024-02-10 10:55:13 -07:00
(16, 3), # max wide camera
2023-10-26 16:23:39 -06:00
(16, 6), # reolink duo 2
(32, 9), # panoramic cameras
(12, 9),
(9, 12),
(22, 15), # Amcrest, NTSC DVT
2024-02-10 10:55:13 -07:00
(1, 1), # fisheye
] # aspects are scaled to have common relative size
known_aspects_ratios = list(
map(lambda aspect: aspect[0] / aspect[1], known_aspects)
)
closest = min(
known_aspects_ratios,
key=lambda x: abs(x - (width / height)),
)
return known_aspects[known_aspects_ratios.index(closest)]
def get_canvas_shape(width: int, height: int) -> tuple[int, int]:
"""Get birdseye canvas shape."""
canvas_width = width
canvas_height = height
a_w, a_h = get_standard_aspect_ratio(width, height)
if round(a_w / a_h, 2) != round(width / height, 2):
canvas_width = int(width // 4 * 4)
canvas_height = int((canvas_width / a_w * a_h) // 4 * 4)
2026-05-12 11:20:39 -05:00
logger.error(
f"Birdseye resolution {width}x{height} is not a supported aspect ratio "
f"and may cause visual distortion; falling back to {canvas_width}x{canvas_height}. "
f"Set width and height to a supported aspect ratio (16:9, 20:10, 16:6, 32:9, 12:9, 22:15, 9:16, 9:12, 16:3, or 1:1)"
)
return (canvas_width, canvas_height)
class Canvas:
2024-02-10 10:55:13 -07:00
def __init__(
self,
canvas_width: int,
canvas_height: int,
2026-03-25 12:53:19 -06:00
scaling_factor: float,
2024-02-10 10:55:13 -07:00
) -> None:
self.scaling_factor = scaling_factor
gcd = math.gcd(canvas_width, canvas_height)
self.aspect = get_standard_aspect_ratio(
2026-03-25 12:53:19 -06:00
int(canvas_width / gcd), int(canvas_height / gcd)
)
self.width = canvas_width
2026-03-25 12:53:19 -06:00
self.height: float = (self.width * self.aspect[1]) / self.aspect[0]
self.coefficient_cache: dict[int, float] = {}
self.aspect_cache: dict[str, tuple[int, int]] = {}
2026-03-25 12:53:19 -06:00
def get_aspect(self, coefficient: float) -> tuple[float, float]:
return (self.aspect[0] * coefficient, self.aspect[1] * coefficient)
2026-03-25 12:53:19 -06:00
def get_coefficient(self, camera_count: int) -> float:
2024-02-10 10:55:13 -07:00
return self.coefficient_cache.get(camera_count, self.scaling_factor)
2026-03-25 12:53:19 -06:00
def set_coefficient(self, camera_count: int, coefficient: float) -> None:
self.coefficient_cache[camera_count] = coefficient
def get_camera_aspect(
self, cam_name: str, camera_width: int, camera_height: int
) -> tuple[int, int]:
cached = self.aspect_cache.get(cam_name)
if cached:
return cached
gcd = math.gcd(camera_width, camera_height)
camera_aspect = get_standard_aspect_ratio(
2026-03-25 12:53:19 -06:00
int(camera_width / gcd), int(camera_height / gcd)
)
self.aspect_cache[cam_name] = camera_aspect
return camera_aspect
2023-11-07 16:24:56 -07:00
class FFMpegConverter(threading.Thread):
2022-12-31 07:54:10 -07:00
def __init__(
self,
2024-09-13 14:14:51 -06:00
ffmpeg: FfmpegConfig,
2023-11-07 16:24:56 -07:00
input_queue: queue.Queue,
2026-03-25 12:53:19 -06:00
stop_event: MpEvent,
2022-12-31 07:54:10 -07:00
in_width: int,
in_height: int,
out_width: int,
out_height: int,
quality: int,
birdseye_rtsp: bool = False,
):
super().__init__(name="birdseye_output_converter")
2024-09-13 14:14:51 -06:00
self.camera = "birdseye"
2023-11-07 16:24:56 -07:00
self.input_queue = input_queue
self.stop_event = stop_event
2026-03-25 12:53:19 -06:00
self.bd_pipe: int | None = None
2022-12-31 07:54:10 -07:00
if birdseye_rtsp:
self.recreate_birdseye_pipe()
2022-12-31 07:54:10 -07:00
ffmpeg_cmd = [
2024-09-13 14:14:51 -06:00
ffmpeg.ffmpeg_path,
"-threads",
"1",
2022-12-31 07:54:10 -07:00
"-f",
"rawvideo",
"-pix_fmt",
"yuv420p",
"-video_size",
f"{in_width}x{in_height}",
"-i",
"pipe:",
"-threads",
"1",
2022-12-31 07:54:10 -07:00
"-f",
"mpegts",
"-s",
f"{out_width}x{out_height}",
"-codec:v",
"mpeg1video",
"-q",
f"{quality}",
"-bf",
"0",
"pipe:",
]
2021-05-30 06:45:37 -05:00
self.process = sp.Popen(
ffmpeg_cmd,
stdout=sp.PIPE,
stderr=sp.DEVNULL,
stdin=sp.PIPE,
start_new_session=True,
)
def recreate_birdseye_pipe(self) -> None:
if self.bd_pipe:
os.close(self.bd_pipe)
if os.path.exists(BIRDSEYE_PIPE):
os.remove(BIRDSEYE_PIPE)
os.mkfifo(BIRDSEYE_PIPE, mode=0o777)
stdin = os.open(BIRDSEYE_PIPE, os.O_RDONLY | os.O_NONBLOCK)
self.bd_pipe = os.open(BIRDSEYE_PIPE, os.O_WRONLY)
os.close(stdin)
self.reading_birdseye = False
2026-03-25 12:53:19 -06:00
def __write(self, b: bytes) -> None:
assert self.process.stdin is not None
2021-05-30 06:45:37 -05:00
self.process.stdin.write(b)
2022-12-31 07:54:10 -07:00
if self.bd_pipe:
try:
os.write(self.bd_pipe, b)
self.reading_birdseye = True
2022-12-31 07:54:10 -07:00
except BrokenPipeError:
if self.reading_birdseye:
# we know the pipe was being read from and now it is not
# so we should recreate the pipe to ensure no partially-read
# frames exist
logger.debug(
"Recreating the birdseye pipe because it was read from and now is not"
)
self.recreate_birdseye_pipe()
2022-12-31 07:54:10 -07:00
return
2026-03-25 12:53:19 -06:00
def read(self, length: int) -> Any:
2021-06-09 07:41:30 -05:00
try:
2026-03-25 12:53:19 -06:00
return self.process.stdout.read1(length) # type: ignore[union-attr]
2021-06-09 07:41:30 -05:00
except ValueError:
return False
2021-05-30 06:45:37 -05:00
2026-03-25 12:53:19 -06:00
def exit(self) -> None:
2022-12-31 07:54:10 -07:00
if self.bd_pipe:
os.close(self.bd_pipe)
2021-05-30 06:45:37 -05:00
self.process.terminate()
try:
self.process.communicate(timeout=30)
except sp.TimeoutExpired:
self.process.kill()
self.process.communicate()
2023-11-07 16:24:56 -07:00
def run(self) -> None:
while not self.stop_event.is_set():
try:
frame = self.input_queue.get(True, timeout=1)
self.__write(frame)
except queue.Empty:
pass
self.exit()
2021-05-30 06:45:37 -05:00
class BroadcastThread(threading.Thread):
2023-11-07 16:24:56 -07:00
def __init__(
self,
camera: str,
converter: FFMpegConverter,
2026-03-25 12:53:19 -06:00
websocket_server: Any,
stop_event: MpEvent,
2026-04-23 13:27:49 -05:00
config: FrigateConfig,
2023-11-07 16:24:56 -07:00
):
super().__init__()
2021-05-30 09:50:33 -05:00
self.camera = camera
2021-05-30 06:45:37 -05:00
self.converter = converter
self.websocket_server = websocket_server
2023-02-03 20:15:47 -06:00
self.stop_event = stop_event
2026-04-23 13:27:49 -05:00
self.config = config
2021-05-30 06:45:37 -05:00
2026-03-25 12:53:19 -06:00
def run(self) -> None:
2023-02-03 20:15:47 -06:00
while not self.stop_event.is_set():
2021-05-30 07:36:33 -05:00
buf = self.converter.read(65536)
2021-05-30 06:45:37 -05:00
if buf:
2021-06-12 10:18:13 -05:00
manager = self.websocket_server.manager
with manager.lock:
websockets = manager.websockets.copy()
ws_iter = iter(websockets.values())
for ws in ws_iter:
2021-08-28 07:42:30 -05:00
if (
not ws.terminated
and ws.environ["PATH_INFO"] == f"/{self.camera}"
2026-04-23 13:27:49 -05:00
and ws_has_camera_access(ws, self.camera, self.config)
2021-06-12 10:18:13 -05:00
):
try:
ws.send(buf, binary=True)
2023-05-29 12:31:17 +02:00
except ValueError:
2021-06-12 10:18:13 -05:00
pass
2025-04-02 08:38:48 -05:00
except (BrokenPipeError, ConnectionResetError, OSError) as e:
2023-08-19 12:38:47 -06:00
logger.debug(f"Websocket unexpectedly closed {e}")
2021-05-30 06:45:37 -05:00
elif self.converter.process.poll() is not None:
break
2021-05-31 09:22:00 -05:00
class BirdsEyeFrameManager:
2023-07-01 07:18:33 -06:00
def __init__(
self,
config: FrigateConfig,
2026-03-25 12:53:19 -06:00
stop_event: MpEvent,
2023-07-01 07:18:33 -06:00
):
2021-06-09 07:41:30 -05:00
self.config = config
width, height = get_canvas_shape(config.birdseye.width, config.birdseye.height)
2021-06-04 07:09:16 -05:00
self.frame_shape = (height, width)
self.yuv_shape = (height * 3 // 2, width)
2026-03-25 12:53:19 -06:00
self.frame: np.ndarray = np.ndarray(self.yuv_shape, dtype=np.uint8)
2024-02-10 10:55:13 -07:00
self.canvas = Canvas(width, height, config.birdseye.layout.scaling_factor)
2023-07-01 07:18:33 -06:00
self.stop_event = stop_event
2026-03-25 12:53:19 -06:00
self.last_refresh_time: float = 0
2021-05-31 09:22:00 -05:00
2023-01-13 10:18:15 -03:00
# initialize the frame as black and with the Frigate logo
2021-06-04 07:09:16 -05:00
self.blank_frame = np.zeros(self.yuv_shape, np.uint8)
2021-05-31 09:22:00 -05:00
self.blank_frame[:] = 128
2021-06-04 07:09:16 -05:00
self.blank_frame[0 : self.frame_shape[0], 0 : self.frame_shape[1]] = 16
2021-05-31 09:22:00 -05:00
2021-06-13 12:32:12 -05:00
# find and copy the logo on the blank frame
birdseye_logo = None
custom_logo_files = glob.glob(f"{BASE_DIR}/custom.png")
if len(custom_logo_files) > 0:
birdseye_logo = cv2.imread(custom_logo_files[0], cv2.IMREAD_UNCHANGED)
if birdseye_logo is None:
2025-03-01 05:35:09 +01:00
logo_files = glob.glob(
os.path.join(INSTALL_DIR, "frigate/images/birdseye.png")
)
if len(logo_files) > 0:
birdseye_logo = cv2.imread(logo_files[0], cv2.IMREAD_UNCHANGED)
2023-05-29 12:31:17 +02:00
if birdseye_logo is not None:
if birdseye_logo.ndim == 2:
# Grayscale image (no channels) — use directly as luminance
transparent_layer = birdseye_logo
elif birdseye_logo.shape[2] >= 4:
# RGBA — use alpha channel as luminance
transparent_layer = birdseye_logo[:, :, 3]
else:
# RGB or other format without alpha — convert to grayscale
transparent_layer = cv2.cvtColor(birdseye_logo, cv2.COLOR_BGR2GRAY)
2021-06-13 12:32:12 -05:00
y_offset = height // 2 - transparent_layer.shape[0] // 2
x_offset = width // 2 - transparent_layer.shape[1] // 2
self.blank_frame[
y_offset : y_offset + transparent_layer.shape[0],
x_offset : x_offset + transparent_layer.shape[1],
2021-06-13 12:32:12 -05:00
] = transparent_layer
else:
2023-01-13 10:18:15 -03:00
logger.warning("Unable to read Frigate logo")
2021-06-13 12:32:12 -05:00
2021-05-31 09:22:00 -05:00
self.frame[:] = self.blank_frame
2026-03-25 12:53:19 -06:00
self.cameras: dict[str, Any] = {}
for camera in self.config.cameras.keys():
self.add_camera(camera)
2021-06-09 07:41:30 -05:00
2026-03-25 12:53:19 -06:00
self.camera_layout: list[Any] = []
self.active_cameras: set[str] = set()
2026-07-15 19:49:05 -05:00
self.layout_camera_order: list[str] = []
2021-06-09 07:41:30 -05:00
self.last_output_time = 0.0
2021-06-04 07:09:16 -05:00
2026-03-25 12:53:19 -06:00
def add_camera(self, cam: str) -> None:
"""Add a camera to self.cameras with the correct structure."""
settings = self.config.cameras[cam]
# precalculate the coordinates for all the channels
y, u1, u2, v1, v2 = get_yuv_crop(
settings.frame_shape_yuv,
(
0,
0,
settings.frame_shape[1],
settings.frame_shape[0],
),
)
self.cameras[cam] = {
"dimensions": [
settings.detect.width,
settings.detect.height,
],
"last_active_frame": 0.0,
"current_frame": 0.0,
"layout_frame": 0.0,
"channel_dims": {
"y": y,
"u1": u1,
"u2": u2,
"v1": v1,
"v2": v2,
},
}
2026-03-25 12:53:19 -06:00
def remove_camera(self, cam: str) -> None:
"""Remove a camera from self.cameras."""
if cam in self.cameras:
del self.cameras[cam]
2026-07-15 19:49:05 -05:00
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),
)
2026-03-25 12:53:19 -06:00
def clear_frame(self) -> None:
2023-05-29 12:31:17 +02:00
logger.debug("Clearing the birdseye frame")
2021-06-04 07:09:16 -05:00
self.frame[:] = self.blank_frame
2026-03-25 12:53:19 -06:00
def copy_to_position(
self,
position: Any,
2026-07-06 09:28:02 -08:00
camera: str | None = None,
frame: np.ndarray | None = None,
2026-03-25 12:53:19 -06:00
) -> None:
2021-06-09 07:41:30 -05:00
if camera is None:
frame = None
channel_dims = None
else:
2024-09-03 10:22:30 -06:00
if frame is None:
2024-11-16 16:00:19 -07:00
logger.debug(f"Unable to copy frame {camera} to birdseye.")
2021-07-16 07:28:30 -05:00
return
2024-09-03 10:22:30 -06:00
2021-06-09 07:41:30 -05:00
channel_dims = self.cameras[camera]["channel_dims"]
2021-06-04 07:09:16 -05:00
copy_yuv_to_position(
self.frame,
2023-06-11 06:54:18 -06:00
[position[1], position[0]],
[position[3], position[2]],
frame,
channel_dims,
)
2021-06-04 07:09:16 -05:00
2026-03-25 12:53:19 -06:00
def camera_active(
self, mode: Any, object_box_count: int, motion_box_count: int
) -> bool:
if mode == BirdseyeModeEnum.continuous:
2021-06-11 19:02:43 -05:00
return True
if mode == BirdseyeModeEnum.motion and motion_box_count > 0:
2021-06-11 19:02:43 -05:00
return True
if mode == BirdseyeModeEnum.objects and object_box_count > 0:
2021-06-11 19:02:43 -05:00
return True
2026-03-25 12:53:19 -06:00
return False
2025-06-08 13:06:17 -05:00
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
2026-07-06 09:28:02 -08:00
def update_frame(self, frame: np.ndarray | None = None) -> tuple[bool, bool]:
2025-03-03 09:30:52 -06:00
"""
Update birdseye, optionally with a new frame.
2025-06-08 13:06:17 -05:00
Returns (frame_changed, layout_changed) to indicate if the frame or layout changed.
2025-03-03 09:30:52 -06:00
"""
2023-06-11 06:54:18 -06:00
2024-02-10 10:55:13 -07:00
# determine how many cameras are tracking objects within the last inactivity_threshold seconds
active_cameras: set[str] = set(
2021-06-09 07:41:30 -05:00
[
cam
for cam, cam_data in self.cameras.items()
2026-03-04 10:07:34 -06:00
if cam in self.config.cameras
and self.config.cameras[cam].birdseye.enabled
2025-03-03 09:30:52 -06:00
and self.config.cameras[cam].enabled_in_config
2025-03-06 06:59:35 -07:00
and self.config.cameras[cam].enabled
and cam_data["last_active_frame"] > 0
2024-11-18 08:38:58 -07:00
and cam_data["current_frame_time"] - cam_data["last_active_frame"]
< self.config.birdseye.inactivity_threshold
2021-06-09 07:41:30 -05:00
]
)
2025-03-03 09:30:52 -06:00
logger.debug(f"Active cameras: {active_cameras}")
2021-06-04 07:09:16 -05:00
2024-02-10 10:55:13 -07:00
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: (
2024-11-18 08:38:58 -07:00
self.cameras[active_camera]["current_frame_time"]
2024-02-10 10:55:13 -07:00
- self.cameras[active_camera]["last_active_frame"]
),
)
2026-03-25 12:53:19 -06:00
active_cameras = set(limited_active_cameras[:max_cameras])
2024-02-10 10:55:13 -07:00
max_camera_refresh = True
self.last_refresh_time = now
2025-06-08 13:06:17 -05:00
# Track if the frame or layout changes
2025-03-03 09:30:52 -06:00
frame_changed = False
2025-06-08 13:06:17 -05:00
layout_changed = False
2025-03-03 09:30:52 -06:00
# If no active cameras and layout is already empty, no update needed
2021-06-09 07:41:30 -05:00
if len(active_cameras) == 0:
# if the layout is already cleared
if len(self.camera_layout) == 0:
2025-06-08 13:06:17 -05:00
return False, False
2021-06-09 07:41:30 -05:00
# if the layout needs to be cleared
2025-03-03 09:30:52 -06:00
self.camera_layout = []
self.active_cameras = set()
2026-07-15 19:49:05 -05:00
self.layout_camera_order = []
2023-06-11 06:54:18 -06:00
self.clear_frame()
2025-03-03 09:30:52 -06:00
frame_changed = True
2025-06-08 13:06:17 -05:00
layout_changed = True
2025-03-03 09:30:52 -06:00
else:
# Determine if layout needs resetting
if len(self.active_cameras) - len(active_cameras) == 0:
if (
len(self.active_cameras) == 1
and self.active_cameras != active_cameras
):
reset_layout = True
elif max_camera_refresh:
reset_layout = True
2024-02-10 10:55:13 -07:00
else:
2025-03-03 09:30:52 -06:00
reset_layout = False
2023-06-11 06:54:18 -06:00
else:
2025-03-03 09:30:52 -06:00
reset_layout = True
2026-07-15 19:49:05 -05:00
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
2025-03-03 09:30:52 -06:00
if reset_layout:
logger.debug("Resetting Birdseye layout...")
self.clear_frame()
self.active_cameras = active_cameras
2026-07-15 19:49:05 -05:00
self.layout_camera_order = sorted_active_cameras
2025-06-08 13:06:17 -05:00
layout_changed = True # Layout is changing due to reset
2025-03-03 09:30:52 -06:00
# this also converts added_cameras from a set to a list since we need
# to pop elements in order
2026-07-15 19:49:05 -05:00
active_cameras_to_add = sorted_active_cameras
2025-03-03 09:30:52 -06:00
if len(active_cameras) == 1:
# show single camera as fullscreen
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]
2021-06-09 07:41:30 -05:00
)
2023-06-11 06:54:18 -06:00
2025-03-03 09:30:52 -06:00
# center camera view in canvas and ensure that it fits
if scaled_width < self.canvas.width:
2026-03-25 12:53:19 -06:00
coefficient: float = 1
2025-03-03 09:30:52 -06:00
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,
(
x_offset,
0,
int(scaled_width * coefficient),
int(self.canvas.height * coefficient),
),
)
]
]
else:
# calculate optimal layout
coefficient = self.canvas.get_coefficient(len(active_cameras))
calculating = True
# decrease scaling coefficient until height of all cameras can fit into the birdseye canvas
while calculating:
if self.stop_event.is_set():
2025-06-08 13:06:17 -05:00
return frame_changed, layout_changed
2023-06-16 06:35:36 -06:00
2025-03-03 09:30:52 -06:00
layout_candidate = self.calculate_layout(
active_cameras_to_add, coefficient
)
2023-06-11 06:54:18 -06:00
2025-03-03 09:30:52 -06:00
if not layout_candidate:
if coefficient < 10:
coefficient += 1
continue
else:
logger.error(
"Error finding appropriate birdseye layout"
)
2025-06-08 13:06:17 -05:00
return frame_changed, layout_changed
2025-03-03 09:30:52 -06:00
calculating = False
self.canvas.set_coefficient(len(active_cameras), coefficient)
2023-06-11 06:54:18 -06:00
2026-03-25 12:53:19 -06:00
self.camera_layout = layout_candidate or []
2025-03-03 09:30:52 -06:00
frame_changed = True
2021-05-31 09:22:00 -05:00
2025-03-03 09:30:52 -06:00
# Draw the layout
for row in self.camera_layout:
for position in row:
src_frame = self.cameras[position[0]]["current_frame"]
if src_frame is None or src_frame.size == 0:
logger.debug(f"Skipping invalid frame for {position[0]}")
continue
self.copy_to_position(position[1], position[0], src_frame)
if frame is not None: # Frame presence indicates a potential change
frame_changed = True
2025-06-08 13:06:17 -05:00
return frame_changed, layout_changed
2021-06-04 07:09:16 -05:00
2024-02-10 10:55:13 -07:00
def calculate_layout(
self,
cameras_to_add: list[str],
coefficient: float,
2026-07-06 09:28:02 -08:00
) -> list[list[Any]] | None:
"""Calculate the optimal layout for 2+ cameras."""
2026-03-25 12:53:19 -06:00
def map_layout(
camera_layout: list[list[Any]], row_height: int
2026-07-06 09:28:02 -08:00
) -> tuple[int, int, list[list[Any]] | None]:
"""Map the calculated layout."""
candidate_layout = []
starting_x = 0
x = 0
max_width = 0
y = 0
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]
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)
# layout is too large
if (
x + scaled_width > self.canvas.width
or y + scaled_height > self.canvas.height
):
2023-10-30 18:24:42 -06:00
return x + scaled_width, y + scaled_height, None
final_row.append((cameras[0], (x, y, scaled_width, scaled_height)))
x += scaled_width
y += row_height
candidate_layout.append(final_row)
2023-10-26 16:23:39 -06:00
if max_width == 0:
max_width = x
return max_width, y, candidate_layout
canvas_aspect_x, canvas_aspect_y = self.canvas.get_aspect(coefficient)
2025-05-13 16:27:20 +02:00
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)
2023-10-30 18:24:42 -06:00
total_width, total_height, standard_candidate_layout = map_layout(
camera_layout, row_height
)
2023-10-26 16:23:39 -06:00
if not standard_candidate_layout:
2023-10-30 18:24:42 -06:00
# if standard layout didn't work
# try reducing row_height by the % overflow
scale_down_percent = max(
total_width / self.canvas.width,
total_height / self.canvas.height,
)
row_height = int(row_height / scale_down_percent)
total_width, total_height, standard_candidate_layout = map_layout(
camera_layout, row_height
)
if not standard_candidate_layout:
return None
2023-10-26 16:23:39 -06:00
# layout can't be optimized more
if total_width / self.canvas.width >= 0.99:
return standard_candidate_layout
scale_up_percent = min(
2023-10-26 16:23:39 -06:00
1 / (total_width / self.canvas.width),
1 / (total_height / self.canvas.height),
)
2023-10-26 16:23:39 -06:00
row_height = int(row_height * scale_up_percent)
2023-10-30 18:24:42 -06:00
_, _, scaled_layout = map_layout(camera_layout, row_height)
if scaled_layout:
return scaled_layout
else:
return standard_candidate_layout
2024-11-16 16:00:19 -07:00
def update(
self,
camera: str,
object_count: int,
motion_count: int,
frame_time: float,
frame: np.ndarray,
2025-06-08 13:06:17 -05:00
) -> 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.
"""
2026-03-04 10:07:34 -06:00
# don't process if camera was removed or birdseye is disabled
camera_config = self.config.cameras.get(camera)
if camera_config is None:
return False, False
2025-03-03 14:05:49 -07:00
force_update = False
# disabling birdseye is a little tricky
2025-03-06 06:59:35 -07:00
if not camera_config.birdseye.enabled or not camera_config.enabled:
# if we've rendered a frame (we have a value for last_active_frame)
# then we need to set it to zero
if self.cameras[camera]["last_active_frame"] > 0:
self.cameras[camera]["last_active_frame"] = 0
2025-03-03 14:05:49 -07:00
force_update = True
else:
2025-06-08 13:06:17 -05:00
return False, False
2021-06-09 07:41:30 -05:00
# update the last active frame for the camera
2024-11-18 08:38:58 -07:00
self.cameras[camera]["current_frame"] = frame.copy()
self.cameras[camera]["current_frame_time"] = frame_time
2025-03-06 06:59:35 -07:00
if self.camera_active(camera_config.birdseye.mode, object_count, motion_count):
2021-06-09 07:41:30 -05:00
self.cameras[camera]["last_active_frame"] = frame_time
now = datetime.datetime.now().timestamp()
2021-06-10 08:05:06 -05:00
# limit output to 10 fps
2025-03-03 14:05:49 -07:00
if not force_update and (now - self.last_output_time) < 1 / 10:
2025-06-08 13:06:17 -05:00
return False, False
2021-06-09 07:41:30 -05:00
2023-06-12 04:06:02 -06:00
try:
2025-06-08 13:06:17 -05:00
frame_changed, layout_changed = self.update_frame(frame)
2023-06-12 04:06:02 -06:00
except Exception:
2025-06-08 13:06:17 -05:00
frame_changed, layout_changed = False, False
self.active_cameras = set()
2023-09-21 04:22:11 -06:00
self.camera_layout = []
2026-07-15 19:49:05 -05:00
self.layout_camera_order = []
2023-06-12 04:06:02 -06:00
print(traceback.format_exc())
2021-06-13 09:01:24 -05:00
# if the frame was updated or the fps is too low, send frame
2025-06-08 13:06:17 -05:00
if force_update or frame_changed or (now - self.last_output_time) > 1:
2021-06-13 09:01:24 -05:00
self.last_output_time = now
2025-06-08 13:06:17 -05:00
return True, layout_changed
return False, layout_changed
2021-06-09 07:41:30 -05:00
2021-05-31 09:22:00 -05:00
class Birdseye:
def __init__(
self,
config: FrigateConfig,
2026-03-25 12:53:19 -06:00
stop_event: MpEvent,
websocket_server: Any,
) -> None:
self.config = config
2026-05-12 11:20:39 -05:00
canvas_width, canvas_height = get_canvas_shape(
config.birdseye.width, config.birdseye.height
)
2026-03-25 12:53:19 -06:00
self.input: queue.Queue[bytes] = queue.Queue(maxsize=10)
self.converter = FFMpegConverter(
2024-09-13 14:14:51 -06:00
config.ffmpeg,
self.input,
2023-11-07 16:24:56 -07:00
stop_event,
2026-05-12 11:20:39 -05:00
canvas_width,
canvas_height,
canvas_width,
canvas_height,
2021-06-11 07:50:09 -05:00
config.birdseye.quality,
2023-01-16 16:50:35 -07:00
config.birdseye.restream,
2021-06-11 07:50:09 -05:00
)
self.broadcaster = BroadcastThread(
2026-04-23 13:27:49 -05:00
"birdseye",
self.converter,
websocket_server,
stop_event,
config,
)
self.birdseye_manager = BirdsEyeFrameManager(self.config, stop_event)
self.frame_manager = SharedMemoryFrameManager()
2025-03-03 09:30:52 -06:00
self.stop_event = stop_event
2025-06-08 13:06:17 -05:00
self.requestor = InterProcessRequestor()
self.idle_fps: float = self.config.birdseye.idle_heartbeat_fps
2026-07-06 09:28:02 -08:00
self._idle_interval: float | None = (
(1.0 / self.idle_fps) if self.idle_fps > 0 else None
)
2021-05-31 09:22:00 -05:00
if config.birdseye.restream:
2026-03-25 12:53:19 -06:00
self.birdseye_buffer: Any = self.frame_manager.create(
"birdseye",
self.birdseye_manager.yuv_shape[0] * self.birdseye_manager.yuv_shape[1],
)
2021-05-30 09:50:33 -05:00
self.converter.start()
self.broadcaster.start()
2023-11-07 16:24:56 -07:00
2025-03-03 14:05:49 -07:00
def __send_new_frame(self) -> None:
frame_bytes = self.birdseye_manager.frame.tobytes()
if self.config.birdseye.restream:
self.birdseye_buffer[:] = frame_bytes
try:
self.input.put_nowait(frame_bytes)
except queue.Full:
# drop frames if queue is full
pass
def all_cameras_disabled(self) -> None:
self.birdseye_manager.clear_frame()
self.__send_new_frame()
def add_camera(self, camera: str) -> None:
"""Add a camera to the birdseye manager."""
self.birdseye_manager.add_camera(camera)
logger.debug(f"Added camera {camera} to birdseye")
def remove_camera(self, camera: str) -> None:
"""Remove a camera from the birdseye manager."""
self.birdseye_manager.remove_camera(camera)
logger.debug(f"Removed camera {camera} from birdseye")
def write_data(
self,
camera: str,
2025-05-13 16:27:20 +02:00
current_tracked_objects: list[dict[str, Any]],
motion_boxes: list[list[int]],
frame_time: float,
2024-11-18 08:38:58 -07:00
frame: np.ndarray,
) -> None:
2025-06-08 13:06:17 -05:00
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,
2025-06-08 13:06:17 -05:00
)
if frame_changed:
2025-03-03 14:05:49 -07:00
self.__send_new_frame()
2021-05-30 06:45:37 -05:00
2025-06-08 13:06:17 -05:00
if frame_layout_changed:
coordinates = self.birdseye_manager.get_camera_coordinates()
self.requestor.send_data(UPDATE_BIRDSEYE_LAYOUT, coordinates)
if self._idle_interval:
now = datetime.datetime.now().timestamp()
is_idle = len(self.birdseye_manager.camera_layout) == 0
if (
is_idle
and (now - self.birdseye_manager.last_output_time)
>= self._idle_interval
):
self.__send_new_frame()
2025-06-08 13:06:17 -05:00
def stop(self) -> None:
self.converter.join()
self.broadcaster.join()