Masks and zones improvements (#22163)

* migrator and runtime config changes

* component changes to use rasterized_mask

* frontend

* convert none to empty string for config save

* i18n

* update tests

* add enabled config to zones

* zones frontend

* i18n

* docs

* tweaks

* use dashed stroke to indicate disabled

* allow toggle from icon

* use filelock to ensure atomic config updates from endpoint

* enforce atomic config update in the frontend

* toggle via mqtt

* fix global object masks

* correctly handle global object masks in dispatcher

* ws hooks

* render masks and zones based on ws enabled state

* use enabled_in_config for zones and masks

* frontend for enabled_in_config

* tweaks

* i18n

* publish websocket on config save

* i18n tweaks

* pydantic title and description

* i18n generation

* tweaks

* fix typing
This commit is contained in:
Josh Hawkins
2026-02-28 07:04:43 -07:00
committed by GitHub
parent fa1f9a1fa4
commit 6a21b2952d
37 changed files with 1964 additions and 739 deletions
+101 -78
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():
+5 -1
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(
+169 -2
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)
+41
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",
+85
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()
+10 -5
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):
+24 -6
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
+8
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",
+159 -53
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}
@@ -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]
+1 -1
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,
)
+1 -1
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,
)
+3 -1
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)
+38 -12
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):
+4
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
+106 -1
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"
+4 -4
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