Merge branch 'dev' into histogram-reid

This commit is contained in:
Josh Hawkins 2025-02-11 08:48:35 -06:00
commit 9153ff605b
44 changed files with 1927 additions and 317 deletions

View File

@ -450,7 +450,7 @@ Note that the labelmap uses a subset of the complete COCO label set that has onl
## ONNX
ONNX is an open format for building machine learning models, Frigate supports running ONNX models on CPU, OpenVINO, and TensorRT. On startup Frigate will automatically try to use a GPU if one is available.
ONNX is an open format for building machine learning models, Frigate supports running ONNX models on CPU, OpenVINO, ROCm, and TensorRT. On startup Frigate will automatically try to use a GPU if one is available.
:::info
@ -517,6 +517,33 @@ model:
labelmap_path: /labelmap/coco-80.txt
```
#### YOLOv9
[YOLOv9](https://github.com/MultimediaTechLab/YOLO) models are supported, but not included by default.
:::tip
The YOLOv9 detector has been designed to support YOLOv9 models, but may support other YOLO model architectures as well.
:::
After placing the downloaded onnx model in your config folder, you can use the following configuration:
```yaml
detectors:
onnx:
type: onnx
model:
model_type: yolov9
width: 640 # <--- should match the imgsize set during model export
height: 640 # <--- should match the imgsize set during model export
input_tensor: nchw
input_dtype: float
path: /config/model_cache/yolov9-t.onnx
labelmap_path: /labelmap/coco-80.txt
```
Note that the labelmap uses a subset of the complete COCO label set that has only 80 objects.
## CPU Detector (not recommended)

View File

@ -409,6 +409,7 @@ motion:
mqtt_off_delay: 30
# Optional: Notification Configuration
# NOTE: Can be overridden at the camera level (except email)
notifications:
# Optional: Enable notification service (default: shown below)
enabled: False
@ -662,7 +663,10 @@ cameras:
front_steps:
# 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.284,0.997,0.389,0.869,0.410,0.745
coordinates: 0.033,0.306,0.324,0.138,0.439,0.185,0.042,0.428
# Optional: The real-world distances of a 4-sided zone used for zones with speed estimation enabled (default: none)
# List distances in order of the zone points coordinates and use the unit system defined in the ui config
distances: 10,15,12,11
# Optional: Number of consecutive frames required for object to be considered present in the zone (default: shown below).
inertia: 3
# Optional: Number of seconds that an object must loiter to be considered in the zone (default: shown below)
@ -813,6 +817,9 @@ ui:
# https://www.gnu.org/software/libc/manual/html_node/Formatting-Calendar-Time.html
# possible values are shown above (default: not set)
strftime_fmt: "%Y/%m/%d %H:%M"
# Optional: Set the unit system to either "imperial" or "metric" (default: metric)
# Used in the UI and in MQTT topics
unit_system: metric
# Optional: Telemetry configuration
telemetry:

View File

@ -122,16 +122,59 @@ cameras:
- car
```
### Loitering Time
### Speed Estimation
Zones support a `loitering_time` configuration which can be used to only consider an object as part of a zone if they loiter in the zone for the specified number of seconds. This can be used, for example, to create alerts for cars that stop on the street but not cars that just drive past your camera.
Frigate can be configured to estimate the speed of objects moving through a zone. This works by combining data from Frigate's object tracker and "real world" distance measurements of the edges of the zone. The recommended use case for this feature is to track the speed of vehicles on a road as they move through the zone.
Your zone must be defined with exactly 4 points and should be aligned to the ground where objects are moving.
![Ground plane 4-point zone](/img/ground-plane.jpg)
Speed estimation requires a minimum number of frames for your object to be tracked before a valid estimate can be calculated, so create your zone away from places where objects enter and exit for the best results. _Your zone should not take up the full frame._ An object's speed is tracked while it is in the zone and then saved to Frigate's database.
Accurate real-world distance measurements are required to estimate speeds. These distances can be specified in your zone config through the `distances` field.
```yaml
cameras:
name_of_your_camera:
zones:
front_yard:
loitering_time: 5 # unit is in seconds
objects:
- person
street:
coordinates: 0.033,0.306,0.324,0.138,0.439,0.185,0.042,0.428
distances: 10,12,11,13.5
```
Each number in the `distance` field represents the real-world distance between the points in the `coordinates` list. So in the example above, the distance between the first two points ([0.033,0.306] and [0.324,0.138]) is 10. The distance between the second and third set of points ([0.324,0.138] and [0.439,0.185]) is 12, and so on. The fastest and most accurate way to configure this is through the Zone Editor in the Frigate UI.
The `distance` values are measured in meters or feet, depending on how `unit_system` is configured in your `ui` config:
```yaml
ui:
# can be "metric" or "imperial", default is metric
unit_system: metric
```
The average speed of your object as it moved through your zone is saved in Frigate's database and can be seen in the UI in the Tracked Object Details pane in Explore. Current estimated speed can also be seen on the debug view as the third value in the object label (see the caveats below). Current estimated speed, average estimated speed, and velocity angle (the angle of the direction the object is moving relative to the frame) of tracked objects is also sent through the `events` MQTT topic. See the [MQTT docs](../integrations/mqtt.md#frigateevents). These speed values are output as a number in miles per hour (mph) or kilometers per hour (kph), depending on how `unit_system` is configured in your `ui` config.
#### Best practices and caveats
- Speed estimation works best with a straight road or path when your object travels in a straight line across that path. If your object makes turns, speed estimation may not be accurate.
- Create a zone where the bottom center of your object's bounding box travels directly through it and does not become obscured at any time.
- Depending on the size and location of your zone, you may want to decrease the zone's `inertia` value from the default of 3.
- The more accurate your real-world dimensions can be measured, the more accurate speed estimation will be. However, due to the way Frigate's tracking algorithm works, you may need to tweak the real-world distance values so that estimated speeds better match real-world speeds.
- Once an object leaves the zone, speed accuracy will likely decrease due to perspective distortion and misalignment with the calibrated area. Therefore, speed values will show as a zero through MQTT and will not be visible on the debug view when an object is outside of a speed tracking zone.
- The speeds are only an _estimation_ and are highly dependent on camera position, zone points, and real-world measurements. This feature should not be used for law enforcement.
### Speed Threshold
Zones can be configured with a minimum speed requirement, meaning an object must be moving at or above this speed to be considered inside the zone. Zone `distances` must be defined as described above.
```yaml
cameras:
name_of_your_camera:
zones:
sidewalk:
coordinates: ...
distances: ...
inertia: 1
speed_threshold: 20 # unit is in kph or mph, depending on how unit_system is set (see above)
```

View File

