2023-12-03 15:16:01 +01:00
|
|
|
"""Handle outputting birdseye frames via jsmpeg and go2rtc."""
|
|
|
|
|
2021-06-04 14:09:16 +02:00
|
|
|
import datetime
|
2021-06-13 19:32:12 +02:00
|
|
|
import glob
|
2021-06-12 02:08:15 +02:00
|
|
|
import logging
|
2023-06-16 14:35:36 +02:00
|
|
|
import math
|
2021-05-30 13:45:37 +02:00
|
|
|
import multiprocessing as mp
|
2022-12-31 15:54:10 +01:00
|
|
|
import os
|
2021-05-29 20:27:00 +02:00
|
|
|
import queue
|
2021-05-30 13:45:37 +02:00
|
|
|
import subprocess as sp
|
|
|
|
import threading
|
2023-06-12 12:06:02 +02:00
|
|
|
import traceback
|
2021-05-30 13:45:37 +02:00
|
|
|
|
2021-06-12 02:08:15 +02:00
|
|
|
import cv2
|
2021-06-04 14:09:16 +02:00
|
|
|
import numpy as np
|
2021-05-30 13:45:37 +02:00
|
|
|
|
2024-02-19 14:26:59 +01:00
|
|
|
from frigate.comms.config_updater import ConfigSubscriber
|
2021-06-24 07:45:27 +02:00
|
|
|
from frigate.config import BirdseyeModeEnum, FrigateConfig
|
2022-12-31 15:54:10 +01:00
|
|
|
from frigate.const import BASE_DIR, BIRDSEYE_PIPE
|
2023-07-06 16:28:50 +02:00
|
|
|
from frigate.util.image import (
|
|
|
|
SharedMemoryFrameManager,
|
|
|
|
copy_yuv_to_position,
|
|
|
|
get_yuv_crop,
|
|
|
|
)
|
2021-06-12 02:08:15 +02:00
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
2021-05-29 20:27:00 +02:00
|
|
|
|
|
|
|
|
2023-09-30 00:53:45 +02:00
|
|
|
def get_standard_aspect_ratio(width: int, height: int) -> tuple[int, int]:
|
2023-07-06 14:30:05 +02:00
|
|
|
"""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
|
2023-07-06 14:30:05 +02:00
|
|
|
known_aspects = [
|
|
|
|
(16, 9),
|
|
|
|
(9, 16),
|
2023-10-27 00:23:39 +02:00
|
|
|
(20, 10),
|
2024-02-10 18:55:13 +01:00
|
|
|
(16, 3), # max wide camera
|
2023-10-27 00:23:39 +02:00
|
|
|
(16, 6), # reolink duo 2
|
2023-10-26 13:21:26 +02:00
|
|
|
(32, 9), # panoramic cameras
|
2023-07-06 14:30:05 +02:00
|
|
|
(12, 9),
|
|
|
|
(9, 12),
|
2023-12-03 04:22:50 +01:00
|
|
|
(22, 15), # Amcrest, NTSC DVT
|
2024-02-10 18:55:13 +01:00
|
|
|
(1, 1), # fisheye
|
2023-07-06 14:30:05 +02:00
|
|
|
] # 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)]
|
|
|
|
|
|
|
|
|
2023-09-30 00:53:45 +02:00
|
|
|
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):
|
2023-11-01 12:13:12 +01:00
|
|
|
canvas_width = int(width // 4 * 4)
|
|
|
|
canvas_height = int((canvas_width / a_w * a_h) // 4 * 4)
|
2023-09-30 00:53:45 +02:00
|
|
|
logger.warning(
|
|
|
|
f"The birdseye resolution is a non-standard aspect ratio, forcing birdseye resolution to {canvas_width} x {canvas_height}"
|
|
|
|
)
|
|
|
|
|
|
|
|
return (canvas_width, canvas_height)
|
|
|
|
|
|
|
|
|
2023-07-06 14:30:05 +02:00
|
|
|
class Canvas:
|
2024-02-10 18:55:13 +01:00
|
|
|
def __init__(
|
|
|
|
self,
|
|
|
|
canvas_width: int,
|
|
|
|
canvas_height: int,
|
|
|
|
scaling_factor: int,
|
|
|
|
) -> None:
|
|
|
|
self.scaling_factor = scaling_factor
|
2023-07-06 14:30:05 +02:00
|
|
|
gcd = math.gcd(canvas_width, canvas_height)
|
|
|
|
self.aspect = get_standard_aspect_ratio(
|
|
|
|
(canvas_width / gcd), (canvas_height / gcd)
|
|
|
|
)
|
|
|
|
self.width = canvas_width
|
|
|
|
self.height = (self.width * self.aspect[1]) / self.aspect[0]
|
|
|
|
self.coefficient_cache: dict[int, int] = {}
|
|
|
|
self.aspect_cache: dict[str, tuple[int, int]] = {}
|
|
|
|
|
|
|
|
def get_aspect(self, coefficient: int) -> tuple[int, int]:
|
|
|
|
return (self.aspect[0] * coefficient, self.aspect[1] * coefficient)
|
|
|
|
|
|
|
|
def get_coefficient(self, camera_count: int) -> int:
|
2024-02-10 18:55:13 +01:00
|
|
|
return self.coefficient_cache.get(camera_count, self.scaling_factor)
|
2023-07-06 14:30:05 +02:00
|
|
|
|
|
|
|
def set_coefficient(self, camera_count: int, coefficient: int) -> 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(
|
|
|
|
camera_width / gcd, camera_height / gcd
|
|
|
|
)
|
|
|
|
self.aspect_cache[cam_name] = camera_aspect
|
|
|
|
return camera_aspect
|
|
|
|
|
|
|
|
|
2023-11-08 00:24:56 +01:00
|
|
|
class FFMpegConverter(threading.Thread):
|
2022-12-31 15:54:10 +01:00
|
|
|
def __init__(
|
|
|
|
self,
|
2023-11-08 00:24:56 +01:00
|
|
|
camera: str,
|
|
|
|
input_queue: queue.Queue,
|
|
|
|
stop_event: mp.Event,
|
2022-12-31 15:54:10 +01:00
|
|
|
in_width: int,
|
|
|
|
in_height: int,
|
|
|
|
out_width: int,
|
|
|
|
out_height: int,
|
|
|
|
quality: int,
|
|
|
|
birdseye_rtsp: bool = False,
|
|
|
|
):
|
2023-11-08 00:24:56 +01:00
|
|
|
threading.Thread.__init__(self)
|
|
|
|
self.name = f"{camera}_output_converter"
|
|
|
|
self.camera = camera
|
|
|
|
self.input_queue = input_queue
|
|
|
|
self.stop_event = stop_event
|
2023-02-16 14:49:31 +01:00
|
|
|
self.bd_pipe = None
|
2022-12-31 15:54:10 +01:00
|
|
|
|
2023-02-16 14:49:31 +01:00
|
|
|
if birdseye_rtsp:
|
|
|
|
self.recreate_birdseye_pipe()
|
2022-12-31 15:54:10 +01:00
|
|
|
|
|
|
|
ffmpeg_cmd = [
|
|
|
|
"ffmpeg",
|
2024-05-30 19:34:01 +02:00
|
|
|
"-threads",
|
|
|
|
"1",
|
2022-12-31 15:54:10 +01:00
|
|
|
"-f",
|
|
|
|
"rawvideo",
|
|
|
|
"-pix_fmt",
|
|
|
|
"yuv420p",
|
|
|
|
"-video_size",
|
|
|
|
f"{in_width}x{in_height}",
|
|
|
|
"-i",
|
|
|
|
"pipe:",
|
2024-05-30 19:34:01 +02:00
|
|
|
"-threads",
|
|
|
|
"1",
|
2022-12-31 15:54:10 +01:00
|
|
|
"-f",
|
|
|
|
"mpegts",
|
|
|
|
"-s",
|
|
|
|
f"{out_width}x{out_height}",
|
|
|
|
"-codec:v",
|
|
|
|
"mpeg1video",
|
|
|
|
"-q",
|
|
|
|
f"{quality}",
|
|
|
|
"-bf",
|
|
|
|
"0",
|
|
|
|
"pipe:",
|
|
|
|
]
|
|
|
|
|
2021-05-30 13:45:37 +02:00
|
|
|
self.process = sp.Popen(
|
|
|
|
ffmpeg_cmd,
|
|
|
|
stdout=sp.PIPE,
|
|
|
|
stderr=sp.DEVNULL,
|
|
|
|
stdin=sp.PIPE,
|
|
|
|
start_new_session=True,
|
|
|
|
)
|
|
|
|
|
2023-02-16 14:49:31 +01:00
|
|
|
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
|
|
|
|
|
2023-11-08 00:24:56 +01:00
|
|
|
def __write(self, b) -> None:
|
2021-05-30 13:45:37 +02:00
|
|
|
self.process.stdin.write(b)
|
|
|
|
|
2022-12-31 15:54:10 +01:00
|
|
|
if self.bd_pipe:
|
|
|
|
try:
|
|
|
|
os.write(self.bd_pipe, b)
|
2023-02-16 14:49:31 +01:00
|
|
|
self.reading_birdseye = True
|
2022-12-31 15:54:10 +01:00
|
|
|
except BrokenPipeError:
|
2023-02-16 14:49:31 +01:00
|
|
|
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 15:54:10 +01:00
|
|
|
return
|
|
|
|
|
2021-05-30 13:45:37 +02:00
|
|
|
def read(self, length):
|
2021-06-09 14:41:30 +02:00
|
|
|
try:
|
|
|
|
return self.process.stdout.read1(length)
|
|
|
|
except ValueError:
|
|
|
|
return False
|
2021-05-30 13:45:37 +02:00
|
|
|
|
|
|
|
def exit(self):
|
2022-12-31 15:54:10 +01:00
|
|
|
if self.bd_pipe:
|
|
|
|
os.close(self.bd_pipe)
|
|
|
|
|
2021-05-30 13:45:37 +02:00
|
|
|
self.process.terminate()
|
|
|
|
try:
|
|
|
|
self.process.communicate(timeout=30)
|
|
|
|
except sp.TimeoutExpired:
|
|
|
|
self.process.kill()
|
|
|
|
self.process.communicate()
|
|
|
|
|
2023-11-08 00:24:56 +01: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 13:45:37 +02:00
|
|
|
|
|
|
|
class BroadcastThread(threading.Thread):
|
2023-11-08 00:24:56 +01:00
|
|
|
def __init__(
|
|
|
|
self,
|
|
|
|
camera: str,
|
|
|
|
converter: FFMpegConverter,
|
|
|
|
websocket_server,
|
|
|
|
stop_event: mp.Event,
|
|
|
|
):
|
2021-05-30 13:45:37 +02:00
|
|
|
super(BroadcastThread, self).__init__()
|
2021-05-30 16:50:33 +02:00
|
|
|
self.camera = camera
|
2021-05-30 13:45:37 +02:00
|
|
|
self.converter = converter
|
|
|
|
self.websocket_server = websocket_server
|
2023-02-04 03:15:47 +01:00
|
|
|
self.stop_event = stop_event
|
2021-05-30 13:45:37 +02:00
|
|
|
|
|
|
|
def run(self):
|
2023-02-04 03:15:47 +01:00
|
|
|
while not self.stop_event.is_set():
|
2021-05-30 14:36:33 +02:00
|
|
|
buf = self.converter.read(65536)
|
2021-05-30 13:45:37 +02:00
|
|
|
if buf:
|
2021-06-12 17:18:13 +02: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 14:42:30 +02:00
|
|
|
if (
|
|
|
|
not ws.terminated
|
|
|
|
and ws.environ["PATH_INFO"] == f"/{self.camera}"
|
2021-06-12 17:18:13 +02:00
|
|
|
):
|
|
|
|
try:
|
|
|
|
ws.send(buf, binary=True)
|
2023-05-29 12:31:17 +02:00
|
|
|
except ValueError:
|
2021-06-12 17:18:13 +02:00
|
|
|
pass
|
2023-09-01 14:06:39 +02:00
|
|
|
except (BrokenPipeError, ConnectionResetError) as e:
|
2023-08-19 20:38:47 +02:00
|
|
|
logger.debug(f"Websocket unexpectedly closed {e}")
|
2021-05-30 13:45:37 +02:00
|
|
|
elif self.converter.process.poll() is not None:
|
|
|
|
break
|
|
|
|
|
|
|
|
|
2021-05-31 16:22:00 +02:00
|
|
|
class BirdsEyeFrameManager:
|
2023-07-01 15:18:33 +02:00
|
|
|
def __init__(
|
|
|
|
self,
|
|
|
|
config: FrigateConfig,
|
|
|
|
frame_manager: SharedMemoryFrameManager,
|
|
|
|
stop_event: mp.Event,
|
|
|
|
):
|
2021-06-09 14:41:30 +02:00
|
|
|
self.config = config
|
2021-06-12 02:02:43 +02:00
|
|
|
self.mode = config.birdseye.mode
|
2021-06-09 14:41:30 +02:00
|
|
|
self.frame_manager = frame_manager
|
2023-09-30 00:53:45 +02:00
|
|
|
width, height = get_canvas_shape(config.birdseye.width, config.birdseye.height)
|
2021-06-04 14:09:16 +02:00
|
|
|
self.frame_shape = (height, width)
|
|
|
|
self.yuv_shape = (height * 3 // 2, width)
|
|
|
|
self.frame = np.ndarray(self.yuv_shape, dtype=np.uint8)
|
2024-02-10 18:55:13 +01:00
|
|
|
self.canvas = Canvas(width, height, config.birdseye.layout.scaling_factor)
|
2023-07-01 15:18:33 +02:00
|
|
|
self.stop_event = stop_event
|
2024-02-10 18:55:13 +01:00
|
|
|
self.inactivity_threshold = config.birdseye.inactivity_threshold
|
|
|
|
|
|
|
|
if config.birdseye.layout.max_cameras:
|
|
|
|
self.last_refresh_time = 0
|
2021-05-31 16:22:00 +02:00
|
|
|
|
2023-01-13 14:18:15 +01:00
|
|
|
# initialize the frame as black and with the Frigate logo
|
2021-06-04 14:09:16 +02:00
|
|
|
self.blank_frame = np.zeros(self.yuv_shape, np.uint8)
|
2021-05-31 16:22:00 +02:00
|
|
|
self.blank_frame[:] = 128
|
2021-06-04 14:09:16 +02:00
|
|
|
self.blank_frame[0 : self.frame_shape[0], 0 : self.frame_shape[1]] = 16
|
2021-05-31 16:22:00 +02:00
|
|
|
|
2021-06-13 19:32:12 +02:00
|
|
|
# find and copy the logo on the blank frame
|
2022-04-10 16:15:56 +02:00
|
|
|
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:
|
2022-11-29 04:47:20 +01:00
|
|
|
logo_files = glob.glob("/opt/frigate/frigate/images/birdseye.png")
|
2022-04-10 16:15:56 +02:00
|
|
|
|
|
|
|
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:
|
2022-04-10 16:15:56 +02:00
|
|
|
transparent_layer = birdseye_logo[:, :, 3]
|
2021-06-13 19:32:12 +02: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[1],
|
|
|
|
x_offset : x_offset + transparent_layer.shape[0],
|
|
|
|
] = transparent_layer
|
|
|
|
else:
|
2023-01-13 14:18:15 +01:00
|
|
|
logger.warning("Unable to read Frigate logo")
|
2021-06-13 19:32:12 +02:00
|
|
|
|
2021-05-31 16:22:00 +02:00
|
|
|
self.frame[:] = self.blank_frame
|
|
|
|
|
2021-06-09 14:41:30 +02:00
|
|
|
self.cameras = {}
|
|
|
|
for camera, settings in self.config.cameras.items():
|
|
|
|
# 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[camera] = {
|
2023-06-11 14:54:18 +02:00
|
|
|
"dimensions": [settings.detect.width, settings.detect.height],
|
2021-06-09 14:41:30 +02:00
|
|
|
"last_active_frame": 0.0,
|
2021-06-13 15:48:14 +02:00
|
|
|
"current_frame": 0.0,
|
2021-06-09 14:41:30 +02:00
|
|
|
"layout_frame": 0.0,
|
|
|
|
"channel_dims": {
|
|
|
|
"y": y,
|
|
|
|
"u1": u1,
|
|
|
|
"u2": u2,
|
|
|
|
"v1": v1,
|
|
|
|
"v2": v2,
|
|
|
|
},
|
|
|
|
}
|
|
|
|
|
2021-06-04 14:09:16 +02:00
|
|
|
self.camera_layout = []
|
2021-06-09 14:41:30 +02:00
|
|
|
self.active_cameras = set()
|
|
|
|
self.last_output_time = 0.0
|
2021-06-04 14:09:16 +02:00
|
|
|
|
|
|
|
def clear_frame(self):
|
2023-05-29 12:31:17 +02:00
|
|
|
logger.debug("Clearing the birdseye frame")
|
2021-06-04 14:09:16 +02:00
|
|
|
self.frame[:] = self.blank_frame
|
|
|
|
|
2021-06-09 14:41:30 +02:00
|
|
|
def copy_to_position(self, position, camera=None, frame_time=None):
|
|
|
|
if camera is None:
|
|
|
|
frame = None
|
|
|
|
channel_dims = None
|
|
|
|
else:
|
2021-07-16 14:28:30 +02:00
|
|
|
try:
|
|
|
|
frame = self.frame_manager.get(
|
|
|
|
f"{camera}{frame_time}", self.config.cameras[camera].frame_shape_yuv
|
|
|
|
)
|
|
|
|
except FileNotFoundError:
|
|
|
|
# TODO: better frame management would prevent this edge case
|
|
|
|
logger.warning(
|
|
|
|
f"Unable to copy frame {camera}{frame_time} to birdseye."
|
|
|
|
)
|
|
|
|
return
|
2021-06-09 14:41:30 +02:00
|
|
|
channel_dims = self.cameras[camera]["channel_dims"]
|
2021-06-04 14:09:16 +02:00
|
|
|
|
2021-06-12 02:26:00 +02:00
|
|
|
copy_yuv_to_position(
|
|
|
|
self.frame,
|
2023-06-11 14:54:18 +02:00
|
|
|
[position[1], position[0]],
|
|
|
|
[position[3], position[2]],
|
2021-06-12 02:26:00 +02:00
|
|
|
frame,
|
|
|
|
channel_dims,
|
|
|
|
)
|
2021-06-04 14:09:16 +02:00
|
|
|
|
2022-04-15 13:59:30 +02:00
|
|
|
def camera_active(self, mode, object_box_count, motion_box_count):
|
|
|
|
if mode == BirdseyeModeEnum.continuous:
|
2021-06-12 02:02:43 +02:00
|
|
|
return True
|
|
|
|
|
2022-04-15 13:59:30 +02:00
|
|
|
if mode == BirdseyeModeEnum.motion and motion_box_count > 0:
|
2021-06-12 02:02:43 +02:00
|
|
|
return True
|
|
|
|
|
2022-04-15 13:59:30 +02:00
|
|
|
if mode == BirdseyeModeEnum.objects and object_box_count > 0:
|
2021-06-12 02:02:43 +02:00
|
|
|
return True
|
|
|
|
|
2021-06-09 14:41:30 +02:00
|
|
|
def update_frame(self):
|
2023-06-11 14:54:18 +02:00
|
|
|
"""Update to a new frame for birdseye."""
|
|
|
|
|
2024-02-10 18:55:13 +01:00
|
|
|
# determine how many cameras are tracking objects within the last inactivity_threshold seconds
|
|
|
|
active_cameras: set[str] = set(
|
2021-06-09 14:41:30 +02:00
|
|
|
[
|
|
|
|
cam
|
|
|
|
for cam, cam_data in self.cameras.items()
|
2021-06-13 15:48:14 +02:00
|
|
|
if cam_data["last_active_frame"] > 0
|
2024-02-10 18:55:13 +01:00
|
|
|
and cam_data["current_frame"] - cam_data["last_active_frame"]
|
|
|
|
< self.inactivity_threshold
|
2021-06-09 14:41:30 +02:00
|
|
|
]
|
|
|
|
)
|
2021-06-04 14:09:16 +02:00
|
|
|
|
2024-02-10 18:55:13 +01: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: (
|
|
|
|
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
|
|
|
|
|
2021-06-09 14:41:30 +02:00
|
|
|
# if there are no active cameras
|
|
|
|
if len(active_cameras) == 0:
|
|
|
|
# if the layout is already cleared
|
|
|
|
if len(self.camera_layout) == 0:
|
|
|
|
return False
|
|
|
|
# if the layout needs to be cleared
|
|
|
|
else:
|
|
|
|
self.camera_layout = []
|
2023-06-11 14:54:18 +02:00
|
|
|
self.active_cameras = set()
|
2021-06-09 14:41:30 +02:00
|
|
|
self.clear_frame()
|
|
|
|
return True
|
2021-06-04 14:09:16 +02:00
|
|
|
|
2023-07-02 14:45:45 +02:00
|
|
|
# check if we need to reset the layout because there is a different number of cameras
|
2024-02-10 18:55:13 +01:00
|
|
|
if len(self.active_cameras) - len(active_cameras) == 0:
|
2024-02-19 14:26:59 +01:00
|
|
|
if len(self.active_cameras) == 1 and self.active_cameras != active_cameras:
|
2024-02-10 18:55:13 +01:00
|
|
|
reset_layout = True
|
|
|
|
elif max_camera_refresh:
|
|
|
|
reset_layout = True
|
|
|
|
else:
|
|
|
|
reset_layout = False
|
|
|
|
else:
|
|
|
|
reset_layout = True
|
2023-04-27 02:29:01 +02:00
|
|
|
|
2021-06-09 14:41:30 +02:00
|
|
|
# reset the layout if it needs to be different
|
2023-06-11 14:54:18 +02:00
|
|
|
if reset_layout:
|
|
|
|
logger.debug("Added new cameras, resetting layout...")
|
2021-06-04 14:09:16 +02:00
|
|
|
self.clear_frame()
|
2023-06-11 14:54:18 +02:00
|
|
|
self.active_cameras = active_cameras
|
|
|
|
|
|
|
|
# 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,
|
|
|
|
),
|
|
|
|
)
|
2021-06-04 14:09:16 +02:00
|
|
|
|
2023-06-11 14:54:18 +02:00
|
|
|
if len(active_cameras) == 1:
|
|
|
|
# show single camera as fullscreen
|
|
|
|
camera = active_cameras_to_add[0]
|
|
|
|
camera_dims = self.cameras[camera]["dimensions"].copy()
|
2023-07-06 14:30:05 +02:00
|
|
|
scaled_width = int(self.canvas.height * camera_dims[0] / camera_dims[1])
|
2024-02-10 18:55:13 +01:00
|
|
|
|
|
|
|
# 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
|
|
|
|
)
|
|
|
|
|
2023-06-11 14:54:18 +02:00
|
|
|
self.camera_layout = [
|
|
|
|
[
|
|
|
|
(
|
|
|
|
camera,
|
|
|
|
(
|
2024-02-10 18:55:13 +01:00
|
|
|
x_offset,
|
2023-06-11 14:54:18 +02:00
|
|
|
0,
|
|
|
|
int(scaled_width * coefficient),
|
2023-07-06 14:30:05 +02:00
|
|
|
int(self.canvas.height * coefficient),
|
2023-06-11 14:54:18 +02:00
|
|
|
),
|
|
|
|
)
|
|
|
|
]
|
|
|
|
]
|
|
|
|
else:
|
|
|
|
# calculate optimal layout
|
2023-07-06 14:30:05 +02:00
|
|
|
coefficient = self.canvas.get_coefficient(len(active_cameras))
|
2023-06-12 12:06:02 +02:00
|
|
|
calculating = True
|
2023-06-11 14:54:18 +02:00
|
|
|
|
|
|
|
# decrease scaling coefficient until height of all cameras can fit into the birdseye canvas
|
2023-06-12 12:06:02 +02:00
|
|
|
while calculating:
|
2023-07-01 15:18:33 +02:00
|
|
|
if self.stop_event.is_set():
|
|
|
|
return
|
|
|
|
|
2023-07-02 14:45:45 +02:00
|
|
|
layout_candidate = self.calculate_layout(
|
2023-06-11 14:54:18 +02:00
|
|
|
active_cameras_to_add,
|
|
|
|
coefficient,
|
|
|
|
)
|
|
|
|
|
2023-06-16 14:35:36 +02:00
|
|
|
if not layout_candidate:
|
|
|
|
if coefficient < 10:
|
|
|
|
coefficient += 1
|
|
|
|
continue
|
|
|
|
else:
|
|
|
|
logger.error("Error finding appropriate birdseye layout")
|
|
|
|
return
|
|
|
|
|
|
|
|
calculating = False
|
2023-07-06 14:30:05 +02:00
|
|
|
self.canvas.set_coefficient(len(active_cameras), coefficient)
|
2023-06-11 14:54:18 +02:00
|
|
|
|
|
|
|
self.camera_layout = layout_candidate
|
|
|
|
|
|
|
|
for row in self.camera_layout:
|
|
|
|
for position in row:
|
2021-06-09 14:41:30 +02:00
|
|
|
self.copy_to_position(
|
2023-06-11 14:54:18 +02:00
|
|
|
position[1], position[0], self.cameras[position[0]]["current_frame"]
|
2021-06-09 14:41:30 +02:00
|
|
|
)
|
2021-05-31 16:22:00 +02:00
|
|
|
|
2021-06-04 14:09:16 +02:00
|
|
|
return True
|
|
|
|
|
2024-02-10 18:55:13 +01:00
|
|
|
def calculate_layout(
|
|
|
|
self,
|
|
|
|
cameras_to_add: list[str],
|
|
|
|
coefficient: float,
|
|
|
|
) -> tuple[any]:
|
2023-07-02 14:45:45 +02:00
|
|
|
"""Calculate the optimal layout for 2+ cameras."""
|
|
|
|
|
2023-10-31 01:24:42 +01:00
|
|
|
def map_layout(camera_layout: list[list[any]], row_height: int):
|
2023-07-02 14:45:45 +02:00
|
|
|
"""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()
|
2023-07-06 14:30:05 +02:00
|
|
|
camera_aspect = cameras[1]
|
2023-07-02 14:45:45 +02:00
|
|
|
|
|
|
|
if camera_dims[1] > camera_dims[0]:
|
|
|
|
scaled_height = int(row_height * 2)
|
2023-07-06 14:30:05 +02:00
|
|
|
scaled_width = int(scaled_height * camera_aspect)
|
2023-07-02 14:45:45 +02:00
|
|
|
starting_x = scaled_width
|
|
|
|
else:
|
|
|
|
scaled_height = row_height
|
2023-07-06 14:30:05 +02:00
|
|
|
scaled_width = int(scaled_height * camera_aspect)
|
2023-07-02 14:45:45 +02:00
|
|
|
|
|
|
|
# layout is too large
|
|
|
|
if (
|
2023-07-06 14:30:05 +02:00
|
|
|
x + scaled_width > self.canvas.width
|
|
|
|
or y + scaled_height > self.canvas.height
|
2023-07-02 14:45:45 +02:00
|
|
|
):
|
2023-10-31 01:24:42 +01:00
|
|
|
return x + scaled_width, y + scaled_height, None
|
2023-07-02 14:45:45 +02:00
|
|
|
|
|
|
|
final_row.append((cameras[0], (x, y, scaled_width, scaled_height)))
|
|
|
|
x += scaled_width
|
|
|
|
|
|
|
|
y += row_height
|
|
|
|
candidate_layout.append(final_row)
|
|
|
|
|
2023-10-27 00:23:39 +02:00
|
|
|
if max_width == 0:
|
|
|
|
max_width = x
|
|
|
|
|
2023-07-02 14:45:45 +02:00
|
|
|
return max_width, y, candidate_layout
|
|
|
|
|
2023-07-06 14:30:05 +02:00
|
|
|
canvas_aspect_x, canvas_aspect_y = self.canvas.get_aspect(coefficient)
|
2023-07-02 14:45:45 +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()
|
2023-07-06 14:30:05 +02:00
|
|
|
camera_aspect_x, camera_aspect_y = self.canvas.get_camera_aspect(
|
|
|
|
camera, camera_dims[0], camera_dims[1]
|
|
|
|
)
|
2023-07-02 14:45:45 +02:00
|
|
|
|
|
|
|
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,
|
2023-07-06 14:30:05 +02:00
|
|
|
camera_aspect_x / camera_aspect_y,
|
2023-07-02 14:45:45 +02:00
|
|
|
)
|
|
|
|
)
|
|
|
|
|
|
|
|
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,
|
2023-07-06 14:30:05 +02:00
|
|
|
camera_aspect_x / camera_aspect_y,
|
2023-07-02 14:45:45 +02:00
|
|
|
)
|
|
|
|
)
|
|
|
|
x += camera_aspect_x
|
|
|
|
|
|
|
|
if y + max_y > canvas_aspect_y:
|
|
|
|
return None
|
|
|
|
|
2023-07-06 14:30:05 +02:00
|
|
|
row_height = int(self.canvas.height / coefficient)
|
2023-10-31 01:24:42 +01:00
|
|
|
total_width, total_height, standard_candidate_layout = map_layout(
|
|
|
|
camera_layout, row_height
|
|
|
|
)
|
2023-07-02 14:45:45 +02:00
|
|
|
|
2023-10-27 00:23:39 +02:00
|
|
|
if not standard_candidate_layout:
|
2023-10-31 01:24:42 +01: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-27 00:23:39 +02:00
|
|
|
|
2023-07-02 14:45:45 +02:00
|
|
|
# layout can't be optimized more
|
2023-07-06 14:30:05 +02:00
|
|
|
if total_width / self.canvas.width >= 0.99:
|
2023-07-02 14:45:45 +02:00
|
|
|
return standard_candidate_layout
|
|
|
|
|
|
|
|
scale_up_percent = min(
|
2023-10-27 00:23:39 +02:00
|
|
|
1 / (total_width / self.canvas.width),
|
|
|
|
1 / (total_height / self.canvas.height),
|
2023-07-02 14:45:45 +02:00
|
|
|
)
|
2023-10-27 00:23:39 +02:00
|
|
|
row_height = int(row_height * scale_up_percent)
|
2023-10-31 01:24:42 +01:00
|
|
|
_, _, scaled_layout = map_layout(camera_layout, row_height)
|
2023-07-02 14:45:45 +02:00
|
|
|
|
|
|
|
if scaled_layout:
|
|
|
|
return scaled_layout
|
|
|
|
else:
|
|
|
|
return standard_candidate_layout
|
|
|
|
|
2021-06-09 14:41:30 +02:00
|
|
|
def update(self, camera, object_count, motion_count, frame_time, frame) -> bool:
|
2022-04-15 13:59:30 +02:00
|
|
|
# don't process if birdseye is disabled for this camera
|
|
|
|
camera_config = self.config.cameras[camera].birdseye
|
2024-02-19 14:26:59 +01:00
|
|
|
|
2022-04-15 13:59:30 +02:00
|
|
|
if not camera_config.enabled:
|
|
|
|
return False
|
2021-06-09 14:41:30 +02:00
|
|
|
|
2023-10-26 13:20:55 +02:00
|
|
|
# disabling birdseye is a little tricky
|
2024-02-19 14:26:59 +01:00
|
|
|
if not camera_config.enabled:
|
2023-10-26 13:20:55 +02:00
|
|
|
# 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
|
|
|
|
|
|
|
|
return False
|
|
|
|
|
2021-06-09 14:41:30 +02:00
|
|
|
# update the last active frame for the camera
|
2021-06-13 15:48:14 +02:00
|
|
|
self.cameras[camera]["current_frame"] = frame_time
|
2024-02-19 14:26:59 +01:00
|
|
|
if self.camera_active(camera_config.mode, object_count, motion_count):
|
2021-06-09 14:41:30 +02:00
|
|
|
self.cameras[camera]["last_active_frame"] = frame_time
|
|
|
|
|
|
|
|
now = datetime.datetime.now().timestamp()
|
|
|
|
|
2021-06-10 15:05:06 +02:00
|
|
|
# limit output to 10 fps
|
|
|
|
if (now - self.last_output_time) < 1 / 10:
|
2021-06-09 14:41:30 +02:00
|
|
|
return False
|
|
|
|
|
2023-06-12 12:06:02 +02:00
|
|
|
try:
|
|
|
|
updated_frame = self.update_frame()
|
|
|
|
except Exception:
|
|
|
|
updated_frame = False
|
|
|
|
self.active_cameras = []
|
2023-09-21 12:22:11 +02:00
|
|
|
self.camera_layout = []
|
2023-06-12 12:06:02 +02:00
|
|
|
print(traceback.format_exc())
|
|
|
|
|
2021-06-13 16:01:24 +02:00
|
|
|
# if the frame was updated or the fps is too low, send frame
|
2023-06-12 12:06:02 +02:00
|
|
|
if updated_frame or (now - self.last_output_time) > 1:
|
2021-06-13 16:01:24 +02:00
|
|
|
self.last_output_time = now
|
|
|
|
return True
|
|
|
|
return False
|
2021-06-09 14:41:30 +02:00
|
|
|
|
2021-05-31 16:22:00 +02:00
|
|
|
|
2023-12-03 15:16:01 +01:00
|
|
|
class Birdseye:
|
|
|
|
def __init__(
|
|
|
|
self,
|
|
|
|
config: FrigateConfig,
|
2023-12-20 15:34:13 +01:00
|
|
|
frame_manager: SharedMemoryFrameManager,
|
2023-12-03 15:16:01 +01:00
|
|
|
stop_event: mp.Event,
|
|
|
|
websocket_server,
|
|
|
|
) -> None:
|
|
|
|
self.config = config
|
|
|
|
self.input = queue.Queue(maxsize=10)
|
|
|
|
self.converter = FFMpegConverter(
|
2023-11-08 00:24:56 +01:00
|
|
|
"birdseye",
|
2023-12-03 15:16:01 +01:00
|
|
|
self.input,
|
2023-11-08 00:24:56 +01:00
|
|
|
stop_event,
|
2021-06-11 14:50:09 +02:00
|
|
|
config.birdseye.width,
|
|
|
|
config.birdseye.height,
|
|
|
|
config.birdseye.width,
|
|
|
|
config.birdseye.height,
|
|
|
|
config.birdseye.quality,
|
2023-01-17 00:50:35 +01:00
|
|
|
config.birdseye.restream,
|
2021-06-11 14:50:09 +02:00
|
|
|
)
|
2023-12-03 15:16:01 +01:00
|
|
|
self.broadcaster = BroadcastThread(
|
|
|
|
"birdseye", self.converter, websocket_server, stop_event
|
|
|
|
)
|
2024-02-19 14:26:59 +01:00
|
|
|
self.birdseye_manager = BirdsEyeFrameManager(config, frame_manager, stop_event)
|
|
|
|
self.config_subscriber = ConfigSubscriber("config/birdseye/")
|
2021-05-31 16:22:00 +02:00
|
|
|
|
2023-12-03 15:16:01 +01:00
|
|
|
if config.birdseye.restream:
|
|
|
|
self.birdseye_buffer = frame_manager.create(
|
|
|
|
"birdseye",
|
|
|
|
self.birdseye_manager.yuv_shape[0] * self.birdseye_manager.yuv_shape[1],
|
|
|
|
)
|
2023-11-08 00:24:56 +01:00
|
|
|
|
2023-12-03 15:16:01 +01:00
|
|
|
self.converter.start()
|
|
|
|
self.broadcaster.start()
|
2021-05-30 13:45:37 +02:00
|
|
|
|
2023-12-03 15:16:01 +01:00
|
|
|
def write_data(
|
|
|
|
self,
|
|
|
|
camera: str,
|
|
|
|
current_tracked_objects: list[dict[str, any]],
|
|
|
|
motion_boxes: list[list[int]],
|
|
|
|
frame_time: float,
|
|
|
|
frame,
|
|
|
|
) -> None:
|
2024-02-19 14:26:59 +01:00
|
|
|
# check if there is an updated config
|
|
|
|
while True:
|
|
|
|
(
|
|
|
|
updated_topic,
|
|
|
|
updated_birdseye_config,
|
|
|
|
) = self.config_subscriber.check_for_update()
|
|
|
|
|
|
|
|
if not updated_topic:
|
|
|
|
break
|
|
|
|
|
|
|
|
camera_name = updated_topic.rpartition("/")[-1]
|
|
|
|
self.config.cameras[camera_name].birdseye = updated_birdseye_config
|
|
|
|
|
2023-12-03 15:16:01 +01:00
|
|
|
if self.birdseye_manager.update(
|
|
|
|
camera,
|
|
|
|
len([o for o in current_tracked_objects if not o["stationary"]]),
|
|
|
|
len(motion_boxes),
|
|
|
|
frame_time,
|
|
|
|
frame,
|
|
|
|
):
|
|
|
|
frame_bytes = self.birdseye_manager.frame.tobytes()
|
2021-05-31 16:22:00 +02:00
|
|
|
|
2023-12-03 15:16:01 +01:00
|
|
|
if self.config.birdseye.restream:
|
|
|
|
self.birdseye_buffer[:] = frame_bytes
|
2022-12-31 15:54:10 +01:00
|
|
|
|
2023-11-08 00:24:56 +01:00
|
|
|
try:
|
2023-12-03 15:16:01 +01:00
|
|
|
self.input.put_nowait(frame_bytes)
|
2023-11-08 00:24:56 +01:00
|
|
|
except queue.Full:
|
|
|
|
# drop frames if queue is full
|
|
|
|
pass
|
2021-05-30 13:45:37 +02:00
|
|
|
|
2023-12-03 15:16:01 +01:00
|
|
|
def stop(self) -> None:
|
2024-02-19 14:26:59 +01:00
|
|
|
self.config_subscriber.stop()
|
2023-12-03 15:16:01 +01:00
|
|
|
self.converter.join()
|
|
|
|
self.broadcaster.join()
|