mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-03 05:16:50 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06ed433e9a | ||
|
|
5ab8666d4a | ||
|
|
9b02a077e9 | ||
|
|
d1cb8395ea | ||
|
|
3d0d12366f | ||
|
|
321e78e65b | ||
|
|
17a8efa09c | ||
|
|
0936f4b4ef | ||
|
|
160d213025 | ||
|
|
24a236a152 | ||
|
|
16eac66463 | ||
|
|
3c57ba5020 | ||
|
|
be497bdb28 | ||
|
|
068068ce19 | ||
|
|
2754f788cc | ||
|
|
b749b0bbc5 | ||
|
|
e37041c879 | ||
|
|
1bb61eb808 | ||
|
|
5e87d101da | ||
|
|
d092189b8e | ||
|
|
6d377e56e4 | ||
|
|
b13164cd3d | ||
|
|
2cdb10a96a | ||
|
|
6051374852 | ||
|
|
abe99e5838 | ||
|
|
698f3c1904 | ||
|
|
22a21c761d | ||
|
|
b5613ca06c | ||
|
|
dfd425655e | ||
|
|
19a7f35c00 | ||
|
|
8266a3d9dd | ||
|
|
2755f7c8c3 | ||
|
|
6259872314 | ||
|
|
bd4f7a3656 | ||
|
|
0ea571abfe | ||
|
|
bf032bc92a | ||
|
|
8b035e36d3 | ||
|
|
b7fae3bafd | ||
|
|
ae88705b88 | ||
|
|
c5f979d522 | ||
|
|
0ea9c96f89 | ||
|
|
ca6a85be29 | ||
|
|
6637103b58 | ||
|
|
19a378af43 | ||
|
|
3e075885f7 | ||
|
|
8858f06624 | ||
|
|
f6e119c89e | ||
|
|
32d15f2cbd | ||
|
|
94146e2051 | ||
|
|
f08e880099 | ||
|
|
e155f67f22 | ||
|
|
c26eb023fc | ||
|
|
bd75d4bba0 | ||
|
|
c95d7f1808 | ||
|
|
e36c7ae118 | ||
|
|
008a02eca8 | ||
|
|
61e50a366f | ||
|
|
dc97f294a0 | ||
|
|
a1c8bf99a7 | ||
|
|
f9a37d4f4f | ||
|
|
0977fa5e15 | ||
|
|
4e196516dd | ||
|
|
3941355051 | ||
|
|
1a278630da | ||
|
|
9d0d8a99bb | ||
|
|
bbc412763d | ||
|
|
ac9ac50df5 | ||
|
|
93aa6c4174 | ||
|
|
26e6adee88 | ||
|
|
de416b7ae7 | ||
|
|
06967fec91 | ||
|
|
d69107de33 | ||
|
|
04480a18b6 | ||
|
|
b02aea03cd | ||
|
|
51171319a4 | ||
|
|
b1b725b80a | ||
|
|
50a2b6729e |
@@ -17,9 +17,14 @@ runs:
|
||||
shell: bash
|
||||
# This creates a virtual volume at /var/lib/docker to maximize the size
|
||||
# As of 2/14/2024, this results in 97G for docker images
|
||||
# Runners no longer have a separate /mnt disk, so the temp PV is also carved
|
||||
# from root and temp-reserve-mb is what actually stays free on root. Keep 4G
|
||||
# there for setup-qemu/buildx caches in ~/.docker and the tool cache
|
||||
- name: Maximize build space
|
||||
uses: easimon/maximize-build-space@master
|
||||
with:
|
||||
root-reserve-mb: 8192
|
||||
temp-reserve-mb: 4096
|
||||
remove-dotnet: 'true'
|
||||
remove-android: 'true'
|
||||
remove-haskell: 'true'
|
||||
|
||||
@@ -13,7 +13,7 @@ pathvalidate == 3.3.*
|
||||
markupsafe == 3.0.*
|
||||
python-multipart == 0.0.31
|
||||
# Classification Model Training
|
||||
tensorflow == 2.21.* ; platform_machine == 'aarch64'
|
||||
tensorflow == 2.19.* ; platform_machine == 'aarch64'
|
||||
tensorflow-cpu == 2.19.* ; platform_machine == 'x86_64'
|
||||
# General
|
||||
mypy == 1.6.1
|
||||
|
||||
@@ -15,6 +15,7 @@ from frigate.const import (
|
||||
)
|
||||
from frigate.ffmpeg_presets import parse_preset_hardware_acceleration_encode
|
||||
from frigate.util.config import find_config_file, resolve_ffmpeg_path
|
||||
from frigate.util.live_streams import raw_transcode_streams
|
||||
from frigate.util.services import (
|
||||
is_go2rtc_arbitrary_exec_allowed,
|
||||
is_restricted_go2rtc_source,
|
||||
@@ -174,6 +175,17 @@ for name in list(go2rtc_config.get("streams", {})):
|
||||
del go2rtc_config["streams"][name]
|
||||
continue
|
||||
|
||||
# add transcoded live streams; a user stream with the same name wins here and
|
||||
# fails Frigate's config validation
|
||||
transcoded_streams = raw_transcode_streams(config)
|
||||
|
||||
if transcoded_streams:
|
||||
if go2rtc_config.get("streams") is None:
|
||||
go2rtc_config["streams"] = {}
|
||||
|
||||
for name, source in transcoded_streams.items():
|
||||
go2rtc_config["streams"].setdefault(name, source)
|
||||
|
||||
# add birdseye restream stream if enabled
|
||||
if config.get("birdseye", {}).get("restream", False):
|
||||
birdseye: dict[str, Any] = config.get("birdseye")
|
||||
|
||||
@@ -824,6 +824,38 @@ cpu:
|
||||
models:
|
||||
- devices:
|
||||
- cpu:3
|
||||
xdna2:
|
||||
title: AMD XDNA2
|
||||
models:
|
||||
- key: yolov9
|
||||
label: YOLOv9
|
||||
recommended: true
|
||||
download: |-
|
||||
Prepare the model using the frigate-xdna setup instructions linked above. For local YOLO models, Frigate must have access to the same ONNX file bytes as the sidecar. The example below uses YOLOv9-C at 320x320. Frigate+ models may instead use the same `plus://MODEL_ID` in Frigate and the sidecar.
|
||||
ui: |-
|
||||
Navigate to **Settings > System > Detection models** and add a model. The ZMQ endpoint is not reported by the hardware probe, so set `devices` to `zmq:tcp://xdna:5555` in YAML. Then, on the same model, open the **Custom Model** tab and configure:
|
||||
|
||||
| Field | Value |
|
||||
| ---------------------------------------- | ------------------------------------------ |
|
||||
| **Custom object detector model path** | `/config/models/yolov9-c-320.onnx` |
|
||||
| **Label map for custom object detector** | `/labelmap/coco-80.txt` |
|
||||
| **Object detection model input width** | `320` |
|
||||
| **Object detection model input height** | `320` |
|
||||
| **Model Input Pixel Color Format** | `rgb` (Frigate's default value) |
|
||||
| **Model Input Tensor Shape** | `nchw` |
|
||||
| **Model Input D Type** | `float` |
|
||||
| **Object Detection Model Type** | `yolo-generic` |
|
||||
yaml: |-
|
||||
models:
|
||||
- devices:
|
||||
- zmq:tcp://xdna:5555
|
||||
model_type: yolo-generic
|
||||
width: 320
|
||||
height: 320
|
||||
input_tensor: nchw
|
||||
input_dtype: float
|
||||
path: /config/models/yolov9-c-320.onnx
|
||||
labelmap_path: /labelmap/coco-80.txt
|
||||
memryx:
|
||||
title: MemryX
|
||||
models:
|
||||
|
||||
@@ -152,9 +152,10 @@ auth:
|
||||
models:
|
||||
# Optional: the camera environment this model is for (default: shown below)
|
||||
# Cameras select a model by setting detect -> scene to a matching value, and
|
||||
# a model with a scene of all is used by any camera that does not set one.
|
||||
# Valid values are all, indoor, outdoor, indoor_thermal, outdoor_thermal
|
||||
- scene: all
|
||||
# the model with a scene of default is used by any camera that does not set one.
|
||||
# Any name made up of letters, numbers, _ and - is valid, such as thermal.
|
||||
# Models that use the same model file are combined into one model.
|
||||
- scene: default
|
||||
# Required: hardware this model runs on, as <detector> or <detector>:<device>
|
||||
# See https://docs.frigate.video/configuration/object_detectors for the
|
||||
# detectors available and the devices each one accepts. All of a model's
|
||||
@@ -293,9 +294,9 @@ ffmpeg:
|
||||
# Optional: output args for detect streams (default: shown below)
|
||||
detect: -threads 2 -f rawvideo -pix_fmt yuv420p
|
||||
# Optional: output args for record streams (default: shown below)
|
||||
record: preset-record-generic
|
||||
record: preset-record-generic-audio-aac
|
||||
# Optional: output args for sub stream record streams (default: the record output args above)
|
||||
# record_sub: preset-record-generic
|
||||
# record_sub: preset-record-generic-audio-aac
|
||||
# Optional: Time in seconds to wait before ffmpeg retries connecting to the camera. (default: shown below)
|
||||
# If set too low, frigate will retry a connection to the camera's stream too frequently, using up the limited streams some cameras can allow at once
|
||||
# If set too high, then if a ffmpeg crash or camera stream timeout occurs, you could potentially lose up to a maximum of retry_interval second(s) of footage
|
||||
@@ -316,9 +317,9 @@ detect:
|
||||
# Optional: height of the frame for the input with the detect role (default: use native stream resolution)
|
||||
height: 720
|
||||
# Optional: the environment this camera looks at, which picks the model it runs on
|
||||
# (default: the model with a scene of all)
|
||||
# Valid values are all, indoor, outdoor, indoor_thermal, outdoor_thermal
|
||||
scene: outdoor
|
||||
# (default: the model with a scene of default)
|
||||
# Must match the scene of a configured model
|
||||
scene: thermal
|
||||
# Optional: desired fps for your camera for the input with the detect role (default: shown below)
|
||||
# NOTE: Recommended value of 5. Ideally, try and reduce your FPS on the camera.
|
||||
fps: 5
|
||||
@@ -900,6 +901,21 @@ live:
|
||||
streams:
|
||||
main_stream: main_stream_name
|
||||
sub_stream: sub_stream_name
|
||||
# Optional: Lower-quality live streams transcoded by go2rtc while someone is watching.
|
||||
# NOTE: Set at the camera level only.
|
||||
transcode:
|
||||
# Optional: Enable transcoded streams (default: shown below)
|
||||
enabled: False
|
||||
# Optional: go2rtc stream to transcode (default: the first live stream)
|
||||
source: main_stream_name
|
||||
# Optional: One transcoded stream per quality (default: shown below)
|
||||
qualities:
|
||||
- height: 720
|
||||
bitrate: 1200
|
||||
- height: 480
|
||||
bitrate: 500
|
||||
- height: 360
|
||||
bitrate: 250
|
||||
# Optional: Set the height of the jsmpeg stream. (default: 720)
|
||||
# This must be less than or equal to the height of the detect stream. Lower resolutions
|
||||
# reduce bandwidth required for viewing the jsmpeg stream. Width is computed to match known aspect ratio.
|
||||
|
||||
@@ -85,6 +85,14 @@ An optional config, `save_attempts`, can be set as a key under the model name. T
|
||||
</TabItem>
|
||||
</ConfigTabs>
|
||||
|
||||
## Review items
|
||||
|
||||
When a model's state changes while its camera has an active review item, the change is recorded on that review item. This includes changes in the few seconds before the item starts, such as a garage door opening just before the car is detected. State changes never create or extend review items on their own, and the first state reported after Frigate starts is not recorded as a change.
|
||||
|
||||
Recorded changes appear in the review item's data as `classification_state_changes` (see the [`frigate/reviews`](/integrations/mqtt#frigatereviews) MQTT topic) and are passed to [GenAI review summaries](/configuration/genai/genai_review) as facts, so a description can note that a gate was opened during the activity.
|
||||
|
||||
Change times are most accurate with `motion: true`. A model that only runs on an `interval` notices a change at its next run, so the change may be recorded late or attached to a later review item.
|
||||
|
||||
## Training the model
|
||||
|
||||
Creating and training the model is done within the Frigate UI using the `Classification` page. The process consists of three steps:
|
||||
|
||||
@@ -201,6 +201,8 @@ Review items are sent to the model as a sequence of still frames. Some models fo
|
||||
|
||||
The notes come from tracking data rather than from the images, so they describe activity the model may not have picked up on its own. In testing with a person carrying three waste bins to the curb one at a time, `gemma4` described a single trip on every attempt with `frames`, and consistently described multiple trips with `annotated_frames`. Models that already handle these sequences well, such as the `qwen3-vl` family, gain little and should stay on `frames`.
|
||||
|
||||
Changes reported by [state classification](/configuration/custom_classification/state_classification#review-items) models during the review item are listed in the prompt in both modes. `annotated_frames` also notes each change before the frame where it happened.
|
||||
|
||||
Annotated mode also caps the number of frames, since the notes already establish the order of events and extra near-duplicate frames tend to crowd out the middle of a clip. Longer review items are sampled more sparsely as a result, and typically use fewer tokens than `frames` mode for the same item.
|
||||
|
||||
:::note
|
||||
|
||||
@@ -378,10 +378,10 @@ Navigate to <NavPath path="Settings > Camera configuration > Object detection" /
|
||||
|
||||
Navigate to <NavPath path="Settings > Camera configuration > Objects" />.
|
||||
|
||||
| Field | Description |
|
||||
| ---------------------------------------------- | ------------------- |
|
||||
| **Objects to track** | Add `license_plate` |
|
||||
| **Object filters > License Plate > Threshold** | Set to `0.7` |
|
||||
| Field | Description |
|
||||
| --------------------------------------------------------- | ------------------- |
|
||||
| **Objects to track** | Add `license_plate` |
|
||||
| **Object filters > License Plate > Confidence threshold** | Set to `0.7` |
|
||||
|
||||
Navigate to <NavPath path="Settings > Camera configuration > Motion detection" />.
|
||||
|
||||
|
||||
@@ -92,7 +92,7 @@ go2rtc:
|
||||
|
||||
### Setting Streams For Live UI
|
||||
|
||||
You can configure Frigate to allow manual selection of the stream you want to view in the Live UI. For example, you may want to view your camera's substream on mobile devices, but the full resolution stream on desktop devices. Setting the streams list will populate a dropdown in the UI's Live view that allows you to choose between the streams. This stream setting is _per device_ and is saved in your browser's local storage.
|
||||
You can configure Frigate to allow manual selection of the stream you want to view in the Live UI. For example, you may want to view your camera's substream on mobile devices, but the full resolution stream on desktop devices. Setting the streams list will populate a dropdown in the UI's Live view that allows you to choose between the streams. This stream setting is _per device_ and is saved in your browser's local storage. When a camera has more than one stream, the dropdown also offers **Auto**, which is used until you pick a specific stream. Auto starts on the first stream, steps down the list when your connection can't keep up, and steps back up when it recovers. To retry the top stream right away, select **Try highest quality** under the stream picker. List streams from highest to lowest quality, and avoid names that are plain numbers (such as `720`), which the browser sorts ahead of the others. In the UI, drag streams to reorder them, or use **Auto order** to sort them by measured bitrate.
|
||||
|
||||
Additionally, when creating and editing camera groups in the UI, you can choose the stream you want to use for your camera group's Live dashboard.
|
||||
|
||||
@@ -158,6 +158,26 @@ cameras:
|
||||
</TabItem>
|
||||
</ConfigTabs>
|
||||
|
||||
### Transcoded streams
|
||||
|
||||
When a camera has no suitable sub stream, Frigate can add lower-quality streams that go2rtc transcodes to H.264 while someone is watching. They appear in the stream list like any other stream, so Auto mode can step down to them. Enable them under <NavPath path="Settings > Camera configuration > Live playback" />, or in YAML:
|
||||
|
||||
```yaml
|
||||
cameras:
|
||||
test_cam:
|
||||
live:
|
||||
transcode:
|
||||
enabled: true
|
||||
source: test_cam # optional, defaults to the first live stream
|
||||
qualities:
|
||||
- height: 720
|
||||
bitrate: 1200 # kbps
|
||||
- height: 480
|
||||
bitrate: 500
|
||||
```
|
||||
|
||||
Each quality becomes a go2rtc stream named `<camera>_transcode_<height>p`. go2rtc picks a hardware encoder automatically and falls back to the CPU, which costs CPU for each transcode while it is being watched. Check go2rtc's `api/ffmpeg/hardware` page to see which encoder it found. Using a sub stream as the `source` lowers the cost.
|
||||
|
||||
### WebRTC extra configuration:
|
||||
|
||||
WebRTC works by creating a TCP or UDP connection on port `8555`. However, it requires additional configuration:
|
||||
@@ -363,6 +383,13 @@ When your browser runs into problems playing back your camera streams, it will l
|
||||
- `Safari reported InvalidStateError.`
|
||||
- `Safari reported decoding errors.`
|
||||
|
||||
- **mse-codec**
|
||||
- What it means: go2rtc has no codec for this stream that the browser can play.
|
||||
- What to try: Pick a stream with a codec the browser supports (H.264 is the most compatible), or use a browser that supports the stream's codec. In Auto, Frigate skips this stream for the rest of the session.
|
||||
|
||||
- Possible console messages from the player code:
|
||||
- `mse: streams: codecs not matched: ...`
|
||||
|
||||
- **stalled**
|
||||
- What it means: Playback has stalled because the player has fallen too far behind live (extended buffering or no data arriving).
|
||||
- What to try: This is usually indicative of the browser struggling to decode too many high-resolution streams at once. Try selecting a lower-bandwidth stream (substream), reduce the number of live streams open, improve the network connection, or lower the camera resolution. Also check your camera's keyframe (I-frame) interval: shorter intervals make playback start and recover faster. You can also try increasing the timeout value in <NavPath path="Settings > UI" /> .
|
||||
|
||||
@@ -30,6 +30,7 @@ Frigate supports multiple different detectors that work on different types of ha
|
||||
|
||||
- [ROCm](#amdrocm-gpu-detector): ROCm can run on AMD Discrete GPUs to provide efficient object detection.
|
||||
- [ONNX](#onnx): ROCm will automatically be detected and used as a detector in the `-rocm` Frigate image when a supported ONNX model is configured.
|
||||
- <CommunityBadge /> [XDNA2](#amd-xdna2): AMD Ryzen AI / XDNA2 NPUs can run object detection through the community-maintained `frigate-xdna` ZMQ sidecar.
|
||||
|
||||
**Apple Silicon**
|
||||
|
||||
@@ -103,32 +104,36 @@ Coral EdgeTPU and MemryX accelerators can only be opened by one process, so thos
|
||||
|
||||
### Running more than one model
|
||||
|
||||
Cameras can be split across models by scene, which is useful when indoor and outdoor cameras benefit from differently trained models. Each model declares the `scene` it is for, and each camera picks one with `detect -> scene`:
|
||||
Cameras can be split across models by scene, which is useful when some cameras benefit from a differently trained model, such as thermal cameras. Each model declares the `scene` it is for, and each camera picks one with `detect -> scene`:
|
||||
|
||||
```yaml
|
||||
models:
|
||||
- scene: outdoor
|
||||
path: plus://your-outdoor-model
|
||||
- scene: default
|
||||
path: plus://your-model
|
||||
devices:
|
||||
- edgetpu:pci:0
|
||||
- scene: indoor
|
||||
path: /config/model_cache/indoor.onnx
|
||||
- scene: thermal
|
||||
path: /config/model_cache/thermal.onnx
|
||||
model_type: yolo-generic
|
||||
devices:
|
||||
- openvino:GPU
|
||||
|
||||
cameras:
|
||||
driveway:
|
||||
detect:
|
||||
scene: outdoor
|
||||
...
|
||||
hallway:
|
||||
backyard_thermal:
|
||||
detect:
|
||||
scene: indoor
|
||||
scene: thermal
|
||||
...
|
||||
```
|
||||
|
||||
Available scenes are `all`, `indoor`, `outdoor`, `indoor_thermal`, and `outdoor_thermal`. A model with a scene of `all` is used by every camera that does not set one, and `all` is the default when a model does not declare a scene. Changing a camera's scene requires a restart.
|
||||
A scene is any name made up of letters, numbers, `_`, and `-`. The model with a scene of `default` is used by every camera that does not set one (or sets a scene that no model is configured for), and `default` is used when a model does not declare a scene. Changing a camera's scene requires a restart.
|
||||
|
||||
:::warning
|
||||
|
||||
Scenes are for running **different** models. Do not configure the same model under several scenes to dedicate a detector to specific cameras: every detector of a model already serves every camera using it, and splitting them only leaves some detectors idle while others fall behind. Frigate detects models that use the same model file, even under a different path or file name, combines them into one model with all of their hardware, and logs a warning.
|
||||
|
||||
:::
|
||||
|
||||
### Choosing a model size
|
||||
|
||||
@@ -563,6 +568,28 @@ When using CPU detectors, you can add one CPU detector per camera. Adding more d
|
||||
|
||||
# Community Supported Detectors
|
||||
|
||||
## AMD XDNA2
|
||||
|
||||
AMD Ryzen AI / XDNA2 NPUs can be used through the community-maintained
|
||||
[frigate-xdna](https://github.com/mitchins/frigate-xdna) detector sidecar.
|
||||
The sidecar runs separately from Frigate and connects using Frigate's ZMQ
|
||||
detector interface.
|
||||
|
||||
Currently qualified on **Ryzen AI Max 300 / Strix Halo**. Other XDNA2 devices
|
||||
are not yet qualified; XDNA1 is unsupported.
|
||||
|
||||
Follow the frigate-xdna setup instructions to prepare and start the sidecar
|
||||
before starting Frigate.
|
||||
|
||||
### Configuration {#configuration-xdna2}
|
||||
|
||||
Using the detector config below will connect Frigate to the sidecar:
|
||||
|
||||
<ModelConfigDropdown detectorTitle="AMD XDNA2" models={objectDetectorsModels.xdna2.models} />
|
||||
|
||||
The example assumes Frigate and the sidecar share a Docker network where the
|
||||
sidecar is named `xdna`.
|
||||
|
||||
## MemryX MX3
|
||||
|
||||
This detector is available for use with the MemryX MX3 accelerator M.2 module. Frigate supports the MX3 on compatible hardware platforms, providing efficient and high-performance object detection.
|
||||
|
||||
@@ -45,10 +45,10 @@ Any detection below `min_score` will be immediately thrown out and never tracked
|
||||
|
||||
Navigate to <NavPath path="Settings > Global configuration > Objects" /> to set score filters globally.
|
||||
|
||||
| Field | Description |
|
||||
| --------------------------------------- | ---------------------------------------------------------------- |
|
||||
| **Object filters > Person > Min Score** | Minimum score for a single detection to initiate tracking |
|
||||
| **Object filters > Person > Threshold** | Minimum computed (median) score to be considered a true positive |
|
||||
| Field | Description |
|
||||
| -------------------------------------------------- | ---------------------------------------------------------------- |
|
||||
| **Object filters > Person > Minimum confidence** | Minimum score for a single detection to initiate tracking |
|
||||
| **Object filters > Person > Confidence threshold** | Minimum computed (median) score to be considered a true positive |
|
||||
|
||||
To override score filters for a specific camera, navigate to <NavPath path="Settings > Camera configuration > Objects" /> and select the camera.
|
||||
|
||||
@@ -103,12 +103,12 @@ Conceptually, a ratio of 1 is a square, 0.5 is a "tall skinny" box, and 2 is a "
|
||||
|
||||
Navigate to <NavPath path="Settings > Global configuration > Objects" /> to set shape filters globally.
|
||||
|
||||
| Field | Description |
|
||||
| --------------------------------------- | ------------------------------------------------------------------------ |
|
||||
| **Object filters > Person > Min Area** | Minimum bounding box area in pixels (or decimal for percentage of frame) |
|
||||
| **Object filters > Person > Max Area** | Maximum bounding box area in pixels (or decimal for percentage of frame) |
|
||||
| **Object filters > Person > Min Ratio** | Minimum width/height ratio of the bounding box |
|
||||
| **Object filters > Person > Max Ratio** | Maximum width/height ratio of the bounding box |
|
||||
| Field | Description |
|
||||
| -------------------------------------------------- | ------------------------------------------------------------------------ |
|
||||
| **Object filters > Person > Minimum object area** | Minimum bounding box area in pixels (or decimal for percentage of frame) |
|
||||
| **Object filters > Person > Maximum object area** | Maximum bounding box area in pixels (or decimal for percentage of frame) |
|
||||
| **Object filters > Person > Minimum aspect ratio** | Minimum width/height ratio of the bounding box |
|
||||
| **Object filters > Person > Maximum aspect ratio** | Maximum width/height ratio of the bounding box |
|
||||
|
||||
To override shape filters for a specific camera, navigate to <NavPath path="Settings > Camera configuration > Objects" /> and select the camera.
|
||||
|
||||
|
||||
@@ -70,14 +70,14 @@ Object filters help reduce false positives by constraining the size, shape, and
|
||||
|
||||
Navigate to <NavPath path="Settings > Global configuration > Objects" />.
|
||||
|
||||
| Field | Description |
|
||||
| --------------------------------------- | ------------------------------------------------------------------------ |
|
||||
| **Object filters > Person > Min Area** | Minimum bounding box area in pixels (or decimal for percentage of frame) |
|
||||
| **Object filters > Person > Max Area** | Maximum bounding box area in pixels (or decimal for percentage of frame) |
|
||||
| **Object filters > Person > Min Ratio** | Minimum width/height ratio of the bounding box |
|
||||
| **Object filters > Person > Max Ratio** | Maximum width/height ratio of the bounding box |
|
||||
| **Object filters > Person > Min Score** | Minimum score for the object to initiate tracking |
|
||||
| **Object filters > Person > Threshold** | Minimum computed score to be considered a true positive |
|
||||
| Field | Description |
|
||||
| -------------------------------------------------- | ------------------------------------------------------------------------ |
|
||||
| **Object filters > Person > Minimum object area** | Minimum bounding box area in pixels (or decimal for percentage of frame) |
|
||||
| **Object filters > Person > Maximum object area** | Maximum bounding box area in pixels (or decimal for percentage of frame) |
|
||||
| **Object filters > Person > Minimum aspect ratio** | Minimum width/height ratio of the bounding box |
|
||||
| **Object filters > Person > Maximum aspect ratio** | Maximum width/height ratio of the bounding box |
|
||||
| **Object filters > Person > Minimum confidence** | Minimum score for the object to initiate tracking |
|
||||
| **Object filters > Person > Confidence threshold** | Minimum computed score to be considered a true positive |
|
||||
|
||||
To override filters for a specific camera, navigate to <NavPath path="Settings > Camera configuration > Objects" />.
|
||||
|
||||
|
||||
@@ -191,14 +191,12 @@ cameras:
|
||||
detect:
|
||||
enabled: false
|
||||
record:
|
||||
enabled: false
|
||||
enabled: true
|
||||
profiles:
|
||||
away:
|
||||
enabled: true
|
||||
detect:
|
||||
enabled: true
|
||||
record:
|
||||
enabled: true
|
||||
home:
|
||||
enabled: false
|
||||
```
|
||||
@@ -251,6 +249,12 @@ Leaving the `objects` section empty (or omitting `track`) does not clear the lis
|
||||
|
||||
Fields that require a Frigate restart to take effect cannot be overridden by profiles, since profiles are applied at runtime without restarting. Those fields are hidden when editing a profile override and can only be changed on the base configuration.
|
||||
|
||||
### Why can't a profile enable recording when it's disabled in the base config?
|
||||
|
||||
Frigate only sets up a camera's recording stream at startup when recording is enabled in the base config, so enabling it later from a profile has no effect. The same applies to turning recording on from the UI or MQTT.
|
||||
|
||||
To keep recording off by default, leave `record.enabled: true` in the base config and create a profile that sets `record.enabled: false`. Activate that profile and it will be restored automatically when Frigate starts.
|
||||
|
||||
### Can I schedule profiles to be enabled or disabled at certain times?
|
||||
|
||||
Not within Frigate itself. Frigate is an NVR, not an automation platform, so it intentionally does not include a scheduler for activating profiles. Instead, activate profiles from an automation platform that already handles time- and event-based triggers well, such as [Home Assistant](https://www.home-assistant.io/) or [Node-RED](https://nodered.org/). These integrate with Frigate and give you far more robust and flexible scheduling than a built-in scheduler could.
|
||||
|
||||
@@ -245,8 +245,8 @@ Triggers are best configured through the Frigate UI.
|
||||
1. Navigate to <NavPath path="Settings > Enrichments > Triggers" /> and select a camera from the dropdown menu.
|
||||
2. Click **Add Trigger** to create a new trigger or use the pencil icon to edit an existing one.
|
||||
3. In the **Create Trigger** wizard:
|
||||
- Enter a **Name** for the trigger (e.g., "Red Car Alert").
|
||||
- Enter a descriptive **Friendly Name** for the trigger (e.g., "Red car on the driveway camera").
|
||||
- Enter a **Name** for the trigger (e.g., "Red Car Alert"). Frigate derives the trigger's
|
||||
internal **ID** from this name, which can be revealed and edited with the show/hide toggle.
|
||||
- Select the **Type** (`Thumbnail` or `Description`).
|
||||
- For `Thumbnail`, select an image to trigger this action when a similar thumbnail image is detected, based on the threshold.
|
||||
- For `Description`, enter text to trigger this action when a similar tracked object description is detected.
|
||||
|
||||
@@ -28,7 +28,7 @@ During testing, enable the Zones option for the [Debug view](/usage/live#the-sin
|
||||
1. Navigate to <NavPath path="Settings > Camera configuration > Masks / Zones" /> and select the desired camera.
|
||||
2. Under the **Zones** section, click the plus icon to add a new zone.
|
||||
3. Click on the camera's latest image to create the points for the zone boundary. Click the first point again to close the polygon.
|
||||
4. Configure zone options such as **Friendly name**, **Objects**, **Loitering time**, and **Inertia** in the zone editor.
|
||||
4. Configure zone options such as **Name**, **Objects**, **Loitering Time**, and **Inertia** in the zone editor.
|
||||
5. Press **Save** when finished.
|
||||
|
||||
</TabItem>
|
||||
@@ -200,7 +200,7 @@ When using loitering zones, a review item will behave in the following way:
|
||||
|
||||
1. Navigate to <NavPath path="Settings > Camera configuration > Masks / Zones" /> and select the desired camera.
|
||||
2. Edit or create the zone (e.g., `sidewalk`).
|
||||
- Set **Loitering time** to the desired number of seconds (e.g., `4`)
|
||||
- Set **Loitering Time** to the desired number of seconds (e.g., `4`)
|
||||
- Under **Objects**, add the relevant object types (e.g., `person`)
|
||||
|
||||
</TabItem>
|
||||
@@ -291,7 +291,7 @@ Accurate real-world distance measurements are required to estimate speeds. These
|
||||
|
||||
1. Navigate to <NavPath path="Settings > Camera configuration > Masks / Zones" /> and select the desired camera.
|
||||
2. Create or edit a zone with exactly 4 points aligned to the ground plane.
|
||||
3. In the zone editor, enter the real-world **Distances** between each pair of consecutive points.
|
||||
3. In the zone editor, enable **Speed Estimation** and enter the real-world **Line A distance**, **Line B distance**, **Line C distance**, and **Line D distance** between each pair of consecutive points.
|
||||
- For example, if the distance between the first and second points is 10 meters, between the second and third is 12 meters, etc.
|
||||
4. Distances are measured in meters (metric) or feet (imperial), depending on the **Unit system** setting.
|
||||
|
||||
@@ -358,7 +358,7 @@ Zones can be configured with a minimum speed requirement, meaning an object must
|
||||
|
||||
1. Navigate to <NavPath path="Settings > Camera configuration > Masks / Zones" /> and select the desired camera.
|
||||
2. Edit or create the zone with distances configured.
|
||||
- Set **Speed threshold** to the desired minimum speed (e.g., `20`)
|
||||
- Set **Speed Threshold** to the desired minimum speed (e.g., `20`)
|
||||
- The unit is kph or mph, depending on the **Unit system** setting
|
||||
|
||||
</TabItem>
|
||||
|
||||
@@ -54,7 +54,7 @@ An object filter mask drops any [bounding box](#bounding-box) whose bottom cente
|
||||
|
||||
## Min Score
|
||||
|
||||
The lowest score a detected object can have to be kept during tracking. Anything scoring below the minimum is assumed to be a [false positive](#false-positive) and discarded.
|
||||
The lowest score a detected object can have to be kept during tracking. Anything scoring below the minimum is assumed to be a [false positive](#false-positive) and discarded. Set with `min_score` in the config, shown as **Minimum confidence** in the settings UI.
|
||||
|
||||
## Model
|
||||
|
||||
@@ -86,7 +86,7 @@ A more specific identity assigned to a [tracked object](#tracked-object-event-in
|
||||
|
||||
## Threshold
|
||||
|
||||
The median score an object must reach to be considered a true positive.
|
||||
The median score an object must reach to be considered a true positive. Set with `threshold` in the config, shown as **Confidence threshold** in the settings UI.
|
||||
|
||||
## Top Score
|
||||
|
||||
|
||||
@@ -75,6 +75,9 @@ Frigate supports multiple different detectors that work on different types of ha
|
||||
- [ROCm](#rocm---amd-gpu): ROCm can run on AMD Discrete GPUs to provide efficient object detection
|
||||
- [Supports limited model architectures](../../configuration/object_detectors#amdrocm-gpu-detector)
|
||||
- Runs best on discrete AMD GPUs
|
||||
- <CommunityBadge /> [XDNA2 (Ryzen AI)](#amd-xdna2): AMD XDNA2 NPU (sub-watt power AI/ML processor separate to the GPU) inside Strix and other "AI" branded AMD platforms
|
||||
- Has only been tested with YOLOv9, in theory other graphs may be compiled too.
|
||||
- Runs via ZMQ proxy which adds some latency, only recommended for local connection
|
||||
|
||||
**Apple Silicon**
|
||||
|
||||
@@ -338,6 +341,32 @@ The inference time of a rk3588 with all 3 cores enabled is typically 25-30 ms fo
|
||||
| ---------------- | ----------------------------------- |
|
||||
| yolov9-tiny | ~ 4 ms |
|
||||
|
||||
### AMD Ryzen AI / XDNA2
|
||||
|
||||
Frigate supports AMD XDNA2 NPUs through the community-maintained
|
||||
frigate-xdna ZMQ sidecar. It works with stock Frigate and supports
|
||||
Frigate+ models or compatible local YOLO ONNX models. Models are compiled
|
||||
once on the target system and cached for subsequent use.
|
||||
|
||||
Currently qualified on **Ryzen AI Max 300 / Strix Halo**. Other XDNA2
|
||||
devices are not yet qualified; XDNA1 is unsupported.
|
||||
|
||||
Measured YOLOv9 detector latency on Strix Halo:
|
||||
|
||||
| Model | 320 | 640 |
|
||||
| ----- | ---: | ---: |
|
||||
| YOLOv9-T | ~7.4 ms | unsupported |
|
||||
| YOLOv9-S | ~9.0 ms | ~20.0 ms |
|
||||
| YOLOv9-M | ~13.1 ms | ~34.4 ms |
|
||||
| YOLOv9-C | ~14.1 ms | ~35.2 ms |
|
||||
| YOLOv9-E | ~69.4 ms | ~224.8 ms |
|
||||
|
||||
**YOLOv9-C at 320 is the recommended quality/performance balance.**
|
||||
C at 640 is also usable where the lower throughput is acceptable.
|
||||
|
||||
Setup, model preparation, and compatibility details are available
|
||||
[in the frigate-xdna documentation](https://github.com/mitchins/frigate-xdna).
|
||||
|
||||
## What does Frigate use the CPU for and what does it use a detector for? (ELI5 Version)
|
||||
|
||||
This is taken from a [user question on reddit](https://www.reddit.com/r/homeassistant/comments/q8mgau/comment/hgqbxh5/?utm_source=share&utm_medium=web2x&context=3). Modified slightly for clarity.
|
||||
|
||||
@@ -11,6 +11,12 @@ MQTT requires a network connection to your broker. This is typically local, but
|
||||
|
||||
:::
|
||||
|
||||
:::note
|
||||
|
||||
Wherever a topic below includes a camera, mask, or zone name, use its `ID` from the config, not its `friendly_name`. For example, a camera with `friendly_name: "Back Yard"` and ID `back_yard` publishes to `frigate/back_yard/...`, not `frigate/Back Yard/...`.
|
||||
|
||||
:::
|
||||
|
||||
## General Frigate Topics
|
||||
|
||||
### `frigate/available`
|
||||
@@ -212,6 +218,7 @@ An `update` with the same ID will be published when:
|
||||
- The severity changes from `detection` to `alert`
|
||||
- Additional objects are detected
|
||||
- An object is recognized via face, lpr, etc.
|
||||
- A [state classification](/configuration/custom_classification/state_classification#review-items) model changes state
|
||||
|
||||
When the review activity has ended a final `end` message is published.
|
||||
|
||||
@@ -235,7 +242,8 @@ When the review activity has ended a final `end` message is published.
|
||||
"objects": ["person", "car"],
|
||||
"sub_labels": [],
|
||||
"zones": [],
|
||||
"audio": []
|
||||
"audio": [],
|
||||
"classification_state_changes": []
|
||||
}
|
||||
},
|
||||
"after": {
|
||||
@@ -254,7 +262,16 @@ When the review activity has ended a final `end` message is published.
|
||||
"objects": ["person", "car"],
|
||||
"sub_labels": ["Bob"],
|
||||
"zones": ["front_yard"],
|
||||
"audio": []
|
||||
"audio": [],
|
||||
"classification_state_changes": [
|
||||
// verified changes of state classification models on this camera
|
||||
{
|
||||
"model": "front_gate",
|
||||
"from": "closed",
|
||||
"to": "open",
|
||||
"timestamp": 1718987131.52
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,10 @@ The [Advanced Camera Card](https://card.camera/#/README) is a Home Assistant das
|
||||
It supports automatically setting the sub labels in Frigate for person objects that are detected and recognized.
|
||||
This is a fork (with fixed errors and new features) of [original Double Take](https://github.com/jakowenko/double-take) project which, unfortunately, isn't being maintained by author.
|
||||
|
||||
## [frigate-abr](https://github.com/007hacky007/frigate-abr)
|
||||
|
||||
[frigate-abr](https://github.com/007hacky007/frigate-abr) is a drop-in Docker image of Frigate that adds adaptive bitrate (ABR) playback for recordings: a sidecar transcodes footage to lower quality tiers on demand, for reviewing over slow remote connections. Segments are transcoded when played and cached, so no additional stream is recorded. Frigate itself is not modified.
|
||||
|
||||
## [Frigate Notify](https://github.com/0x2142/frigate-notify)
|
||||
|
||||
[Frigate Notify](https://github.com/0x2142/frigate-notify) is a simple app designed to send notifications from Frigate to your favorite platforms. Intended to be used with standalone Frigate installations - Home Assistant not required, MQTT is optional but recommended.
|
||||
|
||||
@@ -21,7 +21,13 @@ Yes. Models and metadata are stored in the `model_cache` directory within the co
|
||||
|
||||
### Can I keep using my Frigate+ models even if I do not renew my subscription?
|
||||
|
||||
Yes. Subscriptions to Frigate+ provide access to the infrastructure used to train the models. Models trained with your subscription are yours to keep and use forever. However, do note that the terms and conditions prohibit you from sharing, reselling, or creating derivative products from the models.
|
||||
Yes. Subscriptions to Frigate+ provide access to the infrastructure used to train the models. Models you train during an active subscription remain licensed for your continued use even after your subscription ends — models already in your model cache will keep working indefinitely. An active subscription is required to train new models and download new versions.
|
||||
|
||||
### Can I use Frigate+ models commercially?
|
||||
|
||||
A standard subscription covers use on camera systems you own or operate, including for your business. A shop, restaurant, warehouse, or office running Frigate+ at its own locations (including multiple locations) is exactly the kind of use the subscription is for.
|
||||
What the standard subscription does not cover is using Frigate+ models to provide a product or service to others. If you're deploying models at your customers' sites, bundling them with hardware you sell, or running them as part of a hosted or managed service, even if your customers never receive the model files themselves, you'll need a commercial license.
|
||||
Note that professional installers are fine under standard subscriptions when each customer holds their own Frigate+ subscription. The commercial license is for cases where your license powers your customers' sites.
|
||||
|
||||
### Why can't I submit images to Frigate+?
|
||||
|
||||
|
||||
@@ -63,20 +63,20 @@ Frigate+ models generally have much higher scores than the default model provide
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > Global configuration > Objects" />. Under **Object filters**, set **Min Score** and **Threshold** for each object type, then click **Save**.
|
||||
Navigate to <NavPath path="Settings > Global configuration > Objects" />. Under **Object filters**, set **Minimum confidence** and **Confidence threshold** for each object type, then click **Save**.
|
||||
|
||||
| Object | Min Score | Threshold |
|
||||
| ----------------- | --------- | --------- |
|
||||
| **dog** | .7 | .9 |
|
||||
| **cat** | .65 | .8 |
|
||||
| **face** | .7 | |
|
||||
| **package** | .65 | .9 |
|
||||
| **license_plate** | .6 | |
|
||||
| **amazon** | .75 | |
|
||||
| **ups** | .75 | |
|
||||
| **fedex** | .75 | |
|
||||
| **person** | .65 | .85 |
|
||||
| **car** | .65 | .85 |
|
||||
| Object | Minimum confidence | Confidence threshold |
|
||||
| ----------------- | ------------------ | -------------------- |
|
||||
| **dog** | .7 | .9 |
|
||||
| **cat** | .65 | .8 |
|
||||
| **face** | .7 | |
|
||||
| **package** | .65 | .9 |
|
||||
| **license_plate** | .6 | |
|
||||
| **amazon** | .75 | |
|
||||
| **ups** | .75 | |
|
||||
| **fedex** | .75 | |
|
||||
| **person** | .65 | .85 |
|
||||
| **car** | .65 | .85 |
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
|
||||
@@ -65,11 +65,11 @@ Some users may find that Frigate+ models result in more false positives initiall
|
||||
|
||||
Frigate+ models support a more relevant set of objects for security cameras. The labels for annotation in Frigate+ are configurable by editing the camera in the Cameras section of Frigate+. Currently, the following objects are supported:
|
||||
|
||||
- **People**: `person`, `face`
|
||||
- **Vehicles**: `car`, `motorcycle`, `bicycle`, `boat`, `school_bus`, `license_plate`
|
||||
- **People**: `person`, `face`, `baby`
|
||||
- **Vehicles**: `car`, `motorcycle`, `bicycle`, `boat`, `school_bus`, `garbage truck`, `license_plate`
|
||||
- **Delivery Logos**: `amazon`, `usps`, `ups`, `fedex`, `dhl`, `an_post`, `purolator`, `postnl`, `nzpost`, `postnord`, `gls`, `dpd`, `canada_post`, `royal_mail`
|
||||
- **Animals**: `dog`, `cat`, `deer`, `horse`, `bird`, `raccoon`, `fox`, `bear`, `cow`, `squirrel`, `goat`, `rabbit`, `skunk`, `kangaroo`
|
||||
- **Other**: `package`, `waste_bin`, `bbq_grill`, `robot_lawnmower`, `umbrella`
|
||||
- **Animals**: `dog`, `cat`, `deer`, `horse`, `bird`, `raccoon`, `fox`, `bear`, `cow`, `squirrel`, `goat`, `rabbit`, `skunk`, `kangaroo`, `possum`, `rodent`
|
||||
- **Other**: `package`, `waste_bin`, `bbq_grill`, `robot_lawnmower`, `umbrella`, `baby_stroller`
|
||||
|
||||
Other object types available in the default Frigate model are not available. Additional object types will be added in future releases.
|
||||
|
||||
@@ -77,9 +77,12 @@ Other object types available in the default Frigate model are not available. Add
|
||||
|
||||
Candidate labels are also available for annotation. These labels don't have enough data to be included in the model yet, but using them will help add support sooner. You can enable these labels by editing the camera settings.
|
||||
|
||||
Where possible, these labels are mapped to existing labels during training. For example, any `baby` labels are mapped to `person` until support for new labels is added.
|
||||
Where possible, these labels are mapped to existing labels during training. For example, any `duck` labels are mapped to `bird` until support for new labels is added.
|
||||
|
||||
The candidate labels are: `baby`, `bpost`, `badger`, `possum`, `rodent`, `chicken`, `groundhog`, `boar`, `hedgehog`, `tractor`, `golf cart`, `garbage truck`, `bus`, `sports ball`, `la_poste`, `lawnmower`, `heron`, `rickshaw`, `wombat`, `auspost`, `aramex`, `bobcat`, `mustelid`, `transoflex`, `airplane`, `drone`, `mountain_lion`, `crocodile`, `turkey`, `baby_stroller`, `monkey`, `coyote`, `porcupine`, `parcelforce`, `sheep`, `snake`, `helicopter`, `lizard`, `duck`, `hermes`, `cargus`, `fan_courier`, `sameday`
|
||||
- **Vehicles**: `tractor`, `golf_cart`, `bus`, `airplane`, `helicopter`, `rickshaw`, `scooter`
|
||||
- **Delivery Logos**: `bpost`, `auspost`, `aramex`, `transoflex`, `parcelforce`, `hermes`, `cargus`, `fan_courier`, `sameday`, `la_poste`
|
||||
- **Animals**: `badger`, `chicken`, `duck`, `turkey`, `groundhog`, `boar`, `hedgehog`, `wombat`, `bobcat`, `mustelid`, `mountain_lion`, `crocodile`, `monkey`, `coyote`, `porcupine`, `sheep`, `snake`, `lizard`, `heron`, `elk`, `moose`, `pig`, `donkey`, `civet`
|
||||
- **Other**: `sports_ball`, `drone`, `lawnmower`
|
||||
|
||||
Candidate labels are not available for automatic suggestions.
|
||||
|
||||
|
||||
@@ -397,19 +397,11 @@ dmesg | grep -i -E "gpu|drm|reset|hang"
|
||||
|
||||
Messages like `trying reset from guc_exec_queue_timedout_job` or similar GPU reset/hang messages indicate a driver or hardware issue. Ensure your kernel and GPU drivers (especially Intel) are up to date.
|
||||
|
||||
#### Step 6: Verify hardware acceleration configuration
|
||||
|
||||
An incorrect `hwaccel_args` preset can cause ffmpeg to fail silently or consume excessive CPU, starving the detector of resources.
|
||||
|
||||
- After upgrading Frigate, verify your preset matches your hardware (e.g., `preset-intel-qsv-h264` instead of the deprecated `preset-vaapi`).
|
||||
- For h265 cameras, use the corresponding h265 preset (e.g., `preset-intel-qsv-h265`).
|
||||
- Note that `hwaccel_args` are only relevant for the detect stream. Frigate does not decode the record stream.
|
||||
|
||||
#### Step 7: Verify go2rtc stream configuration
|
||||
#### Step 6: Verify go2rtc stream configuration
|
||||
|
||||
Ensure that the ffmpeg source names in your go2rtc configuration match the correct camera stream. A misconfigured stream name (e.g., copying a config from one camera to another without updating the stream reference) will cause the wrong stream to be used or the stream to fail entirely.
|
||||
|
||||
#### Step 8: Check system resources
|
||||
#### Step 7: Check system resources
|
||||
|
||||
If none of the above apply, the issue may be a general resource constraint. Monitor the following on your host:
|
||||
|
||||
|
||||
+30
-22
@@ -3,6 +3,9 @@ import * as path from "node:path";
|
||||
import type { Config, PluginConfig } from "@docusaurus/types";
|
||||
import type * as OpenApiPlugin from "docusaurus-plugin-openapi-docs";
|
||||
|
||||
// Bump when a new stable release ships
|
||||
const STABLE_VERSION = "0.18";
|
||||
|
||||
const config: Config = {
|
||||
title: "Frigate",
|
||||
tagline: "NVR With Realtime Object Detection for IP Cameras",
|
||||
@@ -23,17 +26,17 @@ const config: Config = {
|
||||
mermaid: true,
|
||||
},
|
||||
i18n: {
|
||||
defaultLocale: 'en',
|
||||
locales: ['en'],
|
||||
defaultLocale: "en",
|
||||
locales: ["en"],
|
||||
localeConfigs: {
|
||||
en: {
|
||||
label: 'English',
|
||||
}
|
||||
label: "English",
|
||||
},
|
||||
},
|
||||
},
|
||||
themeConfig: {
|
||||
announcementBar: {
|
||||
id: 'frigate_plus',
|
||||
id: "frigate_plus",
|
||||
content: `
|
||||
<span style="margin-right: 8px; display: inline-block; animation: pulse 2s infinite;">🚀</span>
|
||||
Get more relevant and accurate detections with Frigate+ models.
|
||||
@@ -45,8 +48,8 @@ const config: Config = {
|
||||
50% { transform: scale(1.1); }
|
||||
}
|
||||
</style>`,
|
||||
backgroundColor: '#005f73',
|
||||
textColor: '#e0fbfc',
|
||||
backgroundColor: "#005f73",
|
||||
textColor: "#e0fbfc",
|
||||
isCloseable: false,
|
||||
},
|
||||
docs: {
|
||||
@@ -83,15 +86,15 @@ const config: Config = {
|
||||
},
|
||||
},
|
||||
prism: {
|
||||
magicComments:[
|
||||
magicComments: [
|
||||
{
|
||||
className: 'theme-code-block-highlighted-line',
|
||||
line: 'highlight-next-line',
|
||||
block: {start: 'highlight-start', end: 'highlight-end'},
|
||||
className: "theme-code-block-highlighted-line",
|
||||
line: "highlight-next-line",
|
||||
block: { start: "highlight-start", end: "highlight-end" },
|
||||
},
|
||||
{
|
||||
className: 'code-block-error-line',
|
||||
line: 'highlight-error-line',
|
||||
className: "code-block-error-line",
|
||||
line: "highlight-error-line",
|
||||
},
|
||||
],
|
||||
additionalLanguages: ["bash", "json"],
|
||||
@@ -131,6 +134,11 @@ const config: Config = {
|
||||
srcDark: "img/branding/logo-dark.svg",
|
||||
},
|
||||
items: [
|
||||
{
|
||||
href: "https://github.com/blakeblackshear/frigate/releases",
|
||||
label: `${STABLE_VERSION}`,
|
||||
position: "left",
|
||||
},
|
||||
{
|
||||
to: "/",
|
||||
activeBasePath: "docs",
|
||||
@@ -148,19 +156,19 @@ const config: Config = {
|
||||
position: "right",
|
||||
},
|
||||
{
|
||||
type: 'localeDropdown',
|
||||
position: 'right',
|
||||
type: "localeDropdown",
|
||||
position: "right",
|
||||
dropdownItemsAfter: [
|
||||
{
|
||||
label: '简体中文(社区翻译)',
|
||||
href: 'https://docs.frigate-cn.video',
|
||||
}
|
||||
]
|
||||
label: "简体中文(社区翻译)",
|
||||
href: "https://docs.frigate-cn.video",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
href: 'https://github.com/blakeblackshear/frigate',
|
||||
label: 'GitHub',
|
||||
position: 'right',
|
||||
href: "https://github.com/blakeblackshear/frigate",
|
||||
label: "GitHub",
|
||||
position: "right",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
Generated
+6
-6
@@ -9460,9 +9460,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.4.13",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz",
|
||||
"integrity": "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==",
|
||||
"version": "3.4.16",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.16.tgz",
|
||||
"integrity": "sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
@@ -10125,9 +10125,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "3.1.7",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz",
|
||||
"integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==",
|
||||
"version": "3.1.8",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
|
||||
"integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
|
||||
Vendored
+58
@@ -412,6 +412,39 @@ paths:
|
||||
security:
|
||||
- frigateAdminAuth: []
|
||||
x-required-role: admin
|
||||
/go2rtc/streams/{stream_name}/bitrate:
|
||||
get:
|
||||
tags:
|
||||
- Camera
|
||||
summary: Go2Rtc Stream Bitrate
|
||||
description: |-
|
||||
**Access:** Admin role required.
|
||||
|
||||
Measure a go2rtc stream's bitrate over a few seconds.
|
||||
operationId:
|
||||
go2rtc_stream_bitrate_go2rtc_streams__stream_name__bitrate_get
|
||||
parameters:
|
||||
- name: stream_name
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
title: Stream Name
|
||||
responses:
|
||||
'200':
|
||||
description: Successful Response
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'422':
|
||||
description: Validation Error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/HTTPValidationError'
|
||||
security:
|
||||
- frigateAdminAuth: []
|
||||
x-required-role: admin
|
||||
/ffprobe:
|
||||
get:
|
||||
tags:
|
||||
@@ -4460,6 +4493,16 @@ paths:
|
||||
- type: 'null'
|
||||
default: 100
|
||||
title: Limit
|
||||
- name: offset
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
anyOf:
|
||||
- type: integer
|
||||
minimum: 0
|
||||
- type: 'null'
|
||||
default: 0
|
||||
title: Offset
|
||||
- name: after
|
||||
in: query
|
||||
required: false
|
||||
@@ -4765,6 +4808,16 @@ paths:
|
||||
- type: 'null'
|
||||
default: 50
|
||||
title: Limit
|
||||
- name: offset
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
anyOf:
|
||||
- type: integer
|
||||
minimum: 0
|
||||
- type: 'null'
|
||||
default: 0
|
||||
title: Offset
|
||||
- name: cameras
|
||||
in: query
|
||||
required: false
|
||||
@@ -7731,6 +7784,11 @@ components:
|
||||
type: boolean
|
||||
title: Skip Save
|
||||
default: false
|
||||
replace_paths:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
title: Replace Paths
|
||||
type: object
|
||||
title: AppConfigSetBody
|
||||
AppPostLoginBody:
|
||||
|
||||
@@ -63,6 +63,7 @@ from frigate.util.builtin import (
|
||||
flatten_config_data,
|
||||
load_labels,
|
||||
process_config_query_string,
|
||||
split_config_key_path,
|
||||
update_yaml_file_bulk,
|
||||
)
|
||||
from frigate.util.config import (
|
||||
@@ -70,6 +71,10 @@ from frigate.util.config import (
|
||||
find_config_file,
|
||||
redact_credential,
|
||||
)
|
||||
from frigate.util.live_streams import (
|
||||
generated_transcode_streams,
|
||||
sync_transcode_streams,
|
||||
)
|
||||
from frigate.util.object_names import get_categorized_object_names
|
||||
from frigate.util.schema import get_config_schema
|
||||
from frigate.util.services import (
|
||||
@@ -393,6 +398,11 @@ def config(request: Request):
|
||||
model_dict["non_logo_attributes"] = model.non_logo_attributes
|
||||
model_dict["labelmap"] = model.merged_labelmap
|
||||
|
||||
# report the configured reference rather than the resolved cache path,
|
||||
# so saving the config back doesn't lose the Frigate+ model
|
||||
if model.plus_id:
|
||||
model_dict["path"] = f"plus://{model.plus_id}"
|
||||
|
||||
if not config["plus"]["enabled"]:
|
||||
continue
|
||||
|
||||
@@ -810,6 +820,17 @@ def _config_set_in_memory(request: Request, body: AppConfigSetBody) -> JSONRespo
|
||||
)
|
||||
|
||||
|
||||
def _config_path_exists(data: Any, key_path: str) -> bool:
|
||||
"""Return whether a dotted config path is present in parsed yaml."""
|
||||
for key in split_config_key_path(key_path):
|
||||
if not isinstance(data, dict) or key not in data:
|
||||
return False
|
||||
|
||||
data = data[key]
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@router.put("/config/set", dependencies=[Depends(require_role(["admin"]))])
|
||||
def config_set(request: Request, body: AppConfigSetBody):
|
||||
config_file = find_config_file()
|
||||
@@ -864,6 +885,19 @@ def config_set(request: Request, body: AppConfigSetBody):
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# delete replaced paths first so their maps are rewritten in
|
||||
# the order sent; update_yaml would otherwise keep old order
|
||||
if body.replace_paths:
|
||||
old_yaml = ruamel.yaml.YAML(typ="safe").load(old_raw_config) or {}
|
||||
updates = {
|
||||
**{
|
||||
path: ""
|
||||
for path in body.replace_paths
|
||||
if _config_path_exists(old_yaml, path)
|
||||
},
|
||||
**updates,
|
||||
}
|
||||
|
||||
# apply all updates in a single operation
|
||||
update_yaml_file_bulk(config_file, updates)
|
||||
|
||||
@@ -927,9 +961,15 @@ def config_set(request: Request, body: AppConfigSetBody):
|
||||
if request.app.dispatcher is not None:
|
||||
request.app.dispatcher.clear_runtime_state_for_yaml_keys(updates.keys())
|
||||
|
||||
go2rtc_synced = True
|
||||
|
||||
if body.requires_restart == 0 or body.update_topic:
|
||||
old_config: FrigateConfig = request.app.frigate_config
|
||||
swap_runtime_config(request.app, config)
|
||||
go2rtc_synced = sync_transcode_streams(
|
||||
generated_transcode_streams(old_config),
|
||||
generated_transcode_streams(config),
|
||||
)
|
||||
|
||||
if body.update_topic:
|
||||
if body.update_topic.startswith("config/cameras/"):
|
||||
@@ -989,6 +1029,7 @@ def config_set(request: Request, body: AppConfigSetBody):
|
||||
if body.requires_restart == 0
|
||||
else "Config successfully updated, restart to apply"
|
||||
),
|
||||
"go2rtc_synced": go2rtc_synced,
|
||||
}
|
||||
),
|
||||
status_code=200,
|
||||
|
||||
+24
-24
@@ -130,23 +130,18 @@ def require_admin_by_default():
|
||||
if path.startswith(EXEMPT_PREFIXES):
|
||||
return
|
||||
|
||||
# Dynamic camera path exemption:
|
||||
# Any path whose first segment matches a configured camera name should
|
||||
# bypass the global admin requirement. These endpoints enforce access
|
||||
# via route-level dependencies (e.g. require_camera_access) to ensure
|
||||
# per-camera authorization. This allows non-admin authenticated users
|
||||
# (e.g. viewer role) to access camera-specific resources without
|
||||
# needing admin privileges.
|
||||
try:
|
||||
if path.startswith("/"):
|
||||
first_segment = path.split("/", 2)[1]
|
||||
if (
|
||||
first_segment
|
||||
and first_segment in request.app.frigate_config.cameras
|
||||
):
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
# Camera routes enforce per-camera access via route-level dependencies
|
||||
# (e.g. require_camera_access). Match on the route template, not the raw
|
||||
# path, so a camera named like another namespace (e.g. "faces") can't
|
||||
# waive the admin check for that namespace's routes.
|
||||
route = request.scope.get("route")
|
||||
if (
|
||||
route is not None
|
||||
and route.path.startswith("/{camera_name}")
|
||||
and request.path_params.get("camera_name")
|
||||
in request.app.frigate_config.cameras
|
||||
):
|
||||
return
|
||||
|
||||
# For all other paths, require admin role
|
||||
# Internal port requests have admin role set automatically
|
||||
@@ -324,11 +319,17 @@ def get_remote_addr(request: Request):
|
||||
network = ipaddress.ip_network(proxy)
|
||||
except ValueError:
|
||||
logger.warning(f"Unable to parse trusted network: {proxy}")
|
||||
continue
|
||||
trusted_proxies.append(network)
|
||||
|
||||
# return the first remote address that is not trusted
|
||||
for addr in route:
|
||||
ip = ipaddress.ip_address(addr.strip())
|
||||
try:
|
||||
ip = ipaddress.ip_address(addr.strip())
|
||||
except ValueError:
|
||||
logger.debug("Invalid address in X-Forwarded-For header")
|
||||
return direct_addr or "127.0.0.1"
|
||||
|
||||
logger.debug(f"Checking {ip} (v{ip.version})")
|
||||
trusted = False
|
||||
for trusted_proxy in trusted_proxies:
|
||||
@@ -473,12 +474,11 @@ def create_encoded_jwt(user, role, expiration, secret):
|
||||
|
||||
def set_jwt_cookie(response: Response, cookie_name, encoded_jwt, max_age, secure):
|
||||
# TODO: ideally this would set secure as well, but that requires TLS
|
||||
# SameSite is intentionally left unset (browsers default to Lax). Setting
|
||||
# SameSite=Lax/Strict would stop the cookie from being sent in cross-origin
|
||||
# iframes, breaking embedded views such as the Home Assistant Frigate card.
|
||||
# CSRF is instead mitigated by requiring a custom X-CSRF-TOKEN header, which
|
||||
# cross-origin pages cannot set without a CORS preflight that Frigate never
|
||||
# grants (see check_csrf in api/fastapi_app.py).
|
||||
# Starlette sets SameSite=Lax by default. The cookie is still sent to
|
||||
# same-site iframes (e.g. Home Assistant on the same host or domain), but
|
||||
# not to cross-site ones. CSRF is also mitigated by requiring a custom
|
||||
# X-CSRF-TOKEN header, which cross-origin pages cannot set without a CORS
|
||||
# preflight that Frigate never grants (see check_csrf in api/fastapi_app.py).
|
||||
response.set_cookie(
|
||||
key=cookie_name,
|
||||
value=encoded_jwt,
|
||||
|
||||
@@ -39,6 +39,11 @@ from frigate.util.builtin import clean_camera_user_pass, get_record_segment_time
|
||||
from frigate.util.camera_cleanup import cleanup_camera_db, cleanup_camera_files
|
||||
from frigate.util.config import find_config_file
|
||||
from frigate.util.image import run_ffmpeg_snapshot
|
||||
from frigate.util.live_streams import (
|
||||
generated_transcode_streams,
|
||||
measure_stream_bitrate,
|
||||
sync_transcode_streams,
|
||||
)
|
||||
from frigate.util.services import (
|
||||
analyze_record_keyframes,
|
||||
ffprobe_stream,
|
||||
@@ -248,6 +253,34 @@ def go2rtc_delete_stream(stream_name: str):
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/go2rtc/streams/{stream_name}/bitrate",
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
)
|
||||
async def go2rtc_stream_bitrate(request: Request, stream_name: str):
|
||||
"""Measure a go2rtc stream's bitrate over a few seconds."""
|
||||
config: FrigateConfig = request.app.frigate_config
|
||||
known = set(config.go2rtc.model_dump().get("streams") or {}) | set(
|
||||
generated_transcode_streams(config)
|
||||
)
|
||||
|
||||
if stream_name not in known:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Unknown stream"},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
kbps = await asyncio.to_thread(measure_stream_bitrate, stream_name)
|
||||
|
||||
if kbps is None:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Stream sent no data"},
|
||||
status_code=502,
|
||||
)
|
||||
|
||||
return JSONResponse(content={"success": True, "kbps": round(kbps)})
|
||||
|
||||
|
||||
@router.get("/ffprobe", dependencies=[Depends(require_role(["admin"]))])
|
||||
def ffprobe(request: Request, paths: str = "", detailed: bool = False):
|
||||
path_param = paths
|
||||
@@ -1342,6 +1375,12 @@ async def delete_camera(
|
||||
except Exception:
|
||||
logger.debug("Failed to remove go2rtc stream for %s", camera_name)
|
||||
|
||||
await asyncio.to_thread(
|
||||
sync_transcode_streams,
|
||||
generated_transcode_streams(frigate_config),
|
||||
generated_transcode_streams(request.app.frigate_config),
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": True,
|
||||
|
||||
@@ -51,6 +51,7 @@ def swap_runtime_config(app: FastAPI, config: FrigateConfig) -> None:
|
||||
|
||||
if app.stats_emitter is not None:
|
||||
app.stats_emitter.config = config
|
||||
app.stats_emitter.hardware_stats.set_config(config)
|
||||
|
||||
if app.dispatcher is not None:
|
||||
app.dispatcher.config = config
|
||||
|
||||
@@ -14,6 +14,7 @@ class EventsQueryParams(BaseModel):
|
||||
zone: str | None = "all"
|
||||
zones: str | None = "all"
|
||||
limit: int | None = 100
|
||||
offset: int | None = Field(0, ge=0)
|
||||
after: float | None = None
|
||||
before: float | None = None
|
||||
time_range: str | None = DEFAULT_TIME_RANGE
|
||||
@@ -55,6 +56,7 @@ class EventsSearchQueryParams(BaseModel):
|
||||
deprecated=True,
|
||||
)
|
||||
limit: int | None = 50
|
||||
offset: int | None = Field(0, ge=0)
|
||||
cameras: str | None = "all"
|
||||
labels: str | None = "all"
|
||||
sub_labels: str | None = "all"
|
||||
|
||||
@@ -10,6 +10,8 @@ class AppConfigSetBody(BaseModel):
|
||||
update_topic: str | None = None
|
||||
config_data: dict[str, Any] | None = None
|
||||
skip_save: bool = False
|
||||
# paths rewritten whole, so a map saves in the order sent
|
||||
replace_paths: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class GenAIProbeBody(BaseModel):
|
||||
|
||||
+11
-2
@@ -129,6 +129,7 @@ def events(
|
||||
zones = zone
|
||||
|
||||
limit = params.limit
|
||||
offset = params.offset
|
||||
after = params.after
|
||||
before = params.before
|
||||
time_range = params.time_range
|
||||
@@ -361,11 +362,15 @@ def events(
|
||||
else:
|
||||
order_by = Event.start_time.desc()
|
||||
|
||||
# offset paging needs a stable order when scores or speeds tie
|
||||
tiebreaker = [Event.id] if sort and sort.startswith(("score", "speed")) else []
|
||||
|
||||
events = (
|
||||
Event.select(*selected_columns)
|
||||
.where(reduce(operator.and_, clauses))
|
||||
.order_by(order_by)
|
||||
.order_by(order_by, *tiebreaker)
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
.dicts()
|
||||
.iterator()
|
||||
)
|
||||
@@ -534,6 +539,7 @@ def events_search(
|
||||
search_type = params.search_type
|
||||
include_thumbnails = params.include_thumbnails
|
||||
limit = params.limit
|
||||
offset = params.offset
|
||||
sort = params.sort
|
||||
|
||||
# Filters
|
||||
@@ -840,6 +846,9 @@ def events_search(
|
||||
if search_results:
|
||||
events_query = events_query.where(Event.id << list(search_results.keys()))
|
||||
|
||||
# sorts below are stable, so this orders ties for offset paging
|
||||
events_query = events_query.order_by(Event.id)
|
||||
|
||||
# Fetch events and process them in a single pass
|
||||
processed_events = []
|
||||
for event in events_query.dicts():
|
||||
@@ -897,7 +906,7 @@ def events_search(
|
||||
processed_events.sort(key=lambda x: x["start_time"], reverse=True)
|
||||
|
||||
# Limit the number of events returned
|
||||
processed_events = processed_events[:limit]
|
||||
processed_events = processed_events[offset:][:limit]
|
||||
|
||||
return JSONResponse(content=processed_events)
|
||||
|
||||
|
||||
@@ -63,7 +63,6 @@ from frigate.util.recording_coverage import (
|
||||
null_audio_glitches,
|
||||
plan_clip,
|
||||
resolve_coverage,
|
||||
stream_has_audio,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -681,15 +680,10 @@ async def _vod_response(
|
||||
end_ts,
|
||||
force_discontinuity,
|
||||
)
|
||||
intervals = resolve_coverage(camera_name, start_ts, end_ts)
|
||||
|
||||
# rows contradicting their stream's audio composition are
|
||||
# truncated-shutdown glitches
|
||||
main_audio = stream_has_audio(intervals, main=True)
|
||||
sub_audio = stream_has_audio(intervals, main=False)
|
||||
|
||||
spans = build_spans(
|
||||
null_audio_glitches(intervals, main_audio, sub_audio),
|
||||
null_audio_glitches(resolve_coverage(camera_name, start_ts, end_ts)),
|
||||
stream_preference,
|
||||
)
|
||||
|
||||
|
||||
+20
-11
@@ -44,6 +44,22 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=[Tags.review])
|
||||
|
||||
|
||||
def get_label_clause(label: str, include_audio: bool = True):
|
||||
"""Build a clause matching a label within a review segment's data.
|
||||
|
||||
Verified objects are stored with a `-verified` suffix (eg. `person-verified`)
|
||||
so that variant is matched as well.
|
||||
"""
|
||||
clause = (ReviewSegment.data["objects"].cast("text") % f'*"{label}"*') | (
|
||||
ReviewSegment.data["objects"].cast("text") % f'*"{label}-verified"*'
|
||||
)
|
||||
|
||||
if include_audio:
|
||||
clause |= ReviewSegment.data["audio"].cast("text") % f'*"{label}"*'
|
||||
|
||||
return clause
|
||||
|
||||
|
||||
@router.get(
|
||||
"/review",
|
||||
response_model=list[ReviewSegmentResponse],
|
||||
@@ -93,10 +109,7 @@ async def review(
|
||||
filtered_labels = labels.split(",")
|
||||
|
||||
for label in filtered_labels:
|
||||
label_clauses.append(
|
||||
(ReviewSegment.data["objects"].cast("text") % f'*"{label}"*')
|
||||
| (ReviewSegment.data["audio"].cast("text") % f'*"{label}"*')
|
||||
)
|
||||
label_clauses.append(get_label_clause(label))
|
||||
clauses.append(reduce(operator.or_, label_clauses))
|
||||
|
||||
if zones != "all":
|
||||
@@ -239,10 +252,7 @@ async def review_summary(
|
||||
filtered_labels = labels.split(",")
|
||||
|
||||
for label in filtered_labels:
|
||||
label_clauses.append(
|
||||
(ReviewSegment.data["objects"].cast("text") % f'*"{label}"*')
|
||||
| (ReviewSegment.data["audio"].cast("text") % f'*"{label}"*')
|
||||
)
|
||||
label_clauses.append(get_label_clause(label))
|
||||
clauses.append(reduce(operator.or_, label_clauses))
|
||||
if zones != "all":
|
||||
# use matching so segments with multiple zones
|
||||
@@ -340,9 +350,8 @@ async def review_summary(
|
||||
filtered_labels = labels.split(",")
|
||||
|
||||
for label in filtered_labels:
|
||||
label_clauses.append(
|
||||
ReviewSegment.data["objects"].cast("text") % f'*"{label}"*'
|
||||
)
|
||||
label_clauses.append(get_label_clause(label, include_audio=False))
|
||||
|
||||
clauses.append(reduce(operator.or_, label_clauses))
|
||||
|
||||
# Find the time range of available data
|
||||
|
||||
+2
-3
@@ -49,7 +49,6 @@ from frigate.debug_replay import (
|
||||
DebugReplayManager,
|
||||
cleanup_replay_cameras,
|
||||
)
|
||||
from frigate.detectors.detector_config import SceneEnum
|
||||
from frigate.detectors.detector_types import api_types
|
||||
from frigate.detectors.device import build_detector_config, runner_names
|
||||
from frigate.embeddings import EmbeddingProcess, EmbeddingsContext
|
||||
@@ -108,7 +107,7 @@ class FrigateApp:
|
||||
self.metrics_manager = manager
|
||||
self.audio_process: mp.Process | None = None
|
||||
self.stop_event = stop_event
|
||||
self.detection_queues: dict[SceneEnum, Queue] = {
|
||||
self.detection_queues: dict[str, Queue] = {
|
||||
model.scene: mp.Queue() for model in config.models
|
||||
}
|
||||
self.detectors: dict[str, ObjectDetectProcess] = {}
|
||||
@@ -395,7 +394,7 @@ class FrigateApp:
|
||||
logger.error("Unable to prepare the %s runtime: %s", detector_type, err)
|
||||
|
||||
def start_detectors(self) -> None:
|
||||
model_cameras: dict[SceneEnum, list[str]] = {
|
||||
model_cameras: dict[str, list[str]] = {
|
||||
model.scene: [] for model in self.config.models
|
||||
}
|
||||
|
||||
|
||||
@@ -104,12 +104,13 @@ class CameraActivityManager:
|
||||
all_objects: list[dict[str, Any]] = []
|
||||
|
||||
for camera in new_activity.keys():
|
||||
if camera not in self.config.cameras:
|
||||
camera_config = self.config.cameras.get(camera)
|
||||
if camera_config is None:
|
||||
continue
|
||||
|
||||
# handle cameras that were added dynamically
|
||||
if camera not in self.camera_all_object_counts:
|
||||
self.__init_camera(self.config.cameras[camera])
|
||||
self.__init_camera(camera_config)
|
||||
|
||||
new_objects = new_activity[camera].get("objects", [])
|
||||
all_objects.extend(new_objects)
|
||||
@@ -234,12 +235,13 @@ class AudioActivityManager:
|
||||
now = datetime.datetime.now().timestamp()
|
||||
|
||||
for camera in new_activity.keys():
|
||||
if camera not in self.config.cameras:
|
||||
camera_config = self.config.cameras.get(camera)
|
||||
if camera_config is None:
|
||||
continue
|
||||
|
||||
# handle cameras that were added dynamically
|
||||
if camera not in self.current_audio_detections:
|
||||
self.__init_camera(self.config.cameras[camera])
|
||||
self.__init_camera(camera_config)
|
||||
|
||||
new_detections = new_activity[camera].get("detections", [])
|
||||
if self.compare_audio_activity(camera, new_detections, now):
|
||||
|
||||
@@ -15,7 +15,6 @@ from frigate.config.camera.updater import (
|
||||
CameraConfigUpdateSubscriber,
|
||||
)
|
||||
from frigate.const import REPLAY_CAMERA_PREFIX
|
||||
from frigate.detectors.detector_config import SceneEnum
|
||||
from frigate.models import Regions
|
||||
from frigate.object_detection.util import detection_frame_size
|
||||
from frigate.util.builtin import empty_and_close_queue
|
||||
@@ -31,7 +30,7 @@ class CameraMaintainer(threading.Thread):
|
||||
def __init__(
|
||||
self,
|
||||
config: FrigateConfig,
|
||||
detection_queues: dict[SceneEnum, Queue],
|
||||
detection_queues: dict[str, Queue],
|
||||
detected_frames_queue: Queue,
|
||||
camera_metrics: DictProxy,
|
||||
ptz_metrics: dict[str, PTZMetrics],
|
||||
|
||||
@@ -12,6 +12,7 @@ class DetectionTypeEnum(str, Enum):
|
||||
video = "video"
|
||||
audio = "audio"
|
||||
lpr = "lpr"
|
||||
classification_state = "classification_state"
|
||||
|
||||
|
||||
class DetectionPublisher(Publisher):
|
||||
|
||||
@@ -782,7 +782,9 @@ class Dispatcher:
|
||||
try:
|
||||
payload = int(payload)
|
||||
except ValueError:
|
||||
f"Received unsupported value for motion contour area: {payload}"
|
||||
logger.warning(
|
||||
f"Received unsupported value for motion contour area: {payload}"
|
||||
)
|
||||
return
|
||||
|
||||
motion_settings = self.config.cameras[camera_name].motion
|
||||
@@ -799,7 +801,9 @@ class Dispatcher:
|
||||
try:
|
||||
payload = int(payload)
|
||||
except ValueError:
|
||||
f"Received unsupported value for motion threshold: {payload}"
|
||||
logger.warning(
|
||||
f"Received unsupported value for motion threshold: {payload}"
|
||||
)
|
||||
return
|
||||
|
||||
motion_settings = self.config.cameras[camera_name].motion
|
||||
@@ -814,7 +818,9 @@ class Dispatcher:
|
||||
def _on_global_notification_command(self, payload: str) -> None:
|
||||
"""Callback for global notification topic."""
|
||||
if payload != "ON" and payload != "OFF":
|
||||
f"Received unsupported value for all notification: {payload}"
|
||||
logger.warning(
|
||||
f"Received unsupported value for all notification: {payload}"
|
||||
)
|
||||
return
|
||||
|
||||
notification_settings = self.config.notifications
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from pydantic import Field, model_validator
|
||||
|
||||
from frigate.detectors.detector_config import SceneEnum
|
||||
from frigate.detectors.detector_config import DEFAULT_SCENE, SCENE_PATTERN
|
||||
|
||||
from ..base import FrigateBaseModel
|
||||
|
||||
@@ -62,10 +62,11 @@ class DetectConfig(FrigateBaseModel):
|
||||
title="Detect width",
|
||||
description="Width (pixels) of frames used for the detect stream; leave empty to use the native stream resolution.",
|
||||
)
|
||||
scene: SceneEnum = Field(
|
||||
default=SceneEnum.all,
|
||||
scene: str = Field(
|
||||
default=DEFAULT_SCENE,
|
||||
pattern=SCENE_PATTERN,
|
||||
title="Detect scene",
|
||||
description="The environment this camera looks at, used to pick which of the configured models runs on it. Cameras left on 'all' run the model configured with a scene of 'all'.",
|
||||
description="The environment this camera looks at, used to pick which of the configured models runs on it. Cameras left on 'default' run the model configured with a scene of 'default'.",
|
||||
)
|
||||
fps: int = Field(
|
||||
default=5,
|
||||
|
||||
@@ -1,8 +1,57 @@
|
||||
from pydantic import Field
|
||||
from pydantic import Field, field_validator
|
||||
|
||||
from frigate.util.live_streams import DEFAULT_TRANSCODE_QUALITIES
|
||||
|
||||
from ..base import FrigateBaseModel
|
||||
|
||||
__all__ = ["CameraLiveConfig"]
|
||||
__all__ = ["CameraLiveConfig", "LiveTranscodeConfig", "LiveTranscodeQualityConfig"]
|
||||
|
||||
|
||||
class LiveTranscodeQualityConfig(FrigateBaseModel):
|
||||
height: int = Field(
|
||||
ge=144,
|
||||
le=2160,
|
||||
title="Height",
|
||||
description="Output height in pixels; width follows the source aspect ratio.",
|
||||
)
|
||||
bitrate: int = Field(
|
||||
ge=64,
|
||||
title="Bitrate",
|
||||
description="Target and maximum video bitrate in kbps.",
|
||||
)
|
||||
|
||||
|
||||
class LiveTranscodeConfig(FrigateBaseModel):
|
||||
enabled: bool = Field(
|
||||
default=False,
|
||||
title="Enable transcoded streams",
|
||||
description="Add lower-quality live streams that go2rtc transcodes in real time while someone is watching.",
|
||||
)
|
||||
source: str | None = Field(
|
||||
default=None,
|
||||
title="Source stream",
|
||||
description="go2rtc stream to transcode. Defaults to the first live stream.",
|
||||
)
|
||||
qualities: list[LiveTranscodeQualityConfig] = Field(
|
||||
default_factory=lambda: [
|
||||
LiveTranscodeQualityConfig(**quality)
|
||||
for quality in DEFAULT_TRANSCODE_QUALITIES
|
||||
],
|
||||
title="Qualities",
|
||||
description="One transcoded stream is added per quality.",
|
||||
)
|
||||
|
||||
@field_validator("qualities")
|
||||
@classmethod
|
||||
def validate_unique_heights(
|
||||
cls, qualities: list[LiveTranscodeQualityConfig]
|
||||
) -> list[LiveTranscodeQualityConfig]:
|
||||
heights = [quality.height for quality in qualities]
|
||||
|
||||
if len(heights) != len(set(heights)):
|
||||
raise ValueError("Transcoded stream heights must be unique.")
|
||||
|
||||
return qualities
|
||||
|
||||
|
||||
class CameraLiveConfig(FrigateBaseModel):
|
||||
@@ -11,6 +60,11 @@ class CameraLiveConfig(FrigateBaseModel):
|
||||
title="Live stream names",
|
||||
description="Mapping of configured stream names to restream/go2rtc names used for live playback.",
|
||||
)
|
||||
transcode: LiveTranscodeConfig = Field(
|
||||
default_factory=LiveTranscodeConfig,
|
||||
title="Transcoded streams",
|
||||
description="Lower-quality live streams transcoded on demand by go2rtc.",
|
||||
)
|
||||
height: int = Field(
|
||||
default=720,
|
||||
title="Live height",
|
||||
|
||||
+180
-22
@@ -19,7 +19,7 @@ from ruamel.yaml import YAML
|
||||
|
||||
from frigate.const import REGEX_JSON
|
||||
from frigate.detectors import ModelConfig
|
||||
from frigate.detectors.detector_config import SceneEnum
|
||||
from frigate.detectors.detector_config import DEFAULT_SCENE
|
||||
from frigate.detectors.device import DeviceParseError, DeviceSpec, parse_device
|
||||
from frigate.plus import PlusApi
|
||||
from frigate.util.builtin import (
|
||||
@@ -35,6 +35,13 @@ from frigate.util.config import (
|
||||
migrate_frigate_config,
|
||||
)
|
||||
from frigate.util.image import create_mask
|
||||
from frigate.util.live_streams import (
|
||||
default_transcode_source,
|
||||
is_transcode_stream_name,
|
||||
transcode_stream_name,
|
||||
transcode_streams,
|
||||
)
|
||||
from frigate.util.runtime_deps import sha256_of
|
||||
from frigate.util.services import auto_detect_hwaccel
|
||||
|
||||
from .auth import AuthConfig
|
||||
@@ -282,11 +289,78 @@ def verify_config_roles(camera_config: CameraConfig) -> None:
|
||||
)
|
||||
|
||||
|
||||
def apply_live_transcode_streams(
|
||||
frigate_config: FrigateConfig, camera_config: CameraConfig
|
||||
) -> None:
|
||||
"""Fold a camera's transcoded streams into its live stream list.
|
||||
|
||||
Enabled qualities missing from live.streams are appended, and entries the
|
||||
user placed keep their position. Transcoded names that are no longer
|
||||
generated are dropped unless they name a real go2rtc stream.
|
||||
"""
|
||||
live = camera_config.live
|
||||
transcode = live.transcode
|
||||
go2rtc_streams = frigate_config.go2rtc.model_dump().get("streams") or {}
|
||||
generated: dict[str, str] = {}
|
||||
|
||||
if transcode.enabled:
|
||||
if transcode.source is None:
|
||||
transcode.source = default_transcode_source(
|
||||
camera_config.name, live.streams
|
||||
)
|
||||
|
||||
if transcode.source not in go2rtc_streams:
|
||||
raise ValueError(
|
||||
f"Camera {camera_config.name} has transcoded streams enabled, but its source {transcode.source} is not a go2rtc stream."
|
||||
)
|
||||
|
||||
generated = transcode_streams(
|
||||
camera_config.name,
|
||||
transcode.source,
|
||||
[quality.model_dump() for quality in transcode.qualities],
|
||||
)
|
||||
|
||||
for name in generated:
|
||||
if name in go2rtc_streams:
|
||||
raise ValueError(
|
||||
f"Camera {camera_config.name} generates transcoded stream {name}, which collides with a go2rtc stream of the same name."
|
||||
)
|
||||
|
||||
streams = {
|
||||
label: name
|
||||
for label, name in live.streams.items()
|
||||
if name in generated
|
||||
or name in go2rtc_streams
|
||||
or not is_transcode_stream_name(camera_config.name, name)
|
||||
}
|
||||
placed = set(streams.values())
|
||||
|
||||
for quality in transcode.qualities if transcode.enabled else []:
|
||||
name = transcode_stream_name(camera_config.name, quality.height)
|
||||
|
||||
if name in placed:
|
||||
continue
|
||||
|
||||
label = f"{quality.height}p"
|
||||
|
||||
if label in streams:
|
||||
raise ValueError(
|
||||
f"Camera {camera_config.name} already has a live stream named {label}; rename it or place the transcoded stream under another name."
|
||||
)
|
||||
|
||||
streams[label] = name
|
||||
|
||||
live.streams = streams
|
||||
|
||||
|
||||
def verify_valid_live_stream_names(
|
||||
frigate_config: FrigateConfig, camera_config: CameraConfig
|
||||
) -> ValueError | None:
|
||||
"""Verify that a restream exists to use for live view."""
|
||||
for _, stream_name in camera_config.live.streams.items():
|
||||
if is_transcode_stream_name(camera_config.name, stream_name):
|
||||
continue
|
||||
|
||||
if (
|
||||
stream_name
|
||||
not in frigate_config.go2rtc.model_dump().get("streams", {}).keys()
|
||||
@@ -536,7 +610,7 @@ class FrigateConfig(FrigateBaseModel):
|
||||
models: list[ModelConfig] = Field(
|
||||
default_factory=_default_models,
|
||||
title="Detection models",
|
||||
description="Object detection models and the hardware each one runs on. Cameras pick a model by matching their detect.scene against a model's scene.",
|
||||
description="Object detection models and the hardware each one runs on. Cameras pick a model by matching their detect.scene against a model's scene, falling back to the 'default' model.",
|
||||
)
|
||||
|
||||
# GenAI config (named provider configs: name -> GenAIConfig)
|
||||
@@ -651,7 +725,9 @@ class FrigateConfig(FrigateBaseModel):
|
||||
)
|
||||
|
||||
_plus_api: PlusApi
|
||||
_model_devices: dict[SceneEnum, list[DeviceSpec]]
|
||||
_model_devices: dict[str, list[DeviceSpec]]
|
||||
# scene -> model, including the scenes of duplicate models folded into another
|
||||
_scene_models: dict[str, ModelConfig]
|
||||
_camera_models: dict[str, ModelConfig]
|
||||
_all_attributes: list[str]
|
||||
_all_attribute_logos: list[str]
|
||||
@@ -686,7 +762,7 @@ class FrigateConfig(FrigateBaseModel):
|
||||
def primary_model(self) -> ModelConfig:
|
||||
"""The model used when no specific camera is in play."""
|
||||
for model in self.models:
|
||||
if model.scene == SceneEnum.all:
|
||||
if model.scene == DEFAULT_SCENE:
|
||||
return model
|
||||
|
||||
return self.models[0]
|
||||
@@ -708,7 +784,7 @@ class FrigateConfig(FrigateBaseModel):
|
||||
|
||||
if model is None:
|
||||
camera = self.cameras.get(camera_name)
|
||||
scene = camera.detect.scene if camera is not None else SceneEnum.all
|
||||
scene = camera.detect.scene if camera is not None else DEFAULT_SCENE
|
||||
model = self._resolve_camera_model(camera_name, scene)
|
||||
self._camera_models[camera_name] = model
|
||||
|
||||
@@ -768,14 +844,14 @@ class FrigateConfig(FrigateBaseModel):
|
||||
if not self.models:
|
||||
raise ValueError("At least one model must be configured under models")
|
||||
|
||||
model_devices: dict[SceneEnum, list[DeviceSpec]] = {}
|
||||
model_devices: dict[str, list[DeviceSpec]] = {}
|
||||
# device string -> the scene of the model that already claimed it
|
||||
claimed_devices: dict[str, SceneEnum] = {}
|
||||
claimed_devices: dict[str, str] = {}
|
||||
|
||||
for index, model in enumerate(self.models):
|
||||
scene = model.scene.value
|
||||
scene = model.scene
|
||||
|
||||
if model.scene in model_devices:
|
||||
if scene in model_devices:
|
||||
raise ValueError(
|
||||
f"Multiple models are configured with a scene of '{scene}'. Each model must use a different scene."
|
||||
)
|
||||
@@ -804,17 +880,20 @@ class FrigateConfig(FrigateBaseModel):
|
||||
other = claimed_devices[device.raw]
|
||||
where = (
|
||||
f"twice by model '{scene}'"
|
||||
if other == model.scene
|
||||
else f"by both the '{other.value}' and '{scene}' models"
|
||||
if other == scene
|
||||
else f"by both the '{other}' and '{scene}' models"
|
||||
)
|
||||
raise ValueError(
|
||||
f"Device '{device.raw}' is used {where}, but it can only run one detection process."
|
||||
)
|
||||
|
||||
claimed_devices[device.raw] = model.scene
|
||||
claimed_devices[device.raw] = scene
|
||||
|
||||
self.models[index] = self._load_model(model, devices[0].detector)
|
||||
model_devices[model.scene] = devices
|
||||
model_devices[scene] = devices
|
||||
|
||||
self._scene_models = {model.scene: model for model in self.models}
|
||||
self._consolidate_duplicate_models(model_devices)
|
||||
|
||||
attributes: set[str] = set()
|
||||
attribute_logos: set[str] = set()
|
||||
@@ -838,36 +917,107 @@ class FrigateConfig(FrigateBaseModel):
|
||||
}
|
||||
self._all_labels = labels
|
||||
|
||||
def _resolve_camera_model(self, name: str, scene: SceneEnum) -> ModelConfig:
|
||||
def _consolidate_duplicate_models(
|
||||
self, model_devices: dict[str, list[DeviceSpec]]
|
||||
) -> None:
|
||||
"""Fold models that load the same model file into a single model.
|
||||
|
||||
Separate scenes for one model only split the same work across separate
|
||||
detection queues, so each device serves fewer cameras and is slower
|
||||
overall than one shared model. The duplicate's devices are moved to the
|
||||
model it duplicates and its scene resolves to that model.
|
||||
|
||||
Args:
|
||||
model_devices: Scene to parsed devices, updated in place
|
||||
"""
|
||||
kept: list[ModelConfig] = []
|
||||
hashes: dict[str, str | None] = {}
|
||||
|
||||
def file_hash(path: str) -> str | None:
|
||||
if path not in hashes:
|
||||
hashes[path] = sha256_of(path) if os.path.isfile(path) else None
|
||||
|
||||
return hashes[path]
|
||||
|
||||
def same_model(a: ModelConfig, b: ModelConfig) -> bool:
|
||||
# a model's devices all share a detector, so folding across
|
||||
# detectors would produce an invalid model
|
||||
if model_devices[a.scene][0].detector != model_devices[b.scene][0].detector:
|
||||
return False
|
||||
|
||||
if not a.path or not b.path:
|
||||
return False
|
||||
|
||||
if os.path.realpath(a.path) == os.path.realpath(b.path):
|
||||
return True
|
||||
|
||||
a_hash = file_hash(a.path)
|
||||
return a_hash is not None and a_hash == file_hash(b.path)
|
||||
|
||||
for model in self.models:
|
||||
original = next((other for other in kept if same_model(other, model)), None)
|
||||
|
||||
if original is None:
|
||||
kept.append(model)
|
||||
continue
|
||||
|
||||
# keep the default model so cameras without a scene still find it
|
||||
if model.scene == DEFAULT_SCENE:
|
||||
kept[kept.index(original)] = model
|
||||
original, model = model, original
|
||||
|
||||
logger.warning(
|
||||
"Models '%s' and '%s' use the same model file, so they have been combined into the '%s' model. Defining one model under several scenes to assign detectors to specific cameras is slower and less efficient than letting every detector serve every camera. Remove the '%s' model and list its devices under the '%s' model instead",
|
||||
original.scene,
|
||||
model.scene,
|
||||
original.scene,
|
||||
model.scene,
|
||||
original.scene,
|
||||
)
|
||||
original.devices = [*original.devices, *model.devices]
|
||||
model_devices[original.scene] = [
|
||||
*model_devices[original.scene],
|
||||
*model_devices.pop(model.scene),
|
||||
]
|
||||
self._scene_models[model.scene] = original
|
||||
|
||||
# anything already folded into the duplicate follows it
|
||||
for scene, target in self._scene_models.items():
|
||||
if target is model:
|
||||
self._scene_models[scene] = original
|
||||
|
||||
self.models = kept
|
||||
|
||||
def _resolve_camera_model(self, name: str, scene: str) -> ModelConfig:
|
||||
"""Resolve which model a camera runs on.
|
||||
|
||||
A camera may name a scene no model is configured for, which is valid as
|
||||
long as an 'all' model is there to fall back to.
|
||||
long as a 'default' model is there to fall back to.
|
||||
|
||||
Args:
|
||||
name: Name of the camera
|
||||
scene: The camera's detect scene, which defaults to 'all'
|
||||
scene: The camera's detect scene, which defaults to 'default'
|
||||
|
||||
Returns:
|
||||
The model the camera runs on
|
||||
"""
|
||||
by_scene = {model.scene: model for model in self.models}
|
||||
model = by_scene.get(scene)
|
||||
model = self._scene_models.get(scene)
|
||||
|
||||
if model is not None:
|
||||
return model
|
||||
|
||||
default = by_scene.get(SceneEnum.all)
|
||||
default = self._scene_models.get(DEFAULT_SCENE)
|
||||
|
||||
if default is None:
|
||||
raise ValueError(
|
||||
f"Camera '{name}' has a detect scene of '{scene.value}', but no model is configured for that scene or for 'all'."
|
||||
f"Camera '{name}' has a detect scene of '{scene}', but no model is configured for that scene or for '{DEFAULT_SCENE}'."
|
||||
)
|
||||
|
||||
logger.warning(
|
||||
"Camera '%s' has a detect scene of '%s', but no model is configured for that scene, so the 'all' model is used",
|
||||
"Camera '%s' has a detect scene of '%s', but no model is configured for that scene, so the '%s' model is used",
|
||||
name,
|
||||
scene.value,
|
||||
scene,
|
||||
DEFAULT_SCENE,
|
||||
)
|
||||
return default
|
||||
|
||||
@@ -978,6 +1128,8 @@ class FrigateConfig(FrigateBaseModel):
|
||||
"face_recognition": ["enabled", "min_area"],
|
||||
"lpr": ["enabled", "expire_time", "min_area", "enhancement"],
|
||||
"audio_transcription": ["enabled", "live_enabled"],
|
||||
# transcode is camera-level only
|
||||
"live": ["streams", "height", "quality"],
|
||||
}
|
||||
|
||||
for section in allowed_fields_map:
|
||||
@@ -998,6 +1150,10 @@ class FrigateConfig(FrigateBaseModel):
|
||||
camera_model = self._resolve_camera_model(name, camera_config.detect.scene)
|
||||
self._camera_models[name] = camera_model
|
||||
|
||||
# point cameras at the model their duplicate scene was folded into
|
||||
if camera_config.detect.scene in self._scene_models:
|
||||
camera_config.detect.scene = camera_model.scene
|
||||
|
||||
if camera_config.ffmpeg.hwaccel_args == "auto":
|
||||
camera_config.ffmpeg.hwaccel_args = self.ffmpeg.hwaccel_args
|
||||
|
||||
@@ -1206,6 +1362,8 @@ class FrigateConfig(FrigateBaseModel):
|
||||
if not camera_config.live.streams:
|
||||
camera_config.live.streams = {name: name}
|
||||
|
||||
apply_live_transcode_streams(self, camera_config)
|
||||
|
||||
# generate the ffmpeg commands
|
||||
camera_config.create_ffmpeg_cmds()
|
||||
self.cameras[name] = camera_config
|
||||
|
||||
@@ -176,6 +176,7 @@ class LicensePlateProcessingMixin:
|
||||
"""
|
||||
input_shape = [3, 48, 320]
|
||||
num_images = len(images)
|
||||
outputs: list[np.ndarray] = []
|
||||
|
||||
for index in range(0, num_images, self.batch_size):
|
||||
input_h, input_w = input_shape[1], input_shape[2]
|
||||
@@ -195,11 +196,11 @@ class LicensePlateProcessingMixin:
|
||||
norm_image = norm_image[np.newaxis, :]
|
||||
norm_images.append(norm_image)
|
||||
|
||||
try:
|
||||
outputs = self.model_runner.recognition_model(norm_images) # type: ignore[arg-type]
|
||||
except Exception as e:
|
||||
logger.warning(f"Error running LPR recognition model: {e}")
|
||||
return [], []
|
||||
try:
|
||||
outputs.extend(self.model_runner.recognition_model(norm_images)) # type: ignore[arg-type]
|
||||
except Exception as e:
|
||||
logger.warning(f"Error running LPR recognition model: {e}")
|
||||
return [], []
|
||||
|
||||
return self.ctc_decoder(outputs)
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"""Frame annotations derived from object tracking data.
|
||||
|
||||
Builds short notes describing what changed during a review item, keyed to the
|
||||
frames sampled from it. Everything here comes from tracked object data already
|
||||
in the database (each event's `path_data` trajectory and the timeline's
|
||||
stationary/active changes), so the notes can be stated to the model as fact
|
||||
rather than as something it must perceive.
|
||||
frames sampled from it. Everything here comes from data already recorded
|
||||
(each event's `path_data` trajectory, the timeline's stationary/active
|
||||
changes, and the review item's state classification changes), so the notes
|
||||
can be stated to the model as fact rather than as something it must perceive.
|
||||
"""
|
||||
|
||||
import logging
|
||||
@@ -95,6 +95,15 @@ def event_name(event: dict[str, Any]) -> str:
|
||||
return f"{article} {label}"
|
||||
|
||||
|
||||
def describe_classification_change(change: dict[str, Any]) -> str:
|
||||
"""Phrase a state classification change, e.g. 'front gate changed from
|
||||
closed to open'."""
|
||||
model = str(change["model"]).replace("_", " ")
|
||||
before = str(change["from"]).replace("_", " ")
|
||||
after = str(change["to"]).replace("_", " ")
|
||||
return f"{model} changed from {before} to {after}"
|
||||
|
||||
|
||||
def path_legs(points: list[Point]) -> list[Leg]:
|
||||
"""Split a trajectory into runs of travel in a consistent direction.
|
||||
|
||||
@@ -359,25 +368,38 @@ def get_state_changes(detection_ids: list[str]) -> list[dict[str, Any]]:
|
||||
def build_frame_captions(
|
||||
detection_ids: list[str],
|
||||
frame_times: list[float],
|
||||
classification_changes: Sequence[dict[str, Any]] = (),
|
||||
) -> list[str]:
|
||||
"""A caption for each sampled frame, in frame order.
|
||||
|
||||
Every frame gets its index and elapsed time so the model can tell them
|
||||
apart; frames where something changed also carry the tracker notes for
|
||||
that moment. Returns an empty list when there is nothing to say, which
|
||||
callers treat as a reason to fall back to sending plain frames.
|
||||
apart; frames where something changed also carry the tracker and state
|
||||
classification notes for that moment. Returns an empty list when there is
|
||||
nothing to say, which callers treat as a reason to fall back to sending
|
||||
plain frames.
|
||||
"""
|
||||
if not frame_times:
|
||||
return []
|
||||
|
||||
span_end = frame_times[-1]
|
||||
timeline: list[tuple[float, str]] = []
|
||||
events = get_tracked_events(detection_ids)
|
||||
|
||||
if not events:
|
||||
logger.debug("No tracked events found for review item, skipping annotations")
|
||||
return []
|
||||
# audio and manual review items can have state changes but no tracked objects
|
||||
if events:
|
||||
timeline.extend(
|
||||
(timestamp, f"[tracker] {note}")
|
||||
for timestamp, note in build_timeline(
|
||||
events, span_end, get_state_changes(detection_ids)
|
||||
)
|
||||
)
|
||||
|
||||
timeline = build_timeline(events, frame_times[-1], get_state_changes(detection_ids))
|
||||
buckets = annotations_by_frame(timeline, frame_times)
|
||||
timeline.extend(
|
||||
(change["timestamp"], f"[state] {describe_classification_change(change)}")
|
||||
for change in classification_changes
|
||||
if change["timestamp"] <= span_end
|
||||
)
|
||||
buckets = annotations_by_frame(sorted(timeline, key=lambda m: m[0]), frame_times)
|
||||
|
||||
if not buckets:
|
||||
return []
|
||||
@@ -388,7 +410,7 @@ def build_frame_captions(
|
||||
|
||||
for index, timestamp in enumerate(frame_times):
|
||||
lines = [f"Frame {index + 1} of {total} (+{timestamp - origin:.1f}s):"]
|
||||
lines.extend(f"[tracker] {note}" for note in buckets.get(index, []))
|
||||
lines.extend(buckets.get(index, []))
|
||||
captions.append("\n".join(lines))
|
||||
|
||||
return captions
|
||||
|
||||
@@ -40,7 +40,7 @@ from frigate.util.image import get_image_from_recording
|
||||
|
||||
from ..post.api import PostProcessorApi
|
||||
from ..types import DataProcessorMetrics
|
||||
from .review_annotations import build_frame_captions
|
||||
from .review_annotations import build_frame_captions, describe_classification_change
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -254,6 +254,10 @@ class ReviewDescriptionProcessor(PostProcessorApi):
|
||||
"start_time": r["start_time"],
|
||||
"end_time": r["end_time"],
|
||||
"metadata": r["data"]["metadata"],
|
||||
"state_changes": [
|
||||
describe_classification_change(change)
|
||||
for change in sorted_classification_state_changes(r["data"])
|
||||
],
|
||||
}
|
||||
for r in (
|
||||
ReviewSegment.select(
|
||||
@@ -298,6 +302,9 @@ class ReviewDescriptionProcessor(PostProcessorApi):
|
||||
primary_item["start_time"] = primary_seg["start_time"]
|
||||
primary_item["end_time"] = primary_seg["end_time"]
|
||||
|
||||
if primary_seg["state_changes"]:
|
||||
primary_item["state_changes"] = primary_seg["state_changes"]
|
||||
|
||||
# Find overlapping contextual items from other cameras
|
||||
primary_start = primary_seg["start_time"]
|
||||
primary_end = primary_seg["end_time"]
|
||||
@@ -318,14 +325,25 @@ class ReviewDescriptionProcessor(PostProcessorApi):
|
||||
seg_end = seg["end_time"]
|
||||
|
||||
if seg_start < primary_end and primary_start < seg_end:
|
||||
# Avoid duplicates if same camera has multiple overlapping segments
|
||||
if seg_camera not in seen_contextual_cameras:
|
||||
contextual_item = copy.deepcopy(seg["metadata"])
|
||||
contextual_item["camera"] = seg_camera
|
||||
contextual_item["start_time"] = seg_start
|
||||
contextual_item["end_time"] = seg_end
|
||||
contextual_items.append(contextual_item)
|
||||
seen_contextual_cameras.add(seg_camera)
|
||||
# Avoid duplicates if same camera has multiple overlapping
|
||||
# segments. One with state changes is kept as its own item
|
||||
# so each change stays within its item's time range.
|
||||
if (
|
||||
seg_camera in seen_contextual_cameras
|
||||
and not seg["state_changes"]
|
||||
):
|
||||
continue
|
||||
|
||||
contextual_item = copy.deepcopy(seg["metadata"])
|
||||
contextual_item["camera"] = seg_camera
|
||||
contextual_item["start_time"] = seg_start
|
||||
contextual_item["end_time"] = seg_end
|
||||
|
||||
if seg["state_changes"]:
|
||||
contextual_item["state_changes"] = seg["state_changes"]
|
||||
|
||||
contextual_items.append(contextual_item)
|
||||
seen_contextual_cameras.add(seg_camera)
|
||||
|
||||
# Add context array to primary item
|
||||
primary_item["context"] = contextual_items
|
||||
@@ -439,6 +457,7 @@ class ReviewDescriptionProcessor(PostProcessorApi):
|
||||
captions = build_frame_captions(
|
||||
final_data["data"].get("detections") or [],
|
||||
[timestamp for _, timestamp in frames],
|
||||
sorted_classification_state_changes(final_data["data"]),
|
||||
)
|
||||
|
||||
if not captions:
|
||||
@@ -688,6 +707,39 @@ def get_recording_buffer_extension(duration: float) -> float:
|
||||
return buffer_extension
|
||||
|
||||
|
||||
def sorted_classification_state_changes(
|
||||
review_data: dict[str, Any],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""A review item's state classification changes in time order."""
|
||||
return sorted(
|
||||
review_data.get("classification_state_changes") or [],
|
||||
key=lambda change: change["timestamp"],
|
||||
)
|
||||
|
||||
|
||||
def format_classification_state_changes(
|
||||
changes: list[dict[str, Any]], start_time: float, end_time: float
|
||||
) -> list[str]:
|
||||
"""Phrase state classification changes with their timing in the activity.
|
||||
|
||||
Changes are attached while the review item is active, which runs past its
|
||||
end_time by the review cutoff, and a few seconds before its start.
|
||||
"""
|
||||
lines = []
|
||||
|
||||
for change in changes:
|
||||
if change["timestamp"] < start_time:
|
||||
when = "just before the activity started"
|
||||
elif change["timestamp"] > end_time:
|
||||
when = "after the activity ended"
|
||||
else:
|
||||
when = f"{round(change['timestamp'] - start_time)}s into the activity"
|
||||
|
||||
lines.append(f"{describe_classification_change(change)}, {when}")
|
||||
|
||||
return lines
|
||||
|
||||
|
||||
def run_analysis(
|
||||
requestor: InterProcessRequestor,
|
||||
genai_client: GenAIClient,
|
||||
@@ -743,6 +795,13 @@ def run_analysis(
|
||||
unified_objects.append(object_type)
|
||||
|
||||
analytics_data["unified_objects"] = unified_objects
|
||||
analytics_data["classification_state_changes"] = (
|
||||
format_classification_state_changes(
|
||||
sorted_classification_state_changes(final_data["data"]),
|
||||
final_data["start_time"],
|
||||
final_data["end_time"],
|
||||
)
|
||||
)
|
||||
|
||||
metadata = genai_client.generate_review_description(
|
||||
analytics_data,
|
||||
|
||||
@@ -91,8 +91,23 @@ class CustomStateClassificationProcessor(DeferredRealtimeProcessorApi):
|
||||
self.tensor_input_details = self.interpreter.get_input_details()
|
||||
self.tensor_output_details = self.interpreter.get_output_details()
|
||||
self.labelmap = load_labels(labelmap_path, prefill=0, indexed=False)
|
||||
self._forget_unknown_states()
|
||||
self.classifications_per_second.start()
|
||||
|
||||
def _forget_unknown_states(self) -> None:
|
||||
"""Drop verified states that are not labels of the loaded model.
|
||||
|
||||
A retrained model can rename or remove labels. Keeping a state it can
|
||||
no longer produce would report its first verified state as a change
|
||||
from that obsolete label.
|
||||
"""
|
||||
labels = set(self.labelmap.values())
|
||||
self.state_history = {
|
||||
camera: history
|
||||
for camera, history in self.state_history.items()
|
||||
if history["current_state"] in labels
|
||||
}
|
||||
|
||||
def __update_metrics(self, duration: float) -> None:
|
||||
self.classifications_per_second.update()
|
||||
if self.inference_speed:
|
||||
@@ -134,15 +149,20 @@ class CustomStateClassificationProcessor(DeferredRealtimeProcessorApi):
|
||||
# Don't save if state is stable (detected_state == current_state) AND score is 100%
|
||||
return False
|
||||
|
||||
def verify_state_change(self, camera: str, detected_state: str) -> str | None:
|
||||
def verify_state_change(
|
||||
self, camera: str, detected_state: str, timestamp: float
|
||||
) -> tuple[str | None, float] | None:
|
||||
"""
|
||||
Verify state change requires 3 consecutive identical states before publishing.
|
||||
Returns state to publish or None if verification not complete.
|
||||
Returns (previous state, time the new state was first seen) once verified,
|
||||
or None if verification not complete. The previous state is None for the
|
||||
first state verified on a camera.
|
||||
"""
|
||||
if camera not in self.state_history:
|
||||
self.state_history[camera] = {
|
||||
"current_state": None,
|
||||
"pending_state": None,
|
||||
"pending_since": 0.0,
|
||||
"consecutive_count": 0,
|
||||
}
|
||||
|
||||
@@ -157,12 +177,14 @@ class CustomStateClassificationProcessor(DeferredRealtimeProcessorApi):
|
||||
verification["consecutive_count"] += 1
|
||||
|
||||
if verification["consecutive_count"] >= 3:
|
||||
previous_state = verification["current_state"]
|
||||
verification["current_state"] = detected_state
|
||||
verification["pending_state"] = None
|
||||
verification["consecutive_count"] = 0
|
||||
return detected_state
|
||||
return previous_state, verification["pending_since"]
|
||||
else:
|
||||
verification["pending_state"] = detected_state
|
||||
verification["pending_since"] = timestamp
|
||||
verification["consecutive_count"] = 1
|
||||
logger.debug(
|
||||
f"New state '{detected_state}' detected for {camera}, need {3 - verification['consecutive_count']} more consecutive detections"
|
||||
@@ -340,16 +362,19 @@ class CustomStateClassificationProcessor(DeferredRealtimeProcessorApi):
|
||||
)
|
||||
return
|
||||
|
||||
verified_state = self.verify_state_change(camera, detected_state)
|
||||
verified = self.verify_state_change(camera, detected_state, timestamp)
|
||||
|
||||
if verified_state is not None:
|
||||
if verified is not None:
|
||||
previous_state, changed_at = verified
|
||||
self._emit_result(
|
||||
{
|
||||
"type": "classification",
|
||||
"processor": "state",
|
||||
"model_name": self.model_config.name,
|
||||
"camera": camera,
|
||||
"state": verified_state,
|
||||
"state": detected_state,
|
||||
"previous_state": previous_state,
|
||||
"timestamp": changed_at,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
+76
-15
@@ -1,8 +1,10 @@
|
||||
import logging
|
||||
import sqlite3
|
||||
import threading
|
||||
from typing import Any
|
||||
|
||||
import regex
|
||||
from peewee import DatabaseError
|
||||
from playhouse.sqliteq import SqliteQueueDatabase
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -17,6 +19,7 @@ class SqliteVecQueueDatabase(SqliteQueueDatabase):
|
||||
self.load_vec_extension: bool = load_vec_extension
|
||||
# no extension necessary, sqlite will load correctly for each platform
|
||||
self.sqlite_vec_path = "/usr/local/lib/vec0"
|
||||
self.upsert_lock = threading.Lock()
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def _connect(self, *args: Any, **kwargs: Any) -> sqlite3.Connection:
|
||||
@@ -53,6 +56,22 @@ class SqliteVecQueueDatabase(SqliteQueueDatabase):
|
||||
|
||||
conn.create_function("REGEXP", 2, regexp)
|
||||
|
||||
def execute_write(self, sql: str, params: Any = None) -> None:
|
||||
"""Run a write and wait for it, so that failures are raised here.
|
||||
|
||||
SqliteQueueDatabase hands non-SELECT statements to a writer thread and
|
||||
stores any exception on the cursor it returns, so callers that ignore
|
||||
that cursor never learn the write failed.
|
||||
"""
|
||||
self.execute_sql(sql, params).fetchall()
|
||||
|
||||
def _table_exists(self, table: str) -> bool:
|
||||
cursor = self.execute_sql(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?",
|
||||
(table,),
|
||||
)
|
||||
return cursor.fetchone() is not None
|
||||
|
||||
def _delete_embeddings(self, table: str, event_ids: list[str]) -> None:
|
||||
"""Delete embeddings for the given events, if the table exists.
|
||||
|
||||
@@ -63,17 +82,17 @@ class SqliteVecQueueDatabase(SqliteQueueDatabase):
|
||||
return
|
||||
|
||||
# the embeddings tables are only created once semantic search has run
|
||||
cursor = self.execute_sql(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?",
|
||||
(table,),
|
||||
)
|
||||
|
||||
if cursor.fetchone() is None:
|
||||
if not self._table_exists(table):
|
||||
logger.debug("Skipping %s cleanup, table does not exist", table)
|
||||
return
|
||||
|
||||
ids = ",".join(["?" for _ in event_ids])
|
||||
self.execute_sql(f"DELETE FROM {table} WHERE id IN ({ids})", event_ids)
|
||||
|
||||
# callers treat cleanup as best effort, so log rather than propagate
|
||||
try:
|
||||
self.execute_write(f"DELETE FROM {table} WHERE id IN ({ids})", event_ids)
|
||||
except DatabaseError:
|
||||
logger.exception("Failed to delete embeddings from %s", table)
|
||||
|
||||
def delete_embeddings_thumbnail(self, event_ids: list[str]) -> None:
|
||||
self._delete_embeddings("vec_thumbnails", event_ids)
|
||||
@@ -81,25 +100,67 @@ class SqliteVecQueueDatabase(SqliteQueueDatabase):
|
||||
def delete_embeddings_description(self, event_ids: list[str]) -> None:
|
||||
self._delete_embeddings("vec_descriptions", event_ids)
|
||||
|
||||
def _restore_vec_info_table(self, table: str) -> None:
|
||||
"""Recreate the _info shadow table a legacy vec0 table is missing.
|
||||
|
||||
sqlite-vec added _info in 0.1.6 and drops it unconditionally when a
|
||||
table is destroyed, so tables written by Frigate 0.17 and earlier fail
|
||||
to drop. An empty stub is enough, and leaving it unseeded keeps the
|
||||
table reading as pre-0.1.10 if the drop does not follow.
|
||||
"""
|
||||
if not self._table_exists(table) or self._table_exists(f"{table}_info"):
|
||||
return
|
||||
|
||||
logger.debug("Restoring the %s_info shadow table before dropping", table)
|
||||
self.execute_write(
|
||||
f'CREATE TABLE "{table}_info" (key TEXT PRIMARY KEY, value ANY)'
|
||||
)
|
||||
|
||||
def drop_embeddings_tables(self) -> None:
|
||||
self.execute_sql("""
|
||||
DROP TABLE vec_descriptions;
|
||||
""")
|
||||
self.execute_sql("""
|
||||
DROP TABLE vec_thumbnails;
|
||||
""")
|
||||
for table in ("vec_descriptions", "vec_thumbnails"):
|
||||
self._restore_vec_info_table(table)
|
||||
self.execute_write(f"DROP TABLE IF EXISTS {table}")
|
||||
|
||||
def create_embeddings_tables(self) -> None:
|
||||
"""Create vec0 virtual table for embeddings"""
|
||||
self.execute_sql("""
|
||||
self.execute_write("""
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS vec_thumbnails USING vec0(
|
||||
id TEXT PRIMARY KEY,
|
||||
thumbnail_embedding FLOAT[768] distance_metric=cosine
|
||||
);
|
||||
""")
|
||||
self.execute_sql("""
|
||||
self.execute_write("""
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS vec_descriptions USING vec0(
|
||||
id TEXT PRIMARY KEY,
|
||||
description_embedding FLOAT[768] distance_metric=cosine
|
||||
);
|
||||
""")
|
||||
|
||||
def upsert_embeddings(
|
||||
self, table: str, column: str, embeddings: dict[str, bytes]
|
||||
) -> None:
|
||||
"""Write embeddings for the given event ids, replacing any that exist.
|
||||
|
||||
vec0 implements neither REPLACE nor UPSERT, so rows that are already
|
||||
there have to be deleted first.
|
||||
"""
|
||||
if not embeddings:
|
||||
return
|
||||
|
||||
event_ids = list(embeddings.keys())
|
||||
ids = ",".join(["?" for _ in event_ids])
|
||||
params: list[Any] = []
|
||||
|
||||
for event_id in event_ids:
|
||||
params.extend((event_id, embeddings[event_id]))
|
||||
|
||||
values = ", ".join(["(?, ?)"] * len(event_ids))
|
||||
|
||||
# reindexing and live embedding run on separate threads, and each write
|
||||
# is queued separately, so the delete and the insert have to be held
|
||||
# together or an interleaved pair fails on the vec0 primary key
|
||||
with self.upsert_lock:
|
||||
self.execute_write(f"DELETE FROM {table} WHERE id IN ({ids})", event_ids)
|
||||
self.execute_write(
|
||||
f"INSERT INTO {table}(id, {column}) VALUES {values}", params
|
||||
)
|
||||
|
||||
@@ -47,21 +47,17 @@ class ModelTypeEnum(str, Enum):
|
||||
yologeneric = "yolo-generic"
|
||||
|
||||
|
||||
class SceneEnum(str, Enum):
|
||||
"""The camera environment a detection model is intended for."""
|
||||
|
||||
all = "all"
|
||||
indoor = "indoor"
|
||||
outdoor = "outdoor"
|
||||
indoor_thermal = "indoor_thermal"
|
||||
outdoor_thermal = "outdoor_thermal"
|
||||
# the scene of the model used by cameras that don't name one
|
||||
DEFAULT_SCENE = "default"
|
||||
SCENE_PATTERN = r"^[A-Za-z0-9_-]+$"
|
||||
|
||||
|
||||
class ModelConfig(BaseModel):
|
||||
scene: SceneEnum = Field(
|
||||
default=SceneEnum.all,
|
||||
scene: str = Field(
|
||||
default=DEFAULT_SCENE,
|
||||
pattern=SCENE_PATTERN,
|
||||
title="Model scene",
|
||||
description="The camera environment this model is used for. Cameras select a model by setting detect.scene to a matching value, and 'all' is used by any camera that does not set one.",
|
||||
description="A name for the camera environment this model is used for, such as 'thermal'. Cameras select a model by setting detect.scene to a matching value, and the 'default' model is used by any camera that does not set one.",
|
||||
)
|
||||
devices: list[str] = Field(
|
||||
default_factory=list,
|
||||
@@ -123,6 +119,7 @@ class ModelConfig(BaseModel):
|
||||
_all_attributes: list[str] = PrivateAttr()
|
||||
_all_attribute_logos: list[str] = PrivateAttr()
|
||||
_model_hash: str = PrivateAttr()
|
||||
_plus_id: str | None = PrivateAttr(default=None)
|
||||
|
||||
@property
|
||||
def merged_labelmap(self) -> dict[int, str]:
|
||||
@@ -148,6 +145,11 @@ class ModelConfig(BaseModel):
|
||||
def model_hash(self) -> str:
|
||||
return self._model_hash
|
||||
|
||||
@property
|
||||
def plus_id(self) -> str | None:
|
||||
"""The Frigate+ model id, once a plus:// path has been resolved."""
|
||||
return self._plus_id
|
||||
|
||||
def __init__(self, **config):
|
||||
super().__init__(**config)
|
||||
|
||||
@@ -178,6 +180,7 @@ class ModelConfig(BaseModel):
|
||||
os.makedirs(MODEL_CACHE_DIR, exist_ok=True)
|
||||
|
||||
model_id = self.path[7:]
|
||||
self._plus_id = model_id
|
||||
self.path = os.path.join(MODEL_CACHE_DIR, model_id)
|
||||
model_info_path = f"{self.path}.json"
|
||||
|
||||
|
||||
@@ -6,9 +6,10 @@ import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
from peewee import DoesNotExist, IntegrityError
|
||||
from peewee import DatabaseError, DoesNotExist, IntegrityError
|
||||
from PIL import Image
|
||||
from playhouse.shortcuts import model_to_dict
|
||||
|
||||
@@ -207,12 +208,10 @@ class Embeddings:
|
||||
embedding = self.vision_embedding([thumbnail])[0]
|
||||
|
||||
if upsert:
|
||||
self.db.execute_sql(
|
||||
"""
|
||||
INSERT OR REPLACE INTO vec_thumbnails(id, thumbnail_embedding)
|
||||
VALUES(?, ?)
|
||||
""",
|
||||
(event_id, serialize(embedding)),
|
||||
self.db.upsert_embeddings(
|
||||
"vec_thumbnails",
|
||||
"thumbnail_embedding",
|
||||
{event_id: serialize(embedding)},
|
||||
)
|
||||
|
||||
self.image_inference_speed.update(datetime.datetime.now().timestamp() - start)
|
||||
@@ -251,19 +250,12 @@ class Embeddings:
|
||||
embeddings = self.vision_embedding(valid_thumbs)
|
||||
|
||||
if upsert:
|
||||
items = []
|
||||
items = {}
|
||||
for i in range(len(valid_ids)):
|
||||
items.append(valid_ids[i])
|
||||
items.append(serialize(embeddings[i]))
|
||||
items[valid_ids[i]] = serialize(embeddings[i])
|
||||
self.image_eps.update()
|
||||
|
||||
self.db.execute_sql(
|
||||
"""
|
||||
INSERT OR REPLACE INTO vec_thumbnails(id, thumbnail_embedding)
|
||||
VALUES {}
|
||||
""".format(", ".join(["(?, ?)"] * len(valid_ids))),
|
||||
items,
|
||||
)
|
||||
self.db.upsert_embeddings("vec_thumbnails", "thumbnail_embedding", items)
|
||||
|
||||
duration = datetime.datetime.now().timestamp() - start
|
||||
self.image_inference_speed.update(duration / len(valid_ids))
|
||||
@@ -277,12 +269,10 @@ class Embeddings:
|
||||
embedding = self.text_embedding([description])[0]
|
||||
|
||||
if upsert:
|
||||
self.db.execute_sql(
|
||||
"""
|
||||
INSERT OR REPLACE INTO vec_descriptions(id, description_embedding)
|
||||
VALUES(?, ?)
|
||||
""",
|
||||
(event_id, serialize(embedding)),
|
||||
self.db.upsert_embeddings(
|
||||
"vec_descriptions",
|
||||
"description_embedding",
|
||||
{event_id: serialize(embedding)},
|
||||
)
|
||||
|
||||
self.text_inference_speed.update(datetime.datetime.now().timestamp() - start)
|
||||
@@ -302,19 +292,14 @@ class Embeddings:
|
||||
|
||||
if upsert:
|
||||
ids = list(event_descriptions.keys())
|
||||
items = []
|
||||
items = {}
|
||||
|
||||
for i in range(len(ids)):
|
||||
items.append(ids[i])
|
||||
items.append(serialize(embeddings[i]))
|
||||
items[ids[i]] = serialize(embeddings[i])
|
||||
self.text_eps.update()
|
||||
|
||||
self.db.execute_sql(
|
||||
"""
|
||||
INSERT OR REPLACE INTO vec_descriptions(id, description_embedding)
|
||||
VALUES {}
|
||||
""".format(", ".join(["(?, ?)"] * len(ids))),
|
||||
items,
|
||||
self.db.upsert_embeddings(
|
||||
"vec_descriptions", "description_embedding", items
|
||||
)
|
||||
|
||||
self.text_inference_speed.update(datetime.datetime.now().timestamp() - start)
|
||||
@@ -322,6 +307,17 @@ class Embeddings:
|
||||
return embeddings
|
||||
|
||||
def reindex(self) -> None:
|
||||
"""Rebuild every tracked object embedding from scratch."""
|
||||
totals: dict[str, Any] = {"status": "indexing"}
|
||||
|
||||
try:
|
||||
self._reindex(totals)
|
||||
except DatabaseError:
|
||||
logger.exception("Unable to reindex tracked object embeddings")
|
||||
totals["status"] = "failed"
|
||||
self.requestor.send_data(UPDATE_EMBEDDINGS_REINDEX_PROGRESS, totals)
|
||||
|
||||
def _reindex(self, totals: dict[str, Any]) -> None:
|
||||
logger.info("Indexing tracked object embeddings...")
|
||||
|
||||
self.db.drop_embeddings_tables()
|
||||
@@ -346,17 +342,24 @@ class Embeddings:
|
||||
batch_size = 32
|
||||
current_page = 1
|
||||
|
||||
totals = {
|
||||
"thumbnails": 0,
|
||||
"descriptions": 0,
|
||||
"processed_objects": total_events - 1 if total_events < batch_size else 0,
|
||||
"total_objects": total_events,
|
||||
"time_remaining": 0 if total_events < batch_size else -1,
|
||||
"status": "indexing",
|
||||
}
|
||||
totals.update(
|
||||
{
|
||||
"thumbnails": 0,
|
||||
"descriptions": 0,
|
||||
"processed_objects": total_events - 1
|
||||
if total_events < batch_size
|
||||
else 0,
|
||||
"total_objects": total_events,
|
||||
"time_remaining": 0 if total_events < batch_size else -1,
|
||||
"status": "indexing",
|
||||
}
|
||||
)
|
||||
|
||||
self.requestor.send_data(UPDATE_EMBEDDINGS_REINDEX_PROGRESS, totals)
|
||||
|
||||
# a single batch sends no progress, so the first message shows it nearly done
|
||||
totals["processed_objects"] = 0
|
||||
|
||||
events = (
|
||||
Event.select()
|
||||
.order_by(Event.start_time.desc())
|
||||
|
||||
@@ -11,7 +11,11 @@ from typing import Any
|
||||
from peewee import DoesNotExist
|
||||
|
||||
from frigate.comms.config_updater import ConfigSubscriber
|
||||
from frigate.comms.detections_updater import DetectionSubscriber, DetectionTypeEnum
|
||||
from frigate.comms.detections_updater import (
|
||||
DetectionPublisher,
|
||||
DetectionSubscriber,
|
||||
DetectionTypeEnum,
|
||||
)
|
||||
from frigate.comms.embeddings_updater import (
|
||||
EmbeddingsRequestEnum,
|
||||
EmbeddingsResponder,
|
||||
@@ -168,6 +172,7 @@ class EmbeddingMaintainer(threading.Thread):
|
||||
)
|
||||
self.review_subscriber = ReviewDataSubscriber("")
|
||||
self.detection_subscriber = DetectionSubscriber(DetectionTypeEnum.video.value)
|
||||
self.detection_publisher = DetectionPublisher(DetectionTypeEnum.all.value)
|
||||
self.embeddings_responder = EmbeddingsResponder()
|
||||
self.frame_manager = SharedMemoryFrameManager()
|
||||
|
||||
@@ -356,6 +361,7 @@ class EmbeddingMaintainer(threading.Thread):
|
||||
self.event_end_subscriber.stop()
|
||||
self.recordings_subscriber.stop()
|
||||
self.detection_subscriber.stop()
|
||||
self.detection_publisher.stop()
|
||||
self.event_metadata_publisher.stop()
|
||||
self.event_metadata_subscriber.stop()
|
||||
self.embeddings_responder.stop()
|
||||
@@ -851,6 +857,21 @@ class EmbeddingMaintainer(threading.Thread):
|
||||
f"{result['camera']}/classification/{result['model_name']}",
|
||||
result["state"],
|
||||
)
|
||||
|
||||
# the first state verified after startup is not a change
|
||||
if result["previous_state"] is not None:
|
||||
self.detection_publisher.publish(
|
||||
(
|
||||
result["camera"],
|
||||
{
|
||||
"model": result["model_name"],
|
||||
"from": result["previous_state"],
|
||||
"to": result["state"],
|
||||
"timestamp": result["timestamp"],
|
||||
},
|
||||
),
|
||||
DetectionTypeEnum.classification_state.value,
|
||||
)
|
||||
elif result["processor"] == "object":
|
||||
object_id = result["object_id"]
|
||||
camera = result["camera"]
|
||||
|
||||
@@ -365,6 +365,7 @@ class EventCleanup(threading.Thread):
|
||||
chunk = ids_to_delete[i : i + CHUNK_SIZE]
|
||||
logger.debug(f"Deleting {len(chunk)} events from the database")
|
||||
Event.delete().where(Event.id << chunk).execute()
|
||||
Timeline.delete().where(Timeline.source_id << chunk).execute()
|
||||
|
||||
# embeddings are always cleaned up, even when semantic search
|
||||
# is disabled, so that they don't outlive their events
|
||||
|
||||
@@ -126,8 +126,8 @@ PRESETS_HW_ACCEL_SCALE = {
|
||||
"preset-apple-silicon-h264": "-r {0} -vf fps={0},scale={1}:{2}",
|
||||
"preset-apple-silicon-h265": "-r {0} -vf fps={0},scale={1}:{2}",
|
||||
FFMPEG_HWACCEL_VAAPI: "-r {0} -vf fps={0},scale_vaapi=w={1}:h={2},hwdownload,format=nv12",
|
||||
"preset-intel-qsv-h264": "-r {0} -vf vpp_qsv=w={1}:h={2}:format=nv12,hwdownload,format=nv12,fps={0},format=yuv420p",
|
||||
"preset-intel-qsv-h265": "-r {0} -vf vpp_qsv=w={1}:h={2}:format=nv12,hwdownload,format=nv12,fps={0},format=yuv420p",
|
||||
"preset-intel-qsv-h264": "-r {0} -vf fps={0},vpp_qsv=w={1}:h={2}:format=nv12,hwdownload,format=nv12,format=yuv420p",
|
||||
"preset-intel-qsv-h265": "-r {0} -vf fps={0},vpp_qsv=w={1}:h={2}:format=nv12,hwdownload,format=nv12,format=yuv420p",
|
||||
FFMPEG_HWACCEL_NVIDIA: "-r {0} -vf fps={0},scale_cuda=w={1}:h={2},hwdownload,format=nv12",
|
||||
"preset-jetson-h264": "-r {0}", # scaled in decoder
|
||||
"preset-jetson-h265": "-r {0}", # scaled in decoder
|
||||
|
||||
@@ -103,11 +103,10 @@ class LlamaCppClient(GenAIClient):
|
||||
_supports_reasoning: bool
|
||||
_image_token_cache: dict[tuple[int, int], int]
|
||||
_text_baseline_tokens: int | None
|
||||
_media_marker: str
|
||||
|
||||
@property
|
||||
def supports_embeddings(self) -> bool:
|
||||
"""llama.cpp exposes an /embeddings endpoint for any loaded model."""
|
||||
"""llama.cpp exposes a /v1/embeddings endpoint for any loaded model."""
|
||||
return True
|
||||
|
||||
def _auth_headers(self) -> dict | None:
|
||||
@@ -159,7 +158,6 @@ class LlamaCppClient(GenAIClient):
|
||||
self._supports_reasoning = False
|
||||
self._image_token_cache = {}
|
||||
self._text_baseline_tokens = None
|
||||
self._media_marker = "<__media__>"
|
||||
|
||||
base_url = (
|
||||
self.genai_config.base_url.rstrip("/")
|
||||
@@ -187,7 +185,6 @@ class LlamaCppClient(GenAIClient):
|
||||
self._supports_audio = info["supports_audio"]
|
||||
self._supports_tools = info["supports_tools"]
|
||||
self._supports_reasoning = info["supports_reasoning"]
|
||||
self._media_marker = info["media_marker"]
|
||||
|
||||
logger.info(
|
||||
"llama.cpp model '%s' initialized — context: %s, vision: %s, audio: %s, tools: %s, reasoning: %s",
|
||||
@@ -215,9 +212,7 @@ class LlamaCppClient(GenAIClient):
|
||||
`architecture.input_modalities` (text/image/audio) — the primary
|
||||
source. When proxied through llama-swap, the same entry carries
|
||||
`status.args` (server launch argv) and, for the loaded model,
|
||||
`meta.n_ctx`. /props remains the only source for `media_marker`,
|
||||
which the server randomizes per startup unless LLAMA_MEDIA_MARKER
|
||||
is set.
|
||||
`meta.n_ctx`.
|
||||
"""
|
||||
info: dict[str, Any] = {
|
||||
"context_size": None,
|
||||
@@ -225,7 +220,6 @@ class LlamaCppClient(GenAIClient):
|
||||
"supports_audio": False,
|
||||
"supports_tools": False,
|
||||
"supports_reasoning": False,
|
||||
"media_marker": "<__media__>",
|
||||
}
|
||||
|
||||
model_entry: dict[str, Any] | None = None
|
||||
@@ -314,16 +308,8 @@ class LlamaCppClient(GenAIClient):
|
||||
# in the Jinja chat template itself.
|
||||
chat_template = props.get("chat_template") or ""
|
||||
info["supports_reasoning"] = "enable_thinking" in chat_template
|
||||
|
||||
media_marker = props.get("media_marker")
|
||||
if isinstance(media_marker, str) and media_marker:
|
||||
info["media_marker"] = media_marker
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Failed to query llama.cpp /props endpoint: %s. "
|
||||
"Image embeddings may fail if the server randomized its media marker.",
|
||||
e,
|
||||
)
|
||||
logger.warning("Failed to query llama.cpp /props endpoint: %s", e)
|
||||
|
||||
return info
|
||||
|
||||
@@ -474,9 +460,6 @@ class LlamaCppClient(GenAIClient):
|
||||
def _transcribe_via_chat(self, audio: bytes, language: str | None) -> str | None:
|
||||
"""Transcribe through /v1/chat/completions, for servers without the
|
||||
transcriptions route.
|
||||
|
||||
The _media_marker / multimodal_data convention is an /embeddings-only
|
||||
protocol, so no marker-refresh retry is needed here.
|
||||
"""
|
||||
prompt = "Transcribe the speech in this audio verbatim. Respond with the transcript only, and with nothing at all if there is no speech."
|
||||
|
||||
@@ -794,41 +777,16 @@ class LlamaCppClient(GenAIClient):
|
||||
)
|
||||
return result if result else None
|
||||
|
||||
def _refresh_media_marker(self) -> bool:
|
||||
"""Re-fetch /props and update the cached media marker if it changed.
|
||||
|
||||
The server randomizes the marker per startup (unless LLAMA_MEDIA_MARKER
|
||||
is set), so a stale marker indicates a restart. Returns True iff the
|
||||
marker was updated to a new value — used to gate a one-shot retry of
|
||||
a failed embeddings request.
|
||||
"""
|
||||
if self.provider is None:
|
||||
return False
|
||||
try:
|
||||
props = self._fetch_llama_props(self.provider, self.genai_config.model)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to refresh llama.cpp media marker: %s", e)
|
||||
return False
|
||||
|
||||
marker = props.get("media_marker")
|
||||
|
||||
if not isinstance(marker, str) or not marker or marker == self._media_marker:
|
||||
return False
|
||||
|
||||
logger.info("llama.cpp media marker changed (server restart); refreshed")
|
||||
self._media_marker = marker
|
||||
return True
|
||||
|
||||
def embed(
|
||||
self,
|
||||
texts: list[str] | None = None,
|
||||
images: list[bytes] | None = None,
|
||||
) -> list[np.ndarray]:
|
||||
"""Generate embeddings via llama.cpp /embeddings endpoint.
|
||||
"""Generate embeddings via llama.cpp /v1/embeddings endpoint.
|
||||
|
||||
Supports batch requests. Uses content format with prompt_string and
|
||||
multimodal_data for images (PR #15108). Server must be started with
|
||||
--embeddings and --mmproj for multimodal support.
|
||||
Each text or image is one entry in `input`, using the chat-style
|
||||
content array from ggml-org/llama.cpp#29556. Server must be started
|
||||
with --embeddings, and --mmproj for image support.
|
||||
"""
|
||||
if self.provider is None:
|
||||
logger.warning(
|
||||
@@ -843,49 +801,42 @@ class LlamaCppClient(GenAIClient):
|
||||
|
||||
EMBEDDING_DIM = 768
|
||||
|
||||
encoded_images: list[str] = []
|
||||
inputs: list[dict[str, Any]] = [
|
||||
{"content": [{"type": "text", "text": text}]} for text in texts
|
||||
]
|
||||
|
||||
for img in images:
|
||||
# llama.cpp uses STB which does not support WebP; convert to JPEG
|
||||
jpeg_bytes = _to_jpeg(img)
|
||||
to_encode = jpeg_bytes if jpeg_bytes is not None else img
|
||||
encoded_images.append(base64.b64encode(to_encode).decode("utf-8"))
|
||||
|
||||
def build_content() -> list[dict[str, Any]]:
|
||||
# prompt_string must contain the server's media marker placeholder
|
||||
# for each image. The marker is randomized per server startup.
|
||||
content: list[dict[str, Any]] = []
|
||||
for text in texts:
|
||||
content.append({"prompt_string": text})
|
||||
for encoded in encoded_images:
|
||||
content.append(
|
||||
{
|
||||
"prompt_string": f"{self._media_marker}\n",
|
||||
"multimodal_data": [encoded],
|
||||
}
|
||||
)
|
||||
return content
|
||||
|
||||
def post_embeddings() -> requests.Response:
|
||||
return self._post(
|
||||
f"{self.provider}/embeddings",
|
||||
json={"model": self.genai_config.model, "content": build_content()},
|
||||
timeout=self.timeout,
|
||||
encoded = base64.b64encode(to_encode).decode("utf-8")
|
||||
# The trailing newline keeps tokenization identical to the older
|
||||
# "<__media__>\n" prompt_string format, so indexed vectors stay valid
|
||||
inputs.append(
|
||||
{
|
||||
"content": [
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": f"data:image/jpeg;base64,{encoded}"},
|
||||
},
|
||||
{"type": "text", "text": "\n"},
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
try:
|
||||
try:
|
||||
response = post_embeddings()
|
||||
response.raise_for_status()
|
||||
except requests.exceptions.RequestException:
|
||||
# The server may have restarted with a new media marker.
|
||||
# Refresh from /props; only retry if the marker actually changed.
|
||||
if not encoded_images or not self._refresh_media_marker():
|
||||
raise
|
||||
response = post_embeddings()
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
response = self._post(
|
||||
f"{self.provider}/v1/embeddings",
|
||||
json={
|
||||
"model": self.genai_config.model,
|
||||
"input": inputs,
|
||||
"encoding_format": "float",
|
||||
},
|
||||
timeout=self.timeout,
|
||||
)
|
||||
response.raise_for_status()
|
||||
items = response.json().get("data")
|
||||
|
||||
items = result.get("data", result) if isinstance(result, dict) else result
|
||||
if not isinstance(items, list):
|
||||
logger.warning("llama.cpp embeddings returned unexpected format")
|
||||
return []
|
||||
@@ -896,11 +847,7 @@ class LlamaCppClient(GenAIClient):
|
||||
if emb is None:
|
||||
logger.warning("llama.cpp embeddings item missing embedding field")
|
||||
continue
|
||||
arr = np.array(emb, dtype=np.float32)
|
||||
if arr.ndim > 1:
|
||||
# llama.cpp can return token-level embeddings; pool per item
|
||||
arr = arr.mean(axis=0)
|
||||
arr = arr.flatten()
|
||||
arr = np.array(emb, dtype=np.float32).flatten()
|
||||
orig_dim = arr.size
|
||||
if orig_dim != EMBEDDING_DIM:
|
||||
if orig_dim > EMBEDDING_DIM:
|
||||
|
||||
@@ -104,6 +104,21 @@ def build_review_description_prompt(
|
||||
else:
|
||||
return "\n- (No objects detected)"
|
||||
|
||||
def get_state_changes_section() -> str:
|
||||
# empty when nothing changed so the prompt is otherwise unaffected
|
||||
changes = review_data.get("classification_state_changes")
|
||||
|
||||
if not changes:
|
||||
return ""
|
||||
|
||||
return (
|
||||
"\n\n## State Changes\n\n"
|
||||
"The camera's state classifiers watch fixed areas of the scene and "
|
||||
"reported these changes. They come from the classifiers rather than "
|
||||
"from the images, and they are reliable. Describe each one where it "
|
||||
"fits in the sequence of events.\n- " + "\n- ".join(changes)
|
||||
)
|
||||
|
||||
fields = get_review_field_guidelines(response_style)
|
||||
frame_guidance = f"\n{FRAME_ANNOTATION_GUIDANCE}" if frame_captions else ""
|
||||
|
||||
@@ -145,7 +160,7 @@ Respond with a JSON object matching the provided schema. Field-specific guidance
|
||||
- Camera: {review_data["camera"]}
|
||||
- Total frames: {len(thumbnails)} (Frame 1 = earliest, Frame {len(thumbnails)} = latest){frame_guidance}
|
||||
- Activity started at {review_data["start"]} and lasted {review_data["duration"]} seconds
|
||||
- Zones involved: {", ".join(review_data["zones"]) if review_data["zones"] else "None"}
|
||||
- Zones involved: {", ".join(review_data["zones"]) if review_data["zones"] else "None"}{get_state_changes_section()}
|
||||
|
||||
## Objects in Scene
|
||||
|
||||
@@ -196,6 +211,17 @@ def build_review_summary_prompt(
|
||||
f" to "
|
||||
f"{datetime.datetime.fromtimestamp(end_ts).strftime('%B %d, %Y at %I:%M %p')}"
|
||||
)
|
||||
has_state_changes = any(
|
||||
"state_changes" in item
|
||||
for event in events
|
||||
for item in [event, *event.get("context", [])]
|
||||
)
|
||||
state_changes_format = (
|
||||
'\n- "state_changes" (only on some events): changes to monitored areas '
|
||||
"reported by the camera's state classifiers, which are reliable"
|
||||
if has_state_changes
|
||||
else ""
|
||||
)
|
||||
prompt = f"""
|
||||
You are a security officer writing a concise security report.
|
||||
|
||||
@@ -203,7 +229,7 @@ Time range: {time_range}
|
||||
|
||||
Input format: Each event is a JSON object with:
|
||||
- "title", "scene", "confidence", "potential_threat_level" (0-2), "other_concerns", "camera", "time", "start_time", "end_time"
|
||||
- "context": array of related events from other cameras that occurred during overlapping time periods
|
||||
- "context": array of related events from other cameras that occurred during overlapping time periods{state_changes_format}
|
||||
|
||||
**Note: Use the "scene" field for event descriptions in the report. Ignore any "shortSummary" field if present.**
|
||||
|
||||
|
||||
@@ -1397,9 +1397,6 @@ class PtzAutoTracker:
|
||||
def is_autotracking(self, camera: str):
|
||||
return self.tracked_object[camera] is not None
|
||||
|
||||
def autotracked_object_region(self, camera: str):
|
||||
return self.tracked_object[camera]["region"]
|
||||
|
||||
def autotrack_object(self, camera: str, obj: TrackedObject):
|
||||
if camera not in self.config.cameras:
|
||||
return
|
||||
@@ -1538,8 +1535,6 @@ class PtzAutoTracker:
|
||||
# returns camera to preset after timeout when tracking is over
|
||||
autotracker_config = self.config.cameras[camera].onvif.autotracking
|
||||
|
||||
if not self.autotracker_init[camera]:
|
||||
self._autotracker_setup(self.config.cameras[camera], camera)
|
||||
# regularly update camera status
|
||||
if not self.ptz_metrics[camera].motor_stopped.is_set():
|
||||
await self.onvif.get_camera_status(camera)
|
||||
|
||||
@@ -966,6 +966,10 @@ class OnvifController:
|
||||
}
|
||||
else:
|
||||
logger.warning(f"ONVIF initialization failed for {camera_name}")
|
||||
self.failed_cams[camera_name] = {
|
||||
"retry_attempts": attempts + 1,
|
||||
"last_attempt": time.time(),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error during ONVIF initialization for {camera_name}: {e}"
|
||||
|
||||
+91
-23
@@ -42,6 +42,8 @@ from frigate.util.ownership import chown_to_runtime
|
||||
from frigate.util.recording_coverage import (
|
||||
build_spans,
|
||||
known_video_codecs,
|
||||
null_audio_glitches,
|
||||
realized_timeline,
|
||||
resolve_coverage,
|
||||
stream_media_summary,
|
||||
)
|
||||
@@ -84,10 +86,20 @@ class StreamRun:
|
||||
|
||||
@dataclass
|
||||
class _ChapterWindow:
|
||||
"""A merged-timeline slice, shaped like the recording rows chapters read."""
|
||||
"""A merged-timeline slice, shaped like the recording rows chapters read.
|
||||
|
||||
lead_in is the output time the slice's vod clip plays before start_time,
|
||||
from snapping its first frame back to a keyframe.
|
||||
"""
|
||||
|
||||
start_time: float
|
||||
end_time: float
|
||||
lead_in: float = 0.0
|
||||
|
||||
|
||||
def _lead_in(recording: Any) -> float:
|
||||
"""Output seconds a chapter source plays before its first wall second."""
|
||||
return recording.lead_in if isinstance(recording, _ChapterWindow) else 0.0
|
||||
|
||||
|
||||
# Matches the setpts factor used in timelapse exports (e.g. setpts=0.04*PTS).
|
||||
@@ -376,15 +388,18 @@ class RecordingExporter(threading.Thread):
|
||||
def _resolve_coverage(self) -> tuple[list[list[Any]], set[str], bool]:
|
||||
"""Resolve the export range into the spans the VOD manifest will serve.
|
||||
|
||||
Delegates to the same coverage resolution the manifest builder
|
||||
uses, so what we plan around and what nginx-vod emits agree by
|
||||
construction. Returns the spans (each [row, start, end, is_main]),
|
||||
the known video codecs, and whether audio survives the range.
|
||||
Delegates to the same coverage resolution and glitch nulling the
|
||||
manifest builder uses, so what we plan around and what nginx-vod
|
||||
emits agree by construction. Returns the spans (each [row, start,
|
||||
end, is_main]), the known video codecs, and whether audio survives
|
||||
the range.
|
||||
Memoized: several stages of the export ask the same question, and
|
||||
the recordings backing a finished range do not change under us.
|
||||
"""
|
||||
if self._coverage is None:
|
||||
intervals = resolve_coverage(self.camera, self.start_time, self.end_time)
|
||||
intervals = null_audio_glitches(
|
||||
resolve_coverage(self.camera, self.start_time, self.end_time)
|
||||
)
|
||||
self._coverage = (
|
||||
build_spans(intervals, self.pinned_stream),
|
||||
known_video_codecs(intervals),
|
||||
@@ -433,17 +448,57 @@ class RecordingExporter(threading.Thread):
|
||||
# hand-off to stage around
|
||||
return True
|
||||
|
||||
spans, codecs, keep_audio = self._resolve_coverage()
|
||||
runs = self._stream_runs(spans)
|
||||
_spans, codecs, keep_audio = self._resolve_coverage()
|
||||
runs = self._planned_stream_runs()
|
||||
|
||||
# a range one stream covers end to end has nothing to hand off,
|
||||
# so it stays on the existing path however long it is
|
||||
if len(runs) < 2:
|
||||
return True
|
||||
|
||||
runs = [piece for run in runs for piece in self._split_long_run(run)]
|
||||
return self._stage_stream_runs(runs, codecs, keep_audio)
|
||||
|
||||
def _planned_stream_runs(self) -> list[StreamRun]:
|
||||
"""The runs a mixed range is staged as, one pinned vod playlist each."""
|
||||
runs = self._stream_runs(self._merged_spans())
|
||||
|
||||
if len(runs) < 2:
|
||||
return runs
|
||||
|
||||
return [piece for run in runs for piece in self._split_long_run(run)]
|
||||
|
||||
def _staged_chapter_windows(self) -> list[_ChapterWindow]:
|
||||
"""Chapter windows for the staged files as they were rendered.
|
||||
|
||||
Each staged run comes from its own pinned vod playlist, whose first
|
||||
clip snaps back to the preceding keyframe, so a staged file runs up
|
||||
to a GOP longer than its slice of the merged timeline. Planning each
|
||||
run the way its playlist does carries that lead-in into the chapter
|
||||
offsets instead of letting it accumulate at every hand-off.
|
||||
"""
|
||||
windows: list[_ChapterWindow] = []
|
||||
|
||||
for run in self._planned_stream_runs():
|
||||
intervals = null_audio_glitches(
|
||||
resolve_coverage(self.camera, run.start_time, run.end_time)
|
||||
)
|
||||
|
||||
for clip in realized_timeline(intervals, run.stream_type):
|
||||
# a skipped clip is absent from the playlist and the file
|
||||
if clip["duration"] <= 0:
|
||||
continue
|
||||
|
||||
span = clip["end_time"] - clip["start_time"]
|
||||
windows.append(
|
||||
_ChapterWindow(
|
||||
clip["start_time"],
|
||||
clip["end_time"],
|
||||
max(0.0, clip["duration"] / 1000 - span),
|
||||
)
|
||||
)
|
||||
|
||||
return windows
|
||||
|
||||
def _stream_runs(self, spans: list[list[Any]]) -> list[StreamRun]:
|
||||
"""Collapse the merged spans into contiguous runs of one stream type.
|
||||
|
||||
@@ -840,6 +895,8 @@ class RecordingExporter(threading.Thread):
|
||||
clipped_end = min(float(rec.end_time), float(self.end_time))
|
||||
if clipped_end <= clipped_start:
|
||||
continue
|
||||
# a staged window's keyframe lead-in plays before it
|
||||
output_offset += _lead_in(rec)
|
||||
windows.append((clipped_start, clipped_end, output_offset))
|
||||
output_offset += clipped_end - clipped_start
|
||||
|
||||
@@ -987,9 +1044,13 @@ class RecordingExporter(threading.Thread):
|
||||
if duration_ms <= 0:
|
||||
continue
|
||||
|
||||
title = datetime.datetime.fromtimestamp(clipped_start, tz=tz).isoformat(
|
||||
timespec="seconds"
|
||||
)
|
||||
# a staged window's keyframe lead-in opens its chapter, with
|
||||
# frames captured that long before the window
|
||||
lead_in = _lead_in(rec)
|
||||
duration_ms += int(round(lead_in * 1000))
|
||||
title = datetime.datetime.fromtimestamp(
|
||||
clipped_start - lead_in, tz=tz
|
||||
).isoformat(timespec="seconds")
|
||||
chapter_blocks.append(
|
||||
"[CHAPTER]\n"
|
||||
"TIMEBASE=1/1000\n"
|
||||
@@ -1128,12 +1189,12 @@ class RecordingExporter(threading.Thread):
|
||||
if self.staged_runs:
|
||||
# each run was already rendered to a temp file with a common
|
||||
# track timescale, so the concat demuxer has nothing left to
|
||||
# reconcile and every chapter offset lines up with the merged
|
||||
# timeline the staged files reproduce
|
||||
recordings = [
|
||||
_ChapterWindow(span_start, span_end)
|
||||
for _row, span_start, span_end, _is_main in self._merged_spans()
|
||||
]
|
||||
# reconcile
|
||||
recordings = (
|
||||
self._staged_chapter_windows()
|
||||
if self.chapters not in (None, ChaptersEnum.none)
|
||||
else []
|
||||
)
|
||||
playlist_lines: list[str] = [f"file '{path}'" for path in self.staged_runs]
|
||||
ffmpeg_input = (
|
||||
"-y -protocol_whitelist pipe,file -f concat -safe 0 -i /dev/stdin"
|
||||
@@ -1149,11 +1210,18 @@ class RecordingExporter(threading.Thread):
|
||||
# its own rows are the ones the chapters describe
|
||||
recordings = self._get_recordings_for_range(pin)
|
||||
else:
|
||||
# never mix streams in one playlist; use main when available
|
||||
# and fall back to sub for expired-main history
|
||||
recordings = self._get_recordings_for_range(STREAM_TYPE_MAIN)
|
||||
# an unstaged auto range resolves to at most one stream run, and
|
||||
# its rows are the ones the chapters describe. Main rows the
|
||||
# manifest drops (glitches, slivers at the edges of a sub range)
|
||||
# must not stand in for it.
|
||||
runs = self._stream_runs(self._merged_spans())
|
||||
recordings = self._get_recordings_for_range(
|
||||
runs[0].stream_type if runs else STREAM_TYPE_MAIN
|
||||
)
|
||||
|
||||
if not recordings:
|
||||
# never mix streams in one playlist; fall back to sub for
|
||||
# expired-main history
|
||||
if not recordings and not runs:
|
||||
recordings = self._get_recordings_for_range(STREAM_TYPE_SUB)
|
||||
|
||||
playlist_lines = []
|
||||
@@ -1307,7 +1375,7 @@ class RecordingExporter(threading.Thread):
|
||||
|
||||
if preview.end_time > self.end_time:
|
||||
playlist_lines.append(
|
||||
f"outpoint {int(preview.end_time - self.end_time)}"
|
||||
f"outpoint {int(self.end_time - preview.start_time)}"
|
||||
)
|
||||
|
||||
ffmpeg_input = (
|
||||
|
||||
@@ -490,9 +490,17 @@ class RecordingMaintainer(threading.Thread):
|
||||
)
|
||||
reviews = reviews_by_camera[camera]
|
||||
|
||||
tasks.extend(
|
||||
[self.validate_and_move_segment(camera, reviews, r) for r in recordings]
|
||||
)
|
||||
# probes run concurrently, but each segment's start chains off the
|
||||
# previous segment's end, so starts resolve in segment order
|
||||
previous: asyncio.Event | None = None
|
||||
for recording in recordings:
|
||||
resolved = asyncio.Event()
|
||||
tasks.append(
|
||||
self._validate_in_order(
|
||||
camera, reviews, recording, previous, resolved
|
||||
)
|
||||
)
|
||||
previous = resolved
|
||||
|
||||
# publish most recently available recording time and None if disabled
|
||||
if stream_type == STREAM_TYPE_MAIN:
|
||||
@@ -550,12 +558,33 @@ class RecordingMaintainer(threading.Thread):
|
||||
while info and info[0][0] < expire_before:
|
||||
info.pop(0)
|
||||
|
||||
async def _validate_in_order(
|
||||
self,
|
||||
camera: str,
|
||||
reviews: Any,
|
||||
recording: dict[str, Any],
|
||||
previous_start: asyncio.Event | None,
|
||||
start_resolved: asyncio.Event,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Validate a segment, always releasing the next one in its stream."""
|
||||
try:
|
||||
return await self.validate_and_move_segment(
|
||||
camera, reviews, recording, previous_start, start_resolved
|
||||
)
|
||||
finally:
|
||||
start_resolved.set()
|
||||
|
||||
def drop_segment(self, cache_path: str) -> None:
|
||||
Path(cache_path).unlink(missing_ok=True)
|
||||
self.end_time_cache.pop(cache_path, None)
|
||||
|
||||
async def validate_and_move_segment(
|
||||
self, camera: str, reviews: Any, recording: dict[str, Any]
|
||||
self,
|
||||
camera: str,
|
||||
reviews: Any,
|
||||
recording: dict[str, Any],
|
||||
previous_start: asyncio.Event | None = None,
|
||||
start_resolved: asyncio.Event | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
cache_path: str = recording["cache_path"]
|
||||
start_time: datetime.datetime = recording["start_time"]
|
||||
@@ -617,6 +646,9 @@ class RecordingMaintainer(threading.Thread):
|
||||
async with self.probe_semaphore:
|
||||
keyframes = await get_keyframe_offsets(cache_path)
|
||||
|
||||
if previous_start is not None:
|
||||
await previous_start.wait()
|
||||
|
||||
start_time = self._resolve_segment_start(
|
||||
camera, stream_type, start_time, duration, cache_path
|
||||
)
|
||||
@@ -654,6 +686,27 @@ class RecordingMaintainer(threading.Thread):
|
||||
RecordingsDataTypeEnum.valid.value,
|
||||
)
|
||||
|
||||
# the start is settled, so the next segment of the stream can chain
|
||||
# off it while this one waits on retention and the move
|
||||
if start_resolved is not None:
|
||||
start_resolved.set()
|
||||
|
||||
# assume that empty means the relevant recording info has not been received yet
|
||||
camera_info = self.object_recordings_info[camera]
|
||||
most_recently_processed_frame_time = (
|
||||
camera_info[-1][0] if len(camera_info) > 0 else 0
|
||||
)
|
||||
|
||||
# ensure delayed segment info does not lead to lost segments, every
|
||||
# retention decision below depends on complete stats for the segment
|
||||
if (
|
||||
datetime.datetime.fromtimestamp(
|
||||
most_recently_processed_frame_time
|
||||
).astimezone(datetime.UTC)
|
||||
< end_time
|
||||
):
|
||||
return None
|
||||
|
||||
record_config = self.config.cameras[camera].record
|
||||
|
||||
# sub's alerts/detections carry the retain mode directly, unlike
|
||||
@@ -681,43 +734,28 @@ class RecordingMaintainer(threading.Thread):
|
||||
# we should first just check if this segment matches that
|
||||
# and avoid any DB calls
|
||||
if highest is not None:
|
||||
# assume that empty means the relevant recording info has not been received yet
|
||||
camera_info = self.object_recordings_info[camera]
|
||||
most_recently_processed_frame_time = (
|
||||
camera_info[-1][0] if len(camera_info) > 0 else 0
|
||||
record_mode = (
|
||||
RetainModeEnum.all if highest == "continuous" else RetainModeEnum.motion
|
||||
)
|
||||
segment_stats = self.segment_stats(camera, start_time, end_time)
|
||||
|
||||
# ensure delayed segment info does not lead to lost segments
|
||||
if (
|
||||
datetime.datetime.fromtimestamp(
|
||||
most_recently_processed_frame_time
|
||||
).astimezone(datetime.UTC)
|
||||
>= end_time
|
||||
):
|
||||
record_mode = (
|
||||
RetainModeEnum.all
|
||||
if highest == "continuous"
|
||||
else RetainModeEnum.motion
|
||||
# Here we only check if we should move the segment based on non-object recording retention
|
||||
# we will always want to check for overlapping review items below before dropping the segment
|
||||
if not segment_stats.should_discard_segment(record_mode):
|
||||
return await self.move_segment(
|
||||
camera,
|
||||
stream_type,
|
||||
start_time,
|
||||
end_time,
|
||||
duration,
|
||||
cache_path,
|
||||
segment_stats,
|
||||
has_audio,
|
||||
audio_rate,
|
||||
audio_codec,
|
||||
video_codec,
|
||||
keyframes,
|
||||
)
|
||||
segment_stats = self.segment_stats(camera, start_time, end_time)
|
||||
|
||||
# Here we only check if we should move the segment based on non-object recording retention
|
||||
# we will always want to check for overlapping review items below before dropping the segment
|
||||
if not segment_stats.should_discard_segment(record_mode):
|
||||
return await self.move_segment(
|
||||
camera,
|
||||
stream_type,
|
||||
start_time,
|
||||
end_time,
|
||||
duration,
|
||||
cache_path,
|
||||
segment_stats,
|
||||
has_audio,
|
||||
audio_rate,
|
||||
audio_codec,
|
||||
video_codec,
|
||||
keyframes,
|
||||
)
|
||||
|
||||
# we fell through the continuous / motion check, so we need to check the review items
|
||||
# if the cached segment overlaps with the review items:
|
||||
@@ -779,10 +817,6 @@ class RecordingMaintainer(threading.Thread):
|
||||
# continuous/motion retention (either disabled or segment_stats said
|
||||
# discard), so waiting longer just fills the cache.
|
||||
else:
|
||||
camera_info = self.object_recordings_info[camera]
|
||||
most_recently_processed_frame_time = (
|
||||
camera_info[-1][0] if len(camera_info) > 0 else 0
|
||||
)
|
||||
retain_cutoff = datetime.datetime.fromtimestamp(
|
||||
most_recently_processed_frame_time - record_config.event_pre_capture
|
||||
).astimezone(datetime.UTC)
|
||||
@@ -1090,6 +1124,7 @@ class RecordingMaintainer(threading.Thread):
|
||||
elif (
|
||||
topic == DetectionTypeEnum.api.value
|
||||
or topic == DetectionTypeEnum.lpr.value
|
||||
or topic == DetectionTypeEnum.classification_state.value
|
||||
):
|
||||
continue
|
||||
|
||||
|
||||
+203
-66
@@ -40,6 +40,10 @@ logger = logging.getLogger(__name__)
|
||||
THUMB_HEIGHT = 180
|
||||
THUMB_WIDTH = 320
|
||||
|
||||
# seconds before a review item starts that a state classification change is
|
||||
# still attached to it, e.g. a garage door opening before the car is visible
|
||||
CLASSIFICATION_STATE_PRE_ROLL = 5
|
||||
|
||||
|
||||
class PendingReviewSegment:
|
||||
def __init__(
|
||||
@@ -61,6 +65,10 @@ class PendingReviewSegment:
|
||||
self.sub_labels = sub_labels
|
||||
self.zones = zones
|
||||
self.audio = audio
|
||||
self.classification_state_changes: list[dict[str, Any]] = []
|
||||
# detection-level activity after the last alert activity, split by the
|
||||
# detection cutoff, these are published when the alert is cut off
|
||||
self.pending_detections: list[PendingReviewSegment] = []
|
||||
self.thumb_time: float | None = None
|
||||
self.last_alert_time: float | None = None
|
||||
self.last_detection_time: float = frame_time
|
||||
@@ -78,6 +86,20 @@ class PendingReviewSegment:
|
||||
CLIPS_DIR, f"review/thumb-{self.camera}-{self.id}.webp"
|
||||
)
|
||||
|
||||
def add_object(self, obj: dict[str, Any], attributes: list[str]) -> None:
|
||||
"""Add a tracked object's label, sub label, and zones to the segment."""
|
||||
if not obj["sub_label"]:
|
||||
self.detections[obj["id"]] = obj["label"]
|
||||
elif obj["sub_label"][0] in attributes:
|
||||
self.detections[obj["id"]] = obj["sub_label"][0]
|
||||
else:
|
||||
self.detections[obj["id"]] = f"{obj['label']}-verified"
|
||||
self.sub_labels[obj["id"]] = obj["sub_label"][0]
|
||||
|
||||
for zone in obj["current_zones"]:
|
||||
if zone not in self.zones:
|
||||
self.zones.append(zone)
|
||||
|
||||
def update_frame(
|
||||
self,
|
||||
camera_config: CameraConfig,
|
||||
@@ -162,6 +184,7 @@ class PendingReviewSegment:
|
||||
"sub_labels": list(self.sub_labels.values()),
|
||||
"zones": self.zones,
|
||||
"audio": list(self.audio),
|
||||
"classification_state_changes": self.classification_state_changes,
|
||||
"thumb_time": self.thumb_time,
|
||||
"metadata": None,
|
||||
},
|
||||
@@ -293,6 +316,9 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
# manual events
|
||||
self.indefinite_events: dict[str, dict[str, Any]] = {}
|
||||
|
||||
# state classification changes seen while a camera had no review item
|
||||
self.recent_classification_state_changes: dict[str, list[dict[str, Any]]] = {}
|
||||
|
||||
# ensure dirs
|
||||
Path(os.path.join(CLIPS_DIR, "review")).mkdir(exist_ok=True)
|
||||
|
||||
@@ -374,6 +400,43 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
self.active_review_segments[segment.camera] = None
|
||||
return end_time
|
||||
|
||||
def _activate_segment(self, segment: PendingReviewSegment) -> None:
|
||||
"""Make a segment the camera's active one, attaching any state
|
||||
classification changes seen just before it started."""
|
||||
self.active_review_segments[segment.camera] = segment
|
||||
recent = self.recent_classification_state_changes.pop(segment.camera, [])
|
||||
segment.classification_state_changes.extend(
|
||||
c
|
||||
for c in recent
|
||||
if c["timestamp"] >= segment.start_time - CLASSIFICATION_STATE_PRE_ROLL
|
||||
)
|
||||
|
||||
def handle_classification_state_change(
|
||||
self, camera: str, change: dict[str, Any]
|
||||
) -> None:
|
||||
"""Attach a verified state classification change to the active segment.
|
||||
|
||||
State changes never start, extend, or upgrade a segment. A change seen
|
||||
with no active segment is held briefly for a segment starting right
|
||||
after it.
|
||||
"""
|
||||
segment = self.active_review_segments.get(camera)
|
||||
|
||||
if segment is None:
|
||||
cutoff = change["timestamp"] - CLASSIFICATION_STATE_PRE_ROLL
|
||||
self.recent_classification_state_changes[camera] = [
|
||||
c
|
||||
for c in self.recent_classification_state_changes.get(camera, [])
|
||||
if c["timestamp"] >= cutoff
|
||||
] + [change]
|
||||
return
|
||||
|
||||
prev_data = segment.get_data(False)
|
||||
segment.classification_state_changes.append(change)
|
||||
self._publish_segment_update(
|
||||
segment, self.config.cameras[camera], None, [], prev_data
|
||||
)
|
||||
|
||||
def forcibly_end_segment(self, camera: str) -> Any:
|
||||
"""Forcibly end the pending segment for a camera."""
|
||||
segment = self.active_review_segments.get(camera)
|
||||
@@ -389,9 +452,29 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
segment.last_detection_time = now
|
||||
|
||||
prev_data = segment.get_data(False)
|
||||
return self._publish_segment_end(segment, prev_data)
|
||||
end_time = self._publish_segment_end(segment, prev_data)
|
||||
self._publish_pending_detections(segment, None)
|
||||
return end_time
|
||||
return None
|
||||
|
||||
def _publish_pending_detections(
|
||||
self, segment: PendingReviewSegment, ongoing_since: float | None
|
||||
) -> None:
|
||||
"""Publish the detections held while an ended alert was active.
|
||||
|
||||
A detection with activity after ongoing_since stays open, only the
|
||||
latest can. With None every detection is ended, this does not read the
|
||||
camera config since a removed camera is no longer in it.
|
||||
"""
|
||||
for pending in segment.pending_detections:
|
||||
self._activate_segment(pending)
|
||||
self._publish_segment_start(pending)
|
||||
|
||||
if ongoing_since is None or pending.last_detection_time < ongoing_since:
|
||||
self._publish_segment_end(pending, pending.get_data(False))
|
||||
|
||||
segment.pending_detections = []
|
||||
|
||||
def get_manual_event_severity(self, camera: str, label: str) -> SeverityEnum | None:
|
||||
"""Determine the review severity for a manual event label.
|
||||
|
||||
@@ -422,6 +505,56 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
"""Close out a deleted camera's segment so a reused name cannot inherit it."""
|
||||
self.forcibly_end_segment(camera)
|
||||
self.indefinite_events.pop(camera, None)
|
||||
self.recent_classification_state_changes.pop(camera, None)
|
||||
|
||||
def _track_pending_detection(
|
||||
self,
|
||||
segment: PendingReviewSegment,
|
||||
camera_config: CameraConfig,
|
||||
frame_name: str,
|
||||
frame_time: float,
|
||||
objects: list[dict[str, Any]],
|
||||
) -> None:
|
||||
"""Hold detection-level activity seen after an alert's last alert
|
||||
activity, starting a separate detection when the gap since the previous
|
||||
activity exceeds the detection cutoff."""
|
||||
pending = segment.pending_detections[-1] if segment.pending_detections else None
|
||||
|
||||
if pending is None or frame_time > (
|
||||
pending.last_detection_time + camera_config.review.detections.cutoff_time
|
||||
):
|
||||
pending = PendingReviewSegment(
|
||||
segment.camera,
|
||||
frame_time,
|
||||
SeverityEnum.detection,
|
||||
{},
|
||||
sub_labels={},
|
||||
audio=set(),
|
||||
zones=[],
|
||||
)
|
||||
segment.pending_detections.append(pending)
|
||||
|
||||
pending.last_detection_time = frame_time
|
||||
|
||||
for obj in objects:
|
||||
pending.add_object(obj, self.config.all_attributes)
|
||||
|
||||
if len(objects) <= pending.frame_active_count:
|
||||
return
|
||||
|
||||
try:
|
||||
yuv_frame = self.frame_manager.get(
|
||||
frame_name, camera_config.frame_shape_yuv
|
||||
)
|
||||
except FileNotFoundError:
|
||||
return
|
||||
|
||||
if yuv_frame is None:
|
||||
logger.debug(f"Failed to get frame {frame_name} from SHM")
|
||||
return
|
||||
|
||||
pending.update_frame(camera_config, yuv_frame, objects)
|
||||
self.frame_manager.close(frame_name)
|
||||
|
||||
def update_existing_segment(
|
||||
self,
|
||||
@@ -458,6 +591,21 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
should_update_state = True
|
||||
should_update_image = True
|
||||
|
||||
# alert activity resumed, so the pending detection activity
|
||||
# falls within this alert
|
||||
for pending in segment.pending_detections:
|
||||
segment.detections.update(pending.detections)
|
||||
segment.sub_labels.update(pending.sub_labels)
|
||||
|
||||
for zone in pending.zones:
|
||||
if zone not in segment.zones:
|
||||
segment.zones.append(zone)
|
||||
|
||||
Path(pending.frame_path).unlink(missing_ok=True)
|
||||
should_update_state = True
|
||||
|
||||
segment.pending_detections = []
|
||||
|
||||
if activity.has_activity_category(SeverityEnum.detection):
|
||||
if (
|
||||
segment.last_detection_time is None
|
||||
@@ -465,6 +613,8 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
):
|
||||
segment.last_detection_time = frame_time
|
||||
|
||||
pending_objects: list[dict[str, Any]] = []
|
||||
|
||||
for object in activity.get_all_objects():
|
||||
# Alert-level objects should always be added (they extend/upgrade the segment)
|
||||
# Detection-level objects should only be added if:
|
||||
@@ -475,23 +625,22 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
|
||||
if not is_alert_object and segment.severity == SeverityEnum.alert:
|
||||
# This is a detection-level object
|
||||
if (
|
||||
segment.last_alert_time is not None
|
||||
and frame_time > segment.last_alert_time
|
||||
):
|
||||
pending_objects.append(object)
|
||||
|
||||
# Only add if it started during the alert's active period
|
||||
if object["start_time"] > segment.last_alert_time:
|
||||
continue
|
||||
|
||||
if not object["sub_label"]:
|
||||
segment.detections[object["id"]] = object["label"]
|
||||
elif object["sub_label"][0] in self.config.all_attributes:
|
||||
segment.detections[object["id"]] = object["sub_label"][0]
|
||||
else:
|
||||
segment.detections[object["id"]] = f"{object['label']}-verified"
|
||||
segment.sub_labels[object["id"]] = object["sub_label"][0]
|
||||
segment.add_object(object, self.config.all_attributes)
|
||||
|
||||
# keep zones up to date
|
||||
if len(object["current_zones"]) > 0:
|
||||
for zone in object["current_zones"]:
|
||||
if zone not in segment.zones:
|
||||
segment.zones.append(zone)
|
||||
if pending_objects:
|
||||
self._track_pending_detection(
|
||||
segment, camera_config, frame_name, frame_time, pending_objects
|
||||
)
|
||||
|
||||
if len(activity.get_all_objects()) > segment.frame_active_count:
|
||||
should_update_state = True
|
||||
@@ -543,50 +692,28 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
except FileNotFoundError:
|
||||
return
|
||||
|
||||
if (
|
||||
segment.severity == SeverityEnum.alert
|
||||
and segment.last_alert_time is not None
|
||||
and frame_time
|
||||
> (segment.last_alert_time + camera_config.review.alerts.cutoff_time)
|
||||
):
|
||||
needs_new_detection = (
|
||||
segment.last_detection_time > segment.last_alert_time
|
||||
and (
|
||||
segment.last_detection_time
|
||||
+ camera_config.review.detections.cutoff_time
|
||||
)
|
||||
> frame_time
|
||||
)
|
||||
last_detection_time = segment.last_detection_time
|
||||
|
||||
end_time = self._publish_segment_end(segment, prev_data)
|
||||
|
||||
if needs_new_detection:
|
||||
new_detections: dict[str, str] = {}
|
||||
new_zones = set()
|
||||
|
||||
for o in activity.categorized_objects["detections"]:
|
||||
new_detections[o["id"]] = o["label"]
|
||||
new_zones.update(o["current_zones"])
|
||||
|
||||
if new_detections:
|
||||
new_segment = PendingReviewSegment(
|
||||
segment.camera,
|
||||
end_time,
|
||||
SeverityEnum.detection,
|
||||
new_detections,
|
||||
sub_labels={},
|
||||
audio=set(),
|
||||
zones=list(new_zones),
|
||||
)
|
||||
self.active_review_segments[segment.camera] = new_segment
|
||||
self._publish_segment_start(new_segment)
|
||||
new_segment.last_detection_time = last_detection_time
|
||||
elif segment.severity == SeverityEnum.detection and frame_time > (
|
||||
# detection-level activity must not keep an alert open, it continues
|
||||
# in a new detection segment once the alert is cut off
|
||||
if (
|
||||
segment.severity == SeverityEnum.alert
|
||||
and segment.last_alert_time is not None
|
||||
and frame_time
|
||||
> (segment.last_alert_time + camera_config.review.alerts.cutoff_time)
|
||||
):
|
||||
self._publish_segment_end(segment, prev_data)
|
||||
self._publish_pending_detections(
|
||||
segment, frame_time - camera_config.review.detections.cutoff_time
|
||||
)
|
||||
elif (
|
||||
not has_activity
|
||||
and segment.severity == SeverityEnum.detection
|
||||
and frame_time
|
||||
> (
|
||||
segment.last_detection_time
|
||||
+ camera_config.review.detections.cutoff_time
|
||||
):
|
||||
self._publish_segment_end(segment, prev_data)
|
||||
)
|
||||
):
|
||||
self._publish_segment_end(segment, prev_data)
|
||||
|
||||
def check_if_new_segment(
|
||||
self,
|
||||
@@ -639,7 +766,7 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
audio=set(),
|
||||
zones=zones,
|
||||
)
|
||||
self.active_review_segments[camera] = new_segment
|
||||
self._activate_segment(new_segment)
|
||||
|
||||
try:
|
||||
yuv_frame = self.frame_manager.get(
|
||||
@@ -713,6 +840,10 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
|
||||
if camera not in self.indefinite_events:
|
||||
self.indefinite_events[camera] = {}
|
||||
elif topic == DetectionTypeEnum.classification_state.value:
|
||||
(camera, classification_change) = data
|
||||
else:
|
||||
continue
|
||||
|
||||
if camera not in self.config.cameras:
|
||||
continue
|
||||
@@ -723,6 +854,10 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
):
|
||||
continue
|
||||
|
||||
if topic == DetectionTypeEnum.classification_state:
|
||||
self.handle_classification_state_change(camera, classification_change)
|
||||
continue
|
||||
|
||||
current_segment = self.active_review_segments.get(camera)
|
||||
|
||||
# Check if the current segment should be processed based on enabled settings
|
||||
@@ -864,14 +999,16 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
severity = SeverityEnum.detection
|
||||
|
||||
if severity:
|
||||
self.active_review_segments[camera] = PendingReviewSegment(
|
||||
camera,
|
||||
frame_time,
|
||||
severity,
|
||||
{},
|
||||
{},
|
||||
[],
|
||||
detections,
|
||||
self._activate_segment(
|
||||
PendingReviewSegment(
|
||||
camera,
|
||||
frame_time,
|
||||
severity,
|
||||
{},
|
||||
{},
|
||||
[],
|
||||
detections,
|
||||
)
|
||||
)
|
||||
elif topic == DetectionTypeEnum.api:
|
||||
severity = self.get_manual_event_severity(
|
||||
@@ -888,7 +1025,7 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
[],
|
||||
set(),
|
||||
)
|
||||
self.active_review_segments[camera] = api_segment
|
||||
self._activate_segment(api_segment)
|
||||
|
||||
if manual_info["state"] == ManualEventState.start:
|
||||
self.indefinite_events[camera][manual_info["event_id"]] = (
|
||||
@@ -915,7 +1052,7 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
[],
|
||||
set(),
|
||||
)
|
||||
self.active_review_segments[camera] = lpr_segment
|
||||
self._activate_segment(lpr_segment)
|
||||
|
||||
if manual_info["state"] == ManualEventState.start:
|
||||
self.indefinite_events[camera][manual_info["event_id"]] = (
|
||||
|
||||
@@ -256,6 +256,17 @@ class HardwareStats:
|
||||
)
|
||||
self.update_config()
|
||||
|
||||
def set_config(self, config: FrigateConfig) -> None:
|
||||
"""Follow a runtime config swap and recalculate the monitored hardware.
|
||||
|
||||
The camera update subscriber has to follow too, or later camera updates
|
||||
would land on the discarded config.
|
||||
"""
|
||||
self.config = config
|
||||
self._config_subscriber.config = config
|
||||
self._config_subscriber.camera_configs = config.cameras
|
||||
self.update_config()
|
||||
|
||||
def update_config(self) -> None:
|
||||
"""Recalculate all hardware that needs to be monitored from the config."""
|
||||
names = self._scan_ffmpeg() | self._scan_detectors() | self._scan_enrichments()
|
||||
|
||||
+12
-8
@@ -111,15 +111,18 @@ def get_detector_stats(
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
"""Get stats for all detectors, including temperatures based on detector type."""
|
||||
detector_stats: dict[str, dict[str, Any]] = {}
|
||||
detector_type_indices: dict[str, int] = {}
|
||||
# detector type -> device -> index into that type's temperatures
|
||||
device_indices: dict[str, dict[str, int]] = {}
|
||||
|
||||
for name, detector in stats_tracking["detectors"].items():
|
||||
pid = detector.detect_process.pid if detector.detect_process else None
|
||||
detector_type = detector.detector_config.type
|
||||
|
||||
# Keep track of the index for each detector type to match temperatures correctly
|
||||
current_index = detector_type_indices.get(detector_type, 0)
|
||||
detector_type_indices[detector_type] = current_index + 1
|
||||
# temperatures are per physical unit, so a repeated device
|
||||
# ("hailo:PCIe#2", see runner_names) shares its unit's reading
|
||||
device = name.partition("#")[0]
|
||||
type_devices = device_indices.setdefault(detector_type, {})
|
||||
current_index = type_devices.setdefault(device, len(type_devices))
|
||||
|
||||
detector_stat = {
|
||||
"inference_speed": round(detector.avg_inference_speed.value * 1000, 2), # type: ignore[attr-defined]
|
||||
@@ -245,8 +248,9 @@ def stats_snapshot(
|
||||
total_camera_fps = total_process_fps = total_skipped_fps = total_detection_fps = 0
|
||||
|
||||
stats["cameras"] = {}
|
||||
for name, camera_stats in camera_metrics.items():
|
||||
if name not in config.cameras:
|
||||
for name, camera_stats in list(camera_metrics.items()):
|
||||
camera_config = config.cameras.get(name)
|
||||
if camera_config is None:
|
||||
continue
|
||||
|
||||
total_camera_fps += camera_stats.camera_fps.value
|
||||
@@ -263,7 +267,7 @@ def stats_snapshot(
|
||||
# Calculate connection quality based on current state
|
||||
# This is computed at stats-collection time so offline cameras
|
||||
# correctly show as unusable rather than excellent
|
||||
expected_fps = config.cameras[name].detect.fps
|
||||
expected_fps = camera_config.detect.fps
|
||||
current_fps = camera_stats.camera_fps.value
|
||||
reconnects = camera_stats.reconnects_last_hour.value
|
||||
stalls = camera_stats.stalls_last_hour.value
|
||||
@@ -296,7 +300,7 @@ def stats_snapshot(
|
||||
config.cameras[name].enabled,
|
||||
),
|
||||
"detection_fps": round(camera_stats.detection_fps.value, 2),
|
||||
"detection_enabled": config.cameras[name].detect.enabled,
|
||||
"detection_enabled": camera_config.detect.enabled,
|
||||
"pid": pid,
|
||||
"capture_pid": capture_pid,
|
||||
"ffmpeg_pid": ffmpeg_pid,
|
||||
|
||||
@@ -145,7 +145,11 @@ class BaseTestHttp(unittest.TestCase):
|
||||
pass
|
||||
|
||||
def create_app(
|
||||
self, stats=None, event_metadata_publisher=None, notice_registry=None
|
||||
self,
|
||||
stats=None,
|
||||
event_metadata_publisher=None,
|
||||
notice_registry=None,
|
||||
enforce_default_admin=False,
|
||||
):
|
||||
from frigate.api.auth import get_allowed_cameras_for_filter, get_current_user
|
||||
|
||||
@@ -160,7 +164,7 @@ class BaseTestHttp(unittest.TestCase):
|
||||
event_metadata_publisher,
|
||||
None,
|
||||
DebugReplayManager(),
|
||||
enforce_default_admin=False,
|
||||
enforce_default_admin=enforce_default_admin,
|
||||
notice_registry=notice_registry,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import json
|
||||
import os
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import frigate.genai
|
||||
from frigate.config import GenAIProviderEnum
|
||||
from frigate.const import REDACTED_CREDENTIAL_SENTINEL
|
||||
from frigate.const import MODEL_CACHE_DIR, REDACTED_CREDENTIAL_SENTINEL
|
||||
from frigate.genai import GenAIClient
|
||||
from frigate.models import Event, Recordings, ReviewSegment
|
||||
from frigate.stats.emitter import StatsEmitter
|
||||
@@ -47,6 +49,25 @@ class TestHttpApp(BaseTestHttp):
|
||||
assert response.status_code == 200
|
||||
assert response.json()["front_door"]["usage_percent"] == 25.0
|
||||
|
||||
def test_camera_name_collision_keeps_admin_default(self):
|
||||
self.minimal_config["cameras"]["faces"] = self.minimal_config["cameras"].pop(
|
||||
"front_door"
|
||||
)
|
||||
app = super().create_app(enforce_default_admin=True)
|
||||
viewer = {"remote-user": "viewer", "remote-role": "viewer"}
|
||||
|
||||
with AuthTestClient(app) as client:
|
||||
assert client.get("/faces", headers=viewer).status_code == 403
|
||||
assert client.get("/faces").status_code == 200
|
||||
assert (
|
||||
client.post("/faces/train/person/classify", headers=viewer).status_code
|
||||
== 403
|
||||
)
|
||||
|
||||
# Camera routes for the same name stay reachable by viewers
|
||||
response = client.get("/faces/recordings/summary", headers=viewer)
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_config_set_in_memory_replaces_objects_track_list(self):
|
||||
self.minimal_config["cameras"]["front_door"]["objects"] = {
|
||||
"track": ["person", "car"],
|
||||
@@ -90,6 +111,44 @@ class TestHttpApp(BaseTestHttp):
|
||||
mqtt = response.json()["mqtt"]
|
||||
assert mqtt["password"] == REDACTED_CREDENTIAL_SENTINEL
|
||||
|
||||
def test_config_response_keeps_plus_model_reference(self):
|
||||
model_id = "test_plus_reference"
|
||||
model_path = os.path.join(MODEL_CACHE_DIR, model_id)
|
||||
os.makedirs(MODEL_CACHE_DIR, exist_ok=True)
|
||||
|
||||
with open(model_path, "w") as f:
|
||||
f.write("model")
|
||||
|
||||
with open(f"{model_path}.json", "w") as f:
|
||||
json.dump(
|
||||
{
|
||||
"id": model_id,
|
||||
"type": "ssd",
|
||||
"supportedDetectors": ["cpu"],
|
||||
"width": 320,
|
||||
"height": 320,
|
||||
"inputShape": "nhwc",
|
||||
"pixelFormat": "rgb",
|
||||
"labelMap": {"0": "person"},
|
||||
},
|
||||
f,
|
||||
)
|
||||
|
||||
self.addCleanup(os.remove, model_path)
|
||||
self.addCleanup(os.remove, f"{model_path}.json")
|
||||
self.minimal_config["models"] = [
|
||||
{"path": f"plus://{model_id}", "devices": ["cpu"]}
|
||||
]
|
||||
app = super().create_app()
|
||||
|
||||
with AuthTestClient(app) as client:
|
||||
response = client.get("/config")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["models"][0]["path"] == f"plus://{model_id}"
|
||||
|
||||
# detection still loads the resolved cache file
|
||||
assert app.frigate_config.models[0].path == model_path
|
||||
|
||||
####################################################################################################################
|
||||
################################### POST /genai/probe Endpoint ##################################################
|
||||
####################################################################################################################
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
"""Tests for config_set live stream ordering and go2rtc transcode sync."""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
import ruamel.yaml
|
||||
from fastapi import Request
|
||||
|
||||
from frigate.api.auth import get_allowed_cameras_for_filter, get_current_user
|
||||
from frigate.api.fastapi_app import create_fastapi_app
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.config.camera.updater import CameraConfigUpdatePublisher
|
||||
from frigate.models import Event, Recordings, ReviewSegment
|
||||
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
|
||||
from frigate.util.live_streams import transcode_stream_source
|
||||
|
||||
STREAMS_PATH = "cameras.front_door.live.streams"
|
||||
|
||||
|
||||
class TestConfigSetLiveStreams(BaseTestHttp):
|
||||
def setUp(self):
|
||||
super().setUp(models=[Event, Recordings, ReviewSegment])
|
||||
self.minimal_config["go2rtc"] = {
|
||||
"streams": {
|
||||
"front_main": ["rtsp://10.0.0.1:554/main"],
|
||||
"front_sub": ["rtsp://10.0.0.1:554/sub"],
|
||||
}
|
||||
}
|
||||
self.minimal_config["cameras"]["front_door"]["live"] = {
|
||||
"streams": {"Main": "front_main", "Sub": "front_sub"}
|
||||
}
|
||||
|
||||
def _write_config_file(self) -> str:
|
||||
yaml = ruamel.yaml.YAML()
|
||||
f = tempfile.NamedTemporaryFile(mode="w", suffix=".yml", delete=False)
|
||||
yaml.dump(self.minimal_config, f)
|
||||
f.close()
|
||||
self.addCleanup(os.unlink, f.name)
|
||||
return f.name
|
||||
|
||||
def _read_config_file(self, path: str) -> dict:
|
||||
with open(path) as f:
|
||||
return ruamel.yaml.YAML(typ="safe").load(f)
|
||||
|
||||
def _app(self):
|
||||
publisher = Mock(spec=CameraConfigUpdatePublisher)
|
||||
publisher.publisher = MagicMock()
|
||||
app = create_fastapi_app(
|
||||
FrigateConfig(**self.minimal_config),
|
||||
self.db,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
publisher,
|
||||
None,
|
||||
enforce_default_admin=False,
|
||||
)
|
||||
|
||||
async def mock_get_current_user(request: Request):
|
||||
return {
|
||||
"username": request.headers.get("remote-user"),
|
||||
"role": request.headers.get("remote-role"),
|
||||
}
|
||||
|
||||
async def mock_get_allowed_cameras_for_filter(request: Request):
|
||||
return ["front_door"]
|
||||
|
||||
app.dependency_overrides[get_current_user] = mock_get_current_user
|
||||
app.dependency_overrides[get_allowed_cameras_for_filter] = (
|
||||
mock_get_allowed_cameras_for_filter
|
||||
)
|
||||
return app
|
||||
|
||||
def _save(self, app, live: dict, replace_paths: list[str] | None = None):
|
||||
body = {
|
||||
"config_data": {"cameras": {"front_door": {"live": live}}},
|
||||
"requires_restart": 0,
|
||||
"update_topic": "config/cameras/front_door/live",
|
||||
}
|
||||
|
||||
if replace_paths is not None:
|
||||
body["replace_paths"] = replace_paths
|
||||
|
||||
with AuthTestClient(app) as client:
|
||||
return client.put("/config/set", json=body)
|
||||
|
||||
@patch("frigate.api.app.find_config_file")
|
||||
def test_replace_paths_saves_map_in_sent_order(self, mock_find_config):
|
||||
path = self._write_config_file()
|
||||
mock_find_config.return_value = path
|
||||
|
||||
resp = self._save(
|
||||
self._app(),
|
||||
{"streams": {"Sub": "front_sub", "Main": "front_main"}},
|
||||
[STREAMS_PATH],
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
streams = self._read_config_file(path)["cameras"]["front_door"]["live"][
|
||||
"streams"
|
||||
]
|
||||
self.assertEqual(list(streams), ["Sub", "Main"])
|
||||
|
||||
@patch("frigate.api.app.find_config_file")
|
||||
def test_without_replace_paths_order_is_kept(self, mock_find_config):
|
||||
path = self._write_config_file()
|
||||
mock_find_config.return_value = path
|
||||
|
||||
self._save(self._app(), {"streams": {"Sub": "front_sub", "Main": "front_main"}})
|
||||
|
||||
streams = self._read_config_file(path)["cameras"]["front_door"]["live"][
|
||||
"streams"
|
||||
]
|
||||
self.assertEqual(list(streams), ["Main", "Sub"])
|
||||
|
||||
@patch("frigate.api.app.find_config_file")
|
||||
def test_replace_path_missing_from_yaml_is_skipped(self, mock_find_config):
|
||||
del self.minimal_config["cameras"]["front_door"]["live"]
|
||||
path = self._write_config_file()
|
||||
mock_find_config.return_value = path
|
||||
|
||||
resp = self._save(
|
||||
self._app(), {"streams": {"Main": "front_main"}}, [STREAMS_PATH]
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertEqual(
|
||||
self._read_config_file(path)["cameras"]["front_door"]["live"]["streams"],
|
||||
{"Main": "front_main"},
|
||||
)
|
||||
|
||||
@patch("frigate.api.app.sync_transcode_streams", return_value=True)
|
||||
@patch("frigate.api.app.find_config_file")
|
||||
def test_enabling_transcode_syncs_go2rtc(self, mock_find_config, mock_sync):
|
||||
path = self._write_config_file()
|
||||
mock_find_config.return_value = path
|
||||
|
||||
resp = self._save(
|
||||
self._app(),
|
||||
{
|
||||
"transcode": {
|
||||
"enabled": True,
|
||||
"qualities": [{"height": 480, "bitrate": 500}],
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertTrue(resp.json()["go2rtc_synced"])
|
||||
mock_sync.assert_called_once_with(
|
||||
{},
|
||||
{
|
||||
"front_door_transcode_480p": transcode_stream_source(
|
||||
"front_main", 480, 500
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
@patch("frigate.api.app.sync_transcode_streams", return_value=False)
|
||||
@patch("frigate.api.app.find_config_file")
|
||||
def test_sync_failure_is_reported(self, mock_find_config, mock_sync):
|
||||
path = self._write_config_file()
|
||||
mock_find_config.return_value = path
|
||||
|
||||
resp = self._save(self._app(), {"transcode": {"enabled": True}})
|
||||
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertTrue(resp.json()["success"])
|
||||
self.assertFalse(resp.json()["go2rtc_synced"])
|
||||
@@ -168,6 +168,29 @@ class TestHttpApp(BaseTestHttp):
|
||||
assert events[0]["id"] == id
|
||||
assert events[1]["id"] == id2
|
||||
|
||||
def test_get_event_list_offset_pages_score_sort(self):
|
||||
now = datetime.now().timestamp()
|
||||
scores = [0.6, 0.9, 0.7, 0.95, 0.8]
|
||||
|
||||
with AuthTestClient(self.app) as client:
|
||||
for i, score in enumerate(scores):
|
||||
super().insert_mock_event(
|
||||
f"event-{i}", start_time=now + i, data={"score": score}
|
||||
)
|
||||
|
||||
params = {"sort": "score_desc"}
|
||||
full = [e["id"] for e in client.get("/events", params=params).json()]
|
||||
paged = [
|
||||
e["id"]
|
||||
for offset in (0, 2, 4)
|
||||
for e in client.get(
|
||||
"/events", params={**params, "limit": 2, "offset": offset}
|
||||
).json()
|
||||
]
|
||||
|
||||
assert full == ["event-3", "event-1", "event-4", "event-2", "event-0"]
|
||||
assert paged == full
|
||||
|
||||
def test_get_event_list_match_multilingual_attribute(self):
|
||||
event_id = "123456.zh"
|
||||
attribute = "中文标签"
|
||||
@@ -219,6 +242,85 @@ class TestHttpApp(BaseTestHttp):
|
||||
assert len(events) == 1
|
||||
assert events[0]["id"] == event_id
|
||||
|
||||
def test_events_search_offset_pages_score_sort(self):
|
||||
now = datetime.now().timestamp()
|
||||
scores = [0.6, 0.9, 0.7, 0.95, 0.8]
|
||||
ids = [f"event-{i}" for i in range(len(scores))]
|
||||
mock_embeddings = Mock()
|
||||
mock_embeddings.search_thumbnail.return_value = [
|
||||
(event_id, 0.1 * i) for i, event_id in enumerate(ids)
|
||||
]
|
||||
|
||||
self.app.frigate_config.semantic_search.enabled = True
|
||||
self.app.embeddings = mock_embeddings
|
||||
|
||||
with AuthTestClient(self.app) as client:
|
||||
for i, score in enumerate(scores):
|
||||
super().insert_mock_event(
|
||||
ids[i], start_time=now + i, data={"score": score}
|
||||
)
|
||||
|
||||
params = {
|
||||
"search_type": "similarity",
|
||||
"event_id": ids[0],
|
||||
"sort": "score_desc",
|
||||
}
|
||||
paged = [
|
||||
e["id"]
|
||||
for offset in (0, 2, 4)
|
||||
for e in client.get(
|
||||
"/events/search",
|
||||
params={**params, "limit": 2, "offset": offset},
|
||||
).json()
|
||||
]
|
||||
|
||||
assert paged == ["event-3", "event-1", "event-4", "event-2", "event-0"]
|
||||
|
||||
def test_events_search_offset_pages_orders_ties_by_id(self):
|
||||
now = datetime.now().timestamp()
|
||||
ids = ["event-c", "event-a", "event-b"]
|
||||
mock_embeddings = Mock()
|
||||
mock_embeddings.search_thumbnail.return_value = [
|
||||
(event_id, 0.1) for event_id in ids
|
||||
]
|
||||
|
||||
self.app.frigate_config.semantic_search.enabled = True
|
||||
self.app.embeddings = mock_embeddings
|
||||
|
||||
with AuthTestClient(self.app) as client:
|
||||
for i, event_id in enumerate(ids):
|
||||
super().insert_mock_event(
|
||||
event_id, start_time=now + i, data={"score": 0.8}
|
||||
)
|
||||
|
||||
for sort in ("score_desc", "relevance"):
|
||||
params = {
|
||||
"search_type": "similarity",
|
||||
"event_id": ids[0],
|
||||
"sort": sort,
|
||||
}
|
||||
paged = [
|
||||
e["id"]
|
||||
for offset in (0, 1, 2)
|
||||
for e in client.get(
|
||||
"/events/search",
|
||||
params={**params, "limit": 1, "offset": offset},
|
||||
).json()
|
||||
]
|
||||
|
||||
assert paged == ["event-a", "event-b", "event-c"]
|
||||
|
||||
def test_event_list_rejects_negative_offset(self):
|
||||
with AuthTestClient(self.app) as client:
|
||||
response = client.get("/events", params={"offset": -5})
|
||||
assert response.status_code == 422
|
||||
|
||||
response = client.get(
|
||||
"/events/search",
|
||||
params={"query": "car", "offset": -5},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
def test_similarity_search_hides_unauthorized_anchor_event(self):
|
||||
mock_embeddings = Mock()
|
||||
self.app.frigate_config.semantic_search.enabled = True
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Tests for the go2rtc stream bitrate endpoint."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
|
||||
|
||||
|
||||
class TestHttpGo2rtcBitrate(BaseTestHttp):
|
||||
def setUp(self):
|
||||
super().setUp([])
|
||||
self.minimal_config["go2rtc"] = {
|
||||
"streams": {"front_main": ["rtsp://10.0.0.1:554/main"]}
|
||||
}
|
||||
self.minimal_config["cameras"]["front_door"]["live"] = {
|
||||
"streams": {"Main": "front_main"},
|
||||
"transcode": {
|
||||
"enabled": True,
|
||||
"qualities": [{"height": 480, "bitrate": 500}],
|
||||
},
|
||||
}
|
||||
self.app = self.create_app()
|
||||
|
||||
@patch("frigate.api.camera.measure_stream_bitrate", return_value=812.4)
|
||||
def test_returns_rounded_kbps(self, mock_measure):
|
||||
with AuthTestClient(self.app) as client:
|
||||
resp = client.get("/go2rtc/streams/front_main/bitrate")
|
||||
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertEqual(resp.json()["kbps"], 812)
|
||||
mock_measure.assert_called_once_with("front_main")
|
||||
|
||||
@patch("frigate.api.camera.measure_stream_bitrate", return_value=480.0)
|
||||
def test_generated_streams_are_known(self, _):
|
||||
with AuthTestClient(self.app) as client:
|
||||
resp = client.get("/go2rtc/streams/front_door_transcode_480p/bitrate")
|
||||
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
@patch("frigate.api.camera.measure_stream_bitrate")
|
||||
def test_unknown_stream_is_404(self, mock_measure):
|
||||
with AuthTestClient(self.app) as client:
|
||||
resp = client.get("/go2rtc/streams/rtsp_somewhere/bitrate")
|
||||
|
||||
self.assertEqual(resp.status_code, 404)
|
||||
mock_measure.assert_not_called()
|
||||
|
||||
@patch("frigate.api.camera.measure_stream_bitrate", return_value=None)
|
||||
def test_no_data_is_502(self, _):
|
||||
with AuthTestClient(self.app) as client:
|
||||
resp = client.get("/go2rtc/streams/front_main/bitrate")
|
||||
|
||||
self.assertEqual(resp.status_code, 502)
|
||||
|
||||
def test_viewer_is_forbidden(self):
|
||||
with AuthTestClient(self.app) as client:
|
||||
resp = client.get(
|
||||
"/go2rtc/streams/front_main/bitrate",
|
||||
headers={"remote-user": "viewer", "remote-role": "viewer"},
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status_code, 403)
|
||||
@@ -240,9 +240,101 @@ class TestHttpReview(BaseTestHttp):
|
||||
assert len(response_json) == 1
|
||||
assert response_json[0]["id"] == id_reviewed
|
||||
|
||||
def test_get_review_with_label_filter_matches_verified(self):
|
||||
"""Test that a label filter also matches the `-verified` variant."""
|
||||
now = datetime.now().timestamp()
|
||||
|
||||
with AuthTestClient(self.app) as client:
|
||||
super().insert_mock_review_segment(
|
||||
"123456.person", now, now + 2, data={"objects": ["person"]}
|
||||
)
|
||||
super().insert_mock_review_segment(
|
||||
"123456.verified", now, now + 2, data={"objects": ["person-verified"]}
|
||||
)
|
||||
super().insert_mock_review_segment(
|
||||
"123456.car", now, now + 2, data={"objects": ["car"]}
|
||||
)
|
||||
|
||||
params = {
|
||||
"labels": "person",
|
||||
"after": now - 1,
|
||||
"before": now + 3,
|
||||
}
|
||||
response = client.get("/review", params=params)
|
||||
assert response.status_code == 200
|
||||
response_json = response.json()
|
||||
assert {r["id"] for r in response_json} == {
|
||||
"123456.person",
|
||||
"123456.verified",
|
||||
}
|
||||
|
||||
def test_get_review_with_label_filter_does_not_match_prefix(self):
|
||||
"""Test that a label filter does not match labels that only share a prefix."""
|
||||
now = datetime.now().timestamp()
|
||||
|
||||
with AuthTestClient(self.app) as client:
|
||||
super().insert_mock_review_segment(
|
||||
"123456.carrot", now, now + 2, data={"objects": ["carrot"]}
|
||||
)
|
||||
|
||||
params = {
|
||||
"labels": "car",
|
||||
"after": now - 1,
|
||||
"before": now + 3,
|
||||
}
|
||||
response = client.get("/review", params=params)
|
||||
assert response.status_code == 200
|
||||
assert len(response.json()) == 0
|
||||
|
||||
def test_get_review_with_audio_label_filter(self):
|
||||
"""Test that a label filter still matches audio labels."""
|
||||
now = datetime.now().timestamp()
|
||||
|
||||
with AuthTestClient(self.app) as client:
|
||||
super().insert_mock_review_segment(
|
||||
"123456.audio", now, now + 2, data={"audio": ["speech"]}
|
||||
)
|
||||
|
||||
params = {
|
||||
"labels": "speech",
|
||||
"after": now - 1,
|
||||
"before": now + 3,
|
||||
}
|
||||
response = client.get("/review", params=params)
|
||||
assert response.status_code == 200
|
||||
response_json = response.json()
|
||||
assert len(response_json) == 1
|
||||
assert response_json[0]["id"] == "123456.audio"
|
||||
|
||||
####################################################################################################################
|
||||
################################### GET /review/summary Endpoint #################################################
|
||||
####################################################################################################################
|
||||
def test_get_review_summary_label_filter_matches_verified(self):
|
||||
"""Test that the summary label filter also matches the `-verified` variant."""
|
||||
with AuthTestClient(self.app) as client:
|
||||
super().insert_mock_review_segment(
|
||||
"123456.verified", data={"objects": ["person-verified"]}
|
||||
)
|
||||
super().insert_mock_review_segment(
|
||||
"123456.car", data={"objects": ["car"]}, severity=SeverityEnum.detection
|
||||
)
|
||||
|
||||
params = {
|
||||
"cameras": "front_door",
|
||||
"labels": "person",
|
||||
"zones": "all",
|
||||
"timezone": "utc",
|
||||
}
|
||||
response = client.get("/review/summary", params=params)
|
||||
assert response.status_code == 200
|
||||
response_json = response.json()
|
||||
assert response_json["last24Hours"]["total_alert"] == 1
|
||||
assert response_json["last24Hours"]["total_detection"] == 0
|
||||
|
||||
today_formatted = datetime.today().strftime("%Y-%m-%d")
|
||||
assert response_json[today_formatted]["total_alert"] == 1
|
||||
assert response_json[today_formatted]["total_detection"] == 0
|
||||
|
||||
def test_get_review_summary_all_filters(self):
|
||||
with AuthTestClient(self.app) as client:
|
||||
super().insert_mock_review_segment("123456.random")
|
||||
|
||||
@@ -9,7 +9,6 @@ from frigate.config.camera.updater import (
|
||||
CameraConfigUpdateSubscriber,
|
||||
)
|
||||
from frigate.const import SUB_CACHE_TAG
|
||||
from frigate.detectors.detector_config import SceneEnum
|
||||
|
||||
|
||||
def _build_scene_frigate_config(scene: str | None) -> FrigateConfig:
|
||||
@@ -119,7 +118,7 @@ class TestRecordUpdateRecreatesFfmpegCmds(unittest.TestCase):
|
||||
subscriber = CameraConfigUpdateSubscriber(
|
||||
config, {}, [CameraConfigUpdateEnum.add, CameraConfigUpdateEnum.remove]
|
||||
)
|
||||
assert config.model_for_camera("front_door").scene == SceneEnum.outdoor
|
||||
assert config.model_for_camera("front_door").scene == "outdoor"
|
||||
|
||||
subscriber.subscriber.check_for_update.side_effect = [
|
||||
("config/cameras/front_door/remove", config.cameras["front_door"]),
|
||||
@@ -136,7 +135,7 @@ class TestRecordUpdateRecreatesFfmpegCmds(unittest.TestCase):
|
||||
]
|
||||
subscriber.check_for_updates()
|
||||
|
||||
assert config.model_for_camera("front_door").scene == SceneEnum.all
|
||||
assert config.model_for_camera("front_door").scene == "default"
|
||||
|
||||
def test_unchanged_record_update_keeps_existing_cmds(self):
|
||||
camera_config = _build_camera_config(sub_enabled=False)
|
||||
|
||||
@@ -179,12 +179,16 @@ class TestReviewMaintainerRemoval(unittest.TestCase):
|
||||
maintainer = ReviewSegmentMaintainer.__new__(ReviewSegmentMaintainer)
|
||||
maintainer.active_review_segments = {"deleted_cam": MagicMock()}
|
||||
maintainer.indefinite_events = {"deleted_cam": {"1234.5-abcdef": 1.0}}
|
||||
maintainer.recent_classification_state_changes = {
|
||||
"deleted_cam": [{"model": "gate", "from": "a", "to": "b", "timestamp": 1.0}]
|
||||
}
|
||||
maintainer.forcibly_end_segment = MagicMock()
|
||||
|
||||
maintainer._handle_camera_removed("deleted_cam")
|
||||
|
||||
maintainer.forcibly_end_segment.assert_called_once_with("deleted_cam")
|
||||
self.assertNotIn("deleted_cam", maintainer.indefinite_events)
|
||||
self.assertNotIn("deleted_cam", maintainer.recent_classification_state_changes)
|
||||
|
||||
|
||||
class TestAutotrackerMoveQueue(unittest.TestCase):
|
||||
|
||||
+125
-7
@@ -1,5 +1,6 @@
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from copy import deepcopy
|
||||
from unittest.mock import patch
|
||||
@@ -11,7 +12,6 @@ from ruamel.yaml.constructor import DuplicateKeyError
|
||||
from frigate.config import BirdseyeModeEnum, FrigateConfig, RetainModeEnum
|
||||
from frigate.const import MODEL_CACHE_DIR
|
||||
from frigate.detectors import DetectorTypeEnum
|
||||
from frigate.detectors.detector_config import SceneEnum
|
||||
from frigate.detectors.device import build_detector_config, runner_names
|
||||
from frigate.util.builtin import deep_merge
|
||||
|
||||
@@ -68,7 +68,7 @@ class TestConfig(unittest.TestCase):
|
||||
def test_config_class(self):
|
||||
frigate_config = FrigateConfig(**self.minimal)
|
||||
model = frigate_config.primary_model
|
||||
assert model.scene == SceneEnum.all
|
||||
assert model.scene == "default"
|
||||
assert model.width == 320
|
||||
assert frigate_config.devices_for_model(model)[0].detector == (
|
||||
DetectorTypeEnum.cpu
|
||||
@@ -140,8 +140,8 @@ class TestConfig(unittest.TestCase):
|
||||
|
||||
frigate_config = FrigateConfig(**(deep_merge(config, self.minimal)))
|
||||
|
||||
assert frigate_config.model_for_camera("back").scene == SceneEnum.outdoor
|
||||
assert frigate_config.model_for_camera("front").scene == SceneEnum.indoor
|
||||
assert frigate_config.model_for_camera("back").scene == "outdoor"
|
||||
assert frigate_config.model_for_camera("front").scene == "indoor"
|
||||
assert frigate_config.model_for_camera("back").width == 320
|
||||
assert frigate_config.model_for_camera("front").width == 300
|
||||
|
||||
@@ -177,7 +177,7 @@ class TestConfig(unittest.TestCase):
|
||||
|
||||
frigate_config = FrigateConfig(**(deep_merge(config, self.minimal)))
|
||||
|
||||
assert frigate_config.model_for_camera("back").scene == SceneEnum.all
|
||||
assert frigate_config.model_for_camera("back").scene == "default"
|
||||
|
||||
@patch("frigate.detectors.detector_config.load_labels")
|
||||
def test_model_for_camera_resolves_camera_added_after_parse(self, mock_labels):
|
||||
@@ -205,7 +205,7 @@ class TestConfig(unittest.TestCase):
|
||||
new_config = FrigateConfig(**(deep_merge(deepcopy(config), added)))
|
||||
frigate_config.cameras["new_cam"] = new_config.cameras["new_cam"]
|
||||
|
||||
assert frigate_config.model_for_camera("new_cam").scene == SceneEnum.outdoor
|
||||
assert frigate_config.model_for_camera("new_cam").scene == "outdoor"
|
||||
assert frigate_config.model_for_camera("new_cam").width == 416
|
||||
|
||||
@patch("frigate.detectors.detector_config.load_labels")
|
||||
@@ -221,7 +221,7 @@ class TestConfig(unittest.TestCase):
|
||||
frigate_config = FrigateConfig(**(deep_merge(deepcopy(config), self.minimal)))
|
||||
|
||||
# a caller racing a runtime remove may still name the popped camera
|
||||
assert frigate_config.model_for_camera("removed").scene == SceneEnum.all
|
||||
assert frigate_config.model_for_camera("removed").scene == "default"
|
||||
|
||||
@patch("frigate.detectors.detector_config.load_labels")
|
||||
def test_camera_scene_without_a_model_or_a_default(self, mock_labels):
|
||||
@@ -256,6 +256,124 @@ class TestConfig(unittest.TestCase):
|
||||
with self.assertRaises(ValidationError):
|
||||
FrigateConfig(**(deep_merge(config, self.minimal)))
|
||||
|
||||
@patch("frigate.detectors.detector_config.load_labels")
|
||||
def test_scene_names_are_not_a_fixed_list(self, mock_labels):
|
||||
mock_labels.return_value = {}
|
||||
config = {
|
||||
"models": [
|
||||
{"devices": ["cpu"]},
|
||||
{"scene": "garage_thermal", "devices": ["openvino:CPU"]},
|
||||
],
|
||||
"cameras": {"back": {"detect": {"scene": "garage_thermal"}}},
|
||||
}
|
||||
|
||||
frigate_config = FrigateConfig(**(deep_merge(config, self.minimal)))
|
||||
|
||||
assert frigate_config.model_for_camera("back").scene == "garage_thermal"
|
||||
|
||||
@patch("frigate.detectors.detector_config.load_labels")
|
||||
def test_scene_names_must_be_simple_identifiers(self, mock_labels):
|
||||
mock_labels.return_value = {}
|
||||
config = {"models": [{"scene": "front yard", "devices": ["cpu"]}]}
|
||||
|
||||
with self.assertRaises(ValidationError):
|
||||
FrigateConfig(**(deep_merge(config, self.minimal)))
|
||||
|
||||
def _two_scene_config(self, default_path: str, outdoor_path: str) -> dict:
|
||||
return {
|
||||
"models": [
|
||||
{"path": default_path, "devices": ["openvino:CPU"]},
|
||||
{"scene": "outdoor", "path": outdoor_path, "devices": ["openvino:GPU"]},
|
||||
],
|
||||
"cameras": {"back": {"detect": {"scene": "outdoor"}}},
|
||||
}
|
||||
|
||||
@patch("frigate.detectors.detector_config.load_labels")
|
||||
def test_models_with_the_same_path_are_combined(self, mock_labels):
|
||||
mock_labels.return_value = {}
|
||||
config = self._two_scene_config("/etc/hosts", "/etc/hosts")
|
||||
|
||||
with self.assertLogs("frigate.config.config", level="WARNING") as logs:
|
||||
frigate_config = FrigateConfig(**(deep_merge(config, self.minimal)))
|
||||
|
||||
assert len(frigate_config.models) == 1
|
||||
model = frigate_config.primary_model
|
||||
assert model.devices == ["openvino:CPU", "openvino:GPU"]
|
||||
assert [d.raw for d in frigate_config.devices_for_model(model)] == [
|
||||
"openvino:CPU",
|
||||
"openvino:GPU",
|
||||
]
|
||||
assert frigate_config.model_for_camera("back") is model
|
||||
assert frigate_config.cameras["back"].detect.scene == "default"
|
||||
assert any("same model file" in line for line in logs.output)
|
||||
|
||||
@patch("frigate.detectors.detector_config.load_labels")
|
||||
def test_models_with_the_same_file_contents_are_combined(self, mock_labels):
|
||||
mock_labels.return_value = {}
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
first = os.path.join(temp_dir, "model.onnx")
|
||||
copy = os.path.join(temp_dir, "renamed.onnx")
|
||||
|
||||
for path in (first, copy):
|
||||
with open(path, "wb") as f:
|
||||
f.write(b"same weights")
|
||||
|
||||
config = self._two_scene_config(first, copy)
|
||||
frigate_config = FrigateConfig(**(deep_merge(config, self.minimal)))
|
||||
|
||||
assert len(frigate_config.models) == 1
|
||||
assert frigate_config.model_for_camera("back").scene == "default"
|
||||
|
||||
@patch("frigate.detectors.detector_config.load_labels")
|
||||
def test_models_with_different_files_are_kept_apart(self, mock_labels):
|
||||
mock_labels.return_value = {}
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
first = os.path.join(temp_dir, "model.onnx")
|
||||
other = os.path.join(temp_dir, "thermal.onnx")
|
||||
|
||||
for path, contents in ((first, b"visible"), (other, b"thermal")):
|
||||
with open(path, "wb") as f:
|
||||
f.write(contents)
|
||||
|
||||
config = self._two_scene_config(first, other)
|
||||
frigate_config = FrigateConfig(**(deep_merge(config, self.minimal)))
|
||||
|
||||
assert len(frigate_config.models) == 2
|
||||
assert frigate_config.model_for_camera("back").scene == "outdoor"
|
||||
|
||||
@patch("frigate.detectors.detector_config.load_labels")
|
||||
def test_combined_models_keep_the_default_model(self, mock_labels):
|
||||
mock_labels.return_value = {}
|
||||
config = {
|
||||
"models": [
|
||||
{"scene": "outdoor", "path": "/etc/hosts", "devices": ["openvino:GPU"]},
|
||||
{"path": "/etc/hosts", "devices": ["openvino:CPU"]},
|
||||
],
|
||||
"cameras": {"back": {"detect": {"scene": "outdoor"}}},
|
||||
}
|
||||
|
||||
frigate_config = FrigateConfig(**(deep_merge(config, self.minimal)))
|
||||
|
||||
assert [model.scene for model in frigate_config.models] == ["default"]
|
||||
assert frigate_config.primary_model.devices == ["openvino:CPU", "openvino:GPU"]
|
||||
assert frigate_config.model_for_camera("back").scene == "default"
|
||||
|
||||
@patch("frigate.detectors.detector_config.load_labels")
|
||||
def test_models_on_different_detectors_are_kept_apart(self, mock_labels):
|
||||
mock_labels.return_value = {}
|
||||
config = {
|
||||
"models": [
|
||||
{"path": "/etc/hosts", "devices": ["openvino:CPU"]},
|
||||
{"scene": "outdoor", "path": "/etc/hosts", "devices": ["onnx"]},
|
||||
],
|
||||
}
|
||||
|
||||
frigate_config = FrigateConfig(**(deep_merge(config, self.minimal)))
|
||||
|
||||
assert len(frigate_config.models) == 2
|
||||
|
||||
@patch("frigate.detectors.detector_config.load_labels")
|
||||
def test_model_devices_must_share_a_detector(self, mock_labels):
|
||||
mock_labels.return_value = {}
|
||||
|
||||
@@ -20,7 +20,7 @@ class TestMigrateModels(unittest.TestCase):
|
||||
def test_single_cpu_detector(self):
|
||||
migrated = migrate_models({"detectors": {"cpu": {"type": "cpu"}}})
|
||||
|
||||
self.assertEqual(migrated["models"], [{"scene": "all", "devices": ["cpu"]}])
|
||||
self.assertEqual(migrated["models"], [{"scene": "default", "devices": ["cpu"]}])
|
||||
self.assertNotIn("detectors", migrated)
|
||||
|
||||
def test_model_settings_are_carried_over(self):
|
||||
@@ -35,7 +35,7 @@ class TestMigrateModels(unittest.TestCase):
|
||||
migrated["models"],
|
||||
[
|
||||
{
|
||||
"scene": "all",
|
||||
"scene": "default",
|
||||
"path": "plus://abc",
|
||||
"width": 320,
|
||||
"devices": ["edgetpu:pci:0"],
|
||||
@@ -257,12 +257,37 @@ class TestMigrateConfigFile(unittest.TestCase):
|
||||
self.assertEqual(migrated["models"][0]["devices"], ["openvino:GPU"])
|
||||
self.assertNotIn("detectors", migrated)
|
||||
|
||||
def test_top_level_changes_survive_later_steps(self):
|
||||
# 0.16 adds detect and 0.17 splits genai, both at the top level
|
||||
migrated = self._migrate(
|
||||
"mqtt:\n"
|
||||
" enabled: false\n"
|
||||
"genai:\n"
|
||||
" provider: ollama\n"
|
||||
" model: llava\n"
|
||||
" prompt: describe it\n"
|
||||
"cameras: {}\n"
|
||||
"version: 0.15-1\n"
|
||||
)
|
||||
|
||||
self.assertEqual(migrated["version"], CURRENT_CONFIG_VERSION)
|
||||
self.assertTrue(migrated["detect"]["enabled"])
|
||||
self.assertEqual(migrated["objects"]["genai"], {"prompt": "describe it"})
|
||||
self.assertEqual(
|
||||
migrated["genai"]["default"],
|
||||
{
|
||||
"provider": "ollama",
|
||||
"model": "llava",
|
||||
"roles": ["descriptions", "chat"],
|
||||
},
|
||||
)
|
||||
|
||||
def test_a_migrated_config_is_left_alone(self):
|
||||
migrated = self._migrate(
|
||||
"mqtt:\n"
|
||||
" enabled: false\n"
|
||||
"models:\n"
|
||||
" - scene: all\n"
|
||||
" - scene: default\n"
|
||||
" devices:\n"
|
||||
" - openvino:GPU\n"
|
||||
"cameras: {}\n"
|
||||
@@ -270,7 +295,7 @@ class TestMigrateConfigFile(unittest.TestCase):
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
migrated["models"], [{"scene": "all", "devices": ["openvino:GPU"]}]
|
||||
migrated["models"], [{"scene": "default", "devices": ["openvino:GPU"]}]
|
||||
)
|
||||
self.assertFalse(
|
||||
os.path.exists(os.path.join(self.temp_dir.name, "backup_config.yaml"))
|
||||
@@ -298,7 +323,7 @@ class TestMigrateConfigFile(unittest.TestCase):
|
||||
"mqtt:\n"
|
||||
" enabled: false\n"
|
||||
"models:\n"
|
||||
" - scene: all\n"
|
||||
" - scene: default\n"
|
||||
" devices:\n"
|
||||
" - hailo8l:PCIe\n"
|
||||
"cameras: {}\n"
|
||||
|
||||
@@ -26,6 +26,7 @@ class TestSwapRuntimeConfig(unittest.TestCase):
|
||||
app.genai_manager.update_config.assert_called_once_with(config)
|
||||
app.profile_manager.update_config.assert_called_once_with(config)
|
||||
self.assertIs(app.stats_emitter.config, config)
|
||||
app.stats_emitter.hardware_stats.set_config.assert_called_once_with(config)
|
||||
self.assertIs(app.dispatcher.config, config)
|
||||
for comm in app.dispatcher.comms:
|
||||
self.assertIs(comm.config, config)
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Tests for per-detector stats."""
|
||||
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from frigate.stats.util import get_detector_stats
|
||||
|
||||
|
||||
def _detector(detector_type: str) -> MagicMock:
|
||||
detector = MagicMock()
|
||||
detector.detector_config.type = detector_type
|
||||
detector.avg_inference_speed.value = 0.01
|
||||
detector.detection_start.value = 0.0
|
||||
detector.detect_process.pid = 1
|
||||
return detector
|
||||
|
||||
|
||||
class TestDetectorTemperatures(unittest.TestCase):
|
||||
def test_repeated_device_shares_its_unit_temperature(self):
|
||||
stats_tracking = {
|
||||
"detectors": {
|
||||
"hailo:PCIe": _detector("hailo8l"),
|
||||
"hailo:PCIe#2": _detector("hailo8l"),
|
||||
"hailo:PCIe:1": _detector("hailo8l"),
|
||||
}
|
||||
}
|
||||
|
||||
with patch(
|
||||
"frigate.stats.util.get_hardware_temperatures", return_value=[50.0, 60.0]
|
||||
):
|
||||
stats = get_detector_stats(stats_tracking)
|
||||
|
||||
self.assertEqual(stats["hailo:PCIe"]["temperature"], 50.0)
|
||||
self.assertEqual(stats["hailo:PCIe#2"]["temperature"], 50.0)
|
||||
self.assertEqual(stats["hailo:PCIe:1"]["temperature"], 60.0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -543,6 +543,56 @@ class TestPinnedStream(unittest.TestCase):
|
||||
self.assertFalse(any("/vod/front/main/" in token for token in cmd))
|
||||
|
||||
|
||||
class TestExportTimelineAlignment(unittest.TestCase):
|
||||
def test_unstaged_auto_reads_the_stream_it_serves(self) -> None:
|
||||
# a sub-only range whose main rows are glitches the manifest drops
|
||||
exporter = _make_exporter([_span("/s1.mp4", 1_000, 1_040, False)], {"h264"})
|
||||
streams: list[str] = []
|
||||
|
||||
def rows(stream: str) -> list:
|
||||
streams.append(stream)
|
||||
return [_FakeRow(f"/{stream}.mp4")]
|
||||
|
||||
exporter._get_recordings_for_range = rows # type: ignore[method-assign]
|
||||
exporter.get_record_export_command("/exports/out.mp4")
|
||||
|
||||
self.assertEqual(streams, ["sub"])
|
||||
|
||||
def test_staged_chapters_carry_keyframe_lead_in(self) -> None:
|
||||
exporter = _make_exporter(
|
||||
[
|
||||
_span("/m1.mp4", 1_000, 1_020, True),
|
||||
_span("/s1.mp4", 1_020, 1_040, False),
|
||||
],
|
||||
{"h264"},
|
||||
)
|
||||
exporter.config.ui.timezone = None
|
||||
|
||||
# each run's vod clip snaps 1.5s back to a keyframe
|
||||
def timeline(_intervals: list, stream: str) -> list[dict]:
|
||||
start, end = (1_000, 1_020) if stream == "main" else (1_020, 1_040)
|
||||
return [
|
||||
{
|
||||
"start_time": start,
|
||||
"end_time": end,
|
||||
"duration": (end - start + 1.5) * 1000,
|
||||
}
|
||||
]
|
||||
|
||||
with (
|
||||
patch("frigate.record.export.resolve_coverage", return_value=[]),
|
||||
patch("frigate.record.export.realized_timeline", side_effect=timeline),
|
||||
):
|
||||
windows = exporter._staged_chapter_windows()
|
||||
|
||||
path = exporter._build_recording_segment_chapter_metadata_file(windows)
|
||||
self.addCleanup(os.remove, path)
|
||||
content = Path(path).read_text()
|
||||
|
||||
self.assertIn("START=0\nEND=21500", content)
|
||||
self.assertIn("START=21500\nEND=43000", content)
|
||||
|
||||
|
||||
class TestStagedFileCleanup(unittest.TestCase):
|
||||
"""A staged path must be tracked before ffmpeg can write to it."""
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ from frigate.genai.prompts import (
|
||||
REVIEW_DESCRIPTION_FIELD_GUIDELINES,
|
||||
REVIEW_RESPONSE_STYLES,
|
||||
build_review_description_prompt,
|
||||
build_review_summary_prompt,
|
||||
get_review_field_guidelines,
|
||||
)
|
||||
|
||||
@@ -83,5 +84,49 @@ class TestReviewResponseStyle(unittest.TestCase):
|
||||
)
|
||||
|
||||
|
||||
class TestClassificationStateChanges(unittest.TestCase):
|
||||
def _build_prompt(self, **extra) -> str:
|
||||
review_data = {
|
||||
"camera": "Front Door",
|
||||
"start": "Monday, 09:30 AM",
|
||||
"duration": 25,
|
||||
"zones": [],
|
||||
"unified_objects": ["person"],
|
||||
**extra,
|
||||
}
|
||||
return build_review_description_prompt(
|
||||
review_data, [b"fake-image"], [], None, "activity context"
|
||||
)
|
||||
|
||||
def test_no_changes_leaves_prompt_unchanged(self):
|
||||
self.assertEqual(
|
||||
self._build_prompt(classification_state_changes=[]),
|
||||
self._build_prompt(),
|
||||
)
|
||||
self.assertNotIn("## State Changes", self._build_prompt())
|
||||
|
||||
def test_changes_are_listed_before_objects(self):
|
||||
change = "front gate changed from closed to open, 12s into the activity"
|
||||
prompt = self._build_prompt(classification_state_changes=[change])
|
||||
self.assertIn(f"\n- {change}\n\n## Objects in Scene", prompt)
|
||||
self.assertLess(
|
||||
prompt.index("## Sequence Details"), prompt.index("## State Changes")
|
||||
)
|
||||
|
||||
def test_summary_describes_state_changes_only_when_present(self):
|
||||
event = {"title": "Person at door", "camera": "Front Door", "context": []}
|
||||
without = build_review_summary_prompt(0, 3600, [event], None)
|
||||
self.assertNotIn('"state_changes"', without)
|
||||
|
||||
context_event = {
|
||||
**event,
|
||||
"context": [
|
||||
{"camera": "Driveway", "state_changes": ["gate changed from a to b"]}
|
||||
],
|
||||
}
|
||||
with_changes = build_review_summary_prompt(0, 3600, [context_event], None)
|
||||
self.assertIn('- "state_changes"', with_changes)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -19,6 +19,8 @@ import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import requests
|
||||
|
||||
from frigate.config import GenAIConfig, GenAIProviderEnum
|
||||
from frigate.genai import PROVIDERS, load_providers
|
||||
|
||||
@@ -566,7 +568,6 @@ class TestLlamaCppProvider(unittest.TestCase):
|
||||
"supports_audio": False,
|
||||
"supports_tools": False,
|
||||
"supports_reasoning": False,
|
||||
"media_marker": "<__media__>",
|
||||
}
|
||||
cls = PROVIDERS[GenAIProviderEnum.llamacpp]
|
||||
with patch.object(cls, "_get_model_info", return_value=info):
|
||||
@@ -589,6 +590,62 @@ class TestLlamaCppProvider(unittest.TestCase):
|
||||
with patch.object(client, "_fetch_models_data", return_value=models_data):
|
||||
self.assertEqual(client.list_models(), ["g4", "gemma", "qwen3-asr"])
|
||||
|
||||
@staticmethod
|
||||
def _embeddings_response(vectors):
|
||||
response = MagicMock()
|
||||
response.status_code = 200
|
||||
response.json.return_value = {
|
||||
"object": "list",
|
||||
"data": [
|
||||
{"object": "embedding", "index": i, "embedding": v}
|
||||
for i, v in enumerate(vectors)
|
||||
],
|
||||
}
|
||||
return response
|
||||
|
||||
def test_embed_posts_content_arrays_to_v1_embeddings(self):
|
||||
client = self._client()
|
||||
response = self._embeddings_response([[0.1] * 768, [0.2] * 768])
|
||||
|
||||
with patch.object(client, "_post", return_value=response) as post:
|
||||
result = client.embed(texts=["a person"], images=[b"not an image"])
|
||||
|
||||
url = post.call_args.args[0]
|
||||
payload = post.call_args.kwargs["json"]
|
||||
self.assertEqual(url, "http://localhost:9999/v1/embeddings")
|
||||
self.assertEqual(payload["model"], "m")
|
||||
self.assertEqual(payload["encoding_format"], "float")
|
||||
self.assertEqual(
|
||||
payload["input"][0], {"content": [{"type": "text", "text": "a person"}]}
|
||||
)
|
||||
image_parts = payload["input"][1]["content"]
|
||||
self.assertEqual(image_parts[0]["type"], "image_url")
|
||||
self.assertEqual(
|
||||
image_parts[0]["image_url"]["url"],
|
||||
"data:image/jpeg;base64," + base64.b64encode(b"not an image").decode(),
|
||||
)
|
||||
self.assertEqual(image_parts[1], {"type": "text", "text": "\n"})
|
||||
self.assertEqual(len(result), 2)
|
||||
self.assertAlmostEqual(float(result[1][0]), 0.2, places=5)
|
||||
|
||||
def test_embed_normalizes_dimension(self):
|
||||
client = self._client()
|
||||
response = self._embeddings_response([[1.0] * 1024, [1.0] * 512])
|
||||
|
||||
with patch.object(client, "_post", return_value=response):
|
||||
result = client.embed(texts=["long", "short"])
|
||||
|
||||
self.assertEqual([r.shape for r in result], [(768,), (768,)])
|
||||
self.assertEqual(float(result[1][-1]), 0.0)
|
||||
|
||||
def test_embed_request_error_returns_empty(self):
|
||||
client = self._client()
|
||||
response = MagicMock()
|
||||
response.raise_for_status.side_effect = requests.exceptions.HTTPError("400")
|
||||
|
||||
with patch.object(client, "_post", return_value=response):
|
||||
self.assertEqual(client.embed(texts=["a"]), [])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# transcribe role
|
||||
@@ -762,7 +819,6 @@ class TestLlamaCppTranscribe(unittest.TestCase):
|
||||
"supports_audio": supports_audio,
|
||||
"supports_tools": False,
|
||||
"supports_reasoning": False,
|
||||
"media_marker": "<__media__>",
|
||||
}
|
||||
cls = PROVIDERS[GenAIProviderEnum.llamacpp]
|
||||
with patch.object(cls, "_get_model_info", return_value=info):
|
||||
|
||||
@@ -288,6 +288,17 @@ class TestUpdateConfig(HardwareStatsTestCase):
|
||||
|
||||
self.assertEqual(set(stats._monitored), {"rockchip"})
|
||||
|
||||
def test_follows_a_runtime_config_swap(self):
|
||||
stats = self.make_stats(self.make_config())
|
||||
self.assertEqual(set(stats._monitored), set())
|
||||
|
||||
swapped = self.make_config("preset-rk-h264")
|
||||
stats.set_config(swapped)
|
||||
|
||||
self.assertEqual(set(stats._monitored), {"rockchip"})
|
||||
self.assertIs(self.subscriber.return_value.config, swapped)
|
||||
self.assertIs(self.subscriber.return_value.camera_configs, swapped.cameras)
|
||||
|
||||
|
||||
class TestUpdateStats(HardwareStatsTestCase):
|
||||
def run_stats(self, stats: HardwareStats) -> dict:
|
||||
|
||||
@@ -0,0 +1,410 @@
|
||||
"""Tests for Frigate-managed go2rtc live streams."""
|
||||
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, call, patch
|
||||
|
||||
import requests
|
||||
from pydantic import ValidationError
|
||||
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.util.live_streams import (
|
||||
DEFAULT_TRANSCODE_QUALITIES,
|
||||
default_transcode_source,
|
||||
generated_transcode_streams,
|
||||
is_transcode_stream_name,
|
||||
measure_stream_bitrate,
|
||||
raw_transcode_streams,
|
||||
sync_transcode_streams,
|
||||
transcode_stream_name,
|
||||
transcode_stream_source,
|
||||
)
|
||||
|
||||
|
||||
class TestTranscodeNaming(unittest.TestCase):
|
||||
def test_stream_name(self):
|
||||
self.assertEqual(transcode_stream_name("front", 720), "front_transcode_720p")
|
||||
|
||||
def test_is_transcode_stream_name(self):
|
||||
self.assertTrue(is_transcode_stream_name("front", "front_transcode_480p"))
|
||||
self.assertFalse(is_transcode_stream_name("front", "front_sub"))
|
||||
self.assertFalse(is_transcode_stream_name("front", "back_transcode_480p"))
|
||||
self.assertFalse(is_transcode_stream_name("front", "front_transcode_480"))
|
||||
|
||||
def test_source_gives_each_ffmpeg_token_its_own_raw_param(self):
|
||||
self.assertEqual(
|
||||
transcode_stream_source("front", 480, 500),
|
||||
"ffmpeg:front#video=h264#height=480#hardware#audio=copy"
|
||||
"#raw=-b:v#raw=500k#raw=-maxrate#raw=500k#raw=-bufsize#raw=1000k",
|
||||
)
|
||||
|
||||
def test_default_source_skips_transcoded_streams(self):
|
||||
streams = {"Low": "front_transcode_360p", "Main": "front_main"}
|
||||
self.assertEqual(default_transcode_source("front", streams), "front_main")
|
||||
self.assertIsNone(default_transcode_source("front", {}))
|
||||
|
||||
|
||||
class TestRawTranscodeStreams(unittest.TestCase):
|
||||
def test_defaults_to_camera_stream_and_default_qualities(self):
|
||||
config = {"cameras": {"front": {"live": {"transcode": {"enabled": True}}}}}
|
||||
streams = raw_transcode_streams(config)
|
||||
|
||||
self.assertEqual(
|
||||
list(streams),
|
||||
[
|
||||
transcode_stream_name("front", q["height"])
|
||||
for q in DEFAULT_TRANSCODE_QUALITIES
|
||||
],
|
||||
)
|
||||
self.assertTrue(streams["front_transcode_720p"].startswith("ffmpeg:front#"))
|
||||
|
||||
def test_uses_first_live_stream_and_explicit_qualities(self):
|
||||
config = {
|
||||
"cameras": {
|
||||
"front": {
|
||||
"live": {
|
||||
"streams": {"Main": "front_main", "Sub": "front_sub"},
|
||||
"transcode": {
|
||||
"enabled": True,
|
||||
"qualities": [{"height": 540, "bitrate": 800}],
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.assertEqual(
|
||||
raw_transcode_streams(config),
|
||||
{"front_transcode_540p": transcode_stream_source("front_main", 540, 800)},
|
||||
)
|
||||
|
||||
def test_explicit_source_wins(self):
|
||||
config = {
|
||||
"cameras": {
|
||||
"front": {
|
||||
"live": {
|
||||
"streams": {"Main": "front_main"},
|
||||
"transcode": {
|
||||
"enabled": True,
|
||||
"source": "front_sub",
|
||||
"qualities": [{"height": 360, "bitrate": 250}],
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.assertEqual(
|
||||
raw_transcode_streams(config)["front_transcode_360p"],
|
||||
transcode_stream_source("front_sub", 360, 250),
|
||||
)
|
||||
|
||||
def test_malformed_qualities_are_skipped(self):
|
||||
config = {
|
||||
"cameras": {
|
||||
"front": {
|
||||
"live": {
|
||||
"transcode": {
|
||||
"enabled": True,
|
||||
"qualities": [
|
||||
{"height": 720},
|
||||
"480",
|
||||
{"height": 360, "bitrate": 250},
|
||||
],
|
||||
}
|
||||
}
|
||||
},
|
||||
"back": {
|
||||
"live": {"transcode": {"enabled": True, "qualities": {"a": 1}}}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
self.assertEqual(list(raw_transcode_streams(config)), ["front_transcode_360p"])
|
||||
|
||||
def test_disabled_or_missing_generates_nothing(self):
|
||||
self.assertEqual(raw_transcode_streams({}), {})
|
||||
self.assertEqual(
|
||||
raw_transcode_streams({"cameras": {"front": {"live": {}}}}), {}
|
||||
)
|
||||
self.assertEqual(
|
||||
raw_transcode_streams(
|
||||
{"cameras": {"front": {"live": {"transcode": {"enabled": False}}}}}
|
||||
),
|
||||
{},
|
||||
)
|
||||
|
||||
|
||||
def camera_config(live: dict | None = None, go2rtc: dict | None = None) -> dict:
|
||||
config = {
|
||||
"mqtt": {"host": "mqtt"},
|
||||
"go2rtc": {
|
||||
"streams": go2rtc
|
||||
or {
|
||||
"front": ["rtsp://10.0.0.1:554/main"],
|
||||
"front_sub": ["rtsp://10.0.0.1:554/sub"],
|
||||
}
|
||||
},
|
||||
"cameras": {
|
||||
"front": {
|
||||
"ffmpeg": {
|
||||
"inputs": [
|
||||
{"path": "rtsp://10.0.0.1:554/main", "roles": ["detect"]}
|
||||
]
|
||||
},
|
||||
"detect": {"height": 1080, "width": 1920, "fps": 5},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
if live is not None:
|
||||
config["cameras"]["front"]["live"] = live
|
||||
|
||||
return config
|
||||
|
||||
|
||||
class TestLiveTranscodeConfig(unittest.TestCase):
|
||||
def streams(self, live: dict, go2rtc: dict | None = None) -> dict[str, str]:
|
||||
config = FrigateConfig(**camera_config(live, go2rtc))
|
||||
return config.cameras["front"].live.streams
|
||||
|
||||
def test_global_transcode_is_ignored(self):
|
||||
config = camera_config()
|
||||
config["live"] = {"transcode": {"enabled": True}}
|
||||
|
||||
streams = FrigateConfig(**config).cameras["front"].live.streams
|
||||
self.assertEqual(streams, {"front": "front"})
|
||||
|
||||
def test_disabled_leaves_streams_alone(self):
|
||||
self.assertEqual(self.streams({}), {"front": "front"})
|
||||
|
||||
def test_enabled_appends_default_qualities(self):
|
||||
streams = self.streams({"transcode": {"enabled": True}})
|
||||
|
||||
self.assertEqual(
|
||||
list(streams.items()),
|
||||
[
|
||||
("front", "front"),
|
||||
("720p", "front_transcode_720p"),
|
||||
("480p", "front_transcode_480p"),
|
||||
("360p", "front_transcode_360p"),
|
||||
],
|
||||
)
|
||||
|
||||
def test_source_resolves_to_first_live_stream(self):
|
||||
config = FrigateConfig(
|
||||
**camera_config(
|
||||
{
|
||||
"streams": {"Sub": "front_sub", "Main": "front"},
|
||||
"transcode": {"enabled": True},
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(config.cameras["front"].live.transcode.source, "front_sub")
|
||||
|
||||
def test_startup_and_validation_agree_on_inherited_source(self):
|
||||
config = camera_config({"transcode": {"enabled": True}})
|
||||
config["live"] = {"streams": {"Sub": "front_sub"}}
|
||||
|
||||
self.assertEqual(
|
||||
raw_transcode_streams(config),
|
||||
generated_transcode_streams(FrigateConfig(**config)),
|
||||
)
|
||||
self.assertEqual(
|
||||
raw_transcode_streams(config)["front_transcode_360p"],
|
||||
transcode_stream_source("front_sub", 360, 250),
|
||||
)
|
||||
|
||||
def test_placed_entries_keep_position_and_label(self):
|
||||
streams = self.streams(
|
||||
{
|
||||
"streams": {
|
||||
"Main": "front",
|
||||
"Low": "front_transcode_480p",
|
||||
"Sub": "front_sub",
|
||||
},
|
||||
"transcode": {"enabled": True},
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
list(streams.items()),
|
||||
[
|
||||
("Main", "front"),
|
||||
("Low", "front_transcode_480p"),
|
||||
("Sub", "front_sub"),
|
||||
("720p", "front_transcode_720p"),
|
||||
("360p", "front_transcode_360p"),
|
||||
],
|
||||
)
|
||||
|
||||
def test_disabling_drops_transcoded_entries(self):
|
||||
streams = self.streams(
|
||||
{
|
||||
"streams": {"Main": "front", "720p": "front_transcode_720p"},
|
||||
"transcode": {"enabled": False},
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(streams, {"Main": "front"})
|
||||
|
||||
def test_changed_height_drops_the_old_entry(self):
|
||||
streams = self.streams(
|
||||
{
|
||||
"streams": {"Main": "front", "720p": "front_transcode_720p"},
|
||||
"transcode": {
|
||||
"enabled": True,
|
||||
"qualities": [{"height": 540, "bitrate": 800}],
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(streams, {"Main": "front", "540p": "front_transcode_540p"})
|
||||
|
||||
def test_real_go2rtc_stream_with_transcode_name_is_kept(self):
|
||||
streams = self.streams(
|
||||
{"streams": {"Main": "front", "Odd": "front_transcode_720p"}},
|
||||
go2rtc={
|
||||
"front": ["rtsp://10.0.0.1:554/main"],
|
||||
"front_transcode_720p": ["rtsp://10.0.0.1:554/odd"],
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(streams, {"Main": "front", "Odd": "front_transcode_720p"})
|
||||
|
||||
def test_generated_name_colliding_with_go2rtc_stream_fails(self):
|
||||
with self.assertRaises(ValidationError):
|
||||
self.streams(
|
||||
{"transcode": {"enabled": True}},
|
||||
go2rtc={
|
||||
"front": ["rtsp://10.0.0.1:554/main"],
|
||||
"front_transcode_720p": ["rtsp://10.0.0.1:554/odd"],
|
||||
},
|
||||
)
|
||||
|
||||
def test_source_must_be_a_go2rtc_stream(self):
|
||||
with self.assertRaises(ValidationError):
|
||||
self.streams({"transcode": {"enabled": True, "source": "missing"}})
|
||||
|
||||
def test_duplicate_heights_fail(self):
|
||||
with self.assertRaises(ValidationError):
|
||||
self.streams(
|
||||
{
|
||||
"transcode": {
|
||||
"enabled": True,
|
||||
"qualities": [
|
||||
{"height": 480, "bitrate": 500},
|
||||
{"height": 480, "bitrate": 600},
|
||||
],
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
def test_label_collision_fails(self):
|
||||
with self.assertRaises(ValidationError):
|
||||
self.streams(
|
||||
{
|
||||
"streams": {"Main": "front", "720p": "front_sub"},
|
||||
"transcode": {"enabled": True},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class TestGeneratedTranscodeStreams(unittest.TestCase):
|
||||
def test_collects_enabled_cameras(self):
|
||||
config = FrigateConfig(
|
||||
**camera_config(
|
||||
{
|
||||
"transcode": {
|
||||
"enabled": True,
|
||||
"qualities": [{"height": 480, "bitrate": 500}],
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
generated_transcode_streams(config),
|
||||
{"front_transcode_480p": transcode_stream_source("front", 480, 500)},
|
||||
)
|
||||
|
||||
def test_disabled_is_empty(self):
|
||||
self.assertEqual(
|
||||
generated_transcode_streams(FrigateConfig(**camera_config())), {}
|
||||
)
|
||||
|
||||
|
||||
class TestSyncTranscodeStreams(unittest.TestCase):
|
||||
@patch("frigate.util.live_streams.requests.request")
|
||||
def test_puts_added_and_changed_and_deletes_removed(self, mock_request):
|
||||
mock_request.return_value = MagicMock(ok=True)
|
||||
|
||||
ok = sync_transcode_streams(
|
||||
{"a": "src-a", "b": "src-b", "c": "src-c"},
|
||||
{"a": "src-a", "b": "src-b2", "d": "src-d"},
|
||||
)
|
||||
|
||||
self.assertTrue(ok)
|
||||
url = "http://127.0.0.1:1984/api/streams"
|
||||
self.assertCountEqual(
|
||||
mock_request.call_args_list,
|
||||
[
|
||||
call("put", url, params={"name": "b", "src": "src-b2"}, timeout=5),
|
||||
call("put", url, params={"name": "d", "src": "src-d"}, timeout=5),
|
||||
call("delete", url, params={"src": "c"}, timeout=5),
|
||||
],
|
||||
)
|
||||
|
||||
@patch("frigate.util.live_streams.requests.request")
|
||||
def test_unchanged_makes_no_calls(self, mock_request):
|
||||
self.assertTrue(sync_transcode_streams({"a": "x"}, {"a": "x"}))
|
||||
mock_request.assert_not_called()
|
||||
|
||||
@patch("frigate.util.live_streams.requests.request")
|
||||
def test_failure_returns_false_and_keeps_going(self, mock_request):
|
||||
mock_request.side_effect = [
|
||||
requests.ConnectionError("down"),
|
||||
MagicMock(ok=True),
|
||||
]
|
||||
|
||||
self.assertFalse(sync_transcode_streams({}, {"a": "x", "b": "y"}))
|
||||
self.assertEqual(mock_request.call_count, 2)
|
||||
|
||||
|
||||
class TestMeasureStreamBitrate(unittest.TestCase):
|
||||
@patch("frigate.util.live_streams.time.monotonic")
|
||||
@patch("frigate.util.live_streams.requests.get")
|
||||
def test_skips_warmup_and_averages(self, mock_get, mock_monotonic):
|
||||
response = MagicMock(ok=True)
|
||||
response.iter_content.return_value = [b"x" * 1000] * 6
|
||||
mock_get.return_value.__enter__.return_value = response
|
||||
# first byte, warmup, window start, then counted chunks up to 6 s
|
||||
mock_monotonic.side_effect = [0.0, 0.5, 1.0, 2.0, 4.0, 7.0]
|
||||
|
||||
kbps = measure_stream_bitrate("front", duration=6.0, warmup=1.0)
|
||||
|
||||
self.assertAlmostEqual(kbps, 3000 * 8 / 1000 / 6.0)
|
||||
mock_get.assert_called_once_with(
|
||||
"http://127.0.0.1:1984/api/stream.mp4",
|
||||
params={"src": "front"},
|
||||
stream=True,
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
@patch("frigate.util.live_streams.requests.get")
|
||||
def test_no_data_returns_none(self, mock_get):
|
||||
response = MagicMock(ok=True)
|
||||
response.iter_content.return_value = []
|
||||
mock_get.return_value.__enter__.return_value = response
|
||||
|
||||
self.assertIsNone(measure_stream_bitrate("front"))
|
||||
|
||||
@patch("frigate.util.live_streams.requests.get")
|
||||
def test_request_error_returns_none(self, mock_get):
|
||||
mock_get.side_effect = requests.ReadTimeout("slow")
|
||||
|
||||
self.assertIsNone(measure_stream_bitrate("front"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,7 +1,7 @@
|
||||
import datetime
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
# Mock complex imports before importing maintainer, saving originals so we can
|
||||
# restore them after import and avoid polluting sys.modules for other tests.
|
||||
@@ -16,7 +16,7 @@ for name in _MOCKED_MODULES:
|
||||
sys.modules[name] = MagicMock()
|
||||
|
||||
# Now import the class under test
|
||||
from frigate.config import FrigateConfig # noqa: E402
|
||||
from frigate.config import FrigateConfig, RetainModeEnum # noqa: E402
|
||||
from frigate.record.maintainer import RecordingMaintainer # noqa: E402
|
||||
|
||||
# Restore original modules (or remove mock if there was no original)
|
||||
@@ -48,8 +48,11 @@ class TestMaintainer(unittest.IsolatedAsyncioTestCase):
|
||||
"frigate.record.maintainer.psutil.process_iter", return_value=[]
|
||||
):
|
||||
with patch("frigate.record.maintainer.logger.warning") as warn:
|
||||
# Mock validate_and_move_segment to avoid further logic
|
||||
maintainer.validate_and_move_segment = MagicMock()
|
||||
# Mock validate_and_move_segment to avoid further logic.
|
||||
# The requestor is real when another test imported the
|
||||
# maintainer first, and it would block on a reply.
|
||||
maintainer.validate_and_move_segment = AsyncMock()
|
||||
maintainer.requestor = MagicMock()
|
||||
|
||||
try:
|
||||
await maintainer.move_files()
|
||||
@@ -121,6 +124,80 @@ class TestMaintainer(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertIsNone(result)
|
||||
maintainer.drop_segment.assert_called_once_with(cache_path)
|
||||
|
||||
async def test_defers_review_overlap_segment_until_metadata_catches_up(self):
|
||||
# Regression: a segment overlapping an active_objects review must not
|
||||
# be dropped while detection metadata lags behind the segment end,
|
||||
# the missing frames may hold the active objects (or continuous
|
||||
# retention would keep it anyway).
|
||||
config = MagicMock(spec=FrigateConfig)
|
||||
|
||||
camera_config = MagicMock()
|
||||
camera_config.record.enabled = True
|
||||
camera_config.record.continuous.days = 7
|
||||
camera_config.record.motion.days = 0
|
||||
camera_config.record.alerts.retain.mode = RetainModeEnum.active_objects
|
||||
camera_config.record.get_review_pre_capture.return_value = 5
|
||||
camera_config.record.get_review_post_capture.return_value = 5
|
||||
config.cameras = {"test_cam": camera_config}
|
||||
|
||||
stop_event = MagicMock()
|
||||
maintainer = RecordingMaintainer(config, stop_event)
|
||||
|
||||
now = datetime.datetime.now(datetime.UTC)
|
||||
start_time = now - datetime.timedelta(seconds=20)
|
||||
end_time = now - datetime.timedelta(seconds=10)
|
||||
cache_path = "/tmp/cache/test_cam@20260417150000+0000.mp4"
|
||||
|
||||
maintainer.end_time_cache = {
|
||||
cache_path: (end_time, 10.0, False, None, None, None, [])
|
||||
}
|
||||
# Metadata has only reached partway into the segment.
|
||||
maintainer.object_recordings_info["test_cam"] = [
|
||||
(end_time.timestamp() - 8, [], [], [])
|
||||
]
|
||||
maintainer.audio_recordings_info["test_cam"] = []
|
||||
|
||||
maintainer.drop_segment = MagicMock()
|
||||
maintainer.move_segment = AsyncMock(return_value=None)
|
||||
maintainer.recordings_publisher = MagicMock()
|
||||
|
||||
review = MagicMock()
|
||||
review.severity = "alert"
|
||||
review.start_time = start_time.timestamp() - 30
|
||||
review.end_time = None
|
||||
|
||||
result = await maintainer.validate_and_move_segment(
|
||||
"test_cam",
|
||||
reviews=[review],
|
||||
recording={
|
||||
"start_time": start_time,
|
||||
"cache_path": cache_path,
|
||||
"stream_type": "main",
|
||||
},
|
||||
)
|
||||
|
||||
self.assertIsNone(result)
|
||||
maintainer.drop_segment.assert_not_called()
|
||||
maintainer.move_segment.assert_not_awaited()
|
||||
|
||||
# Once metadata passes the segment end, continuous retention keeps it.
|
||||
maintainer.object_recordings_info["test_cam"].append(
|
||||
(now.timestamp(), [], [], [])
|
||||
)
|
||||
|
||||
await maintainer.validate_and_move_segment(
|
||||
"test_cam",
|
||||
reviews=[review],
|
||||
recording={
|
||||
"start_time": start_time,
|
||||
"cache_path": cache_path,
|
||||
"stream_type": "main",
|
||||
},
|
||||
)
|
||||
|
||||
maintainer.drop_segment.assert_not_called()
|
||||
maintainer.move_segment.assert_awaited_once()
|
||||
|
||||
async def test_expire_stale_recordings_info_drops_only_absent_cameras(self):
|
||||
config = MagicMock(spec=FrigateConfig)
|
||||
config.cameras = {}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Tests for sub stream cache segment handling in the recording maintainer."""
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
import os
|
||||
import tempfile
|
||||
@@ -532,6 +533,59 @@ class TestSegmentStartChaining(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertAlmostEqual(calls[1].args[2].timestamp(), self.T0 + 10.4, places=3)
|
||||
self.assertAlmostEqual(calls[1].args[3].timestamp(), self.T0 + 20.8, places=3)
|
||||
|
||||
async def test_out_of_order_probes_chain_in_segment_order(self):
|
||||
maintainer = _build_chaining_maintainer(self.T0)
|
||||
|
||||
async def probe(_ffmpeg, cache_path, get_duration=False):
|
||||
# the earlier segment's probe finishes last
|
||||
if "chain0" in cache_path:
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
return {"has_valid_video": True, "duration": 10.4}
|
||||
|
||||
recordings = [
|
||||
{
|
||||
"start_time": datetime.datetime.fromtimestamp(
|
||||
self.T0 + offset, tz=datetime.UTC
|
||||
),
|
||||
"cache_path": f"/tmp/cache/test_cam@chain{offset}.mp4",
|
||||
"stream_type": "main",
|
||||
}
|
||||
for offset in (0, 10)
|
||||
]
|
||||
first_resolved = asyncio.Event()
|
||||
|
||||
with (
|
||||
patch("frigate.record.maintainer.get_video_properties", probe),
|
||||
patch(
|
||||
"frigate.record.maintainer.get_keyframe_offsets",
|
||||
AsyncMock(return_value=[0]),
|
||||
),
|
||||
patch(
|
||||
"frigate.record.maintainer.os.path.getmtime",
|
||||
MagicMock(side_effect=OSError("missing")),
|
||||
),
|
||||
):
|
||||
await asyncio.gather(
|
||||
maintainer._validate_in_order(
|
||||
"test_cam", [], recordings[0], None, first_resolved
|
||||
),
|
||||
maintainer._validate_in_order(
|
||||
"test_cam", [], recordings[1], first_resolved, asyncio.Event()
|
||||
),
|
||||
)
|
||||
|
||||
starts = sorted(
|
||||
call.args[2].timestamp() for call in maintainer.move_segment.await_args_list
|
||||
)
|
||||
self.assertEqual(starts[0], self.T0)
|
||||
self.assertAlmostEqual(starts[1], self.T0 + 10.4, places=3)
|
||||
self.assertAlmostEqual(
|
||||
maintainer.last_segment_end[("test_cam", "main")],
|
||||
self.T0 + 20.8,
|
||||
places=3,
|
||||
)
|
||||
|
||||
async def test_genuine_gap_is_not_snapped(self):
|
||||
maintainer = _build_chaining_maintainer(self.T0)
|
||||
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
"""Tests for tracker-derived review frame annotations."""
|
||||
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from frigate.data_processing.post.review_annotations import (
|
||||
annotations_by_frame,
|
||||
build_frame_captions,
|
||||
build_timeline,
|
||||
describe_classification_change,
|
||||
describe_heading,
|
||||
describe_position,
|
||||
event_name,
|
||||
@@ -312,5 +315,75 @@ class TestFrameBucketing(unittest.TestCase):
|
||||
self.assertEqual(annotations_by_frame([(1.0, "x")], []), {})
|
||||
|
||||
|
||||
class TestClassificationChangeCaptions(unittest.TestCase):
|
||||
def setUp(self):
|
||||
person = track(
|
||||
"1789481994.684479-lpyc2z",
|
||||
"person",
|
||||
0.0,
|
||||
straight_path((0.9, 0.6), (0.4, 0.3), 10, 0.0),
|
||||
)
|
||||
patcher = patch(
|
||||
"frigate.data_processing.post.review_annotations.get_tracked_events",
|
||||
return_value=[person],
|
||||
)
|
||||
patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
patcher = patch(
|
||||
"frigate.data_processing.post.review_annotations.get_state_changes",
|
||||
return_value=[],
|
||||
)
|
||||
patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
|
||||
def test_change_is_phrased_without_underscores(self):
|
||||
self.assertEqual(
|
||||
describe_classification_change(
|
||||
{"model": "trash_day", "from": "no_bins", "to": "bins_at_curb"}
|
||||
),
|
||||
"trash day changed from no bins to bins at curb",
|
||||
)
|
||||
|
||||
def test_change_is_noted_before_the_frame_it_precedes(self):
|
||||
change = {"model": "front_gate", "from": "closed", "to": "open"}
|
||||
captions = build_frame_captions(
|
||||
["1789481994.684479-lpyc2z"],
|
||||
[0.0, 10.0, 20.0],
|
||||
[{**change, "timestamp": 12.0}],
|
||||
)
|
||||
self.assertEqual(
|
||||
captions[2],
|
||||
"Frame 3 of 3 (+20.0s):\n[state] front gate changed from closed to open",
|
||||
)
|
||||
self.assertTrue(captions[0].splitlines()[1].startswith("[tracker] "))
|
||||
|
||||
def test_change_is_noted_without_tracked_objects(self):
|
||||
with patch(
|
||||
"frigate.data_processing.post.review_annotations.get_tracked_events",
|
||||
return_value=[],
|
||||
):
|
||||
captions = build_frame_captions(
|
||||
[],
|
||||
[0.0, 10.0],
|
||||
[{"model": "gate", "from": "a", "to": "b", "timestamp": 5.0}],
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
captions,
|
||||
[
|
||||
"Frame 1 of 2 (+0.0s):",
|
||||
"Frame 2 of 2 (+10.0s):\n[state] gate changed from a to b",
|
||||
],
|
||||
)
|
||||
|
||||
def test_change_after_the_last_frame_is_dropped(self):
|
||||
captions = build_frame_captions(
|
||||
["1789481994.684479-lpyc2z"],
|
||||
[0.0, 10.0, 20.0],
|
||||
[{"model": "gate", "from": "a", "to": "b", "timestamp": 25.0}],
|
||||
)
|
||||
self.assertFalse(any("[state]" in caption for caption in captions))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
"""Tests for attaching state classification changes to review items."""
|
||||
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from frigate.comms.embeddings_updater import EmbeddingsRequestEnum
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.data_processing.post.review_descriptions import (
|
||||
ReviewDescriptionProcessor,
|
||||
format_classification_state_changes,
|
||||
)
|
||||
from frigate.data_processing.real_time.custom_classification import (
|
||||
CustomStateClassificationProcessor,
|
||||
)
|
||||
from frigate.models import ReviewSegment
|
||||
from frigate.review.maintainer import (
|
||||
CLASSIFICATION_STATE_PRE_ROLL,
|
||||
PendingReviewSegment,
|
||||
ReviewSegmentMaintainer,
|
||||
)
|
||||
from frigate.review.types import SeverityEnum
|
||||
|
||||
CONFIG = """
|
||||
mqtt:
|
||||
enabled: False
|
||||
cameras:
|
||||
front_door:
|
||||
ffmpeg:
|
||||
inputs:
|
||||
- path: rtsp://10.0.0.1:554/video
|
||||
roles:
|
||||
- detect
|
||||
detect:
|
||||
width: 1920
|
||||
height: 1080
|
||||
fps: 5
|
||||
"""
|
||||
|
||||
|
||||
def gate_change(timestamp: float, before: str = "closed", after: str = "open"):
|
||||
return {"model": "front_gate", "from": before, "to": after, "timestamp": timestamp}
|
||||
|
||||
|
||||
class TestVerifyStateChange(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.processor = CustomStateClassificationProcessor.__new__(
|
||||
CustomStateClassificationProcessor
|
||||
)
|
||||
self.processor.state_history = {}
|
||||
|
||||
def verify(self, state: str, timestamp: float):
|
||||
return self.processor.verify_state_change("front_door", state, timestamp)
|
||||
|
||||
def test_first_verified_state_has_no_previous_state(self):
|
||||
self.assertIsNone(self.verify("closed", 1.0))
|
||||
self.assertIsNone(self.verify("closed", 2.0))
|
||||
self.assertEqual(self.verify("closed", 3.0), (None, 1.0))
|
||||
|
||||
def test_change_reports_previous_state_and_first_sighting(self):
|
||||
for timestamp in (1.0, 2.0, 3.0):
|
||||
self.verify("closed", timestamp)
|
||||
|
||||
self.assertIsNone(self.verify("open", 10.0))
|
||||
self.assertIsNone(self.verify("open", 11.0))
|
||||
self.assertEqual(self.verify("open", 12.0), ("closed", 10.0))
|
||||
|
||||
def test_interrupted_verification_restarts_the_first_sighting(self):
|
||||
for timestamp in (1.0, 2.0, 3.0):
|
||||
self.verify("closed", timestamp)
|
||||
|
||||
self.verify("open", 10.0)
|
||||
self.verify("closed", 11.0)
|
||||
self.verify("open", 20.0)
|
||||
self.verify("open", 21.0)
|
||||
self.assertEqual(self.verify("open", 22.0), ("closed", 20.0))
|
||||
|
||||
def test_reload_forgets_states_the_model_no_longer_has(self):
|
||||
for timestamp in (1.0, 2.0, 3.0):
|
||||
self.verify("closed", timestamp)
|
||||
|
||||
self.processor.state_history["back_door"] = {"current_state": "open"}
|
||||
self.processor.labelmap = {0: "open", 1: "shut"}
|
||||
self.processor._forget_unknown_states()
|
||||
|
||||
self.assertEqual(list(self.processor.state_history), ["back_door"])
|
||||
self.assertIsNone(self.verify("shut", 10.0))
|
||||
self.assertIsNone(self.verify("shut", 11.0))
|
||||
self.assertEqual(self.verify("shut", 12.0), (None, 10.0))
|
||||
|
||||
|
||||
class TestReviewSegmentAttachment(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.maintainer = ReviewSegmentMaintainer.__new__(ReviewSegmentMaintainer)
|
||||
self.maintainer.config = FrigateConfig.parse_yaml(CONFIG)
|
||||
self.maintainer.active_review_segments = {}
|
||||
self.maintainer.recent_classification_state_changes = {}
|
||||
self.maintainer._publish_segment_update = MagicMock()
|
||||
|
||||
def segment(self, start_time: float) -> PendingReviewSegment:
|
||||
return PendingReviewSegment(
|
||||
"front_door",
|
||||
start_time,
|
||||
SeverityEnum.alert,
|
||||
{"1.0-abcdef": "person"},
|
||||
{},
|
||||
[],
|
||||
set(),
|
||||
)
|
||||
|
||||
def test_change_during_segment_is_attached_and_published(self):
|
||||
segment = self.segment(100.0)
|
||||
self.maintainer.active_review_segments["front_door"] = segment
|
||||
|
||||
self.maintainer.handle_classification_state_change(
|
||||
"front_door", gate_change(110.0)
|
||||
)
|
||||
|
||||
self.assertEqual(segment.classification_state_changes, [gate_change(110.0)])
|
||||
self.maintainer._publish_segment_update.assert_called_once()
|
||||
prev_data = self.maintainer._publish_segment_update.call_args.args[4]
|
||||
self.assertEqual(prev_data["data"]["classification_state_changes"], [])
|
||||
self.assertEqual(
|
||||
segment.get_data(False)["data"]["classification_state_changes"],
|
||||
[gate_change(110.0)],
|
||||
)
|
||||
|
||||
def test_change_never_starts_a_segment(self):
|
||||
self.maintainer.handle_classification_state_change(
|
||||
"front_door", gate_change(110.0)
|
||||
)
|
||||
|
||||
self.assertIsNone(self.maintainer.active_review_segments.get("front_door"))
|
||||
self.maintainer._publish_segment_update.assert_not_called()
|
||||
|
||||
def test_change_just_before_a_segment_is_attached_when_it_starts(self):
|
||||
self.maintainer.handle_classification_state_change(
|
||||
"front_door", gate_change(98.0)
|
||||
)
|
||||
segment = self.segment(100.0)
|
||||
|
||||
self.maintainer._activate_segment(segment)
|
||||
|
||||
self.assertIs(self.maintainer.active_review_segments["front_door"], segment)
|
||||
self.assertEqual(segment.classification_state_changes, [gate_change(98.0)])
|
||||
self.assertNotIn(
|
||||
"front_door", self.maintainer.recent_classification_state_changes
|
||||
)
|
||||
|
||||
def test_change_long_before_a_segment_is_not_attached(self):
|
||||
self.maintainer.handle_classification_state_change(
|
||||
"front_door", gate_change(100.0 - CLASSIFICATION_STATE_PRE_ROLL - 1)
|
||||
)
|
||||
segment = self.segment(100.0)
|
||||
|
||||
self.maintainer._activate_segment(segment)
|
||||
|
||||
self.assertEqual(segment.classification_state_changes, [])
|
||||
|
||||
def test_held_changes_are_pruned(self):
|
||||
self.maintainer.handle_classification_state_change(
|
||||
"front_door", gate_change(10.0)
|
||||
)
|
||||
self.maintainer.handle_classification_state_change(
|
||||
"front_door", gate_change(50.0, "open", "closed")
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
self.maintainer.recent_classification_state_changes["front_door"],
|
||||
[gate_change(50.0, "open", "closed")],
|
||||
)
|
||||
|
||||
|
||||
class TestChangeTiming(unittest.TestCase):
|
||||
def test_changes_are_placed_relative_to_the_activity(self):
|
||||
lines = format_classification_state_changes(
|
||||
[
|
||||
gate_change(98.0),
|
||||
gate_change(112.4, "open", "closed"),
|
||||
gate_change(140.0),
|
||||
],
|
||||
start_time=100.0,
|
||||
end_time=130.0,
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
lines,
|
||||
[
|
||||
"front gate changed from closed to open, "
|
||||
"just before the activity started",
|
||||
"front gate changed from open to closed, 12s into the activity",
|
||||
"front gate changed from closed to open, after the activity ended",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
class TestSummaryContext(unittest.TestCase):
|
||||
def row(self, camera, start, end, threat, changes=()):
|
||||
return {
|
||||
"camera": camera,
|
||||
"start_time": start,
|
||||
"end_time": end,
|
||||
"data": {
|
||||
"metadata": {"title": camera, "potential_threat_level": threat},
|
||||
"classification_state_changes": list(changes),
|
||||
},
|
||||
}
|
||||
|
||||
def summarize(self, rows):
|
||||
processor = ReviewDescriptionProcessor.__new__(ReviewDescriptionProcessor)
|
||||
processor.config = FrigateConfig.parse_yaml(CONFIG)
|
||||
processor.genai_manager = MagicMock()
|
||||
client = processor.genai_manager.description_client
|
||||
|
||||
with patch.object(ReviewSegment, "select") as select:
|
||||
query = select.return_value.where.return_value.order_by.return_value
|
||||
query.dicts.return_value.iterator.return_value = iter(rows)
|
||||
processor.handle_request(
|
||||
EmbeddingsRequestEnum.summarize_review.value,
|
||||
{"start_ts": 0, "end_ts": 100},
|
||||
)
|
||||
|
||||
return client.generate_review_summary.call_args.args[2]
|
||||
|
||||
def test_context_state_changes_stay_with_their_review(self):
|
||||
events = self.summarize(
|
||||
[
|
||||
self.row("front_door", 10, 60, 1),
|
||||
self.row("driveway", 15, 25, 0, [gate_change(20.0)]),
|
||||
self.row("driveway", 30, 40, 0, [gate_change(35.0, "open", "closed")]),
|
||||
]
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
[
|
||||
(item["start_time"], item["end_time"], item["state_changes"])
|
||||
for item in events[0]["context"]
|
||||
],
|
||||
[
|
||||
(15, 25, ["front gate changed from closed to open"]),
|
||||
(30, 40, ["front gate changed from open to closed"]),
|
||||
],
|
||||
)
|
||||
|
||||
def test_later_context_review_without_changes_is_deduplicated(self):
|
||||
events = self.summarize(
|
||||
[
|
||||
self.row("front_door", 10, 60, 1),
|
||||
self.row("driveway", 15, 25, 0, [gate_change(20.0)]),
|
||||
self.row("driveway", 30, 40, 0),
|
||||
]
|
||||
)
|
||||
|
||||
self.assertEqual(len(events[0]["context"]), 1)
|
||||
self.assertEqual(events[0]["context"][0]["start_time"], 15)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,857 @@
|
||||
"""Tests for how tracked objects flow into review segments.
|
||||
|
||||
Frames are fed through the maintainer's run loop with mocked subscribers so
|
||||
the dispatch between starting and updating segments is exercised, and the
|
||||
published review updates are checked for the expected alert, detection, or
|
||||
lack of a review item.
|
||||
"""
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import numpy as np
|
||||
|
||||
from frigate.comms.detections_updater import DetectionTypeEnum
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.review.maintainer import ReviewSegmentMaintainer
|
||||
|
||||
CAMERA = "front_door"
|
||||
|
||||
BASE_CONFIG = """
|
||||
mqtt:
|
||||
enabled: False
|
||||
record:
|
||||
enabled: True
|
||||
cameras:
|
||||
front_door:
|
||||
ffmpeg:
|
||||
inputs:
|
||||
- path: rtsp://10.0.0.1:554/video
|
||||
roles:
|
||||
- detect
|
||||
detect:
|
||||
width: 640
|
||||
height: 360
|
||||
fps: 5
|
||||
zones:
|
||||
driveway:
|
||||
coordinates: 0,0,320,0,320,360,0,360
|
||||
yard:
|
||||
coordinates: 320,0,640,0,640,360,320,360
|
||||
%s
|
||||
"""
|
||||
|
||||
|
||||
class ReviewFlowTestCase(unittest.TestCase):
|
||||
review_config = ""
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.clips_dir = clips_dir = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(clips_dir.cleanup)
|
||||
clips_patch = patch("frigate.review.maintainer.CLIPS_DIR", clips_dir.name)
|
||||
clips_patch.start()
|
||||
self.addCleanup(clips_patch.stop)
|
||||
|
||||
self.maintainer = self._make_maintainer(self.review_config)
|
||||
|
||||
def _make_maintainer(self, review_config: str) -> ReviewSegmentMaintainer:
|
||||
"""Build a maintainer without invoking __init__ (avoids needing ZMQ
|
||||
sockets and shared memory)."""
|
||||
maintainer = ReviewSegmentMaintainer.__new__(ReviewSegmentMaintainer)
|
||||
threading.Thread.__init__(maintainer)
|
||||
maintainer.config = FrigateConfig.parse_yaml(BASE_CONFIG % review_config)
|
||||
maintainer.active_review_segments = {}
|
||||
maintainer.indefinite_events = {}
|
||||
maintainer.recent_classification_state_changes = {}
|
||||
maintainer.requestor = MagicMock()
|
||||
maintainer.review_publisher = MagicMock()
|
||||
maintainer.config_subscriber = MagicMock()
|
||||
maintainer.config_subscriber.check_for_updates.return_value = {}
|
||||
maintainer.detection_subscriber = MagicMock()
|
||||
maintainer.frame_manager = MagicMock()
|
||||
maintainer.frame_manager.get.side_effect = lambda _name, shape: np.zeros(
|
||||
shape, np.uint8
|
||||
)
|
||||
return maintainer
|
||||
|
||||
@property
|
||||
def stationary_threshold(self) -> int:
|
||||
return self.maintainer.config.cameras[CAMERA].detect.stationary.threshold
|
||||
|
||||
@property
|
||||
def alert_cutoff(self) -> int:
|
||||
return self.maintainer.config.cameras[CAMERA].review.alerts.cutoff_time
|
||||
|
||||
@property
|
||||
def detection_cutoff(self) -> int:
|
||||
return self.maintainer.config.cameras[CAMERA].review.detections.cutoff_time
|
||||
|
||||
def tracked(
|
||||
self,
|
||||
obj_id: str,
|
||||
label: str,
|
||||
frame_time: float,
|
||||
*,
|
||||
start_time: float = 0,
|
||||
zones: list[str] | None = None,
|
||||
stationary: bool = False,
|
||||
loitering: bool = False,
|
||||
moved: bool = True,
|
||||
false_positive: bool = False,
|
||||
sub_label: tuple[str, float] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Build a tracked object as published by the object processor."""
|
||||
return {
|
||||
"id": obj_id,
|
||||
"label": label,
|
||||
"sub_label": sub_label,
|
||||
"frame_time": frame_time,
|
||||
"start_time": start_time,
|
||||
"motionless_count": self.stationary_threshold if stationary else 0,
|
||||
"pending_loitering": loitering,
|
||||
"position_changes": 1 if moved else 0,
|
||||
"false_positive": false_positive,
|
||||
"current_zones": zones or [],
|
||||
"box": (100, 100, 200, 200),
|
||||
}
|
||||
|
||||
def feed(self, *frames: tuple[float, list[dict[str, Any]]]) -> None:
|
||||
"""Run the maintainer loop over the given (frame_time, objects) frames."""
|
||||
queue = [
|
||||
(
|
||||
DetectionTypeEnum.video.value,
|
||||
(CAMERA, f"{CAMERA}_{frame_time}", frame_time, objects, [], []),
|
||||
)
|
||||
for frame_time, objects in frames
|
||||
]
|
||||
self.maintainer.stop_event = threading.Event()
|
||||
|
||||
def next_update(timeout: float) -> Any:
|
||||
if not queue:
|
||||
self.maintainer.stop_event.set()
|
||||
return None
|
||||
|
||||
return queue.pop(0)
|
||||
|
||||
self.maintainer.detection_subscriber.check_for_update.side_effect = next_update
|
||||
self.maintainer.run()
|
||||
|
||||
def reviews(self) -> list[dict[str, Any]]:
|
||||
"""All review updates published on the reviews topic."""
|
||||
return [
|
||||
json.loads(c.args[1])
|
||||
for c in self.maintainer.requestor.send_data.call_args_list
|
||||
if c.args[0] == "reviews"
|
||||
]
|
||||
|
||||
def review_summary(self) -> list[tuple[str, str]]:
|
||||
return [(r["type"], r["after"]["severity"]) for r in self.reviews()]
|
||||
|
||||
def non_update_summary(self) -> list[tuple[str, str]]:
|
||||
return [(t, s) for t, s in self.review_summary() if t != "update"]
|
||||
|
||||
def thumbnails(self) -> set[str]:
|
||||
"""Names of the review thumbnails on disk."""
|
||||
return {t.name for t in Path(self.clips_dir.name, "review").iterdir()}
|
||||
|
||||
def assert_no_review(self) -> None:
|
||||
self.assertEqual(self.reviews(), [])
|
||||
self.assertIsNone(self.maintainer.active_review_segments.get(CAMERA))
|
||||
|
||||
|
||||
class TestReviewSeverity(ReviewFlowTestCase):
|
||||
def test_alert_label_creates_alert(self) -> None:
|
||||
self.feed((1, [self.tracked("p1", "person", 1)]))
|
||||
|
||||
self.assertEqual(self.review_summary(), [("new", "alert")])
|
||||
self.assertEqual(self.reviews()[0]["after"]["data"]["objects"], ["person"])
|
||||
|
||||
def test_non_alert_label_creates_detection(self) -> None:
|
||||
self.feed((1, [self.tracked("d1", "dog", 1)]))
|
||||
|
||||
self.assertEqual(self.review_summary(), [("new", "detection")])
|
||||
self.assertEqual(self.reviews()[0]["after"]["data"]["objects"], ["dog"])
|
||||
|
||||
def test_alert_and_detection_objects_create_single_alert(self) -> None:
|
||||
self.feed((1, [self.tracked("p1", "person", 1), self.tracked("d1", "dog", 1)]))
|
||||
|
||||
self.assertEqual(self.review_summary(), [("new", "alert")])
|
||||
self.assertCountEqual(
|
||||
self.reviews()[0]["after"]["data"]["objects"], ["person", "dog"]
|
||||
)
|
||||
|
||||
def test_no_objects_creates_nothing(self) -> None:
|
||||
self.feed((1, []), (2, []))
|
||||
|
||||
self.assert_no_review()
|
||||
|
||||
|
||||
class TestIgnoredObjects(ReviewFlowTestCase):
|
||||
def test_stationary_object_creates_nothing(self) -> None:
|
||||
self.feed((1, [self.tracked("p1", "person", 1, stationary=True)]))
|
||||
|
||||
self.assert_no_review()
|
||||
|
||||
def test_stationary_loitering_object_creates_alert(self) -> None:
|
||||
self.feed(
|
||||
(1, [self.tracked("p1", "person", 1, stationary=True, loitering=True)])
|
||||
)
|
||||
|
||||
self.assertEqual(self.review_summary(), [("new", "alert")])
|
||||
|
||||
def test_object_that_never_moved_creates_nothing(self) -> None:
|
||||
self.feed((1, [self.tracked("p1", "person", 1, moved=False)]))
|
||||
|
||||
self.assert_no_review()
|
||||
|
||||
def test_object_not_detected_in_current_frame_creates_nothing(self) -> None:
|
||||
self.feed((2, [self.tracked("p1", "person", 1)]))
|
||||
|
||||
self.assert_no_review()
|
||||
|
||||
def test_false_positive_creates_nothing(self) -> None:
|
||||
self.feed((1, [self.tracked("p1", "person", 1, false_positive=True)]))
|
||||
|
||||
self.assert_no_review()
|
||||
|
||||
|
||||
class TestAlertRequiredZones(ReviewFlowTestCase):
|
||||
review_config = """
|
||||
review:
|
||||
alerts:
|
||||
required_zones: driveway
|
||||
"""
|
||||
|
||||
def test_alert_label_in_required_zone_creates_alert(self) -> None:
|
||||
self.feed((1, [self.tracked("p1", "person", 1, zones=["driveway"])]))
|
||||
|
||||
self.assertEqual(self.review_summary(), [("new", "alert")])
|
||||
self.assertEqual(self.reviews()[0]["after"]["data"]["zones"], ["driveway"])
|
||||
|
||||
def test_alert_label_outside_required_zone_creates_detection(self) -> None:
|
||||
self.feed((1, [self.tracked("p1", "person", 1, zones=["yard"])]))
|
||||
|
||||
self.assertEqual(self.review_summary(), [("new", "detection")])
|
||||
|
||||
def test_alert_label_in_no_zone_creates_detection(self) -> None:
|
||||
self.feed((1, [self.tracked("p1", "person", 1)]))
|
||||
|
||||
self.assertEqual(self.review_summary(), [("new", "detection")])
|
||||
|
||||
def test_detection_upgrades_to_alert_when_object_enters_required_zone(
|
||||
self,
|
||||
) -> None:
|
||||
self.feed(
|
||||
(1, [self.tracked("p1", "person", 1, zones=["yard"])]),
|
||||
(2, [self.tracked("p1", "person", 2, zones=["driveway"])]),
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
self.review_summary(), [("new", "detection"), ("update", "alert")]
|
||||
)
|
||||
self.assertEqual(
|
||||
self.reviews()[0]["after"]["id"], self.reviews()[1]["after"]["id"]
|
||||
)
|
||||
|
||||
|
||||
class TestDetectionRequiredZones(ReviewFlowTestCase):
|
||||
review_config = """
|
||||
review:
|
||||
detections:
|
||||
required_zones: yard
|
||||
"""
|
||||
|
||||
def test_detection_label_in_required_zone_creates_detection(self) -> None:
|
||||
self.feed((1, [self.tracked("d1", "dog", 1, zones=["yard"])]))
|
||||
|
||||
self.assertEqual(self.review_summary(), [("new", "detection")])
|
||||
|
||||
def test_detection_label_outside_required_zone_creates_nothing(self) -> None:
|
||||
self.feed((1, [self.tracked("d1", "dog", 1, zones=["driveway"])]))
|
||||
|
||||
self.assert_no_review()
|
||||
|
||||
def test_alert_label_ignores_detection_required_zones(self) -> None:
|
||||
self.feed((1, [self.tracked("p1", "person", 1, zones=["driveway"])]))
|
||||
|
||||
self.assertEqual(self.review_summary(), [("new", "alert")])
|
||||
|
||||
def test_activity_outside_required_zone_after_alert_is_not_held(self) -> None:
|
||||
self.feed(
|
||||
(1, [self.tracked("p1", "person", 1, start_time=1)]),
|
||||
(5, [self.tracked("d1", "dog", 5, start_time=5, zones=["driveway"])]),
|
||||
)
|
||||
self.assertEqual(
|
||||
self.maintainer.active_review_segments[CAMERA].pending_detections, []
|
||||
)
|
||||
|
||||
self.feed((2 + self.alert_cutoff, []))
|
||||
|
||||
self.assertEqual(
|
||||
self.non_update_summary(), [("new", "alert"), ("end", "alert")]
|
||||
)
|
||||
self.assertIsNone(self.maintainer.active_review_segments.get(CAMERA))
|
||||
self.assertEqual(len(self.thumbnails()), 1)
|
||||
|
||||
def test_activity_inside_required_zone_after_alert_is_split_out(self) -> None:
|
||||
self.feed(
|
||||
(1, [self.tracked("p1", "person", 1, start_time=1)]),
|
||||
(5, [self.tracked("d1", "dog", 5, start_time=5, zones=["yard"])]),
|
||||
(2 + self.alert_cutoff, []),
|
||||
)
|
||||
|
||||
# the dog is past the detection cutoff, so it is ended right away
|
||||
self.assertEqual(
|
||||
self.non_update_summary(),
|
||||
[
|
||||
("new", "alert"),
|
||||
("end", "alert"),
|
||||
("new", "detection"),
|
||||
("end", "detection"),
|
||||
],
|
||||
)
|
||||
detection = self.reviews()[-1]["after"]
|
||||
self.assertEqual(detection["data"]["objects"], ["dog"])
|
||||
self.assertEqual(detection["data"]["zones"], ["yard"])
|
||||
|
||||
|
||||
class TestDetectionLabels(ReviewFlowTestCase):
|
||||
review_config = """
|
||||
review:
|
||||
detections:
|
||||
labels:
|
||||
- dog
|
||||
"""
|
||||
|
||||
def test_listed_label_creates_detection(self) -> None:
|
||||
self.feed((1, [self.tracked("d1", "dog", 1)]))
|
||||
|
||||
self.assertEqual(self.review_summary(), [("new", "detection")])
|
||||
|
||||
def test_unlisted_label_creates_nothing(self) -> None:
|
||||
self.feed((1, [self.tracked("c1", "cat", 1)]))
|
||||
|
||||
self.assert_no_review()
|
||||
|
||||
def test_unlisted_object_is_left_out_of_detection(self) -> None:
|
||||
self.feed((1, [self.tracked("d1", "dog", 1), self.tracked("c1", "cat", 1)]))
|
||||
|
||||
self.assertEqual(self.review_summary(), [("new", "detection")])
|
||||
self.assertEqual(self.reviews()[0]["after"]["data"]["objects"], ["dog"])
|
||||
|
||||
|
||||
class TestAlertsDisabled(ReviewFlowTestCase):
|
||||
review_config = """
|
||||
review:
|
||||
alerts:
|
||||
enabled: False
|
||||
"""
|
||||
|
||||
def test_alert_label_creates_detection(self) -> None:
|
||||
self.feed((1, [self.tracked("p1", "person", 1)]))
|
||||
|
||||
self.assertEqual(self.review_summary(), [("new", "detection")])
|
||||
|
||||
|
||||
class TestDetectionsDisabled(ReviewFlowTestCase):
|
||||
review_config = """
|
||||
review:
|
||||
detections:
|
||||
enabled: False
|
||||
"""
|
||||
|
||||
def test_alert_label_creates_alert(self) -> None:
|
||||
self.feed((1, [self.tracked("p1", "person", 1)]))
|
||||
|
||||
self.assertEqual(self.review_summary(), [("new", "alert")])
|
||||
|
||||
def test_non_alert_label_creates_nothing(self) -> None:
|
||||
self.feed((1, [self.tracked("d1", "dog", 1)]))
|
||||
|
||||
self.assert_no_review()
|
||||
|
||||
def test_activity_after_alert_is_not_held(self) -> None:
|
||||
self.feed(
|
||||
(1, [self.tracked("p1", "person", 1, start_time=1)]),
|
||||
(5, [self.tracked("d1", "dog", 5, start_time=5)]),
|
||||
)
|
||||
self.assertEqual(
|
||||
self.maintainer.active_review_segments[CAMERA].pending_detections, []
|
||||
)
|
||||
|
||||
self.feed((2 + self.alert_cutoff, []))
|
||||
|
||||
self.assertEqual(
|
||||
self.non_update_summary(), [("new", "alert"), ("end", "alert")]
|
||||
)
|
||||
self.assertIsNone(self.maintainer.active_review_segments.get(CAMERA))
|
||||
self.assertEqual(len(self.thumbnails()), 1)
|
||||
|
||||
|
||||
class TestAlertsAndDetectionsDisabled(ReviewFlowTestCase):
|
||||
review_config = """
|
||||
review:
|
||||
alerts:
|
||||
enabled: False
|
||||
detections:
|
||||
enabled: False
|
||||
"""
|
||||
|
||||
def test_nothing_is_created(self) -> None:
|
||||
self.feed((1, [self.tracked("p1", "person", 1), self.tracked("d1", "dog", 1)]))
|
||||
|
||||
self.assert_no_review()
|
||||
|
||||
|
||||
class TestReviewLifecycle(ReviewFlowTestCase):
|
||||
def test_detection_upgrades_to_alert_when_alert_object_appears(self) -> None:
|
||||
self.feed(
|
||||
(1, [self.tracked("d1", "dog", 1)]),
|
||||
(2, [self.tracked("d1", "dog", 2), self.tracked("p1", "person", 2)]),
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
self.review_summary(), [("new", "detection"), ("update", "alert")]
|
||||
)
|
||||
new, update = self.reviews()
|
||||
self.assertEqual(new["after"]["id"], update["after"]["id"])
|
||||
self.assertCountEqual(update["after"]["data"]["objects"], ["dog", "person"])
|
||||
|
||||
def test_alert_does_not_downgrade_when_only_detection_objects_remain(
|
||||
self,
|
||||
) -> None:
|
||||
self.feed(
|
||||
(1, [self.tracked("p1", "person", 1), self.tracked("d1", "dog", 1)]),
|
||||
(2, [self.tracked("d1", "dog", 2)]),
|
||||
(3, [self.tracked("d1", "dog", 3)]),
|
||||
)
|
||||
|
||||
self.assertEqual({severity for _, severity in self.review_summary()}, {"alert"})
|
||||
self.assertEqual(
|
||||
self.maintainer.active_review_segments[CAMERA].severity.value, "alert"
|
||||
)
|
||||
|
||||
def test_alert_stays_open_until_cutoff(self) -> None:
|
||||
self.feed(
|
||||
(1, [self.tracked("p1", "person", 1)]),
|
||||
(1 + self.alert_cutoff, []),
|
||||
)
|
||||
|
||||
self.assertNotIn("end", [t for t, _ in self.review_summary()])
|
||||
self.assertIsNotNone(self.maintainer.active_review_segments.get(CAMERA))
|
||||
|
||||
def test_alert_ends_after_cutoff_at_last_alert_activity(self) -> None:
|
||||
self.feed(
|
||||
(1, [self.tracked("p1", "person", 1)]),
|
||||
(5, [self.tracked("p1", "person", 5)]),
|
||||
(6, []),
|
||||
(5 + self.alert_cutoff + 1, []),
|
||||
)
|
||||
|
||||
end = self.reviews()[-1]
|
||||
self.assertEqual(end["type"], "end")
|
||||
self.assertEqual(end["after"]["severity"], "alert")
|
||||
self.assertEqual(end["after"]["end_time"], 5)
|
||||
self.assertIsNone(self.maintainer.active_review_segments.get(CAMERA))
|
||||
|
||||
def test_ongoing_alert_activity_extends_alert(self) -> None:
|
||||
last_activity = 1 + self.alert_cutoff * 2
|
||||
self.feed(
|
||||
(1, [self.tracked("p1", "person", 1)]),
|
||||
(
|
||||
1 + self.alert_cutoff,
|
||||
[self.tracked("p1", "person", 1 + self.alert_cutoff)],
|
||||
),
|
||||
(last_activity, [self.tracked("p1", "person", last_activity)]),
|
||||
(last_activity + 1, []),
|
||||
)
|
||||
|
||||
self.assertNotIn("end", [t for t, _ in self.review_summary()])
|
||||
self.assertEqual(
|
||||
self.maintainer.active_review_segments[CAMERA].last_alert_time,
|
||||
last_activity,
|
||||
)
|
||||
|
||||
def test_detection_ends_after_cutoff_at_last_detection_activity(self) -> None:
|
||||
self.feed(
|
||||
(1, [self.tracked("d1", "dog", 1)]),
|
||||
(5, [self.tracked("d1", "dog", 5)]),
|
||||
(5 + self.detection_cutoff, []),
|
||||
)
|
||||
self.assertNotIn("end", [t for t, _ in self.review_summary()])
|
||||
|
||||
self.feed((5 + self.detection_cutoff + 1, []))
|
||||
|
||||
end = self.reviews()[-1]
|
||||
self.assertEqual(end["type"], "end")
|
||||
self.assertEqual(end["after"]["severity"], "detection")
|
||||
self.assertEqual(end["after"]["end_time"], 5)
|
||||
self.assertIsNone(self.maintainer.active_review_segments.get(CAMERA))
|
||||
|
||||
def test_stationary_object_does_not_extend_alert(self) -> None:
|
||||
self.feed(
|
||||
(1, [self.tracked("c1", "car", 1)]),
|
||||
(2, [self.tracked("c1", "car", 2, stationary=True)]),
|
||||
(
|
||||
2 + self.alert_cutoff,
|
||||
[self.tracked("c1", "car", 2 + self.alert_cutoff, stationary=True)],
|
||||
),
|
||||
)
|
||||
|
||||
end = self.reviews()[-1]
|
||||
self.assertEqual(end["type"], "end")
|
||||
self.assertEqual(end["after"]["end_time"], 1)
|
||||
|
||||
def test_new_activity_after_end_creates_new_review(self) -> None:
|
||||
self.feed(
|
||||
(1, [self.tracked("p1", "person", 1)]),
|
||||
(2 + self.alert_cutoff, []),
|
||||
(3 + self.alert_cutoff, [self.tracked("d1", "dog", 3 + self.alert_cutoff)]),
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
self.non_update_summary(),
|
||||
[("new", "alert"), ("end", "alert"), ("new", "detection")],
|
||||
)
|
||||
ids = {r["after"]["id"] for r in self.reviews() if r["type"] != "update"}
|
||||
self.assertEqual(len(ids), 2)
|
||||
|
||||
def test_alert_splits_into_detection_when_detection_activity_continues(
|
||||
self,
|
||||
) -> None:
|
||||
# the person leaves after the first frame while the dog keeps moving
|
||||
dog_frames = [
|
||||
(t, [self.tracked("d1", "dog", t, start_time=1)])
|
||||
for t in range(11, 2 + self.alert_cutoff + 10, 10)
|
||||
]
|
||||
self.feed(
|
||||
(
|
||||
1,
|
||||
[
|
||||
self.tracked("p1", "person", 1, start_time=1),
|
||||
self.tracked("d1", "dog", 1, start_time=1),
|
||||
],
|
||||
),
|
||||
*dog_frames,
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
self.non_update_summary(),
|
||||
[("new", "alert"), ("end", "alert"), ("new", "detection")],
|
||||
)
|
||||
alert_end = next(r for r in self.reviews() if r["type"] == "end")
|
||||
self.assertEqual(alert_end["after"]["end_time"], 1)
|
||||
|
||||
detection = self.maintainer.active_review_segments[CAMERA]
|
||||
self.assertEqual(detection.severity.value, "detection")
|
||||
self.assertEqual(detection.start_time, 11)
|
||||
self.assertEqual(list(detection.detections.values()), ["dog"])
|
||||
|
||||
# the detection ends once the dog stops moving
|
||||
last_dog_time = dog_frames[-1][0]
|
||||
self.feed((last_dog_time + self.detection_cutoff + 1, []))
|
||||
|
||||
end = self.reviews()[-1]
|
||||
self.assertEqual(end["type"], "end")
|
||||
self.assertEqual(end["after"]["severity"], "detection")
|
||||
self.assertEqual(end["after"]["id"], detection.id)
|
||||
self.assertEqual(end["after"]["end_time"], last_dog_time)
|
||||
|
||||
def test_detection_starting_after_alert_activity_is_split_out(self) -> None:
|
||||
# the dog only shows up after the person has left
|
||||
dog_frames = [
|
||||
(t, [self.tracked("d1", "dog", t, start_time=11)])
|
||||
for t in range(11, 2 + self.alert_cutoff + 10, 10)
|
||||
]
|
||||
self.feed((1, [self.tracked("p1", "person", 1, start_time=1)]), *dog_frames)
|
||||
|
||||
self.assertEqual(
|
||||
self.non_update_summary(),
|
||||
[("new", "alert"), ("end", "alert"), ("new", "detection")],
|
||||
)
|
||||
alert_end = next(r for r in self.reviews() if r["type"] == "end")
|
||||
self.assertEqual(alert_end["after"]["end_time"], 1)
|
||||
self.assertEqual(alert_end["after"]["data"]["objects"], ["person"])
|
||||
|
||||
detection = self.maintainer.active_review_segments[CAMERA]
|
||||
self.assertEqual(detection.severity.value, "detection")
|
||||
self.assertEqual(detection.start_time, 11)
|
||||
self.assertEqual(list(detection.detections.values()), ["dog"])
|
||||
|
||||
def test_detection_leaving_before_alert_cutoff_gets_detection(self) -> None:
|
||||
# the dog comes and goes after the person left, all before the alert
|
||||
# cutoff, so nothing is active when the alert ends
|
||||
self.feed(
|
||||
(1, [self.tracked("p1", "person", 1, start_time=1)]),
|
||||
(11, [self.tracked("d1", "dog", 11, start_time=11)]),
|
||||
(21, [self.tracked("d1", "dog", 21, start_time=11)]),
|
||||
(31, []),
|
||||
(2 + self.alert_cutoff, []),
|
||||
(22 + self.detection_cutoff, []),
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
self.non_update_summary(),
|
||||
[
|
||||
("new", "alert"),
|
||||
("end", "alert"),
|
||||
("new", "detection"),
|
||||
("end", "detection"),
|
||||
],
|
||||
)
|
||||
ends = [r["after"] for r in self.reviews() if r["type"] == "end"]
|
||||
self.assertEqual(ends[0]["end_time"], 1)
|
||||
self.assertEqual(ends[0]["data"]["objects"], ["person"])
|
||||
self.assertEqual(ends[1]["data"]["objects"], ["dog"])
|
||||
self.assertEqual(ends[1]["start_time"], 11)
|
||||
self.assertEqual(ends[1]["end_time"], 21)
|
||||
|
||||
def test_detection_older_than_detection_cutoff_gets_detection(self) -> None:
|
||||
# the dog is gone longer than the detection cutoff by the time the
|
||||
# alert ends, its activity still needs a detection
|
||||
self.feed(
|
||||
(1, [self.tracked("p1", "person", 1, start_time=1)]),
|
||||
(3, [self.tracked("d1", "dog", 3, start_time=3)]),
|
||||
(5, [self.tracked("d1", "dog", 5, start_time=3)]),
|
||||
(6, []),
|
||||
(2 + self.alert_cutoff, []),
|
||||
(3 + self.alert_cutoff, []),
|
||||
)
|
||||
self.assertGreater(2 + self.alert_cutoff, 5 + self.detection_cutoff)
|
||||
|
||||
self.assertEqual(
|
||||
self.non_update_summary(),
|
||||
[
|
||||
("new", "alert"),
|
||||
("end", "alert"),
|
||||
("new", "detection"),
|
||||
("end", "detection"),
|
||||
],
|
||||
)
|
||||
ends = [r["after"] for r in self.reviews() if r["type"] == "end"]
|
||||
self.assertEqual(ends[0]["end_time"], 1)
|
||||
self.assertEqual(ends[0]["data"]["objects"], ["person"])
|
||||
self.assertEqual(ends[1]["data"]["objects"], ["dog"])
|
||||
self.assertEqual(ends[1]["start_time"], 3)
|
||||
self.assertEqual(ends[1]["end_time"], 5)
|
||||
self.assertIsNone(self.maintainer.active_review_segments.get(CAMERA))
|
||||
|
||||
def test_separate_detection_activity_after_alert_is_not_combined(self) -> None:
|
||||
# the dog and cat are seen further apart than the detection cutoff
|
||||
# while the alert is waiting to be cut off
|
||||
cat_time = 3 + self.detection_cutoff + 6
|
||||
self.assertLess(cat_time, 1 + self.alert_cutoff)
|
||||
self.feed(
|
||||
(1, [self.tracked("p1", "person", 1, start_time=1)]),
|
||||
(3, [self.tracked("d1", "dog", 3, start_time=3)]),
|
||||
(4, []),
|
||||
(cat_time, [self.tracked("c1", "cat", cat_time, start_time=cat_time)]),
|
||||
(2 + self.alert_cutoff, []),
|
||||
(cat_time + self.detection_cutoff + 1, []),
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
self.non_update_summary(),
|
||||
[
|
||||
("new", "alert"),
|
||||
("end", "alert"),
|
||||
("new", "detection"),
|
||||
("end", "detection"),
|
||||
("new", "detection"),
|
||||
("end", "detection"),
|
||||
],
|
||||
)
|
||||
ends = [r["after"] for r in self.reviews() if r["type"] == "end"]
|
||||
self.assertEqual(ends[0]["data"]["objects"], ["person"])
|
||||
self.assertEqual(ends[1]["data"]["objects"], ["dog"])
|
||||
self.assertEqual((ends[1]["start_time"], ends[1]["end_time"]), (3, 3))
|
||||
self.assertEqual(ends[2]["data"]["objects"], ["cat"])
|
||||
self.assertEqual(
|
||||
(ends[2]["start_time"], ends[2]["end_time"]), (cat_time, cat_time)
|
||||
)
|
||||
for detection in ends[1:]:
|
||||
self.assertTrue(Path(detection["thumb_path"]).is_file())
|
||||
self.assertIsNotNone(detection["data"]["thumb_time"])
|
||||
|
||||
def test_resumed_alert_publishes_pending_detection_objects(self) -> None:
|
||||
self.feed(
|
||||
(1, [self.tracked("p1", "person", 1, start_time=1)]),
|
||||
(5, [self.tracked("d1", "dog", 5, start_time=5)]),
|
||||
(10, [self.tracked("p1", "person", 10, start_time=1)]),
|
||||
)
|
||||
|
||||
latest = self.reviews()[-1]
|
||||
self.assertEqual(latest["type"], "update")
|
||||
self.assertEqual(latest["after"]["severity"], "alert")
|
||||
self.assertCountEqual(latest["after"]["data"]["objects"], ["person", "dog"])
|
||||
|
||||
def test_split_detection_has_thumbnail_of_its_activity(self) -> None:
|
||||
dog_frames = [
|
||||
(t, [self.tracked("d1", "dog", t, start_time=11)])
|
||||
for t in range(11, 2 + self.alert_cutoff + 10, 10)
|
||||
]
|
||||
self.feed((1, [self.tracked("p1", "person", 1, start_time=1)]), *dog_frames)
|
||||
|
||||
new_detection = next(
|
||||
r["after"]
|
||||
for r in self.reviews()
|
||||
if r["type"] == "new" and r["after"]["severity"] == "detection"
|
||||
)
|
||||
self.assertTrue(Path(new_detection["thumb_path"]).is_file())
|
||||
# captured from the dog's first frame, not a later fallback frame
|
||||
self.assertIsNotNone(new_detection["data"]["thumb_time"])
|
||||
self.assertIn(
|
||||
f"{CAMERA}_11",
|
||||
[c.args[0] for c in self.maintainer.frame_manager.get.call_args_list],
|
||||
)
|
||||
|
||||
def assert_force_end_publishes_pending_detection(self, topic: str) -> None:
|
||||
# the dog is held as a pending detection when the alert is ended
|
||||
self.feed(
|
||||
(1, [self.tracked("p1", "person", 1, start_time=1)]),
|
||||
(5, [self.tracked("d1", "dog", 5, start_time=5)]),
|
||||
)
|
||||
|
||||
if topic == "remove":
|
||||
# the config updater has already dropped the camera
|
||||
self.maintainer.config.cameras.pop(CAMERA)
|
||||
|
||||
self.maintainer.config_subscriber.check_for_updates.side_effect = [
|
||||
{topic: [CAMERA]}
|
||||
]
|
||||
self.feed()
|
||||
|
||||
self.assertEqual(
|
||||
self.non_update_summary(),
|
||||
[
|
||||
("new", "alert"),
|
||||
("end", "alert"),
|
||||
("new", "detection"),
|
||||
("end", "detection"),
|
||||
],
|
||||
)
|
||||
end = self.reviews()[-1]["after"]
|
||||
self.assertEqual(end["data"]["objects"], ["dog"])
|
||||
self.assertEqual((end["start_time"], end["end_time"]), (5, 5))
|
||||
self.assertIsNone(self.maintainer.active_review_segments.get(CAMERA))
|
||||
|
||||
# every thumbnail on disk belongs to a published review
|
||||
published = {Path(r["after"]["thumb_path"]).name for r in self.reviews()}
|
||||
self.assertEqual(self.thumbnails(), published)
|
||||
|
||||
def test_disabled_camera_publishes_pending_detections(self) -> None:
|
||||
self.assert_force_end_publishes_pending_detection("enabled")
|
||||
|
||||
def test_removed_camera_publishes_pending_detections(self) -> None:
|
||||
self.assert_force_end_publishes_pending_detection("remove")
|
||||
|
||||
def test_pending_thumbnail_removed_when_alert_resumes(self) -> None:
|
||||
self.feed(
|
||||
(1, [self.tracked("p1", "person", 1, start_time=1)]),
|
||||
(5, [self.tracked("d1", "dog", 5, start_time=5)]),
|
||||
)
|
||||
alert = self.maintainer.active_review_segments[CAMERA]
|
||||
pending_thumb = Path(alert.pending_detections[0].frame_path)
|
||||
self.assertTrue(pending_thumb.is_file())
|
||||
|
||||
self.feed((10, [self.tracked("p1", "person", 10, start_time=1)]))
|
||||
|
||||
self.assertEqual(alert.pending_detections, [])
|
||||
self.assertFalse(pending_thumb.exists())
|
||||
self.assertEqual(self.thumbnails(), {Path(alert.frame_path).name})
|
||||
|
||||
def test_multiple_pending_objects_share_one_detection(self) -> None:
|
||||
# objects within the detection cutoff of each other, whether seen
|
||||
# together or later, make up one detection
|
||||
self.feed(
|
||||
(1, [self.tracked("p1", "person", 1, start_time=1)]),
|
||||
(
|
||||
5,
|
||||
[
|
||||
self.tracked("d1", "dog", 5, start_time=5),
|
||||
self.tracked("c1", "cat", 5, start_time=5),
|
||||
],
|
||||
),
|
||||
(20, [self.tracked("b1", "bird", 20, start_time=20)]),
|
||||
(21, []),
|
||||
(2 + self.alert_cutoff, []),
|
||||
(21 + self.detection_cutoff, []),
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
self.non_update_summary(),
|
||||
[
|
||||
("new", "alert"),
|
||||
("end", "alert"),
|
||||
("new", "detection"),
|
||||
("end", "detection"),
|
||||
],
|
||||
)
|
||||
end = self.reviews()[-1]["after"]
|
||||
self.assertCountEqual(end["data"]["objects"], ["dog", "cat", "bird"])
|
||||
self.assertCountEqual(end["data"]["detections"], ["d1", "c1", "b1"])
|
||||
self.assertEqual((end["start_time"], end["end_time"]), (5, 20))
|
||||
# the thumbnail is framed on both objects seen together, the bird
|
||||
# alone is fewer objects so it does not replace it
|
||||
frames = [c.args[0] for c in self.maintainer.frame_manager.get.call_args_list]
|
||||
self.assertIn(f"{CAMERA}_5", frames)
|
||||
self.assertNotIn(f"{CAMERA}_20", frames)
|
||||
|
||||
|
||||
class TestSplitDetectionLabels(ReviewFlowTestCase):
|
||||
review_config = """
|
||||
review:
|
||||
alerts:
|
||||
labels:
|
||||
- person
|
||||
"""
|
||||
|
||||
def test_split_detection_keeps_sub_labels_and_zones(self) -> None:
|
||||
self.feed(
|
||||
(1, [self.tracked("p1", "person", 1, start_time=1)]),
|
||||
(
|
||||
5,
|
||||
[
|
||||
self.tracked(
|
||||
"d1",
|
||||
"dog",
|
||||
5,
|
||||
start_time=5,
|
||||
zones=["yard"],
|
||||
sub_label=("Rex", 0.95),
|
||||
),
|
||||
self.tracked(
|
||||
"c1",
|
||||
"car",
|
||||
5,
|
||||
start_time=5,
|
||||
zones=["driveway"],
|
||||
sub_label=("fedex", 0.9),
|
||||
),
|
||||
],
|
||||
),
|
||||
(2 + self.alert_cutoff, []),
|
||||
)
|
||||
|
||||
ends = [r["after"] for r in self.reviews() if r["type"] == "end"]
|
||||
self.assertEqual(len(ends), 2)
|
||||
alert_end, detection = ends
|
||||
self.assertEqual(alert_end["data"]["objects"], ["person"])
|
||||
self.assertEqual(alert_end["data"]["sub_labels"], [])
|
||||
self.assertEqual(alert_end["data"]["zones"], [])
|
||||
|
||||
self.assertEqual(detection["severity"], "detection")
|
||||
# attributes replace the label, other sub labels verify it
|
||||
self.assertCountEqual(detection["data"]["objects"], ["dog-verified", "fedex"])
|
||||
self.assertEqual(detection["data"]["verified_objects"], ["dog-verified"])
|
||||
self.assertEqual(detection["data"]["sub_labels"], ["Rex"])
|
||||
self.assertCountEqual(detection["data"]["zones"], ["yard", "driveway"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,16 +1,24 @@
|
||||
"""Tests for embedding cleanup on the main Frigate database.
|
||||
"""Tests for embedding storage and cleanup on the main Frigate database.
|
||||
|
||||
Embeddings are deleted whether or not semantic search is currently enabled, so
|
||||
the delete path has to tolerate databases where the vec0 tables were never
|
||||
created and installs where the sqlite-vec extension is unavailable.
|
||||
|
||||
The write paths need the real extension, since the behavior under test belongs
|
||||
to vec0 itself, so those tests are skipped when it is not installed.
|
||||
"""
|
||||
|
||||
import os
|
||||
import struct
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from peewee import OperationalError
|
||||
|
||||
from frigate.db.sqlitevecq import SqliteVecQueueDatabase
|
||||
|
||||
VEC_EXTENSION_PATH = "/usr/local/lib/vec0.so"
|
||||
|
||||
|
||||
class TestDeleteEmbeddings(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
@@ -52,6 +60,21 @@ class TestDeleteEmbeddings(unittest.TestCase):
|
||||
|
||||
self.assertEqual(self._thumbnail_ids(), ["b"])
|
||||
|
||||
def test_delete_failure_is_logged_not_raised(self) -> None:
|
||||
self._create_thumbnails_table()
|
||||
self.db.execute_sql(
|
||||
"""
|
||||
CREATE TRIGGER vec_thumbnails_no_delete BEFORE DELETE ON vec_thumbnails
|
||||
BEGIN SELECT RAISE(ABORT, 'delete blocked'); END
|
||||
"""
|
||||
).fetchall()
|
||||
|
||||
with self.assertLogs("frigate.db.sqlitevecq", level="ERROR") as logs:
|
||||
self.db.delete_embeddings_thumbnail(event_ids=["a"])
|
||||
|
||||
self.assertIn("Failed to delete embeddings", logs.output[0])
|
||||
self.assertEqual(self._thumbnail_ids(), ["a", "b"])
|
||||
|
||||
def test_delete_skipped_without_extension(self) -> None:
|
||||
self._create_thumbnails_table()
|
||||
self.db.load_vec_extension = False
|
||||
@@ -61,3 +84,104 @@ class TestDeleteEmbeddings(unittest.TestCase):
|
||||
|
||||
# the vec0 tables cannot be written without the extension
|
||||
self.assertEqual(self._thumbnail_ids(), ["a", "b"])
|
||||
|
||||
|
||||
def _vector(value: float) -> bytes:
|
||||
return struct.pack("768f", *([value] * 768))
|
||||
|
||||
|
||||
@unittest.skipUnless(
|
||||
os.path.exists(VEC_EXTENSION_PATH), "sqlite-vec extension is not installed"
|
||||
)
|
||||
class TestEmbeddingsTableWrites(unittest.TestCase):
|
||||
"""Covers the vec0 writes behind semantic search reindexing."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.tmp_dir = tempfile.TemporaryDirectory()
|
||||
self.db = SqliteVecQueueDatabase(
|
||||
os.path.join(self.tmp_dir.name, "test.db"), load_vec_extension=True
|
||||
)
|
||||
self.db.start()
|
||||
self.db.create_embeddings_tables()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.db.stop()
|
||||
self.db.close()
|
||||
self.tmp_dir.cleanup()
|
||||
|
||||
def _vec_tables(self) -> list[str]:
|
||||
return [
|
||||
row[0]
|
||||
for row in self.db.execute_sql(
|
||||
"SELECT name FROM sqlite_master WHERE name LIKE 'vec_%' ORDER BY name"
|
||||
)
|
||||
]
|
||||
|
||||
def _make_legacy(self, table: str) -> None:
|
||||
# sqlite-vec added the _info shadow table in 0.1.6, so tables written by
|
||||
# Frigate 0.17 and earlier do not have one
|
||||
self.db.execute_sql(f"DROP TABLE {table}_info").fetchall()
|
||||
|
||||
def _stored(self, table: str, column: str, event_id: str) -> str | None:
|
||||
row = self.db.execute_sql(
|
||||
f"SELECT vec_to_json({column}) FROM {table} WHERE id = ?", (event_id,)
|
||||
).fetchone()
|
||||
return row[0] if row else None
|
||||
|
||||
def test_write_error_is_raised(self) -> None:
|
||||
# queued writes hide their exception in the returned cursor
|
||||
with self.assertRaises(OperationalError):
|
||||
self.db.execute_write("INSERT INTO vec_missing(id) VALUES ('a')")
|
||||
|
||||
def test_drop_tables_removes_legacy_tables(self) -> None:
|
||||
self._make_legacy("vec_thumbnails")
|
||||
self._make_legacy("vec_descriptions")
|
||||
|
||||
self.db.drop_embeddings_tables()
|
||||
|
||||
self.assertEqual(self._vec_tables(), [])
|
||||
|
||||
def test_drop_tables_without_any_tables_does_not_raise(self) -> None:
|
||||
self.db.drop_embeddings_tables()
|
||||
|
||||
self.db.drop_embeddings_tables()
|
||||
|
||||
def test_upsert_replaces_existing_embedding(self) -> None:
|
||||
self.db.upsert_embeddings(
|
||||
"vec_thumbnails", "thumbnail_embedding", {"evt1": _vector(0.01)}
|
||||
)
|
||||
|
||||
self.db.upsert_embeddings(
|
||||
"vec_thumbnails", "thumbnail_embedding", {"evt1": _vector(0.99)}
|
||||
)
|
||||
|
||||
stored = self._stored("vec_thumbnails", "thumbnail_embedding", "evt1")
|
||||
self.assertTrue(stored.startswith("[0.990000"), stored)
|
||||
|
||||
def test_upsert_keeps_one_row_per_event(self) -> None:
|
||||
for _ in range(3):
|
||||
self.db.upsert_embeddings(
|
||||
"vec_descriptions", "description_embedding", {"evt1": _vector(0.5)}
|
||||
)
|
||||
|
||||
count = self.db.execute_sql(
|
||||
"SELECT count(*) FROM vec_descriptions WHERE id = 'evt1'"
|
||||
).fetchone()[0]
|
||||
self.assertEqual(count, 1)
|
||||
|
||||
def test_reindex_cycle_rewrites_legacy_tables(self) -> None:
|
||||
"""The 0.18 upgrade path: old vectors in, new vectors out."""
|
||||
self.db.upsert_embeddings(
|
||||
"vec_thumbnails", "thumbnail_embedding", {"evt1": _vector(0.01)}
|
||||
)
|
||||
self._make_legacy("vec_thumbnails")
|
||||
self._make_legacy("vec_descriptions")
|
||||
|
||||
self.db.drop_embeddings_tables()
|
||||
self.db.create_embeddings_tables()
|
||||
self.db.upsert_embeddings(
|
||||
"vec_thumbnails", "thumbnail_embedding", {"evt1": _vector(0.99)}
|
||||
)
|
||||
|
||||
stored = self._stored("vec_thumbnails", "thumbnail_embedding", "evt1")
|
||||
self.assertTrue(stored.startswith("[0.990000"), stored)
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from frigate.events.types import EventStateEnum
|
||||
from frigate.models import Timeline
|
||||
from frigate.timeline import TimelineProcessor
|
||||
|
||||
|
||||
def make_event(has_clip: bool, has_snapshot: bool) -> dict:
|
||||
return {
|
||||
"id": "event-1",
|
||||
"frame_time": 1000.0,
|
||||
"box": [0, 0, 10, 10],
|
||||
"region": [0, 0, 100, 100],
|
||||
"label": "car",
|
||||
"sub_label": None,
|
||||
"score": 0.8,
|
||||
"has_clip": has_clip,
|
||||
"has_snapshot": has_snapshot,
|
||||
"current_zones": [],
|
||||
"stationary": False,
|
||||
"attributes": {},
|
||||
"current_attributes": [],
|
||||
}
|
||||
|
||||
|
||||
class TestTimelineProcessor(unittest.TestCase):
|
||||
def setUp(self):
|
||||
camera_config = MagicMock()
|
||||
camera_config.detect.width = 1280
|
||||
camera_config.detect.height = 720
|
||||
config = MagicMock()
|
||||
config.cameras.get.return_value = camera_config
|
||||
self.processor = TimelineProcessor(config, MagicMock(), MagicMock())
|
||||
|
||||
@patch.object(Timeline, "insert")
|
||||
def test_unsaved_event_writes_no_timeline_rows(self, insert):
|
||||
event = make_event(has_clip=False, has_snapshot=False)
|
||||
self.processor.handle_object_detection(
|
||||
"front", EventStateEnum.start, None, event
|
||||
)
|
||||
self.processor.handle_object_detection(
|
||||
"front", EventStateEnum.end, event, event
|
||||
)
|
||||
|
||||
insert.assert_not_called()
|
||||
self.assertEqual(self.processor.pre_event_cache, {})
|
||||
|
||||
@patch.object(Timeline, "insert")
|
||||
def test_cached_entries_flush_when_event_is_saved(self, insert):
|
||||
start = make_event(has_clip=False, has_snapshot=False)
|
||||
self.processor.handle_object_detection(
|
||||
"front", EventStateEnum.start, None, start
|
||||
)
|
||||
insert.assert_not_called()
|
||||
|
||||
end = make_event(has_clip=True, has_snapshot=False)
|
||||
self.processor.handle_object_detection("front", EventStateEnum.end, start, end)
|
||||
|
||||
class_types = [c.args[0][Timeline.class_type] for c in insert.call_args_list]
|
||||
self.assertEqual(class_types, ["visible", "gone"])
|
||||
self.assertEqual(self.processor.pre_event_cache, {})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -191,6 +191,9 @@ class TimelineProcessor(threading.Thread):
|
||||
timeline_entry[Timeline.class_type] = "gone"
|
||||
self.insert_or_save(timeline_entry, prev_event_data, event_data)
|
||||
|
||||
# drop entries for events that ended without being saved
|
||||
self.pre_event_cache.pop(event_id, None)
|
||||
|
||||
def handle_api_entry(
|
||||
self,
|
||||
camera: str,
|
||||
|
||||
@@ -24,6 +24,7 @@ from frigate.comms.event_metadata_updater import (
|
||||
from frigate.comms.events_updater import EventEndSubscriber, EventUpdatePublisher
|
||||
from frigate.comms.inter_process import InterProcessRequestor
|
||||
from frigate.config import (
|
||||
CameraConfig,
|
||||
CameraMqttConfig,
|
||||
FrigateConfig,
|
||||
RecordConfig,
|
||||
@@ -129,8 +130,10 @@ class TrackedObjectProcessor(threading.Thread):
|
||||
)
|
||||
|
||||
def update(camera: str, obj: TrackedObject, frame_name: str) -> None:
|
||||
obj.has_snapshot = self.should_save_snapshot(camera, obj)
|
||||
obj.has_clip = self.should_retain_recording(camera, obj)
|
||||
obj.has_snapshot = self.should_save_snapshot(
|
||||
camera_state.camera_config, obj
|
||||
)
|
||||
obj.has_clip = self.should_retain_recording(camera_state.camera_config, obj)
|
||||
after = obj.to_dict()
|
||||
message = {
|
||||
"before": obj.previous,
|
||||
@@ -154,8 +157,10 @@ class TrackedObjectProcessor(threading.Thread):
|
||||
|
||||
def end(camera: str, obj: TrackedObject, frame_name: str) -> None:
|
||||
# populate has_snapshot
|
||||
obj.has_snapshot = self.should_save_snapshot(camera, obj)
|
||||
obj.has_clip = self.should_retain_recording(camera, obj)
|
||||
obj.has_snapshot = self.should_save_snapshot(
|
||||
camera_state.camera_config, obj
|
||||
)
|
||||
obj.has_clip = self.should_retain_recording(camera_state.camera_config, obj)
|
||||
|
||||
# write thumbnail to disk if it will be saved as an event
|
||||
if obj.has_snapshot or obj.has_clip:
|
||||
@@ -185,8 +190,8 @@ class TrackedObjectProcessor(threading.Thread):
|
||||
)
|
||||
|
||||
def snapshot(camera: str, obj: TrackedObject) -> bool:
|
||||
mqtt_config: CameraMqttConfig = self.config.cameras[camera].mqtt
|
||||
if mqtt_config.enabled and self.should_mqtt_snapshot(camera, obj):
|
||||
mqtt_config: CameraMqttConfig = camera_state.camera_config.mqtt
|
||||
if mqtt_config.enabled and self.should_mqtt_snapshot(mqtt_config, obj):
|
||||
jpg_bytes, _ = obj.get_img_bytes(
|
||||
ext="jpg",
|
||||
timestamp=mqtt_config.timestamp,
|
||||
@@ -241,11 +246,13 @@ class TrackedObjectProcessor(threading.Thread):
|
||||
with self.camera_states_lock:
|
||||
self.camera_states[camera] = camera_state
|
||||
|
||||
def should_save_snapshot(self, camera: str, obj: TrackedObject) -> bool:
|
||||
def should_save_snapshot(
|
||||
self, camera_config: CameraConfig, obj: TrackedObject
|
||||
) -> bool:
|
||||
if obj.false_positive:
|
||||
return False
|
||||
|
||||
snapshot_config: SnapshotsConfig = self.config.cameras[camera].snapshots
|
||||
snapshot_config: SnapshotsConfig = camera_config.snapshots
|
||||
|
||||
if not snapshot_config.enabled:
|
||||
return False
|
||||
@@ -264,11 +271,13 @@ class TrackedObjectProcessor(threading.Thread):
|
||||
|
||||
return True
|
||||
|
||||
def should_retain_recording(self, camera: str, obj: TrackedObject) -> bool:
|
||||
def should_retain_recording(
|
||||
self, camera_config: CameraConfig, obj: TrackedObject
|
||||
) -> bool:
|
||||
if obj.false_positive:
|
||||
return False
|
||||
|
||||
record_config: RecordConfig = self.config.cameras[camera].record
|
||||
record_config: RecordConfig = camera_config.record
|
||||
|
||||
# Recording is disabled
|
||||
if not record_config.enabled:
|
||||
@@ -284,13 +293,15 @@ class TrackedObjectProcessor(threading.Thread):
|
||||
|
||||
return True
|
||||
|
||||
def should_mqtt_snapshot(self, camera: str, obj: TrackedObject) -> bool:
|
||||
def should_mqtt_snapshot(
|
||||
self, mqtt_config: CameraMqttConfig, obj: TrackedObject
|
||||
) -> bool:
|
||||
# object never changed position
|
||||
if obj.is_stationary():
|
||||
return False
|
||||
|
||||
# if there are required zones and there is no overlap
|
||||
required_zones = self.config.cameras[camera].mqtt.required_zones
|
||||
required_zones = mqtt_config.required_zones
|
||||
if len(required_zones) > 0 and not set(obj.entered_zones) & set(required_zones):
|
||||
logger.debug(
|
||||
f"Not sending mqtt for {obj.obj_data['id']} because it did not enter required zones"
|
||||
@@ -300,7 +311,11 @@ class TrackedObjectProcessor(threading.Thread):
|
||||
return True
|
||||
|
||||
def update_mqtt_motion(
|
||||
self, camera: str, frame_time: float, motion_boxes: list
|
||||
self,
|
||||
camera: str,
|
||||
camera_config: CameraConfig,
|
||||
frame_time: float,
|
||||
motion_boxes: list,
|
||||
) -> None:
|
||||
# publish if motion is currently being detected
|
||||
if motion_boxes:
|
||||
@@ -315,7 +330,7 @@ class TrackedObjectProcessor(threading.Thread):
|
||||
# always updated latest motion
|
||||
self.last_motion_detected[camera] = frame_time
|
||||
elif self.last_motion_detected.get(camera, 0) > 0:
|
||||
mqtt_delay = self.config.cameras[camera].motion.mqtt_off_delay
|
||||
mqtt_delay = camera_config.motion.mqtt_off_delay
|
||||
|
||||
# If no motion, make sure the off_delay has passed
|
||||
if frame_time - self.last_motion_detected.get(camera, 0) >= mqtt_delay:
|
||||
@@ -818,7 +833,7 @@ class TrackedObjectProcessor(threading.Thread):
|
||||
frame_name, frame_time, current_tracked_objects, motion_boxes, regions
|
||||
)
|
||||
|
||||
self.update_mqtt_motion(camera, frame_time, motion_boxes)
|
||||
self.update_mqtt_motion(camera, camera_config, frame_time, motion_boxes)
|
||||
|
||||
tracked_objects = [
|
||||
o.to_dict() for o in camera_state.tracked_objects.values()
|
||||
|
||||
@@ -191,7 +191,7 @@ def migrate_frigate_config(config_file: str):
|
||||
|
||||
if previous_version < "0.14":
|
||||
logger.info(f"Migrating frigate config from {previous_version} to 0.14...")
|
||||
new_config = migrate_014(config)
|
||||
new_config = migrate_014(new_config)
|
||||
with open(config_file, "w") as f:
|
||||
yaml.dump(new_config, f)
|
||||
previous_version = "0.14"
|
||||
@@ -209,35 +209,35 @@ def migrate_frigate_config(config_file: str):
|
||||
|
||||
if previous_version < "0.15-0":
|
||||
logger.info(f"Migrating frigate config from {previous_version} to 0.15-0...")
|
||||
new_config = migrate_015_0(config)
|
||||
new_config = migrate_015_0(new_config)
|
||||
with open(config_file, "w") as f:
|
||||
yaml.dump(new_config, f)
|
||||
previous_version = "0.15-0"
|
||||
|
||||
if previous_version < "0.15-1":
|
||||
logger.info(f"Migrating frigate config from {previous_version} to 0.15-1...")
|
||||
new_config = migrate_015_1(config)
|
||||
new_config = migrate_015_1(new_config)
|
||||
with open(config_file, "w") as f:
|
||||
yaml.dump(new_config, f)
|
||||
previous_version = "0.15-1"
|
||||
|
||||
if previous_version < "0.16-0":
|
||||
logger.info(f"Migrating frigate config from {previous_version} to 0.16-0...")
|
||||
new_config = migrate_016_0(config)
|
||||
new_config = migrate_016_0(new_config)
|
||||
with open(config_file, "w") as f:
|
||||
yaml.dump(new_config, f)
|
||||
previous_version = "0.16-0"
|
||||
|
||||
if previous_version < "0.17-0":
|
||||
logger.info(f"Migrating frigate config from {previous_version} to 0.17-0...")
|
||||
new_config = migrate_017_0(config)
|
||||
new_config = migrate_017_0(new_config)
|
||||
with open(config_file, "w") as f:
|
||||
yaml.dump(new_config, f)
|
||||
previous_version = "0.17-0"
|
||||
|
||||
if previous_version < "0.18-0":
|
||||
logger.info(f"Migrating frigate config from {previous_version} to 0.18-0...")
|
||||
new_config = migrate_018_0(config)
|
||||
new_config = migrate_018_0(new_config)
|
||||
with open(config_file, "w") as f:
|
||||
yaml.dump(new_config, f)
|
||||
previous_version = "0.18-0"
|
||||
@@ -978,7 +978,7 @@ def migrate_models(config: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any
|
||||
", ".join(sorted(detector_types)),
|
||||
)
|
||||
|
||||
entry: dict[str, Any] = {"scene": "all", **model}
|
||||
entry: dict[str, Any] = {"scene": "default", **model}
|
||||
|
||||
if model_path:
|
||||
entry["path"] = model_path
|
||||
|
||||
@@ -14,6 +14,10 @@ from frigate.util.file import FileLock
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# (connect, read) seconds. The read timeout bounds each socket read rather than
|
||||
# the whole download, so large models still finish.
|
||||
DOWNLOAD_TIMEOUT = (15, 60)
|
||||
|
||||
# target path -> first line of the last download error for it; every existing
|
||||
# download function swallows its exceptions, so this is how the downloader
|
||||
# thread learns why a file is still missing
|
||||
@@ -124,7 +128,9 @@ class ModelDownloader:
|
||||
logger.info(f"Downloading model file from: {url}")
|
||||
|
||||
try:
|
||||
with requests.get(url, stream=True, allow_redirects=True) as r:
|
||||
with requests.get(
|
||||
url, stream=True, allow_redirects=True, timeout=DOWNLOAD_TIMEOUT
|
||||
) as r:
|
||||
r.raise_for_status()
|
||||
with open(temporary_filename, "wb") as f:
|
||||
for chunk in r.iter_content(chunk_size=8192):
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
"""Frigate-managed go2rtc live streams: transcoded rungs and bitrate probes."""
|
||||
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import requests
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from frigate.config import FrigateConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
GO2RTC_API = "http://127.0.0.1:1984/api"
|
||||
|
||||
DEFAULT_TRANSCODE_QUALITIES: list[dict[str, int]] = [
|
||||
{"height": 720, "bitrate": 1200},
|
||||
{"height": 480, "bitrate": 500},
|
||||
{"height": 360, "bitrate": 250},
|
||||
]
|
||||
|
||||
|
||||
def transcode_stream_name(camera: str, height: int) -> str:
|
||||
"""Return the go2rtc name of a camera's transcoded stream at one height."""
|
||||
return f"{camera}_transcode_{height}p"
|
||||
|
||||
|
||||
def is_transcode_stream_name(camera: str, name: str) -> bool:
|
||||
"""Return whether a stream name follows this camera's transcoded naming."""
|
||||
return re.fullmatch(rf"{re.escape(camera)}_transcode_\d+p", name) is not None
|
||||
|
||||
|
||||
def transcode_stream_source(source: str, height: int, bitrate: int) -> str:
|
||||
"""Build the go2rtc ffmpeg source that transcodes a stream to H.264.
|
||||
|
||||
go2rtc rejects API-registered sources containing spaces, so each ffmpeg
|
||||
token gets its own #raw= parameter. #audio=copy keeps audio, which any
|
||||
#video= option would otherwise drop.
|
||||
"""
|
||||
tokens = (
|
||||
"-b:v",
|
||||
f"{bitrate}k",
|
||||
"-maxrate",
|
||||
f"{bitrate}k",
|
||||
"-bufsize",
|
||||
f"{2 * bitrate}k",
|
||||
)
|
||||
raw = "".join(f"#raw={token}" for token in tokens)
|
||||
return f"ffmpeg:{source}#video=h264#height={height}#hardware#audio=copy{raw}"
|
||||
|
||||
|
||||
def transcode_streams(
|
||||
camera: str, source: str, qualities: list[dict[str, int]]
|
||||
) -> dict[str, str]:
|
||||
"""Map each quality's go2rtc stream name to its source."""
|
||||
return {
|
||||
transcode_stream_name(camera, quality["height"]): transcode_stream_source(
|
||||
source, quality["height"], quality["bitrate"]
|
||||
)
|
||||
for quality in qualities
|
||||
}
|
||||
|
||||
|
||||
def default_transcode_source(camera: str, streams: dict[str, str]) -> str | None:
|
||||
"""Return the first live stream that is not itself transcoded."""
|
||||
return next(
|
||||
(
|
||||
name
|
||||
for name in streams.values()
|
||||
if not is_transcode_stream_name(camera, name)
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def raw_transcode_streams(config: dict[str, Any]) -> dict[str, str]:
|
||||
"""Build transcoded go2rtc streams for every enabled camera in a raw config.
|
||||
|
||||
go2rtc's config is written before FrigateConfig validation runs, so this
|
||||
resolves defaults the same way the validator does.
|
||||
"""
|
||||
streams: dict[str, str] = {}
|
||||
global_streams = (config.get("live") or {}).get("streams") or {}
|
||||
|
||||
for camera, camera_config in (config.get("cameras") or {}).items():
|
||||
live = (camera_config or {}).get("live") or {}
|
||||
transcode = live.get("transcode") or {}
|
||||
|
||||
if not transcode.get("enabled"):
|
||||
continue
|
||||
|
||||
# cameras inherit global live streams after their own, like deep_merge
|
||||
camera_streams = live.get("streams") or {}
|
||||
live_streams = camera_streams | {
|
||||
label: name
|
||||
for label, name in global_streams.items()
|
||||
if label not in camera_streams
|
||||
}
|
||||
source = transcode.get("source") or default_transcode_source(
|
||||
camera, live_streams or {camera: camera}
|
||||
)
|
||||
|
||||
if source is None:
|
||||
continue
|
||||
|
||||
qualities = transcode.get("qualities")
|
||||
|
||||
if qualities is None:
|
||||
qualities = DEFAULT_TRANSCODE_QUALITIES
|
||||
|
||||
# malformed entries fail Frigate's validation; skipping them here keeps
|
||||
# go2rtc from crash-looping alongside it
|
||||
valid = [
|
||||
quality
|
||||
for quality in (qualities if isinstance(qualities, list) else [])
|
||||
if isinstance(quality, dict)
|
||||
and "height" in quality
|
||||
and "bitrate" in quality
|
||||
]
|
||||
streams.update(transcode_streams(camera, source, valid))
|
||||
|
||||
return streams
|
||||
|
||||
|
||||
def generated_transcode_streams(config: "FrigateConfig") -> dict[str, str]:
|
||||
"""Build transcoded go2rtc streams for every enabled camera."""
|
||||
streams: dict[str, str] = {}
|
||||
|
||||
for camera in config.cameras.values():
|
||||
transcode = camera.live.transcode
|
||||
|
||||
if transcode.enabled and transcode.source:
|
||||
streams.update(
|
||||
transcode_streams(
|
||||
camera.name,
|
||||
transcode.source,
|
||||
[quality.model_dump() for quality in transcode.qualities],
|
||||
)
|
||||
)
|
||||
|
||||
return streams
|
||||
|
||||
|
||||
def _go2rtc_streams_call(method: str, name: str, params: dict[str, str]) -> bool:
|
||||
try:
|
||||
response = requests.request(
|
||||
method, f"{GO2RTC_API}/streams", params=params, timeout=5
|
||||
)
|
||||
except requests.RequestException as e:
|
||||
logger.error("Failed to sync go2rtc stream %s: %s", name, e)
|
||||
return False
|
||||
|
||||
if not response.ok:
|
||||
logger.error("Failed to sync go2rtc stream %s: %s", name, response.text)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def sync_transcode_streams(old: dict[str, str], new: dict[str, str]) -> bool:
|
||||
"""Register added or changed streams with go2rtc and remove dropped ones.
|
||||
|
||||
Returns False when any go2rtc call failed.
|
||||
"""
|
||||
ok = True
|
||||
|
||||
for name, source in new.items():
|
||||
if old.get(name) != source:
|
||||
ok = _go2rtc_streams_call("put", name, {"name": name, "src": source}) and ok
|
||||
|
||||
for name in old.keys() - new.keys():
|
||||
ok = _go2rtc_streams_call("delete", name, {"src": name}) and ok
|
||||
|
||||
return ok
|
||||
|
||||
|
||||
def measure_stream_bitrate(
|
||||
name: str, duration: float = 6.0, warmup: float = 1.0
|
||||
) -> float | None:
|
||||
"""Measure a go2rtc stream's average kbps from its local MP4 output.
|
||||
|
||||
The first warmup seconds after the first byte are skipped so the startup
|
||||
keyframe burst does not inflate the result. Returns None when the stream
|
||||
sends nothing.
|
||||
"""
|
||||
first = start = now = None
|
||||
counted = 0
|
||||
|
||||
try:
|
||||
with requests.get(
|
||||
f"{GO2RTC_API}/stream.mp4",
|
||||
params={"src": name},
|
||||
stream=True,
|
||||
timeout=5,
|
||||
) as response:
|
||||
if not response.ok:
|
||||
return None
|
||||
|
||||
for chunk in response.iter_content(chunk_size=16384):
|
||||
now = time.monotonic()
|
||||
|
||||
if first is None:
|
||||
first = now
|
||||
|
||||
if now - first < warmup:
|
||||
continue
|
||||
|
||||
if start is None:
|
||||
start = now
|
||||
continue
|
||||
|
||||
counted += len(chunk)
|
||||
|
||||
if now - start >= duration:
|
||||
break
|
||||
except requests.RequestException as e:
|
||||
logger.warning("Failed to measure go2rtc stream %s: %s", name, e)
|
||||
return None
|
||||
|
||||
if start is None or now is None or now <= start or counted == 0:
|
||||
return None
|
||||
|
||||
return counted * 8 / 1000 / (now - start)
|
||||
@@ -213,17 +213,19 @@ def stream_has_audio(intervals: list[CoverageInterval], main: bool) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def null_audio_glitches(
|
||||
intervals: list[CoverageInterval], main_audio: bool, sub_audio: bool
|
||||
) -> list[CoverageInterval]:
|
||||
def null_audio_glitches(intervals: list[CoverageInterval]) -> list[CoverageInterval]:
|
||||
"""Treat video-only glitch rows on audio-bearing streams as no recording.
|
||||
|
||||
nginx-vod requires every clip in a sequence to carry the same track
|
||||
count, so a truncated video-only segment (a backend restart can flush
|
||||
a sub-second file before any audio packet landed) poisons every
|
||||
manifest that includes it. Nulling the row turns the glitch into a
|
||||
hole the span builder skips like any recording gap.
|
||||
hole the span builder skips like any recording gap. Every consumer of
|
||||
a window's coverage (the vod manifest, its realized timelines, and
|
||||
exports) goes through here, so they all agree on which rows exist.
|
||||
"""
|
||||
main_audio = stream_has_audio(intervals, main=True)
|
||||
sub_audio = stream_has_audio(intervals, main=False)
|
||||
result: list[CoverageInterval] = []
|
||||
for interval in intervals:
|
||||
main = interval.main
|
||||
@@ -449,9 +451,7 @@ def realized_timelines(
|
||||
assembles each variant's realized spans. Keyframe snapping reads the
|
||||
per-row index stored at record time, so no file is touched.
|
||||
"""
|
||||
main_audio = stream_has_audio(intervals, main=True)
|
||||
sub_audio = stream_has_audio(intervals, main=False)
|
||||
nulled = null_audio_glitches(intervals, main_audio, sub_audio)
|
||||
nulled = null_audio_glitches(intervals)
|
||||
|
||||
return {
|
||||
"auto": realized_timeline(nulled, None),
|
||||
|
||||
+27
-15
@@ -18,6 +18,7 @@ from frigate.comms.recordings_updater import (
|
||||
RecordingsDataTypeEnum,
|
||||
)
|
||||
from frigate.config import CameraConfig, LoggerConfig
|
||||
from frigate.config.camera.ffmpeg import CameraRoleEnum
|
||||
from frigate.config.camera.updater import (
|
||||
CameraConfigUpdateEnum,
|
||||
CameraConfigUpdateSubscriber,
|
||||
@@ -187,29 +188,45 @@ class CameraWatchdog(threading.Thread):
|
||||
# Status caching to reduce message volume
|
||||
self._last_detect_status: str | None = None
|
||||
self._last_record_status: dict[str, str] = {}
|
||||
self._last_status_update_time: float = 0.0
|
||||
self._last_detect_status_update_time: float = 0.0
|
||||
self._last_record_status_update_time: dict[str, float] = defaultdict(float)
|
||||
|
||||
def _send_detect_status(self, status: str, now: float) -> None:
|
||||
"""Send detect status only if changed or retry_interval has elapsed."""
|
||||
if (
|
||||
status != self._last_detect_status
|
||||
or (now - self._last_status_update_time) >= self.sleeptime
|
||||
or (now - self._last_detect_status_update_time) >= self.sleeptime
|
||||
):
|
||||
self.requestor.send_data(f"{self.config.name}/status/detect", status)
|
||||
self._last_detect_status = status
|
||||
self._last_status_update_time = now
|
||||
self._last_detect_status_update_time = now
|
||||
|
||||
def _send_record_status(self, stream_type: str, status: str, now: float) -> None:
|
||||
"""Send a record stream's status only if changed or retry_interval has elapsed."""
|
||||
if (
|
||||
status != self._last_record_status.get(stream_type)
|
||||
or (now - self._last_status_update_time) >= self.sleeptime
|
||||
or (now - self._last_record_status_update_time[stream_type])
|
||||
>= self.sleeptime
|
||||
):
|
||||
self.requestor.send_data(
|
||||
f"{self.config.name}/status/{STREAM_TYPE_TO_ROLE[stream_type]}", status
|
||||
)
|
||||
self._last_record_status[stream_type] = status
|
||||
self._last_status_update_time = now
|
||||
self._last_record_status_update_time[stream_type] = now
|
||||
|
||||
def _send_roles_offline(self, roles: list[CameraRoleEnum], now: float) -> None:
|
||||
"""Send offline status for each role of a restarted ffmpeg process."""
|
||||
for role in roles:
|
||||
# record roles go through the status cache so the recovery to
|
||||
# online is published once the stream is healthy again
|
||||
stream_type = ROLE_TO_STREAM_TYPE.get(role.value)
|
||||
|
||||
if stream_type is not None:
|
||||
self._send_record_status(stream_type, "offline", now)
|
||||
else:
|
||||
self.requestor.send_data(
|
||||
f"{self.config.name}/status/{role.value}", "offline"
|
||||
)
|
||||
|
||||
def _reset_segment_times(self) -> None:
|
||||
self.latest_valid_segment_time.clear()
|
||||
@@ -514,18 +531,16 @@ class CameraWatchdog(threading.Thread):
|
||||
ffmpeg_process=p["process"],
|
||||
)
|
||||
|
||||
for role in p["roles"]:
|
||||
self.requestor.send_data(
|
||||
f"{self.config.name}/status/{role.value}", "offline"
|
||||
)
|
||||
self._send_roles_offline(p["roles"], now)
|
||||
|
||||
self._grant_restart_grace(recorded_streams, now_utc)
|
||||
last_restart_time = now
|
||||
|
||||
continue
|
||||
elif stale_stream is None:
|
||||
for stream_type in recorded_streams:
|
||||
self._send_record_status(stream_type, "online", now)
|
||||
if poll is None:
|
||||
for stream_type in recorded_streams:
|
||||
self._send_record_status(stream_type, "online", now)
|
||||
|
||||
p["latest_segment_time"] = max(
|
||||
self.latest_cache_segment_time[stream_type]
|
||||
@@ -535,10 +550,7 @@ class CameraWatchdog(threading.Thread):
|
||||
if poll is None:
|
||||
continue
|
||||
|
||||
for role in p["roles"]:
|
||||
self.requestor.send_data(
|
||||
f"{self.config.name}/status/{role.value}", "offline"
|
||||
)
|
||||
self._send_roles_offline(p["roles"], now)
|
||||
|
||||
p["process"] = start_or_restart_ffmpeg(
|
||||
p["cmd"], self.logger, p["logpipe"], ffmpeg_process=p["process"]
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* Helpers for the live dashboard's draggable grid layout: reading and seeding
|
||||
* the persisted layout, and measuring rendered tiles.
|
||||
*
|
||||
* DraggableGridLayout persists through useUserPersistence, which namespaces
|
||||
* keys by username, and every write is an async idb put. A test that seeds the
|
||||
* bare key, or seeds before the app's own first write has landed, silently
|
||||
* asserts against a key the app never reads. persistedLayoutKey() closes both
|
||||
* holes, so prefer it over building the key by hand.
|
||||
*
|
||||
* Geometry has its own trap: the grid first lays out against window.innerWidth,
|
||||
* then reflows narrower once useResizeObserver reports the real container.
|
||||
* Tiles measured in separate round-trips can straddle that reflow and disagree
|
||||
* on scale, so cameraBoxes() takes every measurement in one evaluate.
|
||||
*
|
||||
* Used by live-grid-aspect-modes.spec.ts and masonry-live-grid.spec.ts.
|
||||
*/
|
||||
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
|
||||
export type LayoutItem = {
|
||||
i: string;
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
};
|
||||
|
||||
export type PersistedLayout = {
|
||||
version: number;
|
||||
naturalAspect: boolean;
|
||||
layout: LayoutItem[];
|
||||
};
|
||||
|
||||
function layoutKeySuffix(group: string): string {
|
||||
return `${group}-draggable-layout`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The key the app has actually written an envelope to, or undefined while its
|
||||
* first write is still in flight.
|
||||
*/
|
||||
function findWrittenKey(
|
||||
page: Page,
|
||||
group: string,
|
||||
): Promise<string | undefined> {
|
||||
return page.evaluate(
|
||||
(suffix) =>
|
||||
new Promise<string | undefined>((resolve) => {
|
||||
const open = indexedDB.open("keyval-store");
|
||||
open.onsuccess = () => {
|
||||
const store = open.result
|
||||
.transaction("keyval", "readonly")
|
||||
.objectStore("keyval");
|
||||
// getAllKeys and getAll both return in key order, so the indexes align
|
||||
const keys = store.getAllKeys();
|
||||
const values = store.getAll();
|
||||
keys.transaction.oncomplete = () => {
|
||||
open.result.close();
|
||||
const names = keys.result as string[];
|
||||
const stored = values.result as { version?: number }[];
|
||||
const match = names.findIndex(
|
||||
(name, index) =>
|
||||
(name === suffix || name.startsWith(`${suffix}:`)) &&
|
||||
typeof stored[index]?.version === "number",
|
||||
);
|
||||
resolve(match === -1 ? undefined : names[match]);
|
||||
};
|
||||
};
|
||||
open.onerror = () => resolve(undefined);
|
||||
}),
|
||||
layoutKeySuffix(group),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the grid to persist its own layout, then return the key it used.
|
||||
* Waiting for that write is what makes a later seed meaningful: it proves the
|
||||
* key is live, and it rules out the app overwriting the seed a moment later.
|
||||
*/
|
||||
export async function persistedLayoutKey(
|
||||
page: Page,
|
||||
group: string,
|
||||
): Promise<string> {
|
||||
let key: string | undefined;
|
||||
|
||||
await expect
|
||||
.poll(async () => (key = await findWrittenKey(page, group)), {
|
||||
timeout: 10_000,
|
||||
message: `grid never persisted a layout for group "${group}"`,
|
||||
})
|
||||
.not.toBeUndefined();
|
||||
|
||||
return key!;
|
||||
}
|
||||
|
||||
/** Overwrite the stored layout, resolving only once the put has committed. */
|
||||
export function seedLayout(
|
||||
page: Page,
|
||||
key: string,
|
||||
value: unknown,
|
||||
): Promise<void> {
|
||||
return page.evaluate(
|
||||
([key, value]) =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
const open = indexedDB.open("keyval-store");
|
||||
open.onupgradeneeded = () => open.result.createObjectStore("keyval");
|
||||
open.onsuccess = () => {
|
||||
const tx = open.result.transaction("keyval", "readwrite");
|
||||
tx.objectStore("keyval").put(value, key as string);
|
||||
tx.oncomplete = () => {
|
||||
open.result.close();
|
||||
resolve();
|
||||
};
|
||||
tx.onerror = () => reject(tx.error);
|
||||
};
|
||||
open.onerror = () => reject(open.error);
|
||||
}),
|
||||
[key, value] as const,
|
||||
);
|
||||
}
|
||||
|
||||
/** Read the stored layout back. Undefined until the app writes it. */
|
||||
export function readLayout(
|
||||
page: Page,
|
||||
key: string,
|
||||
): Promise<PersistedLayout | undefined> {
|
||||
return page.evaluate(
|
||||
(target) =>
|
||||
new Promise((resolve) => {
|
||||
const open = indexedDB.open("keyval-store");
|
||||
open.onsuccess = () => {
|
||||
const tx = open.result.transaction("keyval", "readonly");
|
||||
const request = tx.objectStore("keyval").get(target);
|
||||
tx.oncomplete = () => {
|
||||
open.result.close();
|
||||
resolve(request.result);
|
||||
};
|
||||
};
|
||||
open.onerror = () => resolve(undefined);
|
||||
}),
|
||||
key,
|
||||
) as Promise<PersistedLayout | undefined>;
|
||||
}
|
||||
|
||||
export type Box = { w: number; h: number; x: number; y: number };
|
||||
|
||||
/** The card is the player root; the cell is the grid slot it sits in. */
|
||||
export type BoxTarget = "card" | "cell";
|
||||
|
||||
/** One atomic snapshot, or null while any tile is missing or unlaid out. */
|
||||
function snapshotBoxes(
|
||||
page: Page,
|
||||
cameras: readonly string[],
|
||||
target: BoxTarget,
|
||||
): Promise<Record<string, Box> | null> {
|
||||
return page.evaluate(
|
||||
({ cams, target }) => {
|
||||
const boxes: Record<string, Box> = {};
|
||||
|
||||
for (const cam of cams) {
|
||||
const card = document.querySelector(`[data-camera='${cam}']`);
|
||||
const el = target === "cell" ? card?.closest(".p-1") : card;
|
||||
|
||||
if (!el) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const r = el.getBoundingClientRect();
|
||||
|
||||
// a re-rendering tile can briefly report no box at all
|
||||
if (!r.width || !r.height) {
|
||||
return null;
|
||||
}
|
||||
|
||||
boxes[cam] = { w: r.width, h: r.height, x: r.x, y: r.y };
|
||||
}
|
||||
|
||||
return boxes;
|
||||
},
|
||||
{ cams: cameras as readonly string[], target },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Measure the given cameras' tiles together, once they have all rendered.
|
||||
* Measuring in one evaluate is what keeps the numbers mutually comparable.
|
||||
*/
|
||||
export async function cameraBoxes<T extends string>(
|
||||
page: Page,
|
||||
cameras: readonly T[],
|
||||
target: BoxTarget = "cell",
|
||||
): Promise<Record<T, Box>> {
|
||||
let boxes: Record<string, Box> | null = null;
|
||||
|
||||
await expect
|
||||
.poll(async () => (boxes = await snapshotBoxes(page, cameras, target)), {
|
||||
timeout: 10_000,
|
||||
message: `${target}s never rendered for ${cameras.join(", ")}`,
|
||||
})
|
||||
.not.toBeNull();
|
||||
|
||||
return boxes as unknown as Record<T, Box>;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { FrigateApp } from "../fixtures/frigate-test";
|
||||
|
||||
// On mobile the System tabs sit in an OverflowStrip, which keeps an inert copy
|
||||
// of every tab for measurement and hides the ones that do not fit behind a
|
||||
// kebab. The selected tab always stays in the strip.
|
||||
|
||||
export function systemTab(frigateApp: FrigateApp, name: string) {
|
||||
return frigateApp.page
|
||||
.locator(`[aria-label="Select ${name}" i]:not([inert] *)`)
|
||||
.first();
|
||||
}
|
||||
@@ -45,6 +45,11 @@ export class LivePage extends BasePage {
|
||||
);
|
||||
}
|
||||
|
||||
/** Edit-layout toggle on the draggable grid (desktop, custom groups). */
|
||||
get editLayoutButton(): Locator {
|
||||
return this.page.getByTestId("toggle-edit-layout");
|
||||
}
|
||||
|
||||
/** Open the right-click context menu on a camera card (desktop only). */
|
||||
async openContextMenuOn(cameraName: string): Promise<Locator> {
|
||||
await this.cameraCard(cameraName).first().click({ button: "right" });
|
||||
|
||||
@@ -7,12 +7,13 @@
|
||||
*/
|
||||
|
||||
import { test, expect } from "../fixtures/frigate-test";
|
||||
import { systemTab } from "../helpers/system-tabs";
|
||||
import { viewerProfile } from "../fixtures/mock-data/profile";
|
||||
|
||||
test.describe("Auth — admin access @high", () => {
|
||||
test("admin /system renders general tab", async ({ frigateApp }) => {
|
||||
await frigateApp.goto("/system");
|
||||
await expect(frigateApp.page.getByLabel("Select general")).toBeVisible({
|
||||
await expect(systemTab(frigateApp, "general")).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
});
|
||||
@@ -28,7 +29,7 @@ test.describe("Auth — admin access @high", () => {
|
||||
|
||||
test("admin /logs renders frigate tab", async ({ frigateApp }) => {
|
||||
await frigateApp.goto("/logs");
|
||||
await expect(frigateApp.page.getByLabel("Select frigate")).toBeVisible({
|
||||
await expect(systemTab(frigateApp, "frigate")).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -216,6 +216,18 @@ test.describe("Explore — content @high", () => {
|
||||
// Similarity search URL param
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe("Explore: back button @high", () => {
|
||||
test("direct visits do not show a back button", async ({ frigateApp }) => {
|
||||
await frigateApp.goto("/explore?labels=person");
|
||||
await expect(frigateApp.page.getByLabel("Labels").first()).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(
|
||||
frigateApp.page.getByRole("button", { name: "Go back" }),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Explore — similarity search (desktop) @high", () => {
|
||||
test.skip(
|
||||
({ frigateApp }) => frigateApp.isMobile,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user