mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-09-30 11:56:49 +03:00
Compare commits
48
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef11f7bc50 | ||
|
|
61e50a366f | ||
|
|
4e196516dd | ||
|
|
3941355051 | ||
|
|
1a278630da | ||
|
|
9d0d8a99bb | ||
|
|
bbc412763d | ||
|
|
ac9ac50df5 | ||
|
|
93aa6c4174 | ||
|
|
26e6adee88 | ||
|
|
de416b7ae7 | ||
|
|
06967fec91 | ||
|
|
d69107de33 | ||
|
|
04480a18b6 | ||
|
|
b02aea03cd | ||
|
|
51171319a4 | ||
|
|
b1b725b80a | ||
|
|
77a66e75c6 | ||
|
|
4ac92dac72 | ||
|
|
6ceb370abb | ||
|
|
dfefc5a64b | ||
|
|
97dec9d046 | ||
|
|
ffcadb440e | ||
|
|
a381a88b29 | ||
|
|
9e74adf812 | ||
|
|
287fc42404 | ||
|
|
b4d5035b79 | ||
|
|
7497ef7506 | ||
|
|
0d5038c7a0 | ||
|
|
c71939acc2 | ||
|
|
e983825781 | ||
|
|
25681444d4 | ||
|
|
47d25254e9 | ||
|
|
3e6d60fcac | ||
|
|
77d6b0540d | ||
|
|
632af09f73 | ||
|
|
10eb677e4c | ||
|
|
bc3e4a0b35 | ||
|
|
08a13d955e | ||
|
|
ac16decf05 | ||
|
|
6e106854e4 | ||
|
|
c6688c8ce7 | ||
|
|
149362c77f | ||
|
|
2117b58aba | ||
|
|
a529656a90 | ||
|
|
c1b56b51b0 | ||
|
|
d37dff1b49 | ||
|
|
50a2b6729e |
@@ -1,7 +1,7 @@
|
||||
default_target: local
|
||||
|
||||
COMMIT_HASH := $(shell git log -1 --pretty=format:"%h"|tail -1)
|
||||
VERSION = 0.18.0
|
||||
VERSION = 0.18.1
|
||||
IMAGE_REPO ?= ghcr.io/blakeblackshear/frigate
|
||||
GITHUB_REF_NAME ?= $(shell git rev-parse --abbrev-ref HEAD)
|
||||
BOARDS= #Initialized empty
|
||||
|
||||
@@ -150,7 +150,9 @@ http {
|
||||
include auth_request.conf;
|
||||
types {
|
||||
video/mp4 mp4;
|
||||
image/jpeg jpg;
|
||||
image/jpeg jpg jpeg;
|
||||
image/png png;
|
||||
image/webp webp;
|
||||
}
|
||||
|
||||
expires 7d;
|
||||
|
||||
@@ -894,6 +894,41 @@ deepstack:
|
||||
api_url: http://<your_codeproject_ai_server_ip>:<port>/v1/vision/detection
|
||||
type: deepstack
|
||||
api_timeout: 0.1 # seconds
|
||||
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 > Detectors and model** and select **ZMQ IPC** from the detector type dropdown and click **Add**, then set the endpoint to `tcp://xdna:5555`. Then on the same page, in the **Custom Model** tab, 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: |-
|
||||
detectors:
|
||||
xdna:
|
||||
type: zmq
|
||||
endpoint: tcp://xdna:5555
|
||||
|
||||
model:
|
||||
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:
|
||||
@@ -1100,7 +1135,7 @@ synaptics:
|
||||
- key: ssd
|
||||
label: SSD MobileNet
|
||||
recommended: true
|
||||
download: A synap model is provided in the container at `/mobilenet.synap` and is used by this detector type by default. The model comes from the [Synap-release Github](https://github.com/synaptics-astra/synap-release/tree/v1.5.0/models/dolphin/object_detection/coco/model/mobilenet224_full80).
|
||||
download: A synap model is provided in the container at `/synaptics/mobilenet.synap` and is used by this detector type by default. The model comes from the [Synap-release Github](https://github.com/synaptics-astra/synap-release/tree/v1.5.0/models/dolphin/object_detection/coco/model/mobilenet224_full80).
|
||||
ui: |-
|
||||
Navigate to **Settings > System > Detectors and model** and select **Synaptics** from the detector type dropdown and click **Add**. Then on the same page, in the **Custom Model** tab, configure:
|
||||
|
||||
|
||||
@@ -286,7 +286,7 @@ 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: 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
|
||||
|
||||
@@ -498,7 +498,7 @@ cameras:
|
||||
|
||||
## Synaptics
|
||||
|
||||
Hardware accelerated video de-/encoding is supported on Synpatics SL-series SoC.
|
||||
Hardware accelerated video de-/encoding is supported on Synaptics SL-series SoC.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
|
||||
@@ -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" />.
|
||||
|
||||
|
||||
@@ -29,6 +29,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**
|
||||
|
||||
@@ -508,6 +509,28 @@ To verify that the integration is working correctly, start Frigate and observe t
|
||||
|
||||
# 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.
|
||||
|
||||
@@ -291,7 +291,7 @@ For advanced use cases, the [custom export HTTP API](../integrations/api/export-
|
||||
POST /export/custom/{camera_name}/start/{start_time}/end/{end_time}
|
||||
```
|
||||
|
||||
The request body accepts `ffmpeg_input_args` and `ffmpeg_output_args` to control encoding, frame rate, filters, and other FFmpeg options. If neither is provided, Frigate defaults to time-lapse output settings (25x speed, 30 FPS).
|
||||
The request body accepts `ffmpeg_input_args` and `ffmpeg_output_args` to control encoding, frame rate, filters, and other FFmpeg options. If neither is provided, Frigate defaults to time-lapse output settings (25x speed, 30 FPS) with audio removed (`-an`). When providing your own `ffmpeg_input_args`, include `-an` if you want audio stripped from the export.
|
||||
|
||||
The following example exports a time-lapse at 60x speed with 25 FPS:
|
||||
|
||||
|
||||
@@ -197,7 +197,7 @@ For cameras that support two-way talk, go2rtc will automatically establish an au
|
||||
To prevent this, you must configure two separate stream instances:
|
||||
|
||||
1. One stream instance with `#backchannel=0` for Frigate's viewing, recording, and detection (prevents go2rtc from establishing the blocking backchannel)
|
||||
2. A second stream instance without `#backchannel=0` for two-way talk functionality (can be used by Frigate's WebRTC viewer or other applications)
|
||||
2. A second stream instance with no `#` parameters at all for two-way talk functionality (can be used by Frigate's WebRTC viewer or other applications)
|
||||
|
||||
Configuration example:
|
||||
|
||||
@@ -215,6 +215,8 @@ In this configuration:
|
||||
- `front_door` stream is used by Frigate for viewing, recording, and detection. The `#backchannel=0` parameter prevents go2rtc from establishing the audio output backchannel, so it won't block two-way talk access.
|
||||
- `front_door_twoway` stream is used for two-way talk functionality. This stream can be used by Frigate's WebRTC viewer when two-way talk is enabled, or by other applications (like Home Assistant Advanced Camera Card) that need access to the camera's audio output channel.
|
||||
|
||||
Any `#` parameter on a bare `rtsp://` source disables the backchannel unless the URL explicitly contains `#backchannel=1`. A two-way talk stream with something like `#video=h264` on it silently loses two-way audio, and Frigate will report that two-way talk is unavailable for that stream.
|
||||
|
||||
## Security: Restricted Stream Sources
|
||||
|
||||
For security reasons, the `echo:`, `expr:`, and `exec:` stream sources are disabled by default in go2rtc. These sources allow arbitrary command execution and can pose security risks if misconfigured.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -70,6 +70,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**
|
||||
|
||||
@@ -296,6 +299,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`
|
||||
|
||||
@@ -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+?
|
||||
|
||||
|
||||
@@ -64,20 +64,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.
|
||||
|
||||
|
||||
@@ -39,6 +39,20 @@ To do this efficiently the following setup is required:
|
||||
|
||||
When this is done correctly, the GPU will do the decoding and scaling which will result in a small increase in CPU usage but with better results.
|
||||
|
||||
### How can I rotate my camera's video feed?
|
||||
|
||||
Rotation is best done in the camera's firmware settings (usually called rotate, flip, or corridor mode) so the video arrives already rotated and no extra processing is needed. Check there first.
|
||||
|
||||
If your camera does not support rotation, go2rtc's ffmpeg module can rotate the stream with the `#rotate` parameter (`90`, `180`, `270`, or `-90`), but this is not recommended: rotation requires transcoding (re-encoding) the video, which significantly increases CPU usage, especially for high resolution streams.
|
||||
|
||||
```yaml
|
||||
go2rtc:
|
||||
streams:
|
||||
my_camera: "ffmpeg:rtsp://user:password@192.168.1.10:554/stream#video=h264#hardware#rotate=90"
|
||||
```
|
||||
|
||||
Point the camera's inputs at the restream as described in the [restream docs](/configuration/restream.md), and swap `detect -> width` and `detect -> height` to match the rotated resolution.
|
||||
|
||||
### My mjpeg stream or snapshots look green and crazy
|
||||
|
||||
This almost always means that the width/height defined for your camera are not correct. Double check the resolution with VLC or another player. Also make sure you don't have the width and height values backwards.
|
||||
|
||||
@@ -78,7 +78,9 @@ go2rtc:
|
||||
|
||||
:::warning
|
||||
|
||||
The `#`-modifiers (`#video=`, `#audio=`, `#hardware`, `#backchannel=0`, …) **only take effect on a source that is prefixed with `ffmpeg:`**. Adding them to a bare `rtsp://…#audio=opus` source does nothing: go2rtc ignores them. Likewise, when a source references another stream by name (e.g. `ffmpeg:back#audio=aac`), the name must match the stream key **exactly** (it is case sensitive), or the transcode is silently never produced. This is the single most common configuration mistake. In the Frigate UI, the **Use compatibility mode (ffmpeg)** toggle adds the `ffmpeg:` prefix for you.
|
||||
The transcoding modifiers (`#video=`, `#audio=`, `#hardware`, …) **only take effect on a source that is prefixed with `ffmpeg:`**. Adding them to a bare `rtsp://…#audio=opus` source does nothing: go2rtc ignores them. Likewise, when a source references another stream by name (e.g. `ffmpeg:back#audio=aac`), the name must match the stream key **exactly** (it is case sensitive), or the transcode is silently never produced. This is the single most common configuration mistake. In the Frigate UI, the **Use compatibility mode (ffmpeg)** toggle adds the `ffmpeg:` prefix for you.
|
||||
|
||||
A bare `rtsp://` source reads a different set of modifiers: `#backchannel=`, `#media=`, `#timeout=`, and `#transport=`. These do nothing on an `ffmpeg:` source. Adding **any** modifier to a bare `rtsp://` source also disables the camera's backchannel unless the URL explicitly contains `#backchannel=1`, so a stream dedicated to two-way talk should carry no modifiers at all.
|
||||
|
||||
:::
|
||||
|
||||
@@ -153,7 +155,7 @@ WebRTC is only attempted when MSE fails or when using a camera's two-way talk fe
|
||||
|
||||
- **Codec mismatch**: WebRTC cannot carry H.265 or AAC. The stream backing the WebRTC view must provide Opus (or PCMA/PCMU) audio and H.264 video. Add an `ffmpeg:back#audio=opus` source as shown above.
|
||||
- **Port `8555` not reachable, or no candidates set**: WebRTC needs port `8555` (both TCP and UDP) open and a reachable candidate advertised. On Docker installs running on a custom/overlay network, go2rtc may advertise unreachable container IPs as ICE candidates; setting `webrtc.filters.candidates: []` and supplying only your host's LAN IP resolves this. See [WebRTC extra configuration](/configuration/live#webrtc-extra-configuration).
|
||||
- **Two-way talk** additionally requires a secure context (HTTPS or the authenticated port `8971`, because browsers block microphone access on plain HTTP). The camera's RTSP backchannel must also be handled correctly: go2rtc seizes the backchannel by default, which blocks two-way audio for other consumers and can inject static. Disable it on the primary stream with `#backchannel=0` and use a separate dedicated stream for talk, as documented in [preventing go2rtc from blocking two-way audio](/configuration/restream#two-way-talk-restream).
|
||||
- **Two-way talk** additionally requires a secure context (HTTPS or the authenticated port `8971`, because browsers block microphone access on plain HTTP). The camera's RTSP backchannel must also be handled correctly: go2rtc seizes the backchannel by default, which blocks two-way audio for other consumers and can inject static. Disable it on the primary stream with `#backchannel=0` and use a separate dedicated stream for talk, carrying no `#` modifiers of any kind, as documented in [preventing go2rtc from blocking two-way audio](/configuration/restream#two-way-talk-restream).
|
||||
|
||||
## High CPU usage
|
||||
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
Vendored
+20
@@ -4073,6 +4073,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
|
||||
@@ -4378,6 +4388,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
|
||||
|
||||
@@ -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"
|
||||
|
||||
+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()
|
||||
)
|
||||
@@ -518,6 +523,7 @@ def events_search(
|
||||
search_type = params.search_type
|
||||
include_thumbnails = params.include_thumbnails
|
||||
limit = params.limit
|
||||
offset = params.offset
|
||||
sort = params.sort
|
||||
|
||||
# Filters
|
||||
@@ -824,6 +830,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():
|
||||
@@ -881,7 +890,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)
|
||||
|
||||
|
||||
@@ -69,6 +69,7 @@ from frigate.jobs.export import (
|
||||
from frigate.models import Export, ExportCase, Previews, Recordings
|
||||
from frigate.record.export import (
|
||||
DEFAULT_TIME_LAPSE_FFMPEG_ARGS,
|
||||
DEFAULT_TIME_LAPSE_FFMPEG_INPUT_ARGS,
|
||||
ChaptersEnum,
|
||||
PlaybackSourceEnum,
|
||||
validate_ffmpeg_args,
|
||||
@@ -1012,7 +1013,7 @@ def export_recording_custom(
|
||||
|
||||
# Set default values if not provided (timelapse defaults)
|
||||
if ffmpeg_input_args is None:
|
||||
ffmpeg_input_args = ""
|
||||
ffmpeg_input_args = DEFAULT_TIME_LAPSE_FFMPEG_INPUT_ARGS
|
||||
|
||||
if ffmpeg_output_args is None:
|
||||
ffmpeg_output_args = DEFAULT_TIME_LAPSE_FFMPEG_ARGS
|
||||
|
||||
+20
-11
@@ -43,6 +43,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],
|
||||
@@ -92,10 +108,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":
|
||||
@@ -236,10 +249,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
|
||||
@@ -337,9 +347,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
|
||||
|
||||
@@ -103,12 +103,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)
|
||||
@@ -233,12 +234,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):
|
||||
|
||||
+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
|
||||
)
|
||||
|
||||
@@ -199,15 +199,20 @@ class CudaGraphRunner(BaseModelRunner):
|
||||
EnrichmentModelTypeEnum.yolov9_license_plate.value,
|
||||
]
|
||||
|
||||
# ORT performs two regular runs before it starts capturing, but on some
|
||||
# driver / cuDNN combinations the arena still has to extend on the run that
|
||||
# captures, and cudaMalloc is not allowed during capture. Running with
|
||||
# capture disabled first keeps those allocations outside of the capture.
|
||||
GRAPH_FREE_WARMUP_RUNS = 2
|
||||
|
||||
def __init__(self, session: ort.InferenceSession, cuda_device_id: int):
|
||||
self._session = session
|
||||
self._cuda_device_id = cuda_device_id
|
||||
self._captured = False
|
||||
self._prepared = False
|
||||
self._io_binding: ort.IOBinding | None = None
|
||||
self._input_name: str | None = None
|
||||
self._output_names: list[str] | None = None
|
||||
self._input_ortvalue: ort.OrtValue | None = None
|
||||
self._output_ortvalues: ort.OrtValue | None = None
|
||||
|
||||
def get_input_names(self) -> list[str]:
|
||||
"""Get input names for the model."""
|
||||
@@ -217,35 +222,41 @@ class CudaGraphRunner(BaseModelRunner):
|
||||
"""Get the input width of the model."""
|
||||
return self._session.get_inputs()[0].shape[3]
|
||||
|
||||
def _prepare(self, input_name: str, tensor_input: np.ndarray) -> None:
|
||||
"""Bind CUDA buffers and warm the session up with capture disabled."""
|
||||
self._io_binding = self._session.io_binding()
|
||||
self._input_name = input_name
|
||||
self._output_names = [o.name for o in self._session.get_outputs()]
|
||||
|
||||
self._input_ortvalue = ort.OrtValue.ortvalue_from_numpy(
|
||||
tensor_input, "cuda", self._cuda_device_id
|
||||
)
|
||||
self._io_binding.bind_ortvalue_input(self._input_name, self._input_ortvalue)
|
||||
|
||||
for name in self._output_names:
|
||||
# Bind outputs to CUDA and allow ORT to allocate appropriately
|
||||
self._io_binding.bind_output(name, "cuda", self._cuda_device_id)
|
||||
|
||||
# gpu_graph_id -1 disables capture and replay for the run
|
||||
warmup_options = ort.RunOptions()
|
||||
warmup_options.add_run_config_entry("gpu_graph_id", "-1")
|
||||
|
||||
for _ in range(self.GRAPH_FREE_WARMUP_RUNS):
|
||||
self._session.run_with_iobinding(self._io_binding, warmup_options)
|
||||
|
||||
self._prepared = True
|
||||
|
||||
def run(self, input: dict[str, Any]):
|
||||
# Extract the single tensor input (assuming one input)
|
||||
input_name = list(input.keys())[0]
|
||||
tensor_input = input[input_name]
|
||||
tensor_input = np.ascontiguousarray(tensor_input)
|
||||
tensor_input = np.ascontiguousarray(input[input_name])
|
||||
|
||||
if not self._captured:
|
||||
# Prepare IOBinding with CUDA buffers and let ORT allocate outputs on device
|
||||
self._io_binding = self._session.io_binding()
|
||||
self._input_name = input_name
|
||||
self._output_names = [o.name for o in self._session.get_outputs()]
|
||||
if not self._prepared:
|
||||
self._prepare(input_name, tensor_input)
|
||||
else:
|
||||
# Replay using updated input
|
||||
self._input_ortvalue.update_inplace(tensor_input)
|
||||
|
||||
self._input_ortvalue = ort.OrtValue.ortvalue_from_numpy(
|
||||
tensor_input, "cuda", self._cuda_device_id
|
||||
)
|
||||
self._io_binding.bind_ortvalue_input(self._input_name, self._input_ortvalue)
|
||||
|
||||
for name in self._output_names:
|
||||
# Bind outputs to CUDA and allow ORT to allocate appropriately
|
||||
self._io_binding.bind_output(name, "cuda", self._cuda_device_id)
|
||||
|
||||
# First IOBinding run to allocate, execute, and capture CUDA Graph
|
||||
ro = ort.RunOptions()
|
||||
self._session.run_with_iobinding(self._io_binding, ro)
|
||||
self._captured = True
|
||||
return self._io_binding.copy_outputs_to_cpu()
|
||||
|
||||
# Replay using updated input, copy results to CPU
|
||||
self._input_ortvalue.update_inplace(tensor_input)
|
||||
ro = ort.RunOptions()
|
||||
self._session.run_with_iobinding(self._io_binding, ro)
|
||||
return self._io_binding.copy_outputs_to_cpu()
|
||||
|
||||
@@ -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,14 +342,18 @@ 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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -121,8 +121,8 @@ PRESETS_HW_ACCEL_SCALE = {
|
||||
"preset-rpi-64-h264": "-r {0} -vf fps={0},scale={1}:{2}",
|
||||
"preset-rpi-64-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
|
||||
|
||||
@@ -36,8 +36,9 @@ from frigate.util.time import is_current_hour
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
DEFAULT_TIME_LAPSE_FFMPEG_INPUT_ARGS = "-an"
|
||||
DEFAULT_TIME_LAPSE_FFMPEG_ARGS = "-vf setpts=0.04*PTS -r 30"
|
||||
TIMELAPSE_DATA_INPUT_ARGS = "-an -skip_frame nokey"
|
||||
TIMELAPSE_DATA_INPUT_ARGS = "-skip_frame nokey"
|
||||
|
||||
# Matches the setpts factor used in timelapse exports (e.g. setpts=0.04*PTS).
|
||||
# Captures the floating-point factor so we can scale expected duration.
|
||||
@@ -737,7 +738,7 @@ class RecordingExporter(threading.Thread):
|
||||
parse_preset_hardware_acceleration_encode(
|
||||
self.config.ffmpeg.ffmpeg_path,
|
||||
hwaccel_args,
|
||||
f"{self.ffmpeg_input_args} -an {ffmpeg_input}".strip(),
|
||||
f"{self.ffmpeg_input_args} {ffmpeg_input}".strip(),
|
||||
f"{self.ffmpeg_output_args} -movflags +faststart".strip(),
|
||||
EncodeTypeEnum.timelapse,
|
||||
)
|
||||
|
||||
@@ -352,8 +352,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
|
||||
@@ -370,7 +371,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
|
||||
@@ -398,7 +399,7 @@ def stats_snapshot(
|
||||
"process_fps": round(camera_stats.process_fps.value, 2),
|
||||
"skipped_fps": round(camera_stats.skipped_fps.value, 2),
|
||||
"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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
"""Tests for ONNX Runtime session option selection."""
|
||||
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import numpy as np
|
||||
import onnxruntime as ort
|
||||
|
||||
from frigate.detectors.detection_runners import get_ort_session_options
|
||||
from frigate.detectors.detection_runners import (
|
||||
CudaGraphRunner,
|
||||
get_ort_session_options,
|
||||
)
|
||||
from frigate.detectors.detector_config import ModelTypeEnum
|
||||
from frigate.embeddings.types import EnrichmentModelTypeEnum
|
||||
|
||||
@@ -39,3 +44,45 @@ class TestGetOrtSessionOptions(unittest.TestCase):
|
||||
]:
|
||||
with self.subTest(model_type=model_type):
|
||||
self.assertIsNone(get_ort_session_options(model_type))
|
||||
|
||||
|
||||
class TestCudaGraphRunner(unittest.TestCase):
|
||||
"""CUDA graph capture fails if the arena has to allocate during capture, so
|
||||
the session is warmed up with capture disabled before the first real run."""
|
||||
|
||||
def setUp(self):
|
||||
self.session = MagicMock()
|
||||
self.session.get_outputs.return_value = [MagicMock(name="output")]
|
||||
self.io_binding = self.session.io_binding.return_value
|
||||
self.input = {"images": np.zeros((1, 3, 320, 320), np.float32)}
|
||||
|
||||
def _annotations(self) -> list[str | None]:
|
||||
"""Graph annotation id passed with each run, None when unset."""
|
||||
annotations = []
|
||||
|
||||
for call in self.session.run_with_iobinding.call_args_list:
|
||||
try:
|
||||
annotations.append(call.args[1].get_run_config_entry("gpu_graph_id"))
|
||||
except RuntimeError:
|
||||
annotations.append(None)
|
||||
|
||||
return annotations
|
||||
|
||||
def test_first_run_warms_up_with_capture_disabled(self):
|
||||
with patch.object(ort.OrtValue, "ortvalue_from_numpy"):
|
||||
CudaGraphRunner(self.session, 0).run(self.input)
|
||||
|
||||
self.assertEqual(
|
||||
self._annotations(),
|
||||
["-1"] * CudaGraphRunner.GRAPH_FREE_WARMUP_RUNS + [None],
|
||||
)
|
||||
|
||||
def test_later_runs_allow_capture(self):
|
||||
with patch.object(ort.OrtValue, "ortvalue_from_numpy"):
|
||||
runner = CudaGraphRunner(self.session, 0)
|
||||
runner.run(self.input)
|
||||
self.session.run_with_iobinding.reset_mock()
|
||||
runner.run(self.input)
|
||||
|
||||
self.assertEqual(self._annotations(), [None])
|
||||
runner._input_ortvalue.update_inplace.assert_called_once()
|
||||
|
||||
@@ -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,
|
||||
@@ -128,8 +129,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,
|
||||
@@ -153,8 +156,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:
|
||||
@@ -184,8 +189,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,
|
||||
@@ -238,11 +243,13 @@ class TrackedObjectProcessor(threading.Thread):
|
||||
camera_state.on("camera_activity", camera_activity)
|
||||
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
|
||||
@@ -261,11 +268,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:
|
||||
@@ -281,13 +290,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"
|
||||
@@ -297,7 +308,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:
|
||||
@@ -312,7 +327,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:
|
||||
@@ -783,7 +798,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()
|
||||
|
||||
+24
-7
@@ -34,6 +34,8 @@ from frigate.util.process import FrigateProcess
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
RECORD_GRACE_SECONDS = 90
|
||||
|
||||
|
||||
def capture_frames(
|
||||
ffmpeg_process: sp.Popen[Any],
|
||||
@@ -164,6 +166,7 @@ class CameraWatchdog(threading.Thread):
|
||||
self.latest_invalid_segment_time: float = 0
|
||||
self.latest_cache_segment_time: float = 0
|
||||
self.record_enable_time: datetime | None = None
|
||||
self.record_grace_until: datetime | None = None
|
||||
|
||||
# `valid` segments are published with the segment's start time, so the
|
||||
# gap between consecutive publishes can reach 2 * segment_time. Pad the
|
||||
@@ -280,6 +283,7 @@ class CameraWatchdog(threading.Thread):
|
||||
self.latest_valid_segment_time = 0
|
||||
self.latest_invalid_segment_time = 0
|
||||
self.latest_cache_segment_time = 0
|
||||
self.record_grace_until = None
|
||||
self.record_enable_time = datetime.now().astimezone(UTC)
|
||||
last_restart_time = datetime.now().timestamp()
|
||||
continue
|
||||
@@ -294,6 +298,7 @@ class CameraWatchdog(threading.Thread):
|
||||
self.latest_valid_segment_time = 0
|
||||
self.latest_invalid_segment_time = 0
|
||||
self.latest_cache_segment_time = 0
|
||||
self.record_grace_until = None
|
||||
self.record_enable_time = datetime.now().astimezone(UTC)
|
||||
else:
|
||||
self.logger.debug(f"Disabling camera {self.config.name}")
|
||||
@@ -318,6 +323,7 @@ class CameraWatchdog(threading.Thread):
|
||||
self.latest_valid_segment_time = 0
|
||||
self.latest_invalid_segment_time = 0
|
||||
self.latest_cache_segment_time = 0
|
||||
self.record_grace_until = None
|
||||
self.record_enable_time = datetime.now().astimezone(UTC)
|
||||
last_restart_time = datetime.now().timestamp()
|
||||
self.was_record_enabled_in_config = record_enabled_in_config
|
||||
@@ -404,11 +410,16 @@ class CameraWatchdog(threading.Thread):
|
||||
if self.config.record.enabled and "record" in p["roles"]:
|
||||
now_utc = datetime.now().astimezone(UTC)
|
||||
|
||||
# Check if we're within the grace period after enabling recording
|
||||
# Grace period: 90 seconds allows time for ffmpeg to start and create first segment
|
||||
in_grace_period = self.record_enable_time is not None and (
|
||||
now_utc - self.record_enable_time
|
||||
) < timedelta(seconds=90)
|
||||
# ffmpeg needs time to create a first segment after
|
||||
# recording is enabled and after a restart
|
||||
in_grace_period = (
|
||||
self.record_enable_time is not None
|
||||
and (now_utc - self.record_enable_time)
|
||||
< timedelta(seconds=RECORD_GRACE_SECONDS)
|
||||
) or (
|
||||
self.record_grace_until is not None
|
||||
and now_utc < self.record_grace_until
|
||||
)
|
||||
|
||||
latest_cache_dt = (
|
||||
datetime.fromtimestamp(self.latest_cache_segment_time, tz=UTC)
|
||||
@@ -445,8 +456,9 @@ class CameraWatchdog(threading.Thread):
|
||||
<= self.latest_invalid_segment_time
|
||||
)
|
||||
invalid_stale = invalid_stale_condition
|
||||
stale = cache_stale or valid_stale or invalid_stale
|
||||
|
||||
if cache_stale or valid_stale or invalid_stale:
|
||||
if stale and can_restart:
|
||||
if cache_stale:
|
||||
reason = "No new recording segments were created"
|
||||
elif valid_stale:
|
||||
@@ -471,8 +483,13 @@ class CameraWatchdog(threading.Thread):
|
||||
f"{self.config.name}/status/{role.value}", "offline"
|
||||
)
|
||||
|
||||
self.record_grace_until = now_utc + timedelta(
|
||||
seconds=RECORD_GRACE_SECONDS
|
||||
)
|
||||
last_restart_time = now
|
||||
|
||||
continue
|
||||
else:
|
||||
elif not stale:
|
||||
self._send_record_status("online", now)
|
||||
p["latest_segment_time"] = self.latest_cache_segment_time
|
||||
|
||||
|
||||
@@ -54,8 +54,11 @@ export class FrigateApp {
|
||||
});
|
||||
|
||||
await this.ws.install(this.page);
|
||||
await this.media.install();
|
||||
await this.api.install(overrides);
|
||||
// media goes last so its per-event routes win over the broader
|
||||
// `**/api/events**` list route, which otherwise answers thumbnail and
|
||||
// snapshot requests with the events JSON
|
||||
await this.media.install();
|
||||
}
|
||||
|
||||
/** Navigate to a page. Always call installDefaults() first. */
|
||||
|
||||
@@ -52,6 +52,9 @@ function deepMerge<T extends Record<string, unknown>>(
|
||||
export const BASE_CONFIG = {
|
||||
...configSnapshot,
|
||||
version: "0.15.0-test",
|
||||
// injected by the /config endpoint rather than the Pydantic model, so it
|
||||
// is absent from the snapshot
|
||||
plus: { enabled: false },
|
||||
cameras: {
|
||||
...configSnapshot.cameras,
|
||||
front_door: {
|
||||
|
||||
@@ -249,8 +249,9 @@ export class MediaMocker {
|
||||
}),
|
||||
);
|
||||
|
||||
// Event thumbnails
|
||||
await this.page.route("**/api/events/*/thumbnail.jpg**", (route) =>
|
||||
// Event thumbnails. The explore grid and detail dialog request .webp,
|
||||
// everything else requests .jpg.
|
||||
await this.page.route("**/api/events/*/thumbnail.{jpg,webp}**", (route) =>
|
||||
route.fulfill({
|
||||
contentType: "image/png",
|
||||
body: PLACEHOLDER_PNG,
|
||||
|
||||
@@ -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,
|
||||
@@ -263,3 +275,71 @@ test.describe("Explore — mobile @high @mobile", () => {
|
||||
await expect(searchInput).toBeFocused();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Frigate+ submission — desktop only
|
||||
// The detail dialog's previous/next arrows only render on desktop.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe("Explore — Frigate+ submission (desktop) @high", () => {
|
||||
test.skip(
|
||||
({ frigateApp }) => frigateApp.isMobile,
|
||||
"Detail dialog navigation arrows are desktop-only",
|
||||
);
|
||||
|
||||
test("in-flight submission does not mark the next tracked object as submitted", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.installDefaults({ config: { plus: { enabled: true } } });
|
||||
const page = frigateApp.page;
|
||||
|
||||
// Hold the submission open so it is still in flight while the user moves
|
||||
// on to the next tracked object.
|
||||
let releaseSubmission: () => void = () => {};
|
||||
const submissionHeld = new Promise<void>((resolve) => {
|
||||
releaseSubmission = resolve;
|
||||
});
|
||||
let submissions = 0;
|
||||
await page.route("**/api/events/*/plus", async (route) => {
|
||||
submissions += 1;
|
||||
await submissionHeld;
|
||||
await route.fulfill({ json: { success: true } });
|
||||
});
|
||||
|
||||
await frigateApp.goto("/explore?labels=person");
|
||||
|
||||
const firstResult = page.locator("[data-start]").first();
|
||||
await expect(firstResult).toBeVisible({ timeout: 10_000 });
|
||||
await firstResult.click();
|
||||
|
||||
// The label being confirmed is rendered in a <code> tag inside the
|
||||
// "Is this object a <label>?" question.
|
||||
const dialog = page.getByRole("dialog");
|
||||
await expect(dialog.locator("code")).toHaveText("person");
|
||||
|
||||
await dialog.getByRole("button", { name: "Yes", exact: true }).click();
|
||||
await expect.poll(() => submissions, { timeout: 5_000 }).toBe(1);
|
||||
|
||||
await page.getByRole("button", { name: "Next tracked object" }).click();
|
||||
await expect(dialog.locator("code")).toHaveText("car");
|
||||
|
||||
const submissionLanded = page.waitForResponse(/\/api\/events\/.*\/plus/);
|
||||
releaseSubmission();
|
||||
await submissionLanded;
|
||||
// two frames is enough for React to flush the response handler's state
|
||||
// updates, so the assertions below can't pass by racing ahead of them
|
||||
await page.evaluate(
|
||||
() =>
|
||||
new Promise((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(resolve)),
|
||||
),
|
||||
);
|
||||
|
||||
// The car was never submitted, so its question must be untouched.
|
||||
expect(submissions).toBe(1);
|
||||
await expect(dialog.getByText("Submitted")).toHaveCount(0);
|
||||
await expect(
|
||||
dialog.getByRole("button", { name: "Yes", exact: true }),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,40 +30,51 @@ function groupedFacesMock() {
|
||||
});
|
||||
}
|
||||
|
||||
async function installGroupedFaces(app: FrigateApp) {
|
||||
const GROUPED_EVENT = {
|
||||
id: GROUPED_EVENT_ID,
|
||||
label: "person",
|
||||
sub_label: null,
|
||||
camera: "front_door",
|
||||
start_time: 1775487131.3863528,
|
||||
end_time: 1775487161.3863528,
|
||||
false_positive: false,
|
||||
zones: ["front_yard"],
|
||||
thumbnail: null,
|
||||
has_clip: true,
|
||||
has_snapshot: true,
|
||||
retain_indefinitely: false,
|
||||
plus_id: null,
|
||||
model_hash: "abc123",
|
||||
detector_type: "cpu",
|
||||
model_type: "ssd",
|
||||
data: {
|
||||
top_score: 0.92,
|
||||
score: 0.92,
|
||||
region: [0.1, 0.1, 0.5, 0.8],
|
||||
box: [0.2, 0.15, 0.45, 0.75],
|
||||
area: 0.18,
|
||||
ratio: 0.6,
|
||||
type: "object",
|
||||
path_data: [],
|
||||
},
|
||||
};
|
||||
|
||||
async function installGroupedFaces(
|
||||
app: FrigateApp,
|
||||
opts: { withEventIds?: boolean } = {},
|
||||
) {
|
||||
await app.api.install({
|
||||
events: [
|
||||
{
|
||||
id: GROUPED_EVENT_ID,
|
||||
label: "person",
|
||||
sub_label: null,
|
||||
camera: "front_door",
|
||||
start_time: 1775487131.3863528,
|
||||
end_time: 1775487161.3863528,
|
||||
false_positive: false,
|
||||
zones: ["front_yard"],
|
||||
thumbnail: null,
|
||||
has_clip: true,
|
||||
has_snapshot: true,
|
||||
retain_indefinitely: false,
|
||||
plus_id: null,
|
||||
model_hash: "abc123",
|
||||
detector_type: "cpu",
|
||||
model_type: "ssd",
|
||||
data: {
|
||||
top_score: 0.92,
|
||||
score: 0.92,
|
||||
region: [0.1, 0.1, 0.5, 0.8],
|
||||
box: [0.2, 0.15, 0.45, 0.75],
|
||||
area: 0.18,
|
||||
ratio: 0.6,
|
||||
type: "object",
|
||||
path_data: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
events: [GROUPED_EVENT],
|
||||
faces: groupedFacesMock(),
|
||||
});
|
||||
|
||||
// api-mocker does not cover /api/event_ids, which the card needs to link to
|
||||
// Explore. Registered after install so it takes precedence.
|
||||
if (opts.withEventIds) {
|
||||
await app.page.route("**/api/event_ids**", (route) =>
|
||||
route.fulfill({ json: [GROUPED_EVENT] }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function openGroupedFaceDialog(app: FrigateApp): Promise<Locator> {
|
||||
@@ -512,6 +523,34 @@ test.describe("FaceSelectionDialog @high", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Face Library: return from Explore @high", () => {
|
||||
test("Explore back button returns to an outlined collection", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await installGroupedFaces(frigateApp, { withEventIds: true });
|
||||
await frigateApp.goto("/faces");
|
||||
|
||||
// Mobile opens the collection as a MobilePage, which has no dialog role
|
||||
const card = frigateApp.page
|
||||
.locator('img[src*="clips/faces/train/"]')
|
||||
.first()
|
||||
.locator("xpath=..");
|
||||
await card.click();
|
||||
await frigateApp.page.getByLabel("View in Explore").click();
|
||||
await expect(frigateApp.page).toHaveURL(
|
||||
new RegExp(`/explore\\?event_id=${GROUPED_EVENT_ID}`),
|
||||
);
|
||||
|
||||
const back = frigateApp.page.getByRole("button", { name: "Go back" });
|
||||
await expect(back).toBeVisible({ timeout: 5_000 });
|
||||
await back.click();
|
||||
await expect(frigateApp.page).toHaveURL(/\/faces/);
|
||||
|
||||
await expect(card).toHaveClass(/outline-selected/, { timeout: 5_000 });
|
||||
await expect(card).not.toHaveClass(/outline-selected/, { timeout: 5_000 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Face Library — mobile @high @mobile", () => {
|
||||
test.skip(({ frigateApp }) => !frigateApp.isMobile, "Mobile-only");
|
||||
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* Debug Replay range selection from History -- HIGH tier.
|
||||
*
|
||||
* Covers the "Select from Timeline" flow that Debug Replay shares with
|
||||
* Export. The other half of the same report, a loading spinner latched
|
||||
* after Cancel, needs real media: the vod mock serves an empty playlist.
|
||||
*/
|
||||
|
||||
import { test, expect, type FrigateApp } from "../fixtures/frigate-test";
|
||||
|
||||
// the selection is seeded around the playback position, so land near the
|
||||
// live edge
|
||||
const playbackTime = Math.floor(Date.now() / 1000) - 300;
|
||||
|
||||
async function openRecordingView(frigateApp: FrigateApp) {
|
||||
// The recording view pulls these while the timeline renders; the preview
|
||||
// server 500s on them, which the error collector would flag.
|
||||
await frigateApp.page.route("**/api/*/recordings**", (route) =>
|
||||
route.fulfill({ json: [] }),
|
||||
);
|
||||
await frigateApp.page.route("**/api/recordings/unavailable**", (route) =>
|
||||
route.fulfill({ json: [] }),
|
||||
);
|
||||
// inert here; the 0.19 recording view fetches coverage and needs an
|
||||
// object, so this has to follow the broad recordings route to win
|
||||
await frigateApp.page.route("**/api/*/recordings/coverage**", (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
spans: [
|
||||
{
|
||||
start_time: playbackTime - 3600,
|
||||
end_time: playbackTime + 600,
|
||||
streams: ["main"],
|
||||
},
|
||||
],
|
||||
codecs_compatible: true,
|
||||
streams: {
|
||||
main: {
|
||||
video_codec: "h264",
|
||||
audio_rate: null,
|
||||
audio_codec: null,
|
||||
has_audio: false,
|
||||
bitrate: 2_000_000,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await frigateApp.goto(`/review?timestamp=front_door_${playbackTime}`);
|
||||
}
|
||||
|
||||
// desktop reaches Debug Replay through the Actions menu, mobile through
|
||||
// the settings drawer; both render the same form
|
||||
async function openDebugReplayForm(frigateApp: FrigateApp) {
|
||||
if (frigateApp.isMobile) {
|
||||
await frigateApp.page
|
||||
.getByRole("button", { name: /filters/i })
|
||||
.first()
|
||||
.click({ timeout: 15_000 });
|
||||
await frigateApp.page
|
||||
.getByRole("button", { name: /^debug replay$/i })
|
||||
.click();
|
||||
} else {
|
||||
await frigateApp.page
|
||||
.getByRole("button", { name: /actions/i })
|
||||
.click({ timeout: 15_000 });
|
||||
await frigateApp.page
|
||||
.getByRole("menuitem", { name: /debug replay/i })
|
||||
.click();
|
||||
}
|
||||
|
||||
const form = frigateApp.page.getByRole("dialog");
|
||||
await expect(form).toBeVisible({ timeout: 5_000 });
|
||||
return form;
|
||||
}
|
||||
|
||||
async function selectRangeFromTimeline(frigateApp: FrigateApp) {
|
||||
const form = await openDebugReplayForm(frigateApp);
|
||||
await form.getByText("From Timeline").click();
|
||||
await form.getByRole("button", { name: "Select", exact: true }).click();
|
||||
await expect(form).toBeHidden({ timeout: 5_000 });
|
||||
|
||||
await expect(frigateApp.page.locator(".export-start")).toHaveText(
|
||||
/\d{1,2}:\d{2}/,
|
||||
{ timeout: 5_000 },
|
||||
);
|
||||
}
|
||||
|
||||
// moving the playhead between the two selections is what makes the second
|
||||
// range differ from the first
|
||||
async function reselectRange(frigateApp: FrigateApp) {
|
||||
await selectRangeFromTimeline(frigateApp);
|
||||
|
||||
await frigateApp.page
|
||||
.getByRole("button", { name: /^cancel$/i })
|
||||
.click({ timeout: 5_000 });
|
||||
await expect(frigateApp.page.locator(".export-start")).toHaveCount(0);
|
||||
|
||||
const segments = frigateApp.page.locator(".segment[data-segment-id]");
|
||||
const count = await segments.count();
|
||||
await segments.nth(Math.min(20, count - 1)).click({ force: true });
|
||||
|
||||
await selectRangeFromTimeline(frigateApp);
|
||||
}
|
||||
|
||||
// the loop flipped this label between the two ranges ~25 times a second
|
||||
async function countHandleLabelChanges(frigateApp: FrigateApp) {
|
||||
return frigateApp.page.evaluate(async () => {
|
||||
const handle = document.querySelector(".export-start");
|
||||
if (!handle) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
let changes = 0;
|
||||
let last = handle.textContent;
|
||||
const observer = new MutationObserver(() => {
|
||||
if (handle.textContent !== last) {
|
||||
changes += 1;
|
||||
last = handle.textContent;
|
||||
}
|
||||
});
|
||||
observer.observe(handle, {
|
||||
subtree: true,
|
||||
childList: true,
|
||||
characterData: true,
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
observer.disconnect();
|
||||
return changes;
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("Debug Replay from History @high", () => {
|
||||
test("a reselected range lands once and stays put", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
if (frigateApp.isMobile) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
const pageErrors: string[] = [];
|
||||
frigateApp.page.on("pageerror", (err) => pageErrors.push(err.message));
|
||||
|
||||
await frigateApp.installDefaults();
|
||||
await openRecordingView(frigateApp);
|
||||
await reselectRange(frigateApp);
|
||||
|
||||
expect(await countHandleLabelChanges(frigateApp)).toBe(0);
|
||||
expect(
|
||||
pageErrors.filter((message) => /Maximum update depth/i.test(message)),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("dragging a handle after a reselect moves it", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
if (frigateApp.isMobile) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
await frigateApp.installDefaults();
|
||||
await openRecordingView(frigateApp);
|
||||
await reselectRange(frigateApp);
|
||||
|
||||
const start = frigateApp.page.locator(".export-start");
|
||||
const before = (await start.textContent()) ?? "";
|
||||
const box = await start.boundingBox();
|
||||
if (!box) {
|
||||
throw new Error("export start handle has no bounding box");
|
||||
}
|
||||
|
||||
const x = box.x + box.width / 2;
|
||||
const y = box.y + box.height / 2;
|
||||
await frigateApp.page.mouse.move(x, y);
|
||||
await frigateApp.page.mouse.down();
|
||||
await frigateApp.page.mouse.move(x, y - 90, { steps: 12 });
|
||||
await frigateApp.page.mouse.up();
|
||||
|
||||
await expect(start).not.toHaveText(before, { timeout: 5_000 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Debug Replay from History — mobile @high @mobile", () => {
|
||||
test("a reselected range lands once and stays put", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
if (!frigateApp.isMobile) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
const pageErrors: string[] = [];
|
||||
frigateApp.page.on("pageerror", (err) => pageErrors.push(err.message));
|
||||
|
||||
await frigateApp.installDefaults();
|
||||
await openRecordingView(frigateApp);
|
||||
await reselectRange(frigateApp);
|
||||
|
||||
expect(await countHandleLabelChanges(frigateApp)).toBe(0);
|
||||
expect(
|
||||
pageErrors.filter((message) => /Maximum update depth/i.test(message)),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -27,6 +27,7 @@
|
||||
<link rel="mask-icon" href="/images/branding/favicon.svg" color="#3b82f7" />
|
||||
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)" />
|
||||
<meta name="theme-color" content="#000000" media="(prefers-color-scheme: dark)" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
<link rel="mask-icon" href="/images/branding/favicon.svg" color="#3b82f7" />
|
||||
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)" />
|
||||
<meta name="theme-color" content="#000000" media="(prefers-color-scheme: dark)" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -224,7 +224,8 @@
|
||||
"ur": "اردو (урду)",
|
||||
"withSystem": {
|
||||
"label": "Выкарыстоўваць мову з налад сістэмы"
|
||||
}
|
||||
},
|
||||
"be": "Беларуская"
|
||||
},
|
||||
"appearance": "Выгляд",
|
||||
"darkMode": {
|
||||
|
||||
@@ -51,7 +51,8 @@
|
||||
"ur": "اردو (Urdú)",
|
||||
"hr": "Hrvatski (croat)",
|
||||
"bs": "Bosanski (Bosni)",
|
||||
"zhHant": "繁體中文 (Xinès Tradicional)"
|
||||
"zhHant": "繁體中文 (Xinès Tradicional)",
|
||||
"be": "Беларуская (belarús)"
|
||||
},
|
||||
"system": "Sistema",
|
||||
"systemMetrics": "Mètriques del sistema",
|
||||
|
||||
@@ -2076,7 +2076,7 @@
|
||||
},
|
||||
"lpr": {
|
||||
"globalDisabled": "L'enriquiment de reconeixement de matrícules ha d'estar habilitat perquè les funcions LPR funcionin en aquesta càmera.",
|
||||
"vehicleNotTracked": "El reconeixement de la matrícula requereix que es faci un seguiment del 'cotxe' o de la 'motocicleta'.",
|
||||
"vehicleNotTracked": "El reconeixement de la matrícula requereix un seguiment del vehicle. Habilita el 'cotxe' o un altre tipus de vehicle en Objectes per a aquesta càmera.",
|
||||
"modelSizeLarge": "El model 'gran' està optimitzat per a matrícules multilínies. El model 'petit' proporciona un millor rendiment sobre 'gran' i s'ha d'utilitzar tret que la vostra regió utilitzi formats de placa multilínia."
|
||||
},
|
||||
"record": {
|
||||
|
||||
@@ -194,7 +194,8 @@
|
||||
"id": "Bahasa Indonesia (Indonesisch)",
|
||||
"hr": "Hrvatski (Kroatisch)",
|
||||
"bs": "Bosanski (Bosnisch)",
|
||||
"zhHant": "Traditional Chinese"
|
||||
"zhHant": "Traditional Chinese",
|
||||
"be": "Беларуская (Belarussisch)"
|
||||
},
|
||||
"appearance": "Erscheinung",
|
||||
"theme": {
|
||||
|
||||
@@ -2116,7 +2116,7 @@
|
||||
},
|
||||
"lpr": {
|
||||
"globalDisabled": "Die Erweiterung zur Kennzeichenerkennung muss aktiviert sein, damit die LPR-Funktionen bei dieser Kamera funktionieren.",
|
||||
"vehicleNotTracked": "Für die Kennzeichenerkennung muss entweder „Auto“ oder „Motorrad“ erfasst werden. Aktivieren Sie „Auto“ oder „Motorrad“ unter „Objekte“ für diese Kamera.",
|
||||
"vehicleNotTracked": "Die Kennzeichenerkennung erfordert, dass ein Fahrzeug verfolgt wird. Aktiviere ‘Auto’ oder einen anderen Fahrzeugtyp in den Objekten für diese Kamera.",
|
||||
"modelSizeLarge": "Das „große“ Modell ist für mehrzeilige Kennzeichen optimiert. Das „kleine“ Modell bietet eine bessere Leistung als das „große“ und sollte verwendet werden, sofern in Ihrer Region keine mehrzeiligen Kennzeichenformate verwendet werden."
|
||||
},
|
||||
"record": {
|
||||
|
||||
@@ -1952,7 +1952,11 @@
|
||||
"modelSizeLarge": "The 'large' model is optimized for multi-line license plates. The 'small' model provides better performance over 'large' and should be used unless your region uses multi-line plate formats."
|
||||
},
|
||||
"record": {
|
||||
"noRecordRole": "No streams have the record role defined. Recording will not function."
|
||||
"noRecordRole": "No streams have the record role defined. Recording will not function.",
|
||||
"profileBaseDisabled": "Recording is disabled in this camera's base config, so enabling it in a profile has no effect. Enable recording in the base config and use a profile to disable it instead."
|
||||
},
|
||||
"notifications": {
|
||||
"profileBaseDisabled": "No cameras have notifications enabled in their base config, so enabling them in a profile has no effect. Enable notifications in the base config of at least one camera."
|
||||
},
|
||||
"birdseye": {
|
||||
"objectsModeDetectDisabled": "Birdseye is set to 'objects' mode, but object detection is disabled for this camera. The camera will not appear in Birdseye."
|
||||
|
||||
@@ -233,6 +233,7 @@
|
||||
"detectHighCpuUsage": "{{camera}} has high detect CPU usage ({{detectAvg}}%)",
|
||||
"healthy": "System is healthy",
|
||||
"reindexingEmbeddings": "Reindexing embeddings ({{processed}}% complete)",
|
||||
"reindexEmbeddingsFailed": "Reindexing embeddings failed, check the logs",
|
||||
"cameraIsOffline": "{{camera}} is offline",
|
||||
"detectIsSlow": "{{detect}} is slow ({{speed}} ms)",
|
||||
"detectIsVerySlow": "{{detect}} is very slow ({{speed}} ms)",
|
||||
|
||||
@@ -142,7 +142,8 @@
|
||||
"ur": "اردو (urdu keel)",
|
||||
"hr": "Hrvatski (horvaadi keel)",
|
||||
"bs": "Bosanski (bosnia keel)",
|
||||
"zhHant": "繁體中文 (hiina keel traditsiooniliste hieroglüüfidega)"
|
||||
"zhHant": "繁體中文 (hiina keel traditsiooniliste hieroglüüfidega)",
|
||||
"be": "Беларуская (valgevene keel)"
|
||||
},
|
||||
"system": "Süsteem",
|
||||
"systemMetrics": "Süsteemi meetrika",
|
||||
|
||||
@@ -622,7 +622,11 @@
|
||||
"disabled": "Objektide tuvastamine on lülitatud välja. Hetkepildid, läbivaatamisele kuuluvad objektid ja täiendavad funktsioonid, nagu näotuvastus, sõidukite numbrimärkide tuvastus ja generatiivne tehisintellekt, ei tööta."
|
||||
},
|
||||
"lpr": {
|
||||
"vehicleNotTracked": "Sõidukite numbrimärkide tuvastus eeldab, et auto või mootorratas on jälgitav. Lülita menüüst Objektid sell kaamera jaoks sisse valikud „auto“ või „mootorratas“."
|
||||
"vehicleNotTracked": "Sõidukite numbrimärkide tuvastus eeldab, et sõiduk on jälgitav. Lülita menüüst Objektid selle kaamera jaoks sisse valik „auto“ või muu sõiduki tüüp."
|
||||
},
|
||||
"review": {
|
||||
"recordDisabled": "Salvestamine on lülitatud välja, ülevaatamseks mõeldud objektid jäävad loomata.",
|
||||
"detectDisabled": "Objektide tuvastus on lülitatud välja. Ülevaatamisobjektid vajavad tuvastatud objekte, et hoiatusi ja tuvastusi kategooriatesse jaotada."
|
||||
}
|
||||
},
|
||||
"button": {
|
||||
@@ -650,5 +654,65 @@
|
||||
"label": "Uus väärtus",
|
||||
"reset": "Lähtesta"
|
||||
}
|
||||
},
|
||||
"onvif": {
|
||||
"autotracking": {
|
||||
"zooming": {
|
||||
"disabled": "Pole kasutusel",
|
||||
"absolute": "Absoluutne",
|
||||
"relative": "Suhteline"
|
||||
}
|
||||
},
|
||||
"profileAuto": "Automaatne",
|
||||
"profileLoading": "Laadin profiile..."
|
||||
},
|
||||
"modelSize": {
|
||||
"small": "Väike",
|
||||
"large": "Suur"
|
||||
},
|
||||
"logger": {
|
||||
"logLevel": {
|
||||
"debug": "Silumisteated",
|
||||
"info": "Infoteated",
|
||||
"warning": "Hoiatused",
|
||||
"error": "Veateated",
|
||||
"critical": "Kriitilise teated"
|
||||
}
|
||||
},
|
||||
"birdseye": {
|
||||
"cameraOrder": {
|
||||
"reorderHandle": "Järjekorra muutmiseks lohista",
|
||||
"saving": "Salvestan…",
|
||||
"saved": "Salvestatud"
|
||||
}
|
||||
},
|
||||
"retainMode": {
|
||||
"all": "Kõik",
|
||||
"motion": "Liikumine",
|
||||
"active_objects": "Aktiivsed objektid"
|
||||
},
|
||||
"previewQuality": {
|
||||
"very_high": "Väga kõrge",
|
||||
"high": "Kõrge",
|
||||
"medium": "Keskmine",
|
||||
"low": "Madal",
|
||||
"very_low": "Väga madal"
|
||||
},
|
||||
"ui": {
|
||||
"timeFormat": {
|
||||
"browser": "Brauser",
|
||||
"12hour": "12 tundi",
|
||||
"24hour": "24 tundi"
|
||||
},
|
||||
"unitSystem": {
|
||||
"metric": "Meetermõõdustik",
|
||||
"imperial": "Inglise mõõdustik"
|
||||
}
|
||||
},
|
||||
"review": {
|
||||
"imageSource": {
|
||||
"recordings": "Salvestused",
|
||||
"previews": "Eelvaated"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,7 +140,7 @@
|
||||
"saveAll": "Tout enregistrer",
|
||||
"savingAll": "Enregistrement de tout en cours…",
|
||||
"undoAll": "Tout annuler",
|
||||
"applying": "Enregistrement…",
|
||||
"applying": "Enregistrement …",
|
||||
"retry": "Réessayer"
|
||||
},
|
||||
"menu": {
|
||||
|
||||
@@ -146,7 +146,7 @@
|
||||
"toast": {
|
||||
"started_one": "Un export a démarré. Ouverture du dossier en cours.",
|
||||
"started_many": "{{count}} exports ont démarré. Ouverture du dossier en cours.",
|
||||
"started_other": "{{count}} exports ont démarré. Ouverture du dossier en cours",
|
||||
"started_other": "{{count}} exports ont démarré. Ouverture du dossier en cours.",
|
||||
"startedNoCase_one": "Un export a démarré.",
|
||||
"startedNoCase_many": "{{count}} exports ont démarré.",
|
||||
"startedNoCase_other": "{{count}} exports ont démarré.",
|
||||
|
||||
@@ -493,6 +493,9 @@
|
||||
"max_concurrent": {
|
||||
"label": "Nombre maximum d'exports simultanés",
|
||||
"description": "Nombre maximum de tâches d'exportation à effectuer en même temps."
|
||||
},
|
||||
"chapters": {
|
||||
"label": "Métadonnées des chapitres à incorporer dans les enregistrements exportés"
|
||||
}
|
||||
},
|
||||
"preview": {
|
||||
@@ -500,7 +503,8 @@
|
||||
"quality": {
|
||||
"label": "Prévisualisation qualité",
|
||||
"description": "Niveau de qualité des prévisualisations (très_bas, bas, moyen, haut, très_haut)."
|
||||
}
|
||||
},
|
||||
"description": "Réglages de la qualité des aperçus d'enregistrement montrés dans l'interface utilisateur."
|
||||
},
|
||||
"enabled_in_config": {
|
||||
"label": "État original de l'enregistrement",
|
||||
@@ -522,6 +526,61 @@
|
||||
},
|
||||
"required_zones": {
|
||||
"label": "Zones requises"
|
||||
},
|
||||
"enabled_in_config": {
|
||||
"label": "État des alertes originales",
|
||||
"description": "Vérifie si les alertes étaient initialement activées dans la configuration statique."
|
||||
},
|
||||
"cutoff_time": {
|
||||
"label": "Temps de coupure des alertes",
|
||||
"description": "Délai en secondes après l'absence d'activité déclenchant une alerte avant de la couper."
|
||||
}
|
||||
},
|
||||
"detections": {
|
||||
"label": "Configuration des détections",
|
||||
"description": "Paramètres pour lesquels les objets suivis génèrent des détections (hors alerte) et comment les détections sont conservées.",
|
||||
"enabled": {
|
||||
"label": "Activer les détections",
|
||||
"description": "Activer ou désactiver les événements de détection pour cette caméra."
|
||||
},
|
||||
"labels": {
|
||||
"label": "Étiquettes de détection",
|
||||
"description": "Liste des étiquettes d'objets qui correspondent à des événements de détection."
|
||||
},
|
||||
"required_zones": {
|
||||
"label": "Zones obligatoires",
|
||||
"description": "Zones dans lesquelles un objet doit pénétrer pour être considéré comme une détection ; laisser vide pour autoriser n’importe quelle zone."
|
||||
},
|
||||
"cutoff_time": {
|
||||
"label": "Délai limite de détection",
|
||||
"description": "Nombre de secondes à attendre après l'absence d'activité susceptible de déclencher une détection avant d'interrompre celle-ci."
|
||||
},
|
||||
"enabled_in_config": {
|
||||
"label": "État initial des détections",
|
||||
"description": "Vérifie si les détections étaient activées initialement dans la configuration statique."
|
||||
}
|
||||
},
|
||||
"genai": {
|
||||
"label": "Configuration de l'IA générative",
|
||||
"description": "Contrôle l'utilisation de l'IA générative pour la production de descriptions et de résumés des éléments d'évaluation.",
|
||||
"enabled": {
|
||||
"label": "Activer les descriptions de l'IA générative",
|
||||
"description": "Activer ou désactiver les descriptions et les résumés générés par l'IA générative pour les éléments à examiner."
|
||||
},
|
||||
"alerts": {
|
||||
"label": "Activer l'IA générative pour les alertes",
|
||||
"description": "Utilisez l'IA générative pour générer des descriptions pour les éléments d'alerte."
|
||||
},
|
||||
"detections": {
|
||||
"label": "Activer l'IA générative pour les détections",
|
||||
"description": "Utilisez l'IA générative pour générer des descriptions pour les éléments de détection."
|
||||
},
|
||||
"image_source": {
|
||||
"label": "Source de l'image de la revue",
|
||||
"description": "Source des images envoyées à l'IA générative (« aperçu » ou « enregistrements ») ; « enregistrements » utilise des images de meilleure qualité mais plus de jetons d'IA."
|
||||
},
|
||||
"additional_concerns": {
|
||||
"label": "Autres préoccupations"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,7 +199,10 @@
|
||||
}
|
||||
},
|
||||
"audio_transcription": {
|
||||
"label": "Transcription audio"
|
||||
"label": "Transcription audio",
|
||||
"enabled": {
|
||||
"label": "Activer la transcription audio"
|
||||
}
|
||||
},
|
||||
"audio": {
|
||||
"label": "Détection audio",
|
||||
@@ -313,6 +316,9 @@
|
||||
"max_concurrent": {
|
||||
"label": "Nombre maximum d'exports simultanés",
|
||||
"description": "Nombre maximum de tâches d'exportation à effectuer en même temps."
|
||||
},
|
||||
"chapters": {
|
||||
"label": "Métadonnées des chapitres à incorporer dans les enregistrements exportés"
|
||||
}
|
||||
},
|
||||
"preview": {
|
||||
@@ -320,7 +326,8 @@
|
||||
"quality": {
|
||||
"label": "Prévisualisation qualité",
|
||||
"description": "Niveau de qualité des prévisualisations (très_bas, bas, moyen, haut, très_haut)."
|
||||
}
|
||||
},
|
||||
"description": "Réglages de la qualité des aperçus d'enregistrement montrés dans l'interface utilisateur."
|
||||
},
|
||||
"enabled_in_config": {
|
||||
"label": "État original de l'enregistrement",
|
||||
@@ -340,6 +347,60 @@
|
||||
},
|
||||
"required_zones": {
|
||||
"label": "Zones requises"
|
||||
},
|
||||
"enabled_in_config": {
|
||||
"label": "État des alertes originales",
|
||||
"description": "Vérifie si les alertes étaient initialement activées dans la configuration statique."
|
||||
},
|
||||
"cutoff_time": {
|
||||
"label": "Temps de coupure des alertes",
|
||||
"description": "Délai en secondes après l'absence d'activité déclenchant une alerte avant de la couper."
|
||||
}
|
||||
},
|
||||
"detections": {
|
||||
"label": "Configuration des détections",
|
||||
"description": "Paramètres pour lesquels les objets suivis génèrent des détections (hors alerte) et comment les détections sont conservées.",
|
||||
"enabled": {
|
||||
"label": "Activer les détections"
|
||||
},
|
||||
"labels": {
|
||||
"label": "Étiquettes de détection",
|
||||
"description": "Liste des étiquettes d'objets qui correspondent à des événements de détection."
|
||||
},
|
||||
"required_zones": {
|
||||
"label": "Zones obligatoires",
|
||||
"description": "Zones dans lesquelles un objet doit pénétrer pour être considéré comme une détection ; laisser vide pour autoriser n’importe quelle zone."
|
||||
},
|
||||
"cutoff_time": {
|
||||
"label": "Délai limite de détection",
|
||||
"description": "Nombre de secondes à attendre après l'absence d'activité susceptible de déclencher une détection avant d'interrompre celle-ci."
|
||||
},
|
||||
"enabled_in_config": {
|
||||
"label": "État initial des détections",
|
||||
"description": "Vérifie si les détections étaient activées initialement dans la configuration statique."
|
||||
}
|
||||
},
|
||||
"genai": {
|
||||
"label": "Configuration de l'IA générative",
|
||||
"description": "Contrôle l'utilisation de l'IA générative pour la production de descriptions et de résumés des éléments d'évaluation.",
|
||||
"enabled": {
|
||||
"label": "Activer les descriptions de l'IA générative",
|
||||
"description": "Activer ou désactiver les descriptions et les résumés générés par l'IA générative pour les éléments à examiner."
|
||||
},
|
||||
"alerts": {
|
||||
"label": "Activer l'IA générative pour les alertes",
|
||||
"description": "Utilisez l'IA générative pour générer des descriptions pour les éléments d'alerte."
|
||||
},
|
||||
"detections": {
|
||||
"label": "Activer l'IA générative pour les détections",
|
||||
"description": "Utilisez l'IA générative pour générer des descriptions pour les éléments de détection."
|
||||
},
|
||||
"image_source": {
|
||||
"label": "Source de l'image de la revue",
|
||||
"description": "Source des images envoyées à l'IA générative (« aperçu » ou « enregistrements ») ; « enregistrements » utilise des images de meilleure qualité mais plus de jetons d'IA."
|
||||
},
|
||||
"additional_concerns": {
|
||||
"label": "Autres préoccupations"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
"descriptionLabel": "Description"
|
||||
},
|
||||
"deleteCase": {
|
||||
"desc": "Êtes-vous sûr de vouloir supprimer {{caseName}}?",
|
||||
"desc": "Êtes-vous sûr de vouloir supprimer {{caseName}} ?",
|
||||
"descKeepExports": "Les exports seront disponibles comme exports non catégorisés.",
|
||||
"deleteExports": "Supprimer aussi les exports"
|
||||
}
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
"globalConfig": "Configuration globale - Frigate",
|
||||
"cameraConfig": "Configuration de la caméra - Frigate",
|
||||
"maintenance": "Maintenance - Frigate",
|
||||
"profiles": "Profils - Frigate"
|
||||
"profiles": "Profils - Frigate",
|
||||
"detectorsAndModel": "Détecteurs et modèle - Frigate"
|
||||
},
|
||||
"menu": {
|
||||
"ui": "Interface utilisateur",
|
||||
@@ -90,7 +91,7 @@
|
||||
"maintenance": "Maintenance",
|
||||
"uiSettings": "Paramètres IU",
|
||||
"profiles": "Profils",
|
||||
"systemGo2rtcStreams": "Streams go2rtc"
|
||||
"systemGo2rtcStreams": "Flux go2rtc"
|
||||
},
|
||||
"dialog": {
|
||||
"unsavedChanges": {
|
||||
@@ -1363,9 +1364,9 @@
|
||||
"deleteCameraDialog": {
|
||||
"title": "Supprimer la caméra",
|
||||
"description": "Supprimer la caméra va supprimer de façon permanente les enregistrements, les objets suivis, et la configuration de la caméra. Tous les streams go2rtc associés à la caméra devront être supprimés manuellement.",
|
||||
"selectPlaceholder": "Choisir une caméra...",
|
||||
"confirmTitle": "Êtes-vous sûr?",
|
||||
"confirmWarning": "Supprimer <strong>\n{{cameraName}}\n</strong> ne peut être annulé.",
|
||||
"selectPlaceholder": "Choisir une caméra …",
|
||||
"confirmTitle": "Êtes-vous certain ?",
|
||||
"confirmWarning": "La suppression de la caméra <strong>{{cameraName}}</strong> ne peut être annulé.",
|
||||
"deleteExports": "Supprimer aussi les exports de cette caméra",
|
||||
"confirmButton": "Suppression permanente",
|
||||
"success": "Caméra {{cameraName}} supprimée avec succès",
|
||||
|
||||
@@ -455,5 +455,33 @@
|
||||
"basketball_bounce": "Kosárlabda pattogás",
|
||||
"sine_wave": "Szinusz hullám",
|
||||
"echo": "Visszhang",
|
||||
"cacophony": "Kakófónia"
|
||||
"cacophony": "Kakófónia",
|
||||
"squish": "Facsarás",
|
||||
"drip": "Csöpögés",
|
||||
"pour": "Öntés",
|
||||
"fill": "Töltödés",
|
||||
"spray": "permet",
|
||||
"pump": "Pumpál",
|
||||
"boiling": "Forr",
|
||||
"sonar": "hanglokátor",
|
||||
"whoosh": "suhogás",
|
||||
"electronic_tuner": "Elektronikus hangoló",
|
||||
"effects_unit": "Effektmodul",
|
||||
"chorus_effect": "Kórus-effektus",
|
||||
"bang": "Bumm",
|
||||
"slap": "Pofon",
|
||||
"whack": "Pofon",
|
||||
"breaking": "Törés",
|
||||
"whip": "Ostor",
|
||||
"tearing": "Szakadás",
|
||||
"ping": "Pattan",
|
||||
"ding": "Csing",
|
||||
"squeal": "Visítás",
|
||||
"creak": "Nyikorgás",
|
||||
"rustle": "Suhogás",
|
||||
"whir": "Zúgás",
|
||||
"shofar": "Sófar",
|
||||
"clatter": "csörgés",
|
||||
"sizzle": "Sistergés",
|
||||
"sidetone": "Mellékhang"
|
||||
}
|
||||
|
||||
@@ -75,5 +75,10 @@
|
||||
"enabled": {
|
||||
"label": "Hangról szövegre alakítás engedélyezése"
|
||||
}
|
||||
},
|
||||
"zones": {
|
||||
"speed_threshold": {
|
||||
"label": "Minimum sebesség"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,7 +65,8 @@
|
||||
"batchQueuedSuccess_other": "{{count}} ekspor antrian.",
|
||||
"batchQueueFailed": "Gagal mengantrekan {{total}} ekspor. Kamera yang gagal: {{failedCameras}}",
|
||||
"error": {
|
||||
"failed": "Ekspor gagal mengantri: {{error}}"
|
||||
"failed": "Ekspor gagal mengantri: {{error}}",
|
||||
"noValidTimeSelected": "Rentang waktu yang dipilih tidak valid"
|
||||
}
|
||||
},
|
||||
"case": {
|
||||
@@ -114,7 +115,13 @@
|
||||
"exportingButton": "Mengekspor...",
|
||||
"caseNamePlaceholder": "Ekspor tinjauan - {{date}}"
|
||||
},
|
||||
"queueing": "Mengantrekan Ekspor..."
|
||||
"queueing": "Mengantrekan Ekspor...",
|
||||
"fromTimeline": {
|
||||
"saveExport": "Simpan ektraksi",
|
||||
"queueingExport": "Antrean Ekspor...",
|
||||
"previewExport": "Pratinjau Ekspor",
|
||||
"useThisRange": "Gunakan Rentang Ini"
|
||||
}
|
||||
},
|
||||
"search": {
|
||||
"saveSearch": {
|
||||
@@ -138,7 +145,8 @@
|
||||
}
|
||||
},
|
||||
"showStats": {
|
||||
"label": "Tampilkan statistik streaming"
|
||||
"label": "Tampilkan statistik streaming",
|
||||
"desc": "Aktifkan opsi ini untuk menampilkan statistik streaming sebagai overlay pada tampilan kamera."
|
||||
},
|
||||
"debugView": "Tampilan Debug"
|
||||
},
|
||||
@@ -147,7 +155,9 @@
|
||||
"label": "Bagikan Cap Waktu",
|
||||
"title": "Bagikan Cap Waktu",
|
||||
"custom": "Cap Waktu Kustom",
|
||||
"button": "Bagikan URL Cap Waktu"
|
||||
"button": "Bagikan URL Cap Waktu",
|
||||
"description": "Bagikan URL bertanda waktu sesuai posisi pemutar saat ini atau pilih tanda waktu kustom. Perlu dicatat bahwa ini bukan URL bagikan publik dan hanya dapat diakses oleh pengguna yang memiliki akses ke Frigate dan kamera ini.",
|
||||
"shareTitle": "Pratinjau Penunjuk Waktu Frigate : {{camera}}"
|
||||
},
|
||||
"confirmDelete": {
|
||||
"title": "Konfirmasi Penghapusan",
|
||||
|
||||
@@ -47,11 +47,42 @@
|
||||
"score": "Skor",
|
||||
"estimatedSpeed": "Perkiraan Kecepatan {{unit}}",
|
||||
"features": {
|
||||
"label": "Fitur"
|
||||
"label": "Fitur",
|
||||
"hasSnapshot": "Memiliki Cuplikan",
|
||||
"hasVideoClip": "Memiliki Klip Video",
|
||||
"submittedToFrigatePlus": {
|
||||
"label": "Dikirim ke Frigate+"
|
||||
}
|
||||
},
|
||||
"cameras": {
|
||||
"all": {
|
||||
"short": "Kamera"
|
||||
"short": "Kamera",
|
||||
"title": "Semua Kamera"
|
||||
},
|
||||
"label": "Filter Kamera"
|
||||
},
|
||||
"sort": {
|
||||
"label": "Urutkan",
|
||||
"dateAsc": "Tanggal (Terlama)",
|
||||
"dateDesc": "Tanggal (Terbaru)",
|
||||
"scoreAsc": "Skor Objek (Terendah)",
|
||||
"scoreDesc": "Skor Objek (Tertinggi)",
|
||||
"speedAsc": "Estimasi Kecepatan (Terendah)",
|
||||
"speedDesc": "Estimasi Kecepatan (Tercepat)",
|
||||
"relevance": "Relevansi"
|
||||
},
|
||||
"review": {
|
||||
"showReviewed": "Tunjukan Pratinjau"
|
||||
},
|
||||
"motion": {
|
||||
"showMotionOnly": "Hanya Tampilkan Gerakan"
|
||||
},
|
||||
"explore": {
|
||||
"settings": {
|
||||
"title": "Pengaturan",
|
||||
"defaultView": {
|
||||
"title": "Tampilan Default"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user