mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-01 20:36:51 +03:00
Compare commits
3
Commits
2c2b79302d
...
6af5ff4cb9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6af5ff4cb9 | ||
|
|
c406a93d3d | ||
|
|
edca412856 |
@@ -12,6 +12,7 @@ config/*
|
||||
models
|
||||
*.mp4
|
||||
*.db
|
||||
*.db-*
|
||||
*.csv
|
||||
frigate/version.py
|
||||
web/build
|
||||
|
||||
@@ -1149,6 +1149,11 @@ rknn:
|
||||
| **Model Input D Type** | `int` (Frigate's default value) |
|
||||
| **Object Detection Model Type** | `yolo-generic` |
|
||||
yaml: |-
|
||||
detectors:
|
||||
rknn:
|
||||
type: rknn
|
||||
num_cores: 0
|
||||
|
||||
model: # required
|
||||
# name of model (will be automatically downloaded) or path to your own .rknn model file
|
||||
# possible values are:
|
||||
@@ -1187,6 +1192,11 @@ rknn:
|
||||
| **Model Input D Type** | `int` (Frigate's default value) |
|
||||
| **Object Detection Model Type** | `yolonas` |
|
||||
yaml: |-
|
||||
detectors:
|
||||
rknn:
|
||||
type: rknn
|
||||
num_cores: 0
|
||||
|
||||
model: # required
|
||||
# name of model (will be automatically downloaded) or path to your own .rknn model file
|
||||
# possible values are:
|
||||
@@ -1222,6 +1232,11 @@ rknn:
|
||||
| **Model Input D Type** | `int` (Frigate's default value) |
|
||||
| **Object Detection Model Type** | `yolox` |
|
||||
yaml: |-
|
||||
detectors:
|
||||
rknn:
|
||||
type: rknn
|
||||
num_cores: 0
|
||||
|
||||
model: # required
|
||||
# name of model (will be automatically downloaded) or path to your own .rknn model file
|
||||
# possible values are:
|
||||
@@ -1297,11 +1312,12 @@ degirumAiServer:
|
||||
| **Zoo** | `degirum/public` |
|
||||
| **Token** | your AI Hub token (optional for the public zoo) |
|
||||
yaml: |
|
||||
degirum_detector:
|
||||
type: degirum
|
||||
location: degirum
|
||||
zoo: degirum/public
|
||||
token: dg_example_token
|
||||
detectors:
|
||||
degirum_detector:
|
||||
type: degirum
|
||||
location: degirum
|
||||
zoo: degirum/public
|
||||
token: dg_example_token
|
||||
degirumLocal:
|
||||
title: DeGirum Local
|
||||
models:
|
||||
@@ -1318,11 +1334,12 @@ degirumLocal:
|
||||
| **Zoo** | `degirum/public` |
|
||||
| **Token** | your AI Hub token (optional for the public zoo) |
|
||||
yaml: |
|
||||
degirum_detector:
|
||||
type: degirum
|
||||
location: @local
|
||||
zoo: degirum/public
|
||||
token: dg_example_token
|
||||
detectors:
|
||||
degirum_detector:
|
||||
type: degirum
|
||||
location: "@local"
|
||||
zoo: degirum/public
|
||||
token: dg_example_token
|
||||
degirumCloud:
|
||||
title: DeGirum AI Hub Cloud
|
||||
models:
|
||||
@@ -1339,8 +1356,9 @@ degirumCloud:
|
||||
| **Zoo** | `degirum/public` |
|
||||
| **Token** | your AI Hub token (optional for the public zoo) |
|
||||
yaml: |
|
||||
degirum_detector:
|
||||
type: degirum
|
||||
location: @cloud
|
||||
zoo: degirum/public
|
||||
token: dg_example_token
|
||||
detectors:
|
||||
degirum_detector:
|
||||
type: degirum
|
||||
location: "@cloud"
|
||||
zoo: degirum/public
|
||||
token: dg_example_token
|
||||
|
||||
@@ -4,6 +4,7 @@ title: License Plate Recognition (LPR)
|
||||
---
|
||||
|
||||
import ConfigTabs from "@site/src/components/ConfigTabs";
|
||||
import FrigateConfigMock from "@site/src/components/FrigateConfigMock";
|
||||
import TabItem from "@theme/TabItem";
|
||||
import NavPath from "@site/src/components/NavPath";
|
||||
import FaqItem from "@site/src/components/FaqItem";
|
||||
@@ -50,9 +51,11 @@ License plate recognition is disabled by default and must be enabled before it c
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > Enrichments > License plate recognition" />.
|
||||
|
||||
- Set **Enable LPR** to on
|
||||
<FrigateConfigMock
|
||||
section="lpr"
|
||||
values={{ enabled: true }}
|
||||
targets={["enabled"]}
|
||||
/>
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
@@ -70,7 +73,17 @@ Like other enrichments in Frigate, LPR **must be enabled globally** to use the f
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > Camera configuration > License plate recognition" /> for the desired camera and disable the **Enable LPR** toggle.
|
||||
<FrigateConfigMock
|
||||
section="lpr"
|
||||
level="camera"
|
||||
values={{ enabled: false }}
|
||||
targets={[
|
||||
{
|
||||
field: "enabled",
|
||||
hint: "Disable the Enable LPR toggle for this camera.",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
@@ -99,16 +112,40 @@ Fine-tune the LPR feature using these optional parameters. The only optional par
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > Enrichments > License plate recognition" />.
|
||||
|
||||
- **Detection threshold**: License plate object detection confidence score required before recognition runs. This field only applies to the standalone license plate detection model; `threshold` and `min_score` object filters should be used for models like Frigate+ that have license plate detection built in.
|
||||
- Default: `0.7`
|
||||
- **Minimum plate area**: Minimum area (in pixels) a license plate must be before recognition runs. This is an _area_ measurement (length x width). For reference, 1000 pixels represents a ~32x32 pixel square in your camera image. Depending on the resolution of your camera's `detect` stream, you can increase this value to ignore small or distant plates.
|
||||
- Default: `1000` pixels
|
||||
- **Device**: Device to use to run license plate detection _and_ recognition models. Auto-selected by Frigate and can be `CPU`, `GPU`, or the GPU's device number. For users without a model that detects license plates natively, using a GPU may increase performance of the YOLOv9 license plate detector model. See the [Hardware Accelerated Enrichments](/configuration/hardware_acceleration_enrichments.md) documentation.
|
||||
- Default: `None`
|
||||
- **Model size**: The size of the model used to identify regions of text on plates. The `small` model is fast and identifies groups of Latin and Chinese characters. The `large` model identifies Latin characters only, and uses an enhanced text detector to find characters on multi-line plates. If your country or region does not use multi-line plates, you should use the `small` model.
|
||||
- Default: `small`
|
||||
<FrigateConfigMock
|
||||
showNavigationSteps={false}
|
||||
section="lpr"
|
||||
autoPlay={false}
|
||||
values={{
|
||||
enabled: true,
|
||||
detection_threshold: 0.7,
|
||||
min_area: 1000,
|
||||
device: "CPU",
|
||||
model_size: "small",
|
||||
}}
|
||||
targets={[
|
||||
{
|
||||
field: "detection_threshold",
|
||||
hint:
|
||||
"License plate object detection confidence score required before recognition runs. This field only applies to the standalone license plate detection model; threshold and min_score object filters should be used for models like Frigate+ that have license plate detection built in.",
|
||||
},
|
||||
{
|
||||
field: "min_area",
|
||||
hint:
|
||||
"Minimum area (in pixels) a license plate must be before recognition runs. This is an area measurement (length x width). For reference, 1000 pixels represents a ~32x32 pixel square in your camera image. Depending on the resolution of your camera's detect stream, you can increase this value to ignore small or distant plates.",
|
||||
},
|
||||
{
|
||||
field: "device",
|
||||
hint:
|
||||
"Device to use to run license plate detection and recognition models. Auto-selected by Frigate and can be CPU, GPU, or the GPU's device number. For users without a model that detects license plates natively, using a GPU may increase performance of the YOLOv9 license plate detector model. See the Hardware Accelerated Enrichments documentation.",
|
||||
},
|
||||
{
|
||||
field: "model_size",
|
||||
hint:
|
||||
"The size of the model used to identify regions of text on plates. The small model is fast and identifies groups of Latin and Chinese characters. The large model identifies Latin characters only, and uses an enhanced text detector to find characters on multi-line plates. If your country or region does not use multi-line plates, you should use the small model.",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
@@ -130,12 +167,34 @@ lpr:
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > Enrichments > License plate recognition" />.
|
||||
|
||||
- **Recognition threshold**: Recognition confidence score required to add the plate to the object as a `recognized_license_plate` and/or `sub_label`.
|
||||
- Default: `0.9`
|
||||
- **Min plate length**: Minimum number of characters a detected license plate must have to be added as a `recognized_license_plate` and/or `sub_label`. Use this to filter out short, incomplete, or incorrect detections.
|
||||
- **Plate format regex**: A regular expression defining the expected format of detected plates. Plates that do not match this format will be discarded. Websites like https://regex101.com/ can help test regular expressions for your plates.
|
||||
<FrigateConfigMock
|
||||
showNavigationSteps={false}
|
||||
section="lpr"
|
||||
autoPlay={false}
|
||||
values={{
|
||||
enabled: true,
|
||||
recognition_threshold: 0.9,
|
||||
min_plate_length: 4,
|
||||
format: "^[A-Z]{2}[0-9]{2} [A-Z]{3}$",
|
||||
}}
|
||||
targets={[
|
||||
{
|
||||
field: "recognition_threshold",
|
||||
hint:
|
||||
"Recognition confidence score required to add the plate to the object as a recognized_license_plate and/or sub_label.",
|
||||
},
|
||||
{
|
||||
field: "min_plate_length",
|
||||
hint:
|
||||
"Minimum number of characters a detected license plate must have to be added as a recognized_license_plate and/or sub_label. Use this to filter out short, incomplete, or incorrect detections.",
|
||||
},
|
||||
{
|
||||
field: "format",
|
||||
hint:
|
||||
"A regular expression defining the expected format of detected plates. Plates that do not match this format will be discarded. Websites like https://regex101.com/ can help test regular expressions for your plates.",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
@@ -156,10 +215,31 @@ lpr:
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > Enrichments > License plate recognition" />.
|
||||
|
||||
- **Known plates**: Assign custom `sub_label` values to `car` and `motorcycle` objects when a recognized plate matches a known value. These labels appear in the UI, filters, and notifications. Unknown plates are still saved but are added to the `recognized_license_plate` field rather than the `sub_label`.
|
||||
- **Match distance**: Allows for minor variations (missing/incorrect characters) when matching a detected plate to a known plate. For example, setting to `1` allows a plate `ABCDE` to match `ABCBE` or `ABCD`. This parameter will _not_ operate on known plates that are defined as regular expressions.
|
||||
<FrigateConfigMock
|
||||
showNavigationSteps={false}
|
||||
section="lpr"
|
||||
autoPlay={false}
|
||||
values={{
|
||||
enabled: true,
|
||||
match_distance: 1,
|
||||
known_plates: {
|
||||
"Wife's Car": ["ABC-1234"],
|
||||
Johnny: ["J*N-*234"],
|
||||
},
|
||||
}}
|
||||
targets={[
|
||||
{
|
||||
field: "match_distance",
|
||||
hint:
|
||||
"Allows for minor variations (missing/incorrect characters) when matching a detected plate to a known plate. For example, setting to 1 allows a plate ABCDE to match ABCBE or ABCD. This parameter will not operate on known plates that are defined as regular expressions.",
|
||||
},
|
||||
{
|
||||
field: "known_plates",
|
||||
hint:
|
||||
"Assign custom sub_label values to car and motorcycle objects when a recognized plate matches a known value. These labels appear in the UI, filters, and notifications. Unknown plates are still saved but are added to the recognized_license_plate field rather than the sub_label.",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
@@ -183,10 +263,19 @@ lpr:
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > Enrichments > License plate recognition" />.
|
||||
|
||||
- **Enhancement level**: A value between 0 and 10 that adjusts the level of image enhancement applied to captured license plates before they are processed for recognition. Higher values increase contrast, sharpen details, and reduce noise, but excessive enhancement can blur or distort characters. This setting is best adjusted at the camera level if running LPR on multiple cameras.
|
||||
- Default: `0` (no enhancement)
|
||||
<FrigateConfigMock
|
||||
showNavigationSteps={false}
|
||||
section="lpr"
|
||||
autoPlay={false}
|
||||
values={{ enabled: true, enhancement: 1 }}
|
||||
targets={[
|
||||
{
|
||||
field: "enhancement",
|
||||
hint:
|
||||
"A value between 0 and 10 that adjusts the level of image enhancement applied to captured license plates before they are processed for recognition. Higher values increase contrast, sharpen details, and reduce noise, but excessive enhancement can blur or distort characters. This setting is best adjusted at the camera level if running LPR on multiple cameras.",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
@@ -207,17 +296,27 @@ If Frigate is already recognizing plates correctly, leave enhancement at the def
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > Enrichments > License plate recognition" />.
|
||||
|
||||
Under **Replacement rules**, add regex rules to normalize detected plate strings before matching. Rules fire in order. For example:
|
||||
|
||||
| Pattern | Replacement | Description |
|
||||
| ---------------- | ----------- | -------------------------------------------------- |
|
||||
| `[%#*?]` | _(empty)_ | Remove noise symbols |
|
||||
| `[= ]` | `-` | Normalize `=` or space to dash |
|
||||
| `O` | `0` | Swap `O` to `0` (common OCR error) |
|
||||
| `I` | `1` | Swap `I` to `1` |
|
||||
| `(\w{3})(\w{3})` | `\1-\2` | Split 6 chars into groups (e.g., ABC123 → ABC-123) |
|
||||
<FrigateConfigMock
|
||||
showNavigationSteps={false}
|
||||
section="lpr"
|
||||
autoPlay={false}
|
||||
values={{
|
||||
replace_rules: [
|
||||
{ pattern: "[%#*?]", replacement: "" },
|
||||
{ pattern: "[= ]", replacement: "-" },
|
||||
{ pattern: "O", replacement: "0" },
|
||||
{ pattern: "I", replacement: "1" },
|
||||
{ pattern: "(\\w{3})(\\w{3})", replacement: "\\1-\\2" },
|
||||
],
|
||||
}}
|
||||
targets={[
|
||||
{
|
||||
field: "replace_rules",
|
||||
hint:
|
||||
"Add regex rules to normalize detected plate strings before matching. Rules fire in order.",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
@@ -252,9 +351,19 @@ These rules must be defined at the global level of your `lpr` config.
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > Enrichments > License plate recognition" />.
|
||||
|
||||
- **Save debug plates**: Set to on to save captured text on plates for debugging. These images are stored in `/media/frigate/clips/lpr`, organized into subdirectories by `<camera>/<event_id>`, and named based on the capture timestamp.
|
||||
<FrigateConfigMock
|
||||
showNavigationSteps={false}
|
||||
section="lpr"
|
||||
autoPlay={false}
|
||||
values={{ enabled: true, debug_save_plates: true }}
|
||||
targets={[
|
||||
{
|
||||
field: "debug_save_plates",
|
||||
hint:
|
||||
"Set to on to save captured text on plates for debugging. These images are stored in /media/frigate/clips/lpr and named based on the capture timestamp.",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
@@ -279,17 +388,35 @@ These configuration parameters are available at the global level. The only optio
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > Enrichments > License plate recognition" />.
|
||||
|
||||
| Field | Description |
|
||||
| ------------------------------ | ----------------------------------------------------------------------------------------------------- |
|
||||
| **Enable LPR** | Set to on |
|
||||
| **Minimum plate area** | Set to `1500` to ignore plates with an area (length x width) smaller than 1500 pixels |
|
||||
| **Min plate length** | Set to `4` to only recognize plates with 4 or more characters |
|
||||
| **Known plates > Wife's Car** | `ABC-1234`, `ABC-I234` (accounts for potential confusion between the number one and capital letter I) |
|
||||
| **Known plates > Johnny** | `J*N-*234` (matches JHN-1234 and JMN-I234; `*` matches any number of characters) |
|
||||
| **Known plates > Sally** | `[S5]LL 1234` (matches both SLL 1234 and 5LL 1234) |
|
||||
| **Known plates > Work Trucks** | `EMP-[0-9]{3}[A-Z]` (matches plates like EMP-123A, EMP-456Z) |
|
||||
<FrigateConfigMock
|
||||
showNavigationSteps={false}
|
||||
section="lpr"
|
||||
autoPlay={false}
|
||||
values={{
|
||||
enabled: true,
|
||||
min_area: 1500,
|
||||
min_plate_length: 4,
|
||||
known_plates: {
|
||||
"Wife's Car": ["ABC-1234", "ABC-I234"],
|
||||
Johnny: ["J*N-*234"],
|
||||
Sally: ["[S5]LL 1234"],
|
||||
"Work Trucks": ["EMP-[0-9]{3}[A-Z]"],
|
||||
},
|
||||
}}
|
||||
targets={[
|
||||
{ field: "enabled", hint: "Set to on." },
|
||||
{
|
||||
field: "min_area",
|
||||
hint:
|
||||
"Set to 1500 to ignore plates with an area (length x width) smaller than 1500 pixels.",
|
||||
},
|
||||
{
|
||||
field: "min_plate_length",
|
||||
hint: "Set to 4 to only recognize plates with 4 or more characters.",
|
||||
},
|
||||
"known_plates",
|
||||
]}
|
||||
/>
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
@@ -321,7 +448,18 @@ If a camera is configured to detect `car` or `motorcycle` but you don't want Fri
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > Camera configuration > License plate recognition" /> for the desired camera and disable the **Enable LPR** toggle.
|
||||
<FrigateConfigMock
|
||||
autoPlay={false}
|
||||
section="lpr"
|
||||
level="camera"
|
||||
values={{ enabled: false }}
|
||||
targets={[
|
||||
{
|
||||
field: "enabled",
|
||||
hint: "Disable the Enable LPR toggle for this camera.",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
@@ -362,48 +500,93 @@ An example configuration for a dedicated LPR camera using a `license_plate`-dete
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > Enrichments > License plate recognition" /> and set **Enable LPR** to on. Set **Device** to `CPU` (can also be `GPU` if available).
|
||||
|
||||
Navigate to <NavPath path="Settings > Camera configuration > Streams (FFmpeg)" /> and add your camera streams.
|
||||
|
||||
Navigate to <NavPath path="Settings > Camera configuration > Object detection" />.
|
||||
|
||||
| Field | Description |
|
||||
| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| **Enable object detection** | Set to on |
|
||||
| **Detect FPS** | Set to `5`. Increase to `10` if vehicles move quickly across your frame. Higher than 10 is unnecessary and is not recommended. |
|
||||
| **Minimum initialization frames** | Set to `2` |
|
||||
| **Detect width** | Set to `1920` |
|
||||
| **Detect height** | Set to `1080` |
|
||||
|
||||
Navigate to <NavPath path="Settings > Camera configuration > Objects" />.
|
||||
|
||||
| Field | Description |
|
||||
| ---------------------------------------------- | ------------------- |
|
||||
| **Objects to track** | Add `license_plate` |
|
||||
| **Object filters > License Plate > Threshold** | Set to `0.7` |
|
||||
|
||||
Navigate to <NavPath path="Settings > Camera configuration > Motion detection" />.
|
||||
|
||||
| Field | Description |
|
||||
| -------------------- | --------------------------------------------------------------------- |
|
||||
| **Motion threshold** | Set to `30` |
|
||||
| **Contour area** | Set to `60`. Use an increased value to tune out small motion changes. |
|
||||
| **Improve contrast** | Set to off |
|
||||
|
||||
Also add a motion mask over your camera's timestamp so it is not incorrectly detected as a license plate.
|
||||
|
||||
Navigate to <NavPath path="Settings > Camera configuration > Recording" />.
|
||||
|
||||
| Field | Description |
|
||||
| -------------------- | -------------------------------------------------------- |
|
||||
| **Enable recording** | Set to on. Disable recording if you only want snapshots. |
|
||||
|
||||
Navigate to <NavPath path="Settings > Camera configuration > Snapshots" />.
|
||||
|
||||
| Field | Description |
|
||||
| -------------------- | ----------- |
|
||||
| **Enable snapshots** | Set to on |
|
||||
<FrigateConfigMock
|
||||
section="lpr"
|
||||
autoPlay={false}
|
||||
steps={[
|
||||
{
|
||||
section: "lpr",
|
||||
level: "global",
|
||||
values: { enabled: true, device: "CPU" },
|
||||
targets: ["enabled", "device"],
|
||||
},
|
||||
{
|
||||
section: "detect",
|
||||
level: "camera",
|
||||
values: {
|
||||
enabled: true,
|
||||
fps: 5,
|
||||
min_initialized: 2,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
},
|
||||
targets: [
|
||||
{ field: "enabled", hint: "Set to on." },
|
||||
{
|
||||
field: "fps",
|
||||
hint:
|
||||
"Set to 5. Increase to 10 if vehicles move quickly across your frame. Higher than 10 is unnecessary and is not recommended.",
|
||||
},
|
||||
{ field: "min_initialized", hint: "Set to 2." },
|
||||
{ field: "width", hint: "Set to 1920." },
|
||||
{ field: "height", hint: "Set to 1080." },
|
||||
],
|
||||
},
|
||||
{
|
||||
section: "objects",
|
||||
level: "camera",
|
||||
values: {
|
||||
track: ["license_plate"],
|
||||
filters: { license_plate: { threshold: 0.7 } },
|
||||
},
|
||||
targets: [
|
||||
{ field: "track", hint: "Add license_plate." },
|
||||
"filters",
|
||||
],
|
||||
},
|
||||
{
|
||||
section: "motion",
|
||||
level: "camera",
|
||||
values: {
|
||||
threshold: 30,
|
||||
contour_area: 60,
|
||||
improve_contrast: false,
|
||||
},
|
||||
targets: [
|
||||
{ field: "threshold", hint: "Set to 30." },
|
||||
{
|
||||
field: "contour_area",
|
||||
hint:
|
||||
"Set to 60. Use an increased value to tune out small motion changes.",
|
||||
},
|
||||
{ field: "improve_contrast", hint: "Set to off." },
|
||||
],
|
||||
},
|
||||
{
|
||||
section: "record",
|
||||
level: "camera",
|
||||
values: { enabled: true },
|
||||
targets: [
|
||||
{
|
||||
field: "enabled",
|
||||
hint: "Set to on. Disable recording if you only want snapshots.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
section: "snapshots",
|
||||
level: "camera",
|
||||
values: { enabled: true },
|
||||
targets: [{ field: "enabled", hint: "Set to on." }],
|
||||
},
|
||||
{
|
||||
section: "review",
|
||||
level: "camera",
|
||||
values: { "detections.labels": ["license_plate"] },
|
||||
targets: ["detections.labels"],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
@@ -467,54 +650,104 @@ An example configuration for a dedicated LPR camera using the secondary pipeline
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > Enrichments > License plate recognition" /> and set **Enable LPR** to on. Set **Device** to `CPU` (can also be `GPU` if available and the correct Docker image is used). Set **Detection threshold** to `0.7` (change if necessary).
|
||||
|
||||
Navigate to <NavPath path="Settings > Camera configuration > License plate recognition" /> for your dedicated LPR camera.
|
||||
|
||||
| Field | Description |
|
||||
| --------------------- | -------------------------------------------------------------------------------- |
|
||||
| **Enable LPR** | Set to on |
|
||||
| **Enhancement level** | Set to `3` (optional, enhances the image before trying to recognize characters) |
|
||||
|
||||
Navigate to <NavPath path="Settings > Camera configuration > Streams (FFmpeg)" /> and add your camera streams.
|
||||
|
||||
Navigate to <NavPath path="Settings > Camera configuration > Object detection" />.
|
||||
|
||||
| Field | Description |
|
||||
| --------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Enable object detection** | Set to off to disable Frigate's standard object detection pipeline |
|
||||
| **Detect FPS** | Set to `5`. Increase if necessary, though high values may slow down Frigate's enrichments pipeline and use considerable CPU. |
|
||||
| **Detect width** | Set to `1920` (recommended value, but depends on your camera) |
|
||||
| **Detect height** | Set to `1080` (recommended value, but depends on your camera) |
|
||||
|
||||
Navigate to <NavPath path="Settings > Camera configuration > Objects" />.
|
||||
|
||||
| Field | Description |
|
||||
| -------------------- | -------------------------------------------------------------------------------------- |
|
||||
| **Objects to track** | Set to an empty list, required when not using a Frigate+ model for dedicated LPR mode |
|
||||
|
||||
Navigate to <NavPath path="Settings > Camera configuration > Motion detection" />.
|
||||
|
||||
| Field | Description |
|
||||
| -------------------- | --------------------------------------------------------------------- |
|
||||
| **Motion threshold** | Set to `30` |
|
||||
| **Contour area** | Set to `60`. Use an increased value to tune out small motion changes. |
|
||||
| **Improve contrast** | Set to off |
|
||||
|
||||
Navigate to <NavPath path="Settings > Camera configuration > Masks / Zones" /> and add a motion mask over your camera's timestamp so it is not incorrectly detected as a license plate.
|
||||
|
||||
Navigate to <NavPath path="Settings > Camera configuration > Recording" />.
|
||||
|
||||
| Field | Description |
|
||||
| -------------------- | -------------------------------------------------------- |
|
||||
| **Enable recording** | Set to on. Disable recording if you only want snapshots. |
|
||||
|
||||
Navigate to <NavPath path="Settings > Camera configuration > Review" />.
|
||||
|
||||
| Field | Description |
|
||||
| ----------------------------------------- | --------------- |
|
||||
| **Detections config > Enable detections** | Set to on |
|
||||
| **Detections config > Retain > Default** | Set to `7` days |
|
||||
<FrigateConfigMock
|
||||
steps={[
|
||||
{
|
||||
section: "lpr",
|
||||
level: "global",
|
||||
values: {
|
||||
enabled: true,
|
||||
device: "CPU",
|
||||
detection_threshold: 0.7,
|
||||
},
|
||||
targets: ["enabled", "device", "detection_threshold"],
|
||||
},
|
||||
{
|
||||
section: "lpr",
|
||||
level: "camera",
|
||||
values: { enabled: true, enhancement: 3 },
|
||||
targets: [
|
||||
{ field: "enabled", hint: "Set to on." },
|
||||
{
|
||||
field: "enhancement",
|
||||
hint:
|
||||
"Set to 3. This optional setting enhances the image before trying to recognize characters.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
section: "detect",
|
||||
level: "camera",
|
||||
values: { enabled: false, fps: 5, width: 1920, height: 1080 },
|
||||
targets: [
|
||||
{
|
||||
field: "enabled",
|
||||
hint: "Set to off to disable Frigate's standard object detection pipeline.",
|
||||
},
|
||||
{
|
||||
field: "fps",
|
||||
hint:
|
||||
"Set to 5. Increase if necessary, though high values may slow down Frigate's enrichments pipeline and use considerable CPU.",
|
||||
},
|
||||
{
|
||||
field: "width",
|
||||
hint: "Set to 1920. The appropriate value depends on your camera.",
|
||||
},
|
||||
{
|
||||
field: "height",
|
||||
hint: "Set to 1080. The appropriate value depends on your camera.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
section: "objects",
|
||||
level: "camera",
|
||||
values: { track: [] },
|
||||
targets: [
|
||||
{
|
||||
field: "track",
|
||||
hint:
|
||||
"Set to an empty list. This is required when not using a Frigate+ model for dedicated LPR mode.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
section: "motion",
|
||||
level: "camera",
|
||||
values: {
|
||||
threshold: 30,
|
||||
contour_area: 60,
|
||||
improve_contrast: false,
|
||||
},
|
||||
targets: [
|
||||
{ field: "threshold", hint: "Set to 30." },
|
||||
{
|
||||
field: "contour_area",
|
||||
hint:
|
||||
"Set to 60. Use an increased value to tune out small motion changes.",
|
||||
},
|
||||
{ field: "improve_contrast", hint: "Set to off." },
|
||||
],
|
||||
},
|
||||
{
|
||||
section: "record",
|
||||
level: "camera",
|
||||
values: { enabled: true },
|
||||
targets: [
|
||||
{
|
||||
field: "enabled",
|
||||
hint: "Set to on. Disable recording if you only want snapshots.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
section: "review",
|
||||
level: "camera",
|
||||
values: { "detections.enabled": true },
|
||||
targets: ["detections.enabled"],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
@@ -655,11 +888,13 @@ Start with ["Why isn't my license plate being detected and recognized?"](#why-is
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > Enrichments > License plate recognition" />.
|
||||
|
||||
- Set **Enable LPR** to on
|
||||
- Set **Device** to `CPU`
|
||||
- Set **Save debug plates** to on
|
||||
<FrigateConfigMock
|
||||
autoPlay={false}
|
||||
section="lpr"
|
||||
showNavigationSteps={false}
|
||||
values={{ enabled: true, device: "CPU", debug_save_plates: true }}
|
||||
targets={["enabled", "device", "debug_save_plates"]}
|
||||
/>
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
|
||||
@@ -5,7 +5,7 @@ title: Masks
|
||||
|
||||
import ConfigTabs from "@site/src/components/ConfigTabs";
|
||||
import TabItem from "@theme/TabItem";
|
||||
import NavPath from "@site/src/components/NavPath";
|
||||
import FrigateConfigMock from "@site/src/components/FrigateConfigMock";
|
||||
|
||||
Frigate has two kinds of masks: motion masks and object filter masks. Both are narrow tools for fine-tuning, **not for hiding an area from Frigate**. Masks should be used sparingly; in most cases where users reach for one, a [zone](zones.md) with [`required_zones`](zones.md#restricting-alerts-and-detections-to-specific-zones) is the right tool instead. See [Which tool do I need?](#which-tool-do-i-need) and [Common mistakes](#common-mistakes) below if you're new to Frigate's mask behavior.
|
||||
|
||||
@@ -25,19 +25,64 @@ Object filter masks can be used to filter out stubborn false positives in fixed
|
||||
|
||||
## Which tool do I need?
|
||||
|
||||
| What you're trying to do | Recommended tool | How it works |
|
||||
| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Only get alerts/detections for activity in the areas you care about, ignoring activity elsewhere (e.g., alert when someone enters your yard, but not when they walk past on the sidewalk) | A [zone](zones.md) combined with [`required_zones`](zones.md#restricting-alerts-and-detections-to-specific-zones) | Frigate keeps detecting and tracking activity everywhere in the frame, but a review item is only created once the bottom-center of an object's bounding box enters a required zone. |
|
||||
| Stop a stubborn false positive at a specific fixed spot (e.g., a tree base that keeps being detected as a person) | An **object filter mask** for that object type | Any detection of that object type whose bounding-box bottom-center lands inside the mask is treated as a false positive and discarded. |
|
||||
| Ignore motion in an area that obviously isn't an object of interest (e.g., the camera timestamp, sky, flags, treetops swaying) | A **motion mask** | Motion inside the mask is ignored when deciding whether to run object detection. Objects can still be detected in a motion masked area if motion elsewhere in the frame triggers detection. |
|
||||
| Stop tracking an object type altogether on this camera (e.g., you never care about cats) | Remove the object from the camera's [`objects.track`](objects.md) list | Frigate skips this object type entirely on this camera, regardless of where it appears. |
|
||||
| What you're trying to do | Recommended tool | How it works |
|
||||
| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Only get alerts/detections for activity in the areas you care about, ignoring activity elsewhere (e.g., alert when someone enters your yard, but not when they walk past on the sidewalk) | A [zone](zones.md) combined with [`required_zones`](zones.md#restricting-alerts-and-detections-to-specific-zones) | Frigate keeps detecting and tracking activity everywhere in the frame, but a review item is only created once the bottom-center of an object's bounding box enters a required zone. |
|
||||
| Stop a stubborn false positive at a specific fixed spot (e.g., a tree base that keeps being detected as a person) | An **object filter mask** for that object type | Any detection of that object type whose bounding-box bottom-center lands inside the mask is treated as a false positive and discarded. |
|
||||
| Ignore motion in an area that obviously isn't an object of interest (e.g., the camera timestamp, sky, flags, treetops swaying) | A **motion mask** | Motion inside the mask is ignored when deciding whether to run object detection. Objects can still be detected in a motion masked area if motion elsewhere in the frame triggers detection. |
|
||||
| Stop tracking an object type altogether on this camera (e.g., you never care about cats) | Remove the object from the camera's [`objects.track`](objects.md) list | Frigate skips this object type entirely on this camera, regardless of where it appears. |
|
||||
|
||||
## Using the mask creator
|
||||
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > Camera configuration > Masks / Zones" /> and select a camera. Use the mask editor to draw motion masks and object filter masks directly on the camera feed. Each mask can be given a friendly name and toggled on or off.
|
||||
<FrigateConfigMock
|
||||
level="camera"
|
||||
section="masksAndZones"
|
||||
steps={[
|
||||
{
|
||||
focus: "motionMasks",
|
||||
label: "Motion Mask",
|
||||
hint: "Motion masks prevent unwanted motion from triggering detection. Use them sparingly so object tracking is not disrupted.",
|
||||
},
|
||||
{
|
||||
focus: "motionMask.add",
|
||||
label: "New Motion Mask",
|
||||
hint: "Select the plus button beside Motion Mask to create a mask.",
|
||||
},
|
||||
{
|
||||
focus: "motionMask.canvas",
|
||||
label: "Draw the motion mask",
|
||||
hint: "Select points on the camera image, then close the polygon by selecting the first point again.",
|
||||
},
|
||||
{
|
||||
focus: "motionMask.options",
|
||||
label: "Motion mask options",
|
||||
hint: "Give the mask a friendly name and choose whether it is enabled.",
|
||||
},
|
||||
{
|
||||
focus: "objectMasks",
|
||||
label: "Object Masks",
|
||||
hint: "Object masks filter false positives according to the bottom center of an object's bounding box.",
|
||||
},
|
||||
{
|
||||
focus: "objectMask.add",
|
||||
label: "New Object Mask",
|
||||
hint: "Select the plus button beside Object Masks to create an object filter mask.",
|
||||
},
|
||||
{
|
||||
focus: "objectMask.canvas",
|
||||
label: "Draw the object mask",
|
||||
hint: "Draw a precise polygon over the fixed location that produces false positives.",
|
||||
},
|
||||
{
|
||||
focus: "objectMask.options",
|
||||
label: "Object mask options",
|
||||
hint: "Name the mask, select the object type it applies to, and save it.",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
|
||||
@@ -4,6 +4,7 @@ title: Motion Detection
|
||||
---
|
||||
|
||||
import ConfigTabs from "@site/src/components/ConfigTabs";
|
||||
import FrigateConfigMock from "@site/src/components/FrigateConfigMock";
|
||||
import TabItem from "@theme/TabItem";
|
||||
import NavPath from "@site/src/components/NavPath";
|
||||
|
||||
@@ -44,13 +45,13 @@ The threshold value dictates how much of a change in a pixels luminance is requi
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > Global configuration > Motion detection" /> to set the threshold globally.
|
||||
|
||||
To override for a specific camera, navigate to <NavPath path="Settings > Camera configuration > Motion detection" /> and select the camera, or use the <NavPath path="Settings > Camera configuration > Motion tuner" /> to adjust it live.
|
||||
|
||||
| Field | Description |
|
||||
| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Motion threshold** | The threshold passed to cv2.threshold to determine if a pixel is different enough to be counted as motion. Increasing this value will make motion detection less sensitive and decreasing it will make motion detection more sensitive. The value should be between 1 and 255. (default: 30) |
|
||||
<FrigateConfigMock
|
||||
section="motion"
|
||||
fields={["threshold"]}
|
||||
values={{ threshold: 30 }}
|
||||
focus="threshold"
|
||||
hint="The threshold passed to cv2.threshold to determine if a pixel is different enough to be counted as motion. Increasing this value will make motion detection less sensitive and decreasing it will make motion detection more sensitive. The value should be between 1 and 255. (default: 30)"
|
||||
/>
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
|
||||
@@ -5,6 +5,7 @@ title: Object Detectors
|
||||
|
||||
import CommunityBadge from '@site/src/components/CommunityBadge';
|
||||
import ConfigTabs from '@site/src/components/ConfigTabs';
|
||||
import FrigateConfigMock from '@site/src/components/FrigateConfigMock';
|
||||
import TabItem from '@theme/TabItem';
|
||||
import NavPath from '@site/src/components/NavPath';
|
||||
import ModelConfigDropdown from '@site/src/components/ModelConfigDropdown';
|
||||
@@ -112,7 +113,17 @@ See [common Edge TPU troubleshooting steps](/troubleshooting/edgetpu) if the Edg
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **EdgeTPU** from the detector type dropdown and click **Add**, then set device to `usb`.
|
||||
<FrigateConfigMock
|
||||
autoPlay={false}
|
||||
section="model"
|
||||
values={{ detectors: { coral: { type: "edgetpu", device: "usb" } } }}
|
||||
targets={[
|
||||
{
|
||||
field: "detectors",
|
||||
hint: "Add an EdgeTPU detector named coral and set its device to usb.",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
@@ -132,7 +143,24 @@ detectors:
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **EdgeTPU** from the detector type dropdown and click **Add** to add multiple detectors, specifying `usb:0` and `usb:1` as the device for each.
|
||||
<FrigateConfigMock
|
||||
autoPlay={false}
|
||||
showNavigationSteps={false}
|
||||
section="model"
|
||||
values={{
|
||||
detectors: {
|
||||
coral1: { type: "edgetpu", device: "usb:0" },
|
||||
coral2: { type: "edgetpu", device: "usb:1" },
|
||||
},
|
||||
}}
|
||||
targets={[
|
||||
{
|
||||
field: "detectors",
|
||||
hint:
|
||||
"Add two EdgeTPU detectors and assign usb:0 and usb:1 as their devices.",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
@@ -157,7 +185,19 @@ _warning: may have [compatibility issues](https://github.com/blakeblackshear/fri
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **EdgeTPU** from the detector type dropdown and click **Add**, then leave the device field empty.
|
||||
<FrigateConfigMock
|
||||
autoPlay={false}
|
||||
showNavigationSteps={false}
|
||||
section="model"
|
||||
values={{ detectors: { coral: { type: "edgetpu", device: "" } } }}
|
||||
targets={[
|
||||
{
|
||||
field: "detectors",
|
||||
hint:
|
||||
"Add an EdgeTPU detector and leave Device empty so Frigate auto-detects the native Coral.",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
@@ -177,7 +217,18 @@ detectors:
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **EdgeTPU** from the detector type dropdown and click **Add**, then set device to `pci`.
|
||||
<FrigateConfigMock
|
||||
autoPlay={false}
|
||||
showNavigationSteps={false}
|
||||
section="model"
|
||||
values={{ detectors: { coral: { type: "edgetpu", device: "pci" } } }}
|
||||
targets={[
|
||||
{
|
||||
field: "detectors",
|
||||
hint: "Add an EdgeTPU detector named coral and set its device to pci.",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
@@ -197,7 +248,24 @@ detectors:
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **EdgeTPU** from the detector type dropdown and click **Add** to add multiple detectors, specifying `pci:0` and `pci:1` as the device for each.
|
||||
<FrigateConfigMock
|
||||
autoPlay={false}
|
||||
showNavigationSteps={false}
|
||||
section="model"
|
||||
values={{
|
||||
detectors: {
|
||||
coral1: { type: "edgetpu", device: "pci:0" },
|
||||
coral2: { type: "edgetpu", device: "pci:1" },
|
||||
},
|
||||
}}
|
||||
targets={[
|
||||
{
|
||||
field: "detectors",
|
||||
hint:
|
||||
"Add two EdgeTPU detectors and assign pci:0 and pci:1 as their devices.",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
@@ -220,7 +288,24 @@ detectors:
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Detectors and model" /> and select **EdgeTPU** from the detector type dropdown and click **Add** to add multiple detectors with different device types (e.g., `usb` and `pci`).
|
||||
<FrigateConfigMock
|
||||
autoPlay={false}
|
||||
showNavigationSteps={false}
|
||||
section="model"
|
||||
values={{
|
||||
detectors: {
|
||||
coral_usb: { type: "edgetpu", device: "usb" },
|
||||
coral_pci: { type: "edgetpu", device: "pci" },
|
||||
},
|
||||
}}
|
||||
targets={[
|
||||
{
|
||||
field: "detectors",
|
||||
hint:
|
||||
"Add two EdgeTPU detectors and assign usb to one device and pci to the other.",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
|
||||
@@ -5,7 +5,7 @@ title: Zones
|
||||
|
||||
import ConfigTabs from "@site/src/components/ConfigTabs";
|
||||
import TabItem from "@theme/TabItem";
|
||||
import NavPath from "@site/src/components/NavPath";
|
||||
import FrigateConfigMock from "@site/src/components/FrigateConfigMock";
|
||||
|
||||
Zones allow you to define a specific area of the frame and apply additional filters for object types so you can determine whether or not an object is within a particular area. Presence in a zone is evaluated based on the bottom center of the bounding box for the object. It does not matter how much of the bounding box overlaps with the zone.
|
||||
|
||||
@@ -25,11 +25,32 @@ During testing, enable the Zones option for the [Debug view](/usage/live#the-sin
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
1. Navigate to <NavPath path="Settings > Camera configuration > Masks / Zones" /> and select the desired camera.
|
||||
2. Under the **Zones** section, click the plus icon to add a new zone.
|
||||
3. Click on the camera's latest image to create the points for the zone boundary. Click the first point again to close the polygon.
|
||||
4. Configure zone options such as **Friendly name**, **Objects**, **Loitering time**, and **Inertia** in the zone editor.
|
||||
5. Press **Save** when finished.
|
||||
<FrigateConfigMock
|
||||
level="camera"
|
||||
section="masksAndZones"
|
||||
steps={[
|
||||
{
|
||||
focus: "zone.add",
|
||||
label: "Add Zone",
|
||||
hint: "Select the plus button beside Zones to create a zone.",
|
||||
},
|
||||
{
|
||||
focus: "zone.canvas",
|
||||
label: "Draw the zone",
|
||||
hint: "Select points on the camera image, then close the polygon by selecting the first point again.",
|
||||
},
|
||||
{
|
||||
focus: "zone.options",
|
||||
label: "Zone options",
|
||||
hint: "Configure the friendly name, objects, loitering time, inertia, and optional speed settings.",
|
||||
},
|
||||
{
|
||||
focus: "zone.save",
|
||||
label: "Save",
|
||||
hint: "Save the zone after its boundary and options are complete.",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
@@ -57,11 +78,14 @@ To create an alert only when an object enters the `entire_yard` zone:
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > Camera configuration > Review" />.
|
||||
|
||||
| Field | Description |
|
||||
| ---------------------------------- | ----------------------------------------------------------------------------------------- |
|
||||
| **Alerts config > Required zones** | Set to `entire_yard` so an object must enter that zone to be considered an alert; leave empty to allow alerts anywhere in the frame. |
|
||||
<FrigateConfigMock
|
||||
autoPlay={false}
|
||||
level="camera"
|
||||
section="review"
|
||||
focus="alerts.required_zones"
|
||||
values={{ "alerts.required_zones": ["entire_yard"] }}
|
||||
hint="Select entire_yard so an object must enter that zone to be considered an alert."
|
||||
/>
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
@@ -87,12 +111,26 @@ You may also want to filter detections to only be created when an object enters
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > Camera configuration > Review" />.
|
||||
|
||||
| Field | Description |
|
||||
| -------------------------------------- | -------------------------------------------------------------------------------------------- |
|
||||
| **Alerts config > Required zones** | Set to `inner_yard` so an object must enter that zone to be considered an alert; leave empty to allow alerts anywhere in the frame. |
|
||||
| **Detections config > Required zones** | Set to `edge_yard` so an object must enter that zone to be considered a detection; leave empty to allow detections anywhere in the frame. |
|
||||
<FrigateConfigMock
|
||||
autoPlay={false}
|
||||
showNavigationSteps={false}
|
||||
level="camera"
|
||||
section="review"
|
||||
values={{
|
||||
"alerts.required_zones": ["inner_yard"],
|
||||
"detections.required_zones": ["edge_yard"],
|
||||
}}
|
||||
targets={[
|
||||
{
|
||||
field: "alerts.required_zones",
|
||||
hint: "Select inner_yard so an object must enter the inner area to be considered an alert.",
|
||||
},
|
||||
{
|
||||
field: "detections.required_zones",
|
||||
hint: "Select edge_yard so activity in the secondary area can be retained as a detection.",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
@@ -126,8 +164,15 @@ To only save snapshots when an object enters a specific zone, for example an `en
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
1. Navigate to <NavPath path="Settings > Camera configuration > Snapshots" /> and select your camera.
|
||||
- Set **Required zones** to `entire_yard`
|
||||
<FrigateConfigMock
|
||||
autoPlay={false}
|
||||
showNavigationSteps={false}
|
||||
level="camera"
|
||||
section="snapshots"
|
||||
focus="required_zones"
|
||||
values={{ required_zones: ["entire_yard"] }}
|
||||
hint="Select entire_yard to save snapshots only after an object enters that zone."
|
||||
/>
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
@@ -154,11 +199,15 @@ Sometimes you want to limit a zone to specific object types to have more granula
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
1. Navigate to <NavPath path="Settings > Camera configuration > Masks / Zones" /> and select the desired camera.
|
||||
2. Create a zone named `entire_yard` covering everywhere you want to track a person.
|
||||
- Under **Objects**, add `person`
|
||||
3. Create a second zone named `front_yard_street` covering just the street.
|
||||
- Under **Objects**, add `car`
|
||||
<FrigateConfigMock
|
||||
autoPlay={false}
|
||||
showNavigationSteps={false}
|
||||
level="camera"
|
||||
section="masksAndZones"
|
||||
focus="zone.objects"
|
||||
label="Objects"
|
||||
hint="Choose which object types apply to the zone. For example, use person for the yard and car for the street."
|
||||
/>
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
@@ -198,10 +247,15 @@ When using loitering zones, a review item will behave in the following way:
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
1. Navigate to <NavPath path="Settings > Camera configuration > Masks / Zones" /> and select the desired camera.
|
||||
2. Edit or create the zone (e.g., `sidewalk`).
|
||||
- Set **Loitering time** to the desired number of seconds (e.g., `4`)
|
||||
- Under **Objects**, add the relevant object types (e.g., `person`)
|
||||
<FrigateConfigMock
|
||||
autoPlay={false}
|
||||
showNavigationSteps={false}
|
||||
level="camera"
|
||||
section="masksAndZones"
|
||||
focus="zone.loitering_time"
|
||||
label="Loitering Time"
|
||||
hint="Set the minimum number of seconds an object must remain in the zone before the zone activates."
|
||||
/>
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
@@ -227,9 +281,15 @@ Sometimes an objects bounding box may be slightly incorrect and the bottom cente
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
1. Navigate to <NavPath path="Settings > Camera configuration > Masks / Zones" /> and select the desired camera.
|
||||
2. Edit or create the zone (e.g., `front_yard`).
|
||||
- Set **Inertia** to the desired number of consecutive frames (e.g., `3`)
|
||||
<FrigateConfigMock
|
||||
autoPlay={false}
|
||||
showNavigationSteps={false}
|
||||
level="camera"
|
||||
section="masksAndZones"
|
||||
focus="zone.inertia"
|
||||
label="Inertia"
|
||||
hint="Set the number of consecutive frames an object must be inside the zone. The default is 3."
|
||||
/>
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
@@ -253,9 +313,15 @@ There may also be cases where you expect an object to quickly enter and exit a z
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
1. Navigate to <NavPath path="Settings > Camera configuration > Masks / Zones" /> and select the desired camera.
|
||||
2. Edit or create the zone (e.g., `driveway_entrance`).
|
||||
- Set **Inertia** to `1`
|
||||
<FrigateConfigMock
|
||||
autoPlay={false}
|
||||
showNavigationSteps={false}
|
||||
level="camera"
|
||||
section="masksAndZones"
|
||||
focus="zone.inertia"
|
||||
label="Inertia"
|
||||
hint="Set Inertia to 1 when an object should be considered inside the zone immediately."
|
||||
/>
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
@@ -289,11 +355,23 @@ Accurate real-world distance measurements are required to estimate speeds. These
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
1. Navigate to <NavPath path="Settings > Camera configuration > Masks / Zones" /> and select the desired camera.
|
||||
2. Create or edit a zone with exactly 4 points aligned to the ground plane.
|
||||
3. In the zone editor, enter the real-world **Distances** between each pair of consecutive points.
|
||||
- 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.
|
||||
<FrigateConfigMock
|
||||
level="camera"
|
||||
showNavigationSteps={false}
|
||||
section="masksAndZones"
|
||||
steps={[
|
||||
{
|
||||
focus: "zone.canvas",
|
||||
label: "Draw a four-point zone",
|
||||
hint: "Draw exactly four points aligned to the ground plane where objects will travel.",
|
||||
},
|
||||
{
|
||||
focus: "zone.speed",
|
||||
label: "Speed Estimation",
|
||||
hint: "Enter the real-world distance between each pair of consecutive points. Units follow the UI unit system setting.",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
@@ -317,11 +395,14 @@ The `distance` values are measured in meters (metric) or feet (imperial), depend
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > UI" />.
|
||||
|
||||
| Field | Description |
|
||||
| --------------- | -------------------------------------------------------------------- |
|
||||
| **Unit system** | Set to `metric` (kilometers per hour) or `imperial` (miles per hour) |
|
||||
<FrigateConfigMock
|
||||
autoPlay={false}
|
||||
level="global"
|
||||
section="ui"
|
||||
focus="unit_system"
|
||||
values={{ unit_system: "metric" }}
|
||||
hint="Choose metric for kilometers per hour or imperial for miles per hour."
|
||||
/>
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
@@ -356,10 +437,15 @@ Zones can be configured with a minimum speed requirement, meaning an object must
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
1. Navigate to <NavPath path="Settings > Camera configuration > Masks / Zones" /> and select the desired camera.
|
||||
2. Edit or create the zone with distances configured.
|
||||
- Set **Speed threshold** to the desired minimum speed (e.g., `20`)
|
||||
- The unit is kph or mph, depending on the **Unit system** setting
|
||||
<FrigateConfigMock
|
||||
autoPlay={false}
|
||||
showNavigationSteps={false}
|
||||
level="camera"
|
||||
section="masksAndZones"
|
||||
focus="zone.speed_threshold"
|
||||
label="Speed Threshold"
|
||||
hint="Set the minimum speed required for an object to be considered inside the zone. Units follow the UI unit system setting."
|
||||
/>
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
|
||||
@@ -78,7 +78,7 @@ Users of the Snapcraft build of Docker cannot use storage locations outside your
|
||||
|
||||
Frigate utilizes shared memory to store frames during processing. The default `shm-size` provided by Docker is **64MB**.
|
||||
|
||||
The default shm size of **128MB** is fine for setups with **2 cameras** detecting at **720p**. If Frigate is exiting with "Bus error" messages, it is likely because you have too many high resolution cameras and you need to specify a higher shm size, using [`--shm-size`](https://docs.docker.com/engine/reference/run/#runtime-constraints-on-resources) (or [`service.shm_size`](https://docs.docker.com/compose/compose-file/compose-file-v2/#shm_size) in Docker Compose).
|
||||
The default shm size of **128MB** is fine for setups with **2 cameras** detecting at **720p**. If Frigate is exiting with "Bus error" messages, it is likely because you have too many high resolution cameras and you need to specify a higher shm size, using [`--shm-size`](https://docs.docker.com/engine/reference/run/#runtime-constraints-on-resources) (or [`service.shm_size`](https://docs.docker.com/compose/compose-file/compose-file-v2/#shm_size) in Docker Compose). If raising the shm size does not help, check your [process and file limits](#process-and-file-limits) as well.
|
||||
|
||||
The Frigate container also stores logs in shm, which can take up to **40MB**, so make sure to take this into account in your math as well.
|
||||
|
||||
@@ -86,6 +86,30 @@ The Frigate container also stores logs in shm, which can take up to **40MB**, so
|
||||
|
||||
The shm size cannot be set per container for Home Assistant Apps. However, this is probably not required since by default Home Assistant Supervisor allocates `/dev/shm` with half the size of your total memory. If your machine has 8GB of memory, chances are that Frigate will have access to up to 4GB without any additional configuration.
|
||||
|
||||
### Process and file limits
|
||||
|
||||
Frigate runs many processes and opens a number of shared memory files. Installs with a large number of cameras can exceed the default limits your container runtime applies.
|
||||
|
||||
Hitting the PID limit logs `RuntimeError: can't start new thread`, often followed by a "Bus error" that makes it look like an shm sizing problem. Compare the current count against the max from inside the container:
|
||||
|
||||
```bash
|
||||
cat /sys/fs/cgroup/pids.current
|
||||
cat /sys/fs/cgroup/pids.max
|
||||
```
|
||||
|
||||
If these are close, raise the limit with [`--pids-limit`](https://docs.docker.com/engine/containers/resource_constraints/) (or `service.pids_limit` in Docker Compose).
|
||||
|
||||
Running out of file descriptors logs `OSError: [Errno 24] Too many open files`. Raise the limit in Docker Compose:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
frigate:
|
||||
ulimits:
|
||||
nofile:
|
||||
soft: 65535
|
||||
hard: 65535
|
||||
```
|
||||
|
||||
## Extra Steps for Specific Hardware
|
||||
|
||||
The following sections contain additional setup steps that are only required if you are using specific hardware. If you are not using any of these hardware types, you can skip to the [Docker](#docker) installation section.
|
||||
@@ -484,7 +508,6 @@ Generate a Frigate Docker Compose configuration based on your hardware and requi
|
||||
|
||||
<DockerComposeGenerator/>
|
||||
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="original" label="Example Docker Compose File">
|
||||
```yaml
|
||||
|
||||
Generated
+12
-1
@@ -19,10 +19,12 @@
|
||||
"docusaurus-plugin-openapi-docs": "^4.5.1",
|
||||
"docusaurus-theme-openapi-docs": "^4.5.1",
|
||||
"js-yaml": "^4.1.1",
|
||||
"marked": "^16.4.2",
|
||||
"prism-react-renderer": "^2.4.1",
|
||||
"raw-loader": "^4.0.2",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
"react-dom": "^18.3.1",
|
||||
"react-icons": "^5.7.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@docusaurus/module-type-aliases": "^3.7.0",
|
||||
@@ -18761,6 +18763,15 @@
|
||||
"react": "^16.8.0 || ^17 || ^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/react-icons": {
|
||||
"version": "5.7.0",
|
||||
"resolved": "https://registry.npmmirror.com/react-icons/-/react-icons-5.7.0.tgz",
|
||||
"integrity": "sha512-LBLy340Rzqy6+/yVhZKT3B/QpP1BZaesGqasf09HPOBzRarcDIFH0WwXlXQfE7q7ipxK4MSiC5DIBWURCny6fw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
|
||||
+6
-3
@@ -4,9 +4,11 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build:config": "node scripts/build-config.mjs",
|
||||
"build:mock": "node scripts/generate-mock-manifest.mjs",
|
||||
"check:mock": "node scripts/generate-mock-manifest.mjs --check",
|
||||
"docusaurus": "docusaurus",
|
||||
"start": "npm run build:config && npm run regen-docs && docusaurus start --host 0.0.0.0",
|
||||
"build": "npm run build:config && npm run regen-docs && docusaurus build",
|
||||
"start": "npm run build:config && npm run build:mock && npm run regen-docs && docusaurus start --host 0.0.0.0",
|
||||
"build": "npm run build:config && npm run build:mock && npm run regen-docs && docusaurus build",
|
||||
"swizzle": "docusaurus swizzle",
|
||||
"deploy": "docusaurus deploy",
|
||||
"clear": "docusaurus clear",
|
||||
@@ -33,7 +35,8 @@
|
||||
"prism-react-renderer": "^2.4.1",
|
||||
"raw-loader": "^4.0.2",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
"react-dom": "^18.3.1",
|
||||
"react-icons": "^5.7.0"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/** Build the compact field catalog used by documentation config mocks. */
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(scriptDir, "../..");
|
||||
const schemaPath = path.join(
|
||||
repoRoot,
|
||||
"web/e2e/fixtures/mock-data/config-schema.json",
|
||||
);
|
||||
const localeRoot = path.join(repoRoot, "web/public/locales/en/config");
|
||||
const sectionConfigRoot = path.join(
|
||||
repoRoot,
|
||||
"web/src/components/config-form/section-configs",
|
||||
);
|
||||
const settingsSourcePath = path.join(repoRoot, "web/src/pages/Settings.tsx");
|
||||
const settingsLocalePath = path.join(
|
||||
repoRoot,
|
||||
"web/public/locales/en/views/settings.json",
|
||||
);
|
||||
const outputPath = path.join(
|
||||
repoRoot,
|
||||
"docs/src/components/FrigateConfigMock/manifest.json",
|
||||
);
|
||||
|
||||
const schema = JSON.parse(fs.readFileSync(schemaPath, "utf8"));
|
||||
const translations = {
|
||||
global: JSON.parse(
|
||||
fs.readFileSync(path.join(localeRoot, "global.json"), "utf8"),
|
||||
),
|
||||
camera: JSON.parse(
|
||||
fs.readFileSync(path.join(localeRoot, "cameras.json"), "utf8"),
|
||||
),
|
||||
groups: JSON.parse(
|
||||
fs.readFileSync(path.join(localeRoot, "groups.json"), "utf8"),
|
||||
),
|
||||
};
|
||||
const settingsTranslations = JSON.parse(
|
||||
fs.readFileSync(settingsLocalePath, "utf8"),
|
||||
);
|
||||
|
||||
function resolveNode(node) {
|
||||
if (!node || typeof node !== "object") return {};
|
||||
|
||||
if (node.$ref) {
|
||||
const refName = node.$ref.split("/").at(-1);
|
||||
return {
|
||||
...resolveNode(schema.$defs?.[refName]),
|
||||
...node,
|
||||
$ref: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const variants = node.anyOf ?? node.oneOf;
|
||||
if (Array.isArray(variants)) {
|
||||
const concrete = variants.find((variant) => variant.type !== "null");
|
||||
return {
|
||||
...resolveNode(concrete),
|
||||
...node,
|
||||
anyOf: undefined,
|
||||
oneOf: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
function translationAt(level, section, fieldPath) {
|
||||
let current = translations[level]?.[section];
|
||||
for (const segment of fieldPath) {
|
||||
if (!current || typeof current !== "object") return {};
|
||||
current = current[segment];
|
||||
}
|
||||
return current && typeof current === "object" ? current : {};
|
||||
}
|
||||
|
||||
function inferWidget(node) {
|
||||
if (Array.isArray(node.enum)) return "select";
|
||||
if (node.type === "boolean") return "switch";
|
||||
if (
|
||||
["integer", "number"].includes(node.type) &&
|
||||
node.minimum !== undefined &&
|
||||
(node.maximum !== undefined || node.exclusiveMaximum !== undefined)
|
||||
) {
|
||||
return "range";
|
||||
}
|
||||
if (node.type === "integer" || node.type === "number") return "number";
|
||||
if (node.type === "array") return "tags";
|
||||
if (node.type === "object") return "object";
|
||||
return "text";
|
||||
}
|
||||
|
||||
function extractArray(source, key) {
|
||||
const match = source.match(new RegExp(`${key}\\s*:\\s*\\[([\\s\\S]*?)\\]`));
|
||||
return match
|
||||
? [...match[1].matchAll(/["']([^"']+)["']/g)].map((item) => item[1])
|
||||
: [];
|
||||
}
|
||||
|
||||
function extractObjectBlock(source, key) {
|
||||
const match = new RegExp(`\\b${key}\\s*:\\s*\\{`).exec(source);
|
||||
if (!match) return "";
|
||||
const start = source.indexOf("{", match.index);
|
||||
let depth = 0;
|
||||
let quote = null;
|
||||
let escaped = false;
|
||||
for (let index = start; index < source.length; index += 1) {
|
||||
const character = source[index];
|
||||
if (quote) {
|
||||
if (escaped) escaped = false;
|
||||
else if (character === "\\") escaped = true;
|
||||
else if (character === quote) quote = null;
|
||||
continue;
|
||||
}
|
||||
if (['"', "'", "`"].includes(character)) {
|
||||
quote = character;
|
||||
continue;
|
||||
}
|
||||
if (character === "{") depth += 1;
|
||||
if (character === "}") {
|
||||
depth -= 1;
|
||||
if (depth === 0) return source.slice(start + 1, index);
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function extractGroups(source) {
|
||||
const fieldGroups = {};
|
||||
const groupsBlock = extractObjectBlock(source, "fieldGroups");
|
||||
for (const match of groupsBlock.matchAll(/(\w+)\s*:\s*\[([\s\S]*?)\]/g)) {
|
||||
fieldGroups[match[1]] = [...match[2].matchAll(/["']([^"']+)["']/g)].map(
|
||||
(item) => item[1],
|
||||
);
|
||||
}
|
||||
return fieldGroups;
|
||||
}
|
||||
|
||||
function loadSectionHints(section, level) {
|
||||
const configPath = path.join(sectionConfigRoot, `${section}.ts`);
|
||||
if (!fs.existsSync(configPath)) return {};
|
||||
const source = fs.readFileSync(configPath, "utf8");
|
||||
const base = extractObjectBlock(source, "base");
|
||||
const override = extractObjectBlock(source, level);
|
||||
const overrideHas = (key) => new RegExp(`\\b${key}\\s*:`).test(override);
|
||||
return {
|
||||
order: overrideHas("fieldOrder")
|
||||
? extractArray(override, "fieldOrder")
|
||||
: extractArray(base, "fieldOrder"),
|
||||
hidden: [
|
||||
...extractArray(base, "hiddenFields"),
|
||||
...extractArray(override, "hiddenFields"),
|
||||
],
|
||||
advanced: overrideHas("advancedFields")
|
||||
? extractArray(override, "advancedFields")
|
||||
: extractArray(base, "advancedFields"),
|
||||
groups: overrideHas("fieldGroups")
|
||||
? extractGroups(override)
|
||||
: extractGroups(base),
|
||||
docs: base.match(/sectionDocs\s*:\s*["']([^"']+)["']/)?.[1] ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function groupLabel(level, section, group) {
|
||||
const domain = level === "camera" ? "cameras" : "global";
|
||||
return (
|
||||
translations.groups?.[section]?.[domain]?.[group] ??
|
||||
group.replaceAll("_", " ").replace(/^./, (value) => value.toUpperCase())
|
||||
);
|
||||
}
|
||||
|
||||
function collectFields(level, section, sectionNode, hints) {
|
||||
const fields = {};
|
||||
|
||||
function visit(rawNode, fieldPath = []) {
|
||||
const node = resolveNode(rawNode);
|
||||
const properties = node.properties;
|
||||
if (properties && typeof properties === "object") {
|
||||
for (const [name, child] of Object.entries(properties)) {
|
||||
visit(child, [...fieldPath, name]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (fieldPath.length === 0) return;
|
||||
const key = fieldPath.join(".");
|
||||
const localized = translationAt(level, section, fieldPath);
|
||||
fields[key] = {
|
||||
label: localized.label ?? node.title ?? fieldPath.at(-1),
|
||||
description: localized.description ?? node.description ?? "",
|
||||
widget: inferWidget(node),
|
||||
default: node.default ?? null,
|
||||
enum: node.enum ?? null,
|
||||
minimum: node.minimum ?? node.exclusiveMinimum ?? null,
|
||||
maximum: node.maximum ?? node.exclusiveMaximum ?? null,
|
||||
advanced: hints.advanced?.includes(key) ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
visit(sectionNode);
|
||||
return fields;
|
||||
}
|
||||
|
||||
function buildLevel(level) {
|
||||
const rootProperties =
|
||||
level === "camera"
|
||||
? resolveNode(schema.$defs.CameraConfig).properties
|
||||
: schema.properties;
|
||||
const result = {};
|
||||
|
||||
for (const [section, rawNode] of Object.entries(rootProperties ?? {})) {
|
||||
const node = resolveNode(rawNode);
|
||||
if (!node.properties) continue;
|
||||
|
||||
const hints = loadSectionHints(section, level);
|
||||
const hidden = new Set(hints.hidden ?? []);
|
||||
const fields = collectFields(level, section, node, hints);
|
||||
for (const key of hidden) delete fields[key];
|
||||
|
||||
const localized = translations[level]?.[section] ?? {};
|
||||
result[section] = {
|
||||
label: localized.label ?? rawNode.title ?? node.title ?? section,
|
||||
description: localized.description ?? rawNode.description ?? "",
|
||||
order: hints.order ?? [],
|
||||
groups: Object.entries(hints.groups ?? {}).map(([key, groupFields]) => ({
|
||||
key,
|
||||
label: groupLabel(level, section, key),
|
||||
fields: groupFields,
|
||||
})),
|
||||
docs: hints.docs ?? null,
|
||||
fields,
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function parseSectionMapping(source, constantName, level) {
|
||||
const match = source.match(
|
||||
new RegExp(`const ${constantName}[^=]*=\\s*\\{([\\s\\S]*?)\\n\\};`),
|
||||
);
|
||||
if (!match) return [];
|
||||
return [...match[1].matchAll(/(\w+)\s*:\s*"([^"]+)"/g)].map(
|
||||
([, section, page]) => ({ section, page, level }),
|
||||
);
|
||||
}
|
||||
|
||||
function buildNavigation() {
|
||||
const source = fs.readFileSync(settingsSourcePath, "utf8");
|
||||
const settingsBlock = source.match(
|
||||
/const settingsGroups\s*=\s*\[([\s\S]*?)\n\];/,
|
||||
)?.[1];
|
||||
if (!settingsBlock) return { groups: [], pages: {} };
|
||||
|
||||
const mappings = [
|
||||
...parseSectionMapping(source, "GLOBAL_SECTION_MAPPING", "global"),
|
||||
...parseSectionMapping(source, "CAMERA_SECTION_MAPPING", "camera"),
|
||||
...parseSectionMapping(source, "ENRICHMENTS_SECTION_MAPPING", "global"),
|
||||
...parseSectionMapping(source, "SYSTEM_SECTION_MAPPING", "global"),
|
||||
];
|
||||
const pages = Object.fromEntries(
|
||||
mappings.map((mapping) => [mapping.page, mapping]),
|
||||
);
|
||||
const groupMatches = [...settingsBlock.matchAll(/\{\s*label:\s*"([^"]+)"/g)];
|
||||
const groups = groupMatches.map((match, index) => {
|
||||
const start = match.index ?? 0;
|
||||
const end = groupMatches[index + 1]?.index ?? settingsBlock.length;
|
||||
const sourceSlice = settingsBlock.slice(start, end);
|
||||
const itemKeys = [...sourceSlice.matchAll(/key:\s*"([^"]+)"/g)].map(
|
||||
(item) => item[1],
|
||||
);
|
||||
return {
|
||||
key: match[1],
|
||||
label: settingsTranslations.menu?.[match[1]] ?? match[1],
|
||||
items: itemKeys.map((key) => ({
|
||||
key,
|
||||
label: settingsTranslations.menu?.[key] ?? key,
|
||||
...(key === "masksAndZones"
|
||||
? { section: key, page: key, level: "camera" }
|
||||
: {}),
|
||||
...(pages[key] ?? {}),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
return { groups, pages };
|
||||
}
|
||||
|
||||
function buildDetectorTypes() {
|
||||
const detectorTranslations = translations.global?.detectors ?? {};
|
||||
const reserved = new Set([
|
||||
"label",
|
||||
"description",
|
||||
"type",
|
||||
"model",
|
||||
"model_path",
|
||||
]);
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(detectorTranslations)
|
||||
.filter(
|
||||
([key, value]) =>
|
||||
!reserved.has(key) &&
|
||||
value &&
|
||||
typeof value === "object" &&
|
||||
typeof value.label === "string" &&
|
||||
typeof value.description === "string",
|
||||
)
|
||||
.map(([type, value]) => [
|
||||
type,
|
||||
{
|
||||
label: value.label,
|
||||
description: value.description,
|
||||
fields: Object.fromEntries(
|
||||
Object.entries(value)
|
||||
.filter(
|
||||
([key, field]) =>
|
||||
!["label", "description"].includes(key) &&
|
||||
field &&
|
||||
typeof field === "object" &&
|
||||
typeof field.label === "string",
|
||||
)
|
||||
.map(([key, field]) => [
|
||||
key,
|
||||
{
|
||||
label: field.label,
|
||||
description: field.description ?? "",
|
||||
},
|
||||
]),
|
||||
),
|
||||
},
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
const manifest = {
|
||||
generatedFrom: path.relative(repoRoot, schemaPath).replaceAll("\\", "/"),
|
||||
detectorTypes: buildDetectorTypes(),
|
||||
levels: {
|
||||
global: buildLevel("global"),
|
||||
camera: buildLevel("camera"),
|
||||
},
|
||||
navigation: buildNavigation(),
|
||||
};
|
||||
|
||||
const serialized = `${JSON.stringify(manifest, null, 2)}\n`;
|
||||
if (process.argv.includes("--check")) {
|
||||
const current = fs.existsSync(outputPath)
|
||||
? fs.readFileSync(outputPath, "utf8")
|
||||
: "";
|
||||
if (current !== serialized) {
|
||||
console.error(
|
||||
`${path.relative(repoRoot, outputPath)} is stale. Run npm run build:mock.`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`Checked ${path.relative(repoRoot, outputPath)}`);
|
||||
} else {
|
||||
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
||||
fs.writeFileSync(outputPath, serialized);
|
||||
console.log(`Generated ${path.relative(repoRoot, outputPath)}`);
|
||||
}
|
||||
@@ -45,7 +45,7 @@ from lib.i18n_loader import load_i18n
|
||||
from lib.nav_map import ALL_CONFIG_SECTIONS
|
||||
from lib.schema_loader import load_schema
|
||||
from lib.section_config_parser import load_section_configs
|
||||
from lib.ui_generator import generate_ui_content, wrap_with_config_tabs
|
||||
from lib.ui_generator import generate_mock_content, generate_ui_content, wrap_with_config_tabs
|
||||
from lib.yaml_extractor import (
|
||||
extract_config_tabs_blocks,
|
||||
extract_yaml_blocks,
|
||||
@@ -60,6 +60,7 @@ def process_file(
|
||||
inject: bool = False,
|
||||
verbose: bool = False,
|
||||
outpath: Path | None = None,
|
||||
mock: bool = False,
|
||||
) -> dict:
|
||||
"""Process a single markdown file for initial injection of bare YAML blocks.
|
||||
|
||||
@@ -114,7 +115,8 @@ def process_file(
|
||||
continue
|
||||
|
||||
# Generate UI content
|
||||
ui_content = generate_ui_content(
|
||||
generator = generate_mock_content if mock else generate_ui_content
|
||||
ui_content = generator(
|
||||
block, schema, i18n, section_configs
|
||||
)
|
||||
|
||||
@@ -188,6 +190,7 @@ def regenerate_file(
|
||||
dry_run: bool = False,
|
||||
verbose: bool = False,
|
||||
outpath: Path | None = None,
|
||||
mock: bool = False,
|
||||
) -> dict:
|
||||
"""Regenerate UI tabs in existing ConfigTabs blocks.
|
||||
|
||||
@@ -233,7 +236,8 @@ def regenerate_file(
|
||||
continue
|
||||
|
||||
# Generate fresh UI content
|
||||
new_ui = generate_ui_content(
|
||||
generator = generate_mock_content if mock else generate_ui_content
|
||||
new_ui = generator(
|
||||
yaml_block, schema, i18n, section_configs
|
||||
)
|
||||
|
||||
@@ -302,6 +306,7 @@ def check_file(
|
||||
i18n: dict,
|
||||
section_configs: dict,
|
||||
verbose: bool = False,
|
||||
mock: bool = False,
|
||||
) -> dict:
|
||||
"""Check for drift between existing UI tabs and what would be generated.
|
||||
|
||||
@@ -333,7 +338,8 @@ def check_file(
|
||||
stats["skipped"] += 1
|
||||
continue
|
||||
|
||||
new_ui = generate_ui_content(
|
||||
generator = generate_mock_content if mock else generate_ui_content
|
||||
new_ui = generator(
|
||||
yaml_block, schema, i18n, section_configs
|
||||
)
|
||||
|
||||
@@ -406,6 +412,10 @@ def _ensure_imports(content: str) -> str:
|
||||
needed_imports.append(
|
||||
'import NavPath from "@site/src/components/NavPath";'
|
||||
)
|
||||
if "<FrigateConfigMock" in content and 'import FrigateConfigMock' not in content:
|
||||
needed_imports.append(
|
||||
'import FrigateConfigMock from "@site/src/components/FrigateConfigMock";'
|
||||
)
|
||||
|
||||
if not needed_imports:
|
||||
return content
|
||||
@@ -472,6 +482,11 @@ def main():
|
||||
action="store_true",
|
||||
help="Show detailed warnings and diagnostics",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--mock",
|
||||
action="store_true",
|
||||
help="Generate focused Frigate UI mocks instead of text instructions",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
# Collect files and determine base directory for relative path computation
|
||||
@@ -525,23 +540,25 @@ def main():
|
||||
print(f"Processing {len(files)} file(s)...\n", file=sys.stderr)
|
||||
|
||||
if args.check:
|
||||
_run_check(files, schema, i18n, section_configs, args.verbose)
|
||||
_run_check(files, schema, i18n, section_configs, args.verbose, args.mock)
|
||||
elif args.regenerate:
|
||||
_run_regenerate(
|
||||
files, schema, i18n, section_configs,
|
||||
args.dry_run, args.verbose, file_outpaths,
|
||||
args.mock,
|
||||
)
|
||||
else:
|
||||
_run_inject(
|
||||
files, schema, i18n, section_configs,
|
||||
args.inject, args.verbose, file_outpaths,
|
||||
args.mock,
|
||||
)
|
||||
|
||||
if outdir is not None:
|
||||
print(f"\nOutput written to: {outdir}", file=sys.stderr)
|
||||
|
||||
|
||||
def _run_inject(files, schema, i18n, section_configs, inject, verbose, file_outpaths):
|
||||
def _run_inject(files, schema, i18n, section_configs, inject, verbose, file_outpaths, mock):
|
||||
"""Run default mode: preview or inject bare YAML blocks."""
|
||||
total_stats = {
|
||||
"files": 0,
|
||||
@@ -557,6 +574,7 @@ def _run_inject(files, schema, i18n, section_configs, inject, verbose, file_outp
|
||||
filepath, schema, i18n, section_configs,
|
||||
inject=inject, verbose=verbose,
|
||||
outpath=file_outpaths.get(filepath),
|
||||
mock=mock,
|
||||
)
|
||||
|
||||
total_stats["files"] += 1
|
||||
@@ -580,7 +598,7 @@ def _run_inject(files, schema, i18n, section_configs, inject, verbose, file_outp
|
||||
print("=" * 60, file=sys.stderr)
|
||||
|
||||
|
||||
def _run_regenerate(files, schema, i18n, section_configs, dry_run, verbose, file_outpaths):
|
||||
def _run_regenerate(files, schema, i18n, section_configs, dry_run, verbose, file_outpaths, mock):
|
||||
"""Run regenerate mode: update existing ConfigTabs blocks."""
|
||||
total_stats = {
|
||||
"files": 0,
|
||||
@@ -595,6 +613,7 @@ def _run_regenerate(files, schema, i18n, section_configs, dry_run, verbose, file
|
||||
filepath, schema, i18n, section_configs,
|
||||
dry_run=dry_run, verbose=verbose,
|
||||
outpath=file_outpaths.get(filepath),
|
||||
mock=mock,
|
||||
)
|
||||
|
||||
total_stats["files"] += 1
|
||||
@@ -617,7 +636,7 @@ def _run_regenerate(files, schema, i18n, section_configs, dry_run, verbose, file
|
||||
print("=" * 60, file=sys.stderr)
|
||||
|
||||
|
||||
def _run_check(files, schema, i18n, section_configs, verbose):
|
||||
def _run_check(files, schema, i18n, section_configs, verbose, mock):
|
||||
"""Run check mode: detect drift without modifying files."""
|
||||
total_stats = {
|
||||
"files": 0,
|
||||
@@ -630,6 +649,7 @@ def _run_check(files, schema, i18n, section_configs, verbose):
|
||||
for filepath in files:
|
||||
stats = check_file(
|
||||
filepath, schema, i18n, section_configs, verbose=verbose,
|
||||
mock=mock,
|
||||
)
|
||||
|
||||
total_stats["files"] += 1
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Generate UI tab markdown content from parsed YAML blocks."""
|
||||
"""Generate UI tab content from parsed YAML blocks."""
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from .i18n_loader import get_field_description, get_field_label, get_value_label
|
||||
@@ -9,6 +10,80 @@ from .section_config_parser import get_hidden_fields
|
||||
from .yaml_extractor import YamlBlock, get_leaf_paths
|
||||
|
||||
|
||||
def generate_mock_content(
|
||||
block: YamlBlock,
|
||||
schema: dict[str, Any],
|
||||
i18n: dict[str, Any],
|
||||
section_configs: dict[str, dict[str, Any]],
|
||||
) -> str | None:
|
||||
"""Generate a focused Frigate config mock for a YAML block."""
|
||||
if block.section_key is None:
|
||||
return None
|
||||
|
||||
if block.is_camera_level:
|
||||
cameras = block.parsed.get("cameras", {})
|
||||
camera_name = block.camera_name or next(iter(cameras), None)
|
||||
if not camera_name or not isinstance(cameras.get(camera_name), dict):
|
||||
return None
|
||||
config = cameras[camera_name]
|
||||
level = "camera"
|
||||
else:
|
||||
config = block.parsed
|
||||
level = detect_level(block.section_key)
|
||||
if level not in ("global", "camera"):
|
||||
level = "global"
|
||||
|
||||
steps: list[dict[str, object]] = []
|
||||
for section, section_data in config.items():
|
||||
if section not in ALL_CONFIG_SECTIONS or not isinstance(
|
||||
section_data, dict
|
||||
):
|
||||
continue
|
||||
|
||||
hidden = get_hidden_fields(section_configs, section, level)
|
||||
values: dict[str, object] = {}
|
||||
for path, value in get_leaf_paths(section_data):
|
||||
path_parts = list(path)
|
||||
if not _is_hidden(path_parts[-1], path_parts, hidden):
|
||||
values[".".join(path_parts)] = value
|
||||
|
||||
if values:
|
||||
steps.append(
|
||||
{
|
||||
"section": section,
|
||||
"level": level,
|
||||
"fields": list(values),
|
||||
"values": values,
|
||||
"focus": next(iter(values)),
|
||||
}
|
||||
)
|
||||
|
||||
if not steps:
|
||||
return None
|
||||
|
||||
if len(steps) == 1:
|
||||
step = steps[0]
|
||||
return "\n".join(
|
||||
[
|
||||
"<FrigateConfigMock",
|
||||
f' section="{step["section"]}"',
|
||||
f' level="{step["level"]}"',
|
||||
f" fields={{{json.dumps(step['fields'])}}}",
|
||||
f" values={{{json.dumps(step['values'])}}}",
|
||||
f' focus="{step["focus"]}"',
|
||||
"/>",
|
||||
]
|
||||
)
|
||||
|
||||
return "\n".join(
|
||||
[
|
||||
"<FrigateConfigMock",
|
||||
f" steps={{{json.dumps(steps)}}}",
|
||||
"/>",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _format_value(
|
||||
value: object,
|
||||
field_schema: dict[str, Any] | None,
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Tests for focused Frigate configuration mock generation."""
|
||||
|
||||
import sys
|
||||
import types
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
try:
|
||||
import yaml # noqa: F401
|
||||
except ModuleNotFoundError:
|
||||
yaml_stub = types.ModuleType("yaml")
|
||||
yaml_stub.YAMLError = ValueError
|
||||
yaml_stub.safe_load = lambda _value: {}
|
||||
sys.modules["yaml"] = yaml_stub
|
||||
|
||||
from lib.ui_generator import generate_mock_content
|
||||
from lib.yaml_extractor import YamlBlock
|
||||
|
||||
|
||||
def make_block(parsed: dict, section: str, camera: bool = False) -> YamlBlock:
|
||||
"""Create a parsed YAML block for generator tests."""
|
||||
return YamlBlock(
|
||||
raw="",
|
||||
parsed=parsed,
|
||||
line_start=1,
|
||||
line_end=1,
|
||||
highlight=None,
|
||||
has_comments=False,
|
||||
inside_config_tabs=False,
|
||||
section_key=section,
|
||||
is_camera_level=camera,
|
||||
camera_name="front_door" if camera else None,
|
||||
config_keys=list(parsed),
|
||||
)
|
||||
|
||||
|
||||
class TestGenerateMockContent(unittest.TestCase):
|
||||
def test_generates_focused_global_section(self):
|
||||
content = generate_mock_content(
|
||||
make_block({"motion": {"threshold": 30}}, "motion"),
|
||||
{},
|
||||
{},
|
||||
{},
|
||||
)
|
||||
|
||||
self.assertIn('section="motion"', content)
|
||||
self.assertIn('level="global"', content)
|
||||
self.assertIn('fields={["threshold"]}', content)
|
||||
self.assertIn('values={{"threshold": 30}}', content)
|
||||
self.assertIn('focus="threshold"', content)
|
||||
|
||||
def test_unwraps_camera_and_omits_hidden_fields(self):
|
||||
content = generate_mock_content(
|
||||
make_block(
|
||||
{
|
||||
"cameras": {
|
||||
"front_door": {
|
||||
"motion": {
|
||||
"threshold": 20,
|
||||
"raw_mask": "ignored",
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"motion",
|
||||
camera=True,
|
||||
),
|
||||
{},
|
||||
{},
|
||||
{"motion": {"hiddenFields": ["raw_mask"]}},
|
||||
)
|
||||
|
||||
self.assertIn('level="camera"', content)
|
||||
self.assertIn('fields={["threshold"]}', content)
|
||||
self.assertNotIn("raw_mask", content)
|
||||
|
||||
def test_generates_steps_for_multiple_sections(self):
|
||||
content = generate_mock_content(
|
||||
make_block(
|
||||
{
|
||||
"record": {"enabled": True},
|
||||
"snapshots": {"enabled": True},
|
||||
},
|
||||
"record",
|
||||
),
|
||||
{},
|
||||
{},
|
||||
{},
|
||||
)
|
||||
|
||||
self.assertIn("steps={", content)
|
||||
self.assertIn('"section": "record"', content)
|
||||
self.assertIn('"section": "snapshots"', content)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,9 @@
|
||||
import React, { useState } from "react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import CodeBlock from "@theme/CodeBlock";
|
||||
import ConfigTabs from "@site/src/components/ConfigTabs";
|
||||
import FrigateConfigMock from "@site/src/components/FrigateConfigMock";
|
||||
import TabItem from "@theme/TabItem";
|
||||
import { load } from "js-yaml";
|
||||
import { marked } from "marked";
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
@@ -103,6 +105,40 @@ export default function ModelConfigDropdown({ models }) {
|
||||
|
||||
const selectedModel = models[selectedModelIndex];
|
||||
const hasChoices = models.length > 1;
|
||||
const mockConfig = useMemo(() => {
|
||||
try {
|
||||
const parsed = load(selectedModel.yaml) ?? {};
|
||||
const model = parsed.model ?? {};
|
||||
const legacyDetectors = Object.fromEntries(
|
||||
Object.entries(parsed).filter(
|
||||
([key, value]) =>
|
||||
key !== "model" &&
|
||||
value &&
|
||||
typeof value === "object" &&
|
||||
typeof value.type === "string",
|
||||
),
|
||||
);
|
||||
const detectors = parsed.detectors ?? legacyDetectors;
|
||||
const values = {
|
||||
detectors,
|
||||
...model,
|
||||
};
|
||||
const targets = [
|
||||
...(Object.keys(detectors).length ? ["detectors"] : []),
|
||||
...(Object.keys(model).length
|
||||
? [
|
||||
{
|
||||
field: "custom_model",
|
||||
hint: `Configure the custom detection model, input size, and input format for ${selectedModel.label}.`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
return { targets, values };
|
||||
} catch {
|
||||
return { targets: ["detectors"], values: { detectors: {} } };
|
||||
}
|
||||
}, [selectedModel.label, selectedModel.yaml]);
|
||||
|
||||
const handleModelSelect = (index) => {
|
||||
setSelectedModelIndex(index);
|
||||
@@ -158,7 +194,13 @@ export default function ModelConfigDropdown({ models }) {
|
||||
<h4 className={styles.stepTitle}>Step 3 — Configure the detector</h4>
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
<Markdown>{selectedModel.ui}</Markdown>
|
||||
<FrigateConfigMock
|
||||
autoPlay={false}
|
||||
key={selectedModel.key}
|
||||
section="model"
|
||||
targets={mockConfig.targets}
|
||||
values={mockConfig.values}
|
||||
/>
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
<CodeBlock language="yaml">{selectedModel.yaml}</CodeBlock>
|
||||
|
||||
+5
-1
@@ -971,7 +971,11 @@ def config_set(request: Request, body: AppConfigSetBody):
|
||||
content=(
|
||||
{
|
||||
"success": True,
|
||||
"message": "Config successfully updated, restart to apply",
|
||||
"message": (
|
||||
"Config successfully updated"
|
||||
if body.requires_restart == 0
|
||||
else "Config successfully updated, restart to apply"
|
||||
),
|
||||
}
|
||||
),
|
||||
status_code=200,
|
||||
|
||||
@@ -117,7 +117,9 @@ class CameraMaintainer(threading.Thread):
|
||||
|
||||
if runtime:
|
||||
self.camera_metrics[name] = CameraMetrics(self.metrics_manager)
|
||||
self.ptz_metrics[name] = PTZMetrics(autotracker_enabled=False)
|
||||
self.ptz_metrics[name] = PTZMetrics(
|
||||
autotracker_enabled=config.onvif.autotracking.enabled
|
||||
)
|
||||
self.region_grids[name] = get_camera_regions_grid(
|
||||
name,
|
||||
config.detect,
|
||||
|
||||
@@ -111,9 +111,9 @@ class CameraState:
|
||||
# draw thicker box around ptz autotracked object
|
||||
if (
|
||||
self.camera_config.onvif.autotracking.enabled
|
||||
and self.ptz_autotracker_thread.ptz_autotracker.autotracker_init[
|
||||
and self.ptz_autotracker_thread.ptz_autotracker.autotracker_init.get(
|
||||
self.name
|
||||
]
|
||||
)
|
||||
and self.ptz_autotracker_thread.ptz_autotracker.tracked_object[
|
||||
self.name
|
||||
]
|
||||
|
||||
@@ -588,6 +588,10 @@ class Dispatcher:
|
||||
self.ptz_metrics[camera_name].start_time.value = 0
|
||||
ptz_autotracker_settings.enabled = False
|
||||
|
||||
self.config_updater.publish_update(
|
||||
CameraConfigUpdateTopic(CameraConfigUpdateEnum.autotracking, camera_name),
|
||||
ptz_autotracker_settings,
|
||||
)
|
||||
self.publish(f"{camera_name}/ptz_autotracker/state", payload, retain=True)
|
||||
|
||||
def _on_motion_contour_area_command(self, camera_name: str, payload: int) -> None:
|
||||
|
||||
@@ -14,6 +14,7 @@ class CameraConfigUpdateEnum(str, Enum):
|
||||
add = "add" # for adding a camera
|
||||
audio = "audio"
|
||||
audio_transcription = "audio_transcription"
|
||||
autotracking = "autotracking" # ptz autotracking only, without an onvif reinit
|
||||
birdseye = "birdseye"
|
||||
detect = "detect"
|
||||
enabled = "enabled"
|
||||
@@ -145,6 +146,8 @@ class CameraConfigUpdateSubscriber:
|
||||
config.snapshots = updated_config
|
||||
elif update_type == CameraConfigUpdateEnum.onvif:
|
||||
config.onvif = updated_config
|
||||
elif update_type == CameraConfigUpdateEnum.autotracking:
|
||||
config.onvif.autotracking = updated_config
|
||||
elif update_type == CameraConfigUpdateEnum.timestamp_style:
|
||||
config.timestamp_style = updated_config
|
||||
elif update_type == CameraConfigUpdateEnum.zones:
|
||||
|
||||
@@ -335,6 +335,7 @@ class BirdsEyeFrameManager:
|
||||
|
||||
self.camera_layout: list[Any] = []
|
||||
self.active_cameras: set[str] = set()
|
||||
self.layout_camera_order: list[str] = []
|
||||
self.last_output_time = 0.0
|
||||
|
||||
def add_camera(self, cam: str) -> None:
|
||||
@@ -372,6 +373,13 @@ class BirdsEyeFrameManager:
|
||||
if cam in self.cameras:
|
||||
del self.cameras[cam]
|
||||
|
||||
def sort_cameras(self, cameras: set[str]) -> list[str]:
|
||||
"""Sort cameras by birdseye order, falling back to name when tied."""
|
||||
return sorted(
|
||||
cameras,
|
||||
key=lambda camera: (self.config.cameras[camera].birdseye.order, camera),
|
||||
)
|
||||
|
||||
def clear_frame(self) -> None:
|
||||
logger.debug("Clearing the birdseye frame")
|
||||
self.frame[:] = self.blank_frame
|
||||
@@ -482,6 +490,7 @@ class BirdsEyeFrameManager:
|
||||
# if the layout needs to be cleared
|
||||
self.camera_layout = []
|
||||
self.active_cameras = set()
|
||||
self.layout_camera_order = []
|
||||
self.clear_frame()
|
||||
frame_changed = True
|
||||
layout_changed = True
|
||||
@@ -500,21 +509,21 @@ class BirdsEyeFrameManager:
|
||||
else:
|
||||
reset_layout = True
|
||||
|
||||
sorted_active_cameras = self.sort_cameras(active_cameras)
|
||||
|
||||
if not reset_layout and sorted_active_cameras != self.layout_camera_order:
|
||||
logger.debug("Birdseye camera order changed")
|
||||
reset_layout = True
|
||||
|
||||
if reset_layout:
|
||||
logger.debug("Resetting Birdseye layout...")
|
||||
self.clear_frame()
|
||||
self.active_cameras = active_cameras
|
||||
self.layout_camera_order = sorted_active_cameras
|
||||
layout_changed = True # Layout is changing due to reset
|
||||
# this also converts added_cameras from a set to a list since we need
|
||||
# to pop elements in order
|
||||
active_cameras_to_add = sorted(
|
||||
active_cameras,
|
||||
# sort cameras by order and by name if the order is the same
|
||||
key=lambda active_camera: (
|
||||
self.config.cameras[active_camera].birdseye.order,
|
||||
active_camera,
|
||||
),
|
||||
)
|
||||
active_cameras_to_add = sorted_active_cameras
|
||||
if len(active_cameras) == 1:
|
||||
# show single camera as fullscreen
|
||||
camera = active_cameras_to_add[0]
|
||||
@@ -780,6 +789,7 @@ class BirdsEyeFrameManager:
|
||||
frame_changed, layout_changed = False, False
|
||||
self.active_cameras = set()
|
||||
self.camera_layout = []
|
||||
self.layout_camera_order = []
|
||||
print(traceback.format_exc())
|
||||
|
||||
# if the frame was updated or the fps is too low, send frame
|
||||
|
||||
@@ -20,6 +20,10 @@ from norfair.camera_motion import (
|
||||
from frigate.camera import PTZMetrics
|
||||
from frigate.comms.dispatcher import Dispatcher
|
||||
from frigate.config import CameraConfig, FrigateConfig, ZoomingModeEnum
|
||||
from frigate.config.camera.updater import (
|
||||
CameraConfigUpdateEnum,
|
||||
CameraConfigUpdateSubscriber,
|
||||
)
|
||||
from frigate.const import (
|
||||
AUTOTRACKING_MAX_AREA_RATIO,
|
||||
AUTOTRACKING_MAX_MOVE_METRICS,
|
||||
@@ -194,7 +198,9 @@ class PtzAutoTrackerThread(threading.Thread):
|
||||
|
||||
def run(self):
|
||||
while not self.stop_event.wait(1):
|
||||
for camera, camera_config in self.config.cameras.items():
|
||||
self.ptz_autotracker.check_for_updates()
|
||||
|
||||
for camera, camera_config in list(self.config.cameras.items()):
|
||||
if not camera_config.enabled:
|
||||
continue
|
||||
|
||||
@@ -211,6 +217,7 @@ class PtzAutoTrackerThread(threading.Thread):
|
||||
self.ptz_autotracker.tracked_object[camera] = None
|
||||
self.ptz_autotracker.tracked_object_history[camera].clear()
|
||||
|
||||
self.ptz_autotracker.config_subscriber.stop()
|
||||
logger.info("Exiting autotracker...")
|
||||
|
||||
|
||||
@@ -244,6 +251,16 @@ class PtzAutoTracker:
|
||||
self.zoom_time: dict[str, float] = {}
|
||||
self.zoom_factor: dict[str, object] = {}
|
||||
|
||||
self.config_subscriber = CameraConfigUpdateSubscriber(
|
||||
self.config,
|
||||
self.config.cameras,
|
||||
[
|
||||
CameraConfigUpdateEnum.add,
|
||||
CameraConfigUpdateEnum.autotracking,
|
||||
CameraConfigUpdateEnum.onvif,
|
||||
],
|
||||
)
|
||||
|
||||
# if cam is set to autotrack, onvif should be set up
|
||||
for camera, camera_config in self.config.cameras.items():
|
||||
if not camera_config.enabled:
|
||||
@@ -260,6 +277,29 @@ class PtzAutoTracker:
|
||||
# Wait for the coroutine to complete
|
||||
future.result()
|
||||
|
||||
def check_for_updates(self) -> None:
|
||||
"""Apply camera config updates and mirror autotracking state to ptz metrics.
|
||||
|
||||
The camera processes read autotracker_enabled rather than the config, so it
|
||||
has to follow every path that can change autotracking, not just the mqtt
|
||||
toggle that writes it directly.
|
||||
"""
|
||||
updates = self.config_subscriber.check_for_updates()
|
||||
|
||||
for cameras in updates.values():
|
||||
for camera in cameras:
|
||||
camera_config = self.config.cameras.get(camera)
|
||||
metrics = self.ptz_metrics.get(camera)
|
||||
|
||||
# a camera added at runtime gets its metrics from the maintainer on
|
||||
# another thread, which seeds them from this same config value
|
||||
if camera_config is None or metrics is None:
|
||||
continue
|
||||
|
||||
metrics.autotracker_enabled.value = (
|
||||
camera_config.onvif.autotracking.enabled
|
||||
)
|
||||
|
||||
async def _autotracker_setup(self, camera_config: CameraConfig, camera: str):
|
||||
logger.debug(f"{camera}: Autotracker init")
|
||||
|
||||
@@ -1365,7 +1405,7 @@ class PtzAutoTracker:
|
||||
camera_config = self.config.cameras[camera]
|
||||
|
||||
if camera_config.onvif.autotracking.enabled:
|
||||
if not self.autotracker_init[camera]:
|
||||
if not self.autotracker_init.get(camera):
|
||||
future = asyncio.run_coroutine_threadsafe(
|
||||
self._autotracker_setup(camera_config, camera), self.onvif.loop
|
||||
)
|
||||
@@ -1483,9 +1523,11 @@ class PtzAutoTracker:
|
||||
}
|
||||
|
||||
async def camera_maintenance(self, camera):
|
||||
# bail and don't check anything if we're calibrating or tracking an object
|
||||
# bail and don't check anything if we're not set up yet, calibrating, or
|
||||
# tracking an object. a camera enabled at runtime has no autotracker_init
|
||||
# entry until autotrack_object sets it up
|
||||
if (
|
||||
not self.autotracker_init[camera]
|
||||
not self.autotracker_init.get(camera)
|
||||
or self.calibrating[camera]
|
||||
or self.tracked_object[camera] is not None
|
||||
):
|
||||
|
||||
+10
-9
@@ -344,16 +344,17 @@ class OnvifController:
|
||||
autotracking_config.enabled_in_config and autotracking_config.enabled
|
||||
)
|
||||
|
||||
# autotracking-only: status request and service capabilities
|
||||
if autotracking_enabled:
|
||||
status_request = ptz.create_type("GetStatus")
|
||||
status_request.ProfileToken = profile.token
|
||||
self.cams[camera_name]["status_request"] = status_request
|
||||
# these are local and cost nothing to build, and autotracking can be enabled
|
||||
# after a camera is initialized, so always create them rather than baking the
|
||||
# current config value into init state
|
||||
status_request = ptz.create_type("GetStatus")
|
||||
status_request.ProfileToken = profile.token
|
||||
self.cams[camera_name]["status_request"] = status_request
|
||||
|
||||
service_capabilities_request = ptz.create_type("GetServiceCapabilities")
|
||||
self.cams[camera_name]["service_capabilities_request"] = (
|
||||
service_capabilities_request
|
||||
)
|
||||
service_capabilities_request = ptz.create_type("GetServiceCapabilities")
|
||||
self.cams[camera_name]["service_capabilities_request"] = (
|
||||
service_capabilities_request
|
||||
)
|
||||
|
||||
# setup relative move request when FOV relative movement is supported
|
||||
if (
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"""Test camera user and password cleanup."""
|
||||
|
||||
import multiprocessing as mp
|
||||
import unittest
|
||||
|
||||
from frigate.output.birdseye import get_canvas_shape
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.output.birdseye import BirdsEyeFrameManager, get_canvas_shape
|
||||
|
||||
|
||||
class TestBirdseye(unittest.TestCase):
|
||||
@@ -45,3 +47,70 @@ class TestBirdseye(unittest.TestCase):
|
||||
canvas_width, canvas_height = get_canvas_shape(width, height)
|
||||
assert canvas_width == width # width will be the same
|
||||
assert canvas_height != height
|
||||
|
||||
|
||||
class TestBirdseyeCameraOrder(unittest.TestCase):
|
||||
"""Test that birdseye reacts to camera order changes without a restart."""
|
||||
|
||||
def setUp(self):
|
||||
config = {
|
||||
"mqtt": {"enabled": False},
|
||||
"birdseye": {"enabled": True, "mode": "continuous"},
|
||||
"cameras": {
|
||||
camera: {
|
||||
"ffmpeg": {
|
||||
"inputs": [
|
||||
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
|
||||
]
|
||||
},
|
||||
"detect": {"height": 1080, "width": 1920, "fps": 5},
|
||||
}
|
||||
for camera in ("back", "front", "side")
|
||||
},
|
||||
}
|
||||
self.config = FrigateConfig(**config)
|
||||
self.manager = BirdsEyeFrameManager(self.config, mp.Event())
|
||||
|
||||
# mark every camera as continuously active with no frame to draw, which
|
||||
# exercises the layout without needing real yuv frames
|
||||
for camera_data in self.manager.cameras.values():
|
||||
camera_data["current_frame"] = None
|
||||
camera_data["current_frame_time"] = 1.0
|
||||
camera_data["last_active_frame"] = 1.0
|
||||
|
||||
def layout_order(self) -> list[str]:
|
||||
"""Return the cameras in the order the current layout renders them."""
|
||||
return [position[0] for row in self.manager.camera_layout for position in row]
|
||||
|
||||
def test_layout_uses_configured_order(self):
|
||||
"""Test the layout is sorted by order, then by name when tied."""
|
||||
self.config.cameras["side"].birdseye.order = 0
|
||||
self.config.cameras["back"].birdseye.order = 10
|
||||
self.config.cameras["front"].birdseye.order = 20
|
||||
|
||||
self.manager.update_frame()
|
||||
|
||||
assert self.layout_order() == ["side", "back", "front"]
|
||||
|
||||
def test_order_change_rebuilds_layout(self):
|
||||
"""Test a reorder relayouts even though the active cameras are unchanged."""
|
||||
self.manager.update_frame()
|
||||
assert self.layout_order() == ["back", "front", "side"]
|
||||
|
||||
# a stable active set means only an order change can reset the layout,
|
||||
# which is what a settings reorder publishes to this process
|
||||
self.config.cameras["side"].birdseye.order = -10
|
||||
|
||||
_, layout_changed = self.manager.update_frame()
|
||||
|
||||
assert layout_changed
|
||||
assert self.layout_order() == ["side", "back", "front"]
|
||||
|
||||
def test_unchanged_order_keeps_layout(self):
|
||||
"""Test a repeat update with no order change doesn't reset the layout."""
|
||||
self.manager.update_frame()
|
||||
|
||||
_, layout_changed = self.manager.update_frame()
|
||||
|
||||
assert not layout_changed
|
||||
assert self.layout_order() == ["back", "front", "side"]
|
||||
|
||||
@@ -21,8 +21,12 @@ class TestGpuStats(unittest.TestCase):
|
||||
@patch("frigate.util.services.time.sleep")
|
||||
@patch("frigate.util.services.time.monotonic")
|
||||
@patch("frigate.util.services._read_intel_drm_fdinfo")
|
||||
def test_intel_gpu_stats_fdinfo(self, read_fdinfo, monotonic, sleep, get_names):
|
||||
@patch("frigate.util.services._enumerate_drm_devices")
|
||||
def test_intel_gpu_stats_fdinfo(
|
||||
self, drm_devices, read_fdinfo, monotonic, sleep, get_names
|
||||
):
|
||||
# 1 second of wall clock between snapshots
|
||||
drm_devices.return_value = {"0000:00:02.0": "i915"}
|
||||
monotonic.side_effect = [0.0, 1.0]
|
||||
get_names.return_value = {"0000:00:02.0": "Intel Graphics"}
|
||||
|
||||
@@ -96,13 +100,15 @@ class TestGpuStats(unittest.TestCase):
|
||||
@patch("frigate.util.services.time.sleep")
|
||||
@patch("frigate.util.services.time.monotonic")
|
||||
@patch("frigate.util.services._read_intel_drm_fdinfo")
|
||||
@patch("frigate.util.services._enumerate_drm_devices")
|
||||
def test_intel_gpu_stats_xe_capacity(
|
||||
self, read_fdinfo, monotonic, sleep, get_names
|
||||
self, drm_devices, read_fdinfo, monotonic, sleep, get_names
|
||||
):
|
||||
# Xe engines report cumulative cycles paired with total cycles, plus a
|
||||
# per-class capacity. drm-cycles-* is summed across every instance of a
|
||||
# class, so on Battlemage (capacity 2 for vcs/vecs) busy/total must be
|
||||
# divided by capacity to land in 0-100%.
|
||||
drm_devices.return_value = {"0000:03:00.0": "xe"}
|
||||
monotonic.side_effect = [0.0, 1.0]
|
||||
get_names.return_value = {"0000:03:00.0": "Intel Arc"}
|
||||
|
||||
@@ -150,7 +156,145 @@ class TestGpuStats(unittest.TestCase):
|
||||
},
|
||||
}
|
||||
|
||||
@patch("frigate.stats.intel_gpu_info.intel_gpu_name_resolver.get_names")
|
||||
@patch("frigate.util.services.time.sleep")
|
||||
@patch("frigate.util.services._read_intel_drm_fdinfo")
|
||||
def test_intel_gpu_stats_no_clients(self, read_fdinfo):
|
||||
@patch("frigate.util.services._enumerate_drm_devices")
|
||||
def test_intel_gpu_stats_no_clients_reports_idle(
|
||||
self, drm_devices, read_fdinfo, sleep, get_names
|
||||
):
|
||||
# The device exists but nothing holds it open, e.g. while camera
|
||||
# processes are restarting. This is an idle state, not an error:
|
||||
# returning None here would latch the hwaccel error cooldown and
|
||||
# blank GPU stats for an hour over a momentary gap.
|
||||
drm_devices.return_value = {"0000:00:02.0": "i915"}
|
||||
read_fdinfo.return_value = {}
|
||||
get_names.return_value = {"0000:00:02.0": "Intel Graphics"}
|
||||
|
||||
assert get_intel_gpu_stats(None) == {
|
||||
"0000:00:02.0": {
|
||||
"name": "Intel Graphics",
|
||||
"vendor": "intel",
|
||||
"gpu": "0.0%",
|
||||
"mem": "-%",
|
||||
"compute": "0.0%",
|
||||
"dec": "0.0%",
|
||||
},
|
||||
}
|
||||
# Idle short-circuits before spending the sample window
|
||||
sleep.assert_not_called()
|
||||
read_fdinfo.assert_called_once()
|
||||
|
||||
@patch("frigate.util.services.time.sleep")
|
||||
@patch("frigate.util.services._read_intel_drm_fdinfo")
|
||||
@patch("frigate.util.services._enumerate_drm_devices")
|
||||
def test_intel_gpu_stats_clients_without_engine_counters(
|
||||
self, drm_devices, read_fdinfo, sleep
|
||||
):
|
||||
# i915 publishes drm-driver/drm-pdev/drm-client-id but no drm-engine-*
|
||||
# lines while GuC submission is active on kernels older than 6.5, so
|
||||
# clients are found with nothing to sample. Reporting idle here would
|
||||
# be a lie, and sampling a second time cannot help.
|
||||
drm_devices.return_value = {"0000:00:02.0": "i915"}
|
||||
read_fdinfo.return_value = {
|
||||
("0000:00:02.0", "48", "1109"): {
|
||||
"driver": "i915",
|
||||
"pid": "1109",
|
||||
"engines": {},
|
||||
},
|
||||
("0000:00:02.0", "51", "1258"): {
|
||||
"driver": "i915",
|
||||
"pid": "1258",
|
||||
"engines": {},
|
||||
},
|
||||
}
|
||||
|
||||
assert get_intel_gpu_stats(None) is None
|
||||
sleep.assert_not_called()
|
||||
read_fdinfo.assert_called_once()
|
||||
|
||||
@patch("frigate.util.services._read_intel_drm_fdinfo")
|
||||
@patch("frigate.util.services._enumerate_drm_devices")
|
||||
def test_intel_gpu_stats_no_intel_device(self, drm_devices, read_fdinfo):
|
||||
# Only a non-Intel GPU is visible in sysfs; /proc is never scanned
|
||||
drm_devices.return_value = {"0000:01:00.0": "nvidia"}
|
||||
|
||||
assert get_intel_gpu_stats(None) is None
|
||||
read_fdinfo.assert_not_called()
|
||||
|
||||
@patch("frigate.util.services._read_intel_drm_fdinfo")
|
||||
@patch("frigate.util.services._enumerate_drm_devices")
|
||||
@patch("frigate.util.services._resolve_intel_gpu_pdev")
|
||||
def test_intel_gpu_stats_unresolvable_device_hint(
|
||||
self, resolve_pdev, drm_devices, read_fdinfo
|
||||
):
|
||||
# A configured intel_gpu_device that cannot be resolved is a config
|
||||
# error, not a reason to silently fall back to reporting all GPUs
|
||||
resolve_pdev.return_value = None
|
||||
|
||||
assert get_intel_gpu_stats("/dev/dri/renderD999") is None
|
||||
drm_devices.assert_not_called()
|
||||
read_fdinfo.assert_not_called()
|
||||
|
||||
@patch("frigate.util.services._read_intel_drm_fdinfo")
|
||||
@patch("frigate.util.services._enumerate_drm_devices")
|
||||
@patch("frigate.util.services._resolve_intel_gpu_pdev")
|
||||
def test_intel_gpu_stats_hint_resolves_to_non_intel_gpu(
|
||||
self, resolve_pdev, drm_devices, read_fdinfo
|
||||
):
|
||||
# card numbering can reorder across reboots on multi-GPU hosts, so a
|
||||
# configured hint may point at another vendor's card; call it out
|
||||
# instead of reporting nothing
|
||||
resolve_pdev.return_value = "0000:01:00.0"
|
||||
drm_devices.return_value = {
|
||||
"0000:00:02.0": "i915",
|
||||
"0000:01:00.0": "nvidia",
|
||||
}
|
||||
|
||||
assert get_intel_gpu_stats("/dev/dri/card0") is None
|
||||
read_fdinfo.assert_not_called()
|
||||
|
||||
@patch("frigate.util.services._read_intel_drm_fdinfo")
|
||||
@patch("frigate.util.services._enumerate_drm_devices")
|
||||
def test_intel_gpu_stats_unreadable_proc(self, drm_devices, read_fdinfo):
|
||||
# A scan failure (None) is a different condition than a scan that
|
||||
# finds no clients ({}) and must not report idle
|
||||
drm_devices.return_value = {"0000:00:02.0": "i915"}
|
||||
read_fdinfo.return_value = None
|
||||
|
||||
assert get_intel_gpu_stats(None) is None
|
||||
|
||||
@patch("frigate.stats.intel_gpu_info.intel_gpu_name_resolver.get_names")
|
||||
@patch("frigate.util.services.time.sleep")
|
||||
@patch("frigate.util.services.time.monotonic")
|
||||
@patch("frigate.util.services._read_intel_drm_fdinfo")
|
||||
@patch("frigate.util.services._enumerate_drm_devices")
|
||||
def test_intel_gpu_stats_clients_lost_between_samples(
|
||||
self, drm_devices, read_fdinfo, monotonic, sleep, get_names
|
||||
):
|
||||
# Clients disappearing during the sample window is transient process
|
||||
# churn, so report idle rather than latching an error
|
||||
drm_devices.return_value = {"0000:00:02.0": "i915"}
|
||||
monotonic.side_effect = [0.0, 1.0]
|
||||
get_names.return_value = {"0000:00:02.0": "Intel Graphics"}
|
||||
read_fdinfo.side_effect = [
|
||||
{
|
||||
("0000:00:02.0", "1", "100"): {
|
||||
"driver": "i915",
|
||||
"pid": "100",
|
||||
"engines": {"video": (5_000_000_000, 0, 1)},
|
||||
},
|
||||
},
|
||||
{},
|
||||
]
|
||||
|
||||
assert get_intel_gpu_stats(None) == {
|
||||
"0000:00:02.0": {
|
||||
"name": "Intel Graphics",
|
||||
"vendor": "intel",
|
||||
"gpu": "0.0%",
|
||||
"mem": "-%",
|
||||
"compute": "0.0%",
|
||||
"dec": "0.0%",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Tests for autotracker state that must survive runtime config changes.
|
||||
|
||||
Regression coverage for a family of bugs where per-camera autotracker state was
|
||||
built once at startup and never revisited. A camera that is added or enabled
|
||||
after startup, or has autotracking enabled from the UI, would either raise a
|
||||
KeyError on the autotracker thread or silently keep the wrong state:
|
||||
|
||||
- autotracker_init only got an entry for cameras enabled when PtzAutoTracker was
|
||||
constructed, so runtime-enabled cameras raised KeyError on lookup.
|
||||
- ptz_metrics autotracker_enabled is what the camera processes read, but nothing
|
||||
updated it when autotracking was enabled through a config save, so it stayed
|
||||
False and the tracker never built a motion estimator.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from frigate.camera import PTZMetrics
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.ptz.autotrack import PtzAutoTracker
|
||||
|
||||
CAMERA = "ptz_cam"
|
||||
|
||||
|
||||
def _config(autotracking_enabled: bool) -> FrigateConfig:
|
||||
return FrigateConfig(
|
||||
**{
|
||||
"mqtt": {"enabled": False},
|
||||
"cameras": {
|
||||
CAMERA: {
|
||||
"ffmpeg": {
|
||||
"inputs": [
|
||||
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
|
||||
]
|
||||
},
|
||||
"detect": {"width": 1920, "height": 1080},
|
||||
"zones": {"zone": {"coordinates": "0,0,1,0,1,1,0,1"}},
|
||||
"onvif": {
|
||||
"host": "10.0.0.1",
|
||||
"autotracking": {
|
||||
"enabled": autotracking_enabled,
|
||||
"required_zones": ["zone"],
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _make_tracker(autotracking_enabled: bool = True) -> PtzAutoTracker:
|
||||
"""Build a PtzAutoTracker without invoking __init__, which would try to set up
|
||||
onvif over the network. Only the config/metrics state is relevant here."""
|
||||
tracker = PtzAutoTracker.__new__(PtzAutoTracker)
|
||||
tracker.config = _config(autotracking_enabled)
|
||||
tracker.ptz_metrics = {CAMERA: PTZMetrics(autotracker_enabled=False)}
|
||||
tracker.onvif = MagicMock()
|
||||
tracker.config_subscriber = MagicMock()
|
||||
tracker.autotracker_init = {}
|
||||
tracker.calibrating = {}
|
||||
tracker.tracked_object = {}
|
||||
return tracker
|
||||
|
||||
|
||||
class TestAutotrackerInitGuards(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_camera_maintenance_returns_early_when_not_initialized(self) -> None:
|
||||
# a camera enabled at runtime has no autotracker_init entry, which used to
|
||||
# raise KeyError and kill the autotracker thread for every camera
|
||||
tracker = _make_tracker()
|
||||
self.assertNotIn(CAMERA, tracker.autotracker_init)
|
||||
|
||||
await tracker.camera_maintenance(CAMERA)
|
||||
|
||||
tracker.onvif.get_camera_status.assert_not_called()
|
||||
|
||||
async def test_camera_maintenance_returns_early_when_init_incomplete(self) -> None:
|
||||
# autotracker_init is seeded False for enabled cameras before setup runs
|
||||
tracker = _make_tracker()
|
||||
tracker.autotracker_init[CAMERA] = False
|
||||
|
||||
await tracker.camera_maintenance(CAMERA)
|
||||
|
||||
tracker.onvif.get_camera_status.assert_not_called()
|
||||
|
||||
|
||||
class TestAutotrackerMetricSync(unittest.TestCase):
|
||||
def test_metric_follows_config_when_enabled_by_update(self) -> None:
|
||||
# autotracking enabled via a config save: the metric was seeded False when
|
||||
# the camera was added and nothing else updates it
|
||||
tracker = _make_tracker(autotracking_enabled=True)
|
||||
metrics = tracker.ptz_metrics[CAMERA]
|
||||
self.assertFalse(metrics.autotracker_enabled.value)
|
||||
|
||||
tracker.config_subscriber.check_for_updates.return_value = {"onvif": [CAMERA]}
|
||||
tracker.check_for_updates()
|
||||
|
||||
self.assertTrue(metrics.autotracker_enabled.value)
|
||||
|
||||
def test_metric_follows_config_when_disabled_by_update(self) -> None:
|
||||
tracker = _make_tracker(autotracking_enabled=False)
|
||||
metrics = tracker.ptz_metrics[CAMERA]
|
||||
metrics.autotracker_enabled.value = True
|
||||
|
||||
tracker.config_subscriber.check_for_updates.return_value = {
|
||||
"autotracking": [CAMERA]
|
||||
}
|
||||
tracker.check_for_updates()
|
||||
|
||||
self.assertFalse(metrics.autotracker_enabled.value)
|
||||
|
||||
def test_metric_sync_skips_camera_without_metrics(self) -> None:
|
||||
# `add` reaches the maintainer and the autotracker on separate threads with
|
||||
# no ordering guarantee, so the metrics may not exist yet
|
||||
tracker = _make_tracker()
|
||||
tracker.ptz_metrics = {}
|
||||
tracker.config_subscriber.check_for_updates.return_value = {"add": [CAMERA]}
|
||||
|
||||
tracker.check_for_updates()
|
||||
|
||||
def test_metric_sync_skips_unknown_camera(self) -> None:
|
||||
tracker = _make_tracker()
|
||||
tracker.config_subscriber.check_for_updates.return_value = {
|
||||
"add": ["not_in_config"]
|
||||
}
|
||||
|
||||
tracker.check_for_updates()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,147 @@
|
||||
"""Tests for ONVIF init state that must not depend on the autotracking config.
|
||||
|
||||
Regression coverage for a camera that is initialized while autotracking is off and
|
||||
has it enabled later, which is the normal wizard flow: set the camera up first,
|
||||
configure autotracking afterwards. The autotracking-only request objects used to
|
||||
be created only when autotracking was enabled at init time, so the camera was left
|
||||
with init=True but no status_request. get_camera_status skips its re-init branch
|
||||
when init is True, so it went straight to the missing key and raised KeyError on
|
||||
the tracking thread.
|
||||
|
||||
The request objects are built from the locally parsed WSDL and cost no network, so
|
||||
they are always created and init=True now implies they exist.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.ptz.onvif import OnvifController
|
||||
|
||||
CAMERA = "ptz_cam"
|
||||
|
||||
|
||||
def _config(autotracking_enabled: bool) -> FrigateConfig:
|
||||
return FrigateConfig(
|
||||
**{
|
||||
"mqtt": {"enabled": False},
|
||||
"cameras": {
|
||||
CAMERA: {
|
||||
"ffmpeg": {
|
||||
"inputs": [
|
||||
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
|
||||
]
|
||||
},
|
||||
"detect": {"width": 1920, "height": 1080},
|
||||
"zones": {"zone": {"coordinates": "0,0,1,0,1,1,0,1"}},
|
||||
"onvif": {
|
||||
"host": "10.0.0.1",
|
||||
"autotracking": {
|
||||
"enabled": autotracking_enabled,
|
||||
"required_zones": ["zone"],
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _make_profile() -> MagicMock:
|
||||
profile = MagicMock()
|
||||
profile.token = "profile_1"
|
||||
profile.Name = "MainStream"
|
||||
profile.VideoEncoderConfiguration = MagicMock()
|
||||
ptz_config = MagicMock()
|
||||
ptz_config.token = "ptz_config_1"
|
||||
ptz_config.DefaultContinuousPanTiltVelocitySpace = "space"
|
||||
ptz_config.DefaultContinuousZoomVelocitySpace = "space"
|
||||
profile.PTZConfiguration = ptz_config
|
||||
return profile
|
||||
|
||||
|
||||
def _make_onvif_camera() -> MagicMock:
|
||||
"""A camera that supports PTZ but nothing optional, so init takes the simplest
|
||||
path through the feature detection below."""
|
||||
onvif = MagicMock()
|
||||
onvif.update_xaddrs = AsyncMock()
|
||||
|
||||
video_source = MagicMock()
|
||||
video_source.token = "video_source_1"
|
||||
|
||||
media = MagicMock()
|
||||
media.GetProfiles = AsyncMock(return_value=[_make_profile()])
|
||||
media.GetVideoSources = AsyncMock(return_value=[video_source])
|
||||
onvif.create_media_service = AsyncMock(return_value=media)
|
||||
onvif.get_definition = MagicMock(return_value={"ptz": "definition"})
|
||||
|
||||
ptz = MagicMock()
|
||||
# create_type is a local WSDL lookup, so tag the result to assert on it later
|
||||
ptz.create_type = MagicMock(side_effect=lambda name: MagicMock(request_type=name))
|
||||
ptz.GetConfigurationOptions = AsyncMock(side_effect=Exception("not supported"))
|
||||
onvif.create_ptz_service = AsyncMock(return_value=ptz)
|
||||
onvif.create_imaging_service = AsyncMock(side_effect=Exception("not supported"))
|
||||
return onvif
|
||||
|
||||
|
||||
def _make_controller(autotracking_enabled: bool) -> OnvifController:
|
||||
"""Build a controller without invoking __init__, which would start an event loop
|
||||
thread and reach out to the camera."""
|
||||
config = _config(autotracking_enabled)
|
||||
controller = OnvifController.__new__(OnvifController)
|
||||
controller.config = config
|
||||
controller.cams = {CAMERA: {"onvif": _make_onvif_camera(), "init": False}}
|
||||
controller.failed_cams = {}
|
||||
controller.camera_configs = {CAMERA: config.cameras[CAMERA]}
|
||||
controller.ptz_metrics = {CAMERA: MagicMock()}
|
||||
return controller
|
||||
|
||||
|
||||
class TestOnvifInitRequests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_status_request_created_when_autotracking_disabled(self) -> None:
|
||||
# the wizard flow: onvif configured first, autotracking enabled later
|
||||
controller = _make_controller(autotracking_enabled=False)
|
||||
|
||||
self.assertTrue(await controller._init_onvif(CAMERA))
|
||||
|
||||
cam = controller.cams[CAMERA]
|
||||
self.assertTrue(cam["init"])
|
||||
self.assertIn("status_request", cam)
|
||||
self.assertIn("service_capabilities_request", cam)
|
||||
|
||||
async def test_status_request_created_when_autotracking_enabled(self) -> None:
|
||||
controller = _make_controller(autotracking_enabled=True)
|
||||
|
||||
self.assertTrue(await controller._init_onvif(CAMERA))
|
||||
|
||||
cam = controller.cams[CAMERA]
|
||||
self.assertIn("status_request", cam)
|
||||
self.assertIn("service_capabilities_request", cam)
|
||||
|
||||
async def test_init_implies_status_request_exists(self) -> None:
|
||||
# the invariant get_camera_status relies on: it skips re-init when init is
|
||||
# True and then reads status_request without guarding
|
||||
for autotracking_enabled in (True, False):
|
||||
with self.subTest(autotracking_enabled=autotracking_enabled):
|
||||
controller = _make_controller(autotracking_enabled)
|
||||
|
||||
await controller._init_onvif(CAMERA)
|
||||
|
||||
cam = controller.cams[CAMERA]
|
||||
if cam["init"]:
|
||||
self.assertEqual(cam["status_request"].request_type, "GetStatus")
|
||||
|
||||
async def test_requests_built_without_contacting_camera(self) -> None:
|
||||
# create_type is a local WSDL lookup; cameras that do not implement
|
||||
# GetServiceCapabilities must not be asked about it during init
|
||||
controller = _make_controller(autotracking_enabled=False)
|
||||
|
||||
await controller._init_onvif(CAMERA)
|
||||
|
||||
ptz = controller.cams[CAMERA]["ptz"]
|
||||
ptz.GetServiceCapabilities.assert_not_called()
|
||||
ptz.GetStatus.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+164
-24
@@ -285,6 +285,10 @@ _XE_ENGINE_KEYS = {
|
||||
"vecs": "video-enhance",
|
||||
"ccs": "compute",
|
||||
}
|
||||
_INTEL_DRM_DRIVERS = ("i915", "xe")
|
||||
_PCI_ADDRESS_RE = re.compile(
|
||||
r"^[0-9a-fA-F]{4}:[0-9a-fA-F]{2}:[0-9a-fA-F]{2}\.[0-9a-fA-F]$"
|
||||
)
|
||||
|
||||
|
||||
def _resolve_intel_gpu_pdev(device: str | None) -> str | None:
|
||||
@@ -294,29 +298,70 @@ def _resolve_intel_gpu_pdev(device: str | None) -> str | None:
|
||||
if not device:
|
||||
return None
|
||||
|
||||
if re.match(r"^[0-9a-fA-F]{4}:[0-9a-fA-F]{2}:[0-9a-fA-F]{2}\.[0-9a-fA-F]$", device):
|
||||
if _PCI_ADDRESS_RE.match(device):
|
||||
return device
|
||||
|
||||
name = os.path.basename(device.rstrip("/"))
|
||||
try:
|
||||
return os.path.basename(os.path.realpath(f"/sys/class/drm/{name}/device"))
|
||||
pdev = os.path.basename(os.path.realpath(f"/sys/class/drm/{name}/device"))
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
# realpath does not raise on a nonexistent node; it returns the input
|
||||
# path unchanged, so validate the result actually looks like a PCI
|
||||
# address before trusting it.
|
||||
return pdev if _PCI_ADDRESS_RE.match(pdev) else None
|
||||
|
||||
def _read_intel_drm_fdinfo(target_pdev: str | None) -> dict:
|
||||
|
||||
def _enumerate_drm_devices() -> dict[str, str]:
|
||||
"""Map each PCI-attached DRM device to its bound kernel driver.
|
||||
|
||||
Reads /sys/class/drm, which reflects every GPU on the host even when only
|
||||
some render nodes are mapped into the container, so device presence can be
|
||||
verified without /dev access. Returns {pdev: driver}, e.g.
|
||||
{"0000:00:02.0": "i915"}.
|
||||
"""
|
||||
devices: dict[str, str] = {}
|
||||
|
||||
try:
|
||||
entries = os.listdir("/sys/class/drm")
|
||||
except OSError:
|
||||
return devices
|
||||
|
||||
for entry in entries:
|
||||
device_dir = f"/sys/class/drm/{entry}/device"
|
||||
pdev = os.path.basename(os.path.realpath(device_dir))
|
||||
|
||||
if not _PCI_ADDRESS_RE.match(pdev):
|
||||
continue
|
||||
|
||||
try:
|
||||
driver = os.path.basename(os.readlink(f"{device_dir}/driver"))
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
devices[pdev] = driver
|
||||
|
||||
return devices
|
||||
|
||||
|
||||
def _read_intel_drm_fdinfo(target_pdev: str | None) -> dict | None:
|
||||
"""Snapshot DRM fdinfo for every Intel client visible in /proc.
|
||||
|
||||
Returns a dict keyed by (pdev, drm-client-id, pid) so the same context
|
||||
seen via multiple file descriptors on a single process collapses to one
|
||||
entry.
|
||||
entry. Clients whose fdinfo carries no engine counters are still included
|
||||
with an empty "engines" dict so the caller can distinguish "clients exist
|
||||
but the kernel publishes no busyness" from "no clients at all". Returns
|
||||
None when /proc itself cannot be scanned, which is a different failure
|
||||
than a scan that finds nothing.
|
||||
"""
|
||||
snapshot: dict = {}
|
||||
|
||||
try:
|
||||
proc_entries = os.listdir("/proc")
|
||||
except OSError:
|
||||
return snapshot
|
||||
return None
|
||||
|
||||
for entry in proc_entries:
|
||||
if not entry.isdigit():
|
||||
@@ -400,41 +445,124 @@ def _read_intel_drm_fdinfo(target_pdev: str | None) -> dict:
|
||||
except (ValueError, IndexError):
|
||||
continue
|
||||
|
||||
if not engines:
|
||||
continue
|
||||
|
||||
snapshot[key] = {"driver": driver, "pid": entry, "engines": engines}
|
||||
|
||||
return snapshot
|
||||
|
||||
|
||||
def _idle_intel_gpu_stats(
|
||||
target_pdev: str | None, intel_pdevs: dict[str, str]
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
"""Build a 0% reading for the configured (or every) Intel GPU.
|
||||
|
||||
Used when the device is confirmed present but no DRM client is currently
|
||||
attached, e.g. while camera processes are restarting. That is an idle
|
||||
state, not a collection failure, so it must produce a valid reading:
|
||||
returning None would latch the hwaccel error cooldown and blank GPU stats
|
||||
for an hour over a momentary gap.
|
||||
"""
|
||||
from frigate.stats.intel_gpu_info import intel_gpu_name_resolver
|
||||
|
||||
names = intel_gpu_name_resolver.get_names()
|
||||
pdevs = [target_pdev] if target_pdev else sorted(intel_pdevs)
|
||||
|
||||
return {
|
||||
pdev: {
|
||||
"name": names.get(pdev) or "Intel iGPU",
|
||||
"vendor": "intel",
|
||||
"gpu": "0.0%",
|
||||
"mem": "-%",
|
||||
"compute": "0.0%",
|
||||
"dec": "0.0%",
|
||||
}
|
||||
for pdev in pdevs
|
||||
}
|
||||
|
||||
|
||||
def get_intel_gpu_stats(
|
||||
intel_gpu_device: str | None,
|
||||
) -> dict[str, dict[str, Any]] | None:
|
||||
"""Get stats by reading DRM fdinfo files, bucketed per-pdev.
|
||||
|
||||
Each DRM client FD exposes monotonic per-engine busy counters via
|
||||
/proc/<pid>/fdinfo/<fd> (i915 since kernel 5.19, Xe since first release).
|
||||
We sample twice and divide busy-time deltas by wall-clock to derive
|
||||
utilization. Render/3D and Compute are pooled into "compute"; Video and
|
||||
VideoEnhance into "dec". Overall "gpu" is the sum of those pools (clamped
|
||||
to 100%).
|
||||
/proc/<pid>/fdinfo/<fd>. For i915 this requires kernel 6.5 or newer:
|
||||
earlier kernels omit the per-engine counters whenever GuC submission is
|
||||
active, which is the default on 12th gen and newer. Xe has exposed them
|
||||
since its first release. We sample twice and divide busy-time deltas by
|
||||
wall-clock to derive utilization. Render/3D and Compute are pooled into
|
||||
"compute"; Video and VideoEnhance into "dec". Overall "gpu" is the sum of
|
||||
those pools (clamped to 100%).
|
||||
|
||||
The return value is keyed by the GPU's drm-pdev string so multiple Intel
|
||||
GPUs in the same system are reported separately. Each entry carries a
|
||||
"name" populated from OpenVINO (falling back to the pdev) so callers can
|
||||
surface a real device name in the UI.
|
||||
|
||||
A device that exists but has no attached DRM clients reports an idle 0%
|
||||
reading. None is returned only for durable failures (no Intel GPU, a bad
|
||||
intel_gpu_device config, unreadable /proc, or a kernel that publishes no
|
||||
counters), each of which logs a distinct warning, and the caller latches
|
||||
it against retries for an hour.
|
||||
"""
|
||||
from frigate.stats.intel_gpu_info import intel_gpu_name_resolver
|
||||
|
||||
target_pdev = _resolve_intel_gpu_pdev(intel_gpu_device)
|
||||
if intel_gpu_device and not target_pdev:
|
||||
logger.warning(
|
||||
"Unable to collect Intel GPU stats: configured intel_gpu_device %s "
|
||||
"does not exist or could not be resolved to a PCI device",
|
||||
intel_gpu_device,
|
||||
)
|
||||
return None
|
||||
|
||||
drm_devices = _enumerate_drm_devices()
|
||||
intel_pdevs = {
|
||||
pdev: driver
|
||||
for pdev, driver in drm_devices.items()
|
||||
if driver in _INTEL_DRM_DRIVERS
|
||||
}
|
||||
|
||||
if not intel_pdevs:
|
||||
logger.warning(
|
||||
"Unable to collect Intel GPU stats: no Intel GPU (i915/xe) found in "
|
||||
"/sys/class/drm. Check that the driver is loaded on the host"
|
||||
)
|
||||
return None
|
||||
|
||||
if target_pdev and target_pdev not in intel_pdevs:
|
||||
logger.warning(
|
||||
"Unable to collect Intel GPU stats: configured intel_gpu_device %s "
|
||||
"resolved to %s (driver: %s), which is not an Intel GPU",
|
||||
intel_gpu_device,
|
||||
target_pdev,
|
||||
drm_devices.get(target_pdev, "unknown"),
|
||||
)
|
||||
return None
|
||||
|
||||
snapshot_a = _read_intel_drm_fdinfo(target_pdev)
|
||||
if snapshot_a is None:
|
||||
logger.warning("Unable to collect Intel GPU stats: /proc could not be read")
|
||||
return None
|
||||
|
||||
if not snapshot_a:
|
||||
# No process currently holds the GPU open, e.g. while camera processes
|
||||
# are restarting. The device is confirmed present, so report idle
|
||||
# rather than an error; the next stats cycle re-samples normally.
|
||||
logger.debug("No active DRM clients for Intel GPU, reporting idle")
|
||||
return _idle_intel_gpu_stats(target_pdev, intel_pdevs)
|
||||
|
||||
if not any(client["engines"] for client in snapshot_a.values()):
|
||||
# Clients exist but the kernel published no busyness for them, so
|
||||
# there is nothing to sample and a second snapshot would not help.
|
||||
# i915 suppresses per-client engine counters while GuC submission is
|
||||
# active on kernels older than 6.5 (kernel commit 1324680a80eb lifted
|
||||
# this), which covers stock Debian 12 and Ubuntu 22.04 on 12th gen
|
||||
# and newer.
|
||||
logger.warning(
|
||||
"Unable to collect Intel GPU stats: no DRM fdinfo entries found"
|
||||
"%s. Check that /proc is readable and the i915/xe driver is loaded",
|
||||
f" for pdev {target_pdev}" if target_pdev else "",
|
||||
"Unable to collect Intel GPU stats: found %d DRM client(s) for %s but "
|
||||
"no per-engine counters. Kernel 6.5 or newer is required.",
|
||||
len(snapshot_a),
|
||||
"/".join(sorted({client["driver"] for client in snapshot_a.values()})),
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -443,12 +571,18 @@ def get_intel_gpu_stats(
|
||||
elapsed_ns = (time.monotonic() - start) * 1e9
|
||||
|
||||
snapshot_b = _read_intel_drm_fdinfo(target_pdev)
|
||||
if not snapshot_b or elapsed_ns <= 0:
|
||||
logger.warning(
|
||||
"Unable to collect Intel GPU stats: second DRM fdinfo sample was empty"
|
||||
)
|
||||
if snapshot_b is None:
|
||||
logger.warning("Unable to collect Intel GPU stats: /proc could not be read")
|
||||
return None
|
||||
|
||||
if not snapshot_b or elapsed_ns <= 0:
|
||||
# Every client disappeared during the sample window; transient by
|
||||
# definition, so report idle instead of latching an error.
|
||||
logger.debug(
|
||||
"No DRM clients persisted across Intel GPU samples, reporting idle"
|
||||
)
|
||||
return _idle_intel_gpu_stats(target_pdev, intel_pdevs)
|
||||
|
||||
def _new_engine_pct() -> dict[str, float]:
|
||||
return {"render": 0.0, "video": 0.0, "video-enhance": 0.0, "compute": 0.0}
|
||||
|
||||
@@ -460,6 +594,11 @@ def get_intel_gpu_stats(
|
||||
if not data_a or data_a["driver"] != data_b["driver"]:
|
||||
continue
|
||||
|
||||
# Skip before the setdefault below so a counter-less client cannot
|
||||
# register its pdev on its own.
|
||||
if not data_b["engines"]:
|
||||
continue
|
||||
|
||||
pdev = key[0]
|
||||
engine_pct = per_pdev_engine_pct.setdefault(pdev, _new_engine_pct())
|
||||
pid_pct = per_pdev_pid_pct.setdefault(pdev, {})
|
||||
@@ -491,11 +630,12 @@ def get_intel_gpu_stats(
|
||||
pid_pct[data_b["pid"]] = pid_pct.get(data_b["pid"], 0.0) + client_total
|
||||
|
||||
if not per_pdev_engine_pct:
|
||||
logger.warning(
|
||||
"Unable to collect Intel GPU stats: no per-engine counters available "
|
||||
"(i915 requires kernel >= 5.19)"
|
||||
# Clients were seen in both snapshots but none persisted as the same
|
||||
# (pdev, client-id, pid); process churn, so report idle.
|
||||
logger.debug(
|
||||
"No DRM clients persisted across Intel GPU samples, reporting idle"
|
||||
)
|
||||
return None
|
||||
return _idle_intel_gpu_stats(target_pdev, intel_pdevs)
|
||||
|
||||
names = intel_gpu_name_resolver.get_names()
|
||||
results: dict[str, dict[str, Any]] = {}
|
||||
|
||||
@@ -107,12 +107,7 @@
|
||||
},
|
||||
"npuUsage": "NPU Usage",
|
||||
"npuMemory": "NPU Memory",
|
||||
"npuTemperature": "NPU Temperature",
|
||||
"intelGpuWarning": {
|
||||
"title": "Intel GPU Stats Warning",
|
||||
"message": "GPU stats unavailable",
|
||||
"description": "This is a known bug in Intel's GPU stats reporting tools (intel_gpu_top) where it will break and repeatedly return a GPU usage of 0% even in cases where hardware acceleration and object detection are correctly running on the (i)GPU. This is not a Frigate bug. You can restart the host to temporarily fix the issue and confirm that the GPU is working correctly. This does not affect performance."
|
||||
}
|
||||
"npuTemperature": "NPU Temperature"
|
||||
},
|
||||
"otherProcesses": {
|
||||
"title": "Other Processes",
|
||||
|
||||
@@ -91,6 +91,7 @@ export default function BirdseyeCameraReorder({
|
||||
try {
|
||||
await axios.put("config/set", {
|
||||
requires_restart: 0,
|
||||
update_topic: "config/cameras/*/birdseye",
|
||||
config_data: { cameras: cameraUpdates },
|
||||
});
|
||||
await updateConfig();
|
||||
|
||||
@@ -153,11 +153,15 @@ export default function IconPicker({
|
||||
}
|
||||
|
||||
type IconRendererProps = {
|
||||
icon: IconType;
|
||||
icon: IconType | undefined;
|
||||
size?: number;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function IconRenderer({ icon, size, className }: IconRendererProps) {
|
||||
if (!icon) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <>{React.createElement(icon, { size, className })}</>;
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@ import { Textarea } from "../ui/textarea";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useIsAdmin } from "@/hooks/use-is-admin";
|
||||
import { isReplayCamera } from "@/utils/cameraUtil";
|
||||
import { isValidIconName } from "@/utils/iconUtil";
|
||||
|
||||
const EXPORT_OPTIONS = [
|
||||
"1",
|
||||
@@ -1066,7 +1067,11 @@ export function ExportContent({
|
||||
}
|
||||
>
|
||||
<IconRenderer
|
||||
icon={LuIcons[group.icon]}
|
||||
icon={
|
||||
isValidIconName(group.icon)
|
||||
? LuIcons[group.icon]
|
||||
: LuIcons.LuFolder
|
||||
}
|
||||
className="mr-2 size-4 text-secondary-foreground"
|
||||
/>
|
||||
<span className="truncate">{group.name}</span>
|
||||
|
||||
@@ -470,50 +470,6 @@ export default function GeneralMetrics({
|
||||
return Object.keys(series).length > 0 ? Object.values(series) : undefined;
|
||||
}, [statsHistory]);
|
||||
|
||||
// Check if Intel GPU has all 0% usage values (known bug)
|
||||
const showIntelGpuWarning = useMemo(() => {
|
||||
if (!statsHistory || statsHistory.length < 3) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const hasIntelGpu = Object.values(statsHistory[0]?.gpu_usages ?? {}).some(
|
||||
(stats) => stats.vendor === "intel",
|
||||
);
|
||||
|
||||
if (!hasIntelGpu) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if all GPU usage values are 0% across all stats
|
||||
let allZero = true;
|
||||
let hasDataPoints = false;
|
||||
|
||||
for (const stats of statsHistory) {
|
||||
if (!stats) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Object.values(stats.gpu_usages || {}).forEach((gpuStats) => {
|
||||
if (gpuStats.vendor !== "intel") {
|
||||
return;
|
||||
}
|
||||
if (gpuStats.gpu) {
|
||||
hasDataPoints = true;
|
||||
const gpuValue = parseFloat(gpuStats.gpu.slice(0, -1));
|
||||
if (!isNaN(gpuValue) && gpuValue > 0) {
|
||||
allZero = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!allZero) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return hasDataPoints && allZero;
|
||||
}, [statsHistory]);
|
||||
|
||||
// npu stats
|
||||
|
||||
const npuSeries = useMemo(() => {
|
||||
@@ -819,46 +775,8 @@ export default function GeneralMetrics({
|
||||
<>
|
||||
{statsHistory.length != 0 ? (
|
||||
<div className="rounded-lg bg-background_alt p-2.5 md:rounded-2xl">
|
||||
<div className="mb-5 flex flex-row items-center justify-between">
|
||||
<div className="mb-5">
|
||||
{t("general.hardwareInfo.gpuUsage")}
|
||||
{showIntelGpuWarning && (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className="flex flex-row items-center gap-1.5 text-yellow-600 focus:outline-none dark:text-yellow-500"
|
||||
aria-label={t(
|
||||
"general.hardwareInfo.intelGpuWarning.title",
|
||||
)}
|
||||
>
|
||||
<CiCircleAlert
|
||||
className="size-5"
|
||||
aria-label={t(
|
||||
"general.hardwareInfo.intelGpuWarning.title",
|
||||
)}
|
||||
/>
|
||||
<span className="text-sm">
|
||||
{t(
|
||||
"general.hardwareInfo.intelGpuWarning.message",
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80">
|
||||
<div className="space-y-2">
|
||||
<div className="font-semibold">
|
||||
{t(
|
||||
"general.hardwareInfo.intelGpuWarning.title",
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{t(
|
||||
"general.hardwareInfo.intelGpuWarning.description",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
{gpuSeries.map((series) => (
|
||||
<ThresholdBarGraph
|
||||
|
||||
Reference in New Issue
Block a user