Compare commits

..

30 Commits

Author SHA1 Message Date
Josh Hawkins
3bfbb5091a fix typing 2026-02-27 16:54:50 -06:00
Josh Hawkins
ae4b5f015e tweaks 2026-02-27 16:37:54 -06:00
Josh Hawkins
4217414e1d i18n generation 2026-02-27 16:26:37 -06:00
Josh Hawkins
4a0469a69c pydantic title and description 2026-02-27 16:25:43 -06:00
Josh Hawkins
232a0655e8 i18n tweaks 2026-02-27 16:19:47 -06:00
Josh Hawkins
98453a5cd9 publish websocket on config save 2026-02-27 16:19:47 -06:00
Josh Hawkins
688bef22f2 i18n 2026-02-27 16:19:47 -06:00
Josh Hawkins
6bceafe686 tweaks 2026-02-27 16:19:47 -06:00
Josh Hawkins
e8d227eb0a frontend for enabled_in_config 2026-02-27 16:19:47 -06:00
Josh Hawkins
366365e59f use enabled_in_config for zones and masks 2026-02-27 16:19:47 -06:00
Josh Hawkins
c3be785061 render masks and zones based on ws enabled state 2026-02-27 16:19:47 -06:00
Josh Hawkins
14d9068bfa ws hooks 2026-02-27 16:19:47 -06:00
Josh Hawkins
32b5418ff1 correctly handle global object masks in dispatcher 2026-02-27 16:19:47 -06:00
Josh Hawkins
7d678de445 fix global object masks 2026-02-27 16:19:47 -06:00
Josh Hawkins
abb03bd554 toggle via mqtt 2026-02-27 16:19:47 -06:00
Josh Hawkins
7df7330eae enforce atomic config update in the frontend 2026-02-27 16:19:47 -06:00
Josh Hawkins
1e061538a1 use filelock to ensure atomic config updates from endpoint 2026-02-27 16:19:47 -06:00
Josh Hawkins
b4462138fb allow toggle from icon 2026-02-27 16:19:47 -06:00
Josh Hawkins
65b8a1c201 use dashed stroke to indicate disabled 2026-02-27 16:19:47 -06:00
Josh Hawkins
4f358c376f tweaks 2026-02-27 16:19:47 -06:00
Josh Hawkins
7badcbdbeb docs 2026-02-27 16:19:47 -06:00
Josh Hawkins
66e65afcda i18n 2026-02-27 16:19:47 -06:00
Josh Hawkins
aae5250122 zones frontend 2026-02-27 16:19:47 -06:00
Josh Hawkins
e9aebbe53f add enabled config to zones 2026-02-27 16:19:46 -06:00
Josh Hawkins
9128881924 update tests 2026-02-27 16:19:46 -06:00
Josh Hawkins
4277834757 i18n 2026-02-27 16:19:46 -06:00
Josh Hawkins
79fedee1d1 convert none to empty string for config save 2026-02-27 16:19:46 -06:00
Josh Hawkins
58053eb3f0 frontend 2026-02-27 16:19:46 -06:00
Josh Hawkins
35fd1ccbc0 component changes to use rasterized_mask 2026-02-27 16:19:46 -06:00
Josh Hawkins
105e7ca4fd migrator and runtime config changes 2026-02-27 16:19:46 -06:00
37 changed files with 1966 additions and 741 deletions

View File

