mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-09-30 11:56:49 +03:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef11f7bc50 | ||
|
|
61e50a366f | ||
|
|
4e196516dd | ||
|
|
3941355051 | ||
|
|
1a278630da | ||
|
|
9d0d8a99bb | ||
|
|
bbc412763d | ||
|
|
ac9ac50df5 | ||
|
|
93aa6c4174 | ||
|
|
26e6adee88 |
@@ -894,6 +894,41 @@ deepstack:
|
|||||||
api_url: http://<your_codeproject_ai_server_ip>:<port>/v1/vision/detection
|
api_url: http://<your_codeproject_ai_server_ip>:<port>/v1/vision/detection
|
||||||
type: deepstack
|
type: deepstack
|
||||||
api_timeout: 0.1 # seconds
|
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:
|
memryx:
|
||||||
title: MemryX
|
title: MemryX
|
||||||
models:
|
models:
|
||||||
|
|||||||
@@ -286,7 +286,7 @@ ffmpeg:
|
|||||||
# Optional: output args for detect streams (default: shown below)
|
# Optional: output args for detect streams (default: shown below)
|
||||||
detect: -threads 2 -f rawvideo -pix_fmt yuv420p
|
detect: -threads 2 -f rawvideo -pix_fmt yuv420p
|
||||||
# Optional: output args for record streams (default: shown below)
|
# 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)
|
# 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 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
|
# 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
|
||||||
|
|||||||
@@ -379,9 +379,9 @@ Navigate to <NavPath path="Settings > Camera configuration > Object detection" /
|
|||||||
Navigate to <NavPath path="Settings > Camera configuration > Objects" />.
|
Navigate to <NavPath path="Settings > Camera configuration > Objects" />.
|
||||||
|
|
||||||
| Field | Description |
|
| Field | Description |
|
||||||
| ---------------------------------------------- | ------------------- |
|
| --------------------------------------------------------- | ------------------- |
|
||||||
| **Objects to track** | Add `license_plate` |
|
| **Objects to track** | Add `license_plate` |
|
||||||
| **Object filters > License Plate > Threshold** | Set to `0.7` |
|
| **Object filters > License Plate > Confidence threshold** | Set to `0.7` |
|
||||||
|
|
||||||
Navigate to <NavPath path="Settings > Camera configuration > Motion detection" />.
|
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.
|
- [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.
|
- [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**
|
**Apple Silicon**
|
||||||
|
|
||||||
@@ -508,6 +509,28 @@ To verify that the integration is working correctly, start Frigate and observe t
|
|||||||
|
|
||||||
# Community Supported Detectors
|
# 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
|
## 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.
|
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.
|
||||||
|
|||||||
@@ -46,9 +46,9 @@ 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.
|
Navigate to <NavPath path="Settings > Global configuration > Objects" /> to set score filters globally.
|
||||||
|
|
||||||
| Field | Description |
|
| Field | Description |
|
||||||
| --------------------------------------- | ---------------------------------------------------------------- |
|
| -------------------------------------------------- | ---------------------------------------------------------------- |
|
||||||
| **Object filters > Person > Min Score** | Minimum score for a single detection to initiate tracking |
|
| **Object filters > Person > Minimum confidence** | Minimum score for a single detection to initiate tracking |
|
||||||
| **Object filters > Person > Threshold** | Minimum computed (median) score to be considered a true positive |
|
| **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.
|
To override score filters for a specific camera, navigate to <NavPath path="Settings > Camera configuration > Objects" /> and select the camera.
|
||||||
|
|
||||||
@@ -104,11 +104,11 @@ 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.
|
Navigate to <NavPath path="Settings > Global configuration > Objects" /> to set shape filters globally.
|
||||||
|
|
||||||
| Field | Description |
|
| Field | Description |
|
||||||
| --------------------------------------- | ------------------------------------------------------------------------ |
|
| -------------------------------------------------- | ------------------------------------------------------------------------ |
|
||||||
| **Object filters > Person > Min Area** | Minimum bounding box area in pixels (or decimal for percentage of frame) |
|
| **Object filters > Person > Minimum object 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 > Maximum object 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 > Minimum aspect 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 > 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.
|
To override shape filters for a specific camera, navigate to <NavPath path="Settings > Camera configuration > Objects" /> and select the camera.
|
||||||
|
|
||||||
|
|||||||
@@ -71,13 +71,13 @@ Object filters help reduce false positives by constraining the size, shape, and
|
|||||||
Navigate to <NavPath path="Settings > Global configuration > Objects" />.
|
Navigate to <NavPath path="Settings > Global configuration > Objects" />.
|
||||||
|
|
||||||
| Field | Description |
|
| Field | Description |
|
||||||
| --------------------------------------- | ------------------------------------------------------------------------ |
|
| -------------------------------------------------- | ------------------------------------------------------------------------ |
|
||||||
| **Object filters > Person > Min Area** | Minimum bounding box area in pixels (or decimal for percentage of frame) |
|
| **Object filters > Person > Minimum object 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 > Maximum object 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 > Minimum aspect 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 > Maximum aspect 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 > Minimum confidence** | Minimum score for the object to initiate tracking |
|
||||||
| **Object filters > Person > Threshold** | Minimum computed score to be considered a true positive |
|
| **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" />.
|
To override filters for a specific camera, navigate to <NavPath path="Settings > Camera configuration > Objects" />.
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
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.
|
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:
|
3. In the **Create Trigger** wizard:
|
||||||
- Enter a **Name** for the trigger (e.g., "Red Car Alert").
|
- Enter a **Name** for the trigger (e.g., "Red Car Alert"). Frigate derives the trigger's
|
||||||
- Enter a descriptive **Friendly Name** for the trigger (e.g., "Red car on the driveway camera").
|
internal **ID** from this name, which can be revealed and edited with the show/hide toggle.
|
||||||
- Select the **Type** (`Thumbnail` or `Description`).
|
- 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 `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.
|
- 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.
|
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.
|
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.
|
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.
|
5. Press **Save** when finished.
|
||||||
|
|
||||||
</TabItem>
|
</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.
|
1. Navigate to <NavPath path="Settings > Camera configuration > Masks / Zones" /> and select the desired camera.
|
||||||
2. Edit or create the zone (e.g., `sidewalk`).
|
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`)
|
- Under **Objects**, add the relevant object types (e.g., `person`)
|
||||||
|
|
||||||
</TabItem>
|
</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.
|
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.
|
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.
|
- 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.
|
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.
|
1. Navigate to <NavPath path="Settings > Camera configuration > Masks / Zones" /> and select the desired camera.
|
||||||
2. Edit or create the zone with distances configured.
|
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
|
- The unit is kph or mph, depending on the **Unit system** setting
|
||||||
|
|
||||||
</TabItem>
|
</TabItem>
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ An object filter mask drops any [bounding box](#bounding-box) whose bottom cente
|
|||||||
|
|
||||||
## Min Score
|
## 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
|
## Model
|
||||||
|
|
||||||
@@ -86,7 +86,7 @@ A more specific identity assigned to a [tracked object](#tracked-object-event-in
|
|||||||
|
|
||||||
## Threshold
|
## 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
|
## 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
|
- [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)
|
- [Supports limited model architectures](../../configuration/object_detectors#amdrocm-gpu-detector)
|
||||||
- Runs best on discrete AMD GPUs
|
- 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**
|
**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 |
|
| 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)
|
## 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.
|
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
|
## General Frigate Topics
|
||||||
|
|
||||||
### `frigate/available`
|
### `frigate/available`
|
||||||
|
|||||||
@@ -64,10 +64,10 @@ Frigate+ models generally have much higher scores than the default model provide
|
|||||||
<ConfigTabs>
|
<ConfigTabs>
|
||||||
<TabItem value="ui">
|
<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 |
|
| Object | Minimum confidence | Confidence threshold |
|
||||||
| ----------------- | --------- | --------- |
|
| ----------------- | ------------------ | -------------------- |
|
||||||
| **dog** | .7 | .9 |
|
| **dog** | .7 | .9 |
|
||||||
| **cat** | .65 | .8 |
|
| **cat** | .65 | .8 |
|
||||||
| **face** | .7 | |
|
| **face** | .7 | |
|
||||||
|
|||||||
+30
-22
@@ -3,6 +3,9 @@ import * as path from "node:path";
|
|||||||
import type { Config, PluginConfig } from "@docusaurus/types";
|
import type { Config, PluginConfig } from "@docusaurus/types";
|
||||||
import type * as OpenApiPlugin from "docusaurus-plugin-openapi-docs";
|
import type * as OpenApiPlugin from "docusaurus-plugin-openapi-docs";
|
||||||
|
|
||||||
|
// Bump when a new stable release ships
|
||||||
|
const STABLE_VERSION = "0.18";
|
||||||
|
|
||||||
const config: Config = {
|
const config: Config = {
|
||||||
title: "Frigate",
|
title: "Frigate",
|
||||||
tagline: "NVR With Realtime Object Detection for IP Cameras",
|
tagline: "NVR With Realtime Object Detection for IP Cameras",
|
||||||
@@ -23,17 +26,17 @@ const config: Config = {
|
|||||||
mermaid: true,
|
mermaid: true,
|
||||||
},
|
},
|
||||||
i18n: {
|
i18n: {
|
||||||
defaultLocale: 'en',
|
defaultLocale: "en",
|
||||||
locales: ['en'],
|
locales: ["en"],
|
||||||
localeConfigs: {
|
localeConfigs: {
|
||||||
en: {
|
en: {
|
||||||
label: 'English',
|
label: "English",
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
themeConfig: {
|
themeConfig: {
|
||||||
announcementBar: {
|
announcementBar: {
|
||||||
id: 'frigate_plus',
|
id: "frigate_plus",
|
||||||
content: `
|
content: `
|
||||||
<span style="margin-right: 8px; display: inline-block; animation: pulse 2s infinite;">🚀</span>
|
<span style="margin-right: 8px; display: inline-block; animation: pulse 2s infinite;">🚀</span>
|
||||||
Get more relevant and accurate detections with Frigate+ models.
|
Get more relevant and accurate detections with Frigate+ models.
|
||||||
@@ -45,8 +48,8 @@ const config: Config = {
|
|||||||
50% { transform: scale(1.1); }
|
50% { transform: scale(1.1); }
|
||||||
}
|
}
|
||||||
</style>`,
|
</style>`,
|
||||||
backgroundColor: '#005f73',
|
backgroundColor: "#005f73",
|
||||||
textColor: '#e0fbfc',
|
textColor: "#e0fbfc",
|
||||||
isCloseable: false,
|
isCloseable: false,
|
||||||
},
|
},
|
||||||
docs: {
|
docs: {
|
||||||
@@ -83,15 +86,15 @@ const config: Config = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
prism: {
|
prism: {
|
||||||
magicComments:[
|
magicComments: [
|
||||||
{
|
{
|
||||||
className: 'theme-code-block-highlighted-line',
|
className: "theme-code-block-highlighted-line",
|
||||||
line: 'highlight-next-line',
|
line: "highlight-next-line",
|
||||||
block: {start: 'highlight-start', end: 'highlight-end'},
|
block: { start: "highlight-start", end: "highlight-end" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
className: 'code-block-error-line',
|
className: "code-block-error-line",
|
||||||
line: 'highlight-error-line',
|
line: "highlight-error-line",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
additionalLanguages: ["bash", "json"],
|
additionalLanguages: ["bash", "json"],
|
||||||
@@ -131,6 +134,11 @@ const config: Config = {
|
|||||||
srcDark: "img/branding/logo-dark.svg",
|
srcDark: "img/branding/logo-dark.svg",
|
||||||
},
|
},
|
||||||
items: [
|
items: [
|
||||||
|
{
|
||||||
|
href: "https://github.com/blakeblackshear/frigate/releases",
|
||||||
|
label: `${STABLE_VERSION}`,
|
||||||
|
position: "left",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
to: "/",
|
to: "/",
|
||||||
activeBasePath: "docs",
|
activeBasePath: "docs",
|
||||||
@@ -148,19 +156,19 @@ const config: Config = {
|
|||||||
position: "right",
|
position: "right",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'localeDropdown',
|
type: "localeDropdown",
|
||||||
position: 'right',
|
position: "right",
|
||||||
dropdownItemsAfter: [
|
dropdownItemsAfter: [
|
||||||
{
|
{
|
||||||
label: '简体中文(社区翻译)',
|
label: "简体中文(社区翻译)",
|
||||||
href: 'https://docs.frigate-cn.video',
|
href: "https://docs.frigate-cn.video",
|
||||||
}
|
},
|
||||||
]
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: 'https://github.com/blakeblackshear/frigate',
|
href: "https://github.com/blakeblackshear/frigate",
|
||||||
label: 'GitHub',
|
label: "GitHub",
|
||||||
position: 'right',
|
position: "right",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -103,12 +103,13 @@ class CameraActivityManager:
|
|||||||
all_objects: list[dict[str, Any]] = []
|
all_objects: list[dict[str, Any]] = []
|
||||||
|
|
||||||
for camera in new_activity.keys():
|
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
|
continue
|
||||||
|
|
||||||
# handle cameras that were added dynamically
|
# handle cameras that were added dynamically
|
||||||
if camera not in self.camera_all_object_counts:
|
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", [])
|
new_objects = new_activity[camera].get("objects", [])
|
||||||
all_objects.extend(new_objects)
|
all_objects.extend(new_objects)
|
||||||
@@ -233,12 +234,13 @@ class AudioActivityManager:
|
|||||||
now = datetime.datetime.now().timestamp()
|
now = datetime.datetime.now().timestamp()
|
||||||
|
|
||||||
for camera in new_activity.keys():
|
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
|
continue
|
||||||
|
|
||||||
# handle cameras that were added dynamically
|
# handle cameras that were added dynamically
|
||||||
if camera not in self.current_audio_detections:
|
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", [])
|
new_detections = new_activity[camera].get("detections", [])
|
||||||
if self.compare_audio_activity(camera, new_detections, now):
|
if self.compare_audio_activity(camera, new_detections, now):
|
||||||
|
|||||||
+76
-15
@@ -1,8 +1,10 @@
|
|||||||
import logging
|
import logging
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
import threading
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import regex
|
import regex
|
||||||
|
from peewee import DatabaseError
|
||||||
from playhouse.sqliteq import SqliteQueueDatabase
|
from playhouse.sqliteq import SqliteQueueDatabase
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -17,6 +19,7 @@ class SqliteVecQueueDatabase(SqliteQueueDatabase):
|
|||||||
self.load_vec_extension: bool = load_vec_extension
|
self.load_vec_extension: bool = load_vec_extension
|
||||||
# no extension necessary, sqlite will load correctly for each platform
|
# no extension necessary, sqlite will load correctly for each platform
|
||||||
self.sqlite_vec_path = "/usr/local/lib/vec0"
|
self.sqlite_vec_path = "/usr/local/lib/vec0"
|
||||||
|
self.upsert_lock = threading.Lock()
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
def _connect(self, *args: Any, **kwargs: Any) -> sqlite3.Connection:
|
def _connect(self, *args: Any, **kwargs: Any) -> sqlite3.Connection:
|
||||||
@@ -53,6 +56,22 @@ class SqliteVecQueueDatabase(SqliteQueueDatabase):
|
|||||||
|
|
||||||
conn.create_function("REGEXP", 2, regexp)
|
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:
|
def _delete_embeddings(self, table: str, event_ids: list[str]) -> None:
|
||||||
"""Delete embeddings for the given events, if the table exists.
|
"""Delete embeddings for the given events, if the table exists.
|
||||||
|
|
||||||
@@ -63,17 +82,17 @@ class SqliteVecQueueDatabase(SqliteQueueDatabase):
|
|||||||
return
|
return
|
||||||
|
|
||||||
# the embeddings tables are only created once semantic search has run
|
# the embeddings tables are only created once semantic search has run
|
||||||
cursor = self.execute_sql(
|
if not self._table_exists(table):
|
||||||
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?",
|
|
||||||
(table,),
|
|
||||||
)
|
|
||||||
|
|
||||||
if cursor.fetchone() is None:
|
|
||||||
logger.debug("Skipping %s cleanup, table does not exist", table)
|
logger.debug("Skipping %s cleanup, table does not exist", table)
|
||||||
return
|
return
|
||||||
|
|
||||||
ids = ",".join(["?" for _ in event_ids])
|
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:
|
def delete_embeddings_thumbnail(self, event_ids: list[str]) -> None:
|
||||||
self._delete_embeddings("vec_thumbnails", event_ids)
|
self._delete_embeddings("vec_thumbnails", event_ids)
|
||||||
@@ -81,25 +100,67 @@ class SqliteVecQueueDatabase(SqliteQueueDatabase):
|
|||||||
def delete_embeddings_description(self, event_ids: list[str]) -> None:
|
def delete_embeddings_description(self, event_ids: list[str]) -> None:
|
||||||
self._delete_embeddings("vec_descriptions", event_ids)
|
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:
|
def drop_embeddings_tables(self) -> None:
|
||||||
self.execute_sql("""
|
for table in ("vec_descriptions", "vec_thumbnails"):
|
||||||
DROP TABLE vec_descriptions;
|
self._restore_vec_info_table(table)
|
||||||
""")
|
self.execute_write(f"DROP TABLE IF EXISTS {table}")
|
||||||
self.execute_sql("""
|
|
||||||
DROP TABLE vec_thumbnails;
|
|
||||||
""")
|
|
||||||
|
|
||||||
def create_embeddings_tables(self) -> None:
|
def create_embeddings_tables(self) -> None:
|
||||||
"""Create vec0 virtual table for embeddings"""
|
"""Create vec0 virtual table for embeddings"""
|
||||||
self.execute_sql("""
|
self.execute_write("""
|
||||||
CREATE VIRTUAL TABLE IF NOT EXISTS vec_thumbnails USING vec0(
|
CREATE VIRTUAL TABLE IF NOT EXISTS vec_thumbnails USING vec0(
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
thumbnail_embedding FLOAT[768] distance_metric=cosine
|
thumbnail_embedding FLOAT[768] distance_metric=cosine
|
||||||
);
|
);
|
||||||
""")
|
""")
|
||||||
self.execute_sql("""
|
self.execute_write("""
|
||||||
CREATE VIRTUAL TABLE IF NOT EXISTS vec_descriptions USING vec0(
|
CREATE VIRTUAL TABLE IF NOT EXISTS vec_descriptions USING vec0(
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
description_embedding FLOAT[768] distance_metric=cosine
|
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
|
||||||
|
)
|
||||||
|
|||||||
@@ -6,9 +6,10 @@ import logging
|
|||||||
import os
|
import os
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from peewee import DoesNotExist, IntegrityError
|
from peewee import DatabaseError, DoesNotExist, IntegrityError
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
from playhouse.shortcuts import model_to_dict
|
from playhouse.shortcuts import model_to_dict
|
||||||
|
|
||||||
@@ -207,12 +208,10 @@ class Embeddings:
|
|||||||
embedding = self.vision_embedding([thumbnail])[0]
|
embedding = self.vision_embedding([thumbnail])[0]
|
||||||
|
|
||||||
if upsert:
|
if upsert:
|
||||||
self.db.execute_sql(
|
self.db.upsert_embeddings(
|
||||||
"""
|
"vec_thumbnails",
|
||||||
INSERT OR REPLACE INTO vec_thumbnails(id, thumbnail_embedding)
|
"thumbnail_embedding",
|
||||||
VALUES(?, ?)
|
{event_id: serialize(embedding)},
|
||||||
""",
|
|
||||||
(event_id, serialize(embedding)),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
self.image_inference_speed.update(datetime.datetime.now().timestamp() - start)
|
self.image_inference_speed.update(datetime.datetime.now().timestamp() - start)
|
||||||
@@ -251,19 +250,12 @@ class Embeddings:
|
|||||||
embeddings = self.vision_embedding(valid_thumbs)
|
embeddings = self.vision_embedding(valid_thumbs)
|
||||||
|
|
||||||
if upsert:
|
if upsert:
|
||||||
items = []
|
items = {}
|
||||||
for i in range(len(valid_ids)):
|
for i in range(len(valid_ids)):
|
||||||
items.append(valid_ids[i])
|
items[valid_ids[i]] = serialize(embeddings[i])
|
||||||
items.append(serialize(embeddings[i]))
|
|
||||||
self.image_eps.update()
|
self.image_eps.update()
|
||||||
|
|
||||||
self.db.execute_sql(
|
self.db.upsert_embeddings("vec_thumbnails", "thumbnail_embedding", items)
|
||||||
"""
|
|
||||||
INSERT OR REPLACE INTO vec_thumbnails(id, thumbnail_embedding)
|
|
||||||
VALUES {}
|
|
||||||
""".format(", ".join(["(?, ?)"] * len(valid_ids))),
|
|
||||||
items,
|
|
||||||
)
|
|
||||||
|
|
||||||
duration = datetime.datetime.now().timestamp() - start
|
duration = datetime.datetime.now().timestamp() - start
|
||||||
self.image_inference_speed.update(duration / len(valid_ids))
|
self.image_inference_speed.update(duration / len(valid_ids))
|
||||||
@@ -277,12 +269,10 @@ class Embeddings:
|
|||||||
embedding = self.text_embedding([description])[0]
|
embedding = self.text_embedding([description])[0]
|
||||||
|
|
||||||
if upsert:
|
if upsert:
|
||||||
self.db.execute_sql(
|
self.db.upsert_embeddings(
|
||||||
"""
|
"vec_descriptions",
|
||||||
INSERT OR REPLACE INTO vec_descriptions(id, description_embedding)
|
"description_embedding",
|
||||||
VALUES(?, ?)
|
{event_id: serialize(embedding)},
|
||||||
""",
|
|
||||||
(event_id, serialize(embedding)),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
self.text_inference_speed.update(datetime.datetime.now().timestamp() - start)
|
self.text_inference_speed.update(datetime.datetime.now().timestamp() - start)
|
||||||
@@ -302,19 +292,14 @@ class Embeddings:
|
|||||||
|
|
||||||
if upsert:
|
if upsert:
|
||||||
ids = list(event_descriptions.keys())
|
ids = list(event_descriptions.keys())
|
||||||
items = []
|
items = {}
|
||||||
|
|
||||||
for i in range(len(ids)):
|
for i in range(len(ids)):
|
||||||
items.append(ids[i])
|
items[ids[i]] = serialize(embeddings[i])
|
||||||
items.append(serialize(embeddings[i]))
|
|
||||||
self.text_eps.update()
|
self.text_eps.update()
|
||||||
|
|
||||||
self.db.execute_sql(
|
self.db.upsert_embeddings(
|
||||||
"""
|
"vec_descriptions", "description_embedding", items
|
||||||
INSERT OR REPLACE INTO vec_descriptions(id, description_embedding)
|
|
||||||
VALUES {}
|
|
||||||
""".format(", ".join(["(?, ?)"] * len(ids))),
|
|
||||||
items,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
self.text_inference_speed.update(datetime.datetime.now().timestamp() - start)
|
self.text_inference_speed.update(datetime.datetime.now().timestamp() - start)
|
||||||
@@ -322,6 +307,17 @@ class Embeddings:
|
|||||||
return embeddings
|
return embeddings
|
||||||
|
|
||||||
def reindex(self) -> None:
|
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...")
|
logger.info("Indexing tracked object embeddings...")
|
||||||
|
|
||||||
self.db.drop_embeddings_tables()
|
self.db.drop_embeddings_tables()
|
||||||
@@ -346,14 +342,18 @@ class Embeddings:
|
|||||||
batch_size = 32
|
batch_size = 32
|
||||||
current_page = 1
|
current_page = 1
|
||||||
|
|
||||||
totals = {
|
totals.update(
|
||||||
|
{
|
||||||
"thumbnails": 0,
|
"thumbnails": 0,
|
||||||
"descriptions": 0,
|
"descriptions": 0,
|
||||||
"processed_objects": total_events - 1 if total_events < batch_size else 0,
|
"processed_objects": total_events - 1
|
||||||
|
if total_events < batch_size
|
||||||
|
else 0,
|
||||||
"total_objects": total_events,
|
"total_objects": total_events,
|
||||||
"time_remaining": 0 if total_events < batch_size else -1,
|
"time_remaining": 0 if total_events < batch_size else -1,
|
||||||
"status": "indexing",
|
"status": "indexing",
|
||||||
}
|
}
|
||||||
|
)
|
||||||
|
|
||||||
self.requestor.send_data(UPDATE_EMBEDDINGS_REINDEX_PROGRESS, totals)
|
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]
|
chunk = ids_to_delete[i : i + CHUNK_SIZE]
|
||||||
logger.debug(f"Deleting {len(chunk)} events from the database")
|
logger.debug(f"Deleting {len(chunk)} events from the database")
|
||||||
Event.delete().where(Event.id << chunk).execute()
|
Event.delete().where(Event.id << chunk).execute()
|
||||||
|
Timeline.delete().where(Timeline.source_id << chunk).execute()
|
||||||
|
|
||||||
# embeddings are always cleaned up, even when semantic search
|
# embeddings are always cleaned up, even when semantic search
|
||||||
# is disabled, so that they don't outlive their events
|
# is disabled, so that they don't outlive their events
|
||||||
|
|||||||
@@ -352,8 +352,9 @@ def stats_snapshot(
|
|||||||
total_camera_fps = total_process_fps = total_skipped_fps = total_detection_fps = 0
|
total_camera_fps = total_process_fps = total_skipped_fps = total_detection_fps = 0
|
||||||
|
|
||||||
stats["cameras"] = {}
|
stats["cameras"] = {}
|
||||||
for name, camera_stats in camera_metrics.items():
|
for name, camera_stats in list(camera_metrics.items()):
|
||||||
if name not in config.cameras:
|
camera_config = config.cameras.get(name)
|
||||||
|
if camera_config is None:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
total_camera_fps += camera_stats.camera_fps.value
|
total_camera_fps += camera_stats.camera_fps.value
|
||||||
@@ -370,7 +371,7 @@ def stats_snapshot(
|
|||||||
# Calculate connection quality based on current state
|
# Calculate connection quality based on current state
|
||||||
# This is computed at stats-collection time so offline cameras
|
# This is computed at stats-collection time so offline cameras
|
||||||
# correctly show as unusable rather than excellent
|
# 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
|
current_fps = camera_stats.camera_fps.value
|
||||||
reconnects = camera_stats.reconnects_last_hour.value
|
reconnects = camera_stats.reconnects_last_hour.value
|
||||||
stalls = camera_stats.stalls_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),
|
"process_fps": round(camera_stats.process_fps.value, 2),
|
||||||
"skipped_fps": round(camera_stats.skipped_fps.value, 2),
|
"skipped_fps": round(camera_stats.skipped_fps.value, 2),
|
||||||
"detection_fps": round(camera_stats.detection_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,
|
"pid": pid,
|
||||||
"capture_pid": capture_pid,
|
"capture_pid": capture_pid,
|
||||||
"ffmpeg_pid": ffmpeg_pid,
|
"ffmpeg_pid": ffmpeg_pid,
|
||||||
|
|||||||
@@ -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
|
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
|
the delete path has to tolerate databases where the vec0 tables were never
|
||||||
created and installs where the sqlite-vec extension is unavailable.
|
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 os
|
||||||
|
import struct
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
|
from peewee import OperationalError
|
||||||
|
|
||||||
from frigate.db.sqlitevecq import SqliteVecQueueDatabase
|
from frigate.db.sqlitevecq import SqliteVecQueueDatabase
|
||||||
|
|
||||||
|
VEC_EXTENSION_PATH = "/usr/local/lib/vec0.so"
|
||||||
|
|
||||||
|
|
||||||
class TestDeleteEmbeddings(unittest.TestCase):
|
class TestDeleteEmbeddings(unittest.TestCase):
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
@@ -52,6 +60,21 @@ class TestDeleteEmbeddings(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertEqual(self._thumbnail_ids(), ["b"])
|
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:
|
def test_delete_skipped_without_extension(self) -> None:
|
||||||
self._create_thumbnails_table()
|
self._create_thumbnails_table()
|
||||||
self.db.load_vec_extension = False
|
self.db.load_vec_extension = False
|
||||||
@@ -61,3 +84,104 @@ class TestDeleteEmbeddings(unittest.TestCase):
|
|||||||
|
|
||||||
# the vec0 tables cannot be written without the extension
|
# the vec0 tables cannot be written without the extension
|
||||||
self.assertEqual(self._thumbnail_ids(), ["a", "b"])
|
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"
|
timeline_entry[Timeline.class_type] = "gone"
|
||||||
self.insert_or_save(timeline_entry, prev_event_data, event_data)
|
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(
|
def handle_api_entry(
|
||||||
self,
|
self,
|
||||||
camera: str,
|
camera: str,
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ from frigate.comms.event_metadata_updater import (
|
|||||||
from frigate.comms.events_updater import EventEndSubscriber, EventUpdatePublisher
|
from frigate.comms.events_updater import EventEndSubscriber, EventUpdatePublisher
|
||||||
from frigate.comms.inter_process import InterProcessRequestor
|
from frigate.comms.inter_process import InterProcessRequestor
|
||||||
from frigate.config import (
|
from frigate.config import (
|
||||||
|
CameraConfig,
|
||||||
CameraMqttConfig,
|
CameraMqttConfig,
|
||||||
FrigateConfig,
|
FrigateConfig,
|
||||||
RecordConfig,
|
RecordConfig,
|
||||||
@@ -128,8 +129,10 @@ class TrackedObjectProcessor(threading.Thread):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def update(camera: str, obj: TrackedObject, frame_name: str) -> None:
|
def update(camera: str, obj: TrackedObject, frame_name: str) -> None:
|
||||||
obj.has_snapshot = self.should_save_snapshot(camera, obj)
|
obj.has_snapshot = self.should_save_snapshot(
|
||||||
obj.has_clip = self.should_retain_recording(camera, obj)
|
camera_state.camera_config, obj
|
||||||
|
)
|
||||||
|
obj.has_clip = self.should_retain_recording(camera_state.camera_config, obj)
|
||||||
after = obj.to_dict()
|
after = obj.to_dict()
|
||||||
message = {
|
message = {
|
||||||
"before": obj.previous,
|
"before": obj.previous,
|
||||||
@@ -153,8 +156,10 @@ class TrackedObjectProcessor(threading.Thread):
|
|||||||
|
|
||||||
def end(camera: str, obj: TrackedObject, frame_name: str) -> None:
|
def end(camera: str, obj: TrackedObject, frame_name: str) -> None:
|
||||||
# populate has_snapshot
|
# populate has_snapshot
|
||||||
obj.has_snapshot = self.should_save_snapshot(camera, obj)
|
obj.has_snapshot = self.should_save_snapshot(
|
||||||
obj.has_clip = self.should_retain_recording(camera, obj)
|
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
|
# write thumbnail to disk if it will be saved as an event
|
||||||
if obj.has_snapshot or obj.has_clip:
|
if obj.has_snapshot or obj.has_clip:
|
||||||
@@ -184,8 +189,8 @@ class TrackedObjectProcessor(threading.Thread):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def snapshot(camera: str, obj: TrackedObject) -> bool:
|
def snapshot(camera: str, obj: TrackedObject) -> bool:
|
||||||
mqtt_config: CameraMqttConfig = self.config.cameras[camera].mqtt
|
mqtt_config: CameraMqttConfig = camera_state.camera_config.mqtt
|
||||||
if mqtt_config.enabled and self.should_mqtt_snapshot(camera, obj):
|
if mqtt_config.enabled and self.should_mqtt_snapshot(mqtt_config, obj):
|
||||||
jpg_bytes, _ = obj.get_img_bytes(
|
jpg_bytes, _ = obj.get_img_bytes(
|
||||||
ext="jpg",
|
ext="jpg",
|
||||||
timestamp=mqtt_config.timestamp,
|
timestamp=mqtt_config.timestamp,
|
||||||
@@ -238,11 +243,13 @@ class TrackedObjectProcessor(threading.Thread):
|
|||||||
camera_state.on("camera_activity", camera_activity)
|
camera_state.on("camera_activity", camera_activity)
|
||||||
self.camera_states[camera] = camera_state
|
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:
|
if obj.false_positive:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
snapshot_config: SnapshotsConfig = self.config.cameras[camera].snapshots
|
snapshot_config: SnapshotsConfig = camera_config.snapshots
|
||||||
|
|
||||||
if not snapshot_config.enabled:
|
if not snapshot_config.enabled:
|
||||||
return False
|
return False
|
||||||
@@ -261,11 +268,13 @@ class TrackedObjectProcessor(threading.Thread):
|
|||||||
|
|
||||||
return True
|
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:
|
if obj.false_positive:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
record_config: RecordConfig = self.config.cameras[camera].record
|
record_config: RecordConfig = camera_config.record
|
||||||
|
|
||||||
# Recording is disabled
|
# Recording is disabled
|
||||||
if not record_config.enabled:
|
if not record_config.enabled:
|
||||||
@@ -281,13 +290,15 @@ class TrackedObjectProcessor(threading.Thread):
|
|||||||
|
|
||||||
return True
|
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
|
# object never changed position
|
||||||
if obj.is_stationary():
|
if obj.is_stationary():
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# if there are required zones and there is no overlap
|
# 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):
|
if len(required_zones) > 0 and not set(obj.entered_zones) & set(required_zones):
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"Not sending mqtt for {obj.obj_data['id']} because it did not enter required zones"
|
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
|
return True
|
||||||
|
|
||||||
def update_mqtt_motion(
|
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:
|
) -> None:
|
||||||
# publish if motion is currently being detected
|
# publish if motion is currently being detected
|
||||||
if motion_boxes:
|
if motion_boxes:
|
||||||
@@ -312,7 +327,7 @@ class TrackedObjectProcessor(threading.Thread):
|
|||||||
# always updated latest motion
|
# always updated latest motion
|
||||||
self.last_motion_detected[camera] = frame_time
|
self.last_motion_detected[camera] = frame_time
|
||||||
elif self.last_motion_detected.get(camera, 0) > 0:
|
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 no motion, make sure the off_delay has passed
|
||||||
if frame_time - self.last_motion_detected.get(camera, 0) >= mqtt_delay:
|
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
|
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 = [
|
tracked_objects = [
|
||||||
o.to_dict() for o in camera_state.tracked_objects.values()
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
RECORD_GRACE_SECONDS = 90
|
||||||
|
|
||||||
|
|
||||||
def capture_frames(
|
def capture_frames(
|
||||||
ffmpeg_process: sp.Popen[Any],
|
ffmpeg_process: sp.Popen[Any],
|
||||||
@@ -164,6 +166,7 @@ class CameraWatchdog(threading.Thread):
|
|||||||
self.latest_invalid_segment_time: float = 0
|
self.latest_invalid_segment_time: float = 0
|
||||||
self.latest_cache_segment_time: float = 0
|
self.latest_cache_segment_time: float = 0
|
||||||
self.record_enable_time: datetime | None = None
|
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
|
# `valid` segments are published with the segment's start time, so the
|
||||||
# gap between consecutive publishes can reach 2 * segment_time. Pad 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_valid_segment_time = 0
|
||||||
self.latest_invalid_segment_time = 0
|
self.latest_invalid_segment_time = 0
|
||||||
self.latest_cache_segment_time = 0
|
self.latest_cache_segment_time = 0
|
||||||
|
self.record_grace_until = None
|
||||||
self.record_enable_time = datetime.now().astimezone(UTC)
|
self.record_enable_time = datetime.now().astimezone(UTC)
|
||||||
last_restart_time = datetime.now().timestamp()
|
last_restart_time = datetime.now().timestamp()
|
||||||
continue
|
continue
|
||||||
@@ -294,6 +298,7 @@ class CameraWatchdog(threading.Thread):
|
|||||||
self.latest_valid_segment_time = 0
|
self.latest_valid_segment_time = 0
|
||||||
self.latest_invalid_segment_time = 0
|
self.latest_invalid_segment_time = 0
|
||||||
self.latest_cache_segment_time = 0
|
self.latest_cache_segment_time = 0
|
||||||
|
self.record_grace_until = None
|
||||||
self.record_enable_time = datetime.now().astimezone(UTC)
|
self.record_enable_time = datetime.now().astimezone(UTC)
|
||||||
else:
|
else:
|
||||||
self.logger.debug(f"Disabling camera {self.config.name}")
|
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_valid_segment_time = 0
|
||||||
self.latest_invalid_segment_time = 0
|
self.latest_invalid_segment_time = 0
|
||||||
self.latest_cache_segment_time = 0
|
self.latest_cache_segment_time = 0
|
||||||
|
self.record_grace_until = None
|
||||||
self.record_enable_time = datetime.now().astimezone(UTC)
|
self.record_enable_time = datetime.now().astimezone(UTC)
|
||||||
last_restart_time = datetime.now().timestamp()
|
last_restart_time = datetime.now().timestamp()
|
||||||
self.was_record_enabled_in_config = record_enabled_in_config
|
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"]:
|
if self.config.record.enabled and "record" in p["roles"]:
|
||||||
now_utc = datetime.now().astimezone(UTC)
|
now_utc = datetime.now().astimezone(UTC)
|
||||||
|
|
||||||
# Check if we're within the grace period after enabling recording
|
# ffmpeg needs time to create a first segment after
|
||||||
# Grace period: 90 seconds allows time for ffmpeg to start and create first segment
|
# recording is enabled and after a restart
|
||||||
in_grace_period = self.record_enable_time is not None and (
|
in_grace_period = (
|
||||||
now_utc - self.record_enable_time
|
self.record_enable_time is not None
|
||||||
) < timedelta(seconds=90)
|
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 = (
|
latest_cache_dt = (
|
||||||
datetime.fromtimestamp(self.latest_cache_segment_time, tz=UTC)
|
datetime.fromtimestamp(self.latest_cache_segment_time, tz=UTC)
|
||||||
@@ -445,8 +456,9 @@ class CameraWatchdog(threading.Thread):
|
|||||||
<= self.latest_invalid_segment_time
|
<= self.latest_invalid_segment_time
|
||||||
)
|
)
|
||||||
invalid_stale = invalid_stale_condition
|
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:
|
if cache_stale:
|
||||||
reason = "No new recording segments were created"
|
reason = "No new recording segments were created"
|
||||||
elif valid_stale:
|
elif valid_stale:
|
||||||
@@ -471,8 +483,13 @@ class CameraWatchdog(threading.Thread):
|
|||||||
f"{self.config.name}/status/{role.value}", "offline"
|
f"{self.config.name}/status/{role.value}", "offline"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
self.record_grace_until = now_utc + timedelta(
|
||||||
|
seconds=RECORD_GRACE_SECONDS
|
||||||
|
)
|
||||||
|
last_restart_time = now
|
||||||
|
|
||||||
continue
|
continue
|
||||||
else:
|
elif not stale:
|
||||||
self._send_record_status("online", now)
|
self._send_record_status("online", now)
|
||||||
p["latest_segment_time"] = self.latest_cache_segment_time
|
p["latest_segment_time"] = self.latest_cache_segment_time
|
||||||
|
|
||||||
|
|||||||
@@ -216,6 +216,18 @@ test.describe("Explore — content @high", () => {
|
|||||||
// Similarity search URL param
|
// 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.describe("Explore — similarity search (desktop) @high", () => {
|
||||||
test.skip(
|
test.skip(
|
||||||
({ frigateApp }) => frigateApp.isMobile,
|
({ frigateApp }) => frigateApp.isMobile,
|
||||||
|
|||||||
@@ -30,10 +30,7 @@ function groupedFacesMock() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function installGroupedFaces(app: FrigateApp) {
|
const GROUPED_EVENT = {
|
||||||
await app.api.install({
|
|
||||||
events: [
|
|
||||||
{
|
|
||||||
id: GROUPED_EVENT_ID,
|
id: GROUPED_EVENT_ID,
|
||||||
label: "person",
|
label: "person",
|
||||||
sub_label: null,
|
sub_label: null,
|
||||||
@@ -60,10 +57,24 @@ async function installGroupedFaces(app: FrigateApp) {
|
|||||||
type: "object",
|
type: "object",
|
||||||
path_data: [],
|
path_data: [],
|
||||||
},
|
},
|
||||||
},
|
};
|
||||||
],
|
|
||||||
|
async function installGroupedFaces(
|
||||||
|
app: FrigateApp,
|
||||||
|
opts: { withEventIds?: boolean } = {},
|
||||||
|
) {
|
||||||
|
await app.api.install({
|
||||||
|
events: [GROUPED_EVENT],
|
||||||
faces: groupedFacesMock(),
|
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> {
|
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.describe("Face Library — mobile @high @mobile", () => {
|
||||||
test.skip(({ frigateApp }) => !frigateApp.isMobile, "Mobile-only");
|
test.skip(({ frigateApp }) => !frigateApp.isMobile, "Mobile-only");
|
||||||
|
|
||||||
|
|||||||
@@ -233,6 +233,7 @@
|
|||||||
"detectHighCpuUsage": "{{camera}} has high detect CPU usage ({{detectAvg}}%)",
|
"detectHighCpuUsage": "{{camera}} has high detect CPU usage ({{detectAvg}}%)",
|
||||||
"healthy": "System is healthy",
|
"healthy": "System is healthy",
|
||||||
"reindexingEmbeddings": "Reindexing embeddings ({{processed}}% complete)",
|
"reindexingEmbeddings": "Reindexing embeddings ({{processed}}% complete)",
|
||||||
|
"reindexEmbeddingsFailed": "Reindexing embeddings failed, check the logs",
|
||||||
"cameraIsOffline": "{{camera}} is offline",
|
"cameraIsOffline": "{{camera}} is offline",
|
||||||
"detectIsSlow": "{{detect}} is slow ({{speed}} ms)",
|
"detectIsSlow": "{{detect}} is slow ({{speed}} ms)",
|
||||||
"detectIsVerySlow": "{{detect}} is very slow ({{speed}} ms)",
|
"detectIsVerySlow": "{{detect}} is very slow ({{speed}} ms)",
|
||||||
|
|||||||
@@ -71,8 +71,9 @@ export default function Statusbar() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (reindexState) {
|
if (reindexState) {
|
||||||
if (reindexState.status == "indexing") {
|
|
||||||
clearMessages("embeddings-reindex");
|
clearMessages("embeddings-reindex");
|
||||||
|
|
||||||
|
if (reindexState.status === "indexing") {
|
||||||
addMessage(
|
addMessage(
|
||||||
"embeddings-reindex",
|
"embeddings-reindex",
|
||||||
t("stats.reindexingEmbeddings", {
|
t("stats.reindexingEmbeddings", {
|
||||||
@@ -82,9 +83,8 @@ export default function Statusbar() {
|
|||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
} else if (reindexState.status === "failed") {
|
||||||
if (reindexState.status === "completed") {
|
addMessage("embeddings-reindex", t("stats.reindexEmbeddingsFailed"));
|
||||||
clearMessages("embeddings-reindex");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [reindexState, addMessage, clearMessages, t]);
|
}, [reindexState, addMessage, clearMessages, t]);
|
||||||
|
|||||||
@@ -1,12 +1,20 @@
|
|||||||
import { baseUrl } from "@/api/baseUrl";
|
import { baseUrl } from "@/api/baseUrl";
|
||||||
import useContextMenu from "@/hooks/use-contextmenu";
|
import useContextMenu from "@/hooks/use-contextmenu";
|
||||||
|
import { useOverlayState } from "@/hooks/use-overlay-state";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
ClassificationItemData,
|
ClassificationItemData,
|
||||||
ClassificationThreshold,
|
ClassificationThreshold,
|
||||||
ClassifiedEvent,
|
ClassifiedEvent,
|
||||||
} from "@/types/classification";
|
} from "@/types/classification";
|
||||||
import { forwardRef, useEffect, useMemo, useRef, useState } from "react";
|
import {
|
||||||
|
forwardRef,
|
||||||
|
useEffect,
|
||||||
|
useImperativeHandle,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
import { isDesktop, isIOS, isMobile, isMobileOnly } from "react-device-detect";
|
import { isDesktop, isIOS, isMobile, isMobileOnly } from "react-device-detect";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import TimeAgo from "../dynamic/TimeAgo";
|
import TimeAgo from "../dynamic/TimeAgo";
|
||||||
@@ -16,6 +24,7 @@ import { LuSearch, LuInfo } from "react-icons/lu";
|
|||||||
import { TooltipPortal } from "@radix-ui/react-tooltip";
|
import { TooltipPortal } from "@radix-ui/react-tooltip";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { HiSquare2Stack } from "react-icons/hi2";
|
import { HiSquare2Stack } from "react-icons/hi2";
|
||||||
|
import scrollIntoView from "scroll-into-view-if-needed";
|
||||||
import { ImageShadowOverlay } from "../overlay/ImageShadowOverlay";
|
import { ImageShadowOverlay } from "../overlay/ImageShadowOverlay";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -85,9 +94,14 @@ export const ClassificationCard = forwardRef<
|
|||||||
|
|
||||||
// interaction
|
// interaction
|
||||||
|
|
||||||
|
const cardRef = useRef<HTMLDivElement | null>(null);
|
||||||
const imgRef = useRef<HTMLImageElement | null>(null);
|
const imgRef = useRef<HTMLImageElement | null>(null);
|
||||||
|
|
||||||
useContextMenu(imgRef, () => {
|
useImperativeHandle(ref, () => cardRef.current!);
|
||||||
|
|
||||||
|
// Listen on the whole card, since overlays cover most of the image
|
||||||
|
|
||||||
|
useContextMenu(cardRef, () => {
|
||||||
onClick(data, true);
|
onClick(data, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -101,9 +115,9 @@ export const ClassificationCard = forwardRef<
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={cardRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex size-full flex-col overflow-hidden rounded-lg outline outline-[3px]",
|
"relative flex size-full select-none flex-col overflow-hidden rounded-lg outline outline-[3px]",
|
||||||
className,
|
className,
|
||||||
selected
|
selected
|
||||||
? "shadow-selected outline-selected"
|
? "shadow-selected outline-selected"
|
||||||
@@ -117,11 +131,7 @@ export const ClassificationCard = forwardRef<
|
|||||||
}
|
}
|
||||||
onClick(data, isMeta);
|
onClick(data, isMeta);
|
||||||
}}
|
}}
|
||||||
onContextMenu={(e) => {
|
style={isIOS ? { WebkitTouchCallout: "none" } : undefined}
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
onClick(data, true);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
ref={imgRef}
|
ref={imgRef}
|
||||||
@@ -130,14 +140,6 @@ export const ClassificationCard = forwardRef<
|
|||||||
imgClassName,
|
imgClassName,
|
||||||
isMobile && "w-full",
|
isMobile && "w-full",
|
||||||
)}
|
)}
|
||||||
style={
|
|
||||||
isIOS
|
|
||||||
? {
|
|
||||||
WebkitUserSelect: "none",
|
|
||||||
WebkitTouchCallout: "none",
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
draggable={false}
|
draggable={false}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
onLoad={() => setImageLoaded(true)}
|
onLoad={() => setImageLoaded(true)}
|
||||||
@@ -156,7 +158,7 @@ export const ClassificationCard = forwardRef<
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="absolute bottom-0 left-0 right-0 h-[50%] bg-gradient-to-t from-black/60 to-transparent" />
|
<div className="absolute bottom-0 left-0 right-0 h-[50%] bg-gradient-to-t from-black/60 to-transparent" />
|
||||||
<div className="absolute bottom-0 flex w-full select-none flex-row items-center justify-between gap-2 p-2">
|
<div className="absolute bottom-0 flex w-full flex-row items-center justify-between gap-2 p-2">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col items-start text-white",
|
"flex flex-col items-start text-white",
|
||||||
@@ -216,6 +218,41 @@ export function GroupedClassificationCard({
|
|||||||
const { t } = useTranslation(["views/explore", i18nLibrary]);
|
const { t } = useTranslation(["views/explore", i18nLibrary]);
|
||||||
const [detailOpen, setDetailOpen] = useState(false);
|
const [detailOpen, setDetailOpen] = useState(false);
|
||||||
|
|
||||||
|
// Explore stores this event in history state so going back can point out the
|
||||||
|
// card the user came from
|
||||||
|
|
||||||
|
const cardRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const [returnEventId, setReturnEventId] = useOverlayState<string | undefined>(
|
||||||
|
"returnEventId",
|
||||||
|
);
|
||||||
|
const [highlighted, setHighlighted] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!returnEventId || classifiedEvent?.id !== returnEventId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setReturnEventId(undefined, true);
|
||||||
|
setHighlighted(true);
|
||||||
|
}, [classifiedEvent?.id, returnEventId, setReturnEventId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!highlighted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cardRef.current) {
|
||||||
|
scrollIntoView(cardRef.current, {
|
||||||
|
block: "center",
|
||||||
|
behavior: "smooth",
|
||||||
|
scrollMode: "if-needed",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => setHighlighted(false), 3000);
|
||||||
|
return () => clearTimeout(timeout);
|
||||||
|
}, [highlighted]);
|
||||||
|
|
||||||
// If the component unmounts while the detail overlay is open, we need to
|
// If the component unmounts while the detail overlay is open, we need to
|
||||||
// pop the history state that was pushed by useHistoryBack, otherwise it
|
// pop the history state that was pushed by useHistoryBack, otherwise it
|
||||||
// leaves a stale entry that breaks back navigation.
|
// leaves a stale entry that breaks back navigation.
|
||||||
@@ -308,9 +345,10 @@ export function GroupedClassificationCard({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ClassificationCard
|
<ClassificationCard
|
||||||
|
ref={cardRef}
|
||||||
data={bestItem}
|
data={bestItem}
|
||||||
threshold={threshold}
|
threshold={threshold}
|
||||||
selected={selectedItems.includes(bestItem.filename)}
|
selected={highlighted || selectedItems.includes(bestItem.filename)}
|
||||||
clickable={true}
|
clickable={true}
|
||||||
i18nLibrary={i18nLibrary}
|
i18nLibrary={i18nLibrary}
|
||||||
count={group.length}
|
count={group.length}
|
||||||
@@ -404,13 +442,19 @@ export function GroupedClassificationCard({
|
|||||||
isMobile && "absolute right-4 top-8",
|
isMobile && "absolute right-4 top-8",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Tooltip>
|
<Tooltip open={isDesktop ? undefined : false}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<div
|
<div
|
||||||
className="cursor-pointer"
|
className="cursor-pointer"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
|
aria-label={t("details.item.button.viewInExplore", {
|
||||||
|
ns: "views/explore",
|
||||||
|
})}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
navigate(`/explore?event_id=${classifiedEvent.id}`);
|
setReturnEventId(classifiedEvent.id, true);
|
||||||
|
navigate(`/explore?event_id=${classifiedEvent.id}`, {
|
||||||
|
state: { canGoBack: true },
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<LuSearch className="size-4 text-secondary-foreground" />
|
<LuSearch className="size-4 text-secondary-foreground" />
|
||||||
|
|||||||
@@ -133,8 +133,9 @@ function StatusAlertNav({ className, large }: StatusAlertNavProps) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (reindexState) {
|
if (reindexState) {
|
||||||
if (reindexState.status == "indexing") {
|
|
||||||
clearMessages("embeddings-reindex");
|
clearMessages("embeddings-reindex");
|
||||||
|
|
||||||
|
if (reindexState.status === "indexing") {
|
||||||
addMessage(
|
addMessage(
|
||||||
"embeddings-reindex",
|
"embeddings-reindex",
|
||||||
t("stats.reindexingEmbeddings", {
|
t("stats.reindexingEmbeddings", {
|
||||||
@@ -144,9 +145,8 @@ function StatusAlertNav({ className, large }: StatusAlertNavProps) {
|
|||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
} else if (reindexState.status === "failed") {
|
||||||
if (reindexState.status === "completed") {
|
addMessage("embeddings-reindex", t("stats.reindexEmbeddingsFailed"));
|
||||||
clearMessages("embeddings-reindex");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [reindexState, addMessage, clearMessages, t]);
|
}, [reindexState, addMessage, clearMessages, t]);
|
||||||
|
|||||||
@@ -845,6 +845,7 @@ export function TrackingDetails({
|
|||||||
<div className="text-sm text-secondary-foreground">
|
<div className="text-sm text-secondary-foreground">
|
||||||
<Link
|
<Link
|
||||||
to={`/explore?recognized_license_plate=${event.data.recognized_license_plate}`}
|
to={`/explore?recognized_license_plate=${event.data.recognized_license_plate}`}
|
||||||
|
state={{ canGoBack: true }}
|
||||||
className="text-sm"
|
className="text-sm"
|
||||||
>
|
>
|
||||||
{event.data.recognized_license_plate}
|
{event.data.recognized_license_plate}
|
||||||
|
|||||||
@@ -727,6 +727,7 @@ function EventList({
|
|||||||
<div className="text-sm text-secondary-foreground">
|
<div className="text-sm text-secondary-foreground">
|
||||||
<Link
|
<Link
|
||||||
to={`/explore?recognized_license_plate=${event.data.recognized_license_plate}`}
|
to={`/explore?recognized_license_plate=${event.data.recognized_license_plate}`}
|
||||||
|
state={{ canGoBack: true }}
|
||||||
className="text-sm"
|
className="text-sm"
|
||||||
>
|
>
|
||||||
{event.data.recognized_license_plate}
|
{event.data.recognized_license_plate}
|
||||||
|
|||||||
@@ -135,7 +135,9 @@ export default function EventMenu({
|
|||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="cursor-pointer"
|
className="cursor-pointer"
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
navigate(`/explore?event_id=${event.id}`);
|
navigate(`/explore?event_id=${event.id}`, {
|
||||||
|
state: { canGoBack: true },
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t("details.item.button.viewInExplore")}
|
{t("details.item.button.viewInExplore")}
|
||||||
@@ -177,6 +179,7 @@ export default function EventMenu({
|
|||||||
else
|
else
|
||||||
navigate(
|
navigate(
|
||||||
`/explore?search_type=similarity&event_id=${event.id}`,
|
`/explore?search_type=similarity&event_id=${event.id}`,
|
||||||
|
{ state: { canGoBack: true } },
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { cn } from "@/lib/utils";
|
|||||||
import { FrigateConfig } from "@/types/frigateConfig";
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
import { SearchFilter, SearchResult, SearchSource } from "@/types/search";
|
import { SearchFilter, SearchResult, SearchSource } from "@/types/search";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { isMobileOnly } from "react-device-detect";
|
import { isDesktop, isMobileOnly } from "react-device-detect";
|
||||||
import { LuImage, LuSearchX, LuText } from "react-icons/lu";
|
import { LuImage, LuSearchX, LuText } from "react-icons/lu";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import ExploreView from "../explore/ExploreView";
|
import ExploreView from "../explore/ExploreView";
|
||||||
@@ -32,7 +32,9 @@ import { TooltipPortal } from "@radix-ui/react-tooltip";
|
|||||||
import SearchActionGroup from "@/components/filter/SearchActionGroup";
|
import SearchActionGroup from "@/components/filter/SearchActionGroup";
|
||||||
import { Trans, useTranslation } from "react-i18next";
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
import { use24HourTime } from "@/hooks/use-date-utils";
|
import { use24HourTime } from "@/hooks/use-date-utils";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { IoMdArrowRoundBack } from "react-icons/io";
|
||||||
import { useAllowedCameras } from "@/hooks/use-allowed-cameras";
|
import { useAllowedCameras } from "@/hooks/use-allowed-cameras";
|
||||||
|
|
||||||
type SearchViewProps = {
|
type SearchViewProps = {
|
||||||
@@ -80,6 +82,10 @@ export default function SearchView({
|
|||||||
});
|
});
|
||||||
const is24Hour = use24HourTime(config);
|
const is24Hour = use24HourTime(config);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
// set by in-app links, since an iOS home screen app has no back gesture
|
||||||
|
const canGoBack = location.state?.canGoBack === true;
|
||||||
|
|
||||||
const { data: exploreEvents } = useSWR<SearchResult[]>(
|
const { data: exploreEvents } = useSWR<SearchResult[]>(
|
||||||
(!searchFilter || Object.keys(searchFilter).length === 0) &&
|
(!searchFilter || Object.keys(searchFilter).length === 0) &&
|
||||||
@@ -535,8 +541,28 @@ export default function SearchView({
|
|||||||
isMobileOnly && "mb-2 h-auto flex-wrap gap-2 space-y-0",
|
isMobileOnly && "mb-2 h-auto flex-wrap gap-2 space-y-0",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
{(canGoBack || config?.semantic_search?.enabled) && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"z-[41] flex w-full flex-row items-start gap-2 lg:absolute lg:top-0 lg:w-1/3",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{canGoBack && (
|
||||||
|
<Button
|
||||||
|
className="flex shrink-0 items-center gap-2.5 rounded-lg"
|
||||||
|
aria-label={t("label.back", { ns: "common" })}
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
>
|
||||||
|
<IoMdArrowRoundBack className="size-5 text-secondary-foreground" />
|
||||||
|
{isDesktop && (
|
||||||
|
<div className="text-primary">
|
||||||
|
{t("button.back", { ns: "common" })}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
{config?.semantic_search?.enabled && (
|
{config?.semantic_search?.enabled && (
|
||||||
<div className={cn("z-[41] w-full lg:absolute lg:top-0 lg:w-1/3")}>
|
<div className="min-w-0 flex-1">
|
||||||
<InputWithTags
|
<InputWithTags
|
||||||
inputFocused={inputFocused}
|
inputFocused={inputFocused}
|
||||||
setInputFocused={setInputFocused}
|
setInputFocused={setInputFocused}
|
||||||
@@ -548,6 +574,8 @@ export default function SearchView({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{hasExistingSearch && (
|
{hasExistingSearch && (
|
||||||
<ScrollArea className="w-full whitespace-nowrap lg:ml-[35%]">
|
<ScrollArea className="w-full whitespace-nowrap lg:ml-[35%]">
|
||||||
|
|||||||
@@ -574,6 +574,7 @@ export default function TriggerView({
|
|||||||
</Badge>
|
</Badge>
|
||||||
<Link
|
<Link
|
||||||
to={`/explore?event_id=${trigger_status?.triggers[trigger.name]?.triggering_event_id || ""}`}
|
to={`/explore?event_id=${trigger_status?.triggers[trigger.name]?.triggering_event_id || ""}`}
|
||||||
|
state={{ canGoBack: true }}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-1.5 text-xs text-muted-foreground",
|
"flex items-center gap-1.5 text-xs text-muted-foreground",
|
||||||
!trigger_status?.triggers[trigger.name]
|
!trigger_status?.triggers[trigger.name]
|
||||||
@@ -735,6 +736,7 @@ export default function TriggerView({
|
|||||||
<TableCell>
|
<TableCell>
|
||||||
<Link
|
<Link
|
||||||
to={`/explore?event_id=${trigger_status?.triggers[trigger.name]?.triggering_event_id || ""}`}
|
to={`/explore?event_id=${trigger_status?.triggers[trigger.name]?.triggering_event_id || ""}`}
|
||||||
|
state={{ canGoBack: true }}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-1.5 text-sm",
|
"flex items-center gap-1.5 text-sm",
|
||||||
!trigger_status?.triggers[trigger.name]
|
!trigger_status?.triggers[trigger.name]
|
||||||
|
|||||||
Reference in New Issue
Block a user