@ -52,7 +52,9 @@ Message published for each changed tracked object. The first message is publishe
"attributes": {
"face": 0.64
}, // attributes with top score that have been identified on the object at any point
"current_attributes": [] // detailed data about the current attributes in this frame
"current_attributes": [], // detailed data about the current attributes in this frame
"current_estimated_speed": 0.71, // current estimated speed (mph or kph) for objects moving through zones with speed estimation enabled
"velocity_angle": 180 // direction of travel relative to the frame for objects moving through zones with speed estimation enabled
},
"after": {
"id": "1607123955.475377-mxklsc",
@ -89,7 +91,9 @@ Message published for each changed tracked object. The first message is publishe
"box": [442, 506, 534, 524],
"score": 0.86
}
]
],
"current_estimated_speed": 0.77, // current estimated speed (mph or kph) for objects moving through zones with speed estimation enabled
"velocity_angle": 180 // direction of travel relative to the frame for objects moving through zones with speed estimation enabled
}
}
```
@ -337,3 +341,19 @@ the camera to be removed from the view._
### `frigate/<camera_name>/birdseye_mode/state`
Topic with current state of the Birdseye mode for a camera. Published values are `CONTINUOUS`, `MOTION`, `OBJECTS`.
### `frigate/<camera_name>/notifications/set`
Topic to turn notifications on and off. Expected values are `ON` and `OFF`.
### `frigate/<camera_name>/notifications/state`
Topic with current state of notifications. Published values are `ON` and `OFF`.
### `frigate/<camera_name>/notifications/suspend`
Topic to suspend notifications for a certain number of minutes. Expected value is an integer.
### `frigate/<camera_name>/notifications/suspended`
Topic with timestamp that notifications are suspended until. Published value is a UNIX timestamp, or 0 if notifications are not suspended.

BIN
docs/static/img/ground-plane.jpg vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 KiB

View File

@ -25,6 +25,8 @@ class EventsQueryParams(BaseModel):
favorites: Optional[int] = None
min_score: Optional[float] = None
max_score: Optional[float] = None
min_speed: Optional[float] = None
max_speed: Optional[float] = None
is_submitted: Optional[int] = None
min_length: Optional[float] = None
max_length: Optional[float] = None
@ -51,6 +53,8 @@ class EventsSearchQueryParams(BaseModel):
timezone: Optional[str] = "utc"
min_score: Optional[float] = None
max_score: Optional[float] = None
min_speed: Optional[float] = None
max_speed: Optional[float] = None
sort: Optional[str] = None

View File

@ -92,6 +92,8 @@ def events(params: EventsQueryParams = Depends()):
favorites = params.favorites
min_score = params.min_score
max_score = params.max_score
min_speed = params.min_speed
max_speed = params.max_speed
is_submitted = params.is_submitted
min_length = params.min_length
max_length = params.max_length
@ -226,6 +228,12 @@ def events(params: EventsQueryParams = Depends()):
if min_score is not None:
clauses.append((Event.data["score"] >= min_score))
if max_speed is not None:
clauses.append((Event.data["average_estimated_speed"] <= max_speed))
if min_speed is not None:
clauses.append((Event.data["average_estimated_speed"] >= min_speed))
if min_length is not None:
clauses.append(((Event.end_time - Event.start_time) >= min_length))
@ -249,6 +257,10 @@ def events(params: EventsQueryParams = Depends()):
order_by = Event.data["score"].asc()
elif sort == "score_desc":
order_by = Event.data["score"].desc()
elif sort == "speed_asc":
order_by = Event.data["average_estimated_speed"].asc()
elif sort == "speed_desc":
order_by = Event.data["average_estimated_speed"].desc()
elif sort == "date_asc":
order_by = Event.start_time.asc()
elif sort == "date_desc":
@ -316,7 +328,15 @@ def events_explore(limit: int = 10):
k: v
for k, v in event.data.items()
if k
in ["type", "score", "top_score", "description", "sub_label_score"]
in [
"type",
"score",
"top_score",
"description",
"sub_label_score",
"average_estimated_speed",
"velocity_angle",
]
},
"event_count": label_counts[event.label],
}
@ -367,6 +387,8 @@ def events_search(request: Request, params: EventsSearchQueryParams = Depends())
before = params.before
min_score = params.min_score
max_score = params.max_score
min_speed = params.min_speed
max_speed = params.max_speed
time_range = params.time_range
has_clip = params.has_clip
has_snapshot = params.has_snapshot
@ -466,6 +488,16 @@ def events_search(request: Request, params: EventsSearchQueryParams = Depends())
if max_score is not None:
event_filters.append((Event.data["score"] <= max_score))
if min_speed is not None and max_speed is not None:
event_filters.append(
(Event.data["average_estimated_speed"].between(min_speed, max_speed))
)
else:
if min_speed is not None:
event_filters.append((Event.data["average_estimated_speed"] >= min_speed))
if max_speed is not None:
event_filters.append((Event.data["average_estimated_speed"] <= max_speed))
if time_range != DEFAULT_TIME_RANGE:
tz_name = params.timezone
hour_modifier, minute_modifier, _ = get_tz_modifiers(tz_name)
@ -581,7 +613,16 @@ def events_search(request: Request, params: EventsSearchQueryParams = Depends())
processed_event["data"] = {
k: v
for k, v in event["data"].items()
if k in ["type", "score", "top_score", "description"]
if k
in [
"type",
"score",
"top_score",
"description",
"sub_label_score",
"average_estimated_speed",
"velocity_angle",
]
}
if event["id"] in search_results:
@ -596,6 +637,10 @@ def events_search(request: Request, params: EventsSearchQueryParams = Depends())
processed_events.sort(key=lambda x: x["score"])
elif min_score is not None and max_score is not None and sort == "score_desc":
processed_events.sort(key=lambda x: x["score"], reverse=True)
elif min_speed is not None and max_speed is not None and sort == "speed_asc":
processed_events.sort(key=lambda x: x["average_estimated_speed"])
elif min_speed is not None and max_speed is not None and sort == "speed_desc":
processed_events.sort(key=lambda x: x["average_estimated_speed"], reverse=True)
elif sort == "date_asc":
processed_events.sort(key=lambda x: x["start_time"])
else:

View File

@ -17,8 +17,9 @@ import frigate.util as util
from frigate.api.auth import hash_password
from frigate.api.fastapi_app import create_fastapi_app
from frigate.camera import CameraMetrics, PTZMetrics
from frigate.comms.base_communicator import Communicator
from frigate.comms.config_updater import ConfigPublisher
from frigate.comms.dispatcher import Communicator, Dispatcher
from frigate.comms.dispatcher import Dispatcher
from frigate.comms.event_metadata_updater import (
EventMetadataPublisher,
EventMetadataTypeEnum,
@ -314,8 +315,14 @@ class FrigateApp:
if self.config.mqtt.enabled:
comms.append(MqttClient(self.config))
if self.config.notifications.enabled_in_config:
comms.append(WebPushClient(self.config))
notification_cameras = [
c
for c in self.config.cameras.values()
if c.enabled and c.notifications.enabled_in_config
]
if notification_cameras:
comms.append(WebPushClient(self.config, self.stop_event))
comms.append(WebSocketClient(self.config))
comms.append(self.inter_process_communicator)

View File

@ -0,0 +1,21 @@
from abc import ABC, abstractmethod
from typing import Any, Callable
class Communicator(ABC):
"""pub/sub model via specific protocol."""
@abstractmethod
def publish(self, topic: str, payload: Any, retain: bool = False) -> None:
"""Send data via specific protocol."""
pass
@abstractmethod
def subscribe(self, receiver: Callable) -> None:
"""Pass receiver so communicators can pass commands."""
pass
@abstractmethod
def stop(self) -> None:
"""Stop the communicator."""
pass

View File

@ -3,17 +3,19 @@
import datetime
import json
import logging
from abc import ABC, abstractmethod
from typing import Any, Callable, Optional
from frigate.camera import PTZMetrics
from frigate.camera.activity_manager import CameraActivityManager
from frigate.comms.base_communicator import Communicator
from frigate.comms.config_updater import ConfigPublisher
from frigate.comms.webpush import WebPushClient
from frigate.config import BirdseyeModeEnum, FrigateConfig
from frigate.const import (
CLEAR_ONGOING_REVIEW_SEGMENTS,
INSERT_MANY_RECORDINGS,
INSERT_PREVIEW,
NOTIFICATION_TEST,
REQUEST_REGION_GRID,
UPDATE_CAMERA_ACTIVITY,
UPDATE_EMBEDDINGS_REINDEX_PROGRESS,
@ -30,25 +32,6 @@ from frigate.util.services import restart_frigate
logger = logging.getLogger(__name__)
class Communicator(ABC):
"""pub/sub model via specific protocol."""
@abstractmethod
def publish(self, topic: str, payload: Any, retain: bool = False) -> None:
"""Send data via specific protocol."""
pass
@abstractmethod
def subscribe(self, receiver: Callable) -> None:
"""Pass receiver so communicators can pass commands."""
pass
@abstractmethod
def stop(self) -> None:
"""Stop the communicator."""
pass
class Dispatcher:
"""Handle communication between Frigate and communicators."""
@ -77,18 +60,23 @@ class Dispatcher:
"motion": self._on_motion_command,
"motion_contour_area": self._on_motion_contour_area_command,
"motion_threshold": self._on_motion_threshold_command,
"notifications": self._on_camera_notification_command,
"recordings": self._on_recordings_command,
"snapshots": self._on_snapshots_command,
"birdseye": self._on_birdseye_command,
"birdseye_mode": self._on_birdseye_mode_command,
}
self._global_settings_handlers: dict[str, Callable] = {
"notifications": self._on_notification_command,
"notifications": self._on_global_notification_command,
}
for comm in self.comms:
comm.subscribe(self._receive)
self.web_push_client = next(
(comm for comm in communicators if isinstance(comm, WebPushClient)), None
)
def _receive(self, topic: str, payload: str) -> Optional[Any]:
"""Handle receiving of payload from communicators."""
@ -180,6 +168,13 @@ class Dispatcher:
"snapshots": self.config.cameras[camera].snapshots.enabled,
"record": self.config.cameras[camera].record.enabled,
"audio": self.config.cameras[camera].audio.enabled,
"notifications": self.config.cameras[camera].notifications.enabled,
"notifications_suspended": int(
self.web_push_client.suspended_cameras.get(camera, 0)
)
if self.web_push_client
and camera in self.web_push_client.suspended_cameras
else 0,
"autotracking": self.config.cameras[
camera
].onvif.autotracking.enabled,
@ -192,6 +187,9 @@ class Dispatcher:
json.dumps(self.embeddings_reindex.copy()),
)
def handle_notification_test():
self.publish("notification_test", "Test notification")
# Dictionary mapping topic to handlers
topic_handlers = {
INSERT_MANY_RECORDINGS: handle_insert_many_recordings,
@ -203,13 +201,14 @@ class Dispatcher:
UPDATE_EVENT_DESCRIPTION: handle_update_event_description,
UPDATE_MODEL_STATE: handle_update_model_state,
UPDATE_EMBEDDINGS_REINDEX_PROGRESS: handle_update_embeddings_reindex_progress,
NOTIFICATION_TEST: handle_notification_test,
"restart": handle_restart,
"embeddingsReindexProgress": handle_embeddings_reindex_progress,
"modelState": handle_model_state,
"onConnect": handle_on_connect,
}
if topic.endswith("set") or topic.endswith("ptz"):
if topic.endswith("set") or topic.endswith("ptz") or topic.endswith("suspend"):
try:
parts = topic.split("/")
if len(parts) == 3 and topic.endswith("set"):
@ -224,6 +223,11 @@ class Dispatcher:
# example /cam_name/ptz payload=MOVE_UP|MOVE_DOWN|STOP...
camera_name = parts[-2]
handle_camera_command("ptz", camera_name, "", payload)
elif len(parts) == 3 and topic.endswith("suspend"):
# example /cam_name/notifications/suspend payload=duration
camera_name = parts[-3]
command = parts[-2]
self._on_camera_notification_suspend(camera_name, payload)
except IndexError:
logger.error(
f"Received invalid {topic.split('/')[-1]} command: {topic}"
@ -365,16 +369,18 @@ class Dispatcher:
self.config_updater.publish(f"config/motion/{camera_name}", motion_settings)
self.publish(f"{camera_name}/motion_threshold/state", payload, retain=True)
def _on_notification_command(self, payload: str) -> None:
"""Callback for notification topic."""
def _on_global_notification_command(self, payload: str) -> None:
"""Callback for global notification topic."""
if payload != "ON" and payload != "OFF":
f"Received unsupported value for notification: {payload}"
f"Received unsupported value for all notification: {payload}"
return
notification_settings = self.config.notifications
logger.info(f"Setting notifications: {payload}")
logger.info(f"Setting all notifications: {payload}")
notification_settings.enabled = payload == "ON" # type: ignore[union-attr]
self.config_updater.publish("config/notifications", notification_settings)
self.config_updater.publish(
"config/notifications", {"_global_notifications": notification_settings}
)
self.publish("notifications/state", payload, retain=True)
def _on_audio_command(self, camera_name: str, payload: str) -> None:
@ -491,3 +497,71 @@ class Dispatcher:
self.config_updater.publish(f"config/birdseye/{camera_name}", birdseye_settings)
self.publish(f"{camera_name}/birdseye_mode/state", payload, retain=True)
def _on_camera_notification_command(self, camera_name: str, payload: str) -> None:
"""Callback for camera level notifications topic."""
notification_settings = self.config.cameras[camera_name].notifications
if payload == "ON":
if not self.config.cameras[camera_name].notifications.enabled_in_config:
logger.error(
"Notifications must be enabled in the config to be turned on via MQTT."
)
return
if not notification_settings.enabled:
logger.info(f"Turning on notifications for {camera_name}")
notification_settings.enabled = True
if (
self.web_push_client
and camera_name in self.web_push_client.suspended_cameras
):
self.web_push_client.suspended_cameras[camera_name] = 0
elif payload == "OFF":
if notification_settings.enabled:
logger.info(f"Turning off notifications for {camera_name}")
notification_settings.enabled = False
if (
self.web_push_client
and camera_name in self.web_push_client.suspended_cameras
):
self.web_push_client.suspended_cameras[camera_name] = 0
self.config_updater.publish(
"config/notifications", {camera_name: notification_settings}
)
self.publish(f"{camera_name}/notifications/state", payload, retain=True)
self.publish(f"{camera_name}/notifications/suspended", "0", retain=True)
def _on_camera_notification_suspend(self, camera_name: str, payload: str) -> None:
"""Callback for camera level notifications suspend topic."""
try:
duration = int(payload)
except ValueError:
logger.error(f"Invalid suspension duration: {payload}")
return
if self.web_push_client is None:
logger.error("WebPushClient not available for suspension")
return
notification_settings = self.config.cameras[camera_name].notifications
if not notification_settings.enabled:
logger.error(f"Notifications are not enabled for {camera_name}")
return
if duration != 0:
self.web_push_client.suspend_notifications(camera_name, duration)
else:
self.web_push_client.unsuspend_notifications(camera_name)
self.publish(
f"{camera_name}/notifications/suspended",
str(
int(self.web_push_client.suspended_cameras.get(camera_name, 0))
if camera_name in self.web_push_client.suspended_cameras
else 0
),
retain=True,
)

View File

@ -7,7 +7,7 @@ from typing import Callable
import zmq
from frigate.comms.dispatcher import Communicator
from frigate.comms.base_communicator import Communicator
SOCKET_REP_REQ = "ipc:///tmp/cache/comms"

View File

@ -5,7 +5,7 @@ from typing import Any, Callable
import paho.mqtt.client as mqtt
from paho.mqtt.enums import CallbackAPIVersion
from frigate.comms.dispatcher import Communicator
from frigate.comms.base_communicator import Communicator
from frigate.config import FrigateConfig
logger = logging.getLogger(__name__)

View File

@ -4,13 +4,17 @@ import datetime
import json
import logging
import os
import queue
import threading
from dataclasses import dataclass
from multiprocessing.synchronize import Event as MpEvent
from typing import Any, Callable
from py_vapid import Vapid01
from pywebpush import WebPusher
from frigate.comms.base_communicator import Communicator
from frigate.comms.config_updater import ConfigSubscriber
from frigate.comms.dispatcher import Communicator
from frigate.config import FrigateConfig
from frigate.const import CONFIG_DIR
from frigate.models import User
@ -18,15 +22,36 @@ from frigate.models import User
logger = logging.getLogger(__name__)
@dataclass
class PushNotification:
user: str
payload: dict[str, Any]
title: str
message: str
direct_url: str = ""
image: str = ""
notification_type: str = "alert"
ttl: int = 0
class WebPushClient(Communicator): # type: ignore[misc]
"""Frigate wrapper for webpush client."""
def __init__(self, config: FrigateConfig) -> None:
def __init__(self, config: FrigateConfig, stop_event: MpEvent) -> None:
self.config = config
self.stop_event = stop_event
self.claim_headers: dict[str, dict[str, str]] = {}
self.refresh: int = 0
self.web_pushers: dict[str, list[WebPusher]] = {}
self.expired_subs: dict[str, list[str]] = {}
self.suspended_cameras: dict[str, int] = {
c.name: 0 for c in self.config.cameras.values()
}
self.notification_queue: queue.Queue[PushNotification] = queue.Queue()
self.notification_thread = threading.Thread(
target=self._process_notifications, daemon=True
)
self.notification_thread.start()
if not self.config.notifications.email:
logger.warning("Email must be provided for push notifications to be sent.")
@ -103,30 +128,144 @@ class WebPushClient(Communicator): # type: ignore[misc]
self.expired_subs = {}
def suspend_notifications(self, camera: str, minutes: int) -> None:
"""Suspend notifications for a specific camera."""
suspend_until = int(
(datetime.datetime.now() + datetime.timedelta(minutes=minutes)).timestamp()
)
self.suspended_cameras[camera] = suspend_until
logger.info(
f"Notifications for {camera} suspended until {datetime.datetime.fromtimestamp(suspend_until).strftime('%Y-%m-%d %H:%M:%S')}"
)
def unsuspend_notifications(self, camera: str) -> None:
"""Unsuspend notifications for a specific camera."""
self.suspended_cameras[camera] = 0
logger.info(f"Notifications for {camera} unsuspended")
def is_camera_suspended(self, camera: str) -> bool:
return datetime.datetime.now().timestamp() <= self.suspended_cameras[camera]
def publish(self, topic: str, payload: Any, retain: bool = False) -> None:
"""Wrapper for publishing when client is in valid state."""
# check for updated notification config
_, updated_notification_config = self.config_subscriber.check_for_update()
if updated_notification_config:
self.config.notifications = updated_notification_config
for key, value in updated_notification_config.items():
if key == "_global_notifications":
self.config.notifications = value
if not self.config.notifications.enabled:
return
elif key in self.config.cameras:
self.config.cameras[key].notifications = value
if topic == "reviews":
self.send_alert(json.loads(payload))
decoded = json.loads(payload)
camera = decoded["before"]["camera"]
if not self.config.cameras[camera].notifications.enabled:
return
if self.is_camera_suspended(camera):
logger.debug(f"Notifications for {camera} are currently suspended.")
return
self.send_alert(decoded)
elif topic == "notification_test":
if not self.config.notifications.enabled:
return
self.send_notification_test()
def send_alert(self, payload: dict[str, any]) -> None:
def send_push_notification(
self,
user: str,
payload: dict[str, Any],
title: str,
message: str,
direct_url: str = "",
image: str = "",
notification_type: str = "alert",
ttl: int = 0,
) -> None:
notification = PushNotification(
user=user,
payload=payload,
title=title,
message=message,
direct_url=direct_url,
image=image,
notification_type=notification_type,
ttl=ttl,
)
self.notification_queue.put(notification)
def _process_notifications(self) -> None:
while not self.stop_event.is_set():
try:
notification = self.notification_queue.get(timeout=1.0)
self.check_registrations()
for pusher in self.web_pushers[notification.user]:
endpoint = pusher.subscription_info["endpoint"]
headers = self.claim_headers[
endpoint[: endpoint.index("/", 10)]
].copy()
headers["urgency"] = "high"
resp = pusher.send(
headers=headers,
ttl=notification.ttl,
data=json.dumps(
{
"title": notification.title,
"message": notification.message,
"direct_url": notification.direct_url,
"image": notification.image,
"id": notification.payload.get("after", {}).get(
"id", ""
),
"type": notification.notification_type,
}
),
timeout=10,
)
if resp.status_code in (404, 410):
self.expired_subs.setdefault(notification.user, []).append(
endpoint
)
elif resp.status_code != 201:
logger.warning(
f"Failed to send notification to {notification.user} :: {resp.status_code}"
)
except queue.Empty:
continue
except Exception as e:
logger.error(f"Error processing notification: {str(e)}")
def send_notification_test(self) -> None:
if not self.config.notifications.email:
return
self.check_registrations()
# Only notify for alerts
if payload["after"]["severity"] != "alert":
for user in self.web_pushers:
self.send_push_notification(
user=user,
payload={},
title="Test Notification",
message="This is a test notification from Frigate.",
direct_url="/",
notification_type="test",
)
def send_alert(self, payload: dict[str, Any]) -> None:
if (
not self.config.notifications.email
or payload["after"]["severity"] != "alert"
):
return
self.check_registrations()
state = payload["type"]
# Don't notify if message is an update and important fields don't have an update
@ -155,49 +294,21 @@ class WebPushClient(Communicator): # type: ignore[misc]
# if event is ongoing open to live view otherwise open to recordings view
direct_url = f"/review?id={reviewId}" if state == "end" else f"/#{camera}"
for user, pushers in self.web_pushers.items():
for pusher in pushers:
endpoint = pusher.subscription_info["endpoint"]
# set headers for notification behavior
headers = self.claim_headers[
endpoint[0 : endpoint.index("/", 10)]
].copy()
headers["urgency"] = "high"
ttl = 3600 if state == "end" else 0
# send message
resp = pusher.send(
headers=headers,
for user in self.web_pushers:
self.send_push_notification(
user=user,
payload=payload,
title=title,
message=message,
direct_url=direct_url,
image=image,
ttl=ttl,
data=json.dumps(
{
"title": title,
"message": message,
"direct_url": direct_url,
"image": image,
"id": reviewId,
"type": "alert",
}
),
)
if resp.status_code == 201:
pass
elif resp.status_code == 404 or resp.status_code == 410:
# subscription is not found or has been unsubscribed
if not self.expired_subs.get(user):
self.expired_subs[user] = []
self.expired_subs[user].append(pusher.subscription_info["endpoint"])
# the subscription no longer exists and should be removed
else:
logger.warning(
f"Failed to send notification to {user} :: {resp.headers}"
)
self.cleanup_registrations()
def stop(self) -> None:
pass
logger.info("Closing notification queue")
self.notification_thread.join()

View File

@ -15,7 +15,7 @@ from ws4py.server.wsgirefserver import (
from ws4py.server.wsgiutils import WebSocketWSGIApplication
from ws4py.websocket import WebSocket as WebSocket_
from frigate.comms.dispatcher import Communicator
from frigate.comms.base_communicator import Communicator
from frigate.config import FrigateConfig
logger = logging.getLogger(__name__)

View File

@ -8,7 +8,6 @@ from .config import * # noqa: F403
from .database import * # noqa: F403
from .logger import * # noqa: F403
from .mqtt import * # noqa: F403
from .notification import * # noqa: F403
from .proxy import * # noqa: F403
from .telemetry import * # noqa: F403
from .tls import * # noqa: F403

View File

@ -25,6 +25,7 @@ from .genai import GenAICameraConfig
from .live import CameraLiveConfig
from .motion import MotionConfig
from .mqtt import CameraMqttConfig
from .notification import NotificationConfig
from .objects import ObjectConfig
from .onvif import OnvifConfig
from .record import RecordConfig
@ -85,6 +86,9 @@ class CameraConfig(FrigateBaseModel):
mqtt: CameraMqttConfig = Field(
default_factory=CameraMqttConfig, title="MQTT configuration."
)
notifications: NotificationConfig = Field(
default_factory=NotificationConfig, title="Notifications configuration."
)
onvif: OnvifConfig = Field(
default_factory=OnvifConfig, title="Camera Onvif Configuration."
)

View File

@ -2,7 +2,7 @@ from typing import Optional
from pydantic import Field
from .base import FrigateBaseModel
from ..base import FrigateBaseModel
__all__ = ["NotificationConfig"]

View File

@ -1,13 +1,16 @@
# this uses the base model because the color is an extra attribute
import logging
from typing import Optional, Union
import numpy as np
from pydantic import BaseModel, Field, PrivateAttr, field_validator
from pydantic import BaseModel, Field, PrivateAttr, field_validator, model_validator
from .objects import FilterConfig
__all__ = ["ZoneConfig"]
logger = logging.getLogger(__name__)
class ZoneConfig(BaseModel):
filters: dict[str, FilterConfig] = Field(
@ -16,6 +19,10 @@ class ZoneConfig(BaseModel):
coordinates: Union[str, list[str]] = Field(
title="Coordinates polygon for the defined zone."
)
distances: Optional[Union[str, list[str]]] = Field(
default_factory=list,
title="Real-world distances for the sides of quadrilateral for the defined zone.",
)
inertia: int = Field(
default=3,
title="Number of consecutive frames required for object to be considered present in the zone.",
@ -26,6 +33,11 @@ class ZoneConfig(BaseModel):
ge=0,
title="Number of seconds that an object must loiter to be considered in the zone.",
)
speed_threshold: Optional[float] = Field(
default=None,
ge=0.1,
title="Minimum speed value for an object to be considered in the zone.",
)
objects: Union[str, list[str]] = Field(
default_factory=list,
title="List of objects that can trigger the zone.",
@ -49,6 +61,34 @@ class ZoneConfig(BaseModel):
return v
@field_validator("distances", mode="before")
@classmethod
def validate_distances(cls, v):
if v is None:
return None
if isinstance(v, str):
distances = list(map(str, map(float, v.split(","))))
elif isinstance(v, list):
distances = [str(float(val)) for val in v]
else:
raise ValueError("Invalid type for distances")
if len(distances) != 4:
raise ValueError("distances must have exactly 4 values")
return distances
@model_validator(mode="after")
def check_loitering_time_constraints(self):
if self.loitering_time > 0 and (
self.speed_threshold is not None or len(self.distances) > 0
):
logger.warning(
"loitering_time should not be set on a zone if speed_threshold or distances is set."
)
return self
def __init__(self, **config):
super().__init__(**config)

View File

@ -46,6 +46,7 @@ from .camera.detect import DetectConfig
from .camera.ffmpeg import FfmpegConfig
from .camera.genai import GenAIConfig
from .camera.motion import MotionConfig
from .camera.notification import NotificationConfig
from .camera.objects import FilterConfig, ObjectConfig
from .camera.record import RecordConfig, RetainModeEnum
from .camera.review import ReviewConfig
@ -62,7 +63,6 @@ from .database import DatabaseConfig
from .env import EnvVars
from .logger import LoggerConfig
from .mqtt import MqttConfig
from .notification import NotificationConfig
from .proxy import ProxyConfig
from .telemetry import TelemetryConfig
from .tls import TlsConfig
@ -332,7 +332,7 @@ class FrigateConfig(FrigateBaseModel):
)
mqtt: MqttConfig = Field(title="MQTT configuration.")
notifications: NotificationConfig = Field(
default_factory=NotificationConfig, title="Notification configuration."
default_factory=NotificationConfig, title="Global notification configuration."
)
proxy: ProxyConfig = Field(
default_factory=ProxyConfig, title="Proxy configuration."
@ -452,6 +452,7 @@ class FrigateConfig(FrigateBaseModel):
"review": ...,
"genai": ...,
"motion": ...,
"notifications": ...,
"detect": ...,
"ffmpeg": ...,
"timestamp_style": ...,
@ -527,6 +528,9 @@ class FrigateConfig(FrigateBaseModel):
# set config pre-value
camera_config.audio.enabled_in_config = camera_config.audio.enabled
camera_config.record.enabled_in_config = camera_config.record.enabled
camera_config.notifications.enabled_in_config = (
camera_config.notifications.enabled
)
camera_config.onvif.autotracking.enabled_in_config = (
camera_config.onvif.autotracking.enabled
)

View File

@ -5,7 +5,7 @@ from pydantic import Field
from .base import FrigateBaseModel
__all__ = ["TimeFormatEnum", "DateTimeStyleEnum", "UIConfig"]
__all__ = ["TimeFormatEnum", "DateTimeStyleEnum", "UnitSystemEnum", "UIConfig"]
class TimeFormatEnum(str, Enum):
@ -21,6 +21,11 @@ class DateTimeStyleEnum(str, Enum):
short = "short"
class UnitSystemEnum(str, Enum):
imperial = "imperial"
metric = "metric"
class UIConfig(FrigateBaseModel):
timezone: Optional[str] = Field(default=None, title="Override UI timezone.")
time_format: TimeFormatEnum = Field(
@ -35,3 +40,6 @@ class UIConfig(FrigateBaseModel):
strftime_fmt: Optional[str] = Field(
default=None, title="Override date and time format using strftime syntax."
)
unit_system: UnitSystemEnum = Field(
default=UnitSystemEnum.metric, title="The unit system to use for measurements."
)

View File

@ -104,6 +104,7 @@ UPDATE_CAMERA_ACTIVITY = "update_camera_activity"
UPDATE_EVENT_DESCRIPTION = "update_event_description"
UPDATE_MODEL_STATE = "update_model_state"
UPDATE_EMBEDDINGS_REINDEX_PROGRESS = "handle_embeddings_reindex_progress"
NOTIFICATION_TEST = "notification_test"
# Stats Values

View File

@ -35,6 +35,7 @@ class InputDTypeEnum(str, Enum):
class ModelTypeEnum(str, Enum):
ssd = "ssd"
yolox = "yolox"
yolov9 = "yolov9"
yolonas = "yolonas"

View File

@ -9,7 +9,7 @@ from frigate.detectors.detector_config import (
BaseDetectorConfig,
ModelTypeEnum,
)
from frigate.util.model import get_ort_providers
from frigate.util.model import get_ort_providers, post_process_yolov9
logger = logging.getLogger(__name__)
@ -79,6 +79,9 @@ class ONNXDetector(DetectionApi):
x_max / self.w,
]
return detections
elif self.onnx_model_type == ModelTypeEnum.yolov9:
predictions: np.ndarray = tensor_output[0]
return post_process_yolov9(predictions, self.w, self.h)
else:
raise Exception(
f"{self.onnx_model_type} is currently not supported for rocm. See the docs for more info on supported models."

View File

@ -25,6 +25,9 @@ def should_update_db(prev_event: Event, current_event: Event) -> bool:
or prev_event["entered_zones"] != current_event["entered_zones"]
or prev_event["thumbnail"] != current_event["thumbnail"]
or prev_event["end_time"] != current_event["end_time"]
or prev_event["average_estimated_speed"]
!= current_event["average_estimated_speed"]
or prev_event["velocity_angle"] != current_event["velocity_angle"]
):
return True
return False
@ -210,6 +213,8 @@ class EventProcessor(threading.Thread):
"score": score,
"top_score": event_data["top_score"],
"attributes": attributes,
"average_estimated_speed": event_data["average_estimated_speed"],
"velocity_angle": event_data["velocity_angle"],
"type": "object",
"max_severity": event_data.get("max_severity"),
},

View File

@ -160,7 +160,12 @@ class CameraState:
box[2],
box[3],
text,
f"{obj['score']:.0%} {int(obj['area'])}",
f"{obj['score']:.0%} {int(obj['area'])}"
+ (
f" {float(obj['current_estimated_speed']):.1f}"
if obj["current_estimated_speed"] != 0
else ""
),
thickness=thickness,
color=color,
)
@ -254,6 +259,7 @@ class CameraState:
new_obj = tracked_objects[id] = TrackedObject(
self.config.model,
self.camera_config,
self.config.ui,
self.frame_cache,
current_detections[id],
)

View File

@ -12,6 +12,7 @@ import numpy as np
from frigate.config import (
CameraConfig,
ModelConfig,
UIConfig,
)
from frigate.review.types import SeverityEnum
from frigate.util.image import (
@ -22,6 +23,7 @@ from frigate.util.image import (
is_better_thumbnail,
)
from frigate.util.object import box_inside
from frigate.util.velocity import calculate_real_world_speed
logger = logging.getLogger(__name__)
@ -31,6 +33,7 @@ class TrackedObject:
self,
model_config: ModelConfig,
camera_config: CameraConfig,
ui_config: UIConfig,
frame_cache,
obj_data: dict[str, any],
):
@ -42,6 +45,7 @@ class TrackedObject:
self.colormap = model_config.colormap
self.logos = model_config.all_attribute_logos
self.camera_config = camera_config
self.ui_config = ui_config
self.frame_cache = frame_cache
self.zone_presence: dict[str, int] = {}
self.zone_loitering: dict[str, int] = {}
@ -58,6 +62,10 @@ class TrackedObject:
self.frame = None
self.active = True
self.pending_loitering = False
self.speed_history = []
self.current_estimated_speed = 0
self.average_estimated_speed = 0
self.velocity_angle = 0
self.previous = self.to_dict()
@property
@ -129,6 +137,8 @@ class TrackedObject:
"region": obj_data["region"],
"score": obj_data["score"],
"attributes": obj_data["attributes"],
"current_estimated_speed": self.current_estimated_speed,
"velocity_angle": self.velocity_angle,
}
thumb_update = True
@ -136,6 +146,7 @@ class TrackedObject:
current_zones = []
bottom_center = (obj_data["centroid"][0], obj_data["box"][3])
in_loitering_zone = False
in_speed_zone = False
# check each zone
for name, zone in self.camera_config.zones.items():
@ -144,12 +155,66 @@ class TrackedObject:
continue
contour = zone.contour
zone_score = self.zone_presence.get(name, 0) + 1
# check if the object is in the zone
if cv2.pointPolygonTest(contour, bottom_center, False) >= 0:
# if the object passed the filters once, dont apply again
if name in self.current_zones or not zone_filtered(self, zone.filters):
# an object is only considered present in a zone if it has a zone inertia of 3+
# Calculate speed first if this is a speed zone
if (
zone.distances
and obj_data["frame_time"] == current_frame_time
and self.active
):
speed_magnitude, self.velocity_angle = (
calculate_real_world_speed(
zone.contour,
zone.distances,
self.obj_data["estimate_velocity"],
bottom_center,
self.camera_config.detect.fps,
)
)
if self.ui_config.unit_system == "metric":
self.current_estimated_speed = (
speed_magnitude * 3.6
) # m/s to km/h
else:
self.current_estimated_speed = (
speed_magnitude * 0.681818
) # ft/s to mph
self.speed_history.append(self.current_estimated_speed)
if len(self.speed_history) > 10:
self.speed_history = self.speed_history[-10:]
self.average_estimated_speed = sum(self.speed_history) / len(
self.speed_history
)
# we've exceeded the speed threshold on the zone
# or we don't have a speed threshold set
if (
zone.speed_threshold is None
or self.average_estimated_speed > zone.speed_threshold
):
in_speed_zone = True
logger.debug(
f"Camera: {self.camera_config.name}, tracked object ID: {self.obj_data['id']}, "
f"zone: {name}, pixel velocity: {str(tuple(np.round(self.obj_data['estimate_velocity']).flatten().astype(int)))}, "
f"speed magnitude: {speed_magnitude}, velocity angle: {self.velocity_angle}, "
f"estimated speed: {self.current_estimated_speed:.1f}, "
f"average speed: {self.average_estimated_speed:.1f}, "
f"length: {len(self.speed_history)}"
)
# Check zone entry conditions - for speed zones, require both inertia and speed
if zone_score >= zone.inertia:
if zone.distances and not in_speed_zone:
continue # Skip zone entry for speed zones until speed threshold met
# if the zone has loitering time, update loitering status
if zone.loitering_time > 0:
in_loitering_zone = True
@ -174,6 +239,10 @@ class TrackedObject:
if 0 < zone_score < zone.inertia:
self.zone_presence[name] = zone_score - 1
# Reset speed if not in speed zone
if zone.distances and name not in current_zones:
self.current_estimated_speed = 0
# update loitering status
self.pending_loitering = in_loitering_zone
@ -255,6 +324,9 @@ class TrackedObject:
"current_attributes": self.obj_data["attributes"],
"pending_loitering": self.pending_loitering,
"max_severity": self.max_severity,
"current_estimated_speed": self.current_estimated_speed,
"average_estimated_speed": self.average_estimated_speed,
"velocity_angle": self.velocity_angle,
}
if include_thumbnail:
@ -339,7 +411,12 @@ class TrackedObject:
box[2],
box[3],
self.obj_data["label"],
f"{int(self.thumbnail_data['score'] * 100)}% {int(self.thumbnail_data['area'])}",
f"{int(self.thumbnail_data['score'] * 100)}% {int(self.thumbnail_data['area'])}"
+ (
f" {self.thumbnail_data['current_estimated_speed']:.1f}"
if self.thumbnail_data["current_estimated_speed"] != 0
else ""
),
thickness=thickness,
color=color,
)

View File

@ -4,6 +4,8 @@ import logging
import os
from typing import Any
import cv2
import numpy as np
import onnxruntime as ort
try:
@ -14,6 +16,43 @@ except ImportError:
logger = logging.getLogger(__name__)
### Post Processing
def post_process_yolov9(predictions: np.ndarray, width, height) -> np.ndarray:
predictions = np.squeeze(predictions).T
scores = np.max(predictions[:, 4:], axis=1)
predictions = predictions[scores > 0.4, :]
scores = scores[scores > 0.4]
class_ids = np.argmax(predictions[:, 4:], axis=1)
# Rescale box
boxes = predictions[:, :4]
input_shape = np.array([width, height, width, height])
boxes = np.divide(boxes, input_shape, dtype=np.float32)
indices = cv2.dnn.NMSBoxes(boxes, scores, score_threshold=0.4, nms_threshold=0.4)
detections = np.zeros((20, 6), np.float32)
for i, (bbox, confidence, class_id) in enumerate(
zip(boxes[indices], scores[indices], class_ids[indices])
):
if i == 20:
break
detections[i] = [
class_id,
confidence,
bbox[1] - bbox[3] / 2,
bbox[0] - bbox[2] / 2,
bbox[1] + bbox[3] / 2,
bbox[0] + bbox[2] / 2,
]
return detections
### ONNX Utilities
def get_ort_providers(
force_cpu: bool = False, device: str = "AUTO", requires_fp16: bool = False

127
frigate/util/velocity.py Normal file
View File

@ -0,0 +1,127 @@
import math
import numpy as np
def order_points_clockwise(points):
"""
Ensure points are sorted in clockwise order starting from the top left
:param points: Array of zone corner points in pixel coordinates
:return: Ordered list of points
"""
top_left = min(
points, key=lambda p: (p[1], p[0])
) # Find the top-left point (min y, then x)
# Remove the top-left point from the list of points
remaining_points = [p for p in points if not np.array_equal(p, top_left)]
# Sort the remaining points based on the angle relative to the top-left point
def angle_from_top_left(point):
x, y = point[0] - top_left[0], point[1] - top_left[1]
return math.atan2(y, x)
sorted_points = sorted(remaining_points, key=angle_from_top_left)
return [top_left] + sorted_points
def create_ground_plane(zone_points, distances):
"""
Create a ground plane that accounts for perspective distortion using real-world dimensions for each side of the zone.
:param zone_points: Array of zone corner points in pixel coordinates
[[x1, y1], [x2, y2], [x3, y3], [x4, y4]]
:param distances: Real-world dimensions ordered by A, B, C, D
:return: Function that calculates real-world distance per pixel at any coordinate
"""
A, B, C, D = zone_points
# Calculate pixel lengths of each side
AB_px = np.linalg.norm(np.array(B) - np.array(A))
BC_px = np.linalg.norm(np.array(C) - np.array(B))
CD_px = np.linalg.norm(np.array(D) - np.array(C))
DA_px = np.linalg.norm(np.array(A) - np.array(D))
AB, BC, CD, DA = map(float, distances)
AB_scale = AB / AB_px
BC_scale = BC / BC_px
CD_scale = CD / CD_px
DA_scale = DA / DA_px
def distance_per_pixel(x, y):
"""
Calculate the real-world distance per pixel at a given (x, y) coordinate.
:param x: X-coordinate in the image
:param y: Y-coordinate in the image
:return: Real-world distance per pixel at the given (x, y) coordinate
"""
# Normalize x and y within the zone
x_norm = (x - A[0]) / (B[0] - A[0])
y_norm = (y - A[1]) / (D[1] - A[1])
# Interpolate scales horizontally and vertically
vertical_scale = AB_scale + (CD_scale - AB_scale) * y_norm
horizontal_scale = DA_scale + (BC_scale - DA_scale) * x_norm
# Combine horizontal and vertical scales
return (vertical_scale + horizontal_scale) / 2
return distance_per_pixel
def calculate_real_world_speed(
zone_contour,
distances,
velocity_pixels,
position,
camera_fps,
):
"""
Calculate the real-world speed of a tracked object, accounting for perspective,
directly from the zone string.
:param zone_contour: Array of absolute zone points
:param distances: List of distances of each side, ordered by A, B, C, D
:param velocity_pixels: List of tuples representing velocity in pixels/frame
:param position: Current position of the object (x, y) in pixels
:param camera_fps: Frames per second of the camera
:return: speed and velocity angle direction
"""
# order the zone_contour points clockwise starting at top left
ordered_zone_contour = order_points_clockwise(zone_contour)
# find the indices that would sort the original zone_contour to match ordered_zone_contour
sort_indices = [
np.where((zone_contour == point).all(axis=1))[0][0]
for point in ordered_zone_contour
]
# Reorder distances to match the new order of zone_contour
distances = np.array(distances)
ordered_distances = distances[sort_indices]
ground_plane = create_ground_plane(ordered_zone_contour, ordered_distances)
if not isinstance(velocity_pixels, np.ndarray):
velocity_pixels = np.array(velocity_pixels)
avg_velocity_pixels = velocity_pixels.mean(axis=0)
# get the real-world distance per pixel at the object's current position and calculate real speed
scale = ground_plane(position[0], position[1])
speed_real = avg_velocity_pixels * scale * camera_fps
# euclidean speed in real-world units/second
speed_magnitude = np.linalg.norm(speed_real)
# movement direction
dx, dy = avg_velocity_pixels
angle = math.degrees(math.atan2(dy, dx))
if angle < 0:
angle += 360
return speed_magnitude, angle

View File

@ -486,7 +486,7 @@ def detect(
detect_config: DetectConfig,
object_detector,
frame,
model_config,
model_config: ModelConfig,
region,
objects_to_track,
object_filters,

View File

@ -53,13 +53,26 @@ function useValue(): useValueReturn {
const cameraStates: WsState = {};
Object.entries(cameraActivity).forEach(([name, state]) => {
const { record, detect, snapshots, audio, autotracking } =
const {
record,
detect,
snapshots,
audio,
notifications,
notifications_suspended,
autotracking,
} =
// @ts-expect-error we know this is correct
state["config"];
cameraStates[`${name}/recordings/state`] = record ? "ON" : "OFF";
cameraStates[`${name}/detect/state`] = detect ? "ON" : "OFF";
cameraStates[`${name}/snapshots/state`] = snapshots ? "ON" : "OFF";
cameraStates[`${name}/audio/state`] = audio ? "ON" : "OFF";
cameraStates[`${name}/notifications/state`] = notifications
? "ON"
: "OFF";
cameraStates[`${name}/notifications/suspended`] =
notifications_suspended || 0;
cameraStates[`${name}/ptz_autotracker/state`] = autotracking
? "ON"
: "OFF";
@ -413,3 +426,39 @@ export function useTrackedObjectUpdate(): { payload: string } {
} = useWs("tracked_object_update", "");
return useDeepMemo(JSON.parse(payload as string));
}
export function useNotifications(camera: string): {
payload: ToggleableSetting;
send: (payload: string, retain?: boolean) => void;
} {
const {
value: { payload },
send,
} = useWs(`${camera}/notifications/state`, `${camera}/notifications/set`);
return { payload: payload as ToggleableSetting, send };
}
export function useNotificationSuspend(camera: string): {
payload: string;
send: (payload: number, retain?: boolean) => void;
} {
const {
value: { payload },
send,
} = useWs(
`${camera}/notifications/suspended`,
`${camera}/notifications/suspend`,
);
return { payload: payload as string, send };
}
export function useNotificationTest(): {
payload: string;
send: (payload: string, retain?: boolean) => void;
} {
const {
value: { payload },
send,
} = useWs("notification_test", "notification_test");
return { payload: payload as string, send };
}

View File

@ -116,6 +116,9 @@ export default function SearchFilterGroup({
if (filter?.min_score || filter?.max_score) {
sortTypes.push("score_desc", "score_asc");
}
if (filter?.min_speed || filter?.max_speed) {
sortTypes.push("speed_desc", "speed_asc");
}
if (filter?.event_id || filter?.query) {
sortTypes.push("relevance");
}
@ -498,6 +501,8 @@ export function SortTypeContent({
date_desc: "Date (Descending)",
score_asc: "Object Score (Ascending)",
score_desc: "Object Score (Descending)",
speed_asc: "Estimated Speed (Ascending)",
speed_desc: "Estimated Speed (Descending)",
relevance: "Relevance",
};

View File

@ -216,11 +216,14 @@ export default function InputWithTags({
type == "after" ||
type == "time_range" ||
type == "min_score" ||
type == "max_score"
type == "max_score" ||
type == "min_speed" ||
type == "max_speed"
) {
const newFilters = { ...filters };
let timestamp = 0;
let score = 0;
let speed = 0;
switch (type) {
case "before":
@ -294,6 +297,40 @@ export default function InputWithTags({
newFilters[type] = score / 100;
}
break;
case "min_speed":
case "max_speed":
speed = parseFloat(value);
if (score >= 0) {
// Check for conflicts between min_speed and max_speed
if (
type === "min_speed" &&
filters.max_speed !== undefined &&
speed > filters.max_speed
) {
toast.error(
"The 'min_speed' must be less than or equal to the 'max_speed'.",
{
position: "top-center",
},
);
return;
}
if (
type === "max_speed" &&
filters.min_speed !== undefined &&
speed < filters.min_speed
) {
toast.error(
"The 'max_speed' must be greater than or equal to the 'min_speed'.",
{
position: "top-center",
},
);
return;
}
newFilters[type] = speed;
}
break;
case "time_range":
newFilters[type] = value;
break;
@ -369,6 +406,10 @@ export default function InputWithTags({
}`;
} else if (filterType === "min_score" || filterType === "max_score") {
return Math.round(Number(filterValues) * 100).toString() + "%";
} else if (filterType === "min_speed" || filterType === "max_speed") {
return (
filterValues + (config?.ui.unit_system == "metric" ? " kph" : " mph")
);
} else if (
filterType === "has_clip" ||
filterType === "has_snapshot" ||
@ -397,7 +438,11 @@ export default function InputWithTags({
((filterType === "min_score" || filterType === "max_score") &&
!isNaN(Number(trimmedValue)) &&
Number(trimmedValue) >= 50 &&
Number(trimmedValue) <= 100)
Number(trimmedValue) <= 100) ||
((filterType === "min_speed" || filterType === "max_speed") &&
!isNaN(Number(trimmedValue)) &&
Number(trimmedValue) >= 1 &&
Number(trimmedValue) <= 150)
) {
createFilter(
filterType,

View File

@ -25,6 +25,7 @@ import { baseUrl } from "@/api/baseUrl";
import { cn } from "@/lib/utils";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import {
FaArrowRight,
FaCheckCircle,
FaChevronDown,
FaDownload,
@ -329,6 +330,30 @@ function ObjectDetailsTab({
}
}, [search]);
const averageEstimatedSpeed = useMemo(() => {
if (!search || !search.data?.average_estimated_speed) {
return undefined;
}
if (search.data?.average_estimated_speed != 0) {
return search.data?.average_estimated_speed.toFixed(1);
} else {
return undefined;
}
}, [search]);
const velocityAngle = useMemo(() => {
if (!search || !search.data?.velocity_angle) {
return undefined;
}
if (search.data?.velocity_angle != 0) {
return search.data?.velocity_angle.toFixed(1);
} else {
return undefined;
}
}, [search]);
const updateDescription = useCallback(() => {
if (!search) {
return;
@ -440,6 +465,29 @@ function ObjectDetailsTab({
{score}%{subLabelScore && ` (${subLabelScore}%)`}
</div>
</div>
{averageEstimatedSpeed && (
<div className="flex flex-col gap-1.5">
<div className="text-sm text-primary/40">Estimated Speed</div>
<div className="flex flex-col space-y-0.5 text-sm">
{averageEstimatedSpeed && (
<div className="flex flex-row items-center gap-2">
{averageEstimatedSpeed}{" "}
{config?.ui.unit_system == "imperial" ? "mph" : "kph"}{" "}
{velocityAngle != undefined && (
<span className="text-primary/40">
<FaArrowRight
size={10}
style={{
transform: `rotate(${(360 - Number(velocityAngle)) % 360}deg)`,
}}
/>
</span>
)}
</div>
)}
</div>
</div>
)}
<div className="flex flex-col gap-1.5">
<div className="text-sm text-primary/40">Camera</div>
<div className="text-sm capitalize">

View File

@ -71,9 +71,11 @@ export default function SearchFilterDialog({
currentFilter &&
(currentFilter.time_range ||
(currentFilter.min_score ?? 0) > 0.5 ||
(currentFilter.min_speed ?? 1) > 1 ||
(currentFilter.has_snapshot ?? 0) === 1 ||
(currentFilter.has_clip ?? 0) === 1 ||
(currentFilter.max_score ?? 1) < 1 ||
(currentFilter.max_speed ?? 150) < 150 ||
(currentFilter.zones?.length ?? 0) > 0 ||
(currentFilter.sub_labels?.length ?? 0) > 0),
[currentFilter],
@ -124,6 +126,14 @@ export default function SearchFilterDialog({
setCurrentFilter({ ...currentFilter, min_score: min, max_score: max })
}
/>
<SpeedFilterContent
config={config}
minSpeed={currentFilter.min_speed}
maxSpeed={currentFilter.max_speed}
setSpeedRange={(min, max) =>
setCurrentFilter({ ...currentFilter, min_speed: min, max_speed: max })
}
/>
<SnapshotClipFilterContent
config={config}
hasSnapshot={
@ -178,6 +188,8 @@ export default function SearchFilterDialog({
search_type: undefined,
min_score: undefined,
max_score: undefined,
min_speed: undefined,
max_speed: undefined,
has_snapshot: undefined,
has_clip: undefined,
}));
@ -521,6 +533,62 @@ export function ScoreFilterContent({
);
}
type SpeedFilterContentProps = {
config?: FrigateConfig;
minSpeed: number | undefined;
maxSpeed: number | undefined;
setSpeedRange: (min: number | undefined, max: number | undefined) => void;
};
export function SpeedFilterContent({
config,
minSpeed,
maxSpeed,
setSpeedRange,
}: SpeedFilterContentProps) {
return (
<div className="overflow-x-hidden">
<DropdownMenuSeparator className="mb-3" />
<div className="mb-3 text-lg">
Estimated Speed ({config?.ui.unit_system == "metric" ? "kph" : "mph"})
</div>
<div className="flex items-center gap-1">
<Input
className="w-14 text-center"
inputMode="numeric"
value={minSpeed ?? 1}
onChange={(e) => {
const value = e.target.value;
if (value) {
setSpeedRange(parseInt(value), maxSpeed ?? 1.0);
}
}}
/>
<DualThumbSlider
className="mx-2 w-full"
min={1}
max={150}
step={1}
value={[minSpeed ?? 1, maxSpeed ?? 150]}
onValueChange={([min, max]) => setSpeedRange(min, max)}
/>
<Input
className="w-14 text-center"
inputMode="numeric"
value={maxSpeed ?? 150}
onChange={(e) => {
const value = e.target.value;
if (value) {
setSpeedRange(minSpeed ?? 1, parseInt(value));
}
}}
/>
</div>
</div>
);
}
type SnapshotClipContentProps = {
config?: FrigateConfig;
hasSnapshot: boolean | undefined;

View File

@ -17,6 +17,7 @@ type PolygonCanvasProps = {
activePolygonIndex: number | undefined;
hoveredPolygonIndex: number | null;
selectedZoneMask: PolygonType[] | undefined;
activeLine?: number;
};
export function PolygonCanvas({
@ -29,6 +30,7 @@ export function PolygonCanvas({
activePolygonIndex,
hoveredPolygonIndex,
selectedZoneMask,
activeLine,
}: PolygonCanvasProps) {
const [isLoaded, setIsLoaded] = useState(false);
const [image, setImage] = useState<HTMLImageElement | undefined>();
@ -281,12 +283,14 @@ export function PolygonCanvas({
stageRef={stageRef}
key={index}
points={polygon.points}
distances={polygon.distances}
isActive={index === activePolygonIndex}
isHovered={index === hoveredPolygonIndex}
isFinished={polygon.isFinished}
color={polygon.color}
handlePointDragMove={handlePointDragMove}
handleGroupDragEnd={handleGroupDragEnd}
activeLine={activeLine}
/>
),
)}
@ -298,12 +302,14 @@ export function PolygonCanvas({
stageRef={stageRef}
key={activePolygonIndex}
points={polygons[activePolygonIndex].points}
distances={polygons[activePolygonIndex].distances}
isActive={true}
isHovered={activePolygonIndex === hoveredPolygonIndex}
isFinished={polygons[activePolygonIndex].isFinished}
color={polygons[activePolygonIndex].color}
handlePointDragMove={handlePointDragMove}
handleGroupDragEnd={handleGroupDragEnd}
activeLine={activeLine}
/>
)}
</Layer>

View File

@ -6,7 +6,7 @@ import {
useRef,
useState,
} from "react";
import { Line, Circle, Group } from "react-konva";
import { Line, Circle, Group, Text, Rect } from "react-konva";
import {
minMax,
toRGBColorString,
@ -20,23 +20,27 @@ import { Vector2d } from "konva/lib/types";
type PolygonDrawerProps = {
stageRef: RefObject<Konva.Stage>;
points: number[][];
distances: number[];
isActive: boolean;
isHovered: boolean;
isFinished: boolean;
color: number[];
handlePointDragMove: (e: KonvaEventObject<MouseEvent | TouchEvent>) => void;
handleGroupDragEnd: (e: KonvaEventObject<MouseEvent | TouchEvent>) => void;
activeLine?: number;
};
export default function PolygonDrawer({
stageRef,
points,
distances,
isActive,
isHovered,
isFinished,
color,
handlePointDragMove,
handleGroupDragEnd,
activeLine,
}: PolygonDrawerProps) {
const vertexRadius = 6;
const flattenedPoints = useMemo(() => flattenPoints(points), [points]);
@ -113,6 +117,33 @@ export default function PolygonDrawer({
stageRef.current.container().style.cursor = cursor;
}, [stageRef, cursor]);
// Calculate midpoints for distance labels based on sorted points
const midpoints = useMemo(() => {
const midpointsArray = [];
for (let i = 0; i < points.length; i++) {
const p1 = points[i];
const p2 = points[(i + 1) % points.length];
const midpointX = (p1[0] + p2[0]) / 2;
const midpointY = (p1[1] + p2[1]) / 2;
midpointsArray.push([midpointX, midpointY]);
}
return midpointsArray;
}, [points]);
// Determine the points for the active line
const activeLinePoints = useMemo(() => {
if (
activeLine === undefined ||
activeLine < 1 ||
activeLine > points.length
) {
return [];
}
const p1 = points[activeLine - 1];
const p2 = points[activeLine % points.length];
return [p1[0], p1[1], p2[0], p2[1]];
}, [activeLine, points]);
return (
<Group
name="polygon"
@ -158,6 +189,14 @@ export default function PolygonDrawer({
}
/>
)}
{isActive && activeLinePoints.length > 0 && (
<Line
points={activeLinePoints}
stroke="white"
strokeWidth={6}
hitStrokeWidth={12}
/>
)}
{points.map((point, index) => {
if (!isActive) {
return;
@ -195,6 +234,43 @@ export default function PolygonDrawer({
/>
);
})}
{isFinished && (
<Group>
{midpoints.map((midpoint, index) => {
const [x, y] = midpoint;
const distance = distances[index];
if (distance === undefined) return null;
const squareSize = 22;
return (
<Group
key={`distance-group-${index}`}
x={x - squareSize / 2}
y={y - squareSize / 2}
>
<Rect
width={squareSize}
height={squareSize}
fill={colorString(true)}
stroke="white"
strokeWidth={1}
/>
<Text
text={`${distance}`}
width={squareSize}
y={4}
fontSize={16}
fontFamily="Arial"
fill="white"
align="center"
verticalAlign="middle"
/>
</Group>
);
})}
</Group>
)}
</Group>
);
}

View File

@ -40,6 +40,7 @@ type ZoneEditPaneProps = {
setIsLoading: React.Dispatch<React.SetStateAction<boolean>>;
onSave?: () => void;
onCancel?: () => void;
setActiveLine: React.Dispatch<React.SetStateAction<number | undefined>>;
};
export default function ZoneEditPane({
@ -52,6 +53,7 @@ export default function ZoneEditPane({
setIsLoading,
onSave,
onCancel,
setActiveLine,
}: ZoneEditPaneProps) {
const { data: config, mutate: updateConfig } =
useSWR<FrigateConfig>("config");
@ -80,7 +82,19 @@ export default function ZoneEditPane({
}
}, [polygon, config]);
const formSchema = z.object({
const [lineA, lineB, lineC, lineD] = useMemo(() => {
const distances =
polygon?.camera &&
polygon?.name &&
config?.cameras[polygon.camera]?.zones[polygon.name]?.distances;
return Array.isArray(distances)
? distances.map((value) => parseFloat(value) || 0)
: [undefined, undefined, undefined, undefined];
}, [polygon, config]);
const formSchema = z
.object({
name: z
.string()
.min(2, {
@ -138,11 +152,74 @@ export default function ZoneEditPane({
objects: z.array(z.string()).optional(),
review_alerts: z.boolean().default(false).optional(),
review_detections: z.boolean().default(false).optional(),
});
speedEstimation: z.boolean().default(false),
lineA: z.coerce
.number()
.min(0.1, {
message: "Distance must be greater than or equal to 0.1",
})
.optional()
.or(z.literal("")),
lineB: z.coerce
.number()
.min(0.1, {
message: "Distance must be greater than or equal to 0.1",
})
.optional()
.or(z.literal("")),
lineC: z.coerce
.number()
.min(0.1, {
message: "Distance must be greater than or equal to 0.1",
})
.optional()
.or(z.literal("")),
lineD: z.coerce
.number()
.min(0.1, {
message: "Distance must be greater than or equal to 0.1",
})
.optional()
.or(z.literal("")),
speed_threshold: z.coerce
.number()
.min(0.1, {
message: "Speed threshold must be greater than or equal to 0.1",
})
.optional()
.or(z.literal("")),
})
.refine(
(data) => {
if (data.speedEstimation) {
return !!data.lineA && !!data.lineB && !!data.lineC && !!data.lineD;
}
return true;
},
{
message: "All distance fields must be filled to use speed estimation.",
path: ["speedEstimation"],
},
)
.refine(
(data) => {
// Prevent speed estimation when loitering_time is greater than 0
return !(
data.speedEstimation &&
data.loitering_time &&
data.loitering_time > 0
);
},
{
message:
"Zones with loitering times greater than 0 should not be used with speed estimation.",
path: ["loitering_time"],
},
);
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
mode: "onChange",
mode: "onBlur",
defaultValues: {
name: polygon?.name ?? "",
inertia:
@ -155,9 +232,31 @@ export default function ZoneEditPane({
config?.cameras[polygon.camera]?.zones[polygon.name]?.loitering_time,
isFinished: polygon?.isFinished ?? false,
objects: polygon?.objects ?? [],
speedEstimation: !!(lineA || lineB || lineC || lineD),
lineA,
lineB,
lineC,
lineD,
speed_threshold:
polygon?.camera &&
polygon?.name &&
config?.cameras[polygon.camera]?.zones[polygon.name]?.speed_threshold,
},
});
useEffect(() => {
if (
form.watch("speedEstimation") &&
polygon &&
polygon.points.length !== 4
) {
toast.error(
"Speed estimation has been disabled for this zone. Zones with speed estimation must have exactly 4 points.",
);
form.setValue("speedEstimation", false);
}
}, [polygon, form]);
const saveToConfig = useCallback(
async (
{
@ -165,6 +264,12 @@ export default function ZoneEditPane({
inertia,
loitering_time,
objects: form_objects,
speedEstimation,
lineA,
lineB,
lineC,
lineD,
speed_threshold,
}: ZoneFormValuesType, // values submitted via the form
objects: string[],
) => {
@ -261,9 +366,32 @@ export default function ZoneEditPane({
loiteringTimeQuery = `&cameras.${polygon?.camera}.zones.${zoneName}.loitering_time=${loitering_time}`;
}
let distancesQuery = "";
const distances = [lineA, lineB, lineC, lineD].filter(Boolean).join(",");
if (speedEstimation) {
distancesQuery = `&cameras.${polygon?.camera}.zones.${zoneName}.distances=${distances}`;
} else {
if (distances != "") {
distancesQuery = `&cameras.${polygon?.camera}.zones.${zoneName}.distances`;
}
}
let speedThresholdQuery = "";
if (speed_threshold >= 0 && speedEstimation) {
speedThresholdQuery = `&cameras.${polygon?.camera}.zones.${zoneName}.speed_threshold=${speed_threshold}`;
} else {
if (
polygon?.camera &&
polygon?.name &&
config?.cameras[polygon.camera]?.zones[polygon.name]?.speed_threshold
) {
speedThresholdQuery = `&cameras.${polygon?.camera}.zones.${zoneName}.speed_threshold`;
}
}
axios
.put(
`config/set?cameras.${polygon?.camera}.zones.${zoneName}.coordinates=${coordinates}${inertiaQuery}${loiteringTimeQuery}${objectQueries}${alertQueries}${detectionQueries}`,
`config/set?cameras.${polygon?.camera}.zones.${zoneName}.coordinates=${coordinates}${inertiaQuery}${loiteringTimeQuery}${speedThresholdQuery}${distancesQuery}${objectQueries}${alertQueries}${detectionQueries}`,
{ requires_restart: 0 },
)
.then((res) => {
@ -456,6 +584,183 @@ export default function ZoneEditPane({
/>
</FormItem>
<Separator className="my-2 flex bg-secondary" />
<FormField
control={form.control}
name="speedEstimation"
render={({ field }) => (
<FormItem>
<div className="flex items-center space-x-2">
<FormControl>
<div className="my-2.5 flex w-full items-center justify-between">
<FormLabel
className="cursor-pointer text-primary"
htmlFor="allLabels"
>
Speed Estimation
</FormLabel>
<Switch
checked={field.value}
onCheckedChange={(checked) => {
if (
checked &&
polygons &&
activePolygonIndex &&
polygons[activePolygonIndex].points.length !== 4
) {
toast.error(
"Zones with speed estimation must have exactly 4 points.",
);
return;
}
const loiteringTime =
form.getValues("loitering_time");
if (checked && loiteringTime && loiteringTime > 0) {
toast.error(
"Zones with loitering times greater than 0 should not be used with speed estimation.",
);
}
field.onChange(checked);
}}
/>
</div>
</FormControl>
</div>
<FormDescription>
Enable speed estimation for objects in this zone. The zone
must have exactly 4 points.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{form.watch("speedEstimation") &&
polygons &&
activePolygonIndex &&
polygons[activePolygonIndex].points.length === 4 && (
<>
<FormField
control={form.control}
name="lineA"
render={({ field }) => (
<FormItem>
<FormLabel>
Line A distance (
{config?.ui.unit_system == "imperial"
? "feet"
: "meters"}
)
</FormLabel>
<FormControl>
<Input
className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
{...field}
onFocus={() => setActiveLine(1)}
onBlur={() => setActiveLine(undefined)}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="lineB"
render={({ field }) => (
<FormItem>
<FormLabel>
Line B distance (
{config?.ui.unit_system == "imperial"
? "feet"
: "meters"}
)
</FormLabel>
<FormControl>
<Input
className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
{...field}
onFocus={() => setActiveLine(2)}
onBlur={() => setActiveLine(undefined)}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="lineC"
render={({ field }) => (
<FormItem>
<FormLabel>
Line C distance (
{config?.ui.unit_system == "imperial"
? "feet"
: "meters"}
)
</FormLabel>
<FormControl>
<Input
className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
{...field}
onFocus={() => setActiveLine(3)}
onBlur={() => setActiveLine(undefined)}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="lineD"
render={({ field }) => (
<FormItem>
<FormLabel>
Line D distance (
{config?.ui.unit_system == "imperial"
? "feet"
: "meters"}
)
</FormLabel>
<FormControl>
<Input
className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
{...field}
onFocus={() => setActiveLine(4)}
onBlur={() => setActiveLine(undefined)}
/>
</FormControl>
</FormItem>
)}
/>
<Separator className="my-2 flex bg-secondary" />
<FormField
control={form.control}
name="speed_threshold"
render={({ field }) => (
<FormItem>
<FormLabel>
Speed Threshold (
{config?.ui.unit_system == "imperial" ? "mph" : "kph"})
</FormLabel>
<FormControl>
<Input
className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
{...field}
/>
</FormControl>
<FormDescription>
Specifies a minimum speed for objects to be considered
in this zone.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
<FormField
control={form.control}
name="isFinished"
@ -557,7 +862,9 @@ export function ZoneObjectSelector({
useEffect(() => {
updateLabelFilter(currentLabels);
}, [currentLabels, updateLabelFilter]);
// we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentLabels]);
return (
<>

View File

@ -112,6 +112,8 @@ export default function Explore() {
search_type: searchSearchParams["search_type"],
min_score: searchSearchParams["min_score"],
max_score: searchSearchParams["max_score"],
min_speed: searchSearchParams["min_speed"],
max_speed: searchSearchParams["max_speed"],
has_snapshot: searchSearchParams["has_snapshot"],
is_submitted: searchSearchParams["is_submitted"],
has_clip: searchSearchParams["has_clip"],
@ -145,6 +147,8 @@ export default function Explore() {
search_type: searchSearchParams["search_type"],
min_score: searchSearchParams["min_score"],
max_score: searchSearchParams["max_score"],
min_speed: searchSearchParams["min_speed"],
max_speed: searchSearchParams["max_speed"],
has_snapshot: searchSearchParams["has_snapshot"],
is_submitted: searchSearchParams["is_submitted"],
has_clip: searchSearchParams["has_clip"],

View File

@ -8,6 +8,7 @@ export type Polygon = {
objects: string[];
points: number[][];
pointsOrder?: number[];
distances: number[];
isFinished: boolean;
color: number[];
};
@ -18,6 +19,12 @@ export type ZoneFormValuesType = {
loitering_time: number;
isFinished: boolean;
objects: string[];
speedEstimation: boolean;
lineA: number;
lineB: number;
lineC: number;
lineD: number;
speed_threshold: number;
};
export type ObjectMaskFormValuesType = {

View File

@ -8,6 +8,7 @@ export interface UiConfig {
strftime_fmt?: string;
dashboard: boolean;
order: number;
unit_system?: "metric" | "imperial";
}
export interface BirdseyeConfig {
@ -110,6 +111,11 @@ export interface CameraConfig {
timestamp: boolean;
};
name: string;
notifications: {
enabled: boolean;
email?: string;
enabled_in_config: boolean;
};
objects: {
filters: {
[objectName: string]: {
@ -223,9 +229,11 @@ export interface CameraConfig {
zones: {
[zoneName: string]: {
coordinates: string;
distances: string[];
filters: Record<string, unknown>;
inertia: number;
loitering_time: number;
speed_threshold: number;
objects: string[];
color: number[];
};
@ -390,6 +398,7 @@ export interface FrigateConfig {
notifications: {
enabled: boolean;
email?: string;
enabled_in_config: boolean;
};
objects: {

View File

@ -55,6 +55,8 @@ export type SearchResult = {
ratio: number;
type: "object" | "audio" | "manual";
description?: string;
average_estimated_speed: number;
velocity_angle: number;
};
};
@ -68,6 +70,8 @@ export type SearchFilter = {
after?: number;
min_score?: number;
max_score?: number;
min_speed?: number;
max_speed?: number;
has_snapshot?: number;
has_clip?: number;
is_submitted?: number;
@ -89,6 +93,8 @@ export type SearchQueryParams = {
after?: string;
min_score?: number;
max_score?: number;
min_speed?: number;
max_speed?: number;
search_type?: string;
limit?: number;
in_progress?: number;

View File

@ -158,6 +158,8 @@ export default function SearchView({
after: [formatDateToLocaleString(-5)],
min_score: ["50"],
max_score: ["100"],
min_speed: ["1"],
max_speed: ["150"],
has_clip: ["yes", "no"],
has_snapshot: ["yes", "no"],
...(config?.plus?.enabled &&

View File

@ -61,6 +61,7 @@ export default function MasksAndZonesView({
);
const containerRef = useRef<HTMLDivElement | null>(null);
const [editPane, setEditPane] = useState<PolygonType | undefined>(undefined);
const [activeLine, setActiveLine] = useState<number | undefined>();
const { addMessage } = useContext(StatusBarMessagesContext)!;
@ -161,6 +162,7 @@ export default function MasksAndZonesView({
...(allPolygons || []),
{
points: [],
distances: [],
isFinished: false,
type,
typeIndex: 9999,
@ -238,6 +240,8 @@ export default function MasksAndZonesView({
scaledWidth,
scaledHeight,
),
distances:
zoneData.distances?.map((distance) => parseFloat(distance)) ?? [],
isFinished: true,
color: zoneData.color,
}),
@ -267,6 +271,7 @@ export default function MasksAndZonesView({
scaledWidth,
scaledHeight,
),
distances: [],
isFinished: true,
color: [0, 0, 255],
}));
@ -290,6 +295,7 @@ export default function MasksAndZonesView({
scaledWidth,
scaledHeight,
),
distances: [],
isFinished: true,
color: [128, 128, 128],
}));
@ -316,6 +322,7 @@ export default function MasksAndZonesView({
scaledWidth,
scaledHeight,
),
distances: [],
isFinished: true,
color: [128, 128, 128],
};
@ -391,6 +398,7 @@ export default function MasksAndZonesView({
setIsLoading={setIsLoading}
onCancel={handleCancel}
onSave={handleSave}
setActiveLine={setActiveLine}
/>
)}
{editPane == "motion_mask" && (
@ -653,6 +661,7 @@ export default function MasksAndZonesView({
activePolygonIndex={activePolygonIndex}
hoveredPolygonIndex={hoveredPolygonIndex}
selectedZoneMask={selectedZoneMask}
activeLine={activeLine}
/>
) : (
<Skeleton className="size-full" />

View File

@ -14,24 +14,38 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import { Toaster } from "@/components/ui/sonner";
import { Switch } from "@/components/ui/switch";
import { StatusBarMessagesContext } from "@/context/statusbar-provider";
import { FrigateConfig } from "@/types/frigateConfig";
import { zodResolver } from "@hookform/resolvers/zod";
import axios from "axios";
import { useCallback, useContext, useEffect, useState } from "react";
import { useCallback, useContext, useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { LuExternalLink } from "react-icons/lu";
import { LuCheck, LuExternalLink, LuX } from "react-icons/lu";
import { Link } from "react-router-dom";
import { toast } from "sonner";
import useSWR from "swr";
import { z } from "zod";
import {
useNotifications,
useNotificationSuspend,
useNotificationTest,
} from "@/api/ws";
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from "@/components/ui/select";
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
import FilterSwitch from "@/components/filter/FilterSwitch";
const NOTIFICATION_SERVICE_WORKER = "notifications-worker.js";
type NotificationSettingsValueType = {
enabled: boolean;
allEnabled: boolean;
email?: string;
cameras: string[];
};
type NotificationsSettingsViewProps = {
@ -47,9 +61,52 @@ export default function NotificationView({
},
);
const allCameras = useMemo(() => {
if (!config) {
return [];
}
return Object.values(config.cameras).sort(
(aConf, bConf) => aConf.ui.order - bConf.ui.order,
);
}, [config]);
const notificationCameras = useMemo(() => {
if (!config) {
return [];
}
return Object.values(config.cameras)
.filter(
(conf) =>
conf.enabled &&
conf.notifications &&
conf.notifications.enabled_in_config,
)
.sort((aConf, bConf) => aConf.ui.order - bConf.ui.order);
}, [config]);
const { send: sendTestNotification } = useNotificationTest();
// status bar
const { addMessage, removeMessage } = useContext(StatusBarMessagesContext)!;
const [changedValue, setChangedValue] = useState(false);
useEffect(() => {
if (changedValue) {
addMessage(
"notification_settings",
`Unsaved notification settings`,
undefined,
`notification_settings`,
);
} else {
removeMessage("notification_settings", `notification_settings`);
}
// we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [changedValue]);
// notification key handling
@ -87,7 +144,7 @@ export default function NotificationView({
setRegistration(null);
});
toast.success(
"Successfully registered for notifications. Restart to start receiving notifications.",
"Successfully registered for notifications. Restarting Frigate is required before any notifications (including a test notification) can be sent.",
{
position: "top-center",
},
@ -122,28 +179,44 @@ export default function NotificationView({
const [isLoading, setIsLoading] = useState(false);
const formSchema = z.object({
enabled: z.boolean(),
allEnabled: z.boolean(),
email: z.string(),
cameras: z.array(z.string()),
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
mode: "onChange",
defaultValues: {
enabled: config?.notifications.enabled,
allEnabled: config?.notifications.enabled,
email: config?.notifications.email,
cameras: config?.notifications.enabled
? []
: notificationCameras.map((c) => c.name),
},
});
const watchCameras = form.watch("cameras");
useEffect(() => {
if (watchCameras.length > 0) {
form.setValue("allEnabled", false);
}
}, [watchCameras, allCameras, form]);
const onCancel = useCallback(() => {
if (!config) {
return;
}
setUnsavedChanges(false);
setChangedValue(false);
form.reset({
enabled: config.notifications.enabled,
allEnabled: config.notifications.enabled,
email: config.notifications.email || "",
cameras: config?.notifications.enabled
? []
: notificationCameras.map((c) => c.name),
});
// we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps
@ -151,11 +224,27 @@ export default function NotificationView({
const saveToConfig = useCallback(
async (
{ enabled, email }: NotificationSettingsValueType, // values submitted via the form
{ allEnabled, email, cameras }: NotificationSettingsValueType, // values submitted via the form
) => {
const allCameraNames = allCameras.map((cam) => cam.name);
const enabledCameraQueries = cameras
.map((cam) => `&cameras.${cam}.notifications.enabled=True`)
.join("");
const disabledCameraQueries = allCameraNames
.filter((cam) => !cameras.includes(cam))
.map(
(cam) =>
`&cameras.${cam}.notifications.enabled=${allEnabled ? "True" : "False"}`,
)
.join("");
const allCameraQueries = enabledCameraQueries + disabledCameraQueries;
axios
.put(
`config/set?notifications.enabled=${enabled}&notifications.email=${email}`,
`config/set?notifications.enabled=${allEnabled ? "True" : "False"}&notifications.email=${email}${allCameraQueries}`,
{
requires_restart: 0,
},
@ -182,7 +271,7 @@ export default function NotificationView({
setIsLoading(false);
});
},
[updateConfig, setIsLoading],
[updateConfig, setIsLoading, allCameras],
);
function onSubmit(values: z.infer<typeof formSchema>) {
@ -195,6 +284,8 @@ export default function NotificationView({
<div className="flex size-full flex-col md:flex-row">
<Toaster position="top-center" closeButton={true} />
<div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto rounded-lg border-[1px] border-secondary-foreground bg-background_alt p-2 md:order-none md:mb-0 md:mr-2 md:mt-0">
<div className="grid w-full grid-cols-1 gap-4 md:grid-cols-2">
<div className="col-span-1">
<Heading as="h3" className="my-2">
Notification Settings
</Heading>
@ -202,8 +293,8 @@ export default function NotificationView({
<div className="max-w-6xl">
<div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 text-sm text-primary-variant">
<p>
Frigate can natively send push notifications to your device when
it is running in the browser or installed as a PWA.
Frigate can natively send push notifications to your device
when it is running in the browser or installed as a PWA.
</p>
<div className="flex items-center text-primary">
<Link
@ -224,28 +315,6 @@ export default function NotificationView({
onSubmit={form.handleSubmit(onSubmit)}
className="mt-2 space-y-6"
>
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem>
<FormControl>
<div className="flex flex-row items-center justify-start gap-2">
<Label className="cursor-pointer" htmlFor="auto-live">
Notifications
</Label>
<Switch
id="auto-live"
checked={field.value}
onCheckedChange={(checked) => {
return field.onChange(checked);
}}
/>
</div>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
@ -260,14 +329,84 @@ export default function NotificationView({
/>
</FormControl>
<FormDescription>
Entering a valid email is required, as this is used by the
push server in case problems occur.
Entering a valid email is required, as this is used by
the push server in case problems occur.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="flex w-full flex-row items-center gap-2 pt-2 md:w-[25%]">
<FormField
control={form.control}
name="cameras"
render={({ field }) => (
<FormItem>
{allCameras && allCameras?.length > 0 ? (
<>
<div className="mb-2">
<FormLabel className="flex flex-row items-center text-base">
Cameras
</FormLabel>
</div>
<div className="max-w-md space-y-2 rounded-lg bg-secondary p-4">
<FormField
control={form.control}
name="allEnabled"
render={({ field }) => (
<FilterSwitch
label="All Cameras"
isChecked={field.value}
onCheckedChange={(checked) => {
setChangedValue(true);
if (checked) {
form.setValue("cameras", []);
}
field.onChange(checked);
}}
/>
)}
/>
{allCameras?.map((camera) => (
<FilterSwitch
key={camera.name}
label={camera.name.replaceAll("_", " ")}
isChecked={field.value?.includes(camera.name)}
onCheckedChange={(checked) => {
setChangedValue(true);
let newCameras;
if (checked) {
newCameras = [
...field.value,
camera.name,
];
} else {
newCameras = field.value?.filter(
(value) => value !== camera.name,
);
}
field.onChange(newCameras);
form.setValue("allEnabled", false);
}}
/>
))}
</div>
</>
) : (
<div className="font-normal text-destructive">
No cameras available.
</div>
)}
<FormMessage />
<FormDescription>
Select the cameras to enable notifications for.
</FormDescription>
</FormItem>
)}
/>
<div className="flex w-full flex-row items-center gap-2 pt-2 md:w-[50%]">
<Button
className="flex flex-1"
aria-label="Cancel"
@ -295,10 +434,15 @@ export default function NotificationView({
</div>
</form>
</Form>
</div>
<div className="mt-4 space-y-6">
<div className="space-y-3">
<Separator className="my-2 flex bg-secondary" />
<div className="col-span-1">
<div className="mt-4 gap-2 space-y-6">
<div className="flex flex-col gap-2 md:max-w-[50%]">
<Separator className="my-2 flex bg-secondary md:hidden" />
<Heading as="h4" className="my-2">
Device-Specific Settings
</Heading>
<Button
aria-label="Register or unregister notifications for this device"
disabled={
@ -317,7 +461,8 @@ export default function NotificationView({
subscribeToNotifications(registration);
} else {
setTimeout(
() => subscribeToNotifications(registration),
() =>
subscribeToNotifications(registration),
1000,
);
}
@ -331,13 +476,57 @@ export default function NotificationView({
pushSubscription?.unsubscribe();
registration.unregister();
setRegistration(null);
removeMessage("notification_settings", "registration");
removeMessage(
"notification_settings",
"registration",
);
});
}
}}
>
{`${registration != null ? "Unregister" : "Register"} for notifications on this device`}
</Button>
{registration != null && registration.active && (
<Button
aria-label="Send a test notification"
onClick={() => sendTestNotification("notification_test")}
>
Send a test notification
</Button>
)}
</div>
</div>
{notificationCameras.length > 0 && (
<div className="mt-4 gap-2 space-y-6">
<div className="space-y-3">
<Separator className="my-2 flex bg-secondary" />
<Heading as="h4" className="my-2">
Global Settings
</Heading>
<div className="max-w-xl">
<div className="mb-5 mt-2 flex flex-col gap-2 text-sm text-primary-variant">
<p>
Temporarily suspend notifications for specific cameras
on all registered devices.
</p>
</div>
</div>
<div className="flex max-w-2xl flex-col gap-2.5">
<div className="rounded-lg bg-secondary p-5">
<div className="grid gap-6">
{notificationCameras.map((item) => (
<CameraNotificationSwitch
config={config}
camera={item.name}
/>
))}
</div>
</div>
</div>
</div>
</div>
)}
</div>
</div>
</div>
@ -345,3 +534,110 @@ export default function NotificationView({
</>
);
}
type CameraNotificationSwitchProps = {
config?: FrigateConfig;
camera: string;
};
export function CameraNotificationSwitch({
config,
camera,
}: CameraNotificationSwitchProps) {
const { payload: notificationState, send: sendNotification } =
useNotifications(camera);
const { payload: notificationSuspendUntil, send: sendNotificationSuspend } =
useNotificationSuspend(camera);
const [isSuspended, setIsSuspended] = useState<boolean>(false);
useEffect(() => {
if (notificationSuspendUntil) {
setIsSuspended(
notificationSuspendUntil !== "0" || notificationState === "OFF",
);
}
}, [notificationSuspendUntil, notificationState]);
const handleSuspend = (duration: string) => {
setIsSuspended(true);
if (duration == "off") {
sendNotification("OFF");
} else {
sendNotificationSuspend(parseInt(duration));
}
};
const handleCancelSuspension = () => {
sendNotification("ON");
sendNotificationSuspend(0);
};
const formatSuspendedUntil = (timestamp: string) => {
if (timestamp === "0") return "Frigate restarts.";
return formatUnixTimestampToDateTime(parseInt(timestamp), {
time_style: "medium",
date_style: "medium",
timezone: config?.ui.timezone,
strftime_fmt: `%b %d, ${config?.ui.time_format == "24hour" ? "%H:%M" : "%I:%M %p"}`,
});
};
return (
<div className="flex items-center justify-between gap-2">
<div className="flex flex-col items-start justify-start">
<div className="flex flex-row items-center justify-start gap-3">
{!isSuspended ? (
<LuCheck className="size-6 text-success" />
) : (
<LuX className="size-6 text-danger" />
)}
<div className="flex flex-col">
<Label
className="text-md cursor-pointer capitalize text-primary"
htmlFor="camera"
>
{camera.replaceAll("_", " ")}
</Label>
{!isSuspended ? (
<div className="flex flex-row items-center gap-2 text-sm text-success">
Notifications Active
</div>
) : (
<div className="flex flex-row items-center gap-2 text-sm text-danger">
Notifications suspended until{" "}
{formatSuspendedUntil(notificationSuspendUntil)}
</div>
)}
</div>
</div>
</div>
{!isSuspended ? (
<Select onValueChange={handleSuspend}>
<SelectTrigger className="w-auto">
<SelectValue placeholder="Suspend" />
</SelectTrigger>
<SelectContent>
<SelectItem value="5">Suspend for 5 minutes</SelectItem>
<SelectItem value="10">Suspend for 10 minutes</SelectItem>
<SelectItem value="30">Suspend for 30 minutes</SelectItem>
<SelectItem value="60">Suspend for 1 hour</SelectItem>
<SelectItem value="840">Suspend for 12 hours</SelectItem>
<SelectItem value="1440">Suspend for 24 hours</SelectItem>
<SelectItem value="off">Suspend until restart</SelectItem>
</SelectContent>
</Select>
) : (
<Button
variant="destructive"
size="sm"
onClick={handleCancelSuspension}
>
Cancel Suspension
</Button>
)}
</div>
);
}