Masks and zones improvements (#22163)

* migrator and runtime config changes

* component changes to use rasterized_mask

* frontend

* convert none to empty string for config save

* i18n

* update tests

* add enabled config to zones

* zones frontend

* i18n

* docs

* tweaks

* use dashed stroke to indicate disabled

* allow toggle from icon

* use filelock to ensure atomic config updates from endpoint

* enforce atomic config update in the frontend

* toggle via mqtt

* fix global object masks

* correctly handle global object masks in dispatcher

* ws hooks

* render masks and zones based on ws enabled state

* use enabled_in_config for zones and masks

* frontend for enabled_in_config

* tweaks

* i18n

* publish websocket on config save

* i18n tweaks

* pydantic title and description

* i18n generation

* tweaks

* fix typing
This commit is contained in:
Josh Hawkins 2026-02-28 08:04:43 -06:00 committed by GitHub
parent fa1f9a1fa4
commit 6a21b2952d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
37 changed files with 1966 additions and 741 deletions

View File

@ -138,7 +138,10 @@ cameras:
- detect - detect
motion: motion:
mask: 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 ### Standalone Intel Mini PC with USB Coral
@ -195,7 +198,10 @@ cameras:
- detect - detect
motion: motion:
mask: 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 ### Home Assistant integrated Intel Mini PC with OpenVino
@ -262,5 +268,8 @@ cameras:
- detect - detect
motion: motion:
mask: 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"
``` ```

View File

@ -33,18 +33,55 @@ Your config file will be updated with the relative coordinates of the mask/zone:
```yaml ```yaml
motion: 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 ```yaml
motion: motion:
mask: mask:
- 0.239,1.246,0.175,0.901,0.165,0.805,0.195,0.802 mask1:
- 0.000,0.427,0.002,0.000,0.999,0.000,0.999,0.781,0.885,0.456 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 ### 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): 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):

View File

@ -345,7 +345,15 @@ objects:
# Optional: mask to prevent all object types from being detected in certain areas (default: no mask) # 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. # 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 # 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 # Optional: filters to reduce false positives for specific object types
filters: filters:
person: person:
@ -365,7 +373,15 @@ objects:
threshold: 0.7 threshold: 0.7
# Optional: mask to prevent this object type from being detected in certain areas (default: no mask) # 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 # 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 # Optional: Configuration for AI generated tracked object descriptions
genai: genai:
# Optional: Enable AI object description generation (default: shown below) # Optional: Enable AI object description generation (default: shown below)
@ -489,7 +505,15 @@ motion:
frame_height: 100 frame_height: 100
# Optional: motion mask # Optional: motion mask
# NOTE: see docs for more detailed info on creating masks # 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) # 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 # Enables dynamic contrast improvement. This should help improve night detections at the cost of making motion detection more sensitive
# for daytime. # for daytime.
@ -866,6 +890,9 @@ cameras:
front_steps: front_steps:
# Optional: A friendly name or descriptive text for the zones # Optional: A friendly name or descriptive text for the zones
friendly_name: "" 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. # 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. # 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 coordinates: 0.033,0.306,0.324,0.138,0.439,0.185,0.042,0.428

View File

@ -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. 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. 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. 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. 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 ### 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. 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 :::note
When using loitering zones, a review item will behave in the following way: 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 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. - 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.

View File

@ -240,7 +240,10 @@ cameras:
- detect - detect
motion: motion:
mask: 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 ### Step 6: Enable recordings

View File

@ -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. Topic with current motion contour area for a camera. Published value is an integer.
### `frigate/<camera_name>/motion_mask/<mask_name>/set`
Topic to turn a specific motion mask for a camera on and off. Expected values are `ON` and `OFF`.
### `frigate/<camera_name>/motion_mask/<mask_name>/state`
Topic with current state of a specific motion mask for a camera. Published values are `ON` and `OFF`.
### `frigate/<camera_name>/object_mask/<mask_name>/set`
Topic to turn a specific object mask for a camera on and off. Expected values are `ON` and `OFF`.
### `frigate/<camera_name>/object_mask/<mask_name>/state`
Topic with current state of a specific object mask for a camera. Published values are `ON` and `OFF`.
### `frigate/<camera_name>/zone/<zone_name>/set`
Topic to turn a specific zone for a camera on and off. Expected values are `ON` and `OFF`.
### `frigate/<camera_name>/zone/<zone_name>/state`
Topic with current state of a specific zone for a camera. Published values are `ON` and `OFF`.
### `frigate/<camera_name>/review_status` ### `frigate/<camera_name>/review_status`
Topic with current activity status of the camera. Possible values are `NONE`, `DETECTION`, or `ALERT`. Topic with current activity status of the camera. Possible values are `NONE`, `DETECTION`, or `ALERT`.

View File

@ -19,6 +19,7 @@ from fastapi import APIRouter, Body, Path, Request, Response
from fastapi.encoders import jsonable_encoder from fastapi.encoders import jsonable_encoder
from fastapi.params import Depends from fastapi.params import Depends
from fastapi.responses import JSONResponse, PlainTextResponse, StreamingResponse from fastapi.responses import JSONResponse, PlainTextResponse, StreamingResponse
from filelock import FileLock, Timeout
from markupsafe import escape from markupsafe import escape
from peewee import SQL, fn, operator from peewee import SQL, fn, operator
from pydantic import ValidationError 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"]))]) @router.put("/config/set", dependencies=[Depends(require_role(["admin"]))])
def config_set(request: Request, body: AppConfigSetBody): def config_set(request: Request, body: AppConfigSetBody):
config_file = find_config_file() config_file = find_config_file()
lock = FileLock(f"{config_file}.lock", timeout=5)
with open(config_file, "r") as f:
old_raw_config = f.read()
try: 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) try:
parsed_url = urllib.parse.urlparse(str(request.url)) updates = {}
query_string = urllib.parse.parse_qs(parsed_url.query, keep_blank_values=True)
# Filter out empty keys but keep blank values for non-empty keys # process query string parameters (takes precedence over body.config_data)
query_string = {k: v for k, v in query_string.items() if k} parsed_url = urllib.parse.urlparse(str(request.url))
query_string = urllib.parse.parse_qs(
parsed_url.query, keep_blank_values=True
)
if query_string: # Filter out empty keys but keep blank values for non-empty keys
updates = process_config_query_string(query_string) query_string = {k: v for k, v in query_string.items() if k}
elif body.config_data:
updates = flatten_config_data(body.config_data)
if not updates: if query_string:
return JSONResponse( updates = process_config_query_string(query_string)
content=( elif body.config_data:
{"success": False, "message": "No configuration data provided"} updates = flatten_config_data(body.config_data)
), # Convert None values to empty strings for deletion (e.g., when deleting masks)
status_code=400, updates = {k: ("" if v is None else v) for k, v in updates.items()}
)
# apply all updates in a single operation if not updates:
update_yaml_file_bulk(config_file, updates) return JSONResponse(
content=(
{
"success": False,
"message": "No configuration data provided",
}
),
status_code=400,
)
# validate the updated config # apply all updates in a single operation
with open(config_file, "r") as f: update_yaml_file_bulk(config_file, updates)
new_raw_config = f.read()
# 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( return JSONResponse(
content=( content=(
{ {
"success": False, "success": True,
"message": "Error parsing config. Check logs for error message.", "message": "Config successfully updated, restart to apply",
} }
), ),
status_code=400, status_code=200,
) )
except Exception as e: except Timeout:
logging.error(f"Error updating config: {e}")
return JSONResponse( return JSONResponse(
content=({"success": False, "message": "Error updating config"}), content=(
status_code=500, {
"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())]) @router.get("/vainfo", dependencies=[Depends(allow_any_authenticated())])
def vainfo(): def vainfo():

View File

@ -65,7 +65,7 @@ class CameraState:
frame_copy = cv2.cvtColor(frame_copy, cv2.COLOR_YUV2BGR_I420) frame_copy = cv2.cvtColor(frame_copy, cv2.COLOR_YUV2BGR_I420)
# draw on the frame # draw on the frame
if draw_options.get("mask"): 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] frame_copy[mask_overlay] = [0, 0, 0]
if draw_options.get("bounding_boxes"): if draw_options.get("bounding_boxes"):
@ -197,6 +197,10 @@ class CameraState:
if draw_options.get("zones"): if draw_options.get("zones"):
for name, zone in self.camera_config.zones.items(): for name, zone in self.camera_config.zones.items():
# skip disabled zones
if not zone.enabled:
continue
thickness = ( thickness = (
8 8
if any( if any(

View File

@ -15,6 +15,7 @@ from frigate.config.camera.updater import (
CameraConfigUpdatePublisher, CameraConfigUpdatePublisher,
CameraConfigUpdateTopic, CameraConfigUpdateTopic,
) )
from frigate.config.config import RuntimeFilterConfig, RuntimeMotionConfig
from frigate.const import ( from frigate.const import (
CLEAR_ONGOING_REVIEW_SEGMENTS, CLEAR_ONGOING_REVIEW_SEGMENTS,
EXPIRE_AUDIO_ACTIVITY, EXPIRE_AUDIO_ACTIVITY,
@ -84,6 +85,9 @@ class Dispatcher:
"review_detections": self._on_detections_command, "review_detections": self._on_detections_command,
"object_descriptions": self._on_object_description_command, "object_descriptions": self._on_object_description_command,
"review_descriptions": self._on_review_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] = { self._global_settings_handlers: dict[str, Callable] = {
"notifications": self._on_global_notification_command, "notifications": self._on_global_notification_command,
@ -100,11 +104,20 @@ class Dispatcher:
"""Handle receiving of payload from communicators.""" """Handle receiving of payload from communicators."""
def handle_camera_command( 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: ) -> None:
try: try:
if command_type == "set": 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": elif command_type == "ptz":
self._on_ptz_command(camera_name, payload) self._on_ptz_command(camera_name, payload)
except KeyError: except KeyError:
@ -314,6 +327,14 @@ class Dispatcher:
camera_name = parts[-3] camera_name = parts[-3]
command = parts[-2] command = parts[-2]
handle_camera_command("set", camera_name, command, payload) 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"): elif len(parts) == 2 and topic.endswith("set"):
command = parts[-2] command = parts[-2]
self._global_settings_handlers[command](payload) self._global_settings_handlers[command](payload)
@ -858,3 +879,149 @@ class Dispatcher:
genai_settings, genai_settings,
) )
self.publish(f"{camera_name}/review_descriptions/state", payload, retain=True) 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)

View File

@ -133,6 +133,29 @@ class MqttClient(Communicator):
retain=True, 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: if self.config.notifications.enabled_in_config:
self.publish( self.publish(
"notifications/state", "notifications/state",
@ -242,6 +265,24 @@ class MqttClient(Communicator):
self.on_mqtt_command, 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: if self.config.notifications.enabled_in_config:
self.client.message_callback_add( self.client.message_callback_add(
f"{self.mqtt_config.topic_prefix}/notifications/set", f"{self.mqtt_config.topic_prefix}/notifications/set",

View File

@ -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()

View File

@ -1,8 +1,9 @@
from typing import Any, Optional, Union from typing import Any, Optional
from pydantic import Field, field_serializer from pydantic import Field, field_serializer
from ..base import FrigateBaseModel from ..base import FrigateBaseModel
from .mask import MotionMaskConfig
__all__ = ["MotionConfig"] __all__ = ["MotionConfig"]
@ -52,8 +53,8 @@ class MotionConfig(FrigateBaseModel):
title="Frame height", title="Frame height",
description="Height in pixels to scale frames to when computing motion.", description="Height in pixels to scale frames to when computing motion.",
) )
mask: Union[str, list[str]] = Field( mask: dict[str, Optional[MotionMaskConfig]] = Field(
default="", default_factory=dict,
title="Mask coordinates", title="Mask coordinates",
description="Ordered x,y coordinates defining the motion mask polygon used to include/exclude areas.", 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", title="Original motion state",
description="Indicates whether motion detection was enabled in the original static configuration.", 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") @field_serializer("mask", when_used="json")
def serialize_mask(self, value: Any, info): 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") @field_serializer("raw_mask", when_used="json")
def serialize_raw_mask(self, value: Any, info): def serialize_raw_mask(self, value: Any, info):

View File

@ -3,6 +3,7 @@ from typing import Any, Optional, Union
from pydantic import Field, PrivateAttr, field_serializer, field_validator from pydantic import Field, PrivateAttr, field_serializer, field_validator
from ..base import FrigateBaseModel from ..base import FrigateBaseModel
from .mask import ObjectMaskConfig
__all__ = ["ObjectConfig", "GenAIObjectConfig", "FilterConfig"] __all__ = ["ObjectConfig", "GenAIObjectConfig", "FilterConfig"]
@ -41,16 +42,20 @@ class FilterConfig(FrigateBaseModel):
title="Minimum confidence", title="Minimum confidence",
description="Minimum single-frame detection confidence required for the object to be counted.", description="Minimum single-frame detection confidence required for the object to be counted.",
) )
mask: Optional[Union[str, list[str]]] = Field( mask: dict[str, Optional[ObjectMaskConfig]] = Field(
default=None, default_factory=dict,
title="Filter mask", title="Filter mask",
description="Polygon coordinates defining where this filter applies within the frame.", 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") @field_serializer("mask", when_used="json")
def serialize_mask(self, value: Any, info): 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") @field_serializer("raw_mask", when_used="json")
def serialize_raw_mask(self, value: Any, info): def serialize_raw_mask(self, value: Any, info):
@ -139,11 +144,14 @@ class ObjectConfig(FrigateBaseModel):
title="Object filters", title="Object filters",
description="Filters applied to detected objects to reduce false positives (area, ratio, confidence).", description="Filters applied to detected objects to reduce false positives (area, ratio, confidence).",
) )
mask: Union[str, list[str]] = Field( mask: dict[str, Optional[ObjectMaskConfig]] = Field(
default="", default_factory=dict,
title="Object mask", title="Object mask",
description="Mask polygon used to prevent object detection in specified areas.", 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( genai: GenAIObjectConfig = Field(
default_factory=GenAIObjectConfig, default_factory=GenAIObjectConfig,
title="GenAI object config", title="GenAI object config",
@ -166,3 +174,13 @@ class ObjectConfig(FrigateBaseModel):
enabled_labels.update(camera.objects.track) enabled_labels.update(camera.objects.track)
self._all_objects = list(enabled_labels) 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

View File

@ -18,6 +18,14 @@ class ZoneConfig(BaseModel):
title="Zone name", 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.", 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( filters: dict[str, FilterConfig] = Field(
default_factory=dict, default_factory=dict,
title="Zone filters", title="Zone filters",

View File

@ -3,7 +3,7 @@ from __future__ import annotations
import json import json
import logging import logging
import os import os
from typing import Any, Dict, List, Optional, Union from typing import Any, Dict, Optional
import numpy as np import numpy as np
from pydantic import ( from pydantic import (
@ -46,6 +46,7 @@ from .camera.birdseye import BirdseyeConfig
from .camera.detect import DetectConfig from .camera.detect import DetectConfig
from .camera.ffmpeg import FfmpegConfig from .camera.ffmpeg import FfmpegConfig
from .camera.genai import GenAIConfig, GenAIRoleEnum from .camera.genai import GenAIConfig, GenAIRoleEnum
from .camera.mask import ObjectMaskConfig
from .camera.motion import MotionConfig from .camera.motion import MotionConfig
from .camera.notification import NotificationConfig from .camera.notification import NotificationConfig
from .camera.objects import FilterConfig, ObjectConfig from .camera.objects import FilterConfig, ObjectConfig
@ -93,54 +94,111 @@ stream_info_retriever = StreamInfoRetriever()
class RuntimeMotionConfig(MotionConfig): class RuntimeMotionConfig(MotionConfig):
raw_mask: Union[str, List[str]] = "" """Runtime version of MotionConfig with rasterized masks."""
mask: np.ndarray = None
# The rasterized numpy mask (combination of all enabled masks)
rasterized_mask: np.ndarray = None
def __init__(self, **config): def __init__(self, **config):
frame_shape = config.get("frame_shape", (1, 1)) frame_shape = config.get("frame_shape", (1, 1))
mask = get_relative_coordinates(config.get("mask", ""), frame_shape) # Store original mask dict for serialization
config["raw_mask"] = mask original_mask = config.get("mask", {})
if isinstance(original_mask, dict):
if mask: # Process the new dict format - update raw_coordinates for each mask
config["mask"] = create_mask(frame_shape, mask) processed_mask = {}
else: for mask_id, mask_config in original_mask.items():
empty_mask = np.zeros(frame_shape, np.uint8) if isinstance(mask_config, dict):
empty_mask[:] = 255 coords = mask_config.get("coordinates", "")
config["mask"] = empty_mask 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) 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): def dict(self, **kwargs):
ret = super().model_dump(**kwargs) ret = super().model_dump(**kwargs)
if "mask" in ret: if "rasterized_mask" in ret:
ret["mask"] = ret["raw_mask"] ret.pop("rasterized_mask")
ret.pop("raw_mask")
return ret return ret
@field_serializer("mask", when_used="json") @field_serializer("rasterized_mask", when_used="json")
def serialize_mask(self, value: Any, info): def serialize_rasterized_mask(self, value: Any, info):
return self.raw_mask
@field_serializer("raw_mask", when_used="json")
def serialize_raw_mask(self, value: Any, info):
return None return None
model_config = ConfigDict(arbitrary_types_allowed=True, extra="ignore") model_config = ConfigDict(arbitrary_types_allowed=True, extra="ignore")
class RuntimeFilterConfig(FilterConfig): class RuntimeFilterConfig(FilterConfig):
mask: Optional[np.ndarray] = None """Runtime version of FilterConfig with rasterized masks."""
raw_mask: Optional[Union[str, List[str]]] = None
# The rasterized numpy mask (combination of all enabled masks)
rasterized_mask: Optional[np.ndarray] = None
def __init__(self, **config): def __init__(self, **config):
frame_shape = config.get("frame_shape", (1, 1)) frame_shape = config.get("frame_shape", (1, 1))
mask = get_relative_coordinates(config.get("mask"), frame_shape)
config["raw_mask"] = mask # Store original mask dict for serialization
original_mask = config.get("mask", {})
if mask is not None: if isinstance(original_mask, dict):
config["mask"] = create_mask(frame_shape, mask) # 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 # Convert min_area and max_area to pixels if they're percentages
if "min_area" in config: if "min_area" in config:
@ -151,13 +209,31 @@ class RuntimeFilterConfig(FilterConfig):
super().__init__(**config) 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): def dict(self, **kwargs):
ret = super().model_dump(**kwargs) ret = super().model_dump(**kwargs)
if "mask" in ret: if "rasterized_mask" in ret:
ret["mask"] = ret["raw_mask"] ret.pop("rasterized_mask")
ret.pop("raw_mask")
return ret 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") model_config = ConfigDict(arbitrary_types_allowed=True, extra="ignore")
@ -713,35 +789,63 @@ class FrigateConfig(FrigateBaseModel):
for key in object_keys: for key in object_keys:
camera_config.objects.filters[key] = FilterConfig() 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 # Apply global object masks and convert masks to numpy array
for object, filter in camera_config.objects.filters.items(): 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: if camera_config.objects.mask:
filter_mask = [] for mask_id, mask_config in camera_config.objects.mask.items():
if filter.mask is not None: # Use a global prefix to avoid key collisions
filter_mask = ( global_mask_id = f"global_{mask_id}"
filter.mask merged_mask[global_mask_id] = mask_config
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
# Set runtime filter to create masks # Set runtime filter to create masks
camera_config.objects.filters[object] = RuntimeFilterConfig( camera_config.objects.filters[object] = RuntimeFilterConfig(
frame_shape=camera_config.frame_shape, 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 # Convert motion configuration
if camera_config.motion is None: if camera_config.motion is None:
camera_config.motion = RuntimeMotionConfig( camera_config.motion = RuntimeMotionConfig(
@ -750,10 +854,8 @@ class FrigateConfig(FrigateBaseModel):
else: else:
camera_config.motion = RuntimeMotionConfig( camera_config.motion = RuntimeMotionConfig(
frame_shape=camera_config.frame_shape, frame_shape=camera_config.frame_shape,
raw_mask=camera_config.motion.mask,
**camera_config.motion.model_dump(exclude_unset=True), **camera_config.motion.model_dump(exclude_unset=True),
) )
camera_config.motion.enabled_in_config = camera_config.motion.enabled
# generate zone contours # generate zone contours
if len(camera_config.zones) > 0: if len(camera_config.zones) > 0:
@ -767,6 +869,10 @@ class FrigateConfig(FrigateBaseModel):
zone.generate_contour(camera_config.frame_shape) 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 # Set live view stream if none is set
if not camera_config.live.streams: if not camera_config.live.streams:
camera_config.live.streams = {name: name} camera_config.live.streams = {name: name}

View File

@ -1220,7 +1220,7 @@ class LicensePlateProcessingMixin:
rgb = cv2.cvtColor(frame, cv2.COLOR_YUV2BGR_I420) rgb = cv2.cvtColor(frame, cv2.COLOR_YUV2BGR_I420)
# apply motion mask # 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: if WRITE_DEBUG_IMAGES:
cv2.imwrite( cv2.imwrite(
@ -1324,7 +1324,7 @@ class LicensePlateProcessingMixin:
rgb = cv2.cvtColor(frame, cv2.COLOR_YUV2BGR_I420) rgb = cv2.cvtColor(frame, cv2.COLOR_YUV2BGR_I420)
# apply motion mask # 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 left, top, right, bottom = car_box
car = rgb[top:bottom, left:right] car = rgb[top:bottom, left:right]

View File

@ -28,7 +28,7 @@ class FrigateMotionDetector(MotionDetector):
self.motion_frame_count = 0 self.motion_frame_count = 0
self.frame_counter = 0 self.frame_counter = 0
resized_mask = cv2.resize( resized_mask = cv2.resize(
config.mask, config.rasterized_mask,
dsize=(self.motion_frame_size[1], self.motion_frame_size[0]), dsize=(self.motion_frame_size[1], self.motion_frame_size[0]),
interpolation=cv2.INTER_LINEAR, interpolation=cv2.INTER_LINEAR,
) )

View File

@ -233,7 +233,7 @@ class ImprovedMotionDetector(MotionDetector):
def update_mask(self) -> None: def update_mask(self) -> None:
resized_mask = cv2.resize( resized_mask = cv2.resize(
self.config.mask, self.config.rasterized_mask,
dsize=(self.motion_frame_size[1], self.motion_frame_size[0]), dsize=(self.motion_frame_size[1], self.motion_frame_size[0]),
interpolation=cv2.INTER_AREA, interpolation=cv2.INTER_AREA,
) )

View File

@ -116,7 +116,9 @@ class PtzMotionEstimator:
mask[y1:y2, x1:x2] = 0 mask[y1:y2, x1:x2] = 0
# merge camera config motion mask with detections. Norfair function needs 0,1 mask # 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 # Norfair estimator function needs color so it can convert it right back to gray
frame = cv2.cvtColor(frame, cv2.COLOR_GRAY2BGRA) frame = cv2.cvtColor(frame, cv2.COLOR_GRAY2BGRA)

View File

@ -343,8 +343,24 @@ class TestConfig(unittest.TestCase):
"fps": 5, "fps": 5,
}, },
"objects": { "objects": {
"mask": "0,0,1,1,0,1", "mask": {
"filters": {"dog": {"mask": "1,1,1,1,1,1"}}, "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) frigate_config = FrigateConfig(**config)
back_camera = frigate_config.cameras["back"] back_camera = frigate_config.cameras["back"]
assert "dog" in back_camera.objects.filters assert "dog" in back_camera.objects.filters
assert len(back_camera.objects.filters["dog"].raw_mask) == 2 # dog filter has its own mask + global mask merged
assert len(back_camera.objects.filters["person"].raw_mask) == 1 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): def test_motion_mask_relative_matches_explicit(self):
config = { config = {
@ -373,9 +391,13 @@ class TestConfig(unittest.TestCase):
"fps": 5, "fps": 5,
}, },
"motion": { "motion": {
"mask": [ "mask": {
"0,0,200,100,600,300,800,400", "explicit_mask": {
] "friendly_name": "Explicit Mask",
"enabled": True,
"coordinates": "0,0,200,100,600,300,800,400",
}
}
}, },
}, },
"relative": { "relative": {
@ -390,9 +412,13 @@ class TestConfig(unittest.TestCase):
"fps": 5, "fps": 5,
}, },
"motion": { "motion": {
"mask": [ "mask": {
"0.0,0.0,0.25,0.25,0.75,0.75,1.0,1.0", "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) frigate_config = FrigateConfig(**config)
assert np.array_equal( assert np.array_equal(
frigate_config.cameras["explicit"].motion.mask, frigate_config.cameras["explicit"].motion.rasterized_mask,
frigate_config.cameras["relative"].motion.mask, frigate_config.cameras["relative"].motion.rasterized_mask,
) )
def test_default_input_args(self): def test_default_input_args(self):

View File

@ -188,6 +188,10 @@ class TrackedObject:
# check each zone # check each zone
for name, zone in self.camera_config.zones.items(): 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 the zone is not for this object type, skip
if len(zone.objects) > 0 and obj_data["label"] not in zone.objects: if len(zone.objects) > 0 and obj_data["label"] not in zone.objects:
continue continue

View File

@ -434,6 +434,55 @@ def migrate_017_0(config: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]
return new_config 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]]: def migrate_018_0(config: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]]:
"""Handle migrating frigate config to 0.18-0""" """Handle migrating frigate config to 0.18-0"""
new_config = config.copy() 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"): if not new_config.get("record"):
del new_config["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(): for name, camera in config.get("cameras", {}).items():
camera_config: dict[str, dict[str, Any]] = camera.copy() 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"): if not camera_config.get("record"):
del camera_config["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["cameras"][name] = camera_config
new_config["version"] = "0.18-0" new_config["version"] = "0.18-0"

View File

@ -248,20 +248,20 @@ def is_object_filtered(obj, objects_to_track, object_filters):
if obj_settings.max_ratio < object_ratio: if obj_settings.max_ratio < object_ratio:
return True 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 # compute the coordinates of the object and make sure
# the location isn't outside the bounds of the image (can happen from rounding) # the location isn't outside the bounds of the image (can happen from rounding)
object_xmin = object_box[0] object_xmin = object_box[0]
object_xmax = object_box[2] object_xmax = object_box[2]
object_ymax = object_box[3] 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( x_location = min(
int((object_xmax + object_xmin) / 2.0), 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 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 True
return False return False

View File

@ -348,6 +348,9 @@
"label": "Object mask", "label": "Object mask",
"description": "Mask polygon used to prevent object detection in specified areas." "description": "Mask polygon used to prevent object detection in specified areas."
}, },
"raw_mask": {
"label": "Raw Mask"
},
"genai": { "genai": {
"label": "GenAI object config", "label": "GenAI object config",
"description": "GenAI options for describing tracked objects and sending frames for generation.", "description": "GenAI options for describing tracked objects and sending frames for generation.",
@ -860,6 +863,12 @@
"label": "Zone name", "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." "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": { "filters": {
"label": "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.", "description": "Filters to apply to objects within this zone. Used to reduce false positives or restrict which objects are considered present in the zone.",

View File

@ -1475,6 +1475,9 @@
"label": "Object mask", "label": "Object mask",
"description": "Mask polygon used to prevent object detection in specified areas." "description": "Mask polygon used to prevent object detection in specified areas."
}, },
"raw_mask": {
"label": "Raw Mask"
},
"genai": { "genai": {
"label": "GenAI object config", "label": "GenAI object config",
"description": "GenAI options for describing tracked objects and sending frames for generation.", "description": "GenAI options for describing tracked objects and sending frames for generation.",

View File

@ -505,6 +505,7 @@
"all": "All Masks and Zones" "all": "All Masks and Zones"
}, },
"restart_required": "Restart required (masks/zones changed)", "restart_required": "Restart required (masks/zones changed)",
"disabledInConfig": "Item is disabled in the config file",
"toast": { "toast": {
"success": { "success": {
"copyCoordinates": "Copied coordinates for {{polyName}} to clipboard." "copyCoordinates": "Copied coordinates for {{polyName}} to clipboard."
@ -514,7 +515,7 @@
} }
}, },
"motionMaskLabel": "Motion Mask {{number}}", "motionMaskLabel": "Motion Mask {{number}}",
"objectMaskLabel": "Object Mask {{number}} ({{label}})", "objectMaskLabel": "Object Mask {{number}}",
"form": { "form": {
"zoneName": { "zoneName": {
"error": { "error": {
@ -588,6 +589,10 @@
"inputPlaceHolder": "Enter a name…", "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." "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": { "inertia": {
"title": "Inertia", "title": "Inertia",
"desc": "Specifies how many frames that an object must be in a zone before they are considered in the zone. <em>Default: 3</em>" "desc": "Specifies how many frames that an object must be in a zone before they are considered in the zone. <em>Default: 3</em>"
@ -632,12 +637,18 @@
}, },
"add": "New Motion Mask", "add": "New Motion Mask",
"edit": "Edit Motion Mask", "edit": "Edit Motion Mask",
"defaultName": "Motion Mask {{number}}",
"context": { "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 <em>very sparingly</em>, over-masking will make it more difficult for objects to be tracked." "title": "Motion masks are used to prevent unwanted types of motion from triggering detection (example: tree branches, camera timestamps). Motion masks should be used <em>very sparingly</em>, over-masking will make it more difficult for objects to be tracked."
}, },
"point_one": "{{count}} point", "point_one": "{{count}} point",
"point_other": "{{count}} points", "point_other": "{{count}} points",
"clickDrawPolygon": "Click to draw a polygon on the image.", "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": { "polygonAreaTooLarge": {
"title": "The motion mask is covering {{polygonArea}}% of the camera frame. Large motion masks are not recommended.", "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." "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_one": "{{count}} point",
"point_other": "{{count}} points", "point_other": "{{count}} points",
"clickDrawPolygon": "Click to draw a polygon on the image.", "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": { "objects": {
"title": "Objects", "title": "Objects",
"desc": "The object type that applies to this object mask.", "desc": "The object type that applies to this object mask.",
@ -673,6 +689,12 @@
"noName": "Object Mask has been saved." "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": { "motionDetectionTuner": {

View File

@ -304,6 +304,57 @@ export function useReviewDescriptionState(camera: string): {
return { payload: payload as ToggleableSetting, send }; 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): { export function usePtzCommand(camera: string): {
payload: string; payload: string;
send: (payload: string, retain?: boolean) => void; send: (payload: string, retain?: boolean) => void;

View File

@ -1,21 +1,25 @@
import Heading from "../ui/heading"; import Heading from "../ui/heading";
import { Separator } from "../ui/separator"; import { Separator } from "../ui/separator";
import { Button } from "@/components/ui/button"; 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 { useCallback, useEffect, useMemo } from "react";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form"; import { useForm, FormProvider } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
import PolygonEditControls from "./PolygonEditControls"; import PolygonEditControls from "./PolygonEditControls";
import { FaCheckCircle } from "react-icons/fa"; import { FaCheckCircle } from "react-icons/fa";
import { Polygon } from "@/types/canvas"; import { MotionMaskFormValuesType, Polygon } from "@/types/canvas";
import useSWR from "swr"; import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
import { import { flattenPoints, interpolatePoints } from "@/utils/canvasUtil";
flattenPoints,
interpolatePoints,
parseCoordinates,
} from "@/utils/canvasUtil";
import axios from "axios"; import axios from "axios";
import { toast } from "sonner"; import { toast } from "sonner";
import { Toaster } from "../ui/sonner"; import { Toaster } from "../ui/sonner";
@ -24,6 +28,9 @@ import { Link } from "react-router-dom";
import { LuExternalLink } from "react-icons/lu"; import { LuExternalLink } from "react-icons/lu";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import { useDocDomain } from "@/hooks/use-doc-domain"; import { useDocDomain } from "@/hooks/use-doc-domain";
import NameAndIdFields from "../input/NameAndIdFields";
import { Switch } from "../ui/switch";
import { useMotionMaskState } from "@/api/ws";
type MotionMaskEditPaneProps = { type MotionMaskEditPaneProps = {
polygons?: Polygon[]; polygons?: Polygon[];
@ -65,6 +72,11 @@ export default function MotionMaskEditPane({
} }
}, [polygons, activePolygonIndex]); }, [polygons, activePolygonIndex]);
const { send: sendMotionMaskState } = useMotionMaskState(
polygon?.camera || "",
polygon?.name || "",
);
const cameraConfig = useMemo(() => { const cameraConfig = useMemo(() => {
if (polygon?.camera && config) { if (polygon?.camera && config) {
return config.cameras[polygon.camera]; return config.cameras[polygon.camera];
@ -73,12 +85,24 @@ export default function MotionMaskEditPane({
const defaultName = useMemo(() => { const defaultName = useMemo(() => {
if (!polygons) { if (!polygons) {
return; return "";
} }
const count = polygons.filter((poly) => poly.type == "motion_mask").length; 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]); }, [polygons]);
const polygonArea = useMemo(() => { const polygonArea = useMemo(() => {
@ -104,116 +128,157 @@ export default function MotionMaskEditPane({
} }
}, [polygon, scaledWidth, scaledHeight]); }, [polygon, scaledWidth, scaledHeight]);
const formSchema = z const formSchema = z.object({
.object({ name: z
polygon: z.object({ name: z.string(), isFinished: z.boolean() }), .string()
}) .min(1, {
.refine(() => polygon?.isFinished === true, { 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"), message: t("masksAndZones.form.polygonDrawing.error.mustBeFinished"),
path: ["polygon.isFinished"], }),
}); });
const form = useForm<z.infer<typeof formSchema>>({ const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
mode: "onChange", mode: "onChange",
defaultValues: { 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 () => { const saveToConfig = useCallback(
if (!scaledWidth || !scaledHeight || !polygon || !cameraConfig) { async ({
return; name: maskId,
} friendly_name,
enabled,
}: MotionMaskFormValuesType) => {
if (!scaledWidth || !scaledHeight || !polygon || !cameraConfig) {
return;
}
const coordinates = flattenPoints( const coordinates = flattenPoints(
interpolatePoints(polygon.points, scaledWidth, scaledHeight, 1, 1), interpolatePoints(polygon.points, scaledWidth, scaledHeight, 1, 1),
).join(","); ).join(",");
let index = Array.isArray(cameraConfig.motion.mask) const editingMask = polygon.name.length > 0;
? cameraConfig.motion.mask.length const renamingMask = editingMask && maskId !== polygon.name;
: cameraConfig.motion.mask
? 1
: 0;
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 renaming, we need to delete the old mask first
if (editingMask) { if (renamingMask) {
index = polygon.typeIndex; try {
} await axios.put(
`config/set?cameras.${polygon.camera}.motion.mask.${polygon.name}`,
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"),
{ {
position: "top-center", requires_restart: 0,
}, },
); );
updateConfig(); } catch (error) {
} else { toast.error(t("toast.save.error.noMessage", { ns: "common" }), {
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", 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,
},
},
},
},
}, },
); requires_restart: 0,
}) update_topic: `config/cameras/${polygon.camera}/motion`,
.finally(() => { })
setIsLoading(false); .then((res) => {
}); if (res.status === 200) {
}, [ toast.success(
updateConfig, t("masksAndZones.motionMasks.toast.success.title", {
polygon, polygonName: friendly_name || maskId,
scaledWidth, }),
scaledHeight, {
setIsLoading, position: "top-center",
cameraConfig, },
t, );
]); 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<typeof formSchema>) { function onSubmit(values: z.infer<typeof formSchema>) {
if (activePolygonIndex === undefined || !values || !polygons) { if (activePolygonIndex === undefined || !values || !polygons) {
@ -221,7 +286,7 @@ export default function MotionMaskEditPane({
} }
setIsLoading(true); setIsLoading(true);
saveToConfig(); saveToConfig(values as MotionMaskFormValuesType);
if (onSave) { if (onSave) {
onSave(); onSave();
} }
@ -310,58 +375,83 @@ export default function MotionMaskEditPane({
</> </>
)} )}
<Form {...form}> <FormProvider {...form}>
<form <Form {...form}>
onSubmit={form.handleSubmit(onSubmit)} <form
className="flex flex-1 flex-col space-y-6" onSubmit={form.handleSubmit(onSubmit)}
> className="flex flex-1 flex-col space-y-6"
<FormField >
control={form.control} <NameAndIdFields
name="polygon.name" type="motion_mask"
render={() => ( control={form.control}
<FormItem> nameField="friendly_name"
<FormMessage /> idField="name"
</FormItem> idVisible={(polygon && polygon.name.length > 0) ?? false}
)} nameLabel={t("masksAndZones.motionMasks.name.title")}
/> nameDescription={t("masksAndZones.motionMasks.name.description")}
<FormField placeholderName={t("masksAndZones.motionMasks.name.placeholder")}
control={form.control} />
name="polygon.isFinished" <FormField
render={() => ( control={form.control}
<FormItem> name="enabled"
<FormMessage /> render={({ field }) => (
</FormItem> <FormItem className="flex flex-row items-center justify-between gap-3">
)} <div className="space-y-0.5">
/> <FormLabel>
<div className="flex flex-1 flex-col justify-end"> {t("masksAndZones.masks.enabled.title")}
<div className="flex flex-row gap-2 pt-5"> </FormLabel>
<Button <FormDescription>
className="flex flex-1" {t("masksAndZones.masks.enabled.description")}
aria-label={t("button.cancel", { ns: "common" })} </FormDescription>
onClick={onCancel}
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
variant="select"
aria-label={t("button.save", { ns: "common" })}
disabled={isLoading}
className="flex flex-1"
type="submit"
>
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator />
<span>{t("button.saving", { ns: "common" })}</span>
</div> </div>
) : ( <FormControl>
t("button.save", { ns: "common" }) <Switch
)} checked={field.value}
</Button> onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="isFinished"
render={() => (
<FormItem>
<FormMessage />
</FormItem>
)}
/>
<div className="flex flex-1 flex-col justify-end">
<div className="flex flex-row gap-2 pt-5">
<Button
className="flex flex-1"
aria-label={t("button.cancel", { ns: "common" })}
onClick={onCancel}
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
variant="select"
aria-label={t("button.save", { ns: "common" })}
disabled={isLoading}
className="flex flex-1"
type="submit"
>
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator />
<span>{t("button.saving", { ns: "common" })}</span>
</div>
) : (
t("button.save", { ns: "common" })
)}
</Button>
</div>
</div> </div>
</div> </form>
</form> </Form>
</Form> </FormProvider>
</> </>
); );
} }

View File

@ -23,22 +23,21 @@ import { useCallback, useEffect, useMemo } from "react";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
import useSWR from "swr"; import useSWR from "swr";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form"; import { useForm, FormProvider } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
import { ObjectMaskFormValuesType, Polygon } from "@/types/canvas"; import { ObjectMaskFormValuesType, Polygon } from "@/types/canvas";
import PolygonEditControls from "./PolygonEditControls"; import PolygonEditControls from "./PolygonEditControls";
import { FaCheckCircle } from "react-icons/fa"; import { FaCheckCircle } from "react-icons/fa";
import { import { flattenPoints, interpolatePoints } from "@/utils/canvasUtil";
flattenPoints,
interpolatePoints,
parseCoordinates,
} from "@/utils/canvasUtil";
import axios from "axios"; import axios from "axios";
import { toast } from "sonner"; import { toast } from "sonner";
import { Toaster } from "../ui/sonner"; import { Toaster } from "../ui/sonner";
import ActivityIndicator from "../indicators/activity-indicator"; import ActivityIndicator from "../indicators/activity-indicator";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { getTranslatedLabel } from "@/utils/i18n"; import { getTranslatedLabel } from "@/utils/i18n";
import NameAndIdFields from "../input/NameAndIdFields";
import { Switch } from "../ui/switch";
import { useObjectMaskState } from "@/api/ws";
type ObjectMaskEditPaneProps = { type ObjectMaskEditPaneProps = {
polygons?: Polygon[]; polygons?: Polygon[];
@ -79,6 +78,11 @@ export default function ObjectMaskEditPane({
} }
}, [polygons, activePolygonIndex]); }, [polygons, activePolygonIndex]);
const { send: sendObjectMaskState } = useObjectMaskState(
polygon?.camera || "",
polygon?.name || "",
);
const cameraConfig = useMemo(() => { const cameraConfig = useMemo(() => {
if (polygon?.camera && config) { if (polygon?.camera && config) {
return config.cameras[polygon.camera]; return config.cameras[polygon.camera];
@ -87,48 +91,80 @@ export default function ObjectMaskEditPane({
const defaultName = useMemo(() => { const defaultName = useMemo(() => {
if (!polygons) { if (!polygons) {
return; return "";
} }
const count = polygons.filter((poly) => poly.type == "object_mask").length; const count = polygons.filter((poly) => poly.type == "object_mask").length;
let objectType = ""; return t("masksAndZones.objectMaskLabel", {
const objects = polygon?.objects[0]; number: count,
if (objects === undefined) { });
objectType = "all objects"; }, [polygons, t]);
} else {
objectType = objects; const defaultId = useMemo(() => {
if (!polygons) {
return "";
} }
return t("masksAndZones.objectMaskLabel", { const count = polygons.filter((poly) => poly.type == "object_mask").length;
number: count + 1,
label: getTranslatedLabel(objectType),
});
}, [polygons, polygon, t]);
const formSchema = z return `object_mask_${count}`;
.object({ }, [polygons]);
objects: z.string(),
polygon: z.object({ isFinished: z.boolean(), name: z.string() }), const formSchema = z.object({
}) name: z
.refine(() => polygon?.isFinished === true, { .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"), message: t("masksAndZones.form.polygonDrawing.error.mustBeFinished"),
path: ["polygon.isFinished"], }),
}); });
const form = useForm<z.infer<typeof formSchema>>({ const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
mode: "onChange", mode: "onChange",
defaultValues: { defaultValues: {
name: polygon?.name || defaultId,
friendly_name: polygon?.friendly_name || defaultName,
enabled: polygon?.enabled ?? true,
objects: polygon?.objects[0] ?? "all_labels", objects: polygon?.objects[0] ?? "all_labels",
polygon: { isFinished: polygon?.isFinished ?? false, name: defaultName }, isFinished: polygon?.isFinished ?? false,
}, },
}); });
const saveToConfig = useCallback( const saveToConfig = useCallback(
async ( async ({
{ objects: form_objects }: ObjectMaskFormValuesType, // values submitted via the form name: maskId,
) => { friendly_name,
enabled,
objects: form_objects,
}: ObjectMaskFormValuesType) => {
if (!scaledWidth || !scaledHeight || !polygon || !cameraConfig) { if (!scaledWidth || !scaledHeight || !polygon || !cameraConfig) {
return; return;
} }
@ -137,93 +173,94 @@ export default function ObjectMaskEditPane({
interpolatePoints(polygon.points, scaledWidth, scaledHeight, 1, 1), interpolatePoints(polygon.points, scaledWidth, scaledHeight, 1, 1),
).join(","); ).join(",");
let queryString = "";
let configObject;
let createFilter = false;
let globalMask = false;
let filteredMask = [coordinates];
const editingMask = polygon.name.length > 0; 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 // Build the mask configuration
if (form_objects == "all_labels") { const maskConfig = {
configObject = cameraConfig.objects.mask; friendly_name: friendly_name,
globalMask = true; 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 { } else {
if ( configBody = {
cameraConfig.objects.filters[form_objects] && config_data: {
cameraConfig.objects.filters[form_objects].mask !== null cameras: {
) { [polygon.camera]: {
configObject = cameraConfig.objects.filters[form_objects].mask; objects: {
} else { filters: {
createFilter = true; [form_objects]: {
} mask: {
} [maskId]: maskConfig,
},
if (!createFilter) { },
let index = Array.isArray(configObject) },
? configObject.length },
: configObject },
? 1 },
: 0; },
requires_restart: 0,
// editing existing mask, not creating a new one update_topic: `config/cameras/${polygon.camera}/objects`,
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;
} }
axios axios
.put(`config/set?${queryString}`, { .put("config/set", configBody)
requires_restart: 0,
update_topic: `config/cameras/${polygon.camera}/objects`,
})
.then((res) => { .then((res) => {
if (res.status === 200) { if (res.status === 200) {
toast.success( toast.success(
polygon.name t("masksAndZones.objectMasks.toast.success.title", {
? t("masksAndZones.objectMasks.toast.success.title", { polygonName: friendly_name || maskId,
polygonName: polygon.name, }),
})
: t("masksAndZones.objectMasks.toast.success.noName"),
{ {
position: "top-center", position: "top-center",
}, },
); );
updateConfig(); updateConfig();
// Publish the enabled state through websocket
sendObjectMaskState(enabled ? "ON" : "OFF");
} else { } else {
toast.error( toast.error(
t("toast.save.error.title", { t("toast.save.error.title", {
@ -263,6 +300,7 @@ export default function ObjectMaskEditPane({
setIsLoading, setIsLoading,
cameraConfig, cameraConfig,
t, t,
sendObjectMaskState,
], ],
); );
@ -323,89 +361,118 @@ export default function ObjectMaskEditPane({
<Separator className="my-3 bg-secondary" /> <Separator className="my-3 bg-secondary" />
<Form {...form}> <FormProvider {...form}>
<form <Form {...form}>
onSubmit={form.handleSubmit(onSubmit)} <form
className="flex flex-1 flex-col space-y-6" onSubmit={form.handleSubmit(onSubmit)}
> className="flex flex-1 flex-col space-y-6"
<div> >
<FormField <div className="space-y-4">
control={form.control} <NameAndIdFields
name="polygon.name" type="object_mask"
render={() => ( control={form.control}
<FormItem> nameField="friendly_name"
<FormMessage /> idField="name"
</FormItem> idVisible={(polygon && polygon.name.length > 0) ?? false}
)} nameLabel={t("masksAndZones.objectMasks.name.title")}
/> nameDescription={t(
<FormField "masksAndZones.objectMasks.name.description",
control={form.control}
name="objects"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("masksAndZones.objectMasks.objects.title")}
</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
disabled={polygon.name.length != 0}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select an object type" />
</SelectTrigger>
</FormControl>
<SelectContent>
<ZoneObjectSelector camera={polygon.camera} />
</SelectContent>
</Select>
<FormDescription>
{t("masksAndZones.objectMasks.objects.desc")}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="polygon.isFinished"
render={() => (
<FormItem>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="flex flex-1 flex-col justify-end">
<div className="flex flex-row gap-2 pt-5">
<Button
className="flex flex-1"
aria-label={t("button.cancel", { ns: "common" })}
onClick={onCancel}
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
variant="select"
disabled={isLoading}
className="flex flex-1"
aria-label={t("button.save", { ns: "common" })}
type="submit"
>
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator />
<span>{t("button.saving", { ns: "common" })}</span>
</div>
) : (
t("button.save", { ns: "common" })
)} )}
</Button> placeholderName={t(
"masksAndZones.objectMasks.name.placeholder",
)}
/>
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between gap-3">
<div className="space-y-0.5">
<FormLabel>
{t("masksAndZones.masks.enabled.title")}
</FormLabel>
<FormDescription>
{t("masksAndZones.masks.enabled.description")}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="objects"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("masksAndZones.objectMasks.objects.title")}
</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
disabled={polygon.name.length != 0}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select an object type" />
</SelectTrigger>
</FormControl>
<SelectContent>
<ZoneObjectSelector camera={polygon.camera} />
</SelectContent>
</Select>
<FormDescription>
{t("masksAndZones.objectMasks.objects.desc")}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="isFinished"
render={() => (
<FormItem>
<FormMessage />
</FormItem>
)}
/>
</div> </div>
</div> <div className="flex flex-1 flex-col justify-end">
</form> <div className="flex flex-row gap-2 pt-5">
</Form> <Button
className="flex flex-1"
aria-label={t("button.cancel", { ns: "common" })}
onClick={onCancel}
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
variant="select"
disabled={isLoading}
className="flex flex-1"
aria-label={t("button.save", { ns: "common" })}
type="submit"
>
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator />
<span>{t("button.saving", { ns: "common" })}</span>
</div>
) : (
t("button.save", { ns: "common" })
)}
</Button>
</div>
</div>
</form>
</Form>
</FormProvider>
</> </>
); );
} }

View File

@ -7,6 +7,7 @@ import { Polygon, PolygonType } from "@/types/canvas";
import { useApiHost } from "@/api"; import { useApiHost } from "@/api";
import ActivityIndicator from "@/components/indicators/activity-indicator"; import ActivityIndicator from "@/components/indicators/activity-indicator";
import { snapPointToLines } from "@/utils/canvasUtil"; import { snapPointToLines } from "@/utils/canvasUtil";
import { usePolygonStates } from "@/hooks/use-polygon-states";
type PolygonCanvasProps = { type PolygonCanvasProps = {
containerRef: RefObject<HTMLDivElement>; containerRef: RefObject<HTMLDivElement>;
@ -40,6 +41,7 @@ export function PolygonCanvas({
const imageRef = useRef<Konva.Image | null>(null); const imageRef = useRef<Konva.Image | null>(null);
const stageRef = useRef<Konva.Stage>(null); const stageRef = useRef<Konva.Stage>(null);
const apiHost = useApiHost(); const apiHost = useApiHost();
const getPolygonEnabled = usePolygonStates(polygons);
const videoElement = useMemo(() => { const videoElement = useMemo(() => {
if (camera && width && height) { if (camera && width && height) {
@ -321,6 +323,7 @@ export function PolygonCanvas({
isActive={index === activePolygonIndex} isActive={index === activePolygonIndex}
isHovered={index === hoveredPolygonIndex} isHovered={index === hoveredPolygonIndex}
isFinished={polygon.isFinished} isFinished={polygon.isFinished}
enabled={getPolygonEnabled(polygon)}
color={polygon.color} color={polygon.color}
handlePointDragMove={handlePointDragMove} handlePointDragMove={handlePointDragMove}
handleGroupDragEnd={handleGroupDragEnd} handleGroupDragEnd={handleGroupDragEnd}
@ -350,6 +353,7 @@ export function PolygonCanvas({
isActive={true} isActive={true}
isHovered={activePolygonIndex === hoveredPolygonIndex} isHovered={activePolygonIndex === hoveredPolygonIndex}
isFinished={polygons[activePolygonIndex].isFinished} isFinished={polygons[activePolygonIndex].isFinished}
enabled={getPolygonEnabled(polygons[activePolygonIndex])}
color={polygons[activePolygonIndex].color} color={polygons[activePolygonIndex].color}
handlePointDragMove={handlePointDragMove} handlePointDragMove={handlePointDragMove}
handleGroupDragEnd={handleGroupDragEnd} handleGroupDragEnd={handleGroupDragEnd}

View File

@ -24,6 +24,7 @@ type PolygonDrawerProps = {
isActive: boolean; isActive: boolean;
isHovered: boolean; isHovered: boolean;
isFinished: boolean; isFinished: boolean;
enabled?: boolean;
color: number[]; color: number[];
handlePointDragMove: (e: KonvaEventObject<MouseEvent | TouchEvent>) => void; handlePointDragMove: (e: KonvaEventObject<MouseEvent | TouchEvent>) => void;
handleGroupDragEnd: (e: KonvaEventObject<MouseEvent | TouchEvent>) => void; handleGroupDragEnd: (e: KonvaEventObject<MouseEvent | TouchEvent>) => void;
@ -39,6 +40,7 @@ export default function PolygonDrawer({
isActive, isActive,
isHovered, isHovered,
isFinished, isFinished,
enabled = true,
color, color,
handlePointDragMove, handlePointDragMove,
handleGroupDragEnd, handleGroupDragEnd,
@ -108,9 +110,15 @@ export default function PolygonDrawer({
const colorString = useCallback( const colorString = useCallback(
(darkened: boolean) => { (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); return toRGBColorString(color, darkened);
}, },
[color], [color, enabled],
); );
useEffect(() => { useEffect(() => {
@ -162,9 +170,11 @@ export default function PolygonDrawer({
points={flattenedPoints} points={flattenedPoints}
stroke={colorString(true)} stroke={colorString(true)}
strokeWidth={3} strokeWidth={3}
dash={enabled ? undefined : [10, 5]}
hitStrokeWidth={12} hitStrokeWidth={12}
closed={isFinished} closed={isFinished}
fill={colorString(isActive || isHovered ? true : false)} fill={colorString(isActive || isHovered ? true : false)}
opacity={enabled ? 1 : 0.85}
onMouseOver={() => onMouseOver={() =>
isActive isActive
? isFinished ? isFinished

View File

@ -20,11 +20,7 @@ import { FaDrawPolygon, FaObjectGroup } from "react-icons/fa";
import { BsPersonBoundingBox } from "react-icons/bs"; import { BsPersonBoundingBox } from "react-icons/bs";
import { HiOutlineDotsVertical, HiTrash } from "react-icons/hi"; import { HiOutlineDotsVertical, HiTrash } from "react-icons/hi";
import { isDesktop, isMobile } from "react-device-detect"; import { isDesktop, isMobile } from "react-device-detect";
import { import { toRGBColorString } from "@/utils/canvasUtil";
flattenPoints,
parseCoordinates,
toRGBColorString,
} from "@/utils/canvasUtil";
import { Polygon, PolygonType } from "@/types/canvas"; import { Polygon, PolygonType } from "@/types/canvas";
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import axios from "axios"; import axios from "axios";
@ -36,6 +32,9 @@ import { reviewQueries } from "@/utils/zoneEdutUtil";
import IconWrapper from "../ui/icon-wrapper"; import IconWrapper from "../ui/icon-wrapper";
import { buttonVariants } from "../ui/button"; import { buttonVariants } from "../ui/button";
import { Trans, useTranslation } from "react-i18next"; 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 = { type PolygonItemProps = {
polygon: Polygon; polygon: Polygon;
@ -45,6 +44,10 @@ type PolygonItemProps = {
setActivePolygonIndex: (index: number | undefined) => void; setActivePolygonIndex: (index: number | undefined) => void;
setEditPane: (type: PolygonType) => void; setEditPane: (type: PolygonType) => void;
handleCopyCoordinates: (index: number) => void; handleCopyCoordinates: (index: number) => void;
isLoading: boolean;
setIsLoading: (loading: boolean) => void;
loadingPolygonIndex: number | undefined;
setLoadingPolygonIndex: (index: number | undefined) => void;
}; };
export default function PolygonItem({ export default function PolygonItem({
@ -55,12 +58,40 @@ export default function PolygonItem({
setActivePolygonIndex, setActivePolygonIndex,
setEditPane, setEditPane,
handleCopyCoordinates, handleCopyCoordinates,
isLoading,
setIsLoading,
loadingPolygonIndex,
setLoadingPolygonIndex,
}: PolygonItemProps) { }: PolygonItemProps) {
const { t } = useTranslation("views/settings"); const { t } = useTranslation("views/settings");
const { data: config, mutate: updateConfig } = const { data: config, mutate: updateConfig } =
useSWR<FrigateConfig>("config"); useSWR<FrigateConfig>("config");
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); 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(() => { const cameraConfig = useMemo(() => {
if (polygon?.camera && config) { if (polygon?.camera && config) {
@ -81,93 +112,6 @@ export default function PolygonItem({
if (!polygon || !cameraConfig) { if (!polygon || !cameraConfig) {
return; 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 = const updateTopicType =
polygon.type === "zone" polygon.type === "zone"
@ -179,9 +123,117 @@ export default function PolygonItem({
: polygon.type; : polygon.type;
setIsLoading(true); 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 await axios
.put(`config/set?${url}`, { .put("config/set", {
config_data: configUpdate,
requires_restart: 0, requires_restart: 0,
update_topic: `config/cameras/${polygon.camera}/${updateTopicType}`, update_topic: `config/cameras/${polygon.camera}/${updateTopicType}`,
}) })
@ -191,9 +243,7 @@ export default function PolygonItem({
t("masksAndZones.form.polygonDrawing.delete.success", { t("masksAndZones.form.polygonDrawing.delete.success", {
name: polygon?.friendly_name ?? polygon?.name, name: polygon?.friendly_name ?? polygon?.name,
}), }),
{ { position: "top-center" },
position: "top-center",
},
); );
updateConfig(); updateConfig();
} else { } else {
@ -202,9 +252,7 @@ export default function PolygonItem({
ns: "common", ns: "common",
errorMessage: res.statusText, errorMessage: res.statusText,
}), }),
{ { position: "top-center" },
position: "top-center",
},
); );
} }
}) })
@ -215,16 +263,22 @@ export default function PolygonItem({
"Unknown error"; "Unknown error";
toast.error( toast.error(
t("toast.save.error.title", { errorMessage, ns: "common" }), t("toast.save.error.title", { errorMessage, ns: "common" }),
{ { position: "top-center" },
position: "top-center",
},
); );
}) })
.finally(() => { .finally(() => {
setIsLoading(false); setIsLoading(false);
setLoadingPolygonIndex(undefined);
}); });
}, },
[updateConfig, cameraConfig, t], [
updateConfig,
cameraConfig,
t,
setIsLoading,
index,
setLoadingPolygonIndex,
],
); );
const handleDelete = () => { const handleDelete = () => {
@ -232,6 +286,43 @@ export default function PolygonItem({
saveToConfig(polygon); 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 ( return (
<> <>
<Toaster position="top-center" closeButton={true} /> <Toaster position="top-center" closeButton={true} />
@ -256,17 +347,52 @@ export default function PolygonItem({
: "text-primary-variant" : "text-primary-variant"
}`} }`}
> >
{PolygonItemIcon && ( {PolygonItemIcon &&
<PolygonItemIcon (isLoading && loadingPolygonIndex === index ? (
className="mr-2 size-5" <div className="mr-2">
style={{ <ActivityIndicator className="size-5" />
fill: toRGBColorString(polygon.color, true), </div>
color: toRGBColorString(polygon.color, true), ) : (
}} <Tooltip>
/> <TooltipTrigger asChild>
)} <button
<p className="cursor-default"> type="button"
onClick={handleToggleEnabled}
disabled={isLoading || polygon.enabled_in_config === false}
className="mr-2 cursor-pointer border-none bg-transparent p-0 transition-opacity hover:opacity-70 disabled:cursor-not-allowed disabled:opacity-50"
>
<PolygonItemIcon
className="size-5"
style={{
fill: toRGBColorString(polygon.color, isPolygonEnabled),
color: toRGBColorString(
polygon.color,
isPolygonEnabled,
),
}}
/>
</button>
</TooltipTrigger>
<TooltipContent>
{polygon.enabled_in_config === false
? t("masksAndZones.disabledInConfig", {
ns: "views/settings",
})
: isPolygonEnabled
? t("button.disable", { ns: "common" })
: t("button.enable", { ns: "common" })}
</TooltipContent>
</Tooltip>
))}
<p
className={cn(
"cursor-default",
!isPolygonEnabled && "opacity-60",
polygon.enabled_in_config === false && "line-through",
)}
>
{polygon.friendly_name ?? polygon.name} {polygon.friendly_name ?? polygon.name}
{!isPolygonEnabled && " (disabled)"}
</p> </p>
</div> </div>
<AlertDialog <AlertDialog
@ -316,6 +442,7 @@ export default function PolygonItem({
<DropdownMenuContent> <DropdownMenuContent>
<DropdownMenuItem <DropdownMenuItem
aria-label={t("button.edit", { ns: "common" })} aria-label={t("button.edit", { ns: "common" })}
disabled={isLoading}
onClick={() => { onClick={() => {
setActivePolygonIndex(index); setActivePolygonIndex(index);
setEditPane(polygon.type); setEditPane(polygon.type);
@ -325,6 +452,7 @@ export default function PolygonItem({
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
aria-label={t("button.copy", { ns: "common" })} aria-label={t("button.copy", { ns: "common" })}
disabled={isLoading}
onClick={() => handleCopyCoordinates(index)} onClick={() => handleCopyCoordinates(index)}
> >
{t("button.copy", { ns: "common" })} {t("button.copy", { ns: "common" })}
@ -346,10 +474,17 @@ export default function PolygonItem({
<TooltipTrigger asChild> <TooltipTrigger asChild>
<IconWrapper <IconWrapper
icon={LuPencil} icon={LuPencil}
className={`size-[15px] cursor-pointer ${hoveredPolygonIndex === index && "text-primary-variant"}`} disabled={isLoading}
className={cn(
"size-[15px] cursor-pointer",
hoveredPolygonIndex === index && "text-primary-variant",
isLoading && "cursor-not-allowed opacity-50",
)}
onClick={() => { onClick={() => {
setActivePolygonIndex(index); if (!isLoading) {
setEditPane(polygon.type); setActivePolygonIndex(index);
setEditPane(polygon.type);
}
}} }}
/> />
</TooltipTrigger> </TooltipTrigger>
@ -362,10 +497,16 @@ export default function PolygonItem({
<TooltipTrigger asChild> <TooltipTrigger asChild>
<IconWrapper <IconWrapper
icon={LuCopy} icon={LuCopy}
className={`size-[15px] cursor-pointer ${ className={cn(
hoveredPolygonIndex === index && "text-primary-variant" "size-[15px] cursor-pointer",
}`} hoveredPolygonIndex === index && "text-primary-variant",
onClick={() => handleCopyCoordinates(index)} isLoading && "cursor-not-allowed opacity-50",
)}
onClick={() => {
if (!isLoading) {
handleCopyCoordinates(index);
}
}}
/> />
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
@ -377,10 +518,13 @@ export default function PolygonItem({
<TooltipTrigger asChild> <TooltipTrigger asChild>
<IconWrapper <IconWrapper
icon={HiTrash} icon={HiTrash}
className={`size-[15px] cursor-pointer ${ disabled={isLoading}
className={cn(
"size-[15px] cursor-pointer",
hoveredPolygonIndex === index && hoveredPolygonIndex === index &&
"fill-primary-variant text-primary-variant" "fill-primary-variant text-primary-variant",
}`} isLoading && "cursor-not-allowed opacity-50",
)}
onClick={() => !isLoading && setDeleteDialogOpen(true)} onClick={() => !isLoading && setDeleteDialogOpen(true)}
/> />
</TooltipTrigger> </TooltipTrigger>

View File

@ -35,6 +35,7 @@ import { LuExternalLink } from "react-icons/lu";
import { useDocDomain } from "@/hooks/use-doc-domain"; import { useDocDomain } from "@/hooks/use-doc-domain";
import { getTranslatedLabel } from "@/utils/i18n"; import { getTranslatedLabel } from "@/utils/i18n";
import NameAndIdFields from "../input/NameAndIdFields"; import NameAndIdFields from "../input/NameAndIdFields";
import { useZoneState } from "@/api/ws";
type ZoneEditPaneProps = { type ZoneEditPaneProps = {
polygons?: Polygon[]; polygons?: Polygon[];
@ -88,6 +89,11 @@ export default function ZoneEditPane({
} }
}, [polygons, activePolygonIndex]); }, [polygons, activePolygonIndex]);
const { send: sendZoneState } = useZoneState(
polygon?.camera || "",
polygon?.name || "",
);
const cameraConfig = useMemo(() => { const cameraConfig = useMemo(() => {
if (polygon?.camera && config) { if (polygon?.camera && config) {
return config.cameras[polygon.camera]; return config.cameras[polygon.camera];
@ -178,6 +184,7 @@ export default function ZoneEditPane({
message: t("masksAndZones.form.zoneName.error.alreadyExists"), message: t("masksAndZones.form.zoneName.error.alreadyExists"),
}, },
), ),
enabled: z.boolean().default(true),
inertia: z.coerce inertia: z.coerce
.number() .number()
.min(1, { .min(1, {
@ -271,6 +278,13 @@ export default function ZoneEditPane({
defaultValues: { defaultValues: {
name: polygon?.name ?? "", name: polygon?.name ?? "",
friendly_name: polygon?.friendly_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: inertia:
polygon?.camera && polygon?.camera &&
polygon?.name && polygon?.name &&
@ -311,6 +325,7 @@ export default function ZoneEditPane({
{ {
name: zoneName, name: zoneName,
friendly_name, friendly_name,
enabled,
inertia, inertia,
loitering_time, loitering_time,
objects: form_objects, objects: form_objects,
@ -445,9 +460,11 @@ export default function ZoneEditPane({
friendlyNameQuery = `&cameras.${polygon?.camera}.zones.${zoneName}.friendly_name=${encodeURIComponent(friendly_name)}`; friendlyNameQuery = `&cameras.${polygon?.camera}.zones.${zoneName}.friendly_name=${encodeURIComponent(friendly_name)}`;
} }
const enabledQuery = `&cameras.${polygon?.camera}.zones.${zoneName}.enabled=${enabled ? "True" : "False"}`;
axios axios
.put( .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, requires_restart: 0,
update_topic: `config/cameras/${polygon.camera}/zones`, update_topic: `config/cameras/${polygon.camera}/zones`,
@ -464,6 +481,8 @@ export default function ZoneEditPane({
}, },
); );
updateConfig(); updateConfig();
// Publish the enabled state through websocket
sendZoneState(enabled ? "ON" : "OFF");
} else { } else {
toast.error( toast.error(
t("toast.save.error.title", { t("toast.save.error.title", {
@ -504,6 +523,7 @@ export default function ZoneEditPane({
setIsLoading, setIsLoading,
cameraConfig, cameraConfig,
t, t,
sendZoneState,
], ],
); );
@ -581,6 +601,28 @@ export default function ZoneEditPane({
nameDescription={t("masksAndZones.zones.name.tips")} nameDescription={t("masksAndZones.zones.name.tips")}
placeholderName={t("masksAndZones.zones.name.inputPlaceHolder")} placeholderName={t("masksAndZones.zones.name.inputPlaceHolder")}
/> />
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between gap-3">
<div className="space-y-0.5">
<FormLabel>
{t("masksAndZones.zones.enabled.title")}
</FormLabel>
<FormDescription>
{t("masksAndZones.zones.enabled.description")}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<Separator className="my-2 flex bg-secondary" /> <Separator className="my-2 flex bg-secondary" />
<FormField <FormField

View File

@ -0,0 +1,44 @@
import { useMemo } from "react";
import { Polygon } from "@/types/canvas";
import { useWsState } from "@/api/ws";
/**
* Hook to get enabled state for a polygon from websocket state.
* Memoizes the lookup function to avoid unnecessary re-renders.
*/
export function usePolygonStates(polygons: Polygon[]) {
const wsState = useWsState();
// Create a memoized lookup map that only updates when relevant ws values change
return useMemo(() => {
const stateMap = new Map<string, boolean>();
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]);
}

View File

@ -12,11 +12,14 @@ export type Polygon = {
isFinished: boolean; isFinished: boolean;
color: number[]; color: number[];
friendly_name?: string; friendly_name?: string;
enabled?: boolean;
enabled_in_config?: boolean;
}; };
export type ZoneFormValuesType = { export type ZoneFormValuesType = {
name: string; name: string;
friendly_name: string; friendly_name: string;
enabled: boolean;
inertia: number; inertia: number;
loitering_time: number; loitering_time: number;
isFinished: boolean; isFinished: boolean;
@ -29,10 +32,17 @@ export type ZoneFormValuesType = {
speed_threshold: number; speed_threshold: number;
}; };
export type ObjectMaskFormValuesType = { export type MotionMaskFormValuesType = {
objects: string; name: string;
polygon: { friendly_name: string;
isFinished: boolean; enabled: boolean;
name: string; isFinished: boolean;
}; };
export type ObjectMaskFormValuesType = {
name: string;
friendly_name: string;
enabled: boolean;
objects: string;
isFinished: boolean;
}; };

View File

@ -106,7 +106,14 @@ export interface CameraConfig {
frame_height: number; frame_height: number;
improve_contrast: boolean; improve_contrast: boolean;
lightning_threshold: number; lightning_threshold: number;
mask: string[]; mask: {
[maskId: string]: {
friendly_name?: string;
enabled: boolean;
enabled_in_config?: boolean;
coordinates: string;
};
};
mqtt_off_delay: number; mqtt_off_delay: number;
threshold: number; threshold: number;
}; };
@ -128,7 +135,14 @@ export interface CameraConfig {
objects: { objects: {
filters: { filters: {
[objectName: string]: { [objectName: string]: {
mask: string[] | null; mask: {
[maskId: string]: {
friendly_name?: string;
enabled: boolean;
enabled_in_config?: boolean;
coordinates: string;
};
};
max_area: number; max_area: number;
max_ratio: number; max_ratio: number;
min_area: number; min_area: number;
@ -137,7 +151,14 @@ export interface CameraConfig {
threshold: number; threshold: number;
}; };
}; };
mask: string; mask: {
[maskId: string]: {
friendly_name?: string;
enabled: boolean;
enabled_in_config?: boolean;
coordinates: string;
};
};
track: string[]; track: string[];
genai: { genai: {
enabled: boolean; enabled: boolean;
@ -272,6 +293,8 @@ export interface CameraConfig {
[zoneName: string]: { [zoneName: string]: {
coordinates: string; coordinates: string;
distances: string[]; distances: string[];
enabled: boolean;
enabled_in_config?: boolean;
filters: Record<string, unknown>; filters: Record<string, unknown>;
inertia: number; inertia: number;
loitering_time: number; loitering_time: number;

View File

@ -34,7 +34,6 @@ import { useSearchEffect } from "@/hooks/use-overlay-state";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useDocDomain } from "@/hooks/use-doc-domain"; import { useDocDomain } from "@/hooks/use-doc-domain";
import { getTranslatedLabel } from "@/utils/i18n";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
type MasksAndZoneViewProps = { type MasksAndZoneViewProps = {
@ -54,6 +53,9 @@ export default function MasksAndZonesView({
const [allPolygons, setAllPolygons] = useState<Polygon[]>([]); const [allPolygons, setAllPolygons] = useState<Polygon[]>([]);
const [editingPolygons, setEditingPolygons] = useState<Polygon[]>([]); const [editingPolygons, setEditingPolygons] = useState<Polygon[]>([]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [loadingPolygonIndex, setLoadingPolygonIndex] = useState<
number | undefined
>(undefined);
const [activePolygonIndex, setActivePolygonIndex] = useState< const [activePolygonIndex, setActivePolygonIndex] = useState<
number | undefined number | undefined
>(undefined); >(undefined);
@ -170,6 +172,7 @@ export default function MasksAndZonesView({
objects: [], objects: [],
camera: selectedCamera, camera: selectedCamera,
color: polygonColor, color: polygonColor,
enabled: true,
}, },
]); ]);
}; };
@ -231,6 +234,8 @@ export default function MasksAndZonesView({
camera: cameraConfig.name, camera: cameraConfig.name,
name, name,
friendly_name: zoneData.friendly_name, friendly_name: zoneData.friendly_name,
enabled: zoneData.enabled,
enabled_in_config: zoneData.enabled_in_config,
objects: zoneData.objects, objects: zoneData.objects,
points: interpolatePoints( points: interpolatePoints(
parseCoordinates(zoneData.coordinates), parseCoordinates(zoneData.coordinates),
@ -250,102 +255,93 @@ export default function MasksAndZonesView({
let globalObjectMasks: Polygon[] = []; let globalObjectMasks: Polygon[] = [];
let objectMasks: Polygon[] = []; let objectMasks: Polygon[] = [];
// this can be an array or a string // Motion masks are a dict with mask_id as key
motionMasks = ( motionMasks = Object.entries(cameraConfig.motion.mask || {}).map(
Array.isArray(cameraConfig.motion.mask) ([maskId, maskData], index) => ({
? cameraConfig.motion.mask type: "motion_mask" as PolygonType,
: cameraConfig.motion.mask typeIndex: index,
? [cameraConfig.motion.mask] camera: cameraConfig.name,
: [] name: maskId,
).map((maskData, index) => ({ friendly_name: maskData.friendly_name,
type: "motion_mask" as PolygonType, enabled: maskData.enabled,
typeIndex: index, enabled_in_config: maskData.enabled_in_config,
camera: cameraConfig.name, objects: [],
name: t("masksAndZones.motionMaskLabel", { points: interpolatePoints(
number: index + 1, 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) // Global object masks are a dict with mask_id as key
? cameraConfig.objects.mask globalObjectMasks = Object.entries(cameraConfig.objects.mask || {}).map(
: cameraConfig.objects.mask ([maskId, maskData], index) => ({
? [cameraConfig.objects.mask] type: "object_mask" as PolygonType,
: []; typeIndex: index,
camera: cameraConfig.name,
globalObjectMasks = globalObjectMasksArray.map((maskData, index) => ({ name: maskId,
type: "object_mask" as PolygonType, friendly_name: maskData.friendly_name,
typeIndex: index, enabled: maskData.enabled,
camera: cameraConfig.name, enabled_in_config: maskData.enabled_in_config,
name: t("masksAndZones.objectMaskLabel", { objects: [],
number: index + 1, points: interpolatePoints(
label: t("masksAndZones.zones.allObjects"), 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 objectMaskIndex = globalObjectMasks.length;
let index = 0;
objectMasks = Object.entries(cameraConfig.objects.filters) objectMasks = Object.entries(cameraConfig.objects.filters)
.filter(([, { mask }]) => mask || Array.isArray(mask)) .filter(
.flatMap(([objectName, { mask }]): Polygon[] => { ([, filterConfig]) =>
const maskArray = Array.isArray(mask) ? mask : mask ? [mask] : []; filterConfig.mask && Object.keys(filterConfig.mask).length > 0,
return maskArray.flatMap((maskItem, subIndex) => { )
const maskItemString = maskItem; .flatMap(([objectName, filterConfig]): Polygon[] => {
const newMask = { return Object.entries(filterConfig.mask || {}).flatMap(
type: "object_mask" as PolygonType, ([maskId, maskData]) => {
typeIndex: subIndex, // Skip if this mask is a global mask (prefixed with "global_")
camera: cameraConfig.name, if (maskId.startsWith("global_")) {
name: t("masksAndZones.objectMaskLabel", { return [];
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++;
if ( const newMask = {
globalObjectMasksArray.some( type: "object_mask" as PolygonType,
(globalMask) => globalMask === maskItemString, typeIndex: objectMaskIndex,
) camera: cameraConfig.name,
) { name: maskId,
index--; friendly_name: maskData.friendly_name,
return []; enabled: maskData.enabled,
} else { 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]; return [newMask];
} },
}); );
}); });
setAllPolygons([ setAllPolygons([
@ -548,6 +544,10 @@ export default function MasksAndZonesView({
setActivePolygonIndex={setActivePolygonIndex} setActivePolygonIndex={setActivePolygonIndex}
setEditPane={setEditPane} setEditPane={setEditPane}
handleCopyCoordinates={handleCopyCoordinates} handleCopyCoordinates={handleCopyCoordinates}
isLoading={isLoading}
setIsLoading={setIsLoading}
loadingPolygonIndex={loadingPolygonIndex}
setLoadingPolygonIndex={setLoadingPolygonIndex}
/> />
))} ))}
</div> </div>
@ -618,6 +618,10 @@ export default function MasksAndZonesView({
setActivePolygonIndex={setActivePolygonIndex} setActivePolygonIndex={setActivePolygonIndex}
setEditPane={setEditPane} setEditPane={setEditPane}
handleCopyCoordinates={handleCopyCoordinates} handleCopyCoordinates={handleCopyCoordinates}
isLoading={isLoading}
setIsLoading={setIsLoading}
loadingPolygonIndex={loadingPolygonIndex}
setLoadingPolygonIndex={setLoadingPolygonIndex}
/> />
))} ))}
</div> </div>
@ -688,6 +692,10 @@ export default function MasksAndZonesView({
setActivePolygonIndex={setActivePolygonIndex} setActivePolygonIndex={setActivePolygonIndex}
setEditPane={setEditPane} setEditPane={setEditPane}
handleCopyCoordinates={handleCopyCoordinates} handleCopyCoordinates={handleCopyCoordinates}
isLoading={isLoading}
setIsLoading={setIsLoading}
loadingPolygonIndex={loadingPolygonIndex}
setLoadingPolygonIndex={setLoadingPolygonIndex}
/> />
))} ))}
</div> </div>