mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-12 17:52:48 +03:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74c932c3f5 | ||
|
|
a0042b8d7f | ||
|
|
659d26658b | ||
|
|
6c47bbfbbc | ||
|
|
c4d53484b7 | ||
|
|
928dac506a | ||
|
|
c1bde8ca20 |
@@ -1,7 +1,7 @@
|
|||||||
default_target: local
|
default_target: local
|
||||||
|
|
||||||
COMMIT_HASH := $(shell git log -1 --pretty=format:"%h"|tail -1)
|
COMMIT_HASH := $(shell git log -1 --pretty=format:"%h"|tail -1)
|
||||||
VERSION = 0.18.0
|
VERSION = 0.19.0
|
||||||
IMAGE_REPO ?= ghcr.io/blakeblackshear/frigate
|
IMAGE_REPO ?= ghcr.io/blakeblackshear/frigate
|
||||||
GITHUB_REF_NAME ?= $(shell git rev-parse --abbrev-ref HEAD)
|
GITHUB_REF_NAME ?= $(shell git rev-parse --abbrev-ref HEAD)
|
||||||
BOARDS= #Initialized empty
|
BOARDS= #Initialized empty
|
||||||
|
|||||||
@@ -251,11 +251,17 @@ birdseye:
|
|||||||
# Optional: Encoding quality of the mpeg1 feed (default: shown below)
|
# Optional: Encoding quality of the mpeg1 feed (default: shown below)
|
||||||
# 1 is the highest quality, and 31 is the lowest. Lower quality feeds utilize less CPU resources.
|
# 1 is the highest quality, and 31 is the lowest. Lower quality feeds utilize less CPU resources.
|
||||||
quality: 8
|
quality: 8
|
||||||
# Optional: Mode of the view. Available options are: objects, motion, and continuous
|
# Optional: Activity types that include cameras in Birdseye (default: shown below)
|
||||||
# objects - cameras are included if they have had a tracked object within the last 30 seconds
|
# Multiple activity types can be enabled at the same time.
|
||||||
# motion - cameras are included if motion was detected in the last 30 seconds
|
mode:
|
||||||
# continuous - all cameras are included always
|
# Optional: All cameras are included always (default: shown below)
|
||||||
mode: objects
|
continuous: False
|
||||||
|
# Optional: Cameras are included if motion was detected in the last 30 seconds (default: shown below)
|
||||||
|
motion: False
|
||||||
|
# Optional: Cameras are included if they have had an active tracked object within the last 30 seconds (default: shown below)
|
||||||
|
objects: True
|
||||||
|
# Optional: Cameras are included while they have a stationary tracked object (default: shown below)
|
||||||
|
stationary_objects: False
|
||||||
# Optional: Threshold for camera activity to stop showing camera (default: shown below)
|
# Optional: Threshold for camera activity to stop showing camera (default: shown below)
|
||||||
inactivity_threshold: 30
|
inactivity_threshold: 30
|
||||||
# Optional: Configure the birdseye layout
|
# Optional: Configure the birdseye layout
|
||||||
|
|||||||
@@ -18,13 +18,14 @@ Each camera tile in Birdseye is composed from the frames of the stream assigned
|
|||||||
|
|
||||||
## Birdseye Behavior
|
## Birdseye Behavior
|
||||||
|
|
||||||
### Birdseye Modes
|
### Birdseye Activity Types
|
||||||
|
|
||||||
Birdseye offers different modes to customize which cameras show under which circumstances.
|
Birdseye offers independent activity types that control when cameras are shown. Multiple activity types can be enabled together.
|
||||||
|
|
||||||
- **continuous:** All cameras are always included
|
- **continuous:** The camera is always included
|
||||||
- **motion:** Cameras that have detected motion within the last 30 seconds are included
|
- **motion:** The camera is included when motion was detected within the last 30 seconds
|
||||||
- **objects:** Cameras that have tracked an active object within the last 30 seconds are included
|
- **objects:** The camera is included when an active object was tracked within the last 30 seconds
|
||||||
|
- **stationary_objects:** The camera is included while a stationary object is tracked
|
||||||
|
|
||||||
### Custom Birdseye Icon
|
### Custom Birdseye Icon
|
||||||
|
|
||||||
@@ -39,27 +40,30 @@ To include a camera in Birdseye view only for specific circumstances, or exclude
|
|||||||
|
|
||||||
**Global settings:** Navigate to <NavPath path="Settings > System > Birdseye" /> to configure the default Birdseye behavior for all cameras.
|
**Global settings:** Navigate to <NavPath path="Settings > System > Birdseye" /> to configure the default Birdseye behavior for all cameras.
|
||||||
|
|
||||||
**Per-camera overrides:** Navigate to <NavPath path="Settings > Camera configuration > Birdseye" /> to override the mode or disable Birdseye for a specific camera.
|
**Per-camera overrides:** Navigate to <NavPath path="Settings > Camera configuration > Birdseye" /> to override the activity types or disable Birdseye for a specific camera.
|
||||||
|
|
||||||
| Field | Description |
|
| Field | Description |
|
||||||
| ------------------- | ------------------------------------------------------------- |
|
| ---------------------- | ---------------------------------------------------------- |
|
||||||
| **Enable Birdseye** | Whether this camera appears in Birdseye view |
|
| **Enable Birdseye** | Whether this camera appears in Birdseye view |
|
||||||
| **Tracking mode** | When to show the camera: `continuous`, `motion`, or `objects` |
|
| **Activity types** | Conditions that determine when to show the camera |
|
||||||
|
|
||||||
</TabItem>
|
</TabItem>
|
||||||
<TabItem value="yaml">
|
<TabItem value="yaml">
|
||||||
|
|
||||||
```yaml {8-10,12-14}
|
```yaml {8-11,13-15}
|
||||||
# Include all cameras by default in Birdseye view
|
# Include all cameras by default in Birdseye view
|
||||||
birdseye:
|
birdseye:
|
||||||
enabled: True
|
enabled: True
|
||||||
mode: continuous
|
mode:
|
||||||
|
continuous: True
|
||||||
|
|
||||||
cameras:
|
cameras:
|
||||||
front:
|
front:
|
||||||
# Only include the "front" camera in Birdseye view when objects are detected
|
# Only include the "front" camera in Birdseye view when objects are detected
|
||||||
birdseye:
|
birdseye:
|
||||||
mode: objects
|
mode:
|
||||||
|
continuous: False
|
||||||
|
objects: True
|
||||||
back:
|
back:
|
||||||
# Exclude the "back" camera from Birdseye view
|
# Exclude the "back" camera from Birdseye view
|
||||||
birdseye:
|
birdseye:
|
||||||
|
|||||||
@@ -555,20 +555,21 @@ Topic with current state of Birdseye for a camera. Published values are `ON` and
|
|||||||
|
|
||||||
### `frigate/<camera_name>/birdseye_mode/set`
|
### `frigate/<camera_name>/birdseye_mode/set`
|
||||||
|
|
||||||
Topic to set Birdseye mode for a camera. Birdseye offers different modes to customize under which circumstances the camera is shown.
|
Topic to set the Birdseye activity types for a camera. Send one uppercase activity type or combine multiple types with commas, for example `MOTION,OBJECTS,STATIONARY_OBJECTS`.
|
||||||
|
|
||||||
_Note: Changing the value from `CONTINUOUS` -> `MOTION | OBJECTS` will take up to 30 seconds for
|
_Note: Changing the value from `CONTINUOUS` to non-continuous activity types will take up to 30 seconds for
|
||||||
the camera to be removed from the view._
|
the camera to be removed from the view._
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
| ------------ | ----------------------------------------------------------------- |
|
| -------------------- | ---------------------------------------------------------------- |
|
||||||
| `CONTINUOUS` | Always included |
|
| `CONTINUOUS` | Always included |
|
||||||
| `MOTION` | Show when detected motion within the last 30 seconds are included |
|
| `MOTION` | Shown if motion was detected within the last 30 seconds |
|
||||||
| `OBJECTS` | Shown if an active object tracked within the last 30 seconds |
|
| `OBJECTS` | Shown if an active object was tracked within the last 30 seconds |
|
||||||
|
| `STATIONARY_OBJECTS` | Shown while a stationary object is tracked |
|
||||||
|
|
||||||
### `frigate/<camera_name>/birdseye_mode/state`
|
### `frigate/<camera_name>/birdseye_mode/state`
|
||||||
|
|
||||||
Topic with current state of the Birdseye mode for a camera. Published values are `CONTINUOUS`, `MOTION`, `OBJECTS`.
|
Topic with the current Birdseye activity types for a camera. Multiple enabled types are published as a comma-separated value in the order `OBJECTS`, `MOTION`, `STATIONARY_OBJECTS`, `CONTINUOUS`.
|
||||||
|
|
||||||
### `frigate/<camera_name>/notifications/set`
|
### `frigate/<camera_name>/notifications/set`
|
||||||
|
|
||||||
|
|||||||
Vendored
+38
@@ -2946,6 +2946,44 @@ paths:
|
|||||||
- frigateUserAuth: []
|
- frigateUserAuth: []
|
||||||
x-required-role: any
|
x-required-role: any
|
||||||
description: '**Access:** Any authenticated user.'
|
description: '**Access:** Any authenticated user.'
|
||||||
|
/categorized_object_names:
|
||||||
|
get:
|
||||||
|
tags:
|
||||||
|
- App
|
||||||
|
summary: Get known object names by object type
|
||||||
|
description: |-
|
||||||
|
**Access:** Any authenticated user.
|
||||||
|
|
||||||
|
Returns the sub labels and attributes this install can attach,
|
||||||
|
grouped by object type. Unlike /sub_labels, which reflects what has already been
|
||||||
|
detected, this reads the config and model files, so it covers recognized face
|
||||||
|
names, named license plates, custom object classification categories, and the
|
||||||
|
detector attributes of tracked objects.
|
||||||
|
operationId: categorized_object_names_categorized_object_names_get
|
||||||
|
parameters:
|
||||||
|
- name: object_type
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
anyOf:
|
||||||
|
- type: string
|
||||||
|
- type: 'null'
|
||||||
|
title: Object Type
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Successful Response
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: {}
|
||||||
|
'422':
|
||||||
|
description: Validation Error
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/HTTPValidationError'
|
||||||
|
security:
|
||||||
|
- frigateUserAuth: []
|
||||||
|
x-required-role: any
|
||||||
/audio_labels:
|
/audio_labels:
|
||||||
get:
|
get:
|
||||||
tags:
|
tags:
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ from frigate.util.config import (
|
|||||||
find_config_file,
|
find_config_file,
|
||||||
redact_credential,
|
redact_credential,
|
||||||
)
|
)
|
||||||
|
from frigate.util.object_names import get_categorized_object_names
|
||||||
from frigate.util.schema import get_config_schema
|
from frigate.util.schema import get_config_schema
|
||||||
from frigate.util.services import (
|
from frigate.util.services import (
|
||||||
get_nvidia_driver_info,
|
get_nvidia_driver_info,
|
||||||
@@ -1313,6 +1314,28 @@ def get_sub_labels(
|
|||||||
return JSONResponse(content=sub_labels)
|
return JSONResponse(content=sub_labels)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/categorized_object_names",
|
||||||
|
dependencies=[Depends(allow_any_authenticated())],
|
||||||
|
summary="Get known object names by object type",
|
||||||
|
description="""Returns the sub labels and attributes this install can attach,
|
||||||
|
grouped by object type. Unlike /sub_labels, which reflects what has already been
|
||||||
|
detected, this reads the config and model files, so it covers recognized face
|
||||||
|
names, named license plates, custom object classification categories, and the
|
||||||
|
detector attributes of tracked objects.""",
|
||||||
|
)
|
||||||
|
def categorized_object_names(
|
||||||
|
request: Request,
|
||||||
|
object_type: str | None = None,
|
||||||
|
allowed_cameras: list[str] = Depends(get_allowed_cameras_for_filter),
|
||||||
|
):
|
||||||
|
return JSONResponse(
|
||||||
|
content=get_categorized_object_names(
|
||||||
|
request.app.frigate_config, allowed_cameras, object_type
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/audio_labels", dependencies=[Depends(allow_any_authenticated())])
|
@router.get("/audio_labels", dependencies=[Depends(allow_any_authenticated())])
|
||||||
def get_audio_labels():
|
def get_audio_labels():
|
||||||
labels = load_labels("/audio-labelmap.txt", prefill=521)
|
labels = load_labels("/audio-labelmap.txt", prefill=521)
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ def require_admin_by_default():
|
|||||||
"/nvinfo",
|
"/nvinfo",
|
||||||
"/labels",
|
"/labels",
|
||||||
"/sub_labels",
|
"/sub_labels",
|
||||||
|
"/categorized_object_names",
|
||||||
"/plus/models",
|
"/plus/models",
|
||||||
"/recognized_license_plates",
|
"/recognized_license_plates",
|
||||||
"/timeline",
|
"/timeline",
|
||||||
|
|||||||
+25
-2
@@ -50,6 +50,7 @@ from frigate.jobs.vlm_watch import (
|
|||||||
stop_vlm_watch_job,
|
stop_vlm_watch_job,
|
||||||
)
|
)
|
||||||
from frigate.models import Event
|
from frigate.models import Event
|
||||||
|
from frigate.util.object_names import get_categorized_object_names
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -539,6 +540,11 @@ async def execute_tool(
|
|||||||
if tool_name == "search_objects":
|
if tool_name == "search_objects":
|
||||||
return await _execute_search_objects(request, arguments, allowed_cameras)
|
return await _execute_search_objects(request, arguments, allowed_cameras)
|
||||||
|
|
||||||
|
if tool_name == "get_categorized_object_names":
|
||||||
|
return JSONResponse(
|
||||||
|
content=_execute_get_categorized_object_names(request, allowed_cameras)
|
||||||
|
)
|
||||||
|
|
||||||
if tool_name == "find_similar_objects":
|
if tool_name == "find_similar_objects":
|
||||||
result = await _execute_find_similar_objects(
|
result = await _execute_find_similar_objects(
|
||||||
request, arguments, allowed_cameras
|
request, arguments, allowed_cameras
|
||||||
@@ -717,6 +723,21 @@ async def _execute_set_camera_state(
|
|||||||
return {"success": True, "camera": camera, "feature": feature, "value": value}
|
return {"success": True, "camera": camera, "feature": feature, "value": value}
|
||||||
|
|
||||||
|
|
||||||
|
def _execute_get_categorized_object_names(
|
||||||
|
request: Request,
|
||||||
|
allowed_cameras: list[str],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
names = get_categorized_object_names(request.app.frigate_config, allowed_cameras)
|
||||||
|
|
||||||
|
if not names:
|
||||||
|
return {
|
||||||
|
"names": {},
|
||||||
|
"message": "No names configured; search by label or semantic_query.",
|
||||||
|
}
|
||||||
|
|
||||||
|
return {"names": names}
|
||||||
|
|
||||||
|
|
||||||
async def _execute_tool_internal(
|
async def _execute_tool_internal(
|
||||||
tool_name: str,
|
tool_name: str,
|
||||||
arguments: dict[str, Any],
|
arguments: dict[str, Any],
|
||||||
@@ -741,6 +762,8 @@ async def _execute_tool_internal(
|
|||||||
except (json.JSONDecodeError, AttributeError) as e:
|
except (json.JSONDecodeError, AttributeError) as e:
|
||||||
logger.warning(f"Failed to extract tool result: {e}")
|
logger.warning(f"Failed to extract tool result: {e}")
|
||||||
return {"error": "Failed to parse tool result"}
|
return {"error": "Failed to parse tool result"}
|
||||||
|
elif tool_name == "get_categorized_object_names":
|
||||||
|
return _execute_get_categorized_object_names(request, allowed_cameras)
|
||||||
elif tool_name == "find_similar_objects":
|
elif tool_name == "find_similar_objects":
|
||||||
return await _execute_find_similar_objects(request, arguments, allowed_cameras)
|
return await _execute_find_similar_objects(request, arguments, allowed_cameras)
|
||||||
elif tool_name == "set_camera_state":
|
elif tool_name == "set_camera_state":
|
||||||
@@ -773,8 +796,8 @@ async def _execute_tool_internal(
|
|||||||
else:
|
else:
|
||||||
logger.error(
|
logger.error(
|
||||||
"Tool call failed: unknown tool %r. Expected one of: search_objects, find_similar_objects, "
|
"Tool call failed: unknown tool %r. Expected one of: search_objects, find_similar_objects, "
|
||||||
"get_live_context, start_camera_watch, stop_camera_watch, get_profile_status, get_recap. "
|
"get_categorized_object_names, get_live_context, start_camera_watch, stop_camera_watch, "
|
||||||
"Arguments received: %s",
|
"get_profile_status, get_recap. Arguments received: %s",
|
||||||
tool_name,
|
tool_name,
|
||||||
json.dumps(arguments),
|
json.dumps(arguments),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -575,11 +575,11 @@ async def vod_ts(
|
|||||||
Recordings.start_time,
|
Recordings.start_time,
|
||||||
)
|
)
|
||||||
.where(
|
.where(
|
||||||
Recordings.start_time.between(start_ts, end_ts)
|
Recordings.camera == camera_name,
|
||||||
| Recordings.end_time.between(start_ts, end_ts)
|
Recordings.start_time >= start_ts - MAX_SEGMENT_DURATION,
|
||||||
| ((start_ts > Recordings.start_time) & (end_ts < Recordings.end_time))
|
Recordings.start_time <= end_ts,
|
||||||
|
Recordings.end_time >= start_ts,
|
||||||
)
|
)
|
||||||
.where(Recordings.camera == camera_name)
|
|
||||||
.order_by(Recordings.start_time.asc())
|
.order_by(Recordings.start_time.asc())
|
||||||
.iterator()
|
.iterator()
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ from frigate.api.defs.query.recordings_query_parameters import (
|
|||||||
)
|
)
|
||||||
from frigate.api.defs.response.generic_response import GenericResponse
|
from frigate.api.defs.response.generic_response import GenericResponse
|
||||||
from frigate.api.defs.tags import Tags
|
from frigate.api.defs.tags import Tags
|
||||||
from frigate.const import RECORD_DIR
|
from frigate.const import MAX_SEGMENT_DURATION, RECORD_DIR
|
||||||
from frigate.models import Event, Recordings
|
from frigate.models import Event, Recordings
|
||||||
from frigate.util.time import get_dst_transitions
|
from frigate.util.time import get_dst_transitions
|
||||||
|
|
||||||
@@ -243,6 +243,7 @@ async def recordings(
|
|||||||
)
|
)
|
||||||
.where(
|
.where(
|
||||||
Recordings.camera == camera_name,
|
Recordings.camera == camera_name,
|
||||||
|
Recordings.start_time >= after - MAX_SEGMENT_DURATION,
|
||||||
Recordings.end_time >= after,
|
Recordings.end_time >= after,
|
||||||
Recordings.start_time <= before,
|
Recordings.start_time <= before,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from frigate.camera.activity_manager import AudioActivityManager, CameraActivity
|
|||||||
from frigate.comms.base_communicator import Communicator
|
from frigate.comms.base_communicator import Communicator
|
||||||
from frigate.comms.runtime_state import RuntimeStatePersistence
|
from frigate.comms.runtime_state import RuntimeStatePersistence
|
||||||
from frigate.comms.webpush import WebPushClient
|
from frigate.comms.webpush import WebPushClient
|
||||||
from frigate.config import BirdseyeModeEnum, FrigateConfig
|
from frigate.config import BirdseyeModeConfig, FrigateConfig
|
||||||
from frigate.config.camera.updater import (
|
from frigate.config.camera.updater import (
|
||||||
CameraConfigUpdateEnum,
|
CameraConfigUpdateEnum,
|
||||||
CameraConfigUpdatePublisher,
|
CameraConfigUpdatePublisher,
|
||||||
@@ -882,8 +882,9 @@ class Dispatcher:
|
|||||||
def _on_birdseye_mode_command(self, camera_name: str, payload: str) -> None:
|
def _on_birdseye_mode_command(self, camera_name: str, payload: str) -> None:
|
||||||
"""Callback for birdseye mode topic."""
|
"""Callback for birdseye mode topic."""
|
||||||
|
|
||||||
if payload not in ["CONTINUOUS", "MOTION", "OBJECTS"]:
|
mode = BirdseyeModeConfig.from_mqtt_payload(payload)
|
||||||
logger.info(f"Invalid birdseye_mode command: {payload}")
|
if mode is None:
|
||||||
|
logger.info("Invalid birdseye_mode command: %s", payload)
|
||||||
return
|
return
|
||||||
|
|
||||||
birdseye_settings = self.config.cameras[camera_name].birdseye
|
birdseye_settings = self.config.cameras[camera_name].birdseye
|
||||||
@@ -892,7 +893,7 @@ class Dispatcher:
|
|||||||
logger.info(f"Birdseye mode not enabled for {camera_name}")
|
logger.info(f"Birdseye mode not enabled for {camera_name}")
|
||||||
return
|
return
|
||||||
|
|
||||||
birdseye_settings.mode = BirdseyeModeEnum(payload.lower())
|
birdseye_settings.mode = mode
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Setting birdseye mode for {camera_name} to {birdseye_settings.mode}"
|
f"Setting birdseye mode for {camera_name} to {birdseye_settings.mode}"
|
||||||
)
|
)
|
||||||
@@ -901,7 +902,9 @@ class Dispatcher:
|
|||||||
CameraConfigUpdateTopic(CameraConfigUpdateEnum.birdseye, camera_name),
|
CameraConfigUpdateTopic(CameraConfigUpdateEnum.birdseye, camera_name),
|
||||||
birdseye_settings,
|
birdseye_settings,
|
||||||
)
|
)
|
||||||
self.publish(f"{camera_name}/birdseye_mode/state", payload, retain=True)
|
self.publish(
|
||||||
|
f"{camera_name}/birdseye_mode/state", mode.to_mqtt_payload(), retain=True
|
||||||
|
)
|
||||||
|
|
||||||
def _on_camera_notification_command(self, camera_name: str, payload: str) -> None:
|
def _on_camera_notification_command(self, camera_name: str, payload: str) -> None:
|
||||||
"""Callback for camera level notifications topic."""
|
"""Callback for camera level notifications topic."""
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ class MqttClient(Communicator):
|
|||||||
self.publish(
|
self.publish(
|
||||||
f"{camera_name}/birdseye_mode/state",
|
f"{camera_name}/birdseye_mode/state",
|
||||||
(
|
(
|
||||||
camera.birdseye.mode.value.upper()
|
camera.birdseye.mode.to_mqtt_payload()
|
||||||
if camera.birdseye.enabled
|
if camera.birdseye.enabled
|
||||||
else "OFF"
|
else "OFF"
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
from enum import Enum
|
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from ..base import FrigateBaseModel
|
from ..base import FrigateBaseModel
|
||||||
@@ -8,22 +6,78 @@ __all__ = [
|
|||||||
"BirdseyeCameraConfig",
|
"BirdseyeCameraConfig",
|
||||||
"BirdseyeConfig",
|
"BirdseyeConfig",
|
||||||
"BirdseyeLayoutConfig",
|
"BirdseyeLayoutConfig",
|
||||||
"BirdseyeModeEnum",
|
"BirdseyeModeConfig",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
BIRDSEYE_ACTIVITY_TYPES = (
|
||||||
|
"objects",
|
||||||
|
"motion",
|
||||||
|
"stationary_objects",
|
||||||
|
"continuous",
|
||||||
|
)
|
||||||
|
|
||||||
class BirdseyeModeEnum(str, Enum):
|
|
||||||
objects = "objects"
|
class BirdseyeModeConfig(FrigateBaseModel):
|
||||||
motion = "motion"
|
continuous: bool = Field(
|
||||||
continuous = "continuous"
|
default=False,
|
||||||
|
title="Continuous",
|
||||||
|
description="Always include the camera in Birdseye.",
|
||||||
|
)
|
||||||
|
motion: bool = Field(
|
||||||
|
default=False,
|
||||||
|
title="Motion",
|
||||||
|
description="Include the camera in Birdseye when motion is detected.",
|
||||||
|
)
|
||||||
|
objects: bool = Field(
|
||||||
|
default=False,
|
||||||
|
title="Active objects",
|
||||||
|
description="Include the camera in Birdseye while an active object is tracked.",
|
||||||
|
)
|
||||||
|
stationary_objects: bool = Field(
|
||||||
|
default=False,
|
||||||
|
title="Stationary objects",
|
||||||
|
description="Include the camera in Birdseye while a stationary object is tracked.",
|
||||||
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_index(cls, type):
|
def from_mqtt_payload(cls, payload: str) -> "BirdseyeModeConfig | None":
|
||||||
return list(cls).index(type)
|
"""Create mode options from an uppercase MQTT payload."""
|
||||||
|
raw_modes = payload.split(",")
|
||||||
|
if not raw_modes or any(not mode for mode in raw_modes):
|
||||||
|
return None
|
||||||
|
|
||||||
@classmethod
|
modes = [mode.lower() for mode in raw_modes]
|
||||||
def get(cls, index):
|
if any(
|
||||||
return list(cls)[index]
|
raw_mode != mode.upper() or mode not in BIRDSEYE_ACTIVITY_TYPES
|
||||||
|
for raw_mode, mode in zip(raw_modes, modes)
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
|
||||||
|
if len(modes) != len(set(modes)):
|
||||||
|
return None
|
||||||
|
|
||||||
|
return cls(**{mode: True for mode in modes})
|
||||||
|
|
||||||
|
def has_enabled_activity(self) -> bool:
|
||||||
|
"""Return whether at least one activity type is enabled."""
|
||||||
|
return any(getattr(self, activity) for activity in BIRDSEYE_ACTIVITY_TYPES)
|
||||||
|
|
||||||
|
def to_mqtt_payload(self) -> str:
|
||||||
|
"""Serialize enabled mode options for MQTT state topics."""
|
||||||
|
payload = ",".join(
|
||||||
|
activity.upper()
|
||||||
|
for activity in BIRDSEYE_ACTIVITY_TYPES
|
||||||
|
if getattr(self, activity)
|
||||||
|
)
|
||||||
|
if not payload:
|
||||||
|
raise ValueError("At least one Birdseye activity type must be enabled")
|
||||||
|
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def default_birdseye_mode() -> BirdseyeModeConfig:
|
||||||
|
"""Return the default Birdseye mode configuration."""
|
||||||
|
return BirdseyeModeConfig(objects=True)
|
||||||
|
|
||||||
|
|
||||||
class BirdseyeLayoutConfig(FrigateBaseModel):
|
class BirdseyeLayoutConfig(FrigateBaseModel):
|
||||||
@@ -47,10 +101,10 @@ class BirdseyeConfig(FrigateBaseModel):
|
|||||||
title="Enable Birdseye",
|
title="Enable Birdseye",
|
||||||
description="Enable or disable the Birdseye view feature.",
|
description="Enable or disable the Birdseye view feature.",
|
||||||
)
|
)
|
||||||
mode: BirdseyeModeEnum = Field(
|
mode: BirdseyeModeConfig = Field(
|
||||||
default=BirdseyeModeEnum.objects,
|
default_factory=default_birdseye_mode,
|
||||||
title="Tracking mode",
|
title="Activity types",
|
||||||
description="Mode for including cameras in Birdseye: 'objects', 'motion', or 'continuous'.",
|
description="Activity types that include cameras in Birdseye.",
|
||||||
)
|
)
|
||||||
|
|
||||||
restream: bool = Field(
|
restream: bool = Field(
|
||||||
@@ -102,10 +156,10 @@ class BirdseyeCameraConfig(BaseModel):
|
|||||||
title="Enable Birdseye",
|
title="Enable Birdseye",
|
||||||
description="Enable or disable the Birdseye view feature.",
|
description="Enable or disable the Birdseye view feature.",
|
||||||
)
|
)
|
||||||
mode: BirdseyeModeEnum = Field(
|
mode: BirdseyeModeConfig = Field(
|
||||||
default=BirdseyeModeEnum.objects,
|
default_factory=default_birdseye_mode,
|
||||||
title="Tracking mode",
|
title="Activity types",
|
||||||
description="Mode for including cameras in Birdseye: 'objects', 'motion', or 'continuous'.",
|
description="Activity types that include cameras in Birdseye.",
|
||||||
)
|
)
|
||||||
|
|
||||||
order: int = Field(
|
order: int = Field(
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ from .auth import AuthConfig
|
|||||||
from .base import FrigateBaseModel
|
from .base import FrigateBaseModel
|
||||||
from .camera import CameraConfig, CameraLiveConfig
|
from .camera import CameraConfig, CameraLiveConfig
|
||||||
from .camera.audio import AudioConfig, AudioFilterConfig
|
from .camera.audio import AudioConfig, AudioFilterConfig
|
||||||
from .camera.birdseye import BirdseyeConfig
|
from .camera.birdseye import BirdseyeConfig, BirdseyeModeConfig
|
||||||
from .camera.detect import DetectConfig
|
from .camera.detect import DetectConfig
|
||||||
from .camera.ffmpeg import FfmpegConfig
|
from .camera.ffmpeg import FfmpegConfig
|
||||||
from .camera.genai import GenAIConfig, GenAIRoleEnum
|
from .camera.genai import GenAIConfig, GenAIRoleEnum
|
||||||
@@ -326,8 +326,20 @@ def verify_required_zones_exist(camera_config: CameraConfig) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def verify_profile_overrides_match_base(camera_config: CameraConfig) -> None:
|
def verify_profile_overrides_match_base(camera_config: CameraConfig) -> None:
|
||||||
"""Verify that profile zone and mask IDs reference entries defined on the base camera."""
|
"""Verify profile overrides against the resolved base camera configuration."""
|
||||||
for profile_name, profile in camera_config.profiles.items():
|
for profile_name, profile in camera_config.profiles.items():
|
||||||
|
if profile.birdseye is not None:
|
||||||
|
overrides = profile.birdseye.mode.model_dump(exclude_unset=True)
|
||||||
|
base_mode = camera_config.birdseye.mode.model_dump()
|
||||||
|
resolved_mode = BirdseyeModeConfig.model_validate(
|
||||||
|
deep_merge(overrides, base_mode)
|
||||||
|
)
|
||||||
|
if not resolved_mode.has_enabled_activity():
|
||||||
|
raise ValueError(
|
||||||
|
f"Camera '{camera_config.name}' profile '{profile_name}' must "
|
||||||
|
"enable at least one Birdseye activity type"
|
||||||
|
)
|
||||||
|
|
||||||
if profile.zones:
|
if profile.zones:
|
||||||
for zone_name in profile.zones:
|
for zone_name in profile.zones:
|
||||||
if zone_name not in camera_config.zones:
|
if zone_name not in camera_config.zones:
|
||||||
@@ -998,6 +1010,10 @@ class FrigateConfig(FrigateBaseModel):
|
|||||||
self.cameras[name] = camera_config
|
self.cameras[name] = camera_config
|
||||||
|
|
||||||
verify_config_roles(camera_config)
|
verify_config_roles(camera_config)
|
||||||
|
if not camera_config.birdseye.mode.has_enabled_activity():
|
||||||
|
raise ValueError(
|
||||||
|
f"Camera '{name}' must enable at least one Birdseye activity type"
|
||||||
|
)
|
||||||
verify_valid_live_stream_names(self, camera_config)
|
verify_valid_live_stream_names(self, camera_config)
|
||||||
verify_recording_segments_setup_with_reasonable_time(camera_config)
|
verify_recording_segments_setup_with_reasonable_time(camera_config)
|
||||||
verify_zone_objects_are_tracked(camera_config)
|
verify_zone_objects_are_tracked(camera_config)
|
||||||
|
|||||||
@@ -43,7 +43,9 @@ SECTION_STATE_TOPICS: dict[str, list[tuple[str, Callable[[Any], Any]]]] = {
|
|||||||
("birdseye", lambda c: "ON" if c.birdseye.enabled else "OFF"),
|
("birdseye", lambda c: "ON" if c.birdseye.enabled else "OFF"),
|
||||||
(
|
(
|
||||||
"birdseye_mode",
|
"birdseye_mode",
|
||||||
lambda c: c.birdseye.mode.value.upper() if c.birdseye.enabled else "OFF",
|
lambda c: (
|
||||||
|
c.birdseye.mode.to_mqtt_payload() if c.birdseye.enabled else "OFF"
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
"detect": [("detect", lambda c: "ON" if c.detect.enabled else "OFF")],
|
"detect": [("detect", lambda c: "ON" if c.detect.enabled else "OFF")],
|
||||||
|
|||||||
+65
-121
@@ -262,6 +262,10 @@ def get_tool_definitions(
|
|||||||
`attribute` parameter is exposed for filtering by their labels. When the
|
`attribute` parameter is exposed for filtering by their labels. When the
|
||||||
embeddings model only understands English (JinaV1), the `semantic_query`
|
embeddings model only understands English (JinaV1), the `semantic_query`
|
||||||
description instructs the model to write the query in English.
|
description instructs the model to write the query in English.
|
||||||
|
|
||||||
|
Descriptions here stay mechanical: which tool to reach for, and how the
|
||||||
|
filters relate to each other, is stated once in the system prompt so the
|
||||||
|
guidance is not paid for twice on every request.
|
||||||
"""
|
"""
|
||||||
search_objects_properties: dict[str, Any] = {
|
search_objects_properties: dict[str, Any] = {
|
||||||
"camera": {
|
"camera": {
|
||||||
@@ -270,26 +274,13 @@ def get_tool_definitions(
|
|||||||
},
|
},
|
||||||
"label": {
|
"label": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": (
|
"description": "Tracked object class to filter by.",
|
||||||
"Generic object class to filter by — one of the tracked detector "
|
|
||||||
"labels such as 'person', 'package', 'car', 'dog', 'bird'. Use "
|
|
||||||
"this for broad queries like 'show me all cars today'. Combine "
|
|
||||||
"with semantic_query when the user also describes appearance or "
|
|
||||||
"behavior (e.g. label='person', semantic_query='riding a lawn "
|
|
||||||
"mower')."
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
"sub_label": {
|
"sub_label": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": (
|
"description": (
|
||||||
"Filter by a DISCRETE NAMED entity recognized in the detection. "
|
"Name recognized in the detection: a person, delivery company, "
|
||||||
"Use this for: a known person's name ('John'), a delivery "
|
"animal species or breed, or license plate."
|
||||||
"company ('Amazon', 'UPS'), a recognized animal species or "
|
|
||||||
"breed ('blue jay', 'cardinal', 'golden retriever'), or a "
|
|
||||||
"license plate string. When filtering by a specific name, set "
|
|
||||||
"only sub_label and leave label unset. Do NOT use sub_label "
|
|
||||||
"for descriptions of appearance, clothing, or actions — those "
|
|
||||||
"belong in semantic_query."
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
"after": {
|
"after": {
|
||||||
@@ -313,20 +304,11 @@ def get_tool_definitions(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if attribute_classifications:
|
if attribute_classifications:
|
||||||
model_outline = "; ".join(
|
|
||||||
f"{m['name']} (applies to {', '.join(m['objects']) or 'any object'})"
|
|
||||||
for m in attribute_classifications
|
|
||||||
)
|
|
||||||
search_objects_properties["attribute"] = {
|
search_objects_properties["attribute"] = {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": (
|
"description": (
|
||||||
"Filter by a classification attribute label produced by a "
|
"Attribute label produced by a configured classification model "
|
||||||
"configured attribute classification model. Use this INSTEAD "
|
"(case-sensitive)."
|
||||||
"of semantic_query when the user's request matches one of "
|
|
||||||
"these classifications. Configured models: "
|
|
||||||
f"{model_outline}. "
|
|
||||||
"Set the value to the attribute label that matches the user's "
|
|
||||||
"phrasing (case-sensitive)."
|
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -334,29 +316,12 @@ def get_tool_definitions(
|
|||||||
search_objects_properties["semantic_query"] = {
|
search_objects_properties["semantic_query"] = {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": (
|
"description": (
|
||||||
"Optional natural-language description of a PHYSICAL "
|
"Description of an appearance or activity, used to semantically "
|
||||||
"CHARACTERISTIC, APPEARANCE, or ACTIVITY the user mentioned, "
|
"narrow results."
|
||||||
"used to semantically narrow results. Only set this when the "
|
|
||||||
"user describes something beyond what label and sub_label can "
|
|
||||||
"express on their own.\n"
|
|
||||||
"USE for descriptive phrases like: 'riding a lawn mower', "
|
|
||||||
"'wearing a red jacket', 'carrying a package', 'walking a "
|
|
||||||
"dog', 'on a bicycle', 'holding an umbrella'.\n"
|
|
||||||
"DO NOT USE for:\n"
|
|
||||||
"- specific named people, pets, or delivery companies → use sub_label\n"
|
|
||||||
"- animal species or breed names like 'blue jay', 'cardinal', "
|
|
||||||
"'golden retriever' → use sub_label\n"
|
|
||||||
"- license plate strings → use sub_label\n"
|
|
||||||
"- generic object queries like 'all cars today' or 'every "
|
|
||||||
"person' → use label alone with no semantic_query\n"
|
|
||||||
"When set, combine with label/time/camera/zone filters as "
|
|
||||||
"usual (e.g. label='person', semantic_query='riding a lawn "
|
|
||||||
"mower', after='2024-05-01T00:00:00Z')."
|
|
||||||
+ (
|
+ (
|
||||||
" The configured embeddings model only understands "
|
" The configured embeddings model only understands English, so "
|
||||||
"English, so always write semantic_query in English, "
|
"always write this in English, translating the user's "
|
||||||
"translating the user's description if they phrased it "
|
"description if they phrased it in another language."
|
||||||
"in another language."
|
|
||||||
if embeddings_language == "english"
|
if embeddings_language == "english"
|
||||||
else ""
|
else ""
|
||||||
)
|
)
|
||||||
@@ -364,26 +329,10 @@ def get_tool_definitions(
|
|||||||
}
|
}
|
||||||
|
|
||||||
search_objects_description = (
|
search_objects_description = (
|
||||||
"Search the historical record of detected objects in Frigate. "
|
"Search the historical record of tracked detections. Use this ONLY for "
|
||||||
"Use this ONLY for questions about the PAST — e.g. 'did anyone come by today?', "
|
"questions about the PAST, e.g. 'did anyone come by today?', 'when was the "
|
||||||
"'when was the last car?', 'show me detections from yesterday'. "
|
"last car?'. For alerting on future events use start_camera_watch instead."
|
||||||
"Do NOT use this for monitoring or alerting requests about future events — "
|
|
||||||
"use start_camera_watch instead for those. "
|
|
||||||
"An 'object' in Frigate represents a tracked detection (e.g., a person, package, car).\n\n"
|
|
||||||
"Choose filters based on what the user is asking for:\n"
|
|
||||||
"- Generic class query ('show me all cars today'): set `label` only.\n"
|
|
||||||
"- Specific NAMED entity (known person, delivery company, animal "
|
|
||||||
"species/breed like 'blue jay' or 'golden retriever', license "
|
|
||||||
"plate): set `sub_label` only and leave `label` unset.\n"
|
|
||||||
)
|
)
|
||||||
if semantic_search_enabled:
|
|
||||||
search_objects_description += (
|
|
||||||
"- Physical CHARACTERISTIC, APPEARANCE, or ACTIVITY that is not a "
|
|
||||||
"discrete name ('person riding a lawn mower', 'someone in a red "
|
|
||||||
"jacket', 'person carrying a package'): set `semantic_query` with "
|
|
||||||
"the descriptive phrase, optionally alongside `label` for the "
|
|
||||||
"object class. Do NOT put descriptive phrases in sub_label."
|
|
||||||
)
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -398,20 +347,30 @@ def get_tool_definitions(
|
|||||||
"required": [],
|
"required": [],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "get_categorized_object_names",
|
||||||
|
"description": (
|
||||||
|
"Every name that can be attached as a sub_label, grouped by object "
|
||||||
|
"type: recognized faces, named license plates, classification "
|
||||||
|
"categories, and delivery logos. Takes no arguments and always "
|
||||||
|
"returns the complete map."
|
||||||
|
),
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {},
|
||||||
|
"required": [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "function",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
"name": "find_similar_objects",
|
"name": "find_similar_objects",
|
||||||
"description": (
|
"description": (
|
||||||
"Find tracked objects that are visually and semantically similar "
|
"Find tracked objects visually and semantically similar to a "
|
||||||
"to a specific past event. Use this when the user references a "
|
"specific past event. Requires semantic search to be enabled."
|
||||||
"particular object they have seen and wants to find other "
|
|
||||||
"sightings of the same or similar one ('that green car', 'the "
|
|
||||||
"person in the red jacket', 'the package that was delivered'). "
|
|
||||||
"Prefer this over search_objects whenever the user's intent is "
|
|
||||||
"'find more like this specific one.' Use search_objects first "
|
|
||||||
"only if you need to locate the anchor event. Requires semantic "
|
|
||||||
"search to be enabled."
|
|
||||||
),
|
),
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -473,9 +432,8 @@ def get_tool_definitions(
|
|||||||
"function": {
|
"function": {
|
||||||
"name": "set_camera_state",
|
"name": "set_camera_state",
|
||||||
"description": (
|
"description": (
|
||||||
"Change a camera's feature state (e.g., turn detection on/off, enable/disable recordings). "
|
"Change a camera's feature state, e.g. turn detection on or off. "
|
||||||
"Use camera='*' to apply to all cameras at once. "
|
"Only call this when the user explicitly asks to change a setting. "
|
||||||
"Only call this tool when the user explicitly asks to change a camera setting. "
|
|
||||||
"Requires admin privileges."
|
"Requires admin privileges."
|
||||||
),
|
),
|
||||||
"parameters": {
|
"parameters": {
|
||||||
@@ -510,14 +468,14 @@ def get_tool_definitions(
|
|||||||
],
|
],
|
||||||
"description": (
|
"description": (
|
||||||
"The feature to change. Most features accept ON or OFF. "
|
"The feature to change. Most features accept ON or OFF. "
|
||||||
"birdseye_mode accepts CONTINUOUS, MOTION, or OBJECTS. "
|
"birdseye_mode accepts CONTINUOUS, MOTION, OBJECTS, STATIONARY_OBJECTS, or a comma-separated combination. "
|
||||||
"motion_contour_area and motion_threshold accept a number. "
|
"motion_contour_area and motion_threshold accept a number. "
|
||||||
"profile accepts a profile name or 'none' to deactivate (requires camera='*')."
|
"profile accepts a profile name or 'none' to deactivate (requires camera='*')."
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
"value": {
|
"value": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "The value to set. ON or OFF for toggles, a number for thresholds, a profile name or 'none' for profile.",
|
"description": "The value to set, as accepted by the chosen feature.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"required": ["camera", "feature", "value"],
|
"required": ["camera", "feature", "value"],
|
||||||
@@ -529,11 +487,9 @@ def get_tool_definitions(
|
|||||||
"function": {
|
"function": {
|
||||||
"name": "get_live_context",
|
"name": "get_live_context",
|
||||||
"description": (
|
"description": (
|
||||||
"Get the current live image and detection information for a single camera: objects being tracked, "
|
"Current live image and detections (tracked objects, zones, "
|
||||||
"zones, timestamps. Use this to understand what is visible in the live view. "
|
"timestamps) for one camera. Use this for questions about what is "
|
||||||
"Call this when answering questions about what is happening right now on a specific camera. "
|
"happening right now. Call it again for each additional camera."
|
||||||
"Operates on one camera at a time; call the tool again for each additional camera. "
|
|
||||||
"Wildcards and empty values are not accepted."
|
|
||||||
),
|
),
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -541,8 +497,8 @@ def get_tool_definitions(
|
|||||||
"camera": {
|
"camera": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": (
|
"description": (
|
||||||
"Exact name of a single camera to get live context for. "
|
"Exact name of a single camera. Wildcards (e.g. '*', "
|
||||||
"Wildcards (e.g. '*', 'all') and empty strings are not accepted."
|
"'all') and empty strings are not accepted."
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -555,10 +511,9 @@ def get_tool_definitions(
|
|||||||
"function": {
|
"function": {
|
||||||
"name": "start_camera_watch",
|
"name": "start_camera_watch",
|
||||||
"description": (
|
"description": (
|
||||||
"Start a continuous VLM watch job that monitors a camera and sends a notification "
|
"Start a continuous watch job that monitors a camera and notifies "
|
||||||
"when a specified condition is met. Use this when the user wants to be alerted about "
|
"the user when a condition is met, e.g. 'tell me when guests "
|
||||||
"a future event, e.g. 'tell me when guests arrive' or 'notify me when the package is picked up'. "
|
"arrive'. Only one watch job can run at a time. Returns a job ID."
|
||||||
"Only one watch job can run at a time. Returns a job ID."
|
|
||||||
),
|
),
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -598,10 +553,7 @@ def get_tool_definitions(
|
|||||||
"type": "function",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
"name": "stop_camera_watch",
|
"name": "stop_camera_watch",
|
||||||
"description": (
|
"description": "Cancel the currently running watch job.",
|
||||||
"Cancel the currently running VLM watch job. Use this when the user wants to "
|
|
||||||
"stop a previously started watch, e.g. 'stop watching the front door'."
|
|
||||||
),
|
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {},
|
"properties": {},
|
||||||
@@ -614,11 +566,9 @@ def get_tool_definitions(
|
|||||||
"function": {
|
"function": {
|
||||||
"name": "get_profile_status",
|
"name": "get_profile_status",
|
||||||
"description": (
|
"description": (
|
||||||
"Get the current profile status including the active profile and "
|
"Get the active profile and when each profile was last activated. "
|
||||||
"timestamps of when each profile was last activated. Use this to "
|
"Call this before get_recap to derive the time window for requests "
|
||||||
"determine time periods for recap requests — e.g. when the user asks "
|
"like 'what happened while I was away?'."
|
||||||
"'what happened while I was away?', call this first to find the relevant "
|
|
||||||
"time window based on profile activation history."
|
|
||||||
),
|
),
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -632,11 +582,9 @@ def get_tool_definitions(
|
|||||||
"function": {
|
"function": {
|
||||||
"name": "get_recap",
|
"name": "get_recap",
|
||||||
"description": (
|
"description": (
|
||||||
"Get a recap of all activity (alerts and detections) for a given time period. "
|
"Get all activity (alerts and detections) for a time period, as a "
|
||||||
"Use this after calling get_profile_status to retrieve what happened during "
|
"chronological list with camera, objects, zones, and descriptions "
|
||||||
"a specific window — e.g. 'what happened while I was away?'. Returns a "
|
"when available. Summarize the results for the user."
|
||||||
"chronological list of activity with camera, objects, zones, and GenAI-generated "
|
|
||||||
"descriptions when available. Summarize the results for the user."
|
|
||||||
),
|
),
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -723,14 +671,13 @@ def build_chat_system_prompt(
|
|||||||
)
|
)
|
||||||
speed_units_section = f"\n\nReport object speeds to the user in {speed_unit}."
|
speed_units_section = f"\n\nReport object speeds to the user in {speed_unit}."
|
||||||
|
|
||||||
semantic_search_section = ""
|
filter_routing_section = (
|
||||||
|
"\n\nWhen routing a search_objects call, pick filters by the shape of the user's request:\n"
|
||||||
|
"- Generic class ('show me all cars today'): set `label` only.\n"
|
||||||
|
"- Specific named entity — a known person ('John'), delivery company ('Amazon'), animal species/breed ('blue jay', 'golden retriever'), or license plate: set `sub_label` only and leave `label` unset. Call get_categorized_object_names first and use the exact spelling it returns; a guessed spelling matches nothing. If the name is absent, say it is not configured rather than searching for it."
|
||||||
|
)
|
||||||
if semantic_search_enabled:
|
if semantic_search_enabled:
|
||||||
semantic_search_section = (
|
filter_routing_section += "\n- Physical characteristic, appearance, or activity that is NOT a discrete name ('riding a lawn mower', 'someone in a red jacket'): set `semantic_query` with the descriptive phrase, optionally combined with `label`. Never put descriptive phrases in `sub_label`."
|
||||||
"\n\nWhen routing a search_objects call, pick filters by the shape of the user's request:\n"
|
|
||||||
"- Generic class ('show me all cars today'): set `label` only.\n"
|
|
||||||
"- Specific named entity — a known person ('John'), delivery company ('Amazon'), animal species/breed ('blue jay', 'cardinal', 'golden retriever'), or license plate: set `sub_label` only and leave `label` unset.\n"
|
|
||||||
"- Physical characteristic, appearance, or activity that is NOT a discrete name ('find me people riding a lawn mower', 'someone in a red jacket', 'a person carrying a package'): set `semantic_query` with the descriptive phrase, optionally combined with `label` for the object class. Never put descriptive phrases in `sub_label`."
|
|
||||||
)
|
|
||||||
|
|
||||||
attribute_classification_section = ""
|
attribute_classification_section = ""
|
||||||
if attribute_classifications:
|
if attribute_classifications:
|
||||||
@@ -739,9 +686,9 @@ def build_chat_system_prompt(
|
|||||||
for m in attribute_classifications
|
for m in attribute_classifications
|
||||||
)
|
)
|
||||||
attribute_classification_section = (
|
attribute_classification_section = (
|
||||||
"\n\nAttribute classification models are configured for the following object types:\n"
|
"\n\nConfigured attribute classification models:\n"
|
||||||
f"{model_lines}\n"
|
f"{model_lines}\n"
|
||||||
"When the user's request matches one of these classifications, set the search_objects `attribute` field to the matching label rather than using `semantic_query`. Reserve `semantic_query` for descriptive phrases that fall outside the configured attribute labels."
|
"When the user's request matches one of these classifications, set the search_objects `attribute` field to the matching label (case-sensitive) rather than using `semantic_query`. Reserve `semantic_query` for descriptive phrases outside the configured attribute labels."
|
||||||
)
|
)
|
||||||
|
|
||||||
return f"""You are a helpful assistant for Frigate, a security camera NVR system. You help users answer questions about their cameras, detected objects, and events.
|
return f"""You are a helpful assistant for Frigate, a security camera NVR system. You help users answer questions about their cameras, detected objects, and events.
|
||||||
@@ -750,9 +697,6 @@ Current server local date and time: {current_date_str} at {current_time_str}
|
|||||||
|
|
||||||
Do not start your response with phrases like "I will check...", "Let me see...", or "Let me look...". Answer directly.
|
Do not start your response with phrases like "I will check...", "Let me see...", or "Let me look...". Answer directly.
|
||||||
|
|
||||||
Always present times to the user in the server's local timezone. When tool results include start_time_local and end_time_local, use those exact strings when listing or describing detection times—do not convert or invent timestamps. Do not use UTC or ISO format with Z for the user-facing answer unless the tool result only provides Unix timestamps without local time fields.
|
Always present times in the server's local timezone. When tool results include start_time_local and end_time_local, quote those strings exactly; never convert or invent timestamps, and fall back to UTC or ISO format only when a result has no local time fields. Resolve relative dates like "today" or "this week" against the current date above, and pass dates to tools in ISO 8601 (e.g. {current_date_str}T00:00:00Z for the start of today).
|
||||||
When users ask about "today", "yesterday", "this week", etc., use the current date above as reference.
|
|
||||||
When searching for objects or events, use ISO 8601 format for dates (e.g., {current_date_str}T00:00:00Z for the start of today).
|
|
||||||
Always be accurate with time calculations based on the current date provided.
|
|
||||||
|
|
||||||
When a user refers to a specific object they have seen or describe with identifying details ("that green car", "the person in the red jacket", "a package left today"), prefer the find_similar_objects tool over search_objects. Use search_objects first only to locate the anchor event, then pass its id to find_similar_objects. For generic queries like "show me all cars today", keep using search_objects. If a user message begins with [attached_event:<id>], treat that event id as the anchor for any similarity or "tell me more" request in the same message and call find_similar_objects with that id.{semantic_search_section}{attribute_classification_section}{cameras_section}{speed_units_section}"""
|
When the user refers to a specific object they have seen ("that green car", "the person in the red jacket", "a package left today"), prefer find_similar_objects over search_objects, using search_objects only to locate the anchor event and passing its id along. Keep search_objects for generic queries like "show me all cars today". If a user message begins with [attached_event:<id>], treat that id as the anchor for any similarity or "tell me more" request in the same message.{filter_routing_section}{attribute_classification_section}{cameras_section}{speed_units_section}"""
|
||||||
|
|||||||
+129
-120
@@ -9,6 +9,7 @@ import queue
|
|||||||
import subprocess as sp
|
import subprocess as sp
|
||||||
import threading
|
import threading
|
||||||
import traceback
|
import traceback
|
||||||
|
from dataclasses import dataclass
|
||||||
from multiprocessing.synchronize import Event as MpEvent
|
from multiprocessing.synchronize import Event as MpEvent
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -16,7 +17,7 @@ import cv2
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
from frigate.comms.inter_process import InterProcessRequestor
|
from frigate.comms.inter_process import InterProcessRequestor
|
||||||
from frigate.config import BirdseyeModeEnum, FfmpegConfig, FrigateConfig
|
from frigate.config import BirdseyeModeConfig, FfmpegConfig, FrigateConfig
|
||||||
from frigate.const import BASE_DIR, BIRDSEYE_PIPE, INSTALL_DIR, UPDATE_BIRDSEYE_LAYOUT
|
from frigate.const import BASE_DIR, BIRDSEYE_PIPE, INSTALL_DIR, UPDATE_BIRDSEYE_LAYOUT
|
||||||
from frigate.output.ws_auth import ws_has_camera_access
|
from frigate.output.ws_auth import ws_has_camera_access
|
||||||
from frigate.util.image import (
|
from frigate.util.image import (
|
||||||
@@ -28,6 +29,15 @@ from frigate.util.image import (
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class BirdseyeActivity:
|
||||||
|
"""Activity signals used to decide whether a camera is shown in Birdseye."""
|
||||||
|
|
||||||
|
has_active_object: bool
|
||||||
|
has_stationary_object: bool
|
||||||
|
has_motion: bool
|
||||||
|
|
||||||
|
|
||||||
def get_standard_aspect_ratio(width: int, height: int) -> tuple[int, int]:
|
def get_standard_aspect_ratio(width: int, height: int) -> tuple[int, int]:
|
||||||
"""Ensure that only standard aspect ratios are used."""
|
"""Ensure that only standard aspect ratios are used."""
|
||||||
# it is important that all ratios have the same scale
|
# it is important that all ratios have the same scale
|
||||||
@@ -409,18 +419,16 @@ class BirdsEyeFrameManager:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def camera_active(
|
def camera_active(
|
||||||
self, mode: Any, object_box_count: int, motion_box_count: int
|
self,
|
||||||
|
mode: BirdseyeModeConfig,
|
||||||
|
activity: BirdseyeActivity,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
if mode == BirdseyeModeEnum.continuous:
|
return (
|
||||||
return True
|
mode.continuous
|
||||||
|
or (mode.motion and activity.has_motion)
|
||||||
if mode == BirdseyeModeEnum.motion and motion_box_count > 0:
|
or (mode.objects and activity.has_active_object)
|
||||||
return True
|
or (mode.stationary_objects and activity.has_stationary_object)
|
||||||
|
)
|
||||||
if mode == BirdseyeModeEnum.objects and object_box_count > 0:
|
|
||||||
return True
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
def get_camera_coordinates(self) -> dict[str, dict[str, int]]:
|
def get_camera_coordinates(self) -> dict[str, dict[str, int]]:
|
||||||
"""Return the coordinates of each camera in the current layout."""
|
"""Return the coordinates of each camera in the current layout."""
|
||||||
@@ -604,112 +612,92 @@ class BirdsEyeFrameManager:
|
|||||||
) -> list[list[Any]] | None:
|
) -> list[list[Any]] | None:
|
||||||
"""Calculate the optimal layout for 2+ cameras."""
|
"""Calculate the optimal layout for 2+ cameras."""
|
||||||
|
|
||||||
def map_layout(
|
def find_available_x(
|
||||||
camera_layout: list[list[Any]], row_height: int
|
current_x: int,
|
||||||
) -> tuple[int, int, list[list[Any]] | None]:
|
width: int,
|
||||||
"""Map the calculated layout."""
|
reserved_ranges: list[tuple[int, int]],
|
||||||
candidate_layout = []
|
max_width: int,
|
||||||
starting_x = 0
|
) -> int | None:
|
||||||
x = 0
|
"""Find the first horizontal slot that does not collide with reservations."""
|
||||||
max_width = 0
|
x = current_x
|
||||||
y = 0
|
|
||||||
|
|
||||||
for row in camera_layout:
|
for reserved_start, reserved_end in sorted(reserved_ranges):
|
||||||
final_row = []
|
if x >= reserved_end:
|
||||||
max_width = max(max_width, x)
|
continue
|
||||||
x = starting_x
|
|
||||||
for cameras in row:
|
|
||||||
camera_dims = self.cameras[cameras[0]]["dimensions"].copy()
|
|
||||||
camera_aspect = cameras[1]
|
|
||||||
|
|
||||||
if camera_dims[1] > camera_dims[0]:
|
if x + width <= reserved_start:
|
||||||
scaled_height = int(row_height * 2)
|
return x
|
||||||
scaled_width = int(scaled_height * camera_aspect)
|
|
||||||
starting_x = scaled_width
|
|
||||||
else:
|
|
||||||
scaled_height = row_height
|
|
||||||
scaled_width = int(scaled_height * camera_aspect)
|
|
||||||
|
|
||||||
# layout is too large
|
x = max(x, reserved_end)
|
||||||
if (
|
|
||||||
x + scaled_width > self.canvas.width
|
|
||||||
or y + scaled_height > self.canvas.height
|
|
||||||
):
|
|
||||||
return x + scaled_width, y + scaled_height, None
|
|
||||||
|
|
||||||
final_row.append((cameras[0], (x, y, scaled_width, scaled_height)))
|
if x + width <= max_width:
|
||||||
x += scaled_width
|
return x
|
||||||
|
|
||||||
y += row_height
|
|
||||||
candidate_layout.append(final_row)
|
|
||||||
|
|
||||||
if max_width == 0:
|
|
||||||
max_width = x
|
|
||||||
|
|
||||||
return max_width, y, candidate_layout
|
|
||||||
|
|
||||||
canvas_aspect_x, canvas_aspect_y = self.canvas.get_aspect(coefficient)
|
|
||||||
camera_layout: list[list[Any]] = []
|
|
||||||
camera_layout.append([])
|
|
||||||
starting_x = 0
|
|
||||||
x = starting_x
|
|
||||||
y = 0
|
|
||||||
y_i = 0
|
|
||||||
max_y = 0
|
|
||||||
for camera in cameras_to_add:
|
|
||||||
camera_dims = self.cameras[camera]["dimensions"].copy()
|
|
||||||
camera_aspect_x, camera_aspect_y = self.canvas.get_camera_aspect(
|
|
||||||
camera, camera_dims[0], camera_dims[1]
|
|
||||||
)
|
|
||||||
|
|
||||||
if camera_dims[1] > camera_dims[0]:
|
|
||||||
portrait = True
|
|
||||||
else:
|
|
||||||
portrait = False
|
|
||||||
|
|
||||||
if (x + camera_aspect_x) <= canvas_aspect_x:
|
|
||||||
# insert if camera can fit on current row
|
|
||||||
camera_layout[y_i].append(
|
|
||||||
(
|
|
||||||
camera,
|
|
||||||
camera_aspect_x / camera_aspect_y,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
if portrait:
|
|
||||||
starting_x = camera_aspect_x
|
|
||||||
else:
|
|
||||||
max_y = max(
|
|
||||||
max_y,
|
|
||||||
camera_aspect_y,
|
|
||||||
)
|
|
||||||
|
|
||||||
x += camera_aspect_x
|
|
||||||
else:
|
|
||||||
# move on to the next row and insert
|
|
||||||
y += max_y
|
|
||||||
y_i += 1
|
|
||||||
camera_layout.append([])
|
|
||||||
x = starting_x
|
|
||||||
|
|
||||||
if x + camera_aspect_x > canvas_aspect_x:
|
|
||||||
return None
|
|
||||||
|
|
||||||
camera_layout[y_i].append(
|
|
||||||
(
|
|
||||||
camera,
|
|
||||||
camera_aspect_x / camera_aspect_y,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
x += camera_aspect_x
|
|
||||||
|
|
||||||
if y + max_y > canvas_aspect_y:
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
row_height = int(self.canvas.height / coefficient)
|
def map_layout(row_height: int) -> tuple[int, int, list[list[Any]] | None]:
|
||||||
total_width, total_height, standard_candidate_layout = map_layout(
|
"""Lay out cameras row by row while reserving portrait spans for the next row."""
|
||||||
camera_layout, row_height
|
candidate_layout: list[list[Any]] = []
|
||||||
)
|
reserved_ranges: dict[int, list[tuple[int, int]]] = {}
|
||||||
|
current_row: list[Any] = []
|
||||||
|
row_index = 0
|
||||||
|
row_y = 0
|
||||||
|
row_x = 0
|
||||||
|
max_width = 0
|
||||||
|
max_height = 0
|
||||||
|
|
||||||
|
for camera in cameras_to_add:
|
||||||
|
camera_dims = self.cameras[camera]["dimensions"].copy()
|
||||||
|
camera_aspect_x, camera_aspect_y = self.canvas.get_camera_aspect(
|
||||||
|
camera, camera_dims[0], camera_dims[1]
|
||||||
|
)
|
||||||
|
portrait = camera_dims[1] > camera_dims[0]
|
||||||
|
scaled_height = row_height * 2 if portrait else row_height
|
||||||
|
scaled_width = int(scaled_height * (camera_aspect_x / camera_aspect_y))
|
||||||
|
|
||||||
|
while True:
|
||||||
|
x = find_available_x(
|
||||||
|
row_x,
|
||||||
|
scaled_width,
|
||||||
|
reserved_ranges.get(row_index, []),
|
||||||
|
self.canvas.width,
|
||||||
|
)
|
||||||
|
|
||||||
|
if x is not None and row_y + scaled_height <= self.canvas.height:
|
||||||
|
current_row.append(
|
||||||
|
(camera, (x, row_y, scaled_width, scaled_height))
|
||||||
|
)
|
||||||
|
row_x = x + scaled_width
|
||||||
|
max_width = max(max_width, row_x)
|
||||||
|
max_height = max(max_height, row_y + scaled_height)
|
||||||
|
|
||||||
|
if portrait:
|
||||||
|
reserved_ranges.setdefault(row_index + 1, []).append(
|
||||||
|
(x, row_x)
|
||||||
|
)
|
||||||
|
|
||||||
|
break
|
||||||
|
|
||||||
|
if current_row:
|
||||||
|
candidate_layout.append(current_row)
|
||||||
|
current_row = []
|
||||||
|
|
||||||
|
row_index += 1
|
||||||
|
row_y = row_index * row_height
|
||||||
|
row_x = 0
|
||||||
|
|
||||||
|
if row_y + scaled_height > self.canvas.height:
|
||||||
|
overflow_width = max(max_width, scaled_width)
|
||||||
|
overflow_height = row_y + scaled_height
|
||||||
|
return overflow_width, overflow_height, None
|
||||||
|
|
||||||
|
if current_row:
|
||||||
|
candidate_layout.append(current_row)
|
||||||
|
|
||||||
|
return max_width, max_height, candidate_layout
|
||||||
|
|
||||||
|
row_height = max(1, int(self.canvas.height / coefficient))
|
||||||
|
total_width, total_height, standard_candidate_layout = map_layout(row_height)
|
||||||
|
|
||||||
if not standard_candidate_layout:
|
if not standard_candidate_layout:
|
||||||
# if standard layout didn't work
|
# if standard layout didn't work
|
||||||
@@ -718,9 +706,9 @@ class BirdsEyeFrameManager:
|
|||||||
total_width / self.canvas.width,
|
total_width / self.canvas.width,
|
||||||
total_height / self.canvas.height,
|
total_height / self.canvas.height,
|
||||||
)
|
)
|
||||||
row_height = int(row_height / scale_down_percent)
|
row_height = max(1, int(row_height / scale_down_percent))
|
||||||
total_width, total_height, standard_candidate_layout = map_layout(
|
total_width, total_height, standard_candidate_layout = map_layout(
|
||||||
camera_layout, row_height
|
row_height
|
||||||
)
|
)
|
||||||
|
|
||||||
if not standard_candidate_layout:
|
if not standard_candidate_layout:
|
||||||
@@ -734,8 +722,8 @@ class BirdsEyeFrameManager:
|
|||||||
1 / (total_width / self.canvas.width),
|
1 / (total_width / self.canvas.width),
|
||||||
1 / (total_height / self.canvas.height),
|
1 / (total_height / self.canvas.height),
|
||||||
)
|
)
|
||||||
row_height = int(row_height * scale_up_percent)
|
row_height = max(1, int(row_height * scale_up_percent))
|
||||||
_, _, scaled_layout = map_layout(camera_layout, row_height)
|
_, _, scaled_layout = map_layout(row_height)
|
||||||
|
|
||||||
if scaled_layout:
|
if scaled_layout:
|
||||||
return scaled_layout
|
return scaled_layout
|
||||||
@@ -745,8 +733,7 @@ class BirdsEyeFrameManager:
|
|||||||
def update(
|
def update(
|
||||||
self,
|
self,
|
||||||
camera: str,
|
camera: str,
|
||||||
object_count: int,
|
activity: BirdseyeActivity,
|
||||||
motion_count: int,
|
|
||||||
frame_time: float,
|
frame_time: float,
|
||||||
frame: np.ndarray,
|
frame: np.ndarray,
|
||||||
) -> tuple[bool, bool]:
|
) -> tuple[bool, bool]:
|
||||||
@@ -774,7 +761,10 @@ class BirdsEyeFrameManager:
|
|||||||
# update the last active frame for the camera
|
# update the last active frame for the camera
|
||||||
self.cameras[camera]["current_frame"] = frame.copy()
|
self.cameras[camera]["current_frame"] = frame.copy()
|
||||||
self.cameras[camera]["current_frame_time"] = frame_time
|
self.cameras[camera]["current_frame_time"] = frame_time
|
||||||
if self.camera_active(camera_config.birdseye.mode, object_count, motion_count):
|
if self.camera_active(
|
||||||
|
camera_config.birdseye.mode,
|
||||||
|
activity,
|
||||||
|
):
|
||||||
self.cameras[camera]["last_active_frame"] = frame_time
|
self.cameras[camera]["last_active_frame"] = frame_time
|
||||||
|
|
||||||
now = datetime.datetime.now().timestamp()
|
now = datetime.datetime.now().timestamp()
|
||||||
@@ -882,10 +872,29 @@ class Birdseye:
|
|||||||
frame_time: float,
|
frame_time: float,
|
||||||
frame: np.ndarray,
|
frame: np.ndarray,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
has_active_object = False
|
||||||
|
has_stationary_object = False
|
||||||
|
for tracked_object in current_tracked_objects:
|
||||||
|
if tracked_object["stationary"]:
|
||||||
|
if not tracked_object["false_positive"]:
|
||||||
|
has_stationary_object = True
|
||||||
|
else:
|
||||||
|
# Preserve the existing objects activity behavior, which includes
|
||||||
|
# non-stationary trackers before they are confirmed.
|
||||||
|
has_active_object = True
|
||||||
|
|
||||||
|
if has_active_object and has_stationary_object:
|
||||||
|
break
|
||||||
|
|
||||||
|
activity = BirdseyeActivity(
|
||||||
|
has_active_object=has_active_object,
|
||||||
|
has_stationary_object=has_stationary_object,
|
||||||
|
has_motion=bool(motion_boxes),
|
||||||
|
)
|
||||||
|
|
||||||
frame_changed, frame_layout_changed = self.birdseye_manager.update(
|
frame_changed, frame_layout_changed = self.birdseye_manager.update(
|
||||||
camera,
|
camera,
|
||||||
len([o for o in current_tracked_objects if not o["stationary"]]),
|
activity,
|
||||||
len(motion_boxes),
|
|
||||||
frame_time,
|
frame_time,
|
||||||
frame,
|
frame,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -386,9 +386,19 @@ class TestConfigSetWildcardPropagation(BaseTestHttp):
|
|||||||
guess (mode still equals the previous global) wrongly claims a camera
|
guess (mode still equals the previous global) wrongly claims a camera
|
||||||
whose explicit yaml mode happens to match.
|
whose explicit yaml mode happens to match.
|
||||||
"""
|
"""
|
||||||
self.minimal_config["birdseye"] = {"enabled": True, "mode": "motion"}
|
self.minimal_config["birdseye"] = {
|
||||||
|
"enabled": True,
|
||||||
|
"mode": {"motion": True},
|
||||||
|
}
|
||||||
# explicit override that matches the global value being replaced
|
# explicit override that matches the global value being replaced
|
||||||
self.minimal_config["cameras"]["front_door"]["birdseye"] = {"mode": "motion"}
|
self.minimal_config["cameras"]["front_door"]["birdseye"] = {
|
||||||
|
"mode": {
|
||||||
|
"continuous": False,
|
||||||
|
"motion": True,
|
||||||
|
"objects": False,
|
||||||
|
"stationary_objects": False,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
config_path = self._write_config_file()
|
config_path = self._write_config_file()
|
||||||
mock_find_config.return_value = config_path
|
mock_find_config.return_value = config_path
|
||||||
@@ -399,7 +409,16 @@ class TestConfigSetWildcardPropagation(BaseTestHttp):
|
|||||||
resp = client.put(
|
resp = client.put(
|
||||||
"/config/set",
|
"/config/set",
|
||||||
json={
|
json={
|
||||||
"config_data": {"birdseye": {"mode": "continuous"}},
|
"config_data": {
|
||||||
|
"birdseye": {
|
||||||
|
"mode": {
|
||||||
|
"continuous": True,
|
||||||
|
"motion": False,
|
||||||
|
"objects": False,
|
||||||
|
"stationary_objects": False,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"update_topic": "config/birdseye",
|
"update_topic": "config/birdseye",
|
||||||
"requires_restart": 0,
|
"requires_restart": 0,
|
||||||
},
|
},
|
||||||
@@ -411,7 +430,7 @@ class TestConfigSetWildcardPropagation(BaseTestHttp):
|
|||||||
mock_publisher.publisher.publish.assert_called_once()
|
mock_publisher.publisher.publish.assert_called_once()
|
||||||
topic, settings = mock_publisher.publisher.publish.call_args[0]
|
topic, settings = mock_publisher.publisher.publish.call_args[0]
|
||||||
self.assertEqual(topic, "config/birdseye")
|
self.assertEqual(topic, "config/birdseye")
|
||||||
self.assertEqual(settings.mode.value, "continuous")
|
self.assertEqual(settings.mode.to_mqtt_payload(), "CONTINUOUS")
|
||||||
|
|
||||||
published = {
|
published = {
|
||||||
call[0][0].camera: call[0][1]
|
call[0][0].camera: call[0][1]
|
||||||
@@ -425,8 +444,12 @@ class TestConfigSetWildcardPropagation(BaseTestHttp):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# the override survives, the inheriting camera follows global
|
# the override survives, the inheriting camera follows global
|
||||||
self.assertEqual(published["front_door"].mode.value, "motion")
|
self.assertEqual(
|
||||||
self.assertEqual(published["back_yard"].mode.value, "continuous")
|
published["front_door"].mode.to_mqtt_payload(), "MOTION"
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
published["back_yard"].mode.to_mqtt_payload(), "CONTINUOUS"
|
||||||
|
)
|
||||||
finally:
|
finally:
|
||||||
os.unlink(config_path)
|
os.unlink(config_path)
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,73 @@
|
|||||||
"""Unit tests for recordings/media API endpoints."""
|
"""Unit tests for recordings/media API endpoints."""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytz
|
import pytz
|
||||||
from fastapi import Request
|
from fastapi import Request
|
||||||
|
|
||||||
from frigate.api.auth import get_allowed_cameras_for_filter, get_current_user
|
from frigate.api.auth import get_allowed_cameras_for_filter, get_current_user
|
||||||
|
from frigate.const import MAX_SEGMENT_DURATION
|
||||||
from frigate.models import Recordings
|
from frigate.models import Recordings
|
||||||
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
|
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RangeCase:
|
||||||
|
"""Expected behavior for one segment relative to the requested range.
|
||||||
|
|
||||||
|
Offsets are seconds from REQUEST_START; the request ends at +100 seconds.
|
||||||
|
"""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
start_offset: float
|
||||||
|
end_offset: float
|
||||||
|
included_in_recordings: bool
|
||||||
|
vod_clip_from_ms: int | None = None
|
||||||
|
vod_duration_ms: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
REQUEST_START = 1000
|
||||||
|
REQUEST_END = 1100
|
||||||
|
RANGE_CASES = (
|
||||||
|
RangeCase("before", -MAX_SEGMENT_DURATION + 1, -1, False),
|
||||||
|
RangeCase("meets_start", -10, 0, True),
|
||||||
|
RangeCase(
|
||||||
|
"overlaps_start",
|
||||||
|
-MAX_SEGMENT_DURATION + 0.5,
|
||||||
|
0.25,
|
||||||
|
True,
|
||||||
|
vod_clip_from_ms=599500,
|
||||||
|
vod_duration_ms=250,
|
||||||
|
),
|
||||||
|
RangeCase("starts_at_start", 0, 10, True, vod_duration_ms=10000),
|
||||||
|
RangeCase("inside", 20, 80, True, vod_duration_ms=60000),
|
||||||
|
RangeCase("ends_at_end", 90, 100, True, vod_duration_ms=10000),
|
||||||
|
RangeCase("matches_range", 0, 100, True, vod_duration_ms=100000),
|
||||||
|
RangeCase("starts_with_range", 0, 110, True, vod_duration_ms=100000),
|
||||||
|
RangeCase(
|
||||||
|
"covers_range",
|
||||||
|
-20,
|
||||||
|
120,
|
||||||
|
True,
|
||||||
|
vod_clip_from_ms=20000,
|
||||||
|
vod_duration_ms=100000,
|
||||||
|
),
|
||||||
|
RangeCase(
|
||||||
|
"ends_with_range",
|
||||||
|
-10,
|
||||||
|
100,
|
||||||
|
True,
|
||||||
|
vod_clip_from_ms=10000,
|
||||||
|
vod_duration_ms=100000,
|
||||||
|
),
|
||||||
|
RangeCase("overlaps_end", 95, 105, True, vod_duration_ms=5000),
|
||||||
|
RangeCase("starts_at_end", 100, 110, True),
|
||||||
|
RangeCase("after", 101, 110, False),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestHttpMedia(BaseTestHttp):
|
class TestHttpMedia(BaseTestHttp):
|
||||||
"""Test media API endpoints, particularly recordings with DST handling."""
|
"""Test media API endpoints, particularly recordings with DST handling."""
|
||||||
|
|
||||||
@@ -44,6 +102,26 @@ class TestHttpMedia(BaseTestHttp):
|
|||||||
self.app.dependency_overrides.clear()
|
self.app.dependency_overrides.clear()
|
||||||
super().tearDown()
|
super().tearDown()
|
||||||
|
|
||||||
|
def _assert_vod_response(
|
||||||
|
self,
|
||||||
|
response,
|
||||||
|
expected_clips: list[tuple[str, int | None, int]],
|
||||||
|
) -> None:
|
||||||
|
"""Assert VOD clip metadata and its derived duration fields."""
|
||||||
|
assert response.status_code == 200
|
||||||
|
vod = response.json()
|
||||||
|
assert [
|
||||||
|
(
|
||||||
|
clip["path"],
|
||||||
|
clip.get("clipFrom"),
|
||||||
|
clip["keyFrameDurations"][0],
|
||||||
|
)
|
||||||
|
for clip in vod["sequences"][0]["clips"]
|
||||||
|
] == expected_clips
|
||||||
|
expected_durations = [clip[2] for clip in expected_clips]
|
||||||
|
assert vod["durations"] == expected_durations
|
||||||
|
assert vod["segment_duration"] == max(expected_durations)
|
||||||
|
|
||||||
def test_recordings_summary_across_dst_spring_forward(self):
|
def test_recordings_summary_across_dst_spring_forward(self):
|
||||||
"""
|
"""
|
||||||
Test recordings summary across spring DST transition (spring forward).
|
Test recordings summary across spring DST transition (spring forward).
|
||||||
@@ -404,6 +482,102 @@ class TestHttpMedia(BaseTestHttp):
|
|||||||
assert "2024-03-10" in summary
|
assert "2024-03-10" in summary
|
||||||
assert summary["2024-03-10"] is True
|
assert summary["2024-03-10"] is True
|
||||||
|
|
||||||
|
def test_recordings_handles_all_range_relations(self):
|
||||||
|
"""Recordings return every interval relation that touches the range."""
|
||||||
|
with AuthTestClient(self.app) as client:
|
||||||
|
for case in RANGE_CASES:
|
||||||
|
with self.subTest(case=case.name):
|
||||||
|
Recordings.delete().execute()
|
||||||
|
super().insert_mock_recording(
|
||||||
|
case.name,
|
||||||
|
REQUEST_START + case.start_offset,
|
||||||
|
REQUEST_START + case.end_offset,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.get(
|
||||||
|
"/front_door/recordings",
|
||||||
|
params={"after": REQUEST_START, "before": REQUEST_END},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
expected_ids = [case.name] if case.included_in_recordings else []
|
||||||
|
assert [
|
||||||
|
recording["id"] for recording in response.json()
|
||||||
|
] == expected_ids
|
||||||
|
|
||||||
|
def test_vod_handles_all_range_relations(self):
|
||||||
|
"""VOD clips every interval relation with positive playback duration."""
|
||||||
|
with (
|
||||||
|
AuthTestClient(self.app) as client,
|
||||||
|
patch(
|
||||||
|
"frigate.api.media.get_keyframe_before",
|
||||||
|
side_effect=lambda _path, offset: offset,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
for case in RANGE_CASES:
|
||||||
|
with self.subTest(case=case.name):
|
||||||
|
Recordings.delete().execute()
|
||||||
|
super().insert_mock_recording(
|
||||||
|
case.name,
|
||||||
|
REQUEST_START + case.start_offset,
|
||||||
|
REQUEST_START + case.end_offset,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.get(
|
||||||
|
f"/vod/front_door/start/{REQUEST_START}/end/{REQUEST_END}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if case.vod_duration_ms is None:
|
||||||
|
assert response.status_code == 404
|
||||||
|
continue
|
||||||
|
|
||||||
|
self._assert_vod_response(
|
||||||
|
response,
|
||||||
|
[
|
||||||
|
(
|
||||||
|
case.name,
|
||||||
|
case.vod_clip_from_ms,
|
||||||
|
case.vod_duration_ms,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_vod_handles_segment_ending_at_start_with_keyframe_fallbacks(self):
|
||||||
|
"""VOD keeps a boundary segment when keyframe lookup extends it."""
|
||||||
|
|
||||||
|
def keyframe_before(path: str, offset: int) -> int | None:
|
||||||
|
return offset - 1000 if path == "previous_keyframe" else None
|
||||||
|
|
||||||
|
with (
|
||||||
|
AuthTestClient(self.app) as client,
|
||||||
|
patch(
|
||||||
|
"frigate.api.media.get_keyframe_before",
|
||||||
|
side_effect=keyframe_before,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
super().insert_mock_recording(
|
||||||
|
"previous_keyframe",
|
||||||
|
REQUEST_START - 10,
|
||||||
|
REQUEST_START,
|
||||||
|
)
|
||||||
|
super().insert_mock_recording(
|
||||||
|
"missing_keyframe",
|
||||||
|
REQUEST_START - 5,
|
||||||
|
REQUEST_START,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.get(
|
||||||
|
f"/vod/front_door/start/{REQUEST_START}/end/{REQUEST_END}"
|
||||||
|
)
|
||||||
|
|
||||||
|
self._assert_vod_response(
|
||||||
|
response,
|
||||||
|
[
|
||||||
|
("previous_keyframe", 9000, 1000),
|
||||||
|
("missing_keyframe", None, 5000),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
def test_recordings_unavailable_reports_gap_between_recordings(self):
|
def test_recordings_unavailable_reports_gap_between_recordings(self):
|
||||||
"""A gap between two recordings is reported as an unavailable segment."""
|
"""A gap between two recordings is reported as an unavailable segment."""
|
||||||
with AuthTestClient(self.app) as client:
|
with AuthTestClient(self.app) as client:
|
||||||
|
|||||||
@@ -1,13 +1,70 @@
|
|||||||
"""Test camera user and password cleanup."""
|
"""Tests for Birdseye canvas sizing and layout behavior."""
|
||||||
|
|
||||||
import multiprocessing as mp
|
import multiprocessing as mp
|
||||||
import unittest
|
import unittest
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
from frigate.config import FrigateConfig
|
from frigate.config import BirdseyeModeConfig, FrigateConfig
|
||||||
from frigate.output.birdseye import BirdsEyeFrameManager, get_canvas_shape
|
from frigate.output.birdseye import (
|
||||||
|
Birdseye,
|
||||||
|
BirdseyeActivity,
|
||||||
|
BirdsEyeFrameManager,
|
||||||
|
get_canvas_shape,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestBirdseye(unittest.TestCase):
|
class TestBirdseye(unittest.TestCase):
|
||||||
|
def _build_manager(
|
||||||
|
self, camera_dimensions: dict[str, tuple[int, int]]
|
||||||
|
) -> BirdsEyeFrameManager:
|
||||||
|
config = {
|
||||||
|
"mqtt": {"host": "mqtt"},
|
||||||
|
"birdseye": {"width": 1280, "height": 720},
|
||||||
|
"cameras": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
for order, (camera, dimensions) in enumerate(
|
||||||
|
camera_dimensions.items(), start=1
|
||||||
|
):
|
||||||
|
config["cameras"][camera] = {
|
||||||
|
"ffmpeg": {
|
||||||
|
"inputs": [
|
||||||
|
{
|
||||||
|
"path": f"rtsp://10.0.0.1:554/{camera}",
|
||||||
|
"roles": ["detect"],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"detect": {
|
||||||
|
"width": dimensions[0],
|
||||||
|
"height": dimensions[1],
|
||||||
|
"fps": 5,
|
||||||
|
},
|
||||||
|
"birdseye": {"order": order},
|
||||||
|
}
|
||||||
|
|
||||||
|
return BirdsEyeFrameManager(FrigateConfig(**config), mp.Event())
|
||||||
|
|
||||||
|
def _assert_no_overlaps(
|
||||||
|
self, layout: list[list[tuple[str, tuple[int, int, int, int]]]]
|
||||||
|
):
|
||||||
|
rectangles = [position for row in layout for _, position in row]
|
||||||
|
|
||||||
|
for index, rect in enumerate(rectangles):
|
||||||
|
x1, y1, width1, height1 = rect
|
||||||
|
for other in rectangles[index + 1 :]:
|
||||||
|
x2, y2, width2, height2 = other
|
||||||
|
overlap = (
|
||||||
|
x1 < x2 + width2
|
||||||
|
and x2 < x1 + width1
|
||||||
|
and y1 < y2 + height2
|
||||||
|
and y2 < y1 + height1
|
||||||
|
)
|
||||||
|
self.assertFalse(
|
||||||
|
overlap,
|
||||||
|
msg=f"Overlapping rectangles found: {rect} and {other}",
|
||||||
|
)
|
||||||
|
|
||||||
def test_16x9(self):
|
def test_16x9(self):
|
||||||
"""Test 16x9 aspect ratio works as expected for birdseye."""
|
"""Test 16x9 aspect ratio works as expected for birdseye."""
|
||||||
width = 1280
|
width = 1280
|
||||||
@@ -48,6 +105,212 @@ class TestBirdseye(unittest.TestCase):
|
|||||||
assert canvas_width == width # width will be the same
|
assert canvas_width == width # width will be the same
|
||||||
assert canvas_height != height
|
assert canvas_height != height
|
||||||
|
|
||||||
|
def test_portrait_camera_does_not_overlap_next_row(self):
|
||||||
|
"""Portrait cameras should reserve their real horizontal position on the next row."""
|
||||||
|
manager = self._build_manager(
|
||||||
|
{
|
||||||
|
"cam_a": (1280, 720),
|
||||||
|
"cam_p": (360, 640),
|
||||||
|
"cam_b": (1280, 720),
|
||||||
|
"cam_c": (640, 480),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
layout = manager.calculate_layout(["cam_a", "cam_p", "cam_b", "cam_c"], 3)
|
||||||
|
|
||||||
|
self.assertIsNotNone(layout)
|
||||||
|
assert layout is not None
|
||||||
|
self._assert_no_overlaps(layout)
|
||||||
|
|
||||||
|
cam_c = [
|
||||||
|
position for row in layout for camera, position in row if camera == "cam_c"
|
||||||
|
][0]
|
||||||
|
self.assertEqual(cam_c[0], 0)
|
||||||
|
|
||||||
|
def test_portrait_reservation_only_applies_to_next_row(self):
|
||||||
|
"""Portrait reservations should not push later rows after the span ends."""
|
||||||
|
manager = self._build_manager(
|
||||||
|
{
|
||||||
|
"cam_a": (1280, 720),
|
||||||
|
"cam_p": (360, 640),
|
||||||
|
"cam_b": (1280, 720),
|
||||||
|
"cam_c": (1280, 720),
|
||||||
|
"cam_d": (1280, 720),
|
||||||
|
"cam_e": (1280, 720),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
layout = manager.calculate_layout(
|
||||||
|
["cam_a", "cam_p", "cam_b", "cam_c", "cam_d", "cam_e"],
|
||||||
|
3,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIsNotNone(layout)
|
||||||
|
assert layout is not None
|
||||||
|
self._assert_no_overlaps(layout)
|
||||||
|
|
||||||
|
cam_e = [
|
||||||
|
position for row in layout for camera, position in row if camera == "cam_e"
|
||||||
|
][0]
|
||||||
|
self.assertEqual(cam_e[0], 0)
|
||||||
|
|
||||||
|
def test_multiple_portraits_reserve_distinct_ranges(self):
|
||||||
|
"""Multiple portrait cameras in one row should reserve separate spans below them."""
|
||||||
|
manager = self._build_manager(
|
||||||
|
{
|
||||||
|
"cam_a": (640, 480),
|
||||||
|
"cam_p1": (360, 640),
|
||||||
|
"cam_p2": (360, 640),
|
||||||
|
"cam_b": (640, 480),
|
||||||
|
"cam_c": (1280, 720),
|
||||||
|
"cam_d": (640, 480),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
layout = manager.calculate_layout(
|
||||||
|
["cam_a", "cam_p1", "cam_p2", "cam_b", "cam_c", "cam_d"],
|
||||||
|
4,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIsNotNone(layout)
|
||||||
|
assert layout is not None
|
||||||
|
self._assert_no_overlaps(layout)
|
||||||
|
|
||||||
|
def test_two_landscapes_then_portrait_then_two_landscapes(self):
|
||||||
|
"""A portrait after two landscapes should reserve only its own tail span."""
|
||||||
|
manager = self._build_manager(
|
||||||
|
{
|
||||||
|
"cam_a": (1280, 720),
|
||||||
|
"cam_b": (1280, 720),
|
||||||
|
"cam_p": (360, 640),
|
||||||
|
"cam_c": (1280, 720),
|
||||||
|
"cam_d": (1280, 720),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
layout = manager.calculate_layout(
|
||||||
|
["cam_a", "cam_b", "cam_p", "cam_c", "cam_d"],
|
||||||
|
3,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIsNotNone(layout)
|
||||||
|
assert layout is not None
|
||||||
|
self._assert_no_overlaps(layout)
|
||||||
|
|
||||||
|
cam_c = [
|
||||||
|
position for row in layout for camera, position in row if camera == "cam_c"
|
||||||
|
][0]
|
||||||
|
cam_d = [
|
||||||
|
position for row in layout for camera, position in row if camera == "cam_d"
|
||||||
|
][0]
|
||||||
|
self.assertEqual(cam_c[0], 0)
|
||||||
|
self.assertEqual(cam_d[0], cam_c[0] + cam_c[2])
|
||||||
|
|
||||||
|
|
||||||
|
class TestBirdseyeActivity(unittest.TestCase):
|
||||||
|
"""Test which camera activity is included in each Birdseye mode."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
config = {
|
||||||
|
"mqtt": {"enabled": False},
|
||||||
|
"birdseye": {
|
||||||
|
"enabled": True,
|
||||||
|
"mode": {
|
||||||
|
"motion": True,
|
||||||
|
"objects": True,
|
||||||
|
"stationary_objects": True,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"cameras": {
|
||||||
|
"front": {
|
||||||
|
"ffmpeg": {
|
||||||
|
"inputs": [
|
||||||
|
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"detect": {"height": 1080, "width": 1920, "fps": 5},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
self.manager = BirdsEyeFrameManager(FrigateConfig(**config), mp.Event())
|
||||||
|
|
||||||
|
def test_existing_modes_keep_their_activity_rules(self):
|
||||||
|
continuous = BirdseyeModeConfig(continuous=True)
|
||||||
|
motion = BirdseyeModeConfig(motion=True)
|
||||||
|
objects = BirdseyeModeConfig(objects=True)
|
||||||
|
|
||||||
|
no_activity = BirdseyeActivity(False, False, False)
|
||||||
|
motion_activity = BirdseyeActivity(False, False, True)
|
||||||
|
stationary_activity = BirdseyeActivity(False, True, False)
|
||||||
|
active_object_activity = BirdseyeActivity(True, False, False)
|
||||||
|
|
||||||
|
assert self.manager.camera_active(continuous, no_activity)
|
||||||
|
assert self.manager.camera_active(motion, motion_activity)
|
||||||
|
assert not self.manager.camera_active(motion, stationary_activity)
|
||||||
|
assert self.manager.camera_active(objects, active_object_activity)
|
||||||
|
assert not self.manager.camera_active(objects, stationary_activity)
|
||||||
|
|
||||||
|
def test_modes_can_be_combined(self):
|
||||||
|
mode = BirdseyeModeConfig(motion=True, stationary_objects=True)
|
||||||
|
|
||||||
|
assert self.manager.camera_active(mode, BirdseyeActivity(False, False, True))
|
||||||
|
assert self.manager.camera_active(mode, BirdseyeActivity(False, True, False))
|
||||||
|
assert not self.manager.camera_active(
|
||||||
|
mode, BirdseyeActivity(False, False, False)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_stationary_objects_are_independent_from_active_objects(self):
|
||||||
|
stationary_objects = BirdseyeModeConfig(stationary_objects=True)
|
||||||
|
|
||||||
|
assert self.manager.camera_active(
|
||||||
|
stationary_objects, BirdseyeActivity(False, True, False)
|
||||||
|
)
|
||||||
|
assert not self.manager.camera_active(
|
||||||
|
stationary_objects, BirdseyeActivity(True, False, False)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_write_data_preserves_active_and_confirms_stationary_activity(self):
|
||||||
|
birdseye = Birdseye.__new__(Birdseye)
|
||||||
|
birdseye.birdseye_manager = Mock()
|
||||||
|
birdseye.birdseye_manager.update.return_value = (False, False)
|
||||||
|
birdseye._idle_interval = None
|
||||||
|
frame = Mock()
|
||||||
|
|
||||||
|
birdseye.write_data(
|
||||||
|
"front",
|
||||||
|
[
|
||||||
|
{"stationary": True, "false_positive": True},
|
||||||
|
{"stationary": False, "false_positive": True},
|
||||||
|
{"stationary": True, "false_positive": False},
|
||||||
|
],
|
||||||
|
[[0, 0, 10, 10]],
|
||||||
|
1.0,
|
||||||
|
frame,
|
||||||
|
)
|
||||||
|
|
||||||
|
birdseye.birdseye_manager.update.assert_called_once_with(
|
||||||
|
"front", BirdseyeActivity(True, True, True), 1.0, frame
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_stationary_false_positive_does_not_activate_birdseye(self):
|
||||||
|
birdseye = Birdseye.__new__(Birdseye)
|
||||||
|
birdseye.birdseye_manager = Mock()
|
||||||
|
birdseye.birdseye_manager.update.return_value = (False, False)
|
||||||
|
birdseye._idle_interval = None
|
||||||
|
frame = Mock()
|
||||||
|
|
||||||
|
birdseye.write_data(
|
||||||
|
"front",
|
||||||
|
[{"stationary": True, "false_positive": True}],
|
||||||
|
[],
|
||||||
|
1.0,
|
||||||
|
frame,
|
||||||
|
)
|
||||||
|
|
||||||
|
birdseye.birdseye_manager.update.assert_called_once_with(
|
||||||
|
"front", BirdseyeActivity(False, False, False), 1.0, frame
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestBirdseyeCameraOrder(unittest.TestCase):
|
class TestBirdseyeCameraOrder(unittest.TestCase):
|
||||||
"""Test that birdseye reacts to camera order changes without a restart."""
|
"""Test that birdseye reacts to camera order changes without a restart."""
|
||||||
@@ -55,7 +318,7 @@ class TestBirdseyeCameraOrder(unittest.TestCase):
|
|||||||
def setUp(self):
|
def setUp(self):
|
||||||
config = {
|
config = {
|
||||||
"mqtt": {"enabled": False},
|
"mqtt": {"enabled": False},
|
||||||
"birdseye": {"enabled": True, "mode": "continuous"},
|
"birdseye": {"enabled": True, "mode": {"continuous": True}},
|
||||||
"cameras": {
|
"cameras": {
|
||||||
camera: {
|
camera: {
|
||||||
"ffmpeg": {
|
"ffmpeg": {
|
||||||
|
|||||||
+100
-9
@@ -7,7 +7,7 @@ import numpy as np
|
|||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
from ruamel.yaml.constructor import DuplicateKeyError
|
from ruamel.yaml.constructor import DuplicateKeyError
|
||||||
|
|
||||||
from frigate.config import BirdseyeModeEnum, FrigateConfig
|
from frigate.config import FrigateConfig
|
||||||
from frigate.const import MODEL_CACHE_DIR
|
from frigate.const import MODEL_CACHE_DIR
|
||||||
from frigate.detectors import DetectorTypeEnum
|
from frigate.detectors import DetectorTypeEnum
|
||||||
from frigate.util.builtin import deep_merge
|
from frigate.util.builtin import deep_merge
|
||||||
@@ -170,7 +170,7 @@ class TestConfig(unittest.TestCase):
|
|||||||
def test_override_birdseye(self):
|
def test_override_birdseye(self):
|
||||||
config = {
|
config = {
|
||||||
"mqtt": {"host": "mqtt"},
|
"mqtt": {"host": "mqtt"},
|
||||||
"birdseye": {"enabled": True, "mode": "continuous"},
|
"birdseye": {"enabled": True, "mode": {"continuous": True}},
|
||||||
"cameras": {
|
"cameras": {
|
||||||
"back": {
|
"back": {
|
||||||
"ffmpeg": {
|
"ffmpeg": {
|
||||||
@@ -183,19 +183,30 @@ class TestConfig(unittest.TestCase):
|
|||||||
"width": 1920,
|
"width": 1920,
|
||||||
"fps": 5,
|
"fps": 5,
|
||||||
},
|
},
|
||||||
"birdseye": {"enabled": False, "mode": "motion"},
|
"birdseye": {
|
||||||
|
"enabled": False,
|
||||||
|
"mode": {"continuous": False, "motion": True},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
frigate_config = FrigateConfig(**config)
|
frigate_config = FrigateConfig(**config)
|
||||||
assert not frigate_config.cameras["back"].birdseye.enabled
|
assert not frigate_config.cameras["back"].birdseye.enabled
|
||||||
assert frigate_config.cameras["back"].birdseye.mode is BirdseyeModeEnum.motion
|
mode = frigate_config.cameras["back"].birdseye.mode
|
||||||
|
assert mode.motion
|
||||||
|
assert not mode.continuous
|
||||||
|
assert not mode.objects
|
||||||
|
assert not mode.stationary_objects
|
||||||
|
|
||||||
def test_override_birdseye_non_inheritable(self):
|
def test_override_birdseye_non_inheritable(self):
|
||||||
config = {
|
config = {
|
||||||
"mqtt": {"host": "mqtt"},
|
"mqtt": {"host": "mqtt"},
|
||||||
"birdseye": {"enabled": True, "mode": "continuous", "height": 1920},
|
"birdseye": {
|
||||||
|
"enabled": True,
|
||||||
|
"mode": {"continuous": True},
|
||||||
|
"height": 1920,
|
||||||
|
},
|
||||||
"cameras": {
|
"cameras": {
|
||||||
"back": {
|
"back": {
|
||||||
"ffmpeg": {
|
"ffmpeg": {
|
||||||
@@ -218,7 +229,7 @@ class TestConfig(unittest.TestCase):
|
|||||||
def test_inherit_birdseye(self):
|
def test_inherit_birdseye(self):
|
||||||
config = {
|
config = {
|
||||||
"mqtt": {"host": "mqtt"},
|
"mqtt": {"host": "mqtt"},
|
||||||
"birdseye": {"enabled": True, "mode": "continuous"},
|
"birdseye": {"enabled": True, "mode": {"continuous": True}},
|
||||||
"cameras": {
|
"cameras": {
|
||||||
"back": {
|
"back": {
|
||||||
"ffmpeg": {
|
"ffmpeg": {
|
||||||
@@ -237,9 +248,89 @@ class TestConfig(unittest.TestCase):
|
|||||||
|
|
||||||
frigate_config = FrigateConfig(**config)
|
frigate_config = FrigateConfig(**config)
|
||||||
assert frigate_config.cameras["back"].birdseye.enabled
|
assert frigate_config.cameras["back"].birdseye.enabled
|
||||||
assert (
|
mode = frigate_config.cameras["back"].birdseye.mode
|
||||||
frigate_config.cameras["back"].birdseye.mode is BirdseyeModeEnum.continuous
|
assert mode.continuous
|
||||||
)
|
assert not mode.motion
|
||||||
|
assert not mode.objects
|
||||||
|
assert not mode.stationary_objects
|
||||||
|
|
||||||
|
def test_combine_birdseye_activity_types(self):
|
||||||
|
config = {
|
||||||
|
**self.minimal,
|
||||||
|
"birdseye": {
|
||||||
|
"mode": {
|
||||||
|
"motion": True,
|
||||||
|
"stationary_objects": True,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
frigate_config = FrigateConfig(**config)
|
||||||
|
mode = frigate_config.cameras["back"].birdseye.mode
|
||||||
|
assert mode.motion
|
||||||
|
assert mode.stationary_objects
|
||||||
|
assert not mode.continuous
|
||||||
|
assert not mode.objects
|
||||||
|
|
||||||
|
def test_birdseye_requires_an_activity_type(self):
|
||||||
|
config = {
|
||||||
|
**self.minimal,
|
||||||
|
"birdseye": {
|
||||||
|
"mode": {
|
||||||
|
"continuous": False,
|
||||||
|
"motion": False,
|
||||||
|
"objects": False,
|
||||||
|
"stationary_objects": False,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(
|
||||||
|
ValidationError, "must enable at least one Birdseye activity type"
|
||||||
|
):
|
||||||
|
FrigateConfig(**config)
|
||||||
|
|
||||||
|
def test_camera_can_disable_an_inherited_activity_type(self):
|
||||||
|
config = {
|
||||||
|
**self.minimal,
|
||||||
|
"birdseye": {"mode": {"motion": True, "objects": True}},
|
||||||
|
}
|
||||||
|
config["cameras"]["back"]["birdseye"] = {"mode": {"motion": False}}
|
||||||
|
|
||||||
|
frigate_config = FrigateConfig(**config)
|
||||||
|
mode = frigate_config.cameras["back"].birdseye.mode
|
||||||
|
assert not mode.motion
|
||||||
|
assert mode.objects
|
||||||
|
|
||||||
|
def test_profile_must_leave_an_activity_type_enabled(self):
|
||||||
|
config = {
|
||||||
|
**self.minimal,
|
||||||
|
"profiles": {"away": {"friendly_name": "Away"}},
|
||||||
|
"birdseye": {"mode": {"objects": True}},
|
||||||
|
}
|
||||||
|
config["cameras"]["back"]["profiles"] = {
|
||||||
|
"away": {"birdseye": {"mode": {"objects": False}}}
|
||||||
|
}
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(
|
||||||
|
ValidationError, "must enable at least one Birdseye activity type"
|
||||||
|
):
|
||||||
|
FrigateConfig(**config)
|
||||||
|
|
||||||
|
def test_camera_birdseye_activity_types_override_global_values(self):
|
||||||
|
config = {
|
||||||
|
**self.minimal,
|
||||||
|
"birdseye": {"mode": {"motion": True, "objects": True}},
|
||||||
|
}
|
||||||
|
config["cameras"]["back"]["birdseye"] = {
|
||||||
|
"mode": {"motion": False, "stationary_objects": True}
|
||||||
|
}
|
||||||
|
|
||||||
|
frigate_config = FrigateConfig(**config)
|
||||||
|
mode = frigate_config.cameras["back"].birdseye.mode
|
||||||
|
assert not mode.motion
|
||||||
|
assert mode.objects
|
||||||
|
assert mode.stationary_objects
|
||||||
|
|
||||||
def test_override_tracked_objects(self):
|
def test_override_tracked_objects(self):
|
||||||
config = {
|
config = {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from unittest.mock import MagicMock, patch
|
|||||||
from frigate.app import FrigateApp
|
from frigate.app import FrigateApp
|
||||||
from frigate.comms.dispatcher import Dispatcher
|
from frigate.comms.dispatcher import Dispatcher
|
||||||
from frigate.comms.runtime_state import RuntimeStatePersistence
|
from frigate.comms.runtime_state import RuntimeStatePersistence
|
||||||
|
from frigate.config import BirdseyeModeConfig
|
||||||
|
|
||||||
|
|
||||||
def _make_camera_mock(
|
def _make_camera_mock(
|
||||||
@@ -51,6 +52,58 @@ def _build_dispatcher(cameras: dict[str, MagicMock]) -> Dispatcher:
|
|||||||
return Dispatcher(config, config_updater, onvif, ptz_metrics, communicators)
|
return Dispatcher(config, config_updater, onvif, ptz_metrics, communicators)
|
||||||
|
|
||||||
|
|
||||||
|
class TestBirdseyeModeCommands(unittest.TestCase):
|
||||||
|
"""Verify Birdseye mode commands use the boolean mode contract."""
|
||||||
|
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.camera = _make_camera_mock()
|
||||||
|
self.camera.birdseye.enabled = True
|
||||||
|
self.dispatcher = _build_dispatcher({"front_door": self.camera})
|
||||||
|
self.dispatcher.publish = MagicMock()
|
||||||
|
|
||||||
|
def test_combined_modes_are_accepted(self) -> None:
|
||||||
|
self.dispatcher._on_birdseye_mode_command(
|
||||||
|
"front_door", "STATIONARY_OBJECTS,MOTION"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
self.camera.birdseye.mode,
|
||||||
|
BirdseyeModeConfig(motion=True, stationary_objects=True),
|
||||||
|
)
|
||||||
|
self.dispatcher.config_updater.publish_update.assert_called_once()
|
||||||
|
self.dispatcher.publish.assert_called_once_with(
|
||||||
|
"front_door/birdseye_mode/state",
|
||||||
|
"MOTION,STATIONARY_OBJECTS",
|
||||||
|
retain=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_single_activity_type_is_accepted(self) -> None:
|
||||||
|
self.dispatcher._on_birdseye_mode_command("front_door", "OBJECTS")
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
self.camera.birdseye.mode,
|
||||||
|
BirdseyeModeConfig(objects=True),
|
||||||
|
)
|
||||||
|
self.dispatcher.publish.assert_called_once_with(
|
||||||
|
"front_door/birdseye_mode/state", "OBJECTS", retain=True
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_unknown_mode_is_rejected(self) -> None:
|
||||||
|
for payload in (
|
||||||
|
"UNKNOWN",
|
||||||
|
"motion",
|
||||||
|
"MOTION_OBJECTS",
|
||||||
|
"NONE",
|
||||||
|
"NONE,MOTION",
|
||||||
|
"MOTION,MOTION",
|
||||||
|
"MOTION,",
|
||||||
|
):
|
||||||
|
with self.subTest(payload=payload):
|
||||||
|
self.dispatcher._on_birdseye_mode_command("front_door", payload)
|
||||||
|
|
||||||
|
self.dispatcher.config_updater.publish_update.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
class TestRestoreRuntimeState(unittest.TestCase):
|
class TestRestoreRuntimeState(unittest.TestCase):
|
||||||
"""Verify replay routes through handlers and tolerates missing entries."""
|
"""Verify replay routes through handlers and tolerates missing entries."""
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import unittest
|
import unittest
|
||||||
|
from io import StringIO
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
from frigate.util.services import get_amd_gpu_stats, get_intel_gpu_stats
|
from frigate.util.services import (
|
||||||
|
get_amd_gpu_stats,
|
||||||
|
get_intel_gpu_stats,
|
||||||
|
get_openvino_npu_stats,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestGpuStats(unittest.TestCase):
|
class TestGpuStats(unittest.TestCase):
|
||||||
@@ -17,6 +22,88 @@ class TestGpuStats(unittest.TestCase):
|
|||||||
amd_stats = get_amd_gpu_stats()
|
amd_stats = get_amd_gpu_stats()
|
||||||
assert amd_stats == {"gpu": "4.17%", "mem": "60.37%"}
|
assert amd_stats == {"gpu": "4.17%", "mem": "60.37%"}
|
||||||
|
|
||||||
|
@patch("frigate.util.services.time.sleep")
|
||||||
|
@patch("frigate.util.services.time.time", side_effect=[0.0, 1.0])
|
||||||
|
@patch(
|
||||||
|
"frigate.util.services.os.readlink",
|
||||||
|
return_value="/sys/bus/pci/drivers/intel_vpu",
|
||||||
|
)
|
||||||
|
@patch(
|
||||||
|
"frigate.util.services.glob.glob",
|
||||||
|
return_value=["/sys/class/accel/accel0"],
|
||||||
|
)
|
||||||
|
@patch(
|
||||||
|
"builtins.open",
|
||||||
|
side_effect=[StringIO("1000"), StringIO("1250")],
|
||||||
|
)
|
||||||
|
def test_openvino_npu_stats_discovers_accel0(
|
||||||
|
self, open_file, glob, readlink, time, sleep
|
||||||
|
):
|
||||||
|
assert get_openvino_npu_stats() == {"npu": "25.0", "mem": "-%"}
|
||||||
|
|
||||||
|
open_file.assert_any_call(
|
||||||
|
"/sys/class/accel/accel0/device/power/runtime_active_time"
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch("frigate.util.services.time.sleep")
|
||||||
|
@patch("frigate.util.services.time.time", side_effect=[0.0, 1.0])
|
||||||
|
@patch(
|
||||||
|
"frigate.util.services.os.readlink",
|
||||||
|
side_effect=[
|
||||||
|
"/sys/bus/pci/drivers/other",
|
||||||
|
"/sys/bus/pci/drivers/intel_vpu",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
@patch(
|
||||||
|
"frigate.util.services.glob.glob",
|
||||||
|
return_value=[
|
||||||
|
"/sys/class/accel/accel0",
|
||||||
|
"/sys/class/accel/accel1",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
@patch(
|
||||||
|
"builtins.open",
|
||||||
|
side_effect=[StringIO("1000"), StringIO("1250")],
|
||||||
|
)
|
||||||
|
def test_openvino_npu_stats_skips_non_intel_accelerator(
|
||||||
|
self, open_file, glob, readlink, time, sleep
|
||||||
|
):
|
||||||
|
assert get_openvino_npu_stats() == {"npu": "25.0", "mem": "-%"}
|
||||||
|
|
||||||
|
open_file.assert_any_call(
|
||||||
|
"/sys/class/accel/accel1/device/power/runtime_active_time"
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch(
|
||||||
|
"frigate.util.services.os.readlink",
|
||||||
|
return_value="/sys/bus/pci/drivers/other",
|
||||||
|
)
|
||||||
|
@patch(
|
||||||
|
"frigate.util.services.glob.glob",
|
||||||
|
return_value=["/sys/class/accel/accel0"],
|
||||||
|
)
|
||||||
|
@patch("builtins.open")
|
||||||
|
def test_openvino_npu_stats_no_intel_accelerator(self, open_file, glob, readlink):
|
||||||
|
assert get_openvino_npu_stats() is None
|
||||||
|
open_file.assert_not_called()
|
||||||
|
|
||||||
|
@patch(
|
||||||
|
"frigate.util.services.os.readlink",
|
||||||
|
return_value="/sys/bus/pci/drivers/intel_vpu",
|
||||||
|
)
|
||||||
|
@patch(
|
||||||
|
"frigate.util.services.glob.glob",
|
||||||
|
return_value=["/sys/class/accel/accel0"],
|
||||||
|
)
|
||||||
|
@patch("builtins.open", side_effect=FileNotFoundError)
|
||||||
|
def test_openvino_npu_stats_runtime_counter_unavailable(
|
||||||
|
self, open_file, glob, readlink
|
||||||
|
):
|
||||||
|
assert get_openvino_npu_stats() is None
|
||||||
|
open_file.assert_called_once_with(
|
||||||
|
"/sys/class/accel/accel0/device/power/runtime_active_time"
|
||||||
|
)
|
||||||
|
|
||||||
@patch("frigate.stats.intel_gpu_info.intel_gpu_name_resolver.get_names")
|
@patch("frigate.stats.intel_gpu_info.intel_gpu_name_resolver.get_names")
|
||||||
@patch("frigate.util.services.time.sleep")
|
@patch("frigate.util.services.time.sleep")
|
||||||
@patch("frigate.util.services.time.monotonic")
|
@patch("frigate.util.services.time.monotonic")
|
||||||
|
|||||||
@@ -560,6 +560,25 @@ class TestProfileManager(unittest.TestCase):
|
|||||||
assert err is None
|
assert err is None
|
||||||
assert self.config.cameras["front"].enabled is False
|
assert self.config.cameras["front"].enabled is False
|
||||||
|
|
||||||
|
@patch.object(ProfileManager, "_persist_active_profile")
|
||||||
|
def test_profile_can_disable_inherited_birdseye_activity(self, mock_persist):
|
||||||
|
"""A false-only mode override inherits the other base activity types."""
|
||||||
|
self.config.profiles["away"] = ProfileDefinitionConfig(friendly_name="Away")
|
||||||
|
base_mode = self.config.cameras["front"].birdseye.mode
|
||||||
|
base_mode.motion = True
|
||||||
|
base_mode.objects = True
|
||||||
|
self.config.cameras["front"].profiles["away"] = CameraProfileConfig(
|
||||||
|
birdseye={"mode": {"motion": False}}
|
||||||
|
)
|
||||||
|
self.manager = ProfileManager(self.config, self.mock_updater)
|
||||||
|
|
||||||
|
err = self.manager.activate_profile("away")
|
||||||
|
|
||||||
|
assert err is None
|
||||||
|
mode = self.config.cameras["front"].birdseye.mode
|
||||||
|
assert not mode.motion
|
||||||
|
assert mode.objects
|
||||||
|
|
||||||
@patch.object(ProfileManager, "_persist_active_profile")
|
@patch.object(ProfileManager, "_persist_active_profile")
|
||||||
def test_deactivate_restores_enabled(self, mock_persist):
|
def test_deactivate_restores_enabled(self, mock_persist):
|
||||||
"""Deactivating a profile restores the camera's base enabled state."""
|
"""Deactivating a profile restores the camera's base enabled state."""
|
||||||
|
|||||||
+44
-1
@@ -20,7 +20,7 @@ from frigate.util.services import get_video_properties
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
CURRENT_CONFIG_VERSION = "0.18-0"
|
CURRENT_CONFIG_VERSION = "0.19-0"
|
||||||
DEFAULT_CONFIG_FILE = os.path.join(CONFIG_DIR, "config.yml")
|
DEFAULT_CONFIG_FILE = os.path.join(CONFIG_DIR, "config.yml")
|
||||||
|
|
||||||
|
|
||||||
@@ -93,6 +93,7 @@ def migrate_frigate_config(config_file: str):
|
|||||||
|
|
||||||
logger.info("copying config as backup...")
|
logger.info("copying config as backup...")
|
||||||
shutil.copy(config_file, os.path.join(CONFIG_DIR, "backup_config.yaml"))
|
shutil.copy(config_file, os.path.join(CONFIG_DIR, "backup_config.yaml"))
|
||||||
|
new_config = config
|
||||||
|
|
||||||
if previous_version < "0.14":
|
if previous_version < "0.14":
|
||||||
logger.info(f"Migrating frigate config from {previous_version} to 0.14...")
|
logger.info(f"Migrating frigate config from {previous_version} to 0.14...")
|
||||||
@@ -147,6 +148,13 @@ def migrate_frigate_config(config_file: str):
|
|||||||
yaml.dump(new_config, f)
|
yaml.dump(new_config, f)
|
||||||
previous_version = "0.18-0"
|
previous_version = "0.18-0"
|
||||||
|
|
||||||
|
if previous_version < "0.19-0":
|
||||||
|
logger.info(f"Migrating frigate config from {previous_version} to 0.19-0...")
|
||||||
|
new_config = migrate_019_0(new_config)
|
||||||
|
with open(config_file, "w") as f:
|
||||||
|
yaml.dump(new_config, f)
|
||||||
|
previous_version = "0.19-0"
|
||||||
|
|
||||||
logger.info("Finished frigate config migration...")
|
logger.info("Finished frigate config migration...")
|
||||||
|
|
||||||
|
|
||||||
@@ -525,6 +533,21 @@ def _convert_legacy_mask_to_dict(
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_birdseye_mode(birdseye: dict[str, Any] | None) -> None:
|
||||||
|
"""Convert a scalar Birdseye mode to composable activity types."""
|
||||||
|
if not birdseye or not isinstance(birdseye.get("mode"), str):
|
||||||
|
return
|
||||||
|
|
||||||
|
legacy_mode = birdseye["mode"]
|
||||||
|
activity_types = ("continuous", "motion", "objects", "stationary_objects")
|
||||||
|
if legacy_mode not in activity_types:
|
||||||
|
return
|
||||||
|
|
||||||
|
birdseye["mode"] = {
|
||||||
|
activity_type: activity_type == legacy_mode for activity_type in activity_types
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def migrate_018_0(config: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]]:
|
def migrate_018_0(config: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]]:
|
||||||
"""Handle migrating frigate config to 0.18-0"""
|
"""Handle migrating frigate config to 0.18-0"""
|
||||||
new_config = config.copy()
|
new_config = config.copy()
|
||||||
@@ -658,6 +681,26 @@ def migrate_018_0(config: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]
|
|||||||
return new_config
|
return new_config
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_019_0(config: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]]:
|
||||||
|
"""Handle migrating Frigate config to 0.19-0."""
|
||||||
|
new_config = config.copy()
|
||||||
|
|
||||||
|
_migrate_birdseye_mode(new_config.get("birdseye"))
|
||||||
|
|
||||||
|
for name, camera in new_config.get("cameras", {}).items():
|
||||||
|
camera_config: dict[str, dict[str, Any]] = camera.copy()
|
||||||
|
_migrate_birdseye_mode(camera_config.get("birdseye"))
|
||||||
|
|
||||||
|
for profile in camera_config.get("profiles", {}).values():
|
||||||
|
if isinstance(profile, dict):
|
||||||
|
_migrate_birdseye_mode(profile.get("birdseye"))
|
||||||
|
|
||||||
|
new_config["cameras"][name] = camera_config
|
||||||
|
|
||||||
|
new_config["version"] = "0.19-0"
|
||||||
|
return new_config
|
||||||
|
|
||||||
|
|
||||||
def get_relative_coordinates(
|
def get_relative_coordinates(
|
||||||
mask: str | list | None,
|
mask: str | list | None,
|
||||||
frame_shape: tuple[int, int],
|
frame_shape: tuple[int, int],
|
||||||
|
|||||||
@@ -0,0 +1,209 @@
|
|||||||
|
"""Aggregation of the known sub label names an object can be tagged with."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
|
from pathvalidate import sanitize_filename
|
||||||
|
|
||||||
|
from frigate.config import FrigateConfig
|
||||||
|
from frigate.config.classification import ObjectClassificationType
|
||||||
|
from frigate.const import CLIPS_DIR, FACE_DIR, MODEL_CACHE_DIR
|
||||||
|
from frigate.util.builtin import load_labels
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# subdirectory of FACE_DIR holding unassigned training images, not a face name
|
||||||
|
FACE_TRAIN_DIR = "train"
|
||||||
|
|
||||||
|
# category used by classification models for "no match", never attached to an object
|
||||||
|
CLASSIFICATION_NONE_CATEGORY = "none"
|
||||||
|
|
||||||
|
|
||||||
|
def get_categorized_object_names(
|
||||||
|
config: FrigateConfig,
|
||||||
|
allowed_cameras: list[str],
|
||||||
|
object_type: str | None = None,
|
||||||
|
) -> dict[str, list[str]]:
|
||||||
|
"""Collect every sub label name this install can attach, by object type.
|
||||||
|
|
||||||
|
Unlike the database-backed /sub_labels endpoint, this reads the config and
|
||||||
|
model files, so it also covers names that are configured but have not been
|
||||||
|
detected yet. Names come from the detector's logo attributes (limited to
|
||||||
|
objects the allowed cameras actually track), LPR known plate names,
|
||||||
|
registered face names, and custom object classification categories.
|
||||||
|
|
||||||
|
Structural attributes such as `face` and `license_plate` are excluded: they
|
||||||
|
describe a part of an object rather than naming it, and are never attached
|
||||||
|
as a sub label.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config: The running Frigate config
|
||||||
|
allowed_cameras: Cameras the requesting user may see
|
||||||
|
object_type: Optional object label to restrict the result to
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Mapping of object label to its known sub label names, sorted and
|
||||||
|
deduplicated. Object types with no known names are omitted.
|
||||||
|
"""
|
||||||
|
tracked_objects = _get_tracked_objects(config, allowed_cameras)
|
||||||
|
names: dict[str, set[str]] = {}
|
||||||
|
logos = set(config.model.all_attribute_logos)
|
||||||
|
|
||||||
|
# 1. detector logo attributes, only for objects that are actually tracked
|
||||||
|
for label, label_attributes in config.model.attributes_map.items():
|
||||||
|
if label not in tracked_objects:
|
||||||
|
continue
|
||||||
|
|
||||||
|
label_logos = logos.intersection(label_attributes)
|
||||||
|
|
||||||
|
if label_logos:
|
||||||
|
names.setdefault(label, set()).update(label_logos)
|
||||||
|
|
||||||
|
# 2. LPR known plate names, for objects that can carry a plate
|
||||||
|
if config.lpr.known_plates and _lpr_enabled(config, allowed_cameras):
|
||||||
|
known_plates = set(config.lpr.known_plates)
|
||||||
|
|
||||||
|
for label in _objects_with_attribute(config, tracked_objects, "license_plate"):
|
||||||
|
names.setdefault(label, set()).update(known_plates)
|
||||||
|
|
||||||
|
# 3. registered face names, for objects that can carry a face
|
||||||
|
if _face_recognition_enabled(config, allowed_cameras):
|
||||||
|
face_names = _get_face_names()
|
||||||
|
|
||||||
|
if face_names:
|
||||||
|
for label in _objects_with_attribute(config, tracked_objects, "face"):
|
||||||
|
names.setdefault(label, set()).update(face_names)
|
||||||
|
|
||||||
|
# 4. custom object classification categories
|
||||||
|
for model_key, model_config in config.classification.custom.items():
|
||||||
|
if not model_config.enabled or model_config.object_config is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if (
|
||||||
|
model_config.object_config.classification_type
|
||||||
|
!= ObjectClassificationType.sub_label
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
|
||||||
|
categories = _get_classification_categories(model_key)
|
||||||
|
|
||||||
|
if not categories:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for label in model_config.object_config.objects:
|
||||||
|
names.setdefault(label, set()).update(categories)
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: sorted(label_names)
|
||||||
|
for label, label_names in sorted(names.items())
|
||||||
|
if label_names and (object_type is None or label == object_type)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_tracked_objects(config: FrigateConfig, allowed_cameras: list[str]) -> set[str]:
|
||||||
|
"""Get the union of objects tracked by the cameras the user can see."""
|
||||||
|
tracked: set[str] = set()
|
||||||
|
|
||||||
|
for camera_name in allowed_cameras:
|
||||||
|
camera_config = config.cameras.get(camera_name)
|
||||||
|
|
||||||
|
if camera_config is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
tracked.update(camera_config.objects.track)
|
||||||
|
|
||||||
|
return tracked
|
||||||
|
|
||||||
|
|
||||||
|
def _objects_with_attribute(
|
||||||
|
config: FrigateConfig, tracked_objects: set[str], attribute: str
|
||||||
|
) -> set[str]:
|
||||||
|
"""Get the tracked objects that a given attribute can be recognized on.
|
||||||
|
|
||||||
|
The attribute may also be tracked as an object in its own right, as
|
||||||
|
`license_plate` is on a dedicated LPR camera, in which case the name is
|
||||||
|
attached to that object directly.
|
||||||
|
"""
|
||||||
|
objects = {
|
||||||
|
label
|
||||||
|
for label, label_attributes in config.model.attributes_map.items()
|
||||||
|
if attribute in label_attributes and label in tracked_objects
|
||||||
|
}
|
||||||
|
|
||||||
|
if attribute in tracked_objects:
|
||||||
|
objects.add(attribute)
|
||||||
|
|
||||||
|
return objects
|
||||||
|
|
||||||
|
|
||||||
|
def _lpr_enabled(config: FrigateConfig, allowed_cameras: list[str]) -> bool:
|
||||||
|
return any(
|
||||||
|
config.cameras[camera_name].lpr.enabled
|
||||||
|
for camera_name in allowed_cameras
|
||||||
|
if camera_name in config.cameras
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _face_recognition_enabled(
|
||||||
|
config: FrigateConfig, allowed_cameras: list[str]
|
||||||
|
) -> bool:
|
||||||
|
return any(
|
||||||
|
config.cameras[camera_name].face_recognition.enabled
|
||||||
|
for camera_name in allowed_cameras
|
||||||
|
if camera_name in config.cameras
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_face_names() -> set[str]:
|
||||||
|
"""Get the names of every registered face collection."""
|
||||||
|
if not os.path.exists(FACE_DIR):
|
||||||
|
return set()
|
||||||
|
|
||||||
|
try:
|
||||||
|
entries = os.listdir(FACE_DIR)
|
||||||
|
except OSError:
|
||||||
|
logger.debug("Failed to read face directory %s", FACE_DIR)
|
||||||
|
return set()
|
||||||
|
|
||||||
|
return {
|
||||||
|
name
|
||||||
|
for name in entries
|
||||||
|
if name != FACE_TRAIN_DIR and os.path.isdir(os.path.join(FACE_DIR, name))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_classification_categories(model_key: str) -> set[str]:
|
||||||
|
"""Get the categories a custom classification model can output.
|
||||||
|
|
||||||
|
The trained labelmap is authoritative, but it only exists once the model
|
||||||
|
has been trained, so fall back to the dataset directories that will become
|
||||||
|
the labelmap on the next training run.
|
||||||
|
"""
|
||||||
|
safe_key = sanitize_filename(model_key)
|
||||||
|
categories: set[str] = set()
|
||||||
|
labelmap_path = os.path.join(MODEL_CACHE_DIR, safe_key, "labelmap.txt")
|
||||||
|
|
||||||
|
if os.path.exists(labelmap_path):
|
||||||
|
try:
|
||||||
|
labelmap = load_labels(labelmap_path, prefill=0, indexed=False)
|
||||||
|
except OSError:
|
||||||
|
logger.debug("Failed to read labelmap %s", labelmap_path)
|
||||||
|
labelmap = {}
|
||||||
|
|
||||||
|
categories.update(label for label in labelmap.values() if label)
|
||||||
|
|
||||||
|
dataset_dir = os.path.join(CLIPS_DIR, safe_key, "dataset")
|
||||||
|
|
||||||
|
if os.path.exists(dataset_dir):
|
||||||
|
try:
|
||||||
|
entries = os.listdir(dataset_dir)
|
||||||
|
except OSError:
|
||||||
|
logger.debug("Failed to read dataset directory %s", dataset_dir)
|
||||||
|
entries = []
|
||||||
|
|
||||||
|
categories.update(
|
||||||
|
name for name in entries if os.path.isdir(os.path.join(dataset_dir, name))
|
||||||
|
)
|
||||||
|
|
||||||
|
categories.discard(CLASSIFICATION_NONE_CATEGORY)
|
||||||
|
return categories
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Utilities for services."""
|
"""Utilities for services."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import glob
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
@@ -670,19 +671,33 @@ def get_intel_gpu_stats(
|
|||||||
|
|
||||||
def get_openvino_npu_stats() -> dict[str, str] | None:
|
def get_openvino_npu_stats() -> dict[str, str] | None:
|
||||||
"""Get NPU stats using openvino."""
|
"""Get NPU stats using openvino."""
|
||||||
NPU_RUNTIME_PATH = "/sys/devices/pci0000:00/0000:00:0b.0/power/runtime_active_time"
|
for accel_path in sorted(glob.glob("/sys/class/accel/accel*")):
|
||||||
|
try:
|
||||||
|
driver = os.path.basename(os.readlink(f"{accel_path}/device/driver"))
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if driver != "intel_vpu":
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
runtime_path = f"{accel_path}/device/power/runtime_active_time"
|
||||||
|
with open(runtime_path) as f:
|
||||||
|
initial_runtime = float(f.read().strip())
|
||||||
|
break
|
||||||
|
except (FileNotFoundError, PermissionError, ValueError):
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open(NPU_RUNTIME_PATH) as f:
|
|
||||||
initial_runtime = float(f.read().strip())
|
|
||||||
|
|
||||||
initial_time = time.time()
|
initial_time = time.time()
|
||||||
|
|
||||||
# Sleep for 1 second to get an accurate reading
|
# Sleep for 1 second to get an accurate reading
|
||||||
time.sleep(1.0)
|
time.sleep(1.0)
|
||||||
|
|
||||||
# Read runtime value again
|
# Read runtime value again
|
||||||
with open(NPU_RUNTIME_PATH) as f:
|
with open(runtime_path) as f:
|
||||||
current_runtime = float(f.read().strip())
|
current_runtime = float(f.read().strip())
|
||||||
|
|
||||||
current_time = time.time()
|
current_time = time.time()
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -71,8 +71,24 @@
|
|||||||
"description": "Enable or disable the Birdseye view feature."
|
"description": "Enable or disable the Birdseye view feature."
|
||||||
},
|
},
|
||||||
"mode": {
|
"mode": {
|
||||||
"label": "Tracking mode",
|
"label": "Activity types",
|
||||||
"description": "Mode for including cameras in Birdseye: 'objects', 'motion', or 'continuous'."
|
"description": "Activity types that include cameras in Birdseye.",
|
||||||
|
"continuous": {
|
||||||
|
"label": "Continuous",
|
||||||
|
"description": "Always include the camera in Birdseye."
|
||||||
|
},
|
||||||
|
"motion": {
|
||||||
|
"label": "Motion",
|
||||||
|
"description": "Include the camera in Birdseye when motion is detected."
|
||||||
|
},
|
||||||
|
"objects": {
|
||||||
|
"label": "Active objects",
|
||||||
|
"description": "Include the camera in Birdseye while an active object is tracked."
|
||||||
|
},
|
||||||
|
"stationary_objects": {
|
||||||
|
"label": "Stationary objects",
|
||||||
|
"description": "Include the camera in Birdseye while a stationary object is tracked."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"order": {
|
"order": {
|
||||||
"label": "Position",
|
"label": "Position",
|
||||||
|
|||||||
@@ -558,8 +558,24 @@
|
|||||||
"description": "Enable or disable the Birdseye view feature."
|
"description": "Enable or disable the Birdseye view feature."
|
||||||
},
|
},
|
||||||
"mode": {
|
"mode": {
|
||||||
"label": "Tracking mode",
|
"label": "Activity types",
|
||||||
"description": "Mode for including cameras in Birdseye: 'objects', 'motion', or 'continuous'."
|
"description": "Activity types that include cameras in Birdseye.",
|
||||||
|
"continuous": {
|
||||||
|
"label": "Continuous",
|
||||||
|
"description": "Always include the camera in Birdseye."
|
||||||
|
},
|
||||||
|
"motion": {
|
||||||
|
"label": "Motion",
|
||||||
|
"description": "Include the camera in Birdseye when motion is detected."
|
||||||
|
},
|
||||||
|
"objects": {
|
||||||
|
"label": "Active objects",
|
||||||
|
"description": "Include the camera in Birdseye while an active object is tracked."
|
||||||
|
},
|
||||||
|
"stationary_objects": {
|
||||||
|
"label": "Stationary objects",
|
||||||
|
"description": "Include the camera in Birdseye while a stationary object is tracked."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"restream": {
|
"restream": {
|
||||||
"label": "Restream RTSP",
|
"label": "Restream RTSP",
|
||||||
|
|||||||
@@ -1955,7 +1955,7 @@
|
|||||||
"noRecordRole": "No streams have the record role defined. Recording will not function."
|
"noRecordRole": "No streams have the record role defined. Recording will not function."
|
||||||
},
|
},
|
||||||
"birdseye": {
|
"birdseye": {
|
||||||
"objectsModeDetectDisabled": "Birdseye is set to 'objects' mode, but object detection is disabled for this camera. The camera will not appear in Birdseye."
|
"objectTrackingDetectDisabled": "Birdseye includes tracked objects, but object detection is disabled for this camera. The camera will not appear in Birdseye."
|
||||||
},
|
},
|
||||||
"snapshots": {
|
"snapshots": {
|
||||||
"detectDisabled": "Object detection is disabled. Snapshots are generated from tracked objects and will not be created."
|
"detectDisabled": "Object detection is disabled. Snapshots are generated from tracked objects and will not be created."
|
||||||
|
|||||||
@@ -5,13 +5,18 @@ const birdseye: SectionConfigOverrides = {
|
|||||||
sectionDocs: "/configuration/birdseye",
|
sectionDocs: "/configuration/birdseye",
|
||||||
messages: [
|
messages: [
|
||||||
{
|
{
|
||||||
key: "objects-mode-detect-disabled",
|
key: "object-tracking-detect-disabled",
|
||||||
messageKey: "configMessages.birdseye.objectsModeDetectDisabled",
|
messageKey: "configMessages.birdseye.objectTrackingDetectDisabled",
|
||||||
severity: "info",
|
severity: "info",
|
||||||
condition: (ctx) => {
|
condition: (ctx) => {
|
||||||
if (ctx.level !== "camera" || !ctx.fullCameraConfig) return false;
|
if (ctx.level !== "camera" || !ctx.fullCameraConfig) return false;
|
||||||
|
const mode = ctx.formData?.mode;
|
||||||
|
if (!mode || typeof mode !== "object" || Array.isArray(mode)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
ctx.formData?.mode === "objects" &&
|
(mode.objects === true || mode.stationary_objects === true) &&
|
||||||
ctx.fullCameraConfig.detect?.enabled === false
|
ctx.fullCameraConfig.detect?.enabled === false
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -24,10 +29,10 @@ const birdseye: SectionConfigOverrides = {
|
|||||||
overrideFields: ["enabled", "mode"],
|
overrideFields: ["enabled", "mode"],
|
||||||
uiSchema: {
|
uiSchema: {
|
||||||
mode: {
|
mode: {
|
||||||
"ui:size": "xs",
|
continuous: { "ui:size": "xs" },
|
||||||
"ui:options": {
|
motion: { "ui:size": "xs" },
|
||||||
enumI18nPrefix: "birdseye.trackingMode",
|
objects: { "ui:size": "xs" },
|
||||||
},
|
stationary_objects: { "ui:size": "xs" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -55,7 +60,6 @@ const birdseye: SectionConfigOverrides = {
|
|||||||
],
|
],
|
||||||
uiSchema: {
|
uiSchema: {
|
||||||
mode: {
|
mode: {
|
||||||
"ui:size": "xs",
|
|
||||||
"ui:after": { render: "BirdseyeCameraReorder" },
|
"ui:after": { render: "BirdseyeCameraReorder" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -13,12 +13,19 @@ export interface UiConfig {
|
|||||||
export interface BirdseyeConfig {
|
export interface BirdseyeConfig {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
height: number;
|
height: number;
|
||||||
mode: "objects" | "continuous" | "motion";
|
mode: BirdseyeModeConfig;
|
||||||
quality: number;
|
quality: number;
|
||||||
restream: boolean;
|
restream: boolean;
|
||||||
width: number;
|
width: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BirdseyeModeConfig {
|
||||||
|
continuous: boolean;
|
||||||
|
motion: boolean;
|
||||||
|
objects: boolean;
|
||||||
|
stationary_objects: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface FaceRecognitionConfig {
|
export interface FaceRecognitionConfig {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
model_size: SearchModelSize;
|
model_size: SearchModelSize;
|
||||||
@@ -49,7 +56,7 @@ export interface CameraConfig {
|
|||||||
best_image_timeout: number;
|
best_image_timeout: number;
|
||||||
birdseye: {
|
birdseye: {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
mode: "objects" | "continuous" | "motion";
|
mode: BirdseyeModeConfig;
|
||||||
order: number;
|
order: number;
|
||||||
};
|
};
|
||||||
detect: {
|
detect: {
|
||||||
|
|||||||
Reference in New Issue
Block a user