From 8085b24d2fe0bade470190f48a0d39eca102823f Mon Sep 17 00:00:00 2001 From: aahmetdurmaz Date: Wed, 26 Aug 2026 12:32:05 +0300 Subject: [PATCH] feat(deepx): enforce model_format requirement when ppu is enabled and add integrity checks for driver installation --- docker/deepx/user_installation.sh | 29 ++++++++++++++++++--- docker/main/install_dxrt.sh | 26 ++++++++++++++++++ docs/docs/configuration/object_detectors.md | 2 ++ frigate/detectors/plugins/deepx.py | 28 +++++++++++++++++--- frigate/test/test_deepx.py | 25 ++++++++++++++++++ 5 files changed, 103 insertions(+), 7 deletions(-) diff --git a/docker/deepx/user_installation.sh b/docker/deepx/user_installation.sh index 2e9564d5ea..12c9508b9c 100644 --- a/docker/deepx/user_installation.sh +++ b/docker/deepx/user_installation.sh @@ -28,6 +28,13 @@ set -euo pipefail driver_version="v2.6.0" +# Commit the tag above resolves to, pinned so a later retag or repoint of +# v2.6.0 upstream can't silently swap in different source between review and +# build, before it's compiled and installed as root. DEEPX doesn't sign tags +# or publish checksums for this repo, so an immutable commit is the strongest +# verification available. Update both together when bumping driver_version: +# git ls-remote https://github.com/DEEPX-AI/dx_rt_npu_linux_driver.git "refs/tags/" +driver_commit="7074748e7104f470b02f517583abba652b3f05fa" firmware_version="v2.7.4" sudo apt-get update @@ -39,9 +46,25 @@ if ! lspci -d 1ff4: | grep -q .; then exit 1 fi -git clone --depth 1 --branch "${driver_version}" \ - https://github.com/DEEPX-AI/dx_rt_npu_linux_driver.git -cd dx_rt_npu_linux_driver/modules +# Fetch the pinned commit directly (GitHub allows fetching a public repo by +# SHA, not just by branch/tag) rather than cloning "${driver_version}", so +# driver_commit above is what actually determines the source, not a ref that +# could move after this script was reviewed. +mkdir dx_rt_npu_linux_driver +cd dx_rt_npu_linux_driver +git init -q +git remote add origin https://github.com/DEEPX-AI/dx_rt_npu_linux_driver.git +git fetch --depth 1 origin "${driver_commit}" +git checkout -q FETCH_HEAD + +fetched_commit=$(git rev-parse HEAD) +if [[ "${fetched_commit}" != "${driver_commit}" ]]; then + echo "Fetched commit ${fetched_commit} does not match pinned driver_commit ${driver_commit}." + echo "Refusing to build unverified driver source." + exit 1 +fi + +cd modules # --reload unloads any running modules and loads the newly built ones, so the # NPU is usable without a reboot. diff --git a/docker/main/install_dxrt.sh b/docker/main/install_dxrt.sh index bee184ebc2..cd0aa04d0c 100644 --- a/docker/main/install_dxrt.sh +++ b/docker/main/install_dxrt.sh @@ -20,6 +20,18 @@ set -euxo pipefail # docs/docs/frigate/installation.md in sync. dxrt_version="v3.4.2" +# SHA-256 of the .deb pinned above, one per architecture Frigate builds for. +# DEEPX doesn't publish checksums or sign this release, so these are computed +# once from a vetted download and hardcoded here as the actual integrity +# check: a download that doesn't match one of these hashes is rejected before +# it reaches apt-get, regardless of whether the mismatch came from the v3.4.2 +# tag being retargeted or the file being replaced upstream. Recompute both +# (`sha256sum libdxrt-bin_*.deb`) when bumping dxrt_version. +declare -A dxrt_sha256=( + [amd64]="da782deac10acb1c997b52ea6d0160c06f112a68ad5e25035cfc8e16de0a8bdc" + [arm64]="161223058938c54efadc581b33499210194da58e55cd5ade332e692ca0d649c2" +) + # The release directory and the package filename both use the bare version, # while the git tag carries a "v". dxrt_release="${dxrt_version#v}" @@ -33,6 +45,20 @@ deb_file="/tmp/libdxrt-bin_${dxrt_release}_${deb_arch}.deb" wget -qO "${deb_file}" \ "https://raw.githubusercontent.com/DEEPX-AI/dx_rt/${dxrt_version}/release/${dxrt_release}/libdxrt-bin_${dxrt_release}_${deb_arch}.deb" +expected_sha256="${dxrt_sha256[${deb_arch}]:-}" +if [[ -z "${expected_sha256}" ]]; then + echo "No pinned SHA-256 for architecture ${deb_arch}; refusing to install an unverified package." + exit 1 +fi +actual_sha256=$(sha256sum "${deb_file}" | cut -d' ' -f1) +if [[ "${actual_sha256}" != "${expected_sha256}" ]]; then + echo "SHA-256 mismatch for ${deb_file}:" + echo " expected ${expected_sha256}" + echo " actual ${actual_sha256}" + echo "Refusing to install a package that doesn't match the pinned checksum." + exit 1 +fi + apt-get -qq update apt-get -qq install -y "${deb_file}" rm -rf /var/lib/apt/lists/* diff --git a/docs/docs/configuration/object_detectors.md b/docs/docs/configuration/object_detectors.md index 42e78f6941..86da689ac5 100644 --- a/docs/docs/configuration/object_detectors.md +++ b/docs/docs/configuration/object_detectors.md @@ -707,6 +707,8 @@ detectors: `ppu` and `model_format` are independent: `ppu` says whether candidate selection already happened on the NPU, and `model_format` says how to read the boxes that come back. Both must match how the model was compiled. +`model_format` is required whenever `ppu: true` is set. A PPU record has the same fixed-width layout for every variant, so unlike the raw output path there is no shape to infer the layout from — leaving it unset is rejected at startup rather than risking a silent anchor-free/anchor-based mismatch. + DX-COM cannot compile `yolov10` or `yolov26` models with PPU support, since their heads already produce final detections. Combining either with `ppu: true` is rejected at startup. #### Multiple NPUs diff --git a/frigate/detectors/plugins/deepx.py b/frigate/detectors/plugins/deepx.py index 4a691e7d61..3fe27c8604 100644 --- a/frigate/detectors/plugins/deepx.py +++ b/frigate/detectors/plugins/deepx.py @@ -441,7 +441,8 @@ class DeepxDetectorConfig(BaseDetectorConfig): description="Selects how the model's output is decoded. Leave unset to " "infer the layout from the output shape, which cannot tell an " "anchor-based head from an anchor-free one and is only reliable for " - "YOLOv8 and newer.", + "YOLOv8 and newer. Required when ppu is true, since a PPU record's " + "layout is identical for every variant and cannot be inferred.", ) score_threshold: float = Field( default=0.25, @@ -464,6 +465,25 @@ class DeepxDetectorConfig(BaseDetectorConfig): return self + @model_validator(mode="after") + def validate_ppu_requires_model_format(self): + """Reject an unset model_format when ppu is enabled. + + A PPU record is the same fixed-width layout for every variant, so + unlike the raw output path there is no shape to infer the layout + from. Leaving model_format unset here used to silently default to + the anchor-based decoder, which corrupts detections from anchor-free + PPU models (e.g. YOLOv8) instead of failing loudly. + """ + if self.ppu and self.model_format is None: + raise ValueError( + "model_format must be set when ppu is true. The PPU output " + "layout can't be inferred, and guessing wrong silently " + "corrupts detections." + ) + + return self + class Deepx(DetectionApi): type_key = DETECTOR_KEY @@ -517,9 +537,9 @@ class Deepx(DetectionApi): def decode(self, outputs: list[np.ndarray]) -> np.ndarray: """Decode raw or PPU output according to the configured YOLO variant.""" if self.ppu: - # An unset model_format keeps the original anchor-based behaviour, - # which is the only layout this detector decoded before the option - # existed. + # Config validation guarantees model_format is set to an anchor + # or anchor-free format whenever ppu is true, since PPU records + # give no other way to tell the two apart. if self.model_format in ANCHOR_FREE_FORMATS: return decode_yolo_ppu_anchor_free( outputs, diff --git a/frigate/test/test_deepx.py b/frigate/test/test_deepx.py index 6a661daaef..5902ef805e 100644 --- a/frigate/test/test_deepx.py +++ b/frigate/test/test_deepx.py @@ -3,6 +3,7 @@ import unittest import numpy as np +from pydantic import ValidationError from frigate.detectors.plugins.deepx import ( ANCHOR_FORMATS, @@ -10,6 +11,7 @@ from frigate.detectors.plugins.deepx import ( NMS_IN_HEAD_FORMATS, PPU_ANCHORS, PPU_RECORD_SIZE, + DeepxDetectorConfig, ModelFormatEnum, decode_yolo_ppu_anchor, decode_yolo_ppu_anchor_free, @@ -160,6 +162,29 @@ class TestDeepxModelFormats(unittest.TestCase): self.assertFalse(ANCHOR_FREE_FORMATS & NMS_IN_HEAD_FORMATS) +class TestDeepxDetectorConfig(unittest.TestCase): + def test_ppu_without_model_format_is_rejected(self): + """PPU records are fixed-width regardless of variant, so an unset + model_format can't be inferred and must not silently fall back to the + anchor decoder.""" + with self.assertRaises(ValidationError): + DeepxDetectorConfig(type="deepx", ppu=True) + + def test_ppu_with_anchor_free_model_format_is_accepted(self): + config = DeepxDetectorConfig( + type="deepx", ppu=True, model_format=ModelFormatEnum.yolov8 + ) + + self.assertEqual(config.model_format, ModelFormatEnum.yolov8) + + def test_raw_without_model_format_is_still_accepted(self): + # the raw path falls back to shape-based inference, so this remains + # optional when ppu is not set + config = DeepxDetectorConfig(type="deepx") + + self.assertIsNone(config.model_format) + + class TestDeepxPpuAnchorFreeDecode(unittest.TestCase): def test_reads_the_box_bytes_as_pixel_geometry(self): """An anchor-free PPU record holds cx, cy, w, h directly in pixels."""