Compare commits

...
4 Commits
Author SHA1 Message Date
Rami MouroandGitHub ff402419c4 Merge 8a40ea5c87 into 1c745e0847 2026-07-04 18:44:34 -07:00
Josh HawkinsandGitHub 1c745e0847 fix config corruption when deleting the last commented mask or zone (#23627)
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
CI / AMD64 Extra Build (push) Blocked by required conditions
Deleting the last entry of a mapping or sequence via config/set orphaned ruamel's comment tokens, which were then emitted above a flow-style {} / [] at column 0, which is unparseable yaml that failed validation and silently rolled the change back. The fix is to clear the emptied collection's stale comment metadata (and the parent's entry for it) so the dump stays valid. Non-empty collections are left untouched so sibling comments are preserved. This covers both emptied maps and emptied lists.
2026-07-04 17:10:32 -06:00
Rami MouroandClaude Opus 4.8 8a40ea5c87 fix(qualcomm): make install_cdi resolve QNN skels across BSP and Ubuntu/QAIRT layouts
The bundled CDI descriptors assume the Qualcomm Linux BSP filesystem layout
(/usr/lib/rfsa/adsp). On the equally-official Ubuntu image for RB3 Gen 2 /
Rubik Pi 3, the QAIRT apt packages board-select the Hexagon skels into
/usr/lib/dsp/cdsp and leave /usr/lib/rfsa/adsp as self-referential symlink
loops, so install_cdi silently dropped the skel mounts and NPU offload failed
silently at runtime (or a host-symlink workaround broke on reboot with an
ELOOP CDI mount error).

install_cdi.py now resolves each missing/broken mount from a fallback library
dir and rewrites it to the real file, injects ADSP_LIBRARY_PATH so the DSP
loader finds the skels, and warns loudly (with an optional --strict) when a
critical QNN/HTP library cannot be found instead of failing silently. Docs
updated to cover both images and a stronger verify step.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 10:48:44 -06:00
Rami Mouro e769ba7a8c feat(detectors): add Qualcomm Hexagon NPU support (community board)
Add a community-supported board build for Qualcomm SoCs with a Hexagon
NPU, accelerating TFLite object detection through the QNN TFLite delegate
on the Hexagon Tensor Processor (HTP).

Supported boards:
- IQ9100 (IQ-9075 EVK)
- QCS6490 (RB3 Gen 2 Vision Kit / Rubik Pi 3)

- detector: new `qualcomm_tfl` plugin loading libQnnTFLiteDelegate.so on
  the HTP backend, reusing the shared TFLite delegate helpers
- docker: `docker/qualcomm` board build (Dockerfile, qualcomm.hcl,
  qualcomm.mk) producing the arm64 `-qualcomm` image
- cdi: per-board Container Device Interface descriptors and an install
  helper exposing the NPU device nodes and QNN libraries to the container
- ci/codeowners: register the qualcomm build target and code owner
- docs: installation, hardware, and object detector documentation, using
  the shared model config dropdown component
- i18n: add the Qualcomm detector label and description to the generated
  en config locale

Signed-off-by: Rami Mouro <rmouro@qti.qualcomm.com>
2026-06-29 19:44:04 -06:00
16 changed files with 3972 additions and 0 deletions
+25
View File
@@ -197,6 +197,31 @@ jobs:
set: |
synaptics.tags=${{ steps.setup.outputs.image-name }}-synaptics
*.cache-from=type=gha
qualcomm_build:
runs-on: ubuntu-22.04-arm
name: Qualcomm Build
needs:
- arm64_build
steps:
- name: Check out code
uses: actions/checkout@v6
with:
persist-credentials: false
- name: Set up QEMU and Buildx
id: setup
uses: ./.github/actions/setup
with:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Qualcomm build
uses: docker/bake-action@v7
with:
source: .
push: true
targets: qualcomm
files: docker/qualcomm/qualcomm.hcl
set: |
qualcomm.tags=${{ steps.setup.outputs.image-name }}-qualcomm
*.cache-from=type=gha
# The majority of users running arm64 are rpi users, so the rpi
# build should be the primary arm64 image
assemble_default_build:
+1
View File
@@ -5,3 +5,4 @@
/docker/rockchip/ @MarcA711
/docker/rocm/ @harakas
/docker/hailo8l/ @spanner3003
/docker/qualcomm/ @ramalamadingdong
+21
View File
@@ -0,0 +1,21 @@
# syntax=docker/dockerfile:1.6
# https://askubuntu.com/questions/972516/debian-frontend-environment-variable
ARG DEBIAN_FRONTEND=noninteractive
# Globally set pip break-system-packages option to avoid having to specify it every time
ARG PIP_BREAK_SYSTEM_PACKAGES=1
FROM wheels AS qualcomm-wheels
ARG TARGETARCH
# No extra wheels needed — ai-edge-litert is already in the base image
# and the QNN delegate library (libQnnTFLiteDelegate.so) is provided
# by the host via CDI bind mounts at runtime.
FROM deps AS qualcomm-deps
ARG TARGETARCH
ARG PIP_BREAK_SYSTEM_PACKAGES
WORKDIR /opt/frigate/
COPY --from=rootfs / /
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+151
View File
@@ -0,0 +1,151 @@
import argparse
import json
import os
# The CDI descriptor is authored against the Qualcomm Linux BSP filesystem layout,
# where the QNN/HTP runtime libraries and the Hexagon "skel" files live under
# /usr/lib and /usr/lib/rfsa/adsp. On other officially-supported images for the
# same boards -- notably Ubuntu for RB3 Gen 2 / Rubik Pi 3 with the QAIRT apt
# packages (qairt-libs/qairt-tools) -- the same files are board-selected into a
# different directory, and /usr/lib/rfsa/adsp may even contain self-referential
# symlink loops. When a mount's authored hostPath is missing (or a broken/looping
# symlink), look the file up by name in these fallback directories and rewrite the
# mount to the real, resolved file so NPU offload works on both layouts.
FALLBACK_LIB_DIRS = [
"/usr/lib/dsp/cdsp", # QAIRT apt packages (board-selected from /usr/share/qcom/.../dsp/cdsp)
]
# Libraries without which HTP/NPU offload cannot work. If any of these cannot be
# resolved we warn prominently (and abort under --strict) instead of silently
# producing a CDI that starts fine but never offloads to the NPU.
CRITICAL_BASENAMES = {
"libQnnTFLiteDelegate.so",
"libQnnHtp.so",
"libQnnHtpV68Skel.so",
"libQnnHtpV73Skel.so",
"libQnnHtpV75Skel.so",
"libQnnSystem.so",
}
def resolve_host_path(host_path):
"""Resolve a mount's hostPath to a usable, real file/dir on this host.
Returns the original path when it already resolves (the Qualcomm Linux BSP
layout). If it is missing or a broken/looping symlink, search FALLBACK_LIB_DIRS
by basename and return the real (symlink-resolved) path, so the rewritten mount
is stable across reboots / apt reconfigures. Returns None if not found anywhere.
"""
# os.path.exists() follows symlinks and returns False for a broken link or an
# ELOOP ("too many levels of symbolic links") -- exactly the treat-as-missing
# behavior we want before falling back.
if os.path.exists(host_path):
return host_path
basename = os.path.basename(host_path)
for fallback_dir in FALLBACK_LIB_DIRS:
candidate = os.path.join(fallback_dir, basename)
if candidate != host_path and os.path.exists(candidate):
return os.path.realpath(candidate)
return None
parser = argparse.ArgumentParser(description="Install CDI on Dragonwing IoT boards")
parser.add_argument("--file", type=str, required=True, help="CDI input file")
parser.add_argument(
"--strict",
action="store_true",
help="Exit non-zero if any critical QNN/HTP library cannot be resolved",
)
args, unknown = parser.parse_known_args()
if os.path.exists("/etc/cdi/cdi-hw-acc.json"):
print("/etc/cdi/cdi-hw-acc.json already exists. Remove it first before continuing.")
exit(1)
if not os.path.exists(args.file):
print(f"{args.file} (via --file) does not exist")
exit(1)
# Check if directory exists
if not os.path.exists("/etc/cdi"):
# Check if we can create it in /etc
if not os.access(os.path.dirname("/etc/cdi"), os.W_OK):
print(
f"{os.path.dirname('/etc/cdi')} is not writable. Re-run this script with sudo."
)
exit(1)
os.mkdir("/etc/cdi")
else:
# Directory exists → check if writable
if not os.access("/etc/cdi", os.W_OK):
print("/etc/cdi is not writable. Re-run this script with sudo.")
exit(1)
with open(args.file, "r") as f:
cdi = json.loads(f.read())
print("Resolving mount paths...")
relocated = []
missing = []
for device in cdi["devices"]:
new_mounts = []
for mount in device["containerEdits"].get("mounts", []):
resolved = resolve_host_path(mount["hostPath"])
if resolved is None:
missing.append(mount["hostPath"])
print(f" Missing {mount['hostPath']}")
continue
if resolved != mount["hostPath"]:
relocated.append((mount["hostPath"], resolved))
print(f" Resolved {mount['hostPath']} -> {resolved}")
# Keep containerPath as authored; only the host source moves.
mount["hostPath"] = resolved
new_mounts.append(mount)
device["containerEdits"]["mounts"] = new_mounts
# The QNN HTP backend (libcdsprpc) locates the Hexagon skel purely via
# ADSP_LIBRARY_PATH. Point it at the in-container directory where the skels are
# mounted so offload works regardless of the host's on-disk layout.
container_edits = device["containerEdits"]
env = container_edits.setdefault("env", [])
if not any(e.startswith("ADSP_LIBRARY_PATH=") for e in env):
env.append("ADSP_LIBRARY_PATH=/usr/lib/rfsa/adsp")
print("")
if relocated:
print(
f"Resolved {len(relocated)} mount(s) from a fallback library directory "
"(non-BSP image layout)."
)
critical_missing = [m for m in missing if os.path.basename(m) in CRITICAL_BASENAMES]
if missing:
print(f"WARNING: {len(missing)} expected host path(s) were missing and skipped.")
if critical_missing:
print("")
print(
"WARNING: critical QNN/HTP libraries could not be found on this host -- NPU "
"offload will NOT work and detection would fall back to CPU:"
)
for path in critical_missing:
print(f" {path}")
print(
"Confirm the QAIRT runtime is installed (e.g. the qairt-libs/qairt-tools "
"packages) and that the Hexagon skels exist as real files."
)
if args.strict:
print("Aborting due to --strict.")
exit(1)
print("")
print("Writing to /etc/cdi/cdi-hw-acc.json...")
with open("/etc/cdi/cdi-hw-acc.json", "w") as f:
f.write(json.dumps(cdi, indent=4))
print("Writing to /etc/cdi/cdi-hw-acc.json OK")
+27
View File
@@ -0,0 +1,27 @@
target wheels {
dockerfile = "docker/main/Dockerfile"
platforms = ["linux/arm64"]
target = "wheels"
}
target deps {
dockerfile = "docker/main/Dockerfile"
platforms = ["linux/arm64"]
target = "deps"
}
target rootfs {
dockerfile = "docker/main/Dockerfile"
platforms = ["linux/arm64"]
target = "rootfs"
}
target qualcomm {
dockerfile = "docker/qualcomm/Dockerfile"
contexts = {
wheels = "target:wheels",
deps = "target:deps",
rootfs = "target:rootfs"
}
platforms = ["linux/arm64"]
}
+15
View File
@@ -0,0 +1,15 @@
BOARDS += qualcomm
local-qualcomm: version
docker buildx bake --file=docker/qualcomm/qualcomm.hcl qualcomm \
--set qualcomm.tags=frigate:latest-qualcomm \
--load
build-qualcomm: version
docker buildx bake --file=docker/qualcomm/qualcomm.hcl qualcomm \
--set qualcomm.tags=$(IMAGE_REPO):${GITHUB_REF_NAME}-$(COMMIT_HASH)-qualcomm
push-qualcomm: build-qualcomm
docker buildx bake --file=docker/qualcomm/qualcomm.hcl qualcomm \
--set qualcomm.tags=$(IMAGE_REPO):${GITHUB_REF_NAME}-$(COMMIT_HASH)-qualcomm \
--push
+13
View File
@@ -265,6 +265,19 @@
}
]
},
"qualcomm": {
"title": "Qualcomm",
"models": [
{
"key": "ssd_mobiledet",
"label": "SSD MobileDet",
"recommended": true,
"download": "The default SSD MobileDet TFLite model (`/cpu_model.tflite`) bundled with the Frigate container is used automatically. This model is INT8 quantized and compatible with the QNN HTP backend.\n\nOnly `.tflite` models are supported with this detector. A custom model must be INT8 quantized for the Hexagon NPU.",
"ui": "Navigate to **Settings > System > Detectors and model** and select **Qualcomm** from the detector type dropdown and click **Add**. The bundled SSD MobileDet model is used automatically.\n\nTo use a custom model, configure in the **Custom Model** tab:\n\n| Field | Value |\n| ---------------------------------------- | ---------------------------------- |\n| **Custom object detector model path** | `/config/your_custom_model.tflite` |\n| **Object detection model input width** | `320` |\n| **Object detection model input height** | `320` |\n| **Label map for custom object detector** | `/labelmap/coco-80.txt` |",
"yaml": "detectors:\n qualcomm_npu:\n type: qualcomm_tfl"
}
]
},
"rknn": {
"title": "RKNN",
"models": [
@@ -53,6 +53,10 @@ Frigate supports multiple different detectors that work on different types of ha
- [RKNN](#rockchip-platform): RKNN models can run on Rockchip devices with included NPUs.
**Qualcomm** <CommunityBadge />
- [Qualcomm](#qualcomm): TFLite models can run on Qualcomm SoCs with a Hexagon NPU (e.g. IQ9100, QCS6490) via the QNN TFLite delegate.
**Synaptics** <CommunityBadge />
- [Synaptics](#synaptics): synap models can run on Synaptics devices(e.g astra machina) with included NPUs.
@@ -652,6 +656,21 @@ When configuring the Synap detector, you have to specify the model: a local **pa
<ModelConfigDropdown detectorTitle="Synaptics" models={objectDetectorsModels.synaptics.models} />
## Qualcomm
Hardware accelerated object detection is supported on the following Qualcomm SoCs with a Hexagon NPU:
- IQ9100 / IQ-9075 EVK
- QCS6490 / RB3 Gen 2 Vision Kit / Rubik Pi 3
This implementation uses the QNN TFLite delegate (`libQnnTFLiteDelegate.so`) to accelerate TFLite model inference on the Qualcomm Hexagon Tensor Processor (HTP / NPU). The delegate library and device drivers are provided by the host operating system and made available to the container via [CDI (Container Device Interface)](https://docs.docker.com/build/building/cdi/).
See the [installation docs](../frigate/installation.md#qualcomm) for information on setting up CDI and configuring the hardware.
### Configuration
<ModelConfigDropdown detectorTitle="Qualcomm" models={objectDetectorsModels.qualcomm.models} />
## Rockchip platform
Hardware accelerated object detection is supported on the following SoCs:
+15
View File
@@ -107,6 +107,10 @@ Frigate supports multiple different detectors that work on different types of ha
- [AXEngine](#axera): axera models can run on AXERA NPUs via AXEngine, delivering highly efficient object detection.
**Qualcomm** <CommunityBadge />
- [Qualcomm](#qualcomm): TFLite models can run on Qualcomm SoCs with a Hexagon NPU (e.g. IQ9100, QCS6490) via the QNN TFLite delegate.
:::
### Hailo-8
@@ -295,6 +299,17 @@ The inference time of a rk3588 with all 3 cores enabled is typically 25-30 ms fo
| Name | AXERA AX650N/AX8850N Inference Time |
| ---------------- | ----------------------------------- |
| yolov9-tiny | ~ 4 ms |
### Qualcomm
Frigate supports hardware object detection on Qualcomm SoCs with a Hexagon NPU, including the IQ9100 and QCS6490 (RB3 Gen 2). The QNN TFLite delegate is used to accelerate inference on the HTP (Hexagon Tensor Processor).
A single detector is typically sufficient for multiple camera streams. The default model is **SSD MobileDet** (INT8 quantized).
| Name | IQ9100 Inference Time | QCS6490 Inference Time |
| ------------- | --------------------- | ---------------------- |
| ssd_mobiledet | ~ 0.8 ms | ~ 5.55 ms |
Detailed information is available [in the detector docs](/configuration/object_detectors#qualcomm).
## What does Frigate use the CPU for and what does it use a detector for? (ELI5 Version)
+115
View File
@@ -472,6 +472,120 @@ If you are using `docker run`, add this option to your command `--device /dev/ax
#### Configuration
Finally, configure [hardware object detection](/configuration/object_detectors#axera) to complete the setup.
### Qualcomm
Hardware accelerated object detection is supported on the following Qualcomm SoCs:
| Board | SoC | CDI Config File |
| ----- | --- | --------------- |
| IQ-9075 EVK | IQ9100 | `cdi-hw-acc-9100.json` |
| RB3 Gen 2 Vision Kit / Rubik Pi 3 | QCS6490 | `cdi-hw-acc-6490.json` |
The Qualcomm integration uses [CDI (Container Device Interface)](https://docs.docker.com/build/building/cdi/) to provide the container with access to the NPU device nodes and the QNN delegate libraries from the host.
#### Prerequisites
- **QNN runtime on the host**: The QNN runtime libraries (including `libQnnTFLiteDelegate.so` and `libQnnHtp.so`), the Hexagon "skel" files, and the NPU device nodes (`/dev/fastrpc-cdsp`) must be present on the host; they are bind-mounted into the container via CDI. Both officially-supported images for these boards provide them:
- **Qualcomm Linux BSP** — ships them under the layout the bundled CDI descriptors target (`/usr/lib`, `/usr/lib/rfsa/adsp`).
- **Ubuntu for RB3 Gen 2 / Rubik Pi 3** — install the `qairt-libs` and `qairt-tools` packages from the Qualcomm IoT PPA. These board-select the Hexagon skels into a different directory (`/usr/lib/dsp/cdsp`), and `/usr/lib/rfsa/adsp` may contain self-referential symlink loops; `install_cdi.py` detects this and rewrites the affected mounts to the real files automatically (see below).
- **Docker 25.0+**: CDI device support requires Docker 25.0 or later. Check with `docker --version`.
- **arm64 architecture**: The Qualcomm Docker image is built for `linux/arm64` only. If building locally, you must run the build on the target board itself.
#### CDI Installation
1. Download or copy the CDI setup files from the [Frigate repository](https://github.com/blakeblackshear/frigate/tree/dev/docker/qualcomm/cdi).
2. Install the CDI configuration for your board:
```bash
# IQ9100 / IQ-9075 EVK
sudo python3 install_cdi.py --file cdi-hw-acc-9100.json
# QCS6490 / RB3 Gen 2 Vision Kit / Rubik Pi 3
sudo python3 install_cdi.py --file cdi-hw-acc-6490.json
```
This writes a CDI descriptor to `/etc/cdi/cdi-hw-acc.json`. For any mount whose default (BSP) path is absent or a broken symlink, the script first tries to resolve the file from the QAIRT layout (e.g. `/usr/lib/dsp/cdsp`) and rewrites the mount to the real file; paths it cannot resolve anywhere are skipped with a warning. If a **critical** QNN/HTP library is missing it prints a prominent warning — and, with `--strict`, exits non-zero — so a misconfigured host fails at install time instead of silently falling back to CPU at runtime.
3. Verify the descriptor was written and that it contains the QNN delegate and Hexagon skel mounts (their absence means the QNN runtime is not installed on the host):
```bash
ls -l /etc/cdi/cdi-hw-acc.json
grep -oE 'libQnn(TFLiteDelegate|HtpV[0-9]+Skel|System)\.so' /etc/cdi/cdi-hw-acc.json | sort -u
```
#### Setup
Follow Frigate's default installation instructions, but use a docker image with `-qualcomm` suffix, for example `ghcr.io/blakeblackshear/frigate:stable-qualcomm`.
:::note
The pre-built `stable-qualcomm` image is not yet published. Until it is available, you must build the image locally:
```bash
git clone https://github.com/blakeblackshear/frigate.git
cd frigate
make local-qualcomm
```
This builds `frigate:latest-qualcomm` on your device. Use `frigate:latest-qualcomm` as the image name in the examples below instead of the `ghcr.io` URL.
:::
Grant Docker access to your Qualcomm hardware by passing the CDI device. In your `docker-compose.yml`:
```yaml
services:
frigate:
container_name: frigate
restart: unless-stopped
image: ghcr.io/blakeblackshear/frigate:stable-qualcomm
devices:
- qualcomm.com/device=cdi-hw-acc
volumes:
- /etc/localtime:/etc/localtime:ro
- /path/to/your/config:/config
- /path/to/your/storage:/media/frigate
- type: tmpfs
target: /tmp/cache
tmpfs:
size: 1000000000
ports:
- "8971:8971"
- "8554:8554"
- "8555:8555/tcp"
- "8555:8555/udp"
```
If using `docker run`, pass the CDI device with:
```bash
docker run -d \
--name frigate \
--restart=unless-stopped \
--device qualcomm.com/device=cdi-hw-acc \
--mount type=tmpfs,target=/tmp/cache,tmpfs-size=1000000000 \
--shm-size=256m \
-v /path/to/your/storage:/media/frigate \
-v /path/to/your/config:/config \
-v /etc/localtime:/etc/localtime:ro \
-e FRIGATE_RTSP_PASSWORD='password' \
-p 8971:8971 \
-p 8554:8554 \
-p 8555:8555/tcp \
-p 8555:8555/udp \
ghcr.io/blakeblackshear/frigate:stable-qualcomm
```
:::tip
Unlike other hardware integrations that pass individual `/dev/` device nodes, the Qualcomm CDI approach bundles all required device nodes, library bind mounts, and environment variables into a single `--device qualcomm.com/device=cdi-hw-acc` flag.
:::
#### Configuration
Next, you should configure [hardware object detection](/configuration/object_detectors#qualcomm).
## Docker
@@ -558,6 +672,7 @@ The community supported docker image tags for the current stable version are:
- `stable-tensorrt-jp6` - Frigate build optimized for Nvidia Jetson devices running Jetpack 6
- `stable-rk` - Frigate build for SBCs with Rockchip SoC
- `stable-qualcomm` - Frigate build for Qualcomm SoCs with Hexagon NPU (IQ9100, QCS6490)
## Home Assistant App
+48
View File
@@ -0,0 +1,48 @@
"""Qualcomm QNN TFLite delegate detector for Qualcomm NPU acceleration."""
import logging
from pydantic import ConfigDict
from typing_extensions import Literal
from frigate.detectors.detection_api import DetectionApi
from frigate.detectors.detector_config import BaseDetectorConfig
from ..detector_utils import (
tflite_detect_raw,
tflite_init,
tflite_load_delegate_interpreter,
)
logger = logging.getLogger(__name__)
# Use _tfl suffix to default to the bundled tflite model
DETECTOR_KEY = "qualcomm_tfl"
class QualcommDetectorConfig(BaseDetectorConfig):
"""Qualcomm NPU detector using the QNN TFLite delegate to accelerate inference on Qualcomm SoCs with a Hexagon NPU (e.g. IQ9100, QCS6490)."""
model_config = ConfigDict(
title="Qualcomm",
)
type: Literal[DETECTOR_KEY]
class QualcommTfl(DetectionApi):
type_key = DETECTOR_KEY
def __init__(self, detector_config: QualcommDetectorConfig):
# The QNN TFLite delegate library is provided by the host via CDI bind mounts
delegate_library = "libQnnTFLiteDelegate.so"
# Use the Hexagon Tensor Processor (HTP / NPU) backend
device_config = {"backend_type": "htp"}
interpreter = tflite_load_delegate_interpreter(
delegate_library, detector_config, device_config
)
tflite_init(self, interpreter)
def detect_raw(self, tensor_input):
return tflite_detect_raw(self, tensor_input)
+183
View File
@@ -0,0 +1,183 @@
"""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)
+25
View File
@@ -293,26 +293,51 @@ 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:
+4
View File
@@ -413,6 +413,10 @@
"description": "The device to use for OpenVINO inference (e.g. 'CPU', 'GPU', 'NPU')."
}
},
"qualcomm_tfl": {
"label": "Qualcomm",
"description": "Qualcomm NPU detector using the QNN TFLite delegate to accelerate inference on Qualcomm SoCs with a Hexagon NPU (e.g. IQ9100, QCS6490)."
},
"rknn": {
"label": "RKNN",
"description": "RKNN detector for Rockchip NPUs; runs compiled RKNN models on Rockchip hardware.",