mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-02 21:06:52 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cffef45730 |
@@ -17,14 +17,9 @@ runs:
|
||||
shell: bash
|
||||
# This creates a virtual volume at /var/lib/docker to maximize the size
|
||||
# As of 2/14/2024, this results in 97G for docker images
|
||||
# Runners no longer have a separate /mnt disk, so the temp PV is also carved
|
||||
# from root and temp-reserve-mb is what actually stays free on root. Keep 4G
|
||||
# there for setup-qemu/buildx caches in ~/.docker and the tool cache
|
||||
- name: Maximize build space
|
||||
uses: easimon/maximize-build-space@master
|
||||
with:
|
||||
root-reserve-mb: 8192
|
||||
temp-reserve-mb: 4096
|
||||
remove-dotnet: 'true'
|
||||
remove-android: 'true'
|
||||
remove-haskell: 'true'
|
||||
|
||||
@@ -5,4 +5,3 @@
|
||||
/docker/rockchip/ @MarcA711
|
||||
/docker/rocm/ @harakas
|
||||
/docker/hailo8l/ @spanner3003
|
||||
/docker/deepx/ @sixfab
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
#!/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."
|
||||
@@ -27,7 +27,7 @@ pydantic == 2.10.*
|
||||
git+https://github.com/fbcotter/py3nvml#egg=py3nvml
|
||||
pytz == 2025.*
|
||||
pyzmq == 27.1.*
|
||||
ruamel.yaml == 0.19.*
|
||||
ruamel.yaml == 0.18.*
|
||||
tzlocal == 5.2
|
||||
requests == 2.33.*
|
||||
types-requests == 2.32.*
|
||||
@@ -37,13 +37,13 @@ ws4py == 0.5.*
|
||||
unidecode == 1.4.*
|
||||
titlecase == 2.4.*
|
||||
# Image Manipulation
|
||||
numpy == 1.26.*
|
||||
numpy == 2.5.*
|
||||
opencv-python-headless == 4.11.0.*
|
||||
opencv-contrib-python == 4.11.0.*
|
||||
scipy == 1.16.*
|
||||
# OpenVino & ONNX
|
||||
openvino == 2025.4.*
|
||||
onnxruntime == 1.30.*
|
||||
onnxruntime == 1.22.*
|
||||
# Embeddings
|
||||
transformers == 4.45.*
|
||||
# Generative AI
|
||||
@@ -58,7 +58,7 @@ pyclipper == 1.4.*
|
||||
shapely == 2.0.*
|
||||
rapidfuzz==3.12.*
|
||||
# HailoRT
|
||||
argcomplete==3.7.*
|
||||
argcomplete==2.0.*
|
||||
contextlib2==0.6.*
|
||||
future==0.18.*
|
||||
netaddr==1.3.*
|
||||
|
||||
@@ -37,7 +37,6 @@ device_globs=(
|
||||
"/dev/nvmap"
|
||||
"/dev/nvidia*"
|
||||
"/dev/memx*"
|
||||
"/dev/dxrt*"
|
||||
)
|
||||
|
||||
IFS=',' read -ra extra_globs <<< "${DEVICE_ACL_PATHS:-}"
|
||||
|
||||
@@ -15,7 +15,6 @@ from frigate.const import (
|
||||
)
|
||||
from frigate.ffmpeg_presets import parse_preset_hardware_acceleration_encode
|
||||
from frigate.util.config import find_config_file, resolve_ffmpeg_path
|
||||
from frigate.util.live_streams import raw_transcode_streams
|
||||
from frigate.util.services import (
|
||||
is_go2rtc_arbitrary_exec_allowed,
|
||||
is_restricted_go2rtc_source,
|
||||
@@ -175,17 +174,6 @@ for name in list(go2rtc_config.get("streams", {})):
|
||||
del go2rtc_config["streams"][name]
|
||||
continue
|
||||
|
||||
# add transcoded live streams; a user stream with the same name wins here and
|
||||
# fails Frigate's config validation
|
||||
transcoded_streams = raw_transcode_streams(config)
|
||||
|
||||
if transcoded_streams:
|
||||
if go2rtc_config.get("streams") is None:
|
||||
go2rtc_config["streams"] = {}
|
||||
|
||||
for name, source in transcoded_streams.items():
|
||||
go2rtc_config["streams"].setdefault(name, source)
|
||||
|
||||
# add birdseye restream stream if enabled
|
||||
if config.get("birdseye", {}).get("restream", False):
|
||||
birdseye: dict[str, Any] = config.get("birdseye")
|
||||
|
||||
@@ -824,38 +824,6 @@ cpu:
|
||||
models:
|
||||
- devices:
|
||||
- cpu:3
|
||||
xdna2:
|
||||
title: AMD XDNA2
|
||||
models:
|
||||
- key: yolov9
|
||||
label: YOLOv9
|
||||
recommended: true
|
||||
download: |-
|
||||
Prepare the model using the frigate-xdna setup instructions linked above. For local YOLO models, Frigate must have access to the same ONNX file bytes as the sidecar. The example below uses YOLOv9-C at 320x320. Frigate+ models may instead use the same `plus://MODEL_ID` in Frigate and the sidecar.
|
||||
ui: |-
|
||||
Navigate to **Settings > System > Detection models** and add a model. The ZMQ endpoint is not reported by the hardware probe, so set `devices` to `zmq:tcp://xdna:5555` in YAML. Then, on the same model, open the **Custom Model** tab and configure:
|
||||
|
||||
| Field | Value |
|
||||
| ---------------------------------------- | ------------------------------------------ |
|
||||
| **Custom object detector model path** | `/config/models/yolov9-c-320.onnx` |
|
||||
| **Label map for custom object detector** | `/labelmap/coco-80.txt` |
|
||||
| **Object detection model input width** | `320` |
|
||||
| **Object detection model input height** | `320` |
|
||||
| **Model Input Pixel Color Format** | `rgb` (Frigate's default value) |
|
||||
| **Model Input Tensor Shape** | `nchw` |
|
||||
| **Model Input D Type** | `float` |
|
||||
| **Object Detection Model Type** | `yolo-generic` |
|
||||
yaml: |-
|
||||
models:
|
||||
- devices:
|
||||
- zmq:tcp://xdna:5555
|
||||
model_type: yolo-generic
|
||||
width: 320
|
||||
height: 320
|
||||
input_tensor: nchw
|
||||
input_dtype: float
|
||||
path: /config/models/yolov9-c-320.onnx
|
||||
labelmap_path: /labelmap/coco-80.txt
|
||||
memryx:
|
||||
title: MemryX
|
||||
models:
|
||||
@@ -999,65 +967,6 @@ 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:
|
||||
|
||||
@@ -152,10 +152,9 @@ auth:
|
||||
models:
|
||||
# Optional: the camera environment this model is for (default: shown below)
|
||||
# Cameras select a model by setting detect -> scene to a matching value, and
|
||||
# the model with a scene of default is used by any camera that does not set one.
|
||||
# Any name made up of letters, numbers, _ and - is valid, such as thermal.
|
||||
# Models that use the same model file are combined into one model.
|
||||
- scene: default
|
||||
# a model with a scene of all is used by any camera that does not set one.
|
||||
# Valid values are all, indoor, outdoor, indoor_thermal, outdoor_thermal
|
||||
- scene: all
|
||||
# Required: hardware this model runs on, as <detector> or <detector>:<device>
|
||||
# See https://docs.frigate.video/configuration/object_detectors for the
|
||||
# detectors available and the devices each one accepts. All of a model's
|
||||
@@ -294,9 +293,9 @@ ffmpeg:
|
||||
# Optional: output args for detect streams (default: shown below)
|
||||
detect: -threads 2 -f rawvideo -pix_fmt yuv420p
|
||||
# Optional: output args for record streams (default: shown below)
|
||||
record: preset-record-generic-audio-aac
|
||||
record: preset-record-generic
|
||||
# Optional: output args for sub stream record streams (default: the record output args above)
|
||||
# record_sub: preset-record-generic-audio-aac
|
||||
# record_sub: preset-record-generic
|
||||
# Optional: Time in seconds to wait before ffmpeg retries connecting to the camera. (default: shown below)
|
||||
# If set too low, frigate will retry a connection to the camera's stream too frequently, using up the limited streams some cameras can allow at once
|
||||
# If set too high, then if a ffmpeg crash or camera stream timeout occurs, you could potentially lose up to a maximum of retry_interval second(s) of footage
|
||||
@@ -317,9 +316,9 @@ detect:
|
||||
# Optional: height of the frame for the input with the detect role (default: use native stream resolution)
|
||||
height: 720
|
||||
# Optional: the environment this camera looks at, which picks the model it runs on
|
||||
# (default: the model with a scene of default)
|
||||
# Must match the scene of a configured model
|
||||
scene: thermal
|
||||
# (default: the model with a scene of all)
|
||||
# Valid values are all, indoor, outdoor, indoor_thermal, outdoor_thermal
|
||||
scene: outdoor
|
||||
# Optional: desired fps for your camera for the input with the detect role (default: shown below)
|
||||
# NOTE: Recommended value of 5. Ideally, try and reduce your FPS on the camera.
|
||||
fps: 5
|
||||
@@ -571,8 +570,6 @@ notifications:
|
||||
enabled: False
|
||||
# Optional: Email for push service to reach out to
|
||||
# NOTE: This is required to use notifications
|
||||
# NOTE: Email can be specified with an environment variable or docker secrets that must begin with 'FRIGATE_'.
|
||||
# e.g. email: '{FRIGATE_NOTIFICATION_EMAIL}'
|
||||
email: "admin@example.com"
|
||||
# Optional: Cooldown time for notifications in seconds (default: shown below)
|
||||
cooldown: 0
|
||||
@@ -803,7 +800,7 @@ lpr:
|
||||
# to Google or OpenAI's LLMs to generate descriptions. GenAI features can be configured at
|
||||
# the camera level to enhance privacy for indoor cameras.
|
||||
# NOTE: genai is a map of named providers. Each key is a name you choose for the provider,
|
||||
# and each role (chat, descriptions, embeddings, transcribe) may be assigned to exactly one provider.
|
||||
# and each role (chat, descriptions, embeddings) may be assigned to exactly one provider.
|
||||
genai:
|
||||
# Required: name of the provider (chosen by you, used to reference it elsewhere)
|
||||
my_provider:
|
||||
@@ -816,13 +813,11 @@ genai:
|
||||
# Required: The model to use with the provider.
|
||||
model: gemini-1.5-flash
|
||||
# Optional: Roles this provider handles (default: shown below)
|
||||
# Each role (chat, descriptions, embeddings, transcribe) must be assigned to exactly
|
||||
# one provider.
|
||||
# Each role (chat, descriptions, embeddings) must be assigned to exactly one provider.
|
||||
roles:
|
||||
- chat
|
||||
- descriptions
|
||||
- embeddings
|
||||
- transcribe
|
||||
# Optional additional args to pass to the GenAI Provider (default: None)
|
||||
provider_options:
|
||||
keep_alive: -1
|
||||
@@ -835,19 +830,13 @@ genai:
|
||||
audio_transcription:
|
||||
# Optional: Enable live and speech event audio transcription (default: shown below)
|
||||
enabled: False
|
||||
# Optional: The transcription backend (default: shown below)
|
||||
# Either 'whisper' for Frigate's built-in local models, or the name of a genai
|
||||
# provider that has 'transcribe' in its roles. device and model_size are ignored
|
||||
# when a genai provider is named.
|
||||
model: whisper
|
||||
# Optional: The device to run the models on for live transcription. (default: shown below)
|
||||
device: CPU
|
||||
# Optional: Set the model size used for live transcription. (default: shown below)
|
||||
model_size: small
|
||||
# Optional: Set the language used for transcription translation. (default: shown below)
|
||||
# Use 'auto' to let the model detect the language, or a language code from
|
||||
# https://github.com/openai/whisper/blob/main/whisper/tokenizer.py#L10
|
||||
language: auto
|
||||
# List of language codes: https://github.com/openai/whisper/blob/main/whisper/tokenizer.py#L10
|
||||
language: en
|
||||
|
||||
# Optional: Configuration for classification models
|
||||
classification:
|
||||
@@ -903,21 +892,6 @@ live:
|
||||
streams:
|
||||
main_stream: main_stream_name
|
||||
sub_stream: sub_stream_name
|
||||
# Optional: Lower-quality live streams transcoded by go2rtc while someone is watching.
|
||||
# NOTE: Set at the camera level only.
|
||||
transcode:
|
||||
# Optional: Enable transcoded streams (default: shown below)
|
||||
enabled: False
|
||||
# Optional: go2rtc stream to transcode (default: the first live stream)
|
||||
source: main_stream_name
|
||||
# Optional: One transcoded stream per quality (default: shown below)
|
||||
qualities:
|
||||
- height: 720
|
||||
bitrate: 1200
|
||||
- height: 480
|
||||
bitrate: 500
|
||||
- height: 360
|
||||
bitrate: 250
|
||||
# Optional: Set the height of the jsmpeg stream. (default: 720)
|
||||
# This must be less than or equal to the height of the detect stream. Lower resolutions
|
||||
# reduce bandwidth required for viewing the jsmpeg stream. Width is computed to match known aspect ratio.
|
||||
|
||||
@@ -204,7 +204,7 @@ Frequently-heard labels like `speech` can generate a lot of events, and each eve
|
||||
|
||||
### Audio Transcription
|
||||
|
||||
Frigate supports fully local audio transcription using either `sherpa-onnx` or OpenAI's open-source Whisper models via `faster-whisper`, and can alternatively offload transcription to a [GenAI provider](#genai-provider). The goal of this feature is to support Semantic Search for `speech` audio events. Frigate is not intended to act as a continuous, fully-automatic speech transcription service. Automatically transcribing all speech (or queuing many audio events for transcription) requires substantial CPU (or GPU) resources and is impractical on most systems. For this reason, transcriptions for events are initiated manually from the UI or the API rather than being run continuously in the background.
|
||||
Frigate supports fully local audio transcription using either `sherpa-onnx` or OpenAI's open-source Whisper models via `faster-whisper`. The goal of this feature is to support Semantic Search for `speech` audio events. Frigate is not intended to act as a continuous, fully-automatic speech transcription service. Automatically transcribing all speech (or queuing many audio events for transcription) requires substantial CPU (or GPU) resources and is impractical on most systems. For this reason, transcriptions for events are initiated manually from the UI or the API rather than being run continuously in the background.
|
||||
|
||||
:::info
|
||||
|
||||
@@ -224,7 +224,6 @@ To enable transcription, configure it globally and optionally disable for specif
|
||||
**Global:** Navigate to <NavPath path="Settings > Enrichments > Audio transcription" />.
|
||||
|
||||
- Set **Enable audio transcription** to on
|
||||
- Set **Audio transcription model or GenAI provider name** to `whisper` for Frigate's built-in local models, or to the name of a GenAI provider
|
||||
- Set **Transcription device** to the desired device
|
||||
- Set **Model size** to the desired size
|
||||
|
||||
@@ -236,7 +235,6 @@ To enable transcription, configure it globally and optionally disable for specif
|
||||
```yaml
|
||||
audio_transcription:
|
||||
enabled: True
|
||||
model: whisper
|
||||
device: ...
|
||||
model_size: ...
|
||||
```
|
||||
@@ -265,88 +263,20 @@ The optional config parameters that can be set at the global level include:
|
||||
- **`enabled`**: Enable or disable the audio transcription feature.
|
||||
- Default: `False`
|
||||
- It is recommended to only configure the features at the global level, and enable it at the individual camera level.
|
||||
- **`model`**: The transcription backend.
|
||||
- Default: `whisper`
|
||||
- `whisper` uses Frigate's built-in local models, described by `device` and `model_size` below.
|
||||
- Any other value must name a key in your `genai` config whose entry has `transcribe` in its `roles`. See [GenAI Provider](#genai-provider).
|
||||
- **`device`**: Device to use to run transcription and translation models.
|
||||
- Default: `CPU`
|
||||
- This can be `CPU` or `GPU`. The `sherpa-onnx` models are lightweight and run on the CPU only. The `whisper` models can run on GPU but are only supported on CUDA hardware.
|
||||
- Ignored when `model` names a GenAI provider.
|
||||
- **`model_size`**: The size of the model used for live transcription.
|
||||
- Default: `small`
|
||||
- This can be `small` or `large`. The `small` setting uses `sherpa-onnx` models that are fast, lightweight, and always run on the CPU but are not as accurate as the `whisper` model.
|
||||
- This config option applies to **live transcription only**. With `model: whisper`, recorded `speech` events always use a different `whisper` model (and can be accelerated for CUDA hardware if available with `device: GPU`).
|
||||
- Ignored when `model` names a GenAI provider.
|
||||
- **`language`**: Defines the language used to transcribe and translate `speech` audio events (and live audio only if using the `large` model or a GenAI provider).
|
||||
- Default: `auto`
|
||||
- `auto` lets the model detect the language itself, which most models do well. Set an explicit language only if detection is picking the wrong one.
|
||||
- Otherwise you must use a valid [language code](https://github.com/openai/whisper/blob/main/whisper/tokenizer.py#L10).
|
||||
- This config option applies to **live transcription only**. Recorded `speech` events will always use a different `whisper` model (and can be accelerated for CUDA hardware if available with `device: GPU`).
|
||||
- **`language`**: Defines the language used by `whisper` to translate `speech` audio events (and live audio only if using the `large` model).
|
||||
- Default: `en`
|
||||
- You must use a valid [language code](https://github.com/openai/whisper/blob/main/whisper/tokenizer.py#L10).
|
||||
- Transcriptions for `speech` events are translated.
|
||||
- Live audio is translated only if you are using the `large` model. The `small` `sherpa-onnx` model is English-only.
|
||||
|
||||
The only field that is valid at the camera level is `enabled`. In particular `model` is global only: the transcription backend is a process-wide resource shared by every camera.
|
||||
|
||||
#### GenAI Provider
|
||||
|
||||
Frigate can send audio to a GenAI provider for transcription when that provider has the `transcribe` role. This is useful if you already run a GenAI provider, or if you do not have the CPU/GPU headroom for a local whisper model. Supported providers are **OpenAI**, **Azure OpenAI**, **Gemini**, and **llama.cpp** with an audio-capable model (a dedicated ASR model such as Qwen3-ASR, or a general multimodal model that accepts audio). Ollama is not supported as it has no audio input.
|
||||
|
||||
To use a GenAI provider for audio transcription:
|
||||
|
||||
1. Configure a GenAI provider with `transcribe` in its `roles`.
|
||||
2. Set the audio transcription model to that GenAI config key (e.g. `whisper_cloud`).
|
||||
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > Enrichments > Audio transcription" />.
|
||||
|
||||
| Field | Description |
|
||||
| ---------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
|
||||
| **Audio transcription model or GenAI provider name** | Set to the GenAI config key (e.g. `whisper_cloud`) to use a configured GenAI provider for transcription |
|
||||
|
||||
The GenAI provider must also be configured with the `transcribe` role under <NavPath path="Settings > Enrichments > Generative AI" />.
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
|
||||
```yaml
|
||||
genai:
|
||||
whisper_cloud:
|
||||
provider: openai
|
||||
api_key: your-api-key
|
||||
model: gpt-transcribe
|
||||
roles:
|
||||
- transcribe
|
||||
|
||||
audio_transcription:
|
||||
enabled: True
|
||||
model: whisper_cloud
|
||||
language: en
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
</ConfigTabs>
|
||||
|
||||
:::warning
|
||||
|
||||
**Give `transcribe` its own `genai` entry.** A `genai` entry has a single `model` string that is shared by every role it holds, so `roles: [descriptions, transcribe]` would send the same model name to both the chat endpoint and the transcription endpoint. Transcription models and chat models are almost never the same model, so define a dedicated entry as shown above.
|
||||
|
||||
:::
|
||||
|
||||
:::warning
|
||||
|
||||
**Live transcription against a metered provider is billed continuously.** In live mode Frigate uploads an overlapping ~2 second window of audio roughly once per second, per camera, for as long as audio stays above that camera's `audio.min_volume`. Windows below that threshold are never uploaded, which is what keeps a quiet camera near zero requests, but a camera pointed at a busy street will keep sending.
|
||||
|
||||
Three things keep this opt-in: `transcribe` is not one of the default roles, live transcription is off by default, and the volume gate suppresses silence. Transcription of recorded `speech` events is unaffected - it remains a manual, one-request-per-event action.
|
||||
|
||||
:::
|
||||
|
||||
`device` and `model_size` have no effect on this path and no local model is ever downloaded.
|
||||
|
||||
`language` defaults to `auto`, which sends no language hint and lets the model detect it. Most audio models detect language well, so leave it on `auto` unless detection is picking the wrong one.
|
||||
|
||||
When set explicitly, it is sent as the transcription endpoint's native `language` parameter for OpenAI, Azure, and llama.cpp, and as part of the prompt for Gemini. This matters for dedicated ASR models such as Qwen3-ASR: they read the prompt as contextual biasing rather than as an instruction, so a language named in the prompt is ignored, while the endpoint parameter is honored.
|
||||
The only field that is valid at the camera level is `enabled`.
|
||||
|
||||
#### Live transcription
|
||||
|
||||
@@ -362,8 +292,6 @@ Results can be error-prone due to a number of factors, including:
|
||||
|
||||
For speech sources close to the camera with minimal background noise, use the `small` model.
|
||||
|
||||
A [GenAI provider](#genai-provider) is generally the most accurate option for live transcription, at the cost of a network round trip per window. That round trip has to stay under about a second to keep up with the audio; if it does not, Frigate drops the oldest buffered audio rather than letting the backlog grow.
|
||||
|
||||
If you have CUDA hardware, you can experiment with the `large` `whisper` model on GPU. Performance is not quite as fast as the `sherpa-onnx` `small` model, but live transcription is far more accurate. Using the `large` model with CPU will likely be too slow for real-time transcription.
|
||||
|
||||
#### Transcription and translation of `speech` audio events
|
||||
@@ -380,7 +308,7 @@ Only one `speech` event may be transcribed at a time. Frigate does not automatic
|
||||
|
||||
:::
|
||||
|
||||
With `model: whisper`, recorded `speech` events always use a `whisper` model, regardless of the `model_size` config setting. Without a supported Nvidia GPU, generating transcriptions for longer `speech` events may take a fair amount of time, so be patient. With a [GenAI provider](#genai-provider), the recorded clip is sent to the provider instead and no local model is used.
|
||||
Recorded `speech` events will always use a `whisper` model, regardless of the `model_size` config setting. Without a supported Nvidia GPU, generating transcriptions for longer `speech` events may take a fair amount of time, so be patient.
|
||||
|
||||
#### FAQ
|
||||
|
||||
|
||||
@@ -85,14 +85,6 @@ An optional config, `save_attempts`, can be set as a key under the model name. T
|
||||
</TabItem>
|
||||
</ConfigTabs>
|
||||
|
||||
## Review items
|
||||
|
||||
When a model's state changes while its camera has an active review item, the change is recorded on that review item. This includes changes in the few seconds before the item starts, such as a garage door opening just before the car is detected. State changes never create or extend review items on their own, and the first state reported after Frigate starts is not recorded as a change.
|
||||
|
||||
Recorded changes appear in the review item's data as `classification_state_changes` (see the [`frigate/reviews`](/integrations/mqtt#frigatereviews) MQTT topic) and are passed to [GenAI review summaries](/configuration/genai/genai_review) as facts, so a description can note that a gate was opened during the activity.
|
||||
|
||||
Change times are most accurate with `motion: true`. A model that only runs on an `interval` notices a change at its next run, so the change may be recorded late or attached to a later review item.
|
||||
|
||||
## Training the model
|
||||
|
||||
Creating and training the model is done within the Frigate UI using the `Classification` page. The process consists of three steps:
|
||||
|
||||
@@ -17,19 +17,17 @@ Hardware acceleration arguments tell FFmpeg to decode your camera's video stream
|
||||
|
||||
See [the hardware acceleration docs](/configuration/hardware_acceleration_video.md) for details on setting up hardware acceleration for your GPU / iGPU, then select the preset that matches your hardware.
|
||||
|
||||
| Preset (YAML config) | UI Label | Usage | Notes |
|
||||
| ------------------------- | ----------------------- | --------------------------------------------- | --------------------------------------------------------------- |
|
||||
| preset-rpi-64-h264 | Raspberry Pi (H.264) | 64-bit Raspberry Pi, H.264 stream | |
|
||||
| preset-rpi-64-h265 | Raspberry Pi (H.265) | 64-bit Raspberry Pi, H.265 stream | |
|
||||
| preset-apple-silicon-h264 | Apple Silicon (H.264) | Apple Silicon Mac under lighter, H.264 stream | Needs the `lighter.sh/video` device |
|
||||
| preset-apple-silicon-h265 | Apple Silicon (H.265) | Apple Silicon Mac under lighter, H.265 stream | Needs the `lighter.sh/video` device |
|
||||
| preset-vaapi | VAAPI (Intel/AMD GPU) | Intel or AMD GPU via VAAPI | Check the hwaccel docs to ensure the correct driver is selected |
|
||||
| preset-intel-qsv-h264 | Intel QuickSync (H.264) | Intel QuickSync, H.264 stream | If you have issues, use the VAAPI preset instead |
|
||||
| preset-intel-qsv-h265 | Intel QuickSync (H.265) | Intel QuickSync, H.265 stream | If you have issues, use the VAAPI preset instead |
|
||||
| preset-nvidia | NVIDIA GPU | NVIDIA GPU | |
|
||||
| preset-jetson-h264 | NVIDIA Jetson (H.264) | NVIDIA Jetson, H.264 stream | |
|
||||
| preset-jetson-h265 | NVIDIA Jetson (H.265) | NVIDIA Jetson, H.265 stream | |
|
||||
| preset-rkmpp | Rockchip RKMPP | Rockchip MPP | Use an image with the `-rk` suffix and run in privileged mode |
|
||||
| Preset (YAML config) | UI Label | Usage | Notes |
|
||||
| --------------------- | ----------------------- | --------------------------------- | --------------------------------------------------------------- |
|
||||
| preset-rpi-64-h264 | Raspberry Pi (H.264) | 64-bit Raspberry Pi, H.264 stream | |
|
||||
| preset-rpi-64-h265 | Raspberry Pi (H.265) | 64-bit Raspberry Pi, H.265 stream | |
|
||||
| preset-vaapi | VAAPI (Intel/AMD GPU) | Intel or AMD GPU via VAAPI | Check the hwaccel docs to ensure the correct driver is selected |
|
||||
| preset-intel-qsv-h264 | Intel QuickSync (H.264) | Intel QuickSync, H.264 stream | If you have issues, use the VAAPI preset instead |
|
||||
| preset-intel-qsv-h265 | Intel QuickSync (H.265) | Intel QuickSync, H.265 stream | If you have issues, use the VAAPI preset instead |
|
||||
| preset-nvidia | NVIDIA GPU | NVIDIA GPU | |
|
||||
| preset-jetson-h264 | NVIDIA Jetson (H.264) | NVIDIA Jetson, H.264 stream | |
|
||||
| preset-jetson-h265 | NVIDIA Jetson (H.265) | NVIDIA Jetson, H.265 stream | |
|
||||
| preset-rkmpp | Rockchip RKMPP | Rockchip MPP | Use an image with the `-rk` suffix and run in privileged mode |
|
||||
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
@@ -43,7 +43,7 @@ genai:
|
||||
|
||||
The examples on this page all use `my_provider`, but the name is arbitrary and is only used to reference the provider elsewhere in the config (for example, `semantic_search.model`).
|
||||
|
||||
Each provider handles one or more **roles**: `chat`, `descriptions`, `embeddings`, and `transcribe`. A provider handles the first three by default; `transcribe` must always be listed explicitly, and is not available on Ollama, which has no audio input. Each role may be assigned to exactly one provider. Define a single provider if you want it to do everything, or split the roles across several providers using the `roles` option.
|
||||
Each provider handles one or more **roles**: `chat`, `descriptions`, and `embeddings`. A provider handles all three by default, and each role may be assigned to exactly one provider. Define a single provider if you want it to do everything, or split the roles across several providers using the `roles` option.
|
||||
|
||||
If the provider you choose requires an API key, you may either directly paste it in your configuration, or store it in an environment variable prefixed with `FRIGATE_`.
|
||||
|
||||
@@ -63,11 +63,11 @@ Running Generative AI models on CPU is not recommended, as high inference times
|
||||
|
||||
You must use a vision-capable model with Frigate. The following models are recommended for local deployment of the `descriptions` and `chat` roles:
|
||||
|
||||
| Model | Review [frame mode](/configuration/genai/genai_review#frame-mode) | Notes |
|
||||
| ------------------- | --------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `qwen3-vl` | `frames` | Strong visual and situational understanding, enhanced ability to identify smaller objects and interactions with object. Follows a sequence of frames on its own. |
|
||||
| `qwen3.6`/`qwen3.8` | `frames` | Strong situational understanding, but missing DeepStack from qwen3-vl leading to worse performance for identifying objects in people's hand and other small details. |
|
||||
| `gemma4` | `annotated_frames` | Strong situational understanding, sometimes resorts to more vague terms like 'interacts' instead of assigning a specific action. Loses track of activity that repeats or reverses, so it benefits from annotated frames. |
|
||||
| Model | Notes |
|
||||
| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `qwen3-vl` | Strong visual and situational understanding, enhanced ability to identify smaller objects and interactions with object. |
|
||||
| `qwen3.6`/`qwen3.8` | Strong situational understanding, but missing DeepStack from qwen3-vl leading to worse performance for identifying objects in people's hand and other small details. |
|
||||
| `gemma4` | Strong situational understanding, sometimes resorts to more vague terms like 'interacts' instead of assigning a specific action. |
|
||||
|
||||
#### Embedding models
|
||||
|
||||
@@ -77,17 +77,6 @@ The `embeddings` role needs a different kind of model. Text queries are matched
|
||||
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `qwen3-vl-embedding` | Multimodal embeddings for [Semantic Search](/configuration/semantic_search#genai-provider). Must be served by llama.cpp started with `--embeddings` and `--mmproj`. |
|
||||
|
||||
#### Transcription models
|
||||
|
||||
The `transcribe` role needs a model that accepts audio input. A text-only or vision-only model cannot serve this role. The following are recommended for local deployment of the `transcribe` role:
|
||||
|
||||
| Model | Notes |
|
||||
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `qwen3-asr` | Dedicated speech recognition model covering 30 languages, and the better choice for transcription quality. It only transcribes, so it cannot be shared with the `descriptions` or `chat` roles. |
|
||||
| `gemma4` | General multimodal model that accepts audio as well as images, so one served model can cover `transcribe` alongside the other roles. Transcript quality is below `qwen3-asr`, particularly on noisy audio. |
|
||||
|
||||
Both must be served by llama.cpp started with the matching audio `--mmproj`. llama.cpp only reports audio support when an audio projector is loaded. Without it Frigate sees the model as text-only and the `transcribe` role is unavailable in the UI. Frigate transcribes through the server's `/v1/audio/transcriptions` route, which llama.cpp serves for any audio-capable model.
|
||||
|
||||
:::info
|
||||
|
||||
Each model is available in multiple parameter sizes (3b, 4b, 8b, etc.). Larger sizes are more capable of complex tasks and understanding of situations, but requires more memory and computational resources. It is recommended to try multiple models and experiment to see which performs best.
|
||||
|
||||
@@ -192,45 +192,6 @@ review:
|
||||
</TabItem>
|
||||
</ConfigTabs>
|
||||
|
||||
### Frame Mode
|
||||
|
||||
Review items are sent to the model as a sequence of still frames. Some models follow that sequence well on their own; others lose track of activity that repeats or reverses, and describe a single trip when the subject actually made several. The `frame_mode` option controls how those frames are presented.
|
||||
|
||||
- `frames` (default): the prompt followed by the frames, exactly as earlier versions of Frigate sent them.
|
||||
- `annotated_frames`: each frame is preceded by its frame number and elapsed time, along with notes describing what the object tracker recorded at that moment, such as an object being first detected, starting to move, turning around, stopping, or no longer being detected.
|
||||
|
||||
The notes come from tracking data rather than from the images, so they describe activity the model may not have picked up on its own. In testing with a person carrying three waste bins to the curb one at a time, `gemma4` described a single trip on every attempt with `frames`, and consistently described multiple trips with `annotated_frames`. Models that already handle these sequences well, such as the `qwen3-vl` family, gain little and should stay on `frames`.
|
||||
|
||||
Changes reported by [state classification](/configuration/custom_classification/state_classification#review-items) models during the review item are listed in the prompt in both modes. `annotated_frames` also notes each change before the frame where it happened.
|
||||
|
||||
Annotated mode also caps the number of frames, since the notes already establish the order of events and extra near-duplicate frames tend to crowd out the middle of a clip. Longer review items are sampled more sparsely as a result, and typically use fewer tokens than `frames` mode for the same item.
|
||||
|
||||
:::note
|
||||
|
||||
Annotated mode needs tracking data for the review item. If none is available, Frigate falls back to sending plain frames for that item.
|
||||
|
||||
:::
|
||||
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > Global configuration > Review" />.
|
||||
|
||||
- Set **GenAI config > Frame mode** to the desired mode (e.g., `annotated_frames`)
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
|
||||
```yaml {4}
|
||||
review:
|
||||
genai:
|
||||
enabled: true
|
||||
frame_mode: annotated_frames
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
</ConfigTabs>
|
||||
|
||||
### Response Style
|
||||
|
||||
Different models respond to the built-in prompt with very different writing styles: some produce natural narration while others sound short and mechanical. The `response_style` option selects a writing style preset that rewords the prompt's instructions for the user-facing fields (the title, short summary, and scene description). Presets replace those instructions rather than adding extra ones, so the model never receives competing style directions.
|
||||
|
||||
@@ -43,10 +43,6 @@ Frigate supports presets for optimal hardware accelerated video decoding:
|
||||
|
||||
- [RKNN](#rockchip-platform): Frigate can utilize the media engine in RockChip SOCs to accelerate video decoding.
|
||||
|
||||
**Apple Silicon Mac** <CommunityBadge />
|
||||
|
||||
- [lighter](#apple-silicon-mac-lighter): Frigate can utilize the media engine in Apple Silicon Macs to accelerate video decoding, when running under the lighter container runtime.
|
||||
|
||||
**Other Hardware**
|
||||
|
||||
Depending on your system, these presets may not be compatible, and you may need to use manual hwaccel args to take advantage of your hardware. More information on hardware accelerated decoding for ffmpeg can be found here: https://trac.ffmpeg.org/wiki/HWAccelIntro
|
||||
@@ -537,35 +533,3 @@ output_args:
|
||||
Make sure that your SoC supports hardware acceleration for your input stream and your input stream is h264 encoding. For example, if your camera streams with h264 encoding, your SoC must be able to de- and encode with it. If you are unsure whether your SoC meets the requirements, take a look at the datasheet.
|
||||
|
||||
:::
|
||||
|
||||
## Apple Silicon Mac (lighter)
|
||||
|
||||
[lighter](https://github.com/fieldwork-ai/lighter) is an open-source container runtime for macOS. It gives a container the Mac's media engine as a standard V4L2 decoder, backed by VideoToolbox, so Frigate decodes H.264 and H.265 streams in hardware with the ffmpeg it already ships. It works on M1 and newer Macs with lighter 0.9.2 or newer.
|
||||
|
||||
Give the container the video device. With Docker Compose:
|
||||
|
||||
```yaml {4-5}
|
||||
services:
|
||||
frigate:
|
||||
...
|
||||
devices:
|
||||
- lighter.sh/video=all
|
||||
```
|
||||
|
||||
Or with `docker run`, add `--device lighter.sh/video=all`.
|
||||
|
||||
Then set the preset for the codec your cameras stream. The decoder is specific to the codec, so if your cameras mix H.264 and H.265, set the preset for the most common codec globally and override it on the other cameras:
|
||||
|
||||
```yaml
|
||||
ffmpeg:
|
||||
hwaccel_args: preset-apple-silicon-h264
|
||||
|
||||
cameras:
|
||||
garage: # an H.265 camera
|
||||
ffmpeg:
|
||||
hwaccel_args: preset-apple-silicon-h265
|
||||
```
|
||||
|
||||
The presets decode on the media engine and encode the Birdseye restream and timelapses there too. Scaling to the detect resolution runs on the CPU, as ffmpeg's V4L2 decoders cannot scale.
|
||||
|
||||
lighter can also run object detection on the Mac's Neural Engine; see [Apple Neural Engine (lighter)](object_detectors.md#apple-neural-engine-lighter).
|
||||
|
||||
@@ -378,10 +378,10 @@ Navigate to <NavPath path="Settings > Camera configuration > Object detection" /
|
||||
|
||||
Navigate to <NavPath path="Settings > Camera configuration > Objects" />.
|
||||
|
||||
| Field | Description |
|
||||
| --------------------------------------------------------- | ------------------- |
|
||||
| **Objects to track** | Add `license_plate` |
|
||||
| **Object filters > License Plate > Confidence threshold** | Set to `0.7` |
|
||||
| Field | Description |
|
||||
| ---------------------------------------------- | ------------------- |
|
||||
| **Objects to track** | Add `license_plate` |
|
||||
| **Object filters > License Plate > Threshold** | Set to `0.7` |
|
||||
|
||||
Navigate to <NavPath path="Settings > Camera configuration > Motion detection" />.
|
||||
|
||||
|
||||
@@ -28,24 +28,6 @@ WebRTC may use an external STUN server for NAT traversal. MSE and HLS streaming
|
||||
|
||||
:::
|
||||
|
||||
### Selecting a streaming technology
|
||||
|
||||
Frigate [defaults to MSE](#why-does-frigate-prefer-mse-over-webrtc-for-live-view) for restreamed cameras by design. To use WebRTC, select it explicitly from a camera's single-camera Live view settings (the settings menu in the camera's Live view header on desktop, or the settings drawer on mobile). Three related controls work together:
|
||||
|
||||
- **Stream**: _what_ to play. This lists the [streams you've configured](#setting-streams-for-live-ui) (for example `Main Stream` and `Sub Stream`).
|
||||
- **Force low-bandwidth mode**: a switch that always plays Frigate's built-in low-bandwidth feed (the stream assigned the `detect` role, using JSMpeg) instead of the selected stream. It works anywhere without go2rtc and is useful on slow or metered connections. While it is enabled, the stream and streaming technology selectors are disabled; your stream and technology choices are restored when you turn it off.
|
||||
- **Streaming Technology**: _how_ to play the selected stream, listing **MSE** and **WebRTC**. It is only shown for a restreamed stream.
|
||||
|
||||
- The choices are saved **per device, per camera** in your browser's local storage.
|
||||
- **WebRTC is only selectable when it can actually work for that stream.** When it can't, the option is shown disabled with the reason inline, and a more detailed reason (the failing codecs, or why the connectivity check failed) is logged to your browser's console. Common reasons:
|
||||
- **Not configured**: no `candidates` or `ice_servers` are set under `go2rtc.webrtc` (see [WebRTC extra configuration](#webrtc-extra-configuration)).
|
||||
- **Could not connect**: e.g. port `8555` isn't reachable, or a STUN/TURN server is misconfigured. Frigate runs a one-time WebRTC connectivity check when the Live view opens; the option may briefly show as "checking" while it runs.
|
||||
- **Unsupported video codec**: the stream's video codec can't be played over WebRTC in your browser, most commonly H.265/HEVC in Firefox or Edge.
|
||||
- **Unsupported audio codec**: WebRTC needs opus or G.711 audio, so a stream whose playback audio is only AAC (without an added opus/G.711 track) can't carry audio over WebRTC. See [Audio Support](#audio-support) for how to add one.
|
||||
- **Unsupported browser**: the browser doesn't support WebRTC.
|
||||
|
||||
When WebRTC isn't available, Frigate automatically uses MSE (or falls back to JSMpeg), so live view keeps working regardless of the selection.
|
||||
|
||||
### Camera Settings Recommendations
|
||||
|
||||
If you are using go2rtc, you should adjust the following settings in your camera's firmware for the best experience with Live view:
|
||||
@@ -92,7 +74,7 @@ go2rtc:
|
||||
|
||||
### Setting Streams For Live UI
|
||||
|
||||
You can configure Frigate to allow manual selection of the stream you want to view in the Live UI. For example, you may want to view your camera's substream on mobile devices, but the full resolution stream on desktop devices. Setting the streams list will populate a dropdown in the UI's Live view that allows you to choose between the streams. This stream setting is _per device_ and is saved in your browser's local storage. When a camera has more than one stream, the dropdown also offers **Auto**, which is used until you pick a specific stream. Auto starts on the first stream, steps down the list when your connection can't keep up, and steps back up when it recovers. To retry the top stream right away, select **Try highest quality** under the stream picker. List streams from highest to lowest quality, and avoid names that are plain numbers (such as `720`), which the browser sorts ahead of the others. In the UI, drag streams to reorder them, or use **Auto order** to sort them by measured bitrate.
|
||||
You can configure Frigate to allow manual selection of the stream you want to view in the Live UI. For example, you may want to view your camera's substream on mobile devices, but the full resolution stream on desktop devices. Setting the streams list will populate a dropdown in the UI's Live view that allows you to choose between the streams. This stream setting is _per device_ and is saved in your browser's local storage.
|
||||
|
||||
Additionally, when creating and editing camera groups in the UI, you can choose the stream you want to use for your camera group's Live dashboard.
|
||||
|
||||
@@ -158,26 +140,6 @@ cameras:
|
||||
</TabItem>
|
||||
</ConfigTabs>
|
||||
|
||||
### Transcoded streams
|
||||
|
||||
When a camera has no suitable sub stream, Frigate can add lower-quality streams that go2rtc transcodes to H.264 while someone is watching. They appear in the stream list like any other stream, so Auto mode can step down to them. Enable them under <NavPath path="Settings > Camera configuration > Live playback" />, or in YAML:
|
||||
|
||||
```yaml
|
||||
cameras:
|
||||
test_cam:
|
||||
live:
|
||||
transcode:
|
||||
enabled: true
|
||||
source: test_cam # optional, defaults to the first live stream
|
||||
qualities:
|
||||
- height: 720
|
||||
bitrate: 1200 # kbps
|
||||
- height: 480
|
||||
bitrate: 500
|
||||
```
|
||||
|
||||
Each quality becomes a go2rtc stream named `<camera>_transcode_<height>p`. go2rtc picks a hardware encoder automatically and falls back to the CPU, which costs CPU for each transcode while it is being watched. Check go2rtc's `api/ffmpeg/hardware` page to see which encoder it found. Using a sub stream as the `source` lowers the cost.
|
||||
|
||||
### WebRTC extra configuration:
|
||||
|
||||
WebRTC works by creating a TCP or UDP connection on port `8555`. However, it requires additional configuration:
|
||||
@@ -195,17 +157,6 @@ WebRTC works by creating a TCP or UDP connection on port `8555`. However, it req
|
||||
- stun:8555
|
||||
```
|
||||
|
||||
- The web UI uses the STUN and TURN servers in `ice_servers` and falls back to Google's public STUN server when none are set:
|
||||
|
||||
```yaml title="config.yml"
|
||||
go2rtc:
|
||||
webrtc:
|
||||
ice_servers:
|
||||
- urls: [turn:turn.example.com:3478]
|
||||
username: frigate
|
||||
credential: password
|
||||
```
|
||||
|
||||
- For access through Tailscale, the Frigate system's Tailscale IP must be added as a WebRTC candidate. Tailscale IPs all start with `100.`, and are reserved within the `100.64.0.0/10` CIDR block.
|
||||
|
||||
- Note that some browsers may not support H.265 (HEVC). You can check your browser's current version for H.265 compatibility [here](https://github.com/AlexxIT/go2rtc?tab=readme-ov-file#codecs-madness).
|
||||
@@ -255,8 +206,6 @@ For devices that support two way talk, Frigate can be configured to use the feat
|
||||
- Ensure you access Frigate via https (may require [opening port 8971](/frigate/installation/#ports)).
|
||||
- For the Home Assistant Frigate card, [follow the docs](http://card.camera/#/usage/2-way-audio) for the correct source.
|
||||
|
||||
The two-way talk control in the single-camera Live view is only enabled when WebRTC is available; if WebRTC isn't configured or can't connect, the control is shown disabled.
|
||||
|
||||
To use the Reolink Doorbell with two way talk, you should use the [recommended Reolink configuration](/configuration/camera_specific#reolink-cameras)
|
||||
|
||||
As a starting point to check compatibility for your camera, view the list of cameras supported for two-way talk on the [go2rtc repository](https://github.com/AlexxIT/go2rtc?tab=readme-ov-file#two-way-audio). For cameras in the category `ONVIF Profile T`, you can use the [ONVIF Conformant Products Database](https://www.onvif.org/conformant-products/)'s FeatureList to check for the presence of `AudioOutput`. A camera that supports `ONVIF Profile T` _usually_ supports this, but due to inconsistent support, a camera that explicitly lists this feature may still not work. If no entry for your camera exists on the database, it is recommended not to buy it or to consult with the manufacturer's support on the feature availability.
|
||||
@@ -383,13 +332,6 @@ When your browser runs into problems playing back your camera streams, it will l
|
||||
- `Safari reported InvalidStateError.`
|
||||
- `Safari reported decoding errors.`
|
||||
|
||||
- **mse-codec**
|
||||
- What it means: go2rtc has no codec for this stream that the browser can play.
|
||||
- What to try: Pick a stream with a codec the browser supports (H.264 is the most compatible), or use a browser that supports the stream's codec. In Auto, Frigate skips this stream for the rest of the session.
|
||||
|
||||
- Possible console messages from the player code:
|
||||
- `mse: streams: codecs not matched: ...`
|
||||
|
||||
- **stalled**
|
||||
- What it means: Playback has stalled because the player has fallen too far behind live (extended buffering or no data arriving).
|
||||
- What to try: This is usually indicative of the browser struggling to decode too many high-resolution streams at once. Try selecting a lower-bandwidth stream (substream), reduce the number of live streams open, improve the network connection, or lower the camera resolution. Also check your camera's keyframe (I-frame) interval: shorter intervals make playback start and recover faster. You can also try increasing the timeout value in <NavPath path="Settings > UI" /> .
|
||||
|
||||
@@ -24,18 +24,15 @@ 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**
|
||||
|
||||
- [ROCm](#amdrocm-gpu-detector): ROCm can run on AMD Discrete GPUs to provide efficient object detection.
|
||||
- [ONNX](#onnx): ROCm will automatically be detected and used as a detector in the `-rocm` Frigate image when a supported ONNX model is configured.
|
||||
- <CommunityBadge /> [XDNA2](#amd-xdna2): AMD Ryzen AI / XDNA2 NPUs can run object detection through the community-maintained `frigate-xdna` ZMQ sidecar.
|
||||
|
||||
**Apple Silicon**
|
||||
|
||||
- [Apple Silicon](#apple-silicon-detector): Apple Silicon can run on M1 and newer Apple Silicon devices.
|
||||
- <CommunityBadge /> [ONNX](#apple-neural-engine-lighter): the ONNX detector runs on the Neural Engine of M1 and newer Macs when Frigate runs under the lighter container runtime.
|
||||
|
||||
**Intel**
|
||||
|
||||
@@ -104,36 +101,32 @@ Coral EdgeTPU and MemryX accelerators can only be opened by one process, so thos
|
||||
|
||||
### Running more than one model
|
||||
|
||||
Cameras can be split across models by scene, which is useful when some cameras benefit from a differently trained model, such as thermal cameras. Each model declares the `scene` it is for, and each camera picks one with `detect -> scene`:
|
||||
Cameras can be split across models by scene, which is useful when indoor and outdoor cameras benefit from differently trained models. Each model declares the `scene` it is for, and each camera picks one with `detect -> scene`:
|
||||
|
||||
```yaml
|
||||
models:
|
||||
- scene: default
|
||||
path: plus://your-model
|
||||
- scene: outdoor
|
||||
path: plus://your-outdoor-model
|
||||
devices:
|
||||
- edgetpu:pci:0
|
||||
- scene: thermal
|
||||
path: /config/model_cache/thermal.onnx
|
||||
- scene: indoor
|
||||
path: /config/model_cache/indoor.onnx
|
||||
model_type: yolo-generic
|
||||
devices:
|
||||
- openvino:GPU
|
||||
|
||||
cameras:
|
||||
driveway:
|
||||
...
|
||||
backyard_thermal:
|
||||
detect:
|
||||
scene: thermal
|
||||
scene: outdoor
|
||||
...
|
||||
hallway:
|
||||
detect:
|
||||
scene: indoor
|
||||
...
|
||||
```
|
||||
|
||||
A scene is any name made up of letters, numbers, `_`, and `-`. The model with a scene of `default` is used by every camera that does not set one (or sets a scene that no model is configured for), and `default` is used when a model does not declare a scene. Changing a camera's scene requires a restart.
|
||||
|
||||
:::warning
|
||||
|
||||
Scenes are for running **different** models. Do not configure the same model under several scenes to dedicate a detector to specific cameras: every detector of a model already serves every camera using it, and splitting them only leaves some detectors idle while others fall behind. Frigate detects models that use the same model file, even under a different path or file name, combines them into one model with all of their hardware, and logs a warning.
|
||||
|
||||
:::
|
||||
Available scenes are `all`, `indoor`, `outdoor`, `indoor_thermal`, and `outdoor_thermal`. A model with a scene of `all` is used by every camera that does not set one, and `all` is the default when a model does not declare a scene. Changing a camera's scene requires a restart.
|
||||
|
||||
### Choosing a model size
|
||||
|
||||
@@ -490,7 +483,7 @@ See [ONNX supported models](#onnx) for supported models, there are some caveats:
|
||||
|
||||
## ONNX
|
||||
|
||||
ONNX is an open format for building machine learning models, Frigate supports running ONNX models on CPU, OpenVINO, ROCm, TensorRT, and a Mac's Neural Engine. On startup Frigate will automatically try to use a GPU if one is available.
|
||||
ONNX is an open format for building machine learning models, Frigate supports running ONNX models on CPU, OpenVINO, ROCm, and TensorRT. On startup Frigate will automatically try to use a GPU if one is available.
|
||||
|
||||
:::info
|
||||
|
||||
@@ -506,9 +499,6 @@ If the correct build is used for your GPU then the GPU will be detected and used
|
||||
- Nvidia GPUs will automatically be detected and used with the ONNX detector in the `-tensorrt` Frigate image.
|
||||
- Jetson devices will automatically be detected and used with the ONNX detector in the `-tensorrt-jp6` Frigate image.
|
||||
|
||||
- **Apple Silicon Mac** <CommunityBadge />
|
||||
- The Neural Engine will automatically be detected and used with the ONNX detector when Frigate runs under lighter with its Neural Engine device. See [Apple Neural Engine (lighter)](#apple-neural-engine-lighter).
|
||||
|
||||
:::
|
||||
|
||||
:::tip
|
||||
@@ -524,22 +514,6 @@ models:
|
||||
|
||||
:::
|
||||
|
||||
### Apple Neural Engine (lighter) {#apple-neural-engine-lighter}
|
||||
|
||||
[lighter](https://github.com/fieldwork-ai/lighter) is an open-source container runtime for macOS. A container started with its `lighter.sh/ane` device gets an ONNX Runtime execution provider that runs models on the Mac's Neural Engine, and the ONNX detector uses it automatically, with the same models and configuration as on any other hardware. It works on M1 and newer Macs with lighter 0.9.2 or newer.
|
||||
|
||||
Give the Frigate container the Neural Engine device. With Docker Compose:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
frigate:
|
||||
image: ghcr.io/blakeblackshear/frigate:stable-standard-arm64
|
||||
devices:
|
||||
- lighter.sh/ane=all
|
||||
```
|
||||
|
||||
Or with `docker run`, add `--device lighter.sh/ane=all`. Frigate then reports the Neural Engine under **Settings > System > Detection models**, and the ONNX detector's model loads on it. lighter can also decode camera streams on the Mac's media engine; see [Video Decoding](hardware_acceleration_video.md#apple-silicon-mac-lighter).
|
||||
|
||||
### Configuration {#configuration-onnx}
|
||||
|
||||
<ModelConfigDropdown detectorTitle="ONNX" models={objectDetectorsModels.onnx.models} />
|
||||
@@ -568,28 +542,6 @@ When using CPU detectors, you can add one CPU detector per camera. Adding more d
|
||||
|
||||
# Community Supported Detectors
|
||||
|
||||
## AMD XDNA2
|
||||
|
||||
AMD Ryzen AI / XDNA2 NPUs can be used through the community-maintained
|
||||
[frigate-xdna](https://github.com/mitchins/frigate-xdna) detector sidecar.
|
||||
The sidecar runs separately from Frigate and connects using Frigate's ZMQ
|
||||
detector interface.
|
||||
|
||||
Currently qualified on **Ryzen AI Max 300 / Strix Halo**. Other XDNA2 devices
|
||||
are not yet qualified; XDNA1 is unsupported.
|
||||
|
||||
Follow the frigate-xdna setup instructions to prepare and start the sidecar
|
||||
before starting Frigate.
|
||||
|
||||
### Configuration {#configuration-xdna2}
|
||||
|
||||
Using the detector config below will connect Frigate to the sidecar:
|
||||
|
||||
<ModelConfigDropdown detectorTitle="AMD XDNA2" models={objectDetectorsModels.xdna2.models} />
|
||||
|
||||
The example assumes Frigate and the sidecar share a Docker network where the
|
||||
sidecar is named `xdna`.
|
||||
|
||||
## MemryX MX3
|
||||
|
||||
This detector is available for use with the MemryX MX3 accelerator M.2 module. Frigate supports the MX3 on compatible hardware platforms, providing efficient and high-performance object detection.
|
||||
@@ -660,63 +612,6 @@ 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.
|
||||
|
||||
@@ -45,10 +45,10 @@ Any detection below `min_score` will be immediately thrown out and never tracked
|
||||
|
||||
Navigate to <NavPath path="Settings > Global configuration > Objects" /> to set score filters globally.
|
||||
|
||||
| Field | Description |
|
||||
| -------------------------------------------------- | ---------------------------------------------------------------- |
|
||||
| **Object filters > Person > Minimum confidence** | Minimum score for a single detection to initiate tracking |
|
||||
| **Object filters > Person > Confidence threshold** | Minimum computed (median) score to be considered a true positive |
|
||||
| Field | Description |
|
||||
| --------------------------------------- | ---------------------------------------------------------------- |
|
||||
| **Object filters > Person > Min Score** | Minimum score for a single detection to initiate tracking |
|
||||
| **Object filters > Person > Threshold** | Minimum computed (median) score to be considered a true positive |
|
||||
|
||||
To override score filters for a specific camera, navigate to <NavPath path="Settings > Camera configuration > Objects" /> and select the camera.
|
||||
|
||||
@@ -103,12 +103,12 @@ Conceptually, a ratio of 1 is a square, 0.5 is a "tall skinny" box, and 2 is a "
|
||||
|
||||
Navigate to <NavPath path="Settings > Global configuration > Objects" /> to set shape filters globally.
|
||||
|
||||
| Field | Description |
|
||||
| -------------------------------------------------- | ------------------------------------------------------------------------ |
|
||||
| **Object filters > Person > Minimum object area** | Minimum bounding box area in pixels (or decimal for percentage of frame) |
|
||||
| **Object filters > Person > Maximum object area** | Maximum bounding box area in pixels (or decimal for percentage of frame) |
|
||||
| **Object filters > Person > Minimum aspect ratio** | Minimum width/height ratio of the bounding box |
|
||||
| **Object filters > Person > Maximum aspect ratio** | Maximum width/height ratio of the bounding box |
|
||||
| Field | Description |
|
||||
| --------------------------------------- | ------------------------------------------------------------------------ |
|
||||
| **Object filters > Person > Min Area** | Minimum bounding box area in pixels (or decimal for percentage of frame) |
|
||||
| **Object filters > Person > Max Area** | Maximum bounding box area in pixels (or decimal for percentage of frame) |
|
||||
| **Object filters > Person > Min Ratio** | Minimum width/height ratio of the bounding box |
|
||||
| **Object filters > Person > Max Ratio** | Maximum width/height ratio of the bounding box |
|
||||
|
||||
To override shape filters for a specific camera, navigate to <NavPath path="Settings > Camera configuration > Objects" /> and select the camera.
|
||||
|
||||
|
||||
@@ -70,14 +70,14 @@ Object filters help reduce false positives by constraining the size, shape, and
|
||||
|
||||
Navigate to <NavPath path="Settings > Global configuration > Objects" />.
|
||||
|
||||
| Field | Description |
|
||||
| -------------------------------------------------- | ------------------------------------------------------------------------ |
|
||||
| **Object filters > Person > Minimum object area** | Minimum bounding box area in pixels (or decimal for percentage of frame) |
|
||||
| **Object filters > Person > Maximum object area** | Maximum bounding box area in pixels (or decimal for percentage of frame) |
|
||||
| **Object filters > Person > Minimum aspect ratio** | Minimum width/height ratio of the bounding box |
|
||||
| **Object filters > Person > Maximum aspect ratio** | Maximum width/height ratio of the bounding box |
|
||||
| **Object filters > Person > Minimum confidence** | Minimum score for the object to initiate tracking |
|
||||
| **Object filters > Person > Confidence threshold** | Minimum computed score to be considered a true positive |
|
||||
| Field | Description |
|
||||
| --------------------------------------- | ------------------------------------------------------------------------ |
|
||||
| **Object filters > Person > Min Area** | Minimum bounding box area in pixels (or decimal for percentage of frame) |
|
||||
| **Object filters > Person > Max Area** | Maximum bounding box area in pixels (or decimal for percentage of frame) |
|
||||
| **Object filters > Person > Min Ratio** | Minimum width/height ratio of the bounding box |
|
||||
| **Object filters > Person > Max Ratio** | Maximum width/height ratio of the bounding box |
|
||||
| **Object filters > Person > Min Score** | Minimum score for the object to initiate tracking |
|
||||
| **Object filters > Person > Threshold** | Minimum computed score to be considered a true positive |
|
||||
|
||||
To override filters for a specific camera, navigate to <NavPath path="Settings > Camera configuration > Objects" />.
|
||||
|
||||
|
||||
@@ -191,12 +191,14 @@ cameras:
|
||||
detect:
|
||||
enabled: false
|
||||
record:
|
||||
enabled: true
|
||||
enabled: false
|
||||
profiles:
|
||||
away:
|
||||
enabled: true
|
||||
detect:
|
||||
enabled: true
|
||||
record:
|
||||
enabled: true
|
||||
home:
|
||||
enabled: false
|
||||
```
|
||||
@@ -249,12 +251,6 @@ Leaving the `objects` section empty (or omitting `track`) does not clear the lis
|
||||
|
||||
Fields that require a Frigate restart to take effect cannot be overridden by profiles, since profiles are applied at runtime without restarting. Those fields are hidden when editing a profile override and can only be changed on the base configuration.
|
||||
|
||||
### Why can't a profile enable recording when it's disabled in the base config?
|
||||
|
||||
Frigate only sets up a camera's recording stream at startup when recording is enabled in the base config, so enabling it later from a profile has no effect. The same applies to turning recording on from the UI or MQTT.
|
||||
|
||||
To keep recording off by default, leave `record.enabled: true` in the base config and create a profile that sets `record.enabled: false`. Activate that profile and it will be restored automatically when Frigate starts.
|
||||
|
||||
### Can I schedule profiles to be enabled or disabled at certain times?
|
||||
|
||||
Not within Frigate itself. Frigate is an NVR, not an automation platform, so it intentionally does not include a scheduler for activating profiles. Instead, activate profiles from an automation platform that already handles time- and event-based triggers well, such as [Home Assistant](https://www.home-assistant.io/) or [Node-RED](https://nodered.org/). These integrate with Frigate and give you far more robust and flexible scheduling than a built-in scheduler could.
|
||||
|
||||
@@ -245,8 +245,8 @@ Triggers are best configured through the Frigate UI.
|
||||
1. Navigate to <NavPath path="Settings > Enrichments > Triggers" /> and select a camera from the dropdown menu.
|
||||
2. Click **Add Trigger** to create a new trigger or use the pencil icon to edit an existing one.
|
||||
3. In the **Create Trigger** wizard:
|
||||
- Enter a **Name** for the trigger (e.g., "Red Car Alert"). Frigate derives the trigger's
|
||||
internal **ID** from this name, which can be revealed and edited with the show/hide toggle.
|
||||
- Enter a **Name** for the trigger (e.g., "Red Car Alert").
|
||||
- Enter a descriptive **Friendly Name** for the trigger (e.g., "Red car on the driveway camera").
|
||||
- Select the **Type** (`Thumbnail` or `Description`).
|
||||
- For `Thumbnail`, select an image to trigger this action when a similar thumbnail image is detected, based on the threshold.
|
||||
- For `Description`, enter text to trigger this action when a similar tracked object description is detected.
|
||||
|
||||
@@ -28,7 +28,7 @@ During testing, enable the Zones option for the [Debug view](/usage/live#the-sin
|
||||
1. Navigate to <NavPath path="Settings > Camera configuration > Masks / Zones" /> and select the desired camera.
|
||||
2. Under the **Zones** section, click the plus icon to add a new zone.
|
||||
3. Click on the camera's latest image to create the points for the zone boundary. Click the first point again to close the polygon.
|
||||
4. Configure zone options such as **Name**, **Objects**, **Loitering Time**, and **Inertia** in the zone editor.
|
||||
4. Configure zone options such as **Friendly name**, **Objects**, **Loitering time**, and **Inertia** in the zone editor.
|
||||
5. Press **Save** when finished.
|
||||
|
||||
</TabItem>
|
||||
@@ -200,7 +200,7 @@ When using loitering zones, a review item will behave in the following way:
|
||||
|
||||
1. Navigate to <NavPath path="Settings > Camera configuration > Masks / Zones" /> and select the desired camera.
|
||||
2. Edit or create the zone (e.g., `sidewalk`).
|
||||
- Set **Loitering Time** to the desired number of seconds (e.g., `4`)
|
||||
- Set **Loitering time** to the desired number of seconds (e.g., `4`)
|
||||
- Under **Objects**, add the relevant object types (e.g., `person`)
|
||||
|
||||
</TabItem>
|
||||
@@ -291,7 +291,7 @@ Accurate real-world distance measurements are required to estimate speeds. These
|
||||
|
||||
1. Navigate to <NavPath path="Settings > Camera configuration > Masks / Zones" /> and select the desired camera.
|
||||
2. Create or edit a zone with exactly 4 points aligned to the ground plane.
|
||||
3. In the zone editor, enable **Speed Estimation** and enter the real-world **Line A distance**, **Line B distance**, **Line C distance**, and **Line D distance** between each pair of consecutive points.
|
||||
3. In the zone editor, enter the real-world **Distances** between each pair of consecutive points.
|
||||
- For example, if the distance between the first and second points is 10 meters, between the second and third is 12 meters, etc.
|
||||
4. Distances are measured in meters (metric) or feet (imperial), depending on the **Unit system** setting.
|
||||
|
||||
@@ -358,7 +358,7 @@ Zones can be configured with a minimum speed requirement, meaning an object must
|
||||
|
||||
1. Navigate to <NavPath path="Settings > Camera configuration > Masks / Zones" /> and select the desired camera.
|
||||
2. Edit or create the zone with distances configured.
|
||||
- Set **Speed Threshold** to the desired minimum speed (e.g., `20`)
|
||||
- Set **Speed threshold** to the desired minimum speed (e.g., `20`)
|
||||
- The unit is kph or mph, depending on the **Unit system** setting
|
||||
|
||||
</TabItem>
|
||||
|
||||
@@ -54,7 +54,7 @@ An object filter mask drops any [bounding box](#bounding-box) whose bottom cente
|
||||
|
||||
## Min Score
|
||||
|
||||
The lowest score a detected object can have to be kept during tracking. Anything scoring below the minimum is assumed to be a [false positive](#false-positive) and discarded. Set with `min_score` in the config, shown as **Minimum confidence** in the settings UI.
|
||||
The lowest score a detected object can have to be kept during tracking. Anything scoring below the minimum is assumed to be a [false positive](#false-positive) and discarded.
|
||||
|
||||
## Model
|
||||
|
||||
@@ -86,7 +86,7 @@ A more specific identity assigned to a [tracked object](#tracked-object-event-in
|
||||
|
||||
## Threshold
|
||||
|
||||
The median score an object must reach to be considered a true positive. Set with `threshold` in the config, shown as **Confidence threshold** in the settings UI.
|
||||
The median score an object must reach to be considered a true positive.
|
||||
|
||||
## Top Score
|
||||
|
||||
|
||||
@@ -65,26 +65,14 @@ 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
|
||||
- [Supports limited model architectures](../../configuration/object_detectors#amdrocm-gpu-detector)
|
||||
- Runs best on discrete AMD GPUs
|
||||
- <CommunityBadge /> [XDNA2 (Ryzen AI)](#amd-xdna2): AMD XDNA2 NPU (sub-watt power AI/ML processor separate to the GPU) inside Strix and other "AI" branded AMD platforms
|
||||
- Has only been tested with YOLOv9, in theory other graphs may be compiled too.
|
||||
- Runs via ZMQ proxy which adds some latency, only recommended for local connection
|
||||
|
||||
**Apple Silicon**
|
||||
|
||||
- [ONNX via lighter](#apple-silicon): The ONNX detector runs on the Neural Engine of M1 and newer Macs when Frigate runs in the lighter container runtime
|
||||
- [Supports the same model architectures as the ONNX detector](../../configuration/object_detectors#apple-neural-engine-lighter)
|
||||
- Runs inside the Frigate container, with no separate detector process to set up
|
||||
- The recommended way to run Frigate on a Mac
|
||||
- [Apple Silicon](#apple-silicon): Apple Silicon is usable on all M1 and newer Apple Silicon devices to provide efficient and fast object detection
|
||||
- [Supports primarily ssdlite and mobilenet model architectures](../../configuration/object_detectors#apple-silicon-detector)
|
||||
- Runs well with any size models including large
|
||||
@@ -218,13 +206,7 @@ Inference is done with the `onnx` detector type. Speeds will vary greatly depend
|
||||
|
||||
### Apple Silicon
|
||||
|
||||
Frigate on a Mac is best run in the [lighter](https://github.com/fieldwork-ai/lighter) container runtime, where the [ONNX detector](../configuration/object_detectors.md#apple-neural-engine-lighter) runs on the Neural Engine of M1 and newer Macs from inside the Frigate container. There is no separate detector process to install or keep running, and the same container can decode video on the Mac's media engine.
|
||||
|
||||
| Name | YOLOv9 Inference Time | YOLO-NAS Inference Time | RF-DETR Inference Time |
|
||||
| ---- | -------------------------------------- | ----------------------- | ---------------------- |
|
||||
| M1 | t-320: 3.3 ms s-320: 7 ms s-640: 13 ms | 320: 6.6 ms | Nano-320: 38 ms |
|
||||
|
||||
Alternatively, with the [Apple Silicon](../configuration/object_detectors.md#apple-silicon-detector) detector Frigate can take advantage of the NPU in M1 and newer Apple Silicon.
|
||||
With the [Apple Silicon](../configuration/object_detectors.md#apple-silicon-detector) detector Frigate can take advantage of the NPU in M1 and newer Apple Silicon.
|
||||
|
||||
:::warning
|
||||
|
||||
@@ -275,32 +257,6 @@ 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).
|
||||
@@ -341,32 +297,6 @@ The inference time of a rk3588 with all 3 cores enabled is typically 25-30 ms fo
|
||||
| ---------------- | ----------------------------------- |
|
||||
| yolov9-tiny | ~ 4 ms |
|
||||
|
||||
### AMD Ryzen AI / XDNA2
|
||||
|
||||
Frigate supports AMD XDNA2 NPUs through the community-maintained
|
||||
frigate-xdna ZMQ sidecar. It works with stock Frigate and supports
|
||||
Frigate+ models or compatible local YOLO ONNX models. Models are compiled
|
||||
once on the target system and cached for subsequent use.
|
||||
|
||||
Currently qualified on **Ryzen AI Max 300 / Strix Halo**. Other XDNA2
|
||||
devices are not yet qualified; XDNA1 is unsupported.
|
||||
|
||||
Measured YOLOv9 detector latency on Strix Halo:
|
||||
|
||||
| Model | 320 | 640 |
|
||||
| ----- | ---: | ---: |
|
||||
| YOLOv9-T | ~7.4 ms | unsupported |
|
||||
| YOLOv9-S | ~9.0 ms | ~20.0 ms |
|
||||
| YOLOv9-M | ~13.1 ms | ~34.4 ms |
|
||||
| YOLOv9-C | ~14.1 ms | ~35.2 ms |
|
||||
| YOLOv9-E | ~69.4 ms | ~224.8 ms |
|
||||
|
||||
**YOLOv9-C at 320 is the recommended quality/performance balance.**
|
||||
C at 640 is also usable where the lower throughput is acceptable.
|
||||
|
||||
Setup, model preparation, and compatibility details are available
|
||||
[in the frigate-xdna documentation](https://github.com/mitchins/frigate-xdna).
|
||||
|
||||
## What does Frigate use the CPU for and what does it use a detector for? (ELI5 Version)
|
||||
|
||||
This is taken from a [user question on reddit](https://www.reddit.com/r/homeassistant/comments/q8mgau/comment/hgqbxh5/?utm_source=share&utm_medium=web2x&context=3). Modified slightly for clarity.
|
||||
|
||||
@@ -381,99 +381,6 @@ 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:
|
||||
|
||||
@@ -147,7 +147,7 @@ If an [MQTT broker](/integrations/mqtt) is configured, Frigate maintains a conne
|
||||
For [WebRTC live streaming](/configuration/live), Frigate uses STUN for NAT traversal:
|
||||
|
||||
- **go2rtc** defaults to a local STUN listener (`stun:8555`), no internet required.
|
||||
- **The web UI** uses the servers in `go2rtc.webrtc.ice_servers` for its WebRTC player and for the WebRTC connectivity check it runs when the Live view loads. If none are set, it uses Google's public STUN server (`stun:stun.l.google.com:19302`), which requires internet access from the browser. Set `ice_servers` to a STUN or TURN server on your network to avoid this.
|
||||
- **The web UI's WebRTC player** includes a fallback to Google's public STUN server (`stun:stun.l.google.com:19302`), which requires internet.
|
||||
|
||||
## Home Assistant Supervisor
|
||||
|
||||
|
||||
@@ -11,12 +11,6 @@ MQTT requires a network connection to your broker. This is typically local, but
|
||||
|
||||
:::
|
||||
|
||||
:::note
|
||||
|
||||
Wherever a topic below includes a camera, mask, or zone name, use its `ID` from the config, not its `friendly_name`. For example, a camera with `friendly_name: "Back Yard"` and ID `back_yard` publishes to `frigate/back_yard/...`, not `frigate/Back Yard/...`.
|
||||
|
||||
:::
|
||||
|
||||
## General Frigate Topics
|
||||
|
||||
### `frigate/available`
|
||||
@@ -218,7 +212,6 @@ An `update` with the same ID will be published when:
|
||||
- The severity changes from `detection` to `alert`
|
||||
- Additional objects are detected
|
||||
- An object is recognized via face, lpr, etc.
|
||||
- A [state classification](/configuration/custom_classification/state_classification#review-items) model changes state
|
||||
|
||||
When the review activity has ended a final `end` message is published.
|
||||
|
||||
@@ -242,8 +235,7 @@ When the review activity has ended a final `end` message is published.
|
||||
"objects": ["person", "car"],
|
||||
"sub_labels": [],
|
||||
"zones": [],
|
||||
"audio": [],
|
||||
"classification_state_changes": []
|
||||
"audio": []
|
||||
}
|
||||
},
|
||||
"after": {
|
||||
@@ -262,16 +254,7 @@ When the review activity has ended a final `end` message is published.
|
||||
"objects": ["person", "car"],
|
||||
"sub_labels": ["Bob"],
|
||||
"zones": ["front_yard"],
|
||||
"audio": [],
|
||||
"classification_state_changes": [
|
||||
// verified changes of state classification models on this camera
|
||||
{
|
||||
"model": "front_gate",
|
||||
"from": "closed",
|
||||
"to": "open",
|
||||
"timestamp": 1718987131.52
|
||||
}
|
||||
]
|
||||
"audio": []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,10 +27,6 @@ The [Advanced Camera Card](https://card.camera/#/README) is a Home Assistant das
|
||||
It supports automatically setting the sub labels in Frigate for person objects that are detected and recognized.
|
||||
This is a fork (with fixed errors and new features) of [original Double Take](https://github.com/jakowenko/double-take) project which, unfortunately, isn't being maintained by author.
|
||||
|
||||
## [frigate-abr](https://github.com/007hacky007/frigate-abr)
|
||||
|
||||
[frigate-abr](https://github.com/007hacky007/frigate-abr) is a drop-in Docker image of Frigate that adds adaptive bitrate (ABR) playback for recordings: a sidecar transcodes footage to lower quality tiers on demand, for reviewing over slow remote connections. Segments are transcoded when played and cached, so no additional stream is recorded. Frigate itself is not modified.
|
||||
|
||||
## [Frigate Notify](https://github.com/0x2142/frigate-notify)
|
||||
|
||||
[Frigate Notify](https://github.com/0x2142/frigate-notify) is a simple app designed to send notifications from Frigate to your favorite platforms. Intended to be used with standalone Frigate installations - Home Assistant not required, MQTT is optional but recommended.
|
||||
|
||||
@@ -21,13 +21,7 @@ Yes. Models and metadata are stored in the `model_cache` directory within the co
|
||||
|
||||
### Can I keep using my Frigate+ models even if I do not renew my subscription?
|
||||
|
||||
Yes. Subscriptions to Frigate+ provide access to the infrastructure used to train the models. Models you train during an active subscription remain licensed for your continued use even after your subscription ends — models already in your model cache will keep working indefinitely. An active subscription is required to train new models and download new versions.
|
||||
|
||||
### Can I use Frigate+ models commercially?
|
||||
|
||||
A standard subscription covers use on camera systems you own or operate, including for your business. A shop, restaurant, warehouse, or office running Frigate+ at its own locations (including multiple locations) is exactly the kind of use the subscription is for.
|
||||
What the standard subscription does not cover is using Frigate+ models to provide a product or service to others. If you're deploying models at your customers' sites, bundling them with hardware you sell, or running them as part of a hosted or managed service, even if your customers never receive the model files themselves, you'll need a commercial license.
|
||||
Note that professional installers are fine under standard subscriptions when each customer holds their own Frigate+ subscription. The commercial license is for cases where your license powers your customers' sites.
|
||||
Yes. Subscriptions to Frigate+ provide access to the infrastructure used to train the models. Models trained with your subscription are yours to keep and use forever. However, do note that the terms and conditions prohibit you from sharing, reselling, or creating derivative products from the models.
|
||||
|
||||
### Why can't I submit images to Frigate+?
|
||||
|
||||
|
||||
@@ -63,20 +63,20 @@ Frigate+ models generally have much higher scores than the default model provide
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > Global configuration > Objects" />. Under **Object filters**, set **Minimum confidence** and **Confidence threshold** for each object type, then click **Save**.
|
||||
Navigate to <NavPath path="Settings > Global configuration > Objects" />. Under **Object filters**, set **Min Score** and **Threshold** for each object type, then click **Save**.
|
||||
|
||||
| Object | Minimum confidence | Confidence threshold |
|
||||
| ----------------- | ------------------ | -------------------- |
|
||||
| **dog** | .7 | .9 |
|
||||
| **cat** | .65 | .8 |
|
||||
| **face** | .7 | |
|
||||
| **package** | .65 | .9 |
|
||||
| **license_plate** | .6 | |
|
||||
| **amazon** | .75 | |
|
||||
| **ups** | .75 | |
|
||||
| **fedex** | .75 | |
|
||||
| **person** | .65 | .85 |
|
||||
| **car** | .65 | .85 |
|
||||
| Object | Min Score | Threshold |
|
||||
| ----------------- | --------- | --------- |
|
||||
| **dog** | .7 | .9 |
|
||||
| **cat** | .65 | .8 |
|
||||
| **face** | .7 | |
|
||||
| **package** | .65 | .9 |
|
||||
| **license_plate** | .6 | |
|
||||
| **amazon** | .75 | |
|
||||
| **ups** | .75 | |
|
||||
| **fedex** | .75 | |
|
||||
| **person** | .65 | .85 |
|
||||
| **car** | .65 | .85 |
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
|
||||
@@ -65,11 +65,11 @@ Some users may find that Frigate+ models result in more false positives initiall
|
||||
|
||||
Frigate+ models support a more relevant set of objects for security cameras. The labels for annotation in Frigate+ are configurable by editing the camera in the Cameras section of Frigate+. Currently, the following objects are supported:
|
||||
|
||||
- **People**: `person`, `face`, `baby`
|
||||
- **Vehicles**: `car`, `motorcycle`, `bicycle`, `boat`, `school_bus`, `garbage truck`, `license_plate`
|
||||
- **People**: `person`, `face`
|
||||
- **Vehicles**: `car`, `motorcycle`, `bicycle`, `boat`, `school_bus`, `license_plate`
|
||||
- **Delivery Logos**: `amazon`, `usps`, `ups`, `fedex`, `dhl`, `an_post`, `purolator`, `postnl`, `nzpost`, `postnord`, `gls`, `dpd`, `canada_post`, `royal_mail`
|
||||
- **Animals**: `dog`, `cat`, `deer`, `horse`, `bird`, `raccoon`, `fox`, `bear`, `cow`, `squirrel`, `goat`, `rabbit`, `skunk`, `kangaroo`, `possum`, `rodent`
|
||||
- **Other**: `package`, `waste_bin`, `bbq_grill`, `robot_lawnmower`, `umbrella`, `baby_stroller`
|
||||
- **Animals**: `dog`, `cat`, `deer`, `horse`, `bird`, `raccoon`, `fox`, `bear`, `cow`, `squirrel`, `goat`, `rabbit`, `skunk`, `kangaroo`
|
||||
- **Other**: `package`, `waste_bin`, `bbq_grill`, `robot_lawnmower`, `umbrella`
|
||||
|
||||
Other object types available in the default Frigate model are not available. Additional object types will be added in future releases.
|
||||
|
||||
@@ -77,12 +77,9 @@ Other object types available in the default Frigate model are not available. Add
|
||||
|
||||
Candidate labels are also available for annotation. These labels don't have enough data to be included in the model yet, but using them will help add support sooner. You can enable these labels by editing the camera settings.
|
||||
|
||||
Where possible, these labels are mapped to existing labels during training. For example, any `duck` labels are mapped to `bird` until support for new labels is added.
|
||||
Where possible, these labels are mapped to existing labels during training. For example, any `baby` labels are mapped to `person` until support for new labels is added.
|
||||
|
||||
- **Vehicles**: `tractor`, `golf_cart`, `bus`, `airplane`, `helicopter`, `rickshaw`, `scooter`
|
||||
- **Delivery Logos**: `bpost`, `auspost`, `aramex`, `transoflex`, `parcelforce`, `hermes`, `cargus`, `fan_courier`, `sameday`, `la_poste`
|
||||
- **Animals**: `badger`, `chicken`, `duck`, `turkey`, `groundhog`, `boar`, `hedgehog`, `wombat`, `bobcat`, `mustelid`, `mountain_lion`, `crocodile`, `monkey`, `coyote`, `porcupine`, `sheep`, `snake`, `lizard`, `heron`, `elk`, `moose`, `pig`, `donkey`, `civet`
|
||||
- **Other**: `sports_ball`, `drone`, `lawnmower`
|
||||
The candidate labels are: `baby`, `bpost`, `badger`, `possum`, `rodent`, `chicken`, `groundhog`, `boar`, `hedgehog`, `tractor`, `golf cart`, `garbage truck`, `bus`, `sports ball`, `la_poste`, `lawnmower`, `heron`, `rickshaw`, `wombat`, `auspost`, `aramex`, `bobcat`, `mustelid`, `transoflex`, `airplane`, `drone`, `mountain_lion`, `crocodile`, `turkey`, `baby_stroller`, `monkey`, `coyote`, `porcupine`, `parcelforce`, `sheep`, `snake`, `helicopter`, `lizard`, `duck`, `hermes`, `cargus`, `fan_courier`, `sameday`
|
||||
|
||||
Candidate labels are not available for automatic suggestions.
|
||||
|
||||
|
||||
@@ -184,6 +184,6 @@ Filters and masks only hide the incorrect result - they don't teach Frigate what
|
||||
|
||||
### Where do I see problems Frigate has detected?
|
||||
|
||||
Open System > Health. The Notices list keeps a record of problems Frigate has found. Acknowledge an entry to hide it until the problem happens again, or mute it to hide it for good. Hidden entries stay listed under Show hidden in the filter. Ongoing conditions, such as an offline camera or recordings deleted before their retention period, appear in the status bar for admins until they clear, and the status bar links to the Notices list while it has entries showing. On mobile, tap the warning icon in the bottom navigation bar to see them.
|
||||
Open System > Health. The Notices list keeps a record of problems Frigate has found, and you can dismiss any entry to acknowledge it. Ongoing conditions, such as an offline camera or recordings deleted before their retention period, appear in the status bar for admins until they clear, and the status bar links to the Notices list while it has undismissed entries. On mobile, tap the warning icon in the bottom navigation bar to see them.
|
||||
|
||||
The Hardware section below the notices shows whether the detection hardware, hardware acceleration, and enrichment devices in your config were found and are being used, so a GPU that silently fell back to the CPU shows up as a warning. Run stream checks to probe every camera's streams for the same problems the camera wizard reports.
|
||||
|
||||
@@ -397,11 +397,19 @@ dmesg | grep -i -E "gpu|drm|reset|hang"
|
||||
|
||||
Messages like `trying reset from guc_exec_queue_timedout_job` or similar GPU reset/hang messages indicate a driver or hardware issue. Ensure your kernel and GPU drivers (especially Intel) are up to date.
|
||||
|
||||
#### Step 6: Verify go2rtc stream configuration
|
||||
#### Step 6: Verify hardware acceleration configuration
|
||||
|
||||
An incorrect `hwaccel_args` preset can cause ffmpeg to fail silently or consume excessive CPU, starving the detector of resources.
|
||||
|
||||
- After upgrading Frigate, verify your preset matches your hardware (e.g., `preset-intel-qsv-h264` instead of the deprecated `preset-vaapi`).
|
||||
- For h265 cameras, use the corresponding h265 preset (e.g., `preset-intel-qsv-h265`).
|
||||
- Note that `hwaccel_args` are only relevant for the detect stream. Frigate does not decode the record stream.
|
||||
|
||||
#### Step 7: Verify go2rtc stream configuration
|
||||
|
||||
Ensure that the ffmpeg source names in your go2rtc configuration match the correct camera stream. A misconfigured stream name (e.g., copying a config from one camera to another without updating the stream reference) will cause the wrong stream to be used or the stream to fail entirely.
|
||||
|
||||
#### Step 7: Check system resources
|
||||
#### Step 8: Check system resources
|
||||
|
||||
If none of the above apply, the issue may be a general resource constraint. Monitor the following on your host:
|
||||
|
||||
|
||||
+22
-30
@@ -3,9 +3,6 @@ import * as path from "node:path";
|
||||
import type { Config, PluginConfig } from "@docusaurus/types";
|
||||
import type * as OpenApiPlugin from "docusaurus-plugin-openapi-docs";
|
||||
|
||||
// Bump when a new stable release ships
|
||||
const STABLE_VERSION = "0.18";
|
||||
|
||||
const config: Config = {
|
||||
title: "Frigate",
|
||||
tagline: "NVR With Realtime Object Detection for IP Cameras",
|
||||
@@ -26,17 +23,17 @@ const config: Config = {
|
||||
mermaid: true,
|
||||
},
|
||||
i18n: {
|
||||
defaultLocale: "en",
|
||||
locales: ["en"],
|
||||
defaultLocale: 'en',
|
||||
locales: ['en'],
|
||||
localeConfigs: {
|
||||
en: {
|
||||
label: "English",
|
||||
},
|
||||
label: 'English',
|
||||
}
|
||||
},
|
||||
},
|
||||
themeConfig: {
|
||||
announcementBar: {
|
||||
id: "frigate_plus",
|
||||
id: 'frigate_plus',
|
||||
content: `
|
||||
<span style="margin-right: 8px; display: inline-block; animation: pulse 2s infinite;">🚀</span>
|
||||
Get more relevant and accurate detections with Frigate+ models.
|
||||
@@ -48,8 +45,8 @@ const config: Config = {
|
||||
50% { transform: scale(1.1); }
|
||||
}
|
||||
</style>`,
|
||||
backgroundColor: "#005f73",
|
||||
textColor: "#e0fbfc",
|
||||
backgroundColor: '#005f73',
|
||||
textColor: '#e0fbfc',
|
||||
isCloseable: false,
|
||||
},
|
||||
docs: {
|
||||
@@ -86,15 +83,15 @@ const config: Config = {
|
||||
},
|
||||
},
|
||||
prism: {
|
||||
magicComments: [
|
||||
magicComments:[
|
||||
{
|
||||
className: "theme-code-block-highlighted-line",
|
||||
line: "highlight-next-line",
|
||||
block: { start: "highlight-start", end: "highlight-end" },
|
||||
className: 'theme-code-block-highlighted-line',
|
||||
line: 'highlight-next-line',
|
||||
block: {start: 'highlight-start', end: 'highlight-end'},
|
||||
},
|
||||
{
|
||||
className: "code-block-error-line",
|
||||
line: "highlight-error-line",
|
||||
className: 'code-block-error-line',
|
||||
line: 'highlight-error-line',
|
||||
},
|
||||
],
|
||||
additionalLanguages: ["bash", "json"],
|
||||
@@ -134,11 +131,6 @@ const config: Config = {
|
||||
srcDark: "img/branding/logo-dark.svg",
|
||||
},
|
||||
items: [
|
||||
{
|
||||
href: "https://github.com/blakeblackshear/frigate/releases",
|
||||
label: `${STABLE_VERSION}`,
|
||||
position: "left",
|
||||
},
|
||||
{
|
||||
to: "/",
|
||||
activeBasePath: "docs",
|
||||
@@ -156,19 +148,19 @@ const config: Config = {
|
||||
position: "right",
|
||||
},
|
||||
{
|
||||
type: "localeDropdown",
|
||||
position: "right",
|
||||
type: 'localeDropdown',
|
||||
position: 'right',
|
||||
dropdownItemsAfter: [
|
||||
{
|
||||
label: "简体中文(社区翻译)",
|
||||
href: "https://docs.frigate-cn.video",
|
||||
},
|
||||
],
|
||||
label: '简体中文(社区翻译)',
|
||||
href: 'https://docs.frigate-cn.video',
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
href: "https://github.com/blakeblackshear/frigate",
|
||||
label: "GitHub",
|
||||
position: "right",
|
||||
href: 'https://github.com/blakeblackshear/frigate',
|
||||
label: 'GitHub',
|
||||
position: 'right',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
Generated
+10
-10
@@ -9460,9 +9460,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.4.16",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.16.tgz",
|
||||
"integrity": "sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ==",
|
||||
"version": "3.4.13",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz",
|
||||
"integrity": "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
@@ -10125,9 +10125,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "3.1.8",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
|
||||
"integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
|
||||
"version": "3.1.7",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz",
|
||||
"integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -11375,15 +11375,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/image-size": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/image-size/-/image-size-2.0.4.tgz",
|
||||
"integrity": "sha512-QRUkFFsRV/6fuESxb9Vkq+a0LkSrgKXuc2NEqfikiXxxN/G3tjWt5EVUlMaImRBZRZK/jRBEbYvpPYZL8t08Zw==",
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/image-size/-/image-size-2.0.2.tgz",
|
||||
"integrity": "sha512-IRqXKlaXwgSMAMtpNzZa1ZAe8m+Sa1770Dhk8VkSsP9LS+iHD62Zd8FQKs8fbPiagBE7BzoFX23cxFnwshpV6w==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"image-size": "bin/image-size.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=16.x"
|
||||
}
|
||||
},
|
||||
"node_modules/immer": {
|
||||
|
||||
Vendored
+16
-140
@@ -412,39 +412,6 @@ paths:
|
||||
security:
|
||||
- frigateAdminAuth: []
|
||||
x-required-role: admin
|
||||
/go2rtc/streams/{stream_name}/bitrate:
|
||||
get:
|
||||
tags:
|
||||
- Camera
|
||||
summary: Go2Rtc Stream Bitrate
|
||||
description: |-
|
||||
**Access:** Admin role required.
|
||||
|
||||
Measure a go2rtc stream's bitrate over a few seconds.
|
||||
operationId:
|
||||
go2rtc_stream_bitrate_go2rtc_streams__stream_name__bitrate_get
|
||||
parameters:
|
||||
- name: stream_name
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
title: Stream Name
|
||||
responses:
|
||||
'200':
|
||||
description: Successful Response
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'422':
|
||||
description: Validation Error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/HTTPValidationError'
|
||||
security:
|
||||
- frigateAdminAuth: []
|
||||
x-required-role: admin
|
||||
/ffprobe:
|
||||
get:
|
||||
tags:
|
||||
@@ -4207,20 +4174,19 @@ paths:
|
||||
Get notices, most severe first.
|
||||
|
||||
Args:
|
||||
include_hidden: Also return acknowledged and muted notices, for the
|
||||
hidden list
|
||||
include_dismissed: Also return dismissed notices, for the history view
|
||||
|
||||
Returns:
|
||||
The notices
|
||||
operationId: get_notices_notices_get
|
||||
parameters:
|
||||
- name: include_hidden
|
||||
- name: include_dismissed
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: boolean
|
||||
default: false
|
||||
title: Include Hidden
|
||||
title: Include Dismissed
|
||||
responses:
|
||||
'200':
|
||||
description: Successful Response
|
||||
@@ -4255,16 +4221,16 @@ paths:
|
||||
security:
|
||||
- frigateAdminAuth: []
|
||||
x-required-role: admin
|
||||
/notices/muted_checks:
|
||||
/notices/dismissed_checks:
|
||||
get:
|
||||
tags:
|
||||
- Notices
|
||||
summary: Get Muted Checks
|
||||
summary: Get Dismissed Checks
|
||||
description: |-
|
||||
**Access:** Admin role required.
|
||||
|
||||
Get the muted config and stream check rows, newest first.
|
||||
operationId: get_muted_checks_notices_muted_checks_get
|
||||
Get the dismissed config and stream check rows, newest first.
|
||||
operationId: get_dismissed_checks_notices_dismissed_checks_get
|
||||
responses:
|
||||
'200':
|
||||
description: Successful Response
|
||||
@@ -4274,16 +4240,16 @@ paths:
|
||||
security:
|
||||
- frigateAdminAuth: []
|
||||
x-required-role: admin
|
||||
/notices/hidden:
|
||||
/notices/dismissed:
|
||||
delete:
|
||||
tags:
|
||||
- Notices
|
||||
summary: Unhide All Notices
|
||||
summary: Purge Dismissed
|
||||
description: |-
|
||||
**Access:** Admin role required.
|
||||
|
||||
Show every acknowledged and muted notice and check row again.
|
||||
operationId: unhide_all_notices_notices_hidden_delete
|
||||
Delete every dismissed notice and check row so each can show again.
|
||||
operationId: purge_dismissed_notices_dismissed_delete
|
||||
responses:
|
||||
'200':
|
||||
description: Successful Response
|
||||
@@ -4293,83 +4259,18 @@ paths:
|
||||
security:
|
||||
- frigateAdminAuth: []
|
||||
x-required-role: admin
|
||||
/notices/{notice_id}/acknowledge:
|
||||
/notices/{notice_id}/dismiss:
|
||||
post:
|
||||
tags:
|
||||
- Notices
|
||||
summary: Acknowledge Notice
|
||||
summary: Dismiss Notice
|
||||
description: |-
|
||||
**Access:** Admin role required.
|
||||
|
||||
Hide a notice until it happens again.
|
||||
Hide a notice or a config or stream check row.
|
||||
|
||||
Config and stream check rows and the update notice never repeat, so they
|
||||
can only be muted.
|
||||
operationId: acknowledge_notice_notices__notice_id__acknowledge_post
|
||||
parameters:
|
||||
- name: notice_id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
title: Notice Id
|
||||
responses:
|
||||
'200':
|
||||
description: Successful Response
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'422':
|
||||
description: Validation Error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/HTTPValidationError'
|
||||
security:
|
||||
- frigateAdminAuth: []
|
||||
x-required-role: admin
|
||||
/notices/{notice_id}/mute:
|
||||
post:
|
||||
tags:
|
||||
- Notices
|
||||
summary: Mute Notice
|
||||
description: |-
|
||||
**Access:** Admin role required.
|
||||
|
||||
Hide a notice or a config or stream check row for good.
|
||||
operationId: mute_notice_notices__notice_id__mute_post
|
||||
parameters:
|
||||
- name: notice_id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
title: Notice Id
|
||||
responses:
|
||||
'200':
|
||||
description: Successful Response
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'422':
|
||||
description: Validation Error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/HTTPValidationError'
|
||||
security:
|
||||
- frigateAdminAuth: []
|
||||
x-required-role: admin
|
||||
/notices/{notice_id}/hidden:
|
||||
delete:
|
||||
tags:
|
||||
- Notices
|
||||
summary: Unhide Notice
|
||||
description: |-
|
||||
**Access:** Admin role required.
|
||||
|
||||
Show an acknowledged or muted notice or check row again.
|
||||
operationId: unhide_notice_notices__notice_id__hidden_delete
|
||||
It stays hidden if the same problem happens again.
|
||||
operationId: dismiss_notice_notices__notice_id__dismiss_post
|
||||
parameters:
|
||||
- name: notice_id
|
||||
in: path
|
||||
@@ -4493,16 +4394,6 @@ paths:
|
||||
- type: 'null'
|
||||
default: 100
|
||||
title: Limit
|
||||
- name: offset
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
anyOf:
|
||||
- type: integer
|
||||
minimum: 0
|
||||
- type: 'null'
|
||||
default: 0
|
||||
title: Offset
|
||||
- name: after
|
||||
in: query
|
||||
required: false
|
||||
@@ -4808,16 +4699,6 @@ paths:
|
||||
- type: 'null'
|
||||
default: 50
|
||||
title: Limit
|
||||
- name: offset
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
anyOf:
|
||||
- type: integer
|
||||
minimum: 0
|
||||
- type: 'null'
|
||||
default: 0
|
||||
title: Offset
|
||||
- name: cameras
|
||||
in: query
|
||||
required: false
|
||||
@@ -7784,11 +7665,6 @@ components:
|
||||
type: boolean
|
||||
title: Skip Save
|
||||
default: false
|
||||
replace_paths:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
title: Replace Paths
|
||||
type: object
|
||||
title: AppConfigSetBody
|
||||
AppPostLoginBody:
|
||||
|
||||
@@ -99,10 +99,6 @@ def main() -> None:
|
||||
print("*** End Config Validation Errors ***")
|
||||
print("*************************************************************")
|
||||
|
||||
# force a non-zero exit code for config failures
|
||||
if args.validate_config:
|
||||
sys.exit(1)
|
||||
|
||||
# attempt to start Frigate in recovery mode
|
||||
try:
|
||||
config = FrigateConfig.load(install=True, safe_load=True)
|
||||
|
||||
+3
-57
@@ -63,7 +63,6 @@ from frigate.util.builtin import (
|
||||
flatten_config_data,
|
||||
load_labels,
|
||||
process_config_query_string,
|
||||
split_config_key_path,
|
||||
update_yaml_file_bulk,
|
||||
)
|
||||
from frigate.util.config import (
|
||||
@@ -71,10 +70,6 @@ from frigate.util.config import (
|
||||
find_config_file,
|
||||
redact_credential,
|
||||
)
|
||||
from frigate.util.live_streams import (
|
||||
generated_transcode_streams,
|
||||
sync_transcode_streams,
|
||||
)
|
||||
from frigate.util.object_names import get_categorized_object_names
|
||||
from frigate.util.schema import get_config_schema
|
||||
from frigate.util.services import (
|
||||
@@ -311,12 +306,9 @@ def config(request: Request):
|
||||
mode="json", warnings="none", exclude_none=True
|
||||
)
|
||||
|
||||
is_admin = request.headers.get("remote-role") == "admin"
|
||||
|
||||
# hide environment_vars and the notification email from non-admin users
|
||||
if not is_admin:
|
||||
# remove environment_vars for non-admin users
|
||||
if request.headers.get("remote-role") != "admin":
|
||||
config.pop("environment_vars", None)
|
||||
redact_credential(config["notifications"], "email")
|
||||
|
||||
# redact mqtt credentials
|
||||
redact_credential(config["mqtt"], "password")
|
||||
@@ -373,15 +365,7 @@ def config(request: Request):
|
||||
camera_name
|
||||
)
|
||||
if base_sections:
|
||||
# copy so redaction below can't alter the profile manager's cache
|
||||
camera_dict["base_config"] = copy.deepcopy(base_sections)
|
||||
|
||||
# cameras inherit the global notification email
|
||||
if not is_admin:
|
||||
redact_credential(camera_dict["notifications"], "email")
|
||||
redact_credential(
|
||||
camera_dict.get("base_config", {}).get("notifications", {}), "email"
|
||||
)
|
||||
camera_dict["base_config"] = base_sections
|
||||
|
||||
# remove go2rtc stream passwords
|
||||
go2rtc: dict[str, Any] = config_obj.go2rtc.model_dump(
|
||||
@@ -409,11 +393,6 @@ def config(request: Request):
|
||||
model_dict["non_logo_attributes"] = model.non_logo_attributes
|
||||
model_dict["labelmap"] = model.merged_labelmap
|
||||
|
||||
# report the configured reference rather than the resolved cache path,
|
||||
# so saving the config back doesn't lose the Frigate+ model
|
||||
if model.plus_id:
|
||||
model_dict["path"] = f"plus://{model.plus_id}"
|
||||
|
||||
if not config["plus"]["enabled"]:
|
||||
continue
|
||||
|
||||
@@ -450,8 +429,6 @@ def ffmpeg_presets():
|
||||
hwaccel_presets = [
|
||||
"preset-rpi-64-h264",
|
||||
"preset-rpi-64-h265",
|
||||
"preset-apple-silicon-h264",
|
||||
"preset-apple-silicon-h265",
|
||||
"preset-jetson-h264",
|
||||
"preset-jetson-h265",
|
||||
"preset-rkmpp",
|
||||
@@ -831,17 +808,6 @@ def _config_set_in_memory(request: Request, body: AppConfigSetBody) -> JSONRespo
|
||||
)
|
||||
|
||||
|
||||
def _config_path_exists(data: Any, key_path: str) -> bool:
|
||||
"""Return whether a dotted config path is present in parsed yaml."""
|
||||
for key in split_config_key_path(key_path):
|
||||
if not isinstance(data, dict) or key not in data:
|
||||
return False
|
||||
|
||||
data = data[key]
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@router.put("/config/set", dependencies=[Depends(require_role(["admin"]))])
|
||||
def config_set(request: Request, body: AppConfigSetBody):
|
||||
config_file = find_config_file()
|
||||
@@ -896,19 +862,6 @@ def config_set(request: Request, body: AppConfigSetBody):
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# delete replaced paths first so their maps are rewritten in
|
||||
# the order sent; update_yaml would otherwise keep old order
|
||||
if body.replace_paths:
|
||||
old_yaml = ruamel.yaml.YAML(typ="safe").load(old_raw_config) or {}
|
||||
updates = {
|
||||
**{
|
||||
path: ""
|
||||
for path in body.replace_paths
|
||||
if _config_path_exists(old_yaml, path)
|
||||
},
|
||||
**updates,
|
||||
}
|
||||
|
||||
# apply all updates in a single operation
|
||||
update_yaml_file_bulk(config_file, updates)
|
||||
|
||||
@@ -972,15 +925,9 @@ def config_set(request: Request, body: AppConfigSetBody):
|
||||
if request.app.dispatcher is not None:
|
||||
request.app.dispatcher.clear_runtime_state_for_yaml_keys(updates.keys())
|
||||
|
||||
go2rtc_synced = True
|
||||
|
||||
if body.requires_restart == 0 or body.update_topic:
|
||||
old_config: FrigateConfig = request.app.frigate_config
|
||||
swap_runtime_config(request.app, config)
|
||||
go2rtc_synced = sync_transcode_streams(
|
||||
generated_transcode_streams(old_config),
|
||||
generated_transcode_streams(config),
|
||||
)
|
||||
|
||||
if body.update_topic:
|
||||
if body.update_topic.startswith("config/cameras/"):
|
||||
@@ -1040,7 +987,6 @@ def config_set(request: Request, body: AppConfigSetBody):
|
||||
if body.requires_restart == 0
|
||||
else "Config successfully updated, restart to apply"
|
||||
),
|
||||
"go2rtc_synced": go2rtc_synced,
|
||||
}
|
||||
),
|
||||
status_code=200,
|
||||
|
||||
+24
-24
@@ -130,18 +130,23 @@ def require_admin_by_default():
|
||||
if path.startswith(EXEMPT_PREFIXES):
|
||||
return
|
||||
|
||||
# Camera routes enforce per-camera access via route-level dependencies
|
||||
# (e.g. require_camera_access). Match on the route template, not the raw
|
||||
# path, so a camera named like another namespace (e.g. "faces") can't
|
||||
# waive the admin check for that namespace's routes.
|
||||
route = request.scope.get("route")
|
||||
if (
|
||||
route is not None
|
||||
and route.path.startswith("/{camera_name}")
|
||||
and request.path_params.get("camera_name")
|
||||
in request.app.frigate_config.cameras
|
||||
):
|
||||
return
|
||||
# Dynamic camera path exemption:
|
||||
# Any path whose first segment matches a configured camera name should
|
||||
# bypass the global admin requirement. These endpoints enforce access
|
||||
# via route-level dependencies (e.g. require_camera_access) to ensure
|
||||
# per-camera authorization. This allows non-admin authenticated users
|
||||
# (e.g. viewer role) to access camera-specific resources without
|
||||
# needing admin privileges.
|
||||
try:
|
||||
if path.startswith("/"):
|
||||
first_segment = path.split("/", 2)[1]
|
||||
if (
|
||||
first_segment
|
||||
and first_segment in request.app.frigate_config.cameras
|
||||
):
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# For all other paths, require admin role
|
||||
# Internal port requests have admin role set automatically
|
||||
@@ -319,17 +324,11 @@ def get_remote_addr(request: Request):
|
||||
network = ipaddress.ip_network(proxy)
|
||||
except ValueError:
|
||||
logger.warning(f"Unable to parse trusted network: {proxy}")
|
||||
continue
|
||||
trusted_proxies.append(network)
|
||||
|
||||
# return the first remote address that is not trusted
|
||||
for addr in route:
|
||||
try:
|
||||
ip = ipaddress.ip_address(addr.strip())
|
||||
except ValueError:
|
||||
logger.debug("Invalid address in X-Forwarded-For header")
|
||||
return direct_addr or "127.0.0.1"
|
||||
|
||||
ip = ipaddress.ip_address(addr.strip())
|
||||
logger.debug(f"Checking {ip} (v{ip.version})")
|
||||
trusted = False
|
||||
for trusted_proxy in trusted_proxies:
|
||||
@@ -474,11 +473,12 @@ def create_encoded_jwt(user, role, expiration, secret):
|
||||
|
||||
def set_jwt_cookie(response: Response, cookie_name, encoded_jwt, max_age, secure):
|
||||
# TODO: ideally this would set secure as well, but that requires TLS
|
||||
# Starlette sets SameSite=Lax by default. The cookie is still sent to
|
||||
# same-site iframes (e.g. Home Assistant on the same host or domain), but
|
||||
# not to cross-site ones. CSRF is also mitigated by requiring a custom
|
||||
# X-CSRF-TOKEN header, which cross-origin pages cannot set without a CORS
|
||||
# preflight that Frigate never grants (see check_csrf in api/fastapi_app.py).
|
||||
# SameSite is intentionally left unset (browsers default to Lax). Setting
|
||||
# SameSite=Lax/Strict would stop the cookie from being sent in cross-origin
|
||||
# iframes, breaking embedded views such as the Home Assistant Frigate card.
|
||||
# CSRF is instead mitigated by requiring a custom X-CSRF-TOKEN header, which
|
||||
# cross-origin pages cannot set without a CORS preflight that Frigate never
|
||||
# grants (see check_csrf in api/fastapi_app.py).
|
||||
response.set_cookie(
|
||||
key=cookie_name,
|
||||
value=encoded_jwt,
|
||||
|
||||
@@ -39,11 +39,6 @@ from frigate.util.builtin import clean_camera_user_pass, get_record_segment_time
|
||||
from frigate.util.camera_cleanup import cleanup_camera_db, cleanup_camera_files
|
||||
from frigate.util.config import find_config_file
|
||||
from frigate.util.image import run_ffmpeg_snapshot
|
||||
from frigate.util.live_streams import (
|
||||
generated_transcode_streams,
|
||||
measure_stream_bitrate,
|
||||
sync_transcode_streams,
|
||||
)
|
||||
from frigate.util.services import (
|
||||
analyze_record_keyframes,
|
||||
ffprobe_stream,
|
||||
@@ -253,34 +248,6 @@ def go2rtc_delete_stream(stream_name: str):
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/go2rtc/streams/{stream_name}/bitrate",
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
)
|
||||
async def go2rtc_stream_bitrate(request: Request, stream_name: str):
|
||||
"""Measure a go2rtc stream's bitrate over a few seconds."""
|
||||
config: FrigateConfig = request.app.frigate_config
|
||||
known = set(config.go2rtc.model_dump().get("streams") or {}) | set(
|
||||
generated_transcode_streams(config)
|
||||
)
|
||||
|
||||
if stream_name not in known:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Unknown stream"},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
kbps = await asyncio.to_thread(measure_stream_bitrate, stream_name)
|
||||
|
||||
if kbps is None:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Stream sent no data"},
|
||||
status_code=502,
|
||||
)
|
||||
|
||||
return JSONResponse(content={"success": True, "kbps": round(kbps)})
|
||||
|
||||
|
||||
@router.get("/ffprobe", dependencies=[Depends(require_role(["admin"]))])
|
||||
def ffprobe(request: Request, paths: str = "", detailed: bool = False):
|
||||
path_param = paths
|
||||
@@ -1375,12 +1342,6 @@ async def delete_camera(
|
||||
except Exception:
|
||||
logger.debug("Failed to remove go2rtc stream for %s", camera_name)
|
||||
|
||||
await asyncio.to_thread(
|
||||
sync_transcode_streams,
|
||||
generated_transcode_streams(frigate_config),
|
||||
generated_transcode_streams(request.app.frigate_config),
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": True,
|
||||
|
||||
@@ -51,7 +51,6 @@ def swap_runtime_config(app: FastAPI, config: FrigateConfig) -> None:
|
||||
|
||||
if app.stats_emitter is not None:
|
||||
app.stats_emitter.config = config
|
||||
app.stats_emitter.hardware_stats.set_config(config)
|
||||
|
||||
if app.dispatcher is not None:
|
||||
app.dispatcher.config = config
|
||||
|
||||
@@ -14,7 +14,6 @@ class EventsQueryParams(BaseModel):
|
||||
zone: str | None = "all"
|
||||
zones: str | None = "all"
|
||||
limit: int | None = 100
|
||||
offset: int | None = Field(0, ge=0)
|
||||
after: float | None = None
|
||||
before: float | None = None
|
||||
time_range: str | None = DEFAULT_TIME_RANGE
|
||||
@@ -56,7 +55,6 @@ class EventsSearchQueryParams(BaseModel):
|
||||
deprecated=True,
|
||||
)
|
||||
limit: int | None = 50
|
||||
offset: int | None = Field(0, ge=0)
|
||||
cameras: str | None = "all"
|
||||
labels: str | None = "all"
|
||||
sub_labels: str | None = "all"
|
||||
|
||||
@@ -10,8 +10,6 @@ class AppConfigSetBody(BaseModel):
|
||||
update_topic: str | None = None
|
||||
config_data: dict[str, Any] | None = None
|
||||
skip_save: bool = False
|
||||
# paths rewritten whole, so a map saves in the order sent
|
||||
replace_paths: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class GenAIProbeBody(BaseModel):
|
||||
|
||||
+2
-11
@@ -129,7 +129,6 @@ def events(
|
||||
zones = zone
|
||||
|
||||
limit = params.limit
|
||||
offset = params.offset
|
||||
after = params.after
|
||||
before = params.before
|
||||
time_range = params.time_range
|
||||
@@ -362,15 +361,11 @@ def events(
|
||||
else:
|
||||
order_by = Event.start_time.desc()
|
||||
|
||||
# offset paging needs a stable order when scores or speeds tie
|
||||
tiebreaker = [Event.id] if sort and sort.startswith(("score", "speed")) else []
|
||||
|
||||
events = (
|
||||
Event.select(*selected_columns)
|
||||
.where(reduce(operator.and_, clauses))
|
||||
.order_by(order_by, *tiebreaker)
|
||||
.order_by(order_by)
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
.dicts()
|
||||
.iterator()
|
||||
)
|
||||
@@ -539,7 +534,6 @@ def events_search(
|
||||
search_type = params.search_type
|
||||
include_thumbnails = params.include_thumbnails
|
||||
limit = params.limit
|
||||
offset = params.offset
|
||||
sort = params.sort
|
||||
|
||||
# Filters
|
||||
@@ -846,9 +840,6 @@ def events_search(
|
||||
if search_results:
|
||||
events_query = events_query.where(Event.id << list(search_results.keys()))
|
||||
|
||||
# sorts below are stable, so this orders ties for offset paging
|
||||
events_query = events_query.order_by(Event.id)
|
||||
|
||||
# Fetch events and process them in a single pass
|
||||
processed_events = []
|
||||
for event in events_query.dicts():
|
||||
@@ -906,7 +897,7 @@ def events_search(
|
||||
processed_events.sort(key=lambda x: x["start_time"], reverse=True)
|
||||
|
||||
# Limit the number of events returned
|
||||
processed_events = processed_events[offset:][:limit]
|
||||
processed_events = processed_events[:limit]
|
||||
|
||||
return JSONResponse(content=processed_events)
|
||||
|
||||
|
||||
+68
-83
@@ -63,6 +63,7 @@ from frigate.util.recording_coverage import (
|
||||
null_audio_glitches,
|
||||
plan_clip,
|
||||
resolve_coverage,
|
||||
stream_has_audio,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -257,15 +258,15 @@ async def latest_frame(
|
||||
|
||||
frame = request.app.camera_error_image
|
||||
|
||||
height = int(params.height or str(frame.shape[0]))
|
||||
width = int(height * frame.shape[1] / frame.shape[0])
|
||||
|
||||
if frame is None:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Unable to get valid frame"},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
height = int(params.height or str(frame.shape[0]))
|
||||
width = int(height * frame.shape[1] / frame.shape[0])
|
||||
|
||||
if height < 1 or width < 1:
|
||||
return JSONResponse(
|
||||
content="Invalid height / width requested :: {} / {}".format(
|
||||
@@ -680,10 +681,15 @@ async def _vod_response(
|
||||
end_ts,
|
||||
force_discontinuity,
|
||||
)
|
||||
intervals = resolve_coverage(camera_name, start_ts, end_ts)
|
||||
|
||||
# rows contradicting their stream's audio composition are
|
||||
# truncated-shutdown glitches
|
||||
main_audio = stream_has_audio(intervals, main=True)
|
||||
sub_audio = stream_has_audio(intervals, main=False)
|
||||
|
||||
spans = build_spans(
|
||||
null_audio_glitches(resolve_coverage(camera_name, start_ts, end_ts)),
|
||||
null_audio_glitches(intervals, main_audio, sub_audio),
|
||||
stream_preference,
|
||||
)
|
||||
|
||||
@@ -880,8 +886,9 @@ async def vod_event(
|
||||
# If the recordings are not found and the event started more than 5 minutes ago, set has_clip to false
|
||||
if (
|
||||
event.start_time < datetime.now().timestamp() - 300
|
||||
and isinstance(vod_response, JSONResponse)
|
||||
and vod_response.status_code == 404
|
||||
and type(vod_response) is tuple
|
||||
and len(vod_response) == 2
|
||||
and vod_response[1] == 404
|
||||
):
|
||||
Event.update(has_clip=False).where(Event.id == event_id).execute()
|
||||
|
||||
@@ -949,80 +956,64 @@ async def event_snapshot(
|
||||
event_complete = False
|
||||
jpg_bytes = None
|
||||
frame_time = 0
|
||||
|
||||
try:
|
||||
event = Event.get(Event.id == event_id, Event.end_time != None)
|
||||
await require_camera_access(event.camera, request=request)
|
||||
except DoesNotExist:
|
||||
event = None
|
||||
|
||||
if event is not None:
|
||||
event_complete = True
|
||||
|
||||
await require_camera_access(event.camera, request=request)
|
||||
if not event.has_snapshot:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Snapshot not available"},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
snapshot_settings = _resolve_snapshot_settings(
|
||||
request.app.frigate_config.cameras[event.camera].snapshots, params
|
||||
)
|
||||
jpg_bytes, frame_time = get_event_snapshot_bytes(
|
||||
event,
|
||||
ext="jpg",
|
||||
timestamp=snapshot_settings["timestamp"],
|
||||
bounding_box=snapshot_settings["bounding_box"],
|
||||
crop=snapshot_settings["crop"],
|
||||
height=snapshot_settings["height"],
|
||||
quality=snapshot_settings["quality"],
|
||||
timestamp_style=request.app.frigate_config.cameras[
|
||||
event.camera
|
||||
].timestamp_style,
|
||||
colormap=request.app.frigate_config.model_for_camera(event.camera).colormap,
|
||||
)
|
||||
except DoesNotExist:
|
||||
# see if the object is currently being tracked
|
||||
try:
|
||||
snapshot_settings = _resolve_snapshot_settings(
|
||||
request.app.frigate_config.cameras[event.camera].snapshots, params
|
||||
)
|
||||
jpg_bytes, frame_time = get_event_snapshot_bytes(
|
||||
event,
|
||||
ext="jpg",
|
||||
timestamp=snapshot_settings["timestamp"],
|
||||
bounding_box=snapshot_settings["bounding_box"],
|
||||
crop=snapshot_settings["crop"],
|
||||
height=snapshot_settings["height"],
|
||||
quality=snapshot_settings["quality"],
|
||||
timestamp_style=request.app.frigate_config.cameras[
|
||||
event.camera
|
||||
].timestamp_style,
|
||||
colormap=request.app.frigate_config.model_for_camera(
|
||||
event.camera
|
||||
).colormap,
|
||||
camera_states: list[CameraState] = (
|
||||
request.app.detected_frames_processor.get_camera_states()
|
||||
)
|
||||
for camera_state in camera_states:
|
||||
if event_id in camera_state.tracked_objects:
|
||||
tracked_obj = camera_state.tracked_objects.get(event_id)
|
||||
if tracked_obj is not None:
|
||||
snapshot_settings = _resolve_snapshot_settings(
|
||||
camera_state.camera_config.snapshots, params
|
||||
)
|
||||
jpg_bytes, frame_time = tracked_obj.get_img_bytes(
|
||||
ext="jpg",
|
||||
timestamp=snapshot_settings["timestamp"],
|
||||
bounding_box=snapshot_settings["bounding_box"],
|
||||
crop=snapshot_settings["crop"],
|
||||
height=snapshot_settings["height"],
|
||||
quality=snapshot_settings["quality"],
|
||||
)
|
||||
await require_camera_access(camera_state.name, request=request)
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Unknown error occurred"},
|
||||
content={"success": False, "message": "Ongoing event not found"},
|
||||
status_code=404,
|
||||
)
|
||||
else:
|
||||
# see if the object is currently being tracked
|
||||
camera_states: list[CameraState] = (
|
||||
request.app.detected_frames_processor.get_camera_states()
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Unknown error occurred"},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
for camera_state in camera_states:
|
||||
tracked_obj = camera_state.tracked_objects.get(event_id)
|
||||
|
||||
if tracked_obj is None:
|
||||
continue
|
||||
|
||||
await require_camera_access(camera_state.name, request=request)
|
||||
|
||||
try:
|
||||
snapshot_settings = _resolve_snapshot_settings(
|
||||
camera_state.camera_config.snapshots, params
|
||||
)
|
||||
jpg_bytes, frame_time = tracked_obj.get_img_bytes(
|
||||
ext="jpg",
|
||||
timestamp=snapshot_settings["timestamp"],
|
||||
bounding_box=snapshot_settings["bounding_box"],
|
||||
crop=snapshot_settings["crop"],
|
||||
height=snapshot_settings["height"],
|
||||
quality=snapshot_settings["quality"],
|
||||
)
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Ongoing event not found"},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
break
|
||||
|
||||
if jpg_bytes is None:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Live frame not available"},
|
||||
@@ -1071,25 +1062,19 @@ async def event_thumbnail(
|
||||
|
||||
if not thumbnail_bytes:
|
||||
# see if the object is currently being tracked
|
||||
camera_states = request.app.detected_frames_processor.get_camera_states()
|
||||
|
||||
for camera_state in camera_states:
|
||||
tracked_obj = camera_state.tracked_objects.get(event_id)
|
||||
|
||||
if tracked_obj is None:
|
||||
continue
|
||||
|
||||
await require_camera_access(camera_state.name, request=request)
|
||||
|
||||
try:
|
||||
thumbnail_bytes = tracked_obj.get_thumbnail(extension.value)
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Event not found"},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
break
|
||||
try:
|
||||
camera_states = request.app.detected_frames_processor.get_camera_states()
|
||||
for camera_state in camera_states:
|
||||
if event_id in camera_state.tracked_objects:
|
||||
tracked_obj = camera_state.tracked_objects.get(event_id)
|
||||
if tracked_obj is not None:
|
||||
await require_camera_access(camera_state.name, request=request)
|
||||
thumbnail_bytes = tracked_obj.get_thumbnail(extension.value)
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Event not found"},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
if not thumbnail_bytes:
|
||||
return JSONResponse(
|
||||
|
||||
+22
-50
@@ -14,18 +14,17 @@ router = APIRouter(tags=[Tags.notices])
|
||||
|
||||
|
||||
@router.get("/notices", dependencies=[Depends(require_role(["admin"]))])
|
||||
def get_notices(request: Request, include_hidden: bool = False) -> JSONResponse:
|
||||
def get_notices(request: Request, include_dismissed: bool = False) -> JSONResponse:
|
||||
"""Get notices, most severe first.
|
||||
|
||||
Args:
|
||||
include_hidden: Also return acknowledged and muted notices, for the
|
||||
hidden list
|
||||
include_dismissed: Also return dismissed notices, for the history view
|
||||
|
||||
Returns:
|
||||
The notices
|
||||
"""
|
||||
return JSONResponse(
|
||||
content=request.app.notice_registry.active(include_hidden=include_hidden)
|
||||
content=request.app.notice_registry.active(include_dismissed=include_dismissed)
|
||||
)
|
||||
|
||||
|
||||
@@ -35,64 +34,37 @@ def get_notice_stats(request: Request) -> JSONResponse:
|
||||
return JSONResponse(content=request.app.notice_registry.stats())
|
||||
|
||||
|
||||
@router.get("/notices/muted_checks", dependencies=[Depends(require_role(["admin"]))])
|
||||
def get_muted_checks(request: Request) -> JSONResponse:
|
||||
"""Get the muted config and stream check rows, newest first."""
|
||||
return JSONResponse(content=request.app.notice_registry.muted_checks())
|
||||
@router.get(
|
||||
"/notices/dismissed_checks", dependencies=[Depends(require_role(["admin"]))]
|
||||
)
|
||||
def get_dismissed_checks(request: Request) -> JSONResponse:
|
||||
"""Get the dismissed config and stream check rows, newest first."""
|
||||
return JSONResponse(content=request.app.notice_registry.dismissed_checks())
|
||||
|
||||
|
||||
@router.delete("/notices/hidden", dependencies=[Depends(require_role(["admin"]))])
|
||||
def unhide_all_notices(request: Request) -> JSONResponse:
|
||||
"""Show every acknowledged and muted notice and check row again."""
|
||||
request.app.notice_registry.unhide_all()
|
||||
return JSONResponse(content={"success": True, "message": "Notices shown again"})
|
||||
@router.delete("/notices/dismissed", dependencies=[Depends(require_role(["admin"]))])
|
||||
def purge_dismissed(request: Request) -> JSONResponse:
|
||||
"""Delete every dismissed notice and check row so each can show again."""
|
||||
request.app.notice_registry.purge_dismissed()
|
||||
return JSONResponse(
|
||||
content={"success": True, "message": "Dismissed notices cleared"}
|
||||
)
|
||||
|
||||
|
||||
# model notice ids contain a slash, so the id is a path parameter
|
||||
@router.post(
|
||||
"/notices/{notice_id:path}/acknowledge",
|
||||
"/notices/{notice_id:path}/dismiss",
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
)
|
||||
def acknowledge_notice(request: Request, notice_id: str) -> JSONResponse:
|
||||
"""Hide a notice until it happens again.
|
||||
def dismiss_notice(request: Request, notice_id: str) -> JSONResponse:
|
||||
"""Hide a notice or a config or stream check row.
|
||||
|
||||
Config and stream check rows and the update notice never repeat, so they
|
||||
can only be muted.
|
||||
It stays hidden if the same problem happens again.
|
||||
"""
|
||||
if not request.app.notice_registry.acknowledge(notice_id):
|
||||
if not request.app.notice_registry.dismiss(notice_id):
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Notice not found"},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
return JSONResponse(content={"success": True, "message": "Notice acknowledged"})
|
||||
|
||||
|
||||
@router.post(
|
||||
"/notices/{notice_id:path}/mute",
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
)
|
||||
def mute_notice(request: Request, notice_id: str) -> JSONResponse:
|
||||
"""Hide a notice or a config or stream check row for good."""
|
||||
if not request.app.notice_registry.mute(notice_id):
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Notice not found"},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
return JSONResponse(content={"success": True, "message": "Notice muted"})
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/notices/{notice_id:path}/hidden",
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
)
|
||||
def unhide_notice(request: Request, notice_id: str) -> JSONResponse:
|
||||
"""Show an acknowledged or muted notice or check row again."""
|
||||
if not request.app.notice_registry.unhide(notice_id):
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Notice not found"},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
return JSONResponse(content={"success": True, "message": "Notice shown again"})
|
||||
return JSONResponse(content={"success": True, "message": "Notice dismissed"})
|
||||
|
||||
+11
-20
@@ -44,22 +44,6 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=[Tags.review])
|
||||
|
||||
|
||||
def get_label_clause(label: str, include_audio: bool = True):
|
||||
"""Build a clause matching a label within a review segment's data.
|
||||
|
||||
Verified objects are stored with a `-verified` suffix (eg. `person-verified`)
|
||||
so that variant is matched as well.
|
||||
"""
|
||||
clause = (ReviewSegment.data["objects"].cast("text") % f'*"{label}"*') | (
|
||||
ReviewSegment.data["objects"].cast("text") % f'*"{label}-verified"*'
|
||||
)
|
||||
|
||||
if include_audio:
|
||||
clause |= ReviewSegment.data["audio"].cast("text") % f'*"{label}"*'
|
||||
|
||||
return clause
|
||||
|
||||
|
||||
@router.get(
|
||||
"/review",
|
||||
response_model=list[ReviewSegmentResponse],
|
||||
@@ -109,7 +93,10 @@ async def review(
|
||||
filtered_labels = labels.split(",")
|
||||
|
||||
for label in filtered_labels:
|
||||
label_clauses.append(get_label_clause(label))
|
||||
label_clauses.append(
|
||||
(ReviewSegment.data["objects"].cast("text") % f'*"{label}"*')
|
||||
| (ReviewSegment.data["audio"].cast("text") % f'*"{label}"*')
|
||||
)
|
||||
clauses.append(reduce(operator.or_, label_clauses))
|
||||
|
||||
if zones != "all":
|
||||
@@ -252,7 +239,10 @@ async def review_summary(
|
||||
filtered_labels = labels.split(",")
|
||||
|
||||
for label in filtered_labels:
|
||||
label_clauses.append(get_label_clause(label))
|
||||
label_clauses.append(
|
||||
(ReviewSegment.data["objects"].cast("text") % f'*"{label}"*')
|
||||
| (ReviewSegment.data["audio"].cast("text") % f'*"{label}"*')
|
||||
)
|
||||
clauses.append(reduce(operator.or_, label_clauses))
|
||||
if zones != "all":
|
||||
# use matching so segments with multiple zones
|
||||
@@ -350,8 +340,9 @@ async def review_summary(
|
||||
filtered_labels = labels.split(",")
|
||||
|
||||
for label in filtered_labels:
|
||||
label_clauses.append(get_label_clause(label, include_audio=False))
|
||||
|
||||
label_clauses.append(
|
||||
ReviewSegment.data["objects"].cast("text") % f'*"{label}"*'
|
||||
)
|
||||
clauses.append(reduce(operator.or_, label_clauses))
|
||||
|
||||
# Find the time range of available data
|
||||
|
||||
+5
-14
@@ -49,6 +49,7 @@ from frigate.debug_replay import (
|
||||
DebugReplayManager,
|
||||
cleanup_replay_cameras,
|
||||
)
|
||||
from frigate.detectors.detector_config import SceneEnum
|
||||
from frigate.detectors.detector_types import api_types
|
||||
from frigate.detectors.device import build_detector_config, runner_names
|
||||
from frigate.embeddings import EmbeddingProcess, EmbeddingsContext
|
||||
@@ -107,7 +108,7 @@ class FrigateApp:
|
||||
self.metrics_manager = manager
|
||||
self.audio_process: mp.Process | None = None
|
||||
self.stop_event = stop_event
|
||||
self.detection_queues: dict[str, Queue] = {
|
||||
self.detection_queues: dict[SceneEnum, Queue] = {
|
||||
model.scene: mp.Queue() for model in config.models
|
||||
}
|
||||
self.detectors: dict[str, ObjectDetectProcess] = {}
|
||||
@@ -394,7 +395,7 @@ class FrigateApp:
|
||||
logger.error("Unable to prepare the %s runtime: %s", detector_type, err)
|
||||
|
||||
def start_detectors(self) -> None:
|
||||
model_cameras: dict[str, list[str]] = {
|
||||
model_cameras: dict[SceneEnum, list[str]] = {
|
||||
model.scene: [] for model in self.config.models
|
||||
}
|
||||
|
||||
@@ -482,7 +483,7 @@ class FrigateApp:
|
||||
|
||||
def start_audio_processor(self) -> None:
|
||||
self.audio_process = AudioProcessor(
|
||||
self.config, self.camera_metrics, self.embeddings_metrics, self.stop_event
|
||||
self.config, self.camera_metrics, self.stop_event
|
||||
)
|
||||
self.audio_process.start()
|
||||
self.processes["audio_detector"] = self.audio_process.pid or 0
|
||||
@@ -555,20 +556,10 @@ class FrigateApp:
|
||||
"output",
|
||||
lambda: OutputProcess(self.config, self.stop_event),
|
||||
),
|
||||
(
|
||||
"audio_process",
|
||||
"audio_detector",
|
||||
lambda: AudioProcessor(
|
||||
self.config,
|
||||
self.camera_metrics,
|
||||
self.embeddings_metrics,
|
||||
self.stop_event,
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
for attr, key, factory in specs:
|
||||
if getattr(self, attr, None) is None:
|
||||
if not hasattr(self, attr):
|
||||
continue
|
||||
|
||||
def on_restart(
|
||||
|
||||
@@ -104,13 +104,12 @@ class CameraActivityManager:
|
||||
all_objects: list[dict[str, Any]] = []
|
||||
|
||||
for camera in new_activity.keys():
|
||||
camera_config = self.config.cameras.get(camera)
|
||||
if camera_config is None:
|
||||
if camera not in self.config.cameras:
|
||||
continue
|
||||
|
||||
# handle cameras that were added dynamically
|
||||
if camera not in self.camera_all_object_counts:
|
||||
self.__init_camera(camera_config)
|
||||
self.__init_camera(self.config.cameras[camera])
|
||||
|
||||
new_objects = new_activity[camera].get("objects", [])
|
||||
all_objects.extend(new_objects)
|
||||
@@ -235,13 +234,12 @@ class AudioActivityManager:
|
||||
now = datetime.datetime.now().timestamp()
|
||||
|
||||
for camera in new_activity.keys():
|
||||
camera_config = self.config.cameras.get(camera)
|
||||
if camera_config is None:
|
||||
if camera not in self.config.cameras:
|
||||
continue
|
||||
|
||||
# handle cameras that were added dynamically
|
||||
if camera not in self.current_audio_detections:
|
||||
self.__init_camera(camera_config)
|
||||
self.__init_camera(self.config.cameras[camera])
|
||||
|
||||
new_detections = new_activity[camera].get("detections", [])
|
||||
if self.compare_audio_activity(camera, new_detections, now):
|
||||
|
||||
@@ -15,6 +15,7 @@ from frigate.config.camera.updater import (
|
||||
CameraConfigUpdateSubscriber,
|
||||
)
|
||||
from frigate.const import REPLAY_CAMERA_PREFIX
|
||||
from frigate.detectors.detector_config import SceneEnum
|
||||
from frigate.models import Regions
|
||||
from frigate.object_detection.util import detection_frame_size
|
||||
from frigate.util.builtin import empty_and_close_queue
|
||||
@@ -30,7 +31,7 @@ class CameraMaintainer(threading.Thread):
|
||||
def __init__(
|
||||
self,
|
||||
config: FrigateConfig,
|
||||
detection_queues: dict[str, Queue],
|
||||
detection_queues: dict[SceneEnum, Queue],
|
||||
detected_frames_queue: Queue,
|
||||
camera_metrics: DictProxy,
|
||||
ptz_metrics: dict[str, PTZMetrics],
|
||||
|
||||
@@ -12,7 +12,6 @@ class DetectionTypeEnum(str, Enum):
|
||||
video = "video"
|
||||
audio = "audio"
|
||||
lpr = "lpr"
|
||||
classification_state = "classification_state"
|
||||
|
||||
|
||||
class DetectionPublisher(Publisher):
|
||||
|
||||
@@ -782,9 +782,7 @@ class Dispatcher:
|
||||
try:
|
||||
payload = int(payload)
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
f"Received unsupported value for motion contour area: {payload}"
|
||||
)
|
||||
f"Received unsupported value for motion contour area: {payload}"
|
||||
return
|
||||
|
||||
motion_settings = self.config.cameras[camera_name].motion
|
||||
@@ -801,9 +799,7 @@ class Dispatcher:
|
||||
try:
|
||||
payload = int(payload)
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
f"Received unsupported value for motion threshold: {payload}"
|
||||
)
|
||||
f"Received unsupported value for motion threshold: {payload}"
|
||||
return
|
||||
|
||||
motion_settings = self.config.cameras[camera_name].motion
|
||||
@@ -818,9 +814,7 @@ class Dispatcher:
|
||||
def _on_global_notification_command(self, payload: str) -> None:
|
||||
"""Callback for global notification topic."""
|
||||
if payload != "ON" and payload != "OFF":
|
||||
logger.warning(
|
||||
f"Received unsupported value for all notification: {payload}"
|
||||
)
|
||||
f"Received unsupported value for all notification: {payload}"
|
||||
return
|
||||
|
||||
notification_settings = self.config.notifications
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from pydantic import Field, model_validator
|
||||
|
||||
from frigate.detectors.detector_config import DEFAULT_SCENE, SCENE_PATTERN
|
||||
from frigate.detectors.detector_config import SceneEnum
|
||||
|
||||
from ..base import FrigateBaseModel
|
||||
|
||||
@@ -62,11 +62,10 @@ class DetectConfig(FrigateBaseModel):
|
||||
title="Detect width",
|
||||
description="Width (pixels) of frames used for the detect stream; leave empty to use the native stream resolution.",
|
||||
)
|
||||
scene: str = Field(
|
||||
default=DEFAULT_SCENE,
|
||||
pattern=SCENE_PATTERN,
|
||||
scene: SceneEnum = Field(
|
||||
default=SceneEnum.all,
|
||||
title="Detect scene",
|
||||
description="The environment this camera looks at, used to pick which of the configured models runs on it. Cameras left on 'default' run the model configured with a scene of 'default'.",
|
||||
description="The environment this camera looks at, used to pick which of the configured models runs on it. Cameras left on 'all' run the model configured with a scene of 'all'.",
|
||||
)
|
||||
fps: int = Field(
|
||||
default=5,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from enum import Enum
|
||||
from typing import Any, Self
|
||||
from typing import Any
|
||||
|
||||
from pydantic import Field, model_validator
|
||||
from pydantic import Field
|
||||
|
||||
from ..base import FrigateBaseModel
|
||||
from ..env import EnvString
|
||||
@@ -21,17 +21,6 @@ class GenAIRoleEnum(str, Enum):
|
||||
chat = "chat"
|
||||
descriptions = "descriptions"
|
||||
embeddings = "embeddings"
|
||||
transcribe = "transcribe"
|
||||
|
||||
|
||||
# Providers that can accept audio input for the transcribe role. Ollama has no
|
||||
# audio input support, so claiming the role there would fail at request time.
|
||||
TRANSCRIBE_CAPABLE_PROVIDERS = {
|
||||
GenAIProviderEnum.openai,
|
||||
GenAIProviderEnum.azure_openai,
|
||||
GenAIProviderEnum.gemini,
|
||||
GenAIProviderEnum.llamacpp,
|
||||
}
|
||||
|
||||
|
||||
class GenAIConfig(FrigateBaseModel):
|
||||
@@ -63,7 +52,7 @@ class GenAIConfig(FrigateBaseModel):
|
||||
GenAIRoleEnum.chat,
|
||||
],
|
||||
title="Roles",
|
||||
description="GenAI roles (chat, descriptions, embeddings, transcribe); one provider per role. Only chat, descriptions, and embeddings are granted by default; transcribe must be listed explicitly.",
|
||||
description="GenAI roles (chat, descriptions, embeddings); one provider per role.",
|
||||
)
|
||||
provider_options: dict[str, Any] = Field(
|
||||
default={},
|
||||
@@ -77,17 +66,3 @@ class GenAIConfig(FrigateBaseModel):
|
||||
description="Runtime options passed to the provider for each inference call.",
|
||||
json_schema_extra={"additionalProperties": {}},
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_transcribe_provider(self) -> Self:
|
||||
"""Reject the transcribe role on providers that cannot accept audio input."""
|
||||
if (
|
||||
GenAIRoleEnum.transcribe in self.roles
|
||||
and self.provider not in TRANSCRIBE_CAPABLE_PROVIDERS
|
||||
):
|
||||
raise ValueError(
|
||||
f"GenAI provider '{self.provider.value}' does not support audio input "
|
||||
"and cannot be given the 'transcribe' role."
|
||||
)
|
||||
|
||||
return self
|
||||
|
||||
@@ -1,57 +1,8 @@
|
||||
from pydantic import Field, field_validator
|
||||
|
||||
from frigate.util.live_streams import DEFAULT_TRANSCODE_QUALITIES
|
||||
from pydantic import Field
|
||||
|
||||
from ..base import FrigateBaseModel
|
||||
|
||||
__all__ = ["CameraLiveConfig", "LiveTranscodeConfig", "LiveTranscodeQualityConfig"]
|
||||
|
||||
|
||||
class LiveTranscodeQualityConfig(FrigateBaseModel):
|
||||
height: int = Field(
|
||||
ge=144,
|
||||
le=2160,
|
||||
title="Height",
|
||||
description="Output height in pixels; width follows the source aspect ratio.",
|
||||
)
|
||||
bitrate: int = Field(
|
||||
ge=64,
|
||||
title="Bitrate",
|
||||
description="Target and maximum video bitrate in kbps.",
|
||||
)
|
||||
|
||||
|
||||
class LiveTranscodeConfig(FrigateBaseModel):
|
||||
enabled: bool = Field(
|
||||
default=False,
|
||||
title="Enable transcoded streams",
|
||||
description="Add lower-quality live streams that go2rtc transcodes in real time while someone is watching.",
|
||||
)
|
||||
source: str | None = Field(
|
||||
default=None,
|
||||
title="Source stream",
|
||||
description="go2rtc stream to transcode. Defaults to the first live stream.",
|
||||
)
|
||||
qualities: list[LiveTranscodeQualityConfig] = Field(
|
||||
default_factory=lambda: [
|
||||
LiveTranscodeQualityConfig(**quality)
|
||||
for quality in DEFAULT_TRANSCODE_QUALITIES
|
||||
],
|
||||
title="Qualities",
|
||||
description="One transcoded stream is added per quality.",
|
||||
)
|
||||
|
||||
@field_validator("qualities")
|
||||
@classmethod
|
||||
def validate_unique_heights(
|
||||
cls, qualities: list[LiveTranscodeQualityConfig]
|
||||
) -> list[LiveTranscodeQualityConfig]:
|
||||
heights = [quality.height for quality in qualities]
|
||||
|
||||
if len(heights) != len(set(heights)):
|
||||
raise ValueError("Transcoded stream heights must be unique.")
|
||||
|
||||
return qualities
|
||||
__all__ = ["CameraLiveConfig"]
|
||||
|
||||
|
||||
class CameraLiveConfig(FrigateBaseModel):
|
||||
@@ -60,11 +11,6 @@ class CameraLiveConfig(FrigateBaseModel):
|
||||
title="Live stream names",
|
||||
description="Mapping of configured stream names to restream/go2rtc names used for live playback.",
|
||||
)
|
||||
transcode: LiveTranscodeConfig = Field(
|
||||
default_factory=LiveTranscodeConfig,
|
||||
title="Transcoded streams",
|
||||
description="Lower-quality live streams transcoded on demand by go2rtc.",
|
||||
)
|
||||
height: int = Field(
|
||||
default=720,
|
||||
title="Live height",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from pydantic import Field
|
||||
|
||||
from ..base import FrigateBaseModel
|
||||
from ..env import EnvString
|
||||
|
||||
__all__ = ["NotificationConfig"]
|
||||
|
||||
@@ -12,7 +11,7 @@ class NotificationConfig(FrigateBaseModel):
|
||||
title="Enable notifications",
|
||||
description="Enable or disable notifications for all cameras; can be overridden per-camera.",
|
||||
)
|
||||
email: EnvString | None = Field(
|
||||
email: str | None = Field(
|
||||
default=None,
|
||||
title="Notification email",
|
||||
description="Email address used for push notifications or required by certain notification providers.",
|
||||
|
||||
@@ -9,7 +9,6 @@ __all__ = [
|
||||
"DetectionsConfig",
|
||||
"AlertsConfig",
|
||||
"ImageSourceEnum",
|
||||
"ReviewFrameModeEnum",
|
||||
"ReviewResponseStyleEnum",
|
||||
]
|
||||
|
||||
@@ -21,13 +20,6 @@ class ImageSourceEnum(str, Enum):
|
||||
recordings = "recordings"
|
||||
|
||||
|
||||
class ReviewFrameModeEnum(str, Enum):
|
||||
"""How review frames are presented to the GenAI provider."""
|
||||
|
||||
frames = "frames"
|
||||
annotated_frames = "annotated_frames"
|
||||
|
||||
|
||||
class ReviewResponseStyleEnum(str, Enum):
|
||||
"""Writing style presets for GenAI review descriptions."""
|
||||
|
||||
@@ -161,11 +153,6 @@ class GenAIReviewConfig(FrigateBaseModel):
|
||||
description="Preferred language to request from the GenAI provider for generated responses.",
|
||||
default=None,
|
||||
)
|
||||
frame_mode: ReviewFrameModeEnum = Field(
|
||||
default=ReviewFrameModeEnum.frames,
|
||||
title="Frame mode",
|
||||
description="How frames are presented to the model. 'frames' sends the prompt followed by the frames, which suits models that track a sequence well on their own. 'annotated_frames' labels each frame and interleaves notes derived from object tracking, which helps models that lose track of activity that repeats or reverses.",
|
||||
)
|
||||
response_style: ReviewResponseStyleEnum = Field(
|
||||
default=ReviewResponseStyleEnum.default,
|
||||
title="Response style",
|
||||
|
||||
@@ -5,7 +5,6 @@ from pydantic import ConfigDict, Field, field_validator
|
||||
from .base import FrigateBaseModel
|
||||
|
||||
__all__ = [
|
||||
"AudioTranscriptionModelEnum",
|
||||
"CameraFaceRecognitionConfig",
|
||||
"CameraLicensePlateRecognitionConfig",
|
||||
"CameraAudioTranscriptionConfig",
|
||||
@@ -21,10 +20,6 @@ class SemanticSearchModelEnum(str, Enum):
|
||||
jinav2 = "jinav2"
|
||||
|
||||
|
||||
class AudioTranscriptionModelEnum(str, Enum):
|
||||
whisper = "whisper"
|
||||
|
||||
|
||||
class EnrichmentsDeviceEnum(str, Enum):
|
||||
GPU = "GPU"
|
||||
CPU = "CPU"
|
||||
@@ -58,35 +53,10 @@ class AudioTranscriptionConfig(FrigateBaseModel):
|
||||
description="Enable or disable automatic audio transcription for all cameras; can be overridden per-camera.",
|
||||
)
|
||||
language: str = Field(
|
||||
default="auto",
|
||||
default="en",
|
||||
title="Transcription language",
|
||||
description="Language code used for transcription/translation (for example 'en' for English), or 'auto' to let the model detect it. See https://whisper-api.com/docs/languages/ for supported language codes.",
|
||||
description="Language code used for transcription/translation (for example 'en' for English). See https://whisper-api.com/docs/languages/ for supported language codes.",
|
||||
)
|
||||
model: AudioTranscriptionModelEnum | str | None = Field(
|
||||
default=AudioTranscriptionModelEnum.whisper,
|
||||
title="Audio transcription model or GenAI provider name",
|
||||
description="The transcription backend: 'whisper' for Frigate's built-in local models, or the name of a GenAI provider with the transcribe role.",
|
||||
)
|
||||
|
||||
@field_validator("model", mode="before")
|
||||
@classmethod
|
||||
def coerce_model_enum(cls, v):
|
||||
# An absent value ("model:" with nothing after it, or an explicit null)
|
||||
# means unspecified, so fall back to the built-in backend. Left as None
|
||||
# it would pass the GenAI-provider validation, which only inspects
|
||||
# strings, and then be treated as a provider name that resolves to no
|
||||
# client, turning transcription into a silent no-op.
|
||||
if v is None or (isinstance(v, str) and not v.strip()):
|
||||
return AudioTranscriptionModelEnum.whisper
|
||||
|
||||
if isinstance(v, str):
|
||||
try:
|
||||
return AudioTranscriptionModelEnum(v)
|
||||
except ValueError:
|
||||
return v
|
||||
|
||||
return v
|
||||
|
||||
device: EnrichmentsDeviceEnum = Field(
|
||||
default=EnrichmentsDeviceEnum.CPU,
|
||||
title="Transcription device",
|
||||
|
||||
+23
-211
@@ -19,7 +19,7 @@ from ruamel.yaml import YAML
|
||||
|
||||
from frigate.const import REGEX_JSON
|
||||
from frigate.detectors import ModelConfig
|
||||
from frigate.detectors.detector_config import DEFAULT_SCENE
|
||||
from frigate.detectors.detector_config import SceneEnum
|
||||
from frigate.detectors.device import DeviceParseError, DeviceSpec, parse_device
|
||||
from frigate.plus import PlusApi
|
||||
from frigate.util.builtin import (
|
||||
@@ -35,13 +35,6 @@ from frigate.util.config import (
|
||||
migrate_frigate_config,
|
||||
)
|
||||
from frigate.util.image import create_mask
|
||||
from frigate.util.live_streams import (
|
||||
default_transcode_source,
|
||||
is_transcode_stream_name,
|
||||
transcode_stream_name,
|
||||
transcode_streams,
|
||||
)
|
||||
from frigate.util.runtime_deps import sha256_of
|
||||
from frigate.util.services import auto_detect_hwaccel
|
||||
|
||||
from .auth import AuthConfig
|
||||
@@ -63,7 +56,6 @@ from .camera.timestamp import TimestampStyleConfig
|
||||
from .camera_group import CameraGroupConfig
|
||||
from .classification import (
|
||||
AudioTranscriptionConfig,
|
||||
AudioTranscriptionModelEnum,
|
||||
ClassificationConfig,
|
||||
FaceRecognitionConfig,
|
||||
LicensePlateRecognitionConfig,
|
||||
@@ -289,78 +281,11 @@ def verify_config_roles(camera_config: CameraConfig) -> None:
|
||||
)
|
||||
|
||||
|
||||
def apply_live_transcode_streams(
|
||||
frigate_config: FrigateConfig, camera_config: CameraConfig
|
||||
) -> None:
|
||||
"""Fold a camera's transcoded streams into its live stream list.
|
||||
|
||||
Enabled qualities missing from live.streams are appended, and entries the
|
||||
user placed keep their position. Transcoded names that are no longer
|
||||
generated are dropped unless they name a real go2rtc stream.
|
||||
"""
|
||||
live = camera_config.live
|
||||
transcode = live.transcode
|
||||
go2rtc_streams = frigate_config.go2rtc.model_dump().get("streams") or {}
|
||||
generated: dict[str, str] = {}
|
||||
|
||||
if transcode.enabled:
|
||||
if transcode.source is None:
|
||||
transcode.source = default_transcode_source(
|
||||
camera_config.name, live.streams
|
||||
)
|
||||
|
||||
if transcode.source not in go2rtc_streams:
|
||||
raise ValueError(
|
||||
f"Camera {camera_config.name} has transcoded streams enabled, but its source {transcode.source} is not a go2rtc stream."
|
||||
)
|
||||
|
||||
generated = transcode_streams(
|
||||
camera_config.name,
|
||||
transcode.source,
|
||||
[quality.model_dump() for quality in transcode.qualities],
|
||||
)
|
||||
|
||||
for name in generated:
|
||||
if name in go2rtc_streams:
|
||||
raise ValueError(
|
||||
f"Camera {camera_config.name} generates transcoded stream {name}, which collides with a go2rtc stream of the same name."
|
||||
)
|
||||
|
||||
streams = {
|
||||
label: name
|
||||
for label, name in live.streams.items()
|
||||
if name in generated
|
||||
or name in go2rtc_streams
|
||||
or not is_transcode_stream_name(camera_config.name, name)
|
||||
}
|
||||
placed = set(streams.values())
|
||||
|
||||
for quality in transcode.qualities if transcode.enabled else []:
|
||||
name = transcode_stream_name(camera_config.name, quality.height)
|
||||
|
||||
if name in placed:
|
||||
continue
|
||||
|
||||
label = f"{quality.height}p"
|
||||
|
||||
if label in streams:
|
||||
raise ValueError(
|
||||
f"Camera {camera_config.name} already has a live stream named {label}; rename it or place the transcoded stream under another name."
|
||||
)
|
||||
|
||||
streams[label] = name
|
||||
|
||||
live.streams = streams
|
||||
|
||||
|
||||
def verify_valid_live_stream_names(
|
||||
frigate_config: FrigateConfig, camera_config: CameraConfig
|
||||
) -> ValueError | None:
|
||||
"""Verify that a restream exists to use for live view."""
|
||||
for _, stream_name in camera_config.live.streams.items():
|
||||
if is_transcode_stream_name(camera_config.name, stream_name):
|
||||
continue
|
||||
|
||||
if (
|
||||
stream_name
|
||||
not in frigate_config.go2rtc.model_dump().get("streams", {}).keys()
|
||||
@@ -610,7 +535,7 @@ class FrigateConfig(FrigateBaseModel):
|
||||
models: list[ModelConfig] = Field(
|
||||
default_factory=_default_models,
|
||||
title="Detection models",
|
||||
description="Object detection models and the hardware each one runs on. Cameras pick a model by matching their detect.scene against a model's scene, falling back to the 'default' model.",
|
||||
description="Object detection models and the hardware each one runs on. Cameras pick a model by matching their detect.scene against a model's scene.",
|
||||
)
|
||||
|
||||
# GenAI config (named provider configs: name -> GenAIConfig)
|
||||
@@ -725,9 +650,7 @@ class FrigateConfig(FrigateBaseModel):
|
||||
)
|
||||
|
||||
_plus_api: PlusApi
|
||||
_model_devices: dict[str, list[DeviceSpec]]
|
||||
# scene -> model, including the scenes of duplicate models folded into another
|
||||
_scene_models: dict[str, ModelConfig]
|
||||
_model_devices: dict[SceneEnum, list[DeviceSpec]]
|
||||
_camera_models: dict[str, ModelConfig]
|
||||
_all_attributes: list[str]
|
||||
_all_attribute_logos: list[str]
|
||||
@@ -762,7 +685,7 @@ class FrigateConfig(FrigateBaseModel):
|
||||
def primary_model(self) -> ModelConfig:
|
||||
"""The model used when no specific camera is in play."""
|
||||
for model in self.models:
|
||||
if model.scene == DEFAULT_SCENE:
|
||||
if model.scene == SceneEnum.all:
|
||||
return model
|
||||
|
||||
return self.models[0]
|
||||
@@ -784,7 +707,7 @@ class FrigateConfig(FrigateBaseModel):
|
||||
|
||||
if model is None:
|
||||
camera = self.cameras.get(camera_name)
|
||||
scene = camera.detect.scene if camera is not None else DEFAULT_SCENE
|
||||
scene = camera.detect.scene if camera is not None else SceneEnum.all
|
||||
model = self._resolve_camera_model(camera_name, scene)
|
||||
self._camera_models[camera_name] = model
|
||||
|
||||
@@ -844,14 +767,14 @@ class FrigateConfig(FrigateBaseModel):
|
||||
if not self.models:
|
||||
raise ValueError("At least one model must be configured under models")
|
||||
|
||||
model_devices: dict[str, list[DeviceSpec]] = {}
|
||||
model_devices: dict[SceneEnum, list[DeviceSpec]] = {}
|
||||
# device string -> the scene of the model that already claimed it
|
||||
claimed_devices: dict[str, str] = {}
|
||||
claimed_devices: dict[str, SceneEnum] = {}
|
||||
|
||||
for index, model in enumerate(self.models):
|
||||
scene = model.scene
|
||||
scene = model.scene.value
|
||||
|
||||
if scene in model_devices:
|
||||
if model.scene in model_devices:
|
||||
raise ValueError(
|
||||
f"Multiple models are configured with a scene of '{scene}'. Each model must use a different scene."
|
||||
)
|
||||
@@ -880,20 +803,17 @@ class FrigateConfig(FrigateBaseModel):
|
||||
other = claimed_devices[device.raw]
|
||||
where = (
|
||||
f"twice by model '{scene}'"
|
||||
if other == scene
|
||||
else f"by both the '{other}' and '{scene}' models"
|
||||
if other == model.scene
|
||||
else f"by both the '{other.value}' and '{scene}' models"
|
||||
)
|
||||
raise ValueError(
|
||||
f"Device '{device.raw}' is used {where}, but it can only run one detection process."
|
||||
)
|
||||
|
||||
claimed_devices[device.raw] = scene
|
||||
claimed_devices[device.raw] = model.scene
|
||||
|
||||
self.models[index] = self._load_model(model, devices[0].detector)
|
||||
model_devices[scene] = devices
|
||||
|
||||
self._scene_models = {model.scene: model for model in self.models}
|
||||
self._consolidate_duplicate_models(model_devices)
|
||||
model_devices[model.scene] = devices
|
||||
|
||||
attributes: set[str] = set()
|
||||
attribute_logos: set[str] = set()
|
||||
@@ -917,107 +837,36 @@ class FrigateConfig(FrigateBaseModel):
|
||||
}
|
||||
self._all_labels = labels
|
||||
|
||||
def _consolidate_duplicate_models(
|
||||
self, model_devices: dict[str, list[DeviceSpec]]
|
||||
) -> None:
|
||||
"""Fold models that load the same model file into a single model.
|
||||
|
||||
Separate scenes for one model only split the same work across separate
|
||||
detection queues, so each device serves fewer cameras and is slower
|
||||
overall than one shared model. The duplicate's devices are moved to the
|
||||
model it duplicates and its scene resolves to that model.
|
||||
|
||||
Args:
|
||||
model_devices: Scene to parsed devices, updated in place
|
||||
"""
|
||||
kept: list[ModelConfig] = []
|
||||
hashes: dict[str, str | None] = {}
|
||||
|
||||
def file_hash(path: str) -> str | None:
|
||||
if path not in hashes:
|
||||
hashes[path] = sha256_of(path) if os.path.isfile(path) else None
|
||||
|
||||
return hashes[path]
|
||||
|
||||
def same_model(a: ModelConfig, b: ModelConfig) -> bool:
|
||||
# a model's devices all share a detector, so folding across
|
||||
# detectors would produce an invalid model
|
||||
if model_devices[a.scene][0].detector != model_devices[b.scene][0].detector:
|
||||
return False
|
||||
|
||||
if not a.path or not b.path:
|
||||
return False
|
||||
|
||||
if os.path.realpath(a.path) == os.path.realpath(b.path):
|
||||
return True
|
||||
|
||||
a_hash = file_hash(a.path)
|
||||
return a_hash is not None and a_hash == file_hash(b.path)
|
||||
|
||||
for model in self.models:
|
||||
original = next((other for other in kept if same_model(other, model)), None)
|
||||
|
||||
if original is None:
|
||||
kept.append(model)
|
||||
continue
|
||||
|
||||
# keep the default model so cameras without a scene still find it
|
||||
if model.scene == DEFAULT_SCENE:
|
||||
kept[kept.index(original)] = model
|
||||
original, model = model, original
|
||||
|
||||
logger.warning(
|
||||
"Models '%s' and '%s' use the same model file, so they have been combined into the '%s' model. Defining one model under several scenes to assign detectors to specific cameras is slower and less efficient than letting every detector serve every camera. Remove the '%s' model and list its devices under the '%s' model instead",
|
||||
original.scene,
|
||||
model.scene,
|
||||
original.scene,
|
||||
model.scene,
|
||||
original.scene,
|
||||
)
|
||||
original.devices = [*original.devices, *model.devices]
|
||||
model_devices[original.scene] = [
|
||||
*model_devices[original.scene],
|
||||
*model_devices.pop(model.scene),
|
||||
]
|
||||
self._scene_models[model.scene] = original
|
||||
|
||||
# anything already folded into the duplicate follows it
|
||||
for scene, target in self._scene_models.items():
|
||||
if target is model:
|
||||
self._scene_models[scene] = original
|
||||
|
||||
self.models = kept
|
||||
|
||||
def _resolve_camera_model(self, name: str, scene: str) -> ModelConfig:
|
||||
def _resolve_camera_model(self, name: str, scene: SceneEnum) -> ModelConfig:
|
||||
"""Resolve which model a camera runs on.
|
||||
|
||||
A camera may name a scene no model is configured for, which is valid as
|
||||
long as a 'default' model is there to fall back to.
|
||||
long as an 'all' model is there to fall back to.
|
||||
|
||||
Args:
|
||||
name: Name of the camera
|
||||
scene: The camera's detect scene, which defaults to 'default'
|
||||
scene: The camera's detect scene, which defaults to 'all'
|
||||
|
||||
Returns:
|
||||
The model the camera runs on
|
||||
"""
|
||||
model = self._scene_models.get(scene)
|
||||
by_scene = {model.scene: model for model in self.models}
|
||||
model = by_scene.get(scene)
|
||||
|
||||
if model is not None:
|
||||
return model
|
||||
|
||||
default = self._scene_models.get(DEFAULT_SCENE)
|
||||
default = by_scene.get(SceneEnum.all)
|
||||
|
||||
if default is None:
|
||||
raise ValueError(
|
||||
f"Camera '{name}' has a detect scene of '{scene}', but no model is configured for that scene or for '{DEFAULT_SCENE}'."
|
||||
f"Camera '{name}' has a detect scene of '{scene.value}', but no model is configured for that scene or for 'all'."
|
||||
)
|
||||
|
||||
logger.warning(
|
||||
"Camera '%s' has a detect scene of '%s', but no model is configured for that scene, so the '%s' model is used",
|
||||
"Camera '%s' has a detect scene of '%s', but no model is configured for that scene, so the 'all' model is used",
|
||||
name,
|
||||
scene,
|
||||
DEFAULT_SCENE,
|
||||
scene.value,
|
||||
)
|
||||
return default
|
||||
|
||||
@@ -1035,7 +884,7 @@ class FrigateConfig(FrigateBaseModel):
|
||||
# set notifications state
|
||||
self.notifications.enabled_in_config = self.notifications.enabled
|
||||
|
||||
# validate genai: each role (chat, descriptions, embeddings, transcribe) at most once
|
||||
# validate genai: each role (chat, descriptions, embeddings) at most once
|
||||
role_to_name: dict[GenAIRoleEnum, str] = {}
|
||||
for name, genai_cfg in self.genai.items():
|
||||
for role in genai_cfg.roles:
|
||||
@@ -1128,8 +977,6 @@ class FrigateConfig(FrigateBaseModel):
|
||||
"face_recognition": ["enabled", "min_area"],
|
||||
"lpr": ["enabled", "expire_time", "min_area", "enhancement"],
|
||||
"audio_transcription": ["enabled", "live_enabled"],
|
||||
# transcode is camera-level only
|
||||
"live": ["streams", "height", "quality"],
|
||||
}
|
||||
|
||||
for section in allowed_fields_map:
|
||||
@@ -1150,10 +997,6 @@ class FrigateConfig(FrigateBaseModel):
|
||||
camera_model = self._resolve_camera_model(name, camera_config.detect.scene)
|
||||
self._camera_models[name] = camera_model
|
||||
|
||||
# point cameras at the model their duplicate scene was folded into
|
||||
if camera_config.detect.scene in self._scene_models:
|
||||
camera_config.detect.scene = camera_model.scene
|
||||
|
||||
if camera_config.ffmpeg.hwaccel_args == "auto":
|
||||
camera_config.ffmpeg.hwaccel_args = self.ffmpeg.hwaccel_args
|
||||
|
||||
@@ -1362,8 +1205,6 @@ class FrigateConfig(FrigateBaseModel):
|
||||
if not camera_config.live.streams:
|
||||
camera_config.live.streams = {name: name}
|
||||
|
||||
apply_live_transcode_streams(self, camera_config)
|
||||
|
||||
# generate the ffmpeg commands
|
||||
camera_config.create_ffmpeg_cmds()
|
||||
self.cameras[name] = camera_config
|
||||
@@ -1404,35 +1245,6 @@ class FrigateConfig(FrigateBaseModel):
|
||||
for model in self.models:
|
||||
model.create_colormap(colored_labels)
|
||||
|
||||
# validate audio_transcription.model when it is a GenAI provider name.
|
||||
# this runs here rather than beside the semantic_search check because the
|
||||
# global->camera merge above is what resolves camera-level enablement.
|
||||
transcription_active = self.audio_transcription.enabled or any(
|
||||
camera.audio_transcription.enabled for camera in self.cameras.values()
|
||||
)
|
||||
|
||||
if (
|
||||
transcription_active
|
||||
and isinstance(self.audio_transcription.model, str)
|
||||
and not isinstance(
|
||||
self.audio_transcription.model, AudioTranscriptionModelEnum
|
||||
)
|
||||
):
|
||||
if self.audio_transcription.model not in self.genai:
|
||||
raise ValueError(
|
||||
f"audio_transcription.model '{self.audio_transcription.model}' is not a "
|
||||
"valid GenAI config key. Must match a key in genai config."
|
||||
)
|
||||
|
||||
if (
|
||||
GenAIRoleEnum.transcribe
|
||||
not in self.genai[self.audio_transcription.model].roles
|
||||
):
|
||||
raise ValueError(
|
||||
f"GenAI provider '{self.audio_transcription.model}' must have "
|
||||
"'transcribe' in its roles for audio transcription."
|
||||
)
|
||||
|
||||
# Check audio transcription and audio detection requirements
|
||||
if self.audio_transcription.enabled:
|
||||
# If audio transcription is enabled globally, at least one camera must have audio detection enabled
|
||||
|
||||
@@ -176,7 +176,6 @@ class LicensePlateProcessingMixin:
|
||||
"""
|
||||
input_shape = [3, 48, 320]
|
||||
num_images = len(images)
|
||||
outputs: list[np.ndarray] = []
|
||||
|
||||
for index in range(0, num_images, self.batch_size):
|
||||
input_h, input_w = input_shape[1], input_shape[2]
|
||||
@@ -196,11 +195,11 @@ class LicensePlateProcessingMixin:
|
||||
norm_image = norm_image[np.newaxis, :]
|
||||
norm_images.append(norm_image)
|
||||
|
||||
try:
|
||||
outputs.extend(self.model_runner.recognition_model(norm_images)) # type: ignore[arg-type]
|
||||
except Exception as e:
|
||||
logger.warning(f"Error running LPR recognition model: {e}")
|
||||
return [], []
|
||||
try:
|
||||
outputs = self.model_runner.recognition_model(norm_images) # type: ignore[arg-type]
|
||||
except Exception as e:
|
||||
logger.warning(f"Error running LPR recognition model: {e}")
|
||||
return [], []
|
||||
|
||||
return self.ctc_decoder(outputs)
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ from peewee import DoesNotExist
|
||||
|
||||
from frigate.comms.inter_process import InterProcessRequestor
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.config.classification import AudioTranscriptionModelEnum
|
||||
from frigate.const import (
|
||||
CACHE_DIR,
|
||||
MODEL_CACHE_DIR,
|
||||
@@ -19,13 +18,8 @@ from frigate.const import (
|
||||
)
|
||||
from frigate.data_processing.types import PostProcessDataEnum
|
||||
from frigate.embeddings.embeddings import Embeddings
|
||||
from frigate.genai.manager import GenAIClientManager
|
||||
from frigate.types import TrackedObjectUpdateTypesEnum
|
||||
from frigate.util.audio import (
|
||||
clean_transcript,
|
||||
get_audio_from_recording,
|
||||
resolve_language,
|
||||
)
|
||||
from frigate.util.audio import get_audio_from_recording
|
||||
|
||||
from ..types import DataProcessorMetrics
|
||||
from .api import PostProcessorApi
|
||||
@@ -40,25 +34,15 @@ class AudioTranscriptionPostProcessor(PostProcessorApi):
|
||||
requestor: InterProcessRequestor,
|
||||
embeddings: Embeddings,
|
||||
metrics: DataProcessorMetrics,
|
||||
genai_manager: GenAIClientManager | None = None,
|
||||
):
|
||||
super().__init__(config, metrics, None)
|
||||
self.config = config
|
||||
self.requestor = requestor
|
||||
self.embeddings = embeddings
|
||||
self.genai_manager = genai_manager
|
||||
self.recognizer = None
|
||||
self.transcription_lock = threading.Lock()
|
||||
self.transcription_thread: threading.Thread | None = None
|
||||
self.transcription_running = False
|
||||
self._use_genai = not isinstance(
|
||||
config.audio_transcription.model, AudioTranscriptionModelEnum
|
||||
)
|
||||
|
||||
if self._use_genai:
|
||||
# never build the local recognizer on the GenAI path; WhisperModel
|
||||
# downloads several hundred MB on first use
|
||||
return
|
||||
|
||||
# faster-whisper handles model downloading automatically
|
||||
self.model_path = os.path.join(MODEL_CACHE_DIR, "whisper")
|
||||
@@ -163,31 +147,6 @@ class AudioTranscriptionPostProcessor(PostProcessorApi):
|
||||
logger.error(f"Error in audio transcription post-processing: {e}")
|
||||
|
||||
def __transcribe_audio(self, audio_data: bytes) -> str | None:
|
||||
"""Transcribe WAV audio data with the configured backend."""
|
||||
if self._use_genai:
|
||||
return self.__transcribe_audio_genai(audio_data)
|
||||
|
||||
return self.__transcribe_audio_whisper(audio_data)
|
||||
|
||||
def __transcribe_audio_genai(self, audio_data: bytes) -> str | None:
|
||||
"""Hand the WAV bytes to the GenAI provider holding the transcribe role."""
|
||||
client = self.genai_manager.transcribe_client if self.genai_manager else None
|
||||
|
||||
if not client:
|
||||
logger.error(
|
||||
"audio_transcription.model is '%s' (GenAI provider) but no transcribe "
|
||||
"client is configured. Ensure the GenAI provider has 'transcribe' in its roles",
|
||||
self.config.audio_transcription.model,
|
||||
)
|
||||
return None
|
||||
|
||||
text = client.transcribe(
|
||||
audio_data,
|
||||
language=resolve_language(self.config.audio_transcription.language),
|
||||
)
|
||||
return clean_transcript(text) or None
|
||||
|
||||
def __transcribe_audio_whisper(self, audio_data: bytes) -> str | None:
|
||||
"""Transcribe WAV audio data using faster-whisper."""
|
||||
if not self.recognizer:
|
||||
logger.debug("Recognizer not initialized")
|
||||
@@ -201,7 +160,7 @@ class AudioTranscriptionPostProcessor(PostProcessorApi):
|
||||
|
||||
segments, info = self.recognizer.transcribe(
|
||||
temp_wav,
|
||||
language=resolve_language(self.config.audio_transcription.language),
|
||||
language=self.config.audio_transcription.language,
|
||||
beam_size=5,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,416 +0,0 @@
|
||||
"""Frame annotations derived from object tracking data.
|
||||
|
||||
Builds short notes describing what changed during a review item, keyed to the
|
||||
frames sampled from it. Everything here comes from data already recorded
|
||||
(each event's `path_data` trajectory, the timeline's stationary/active
|
||||
changes, and the review item's state classification changes), so the notes
|
||||
can be stated to the model as fact rather than as something it must perceive.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import math
|
||||
from collections.abc import Sequence
|
||||
from typing import Any
|
||||
|
||||
from frigate.models import Event, Timeline
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Movement smaller than this (normalized frame units) between two path points
|
||||
# is treated as the object holding still rather than travelling.
|
||||
STILL_THRESHOLD = 0.02
|
||||
|
||||
# A heading change beyond this (dot product against the leg's own heading)
|
||||
# counts as the object turning back rather than curving.
|
||||
REVERSAL_DOT = -0.3
|
||||
|
||||
# A run of travel shorter than this (normalized frame units) is treated as
|
||||
# milling about rather than going somewhere. Without it, a subject pacing in
|
||||
# one spot produces a burst of contradictory "turns around" notes on a single
|
||||
# frame.
|
||||
MIN_LEG_DISTANCE = 0.08
|
||||
|
||||
# Movement that begins within this many seconds of detection is folded into
|
||||
# the detection note, so each arrival reads as one event instead of several.
|
||||
DETECT_MOVE_MERGE_SECONDS = 2.0
|
||||
|
||||
STATE_CHANGE_PHRASES = {
|
||||
"stationary": "has stopped moving",
|
||||
"active": "starts moving again",
|
||||
}
|
||||
|
||||
Point = tuple[float, float, float]
|
||||
Leg = tuple[int, int]
|
||||
|
||||
|
||||
def describe_position(x: float, y: float) -> str:
|
||||
"""Name a normalized frame position in plain terms."""
|
||||
horizontal = "left" if x < 0.34 else ("right" if x > 0.66 else "center")
|
||||
vertical = "top" if y < 0.34 else ("bottom" if y > 0.66 else "middle")
|
||||
|
||||
if horizontal == "center" and vertical == "middle":
|
||||
return "the middle of the frame"
|
||||
|
||||
if horizontal == "center":
|
||||
return f"the {vertical} of the frame"
|
||||
|
||||
if vertical == "middle":
|
||||
return f"the {horizontal} of the frame"
|
||||
|
||||
return f"the {vertical} {horizontal} of the frame"
|
||||
|
||||
|
||||
def describe_heading(dx: float, dy: float) -> str:
|
||||
"""Name a direction of travel in frame terms.
|
||||
|
||||
y grows downward in normalized coordinates, so a falling y reads as moving
|
||||
toward the top of the frame.
|
||||
"""
|
||||
parts = []
|
||||
|
||||
if abs(dy) > abs(dx) * 0.4:
|
||||
parts.append("down" if dy > 0 else "up")
|
||||
|
||||
if abs(dx) > abs(dy) * 0.4:
|
||||
parts.append("right" if dx > 0 else "left")
|
||||
|
||||
return " and ".join(parts) if parts else "in place"
|
||||
|
||||
|
||||
def event_name(event: dict[str, Any]) -> str:
|
||||
"""Name an object for the notes, e.g. 'a person' or 'waste bin "Compost"'.
|
||||
|
||||
Objects are never numbered or given track identifiers. Frigate opens a new
|
||||
tracked object whenever a subject is re-detected, so the tracking data
|
||||
cannot say whether two entries are the same subject, and the notes stay
|
||||
ambiguous rather than implying either answer.
|
||||
"""
|
||||
label = str(event["label"]).replace("_", " ").replace("-verified", "")
|
||||
sub_label = event.get("sub_label")
|
||||
|
||||
if sub_label:
|
||||
return f'{label} "{sub_label}"'
|
||||
|
||||
article = "an" if label[:1].lower() in "aeiou" else "a"
|
||||
return f"{article} {label}"
|
||||
|
||||
|
||||
def describe_classification_change(change: dict[str, Any]) -> str:
|
||||
"""Phrase a state classification change, e.g. 'front gate changed from
|
||||
closed to open'."""
|
||||
model = str(change["model"]).replace("_", " ")
|
||||
before = str(change["from"]).replace("_", " ")
|
||||
after = str(change["to"]).replace("_", " ")
|
||||
return f"{model} changed from {before} to {after}"
|
||||
|
||||
|
||||
def path_legs(points: list[Point]) -> list[Leg]:
|
||||
"""Split a trajectory into runs of travel in a consistent direction.
|
||||
|
||||
A leg ends when the subject starts moving back against the direction that
|
||||
leg established, and only once the leg has covered MIN_LEG_DISTANCE, so
|
||||
jitter around a standing subject does not register as a turn.
|
||||
|
||||
Returns (start, end) index pairs into `points`.
|
||||
"""
|
||||
legs: list[Leg] = []
|
||||
start = 0
|
||||
|
||||
for i in range(1, len(points)):
|
||||
lx = points[i][0] - points[start][0]
|
||||
ly = points[i][1] - points[start][1]
|
||||
leg_distance = (lx * lx + ly * ly) ** 0.5
|
||||
|
||||
if leg_distance < MIN_LEG_DISTANCE:
|
||||
continue
|
||||
|
||||
sx = points[i][0] - points[i - 1][0]
|
||||
sy = points[i][1] - points[i - 1][1]
|
||||
step = (sx * sx + sy * sy) ** 0.5
|
||||
|
||||
if step < STILL_THRESHOLD:
|
||||
continue
|
||||
|
||||
dot = (lx / leg_distance) * (sx / step) + (ly / leg_distance) * (sy / step)
|
||||
|
||||
if dot < REVERSAL_DOT:
|
||||
legs.append((start, i - 1))
|
||||
start = i - 1
|
||||
|
||||
if start < len(points) - 1:
|
||||
legs.append((start, len(points) - 1))
|
||||
|
||||
return [
|
||||
(a, b)
|
||||
for a, b in legs
|
||||
if ((points[b][0] - points[a][0]) ** 2 + (points[b][1] - points[a][1]) ** 2)
|
||||
** 0.5
|
||||
>= MIN_LEG_DISTANCE
|
||||
]
|
||||
|
||||
|
||||
def path_points(path_data: list[Any]) -> list[Point]:
|
||||
"""Flatten path_data into (x, y, timestamp) tuples, or [] if malformed."""
|
||||
try:
|
||||
return [(p[0][0], p[0][1], p[1]) for p in path_data or []]
|
||||
except (IndexError, TypeError):
|
||||
logger.debug("Malformed path_data, skipping trajectory notes")
|
||||
return []
|
||||
|
||||
|
||||
def leg_start_time(points: list[Point], leg: Leg) -> float:
|
||||
"""When a leg's movement actually began.
|
||||
|
||||
path_data always keeps an object's first two samples, so a leg can open
|
||||
with points recorded long before the object moved. The first sample that
|
||||
has left the leg's origin is the earliest evidence of movement.
|
||||
"""
|
||||
a, b = leg
|
||||
x0, y0, t0 = points[a]
|
||||
|
||||
for x, y, t in points[a + 1 : b + 1]:
|
||||
if math.hypot(x - x0, y - y0) >= STILL_THRESHOLD:
|
||||
return t
|
||||
|
||||
return t0
|
||||
|
||||
|
||||
def leg_heading(points: list[Point], leg: Leg) -> str:
|
||||
a, b = leg
|
||||
return describe_heading(points[b][0] - points[a][0], points[b][1] - points[a][1])
|
||||
|
||||
|
||||
def leg_phrase(points: list[Point], leg: Leg, first: bool) -> str:
|
||||
"""Describe the start of a leg, e.g. 'turns around at ... and heads left'."""
|
||||
heading = leg_heading(points, leg)
|
||||
place = describe_position(points[leg[0]][0], points[leg[0]][1])
|
||||
|
||||
if first:
|
||||
return f"starts moving {heading} from {place}"
|
||||
|
||||
return f"turns around at {place} and heads {heading}"
|
||||
|
||||
|
||||
def path_moments(path_data: list[Any]) -> list[tuple[float, str]]:
|
||||
"""Key moments in one trajectory as (timestamp, phrase).
|
||||
|
||||
Emits one note per leg of travel. Where the last leg ends is left out:
|
||||
path_data only records significant movement, so its final point cannot
|
||||
distinguish an object coming to rest from one leaving the frame.
|
||||
"""
|
||||
points = path_points(path_data)
|
||||
|
||||
if len(points) < 2:
|
||||
return []
|
||||
|
||||
return [
|
||||
(leg_start_time(points, leg), leg_phrase(points, leg, index == 0))
|
||||
for index, leg in enumerate(path_legs(points))
|
||||
]
|
||||
|
||||
|
||||
def build_timeline(
|
||||
events: list[dict[str, Any]],
|
||||
span_end: float,
|
||||
state_changes: Sequence[dict[str, Any]] = (),
|
||||
) -> list[tuple[float, str]]:
|
||||
"""All annotated moments across every event, in time order.
|
||||
|
||||
Only changes are noted, since those are what sparse frames miss; an
|
||||
object's state at the end of the clip is visible in the last frame.
|
||||
`span_end` is the timestamp of the last sampled frame, and moments past it
|
||||
describe nothing the model can see. A track ending means the object
|
||||
stopped being detected, which may or may not mean it left the frame.
|
||||
|
||||
`state_changes` are timeline rows (timestamp, source_id, class_type); the
|
||||
stationary and active ones become "has stopped moving" / "starts moving
|
||||
again". Frigate only marks an object stationary after it has been still
|
||||
for a while, which the past-tense wording reflects.
|
||||
|
||||
Each object keeps its own notes. Folding an object into the note of the
|
||||
person moving it ("alongside ...") was tried and made models lose track of
|
||||
where the object went.
|
||||
"""
|
||||
changes_by_event: dict[str, list[tuple[float, str]]] = {}
|
||||
|
||||
for change in state_changes:
|
||||
phrase = STATE_CHANGE_PHRASES.get(change["class_type"])
|
||||
|
||||
if phrase:
|
||||
changes_by_event.setdefault(change["source_id"], []).append(
|
||||
(change["timestamp"], phrase)
|
||||
)
|
||||
|
||||
timeline: list[tuple[float, str]] = []
|
||||
|
||||
for event in sorted(events, key=lambda e: e["start_time"]):
|
||||
# Frame extraction can come up short at the end of a clip, leaving
|
||||
# objects that only appear after the last frame we actually have.
|
||||
if event["start_time"] > span_end:
|
||||
continue
|
||||
|
||||
points = path_points(event.get("path_data") or [])
|
||||
legs = path_legs(points) if len(points) >= 2 else []
|
||||
name = event_name(event)
|
||||
detected_at = event["start_time"]
|
||||
where = describe_position(points[0][0], points[0][1]) if points else "the frame"
|
||||
merges = bool(legs) and leg_start_time(points, legs[0]) - detected_at <= (
|
||||
DETECT_MOVE_MERGE_SECONDS
|
||||
)
|
||||
remaining = list(enumerate(legs))
|
||||
moments: list[tuple[float, str]] = []
|
||||
|
||||
if merges:
|
||||
heading = leg_heading(points, legs[0])
|
||||
timeline.append(
|
||||
(detected_at, f"{name} first detected at {where}, moving {heading}")
|
||||
)
|
||||
remaining = remaining[1:]
|
||||
else:
|
||||
timeline.append((detected_at, f"{name} first detected at {where}"))
|
||||
|
||||
for index, leg in remaining:
|
||||
moments.append(
|
||||
(
|
||||
leg_start_time(points, leg),
|
||||
leg_phrase(points, leg, index == 0),
|
||||
)
|
||||
)
|
||||
|
||||
moments.extend(
|
||||
(timestamp, phrase)
|
||||
for timestamp, phrase in changes_by_event.get(event["id"], [])
|
||||
if timestamp >= detected_at
|
||||
)
|
||||
|
||||
for timestamp, phrase in moments:
|
||||
if timestamp <= span_end:
|
||||
timeline.append((timestamp, f"{name} {phrase}"))
|
||||
|
||||
if event["end_time"] and event["end_time"] <= span_end:
|
||||
timeline.append((event["end_time"], f"{name} is no longer detected"))
|
||||
|
||||
return sorted(timeline, key=lambda m: m[0])
|
||||
|
||||
|
||||
def annotations_by_frame(
|
||||
timeline: list[tuple[float, str]], frame_times: list[float]
|
||||
) -> dict[int, list[str]]:
|
||||
"""Bucket timeline moments onto the frame that follows each one.
|
||||
|
||||
A moment is attached to the first frame at or after it happened, so the
|
||||
note always precedes the image in which the change becomes visible.
|
||||
"""
|
||||
buckets: dict[int, list[str]] = {}
|
||||
|
||||
if not frame_times:
|
||||
return buckets
|
||||
|
||||
for timestamp, phrase in timeline:
|
||||
index = next(
|
||||
(i for i, ft in enumerate(frame_times) if ft >= timestamp),
|
||||
len(frame_times) - 1,
|
||||
)
|
||||
buckets.setdefault(index, []).append(phrase)
|
||||
|
||||
return buckets
|
||||
|
||||
|
||||
def get_tracked_events(detection_ids: list[str]) -> list[dict[str, Any]]:
|
||||
"""Load the tracked objects behind a review item's detections."""
|
||||
if not detection_ids:
|
||||
return []
|
||||
|
||||
rows = list(
|
||||
Event.select(
|
||||
Event.id,
|
||||
Event.label,
|
||||
Event.sub_label,
|
||||
Event.start_time,
|
||||
Event.end_time,
|
||||
Event.data,
|
||||
)
|
||||
.where(Event.id << detection_ids)
|
||||
.dicts()
|
||||
.iterator()
|
||||
)
|
||||
|
||||
return [
|
||||
{
|
||||
"id": row["id"],
|
||||
"label": row["label"],
|
||||
"sub_label": row["sub_label"],
|
||||
"start_time": row["start_time"],
|
||||
"end_time": row["end_time"],
|
||||
"path_data": (row["data"] or {}).get("path_data") or [],
|
||||
}
|
||||
for row in rows
|
||||
if row["start_time"] is not None
|
||||
]
|
||||
|
||||
|
||||
def get_state_changes(detection_ids: list[str]) -> list[dict[str, Any]]:
|
||||
"""Stationary/active changes the timeline recorded for these objects."""
|
||||
if not detection_ids:
|
||||
return []
|
||||
|
||||
return list(
|
||||
Timeline.select(Timeline.timestamp, Timeline.source_id, Timeline.class_type)
|
||||
.where(
|
||||
(Timeline.source_id << detection_ids)
|
||||
& (Timeline.class_type << list(STATE_CHANGE_PHRASES))
|
||||
)
|
||||
.dicts()
|
||||
.iterator()
|
||||
)
|
||||
|
||||
|
||||
def build_frame_captions(
|
||||
detection_ids: list[str],
|
||||
frame_times: list[float],
|
||||
classification_changes: Sequence[dict[str, Any]] = (),
|
||||
) -> list[str]:
|
||||
"""A caption for each sampled frame, in frame order.
|
||||
|
||||
Every frame gets its index and elapsed time so the model can tell them
|
||||
apart; frames where something changed also carry the tracker and state
|
||||
classification notes for that moment. Returns an empty list when there is
|
||||
nothing to say, which callers treat as a reason to fall back to sending
|
||||
plain frames.
|
||||
"""
|
||||
if not frame_times:
|
||||
return []
|
||||
|
||||
span_end = frame_times[-1]
|
||||
timeline: list[tuple[float, str]] = []
|
||||
events = get_tracked_events(detection_ids)
|
||||
|
||||
# audio and manual review items can have state changes but no tracked objects
|
||||
if events:
|
||||
timeline.extend(
|
||||
(timestamp, f"[tracker] {note}")
|
||||
for timestamp, note in build_timeline(
|
||||
events, span_end, get_state_changes(detection_ids)
|
||||
)
|
||||
)
|
||||
|
||||
timeline.extend(
|
||||
(change["timestamp"], f"[state] {describe_classification_change(change)}")
|
||||
for change in classification_changes
|
||||
if change["timestamp"] <= span_end
|
||||
)
|
||||
buckets = annotations_by_frame(sorted(timeline, key=lambda m: m[0]), frame_times)
|
||||
|
||||
if not buckets:
|
||||
return []
|
||||
|
||||
total = len(frame_times)
|
||||
origin = frame_times[0]
|
||||
captions: list[str] = []
|
||||
|
||||
for index, timestamp in enumerate(frame_times):
|
||||
lines = [f"Frame {index + 1} of {total} (+{timestamp - origin:.1f}s):"]
|
||||
lines.extend(buckets.get(index, []))
|
||||
captions.append("\n".join(lines))
|
||||
|
||||
return captions
|
||||
@@ -19,11 +19,7 @@ from frigate.comms.embeddings_updater import EmbeddingsRequestEnum
|
||||
from frigate.comms.inter_process import InterProcessRequestor
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.config.camera import CameraConfig
|
||||
from frigate.config.camera.review import (
|
||||
GenAIReviewConfig,
|
||||
ImageSourceEnum,
|
||||
ReviewFrameModeEnum,
|
||||
)
|
||||
from frigate.config.camera.review import GenAIReviewConfig, ImageSourceEnum
|
||||
from frigate.const import (
|
||||
ATTRIBUTE_LABEL_DISPLAY_MAP,
|
||||
CACHE_DIR,
|
||||
@@ -40,7 +36,6 @@ from frigate.util.image import get_image_from_recording
|
||||
|
||||
from ..post.api import PostProcessorApi
|
||||
from ..types import DataProcessorMetrics
|
||||
from .review_annotations import build_frame_captions, describe_classification_change
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -48,7 +43,6 @@ RECORDING_BUFFER_EXTENSION_PERCENT = 0.10
|
||||
MIN_RECORDING_DURATION = 10
|
||||
MAX_IMAGE_TOKENS = 24000
|
||||
MAX_FRAMES_PER_SECOND = 1
|
||||
MAX_ANNOTATED_FRAMES = 28
|
||||
|
||||
|
||||
class ReviewDescriptionProcessor(PostProcessorApi):
|
||||
@@ -73,7 +67,6 @@ class ReviewDescriptionProcessor(PostProcessorApi):
|
||||
duration: float,
|
||||
image_source: ImageSourceEnum = ImageSourceEnum.preview,
|
||||
height: int = 480,
|
||||
frame_mode: ReviewFrameModeEnum = ReviewFrameModeEnum.frames,
|
||||
) -> int:
|
||||
"""Calculate optimal number of frames based on event duration, context size,
|
||||
image source, and resolution.
|
||||
@@ -87,8 +80,6 @@ class ReviewDescriptionProcessor(PostProcessorApi):
|
||||
- MAX_FRAMES_PER_SECOND x duration, to avoid drowning short events in
|
||||
near-duplicate frames where the model latches onto the redundant middle
|
||||
and skips the start/end action
|
||||
- MAX_ANNOTATED_FRAMES in annotated mode, where the tracking notes
|
||||
already carry the sequence
|
||||
"""
|
||||
client = self.genai_manager.description_client
|
||||
|
||||
@@ -134,10 +125,6 @@ class ReviewDescriptionProcessor(PostProcessorApi):
|
||||
max_frames_by_tokens = int(image_token_budget / tokens_per_image)
|
||||
max_frames_by_duration = int(duration * MAX_FRAMES_PER_SECOND)
|
||||
max_frames = min(max_frames_by_tokens, max_frames_by_duration)
|
||||
|
||||
if frame_mode == ReviewFrameModeEnum.annotated_frames:
|
||||
max_frames = min(max_frames, MAX_ANNOTATED_FRAMES)
|
||||
|
||||
return max(max_frames, 3)
|
||||
|
||||
def process_data(
|
||||
@@ -179,7 +166,6 @@ class ReviewDescriptionProcessor(PostProcessorApi):
|
||||
return
|
||||
|
||||
image_source = camera_config.review.genai.image_source
|
||||
frame_mode = camera_config.review.genai.frame_mode
|
||||
|
||||
if image_source == ImageSourceEnum.recordings:
|
||||
buffer_extension = get_recording_buffer_extension(
|
||||
@@ -188,43 +174,40 @@ class ReviewDescriptionProcessor(PostProcessorApi):
|
||||
final_data["start_time"] -= buffer_extension
|
||||
final_data["end_time"] += buffer_extension
|
||||
|
||||
frames = self.get_recording_frames(
|
||||
thumbs = self.get_recording_frames(
|
||||
camera,
|
||||
final_data["start_time"],
|
||||
final_data["end_time"],
|
||||
height=480, # Use 480p for good balance between quality and token usage
|
||||
frame_mode=frame_mode,
|
||||
)
|
||||
|
||||
if not frames:
|
||||
if not thumbs:
|
||||
# Fallback to preview frames if no recordings available
|
||||
logger.warning(
|
||||
f"No recording frames found for {camera}, falling back to preview frames"
|
||||
)
|
||||
frames = self.get_preview_frames_as_bytes(
|
||||
thumbs = self.get_preview_frames_as_bytes(
|
||||
camera,
|
||||
final_data["start_time"],
|
||||
final_data["end_time"],
|
||||
final_data["thumb_path"],
|
||||
id,
|
||||
camera_config.review.genai.debug_save_thumbnails,
|
||||
frame_mode,
|
||||
)
|
||||
elif camera_config.review.genai.debug_save_thumbnails:
|
||||
self.save_debug_recording_frames(id, frames)
|
||||
self.save_debug_recording_frames(id, thumbs)
|
||||
else:
|
||||
# Use preview frames
|
||||
frames = self.get_preview_frames_as_bytes(
|
||||
thumbs = self.get_preview_frames_as_bytes(
|
||||
camera,
|
||||
final_data["start_time"],
|
||||
final_data["end_time"],
|
||||
final_data["thumb_path"],
|
||||
id,
|
||||
camera_config.review.genai.debug_save_thumbnails,
|
||||
frame_mode,
|
||||
)
|
||||
|
||||
self.start_analysis(camera_config, final_data, frames)
|
||||
self.start_analysis(camera_config, final_data, thumbs)
|
||||
|
||||
def handle_request(self, topic: str, request_data: dict[str, Any]) -> str | None:
|
||||
if topic == EmbeddingsRequestEnum.regenerate_review_description.value:
|
||||
@@ -254,10 +237,6 @@ class ReviewDescriptionProcessor(PostProcessorApi):
|
||||
"start_time": r["start_time"],
|
||||
"end_time": r["end_time"],
|
||||
"metadata": r["data"]["metadata"],
|
||||
"state_changes": [
|
||||
describe_classification_change(change)
|
||||
for change in sorted_classification_state_changes(r["data"])
|
||||
],
|
||||
}
|
||||
for r in (
|
||||
ReviewSegment.select(
|
||||
@@ -302,9 +281,6 @@ class ReviewDescriptionProcessor(PostProcessorApi):
|
||||
primary_item["start_time"] = primary_seg["start_time"]
|
||||
primary_item["end_time"] = primary_seg["end_time"]
|
||||
|
||||
if primary_seg["state_changes"]:
|
||||
primary_item["state_changes"] = primary_seg["state_changes"]
|
||||
|
||||
# Find overlapping contextual items from other cameras
|
||||
primary_start = primary_seg["start_time"]
|
||||
primary_end = primary_seg["end_time"]
|
||||
@@ -325,25 +301,14 @@ class ReviewDescriptionProcessor(PostProcessorApi):
|
||||
seg_end = seg["end_time"]
|
||||
|
||||
if seg_start < primary_end and primary_start < seg_end:
|
||||
# Avoid duplicates if same camera has multiple overlapping
|
||||
# segments. One with state changes is kept as its own item
|
||||
# so each change stays within its item's time range.
|
||||
if (
|
||||
seg_camera in seen_contextual_cameras
|
||||
and not seg["state_changes"]
|
||||
):
|
||||
continue
|
||||
|
||||
contextual_item = copy.deepcopy(seg["metadata"])
|
||||
contextual_item["camera"] = seg_camera
|
||||
contextual_item["start_time"] = seg_start
|
||||
contextual_item["end_time"] = seg_end
|
||||
|
||||
if seg["state_changes"]:
|
||||
contextual_item["state_changes"] = seg["state_changes"]
|
||||
|
||||
contextual_items.append(contextual_item)
|
||||
seen_contextual_cameras.add(seg_camera)
|
||||
# Avoid duplicates if same camera has multiple overlapping segments
|
||||
if seg_camera not in seen_contextual_cameras:
|
||||
contextual_item = copy.deepcopy(seg["metadata"])
|
||||
contextual_item["camera"] = seg_camera
|
||||
contextual_item["start_time"] = seg_start
|
||||
contextual_item["end_time"] = seg_end
|
||||
contextual_items.append(contextual_item)
|
||||
seen_contextual_cameras.add(seg_camera)
|
||||
|
||||
# Add context array to primary item
|
||||
primary_item["context"] = contextual_items
|
||||
@@ -421,51 +386,31 @@ class ReviewDescriptionProcessor(PostProcessorApi):
|
||||
buffer_extension = get_recording_buffer_extension(
|
||||
final_data["end_time"] - final_data["start_time"]
|
||||
)
|
||||
frames = self.get_recording_frames(
|
||||
thumbs = self.get_recording_frames(
|
||||
str(review.camera),
|
||||
final_data["start_time"] - buffer_extension,
|
||||
final_data["end_time"] + buffer_extension,
|
||||
height=480,
|
||||
frame_mode=camera_config.review.genai.frame_mode,
|
||||
)
|
||||
|
||||
if not frames:
|
||||
if not thumbs:
|
||||
logger.error(
|
||||
"No recording frames are available for review item %s", review_id
|
||||
)
|
||||
return
|
||||
|
||||
if camera_config.review.genai.debug_save_thumbnails:
|
||||
self.save_debug_recording_frames(review_id, frames)
|
||||
self.save_debug_recording_frames(review_id, thumbs)
|
||||
|
||||
self.start_analysis(camera_config, final_data, frames)
|
||||
self.start_analysis(camera_config, final_data, thumbs)
|
||||
|
||||
def start_analysis(
|
||||
self,
|
||||
camera_config: CameraConfig,
|
||||
final_data: dict[str, Any],
|
||||
frames: list[tuple[bytes, float]],
|
||||
thumbs: list[bytes],
|
||||
) -> None:
|
||||
"""Kick off description generation for a review item in the background."""
|
||||
thumbs = [frame for frame, _ in frames]
|
||||
captions: list[str] = []
|
||||
|
||||
if (
|
||||
camera_config.review.genai.frame_mode
|
||||
== ReviewFrameModeEnum.annotated_frames
|
||||
):
|
||||
captions = build_frame_captions(
|
||||
final_data["data"].get("detections") or [],
|
||||
[timestamp for _, timestamp in frames],
|
||||
sorted_classification_state_changes(final_data["data"]),
|
||||
)
|
||||
|
||||
if not captions:
|
||||
logger.debug(
|
||||
"No tracking annotations for review item %s, sending plain frames",
|
||||
final_data["id"],
|
||||
)
|
||||
|
||||
self.review_desc_dps.update()
|
||||
threading.Thread(
|
||||
target=run_analysis,
|
||||
@@ -476,22 +421,19 @@ class ReviewDescriptionProcessor(PostProcessorApi):
|
||||
camera_config,
|
||||
final_data,
|
||||
thumbs,
|
||||
captions,
|
||||
camera_config.review.genai,
|
||||
sorted(self.config.all_labels),
|
||||
self.config.all_attributes,
|
||||
),
|
||||
).start()
|
||||
|
||||
def save_debug_recording_frames(
|
||||
self, review_id: str, frames: list[tuple[bytes, float]]
|
||||
) -> None:
|
||||
def save_debug_recording_frames(self, review_id: str, thumbs: list[bytes]) -> None:
|
||||
"""Write the recording frames sent to the provider out for debugging."""
|
||||
Path(os.path.join(CLIPS_DIR, "genai-requests", review_id)).mkdir(
|
||||
parents=True, exist_ok=True
|
||||
)
|
||||
|
||||
for idx, (frame_bytes, _) in enumerate(frames):
|
||||
for idx, frame_bytes in enumerate(thumbs):
|
||||
with open(
|
||||
os.path.join(CLIPS_DIR, f"genai-requests/{review_id}/{idx}.jpg"),
|
||||
"wb",
|
||||
@@ -503,9 +445,7 @@ class ReviewDescriptionProcessor(PostProcessorApi):
|
||||
camera: str,
|
||||
start_time: float,
|
||||
end_time: float,
|
||||
frame_mode: ReviewFrameModeEnum = ReviewFrameModeEnum.frames,
|
||||
) -> list[tuple[str, float]]:
|
||||
"""Preview frame paths paired with the time each one was captured."""
|
||||
) -> list[str]:
|
||||
preview_dir = os.path.join(CACHE_DIR, "preview_frames")
|
||||
file_start = f"preview_{camera}-"
|
||||
start_file = f"{file_start}{start_time}.webp"
|
||||
@@ -537,29 +477,18 @@ class ReviewDescriptionProcessor(PostProcessorApi):
|
||||
|
||||
frame_count = len(all_frames)
|
||||
desired_frame_count = self.calculate_frame_count(
|
||||
camera,
|
||||
duration=end_time - start_time,
|
||||
frame_mode=frame_mode,
|
||||
camera, duration=end_time - start_time
|
||||
)
|
||||
|
||||
def with_timestamp(path: str) -> tuple[str, float]:
|
||||
# Preview frames are named preview_<camera>-<timestamp>.webp
|
||||
stem = os.path.basename(path).removesuffix(".webp")
|
||||
|
||||
try:
|
||||
return (path, float(stem.removeprefix(file_start)))
|
||||
except ValueError:
|
||||
return (path, start_time)
|
||||
|
||||
if frame_count <= desired_frame_count:
|
||||
return [with_timestamp(f) for f in all_frames]
|
||||
return all_frames
|
||||
|
||||
selected_frames = []
|
||||
step_size = (frame_count - 1) / (desired_frame_count - 1)
|
||||
|
||||
for i in range(desired_frame_count):
|
||||
index = round(i * step_size)
|
||||
selected_frames.append(with_timestamp(all_frames[index]))
|
||||
selected_frames.append(all_frames[index])
|
||||
|
||||
return selected_frames
|
||||
|
||||
@@ -569,12 +498,11 @@ class ReviewDescriptionProcessor(PostProcessorApi):
|
||||
start_time: float,
|
||||
end_time: float,
|
||||
height: int = 480,
|
||||
frame_mode: ReviewFrameModeEnum = ReviewFrameModeEnum.frames,
|
||||
) -> list[tuple[bytes, float]]:
|
||||
"""Get frames from recordings paired with the time each was captured."""
|
||||
) -> list[bytes]:
|
||||
"""Get frames from recordings at specified timestamps."""
|
||||
duration = end_time - start_time
|
||||
desired_frame_count = self.calculate_frame_count(
|
||||
camera, duration, ImageSourceEnum.recordings, height, frame_mode
|
||||
camera, duration, ImageSourceEnum.recordings, height
|
||||
)
|
||||
|
||||
# Calculate evenly spaced timestamps throughout the duration
|
||||
@@ -612,7 +540,7 @@ class ReviewDescriptionProcessor(PostProcessorApi):
|
||||
except DoesNotExist:
|
||||
return None
|
||||
|
||||
frames: list[tuple[bytes, float]] = []
|
||||
frames = []
|
||||
|
||||
for timestamp in timestamps:
|
||||
try:
|
||||
@@ -625,7 +553,7 @@ class ReviewDescriptionProcessor(PostProcessorApi):
|
||||
image_data = extract_frame_from_recording(rounded_timestamp)
|
||||
|
||||
if image_data:
|
||||
frames.append((image_data, timestamp))
|
||||
frames.append(image_data)
|
||||
else:
|
||||
logger.warning(
|
||||
f"No recording found for {camera} at timestamp {timestamp}"
|
||||
@@ -646,8 +574,7 @@ class ReviewDescriptionProcessor(PostProcessorApi):
|
||||
thumb_path_fallback: str,
|
||||
review_id: str,
|
||||
save_debug: bool,
|
||||
frame_mode: ReviewFrameModeEnum = ReviewFrameModeEnum.frames,
|
||||
) -> list[tuple[bytes, float]]:
|
||||
) -> list[bytes]:
|
||||
"""Get preview frames and convert them to JPEG bytes.
|
||||
|
||||
Args:
|
||||
@@ -659,14 +586,14 @@ class ReviewDescriptionProcessor(PostProcessorApi):
|
||||
save_debug: Whether to save debug thumbnails
|
||||
|
||||
Returns:
|
||||
List of (JPEG image bytes, capture timestamp) pairs
|
||||
List of JPEG image bytes
|
||||
"""
|
||||
frame_paths = self.get_cache_frames(camera, start_time, end_time, frame_mode)
|
||||
frame_paths = self.get_cache_frames(camera, start_time, end_time)
|
||||
if not frame_paths:
|
||||
frame_paths = [(thumb_path_fallback, start_time)]
|
||||
frame_paths = [thumb_path_fallback]
|
||||
|
||||
thumbs: list[tuple[bytes, float]] = []
|
||||
for idx, (thumb_path, timestamp) in enumerate(frame_paths):
|
||||
thumbs = []
|
||||
for idx, thumb_path in enumerate(frame_paths):
|
||||
thumb_data = cv2.imread(thumb_path)
|
||||
|
||||
if thumb_data is None:
|
||||
@@ -679,7 +606,7 @@ class ReviewDescriptionProcessor(PostProcessorApi):
|
||||
".jpg", thumb_data, [int(cv2.IMWRITE_JPEG_QUALITY), 100]
|
||||
)
|
||||
if ret:
|
||||
thumbs.append((jpg.tobytes(), timestamp))
|
||||
thumbs.append(jpg.tobytes())
|
||||
|
||||
if save_debug:
|
||||
Path(os.path.join(CLIPS_DIR, "genai-requests", review_id)).mkdir(
|
||||
@@ -707,39 +634,6 @@ def get_recording_buffer_extension(duration: float) -> float:
|
||||
return buffer_extension
|
||||
|
||||
|
||||
def sorted_classification_state_changes(
|
||||
review_data: dict[str, Any],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""A review item's state classification changes in time order."""
|
||||
return sorted(
|
||||
review_data.get("classification_state_changes") or [],
|
||||
key=lambda change: change["timestamp"],
|
||||
)
|
||||
|
||||
|
||||
def format_classification_state_changes(
|
||||
changes: list[dict[str, Any]], start_time: float, end_time: float
|
||||
) -> list[str]:
|
||||
"""Phrase state classification changes with their timing in the activity.
|
||||
|
||||
Changes are attached while the review item is active, which runs past its
|
||||
end_time by the review cutoff, and a few seconds before its start.
|
||||
"""
|
||||
lines = []
|
||||
|
||||
for change in changes:
|
||||
if change["timestamp"] < start_time:
|
||||
when = "just before the activity started"
|
||||
elif change["timestamp"] > end_time:
|
||||
when = "after the activity ended"
|
||||
else:
|
||||
when = f"{round(change['timestamp'] - start_time)}s into the activity"
|
||||
|
||||
lines.append(f"{describe_classification_change(change)}, {when}")
|
||||
|
||||
return lines
|
||||
|
||||
|
||||
def run_analysis(
|
||||
requestor: InterProcessRequestor,
|
||||
genai_client: GenAIClient,
|
||||
@@ -747,7 +641,6 @@ def run_analysis(
|
||||
camera_config: CameraConfig,
|
||||
final_data: dict[str, Any],
|
||||
thumbs: list[bytes],
|
||||
frame_captions: list[str],
|
||||
genai_config: GenAIReviewConfig,
|
||||
labelmap_objects: list[str],
|
||||
attribute_labels: list[str],
|
||||
@@ -795,13 +688,6 @@ def run_analysis(
|
||||
unified_objects.append(object_type)
|
||||
|
||||
analytics_data["unified_objects"] = unified_objects
|
||||
analytics_data["classification_state_changes"] = (
|
||||
format_classification_state_changes(
|
||||
sorted_classification_state_changes(final_data["data"]),
|
||||
final_data["start_time"],
|
||||
final_data["end_time"],
|
||||
)
|
||||
)
|
||||
|
||||
metadata = genai_client.generate_review_description(
|
||||
analytics_data,
|
||||
@@ -811,7 +697,6 @@ def run_analysis(
|
||||
genai_config.debug_save_thumbnails,
|
||||
genai_config.activity_context_prompt,
|
||||
genai_config.response_style,
|
||||
frame_captions,
|
||||
)
|
||||
review_inference_speed.update(datetime.datetime.now().timestamp() - start)
|
||||
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
"""Handle processing audio for speech transcription using sherpa-onnx with FFmpeg pipe."""
|
||||
|
||||
import collections
|
||||
import logging
|
||||
import os
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from frigate.comms.inter_process import InterProcessRequestor
|
||||
from frigate.config import CameraConfig, FrigateConfig
|
||||
from frigate.config.classification import AudioTranscriptionModelEnum
|
||||
from frigate.const import AUDIO_DURATION, MODEL_CACHE_DIR
|
||||
from frigate.const import MODEL_CACHE_DIR
|
||||
from frigate.data_processing.common.audio_transcription.model import (
|
||||
AudioTranscriptionModelRunner,
|
||||
)
|
||||
@@ -21,38 +18,12 @@ from frigate.data_processing.real_time.whisper_online import (
|
||||
FasterWhisperASR,
|
||||
OnlineASRProcessor,
|
||||
)
|
||||
from frigate.util.audio import (
|
||||
clean_transcript,
|
||||
pcm16_to_wav,
|
||||
resolve_language,
|
||||
stitch_transcripts,
|
||||
)
|
||||
|
||||
from ..types import DataProcessorMetrics
|
||||
from .api import RealTimeProcessorApi
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# importing frigate.genai eagerly would pull the provider SDKs into the
|
||||
# audio process even when transcription runs on a local model
|
||||
from frigate.genai.manager import GenAIClientManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Number of ~0.975s audio detector chunks per GenAI request. The window advances
|
||||
# one chunk at a time, so two chunks means a 50% overlap: every word lands whole
|
||||
# in at least one window, which whisper-family models need to avoid hallucinating
|
||||
# on a clipped clip. The cadence is fixed by the audio detector's frame size, so
|
||||
# this is a constant rather than a config knob.
|
||||
GENAI_WINDOW_CHUNKS = 2
|
||||
|
||||
# Bound the queue at ~30s of audio so a slow or hung provider cannot grow it
|
||||
# without limit. The producer is the ffmpeg read thread and must never block.
|
||||
AUDIO_QUEUE_MAXSIZE = int(30 / AUDIO_DURATION)
|
||||
|
||||
# A backed-up queue drops a chunk per cycle, so warning on each one would spam
|
||||
# the log once a second per camera for as long as the provider stays slow.
|
||||
AUDIO_DROP_WARN_INTERVAL = 10.0
|
||||
|
||||
|
||||
class AudioTranscriptionRealTimeProcessor(RealTimeProcessorApi):
|
||||
def __init__(
|
||||
@@ -60,10 +31,9 @@ class AudioTranscriptionRealTimeProcessor(RealTimeProcessorApi):
|
||||
config: FrigateConfig,
|
||||
camera_config: CameraConfig,
|
||||
requestor: InterProcessRequestor,
|
||||
model_runner: AudioTranscriptionModelRunner | None,
|
||||
model_runner: AudioTranscriptionModelRunner,
|
||||
metrics: DataProcessorMetrics,
|
||||
stop_event: threading.Event,
|
||||
genai_manager: "GenAIClientManager | None" = None,
|
||||
):
|
||||
super().__init__(config, metrics)
|
||||
self.config = config
|
||||
@@ -72,31 +42,11 @@ class AudioTranscriptionRealTimeProcessor(RealTimeProcessorApi):
|
||||
self.stream: Any = None
|
||||
self.whisper_model: FasterWhisperASR | None = None
|
||||
self.model_runner = model_runner
|
||||
self.genai_manager = genai_manager
|
||||
self.transcription_segments: list[str] = []
|
||||
self.audio_queue: queue.Queue[tuple[dict[str, Any], np.ndarray]] = queue.Queue(
|
||||
maxsize=AUDIO_QUEUE_MAXSIZE
|
||||
)
|
||||
self.audio_queue: queue.Queue[tuple[dict[str, Any], np.ndarray]] = queue.Queue()
|
||||
self.stop_event = stop_event
|
||||
self._use_genai = not isinstance(
|
||||
config.audio_transcription.model, AudioTranscriptionModelEnum
|
||||
)
|
||||
# sliding window of raw int16 chunks; the deque's maxlen is what evicts
|
||||
# the oldest chunk and so produces the overlap
|
||||
self._genai_window: collections.deque[np.ndarray] = collections.deque(
|
||||
maxlen=GENAI_WINDOW_CHUNKS
|
||||
)
|
||||
self._genai_committed = ""
|
||||
# set by the producer when it discards a chunk, so the consumer knows the
|
||||
# audio it is about to receive is not contiguous with what it buffered
|
||||
self._audio_dropped = threading.Event()
|
||||
self._last_drop_warning = 0.0
|
||||
|
||||
def __build_recognizer(self) -> None:
|
||||
if self._use_genai:
|
||||
# nothing local to load; never import sherpa or FasterWhisperASR
|
||||
return
|
||||
|
||||
try:
|
||||
if self.config.audio_transcription.model_size == "large":
|
||||
# Whisper models need to be per-process and can only run one stream at a time
|
||||
@@ -114,7 +64,7 @@ class AudioTranscriptionRealTimeProcessor(RealTimeProcessorApi):
|
||||
self.stream = OnlineASRProcessor(
|
||||
asr=self.whisper_model,
|
||||
)
|
||||
elif self.model_runner is not None:
|
||||
else:
|
||||
logger.debug(f"Loading sherpa stream for {self.camera_config.name}")
|
||||
self.stream = self.model_runner.model.create_stream()
|
||||
logger.debug(
|
||||
@@ -126,15 +76,6 @@ class AudioTranscriptionRealTimeProcessor(RealTimeProcessorApi):
|
||||
)
|
||||
|
||||
def __process_audio_stream(self, audio_data: np.ndarray) -> tuple[str, bool] | None:
|
||||
# must precede both the model_runner guard (model_runner is None on this
|
||||
# path) and the float32 normalization below (GenAI wants untouched int16)
|
||||
if self._use_genai:
|
||||
return self.__process_audio_genai(audio_data)
|
||||
|
||||
if self.model_runner is None:
|
||||
logger.debug("Audio transcription (live) model runner not initialized")
|
||||
return None
|
||||
|
||||
if (
|
||||
self.model_runner.model is None
|
||||
and self.config.audio_transcription.model_size == "small"
|
||||
@@ -199,82 +140,6 @@ class AudioTranscriptionRealTimeProcessor(RealTimeProcessorApi):
|
||||
logger.error(f"Error processing audio stream: {e}")
|
||||
return None
|
||||
|
||||
def __process_audio_genai(self, audio_data: np.ndarray) -> tuple[str, bool] | None:
|
||||
"""Transcribe a sliding overlapped window through the GenAI provider."""
|
||||
client = self.genai_manager.transcribe_client if self.genai_manager else None
|
||||
|
||||
if not client:
|
||||
logger.error(
|
||||
"audio_transcription.model is '%s' (GenAI provider) but no transcribe "
|
||||
"client is configured. Ensure the GenAI provider has 'transcribe' in its roles",
|
||||
self.config.audio_transcription.model,
|
||||
)
|
||||
return None
|
||||
|
||||
if self._audio_dropped.is_set():
|
||||
self._audio_dropped.clear()
|
||||
|
||||
# Chunks were discarded between what is buffered and this one, so
|
||||
# concatenating them would splice non-adjacent audio into one window
|
||||
# and destroy the overlap the stitcher depends on.
|
||||
self._genai_window.clear()
|
||||
|
||||
if self._genai_committed:
|
||||
# the transcript has a gap in it; close the utterance out rather
|
||||
# than stitching across missing speech
|
||||
return self.__end_genai_utterance()
|
||||
|
||||
self._genai_window.append(audio_data)
|
||||
|
||||
if len(self._genai_window) < GENAI_WINDOW_CHUNKS:
|
||||
# wait for a full window so the first request is never a clipped clip
|
||||
return None
|
||||
|
||||
window = np.concatenate(list(self._genai_window))
|
||||
|
||||
# Silence gate, using the same threshold audio detection uses. Gate the
|
||||
# whole window rather than individual chunks; this is the primary cost
|
||||
# and privacy brake and is what keeps a quiet camera near zero requests.
|
||||
window_as_float = window.astype(np.float32)
|
||||
rms = float(np.sqrt(np.mean(np.absolute(np.square(window_as_float)))))
|
||||
|
||||
if rms < self.camera_config.audio.min_volume:
|
||||
logger.debug(
|
||||
f"Window RMS {rms:.1f} below min_volume, skipping transcription"
|
||||
)
|
||||
return self.__end_genai_utterance()
|
||||
|
||||
text = client.transcribe(
|
||||
pcm16_to_wav(window),
|
||||
language=resolve_language(self.config.audio_transcription.language),
|
||||
)
|
||||
|
||||
# cleaning has to come first: a silent window often comes back as the
|
||||
# model's preamble alone, which is silence, not a word to commit
|
||||
cleaned = clean_transcript(text)
|
||||
|
||||
if not cleaned:
|
||||
return self.__end_genai_utterance()
|
||||
|
||||
self._genai_committed = stitch_transcripts(self._genai_committed, cleaned)
|
||||
|
||||
# no VAD on this path, so mirror the whisper branch's heuristic endpoint
|
||||
is_endpoint = (
|
||||
self._genai_committed.endswith((".", "!", "?"))
|
||||
and len(self._genai_committed) > 300
|
||||
)
|
||||
|
||||
logger.debug(f"GenAI transcription: '{self._genai_committed}'")
|
||||
|
||||
return self._genai_committed, is_endpoint
|
||||
|
||||
def __end_genai_utterance(self) -> tuple[str, bool] | None:
|
||||
"""Close out the current utterance when a window carries no speech."""
|
||||
if not self._genai_committed:
|
||||
return None
|
||||
|
||||
return self._genai_committed, True
|
||||
|
||||
def process_frame(self, obj_data: dict[str, Any], frame: np.ndarray) -> None:
|
||||
pass
|
||||
|
||||
@@ -283,38 +148,8 @@ class AudioTranscriptionRealTimeProcessor(RealTimeProcessorApi):
|
||||
logger.debug("No audio data provided for transcription")
|
||||
return None
|
||||
|
||||
# enqueue audio data for processing in the thread. never block: the
|
||||
# producer is the ffmpeg read thread that audio detection depends on,
|
||||
# so on a backlog drop the oldest chunk instead.
|
||||
try:
|
||||
self.audio_queue.put_nowait((obj_data, audio))
|
||||
except queue.Full:
|
||||
try:
|
||||
self.audio_queue.get_nowait()
|
||||
self.audio_queue.task_done()
|
||||
except queue.Empty:
|
||||
pass
|
||||
|
||||
# the stream now has a hole in it, which the consumer has to know
|
||||
# about before it splices the next chunk onto what it already holds
|
||||
self._audio_dropped.set()
|
||||
|
||||
now = time.monotonic()
|
||||
|
||||
if now - self._last_drop_warning >= AUDIO_DROP_WARN_INTERVAL:
|
||||
self._last_drop_warning = now
|
||||
logger.warning(
|
||||
"Audio transcription queue for %s is full, dropping audio. The "
|
||||
"provider is not keeping up with the %.2fs chunk rate",
|
||||
self.camera_config.name,
|
||||
AUDIO_DURATION,
|
||||
)
|
||||
|
||||
try:
|
||||
self.audio_queue.put_nowait((obj_data, audio))
|
||||
except queue.Full:
|
||||
pass
|
||||
|
||||
# enqueue audio data for processing in the thread
|
||||
self.audio_queue.put((obj_data, audio))
|
||||
return None
|
||||
|
||||
def run(self) -> None:
|
||||
@@ -370,14 +205,6 @@ class AudioTranscriptionRealTimeProcessor(RealTimeProcessorApi):
|
||||
break
|
||||
|
||||
def reset(self) -> None:
|
||||
if self._use_genai:
|
||||
self._genai_committed = ""
|
||||
# stale audio carried across an utterance boundary would be
|
||||
# re-transcribed into the next one
|
||||
self._genai_window.clear()
|
||||
logger.debug("Stream reset")
|
||||
return
|
||||
|
||||
if self.config.audio_transcription.model_size == "large":
|
||||
# get final output from whisper
|
||||
output = self.stream.finish()
|
||||
@@ -391,7 +218,7 @@ class AudioTranscriptionRealTimeProcessor(RealTimeProcessorApi):
|
||||
# reset whisper
|
||||
self.stream.init()
|
||||
self.transcription_segments = []
|
||||
elif self.model_runner is not None:
|
||||
else:
|
||||
# reset sherpa
|
||||
self.model_runner.model.reset(self.stream)
|
||||
|
||||
@@ -399,24 +226,6 @@ class AudioTranscriptionRealTimeProcessor(RealTimeProcessorApi):
|
||||
|
||||
def check_unload_model(self) -> None:
|
||||
# regularly called in the loop in audio maintainer
|
||||
if self._use_genai:
|
||||
# no model to unload, but this is the hook that fires when
|
||||
# live_enabled flips off. guard on emptiness: called ~1x/s per camera.
|
||||
if self._genai_committed or self._genai_window:
|
||||
logger.debug(
|
||||
f"Clearing GenAI transcription state for {self.camera_config.name}"
|
||||
)
|
||||
self.clear_audio_queue()
|
||||
self._genai_committed = ""
|
||||
self._genai_window.clear()
|
||||
|
||||
self.requestor.send_data(
|
||||
f"{self.camera_config.name}/audio/transcription",
|
||||
"",
|
||||
)
|
||||
|
||||
return
|
||||
|
||||
if (
|
||||
self.config.audio_transcription.model_size == "large"
|
||||
and self.whisper_model is not None
|
||||
@@ -461,10 +270,6 @@ class AudioTranscriptionRealTimeProcessor(RealTimeProcessorApi):
|
||||
self, topic: str, request_data: dict[str, Any]
|
||||
) -> dict[str, Any] | None:
|
||||
if topic == "clear_audio_recognizer":
|
||||
if self._use_genai:
|
||||
self.reset()
|
||||
return {"message": "Audio transcription state cleared", "success": True}
|
||||
|
||||
self.stream = None
|
||||
self.__build_recognizer()
|
||||
return {"message": "Audio recognizer cleared and rebuilt", "success": True}
|
||||
|
||||
@@ -91,23 +91,8 @@ class CustomStateClassificationProcessor(DeferredRealtimeProcessorApi):
|
||||
self.tensor_input_details = self.interpreter.get_input_details()
|
||||
self.tensor_output_details = self.interpreter.get_output_details()
|
||||
self.labelmap = load_labels(labelmap_path, prefill=0, indexed=False)
|
||||
self._forget_unknown_states()
|
||||
self.classifications_per_second.start()
|
||||
|
||||
def _forget_unknown_states(self) -> None:
|
||||
"""Drop verified states that are not labels of the loaded model.
|
||||
|
||||
A retrained model can rename or remove labels. Keeping a state it can
|
||||
no longer produce would report its first verified state as a change
|
||||
from that obsolete label.
|
||||
"""
|
||||
labels = set(self.labelmap.values())
|
||||
self.state_history = {
|
||||
camera: history
|
||||
for camera, history in self.state_history.items()
|
||||
if history["current_state"] in labels
|
||||
}
|
||||
|
||||
def __update_metrics(self, duration: float) -> None:
|
||||
self.classifications_per_second.update()
|
||||
if self.inference_speed:
|
||||
@@ -149,20 +134,15 @@ class CustomStateClassificationProcessor(DeferredRealtimeProcessorApi):
|
||||
# Don't save if state is stable (detected_state == current_state) AND score is 100%
|
||||
return False
|
||||
|
||||
def verify_state_change(
|
||||
self, camera: str, detected_state: str, timestamp: float
|
||||
) -> tuple[str | None, float] | None:
|
||||
def verify_state_change(self, camera: str, detected_state: str) -> str | None:
|
||||
"""
|
||||
Verify state change requires 3 consecutive identical states before publishing.
|
||||
Returns (previous state, time the new state was first seen) once verified,
|
||||
or None if verification not complete. The previous state is None for the
|
||||
first state verified on a camera.
|
||||
Returns state to publish or None if verification not complete.
|
||||
"""
|
||||
if camera not in self.state_history:
|
||||
self.state_history[camera] = {
|
||||
"current_state": None,
|
||||
"pending_state": None,
|
||||
"pending_since": 0.0,
|
||||
"consecutive_count": 0,
|
||||
}
|
||||
|
||||
@@ -177,14 +157,12 @@ class CustomStateClassificationProcessor(DeferredRealtimeProcessorApi):
|
||||
verification["consecutive_count"] += 1
|
||||
|
||||
if verification["consecutive_count"] >= 3:
|
||||
previous_state = verification["current_state"]
|
||||
verification["current_state"] = detected_state
|
||||
verification["pending_state"] = None
|
||||
verification["consecutive_count"] = 0
|
||||
return previous_state, verification["pending_since"]
|
||||
return detected_state
|
||||
else:
|
||||
verification["pending_state"] = detected_state
|
||||
verification["pending_since"] = timestamp
|
||||
verification["consecutive_count"] = 1
|
||||
logger.debug(
|
||||
f"New state '{detected_state}' detected for {camera}, need {3 - verification['consecutive_count']} more consecutive detections"
|
||||
@@ -362,19 +340,16 @@ class CustomStateClassificationProcessor(DeferredRealtimeProcessorApi):
|
||||
)
|
||||
return
|
||||
|
||||
verified = self.verify_state_change(camera, detected_state, timestamp)
|
||||
verified_state = self.verify_state_change(camera, detected_state)
|
||||
|
||||
if verified is not None:
|
||||
previous_state, changed_at = verified
|
||||
if verified_state is not None:
|
||||
self._emit_result(
|
||||
{
|
||||
"type": "classification",
|
||||
"processor": "state",
|
||||
"model_name": self.model_config.name,
|
||||
"camera": camera,
|
||||
"state": detected_state,
|
||||
"previous_state": previous_state,
|
||||
"timestamp": changed_at,
|
||||
"state": verified_state,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
+15
-76
@@ -1,10 +1,8 @@
|
||||
import logging
|
||||
import sqlite3
|
||||
import threading
|
||||
from typing import Any
|
||||
|
||||
import regex
|
||||
from peewee import DatabaseError
|
||||
from playhouse.sqliteq import SqliteQueueDatabase
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -19,7 +17,6 @@ class SqliteVecQueueDatabase(SqliteQueueDatabase):
|
||||
self.load_vec_extension: bool = load_vec_extension
|
||||
# no extension necessary, sqlite will load correctly for each platform
|
||||
self.sqlite_vec_path = "/usr/local/lib/vec0"
|
||||
self.upsert_lock = threading.Lock()
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def _connect(self, *args: Any, **kwargs: Any) -> sqlite3.Connection:
|
||||
@@ -56,22 +53,6 @@ class SqliteVecQueueDatabase(SqliteQueueDatabase):
|
||||
|
||||
conn.create_function("REGEXP", 2, regexp)
|
||||
|
||||
def execute_write(self, sql: str, params: Any = None) -> None:
|
||||
"""Run a write and wait for it, so that failures are raised here.
|
||||
|
||||
SqliteQueueDatabase hands non-SELECT statements to a writer thread and
|
||||
stores any exception on the cursor it returns, so callers that ignore
|
||||
that cursor never learn the write failed.
|
||||
"""
|
||||
self.execute_sql(sql, params).fetchall()
|
||||
|
||||
def _table_exists(self, table: str) -> bool:
|
||||
cursor = self.execute_sql(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?",
|
||||
(table,),
|
||||
)
|
||||
return cursor.fetchone() is not None
|
||||
|
||||
def _delete_embeddings(self, table: str, event_ids: list[str]) -> None:
|
||||
"""Delete embeddings for the given events, if the table exists.
|
||||
|
||||
@@ -82,17 +63,17 @@ class SqliteVecQueueDatabase(SqliteQueueDatabase):
|
||||
return
|
||||
|
||||
# the embeddings tables are only created once semantic search has run
|
||||
if not self._table_exists(table):
|
||||
cursor = self.execute_sql(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?",
|
||||
(table,),
|
||||
)
|
||||
|
||||
if cursor.fetchone() is None:
|
||||
logger.debug("Skipping %s cleanup, table does not exist", table)
|
||||
return
|
||||
|
||||
ids = ",".join(["?" for _ in event_ids])
|
||||
|
||||
# callers treat cleanup as best effort, so log rather than propagate
|
||||
try:
|
||||
self.execute_write(f"DELETE FROM {table} WHERE id IN ({ids})", event_ids)
|
||||
except DatabaseError:
|
||||
logger.exception("Failed to delete embeddings from %s", table)
|
||||
self.execute_sql(f"DELETE FROM {table} WHERE id IN ({ids})", event_ids)
|
||||
|
||||
def delete_embeddings_thumbnail(self, event_ids: list[str]) -> None:
|
||||
self._delete_embeddings("vec_thumbnails", event_ids)
|
||||
@@ -100,67 +81,25 @@ class SqliteVecQueueDatabase(SqliteQueueDatabase):
|
||||
def delete_embeddings_description(self, event_ids: list[str]) -> None:
|
||||
self._delete_embeddings("vec_descriptions", event_ids)
|
||||
|
||||
def _restore_vec_info_table(self, table: str) -> None:
|
||||
"""Recreate the _info shadow table a legacy vec0 table is missing.
|
||||
|
||||
sqlite-vec added _info in 0.1.6 and drops it unconditionally when a
|
||||
table is destroyed, so tables written by Frigate 0.17 and earlier fail
|
||||
to drop. An empty stub is enough, and leaving it unseeded keeps the
|
||||
table reading as pre-0.1.10 if the drop does not follow.
|
||||
"""
|
||||
if not self._table_exists(table) or self._table_exists(f"{table}_info"):
|
||||
return
|
||||
|
||||
logger.debug("Restoring the %s_info shadow table before dropping", table)
|
||||
self.execute_write(
|
||||
f'CREATE TABLE "{table}_info" (key TEXT PRIMARY KEY, value ANY)'
|
||||
)
|
||||
|
||||
def drop_embeddings_tables(self) -> None:
|
||||
for table in ("vec_descriptions", "vec_thumbnails"):
|
||||
self._restore_vec_info_table(table)
|
||||
self.execute_write(f"DROP TABLE IF EXISTS {table}")
|
||||
self.execute_sql("""
|
||||
DROP TABLE vec_descriptions;
|
||||
""")
|
||||
self.execute_sql("""
|
||||
DROP TABLE vec_thumbnails;
|
||||
""")
|
||||
|
||||
def create_embeddings_tables(self) -> None:
|
||||
"""Create vec0 virtual table for embeddings"""
|
||||
self.execute_write("""
|
||||
self.execute_sql("""
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS vec_thumbnails USING vec0(
|
||||
id TEXT PRIMARY KEY,
|
||||
thumbnail_embedding FLOAT[768] distance_metric=cosine
|
||||
);
|
||||
""")
|
||||
self.execute_write("""
|
||||
self.execute_sql("""
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS vec_descriptions USING vec0(
|
||||
id TEXT PRIMARY KEY,
|
||||
description_embedding FLOAT[768] distance_metric=cosine
|
||||
);
|
||||
""")
|
||||
|
||||
def upsert_embeddings(
|
||||
self, table: str, column: str, embeddings: dict[str, bytes]
|
||||
) -> None:
|
||||
"""Write embeddings for the given event ids, replacing any that exist.
|
||||
|
||||
vec0 implements neither REPLACE nor UPSERT, so rows that are already
|
||||
there have to be deleted first.
|
||||
"""
|
||||
if not embeddings:
|
||||
return
|
||||
|
||||
event_ids = list(embeddings.keys())
|
||||
ids = ",".join(["?" for _ in event_ids])
|
||||
params: list[Any] = []
|
||||
|
||||
for event_id in event_ids:
|
||||
params.extend((event_id, embeddings[event_id]))
|
||||
|
||||
values = ", ".join(["(?, ?)"] * len(event_ids))
|
||||
|
||||
# reindexing and live embedding run on separate threads, and each write
|
||||
# is queued separately, so the delete and the insert have to be held
|
||||
# together or an interleaved pair fails on the vec0 primary key
|
||||
with self.upsert_lock:
|
||||
self.execute_write(f"DELETE FROM {table} WHERE id IN ({ids})", event_ids)
|
||||
self.execute_write(
|
||||
f"INSERT INTO {table}(id, {column}) VALUES {values}", params
|
||||
)
|
||||
|
||||
@@ -46,42 +46,8 @@ _PROVIDER_LABELS = {
|
||||
"MIGraphXExecutionProvider": "MIGraphX",
|
||||
"OpenVINOExecutionProvider": "OpenVINO",
|
||||
"CPUExecutionProvider": "CPU",
|
||||
"LighterANE": "Neural Engine",
|
||||
}
|
||||
|
||||
# lighter (https://github.com/fieldwork-ai/lighter) places an ONNX Runtime plugin
|
||||
# execution provider in a container started with --device lighter.sh/ane=all,
|
||||
# which runs models on a Mac's Neural Engine; LIGHTER_ANE_EP names where it is
|
||||
LIGHTER_ANE_EP_NAME = "LighterANE"
|
||||
LIGHTER_ANE_LIBRARY = "/usr/lib/lighter/liblighter_ane_ep.so"
|
||||
|
||||
|
||||
def get_lighter_ane_devices() -> list[Any]:
|
||||
"""Get the Neural Engine devices lighter's provider offers, registering it once.
|
||||
|
||||
Returns:
|
||||
The provider's ONNX Runtime devices, or an empty list without lighter's device
|
||||
"""
|
||||
library = os.environ.get("LIGHTER_ANE_EP", LIGHTER_ANE_LIBRARY)
|
||||
|
||||
if not os.path.exists(library):
|
||||
return []
|
||||
|
||||
devices = [d for d in ort.get_ep_devices() if d.ep_name == LIGHTER_ANE_EP_NAME]
|
||||
|
||||
if not devices:
|
||||
try:
|
||||
ort.register_execution_provider_library(LIGHTER_ANE_EP_NAME, library)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to load the Neural Engine provider from {library}: {e}"
|
||||
)
|
||||
return []
|
||||
|
||||
devices = [d for d in ort.get_ep_devices() if d.ep_name == LIGHTER_ANE_EP_NAME]
|
||||
|
||||
return devices
|
||||
|
||||
|
||||
def is_arm64_platform() -> bool:
|
||||
"""Check if we're running on an ARM platform."""
|
||||
@@ -723,23 +689,6 @@ def get_optimized_runner(
|
||||
if rknn_path:
|
||||
return _record_runner(model_path, model_type, RKNNModelRunner(rknn_path))
|
||||
|
||||
if device != "CPU" and (ane_devices := get_lighter_ane_devices()):
|
||||
sess_options = get_ort_session_options(model_type) or ort.SessionOptions()
|
||||
sess_options.add_provider_for_devices(ane_devices, {})
|
||||
|
||||
try:
|
||||
session = ort.InferenceSession(model_path, sess_options=sess_options)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to load {model_path} on the Neural Engine, using the default providers: {e}"
|
||||
)
|
||||
else:
|
||||
return _record_runner(
|
||||
model_path,
|
||||
model_type,
|
||||
ONNXModelRunner(session, model_type=model_type),
|
||||
)
|
||||
|
||||
providers, options = get_ort_providers(device == "CPU", device, **kwargs)
|
||||
|
||||
if providers[0] == "CPUExecutionProvider":
|
||||
|
||||
@@ -47,17 +47,21 @@ class ModelTypeEnum(str, Enum):
|
||||
yologeneric = "yolo-generic"
|
||||
|
||||
|
||||
# the scene of the model used by cameras that don't name one
|
||||
DEFAULT_SCENE = "default"
|
||||
SCENE_PATTERN = r"^[A-Za-z0-9_-]+$"
|
||||
class SceneEnum(str, Enum):
|
||||
"""The camera environment a detection model is intended for."""
|
||||
|
||||
all = "all"
|
||||
indoor = "indoor"
|
||||
outdoor = "outdoor"
|
||||
indoor_thermal = "indoor_thermal"
|
||||
outdoor_thermal = "outdoor_thermal"
|
||||
|
||||
|
||||
class ModelConfig(BaseModel):
|
||||
scene: str = Field(
|
||||
default=DEFAULT_SCENE,
|
||||
pattern=SCENE_PATTERN,
|
||||
scene: SceneEnum = Field(
|
||||
default=SceneEnum.all,
|
||||
title="Model scene",
|
||||
description="A name for the camera environment this model is used for, such as 'thermal'. Cameras select a model by setting detect.scene to a matching value, and the 'default' model is used by any camera that does not set one.",
|
||||
description="The camera environment this model is used for. Cameras select a model by setting detect.scene to a matching value, and 'all' is used by any camera that does not set one.",
|
||||
)
|
||||
devices: list[str] = Field(
|
||||
default_factory=list,
|
||||
@@ -119,7 +123,6 @@ class ModelConfig(BaseModel):
|
||||
_all_attributes: list[str] = PrivateAttr()
|
||||
_all_attribute_logos: list[str] = PrivateAttr()
|
||||
_model_hash: str = PrivateAttr()
|
||||
_plus_id: str | None = PrivateAttr(default=None)
|
||||
|
||||
@property
|
||||
def merged_labelmap(self) -> dict[int, str]:
|
||||
@@ -145,11 +148,6 @@ class ModelConfig(BaseModel):
|
||||
def model_hash(self) -> str:
|
||||
return self._model_hash
|
||||
|
||||
@property
|
||||
def plus_id(self) -> str | None:
|
||||
"""The Frigate+ model id, once a plus:// path has been resolved."""
|
||||
return self._plus_id
|
||||
|
||||
def __init__(self, **config):
|
||||
super().__init__(**config)
|
||||
|
||||
@@ -180,7 +178,6 @@ class ModelConfig(BaseModel):
|
||||
os.makedirs(MODEL_CACHE_DIR, exist_ok=True)
|
||||
|
||||
model_id = self.path[7:]
|
||||
self._plus_id = model_id
|
||||
self.path = os.path.join(MODEL_CACHE_DIR, model_id)
|
||||
model_info_path = f"{self.path}.json"
|
||||
|
||||
|
||||
@@ -25,7 +25,6 @@ SYS_ROOT = "/sys"
|
||||
DEV_ROOT = "/dev"
|
||||
PROC_ROOT = "/proc"
|
||||
ETC_ROOT = "/etc"
|
||||
LIB_ROOT = "/usr/lib"
|
||||
|
||||
# a Coral reports as Global Unichip until its firmware is loaded, then as Google
|
||||
CORAL_USB_IDS = {("1a6e", "089a"), ("18d1", "9302")}
|
||||
@@ -274,16 +273,6 @@ 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")
|
||||
@@ -318,19 +307,6 @@ def detect_synaptics() -> DetectionHardware | None:
|
||||
return _hardware("synaptics", "synaptics", "Synaptics NPU", units)
|
||||
|
||||
|
||||
def detect_lighter_ane() -> DetectionHardware | None:
|
||||
"""Find a Mac's Neural Engine by the provider library lighter's device places."""
|
||||
library = os.environ.get(
|
||||
"LIGHTER_ANE_EP", f"{LIB_ROOT}/lighter/liblighter_ane_ep.so"
|
||||
)
|
||||
if not os.path.exists(library):
|
||||
return None
|
||||
|
||||
# runs through onnx, whose session picks lighter's provider when it is present
|
||||
units = [HardwareUnit(device="onnx", label="Neural Engine")]
|
||||
return _hardware("onnx:lighter", "onnx", "Apple Neural Engine", units)
|
||||
|
||||
|
||||
def detect_cpu() -> DetectionHardware:
|
||||
"""The CPU, which is always available."""
|
||||
units = [HardwareUnit(device="cpu", label="CPU")]
|
||||
@@ -343,7 +319,6 @@ PROBES = (
|
||||
detect_coral_usb,
|
||||
detect_hailo,
|
||||
detect_memryx,
|
||||
detect_deepx,
|
||||
detect_intel_npu,
|
||||
detect_intel_gpu,
|
||||
detect_nvidia_gpu,
|
||||
@@ -352,7 +327,6 @@ PROBES = (
|
||||
detect_rockchip,
|
||||
detect_axengine,
|
||||
detect_synaptics,
|
||||
detect_lighter_ane,
|
||||
detect_cpu,
|
||||
)
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, ClassVar, Literal
|
||||
from typing import Any, Literal
|
||||
|
||||
import numpy as np
|
||||
import zmq
|
||||
@@ -21,7 +21,6 @@ class ZmqDetectorConfig(BaseDetectorConfig):
|
||||
model_config = ConfigDict(
|
||||
title="ZMQ IPC",
|
||||
)
|
||||
device_spec_field: ClassVar[str] = "endpoint"
|
||||
|
||||
type: Literal[DETECTOR_KEY]
|
||||
endpoint: str = Field(
|
||||
|
||||
@@ -6,10 +6,9 @@ import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
from peewee import DatabaseError, DoesNotExist, IntegrityError
|
||||
from peewee import DoesNotExist, IntegrityError
|
||||
from PIL import Image
|
||||
from playhouse.shortcuts import model_to_dict
|
||||
|
||||
@@ -208,10 +207,12 @@ class Embeddings:
|
||||
embedding = self.vision_embedding([thumbnail])[0]
|
||||
|
||||
if upsert:
|
||||
self.db.upsert_embeddings(
|
||||
"vec_thumbnails",
|
||||
"thumbnail_embedding",
|
||||
{event_id: serialize(embedding)},
|
||||
self.db.execute_sql(
|
||||
"""
|
||||
INSERT OR REPLACE INTO vec_thumbnails(id, thumbnail_embedding)
|
||||
VALUES(?, ?)
|
||||
""",
|
||||
(event_id, serialize(embedding)),
|
||||
)
|
||||
|
||||
self.image_inference_speed.update(datetime.datetime.now().timestamp() - start)
|
||||
@@ -250,12 +251,19 @@ class Embeddings:
|
||||
embeddings = self.vision_embedding(valid_thumbs)
|
||||
|
||||
if upsert:
|
||||
items = {}
|
||||
items = []
|
||||
for i in range(len(valid_ids)):
|
||||
items[valid_ids[i]] = serialize(embeddings[i])
|
||||
items.append(valid_ids[i])
|
||||
items.append(serialize(embeddings[i]))
|
||||
self.image_eps.update()
|
||||
|
||||
self.db.upsert_embeddings("vec_thumbnails", "thumbnail_embedding", items)
|
||||
self.db.execute_sql(
|
||||
"""
|
||||
INSERT OR REPLACE INTO vec_thumbnails(id, thumbnail_embedding)
|
||||
VALUES {}
|
||||
""".format(", ".join(["(?, ?)"] * len(valid_ids))),
|
||||
items,
|
||||
)
|
||||
|
||||
duration = datetime.datetime.now().timestamp() - start
|
||||
self.image_inference_speed.update(duration / len(valid_ids))
|
||||
@@ -269,10 +277,12 @@ class Embeddings:
|
||||
embedding = self.text_embedding([description])[0]
|
||||
|
||||
if upsert:
|
||||
self.db.upsert_embeddings(
|
||||
"vec_descriptions",
|
||||
"description_embedding",
|
||||
{event_id: serialize(embedding)},
|
||||
self.db.execute_sql(
|
||||
"""
|
||||
INSERT OR REPLACE INTO vec_descriptions(id, description_embedding)
|
||||
VALUES(?, ?)
|
||||
""",
|
||||
(event_id, serialize(embedding)),
|
||||
)
|
||||
|
||||
self.text_inference_speed.update(datetime.datetime.now().timestamp() - start)
|
||||
@@ -292,14 +302,19 @@ class Embeddings:
|
||||
|
||||
if upsert:
|
||||
ids = list(event_descriptions.keys())
|
||||
items = {}
|
||||
items = []
|
||||
|
||||
for i in range(len(ids)):
|
||||
items[ids[i]] = serialize(embeddings[i])
|
||||
items.append(ids[i])
|
||||
items.append(serialize(embeddings[i]))
|
||||
self.text_eps.update()
|
||||
|
||||
self.db.upsert_embeddings(
|
||||
"vec_descriptions", "description_embedding", items
|
||||
self.db.execute_sql(
|
||||
"""
|
||||
INSERT OR REPLACE INTO vec_descriptions(id, description_embedding)
|
||||
VALUES {}
|
||||
""".format(", ".join(["(?, ?)"] * len(ids))),
|
||||
items,
|
||||
)
|
||||
|
||||
self.text_inference_speed.update(datetime.datetime.now().timestamp() - start)
|
||||
@@ -307,17 +322,6 @@ class Embeddings:
|
||||
return embeddings
|
||||
|
||||
def reindex(self) -> None:
|
||||
"""Rebuild every tracked object embedding from scratch."""
|
||||
totals: dict[str, Any] = {"status": "indexing"}
|
||||
|
||||
try:
|
||||
self._reindex(totals)
|
||||
except DatabaseError:
|
||||
logger.exception("Unable to reindex tracked object embeddings")
|
||||
totals["status"] = "failed"
|
||||
self.requestor.send_data(UPDATE_EMBEDDINGS_REINDEX_PROGRESS, totals)
|
||||
|
||||
def _reindex(self, totals: dict[str, Any]) -> None:
|
||||
logger.info("Indexing tracked object embeddings...")
|
||||
|
||||
self.db.drop_embeddings_tables()
|
||||
@@ -342,24 +346,17 @@ class Embeddings:
|
||||
batch_size = 32
|
||||
current_page = 1
|
||||
|
||||
totals.update(
|
||||
{
|
||||
"thumbnails": 0,
|
||||
"descriptions": 0,
|
||||
"processed_objects": total_events - 1
|
||||
if total_events < batch_size
|
||||
else 0,
|
||||
"total_objects": total_events,
|
||||
"time_remaining": 0 if total_events < batch_size else -1,
|
||||
"status": "indexing",
|
||||
}
|
||||
)
|
||||
totals = {
|
||||
"thumbnails": 0,
|
||||
"descriptions": 0,
|
||||
"processed_objects": total_events - 1 if total_events < batch_size else 0,
|
||||
"total_objects": total_events,
|
||||
"time_remaining": 0 if total_events < batch_size else -1,
|
||||
"status": "indexing",
|
||||
}
|
||||
|
||||
self.requestor.send_data(UPDATE_EMBEDDINGS_REINDEX_PROGRESS, totals)
|
||||
|
||||
# a single batch sends no progress, so the first message shows it nearly done
|
||||
totals["processed_objects"] = 0
|
||||
|
||||
events = (
|
||||
Event.select()
|
||||
.order_by(Event.start_time.desc())
|
||||
|
||||
@@ -11,11 +11,7 @@ from typing import Any
|
||||
from peewee import DoesNotExist
|
||||
|
||||
from frigate.comms.config_updater import ConfigSubscriber
|
||||
from frigate.comms.detections_updater import (
|
||||
DetectionPublisher,
|
||||
DetectionSubscriber,
|
||||
DetectionTypeEnum,
|
||||
)
|
||||
from frigate.comms.detections_updater import DetectionSubscriber, DetectionTypeEnum
|
||||
from frigate.comms.embeddings_updater import (
|
||||
EmbeddingsRequestEnum,
|
||||
EmbeddingsResponder,
|
||||
@@ -72,7 +68,7 @@ from frigate.events.types import (
|
||||
RegenerateDescriptionEnum,
|
||||
)
|
||||
from frigate.genai import GenAIClientManager
|
||||
from frigate.models import Event, Recordings, ReviewSegment, Timeline, Trigger
|
||||
from frigate.models import Event, Recordings, ReviewSegment, Trigger
|
||||
from frigate.types import TrackedObjectUpdateTypesEnum
|
||||
from frigate.util.builtin import serialize
|
||||
from frigate.util.file import get_event_thumbnail_bytes
|
||||
@@ -143,7 +139,7 @@ class EmbeddingMaintainer(threading.Thread):
|
||||
),
|
||||
load_vec_extension=True,
|
||||
)
|
||||
models = [Event, Recordings, ReviewSegment, Timeline, Trigger]
|
||||
models = [Event, Recordings, ReviewSegment, Trigger]
|
||||
db.bind(models)
|
||||
|
||||
self.genai_manager = GenAIClientManager(config)
|
||||
@@ -172,7 +168,6 @@ class EmbeddingMaintainer(threading.Thread):
|
||||
)
|
||||
self.review_subscriber = ReviewDataSubscriber("")
|
||||
self.detection_subscriber = DetectionSubscriber(DetectionTypeEnum.video.value)
|
||||
self.detection_publisher = DetectionPublisher(DetectionTypeEnum.all.value)
|
||||
self.embeddings_responder = EmbeddingsResponder()
|
||||
self.frame_manager = SharedMemoryFrameManager()
|
||||
|
||||
@@ -256,11 +251,7 @@ class EmbeddingMaintainer(threading.Thread):
|
||||
):
|
||||
self.post_processors.append(
|
||||
AudioTranscriptionPostProcessor(
|
||||
self.config,
|
||||
self.requestor,
|
||||
self.embeddings,
|
||||
metrics,
|
||||
self.genai_manager,
|
||||
self.config, self.requestor, self.embeddings, metrics
|
||||
)
|
||||
)
|
||||
|
||||
@@ -361,7 +352,6 @@ class EmbeddingMaintainer(threading.Thread):
|
||||
self.event_end_subscriber.stop()
|
||||
self.recordings_subscriber.stop()
|
||||
self.detection_subscriber.stop()
|
||||
self.detection_publisher.stop()
|
||||
self.event_metadata_publisher.stop()
|
||||
self.event_metadata_subscriber.stop()
|
||||
self.embeddings_responder.stop()
|
||||
@@ -857,21 +847,6 @@ class EmbeddingMaintainer(threading.Thread):
|
||||
f"{result['camera']}/classification/{result['model_name']}",
|
||||
result["state"],
|
||||
)
|
||||
|
||||
# the first state verified after startup is not a change
|
||||
if result["previous_state"] is not None:
|
||||
self.detection_publisher.publish(
|
||||
(
|
||||
result["camera"],
|
||||
{
|
||||
"model": result["model_name"],
|
||||
"from": result["previous_state"],
|
||||
"to": result["state"],
|
||||
"timestamp": result["timestamp"],
|
||||
},
|
||||
),
|
||||
DetectionTypeEnum.classification_state.value,
|
||||
)
|
||||
elif result["processor"] == "object":
|
||||
object_id = result["object_id"]
|
||||
camera = result["camera"]
|
||||
|
||||
+38
-71
@@ -11,7 +11,6 @@ from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from frigate.camera import CameraMetrics
|
||||
from frigate.comms.detections_updater import DetectionPublisher, DetectionTypeEnum
|
||||
from frigate.comms.inter_process import InterProcessRequestor
|
||||
from frigate.config import CameraConfig, CameraInput, FrigateConfig
|
||||
@@ -20,7 +19,6 @@ from frigate.config.camera.updater import (
|
||||
CameraConfigUpdateEnum,
|
||||
CameraConfigUpdateSubscriber,
|
||||
)
|
||||
from frigate.config.classification import AudioTranscriptionModelEnum
|
||||
from frigate.const import (
|
||||
AUDIO_DURATION,
|
||||
AUDIO_FORMAT,
|
||||
@@ -37,7 +35,6 @@ from frigate.data_processing.common.audio_transcription.model import (
|
||||
from frigate.data_processing.real_time.audio_transcription import (
|
||||
AudioTranscriptionRealTimeProcessor,
|
||||
)
|
||||
from frigate.data_processing.types import DataProcessorMetrics
|
||||
from frigate.ffmpeg_presets import parse_preset_input
|
||||
from frigate.log import LogPipe, suppress_stderr_during
|
||||
from frigate.util.builtin import get_ffmpeg_arg_list, load_labels
|
||||
@@ -88,7 +85,6 @@ class AudioProcessor(FrigateProcess):
|
||||
self,
|
||||
config: FrigateConfig,
|
||||
camera_metrics: DictProxy,
|
||||
embeddings_metrics: DataProcessorMetrics,
|
||||
stop_event: MpEvent,
|
||||
):
|
||||
super().__init__(
|
||||
@@ -96,38 +92,8 @@ class AudioProcessor(FrigateProcess):
|
||||
)
|
||||
|
||||
self.camera_metrics = camera_metrics
|
||||
self.embeddings_metrics = embeddings_metrics
|
||||
self.config = config
|
||||
|
||||
def spawn_if_needed(self, camera: CameraConfig) -> None:
|
||||
"""Start an audio maintainer for the camera once everything it needs
|
||||
has arrived. Returning early leaves the camera for the next poll."""
|
||||
name = camera.name
|
||||
if name is None or name in self.audio_threads:
|
||||
return
|
||||
if not camera.enabled or not camera.audio.enabled:
|
||||
return
|
||||
# ffmpeg update may not have arrived yet
|
||||
if not any("audio" in i.roles for i in camera.ffmpeg.inputs):
|
||||
return
|
||||
# the camera maintainer creates metrics on its own poll of the same
|
||||
# add update and may not have gotten there yet
|
||||
metrics = self.camera_metrics.get(name)
|
||||
if metrics is None:
|
||||
return
|
||||
thread = AudioEventMaintainer(
|
||||
camera,
|
||||
self.config,
|
||||
metrics,
|
||||
self.embeddings_metrics,
|
||||
self.transcription_model_runner,
|
||||
self.stop_event, # type: ignore[arg-type]
|
||||
self.genai_manager,
|
||||
)
|
||||
self.audio_threads[name] = thread
|
||||
thread.start()
|
||||
self.logger.info(f"Audio maintainer started for {name}")
|
||||
|
||||
def __stop_audio_thread(self, camera: str) -> None:
|
||||
thread = self.audio_threads.pop(camera, None)
|
||||
if thread is None:
|
||||
@@ -146,31 +112,18 @@ class AudioProcessor(FrigateProcess):
|
||||
|
||||
threading.current_thread().name = "process:audio_manager"
|
||||
|
||||
self.transcription_model_runner: AudioTranscriptionModelRunner | None = None
|
||||
self.genai_manager: Any = None
|
||||
|
||||
if any(
|
||||
c.enabled_in_config and c.audio_transcription.enabled
|
||||
for c in self.config.cameras.values()
|
||||
):
|
||||
if isinstance(
|
||||
self.config.audio_transcription.model, AudioTranscriptionModelEnum
|
||||
):
|
||||
# AudioTranscriptionModelRunner.__init__ unconditionally fetches
|
||||
# sherpa-onnx or whisper weights, so only build it on the local path
|
||||
self.transcription_model_runner = AudioTranscriptionModelRunner(
|
||||
self.transcription_model_runner: AudioTranscriptionModelRunner | None = (
|
||||
AudioTranscriptionModelRunner(
|
||||
self.config.audio_transcription.device or "AUTO",
|
||||
self.config.audio_transcription.model_size,
|
||||
)
|
||||
else:
|
||||
# imported here rather than at module scope: frigate.genai pulls in
|
||||
# numpy, the provider SDKs, frigate.models, and the prompt builders,
|
||||
# and this process runs at PROCESS_PRIORITY_HIGH. built after the
|
||||
# fork because SDK clients hold sockets and TLS state that must not
|
||||
# cross it; clients themselves stay lazy behind the role property.
|
||||
from frigate.genai.manager import GenAIClientManager
|
||||
|
||||
self.genai_manager = GenAIClientManager(self.config)
|
||||
)
|
||||
else:
|
||||
self.transcription_model_runner = None
|
||||
|
||||
config_subscriber = CameraConfigUpdateSubscriber(
|
||||
self.config,
|
||||
@@ -183,8 +136,28 @@ class AudioProcessor(FrigateProcess):
|
||||
],
|
||||
)
|
||||
|
||||
def spawn_if_needed(camera: CameraConfig) -> None:
|
||||
name = camera.name
|
||||
if name is None or name in self.audio_threads:
|
||||
return
|
||||
if not camera.enabled or not camera.audio.enabled:
|
||||
return
|
||||
# ffmpeg update may not have arrived yet; wait for next poll
|
||||
if not any("audio" in i.roles for i in camera.ffmpeg.inputs):
|
||||
return
|
||||
thread = AudioEventMaintainer(
|
||||
camera,
|
||||
self.config,
|
||||
self.camera_metrics,
|
||||
self.transcription_model_runner,
|
||||
self.stop_event, # type: ignore[arg-type]
|
||||
)
|
||||
self.audio_threads[name] = thread
|
||||
thread.start()
|
||||
self.logger.info(f"Audio maintainer started for {name}")
|
||||
|
||||
for camera in self.config.cameras.values():
|
||||
self.spawn_if_needed(camera)
|
||||
spawn_if_needed(camera)
|
||||
|
||||
self.logger.info(f"Audio processor started (pid: {self.pid})")
|
||||
|
||||
@@ -194,14 +167,15 @@ class AudioProcessor(FrigateProcess):
|
||||
updated_topics = config_subscriber.check_for_updates()
|
||||
|
||||
# stop maintainers for removed cameras so their ffmpeg process is
|
||||
# torn down
|
||||
# torn down and they stop touching camera_metrics (which the camera
|
||||
# maintainer has already popped for the removed camera)
|
||||
for removed_camera in updated_topics.get(
|
||||
CameraConfigUpdateEnum.remove.name, []
|
||||
):
|
||||
self.__stop_audio_thread(removed_camera)
|
||||
|
||||
for camera in self.config.cameras.values():
|
||||
self.spawn_if_needed(camera)
|
||||
spawn_if_needed(camera)
|
||||
|
||||
config_subscriber.stop()
|
||||
|
||||
@@ -223,21 +197,15 @@ class AudioEventMaintainer(threading.Thread):
|
||||
self,
|
||||
camera: CameraConfig,
|
||||
config: FrigateConfig,
|
||||
metrics: CameraMetrics,
|
||||
embeddings_metrics: DataProcessorMetrics,
|
||||
camera_metrics: DictProxy,
|
||||
audio_transcription_model_runner: AudioTranscriptionModelRunner | None,
|
||||
stop_event: threading.Event,
|
||||
genai_manager: Any = None,
|
||||
) -> None:
|
||||
super().__init__(name=f"{camera.name}_audio_event_processor")
|
||||
|
||||
self.config = config
|
||||
self.camera_config = camera
|
||||
# hold the metrics object rather than indexing the manager dict per
|
||||
# chunk, which costs an IPC round trip and breaks once the camera
|
||||
# maintainer pops the entry on removal
|
||||
self.metrics = metrics
|
||||
self.embeddings_metrics = embeddings_metrics
|
||||
self.camera_metrics = camera_metrics
|
||||
self.stop_event = stop_event
|
||||
# per-camera stop signal so a single maintainer can be torn down at
|
||||
# runtime (e.g. on camera removal) without stopping the whole process
|
||||
@@ -254,7 +222,6 @@ class AudioEventMaintainer(threading.Thread):
|
||||
self.logpipe = LogPipe(f"ffmpeg.{self.camera_config.name}.audio")
|
||||
self.audio_listener: subprocess.Popen[Any] | None = None
|
||||
self.audio_transcription_model_runner = audio_transcription_model_runner
|
||||
self.genai_manager = genai_manager
|
||||
self.transcription_processor = None
|
||||
self.transcription_thread = None
|
||||
|
||||
@@ -271,9 +238,9 @@ class AudioEventMaintainer(threading.Thread):
|
||||
)
|
||||
self.detection_publisher = DetectionPublisher(DetectionTypeEnum.audio.value)
|
||||
|
||||
if self.camera_config.audio_transcription.enabled and (
|
||||
self.audio_transcription_model_runner is not None
|
||||
or self.genai_manager is not None
|
||||
if (
|
||||
self.camera_config.audio_transcription.enabled
|
||||
and self.audio_transcription_model_runner is not None
|
||||
):
|
||||
# init the transcription processor for this camera
|
||||
self.transcription_processor = AudioTranscriptionRealTimeProcessor(
|
||||
@@ -281,9 +248,8 @@ class AudioEventMaintainer(threading.Thread):
|
||||
camera_config=self.camera_config,
|
||||
requestor=self.requestor,
|
||||
model_runner=self.audio_transcription_model_runner,
|
||||
metrics=self.embeddings_metrics,
|
||||
metrics=self.camera_metrics[self.camera_config.name],
|
||||
stop_event=self.stop_event,
|
||||
genai_manager=self.genai_manager,
|
||||
)
|
||||
|
||||
self.transcription_thread = threading.Thread(
|
||||
@@ -307,8 +273,8 @@ class AudioEventMaintainer(threading.Thread):
|
||||
audio_as_float: np.ndarray = audio.astype(np.float32)
|
||||
rms, dBFS = self.calculate_audio_levels(audio_as_float)
|
||||
|
||||
self.metrics.audio_rms.value = rms
|
||||
self.metrics.audio_dBFS.value = dBFS
|
||||
self.camera_metrics[self.camera_config.name].audio_rms.value = rms
|
||||
self.camera_metrics[self.camera_config.name].audio_dBFS.value = dBFS
|
||||
|
||||
audio_detections: list[tuple[str, float]] = []
|
||||
|
||||
@@ -393,6 +359,7 @@ class AudioEventMaintainer(threading.Thread):
|
||||
return
|
||||
|
||||
time.sleep(self.camera_config.ffmpeg.retry_interval)
|
||||
self.logpipe.dump()
|
||||
self.start_or_restart_ffmpeg()
|
||||
|
||||
if self.audio_listener is None or self.audio_listener.stdout is None:
|
||||
|
||||
@@ -365,7 +365,6 @@ class EventCleanup(threading.Thread):
|
||||
chunk = ids_to_delete[i : i + CHUNK_SIZE]
|
||||
logger.debug(f"Deleting {len(chunk)} events from the database")
|
||||
Event.delete().where(Event.id << chunk).execute()
|
||||
Timeline.delete().where(Timeline.source_id << chunk).execute()
|
||||
|
||||
# embeddings are always cleaned up, even when semantic search
|
||||
# is disabled, so that they don't outlive their events
|
||||
|
||||
@@ -84,8 +84,6 @@ _user_agent_args = [
|
||||
PRESETS_HW_ACCEL_DECODE = {
|
||||
"preset-rpi-64-h264": "-c:v:1 h264_v4l2m2m",
|
||||
"preset-rpi-64-h265": "-c:v:1 hevc_v4l2m2m",
|
||||
"preset-apple-silicon-h264": "-c:v h264_v4l2m2m",
|
||||
"preset-apple-silicon-h265": "-c:v hevc_v4l2m2m",
|
||||
FFMPEG_HWACCEL_VAAPI: "-hwaccel_flags allow_profile_mismatch -hwaccel vaapi -hwaccel_device {3} -hwaccel_output_format vaapi",
|
||||
"preset-intel-qsv-h264": f"-hwaccel qsv -qsv_device {{3}} -hwaccel_output_format qsv -c:v h264_qsv{' -bsf:v dump_extra' if LIBAVFORMAT_VERSION_MAJOR >= 61 else ''}", # https://trac.ffmpeg.org/ticket/9766#comment:17
|
||||
"preset-intel-qsv-h265": f"-load_plugin hevc_hw -hwaccel qsv -qsv_device {{3}} -hwaccel_output_format qsv{' -bsf:v dump_extra' if LIBAVFORMAT_VERSION_MAJOR >= 61 else ''}", # https://trac.ffmpeg.org/ticket/9766#comment:17
|
||||
@@ -122,12 +120,9 @@ PRESETS_HW_ACCEL_DECODE["preset-rk-h265"] = PRESETS_HW_ACCEL_DECODE[
|
||||
PRESETS_HW_ACCEL_SCALE = {
|
||||
"preset-rpi-64-h264": "-r {0} -vf fps={0},scale={1}:{2}",
|
||||
"preset-rpi-64-h265": "-r {0} -vf fps={0},scale={1}:{2}",
|
||||
# ffmpeg's v4l2m2m decoders cannot scale, so frames are scaled on the CPU
|
||||
"preset-apple-silicon-h264": "-r {0} -vf fps={0},scale={1}:{2}",
|
||||
"preset-apple-silicon-h265": "-r {0} -vf fps={0},scale={1}:{2}",
|
||||
FFMPEG_HWACCEL_VAAPI: "-r {0} -vf fps={0},scale_vaapi=w={1}:h={2},hwdownload,format=nv12",
|
||||
"preset-intel-qsv-h264": "-r {0} -vf fps={0},vpp_qsv=w={1}:h={2}:format=nv12,hwdownload,format=nv12,format=yuv420p",
|
||||
"preset-intel-qsv-h265": "-r {0} -vf fps={0},vpp_qsv=w={1}:h={2}:format=nv12,hwdownload,format=nv12,format=yuv420p",
|
||||
"preset-intel-qsv-h264": "-r {0} -vf vpp_qsv=w={1}:h={2}:format=nv12,hwdownload,format=nv12,fps={0},format=yuv420p",
|
||||
"preset-intel-qsv-h265": "-r {0} -vf vpp_qsv=w={1}:h={2}:format=nv12,hwdownload,format=nv12,fps={0},format=yuv420p",
|
||||
FFMPEG_HWACCEL_NVIDIA: "-r {0} -vf fps={0},scale_cuda=w={1}:h={2},hwdownload,format=nv12",
|
||||
"preset-jetson-h264": "-r {0}", # scaled in decoder
|
||||
"preset-jetson-h265": "-r {0}", # scaled in decoder
|
||||
@@ -155,8 +150,6 @@ PRESETS_HW_ACCEL_SCALE["preset-rk-h265"] = PRESETS_HW_ACCEL_SCALE[FFMPEG_HWACCEL
|
||||
PRESETS_HW_ACCEL_ENCODE_BIRDSEYE = {
|
||||
"preset-rpi-64-h264": "{0} -hide_banner {1} -c:v h264_v4l2m2m {2}",
|
||||
"preset-rpi-64-h265": "{0} -hide_banner {1} -c:v hevc_v4l2m2m {2}",
|
||||
"preset-apple-silicon-h264": "{0} -hide_banner {1} -c:v h264_v4l2m2m {2}",
|
||||
"preset-apple-silicon-h265": "{0} -hide_banner {1} -c:v h264_v4l2m2m {2}",
|
||||
# -vaapi_device is required in addition to -hwaccel_device: this is the only
|
||||
# birdseye preset that uses hwupload, and ffmpeg 8 initializes filters before
|
||||
# the decoder creates a device, so hwupload cannot see an -hwaccel_device one.
|
||||
@@ -191,8 +184,6 @@ PRESETS_HW_ACCEL_ENCODE_BIRDSEYE["preset-rk-h264"] = PRESETS_HW_ACCEL_ENCODE_BIR
|
||||
PRESETS_HW_ACCEL_ENCODE_TIMELAPSE = {
|
||||
"preset-rpi-64-h264": "{0} -hide_banner {1} -c:v h264_v4l2m2m -pix_fmt yuv420p {2}",
|
||||
"preset-rpi-64-h265": "{0} -hide_banner {1} -c:v hevc_v4l2m2m -pix_fmt yuv420p {2}",
|
||||
"preset-apple-silicon-h264": "{0} -hide_banner {1} -c:v h264_v4l2m2m -pix_fmt yuv420p {2}",
|
||||
"preset-apple-silicon-h265": "{0} -hide_banner {1} -c:v hevc_v4l2m2m -pix_fmt yuv420p {2}",
|
||||
FFMPEG_HWACCEL_VAAPI: "{0} -hide_banner -hwaccel vaapi -hwaccel_output_format vaapi -hwaccel_device {3} {1} -c:v h264_vaapi {2}",
|
||||
"preset-intel-qsv-h264": "{0} -hide_banner {1} -c:v h264_qsv -profile:v high -level:v 4.1 -async_depth:v 1 {2}",
|
||||
"preset-intel-qsv-h265": "{0} -hide_banner {1} -c:v hevc_qsv -profile:v main -level:v 4.1 -async_depth:v 1 {2}",
|
||||
|
||||
@@ -105,21 +105,8 @@ class GenAIClient:
|
||||
debug_save: bool,
|
||||
activity_context_prompt: str,
|
||||
response_style: str = "default",
|
||||
frame_captions: list[str] | None = None,
|
||||
) -> ReviewMetadata | None:
|
||||
"""Generate a description for the review item activity.
|
||||
|
||||
`frame_captions` holds one caption per thumbnail for the annotated
|
||||
frame mode; each is sent directly before its frame.
|
||||
"""
|
||||
if frame_captions and len(frame_captions) != len(thumbnails):
|
||||
logger.warning(
|
||||
"Got %d frame captions for %d thumbnails, sending plain frames",
|
||||
len(frame_captions),
|
||||
len(thumbnails),
|
||||
)
|
||||
frame_captions = None
|
||||
|
||||
"""Generate a description for the review item activity."""
|
||||
context_prompt = build_review_description_prompt(
|
||||
review_data,
|
||||
thumbnails,
|
||||
@@ -127,7 +114,6 @@ class GenAIClient:
|
||||
preferred_language,
|
||||
activity_context_prompt,
|
||||
response_style,
|
||||
frame_captions,
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
@@ -143,30 +129,9 @@ class GenAIClient:
|
||||
) as f:
|
||||
f.write(context_prompt)
|
||||
|
||||
if frame_captions:
|
||||
# One file per frame, numbered to match the image it precedes
|
||||
# (0.txt goes with 0.jpg), so the debug folder replays without
|
||||
# having to re-derive the mapping.
|
||||
for index, caption in enumerate(frame_captions):
|
||||
with open(
|
||||
os.path.join(
|
||||
CLIPS_DIR,
|
||||
"genai-requests",
|
||||
review_data["id"],
|
||||
f"{index}.txt",
|
||||
),
|
||||
"w",
|
||||
) as f:
|
||||
f.write(caption)
|
||||
|
||||
response_format = build_review_description_response_format(concerns)
|
||||
|
||||
response = self._send(
|
||||
context_prompt,
|
||||
thumbnails,
|
||||
response_format,
|
||||
image_captions=frame_captions,
|
||||
)
|
||||
response = self._send(context_prompt, thumbnails, response_format)
|
||||
|
||||
if debug_save and response:
|
||||
with open(
|
||||
@@ -304,7 +269,6 @@ class GenAIClient:
|
||||
images: list[bytes],
|
||||
response_format: dict | None = None,
|
||||
enable_thinking: bool = False,
|
||||
image_captions: list[str] | None = None,
|
||||
) -> str | None:
|
||||
"""Submit a request to the provider.
|
||||
|
||||
@@ -312,10 +276,6 @@ class GenAIClient:
|
||||
``supports_toggleable_thinking``. Description-style callers leave it
|
||||
at the default (off) since synthesis tasks don't benefit from
|
||||
reasoning traces.
|
||||
|
||||
``image_captions`` carries one caption per image, to be placed
|
||||
immediately before its image so the model can tell the frames apart.
|
||||
Providers build their request order with ``interleave_images``.
|
||||
"""
|
||||
return None
|
||||
|
||||
@@ -338,11 +298,6 @@ class GenAIClient:
|
||||
"""Whether the configured model can generate embeddings via embed()."""
|
||||
return False
|
||||
|
||||
@property
|
||||
def supports_transcription(self) -> bool:
|
||||
"""Whether the configured model can transcribe audio via transcribe()."""
|
||||
return False
|
||||
|
||||
def list_models(self) -> list[str]:
|
||||
"""Return the list of model names available from this provider.
|
||||
|
||||
@@ -350,21 +305,6 @@ class GenAIClient:
|
||||
"""
|
||||
return []
|
||||
|
||||
def list_model_capabilities(self) -> dict[str, dict[str, bool]]:
|
||||
"""Return capability flags for each model the provider serves.
|
||||
|
||||
Only providers whose backend advertises capabilities per model can
|
||||
populate this; llama.cpp reports input modalities for every model it
|
||||
serves, so one request describes them all. An empty mapping means "no
|
||||
per-model information available", and callers fall back to this
|
||||
client's own capability properties, which describe only the configured
|
||||
model. A model absent from a non-empty mapping means the same thing.
|
||||
|
||||
Returns:
|
||||
Model name (including aliases) to its capability flags
|
||||
"""
|
||||
return {}
|
||||
|
||||
def get_context_size(self) -> int:
|
||||
"""Get the context window size for this provider in tokens."""
|
||||
return 4096
|
||||
@@ -396,33 +336,6 @@ class GenAIClient:
|
||||
)
|
||||
return []
|
||||
|
||||
def transcribe(
|
||||
self,
|
||||
audio: bytes,
|
||||
language: str | None = None,
|
||||
mime_type: str = "audio/wav",
|
||||
) -> str | None:
|
||||
"""Transcribe speech audio to text.
|
||||
|
||||
Audio is passed as a self-describing blob rather than raw samples so
|
||||
every provider receives a container it can declare, and WAV framing
|
||||
lives in one place instead of in each plugin.
|
||||
|
||||
Args:
|
||||
audio: The encoded audio payload (WAV bytes by default)
|
||||
language: Optional ISO language hint for the provider
|
||||
mime_type: Media type of ``audio``
|
||||
|
||||
Returns:
|
||||
The transcript, or None when the provider cannot produce one
|
||||
"""
|
||||
logger.warning(
|
||||
"%s does not support transcription. "
|
||||
"This method should be overridden by the provider implementation.",
|
||||
self.__class__.__name__,
|
||||
)
|
||||
return None
|
||||
|
||||
def chat_with_tools(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
|
||||
@@ -110,12 +110,6 @@ class GenAIClientManager:
|
||||
name = self._role_map.get(GenAIRoleEnum.embeddings)
|
||||
return self._get_client(name) if name else None
|
||||
|
||||
@property
|
||||
def transcribe_client(self) -> "GenAIClient | None":
|
||||
"""Client configured for the transcribe role."""
|
||||
name = self._role_map.get(GenAIRoleEnum.transcribe)
|
||||
return self._get_client(name) if name else None
|
||||
|
||||
def role_info(self) -> dict[str, dict[str, Any]]:
|
||||
"""Return the model selected for each configured role and its context size.
|
||||
|
||||
@@ -150,11 +144,5 @@ class GenAIClientManager:
|
||||
"roles": [r.value for r in genai_cfg.roles],
|
||||
"supports_toggleable_thinking": client.supports_toggleable_thinking,
|
||||
"supports_embeddings": client.supports_embeddings,
|
||||
"supports_transcription": client.supports_transcription,
|
||||
# Capabilities of the configured model are above; this maps every
|
||||
# model the provider serves to its own, so the UI can react to a
|
||||
# model selected but not yet saved. Empty when the provider
|
||||
# cannot report capabilities without loading a model.
|
||||
"model_capabilities": client.list_model_capabilities(),
|
||||
}
|
||||
return result
|
||||
|
||||
@@ -13,19 +13,6 @@ overrides what is genuinely Azure-specific:
|
||||
- Context size: Azure does not expose a per-model ``max_model_len`` field
|
||||
reliably, so we keep the historical 128K default rather than the
|
||||
model-name heuristic used by OpenAI.
|
||||
|
||||
Transcription is inherited too: :class:`openai.AzureOpenAI` exposes the same
|
||||
``audio.transcriptions.create``. Two Azure-specific caveats apply when using
|
||||
the ``transcribe`` role:
|
||||
|
||||
- ``model`` must be the Azure *deployment* name, not the underlying model name.
|
||||
- The ``api-version`` parsed from ``base_url`` must be 2024-06-01 or later;
|
||||
earlier versions have no transcriptions route and the 404 surfaces only as a
|
||||
generic provider error.
|
||||
- Because ``model`` is a deployment name, the inherited check that picks
|
||||
``languages`` over ``language`` for gpt-transcribe cannot fire unless the
|
||||
deployment happens to be named after the model. Name the deployment
|
||||
``gpt-transcribe`` to get the right field, or leave the language on ``auto``.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
@@ -13,14 +13,9 @@ from google.genai.types import FunctionCallingConfigMode
|
||||
|
||||
from frigate.config import GenAIProviderEnum
|
||||
from frigate.genai import GenAIClient, register_genai_provider
|
||||
from frigate.genai.utils import interleave_images
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Gemini requests carrying inline data are capped at ~20 MB total; stay well
|
||||
# under it so the request fails as a log line rather than a 400.
|
||||
GEMINI_MAX_INLINE_BYTES = 15 * 1024 * 1024
|
||||
|
||||
|
||||
def _decode_thought_signature(value: Any) -> bytes | None:
|
||||
"""Decode a base64-encoded thought_signature carried across conversation turns."""
|
||||
@@ -123,16 +118,11 @@ class GeminiClient(GenAIClient):
|
||||
images: list[bytes],
|
||||
response_format: dict | None = None,
|
||||
enable_thinking: bool = False,
|
||||
image_captions: list[str] | None = None,
|
||||
) -> str | None:
|
||||
"""Submit a request to Gemini."""
|
||||
contents: list[Any] = [
|
||||
part
|
||||
if isinstance(part, str)
|
||||
else types.Part.from_bytes(data=part, mime_type="image/jpeg")
|
||||
for part in interleave_images(prompt, images, image_captions)
|
||||
contents = [prompt] + [
|
||||
types.Part.from_bytes(data=img, mime_type="image/jpeg") for img in images
|
||||
]
|
||||
|
||||
try:
|
||||
# Merge runtime_options into generation_config if provided
|
||||
generation_config_dict: dict[str, Any] = {"candidate_count": 1}
|
||||
@@ -146,7 +136,7 @@ class GeminiClient(GenAIClient):
|
||||
|
||||
response = self.provider.models.generate_content(
|
||||
model=self.genai_config.model,
|
||||
contents=contents,
|
||||
contents=contents, # type: ignore[arg-type]
|
||||
config=types.GenerateContentConfig(
|
||||
**generation_config_dict,
|
||||
),
|
||||
@@ -167,58 +157,6 @@ class GeminiClient(GenAIClient):
|
||||
return None
|
||||
return description
|
||||
|
||||
@property
|
||||
def supports_transcription(self) -> bool:
|
||||
"""Gemini models accept inline audio parts."""
|
||||
return True
|
||||
|
||||
def transcribe(
|
||||
self,
|
||||
audio: bytes,
|
||||
language: str | None = None,
|
||||
mime_type: str = "audio/wav",
|
||||
) -> str | None:
|
||||
"""Transcribe audio by sending it as an inline part alongside a prompt."""
|
||||
if len(audio) > GEMINI_MAX_INLINE_BYTES:
|
||||
logger.warning(
|
||||
"Audio payload of %d bytes exceeds the Gemini inline limit; skipping transcription",
|
||||
len(audio),
|
||||
)
|
||||
return None
|
||||
|
||||
prompt = "Transcribe the speech in this audio verbatim. Respond with the transcript only, and with nothing at all if there is no speech."
|
||||
|
||||
if language:
|
||||
prompt += f" The speech is in language '{language}'."
|
||||
|
||||
try:
|
||||
contents: list[Any] = [
|
||||
prompt,
|
||||
types.Part.from_bytes(data=audio, mime_type=mime_type),
|
||||
]
|
||||
response = self.provider.models.generate_content(
|
||||
model=self.genai_config.model,
|
||||
contents=contents,
|
||||
config=types.GenerateContentConfig(candidate_count=1),
|
||||
)
|
||||
except errors.APIError as e:
|
||||
logger.warning("Gemini returned an error: %s", str(e))
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning("An unexpected error occurred with Gemini: %s", str(e))
|
||||
return None
|
||||
|
||||
try:
|
||||
if response.text is None:
|
||||
return None
|
||||
|
||||
transcript = response.text.strip()
|
||||
except (ValueError, AttributeError):
|
||||
# No transcript was generated
|
||||
return None
|
||||
|
||||
return transcript or None
|
||||
|
||||
def list_models(self) -> list[str]:
|
||||
"""Return available model names from Gemini."""
|
||||
try:
|
||||
|
||||
+104
-216
@@ -14,7 +14,7 @@ from PIL import Image
|
||||
|
||||
from frigate.config import GenAIProviderEnum
|
||||
from frigate.genai import GenAIClient, register_genai_provider
|
||||
from frigate.genai.utils import interleave_images, parse_tool_calls_from_message
|
||||
from frigate.genai.utils import parse_tool_calls_from_message
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -103,10 +103,11 @@ class LlamaCppClient(GenAIClient):
|
||||
_supports_reasoning: bool
|
||||
_image_token_cache: dict[tuple[int, int], int]
|
||||
_text_baseline_tokens: int | None
|
||||
_media_marker: str
|
||||
|
||||
@property
|
||||
def supports_embeddings(self) -> bool:
|
||||
"""llama.cpp exposes a /v1/embeddings endpoint for any loaded model."""
|
||||
"""llama.cpp exposes an /embeddings endpoint for any loaded model."""
|
||||
return True
|
||||
|
||||
def _auth_headers(self) -> dict | None:
|
||||
@@ -158,6 +159,7 @@ class LlamaCppClient(GenAIClient):
|
||||
self._supports_reasoning = False
|
||||
self._image_token_cache = {}
|
||||
self._text_baseline_tokens = None
|
||||
self._media_marker = "<__media__>"
|
||||
|
||||
base_url = (
|
||||
self.genai_config.base_url.rstrip("/")
|
||||
@@ -185,6 +187,7 @@ class LlamaCppClient(GenAIClient):
|
||||
self._supports_audio = info["supports_audio"]
|
||||
self._supports_tools = info["supports_tools"]
|
||||
self._supports_reasoning = info["supports_reasoning"]
|
||||
self._media_marker = info["media_marker"]
|
||||
|
||||
logger.info(
|
||||
"llama.cpp model '%s' initialized — context: %s, vision: %s, audio: %s, tools: %s, reasoning: %s",
|
||||
@@ -212,7 +215,9 @@ class LlamaCppClient(GenAIClient):
|
||||
`architecture.input_modalities` (text/image/audio) — the primary
|
||||
source. When proxied through llama-swap, the same entry carries
|
||||
`status.args` (server launch argv) and, for the loaded model,
|
||||
`meta.n_ctx`.
|
||||
`meta.n_ctx`. /props remains the only source for `media_marker`,
|
||||
which the server randomizes per startup unless LLAMA_MEDIA_MARKER
|
||||
is set.
|
||||
"""
|
||||
info: dict[str, Any] = {
|
||||
"context_size": None,
|
||||
@@ -220,6 +225,7 @@ class LlamaCppClient(GenAIClient):
|
||||
"supports_audio": False,
|
||||
"supports_tools": False,
|
||||
"supports_reasoning": False,
|
||||
"media_marker": "<__media__>",
|
||||
}
|
||||
|
||||
model_entry: dict[str, Any] | None = None
|
||||
@@ -308,8 +314,16 @@ class LlamaCppClient(GenAIClient):
|
||||
# in the Jinja chat template itself.
|
||||
chat_template = props.get("chat_template") or ""
|
||||
info["supports_reasoning"] = "enable_thinking" in chat_template
|
||||
|
||||
media_marker = props.get("media_marker")
|
||||
if isinstance(media_marker, str) and media_marker:
|
||||
info["media_marker"] = media_marker
|
||||
except Exception as e:
|
||||
logger.warning("Failed to query llama.cpp /props endpoint: %s", e)
|
||||
logger.warning(
|
||||
"Failed to query llama.cpp /props endpoint: %s. "
|
||||
"Image embeddings may fail if the server randomized its media marker.",
|
||||
e,
|
||||
)
|
||||
|
||||
return info
|
||||
|
||||
@@ -319,7 +333,6 @@ class LlamaCppClient(GenAIClient):
|
||||
images: list[bytes],
|
||||
response_format: dict | None = None,
|
||||
enable_thinking: bool = False,
|
||||
image_captions: list[str] | None = None,
|
||||
) -> str | None:
|
||||
"""Submit a request to llama.cpp server."""
|
||||
if self.provider is None:
|
||||
@@ -329,17 +342,18 @@ class LlamaCppClient(GenAIClient):
|
||||
return None
|
||||
|
||||
try:
|
||||
content: list[dict[str, Any]] = []
|
||||
for part in interleave_images(prompt, images, image_captions):
|
||||
if isinstance(part, str):
|
||||
content.append({"type": "text", "text": part})
|
||||
continue
|
||||
|
||||
encoded_image = base64.b64encode(part).decode("utf-8")
|
||||
content = [
|
||||
{
|
||||
"type": "text",
|
||||
"text": prompt,
|
||||
}
|
||||
]
|
||||
for image in images:
|
||||
encoded_image = base64.b64encode(image).decode("utf-8")
|
||||
content.append(
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {
|
||||
"image_url": { # type: ignore[dict-item]
|
||||
"url": f"data:image/jpeg;base64,{encoded_image}",
|
||||
},
|
||||
}
|
||||
@@ -394,123 +408,6 @@ class LlamaCppClient(GenAIClient):
|
||||
"""Whether the loaded model supports audio input."""
|
||||
return self._supports_audio
|
||||
|
||||
@property
|
||||
def supports_transcription(self) -> bool:
|
||||
"""Audio-capable models can transcribe through chat completions."""
|
||||
return self._supports_audio
|
||||
|
||||
def transcribe(
|
||||
self,
|
||||
audio: bytes,
|
||||
language: str | None = None,
|
||||
mime_type: str = "audio/wav",
|
||||
) -> str | None:
|
||||
"""Transcribe audio through the OpenAI-compatible transcriptions route.
|
||||
|
||||
llama.cpp serves /v1/audio/transcriptions for any audio-capable model,
|
||||
not only a separately loaded whisper (ggml-org/llama.cpp#21863), so it
|
||||
covers exactly the models supports_transcription detects. It takes the
|
||||
language as a native multipart field, which is the only thing dedicated
|
||||
ASR models honor: they read the chat prompt as contextual biasing, so
|
||||
asking one there to use a language does nothing.
|
||||
|
||||
Falls back to chat completions when the server predates that route.
|
||||
"""
|
||||
if self.provider is None:
|
||||
logger.warning(
|
||||
"llama.cpp provider has not been initialized, audio will not be transcribed. Check your llama.cpp configuration."
|
||||
)
|
||||
return None
|
||||
|
||||
if not self._supports_audio:
|
||||
logger.warning(
|
||||
"llama.cpp model '%s' does not accept audio input",
|
||||
self.genai_config.model,
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
data = {"model": self.genai_config.model, "response_format": "json"}
|
||||
|
||||
if language:
|
||||
data["language"] = language
|
||||
|
||||
response = self._post(
|
||||
f"{self.provider}/v1/audio/transcriptions",
|
||||
files={"file": ("audio.wav", audio, mime_type)},
|
||||
data=data,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
|
||||
if response.status_code == 404:
|
||||
logger.debug(
|
||||
"llama.cpp server has no /v1/audio/transcriptions route, using chat completions"
|
||||
)
|
||||
return self._transcribe_via_chat(audio, language)
|
||||
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
text = result.get("text") if isinstance(result, dict) else None
|
||||
|
||||
return str(text).strip() or None if text else None
|
||||
except Exception as e:
|
||||
logger.warning("llama.cpp returned an error: %s", str(e))
|
||||
return None
|
||||
|
||||
def _transcribe_via_chat(self, audio: bytes, language: str | None) -> str | None:
|
||||
"""Transcribe through /v1/chat/completions, for servers without the
|
||||
transcriptions route.
|
||||
"""
|
||||
prompt = "Transcribe the speech in this audio verbatim. Respond with the transcript only, and with nothing at all if there is no speech."
|
||||
|
||||
if language:
|
||||
prompt += f" The speech is in language '{language}'."
|
||||
|
||||
try:
|
||||
encoded_audio = base64.b64encode(audio).decode("utf-8")
|
||||
payload: dict[str, Any] = {
|
||||
"model": self.genai_config.model,
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": prompt},
|
||||
{
|
||||
"type": "input_audio",
|
||||
"input_audio": {
|
||||
"data": encoded_audio,
|
||||
"format": "wav",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
**self.provider_options,
|
||||
}
|
||||
|
||||
response = self._post(
|
||||
f"{self.provider}/v1/chat/completions",
|
||||
json=payload,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
|
||||
if (
|
||||
result is not None
|
||||
and "choices" in result
|
||||
and len(result["choices"]) > 0
|
||||
):
|
||||
choice = result["choices"][0]
|
||||
|
||||
if "message" in choice and choice["message"].get("content"):
|
||||
return str(choice["message"]["content"].strip()) or None
|
||||
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning("llama.cpp returned an error: %s", str(e))
|
||||
return None
|
||||
|
||||
@property
|
||||
def supports_tools(self) -> bool:
|
||||
"""Whether the loaded model supports tool/function calling."""
|
||||
@@ -520,73 +417,28 @@ class LlamaCppClient(GenAIClient):
|
||||
def supports_toggleable_thinking(self) -> bool:
|
||||
return self._supports_reasoning
|
||||
|
||||
def _fetch_models_data(self) -> list[dict[str, Any]]:
|
||||
"""Return the raw /v1/models entries, or an empty list if unreachable."""
|
||||
def list_models(self) -> list[str]:
|
||||
"""Return available model IDs from the llama.cpp server."""
|
||||
base_url = self.provider or (
|
||||
self.genai_config.base_url.rstrip("/")
|
||||
if self.genai_config.base_url
|
||||
else None
|
||||
)
|
||||
|
||||
if base_url is None:
|
||||
return []
|
||||
|
||||
try:
|
||||
response = self._get(f"{base_url}/v1/models", timeout=10)
|
||||
response.raise_for_status()
|
||||
data = response.json().get("data", [])
|
||||
models = []
|
||||
for m in response.json().get("data", []):
|
||||
models.append(m.get("id", "unknown"))
|
||||
for alias in m.get("aliases", []):
|
||||
models.append(alias)
|
||||
return sorted(models)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to list llama.cpp models: %s", e)
|
||||
return []
|
||||
|
||||
return data if isinstance(data, list) else []
|
||||
|
||||
def list_models(self) -> list[str]:
|
||||
"""Return available model IDs from the llama.cpp server."""
|
||||
models: set[str] = set()
|
||||
|
||||
# llama-server lists the id among the aliases when --alias is set
|
||||
for m in self._fetch_models_data():
|
||||
models.add(m.get("id", "unknown"))
|
||||
models.update(m.get("aliases", []))
|
||||
|
||||
return sorted(models)
|
||||
|
||||
def list_model_capabilities(self) -> dict[str, dict[str, bool]]:
|
||||
"""Report input modalities for every model the server serves.
|
||||
|
||||
Since ggml-org/llama.cpp#22952 each /v1/models entry carries
|
||||
architecture.input_modalities, so a single request describes every
|
||||
model rather than just the configured one. That is what lets the UI
|
||||
answer "can the model I just picked transcribe" before the config is
|
||||
saved and a client for it exists.
|
||||
|
||||
Models whose entry predates that field are omitted rather than reported
|
||||
as incapable, so an older server falls back to the /props probe instead
|
||||
of silently losing capabilities it actually has.
|
||||
"""
|
||||
capabilities: dict[str, dict[str, bool]] = {}
|
||||
|
||||
for model in self._fetch_models_data():
|
||||
architecture = model.get("architecture") or {}
|
||||
modalities = architecture.get("input_modalities")
|
||||
|
||||
if not isinstance(modalities, list) or not modalities:
|
||||
continue
|
||||
|
||||
flags = {
|
||||
"supports_vision": "image" in modalities,
|
||||
"supports_transcription": "audio" in modalities,
|
||||
}
|
||||
|
||||
names = [model.get("id"), *(model.get("aliases") or [])]
|
||||
|
||||
for name in names:
|
||||
if isinstance(name, str) and name:
|
||||
capabilities[name] = flags
|
||||
|
||||
return capabilities
|
||||
|
||||
def get_context_size(self) -> int:
|
||||
"""Get the context window size for llama.cpp.
|
||||
|
||||
@@ -777,16 +629,41 @@ class LlamaCppClient(GenAIClient):
|
||||
)
|
||||
return result if result else None
|
||||
|
||||
def _refresh_media_marker(self) -> bool:
|
||||
"""Re-fetch /props and update the cached media marker if it changed.
|
||||
|
||||
The server randomizes the marker per startup (unless LLAMA_MEDIA_MARKER
|
||||
is set), so a stale marker indicates a restart. Returns True iff the
|
||||
marker was updated to a new value — used to gate a one-shot retry of
|
||||
a failed embeddings request.
|
||||
"""
|
||||
if self.provider is None:
|
||||
return False
|
||||
try:
|
||||
props = self._fetch_llama_props(self.provider, self.genai_config.model)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to refresh llama.cpp media marker: %s", e)
|
||||
return False
|
||||
|
||||
marker = props.get("media_marker")
|
||||
|
||||
if not isinstance(marker, str) or not marker or marker == self._media_marker:
|
||||
return False
|
||||
|
||||
logger.info("llama.cpp media marker changed (server restart); refreshed")
|
||||
self._media_marker = marker
|
||||
return True
|
||||
|
||||
def embed(
|
||||
self,
|
||||
texts: list[str] | None = None,
|
||||
images: list[bytes] | None = None,
|
||||
) -> list[np.ndarray]:
|
||||
"""Generate embeddings via llama.cpp /v1/embeddings endpoint.
|
||||
"""Generate embeddings via llama.cpp /embeddings endpoint.
|
||||
|
||||
Each text or image is one entry in `input`, using the chat-style
|
||||
content array from ggml-org/llama.cpp#29556. Server must be started
|
||||
with --embeddings, and --mmproj for image support.
|
||||
Supports batch requests. Uses content format with prompt_string and
|
||||
multimodal_data for images (PR #15108). Server must be started with
|
||||
--embeddings and --mmproj for multimodal support.
|
||||
"""
|
||||
if self.provider is None:
|
||||
logger.warning(
|
||||
@@ -801,42 +678,49 @@ class LlamaCppClient(GenAIClient):
|
||||
|
||||
EMBEDDING_DIM = 768
|
||||
|
||||
inputs: list[dict[str, Any]] = [
|
||||
{"content": [{"type": "text", "text": text}]} for text in texts
|
||||
]
|
||||
|
||||
encoded_images: list[str] = []
|
||||
for img in images:
|
||||
# llama.cpp uses STB which does not support WebP; convert to JPEG
|
||||
jpeg_bytes = _to_jpeg(img)
|
||||
to_encode = jpeg_bytes if jpeg_bytes is not None else img
|
||||
encoded = base64.b64encode(to_encode).decode("utf-8")
|
||||
# The trailing newline keeps tokenization identical to the older
|
||||
# "<__media__>\n" prompt_string format, so indexed vectors stay valid
|
||||
inputs.append(
|
||||
{
|
||||
"content": [
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": f"data:image/jpeg;base64,{encoded}"},
|
||||
},
|
||||
{"type": "text", "text": "\n"},
|
||||
]
|
||||
}
|
||||
encoded_images.append(base64.b64encode(to_encode).decode("utf-8"))
|
||||
|
||||
def build_content() -> list[dict[str, Any]]:
|
||||
# prompt_string must contain the server's media marker placeholder
|
||||
# for each image. The marker is randomized per server startup.
|
||||
content: list[dict[str, Any]] = []
|
||||
for text in texts:
|
||||
content.append({"prompt_string": text})
|
||||
for encoded in encoded_images:
|
||||
content.append(
|
||||
{
|
||||
"prompt_string": f"{self._media_marker}\n",
|
||||
"multimodal_data": [encoded],
|
||||
}
|
||||
)
|
||||
return content
|
||||
|
||||
def post_embeddings() -> requests.Response:
|
||||
return self._post(
|
||||
f"{self.provider}/embeddings",
|
||||
json={"model": self.genai_config.model, "content": build_content()},
|
||||
timeout=self.timeout,
|
||||
)
|
||||
|
||||
try:
|
||||
response = self._post(
|
||||
f"{self.provider}/v1/embeddings",
|
||||
json={
|
||||
"model": self.genai_config.model,
|
||||
"input": inputs,
|
||||
"encoding_format": "float",
|
||||
},
|
||||
timeout=self.timeout,
|
||||
)
|
||||
response.raise_for_status()
|
||||
items = response.json().get("data")
|
||||
try:
|
||||
response = post_embeddings()
|
||||
response.raise_for_status()
|
||||
except requests.exceptions.RequestException:
|
||||
# The server may have restarted with a new media marker.
|
||||
# Refresh from /props; only retry if the marker actually changed.
|
||||
if not encoded_images or not self._refresh_media_marker():
|
||||
raise
|
||||
response = post_embeddings()
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
|
||||
items = result.get("data", result) if isinstance(result, dict) else result
|
||||
if not isinstance(items, list):
|
||||
logger.warning("llama.cpp embeddings returned unexpected format")
|
||||
return []
|
||||
@@ -847,7 +731,11 @@ class LlamaCppClient(GenAIClient):
|
||||
if emb is None:
|
||||
logger.warning("llama.cpp embeddings item missing embedding field")
|
||||
continue
|
||||
arr = np.array(emb, dtype=np.float32).flatten()
|
||||
arr = np.array(emb, dtype=np.float32)
|
||||
if arr.ndim > 1:
|
||||
# llama.cpp can return token-level embeddings; pool per item
|
||||
arr = arr.mean(axis=0)
|
||||
arr = arr.flatten()
|
||||
orig_dim = arr.size
|
||||
if orig_dim != EMBEDDING_DIM:
|
||||
if orig_dim > EMBEDDING_DIM:
|
||||
|
||||
@@ -14,7 +14,7 @@ from ollama import ResponseError
|
||||
|
||||
from frigate.config import GenAIProviderEnum
|
||||
from frigate.genai import GenAIClient, register_genai_provider
|
||||
from frigate.genai.utils import interleave_images, parse_tool_calls_from_message
|
||||
from frigate.genai.utils import parse_tool_calls_from_message
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -50,28 +50,6 @@ def _extract_ollama_stats(response: Any) -> dict[str, Any] | None:
|
||||
return stats or None
|
||||
|
||||
|
||||
# Ollama replaces each occurrence of this marker in a message, in order, with
|
||||
# the next image from the message's images list. Without markers it puts every
|
||||
# image before the text.
|
||||
IMAGE_PLACEHOLDER = "[img]"
|
||||
|
||||
|
||||
def _flatten_parts(parts: list[str | bytes]) -> tuple[str, list[bytes] | None]:
|
||||
"""Collapse ordered text and image parts into Ollama's (content, images)
|
||||
shape, marking where each image goes so the order survives."""
|
||||
text: list[str] = []
|
||||
images: list[bytes] = []
|
||||
|
||||
for part in parts:
|
||||
if isinstance(part, bytes):
|
||||
text.append(IMAGE_PLACEHOLDER)
|
||||
images.append(part)
|
||||
elif part:
|
||||
text.append(part)
|
||||
|
||||
return "\n".join(text), (images or None)
|
||||
|
||||
|
||||
def _normalize_multimodal_content(
|
||||
content: Any,
|
||||
) -> tuple[str | None, list[bytes] | None]:
|
||||
@@ -80,13 +58,13 @@ def _normalize_multimodal_content(
|
||||
The chat API constructs user messages with content as a list of
|
||||
``{"type": "text"}`` and ``{"type": "image_url"}`` parts when a tool
|
||||
returns a live frame. Ollama's SDK requires content to be a string and
|
||||
images to be passed in a separate field, so images are pulled out and
|
||||
their positions marked with placeholders.
|
||||
images to be passed in a separate field, so we extract each.
|
||||
"""
|
||||
if not isinstance(content, list):
|
||||
return content, None
|
||||
|
||||
parts: list[str | bytes] = []
|
||||
text_parts: list[str] = []
|
||||
images: list[bytes] = []
|
||||
for part in content:
|
||||
if not isinstance(part, dict):
|
||||
continue
|
||||
@@ -94,20 +72,17 @@ def _normalize_multimodal_content(
|
||||
if part_type == "text":
|
||||
text = part.get("text")
|
||||
if text:
|
||||
parts.append(str(text))
|
||||
text_parts.append(str(text))
|
||||
elif part_type == "image_url":
|
||||
url = (part.get("image_url") or {}).get("url", "")
|
||||
if isinstance(url, str) and url.startswith("data:"):
|
||||
try:
|
||||
encoded = url.split(",", 1)[1]
|
||||
parts.append(base64.b64decode(encoded, validate=True))
|
||||
images.append(base64.b64decode(encoded, validate=True))
|
||||
except (ValueError, IndexError, binascii.Error) as e:
|
||||
logger.debug("Failed to decode multimodal image url: %s", e)
|
||||
|
||||
if not parts:
|
||||
return None, None
|
||||
|
||||
return _flatten_parts(parts)
|
||||
return ("\n".join(text_parts) if text_parts else None), (images or None)
|
||||
|
||||
|
||||
@register_genai_provider(GenAIProviderEnum.ollama)
|
||||
@@ -221,46 +196,58 @@ class OllamaClient(GenAIClient):
|
||||
images: list[bytes],
|
||||
response_format: dict | None = None,
|
||||
enable_thinking: bool = False,
|
||||
image_captions: list[str] | None = None,
|
||||
) -> str | None:
|
||||
"""Submit a request to Ollama through the chat API, the same path the
|
||||
tool-calling chat uses, with image placeholders keeping any captions
|
||||
next to their frames."""
|
||||
"""Submit a request to Ollama"""
|
||||
if self.provider is None:
|
||||
logger.warning(
|
||||
"Ollama provider has not been initialized, a description will not be generated. Check your Ollama configuration."
|
||||
)
|
||||
return None
|
||||
|
||||
content, message_images = _flatten_parts(
|
||||
interleave_images(prompt, images, image_captions)
|
||||
)
|
||||
message: dict[str, Any] = {"role": "user", "content": content}
|
||||
|
||||
if message_images:
|
||||
message["images"] = message_images
|
||||
|
||||
request_params = self._build_request_params(
|
||||
[message], None, None, enable_thinking=enable_thinking
|
||||
)
|
||||
|
||||
if response_format and response_format.get("type") == "json_schema":
|
||||
schema = response_format.get("json_schema", {}).get("schema")
|
||||
if schema:
|
||||
request_params["format"] = self._clean_schema_for_ollama(schema)
|
||||
|
||||
logger.debug(
|
||||
"Ollama chat request: model=%s, prompt_len=%s, image_count=%s, "
|
||||
"has_format=%s, think=%s",
|
||||
self.genai_config.model,
|
||||
len(prompt),
|
||||
len(images),
|
||||
"format" in request_params,
|
||||
request_params.get("think"),
|
||||
)
|
||||
|
||||
try:
|
||||
response = self.provider.chat(**request_params)
|
||||
ollama_options = {
|
||||
**self.provider_options,
|
||||
**self.genai_config.runtime_options,
|
||||
}
|
||||
if response_format and response_format.get("type") == "json_schema":
|
||||
schema = response_format.get("json_schema", {}).get("schema")
|
||||
if schema:
|
||||
ollama_options["format"] = self._clean_schema_for_ollama(schema)
|
||||
if self.supports_toggleable_thinking:
|
||||
ollama_options["think"] = enable_thinking
|
||||
logger.debug(
|
||||
"Ollama generate request: model=%s, prompt_len=%s, image_count=%s, "
|
||||
"has_format=%s, options=%s",
|
||||
self.genai_config.model,
|
||||
len(prompt),
|
||||
len(images) if images else 0,
|
||||
"format" in ollama_options,
|
||||
{k: v for k, v in ollama_options.items() if k != "format"},
|
||||
)
|
||||
result = self.provider.generate(
|
||||
self.genai_config.model,
|
||||
prompt,
|
||||
images=images if images else None,
|
||||
**ollama_options,
|
||||
)
|
||||
logger.debug(
|
||||
"Ollama generate response: done=%s, done_reason=%s, eval_count=%s, "
|
||||
"prompt_eval_count=%s, response_len=%s",
|
||||
result.get("done"),
|
||||
result.get("done_reason"),
|
||||
result.get("eval_count"),
|
||||
result.get("prompt_eval_count"),
|
||||
len(result.get("response", "") or ""),
|
||||
)
|
||||
response_text = str(result["response"]).strip()
|
||||
if not response_text:
|
||||
logger.warning(
|
||||
"Ollama returned a blank response for model %s (done_reason=%s, "
|
||||
"eval_count=%s). Check model output, ensure thinking is disabled.",
|
||||
self.genai_config.model,
|
||||
result.get("done_reason"),
|
||||
result.get("eval_count"),
|
||||
)
|
||||
return response_text
|
||||
except (
|
||||
TimeoutException,
|
||||
ResponseError,
|
||||
@@ -270,27 +257,6 @@ class OllamaClient(GenAIClient):
|
||||
logger.warning("Ollama returned an error: %s", str(e))
|
||||
return None
|
||||
|
||||
logger.debug(
|
||||
"Ollama chat response: done=%s, done_reason=%s, eval_count=%s, "
|
||||
"prompt_eval_count=%s",
|
||||
response.get("done"),
|
||||
response.get("done_reason"),
|
||||
response.get("eval_count"),
|
||||
response.get("prompt_eval_count"),
|
||||
)
|
||||
response_text = self._message_from_response(response)["content"] or ""
|
||||
|
||||
if not response_text:
|
||||
logger.warning(
|
||||
"Ollama returned a blank response for model %s (done_reason=%s, "
|
||||
"eval_count=%s). Check model output, ensure thinking is disabled.",
|
||||
self.genai_config.model,
|
||||
response.get("done_reason"),
|
||||
response.get("eval_count"),
|
||||
)
|
||||
|
||||
return response_text
|
||||
|
||||
def list_models(self) -> list[str]:
|
||||
"""Return available model names from the Ollama server."""
|
||||
client = self.provider
|
||||
@@ -340,8 +306,6 @@ class OllamaClient(GenAIClient):
|
||||
}
|
||||
if images:
|
||||
msg_dict["images"] = images
|
||||
elif msg.get("images"):
|
||||
msg_dict["images"] = msg["images"]
|
||||
if msg.get("tool_call_id"):
|
||||
msg_dict["tool_call_id"] = msg["tool_call_id"]
|
||||
if msg.get("name"):
|
||||
|
||||
@@ -11,16 +11,9 @@ from openai import OpenAI
|
||||
|
||||
from frigate.config import GenAIProviderEnum
|
||||
from frigate.genai import GenAIClient, register_genai_provider
|
||||
from frigate.genai.utils import interleave_images
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# gpt-transcribe replaced the singular `language` field with a `languages` array
|
||||
# and rejects a request that sends both. Older transcription models
|
||||
# (gpt-4o-transcribe, gpt-4o-mini-transcribe, whisper-1) still take the singular
|
||||
# form. https://developers.openai.com/api/docs/guides/speech-to-text
|
||||
_LANGUAGES_ARRAY_MODEL_PREFIX = "gpt-transcribe"
|
||||
|
||||
|
||||
def _stats_from_openai_usage(usage: Any) -> dict[str, Any] | None:
|
||||
"""Build a stats dict from an OpenAI-compatible usage object."""
|
||||
@@ -70,21 +63,21 @@ class OpenAIClient(GenAIClient):
|
||||
images: list[bytes],
|
||||
response_format: dict | None = None,
|
||||
enable_thinking: bool = False,
|
||||
image_captions: list[str] | None = None,
|
||||
) -> str | None:
|
||||
"""Submit a request to OpenAI."""
|
||||
messages_content: list[dict] = []
|
||||
for part in interleave_images(prompt, images, image_captions):
|
||||
if isinstance(part, str):
|
||||
messages_content.append({"type": "text", "text": part})
|
||||
continue
|
||||
|
||||
encoded = base64.b64encode(part).decode("utf-8")
|
||||
encoded_images = [base64.b64encode(image).decode("utf-8") for image in images]
|
||||
messages_content: list[dict] = [
|
||||
{
|
||||
"type": "text",
|
||||
"text": prompt,
|
||||
}
|
||||
]
|
||||
for image in encoded_images:
|
||||
messages_content.append(
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {
|
||||
"url": f"data:image/jpeg;base64,{encoded}",
|
||||
"url": f"data:image/jpeg;base64,{image}",
|
||||
"detail": "low",
|
||||
},
|
||||
}
|
||||
@@ -140,51 +133,6 @@ class OpenAIClient(GenAIClient):
|
||||
logger.warning("OpenAI returned an error: %s", str(e))
|
||||
return None
|
||||
|
||||
@property
|
||||
def supports_transcription(self) -> bool:
|
||||
"""OpenAI exposes /v1/audio/transcriptions for its speech models."""
|
||||
return True
|
||||
|
||||
def transcribe(
|
||||
self,
|
||||
audio: bytes,
|
||||
language: str | None = None,
|
||||
mime_type: str = "audio/wav",
|
||||
) -> str | None:
|
||||
"""Transcribe audio via the OpenAI audio transcriptions endpoint."""
|
||||
try:
|
||||
# runtime_options are chat-completion parameters; the transcriptions
|
||||
# endpoint rejects unknown fields, so they are deliberately not splatted
|
||||
# in here the way _send() does.
|
||||
request_params: dict[str, Any] = {
|
||||
"model": self.genai_config.model,
|
||||
"file": ("audio.wav", audio, mime_type),
|
||||
"response_format": "text",
|
||||
"timeout": self.timeout,
|
||||
}
|
||||
|
||||
if language:
|
||||
if (
|
||||
self.genai_config.model.strip()
|
||||
.lower()
|
||||
.startswith(_LANGUAGES_ARRAY_MODEL_PREFIX)
|
||||
):
|
||||
# not a typed parameter on the SDK method, so it has to ride
|
||||
# along in extra_body
|
||||
request_params["extra_body"] = {"languages": [language]}
|
||||
else:
|
||||
request_params["language"] = language
|
||||
|
||||
result = self.provider.audio.transcriptions.create(**request_params)
|
||||
except (TimeoutException, Exception) as e:
|
||||
logger.warning("OpenAI returned an error: %s", str(e))
|
||||
return None
|
||||
|
||||
# response_format="text" yields a bare string, but some compatible
|
||||
# servers still return the object form
|
||||
text = result if isinstance(result, str) else getattr(result, "text", None)
|
||||
return text.strip() if text else None
|
||||
|
||||
def list_models(self) -> list[str]:
|
||||
"""Return available model IDs from the OpenAI-compatible API."""
|
||||
try:
|
||||
|
||||
@@ -59,13 +59,6 @@ def get_review_field_guidelines(response_style: str = "default") -> dict[str, st
|
||||
}
|
||||
|
||||
|
||||
# Explains the per-frame labels and tracker notes used by the annotated frame
|
||||
# mode. Neither the notes nor this guidance say whether repeated detections are
|
||||
# the same subject, since the tracking data cannot tell.
|
||||
FRAME_ANNOTATION_GUIDANCE = """- Each image below is immediately preceded by a text label giving its frame number and how many seconds into the sequence it was captured. Use these labels to track the order of events and the time between them.
|
||||
- Some images below are preceded by notes from the camera's object tracker recording what changed at that point: an object being first detected, starting to move, reversing direction, stopping, or no longer being detected. These notes come from tracking data rather than from the images, and they are reliable. Use them to establish how many distinct activities occur and in what order, and describe every one of them."""
|
||||
|
||||
|
||||
def build_review_description_prompt(
|
||||
review_data: dict[str, Any],
|
||||
thumbnails: list[bytes],
|
||||
@@ -73,13 +66,8 @@ def build_review_description_prompt(
|
||||
preferred_language: str | None,
|
||||
activity_context_prompt: str,
|
||||
response_style: str = "default",
|
||||
frame_captions: list[str] | None = None,
|
||||
) -> str:
|
||||
"""Build the prompt for review activity description generation.
|
||||
|
||||
When `frame_captions` is set, each caption is sent directly before its
|
||||
image, so the prompt explains that layout.
|
||||
"""
|
||||
"""Build the prompt for review activity description generation."""
|
||||
|
||||
def get_concern_prompt() -> str:
|
||||
if concerns:
|
||||
@@ -104,23 +92,7 @@ def build_review_description_prompt(
|
||||
else:
|
||||
return "\n- (No objects detected)"
|
||||
|
||||
def get_state_changes_section() -> str:
|
||||
# empty when nothing changed so the prompt is otherwise unaffected
|
||||
changes = review_data.get("classification_state_changes")
|
||||
|
||||
if not changes:
|
||||
return ""
|
||||
|
||||
return (
|
||||
"\n\n## State Changes\n\n"
|
||||
"The camera's state classifiers watch fixed areas of the scene and "
|
||||
"reported these changes. They come from the classifiers rather than "
|
||||
"from the images, and they are reliable. Describe each one where it "
|
||||
"fits in the sequence of events.\n- " + "\n- ".join(changes)
|
||||
)
|
||||
|
||||
fields = get_review_field_guidelines(response_style)
|
||||
frame_guidance = f"\n{FRAME_ANNOTATION_GUIDANCE}" if frame_captions else ""
|
||||
|
||||
return f"""
|
||||
Your task is to analyze a sequence of images taken in chronological order from a security camera.
|
||||
@@ -158,9 +130,9 @@ Respond with a JSON object matching the provided schema. Field-specific guidance
|
||||
## Sequence Details
|
||||
|
||||
- Camera: {review_data["camera"]}
|
||||
- Total frames: {len(thumbnails)} (Frame 1 = earliest, Frame {len(thumbnails)} = latest){frame_guidance}
|
||||
- Total frames: {len(thumbnails)} (Frame 1 = earliest, Frame {len(thumbnails)} = latest)
|
||||
- Activity started at {review_data["start"]} and lasted {review_data["duration"]} seconds
|
||||
- Zones involved: {", ".join(review_data["zones"]) if review_data["zones"] else "None"}{get_state_changes_section()}
|
||||
- Zones involved: {", ".join(review_data["zones"]) if review_data["zones"] else "None"}
|
||||
|
||||
## Objects in Scene
|
||||
|
||||
@@ -211,17 +183,6 @@ def build_review_summary_prompt(
|
||||
f" to "
|
||||
f"{datetime.datetime.fromtimestamp(end_ts).strftime('%B %d, %Y at %I:%M %p')}"
|
||||
)
|
||||
has_state_changes = any(
|
||||
"state_changes" in item
|
||||
for event in events
|
||||
for item in [event, *event.get("context", [])]
|
||||
)
|
||||
state_changes_format = (
|
||||
'\n- "state_changes" (only on some events): changes to monitored areas '
|
||||
"reported by the camera's state classifiers, which are reliable"
|
||||
if has_state_changes
|
||||
else ""
|
||||
)
|
||||
prompt = f"""
|
||||
You are a security officer writing a concise security report.
|
||||
|
||||
@@ -229,7 +190,7 @@ Time range: {time_range}
|
||||
|
||||
Input format: Each event is a JSON object with:
|
||||
- "title", "scene", "confidence", "potential_threat_level" (0-2), "other_concerns", "camera", "time", "start_time", "end_time"
|
||||
- "context": array of related events from other cameras that occurred during overlapping time periods{state_changes_format}
|
||||
- "context": array of related events from other cameras that occurred during overlapping time periods
|
||||
|
||||
**Note: Use the "scene" field for event descriptions in the report. Ignore any "shortSummary" field if present.**
|
||||
|
||||
|
||||
@@ -7,25 +7,6 @@ from typing import Any
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def interleave_images(
|
||||
prompt: str, images: list[bytes], captions: list[str] | None = None
|
||||
) -> list[str | bytes]:
|
||||
"""The prompt, then each image preceded by its caption when one is given.
|
||||
|
||||
Providers map the text and image parts onto their own request format, so
|
||||
every provider sends the same order.
|
||||
"""
|
||||
parts: list[str | bytes] = [prompt]
|
||||
|
||||
for index, image in enumerate(images):
|
||||
if captions and index < len(captions):
|
||||
parts.append(captions[index])
|
||||
|
||||
parts.append(image)
|
||||
|
||||
return parts
|
||||
|
||||
|
||||
def parse_tool_calls_from_message(
|
||||
message: dict[str, Any],
|
||||
) -> list[dict[str, Any]] | None:
|
||||
|
||||
@@ -144,14 +144,6 @@ class LogPipe(threading.Thread):
|
||||
self.pipeReader.close()
|
||||
|
||||
def dump(self) -> None:
|
||||
if not self.deque:
|
||||
return
|
||||
|
||||
self.logger.log(
|
||||
self.level,
|
||||
"The following ffmpeg logs include the last 100 lines prior to exit.",
|
||||
)
|
||||
|
||||
while len(self.deque) > 0:
|
||||
self.logger.log(self.level, self.deque.popleft())
|
||||
|
||||
|
||||
+3
-8
@@ -195,20 +195,15 @@ class Notice(Model):
|
||||
first_seen = DateTimeField()
|
||||
last_seen = DateTimeField()
|
||||
count = IntegerField(default=1)
|
||||
# hidden until the next occurrence
|
||||
acknowledged_at = DateTimeField(null=True)
|
||||
# hidden for good
|
||||
muted_at = DateTimeField(null=True)
|
||||
dismissed_at = DateTimeField(null=True)
|
||||
|
||||
|
||||
class NoticeStats(Model):
|
||||
kind = CharField(null=False, primary_key=True, max_length=50)
|
||||
occurrences = IntegerField(default=0)
|
||||
acknowledgements = IntegerField(default=0)
|
||||
mutes = IntegerField(default=0)
|
||||
dismissals = IntegerField(default=0)
|
||||
first_seen = DateTimeField()
|
||||
last_seen = DateTimeField()
|
||||
# watermarks for a future analytics reporter; unused until then
|
||||
reported_occurrences = IntegerField(default=0)
|
||||
reported_acknowledgements = IntegerField(default=0)
|
||||
reported_mutes = IntegerField(default=0)
|
||||
reported_dismissals = IntegerField(default=0)
|
||||
|
||||
@@ -188,12 +188,8 @@ class ImprovedMotionDetector(MotionDetector):
|
||||
self.config.skip_motion_threshold is not None
|
||||
and pct_motion > self.config.skip_motion_threshold
|
||||
):
|
||||
# recalibrate so we transition to the new background. the frame
|
||||
# still has to be blended in here, otherwise the background stays
|
||||
# frozen and every subsequent frame skips as well
|
||||
# force a recalibration so we transition to the new background
|
||||
self.calibrating = True
|
||||
cv2.accumulateWeighted(resized_frame, self.avg_frame, 0.2)
|
||||
self.motion_frame_count = 0
|
||||
return []
|
||||
|
||||
# once the motion is less than 5% and the number of contours is < 4, assume its calibrated
|
||||
|
||||
+41
-108
@@ -5,7 +5,7 @@ import threading
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any, cast
|
||||
from typing import Any
|
||||
|
||||
from frigate.const import REPLAY_CAMERA_PREFIX
|
||||
from frigate.models import Notice, NoticeStats
|
||||
@@ -77,8 +77,9 @@ class NoticeRegistry:
|
||||
) -> None:
|
||||
"""Insert a notice or count another occurrence of it.
|
||||
|
||||
Another occurrence shows an acknowledged notice again. A muted notice
|
||||
stays hidden.
|
||||
A dismissed notice stays dismissed when it is raised again, unless its
|
||||
kind sets reopen_at_count. A kind that should come back after a
|
||||
dismissal gives each episode its own scope.
|
||||
"""
|
||||
definition = NOTICE_KINDS.get(kind)
|
||||
|
||||
@@ -111,6 +112,7 @@ class NoticeRegistry:
|
||||
first_seen=now,
|
||||
last_seen=now,
|
||||
count=1,
|
||||
dismissed_at=None,
|
||||
)
|
||||
self._bump_occurrences(kind, 1, now)
|
||||
|
||||
@@ -173,7 +175,7 @@ class NoticeRegistry:
|
||||
self._notify()
|
||||
|
||||
def resolve_camera(self, camera: str) -> None:
|
||||
"""Drop the notices and check mutes of a camera being deleted."""
|
||||
"""Drop the notices and check dismissals of a camera being deleted."""
|
||||
camera_kinds = [
|
||||
key
|
||||
for key, definition in NOTICE_KINDS.items()
|
||||
@@ -191,7 +193,7 @@ class NoticeRegistry:
|
||||
)
|
||||
|
||||
# a stream id names its camera first; a config id ends with camera.<name>
|
||||
for check in self.muted_checks():
|
||||
for check in self.dismissed_checks():
|
||||
check_id = check["id"]
|
||||
|
||||
if check_id.startswith(f"stream:{camera}:") or (
|
||||
@@ -203,50 +205,26 @@ class NoticeRegistry:
|
||||
if deleted:
|
||||
self._notify()
|
||||
|
||||
def acknowledge(self, row_id: str) -> bool:
|
||||
"""Hide a notice until it happens again.
|
||||
|
||||
Returns False for an unknown id or a kind that never repeats, such as
|
||||
a check row or the update notice.
|
||||
"""
|
||||
def purge_dismissed(self) -> int:
|
||||
"""Delete every dismissed row so each can show again. Returns how many."""
|
||||
with self._lock:
|
||||
existing = Notice.get_or_none(Notice.id == row_id)
|
||||
return int(
|
||||
Notice.delete().where(Notice.dismissed_at.is_null(False)).execute()
|
||||
)
|
||||
|
||||
if existing is None:
|
||||
return False
|
||||
|
||||
definition = NOTICE_KINDS.get(existing.kind)
|
||||
|
||||
if definition is None or not definition.counts_repeats:
|
||||
return False
|
||||
|
||||
if existing.acknowledged_at is not None or existing.muted_at is not None:
|
||||
return True
|
||||
|
||||
Notice.update(acknowledged_at=datetime.now().timestamp()).where(
|
||||
Notice.id == row_id
|
||||
).execute()
|
||||
NoticeStats.update(acknowledgements=NoticeStats.acknowledgements + 1).where(
|
||||
NoticeStats.kind == existing.kind
|
||||
).execute()
|
||||
|
||||
self._notify()
|
||||
return True
|
||||
|
||||
def mute(self, row_id: str) -> bool:
|
||||
def dismiss(self, row_id: str) -> bool:
|
||||
"""Hide a notice or check row for good. Returns False for an unknown id."""
|
||||
now = datetime.now().timestamp()
|
||||
|
||||
with self._lock:
|
||||
existing = Notice.get_or_none(Notice.id == row_id)
|
||||
|
||||
if existing is None:
|
||||
# a check row gets a notice row only once it is muted
|
||||
# a check row gets a notice row only once it is dismissed
|
||||
kind, _, scope = row_id.partition(":")
|
||||
|
||||
if kind not in CHECK_KINDS or not scope:
|
||||
return False
|
||||
|
||||
now = datetime.now().timestamp()
|
||||
Notice.create(
|
||||
id=row_id,
|
||||
kind=kind,
|
||||
@@ -255,78 +233,40 @@ class NoticeRegistry:
|
||||
first_seen=now,
|
||||
last_seen=now,
|
||||
count=1,
|
||||
muted_at=now,
|
||||
dismissed_at=now,
|
||||
)
|
||||
return True
|
||||
|
||||
if existing.muted_at is not None:
|
||||
if existing.dismissed_at is not None:
|
||||
return True
|
||||
|
||||
if existing.kind not in NOTICE_KINDS:
|
||||
return False
|
||||
|
||||
Notice.update(acknowledged_at=None, muted_at=now).where(
|
||||
Notice.update(dismissed_at=datetime.now().timestamp()).where(
|
||||
Notice.id == row_id
|
||||
).execute()
|
||||
NoticeStats.update(mutes=NoticeStats.mutes + 1).where(
|
||||
NoticeStats.update(dismissals=NoticeStats.dismissals + 1).where(
|
||||
NoticeStats.kind == existing.kind
|
||||
).execute()
|
||||
|
||||
self._notify()
|
||||
return True
|
||||
|
||||
def unhide(self, row_id: str) -> bool:
|
||||
"""Show an acknowledged or muted row again. Returns False for an unknown id."""
|
||||
with self._lock:
|
||||
existing = Notice.get_or_none(Notice.id == row_id)
|
||||
|
||||
if existing is None:
|
||||
return False
|
||||
|
||||
if existing.kind in CHECK_KINDS:
|
||||
Notice.delete_by_id(row_id)
|
||||
return True
|
||||
|
||||
Notice.update(acknowledged_at=None, muted_at=None).where(
|
||||
Notice.id == row_id
|
||||
).execute()
|
||||
|
||||
self._notify()
|
||||
return True
|
||||
|
||||
def unhide_all(self) -> None:
|
||||
"""Show every acknowledged and muted row again."""
|
||||
with self._lock:
|
||||
for check in self.muted_checks():
|
||||
Notice.delete_by_id(check["id"])
|
||||
|
||||
shown = (
|
||||
Notice.update(acknowledged_at=None, muted_at=None)
|
||||
.where(
|
||||
Notice.acknowledged_at.is_null(False)
|
||||
| Notice.muted_at.is_null(False)
|
||||
)
|
||||
.execute()
|
||||
)
|
||||
|
||||
if shown:
|
||||
self._notify()
|
||||
|
||||
def muted_checks(self) -> list[dict[str, Any]]:
|
||||
"""Muted config and stream check rows, newest first."""
|
||||
def dismissed_checks(self) -> list[dict[str, Any]]:
|
||||
"""Dismissed config and stream check rows, newest first."""
|
||||
rows = (
|
||||
Notice.select()
|
||||
.where(Notice.kind.in_(list(CHECK_KINDS)))
|
||||
.order_by(Notice.muted_at.desc())
|
||||
.order_by(Notice.dismissed_at.desc())
|
||||
)
|
||||
return [{"id": row.id, "muted_at": row.muted_at} for row in rows]
|
||||
return [{"id": row.id, "dismissed_at": row.dismissed_at} for row in rows]
|
||||
|
||||
def active(self, include_hidden: bool = False) -> list[dict[str, Any]]:
|
||||
def active(self, include_dismissed: bool = False) -> list[dict[str, Any]]:
|
||||
"""Notices most severe first, then most recent first.
|
||||
|
||||
Args:
|
||||
include_hidden: Also return acknowledged and muted notices, for the
|
||||
hidden list
|
||||
include_dismissed: Also return dismissed notices, for the history view
|
||||
"""
|
||||
rows = []
|
||||
|
||||
@@ -336,9 +276,7 @@ class NoticeRegistry:
|
||||
if definition is None:
|
||||
continue
|
||||
|
||||
hidden = row.acknowledged_at is not None or row.muted_at is not None
|
||||
|
||||
if hidden and not include_hidden:
|
||||
if row.dismissed_at is not None and not include_dismissed:
|
||||
continue
|
||||
|
||||
rows.append(
|
||||
@@ -353,9 +291,7 @@ class NoticeRegistry:
|
||||
"first_seen": row.first_seen,
|
||||
"last_seen": row.last_seen,
|
||||
"count": row.count,
|
||||
"acknowledgeable": definition.counts_repeats,
|
||||
"acknowledged_at": row.acknowledged_at,
|
||||
"muted_at": row.muted_at,
|
||||
"dismissed_at": row.dismissed_at,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -373,13 +309,11 @@ class NoticeRegistry:
|
||||
{
|
||||
"kind": row.kind,
|
||||
"occurrences": row.occurrences,
|
||||
"acknowledgements": row.acknowledgements,
|
||||
"mutes": row.mutes,
|
||||
"dismissals": row.dismissals,
|
||||
"first_seen": row.first_seen,
|
||||
"last_seen": row.last_seen,
|
||||
"reported_occurrences": row.reported_occurrences,
|
||||
"reported_acknowledgements": row.reported_acknowledgements,
|
||||
"reported_mutes": row.reported_mutes,
|
||||
"reported_dismissals": row.reported_dismissals,
|
||||
}
|
||||
for row in NoticeStats.select()
|
||||
if row.kind in NOTICE_KINDS
|
||||
@@ -393,18 +327,18 @@ class NoticeRegistry:
|
||||
last_seen: float,
|
||||
params: dict[str, Any],
|
||||
) -> None:
|
||||
# called with the lock held; held repeats from before an acknowledgement
|
||||
# still count but leave the notice hidden
|
||||
still_acknowledged = row.acknowledged_at is not None and (
|
||||
last_seen <= cast(float, row.acknowledged_at)
|
||||
)
|
||||
# called with the lock held
|
||||
fields: dict[str, Any] = {
|
||||
"count": row.count + count,
|
||||
"last_seen": last_seen,
|
||||
"params": params,
|
||||
}
|
||||
reopen_at = NOTICE_KINDS[kind].reopen_at_count
|
||||
|
||||
Notice.update(
|
||||
count=row.count + count,
|
||||
last_seen=last_seen,
|
||||
params=params,
|
||||
acknowledged_at=row.acknowledged_at if still_acknowledged else None,
|
||||
).where(Notice.id == row.id).execute()
|
||||
if reopen_at is not None and row.count < reopen_at <= row.count + count:
|
||||
fields["dismissed_at"] = None
|
||||
|
||||
Notice.update(**fields).where(Notice.id == row.id).execute()
|
||||
self._bump_occurrences(kind, count, last_seen)
|
||||
|
||||
def _prune(self, kind: str, keep: int) -> None:
|
||||
@@ -428,8 +362,7 @@ class NoticeRegistry:
|
||||
NoticeStats.create(
|
||||
kind=kind,
|
||||
occurrences=count,
|
||||
acknowledgements=0,
|
||||
mutes=0,
|
||||
dismissals=0,
|
||||
first_seen=now,
|
||||
last_seen=now,
|
||||
)
|
||||
|
||||
@@ -28,9 +28,9 @@ class NoticeKind:
|
||||
category: camera, detector, model, or system; a camera scope is a
|
||||
camera name, and the UI shows it
|
||||
link: app route or absolute URL for the row, filled in from params
|
||||
counts_repeats: whether raising an existing notice counts another
|
||||
occurrence, which also shows an acknowledged notice again
|
||||
counts_repeats: whether raising an existing notice counts another occurrence
|
||||
batch_repeats: whether repeats wait in memory for the next flush
|
||||
reopen_at_count: count at which a dismissed notice shows again
|
||||
keep_latest: rows of this kind to keep; a new row drops the oldest
|
||||
reportable: whether a future analytics reporter may send this kind's counts
|
||||
"""
|
||||
@@ -41,6 +41,7 @@ class NoticeKind:
|
||||
link: str | None = None
|
||||
counts_repeats: bool = True
|
||||
batch_repeats: bool = False
|
||||
reopen_at_count: int | None = None
|
||||
keep_latest: int | None = None
|
||||
reportable: bool = True
|
||||
|
||||
@@ -66,12 +67,6 @@ _KINDS = (
|
||||
"camera",
|
||||
link="/system#cameras",
|
||||
),
|
||||
NoticeKind(
|
||||
"ffmpeg_high_cpu", NoticeSeverity.warning, "camera", link="/system#cameras"
|
||||
),
|
||||
NoticeKind(
|
||||
"detect_high_cpu", NoticeSeverity.warning, "camera", link="/system#cameras"
|
||||
),
|
||||
NoticeKind("shm_too_low", NoticeSeverity.warning, "system", link="/system#storage"),
|
||||
# one row per user per burst; the login log lines carry the address
|
||||
NoticeKind(
|
||||
@@ -80,9 +75,10 @@ _KINDS = (
|
||||
"system",
|
||||
link="/logs",
|
||||
batch_repeats=True,
|
||||
reopen_at_count=5,
|
||||
keep_latest=100,
|
||||
),
|
||||
# one row per release, so muting it lasts until the next release
|
||||
# one row per release, so a dismissal lasts until the next release
|
||||
NoticeKind(
|
||||
"update_available",
|
||||
NoticeSeverity.info,
|
||||
@@ -96,7 +92,7 @@ _KINDS = (
|
||||
NOTICE_KINDS: dict[str, NoticeKind] = {kind.key: kind for kind in _KINDS}
|
||||
|
||||
# the Health tab builds config and stream check rows in the browser, so a notice
|
||||
# row of these kinds only records a mute; its other fields are placeholders
|
||||
# row of these kinds only records a dismissal; its other fields are placeholders
|
||||
CHECK_KINDS = frozenset({"config", "stream"})
|
||||
|
||||
|
||||
|
||||
@@ -1397,6 +1397,9 @@ class PtzAutoTracker:
|
||||
def is_autotracking(self, camera: str):
|
||||
return self.tracked_object[camera] is not None
|
||||
|
||||
def autotracked_object_region(self, camera: str):
|
||||
return self.tracked_object[camera]["region"]
|
||||
|
||||
def autotrack_object(self, camera: str, obj: TrackedObject):
|
||||
if camera not in self.config.cameras:
|
||||
return
|
||||
@@ -1535,6 +1538,8 @@ class PtzAutoTracker:
|
||||
# returns camera to preset after timeout when tracking is over
|
||||
autotracker_config = self.config.cameras[camera].onvif.autotracking
|
||||
|
||||
if not self.autotracker_init[camera]:
|
||||
self._autotracker_setup(self.config.cameras[camera], camera)
|
||||
# regularly update camera status
|
||||
if not self.ptz_metrics[camera].motor_stopped.is_set():
|
||||
await self.onvif.get_camera_status(camera)
|
||||
|
||||
@@ -966,10 +966,6 @@ class OnvifController:
|
||||
}
|
||||
else:
|
||||
logger.warning(f"ONVIF initialization failed for {camera_name}")
|
||||
self.failed_cams[camera_name] = {
|
||||
"retry_attempts": attempts + 1,
|
||||
"last_attempt": time.time(),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error during ONVIF initialization for {camera_name}: {e}"
|
||||
@@ -1124,18 +1120,6 @@ class OnvifController:
|
||||
f"Camera {camera_name} is still in ONVIF 'MOVING' status."
|
||||
)
|
||||
|
||||
async def _shutdown(self) -> None:
|
||||
"""Close the camera sessions and cancel the tasks running on the loop."""
|
||||
for cam_name in list(self.cams):
|
||||
await self._close_camera(cam_name)
|
||||
|
||||
tasks = [t for t in asyncio.all_tasks() if t is not asyncio.current_task()]
|
||||
|
||||
for task in tasks:
|
||||
task.cancel()
|
||||
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
def close(self) -> None:
|
||||
"""Gracefully shut down the ONVIF controller."""
|
||||
if not hasattr(self, "loop") or self.loop.is_closed():
|
||||
@@ -1143,16 +1127,6 @@ class OnvifController:
|
||||
return
|
||||
|
||||
logger.info("Exiting ONVIF controller...")
|
||||
|
||||
# anything left open here is garbage collected during interpreter
|
||||
# shutdown, where its warnings can no longer be logged cleanly
|
||||
try:
|
||||
asyncio.run_coroutine_threadsafe(self._shutdown(), self.loop).result(
|
||||
timeout=5
|
||||
)
|
||||
except TimeoutError:
|
||||
logger.debug("Timed out closing ONVIF sessions")
|
||||
|
||||
self.config_subscriber.stop()
|
||||
|
||||
def stop_and_cleanup():
|
||||
|
||||
+23
-91
@@ -42,8 +42,6 @@ from frigate.util.ownership import chown_to_runtime
|
||||
from frigate.util.recording_coverage import (
|
||||
build_spans,
|
||||
known_video_codecs,
|
||||
null_audio_glitches,
|
||||
realized_timeline,
|
||||
resolve_coverage,
|
||||
stream_media_summary,
|
||||
)
|
||||
@@ -86,20 +84,10 @@ class StreamRun:
|
||||
|
||||
@dataclass
|
||||
class _ChapterWindow:
|
||||
"""A merged-timeline slice, shaped like the recording rows chapters read.
|
||||
|
||||
lead_in is the output time the slice's vod clip plays before start_time,
|
||||
from snapping its first frame back to a keyframe.
|
||||
"""
|
||||
"""A merged-timeline slice, shaped like the recording rows chapters read."""
|
||||
|
||||
start_time: float
|
||||
end_time: float
|
||||
lead_in: float = 0.0
|
||||
|
||||
|
||||
def _lead_in(recording: Any) -> float:
|
||||
"""Output seconds a chapter source plays before its first wall second."""
|
||||
return recording.lead_in if isinstance(recording, _ChapterWindow) else 0.0
|
||||
|
||||
|
||||
# Matches the setpts factor used in timelapse exports (e.g. setpts=0.04*PTS).
|
||||
@@ -388,18 +376,15 @@ class RecordingExporter(threading.Thread):
|
||||
def _resolve_coverage(self) -> tuple[list[list[Any]], set[str], bool]:
|
||||
"""Resolve the export range into the spans the VOD manifest will serve.
|
||||
|
||||
Delegates to the same coverage resolution and glitch nulling the
|
||||
manifest builder uses, so what we plan around and what nginx-vod
|
||||
emits agree by construction. Returns the spans (each [row, start,
|
||||
end, is_main]), the known video codecs, and whether audio survives
|
||||
the range.
|
||||
Delegates to the same coverage resolution the manifest builder
|
||||
uses, so what we plan around and what nginx-vod emits agree by
|
||||
construction. Returns the spans (each [row, start, end, is_main]),
|
||||
the known video codecs, and whether audio survives the range.
|
||||
Memoized: several stages of the export ask the same question, and
|
||||
the recordings backing a finished range do not change under us.
|
||||
"""
|
||||
if self._coverage is None:
|
||||
intervals = null_audio_glitches(
|
||||
resolve_coverage(self.camera, self.start_time, self.end_time)
|
||||
)
|
||||
intervals = resolve_coverage(self.camera, self.start_time, self.end_time)
|
||||
self._coverage = (
|
||||
build_spans(intervals, self.pinned_stream),
|
||||
known_video_codecs(intervals),
|
||||
@@ -448,57 +433,17 @@ class RecordingExporter(threading.Thread):
|
||||
# hand-off to stage around
|
||||
return True
|
||||
|
||||
_spans, codecs, keep_audio = self._resolve_coverage()
|
||||
runs = self._planned_stream_runs()
|
||||
spans, codecs, keep_audio = self._resolve_coverage()
|
||||
runs = self._stream_runs(spans)
|
||||
|
||||
# a range one stream covers end to end has nothing to hand off,
|
||||
# so it stays on the existing path however long it is
|
||||
if len(runs) < 2:
|
||||
return True
|
||||
|
||||
runs = [piece for run in runs for piece in self._split_long_run(run)]
|
||||
return self._stage_stream_runs(runs, codecs, keep_audio)
|
||||
|
||||
def _planned_stream_runs(self) -> list[StreamRun]:
|
||||
"""The runs a mixed range is staged as, one pinned vod playlist each."""
|
||||
runs = self._stream_runs(self._merged_spans())
|
||||
|
||||
if len(runs) < 2:
|
||||
return runs
|
||||
|
||||
return [piece for run in runs for piece in self._split_long_run(run)]
|
||||
|
||||
def _staged_chapter_windows(self) -> list[_ChapterWindow]:
|
||||
"""Chapter windows for the staged files as they were rendered.
|
||||
|
||||
Each staged run comes from its own pinned vod playlist, whose first
|
||||
clip snaps back to the preceding keyframe, so a staged file runs up
|
||||
to a GOP longer than its slice of the merged timeline. Planning each
|
||||
run the way its playlist does carries that lead-in into the chapter
|
||||
offsets instead of letting it accumulate at every hand-off.
|
||||
"""
|
||||
windows: list[_ChapterWindow] = []
|
||||
|
||||
for run in self._planned_stream_runs():
|
||||
intervals = null_audio_glitches(
|
||||
resolve_coverage(self.camera, run.start_time, run.end_time)
|
||||
)
|
||||
|
||||
for clip in realized_timeline(intervals, run.stream_type):
|
||||
# a skipped clip is absent from the playlist and the file
|
||||
if clip["duration"] <= 0:
|
||||
continue
|
||||
|
||||
span = clip["end_time"] - clip["start_time"]
|
||||
windows.append(
|
||||
_ChapterWindow(
|
||||
clip["start_time"],
|
||||
clip["end_time"],
|
||||
max(0.0, clip["duration"] / 1000 - span),
|
||||
)
|
||||
)
|
||||
|
||||
return windows
|
||||
|
||||
def _stream_runs(self, spans: list[list[Any]]) -> list[StreamRun]:
|
||||
"""Collapse the merged spans into contiguous runs of one stream type.
|
||||
|
||||
@@ -895,8 +840,6 @@ class RecordingExporter(threading.Thread):
|
||||
clipped_end = min(float(rec.end_time), float(self.end_time))
|
||||
if clipped_end <= clipped_start:
|
||||
continue
|
||||
# a staged window's keyframe lead-in plays before it
|
||||
output_offset += _lead_in(rec)
|
||||
windows.append((clipped_start, clipped_end, output_offset))
|
||||
output_offset += clipped_end - clipped_start
|
||||
|
||||
@@ -1044,13 +987,9 @@ class RecordingExporter(threading.Thread):
|
||||
if duration_ms <= 0:
|
||||
continue
|
||||
|
||||
# a staged window's keyframe lead-in opens its chapter, with
|
||||
# frames captured that long before the window
|
||||
lead_in = _lead_in(rec)
|
||||
duration_ms += int(round(lead_in * 1000))
|
||||
title = datetime.datetime.fromtimestamp(
|
||||
clipped_start - lead_in, tz=tz
|
||||
).isoformat(timespec="seconds")
|
||||
title = datetime.datetime.fromtimestamp(clipped_start, tz=tz).isoformat(
|
||||
timespec="seconds"
|
||||
)
|
||||
chapter_blocks.append(
|
||||
"[CHAPTER]\n"
|
||||
"TIMEBASE=1/1000\n"
|
||||
@@ -1189,12 +1128,12 @@ class RecordingExporter(threading.Thread):
|
||||
if self.staged_runs:
|
||||
# each run was already rendered to a temp file with a common
|
||||
# track timescale, so the concat demuxer has nothing left to
|
||||
# reconcile
|
||||
recordings = (
|
||||
self._staged_chapter_windows()
|
||||
if self.chapters not in (None, ChaptersEnum.none)
|
||||
else []
|
||||
)
|
||||
# reconcile and every chapter offset lines up with the merged
|
||||
# timeline the staged files reproduce
|
||||
recordings = [
|
||||
_ChapterWindow(span_start, span_end)
|
||||
for _row, span_start, span_end, _is_main in self._merged_spans()
|
||||
]
|
||||
playlist_lines: list[str] = [f"file '{path}'" for path in self.staged_runs]
|
||||
ffmpeg_input = (
|
||||
"-y -protocol_whitelist pipe,file -f concat -safe 0 -i /dev/stdin"
|
||||
@@ -1210,18 +1149,11 @@ class RecordingExporter(threading.Thread):
|
||||
# its own rows are the ones the chapters describe
|
||||
recordings = self._get_recordings_for_range(pin)
|
||||
else:
|
||||
# an unstaged auto range resolves to at most one stream run, and
|
||||
# its rows are the ones the chapters describe. Main rows the
|
||||
# manifest drops (glitches, slivers at the edges of a sub range)
|
||||
# must not stand in for it.
|
||||
runs = self._stream_runs(self._merged_spans())
|
||||
recordings = self._get_recordings_for_range(
|
||||
runs[0].stream_type if runs else STREAM_TYPE_MAIN
|
||||
)
|
||||
# never mix streams in one playlist; use main when available
|
||||
# and fall back to sub for expired-main history
|
||||
recordings = self._get_recordings_for_range(STREAM_TYPE_MAIN)
|
||||
|
||||
# never mix streams in one playlist; fall back to sub for
|
||||
# expired-main history
|
||||
if not recordings and not runs:
|
||||
if not recordings:
|
||||
recordings = self._get_recordings_for_range(STREAM_TYPE_SUB)
|
||||
|
||||
playlist_lines = []
|
||||
@@ -1375,7 +1307,7 @@ class RecordingExporter(threading.Thread):
|
||||
|
||||
if preview.end_time > self.end_time:
|
||||
playlist_lines.append(
|
||||
f"outpoint {int(self.end_time - preview.start_time)}"
|
||||
f"outpoint {int(preview.end_time - self.end_time)}"
|
||||
)
|
||||
|
||||
ffmpeg_input = (
|
||||
|
||||
@@ -490,17 +490,9 @@ class RecordingMaintainer(threading.Thread):
|
||||
)
|
||||
reviews = reviews_by_camera[camera]
|
||||
|
||||
# probes run concurrently, but each segment's start chains off the
|
||||
# previous segment's end, so starts resolve in segment order
|
||||
previous: asyncio.Event | None = None
|
||||
for recording in recordings:
|
||||
resolved = asyncio.Event()
|
||||
tasks.append(
|
||||
self._validate_in_order(
|
||||
camera, reviews, recording, previous, resolved
|
||||
)
|
||||
)
|
||||
previous = resolved
|
||||
tasks.extend(
|
||||
[self.validate_and_move_segment(camera, reviews, r) for r in recordings]
|
||||
)
|
||||
|
||||
# publish most recently available recording time and None if disabled
|
||||
if stream_type == STREAM_TYPE_MAIN:
|
||||
@@ -558,33 +550,12 @@ class RecordingMaintainer(threading.Thread):
|
||||
while info and info[0][0] < expire_before:
|
||||
info.pop(0)
|
||||
|
||||
async def _validate_in_order(
|
||||
self,
|
||||
camera: str,
|
||||
reviews: Any,
|
||||
recording: dict[str, Any],
|
||||
previous_start: asyncio.Event | None,
|
||||
start_resolved: asyncio.Event,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Validate a segment, always releasing the next one in its stream."""
|
||||
try:
|
||||
return await self.validate_and_move_segment(
|
||||
camera, reviews, recording, previous_start, start_resolved
|
||||
)
|
||||
finally:
|
||||
start_resolved.set()
|
||||
|
||||
def drop_segment(self, cache_path: str) -> None:
|
||||
Path(cache_path).unlink(missing_ok=True)
|
||||
self.end_time_cache.pop(cache_path, None)
|
||||
|
||||
async def validate_and_move_segment(
|
||||
self,
|
||||
camera: str,
|
||||
reviews: Any,
|
||||
recording: dict[str, Any],
|
||||
previous_start: asyncio.Event | None = None,
|
||||
start_resolved: asyncio.Event | None = None,
|
||||
self, camera: str, reviews: Any, recording: dict[str, Any]
|
||||
) -> dict[str, Any] | None:
|
||||
cache_path: str = recording["cache_path"]
|
||||
start_time: datetime.datetime = recording["start_time"]
|
||||
@@ -646,9 +617,6 @@ class RecordingMaintainer(threading.Thread):
|
||||
async with self.probe_semaphore:
|
||||
keyframes = await get_keyframe_offsets(cache_path)
|
||||
|
||||
if previous_start is not None:
|
||||
await previous_start.wait()
|
||||
|
||||
start_time = self._resolve_segment_start(
|
||||
camera, stream_type, start_time, duration, cache_path
|
||||
)
|
||||
@@ -686,27 +654,6 @@ class RecordingMaintainer(threading.Thread):
|
||||
RecordingsDataTypeEnum.valid.value,
|
||||
)
|
||||
|
||||
# the start is settled, so the next segment of the stream can chain
|
||||
# off it while this one waits on retention and the move
|
||||
if start_resolved is not None:
|
||||
start_resolved.set()
|
||||
|
||||
# assume that empty means the relevant recording info has not been received yet
|
||||
camera_info = self.object_recordings_info[camera]
|
||||
most_recently_processed_frame_time = (
|
||||
camera_info[-1][0] if len(camera_info) > 0 else 0
|
||||
)
|
||||
|
||||
# ensure delayed segment info does not lead to lost segments, every
|
||||
# retention decision below depends on complete stats for the segment
|
||||
if (
|
||||
datetime.datetime.fromtimestamp(
|
||||
most_recently_processed_frame_time
|
||||
).astimezone(datetime.UTC)
|
||||
< end_time
|
||||
):
|
||||
return None
|
||||
|
||||
record_config = self.config.cameras[camera].record
|
||||
|
||||
# sub's alerts/detections carry the retain mode directly, unlike
|
||||
@@ -734,28 +681,43 @@ class RecordingMaintainer(threading.Thread):
|
||||
# we should first just check if this segment matches that
|
||||
# and avoid any DB calls
|
||||
if highest is not None:
|
||||
record_mode = (
|
||||
RetainModeEnum.all if highest == "continuous" else RetainModeEnum.motion
|
||||
# assume that empty means the relevant recording info has not been received yet
|
||||
camera_info = self.object_recordings_info[camera]
|
||||
most_recently_processed_frame_time = (
|
||||
camera_info[-1][0] if len(camera_info) > 0 else 0
|
||||
)
|
||||
segment_stats = self.segment_stats(camera, start_time, end_time)
|
||||
|
||||
# Here we only check if we should move the segment based on non-object recording retention
|
||||
# we will always want to check for overlapping review items below before dropping the segment
|
||||
if not segment_stats.should_discard_segment(record_mode):
|
||||
return await self.move_segment(
|
||||
camera,
|
||||
stream_type,
|
||||
start_time,
|
||||
end_time,
|
||||
duration,
|
||||
cache_path,
|
||||
segment_stats,
|
||||
has_audio,
|
||||
audio_rate,
|
||||
audio_codec,
|
||||
video_codec,
|
||||
keyframes,
|
||||
# ensure delayed segment info does not lead to lost segments
|
||||
if (
|
||||
datetime.datetime.fromtimestamp(
|
||||
most_recently_processed_frame_time
|
||||
).astimezone(datetime.UTC)
|
||||
>= end_time
|
||||
):
|
||||
record_mode = (
|
||||
RetainModeEnum.all
|
||||
if highest == "continuous"
|
||||
else RetainModeEnum.motion
|
||||
)
|
||||
segment_stats = self.segment_stats(camera, start_time, end_time)
|
||||
|
||||
# Here we only check if we should move the segment based on non-object recording retention
|
||||
# we will always want to check for overlapping review items below before dropping the segment
|
||||
if not segment_stats.should_discard_segment(record_mode):
|
||||
return await self.move_segment(
|
||||
camera,
|
||||
stream_type,
|
||||
start_time,
|
||||
end_time,
|
||||
duration,
|
||||
cache_path,
|
||||
segment_stats,
|
||||
has_audio,
|
||||
audio_rate,
|
||||
audio_codec,
|
||||
video_codec,
|
||||
keyframes,
|
||||
)
|
||||
|
||||
# we fell through the continuous / motion check, so we need to check the review items
|
||||
# if the cached segment overlaps with the review items:
|
||||
@@ -817,6 +779,10 @@ class RecordingMaintainer(threading.Thread):
|
||||
# continuous/motion retention (either disabled or segment_stats said
|
||||
# discard), so waiting longer just fills the cache.
|
||||
else:
|
||||
camera_info = self.object_recordings_info[camera]
|
||||
most_recently_processed_frame_time = (
|
||||
camera_info[-1][0] if len(camera_info) > 0 else 0
|
||||
)
|
||||
retain_cutoff = datetime.datetime.fromtimestamp(
|
||||
most_recently_processed_frame_time - record_config.event_pre_capture
|
||||
).astimezone(datetime.UTC)
|
||||
@@ -1124,7 +1090,6 @@ class RecordingMaintainer(threading.Thread):
|
||||
elif (
|
||||
topic == DetectionTypeEnum.api.value
|
||||
or topic == DetectionTypeEnum.lpr.value
|
||||
or topic == DetectionTypeEnum.classification_state.value
|
||||
):
|
||||
continue
|
||||
|
||||
|
||||
+66
-203
@@ -40,10 +40,6 @@ logger = logging.getLogger(__name__)
|
||||
THUMB_HEIGHT = 180
|
||||
THUMB_WIDTH = 320
|
||||
|
||||
# seconds before a review item starts that a state classification change is
|
||||
# still attached to it, e.g. a garage door opening before the car is visible
|
||||
CLASSIFICATION_STATE_PRE_ROLL = 5
|
||||
|
||||
|
||||
class PendingReviewSegment:
|
||||
def __init__(
|
||||
@@ -65,10 +61,6 @@ class PendingReviewSegment:
|
||||
self.sub_labels = sub_labels
|
||||
self.zones = zones
|
||||
self.audio = audio
|
||||
self.classification_state_changes: list[dict[str, Any]] = []
|
||||
# detection-level activity after the last alert activity, split by the
|
||||
# detection cutoff, these are published when the alert is cut off
|
||||
self.pending_detections: list[PendingReviewSegment] = []
|
||||
self.thumb_time: float | None = None
|
||||
self.last_alert_time: float | None = None
|
||||
self.last_detection_time: float = frame_time
|
||||
@@ -86,20 +78,6 @@ class PendingReviewSegment:
|
||||
CLIPS_DIR, f"review/thumb-{self.camera}-{self.id}.webp"
|
||||
)
|
||||
|
||||
def add_object(self, obj: dict[str, Any], attributes: list[str]) -> None:
|
||||
"""Add a tracked object's label, sub label, and zones to the segment."""
|
||||
if not obj["sub_label"]:
|
||||
self.detections[obj["id"]] = obj["label"]
|
||||
elif obj["sub_label"][0] in attributes:
|
||||
self.detections[obj["id"]] = obj["sub_label"][0]
|
||||
else:
|
||||
self.detections[obj["id"]] = f"{obj['label']}-verified"
|
||||
self.sub_labels[obj["id"]] = obj["sub_label"][0]
|
||||
|
||||
for zone in obj["current_zones"]:
|
||||
if zone not in self.zones:
|
||||
self.zones.append(zone)
|
||||
|
||||
def update_frame(
|
||||
self,
|
||||
camera_config: CameraConfig,
|
||||
@@ -184,7 +162,6 @@ class PendingReviewSegment:
|
||||
"sub_labels": list(self.sub_labels.values()),
|
||||
"zones": self.zones,
|
||||
"audio": list(self.audio),
|
||||
"classification_state_changes": self.classification_state_changes,
|
||||
"thumb_time": self.thumb_time,
|
||||
"metadata": None,
|
||||
},
|
||||
@@ -316,9 +293,6 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
# manual events
|
||||
self.indefinite_events: dict[str, dict[str, Any]] = {}
|
||||
|
||||
# state classification changes seen while a camera had no review item
|
||||
self.recent_classification_state_changes: dict[str, list[dict[str, Any]]] = {}
|
||||
|
||||
# ensure dirs
|
||||
Path(os.path.join(CLIPS_DIR, "review")).mkdir(exist_ok=True)
|
||||
|
||||
@@ -400,43 +374,6 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
self.active_review_segments[segment.camera] = None
|
||||
return end_time
|
||||
|
||||
def _activate_segment(self, segment: PendingReviewSegment) -> None:
|
||||
"""Make a segment the camera's active one, attaching any state
|
||||
classification changes seen just before it started."""
|
||||
self.active_review_segments[segment.camera] = segment
|
||||
recent = self.recent_classification_state_changes.pop(segment.camera, [])
|
||||
segment.classification_state_changes.extend(
|
||||
c
|
||||
for c in recent
|
||||
if c["timestamp"] >= segment.start_time - CLASSIFICATION_STATE_PRE_ROLL
|
||||
)
|
||||
|
||||
def handle_classification_state_change(
|
||||
self, camera: str, change: dict[str, Any]
|
||||
) -> None:
|
||||
"""Attach a verified state classification change to the active segment.
|
||||
|
||||
State changes never start, extend, or upgrade a segment. A change seen
|
||||
with no active segment is held briefly for a segment starting right
|
||||
after it.
|
||||
"""
|
||||
segment = self.active_review_segments.get(camera)
|
||||
|
||||
if segment is None:
|
||||
cutoff = change["timestamp"] - CLASSIFICATION_STATE_PRE_ROLL
|
||||
self.recent_classification_state_changes[camera] = [
|
||||
c
|
||||
for c in self.recent_classification_state_changes.get(camera, [])
|
||||
if c["timestamp"] >= cutoff
|
||||
] + [change]
|
||||
return
|
||||
|
||||
prev_data = segment.get_data(False)
|
||||
segment.classification_state_changes.append(change)
|
||||
self._publish_segment_update(
|
||||
segment, self.config.cameras[camera], None, [], prev_data
|
||||
)
|
||||
|
||||
def forcibly_end_segment(self, camera: str) -> Any:
|
||||
"""Forcibly end the pending segment for a camera."""
|
||||
segment = self.active_review_segments.get(camera)
|
||||
@@ -452,29 +389,9 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
segment.last_detection_time = now
|
||||
|
||||
prev_data = segment.get_data(False)
|
||||
end_time = self._publish_segment_end(segment, prev_data)
|
||||
self._publish_pending_detections(segment, None)
|
||||
return end_time
|
||||
return self._publish_segment_end(segment, prev_data)
|
||||
return None
|
||||
|
||||
def _publish_pending_detections(
|
||||
self, segment: PendingReviewSegment, ongoing_since: float | None
|
||||
) -> None:
|
||||
"""Publish the detections held while an ended alert was active.
|
||||
|
||||
A detection with activity after ongoing_since stays open, only the
|
||||
latest can. With None every detection is ended, this does not read the
|
||||
camera config since a removed camera is no longer in it.
|
||||
"""
|
||||
for pending in segment.pending_detections:
|
||||
self._activate_segment(pending)
|
||||
self._publish_segment_start(pending)
|
||||
|
||||
if ongoing_since is None or pending.last_detection_time < ongoing_since:
|
||||
self._publish_segment_end(pending, pending.get_data(False))
|
||||
|
||||
segment.pending_detections = []
|
||||
|
||||
def get_manual_event_severity(self, camera: str, label: str) -> SeverityEnum | None:
|
||||
"""Determine the review severity for a manual event label.
|
||||
|
||||
@@ -505,56 +422,6 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
"""Close out a deleted camera's segment so a reused name cannot inherit it."""
|
||||
self.forcibly_end_segment(camera)
|
||||
self.indefinite_events.pop(camera, None)
|
||||
self.recent_classification_state_changes.pop(camera, None)
|
||||
|
||||
def _track_pending_detection(
|
||||
self,
|
||||
segment: PendingReviewSegment,
|
||||
camera_config: CameraConfig,
|
||||
frame_name: str,
|
||||
frame_time: float,
|
||||
objects: list[dict[str, Any]],
|
||||
) -> None:
|
||||
"""Hold detection-level activity seen after an alert's last alert
|
||||
activity, starting a separate detection when the gap since the previous
|
||||
activity exceeds the detection cutoff."""
|
||||
pending = segment.pending_detections[-1] if segment.pending_detections else None
|
||||
|
||||
if pending is None or frame_time > (
|
||||
pending.last_detection_time + camera_config.review.detections.cutoff_time
|
||||
):
|
||||
pending = PendingReviewSegment(
|
||||
segment.camera,
|
||||
frame_time,
|
||||
SeverityEnum.detection,
|
||||
{},
|
||||
sub_labels={},
|
||||
audio=set(),
|
||||
zones=[],
|
||||
)
|
||||
segment.pending_detections.append(pending)
|
||||
|
||||
pending.last_detection_time = frame_time
|
||||
|
||||
for obj in objects:
|
||||
pending.add_object(obj, self.config.all_attributes)
|
||||
|
||||
if len(objects) <= pending.frame_active_count:
|
||||
return
|
||||
|
||||
try:
|
||||
yuv_frame = self.frame_manager.get(
|
||||
frame_name, camera_config.frame_shape_yuv
|
||||
)
|
||||
except FileNotFoundError:
|
||||
return
|
||||
|
||||
if yuv_frame is None:
|
||||
logger.debug(f"Failed to get frame {frame_name} from SHM")
|
||||
return
|
||||
|
||||
pending.update_frame(camera_config, yuv_frame, objects)
|
||||
self.frame_manager.close(frame_name)
|
||||
|
||||
def update_existing_segment(
|
||||
self,
|
||||
@@ -591,21 +458,6 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
should_update_state = True
|
||||
should_update_image = True
|
||||
|
||||
# alert activity resumed, so the pending detection activity
|
||||
# falls within this alert
|
||||
for pending in segment.pending_detections:
|
||||
segment.detections.update(pending.detections)
|
||||
segment.sub_labels.update(pending.sub_labels)
|
||||
|
||||
for zone in pending.zones:
|
||||
if zone not in segment.zones:
|
||||
segment.zones.append(zone)
|
||||
|
||||
Path(pending.frame_path).unlink(missing_ok=True)
|
||||
should_update_state = True
|
||||
|
||||
segment.pending_detections = []
|
||||
|
||||
if activity.has_activity_category(SeverityEnum.detection):
|
||||
if (
|
||||
segment.last_detection_time is None
|
||||
@@ -613,8 +465,6 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
):
|
||||
segment.last_detection_time = frame_time
|
||||
|
||||
pending_objects: list[dict[str, Any]] = []
|
||||
|
||||
for object in activity.get_all_objects():
|
||||
# Alert-level objects should always be added (they extend/upgrade the segment)
|
||||
# Detection-level objects should only be added if:
|
||||
@@ -625,22 +475,23 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
|
||||
if not is_alert_object and segment.severity == SeverityEnum.alert:
|
||||
# This is a detection-level object
|
||||
if (
|
||||
segment.last_alert_time is not None
|
||||
and frame_time > segment.last_alert_time
|
||||
):
|
||||
pending_objects.append(object)
|
||||
|
||||
# Only add if it started during the alert's active period
|
||||
if object["start_time"] > segment.last_alert_time:
|
||||
continue
|
||||
|
||||
segment.add_object(object, self.config.all_attributes)
|
||||
if not object["sub_label"]:
|
||||
segment.detections[object["id"]] = object["label"]
|
||||
elif object["sub_label"][0] in self.config.all_attributes:
|
||||
segment.detections[object["id"]] = object["sub_label"][0]
|
||||
else:
|
||||
segment.detections[object["id"]] = f"{object['label']}-verified"
|
||||
segment.sub_labels[object["id"]] = object["sub_label"][0]
|
||||
|
||||
if pending_objects:
|
||||
self._track_pending_detection(
|
||||
segment, camera_config, frame_name, frame_time, pending_objects
|
||||
)
|
||||
# keep zones up to date
|
||||
if len(object["current_zones"]) > 0:
|
||||
for zone in object["current_zones"]:
|
||||
if zone not in segment.zones:
|
||||
segment.zones.append(zone)
|
||||
|
||||
if len(activity.get_all_objects()) > segment.frame_active_count:
|
||||
should_update_state = True
|
||||
@@ -692,28 +543,50 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
except FileNotFoundError:
|
||||
return
|
||||
|
||||
# detection-level activity must not keep an alert open, it continues
|
||||
# in a new detection segment once the alert is cut off
|
||||
if (
|
||||
segment.severity == SeverityEnum.alert
|
||||
and segment.last_alert_time is not None
|
||||
and frame_time
|
||||
> (segment.last_alert_time + camera_config.review.alerts.cutoff_time)
|
||||
):
|
||||
self._publish_segment_end(segment, prev_data)
|
||||
self._publish_pending_detections(
|
||||
segment, frame_time - camera_config.review.detections.cutoff_time
|
||||
)
|
||||
elif (
|
||||
not has_activity
|
||||
and segment.severity == SeverityEnum.detection
|
||||
and frame_time
|
||||
> (
|
||||
if (
|
||||
segment.severity == SeverityEnum.alert
|
||||
and segment.last_alert_time is not None
|
||||
and frame_time
|
||||
> (segment.last_alert_time + camera_config.review.alerts.cutoff_time)
|
||||
):
|
||||
needs_new_detection = (
|
||||
segment.last_detection_time > segment.last_alert_time
|
||||
and (
|
||||
segment.last_detection_time
|
||||
+ camera_config.review.detections.cutoff_time
|
||||
)
|
||||
> frame_time
|
||||
)
|
||||
last_detection_time = segment.last_detection_time
|
||||
|
||||
end_time = self._publish_segment_end(segment, prev_data)
|
||||
|
||||
if needs_new_detection:
|
||||
new_detections: dict[str, str] = {}
|
||||
new_zones = set()
|
||||
|
||||
for o in activity.categorized_objects["detections"]:
|
||||
new_detections[o["id"]] = o["label"]
|
||||
new_zones.update(o["current_zones"])
|
||||
|
||||
if new_detections:
|
||||
new_segment = PendingReviewSegment(
|
||||
segment.camera,
|
||||
end_time,
|
||||
SeverityEnum.detection,
|
||||
new_detections,
|
||||
sub_labels={},
|
||||
audio=set(),
|
||||
zones=list(new_zones),
|
||||
)
|
||||
self.active_review_segments[segment.camera] = new_segment
|
||||
self._publish_segment_start(new_segment)
|
||||
new_segment.last_detection_time = last_detection_time
|
||||
elif segment.severity == SeverityEnum.detection and frame_time > (
|
||||
segment.last_detection_time
|
||||
+ camera_config.review.detections.cutoff_time
|
||||
)
|
||||
):
|
||||
self._publish_segment_end(segment, prev_data)
|
||||
):
|
||||
self._publish_segment_end(segment, prev_data)
|
||||
|
||||
def check_if_new_segment(
|
||||
self,
|
||||
@@ -766,7 +639,7 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
audio=set(),
|
||||
zones=zones,
|
||||
)
|
||||
self._activate_segment(new_segment)
|
||||
self.active_review_segments[camera] = new_segment
|
||||
|
||||
try:
|
||||
yuv_frame = self.frame_manager.get(
|
||||
@@ -840,10 +713,6 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
|
||||
if camera not in self.indefinite_events:
|
||||
self.indefinite_events[camera] = {}
|
||||
elif topic == DetectionTypeEnum.classification_state.value:
|
||||
(camera, classification_change) = data
|
||||
else:
|
||||
continue
|
||||
|
||||
if camera not in self.config.cameras:
|
||||
continue
|
||||
@@ -854,10 +723,6 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
):
|
||||
continue
|
||||
|
||||
if topic == DetectionTypeEnum.classification_state:
|
||||
self.handle_classification_state_change(camera, classification_change)
|
||||
continue
|
||||
|
||||
current_segment = self.active_review_segments.get(camera)
|
||||
|
||||
# Check if the current segment should be processed based on enabled settings
|
||||
@@ -999,16 +864,14 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
severity = SeverityEnum.detection
|
||||
|
||||
if severity:
|
||||
self._activate_segment(
|
||||
PendingReviewSegment(
|
||||
camera,
|
||||
frame_time,
|
||||
severity,
|
||||
{},
|
||||
{},
|
||||
[],
|
||||
detections,
|
||||
)
|
||||
self.active_review_segments[camera] = PendingReviewSegment(
|
||||
camera,
|
||||
frame_time,
|
||||
severity,
|
||||
{},
|
||||
{},
|
||||
[],
|
||||
detections,
|
||||
)
|
||||
elif topic == DetectionTypeEnum.api:
|
||||
severity = self.get_manual_event_severity(
|
||||
@@ -1025,7 +888,7 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
[],
|
||||
set(),
|
||||
)
|
||||
self._activate_segment(api_segment)
|
||||
self.active_review_segments[camera] = api_segment
|
||||
|
||||
if manual_info["state"] == ManualEventState.start:
|
||||
self.indefinite_events[camera][manual_info["event_id"]] = (
|
||||
@@ -1052,7 +915,7 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
[],
|
||||
set(),
|
||||
)
|
||||
self._activate_segment(lpr_segment)
|
||||
self.active_review_segments[camera] = lpr_segment
|
||||
|
||||
if manual_info["state"] == ManualEventState.start:
|
||||
self.indefinite_events[camera][manual_info["event_id"]] = (
|
||||
|
||||
+15
-61
@@ -29,59 +29,38 @@ VERSION_REFRESH_S = 24 * 60 * 60
|
||||
# a camera skipping at least this percent of its frames is falling behind
|
||||
SKIPPED_DETECTIONS_PCT = 5
|
||||
|
||||
# lifetime CPU averages at or above these are high; they match
|
||||
# CameraFfmpegThreshold.error and CameraDetectThreshold.error in the UI
|
||||
FFMPEG_HIGH_CPU_PCT = 20
|
||||
DETECT_HIGH_CPU_PCT = 40
|
||||
|
||||
# a camera stays over a threshold this long before it becomes a notice
|
||||
EPISODE_HOLD_S = 60
|
||||
# for at least this long before it becomes a notice
|
||||
SKIPPED_DETECTIONS_HOLD_S = 60
|
||||
|
||||
# detectors warm up after a start; the status bar waits this long too
|
||||
STARTUP_GRACE_S = 120
|
||||
|
||||
|
||||
def cpu_average(cpu_usages: dict[str, Any], pid: int | None) -> float | None:
|
||||
"""A process's CPU use averaged over its lifetime, or None if unknown."""
|
||||
usage = cpu_usages.get(str(pid)) if pid else None
|
||||
class SkippedDetectionsTracker:
|
||||
"""Finds cameras whose skipped share stays high long enough for a notice."""
|
||||
|
||||
try:
|
||||
return float(usage["cpu_average"]) if usage else None
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
class EpisodeTracker:
|
||||
"""Finds cameras whose value stays over a threshold long enough for a notice."""
|
||||
|
||||
def __init__(self, threshold: float) -> None:
|
||||
self._threshold = threshold
|
||||
def __init__(self) -> None:
|
||||
self._since: dict[str, float] = {}
|
||||
self._raised: set[str] = set()
|
||||
|
||||
def update(self, values: dict[str, float | None], now: float) -> list[str]:
|
||||
"""Return the cameras whose episode qualified on this sample.
|
||||
|
||||
Args:
|
||||
values: Each camera's current value, or None when it has none
|
||||
now: Sample time in seconds
|
||||
"""
|
||||
def update(self, cameras: dict[str, dict[str, Any]], now: float) -> list[str]:
|
||||
"""Return the cameras whose episode qualified on this sample."""
|
||||
qualified: list[str] = []
|
||||
|
||||
# a removed camera that comes back starts a new episode
|
||||
for camera in self._since.keys() - values.keys():
|
||||
for camera in self._since.keys() - cameras.keys():
|
||||
self._since.pop(camera)
|
||||
self._raised.discard(camera)
|
||||
|
||||
for camera, value in values.items():
|
||||
if value is None or value < self._threshold:
|
||||
for camera, camera_stats in cameras.items():
|
||||
if camera_stats["skipped_pct"] < SKIPPED_DETECTIONS_PCT:
|
||||
self._since.pop(camera, None)
|
||||
self._raised.discard(camera)
|
||||
continue
|
||||
|
||||
since = self._since.setdefault(camera, now)
|
||||
|
||||
if camera not in self._raised and now - since >= EPISODE_HOLD_S:
|
||||
if camera not in self._raised and now - since >= SKIPPED_DETECTIONS_HOLD_S:
|
||||
self._raised.add(camera)
|
||||
qualified.append(camera)
|
||||
|
||||
@@ -101,9 +80,7 @@ class StatsEmitter(threading.Thread):
|
||||
self.stop_event = stop_event
|
||||
self.hardware_stats = HardwareStats(config)
|
||||
self.stats_history: list[dict[str, Any]] = []
|
||||
self.skipped_detections = EpisodeTracker(SKIPPED_DETECTIONS_PCT)
|
||||
self.ffmpeg_cpu = EpisodeTracker(FFMPEG_HIGH_CPU_PCT)
|
||||
self.detect_cpu = EpisodeTracker(DETECT_HIGH_CPU_PCT)
|
||||
self.skipped_detections = SkippedDetectionsTracker()
|
||||
|
||||
# the shm notice's params as last sent, so only a change is written
|
||||
self._shm_checked = False
|
||||
@@ -250,38 +227,15 @@ class StatsEmitter(threading.Thread):
|
||||
|
||||
def _update_notices(self, stats: dict[str, Any], now: float) -> None:
|
||||
"""Update notices based on current stats or time."""
|
||||
cameras = stats["cameras"]
|
||||
|
||||
# absent when CPU collection timed out or failed on this tick
|
||||
cpu_usages = stats.get("cpu_usages", {})
|
||||
|
||||
# skipped detections
|
||||
if stats["service"]["uptime"] >= STARTUP_GRACE_S:
|
||||
# skipped detections
|
||||
skipped = {
|
||||
camera: camera_stats["skipped_pct"]
|
||||
for camera, camera_stats in cameras.items()
|
||||
}
|
||||
|
||||
for camera in self.skipped_detections.update(skipped, now):
|
||||
for camera in self.skipped_detections.update(stats["cameras"], now):
|
||||
raise_notice(
|
||||
"skipped_detections",
|
||||
scope=camera,
|
||||
params={"pct": skipped[camera]},
|
||||
params={"pct": stats["cameras"][camera]["skipped_pct"]},
|
||||
)
|
||||
|
||||
# high ffmpeg and detect CPU
|
||||
for tracker, kind, pid_key in (
|
||||
(self.ffmpeg_cpu, "ffmpeg_high_cpu", "ffmpeg_pid"),
|
||||
(self.detect_cpu, "detect_high_cpu", "pid"),
|
||||
):
|
||||
averages = {
|
||||
camera: cpu_average(cpu_usages, camera_stats.get(pid_key))
|
||||
for camera, camera_stats in cameras.items()
|
||||
}
|
||||
|
||||
for camera in tracker.update(averages, now):
|
||||
raise_notice(kind, scope=camera, params={"cpu": averages[camera]})
|
||||
|
||||
# shm too small for the cameras
|
||||
self._update_shm_notice(stats["service"]["storage"]["/dev/shm"])
|
||||
|
||||
|
||||
@@ -256,17 +256,6 @@ class HardwareStats:
|
||||
)
|
||||
self.update_config()
|
||||
|
||||
def set_config(self, config: FrigateConfig) -> None:
|
||||
"""Follow a runtime config swap and recalculate the monitored hardware.
|
||||
|
||||
The camera update subscriber has to follow too, or later camera updates
|
||||
would land on the discarded config.
|
||||
"""
|
||||
self.config = config
|
||||
self._config_subscriber.config = config
|
||||
self._config_subscriber.camera_configs = config.cameras
|
||||
self.update_config()
|
||||
|
||||
def update_config(self) -> None:
|
||||
"""Recalculate all hardware that needs to be monitored from the config."""
|
||||
names = self._scan_ffmpeg() | self._scan_detectors() | self._scan_enrichments()
|
||||
|
||||
+9
-29
@@ -111,18 +111,15 @@ def get_detector_stats(
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
"""Get stats for all detectors, including temperatures based on detector type."""
|
||||
detector_stats: dict[str, dict[str, Any]] = {}
|
||||
# detector type -> device -> index into that type's temperatures
|
||||
device_indices: dict[str, dict[str, int]] = {}
|
||||
detector_type_indices: dict[str, int] = {}
|
||||
|
||||
for name, detector in stats_tracking["detectors"].items():
|
||||
pid = detector.detect_process.pid if detector.detect_process else None
|
||||
detector_type = detector.detector_config.type
|
||||
|
||||
# temperatures are per physical unit, so a repeated device
|
||||
# ("hailo:PCIe#2", see runner_names) shares its unit's reading
|
||||
device = name.partition("#")[0]
|
||||
type_devices = device_indices.setdefault(detector_type, {})
|
||||
current_index = type_devices.setdefault(device, len(type_devices))
|
||||
# Keep track of the index for each detector type to match temperatures correctly
|
||||
current_index = detector_type_indices.get(detector_type, 0)
|
||||
detector_type_indices[detector_type] = current_index + 1
|
||||
|
||||
detector_stat = {
|
||||
"inference_speed": round(detector.avg_inference_speed.value * 1000, 2), # type: ignore[attr-defined]
|
||||
@@ -236,15 +233,6 @@ def skipped_percent(skipped_fps: float, camera_fps: float, enabled: bool) -> flo
|
||||
return round(skipped_fps / camera_fps * 100, 1)
|
||||
|
||||
|
||||
def get_go2rtc_pid(cpu_usages: dict[str, dict[str, Any]]) -> int | None:
|
||||
"""Find the pid of the running go2rtc process in the cpu usages."""
|
||||
for pid, usage in cpu_usages.items():
|
||||
if usage.get("cmdline", "").split(" ")[0].endswith("/go2rtc"):
|
||||
return int(pid)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def stats_snapshot(
|
||||
config: FrigateConfig,
|
||||
stats_tracking: StatsTrackingTypes,
|
||||
@@ -257,9 +245,8 @@ def stats_snapshot(
|
||||
total_camera_fps = total_process_fps = total_skipped_fps = total_detection_fps = 0
|
||||
|
||||
stats["cameras"] = {}
|
||||
for name, camera_stats in list(camera_metrics.items()):
|
||||
camera_config = config.cameras.get(name)
|
||||
if camera_config is None:
|
||||
for name, camera_stats in camera_metrics.items():
|
||||
if name not in config.cameras:
|
||||
continue
|
||||
|
||||
total_camera_fps += camera_stats.camera_fps.value
|
||||
@@ -276,7 +263,7 @@ def stats_snapshot(
|
||||
# Calculate connection quality based on current state
|
||||
# This is computed at stats-collection time so offline cameras
|
||||
# correctly show as unusable rather than excellent
|
||||
expected_fps = camera_config.detect.fps
|
||||
expected_fps = config.cameras[name].detect.fps
|
||||
current_fps = camera_stats.camera_fps.value
|
||||
reconnects = camera_stats.reconnects_last_hour.value
|
||||
stalls = camera_stats.stalls_last_hour.value
|
||||
@@ -309,7 +296,7 @@ def stats_snapshot(
|
||||
config.cameras[name].enabled,
|
||||
),
|
||||
"detection_fps": round(camera_stats.detection_fps.value, 2),
|
||||
"detection_enabled": camera_config.detect.enabled,
|
||||
"detection_enabled": config.cameras[name].detect.enabled,
|
||||
"pid": pid,
|
||||
"capture_pid": capture_pid,
|
||||
"ffmpeg_pid": ffmpeg_pid,
|
||||
@@ -365,14 +352,6 @@ def stats_snapshot(
|
||||
|
||||
stats["service"]["storage"]["/dev/shm"] = calculate_shm_requirements(config)
|
||||
|
||||
cpu_usages = stats.get("cpu_usages", {})
|
||||
|
||||
# go2rtc is supervised by s6, so its pid changes when s6 restarts it
|
||||
go2rtc_pid = get_go2rtc_pid(cpu_usages)
|
||||
|
||||
if go2rtc_pid is not None:
|
||||
stats_tracking["processes"]["go2rtc"] = go2rtc_pid
|
||||
|
||||
stats["processes"] = {}
|
||||
for name, pid in stats_tracking["processes"].items():
|
||||
stats["processes"][name] = {
|
||||
@@ -381,6 +360,7 @@ def stats_snapshot(
|
||||
|
||||
# Embed cpu/mem stats into detectors, cameras, and processes
|
||||
# so history consumers don't need the full cpu_usages dict
|
||||
cpu_usages = stats.get("cpu_usages", {})
|
||||
|
||||
for det_stats in stats["detectors"].values():
|
||||
pid_str = str(det_stats.get("pid", ""))
|
||||
|
||||
@@ -145,11 +145,7 @@ class BaseTestHttp(unittest.TestCase):
|
||||
pass
|
||||
|
||||
def create_app(
|
||||
self,
|
||||
stats=None,
|
||||
event_metadata_publisher=None,
|
||||
notice_registry=None,
|
||||
enforce_default_admin=False,
|
||||
self, stats=None, event_metadata_publisher=None, notice_registry=None
|
||||
):
|
||||
from frigate.api.auth import get_allowed_cameras_for_filter, get_current_user
|
||||
|
||||
@@ -164,7 +160,7 @@ class BaseTestHttp(unittest.TestCase):
|
||||
event_metadata_publisher,
|
||||
None,
|
||||
DebugReplayManager(),
|
||||
enforce_default_admin=enforce_default_admin,
|
||||
enforce_default_admin=False,
|
||||
notice_registry=notice_registry,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import json
|
||||
import os
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import frigate.genai
|
||||
from frigate.config import GenAIProviderEnum
|
||||
from frigate.config.env import FRIGATE_ENV_VARS
|
||||
from frigate.const import MODEL_CACHE_DIR, REDACTED_CREDENTIAL_SENTINEL
|
||||
from frigate.const import REDACTED_CREDENTIAL_SENTINEL
|
||||
from frigate.genai import GenAIClient
|
||||
from frigate.models import Event, Recordings, ReviewSegment
|
||||
from frigate.stats.emitter import StatsEmitter
|
||||
@@ -50,25 +47,6 @@ class TestHttpApp(BaseTestHttp):
|
||||
assert response.status_code == 200
|
||||
assert response.json()["front_door"]["usage_percent"] == 25.0
|
||||
|
||||
def test_camera_name_collision_keeps_admin_default(self):
|
||||
self.minimal_config["cameras"]["faces"] = self.minimal_config["cameras"].pop(
|
||||
"front_door"
|
||||
)
|
||||
app = super().create_app(enforce_default_admin=True)
|
||||
viewer = {"remote-user": "viewer", "remote-role": "viewer"}
|
||||
|
||||
with AuthTestClient(app) as client:
|
||||
assert client.get("/faces", headers=viewer).status_code == 403
|
||||
assert client.get("/faces").status_code == 200
|
||||
assert (
|
||||
client.post("/faces/train/person/classify", headers=viewer).status_code
|
||||
== 403
|
||||
)
|
||||
|
||||
# Camera routes for the same name stay reachable by viewers
|
||||
response = client.get("/faces/recordings/summary", headers=viewer)
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_config_set_in_memory_replaces_objects_track_list(self):
|
||||
self.minimal_config["cameras"]["front_door"]["objects"] = {
|
||||
"track": ["person", "car"],
|
||||
@@ -112,68 +90,6 @@ class TestHttpApp(BaseTestHttp):
|
||||
mqtt = response.json()["mqtt"]
|
||||
assert mqtt["password"] == REDACTED_CREDENTIAL_SENTINEL
|
||||
|
||||
def test_config_response_hides_notification_email_from_viewers(self):
|
||||
self.minimal_config["notifications"] = {"email": "{FRIGATE_TEST_EMAIL}"}
|
||||
|
||||
with patch.dict(FRIGATE_ENV_VARS, {"FRIGATE_TEST_EMAIL": "me@example.com"}):
|
||||
app = super().create_app()
|
||||
|
||||
assert app.frigate_config.notifications.email == "me@example.com"
|
||||
|
||||
with AuthTestClient(app) as client:
|
||||
response = client.get(
|
||||
"/config",
|
||||
headers={"remote-user": "viewer", "remote-role": "viewer"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
config = response.json()
|
||||
assert config["notifications"]["email"] == REDACTED_CREDENTIAL_SENTINEL
|
||||
assert (
|
||||
config["cameras"]["front_door"]["notifications"]["email"]
|
||||
== REDACTED_CREDENTIAL_SENTINEL
|
||||
)
|
||||
|
||||
response = client.get("/config")
|
||||
assert response.json()["notifications"]["email"] == "me@example.com"
|
||||
|
||||
def test_config_response_keeps_plus_model_reference(self):
|
||||
model_id = "test_plus_reference"
|
||||
model_path = os.path.join(MODEL_CACHE_DIR, model_id)
|
||||
os.makedirs(MODEL_CACHE_DIR, exist_ok=True)
|
||||
|
||||
with open(model_path, "w") as f:
|
||||
f.write("model")
|
||||
|
||||
with open(f"{model_path}.json", "w") as f:
|
||||
json.dump(
|
||||
{
|
||||
"id": model_id,
|
||||
"type": "ssd",
|
||||
"supportedDetectors": ["cpu"],
|
||||
"width": 320,
|
||||
"height": 320,
|
||||
"inputShape": "nhwc",
|
||||
"pixelFormat": "rgb",
|
||||
"labelMap": {"0": "person"},
|
||||
},
|
||||
f,
|
||||
)
|
||||
|
||||
self.addCleanup(os.remove, model_path)
|
||||
self.addCleanup(os.remove, f"{model_path}.json")
|
||||
self.minimal_config["models"] = [
|
||||
{"path": f"plus://{model_id}", "devices": ["cpu"]}
|
||||
]
|
||||
app = super().create_app()
|
||||
|
||||
with AuthTestClient(app) as client:
|
||||
response = client.get("/config")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["models"][0]["path"] == f"plus://{model_id}"
|
||||
|
||||
# detection still loads the resolved cache file
|
||||
assert app.frigate_config.models[0].path == model_path
|
||||
|
||||
####################################################################################################################
|
||||
################################### POST /genai/probe Endpoint ##################################################
|
||||
####################################################################################################################
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user