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:
Josh Hawkins
2025-03-03 08:30:52 -07:00
committed by GitHub
parent 71e6e04d77
commit 531042467a
24 changed files with 713 additions and 202 deletions
+129 -98
View File
@@ -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()
+20
View File
@@ -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()