@@ -138,7 +138,10 @@ cameras:
- detect
motion:
mask:
- 0.000,0.427,0.002,0.000,0.999,0.000,0.999,0.781,0.885,0.456,0.700,0.424,0.701,0.311,0.507,0.294,0.453,0.347,0.451,0.400
timestamp:
friendly_name: "Camera timestamp"
enabled: true
coordinates: "0.000,0.427,0.002,0.000,0.999,0.000,0.999,0.781,0.885,0.456,0.700,0.424,0.701,0.311,0.507,0.294,0.453,0.347,0.451,0.400"
```
### Standalone Intel Mini PC with USB Coral
@@ -195,7 +198,10 @@ cameras:
- detect
motion:
mask:
- 0.000,0.427,0.002,0.000,0.999,0.000,0.999,0.781,0.885,0.456,0.700,0.424,0.701,0.311,0.507,0.294,0.453,0.347,0.451,0.400
timestamp:
friendly_name: "Camera timestamp"
enabled: true
coordinates: "0.000,0.427,0.002,0.000,0.999,0.000,0.999,0.781,0.885,0.456,0.700,0.424,0.701,0.311,0.507,0.294,0.453,0.347,0.451,0.400"
```
### Home Assistant integrated Intel Mini PC with OpenVino
@@ -262,5 +268,8 @@ cameras:
- detect
motion:
mask:
- 0.000,0.427,0.002,0.000,0.999,0.000,0.999,0.781,0.885,0.456,0.700,0.424,0.701,0.311,0.507,0.294,0.453,0.347,0.451,0.400
timestamp:
friendly_name: "Camera timestamp"
enabled: true
coordinates: "0.000,0.427,0.002,0.000,0.999,0.000,0.999,0.781,0.885,0.456,0.700,0.424,0.701,0.311,0.507,0.294,0.453,0.347,0.451,0.400"
```

View File

@@ -33,18 +33,55 @@ Your config file will be updated with the relative coordinates of the mask/zone:
```yaml
motion:
mask: "0.000,0.427,0.002,0.000,0.999,0.000,0.999,0.781,0.885,0.456,0.700,0.424,0.701,0.311,0.507,0.294,0.453,0.347,0.451,0.400"
mask:
# Motion mask name (required)
mask1:
# Optional: A friendly name for the mask
friendly_name: "Timestamp area"
# Optional: Whether this mask is active (default: true)
enabled: true
# Required: Coordinates polygon for the mask
coordinates: "0.000,0.427,0.002,0.000,0.999,0.000,0.999,0.781,0.885,0.456,0.700,0.424,0.701,0.311,0.507,0.294,0.453,0.347,0.451,0.400"
```
Multiple masks can be listed in your config.
Multiple motion masks can be listed in your config:
```yaml
motion:
mask:
- 0.239,1.246,0.175,0.901,0.165,0.805,0.195,0.802
- 0.000,0.427,0.002,0.000,0.999,0.000,0.999,0.781,0.885,0.456
mask1:
friendly_name: "Timestamp area"
enabled: true
coordinates: "0.239,1.246,0.175,0.901,0.165,0.805,0.195,0.802"
mask2:
friendly_name: "Tree area"
enabled: true
coordinates: "0.000,0.427,0.002,0.000,0.999,0.000,0.999,0.781,0.885,0.456"
```
Object filter masks can also be created through the UI or manually in the config. They are configured under the object filters section for each object type:
```yaml
objects:
filters:
person:
mask:
person_filter1:
friendly_name: "Roof area"
enabled: true
coordinates: "0.000,0.000,1.000,0.000,1.000,0.400,0.000,0.400"
car:
mask:
car_filter1:
friendly_name: "Sidewalk area"
enabled: true
coordinates: "0.000,0.700,1.000,0.700,1.000,1.000,0.000,1.000"
```
## Enabling/Disabling Masks
Both motion masks and object filter masks can be toggled on or off without removing them from the configuration. Disabled masks are completely ignored at runtime - they will not affect motion detection or object filtering. This is useful for temporarily disabling a mask during certain seasons or times of day without modifying the configuration.
### Further Clarification
This is a response to a [question posed on reddit](https://www.reddit.com/r/homeautomation/comments/ppxdve/replacing_my_doorbell_with_a_security_camera_a_6/hd876w4?utm_source=share&utm_medium=web2x&context=3):

View File

@@ -345,7 +345,15 @@ objects:
# Optional: mask to prevent all object types from being detected in certain areas (default: no mask)
# Checks based on the bottom center of the bounding box of the object.
# NOTE: This mask is COMBINED with the object type specific mask below
mask: 0.000,0.000,0.781,0.000,0.781,0.278,0.000,0.278
mask:
# Object filter mask name (required)
mask1:
# Optional: A friendly name for the mask
friendly_name: "Object filter mask area"
# Optional: Whether this mask is active (default: true)
enabled: true
# Required: Coordinates polygon for the mask
coordinates: "0.000,0.000,0.781,0.000,0.781,0.278,0.000,0.278"
# Optional: filters to reduce false positives for specific object types
filters:
person:
@@ -365,7 +373,15 @@ objects:
threshold: 0.7
# Optional: mask to prevent this object type from being detected in certain areas (default: no mask)
# Checks based on the bottom center of the bounding box of the object
mask: 0.000,0.000,0.781,0.000,0.781,0.278,0.000,0.278
mask:
# Object filter mask name (required)
mask1:
# Optional: A friendly name for the mask
friendly_name: "Object filter mask area"
# Optional: Whether this mask is active (default: true)
enabled: true
# Required: Coordinates polygon for the mask
coordinates: "0.000,0.000,0.781,0.000,0.781,0.278,0.000,0.278"
# Optional: Configuration for AI generated tracked object descriptions
genai:
# Optional: Enable AI object description generation (default: shown below)
@@ -489,7 +505,15 @@ motion:
frame_height: 100
# Optional: motion mask
# NOTE: see docs for more detailed info on creating masks
mask: 0.000,0.469,1.000,0.469,1.000,1.000,0.000,1.000
mask:
# Motion mask name (required)
mask1:
# Optional: A friendly name for the mask
friendly_name: "Motion mask area"
# Optional: Whether this mask is active (default: true)
enabled: true
# Required: Coordinates polygon for the mask
coordinates: "0.000,0.469,1.000,0.469,1.000,1.000,0.000,1.000"
# Optional: improve contrast (default: shown below)
# Enables dynamic contrast improvement. This should help improve night detections at the cost of making motion detection more sensitive
# for daytime.
@@ -866,6 +890,9 @@ cameras:
front_steps:
# Optional: A friendly name or descriptive text for the zones
friendly_name: ""
# Optional: Whether this zone is active (default: shown below)
# Disabled zones are completely ignored at runtime - no object tracking or debug drawing
enabled: True
# Required: List of x,y coordinates to define the polygon of the zone.
# NOTE: Presence in a zone is evaluated only based on the bottom center of the objects bounding box.
coordinates: 0.033,0.306,0.324,0.138,0.439,0.185,0.042,0.428

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.
## Enabling/Disabling Zones
Zones can be toggled on or off without removing them from the configuration. Disabled zones are completely ignored at runtime - objects will not be tracked for zone presence, and zones will not appear in the debug view. This is useful for temporarily disabling a zone during certain seasons or times of day without modifying the configuration.
During testing, enable the Zones option for the Debug view of your camera (Settings --> Debug) so you can adjust as needed. The zone line will increase in thickness when any object enters the zone.
To create a zone, follow [the steps for a "Motion mask"](masks.md), but use the section of the web UI for creating a zone instead.
@@ -86,7 +90,6 @@ cameras:
Only car objects can trigger the `front_yard_street` zone and only person can trigger the `entire_yard`. Objects will be tracked for any `person` that enter anywhere in the yard, and for cars only if they enter the street.
### Zone Loitering
Sometimes objects are expected to be passing through a zone, but an object loitering in an area is unexpected. Zones can be configured to have a minimum loitering time after which the object will be considered in the zone.
@@ -94,6 +97,7 @@ Sometimes objects are expected to be passing through a zone, but an object loite
:::note
When using loitering zones, a review item will behave in the following way:
- When a person is in a loitering zone, the review item will remain active until the person leaves the loitering zone, regardless of if they are stationary.
- When any other object is in a loitering zone, the review item will remain active until the loitering time is met. Then if the object is stationary the review item will end.

View File

@@ -240,7 +240,10 @@ cameras:
- detect
motion:
mask:
- 0,461,3,0,1919,0,1919,843,1699,492,1344,458,1346,336,973,317,869,375,866,432
motion_area:
friendly_name: "Motion mask"
enabled: true
coordinates: "0,461,3,0,1919,0,1919,843,1699,492,1344,458,1346,336,973,317,869,375,866,432"
```
### Step 6: Enable recordings

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.
### `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`
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.params import Depends
from fastapi.responses import JSONResponse, PlainTextResponse, StreamingResponse
from filelock import FileLock, Timeout
from markupsafe import escape
from peewee import SQL, fn, operator
from pydantic import ValidationError
@@ -424,102 +425,124 @@ def config_save(save_option: str, body: Any = Body(media_type="text/plain")):
@router.put("/config/set", dependencies=[Depends(require_role(["admin"]))])
def config_set(request: Request, body: AppConfigSetBody):
config_file = find_config_file()
with open(config_file, "r") as f:
old_raw_config = f.read()
lock = FileLock(f"{config_file}.lock", timeout=5)
try:
updates = {}
with lock:
with open(config_file, "r") as f:
old_raw_config = f.read()
# process query string parameters (takes precedence over body.config_data)
parsed_url = urllib.parse.urlparse(str(request.url))
query_string = urllib.parse.parse_qs(parsed_url.query, keep_blank_values=True)
try:
updates = {}
# Filter out empty keys but keep blank values for non-empty keys
query_string = {k: v for k, v in query_string.items() if k}
# process query string parameters (takes precedence over body.config_data)
parsed_url = urllib.parse.urlparse(str(request.url))
query_string = urllib.parse.parse_qs(
parsed_url.query, keep_blank_values=True
)
if query_string:
updates = process_config_query_string(query_string)
elif body.config_data:
updates = flatten_config_data(body.config_data)
# Filter out empty keys but keep blank values for non-empty keys
query_string = {k: v for k, v in query_string.items() if k}
if not updates:
return JSONResponse(
content=(
{"success": False, "message": "No configuration data provided"}
),
status_code=400,
)
if query_string:
updates = process_config_query_string(query_string)
elif body.config_data:
updates = flatten_config_data(body.config_data)
# Convert None values to empty strings for deletion (e.g., when deleting masks)
updates = {k: ("" if v is None else v) for k, v in updates.items()}
# apply all updates in a single operation
update_yaml_file_bulk(config_file, updates)
if not updates:
return JSONResponse(
content=(
{
"success": False,
"message": "No configuration data provided",
}
),
status_code=400,
)
# validate the updated config
with open(config_file, "r") as f:
new_raw_config = f.read()
# apply all updates in a single operation
update_yaml_file_bulk(config_file, updates)
# validate the updated config
with open(config_file, "r") as f:
new_raw_config = f.read()
try:
config = FrigateConfig.parse(new_raw_config)
except Exception:
with open(config_file, "w") as f:
f.write(old_raw_config)
f.close()
logger.error(f"\nConfig Error:\n\n{str(traceback.format_exc())}")
return JSONResponse(
content=(
{
"success": False,
"message": "Error parsing config. Check logs for error message.",
}
),
status_code=400,
)
except Exception as e:
logging.error(f"Error updating config: {e}")
return JSONResponse(
content=({"success": False, "message": "Error updating config"}),
status_code=500,
)
if body.requires_restart == 0 or body.update_topic:
old_config: FrigateConfig = request.app.frigate_config
request.app.frigate_config = config
request.app.genai_manager.update_config(config)
if body.update_topic:
if body.update_topic.startswith("config/cameras/"):
_, _, camera, field = body.update_topic.split("/")
if field == "add":
settings = config.cameras[camera]
elif field == "remove":
settings = old_config.cameras[camera]
else:
settings = config.get_nested_object(body.update_topic)
request.app.config_publisher.publish_update(
CameraConfigUpdateTopic(
CameraConfigUpdateEnum[field], camera
),
settings,
)
else:
# Generic handling for global config updates
settings = config.get_nested_object(body.update_topic)
# Publish None for removal, actual config for add/update
request.app.config_publisher.publisher.publish(
body.update_topic, settings
)
try:
config = FrigateConfig.parse(new_raw_config)
except Exception:
with open(config_file, "w") as f:
f.write(old_raw_config)
f.close()
logger.error(f"\nConfig Error:\n\n{str(traceback.format_exc())}")
return JSONResponse(
content=(
{
"success": False,
"message": "Error parsing config. Check logs for error message.",
"success": True,
"message": "Config successfully updated, restart to apply",
}
),
status_code=400,
status_code=200,
)
except Exception as e:
logging.error(f"Error updating config: {e}")
except Timeout:
return JSONResponse(
content=({"success": False, "message": "Error updating config"}),
status_code=500,
content=(
{
"success": False,
"message": "Another process is currently updating the config. Please try again in a few seconds.",
}
),
status_code=503,
)
if body.requires_restart == 0 or body.update_topic:
old_config: FrigateConfig = request.app.frigate_config
request.app.frigate_config = config
request.app.genai_manager.update_config(config)
if body.update_topic:
if body.update_topic.startswith("config/cameras/"):
_, _, camera, field = body.update_topic.split("/")
if field == "add":
settings = config.cameras[camera]
elif field == "remove":
settings = old_config.cameras[camera]
else:
settings = config.get_nested_object(body.update_topic)
request.app.config_publisher.publish_update(
CameraConfigUpdateTopic(CameraConfigUpdateEnum[field], camera),
settings,
)
else:
# Generic handling for global config updates
settings = config.get_nested_object(body.update_topic)
# Publish None for removal, actual config for add/update
request.app.config_publisher.publisher.publish(
body.update_topic, settings
)
return JSONResponse(
content=(
{
"success": True,
"message": "Config successfully updated, restart to apply",
}
),
status_code=200,
)
@router.get("/vainfo", dependencies=[Depends(allow_any_authenticated())])
def vainfo():

View File

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

View File

