mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-07-21 03:09:02 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d8dcc7595 | ||
|
|
b420efdebd |
@@ -211,7 +211,7 @@ jobs:
|
||||
with:
|
||||
string: ${{ github.repository }}
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0
|
||||
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
with:
|
||||
string: ${{ github.repository }}
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0
|
||||
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ruamel.yaml import YAML
|
||||
@@ -16,24 +15,12 @@ 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.services import (
|
||||
is_go2rtc_arbitrary_exec_allowed,
|
||||
is_restricted_go2rtc_source,
|
||||
)
|
||||
from frigate.util.services import is_restricted_go2rtc_source
|
||||
|
||||
sys.path.remove("/opt/frigate")
|
||||
|
||||
yaml = YAML()
|
||||
|
||||
FRIGATE_ENV_VARS = {k: v for k, v in os.environ.items() if k.startswith("FRIGATE_")}
|
||||
# read docker secret files as env vars too
|
||||
if os.path.isdir("/run/secrets"):
|
||||
for secret_file in os.listdir("/run/secrets"):
|
||||
if secret_file.startswith("FRIGATE_"):
|
||||
FRIGATE_ENV_VARS[secret_file] = (
|
||||
Path(os.path.join("/run/secrets", secret_file)).read_text().strip()
|
||||
)
|
||||
|
||||
config_file = find_config_file()
|
||||
|
||||
try:
|
||||
@@ -113,7 +100,7 @@ for name in list(go2rtc_config.get("streams", {})):
|
||||
|
||||
if isinstance(stream, str):
|
||||
try:
|
||||
formatted_stream = stream.format(**FRIGATE_ENV_VARS)
|
||||
formatted_stream = substitute_frigate_vars(stream)
|
||||
if is_restricted_go2rtc_source(formatted_stream):
|
||||
print(
|
||||
f"[ERROR] Stream '{name}' uses a restricted source (echo/expr/exec) which is disabled by default for security. "
|
||||
@@ -132,7 +119,7 @@ for name in list(go2rtc_config.get("streams", {})):
|
||||
filtered_streams = []
|
||||
for i, stream_item in enumerate(stream):
|
||||
try:
|
||||
formatted_stream = stream_item.format(**FRIGATE_ENV_VARS)
|
||||
formatted_stream = substitute_frigate_vars(stream_item)
|
||||
if is_restricted_go2rtc_source(formatted_stream):
|
||||
print(
|
||||
f"[ERROR] Stream '{name}' item {i + 1} uses a restricted source (echo/expr/exec) which is disabled by default for security. "
|
||||
@@ -156,20 +143,6 @@ for name in list(go2rtc_config.get("streams", {})):
|
||||
)
|
||||
del go2rtc_config["streams"][name]
|
||||
|
||||
elif isinstance(stream, dict):
|
||||
# The map form ({"url": ...}) lets go2rtc resolve the source
|
||||
# recursively, so it is effectively a dynamic way to generate the URL
|
||||
# for a stream. That can only be backed by an exec source, so it cannot
|
||||
# be allowed unless arbitrary exec is explicitly enabled. When it is
|
||||
# enabled, leave the map untouched for go2rtc to resolve.
|
||||
if not is_go2rtc_arbitrary_exec_allowed():
|
||||
print(
|
||||
f"[ERROR] Stream '{name}' uses a dynamic source format which is disabled by default for security. "
|
||||
f"Set GO2RTC_ALLOW_ARBITRARY_EXEC=true to enable arbitrary exec sources."
|
||||
)
|
||||
del go2rtc_config["streams"][name]
|
||||
continue
|
||||
|
||||
# add birdseye restream stream if enabled
|
||||
if config.get("birdseye", {}).get("restream", False):
|
||||
birdseye: dict[str, Any] = config.get("birdseye")
|
||||
|
||||
@@ -1,349 +0,0 @@
|
||||
{
|
||||
"edgeTPU": {
|
||||
"title": "EdgeTPU",
|
||||
"models": [
|
||||
{
|
||||
"key": "mobiledet",
|
||||
"label": "Mobiledet",
|
||||
"recommended": true,
|
||||
"download": "A TensorFlow Lite model is provided in the container at `/edgetpu_model.tflite` and is used by this detector type by default. To provide your own model, bind mount the file into the container and provide the path with `model.path`.",
|
||||
"ui": "Navigate to **Settings > System > Detectors and model** and select **EdgeTPU** from the detector type dropdown and click **Add**, then set device to `usb`.",
|
||||
"yaml": "detectors:\n coral:\n type: edgetpu\n device: usb"
|
||||
},
|
||||
{
|
||||
"key": "yolov9",
|
||||
"label": "YOLOv9",
|
||||
"recommended": false,
|
||||
"download": "[Download the model](https://github.com/dbro/frigate-detector-edgetpu-yolo9/releases/download/v1.0/yolov9-s-relu6-best_320_int8_edgetpu.tflite), bind mount the file into the container, and provide the path with `model.path`. Note that the linked model requires a 17-label [labelmap file](https://raw.githubusercontent.com/dbro/frigate-detector-edgetpu-yolo9/refs/heads/main/labels-coco17.txt) that includes only 17 COCO classes.",
|
||||
"ui": "Navigate to **Settings > System > Detectors and model** and select **EdgeTPU** from the detector type dropdown and click **Add**, then set device to `usb`. Then on the same page, in the **Custom Model** tab, configure the model settings:\n\n| Field | Value |\n| ---------------------------------------- | ----------------------------------------------------------------- |\n| **Object Detection Model Type** | `yolo-generic` |\n| **Object detection model input width** | `320` (should match the imgsize of the model) |\n| **Object detection model input height** | `320` (should match the imgsize of the model) |\n| **Custom object detector model path** | `/config/model_cache/yolov9-s-relu6-best_320_int8_edgetpu.tflite` |\n| **Label map for custom object detector** | `/config/labels-coco17.txt` |",
|
||||
"yaml": "detectors:\n coral:\n type: edgetpu\n device: usb\n\nmodel:\n model_type: yolo-generic\n width: 320 # <--- should match the imgsize of the model, typically 320\n height: 320 # <--- should match the imgsize of the model, typically 320\n path: /config/model_cache/yolov9-s-relu6-best_320_int8_edgetpu.tflite\n labelmap_path: /config/labels-coco17.txt"
|
||||
}
|
||||
]
|
||||
},
|
||||
"hailo8l": {
|
||||
"title": "Hailo-8/Hailo-8L",
|
||||
"models": [
|
||||
{
|
||||
"key": "yolo",
|
||||
"label": "YOLO",
|
||||
"recommended": true,
|
||||
"download": "If no custom model path or URL is provided, the Hailo detector automatically downloads the default model (YOLOv6n) from the Hailo Model Zoo on first startup based on the detected hardware. Once cached under `/config/model_cache/hailo`, the model works fully offline.",
|
||||
"ui": "Navigate to **Settings > System > Detectors and model** and select **Hailo-8/Hailo-8L** from the detector type dropdown and click **Add**, then set device to `PCIe`. Then on the same page, in the **Custom Model** tab, configure the model settings:\n\n| Field | Value |\n| ---------------------------------------- | ----------------------- |\n| **Object detection model input width** | `320` |\n| **Object detection model input height** | `320` |\n| **Model Input Tensor Shape** | `nhwc` |\n| **Model Input Pixel Color Format** | `rgb` |\n| **Model Input D Type** | `int` |\n| **Object Detection Model Type** | `yolo-generic` |\n| **Label map for custom object detector** | `/labelmap/coco-80.txt` |\n\nThe detector automatically selects the default model based on your hardware. Optionally, specify a local model path or URL to override.",
|
||||
"yaml": "detectors:\n hailo:\n type: hailo8l\n device: PCIe\n\nmodel:\n width: 320\n height: 320\n input_tensor: nhwc\n input_pixel_format: rgb\n input_dtype: int\n model_type: yolo-generic\n labelmap_path: /labelmap/coco-80.txt\n\n # The detector automatically selects the default model based on your hardware:\n # - For Hailo-8 hardware: YOLOv6n (default: yolov6n.hef)\n # - For Hailo-8L hardware: YOLOv6n (default: yolov6n.hef)\n #\n # Optionally, you can specify a local model path to override the default.\n # If a local path is provided and the file exists, it will be used instead of downloading.\n # Example:\n # path: /config/model_cache/hailo/yolov6n.hef\n #\n # You can also override using a custom URL:\n # path: https://hailo-model-zoo.s3.eu-west-2.amazonaws.com/ModelZoo/Compiled/v2.14.0/hailo8/yolov6n.hef\n # just make sure to give it the write configuration based on the model"
|
||||
},
|
||||
{
|
||||
"key": "ssd",
|
||||
"label": "SSD MobileNet v1",
|
||||
"recommended": false,
|
||||
"download": "For SSD-based models, provide either a model path or URL to your compiled SSD model. The integration will first check the local path before downloading if necessary. The model file is cached under `/config/model_cache/hailo`.",
|
||||
"ui": "Navigate to **Settings > System > Detectors and model** and select **Hailo-8/Hailo-8L** from the detector type dropdown and click **Add**, then set device to `PCIe`. Then on the same page, in the **Custom Model** tab, configure the model settings:\n\n| Field | Value |\n| --------------------------------------- | ------ |\n| **Object detection model input width** | `300` |\n| **Object detection model input height** | `300` |\n| **Model Input Tensor Shape** | `nhwc` |\n| **Model Input Pixel Color Format** | `rgb` |\n| **Object Detection Model Type** | `ssd` |\n\nSpecify the local model path or URL for SSD MobileNet v1.",
|
||||
"yaml": "detectors:\n hailo:\n type: hailo8l\n device: PCIe\n\nmodel:\n width: 300\n height: 300\n input_tensor: nhwc\n input_pixel_format: rgb\n model_type: ssd\n # Specify the local model path (if available) or URL for SSD MobileNet v1.\n # Example with a local path:\n # path: /config/model_cache/h8l_cache/ssd_mobilenet_v1.hef\n #\n # Or override using a custom URL:\n # path: https://hailo-model-zoo.s3.eu-west-2.amazonaws.com/ModelZoo/Compiled/v2.14.0/hailo8l/ssd_mobilenet_v1.hef"
|
||||
}
|
||||
]
|
||||
},
|
||||
"openvino": {
|
||||
"title": "OpenVINO",
|
||||
"models": [
|
||||
{
|
||||
"key": "ssd",
|
||||
"label": "SSDLite MobileNet v2",
|
||||
"recommended": true,
|
||||
"download": "An OpenVINO model is provided in the container at `/openvino-model/ssdlite_mobilenet_v2.xml` and is used by this detector type by default. The model comes from Intel's Open Model Zoo [SSDLite MobileNet V2](https://github.com/openvinotoolkit/open_model_zoo/tree/master/models/public/ssdlite_mobilenet_v2) and is converted to an FP16 precision IR model.",
|
||||
"ui": "Navigate to **Settings > System > Detectors and model** and select **OpenVINO** from the detector type dropdown and click **Add**, then set device to `GPU` (or `NPU`). Then on the same page, in the **Custom Model** tab, configure:\n\n| Field | Value |\n| ---------------------------------------- | ------------------------------------------ |\n| **Object detection model input width** | `300` |\n| **Object detection model input height** | `300` |\n| **Model Input Tensor Shape** | `nhwc` |\n| **Model Input Pixel Color Format** | `bgr` |\n| **Custom object detector model path** | `/openvino-model/ssdlite_mobilenet_v2.xml` |\n| **Label map for custom object detector** | `/openvino-model/coco_91cl_bkgr.txt` |",
|
||||
"yaml": "detectors:\n ov:\n type: openvino\n device: GPU # Or NPU\n\nmodel:\n width: 300\n height: 300\n input_tensor: nhwc\n input_pixel_format: bgr\n path: /openvino-model/ssdlite_mobilenet_v2.xml\n labelmap_path: /openvino-model/coco_91cl_bkgr.txt"
|
||||
},
|
||||
{
|
||||
"key": "yolov9",
|
||||
"label": "YOLOv9",
|
||||
"recommended": false,
|
||||
"download": "YOLOv9 model can be exported as ONNX using the command below. You can copy and paste the whole thing to your terminal and execute, altering `MODEL_SIZE=t` and `IMG_SIZE=320` in the first line to the [model size](https://github.com/WongKinYiu/yolov9#performance) you would like to convert (available model sizes are `t`, `s`, `m`, `c`, and `e`, common image sizes are `320` and `640`).\n\n```sh\ndocker build . --build-arg MODEL_SIZE=t --build-arg IMG_SIZE=320 --output . -f- <<'EOF'\nFROM python:3.11 AS build\nRUN apt-get update && apt-get install --no-install-recommends -y cmake libgl1 && rm -rf /var/lib/apt/lists/*\nCOPY --from=ghcr.io/astral-sh/uv:0.10.4 /uv /bin/\nWORKDIR /yolov9\nADD https://github.com/WongKinYiu/yolov9.git .\nRUN uv pip install --system -r requirements.txt\nRUN uv pip install --system onnx==1.18.0 onnxruntime onnx-simplifier==0.4.* onnxscript\nARG MODEL_SIZE\nARG IMG_SIZE\nADD https://github.com/WongKinYiu/yolov9/releases/download/v0.1/yolov9-${MODEL_SIZE}-converted.pt yolov9-${MODEL_SIZE}.pt\nRUN sed -i \"s/ckpt = torch.load(attempt_download(w), map_location='cpu')/ckpt = torch.load(attempt_download(w), map_location='cpu', weights_only=False)/g\" models/experimental.py\nRUN python3 export.py --weights ./yolov9-${MODEL_SIZE}.pt --imgsz ${IMG_SIZE} --simplify --include onnx\nFROM scratch\nARG MODEL_SIZE\nARG IMG_SIZE\nCOPY --from=build /yolov9/yolov9-${MODEL_SIZE}.onnx /yolov9-${MODEL_SIZE}-${IMG_SIZE}.onnx\nEOF\n```",
|
||||
"ui": "Navigate to **Settings > System > Detectors and model** and select **OpenVINO** from the detector type dropdown and click **Add**, then set device to `GPU` (or `NPU`). Then on the same page, in the **Custom Model** tab, configure:\n\n| Field | Value |\n| ---------------------------------------- | -------------------------------------------------------- |\n| **Object Detection Model Type** | `yolo-generic` |\n| **Object detection model input width** | `320` (should match the imgsize set during model export) |\n| **Object detection model input height** | `320` (should match the imgsize set during model export) |\n| **Model Input Tensor Shape** | `nchw` |\n| **Model Input D Type** | `float` |\n| **Custom object detector model path** | `/config/model_cache/yolo.onnx` |\n| **Label map for custom object detector** | `/labelmap/coco-80.txt` |",
|
||||
"yaml": "detectors:\n ov:\n type: openvino\n device: GPU # or NPU\n\nmodel:\n model_type: yolo-generic\n width: 320 # <--- should match the imgsize set during model export\n height: 320 # <--- should match the imgsize set during model export\n input_tensor: nchw\n input_dtype: float\n path: /config/model_cache/yolo.onnx\n labelmap_path: /labelmap/coco-80.txt"
|
||||
},
|
||||
{
|
||||
"key": "yolo-legacy",
|
||||
"label": "YOLO (v3, v4, v7)",
|
||||
"recommended": false,
|
||||
"download": "To export as ONNX:\n\n```sh\ngit clone https://github.com/NateMeyer/tensorrt_demos\ncd tensorrt_demos/yolo\n./download_yolo.sh\npython3 yolo_to_onnx.py -m yolov7-320\n```",
|
||||
"ui": "Navigate to **Settings > System > Detectors and model** and select **OpenVINO** from the detector type dropdown and click **Add**, then set device to `GPU` (or `NPU`). Then on the same page, in the **Custom Model** tab, configure:\n\n| Field | Value |\n| ---------------------------------------- | -------------------------------------------------------- |\n| **Object Detection Model Type** | `yolo-generic` |\n| **Object detection model input width** | `320` (should match the imgsize set during model export) |\n| **Object detection model input height** | `320` (should match the imgsize set during model export) |\n| **Model Input Tensor Shape** | `nchw` |\n| **Model Input D Type** | `float` |\n| **Custom object detector model path** | `/config/model_cache/yolo.onnx` |\n| **Label map for custom object detector** | `/labelmap/coco-80.txt` |",
|
||||
"yaml": "detectors:\n ov:\n type: openvino\n device: GPU # or NPU\n\nmodel:\n model_type: yolo-generic\n width: 320 # <--- should match the imgsize set during model export\n height: 320 # <--- should match the imgsize set during model export\n input_tensor: nchw\n input_dtype: float\n path: /config/model_cache/yolo.onnx\n labelmap_path: /labelmap/coco-80.txt"
|
||||
},
|
||||
{
|
||||
"key": "yolonas",
|
||||
"label": "YOLO-NAS",
|
||||
"recommended": false,
|
||||
"download": "You can build and download a compatible model with pre-trained weights using [this notebook](https://github.com/blakeblackshear/frigate/blob/dev/notebooks/YOLO_NAS_Pretrained_Export.ipynb) [](https://colab.research.google.com/github/blakeblackshear/frigate/blob/dev/notebooks/YOLO_NAS_Pretrained_Export.ipynb) which can be run directly in [Google Colab](https://colab.research.google.com/github/blakeblackshear/frigate/blob/dev/notebooks/YOLO_NAS_Pretrained_Export.ipynb).\n\n:::warning\n\nThe pre-trained YOLO-NAS weights from DeciAI are subject to their license and can't be used commercially. For more information, see: https://docs.deci.ai/super-gradients/latest/LICENSE.YOLONAS.html\n\n:::\n\nThe input image size in this notebook is set to 320x320. This results in lower CPU usage and faster inference times without impacting performance in most cases due to the way Frigate crops video frames to areas of interest before running detection. The notebook and config can be updated to 640x640 if desired.",
|
||||
"ui": "Navigate to **Settings > System > Detectors and model** and select **OpenVINO** from the detector type dropdown and click **Add**, then set device to `GPU`. Then on the same page, in the **Custom Model** tab, configure:\n\n| Field | Value |\n| ---------------------------------------- | ------------------------------------------------- |\n| **Object Detection Model Type** | `yolonas` |\n| **Object detection model input width** | `320` (should match whatever was set in notebook) |\n| **Object detection model input height** | `320` (should match whatever was set in notebook) |\n| **Model Input Tensor Shape** | `nchw` |\n| **Model Input Pixel Color Format** | `bgr` |\n| **Custom object detector model path** | `/config/yolo_nas_s.onnx` |\n| **Label map for custom object detector** | `/labelmap/coco-80.txt` |",
|
||||
"yaml": "detectors:\n ov:\n type: openvino\n device: GPU\n\nmodel:\n model_type: yolonas\n width: 320 # <--- should match whatever was set in notebook\n height: 320 # <--- should match whatever was set in notebook\n input_tensor: nchw\n input_pixel_format: bgr\n path: /config/yolo_nas_s.onnx\n labelmap_path: /labelmap/coco-80.txt"
|
||||
},
|
||||
{
|
||||
"key": "yolox",
|
||||
"label": "YOLOX",
|
||||
"recommended": false,
|
||||
"download": "YOLOx models can be downloaded [from the YOLOx repo](https://github.com/Megvii-BaseDetection/YOLOX/tree/main/demo/ONNXRuntime).",
|
||||
"ui": "Navigate to **Settings > System > Detectors and model** and select **OpenVINO** from the detector type dropdown and click **Add**, then set device to `GPU`. Then on the same page, in the **Custom Model** tab, configure:\n\n| Field | Value |\n| ------------------------------------- | -------------------------------- |\n| **Object Detection Model Type** | `yolox` |\n| **Custom object detector model path** | path to your YOLOX ONNX model |",
|
||||
"yaml": "detectors:\n ov:\n type: openvino\n device: GPU\n\nmodel:\n model_type: yolox\n path: /config/model_cache/yolox.onnx\n labelmap_path: /labelmap/coco-80.txt"
|
||||
},
|
||||
{
|
||||
"key": "rfdetr",
|
||||
"label": "RF-DETR",
|
||||
"recommended": false,
|
||||
"download": "RF-DETR can be exported as ONNX by running the command below. You can copy and paste the whole thing to your terminal and execute, altering `MODEL_SIZE=Nano` in the first line to `Nano`, `Small`, or `Medium` size.\n\n```sh\ndocker build . --build-arg MODEL_SIZE=Nano --rm --output . -f- <<'EOF'\nFROM python:3.12 AS build\nRUN apt-get update && apt-get install --no-install-recommends -y libgl1 && rm -rf /var/lib/apt/lists/*\nCOPY --from=ghcr.io/astral-sh/uv:0.10.4 /uv /bin/\nWORKDIR /rfdetr\nRUN uv pip install --system rfdetr[onnxexport] torch==2.8.0 onnx==1.19.1 transformers==4.57.6 onnxscript\nARG MODEL_SIZE\nRUN python3 -c \"from rfdetr import RFDETR${MODEL_SIZE}; x = RFDETR${MODEL_SIZE}(resolution=320); x.export(simplify=True)\"\nFROM scratch\nARG MODEL_SIZE\nCOPY --from=build /rfdetr/output/inference_model.onnx /rfdetr-${MODEL_SIZE}.onnx\nEOF\n```",
|
||||
"ui": "Navigate to **Settings > System > Detectors and model** and select **OpenVINO** from the detector type dropdown and click **Add**, then set device to `GPU`. Then on the same page, in the **Custom Model** tab, configure:\n\n| Field | Value |\n| --------------------------------------- | --------------------------------- |\n| **Object Detection Model Type** | `rfdetr` |\n| **Object detection model input width** | `320` |\n| **Object detection model input height** | `320` |\n| **Model Input Tensor Shape** | `nchw` |\n| **Model Input D Type** | `float` |\n| **Custom object detector model path** | `/config/model_cache/rfdetr.onnx` |",
|
||||
"yaml": "detectors:\n ov:\n type: openvino\n device: GPU\n\nmodel:\n model_type: rfdetr\n width: 320\n height: 320\n input_tensor: nchw\n input_dtype: float\n path: /config/model_cache/rfdetr.onnx"
|
||||
},
|
||||
{
|
||||
"key": "dfine",
|
||||
"label": "D-FINE / DEIMv2",
|
||||
"recommended": false,
|
||||
"download": "#### D-FINE\n\nD-FINE can be exported as ONNX by running the command below. You can copy and paste the whole thing to your terminal and execute, altering `MODEL_SIZE=s` in the first line to `s`, `m`, or `l` size.\n\n```sh\ndocker build . --build-arg MODEL_SIZE=s --output . -f- <<'EOF'\nFROM python:3.11 AS build\nRUN apt-get update && apt-get install --no-install-recommends -y libgl1 && rm -rf /var/lib/apt/lists/*\nCOPY --from=ghcr.io/astral-sh/uv:0.8.0 /uv /bin/\nWORKDIR /dfine\nRUN git clone https://github.com/Peterande/D-FINE.git .\nRUN uv pip install --system -r requirements.txt\nRUN uv pip install --system onnx onnxruntime onnxsim onnxscript\n# Create output directory and download checkpoint\nRUN mkdir -p output\nARG MODEL_SIZE\nRUN wget https://github.com/Peterande/storage/releases/download/dfinev1.0/dfine_${MODEL_SIZE}_obj2coco.pth -O output/dfine_${MODEL_SIZE}_obj2coco.pth\n# Modify line 58 of export_onnx.py to change batch size to 1\nRUN sed -i '58s/data = torch.rand(.*)/data = torch.rand(1, 3, 640, 640)/' tools/deployment/export_onnx.py\nRUN python3 tools/deployment/export_onnx.py -c configs/dfine/objects365/dfine_hgnetv2_${MODEL_SIZE}_obj2coco.yml -r output/dfine_${MODEL_SIZE}_obj2coco.pth\nFROM scratch\nARG MODEL_SIZE\nCOPY --from=build /dfine/output/dfine_${MODEL_SIZE}_obj2coco.onnx /dfine-${MODEL_SIZE}.onnx\nEOF\n```\n\n#### DEIMv2\n\n[DEIMv2](https://github.com/Intellindust-AI-Lab/DEIMv2) can be exported as ONNX by running the command below. Pretrained weights are available on Hugging Face for two backbone families:\n\n- **HGNetv2** (smaller/faster): `atto`, `femto`, `pico`, `n`\n- **DINOv3** (larger/more accurate): `s`, `m`, `l`, `x`\n\nSet `BACKBONE` and `MODEL_SIZE` in the first line to match your desired variant. Hugging Face model names use uppercase (e.g. `HGNetv2_N`, `DINOv3_S`), while config files use lowercase (e.g. `hgnetv2_n`, `dinov3_s`).\n\n```sh\ndocker build . --rm --build-arg BACKBONE=hgnetv2 --build-arg MODEL_SIZE=n --output . -f- <<'EOF'\nFROM python:3.11-slim AS build\nRUN apt-get update && apt-get install --no-install-recommends -y git libgl1 libglib2.0-0 && rm -rf /var/lib/apt/lists/*\nCOPY --from=ghcr.io/astral-sh/uv:0.8.0 /uv /bin/\nWORKDIR /deimv2\nRUN git clone https://github.com/Intellindust-AI-Lab/DEIMv2.git .\n# Install CPU-only PyTorch first to avoid pulling CUDA variant\nRUN uv pip install --no-cache --system torch torchvision --index-url https://download.pytorch.org/whl/cpu\nRUN uv pip install --no-cache --system -r requirements.txt\nRUN uv pip install --no-cache --system onnx safetensors huggingface_hub\nRUN mkdir -p output\nARG BACKBONE\nARG MODEL_SIZE\n# Download from Hugging Face and convert safetensors to pth\nRUN python3 -c \"\\\nfrom huggingface_hub import hf_hub_download; \\\nfrom safetensors.torch import load_file; \\\nimport torch; \\\nbackbone = '${BACKBONE}'.replace('hgnetv2','HGNetv2').replace('dinov3','DINOv3'); \\\nsize = '${MODEL_SIZE}'.upper(); \\\nst = load_file(hf_hub_download('Intellindust/DEIMv2_' + backbone + '_' + size + '_COCO', 'model.safetensors')); \\\ntorch.save({'model': st}, 'output/deimv2.pth')\"\nRUN sed -i \"s/data = torch.rand(2/data = torch.rand(1/\" tools/deployment/export_onnx.py\n# HuggingFace safetensors omits frozen constants that the model constructor initializes\nRUN sed -i \"s/cfg.model.load_state_dict(state)/cfg.model.load_state_dict(state, strict=False)/\" tools/deployment/export_onnx.py\nRUN python3 tools/deployment/export_onnx.py -c configs/deimv2/deimv2_${BACKBONE}_${MODEL_SIZE}_coco.yml -r output/deimv2.pth\nFROM scratch\nARG BACKBONE\nARG MODEL_SIZE\nCOPY --from=build /deimv2/output/deimv2.onnx /deimv2_${BACKBONE}_${MODEL_SIZE}.onnx\nEOF\n```",
|
||||
"ui": "Navigate to **Settings > System > Detectors and model** and select **OpenVINO** from the detector type dropdown and click **Add**, then set device to `CPU`. Then on the same page, in the **Custom Model** tab, configure:\n\n| Field | Value |\n| ---------------------------------------- | ---------------------------------- |\n| **Object Detection Model Type** | `dfine` |\n| **Object detection model input width** | `640` |\n| **Object detection model input height** | `640` |\n| **Model Input Tensor Shape** | `nchw` |\n| **Model Input D Type** | `float` |\n| **Custom object detector model path** | `/config/model_cache/dfine-s.onnx` |\n| **Label map for custom object detector** | `/labelmap/coco-80.txt` |",
|
||||
"yaml": "detectors:\n ov:\n type: openvino\n device: CPU\n\nmodel:\n model_type: dfine\n width: 640\n height: 640\n input_tensor: nchw\n input_dtype: float\n path: /config/model_cache/dfine-s.onnx\n labelmap_path: /labelmap/coco-80.txt"
|
||||
}
|
||||
]
|
||||
},
|
||||
"appleSilicon": {
|
||||
"title": "Apple Silicon",
|
||||
"models": [
|
||||
{
|
||||
"key": "yolov9",
|
||||
"label": "YOLOv9",
|
||||
"recommended": true,
|
||||
"download": "YOLOv9 model can be exported as ONNX using the command below. You can copy and paste the whole thing to your terminal and execute, altering `MODEL_SIZE=t` and `IMG_SIZE=320` in the first line to the [model size](https://github.com/WongKinYiu/yolov9#performance) you would like to convert (available model sizes are `t`, `s`, `m`, `c`, and `e`, common image sizes are `320` and `640`).\n\n```sh\ndocker build . --build-arg MODEL_SIZE=t --build-arg IMG_SIZE=320 --output . -f- <<'EOF'\nFROM python:3.11 AS build\nRUN apt-get update && apt-get install --no-install-recommends -y cmake libgl1 && rm -rf /var/lib/apt/lists/*\nCOPY --from=ghcr.io/astral-sh/uv:0.10.4 /uv /bin/\nWORKDIR /yolov9\nADD https://github.com/WongKinYiu/yolov9.git .\nRUN uv pip install --system -r requirements.txt\nRUN uv pip install --system onnx==1.18.0 onnxruntime onnx-simplifier==0.4.* onnxscript\nARG MODEL_SIZE\nARG IMG_SIZE\nADD https://github.com/WongKinYiu/yolov9/releases/download/v0.1/yolov9-${MODEL_SIZE}-converted.pt yolov9-${MODEL_SIZE}.pt\nRUN sed -i \"s/ckpt = torch.load(attempt_download(w), map_location='cpu')/ckpt = torch.load(attempt_download(w), map_location='cpu', weights_only=False)/g\" models/experimental.py\nRUN python3 export.py --weights ./yolov9-${MODEL_SIZE}.pt --imgsz ${IMG_SIZE} --simplify --include onnx\nFROM scratch\nARG MODEL_SIZE\nARG IMG_SIZE\nCOPY --from=build /yolov9/yolov9-${MODEL_SIZE}.onnx /yolov9-${MODEL_SIZE}-${IMG_SIZE}.onnx\nEOF\n```",
|
||||
"ui": "Navigate to **Settings > System > Detectors and model** and select **ZMQ IPC** from the detector type dropdown and click **Add**, then set the endpoint to `tcp://host.docker.internal:5555`. Then on the same page, in the **Custom Model** tab, configure:\n\n| Field | Value |\n| ---------------------------------------- | -------------------------------------------------------- |\n| **Object Detection Model Type** | `yolo-generic` |\n| **Object detection model input width** | `320` (should match the imgsize set during model export) |\n| **Object detection model input height** | `320` (should match the imgsize set during model export) |\n| **Model Input Tensor Shape** | `nchw` |\n| **Model Input D Type** | `float` |\n| **Custom object detector model path** | `/config/model_cache/yolo.onnx` |\n| **Label map for custom object detector** | `/labelmap/coco-80.txt` |",
|
||||
"yaml": "detectors:\n apple-silicon:\n type: zmq\n endpoint: tcp://host.docker.internal:5555\n\nmodel:\n model_type: yolo-generic\n width: 320 # <--- should match the imgsize set during model export\n height: 320 # <--- should match the imgsize set during model export\n input_tensor: nchw\n input_dtype: float\n path: /config/model_cache/yolo.onnx\n labelmap_path: /labelmap/coco-80.txt"
|
||||
},
|
||||
{
|
||||
"key": "yolo-legacy",
|
||||
"label": "YOLO (v3, v4, v7)",
|
||||
"recommended": false,
|
||||
"download": "To export as ONNX:\n\n```sh\ngit clone https://github.com/NateMeyer/tensorrt_demos\ncd tensorrt_demos/yolo\n./download_yolo.sh\npython3 yolo_to_onnx.py -m yolov7-320\n```",
|
||||
"ui": "Navigate to **Settings > System > Detectors and model** and select **ZMQ IPC** from the detector type dropdown and click **Add**, then set the endpoint to `tcp://host.docker.internal:5555`. Then on the same page, in the **Custom Model** tab, configure:\n\n| Field | Value |\n| ---------------------------------------- | -------------------------------------------------------- |\n| **Object Detection Model Type** | `yolo-generic` |\n| **Object detection model input width** | `320` (should match the imgsize set during model export) |\n| **Object detection model input height** | `320` (should match the imgsize set during model export) |\n| **Model Input Tensor Shape** | `nchw` |\n| **Model Input D Type** | `float` |\n| **Custom object detector model path** | `/config/model_cache/yolo.onnx` |\n| **Label map for custom object detector** | `/labelmap/coco-80.txt` |",
|
||||
"yaml": "detectors:\n apple-silicon:\n type: zmq\n endpoint: tcp://host.docker.internal:5555\n\nmodel:\n model_type: yolo-generic\n width: 320 # <--- should match the imgsize set during model export\n height: 320 # <--- should match the imgsize set during model export\n input_tensor: nchw\n input_dtype: float\n path: /config/model_cache/yolo.onnx\n labelmap_path: /labelmap/coco-80.txt"
|
||||
}
|
||||
]
|
||||
},
|
||||
"onnx": {
|
||||
"title": "ONNX",
|
||||
"models": [
|
||||
{
|
||||
"key": "yolov9",
|
||||
"label": "YOLOv9",
|
||||
"recommended": true,
|
||||
"download": "YOLOv9 model can be exported as ONNX using the command below. You can copy and paste the whole thing to your terminal and execute, altering `MODEL_SIZE=t` and `IMG_SIZE=320` in the first line to the [model size](https://github.com/WongKinYiu/yolov9#performance) you would like to convert (available model sizes are `t`, `s`, `m`, `c`, and `e`, common image sizes are `320` and `640`).\n\n```sh\ndocker build . --build-arg MODEL_SIZE=t --build-arg IMG_SIZE=320 --output . -f- <<'EOF'\nFROM python:3.11 AS build\nRUN apt-get update && apt-get install --no-install-recommends -y cmake libgl1 && rm -rf /var/lib/apt/lists/*\nCOPY --from=ghcr.io/astral-sh/uv:0.10.4 /uv /bin/\nWORKDIR /yolov9\nADD https://github.com/WongKinYiu/yolov9.git .\nRUN uv pip install --system -r requirements.txt\nRUN uv pip install --system onnx==1.18.0 onnxruntime onnx-simplifier==0.4.* onnxscript\nARG MODEL_SIZE\nARG IMG_SIZE\nADD https://github.com/WongKinYiu/yolov9/releases/download/v0.1/yolov9-${MODEL_SIZE}-converted.pt yolov9-${MODEL_SIZE}.pt\nRUN sed -i \"s/ckpt = torch.load(attempt_download(w), map_location='cpu')/ckpt = torch.load(attempt_download(w), map_location='cpu', weights_only=False)/g\" models/experimental.py\nRUN python3 export.py --weights ./yolov9-${MODEL_SIZE}.pt --imgsz ${IMG_SIZE} --simplify --include onnx\nFROM scratch\nARG MODEL_SIZE\nARG IMG_SIZE\nCOPY --from=build /yolov9/yolov9-${MODEL_SIZE}.onnx /yolov9-${MODEL_SIZE}-${IMG_SIZE}.onnx\nEOF\n```",
|
||||
"ui": "Navigate to **Settings > System > Detectors and model** and select **ONNX** from the detector type dropdown and click **Add**. Then on the same page, in the **Custom Model** tab, configure:\n\n| Field | Value |\n| ---------------------------------------- | -------------------------------------------------------- |\n| **Object Detection Model Type** | `yolo-generic` |\n| **Object detection model input width** | `320` (should match the imgsize set during model export) |\n| **Object detection model input height** | `320` (should match the imgsize set during model export) |\n| **Model Input Tensor Shape** | `nchw` |\n| **Model Input D Type** | `float` |\n| **Custom object detector model path** | `/config/model_cache/yolo.onnx` |\n| **Label map for custom object detector** | `/labelmap/coco-80.txt` |",
|
||||
"yaml": "detectors:\n onnx:\n type: onnx\n\nmodel:\n model_type: yolo-generic\n width: 320 # <--- should match the imgsize set during model export\n height: 320 # <--- should match the imgsize set during model export\n input_tensor: nchw\n input_dtype: float\n path: /config/model_cache/yolo.onnx\n labelmap_path: /labelmap/coco-80.txt"
|
||||
},
|
||||
{
|
||||
"key": "rfdetr",
|
||||
"label": "RF-DETR",
|
||||
"recommended": false,
|
||||
"download": "RF-DETR can be exported as ONNX by running the command below. You can copy and paste the whole thing to your terminal and execute, altering `MODEL_SIZE=Nano` in the first line to `Nano`, `Small`, or `Medium` size.\n\n```sh\ndocker build . --build-arg MODEL_SIZE=Nano --rm --output . -f- <<'EOF'\nFROM python:3.12 AS build\nRUN apt-get update && apt-get install --no-install-recommends -y libgl1 && rm -rf /var/lib/apt/lists/*\nCOPY --from=ghcr.io/astral-sh/uv:0.10.4 /uv /bin/\nWORKDIR /rfdetr\nRUN uv pip install --system rfdetr[onnxexport] torch==2.8.0 onnx==1.19.1 transformers==4.57.6 onnxscript\nARG MODEL_SIZE\nRUN python3 -c \"from rfdetr import RFDETR${MODEL_SIZE}; x = RFDETR${MODEL_SIZE}(resolution=320); x.export(simplify=True)\"\nFROM scratch\nARG MODEL_SIZE\nCOPY --from=build /rfdetr/output/inference_model.onnx /rfdetr-${MODEL_SIZE}.onnx\nEOF\n```",
|
||||
"ui": "Navigate to **Settings > System > Detectors and model** and select **ONNX** from the detector type dropdown and click **Add**. Then on the same page, in the **Custom Model** tab, configure:\n\n| Field | Value |\n| --------------------------------------- | --------------------------------- |\n| **Object Detection Model Type** | `rfdetr` |\n| **Object detection model input width** | `320` |\n| **Object detection model input height** | `320` |\n| **Model Input Tensor Shape** | `nchw` |\n| **Model Input D Type** | `float` |\n| **Custom object detector model path** | `/config/model_cache/rfdetr.onnx` |",
|
||||
"yaml": "detectors:\n onnx:\n type: onnx\n\nmodel:\n model_type: rfdetr\n width: 320\n height: 320\n input_tensor: nchw\n input_dtype: float\n path: /config/model_cache/rfdetr.onnx"
|
||||
},
|
||||
{
|
||||
"key": "yolonas",
|
||||
"label": "YOLO-NAS",
|
||||
"recommended": false,
|
||||
"download": "You can build and download a compatible model with pre-trained weights using [this notebook](https://github.com/blakeblackshear/frigate/blob/dev/notebooks/YOLO_NAS_Pretrained_Export.ipynb) [](https://colab.research.google.com/github/blakeblackshear/frigate/blob/dev/notebooks/YOLO_NAS_Pretrained_Export.ipynb) which can be run directly in [Google Colab](https://colab.research.google.com/github/blakeblackshear/frigate/blob/dev/notebooks/YOLO_NAS_Pretrained_Export.ipynb).\n\n:::warning\n\nThe pre-trained YOLO-NAS weights from DeciAI are subject to their license and can't be used commercially. For more information, see: https://docs.deci.ai/super-gradients/latest/LICENSE.YOLONAS.html\n\n:::\n\nThe input image size in this notebook is set to 320x320. This results in lower CPU usage and faster inference times without impacting performance in most cases due to the way Frigate crops video frames to areas of interest before running detection. The notebook and config can be updated to 640x640 if desired.",
|
||||
"ui": "Navigate to **Settings > System > Detectors and model** and select **ONNX** from the detector type dropdown and click **Add**. Then on the same page, in the **Custom Model** tab, configure:\n\n| Field | Value |\n| ---------------------------------------- | ------------------------------------------------- |\n| **Object Detection Model Type** | `yolonas` |\n| **Object detection model input width** | `320` (should match whatever was set in notebook) |\n| **Object detection model input height** | `320` (should match whatever was set in notebook) |\n| **Model Input Pixel Color Format** | `bgr` |\n| **Model Input Tensor Shape** | `nchw` |\n| **Custom object detector model path** | `/config/yolo_nas_s.onnx` |\n| **Label map for custom object detector** | `/labelmap/coco-80.txt` |",
|
||||
"yaml": "detectors:\n onnx:\n type: onnx\n\nmodel:\n model_type: yolonas\n width: 320 # <--- should match whatever was set in notebook\n height: 320 # <--- should match whatever was set in notebook\n input_pixel_format: bgr\n input_tensor: nchw\n path: /config/yolo_nas_s.onnx\n labelmap_path: /labelmap/coco-80.txt"
|
||||
},
|
||||
{
|
||||
"key": "yolox",
|
||||
"label": "YOLOX",
|
||||
"recommended": false,
|
||||
"download": "YOLOx models can be downloaded [from the YOLOx repo](https://github.com/Megvii-BaseDetection/YOLOX/tree/main/demo/ONNXRuntime).",
|
||||
"ui": "Navigate to **Settings > System > Detectors and model** and select **ONNX** from the detector type dropdown and click **Add**. Then on the same page, in the **Custom Model** tab, configure:\n\n| Field | Value |\n| ---------------------------------------- | -------------------------------------------------------- |\n| **Object Detection Model Type** | `yolox` |\n| **Object detection model input width** | `416` (should match the imgsize set during model export) |\n| **Object detection model input height** | `416` (should match the imgsize set during model export) |\n| **Model Input Tensor Shape** | `nchw` |\n| **Model Input D Type** | `float_denorm` |\n| **Custom object detector model path** | `/config/model_cache/yolox_tiny.onnx` |\n| **Label map for custom object detector** | `/labelmap/coco-80.txt` |",
|
||||
"yaml": "detectors:\n onnx:\n type: onnx\n\nmodel:\n model_type: yolox\n width: 416 # <--- should match the imgsize set during model export\n height: 416 # <--- should match the imgsize set during model export\n input_tensor: nchw\n input_dtype: float_denorm\n path: /config/model_cache/yolox_tiny.onnx\n labelmap_path: /labelmap/coco-80.txt"
|
||||
},
|
||||
{
|
||||
"key": "dfine",
|
||||
"label": "D-FINE / DEIMv2",
|
||||
"recommended": false,
|
||||
"download": "#### Downloading D-FINE Model\n\nD-FINE can be exported as ONNX by running the command below. You can copy and paste the whole thing to your terminal and execute, altering `MODEL_SIZE=s` in the first line to `s`, `m`, or `l` size.\n\n```sh\ndocker build . --build-arg MODEL_SIZE=s --output . -f- <<'EOF'\nFROM python:3.11 AS build\nRUN apt-get update && apt-get install --no-install-recommends -y libgl1 && rm -rf /var/lib/apt/lists/*\nCOPY --from=ghcr.io/astral-sh/uv:0.8.0 /uv /bin/\nWORKDIR /dfine\nRUN git clone https://github.com/Peterande/D-FINE.git .\nRUN uv pip install --system -r requirements.txt\nRUN uv pip install --system onnx onnxruntime onnxsim onnxscript\n# Create output directory and download checkpoint\nRUN mkdir -p output\nARG MODEL_SIZE\nRUN wget https://github.com/Peterande/storage/releases/download/dfinev1.0/dfine_${MODEL_SIZE}_obj2coco.pth -O output/dfine_${MODEL_SIZE}_obj2coco.pth\n# Modify line 58 of export_onnx.py to change batch size to 1\nRUN sed -i '58s/data = torch.rand(.*)/data = torch.rand(1, 3, 640, 640)/' tools/deployment/export_onnx.py\nRUN python3 tools/deployment/export_onnx.py -c configs/dfine/objects365/dfine_hgnetv2_${MODEL_SIZE}_obj2coco.yml -r output/dfine_${MODEL_SIZE}_obj2coco.pth\nFROM scratch\nARG MODEL_SIZE\nCOPY --from=build /dfine/output/dfine_${MODEL_SIZE}_obj2coco.onnx /dfine-${MODEL_SIZE}.onnx\nEOF\n```\n\n#### Downloading DEIMv2 Model\n\n[DEIMv2](https://github.com/Intellindust-AI-Lab/DEIMv2) can be exported as ONNX by running the command below. Pretrained weights are available on Hugging Face for two backbone families:\n\n- **HGNetv2** (smaller/faster): `atto`, `femto`, `pico`, `n`\n- **DINOv3** (larger/more accurate): `s`, `m`, `l`, `x`\n\nSet `BACKBONE` and `MODEL_SIZE` in the first line to match your desired variant. Hugging Face model names use uppercase (e.g. `HGNetv2_N`, `DINOv3_S`), while config files use lowercase (e.g. `hgnetv2_n`, `dinov3_s`).\n\n```sh\ndocker build . --rm --build-arg BACKBONE=hgnetv2 --build-arg MODEL_SIZE=n --output . -f- <<'EOF'\nFROM python:3.11-slim AS build\nRUN apt-get update && apt-get install --no-install-recommends -y git libgl1 libglib2.0-0 && rm -rf /var/lib/apt/lists/*\nCOPY --from=ghcr.io/astral-sh/uv:0.8.0 /uv /bin/\nWORKDIR /deimv2\nRUN git clone https://github.com/Intellindust-AI-Lab/DEIMv2.git .\n# Install CPU-only PyTorch first to avoid pulling CUDA variant\nRUN uv pip install --no-cache --system torch torchvision --index-url https://download.pytorch.org/whl/cpu\nRUN uv pip install --no-cache --system -r requirements.txt\nRUN uv pip install --no-cache --system onnx safetensors huggingface_hub\nRUN mkdir -p output\nARG BACKBONE\nARG MODEL_SIZE\n# Download from Hugging Face and convert safetensors to pth\nRUN python3 -c \"\\\nfrom huggingface_hub import hf_hub_download; \\\nfrom safetensors.torch import load_file; \\\nimport torch; \\\nbackbone = '${BACKBONE}'.replace('hgnetv2','HGNetv2').replace('dinov3','DINOv3'); \\\nsize = '${MODEL_SIZE}'.upper(); \\\nst = load_file(hf_hub_download('Intellindust/DEIMv2_' + backbone + '_' + size + '_COCO', 'model.safetensors')); \\\ntorch.save({'model': st}, 'output/deimv2.pth')\"\nRUN sed -i \"s/data = torch.rand(2/data = torch.rand(1/\" tools/deployment/export_onnx.py\n# HuggingFace safetensors omits frozen constants that the model constructor initializes\nRUN sed -i \"s/cfg.model.load_state_dict(state)/cfg.model.load_state_dict(state, strict=False)/\" tools/deployment/export_onnx.py\nRUN python3 tools/deployment/export_onnx.py -c configs/deimv2/deimv2_${BACKBONE}_${MODEL_SIZE}_coco.yml -r output/deimv2.pth\nFROM scratch\nARG BACKBONE\nARG MODEL_SIZE\nCOPY --from=build /deimv2/output/deimv2.onnx /deimv2_${BACKBONE}_${MODEL_SIZE}.onnx\nEOF\n```",
|
||||
"ui": "Navigate to **Settings > System > Detectors and model** and select **ONNX** from the detector type dropdown and click **Add**. Then on the same page, in the **Custom Model** tab, configure:\n\n| Field | Value |\n| ---------------------------------------- | ------------------------------------------- |\n| **Object Detection Model Type** | `dfine` |\n| **Object detection model input width** | `640` |\n| **Object detection model input height** | `640` |\n| **Model Input Tensor Shape** | `nchw` |\n| **Model Input D Type** | `float` |\n| **Custom object detector model path** | `/config/model_cache/dfine_m_obj2coco.onnx` |\n| **Label map for custom object detector** | `/labelmap/coco-80.txt` |",
|
||||
"yaml": "detectors:\n onnx:\n type: onnx\n\nmodel:\n model_type: dfine\n width: 640\n height: 640\n input_tensor: nchw\n input_dtype: float\n path: /config/model_cache/dfine_m_obj2coco.onnx\n labelmap_path: /labelmap/coco-80.txt"
|
||||
},
|
||||
{
|
||||
"key": "yolo-legacy",
|
||||
"label": "YOLO (v3, v4, v7)",
|
||||
"recommended": false,
|
||||
"download": "To export as ONNX:\n\n```sh\ngit clone https://github.com/NateMeyer/tensorrt_demos\ncd tensorrt_demos/yolo\n./download_yolo.sh\npython3 yolo_to_onnx.py -m yolov7-320\n```",
|
||||
"ui": "Navigate to **Settings > System > Detectors and model** and select **ONNX** from the detector type dropdown and click **Add**. Then on the same page, in the **Custom Model** tab, configure:\n\n| Field | Value |\n| ---------------------------------------- | -------------------------------------------------------- |\n| **Object Detection Model Type** | `yolo-generic` |\n| **Object detection model input width** | `320` (should match the imgsize set during model export) |\n| **Object detection model input height** | `320` (should match the imgsize set during model export) |\n| **Model Input Tensor Shape** | `nchw` |\n| **Model Input D Type** | `float` |\n| **Custom object detector model path** | `/config/model_cache/yolo.onnx` |\n| **Label map for custom object detector** | `/labelmap/coco-80.txt` |",
|
||||
"yaml": "detectors:\n onnx:\n type: onnx\n\nmodel:\n model_type: yolo-generic\n width: 320 # <--- should match the imgsize set during model export\n height: 320 # <--- should match the imgsize set during model export\n input_tensor: nchw\n input_dtype: float\n path: /config/model_cache/yolo.onnx\n labelmap_path: /labelmap/coco-80.txt"
|
||||
}
|
||||
]
|
||||
},
|
||||
"cpu": {
|
||||
"title": "CPU",
|
||||
"models": [
|
||||
{
|
||||
"key": "ssd",
|
||||
"label": "MobileNet v2",
|
||||
"recommended": true,
|
||||
"download": "A TensorFlow Lite model is provided in the container at `/cpu_model.tflite` and is used by this detector type by default. To provide your own model, bind mount the file into the container and provide the path with `model.path`.",
|
||||
"ui": "Navigate to **Settings > System > Detectors and model** and select **CPU** from the detector type dropdown and click **Add**. Configure the number of threads and click **Add** again to add additional CPU detectors as needed (one per camera is recommended).\n\n| Field | Value |\n| ----------------- | ----- |\n| **Detector type** | `cpu` |\n| **Num threads** | `3` |",
|
||||
"yaml": "detectors:\n cpu1:\n type: cpu\n num_threads: 3"
|
||||
}
|
||||
]
|
||||
},
|
||||
"deepstack": {
|
||||
"title": "DeepStack / CodeProject.AI",
|
||||
"models": [
|
||||
{
|
||||
"key": "yolo",
|
||||
"label": "YOLO",
|
||||
"recommended": true,
|
||||
"download": "This detector runs object detection over the network against a CodeProject.AI or DeepStack server, so no model is downloaded into Frigate itself. Visit the [CodeProject.AI official website](https://www.codeproject.com/Articles/5322557/CodeProject-AI-Server-AI-the-easy-way) to download and install the AI server on your preferred device (e.g. Raspberry Pi, Nvidia Jetson, or other compatible hardware) before configuring the detector.",
|
||||
"ui": "Navigate to **Settings > System > Detectors and model** and select **DeepStack** from the detector type dropdown and click **Add**. Set the API URL to point to your CodeProject.AI server (e.g., `http://<your_codeproject_ai_server_ip>:<port>/v1/vision/detection`).\n\n| Field | Value |\n| ------------- | ---------------------------------------------------------------------- |\n| **API URL** | `http://<your_codeproject_ai_server_ip>:<port>/v1/vision/detection` |\n| **API Timeout** | `0.1` (seconds) |",
|
||||
"yaml": "detectors:\n deepstack:\n api_url: http://<your_codeproject_ai_server_ip>:<port>/v1/vision/detection\n type: deepstack\n api_timeout: 0.1 # seconds"
|
||||
}
|
||||
]
|
||||
},
|
||||
"memryx": {
|
||||
"title": "MemryX",
|
||||
"models": [
|
||||
{
|
||||
"key": "yolonas",
|
||||
"label": "YOLO-NAS",
|
||||
"recommended": true,
|
||||
"download": "The [YOLO-NAS](https://github.com/Deci-AI/super-gradients/blob/master/YOLONAS.md) model included in this detector is downloaded automatically and compiled to DFP with [mx_nc](https://developer.memryx.com/2p1/tools/neural_compiler.html#usage).\n\n**Note:** The default model for the MemryX detector is YOLO-NAS 320x320.\n\nThe input size for **YOLO-NAS** can be set to either **320x320** (default) or **640x640**.\n\n- The default size of **320x320** is optimized for lower CPU usage and faster inference times.\n\nMemryX `.dfp` models are automatically downloaded at runtime, if enabled, to the container at `/memryx_models/model_folder/`.",
|
||||
"ui": "Navigate to **Settings > System > Detectors and model** and select **MemryX** from the detector type dropdown and click **Add**, then set device to `PCIe:0`. Then on the same page, in the **Custom Model** tab, configure:\n\n| Field | Value |\n| ---------------------------------------- | ------------------------------------------------- |\n| **Object Detection Model Type** | `yolonas` |\n| **Object detection model input width** | `320` (can be set to `640` for higher resolution) |\n| **Object detection model input height** | `320` (can be set to `640` for higher resolution) |\n| **Model Input Tensor Shape** | `nchw` |\n| **Model Input D Type** | `float` |\n| **Label map for custom object detector** | `/labelmap/coco-80.txt` |",
|
||||
"yaml": "detectors:\n memx0:\n type: memryx\n device: PCIe:0\n\nmodel:\n model_type: yolonas\n width: 320 # (Can be set to 640 for higher resolution)\n height: 320 # (Can be set to 640 for higher resolution)\n input_tensor: nchw\n input_dtype: float\n labelmap_path: /labelmap/coco-80.txt\n # Optional: The model is normally fetched through the runtime, so 'path' can be omitted unless you want to use a custom or local model.\n # path: /config/yolonas.zip\n # The .zip file must contain:\n # \u251c\u2500\u2500 yolonas.dfp (a file ending with .dfp)\n # \u2514\u2500\u2500 yolonas_post.onnx (optional; only if the model includes a cropped post-processing network)"
|
||||
},
|
||||
{
|
||||
"key": "yolov9",
|
||||
"label": "YOLOv9",
|
||||
"recommended": false,
|
||||
"download": "The YOLOv9s model included in this detector is downloaded from [the original GitHub](https://github.com/WongKinYiu/yolov9) and compiled to DFP with [mx_nc](https://developer.memryx.com/2p1/tools/neural_compiler.html#usage).\n\nMemryX `.dfp` models are automatically downloaded at runtime, if enabled, to the container at `/memryx_models/model_folder/`.",
|
||||
"ui": "Navigate to **Settings > System > Detectors and model** and select **MemryX** from the detector type dropdown and click **Add**, then set device to `PCIe:0`. Then on the same page, in the **Custom Model** tab, configure:\n\n| Field | Value |\n| ---------------------------------------- | ------------------------------------------------- |\n| **Object Detection Model Type** | `yolo-generic` |\n| **Object detection model input width** | `320` (can be set to `640` for higher resolution) |\n| **Object detection model input height** | `320` (can be set to `640` for higher resolution) |\n| **Model Input Tensor Shape** | `nchw` |\n| **Model Input D Type** | `float` |\n| **Label map for custom object detector** | `/labelmap/coco-80.txt` |",
|
||||
"yaml": "detectors:\n memx0:\n type: memryx\n device: PCIe:0\n\nmodel:\n model_type: yolo-generic\n width: 320 # (Can be set to 640 for higher resolution)\n height: 320 # (Can be set to 640 for higher resolution)\n input_tensor: nchw\n input_dtype: float\n labelmap_path: /labelmap/coco-80.txt\n # Optional: The model is normally fetched through the runtime, so 'path' can be omitted unless you want to use a custom or local model.\n # path: /config/yolov9.zip\n # The .zip file must contain:\n # \u251c\u2500\u2500 yolov9.dfp (a file ending with .dfp)"
|
||||
},
|
||||
{
|
||||
"key": "yolox",
|
||||
"label": "YOLOX",
|
||||
"recommended": false,
|
||||
"download": "The model is sourced from the [OpenCV Model Zoo](https://github.com/opencv/opencv_zoo) and precompiled to DFP.\n\nMemryX `.dfp` models are automatically downloaded at runtime, if enabled, to the container at `/memryx_models/model_folder/`.",
|
||||
"ui": "Navigate to **Settings > System > Detectors and model** and select **MemryX** from the detector type dropdown and click **Add**, then set device to `PCIe:0`. Then on the same page, in the **Custom Model** tab, configure:\n\n| Field | Value |\n| ---------------------------------------- | ----------------------- |\n| **Object Detection Model Type** | `yolox` |\n| **Object detection model input width** | `640` |\n| **Object detection model input height** | `640` |\n| **Model Input Tensor Shape** | `nchw` |\n| **Model Input D Type** | `float_denorm` |\n| **Label map for custom object detector** | `/labelmap/coco-80.txt` |",
|
||||
"yaml": "detectors:\n memx0:\n type: memryx\n device: PCIe:0\n\nmodel:\n model_type: yolox\n width: 640\n height: 640\n input_tensor: nchw\n input_dtype: float_denorm\n labelmap_path: /labelmap/coco-80.txt\n # Optional: The model is normally fetched through the runtime, so 'path' can be omitted unless you want to use a custom or local model.\n # path: /config/yolox.zip\n # The .zip file must contain:\n # \u251c\u2500\u2500 yolox.dfp (a file ending with .dfp)"
|
||||
},
|
||||
{
|
||||
"key": "ssd",
|
||||
"label": "SSDLite MobileNet v2",
|
||||
"recommended": false,
|
||||
"download": "The model is sourced from the [OpenMMLab Model Zoo](https://mmdeploy-oss.openmmlab.com/model/mmdet-det/ssdlite-e8679f.onnx) and has been converted to DFP.\n\nMemryX `.dfp` models are automatically downloaded at runtime, if enabled, to the container at `/memryx_models/model_folder/`.",
|
||||
"ui": "Navigate to **Settings > System > Detectors and model** and select **MemryX** from the detector type dropdown and click **Add**, then set device to `PCIe:0`. Then on the same page, in the **Custom Model** tab, configure:\n\n| Field | Value |\n| ---------------------------------------- | ----------------------- |\n| **Object Detection Model Type** | `ssd` |\n| **Object detection model input width** | `320` |\n| **Object detection model input height** | `320` |\n| **Model Input Tensor Shape** | `nchw` |\n| **Model Input D Type** | `float` |\n| **Label map for custom object detector** | `/labelmap/coco-80.txt` |",
|
||||
"yaml": "detectors:\n memx0:\n type: memryx\n device: PCIe:0\n\nmodel:\n model_type: ssd\n width: 320\n height: 320\n input_tensor: nchw\n input_dtype: float\n labelmap_path: /labelmap/coco-80.txt\n # Optional: The model is normally fetched through the runtime, so 'path' can be omitted unless you want to use a custom or local model.\n # path: /config/ssdlite_mobilenet.zip\n # The .zip file must contain:\n # \u251c\u2500\u2500 ssdlite_mobilenet.dfp (a file ending with .dfp)\n # \u2514\u2500\u2500 ssdlite_mobilenet_post.onnx (optional; only if the model includes a cropped post-processing network)"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tensorrt": {
|
||||
"title": "TensorRT",
|
||||
"models": [
|
||||
{
|
||||
"key": "yolo-legacy",
|
||||
"label": "YOLO (v3, v4, v7)",
|
||||
"recommended": true,
|
||||
"download": "The model used for TensorRT must be preprocessed on the same hardware platform that it will run on, so Frigate generates the `.trt` model file on-device at startup. Processed models are stored in the `/config/model_cache` folder.\n\nBy default no models are generated. Set the `YOLO_MODELS` environment variable in Docker to one or more comma-separated model names (from the available `yolov3`/`yolov4`/`yolov7` models) and each one will be generated on startup if the corresponding `{model}.trt` file is not already present in `model_cache` (delete it to force regeneration). On Jetson devices with DLAs (Xavier or Orin), append `-dla` to a model name to generate a DLA model. If your GPU does not support FP16 operations, pass `USE_FP16=False` to disable it.\n\nAn example `docker-compose.yml` fragment that converts the `yolov7-320` and `yolov7x-640` models:\n\n```yml\nfrigate:\n environment:\n - YOLO_MODELS=yolov7-320,yolov7x-640\n - USE_FP16=false\n```",
|
||||
"ui": "Navigate to **Settings > System > Detectors and model** and select **TensorRT** from the detector type dropdown and click **Add**, then set the device to `0` (the default GPU index). Then on the same page, in the **Custom Model** tab, configure:\n\n| Field | Value |\n| ---------------------------------------- | ------------------------------------------------------------ |\n| **Custom object detector model path** | `/config/model_cache/tensorrt/yolov7-320.trt` |\n| **Label map for custom object detector** | `/labelmap/coco-80.txt` |\n| **Model Input Tensor Shape** | `nchw` |\n| **Model Input Pixel Color Format** | `rgb` |\n| **Object detection model input width** | `320` (MUST match the chosen model, e.g., yolov7-320 -> 320) |\n| **Object detection model input height** | `320` (MUST match the chosen model, e.g., yolov7-320 -> 320) |",
|
||||
"yaml": "detectors:\n tensorrt:\n type: tensorrt\n device: 0 #This is the default, select the first GPU\n\nmodel:\n path: /config/model_cache/tensorrt/yolov7-320.trt\n labelmap_path: /labelmap/coco-80.txt\n input_tensor: nchw\n input_pixel_format: rgb\n width: 320 # MUST match the chosen model i.e yolov7-320 -> 320, yolov4-416 -> 416\n height: 320 # MUST match the chosen model i.e yolov7-320 -> 320 yolov4-416 -> 416"
|
||||
}
|
||||
]
|
||||
},
|
||||
"synaptics": {
|
||||
"title": "Synaptics",
|
||||
"models": [
|
||||
{
|
||||
"key": "ssd",
|
||||
"label": "SSD MobileNet",
|
||||
"recommended": true,
|
||||
"download": "A synap model is provided in the container at `/mobilenet.synap` and is used by this detector type by default. The model comes from the [Synap-release Github](https://github.com/synaptics-astra/synap-release/tree/v1.5.0/models/dolphin/object_detection/coco/model/mobilenet224_full80).",
|
||||
"ui": "Navigate to **Settings > System > Detectors and model** and select **Synaptics** from the detector type dropdown and click **Add**. Then on the same page, in the **Custom Model** tab, configure:\n\n| Field | Value |\n| ---------------------------------------- | ---------------------------- |\n| **Custom object detector model path** | `/synaptics/mobilenet.synap` |\n| **Object detection model input width** | `224` |\n| **Object detection model input height** | `224` |\n| **Model Input Tensor Shape** | `nhwc` |\n| **Label map for custom object detector** | `/labelmap/coco-80.txt` |",
|
||||
"yaml": "detectors: # required\n synap_npu: # required\n type: synaptics # required\n\nmodel: # required\n path: /synaptics/mobilenet.synap # required\n width: 224 # required\n height: 224 # required\n input_tensor: nhwc # default value (optional. If you change the model, it is required)\n labelmap_path: /labelmap/coco-80.txt # required"
|
||||
}
|
||||
]
|
||||
},
|
||||
"rknn": {
|
||||
"title": "RKNN",
|
||||
"models": [
|
||||
{
|
||||
"key": "yolov9",
|
||||
"label": "YOLOv9",
|
||||
"recommended": true,
|
||||
"download": "If no custom model is provided, the RKNN detector downloads a default model from GitHub on first startup. Once cached, the model works fully offline. All models are automatically downloaded and stored in the folder `config/model_cache/rknn_cache`. After upgrading Frigate, you should remove older models to free up space.\n\nYou can also provide your own `.rknn` model. You should not save your own models in the `rknn_cache` folder, store them directly in the `model_cache` folder or another subfolder. To convert a model to `.rknn` format see the `rknn-toolkit2` (requires a x86 machine). Note, that there is only post-processing for the supported models.",
|
||||
"ui": "Navigate to **Settings > System > Detectors and model** and, in the **Custom Model** tab, configure:\n\n| Field | Value |\n| ---------------------------------------- | -------------------------------------------------- |\n| **Custom object detector model path** | `frigate-fp16-yolov9-t` (or other yolov9 variants) |\n| **Object Detection Model Type** | `yolo-generic` |\n| **Object detection model input width** | `320` |\n| **Object detection model input height** | `320` |\n| **Model Input Tensor Shape** | `nhwc` |\n| **Label map for custom object detector** | `/labelmap/coco-80.txt` |",
|
||||
"yaml": "model: # required\n # name of model (will be automatically downloaded) or path to your own .rknn model file\n # possible values are:\n # - frigate-fp16-yolov9-t\n # - frigate-fp16-yolov9-s\n # - frigate-fp16-yolov9-m\n # - frigate-fp16-yolov9-c\n # - frigate-fp16-yolov9-e\n # your yolo_model.rknn\n path: frigate-fp16-yolov9-t\n model_type: yolo-generic\n width: 320\n height: 320\n input_tensor: nhwc\n labelmap_path: /labelmap/coco-80.txt"
|
||||
},
|
||||
{
|
||||
"key": "yolonas",
|
||||
"label": "YOLO-NAS",
|
||||
"recommended": false,
|
||||
"download": "If no custom model is provided, the RKNN detector downloads a default model from GitHub on first startup. Once cached, the model works fully offline. All models are automatically downloaded and stored in the folder `config/model_cache/rknn_cache`. After upgrading Frigate, you should remove older models to free up space.\n\nYou can also provide your own `.rknn` model. You should not save your own models in the `rknn_cache` folder, store them directly in the `model_cache` folder or another subfolder. To convert a model to `.rknn` format see the `rknn-toolkit2` (requires a x86 machine). Note, that there is only post-processing for the supported models.\n\n**Note:** The pre-trained YOLO-NAS weights from DeciAI are subject to their license and can't be used commercially. For more information, see: https://docs.deci.ai/super-gradients/latest/LICENSE.YOLONAS.html",
|
||||
"ui": "Navigate to **Settings > System > Detectors and model** and, in the **Custom Model** tab, configure:\n\n| Field | Value |\n| ---------------------------------------- | ----------------------------------------------------------------------- |\n| **Custom object detector model path** | `deci-fp16-yolonas_s` (or `deci-fp16-yolonas_m`, `deci-fp16-yolonas_l`) |\n| **Object Detection Model Type** | `yolonas` |\n| **Object detection model input width** | `320` |\n| **Object detection model input height** | `320` |\n| **Model Input Pixel Color Format** | `bgr` |\n| **Model Input Tensor Shape** | `nhwc` |\n| **Label map for custom object detector** | `/labelmap/coco-80.txt` |",
|
||||
"yaml": "model: # required\n # name of model (will be automatically downloaded) or path to your own .rknn model file\n # possible values are:\n # - deci-fp16-yolonas_s\n # - deci-fp16-yolonas_m\n # - deci-fp16-yolonas_l\n # your yolonas_model.rknn\n path: deci-fp16-yolonas_s\n model_type: yolonas\n width: 320\n height: 320\n input_pixel_format: bgr\n input_tensor: nhwc\n labelmap_path: /labelmap/coco-80.txt"
|
||||
},
|
||||
{
|
||||
"key": "yolox",
|
||||
"label": "YOLOx",
|
||||
"recommended": false,
|
||||
"download": "If no custom model is provided, the RKNN detector downloads a default model from GitHub on first startup. Once cached, the model works fully offline. All models are automatically downloaded and stored in the folder `config/model_cache/rknn_cache`. After upgrading Frigate, you should remove older models to free up space.\n\nYou can also provide your own `.rknn` model. You should not save your own models in the `rknn_cache` folder, store them directly in the `model_cache` folder or another subfolder. To convert a model to `.rknn` format see the `rknn-toolkit2` (requires a x86 machine). Note, that there is only post-processing for the supported models.",
|
||||
"ui": "Navigate to **Settings > System > Detectors and model** and, in the **Custom Model** tab, configure:\n\n| Field | Value |\n| ---------------------------------------- | ---------------------------------------------- |\n| **Custom object detector model path** | `rock-i8-yolox_nano` (or other yolox variants) |\n| **Object Detection Model Type** | `yolox` |\n| **Object detection model input width** | `416` |\n| **Object detection model input height** | `416` |\n| **Model Input Tensor Shape** | `nhwc` |\n| **Label map for custom object detector** | `/labelmap/coco-80.txt` |",
|
||||
"yaml": "model: # required\n # name of model (will be automatically downloaded) or path to your own .rknn model file\n # possible values are:\n # - rock-i8-yolox_nano\n # - rock-i8-yolox_tiny\n # - rock-fp16-yolox_nano\n # - rock-fp16-yolox_tiny\n # your yolox_model.rknn\n path: rock-i8-yolox_nano\n model_type: yolox\n width: 416\n height: 416\n input_tensor: nhwc\n labelmap_path: /labelmap/coco-80.txt"
|
||||
}
|
||||
]
|
||||
},
|
||||
"axengine": {
|
||||
"title": "AXEngine",
|
||||
"models": [
|
||||
{
|
||||
"key": "yolov9",
|
||||
"label": "YOLOv9",
|
||||
"recommended": true,
|
||||
"download": "A yolov9 axmodel is provided in the container at `/axmodels` and is used by this detector type by default. The AXEngine detector downloads its default model from HuggingFace on first startup; once cached, the model works fully offline.",
|
||||
"ui": "Navigate to **Settings > System > Detectors and model** and select **AXEngine NPU** from the detector type dropdown and click **Add**. Then on the same page, in the **Custom Model** tab, configure:\n\n| Field | Value |\n| ---------------------------------------- | ----------------------- |\n| **Custom object detector model path** | `frigate-yolov9-tiny` |\n| **Object Detection Model Type** | `yolo-generic` |\n| **Object detection model input width** | `320` |\n| **Object detection model input height** | `320` |\n| **Model Input D Type** | `int` |\n| **Model Input Pixel Color Format** | `bgr` |\n| **Label map for custom object detector** | `/labelmap/coco-80.txt` |",
|
||||
"yaml": "detectors:\n axengine:\n type: axengine\n\nmodel:\n path: frigate-yolov9-tiny\n model_type: yolo-generic\n width: 320\n height: 320\n input_dtype: int\n input_pixel_format: bgr\n labelmap_path: /labelmap/coco-80.txt"
|
||||
}
|
||||
]
|
||||
},
|
||||
"degirumAiServer": {
|
||||
"title": "DeGirum AI Server",
|
||||
"models": [
|
||||
{
|
||||
"key": "ai-server-inference",
|
||||
"label": "AI Server Inference",
|
||||
"recommended": true,
|
||||
"download": "Launch a DeGirum AI server as a Docker container, then point the detector at it. Add this to your `docker-compose.yml`:\n\n```yaml\ndegirum_detector:\n container_name: degirum\n image: degirum/aiserver:latest\n privileged: true\n ports:\n - \"8778:8778\"\n```\n\nSet `location` to the server's service name, container name, or `host:port`.",
|
||||
"ui": "Navigate to **Settings > System > Detectors and model** and select **DeGirum** from the detector type dropdown and click **Add**.\n\n| Field | Value |\n| --- | --- |\n| **Location** | `degirum` |\n| **Zoo** | `degirum/public` |\n| **Token** | your AI Hub token (optional for the public zoo) |\n",
|
||||
"yaml": "degirum_detector:\n type: degirum\n location: degirum\n zoo: degirum/public\n token: dg_example_token\n"
|
||||
}
|
||||
]
|
||||
},
|
||||
"degirumLocal": {
|
||||
"title": "DeGirum Local",
|
||||
"models": [
|
||||
{
|
||||
"key": "local-inference",
|
||||
"label": "Local Inference",
|
||||
"recommended": true,
|
||||
"download": "Run hardware directly inside the Frigate container with `@local`, removing the AI server hop. The matching device runtime (e.g. the Hailo runtime) must be installed in the container; confirm it with `degirum sys-info`.",
|
||||
"ui": "Navigate to **Settings > System > Detectors and model** and select **DeGirum** from the detector type dropdown and click **Add**.\n\n| Field | Value |\n| --- | --- |\n| **Location** | `@local` |\n| **Zoo** | `degirum/public` |\n| **Token** | your AI Hub token (optional for the public zoo) |\n",
|
||||
"yaml": "degirum_detector:\n type: degirum\n location: @local\n zoo: degirum/public\n token: dg_example_token\n"
|
||||
}
|
||||
]
|
||||
},
|
||||
"degirumCloud": {
|
||||
"title": "DeGirum AI Hub Cloud",
|
||||
"models": [
|
||||
{
|
||||
"key": "ai-hub-cloud-inference",
|
||||
"label": "AI Hub Cloud Inference",
|
||||
"recommended": true,
|
||||
"download": "Run inferences on DeGirum's [AI Hub](https://hub.degirum.com) cloud with `@cloud`. Sign up, create an access token, and set it as `token`. Network latency may require lowering your detection fps.",
|
||||
"ui": "Navigate to **Settings > System > Detectors and model** and select **DeGirum** from the detector type dropdown and click **Add**.\n\n| Field | Value |\n| --- | --- |\n| **Location** | `@cloud` |\n| **Zoo** | `degirum/public` |\n| **Token** | your AI Hub token (optional for the public zoo) |\n",
|
||||
"yaml": "degirum_detector:\n type: degirum\n location: @cloud\n zoo: degirum/public\n token: dg_example_token\n"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -126,12 +126,12 @@ birdseye:
|
||||
|
||||
### Sorting cameras in the Birdseye view
|
||||
|
||||
It is possible to override the order of cameras that are being shown in the Birdseye view. The order is set at the camera level (when using YAML).
|
||||
It is possible to override the order of cameras that are being shown in the Birdseye view. The order is set at the camera level.
|
||||
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
Navigate to <NavPath path="Settings > System > Birdseye" /> and in the **Camera order** field, use the drag handle next to each camera name to control the display order.
|
||||
Navigate to <NavPath path="Settings > Camera configuration > Birdseye" /> for each camera and set the **Position** field to control the display order.
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
|
||||
@@ -194,7 +194,7 @@ Camera groups let you organize cameras together with a shared name and icon, mak
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
On the Live dashboard, press the **pencil icon** in the main navigation to add a new camera group. Configure the group name, select which cameras to include, choose an icon, and set the display order.
|
||||
On the Live dashboard, press the **+** icon in the main navigation to add a new camera group. Configure the group name, select which cameras to include, choose an icon, and set the display order.
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
|
||||
@@ -86,7 +86,7 @@ Navigate to <NavPath path="Settings > Enrichments > Face recognition" />.
|
||||
- **Detection threshold**: Face detection confidence score required before recognition runs. This field only applies to the standalone face detection model; `min_score` should be used to filter for models that have face detection built in.
|
||||
- Default: `0.7`
|
||||
- **Minimum face area**: Minimum size (in pixels) a face must be before recognition runs. Depending on the resolution of your camera's `detect` stream, you can increase this value to ignore small or distant faces.
|
||||
- Default: `750` pixels
|
||||
- Default: `500` pixels
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
@@ -95,7 +95,7 @@ Navigate to <NavPath path="Settings > Enrichments > Face recognition" />.
|
||||
face_recognition:
|
||||
enabled: true
|
||||
detection_threshold: 0.7
|
||||
min_area: 750
|
||||
min_area: 500
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
|
||||
@@ -7,27 +7,27 @@ import ConfigTabs from "@site/src/components/ConfigTabs";
|
||||
import TabItem from "@theme/TabItem";
|
||||
import NavPath from "@site/src/components/NavPath";
|
||||
|
||||
Frigate ships with a set of FFmpeg presets to keep your configuration short and readable. Each preset expands to a longer list of FFmpeg arguments at runtime. You can see exactly what every preset expands to in [this file](https://github.com/blakeblackshear/frigate/blob/master/frigate/ffmpeg_presets.py).
|
||||
Some presets of FFmpeg args are provided by default to make the configuration easier. All presets can be seen in [this file](https://github.com/blakeblackshear/frigate/blob/master/frigate/ffmpeg_presets.py).
|
||||
|
||||
In the config file you reference a preset by its name (for example, `preset-vaapi`). In the UI, the same preset is shown with a friendly label (for example, **VAAPI (Intel/AMD GPU)**). Both refer to the same thing — the tables below list the config name alongside the label you'll see in the UI.
|
||||
### Hwaccel Presets
|
||||
|
||||
### Hwaccel (Hardware Acceleration) Presets
|
||||
It is highly recommended to use hwaccel presets in the config. These presets not only replace the longer args, but they also give Frigate hints of what hardware is available and allows Frigate to make other optimizations using the GPU such as when encoding the birdseye restream or when scaling a stream that has a size different than the native stream size.
|
||||
|
||||
Hardware acceleration arguments tell FFmpeg to decode your camera's video stream on a GPU or integrated graphics chip instead of the CPU, which dramatically lowers CPU usage. Using a preset is highly recommended. Beyond replacing a long list of arguments, each preset also tells Frigate what hardware is available so it can offload additional work to the GPU — for example, encoding the Birdseye restream or scaling a stream whose resolution differs from the camera's native size.
|
||||
See [the hwaccel docs](/configuration/hardware_acceleration_video.md) for more info on how to setup hwaccel for your GPU / iGPU.
|
||||
|
||||
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 | Usage | Other Notes |
|
||||
| --------------------- | ------------------------------ | ----------------------------------------------------- |
|
||||
| preset-rpi-64-h264 | 64 bit Rpi with h264 stream | |
|
||||
| preset-rpi-64-h265 | 64 bit Rpi with h265 stream | |
|
||||
| preset-vaapi | Intel & AMD VAAPI | Check hwaccel docs to ensure correct driver is chosen |
|
||||
| preset-intel-qsv-h264 | Intel QSV with h264 stream | If issues occur recommend using vaapi preset instead |
|
||||
| preset-intel-qsv-h265 | Intel QSV with h265 stream | If issues occur recommend using vaapi preset instead |
|
||||
| preset-nvidia | Nvidia GPU | |
|
||||
| preset-jetson-h264 | Nvidia Jetson with h264 stream | |
|
||||
| preset-jetson-h265 | Nvidia Jetson with h265 stream | |
|
||||
| preset-rkmpp | Rockchip MPP | Use image with \*-rk suffix and 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 |
|
||||
Select the appropriate hwaccel preset for your hardware.
|
||||
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
@@ -53,25 +53,25 @@ cameras:
|
||||
|
||||
### Input Args Presets
|
||||
|
||||
Input arguments are passed to FFmpeg before your camera source and control how Frigate connects to and reads the stream — the transport protocol, timeouts, reconnection behavior, and how the stream is probed. The right input args ensure a reliable connection and maximum compatibility for each type of stream.
|
||||
Input args presets help make the config more readable and handle use cases for different types of streams to ensure maximum compatibility.
|
||||
|
||||
See [the camera-specific docs](/configuration/camera_specific.md) for more on non-standard cameras and recommendations for using them in Frigate.
|
||||
See [the camera specific docs](/configuration/camera_specific.md) for more info on non-standard cameras and recommendations for using them in Frigate.
|
||||
|
||||
| Preset (config) | UI Label | Usage | Notes |
|
||||
| -------------------------------- | ----------------------------------------- | --------------------------- | ------------------------------------------------------------------------------- |
|
||||
| preset-http-jpeg-generic | HTTP JPEG (Generic) | HTTP live JPEG | Restreaming the live JPEG is recommended instead |
|
||||
| preset-http-mjpeg-generic | HTTP MJPEG (Generic) | HTTP MJPEG stream | Restreaming the MJPEG stream is recommended instead |
|
||||
| preset-http-reolink | HTTP - Reolink Cameras | Reolink HTTP-FLV stream | Only for Reolink HTTP, not when restreaming as RTSP |
|
||||
| preset-rtmp-generic | RTMP (Generic) | RTMP stream | |
|
||||
| preset-rtsp-generic | RTSP (Generic) | RTSP stream | The default when no input args are specified |
|
||||
| preset-rtsp-restream | RTSP - Restream from go2rtc | RTSP stream from a restream | Use when a go2rtc restream is the source for Frigate |
|
||||
| preset-rtsp-restream-low-latency | RTSP - Restream from go2rtc (Low Latency) | RTSP stream from a restream | Lowers latency for a go2rtc restream source; may cause issues with some cameras |
|
||||
| preset-rtsp-udp | RTSP - UDP | RTSP stream over UDP | Use when the camera only supports UDP |
|
||||
| preset-rtsp-blue-iris | RTSP - Blue Iris | Blue Iris RTSP stream | Use when consuming a stream from Blue Iris |
|
||||
| Preset | Usage | Other Notes |
|
||||
| -------------------------------- | ------------------------- | ------------------------------------------------------------------------------------------------ |
|
||||
| preset-http-jpeg-generic | HTTP Live Jpeg | Recommend restreaming live jpeg instead |
|
||||
| preset-http-mjpeg-generic | HTTP Mjpeg Stream | Recommend restreaming mjpeg stream instead |
|
||||
| preset-http-reolink | Reolink HTTP-FLV Stream | Only for reolink http, not when restreaming as rtsp |
|
||||
| preset-rtmp-generic | RTMP Stream | |
|
||||
| preset-rtsp-generic | RTSP Stream | This is the default when nothing is specified |
|
||||
| preset-rtsp-restream | RTSP Stream from restream | Use for rtsp restream as source for frigate |
|
||||
| preset-rtsp-restream-low-latency | RTSP Stream from restream | Use for rtsp restream as source for frigate to lower latency, may cause issues with some cameras |
|
||||
| preset-rtsp-udp | RTSP Stream via UDP | Use when camera is UDP only |
|
||||
| preset-rtsp-blue-iris | Blue Iris RTSP Stream | Use when consuming a stream from Blue Iris |
|
||||
|
||||
:::warning
|
||||
|
||||
Be mindful of input arguments when restreaming, because you can end up with a mix of protocols. The `http` and `rtmp` presets cannot be used with `rtsp` streams. For example, using a Reolink camera with an RTSP restream as the recording source while `preset-http-reolink` is applied will cause a crash. In cases like this, set the preset at the stream level instead. See the example below.
|
||||
It is important to be mindful of input args when using restream because you can have a mix of protocols. `http` and `rtmp` presets cannot be used with `rtsp` streams. For example, when using a reolink cam with the rtsp restream as a source for record the preset-http-reolink will cause a crash. In this case presets will need to be set at the stream level. See the example below.
|
||||
|
||||
:::
|
||||
|
||||
@@ -96,13 +96,13 @@ cameras:
|
||||
|
||||
### Output Args Presets
|
||||
|
||||
Output arguments are passed to FFmpeg after your camera source and control how recordings are written — which codecs are used and whether audio and video are copied as-is or re-encoded. The right output args ensure consistent, playable recordings for each type of stream.
|
||||
Output args presets help make the config more readable and handle use cases for different types of streams to ensure consistent recordings.
|
||||
|
||||
| Preset (config) | UI Label | Usage | Notes |
|
||||
| -------------------------------- | ------------------------------- | ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| preset-record-generic | Record (Generic, no audio) | Record without audio | Use this if your camera has no audio, or if you don't want to record audio |
|
||||
| preset-record-generic-audio-copy | Record (Generic + Copy Audio) | Record with the original audio | Use this to keep the camera's audio in recordings without re-encoding |
|
||||
| preset-record-generic-audio-aac | Record (Generic + Audio to AAC) | Record with audio transcoded to AAC | The default when no output args are specified. Transcodes audio to AAC. If the source is already AAC, use `preset-record-generic-audio-copy` to avoid re-encoding |
|
||||
| preset-record-mjpeg | Record - MJPEG Cameras | Record an MJPEG stream | Restreaming the MJPEG stream is recommended instead |
|
||||
| preset-record-jpeg | Record - JPEG Cameras | Record a live JPEG | Restreaming the live JPEG is recommended instead |
|
||||
| preset-record-ubiquiti | Record - Ubiquiti Cameras | Record a Ubiquiti stream with audio | Handles Ubiquiti's non-standard audio format |
|
||||
| Preset | Usage | Other Notes |
|
||||
| -------------------------------- | --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| preset-record-generic | Record WITHOUT audio | If your camera doesn't have audio, or if you don't want to record audio, use this option |
|
||||
| preset-record-generic-audio-copy | Record WITH original audio | Use this to enable audio in recordings |
|
||||
| preset-record-generic-audio-aac | Record WITH transcoded aac audio | This is the default when no option is specified. Use it to transcode audio to AAC. If the source is already in AAC format, use preset-record-generic-audio-copy instead to avoid unnecessary re-encoding |
|
||||
| preset-record-mjpeg | Record an mjpeg stream | Recommend restreaming mjpeg stream instead |
|
||||
| preset-record-jpeg | Record live jpeg | Recommend restreaming live jpeg instead |
|
||||
| preset-record-ubiquiti | Record ubiquiti stream with audio | Recordings with ubiquiti non-standard audio |
|
||||
|
||||
@@ -27,12 +27,13 @@ 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:
|
||||
|
||||
| Model | Notes |
|
||||
| ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `qwen3-vl` | Strong visual and situational understanding, enhanced ability to identify smaller objects and interactions with object. |
|
||||
| `qwen3.5` | Strong situational understanding, but missing DeepStack from qwen3-vl leading to worse performance for identifying objects in people's hand and other small details. |
|
||||
| `qwen3.6` | Strong situational understanding, similar to qwen3-vl |
|
||||
| `gemma4` | Strong situational understanding, sometimes resorts to more vague terms like 'interacts' instead of assigning a specific action. |
|
||||
| Model | Notes |
|
||||
| ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `qwen3-vl` | Strong visual and situational understanding, enhanced ability to identify smaller objects and interactions with object. |
|
||||
| `qwen3.5` | 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. |
|
||||
| `Intern3.5VL` | Relatively fast with good vision comprehension |
|
||||
| `gemma3` | Slower model with good vision and temporal understanding |
|
||||
|
||||
:::info
|
||||
|
||||
@@ -293,7 +294,7 @@ Other HTTP options are available, see the [python-genai documentation](https://g
|
||||
|
||||
### OpenAI
|
||||
|
||||
OpenAI does not have a free tier for their API.
|
||||
OpenAI does not have a free tier for their API. With the release of gpt-4o, pricing has been reduced and each generation should cost fractions of a cent if you choose to go this route.
|
||||
|
||||
#### Supported Models
|
||||
|
||||
|
||||
@@ -671,7 +671,7 @@ lpr:
|
||||
3. Ensure your plates are being _detected_.
|
||||
|
||||
If you are using a Frigate+ or `license_plate` detecting model:
|
||||
- Watch the [Debug view](/usage/live#the-single-camera-view) to ensure that `license_plate` is being detected.
|
||||
- Watch the debug view (Settings --> Debug) to ensure that `license_plate` is being detected.
|
||||
- View MQTT messages for `frigate/events` to verify detected plates.
|
||||
- You may need to adjust your `min_score` and/or `threshold` for the `license_plate` object if your plates are not being detected.
|
||||
|
||||
|
||||
@@ -7,8 +7,6 @@ import ConfigTabs from "@site/src/components/ConfigTabs";
|
||||
import TabItem from "@theme/TabItem";
|
||||
import NavPath from "@site/src/components/NavPath";
|
||||
|
||||
Frigate has two kinds of masks: motion masks and object filter masks. Both are narrow tools for fine-tuning, **not for hiding an area from Frigate**. Masks should be used sparingly; in most cases where users reach for one, a [zone](zones.md) with `required_zones` is the right tool instead. See [Which tool do I need?](#which-tool-do-i-need) and [Common mistakes](#common-mistakes) below if you're new to Frigate's mask behavior.
|
||||
|
||||
## Motion masks
|
||||
|
||||
Motion masks are used to prevent unwanted types of motion from triggering detection. Try watching the Debug feed (Settings --> Debug) with `Motion Boxes` enabled to see what may be regularly detected as motion. For example, you want to mask out your timestamp, the sky, rooftops, etc. Keep in mind that this mask only prevents motion from being detected and does not prevent objects from being detected if object detection was started due to motion in unmasked areas. Motion is also used during object tracking to refine the object detection area in the next frame. _Over-masking will make it more difficult for objects to be tracked._
|
||||
@@ -23,16 +21,7 @@ Object filter masks can be used to filter out stubborn false positives in fixed
|
||||
|
||||

|
||||
|
||||
## Which tool do I need?
|
||||
|
||||
| What you're trying to do | Recommended tool | How it works |
|
||||
| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Don't get alerts or recordings for activity in an area (e.g., the sidewalk in front of your house) | A [zone](zones.md) combined with `review.alerts.required_zones` (and/or `review.detections.required_zones`) | Frigate keeps detecting and tracking activity in the area, but a review item is only created once the bottom-center of an object's bounding box enters a required zone. |
|
||||
| Stop a stubborn false positive at a specific fixed spot (e.g., a tree base that keeps being detected as a person) | An **object filter mask** for that object type | Any detection of that object type whose bounding-box bottom-center lands inside the mask is treated as a false positive and discarded. |
|
||||
| Ignore motion in an area that obviously isn't an object of interest (e.g., the camera timestamp, sky, flags, treetops swaying) | A **motion mask** | Motion inside the mask is ignored when deciding whether to run object detection. Objects can still be detected in a motion masked area if motion elsewhere in the frame triggers detection. |
|
||||
| Stop tracking an object type altogether on this camera (e.g., you never care about cats) | Remove the object from the camera's [`objects.track`](objects.md) list | Frigate skips this object type entirely on this camera, regardless of where it appears. |
|
||||
|
||||
## Using the mask creator
|
||||
## Creating masks
|
||||
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
@@ -135,14 +124,3 @@ This is what `required_zones` are for. You should define a zone (remember this i
|
||||
> Maybe my specific situation just warrants this. I've just been having a hard time understanding the relevance of this information - it seems to be that it's exactly what would be expected when "masking out" an area of ANY image.
|
||||
|
||||
That may be the case for you. Frigate will definitely work harder tracking people on the sidewalk to make sure it doesn't miss anyone who steps foot on your stoop. The trade off with the way you have it now is slower recognition of objects and potential misses. That may be acceptable based on your needs. Also, if your resolution is low enough on the detect stream, your regions may already be so big that they grab the entire object anyway.
|
||||
|
||||
## Common mistakes
|
||||
|
||||
**"I added a motion mask to ignore my driveway/sidewalk."**
|
||||
A motion mask doesn't hide an area from Frigate. Objects can still be detected and tracked inside a masked area. The mask only stops motion _in that area_ from triggering object detection. If you want activity on the sidewalk to never produce a review item, define a [zone](zones.md) over the area you DO care about (your stoop, your driveway) and add it to `review.alerts.required_zones`. Frigate will still see people on the sidewalk, but it won't create an alert until they cross into the zone.
|
||||
|
||||
**"I added an object filter mask because I don't care about cars in my yard."**
|
||||
Object filter masks are for stubborn false positives at fixed locations, not for filtering whole areas or whole object types. If you only want alerts when a car enters the driveway, use a [zone](zones.md) with `required_zones`. If you don't care about a whole object type on this camera, remove it from [`objects.track`](objects.md).
|
||||
|
||||
**"I masked everything except a thin strip on my stoop."**
|
||||
Heavy masking hurts tracking. Frigate uses motion near a tracked object's previous bounding box to decide where to look in the next frame; with most of the frame masked, an object walking from an unmasked area into a masked one effectively disappears and gets picked up as a "new" object when it reappears. For example: someone walks down your sidewalk, stops under a tree (masked area) to tie their shoe, then continues. Frigate sees that as two separate people and can create two separate review items. Because Frigate needs several consecutive frames above the confidence threshold to commit to a detection, each re-appearance can also delay or miss alerts. Use `required_zones` for "only alert me about this spot" and leave the surrounding area unmasked so tracking stays intact.
|
||||
|
||||
@@ -59,8 +59,6 @@ Metrics are available at `/api/metrics` by default. No additional Frigate config
|
||||
- `frigate_storage_used_bytes{storage=""}` - Storage used bytes
|
||||
- `frigate_storage_mount_type{mount_type="", storage=""}` - Storage mount type info
|
||||
|
||||
These gauges report the operating system's figures for the whole filesystem (the same numbers as `df`), not Frigate's own recording footprint. For how this differs from the recordings usage shown in the UI, see [Understanding storage usage](/configuration/record#understanding-storage-usage).
|
||||
|
||||
### Service Metrics
|
||||
|
||||
- `frigate_service_uptime_seconds` - Uptime in seconds
|
||||
|
||||
@@ -198,6 +198,46 @@ When the skip threshold is exceeded, **no motion is reported** for that frame, m
|
||||
|
||||
:::
|
||||
|
||||
## Using Camera-Side ONVIF Motion Detection
|
||||
|
||||
For cameras that publish their own ONVIF cell-motion analytics (e.g. OpenIPC firmware for HiSilicon, Ingenic and SigmaStar SoCs, plus most ONVIF Profile-M devices from Hikvision, Reolink, Foscam, Amcrest, etc.), Frigate can use the camera's hardware motion engine instead of running per-frame analysis on the host CPU. This both removes CPU load from the Frigate machine and gives a more accurate motion signal than encoded-stream analysis can produce.
|
||||
|
||||
Frigate consumes the two standard ONVIF transports:
|
||||
|
||||
- **PullPoint** event subscription on `tns1:RuleEngine/CellMotionDetector/Motion` carries the binary on/off state (the legacy `tns1:VideoSource/MotionAlarm` payload is also accepted).
|
||||
- **RTSP analytics metadata stream** (the `application/vnd.onvif.metadata` track on the primary RTSP profile) carries the per-frame cell grid (`tt:MotionInCells`) which Frigate decodes (base64 + PackBits) and maps through the `CellLayout` transformation into Frigate's detect-frame pixel coordinates.
|
||||
|
||||
```yaml
|
||||
cameras:
|
||||
back_door:
|
||||
onvif:
|
||||
host: 10.0.0.10
|
||||
port: 80
|
||||
user: root
|
||||
password: "secret"
|
||||
events:
|
||||
# Subscribe to camera-side motion events.
|
||||
enabled: true
|
||||
# Seconds before the PullPoint subscription expires (we renew at half this).
|
||||
subscription_timeout: 60
|
||||
# Open the RTSP analytics metadata stream for per-cell motion coordinates.
|
||||
# Disable if your camera only publishes the binary event topic.
|
||||
use_metadata_stream: true
|
||||
motion:
|
||||
# Use the camera's ONVIF events as Frigate's motion signal. The internal
|
||||
# CPU motion detector is skipped.
|
||||
source: onvif
|
||||
detect:
|
||||
enabled: true
|
||||
```
|
||||
|
||||
When `motion.source: onvif`:
|
||||
|
||||
- Frigate's internal `ImprovedMotionDetector` is **not** run on the camera's frames.
|
||||
- Object detection still runs every detection frame; motion boxes are used for region clustering exactly as with the internal detector.
|
||||
- If `use_metadata_stream: true` but the camera doesn't advertise the metadata track (or PackBits decoding fails for a frame), Frigate falls back to a full-frame motion box while the binary event signal is active.
|
||||
- The validator requires `onvif.events.enabled: true` whenever `motion.source: onvif`.
|
||||
|
||||
## Reviewing Detected Motion
|
||||
|
||||
To review what the detector picked up — or to search past recordings for motion in a specific region — see [Reviewing Motion](/usage/review#reviewing-motion) on the Review page.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -199,6 +199,10 @@ Because recording segments are written in 10 second chunks, pre-capture timing d
|
||||
|
||||
Pre and post capture footage is included in the **recording timeline**, visible in the History view. Note that pre/post capture settings only affect which recording segments are **retained on disk** — they do not change the start and end points shown in the UI. The History view will still center on the review item's actual time range, but you can scrub backward and forward through the retained pre/post capture footage on the timeline. The Explore view shows object-specific clips that are trimmed to when the tracked object was actually visible, so pre/post capture time will not be reflected there.
|
||||
|
||||
## Will Frigate delete old recordings if my storage runs out?
|
||||
|
||||
If there is less than an hour left of storage, the oldest hour of recordings will be deleted and a message will be printed in the Frigate logs. This emergency cleanup deletes the oldest recordings first regardless of retention settings to reclaim space as quickly as possible.
|
||||
|
||||
## Configuring Recording Retention
|
||||
|
||||
Frigate supports both continuous and tracked object based recordings with separate retention modes and retention periods.
|
||||
@@ -351,63 +355,3 @@ Setting `verbose: true` writes a detailed report of every orphaned file and data
|
||||
This operation uses considerable CPU resources and includes a safety threshold that aborts if more than 50% of files would be deleted. Only run when necessary. If you set `force: true` the safety threshold will be bypassed; do not use `force` unless you are certain the deletions are intended.
|
||||
|
||||
:::
|
||||
|
||||
## Understanding storage usage
|
||||
|
||||
The storage usage Frigate reports will not exactly match what the operating system reports with `df` or `du`. This is expected, not a bug. The sections below explain how Frigate derives its storage figures and why they differ from the disk's own accounting.
|
||||
|
||||
### How Frigate measures recording usage
|
||||
|
||||
The **Recordings** value on the Storage Metrics page (<NavPath path="System > Storage" />) — and the per-camera **Camera Storage** breakdown — is the sum of the recording segment sizes Frigate has written, taken from Frigate's database. It is **not** computed by a scan of the disk. Frigate tracks usage this way by design: repeatedly walking the entire drive to total its size would keep hard drives spun up and add unnecessary I/O.
|
||||
|
||||
The disk **total** shown beside it, and the free-space figure Frigate uses to decide when to delete recordings, instead come from the operating system's report for the whole filesystem mounted at `/media/frigate`. As a result, the **Unused** value on the page is _total disk capacity minus Frigate's recordings_ — not the drive's real free space, which will be lower whenever anything else is stored on the disk.
|
||||
|
||||
### What counts toward usage — and why it won't match `df`
|
||||
|
||||
Only **recording segments** (`/media/frigate/recordings`) are included in the recordings storage total. Plenty of other things consume real disk space but are **not** part of that number:
|
||||
|
||||
- **Snapshots and thumbnails** (`/media/frigate/clips`) — see [Snapshots](/configuration/snapshots). These are retained independently of recordings.
|
||||
- **Preview videos** and **review thumbnails** (also under `/media/frigate/clips`).
|
||||
- **Exports** (`/media/frigate/exports`) — exports are never removed by retention.
|
||||
- **The database, downloaded detection models, and face / license plate training images** (stored under `/config`).
|
||||
- **Debug images from enrichments** (`/media/frigate/clips`) — when enabled, License Plate Recognition's `debug_save_plates` and GenAI's `debug_save_thumbnails` save plate crops and request images for troubleshooting.
|
||||
|
||||
These files are the usual explanation for an "other" or seemingly unaccounted bucket of space — it is real, it is Frigate's, and it simply isn't part of the _recordings_ total. They are also why comparing the **Recordings** figure to `df -h` always shows a gap: `df` additionally counts any non-Frigate data on the disk, filesystem overhead and reserved blocks (ext4 reserves ~5% for root by default, so a disk can read "full" before recordings approach the total), and recently deleted recordings whose space has not yet been reclaimed.
|
||||
|
||||
:::tip
|
||||
|
||||
The Storage page is not intended to be a system-wide disk monitor — it shows how much space _Frigate's recordings_ use. To see true disk usage, use `df -h` (free space) and `du -sh` (per-directory usage) on the host.
|
||||
|
||||
:::
|
||||
|
||||
### Free space and the `/media/frigate` mount
|
||||
|
||||
Frigate reports the capacity and free space of whatever filesystem is actually mounted at `/media/frigate` **inside the container**. If an external drive or network share isn't truly mounted there — a missing `/etc/fstab` entry, a share that was offline when the container started, or a host that doesn't pass the path through — the container falls back to the host's OS disk, and Frigate will correctly report that smaller disk instead of the drive you intended.
|
||||
|
||||
If the reported capacity doesn't match your drive, the mount is the place to look, not Frigate. Verify what is actually mounted from inside the container:
|
||||
|
||||
```bash
|
||||
docker exec -it frigate df -h /media/frigate
|
||||
docker exec -it frigate mount | grep media
|
||||
```
|
||||
|
||||
See the [storage mount layout](/frigate/installation#storage) for how the volumes are expected to be configured.
|
||||
|
||||
### The `/tmp/cache` area is separate
|
||||
|
||||
Recording segments are first written to `/tmp/cache` — a small, in-memory (`tmpfs`) area — before being checked and moved to `/media/frigate/recordings`. Because it is separate and small, `/tmp/cache` can fill up and produce `No space left on device` errors even when the recordings disk has plenty of room — they are different storage areas. See [Recordings troubleshooting](/troubleshooting/recordings) for diagnosing cache and slow-storage issues.
|
||||
|
||||
### When the metrics don't match what's on disk
|
||||
|
||||
Because usage is tracked in the database, deleting recording files directly on disk — or files left behind after an upgrade — will not update the reported usage, and can even push it above 100%. Frigate is unaware of files it didn't record and won't count or remove them automatically. Use [Syncing Media Files With Disk](#syncing-media-files-with-disk) to reconcile the database with what is actually on disk.
|
||||
|
||||
## Will Frigate delete old recordings if my storage runs out?
|
||||
|
||||
Yes. Frigate continuously checks the **free space of the disk** holding `/media/frigate/recordings`. This is different from adding up the size of every recording: free space is a single number the operating system already tracks, so Frigate can ask for it instantly without reading through your files or spinning up the disk — which is exactly why it relies on this check rather than scanning the drive. When less than roughly one hour of recording space remains — estimated from the current recording bitrate, **not** a fixed percentage — Frigate deletes the oldest recordings to reclaim space and logs a message. This emergency cleanup removes the oldest recordings first **regardless of retention settings**.
|
||||
|
||||
Two consequences follow from this being based on whole-disk free space:
|
||||
|
||||
- Because the check uses the disk's real free space, **anything** filling the drive — including non-Frigate files — can trigger deletion of your oldest recordings.
|
||||
- Cleanup can run while a meaningful percentage of the disk is still free (for example, with high bitrates or many cameras), because the threshold is "less than ~1 hour of recording headroom," not "X% full."
|
||||
|
||||
Frequent emergency cleanups usually mean your configured retention exceeds what the disk can hold. Reduce your retention days so the normal retention cleanup keeps up and the emergency path rarely triggers.
|
||||
|
||||
@@ -68,26 +68,26 @@ Frigate supports multiple different detectors that work on different types of ha
|
||||
**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)
|
||||
- [Supports limited model architectures](../../configuration/object_detectors#rocm-supported-models)
|
||||
- Runs best on discrete AMD GPUs
|
||||
|
||||
**Apple Silicon**
|
||||
|
||||
- [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)
|
||||
- [Supports primarily ssdlite and mobilenet model architectures](../../configuration/object_detectors#apple-silicon-supported-models)
|
||||
- Runs well with any size models including large
|
||||
- Runs via ZMQ proxy which adds some latency, only recommended for local connection
|
||||
|
||||
**Intel**
|
||||
|
||||
- [OpenVino](#openvino---intel): OpenVino can run on Intel Arc GPUs, Intel integrated GPUs, and Intel NPUs to provide efficient object detection.
|
||||
- [Supports majority of model architectures](../../configuration/object_detectors#openvino-detector)
|
||||
- [Supports majority of model architectures](../../configuration/object_detectors#openvino-supported-models)
|
||||
- Runs best with tiny, small, or medium models
|
||||
|
||||
**Nvidia**
|
||||
|
||||
- [Nvidia GPU](#nvidia-gpus): Nvidia GPUs can provide efficient object detection.
|
||||
- [Supports majority of model architectures via ONNX](../../configuration/object_detectors#onnx)
|
||||
- [Supports majority of model architectures via ONNX](../../configuration/object_detectors#onnx-supported-models)
|
||||
- Runs well with any size models including large
|
||||
|
||||
- <CommunityBadge /> [Jetson](#nvidia-jetson): Jetson devices are supported via the TensorRT or ONNX detectors when running Jetpack 6.
|
||||
|
||||
@@ -42,8 +42,6 @@ Frigate requires a CPU with AVX + AVX2 instructions. Most modern CPUs (post-2011
|
||||
|
||||
Storage is an important consideration when planning a new installation. To get a more precise estimate of your storage requirements, you can use an IP camera storage calculator. Websites like [IPConfigure Storage Calculator](https://calculator.ipconfigure.com/) can help you determine the necessary disk space based on your camera settings.
|
||||
|
||||
Once running, see [Understanding storage usage](/configuration/record#understanding-storage-usage) for how Frigate measures and reports disk usage — and why its numbers won't exactly match `df` or `du`.
|
||||
|
||||
#### SSDs (Solid State Drives)
|
||||
|
||||
SSDs are an excellent choice for Frigate, offering high speed and responsiveness. The older concern that SSDs would quickly "wear out" from constant video recording is largely no longer valid for modern consumer and enterprise-grade SSDs.
|
||||
|
||||
@@ -49,9 +49,9 @@ This almost always means that the width/height defined for your camera are not c
|
||||
|
||||
These messages in the logs are expected in certain situations. Frigate checks the integrity of the recordings before storing. Occasionally these cached files will be invalid and cleaned up automatically.
|
||||
|
||||
### "MQTT connected" repeats in the logs
|
||||
### "On connect called"
|
||||
|
||||
If you see repeated "MQTT connected" messages in your logs, check for another instance of Frigate. This happens when multiple Frigate containers are trying to connect to MQTT with the same `client_id`.
|
||||
If you see repeated "On connect called" messages in your logs, check for another instance of Frigate. This happens when multiple Frigate containers are trying to connect to MQTT with the same `client_id`.
|
||||
|
||||
### Error: Database Is Locked
|
||||
|
||||
@@ -124,19 +124,3 @@ cameras:
|
||||
width: 1280
|
||||
height: 720
|
||||
```
|
||||
|
||||
### Why does Frigate keep creating new events for my parked car?
|
||||
|
||||
Stationary tracking is designed to _prevent_ this — a parked car should stay one tracked object and not generate new events. If you're getting repeated events for the same car, it's likely that Frigate is losing the tracked object and re-detecting it as a new one.
|
||||
|
||||
Open one of the events in Explore → **Tracking Details**. If the detection scores are low (< 70% or so), the model isn't confident the parked car is a car. This is common with the free [COCO-trained](https://cocodataset.org/#explore) object detection models on steep/top-down angles, partially occluded cars, foliage, or low-light footage. When detections fall below `min_score` for too many frames the tracker loses the object, and the next confident frame creates a brand new one.
|
||||
|
||||
What helps:
|
||||
|
||||
- **Improve the view** — even a small angle change that gets more of the car visible could lift scores enough to stabilize tracking.
|
||||
- **Use a more accurate model** — switching from `mobiledet` to `yolov9`, or stepping up to a larger variant like `yolov9-s` over `yolov9-t`, can help (at the cost of inference time, and still on the COCO dataset). The biggest gains usually come from fine-tuning a model on images from your own cameras so it learns your specific scene. [Frigate+](https://frigate.video/plus) is a paid option that does this - models are trained on security-camera footage and can be fine-tuned on images you submit from your own setup.
|
||||
- **Don't set `detect -> stationary -> max_frames` for `car`** — it artificially ends tracking and forces re-detection as a new object. See [Stationary Objects](../configuration/stationary_objects.md).
|
||||
- **Restrict alerts to the areas you care about** with `required_zones` — see [Zones](../configuration/zones.md#restricting-alerts-and-detections-to-specific-zones). Make sure those zones use the default `loitering_time: 0` unless you specifically want the review item to stay open until the car leaves.
|
||||
- **Filter impossible locations** with [object filter masks](../configuration/masks.md#object-filter-masks) if cars are being detected on rooftops, treetops, etc.
|
||||
|
||||
See [Object Filters](../configuration/object_filters.md) for more on tuning `min_score` and `threshold` — note that raising them too high will make this exact problem worse.
|
||||
|
||||
@@ -10,47 +10,4 @@ title: GPU Errors
|
||||
Some users have reported issues using some Intel iGPUs with OpenVINO, where the GPU would not be detected. This error can be caused by various problems, so it is important to ensure the configuration is setup correctly. Some solutions users have noted:
|
||||
|
||||
- In some cases users have noted that an HDMI dummy plug was necessary to be plugged into the motherboard's HDMI port.
|
||||
- When mixing an Intel iGPU with Nvidia GPU, the devices can be mixed up between `/dev/dri/renderD128` and `/dev/dri/renderD129` so it is important to confirm the correct device, or map the entire `/dev/dri` directory into the Frigate container.
|
||||
|
||||
## Intel/AMD GPU
|
||||
|
||||
### Hardware acceleration is not being used
|
||||
|
||||
For VAAPI or QSV to work, the GPU's render device must be passed through to the Frigate container. Intel and AMD GPUs expose this as a render node under `/dev/dri`, usually `/dev/dri/renderD128`. If it is not passed through, hardware acceleration is unavailable — ffmpeg fails to initialize it (for example `Failed to open the drm device` or `No VA display found for device`) and GPU usage stays at zero while CPU usage remains high.
|
||||
|
||||
Pass the render device through when starting the container. With `docker compose`:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
frigate:
|
||||
devices:
|
||||
- /dev/dri/renderD128:/dev/dri/renderD128 # Intel / AMD GPU, update for your hardware
|
||||
```
|
||||
|
||||
Or with `docker run`, add `--device /dev/dri/renderD128`. See the [installation docs](/frigate/installation) for a complete example.
|
||||
|
||||
If it still isn't working after passing the device through:
|
||||
|
||||
- **Confirm the render node exists and is the correct one.** Run `ls /dev/dri` on the host — you should see one or more `renderD12X` entries. Systems with more than one GPU (an Intel iGPU plus a discrete GPU) can expose both `/dev/dri/renderD128` and `/dev/dri/renderD129`, and the numbering is not guaranteed. Pass through the correct node, or map the entire directory (`/dev/dri:/dev/dri`, or `--device /dev/dri`) so all render nodes are available.
|
||||
- **Check device permissions.** The Frigate process must be able to access the render node. This is usually automatic when the container runs as root (the default), but nested setups such as an unprivileged Proxmox/LXC container often require making the device accessible on the host (for example, a world-readable render node) or running the container privileged. Note that running Frigate inside an LXC is not officially supported — see the [installation docs](/frigate/installation#proxmox) for details.
|
||||
|
||||
### Failed to download frame: -5
|
||||
|
||||
When using VAAPI or QSV hardware acceleration, ffmpeg may crash and restart periodically with a signature like this in the `ffmpeg.<camera>.detect` log:
|
||||
|
||||
```
|
||||
[AVHWFramesContext @ 0x...] Failed to sync surface ... (operation failed).
|
||||
[hwdownload @ 0x...] Failed to download frame: -5.
|
||||
[vf#0:0 @ 0x...] Error while filtering: Input/output error
|
||||
[vf#0:0 @ 0x...] Task finished with error code: -5 (Input/output error)
|
||||
[frigate.video] <camera>: Unable to read frames from ffmpeg process.
|
||||
```
|
||||
|
||||
This is a hardware frame synchronization failure between ffmpeg and the GPU driver, not a Frigate bug. It comes from how a specific camera stream interacts with the GPU's decode and scaling path, so it is highly dependent on your hardware, driver, and stream. Frigate's automatic hardware acceleration detection is a best-guess effort, so the fix is usually to tune the configuration for your specific hardware and camera. The solutions below are ordered from most to least likely to help:
|
||||
|
||||
- **Switch between the VAAPI and QSV presets.** On Intel Gen 12 and newer iGPUs, `preset-intel-qsv-h264` / `preset-intel-qsv-h265` is often more stable than the auto-detected `preset-vaapi`. See the [hardware acceleration docs](/configuration/hardware_acceleration_video.md#intel-based-cpus) for the recommended preset for your Intel generation.
|
||||
- **Try a different VAAPI driver.** The default driver is `iHD`. On older Intel CPUs, `LIBVA_DRIVER_NAME=i965` can be more stable; on AMD GPUs use `LIBVA_DRIVER_NAME=radeonsi`. See [the hardware acceleration docs](/configuration/hardware_acceleration_video.md#intel-based-cpus) for how to set the driver.
|
||||
- **Use a codec that decodes more reliably.** H.265/HEVC streams may trigger this error far more often than H.264 depending on your CPU generation. If your camera exposes a separate sub-stream, assign an H.264 stream to the `detect` role. Cameras that output full-range YUV (for example some Hikvision models) are especially prone to it.
|
||||
- **Match the detect resolution to the stream resolution.** When the `detect` resolution differs from the stream, Frigate inserts a GPU scaling filter (`scale_vaapi`), which is where these surface-sync failures can often originate. Set the `detect` `width` and `height` to match the exact resolution of the stream assigned the `detect` role.
|
||||
- **Match the detect `fps` to the camera stream.** Aggressively dropping frames (for example `detect` `fps: 1` on a stream that runs at 15 fps) can cause timing mismatches in the GPU's frame buffer. Lower the sub-stream's frame rate on the camera itself instead of dropping most frames in Frigate.
|
||||
- **Fall back to software decoding.** If none of the above resolve it, remove the preset for that camera (`hwaccel_args: []`). Hardware decoding is only an optimization — on a capable CPU, software-decoding a low-resolution sub-stream is inexpensive and gives a stable detect pipeline.
|
||||
- When mixing an Intel iGPU with Nvidia GPU, the devices can be mixed up between `/dev/dri/renderD128` and `/dev/dri/renderD129` so it is important to confirm the correct device, or map the entire `/dev/dri` directory into the Frigate container.
|
||||
@@ -9,12 +9,6 @@ import NavPath from "@site/src/components/NavPath";
|
||||
|
||||
This page describes how to _use_ the Explore view. For how the underlying features are _configured_, see [Semantic Search](/configuration/semantic_search) and [Generative AI descriptions](/configuration/genai/genai_objects).
|
||||
|
||||
:::tip
|
||||
|
||||
If you just want to quickly see what happened on your cameras, it's recommended to use [Review](/usage/review) rather than Explore. Review groups overlapping and adjacent activity on a camera into **review items** and sorts them into Alerts, Detections, and Motion, so you can scan and play back footage in a few clicks instead of sifting through individual objects. Reach for Explore when you need to find a _specific_ tracked object after the fact — by label, time, zone, or description.
|
||||
|
||||
:::
|
||||
|
||||
## Browsing tracked objects
|
||||
|
||||
The default view shows your most recent tracked objects grouped into rows by label — _Person_, _Car_, _Dog_, and so on — each row labeled with the object type and a count. The arrow at the end of a row opens the full, filterable grid for that label.
|
||||
|
||||
@@ -29,7 +29,7 @@ If you see **"No recordings found for this time"**, the most common causes are:
|
||||
|
||||
A toggle (a drawer on mobile) switches the side panel between three modes:
|
||||
|
||||
- **Timeline** — a scrubbable vertical timeline of the selected camera. Horizontal lines down the center represent motion, with longer lines indicating more motion at that moment. Review items are marked as shaded areas (**red** for alerts, **orange** for detections), and sections with no colored background are times when no recording exists.
|
||||
- **Timeline** — a scrubbable vertical timeline of the selected camera, annotated with a motion line, review-item markers, and gaps where no recording exists.
|
||||
- **Events** — a scrollable list of the camera's review items for the time range; clicking one seeks the player to it.
|
||||
- **Detail** — the [tracking details inspector](#the-detail-view) for the objects in view.
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ This page describes how to _use_ the Live view. For how to _configure_ live stre
|
||||
|
||||
The default **All Cameras** dashboard shows every camera, with a filmstrip of recent **alerts** scrolling across the top. Clicking an alert opens it in [Review](/usage/review); each card also has a check button to mark it reviewed without leaving the dashboard. Only **alerts** appear in the filmstrip — to suppress a label or zone from showing there, configure it as a detection instead (see [Alerts and Detections](/configuration/review#alerts-and-detections)).
|
||||
|
||||
By default Frigate uses **smart streaming**: a camera's image updates roughly once per minute while nothing is happening, and switches to a full live stream the moment activity is detected. This conserves bandwidth and resources. You can change this for each camera when using a camera group (see [Streaming settings](#streaming-settings-and-the-right-click-menu) below), and the behavior is explained in detail under [Live view technologies](/configuration/live#live-view-technologies).
|
||||
By default Frigate uses **smart streaming**: a camera's image updates roughly once per minute while nothing is happening, and switches to a full live stream the moment activity is detected. This conserves bandwidth and resources. You can change this per camera or per group (see [Streaming settings](#streaming-settings-and-the-right-click-menu) below), and the behavior is explained in detail under [Live view technologies](/configuration/live#live-view-technologies).
|
||||
|
||||
On mobile, a toggle in the header switches between a **grid** layout and a single-column **list** layout. On desktop a **fullscreen** button is available in the lower-right corner.
|
||||
|
||||
@@ -24,7 +24,7 @@ The icon rail (top-left on desktop, a horizontal strip on mobile) switches betwe
|
||||
- The **home** icon is the **All Cameras** dashboard, which shows every camera enabled for the dashboard.
|
||||
- Each **camera group** you create appears as its own icon. Selecting a group shows only that group's cameras.
|
||||
|
||||
Camera groups are useful for organizing cameras by location (for example, _Front of House_ or _Backyard_) and for giving each group its own dashboard layout and camera streaming preferences.
|
||||
Camera groups are useful for organizing cameras by location (for example, _Front of House_ or _Backyard_) and for giving each group its own dashboard layout and streaming preferences.
|
||||
|
||||
You can also view [Birdseye](/configuration/birdseye) on the dashboard, or open it directly at `http://<frigate_host>:5000/#birdseye`. Clicking a camera inside the Birdseye view jumps to that camera's live feed.
|
||||
|
||||
|
||||
@@ -29,7 +29,6 @@
|
||||
"docusaurus-plugin-openapi-docs": "^4.5.1",
|
||||
"docusaurus-theme-openapi-docs": "^4.5.1",
|
||||
"js-yaml": "^4.1.1",
|
||||
"marked": "^16.4.2",
|
||||
"prism-react-renderer": "^2.4.1",
|
||||
"raw-loader": "^4.0.2",
|
||||
"react": "^18.3.1",
|
||||
|
||||
@@ -1,171 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import CodeBlock from "@theme/CodeBlock";
|
||||
import ConfigTabs from "@site/src/components/ConfigTabs";
|
||||
import TabItem from "@theme/TabItem";
|
||||
import { marked } from "marked";
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
marked.setOptions({ gfm: true });
|
||||
|
||||
/**
|
||||
* @typedef {Object} Model
|
||||
* @property {string} key
|
||||
* @property {string} label
|
||||
* @property {boolean} recommended
|
||||
* @property {string} download Markdown for the "download the model" step.
|
||||
* @property {string} ui Markdown for the Frigate UI configuration step.
|
||||
* @property {string} yaml Raw YAML for the configuration step.
|
||||
*/
|
||||
|
||||
// Render a markdown string to React nodes. Fenced code blocks become Docusaurus
|
||||
// CodeBlock components (so they get syntax highlighting and a copy button);
|
||||
// everything else is marked-parsed to HTML.
|
||||
function renderBlocks(md, keyPrefix) {
|
||||
if (!md.trim()) return [];
|
||||
const tokens = marked.lexer(md);
|
||||
const nodes = [];
|
||||
let buffer = [];
|
||||
let idx = 0;
|
||||
|
||||
const flush = () => {
|
||||
if (buffer.length) {
|
||||
buffer.links = tokens.links;
|
||||
nodes.push(
|
||||
<div
|
||||
key={`${keyPrefix}-h${idx++}`}
|
||||
dangerouslySetInnerHTML={{ __html: marked.parser(buffer) }}
|
||||
/>,
|
||||
);
|
||||
buffer = [];
|
||||
}
|
||||
};
|
||||
|
||||
tokens.forEach((token) => {
|
||||
if (token.type === "code") {
|
||||
flush();
|
||||
const language = (token.lang || "text").split(/\s+/)[0];
|
||||
nodes.push(
|
||||
<CodeBlock key={`${keyPrefix}-c${idx++}`} language={language}>
|
||||
{token.text}
|
||||
</CodeBlock>,
|
||||
);
|
||||
} else {
|
||||
buffer.push(token);
|
||||
}
|
||||
});
|
||||
flush();
|
||||
return nodes;
|
||||
}
|
||||
|
||||
// marked does not understand Docusaurus admonitions (:::warning ... :::), so
|
||||
// render those blocks ourselves and render everything around them normally.
|
||||
function renderMarkdown(md) {
|
||||
if (!md) return null;
|
||||
const admonition = /:::(\w+)[ \t]*([^\n]*)\n([\s\S]*?)\n:::/g;
|
||||
const nodes = [];
|
||||
let lastIndex = 0;
|
||||
let match;
|
||||
let k = 0;
|
||||
while ((match = admonition.exec(md)) !== null) {
|
||||
nodes.push(...renderBlocks(md.slice(lastIndex, match.index), `seg${k}`));
|
||||
const [, type, title, body] = match;
|
||||
const heading = (title || type).trim();
|
||||
nodes.push(
|
||||
<div
|
||||
key={`adm${k}`}
|
||||
className={`${styles.admonition} ${styles[`admonition_${type}`] || ""}`}
|
||||
>
|
||||
<div className={styles.admonitionTitle}>{heading}</div>
|
||||
{renderBlocks(body, `adm${k}`)}
|
||||
</div>,
|
||||
);
|
||||
lastIndex = admonition.lastIndex;
|
||||
k++;
|
||||
}
|
||||
nodes.push(...renderBlocks(md.slice(lastIndex), `seg${k}`));
|
||||
return nodes;
|
||||
}
|
||||
|
||||
function Markdown({ children }) {
|
||||
return <div className={styles.markdown}>{renderMarkdown(children)}</div>;
|
||||
}
|
||||
|
||||
function RecommendedBadge() {
|
||||
return <span className={styles.recommendedBadge}>Recommended</span>;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ models: Model[] }} props
|
||||
*/
|
||||
export default function ModelConfigDropdown({ models }) {
|
||||
const [selectedModelIndex, setSelectedModelIndex] = useState(0);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const selectedModel = models[selectedModelIndex];
|
||||
const hasChoices = models.length > 1;
|
||||
|
||||
const handleModelSelect = (index) => {
|
||||
setSelectedModelIndex(index);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<div className={styles.panel}>
|
||||
<div className={styles.step}>
|
||||
<h4 className={styles.stepTitle}>Step 1 — Choose a model</h4>
|
||||
<div
|
||||
className={`${styles.dropdown} ${isOpen ? styles.open : ""} ${
|
||||
hasChoices ? "" : styles.static
|
||||
}`}
|
||||
onClick={hasChoices ? () => setIsOpen(!isOpen) : undefined}
|
||||
>
|
||||
<div className={styles.dropdownContent}>
|
||||
<span className={styles.modelName}>
|
||||
{selectedModel.label}
|
||||
{selectedModel.recommended && <RecommendedBadge />}
|
||||
</span>
|
||||
{hasChoices && (
|
||||
<span className={styles.arrow}>{isOpen ? "▲" : "▼"}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isOpen && hasChoices && (
|
||||
<div className={styles.menu}>
|
||||
{models.map((model, index) => (
|
||||
<div
|
||||
key={model.key}
|
||||
className={`${styles.menuItem} ${
|
||||
index === selectedModelIndex ? styles.menuItemActive : ""
|
||||
}`}
|
||||
onClick={() => handleModelSelect(index)}
|
||||
>
|
||||
{model.label}
|
||||
{model.recommended && <RecommendedBadge />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.step}>
|
||||
<h4 className={styles.stepTitle}>Step 2 — Download the model</h4>
|
||||
<Markdown>{selectedModel.download}</Markdown>
|
||||
</div>
|
||||
|
||||
<div className={styles.step}>
|
||||
<h4 className={styles.stepTitle}>Step 3 — Configure the detector</h4>
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
<Markdown>{selectedModel.ui}</Markdown>
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
<CodeBlock language="yaml">{selectedModel.yaml}</CodeBlock>
|
||||
</TabItem>
|
||||
</ConfigTabs>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,275 +0,0 @@
|
||||
/* ===================================================================
|
||||
ModelConfigDropdown — styles
|
||||
=================================================================== */
|
||||
|
||||
.wrapper {
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
/* --- Dropdown button --- */
|
||||
|
||||
.dropdown {
|
||||
display: inline-block;
|
||||
width: 360px;
|
||||
max-width: 100%;
|
||||
text-align: left;
|
||||
border: 1px solid var(--ifm-color-emphasis-400);
|
||||
border-radius: 8px;
|
||||
background: var(--ifm-background-color);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 0.2s,
|
||||
box-shadow 0.2s;
|
||||
}
|
||||
|
||||
[data-theme="light"] .dropdown {
|
||||
border: 1px solid #d0d7de;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .dropdown {
|
||||
border: 1px solid var(--ifm-color-emphasis-300);
|
||||
background: #21262d;
|
||||
}
|
||||
|
||||
.dropdown:hover {
|
||||
border-color: var(--ifm-color-primary);
|
||||
box-shadow: 0 0 0 3px var(--ifm-color-primary-lightest);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .dropdown:hover {
|
||||
box-shadow: 0 0 0 3px var(--ifm-color-primary-lightest);
|
||||
}
|
||||
|
||||
.dropdown.open {
|
||||
border-color: var(--ifm-color-primary);
|
||||
box-shadow: 0 0 0 3px var(--ifm-color-primary-lightest);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .dropdown.open {
|
||||
border-color: var(--ifm-color-primary);
|
||||
}
|
||||
|
||||
/* Single-model detectors render the label without a clickable menu. */
|
||||
.dropdown.static {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.dropdown.static:hover {
|
||||
border-color: var(--ifm-color-emphasis-400);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
[data-theme="light"] .dropdown.static:hover {
|
||||
border-color: #d0d7de;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .dropdown.static:hover {
|
||||
border-color: var(--ifm-color-emphasis-300);
|
||||
}
|
||||
|
||||
.dropdownContent {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.8rem 1rem;
|
||||
}
|
||||
|
||||
/* --- Model menu --- */
|
||||
|
||||
.menu {
|
||||
margin-top: 0.25rem;
|
||||
width: 360px;
|
||||
max-width: 100%;
|
||||
border: 1px solid var(--ifm-color-emphasis-400);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: var(--ifm-background-color);
|
||||
}
|
||||
|
||||
[data-theme="light"] .menu {
|
||||
border: 1px solid #d0d7de;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .menu {
|
||||
border: 1px solid var(--ifm-color-emphasis-300);
|
||||
background: #21262d;
|
||||
}
|
||||
|
||||
.menuItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.6rem 1rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
color: var(--ifm-font-color-base);
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.menuItem:not(:last-child) {
|
||||
border-bottom: 1px solid var(--ifm-color-emphasis-200);
|
||||
}
|
||||
|
||||
.menuItem:hover {
|
||||
background: var(--ifm-color-emphasis-100);
|
||||
}
|
||||
|
||||
.menuItemActive {
|
||||
font-weight: var(--ifm-font-weight-semibold);
|
||||
background: var(--ifm-color-primary-lightest);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .menuItem:hover {
|
||||
background: #2b3139;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .menuItemActive {
|
||||
background: #2b3139;
|
||||
}
|
||||
|
||||
.modelName {
|
||||
font-weight: var(--ifm-font-weight-semibold);
|
||||
color: var(--ifm-font-color-base);
|
||||
font-size: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.recommendedBadge {
|
||||
display: inline-block;
|
||||
background: var(--ifm-color-success);
|
||||
color: #fff;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
font-size: 0.7rem;
|
||||
color: var(--ifm-font-color-secondary);
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.dropdown.open .arrow {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* --- Panel --- */
|
||||
|
||||
.panel {
|
||||
margin-top: 0.5rem;
|
||||
border: 1px solid var(--ifm-color-emphasis-400);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: var(--ifm-background-color);
|
||||
}
|
||||
|
||||
[data-theme="light"] .panel {
|
||||
border: 1px solid #d0d7de;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .panel {
|
||||
border: 1px solid var(--ifm-color-emphasis-300);
|
||||
background: #21262d;
|
||||
}
|
||||
|
||||
/* --- Steps --- */
|
||||
|
||||
.step {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.step:not(:last-child) {
|
||||
border-bottom: 1px solid var(--ifm-color-emphasis-200);
|
||||
}
|
||||
|
||||
.stepTitle {
|
||||
margin: 0 0 0.75rem 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--ifm-font-color-base);
|
||||
}
|
||||
|
||||
/* Rendered markdown (download + Frigate UI instructions). */
|
||||
|
||||
.markdown {
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.markdown > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.markdown a {
|
||||
color: var(--ifm-color-primary);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.markdown table {
|
||||
display: table;
|
||||
width: 100%;
|
||||
margin: 0.75rem 0;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* Docusaurus-style admonitions rendered from markdown. */
|
||||
|
||||
.admonition {
|
||||
margin: 0.75rem 0;
|
||||
padding: 0.75rem 1rem;
|
||||
border-left: 4px solid var(--ifm-color-info);
|
||||
border-radius: 4px;
|
||||
background: var(--ifm-color-info-contrast-background);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.admonition > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.admonitionTitle {
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
font-size: 0.75rem;
|
||||
margin-bottom: 0.4rem;
|
||||
color: var(--ifm-color-info);
|
||||
}
|
||||
|
||||
.admonition_warning {
|
||||
border-left-color: var(--ifm-color-warning);
|
||||
background: var(--ifm-color-warning-contrast-background);
|
||||
}
|
||||
|
||||
.admonition_warning .admonitionTitle {
|
||||
color: var(--ifm-color-warning-dark);
|
||||
}
|
||||
|
||||
.admonition_danger {
|
||||
border-left-color: var(--ifm-color-danger);
|
||||
background: var(--ifm-color-danger-contrast-background);
|
||||
}
|
||||
|
||||
.admonition_danger .admonitionTitle {
|
||||
color: var(--ifm-color-danger-dark);
|
||||
}
|
||||
|
||||
.admonition_tip {
|
||||
border-left-color: var(--ifm-color-success);
|
||||
background: var(--ifm-color-success-contrast-background);
|
||||
}
|
||||
|
||||
.admonition_tip .admonitionTitle {
|
||||
color: var(--ifm-color-success-dark);
|
||||
}
|
||||
Vendored
-14
@@ -7393,13 +7393,6 @@ components:
|
||||
required:
|
||||
- value
|
||||
title: CameraSetBody
|
||||
ChaptersEnum:
|
||||
type: string
|
||||
enum:
|
||||
- none
|
||||
- recording_segments
|
||||
- review_items
|
||||
title: ChaptersEnum
|
||||
ChatCompletionRequest:
|
||||
properties:
|
||||
messages:
|
||||
@@ -8126,13 +8119,6 @@ components:
|
||||
- type: 'null'
|
||||
title: Export case ID
|
||||
description: ID of the export case to assign this export to
|
||||
chapters:
|
||||
anyOf:
|
||||
- $ref: '#/components/schemas/ChaptersEnum'
|
||||
- type: 'null'
|
||||
title: Chapter mode
|
||||
description: Optional chapter metadata to embed in the export. When
|
||||
omitted, the camera's configured export chapter mode is used.
|
||||
type: object
|
||||
title: ExportRecordingsBody
|
||||
ExportRecordingsCustomBody:
|
||||
|
||||
+8
-16
@@ -254,14 +254,7 @@ rateLimiter = RateLimiter()
|
||||
|
||||
|
||||
def get_remote_addr(request: Request):
|
||||
# fall back to the direct TCP peer when no proxy chain is present
|
||||
direct_addr = request.client.host if request.client else None
|
||||
|
||||
forwarded_for = request.headers.get("x-forwarded-for")
|
||||
if not forwarded_for:
|
||||
return direct_addr or "127.0.0.1"
|
||||
|
||||
route = list(reversed(forwarded_for.split(",")))
|
||||
route = list(reversed(request.headers.get("x-forwarded-for").split(",")))
|
||||
logger.debug(f"IP Route: {[r for r in route]}")
|
||||
trusted_proxies = []
|
||||
for proxy in request.app.frigate_config.auth.trusted_proxies:
|
||||
@@ -298,8 +291,13 @@ def get_remote_addr(request: Request):
|
||||
logger.debug(f"First untrusted IP: {str(ip)}")
|
||||
return str(ip)
|
||||
|
||||
# every hop in the route was trusted, so fall back to the direct peer
|
||||
return direct_addr or "127.0.0.1"
|
||||
# if there wasn't anything in the route, just return the default
|
||||
remote_addr = None
|
||||
|
||||
if hasattr(request, "remote_addr"):
|
||||
remote_addr = request.remote_addr
|
||||
|
||||
return remote_addr or "127.0.0.1"
|
||||
|
||||
|
||||
def _cleanup_first_load_seen() -> None:
|
||||
@@ -418,12 +416,6 @@ def create_encoded_jwt(user, role, expiration, secret):
|
||||
|
||||
def set_jwt_cookie(response: Response, cookie_name, encoded_jwt, expiration, secure):
|
||||
# TODO: ideally this would set secure as well, but that requires TLS
|
||||
# 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,
|
||||
|
||||
@@ -147,19 +147,6 @@ def go2rtc_camera_stream(request: Request, stream_name: str):
|
||||
)
|
||||
def go2rtc_add_stream(request: Request, stream_name: str, src: str = ""):
|
||||
"""Add or update a go2rtc stream configuration."""
|
||||
if src and is_restricted_go2rtc_source(src):
|
||||
logger.warning(
|
||||
"Rejected go2rtc stream '%s' with restricted source type (echo/expr/exec)",
|
||||
stream_name,
|
||||
)
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
"message": "Restricted stream source type",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
try:
|
||||
params = {"name": stream_name}
|
||||
if src:
|
||||
|
||||
@@ -3,10 +3,7 @@ from typing import Optional, Union
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic.json_schema import SkipJsonSchema
|
||||
|
||||
from frigate.record.export import (
|
||||
ChaptersEnum,
|
||||
PlaybackSourceEnum,
|
||||
)
|
||||
from frigate.record.export import PlaybackSourceEnum
|
||||
|
||||
|
||||
class ExportRecordingsBody(BaseModel):
|
||||
@@ -21,14 +18,6 @@ class ExportRecordingsBody(BaseModel):
|
||||
max_length=30,
|
||||
description="ID of the export case to assign this export to",
|
||||
)
|
||||
chapters: Optional[ChaptersEnum] = Field(
|
||||
default=None,
|
||||
title="Chapter mode",
|
||||
description=(
|
||||
"Optional chapter metadata to embed in the export. When omitted, "
|
||||
"the camera's configured export chapter mode is used."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class ExportRecordingsCustomBody(BaseModel):
|
||||
|
||||
@@ -68,7 +68,6 @@ from frigate.jobs.export import (
|
||||
from frigate.models import Export, ExportCase, Previews, Recordings
|
||||
from frigate.record.export import (
|
||||
DEFAULT_TIME_LAPSE_FFMPEG_ARGS,
|
||||
ChaptersEnum,
|
||||
PlaybackSourceEnum,
|
||||
validate_ffmpeg_args,
|
||||
)
|
||||
@@ -129,15 +128,6 @@ def _validate_export_case(export_case_id: Optional[str]) -> Optional[JSONRespons
|
||||
def _sanitize_existing_image(
|
||||
image_path: Optional[str],
|
||||
) -> tuple[Optional[str], Optional[JSONResponse]]:
|
||||
# sanitize_filepath normalizes "\" to "/" but leaves ".." intact, so a path
|
||||
# like "clips\..\..\etc/passwd" passes the CLIPS_DIR prefix check yet still
|
||||
# escapes the directory once resolved. A valid snapshot path never uses "..".
|
||||
if image_path and ".." in image_path:
|
||||
return None, JSONResponse(
|
||||
content={"success": False, "message": "Invalid image path"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
existing_image = sanitize_filepath(image_path) if image_path else None
|
||||
|
||||
if existing_image and not existing_image.startswith(CLIPS_DIR):
|
||||
@@ -264,7 +254,6 @@ def _build_export_job(
|
||||
ffmpeg_input_args: Optional[str] = None,
|
||||
ffmpeg_output_args: Optional[str] = None,
|
||||
cpu_fallback: bool = False,
|
||||
chapters: Optional[ChaptersEnum] = None,
|
||||
) -> ExportJob:
|
||||
return ExportJob(
|
||||
id=_generate_export_id(camera_name),
|
||||
@@ -278,7 +267,6 @@ def _build_export_job(
|
||||
ffmpeg_input_args=ffmpeg_input_args,
|
||||
ffmpeg_output_args=ffmpeg_output_args,
|
||||
cpu_fallback=cpu_fallback,
|
||||
chapters=chapters,
|
||||
)
|
||||
|
||||
|
||||
@@ -737,9 +725,6 @@ def export_recordings_batch(
|
||||
sanitized_images[index],
|
||||
PlaybackSourceEnum.recordings,
|
||||
export_case_id,
|
||||
chapters=request.app.frigate_config.cameras[
|
||||
item.camera
|
||||
].record.export.chapters,
|
||||
)
|
||||
try:
|
||||
start_export_job(request.app.frigate_config, export_job)
|
||||
@@ -818,14 +803,6 @@ def export_recording(
|
||||
|
||||
export_case_id = body.export_case_id
|
||||
|
||||
# a chapters value in the request body overrides the camera's export config
|
||||
camera_config = request.app.frigate_config.cameras[camera_name]
|
||||
chapters = (
|
||||
body.chapters
|
||||
if body.chapters is not None
|
||||
else camera_config.record.export.chapters
|
||||
)
|
||||
|
||||
# Attaching to an existing case requires admin. Single-export for
|
||||
# cameras the user can access is otherwise non-admin; we only gate
|
||||
# the case-attachment side effect.
|
||||
@@ -862,7 +839,6 @@ def export_recording(
|
||||
existing_image,
|
||||
playback_source,
|
||||
export_case_id,
|
||||
chapters=chapters,
|
||||
)
|
||||
try:
|
||||
start_export_job(request.app.frigate_config, export_job)
|
||||
|
||||
+13
-32
@@ -60,19 +60,6 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=[Tags.media])
|
||||
|
||||
|
||||
def _resolve_cache_age(max_cache_age: int) -> int:
|
||||
"""Return max_cache_age as an int.
|
||||
|
||||
When a media handler is invoked directly by another handler instead of
|
||||
through its route, FastAPI doesn't resolve the Query() default and
|
||||
max_cache_age arrives as the Query object; fall back to its int default.
|
||||
"""
|
||||
if isinstance(max_cache_age, int):
|
||||
return max_cache_age
|
||||
|
||||
return max_cache_age.default
|
||||
|
||||
|
||||
@router.get("/{camera_name}", dependencies=[Depends(require_camera_access)])
|
||||
async def mjpeg_feed(
|
||||
request: Request,
|
||||
@@ -426,9 +413,7 @@ async def submit_recording_snapshot_to_plus(
|
||||
)
|
||||
|
||||
nd = cv2.imdecode(np.frombuffer(image_data, dtype=np.int8), cv2.IMREAD_COLOR)
|
||||
await asyncio.to_thread(
|
||||
request.app.frigate_config.plus_api.upload_image, nd, camera_name
|
||||
)
|
||||
request.app.frigate_config.plus_api.upload_image(nd, camera_name)
|
||||
|
||||
return JSONResponse(
|
||||
content={
|
||||
@@ -951,7 +936,7 @@ async def event_thumbnail(
|
||||
thumbnail_bytes,
|
||||
media_type=extension.get_mime_type(),
|
||||
headers={
|
||||
"Cache-Control": f"private, max-age={_resolve_cache_age(max_cache_age)}"
|
||||
"Cache-Control": f"private, max-age={max_cache_age}"
|
||||
if event_complete
|
||||
else "no-store",
|
||||
},
|
||||
@@ -1285,14 +1270,14 @@ async def event_preview(request: Request, event_id: str):
|
||||
end_ts = start_ts + (
|
||||
min(event.end_time - event.start_time, 20) if event.end_time else 20
|
||||
)
|
||||
return await preview_gif(request, event.camera, start_ts, end_ts)
|
||||
return preview_gif(request, event.camera, start_ts, end_ts)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{camera_name}/start/{start_ts}/end/{end_ts}/preview.gif",
|
||||
dependencies=[Depends(require_camera_access)],
|
||||
)
|
||||
async def preview_gif(
|
||||
def preview_gif(
|
||||
request: Request,
|
||||
camera_name: str,
|
||||
start_ts: float,
|
||||
@@ -1355,8 +1340,7 @@ async def preview_gif(
|
||||
"-",
|
||||
]
|
||||
|
||||
process = await asyncio.to_thread(
|
||||
sp.run,
|
||||
process = sp.run(
|
||||
ffmpeg_cmd,
|
||||
capture_output=True,
|
||||
)
|
||||
@@ -1435,8 +1419,7 @@ async def preview_gif(
|
||||
"-",
|
||||
]
|
||||
|
||||
process = await asyncio.to_thread(
|
||||
sp.run,
|
||||
process = sp.run(
|
||||
ffmpeg_cmd,
|
||||
input=str.encode("\n".join(selected_previews)),
|
||||
capture_output=True,
|
||||
@@ -1455,7 +1438,7 @@ async def preview_gif(
|
||||
gif_bytes,
|
||||
media_type="image/gif",
|
||||
headers={
|
||||
"Cache-Control": f"private, max-age={_resolve_cache_age(max_cache_age)}",
|
||||
"Cache-Control": f"private, max-age={max_cache_age}",
|
||||
"Content-Type": "image/gif",
|
||||
},
|
||||
)
|
||||
@@ -1465,7 +1448,7 @@ async def preview_gif(
|
||||
"/{camera_name}/start/{start_ts}/end/{end_ts}/preview.mp4",
|
||||
dependencies=[Depends(require_camera_access)],
|
||||
)
|
||||
async def preview_mp4(
|
||||
def preview_mp4(
|
||||
request: Request,
|
||||
camera_name: str,
|
||||
start_ts: float,
|
||||
@@ -1545,8 +1528,7 @@ async def preview_mp4(
|
||||
path,
|
||||
]
|
||||
|
||||
process = await asyncio.to_thread(
|
||||
sp.run,
|
||||
process = sp.run(
|
||||
ffmpeg_cmd,
|
||||
capture_output=True,
|
||||
)
|
||||
@@ -1622,8 +1604,7 @@ async def preview_mp4(
|
||||
path,
|
||||
]
|
||||
|
||||
process = await asyncio.to_thread(
|
||||
sp.run,
|
||||
process = sp.run(
|
||||
ffmpeg_cmd,
|
||||
input=str.encode("\n".join(selected_previews)),
|
||||
capture_output=True,
|
||||
@@ -1638,7 +1619,7 @@ async def preview_mp4(
|
||||
|
||||
headers = {
|
||||
"Content-Description": "File Transfer",
|
||||
"Cache-Control": f"private, max-age={_resolve_cache_age(max_cache_age)}",
|
||||
"Cache-Control": f"private, max-age={max_cache_age}",
|
||||
"Content-Type": "video/mp4",
|
||||
"Content-Length": str(os.path.getsize(path)),
|
||||
# nginx: https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_ignore_headers
|
||||
@@ -1676,9 +1657,9 @@ async def review_preview(
|
||||
)
|
||||
|
||||
if format == "gif":
|
||||
return await preview_gif(request, review.camera, start_ts, end_ts)
|
||||
return preview_gif(request, review.camera, start_ts, end_ts)
|
||||
else:
|
||||
return await preview_mp4(request, review.camera, start_ts, end_ts)
|
||||
return preview_mp4(request, review.camera, start_ts, end_ts)
|
||||
|
||||
|
||||
@router.get(
|
||||
|
||||
+11
-9
@@ -269,12 +269,12 @@ async def no_recordings(
|
||||
cameras = params.cameras
|
||||
if cameras != "all":
|
||||
requested = set(unquote(cameras).split(","))
|
||||
camera_list = list(requested.intersection(allowed_cameras))
|
||||
filtered = requested.intersection(allowed_cameras)
|
||||
if not filtered:
|
||||
return JSONResponse(content=[])
|
||||
cameras = ",".join(filtered)
|
||||
else:
|
||||
camera_list = list(allowed_cameras)
|
||||
|
||||
if not camera_list:
|
||||
return JSONResponse(content=[])
|
||||
cameras = allowed_cameras
|
||||
|
||||
before = params.before or datetime.datetime.now().timestamp()
|
||||
after = (
|
||||
@@ -283,10 +283,12 @@ async def no_recordings(
|
||||
)
|
||||
scale = params.scale
|
||||
|
||||
clauses = [
|
||||
(Recordings.end_time >= after) & (Recordings.start_time <= before),
|
||||
(Recordings.camera << camera_list),
|
||||
]
|
||||
clauses = [(Recordings.end_time >= after) & (Recordings.start_time <= before)]
|
||||
if cameras != "all":
|
||||
camera_list = cameras.split(",")
|
||||
clauses.append((Recordings.camera << camera_list))
|
||||
else:
|
||||
camera_list = allowed_cameras
|
||||
|
||||
# Get recording start times
|
||||
data: list[Recordings] = (
|
||||
|
||||
+3
-1
@@ -309,7 +309,9 @@ class FrigateApp:
|
||||
self.detection_proxy = DetectorProxy()
|
||||
|
||||
def init_onvif(self) -> None:
|
||||
self.onvif_controller = OnvifController(self.config, self.ptz_metrics)
|
||||
self.onvif_controller = OnvifController(
|
||||
self.config, self.ptz_metrics, self.camera_metrics
|
||||
)
|
||||
|
||||
def init_dispatcher(self) -> None:
|
||||
comms: list[Communicator] = []
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import multiprocessing as mp
|
||||
import queue
|
||||
from multiprocessing.managers import SyncManager, ValueProxy
|
||||
from multiprocessing.managers import ListProxy, SyncManager, ValueProxy
|
||||
from multiprocessing.sharedctypes import Synchronized
|
||||
from multiprocessing.synchronize import Event
|
||||
|
||||
@@ -23,6 +23,14 @@ class CameraMetrics:
|
||||
reconnects_last_hour: ValueProxy[int]
|
||||
stalls_last_hour: ValueProxy[int]
|
||||
|
||||
# External motion published by OnvifController when motion.source=onvif.
|
||||
# external_motion_active mirrors the PullPoint IsMotion state.
|
||||
# external_motion_boxes carries the per-frame cell-derived rectangles in
|
||||
# detect-frame pixel coordinates; empty list means no current spatial
|
||||
# data (consumer should fall back to a full-frame box when active=1).
|
||||
external_motion_active: ValueProxy[int]
|
||||
external_motion_boxes: ListProxy
|
||||
|
||||
def __init__(self, manager: SyncManager):
|
||||
self.camera_fps = manager.Value("d", 0)
|
||||
self.detection_fps = manager.Value("d", 0)
|
||||
@@ -41,6 +49,9 @@ class CameraMetrics:
|
||||
self.reconnects_last_hour = manager.Value("i", 0)
|
||||
self.stalls_last_hour = manager.Value("i", 0)
|
||||
|
||||
self.external_motion_active = manager.Value("b", 0)
|
||||
self.external_motion_boxes = manager.list()
|
||||
|
||||
|
||||
class PTZMetrics:
|
||||
autotracker_enabled: Synchronized
|
||||
|
||||
@@ -13,10 +13,6 @@ from frigate.comms.event_metadata_updater import (
|
||||
EventMetadataTypeEnum,
|
||||
)
|
||||
from frigate.config import CameraConfig, FrigateConfig
|
||||
from frigate.config.camera.updater import (
|
||||
CameraConfigUpdateEnum,
|
||||
CameraConfigUpdateSubscriber,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -33,11 +29,6 @@ class CameraActivityManager:
|
||||
self.zone_all_object_counts: dict[str, Counter] = {}
|
||||
self.zone_active_object_counts: dict[str, Counter] = {}
|
||||
self.all_zone_labels: dict[str, set[str]] = {}
|
||||
self.config_subscriber = CameraConfigUpdateSubscriber(
|
||||
config,
|
||||
config.cameras,
|
||||
[CameraConfigUpdateEnum.zones, CameraConfigUpdateEnum.objects],
|
||||
)
|
||||
|
||||
for camera_config in config.cameras.values():
|
||||
if not camera_config.enabled_in_config:
|
||||
@@ -65,40 +56,7 @@ class CameraActivityManager:
|
||||
else camera_config.objects.track
|
||||
)
|
||||
|
||||
def __rebuild_zone_labels(self) -> None:
|
||||
"""Rebuild zone label tracking after a runtime zones/objects change."""
|
||||
new_zone_labels: dict[str, set[str]] = {}
|
||||
|
||||
for camera_config in self.config.cameras.values():
|
||||
if not camera_config.enabled_in_config or camera_config.name is None:
|
||||
continue
|
||||
|
||||
for zone, zone_config in camera_config.zones.items():
|
||||
new_zone_labels.setdefault(zone, set()).update(
|
||||
zone_config.objects
|
||||
if zone_config.objects
|
||||
else camera_config.objects.track
|
||||
)
|
||||
|
||||
# drop counters for zones that no longer exist
|
||||
for zone in list(self.zone_all_object_counts.keys()):
|
||||
if zone not in new_zone_labels:
|
||||
self.zone_all_object_counts.pop(zone, None)
|
||||
self.zone_active_object_counts.pop(zone, None)
|
||||
|
||||
# ensure counters exist for new zones so the first count is published
|
||||
for zone in new_zone_labels:
|
||||
self.zone_all_object_counts.setdefault(zone, Counter())
|
||||
self.zone_active_object_counts.setdefault(zone, Counter())
|
||||
|
||||
self.all_zone_labels = new_zone_labels
|
||||
|
||||
def update_activity(self, new_activity: dict[str, dict[str, Any]]) -> None:
|
||||
updated_topics = self.config_subscriber.check_for_updates()
|
||||
|
||||
if "zones" in updated_topics or "objects" in updated_topics:
|
||||
self.__rebuild_zone_labels()
|
||||
|
||||
all_objects: list[dict[str, Any]] = []
|
||||
|
||||
for camera in new_activity.keys():
|
||||
@@ -203,9 +161,6 @@ class CameraActivityManager:
|
||||
self.publish(f"{camera}/all", sum(list(all_objects.values())))
|
||||
self.publish(f"{camera}/all/active", sum(list(active_objects.values())))
|
||||
|
||||
def stop(self) -> None:
|
||||
self.config_subscriber.stop()
|
||||
|
||||
|
||||
class AudioActivityManager:
|
||||
def __init__(
|
||||
|
||||
@@ -397,8 +397,6 @@ class Dispatcher:
|
||||
comm.publish(topic, payload, retain)
|
||||
|
||||
def stop(self) -> None:
|
||||
self.camera_activity.stop()
|
||||
|
||||
for comm in self.comms:
|
||||
comm.stop()
|
||||
|
||||
|
||||
+2
-21
@@ -41,18 +41,6 @@ class MqttClient(Communicator):
|
||||
self.publish("available", "stopped", retain=True)
|
||||
self.client.disconnect()
|
||||
|
||||
def _notifications_enabled_in_config(self) -> bool:
|
||||
"""Whether notifications are configured globally or on any camera.
|
||||
|
||||
Notifications can be enabled per camera with the global config left
|
||||
disabled, so the global topics must consider both (matching how
|
||||
app.py decides to create the WebPushClient).
|
||||
"""
|
||||
return self.config.notifications.enabled_in_config or any(
|
||||
cam.enabled and cam.notifications.enabled_in_config
|
||||
for cam in self.config.cameras.values()
|
||||
)
|
||||
|
||||
def _set_initial_topics(self) -> None:
|
||||
"""Set initial state topics."""
|
||||
for camera_name, camera in self.config.cameras.items():
|
||||
@@ -169,7 +157,7 @@ class MqttClient(Communicator):
|
||||
retain=True,
|
||||
)
|
||||
|
||||
if self._notifications_enabled_in_config():
|
||||
if self.config.notifications.enabled_in_config:
|
||||
self.publish(
|
||||
"notifications/state",
|
||||
"ON" if self.config.notifications.enabled else "OFF",
|
||||
@@ -268,7 +256,6 @@ class MqttClient(Communicator):
|
||||
"review_detections",
|
||||
"object_descriptions",
|
||||
"review_descriptions",
|
||||
"notifications",
|
||||
]
|
||||
|
||||
for name in self.config.cameras.keys():
|
||||
@@ -278,12 +265,6 @@ class MqttClient(Communicator):
|
||||
self.on_mqtt_command,
|
||||
)
|
||||
|
||||
# notifications suspend doesn't follow the /set topic pattern
|
||||
self.client.message_callback_add(
|
||||
f"{self.mqtt_config.topic_prefix}/{name}/notifications/suspend",
|
||||
self.on_mqtt_command,
|
||||
)
|
||||
|
||||
if self.config.cameras[name].onvif.host:
|
||||
self.client.message_callback_add(
|
||||
f"{self.mqtt_config.topic_prefix}/{name}/ptz",
|
||||
@@ -308,7 +289,7 @@ class MqttClient(Communicator):
|
||||
self.on_mqtt_command,
|
||||
)
|
||||
|
||||
if self._notifications_enabled_in_config():
|
||||
if self.config.notifications.enabled_in_config:
|
||||
self.client.message_callback_add(
|
||||
f"{self.mqtt_config.topic_prefix}/notifications/set",
|
||||
self.on_mqtt_command,
|
||||
|
||||
+7
-39
@@ -72,16 +72,11 @@ _WS_VIEWER_TOPICS = frozenset(
|
||||
}
|
||||
)
|
||||
|
||||
# Camera-scoped command topics a camera-authorized (non-admin) user may send.
|
||||
_WS_CAMERA_COMMAND_TOPICS = frozenset({"ptz"})
|
||||
|
||||
|
||||
def _check_ws_authorization(
|
||||
topic: str,
|
||||
role_header: str | None,
|
||||
separator: str,
|
||||
roles_config: dict[str, list[str]] | None = None,
|
||||
camera_names: set[str] | None = None,
|
||||
) -> bool:
|
||||
"""Check if a WebSocket message is authorized.
|
||||
|
||||
@@ -89,10 +84,6 @@ def _check_ws_authorization(
|
||||
topic: The message topic.
|
||||
role_header: The HTTP_REMOTE_ROLE header value, or None.
|
||||
separator: The role separator character from proxy config.
|
||||
roles_config: The auth.roles mapping (role -> allowed cameras), used to
|
||||
authorize camera-scoped commands for non-admin users.
|
||||
camera_names: All configured camera names, used to resolve a role's
|
||||
allowed cameras.
|
||||
|
||||
Returns:
|
||||
True if authorized, False if blocked.
|
||||
@@ -102,33 +93,16 @@ def _check_ws_authorization(
|
||||
return False
|
||||
|
||||
# No role header: default to viewer (fail-closed)
|
||||
roles = [r.strip() for r in role_header.split(separator)] if role_header else []
|
||||
if role_header is None:
|
||||
return topic in _WS_VIEWER_TOPICS
|
||||
|
||||
# Admin can send anything
|
||||
# Check if any role is admin
|
||||
roles = [r.strip() for r in role_header.split(separator)]
|
||||
if "admin" in roles:
|
||||
return True
|
||||
|
||||
# Read-only topics any authenticated user can send
|
||||
if topic in _WS_VIEWER_TOPICS:
|
||||
return True
|
||||
|
||||
# Camera-scoped command like "<camera>/ptz": allow when the user's role(s)
|
||||
# grant access to that camera.
|
||||
parts = topic.split("/")
|
||||
if (
|
||||
roles_config is not None
|
||||
and len(parts) == 2
|
||||
and parts[1] in _WS_CAMERA_COMMAND_TOPICS
|
||||
):
|
||||
allowed: set[str] = set()
|
||||
# No role header maps to the default viewer role (e.g. proxy-only setups)
|
||||
for role in roles or ["viewer"]:
|
||||
allowed.update(
|
||||
User.get_allowed_cameras(role, roles_config, camera_names or set())
|
||||
)
|
||||
return parts[0] in allowed
|
||||
|
||||
return False
|
||||
# Non-admin: only viewer topics allowed
|
||||
return topic in _WS_VIEWER_TOPICS
|
||||
|
||||
|
||||
# ---- Outbound filtering ---------------------------------------------------
|
||||
@@ -475,8 +449,6 @@ class WebSocketClient(Communicator):
|
||||
class _WebSocketHandler(WebSocket):
|
||||
receiver = self._dispatcher
|
||||
role_separator = self.config.proxy.separator or ","
|
||||
roles_config = self.config.auth.roles
|
||||
camera_names = set(self.config.cameras.keys())
|
||||
|
||||
def received_message(self, message: WebSocket.received_message) -> None: # type: ignore[name-defined]
|
||||
try:
|
||||
@@ -498,11 +470,7 @@ class WebSocketClient(Communicator):
|
||||
self.environ.get("HTTP_REMOTE_ROLE") if self.environ else None
|
||||
)
|
||||
if self.environ is not None and not _check_ws_authorization(
|
||||
topic,
|
||||
role_header,
|
||||
self.role_separator,
|
||||
self.roles_config,
|
||||
self.camera_names,
|
||||
topic, role_header, self.role_separator
|
||||
):
|
||||
logger.warning(
|
||||
"Blocked unauthorized WebSocket message: topic=%s, role=%s",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from enum import Enum
|
||||
from typing import Any, Optional
|
||||
|
||||
from pydantic import Field, field_serializer
|
||||
@@ -5,7 +6,12 @@ from pydantic import Field, field_serializer
|
||||
from ..base import FrigateBaseModel
|
||||
from .mask import MotionMaskConfig
|
||||
|
||||
__all__ = ["MotionConfig"]
|
||||
__all__ = ["MotionConfig", "MotionSourceEnum"]
|
||||
|
||||
|
||||
class MotionSourceEnum(str, Enum):
|
||||
internal = "internal"
|
||||
onvif = "onvif"
|
||||
|
||||
|
||||
class MotionConfig(FrigateBaseModel):
|
||||
@@ -14,6 +20,11 @@ class MotionConfig(FrigateBaseModel):
|
||||
title="Enable motion detection",
|
||||
description="Enable or disable motion detection for all cameras; can be overridden per-camera.",
|
||||
)
|
||||
source: MotionSourceEnum = Field(
|
||||
default=MotionSourceEnum.internal,
|
||||
title="Motion source",
|
||||
description="Where motion state comes from: Frigate's internal frame analyser, or the camera's ONVIF cell-motion events (requires onvif.events.enabled).",
|
||||
)
|
||||
threshold: int = Field(
|
||||
default=30,
|
||||
title="Motion threshold",
|
||||
|
||||
@@ -7,7 +7,12 @@ from ..base import FrigateBaseModel
|
||||
from ..env import EnvString
|
||||
from .objects import DEFAULT_TRACKED_OBJECTS
|
||||
|
||||
__all__ = ["OnvifConfig", "PtzAutotrackConfig", "ZoomingModeEnum"]
|
||||
__all__ = [
|
||||
"OnvifConfig",
|
||||
"OnvifEventsConfig",
|
||||
"PtzAutotrackConfig",
|
||||
"ZoomingModeEnum",
|
||||
]
|
||||
|
||||
|
||||
class ZoomingModeEnum(str, Enum):
|
||||
@@ -91,6 +96,26 @@ class PtzAutotrackConfig(FrigateBaseModel):
|
||||
return weights
|
||||
|
||||
|
||||
class OnvifEventsConfig(FrigateBaseModel):
|
||||
enabled: bool = Field(
|
||||
default=False,
|
||||
title="Enable ONVIF events",
|
||||
description="Subscribe to the camera's ONVIF cell-motion notifications and use them as Frigate's motion signal.",
|
||||
)
|
||||
subscription_timeout: int = Field(
|
||||
default=60,
|
||||
ge=10,
|
||||
le=600,
|
||||
title="Subscription timeout",
|
||||
description="Seconds before the PullPoint subscription expires and is renewed.",
|
||||
)
|
||||
use_metadata_stream: bool = Field(
|
||||
default=True,
|
||||
title="Use metadata stream",
|
||||
description="Open the ONVIF analytics RTSP metadata stream to receive per-cell motion coordinates. Falls back to a full-frame box when disabled or when the camera does not advertise the track.",
|
||||
)
|
||||
|
||||
|
||||
class OnvifConfig(FrigateBaseModel):
|
||||
host: EnvString = Field(
|
||||
default="",
|
||||
@@ -127,6 +152,11 @@ class OnvifConfig(FrigateBaseModel):
|
||||
title="Autotracking",
|
||||
description="Automatically track moving objects and keep them centered in the frame using PTZ camera movements.",
|
||||
)
|
||||
events: OnvifEventsConfig = Field(
|
||||
default_factory=OnvifEventsConfig,
|
||||
title="ONVIF events",
|
||||
description="Consume camera-side ONVIF motion notifications instead of Frigate's CPU motion detector.",
|
||||
)
|
||||
ignore_time_mismatch: bool = Field(
|
||||
default=False,
|
||||
title="Ignore time mismatch",
|
||||
|
||||
@@ -9,7 +9,6 @@ from frigate.review.types import SeverityEnum
|
||||
from ..base import FrigateBaseModel
|
||||
|
||||
__all__ = [
|
||||
"ChaptersEnum",
|
||||
"RecordConfig",
|
||||
"RecordExportConfig",
|
||||
"RecordPreviewConfig",
|
||||
@@ -87,12 +86,6 @@ class RecordPreviewConfig(FrigateBaseModel):
|
||||
)
|
||||
|
||||
|
||||
class ChaptersEnum(str, Enum):
|
||||
none = "none"
|
||||
recording_segments = "recording_segments"
|
||||
review_items = "review_items"
|
||||
|
||||
|
||||
class RecordExportConfig(FrigateBaseModel):
|
||||
hwaccel_args: Union[str, list[str]] = Field(
|
||||
default="auto",
|
||||
@@ -105,10 +98,6 @@ class RecordExportConfig(FrigateBaseModel):
|
||||
title="Maximum concurrent exports",
|
||||
description="Maximum number of export jobs to process at the same time.",
|
||||
)
|
||||
chapters: ChaptersEnum = Field(
|
||||
default=ChaptersEnum.review_items,
|
||||
title="Chapter metadata to embed in exported recordings",
|
||||
)
|
||||
|
||||
|
||||
class RecordConfig(FrigateBaseModel):
|
||||
|
||||
@@ -47,7 +47,7 @@ from .camera.detect import DetectConfig
|
||||
from .camera.ffmpeg import FfmpegConfig
|
||||
from .camera.genai import GenAIConfig, GenAIRoleEnum
|
||||
from .camera.mask import ObjectMaskConfig
|
||||
from .camera.motion import MotionConfig
|
||||
from .camera.motion import MotionConfig, MotionSourceEnum
|
||||
from .camera.notification import NotificationConfig
|
||||
from .camera.objects import FilterConfig, ObjectConfig
|
||||
from .camera.record import RecordConfig
|
||||
@@ -380,10 +380,19 @@ def verify_autotrack_zones(camera_config: CameraConfig) -> ValueError | None:
|
||||
|
||||
def verify_motion_and_detect(camera_config: CameraConfig) -> ValueError | None:
|
||||
"""Verify that motion detection is not disabled and object detection is enabled."""
|
||||
if camera_config.detect.enabled and not camera_config.motion.enabled:
|
||||
motion_via_onvif = camera_config.motion.source == MotionSourceEnum.onvif
|
||||
if (
|
||||
camera_config.detect.enabled
|
||||
and not camera_config.motion.enabled
|
||||
and not motion_via_onvif
|
||||
):
|
||||
raise ValueError(
|
||||
f"Camera {camera_config.name} has motion detection disabled and object detection enabled but object detection requires motion detection."
|
||||
)
|
||||
if motion_via_onvif and not camera_config.onvif.events.enabled:
|
||||
raise ValueError(
|
||||
f"Camera {camera_config.name} has motion.source=onvif but onvif.events.enabled is false; enable ONVIF events to use them as the motion source."
|
||||
)
|
||||
|
||||
|
||||
def verify_objects_track(
|
||||
|
||||
@@ -86,15 +86,13 @@ class LicensePlateProcessingMixin:
|
||||
self.similarity_threshold = 0.8
|
||||
self.cluster_threshold = 0.85
|
||||
|
||||
def _detect(self, image: np.ndarray, debug_frame_id: int) -> List[np.ndarray]:
|
||||
def _detect(self, image: np.ndarray) -> List[np.ndarray]:
|
||||
"""
|
||||
Detect possible areas of text in the input image by first resizing and normalizing it,
|
||||
running a detection model, and filtering out low-probability regions.
|
||||
|
||||
Args:
|
||||
image (np.ndarray): The input image in which license plates will be detected.
|
||||
debug_frame_id (int): Shared id used to name debug images so all artifacts
|
||||
from a single LPR pass share the same filename suffix.
|
||||
|
||||
Returns:
|
||||
List[np.ndarray]: A list of bounding box coordinates representing detected license plates.
|
||||
@@ -108,8 +106,9 @@ class LicensePlateProcessingMixin:
|
||||
normalized_image = self._normalize_image(resized_image)
|
||||
|
||||
if WRITE_DEBUG_IMAGES:
|
||||
current_time = int(datetime.datetime.now().timestamp())
|
||||
cv2.imwrite(
|
||||
f"debug/frames/license_plate_resized_{debug_frame_id}.jpg",
|
||||
f"debug/frames/license_plate_resized_{current_time}.jpg",
|
||||
resized_image,
|
||||
)
|
||||
|
||||
@@ -204,7 +203,7 @@ class LicensePlateProcessingMixin:
|
||||
return self.ctc_decoder(outputs)
|
||||
|
||||
def _process_license_plate(
|
||||
self, camera: str, id: str, image: np.ndarray, debug_frame_id: int
|
||||
self, camera: str, id: str, image: np.ndarray
|
||||
) -> Tuple[List[str], List[List[float]], List[int]]:
|
||||
"""
|
||||
Complete pipeline for detecting, classifying, and recognizing license plates in the input image.
|
||||
@@ -215,8 +214,6 @@ class LicensePlateProcessingMixin:
|
||||
camera (str): Camera identifier.
|
||||
id (str): Event identifier.
|
||||
image (np.ndarray): The input image in which to detect, classify, and recognize license plates.
|
||||
debug_frame_id (int): Shared id used to name debug images so all artifacts
|
||||
from a single LPR pass share the same filename suffix.
|
||||
|
||||
Returns:
|
||||
Tuple[List[str], List[List[float]], List[int]]: Detected license plate texts, character-level confidence scores for each plate (flattened into a single list per plate), and areas of the plates.
|
||||
@@ -230,7 +227,7 @@ class LicensePlateProcessingMixin:
|
||||
logger.debug("Model runners not loaded")
|
||||
return [], [], []
|
||||
|
||||
boxes = self._detect(image, debug_frame_id)
|
||||
boxes = self._detect(image)
|
||||
if len(boxes) == 0:
|
||||
logger.debug(f"{camera}: No boxes found by OCR detector model")
|
||||
return [], [], []
|
||||
@@ -246,6 +243,7 @@ class LicensePlateProcessingMixin:
|
||||
boxes, plate_width=plate_width, gap_fraction=0.1
|
||||
)
|
||||
|
||||
current_time = int(datetime.datetime.now().timestamp())
|
||||
if WRITE_DEBUG_IMAGES:
|
||||
debug_image = image.copy()
|
||||
for box in boxes:
|
||||
@@ -261,7 +259,7 @@ class LicensePlateProcessingMixin:
|
||||
)
|
||||
|
||||
cv2.imwrite(
|
||||
f"debug/frames/license_plate_boxes_{debug_frame_id}.jpg", debug_image
|
||||
f"debug/frames/license_plate_boxes_{current_time}.jpg", debug_image
|
||||
)
|
||||
|
||||
boxes = self._sort_boxes(list(boxes))
|
||||
@@ -324,7 +322,7 @@ class LicensePlateProcessingMixin:
|
||||
if WRITE_DEBUG_IMAGES:
|
||||
for i, img in enumerate(group_plate_images):
|
||||
cv2.imwrite(
|
||||
f"debug/frames/license_plate_cropped_{debug_frame_id}_{group_indices[i] + 1}.jpg",
|
||||
f"debug/frames/license_plate_cropped_{current_time}_{group_indices[i] + 1}.jpg",
|
||||
img,
|
||||
)
|
||||
|
||||
@@ -337,7 +335,7 @@ class LicensePlateProcessingMixin:
|
||||
cv2.imwrite(
|
||||
os.path.join(
|
||||
CLIPS_DIR,
|
||||
f"lpr/{camera}/{id}/{debug_frame_id}_{group_indices[i] + 1}.jpg",
|
||||
f"lpr/{camera}/{id}/{current_time}_{group_indices[i] + 1}.jpg",
|
||||
),
|
||||
img,
|
||||
)
|
||||
@@ -1201,7 +1199,6 @@ class LicensePlateProcessingMixin:
|
||||
self.metrics.yolov9_lpr_pps.value = self.plates_det_second.eps()
|
||||
camera = obj_data if dedicated_lpr else obj_data["camera"]
|
||||
current_time = int(datetime.datetime.now().timestamp())
|
||||
debug_frame_id = int(datetime.datetime.now().timestamp() * 1000)
|
||||
|
||||
if not self.config.cameras[camera].lpr.enabled:
|
||||
return
|
||||
@@ -1217,7 +1214,7 @@ class LicensePlateProcessingMixin:
|
||||
|
||||
if WRITE_DEBUG_IMAGES:
|
||||
cv2.imwrite(
|
||||
f"debug/frames/dedicated_lpr_masked_{debug_frame_id}.jpg",
|
||||
f"debug/frames/dedicated_lpr_masked_{current_time}.jpg",
|
||||
rgb,
|
||||
)
|
||||
|
||||
@@ -1329,7 +1326,7 @@ class LicensePlateProcessingMixin:
|
||||
|
||||
if WRITE_DEBUG_IMAGES:
|
||||
cv2.imwrite(
|
||||
f"debug/frames/car_frame_{debug_frame_id}.jpg",
|
||||
f"debug/frames/car_frame_{current_time}.jpg",
|
||||
car,
|
||||
)
|
||||
|
||||
@@ -1457,7 +1454,7 @@ class LicensePlateProcessingMixin:
|
||||
|
||||
if WRITE_DEBUG_IMAGES:
|
||||
cv2.imwrite(
|
||||
f"debug/frames/license_plate_frame_{debug_frame_id}.jpg",
|
||||
f"debug/frames/license_plate_frame_{current_time}.jpg",
|
||||
license_plate_frame,
|
||||
)
|
||||
|
||||
@@ -1467,7 +1464,7 @@ class LicensePlateProcessingMixin:
|
||||
# run detection, returns results sorted by confidence, best first
|
||||
start = datetime.datetime.now().timestamp()
|
||||
license_plates, confidences, areas = self._process_license_plate(
|
||||
camera, id, license_plate_frame, debug_frame_id
|
||||
camera, id, license_plate_frame
|
||||
)
|
||||
self.plates_rec_second.update()
|
||||
self.plate_rec_speed.update(datetime.datetime.now().timestamp() - start)
|
||||
|
||||
@@ -13,7 +13,6 @@ from peewee import DoesNotExist
|
||||
|
||||
from frigate.comms.inter_process import InterProcessRequestor
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.config.camera.record import ChaptersEnum
|
||||
from frigate.const import UPDATE_JOB_STATE
|
||||
from frigate.jobs.job import Job
|
||||
from frigate.models import Export
|
||||
@@ -56,7 +55,6 @@ class ExportJob(Job):
|
||||
ffmpeg_input_args: Optional[str] = None
|
||||
ffmpeg_output_args: Optional[str] = None
|
||||
cpu_fallback: bool = False
|
||||
chapters: Optional[ChaptersEnum] = None
|
||||
current_step: str = "queued"
|
||||
progress_percent: float = 0.0
|
||||
|
||||
@@ -345,7 +343,6 @@ class ExportJobManager:
|
||||
job.ffmpeg_input_args,
|
||||
job.ffmpeg_output_args,
|
||||
job.cpu_fallback,
|
||||
job.chapters,
|
||||
on_progress=self._make_progress_callback(job),
|
||||
)
|
||||
|
||||
|
||||
+216
-3
@@ -13,17 +13,39 @@ import numpy
|
||||
from onvif import ONVIFCamera, ONVIFError, ONVIFService
|
||||
from zeep.exceptions import Fault, TransportError
|
||||
|
||||
from frigate.camera import PTZMetrics
|
||||
from frigate.camera import CameraMetrics, PTZMetrics
|
||||
from frigate.config import FrigateConfig, ZoomingModeEnum
|
||||
from frigate.config.camera.updater import (
|
||||
CameraConfigUpdateEnum,
|
||||
CameraConfigUpdateSubscriber,
|
||||
)
|
||||
from frigate.ptz.onvif_events import run_pullpoint_subscription
|
||||
from frigate.ptz.onvif_metadata import run_metadata_stream
|
||||
from frigate.util.builtin import find_by_key
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _inject_rtsp_credentials(url: str, user: str | None, password: str | None) -> str:
|
||||
"""Insert user:password into an rtsp:// URL if not already present.
|
||||
|
||||
The ONVIF GetStreamUri response typically returns an rtsp URL without
|
||||
credentials, but downstream consumers (ffmpeg, RTSP libs) need them in
|
||||
the URL because the camera challenges Basic/Digest on DESCRIBE.
|
||||
"""
|
||||
if not user or not password:
|
||||
return url
|
||||
if "@" in url.split("://", 1)[-1].split("/", 1)[0]:
|
||||
# URL already has user:pass — don't touch it.
|
||||
return url
|
||||
if "://" not in url:
|
||||
return url
|
||||
scheme, rest = url.split("://", 1)
|
||||
from urllib.parse import quote
|
||||
|
||||
return f"{scheme}://{quote(user, safe='')}:{quote(password, safe='')}@{rest}"
|
||||
|
||||
|
||||
class OnvifCommandEnum(str, Enum):
|
||||
"""Holds all possible move commands"""
|
||||
|
||||
@@ -45,7 +67,10 @@ class OnvifController:
|
||||
ptz_metrics: dict[str, PTZMetrics]
|
||||
|
||||
def __init__(
|
||||
self, config: FrigateConfig, ptz_metrics: dict[str, PTZMetrics]
|
||||
self,
|
||||
config: FrigateConfig,
|
||||
ptz_metrics: dict[str, PTZMetrics],
|
||||
camera_metrics: dict[str, CameraMetrics] | None = None,
|
||||
) -> None:
|
||||
self.cams: dict[str, dict] = {}
|
||||
self.failed_cams: dict[str, dict] = {}
|
||||
@@ -53,6 +78,7 @@ class OnvifController:
|
||||
self.reset_timeout = 900 # 15 minutes
|
||||
self.config = config
|
||||
self.ptz_metrics = ptz_metrics
|
||||
self.camera_metrics = camera_metrics or {}
|
||||
|
||||
self.status_locks: dict[str, asyncio.Lock] = {}
|
||||
|
||||
@@ -107,7 +133,28 @@ class OnvifController:
|
||||
async def _close_camera(self, cam_name: str) -> None:
|
||||
"""Close the ONVIF client session for a camera."""
|
||||
cam_state = self.cams.get(cam_name)
|
||||
if cam_state and "onvif" in cam_state:
|
||||
if not cam_state:
|
||||
return
|
||||
# Stop any long-running event-consumption tasks first so they release
|
||||
# any resources held against the ONVIFCamera session before we close it.
|
||||
for key in ("pullpoint", "metadata"):
|
||||
handle = cam_state.get(key)
|
||||
if not handle:
|
||||
continue
|
||||
task, stop_event = handle
|
||||
try:
|
||||
stop_event.set()
|
||||
except Exception:
|
||||
pass
|
||||
task.cancel()
|
||||
try:
|
||||
await asyncio.wait_for(task, timeout=5.0)
|
||||
except (asyncio.CancelledError, asyncio.TimeoutError):
|
||||
pass
|
||||
except Exception:
|
||||
logger.debug(f"Error awaiting {key} task for {cam_name}")
|
||||
cam_state.pop(key, None)
|
||||
if "onvif" in cam_state:
|
||||
try:
|
||||
await cam_state["onvif"].close()
|
||||
except Exception:
|
||||
@@ -187,6 +234,172 @@ class OnvifController:
|
||||
logger.error(f"Onvif connection failed for {camera_name}: {e}")
|
||||
return False
|
||||
|
||||
# Events init runs first, independent of PTZ capability. Many ONVIF
|
||||
# cameras don't expose PTZ and would otherwise be skipped at the
|
||||
# get_definition("ptz") check below.
|
||||
await self._init_onvif_events(camera_name)
|
||||
|
||||
return await self._init_onvif_ptz(camera_name)
|
||||
|
||||
async def _init_onvif_events(self, camera_name: str) -> None:
|
||||
"""Subscribe to PullPoint motion events and optionally open the
|
||||
analytics metadata stream. Failure here is non-fatal — PTZ init still
|
||||
proceeds and the camera continues to work without external motion."""
|
||||
cam_cfg = self.config.cameras[camera_name]
|
||||
if not cam_cfg.onvif.events.enabled:
|
||||
return
|
||||
|
||||
cm = self.camera_metrics.get(camera_name)
|
||||
if cm is None:
|
||||
logger.warning(
|
||||
f"ONVIF events enabled for {camera_name} but no CameraMetrics "
|
||||
"available; external motion will not be published"
|
||||
)
|
||||
return
|
||||
|
||||
onvif: ONVIFCamera = self.cams[camera_name]["onvif"]
|
||||
|
||||
cell_layout = await self._discover_cell_layout(onvif, camera_name)
|
||||
self.cams[camera_name]["cell_layout"] = cell_layout
|
||||
|
||||
def on_state(active: bool) -> None:
|
||||
cm.external_motion_active.value = 1 if active else 0
|
||||
if not active:
|
||||
# Drop spatial data when motion ends — keep the consumer's
|
||||
# snapshot consistent with the binary state.
|
||||
try:
|
||||
cm.external_motion_boxes[:] = []
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
pp_stop = asyncio.Event()
|
||||
pp_task = asyncio.create_task(
|
||||
run_pullpoint_subscription(
|
||||
onvif,
|
||||
camera_name,
|
||||
cam_cfg.onvif.events.subscription_timeout,
|
||||
on_state,
|
||||
pp_stop,
|
||||
)
|
||||
)
|
||||
self.cams[camera_name]["pullpoint"] = (pp_task, pp_stop)
|
||||
logger.info(f"ONVIF events: PullPoint subscriber started for {camera_name}")
|
||||
|
||||
if not cam_cfg.onvif.events.use_metadata_stream or cell_layout is None:
|
||||
return
|
||||
|
||||
rtsp_url = await self._discover_primary_rtsp_url(onvif, camera_name)
|
||||
if not rtsp_url:
|
||||
logger.warning(
|
||||
f"ONVIF events for {camera_name}: no primary RTSP URL "
|
||||
"available; skipping metadata stream"
|
||||
)
|
||||
return
|
||||
rtsp_url = _inject_rtsp_credentials(
|
||||
rtsp_url, cam_cfg.onvif.user, cam_cfg.onvif.password
|
||||
)
|
||||
|
||||
detect_size = (cam_cfg.detect.width, cam_cfg.detect.height)
|
||||
|
||||
def on_boxes(boxes: list[tuple[int, int, int, int]]) -> None:
|
||||
try:
|
||||
cm.external_motion_boxes[:] = boxes
|
||||
except Exception:
|
||||
logger.debug(f"Failed to publish boxes for {camera_name}")
|
||||
|
||||
md_stop = asyncio.Event()
|
||||
md_task = asyncio.create_task(
|
||||
run_metadata_stream(
|
||||
rtsp_url,
|
||||
camera_name,
|
||||
cell_layout,
|
||||
detect_size,
|
||||
on_boxes,
|
||||
md_stop,
|
||||
)
|
||||
)
|
||||
self.cams[camera_name]["metadata"] = (md_task, md_stop)
|
||||
logger.info(f"ONVIF events: metadata stream consumer started for {camera_name}")
|
||||
|
||||
async def _discover_cell_layout(
|
||||
self, onvif: ONVIFCamera, camera_name: str
|
||||
) -> tuple[int, int, tuple[float, float], tuple[float, float]] | None:
|
||||
"""Query AnalyticsService.GetAnalyticsModules and extract the
|
||||
CellMotionEngine's CellLayout (Columns, Rows, Translate, Scale).
|
||||
Returns None on failure — caller should fall back to a full-frame box."""
|
||||
try:
|
||||
analytics = await onvif.create_analytics_service()
|
||||
modules = await analytics.GetAnalyticsModules(
|
||||
{"ConfigurationToken": "VA_CFG_000"}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"ONVIF analytics service unavailable for {camera_name}: {e}")
|
||||
return None
|
||||
|
||||
try:
|
||||
for mod in modules or []:
|
||||
mod_type = getattr(mod, "Type", None) or getattr(mod, "_attr_1", None)
|
||||
if mod_type and "CellMotionEngine" not in str(mod_type):
|
||||
continue
|
||||
element_items = getattr(mod.Parameters, "ElementItem", None) or []
|
||||
for item in element_items:
|
||||
if item.Name != "Layout":
|
||||
continue
|
||||
raw = item._value_1
|
||||
if raw is None or not hasattr(raw, "attrib"):
|
||||
continue
|
||||
cols = int(raw.attrib.get("Columns", 0))
|
||||
rows = int(raw.attrib.get("Rows", 0))
|
||||
if cols <= 0 or rows <= 0:
|
||||
continue
|
||||
tx = ty = 0.0
|
||||
sx = sy = 0.0
|
||||
for child in raw.iter():
|
||||
if child.tag.endswith("}Translate"):
|
||||
tx = float(child.attrib.get("x", 0))
|
||||
ty = float(child.attrib.get("y", 0))
|
||||
elif child.tag.endswith("}Scale"):
|
||||
sx = float(child.attrib.get("x", 0))
|
||||
sy = float(child.attrib.get("y", 0))
|
||||
logger.info(
|
||||
f"ONVIF cell layout for {camera_name}: {cols}x{rows} "
|
||||
f"translate=({tx},{ty}) scale=({sx},{sy})"
|
||||
)
|
||||
return (cols, rows, (tx, ty), (sx, sy))
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
f"Failed parsing CellMotionEngine layout for {camera_name}: {e}"
|
||||
)
|
||||
return None
|
||||
|
||||
async def _discover_primary_rtsp_url(
|
||||
self, onvif: ONVIFCamera, camera_name: str
|
||||
) -> str | None:
|
||||
"""Return the RTSP URL for the primary profile. The ONVIF analytics
|
||||
metadata track is typically bound to the primary media profile only
|
||||
(sub-streams may omit it)."""
|
||||
try:
|
||||
media = await onvif.create_media_service()
|
||||
profiles = await media.GetProfiles()
|
||||
if not profiles:
|
||||
return None
|
||||
uri = await media.GetStreamUri(
|
||||
{
|
||||
"StreamSetup": {
|
||||
"Stream": "RTP-Unicast",
|
||||
"Transport": {"Protocol": "RTSP"},
|
||||
},
|
||||
"ProfileToken": profiles[0].token,
|
||||
}
|
||||
)
|
||||
return uri.Uri
|
||||
except Exception as e:
|
||||
logger.debug(f"GetStreamUri failed for {camera_name}: {e}")
|
||||
return None
|
||||
|
||||
async def _init_onvif_ptz(self, camera_name: str) -> bool:
|
||||
onvif: ONVIFCamera = self.cams[camera_name]["onvif"]
|
||||
|
||||
# create init services
|
||||
media: ONVIFService = await onvif.create_media_service()
|
||||
logger.debug(f"Onvif media xaddr for {camera_name}: {media.xaddr}")
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
"""ONVIF PullPoint subscriber for camera-side motion events.
|
||||
|
||||
Long-running per-camera coroutine that subscribes to the camera's PullPoint
|
||||
service via `onvif-zeep-async`'s `PullPointManager` (which owns subscription
|
||||
creation, renewal, and lifecycle), pulls notification messages, parses
|
||||
IsMotion/State on each round-trip, and invokes a callback on transitions.
|
||||
|
||||
Lives on the OnvifController's dedicated asyncio loop (see
|
||||
`frigate/ptz/onvif.py` for the loop setup).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import datetime as dt
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Awaitable, Callable
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from onvif import ONVIFCamera
|
||||
|
||||
try:
|
||||
from zeep.exceptions import Fault
|
||||
except ImportError: # tests can run without zeep installed
|
||||
|
||||
class Fault(Exception): # type: ignore[no-redef]
|
||||
pass
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Names of the boolean state SimpleItem we accept inside the message Data
|
||||
# block. Spec calls it IsMotion; the legacy MotionAlarm topic uses State.
|
||||
_STATE_NAMES = ("IsMotion", "State")
|
||||
|
||||
# Bounds on backoff between subscription failures.
|
||||
_BACKOFF_INITIAL_S = 1.0
|
||||
_BACKOFF_MAX_S = 60.0
|
||||
|
||||
|
||||
def _parse_motion_state(msg) -> bool | None:
|
||||
"""Walk a NotificationMessage and return the IsMotion/State value, or
|
||||
None if not present. The Message body is often an `lxml.etree._Element`
|
||||
that python-onvif-zeep returns for ##any wildcards — walk via .iter()."""
|
||||
body = getattr(msg, "Message", None)
|
||||
if body is None:
|
||||
return None
|
||||
raw = getattr(body, "_value_1", body)
|
||||
if not hasattr(raw, "iter"):
|
||||
return None
|
||||
for el in raw.iter():
|
||||
if not el.tag.endswith("}SimpleItem"):
|
||||
continue
|
||||
name = el.attrib.get("Name", "")
|
||||
if name not in _STATE_NAMES:
|
||||
continue
|
||||
val = el.attrib.get("Value", "").strip().lower()
|
||||
if val in ("true", "1"):
|
||||
return True
|
||||
if val in ("false", "0"):
|
||||
return False
|
||||
return None
|
||||
|
||||
|
||||
async def run_pullpoint_subscription(
|
||||
onvif_cam: "ONVIFCamera",
|
||||
cam_name: str,
|
||||
timeout_seconds: int,
|
||||
on_state: Callable[[bool], None] | Callable[[bool], Awaitable[None]],
|
||||
stop_event: asyncio.Event,
|
||||
) -> None:
|
||||
"""Loop until stop_event: create a PullPointManager, pull messages,
|
||||
dispatch on_state on transitions, reconnect on Fault with exponential
|
||||
backoff."""
|
||||
backoff = _BACKOFF_INITIAL_S
|
||||
last_state: bool | None = None
|
||||
|
||||
while not stop_event.is_set():
|
||||
manager = None
|
||||
sub_lost = asyncio.Event()
|
||||
|
||||
def _subscription_lost() -> None:
|
||||
sub_lost.set()
|
||||
|
||||
try:
|
||||
manager = await onvif_cam.create_pullpoint_manager(
|
||||
dt.timedelta(seconds=timeout_seconds),
|
||||
_subscription_lost,
|
||||
)
|
||||
service = manager.get_service()
|
||||
logger.info(f"ONVIF PullPoint subscribed for {cam_name}")
|
||||
|
||||
while not stop_event.is_set() and not sub_lost.is_set():
|
||||
# Long-poll up to 10s. The subscription manager keeps the
|
||||
# subscription itself alive in the background — we just pull.
|
||||
msgs = await service.PullMessages(
|
||||
{"Timeout": "PT10S", "MessageLimit": 32}
|
||||
)
|
||||
for m in msgs.NotificationMessage or []:
|
||||
state = _parse_motion_state(m)
|
||||
if state is None or state == last_state:
|
||||
continue
|
||||
last_state = state
|
||||
try:
|
||||
result = on_state(state)
|
||||
if asyncio.iscoroutine(result):
|
||||
await result
|
||||
except Exception:
|
||||
logger.exception(f"on_state callback error for {cam_name}")
|
||||
|
||||
if sub_lost.is_set():
|
||||
raise Fault("PullPoint subscription lost")
|
||||
|
||||
# Clean exit (stop_event set) — leave the loop.
|
||||
backoff = _BACKOFF_INITIAL_S
|
||||
break
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"ONVIF PullPoint subscription error for {cam_name}: {e!r}; "
|
||||
f"reconnecting in {backoff:.1f}s"
|
||||
)
|
||||
finally:
|
||||
if manager is not None:
|
||||
try:
|
||||
await manager.shutdown()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if stop_event.is_set():
|
||||
return
|
||||
try:
|
||||
await asyncio.wait_for(stop_event.wait(), timeout=backoff)
|
||||
return
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
backoff = min(backoff * 2, _BACKOFF_MAX_S)
|
||||
@@ -0,0 +1,357 @@
|
||||
"""ONVIF analytics metadata stream consumer.
|
||||
|
||||
Per-camera asyncio task that opens an RTSP connection to the camera's
|
||||
primary profile, extracts the `application/vnd.onvif.metadata` data track
|
||||
via an ffmpeg subprocess, and converts the per-frame `<tt:MotionInCells>`
|
||||
bitmap into a list of motion rectangles in Frigate detect-frame pixels.
|
||||
|
||||
Why ffmpeg rather than an in-process RTSP client: Frigate already ships
|
||||
ffmpeg and uses it heavily for video/recording; there is no async RTSP
|
||||
client in the existing dependency set that handles the `vnd.onvif.metadata`
|
||||
payload cleanly. The data track is low-bandwidth (~1 packet/sec at idle,
|
||||
≤300 bytes XML each), so the subprocess cost is negligible.
|
||||
|
||||
Wire format (ONVIF Analytics Service Spec, Annex B "Cell Motion Detection"):
|
||||
- Each RTP packet payload is one complete <tt:MetadataStream> XML doc.
|
||||
- Cells attribute = base64(PackBits(bit-packed row-major bitmap)).
|
||||
- Bits: cols*rows total, MSB-first within bytes, zero-padded.
|
||||
|
||||
Cell → detect-frame mapping uses the CellLayout transformation discovered
|
||||
at OnvifController init: Translate(tx, ty) + Scale(sx, sy) maps cell index
|
||||
(c, r) to normalized ONVIF coords [-1, +1]. We convert that to detect-frame
|
||||
pixels.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import logging
|
||||
from typing import Awaitable, Callable
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
import numpy as np
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_TT_NS = "http://www.onvif.org/ver10/schema"
|
||||
_MIC_TAG = f"{{{_TT_NS}}}MotionInCells"
|
||||
|
||||
# ffmpeg's -map 0:d:0 selects the first data track from the input. -c copy
|
||||
# bypasses any transcode. -f data writes raw packet payloads to stdout.
|
||||
# -flush_packets 1 disables muxer-side buffering so each metadata frame
|
||||
# reaches us within ~1 packet of being received from the camera.
|
||||
_FFMPEG_ARGS_TEMPLATE = (
|
||||
"-nostdin",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-rtsp_transport",
|
||||
"tcp",
|
||||
"-i",
|
||||
"{url}",
|
||||
"-map",
|
||||
"0:d:0?",
|
||||
"-c",
|
||||
"copy",
|
||||
"-flush_packets",
|
||||
"1",
|
||||
"-f",
|
||||
"data",
|
||||
"pipe:1",
|
||||
)
|
||||
|
||||
# Each metadata document ends with this closing tag — we split incoming
|
||||
# stdout on it to recover packet boundaries (no other framing on a `-f data`
|
||||
# stream).
|
||||
_DOC_TERMINATOR = b"</tt:MetadataStream>"
|
||||
|
||||
_BACKOFF_INITIAL_S = 1.0
|
||||
_BACKOFF_MAX_S = 60.0
|
||||
|
||||
# Stop reading at this many bytes per single document — guards against a
|
||||
# misbehaving stream filling memory if the terminator never arrives.
|
||||
_MAX_DOC_BYTES = 64 * 1024
|
||||
|
||||
|
||||
def _packbits_decode(packed: bytes) -> bytes:
|
||||
"""ISO 12639 / TIFF 6.0 PackBits decoder."""
|
||||
out = bytearray()
|
||||
i = 0
|
||||
n = len(packed)
|
||||
while i < n:
|
||||
h = packed[i]
|
||||
i += 1
|
||||
if h <= 0x7F:
|
||||
count = h + 1
|
||||
out += packed[i : i + count]
|
||||
i += count
|
||||
elif h == 0x80:
|
||||
continue # no-op header
|
||||
else:
|
||||
count = 257 - h
|
||||
if i >= n:
|
||||
break
|
||||
out += bytes([packed[i]]) * count
|
||||
i += 1
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def _decode_cells(cells_b64: str, cols: int, rows: int) -> np.ndarray | None:
|
||||
"""Decode the Cells attribute into a 2-D uint8 array shape (rows, cols).
|
||||
|
||||
Returns None if the decoded length doesn't match what the layout
|
||||
expects — caller should treat that as "no spatial data this frame"
|
||||
and fall back to whatever default (e.g. full-frame box)."""
|
||||
if not cells_b64:
|
||||
return None
|
||||
try:
|
||||
packed = base64.b64decode(cells_b64, validate=False)
|
||||
except Exception:
|
||||
return None
|
||||
raw = _packbits_decode(packed)
|
||||
needed_bytes = (cols * rows + 7) // 8
|
||||
if len(raw) < needed_bytes:
|
||||
return None
|
||||
bits = np.unpackbits(np.frombuffer(raw[:needed_bytes], dtype=np.uint8))
|
||||
bits = bits[: cols * rows]
|
||||
return bits.reshape((rows, cols)).astype(np.uint8)
|
||||
|
||||
|
||||
def _connected_component_bboxes(
|
||||
cells: np.ndarray,
|
||||
) -> list[tuple[int, int, int, int]]:
|
||||
"""4-connectivity flood fill over a small 0/1 grid; returns list of
|
||||
(c_left, c_top, c_right, c_bottom) inclusive cell-index bounding boxes
|
||||
for each connected region.
|
||||
|
||||
cv2.connectedComponentsWithStats would be faster, but the cell grid is
|
||||
tiny (typically 22x18 = 396 cells) and avoiding the cv2 import keeps
|
||||
this module testable without OpenCV installed.
|
||||
"""
|
||||
rows, cols = cells.shape
|
||||
visited = np.zeros_like(cells, dtype=bool)
|
||||
out: list[tuple[int, int, int, int]] = []
|
||||
for r0 in range(rows):
|
||||
for c0 in range(cols):
|
||||
if not cells[r0, c0] or visited[r0, c0]:
|
||||
continue
|
||||
stack = [(r0, c0)]
|
||||
cmin = cmax = c0
|
||||
rmin = rmax = r0
|
||||
while stack:
|
||||
r, c = stack.pop()
|
||||
if r < 0 or r >= rows or c < 0 or c >= cols:
|
||||
continue
|
||||
if visited[r, c] or not cells[r, c]:
|
||||
continue
|
||||
visited[r, c] = True
|
||||
if r < rmin:
|
||||
rmin = r
|
||||
if r > rmax:
|
||||
rmax = r
|
||||
if c < cmin:
|
||||
cmin = c
|
||||
if c > cmax:
|
||||
cmax = c
|
||||
stack.append((r + 1, c))
|
||||
stack.append((r - 1, c))
|
||||
stack.append((r, c + 1))
|
||||
stack.append((r, c - 1))
|
||||
out.append((cmin, rmin, cmax, rmax))
|
||||
return out
|
||||
|
||||
|
||||
def _cells_to_boxes(
|
||||
cells: np.ndarray,
|
||||
cell_layout: tuple[int, int, tuple[float, float], tuple[float, float]],
|
||||
detect_size: tuple[int, int],
|
||||
) -> list[tuple[int, int, int, int]]:
|
||||
"""Connected-components on the cell grid → list of detect-frame boxes.
|
||||
|
||||
cell_layout = (cols, rows, (tx, ty), (sx, sy)) — the Translate + Scale
|
||||
from CellLayout.Transformation. detect_size = (width, height) in
|
||||
detect-frame pixels.
|
||||
"""
|
||||
if cells is None or cells.size == 0 or not cells.any():
|
||||
return []
|
||||
|
||||
cols, rows, (tx, ty), (sx, sy) = cell_layout
|
||||
det_w, det_h = detect_size
|
||||
if det_w <= 0 or det_h <= 0:
|
||||
return []
|
||||
|
||||
boxes: list[tuple[int, int, int, int]] = []
|
||||
|
||||
# Map cell index → detect-frame pixel via the CellLayout transformation:
|
||||
# cell (c, r) covers normalized [tx + c*sx, tx + (c+1)*sx] horizontally
|
||||
# and similarly vertically. Convert normalized [-1, +1] → pixel.
|
||||
def cell_to_px(
|
||||
c: int, r: int, *, right_edge: bool, bottom_edge: bool
|
||||
) -> tuple[int, int]:
|
||||
cx_idx = c + 1 if right_edge else c
|
||||
cy_idx = r + 1 if bottom_edge else r
|
||||
nx = tx + cx_idx * sx
|
||||
ny = ty + cy_idx * sy
|
||||
px = int(round((nx + 1.0) * 0.5 * det_w))
|
||||
py = int(round((ny + 1.0) * 0.5 * det_h))
|
||||
return px, py
|
||||
|
||||
for c_left, c_top, c_right, c_bottom in _connected_component_bboxes(cells):
|
||||
x1, y1 = cell_to_px(c_left, c_top, right_edge=False, bottom_edge=False)
|
||||
x2, y2 = cell_to_px(c_right, c_bottom, right_edge=True, bottom_edge=True)
|
||||
x1 = max(0, min(det_w - 1, x1))
|
||||
y1 = max(0, min(det_h - 1, y1))
|
||||
x2 = max(0, min(det_w - 1, x2))
|
||||
y2 = max(0, min(det_h - 1, y2))
|
||||
if x2 <= x1 or y2 <= y1:
|
||||
continue
|
||||
boxes.append((x1, y1, x2, y2))
|
||||
|
||||
return boxes
|
||||
|
||||
|
||||
def _extract_cells_from_doc(doc_bytes: bytes) -> tuple[str | None, int, int]:
|
||||
"""Parse a <tt:MetadataStream> XML doc, return (cells_b64, cols, rows).
|
||||
|
||||
Returns (None, 0, 0) if no MotionInCells element is found."""
|
||||
try:
|
||||
root = ET.fromstring(doc_bytes)
|
||||
except ET.ParseError:
|
||||
return None, 0, 0
|
||||
for el in root.iter(_MIC_TAG):
|
||||
cells_b64 = el.attrib.get("Cells")
|
||||
try:
|
||||
cols = int(el.attrib.get("Columns", "0"))
|
||||
rows = int(el.attrib.get("Rows", "0"))
|
||||
except ValueError:
|
||||
return None, 0, 0
|
||||
return cells_b64, cols, rows
|
||||
return None, 0, 0
|
||||
|
||||
|
||||
async def run_metadata_stream(
|
||||
rtsp_url: str,
|
||||
cam_name: str,
|
||||
cell_layout: tuple[int, int, tuple[float, float], tuple[float, float]],
|
||||
detect_size: tuple[int, int],
|
||||
on_boxes: Callable[[list[tuple[int, int, int, int]]], None]
|
||||
| Callable[[list[tuple[int, int, int, int]]], Awaitable[None]],
|
||||
stop_event: asyncio.Event,
|
||||
) -> None:
|
||||
"""Loop until stop_event: spawn ffmpeg → read XML docs → decode → on_boxes."""
|
||||
backoff = _BACKOFF_INITIAL_S
|
||||
|
||||
while not stop_event.is_set():
|
||||
proc = None
|
||||
try:
|
||||
args = [a.format(url=rtsp_url) for a in _FFMPEG_ARGS_TEMPLATE]
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"ffmpeg",
|
||||
*args,
|
||||
stdin=asyncio.subprocess.DEVNULL,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
logger.info(
|
||||
f"ONVIF metadata stream: ffmpeg started for {cam_name} pid={proc.pid}"
|
||||
)
|
||||
await _consume_ffmpeg(
|
||||
proc, cam_name, cell_layout, detect_size, on_boxes, stop_event
|
||||
)
|
||||
backoff = _BACKOFF_INITIAL_S
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"ONVIF metadata stream error for {cam_name}: {e!r}; "
|
||||
f"reconnecting in {backoff:.1f}s"
|
||||
)
|
||||
finally:
|
||||
if proc is not None and proc.returncode is None:
|
||||
proc.terminate()
|
||||
try:
|
||||
await asyncio.wait_for(proc.wait(), timeout=2.0)
|
||||
except asyncio.TimeoutError:
|
||||
proc.kill()
|
||||
await proc.wait()
|
||||
|
||||
if stop_event.is_set():
|
||||
return
|
||||
try:
|
||||
await asyncio.wait_for(stop_event.wait(), timeout=backoff)
|
||||
return
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
backoff = min(backoff * 2, _BACKOFF_MAX_S)
|
||||
|
||||
|
||||
async def _consume_ffmpeg(
|
||||
proc: asyncio.subprocess.Process,
|
||||
cam_name: str,
|
||||
cell_layout: tuple[int, int, tuple[float, float], tuple[float, float]],
|
||||
detect_size: tuple[int, int],
|
||||
on_boxes,
|
||||
stop_event: asyncio.Event,
|
||||
) -> None:
|
||||
"""Read XML docs from ffmpeg stdout and dispatch boxes."""
|
||||
layout_cols, layout_rows, _, _ = cell_layout
|
||||
assert proc.stdout is not None
|
||||
buf = bytearray()
|
||||
|
||||
while not stop_event.is_set():
|
||||
chunk = await proc.stdout.read(4096)
|
||||
if not chunk:
|
||||
# ffmpeg exited or stream ended.
|
||||
stderr_tail = b""
|
||||
if proc.stderr is not None:
|
||||
try:
|
||||
stderr_tail = await asyncio.wait_for(
|
||||
proc.stderr.read(4096), timeout=0.5
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
raise RuntimeError(
|
||||
f"ffmpeg exited for {cam_name} rc={proc.returncode} "
|
||||
f"stderr={stderr_tail.decode('utf-8', 'replace').strip()[:200]}"
|
||||
)
|
||||
|
||||
buf.extend(chunk)
|
||||
if len(buf) > _MAX_DOC_BYTES * 4:
|
||||
# Drop the head to avoid unbounded growth on a wedged stream.
|
||||
buf = buf[-_MAX_DOC_BYTES:]
|
||||
|
||||
while True:
|
||||
end = buf.find(_DOC_TERMINATOR)
|
||||
if end < 0:
|
||||
break
|
||||
end += len(_DOC_TERMINATOR)
|
||||
doc = bytes(buf[:end])
|
||||
del buf[:end]
|
||||
|
||||
cells_b64, cols, rows = _extract_cells_from_doc(doc)
|
||||
if cells_b64 is None:
|
||||
continue
|
||||
# Trust the layout we discovered at init; warn (don't fail) if the
|
||||
# camera reports a different grid mid-stream.
|
||||
if cols != layout_cols or rows != layout_rows:
|
||||
logger.debug(
|
||||
f"{cam_name}: MotionInCells grid {cols}x{rows} differs "
|
||||
f"from discovered layout {layout_cols}x{layout_rows}"
|
||||
)
|
||||
use_layout = (
|
||||
cols,
|
||||
rows,
|
||||
cell_layout[2],
|
||||
(2.0 / cols if cols else 0, 2.0 / rows if rows else 0),
|
||||
)
|
||||
else:
|
||||
use_layout = cell_layout
|
||||
|
||||
cells = _decode_cells(cells_b64, cols, rows)
|
||||
if cells is None:
|
||||
continue
|
||||
boxes = _cells_to_boxes(cells, use_layout, detect_size)
|
||||
try:
|
||||
result = on_boxes(boxes)
|
||||
if asyncio.iscoroutine(result):
|
||||
await result
|
||||
except Exception:
|
||||
logger.exception(f"on_boxes callback error for {cam_name}")
|
||||
+5
-113
@@ -17,7 +17,6 @@ import pytz # type: ignore[import-untyped]
|
||||
from peewee import DoesNotExist
|
||||
|
||||
from frigate.config import FfmpegConfig, FrigateConfig
|
||||
from frigate.config.camera.record import ChaptersEnum
|
||||
from frigate.const import (
|
||||
CACHE_DIR,
|
||||
CLIPS_DIR,
|
||||
@@ -218,7 +217,6 @@ class RecordingExporter(threading.Thread):
|
||||
ffmpeg_input_args: Optional[str] = None,
|
||||
ffmpeg_output_args: Optional[str] = None,
|
||||
cpu_fallback: bool = False,
|
||||
chapters: Optional[ChaptersEnum] = None,
|
||||
on_progress: Optional[Callable[[str, float], None]] = None,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
@@ -234,7 +232,6 @@ class RecordingExporter(threading.Thread):
|
||||
self.ffmpeg_input_args = ffmpeg_input_args
|
||||
self.ffmpeg_output_args = ffmpeg_output_args
|
||||
self.cpu_fallback = cpu_fallback
|
||||
self.chapters = chapters
|
||||
self.on_progress = on_progress
|
||||
|
||||
# ensure export thumb dir
|
||||
@@ -512,74 +509,6 @@ class RecordingExporter(threading.Thread):
|
||||
|
||||
return meta_path
|
||||
|
||||
def _build_recording_segment_chapter_metadata_file(
|
||||
self, recordings: list
|
||||
) -> Optional[str]:
|
||||
"""Write an FFmpeg metadata file with one chapter per recording segment.
|
||||
|
||||
Each chapter's title is the segment's wallclock start time in
|
||||
strict ISO 8601 form so a viewer can map any point in the
|
||||
export's playback timeline back to real-world time without
|
||||
OCR-ing a burnt-in timestamp. Chapter offsets are computed in
|
||||
*output time*: the VOD endpoint concatenates recording clips
|
||||
back-to-back, so wall-clock gaps between recordings collapse in
|
||||
the produced video. Returns ``None`` when there are no
|
||||
recordings or every segment is empty after clipping.
|
||||
"""
|
||||
if not recordings:
|
||||
return None
|
||||
|
||||
tz_name = self.config.ui.timezone
|
||||
tz: Optional[datetime.tzinfo] = None
|
||||
if tz_name:
|
||||
try:
|
||||
tz = pytz.timezone(tz_name)
|
||||
except pytz.UnknownTimeZoneError:
|
||||
tz = None
|
||||
if tz is None:
|
||||
tz = datetime.timezone.utc
|
||||
|
||||
chapter_blocks: list[str] = []
|
||||
output_offset_ms = 0
|
||||
for rec in recordings:
|
||||
clipped_start = max(float(rec.start_time), float(self.start_time))
|
||||
clipped_end = min(float(rec.end_time), float(self.end_time))
|
||||
if clipped_end <= clipped_start:
|
||||
continue
|
||||
|
||||
duration_ms = int(round((clipped_end - clipped_start) * 1000))
|
||||
if duration_ms <= 0:
|
||||
continue
|
||||
|
||||
title = datetime.datetime.fromtimestamp(clipped_start, tz=tz).isoformat(
|
||||
timespec="seconds"
|
||||
)
|
||||
chapter_blocks.append(
|
||||
"[CHAPTER]\n"
|
||||
"TIMEBASE=1/1000\n"
|
||||
f"START={output_offset_ms}\n"
|
||||
f"END={output_offset_ms + duration_ms}\n"
|
||||
f"title={title}"
|
||||
)
|
||||
output_offset_ms += duration_ms
|
||||
|
||||
if not chapter_blocks:
|
||||
return None
|
||||
|
||||
meta_path = self._chapter_metadata_path()
|
||||
try:
|
||||
with open(meta_path, "w", encoding="utf-8") as f:
|
||||
f.write(";FFMETADATA1\n")
|
||||
f.write("\n".join(chapter_blocks))
|
||||
f.write("\n")
|
||||
except OSError:
|
||||
logger.exception(
|
||||
"Failed to write chapter metadata file for export %s", self.export_id
|
||||
)
|
||||
return None
|
||||
|
||||
return meta_path
|
||||
|
||||
def save_thumbnail(self, id: str) -> str:
|
||||
thumb_path = os.path.join(CLIPS_DIR, f"export/{id}.webp")
|
||||
|
||||
@@ -743,18 +672,7 @@ class RecordingExporter(threading.Thread):
|
||||
)
|
||||
).split(" ")
|
||||
else:
|
||||
# Realtime/stream-copy export. Embed chapter metadata according to
|
||||
# the camera's configured chapter mode: per-recording-segment
|
||||
# timestamps or per-review-item titles.
|
||||
if self.chapters == ChaptersEnum.recording_segments:
|
||||
chapters_path = self._build_recording_segment_chapter_metadata_file(
|
||||
recordings
|
||||
)
|
||||
elif self.chapters == ChaptersEnum.review_items:
|
||||
chapters_path = self._build_chapter_metadata_file(recordings)
|
||||
else:
|
||||
chapters_path = None
|
||||
|
||||
chapters_path = self._build_chapter_metadata_file(recordings)
|
||||
chapter_args = (
|
||||
f" -i {chapters_path} -map 0 -dn -map_metadata 1"
|
||||
if chapters_path
|
||||
@@ -766,19 +684,7 @@ class RecordingExporter(threading.Thread):
|
||||
|
||||
# add metadata
|
||||
title = f"Frigate Recording for {self.camera}, {self.get_datetime_from_timestamp(self.start_time)} - {self.get_datetime_from_timestamp(self.end_time)}"
|
||||
creation_time = datetime.datetime.fromtimestamp(
|
||||
self.start_time, tz=datetime.timezone.utc
|
||||
).strftime("%Y-%m-%dT%H:%M:%S.%fZ")
|
||||
ffmpeg_cmd.extend(
|
||||
[
|
||||
"-metadata",
|
||||
f"title={title}",
|
||||
"-metadata",
|
||||
f"creation_time={creation_time}",
|
||||
"-metadata",
|
||||
f"comment=Camera: {self.camera}",
|
||||
]
|
||||
)
|
||||
ffmpeg_cmd.extend(["-metadata", f"title={title}"])
|
||||
|
||||
ffmpeg_cmd.append(video_path)
|
||||
|
||||
@@ -864,32 +770,18 @@ class RecordingExporter(threading.Thread):
|
||||
self.config.ffmpeg.ffmpeg_path,
|
||||
hwaccel_args,
|
||||
f"{self.ffmpeg_input_args} {TIMELAPSE_DATA_INPUT_ARGS} {ffmpeg_input}".strip(),
|
||||
f"{self.ffmpeg_output_args} -movflags +faststart".strip(),
|
||||
f"{self.ffmpeg_output_args} -movflags +faststart {video_path}".strip(),
|
||||
EncodeTypeEnum.timelapse,
|
||||
)
|
||||
).split(" ")
|
||||
else:
|
||||
ffmpeg_cmd = (
|
||||
f"{self.config.ffmpeg.ffmpeg_path} -hide_banner {ffmpeg_input} {codec} -movflags +faststart"
|
||||
f"{self.config.ffmpeg.ffmpeg_path} -hide_banner {ffmpeg_input} {codec} -movflags +faststart {video_path}"
|
||||
).split(" ")
|
||||
|
||||
# add metadata
|
||||
title = f"Frigate Preview for {self.camera}, {self.get_datetime_from_timestamp(self.start_time)} - {self.get_datetime_from_timestamp(self.end_time)}"
|
||||
creation_time = datetime.datetime.fromtimestamp(
|
||||
self.start_time, tz=datetime.timezone.utc
|
||||
).strftime("%Y-%m-%dT%H:%M:%S.%fZ")
|
||||
ffmpeg_cmd.extend(
|
||||
[
|
||||
"-metadata",
|
||||
f"title={title}",
|
||||
"-metadata",
|
||||
f"creation_time={creation_time}",
|
||||
"-metadata",
|
||||
f"comment=Camera: {self.camera}",
|
||||
]
|
||||
)
|
||||
|
||||
ffmpeg_cmd.append(video_path)
|
||||
ffmpeg_cmd.extend(["-metadata", f"title={title}"])
|
||||
|
||||
return ffmpeg_cmd, playlist_lines
|
||||
|
||||
|
||||
@@ -42,8 +42,6 @@ from frigate.util.services import get_video_properties
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
STALE_RECORDINGS_INFO_TTL = MAX_SEGMENTS_IN_CACHE * MAX_SEGMENT_DURATION * 2
|
||||
|
||||
|
||||
class SegmentInfo:
|
||||
def __init__(
|
||||
@@ -303,8 +301,6 @@ class RecordingMaintainer(threading.Thread):
|
||||
RecordingsDataTypeEnum.saved.value,
|
||||
)
|
||||
|
||||
self._expire_stale_recordings_info(grouped_recordings)
|
||||
|
||||
recordings_to_insert: list[Optional[dict[str, Any]]] = await asyncio.gather(
|
||||
*tasks
|
||||
)
|
||||
@@ -315,21 +311,6 @@ class RecordingMaintainer(threading.Thread):
|
||||
[r for r in recordings_to_insert if r is not None],
|
||||
)
|
||||
|
||||
def _expire_stale_recordings_info(
|
||||
self, grouped_recordings: defaultdict[str, list[dict[str, Any]]]
|
||||
) -> None:
|
||||
expire_before = datetime.datetime.now().timestamp() - STALE_RECORDINGS_INFO_TTL
|
||||
for recordings_info in (
|
||||
self.object_recordings_info,
|
||||
self.audio_recordings_info,
|
||||
):
|
||||
for camera in list(recordings_info.keys()):
|
||||
if camera in grouped_recordings:
|
||||
continue
|
||||
info = recordings_info[camera]
|
||||
while info and info[0][0] < expire_before:
|
||||
info.pop(0)
|
||||
|
||||
def drop_segment(self, cache_path: str) -> None:
|
||||
Path(cache_path).unlink(missing_ok=True)
|
||||
self.end_time_cache.pop(cache_path, None)
|
||||
@@ -650,8 +631,6 @@ class RecordingMaintainer(threading.Thread):
|
||||
"copy",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
"-metadata",
|
||||
f"creation_time={start_time.strftime('%Y-%m-%dT%H:%M:%S.%fZ')}",
|
||||
file_path,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
stdout=asyncio.subprocess.DEVNULL,
|
||||
|
||||
@@ -475,55 +475,3 @@ class TestHttpMedia(BaseTestHttp):
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == []
|
||||
|
||||
def test_recordings_unavailable_cameras_all_scopes_to_allowed_cameras(self):
|
||||
"""cameras=all must not error and must only consider allowed cameras.
|
||||
|
||||
allowed_cameras is mocked to ["front_door"]. A back_door recording that
|
||||
would otherwise fill the gap must be ignored, and the request must not
|
||||
500 the way it did when cameras was reassigned to a list.
|
||||
"""
|
||||
with AuthTestClient(self.app) as client:
|
||||
# front_door has a 20s gap (1010-1030).
|
||||
Recordings.insert(
|
||||
id="front_a",
|
||||
path="/media/recordings/front_a.mp4",
|
||||
camera="front_door",
|
||||
start_time=1000,
|
||||
end_time=1010,
|
||||
duration=10,
|
||||
motion=0,
|
||||
).execute()
|
||||
Recordings.insert(
|
||||
id="front_b",
|
||||
path="/media/recordings/front_b.mp4",
|
||||
camera="front_door",
|
||||
start_time=1030,
|
||||
end_time=1040,
|
||||
duration=10,
|
||||
motion=0,
|
||||
).execute()
|
||||
# back_door is not in allowed_cameras; its full-window coverage must
|
||||
# not mask the front_door gap.
|
||||
Recordings.insert(
|
||||
id="back_a",
|
||||
path="/media/recordings/back_a.mp4",
|
||||
camera="back_door",
|
||||
start_time=1000,
|
||||
end_time=1040,
|
||||
duration=40,
|
||||
motion=0,
|
||||
).execute()
|
||||
|
||||
response = client.get(
|
||||
"/recordings/unavailable",
|
||||
params={
|
||||
"after": 1000,
|
||||
"before": 1040,
|
||||
"scale": 5,
|
||||
"cameras": "all",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == [{"start_time": 1010, "end_time": 1030}]
|
||||
|
||||
@@ -115,46 +115,6 @@ class TestMaintainer(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertIsNone(result)
|
||||
maintainer.drop_segment.assert_called_once_with(cache_path)
|
||||
|
||||
async def test_expire_stale_recordings_info_drops_only_absent_cameras(self):
|
||||
config = MagicMock(spec=FrigateConfig)
|
||||
config.cameras = {}
|
||||
stop_event = MagicMock()
|
||||
maintainer = RecordingMaintainer(config, stop_event)
|
||||
|
||||
now = datetime.datetime.now().timestamp()
|
||||
ancient = now - 86400
|
||||
recent = now - 1
|
||||
|
||||
maintainer.object_recordings_info["present_cam"] = [(ancient, [], [], [])]
|
||||
maintainer.audio_recordings_info["present_cam"] = [(ancient, 0, [])]
|
||||
|
||||
maintainer.object_recordings_info["absent_cam"] = [
|
||||
(ancient, [], [], []),
|
||||
(recent, [], [], []),
|
||||
]
|
||||
maintainer.audio_recordings_info["absent_cam"] = [
|
||||
(ancient, 0, []),
|
||||
(recent, 0, []),
|
||||
]
|
||||
|
||||
grouped_recordings = {"present_cam": [{"start_time": ancient}]}
|
||||
|
||||
maintainer._expire_stale_recordings_info(grouped_recordings)
|
||||
|
||||
self.assertEqual(
|
||||
maintainer.object_recordings_info["present_cam"], [(ancient, [], [], [])]
|
||||
)
|
||||
self.assertEqual(
|
||||
maintainer.audio_recordings_info["present_cam"], [(ancient, 0, [])]
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
maintainer.object_recordings_info["absent_cam"], [(recent, [], [], [])]
|
||||
)
|
||||
self.assertEqual(
|
||||
maintainer.audio_recordings_info["absent_cam"], [(recent, 0, [])]
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Validator tests for `motion.source=onvif` interactions with
|
||||
`onvif.events.enabled` and `motion.enabled`. Exercises `verify_motion_and_detect`
|
||||
directly so we don't need the full FrigateConfig path (which mounts /config)."""
|
||||
|
||||
import unittest
|
||||
|
||||
from frigate.config.config import verify_motion_and_detect
|
||||
|
||||
|
||||
class _Dummy:
|
||||
"""Light shim for the nested config attributes the validator reads."""
|
||||
|
||||
def __init__(self, **kw):
|
||||
for k, v in kw.items():
|
||||
setattr(self, k, v)
|
||||
|
||||
|
||||
def _camera(*, name, detect, motion, onvif):
|
||||
return _Dummy(name=name, detect=detect, motion=motion, onvif=onvif)
|
||||
|
||||
|
||||
class TestVerifyMotionAndDetect(unittest.TestCase):
|
||||
def test_internal_motion_with_detect_passes(self):
|
||||
cam = _camera(
|
||||
name="c",
|
||||
detect=_Dummy(enabled=True),
|
||||
motion=_Dummy(enabled=True, source="internal"),
|
||||
onvif=_Dummy(events=_Dummy(enabled=False)),
|
||||
)
|
||||
# No exception.
|
||||
self.assertIsNone(verify_motion_and_detect(cam))
|
||||
|
||||
def test_detect_with_motion_disabled_rejected(self):
|
||||
cam = _camera(
|
||||
name="c",
|
||||
detect=_Dummy(enabled=True),
|
||||
motion=_Dummy(enabled=False, source="internal"),
|
||||
onvif=_Dummy(events=_Dummy(enabled=False)),
|
||||
)
|
||||
with self.assertRaisesRegex(ValueError, "object detection requires motion"):
|
||||
verify_motion_and_detect(cam)
|
||||
|
||||
def test_source_onvif_requires_events_enabled(self):
|
||||
cam = _camera(
|
||||
name="c",
|
||||
detect=_Dummy(enabled=True),
|
||||
motion=_Dummy(enabled=False, source="onvif"),
|
||||
onvif=_Dummy(events=_Dummy(enabled=False)),
|
||||
)
|
||||
with self.assertRaisesRegex(ValueError, "onvif.events.enabled is false"):
|
||||
verify_motion_and_detect(cam)
|
||||
|
||||
def test_source_onvif_with_events_passes_even_with_motion_disabled(self):
|
||||
cam = _camera(
|
||||
name="c",
|
||||
detect=_Dummy(enabled=True),
|
||||
motion=_Dummy(enabled=False, source="onvif"),
|
||||
onvif=_Dummy(events=_Dummy(enabled=True)),
|
||||
)
|
||||
self.assertIsNone(verify_motion_and_detect(cam))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,100 +0,0 @@
|
||||
"""Tests for MQTT command topic callback registration."""
|
||||
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from frigate.comms.mqtt import MqttClient
|
||||
|
||||
|
||||
def _make_camera_mock(
|
||||
*,
|
||||
enabled: bool = True,
|
||||
notifications_enabled_in_config: bool = False,
|
||||
) -> MagicMock:
|
||||
"""Build a camera config mock with the fields _start() reads."""
|
||||
camera = MagicMock()
|
||||
camera.enabled = enabled
|
||||
camera.notifications.enabled_in_config = notifications_enabled_in_config
|
||||
camera.onvif.host = None
|
||||
camera.motion.mask = {}
|
||||
camera.objects.mask = {}
|
||||
camera.zones = {}
|
||||
return camera
|
||||
|
||||
|
||||
def _registered_topics(
|
||||
cameras: dict[str, MagicMock],
|
||||
*,
|
||||
global_notifications_enabled_in_config: bool = False,
|
||||
) -> set[str]:
|
||||
"""Start an MqttClient against a mocked paho client and collect the
|
||||
topics registered via message_callback_add."""
|
||||
config = MagicMock()
|
||||
config.cameras = cameras
|
||||
config.notifications.enabled_in_config = global_notifications_enabled_in_config
|
||||
config.mqtt.topic_prefix = "frigate"
|
||||
config.mqtt.client_id = "frigate"
|
||||
config.mqtt.user = None
|
||||
config.mqtt.tls_ca_certs = None
|
||||
config.mqtt.tls_insecure = None
|
||||
|
||||
with patch("frigate.comms.mqtt.mqtt.Client") as client_cls:
|
||||
mqtt_client = MqttClient(config)
|
||||
mqtt_client.subscribe(MagicMock())
|
||||
|
||||
paho_client = client_cls.return_value
|
||||
return {call.args[0] for call in paho_client.message_callback_add.call_args_list}
|
||||
|
||||
|
||||
class TestMqttTopicRegistration(unittest.TestCase):
|
||||
def test_camera_notification_topics_registered(self):
|
||||
"""Per-camera notification set/suspend must be registered so paho
|
||||
routes them to the dispatcher (unregistered topics drop silently)."""
|
||||
topics = _registered_topics(
|
||||
{"front_door": _make_camera_mock(notifications_enabled_in_config=True)}
|
||||
)
|
||||
|
||||
self.assertIn("frigate/front_door/notifications/set", topics)
|
||||
self.assertIn("frigate/front_door/notifications/suspend", topics)
|
||||
|
||||
def test_global_set_registered_with_camera_only_notifications(self):
|
||||
"""The global topic must work when notifications are enabled only at
|
||||
the camera level, matching the WebPushClient gating in app.py."""
|
||||
topics = _registered_topics(
|
||||
{"front_door": _make_camera_mock(notifications_enabled_in_config=True)},
|
||||
global_notifications_enabled_in_config=False,
|
||||
)
|
||||
|
||||
self.assertIn("frigate/notifications/set", topics)
|
||||
|
||||
def test_global_set_registered_with_global_notifications(self):
|
||||
topics = _registered_topics(
|
||||
{"front_door": _make_camera_mock()},
|
||||
global_notifications_enabled_in_config=True,
|
||||
)
|
||||
|
||||
self.assertIn("frigate/notifications/set", topics)
|
||||
|
||||
def test_global_set_not_registered_when_notifications_unconfigured(self):
|
||||
topics = _registered_topics(
|
||||
{"front_door": _make_camera_mock()},
|
||||
global_notifications_enabled_in_config=False,
|
||||
)
|
||||
|
||||
self.assertNotIn("frigate/notifications/set", topics)
|
||||
|
||||
def test_disabled_camera_does_not_enable_global_set(self):
|
||||
topics = _registered_topics(
|
||||
{
|
||||
"front_door": _make_camera_mock(
|
||||
enabled=False, notifications_enabled_in_config=True
|
||||
)
|
||||
},
|
||||
global_notifications_enabled_in_config=False,
|
||||
)
|
||||
|
||||
self.assertNotIn("frigate/notifications/set", topics)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,22 +1,6 @@
|
||||
import random
|
||||
import unittest
|
||||
|
||||
import numpy as np
|
||||
|
||||
from frigate.track.tracked_object import TrackedObjectAttribute
|
||||
from frigate.util.object import average_boxes
|
||||
|
||||
|
||||
class TestBoxStatistics(unittest.TestCase):
|
||||
def test_average_boxes_matches_numpy(self) -> None:
|
||||
rng = random.Random(0)
|
||||
for _ in range(5000):
|
||||
boxes = [
|
||||
[rng.randint(0, 4000) for _ in range(4)]
|
||||
for _ in range(rng.randint(1, 10))
|
||||
]
|
||||
expected = [float(np.mean([b[i] for b in boxes])) for i in range(4)]
|
||||
self.assertEqual(average_boxes(boxes), expected)
|
||||
|
||||
|
||||
class TestAttribute(unittest.TestCase):
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
"""Unit tests for the ONVIF analytics metadata decoder + cell→box mapper."""
|
||||
|
||||
import base64
|
||||
import unittest
|
||||
|
||||
import numpy as np
|
||||
|
||||
from frigate.ptz.onvif_metadata import (
|
||||
_cells_to_boxes,
|
||||
_decode_cells,
|
||||
_extract_cells_from_doc,
|
||||
_packbits_decode,
|
||||
)
|
||||
|
||||
|
||||
class TestPackBits(unittest.TestCase):
|
||||
def test_spec_example(self):
|
||||
# ONVIF Analytics Annex B worked example:
|
||||
# raw = ff ff ff f0 f0 f0
|
||||
# packed (PackBits) = fe ff fe f0
|
||||
packed = bytes.fromhex("feff fef0".replace(" ", ""))
|
||||
self.assertEqual(
|
||||
_packbits_decode(packed),
|
||||
bytes.fromhex("ffff fff0 f0f0".replace(" ", "")),
|
||||
)
|
||||
|
||||
def test_idle_frame_from_live_camera(self):
|
||||
# `zwA=` is a representative idle-frame payload from a 22×18 grid:
|
||||
# 396 bits → 50 bytes after byte-padding; PackBits compresses 50
|
||||
# zeros to `cf 00` → base64 `zwA=`.
|
||||
packed = base64.b64decode("zwA=")
|
||||
self.assertEqual(packed, bytes.fromhex("cf 00".replace(" ", "")))
|
||||
raw = _packbits_decode(packed)
|
||||
self.assertEqual(len(raw), 50)
|
||||
self.assertEqual(raw, b"\x00" * 50)
|
||||
|
||||
def test_literal_run(self):
|
||||
# Header 0x03 → 4 literal bytes follow.
|
||||
self.assertEqual(_packbits_decode(b"\x03ABCD"), b"ABCD")
|
||||
|
||||
def test_noop_header(self):
|
||||
# 0x80 is no-op per spec; literal after should still decode normally.
|
||||
# \x80 → no-op; \x02 → literal of 3 bytes follows; "ABC" copied.
|
||||
self.assertEqual(_packbits_decode(b"\x80\x02ABC"), b"ABC")
|
||||
|
||||
|
||||
class TestDecodeCells(unittest.TestCase):
|
||||
def test_idle(self):
|
||||
cells = _decode_cells("zwA=", 22, 18)
|
||||
self.assertIsNotNone(cells)
|
||||
self.assertEqual(cells.shape, (18, 22))
|
||||
self.assertEqual(int(cells.sum()), 0)
|
||||
|
||||
def test_invalid_base64(self):
|
||||
self.assertIsNone(_decode_cells("not-base64!@", 22, 18))
|
||||
|
||||
def test_short_payload_returns_none(self):
|
||||
# `cf 00` decodes to 50 bytes; ask for 100×100 grid (1250 bytes
|
||||
# needed) → expect None.
|
||||
self.assertIsNone(_decode_cells("zwA=", 100, 100))
|
||||
|
||||
def test_top_left_active(self):
|
||||
# Build a 22×18 grid with only cell (0,0) active. Raw bitmap byte 0
|
||||
# = 0x80 (MSB set), bytes 1..49 = 0x00. PackBits of that 50-byte
|
||||
# sequence: literal-1-byte (header 0x00) of 0x80, then replicate of
|
||||
# 49 zeros (header 257-49=208=0xD0, byte 0x00).
|
||||
packed = bytes([0x00, 0x80, 0xD0, 0x00])
|
||||
b64 = base64.b64encode(packed).decode()
|
||||
cells = _decode_cells(b64, 22, 18)
|
||||
self.assertIsNotNone(cells)
|
||||
self.assertEqual(int(cells[0, 0]), 1)
|
||||
self.assertEqual(int(cells.sum()), 1)
|
||||
|
||||
|
||||
class TestCellsToBoxes(unittest.TestCase):
|
||||
"""Verify the cell-grid → detect-frame pixel mapping using a representative
|
||||
CellLayout (22x18, Translate(-1,-1), Scale(2/22, 2/18))."""
|
||||
|
||||
LAYOUT = (22, 18, (-1.0, -1.0), (2.0 / 22, 2.0 / 18))
|
||||
DETECT = (1280, 720)
|
||||
|
||||
def test_empty(self):
|
||||
cells = np.zeros((18, 22), dtype=np.uint8)
|
||||
self.assertEqual(_cells_to_boxes(cells, self.LAYOUT, self.DETECT), [])
|
||||
|
||||
def test_top_left_cell(self):
|
||||
cells = np.zeros((18, 22), dtype=np.uint8)
|
||||
cells[0, 0] = 1
|
||||
boxes = _cells_to_boxes(cells, self.LAYOUT, self.DETECT)
|
||||
self.assertEqual(len(boxes), 1)
|
||||
x1, y1, x2, y2 = boxes[0]
|
||||
# Cell (0,0) covers normalized [-1, -1+2/22] × [-1, -1+2/18]
|
||||
# → detect px [0, 1280/22] × [0, 720/18] = [0, ~58] × [0, 40]
|
||||
self.assertEqual(x1, 0)
|
||||
self.assertEqual(y1, 0)
|
||||
self.assertAlmostEqual(x2, round(1280 / 22), delta=2)
|
||||
self.assertAlmostEqual(y2, round(720 / 18), delta=2)
|
||||
|
||||
def test_bottom_right_cell(self):
|
||||
cells = np.zeros((18, 22), dtype=np.uint8)
|
||||
cells[17, 21] = 1
|
||||
boxes = _cells_to_boxes(cells, self.LAYOUT, self.DETECT)
|
||||
self.assertEqual(len(boxes), 1)
|
||||
x1, y1, x2, y2 = boxes[0]
|
||||
# Bottom-right edge clamps to detect_size - 1.
|
||||
self.assertEqual(x2, self.DETECT[0] - 1)
|
||||
self.assertEqual(y2, self.DETECT[1] - 1)
|
||||
self.assertAlmostEqual(x1, round(21 * 1280 / 22), delta=2)
|
||||
self.assertAlmostEqual(y1, round(17 * 720 / 18), delta=2)
|
||||
|
||||
def test_two_separated_regions(self):
|
||||
cells = np.zeros((18, 22), dtype=np.uint8)
|
||||
# Region A: top-left 2×2 block
|
||||
cells[0:2, 0:2] = 1
|
||||
# Region B: bottom-right 2×2 block (separated by inactive cells)
|
||||
cells[15:17, 18:20] = 1
|
||||
boxes = _cells_to_boxes(cells, self.LAYOUT, self.DETECT)
|
||||
self.assertEqual(len(boxes), 2)
|
||||
|
||||
|
||||
class TestExtractCellsFromDoc(unittest.TestCase):
|
||||
def test_typical_frame(self):
|
||||
doc = (
|
||||
b'<tt:MetadataStream xmlns:tt="http://www.onvif.org/ver10/schema">'
|
||||
b"<tt:VideoAnalytics>"
|
||||
b'<tt:Frame UtcTime="2026-05-29T14:12:20Z">'
|
||||
b"<tt:Extension>"
|
||||
b'<tt:MotionInCells Columns="22" Rows="18" Cells="zwA="/>'
|
||||
b"</tt:Extension></tt:Frame></tt:VideoAnalytics></tt:MetadataStream>"
|
||||
)
|
||||
cells_b64, cols, rows = _extract_cells_from_doc(doc)
|
||||
self.assertEqual(cells_b64, "zwA=")
|
||||
self.assertEqual(cols, 22)
|
||||
self.assertEqual(rows, 18)
|
||||
|
||||
def test_malformed_xml(self):
|
||||
self.assertEqual(
|
||||
_extract_cells_from_doc(b"not-xml"),
|
||||
(None, 0, 0),
|
||||
)
|
||||
|
||||
def test_doc_without_motioncells(self):
|
||||
doc = (
|
||||
b'<tt:MetadataStream xmlns:tt="http://www.onvif.org/ver10/schema">'
|
||||
b"<tt:VideoAnalytics><tt:Frame/></tt:VideoAnalytics>"
|
||||
b"</tt:MetadataStream>"
|
||||
)
|
||||
self.assertEqual(_extract_cells_from_doc(doc), (None, 0, 0))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Unit tests for the ONVIF PullPoint motion-state parser."""
|
||||
|
||||
import unittest
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
from frigate.ptz.onvif_events import _parse_motion_state
|
||||
|
||||
|
||||
class FakeMessage:
|
||||
"""Mimic the zeep NotificationMessage shape: a Message attribute holding
|
||||
an object whose `_value_1` is an lxml/etree element."""
|
||||
|
||||
class _Body:
|
||||
def __init__(self, element):
|
||||
self._value_1 = element
|
||||
|
||||
def __init__(self, xml: str):
|
||||
self.Message = self._Body(ET.fromstring(xml))
|
||||
|
||||
|
||||
_NS = 'xmlns:tt="http://www.onvif.org/ver10/schema"'
|
||||
|
||||
|
||||
def _build_msg(name: str, value: str) -> FakeMessage:
|
||||
xml = (
|
||||
f"<tt:Message {_NS}>"
|
||||
"<tt:Source>"
|
||||
'<tt:SimpleItem Name="Source" Value="VideoSourceToken"/>'
|
||||
"</tt:Source>"
|
||||
"<tt:Data>"
|
||||
f'<tt:SimpleItem Name="{name}" Value="{value}"/>'
|
||||
"</tt:Data>"
|
||||
"</tt:Message>"
|
||||
)
|
||||
return FakeMessage(xml)
|
||||
|
||||
|
||||
class TestParseMotionState(unittest.TestCase):
|
||||
def test_is_motion_true(self):
|
||||
self.assertTrue(_parse_motion_state(_build_msg("IsMotion", "true")))
|
||||
|
||||
def test_is_motion_false(self):
|
||||
self.assertFalse(_parse_motion_state(_build_msg("IsMotion", "false")))
|
||||
|
||||
def test_legacy_state_topic_name(self):
|
||||
# The legacy tns1:VideoSource/MotionAlarm payload uses "State" instead
|
||||
# of the spec-compliant "IsMotion"; we accept either.
|
||||
self.assertTrue(_parse_motion_state(_build_msg("State", "true")))
|
||||
self.assertFalse(_parse_motion_state(_build_msg("State", "false")))
|
||||
|
||||
def test_boolean_aliases(self):
|
||||
self.assertTrue(_parse_motion_state(_build_msg("IsMotion", "1")))
|
||||
self.assertFalse(_parse_motion_state(_build_msg("IsMotion", "0")))
|
||||
|
||||
def test_no_state_returns_none(self):
|
||||
# Missing the State/IsMotion SimpleItem.
|
||||
xml = (
|
||||
f"<tt:Message {_NS}>"
|
||||
'<tt:Data><tt:SimpleItem Name="Other" Value="yes"/></tt:Data>'
|
||||
"</tt:Message>"
|
||||
)
|
||||
self.assertIsNone(_parse_motion_state(FakeMessage(xml)))
|
||||
|
||||
def test_no_message_returns_none(self):
|
||||
class Empty:
|
||||
pass
|
||||
|
||||
self.assertIsNone(_parse_motion_state(Empty()))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,39 +0,0 @@
|
||||
"""Tests for stationary object classification thresholds."""
|
||||
|
||||
import unittest
|
||||
|
||||
from frigate.track.stationary_classifier import (
|
||||
DEFAULT_OBJECT_THRESHOLDS,
|
||||
DYNAMIC_OBJECT_THRESHOLDS,
|
||||
NON_STATIONARY_OBJECT_THRESHOLDS,
|
||||
STATIONARY_OBJECT_THRESHOLDS,
|
||||
StationaryThresholds,
|
||||
get_stationary_threshold,
|
||||
)
|
||||
|
||||
|
||||
class TestStationaryThresholds(unittest.TestCase):
|
||||
def test_known_labels_return_expected_singletons(self) -> None:
|
||||
self.assertIs(get_stationary_threshold("package"), STATIONARY_OBJECT_THRESHOLDS)
|
||||
self.assertIs(get_stationary_threshold("car"), DYNAMIC_OBJECT_THRESHOLDS)
|
||||
self.assertIs(
|
||||
get_stationary_threshold("license_plate"),
|
||||
NON_STATIONARY_OBJECT_THRESHOLDS,
|
||||
)
|
||||
|
||||
def test_unknown_label_returns_shared_default(self) -> None:
|
||||
# an unknown label must reuse the shared default instance, not allocate
|
||||
# a fresh one on every call (this runs per object per frame)
|
||||
first = get_stationary_threshold("person")
|
||||
second = get_stationary_threshold("dog")
|
||||
self.assertIs(first, DEFAULT_OBJECT_THRESHOLDS)
|
||||
self.assertIs(second, DEFAULT_OBJECT_THRESHOLDS)
|
||||
|
||||
def test_default_matches_a_fresh_instance(self) -> None:
|
||||
# the shared default must be value-equivalent to the previous
|
||||
# per-call StationaryThresholds()
|
||||
self.assertEqual(DEFAULT_OBJECT_THRESHOLDS, StationaryThresholds())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,183 +0,0 @@
|
||||
"""Test in-place yaml config updates."""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from ruamel.yaml import YAML
|
||||
|
||||
from frigate.util.builtin import update_yaml_file_bulk
|
||||
|
||||
|
||||
class TestUpdateYaml(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.yaml = YAML()
|
||||
fd, self.config_path = tempfile.mkstemp(suffix=".yml")
|
||||
os.close(fd)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
os.unlink(self.config_path)
|
||||
|
||||
def _write(self, text: str) -> None:
|
||||
with open(self.config_path, "w") as f:
|
||||
f.write(text)
|
||||
|
||||
def _read(self) -> str:
|
||||
with open(self.config_path) as f:
|
||||
return f.read()
|
||||
|
||||
def _load(self):
|
||||
with open(self.config_path) as f:
|
||||
return self.yaml.load(f)
|
||||
|
||||
def test_delete_key(self):
|
||||
"""Deleting a key removes it and leaves valid yaml."""
|
||||
self._write(
|
||||
"cameras:\n"
|
||||
" cam1:\n"
|
||||
" objects:\n"
|
||||
" filters:\n"
|
||||
" car:\n"
|
||||
" mask: 0,0.45,0.245,0.45\n"
|
||||
)
|
||||
update_yaml_file_bulk(
|
||||
self.config_path, {"cameras.cam1.objects.filters.car.mask": ""}
|
||||
)
|
||||
data = self._load()
|
||||
assert "mask" not in data["cameras"]["cam1"]["objects"]["filters"]["car"]
|
||||
|
||||
def test_delete_commented_key_emptying_map(self):
|
||||
"""Deleting the only key of a map whose key carries comments must not
|
||||
emit unparseable yaml (orphaned comment tokens above a flow-style {})."""
|
||||
self._write(
|
||||
"cameras:\n"
|
||||
" cam1:\n"
|
||||
" objects:\n"
|
||||
" filters:\n"
|
||||
" car:\n"
|
||||
" # cars parked across the street\n"
|
||||
" # second comment line\n"
|
||||
" mask: 0,0.45,0.245,0.45\n"
|
||||
" motion:\n"
|
||||
" mask: 0,0.449,0.686,0.395\n"
|
||||
)
|
||||
update_yaml_file_bulk(
|
||||
self.config_path, {"cameras.cam1.objects.filters.car.mask": ""}
|
||||
)
|
||||
# must re-parse cleanly
|
||||
data = self._load()
|
||||
assert "mask" not in data["cameras"]["cam1"]["objects"]["filters"]["car"]
|
||||
assert data["cameras"]["cam1"]["motion"]["mask"] == "0,0.449,0.686,0.395"
|
||||
# the orphaned comments must be gone from the file, not just parseable
|
||||
content = self._read()
|
||||
assert "cars parked across the street" not in content
|
||||
assert "second comment line" not in content
|
||||
|
||||
def test_delete_last_named_mask_emptying_map(self):
|
||||
"""The path the current UI actually sends: a named object mask deleted
|
||||
down to an empty `mask` map, with a comment inside that map."""
|
||||
self._write(
|
||||
"cameras:\n"
|
||||
" cam1:\n"
|
||||
" objects:\n"
|
||||
" filters:\n"
|
||||
" car:\n"
|
||||
" mask:\n"
|
||||
" # ignore the neighbor's driveway\n"
|
||||
" driveway:\n"
|
||||
" coordinates: 0,0.1,0.2,0.3\n"
|
||||
)
|
||||
update_yaml_file_bulk(
|
||||
self.config_path,
|
||||
{"cameras.cam1.objects.filters.car.mask.driveway": ""},
|
||||
)
|
||||
data = self._load()
|
||||
assert data["cameras"]["cam1"]["objects"]["filters"]["car"]["mask"] == {}
|
||||
assert "ignore the neighbor's driveway" not in self._read()
|
||||
|
||||
def test_delete_last_commented_list_item(self):
|
||||
"""Deleting the last element of a commented sequence must not emit
|
||||
an orphaned comment above a flow-style [] at column 0."""
|
||||
self._write(
|
||||
"cameras:\n"
|
||||
" cam1:\n"
|
||||
" motion:\n"
|
||||
" mask:\n"
|
||||
" # driveway motion mask\n"
|
||||
" - 0,0.4,0.6,0.4\n"
|
||||
)
|
||||
update_yaml_file_bulk(self.config_path, {"cameras.cam1.motion.mask.0": ""})
|
||||
data = self._load()
|
||||
assert data["cameras"]["cam1"]["motion"]["mask"] == []
|
||||
assert "driveway motion mask" not in self._read()
|
||||
|
||||
def test_delete_list_item_preserves_remaining(self):
|
||||
"""Deleting one element of a sequence keeps the others and stays valid."""
|
||||
self._write(
|
||||
"cameras:\n"
|
||||
" cam1:\n"
|
||||
" motion:\n"
|
||||
" mask:\n"
|
||||
" - 0,0.4,0.6,0.4\n"
|
||||
" - 0,0.1,0.2,0.3\n"
|
||||
)
|
||||
update_yaml_file_bulk(self.config_path, {"cameras.cam1.motion.mask.0": ""})
|
||||
data = self._load()
|
||||
assert data["cameras"]["cam1"]["motion"]["mask"] == ["0,0.1,0.2,0.3"]
|
||||
|
||||
def test_delete_key_preserves_siblings(self):
|
||||
"""Deleting one key among several keeps the sibling entries and any
|
||||
comments on keys preceding the deleted one."""
|
||||
self._write(
|
||||
"cameras:\n"
|
||||
" cam1:\n"
|
||||
" objects:\n"
|
||||
" filters:\n"
|
||||
" car:\n"
|
||||
" # mask drawn around the parked suv\n"
|
||||
" mask: 0,0.45,0.245,0.45\n"
|
||||
" threshold: 0.8\n"
|
||||
)
|
||||
update_yaml_file_bulk(
|
||||
self.config_path, {"cameras.cam1.objects.filters.car.threshold": ""}
|
||||
)
|
||||
data = self._load()
|
||||
car = data["cameras"]["cam1"]["objects"]["filters"]["car"]
|
||||
assert "threshold" not in car
|
||||
assert car["mask"] == "0,0.45,0.245,0.45"
|
||||
assert "# mask drawn around the parked suv" in self._read()
|
||||
|
||||
def test_delete_first_commented_key_keeps_map_valid(self):
|
||||
"""Deleting a commented key from a map that still has other keys
|
||||
leaves the remaining entries intact and the file parseable."""
|
||||
self._write(
|
||||
"cameras:\n"
|
||||
" cam1:\n"
|
||||
" objects:\n"
|
||||
" filters:\n"
|
||||
" car:\n"
|
||||
" # comment on the deleted key\n"
|
||||
" mask: 0,0.45,0.245,0.45\n"
|
||||
" threshold: 0.8\n"
|
||||
)
|
||||
update_yaml_file_bulk(
|
||||
self.config_path, {"cameras.cam1.objects.filters.car.mask": ""}
|
||||
)
|
||||
data = self._load()
|
||||
car = data["cameras"]["cam1"]["objects"]["filters"]["car"]
|
||||
assert "mask" not in car
|
||||
assert car["threshold"] == 0.8
|
||||
|
||||
def test_update_value_preserves_comments(self):
|
||||
"""Updating a value keeps surrounding comments intact."""
|
||||
self._write(
|
||||
"cameras:\n cam1:\n detect:\n # tuned for the pi\n fps: 4\n"
|
||||
)
|
||||
update_yaml_file_bulk(self.config_path, {"cameras.cam1.detect.fps": 5})
|
||||
data = self._load()
|
||||
assert data["cameras"]["cam1"]["detect"]["fps"] == 5
|
||||
assert "# tuned for the pi" in self._read()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
@@ -82,24 +82,6 @@ class TestRegion(unittest.TestCase):
|
||||
|
||||
assert len(cluster_candidates) == 2
|
||||
|
||||
def test_cluster_candidates_partition_boxes(self):
|
||||
# every box index must appear in exactly one cluster (no box used twice,
|
||||
# none dropped) - the invariant the used-box tracking enforces
|
||||
boxes = [
|
||||
(100, 100, 200, 200),
|
||||
(202, 150, 252, 200),
|
||||
(210, 160, 260, 210),
|
||||
(900, 900, 950, 950),
|
||||
(905, 905, 955, 955),
|
||||
]
|
||||
|
||||
cluster_candidates = get_cluster_candidates(
|
||||
self.frame_shape, self.min_region_size, boxes
|
||||
)
|
||||
|
||||
assigned = [idx for cluster in cluster_candidates for idx in cluster]
|
||||
self.assertEqual(sorted(assigned), list(range(len(boxes))))
|
||||
|
||||
def test_transliterate_to_latin(self):
|
||||
self.assertEqual(transliterate_to_latin("frégate"), "fregate")
|
||||
self.assertEqual(transliterate_to_latin("utilité"), "utilite")
|
||||
|
||||
@@ -11,16 +11,6 @@ class TestCheckWsAuthorization(unittest.TestCase):
|
||||
|
||||
DEFAULT_SEPARATOR = ","
|
||||
|
||||
# admin/viewer are reserved and always map to all cameras (empty list);
|
||||
# custom roles map to a specific set of cameras.
|
||||
ROLES_CONFIG = {
|
||||
"admin": [],
|
||||
"viewer": [],
|
||||
"yard": ["front_door", "backyard"],
|
||||
"garage_only": ["garage"],
|
||||
}
|
||||
CAMERA_NAMES = {"front_door", "backyard", "garage"}
|
||||
|
||||
# --- IPC topic blocking (unconditional, regardless of role) ---
|
||||
|
||||
def test_ipc_topic_blocked_for_admin(self):
|
||||
@@ -171,124 +161,6 @@ class TestCheckWsAuthorization(unittest.TestCase):
|
||||
_check_ws_authorization("onConnect", None, self.DEFAULT_SEPARATOR)
|
||||
)
|
||||
|
||||
# --- Camera-scoped PTZ access (non-admin with camera access) ---
|
||||
|
||||
def test_viewer_can_ptz_camera_with_access(self):
|
||||
# viewer maps to all cameras, so PTZ is allowed
|
||||
self.assertTrue(
|
||||
_check_ws_authorization(
|
||||
"front_door/ptz",
|
||||
"viewer",
|
||||
self.DEFAULT_SEPARATOR,
|
||||
self.ROLES_CONFIG,
|
||||
self.CAMERA_NAMES,
|
||||
)
|
||||
)
|
||||
|
||||
def test_custom_role_can_ptz_assigned_camera(self):
|
||||
self.assertTrue(
|
||||
_check_ws_authorization(
|
||||
"front_door/ptz",
|
||||
"yard",
|
||||
self.DEFAULT_SEPARATOR,
|
||||
self.ROLES_CONFIG,
|
||||
self.CAMERA_NAMES,
|
||||
)
|
||||
)
|
||||
|
||||
def test_custom_role_blocked_from_ptz_unassigned_camera(self):
|
||||
self.assertFalse(
|
||||
_check_ws_authorization(
|
||||
"garage/ptz",
|
||||
"yard",
|
||||
self.DEFAULT_SEPARATOR,
|
||||
self.ROLES_CONFIG,
|
||||
self.CAMERA_NAMES,
|
||||
)
|
||||
)
|
||||
|
||||
def test_multiple_roles_union_camera_access_for_ptz(self):
|
||||
# "yard" covers front_door/backyard, "garage_only" covers garage
|
||||
self.assertTrue(
|
||||
_check_ws_authorization(
|
||||
"garage/ptz",
|
||||
"yard,garage_only",
|
||||
self.DEFAULT_SEPARATOR,
|
||||
self.ROLES_CONFIG,
|
||||
self.CAMERA_NAMES,
|
||||
)
|
||||
)
|
||||
|
||||
def test_unknown_role_blocked_from_ptz(self):
|
||||
self.assertFalse(
|
||||
_check_ws_authorization(
|
||||
"front_door/ptz",
|
||||
"nonexistent",
|
||||
self.DEFAULT_SEPARATOR,
|
||||
self.ROLES_CONFIG,
|
||||
self.CAMERA_NAMES,
|
||||
)
|
||||
)
|
||||
|
||||
def test_no_role_header_treated_as_viewer_for_ptz(self):
|
||||
# proxy-only / auth-disabled setups default to the viewer role
|
||||
self.assertTrue(
|
||||
_check_ws_authorization(
|
||||
"front_door/ptz",
|
||||
None,
|
||||
self.DEFAULT_SEPARATOR,
|
||||
self.ROLES_CONFIG,
|
||||
self.CAMERA_NAMES,
|
||||
)
|
||||
)
|
||||
|
||||
def test_camera_access_does_not_grant_set_commands(self):
|
||||
# camera access enables PTZ only, not config-changing "set" commands
|
||||
self.assertFalse(
|
||||
_check_ws_authorization(
|
||||
"front_door/detect/set",
|
||||
"yard",
|
||||
self.DEFAULT_SEPARATOR,
|
||||
self.ROLES_CONFIG,
|
||||
self.CAMERA_NAMES,
|
||||
)
|
||||
)
|
||||
|
||||
def test_ptz_autotracker_stays_admin_only(self):
|
||||
# ptz_autotracker is a config toggle, not a live-view action
|
||||
self.assertFalse(
|
||||
_check_ws_authorization(
|
||||
"front_door/ptz_autotracker/set",
|
||||
"viewer",
|
||||
self.DEFAULT_SEPARATOR,
|
||||
self.ROLES_CONFIG,
|
||||
self.CAMERA_NAMES,
|
||||
)
|
||||
)
|
||||
|
||||
def test_admin_can_ptz_any_camera_with_config(self):
|
||||
self.assertTrue(
|
||||
_check_ws_authorization(
|
||||
"garage/ptz",
|
||||
"admin",
|
||||
self.DEFAULT_SEPARATOR,
|
||||
self.ROLES_CONFIG,
|
||||
self.CAMERA_NAMES,
|
||||
)
|
||||
)
|
||||
|
||||
def test_ipc_topic_still_blocked_with_camera_access(self):
|
||||
# IPC topics are blocked unconditionally, even with camera access
|
||||
self.assertFalse(
|
||||
_check_ws_authorization(
|
||||
UPDATE_CAMERA_ACTIVITY,
|
||||
"viewer",
|
||||
self.DEFAULT_SEPARATOR,
|
||||
self.ROLES_CONFIG,
|
||||
self.CAMERA_NAMES,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -641,11 +641,9 @@ class NorfairTracker(ObjectTracker):
|
||||
self.deregister(self.track_id_map[e_id], e_id)
|
||||
|
||||
# update list of object boxes that don't have a tracked object yet
|
||||
tracked_object_boxes = {
|
||||
tuple(obj["box"]) for obj in self.tracked_objects.values()
|
||||
}
|
||||
tracked_object_boxes = [obj["box"] for obj in self.tracked_objects.values()]
|
||||
self.untracked_object_boxes = [
|
||||
o[2] for o in detections if tuple(o[2]) not in tracked_object_boxes
|
||||
o[2] for o in detections if o[2] not in tracked_object_boxes
|
||||
]
|
||||
|
||||
def print_objects_as_table(self, tracked_objects: Sequence) -> None:
|
||||
|
||||
@@ -63,9 +63,6 @@ NON_STATIONARY_OBJECT_THRESHOLDS = StationaryThresholds(
|
||||
max_stationary_history=4,
|
||||
)
|
||||
|
||||
# Default thresholds for any other object label
|
||||
DEFAULT_OBJECT_THRESHOLDS = StationaryThresholds()
|
||||
|
||||
|
||||
def get_stationary_threshold(label: str) -> StationaryThresholds:
|
||||
"""Get the stationary thresholds for a given object label."""
|
||||
@@ -79,7 +76,7 @@ def get_stationary_threshold(label: str) -> StationaryThresholds:
|
||||
if label in NON_STATIONARY_OBJECT_THRESHOLDS.objects:
|
||||
return NON_STATIONARY_OBJECT_THRESHOLDS
|
||||
|
||||
return DEFAULT_OBJECT_THRESHOLDS
|
||||
return StationaryThresholds()
|
||||
|
||||
|
||||
class StationaryMotionClassifier:
|
||||
|
||||
@@ -293,51 +293,26 @@ def update_yaml_file_bulk(file_path: str, updates: Dict[str, Any]):
|
||||
logger.error(f"Unable to write to Frigate config file {file_path}: {e}")
|
||||
|
||||
|
||||
def clear_orphaned_comments(collection, parent, parent_key) -> None:
|
||||
"""Drop stale ruamel comment tokens after a deletion empties a collection.
|
||||
|
||||
When the last entry of a mapping or sequence is removed, any comments that
|
||||
lived inside that collection's block are orphaned. ruamel then emits them
|
||||
above a flow-style `{}`/`[]` dedented to column 0, which is unparseable and
|
||||
corrupts the config. Clearing the emptied collection's own comment metadata
|
||||
(and the parent's entry pointing at it) keeps the dump valid. Non-empty
|
||||
collections are left untouched so comments on remaining siblings survive.
|
||||
"""
|
||||
if not hasattr(collection, "ca") or len(collection) != 0:
|
||||
return
|
||||
|
||||
collection.ca.items.clear()
|
||||
collection.ca.comment = None
|
||||
if parent is not None and hasattr(parent, "ca"):
|
||||
parent.ca.items.pop(parent_key, None)
|
||||
|
||||
|
||||
def update_yaml(data, key_path, new_value):
|
||||
temp = data
|
||||
parent = None
|
||||
parent_key = None
|
||||
for key in key_path[:-1]:
|
||||
if isinstance(key, tuple):
|
||||
if key[0] not in temp:
|
||||
temp[key[0]] = [{}] * max(1, key[1] + 1)
|
||||
elif len(temp[key[0]]) <= key[1]:
|
||||
temp[key[0]] += [{}] * (key[1] - len(temp[key[0]]) + 1)
|
||||
parent, parent_key = temp[key[0]], key[1]
|
||||
temp = temp[key[0]][key[1]]
|
||||
else:
|
||||
if key not in temp or temp[key] is None:
|
||||
temp[key] = {}
|
||||
parent, parent_key = temp, key
|
||||
temp = temp[key]
|
||||
|
||||
last_key = key_path[-1]
|
||||
if new_value == "":
|
||||
if isinstance(last_key, tuple):
|
||||
del temp[last_key[0]][last_key[1]]
|
||||
clear_orphaned_comments(temp[last_key[0]], temp, last_key[0])
|
||||
else:
|
||||
del temp[last_key]
|
||||
clear_orphaned_comments(temp, parent, parent_key)
|
||||
else:
|
||||
if isinstance(last_key, tuple):
|
||||
if last_key[0] not in temp:
|
||||
|
||||
+19
-15
@@ -339,13 +339,18 @@ def reduce_boxes(boxes, iou_threshold=0.0):
|
||||
|
||||
def average_boxes(boxes: list[list[int, int, int, int]]) -> list[int, int, int, int]:
|
||||
"""Return a box that is the average of a list of boxes."""
|
||||
n = len(boxes)
|
||||
return [
|
||||
sum(box[0] for box in boxes) / n,
|
||||
sum(box[1] for box in boxes) / n,
|
||||
sum(box[2] for box in boxes) / n,
|
||||
sum(box[3] for box in boxes) / n,
|
||||
]
|
||||
x_mins = []
|
||||
y_mins = []
|
||||
x_max = []
|
||||
y_max = []
|
||||
|
||||
for box in boxes:
|
||||
x_mins.append(box[0])
|
||||
y_mins.append(box[1])
|
||||
x_max.append(box[2])
|
||||
y_max.append(box[3])
|
||||
|
||||
return [np.mean(x_mins), np.mean(y_mins), np.mean(x_max), np.mean(y_max)]
|
||||
|
||||
|
||||
def median_of_boxes(boxes: list[list[int, int, int, int]]) -> list[int, int, int, int]:
|
||||
@@ -396,13 +401,13 @@ def get_cluster_candidates(frame_shape, min_region, boxes):
|
||||
# determined by the max_region size minus half the box + 20%
|
||||
# TODO: see if we can do this with numpy
|
||||
cluster_candidates = []
|
||||
used_boxes = set()
|
||||
used_boxes = []
|
||||
# loop over each box
|
||||
for current_index, b in enumerate(boxes):
|
||||
if current_index in used_boxes:
|
||||
continue
|
||||
cluster = [current_index]
|
||||
used_boxes.add(current_index)
|
||||
used_boxes.append(current_index)
|
||||
cluster_boundary = get_cluster_boundary(b, min_region)
|
||||
# find all other boxes that fit inside the boundary
|
||||
for compare_index, compare_box in enumerate(boxes):
|
||||
@@ -431,7 +436,7 @@ def get_cluster_candidates(frame_shape, min_region, boxes):
|
||||
|
||||
if should_cluster:
|
||||
cluster.append(compare_index)
|
||||
used_boxes.add(compare_index)
|
||||
used_boxes.append(compare_index)
|
||||
cluster_candidates.append(cluster)
|
||||
|
||||
# return the unique clusters only
|
||||
@@ -553,7 +558,6 @@ def reduce_detections(
|
||||
current_detection = sorted_by_area[current_detection_idx]
|
||||
current_label = current_detection[0]
|
||||
current_box = current_detection[2]
|
||||
current_area = area(current_box)
|
||||
overlap = 0
|
||||
for to_check_idx in range(
|
||||
min(current_detection_idx + 1, len(sorted_by_area)),
|
||||
@@ -564,14 +568,14 @@ def reduce_detections(
|
||||
# if area of current detection / area of check < 5% they should not be compared
|
||||
# this covers cases where a large car parked in a driveway doesn't block detections
|
||||
# of cars in the street behind it
|
||||
if current_area / area(to_check) < 0.05:
|
||||
if area(current_box) / area(to_check) < 0.05:
|
||||
continue
|
||||
|
||||
intersect_box = intersection(current_box, to_check)
|
||||
# if % of smaller detection is inside of another detection, consolidate
|
||||
if intersect_box is not None and area(
|
||||
intersect_box
|
||||
) / current_area > LABEL_CONSOLIDATION_MAP.get(
|
||||
if intersect_box is not None and area(intersect_box) / area(
|
||||
current_box
|
||||
) > LABEL_CONSOLIDATION_MAP.get(
|
||||
current_label, LABEL_CONSOLIDATION_DEFAULT
|
||||
):
|
||||
overlap = 1
|
||||
|
||||
@@ -790,7 +790,7 @@ def get_hailo_temps() -> dict[str, float]:
|
||||
return temps
|
||||
|
||||
|
||||
def is_go2rtc_arbitrary_exec_allowed() -> bool:
|
||||
def _go2rtc_arbitrary_exec_allowed() -> bool:
|
||||
"""Read the GO2RTC_ALLOW_ARBITRARY_EXEC override from env, docker
|
||||
secrets, or the Home Assistant add-on options file."""
|
||||
raw: Optional[str] = None
|
||||
@@ -822,7 +822,7 @@ def is_restricted_go2rtc_source(stream_source: str) -> bool:
|
||||
and the GO2RTC_ALLOW_ARBITRARY_EXEC override is not set."""
|
||||
if not stream_source.strip().startswith(("echo:", "expr:", "exec:")):
|
||||
return False
|
||||
return not is_go2rtc_arbitrary_exec_allowed()
|
||||
return not _go2rtc_arbitrary_exec_allowed()
|
||||
|
||||
|
||||
def ffprobe_stream(ffmpeg, path: str, detailed: bool = False) -> sp.CompletedProcess:
|
||||
|
||||
+17
-1
@@ -14,6 +14,7 @@ from frigate.camera import CameraMetrics, PTZMetrics
|
||||
from frigate.comms.inter_process import InterProcessRequestor
|
||||
from frigate.config import CameraConfig, DetectConfig, LoggerConfig, ModelConfig
|
||||
from frigate.config.camera.camera import CameraTypeEnum
|
||||
from frigate.config.camera.motion import MotionSourceEnum
|
||||
from frigate.config.camera.updater import (
|
||||
CameraConfigUpdateEnum,
|
||||
CameraConfigUpdateSubscriber,
|
||||
@@ -300,7 +301,22 @@ def process_frames(
|
||||
continue
|
||||
|
||||
# look for motion if enabled
|
||||
motion_boxes = motion_detector.detect(frame)
|
||||
if camera_config.motion.source == MotionSourceEnum.onvif:
|
||||
# Motion is supplied by an external ONVIF cell-motion subscriber
|
||||
# writing to camera_metrics. Skip the per-frame internal detector.
|
||||
if camera_metrics.external_motion_active.value:
|
||||
boxes = list(camera_metrics.external_motion_boxes)
|
||||
if boxes:
|
||||
motion_boxes = [tuple(b) for b in boxes]
|
||||
else:
|
||||
# Active but no spatial data yet — fall back to full frame
|
||||
# so downstream region clustering still has something to
|
||||
# scan.
|
||||
motion_boxes = [(0, 0, frame_shape[1] - 1, frame_shape[0] - 1)]
|
||||
else:
|
||||
motion_boxes = []
|
||||
else:
|
||||
motion_boxes = motion_detector.detect(frame)
|
||||
|
||||
regions = []
|
||||
consolidated_detections = []
|
||||
|
||||
@@ -149,17 +149,5 @@
|
||||
"cluck": "قرقرة",
|
||||
"cock_a_doodle_doo": "كوكو-كو-كوووووو",
|
||||
"turkey": "ديك رومى",
|
||||
"gobble": "كركرة",
|
||||
"tearing": "يمزق",
|
||||
"ping": "طنّة",
|
||||
"clang": "صوت رنين",
|
||||
"squeal": "يصرخ",
|
||||
"creak": "صرير",
|
||||
"sizzle": "صوت الأزيز",
|
||||
"clicking": "النقر",
|
||||
"clickety_clack": "طقطقة",
|
||||
"rumble": "الحلبة",
|
||||
"skateboard": "لوح تزلج",
|
||||
"echo": "صدى الصوت",
|
||||
"noise": "ازعاج"
|
||||
"gobble": "كركرة"
|
||||
}
|
||||
|
||||
@@ -22,6 +22,5 @@
|
||||
"mouse": "فأر",
|
||||
"keyboard": "لوحة المفاتيح",
|
||||
"goat": "معزة",
|
||||
"sheep": "غنم",
|
||||
"skateboard": "لوح تزلج"
|
||||
"sheep": "غنم"
|
||||
}
|
||||
|
||||
@@ -355,43 +355,8 @@
|
||||
"steam_whistle": "Парна свирка",
|
||||
"mechanisms": "Механизми",
|
||||
"clock": "Часовник",
|
||||
"tick": "Тик",
|
||||
"tick": "",
|
||||
"tick-tock": "Тиктакане",
|
||||
"gears": "Зъбни колела",
|
||||
"sewing_machine": "Шиеща машина",
|
||||
"sound_effect": "Звуков ефект",
|
||||
"tubular_bells": "Тръбни камбани",
|
||||
"mallet_percussion": "Ударни чукчета",
|
||||
"marimba": "Маримба",
|
||||
"glockenspiel": "Металлофон",
|
||||
"steelpan": "Пан барабан",
|
||||
"string_section": "Струнна група",
|
||||
"jingle_bell": "Звънче",
|
||||
"chime": "Звънече",
|
||||
"wind_chime": "Вятърен звън",
|
||||
"singing_bowl": "Пееща купа",
|
||||
"ambient_music": "Амбиентна музика",
|
||||
"new-age_music": "Музика от ново поколение",
|
||||
"vocal_music": "Вокална музика",
|
||||
"gurgling": "Бълбукане",
|
||||
"light_engine": "Лек двигател",
|
||||
"medium_engine": "Среден двигател",
|
||||
"heavy_engine": "Тежък двигател",
|
||||
"ratchet": "Тресчотка",
|
||||
"hammer": "Ръчен чук",
|
||||
"pulleys": "Макари",
|
||||
"mechanical_fan": "Механичен вентилатор",
|
||||
"air_conditioning": "Климатик",
|
||||
"cash_register": "Каса",
|
||||
"printer": "Принтер",
|
||||
"camera": "Камера",
|
||||
"tools": "Инструменти",
|
||||
"artillery_fire": "Артилерийски огън",
|
||||
"boom": "Гръм",
|
||||
"glass": "Стъкло",
|
||||
"crack": "Пукнатина",
|
||||
"wood": "Дърво",
|
||||
"silence": "Тишина",
|
||||
"liquid": "Течност",
|
||||
"splash": "Разливам"
|
||||
"sewing_machine": "Шиеща машина"
|
||||
}
|
||||
|
||||
@@ -63,17 +63,13 @@
|
||||
"untilRestart": "До рестарт",
|
||||
"mo": "{{time}}мес",
|
||||
"m": "{{time}}м",
|
||||
"s": "{{time}}с",
|
||||
"never": "Никога",
|
||||
"formattedTimestampMonthDayYearHourMinute": {
|
||||
"12hour": "d MMM yyyy, h:mm aaa"
|
||||
}
|
||||
"s": "{{time}}с"
|
||||
},
|
||||
"button": {
|
||||
"apply": "Приложи",
|
||||
"reset": "Нулиране",
|
||||
"done": "Готово",
|
||||
"disabled": "Изключено",
|
||||
"disabled": "Деактивирано",
|
||||
"save": "Запази",
|
||||
"saving": "Запазване…",
|
||||
"cancel": "Отказ",
|
||||
@@ -84,7 +80,7 @@
|
||||
"delete": "Изтриване",
|
||||
"yes": "Да",
|
||||
"download": "Изтегляне",
|
||||
"enabled": "Включено",
|
||||
"enabled": "Активирано",
|
||||
"history": "История",
|
||||
"back": "Назад",
|
||||
"fullscreen": "Цял екран",
|
||||
@@ -103,8 +99,8 @@
|
||||
"export": "Експортиране",
|
||||
"deleteNow": "Изтрии сега",
|
||||
"next": "Следващ",
|
||||
"disable": "Изключи",
|
||||
"enable": "Включи"
|
||||
"disable": "Деактивирай",
|
||||
"enable": "Активирай"
|
||||
},
|
||||
"menu": {
|
||||
"live": {
|
||||
|
||||
@@ -30,10 +30,5 @@
|
||||
"title": "Всички дати",
|
||||
"short": "Дати"
|
||||
}
|
||||
},
|
||||
"more": "Още филтри",
|
||||
"reset": {
|
||||
"label": "Рестартирай филтрите по подразбиране"
|
||||
},
|
||||
"timeRange": "Времеви диапазон"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,8 +34,7 @@
|
||||
},
|
||||
"submitFrigatePlus": {
|
||||
"title": "Да се изпрати ли този кадър към Frigate+?",
|
||||
"submit": "Изпрати",
|
||||
"previewError": "Не можe да се зареди предварителен преглед на моментната снимка. Записът може да не е наличен в момента."
|
||||
"submit": "Изпрати"
|
||||
},
|
||||
"noPreviewFound": "Не е намерен предварителен преглед",
|
||||
"noRecordingsFoundForThisTime": "За това време не са намерени записи",
|
||||
@@ -48,6 +47,5 @@
|
||||
"error": {
|
||||
"submitFrigatePlusFailed": "Неуспешно изпратен кадър към Frigate+"
|
||||
}
|
||||
},
|
||||
"cameraOff": "Камерата е изключена"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,69 +1 @@
|
||||
{
|
||||
"label": "Конфигурация на камерата",
|
||||
"timestamp_style": {
|
||||
"label": "Стил на времева щампа",
|
||||
"format": {
|
||||
"label": "Формат на времева щампа"
|
||||
},
|
||||
"color": {
|
||||
"red": {
|
||||
"label": "Червено",
|
||||
"description": "Червен компонент (0-255) за времева щампа."
|
||||
},
|
||||
"green": {
|
||||
"description": "Зелен компонент (0-255) за времева щампа.",
|
||||
"label": "Зелено"
|
||||
},
|
||||
"blue": {
|
||||
"description": "Син компонент (0-255) за времева щампа.",
|
||||
"label": "Синьо"
|
||||
},
|
||||
"label": "Цвят на времева щампа",
|
||||
"description": "RGB стойности за текста на времева щампа (всички стойности 0-255)."
|
||||
},
|
||||
"thickness": {
|
||||
"description": "Дебелина на текстовата линия за времева щампа.",
|
||||
"label": "Дебелина на времева щампа"
|
||||
},
|
||||
"effect": {
|
||||
"label": "Ефект на времева щампа",
|
||||
"description": "Визуален ефект на времева щампа (без, плътен, сянка)."
|
||||
},
|
||||
"position": {
|
||||
"label": "Позиция на времева щампа",
|
||||
"description": "Позиция за времева щампа на снимката (гл/гд/дл/дд)."
|
||||
}
|
||||
},
|
||||
"audio_transcription": {
|
||||
"label": "Аудио транскрипция",
|
||||
"live_enabled": {
|
||||
"label": "Транскрипция на живо"
|
||||
}
|
||||
},
|
||||
"snapshots": {
|
||||
"quality": {
|
||||
"label": "Качество на моментната снимка"
|
||||
},
|
||||
"required_zones": {
|
||||
"label": "Задължителни зони",
|
||||
"description": "Зони в които обект трябва да влезе за да се запази моментна снимка."
|
||||
},
|
||||
"height": {
|
||||
"label": "Височина на моментната снимка"
|
||||
},
|
||||
"retain": {
|
||||
"default": {
|
||||
"description": "Дни по подразбиране за задържане на моментните снимки."
|
||||
}
|
||||
}
|
||||
},
|
||||
"semantic_search": {
|
||||
"label": "Семантично търсене",
|
||||
"triggers": {
|
||||
"label": "Спусък",
|
||||
"friendly_name": {
|
||||
"label": "Удобно име"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
{}
|
||||
|
||||
@@ -4,148 +4,5 @@
|
||||
"session_length": {
|
||||
"label": "Продължителност на сесията"
|
||||
}
|
||||
},
|
||||
"version": {
|
||||
"label": "Текуща версия на конфигурацията",
|
||||
"description": "Версия на активната конфигурация. Помага за проследяване на промени от миграция или форматиране."
|
||||
},
|
||||
"safe_mode": {
|
||||
"label": "Безопасен режим",
|
||||
"description": "При избор, Frigate ще стартира в безопасен режим за отстраняване на неизправности."
|
||||
},
|
||||
"environment_vars": {
|
||||
"label": "Променливи",
|
||||
"description": "Параметри за стартиране на Frigate в Home Assistant OS. Non-HAOS потребителите трябва да използват Docker конфигурация."
|
||||
},
|
||||
"logger": {
|
||||
"label": "Логове",
|
||||
"default": {
|
||||
"label": "Ниво на логовете"
|
||||
}
|
||||
},
|
||||
"timestamp_style": {
|
||||
"label": "Стил на времева щампа",
|
||||
"format": {
|
||||
"label": "Формат на времева щампа"
|
||||
},
|
||||
"color": {
|
||||
"red": {
|
||||
"label": "Червено",
|
||||
"description": "Червен компонент (0-255) за времева щампа."
|
||||
},
|
||||
"green": {
|
||||
"description": "Зелен компонент (0-255) за времева щампа.",
|
||||
"label": "Зелено"
|
||||
},
|
||||
"blue": {
|
||||
"description": "Син компонент (0-255) за времева щампа.",
|
||||
"label": "Синьо"
|
||||
},
|
||||
"label": "Цвят на времева щампа",
|
||||
"description": "RGB стойности за текста на времева щампа (всички стойности 0-255)."
|
||||
},
|
||||
"thickness": {
|
||||
"description": "Дебелина на текстовата линия за времева щампа.",
|
||||
"label": "Дебелина на времева щампа"
|
||||
},
|
||||
"effect": {
|
||||
"label": "Ефект на времева щампа",
|
||||
"description": "Визуален ефект на времева щампа (без, плътен, сянка)."
|
||||
},
|
||||
"position": {
|
||||
"label": "Позиция на времева щампа",
|
||||
"description": "Позиция за времева щампа на снимката (гл/гд/дл/дд)."
|
||||
}
|
||||
},
|
||||
"audio_transcription": {
|
||||
"label": "Аудио транскрипция",
|
||||
"enabled": {
|
||||
"label": "Включи аудио транскрипцията"
|
||||
},
|
||||
"language": {
|
||||
"label": "Език на транскрипция"
|
||||
},
|
||||
"device": {
|
||||
"label": "Устройство за транскрипция"
|
||||
},
|
||||
"model_size": {
|
||||
"label": "Големина на модела"
|
||||
},
|
||||
"live_enabled": {
|
||||
"label": "Транскрипция на живо"
|
||||
}
|
||||
},
|
||||
"snapshots": {
|
||||
"quality": {
|
||||
"label": "Качество на моментната снимка"
|
||||
},
|
||||
"required_zones": {
|
||||
"label": "Задължителни зони",
|
||||
"description": "Зони в които обект трябва да влезе за да се запази моментна снимка."
|
||||
},
|
||||
"height": {
|
||||
"label": "Височина на моментната снимка"
|
||||
},
|
||||
"retain": {
|
||||
"default": {
|
||||
"description": "Дни по подразбиране за задържане на моментните снимки."
|
||||
}
|
||||
}
|
||||
},
|
||||
"classification": {
|
||||
"label": "Класификация на обекти",
|
||||
"bird": {
|
||||
"label": "Конфигурация за класификация на птици",
|
||||
"enabled": {
|
||||
"label": "Класификация на птици",
|
||||
"description": "Клацификация на птици (Вкл./Изкл.)."
|
||||
},
|
||||
"threshold": {
|
||||
"label": "Минимален резултат"
|
||||
}
|
||||
},
|
||||
"custom": {
|
||||
"label": "Потребителски класификационни модели",
|
||||
"enabled": {
|
||||
"label": "Включи модел",
|
||||
"description": "Потребителски класификационнен модел (Вкл./Изкл.)."
|
||||
},
|
||||
"name": {
|
||||
"label": "Име на модел"
|
||||
},
|
||||
"save_attempts": {
|
||||
"label": "Опити за запазване"
|
||||
},
|
||||
"state_config": {
|
||||
"motion": {
|
||||
"label": "Изпълни при движение"
|
||||
},
|
||||
"interval": {
|
||||
"label": "Интервал за класификация"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"semantic_search": {
|
||||
"label": "Семантично търсене",
|
||||
"enabled": {
|
||||
"label": "Включи семантично търсене",
|
||||
"description": "Семантично търсене (Вкл./Изкл.)."
|
||||
},
|
||||
"reindex": {
|
||||
"label": "Реиндексирай при стартиране"
|
||||
},
|
||||
"model_size": {
|
||||
"label": "Големина на модел"
|
||||
},
|
||||
"device": {
|
||||
"label": "Устройство"
|
||||
},
|
||||
"triggers": {
|
||||
"label": "Спусък",
|
||||
"friendly_name": {
|
||||
"label": "Удобно име"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,5 @@
|
||||
"hair_dryer": "Сешоар",
|
||||
"toothbrush": "Четка за зъби",
|
||||
"scissors": "Ножица",
|
||||
"clock": "Часовник",
|
||||
"airplane": "Самолет"
|
||||
"clock": "Часовник"
|
||||
}
|
||||
|
||||
@@ -2,13 +2,5 @@
|
||||
"documentTitle": "Модели за класификация - Frigate",
|
||||
"description": {
|
||||
"invalidName": "Невалидно име. Имената могат да съдържат единствено: букви, числа, празни места, долни черти и тирета."
|
||||
},
|
||||
"details": {
|
||||
"scoreInfo": "Резултатът представлява средната степен на увереност в класификацията при всички засечки на този обект."
|
||||
},
|
||||
"wizard": {
|
||||
"step1": {
|
||||
"classificationAttribute": "Атрибут"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,5 @@
|
||||
"detections": "Засичания",
|
||||
"motion": {
|
||||
"label": "Движение"
|
||||
},
|
||||
"camera": "Камера"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,69 +5,10 @@
|
||||
"mismatch_one": "{{count}} недостъпен обект беше открит и включен в този елемент за преглед. Тези обекти или не са квалифицирани като предупреждение или откриване, или вече са били изчистени/изтрити.",
|
||||
"mismatch_other": "{{count}} недостъпни обекта бяха открити и включени в този елемент за преглед. Тези обекти или не са квалифицирани като предупреждение или откриване, или вече са били изчистени/изтрити."
|
||||
}
|
||||
},
|
||||
"editLPR": {
|
||||
"title": "Редактиране на регистрационния номер"
|
||||
},
|
||||
"editAttributes": {
|
||||
"title": "Редактиране на атрибутите"
|
||||
},
|
||||
"topScore": {
|
||||
"label": "Най-силен резултат"
|
||||
},
|
||||
"estimatedSpeed": "Естимирана скорост",
|
||||
"objects": "Обекти",
|
||||
"camera": "Камера",
|
||||
"zones": "Зони",
|
||||
"timestamp": "Времева щампа",
|
||||
"button": {
|
||||
"findSimilar": "Намери подобни",
|
||||
"regenerate": {
|
||||
"title": "Регенерирай",
|
||||
"label": "Регенерирай описанието на следените обекти"
|
||||
}
|
||||
},
|
||||
"description": {
|
||||
"label": "Описание",
|
||||
"placeholder": "Описание на следените обекти"
|
||||
}
|
||||
},
|
||||
"trackedObjectsCount_one": "{{count}} проследен обект ",
|
||||
"trackedObjectsCount_other": "{{count}} проследени обекта ",
|
||||
"documentTitle": "Разгледай - Фригейт",
|
||||
"generativeAI": "Генеративен Изкъствен Интелект",
|
||||
"itemMenu": {
|
||||
"downloadSnapshot": {
|
||||
"aria": "Сваляне на моментна снимка/кадър"
|
||||
},
|
||||
"viewTrackingDetails": {
|
||||
"label": "Виж детайли за следенето",
|
||||
"aria": "Покажи детайли за следенето"
|
||||
},
|
||||
"findSimilar": {
|
||||
"label": "Намери подобни"
|
||||
},
|
||||
"submitToPlus": {
|
||||
"label": "Изпрати към Frigate+",
|
||||
"aria": "Изпрати към Frigate Plus"
|
||||
},
|
||||
"viewInHistory": {
|
||||
"label": "Виж в история",
|
||||
"aria": "Виж в история"
|
||||
},
|
||||
"more": {
|
||||
"aria": "Повече"
|
||||
}
|
||||
},
|
||||
"dialog": {
|
||||
"confirmDelete": {
|
||||
"title": "Потвърди изтриване"
|
||||
}
|
||||
},
|
||||
"aiAnalysis": {
|
||||
"title": "AI Анализ"
|
||||
},
|
||||
"concerns": {
|
||||
"label": "Притеснения"
|
||||
}
|
||||
"generativeAI": "Генеративен Изкъствен Интелект"
|
||||
}
|
||||
|
||||
@@ -17,58 +17,7 @@
|
||||
},
|
||||
"toast": {
|
||||
"error": {
|
||||
"renameExportFailed": "Неуспешно преименуване на експорт: {{errorMessage}}",
|
||||
"assignCaseFailed": "Неуспешно обновяване на възложен случай: {{errorMessage}}",
|
||||
"caseSaveFailed": "Неуспешно запазен случай: {{errorMessage}}",
|
||||
"caseDeleteFailed": "Неуспешно изтрит случай: {{errorMessage}}"
|
||||
}
|
||||
},
|
||||
"deleteCase": {
|
||||
"desc": "Сигурен ли сте, че искате да изтриете {{caseName}}?",
|
||||
"label": "Изтрии случай"
|
||||
},
|
||||
"caseDialog": {
|
||||
"nameLabel": "Име на случай",
|
||||
"descriptionLabel": "Описание"
|
||||
},
|
||||
"toolbar": {
|
||||
"editCase": "Редактирай случай",
|
||||
"deleteCase": "Изтрии случай"
|
||||
},
|
||||
"caseCard": {
|
||||
"emptyCase": "Все още няма експорти"
|
||||
},
|
||||
"jobCard": {
|
||||
"defaultName": "{{camera}} експортиране",
|
||||
"queued": "На опашката",
|
||||
"running": "В ход",
|
||||
"preparing": "Подготвяне",
|
||||
"copying": "Копиране",
|
||||
"encoding": "Енкодиране",
|
||||
"encodingRetry": "Енкодиране (повторно)",
|
||||
"finalizing": "Финализиране"
|
||||
},
|
||||
"caseView": {
|
||||
"noDescription": "Без описание",
|
||||
"createdAt": "Създаден {{value}}",
|
||||
"exportCount_one": "1 експорт",
|
||||
"exportCount_other": "{{count}} експорти",
|
||||
"cameraCount_one": "1 камера",
|
||||
"cameraCount_other": "{{count}} камери",
|
||||
"showMore": "Покажи повече",
|
||||
"showLess": "Покажи по-малко"
|
||||
},
|
||||
"bulkActions": {
|
||||
"delete": "Изтрии",
|
||||
"deleteNow": "Изтрии сега"
|
||||
},
|
||||
"bulkDelete": {
|
||||
"title": "Изтрии експорти",
|
||||
"desc_one": "Сигурни ли сте , че искате да изтриете {{count}} експорта?"
|
||||
},
|
||||
"bulkToast": {
|
||||
"success": {
|
||||
"delete": "Успешно изтрити експорти"
|
||||
"renameExportFailed": "Неуспешно преименуване на експорт: {{errorMessage}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,8 +15,5 @@
|
||||
"addFace": "Добавете нова колекция във библиотеката за лица при качването на първата ви снимка.",
|
||||
"placeholder": "Напишете име за тази колекция",
|
||||
"invalidName": "Невалидно име. Имената могат да съдържат единствено: букви, числа, празни места, долни черти и тирета."
|
||||
},
|
||||
"details": {
|
||||
"timestamp": "Времева щампа"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,8 +16,7 @@
|
||||
"move": {
|
||||
"clickMove": {
|
||||
"enable": "Включи кликване за преместване",
|
||||
"disable": "Изключи кликване за преместване",
|
||||
"label": "Кликнете в центъра на кадъра за да центрирате камерата"
|
||||
"disable": "Изключи кликване за преместване"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -27,8 +26,7 @@
|
||||
},
|
||||
"recording": {
|
||||
"enable": "Включи запис",
|
||||
"disable": "Изключи запис",
|
||||
"disabledInConfig": "\"Записване\" трябва първо да се вкючи през настройките за тази камера."
|
||||
"disable": "Изключи запис"
|
||||
},
|
||||
"snapshots": {
|
||||
"enable": "Включи моментни снимки",
|
||||
@@ -40,9 +38,7 @@
|
||||
},
|
||||
"camera": {
|
||||
"enable": "Включи камера",
|
||||
"disable": "Изключи камера",
|
||||
"turnOn": "Включване на камера",
|
||||
"turnOff": "Изключване на камера"
|
||||
"disable": "Изключи камера"
|
||||
},
|
||||
"detect": {
|
||||
"enable": "Включи разпознаване",
|
||||
@@ -69,16 +65,5 @@
|
||||
"cameraEnabled": "Камерата е включена"
|
||||
},
|
||||
"documentTitle": "Наживо - Frigate",
|
||||
"documentTitle.withCamera": "{{camera}} - На живо - Фригейт",
|
||||
"noCameras": {
|
||||
"default": {
|
||||
"buttonText": "Добави камера"
|
||||
},
|
||||
"group": {
|
||||
"title": "Няма камери в групата",
|
||||
"description": "Тази група няма добавени или включени камери.",
|
||||
"buttonText": "Управление на групите"
|
||||
}
|
||||
},
|
||||
"lowBandwidthMode": "Режим ограничена/бавна връзка"
|
||||
"documentTitle.withCamera": "{{camera}} - На живо - Фригейт"
|
||||
}
|
||||
|
||||
@@ -24,9 +24,6 @@
|
||||
},
|
||||
"state": {
|
||||
"submitted": "Enviat"
|
||||
},
|
||||
"toast": {
|
||||
"error": "No s'ha pogut enviar a Frigate+. Si us plau, comproveu la vostra connexió de xarxa i torneu-ho a provar."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -110,14 +107,7 @@
|
||||
"queueingButton": "S'estan posant a la cua les exportacions...",
|
||||
"exportButton_one": "Exporta 1 càmera",
|
||||
"exportButton_many": "Exporta {{count}} càmeres",
|
||||
"exportButton_other": "Exporta {{count}} càmeres",
|
||||
"searchOrSelectGroup": "Cerca, o selecciona un grup de càmeres...",
|
||||
"selectAll": "Selecciona totes les càmeres",
|
||||
"clearSelection": "Neteja la selecció",
|
||||
"selectWithActivity": "Càmeres amb objectes rastrejats",
|
||||
"selectGroup": "Selecciona un grup",
|
||||
"noMatchingCameras": "No hi ha càmeres que coincideixin amb la cerca",
|
||||
"selectedCount": "{{selected}} / {{total}} seleccionats"
|
||||
"exportButton_other": "Exporta {{count}} càmeres"
|
||||
},
|
||||
"multi": {
|
||||
"title_one": "Exporta {{count}} ressenyes",
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
},
|
||||
"listen": {
|
||||
"label": "Tipus d'escoltes",
|
||||
"description": "Llista de tipus d'esdeveniment d'àudio a detectar (per exemple: escorça, focarmalarma, parla, crida)."
|
||||
"description": "Llista de tipus d'esdeveniment d'àudio a detectar (per exemple: escorça, focarmalarma, crit, parla, crida)."
|
||||
},
|
||||
"filters": {
|
||||
"label": "Filtres d'àudio",
|
||||
@@ -152,11 +152,11 @@
|
||||
}
|
||||
},
|
||||
"ffmpeg": {
|
||||
"label": "Fluxos (FFmpeg)",
|
||||
"description": "Les entrades de flux de càmera i les opcions del FFmpeg, incloent-hi el camí binari, els arguments, l'hwaccel i els arguments de sortida per rol.",
|
||||
"label": "FFmpeg",
|
||||
"description": "Paràmetres del FFmpeg que inclouen la ruta dels binaris, args, opcions de hwaccel i args de sortida per rol.",
|
||||
"path": {
|
||||
"label": "Ruta FFmpeg",
|
||||
"description": "Ruta al binari FFmpeg a usar o un àlies de versió («7.0» o «8.0»)."
|
||||
"description": "Ruta al binari FFmpeg a usar o un àlies de versió («5.0» o «7.0»)."
|
||||
},
|
||||
"global_args": {
|
||||
"label": "Arguments globals del FFmpeg",
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"description": "Paràmetres del FFmpeg que inclouen la ruta dels binaris, args, opcions de hwaccel i args de sortida per rol.",
|
||||
"path": {
|
||||
"label": "Ruta FFmpeg",
|
||||
"description": "Ruta al binari FFmpeg a usar o un àlies de versió («7.0» o «8.0»)."
|
||||
"description": "Ruta al binari FFmpeg a usar o un àlies de versió («5.0» o «7.0»)."
|
||||
},
|
||||
"global_args": {
|
||||
"label": "Arguments globals del FFmpeg",
|
||||
@@ -2018,7 +2018,7 @@
|
||||
},
|
||||
"listen": {
|
||||
"label": "Tipus d'escoltes",
|
||||
"description": "Llista de tipus d'esdeveniment d'àudio a detectar (per exemple: escorça, focarmalarma, parla, crida)."
|
||||
"description": "Llista de tipus d'esdeveniment d'àudio a detectar (per exemple: escorça, focarmalarma, crit, parla, crida)."
|
||||
},
|
||||
"filters": {
|
||||
"label": "Filtres d'àudio",
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
"integrationObjectClassification": "Classificació de l'objecte",
|
||||
"integrationAudioTranscription": "Transcripció d'àudio",
|
||||
"cameraDetect": "Detecció d'objectes",
|
||||
"cameraFfmpeg": "Fluxos (FFmpeg)",
|
||||
"cameraFfmpeg": "FFmpeg",
|
||||
"cameraRecording": "Enregistrament",
|
||||
"cameraSnapshots": "Instantànies",
|
||||
"cameraMotion": "Detecció de moviment",
|
||||
@@ -1689,17 +1689,7 @@
|
||||
}
|
||||
},
|
||||
"cameraInputs": {
|
||||
"itemTitle": "Flux {{index}}",
|
||||
"sourceMode": {
|
||||
"restream": "Restream (go2rtc)",
|
||||
"manual": "Camí d'entrada manual",
|
||||
"go2rtcStreamLabel": "flux go2rtc",
|
||||
"go2rtcStreamPlaceholder": "Selecciona un flux go2rtc",
|
||||
"noGo2rtcStreams": "No s'ha configurat cap flux go2rtc",
|
||||
"go2rtcStreamSearch": "Cerca fluxos...",
|
||||
"availableStreams": "Fluxos disponibles",
|
||||
"noMatchingStreams": "No hi ha fluxos coincidents"
|
||||
}
|
||||
"itemTitle": "Flux {{index}}"
|
||||
},
|
||||
"restartRequiredField": "Reinicia requerit",
|
||||
"restartRequiredFooter": "S'ha canviat la configuració - es requereix reiniciar",
|
||||
@@ -2094,13 +2084,6 @@
|
||||
},
|
||||
"onvif": {
|
||||
"autotrackingNoZones": "Autotraquejar requereix al menys una zona. Defineix una zona per aquesta cámera a Mascares/Zones, després usa'l com a requerit a la part inferior."
|
||||
},
|
||||
"ffmpeg": {
|
||||
"hwaccelManualNotRecommended": "No es recomanen arguments manuals d'acceleració de maquinari. Tret que existeixi un requisit específic, seleccioneu el predefinit que coincideixi amb el vostre maquinari."
|
||||
},
|
||||
"model": {
|
||||
"optimizedFor320": "Frigate està optimitzada per a un model 320x320, que és la millor opció per a la majoria de configuracions. Un model 640x640 és més lent i només ajuda en escenaris específics.",
|
||||
"inputDimensionsNotDetectResolution": "L'amplada i l'alçada del model d'entrada són les dimensions d'entrada del model de detecció d'objectes, no la resolució de detecció de la càmera. Haurien de coincidir amb les dimensions del model que esteu utilitzant - típicament una mida quadrada com 320x320 o 640x640."
|
||||
}
|
||||
},
|
||||
"modelSize": {
|
||||
|
||||
@@ -191,22 +191,7 @@
|
||||
},
|
||||
"audio": "Àudio:",
|
||||
"cameraProbeInfo": "Informació del sondeig de la càmera {{camera}}",
|
||||
"streamDataFromFFPROBE": "Les dades de la transmissió són obtingudes mitjançant <code>ffprobe</code>.",
|
||||
"keyframes": {
|
||||
"title": "Anàlisi de fotogrames clau",
|
||||
"analyzing": "S'estan analitzant els fotogrames clau... queden {{seconds}} segons",
|
||||
"stillAnalyzing": "Encara s'estan analitzant els fotogrames clau...",
|
||||
"recordStream": "Registre de flux:",
|
||||
"keyframeCount": "Fotogrames clau observats:",
|
||||
"observedDuration": "Durada observada:",
|
||||
"gap": "Espai de fotogrames clau (mín / avg / max):",
|
||||
"segmentLength": "Longitud del segment d'enregistrament:",
|
||||
"ok": "Fotogrames clau cada ,{{seconds}}s, bons per enregistrar i reproduir.",
|
||||
"warning": "Els fotogrames clau dispersos o variables (espai més llarg .{{seconds}}s), probablement un còdec intel·ligent (H.264+/H.265+), això no és recomanable.",
|
||||
"error": "El buit dels fotogrames clau ( the{{seconds}}s) excedeix la longitud del segment d'enregistrament ({{segmentTime}}s). Alguns segments poden no tenir un fotograma clau, el qual trenca la reproducció. Desactiva el còdec intel·ligent/+ a la càmera o escurça el seu interval de fotogrames clau.",
|
||||
"unknown": "No s'ha pogut determinar l'espaiat dels fotogrames clau.",
|
||||
"recordDisabled": "L'enregistrament està desactivat per a aquesta càmera."
|
||||
}
|
||||
"streamDataFromFFPROBE": "Les dades de la transmissió són obtingudes mitjançant <code>ffprobe</code>."
|
||||
},
|
||||
"title": "Càmeres",
|
||||
"overview": "Visió general",
|
||||
|
||||
@@ -180,7 +180,7 @@
|
||||
"rats": "Rotter",
|
||||
"patter": "Dråbelyd",
|
||||
"insect": "Insekt",
|
||||
"cricket": "Fårekylling",
|
||||
"cricket": "Cricket",
|
||||
"guitar": "Guitar",
|
||||
"electric_guitar": "Elektrisk Guitar",
|
||||
"bass_guitar": "Basguitar",
|
||||
@@ -195,16 +195,5 @@
|
||||
"rimshot": "Kantslag",
|
||||
"drum_roll": "Trommehvirvel",
|
||||
"bass_drum": "Stortromme",
|
||||
"techno": "Techno",
|
||||
"mosquito": "Myg",
|
||||
"fly": "Flue",
|
||||
"buzz": "Summen",
|
||||
"frog": "Frø",
|
||||
"croak": "Kvæk",
|
||||
"snake": "Slange",
|
||||
"rattle": "Klapren",
|
||||
"whale_vocalization": "Hvallyde",
|
||||
"music": "Musik",
|
||||
"musical_instrument": "Musikinstrument",
|
||||
"plucked_string_instrument": "Strengeinstrument"
|
||||
"techno": "Techno"
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"am": "am",
|
||||
"year_one": "{{time}} år",
|
||||
"year_other": "{{time}} år",
|
||||
"mo": "{{time}}md.",
|
||||
"mo": "{{time}}må",
|
||||
"month_one": "{{time}} måned",
|
||||
"month_other": "{{time}} måneder",
|
||||
"d": "{{time}}d",
|
||||
@@ -121,18 +121,18 @@
|
||||
"back": "Tilbage",
|
||||
"history": "Historik",
|
||||
"fullscreen": "Fuldskærm",
|
||||
"exitFullscreen": "Afslut Fuldskærm",
|
||||
"pictureInPicture": "Billede i billede",
|
||||
"twoWayTalk": "Samtale",
|
||||
"cameraAudio": "Kameralyd",
|
||||
"on": "Til",
|
||||
"off": "Fra",
|
||||
"exitFullscreen": "Afslut Fludskærm",
|
||||
"pictureInPicture": "Billede i Billede",
|
||||
"twoWayTalk": "2 vejs samtale",
|
||||
"cameraAudio": "Kamera Lyd",
|
||||
"on": "ON",
|
||||
"off": "OFF",
|
||||
"edit": "Rediger",
|
||||
"copyCoordinates": "Kopier koordinater",
|
||||
"delete": "Slet",
|
||||
"yes": "Ja",
|
||||
"no": "Nej",
|
||||
"download": "Hent",
|
||||
"download": "Download",
|
||||
"info": "Info",
|
||||
"suspended": "Sat på pause",
|
||||
"unsuspended": "Genoptag",
|
||||
@@ -141,19 +141,7 @@
|
||||
"export": "Eksporter",
|
||||
"deleteNow": "Slet nu",
|
||||
"next": "Næste",
|
||||
"continue": "Fortsæt",
|
||||
"add": "Tilføj",
|
||||
"applying": "Anvender…",
|
||||
"undo": "Annuler",
|
||||
"copiedToClipboard": "Kopieret til udklipsholder",
|
||||
"modified": "Ændret",
|
||||
"overridden": "Overskrevet",
|
||||
"resetToGlobal": "Gendan til global",
|
||||
"resetToDefault": "Gendan standard",
|
||||
"saveAll": "Gem alle",
|
||||
"savingAll": "Gemmer alle…",
|
||||
"undoAll": "Fortryd alle",
|
||||
"retry": "Prøv igen"
|
||||
"continue": "Fortsæt"
|
||||
},
|
||||
"menu": {
|
||||
"system": "System",
|
||||
@@ -206,9 +194,7 @@
|
||||
"gl": "Galego (Galisisk)",
|
||||
"id": "Bahasa Indonesia (Indonesisk)",
|
||||
"ur": "اردو (Urdu)",
|
||||
"hr": "Hrvatski (Kroatisk)",
|
||||
"zhHant": "繁體中文 (Traditionel Kinesisk)",
|
||||
"bs": "Bosanski (Bosnisk)"
|
||||
"hr": "Hrvatski (Kroatisk)"
|
||||
},
|
||||
"appearance": "Udseende",
|
||||
"darkMode": {
|
||||
@@ -257,11 +243,7 @@
|
||||
"logout": "Log ud",
|
||||
"setPassword": "Vælg kodeord"
|
||||
},
|
||||
"classification": "Kategorisering",
|
||||
"profiles": "Profiler",
|
||||
"actions": "Handlinger",
|
||||
"features": "Funktioner",
|
||||
"chat": "Chat"
|
||||
"classification": "Kategorisering"
|
||||
},
|
||||
"toast": {
|
||||
"copyUrlToClipboard": "Kopieret URL til udklipsholder.",
|
||||
@@ -270,14 +252,13 @@
|
||||
"error": {
|
||||
"title": "Ændringer kunne ikke gemmes: {{errorMessage}}",
|
||||
"noMessage": "Kunne ikke gemme konfigurationsændringer"
|
||||
},
|
||||
"success": "Gemte ændringerne i konfigurationen."
|
||||
}
|
||||
}
|
||||
},
|
||||
"role": {
|
||||
"title": "Rolle",
|
||||
"admin": "Admin",
|
||||
"viewer": "Seer",
|
||||
"viewer": "Viewer",
|
||||
"desc": "Admins har fuld adgang til Frigate UI. Viewers er begrænset til at se kameraer, gennemse items, og historik i UI."
|
||||
},
|
||||
"pagination": {
|
||||
@@ -315,10 +296,5 @@
|
||||
},
|
||||
"information": {
|
||||
"pixels": "{{area}}px"
|
||||
},
|
||||
"no_items": "Intet fundet",
|
||||
"validation_errors": "Valideringsfejl",
|
||||
"credentialField": {
|
||||
"savedPlaceholder": "Gemt - efterlad blank for at bevare nuværende"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
"setting": {
|
||||
"label": "Kamera Streaming Indstillinger",
|
||||
"title": "{{cameraName}} Streaming Indstillinger",
|
||||
"desc": "Skift indstillingerne for direkte visning af denne kameragruppes dashboard. <em> Disse indstillinger er enheds- og browserspecifikke.</em>",
|
||||
"desc": "Skift de live streaming muligheder for denne kameragruppes dashboard. <em> Disse indstillinger er enheds- og browserspecifikke.</em>",
|
||||
"audioIsAvailable": "Lyd er tilgængelig for denne stream",
|
||||
"audioIsUnavailable": "Lyd er ikke tilgængelig for denne strøm",
|
||||
"audio": {
|
||||
@@ -67,10 +67,7 @@
|
||||
"desc": "Aktivér kun denne mulighed, hvis kameraets live stream viser farve artefakter og har en diagonal linje på højre side af billedet."
|
||||
}
|
||||
}
|
||||
},
|
||||
"showAll": "Vis alle kameragrupper",
|
||||
"showLess": "Vis mindre",
|
||||
"editGroups": "Rediger kameragrupper"
|
||||
}
|
||||
},
|
||||
"debug": {
|
||||
"options": {
|
||||
@@ -84,7 +81,6 @@
|
||||
"zones": "Zoner",
|
||||
"mask": "Maske",
|
||||
"motion": "Bevægelse",
|
||||
"regions": "Regioner",
|
||||
"paths": "Stier"
|
||||
"regions": "Regioner"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"restart": {
|
||||
"title": "Er du sikker på, at du vil genstarte Frigate?",
|
||||
"title": "Er du sikker på at du vil genstarte Frigate?",
|
||||
"button": "Genstart",
|
||||
"restarting": {
|
||||
"title": "Frigate genstarter",
|
||||
@@ -21,46 +21,8 @@
|
||||
"ask_a": "Er dette objekt et <code>{{label}}</code>?",
|
||||
"ask_an": "Er dette objekt en <code>{{label}}</code>?",
|
||||
"ask_full": "Er dette objekt en <code>{{untranslatedLabel}}</code> ({{translatedLabel}})?"
|
||||
},
|
||||
"state": {
|
||||
"submitted": "Indsendt"
|
||||
},
|
||||
"toast": {
|
||||
"error": "Fejl under indsendelse til Frigate+. Kontroller venligst din netværksforbindelse og prøv igen."
|
||||
}
|
||||
}
|
||||
},
|
||||
"video": {
|
||||
"viewInHistory": "Se i historik"
|
||||
}
|
||||
},
|
||||
"export": {
|
||||
"time": {
|
||||
"fromTimeline": "Vælg fra tidslinje",
|
||||
"lastHour_one": "Sidste time",
|
||||
"lastHour_other": "Sidste {{count}} timer",
|
||||
"custom": "Tidsinterval",
|
||||
"start": {
|
||||
"title": "Starttidspunkt",
|
||||
"label": "Vælg starttidspunkt"
|
||||
},
|
||||
"end": {
|
||||
"title": "Sluttidspunkt",
|
||||
"label": "Vælg sluttidspunkt"
|
||||
}
|
||||
},
|
||||
"name": {
|
||||
"placeholder": "Navngiv eksport"
|
||||
},
|
||||
"case": {
|
||||
"newCaseOption": "Opret ny sag",
|
||||
"newCaseNamePlaceholder": "Nyt sagsnavn",
|
||||
"newCaseDescriptionPlaceholder": "Sagsbeskrivelse",
|
||||
"label": "Sag",
|
||||
"nonAdminHelp": "En ny sag oprettes til disse eksporter.",
|
||||
"placeholder": "Vælg en sag"
|
||||
},
|
||||
"select": "Vælg",
|
||||
"export": "Eksporter"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
"notifications": "Notifikations indstillinger - Frigate"
|
||||
},
|
||||
"menu": {
|
||||
"ui": "Brugergrænseflade",
|
||||
"profiles": "Profiler"
|
||||
"ui": "Brugergrænseflade"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
"toothbrush": "Zahnbürste",
|
||||
"bicycle": "Fahrrad",
|
||||
"door": "Tür",
|
||||
"keyboard": "Klavier",
|
||||
"keyboard": "Klaviatur",
|
||||
"bus": "Bus",
|
||||
"horse": "Pferd",
|
||||
"cat": "Katze",
|
||||
@@ -123,7 +123,7 @@
|
||||
"chicken": "Huhn",
|
||||
"sitar": "Sitar",
|
||||
"ukulele": "Ukulele",
|
||||
"tapping": "Tippen",
|
||||
"tapping": "Klopfen",
|
||||
"flapping_wings": "Flügelschlagen",
|
||||
"strum": "Herumklimpern",
|
||||
"electronic_organ": "Elektrische Orgel",
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
"untilRestart": "Bis zum Neustart",
|
||||
"justNow": "Gerade",
|
||||
"pm": "nachmittags",
|
||||
"mo": "{{time}} Mon.",
|
||||
"mo": "{{time}} Mon",
|
||||
"formattedTimestamp": {
|
||||
"12hour": "d. MMM, hh:mm:ss aaa",
|
||||
"24hour": "dd. MMM, hh:mm:ss aaa"
|
||||
@@ -193,7 +193,7 @@
|
||||
"gl": "Galego (Galicisch)",
|
||||
"id": "Bahasa Indonesia (Indonesisch)",
|
||||
"hr": "Hrvatski (Kroatisch)",
|
||||
"bs": "Bosanski (Bosnisch)",
|
||||
"bs": "Bosnisch",
|
||||
"zhHant": "Traditional Chinese"
|
||||
},
|
||||
"appearance": "Erscheinung",
|
||||
|
||||
@@ -68,10 +68,7 @@
|
||||
},
|
||||
"label": "Kamera Gruppen",
|
||||
"edit": "Kameragruppe bearbeiten",
|
||||
"success": "Kameragruppe {{name}} wurde gespeichert.",
|
||||
"showAll": "Alle Kameragruppen anzeigen",
|
||||
"showLess": "Weniger anzeigen",
|
||||
"editGroups": "Kameragruppen bearbeiten"
|
||||
"success": "Kameragruppe {{name}} wurde gespeichert."
|
||||
},
|
||||
"debug": {
|
||||
"options": {
|
||||
|
||||
@@ -105,21 +105,14 @@
|
||||
"cameraSelection": "Kameras",
|
||||
"cameraSelectionHelp": "Kameras, die in diesem Zeitbereich Objekte verfolgen, sind vorausgewählt",
|
||||
"checkingActivity": "Kameraaktivität wird überprüft...",
|
||||
"noCameras": "Keine Kameras verfügbar",
|
||||
"noCameras": "keine kamaeras verfügbar",
|
||||
"detectionCount_one": "1 verfolgtes Objekt",
|
||||
"detectionCount_other": "{{count}} verfolgte Objekte",
|
||||
"nameLabel": "Exportname",
|
||||
"detectionCount_other": "{{count}} verfolgtesObjekte",
|
||||
"nameLabel": "Export Name",
|
||||
"namePlaceholder": "Optionaler Basisname für diese Exporte",
|
||||
"queueingButton": "Exporte werden in die Warteschlange gestellt...",
|
||||
"exportButton_one": "Export 1 Kamera",
|
||||
"exportButton_other": "xport {{count}} Kameras",
|
||||
"searchOrSelectGroup": "Suchen, oder Kameragruppe auswählen...",
|
||||
"selectAll": "Alle Kameras auswählen",
|
||||
"clearSelection": "Auswahl löschen",
|
||||
"selectWithActivity": "Kameras mit verfolgten Objekten",
|
||||
"selectGroup": "Gruppe auswählen",
|
||||
"noMatchingCameras": "Der Suche entsprechen keine Kameras",
|
||||
"selectedCount": "{{selected}} / {{total}} ausgewählt"
|
||||
"exportButton_other": "xport {{count}} Kameras"
|
||||
},
|
||||
"multi": {
|
||||
"title_one": "1 Bewertung exportieren",
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"description": "Mindest-RMS-Lautstärkeschwelle, die für die Audioerkennung erforderlich ist; niedrigere Werte erhöhen die Empfindlichkeit (z. B. 200 hoch, 500 mittel, 1000 niedrig)."
|
||||
},
|
||||
"listen": {
|
||||
"description": "Liste der zu erkennenden Audioereignisse (z.B: bellen, Feueralarm, Gespräche, Rufen).",
|
||||
"description": "Liste der zu erkennenden Audioereignisse (z.B: bellen, Feueralarm, schreien, sprechen, rufen).",
|
||||
"label": "Hörtypen"
|
||||
},
|
||||
"filters": {
|
||||
@@ -204,11 +204,11 @@
|
||||
"description": "Einstellungen zum Aktivieren und Verwalten von Benachrichtigungen für diese Kamera."
|
||||
},
|
||||
"ffmpeg": {
|
||||
"label": "Streams (FFmpeg)",
|
||||
"description": "Kamera-Stream-Eingaben und FFmpeg-Optionen, einschließlich Binärpfad, Argumente, hwaccel und rollenspezifische Ausgabeargumente.",
|
||||
"label": "FFmpeg",
|
||||
"description": "FFmpeg-Einstellungen, einschließlich Binärpfad, Argumente, hwaccel-Optionen und rollenspezifische Ausgabeargumente.",
|
||||
"path": {
|
||||
"label": "FFmpeg-Pfad",
|
||||
"description": "Pfad zur zu verwendenden FFmpeg-Binärdatei oder ein Versionsalias („7.0” oder „8.0”)."
|
||||
"description": "Pfad zur zu verwendenden FFmpeg-Binärdatei oder ein Versionsalias („5.0” oder „7.0”)."
|
||||
},
|
||||
"global_args": {
|
||||
"label": "Globale Argumente von FFmpeg",
|
||||
@@ -770,10 +770,6 @@
|
||||
"dashboard": {
|
||||
"label": "In der Benutzeroberfläche anzeigen",
|
||||
"description": "Schalte ein, ob diese Kamera überall in der Benutzeroberfläche von „Frigate“ sichtbar ist. Wenn du diese Option deaktivierst, musst du die Konfiguration manuell bearbeiten, um diese Kamera wieder in der Benutzeroberfläche anzuzeigen."
|
||||
},
|
||||
"review": {
|
||||
"label": "In der Überprüfung anzeigen",
|
||||
"description": "Legen Sie fest, ob diese Kamera in der Übersicht angezeigt wird (auf der Übersichtsseite sowie im Kamerafilter, in der Bewegungsübersicht und in der Verlaufsansicht)."
|
||||
}
|
||||
},
|
||||
"snapshots": {
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"description": "Mindest-RMS-Lautstärkeschwelle, die für die Audioerkennung erforderlich ist; niedrigere Werte erhöhen die Empfindlichkeit (z. B. 200 hoch, 500 mittel, 1000 niedrig)."
|
||||
},
|
||||
"listen": {
|
||||
"description": "Liste der zu erkennenden Audioereignisse (z.B: bellen, Feueralarm, Gespräche, Rufen).",
|
||||
"description": "Liste der zu erkennenden Audioereignisse (z.B: bellen, Feueralarm, schreien, sprechen, rufen).",
|
||||
"label": "Hörtypen"
|
||||
},
|
||||
"filters": {
|
||||
@@ -380,7 +380,7 @@
|
||||
"description": "FFmpeg-Einstellungen, einschließlich Binärpfad, Argumente, hwaccel-Optionen und rollenspezifische Ausgabeargumente.",
|
||||
"path": {
|
||||
"label": "FFmpeg-Pfad",
|
||||
"description": "Pfad zur zu verwendenden FFmpeg-Binärdatei oder ein Versionsalias („7.0” oder „8.0”)."
|
||||
"description": "Pfad zur zu verwendenden FFmpeg-Binärdatei oder ein Versionsalias („5.0” oder „7.0”)."
|
||||
},
|
||||
"global_args": {
|
||||
"label": "Globale Argumente von FFmpeg",
|
||||
@@ -1934,10 +1934,6 @@
|
||||
"dashboard": {
|
||||
"label": "In der Benutzeroberfläche anzeigen",
|
||||
"description": "Schalte ein, ob diese Kamera überall in der Benutzeroberfläche von „Frigate“ sichtbar ist. Wenn du diese Option deaktivierst, musst du die Konfiguration manuell bearbeiten, um diese Kamera wieder in der Benutzeroberfläche anzuzeigen."
|
||||
},
|
||||
"review": {
|
||||
"label": "In der Überprüfung anzeigen",
|
||||
"description": "Legen Sie fest, ob diese Kamera in der Übersicht angezeigt wird (auf der Übersichtsseite sowie im Kamerafilter, in der Bewegungsübersicht und in der Verlaufsansicht)."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@
|
||||
"laptop": "Laptop",
|
||||
"mouse": "Maus",
|
||||
"goat": "Ziege",
|
||||
"keyboard": "Klavier",
|
||||
"keyboard": "Klaviatur",
|
||||
"cell_phone": "Handy",
|
||||
"remote": "Fernbedienung",
|
||||
"airplane": "Flugzeug",
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
"threshold": "Empfindlichkeitsschwelle",
|
||||
"thresholdDesc": "Niedrigere Werte erkennen geringere Veränderungen (1–255)",
|
||||
"minArea": "Mindestwechselbereich",
|
||||
"minAreaDesc": "Mindestgröße eines einzelnen sich bewegenden Bereichs, ausgedrückt als Prozentsatz des untersuchten Bereichs",
|
||||
"minAreaDesc": "Mindestanteil der untersuchten Region, der sich ändern muss, damit die Veränderung als signifikant gilt",
|
||||
"frameSkip": "Bild überspringen",
|
||||
"frameSkipDesc": "Verarbeite jeden N-ten Frame. Stelle diesen Wert auf die Bildrate deiner Kamera ein, um einen Frame pro Sekunde zu verarbeiten (z. B. 5 für eine Kamera mit 5 FPS, 30 für eine Kamera mit 30 FPS). Höhere Werte sorgen für eine schnellere Verarbeitung, können jedoch kurze Bewegungsabläufe übersehen.",
|
||||
"maxResults": "Maximale Ergebnisse",
|
||||
@@ -72,9 +72,6 @@
|
||||
"framesDecoded": "Rahmen decodiert",
|
||||
"wallTime": "Suchzeit",
|
||||
"segmentErrors": "Segmentfehler",
|
||||
"seconds": "{{seconds}}s",
|
||||
"minutesSeconds": "{{minutes}}m {{seconds}}s",
|
||||
"scanSummary": "{{segments}} Segmente · {{time}}"
|
||||
},
|
||||
"scanning": "Wird gescannt {{time}}"
|
||||
"seconds": "{{seconds}}s"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
"integrationObjectClassification": "Objekt Klassifizierung",
|
||||
"integrationAudioTranscription": "Audio-Transkription",
|
||||
"cameraDetect": "Objekterkennung",
|
||||
"cameraFfmpeg": "Streams (FFmpeg)",
|
||||
"cameraFfmpeg": "FFmpeg",
|
||||
"cameraRecording": "Aufnahme",
|
||||
"cameraSnapshots": "Momentaufnahme",
|
||||
"cameraMotion": "Bewegungserkennung",
|
||||
@@ -1376,16 +1376,12 @@
|
||||
"details": {
|
||||
"edit": "Kameradaten bearbeiten",
|
||||
"title": "Kameradaten bearbeiten",
|
||||
"description": "Aktualisieren Sie den Anzeigenamen, die externe URL und die Sichtbarkeit, die für diese Kamera in der gesamten Frigate-Benutzeroberfläche verwendet werden.",
|
||||
"description": "Aktualisieren Sie den Anzeigenamen und die externe URL, die für diese Kamera in der gesamten Frigate-Benutzeroberfläche verwendet werden.",
|
||||
"friendlyNameLabel": "Display Name",
|
||||
"friendlyNameHelp": "Der in der Benutzeroberfläche von „Frigate“ für diese Kamera angezeigte Spitzname. Lassen Sie das Feld leer, um die Kamera-ID zu verwenden.",
|
||||
"webuiUrlLabel": "URL der Web-Benutzeroberfläche",
|
||||
"webuiUrlHelp": "URL, um die Web-Benutzeroberfläche der Kamera direkt aus der Debug-Ansicht aufzurufen. Lassen Sie das Feld leer, um den Link zu deaktivieren.",
|
||||
"webuiUrlInvalid": "Es muss sich um eine gültige URL handeln (z. B. https://example.com).",
|
||||
"dashboardLabel": "Im Live-Dashboard anzeigen",
|
||||
"dashboardHelp": "Diese Kamera im Live-Dashboard anzeigen.",
|
||||
"reviewLabel": "In der Überprüfung anzeigen",
|
||||
"reviewHelp": "Zeige diese Kamera in der Übersicht an, einschließlich des Kamerafilters, der Bewegungsübersicht und der Verlaufsansicht."
|
||||
"webuiUrlInvalid": "Es muss sich um eine gültige URL handeln (z. B. https://example.com)."
|
||||
},
|
||||
"label": "Kamerazustand",
|
||||
"description": "Legen Sie den Betriebszustand für jede Kamera fest.<br /><br /><strong>Ein</strong>: Streams werden normal verarbeitet.<br /><strong>Aus</strong>: Die Verarbeitung wird vorübergehend angehalten. Diese Einstellung bleibt bei einem Neustart von Frigate nicht erhalten.<br /><strong>Deaktiviert</strong>: Die Verarbeitung wird beendet und die Änderung in Ihrer Konfiguration gespeichert. Um eine deaktivierte Kamera wieder zu aktivieren, ist ein Neustart erforderlich.<br /><br /><em>Hinweis: Die Deaktivierung hat keine Auswirkungen auf go2rtc-Restreams.</em><br /><br />Ziehen Sie den Griff, um die Reihenfolge der aktiven Kameras in der Benutzeroberfläche anzupassen, einschließlich des Live-Dashboards und der Dropdown-Menüs zur Kameraauswahl.",
|
||||
@@ -1768,17 +1764,7 @@
|
||||
}
|
||||
},
|
||||
"cameraInputs": {
|
||||
"itemTitle": "Stream {{index}}",
|
||||
"sourceMode": {
|
||||
"restream": "Restream (go2rtc)",
|
||||
"manual": "Pfad für die manuelle Eingabe",
|
||||
"go2rtcStreamLabel": "go2rtc stream",
|
||||
"go2rtcStreamPlaceholder": "Wählen Sie einen go2rtc-Stream aus",
|
||||
"noGo2rtcStreams": "Es sind keine go2rtc-Streams konfiguriert",
|
||||
"go2rtcStreamSearch": "Suche Streams...",
|
||||
"availableStreams": "Verfügbare Streams",
|
||||
"noMatchingStreams": "Keine passenden Streams"
|
||||
}
|
||||
"itemTitle": "Stream {{index}}"
|
||||
},
|
||||
"restartRequiredField": "Neustart erforderlich",
|
||||
"restartRequiredFooter": "Konfiguration geändert – Neustart erforderlich",
|
||||
@@ -2096,11 +2082,7 @@
|
||||
"fpsGreaterThanFive": "Es wird nicht empfohlen, den Wert für die FPS-Erkennung auf mehr als 5 zu setzen. Höhere Werte können zu Leistungseinbußen führen und bieten keinerlei Vorteile.",
|
||||
"disabled": "Die Objekterkennung ist deaktiviert. Momentaufnahmen, Überprüfungselemente und Erweiterungsfunktionen wie Gesichtserkennung, Kennzeichenerkennung und generative KI funktionieren nicht.",
|
||||
"resolutionShouldBeMultipleOfFour": "Um optimale Ergebnisse zu erzielen, sollten Breite und Höhe ein Vielfaches von 4 sein. Andere gerade Werte können zu visuellen Artefakten oder leichten Verzerrungen im Erkennungsstrom führen.",
|
||||
"aspectRatioMismatch": "Die von Ihnen eingegebene Breite und Höhe stimmen nicht mit dem Seitenverhältnis Ihrer aktuell erkannten Auflösung überein. Dies kann zu einem gestreckten oder verzerrten Bild führen.",
|
||||
"maxFramesSet": "Die Festlegung einer maximalen Bildrate überschreibt das Standardverhalten und deaktiviert die Verfolgung stationärer Objekte. Dies ist nur in sehr wenigen Fällen erforderlich; verwenden Sie diese Option daher mit Bedacht.",
|
||||
"squareResolution": "Eine quadratische Erkennungsauflösung ist ungewöhnlich. Die Erkennungsbreite und -höhe sollten dem Seitenverhältnis Ihrer Kamera entsprechen (zum Beispiel 16:9) und nicht den Abmessungen des Objekterkennungsmodells. Ein nicht übereinstimmendes Seitenverhältnis kann das Bild verzerren und die Erkennungsgenauigkeit beeinträchtigen.",
|
||||
"resolutionHigh": "Diese Erkennungsauflösung liegt über der empfohlenen Wert und kann zu einem erhöhten Ressourcenverbrauch führen, ohne die Erkennungsgenauigkeit zu verbessern. Für die meisten Kameras wird eine Erkennungsauflösung von maximal 1080p empfohlen.",
|
||||
"globalResolutionMultipleCameras": "Bei der Konfiguration mehrerer Kameras wird eine globale Erkennungsauflösung festgelegt. Sofern nicht alle Kameras dieselbe Auflösung und dasselbe Seitenverhältnis aufweisen, sollten die Erkennungsbreite und -höhe für jede Kamera separat festgelegt werden, um dem nativen Seitenverhältnis der jeweiligen Kamera zu entsprechen."
|
||||
"aspectRatioMismatch": "Die von Ihnen eingegebene Breite und Höhe stimmen nicht mit dem Seitenverhältnis Ihrer aktuell erkannten Auflösung überein. Dies kann zu einem gestreckten oder verzerrten Bild führen."
|
||||
},
|
||||
"faceRecognition": {
|
||||
"globalDisabled": "Die Gesichtserkennungserweiterung muss aktiviert sein, damit die Gesichtserkennungsfunktionen bei dieser Kamera funktionieren.",
|
||||
@@ -2133,13 +2115,6 @@
|
||||
},
|
||||
"onvif": {
|
||||
"autotrackingNoZones": "Für die automatische Verfolgung ist mindestens eine Zone erforderlich. Definieren Sie unter „Masken / Zonen“ eine Zone für diese Kamera und legen Sie diese anschließend unten als erforderliche Zone fest."
|
||||
},
|
||||
"ffmpeg": {
|
||||
"hwaccelManualNotRecommended": "Explizite Definitionen der Hardware-beschleunigungs Variablen sind nicht empfohlen. Wähle die Voreinstellung die zu deiner Hardware passt, außer wenn spezifische Anforderungen eine andere Konfiguration erfordern."
|
||||
},
|
||||
"model": {
|
||||
"optimizedFor320": "Frigate ist für ein 320x320-Modell optimiert, was für die meisten Konfigurationen die beste Wahl ist. Ein 640x640-Modell ist langsamer und bietet nur in bestimmten Szenarien Vorteile.",
|
||||
"inputDimensionsNotDetectResolution": "Die Eingangsbreite und -höhe des Modells beziehen sich auf die Abmessungen des Objekterkennungsmodells und nicht auf die Erkennungsauflösung Ihrer Kamera. Sie sollten mit den Abmessungen des von Ihnen verwendeten Modells übereinstimmen – in der Regel quadratische Abmessungen wie 320×320 oder 640×640."
|
||||
}
|
||||
},
|
||||
"birdseye": {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user