mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-09-25 22:58:58 +03:00
Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6fce18f156 | ||
|
|
7001623ba2 | ||
|
|
f8c180319c | ||
|
|
8b5dc77891 | ||
|
|
3990a64988 | ||
|
|
f185e64379 | ||
|
|
635aee552e | ||
|
|
1834d9a22e | ||
|
|
f0d070899f | ||
|
|
92e2a8b444 | ||
|
|
a8ed8b8592 | ||
|
|
7f2fd71d12 | ||
|
|
274277b85e | ||
|
|
faa0b68bc4 | ||
|
|
c252e07ffb | ||
|
|
90e1fa756d | ||
|
|
ab43524fe2 | ||
|
|
51eb5058b3 | ||
|
|
aa72e555f5 | ||
|
|
af0ba19196 |
@@ -124,5 +124,7 @@ jobs:
|
||||
run: devcontainer exec --workspace-folder . bash -lc "python3 -u -m mypy --config-file frigate/mypy.ini frigate"
|
||||
- name: Check API spec is up to date
|
||||
run: devcontainer exec --workspace-folder . bash -lc "python3 generate_api_auth_spec.py --check"
|
||||
- name: Check analytics schema is up to date
|
||||
run: devcontainer exec --workspace-folder . bash -lc "python3 generate_analytics_schema.py --check"
|
||||
- name: Run unit tests in devcontainer
|
||||
run: devcontainer exec --workspace-folder . bash -lc "python3 -u -m unittest"
|
||||
|
||||
@@ -5,3 +5,4 @@
|
||||
/docker/rockchip/ @MarcA711
|
||||
/docker/rocm/ @harakas
|
||||
/docker/hailo8l/ @spanner3003
|
||||
/docker/deepx/ @sixfab
|
||||
|
||||
Executable
+131
@@ -0,0 +1,131 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Installs the DEEPX NPU kernel driver and the DX-RT runtime on the Docker host,
|
||||
# then enables the vendor's dxrt.service. A container cannot load kernel
|
||||
# modules, so this runs outside the image; the driver creates the /dev/dxrt*
|
||||
# nodes and the daemon multiplexes the NPU across host and container.
|
||||
#
|
||||
# Driver, runtime and firmware versions must agree or inference hangs instead
|
||||
# of failing at startup. The set this script installs is pinned in
|
||||
# driver_version, runtime_version and firmware_version below; move them
|
||||
# together, never one at a time.
|
||||
#
|
||||
# DEEPX NPU support in Frigate is maintained by Sixfab (https://sixfab.com).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
driver_version="v2.6.0"
|
||||
# the commit the tag resolves to, since DEEPX signs neither tags nor releases
|
||||
# and this is compiled and installed as root. Update both together
|
||||
driver_commit="7074748e7104f470b02f517583abba652b3f05fa"
|
||||
firmware_version="v2.7.4"
|
||||
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y git build-essential "linux-headers-$(uname -r)" pciutils wget
|
||||
|
||||
if ! lspci -d 1ff4: | grep -q .; then
|
||||
echo "No DEEPX device found on the PCIe bus (lspci -d 1ff4:)."
|
||||
echo "Check that the module is seated correctly before continuing."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# fetch the pinned commit rather than cloning the tag, so a retag cannot swap
|
||||
# in different source. The build directory is reused so a second run after a
|
||||
# failure does not stop on the directory already being there
|
||||
mkdir -p dx_rt_npu_linux_driver
|
||||
cd dx_rt_npu_linux_driver
|
||||
git init -q
|
||||
git remote get-url origin > /dev/null 2>&1 ||
|
||||
git remote add origin https://github.com/DEEPX-AI/dx_rt_npu_linux_driver.git
|
||||
git fetch --depth 1 origin "${driver_commit}"
|
||||
git checkout -q FETCH_HEAD
|
||||
|
||||
fetched_commit=$(git rev-parse HEAD)
|
||||
if [[ "${fetched_commit}" != "${driver_commit}" ]]; then
|
||||
echo "Fetched commit ${fetched_commit} does not match pinned driver_commit ${driver_commit}."
|
||||
echo "Refusing to build unverified driver source."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd modules
|
||||
|
||||
sudo ./build.sh -c install --reload
|
||||
|
||||
sudo depmod -A
|
||||
|
||||
# dx_dma is the PCIe transport, dxrt_driver the NPU driver on top of it
|
||||
for module in dx_dma dxrt_driver; do
|
||||
if ! sudo modprobe "${module}"; then
|
||||
echo "Unable to load the ${module} kernel module, common reasons are:"
|
||||
echo "- Secure Boot is enabled and is rejecting the unsigned module."
|
||||
echo "- The running kernel does not match the installed linux-headers."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
if ! compgen -G "/dev/dxrt*" > /dev/null; then
|
||||
echo "Modules loaded but no /dev/dxrt* device node appeared."
|
||||
echo "Run ./sanity_check.sh from the driver repo to diagnose."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
runtime_version="v3.4.0"
|
||||
declare -A runtime_sha256=(
|
||||
[amd64]="736cfef009ce9e974ab1ab610d867239d19d72a426a53e367ddcbd53297b6e20"
|
||||
[arm64]="eb6107f5f02f2ad76ae89f414e8b5f346f34fbc6f0888236136853a26be6f6a0"
|
||||
)
|
||||
|
||||
runtime_release="${runtime_version#v}"
|
||||
deb_arch=$(dpkg --print-architecture)
|
||||
deb_file="/tmp/libdxrt-bin_${runtime_release}_${deb_arch}.deb"
|
||||
|
||||
wget -qO "${deb_file}" \
|
||||
"https://raw.githubusercontent.com/DEEPX-AI/dx_rt/${runtime_version}/release/${runtime_release}/libdxrt-bin_${runtime_release}_${deb_arch}.deb"
|
||||
|
||||
expected_sha256="${runtime_sha256[${deb_arch}]:-}"
|
||||
if [[ -z "${expected_sha256}" ]]; then
|
||||
echo "No pinned SHA-256 for architecture ${deb_arch}; refusing to install."
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$(sha256sum "${deb_file}" | cut -d' ' -f1)" != "${expected_sha256}" ]]; then
|
||||
echo "SHA-256 mismatch for ${deb_file}; refusing to install."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
sudo dpkg -i "${deb_file}"
|
||||
sudo ldconfig
|
||||
rm -f "${deb_file}"
|
||||
|
||||
sudo cp /usr/share/libdxrt-bin/service/dxrt.service /etc/systemd/system/
|
||||
|
||||
# With an endpoint set, dxrtd binds that path only, so the socket goes in a
|
||||
# directory Frigate can mount (kept across restarts so the mount stays valid)
|
||||
# and a symlink at the default /tmp path keeps host tools that do not set the
|
||||
# variable working through their own fallback.
|
||||
sudo mkdir -p /etc/systemd/system/dxrt.service.d
|
||||
sudo tee /etc/systemd/system/dxrt.service.d/frigate.conf > /dev/null <<'UNIT'
|
||||
[Service]
|
||||
RuntimeDirectory=dxrt
|
||||
RuntimeDirectoryMode=0755
|
||||
RuntimeDirectoryPreserve=yes
|
||||
Environment=DXRT_DYNAMIC_IPC_ENDPOINT=/run/dxrt/dxrt_dynamic_ipc.sock
|
||||
ExecStartPost=/bin/ln -sfn /run/dxrt/dxrt_dynamic_ipc.sock /tmp/dxrt_dynamic_ipc.sock
|
||||
UNIT
|
||||
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable dxrt.service
|
||||
sudo systemctl restart dxrt.service
|
||||
|
||||
if ! sudo systemctl is-active --quiet dxrt.service; then
|
||||
echo "dxrt.service did not start. Check: sudo journalctl -u dxrt.service"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "DEEPX driver and runtime installation complete."
|
||||
echo "Driver version: $(modinfo -F version dxrt_driver) (expected ${driver_version#v})"
|
||||
echo "Runtime version: ${runtime_release}"
|
||||
echo "Device node(s): $(echo /dev/dxrt*)"
|
||||
echo
|
||||
echo "This driver expects NPU firmware ${firmware_version}. Check it with:"
|
||||
echo " dxrt-cli --status"
|
||||
echo "Update the module if it does not match before starting Frigate."
|
||||
@@ -381,6 +381,7 @@ FROM deps AS frigate
|
||||
|
||||
WORKDIR /opt/frigate/
|
||||
COPY --from=rootfs / /
|
||||
ENV FRIGATE_IMAGE_VARIANT=standard
|
||||
|
||||
# Pre-compile bytecode so a read-only rootfs doesn't force re-parsing the
|
||||
# source tree on every boot (pip-installed packages are already compiled)
|
||||
|
||||
@@ -37,6 +37,7 @@ device_globs=(
|
||||
"/dev/nvmap"
|
||||
"/dev/nvidia*"
|
||||
"/dev/memx*"
|
||||
"/dev/dxrt*"
|
||||
)
|
||||
|
||||
IFS=',' read -ra extra_globs <<< "${DEVICE_ACL_PATHS:-}"
|
||||
|
||||
@@ -25,6 +25,7 @@ RUN --mount=type=bind,from=rk-wheels,source=/rk-wheels,target=/deps/rk-wheels \
|
||||
|
||||
WORKDIR /opt/frigate/
|
||||
COPY --from=rootfs / /
|
||||
ENV FRIGATE_IMAGE_VARIANT=rk
|
||||
COPY docker/rockchip/COCO /COCO
|
||||
COPY docker/rockchip/conv2rknn.py /opt/conv2rknn.py
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ RUN echo "deb http://deb.debian.org/debian trixie main" > /etc/apt/sources.list.
|
||||
|
||||
WORKDIR /opt/frigate
|
||||
COPY --from=rootfs / /
|
||||
ENV FRIGATE_IMAGE_VARIANT=rocm
|
||||
|
||||
RUN wget -q https://bootstrap.pypa.io/get-pip.py -O get-pip.py \
|
||||
&& sed -i 's/args.append("setuptools")/args.append("setuptools==77.0.3")/' get-pip.py \
|
||||
|
||||
@@ -15,3 +15,4 @@ ENV INCLUDED_FFMPEG_VERSIONS="${DEFAULT_FFMPEG_VERSION}:${INCLUDED_FFMPEG_VERSIO
|
||||
|
||||
WORKDIR /opt/frigate/
|
||||
COPY --from=rootfs / /
|
||||
ENV FRIGATE_IMAGE_VARIANT=rpi
|
||||
|
||||
@@ -22,6 +22,7 @@ pip3 install --no-deps -U /deps/synap-wheels/*.whl
|
||||
|
||||
WORKDIR /opt/frigate/
|
||||
COPY --from=rootfs / /
|
||||
ENV FRIGATE_IMAGE_VARIANT=synaptics
|
||||
|
||||
COPY --from=synap1680-wheels /rootfs/usr/local/lib/*.so /usr/lib
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ RUN --mount=type=bind,from=trt-wheels,source=/trt-wheels,target=/deps/trt-wheels
|
||||
&& pip3 install -U /deps/trt-wheels/*.whl
|
||||
|
||||
COPY --from=rootfs / /
|
||||
ENV FRIGATE_IMAGE_VARIANT=tensorrt
|
||||
COPY docker/tensorrt/detector/rootfs/etc/ld.so.conf.d /etc/ld.so.conf.d
|
||||
RUN ldconfig
|
||||
|
||||
|
||||
@@ -151,6 +151,7 @@ RUN --mount=type=bind,from=trt-wheels,source=/trt-wheels,target=/deps/trt-wheels
|
||||
|
||||
WORKDIR /opt/frigate/
|
||||
COPY --from=rootfs / /
|
||||
ENV FRIGATE_IMAGE_VARIANT=tensorrt-jp6
|
||||
|
||||
# Fixes "Error importing detector runtime: /usr/lib/aarch64-linux-gnu/libstdc++.so.6: cannot allocate memory in static TLS block"
|
||||
ENV LD_PRELOAD /usr/lib/aarch64-linux-gnu/libstdc++.so.6
|
||||
@@ -967,6 +967,65 @@ memryx:
|
||||
# The .zip file must contain:
|
||||
# ├── ssdlite_mobilenet.dfp (a file ending with .dfp)
|
||||
# └── ssdlite_mobilenet_post.onnx (optional; only if the model includes a cropped post-processing network)
|
||||
deepx:
|
||||
title: DEEPX NPU
|
||||
models:
|
||||
- key: yolo
|
||||
label: YOLO
|
||||
recommended: true
|
||||
download: No model is bundled with Frigate. Download a pre-compiled YOLO `.dxnn` model from the [DEEPX ModelZoo](https://developer.deepx.ai/modelzoo) or compile your own with DX-COM, then bind-mount it into the container and point the model's `path` at it. The recommended model is `yolox-s_640x640_ppu.dxnn`. Its Post-Processing Unit (PPU) compile moves candidate selection onto the NPU, which makes it the fastest ModelZoo model measured through Frigate (about 13 ms on a DX-M1). The output layout is read from the compiled model, so anchor-based, anchor-free, NMS-in-head and PPU models (anchor-based or anchor-free) all need no extra configuration; prefer a `PPU` variant whenever the ModelZoo offers one. PPU models must be compiled with DX-COM 2.4.0 or later, which writes the head layout Frigate reads into the file.
|
||||
ui: |-
|
||||
Navigate to **Settings > System > Detection models** and select **DEEPX NPU** from the **Hardware** dropdown. Then, on the same model, open the **Custom Model** tab and configure:
|
||||
|
||||
| Field | Value |
|
||||
| ---------------------------------------- | ---------------------------------------------------- |
|
||||
| **Custom object detector model path** | `/config/model_cache/deepx/yolox-s_640x640_ppu.dxnn` |
|
||||
| **Label map for custom object detector** | `/labelmap/coco-80.txt` |
|
||||
| **Object detection model input width** | `640` |
|
||||
| **Object detection model input height** | `640` |
|
||||
| **Model Input Pixel Color Format** | `rgb` (Frigate's default value) |
|
||||
| **Model Input Tensor Shape** | `nhwc` (Frigate's default value) |
|
||||
| **Model Input D Type** | `int` (Frigate's default value) |
|
||||
| **Object Detection Model Type** | `yolo-generic` |
|
||||
|
||||
Quantization is baked into the compiled model, so no normalization is applied on the host and the input defaults do not need to be overridden.
|
||||
yaml: |-
|
||||
models:
|
||||
- devices:
|
||||
- deepx:PCIe:0
|
||||
path: /config/model_cache/deepx/yolox-s_640x640_ppu.dxnn
|
||||
labelmap_path: /labelmap/coco-80.txt
|
||||
model_type: yolo-generic
|
||||
width: 640
|
||||
height: 640
|
||||
- key: yolox
|
||||
label: YOLOX
|
||||
recommended: false
|
||||
download: No model is bundled with Frigate. Download a pre-compiled YOLOX `.dxnn` model from the [DEEPX ModelZoo](https://developer.deepx.ai/modelzoo), then bind-mount it into the container and point the model's `path` at it. The `_ppu` variant is faster and also works with the `yolo-generic` model type; the plain export needs `yolox` so its raw head is decoded.
|
||||
ui: |-
|
||||
Navigate to **Settings > System > Detection models** and select **DEEPX NPU** from the **Hardware** dropdown. Then, on the same model, open the **Custom Model** tab and configure:
|
||||
|
||||
| Field | Value |
|
||||
| ---------------------------------------- | ---------------------------------------- |
|
||||
| **Custom object detector model path** | `/config/model_cache/deepx/yolox-s_640x640.dxnn`|
|
||||
| **Label map for custom object detector** | `/labelmap/coco-80.txt` |
|
||||
| **Object detection model input width** | `640` |
|
||||
| **Object detection model input height** | `640` |
|
||||
| **Model Input Pixel Color Format** | `rgb` (Frigate's default value) |
|
||||
| **Model Input Tensor Shape** | `nhwc` (Frigate's default value) |
|
||||
| **Model Input D Type** | `int` (Frigate's default value) |
|
||||
| **Object Detection Model Type** | `yolox` |
|
||||
|
||||
The width and height must match the resolution the `.dxnn` file was compiled for.
|
||||
yaml: |-
|
||||
models:
|
||||
- devices:
|
||||
- deepx:PCIe:0
|
||||
path: /config/model_cache/deepx/yolox-s_640x640.dxnn
|
||||
labelmap_path: /labelmap/coco-80.txt
|
||||
model_type: yolox
|
||||
width: 640
|
||||
height: 640
|
||||
tensorrt:
|
||||
title: TensorRT
|
||||
models:
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
id: analytics
|
||||
title: Anonymous Analytics
|
||||
---
|
||||
|
||||
import AnalyticsFields from "@site/src/components/AnalyticsFields";
|
||||
import NavPath from "@site/src/components/NavPath";
|
||||
|
||||
Frigate can send one anonymous usage report a day. The reports show the maintainers which hardware to support, which features people use, and how releases perform. Sharing is off until you turn it on.
|
||||
|
||||
## Turning it on
|
||||
|
||||
Enable **Share anonymous analytics** at <NavPath path="Settings > System > Telemetry" />, or set it in your config:
|
||||
|
||||
```yaml
|
||||
telemetry:
|
||||
analytics: true
|
||||
```
|
||||
|
||||
The same page has a **Preview the report** button that shows exactly what the next report contains.
|
||||
|
||||
## How it's sent
|
||||
|
||||
- Once a day, as a JSON POST to `https://analytics.frigate.video/report`
|
||||
- The server looks up your country and region from your IP address and never stores the address
|
||||
- Raw reports are kept for 60 days; only aggregate totals are published
|
||||
- A random install ID, stored in `/config/.analytics.json`, keeps your install from being counted twice. Turning sharing off deletes it
|
||||
|
||||
## What's never sent
|
||||
|
||||
- Camera, zone, group, profile, or user names
|
||||
- Object labels, face names, or license plate text
|
||||
- IP addresses, hostnames, URLs, or stream paths
|
||||
- Credentials or API keys
|
||||
- Events, recordings, or anything from them
|
||||
|
||||
## Every field
|
||||
|
||||
Fields marked public appear in the published totals. The machine-readable schema is [frigate-analytics-schema.json](pathname:///frigate-analytics-schema.json).
|
||||
|
||||
<AnalyticsFields />
|
||||
@@ -1194,6 +1194,9 @@ ui:
|
||||
|
||||
# Optional: Telemetry configuration
|
||||
telemetry:
|
||||
# Optional: Share one anonymous usage report a day (default: shown below)
|
||||
# NOTE: See https://docs.frigate.video/configuration/advanced/analytics for what is sent
|
||||
analytics: False
|
||||
# Optional: Enabled network interfaces for bandwidth stats monitoring (default: empty list, let nethogs search all)
|
||||
network_interfaces:
|
||||
- eth
|
||||
|
||||
@@ -24,6 +24,7 @@ Frigate supports multiple different detectors that work on different types of ha
|
||||
- [Coral EdgeTPU](#edge-tpu-detector): The Google Coral EdgeTPU is available in USB, Mini PCIe, and m.2 formats allowing for a wide range of compatibility with devices.
|
||||
- [Hailo](#hailo): The Hailo-8, Hailo-8L and Hailo-8R AI Acceleration modules are available in m.2 format with a HAT for RPi devices, offering a wide range of compatibility with devices.
|
||||
- <CommunityBadge /> [MemryX](#memryx-mx3): The MX3 Acceleration module is available in m.2 format, offering broad compatibility across various platforms.
|
||||
- <CommunityBadge /> [DEEPX](#deepx-npu): The DEEPX NPU is available in m.2 format and as a HAT+ for the Raspberry Pi 5, offering broad compatibility across various platforms.
|
||||
|
||||
**AMD**
|
||||
|
||||
@@ -612,6 +613,63 @@ For detailed instructions on compiling models, refer to the [MemryX Compiler](ht
|
||||
|
||||
---
|
||||
|
||||
## DEEPX NPU
|
||||
|
||||
This detector is available for use with the DEEPX NPU, both the DX-M1 M.2 module and the DX-M1M on the Sixfab AI HAT+ for the Raspberry Pi 5. The configuration below applies unchanged to either form factor. DEEPX NPU support in Frigate is developed and maintained by [Sixfab](https://sixfab.com).
|
||||
|
||||
See the [installation docs](../frigate/installation.md#deepx-npu) for information on installing the DEEPX kernel driver and runtime on the host and passing the NPU through to the container.
|
||||
|
||||
To run a model on a DEEPX NPU, list a `deepx` device on that model.
|
||||
|
||||
:::info
|
||||
|
||||
The DX-RT Python bindings are not part of the Frigate image. They are downloaded and installed into `/config/.local` the first time a DEEPX device is configured, verified against pinned checksums, and updated automatically when a Frigate release pins a new version. If the container has no internet access, see [Detector runtimes](/frigate/network_requirements#detector-runtimes) for how to provide the files yourself.
|
||||
|
||||
:::
|
||||
|
||||
### Configuration {#configuration-deepx}
|
||||
|
||||
<ModelConfigDropdown detectorTitle="DEEPX" models={objectDetectorsModels.deepx.models} />
|
||||
|
||||
Frigate does not bundle a model for this detector. Models must be compiled to DEEPX's `.dxnn` format. Two model types are supported:
|
||||
|
||||
- `yolo-generic` for YOLO object detection models, the recommended default. The detector reads the model's output layout from the compiled file, so anchor-based, anchor-free and NMS-in-head models all work with the same configuration, as do models compiled with DEEPX's Post-Processing Unit (PPU) support.
|
||||
- `yolox` for YOLOX models compiled without PPU support, whose raw head needs Frigate's YOLOX decoder. A YOLOX model compiled with PPU support works under either `yolox` or `yolo-generic`.
|
||||
|
||||
The quickest way to get one is the [DEEPX ModelZoo](https://developer.deepx.ai/modelzoo), which publishes pre-compiled `.dxnn` files for a range of YOLO object detection models. Download the `.dxnn`, bind-mount it into the container, and point the model's `path` at it. Alternatively, compile your own model with the DX-COM compiler. The recommended starting point is `yolox-s_640x640_ppu.dxnn`, the fastest ModelZoo model measured through Frigate:
|
||||
|
||||
```yaml
|
||||
models:
|
||||
- devices:
|
||||
- deepx:PCIe:0
|
||||
path: /config/model_cache/deepx/yolox-s_640x640_ppu.dxnn
|
||||
labelmap_path: /labelmap/coco-80.txt
|
||||
model_type: yolo-generic
|
||||
width: 640
|
||||
height: 640
|
||||
```
|
||||
|
||||
For PPU models, use a `.dxnn` compiled with DX-COM 2.4.0 or later. Frigate reads the PPU head layout the compiler writes into the file and refuses to load a PPU model without it.
|
||||
|
||||
`model_type` must be set to `yolo-generic` or `yolox` to match the model; `yolo-generic` is the recommended default unless the model is a raw YOLOX export. Frigate defaults it to `ssd`, which this detector does not support, so the detector refuses to start on a model that leaves it unset.
|
||||
|
||||
`width` and `height` must match the resolution the model was compiled for. Quantization parameters are baked into the `.dxnn` file at compile time, so no normalization is applied on the host and Frigate's default `input_tensor`, `input_pixel_format`, and `input_dtype` values do not need to be overridden.
|
||||
|
||||
A DEEPX device is `PCIe:<index>`, as reported on the detector settings page. The NPU daemon multiplexes across processes, so the same device may be listed more than once to run additional inference processes against it:
|
||||
|
||||
```yaml
|
||||
models:
|
||||
- devices:
|
||||
- deepx:PCIe:0
|
||||
- deepx:PCIe:0
|
||||
```
|
||||
|
||||
#### Label maps
|
||||
|
||||
The object detection models in the DEEPX ModelZoo are trained on the standard 80-class COCO label set, so `labelmap_path` must be set to `/labelmap/coco-80.txt`. Frigate's default label map uses an extended 91-class COCO scheme, and leaving it in place will cause detections to be reported as the wrong object type. For `yolo-generic` models the label map is also what the detector uses to tell the output layout, so a label map with the wrong number of classes is reported as an error at startup.
|
||||
|
||||
---
|
||||
|
||||
## NVidia TensorRT Detector
|
||||
|
||||
Nvidia Jetson devices may be used for object detection using the TensorRT libraries. Due to the size of the additional libraries, this detector is only provided in images with the `-tensorrt-jp6` tag suffix, e.g. `ghcr.io/blakeblackshear/frigate:stable-tensorrt-jp6`. This detector is designed to work with Yolo models for object detection.
|
||||
|
||||
@@ -65,6 +65,11 @@ Frigate supports multiple different detectors that work on different types of ha
|
||||
- [Supports many model architectures](../../configuration/object_detectors#memryx-mx3)
|
||||
- Runs best with tiny, small, or medium-size models
|
||||
|
||||
- <CommunityBadge /> [DEEPX](#deepx-npu): The DEEPX NPU is available in m.2 format and as a HAT+ for the Raspberry Pi 5, allowing for a wide range of compatibility with devices.
|
||||
- [Supports YOLO model architectures](../../configuration/object_detectors#deepx-npu)
|
||||
- Runs best with tiny or small size models
|
||||
- Runs efficiently on low power hardware
|
||||
|
||||
**AMD**
|
||||
|
||||
- [ROCm](#rocm---amd-gpu): ROCm can run on AMD Discrete GPUs to provide efficient object detection
|
||||
@@ -257,6 +262,32 @@ The MX3 is a pipelined architecture, where the maximum frames per second support
|
||||
|
||||
Inference speeds may vary depending on the host platform. The above data was measured on an **Intel 13700 CPU**. Platforms like Raspberry Pi, Orange Pi, and other ARM-based SBCs have different levels of processing capability, which may limit total FPS.
|
||||
|
||||
### DEEPX NPU
|
||||
|
||||
Frigate supports the DEEPX NPU in both of its form factors: the **DX-M1** M.2 module, which works on x86 (Intel/AMD) and ARM-based SBCs such as the Raspberry Pi 5, and the **DX-M1M** on the [Sixfab AI HAT+](https://docs.sixfab.com/docs/ai-hat-plus-raspberry-pi-5-quickstart) for the Raspberry Pi 5. Both use the same driver and runtime, so the configuration is identical for either one. DEEPX NPU support in Frigate is developed and maintained by [Sixfab](https://sixfab.com).
|
||||
|
||||
The DEEPX driver and runtime run on the Docker host rather than inside the Frigate container and must be installed before the NPU can be used. See the [installation docs](installation.md#deepx-npu) for the setup steps and [the detector docs](/configuration/object_detectors#deepx-npu) for the configuration.
|
||||
|
||||
Frigate does not bundle a model for this detector. Models use DEEPX's `.dxnn` format, and pre-compiled YOLO models can be downloaded from the [DEEPX ModelZoo](https://developer.deepx.ai/modelzoo). Prefer a model with a `_ppu` suffix whenever one is available for the architecture you want: these run part of the post-processing on the NPU itself and are considerably faster, roughly 2.5x for the same architecture and input size. **YOLOX-S with PPU is the recommended starting point.**
|
||||
|
||||
Inference times for a few recommended models, measured through Frigate's own stats on a DX-M1:
|
||||
|
||||
| Model | Input Size | DX-M1 Inference Time |
|
||||
| ----------------- | ---------- | -------------------- |
|
||||
| YOLOX-S (PPU) | 640 | ~ 13 ms |
|
||||
| YOLOv9-t (PPU) | 640 | ~ 18 ms |
|
||||
| YOLOv4 (PPU) | 512 | ~ 20 ms |
|
||||
| YOLOX-S | 640 | ~ 34 ms |
|
||||
| YOLOv9-s | 640 | ~ 39 ms |
|
||||
|
||||
Other ModelZoo YOLO variants are also supported but have not been measured. Inference speeds vary with the host platform, so a slower host such as a Raspberry Pi 5 will report higher times than those above.
|
||||
|
||||
:::note
|
||||
|
||||
A few ModelZoo models can not be used with Frigate: SSD models (they are trained on Pascal VOC, so their labels do not match Frigate's), DAMO-YOLO models, face and pose models, and the PPU builds of YOLOv7.
|
||||
|
||||
:::
|
||||
|
||||
### Nvidia Jetson
|
||||
|
||||
Jetson devices are supported via the TensorRT or ONNX detectors when running Jetpack 6. It will [make use of the Jetson's hardware media engine](/configuration/hardware_acceleration_video#nvidia-jetson) when configured with the [appropriate presets](/configuration/ffmpeg_presets#hwaccel-presets), and will make use of the Jetson's GPU and DLA for object detection when configured with the [TensorRT detector](/configuration/object_detectors#nvidia-tensorrt-detector).
|
||||
|
||||
@@ -381,6 +381,99 @@ If you can't use Docker Compose, you can run the container with something simila
|
||||
|
||||
Finally, configure [hardware object detection](/configuration/object_detectors#memryx-mx3) to complete the setup.
|
||||
|
||||
### DEEPX NPU
|
||||
|
||||
The DEEPX NPU is available in two form factors, and Frigate supports both:
|
||||
|
||||
- **DX-M1** in the M.2 2280 form factor (like an NVMe SSD), for x86 (Intel/AMD) PCs, the Raspberry Pi 5, and other ARM SBCs with an exposed PCIe M.2 slot.
|
||||
- **DX-M1M** on the [Sixfab AI HAT+](https://docs.sixfab.com/docs/ai-hat-plus-raspberry-pi-5-quickstart), a HAT+ board that connects to the Raspberry Pi 5 over PCIe Gen 3 x1.
|
||||
|
||||
Both present the NPU through the same PCIe driver and DX-RT runtime, so the setup below and the detector configuration are identical for either one. Nothing needs to change when moving between them.
|
||||
|
||||
DEEPX NPU support in Frigate is developed and maintained by [Sixfab](https://sixfab.com).
|
||||
|
||||
#### Versions
|
||||
|
||||
A DEEPX install has several separately versioned pieces, and they all have to agree. The driver, the runtime, and the daemon live on the Docker host; Frigate itself carries only the Python bindings, which it downloads on first start:
|
||||
|
||||
| Component | Version | Installed on | Installed by |
|
||||
| -------------- | -------- | ------------ | ------------------------- |
|
||||
| Kernel driver | `v2.6.0` | Host | `user_installation.sh` |
|
||||
| DX-RT runtime | `v3.4.0` | Host | `user_installation.sh` |
|
||||
| NPU firmware | `v2.7.4` | The module | Flashed from the host |
|
||||
| DX-RT bindings | `v3.4.0` | Frigate | Downloaded at first start |
|
||||
|
||||
:::warning
|
||||
|
||||
A version mismatch does not produce a startup error. It typically shows up as inference requests that are accepted but never return a result, so detections simply stop appearing while Frigate looks healthy. If that happens after a Frigate upgrade, check every version in the table before anything else.
|
||||
|
||||
:::
|
||||
|
||||
The installation script installs the DX-RT runtime on the host and enables `dxrt.service`, so the daemon starts at boot and any other program on the host can share the NPU with Frigate. Check the firmware version with `dxrt-cli --status` and update the module if it does not match the table above.
|
||||
|
||||
#### Installation
|
||||
|
||||
The DEEPX kernel driver must be installed on the host rather than in the container, because containers share the host kernel and cannot load kernel modules. Installing it creates the `/dev/dxrt*` device nodes that are passed through to Frigate. The same script installs the DX-RT runtime and enables `dxrt.service`, the daemon that owns the NPU and hands work to it on behalf of Frigate and anything else on the host.
|
||||
|
||||
1. Copy or download [this script](https://github.com/blakeblackshear/frigate/blob/dev/docker/deepx/user_installation.sh).
|
||||
2. Ensure it has execution permissions with `sudo chmod +x user_installation.sh`
|
||||
3. Run the script with `./user_installation.sh`
|
||||
4. **Restart your computer** to complete driver installation.
|
||||
|
||||
Confirm the NPU is visible before continuing:
|
||||
|
||||
```bash
|
||||
ls /dev/dxrt*
|
||||
```
|
||||
|
||||
Then confirm the daemon is running and listening in `/run/dxrt`:
|
||||
|
||||
```bash
|
||||
systemctl is-active dxrt.service
|
||||
ls /run/dxrt/
|
||||
```
|
||||
|
||||
#### Setup
|
||||
|
||||
To set up Frigate, follow the default installation instructions, for example: `ghcr.io/blakeblackshear/frigate:stable`
|
||||
|
||||
#### Docker configuration
|
||||
|
||||
Frigate needs the NPU device node and the directory holding the daemon's socket:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
frigate:
|
||||
devices:
|
||||
- /dev/dxrt0:/dev/dxrt0
|
||||
volumes:
|
||||
- /run/dxrt:/run/dxrt
|
||||
```
|
||||
|
||||
If you can't use Docker Compose, add `--device /dev/dxrt0:/dev/dxrt0 -v /run/dxrt:/run/dxrt` to your `docker run` command.
|
||||
|
||||
Add one `--device` per NPU, contiguously from `/dev/dxrt0`, since the client stops enumerating at the first gap.
|
||||
|
||||
The installation script configures `dxrt.service` to place its socket in `/run/dxrt` through a systemd drop-in. Mounting the directory rather than the socket file means the container sees the new socket after `dxrt.service` is restarted, rather than holding on to a deleted one.
|
||||
|
||||
`dxrtd` listens on an abstract socket as well, but that one does not cross into a container, so Frigate names the filesystem socket through `DXRT_DYNAMIC_IPC_ENDPOINT` on your behalf. Set that variable on the container yourself only if the daemon listens somewhere else, which means you also set it for `dxrtd` through its own systemd drop-in. The script writes `/etc/systemd/system/dxrt.service.d/frigate.conf` for exactly that, and has `dxrt.service` link the socket to `/tmp/dxrt_dynamic_ipc.sock` when it starts, so the host's own `dxrt-cli` and `dxtop` keep finding it at the default path they fall back to.
|
||||
|
||||
:::note
|
||||
|
||||
The DX-RT client exits when `dxrt.service` stops, so restart the Frigate container after restarting `dxrt.service`.
|
||||
|
||||
:::
|
||||
|
||||
The device node is needed as well as the socket, because the client opens the NPU directly even though the daemon arbitrates access. Without it, inference fails with `Device not found`.
|
||||
|
||||
`/dev/shm` does not need sharing.
|
||||
|
||||
The DX-RT python bindings are not shipped in the Frigate image. Frigate downloads them on first start when a DEEPX detector is configured, and caches them under `/config`.
|
||||
|
||||
#### Configuration
|
||||
|
||||
Finally, configure [hardware object detection](/configuration/object_detectors#deepx-npu) to complete the setup.
|
||||
|
||||
### Rockchip platform
|
||||
|
||||
Make sure that you use a linux distribution that comes with the rockchip BSP kernel 5.10 or 6.1 and necessary drivers (especially rkvdec2 and rknpu). To check, enter the following commands:
|
||||
|
||||
@@ -134,6 +134,15 @@ telemetry:
|
||||
version_check: false
|
||||
```
|
||||
|
||||
### Anonymous Analytics
|
||||
|
||||
If [anonymous analytics](/configuration/advanced/analytics) sharing is turned on, Frigate sends one report a day to `https://analytics.frigate.video`. It's off by default, so no outbound connection happens unless you enable it:
|
||||
|
||||
```yaml
|
||||
telemetry:
|
||||
analytics: true
|
||||
```
|
||||
|
||||
### Push Notifications
|
||||
|
||||
When [notifications](/configuration/notifications) are enabled and users have registered for push notifications in the web UI, Frigate sends push messages through the browser vendor's push service (e.g., Google FCM, Mozilla autopush). This requires internet access from the Frigate server to these push endpoints.
|
||||
@@ -170,7 +179,7 @@ To run Frigate in an air-gapped or offline environment:
|
||||
2. **Pre-download the training base weights**: If you plan to train custom classification models, set `TF_KERAS_MOBILENET_V2_WEIGHTS_URL` before training, then run one training job while online. Without this variable the base weights are cached outside `/config/` and are lost whenever the container is recreated, so a later training run will fail offline. If the machine never has internet access, copy the weights in manually as described below.
|
||||
3. **Disable version check**: Set `telemetry.version_check: false` in your configuration.
|
||||
4. **Block outbound model requests**: Set the `HF_HUB_OFFLINE=1` and `TRANSFORMERS_OFFLINE=1` environment variables to prevent HuggingFace and Transformers from attempting any network requests.
|
||||
5. **Avoid cloud features**: Do not configure Frigate+, Generative AI providers that require internet, or cloud MQTT brokers.
|
||||
5. **Avoid cloud features**: Do not configure Frigate+, Generative AI providers that require internet, or cloud MQTT brokers, and leave anonymous analytics off (its default).
|
||||
6. **Use local model mirrors**: If limited internet is available, set the `HF_ENDPOINT`, `GITHUB_ENDPOINT`, `GITHUB_RAW_ENDPOINT`, and `TF_KERAS_MOBILENET_V2_WEIGHTS_URL` environment variables to point to local mirrors.
|
||||
|
||||
After these steps, Frigate will operate with no outbound internet connections.
|
||||
|
||||
@@ -130,6 +130,7 @@ const sidebars: SidebarsConfig = {
|
||||
label: "Advanced Configuration",
|
||||
items: [
|
||||
"configuration/advanced/system",
|
||||
"configuration/advanced/analytics",
|
||||
"configuration/advanced/reference",
|
||||
{
|
||||
type: "link",
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import React from "react";
|
||||
import schema from "@site/static/frigate-analytics-schema.json";
|
||||
|
||||
function resolve(node) {
|
||||
if (!node) return node;
|
||||
if (node.$ref) return schema.$defs[node.$ref.split("/").pop()];
|
||||
if (node.anyOf) {
|
||||
const inner = node.anyOf.find((option) => option.type !== "null");
|
||||
return inner ? resolve(inner) : node;
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
function rows(properties, prefix = "") {
|
||||
return Object.entries(properties).flatMap(([name, field]) => {
|
||||
const path = prefix ? `${prefix}.${name}` : name;
|
||||
const row = {
|
||||
path,
|
||||
description: field.description,
|
||||
isPublic: field["x-public"],
|
||||
};
|
||||
const target = resolve(field);
|
||||
|
||||
if (target?.properties) return [row, ...rows(target.properties, path)];
|
||||
|
||||
const values = resolve(target?.additionalProperties);
|
||||
if (values?.properties)
|
||||
return [row, ...rows(values.properties, `${path}.<key>`)];
|
||||
|
||||
const items = resolve(target?.items);
|
||||
if (items?.properties) return [row, ...rows(items.properties, `${path}[]`)];
|
||||
|
||||
return [row];
|
||||
});
|
||||
}
|
||||
|
||||
export default function AnalyticsFields() {
|
||||
return (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Field</th>
|
||||
<th>Description</th>
|
||||
<th>Public</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows(schema.properties).map((row) => (
|
||||
<tr key={row.path}>
|
||||
<td>
|
||||
<code>{row.path}</code>
|
||||
</td>
|
||||
<td>{row.description}</td>
|
||||
<td>{row.isPublic ? "Yes" : "No"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
+1482
File diff suppressed because it is too large
Load Diff
Vendored
+19
@@ -10,6 +10,25 @@ servers:
|
||||
- url: https://demo.frigate.video/api
|
||||
- url: http://localhost:5001/api
|
||||
paths:
|
||||
/analytics/preview:
|
||||
get:
|
||||
tags:
|
||||
- Analytics
|
||||
summary: Get Analytics Preview
|
||||
description: |-
|
||||
**Access:** Admin role required.
|
||||
|
||||
Get the analytics report Frigate would send next, without sending it.
|
||||
operationId: get_analytics_preview_analytics_preview_get
|
||||
responses:
|
||||
'200':
|
||||
description: Successful Response
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
security:
|
||||
- frigateAdminAuth: []
|
||||
x-required-role: admin
|
||||
/auth/first_time_login:
|
||||
get:
|
||||
tags:
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Opt-in anonymous analytics reports."""
|
||||
@@ -0,0 +1 @@
|
||||
"""One collector per report section."""
|
||||
@@ -0,0 +1,223 @@
|
||||
"""Cameras section: counts and histograms across cameras, never per camera."""
|
||||
|
||||
from collections import Counter
|
||||
from typing import Any
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from frigate.analytics.collectors.common import closed, histogram
|
||||
from frigate.analytics.context import ReportContext
|
||||
from frigate.analytics.schema import (
|
||||
CamerasSection,
|
||||
ConnectionQuality,
|
||||
FpsBucket,
|
||||
HeightBucket,
|
||||
HwaccelKey,
|
||||
InputPresetKey,
|
||||
RetainBucket,
|
||||
RetainDays,
|
||||
)
|
||||
from frigate.config import CameraConfig
|
||||
from frigate.config.camera.camera import CameraTypeEnum
|
||||
from frigate.config.camera.ffmpeg import CameraInput, CameraRoleEnum
|
||||
from frigate.const import REPLAY_CAMERA_PREFIX
|
||||
|
||||
# the upper edge of every height bucket but the last
|
||||
HEIGHT_BUCKETS = (
|
||||
(360, HeightBucket.le_360),
|
||||
(540, HeightBucket.h480),
|
||||
(900, HeightBucket.h720),
|
||||
(1260, HeightBucket.h1080),
|
||||
(1800, HeightBucket.h1440),
|
||||
)
|
||||
RESTREAM_HOSTS = frozenset({"127.0.0.1", "localhost"})
|
||||
RESTREAM_PORT = 8554
|
||||
QUALITIES = frozenset(ConnectionQuality)
|
||||
|
||||
|
||||
def height_bucket(height: int) -> HeightBucket:
|
||||
for limit, bucket in HEIGHT_BUCKETS:
|
||||
if height <= limit:
|
||||
return bucket
|
||||
|
||||
return HeightBucket.ge_2160
|
||||
|
||||
|
||||
def fps_bucket(fps: int) -> FpsBucket:
|
||||
if fps <= 5:
|
||||
return FpsBucket.le_5
|
||||
|
||||
if fps <= 10:
|
||||
return FpsBucket.f6_10
|
||||
|
||||
return FpsBucket.gt_10
|
||||
|
||||
|
||||
def retain_bucket(days: float) -> RetainBucket:
|
||||
if days <= 0:
|
||||
return RetainBucket.zero
|
||||
|
||||
if days <= 7:
|
||||
return RetainBucket.d1_7
|
||||
|
||||
if days <= 30:
|
||||
return RetainBucket.d8_30
|
||||
|
||||
return RetainBucket.gt_30
|
||||
|
||||
|
||||
def preset_key(args: str | list[str], enum: Any) -> Any:
|
||||
"""The preset's name, custom for hand written args, or none."""
|
||||
if not args:
|
||||
return enum("none")
|
||||
|
||||
if isinstance(args, str) and args.startswith("preset-"):
|
||||
return closed(enum, args.removeprefix("preset-"), enum("custom"))
|
||||
|
||||
return enum("custom")
|
||||
|
||||
|
||||
def is_restream(path: str) -> bool:
|
||||
try:
|
||||
url = urlsplit(path)
|
||||
return url.hostname in RESTREAM_HOSTS and url.port == RESTREAM_PORT
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def role_input(camera: CameraConfig, role: CameraRoleEnum) -> CameraInput | None:
|
||||
return next((i for i in camera.ffmpeg.inputs if role in i.roles), None)
|
||||
|
||||
|
||||
def object_masks(camera: CameraConfig) -> int:
|
||||
# parsing copies camera-wide masks into every filter as global_<id>
|
||||
own = sum(1 for mask in camera.objects.mask.values() if mask is not None)
|
||||
per_label = sum(
|
||||
1
|
||||
for label_filter in camera.objects.filters.values()
|
||||
for mask_id, mask in label_filter.mask.items()
|
||||
if mask is not None and not mask_id.startswith("global_")
|
||||
)
|
||||
return own + per_label
|
||||
|
||||
|
||||
def collect(ctx: ReportContext) -> CamerasSection:
|
||||
cameras = {
|
||||
name: camera
|
||||
for name, camera in ctx.config.cameras.items()
|
||||
if not name.startswith(REPLAY_CAMERA_PREFIX)
|
||||
}
|
||||
camera_stats = ctx.stats.get("cameras", {})
|
||||
|
||||
flags: Counter[str] = Counter()
|
||||
types: Counter[CameraTypeEnum] = Counter()
|
||||
heights: Counter[HeightBucket] = Counter()
|
||||
fps: Counter[FpsBucket] = Counter()
|
||||
hwaccel: Counter[Any] = Counter()
|
||||
input_presets: Counter[Any] = Counter()
|
||||
quality: Counter[ConnectionQuality] = Counter()
|
||||
retain: dict[str, Counter[RetainBucket]] = {
|
||||
period: Counter() for period in ("continuous", "motion", "alerts", "detections")
|
||||
}
|
||||
|
||||
for name, camera in cameras.items():
|
||||
detect_input = role_input(camera, CameraRoleEnum.detect)
|
||||
record_input = role_input(camera, CameraRoleEnum.record)
|
||||
|
||||
# config validation requires a detect input, so this never skips
|
||||
if detect_input is None:
|
||||
continue
|
||||
|
||||
types[camera.type] += 1
|
||||
fps[fps_bucket(camera.detect.fps)] += 1
|
||||
hwaccel[
|
||||
preset_key(
|
||||
detect_input.hwaccel_args or camera.ffmpeg.hwaccel_args, HwaccelKey
|
||||
)
|
||||
] += 1
|
||||
input_presets[
|
||||
preset_key(
|
||||
detect_input.input_args or camera.ffmpeg.input_args, InputPresetKey
|
||||
)
|
||||
] += 1
|
||||
|
||||
if camera.detect.height:
|
||||
heights[height_bucket(camera.detect.height)] += 1
|
||||
|
||||
state = camera_stats.get(name, {}).get("connection_quality")
|
||||
|
||||
if state in QUALITIES:
|
||||
quality[ConnectionQuality(state)] += 1
|
||||
|
||||
if camera.record.enabled:
|
||||
retain["continuous"][retain_bucket(camera.record.continuous.days)] += 1
|
||||
retain["motion"][retain_bucket(camera.record.motion.days)] += 1
|
||||
retain["alerts"][retain_bucket(camera.record.alerts.retain.days)] += 1
|
||||
retain["detections"][
|
||||
retain_bucket(camera.record.detections.retain.days)
|
||||
] += 1
|
||||
|
||||
zones = len(camera.zones)
|
||||
flags["enabled"] += camera.enabled
|
||||
flags["go2rtc_restream"] += any(
|
||||
is_restream(i.path) for i in camera.ffmpeg.inputs
|
||||
)
|
||||
flags["separate_detect_stream"] += (
|
||||
record_input is not None and record_input.path != detect_input.path
|
||||
)
|
||||
flags["detect"] += camera.detect.enabled
|
||||
flags["record"] += camera.record.enabled
|
||||
flags["sub_stream_record"] += camera.record.sub.enabled
|
||||
flags["snapshots"] += camera.snapshots.enabled
|
||||
flags["audio"] += camera.audio.enabled
|
||||
flags["audio_transcription"] += camera.audio_transcription.enabled
|
||||
flags["birdseye"] += camera.birdseye.enabled
|
||||
flags["onvif"] += bool(camera.onvif.host)
|
||||
flags["autotracking"] += camera.onvif.autotracking.enabled
|
||||
flags["face_recognition"] += camera.face_recognition.enabled
|
||||
flags["lpr"] += camera.lpr.enabled
|
||||
flags["review_genai"] += camera.review.genai.enabled
|
||||
flags["object_genai"] += camera.objects.genai.enabled
|
||||
flags["notifications"] += camera.notifications.enabled
|
||||
flags["zones"] += zones
|
||||
flags["cameras_with_zones"] += zones > 0
|
||||
flags["motion_masks"] += sum(
|
||||
1 for mask in camera.motion.mask.values() if mask is not None
|
||||
)
|
||||
flags["object_masks"] += object_masks(camera)
|
||||
|
||||
return CamerasSection(
|
||||
total=len(cameras),
|
||||
enabled=flags["enabled"],
|
||||
types=histogram(types),
|
||||
detect_height=histogram(heights),
|
||||
detect_fps=histogram(fps),
|
||||
hwaccel=histogram(hwaccel),
|
||||
input_preset=histogram(input_presets),
|
||||
go2rtc_restream=flags["go2rtc_restream"],
|
||||
separate_detect_stream=flags["separate_detect_stream"],
|
||||
detect=flags["detect"],
|
||||
record=flags["record"],
|
||||
sub_stream_record=flags["sub_stream_record"],
|
||||
snapshots=flags["snapshots"],
|
||||
audio=flags["audio"],
|
||||
audio_transcription=flags["audio_transcription"],
|
||||
birdseye=flags["birdseye"],
|
||||
onvif=flags["onvif"],
|
||||
autotracking=flags["autotracking"],
|
||||
face_recognition=flags["face_recognition"],
|
||||
lpr=flags["lpr"],
|
||||
review_genai=flags["review_genai"],
|
||||
object_genai=flags["object_genai"],
|
||||
notifications=flags["notifications"],
|
||||
zones=flags["zones"],
|
||||
cameras_with_zones=flags["cameras_with_zones"],
|
||||
motion_masks=flags["motion_masks"],
|
||||
object_masks=flags["object_masks"],
|
||||
connection_quality=histogram(quality),
|
||||
retain_days=RetainDays(
|
||||
continuous=histogram(retain["continuous"]),
|
||||
motion=histogram(retain["motion"]),
|
||||
alerts=histogram(retain["alerts"]),
|
||||
detections=histogram(retain["detections"]),
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Helpers the section collectors share."""
|
||||
|
||||
import math
|
||||
from collections import Counter
|
||||
from enum import Enum
|
||||
from typing import Any, TypeVar
|
||||
|
||||
E = TypeVar("E", bound=Enum)
|
||||
|
||||
|
||||
def closed(enum: type[E], value: Any, fallback: E) -> E:
|
||||
"""The member for a value, or the fallback for one the enum doesn't know."""
|
||||
try:
|
||||
return enum(str(value))
|
||||
except ValueError:
|
||||
return fallback
|
||||
|
||||
|
||||
def rate(value: Any) -> float:
|
||||
"""A finite, non-negative number rounded to 2 decimals, else 0.
|
||||
|
||||
A NaN would serialize as null and fail the schema's number type.
|
||||
"""
|
||||
try:
|
||||
number = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
if not math.isfinite(number):
|
||||
return 0.0
|
||||
|
||||
return round(max(number, 0.0), 2)
|
||||
|
||||
|
||||
def histogram(counter: "Counter[E]") -> dict[E, int]:
|
||||
"""Drop the empty buckets, since an absent key means zero."""
|
||||
return {key: total for key, total in counter.items() if total > 0}
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Detection section: models, the detectors they run on, and inference speed."""
|
||||
|
||||
import os
|
||||
|
||||
from frigate.analytics.collectors.common import rate
|
||||
from frigate.analytics.context import ReportContext
|
||||
from frigate.analytics.schema import DetectionModel, DetectionSection, ModelSource
|
||||
from frigate.config.config import DEFAULT_MODEL
|
||||
from frigate.const import MODEL_CACHE_DIR
|
||||
from frigate.detectors.detector_config import SceneEnum
|
||||
from frigate.detectors.detector_types import DetectorTypeEnum
|
||||
from frigate.detectors.device import runner_names
|
||||
|
||||
# the paths FrigateConfig fills in for a model that sets none
|
||||
BUNDLED_MODEL_PATHS = frozenset(
|
||||
{"/cpu_model.tflite", "/edgetpu_model.tflite", str(DEFAULT_MODEL["path"])}
|
||||
)
|
||||
|
||||
|
||||
def model_source(path: str | None) -> ModelSource:
|
||||
"""Default, Frigate+ (a cached model next to its info file), or custom.
|
||||
|
||||
Parsing rewrites plus://<id> to the model cache, so the prefix is gone by now.
|
||||
"""
|
||||
if path is None or path in BUNDLED_MODEL_PATHS:
|
||||
return ModelSource.default
|
||||
|
||||
if path.startswith(f"{MODEL_CACHE_DIR}/") and os.path.isfile(f"{path}.json"):
|
||||
return ModelSource.plus
|
||||
|
||||
return ModelSource.custom
|
||||
|
||||
|
||||
def collect(ctx: ReportContext) -> DetectionSection:
|
||||
config = ctx.config
|
||||
detectors = ctx.stats.get("detectors", {})
|
||||
model_specs = [(model, config.devices_for_model(model)) for model in config.models]
|
||||
|
||||
# FrigateApp.start_detectors names the processes in this same order
|
||||
names = iter(runner_names([spec for _, specs in model_specs for spec in specs]))
|
||||
models: dict[SceneEnum, DetectionModel] = {}
|
||||
|
||||
for model, specs in model_specs:
|
||||
speeds: list[float] = []
|
||||
|
||||
for _ in specs:
|
||||
speed = detectors.get(next(names), {}).get("inference_speed")
|
||||
|
||||
if isinstance(speed, int | float) and speed > 0:
|
||||
speeds.append(float(speed))
|
||||
|
||||
models[model.scene] = DetectionModel(
|
||||
detector=DetectorTypeEnum(specs[0].detector),
|
||||
devices=len(specs),
|
||||
model_type=model.model_type,
|
||||
input=f"{model.width}x{model.height}",
|
||||
source=model_source(model.path),
|
||||
inference_ms=rate(sum(speeds) / len(speeds)) if speeds else None,
|
||||
)
|
||||
|
||||
return DetectionSection(
|
||||
models=models,
|
||||
detection_fps=rate(ctx.stats.get("detection_fps")),
|
||||
skipped_fps=rate(ctx.stats.get("skipped_fps")),
|
||||
)
|
||||
@@ -0,0 +1,145 @@
|
||||
"""Features section: enrichments, GenAI, integrations, and users."""
|
||||
|
||||
from collections import Counter
|
||||
|
||||
from frigate.analytics.collectors.common import closed, histogram
|
||||
from frigate.analytics.context import ReportContext
|
||||
from frigate.analytics.schema import (
|
||||
BirdseyeUsage,
|
||||
ClassificationUsage,
|
||||
EnrichmentDevice,
|
||||
EnrichmentUsage,
|
||||
FeaturesSection,
|
||||
GenAIUsage,
|
||||
SemanticSearchModel,
|
||||
SemanticSearchUsage,
|
||||
TranscriptionModel,
|
||||
TranscriptionUsage,
|
||||
UserRole,
|
||||
)
|
||||
from frigate.config.camera.genai import GenAIProviderEnum, GenAIRoleEnum
|
||||
from frigate.const import REPLAY_CAMERA_PREFIX
|
||||
from frigate.models import User
|
||||
|
||||
RUNTIME_DEVICES = {
|
||||
"cpu": EnrichmentDevice.cpu,
|
||||
"cuda": EnrichmentDevice.cuda,
|
||||
"tensorrt": EnrichmentDevice.tensorrt,
|
||||
"migraphx": EnrichmentDevice.migraphx,
|
||||
}
|
||||
OPENVINO_DEVICES = {
|
||||
"cpu": EnrichmentDevice.openvino_cpu,
|
||||
"gpu": EnrichmentDevice.openvino_gpu,
|
||||
"npu": EnrichmentDevice.openvino_npu,
|
||||
}
|
||||
|
||||
|
||||
def enrichment_device(label: object) -> EnrichmentDevice | None:
|
||||
"""Map a runner's device label, like "CUDA" or "OpenVINO GPU.0,CPU"."""
|
||||
if not isinstance(label, str) or not label:
|
||||
return None
|
||||
|
||||
runtime, _, target = label.partition(" ")
|
||||
|
||||
if runtime == "OpenVINO":
|
||||
first = target.split(",")[0].split(".")[0].strip().lower()
|
||||
return OPENVINO_DEVICES.get(first, EnrichmentDevice.other)
|
||||
|
||||
return RUNTIME_DEVICES.get(label.lower(), EnrichmentDevice.other)
|
||||
|
||||
|
||||
def model_name(model: object) -> str | None:
|
||||
if model is None:
|
||||
return None
|
||||
|
||||
return str(getattr(model, "value", model))
|
||||
|
||||
|
||||
def semantic_model(model: object) -> SemanticSearchModel | None:
|
||||
# any string that isn't a built-in model names a GenAI provider
|
||||
name = model_name(model)
|
||||
|
||||
if name is None:
|
||||
return None
|
||||
|
||||
if name in ("jinav1", "jinav2"):
|
||||
return SemanticSearchModel(name)
|
||||
|
||||
return SemanticSearchModel.genai
|
||||
|
||||
|
||||
def transcription_model(model: object) -> TranscriptionModel | None:
|
||||
name = model_name(model)
|
||||
|
||||
if name is None:
|
||||
return None
|
||||
|
||||
return TranscriptionModel.whisper if name == "whisper" else TranscriptionModel.genai
|
||||
|
||||
|
||||
def users() -> dict[UserRole, int]:
|
||||
roles: Counter[UserRole] = Counter(
|
||||
closed(UserRole, user.role, UserRole.custom) for user in User.select(User.role)
|
||||
)
|
||||
return histogram(roles)
|
||||
|
||||
|
||||
def collect(ctx: ReportContext) -> FeaturesSection:
|
||||
config = ctx.config
|
||||
devices = ctx.stats.get("embeddings", {}).get("devices", {})
|
||||
cameras = [
|
||||
camera
|
||||
for name, camera in config.cameras.items()
|
||||
if not name.startswith(REPLAY_CAMERA_PREFIX)
|
||||
]
|
||||
providers: Counter[GenAIProviderEnum] = Counter(
|
||||
genai.provider for genai in config.genai.values()
|
||||
)
|
||||
roles: Counter[GenAIRoleEnum] = Counter(
|
||||
role for genai in config.genai.values() for role in genai.roles
|
||||
)
|
||||
custom = list(config.classification.custom.values())
|
||||
|
||||
return FeaturesSection(
|
||||
face_recognition=EnrichmentUsage(
|
||||
enabled=config.face_recognition.enabled,
|
||||
model_size=config.face_recognition.model_size,
|
||||
device=enrichment_device(devices.get("face_recognition")),
|
||||
),
|
||||
lpr=EnrichmentUsage(
|
||||
enabled=config.lpr.enabled,
|
||||
model_size=config.lpr.model_size,
|
||||
device=enrichment_device(devices.get("lpr")),
|
||||
),
|
||||
semantic_search=SemanticSearchUsage(
|
||||
enabled=config.semantic_search.enabled,
|
||||
model=semantic_model(config.semantic_search.model),
|
||||
model_size=config.semantic_search.model_size,
|
||||
device=enrichment_device(devices.get("semantic_search")),
|
||||
triggers=sum(len(camera.semantic_search.triggers) for camera in cameras),
|
||||
),
|
||||
audio_transcription=TranscriptionUsage(
|
||||
enabled=config.audio_transcription.enabled,
|
||||
model=transcription_model(config.audio_transcription.model),
|
||||
model_size=config.audio_transcription.model_size,
|
||||
),
|
||||
genai=GenAIUsage(providers=histogram(providers), roles=histogram(roles)),
|
||||
classification_models=ClassificationUsage(
|
||||
state=sum(1 for model in custom if model.state_config is not None),
|
||||
object=sum(1 for model in custom if model.object_config is not None),
|
||||
),
|
||||
birdseye=BirdseyeUsage(
|
||||
enabled=config.birdseye.enabled,
|
||||
modes=list(config.birdseye.modes),
|
||||
restream=config.birdseye.restream,
|
||||
),
|
||||
mqtt=config.mqtt.enabled,
|
||||
notifications=config.notifications.enabled,
|
||||
auth=config.auth.enabled,
|
||||
proxy_auth=config.proxy.header_map.user is not None,
|
||||
tls=config.tls.enabled,
|
||||
users=users(),
|
||||
camera_groups=len(config.camera_groups),
|
||||
profiles=len(config.profiles),
|
||||
plus_api_key=config.plus_api.is_active(),
|
||||
)
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Hardware section: CPU, memory, GPUs, decode and detection hardware, storage."""
|
||||
|
||||
import os
|
||||
from collections import Counter
|
||||
from typing import Any
|
||||
|
||||
import psutil
|
||||
|
||||
from frigate.analytics.collectors.common import closed, histogram
|
||||
from frigate.analytics.context import ReportContext
|
||||
from frigate.analytics.schema import (
|
||||
DecodeFamily,
|
||||
GpuInfo,
|
||||
GpuVendor,
|
||||
HardwareKey,
|
||||
HardwareSection,
|
||||
StorageInfo,
|
||||
)
|
||||
from frigate.const import RECORD_DIR
|
||||
from frigate.detectors.hardware import hardware_prober
|
||||
from frigate.util.hwaccel import hwaccel_options
|
||||
|
||||
DEVICE_TREE_MODEL = "/proc/device-tree/model"
|
||||
CPUINFO = "/proc/cpuinfo"
|
||||
|
||||
|
||||
def cpu_model() -> str:
|
||||
"""The board model on ARM boards, else the CPU's model name."""
|
||||
try:
|
||||
with open(DEVICE_TREE_MODEL) as f:
|
||||
board = f.read().strip("\x00\n ")
|
||||
|
||||
if board:
|
||||
return board[:64]
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
try:
|
||||
with open(CPUINFO) as f:
|
||||
for line in f:
|
||||
key, _, value = line.partition(":")
|
||||
|
||||
if key.strip() == "model name" and value.strip():
|
||||
return value.strip()[:64]
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
return "unknown"
|
||||
|
||||
|
||||
def gpus(stats: dict[str, Any]) -> list[GpuInfo]:
|
||||
found: list[GpuInfo] = []
|
||||
|
||||
for name, entry in stats.get("gpu_usages", {}).items():
|
||||
vendor = entry.get("vendor") if isinstance(entry, dict) else None
|
||||
found.append(
|
||||
GpuInfo(
|
||||
vendor=closed(GpuVendor, vendor, GpuVendor.other),
|
||||
name=str(name)[:64],
|
||||
)
|
||||
)
|
||||
|
||||
return found
|
||||
|
||||
|
||||
def decode_families() -> list[DecodeFamily]:
|
||||
_, available = hwaccel_options()
|
||||
families = [
|
||||
closed(DecodeFamily, family.key, DecodeFamily.other) for family in available
|
||||
]
|
||||
return list(dict.fromkeys(families))
|
||||
|
||||
|
||||
def detection_hardware() -> dict[HardwareKey, int]:
|
||||
units: Counter[HardwareKey] = Counter()
|
||||
|
||||
for found in hardware_prober.probe():
|
||||
units[closed(HardwareKey, found.key, HardwareKey.other)] += found.count
|
||||
|
||||
return histogram(units)
|
||||
|
||||
|
||||
def storage(stats: dict[str, Any]) -> StorageInfo:
|
||||
# stats report sizes in MB
|
||||
entry = stats.get("service", {}).get("storage", {}).get(RECORD_DIR) or {}
|
||||
total_mb = float(entry.get("total") or 0)
|
||||
used_mb = float(entry.get("used") or 0)
|
||||
|
||||
return StorageInfo(
|
||||
record_fs=str(entry.get("mount_type") or "unknown")[:16],
|
||||
record_total_gb=round(total_mb / 1024),
|
||||
record_used_pct=min(round(used_mb / total_mb * 100), 100)
|
||||
if total_mb > 0
|
||||
else 0,
|
||||
)
|
||||
|
||||
|
||||
def collect(ctx: ReportContext) -> HardwareSection:
|
||||
return HardwareSection(
|
||||
cpu_model=cpu_model(),
|
||||
cpu_cores=os.cpu_count() or 0,
|
||||
memory_gb=round(psutil.virtual_memory().total / 2**30),
|
||||
gpus=gpus(ctx.stats),
|
||||
decode_families=decode_families(),
|
||||
detection_hardware=detection_hardware(),
|
||||
storage=storage(ctx.stats),
|
||||
)
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Health section: uptime, CPU, enrichment speed, and notice counts."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from frigate.analytics.collectors.common import rate
|
||||
from frigate.analytics.context import ReportContext
|
||||
from frigate.analytics.schema import (
|
||||
EnrichmentTiming,
|
||||
HealthSection,
|
||||
NoticeCounts,
|
||||
NoticeKindKey,
|
||||
)
|
||||
|
||||
TIMING_STATS = {
|
||||
EnrichmentTiming.face: "face_recognition_speed",
|
||||
EnrichmentTiming.lpr: "plate_recognition_speed",
|
||||
EnrichmentTiming.plate_detection: "yolov9_plate_detection_speed",
|
||||
EnrichmentTiming.image_embedding: "image_embedding_speed",
|
||||
EnrichmentTiming.text_embedding: "text_embedding_speed",
|
||||
EnrichmentTiming.review_description: "review_description_speed",
|
||||
EnrichmentTiming.object_description: "object_description_speed",
|
||||
}
|
||||
REPORTABLE_KINDS = frozenset(key.value for key in NoticeKindKey)
|
||||
|
||||
|
||||
def notice_deltas(notice_stats: list[dict[str, Any]]) -> dict[Any, NoticeCounts]:
|
||||
"""What changed since the last accepted report, per reportable kind."""
|
||||
deltas: dict[Any, NoticeCounts] = {}
|
||||
|
||||
for row in notice_stats:
|
||||
if row["kind"] not in REPORTABLE_KINDS:
|
||||
continue
|
||||
|
||||
occurrences = max(row["occurrences"] - row["reported_occurrences"], 0)
|
||||
dismissals = max(row["dismissals"] - row["reported_dismissals"], 0)
|
||||
|
||||
if occurrences or dismissals:
|
||||
deltas[NoticeKindKey(row["kind"])] = NoticeCounts(
|
||||
occurrences=occurrences, dismissals=dismissals
|
||||
)
|
||||
|
||||
return deltas
|
||||
|
||||
|
||||
def collect(ctx: ReportContext) -> HealthSection:
|
||||
service = ctx.stats.get("service", {})
|
||||
embeddings = ctx.stats.get("embeddings", {})
|
||||
cpu = ctx.stats.get("cpu_usages", {}).get("frigate.full_system", {}).get("cpu")
|
||||
timings = {
|
||||
timing: rate(embeddings.get(key)) for timing, key in TIMING_STATS.items()
|
||||
}
|
||||
|
||||
return HealthSection(
|
||||
uptime_hours=int(rate(service.get("uptime")) // 3600),
|
||||
cpu_percent=min(round(rate(cpu)), 100),
|
||||
enrichment_ms={timing: value for timing, value in timings.items() if value > 0},
|
||||
retention_unmet=bool(service.get("retention_unmet", False)),
|
||||
notices=notice_deltas(ctx.notice_stats),
|
||||
)
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Install section: version, image variant, install type, platform."""
|
||||
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
|
||||
from frigate.analytics.collectors.common import closed
|
||||
from frigate.analytics.context import ReportContext
|
||||
from frigate.analytics.schema import Arch, ImageVariant, InstallSection, InstallType
|
||||
from frigate.version import VERSION
|
||||
|
||||
KERNEL_PATTERN = re.compile(r"^(\d{1,3})\.(\d{1,3})")
|
||||
|
||||
|
||||
def image_variant(value: str | None) -> ImageVariant:
|
||||
"""The published image from the build-time FRIGATE_IMAGE_VARIANT, dev when unset."""
|
||||
if not value:
|
||||
return ImageVariant.dev
|
||||
|
||||
return closed(ImageVariant, value, ImageVariant.other)
|
||||
|
||||
|
||||
def install_type() -> InstallType:
|
||||
# the add-on is a container too, so it has to be checked first
|
||||
if os.path.isfile("/data/options.json"):
|
||||
return InstallType.ha_addon
|
||||
|
||||
if os.environ.get("KUBERNETES_SERVICE_HOST"):
|
||||
return InstallType.kubernetes
|
||||
|
||||
if os.path.exists("/run/.containerenv"):
|
||||
return InstallType.podman
|
||||
|
||||
if os.path.exists("/.dockerenv"):
|
||||
return InstallType.docker
|
||||
|
||||
return InstallType.unknown
|
||||
|
||||
|
||||
def arch(machine: str) -> Arch:
|
||||
match machine.lower():
|
||||
case "x86_64" | "amd64":
|
||||
return Arch.x86_64
|
||||
case "aarch64" | "arm64":
|
||||
return Arch.aarch64
|
||||
case _:
|
||||
return Arch.other
|
||||
|
||||
|
||||
def kernel(release: str) -> str:
|
||||
match = KERNEL_PATTERN.match(release)
|
||||
return f"{match.group(1)}.{match.group(2)}" if match else "unknown"
|
||||
|
||||
|
||||
def collect(ctx: ReportContext) -> InstallSection:
|
||||
return InstallSection(
|
||||
version=VERSION[:32],
|
||||
image_variant=image_variant(os.environ.get("FRIGATE_IMAGE_VARIANT")),
|
||||
install_type=install_type(),
|
||||
arch=arch(platform.machine()),
|
||||
kernel=kernel(platform.release()),
|
||||
run_as_root=os.geteuid() == 0,
|
||||
)
|
||||
@@ -0,0 +1,13 @@
|
||||
"""What the collectors read, gathered once per report."""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from frigate.config import FrigateConfig
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReportContext:
|
||||
config: FrigateConfig
|
||||
stats: dict[str, Any]
|
||||
notice_stats: list[dict[str, Any]] = field(default_factory=list)
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Build a report from the section collectors."""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from uuid import uuid4
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from frigate.analytics.collectors import (
|
||||
cameras,
|
||||
detection,
|
||||
features,
|
||||
hardware,
|
||||
health,
|
||||
install,
|
||||
)
|
||||
from frigate.analytics.context import ReportContext
|
||||
from frigate.analytics.schema import SCHEMA_VERSION, AnalyticsReport
|
||||
from frigate.analytics.state import load_state
|
||||
from frigate.config import FrigateConfig
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from frigate.notices.registry import NoticeRegistry
|
||||
from frigate.stats.emitter import StatsEmitter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
COLLECTORS: dict[str, Callable[[ReportContext], BaseModel | None]] = {
|
||||
"install": install.collect,
|
||||
"hardware": hardware.collect,
|
||||
"detection": detection.collect,
|
||||
"cameras": cameras.collect,
|
||||
"features": features.collect,
|
||||
"health": health.collect,
|
||||
}
|
||||
|
||||
# shown in the preview until the first report creates a real ID
|
||||
PREVIEW_INSTALL_ID = "0" * 32
|
||||
|
||||
|
||||
def build_report(
|
||||
ctx: ReportContext, install_id: str, sent_at: int | None = None
|
||||
) -> AnalyticsReport:
|
||||
"""Run every collector; one that fails sends its section as null."""
|
||||
sections: dict[str, Any] = {}
|
||||
|
||||
for name, collect in COLLECTORS.items():
|
||||
try:
|
||||
sections[name] = collect(ctx)
|
||||
except Exception:
|
||||
# a collector bug must cost one section, never the whole report
|
||||
logger.warning("Analytics %s section failed", name, exc_info=True)
|
||||
sections[name] = None
|
||||
|
||||
return AnalyticsReport.model_validate(
|
||||
{
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"install_id": install_id,
|
||||
"report_id": str(uuid4()),
|
||||
"sent_at": int(time.time()) if sent_at is None else sent_at,
|
||||
**sections,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def gather_context(
|
||||
config: FrigateConfig,
|
||||
stats_emitter: "StatsEmitter | None",
|
||||
notice_registry: "NoticeRegistry | None",
|
||||
) -> ReportContext:
|
||||
return ReportContext(
|
||||
config=config,
|
||||
stats=stats_emitter.get_latest_stats() if stats_emitter is not None else {},
|
||||
notice_stats=notice_registry.stats() if notice_registry is not None else [],
|
||||
)
|
||||
|
||||
|
||||
def preview_report(
|
||||
config: FrigateConfig,
|
||||
stats_emitter: "StatsEmitter | None",
|
||||
notice_registry: "NoticeRegistry | None",
|
||||
) -> AnalyticsReport:
|
||||
"""The report the next send would carry, without sending it."""
|
||||
state = load_state()
|
||||
ctx = gather_context(config, stats_emitter, notice_registry)
|
||||
return build_report(ctx, state.install_id if state else PREVIEW_INSTALL_ID)
|
||||
@@ -0,0 +1,177 @@
|
||||
"""Send one analytics report a day while the admin has opted in."""
|
||||
|
||||
import logging
|
||||
import random
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from multiprocessing.synchronize import Event as MpEvent
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from frigate.analytics.context import ReportContext
|
||||
from frigate.analytics.report import build_report
|
||||
from frigate.analytics.state import (
|
||||
STATE_PATH,
|
||||
AnalyticsState,
|
||||
delete_state,
|
||||
load_state,
|
||||
new_state,
|
||||
save_state,
|
||||
)
|
||||
from frigate.analytics.transport import SendOutcome, send_report
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.config.holder import ConfigHolder
|
||||
from frigate.const import ANALYTICS_URL
|
||||
from frigate.notices import raise_notice, resolve_notice
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from frigate.notices.registry import NoticeRegistry
|
||||
from frigate.stats.emitter import StatsEmitter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
WAKE_S = 10 * 60
|
||||
INTERVAL_S = 24 * 60 * 60
|
||||
JITTER_S = 60 * 60
|
||||
FIRST_DELAY_S = (15 * 60, 45 * 60)
|
||||
PROMPT_KIND = "analytics_prompt"
|
||||
|
||||
|
||||
class AnalyticsReporter(threading.Thread):
|
||||
"""Follows the live config, so a settings save needs no restart."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config_holder: ConfigHolder,
|
||||
stats_emitter: "StatsEmitter",
|
||||
notice_registry: "NoticeRegistry",
|
||||
stop_event: MpEvent | threading.Event,
|
||||
*,
|
||||
state_path: str = STATE_PATH,
|
||||
url: str = ANALYTICS_URL,
|
||||
send: Callable[[str, str], SendOutcome] = send_report,
|
||||
clock: Callable[[], float] = time.time,
|
||||
rng: random.Random | None = None,
|
||||
) -> None:
|
||||
super().__init__(name="analytics_reporter", daemon=True)
|
||||
self.config_holder = config_holder
|
||||
self.stats_emitter = stats_emitter
|
||||
self.notice_registry = notice_registry
|
||||
self.stop_event = stop_event
|
||||
self.state_path = state_path
|
||||
self.url = url
|
||||
self.send = send
|
||||
self.clock = clock
|
||||
self.rng = rng or random.Random()
|
||||
self.first_due = clock() + self.rng.uniform(*FIRST_DELAY_S)
|
||||
self.interval = self._next_interval()
|
||||
self.opted_in: bool | None = None
|
||||
self.warned_unwritable = False
|
||||
# a settings save runs on an API thread while a wake may be mid-attempt
|
||||
self._lock = threading.Lock()
|
||||
config_holder.subscribe(self._on_config)
|
||||
|
||||
def _next_interval(self) -> float:
|
||||
return INTERVAL_S + self.rng.uniform(-JITTER_S, JITTER_S)
|
||||
|
||||
def run(self) -> None:
|
||||
while True:
|
||||
try:
|
||||
self.tick()
|
||||
except Exception:
|
||||
logger.exception("Analytics reporter failed")
|
||||
|
||||
if self.stop_event.wait(WAKE_S):
|
||||
break
|
||||
|
||||
def _on_config(self, config: FrigateConfig) -> None:
|
||||
# a save can turn sharing off and back on between two wakes, so an
|
||||
# opt-out is handled when it's saved rather than at the next wake
|
||||
with self._lock:
|
||||
if not config.safe_mode:
|
||||
self._apply_consent(config.telemetry.analytics)
|
||||
|
||||
def _apply_consent(self, opted_in: bool) -> None:
|
||||
# called with the lock held
|
||||
if opted_in == self.opted_in:
|
||||
return
|
||||
|
||||
self.opted_in = opted_in
|
||||
|
||||
if opted_in:
|
||||
resolve_notice(PROMPT_KIND)
|
||||
else:
|
||||
raise_notice(PROMPT_KIND)
|
||||
delete_state(self.state_path)
|
||||
|
||||
def tick(self) -> None:
|
||||
with self._lock:
|
||||
config = self.config_holder.config
|
||||
|
||||
# safe mode parses a default config where analytics reads as off,
|
||||
# and handling that as an opt-out would delete the install ID
|
||||
if config.safe_mode:
|
||||
return
|
||||
|
||||
self._apply_consent(config.telemetry.analytics)
|
||||
|
||||
if not config.telemetry.analytics:
|
||||
return
|
||||
|
||||
now = self.clock()
|
||||
|
||||
if now < self.first_due:
|
||||
return
|
||||
|
||||
state = load_state(self.state_path) or new_state()
|
||||
|
||||
if not self._due(state.last_attempt_at, now):
|
||||
return
|
||||
|
||||
# saved before sending, so a failing endpoint or a crash mid-send
|
||||
# still waits a full interval; without saved state every boot would
|
||||
# send under a new install ID
|
||||
if not save_state(AnalyticsState(state.install_id, now), self.state_path):
|
||||
if not self.warned_unwritable:
|
||||
logger.warning(
|
||||
"Analytics is on, but %s isn't writable, so no report is sent",
|
||||
self.state_path,
|
||||
)
|
||||
self.warned_unwritable = True
|
||||
|
||||
return
|
||||
|
||||
self.interval = self._next_interval()
|
||||
|
||||
# the lock stays free during the request, so a save never waits on it
|
||||
self._send(state.install_id, now)
|
||||
|
||||
def _due(self, last_attempt_at: float, now: float) -> bool:
|
||||
# a last attempt stamped in the future came from a wrong clock
|
||||
return (
|
||||
now >= last_attempt_at + self.interval or last_attempt_at > now + INTERVAL_S
|
||||
)
|
||||
|
||||
def _send(self, install_id: str, now: float) -> None:
|
||||
notice_stats = self.notice_registry.stats()
|
||||
ctx = ReportContext(
|
||||
config=self.config_holder.config,
|
||||
stats=self.stats_emitter.get_latest_stats(),
|
||||
notice_stats=notice_stats,
|
||||
)
|
||||
report = build_report(ctx, install_id, sent_at=int(now))
|
||||
body = report.model_dump_json()
|
||||
|
||||
# consent can be withdrawn while the report builds
|
||||
if not self.config_holder.config.telemetry.analytics:
|
||||
return
|
||||
|
||||
if self.send(self.url, body) is not SendOutcome.accepted:
|
||||
return
|
||||
|
||||
# a failed health section sent no notice counts, so they stay pending
|
||||
if report.health is not None:
|
||||
self.notice_registry.mark_reported(notice_stats)
|
||||
|
||||
logger.info("Sent the daily analytics report")
|
||||
logger.debug("Analytics report: %s", body)
|
||||
@@ -0,0 +1,407 @@
|
||||
"""Models for the analytics report, the contract with the ingest endpoint.
|
||||
|
||||
Every field carries a description and an x-public flag, and no field accepts
|
||||
user-entered text, so a report can't carry camera names or other free text.
|
||||
"""
|
||||
|
||||
from enum import StrEnum
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from frigate.config.camera.birdseye import BirdseyeModeEnum
|
||||
from frigate.config.camera.camera import CameraTypeEnum
|
||||
from frigate.config.camera.genai import GenAIProviderEnum, GenAIRoleEnum
|
||||
from frigate.config.classification import ModelSizeEnum
|
||||
from frigate.detectors.detector_config import ModelTypeEnum, SceneEnum
|
||||
from frigate.detectors.detector_types import DetectorTypeEnum
|
||||
from frigate.ffmpeg_presets import PRESETS_HW_ACCEL_DECODE, PRESETS_INPUT
|
||||
from frigate.notices.types import NOTICE_KINDS
|
||||
|
||||
SCHEMA_VERSION = 1
|
||||
|
||||
|
||||
class ImageVariant(StrEnum):
|
||||
standard = "standard"
|
||||
rpi = "rpi"
|
||||
tensorrt = "tensorrt"
|
||||
tensorrt_jp6 = "tensorrt-jp6"
|
||||
rocm = "rocm"
|
||||
rk = "rk"
|
||||
synaptics = "synaptics"
|
||||
dev = "dev"
|
||||
other = "other"
|
||||
|
||||
|
||||
class InstallType(StrEnum):
|
||||
ha_addon = "ha_addon"
|
||||
docker = "docker"
|
||||
podman = "podman"
|
||||
kubernetes = "kubernetes"
|
||||
unknown = "unknown"
|
||||
|
||||
|
||||
class Arch(StrEnum):
|
||||
x86_64 = "x86_64"
|
||||
aarch64 = "aarch64"
|
||||
other = "other"
|
||||
|
||||
|
||||
class GpuVendor(StrEnum):
|
||||
intel = "intel"
|
||||
amd = "amd"
|
||||
nvidia = "nvidia"
|
||||
rockchip = "rockchip"
|
||||
rpi = "rpi"
|
||||
other = "other"
|
||||
|
||||
|
||||
class DecodeFamily(StrEnum):
|
||||
nvidia = "nvidia"
|
||||
vaapi = "vaapi"
|
||||
rkmpp = "rkmpp"
|
||||
intel_qsv = "intel-qsv"
|
||||
jetson = "jetson"
|
||||
rpi = "rpi"
|
||||
other = "other"
|
||||
|
||||
|
||||
class HardwareKey(StrEnum):
|
||||
edgetpu_pci = "edgetpu:pci"
|
||||
edgetpu_usb = "edgetpu:usb"
|
||||
openvino_gpu = "openvino:GPU"
|
||||
openvino_npu = "openvino:NPU"
|
||||
onnx_amd = "onnx:amd"
|
||||
onnx_nvidia = "onnx:nvidia"
|
||||
tensorrt = "tensorrt"
|
||||
hailo = "hailo"
|
||||
memryx = "memryx"
|
||||
deepx = "deepx"
|
||||
rknn = "rknn"
|
||||
axengine = "axengine"
|
||||
synaptics = "synaptics"
|
||||
cpu = "cpu"
|
||||
other = "other"
|
||||
|
||||
|
||||
class ModelSource(StrEnum):
|
||||
default = "default"
|
||||
plus = "plus"
|
||||
custom = "custom"
|
||||
|
||||
|
||||
class HeightBucket(StrEnum):
|
||||
le_360 = "le_360"
|
||||
h480 = "480"
|
||||
h720 = "720"
|
||||
h1080 = "1080"
|
||||
h1440 = "1440"
|
||||
ge_2160 = "ge_2160"
|
||||
|
||||
|
||||
class FpsBucket(StrEnum):
|
||||
le_5 = "le_5"
|
||||
f6_10 = "6_10"
|
||||
gt_10 = "gt_10"
|
||||
|
||||
|
||||
class RetainBucket(StrEnum):
|
||||
zero = "0"
|
||||
d1_7 = "1_7"
|
||||
d8_30 = "8_30"
|
||||
gt_30 = "gt_30"
|
||||
|
||||
|
||||
class ConnectionQuality(StrEnum):
|
||||
excellent = "excellent"
|
||||
fair = "fair"
|
||||
poor = "poor"
|
||||
unusable = "unusable"
|
||||
|
||||
|
||||
class EnrichmentDevice(StrEnum):
|
||||
cpu = "cpu"
|
||||
cuda = "cuda"
|
||||
tensorrt = "tensorrt"
|
||||
migraphx = "migraphx"
|
||||
openvino_cpu = "openvino_cpu"
|
||||
openvino_gpu = "openvino_gpu"
|
||||
openvino_npu = "openvino_npu"
|
||||
other = "other"
|
||||
|
||||
|
||||
class SemanticSearchModel(StrEnum):
|
||||
jinav1 = "jinav1"
|
||||
jinav2 = "jinav2"
|
||||
genai = "genai"
|
||||
|
||||
|
||||
class TranscriptionModel(StrEnum):
|
||||
whisper = "whisper"
|
||||
genai = "genai"
|
||||
|
||||
|
||||
class UserRole(StrEnum):
|
||||
admin = "admin"
|
||||
viewer = "viewer"
|
||||
custom = "custom"
|
||||
|
||||
|
||||
class EnrichmentTiming(StrEnum):
|
||||
face = "face"
|
||||
lpr = "lpr"
|
||||
plate_detection = "plate_detection"
|
||||
image_embedding = "image_embedding"
|
||||
text_embedding = "text_embedding"
|
||||
review_description = "review_description"
|
||||
object_description = "object_description"
|
||||
|
||||
|
||||
def _preset_keys(presets: dict[str, Any]) -> dict[str, str]:
|
||||
keys = [name.removeprefix("preset-") for name in presets]
|
||||
return {key: key for key in [*keys, "custom", "none"]}
|
||||
|
||||
|
||||
# built from the registries they mirror, so a new preset or notice kind reaches
|
||||
# the schema through generate_analytics_schema.py instead of a hand edit
|
||||
HwaccelKey = StrEnum("HwaccelKey", _preset_keys(PRESETS_HW_ACCEL_DECODE)) # type: ignore[misc]
|
||||
InputPresetKey = StrEnum("InputPresetKey", _preset_keys(PRESETS_INPUT)) # type: ignore[misc]
|
||||
NoticeKindKey = StrEnum( # type: ignore[misc]
|
||||
"NoticeKindKey",
|
||||
{key: key for key, kind in NOTICE_KINDS.items() if kind.reportable},
|
||||
)
|
||||
|
||||
|
||||
class AnalyticsModel(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
def metric(description: str, *, public: bool = True, **kwargs: Any) -> Any:
|
||||
"""Declare a report field; the description and flag land in the schema."""
|
||||
return Field(
|
||||
description=description, json_schema_extra={"x-public": public}, **kwargs
|
||||
)
|
||||
|
||||
|
||||
def count(description: str) -> Any:
|
||||
return metric(description, ge=0)
|
||||
|
||||
|
||||
class InstallSection(AnalyticsModel):
|
||||
version: str = metric("Frigate version string", max_length=32)
|
||||
image_variant: ImageVariant = metric("Published image the install runs")
|
||||
install_type: InstallType = metric("How Frigate is installed")
|
||||
arch: Arch = metric("CPU architecture")
|
||||
kernel: str = metric(
|
||||
"Host kernel as major.minor", pattern=r"^(\d{1,3}\.\d{1,3}|unknown)$"
|
||||
)
|
||||
run_as_root: bool = metric("Whether the main process runs as root")
|
||||
|
||||
|
||||
class GpuInfo(AnalyticsModel):
|
||||
vendor: GpuVendor = metric("GPU vendor")
|
||||
name: str = metric("GPU name as the hardware reports it", max_length=64)
|
||||
|
||||
|
||||
class StorageInfo(AnalyticsModel):
|
||||
record_fs: str = metric("Filesystem of the recordings volume", max_length=16)
|
||||
record_total_gb: int = count("Size of the recordings volume in GB")
|
||||
record_used_pct: int = metric(
|
||||
"Percent of the recordings volume in use", ge=0, le=100
|
||||
)
|
||||
|
||||
|
||||
class HardwareSection(AnalyticsModel):
|
||||
cpu_model: str = metric(
|
||||
"CPU or board model as the hardware reports it", max_length=64
|
||||
)
|
||||
cpu_cores: int = count("Logical CPU count")
|
||||
memory_gb: int = count("Total memory in GB")
|
||||
gpus: list[GpuInfo] = metric("GPUs the stats collector found")
|
||||
decode_families: list[DecodeFamily] = metric(
|
||||
"Hardware decode families this system can use"
|
||||
)
|
||||
detection_hardware: dict[HardwareKey, int] = metric(
|
||||
"Detection hardware found, as unit counts by kind"
|
||||
)
|
||||
storage: StorageInfo = metric("Recordings storage")
|
||||
|
||||
|
||||
class DetectionModel(AnalyticsModel):
|
||||
detector: DetectorTypeEnum = metric("Detector type the model runs on")
|
||||
devices: int = count("Devices the model runs on")
|
||||
model_type: ModelTypeEnum = metric("Model architecture")
|
||||
input: str = metric("Model input size as WxH", pattern=r"^\d{1,5}x\d{1,5}$")
|
||||
source: ModelSource = metric("Where the model came from", public=False)
|
||||
inference_ms: float | None = metric(
|
||||
"Mean inference time across the model's detector processes, null before the first stats",
|
||||
ge=0,
|
||||
)
|
||||
|
||||
|
||||
class DetectionSection(AnalyticsModel):
|
||||
models: dict[SceneEnum, DetectionModel] = metric("Detection models keyed by scene")
|
||||
detection_fps: float = metric("Detections per second across cameras", ge=0)
|
||||
skipped_fps: float = metric("Frames per second skipped across cameras", ge=0)
|
||||
|
||||
|
||||
class RetainDays(AnalyticsModel):
|
||||
continuous: dict[RetainBucket, int] = metric(
|
||||
"Recording cameras by continuous retention in days"
|
||||
)
|
||||
motion: dict[RetainBucket, int] = metric(
|
||||
"Recording cameras by motion retention in days"
|
||||
)
|
||||
alerts: dict[RetainBucket, int] = metric(
|
||||
"Recording cameras by alert retention in days"
|
||||
)
|
||||
detections: dict[RetainBucket, int] = metric(
|
||||
"Recording cameras by detection retention in days"
|
||||
)
|
||||
|
||||
|
||||
class CamerasSection(AnalyticsModel):
|
||||
total: int = count("Configured cameras")
|
||||
enabled: int = count("Enabled cameras")
|
||||
types: dict[CameraTypeEnum, int] = metric("Cameras by type")
|
||||
detect_height: dict[HeightBucket, int] = metric(
|
||||
"Cameras by detect resolution height"
|
||||
)
|
||||
detect_fps: dict[FpsBucket, int] = metric("Cameras by detect fps")
|
||||
hwaccel: dict[HwaccelKey, int] = metric(
|
||||
"Cameras by the resolved hwaccel preset of the detect input"
|
||||
)
|
||||
input_preset: dict[InputPresetKey, int] = metric(
|
||||
"Cameras by the input preset of the detect input"
|
||||
)
|
||||
go2rtc_restream: int = count("Cameras with an input from the go2rtc restream")
|
||||
separate_detect_stream: int = count(
|
||||
"Cameras whose detect input differs from their record input"
|
||||
)
|
||||
detect: int = count("Cameras with detection on")
|
||||
record: int = count("Cameras with recording on")
|
||||
sub_stream_record: int = count("Cameras with sub stream recording on")
|
||||
snapshots: int = count("Cameras with snapshots on")
|
||||
audio: int = count("Cameras with audio detection on")
|
||||
audio_transcription: int = count("Cameras with audio transcription on")
|
||||
birdseye: int = count("Cameras in birdseye")
|
||||
onvif: int = count("Cameras with an ONVIF host")
|
||||
autotracking: int = count("Cameras with PTZ autotracking on")
|
||||
face_recognition: int = count("Cameras with face recognition on")
|
||||
lpr: int = count("Cameras with license plate recognition on")
|
||||
review_genai: int = count("Cameras with GenAI review summaries on")
|
||||
object_genai: int = count("Cameras with GenAI object descriptions on")
|
||||
notifications: int = count("Cameras with notifications on")
|
||||
zones: int = count("Zones across cameras")
|
||||
cameras_with_zones: int = count("Cameras with at least one zone")
|
||||
motion_masks: int = count("Motion masks across cameras")
|
||||
object_masks: int = count("Object masks across cameras")
|
||||
connection_quality: dict[ConnectionQuality, int] = metric(
|
||||
"Cameras by connection quality at send time"
|
||||
)
|
||||
retain_days: RetainDays = metric("Recording retention")
|
||||
|
||||
|
||||
class EnrichmentUsage(AnalyticsModel):
|
||||
enabled: bool = metric("Whether the enrichment is on")
|
||||
model_size: ModelSizeEnum = metric("Configured model size")
|
||||
device: EnrichmentDevice | None = metric(
|
||||
"Device the model loaded on, null when it isn't loaded"
|
||||
)
|
||||
|
||||
|
||||
class SemanticSearchUsage(AnalyticsModel):
|
||||
enabled: bool = metric("Whether semantic search is on")
|
||||
model: SemanticSearchModel | None = metric(
|
||||
"Embedding model, genai for a GenAI provider"
|
||||
)
|
||||
model_size: ModelSizeEnum = metric("Configured model size")
|
||||
device: EnrichmentDevice | None = metric(
|
||||
"Device the model loaded on, null when it isn't loaded"
|
||||
)
|
||||
triggers: int = count("Semantic search triggers across cameras")
|
||||
|
||||
|
||||
class TranscriptionUsage(AnalyticsModel):
|
||||
enabled: bool = metric("Whether audio transcription is on")
|
||||
model: TranscriptionModel | None = metric(
|
||||
"Transcription model, genai for a GenAI provider"
|
||||
)
|
||||
model_size: ModelSizeEnum = metric("Configured model size")
|
||||
|
||||
|
||||
class GenAIUsage(AnalyticsModel):
|
||||
providers: dict[GenAIProviderEnum, int] = metric(
|
||||
"Configured GenAI providers by type"
|
||||
)
|
||||
roles: dict[GenAIRoleEnum, int] = metric("Configured GenAI providers by role")
|
||||
|
||||
|
||||
class ClassificationUsage(AnalyticsModel):
|
||||
state: int = count("Custom state classification models")
|
||||
object: int = count("Custom object classification models")
|
||||
|
||||
|
||||
class BirdseyeUsage(AnalyticsModel):
|
||||
enabled: bool = metric("Whether birdseye is on")
|
||||
modes: list[BirdseyeModeEnum] = metric("Birdseye modes")
|
||||
restream: bool = metric("Whether birdseye is restreamed")
|
||||
|
||||
|
||||
class FeaturesSection(AnalyticsModel):
|
||||
face_recognition: EnrichmentUsage = metric("Face recognition")
|
||||
lpr: EnrichmentUsage = metric("License plate recognition")
|
||||
semantic_search: SemanticSearchUsage = metric("Semantic search")
|
||||
audio_transcription: TranscriptionUsage = metric("Audio transcription")
|
||||
genai: GenAIUsage = metric("Generative AI providers")
|
||||
classification_models: ClassificationUsage = metric("Custom classification models")
|
||||
birdseye: BirdseyeUsage = metric("Birdseye")
|
||||
mqtt: bool = metric("Whether MQTT is on")
|
||||
notifications: bool = metric("Whether web push notifications are on")
|
||||
auth: bool = metric("Whether authentication is on")
|
||||
proxy_auth: bool = metric("Whether a proxy supplies the user header")
|
||||
tls: bool = metric("Whether TLS is on")
|
||||
users: dict[UserRole, int] = metric("Users by role")
|
||||
camera_groups: int = count("Camera groups")
|
||||
profiles: int = count("Profiles")
|
||||
plus_api_key: bool = metric("Whether a Frigate+ API key is set", public=False)
|
||||
|
||||
|
||||
class NoticeCounts(AnalyticsModel):
|
||||
occurrences: int = count("Occurrences since the last accepted report")
|
||||
dismissals: int = count("Dismissals since the last accepted report")
|
||||
|
||||
|
||||
class HealthSection(AnalyticsModel):
|
||||
uptime_hours: int = count("Hours since Frigate started")
|
||||
cpu_percent: int = metric("System CPU use at send time", ge=0, le=100)
|
||||
enrichment_ms: dict[EnrichmentTiming, float] = metric(
|
||||
"Mean enrichment inference times in milliseconds"
|
||||
)
|
||||
retention_unmet: bool = metric(
|
||||
"Whether storage can't keep the configured retention"
|
||||
)
|
||||
notices: dict[NoticeKindKey, NoticeCounts] = metric(
|
||||
"Notice counts by kind since the last accepted report", public=False
|
||||
)
|
||||
|
||||
|
||||
class AnalyticsReport(AnalyticsModel):
|
||||
schema_version: int = metric("Report format version", ge=1)
|
||||
install_id: str = metric(
|
||||
"Random install identifier", public=False, pattern=r"^[0-9a-f]{32}$"
|
||||
)
|
||||
report_id: str = metric(
|
||||
"Random identifier of this report",
|
||||
public=False,
|
||||
pattern=r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
|
||||
)
|
||||
sent_at: int = count("Unix time the report was built")
|
||||
install: InstallSection | None = metric("Install, null if its collector failed")
|
||||
hardware: HardwareSection | None = metric("Hardware, null if its collector failed")
|
||||
detection: DetectionSection | None = metric(
|
||||
"Object detection, null if its collector failed"
|
||||
)
|
||||
cameras: CamerasSection | None = metric("Cameras, null if its collector failed")
|
||||
features: FeaturesSection | None = metric("Features, null if its collector failed")
|
||||
health: HealthSection | None = metric("Health, null if its collector failed")
|
||||
@@ -0,0 +1,85 @@
|
||||
"""The install's analytics identity and last attempt time, kept under /config."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from uuid import uuid4
|
||||
|
||||
from frigate.const import CONFIG_DIR
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
STATE_PATH = os.path.join(CONFIG_DIR, ".analytics.json")
|
||||
INSTALL_ID_PATTERN = re.compile(r"[0-9a-f]{32}")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AnalyticsState:
|
||||
install_id: str
|
||||
last_attempt_at: float
|
||||
|
||||
|
||||
def new_state() -> AnalyticsState:
|
||||
return AnalyticsState(install_id=uuid4().hex, last_attempt_at=0.0)
|
||||
|
||||
|
||||
def load_state(path: str = STATE_PATH) -> AnalyticsState | None:
|
||||
"""The saved state, or None when the file is missing or unusable."""
|
||||
try:
|
||||
with open(path) as f:
|
||||
data = json.load(f)
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
except (OSError, ValueError):
|
||||
logger.warning("Ignoring unreadable analytics state at %s", path)
|
||||
return None
|
||||
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
|
||||
install_id = data.get("install_id")
|
||||
last_attempt_at = data.get("last_attempt_at")
|
||||
|
||||
if (
|
||||
not isinstance(install_id, str)
|
||||
or not INSTALL_ID_PATTERN.fullmatch(install_id)
|
||||
or isinstance(last_attempt_at, bool)
|
||||
or not isinstance(last_attempt_at, int | float)
|
||||
):
|
||||
logger.warning("Ignoring invalid analytics state at %s", path)
|
||||
return None
|
||||
|
||||
return AnalyticsState(install_id=install_id, last_attempt_at=float(last_attempt_at))
|
||||
|
||||
|
||||
def save_state(state: AnalyticsState, path: str = STATE_PATH) -> bool:
|
||||
"""Write the state atomically, returning False when it can't be written."""
|
||||
temp_path = f"{path}.tmp"
|
||||
|
||||
try:
|
||||
with open(temp_path, "w") as f:
|
||||
json.dump(
|
||||
{
|
||||
"install_id": state.install_id,
|
||||
"last_attempt_at": state.last_attempt_at,
|
||||
},
|
||||
f,
|
||||
)
|
||||
|
||||
os.replace(temp_path, path)
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def delete_state(path: str = STATE_PATH) -> None:
|
||||
"""Forget the install ID, so a later opt-in starts a fresh identity."""
|
||||
try:
|
||||
os.remove(path)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except OSError:
|
||||
logger.warning("Unable to delete analytics state at %s", path)
|
||||
@@ -0,0 +1,54 @@
|
||||
"""POST a report to the ingest endpoint."""
|
||||
|
||||
import logging
|
||||
from enum import Enum
|
||||
|
||||
import requests
|
||||
|
||||
from frigate.version import VERSION
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TIMEOUT_S = 30
|
||||
|
||||
|
||||
class SendOutcome(Enum):
|
||||
accepted = "accepted"
|
||||
rejected = "rejected"
|
||||
rate_limited = "rate_limited"
|
||||
failed = "failed"
|
||||
|
||||
|
||||
def send_report(url: str, body: str) -> SendOutcome:
|
||||
"""Send one report. Never raises; the outcome says what happened."""
|
||||
try:
|
||||
# a redirect would turn the POST into a GET, so it counts as a failure
|
||||
response = requests.post(
|
||||
url,
|
||||
data=body.encode(),
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": f"Frigate/{VERSION}",
|
||||
},
|
||||
timeout=TIMEOUT_S,
|
||||
allow_redirects=False,
|
||||
)
|
||||
except requests.RequestException as err:
|
||||
logger.warning("Unable to send the analytics report: %s", err)
|
||||
return SendOutcome.failed
|
||||
|
||||
status = response.status_code
|
||||
|
||||
if 200 <= status < 300:
|
||||
return SendOutcome.accepted
|
||||
|
||||
if status == 400:
|
||||
logger.warning("The analytics report was rejected: %s", response.text[:200])
|
||||
return SendOutcome.rejected
|
||||
|
||||
if status == 429:
|
||||
logger.debug("The analytics endpoint is rate limiting this install")
|
||||
return SendOutcome.rate_limited
|
||||
|
||||
logger.warning("The analytics endpoint returned %s", status)
|
||||
return SendOutcome.failed
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Analytics APIs."""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from frigate.analytics.report import preview_report
|
||||
from frigate.api.auth import require_role
|
||||
from frigate.api.defs.tags import Tags
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=[Tags.analytics])
|
||||
|
||||
|
||||
@router.get("/analytics/preview", dependencies=[Depends(require_role(["admin"]))])
|
||||
def get_analytics_preview(request: Request) -> JSONResponse:
|
||||
"""Get the analytics report Frigate would send next, without sending it."""
|
||||
report = preview_report(
|
||||
request.app.frigate_config,
|
||||
request.app.stats_emitter,
|
||||
request.app.notice_registry,
|
||||
)
|
||||
return JSONResponse(content=report.model_dump(mode="json"))
|
||||
@@ -2,6 +2,7 @@ from enum import Enum
|
||||
|
||||
|
||||
class Tags(Enum):
|
||||
analytics = "Analytics"
|
||||
app = "App"
|
||||
auth = "Auth"
|
||||
camera = "Camera"
|
||||
|
||||
@@ -12,8 +12,8 @@ from slowapi.middleware import SlowAPIMiddleware
|
||||
from starlette_context import middleware, plugins
|
||||
from starlette_context.plugins import Plugin
|
||||
|
||||
from frigate.api import app as main_app
|
||||
from frigate.api import (
|
||||
analytics,
|
||||
auth,
|
||||
camera,
|
||||
chat,
|
||||
@@ -30,6 +30,7 @@ from frigate.api import (
|
||||
record,
|
||||
review,
|
||||
)
|
||||
from frigate.api import app as main_app
|
||||
from frigate.api.auth import get_jwt_secret, limiter, require_admin_by_default
|
||||
from frigate.comms.dispatcher import Dispatcher
|
||||
from frigate.comms.event_metadata_updater import (
|
||||
@@ -140,6 +141,7 @@ def create_fastapi_app(
|
||||
|
||||
# Routes
|
||||
# Order of include_router matters: https://fastapi.tiangolo.com/tutorial/path-params/#order-matters
|
||||
app.include_router(analytics.router)
|
||||
app.include_router(auth.router)
|
||||
app.include_router(camera.router)
|
||||
app.include_router(chat.router)
|
||||
|
||||
@@ -15,6 +15,7 @@ import uvicorn
|
||||
from peewee_migrate import Router
|
||||
from playhouse.sqlite_ext import SqliteExtDatabase
|
||||
|
||||
from frigate.analytics.reporter import AnalyticsReporter
|
||||
from frigate.api.auth import hash_password
|
||||
from frigate.api.fastapi_app import create_fastapi_app
|
||||
from frigate.camera import CameraMetrics, PTZMetrics
|
||||
@@ -529,6 +530,15 @@ class FrigateApp:
|
||||
)
|
||||
self.stats_emitter.start()
|
||||
|
||||
def start_analytics_reporter(self) -> None:
|
||||
self.analytics_reporter = AnalyticsReporter(
|
||||
self.config_holder,
|
||||
self.stats_emitter,
|
||||
self.notice_registry,
|
||||
self.stop_event,
|
||||
)
|
||||
self.analytics_reporter.start()
|
||||
|
||||
def start_watchdog(self) -> None:
|
||||
self.frigate_watchdog = FrigateWatchdog(self.detectors, self.stop_event)
|
||||
|
||||
@@ -680,6 +690,7 @@ class FrigateApp:
|
||||
self.start_audio_processor()
|
||||
self.start_storage_maintainer()
|
||||
self.start_stats_emitter()
|
||||
self.start_analytics_reporter()
|
||||
self.start_timeline_processor()
|
||||
self.start_event_processor()
|
||||
self.start_event_cleanup()
|
||||
@@ -779,6 +790,8 @@ class FrigateApp:
|
||||
self.event_cleanup.join()
|
||||
self.record_cleanup.join()
|
||||
self.stats_emitter.join()
|
||||
# a send in flight can hold the thread for the whole request timeout
|
||||
self.analytics_reporter.join(timeout=5)
|
||||
self.frigate_watchdog.join()
|
||||
self.camera_maintainer.join()
|
||||
self.db.stop()
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
"""Shared handle on the config object that is current for this instance."""
|
||||
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
|
||||
from .config import FrigateConfig
|
||||
|
||||
__all__ = ["ConfigHolder"]
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ConfigHolder:
|
||||
"""Indirection for the most recently parsed config.
|
||||
@@ -23,12 +28,24 @@ class ConfigHolder:
|
||||
|
||||
def __init__(self, config: FrigateConfig) -> None:
|
||||
self._config = config
|
||||
self._listeners: list[Callable[[FrigateConfig], None]] = []
|
||||
|
||||
@property
|
||||
def config(self) -> FrigateConfig:
|
||||
"""The config as of the most recent successful save."""
|
||||
return self._config
|
||||
|
||||
def subscribe(self, listener: Callable[[FrigateConfig], None]) -> None:
|
||||
"""Call listener on the saving thread with each config installed later."""
|
||||
self._listeners.append(listener)
|
||||
|
||||
def set(self, config: FrigateConfig) -> None:
|
||||
"""Install a freshly parsed config as the current one."""
|
||||
self._config = config
|
||||
|
||||
for listener in self._listeners:
|
||||
try:
|
||||
listener(config)
|
||||
except Exception:
|
||||
# a listener bug must not fail the save that has already applied
|
||||
logger.exception("Config listener failed")
|
||||
|
||||
@@ -29,6 +29,11 @@ class StatsConfig(FrigateBaseModel):
|
||||
|
||||
|
||||
class TelemetryConfig(FrigateBaseModel):
|
||||
analytics: bool = Field(
|
||||
default=False,
|
||||
title="Share anonymous analytics",
|
||||
description="Send one anonymous usage report a day to help the Frigate maintainers decide what to support. Nothing is sent until this is on.",
|
||||
)
|
||||
network_interfaces: list[str] = Field(
|
||||
default=[],
|
||||
title="Network interfaces",
|
||||
|
||||
@@ -101,6 +101,9 @@ MAX_WAL_SIZE = 10 # MB
|
||||
|
||||
DEFAULT_FFMPEG_VERSION = os.environ.get("DEFAULT_FFMPEG_VERSION", "")
|
||||
INCLUDED_FFMPEG_VERSIONS = os.environ.get("INCLUDED_FFMPEG_VERSIONS", "").split(":")
|
||||
ANALYTICS_URL = os.environ.get(
|
||||
"FRIGATE_ANALYTICS_URL", "https://analytics.frigate.video/report"
|
||||
)
|
||||
LIBAVFORMAT_VERSION_MAJOR = int(os.environ.get("LIBAVFORMAT_VERSION_MAJOR", "59"))
|
||||
FFMPEG_HWACCEL_NVIDIA = "preset-nvidia"
|
||||
FFMPEG_HWACCEL_VAAPI = "preset-vaapi"
|
||||
|
||||
@@ -273,6 +273,16 @@ def detect_memryx() -> DetectionHardware | None:
|
||||
return _hardware("memryx", "memryx", "MemryX MX3", units)
|
||||
|
||||
|
||||
def detect_deepx() -> DetectionHardware | None:
|
||||
"""Find DEEPX NPUs by their device nodes."""
|
||||
units = _dev_units("dxrt*", "deepx:PCIe:{index}", "PCIe")
|
||||
|
||||
if not units:
|
||||
return None
|
||||
|
||||
return _hardware("deepx", "deepx", "DEEPX NPU", units)
|
||||
|
||||
|
||||
def detect_rockchip() -> DetectionHardware | None:
|
||||
"""Find a Rockchip NPU by reading the SoC from the device tree."""
|
||||
compatible = _read(f"{PROC_ROOT}/device-tree/compatible")
|
||||
@@ -319,6 +329,7 @@ PROBES = (
|
||||
detect_coral_usb,
|
||||
detect_hailo,
|
||||
detect_memryx,
|
||||
detect_deepx,
|
||||
detect_intel_npu,
|
||||
detect_intel_gpu,
|
||||
detect_nvidia_gpu,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -319,6 +319,19 @@ class NoticeRegistry:
|
||||
if row.kind in NOTICE_KINDS
|
||||
]
|
||||
|
||||
def mark_reported(self, snapshot: list[dict[str, Any]]) -> None:
|
||||
"""Move the analytics watermarks to the counts a sent report was built from.
|
||||
|
||||
Using the snapshot rather than the current counts sends anything raised
|
||||
while the report was in flight with the next one.
|
||||
"""
|
||||
with self._lock:
|
||||
for row in snapshot:
|
||||
NoticeStats.update(
|
||||
reported_occurrences=row["occurrences"],
|
||||
reported_dismissals=row["dismissals"],
|
||||
).where(NoticeStats.kind == row["kind"]).execute()
|
||||
|
||||
def _write_repeats(
|
||||
self,
|
||||
row: Notice,
|
||||
|
||||
@@ -86,6 +86,16 @@ _KINDS = (
|
||||
link="https://github.com/blakeblackshear/frigate/releases/tag/v{version}",
|
||||
counts_repeats=False,
|
||||
keep_latest=1,
|
||||
reportable=False,
|
||||
),
|
||||
# raised while analytics is off; the row keeps a dismissal across restarts
|
||||
NoticeKind(
|
||||
"analytics_prompt",
|
||||
NoticeSeverity.info,
|
||||
"system",
|
||||
link="/settings?page=systemTelemetry",
|
||||
counts_repeats=False,
|
||||
reportable=False,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Shared fixtures for the analytics tests."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from frigate.analytics.context import ReportContext
|
||||
from frigate.config import FrigateConfig
|
||||
|
||||
FRONT_CAMERA: dict[str, Any] = {
|
||||
"ffmpeg": {"inputs": [{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}]},
|
||||
"detect": {"height": 720, "width": 1280, "fps": 5},
|
||||
}
|
||||
|
||||
|
||||
def make_config(config: dict[str, Any] | None = None) -> FrigateConfig:
|
||||
"""A parsed config; top-level keys in config replace the defaults here.
|
||||
|
||||
hwaccel_args is set so parsing never probes the test host's GPU.
|
||||
"""
|
||||
base: dict[str, Any] = {
|
||||
"mqtt": {"host": "mqtt"},
|
||||
"ffmpeg": {"hwaccel_args": []},
|
||||
"cameras": {"front": FRONT_CAMERA},
|
||||
}
|
||||
return FrigateConfig(**{**base, **(config or {})})
|
||||
|
||||
|
||||
def make_context(
|
||||
config: FrigateConfig | None = None,
|
||||
stats: dict[str, Any] | None = None,
|
||||
notice_stats: list[dict[str, Any]] | None = None,
|
||||
) -> ReportContext:
|
||||
return ReportContext(
|
||||
config=config or make_config(),
|
||||
stats=stats or {},
|
||||
notice_stats=notice_stats or [],
|
||||
)
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Tests for the analytics preview API."""
|
||||
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from frigate.analytics import report
|
||||
from frigate.analytics.collectors import hardware
|
||||
from frigate.models import Notice, NoticeStats, User
|
||||
from frigate.notices.registry import NoticeRegistry
|
||||
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
|
||||
|
||||
|
||||
class TestHttpAnalytics(BaseTestHttp):
|
||||
def setUp(self):
|
||||
super().setUp([Notice, NoticeStats, User])
|
||||
stats = Mock()
|
||||
stats.get_latest_stats.return_value = self.test_stats
|
||||
self.app = self.create_app(stats=stats, notice_registry=NoticeRegistry())
|
||||
|
||||
for target, name, value in (
|
||||
(report, "load_state", None),
|
||||
(hardware.hardware_prober, "probe", []),
|
||||
(hardware, "hwaccel_options", ("", [])),
|
||||
):
|
||||
patcher = patch.object(target, name, return_value=value)
|
||||
patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
|
||||
def test_preview_returns_the_report_without_sending_it(self):
|
||||
with (
|
||||
patch("frigate.analytics.transport.requests.post") as post,
|
||||
AuthTestClient(self.app) as client,
|
||||
):
|
||||
response = client.get("/analytics/preview")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
body = response.json()
|
||||
self.assertEqual(body["install_id"], report.PREVIEW_INSTALL_ID)
|
||||
self.assertEqual(body["schema_version"], 1)
|
||||
self.assertEqual(body["cameras"]["total"], 1)
|
||||
post.assert_not_called()
|
||||
|
||||
def test_preview_is_admin_only(self):
|
||||
with AuthTestClient(self.app) as client:
|
||||
response = client.get(
|
||||
"/analytics/preview",
|
||||
headers={"remote-user": "viewer", "remote-role": "viewer"},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 403)
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Tests for the cameras collector."""
|
||||
|
||||
import unittest
|
||||
|
||||
from frigate.analytics.collectors import cameras
|
||||
from frigate.analytics.schema import HeightBucket, HwaccelKey, RetainBucket
|
||||
from frigate.test.analytics_helpers import make_config, make_context
|
||||
|
||||
MASK = {"coordinates": "0,0,1,0,1,1"}
|
||||
|
||||
CONFIG = {
|
||||
"record": {
|
||||
"enabled": True,
|
||||
"continuous": {"days": 3},
|
||||
"alerts": {"retain": {"days": 14}},
|
||||
"detections": {"retain": {"days": 45}},
|
||||
},
|
||||
"cameras": {
|
||||
"front": {
|
||||
"ffmpeg": {
|
||||
"hwaccel_args": "preset-vaapi",
|
||||
"inputs": [
|
||||
{
|
||||
"path": "rtsp://127.0.0.1:8554/front_sub",
|
||||
"roles": ["detect"],
|
||||
"input_args": "preset-rtsp-restream",
|
||||
},
|
||||
{"path": "rtsp://127.0.0.1:8554/front", "roles": ["record"]},
|
||||
],
|
||||
},
|
||||
"detect": {"enabled": True, "height": 720, "width": 1280, "fps": 5},
|
||||
"zones": {"porch": MASK, "yard": MASK},
|
||||
"motion": {"mask": {"tree": MASK}},
|
||||
"objects": {
|
||||
"mask": {"sign": MASK},
|
||||
"filters": {"person": {"mask": {"bush": MASK}}},
|
||||
},
|
||||
"onvif": {"host": "10.0.0.9"},
|
||||
},
|
||||
"garage": {
|
||||
"enabled": False,
|
||||
"type": "lpr",
|
||||
"ffmpeg": {
|
||||
"hwaccel_args": ["-hwaccel", "vaapi"],
|
||||
"inputs": [
|
||||
{"path": "rtsp://10.0.0.5/stream", "roles": ["detect", "record"]}
|
||||
],
|
||||
},
|
||||
"detect": {"height": 2160, "width": 3840, "fps": 15},
|
||||
"record": {"enabled": False},
|
||||
},
|
||||
"_replay_front": {
|
||||
"ffmpeg": {
|
||||
"inputs": [{"path": "rtsp://10.0.0.1/replay", "roles": ["detect"]}]
|
||||
},
|
||||
"detect": {"height": 1080, "width": 1920, "fps": 5},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
STATS = {
|
||||
"cameras": {
|
||||
"front": {"connection_quality": "excellent"},
|
||||
"garage": {"connection_quality": "offline"},
|
||||
"_replay_front": {"connection_quality": "fair"},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class TestCamerasCollector(unittest.TestCase):
|
||||
def test_aggregates_cameras_and_skips_replays(self):
|
||||
section = cameras.collect(make_context(make_config(CONFIG), STATS))
|
||||
|
||||
self.assertEqual((section.total, section.enabled), (2, 1))
|
||||
self.assertEqual(section.types, {"generic": 1, "lpr": 1})
|
||||
self.assertEqual(section.detect_height, {"720": 1, "ge_2160": 1})
|
||||
self.assertEqual(section.detect_fps, {"le_5": 1, "gt_10": 1})
|
||||
self.assertEqual(section.hwaccel, {"vaapi": 1, "custom": 1})
|
||||
self.assertEqual(section.input_preset, {"rtsp-restream": 1, "rtsp-generic": 1})
|
||||
self.assertEqual(section.go2rtc_restream, 1)
|
||||
self.assertEqual(section.separate_detect_stream, 1)
|
||||
self.assertEqual((section.detect, section.record, section.onvif), (1, 1, 1))
|
||||
self.assertEqual((section.zones, section.cameras_with_zones), (2, 1))
|
||||
self.assertEqual((section.motion_masks, section.object_masks), (1, 2))
|
||||
self.assertEqual(section.connection_quality, {"excellent": 1})
|
||||
self.assertEqual(section.retain_days.continuous, {"1_7": 1})
|
||||
self.assertEqual(section.retain_days.motion, {"0": 1})
|
||||
self.assertEqual(section.retain_days.alerts, {"8_30": 1})
|
||||
self.assertEqual(section.retain_days.detections, {"gt_30": 1})
|
||||
|
||||
def test_buckets(self):
|
||||
self.assertEqual(cameras.height_bucket(360), HeightBucket.le_360)
|
||||
self.assertEqual(cameras.height_bucket(361), HeightBucket.h480)
|
||||
self.assertEqual(cameras.height_bucket(1080), HeightBucket.h1080)
|
||||
self.assertEqual(cameras.height_bucket(1440), HeightBucket.h1440)
|
||||
self.assertEqual(cameras.height_bucket(2160), HeightBucket.ge_2160)
|
||||
self.assertEqual(cameras.retain_bucket(0.5), RetainBucket.d1_7)
|
||||
self.assertEqual(cameras.retain_bucket(0), RetainBucket.zero)
|
||||
|
||||
def test_preset_key(self):
|
||||
self.assertEqual(cameras.preset_key([], HwaccelKey), "none")
|
||||
self.assertEqual(cameras.preset_key("", HwaccelKey), "none")
|
||||
self.assertEqual(cameras.preset_key("preset-bogus", HwaccelKey), "custom")
|
||||
self.assertEqual(cameras.preset_key("-hwaccel vaapi", HwaccelKey), "custom")
|
||||
self.assertEqual(cameras.preset_key("preset-nvidia", HwaccelKey), "nvidia")
|
||||
|
||||
def test_is_restream(self):
|
||||
self.assertTrue(cameras.is_restream("rtsp://127.0.0.1:8554/front"))
|
||||
self.assertTrue(cameras.is_restream("rtsp://localhost:8554/front"))
|
||||
self.assertFalse(cameras.is_restream("rtsp://10.0.0.5:8554/front"))
|
||||
self.assertFalse(cameras.is_restream("rtsp://[bad"))
|
||||
self.assertFalse(cameras.is_restream("rtsp://127.0.0.1:notaport/x"))
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Tests for the detection collector."""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from frigate.analytics.collectors import detection
|
||||
from frigate.analytics.schema import ModelSource
|
||||
from frigate.detectors.detector_config import SceneEnum
|
||||
from frigate.test.analytics_helpers import make_config, make_context
|
||||
|
||||
|
||||
class TestDetectionCollector(unittest.TestCase):
|
||||
def test_reports_each_model_with_its_mean_inference_speed(self):
|
||||
config = make_config({"models": [{"devices": ["cpu", "cpu"]}]})
|
||||
stats = {
|
||||
"detectors": {
|
||||
"cpu": {"inference_speed": 10.0},
|
||||
"cpu#2": {"inference_speed": 20.0},
|
||||
},
|
||||
"detection_fps": 12.346,
|
||||
"skipped_fps": 0,
|
||||
}
|
||||
|
||||
section = detection.collect(make_context(config, stats))
|
||||
|
||||
model = section.models[SceneEnum.all]
|
||||
self.assertEqual(model.detector, "cpu")
|
||||
self.assertEqual(model.devices, 2)
|
||||
self.assertEqual(model.model_type, "ssd")
|
||||
self.assertEqual(model.input, "320x320")
|
||||
self.assertEqual(model.source, ModelSource.default)
|
||||
self.assertEqual(model.inference_ms, 15.0)
|
||||
self.assertEqual(section.detection_fps, 12.35)
|
||||
self.assertEqual(section.skipped_fps, 0.0)
|
||||
|
||||
def test_inference_is_null_before_the_first_stats(self):
|
||||
section = detection.collect(make_context())
|
||||
|
||||
self.assertIsNone(section.models[SceneEnum.all].inference_ms)
|
||||
|
||||
def test_model_source(self):
|
||||
with tempfile.TemporaryDirectory() as cache:
|
||||
plus_model = os.path.join(cache, "abc123")
|
||||
open(f"{plus_model}.json", "w").close()
|
||||
|
||||
with patch.object(detection, "MODEL_CACHE_DIR", cache):
|
||||
self.assertEqual(detection.model_source(plus_model), ModelSource.plus)
|
||||
self.assertEqual(
|
||||
detection.model_source(os.path.join(cache, "no_info")),
|
||||
ModelSource.custom,
|
||||
)
|
||||
|
||||
self.assertEqual(detection.model_source(None), ModelSource.default)
|
||||
self.assertEqual(
|
||||
detection.model_source("/cpu_model.tflite"), ModelSource.default
|
||||
)
|
||||
self.assertEqual(
|
||||
detection.model_source("/config/yolo.onnx"), ModelSource.custom
|
||||
)
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Tests for the features collector."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import unittest
|
||||
|
||||
from peewee_migrate import Router
|
||||
from playhouse.sqlite_ext import SqliteExtDatabase
|
||||
from playhouse.sqliteq import SqliteQueueDatabase
|
||||
|
||||
from frigate.analytics.collectors import features
|
||||
from frigate.analytics.schema import EnrichmentDevice, SemanticSearchModel
|
||||
from frigate.models import User
|
||||
from frigate.test.analytics_helpers import FRONT_CAMERA, make_config, make_context
|
||||
from frigate.test.const import TEST_DB, TEST_DB_CLEANUPS
|
||||
|
||||
CONFIG = {
|
||||
"mqtt": {"host": "mqtt", "enabled": True},
|
||||
"genai": {
|
||||
"local": {
|
||||
"provider": "ollama",
|
||||
"model": "llava",
|
||||
"base_url": "http://ollama:11434",
|
||||
"roles": ["descriptions", "embeddings"],
|
||||
}
|
||||
},
|
||||
"semantic_search": {"enabled": True, "model": "local"},
|
||||
"face_recognition": {"enabled": True, "model_size": "large"},
|
||||
"birdseye": {"enabled": True, "modes": ["motion", "alerts"], "restream": True},
|
||||
"proxy": {"header_map": {"user": "x-forwarded-user"}},
|
||||
"camera_groups": {"outside": {"cameras": ["front"], "icon": "LuCar", "order": 0}},
|
||||
"cameras": {
|
||||
"front": {
|
||||
**FRONT_CAMERA,
|
||||
"semantic_search": {
|
||||
"triggers": {
|
||||
"cat": {
|
||||
"type": "description",
|
||||
"data": "a cat",
|
||||
"threshold": 0.8,
|
||||
"actions": ["notification"],
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class TestFeaturesCollector(unittest.TestCase):
|
||||
def setUp(self):
|
||||
migrate_db = SqliteExtDatabase(TEST_DB)
|
||||
del logging.getLogger("peewee_migrate").handlers[:]
|
||||
Router(migrate_db).run()
|
||||
migrate_db.close()
|
||||
self.db = SqliteQueueDatabase(TEST_DB)
|
||||
self.db.bind([User])
|
||||
|
||||
def tearDown(self):
|
||||
# close() leaves the queue's writer thread running against the deleted file
|
||||
self.db.stop()
|
||||
|
||||
if not self.db.is_closed():
|
||||
self.db.close()
|
||||
|
||||
for file in TEST_DB_CLEANUPS:
|
||||
try:
|
||||
os.remove(file)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def test_collects_enrichments_genai_integrations_and_users(self):
|
||||
for username, role in (("a", "admin"), ("v", "viewer"), ("o", "operator")):
|
||||
User.create(
|
||||
username=username, role=role, password_hash="x", notification_tokens=[]
|
||||
)
|
||||
|
||||
stats = {
|
||||
"embeddings": {
|
||||
"devices": {
|
||||
"face_recognition": "OpenVINO GPU.0",
|
||||
"semantic_search": "CUDA",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
section = features.collect(make_context(make_config(CONFIG), stats))
|
||||
|
||||
self.assertEqual(section.face_recognition.device, EnrichmentDevice.openvino_gpu)
|
||||
self.assertEqual(section.face_recognition.model_size, "large")
|
||||
self.assertIsNone(section.lpr.device)
|
||||
self.assertEqual(section.semantic_search.model, SemanticSearchModel.genai)
|
||||
self.assertEqual(section.semantic_search.device, EnrichmentDevice.cuda)
|
||||
self.assertEqual(section.semantic_search.triggers, 1)
|
||||
self.assertEqual(section.genai.providers, {"ollama": 1})
|
||||
self.assertEqual(section.genai.roles, {"descriptions": 1, "embeddings": 1})
|
||||
self.assertEqual(section.birdseye.modes, ["motion", "alerts"])
|
||||
self.assertTrue(section.mqtt)
|
||||
self.assertTrue(section.proxy_auth)
|
||||
self.assertEqual(section.users, {"admin": 1, "viewer": 1, "custom": 1})
|
||||
self.assertEqual((section.camera_groups, section.profiles), (1, 0))
|
||||
self.assertFalse(section.plus_api_key)
|
||||
|
||||
|
||||
class TestFeatureMappings(unittest.TestCase):
|
||||
def test_enrichment_device_labels(self):
|
||||
cases = {
|
||||
"CPU": EnrichmentDevice.cpu,
|
||||
"MIGraphX": EnrichmentDevice.migraphx,
|
||||
"OpenVINO NPU": EnrichmentDevice.openvino_npu,
|
||||
"OpenVINO GPU.0,CPU": EnrichmentDevice.openvino_gpu,
|
||||
"OpenVINO": EnrichmentDevice.other,
|
||||
"CoreML": EnrichmentDevice.other,
|
||||
}
|
||||
|
||||
for label, device in cases.items():
|
||||
with self.subTest(label=label):
|
||||
self.assertEqual(features.enrichment_device(label), device)
|
||||
|
||||
self.assertIsNone(features.enrichment_device(None))
|
||||
|
||||
def test_models_named_after_a_provider_are_genai(self):
|
||||
self.assertEqual(features.semantic_model("jinav2"), SemanticSearchModel.jinav2)
|
||||
self.assertEqual(
|
||||
features.semantic_model("my-ollama"), SemanticSearchModel.genai
|
||||
)
|
||||
self.assertIsNone(features.semantic_model(None))
|
||||
self.assertEqual(features.transcription_model("openai"), "genai")
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Tests for the health collector."""
|
||||
|
||||
import unittest
|
||||
|
||||
from frigate.analytics.collectors import health
|
||||
from frigate.test.analytics_helpers import make_context
|
||||
|
||||
|
||||
class TestHealthCollector(unittest.TestCase):
|
||||
def test_collects_uptime_cpu_timings_and_notice_deltas(self):
|
||||
stats = {
|
||||
"service": {"uptime": 7300, "retention_unmet": True},
|
||||
"cpu_usages": {"frigate.full_system": {"cpu": "23.6", "mem": "40"}},
|
||||
"embeddings": {
|
||||
"face_recognition_speed": 41.234,
|
||||
"text_embedding_speed": 0,
|
||||
"devices": {},
|
||||
},
|
||||
}
|
||||
notice_stats = [
|
||||
{
|
||||
"kind": "detector_stuck",
|
||||
"occurrences": 5,
|
||||
"dismissals": 1,
|
||||
"reported_occurrences": 3,
|
||||
"reported_dismissals": 1,
|
||||
},
|
||||
{
|
||||
"kind": "shm_too_low",
|
||||
"occurrences": 2,
|
||||
"dismissals": 0,
|
||||
"reported_occurrences": 2,
|
||||
"reported_dismissals": 0,
|
||||
},
|
||||
{
|
||||
"kind": "update_available",
|
||||
"occurrences": 4,
|
||||
"dismissals": 0,
|
||||
"reported_occurrences": 0,
|
||||
"reported_dismissals": 0,
|
||||
},
|
||||
]
|
||||
|
||||
section = health.collect(make_context(stats=stats, notice_stats=notice_stats))
|
||||
|
||||
self.assertEqual(section.uptime_hours, 2)
|
||||
self.assertEqual(section.cpu_percent, 24)
|
||||
self.assertEqual(section.enrichment_ms, {"face": 41.23})
|
||||
self.assertTrue(section.retention_unmet)
|
||||
self.assertEqual(
|
||||
{
|
||||
kind: (c.occurrences, c.dismissals)
|
||||
for kind, c in section.notices.items()
|
||||
},
|
||||
{"detector_stuck": (2, 0)},
|
||||
)
|
||||
|
||||
def test_missing_stats_give_zeros(self):
|
||||
section = health.collect(make_context())
|
||||
|
||||
self.assertEqual(
|
||||
(section.uptime_hours, section.cpu_percent, section.enrichment_ms),
|
||||
(0, 0, {}),
|
||||
)
|
||||
self.assertEqual(section.notices, {})
|
||||
@@ -0,0 +1,188 @@
|
||||
"""Tests for the install and hardware collectors."""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from collections import Counter
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from frigate.analytics.collectors import hardware, install
|
||||
from frigate.analytics.collectors.common import closed, histogram, rate
|
||||
from frigate.analytics.schema import (
|
||||
Arch,
|
||||
DecodeFamily,
|
||||
GpuVendor,
|
||||
HardwareKey,
|
||||
ImageVariant,
|
||||
InstallType,
|
||||
)
|
||||
from frigate.const import RECORD_DIR
|
||||
from frigate.test.analytics_helpers import make_context
|
||||
|
||||
|
||||
class TestCommon(unittest.TestCase):
|
||||
def test_closed_falls_back_for_unknown_values(self):
|
||||
self.assertEqual(closed(Arch, "x86_64", Arch.other), Arch.x86_64)
|
||||
self.assertEqual(closed(Arch, "sparc", Arch.other), Arch.other)
|
||||
self.assertEqual(closed(Arch, None, Arch.other), Arch.other)
|
||||
|
||||
def test_rate_rounds_and_rejects_bad_numbers(self):
|
||||
self.assertEqual(rate("12.346"), 12.35)
|
||||
self.assertEqual(rate(-3), 0.0)
|
||||
self.assertEqual(rate(float("nan")), 0.0)
|
||||
self.assertEqual(rate(float("inf")), 0.0)
|
||||
self.assertEqual(rate(None), 0.0)
|
||||
|
||||
def test_histogram_drops_empty_buckets(self):
|
||||
self.assertEqual(
|
||||
histogram(Counter({Arch.x86_64: 2, Arch.other: 0})), {"x86_64": 2}
|
||||
)
|
||||
|
||||
|
||||
class TestInstallCollector(unittest.TestCase):
|
||||
def test_collects_version_variant_and_platform(self):
|
||||
with (
|
||||
patch.dict(os.environ, {"FRIGATE_IMAGE_VARIANT": "tensorrt-jp6"}),
|
||||
patch.object(install, "install_type", return_value=InstallType.docker),
|
||||
patch("platform.machine", return_value="aarch64"),
|
||||
patch("platform.release", return_value="6.8.0-45-generic"),
|
||||
patch("os.geteuid", return_value=1000),
|
||||
):
|
||||
section = install.collect(make_context())
|
||||
|
||||
self.assertEqual(section.image_variant, ImageVariant.tensorrt_jp6)
|
||||
self.assertEqual(section.install_type, InstallType.docker)
|
||||
self.assertEqual(section.arch, Arch.aarch64)
|
||||
self.assertEqual(section.kernel, "6.8")
|
||||
self.assertFalse(section.run_as_root)
|
||||
|
||||
def test_image_variant_is_dev_when_unset_and_other_when_unknown(self):
|
||||
self.assertEqual(install.image_variant(None), ImageVariant.dev)
|
||||
self.assertEqual(install.image_variant(""), ImageVariant.dev)
|
||||
self.assertEqual(install.image_variant("h8l"), ImageVariant.other)
|
||||
|
||||
def test_install_type_checks_the_add_on_first(self):
|
||||
with patch("os.path.isfile", return_value=True):
|
||||
self.assertEqual(install.install_type(), InstallType.ha_addon)
|
||||
|
||||
with (
|
||||
patch("os.path.isfile", return_value=False),
|
||||
patch.dict(os.environ, {"KUBERNETES_SERVICE_HOST": "10.0.0.1"}),
|
||||
):
|
||||
self.assertEqual(install.install_type(), InstallType.kubernetes)
|
||||
|
||||
env = {k: v for k, v in os.environ.items() if k != "KUBERNETES_SERVICE_HOST"}
|
||||
|
||||
with (
|
||||
patch("os.path.isfile", return_value=False),
|
||||
patch.dict(os.environ, env, clear=True),
|
||||
patch("os.path.exists", side_effect=lambda path: path == "/.dockerenv"),
|
||||
):
|
||||
self.assertEqual(install.install_type(), InstallType.docker)
|
||||
|
||||
def test_kernel_and_arch_fall_back(self):
|
||||
self.assertEqual(install.kernel("weird"), "unknown")
|
||||
self.assertEqual(install.arch("armv7l"), Arch.other)
|
||||
self.assertEqual(install.arch("amd64"), Arch.x86_64)
|
||||
|
||||
|
||||
class TestHardwareCollector(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.dir = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.dir.cleanup)
|
||||
|
||||
def write(self, name: str, content: str) -> str:
|
||||
path = os.path.join(self.dir.name, name)
|
||||
|
||||
with open(path, "w") as f:
|
||||
f.write(content)
|
||||
|
||||
return path
|
||||
|
||||
def test_cpu_model_prefers_the_board_model(self):
|
||||
board = self.write("model", "Raspberry Pi 5 Model B Rev 1.0\x00")
|
||||
cpuinfo = self.write("cpuinfo", "model name\t: Cortex-A76\n")
|
||||
|
||||
with (
|
||||
patch.object(hardware, "DEVICE_TREE_MODEL", board),
|
||||
patch.object(hardware, "CPUINFO", cpuinfo),
|
||||
):
|
||||
self.assertEqual(hardware.cpu_model(), "Raspberry Pi 5 Model B Rev 1.0")
|
||||
|
||||
def test_cpu_model_reads_cpuinfo_and_truncates(self):
|
||||
cpuinfo = self.write("cpuinfo", "processor\t: 0\nmodel name\t: " + "x" * 80)
|
||||
|
||||
with (
|
||||
patch.object(hardware, "DEVICE_TREE_MODEL", self.dir.name + "/none"),
|
||||
patch.object(hardware, "CPUINFO", cpuinfo),
|
||||
):
|
||||
self.assertEqual(hardware.cpu_model(), "x" * 64)
|
||||
|
||||
def test_collects_gpus_decode_detection_and_storage(self):
|
||||
stats = {
|
||||
"gpu_usages": {
|
||||
"NVIDIA GeForce RTX 3060": {"vendor": "nvidia"},
|
||||
"mystery": {},
|
||||
},
|
||||
"service": {
|
||||
"storage": {
|
||||
RECORD_DIR: {
|
||||
"total": 2048000.0,
|
||||
"used": 512000.0,
|
||||
"mount_type": "nfs4",
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
probed = [
|
||||
SimpleNamespace(key="edgetpu:usb", count=2),
|
||||
SimpleNamespace(key="new:thing", count=1),
|
||||
SimpleNamespace(key="cpu", count=1),
|
||||
]
|
||||
families = [
|
||||
SimpleNamespace(key="vaapi"),
|
||||
SimpleNamespace(key="intel-qsv"),
|
||||
SimpleNamespace(key="future"),
|
||||
]
|
||||
|
||||
with (
|
||||
patch.object(hardware.hardware_prober, "probe", return_value=probed),
|
||||
patch.object(hardware, "hwaccel_options", return_value=("vaapi", families)),
|
||||
patch.object(hardware, "cpu_model", return_value="Intel(R) N100"),
|
||||
patch("os.cpu_count", return_value=4),
|
||||
patch(
|
||||
"psutil.virtual_memory", return_value=SimpleNamespace(total=16 * 2**30)
|
||||
),
|
||||
):
|
||||
section = hardware.collect(make_context(stats=stats))
|
||||
|
||||
self.assertEqual(section.cpu_model, "Intel(R) N100")
|
||||
self.assertEqual(section.cpu_cores, 4)
|
||||
self.assertEqual(section.memory_gb, 16)
|
||||
self.assertEqual(
|
||||
[(gpu.vendor, gpu.name) for gpu in section.gpus],
|
||||
[
|
||||
(GpuVendor.nvidia, "NVIDIA GeForce RTX 3060"),
|
||||
(GpuVendor.other, "mystery"),
|
||||
],
|
||||
)
|
||||
self.assertEqual(
|
||||
section.decode_families,
|
||||
[DecodeFamily.vaapi, DecodeFamily.intel_qsv, DecodeFamily.other],
|
||||
)
|
||||
self.assertEqual(
|
||||
section.detection_hardware,
|
||||
{HardwareKey.edgetpu_usb: 2, HardwareKey.other: 1, HardwareKey.cpu: 1},
|
||||
)
|
||||
self.assertEqual(section.storage.record_fs, "nfs4")
|
||||
self.assertEqual(section.storage.record_total_gb, 2000)
|
||||
self.assertEqual(section.storage.record_used_pct, 25)
|
||||
|
||||
def test_storage_without_stats_is_unknown_and_empty(self):
|
||||
section = hardware.storage({})
|
||||
|
||||
self.assertEqual(
|
||||
(section.record_fs, section.record_total_gb, section.record_used_pct),
|
||||
("unknown", 0, 0),
|
||||
)
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Tests for building a report from the collectors."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import unittest
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from peewee_migrate import Router
|
||||
from playhouse.sqlite_ext import SqliteExtDatabase
|
||||
from playhouse.sqliteq import SqliteQueueDatabase
|
||||
|
||||
from frigate.analytics import report
|
||||
from frigate.analytics.collectors import hardware
|
||||
from frigate.analytics.schema import AnalyticsReport, InstallSection
|
||||
from frigate.models import User
|
||||
from frigate.test.analytics_helpers import make_context
|
||||
from frigate.test.const import TEST_DB, TEST_DB_CLEANUPS
|
||||
|
||||
INSTALL = InstallSection(
|
||||
version="0.19.0",
|
||||
image_variant="dev",
|
||||
install_type="docker",
|
||||
arch="x86_64",
|
||||
kernel="6.8",
|
||||
run_as_root=False,
|
||||
)
|
||||
|
||||
|
||||
class TestBuildReport(unittest.TestCase):
|
||||
def test_a_failing_collector_nulls_only_its_section(self):
|
||||
collectors = {
|
||||
"install": lambda ctx: INSTALL,
|
||||
"hardware": Mock(side_effect=RuntimeError("boom")),
|
||||
"detection": lambda ctx: None,
|
||||
"cameras": lambda ctx: None,
|
||||
"features": lambda ctx: None,
|
||||
"health": lambda ctx: None,
|
||||
}
|
||||
|
||||
with patch.dict(report.COLLECTORS, collectors):
|
||||
built = report.build_report(make_context(), "a" * 32, sent_at=1790000000)
|
||||
|
||||
self.assertEqual(built.install, INSTALL)
|
||||
self.assertIsNone(built.hardware)
|
||||
self.assertEqual(built.schema_version, 1)
|
||||
self.assertEqual(built.sent_at, 1790000000)
|
||||
self.assertRegex(built.report_id, r"^[0-9a-f-]{36}$")
|
||||
|
||||
|
||||
class TestRealCollectors(unittest.TestCase):
|
||||
def setUp(self):
|
||||
migrate_db = SqliteExtDatabase(TEST_DB)
|
||||
del logging.getLogger("peewee_migrate").handlers[:]
|
||||
Router(migrate_db).run()
|
||||
migrate_db.close()
|
||||
self.db = SqliteQueueDatabase(TEST_DB)
|
||||
self.db.bind([User])
|
||||
|
||||
def tearDown(self):
|
||||
# close() leaves the queue's writer thread running against the deleted file
|
||||
self.db.stop()
|
||||
|
||||
if not self.db.is_closed():
|
||||
self.db.close()
|
||||
|
||||
for file in TEST_DB_CLEANUPS:
|
||||
try:
|
||||
os.remove(file)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def test_every_section_builds_and_round_trips_through_json(self):
|
||||
with (
|
||||
patch.object(hardware.hardware_prober, "probe", return_value=[]),
|
||||
patch.object(hardware, "hwaccel_options", return_value=("", [])),
|
||||
):
|
||||
built = report.build_report(make_context(), "a" * 32)
|
||||
|
||||
body = built.model_dump_json()
|
||||
parsed = json.loads(body)
|
||||
|
||||
for section in (
|
||||
"install",
|
||||
"hardware",
|
||||
"detection",
|
||||
"cameras",
|
||||
"features",
|
||||
"health",
|
||||
):
|
||||
with self.subTest(section=section):
|
||||
self.assertIsNotNone(parsed[section])
|
||||
|
||||
self.assertEqual(AnalyticsReport.model_validate_json(body), built)
|
||||
|
||||
def test_preview_uses_a_placeholder_id_before_the_first_report(self):
|
||||
stats_emitter = Mock()
|
||||
stats_emitter.get_latest_stats.return_value = {}
|
||||
|
||||
with (
|
||||
patch.object(report, "load_state", return_value=None),
|
||||
patch.object(hardware.hardware_prober, "probe", return_value=[]),
|
||||
patch.object(hardware, "hwaccel_options", return_value=("", [])),
|
||||
):
|
||||
built = report.preview_report(make_context().config, stats_emitter, None)
|
||||
|
||||
self.assertEqual(built.install_id, report.PREVIEW_INSTALL_ID)
|
||||
@@ -0,0 +1,227 @@
|
||||
"""Tests for the analytics reporter's schedule and consent handling."""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from frigate.analytics import reporter as reporter_module
|
||||
from frigate.analytics.reporter import (
|
||||
FIRST_DELAY_S,
|
||||
INTERVAL_S,
|
||||
JITTER_S,
|
||||
PROMPT_KIND,
|
||||
WAKE_S,
|
||||
AnalyticsReporter,
|
||||
)
|
||||
from frigate.analytics.state import AnalyticsState, load_state, save_state
|
||||
from frigate.analytics.transport import SendOutcome
|
||||
from frigate.config.holder import ConfigHolder
|
||||
from frigate.test.analytics_helpers import make_config
|
||||
|
||||
SNAPSHOT = [
|
||||
{
|
||||
"kind": "detector_stuck",
|
||||
"occurrences": 3,
|
||||
"dismissals": 0,
|
||||
"reported_occurrences": 1,
|
||||
"reported_dismissals": 0,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
class TestAnalyticsReporter(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.dir = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.dir.cleanup)
|
||||
self.path = os.path.join(self.dir.name, ".analytics.json")
|
||||
self.now = 1_790_000_000.0
|
||||
self.send = Mock(return_value=SendOutcome.accepted)
|
||||
self.registry = Mock()
|
||||
self.registry.stats.return_value = SNAPSHOT
|
||||
self.stats = Mock()
|
||||
self.stats.get_latest_stats.return_value = {}
|
||||
self.holder = ConfigHolder(make_config({"telemetry": {"analytics": True}}))
|
||||
|
||||
for name in ("raise_notice", "resolve_notice"):
|
||||
patcher = patch.object(reporter_module, name)
|
||||
setattr(self, name, patcher.start())
|
||||
self.addCleanup(patcher.stop)
|
||||
|
||||
self.built = Mock()
|
||||
self.built.model_dump_json.return_value = '{"report": 1}'
|
||||
patcher = patch.object(reporter_module, "build_report", return_value=self.built)
|
||||
self.build_report = patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
|
||||
def reporter(self, path: str | None = None) -> AnalyticsReporter:
|
||||
# uniform(a, b) returns a: the shortest startup delay and interval
|
||||
rng = Mock()
|
||||
rng.uniform.side_effect = lambda low, high: low
|
||||
|
||||
return AnalyticsReporter(
|
||||
self.holder,
|
||||
self.stats,
|
||||
self.registry,
|
||||
threading.Event(),
|
||||
state_path=path or self.path,
|
||||
url="https://example.test/report",
|
||||
send=self.send,
|
||||
clock=lambda: self.now,
|
||||
rng=rng,
|
||||
)
|
||||
|
||||
def advance(self, seconds: float) -> None:
|
||||
self.now += seconds
|
||||
|
||||
def test_waits_for_the_startup_delay_then_sends(self):
|
||||
reporter = self.reporter()
|
||||
|
||||
reporter.tick()
|
||||
self.send.assert_not_called()
|
||||
|
||||
self.advance(FIRST_DELAY_S[0])
|
||||
reporter.tick()
|
||||
|
||||
self.send.assert_called_once_with(
|
||||
"https://example.test/report", '{"report": 1}'
|
||||
)
|
||||
self.registry.mark_reported.assert_called_once_with(SNAPSHOT)
|
||||
self.assertEqual(load_state(self.path).last_attempt_at, self.now)
|
||||
|
||||
def test_sends_once_per_interval(self):
|
||||
reporter = self.reporter()
|
||||
self.advance(FIRST_DELAY_S[0])
|
||||
reporter.tick()
|
||||
|
||||
self.advance(WAKE_S)
|
||||
reporter.tick()
|
||||
self.assertEqual(self.send.call_count, 1)
|
||||
|
||||
self.advance(INTERVAL_S - JITTER_S)
|
||||
reporter.tick()
|
||||
self.assertEqual(self.send.call_count, 2)
|
||||
|
||||
def test_a_restart_inside_the_interval_does_not_send(self):
|
||||
save_state(AnalyticsState("a" * 32, self.now), self.path)
|
||||
reporter = self.reporter()
|
||||
|
||||
self.advance(FIRST_DELAY_S[0])
|
||||
reporter.tick()
|
||||
|
||||
self.send.assert_not_called()
|
||||
|
||||
def test_a_failed_send_keeps_watermarks_and_waits_a_full_interval(self):
|
||||
self.send.return_value = SendOutcome.failed
|
||||
reporter = self.reporter()
|
||||
self.advance(FIRST_DELAY_S[0])
|
||||
|
||||
reporter.tick()
|
||||
self.advance(WAKE_S)
|
||||
reporter.tick()
|
||||
|
||||
self.assertEqual(self.send.call_count, 1)
|
||||
self.registry.mark_reported.assert_not_called()
|
||||
|
||||
def test_an_accepted_report_without_health_keeps_the_watermarks(self):
|
||||
self.built.health = None
|
||||
reporter = self.reporter()
|
||||
self.advance(FIRST_DELAY_S[0])
|
||||
|
||||
reporter.tick()
|
||||
|
||||
self.send.assert_called_once()
|
||||
self.registry.mark_reported.assert_not_called()
|
||||
|
||||
def test_consent_withdrawn_while_the_report_builds_stops_the_send(self):
|
||||
reporter = self.reporter()
|
||||
self.advance(FIRST_DELAY_S[0])
|
||||
|
||||
def withdraw(*args, **kwargs):
|
||||
self.holder.set(make_config())
|
||||
return self.built
|
||||
|
||||
self.build_report.side_effect = withdraw
|
||||
reporter.tick()
|
||||
|
||||
self.send.assert_not_called()
|
||||
self.assertFalse(os.path.exists(self.path))
|
||||
|
||||
def test_turning_sharing_off_and_on_between_wakes_starts_a_fresh_identity(self):
|
||||
save_state(AnalyticsState("a" * 32, 0.0), self.path)
|
||||
reporter = self.reporter()
|
||||
reporter.tick()
|
||||
|
||||
self.holder.set(make_config())
|
||||
self.holder.set(make_config({"telemetry": {"analytics": True}}))
|
||||
self.advance(FIRST_DELAY_S[0])
|
||||
reporter.tick()
|
||||
|
||||
self.raise_notice.assert_called_once_with(PROMPT_KIND)
|
||||
self.assertNotEqual(load_state(self.path).install_id, "a" * 32)
|
||||
|
||||
def test_opting_out_raises_the_prompt_once_and_deletes_the_state(self):
|
||||
self.holder.set(make_config())
|
||||
save_state(AnalyticsState("a" * 32, 0.0), self.path)
|
||||
reporter = self.reporter()
|
||||
|
||||
reporter.tick()
|
||||
reporter.tick()
|
||||
|
||||
self.raise_notice.assert_called_once_with(PROMPT_KIND)
|
||||
self.assertFalse(os.path.exists(self.path))
|
||||
self.send.assert_not_called()
|
||||
|
||||
def test_opting_in_resolves_the_prompt(self):
|
||||
self.holder.set(make_config())
|
||||
reporter = self.reporter()
|
||||
reporter.tick()
|
||||
|
||||
self.holder.set(make_config({"telemetry": {"analytics": True}}))
|
||||
reporter.tick()
|
||||
|
||||
self.resolve_notice.assert_called_once_with(PROMPT_KIND)
|
||||
|
||||
def test_safe_mode_touches_nothing(self):
|
||||
self.holder.set(make_config({"safe_mode": True}))
|
||||
save_state(AnalyticsState("a" * 32, 0.0), self.path)
|
||||
reporter = self.reporter()
|
||||
|
||||
self.advance(FIRST_DELAY_S[0])
|
||||
reporter.tick()
|
||||
|
||||
self.raise_notice.assert_not_called()
|
||||
self.assertTrue(os.path.exists(self.path))
|
||||
self.send.assert_not_called()
|
||||
|
||||
def test_an_unwritable_config_dir_skips_sending_and_warns_once(self):
|
||||
reporter = self.reporter(os.path.join(self.dir.name, "missing", "x.json"))
|
||||
self.advance(FIRST_DELAY_S[0])
|
||||
|
||||
with self.assertLogs("frigate.analytics.reporter", "WARNING") as logs:
|
||||
reporter.tick()
|
||||
self.advance(WAKE_S)
|
||||
reporter.tick()
|
||||
|
||||
self.send.assert_not_called()
|
||||
self.assertEqual(len(logs.output), 1)
|
||||
|
||||
def test_a_last_attempt_in_the_future_is_ignored(self):
|
||||
save_state(AnalyticsState("a" * 32, self.now + 10 * INTERVAL_S), self.path)
|
||||
reporter = self.reporter()
|
||||
|
||||
self.advance(FIRST_DELAY_S[0])
|
||||
reporter.tick()
|
||||
|
||||
self.send.assert_called_once()
|
||||
|
||||
def test_the_loop_survives_a_failing_tick(self):
|
||||
reporter = self.reporter()
|
||||
reporter.stop_event.set()
|
||||
|
||||
with (
|
||||
patch.object(reporter, "tick", side_effect=RuntimeError("boom")),
|
||||
self.assertLogs("frigate.analytics.reporter", "ERROR"),
|
||||
):
|
||||
reporter.run()
|
||||
@@ -0,0 +1,137 @@
|
||||
"""Tests for the analytics report schema."""
|
||||
|
||||
import inspect
|
||||
import re
|
||||
import unittest
|
||||
from enum import Enum
|
||||
from typing import Any, get_args, get_origin
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
import frigate.detectors.hardware as detection_hardware
|
||||
import frigate.util.hwaccel as hwaccel
|
||||
from frigate.analytics.schema import (
|
||||
AnalyticsReport,
|
||||
DecodeFamily,
|
||||
HardwareKey,
|
||||
HwaccelKey,
|
||||
InputPresetKey,
|
||||
NoticeKindKey,
|
||||
)
|
||||
from frigate.ffmpeg_presets import PRESETS_HW_ACCEL_DECODE, PRESETS_INPUT
|
||||
from frigate.notices.types import NOTICE_KINDS
|
||||
from frigate.util.hwaccel import HwaccelFamily
|
||||
|
||||
# the only str fields: generated ids and strings the hardware reports
|
||||
ALLOWED_STRINGS = {
|
||||
("AnalyticsReport", "install_id"),
|
||||
("AnalyticsReport", "report_id"),
|
||||
("InstallSection", "version"),
|
||||
("InstallSection", "kernel"),
|
||||
("HardwareSection", "cpu_model"),
|
||||
("GpuInfo", "name"),
|
||||
("StorageInfo", "record_fs"),
|
||||
("DetectionModel", "input"),
|
||||
}
|
||||
|
||||
|
||||
def nested_models(annotation: Any) -> list[type[BaseModel]]:
|
||||
if isinstance(annotation, type) and issubclass(annotation, BaseModel):
|
||||
return [annotation]
|
||||
|
||||
return [model for arg in get_args(annotation) for model in nested_models(arg)]
|
||||
|
||||
|
||||
def all_models(
|
||||
model: type[BaseModel], seen: set[type[BaseModel]] | None = None
|
||||
) -> list[type[BaseModel]]:
|
||||
seen = set() if seen is None else seen
|
||||
|
||||
if model in seen:
|
||||
return []
|
||||
|
||||
seen.add(model)
|
||||
found = [model]
|
||||
|
||||
for field in model.model_fields.values():
|
||||
for nested in nested_models(field.annotation):
|
||||
found += all_models(nested, seen)
|
||||
|
||||
return found
|
||||
|
||||
|
||||
def dict_key_types(annotation: Any) -> list[Any]:
|
||||
keys = [get_args(annotation)[0]] if get_origin(annotation) is dict else []
|
||||
return keys + [key for arg in get_args(annotation) for key in dict_key_types(arg)]
|
||||
|
||||
|
||||
class TestSchemaDiscipline(unittest.TestCase):
|
||||
def test_every_field_has_a_description_and_public_flag(self):
|
||||
for model in all_models(AnalyticsReport):
|
||||
for name, field in model.model_fields.items():
|
||||
with self.subTest(field=f"{model.__name__}.{name}"):
|
||||
self.assertTrue(field.description)
|
||||
self.assertIsInstance(field.json_schema_extra, dict)
|
||||
self.assertIsInstance(field.json_schema_extra.get("x-public"), bool)
|
||||
|
||||
def test_strings_are_limited_to_ids_and_hardware_names(self):
|
||||
for model in all_models(AnalyticsReport):
|
||||
properties = model.model_json_schema()["properties"]
|
||||
|
||||
for name, field in model.model_fields.items():
|
||||
if field.annotation is not str:
|
||||
continue
|
||||
|
||||
with self.subTest(field=f"{model.__name__}.{name}"):
|
||||
self.assertIn((model.__name__, name), ALLOWED_STRINGS)
|
||||
self.assertTrue(
|
||||
"maxLength" in properties[name] or "pattern" in properties[name]
|
||||
)
|
||||
|
||||
def test_map_keys_are_enums(self):
|
||||
for model in all_models(AnalyticsReport):
|
||||
for name, field in model.model_fields.items():
|
||||
for key_type in dict_key_types(field.annotation):
|
||||
with self.subTest(field=f"{model.__name__}.{name}"):
|
||||
self.assertTrue(issubclass(key_type, Enum))
|
||||
|
||||
|
||||
class TestEnumsTrackTheirSources(unittest.TestCase):
|
||||
def test_hardware_keys_cover_every_probe(self):
|
||||
probed = set(
|
||||
re.findall(
|
||||
r'_hardware\(\s*"([^"]+)"', inspect.getsource(detection_hardware)
|
||||
)
|
||||
)
|
||||
|
||||
self.assertTrue(probed)
|
||||
self.assertLessEqual(probed, {key.value for key in HardwareKey})
|
||||
|
||||
def test_decode_families_cover_every_hwaccel_family(self):
|
||||
families = {
|
||||
value.key
|
||||
for value in vars(hwaccel).values()
|
||||
if isinstance(value, HwaccelFamily)
|
||||
}
|
||||
|
||||
self.assertTrue(families)
|
||||
self.assertLessEqual(families, {family.value for family in DecodeFamily})
|
||||
|
||||
def test_preset_keys_mirror_the_presets(self):
|
||||
for enum, presets in (
|
||||
(HwaccelKey, PRESETS_HW_ACCEL_DECODE),
|
||||
(InputPresetKey, PRESETS_INPUT),
|
||||
):
|
||||
with self.subTest(enum=enum.__name__):
|
||||
self.assertEqual(
|
||||
{key.value for key in enum},
|
||||
{name.removeprefix("preset-") for name in presets}
|
||||
| {"custom", "none"},
|
||||
)
|
||||
|
||||
def test_notice_keys_are_the_reportable_kinds(self):
|
||||
self.assertEqual(
|
||||
{key.value for key in NoticeKindKey},
|
||||
{key for key, kind in NOTICE_KINDS.items() if kind.reportable},
|
||||
)
|
||||
self.assertNotIn("analytics_prompt", {key.value for key in NoticeKindKey})
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Tests for the analytics state file."""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from frigate.analytics.state import (
|
||||
AnalyticsState,
|
||||
delete_state,
|
||||
load_state,
|
||||
new_state,
|
||||
save_state,
|
||||
)
|
||||
|
||||
|
||||
class TestAnalyticsState(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.dir = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.dir.cleanup)
|
||||
self.path = os.path.join(self.dir.name, ".analytics.json")
|
||||
|
||||
def write(self, content: str) -> None:
|
||||
with open(self.path, "w") as f:
|
||||
f.write(content)
|
||||
|
||||
def test_missing_file_loads_as_none(self):
|
||||
self.assertIsNone(load_state(self.path))
|
||||
|
||||
def test_save_then_load_round_trips(self):
|
||||
state = AnalyticsState(install_id="a" * 32, last_attempt_at=1790000000.5)
|
||||
|
||||
self.assertTrue(save_state(state, self.path))
|
||||
self.assertEqual(load_state(self.path), state)
|
||||
|
||||
def test_unusable_files_load_as_none(self):
|
||||
for content in (
|
||||
"{not json",
|
||||
'["a"]',
|
||||
'{"install_id": "short", "last_attempt_at": 1}',
|
||||
'{"install_id": "' + "a" * 32 + '"}',
|
||||
'{"install_id": "' + "a" * 32 + '", "last_attempt_at": true}',
|
||||
):
|
||||
with self.subTest(content=content):
|
||||
self.write(content)
|
||||
self.assertIsNone(load_state(self.path))
|
||||
|
||||
def test_save_reports_failure_instead_of_raising(self):
|
||||
missing = os.path.join(self.dir.name, "missing", ".analytics.json")
|
||||
|
||||
self.assertFalse(save_state(new_state(), missing))
|
||||
|
||||
def test_new_state_has_a_hex_id_and_no_attempt(self):
|
||||
state = new_state()
|
||||
|
||||
self.assertRegex(state.install_id, r"^[0-9a-f]{32}$")
|
||||
self.assertEqual(state.last_attempt_at, 0.0)
|
||||
|
||||
def test_delete_removes_the_file_and_tolerates_a_missing_one(self):
|
||||
save_state(new_state(), self.path)
|
||||
|
||||
delete_state(self.path)
|
||||
delete_state(self.path)
|
||||
|
||||
self.assertFalse(os.path.exists(self.path))
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Tests for sending a report."""
|
||||
|
||||
import unittest
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import requests
|
||||
|
||||
from frigate.analytics.transport import TIMEOUT_S, SendOutcome, send_report
|
||||
from frigate.version import VERSION
|
||||
|
||||
URL = "https://example.test/report"
|
||||
|
||||
|
||||
def post(status: int = 204, text: str = "", side_effect: Exception | None = None):
|
||||
return patch(
|
||||
"frigate.analytics.transport.requests.post",
|
||||
return_value=Mock(status_code=status, text=text),
|
||||
side_effect=side_effect,
|
||||
)
|
||||
|
||||
|
||||
class TestSendReport(unittest.TestCase):
|
||||
def test_posts_the_body_as_json_with_a_user_agent(self):
|
||||
with post() as mock_post:
|
||||
outcome = send_report(URL, '{"a":1}')
|
||||
|
||||
self.assertIs(outcome, SendOutcome.accepted)
|
||||
args, kwargs = mock_post.call_args
|
||||
self.assertEqual(args[0], URL)
|
||||
self.assertEqual(kwargs["data"], b'{"a":1}')
|
||||
self.assertEqual(kwargs["headers"]["Content-Type"], "application/json")
|
||||
self.assertEqual(kwargs["headers"]["User-Agent"], f"Frigate/{VERSION}")
|
||||
self.assertFalse(kwargs["allow_redirects"])
|
||||
self.assertEqual(kwargs["timeout"], TIMEOUT_S)
|
||||
|
||||
def test_maps_statuses_to_outcomes(self):
|
||||
cases = (
|
||||
(200, SendOutcome.accepted),
|
||||
(400, SendOutcome.rejected),
|
||||
(429, SendOutcome.rate_limited),
|
||||
(302, SendOutcome.failed),
|
||||
(500, SendOutcome.failed),
|
||||
)
|
||||
|
||||
for status, outcome in cases:
|
||||
with self.subTest(status=status), post(status):
|
||||
self.assertIs(send_report(URL, "{}"), outcome)
|
||||
|
||||
def test_logs_the_rejection_reason(self):
|
||||
with (
|
||||
post(400, "install_id: bad"),
|
||||
self.assertLogs("frigate.analytics.transport", "WARNING") as logs,
|
||||
):
|
||||
send_report(URL, "{}")
|
||||
|
||||
self.assertIn("install_id: bad", logs.output[0])
|
||||
|
||||
def test_a_network_error_fails_without_raising(self):
|
||||
with post(side_effect=requests.ConnectionError("down")):
|
||||
self.assertIs(send_report(URL, "{}"), SendOutcome.failed)
|
||||
@@ -17,6 +17,11 @@ from frigate.util.builtin import deep_merge
|
||||
|
||||
|
||||
class TestConfig(unittest.TestCase):
|
||||
def test_analytics_is_off_by_default(self):
|
||||
frigate_config = FrigateConfig(**self.minimal)
|
||||
|
||||
self.assertFalse(frigate_config.telemetry.analytics)
|
||||
|
||||
def setUp(self):
|
||||
self.minimal = {
|
||||
"mqtt": {"host": "mqtt"},
|
||||
|
||||
@@ -82,5 +82,30 @@ class TestSwapRuntimeConfig(unittest.TestCase):
|
||||
app.genai_manager.update_config.assert_called_once_with(config)
|
||||
|
||||
|
||||
class TestConfigHolder(unittest.TestCase):
|
||||
def test_set_passes_the_new_config_to_subscribers(self) -> None:
|
||||
holder = ConfigHolder(MagicMock(name="boot_config"))
|
||||
listener = MagicMock()
|
||||
holder.subscribe(listener)
|
||||
config = MagicMock(name="new_config")
|
||||
|
||||
holder.set(config)
|
||||
|
||||
listener.assert_called_once_with(config)
|
||||
|
||||
def test_a_failing_subscriber_does_not_block_the_swap(self) -> None:
|
||||
holder = ConfigHolder(MagicMock(name="boot_config"))
|
||||
after = MagicMock()
|
||||
holder.subscribe(MagicMock(side_effect=RuntimeError("boom")))
|
||||
holder.subscribe(after)
|
||||
config = MagicMock(name="new_config")
|
||||
|
||||
with self.assertLogs("frigate.config.holder", "ERROR"):
|
||||
holder.set(config)
|
||||
|
||||
self.assertIs(holder.config, config)
|
||||
after.assert_called_once_with(config)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,896 @@
|
||||
"""Tests for the DEEPX detector."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import numpy as np
|
||||
from pydantic import ValidationError
|
||||
|
||||
from frigate.detectors.detector_config import ModelConfig, ModelTypeEnum
|
||||
from frigate.detectors.device import (
|
||||
DeviceParseError,
|
||||
build_detector_config,
|
||||
parse_device,
|
||||
)
|
||||
from frigate.detectors.plugins.deepx import (
|
||||
DEEPX_MANIFEST,
|
||||
DXRT_VERSION,
|
||||
PPU_RECORD_SIZE,
|
||||
DeepxDetector,
|
||||
DeepxDetectorConfig,
|
||||
PpuLayout,
|
||||
YoloLayout,
|
||||
class_count,
|
||||
decode_raw_anchor,
|
||||
decode_raw_nms_in_head,
|
||||
infer_yolo_layout,
|
||||
read_ppu_layout,
|
||||
resolve_device,
|
||||
validate_yolox_outputs,
|
||||
)
|
||||
|
||||
|
||||
def model_with_type(model_type) -> ModelConfig:
|
||||
return ModelConfig(
|
||||
model_type=model_type,
|
||||
labelmap_path=None,
|
||||
labelmap={79: "toothbrush"},
|
||||
width=640,
|
||||
height=640,
|
||||
)
|
||||
|
||||
|
||||
def build_ppu_record(box, score=0.9, label=0, grid=(7, 9, 2, 2)) -> np.ndarray:
|
||||
record = np.zeros(PPU_RECORD_SIZE, dtype=np.uint8)
|
||||
record[0:16] = np.array(box, dtype=np.float32).view(np.uint8)
|
||||
record[16:20] = grid
|
||||
record[20:24] = np.array([score], dtype=np.float32).view(np.uint8)
|
||||
record[24:28] = np.array([label], dtype=np.uint32).view(np.uint8)
|
||||
return record.reshape(1, 1, PPU_RECORD_SIZE)
|
||||
|
||||
|
||||
# compile_config.ppu as DX-COM writes it for the two head kinds
|
||||
ANCHOR_BASED_PPU = {"type": 0, "num_classes": 80, "activation": "Sigmoid"}
|
||||
ANCHOR_FREE_PPU = {"type": 1, "num_classes": 80}
|
||||
|
||||
PPU_BBOX_NODE = "/head/Mul_2"
|
||||
|
||||
# (grid_w, grid_h, entries) per scale, finest first; the grids give the
|
||||
# strides at a 640 input
|
||||
THREE_SCALE_ANCHORS = [(80, 80, 3), (40, 40, 3), (20, 20, 3)]
|
||||
TWO_SCALE_ANCHORS = [(40, 40, 3), (20, 20, 3)]
|
||||
FOUR_SCALE_ANCHORS = [(160, 160, 3), (80, 80, 3), (40, 40, 3), (20, 20, 3)]
|
||||
THREE_SCALE_FREE = [(80, 80, 1), (40, 40, 1), (20, 20, 1)]
|
||||
ONE_SCALE_FREE = [(100, 84, 1)]
|
||||
|
||||
|
||||
def proto_varint(value: int) -> bytes:
|
||||
out = bytearray()
|
||||
while True:
|
||||
byte = value & 0x7F
|
||||
value >>= 7
|
||||
out.append(byte | (0x80 if value else 0))
|
||||
if not value:
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def proto_bytes(field: int, payload: bytes) -> bytes:
|
||||
return proto_varint(field << 3 | 2) + proto_varint(len(payload)) + payload
|
||||
|
||||
|
||||
def proto_number(field: int, value: int) -> bytes:
|
||||
return proto_varint(field << 3) + proto_varint(value)
|
||||
|
||||
|
||||
def onnx_node(op_type: str, name: str, inputs: list, outputs: list) -> bytes:
|
||||
body = b"".join(proto_bytes(1, tensor.encode()) for tensor in inputs)
|
||||
body += b"".join(proto_bytes(2, tensor.encode()) for tensor in outputs)
|
||||
return body + proto_bytes(3, name.encode()) + proto_bytes(4, op_type.encode())
|
||||
|
||||
|
||||
def onnx_box_graph(box_format: str) -> bytes:
|
||||
if box_format == "broken":
|
||||
return proto_varint(1 << 3 | 3)
|
||||
|
||||
unnamed = box_format == "unnamed"
|
||||
|
||||
def graph_node(op_type: str, name: str, inputs: list, outputs: list) -> bytes:
|
||||
return onnx_node(op_type, "" if unnamed else name, inputs, outputs)
|
||||
|
||||
nodes = [
|
||||
graph_node("Split", "dfl_split", ["dfl", "sizes"], ["lt", "rb"]),
|
||||
graph_node("Sub", "corner_min", ["anchors", "lt"], ["x1y1"]),
|
||||
graph_node("Add", "corner_max", ["rb", "anchors"], ["x2y2"]),
|
||||
]
|
||||
if box_format in ("centre", "unnamed"):
|
||||
nodes += [
|
||||
graph_node("Add", "corner_sum", ["x1y1", "x2y2"], ["sum"]),
|
||||
graph_node("Mul", "corner_mean", ["sum", "half"], ["cxy"]),
|
||||
graph_node("Sub", "corner_span", ["x2y2", "x1y1"], ["wh"]),
|
||||
graph_node("Concat", "box_concat", ["cxy", "wh"], ["box"]),
|
||||
]
|
||||
elif box_format == "corner":
|
||||
nodes.append(graph_node("Concat", "box_concat", ["x1y1", "x2y2"], ["box"]))
|
||||
else:
|
||||
nodes.append(graph_node("Concat", "box_concat", ["x1y1", "x1y1"], ["box"]))
|
||||
|
||||
box = "box"
|
||||
if unnamed:
|
||||
box = "box_flat"
|
||||
nodes.append(graph_node("Reshape", "box_reshape", ["shape", "box"], [box]))
|
||||
|
||||
nodes.append(onnx_node("Mul", PPU_BBOX_NODE, [box, "strides"], ["bbox_out"]))
|
||||
graph = b"".join(proto_bytes(1, node) for node in nodes)
|
||||
graph += proto_bytes(5, b"weights")
|
||||
return (
|
||||
proto_number(1, 10) # ir_version
|
||||
+ proto_bytes(2, b"onnx_frontend_compiler") # producer_name
|
||||
+ proto_bytes(7, graph)
|
||||
)
|
||||
|
||||
|
||||
def write_dxnn(
|
||||
directory, ppu, layers, name="model.dxnn", table=True, box_format=None
|
||||
) -> str:
|
||||
"""A minimal .dxnn as DX-RT's parsers read it: the container header, a
|
||||
compile_config carrying `ppu`, and either the PPU tensor table with one
|
||||
entry per (layer, anchor) as a v8 file has, or with `table` False only
|
||||
the rmap_info listing of the PPU output tensors, as a v7 file has.
|
||||
`layers` is (grid_w, grid_h, entries) per scale, finest first; an
|
||||
anchor-free scale has one entry, and grid_h 1 means a flattened
|
||||
(1, cells, channels) tensor. `box_format`, "centre" or "corner", adds
|
||||
the compiled graph that says how the head writes its boxes, along with
|
||||
the compile_config layer naming the node the PPU reads them from."""
|
||||
if box_format is not None and "layer" not in ppu:
|
||||
ppu = dict(ppu, layer=[{"bbox": PPU_BBOX_NODE, "cls_conf": "/head/Sigmoid"}])
|
||||
|
||||
compile_config = json.dumps({"compile_version": "2.4.0", "ppu": ppu}).encode()
|
||||
graph = onnx_box_graph(box_format) if box_format is not None else b""
|
||||
|
||||
if table:
|
||||
part = bytearray(struct.pack("<BBBB", 1, sum(n for _, _, n in layers), 0, 0))
|
||||
for conv, (grid_w, grid_h, entries) in enumerate(layers):
|
||||
for anchor in range(entries):
|
||||
part += struct.pack(
|
||||
"<HHfBBBBBBBB",
|
||||
128,
|
||||
80,
|
||||
0.001,
|
||||
conv,
|
||||
anchor,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
grid_w,
|
||||
grid_h,
|
||||
0,
|
||||
)
|
||||
else:
|
||||
outputs = []
|
||||
for conv, (grid_w, grid_h, entries) in enumerate(layers):
|
||||
for anchor in range(entries):
|
||||
name_ = f"PPU_Transpose_Output_{conv}"
|
||||
if entries > 1:
|
||||
name_ += f"_anchor_{anchor}"
|
||||
shape = [1, grid_w, 127] if grid_h == 1 else [1, grid_h, grid_w, 128]
|
||||
outputs.append({"name": name_, "shape": shape, "layout": "PPU_YOLO"})
|
||||
part = json.dumps({"inputs": [], "outputs": outputs}).encode()
|
||||
|
||||
data = {
|
||||
"compile_config": {
|
||||
"type": "str",
|
||||
"offset": 0,
|
||||
"size": len(compile_config),
|
||||
},
|
||||
"compiled_data": {
|
||||
"M1A_4K": {
|
||||
"npu_0": {
|
||||
"rmap": {"type": "bytes", "offset": 0, "size": 0},
|
||||
"ppu" if table else "rmap_info": {
|
||||
"type": "bytes" if table else "str",
|
||||
"offset": len(compile_config),
|
||||
"size": len(part),
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
if graph:
|
||||
data["vis_npu_models"] = {
|
||||
"npu_0": {
|
||||
"type": "bytes",
|
||||
"offset": len(compile_config) + len(part),
|
||||
"size": len(graph),
|
||||
}
|
||||
}
|
||||
|
||||
index = json.dumps(
|
||||
{
|
||||
"version": 8 if table else 7,
|
||||
"signature": "DXNN",
|
||||
"size": 8192,
|
||||
"data": data,
|
||||
}
|
||||
).encode()
|
||||
|
||||
path = os.path.join(directory, name)
|
||||
with open(path, "wb") as model:
|
||||
model.write(b"DXNN" + struct.pack("<I", 8))
|
||||
model.write(index.ljust(8192 - 8, b"\0"))
|
||||
model.write(compile_config + part + graph)
|
||||
|
||||
return path
|
||||
|
||||
|
||||
def layout_of(shapes, num_classes, ppu=False, dynamic_output=False) -> YoloLayout:
|
||||
return infer_yolo_layout(shapes, num_classes, ppu, dynamic_output).layout
|
||||
|
||||
|
||||
class TestDeepxModelFile(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
|
||||
def layout(self, ppu, layers, **kwargs) -> PpuLayout | None:
|
||||
return read_ppu_layout(write_dxnn(self.tmp.name, ppu, layers, **kwargs))
|
||||
|
||||
def test_the_head_kind_and_one_grid_per_scale_are_read(self):
|
||||
cases = {
|
||||
"anchor-based: three anchors per scale collapse to one grid each": (
|
||||
(ANCHOR_BASED_PPU, THREE_SCALE_ANCHORS, {}),
|
||||
PpuLayout(anchor_based=True, grids=((80, 80), (40, 40), (20, 20))),
|
||||
),
|
||||
"anchor-free, one scale flattened into a single tensor": (
|
||||
(ANCHOR_FREE_PPU, ONE_SCALE_FREE, {"box_format": "centre"}),
|
||||
PpuLayout(anchor_based=False, grids=((100, 84),), centre_boxes=True),
|
||||
),
|
||||
"a head kind the compiler does not name still yields the scales": (
|
||||
({"type": 7}, [(80, 80, 3), (40, 40, 3)], {}),
|
||||
PpuLayout(anchor_based=None, grids=((80, 80), (40, 40))),
|
||||
),
|
||||
"no table: the per-anchor split says anchor-based": (
|
||||
({"num_classes": 80}, THREE_SCALE_ANCHORS, {"table": False}),
|
||||
PpuLayout(anchor_based=True, grids=((80, 80), (40, 40), (20, 20))),
|
||||
),
|
||||
"no table: compile_config places the scales ahead of the names": (
|
||||
(
|
||||
{
|
||||
"type": 1,
|
||||
"outputs": {
|
||||
f"PPU_Transpose_Output_{i}": {"conv_idx": 2 - i}
|
||||
for i in range(3)
|
||||
},
|
||||
},
|
||||
[(20, 20, 1), (40, 40, 1), (80, 80, 1)],
|
||||
{"table": False},
|
||||
),
|
||||
PpuLayout(anchor_based=False, grids=((80, 80), (40, 40), (20, 20))),
|
||||
),
|
||||
"no table: every scale in one (1, cells, channels) tensor": (
|
||||
(ANCHOR_FREE_PPU, [(8400, 1, 1)], {"table": False}),
|
||||
PpuLayout(anchor_based=False, grids=((8400, 1),)),
|
||||
),
|
||||
}
|
||||
|
||||
for head, ((ppu, layers, kwargs), expected) in cases.items():
|
||||
with self.subTest(head=head):
|
||||
self.assertEqual(self.layout(ppu, layers, **kwargs), expected)
|
||||
|
||||
def test_the_box_format_comes_from_the_compiled_graph(self):
|
||||
for box_format, centre in (
|
||||
("centre", True),
|
||||
("corner", False),
|
||||
("unnamed", True),
|
||||
):
|
||||
with self.subTest(box_format=box_format):
|
||||
self.assertEqual(
|
||||
self.layout(ANCHOR_FREE_PPU, ONE_SCALE_FREE, box_format=box_format),
|
||||
PpuLayout(
|
||||
anchor_based=False, grids=((100, 84),), centre_boxes=centre
|
||||
),
|
||||
)
|
||||
|
||||
def test_a_box_format_the_graph_does_not_answer_is_left_open(self):
|
||||
cases = {
|
||||
"no compiled graph at all": (ANCHOR_FREE_PPU, ONE_SCALE_FREE, {}),
|
||||
"a graph without the node compile_config names": (
|
||||
dict(ANCHOR_FREE_PPU, layer=[{"bbox": "/head/Missing"}]),
|
||||
ONE_SCALE_FREE,
|
||||
{"box_format": "centre"},
|
||||
),
|
||||
"a graph that builds its boxes from neither shape": (
|
||||
ANCHOR_FREE_PPU,
|
||||
ONE_SCALE_FREE,
|
||||
{"box_format": "neither"},
|
||||
),
|
||||
"a graph section the reader cannot make sense of": (
|
||||
ANCHOR_FREE_PPU,
|
||||
ONE_SCALE_FREE,
|
||||
{"box_format": "broken"},
|
||||
),
|
||||
"a grid-decoded head, where the question does not arise": (
|
||||
ANCHOR_FREE_PPU,
|
||||
THREE_SCALE_FREE,
|
||||
{"box_format": "centre"},
|
||||
),
|
||||
}
|
||||
|
||||
for graph, (ppu, layers, kwargs) in cases.items():
|
||||
with self.subTest(graph=graph):
|
||||
self.assertIsNone(self.layout(ppu, layers, **kwargs).centre_boxes)
|
||||
|
||||
def test_nothing_is_read_from_a_model_without_ppu_metadata(self):
|
||||
self.assertIsNone(read_ppu_layout(os.path.join(self.tmp.name, "missing")))
|
||||
|
||||
path = write_dxnn(self.tmp.name, None, [(80, 80, 3)])
|
||||
self.assertIsNone(read_ppu_layout(path))
|
||||
|
||||
with open(path, "wb") as model:
|
||||
model.write(b"ONNX" + b"\0" * 100)
|
||||
self.assertIsNone(read_ppu_layout(path))
|
||||
|
||||
path = write_dxnn(self.tmp.name, ANCHOR_BASED_PPU, [(80, 80, 3), (40, 40, 3)])
|
||||
os.truncate(path, os.path.getsize(path) - 40)
|
||||
self.assertIsNone(read_ppu_layout(path))
|
||||
|
||||
|
||||
class TestDeepxLayoutInference(unittest.TestCase):
|
||||
def test_a_shape_and_class_count_pick_one_layout(self):
|
||||
cases = {
|
||||
"anchor-free, four columns ahead of the classes": (
|
||||
[(1, 84, 8400)],
|
||||
80,
|
||||
YoloLayout.anchor_free,
|
||||
84,
|
||||
),
|
||||
"anchor-free, row-major": ([(1, 8400, 84)], 80, YoloLayout.anchor_free, 84),
|
||||
"anchor-based, an objectness column as well": (
|
||||
[(1, 25200, 85)],
|
||||
80,
|
||||
YoloLayout.anchor,
|
||||
85,
|
||||
),
|
||||
"anchor-based, channel-major": (
|
||||
[(1, 85, 25200)],
|
||||
80,
|
||||
YoloLayout.anchor,
|
||||
85,
|
||||
),
|
||||
"NMS in the head, a fixed run of corner records": (
|
||||
[(1, 300, 6)],
|
||||
80,
|
||||
YoloLayout.nms_in_head,
|
||||
None,
|
||||
),
|
||||
# only the label map can tell these two apart
|
||||
"85 columns with 81 classes is anchor-free": (
|
||||
[(1, 8400, 85)],
|
||||
81,
|
||||
YoloLayout.anchor_free,
|
||||
85,
|
||||
),
|
||||
"85 columns with 80 classes is anchor-based": (
|
||||
[(1, 8400, 85)],
|
||||
80,
|
||||
YoloLayout.anchor,
|
||||
85,
|
||||
),
|
||||
# 6 columns is all three layouts, told apart by the row count
|
||||
"6 columns and thousands of rows, one class": (
|
||||
[(1, 25200, 6)],
|
||||
1,
|
||||
YoloLayout.anchor,
|
||||
6,
|
||||
),
|
||||
"6 columns and thousands of rows, two classes": (
|
||||
[(1, 8400, 6)],
|
||||
2,
|
||||
YoloLayout.anchor_free,
|
||||
6,
|
||||
),
|
||||
"6 columns and a few hundred rows, one class": (
|
||||
[(1, 300, 6)],
|
||||
1,
|
||||
YoloLayout.nms_in_head,
|
||||
None,
|
||||
),
|
||||
"7 columns with two classes is only anchor-based": (
|
||||
[(1, 8400, 7)],
|
||||
2,
|
||||
YoloLayout.anchor,
|
||||
7,
|
||||
),
|
||||
# a square output matches the same width on both axes, which must
|
||||
# not read as two candidate layouts
|
||||
"a square output is not ambiguous with itself": (
|
||||
[(1, 85, 85)],
|
||||
80,
|
||||
YoloLayout.anchor,
|
||||
85,
|
||||
),
|
||||
"three NCHW maps with 255 channels are feature maps": (
|
||||
[(1, 255, 80, 80), (1, 255, 40, 40), (1, 255, 20, 20)],
|
||||
80,
|
||||
YoloLayout.multipart,
|
||||
None,
|
||||
),
|
||||
}
|
||||
|
||||
for head, (shapes, num_classes, layout, columns) in cases.items():
|
||||
with self.subTest(head=head):
|
||||
output = infer_yolo_layout(shapes, num_classes, False, False)
|
||||
|
||||
self.assertIs(output.layout, layout)
|
||||
if columns is not None:
|
||||
self.assertEqual(output.columns, columns)
|
||||
|
||||
def test_the_runtime_flags_outrank_the_shapes(self):
|
||||
self.assertIs(layout_of([(8400,)], 80, ppu=True), YoloLayout.ppu)
|
||||
self.assertIs(
|
||||
layout_of([(1, -1, 6)], 80, dynamic_output=True), YoloLayout.nms_in_head
|
||||
)
|
||||
|
||||
def test_a_shape_no_layout_fits_is_refused_with_the_reason(self):
|
||||
cases = {
|
||||
"fits two layouts": ([(1, 84, 6)], 80),
|
||||
"labelmap_path": ([(1, 8400, 84)], 91),
|
||||
"no output tensor": ([], 80),
|
||||
"255 channels": (
|
||||
[(1, 80, 80, 255), (1, 40, 40, 255), (1, 20, 20, 255)],
|
||||
80,
|
||||
),
|
||||
"feature maps": ([(1, 8400, 80), (1, 8400, 4)], 80),
|
||||
"80-class": ([(1, 24, 80, 80), (1, 24, 40, 40), (1, 24, 20, 20)], 3),
|
||||
}
|
||||
|
||||
for reason, (shapes, num_classes) in cases.items():
|
||||
with (
|
||||
self.subTest(reason=reason),
|
||||
self.assertRaisesRegex(ValueError, reason),
|
||||
):
|
||||
layout_of(shapes, num_classes)
|
||||
|
||||
|
||||
class TestDeepxOutputValidation(unittest.TestCase):
|
||||
def test_the_raw_yolox_head_is_read_for_the_configured_input(self):
|
||||
for shapes, size in (
|
||||
([(1, 8400, 85)], 640),
|
||||
([(1, 85, 8400)], 640),
|
||||
# 52*52 + 26*26 + 13*13 cells at 416
|
||||
([(1, 3549, 85)], 416),
|
||||
):
|
||||
with self.subTest(shapes=shapes, size=size):
|
||||
self.assertEqual(validate_yolox_outputs(shapes, 80, size, size), 85)
|
||||
|
||||
def test_another_head_under_yolox_is_refused_with_the_reason(self):
|
||||
cases = {
|
||||
"width and height": ([(1, 8400, 85)], 80, 416),
|
||||
"labelmap_path": ([(1, 8400, 85)], 91, 640),
|
||||
"yolo-generic": ([(1, 8400, 80), (1, 8400, 4)], 80, 640),
|
||||
}
|
||||
|
||||
for reason, (shapes, num_classes, size) in cases.items():
|
||||
with (
|
||||
self.subTest(reason=reason),
|
||||
self.assertRaisesRegex(ValueError, reason),
|
||||
):
|
||||
validate_yolox_outputs(shapes, num_classes, size, size)
|
||||
|
||||
def test_the_label_map_bounds_the_class_count(self):
|
||||
self.assertEqual(class_count({0: "person", 79: "toothbrush"}), 80)
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "labelmap_path"):
|
||||
class_count({})
|
||||
|
||||
|
||||
class TestDeepxRawDecode(unittest.TestCase):
|
||||
def anchor_rows(self, rows) -> list:
|
||||
out = np.zeros((1, len(rows), 85), dtype=np.float32)
|
||||
|
||||
for i, (cx, cy, w, h, obj, label, score) in enumerate(rows):
|
||||
out[0, i, 0:4] = [cx, cy, w, h]
|
||||
out[0, i, 4] = obj
|
||||
out[0, i, 5 + label] = score
|
||||
|
||||
return [out]
|
||||
|
||||
def test_an_anchor_based_head_becomes_normalized_corners(self):
|
||||
outputs = self.anchor_rows([(320.0, 160.0, 64.0, 32.0, 0.8, 3, 0.5)])
|
||||
|
||||
detections = decode_raw_anchor(outputs, 640, 640, 0.25, 0.45)
|
||||
|
||||
self.assertEqual(detections[0][0], 3)
|
||||
self.assertAlmostEqual(detections[0][1], 0.4, places=5)
|
||||
self.assertAlmostEqual(detections[0][2], 144 / 640, places=5)
|
||||
self.assertAlmostEqual(detections[0][3], 288 / 640, places=5)
|
||||
self.assertAlmostEqual(detections[0][4], 176 / 640, places=5)
|
||||
self.assertAlmostEqual(detections[0][5], 352 / 640, places=5)
|
||||
|
||||
def test_a_channel_major_export_is_read_by_column_count(self):
|
||||
outputs = self.anchor_rows([(320.0, 160.0, 64.0, 32.0, 1.0, 3, 0.9)])
|
||||
|
||||
detections = decode_raw_anchor(
|
||||
[np.swapaxes(outputs[0], 1, 2)], 640, 640, 0.25, 0.45, columns=85
|
||||
)
|
||||
|
||||
self.assertEqual(detections[0][0], 3)
|
||||
self.assertAlmostEqual(detections[0][3], 288 / 640, places=5)
|
||||
|
||||
def test_rows_below_the_combined_threshold_are_dropped(self):
|
||||
# 0.4 * 0.5 = 0.2, under the threshold both parts clear on their own
|
||||
outputs = self.anchor_rows([(320.0, 320.0, 40.0, 80.0, 0.4, 3, 0.5)])
|
||||
|
||||
self.assertTrue(np.all(decode_raw_anchor(outputs, 640, 640, 0.25, 0.45) == 0))
|
||||
|
||||
def test_at_most_twenty_detections_are_returned(self):
|
||||
rows = [(20.0 + 24 * i, 320.0, 16.0, 16.0, 1.0, i % 80, 0.9) for i in range(25)]
|
||||
|
||||
detections = decode_raw_anchor(self.anchor_rows(rows), 640, 640, 0.25, 0.45)
|
||||
|
||||
self.assertEqual(detections.shape, (20, 6))
|
||||
self.assertEqual(int((detections[:, 1] > 0).sum()), 20)
|
||||
|
||||
def test_an_nms_in_head_output_is_read_without_running_nms(self):
|
||||
out = np.array(
|
||||
[
|
||||
[
|
||||
[100.0, 100.0, 200.0, 200.0, 0.9, 2.0],
|
||||
[102.0, 102.0, 202.0, 202.0, 0.8, 2.0],
|
||||
[300.0, 300.0, 400.0, 400.0, 0.1, 5.0],
|
||||
]
|
||||
],
|
||||
dtype=np.float32,
|
||||
)
|
||||
|
||||
detections = decode_raw_nms_in_head([out], 640, 640, 0.25)
|
||||
|
||||
self.assertEqual(detections[0][0], 2)
|
||||
self.assertAlmostEqual(detections[0][1], 0.9, places=5)
|
||||
self.assertAlmostEqual(detections[0][3], 100 / 640, places=5)
|
||||
self.assertEqual(detections[1][0], 2)
|
||||
self.assertAlmostEqual(detections[1][1], 0.8, places=5)
|
||||
self.assertTrue(np.all(detections[2] == 0))
|
||||
|
||||
empty = np.zeros((1, 0, 6), dtype=np.float32)
|
||||
self.assertTrue(np.all(decode_raw_nms_in_head([empty], 640, 640, 0.25) == 0))
|
||||
|
||||
|
||||
class TestDeepxConfig(unittest.TestCase):
|
||||
def test_a_device_string_resolves_to_an_npu_index(self):
|
||||
for configured, index in (("PCIe:1", 1), ("2", 2), ("", 0)):
|
||||
with self.subTest(device=configured):
|
||||
self.assertEqual(resolve_device(configured), index)
|
||||
|
||||
def test_a_device_that_is_not_an_index_is_rejected(self):
|
||||
"""The whole string has to be an index. Reading only the tail would
|
||||
take the 1 out of "PCIe:0,PCIe:1" and bind to an NPU the config never
|
||||
named, and several NPUs are configured as separate devices entries."""
|
||||
for configured in (
|
||||
"PCIe:the-fast-one",
|
||||
"PCIe:0,PCIe:1",
|
||||
"0,1",
|
||||
"PCIe:0 PCIe:1",
|
||||
"PCIe:-1",
|
||||
"PCIe:",
|
||||
):
|
||||
with self.subTest(device=configured):
|
||||
with self.assertRaises(ValueError):
|
||||
resolve_device(configured)
|
||||
|
||||
with self.assertRaises(ValidationError):
|
||||
DeepxDetectorConfig(type="deepx", device=configured)
|
||||
|
||||
def test_a_bad_device_is_refused_where_the_config_is_parsed(self):
|
||||
"""parse_device builds the detector config to surface a bad device at
|
||||
startup, so the comma-separated form fails there rather than binding a
|
||||
detector process to the wrong NPU."""
|
||||
self.assertEqual(parse_device("deepx:PCIe:1").device, "PCIe:1")
|
||||
|
||||
for raw in ("deepx:PCIe:0,PCIe:1", "deepx:the-fast-one"):
|
||||
with self.subTest(raw=raw), self.assertRaises(DeviceParseError):
|
||||
parse_device(raw)
|
||||
|
||||
def test_a_device_string_builds_this_detector_config(self):
|
||||
"""Frigate turns a `deepx:PCIe:0` entry into the detector config with
|
||||
the model already attached, which is the path app.py takes; a bare
|
||||
constructor call does not exercise it."""
|
||||
config = build_detector_config(
|
||||
parse_device("deepx:PCIe:0"), model_with_type(ModelTypeEnum.yologeneric)
|
||||
)
|
||||
|
||||
self.assertIsInstance(config, DeepxDetectorConfig)
|
||||
self.assertEqual(config.device, "PCIe:0")
|
||||
self.assertEqual(config.model.model_type, ModelTypeEnum.yologeneric)
|
||||
|
||||
def test_the_runtime_manifest_pins_its_wheels_to_the_version(self):
|
||||
"""A PyPI path carries a per-file digest, so bumping DXRT_VERSION has
|
||||
to rewrite the whole URL; a stale one installs the old wheel and fails
|
||||
the sha256 on every user's first start."""
|
||||
self.assertEqual(DEEPX_MANIFEST.version, DXRT_VERSION)
|
||||
|
||||
for artifact in DEEPX_MANIFEST.artifacts:
|
||||
with self.subTest(url=artifact.url):
|
||||
self.assertIn(f"dx_engine-{DXRT_VERSION}-", artifact.url)
|
||||
|
||||
def test_a_model_less_config_still_validates(self):
|
||||
config = DeepxDetectorConfig(type="deepx")
|
||||
|
||||
self.assertIsNone(config.model)
|
||||
|
||||
|
||||
class DeepxDetectorTestCase(unittest.TestCase):
|
||||
def detector(
|
||||
self,
|
||||
model_type=ModelTypeEnum.yologeneric,
|
||||
outputs_info=None,
|
||||
ppu=False,
|
||||
dynamic=False,
|
||||
model_path="/nonexistent/model.dxnn",
|
||||
) -> DeepxDetector:
|
||||
dx_engine = MagicMock()
|
||||
dx_engine.Configuration.ITEM.SERVICE = object()
|
||||
session = dx_engine.InferenceEngine.return_value
|
||||
session.get_output_tensors_info.return_value = (
|
||||
[{"shape": [8400]}] if ppu else outputs_info or []
|
||||
)
|
||||
session.is_ppu.return_value = ppu
|
||||
session.has_dynamic_output.return_value = dynamic
|
||||
|
||||
config = DeepxDetectorConfig(type="deepx")
|
||||
config.model = model_with_type(model_type)
|
||||
config.model.path = model_path
|
||||
|
||||
with (
|
||||
# the detector writes the endpoint into the environment, which the
|
||||
# rest of the suite shares when it runs in one process
|
||||
patch.dict(os.environ),
|
||||
patch.dict(sys.modules, {"dx_engine": dx_engine}),
|
||||
patch.object(DeepxDetector, "activate_dependencies"),
|
||||
patch("os.path.isfile", return_value=True),
|
||||
):
|
||||
return DeepxDetector(config)
|
||||
|
||||
def ppu_detector(
|
||||
self, ppu, layers, model_type=ModelTypeEnum.yologeneric, box_format=None
|
||||
) -> DeepxDetector:
|
||||
tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(tmp.cleanup)
|
||||
|
||||
return self.detector(
|
||||
model_type,
|
||||
ppu=True,
|
||||
model_path=write_dxnn(tmp.name, ppu, layers, box_format=box_format),
|
||||
)
|
||||
|
||||
def detect(self, detector, outputs) -> np.ndarray:
|
||||
detector.session.run.return_value = outputs
|
||||
return detector.detect_raw(np.zeros((1, 640, 640, 3), np.uint8))
|
||||
|
||||
|
||||
class TestDeepxModelType(DeepxDetectorTestCase):
|
||||
def test_a_model_type_with_no_decoder_is_rejected(self):
|
||||
for model_type in (ModelTypeEnum.ssd, ModelTypeEnum.dfine):
|
||||
with (
|
||||
self.subTest(model_type=model_type),
|
||||
self.assertRaisesRegex(ValueError, model_type.value),
|
||||
):
|
||||
self.detector(model_type)
|
||||
|
||||
def test_supported_model_types_are_accepted(self):
|
||||
for model_type, outputs_info in (
|
||||
(ModelTypeEnum.yologeneric, [{"shape": [1, 84, 8400]}]),
|
||||
(ModelTypeEnum.yolox, [{"shape": [1, 8400, 85]}]),
|
||||
):
|
||||
with self.subTest(model_type=model_type):
|
||||
detector = self.detector(model_type, outputs_info)
|
||||
|
||||
self.assertEqual(detector.model_type, model_type)
|
||||
|
||||
|
||||
class TestDeepxDetectorLoad(DeepxDetectorTestCase):
|
||||
def test_the_layout_is_settled_at_load(self):
|
||||
detector = self.detector(ModelTypeEnum.yologeneric, [{"shape": [1, 84, 8400]}])
|
||||
self.assertIs(detector.output.layout, YoloLayout.anchor_free)
|
||||
self.assertEqual(detector.output.columns, 84)
|
||||
|
||||
detector = self.detector(ModelTypeEnum.yolox, [{"shape": [1, 8400, 85]}])
|
||||
self.assertIs(detector.output.layout, YoloLayout.yolox)
|
||||
|
||||
for model_type in (ModelTypeEnum.yologeneric, ModelTypeEnum.yolox):
|
||||
with self.subTest(model_type=model_type):
|
||||
detector = self.ppu_detector(
|
||||
ANCHOR_FREE_PPU, THREE_SCALE_FREE, model_type=model_type
|
||||
)
|
||||
|
||||
self.assertIs(detector.output.layout, YoloLayout.ppu)
|
||||
self.assertEqual(detector.ppu_layout.scale_count, 3)
|
||||
|
||||
def test_a_model_the_detector_cannot_decode_is_refused_at_load(self):
|
||||
cases = {
|
||||
"Cannot decode DEEPX model": lambda: self.detector(
|
||||
ModelTypeEnum.yologeneric, [{"shape": [1, 8400, 7]}]
|
||||
),
|
||||
"DX-COM 2.4.0": lambda: self.detector(ModelTypeEnum.yologeneric, ppu=True),
|
||||
"face and pose": lambda: self.ppu_detector({"type": 2}, [(80, 80, 1)]),
|
||||
"centre and size or as two corners": lambda: self.ppu_detector(
|
||||
ANCHOR_FREE_PPU, ONE_SCALE_FREE
|
||||
),
|
||||
}
|
||||
|
||||
for reason, load in cases.items():
|
||||
with (
|
||||
self.subTest(reason=reason),
|
||||
self.assertRaisesRegex(ValueError, reason),
|
||||
):
|
||||
load()
|
||||
|
||||
|
||||
class TestDeepxDetectRaw(DeepxDetectorTestCase):
|
||||
def test_a_ppu_record_decodes_by_the_head_in_the_model(self):
|
||||
cases = {
|
||||
"anchor-based, layer 2 of 3 at stride 32, the 373x326 anchor": (
|
||||
(ANCHOR_BASED_PPU, THREE_SCALE_ANCHORS, None),
|
||||
build_ppu_record((0.6, 0.4, 0.3, 0.7), label=5),
|
||||
(5, (0.0, 0.380094, 0.864187, 0.589906)),
|
||||
),
|
||||
"anchor-based, layer 0 of 3 at stride 8, the 16x30 anchor": (
|
||||
(ANCHOR_BASED_PPU, THREE_SCALE_ANCHORS, None),
|
||||
build_ppu_record((0.6, 0.4, 0.3, 0.7), grid=(7, 9, 1, 0)),
|
||||
(0, (None, 0.116750, None, None)),
|
||||
),
|
||||
"anchor-based, two scales: layer 0 is stride 16, not stride 8": (
|
||||
(ANCHOR_BASED_PPU, TWO_SCALE_ANCHORS, None),
|
||||
build_ppu_record((0.6, 0.4, 0.3, 0.7), grid=(7, 9, 1, 0)),
|
||||
(0, (0.141156, 0.236031, 0.223844, 0.248969)),
|
||||
),
|
||||
"anchor-free, three scales: cell (10, 9) of stride 32": (
|
||||
(ANCHOR_FREE_PPU, THREE_SCALE_FREE, None),
|
||||
build_ppu_record((1.2, 0.5, 1.0, 0.5), grid=(9, 10, 0, 2), label=7),
|
||||
(7, (0.433782, 0.492043, 0.516218, 0.627957)),
|
||||
),
|
||||
"anchor-free, one scale: the box fields are already pixels": (
|
||||
(ANCHOR_FREE_PPU, ONE_SCALE_FREE, "centre"),
|
||||
build_ppu_record((320.0, 160.0, 64.0, 32.0), label=3),
|
||||
(3, (144 / 640, 288 / 640, 176 / 640, 352 / 640)),
|
||||
),
|
||||
"anchor-free, one scale: a sub-pixel box stays sub-pixel": (
|
||||
(ANCHOR_FREE_PPU, ONE_SCALE_FREE, "centre"),
|
||||
build_ppu_record((0.6, 0.4, 0.3, 0.7)),
|
||||
(0, (None, 0.45 / 640, None, None)),
|
||||
),
|
||||
"a corner-format head reads the record as two corners": (
|
||||
(ANCHOR_FREE_PPU, ONE_SCALE_FREE, "corner"),
|
||||
build_ppu_record((100.0, 50.0, 300.0, 250.0), label=2),
|
||||
(2, (50 / 640, 100 / 640, 250 / 640, 300 / 640)),
|
||||
),
|
||||
"a centre-format head reads it as a centre and size": (
|
||||
(ANCHOR_FREE_PPU, ONE_SCALE_FREE, "centre"),
|
||||
build_ppu_record((100.0, 50.0, 300.0, 250.0), label=2),
|
||||
(2, (0.0, 0.0, 175 / 640, 250 / 640)),
|
||||
),
|
||||
}
|
||||
|
||||
for head, ((ppu, layers, box_format), record, (label, box)) in cases.items():
|
||||
with self.subTest(head=head):
|
||||
detector = self.ppu_detector(ppu, layers, box_format=box_format)
|
||||
|
||||
detections = self.detect(detector, [record])
|
||||
|
||||
self.assertEqual(detections[0][0], label)
|
||||
for i, expected in enumerate(box, start=2):
|
||||
if expected is not None:
|
||||
self.assertAlmostEqual(detections[0][i], expected, places=5)
|
||||
|
||||
def test_the_strides_come_from_the_grids_in_the_model(self):
|
||||
detector = self.ppu_detector(
|
||||
ANCHOR_FREE_PPU, [(40, 40, 1), (20, 20, 1), (10, 10, 1)]
|
||||
)
|
||||
|
||||
detections = self.detect(
|
||||
detector,
|
||||
[build_ppu_record((1.2, 0.5, 1.0, 0.5), grid=(9, 10, 0, 0), label=7)],
|
||||
)
|
||||
|
||||
# layer 0 is stride 16: centre (11.2, 9.5) * 16, size e * 16 x sqrt(e) * 16
|
||||
self.assertEqual(detections[0][0], 7)
|
||||
self.assertAlmostEqual(detections[0][2], (152 - np.exp(0.5) * 8) / 640, 5)
|
||||
self.assertAlmostEqual(detections[0][3], (179.2 - np.exp(1.0) * 8) / 640, 5)
|
||||
|
||||
def test_the_head_stays_what_the_model_said_across_frames(self):
|
||||
detector = self.ppu_detector(ANCHOR_BASED_PPU, THREE_SCALE_ANCHORS)
|
||||
|
||||
first = self.detect(
|
||||
detector, [build_ppu_record((0.6, 0.4, 0.3, 0.7), grid=(7, 9, 1, 0))]
|
||||
)
|
||||
# layer 0 of 3: stride 8, anchor 16x30
|
||||
self.assertAlmostEqual(first[0][3], 0.116750, places=5)
|
||||
|
||||
second = self.detect(
|
||||
detector, [build_ppu_record((0.5, 0.5, 0.4, 0.4), grid=(3, 4, 0, 1))]
|
||||
)
|
||||
# layer 1 of 3: stride 16, anchor 30x61, not layer 1 of 2
|
||||
self.assertAlmostEqual(second[0][3], 0.0975, places=5)
|
||||
|
||||
detector = self.ppu_detector(
|
||||
ANCHOR_FREE_PPU, ONE_SCALE_FREE, box_format="centre"
|
||||
)
|
||||
record = [build_ppu_record((100.0, 50.0, 300.0, 250.0), label=2)]
|
||||
# centre (100, 50), size 300 x 250: the right edge lands at 250
|
||||
for frame in range(2):
|
||||
with self.subTest(frame=frame):
|
||||
self.assertAlmostEqual(
|
||||
self.detect(detector, record)[0][5], 250 / 640, places=5
|
||||
)
|
||||
|
||||
def test_records_the_head_cannot_place_come_back_empty(self):
|
||||
cases = {
|
||||
"a level or box the anchor table does not carry": (
|
||||
THREE_SCALE_ANCHORS,
|
||||
[build_ppu_record((0.6, 0.4, 0.3, 0.7), grid=(7, 9, 2, 5))],
|
||||
),
|
||||
"a scale count Frigate has no anchor table for": (
|
||||
FOUR_SCALE_ANCHORS,
|
||||
[build_ppu_record((0.6, 0.4, 0.3, 0.7))],
|
||||
),
|
||||
"a record below the score threshold": (
|
||||
THREE_SCALE_ANCHORS,
|
||||
[build_ppu_record((0.6, 0.4, 0.3, 0.7), score=0.1)],
|
||||
),
|
||||
"an unexpected record width": (
|
||||
THREE_SCALE_ANCHORS,
|
||||
[np.zeros((1, 3, 16), dtype=np.uint8)],
|
||||
),
|
||||
**{
|
||||
f"no records at all, shaped {shape}": (
|
||||
THREE_SCALE_ANCHORS,
|
||||
[np.zeros(shape, dtype=np.uint8)],
|
||||
)
|
||||
for shape in ((1, 0, PPU_RECORD_SIZE), (0, PPU_RECORD_SIZE), (0,))
|
||||
},
|
||||
}
|
||||
|
||||
for output, (layers, outputs) in cases.items():
|
||||
with self.subTest(output=output):
|
||||
detector = self.ppu_detector(ANCHOR_BASED_PPU, layers)
|
||||
|
||||
self.assertTrue(np.all(self.detect(detector, outputs) == 0))
|
||||
|
||||
def test_a_yolox_raw_head_is_decoded_through_the_grid(self):
|
||||
detector = self.detector(ModelTypeEnum.yolox, [{"shape": [1, 8400, 85]}])
|
||||
|
||||
# cell (x 10, y 5) of the stride-8 grid is row 5 * 80 + 10
|
||||
tensor = np.zeros((1, 8400, 85), np.float32)
|
||||
tensor[0, 410, :5] = [0.5, 0.5, np.log(4.0), np.log(2.0), 0.9]
|
||||
tensor[0, 410, 5 + 7] = 0.8
|
||||
|
||||
detections = self.detect(detector, [tensor])
|
||||
|
||||
# centre (84, 44), size 32 x 16
|
||||
self.assertEqual(detections[0][0], 7)
|
||||
self.assertAlmostEqual(detections[0][1], 0.72, places=5)
|
||||
self.assertAlmostEqual(detections[0][2], 36 / 640, places=5)
|
||||
self.assertAlmostEqual(detections[0][3], 68 / 640, places=5)
|
||||
self.assertAlmostEqual(detections[0][4], 52 / 640, places=5)
|
||||
self.assertAlmostEqual(detections[0][5], 100 / 640, places=5)
|
||||
|
||||
detector = self.detector(ModelTypeEnum.yolox, [{"shape": [1, 85, 8400]}])
|
||||
detections = self.detect(detector, [np.swapaxes(tensor, 1, 2)])
|
||||
self.assertAlmostEqual(detections[0][5], 100 / 640, places=5)
|
||||
|
||||
def test_a_raw_head_comes_back_as_frigates_detection_rows(self):
|
||||
detector = self.detector(ModelTypeEnum.yologeneric, [{"shape": [1, 84, 8400]}])
|
||||
output = np.zeros((1, 84, 8400), dtype=np.float32)
|
||||
output[0, 0:4, 0] = [320.0, 160.0, 64.0, 32.0]
|
||||
output[0, 4 + 2, 0] = 0.9
|
||||
|
||||
detections = self.detect(detector, [output])
|
||||
|
||||
self.assertEqual(detections.shape, (20, 6))
|
||||
self.assertEqual(detections[0][0], 2)
|
||||
self.assertAlmostEqual(detections[0][1], 0.9, places=5)
|
||||
self.assertAlmostEqual(detections[0][3], 288 / 640, places=5)
|
||||
@@ -184,6 +184,26 @@ class TestAccelerators(HardwareProbeTestCase):
|
||||
)
|
||||
self.assertFalse(memryx.unlimited)
|
||||
|
||||
def test_each_deepx_node_is_a_unit(self):
|
||||
write(os.path.join(self.dev_root, "dxrt0"))
|
||||
write(os.path.join(self.dev_root, "dxrt1"))
|
||||
|
||||
deepx = self.probe()["deepx"]
|
||||
|
||||
self.assertEqual(
|
||||
[unit.device for unit in deepx.units],
|
||||
["deepx:PCIe:0", "deepx:PCIe:1"],
|
||||
)
|
||||
|
||||
def test_a_deepx_npu_is_unlimited(self):
|
||||
# the host daemon multiplexes, so one module takes several processes
|
||||
write(os.path.join(self.dev_root, "dxrt0"))
|
||||
|
||||
self.assertTrue(self.probe()["deepx"].unlimited)
|
||||
|
||||
def test_no_deepx_is_reported_without_a_node(self):
|
||||
self.assertNotIn("deepx", self.probe())
|
||||
|
||||
def test_a_supported_rockchip_soc_is_reported(self):
|
||||
write(
|
||||
os.path.join(self.proc_root, "device-tree", "compatible"),
|
||||
|
||||
@@ -48,6 +48,16 @@ class TestNoticeKinds(unittest.TestCase):
|
||||
|
||||
self.assertIsNone(kind.link_for({"version": "0.19.1"}))
|
||||
|
||||
def test_analytics_prompt_links_to_telemetry_settings(self):
|
||||
kind = NOTICE_KINDS["analytics_prompt"]
|
||||
|
||||
self.assertEqual(kind.link_for({}), "/settings?page=systemTelemetry")
|
||||
self.assertFalse(kind.counts_repeats)
|
||||
self.assertFalse(kind.reportable)
|
||||
|
||||
def test_update_available_is_not_reported(self):
|
||||
self.assertFalse(NOTICE_KINDS["update_available"].reportable)
|
||||
|
||||
|
||||
class RegistryTestCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
@@ -278,6 +288,24 @@ class TestNoticeRegistry(RegistryTestCase):
|
||||
self.assertEqual(dismissals["detector_stuck"], 1)
|
||||
|
||||
|
||||
class TestMarkReported(RegistryTestCase):
|
||||
def test_moves_watermarks_to_the_snapshot_not_the_current_count(self):
|
||||
self.registry.raise_notice(
|
||||
"detector_stuck", scope="ov", params={"detector": "ov"}
|
||||
)
|
||||
snapshot = self.registry.stats()
|
||||
self.registry.raise_notice(
|
||||
"detector_stuck", scope="ov", params={"detector": "ov"}
|
||||
)
|
||||
|
||||
self.registry.mark_reported(snapshot)
|
||||
|
||||
row = self.registry.stats()[0]
|
||||
self.assertEqual(row["occurrences"], 2)
|
||||
self.assertEqual(row["reported_occurrences"], 1)
|
||||
self.assertEqual(row["reported_dismissals"], 0)
|
||||
|
||||
|
||||
class TestApply(RegistryTestCase):
|
||||
def test_each_action_reaches_its_method(self):
|
||||
self.registry.apply(
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Generate the analytics report JSON Schema from the Pydantic models.
|
||||
|
||||
The committed docs/static/frigate-analytics-schema.json documents every field
|
||||
for the ingest Worker's aggregation job and the docs page. It is exact for this
|
||||
version only, so the Worker validates just the envelope against it. Never edit
|
||||
it by hand, and never run a formatter over it.
|
||||
|
||||
Usage (from the repository root):
|
||||
|
||||
python3 generate_analytics_schema.py # write the schema
|
||||
python3 generate_analytics_schema.py --check # CI guard: fail if stale
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from frigate.analytics.schema import SCHEMA_VERSION, AnalyticsReport
|
||||
|
||||
OUTPUT = Path(__file__).parent / "docs" / "static" / "frigate-analytics-schema.json"
|
||||
|
||||
|
||||
def render() -> str:
|
||||
schema = AnalyticsReport.model_json_schema()
|
||||
schema["$schema"] = "https://json-schema.org/draft/2020-12/schema"
|
||||
schema["$id"] = "https://docs.frigate.video/frigate-analytics-schema.json"
|
||||
schema["x-schema-version"] = SCHEMA_VERSION
|
||||
return json.dumps(schema, indent=2, sort_keys=True) + "\n"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
parser.add_argument(
|
||||
"--check", action="store_true", help="fail if the committed schema is stale"
|
||||
)
|
||||
args = parser.parse_args()
|
||||
rendered = render()
|
||||
|
||||
if args.check:
|
||||
current = OUTPUT.read_text() if OUTPUT.exists() else ""
|
||||
|
||||
if current != rendered:
|
||||
print(
|
||||
f"{OUTPUT} is out of date, run python3 generate_analytics_schema.py",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
print(f"{OUTPUT} is up to date")
|
||||
return 0
|
||||
|
||||
OUTPUT.write_text(rendered)
|
||||
print(f"Wrote {OUTPUT}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -47,8 +47,8 @@ from fastapi.routing import APIRoute
|
||||
from ruamel.yaml import YAML
|
||||
from ruamel.yaml.scalarstring import LiteralScalarString
|
||||
|
||||
from frigate.api import app as main_app
|
||||
from frigate.api import (
|
||||
analytics,
|
||||
auth,
|
||||
camera,
|
||||
chat,
|
||||
@@ -65,6 +65,7 @@ from frigate.api import (
|
||||
record,
|
||||
review,
|
||||
)
|
||||
from frigate.api import app as main_app
|
||||
from frigate.api.auth import require_admin_by_default
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
||||
@@ -145,6 +146,7 @@ def build_app() -> FastAPI:
|
||||
"""
|
||||
app = FastAPI()
|
||||
routers = [
|
||||
analytics.router,
|
||||
auth.router,
|
||||
camera.router,
|
||||
chat.router,
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Telemetry settings: the analytics preview shows the report Frigate would
|
||||
* send, fetched only when opened.
|
||||
*/
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { test, expect } from "../../fixtures/frigate-test";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const CONFIG_SCHEMA = JSON.parse(
|
||||
readFileSync(
|
||||
resolve(__dirname, "../../fixtures/mock-data/config-schema.json"),
|
||||
"utf-8",
|
||||
),
|
||||
);
|
||||
|
||||
const PREVIEW = {
|
||||
schema_version: 1,
|
||||
install_id: "0".repeat(32),
|
||||
install: { version: "0.19.0-test" },
|
||||
};
|
||||
|
||||
test.describe("telemetry settings: analytics preview @medium", () => {
|
||||
test("shows the report on demand", async ({ frigateApp }) => {
|
||||
let requests = 0;
|
||||
const page = frigateApp.page;
|
||||
|
||||
await page.route("**/api/config/schema.json", (route) =>
|
||||
route.fulfill({ json: CONFIG_SCHEMA }),
|
||||
);
|
||||
await page.route("**/api/config/raw_paths", (route) =>
|
||||
route.fulfill({ json: { telemetry: {} } }),
|
||||
);
|
||||
await page.route("**/api/analytics/preview", (route) => {
|
||||
requests += 1;
|
||||
return route.fulfill({ json: PREVIEW });
|
||||
});
|
||||
|
||||
await frigateApp.goto("/settings?page=systemTelemetry");
|
||||
|
||||
const button = page.getByRole("button", { name: "Preview the report" });
|
||||
await expect(button).toBeVisible();
|
||||
expect(requests).toBe(0);
|
||||
|
||||
await button.click();
|
||||
|
||||
await expect(page.getByTestId("analytics-preview")).toContainText(
|
||||
'"schema_version": 1',
|
||||
);
|
||||
expect(requests).toBe(1);
|
||||
});
|
||||
});
|
||||
Generated
+586
-585
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -114,7 +114,7 @@
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.7",
|
||||
"globals": "^17.12.0",
|
||||
"i18next-cli": "^1.74.1",
|
||||
"i18next-cli": "^1.5.11",
|
||||
"monaco-editor": "^0.52.2",
|
||||
"patch-package": "^8.0.1",
|
||||
"postcss": "^8.5.12",
|
||||
|
||||
@@ -222,6 +222,10 @@
|
||||
"telemetry": {
|
||||
"label": "Telemetry",
|
||||
"description": "System telemetry and stats options including GPU and network bandwidth monitoring.",
|
||||
"analytics": {
|
||||
"label": "Share anonymous analytics",
|
||||
"description": "Send one anonymous usage report a day to help the Frigate maintainers decide what to support. Nothing is sent until this is on."
|
||||
},
|
||||
"network_interfaces": {
|
||||
"label": "Network interfaces",
|
||||
"description": "List of network interface name prefixes to monitor for bandwidth statistics."
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
{
|
||||
"analyticsPreview": {
|
||||
"show": "Preview the report",
|
||||
"desc": "Frigate sends this report once a day while sharing is on. It never includes camera names, zones, labels, or addresses.",
|
||||
"error": "Unable to build the preview"
|
||||
},
|
||||
"documentTitle": {
|
||||
"default": "Settings - Frigate",
|
||||
"authentication": "Authentication Settings - Frigate",
|
||||
|
||||
@@ -45,7 +45,8 @@
|
||||
"shm_too_low": "/dev/shm allocation ({{total}} MB) should be increased to at least {{min}} MB",
|
||||
"failed_login_one": "Failed login attempt for {{user}}",
|
||||
"failed_login_other": "Failed login attempts for {{user}}",
|
||||
"update_available": "Frigate {{version}} is available"
|
||||
"update_available": "Frigate {{version}} is available",
|
||||
"analytics_prompt": "Consider sharing anonymous usage statistics to help the developers improve Frigate"
|
||||
},
|
||||
"streamPrefix": "Stream {{index}}: {{message}}",
|
||||
"streamPrefixRestream": "Stream {{index}} (via go2rtc): {{message}}",
|
||||
|
||||
@@ -3,9 +3,17 @@ import type { SectionConfigOverrides } from "./types";
|
||||
const telemetry: SectionConfigOverrides = {
|
||||
base: {
|
||||
sectionDocs: "/configuration/advanced/reference",
|
||||
fieldDocs: {
|
||||
analytics: "/configuration/advanced/analytics",
|
||||
},
|
||||
restartRequired: ["version_check"],
|
||||
fieldOrder: ["network_interfaces", "stats", "version_check"],
|
||||
fieldOrder: ["analytics", "network_interfaces", "stats", "version_check"],
|
||||
advancedFields: [],
|
||||
uiSchema: {
|
||||
analytics: {
|
||||
"ui:after": { render: "AnalyticsPreview" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useState } from "react";
|
||||
import useSWR from "swr";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuChevronDown, LuChevronRight } from "react-icons/lu";
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
|
||||
export default function AnalyticsPreview() {
|
||||
const { t } = useTranslation("views/settings");
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
// built on demand, since building runs every collector on the server
|
||||
const { data, error } = useSWR<Record<string, unknown>>(
|
||||
open ? "analytics/preview" : null,
|
||||
{ revalidateOnFocus: false },
|
||||
);
|
||||
|
||||
return (
|
||||
<Collapsible open={open} onOpenChange={setOpen} className="mt-2">
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="gap-1">
|
||||
{open ? (
|
||||
<LuChevronDown className="size-4" />
|
||||
) : (
|
||||
<LuChevronRight className="size-4" />
|
||||
)}
|
||||
{t("analyticsPreview.show")}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{t("analyticsPreview.desc")}
|
||||
</div>
|
||||
<CollapsibleContent className="mt-2">
|
||||
{error ? (
|
||||
<div className="text-sm text-danger">
|
||||
{t("analyticsPreview.error")}
|
||||
</div>
|
||||
) : data ? (
|
||||
<pre
|
||||
data-testid="analytics-preview"
|
||||
className="max-h-96 overflow-auto rounded-md bg-secondary p-3 text-xs"
|
||||
>
|
||||
{JSON.stringify(data, null, 2)}
|
||||
</pre>
|
||||
) : (
|
||||
<ActivityIndicator className="size-5" />
|
||||
)}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ComponentType } from "react";
|
||||
import AnalyticsPreview from "./AnalyticsPreview";
|
||||
import SemanticSearchReindex from "./SemanticSearchReindex.tsx";
|
||||
import CameraReviewStatusToggles from "./CameraReviewStatusToggles";
|
||||
import ProxyRoleMap from "./ProxyRoleMap";
|
||||
@@ -56,6 +57,9 @@ export const sectionRenderers: SectionRenderers = {
|
||||
birdseye: {
|
||||
BirdseyeCameraReorder,
|
||||
},
|
||||
telemetry: {
|
||||
AnalyticsPreview,
|
||||
},
|
||||
};
|
||||
|
||||
export default sectionRenderers;
|
||||
|
||||
@@ -649,6 +649,7 @@ export interface FrigateConfig {
|
||||
};
|
||||
|
||||
telemetry: {
|
||||
analytics: boolean;
|
||||
network_interfaces: string[];
|
||||
stats: {
|
||||
amd_gpu_stats: boolean;
|
||||
|
||||
Reference in New Issue
Block a user