@@ -15,6 +15,7 @@ from frigate.config.camera.updater import (
CameraConfigUpdatePublisher,
CameraConfigUpdateTopic,
)
from frigate.config.config import RuntimeFilterConfig, RuntimeMotionConfig
from frigate.const import (
CLEAR_ONGOING_REVIEW_SEGMENTS,
EXPIRE_AUDIO_ACTIVITY,
@@ -84,6 +85,9 @@ class Dispatcher:
"review_detections": self._on_detections_command,
"object_descriptions": self._on_object_description_command,
"review_descriptions": self._on_review_description_command,
"motion_mask": self._on_motion_mask_command,
"object_mask": self._on_object_mask_command,
"zone": self._on_zone_command,
}
self._global_settings_handlers: dict[str, Callable] = {
"notifications": self._on_global_notification_command,
@@ -100,11 +104,20 @@ class Dispatcher:
"""Handle receiving of payload from communicators."""
def handle_camera_command(
command_type: str, camera_name: str, command: str, payload: str
command_type: str,
camera_name: str,
command: str,
payload: str,
sub_command: str | None = None,
) -> None:
try:
if command_type == "set":
self._camera_settings_handlers[command](camera_name, payload)
if sub_command:
self._camera_settings_handlers[command](
camera_name, sub_command, payload
)
else:
self._camera_settings_handlers[command](camera_name, payload)
elif command_type == "ptz":
self._on_ptz_command(camera_name, payload)
except KeyError:
@@ -314,6 +327,14 @@ class Dispatcher:
camera_name = parts[-3]
command = parts[-2]
handle_camera_command("set", camera_name, command, payload)
elif len(parts) == 4 and topic.endswith("set"):
# example /cam_name/motion_mask/mask_name/set payload=ON|OFF
camera_name = parts[-4]
command = parts[-3]
sub_command = parts[-2]
handle_camera_command(
"set", camera_name, command, payload, sub_command
)
elif len(parts) == 2 and topic.endswith("set"):
command = parts[-2]
self._global_settings_handlers[command](payload)
@@ -858,3 +879,149 @@ class Dispatcher:
genai_settings,
)
self.publish(f"{camera_name}/review_descriptions/state", payload, retain=True)
def _on_motion_mask_command(
self, camera_name: str, mask_name: str, payload: str
) -> None:
"""Callback for motion mask topic."""
if payload not in ["ON", "OFF"]:
logger.error(f"Invalid payload for motion mask {mask_name}: {payload}")
return
motion_settings = self.config.cameras[camera_name].motion
if mask_name not in motion_settings.mask:
logger.error(f"Unknown motion mask: {mask_name}")
return
mask = motion_settings.mask[mask_name]
if not mask:
logger.error(f"Motion mask {mask_name} is None")
return
if payload == "ON":
if not mask.enabled_in_config:
logger.error(
f"Motion mask {mask_name} must be enabled in the config to be turned on via MQTT."
)
return
mask.enabled = payload == "ON"
# Recreate RuntimeMotionConfig to update rasterized_mask
motion_settings = RuntimeMotionConfig(
frame_shape=self.config.cameras[camera_name].frame_shape,
**motion_settings.model_dump(exclude_unset=True),
)
# Update the dispatcher's own config
self.config.cameras[camera_name].motion = motion_settings
self.config_updater.publish_update(
CameraConfigUpdateTopic(CameraConfigUpdateEnum.motion, camera_name),
motion_settings,
)
self.publish(
f"{camera_name}/motion_mask/{mask_name}/state", payload, retain=True
)
def _on_object_mask_command(
self, camera_name: str, mask_name: str, payload: str
) -> None:
"""Callback for object mask topic."""
if payload not in ["ON", "OFF"]:
logger.error(f"Invalid payload for object mask {mask_name}: {payload}")
return
object_settings = self.config.cameras[camera_name].objects
# Check if this is a global mask
mask_found = False
if mask_name in object_settings.mask:
mask = object_settings.mask[mask_name]
if mask:
if payload == "ON":
if not mask.enabled_in_config:
logger.error(
f"Object mask {mask_name} must be enabled in the config to be turned on via MQTT."
)
return
mask.enabled = payload == "ON"
mask_found = True
# Check if this is a per-object filter mask
for object_name, filter_config in object_settings.filters.items():
if mask_name in filter_config.mask:
mask = filter_config.mask[mask_name]
if mask:
if payload == "ON":
if not mask.enabled_in_config:
logger.error(
f"Object mask {mask_name} must be enabled in the config to be turned on via MQTT."
)
return
mask.enabled = payload == "ON"
mask_found = True
if not mask_found:
logger.error(f"Unknown object mask: {mask_name}")
return
# Recreate RuntimeFilterConfig for each object filter to update rasterized_mask
for object_name, filter_config in object_settings.filters.items():
# Merge global object masks with per-object filter masks
merged_mask = dict(filter_config.mask) # Copy filter-specific masks
# Add global object masks if they exist
if object_settings.mask:
for global_mask_id, global_mask_config in object_settings.mask.items():
# Use a global prefix to avoid key collisions
global_mask_id_prefixed = f"global_{global_mask_id}"
merged_mask[global_mask_id_prefixed] = global_mask_config
object_settings.filters[object_name] = RuntimeFilterConfig(
frame_shape=self.config.cameras[camera_name].frame_shape,
mask=merged_mask,
**filter_config.model_dump(
exclude_unset=True, exclude={"mask", "raw_mask"}
),
)
# Update the dispatcher's own config
self.config.cameras[camera_name].objects = object_settings
self.config_updater.publish_update(
CameraConfigUpdateTopic(CameraConfigUpdateEnum.objects, camera_name),
object_settings,
)
self.publish(
f"{camera_name}/object_mask/{mask_name}/state", payload, retain=True
)
def _on_zone_command(self, camera_name: str, zone_name: str, payload: str) -> None:
"""Callback for zone topic."""
if payload not in ["ON", "OFF"]:
logger.error(f"Invalid payload for zone {zone_name}: {payload}")
return
camera_config = self.config.cameras[camera_name]
if zone_name not in camera_config.zones:
logger.error(f"Unknown zone: {zone_name}")
return
if payload == "ON":
if not camera_config.zones[zone_name].enabled_in_config:
logger.error(
f"Zone {zone_name} must be enabled in the config to be turned on via MQTT."
)
return
camera_config.zones[zone_name].enabled = payload == "ON"
self.config_updater.publish_update(
CameraConfigUpdateTopic(CameraConfigUpdateEnum.zones, camera_name),
camera_config.zones,
)
self.publish(f"{camera_name}/zone/{zone_name}/state", payload, retain=True)

View File

