mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-07-26 13:47:03 +02:00
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:
parent
559af44682
commit
5dd30b273a
@ -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=(
|
||||
{
|
||||
|
@ -5,6 +5,7 @@ from pydantic import BaseModel
|
||||
|
||||
class AppConfigSetBody(BaseModel):
|
||||
requires_restart: int = 1
|
||||
update_topic: str | None = None
|
||||
|
||||
|
||||
class AppPutPasswordBody(BaseModel):
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
@ -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:
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -119,6 +119,7 @@ class BaseTestHttp(unittest.TestCase):
|
||||
None,
|
||||
stats,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
|
||||
def insert_mock_event(
|
||||
|
@ -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:
|
||||
|
@ -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()
|
||||
|
@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
|
@ -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) {
|
||||
|
@ -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) {
|
||||
|
@ -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) {
|
||||
|
@ -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) {
|
||||
|
Loading…
Reference in New Issue
Block a user