diff --git a/frigate/api/app.py b/frigate/api/app.py index 5860377e7..38116f6d6 100644 --- a/frigate/api/app.py +++ b/frigate/api/app.py @@ -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=( { diff --git a/frigate/api/defs/request/app_body.py b/frigate/api/defs/request/app_body.py index 1fc05db2f..7456a6c77 100644 --- a/frigate/api/defs/request/app_body.py +++ b/frigate/api/defs/request/app_body.py @@ -5,6 +5,7 @@ from pydantic import BaseModel class AppConfigSetBody(BaseModel): requires_restart: int = 1 + update_topic: str | None = None class AppPutPasswordBody(BaseModel): diff --git a/frigate/api/fastapi_app.py b/frigate/api/fastapi_app.py index 0657752dc..1265f3af9 100644 --- a/frigate/api/fastapi_app.py +++ b/frigate/api/fastapi_app.py @@ -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 diff --git a/frigate/app.py b/frigate/app.py index 1b78181ff..ebbc003e8 100644 --- a/frigate/app.py +++ b/frigate/app.py @@ -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, diff --git a/frigate/config/base.py b/frigate/config/base.py index 068a68acd..1e369e293 100644 --- a/frigate/config/base.py +++ b/frigate/config/base.py @@ -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 diff --git a/frigate/config/camera/updater.py b/frigate/config/camera/updater.py index 5abca57eb..140e02207 100644 --- a/frigate/config/camera/updater.py +++ b/frigate/config/camera/updater.py @@ -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: diff --git a/frigate/motion/__init__.py b/frigate/motion/__init__.py index db5f25879..1f6785d5d 100644 --- a/frigate/motion/__init__.py +++ b/frigate/motion/__init__.py @@ -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 diff --git a/frigate/motion/improved_motion.py b/frigate/motion/improved_motion.py index 10818ea70..77eae26a9 100644 --- a/frigate/motion/improved_motion.py +++ b/frigate/motion/improved_motion.py @@ -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 diff --git a/frigate/test/http_api/base_http_test.py b/frigate/test/http_api/base_http_test.py index 3c4a7ccdc..e758a14dc 100644 --- a/frigate/test/http_api/base_http_test.py +++ b/frigate/test/http_api/base_http_test.py @@ -119,6 +119,7 @@ class BaseTestHttp(unittest.TestCase): None, stats, None, + None, ) def insert_mock_event( diff --git a/frigate/test/test_http.py b/frigate/test/test_http.py index 4d949c543..5761e83aa 100644 --- a/frigate/test/test_http.py +++ b/frigate/test/test_http.py @@ -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: diff --git a/frigate/track/object_processing.py b/frigate/track/object_processing.py index 5157e0424..af4f7bd42 100644 --- a/frigate/track/object_processing.py +++ b/frigate/track/object_processing.py @@ -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() diff --git a/frigate/video.py b/frigate/video.py index 2efabfd93..5fc70ca02 100755 --- a/frigate/video.py +++ b/frigate/video.py @@ -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, ) ) diff --git a/web/src/components/settings/MotionMaskEditPane.tsx b/web/src/components/settings/MotionMaskEditPane.tsx index 7aaeed70c..e5c1d9b21 100644 --- a/web/src/components/settings/MotionMaskEditPane.tsx +++ b/web/src/components/settings/MotionMaskEditPane.tsx @@ -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) { diff --git a/web/src/components/settings/ObjectMaskEditPane.tsx b/web/src/components/settings/ObjectMaskEditPane.tsx index 2a4a89a47..dc92c63eb 100644 --- a/web/src/components/settings/ObjectMaskEditPane.tsx +++ b/web/src/components/settings/ObjectMaskEditPane.tsx @@ -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) { diff --git a/web/src/components/settings/ZoneEditPane.tsx b/web/src/components/settings/ZoneEditPane.tsx index 3f46ef6f2..db0128f64 100644 --- a/web/src/components/settings/ZoneEditPane.tsx +++ b/web/src/components/settings/ZoneEditPane.tsx @@ -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) { diff --git a/web/src/views/settings/MasksAndZonesView.tsx b/web/src/views/settings/MasksAndZonesView.tsx index 2163bfa51..303b0ed88 100644 --- a/web/src/views/settings/MasksAndZonesView.tsx +++ b/web/src/views/settings/MasksAndZonesView.tsx @@ -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(); 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) {