diff --git a/docs/docs/configuration/index.md b/docs/docs/configuration/index.md index 2144ef7ea..ac5247000 100644 --- a/docs/docs/configuration/index.md +++ b/docs/docs/configuration/index.md @@ -138,7 +138,10 @@ cameras: - detect motion: mask: - - 0.000,0.427,0.002,0.000,0.999,0.000,0.999,0.781,0.885,0.456,0.700,0.424,0.701,0.311,0.507,0.294,0.453,0.347,0.451,0.400 + timestamp: + friendly_name: "Camera timestamp" + enabled: true + coordinates: "0.000,0.427,0.002,0.000,0.999,0.000,0.999,0.781,0.885,0.456,0.700,0.424,0.701,0.311,0.507,0.294,0.453,0.347,0.451,0.400" ``` ### Standalone Intel Mini PC with USB Coral @@ -195,7 +198,10 @@ cameras: - detect motion: mask: - - 0.000,0.427,0.002,0.000,0.999,0.000,0.999,0.781,0.885,0.456,0.700,0.424,0.701,0.311,0.507,0.294,0.453,0.347,0.451,0.400 + timestamp: + friendly_name: "Camera timestamp" + enabled: true + coordinates: "0.000,0.427,0.002,0.000,0.999,0.000,0.999,0.781,0.885,0.456,0.700,0.424,0.701,0.311,0.507,0.294,0.453,0.347,0.451,0.400" ``` ### Home Assistant integrated Intel Mini PC with OpenVino @@ -262,5 +268,8 @@ cameras: - detect motion: mask: - - 0.000,0.427,0.002,0.000,0.999,0.000,0.999,0.781,0.885,0.456,0.700,0.424,0.701,0.311,0.507,0.294,0.453,0.347,0.451,0.400 + timestamp: + friendly_name: "Camera timestamp" + enabled: true + coordinates: "0.000,0.427,0.002,0.000,0.999,0.000,0.999,0.781,0.885,0.456,0.700,0.424,0.701,0.311,0.507,0.294,0.453,0.347,0.451,0.400" ``` diff --git a/docs/docs/configuration/masks.md b/docs/docs/configuration/masks.md index 4a4722586..32280531d 100644 --- a/docs/docs/configuration/masks.md +++ b/docs/docs/configuration/masks.md @@ -33,18 +33,55 @@ Your config file will be updated with the relative coordinates of the mask/zone: ```yaml motion: - mask: "0.000,0.427,0.002,0.000,0.999,0.000,0.999,0.781,0.885,0.456,0.700,0.424,0.701,0.311,0.507,0.294,0.453,0.347,0.451,0.400" + mask: + # Motion mask name (required) + mask1: + # Optional: A friendly name for the mask + friendly_name: "Timestamp area" + # Optional: Whether this mask is active (default: true) + enabled: true + # Required: Coordinates polygon for the mask + coordinates: "0.000,0.427,0.002,0.000,0.999,0.000,0.999,0.781,0.885,0.456,0.700,0.424,0.701,0.311,0.507,0.294,0.453,0.347,0.451,0.400" ``` -Multiple masks can be listed in your config. +Multiple motion masks can be listed in your config: ```yaml motion: mask: - - 0.239,1.246,0.175,0.901,0.165,0.805,0.195,0.802 - - 0.000,0.427,0.002,0.000,0.999,0.000,0.999,0.781,0.885,0.456 + mask1: + friendly_name: "Timestamp area" + enabled: true + coordinates: "0.239,1.246,0.175,0.901,0.165,0.805,0.195,0.802" + mask2: + friendly_name: "Tree area" + enabled: true + coordinates: "0.000,0.427,0.002,0.000,0.999,0.000,0.999,0.781,0.885,0.456" ``` +Object filter masks can also be created through the UI or manually in the config. They are configured under the object filters section for each object type: + +```yaml +objects: + filters: + person: + mask: + person_filter1: + friendly_name: "Roof area" + enabled: true + coordinates: "0.000,0.000,1.000,0.000,1.000,0.400,0.000,0.400" + car: + mask: + car_filter1: + friendly_name: "Sidewalk area" + enabled: true + coordinates: "0.000,0.700,1.000,0.700,1.000,1.000,0.000,1.000" +``` + +## Enabling/Disabling Masks + +Both motion masks and object filter masks can be toggled on or off without removing them from the configuration. Disabled masks are completely ignored at runtime - they will not affect motion detection or object filtering. This is useful for temporarily disabling a mask during certain seasons or times of day without modifying the configuration. + ### Further Clarification This is a response to a [question posed on reddit](https://www.reddit.com/r/homeautomation/comments/ppxdve/replacing_my_doorbell_with_a_security_camera_a_6/hd876w4?utm_source=share&utm_medium=web2x&context=3): diff --git a/docs/docs/configuration/reference.md b/docs/docs/configuration/reference.md index 5c3ca4ea8..3efc4f0ed 100644 --- a/docs/docs/configuration/reference.md +++ b/docs/docs/configuration/reference.md @@ -345,7 +345,15 @@ objects: # Optional: mask to prevent all object types from being detected in certain areas (default: no mask) # Checks based on the bottom center of the bounding box of the object. # NOTE: This mask is COMBINED with the object type specific mask below - mask: 0.000,0.000,0.781,0.000,0.781,0.278,0.000,0.278 + mask: + # Object filter mask name (required) + mask1: + # Optional: A friendly name for the mask + friendly_name: "Object filter mask area" + # Optional: Whether this mask is active (default: true) + enabled: true + # Required: Coordinates polygon for the mask + coordinates: "0.000,0.000,0.781,0.000,0.781,0.278,0.000,0.278" # Optional: filters to reduce false positives for specific object types filters: person: @@ -365,7 +373,15 @@ objects: threshold: 0.7 # Optional: mask to prevent this object type from being detected in certain areas (default: no mask) # Checks based on the bottom center of the bounding box of the object - mask: 0.000,0.000,0.781,0.000,0.781,0.278,0.000,0.278 + mask: + # Object filter mask name (required) + mask1: + # Optional: A friendly name for the mask + friendly_name: "Object filter mask area" + # Optional: Whether this mask is active (default: true) + enabled: true + # Required: Coordinates polygon for the mask + coordinates: "0.000,0.000,0.781,0.000,0.781,0.278,0.000,0.278" # Optional: Configuration for AI generated tracked object descriptions genai: # Optional: Enable AI object description generation (default: shown below) @@ -489,7 +505,15 @@ motion: frame_height: 100 # Optional: motion mask # NOTE: see docs for more detailed info on creating masks - mask: 0.000,0.469,1.000,0.469,1.000,1.000,0.000,1.000 + mask: + # Motion mask name (required) + mask1: + # Optional: A friendly name for the mask + friendly_name: "Motion mask area" + # Optional: Whether this mask is active (default: true) + enabled: true + # Required: Coordinates polygon for the mask + coordinates: "0.000,0.469,1.000,0.469,1.000,1.000,0.000,1.000" # Optional: improve contrast (default: shown below) # Enables dynamic contrast improvement. This should help improve night detections at the cost of making motion detection more sensitive # for daytime. @@ -866,6 +890,9 @@ cameras: front_steps: # Optional: A friendly name or descriptive text for the zones friendly_name: "" + # Optional: Whether this zone is active (default: shown below) + # Disabled zones are completely ignored at runtime - no object tracking or debug drawing + enabled: True # Required: List of x,y coordinates to define the polygon of the zone. # NOTE: Presence in a zone is evaluated only based on the bottom center of the objects bounding box. coordinates: 0.033,0.306,0.324,0.138,0.439,0.185,0.042,0.428 diff --git a/docs/docs/configuration/zones.md b/docs/docs/configuration/zones.md index c0a11d4f6..bf846c3a7 100644 --- a/docs/docs/configuration/zones.md +++ b/docs/docs/configuration/zones.md @@ -10,6 +10,10 @@ For example, the cat in this image is currently in Zone 1, but **not** Zone 2. Zones cannot have the same name as a camera. If desired, a single zone can include multiple cameras if you have multiple cameras covering the same area by configuring zones with the same name for each camera. +## Enabling/Disabling Zones + +Zones can be toggled on or off without removing them from the configuration. Disabled zones are completely ignored at runtime - objects will not be tracked for zone presence, and zones will not appear in the debug view. This is useful for temporarily disabling a zone during certain seasons or times of day without modifying the configuration. + During testing, enable the Zones option for the Debug view of your camera (Settings --> Debug) so you can adjust as needed. The zone line will increase in thickness when any object enters the zone. To create a zone, follow [the steps for a "Motion mask"](masks.md), but use the section of the web UI for creating a zone instead. @@ -86,7 +90,6 @@ cameras: Only car objects can trigger the `front_yard_street` zone and only person can trigger the `entire_yard`. Objects will be tracked for any `person` that enter anywhere in the yard, and for cars only if they enter the street. - ### Zone Loitering Sometimes objects are expected to be passing through a zone, but an object loitering in an area is unexpected. Zones can be configured to have a minimum loitering time after which the object will be considered in the zone. @@ -94,6 +97,7 @@ Sometimes objects are expected to be passing through a zone, but an object loite :::note When using loitering zones, a review item will behave in the following way: + - When a person is in a loitering zone, the review item will remain active until the person leaves the loitering zone, regardless of if they are stationary. - When any other object is in a loitering zone, the review item will remain active until the loitering time is met. Then if the object is stationary the review item will end. diff --git a/docs/docs/guides/getting_started.md b/docs/docs/guides/getting_started.md index f0f2f0f98..92f485333 100644 --- a/docs/docs/guides/getting_started.md +++ b/docs/docs/guides/getting_started.md @@ -240,7 +240,10 @@ cameras: - detect motion: mask: - - 0,461,3,0,1919,0,1919,843,1699,492,1344,458,1346,336,973,317,869,375,866,432 + motion_area: + friendly_name: "Motion mask" + enabled: true + coordinates: "0,461,3,0,1919,0,1919,843,1699,492,1344,458,1346,336,973,317,869,375,866,432" ``` ### Step 6: Enable recordings diff --git a/docs/docs/integrations/mqtt.md b/docs/docs/integrations/mqtt.md index 66775a473..ca3589df1 100644 --- a/docs/docs/integrations/mqtt.md +++ b/docs/docs/integrations/mqtt.md @@ -429,6 +429,30 @@ Topic to adjust motion contour area for a camera. Expected value is an integer. Topic with current motion contour area for a camera. Published value is an integer. +### `frigate//motion_mask//set` + +Topic to turn a specific motion mask for a camera on and off. Expected values are `ON` and `OFF`. + +### `frigate//motion_mask//state` + +Topic with current state of a specific motion mask for a camera. Published values are `ON` and `OFF`. + +### `frigate//object_mask//set` + +Topic to turn a specific object mask for a camera on and off. Expected values are `ON` and `OFF`. + +### `frigate//object_mask//state` + +Topic with current state of a specific object mask for a camera. Published values are `ON` and `OFF`. + +### `frigate//zone//set` + +Topic to turn a specific zone for a camera on and off. Expected values are `ON` and `OFF`. + +### `frigate//zone//state` + +Topic with current state of a specific zone for a camera. Published values are `ON` and `OFF`. + ### `frigate//review_status` Topic with current activity status of the camera. Possible values are `NONE`, `DETECTION`, or `ALERT`. diff --git a/frigate/api/app.py b/frigate/api/app.py index d24d9e868..04d1c2238 100644 --- a/frigate/api/app.py +++ b/frigate/api/app.py @@ -19,6 +19,7 @@ from fastapi import APIRouter, Body, Path, Request, Response from fastapi.encoders import jsonable_encoder from fastapi.params import Depends from fastapi.responses import JSONResponse, PlainTextResponse, StreamingResponse +from filelock import FileLock, Timeout from markupsafe import escape from peewee import SQL, fn, operator from pydantic import ValidationError @@ -424,102 +425,124 @@ def config_save(save_option: str, body: Any = Body(media_type="text/plain")): @router.put("/config/set", dependencies=[Depends(require_role(["admin"]))]) def config_set(request: Request, body: AppConfigSetBody): config_file = find_config_file() - - with open(config_file, "r") as f: - old_raw_config = f.read() + lock = FileLock(f"{config_file}.lock", timeout=5) try: - updates = {} + with lock: + with open(config_file, "r") as f: + old_raw_config = f.read() - # process query string parameters (takes precedence over body.config_data) - parsed_url = urllib.parse.urlparse(str(request.url)) - query_string = urllib.parse.parse_qs(parsed_url.query, keep_blank_values=True) + try: + updates = {} - # Filter out empty keys but keep blank values for non-empty keys - query_string = {k: v for k, v in query_string.items() if k} + # process query string parameters (takes precedence over body.config_data) + parsed_url = urllib.parse.urlparse(str(request.url)) + query_string = urllib.parse.parse_qs( + parsed_url.query, keep_blank_values=True + ) - if query_string: - updates = process_config_query_string(query_string) - elif body.config_data: - updates = flatten_config_data(body.config_data) + # Filter out empty keys but keep blank values for non-empty keys + query_string = {k: v for k, v in query_string.items() if k} - if not updates: - return JSONResponse( - content=( - {"success": False, "message": "No configuration data provided"} - ), - status_code=400, - ) + if query_string: + updates = process_config_query_string(query_string) + elif body.config_data: + updates = flatten_config_data(body.config_data) + # Convert None values to empty strings for deletion (e.g., when deleting masks) + updates = {k: ("" if v is None else v) for k, v in updates.items()} - # apply all updates in a single operation - update_yaml_file_bulk(config_file, updates) + if not updates: + return JSONResponse( + content=( + { + "success": False, + "message": "No configuration data provided", + } + ), + status_code=400, + ) - # validate the updated config - with open(config_file, "r") as f: - new_raw_config = f.read() + # apply all updates in a single operation + update_yaml_file_bulk(config_file, updates) + + # validate the updated config + with open(config_file, "r") as f: + new_raw_config = f.read() + + try: + config = FrigateConfig.parse(new_raw_config) + except Exception: + with open(config_file, "w") as f: + f.write(old_raw_config) + f.close() + logger.error(f"\nConfig Error:\n\n{str(traceback.format_exc())}") + return JSONResponse( + content=( + { + "success": False, + "message": "Error parsing config. Check logs for error message.", + } + ), + status_code=400, + ) + except Exception as e: + logging.error(f"Error updating config: {e}") + return JSONResponse( + content=({"success": False, "message": "Error updating config"}), + status_code=500, + ) + + if body.requires_restart == 0 or body.update_topic: + old_config: FrigateConfig = request.app.frigate_config + request.app.frigate_config = config + request.app.genai_manager.update_config(config) + + if body.update_topic: + if body.update_topic.startswith("config/cameras/"): + _, _, camera, field = body.update_topic.split("/") + + if field == "add": + settings = config.cameras[camera] + elif field == "remove": + settings = old_config.cameras[camera] + else: + settings = config.get_nested_object(body.update_topic) + + request.app.config_publisher.publish_update( + CameraConfigUpdateTopic( + CameraConfigUpdateEnum[field], camera + ), + settings, + ) + else: + # Generic handling for global config updates + settings = config.get_nested_object(body.update_topic) + + # Publish None for removal, actual config for add/update + request.app.config_publisher.publisher.publish( + body.update_topic, settings + ) - try: - config = FrigateConfig.parse(new_raw_config) - except Exception: - with open(config_file, "w") as f: - f.write(old_raw_config) - f.close() - logger.error(f"\nConfig Error:\n\n{str(traceback.format_exc())}") return JSONResponse( content=( { - "success": False, - "message": "Error parsing config. Check logs for error message.", + "success": True, + "message": "Config successfully updated, restart to apply", } ), - status_code=400, + status_code=200, ) - except Exception as e: - logging.error(f"Error updating config: {e}") + except Timeout: return JSONResponse( - content=({"success": False, "message": "Error updating config"}), - status_code=500, + content=( + { + "success": False, + "message": "Another process is currently updating the config. Please try again in a few seconds.", + } + ), + status_code=503, ) - if body.requires_restart == 0 or body.update_topic: - old_config: FrigateConfig = request.app.frigate_config - request.app.frigate_config = config - request.app.genai_manager.update_config(config) - - if body.update_topic: - if body.update_topic.startswith("config/cameras/"): - _, _, camera, field = body.update_topic.split("/") - - if field == "add": - settings = config.cameras[camera] - elif field == "remove": - settings = old_config.cameras[camera] - else: - settings = config.get_nested_object(body.update_topic) - - request.app.config_publisher.publish_update( - CameraConfigUpdateTopic(CameraConfigUpdateEnum[field], camera), - settings, - ) - else: - # Generic handling for global config updates - settings = config.get_nested_object(body.update_topic) - - # Publish None for removal, actual config for add/update - request.app.config_publisher.publisher.publish( - body.update_topic, settings - ) - - return JSONResponse( - content=( - { - "success": True, - "message": "Config successfully updated, restart to apply", - } - ), - status_code=200, - ) - @router.get("/vainfo", dependencies=[Depends(allow_any_authenticated())]) def vainfo(): diff --git a/frigate/camera/state.py b/frigate/camera/state.py index 97c715388..eccdc41e8 100644 --- a/frigate/camera/state.py +++ b/frigate/camera/state.py @@ -65,7 +65,7 @@ class CameraState: frame_copy = cv2.cvtColor(frame_copy, cv2.COLOR_YUV2BGR_I420) # draw on the frame if draw_options.get("mask"): - mask_overlay = np.where(self.camera_config.motion.mask == [0]) + mask_overlay = np.where(self.camera_config.motion.rasterized_mask == [0]) frame_copy[mask_overlay] = [0, 0, 0] if draw_options.get("bounding_boxes"): @@ -197,6 +197,10 @@ class CameraState: if draw_options.get("zones"): for name, zone in self.camera_config.zones.items(): + # skip disabled zones + if not zone.enabled: + continue + thickness = ( 8 if any( diff --git a/frigate/comms/dispatcher.py b/frigate/comms/dispatcher.py index 68749b102..6da154814 100644 --- a/frigate/comms/dispatcher.py +++ b/frigate/comms/dispatcher.py @@ -15,6 +15,7 @@ from frigate.config.camera.updater import ( CameraConfigUpdatePublisher, CameraConfigUpdateTopic, ) +from frigate.config.config import RuntimeFilterConfig, RuntimeMotionConfig from frigate.const import ( CLEAR_ONGOING_REVIEW_SEGMENTS, EXPIRE_AUDIO_ACTIVITY, @@ -84,6 +85,9 @@ class Dispatcher: "review_detections": self._on_detections_command, "object_descriptions": self._on_object_description_command, "review_descriptions": self._on_review_description_command, + "motion_mask": self._on_motion_mask_command, + "object_mask": self._on_object_mask_command, + "zone": self._on_zone_command, } self._global_settings_handlers: dict[str, Callable] = { "notifications": self._on_global_notification_command, @@ -100,11 +104,20 @@ class Dispatcher: """Handle receiving of payload from communicators.""" def handle_camera_command( - command_type: str, camera_name: str, command: str, payload: str + command_type: str, + camera_name: str, + command: str, + payload: str, + sub_command: str | None = None, ) -> None: try: if command_type == "set": - self._camera_settings_handlers[command](camera_name, payload) + if sub_command: + self._camera_settings_handlers[command]( + camera_name, sub_command, payload + ) + else: + self._camera_settings_handlers[command](camera_name, payload) elif command_type == "ptz": self._on_ptz_command(camera_name, payload) except KeyError: @@ -314,6 +327,14 @@ class Dispatcher: camera_name = parts[-3] command = parts[-2] handle_camera_command("set", camera_name, command, payload) + elif len(parts) == 4 and topic.endswith("set"): + # example /cam_name/motion_mask/mask_name/set payload=ON|OFF + camera_name = parts[-4] + command = parts[-3] + sub_command = parts[-2] + handle_camera_command( + "set", camera_name, command, payload, sub_command + ) elif len(parts) == 2 and topic.endswith("set"): command = parts[-2] self._global_settings_handlers[command](payload) @@ -858,3 +879,149 @@ class Dispatcher: genai_settings, ) self.publish(f"{camera_name}/review_descriptions/state", payload, retain=True) + + def _on_motion_mask_command( + self, camera_name: str, mask_name: str, payload: str + ) -> None: + """Callback for motion mask topic.""" + if payload not in ["ON", "OFF"]: + logger.error(f"Invalid payload for motion mask {mask_name}: {payload}") + return + + motion_settings = self.config.cameras[camera_name].motion + + if mask_name not in motion_settings.mask: + logger.error(f"Unknown motion mask: {mask_name}") + return + + mask = motion_settings.mask[mask_name] + + if not mask: + logger.error(f"Motion mask {mask_name} is None") + return + + if payload == "ON": + if not mask.enabled_in_config: + logger.error( + f"Motion mask {mask_name} must be enabled in the config to be turned on via MQTT." + ) + return + + mask.enabled = payload == "ON" + + # Recreate RuntimeMotionConfig to update rasterized_mask + motion_settings = RuntimeMotionConfig( + frame_shape=self.config.cameras[camera_name].frame_shape, + **motion_settings.model_dump(exclude_unset=True), + ) + + # Update the dispatcher's own config + self.config.cameras[camera_name].motion = motion_settings + + self.config_updater.publish_update( + CameraConfigUpdateTopic(CameraConfigUpdateEnum.motion, camera_name), + motion_settings, + ) + self.publish( + f"{camera_name}/motion_mask/{mask_name}/state", payload, retain=True + ) + + def _on_object_mask_command( + self, camera_name: str, mask_name: str, payload: str + ) -> None: + """Callback for object mask topic.""" + if payload not in ["ON", "OFF"]: + logger.error(f"Invalid payload for object mask {mask_name}: {payload}") + return + + object_settings = self.config.cameras[camera_name].objects + + # Check if this is a global mask + mask_found = False + if mask_name in object_settings.mask: + mask = object_settings.mask[mask_name] + if mask: + if payload == "ON": + if not mask.enabled_in_config: + logger.error( + f"Object mask {mask_name} must be enabled in the config to be turned on via MQTT." + ) + return + mask.enabled = payload == "ON" + mask_found = True + + # Check if this is a per-object filter mask + for object_name, filter_config in object_settings.filters.items(): + if mask_name in filter_config.mask: + mask = filter_config.mask[mask_name] + if mask: + if payload == "ON": + if not mask.enabled_in_config: + logger.error( + f"Object mask {mask_name} must be enabled in the config to be turned on via MQTT." + ) + return + mask.enabled = payload == "ON" + mask_found = True + + if not mask_found: + logger.error(f"Unknown object mask: {mask_name}") + return + + # Recreate RuntimeFilterConfig for each object filter to update rasterized_mask + for object_name, filter_config in object_settings.filters.items(): + # Merge global object masks with per-object filter masks + merged_mask = dict(filter_config.mask) # Copy filter-specific masks + + # Add global object masks if they exist + if object_settings.mask: + for global_mask_id, global_mask_config in object_settings.mask.items(): + # Use a global prefix to avoid key collisions + global_mask_id_prefixed = f"global_{global_mask_id}" + merged_mask[global_mask_id_prefixed] = global_mask_config + + object_settings.filters[object_name] = RuntimeFilterConfig( + frame_shape=self.config.cameras[camera_name].frame_shape, + mask=merged_mask, + **filter_config.model_dump( + exclude_unset=True, exclude={"mask", "raw_mask"} + ), + ) + + # Update the dispatcher's own config + self.config.cameras[camera_name].objects = object_settings + + self.config_updater.publish_update( + CameraConfigUpdateTopic(CameraConfigUpdateEnum.objects, camera_name), + object_settings, + ) + self.publish( + f"{camera_name}/object_mask/{mask_name}/state", payload, retain=True + ) + + def _on_zone_command(self, camera_name: str, zone_name: str, payload: str) -> None: + """Callback for zone topic.""" + if payload not in ["ON", "OFF"]: + logger.error(f"Invalid payload for zone {zone_name}: {payload}") + return + + camera_config = self.config.cameras[camera_name] + + if zone_name not in camera_config.zones: + logger.error(f"Unknown zone: {zone_name}") + return + + if payload == "ON": + if not camera_config.zones[zone_name].enabled_in_config: + logger.error( + f"Zone {zone_name} must be enabled in the config to be turned on via MQTT." + ) + return + + camera_config.zones[zone_name].enabled = payload == "ON" + + self.config_updater.publish_update( + CameraConfigUpdateTopic(CameraConfigUpdateEnum.zones, camera_name), + camera_config.zones, + ) + self.publish(f"{camera_name}/zone/{zone_name}/state", payload, retain=True) diff --git a/frigate/comms/mqtt.py b/frigate/comms/mqtt.py index 68ae698d9..9279b4388 100644 --- a/frigate/comms/mqtt.py +++ b/frigate/comms/mqtt.py @@ -133,6 +133,29 @@ class MqttClient(Communicator): retain=True, ) + for mask_name, motion_mask in camera.motion.mask.items(): + if motion_mask: + self.publish( + f"{camera_name}/motion_mask/{mask_name}/state", + "ON" if motion_mask.enabled else "OFF", + retain=True, + ) + + for mask_name, object_mask in camera.objects.mask.items(): + if object_mask: + self.publish( + f"{camera_name}/object_mask/{mask_name}/state", + "ON" if object_mask.enabled else "OFF", + retain=True, + ) + + for zone_name, zone in camera.zones.items(): + self.publish( + f"{camera_name}/zone/{zone_name}/state", + "ON" if zone.enabled else "OFF", + retain=True, + ) + if self.config.notifications.enabled_in_config: self.publish( "notifications/state", @@ -242,6 +265,24 @@ class MqttClient(Communicator): self.on_mqtt_command, ) + for mask_name in self.config.cameras[name].motion.mask.keys(): + self.client.message_callback_add( + f"{self.mqtt_config.topic_prefix}/{name}/motion_mask/{mask_name}/set", + self.on_mqtt_command, + ) + + for mask_name in self.config.cameras[name].objects.mask.keys(): + self.client.message_callback_add( + f"{self.mqtt_config.topic_prefix}/{name}/object_mask/{mask_name}/set", + self.on_mqtt_command, + ) + + for zone_name in self.config.cameras[name].zones.keys(): + self.client.message_callback_add( + f"{self.mqtt_config.topic_prefix}/{name}/zone/{zone_name}/set", + self.on_mqtt_command, + ) + if self.config.notifications.enabled_in_config: self.client.message_callback_add( f"{self.mqtt_config.topic_prefix}/notifications/set", diff --git a/frigate/config/camera/mask.py b/frigate/config/camera/mask.py new file mode 100644 index 000000000..dbe0f063c --- /dev/null +++ b/frigate/config/camera/mask.py @@ -0,0 +1,85 @@ +"""Mask configuration for motion and object masks.""" + +from typing import Any, Optional, Union + +from pydantic import Field, field_serializer + +from ..base import FrigateBaseModel + +__all__ = ["MotionMaskConfig", "ObjectMaskConfig"] + + +class MotionMaskConfig(FrigateBaseModel): + """Configuration for a single motion mask.""" + + friendly_name: Optional[str] = Field( + default=None, + title="Friendly name", + description="A friendly name for this motion mask used in the Frigate UI", + ) + enabled: bool = Field( + default=True, + title="Enabled", + description="Enable or disable this motion mask", + ) + coordinates: Union[str, list[str]] = Field( + default="", + title="Coordinates", + description="Ordered x,y coordinates defining the motion mask polygon used to include/exclude areas.", + ) + raw_coordinates: Union[str, list[str]] = "" + enabled_in_config: Optional[bool] = Field( + default=None, title="Keep track of original state of motion mask." + ) + + def get_formatted_name(self, mask_id: str) -> str: + """Return the friendly name if set, otherwise return a formatted version of the mask ID.""" + if self.friendly_name: + return self.friendly_name + return mask_id.replace("_", " ").title() + + @field_serializer("coordinates", when_used="json") + def serialize_coordinates(self, value: Any, info): + return self.raw_coordinates if self.raw_coordinates else value + + @field_serializer("raw_coordinates", when_used="json") + def serialize_raw_coordinates(self, value: Any, info): + return None + + +class ObjectMaskConfig(FrigateBaseModel): + """Configuration for a single object mask.""" + + friendly_name: Optional[str] = Field( + default=None, + title="Friendly name", + description="A friendly name for this object mask used in the Frigate UI", + ) + enabled: bool = Field( + default=True, + title="Enabled", + description="Enable or disable this object mask", + ) + coordinates: Union[str, list[str]] = Field( + default="", + title="Coordinates", + description="Ordered x,y coordinates defining the object mask polygon used to include/exclude areas.", + ) + raw_coordinates: Union[str, list[str]] = "" + enabled_in_config: Optional[bool] = Field( + default=None, title="Keep track of original state of object mask." + ) + + @field_serializer("coordinates", when_used="json") + def serialize_coordinates(self, value: Any, info): + return self.raw_coordinates if self.raw_coordinates else value + + @field_serializer("raw_coordinates", when_used="json") + def serialize_raw_coordinates(self, value: Any, info): + return None + + def get_formatted_name(self, mask_id: str) -> str: + """Return the friendly name if set, otherwise return a formatted version of the mask ID.""" + if self.friendly_name: + return self.friendly_name + return mask_id.replace("_", " ").title() diff --git a/frigate/config/camera/motion.py b/frigate/config/camera/motion.py index d39130108..b6877693b 100644 --- a/frigate/config/camera/motion.py +++ b/frigate/config/camera/motion.py @@ -1,8 +1,9 @@ -from typing import Any, Optional, Union +from typing import Any, Optional from pydantic import Field, field_serializer from ..base import FrigateBaseModel +from .mask import MotionMaskConfig __all__ = ["MotionConfig"] @@ -52,8 +53,8 @@ class MotionConfig(FrigateBaseModel): title="Frame height", description="Height in pixels to scale frames to when computing motion.", ) - mask: Union[str, list[str]] = Field( - default="", + mask: dict[str, Optional[MotionMaskConfig]] = Field( + default_factory=dict, title="Mask coordinates", description="Ordered x,y coordinates defining the motion mask polygon used to include/exclude areas.", ) @@ -67,11 +68,15 @@ class MotionConfig(FrigateBaseModel): title="Original motion state", description="Indicates whether motion detection was enabled in the original static configuration.", ) - raw_mask: Union[str, list[str]] = "" + raw_mask: dict[str, Optional[MotionMaskConfig]] = Field( + default_factory=dict, exclude=True + ) @field_serializer("mask", when_used="json") def serialize_mask(self, value: Any, info): - return self.raw_mask + if self.raw_mask: + return self.raw_mask + return value @field_serializer("raw_mask", when_used="json") def serialize_raw_mask(self, value: Any, info): diff --git a/frigate/config/camera/objects.py b/frigate/config/camera/objects.py index 97a4d5b7c..e93778f23 100644 --- a/frigate/config/camera/objects.py +++ b/frigate/config/camera/objects.py @@ -3,6 +3,7 @@ from typing import Any, Optional, Union from pydantic import Field, PrivateAttr, field_serializer, field_validator from ..base import FrigateBaseModel +from .mask import ObjectMaskConfig __all__ = ["ObjectConfig", "GenAIObjectConfig", "FilterConfig"] @@ -41,16 +42,20 @@ class FilterConfig(FrigateBaseModel): title="Minimum confidence", description="Minimum single-frame detection confidence required for the object to be counted.", ) - mask: Optional[Union[str, list[str]]] = Field( - default=None, + mask: dict[str, Optional[ObjectMaskConfig]] = Field( + default_factory=dict, title="Filter mask", description="Polygon coordinates defining where this filter applies within the frame.", ) - raw_mask: Union[str, list[str]] = "" + raw_mask: dict[str, Optional[ObjectMaskConfig]] = Field( + default_factory=dict, exclude=True + ) @field_serializer("mask", when_used="json") def serialize_mask(self, value: Any, info): - return self.raw_mask + if self.raw_mask: + return self.raw_mask + return value @field_serializer("raw_mask", when_used="json") def serialize_raw_mask(self, value: Any, info): @@ -139,11 +144,14 @@ class ObjectConfig(FrigateBaseModel): title="Object filters", description="Filters applied to detected objects to reduce false positives (area, ratio, confidence).", ) - mask: Union[str, list[str]] = Field( - default="", + mask: dict[str, Optional[ObjectMaskConfig]] = Field( + default_factory=dict, title="Object mask", description="Mask polygon used to prevent object detection in specified areas.", ) + raw_mask: dict[str, Optional[ObjectMaskConfig]] = Field( + default_factory=dict, exclude=True + ) genai: GenAIObjectConfig = Field( default_factory=GenAIObjectConfig, title="GenAI object config", @@ -166,3 +174,13 @@ class ObjectConfig(FrigateBaseModel): enabled_labels.update(camera.objects.track) self._all_objects = list(enabled_labels) + + @field_serializer("mask", when_used="json") + def serialize_mask(self, value: Any, info): + if self.raw_mask: + return self.raw_mask + return value + + @field_serializer("raw_mask", when_used="json") + def serialize_raw_mask(self, value: Any, info): + return None diff --git a/frigate/config/camera/zone.py b/frigate/config/camera/zone.py index 1ee25d4b6..e4737f8dc 100644 --- a/frigate/config/camera/zone.py +++ b/frigate/config/camera/zone.py @@ -18,6 +18,14 @@ class ZoneConfig(BaseModel): title="Zone name", description="A user-friendly name for the zone, displayed in the Frigate UI. If not set, a formatted version of the zone name will be used.", ) + enabled: bool = Field( + default=True, + title="Enabled", + description="Enable or disable this zone. Disabled zones are ignored at runtime.", + ) + enabled_in_config: Optional[bool] = Field( + default=None, title="Keep track of original state of zone." + ) filters: dict[str, FilterConfig] = Field( default_factory=dict, title="Zone filters", diff --git a/frigate/config/config.py b/frigate/config/config.py index 3934976d3..7e2d0eddc 100644 --- a/frigate/config/config.py +++ b/frigate/config/config.py @@ -3,7 +3,7 @@ from __future__ import annotations import json import logging import os -from typing import Any, Dict, List, Optional, Union +from typing import Any, Dict, Optional import numpy as np from pydantic import ( @@ -46,6 +46,7 @@ from .camera.birdseye import BirdseyeConfig from .camera.detect import DetectConfig from .camera.ffmpeg import FfmpegConfig from .camera.genai import GenAIConfig, GenAIRoleEnum +from .camera.mask import ObjectMaskConfig from .camera.motion import MotionConfig from .camera.notification import NotificationConfig from .camera.objects import FilterConfig, ObjectConfig @@ -93,54 +94,111 @@ stream_info_retriever = StreamInfoRetriever() class RuntimeMotionConfig(MotionConfig): - raw_mask: Union[str, List[str]] = "" - mask: np.ndarray = None + """Runtime version of MotionConfig with rasterized masks.""" + + # The rasterized numpy mask (combination of all enabled masks) + rasterized_mask: np.ndarray = None def __init__(self, **config): frame_shape = config.get("frame_shape", (1, 1)) - mask = get_relative_coordinates(config.get("mask", ""), frame_shape) - config["raw_mask"] = mask - - if mask: - config["mask"] = create_mask(frame_shape, mask) - else: - empty_mask = np.zeros(frame_shape, np.uint8) - empty_mask[:] = 255 - config["mask"] = empty_mask + # Store original mask dict for serialization + original_mask = config.get("mask", {}) + if isinstance(original_mask, dict): + # Process the new dict format - update raw_coordinates for each mask + processed_mask = {} + for mask_id, mask_config in original_mask.items(): + if isinstance(mask_config, dict): + coords = mask_config.get("coordinates", "") + relative_coords = get_relative_coordinates(coords, frame_shape) + mask_config_copy = mask_config.copy() + mask_config_copy["raw_coordinates"] = ( + relative_coords if relative_coords else coords + ) + mask_config_copy["coordinates"] = ( + relative_coords if relative_coords else coords + ) + processed_mask[mask_id] = mask_config_copy + else: + processed_mask[mask_id] = mask_config + config["mask"] = processed_mask + config["raw_mask"] = processed_mask super().__init__(**config) + # Rasterize only enabled masks + enabled_coords = [] + for mask_config in self.mask.values(): + if mask_config.enabled and mask_config.coordinates: + coords = mask_config.coordinates + if isinstance(coords, list): + enabled_coords.extend(coords) + else: + enabled_coords.append(coords) + + if enabled_coords: + self.rasterized_mask = create_mask(frame_shape, enabled_coords) + else: + empty_mask = np.zeros(frame_shape, np.uint8) + empty_mask[:] = 255 + self.rasterized_mask = empty_mask + def dict(self, **kwargs): ret = super().model_dump(**kwargs) - if "mask" in ret: - ret["mask"] = ret["raw_mask"] - ret.pop("raw_mask") + if "rasterized_mask" in ret: + ret.pop("rasterized_mask") return ret - @field_serializer("mask", when_used="json") - def serialize_mask(self, value: Any, info): - return self.raw_mask - - @field_serializer("raw_mask", when_used="json") - def serialize_raw_mask(self, value: Any, info): + @field_serializer("rasterized_mask", when_used="json") + def serialize_rasterized_mask(self, value: Any, info): return None model_config = ConfigDict(arbitrary_types_allowed=True, extra="ignore") class RuntimeFilterConfig(FilterConfig): - mask: Optional[np.ndarray] = None - raw_mask: Optional[Union[str, List[str]]] = None + """Runtime version of FilterConfig with rasterized masks.""" + + # The rasterized numpy mask (combination of all enabled masks) + rasterized_mask: Optional[np.ndarray] = None def __init__(self, **config): frame_shape = config.get("frame_shape", (1, 1)) - mask = get_relative_coordinates(config.get("mask"), frame_shape) - config["raw_mask"] = mask - - if mask is not None: - config["mask"] = create_mask(frame_shape, mask) + # Store original mask dict for serialization + original_mask = config.get("mask", {}) + if isinstance(original_mask, dict): + # Process the new dict format - update raw_coordinates for each mask + processed_mask = {} + for mask_id, mask_config in original_mask.items(): + # Handle both dict and ObjectMaskConfig formats + if hasattr(mask_config, "model_dump"): + # It's an ObjectMaskConfig object + mask_dict = mask_config.model_dump() + coords = mask_dict.get("coordinates", "") + relative_coords = get_relative_coordinates(coords, frame_shape) + mask_dict["raw_coordinates"] = ( + relative_coords if relative_coords else coords + ) + mask_dict["coordinates"] = ( + relative_coords if relative_coords else coords + ) + processed_mask[mask_id] = mask_dict + elif isinstance(mask_config, dict): + coords = mask_config.get("coordinates", "") + relative_coords = get_relative_coordinates(coords, frame_shape) + mask_config_copy = mask_config.copy() + mask_config_copy["raw_coordinates"] = ( + relative_coords if relative_coords else coords + ) + mask_config_copy["coordinates"] = ( + relative_coords if relative_coords else coords + ) + processed_mask[mask_id] = mask_config_copy + else: + processed_mask[mask_id] = mask_config + config["mask"] = processed_mask + config["raw_mask"] = processed_mask # Convert min_area and max_area to pixels if they're percentages if "min_area" in config: @@ -151,13 +209,31 @@ class RuntimeFilterConfig(FilterConfig): super().__init__(**config) + # Rasterize only enabled masks + enabled_coords = [] + for mask_config in self.mask.values(): + if mask_config.enabled and mask_config.coordinates: + coords = mask_config.coordinates + if isinstance(coords, list): + enabled_coords.extend(coords) + else: + enabled_coords.append(coords) + + if enabled_coords: + self.rasterized_mask = create_mask(frame_shape, enabled_coords) + else: + self.rasterized_mask = None + def dict(self, **kwargs): ret = super().model_dump(**kwargs) - if "mask" in ret: - ret["mask"] = ret["raw_mask"] - ret.pop("raw_mask") + if "rasterized_mask" in ret: + ret.pop("rasterized_mask") return ret + @field_serializer("rasterized_mask", when_used="json") + def serialize_rasterized_mask(self, value: Any, info): + return None + model_config = ConfigDict(arbitrary_types_allowed=True, extra="ignore") @@ -713,35 +789,63 @@ class FrigateConfig(FrigateBaseModel): for key in object_keys: camera_config.objects.filters[key] = FilterConfig() + # Process global object masks to set raw_coordinates + if camera_config.objects.mask: + processed_global_masks = {} + for mask_id, mask_config in camera_config.objects.mask.items(): + if mask_config: + coords = mask_config.coordinates + relative_coords = get_relative_coordinates( + coords, camera_config.frame_shape + ) + # Create a new ObjectMaskConfig with raw_coordinates set + processed_global_masks[mask_id] = ObjectMaskConfig( + friendly_name=mask_config.friendly_name, + enabled=mask_config.enabled, + coordinates=relative_coords if relative_coords else coords, + raw_coordinates=relative_coords + if relative_coords + else coords, + enabled_in_config=mask_config.enabled, + ) + else: + processed_global_masks[mask_id] = mask_config + camera_config.objects.mask = processed_global_masks + camera_config.objects.raw_mask = processed_global_masks + # Apply global object masks and convert masks to numpy array for object, filter in camera_config.objects.filters.items(): + # Set enabled_in_config for per-object masks before processing + for mask_config in filter.mask.values(): + if mask_config: + mask_config.enabled_in_config = mask_config.enabled + + # Merge global object masks with per-object filter masks + merged_mask = dict(filter.mask) # Copy filter-specific masks + + # Add global object masks if they exist if camera_config.objects.mask: - filter_mask = [] - if filter.mask is not None: - filter_mask = ( - filter.mask - if isinstance(filter.mask, list) - else [filter.mask] - ) - object_mask = ( - get_relative_coordinates( - ( - camera_config.objects.mask - if isinstance(camera_config.objects.mask, list) - else [camera_config.objects.mask] - ), - camera_config.frame_shape, - ) - or [] - ) - filter.mask = filter_mask + object_mask + for mask_id, mask_config in camera_config.objects.mask.items(): + # Use a global prefix to avoid key collisions + global_mask_id = f"global_{mask_id}" + merged_mask[global_mask_id] = mask_config # Set runtime filter to create masks camera_config.objects.filters[object] = RuntimeFilterConfig( frame_shape=camera_config.frame_shape, - **filter.model_dump(exclude_unset=True), + mask=merged_mask, + **filter.model_dump( + exclude_unset=True, exclude={"mask", "raw_mask"} + ), ) + # Set enabled_in_config for motion masks to match config file state BEFORE creating RuntimeMotionConfig + if camera_config.motion: + camera_config.motion.enabled_in_config = camera_config.motion.enabled + for mask_config in camera_config.motion.mask.values(): + if mask_config: + mask_config.enabled_in_config = mask_config.enabled + # Convert motion configuration if camera_config.motion is None: camera_config.motion = RuntimeMotionConfig( @@ -750,10 +854,8 @@ class FrigateConfig(FrigateBaseModel): else: camera_config.motion = RuntimeMotionConfig( frame_shape=camera_config.frame_shape, - raw_mask=camera_config.motion.mask, **camera_config.motion.model_dump(exclude_unset=True), ) - camera_config.motion.enabled_in_config = camera_config.motion.enabled # generate zone contours if len(camera_config.zones) > 0: @@ -767,6 +869,10 @@ class FrigateConfig(FrigateBaseModel): zone.generate_contour(camera_config.frame_shape) + # Set enabled_in_config for zones to match config file state + for zone in camera_config.zones.values(): + zone.enabled_in_config = zone.enabled + # Set live view stream if none is set if not camera_config.live.streams: camera_config.live.streams = {name: name} diff --git a/frigate/data_processing/common/license_plate/mixin.py b/frigate/data_processing/common/license_plate/mixin.py index b56c66a19..ae06c0d0a 100644 --- a/frigate/data_processing/common/license_plate/mixin.py +++ b/frigate/data_processing/common/license_plate/mixin.py @@ -1220,7 +1220,7 @@ class LicensePlateProcessingMixin: rgb = cv2.cvtColor(frame, cv2.COLOR_YUV2BGR_I420) # apply motion mask - rgb[self.config.cameras[obj_data].motion.mask == 0] = [0, 0, 0] + rgb[self.config.cameras[obj_data].motion.rasterized_mask == 0] = [0, 0, 0] if WRITE_DEBUG_IMAGES: cv2.imwrite( @@ -1324,7 +1324,7 @@ class LicensePlateProcessingMixin: rgb = cv2.cvtColor(frame, cv2.COLOR_YUV2BGR_I420) # apply motion mask - rgb[self.config.cameras[camera].motion.mask == 0] = [0, 0, 0] + rgb[self.config.cameras[camera].motion.rasterized_mask == 0] = [0, 0, 0] left, top, right, bottom = car_box car = rgb[top:bottom, left:right] diff --git a/frigate/motion/frigate_motion.py b/frigate/motion/frigate_motion.py index fd362de34..d49b0e861 100644 --- a/frigate/motion/frigate_motion.py +++ b/frigate/motion/frigate_motion.py @@ -28,7 +28,7 @@ class FrigateMotionDetector(MotionDetector): self.motion_frame_count = 0 self.frame_counter = 0 resized_mask = cv2.resize( - config.mask, + config.rasterized_mask, dsize=(self.motion_frame_size[1], self.motion_frame_size[0]), interpolation=cv2.INTER_LINEAR, ) diff --git a/frigate/motion/improved_motion.py b/frigate/motion/improved_motion.py index b081d3791..a8f2ab12c 100644 --- a/frigate/motion/improved_motion.py +++ b/frigate/motion/improved_motion.py @@ -233,7 +233,7 @@ class ImprovedMotionDetector(MotionDetector): def update_mask(self) -> None: resized_mask = cv2.resize( - self.config.mask, + self.config.rasterized_mask, dsize=(self.motion_frame_size[1], self.motion_frame_size[0]), interpolation=cv2.INTER_AREA, ) diff --git a/frigate/ptz/autotrack.py b/frigate/ptz/autotrack.py index 6e86ecbf2..eb2d16940 100644 --- a/frigate/ptz/autotrack.py +++ b/frigate/ptz/autotrack.py @@ -116,7 +116,9 @@ class PtzMotionEstimator: mask[y1:y2, x1:x2] = 0 # merge camera config motion mask with detections. Norfair function needs 0,1 mask - mask = np.bitwise_and(mask, self.camera_config.motion.mask).clip(max=1) + mask = np.bitwise_and(mask, self.camera_config.motion.rasterized_mask).clip( + max=1 + ) # Norfair estimator function needs color so it can convert it right back to gray frame = cv2.cvtColor(frame, cv2.COLOR_GRAY2BGRA) diff --git a/frigate/test/test_config.py b/frigate/test/test_config.py index afe577f2f..98799fcf0 100644 --- a/frigate/test/test_config.py +++ b/frigate/test/test_config.py @@ -343,8 +343,24 @@ class TestConfig(unittest.TestCase): "fps": 5, }, "objects": { - "mask": "0,0,1,1,0,1", - "filters": {"dog": {"mask": "1,1,1,1,1,1"}}, + "mask": { + "global_mask_1": { + "friendly_name": "Global Mask 1", + "enabled": True, + "coordinates": "0,0,1,1,0,1", + } + }, + "filters": { + "dog": { + "mask": { + "dog_mask_1": { + "friendly_name": "Dog Mask 1", + "enabled": True, + "coordinates": "1,1,1,1,1,1", + } + } + } + }, }, } }, @@ -353,8 +369,10 @@ class TestConfig(unittest.TestCase): frigate_config = FrigateConfig(**config) back_camera = frigate_config.cameras["back"] assert "dog" in back_camera.objects.filters - assert len(back_camera.objects.filters["dog"].raw_mask) == 2 - assert len(back_camera.objects.filters["person"].raw_mask) == 1 + # dog filter has its own mask + global mask merged + assert len(back_camera.objects.filters["dog"].mask) == 2 + # person filter only has the global mask + assert len(back_camera.objects.filters["person"].mask) == 1 def test_motion_mask_relative_matches_explicit(self): config = { @@ -373,9 +391,13 @@ class TestConfig(unittest.TestCase): "fps": 5, }, "motion": { - "mask": [ - "0,0,200,100,600,300,800,400", - ] + "mask": { + "explicit_mask": { + "friendly_name": "Explicit Mask", + "enabled": True, + "coordinates": "0,0,200,100,600,300,800,400", + } + } }, }, "relative": { @@ -390,9 +412,13 @@ class TestConfig(unittest.TestCase): "fps": 5, }, "motion": { - "mask": [ - "0.0,0.0,0.25,0.25,0.75,0.75,1.0,1.0", - ] + "mask": { + "relative_mask": { + "friendly_name": "Relative Mask", + "enabled": True, + "coordinates": "0.0,0.0,0.25,0.25,0.75,0.75,1.0,1.0", + } + } }, }, }, @@ -400,8 +426,8 @@ class TestConfig(unittest.TestCase): frigate_config = FrigateConfig(**config) assert np.array_equal( - frigate_config.cameras["explicit"].motion.mask, - frigate_config.cameras["relative"].motion.mask, + frigate_config.cameras["explicit"].motion.rasterized_mask, + frigate_config.cameras["relative"].motion.rasterized_mask, ) def test_default_input_args(self): diff --git a/frigate/track/tracked_object.py b/frigate/track/tracked_object.py index f435de7b6..c4398dec6 100644 --- a/frigate/track/tracked_object.py +++ b/frigate/track/tracked_object.py @@ -188,6 +188,10 @@ class TrackedObject: # check each zone for name, zone in self.camera_config.zones.items(): + # skip disabled zones + if not zone.enabled: + continue + # if the zone is not for this object type, skip if len(zone.objects) > 0 and obj_data["label"] not in zone.objects: continue diff --git a/frigate/util/config.py b/frigate/util/config.py index 62db3c42b..c689d16e4 100644 --- a/frigate/util/config.py +++ b/frigate/util/config.py @@ -434,6 +434,55 @@ def migrate_017_0(config: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any] return new_config +def _convert_legacy_mask_to_dict( + mask: Optional[Union[str, list]], mask_type: str = "motion_mask", label: str = "" +) -> dict[str, dict[str, Any]]: + """Convert legacy mask format (str or list[str]) to new dict format. + + Args: + mask: Legacy mask format (string or list of strings) + mask_type: Type of mask for naming ("motion_mask" or "object_mask") + label: Optional label for object masks (e.g., "person") + + Returns: + Dictionary with mask_id as key and mask config as value + """ + if not mask: + return {} + + result = {} + + if isinstance(mask, str): + if mask: + mask_id = f"{mask_type}_1" + friendly_name = ( + f"Object Mask 1 ({label})" + if label + else f"{mask_type.replace('_', ' ').title()} 1" + ) + result[mask_id] = { + "friendly_name": friendly_name, + "enabled": True, + "coordinates": mask, + } + elif isinstance(mask, list): + for i, coords in enumerate(mask): + if coords: + mask_id = f"{mask_type}_{i + 1}" + friendly_name = ( + f"Object Mask {i + 1} ({label})" + if label + else f"{mask_type.replace('_', ' ').title()} {i + 1}" + ) + result[mask_id] = { + "friendly_name": friendly_name, + "enabled": True, + "coordinates": coords, + } + + return result + + def migrate_018_0(config: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]]: """Handle migrating frigate config to 0.18-0""" new_config = config.copy() @@ -459,7 +508,35 @@ def migrate_018_0(config: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any] if not new_config.get("record"): del new_config["record"] - # Remove deprecated sync_recordings and timelapse_args from camera-specific record configs + # Migrate global motion masks + global_motion = new_config.get("motion", {}) + if global_motion and "mask" in global_motion: + mask = global_motion.get("mask") + if mask is not None and not isinstance(mask, dict): + new_config["motion"]["mask"] = _convert_legacy_mask_to_dict( + mask, "motion_mask" + ) + + # Migrate global object masks + global_objects = new_config.get("objects", {}) + if global_objects and "mask" in global_objects: + mask = global_objects.get("mask") + if mask is not None and not isinstance(mask, dict): + new_config["objects"]["mask"] = _convert_legacy_mask_to_dict( + mask, "object_mask" + ) + + # Migrate global object filters masks + if global_objects and "filters" in global_objects: + for obj_name, filter_config in global_objects.get("filters", {}).items(): + if isinstance(filter_config, dict) and "mask" in filter_config: + mask = filter_config.get("mask") + if mask is not None and not isinstance(mask, dict): + new_config["objects"]["filters"][obj_name]["mask"] = ( + _convert_legacy_mask_to_dict(mask, "object_mask", obj_name) + ) + + # Remove deprecated sync_recordings and migrate masks for camera-specific configs for name, camera in config.get("cameras", {}).items(): camera_config: dict[str, dict[str, Any]] = camera.copy() @@ -478,6 +555,34 @@ def migrate_018_0(config: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any] if not camera_config.get("record"): del camera_config["record"] + # Migrate camera motion masks + camera_motion = camera_config.get("motion", {}) + if camera_motion and "mask" in camera_motion: + mask = camera_motion.get("mask") + if mask is not None and not isinstance(mask, dict): + camera_config["motion"]["mask"] = _convert_legacy_mask_to_dict( + mask, "motion_mask" + ) + + # Migrate camera global object masks + camera_objects = camera_config.get("objects", {}) + if camera_objects and "mask" in camera_objects: + mask = camera_objects.get("mask") + if mask is not None and not isinstance(mask, dict): + camera_config["objects"]["mask"] = _convert_legacy_mask_to_dict( + mask, "object_mask" + ) + + # Migrate camera object filter masks + if camera_objects and "filters" in camera_objects: + for obj_name, filter_config in camera_objects.get("filters", {}).items(): + if isinstance(filter_config, dict) and "mask" in filter_config: + mask = filter_config.get("mask") + if mask is not None and not isinstance(mask, dict): + camera_config["objects"]["filters"][obj_name]["mask"] = ( + _convert_legacy_mask_to_dict(mask, "object_mask", obj_name) + ) + new_config["cameras"][name] = camera_config new_config["version"] = "0.18-0" diff --git a/frigate/util/object.py b/frigate/util/object.py index 905745da6..021150132 100644 --- a/frigate/util/object.py +++ b/frigate/util/object.py @@ -248,20 +248,20 @@ def is_object_filtered(obj, objects_to_track, object_filters): if obj_settings.max_ratio < object_ratio: return True - if obj_settings.mask is not None: + if obj_settings.rasterized_mask is not None: # compute the coordinates of the object and make sure # the location isn't outside the bounds of the image (can happen from rounding) object_xmin = object_box[0] object_xmax = object_box[2] object_ymax = object_box[3] - y_location = min(int(object_ymax), len(obj_settings.mask) - 1) + y_location = min(int(object_ymax), len(obj_settings.rasterized_mask) - 1) x_location = min( int((object_xmax + object_xmin) / 2.0), - len(obj_settings.mask[0]) - 1, + len(obj_settings.rasterized_mask[0]) - 1, ) # if the object is in a masked location, don't add it to detected objects - if obj_settings.mask[y_location][x_location] == 0: + if obj_settings.rasterized_mask[y_location][x_location] == 0: return True return False diff --git a/web/public/locales/en/config/cameras.json b/web/public/locales/en/config/cameras.json index b2b34c8fb..bbb8d4b45 100644 --- a/web/public/locales/en/config/cameras.json +++ b/web/public/locales/en/config/cameras.json @@ -348,6 +348,9 @@ "label": "Object mask", "description": "Mask polygon used to prevent object detection in specified areas." }, + "raw_mask": { + "label": "Raw Mask" + }, "genai": { "label": "GenAI object config", "description": "GenAI options for describing tracked objects and sending frames for generation.", @@ -860,6 +863,12 @@ "label": "Zone name", "description": "A user-friendly name for the zone, displayed in the Frigate UI. If not set, a formatted version of the zone name will be used." }, + "enabled": { + "label": "Whether this zone is active. Disabled zones are ignored at runtime." + }, + "enabled_in_config": { + "label": "Keep track of original state of zone." + }, "filters": { "label": "Zone filters", "description": "Filters to apply to objects within this zone. Used to reduce false positives or restrict which objects are considered present in the zone.", diff --git a/web/public/locales/en/config/global.json b/web/public/locales/en/config/global.json index 9dc991491..1bda2ab44 100644 --- a/web/public/locales/en/config/global.json +++ b/web/public/locales/en/config/global.json @@ -1475,6 +1475,9 @@ "label": "Object mask", "description": "Mask polygon used to prevent object detection in specified areas." }, + "raw_mask": { + "label": "Raw Mask" + }, "genai": { "label": "GenAI object config", "description": "GenAI options for describing tracked objects and sending frames for generation.", diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index 4aa50ad9c..ca772377b 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -505,6 +505,7 @@ "all": "All Masks and Zones" }, "restart_required": "Restart required (masks/zones changed)", + "disabledInConfig": "Item is disabled in the config file", "toast": { "success": { "copyCoordinates": "Copied coordinates for {{polyName}} to clipboard." @@ -514,7 +515,7 @@ } }, "motionMaskLabel": "Motion Mask {{number}}", - "objectMaskLabel": "Object Mask {{number}} ({{label}})", + "objectMaskLabel": "Object Mask {{number}}", "form": { "zoneName": { "error": { @@ -588,6 +589,10 @@ "inputPlaceHolder": "Enter a name…", "tips": "Name must be at least 2 characters, must have at least one letter, and must not be the name of a camera or another zone on this camera." }, + "enabled": { + "title": "Enabled", + "description": "Whether this zone is active and enabled in the config file. If disabled, it cannot be enabled by MQTT. Disabled zones are ignored at runtime." + }, "inertia": { "title": "Inertia", "desc": "Specifies how many frames that an object must be in a zone before they are considered in the zone. Default: 3" @@ -632,12 +637,18 @@ }, "add": "New Motion Mask", "edit": "Edit Motion Mask", + "defaultName": "Motion Mask {{number}}", "context": { "title": "Motion masks are used to prevent unwanted types of motion from triggering detection (example: tree branches, camera timestamps). Motion masks should be used very sparingly, over-masking will make it more difficult for objects to be tracked." }, "point_one": "{{count}} point", "point_other": "{{count}} points", "clickDrawPolygon": "Click to draw a polygon on the image.", + "name": { + "title": "Name", + "description": "An optional friendly name for this motion mask.", + "placeholder": "Enter a name..." + }, "polygonAreaTooLarge": { "title": "The motion mask is covering {{polygonArea}}% of the camera frame. Large motion masks are not recommended.", "tips": "Motion masks do not prevent objects from being detected. You should use a required zone instead." @@ -662,6 +673,11 @@ "point_one": "{{count}} point", "point_other": "{{count}} points", "clickDrawPolygon": "Click to draw a polygon on the image.", + "name": { + "title": "Name", + "description": "An optional friendly name for this object mask.", + "placeholder": "Enter a name..." + }, "objects": { "title": "Objects", "desc": "The object type that applies to this object mask.", @@ -673,6 +689,12 @@ "noName": "Object Mask has been saved." } } + }, + "masks": { + "enabled": { + "title": "Enabled", + "description": "Whether this mask is enabled in the config file. If disabled, it cannot be enabled by MQTT. Disabled masks are ignored at runtime." + } } }, "motionDetectionTuner": { diff --git a/web/src/api/ws.tsx b/web/src/api/ws.tsx index 6bb2fdc32..f5b7b57a3 100644 --- a/web/src/api/ws.tsx +++ b/web/src/api/ws.tsx @@ -304,6 +304,57 @@ export function useReviewDescriptionState(camera: string): { return { payload: payload as ToggleableSetting, send }; } +export function useMotionMaskState( + camera: string, + maskName: string, +): { + payload: ToggleableSetting; + send: (payload: ToggleableSetting, retain?: boolean) => void; +} { + const { + value: { payload }, + send, + } = useWs( + `${camera}/motion_mask/${maskName}/state`, + `${camera}/motion_mask/${maskName}/set`, + ); + return { payload: payload as ToggleableSetting, send }; +} + +export function useObjectMaskState( + camera: string, + maskName: string, +): { + payload: ToggleableSetting; + send: (payload: ToggleableSetting, retain?: boolean) => void; +} { + const { + value: { payload }, + send, + } = useWs( + `${camera}/object_mask/${maskName}/state`, + `${camera}/object_mask/${maskName}/set`, + ); + return { payload: payload as ToggleableSetting, send }; +} + +export function useZoneState( + camera: string, + zoneName: string, +): { + payload: ToggleableSetting; + send: (payload: ToggleableSetting, retain?: boolean) => void; +} { + const { + value: { payload }, + send, + } = useWs( + `${camera}/zone/${zoneName}/state`, + `${camera}/zone/${zoneName}/set`, + ); + return { payload: payload as ToggleableSetting, send }; +} + export function usePtzCommand(camera: string): { payload: string; send: (payload: string, retain?: boolean) => void; diff --git a/web/src/components/settings/MotionMaskEditPane.tsx b/web/src/components/settings/MotionMaskEditPane.tsx index ddb78c877..3857c4060 100644 --- a/web/src/components/settings/MotionMaskEditPane.tsx +++ b/web/src/components/settings/MotionMaskEditPane.tsx @@ -1,21 +1,25 @@ import Heading from "../ui/heading"; import { Separator } from "../ui/separator"; import { Button } from "@/components/ui/button"; -import { Form, FormField, FormItem, FormMessage } from "@/components/ui/form"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; import { useCallback, useEffect, useMemo } from "react"; import { zodResolver } from "@hookform/resolvers/zod"; -import { useForm } from "react-hook-form"; +import { useForm, FormProvider } from "react-hook-form"; import { z } from "zod"; import PolygonEditControls from "./PolygonEditControls"; import { FaCheckCircle } from "react-icons/fa"; -import { Polygon } from "@/types/canvas"; +import { MotionMaskFormValuesType, Polygon } from "@/types/canvas"; import useSWR from "swr"; import { FrigateConfig } from "@/types/frigateConfig"; -import { - flattenPoints, - interpolatePoints, - parseCoordinates, -} from "@/utils/canvasUtil"; +import { flattenPoints, interpolatePoints } from "@/utils/canvasUtil"; import axios from "axios"; import { toast } from "sonner"; import { Toaster } from "../ui/sonner"; @@ -24,6 +28,9 @@ import { Link } from "react-router-dom"; import { LuExternalLink } from "react-icons/lu"; import { Trans, useTranslation } from "react-i18next"; import { useDocDomain } from "@/hooks/use-doc-domain"; +import NameAndIdFields from "../input/NameAndIdFields"; +import { Switch } from "../ui/switch"; +import { useMotionMaskState } from "@/api/ws"; type MotionMaskEditPaneProps = { polygons?: Polygon[]; @@ -65,6 +72,11 @@ export default function MotionMaskEditPane({ } }, [polygons, activePolygonIndex]); + const { send: sendMotionMaskState } = useMotionMaskState( + polygon?.camera || "", + polygon?.name || "", + ); + const cameraConfig = useMemo(() => { if (polygon?.camera && config) { return config.cameras[polygon.camera]; @@ -73,12 +85,24 @@ export default function MotionMaskEditPane({ const defaultName = useMemo(() => { if (!polygons) { - return; + return ""; } const count = polygons.filter((poly) => poly.type == "motion_mask").length; - return `Motion Mask ${count + 1}`; + return t("masksAndZones.motionMasks.defaultName", { + number: count, + }); + }, [polygons, t]); + + const defaultId = useMemo(() => { + if (!polygons) { + return ""; + } + + const count = polygons.filter((poly) => poly.type == "motion_mask").length; + + return `motion_mask_${count}`; }, [polygons]); const polygonArea = useMemo(() => { @@ -104,116 +128,157 @@ export default function MotionMaskEditPane({ } }, [polygon, scaledWidth, scaledHeight]); - const formSchema = z - .object({ - polygon: z.object({ name: z.string(), isFinished: z.boolean() }), - }) - .refine(() => polygon?.isFinished === true, { + const formSchema = z.object({ + name: z + .string() + .min(1, { + message: t("masksAndZones.form.id.error.mustNotBeEmpty"), + }) + .refine( + (value: string) => { + // When editing, allow the same name + if (polygon?.name && value === polygon.name) { + return true; + } + // Check if mask ID already exists + const existingMaskIds = Object.keys(cameraConfig?.motion.mask || {}); + return !existingMaskIds.includes(value); + }, + { + message: t("masksAndZones.form.id.error.alreadyExists"), + }, + ), + friendly_name: z.string().min(1, { + message: t("masksAndZones.form.name.error.mustNotBeEmpty"), + }), + enabled: z.boolean(), + isFinished: z.boolean().refine(() => polygon?.isFinished === true, { message: t("masksAndZones.form.polygonDrawing.error.mustBeFinished"), - path: ["polygon.isFinished"], - }); + }), + }); const form = useForm>({ resolver: zodResolver(formSchema), mode: "onChange", defaultValues: { - polygon: { isFinished: polygon?.isFinished ?? false, name: defaultName }, + name: polygon?.name || defaultId, + friendly_name: polygon?.friendly_name || defaultName, + enabled: polygon?.enabled ?? true, + isFinished: polygon?.isFinished ?? false, }, }); - const saveToConfig = useCallback(async () => { - if (!scaledWidth || !scaledHeight || !polygon || !cameraConfig) { - return; - } + const saveToConfig = useCallback( + async ({ + name: maskId, + friendly_name, + enabled, + }: MotionMaskFormValuesType) => { + if (!scaledWidth || !scaledHeight || !polygon || !cameraConfig) { + return; + } - const coordinates = flattenPoints( - interpolatePoints(polygon.points, scaledWidth, scaledHeight, 1, 1), - ).join(","); + const coordinates = flattenPoints( + interpolatePoints(polygon.points, scaledWidth, scaledHeight, 1, 1), + ).join(","); - let index = Array.isArray(cameraConfig.motion.mask) - ? cameraConfig.motion.mask.length - : cameraConfig.motion.mask - ? 1 - : 0; + const editingMask = polygon.name.length > 0; + const renamingMask = editingMask && maskId !== polygon.name; - const editingMask = polygon.name.length > 0; + // Build the new mask configuration + const maskConfig = { + friendly_name: friendly_name, + enabled: enabled, + coordinates: coordinates, + }; - // editing existing mask, not creating a new one - if (editingMask) { - index = polygon.typeIndex; - } - - const filteredMask = ( - Array.isArray(cameraConfig.motion.mask) - ? cameraConfig.motion.mask - : [cameraConfig.motion.mask] - ).filter((_, currentIndex) => currentIndex !== index); - - filteredMask.splice(index, 0, coordinates); - - const queryString = filteredMask - .map((pointsArray) => { - const coordinates = flattenPoints(parseCoordinates(pointsArray)).join( - ",", - ); - return `cameras.${polygon?.camera}.motion.mask=${coordinates}&`; - }) - .join(""); - - axios - .put(`config/set?${queryString}`, { - requires_restart: 0, - update_topic: `config/cameras/${polygon.camera}/motion`, - }) - .then((res) => { - if (res.status === 200) { - toast.success( - polygon.name - ? t("masksAndZones.motionMasks.toast.success.title", { - polygonName: polygon.name, - }) - : t("masksAndZones.motionMasks.toast.success.noName"), + // If renaming, we need to delete the old mask first + if (renamingMask) { + try { + await axios.put( + `config/set?cameras.${polygon.camera}.motion.mask.${polygon.name}`, { - position: "top-center", + requires_restart: 0, }, ); - updateConfig(); - } else { - toast.error( - t("toast.save.error.title", { - errorMessage: res.statusText, - ns: "common", - }), - { - position: "top-center", - }, - ); - } - }) - .catch((error) => { - const errorMessage = - error.response?.data?.message || - error.response?.data?.detail || - "Unknown error"; - toast.error( - t("toast.save.error.title", { errorMessage, ns: "common" }), - { + } catch (error) { + toast.error(t("toast.save.error.noMessage", { ns: "common" }), { position: "top-center", + }); + setIsLoading(false); + return; + } + } + + // Save the new/updated mask using JSON body + axios + .put("config/set", { + config_data: { + cameras: { + [polygon.camera]: { + motion: { + mask: { + [maskId]: maskConfig, + }, + }, + }, + }, }, - ); - }) - .finally(() => { - setIsLoading(false); - }); - }, [ - updateConfig, - polygon, - scaledWidth, - scaledHeight, - setIsLoading, - cameraConfig, - t, - ]); + requires_restart: 0, + update_topic: `config/cameras/${polygon.camera}/motion`, + }) + .then((res) => { + if (res.status === 200) { + toast.success( + t("masksAndZones.motionMasks.toast.success.title", { + polygonName: friendly_name || maskId, + }), + { + position: "top-center", + }, + ); + updateConfig(); + // Publish the enabled state through websocket + sendMotionMaskState(enabled ? "ON" : "OFF"); + } else { + toast.error( + t("toast.save.error.title", { + errorMessage: res.statusText, + ns: "common", + }), + { + position: "top-center", + }, + ); + } + }) + .catch((error) => { + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error( + t("toast.save.error.title", { errorMessage, ns: "common" }), + { + position: "top-center", + }, + ); + }) + .finally(() => { + setIsLoading(false); + }); + }, + [ + updateConfig, + polygon, + scaledWidth, + scaledHeight, + setIsLoading, + cameraConfig, + t, + sendMotionMaskState, + ], + ); function onSubmit(values: z.infer) { if (activePolygonIndex === undefined || !values || !polygons) { @@ -221,7 +286,7 @@ export default function MotionMaskEditPane({ } setIsLoading(true); - saveToConfig(); + saveToConfig(values as MotionMaskFormValuesType); if (onSave) { onSave(); } @@ -310,58 +375,83 @@ export default function MotionMaskEditPane({ )} -
- - ( - - - - )} - /> - ( - - - - )} - /> -
-
- - + + + + + )} + /> + ( + + + + )} + /> +
+
+ + +
-
- - + + + ); } diff --git a/web/src/components/settings/ObjectMaskEditPane.tsx b/web/src/components/settings/ObjectMaskEditPane.tsx index 2874c8b92..380c40be1 100644 --- a/web/src/components/settings/ObjectMaskEditPane.tsx +++ b/web/src/components/settings/ObjectMaskEditPane.tsx @@ -23,22 +23,21 @@ import { useCallback, useEffect, useMemo } from "react"; import { FrigateConfig } from "@/types/frigateConfig"; import useSWR from "swr"; import { zodResolver } from "@hookform/resolvers/zod"; -import { useForm } from "react-hook-form"; +import { useForm, FormProvider } from "react-hook-form"; import { z } from "zod"; import { ObjectMaskFormValuesType, Polygon } from "@/types/canvas"; import PolygonEditControls from "./PolygonEditControls"; import { FaCheckCircle } from "react-icons/fa"; -import { - flattenPoints, - interpolatePoints, - parseCoordinates, -} from "@/utils/canvasUtil"; +import { flattenPoints, interpolatePoints } from "@/utils/canvasUtil"; import axios from "axios"; import { toast } from "sonner"; import { Toaster } from "../ui/sonner"; import ActivityIndicator from "../indicators/activity-indicator"; import { useTranslation } from "react-i18next"; import { getTranslatedLabel } from "@/utils/i18n"; +import NameAndIdFields from "../input/NameAndIdFields"; +import { Switch } from "../ui/switch"; +import { useObjectMaskState } from "@/api/ws"; type ObjectMaskEditPaneProps = { polygons?: Polygon[]; @@ -79,6 +78,11 @@ export default function ObjectMaskEditPane({ } }, [polygons, activePolygonIndex]); + const { send: sendObjectMaskState } = useObjectMaskState( + polygon?.camera || "", + polygon?.name || "", + ); + const cameraConfig = useMemo(() => { if (polygon?.camera && config) { return config.cameras[polygon.camera]; @@ -87,48 +91,80 @@ export default function ObjectMaskEditPane({ const defaultName = useMemo(() => { if (!polygons) { - return; + return ""; } const count = polygons.filter((poly) => poly.type == "object_mask").length; - let objectType = ""; - const objects = polygon?.objects[0]; - if (objects === undefined) { - objectType = "all objects"; - } else { - objectType = objects; + return t("masksAndZones.objectMaskLabel", { + number: count, + }); + }, [polygons, t]); + + const defaultId = useMemo(() => { + if (!polygons) { + return ""; } - return t("masksAndZones.objectMaskLabel", { - number: count + 1, - label: getTranslatedLabel(objectType), - }); - }, [polygons, polygon, t]); + const count = polygons.filter((poly) => poly.type == "object_mask").length; - const formSchema = z - .object({ - objects: z.string(), - polygon: z.object({ isFinished: z.boolean(), name: z.string() }), - }) - .refine(() => polygon?.isFinished === true, { + return `object_mask_${count}`; + }, [polygons]); + + const formSchema = z.object({ + name: z + .string() + .min(1, { + message: t("masksAndZones.form.id.error.mustNotBeEmpty"), + }) + .refine( + (value: string) => { + // When editing, allow the same name + if (polygon?.name && value === polygon.name) { + return true; + } + // Check if mask ID already exists in global masks or filter masks + const globalMaskIds = Object.keys(cameraConfig?.objects.mask || {}); + const filterMaskIds = Object.values( + cameraConfig?.objects.filters || {}, + ).flatMap((filter) => Object.keys(filter.mask || {})); + return ( + !globalMaskIds.includes(value) && !filterMaskIds.includes(value) + ); + }, + { + message: t("masksAndZones.form.id.error.alreadyExists"), + }, + ), + friendly_name: z.string().min(1, { + message: t("masksAndZones.form.name.error.mustNotBeEmpty"), + }), + enabled: z.boolean(), + objects: z.string(), + isFinished: z.boolean().refine(() => polygon?.isFinished === true, { message: t("masksAndZones.form.polygonDrawing.error.mustBeFinished"), - path: ["polygon.isFinished"], - }); + }), + }); const form = useForm>({ resolver: zodResolver(formSchema), mode: "onChange", defaultValues: { + name: polygon?.name || defaultId, + friendly_name: polygon?.friendly_name || defaultName, + enabled: polygon?.enabled ?? true, objects: polygon?.objects[0] ?? "all_labels", - polygon: { isFinished: polygon?.isFinished ?? false, name: defaultName }, + isFinished: polygon?.isFinished ?? false, }, }); const saveToConfig = useCallback( - async ( - { objects: form_objects }: ObjectMaskFormValuesType, // values submitted via the form - ) => { + async ({ + name: maskId, + friendly_name, + enabled, + objects: form_objects, + }: ObjectMaskFormValuesType) => { if (!scaledWidth || !scaledHeight || !polygon || !cameraConfig) { return; } @@ -137,93 +173,94 @@ export default function ObjectMaskEditPane({ interpolatePoints(polygon.points, scaledWidth, scaledHeight, 1, 1), ).join(","); - let queryString = ""; - let configObject; - let createFilter = false; - let globalMask = false; - let filteredMask = [coordinates]; const editingMask = polygon.name.length > 0; + const renamingMask = editingMask && maskId !== polygon.name; + const globalMask = form_objects === "all_labels"; - // global mask on camera for all objects - if (form_objects == "all_labels") { - configObject = cameraConfig.objects.mask; - globalMask = true; + // Build the mask configuration + const maskConfig = { + friendly_name: friendly_name, + enabled: enabled, + coordinates: coordinates, + }; + + // If renaming, delete the old mask first + if (renamingMask) { + try { + // Determine if old mask was global or per-object + const wasGlobal = + polygon.objects.length === 0 || polygon.objects[0] === "all_labels"; + const oldPath = wasGlobal + ? `cameras.${polygon.camera}.objects.mask.${polygon.name}` + : `cameras.${polygon.camera}.objects.filters.${polygon.objects[0]}.mask.${polygon.name}`; + + await axios.put(`config/set?${oldPath}`, { + requires_restart: 0, + }); + } catch (error) { + toast.error(t("toast.save.error.noMessage", { ns: "common" }), { + position: "top-center", + }); + setIsLoading(false); + return; + } + } + + // Build the config structure based on whether it's global or per-object + let configBody; + if (globalMask) { + configBody = { + config_data: { + cameras: { + [polygon.camera]: { + objects: { + mask: { + [maskId]: maskConfig, + }, + }, + }, + }, + }, + requires_restart: 0, + update_topic: `config/cameras/${polygon.camera}/objects`, + }; } else { - if ( - cameraConfig.objects.filters[form_objects] && - cameraConfig.objects.filters[form_objects].mask !== null - ) { - configObject = cameraConfig.objects.filters[form_objects].mask; - } else { - createFilter = true; - } - } - - if (!createFilter) { - let index = Array.isArray(configObject) - ? configObject.length - : configObject - ? 1 - : 0; - - // editing existing mask, not creating a new one - if (editingMask) { - index = polygon.typeIndex; - } - - filteredMask = ( - Array.isArray(configObject) ? configObject : [configObject as string] - ).filter((_, currentIndex) => currentIndex !== index); - - filteredMask.splice(index, 0, coordinates); - } - - // prevent duplicating global masks under specific object filters - if (!globalMask) { - const globalObjectMasksArray = Array.isArray(cameraConfig.objects.mask) - ? cameraConfig.objects.mask - : cameraConfig.objects.mask - ? [cameraConfig.objects.mask] - : []; - - filteredMask = filteredMask.filter( - (mask) => !globalObjectMasksArray.includes(mask), - ); - } - - queryString = filteredMask - .map((pointsArray) => { - const coordinates = flattenPoints(parseCoordinates(pointsArray)).join( - ",", - ); - return globalMask - ? `cameras.${polygon?.camera}.objects.mask=${coordinates}&` - : `cameras.${polygon?.camera}.objects.filters.${form_objects}.mask=${coordinates}&`; - }) - .join(""); - - if (!queryString) { - return; + configBody = { + config_data: { + cameras: { + [polygon.camera]: { + objects: { + filters: { + [form_objects]: { + mask: { + [maskId]: maskConfig, + }, + }, + }, + }, + }, + }, + }, + requires_restart: 0, + update_topic: `config/cameras/${polygon.camera}/objects`, + }; } axios - .put(`config/set?${queryString}`, { - requires_restart: 0, - update_topic: `config/cameras/${polygon.camera}/objects`, - }) + .put("config/set", configBody) .then((res) => { if (res.status === 200) { toast.success( - polygon.name - ? t("masksAndZones.objectMasks.toast.success.title", { - polygonName: polygon.name, - }) - : t("masksAndZones.objectMasks.toast.success.noName"), + t("masksAndZones.objectMasks.toast.success.title", { + polygonName: friendly_name || maskId, + }), { position: "top-center", }, ); updateConfig(); + // Publish the enabled state through websocket + sendObjectMaskState(enabled ? "ON" : "OFF"); } else { toast.error( t("toast.save.error.title", { @@ -263,6 +300,7 @@ export default function ObjectMaskEditPane({ setIsLoading, cameraConfig, t, + sendObjectMaskState, ], ); @@ -323,89 +361,118 @@ export default function ObjectMaskEditPane({ -
- -
- ( - - - - )} - /> - ( - - - {t("masksAndZones.objectMasks.objects.title")} - - - - {t("masksAndZones.objectMasks.objects.desc")} - - - - )} - /> - ( - - - - )} - /> -
-
-
- - + placeholderName={t( + "masksAndZones.objectMasks.name.placeholder", + )} + /> + ( + +
+ + {t("masksAndZones.masks.enabled.title")} + + + {t("masksAndZones.masks.enabled.description")} + +
+ + + +
+ )} + /> + ( + + + {t("masksAndZones.objectMasks.objects.title")} + + + + {t("masksAndZones.objectMasks.objects.desc")} + + + + )} + /> + ( + + + + )} + />
-
-
- +
+
+ + +
+
+ + + ); } diff --git a/web/src/components/settings/PolygonCanvas.tsx b/web/src/components/settings/PolygonCanvas.tsx index 307393eae..30b203bd3 100644 --- a/web/src/components/settings/PolygonCanvas.tsx +++ b/web/src/components/settings/PolygonCanvas.tsx @@ -7,6 +7,7 @@ import { Polygon, PolygonType } from "@/types/canvas"; import { useApiHost } from "@/api"; import ActivityIndicator from "@/components/indicators/activity-indicator"; import { snapPointToLines } from "@/utils/canvasUtil"; +import { usePolygonStates } from "@/hooks/use-polygon-states"; type PolygonCanvasProps = { containerRef: RefObject; @@ -40,6 +41,7 @@ export function PolygonCanvas({ const imageRef = useRef(null); const stageRef = useRef(null); const apiHost = useApiHost(); + const getPolygonEnabled = usePolygonStates(polygons); const videoElement = useMemo(() => { if (camera && width && height) { @@ -321,6 +323,7 @@ export function PolygonCanvas({ isActive={index === activePolygonIndex} isHovered={index === hoveredPolygonIndex} isFinished={polygon.isFinished} + enabled={getPolygonEnabled(polygon)} color={polygon.color} handlePointDragMove={handlePointDragMove} handleGroupDragEnd={handleGroupDragEnd} @@ -350,6 +353,7 @@ export function PolygonCanvas({ isActive={true} isHovered={activePolygonIndex === hoveredPolygonIndex} isFinished={polygons[activePolygonIndex].isFinished} + enabled={getPolygonEnabled(polygons[activePolygonIndex])} color={polygons[activePolygonIndex].color} handlePointDragMove={handlePointDragMove} handleGroupDragEnd={handleGroupDragEnd} diff --git a/web/src/components/settings/PolygonDrawer.tsx b/web/src/components/settings/PolygonDrawer.tsx index 9cc5649a6..6b29b5e85 100644 --- a/web/src/components/settings/PolygonDrawer.tsx +++ b/web/src/components/settings/PolygonDrawer.tsx @@ -24,6 +24,7 @@ type PolygonDrawerProps = { isActive: boolean; isHovered: boolean; isFinished: boolean; + enabled?: boolean; color: number[]; handlePointDragMove: (e: KonvaEventObject) => void; handleGroupDragEnd: (e: KonvaEventObject) => void; @@ -39,6 +40,7 @@ export default function PolygonDrawer({ isActive, isHovered, isFinished, + enabled = true, color, handlePointDragMove, handleGroupDragEnd, @@ -108,9 +110,15 @@ export default function PolygonDrawer({ const colorString = useCallback( (darkened: boolean) => { + if (!enabled) { + // Slightly desaturate the color when disabled + const avg = (color[0] + color[1] + color[2]) / 3; + const desaturated = color.map((c) => Math.round(c * 0.4 + avg * 0.6)); + return toRGBColorString(desaturated, darkened); + } return toRGBColorString(color, darkened); }, - [color], + [color, enabled], ); useEffect(() => { @@ -162,9 +170,11 @@ export default function PolygonDrawer({ points={flattenedPoints} stroke={colorString(true)} strokeWidth={3} + dash={enabled ? undefined : [10, 5]} hitStrokeWidth={12} closed={isFinished} fill={colorString(isActive || isHovered ? true : false)} + opacity={enabled ? 1 : 0.85} onMouseOver={() => isActive ? isFinished diff --git a/web/src/components/settings/PolygonItem.tsx b/web/src/components/settings/PolygonItem.tsx index 015bf9bd2..8fc6b639e 100644 --- a/web/src/components/settings/PolygonItem.tsx +++ b/web/src/components/settings/PolygonItem.tsx @@ -20,11 +20,7 @@ import { FaDrawPolygon, FaObjectGroup } from "react-icons/fa"; import { BsPersonBoundingBox } from "react-icons/bs"; import { HiOutlineDotsVertical, HiTrash } from "react-icons/hi"; import { isDesktop, isMobile } from "react-device-detect"; -import { - flattenPoints, - parseCoordinates, - toRGBColorString, -} from "@/utils/canvasUtil"; +import { toRGBColorString } from "@/utils/canvasUtil"; import { Polygon, PolygonType } from "@/types/canvas"; import { useCallback, useMemo, useState } from "react"; import axios from "axios"; @@ -36,6 +32,9 @@ import { reviewQueries } from "@/utils/zoneEdutUtil"; import IconWrapper from "../ui/icon-wrapper"; import { buttonVariants } from "../ui/button"; import { Trans, useTranslation } from "react-i18next"; +import ActivityIndicator from "../indicators/activity-indicator"; +import { cn } from "@/lib/utils"; +import { useMotionMaskState, useObjectMaskState, useZoneState } from "@/api/ws"; type PolygonItemProps = { polygon: Polygon; @@ -45,6 +44,10 @@ type PolygonItemProps = { setActivePolygonIndex: (index: number | undefined) => void; setEditPane: (type: PolygonType) => void; handleCopyCoordinates: (index: number) => void; + isLoading: boolean; + setIsLoading: (loading: boolean) => void; + loadingPolygonIndex: number | undefined; + setLoadingPolygonIndex: (index: number | undefined) => void; }; export default function PolygonItem({ @@ -55,12 +58,40 @@ export default function PolygonItem({ setActivePolygonIndex, setEditPane, handleCopyCoordinates, + isLoading, + setIsLoading, + loadingPolygonIndex, + setLoadingPolygonIndex, }: PolygonItemProps) { const { t } = useTranslation("views/settings"); const { data: config, mutate: updateConfig } = useSWR("config"); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); - const [isLoading, setIsLoading] = useState(false); + const { payload: motionMaskState, send: sendMotionMaskState } = + useMotionMaskState(polygon.camera, polygon.name); + const { payload: objectMaskState, send: sendObjectMaskState } = + useObjectMaskState(polygon.camera, polygon.name); + const { payload: zoneState, send: sendZoneState } = useZoneState( + polygon.camera, + polygon.name, + ); + const isPolygonEnabled = useMemo(() => { + const wsState = + polygon.type === "zone" + ? zoneState + : polygon.type === "motion_mask" + ? motionMaskState + : objectMaskState; + const wsEnabled = + wsState === "ON" ? true : wsState === "OFF" ? false : undefined; + return wsEnabled ?? polygon.enabled ?? true; + }, [ + polygon.enabled, + polygon.type, + zoneState, + motionMaskState, + objectMaskState, + ]); const cameraConfig = useMemo(() => { if (polygon?.camera && config) { @@ -81,93 +112,6 @@ export default function PolygonItem({ if (!polygon || !cameraConfig) { return; } - let url = ""; - if (polygon.type == "zone") { - const { alertQueries, detectionQueries } = reviewQueries( - polygon.name, - false, - false, - polygon.camera, - cameraConfig?.review.alerts.required_zones || [], - cameraConfig?.review.detections.required_zones || [], - ); - url = `cameras.${polygon.camera}.zones.${polygon.name}${alertQueries}${detectionQueries}`; - } - if (polygon.type == "motion_mask") { - const filteredMask = ( - Array.isArray(cameraConfig.motion.mask) - ? cameraConfig.motion.mask - : [cameraConfig.motion.mask] - ).filter((_, currentIndex) => currentIndex !== polygon.typeIndex); - - url = filteredMask - .map((pointsArray) => { - const coordinates = flattenPoints( - parseCoordinates(pointsArray), - ).join(","); - return `cameras.${polygon?.camera}.motion.mask=${coordinates}&`; - }) - .join(""); - - if (!url) { - // deleting last mask - url = `cameras.${polygon?.camera}.motion.mask&`; - } - } - - if (polygon.type == "object_mask") { - let configObject; - let globalMask = false; - - // global mask on camera for all objects - if (!polygon.objects.length) { - configObject = cameraConfig.objects.mask; - globalMask = true; - } else { - configObject = cameraConfig.objects.filters[polygon.objects[0]].mask; - } - - if (!configObject) { - return; - } - - const globalObjectMasksArray = Array.isArray(cameraConfig.objects.mask) - ? cameraConfig.objects.mask - : cameraConfig.objects.mask - ? [cameraConfig.objects.mask] - : []; - - let filteredMask; - if (globalMask) { - filteredMask = ( - Array.isArray(configObject) ? configObject : [configObject] - ).filter((_, currentIndex) => currentIndex !== polygon.typeIndex); - } else { - filteredMask = ( - Array.isArray(configObject) ? configObject : [configObject] - ) - .filter((mask) => !globalObjectMasksArray.includes(mask)) - .filter((_, currentIndex) => currentIndex !== polygon.typeIndex); - } - - url = filteredMask - .map((pointsArray) => { - const coordinates = flattenPoints( - parseCoordinates(pointsArray), - ).join(","); - return globalMask - ? `cameras.${polygon?.camera}.objects.mask=${coordinates}&` - : `cameras.${polygon?.camera}.objects.filters.${polygon.objects[0]}.mask=${coordinates}&`; - }) - .join(""); - - if (!url) { - // deleting last mask - url = globalMask - ? `cameras.${polygon?.camera}.objects.mask&` - : `cameras.${polygon?.camera}.objects.filters.${polygon.objects[0]}.mask`; - } - } const updateTopicType = polygon.type === "zone" @@ -179,9 +123,117 @@ export default function PolygonItem({ : polygon.type; setIsLoading(true); + setLoadingPolygonIndex(index); + + if (polygon.type === "zone") { + // Zones use query string format + const { alertQueries, detectionQueries } = reviewQueries( + polygon.name, + false, + false, + polygon.camera, + cameraConfig?.review.alerts.required_zones || [], + cameraConfig?.review.detections.required_zones || [], + ); + const url = `cameras.${polygon.camera}.zones.${polygon.name}${alertQueries}${detectionQueries}`; + + await axios + .put(`config/set?${url}`, { + requires_restart: 0, + update_topic: `config/cameras/${polygon.camera}/${updateTopicType}`, + }) + .then((res) => { + if (res.status === 200) { + toast.success( + t("masksAndZones.form.polygonDrawing.delete.success", { + name: polygon?.friendly_name ?? polygon?.name, + }), + { position: "top-center" }, + ); + updateConfig(); + } else { + toast.error( + t("toast.save.error.title", { + ns: "common", + errorMessage: res.statusText, + }), + { position: "top-center" }, + ); + } + }) + .catch((error) => { + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error( + t("toast.save.error.title", { errorMessage, ns: "common" }), + { position: "top-center" }, + ); + }) + .finally(() => { + setIsLoading(false); + }); + return; + } + + // Motion masks and object masks use JSON body format + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let configUpdate: any = {}; + + if (polygon.type === "motion_mask") { + // Delete mask from motion.mask dict by setting it to undefined + configUpdate = { + cameras: { + [polygon.camera]: { + motion: { + mask: { + [polygon.name]: null, // Setting to null will delete the key + }, + }, + }, + }, + }; + } + + if (polygon.type === "object_mask") { + // Determine if this is a global mask or object-specific mask + const isGlobalMask = !polygon.objects.length; + + if (isGlobalMask) { + configUpdate = { + cameras: { + [polygon.camera]: { + objects: { + mask: { + [polygon.name]: null, // Setting to null will delete the key + }, + }, + }, + }, + }; + } else { + configUpdate = { + cameras: { + [polygon.camera]: { + objects: { + filters: { + [polygon.objects[0]]: { + mask: { + [polygon.name]: null, // Setting to null will delete the key + }, + }, + }, + }, + }, + }, + }; + } + } await axios - .put(`config/set?${url}`, { + .put("config/set", { + config_data: configUpdate, requires_restart: 0, update_topic: `config/cameras/${polygon.camera}/${updateTopicType}`, }) @@ -191,9 +243,7 @@ export default function PolygonItem({ t("masksAndZones.form.polygonDrawing.delete.success", { name: polygon?.friendly_name ?? polygon?.name, }), - { - position: "top-center", - }, + { position: "top-center" }, ); updateConfig(); } else { @@ -202,9 +252,7 @@ export default function PolygonItem({ ns: "common", errorMessage: res.statusText, }), - { - position: "top-center", - }, + { position: "top-center" }, ); } }) @@ -215,16 +263,22 @@ export default function PolygonItem({ "Unknown error"; toast.error( t("toast.save.error.title", { errorMessage, ns: "common" }), - { - position: "top-center", - }, + { position: "top-center" }, ); }) .finally(() => { setIsLoading(false); + setLoadingPolygonIndex(undefined); }); }, - [updateConfig, cameraConfig, t], + [ + updateConfig, + cameraConfig, + t, + setIsLoading, + index, + setLoadingPolygonIndex, + ], ); const handleDelete = () => { @@ -232,6 +286,43 @@ export default function PolygonItem({ saveToConfig(polygon); }; + const handleToggleEnabled = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + // Prevent toggling if disabled in config + if (polygon.enabled_in_config === false) { + return; + } + if (!polygon) { + return; + } + + const isEnabled = isPolygonEnabled; + const nextState = isEnabled ? "OFF" : "ON"; + + if (polygon.type === "zone") { + sendZoneState(nextState); + return; + } + + if (polygon.type === "motion_mask") { + sendMotionMaskState(nextState); + return; + } + + if (polygon.type === "object_mask") { + sendObjectMaskState(nextState); + } + }, + [ + isPolygonEnabled, + polygon, + sendZoneState, + sendMotionMaskState, + sendObjectMaskState, + ], + ); + return ( <> @@ -256,17 +347,52 @@ export default function PolygonItem({ : "text-primary-variant" }`} > - {PolygonItemIcon && ( - - )} -

+ {PolygonItemIcon && + (isLoading && loadingPolygonIndex === index ? ( +

+ +
+ ) : ( + + + + + + {polygon.enabled_in_config === false + ? t("masksAndZones.disabledInConfig", { + ns: "views/settings", + }) + : isPolygonEnabled + ? t("button.disable", { ns: "common" }) + : t("button.enable", { ns: "common" })} + + + ))} +

{polygon.friendly_name ?? polygon.name} + {!isPolygonEnabled && " (disabled)"}

{ setActivePolygonIndex(index); setEditPane(polygon.type); @@ -325,6 +452,7 @@ export default function PolygonItem({ handleCopyCoordinates(index)} > {t("button.copy", { ns: "common" })} @@ -346,10 +474,17 @@ export default function PolygonItem({ { - setActivePolygonIndex(index); - setEditPane(polygon.type); + if (!isLoading) { + setActivePolygonIndex(index); + setEditPane(polygon.type); + } }} /> @@ -362,10 +497,16 @@ export default function PolygonItem({ handleCopyCoordinates(index)} + className={cn( + "size-[15px] cursor-pointer", + hoveredPolygonIndex === index && "text-primary-variant", + isLoading && "cursor-not-allowed opacity-50", + )} + onClick={() => { + if (!isLoading) { + handleCopyCoordinates(index); + } + }} /> @@ -377,10 +518,13 @@ export default function PolygonItem({ !isLoading && setDeleteDialogOpen(true)} /> diff --git a/web/src/components/settings/ZoneEditPane.tsx b/web/src/components/settings/ZoneEditPane.tsx index ebcb5d2ed..e52853972 100644 --- a/web/src/components/settings/ZoneEditPane.tsx +++ b/web/src/components/settings/ZoneEditPane.tsx @@ -35,6 +35,7 @@ import { LuExternalLink } from "react-icons/lu"; import { useDocDomain } from "@/hooks/use-doc-domain"; import { getTranslatedLabel } from "@/utils/i18n"; import NameAndIdFields from "../input/NameAndIdFields"; +import { useZoneState } from "@/api/ws"; type ZoneEditPaneProps = { polygons?: Polygon[]; @@ -88,6 +89,11 @@ export default function ZoneEditPane({ } }, [polygons, activePolygonIndex]); + const { send: sendZoneState } = useZoneState( + polygon?.camera || "", + polygon?.name || "", + ); + const cameraConfig = useMemo(() => { if (polygon?.camera && config) { return config.cameras[polygon.camera]; @@ -178,6 +184,7 @@ export default function ZoneEditPane({ message: t("masksAndZones.form.zoneName.error.alreadyExists"), }, ), + enabled: z.boolean().default(true), inertia: z.coerce .number() .min(1, { @@ -271,6 +278,13 @@ export default function ZoneEditPane({ defaultValues: { name: polygon?.name ?? "", friendly_name: polygon?.friendly_name ?? polygon?.name ?? "", + enabled: + polygon?.camera && + polygon?.name && + config?.cameras[polygon.camera]?.zones[polygon.name]?.enabled !== + undefined + ? config?.cameras[polygon.camera]?.zones[polygon.name]?.enabled + : (polygon?.enabled ?? true), inertia: polygon?.camera && polygon?.name && @@ -311,6 +325,7 @@ export default function ZoneEditPane({ { name: zoneName, friendly_name, + enabled, inertia, loitering_time, objects: form_objects, @@ -445,9 +460,11 @@ export default function ZoneEditPane({ friendlyNameQuery = `&cameras.${polygon?.camera}.zones.${zoneName}.friendly_name=${encodeURIComponent(friendly_name)}`; } + const enabledQuery = `&cameras.${polygon?.camera}.zones.${zoneName}.enabled=${enabled ? "True" : "False"}`; + axios .put( - `config/set?cameras.${polygon?.camera}.zones.${zoneName}.coordinates=${coordinates}${inertiaQuery}${loiteringTimeQuery}${speedThresholdQuery}${distancesQuery}${objectQueries}${friendlyNameQuery}${alertQueries}${detectionQueries}`, + `config/set?cameras.${polygon?.camera}.zones.${zoneName}.coordinates=${coordinates}${enabledQuery}${inertiaQuery}${loiteringTimeQuery}${speedThresholdQuery}${distancesQuery}${objectQueries}${friendlyNameQuery}${alertQueries}${detectionQueries}`, { requires_restart: 0, update_topic: `config/cameras/${polygon.camera}/zones`, @@ -464,6 +481,8 @@ export default function ZoneEditPane({ }, ); updateConfig(); + // Publish the enabled state through websocket + sendZoneState(enabled ? "ON" : "OFF"); } else { toast.error( t("toast.save.error.title", { @@ -504,6 +523,7 @@ export default function ZoneEditPane({ setIsLoading, cameraConfig, t, + sendZoneState, ], ); @@ -581,6 +601,28 @@ export default function ZoneEditPane({ nameDescription={t("masksAndZones.zones.name.tips")} placeholderName={t("masksAndZones.zones.name.inputPlaceHolder")} /> + ( + +
+ + {t("masksAndZones.zones.enabled.title")} + + + {t("masksAndZones.zones.enabled.description")} + +
+ + + +
+ )} + /> { + const stateMap = new Map(); + + polygons.forEach((polygon) => { + const topic = + polygon.type === "zone" + ? `${polygon.camera}/zone/${polygon.name}/state` + : polygon.type === "motion_mask" + ? `${polygon.camera}/motion_mask/${polygon.name}/state` + : `${polygon.camera}/object_mask/${polygon.name}/state`; + + const wsValue = wsState[topic]; + const enabled = + wsValue === "ON" + ? true + : wsValue === "OFF" + ? false + : (polygon.enabled ?? true); + stateMap.set( + `${polygon.camera}/${polygon.type}/${polygon.name}`, + enabled, + ); + }); + + return (polygon: Polygon) => { + return ( + stateMap.get(`${polygon.camera}/${polygon.type}/${polygon.name}`) ?? + true + ); + }; + }, [polygons, wsState]); +} diff --git a/web/src/types/canvas.ts b/web/src/types/canvas.ts index a70238834..c0c67e0f4 100644 --- a/web/src/types/canvas.ts +++ b/web/src/types/canvas.ts @@ -12,11 +12,14 @@ export type Polygon = { isFinished: boolean; color: number[]; friendly_name?: string; + enabled?: boolean; + enabled_in_config?: boolean; }; export type ZoneFormValuesType = { name: string; friendly_name: string; + enabled: boolean; inertia: number; loitering_time: number; isFinished: boolean; @@ -29,10 +32,17 @@ export type ZoneFormValuesType = { speed_threshold: number; }; -export type ObjectMaskFormValuesType = { - objects: string; - polygon: { - isFinished: boolean; - name: string; - }; +export type MotionMaskFormValuesType = { + name: string; + friendly_name: string; + enabled: boolean; + isFinished: boolean; +}; + +export type ObjectMaskFormValuesType = { + name: string; + friendly_name: string; + enabled: boolean; + objects: string; + isFinished: boolean; }; diff --git a/web/src/types/frigateConfig.ts b/web/src/types/frigateConfig.ts index d4ed0b9b7..dc3554940 100644 --- a/web/src/types/frigateConfig.ts +++ b/web/src/types/frigateConfig.ts @@ -106,7 +106,14 @@ export interface CameraConfig { frame_height: number; improve_contrast: boolean; lightning_threshold: number; - mask: string[]; + mask: { + [maskId: string]: { + friendly_name?: string; + enabled: boolean; + enabled_in_config?: boolean; + coordinates: string; + }; + }; mqtt_off_delay: number; threshold: number; }; @@ -128,7 +135,14 @@ export interface CameraConfig { objects: { filters: { [objectName: string]: { - mask: string[] | null; + mask: { + [maskId: string]: { + friendly_name?: string; + enabled: boolean; + enabled_in_config?: boolean; + coordinates: string; + }; + }; max_area: number; max_ratio: number; min_area: number; @@ -137,7 +151,14 @@ export interface CameraConfig { threshold: number; }; }; - mask: string; + mask: { + [maskId: string]: { + friendly_name?: string; + enabled: boolean; + enabled_in_config?: boolean; + coordinates: string; + }; + }; track: string[]; genai: { enabled: boolean; @@ -272,6 +293,8 @@ export interface CameraConfig { [zoneName: string]: { coordinates: string; distances: string[]; + enabled: boolean; + enabled_in_config?: boolean; filters: Record; inertia: number; loitering_time: number; diff --git a/web/src/views/settings/MasksAndZonesView.tsx b/web/src/views/settings/MasksAndZonesView.tsx index 833d3887a..b38a7d430 100644 --- a/web/src/views/settings/MasksAndZonesView.tsx +++ b/web/src/views/settings/MasksAndZonesView.tsx @@ -34,7 +34,6 @@ import { useSearchEffect } from "@/hooks/use-overlay-state"; import { useTranslation } from "react-i18next"; import { useDocDomain } from "@/hooks/use-doc-domain"; -import { getTranslatedLabel } from "@/utils/i18n"; import { cn } from "@/lib/utils"; type MasksAndZoneViewProps = { @@ -54,6 +53,9 @@ export default function MasksAndZonesView({ const [allPolygons, setAllPolygons] = useState([]); const [editingPolygons, setEditingPolygons] = useState([]); const [isLoading, setIsLoading] = useState(false); + const [loadingPolygonIndex, setLoadingPolygonIndex] = useState< + number | undefined + >(undefined); const [activePolygonIndex, setActivePolygonIndex] = useState< number | undefined >(undefined); @@ -170,6 +172,7 @@ export default function MasksAndZonesView({ objects: [], camera: selectedCamera, color: polygonColor, + enabled: true, }, ]); }; @@ -231,6 +234,8 @@ export default function MasksAndZonesView({ camera: cameraConfig.name, name, friendly_name: zoneData.friendly_name, + enabled: zoneData.enabled, + enabled_in_config: zoneData.enabled_in_config, objects: zoneData.objects, points: interpolatePoints( parseCoordinates(zoneData.coordinates), @@ -250,102 +255,93 @@ export default function MasksAndZonesView({ let globalObjectMasks: Polygon[] = []; let objectMasks: Polygon[] = []; - // this can be an array or a string - motionMasks = ( - Array.isArray(cameraConfig.motion.mask) - ? cameraConfig.motion.mask - : cameraConfig.motion.mask - ? [cameraConfig.motion.mask] - : [] - ).map((maskData, index) => ({ - type: "motion_mask" as PolygonType, - typeIndex: index, - camera: cameraConfig.name, - name: t("masksAndZones.motionMaskLabel", { - number: index + 1, + // Motion masks are a dict with mask_id as key + motionMasks = Object.entries(cameraConfig.motion.mask || {}).map( + ([maskId, maskData], index) => ({ + type: "motion_mask" as PolygonType, + typeIndex: index, + camera: cameraConfig.name, + name: maskId, + friendly_name: maskData.friendly_name, + enabled: maskData.enabled, + enabled_in_config: maskData.enabled_in_config, + objects: [], + points: interpolatePoints( + parseCoordinates(maskData.coordinates), + 1, + 1, + scaledWidth, + scaledHeight, + ), + distances: [], + isFinished: true, + color: [0, 0, 255], }), - objects: [], - points: interpolatePoints( - parseCoordinates(maskData), - 1, - 1, - scaledWidth, - scaledHeight, - ), - distances: [], - isFinished: true, - color: [0, 0, 255], - })); + ); - const globalObjectMasksArray = Array.isArray(cameraConfig.objects.mask) - ? cameraConfig.objects.mask - : cameraConfig.objects.mask - ? [cameraConfig.objects.mask] - : []; - - globalObjectMasks = globalObjectMasksArray.map((maskData, index) => ({ - type: "object_mask" as PolygonType, - typeIndex: index, - camera: cameraConfig.name, - name: t("masksAndZones.objectMaskLabel", { - number: index + 1, - label: t("masksAndZones.zones.allObjects"), + // Global object masks are a dict with mask_id as key + globalObjectMasks = Object.entries(cameraConfig.objects.mask || {}).map( + ([maskId, maskData], index) => ({ + type: "object_mask" as PolygonType, + typeIndex: index, + camera: cameraConfig.name, + name: maskId, + friendly_name: maskData.friendly_name, + enabled: maskData.enabled, + enabled_in_config: maskData.enabled_in_config, + objects: [], + points: interpolatePoints( + parseCoordinates(maskData.coordinates), + 1, + 1, + scaledWidth, + scaledHeight, + ), + distances: [], + isFinished: true, + color: [128, 128, 128], }), - objects: [], - points: interpolatePoints( - parseCoordinates(maskData), - 1, - 1, - scaledWidth, - scaledHeight, - ), - distances: [], - isFinished: true, - color: [128, 128, 128], - })); + ); - const globalObjectMasksCount = globalObjectMasks.length; - let index = 0; + let objectMaskIndex = globalObjectMasks.length; objectMasks = Object.entries(cameraConfig.objects.filters) - .filter(([, { mask }]) => mask || Array.isArray(mask)) - .flatMap(([objectName, { mask }]): Polygon[] => { - const maskArray = Array.isArray(mask) ? mask : mask ? [mask] : []; - return maskArray.flatMap((maskItem, subIndex) => { - const maskItemString = maskItem; - const newMask = { - type: "object_mask" as PolygonType, - typeIndex: subIndex, - camera: cameraConfig.name, - name: t("masksAndZones.objectMaskLabel", { - number: globalObjectMasksCount + index + 1, - label: getTranslatedLabel(objectName), - }), - objects: [objectName], - points: interpolatePoints( - parseCoordinates(maskItem), - 1, - 1, - scaledWidth, - scaledHeight, - ), - distances: [], - isFinished: true, - color: [128, 128, 128], - }; - index++; + .filter( + ([, filterConfig]) => + filterConfig.mask && Object.keys(filterConfig.mask).length > 0, + ) + .flatMap(([objectName, filterConfig]): Polygon[] => { + return Object.entries(filterConfig.mask || {}).flatMap( + ([maskId, maskData]) => { + // Skip if this mask is a global mask (prefixed with "global_") + if (maskId.startsWith("global_")) { + return []; + } - if ( - globalObjectMasksArray.some( - (globalMask) => globalMask === maskItemString, - ) - ) { - index--; - return []; - } else { + const newMask = { + type: "object_mask" as PolygonType, + typeIndex: objectMaskIndex, + camera: cameraConfig.name, + name: maskId, + friendly_name: maskData.friendly_name, + enabled: maskData.enabled, + enabled_in_config: maskData.enabled_in_config, + objects: [objectName], + points: interpolatePoints( + parseCoordinates(maskData.coordinates), + 1, + 1, + scaledWidth, + scaledHeight, + ), + distances: [], + isFinished: true, + color: [128, 128, 128], + }; + objectMaskIndex++; return [newMask]; - } - }); + }, + ); }); setAllPolygons([ @@ -548,6 +544,10 @@ export default function MasksAndZonesView({ setActivePolygonIndex={setActivePolygonIndex} setEditPane={setEditPane} handleCopyCoordinates={handleCopyCoordinates} + isLoading={isLoading} + setIsLoading={setIsLoading} + loadingPolygonIndex={loadingPolygonIndex} + setLoadingPolygonIndex={setLoadingPolygonIndex} /> ))} @@ -618,6 +618,10 @@ export default function MasksAndZonesView({ setActivePolygonIndex={setActivePolygonIndex} setEditPane={setEditPane} handleCopyCoordinates={handleCopyCoordinates} + isLoading={isLoading} + setIsLoading={setIsLoading} + loadingPolygonIndex={loadingPolygonIndex} + setLoadingPolygonIndex={setLoadingPolygonIndex} /> ))} @@ -688,6 +692,10 @@ export default function MasksAndZonesView({ setActivePolygonIndex={setActivePolygonIndex} setEditPane={setEditPane} handleCopyCoordinates={handleCopyCoordinates} + isLoading={isLoading} + setIsLoading={setIsLoading} + loadingPolygonIndex={loadingPolygonIndex} + setLoadingPolygonIndex={setLoadingPolygonIndex} /> ))}