@@ -133,6 +133,29 @@ class MqttClient(Communicator):
retain=True,
)
for mask_name, motion_mask in camera.motion.mask.items():
if motion_mask:
self.publish(
f"{camera_name}/motion_mask/{mask_name}/state",
"ON" if motion_mask.enabled else "OFF",
retain=True,
)
for mask_name, object_mask in camera.objects.mask.items():
if object_mask:
self.publish(
f"{camera_name}/object_mask/{mask_name}/state",
"ON" if object_mask.enabled else "OFF",
retain=True,
)
for zone_name, zone in camera.zones.items():
self.publish(
f"{camera_name}/zone/{zone_name}/state",
"ON" if zone.enabled else "OFF",
retain=True,
)
if self.config.notifications.enabled_in_config:
self.publish(
"notifications/state",
@@ -242,6 +265,24 @@ class MqttClient(Communicator):
self.on_mqtt_command,
)
for mask_name in self.config.cameras[name].motion.mask.keys():
self.client.message_callback_add(
f"{self.mqtt_config.topic_prefix}/{name}/motion_mask/{mask_name}/set",
self.on_mqtt_command,
)
for mask_name in self.config.cameras[name].objects.mask.keys():
self.client.message_callback_add(
f"{self.mqtt_config.topic_prefix}/{name}/object_mask/{mask_name}/set",
self.on_mqtt_command,
)
for zone_name in self.config.cameras[name].zones.keys():
self.client.message_callback_add(
f"{self.mqtt_config.topic_prefix}/{name}/zone/{zone_name}/set",
self.on_mqtt_command,
)
if self.config.notifications.enabled_in_config:
self.client.message_callback_add(
f"{self.mqtt_config.topic_prefix}/notifications/set",

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

View File

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

View File

@@ -18,6 +18,14 @@ class ZoneConfig(BaseModel):
title="Zone name",
description="A user-friendly name for the zone, displayed in the Frigate UI. If not set, a formatted version of the zone name will be used.",
)
enabled: bool = Field(
default=True,
title="Enabled",
description="Enable or disable this zone. Disabled zones are ignored at runtime.",
)
enabled_in_config: Optional[bool] = Field(
default=None, title="Keep track of original state of zone."
)
filters: dict[str, FilterConfig] = Field(
default_factory=dict,
title="Zone filters",

View File

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

View File

@@ -1220,7 +1220,7 @@ class LicensePlateProcessingMixin:
rgb = cv2.cvtColor(frame, cv2.COLOR_YUV2BGR_I420)
# apply motion mask
rgb[self.config.cameras[obj_data].motion.mask == 0] = [0, 0, 0]
rgb[self.config.cameras[obj_data].motion.rasterized_mask == 0] = [0, 0, 0]
if WRITE_DEBUG_IMAGES:
cv2.imwrite(
@@ -1324,7 +1324,7 @@ class LicensePlateProcessingMixin:
rgb = cv2.cvtColor(frame, cv2.COLOR_YUV2BGR_I420)
# apply motion mask
rgb[self.config.cameras[camera].motion.mask == 0] = [0, 0, 0]
rgb[self.config.cameras[camera].motion.rasterized_mask == 0] = [0, 0, 0]
left, top, right, bottom = car_box
car = rgb[top:bottom, left:right]

View File

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

View File

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

View File

@@ -116,7 +116,9 @@ class PtzMotionEstimator:
mask[y1:y2, x1:x2] = 0
# merge camera config motion mask with detections. Norfair function needs 0,1 mask
mask = np.bitwise_and(mask, self.camera_config.motion.mask).clip(max=1)
mask = np.bitwise_and(mask, self.camera_config.motion.rasterized_mask).clip(
max=1
)
# Norfair estimator function needs color so it can convert it right back to gray
frame = cv2.cvtColor(frame, cv2.COLOR_GRAY2BGRA)

View File

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

View File

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

View File

@@ -434,6 +434,55 @@ def migrate_017_0(config: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]
return new_config
def _convert_legacy_mask_to_dict(
mask: Optional[Union[str, list]], mask_type: str = "motion_mask", label: str = ""
) -> dict[str, dict[str, Any]]:
"""Convert legacy mask format (str or list[str]) to new dict format.
Args:
mask: Legacy mask format (string or list of strings)
mask_type: Type of mask for naming ("motion_mask" or "object_mask")
label: Optional label for object masks (e.g., "person")
Returns:
Dictionary with mask_id as key and mask config as value
"""
if not mask:
return {}
result = {}
if isinstance(mask, str):
if mask:
mask_id = f"{mask_type}_1"
friendly_name = (
f"Object Mask 1 ({label})"
if label
else f"{mask_type.replace('_', ' ').title()} 1"
)
result[mask_id] = {
"friendly_name": friendly_name,
"enabled": True,
"coordinates": mask,
}
elif isinstance(mask, list):
for i, coords in enumerate(mask):
if coords:
mask_id = f"{mask_type}_{i + 1}"
friendly_name = (
f"Object Mask {i + 1} ({label})"
if label
else f"{mask_type.replace('_', ' ').title()} {i + 1}"
)
result[mask_id] = {
"friendly_name": friendly_name,
"enabled": True,
"coordinates": coords,
}
return result
def migrate_018_0(config: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]]:
"""Handle migrating frigate config to 0.18-0"""
new_config = config.copy()
@@ -459,7 +508,35 @@ def migrate_018_0(config: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]
if not new_config.get("record"):
del new_config["record"]
# Remove deprecated sync_recordings and timelapse_args from camera-specific record configs
# Migrate global motion masks
global_motion = new_config.get("motion", {})
if global_motion and "mask" in global_motion:
mask = global_motion.get("mask")
if mask is not None and not isinstance(mask, dict):
new_config["motion"]["mask"] = _convert_legacy_mask_to_dict(
mask, "motion_mask"
)
# Migrate global object masks
global_objects = new_config.get("objects", {})
if global_objects and "mask" in global_objects:
mask = global_objects.get("mask")
if mask is not None and not isinstance(mask, dict):
new_config["objects"]["mask"] = _convert_legacy_mask_to_dict(
mask, "object_mask"
)
# Migrate global object filters masks
if global_objects and "filters" in global_objects:
for obj_name, filter_config in global_objects.get("filters", {}).items():
if isinstance(filter_config, dict) and "mask" in filter_config:
mask = filter_config.get("mask")
if mask is not None and not isinstance(mask, dict):
new_config["objects"]["filters"][obj_name]["mask"] = (
_convert_legacy_mask_to_dict(mask, "object_mask", obj_name)
)
# Remove deprecated sync_recordings and migrate masks for camera-specific configs
for name, camera in config.get("cameras", {}).items():
camera_config: dict[str, dict[str, Any]] = camera.copy()
@@ -478,6 +555,34 @@ def migrate_018_0(config: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]
if not camera_config.get("record"):
del camera_config["record"]
# Migrate camera motion masks
camera_motion = camera_config.get("motion", {})
if camera_motion and "mask" in camera_motion:
mask = camera_motion.get("mask")
if mask is not None and not isinstance(mask, dict):
camera_config["motion"]["mask"] = _convert_legacy_mask_to_dict(
mask, "motion_mask"
)
# Migrate camera global object masks
camera_objects = camera_config.get("objects", {})
if camera_objects and "mask" in camera_objects:
mask = camera_objects.get("mask")
if mask is not None and not isinstance(mask, dict):
camera_config["objects"]["mask"] = _convert_legacy_mask_to_dict(
mask, "object_mask"
)
# Migrate camera object filter masks
if camera_objects and "filters" in camera_objects:
for obj_name, filter_config in camera_objects.get("filters", {}).items():
if isinstance(filter_config, dict) and "mask" in filter_config:
mask = filter_config.get("mask")
if mask is not None and not isinstance(mask, dict):
camera_config["objects"]["filters"][obj_name]["mask"] = (
_convert_legacy_mask_to_dict(mask, "object_mask", obj_name)
)
new_config["cameras"][name] = camera_config
new_config["version"] = "0.18-0"

View File

@@ -248,20 +248,20 @@ def is_object_filtered(obj, objects_to_track, object_filters):
if obj_settings.max_ratio < object_ratio:
return True
if obj_settings.mask is not None:
if obj_settings.rasterized_mask is not None:
# compute the coordinates of the object and make sure
# the location isn't outside the bounds of the image (can happen from rounding)
object_xmin = object_box[0]
object_xmax = object_box[2]
object_ymax = object_box[3]
y_location = min(int(object_ymax), len(obj_settings.mask) - 1)
y_location = min(int(object_ymax), len(obj_settings.rasterized_mask) - 1)
x_location = min(
int((object_xmax + object_xmin) / 2.0),
len(obj_settings.mask[0]) - 1,
len(obj_settings.rasterized_mask[0]) - 1,
)
# if the object is in a masked location, don't add it to detected objects
if obj_settings.mask[y_location][x_location] == 0:
if obj_settings.rasterized_mask[y_location][x_location] == 0:
return True
return False

View File

@@ -348,6 +348,9 @@
"label": "Object mask",
"description": "Mask polygon used to prevent object detection in specified areas."
},
"raw_mask": {
"label": "Raw Mask"
},
"genai": {
"label": "GenAI object config",
"description": "GenAI options for describing tracked objects and sending frames for generation.",
@@ -860,6 +863,12 @@
"label": "Zone name",
"description": "A user-friendly name for the zone, displayed in the Frigate UI. If not set, a formatted version of the zone name will be used."
},
"enabled": {
"label": "Whether this zone is active. Disabled zones are ignored at runtime."
},
"enabled_in_config": {
"label": "Keep track of original state of zone."
},
"filters": {
"label": "Zone filters",
"description": "Filters to apply to objects within this zone. Used to reduce false positives or restrict which objects are considered present in the zone.",

View File

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

View File

@@ -505,6 +505,7 @@
"all": "All Masks and Zones"
},
"restart_required": "Restart required (masks/zones changed)",
"disabledInConfig": "Item is disabled in the config file",
"toast": {
"success": {
"copyCoordinates": "Copied coordinates for {{polyName}} to clipboard."
@@ -514,7 +515,7 @@
}
},
"motionMaskLabel": "Motion Mask {{number}}",
"objectMaskLabel": "Object Mask {{number}} ({{label}})",
"objectMaskLabel": "Object Mask {{number}}",
"form": {
"zoneName": {
"error": {
@@ -588,6 +589,10 @@
"inputPlaceHolder": "Enter a name…",
"tips": "Name must be at least 2 characters, must have at least one letter, and must not be the name of a camera or another zone on this camera."
},
"enabled": {
"title": "Enabled",
"description": "Whether this zone is active and enabled in the config file. If disabled, it cannot be enabled by MQTT. Disabled zones are ignored at runtime."
},
"inertia": {
"title": "Inertia",
"desc": "Specifies how many frames that an object must be in a zone before they are considered in the zone. <em>Default: 3</em>"
@@ -632,12 +637,18 @@
},
"add": "New Motion Mask",
"edit": "Edit Motion Mask",
"defaultName": "Motion Mask {{number}}",
"context": {
"title": "Motion masks are used to prevent unwanted types of motion from triggering detection (example: tree branches, camera timestamps). Motion masks should be used <em>very sparingly</em>, over-masking will make it more difficult for objects to be tracked."
},
"point_one": "{{count}} point",
"point_other": "{{count}} points",
"clickDrawPolygon": "Click to draw a polygon on the image.",
"name": {
"title": "Name",
"description": "An optional friendly name for this motion mask.",
"placeholder": "Enter a name..."
},
"polygonAreaTooLarge": {
"title": "The motion mask is covering {{polygonArea}}% of the camera frame. Large motion masks are not recommended.",
"tips": "Motion masks do not prevent objects from being detected. You should use a required zone instead."
@@ -662,6 +673,11 @@
"point_one": "{{count}} point",
"point_other": "{{count}} points",
"clickDrawPolygon": "Click to draw a polygon on the image.",
"name": {
"title": "Name",
"description": "An optional friendly name for this object mask.",
"placeholder": "Enter a name..."
},
"objects": {
"title": "Objects",
"desc": "The object type that applies to this object mask.",
@@ -673,6 +689,12 @@
"noName": "Object Mask has been saved."
}
}
},
"masks": {
"enabled": {
"title": "Enabled",
"description": "Whether this mask is enabled in the config file. If disabled, it cannot be enabled by MQTT. Disabled masks are ignored at runtime."
}
}
},
"motionDetectionTuner": {

View File

@@ -304,6 +304,57 @@ export function useReviewDescriptionState(camera: string): {
return { payload: payload as ToggleableSetting, send };
}
export function useMotionMaskState(
camera: string,
maskName: string,
): {
payload: ToggleableSetting;
send: (payload: ToggleableSetting, retain?: boolean) => void;
} {
const {
value: { payload },
send,
} = useWs(
`${camera}/motion_mask/${maskName}/state`,
`${camera}/motion_mask/${maskName}/set`,
);
return { payload: payload as ToggleableSetting, send };
}
export function useObjectMaskState(
camera: string,
maskName: string,
): {
payload: ToggleableSetting;
send: (payload: ToggleableSetting, retain?: boolean) => void;
} {
const {
value: { payload },
send,
} = useWs(
`${camera}/object_mask/${maskName}/state`,
`${camera}/object_mask/${maskName}/set`,
);
return { payload: payload as ToggleableSetting, send };
}
export function useZoneState(
camera: string,
zoneName: string,
): {
payload: ToggleableSetting;
send: (payload: ToggleableSetting, retain?: boolean) => void;
} {
const {
value: { payload },
send,
} = useWs(
`${camera}/zone/${zoneName}/state`,
`${camera}/zone/${zoneName}/set`,
);
return { payload: payload as ToggleableSetting, send };
}
export function usePtzCommand(camera: string): {
payload: string;
send: (payload: string, retain?: boolean) => void;

View File

@@ -1,21 +1,25 @@
import Heading from "../ui/heading";
import { Separator } from "../ui/separator";
import { Button } from "@/components/ui/button";
import { Form, FormField, FormItem, FormMessage } from "@/components/ui/form";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { useCallback, useEffect, useMemo } from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { useForm, FormProvider } from "react-hook-form";
import { z } from "zod";
import PolygonEditControls from "./PolygonEditControls";
import { FaCheckCircle } from "react-icons/fa";
import { Polygon } from "@/types/canvas";
import { MotionMaskFormValuesType, Polygon } from "@/types/canvas";
import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig";
import {
flattenPoints,
interpolatePoints,
parseCoordinates,
} from "@/utils/canvasUtil";
import { flattenPoints, interpolatePoints } from "@/utils/canvasUtil";
import axios from "axios";
import { toast } from "sonner";
import { Toaster } from "../ui/sonner";
@@ -24,6 +28,9 @@ import { Link } from "react-router-dom";
import { LuExternalLink } from "react-icons/lu";
import { Trans, useTranslation } from "react-i18next";
import { useDocDomain } from "@/hooks/use-doc-domain";
import NameAndIdFields from "../input/NameAndIdFields";
import { Switch } from "../ui/switch";
import { useMotionMaskState } from "@/api/ws";
type MotionMaskEditPaneProps = {
polygons?: Polygon[];
@@ -65,6 +72,11 @@ export default function MotionMaskEditPane({
}
}, [polygons, activePolygonIndex]);
const { send: sendMotionMaskState } = useMotionMaskState(
polygon?.camera || "",
polygon?.name || "",
);
const cameraConfig = useMemo(() => {
if (polygon?.camera && config) {
return config.cameras[polygon.camera];
@@ -73,12 +85,24 @@ export default function MotionMaskEditPane({
const defaultName = useMemo(() => {
if (!polygons) {
return;
return "";
}
const count = polygons.filter((poly) => poly.type == "motion_mask").length;
return `Motion Mask ${count + 1}`;
return t("masksAndZones.motionMasks.defaultName", {
number: count,
});
}, [polygons, t]);
const defaultId = useMemo(() => {
if (!polygons) {
return "";
}
const count = polygons.filter((poly) => poly.type == "motion_mask").length;
return `motion_mask_${count}`;
}, [polygons]);
const polygonArea = useMemo(() => {
@@ -104,116 +128,157 @@ export default function MotionMaskEditPane({
}
}, [polygon, scaledWidth, scaledHeight]);
const formSchema = z
.object({
polygon: z.object({ name: z.string(), isFinished: z.boolean() }),
})
.refine(() => polygon?.isFinished === true, {
const formSchema = z.object({
name: z
.string()
.min(1, {
message: t("masksAndZones.form.id.error.mustNotBeEmpty"),
})
.refine(
(value: string) => {
// When editing, allow the same name
if (polygon?.name && value === polygon.name) {
return true;
}
// Check if mask ID already exists
const existingMaskIds = Object.keys(cameraConfig?.motion.mask || {});
return !existingMaskIds.includes(value);
},
{
message: t("masksAndZones.form.id.error.alreadyExists"),
},
),
friendly_name: z.string().min(1, {
message: t("masksAndZones.form.name.error.mustNotBeEmpty"),
}),
enabled: z.boolean(),
isFinished: z.boolean().refine(() => polygon?.isFinished === true, {
message: t("masksAndZones.form.polygonDrawing.error.mustBeFinished"),
path: ["polygon.isFinished"],
});
}),
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
mode: "onChange",
defaultValues: {
polygon: { isFinished: polygon?.isFinished ?? false, name: defaultName },
name: polygon?.name || defaultId,
friendly_name: polygon?.friendly_name || defaultName,
enabled: polygon?.enabled ?? true,
isFinished: polygon?.isFinished ?? false,
},
});
const saveToConfig = useCallback(async () => {
if (!scaledWidth || !scaledHeight || !polygon || !cameraConfig) {
return;
}
const saveToConfig = useCallback(
async ({
name: maskId,
friendly_name,
enabled,
}: MotionMaskFormValuesType) => {
if (!scaledWidth || !scaledHeight || !polygon || !cameraConfig) {
return;
}
const coordinates = flattenPoints(
interpolatePoints(polygon.points, scaledWidth, scaledHeight, 1, 1),
).join(",");
const coordinates = flattenPoints(
interpolatePoints(polygon.points, scaledWidth, scaledHeight, 1, 1),
).join(",");
let index = Array.isArray(cameraConfig.motion.mask)
? cameraConfig.motion.mask.length
: cameraConfig.motion.mask
? 1
: 0;
const editingMask = polygon.name.length > 0;
const renamingMask = editingMask && maskId !== polygon.name;
const editingMask = polygon.name.length > 0;
// Build the new mask configuration
const maskConfig = {
friendly_name: friendly_name,
enabled: enabled,
coordinates: coordinates,
};
// editing existing mask, not creating a new one
if (editingMask) {
index = polygon.typeIndex;
}
const filteredMask = (
Array.isArray(cameraConfig.motion.mask)
? cameraConfig.motion.mask
: [cameraConfig.motion.mask]
).filter((_, currentIndex) => currentIndex !== index);
filteredMask.splice(index, 0, coordinates);
const queryString = filteredMask
.map((pointsArray) => {
const coordinates = flattenPoints(parseCoordinates(pointsArray)).join(
",",
);
return `cameras.${polygon?.camera}.motion.mask=${coordinates}&`;
})
.join("");
axios
.put(`config/set?${queryString}`, {
requires_restart: 0,
update_topic: `config/cameras/${polygon.camera}/motion`,
})
.then((res) => {
if (res.status === 200) {
toast.success(
polygon.name
? t("masksAndZones.motionMasks.toast.success.title", {
polygonName: polygon.name,
})
: t("masksAndZones.motionMasks.toast.success.noName"),
// If renaming, we need to delete the old mask first
if (renamingMask) {
try {
await axios.put(
`config/set?cameras.${polygon.camera}.motion.mask.${polygon.name}`,
{
position: "top-center",
requires_restart: 0,
},
);
updateConfig();
} else {
toast.error(
t("toast.save.error.title", {
errorMessage: res.statusText,
ns: "common",
}),
{
position: "top-center",
},
);
}
})
.catch((error) => {
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(
t("toast.save.error.title", { errorMessage, ns: "common" }),
{
} catch (error) {
toast.error(t("toast.save.error.noMessage", { ns: "common" }), {
position: "top-center",
});
setIsLoading(false);
return;
}
}
// Save the new/updated mask using JSON body
axios
.put("config/set", {
config_data: {
cameras: {
[polygon.camera]: {
motion: {
mask: {
[maskId]: maskConfig,
},
},
},
},
},
);
})
.finally(() => {
setIsLoading(false);
});
}, [
updateConfig,
polygon,
scaledWidth,
scaledHeight,
setIsLoading,
cameraConfig,
t,
]);
requires_restart: 0,
update_topic: `config/cameras/${polygon.camera}/motion`,
})
.then((res) => {
if (res.status === 200) {
toast.success(
t("masksAndZones.motionMasks.toast.success.title", {
polygonName: friendly_name || maskId,
}),
{
position: "top-center",
},
);
updateConfig();
// Publish the enabled state through websocket
sendMotionMaskState(enabled ? "ON" : "OFF");
} else {
toast.error(
t("toast.save.error.title", {
errorMessage: res.statusText,
ns: "common",
}),
{
position: "top-center",
},
);
}
})
.catch((error) => {
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(
t("toast.save.error.title", { errorMessage, ns: "common" }),
{
position: "top-center",
},
);
})
.finally(() => {
setIsLoading(false);
});
},
[
updateConfig,
polygon,
scaledWidth,
scaledHeight,
setIsLoading,
cameraConfig,
t,
sendMotionMaskState,
],
);
function onSubmit(values: z.infer<typeof formSchema>) {
if (activePolygonIndex === undefined || !values || !polygons) {
@@ -221,7 +286,7 @@ export default function MotionMaskEditPane({
}
setIsLoading(true);
saveToConfig();
saveToConfig(values as MotionMaskFormValuesType);
if (onSave) {
onSave();
}
@@ -310,58 +375,83 @@ export default function MotionMaskEditPane({
</>
)}
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="flex flex-1 flex-col space-y-6"
>
<FormField
control={form.control}
name="polygon.name"
render={() => (
<FormItem>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="polygon.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>
<FormProvider {...form}>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="flex flex-1 flex-col space-y-6"
>
<NameAndIdFields
type="motion_mask"
control={form.control}
nameField="friendly_name"
idField="name"
idVisible={(polygon && polygon.name.length > 0) ?? false}
nameLabel={t("masksAndZones.motionMasks.name.title")}
nameDescription={t("masksAndZones.motionMasks.name.description")}
placeholderName={t("masksAndZones.motionMasks.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>
) : (
t("button.save", { ns: "common" })
)}
</Button>
<FormControl>
<Switch
checked={field.value}
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>
</form>
</Form>
</form>
</Form>
</FormProvider>
</>
);
}

View File

@@ -23,22 +23,21 @@ import { useCallback, useEffect, useMemo } from "react";
import { FrigateConfig } from "@/types/frigateConfig";
import useSWR from "swr";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { useForm, FormProvider } from "react-hook-form";
import { z } from "zod";
import { ObjectMaskFormValuesType, Polygon } from "@/types/canvas";
import PolygonEditControls from "./PolygonEditControls";
import { FaCheckCircle } from "react-icons/fa";
import {
flattenPoints,
interpolatePoints,
parseCoordinates,
} from "@/utils/canvasUtil";
import { flattenPoints, interpolatePoints } from "@/utils/canvasUtil";
import axios from "axios";
import { toast } from "sonner";
import { Toaster } from "../ui/sonner";
import ActivityIndicator from "../indicators/activity-indicator";
import { useTranslation } from "react-i18next";
import { getTranslatedLabel } from "@/utils/i18n";
import NameAndIdFields from "../input/NameAndIdFields";
import { Switch } from "../ui/switch";
import { useObjectMaskState } from "@/api/ws";
type ObjectMaskEditPaneProps = {
polygons?: Polygon[];
@@ -79,6 +78,11 @@ export default function ObjectMaskEditPane({
}
}, [polygons, activePolygonIndex]);
const { send: sendObjectMaskState } = useObjectMaskState(
polygon?.camera || "",
polygon?.name || "",
);
const cameraConfig = useMemo(() => {
if (polygon?.camera && config) {
return config.cameras[polygon.camera];
@@ -87,48 +91,80 @@ export default function ObjectMaskEditPane({
const defaultName = useMemo(() => {
if (!polygons) {
return;
return "";
}
const count = polygons.filter((poly) => poly.type == "object_mask").length;
let objectType = "";
const objects = polygon?.objects[0];
if (objects === undefined) {
objectType = "all objects";
} else {
objectType = objects;
return t("masksAndZones.objectMaskLabel", {
number: count,
});
}, [polygons, t]);
const defaultId = useMemo(() => {
if (!polygons) {
return "";
}
return t("masksAndZones.objectMaskLabel", {
number: count + 1,
label: getTranslatedLabel(objectType),
});
}, [polygons, polygon, t]);
const count = polygons.filter((poly) => poly.type == "object_mask").length;
const formSchema = z
.object({
objects: z.string(),
polygon: z.object({ isFinished: z.boolean(), name: z.string() }),
})
.refine(() => polygon?.isFinished === true, {
return `object_mask_${count}`;
}, [polygons]);
const formSchema = z.object({
name: z
.string()
.min(1, {
message: t("masksAndZones.form.id.error.mustNotBeEmpty"),
})
.refine(
(value: string) => {
// When editing, allow the same name
if (polygon?.name && value === polygon.name) {
return true;
}
// Check if mask ID already exists in global masks or filter masks
const globalMaskIds = Object.keys(cameraConfig?.objects.mask || {});
const filterMaskIds = Object.values(
cameraConfig?.objects.filters || {},
).flatMap((filter) => Object.keys(filter.mask || {}));
return (
!globalMaskIds.includes(value) && !filterMaskIds.includes(value)
);
},
{
message: t("masksAndZones.form.id.error.alreadyExists"),
},
),
friendly_name: z.string().min(1, {
message: t("masksAndZones.form.name.error.mustNotBeEmpty"),
}),
enabled: z.boolean(),
objects: z.string(),
isFinished: z.boolean().refine(() => polygon?.isFinished === true, {
message: t("masksAndZones.form.polygonDrawing.error.mustBeFinished"),
path: ["polygon.isFinished"],
});
}),
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
mode: "onChange",
defaultValues: {
name: polygon?.name || defaultId,
friendly_name: polygon?.friendly_name || defaultName,
enabled: polygon?.enabled ?? true,
objects: polygon?.objects[0] ?? "all_labels",
polygon: { isFinished: polygon?.isFinished ?? false, name: defaultName },
isFinished: polygon?.isFinished ?? false,
},
});
const saveToConfig = useCallback(
async (
{ objects: form_objects }: ObjectMaskFormValuesType, // values submitted via the form
) => {
async ({
name: maskId,
friendly_name,
enabled,
objects: form_objects,
}: ObjectMaskFormValuesType) => {
if (!scaledWidth || !scaledHeight || !polygon || !cameraConfig) {
return;
}
@@ -137,93 +173,94 @@ export default function ObjectMaskEditPane({
interpolatePoints(polygon.points, scaledWidth, scaledHeight, 1, 1),
).join(",");
let queryString = "";
let configObject;
let createFilter = false;
let globalMask = false;
let filteredMask = [coordinates];
const editingMask = polygon.name.length > 0;
const renamingMask = editingMask && maskId !== polygon.name;
const globalMask = form_objects === "all_labels";
// global mask on camera for all objects
if (form_objects == "all_labels") {
configObject = cameraConfig.objects.mask;
globalMask = true;
// Build the mask configuration
const maskConfig = {
friendly_name: friendly_name,
enabled: enabled,
coordinates: coordinates,
};
// If renaming, delete the old mask first
if (renamingMask) {
try {
// Determine if old mask was global or per-object
const wasGlobal =
polygon.objects.length === 0 || polygon.objects[0] === "all_labels";
const oldPath = wasGlobal
? `cameras.${polygon.camera}.objects.mask.${polygon.name}`
: `cameras.${polygon.camera}.objects.filters.${polygon.objects[0]}.mask.${polygon.name}`;
await axios.put(`config/set?${oldPath}`, {
requires_restart: 0,
});
} catch (error) {
toast.error(t("toast.save.error.noMessage", { ns: "common" }), {
position: "top-center",
});
setIsLoading(false);
return;
}
}
// Build the config structure based on whether it's global or per-object
let configBody;
if (globalMask) {
configBody = {
config_data: {
cameras: {
[polygon.camera]: {
objects: {
mask: {
[maskId]: maskConfig,
},
},
},
},
},
requires_restart: 0,
update_topic: `config/cameras/${polygon.camera}/objects`,
};
} else {
if (
cameraConfig.objects.filters[form_objects] &&
cameraConfig.objects.filters[form_objects].mask !== null
) {
configObject = cameraConfig.objects.filters[form_objects].mask;
} else {
createFilter = true;
}
}
if (!createFilter) {
let index = Array.isArray(configObject)
? configObject.length
: configObject
? 1
: 0;
// editing existing mask, not creating a new one
if (editingMask) {
index = polygon.typeIndex;
}
filteredMask = (
Array.isArray(configObject) ? configObject : [configObject as string]
).filter((_, currentIndex) => currentIndex !== index);
filteredMask.splice(index, 0, coordinates);
}
// prevent duplicating global masks under specific object filters
if (!globalMask) {
const globalObjectMasksArray = Array.isArray(cameraConfig.objects.mask)
? cameraConfig.objects.mask
: cameraConfig.objects.mask
? [cameraConfig.objects.mask]
: [];
filteredMask = filteredMask.filter(
(mask) => !globalObjectMasksArray.includes(mask),
);
}
queryString = filteredMask
.map((pointsArray) => {
const coordinates = flattenPoints(parseCoordinates(pointsArray)).join(
",",
);
return globalMask
? `cameras.${polygon?.camera}.objects.mask=${coordinates}&`
: `cameras.${polygon?.camera}.objects.filters.${form_objects}.mask=${coordinates}&`;
})
.join("");
if (!queryString) {
return;
configBody = {
config_data: {
cameras: {
[polygon.camera]: {
objects: {
filters: {
[form_objects]: {
mask: {
[maskId]: maskConfig,
},
},
},
},
},
},
},
requires_restart: 0,
update_topic: `config/cameras/${polygon.camera}/objects`,
};
}
axios
.put(`config/set?${queryString}`, {
requires_restart: 0,
update_topic: `config/cameras/${polygon.camera}/objects`,
})
.put("config/set", configBody)
.then((res) => {
if (res.status === 200) {
toast.success(
polygon.name
? t("masksAndZones.objectMasks.toast.success.title", {
polygonName: polygon.name,
})
: t("masksAndZones.objectMasks.toast.success.noName"),
t("masksAndZones.objectMasks.toast.success.title", {
polygonName: friendly_name || maskId,
}),
{
position: "top-center",
},
);
updateConfig();
// Publish the enabled state through websocket
sendObjectMaskState(enabled ? "ON" : "OFF");
} else {
toast.error(
t("toast.save.error.title", {
@@ -263,6 +300,7 @@ export default function ObjectMaskEditPane({
setIsLoading,
cameraConfig,
t,
sendObjectMaskState,
],
);
@@ -323,89 +361,118 @@ export default function ObjectMaskEditPane({
<Separator className="my-3 bg-secondary" />
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="flex flex-1 flex-col space-y-6"
>
<div>
<FormField
control={form.control}
name="polygon.name"
render={() => (
<FormItem>
<FormMessage />
</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="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" })
<FormProvider {...form}>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="flex flex-1 flex-col space-y-6"
>
<div className="space-y-4">
<NameAndIdFields
type="object_mask"
control={form.control}
nameField="friendly_name"
idField="name"
idVisible={(polygon && polygon.name.length > 0) ?? false}
nameLabel={t("masksAndZones.objectMasks.name.title")}
nameDescription={t(
"masksAndZones.objectMasks.name.description",
)}
</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>
</form>
</Form>
<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>
</div>
</div>
</form>
</Form>
</FormProvider>
</>
);
}

View File

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

View File

@@ -24,6 +24,7 @@ type PolygonDrawerProps = {
isActive: boolean;
isHovered: boolean;
isFinished: boolean;
enabled?: boolean;
color: number[];
handlePointDragMove: (e: KonvaEventObject<MouseEvent | TouchEvent>) => void;
handleGroupDragEnd: (e: KonvaEventObject<MouseEvent | TouchEvent>) => void;
@@ -39,6 +40,7 @@ export default function PolygonDrawer({
isActive,
isHovered,
isFinished,
enabled = true,
color,
handlePointDragMove,
handleGroupDragEnd,
@@ -108,9 +110,15 @@ export default function PolygonDrawer({
const colorString = useCallback(
(darkened: boolean) => {
if (!enabled) {
// Slightly desaturate the color when disabled
const avg = (color[0] + color[1] + color[2]) / 3;
const desaturated = color.map((c) => Math.round(c * 0.4 + avg * 0.6));
return toRGBColorString(desaturated, darkened);
}
return toRGBColorString(color, darkened);
},
[color],
[color, enabled],
);
useEffect(() => {
@@ -162,9 +170,11 @@ export default function PolygonDrawer({
points={flattenedPoints}
stroke={colorString(true)}
strokeWidth={3}
dash={enabled ? undefined : [10, 5]}
hitStrokeWidth={12}
closed={isFinished}
fill={colorString(isActive || isHovered ? true : false)}
opacity={enabled ? 1 : 0.85}
onMouseOver={() =>
isActive
? isFinished

View File

@@ -20,11 +20,7 @@ import { FaDrawPolygon, FaObjectGroup } from "react-icons/fa";
import { BsPersonBoundingBox } from "react-icons/bs";
import { HiOutlineDotsVertical, HiTrash } from "react-icons/hi";
import { isDesktop, isMobile } from "react-device-detect";
import {
flattenPoints,
parseCoordinates,
toRGBColorString,
} from "@/utils/canvasUtil";
import { toRGBColorString } from "@/utils/canvasUtil";
import { Polygon, PolygonType } from "@/types/canvas";
import { useCallback, useMemo, useState } from "react";
import axios from "axios";
@@ -36,6 +32,9 @@ import { reviewQueries } from "@/utils/zoneEdutUtil";
import IconWrapper from "../ui/icon-wrapper";
import { buttonVariants } from "../ui/button";
import { Trans, useTranslation } from "react-i18next";
import ActivityIndicator from "../indicators/activity-indicator";
import { cn } from "@/lib/utils";
import { useMotionMaskState, useObjectMaskState, useZoneState } from "@/api/ws";
type PolygonItemProps = {
polygon: Polygon;
@@ -45,6 +44,10 @@ type PolygonItemProps = {
setActivePolygonIndex: (index: number | undefined) => void;
setEditPane: (type: PolygonType) => void;
handleCopyCoordinates: (index: number) => void;
isLoading: boolean;
setIsLoading: (loading: boolean) => void;
loadingPolygonIndex: number | undefined;
setLoadingPolygonIndex: (index: number | undefined) => void;
};
export default function PolygonItem({
@@ -55,12 +58,40 @@ export default function PolygonItem({
setActivePolygonIndex,
setEditPane,
handleCopyCoordinates,
isLoading,
setIsLoading,
loadingPolygonIndex,
setLoadingPolygonIndex,
}: PolygonItemProps) {
const { t } = useTranslation("views/settings");
const { data: config, mutate: updateConfig } =
useSWR<FrigateConfig>("config");
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const { payload: motionMaskState, send: sendMotionMaskState } =
useMotionMaskState(polygon.camera, polygon.name);
const { payload: objectMaskState, send: sendObjectMaskState } =
useObjectMaskState(polygon.camera, polygon.name);
const { payload: zoneState, send: sendZoneState } = useZoneState(
polygon.camera,
polygon.name,
);
const isPolygonEnabled = useMemo(() => {
const wsState =
polygon.type === "zone"
? zoneState
: polygon.type === "motion_mask"
? motionMaskState
: objectMaskState;
const wsEnabled =
wsState === "ON" ? true : wsState === "OFF" ? false : undefined;
return wsEnabled ?? polygon.enabled ?? true;
}, [
polygon.enabled,
polygon.type,
zoneState,
motionMaskState,
objectMaskState,
]);
const cameraConfig = useMemo(() => {
if (polygon?.camera && config) {
@@ -81,93 +112,6 @@ export default function PolygonItem({
if (!polygon || !cameraConfig) {
return;
}
let url = "";
if (polygon.type == "zone") {
const { alertQueries, detectionQueries } = reviewQueries(
polygon.name,
false,
false,
polygon.camera,
cameraConfig?.review.alerts.required_zones || [],
cameraConfig?.review.detections.required_zones || [],
);
url = `cameras.${polygon.camera}.zones.${polygon.name}${alertQueries}${detectionQueries}`;
}
if (polygon.type == "motion_mask") {
const filteredMask = (
Array.isArray(cameraConfig.motion.mask)
? cameraConfig.motion.mask
: [cameraConfig.motion.mask]
).filter((_, currentIndex) => currentIndex !== polygon.typeIndex);
url = filteredMask
.map((pointsArray) => {
const coordinates = flattenPoints(
parseCoordinates(pointsArray),
).join(",");
return `cameras.${polygon?.camera}.motion.mask=${coordinates}&`;
})
.join("");
if (!url) {
// deleting last mask
url = `cameras.${polygon?.camera}.motion.mask&`;
}
}
if (polygon.type == "object_mask") {
let configObject;
let globalMask = false;
// global mask on camera for all objects
if (!polygon.objects.length) {
configObject = cameraConfig.objects.mask;
globalMask = true;
} else {
configObject = cameraConfig.objects.filters[polygon.objects[0]].mask;
}
if (!configObject) {
return;
}
const globalObjectMasksArray = Array.isArray(cameraConfig.objects.mask)
? cameraConfig.objects.mask
: cameraConfig.objects.mask
? [cameraConfig.objects.mask]
: [];
let filteredMask;
if (globalMask) {
filteredMask = (
Array.isArray(configObject) ? configObject : [configObject]
).filter((_, currentIndex) => currentIndex !== polygon.typeIndex);
} else {
filteredMask = (
Array.isArray(configObject) ? configObject : [configObject]
)
.filter((mask) => !globalObjectMasksArray.includes(mask))
.filter((_, currentIndex) => currentIndex !== polygon.typeIndex);
}
url = filteredMask
.map((pointsArray) => {
const coordinates = flattenPoints(
parseCoordinates(pointsArray),
).join(",");
return globalMask
? `cameras.${polygon?.camera}.objects.mask=${coordinates}&`
: `cameras.${polygon?.camera}.objects.filters.${polygon.objects[0]}.mask=${coordinates}&`;
})
.join("");
if (!url) {
// deleting last mask
url = globalMask
? `cameras.${polygon?.camera}.objects.mask&`
: `cameras.${polygon?.camera}.objects.filters.${polygon.objects[0]}.mask`;
}
}
const updateTopicType =
polygon.type === "zone"
@@ -179,9 +123,117 @@ export default function PolygonItem({
: polygon.type;
setIsLoading(true);
setLoadingPolygonIndex(index);
if (polygon.type === "zone") {
// Zones use query string format
const { alertQueries, detectionQueries } = reviewQueries(
polygon.name,
false,
false,
polygon.camera,
cameraConfig?.review.alerts.required_zones || [],
cameraConfig?.review.detections.required_zones || [],
);
const url = `cameras.${polygon.camera}.zones.${polygon.name}${alertQueries}${detectionQueries}`;
await axios
.put(`config/set?${url}`, {
requires_restart: 0,
update_topic: `config/cameras/${polygon.camera}/${updateTopicType}`,
})
.then((res) => {
if (res.status === 200) {
toast.success(
t("masksAndZones.form.polygonDrawing.delete.success", {
name: polygon?.friendly_name ?? polygon?.name,
}),
{ position: "top-center" },
);
updateConfig();
} else {
toast.error(
t("toast.save.error.title", {
ns: "common",
errorMessage: res.statusText,
}),
{ position: "top-center" },
);
}
})
.catch((error) => {
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(
t("toast.save.error.title", { errorMessage, ns: "common" }),
{ position: "top-center" },
);
})
.finally(() => {
setIsLoading(false);
});
return;
}
// Motion masks and object masks use JSON body format
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let configUpdate: any = {};
if (polygon.type === "motion_mask") {
// Delete mask from motion.mask dict by setting it to undefined
configUpdate = {
cameras: {
[polygon.camera]: {
motion: {
mask: {
[polygon.name]: null, // Setting to null will delete the key
},
},
},
},
};
}
if (polygon.type === "object_mask") {
// Determine if this is a global mask or object-specific mask
const isGlobalMask = !polygon.objects.length;
if (isGlobalMask) {
configUpdate = {
cameras: {
[polygon.camera]: {
objects: {
mask: {
[polygon.name]: null, // Setting to null will delete the key
},
},
},
},
};
} else {
configUpdate = {
cameras: {
[polygon.camera]: {
objects: {
filters: {
[polygon.objects[0]]: {
mask: {
[polygon.name]: null, // Setting to null will delete the key
},
},
},
},
},
},
};
}
}
await axios
.put(`config/set?${url}`, {
.put("config/set", {
config_data: configUpdate,
requires_restart: 0,
update_topic: `config/cameras/${polygon.camera}/${updateTopicType}`,
})
@@ -191,9 +243,7 @@ export default function PolygonItem({
t("masksAndZones.form.polygonDrawing.delete.success", {
name: polygon?.friendly_name ?? polygon?.name,
}),
{
position: "top-center",
},
{ position: "top-center" },
);
updateConfig();
} else {
@@ -202,9 +252,7 @@ export default function PolygonItem({
ns: "common",
errorMessage: res.statusText,
}),
{
position: "top-center",
},
{ position: "top-center" },
);
}
})
@@ -215,16 +263,22 @@ export default function PolygonItem({
"Unknown error";
toast.error(
t("toast.save.error.title", { errorMessage, ns: "common" }),
{
position: "top-center",
},
{ position: "top-center" },
);
})
.finally(() => {
setIsLoading(false);
setLoadingPolygonIndex(undefined);
});
},
[updateConfig, cameraConfig, t],
[
updateConfig,
cameraConfig,
t,
setIsLoading,
index,
setLoadingPolygonIndex,
],
);
const handleDelete = () => {
@@ -232,6 +286,43 @@ export default function PolygonItem({
saveToConfig(polygon);
};
const handleToggleEnabled = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
// Prevent toggling if disabled in config
if (polygon.enabled_in_config === false) {
return;
}
if (!polygon) {
return;
}
const isEnabled = isPolygonEnabled;
const nextState = isEnabled ? "OFF" : "ON";
if (polygon.type === "zone") {
sendZoneState(nextState);
return;
}
if (polygon.type === "motion_mask") {
sendMotionMaskState(nextState);
return;
}
if (polygon.type === "object_mask") {
sendObjectMaskState(nextState);
}
},
[
isPolygonEnabled,
polygon,
sendZoneState,
sendMotionMaskState,
sendObjectMaskState,
],
);
return (
<>
<Toaster position="top-center" closeButton={true} />
@@ -256,17 +347,52 @@ export default function PolygonItem({
: "text-primary-variant"
}`}
>
{PolygonItemIcon && (
<PolygonItemIcon
className="mr-2 size-5"
style={{
fill: toRGBColorString(polygon.color, true),
color: toRGBColorString(polygon.color, true),
}}
/>
)}
<p className="cursor-default">
{PolygonItemIcon &&
(isLoading && loadingPolygonIndex === index ? (
<div className="mr-2">
<ActivityIndicator className="size-5" />
</div>
) : (
<Tooltip>
<TooltipTrigger asChild>
<button
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}
{!isPolygonEnabled && " (disabled)"}
</p>
</div>
<AlertDialog
@@ -316,6 +442,7 @@ export default function PolygonItem({
<DropdownMenuContent>
<DropdownMenuItem
aria-label={t("button.edit", { ns: "common" })}
disabled={isLoading}
onClick={() => {
setActivePolygonIndex(index);
setEditPane(polygon.type);
@@ -325,6 +452,7 @@ export default function PolygonItem({
</DropdownMenuItem>
<DropdownMenuItem
aria-label={t("button.copy", { ns: "common" })}
disabled={isLoading}
onClick={() => handleCopyCoordinates(index)}
>
{t("button.copy", { ns: "common" })}
@@ -346,10 +474,17 @@ export default function PolygonItem({
<TooltipTrigger asChild>
<IconWrapper
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={() => {
setActivePolygonIndex(index);
setEditPane(polygon.type);
if (!isLoading) {
setActivePolygonIndex(index);
setEditPane(polygon.type);
}
}}
/>
</TooltipTrigger>
@@ -362,10 +497,16 @@ export default function PolygonItem({
<TooltipTrigger asChild>
<IconWrapper
icon={LuCopy}
className={`size-[15px] cursor-pointer ${
hoveredPolygonIndex === index && "text-primary-variant"
}`}
onClick={() => handleCopyCoordinates(index)}
className={cn(
"size-[15px] cursor-pointer",
hoveredPolygonIndex === index && "text-primary-variant",
isLoading && "cursor-not-allowed opacity-50",
)}
onClick={() => {
if (!isLoading) {
handleCopyCoordinates(index);
}
}}
/>
</TooltipTrigger>
<TooltipContent>
@@ -377,10 +518,13 @@ export default function PolygonItem({
<TooltipTrigger asChild>
<IconWrapper
icon={HiTrash}
className={`size-[15px] cursor-pointer ${
disabled={isLoading}
className={cn(
"size-[15px] cursor-pointer",
hoveredPolygonIndex === index &&
"fill-primary-variant text-primary-variant"
}`}
"fill-primary-variant text-primary-variant",
isLoading && "cursor-not-allowed opacity-50",
)}
onClick={() => !isLoading && setDeleteDialogOpen(true)}
/>
</TooltipTrigger>

View File

@@ -35,6 +35,7 @@ import { LuExternalLink } from "react-icons/lu";
import { useDocDomain } from "@/hooks/use-doc-domain";
import { getTranslatedLabel } from "@/utils/i18n";
import NameAndIdFields from "../input/NameAndIdFields";
import { useZoneState } from "@/api/ws";
type ZoneEditPaneProps = {
polygons?: Polygon[];
@@ -88,6 +89,11 @@ export default function ZoneEditPane({
}
}, [polygons, activePolygonIndex]);
const { send: sendZoneState } = useZoneState(
polygon?.camera || "",
polygon?.name || "",
);
const cameraConfig = useMemo(() => {
if (polygon?.camera && config) {
return config.cameras[polygon.camera];
@@ -178,6 +184,7 @@ export default function ZoneEditPane({
message: t("masksAndZones.form.zoneName.error.alreadyExists"),
},
),
enabled: z.boolean().default(true),
inertia: z.coerce
.number()
.min(1, {
@@ -271,6 +278,13 @@ export default function ZoneEditPane({
defaultValues: {
name: polygon?.name ?? "",
friendly_name: polygon?.friendly_name ?? polygon?.name ?? "",
enabled:
polygon?.camera &&
polygon?.name &&
config?.cameras[polygon.camera]?.zones[polygon.name]?.enabled !==
undefined
? config?.cameras[polygon.camera]?.zones[polygon.name]?.enabled
: (polygon?.enabled ?? true),
inertia:
polygon?.camera &&
polygon?.name &&
@@ -311,6 +325,7 @@ export default function ZoneEditPane({
{
name: zoneName,
friendly_name,
enabled,
inertia,
loitering_time,
objects: form_objects,
@@ -445,9 +460,11 @@ export default function ZoneEditPane({
friendlyNameQuery = `&cameras.${polygon?.camera}.zones.${zoneName}.friendly_name=${encodeURIComponent(friendly_name)}`;
}
const enabledQuery = `&cameras.${polygon?.camera}.zones.${zoneName}.enabled=${enabled ? "True" : "False"}`;
axios
.put(
`config/set?cameras.${polygon?.camera}.zones.${zoneName}.coordinates=${coordinates}${inertiaQuery}${loiteringTimeQuery}${speedThresholdQuery}${distancesQuery}${objectQueries}${friendlyNameQuery}${alertQueries}${detectionQueries}`,
`config/set?cameras.${polygon?.camera}.zones.${zoneName}.coordinates=${coordinates}${enabledQuery}${inertiaQuery}${loiteringTimeQuery}${speedThresholdQuery}${distancesQuery}${objectQueries}${friendlyNameQuery}${alertQueries}${detectionQueries}`,
{
requires_restart: 0,
update_topic: `config/cameras/${polygon.camera}/zones`,
@@ -464,6 +481,8 @@ export default function ZoneEditPane({
},
);
updateConfig();
// Publish the enabled state through websocket
sendZoneState(enabled ? "ON" : "OFF");
} else {
toast.error(
t("toast.save.error.title", {
@@ -504,6 +523,7 @@ export default function ZoneEditPane({
setIsLoading,
cameraConfig,
t,
sendZoneState,
],
);
@@ -581,6 +601,28 @@ export default function ZoneEditPane({
nameDescription={t("masksAndZones.zones.name.tips")}
placeholderName={t("masksAndZones.zones.name.inputPlaceHolder")}
/>
<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" />
<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;
color: number[];
friendly_name?: string;
enabled?: boolean;
enabled_in_config?: boolean;
};
export type ZoneFormValuesType = {
name: string;
friendly_name: string;
enabled: boolean;
inertia: number;
loitering_time: number;
isFinished: boolean;
@@ -29,10 +32,17 @@ export type ZoneFormValuesType = {
speed_threshold: number;
};
export type ObjectMaskFormValuesType = {
objects: string;
polygon: {
isFinished: boolean;
name: string;
};
export type MotionMaskFormValuesType = {
name: string;
friendly_name: string;
enabled: boolean;
isFinished: boolean;
};
export type ObjectMaskFormValuesType = {
name: string;
friendly_name: string;
enabled: boolean;
objects: string;
isFinished: boolean;
};

View File

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

View File

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