Dynamically update masks and zones for cameras (#18359)

* Include config publisher in api

* Call update topic for passed topics

* Update zones dynamically

* Update zones internally

* Support zone and mask reset

* Handle updating objects config

* Don't put status for needing to restart Frigate

* Cleanup http tests

* Fix tests
This commit is contained in:
Nicolas Mowen 2025-05-22 20:51:23 -06:00 committed by GitHub
parent 559af44682
commit 5dd30b273a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 100 additions and 118 deletions

View File

@ -28,6 +28,10 @@ from frigate.api.defs.query.app_query_parameters import AppTimelineHourlyQueryPa
from frigate.api.defs.request.app_body import AppConfigSetBody
from frigate.api.defs.tags import Tags
from frigate.config import FrigateConfig
from frigate.config.camera.updater import (
CameraConfigUpdateEnum,
CameraConfigUpdateTopic,
)
from frigate.models import Event, Timeline
from frigate.stats.prometheus import get_metrics, update_metrics
from frigate.util.builtin import (
@ -385,8 +389,18 @@ def config_set(request: Request, body: AppConfigSetBody):
status_code=500,
)
if body.requires_restart == 0:
if body.requires_restart == 0 or body.update_topic:
request.app.frigate_config = config
if body.update_topic and body.update_topic.startswith("config/cameras/"):
_, _, camera, field = body.update_topic.split("/")
settings = config.get_nested_object(body.update_topic)
request.app.config_publisher.publish_update(
CameraConfigUpdateTopic(CameraConfigUpdateEnum[field], camera),
settings,
)
return JSONResponse(
content=(
{

View File

@ -5,6 +5,7 @@ from pydantic import BaseModel
class AppConfigSetBody(BaseModel):
requires_restart: int = 1
update_topic: str | None = None
class AppPutPasswordBody(BaseModel):

View File

@ -26,6 +26,7 @@ from frigate.comms.event_metadata_updater import (
EventMetadataPublisher,
)
from frigate.config import FrigateConfig
from frigate.config.camera.updater import CameraConfigUpdatePublisher
from frigate.embeddings import EmbeddingsContext
from frigate.ptz.onvif import OnvifController
from frigate.stats.emitter import StatsEmitter
@ -57,6 +58,7 @@ def create_fastapi_app(
onvif: OnvifController,
stats_emitter: StatsEmitter,
event_metadata_updater: EventMetadataPublisher,
config_publisher: CameraConfigUpdatePublisher,
):
logger.info("Starting FastAPI app")
app = FastAPI(
@ -127,6 +129,7 @@ def create_fastapi_app(
app.onvif = onvif
app.stats_emitter = stats_emitter
app.event_metadata_updater = event_metadata_updater
app.config_publisher = config_publisher
app.jwt_token = get_jwt_secret() if frigate_config.auth.enabled else None
return app

View File

@ -673,6 +673,7 @@ class FrigateApp:
self.onvif_controller,
self.stats_emitter,
self.event_metadata_updater,
self.inter_config_updater,
),
host="127.0.0.1",
port=5001,

View File

@ -1,5 +1,29 @@
from typing import Any
from pydantic import BaseModel, ConfigDict
class FrigateBaseModel(BaseModel):
model_config = ConfigDict(extra="forbid", protected_namespaces=())
def get_nested_object(self, path: str) -> Any:
parts = path.split("/")
obj = self
for part in parts:
if part == "config":
continue
if isinstance(obj, BaseModel):
try:
obj = getattr(obj, part)
except AttributeError:
return None
elif isinstance(obj, dict):
try:
obj = obj[part]
except KeyError:
return None
else:
return None
return obj

View File

@ -17,6 +17,7 @@ class CameraConfigUpdateEnum(str, Enum):
enabled = "enabled"
motion = "motion" # includes motion and motion masks
notifications = "notifications"
objects = "objects"
record = "record"
review = "review"
snapshots = "snapshots"
@ -83,6 +84,8 @@ class CameraConfigUpdateSubscriber:
config.motion = updated_config
elif update_type == CameraConfigUpdateEnum.notifications:
config.notifications = updated_config
elif update_type == CameraConfigUpdateEnum.objects:
config.objects = updated_config
elif update_type == CameraConfigUpdateEnum.record:
config.record = updated_config
elif update_type == CameraConfigUpdateEnum.review:

View File

@ -1,6 +1,8 @@
from abc import ABC, abstractmethod
from typing import Tuple
from numpy import ndarray
from frigate.config import MotionConfig
@ -18,13 +20,21 @@ class MotionDetector(ABC):
pass
@abstractmethod
def detect(self, frame):
def detect(self, frame: ndarray) -> list:
"""Detect motion and return motion boxes."""
pass
@abstractmethod
def is_calibrating(self):
"""Return if motion is recalibrating."""
pass
@abstractmethod
def update_mask(self) -> None:
"""Update the motion mask after a config change."""
pass
@abstractmethod
def stop(self):
"""Stop any ongoing work and processes."""
pass

View File

@ -35,12 +35,7 @@ class ImprovedMotionDetector(MotionDetector):
self.avg_frame = np.zeros(self.motion_frame_size, np.float32)
self.motion_frame_count = 0
self.frame_counter = 0
resized_mask = cv2.resize(
config.mask,
dsize=(self.motion_frame_size[1], self.motion_frame_size[0]),
interpolation=cv2.INTER_AREA,
)
self.mask = np.where(resized_mask == [0])
self.update_mask()
self.save_images = False
self.calibrating = True
self.blur_radius = blur_radius
@ -236,6 +231,14 @@ class ImprovedMotionDetector(MotionDetector):
return motion_boxes
def update_mask(self) -> None:
resized_mask = cv2.resize(
self.config.mask,
dsize=(self.motion_frame_size[1], self.motion_frame_size[0]),
interpolation=cv2.INTER_AREA,
)
self.mask = np.where(resized_mask == [0])
def stop(self) -> None:
"""stop the motion detector."""
pass

View File

@ -119,6 +119,7 @@ class BaseTestHttp(unittest.TestCase):
None,
stats,
None,
None,
)
def insert_mock_event(

View File

@ -2,6 +2,7 @@ import datetime
import logging
import os
import unittest
from typing import Any
from unittest.mock import Mock
from fastapi.testclient import TestClient
@ -112,8 +113,8 @@ class TestHttp(unittest.TestCase):
except OSError:
pass
def test_get_good_event(self):
app = create_fastapi_app(
def __init_app(self, updater: Any | None = None) -> Any:
return create_fastapi_app(
FrigateConfig(**self.minimal_config),
self.db,
None,
@ -121,8 +122,12 @@ class TestHttp(unittest.TestCase):
None,
None,
None,
updater,
None,
)
def test_get_good_event(self):
app = self.__init_app()
id = "123456.random"
with TestClient(app) as client:
@ -134,16 +139,7 @@ class TestHttp(unittest.TestCase):
assert event["id"] == model_to_dict(Event.get(Event.id == id))["id"]
def test_get_bad_event(self):
app = create_fastapi_app(
FrigateConfig(**self.minimal_config),
self.db,
None,
None,
None,
None,
None,
None,
)
app = self.__init_app()
id = "123456.random"
bad_id = "654321.other"
@ -154,16 +150,7 @@ class TestHttp(unittest.TestCase):
assert event_response.json() == "Event not found"
def test_delete_event(self):
app = create_fastapi_app(
FrigateConfig(**self.minimal_config),
self.db,
None,
None,
None,
None,
None,
None,
)
app = self.__init_app()
id = "123456.random"
with TestClient(app) as client:
@ -176,16 +163,7 @@ class TestHttp(unittest.TestCase):
assert event == "Event not found"
def test_event_retention(self):
app = create_fastapi_app(
FrigateConfig(**self.minimal_config),
self.db,
None,
None,
None,
None,
None,
None,
)
app = self.__init_app()
id = "123456.random"
with TestClient(app) as client:
@ -202,16 +180,7 @@ class TestHttp(unittest.TestCase):
assert event["retain_indefinitely"] is False
def test_event_time_filtering(self):
app = create_fastapi_app(
FrigateConfig(**self.minimal_config),
self.db,
None,
None,
None,
None,
None,
None,
)
app = self.__init_app()
morning_id = "123456.random"
evening_id = "654321.random"
morning = 1656590400 # 06/30/2022 6 am (GMT)
@ -241,16 +210,7 @@ class TestHttp(unittest.TestCase):
def test_set_delete_sub_label(self):
mock_event_updater = Mock(spec=EventMetadataPublisher)
app = create_fastapi_app(
FrigateConfig(**self.minimal_config),
self.db,
None,
None,
None,
None,
None,
mock_event_updater,
)
app = app = self.__init_app(updater=mock_event_updater)
id = "123456.random"
sub_label = "sub"
@ -286,16 +246,7 @@ class TestHttp(unittest.TestCase):
def test_sub_label_list(self):
mock_event_updater = Mock(spec=EventMetadataPublisher)
app = create_fastapi_app(
FrigateConfig(**self.minimal_config),
self.db,
None,
None,
None,
None,
None,
mock_event_updater,
)
app = self.__init_app(updater=mock_event_updater)
id = "123456.random"
sub_label = "sub"
@ -318,16 +269,7 @@ class TestHttp(unittest.TestCase):
assert sub_labels == [sub_label]
def test_config(self):
app = create_fastapi_app(
FrigateConfig(**self.minimal_config),
self.db,
None,
None,
None,
None,
None,
None,
)
app = self.__init_app()
with TestClient(app) as client:
config = client.get("/config").json()
@ -335,16 +277,7 @@ class TestHttp(unittest.TestCase):
assert config["cameras"]["front_door"]
def test_recordings(self):
app = create_fastapi_app(
FrigateConfig(**self.minimal_config),
self.db,
None,
None,
None,
None,
None,
None,
)
app = self.__init_app()
id = "123456.random"
with TestClient(app) as client:

View File

@ -67,7 +67,8 @@ class TrackedObjectProcessor(threading.Thread):
self.ptz_autotracker_thread = ptz_autotracker_thread
self.config_subscriber = CameraConfigUpdateSubscriber(
self.config.cameras, [CameraConfigUpdateEnum.enabled]
self.config.cameras,
[CameraConfigUpdateEnum.enabled, CameraConfigUpdateEnum.zones],
)
self.requestor = InterProcessRequestor()

View File

@ -494,8 +494,6 @@ def track_camera(
frame_queue = camera_metrics.frame_queue
frame_shape = config.frame_shape
objects_to_track = config.objects.track
object_filters = config.objects.filters
motion_detector = ImprovedMotionDetector(
frame_shape,
@ -528,8 +526,6 @@ def track_camera(
object_tracker,
detected_objects_queue,
camera_metrics,
objects_to_track,
object_filters,
stop_event,
ptz_metrics,
region_grid,
@ -594,8 +590,6 @@ def process_frames(
object_tracker: ObjectTracker,
detected_objects_queue: Queue,
camera_metrics: CameraMetrics,
objects_to_track: list[str],
object_filters,
stop_event: MpEvent,
ptz_metrics: PTZMetrics,
region_grid: list[list[dict[str, Any]]],
@ -608,6 +602,7 @@ def process_frames(
CameraConfigUpdateEnum.detect,
CameraConfigUpdateEnum.enabled,
CameraConfigUpdateEnum.motion,
CameraConfigUpdateEnum.objects,
],
)
@ -650,6 +645,9 @@ def process_frames(
prev_enabled = camera_enabled
camera_enabled = camera_config.enabled
if "motion" in updated_configs:
motion_detector.update_mask()
if (
not camera_enabled
and prev_enabled != camera_enabled
@ -822,8 +820,8 @@ def process_frames(
frame,
model_config,
region,
objects_to_track,
object_filters,
camera_config.objects.track,
camera_config.objects.filters,
)
)

View File

@ -161,6 +161,7 @@ export default function MotionMaskEditPane({
axios
.put(`config/set?${queryString}`, {
requires_restart: 0,
update_topic: `config/cameras/${polygon.camera}/motion`,
})
.then((res) => {
if (res.status === 200) {

View File

@ -195,6 +195,7 @@ export default function ObjectMaskEditPane({
axios
.put(`config/set?${queryString}`, {
requires_restart: 0,
update_topic: `config/cameras/${polygon.camera}/objects`,
})
.then((res) => {
if (res.status === 200) {

View File

@ -326,6 +326,7 @@ export default function ZoneEditPane({
`config/set?cameras.${polygon.camera}.zones.${polygon.name}${renameAlertQueries}${renameDetectionQueries}`,
{
requires_restart: 0,
update_topic: `config/cameras/${polygon.camera}/zones`,
},
);
@ -409,7 +410,10 @@ export default function ZoneEditPane({
axios
.put(
`config/set?cameras.${polygon?.camera}.zones.${zoneName}.coordinates=${coordinates}${inertiaQuery}${loiteringTimeQuery}${speedThresholdQuery}${distancesQuery}${objectQueries}${alertQueries}${detectionQueries}`,
{ requires_restart: 0 },
{
requires_restart: 0,
update_topic: `config/cameras/${polygon.camera}/zones`,
},
)
.then((res) => {
if (res.status === 200) {

View File

@ -1,14 +1,7 @@
import { FrigateConfig } from "@/types/frigateConfig";
import useSWR from "swr";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import {
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { PolygonCanvas } from "@/components/settings/PolygonCanvas";
import { Polygon, PolygonType } from "@/types/canvas";
import { interpolatePoints, parseCoordinates } from "@/utils/canvasUtil";
@ -36,7 +29,6 @@ import ObjectMaskEditPane from "@/components/settings/ObjectMaskEditPane";
import PolygonItem from "@/components/settings/PolygonItem";
import { Link } from "react-router-dom";
import { isDesktop } from "react-device-detect";
import { StatusBarMessagesContext } from "@/context/statusbar-provider";
import { useSearchEffect } from "@/hooks/use-overlay-state";
import { useTranslation } from "react-i18next";
@ -68,8 +60,6 @@ export default function MasksAndZonesView({
const [activeLine, setActiveLine] = useState<number | undefined>();
const [snapPoints, setSnapPoints] = useState(false);
const { addMessage } = useContext(StatusBarMessagesContext)!;
const cameraConfig = useMemo(() => {
if (config && selectedCamera) {
return config.cameras[selectedCamera];
@ -192,13 +182,7 @@ export default function MasksAndZonesView({
setAllPolygons([...(editingPolygons ?? [])]);
setHoveredPolygonIndex(null);
setUnsavedChanges(false);
addMessage(
"masks_zones",
t("masksAndZones.restart_required"),
undefined,
"masks_zones",
);
}, [t, editingPolygons, setUnsavedChanges, addMessage]);
}, [editingPolygons, setUnsavedChanges]);
useEffect(() => {
if (isLoading) {