mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-08-02 09:02:15 +03:00
Dynamically enable/disable cameras (#16894)
* config options * metrics * stop and restart ffmpeg processes * dispatcher * frontend websocket * buttons for testing * don't recreate log pipe * add/remove cam from birdseye when enabling/disabling * end all objects and send empty camera activity * enable/disable switch in ui * disable buttons when camera is disabled * use enabled_in_config for some frontend checks * tweaks * handle settings pane with disabled cameras * frontend tweaks * change to debug log * mqtt docs * tweak * ensure all ffmpeg processes are initially started * clean up * use zmq * remove camera metrics * remove camera metrics * tweaks * frontend tweaks
This commit is contained in:
+129
-98
@@ -10,6 +10,7 @@ import queue
|
||||
import subprocess as sp
|
||||
import threading
|
||||
import traceback
|
||||
from typing import Optional
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
@@ -280,6 +281,12 @@ class BirdsEyeFrameManager:
|
||||
self.stop_event = stop_event
|
||||
self.inactivity_threshold = config.birdseye.inactivity_threshold
|
||||
|
||||
self.enabled_subscribers = {
|
||||
cam: ConfigSubscriber(f"config/enabled/{cam}", True)
|
||||
for cam in config.cameras.keys()
|
||||
if config.cameras[cam].enabled_in_config
|
||||
}
|
||||
|
||||
if config.birdseye.layout.max_cameras:
|
||||
self.last_refresh_time = 0
|
||||
|
||||
@@ -380,8 +387,18 @@ class BirdsEyeFrameManager:
|
||||
if mode == BirdseyeModeEnum.objects and object_box_count > 0:
|
||||
return True
|
||||
|
||||
def update_frame(self, frame: np.ndarray):
|
||||
"""Update to a new frame for birdseye."""
|
||||
def _get_enabled_state(self, camera: str) -> bool:
|
||||
"""Fetch the latest enabled state for a camera from ZMQ."""
|
||||
_, config_data = self.enabled_subscribers[camera].check_for_update()
|
||||
if config_data:
|
||||
return config_data.enabled
|
||||
return self.config.cameras[camera].enabled
|
||||
|
||||
def update_frame(self, frame: Optional[np.ndarray] = None) -> bool:
|
||||
"""
|
||||
Update birdseye, optionally with a new frame.
|
||||
When no frame is passed, check the layout and update for any disabled cameras.
|
||||
"""
|
||||
|
||||
# determine how many cameras are tracking objects within the last inactivity_threshold seconds
|
||||
active_cameras: set[str] = set(
|
||||
@@ -389,11 +406,14 @@ class BirdsEyeFrameManager:
|
||||
cam
|
||||
for cam, cam_data in self.cameras.items()
|
||||
if self.config.cameras[cam].birdseye.enabled
|
||||
and self.config.cameras[cam].enabled_in_config
|
||||
and self._get_enabled_state(cam)
|
||||
and cam_data["last_active_frame"] > 0
|
||||
and cam_data["current_frame_time"] - cam_data["last_active_frame"]
|
||||
< self.inactivity_threshold
|
||||
]
|
||||
)
|
||||
logger.debug(f"Active cameras: {active_cameras}")
|
||||
|
||||
max_cameras = self.config.birdseye.layout.max_cameras
|
||||
max_camera_refresh = False
|
||||
@@ -411,118 +431,125 @@ class BirdsEyeFrameManager:
|
||||
- self.cameras[active_camera]["last_active_frame"]
|
||||
),
|
||||
)
|
||||
active_cameras = limited_active_cameras[
|
||||
: self.config.birdseye.layout.max_cameras
|
||||
]
|
||||
active_cameras = limited_active_cameras[:max_cameras]
|
||||
max_camera_refresh = True
|
||||
self.last_refresh_time = now
|
||||
|
||||
# if there are no active cameras
|
||||
# Track if the frame changes
|
||||
frame_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
|
||||
# if the layout needs to be cleared
|
||||
else:
|
||||
self.camera_layout = []
|
||||
self.active_cameras = set()
|
||||
self.clear_frame()
|
||||
return True
|
||||
|
||||
# check if we need to reset the layout because there is a different number of cameras
|
||||
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
|
||||
else:
|
||||
reset_layout = False
|
||||
else:
|
||||
reset_layout = True
|
||||
|
||||
# reset the layout if it needs to be different
|
||||
if reset_layout:
|
||||
logger.debug("Added new cameras, resetting layout...")
|
||||
self.camera_layout = []
|
||||
self.active_cameras = set()
|
||||
self.clear_frame()
|
||||
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,
|
||||
),
|
||||
)
|
||||
|
||||
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])
|
||||
|
||||
# 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)
|
||||
frame_changed = True
|
||||
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
|
||||
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),
|
||||
),
|
||||
)
|
||||
]
|
||||
]
|
||||
reset_layout = False
|
||||
else:
|
||||
# calculate optimal layout
|
||||
coefficient = self.canvas.get_coefficient(len(active_cameras))
|
||||
calculating = True
|
||||
reset_layout = True
|
||||
|
||||
# decrease scaling coefficient until height of all cameras can fit into the birdseye canvas
|
||||
while calculating:
|
||||
if self.stop_event.is_set():
|
||||
return
|
||||
if reset_layout:
|
||||
logger.debug("Resetting Birdseye layout...")
|
||||
self.clear_frame()
|
||||
self.active_cameras = active_cameras
|
||||
|
||||
layout_candidate = self.calculate_layout(
|
||||
active_cameras_to_add,
|
||||
coefficient,
|
||||
# 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,
|
||||
),
|
||||
)
|
||||
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]
|
||||
)
|
||||
|
||||
if not layout_candidate:
|
||||
if coefficient < 10:
|
||||
coefficient += 1
|
||||
continue
|
||||
else:
|
||||
logger.error("Error finding appropriate birdseye layout")
|
||||
# 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,
|
||||
(
|
||||
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():
|
||||
return
|
||||
|
||||
calculating = False
|
||||
self.canvas.set_coefficient(len(active_cameras), coefficient)
|
||||
layout_candidate = self.calculate_layout(
|
||||
active_cameras_to_add, coefficient
|
||||
)
|
||||
|
||||
self.camera_layout = layout_candidate
|
||||
if not layout_candidate:
|
||||
if coefficient < 10:
|
||||
coefficient += 1
|
||||
continue
|
||||
else:
|
||||
logger.error(
|
||||
"Error finding appropriate birdseye layout"
|
||||
)
|
||||
return
|
||||
calculating = False
|
||||
self.canvas.set_coefficient(len(active_cameras), coefficient)
|
||||
|
||||
for row in self.camera_layout:
|
||||
for position in row:
|
||||
self.copy_to_position(
|
||||
position[1],
|
||||
position[0],
|
||||
self.cameras[position[0]]["current_frame"],
|
||||
)
|
||||
self.camera_layout = layout_candidate
|
||||
frame_changed = True
|
||||
|
||||
return True
|
||||
# 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
|
||||
|
||||
return frame_changed
|
||||
|
||||
def calculate_layout(
|
||||
self,
|
||||
@@ -678,11 +705,8 @@ class BirdsEyeFrameManager:
|
||||
# don't process if birdseye is disabled for this camera
|
||||
camera_config = self.config.cameras[camera].birdseye
|
||||
|
||||
if not camera_config.enabled:
|
||||
return False
|
||||
|
||||
# disabling birdseye is a little tricky
|
||||
if not camera_config.enabled:
|
||||
if not camera_config.enabled or not self._get_enabled_state(camera):
|
||||
# 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:
|
||||
@@ -716,6 +740,11 @@ class BirdsEyeFrameManager:
|
||||
return True
|
||||
return False
|
||||
|
||||
def stop(self):
|
||||
"""Clean up subscribers when stopping."""
|
||||
for subscriber in self.enabled_subscribers.values():
|
||||
subscriber.stop()
|
||||
|
||||
|
||||
class Birdseye:
|
||||
def __init__(
|
||||
@@ -743,6 +772,7 @@ class Birdseye:
|
||||
self.birdseye_manager = BirdsEyeFrameManager(config, stop_event)
|
||||
self.config_subscriber = ConfigSubscriber("config/birdseye/")
|
||||
self.frame_manager = SharedMemoryFrameManager()
|
||||
self.stop_event = stop_event
|
||||
|
||||
if config.birdseye.restream:
|
||||
self.birdseye_buffer = self.frame_manager.create(
|
||||
@@ -794,5 +824,6 @@ class Birdseye:
|
||||
|
||||
def stop(self) -> None:
|
||||
self.config_subscriber.stop()
|
||||
self.birdseye_manager.stop()
|
||||
self.converter.join()
|
||||
self.broadcaster.join()
|
||||
|
||||
@@ -17,6 +17,7 @@ from ws4py.server.wsgirefserver import (
|
||||
)
|
||||
from ws4py.server.wsgiutils import WebSocketWSGIApplication
|
||||
|
||||
from frigate.comms.config_updater import ConfigSubscriber
|
||||
from frigate.comms.detections_updater import DetectionSubscriber, DetectionTypeEnum
|
||||
from frigate.comms.ws import WebSocket
|
||||
from frigate.config import FrigateConfig
|
||||
@@ -59,6 +60,12 @@ def output_frames(
|
||||
|
||||
detection_subscriber = DetectionSubscriber(DetectionTypeEnum.video)
|
||||
|
||||
enabled_subscribers = {
|
||||
camera: ConfigSubscriber(f"config/enabled/{camera}", True)
|
||||
for camera in config.cameras.keys()
|
||||
if config.cameras[camera].enabled_in_config
|
||||
}
|
||||
|
||||
jsmpeg_cameras: dict[str, JsmpegCamera] = {}
|
||||
birdseye: Optional[Birdseye] = None
|
||||
preview_recorders: dict[str, PreviewRecorder] = {}
|
||||
@@ -80,6 +87,13 @@ def output_frames(
|
||||
|
||||
websocket_thread.start()
|
||||
|
||||
def get_enabled_state(camera: str) -> bool:
|
||||
_, config_data = enabled_subscribers[camera].check_for_update()
|
||||
if config_data:
|
||||
return config_data.enabled
|
||||
# default
|
||||
return config.cameras[camera].enabled
|
||||
|
||||
while not stop_event.is_set():
|
||||
(topic, data) = detection_subscriber.check_for_update(timeout=1)
|
||||
|
||||
@@ -95,6 +109,9 @@ def output_frames(
|
||||
_,
|
||||
) = data
|
||||
|
||||
if not get_enabled_state(camera):
|
||||
continue
|
||||
|
||||
frame = frame_manager.get(frame_name, config.cameras[camera].frame_shape_yuv)
|
||||
|
||||
if frame is None:
|
||||
@@ -184,6 +201,9 @@ def output_frames(
|
||||
if birdseye is not None:
|
||||
birdseye.stop()
|
||||
|
||||
for subscriber in enabled_subscribers.values():
|
||||
subscriber.stop()
|
||||
|
||||
websocket_server.manager.close_all()
|
||||
websocket_server.manager.stop()
|
||||
websocket_server.manager.join()
|
||||
|
||||
Reference in New Issue
Block a user