mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-06 06:42:51 +03:00
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:
+101
-78
@@ -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():
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
@@ -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):
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user