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.request.app_body import AppConfigSetBody
from frigate.api.defs.tags import Tags from frigate.api.defs.tags import Tags
from frigate.config import FrigateConfig from frigate.config import FrigateConfig
from frigate.config.camera.updater import (
CameraConfigUpdateEnum,
CameraConfigUpdateTopic,
)
from frigate.models import Event, Timeline from frigate.models import Event, Timeline
from frigate.stats.prometheus import get_metrics, update_metrics from frigate.stats.prometheus import get_metrics, update_metrics
from frigate.util.builtin import ( from frigate.util.builtin import (
@ -385,8 +389,18 @@ def config_set(request: Request, body: AppConfigSetBody):
status_code=500, status_code=500,
) )
if body.requires_restart == 0: if body.requires_restart == 0 or body.update_topic:
request.app.frigate_config = config 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( return JSONResponse(
content=( content=(
{ {

View File

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

View File

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

View File

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

View File

@ -1,5 +1,29 @@
from typing import Any
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
class FrigateBaseModel(BaseModel): class FrigateBaseModel(BaseModel):
model_config = ConfigDict(extra="forbid", protected_namespaces=()) 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" enabled = "enabled"
motion = "motion" # includes motion and motion masks motion = "motion" # includes motion and motion masks
notifications = "notifications" notifications = "notifications"
objects = "objects"
record = "record" record = "record"
review = "review" review = "review"
snapshots = "snapshots" snapshots = "snapshots"
@ -83,6 +84,8 @@ class CameraConfigUpdateSubscriber:
config.motion = updated_config config.motion = updated_config
elif update_type == CameraConfigUpdateEnum.notifications: elif update_type == CameraConfigUpdateEnum.notifications:
config.notifications = updated_config config.notifications = updated_config
elif update_type == CameraConfigUpdateEnum.objects:
config.objects = updated_config
elif update_type == CameraConfigUpdateEnum.record: elif update_type == CameraConfigUpdateEnum.record:
config.record = updated_config config.record = updated_config
elif update_type == CameraConfigUpdateEnum.review: elif update_type == CameraConfigUpdateEnum.review:

View File

@ -1,6 +1,8 @@
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import Tuple from typing import Tuple
from numpy import ndarray
from frigate.config import MotionConfig from frigate.config import MotionConfig
@ -18,13 +20,21 @@ class MotionDetector(ABC):
pass pass
@abstractmethod @abstractmethod
def detect(self, frame): def detect(self, frame: ndarray) -> list:
"""Detect motion and return motion boxes."""
pass pass
@abstractmethod @abstractmethod
def is_calibrating(self): 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 pass
@abstractmethod @abstractmethod
def stop(self): def stop(self):
"""Stop any ongoing work and processes."""
pass pass

View File

@ -35,12 +35,7 @@ class ImprovedMotionDetector(MotionDetector):
self.avg_frame = np.zeros(self.motion_frame_size, np.float32) self.avg_frame = np.zeros(self.motion_frame_size, np.float32)
self.motion_frame_count = 0 self.motion_frame_count = 0
self.frame_counter = 0 self.frame_counter = 0
resized_mask = cv2.resize( self.update_mask()
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.save_images = False self.save_images = False
self.calibrating = True self.calibrating = True
self.blur_radius = blur_radius self.blur_radius = blur_radius
@ -236,6 +231,14 @@ class ImprovedMotionDetector(MotionDetector):
return motion_boxes 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: def stop(self) -> None:
"""stop the motion detector.""" """stop the motion detector."""
pass pass

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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