mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-09-27 12:48:58 +03:00
Compare commits
7
Commits
de416b7ae7
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3941355051 | ||
|
|
1a278630da | ||
|
|
9d0d8a99bb | ||
|
|
bbc412763d | ||
|
|
ac9ac50df5 | ||
|
|
93aa6c4174 | ||
|
|
26e6adee88 |
@@ -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
|
||||||
|
|||||||
@@ -378,10 +378,10 @@ 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" />.
|
||||||
|
|
||||||
|
|||||||
@@ -45,10 +45,10 @@ Any detection below `min_score` will be immediately thrown out and never tracked
|
|||||||
|
|
||||||
Navigate to <NavPath path="Settings > Global configuration > Objects" /> to set score filters globally.
|
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.
|
||||||
|
|
||||||
@@ -103,12 +103,12 @@ Conceptually, a ratio of 1 is a square, 0.5 is a "tall skinny" box, and 2 is a "
|
|||||||
|
|
||||||
Navigate to <NavPath path="Settings > Global configuration > Objects" /> to set shape filters globally.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -70,14 +70,14 @@ 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
|
||||||
|
|
||||||
|
|||||||
@@ -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,20 +64,20 @@ 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 | |
|
||||||
| **package** | .65 | .9 |
|
| **package** | .65 | .9 |
|
||||||
| **license_plate** | .6 | |
|
| **license_plate** | .6 | |
|
||||||
| **amazon** | .75 | |
|
| **amazon** | .75 | |
|
||||||
| **ups** | .75 | |
|
| **ups** | .75 | |
|
||||||
| **fedex** | .75 | |
|
| **fedex** | .75 | |
|
||||||
| **person** | .65 | .85 |
|
| **person** | .65 | .85 |
|
||||||
| **car** | .65 | .85 |
|
| **car** | .65 | .85 |
|
||||||
|
|
||||||
</TabItem>
|
</TabItem>
|
||||||
<TabItem value="yaml">
|
<TabItem value="yaml">
|
||||||
|
|||||||
+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,
|
{
|
||||||
"descriptions": 0,
|
"thumbnails": 0,
|
||||||
"processed_objects": total_events - 1 if total_events < batch_size else 0,
|
"descriptions": 0,
|
||||||
"total_objects": total_events,
|
"processed_objects": total_events - 1
|
||||||
"time_remaining": 0 if total_events < batch_size else -1,
|
if total_events < batch_size
|
||||||
"status": "indexing",
|
else 0,
|
||||||
}
|
"total_objects": total_events,
|
||||||
|
"time_remaining": 0 if total_events < batch_size else -1,
|
||||||
|
"status": "indexing",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
self.requestor.send_data(UPDATE_EMBEDDINGS_REINDEX_PROGRESS, totals)
|
self.requestor.send_data(UPDATE_EMBEDDINGS_REINDEX_PROGRESS, totals)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
Reference in New Issue
Block a user