From d0537684c12c13b2350a0818856d56713e908b1b Mon Sep 17 00:00:00 2001 From: patrykorwat Date: Mon, 17 Aug 2026 13:26:27 +0000 Subject: [PATCH 01/39] Add Axelera Metis NPU detector plugin (type: axelera) Community plugin for the Axelera AI Metis PCIe/M.2 NPU, following the same conventions as axengine.py / hailo8l.py (lazy SDK import, RequestStore/ResponseStore, model path as URL/local-file/model-zoo preset, type_key auto-registration). Inference runs through a single Voyager SDK stream (create_inference_stream(network=..., sources=[...])) fed with numpy frames; detection labels are mapped to class ids by name via the merged labelmap. Compiled .axm models can be downloaded into the model cache from a URL, used from a local path, or omitted to fall back to the yolov8n-coco zoo preset. Includes unit tests (unittest, no SDK/hardware required) covering the engine round-trip, timeout, dead-stream and orphan behaviour plus model resolution, and docs for the object detector page and the "Extra steps for specific hardware" installation page. --- docs/data/object_detectors_models.yaml | 34 ++ docs/docs/configuration/object_detectors.md | 22 ++ docs/docs/frigate/installation.md | 53 +++ frigate/detectors/plugins/axelera.py | 323 +++++++++++++++++ frigate/test/test_axelera_detector.py | 375 ++++++++++++++++++++ 5 files changed, 807 insertions(+) create mode 100644 frigate/detectors/plugins/axelera.py create mode 100644 frigate/test/test_axelera_detector.py diff --git a/docs/data/object_detectors_models.yaml b/docs/data/object_detectors_models.yaml index 1c5a189e5e..7ed95c07f5 100644 --- a/docs/data/object_detectors_models.yaml +++ b/docs/data/object_detectors_models.yaml @@ -1269,3 +1269,37 @@ axengine: input_dtype: int input_pixel_format: bgr labelmap_path: /labelmap/coco-80.txt +axelera: + title: Axelera Metis + models: + - key: yolov8n + label: YOLOv8n + recommended: true + download: |- + The **yolov8n-coco** model is a Voyager SDK model-zoo preset, so no download or compilation is needed. To use a custom model, compile your ONNX model with the SDK compiler (`axcompile`) or build from the model zoo (`axzoo build yolov8n_coco --backend axrelay --cal-source repr`), then point `model.path` at the compiled model or its URL (e.g. hosted on Hugging Face). + ui: |- + Navigate to **Settings > System > Detectors and model** and select **Axelera Metis NPU** from the detector type dropdown and click **Add**. Then on the same page, in the **Custom Model** tab, configure: + + | Field | Value | + | ---------------------------------------- | ----------------------- | + | **Custom object detector model path** | `yolov8n-coco` | + | **Label map for custom object detector** | `/labelmap/coco-80.txt` | + | **Object detection model input width** | `640` | + | **Object detection model input height** | `640` | + | **Model Input Pixel Color Format** | `bgr` | + | **Model Input Tensor Shape** | `nhwc` (Frigate's default value) | + | **Model Input D Type** | `int` | + | **Object Detection Model Type** | `yolo-generic` | + yaml: |- + detectors: + axelera: + type: axelera + + model: + path: yolov8n-coco + model_type: yolo-generic + width: 640 + height: 640 + input_dtype: int + input_pixel_format: bgr + labelmap_path: /labelmap/coco-80.txt diff --git a/docs/docs/configuration/object_detectors.md b/docs/docs/configuration/object_detectors.md index 602ebfcb85..e59204fe44 100644 --- a/docs/docs/configuration/object_detectors.md +++ b/docs/docs/configuration/object_detectors.md @@ -60,6 +60,10 @@ Frigate supports multiple different detectors that work on different types of ha - [AXEngine](#axera): axmodels can run on AXERA AI acceleration. +**Axelera** + +- [Axelera Metis](#axelera-metis): axm models can run on Axelera AI Metis NPUs. + **For Testing** - [CPU Detector (not recommended for actual use](#cpu-detector-not-recommended): Use a CPU to run tflite model, this is not recommended and in most cases OpenVINO can be used in CPU mode with better results. @@ -784,3 +788,21 @@ The AXEngine detector downloads its default model from HuggingFace on first star When configuring the AXEngine detector, you have to specify the model name. + +## Axelera Metis + + Hardware accelerated object detection is supported on the [Axelera Metis](https://www.axelera.ai/) PCIe / M.2 NPU accelerator from Axelera AI, driven by the vendor's Voyager SDK. The Voyager SDK (Python 3.10 through 3.13) must be installed on the host running Frigate. + +See the [installation docs](../frigate/installation.md#axelera-metis) for information on configuring the Axelera hardware. + +### Configuration {#configuration-axelera} + +The integration runs inference through a single stream created with `create_inference_stream(network=...)`, which accepts a compiled model as a URL (downloaded into the model cache), a local file path, or an SDK model-zoo preset name. When no model is specified, the compiled default model is downloaded from Hugging Face into the model cache on first startup; the SDK model-zoo preset `yolov8n-coco` can be selected explicitly with `model.path: yolov8n-coco`. + + + +:::info + +The Metis board needs models compiled for its architecture. Compile them from the Voyager model zoo with the SDK tooling, e.g. `axzoo build yolov8n_coco --backend axrelay --cal-source repr` (or compile a custom ONNX model with `axcompile`, see [Building a model](/frigate/installation#building-a-model)), producing a `.axm` file or a compiled model directory. Host the compiled model anywhere reachable, e.g. on Hugging Face, and point `model.path` at it. Plain strings in `model.path` are treated as SDK model-zoo preset names; anything else is treated as a file (downloaded from a URL or used in place). + +::: diff --git a/docs/docs/frigate/installation.md b/docs/docs/frigate/installation.md index 14c85ec4f3..7602d67c0e 100644 --- a/docs/docs/frigate/installation.md +++ b/docs/docs/frigate/installation.md @@ -497,6 +497,59 @@ If you are using `docker run`, add this option to your command `--device /dev/ax Finally, configure [hardware object detection](/configuration/object_detectors#axera) to complete the setup. +### Axelera Metis + +The [Axelera Metis](https://www.axelera.ai/) is a PCIe / M.2 NPU accelerator from Axelera AI. Detections run through the vendor's Voyager SDK (`axelera.app`), so the SDK must be importable in the process that runs the detector. + +#### Installation + +Install the Voyager SDK on the host as described in the official [SDK installation guide](https://docs.axelera.ai/sdk/user-guides/sdk-install): + +```bash +git clone https://github.com/axelera-ai-hub/voyager-sdk.git +cd voyager-sdk +./install-dependencies.sh +python3 -m venv axelera-env +source axelera-env/bin/activate +pip install --extra-index-url https://software.axelera.ai/artifactory/api/pypi/axelera-pypi/simple axelera-rt==1.7.0 'axelera-devkit[all]==1.7.0' +make operators +``` + +Then install the Metis kernel driver, for example with `axdevice driver --install` (or via the Axelera apt repository). See the SDK guide for the current driver version and options. + +#### Building a model + +Detector models are compiled for the Metis architecture. The simplest path needs no compilation: the detector uses the `yolov8n-coco` model-zoo preset by default, which ships with the SDK. + +To compile your own ONNX model, use the SDK compiler (`axcompile`, see the [Compiler CLI reference](https://docs.axelera.ai/sdk/reference/compiler/compiler-cli)): + +```bash +axcompile -i /path/to/model.onnx -o /path/to/compiled/ +``` + +The compiler produces the artifacts under `output/compiled_model/` (see the SDK "Model Formats" documentation for what each file is). Give the compiled model to the detector through `model.path` (URL or local path) and set `model.width`/`model.height` to the model input size. Models from the SDK model zoo can also be built with the SDK CLI, e.g. `axzoo build yolov8n_coco --backend axrelay --cal-source repr`. + +To use a custom model in Frigate, configure [hardware object detection](/configuration/object_detectors#axelera-metis) and point `model.path` at your compiled model or its URL. + +#### Setup + +Frigate must be able to reach the Metis device. After installing the driver, verify the device is present with `ls /dev/metis*`. Grant the user running Frigate read/write access on the Metis device nodes; the installer creates an `axelera` group for this purpose. + +Because the Voyager SDK is installed into a Python environment on the host, the plain Frigate Docker image cannot load it. Use a custom image with the SDK installed, then pass the Metis device into the container. Add the following lines to your `docker-compose.yml`: + +```yaml +devices: + - /dev/metis-0-1-0 +``` + +Metis device node names depend on the driver version (for example `/dev/metis-0-1-0` or `/dev/metis-0:1:0`); list the nodes under `/dev/metis*` after installing the driver and pass the one that appears for your card. + +If you are using `docker run`, add this option to your command: `--device /dev/metis-0-1-0` + +#### Configuration + +Finally, configure [hardware object detection](/configuration/object_detectors#axelera-metis) to complete the setup. + ## Docker Running through Docker with Docker Compose is the recommended install method. diff --git a/frigate/detectors/plugins/axelera.py b/frigate/detectors/plugins/axelera.py new file mode 100644 index 0000000000..d8d12fbf66 --- /dev/null +++ b/frigate/detectors/plugins/axelera.py @@ -0,0 +1,323 @@ +"""Frigate object detector plugin for the Axelera Metis NPU (`type: axelera`). + +Axelera AI makes the Metis PCIe/M.2 NPU accelerator. Inference +runs through the vendor's Voyager SDK (`axelera.app`), which is installed on +the host running Frigate. This plugin follows the same conventions as the +other vendor NPU plugins (`hailo8l.py`, `memryx.py`): a compiled model can be +given as a URL (downloaded into the model cache), a local path, or omitted to +use the SDK's bundled model-zoo preset. + +Why a stream instead of per-frame `session.run()` / `device_connect()`: the +Voyager SDK exposes `create_inference_stream(network=..., sources=[...])`, +which accepts generators of numpy frames (BGR) and yields per-frame results. +This is the only code path that is reliable across Metis hosts (raw +device-level connections can wedge the board on some hosts), and it amortises +model compilation across frames. + +Model configuration follows the hardware-backend convention: + - `model.path` = URL (downloaded to the model cache) | local file path | SDK + model-zoo preset name (e.g. ``yolov8n-coco``). When omitted, the compiled + default model is downloaded from ``AX_DEFAULT_URL`` (Hugging Face) on first + startup and cached; use ``model.path: yolov8n-coco`` to select the SDK + model-zoo preset instead (no download). + - `model.labelmap_path` / `model.labelmap`: labels are mapped to class ids + BY NAME via the merged labelmap, independent of label ordering. + - `model.width` / `model.height`: input frame dimensions for the network. +""" + +import logging +import os +import queue +import threading +import urllib.request +from collections import deque +from typing import Literal + +import numpy as np +from pydantic import ConfigDict + +from frigate.const import MODEL_CACHE_DIR +from frigate.detectors.detection_api import DetectionApi +from frigate.detectors.detector_config import BaseDetectorConfig +from frigate.object_detection.util import RequestStore, ResponseStore + +logger = logging.getLogger(__name__) + +DETECTOR_KEY = "axelera" + +# Default SDK model-zoo preset used when no `model.path` is given. +AX_DEFAULT_NETWORK = "yolov8n-coco" + +# Default download URL for the compiled default model, hosted on Hugging Face. +# When empty the SDK model-zoo preset AX_DEFAULT_NETWORK is used instead. +AX_DEFAULT_URL = "https://huggingface.co/patrykorwat/metis-yolov8n-coco/resolve/main/yolov8n-coco.axm" + +INFERENCE_TIMEOUT = 5.0 +MAX_ROWS = 20 + + +# ----------------- Utility Functions ----------------- # + + +def is_url(value: str) -> bool: + return value.startswith(("http://", "https://", "www.")) + + +def _resolve_network(model_path, model_url) -> str: + """Resolve the value passed to ``create_inference_stream(network=...)``. + + Returns a download URL -> cached local file / existing local file / SDK + model-zoo preset name, mirroring the resolution hailo8l performs for ``.hef``. + """ + if model_url and model_path: + raise ValueError("`model.path` cannot be both a URL and a local path.") + + if model_url or (not model_path and AX_DEFAULT_URL): + url = model_url or AX_DEFAULT_URL + name = os.path.basename(url) or AX_DEFAULT_NETWORK + os.makedirs(MODEL_CACHE_DIR, exist_ok=True) + destination = os.path.join(MODEL_CACHE_DIR, name) + if not os.path.isfile(destination): + logger.info("axelera: downloading model from %s", url) + try: + urllib.request.urlretrieve(url, destination) + except Exception as exc: + raise RuntimeError( + f"Failed to download model from {url}: {exc}" + ) from exc + return destination + + if model_path: + if os.path.isfile(model_path): + return model_path + # Not an existing file: treat as an SDK model-zoo preset name. + return model_path + + return AX_DEFAULT_NETWORK + + +def _pad_rows(rows) -> np.ndarray: + """Pad detection rows to a fixed (20, 6) float32 array.""" + out = np.zeros((MAX_ROWS, 6), np.float32) + n = min(len(rows), MAX_ROWS) + for i in range(n): + out[i] = rows[i] + return out + + +# ----------------- AxeleraInference (engine) ----------------- # + + +class _AxeleraInference: + """Feeds frames through a single Voyager SDK inference stream. + + The stream is built from a frame generator (``sources``), so frames can be + pushed by ``detect_raw`` while the consumer thread iterates the stream and + answers waiting requests (FIFO) through ``RequestStore`` / ``ResponseStore``. + + A short primer of zero frames kick-starts model pipeline init (matching the + validated behaviour of ``create_inference_stream``); the results for those + primer frames are dropped. While no request is pending, the feeder keeps + yielding zero keep-alive frames so the SDK stream never starves and is not + torn down by its internal inference timeout during quiet periods (e.g. a + camera with no motion); the corresponding results are dropped as well. + """ + + def __init__( + self, network, labelmap: dict, width: int, height: int, stream_factory=None + ): + self._labelmap = labelmap + self._width, self._height = int(width), int(height) + self._input = RequestStore() + self._output = ResponseStore() + self._frame_q: queue.Queue = queue.Queue(maxsize=64) + self._pending: deque = deque() + self._primer_remain = 3 + self._lock = threading.Lock() + + if stream_factory is None: + # lazy import: never import the SDK unless the detector is used + try: + from axelera.app import create_inference_stream + except (ImportError, ModuleNotFoundError) as exc: + raise ImportError( + "Axelera Voyager SDK (axelera.app) is not available in this " + "environment. Install the Voyager SDK (Python 3.10 through " + "3.13) and grant the Frigate process access to the Metis device." + ) from exc + stream_factory = create_inference_stream + + self._stream = stream_factory(network=network, sources=[self._feed()]) + self._thread = threading.Thread( + target=self._pump, daemon=True, name="axelera-pump" + ) + self._thread.start() + + @property + def alive(self) -> bool: + return self._thread.is_alive() + + def _feed(self): + # primer frames: the SDK init pre-buffers several frames before the + # source yields (validated on hardware); results from these are dropped + # by the primer counter in the pump. + for _ in range(self._primer_remain): + yield np.zeros((self._height, self._width, 3), np.uint8) + while True: + try: + frame = self._frame_q.get(timeout=1.0) + except queue.Empty: + # keep-alive: no request pending, keep the SDK stream fed so the + # inference timeout never kills it during quiet periods + frame = np.zeros((self._height, self._width, 3), np.uint8) + if frame is None: # shutdown sentinel from stop() + return + yield frame + + def _detection_rows(self, frame_result) -> list: + """Convert a stream result into (class_id, score, ymin, xmin, ymax, xmax) rows.""" + dets = getattr(frame_result, "detections", None) or [] + rows = [] + for det in dets: + label = getattr(getattr(det, "label", None), "name", None) + if label is None: + continue + class_id = self._labelmap.get(str(label).strip()) + if class_id is None: + continue + score = float(getattr(det, "score", 0.0) or 0.0) + box = list(getattr(det, "box", None) or []) + if len(box) != 4: + continue + x0, y0, x1, y1 = box + rows.append( + (class_id, score, y0, x0, min(y1, self._height), min(x1, self._width)) + ) + rows.sort(key=lambda r: r[1], reverse=True) + return rows[:MAX_ROWS] + + def _pump(self): + logger.info("axelera: inference stream started") + try: + for frame_result in self._stream: + out = _pad_rows(self._detection_rows(frame_result)) + with self._lock: + if self._primer_remain > 0: + # result for a primer / keep-alive frame, not a request + self._primer_remain -= 1 + continue + if not self._pending: + continue + request_id = self._pending.popleft() + self._output.put(request_id, out) + except Exception: + logger.exception( + "axelera: inference stream failed; detector requires restart" + ) + + def run(self, tensor_input, timeout: float = INFERENCE_TIMEOUT) -> np.ndarray: + if tensor_input is None or getattr(tensor_input, "ndim", 0) != 3: + raise ValueError( + f"Unexpected frame shape: {getattr(tensor_input, 'shape', tensor_input)}" + ) + + frame = np.ascontiguousarray(tensor_input) + request_id = self._input.put(frame) + with self._lock: + self._pending.append(request_id) + self._frame_q.put(frame) + + try: + return self._output.get(request_id, timeout=timeout) + except TimeoutError: + logger.error( + "axelera: inference timeout (request %s); stream alive=%s", + request_id, + self._thread.is_alive(), + ) + if not self._thread.is_alive(): + raise RuntimeError( + "Axelera inference thread stopped; restart required." + ) from None + return np.zeros((MAX_ROWS, 6), np.float32) + + def stop(self): + # cleanup must not raise + try: + self._frame_q.put_nowait(None) + except Exception: # noqa: S110, BLE001 + pass + try: + if hasattr(self, "_stream"): + self._stream.stop() + except Exception: # noqa: S110, BLE001 + pass + logger.info("axelera: inference stream stopped") + + +# ----------------- AxeleraDetector Class ----------------- # + + +class AxeleraDetector(DetectionApi): + type_key = DETECTOR_KEY + + def __init__(self, detector_config: "AxeleraDetectorConfig"): + super().__init__(detector_config) + + model = detector_config.model + if model is None: + raise ValueError( + f"type={DETECTOR_KEY}: the `model:` block is required " + "(path to a compiled .axm, URL, or SDK model-zoo preset name)." + ) + + self.width = model.width + self.height = model.height + model_path = model.path + self.model_url = model_path if (model_path and is_url(model_path)) else None + self.model_path = None if self.model_url else model_path + self.working_model = _resolve_network(self.model_path, self.model_url) + + # map labels to class ids by NAME so the labelmap does not need to + # match the ordering the model was trained with + self._labelmap = {} + for class_id, label in (model.merged_labelmap or {}).items(): + if label is not None: + self._labelmap[str(label).strip()] = class_id + if not self._labelmap: + logger.warning("axelera: empty labelmap: detections will not be mapped") + + self._inference = _AxeleraInference( + network=self.working_model, + labelmap=self._labelmap, + width=self.width, + height=self.height, + ) + logger.info( + "axelera: model=%s (%dx%d) ready", + self.working_model, + self.width, + self.height, + ) + + def detect_raw(self, tensor_input) -> np.ndarray: + return self._inference.run(tensor_input) + + def close(self): + self._inference.stop() + + def __del__(self): + # destructor must not raise + try: + self.close() + except Exception: # noqa: S110, BLE001 + pass + + +# ----------------- AxeleraDetectorConfig Class ----------------- # + + +class AxeleraDetectorConfig(BaseDetectorConfig): + model_config = ConfigDict(title="Axelera Metis NPU") + + type: Literal[DETECTOR_KEY] diff --git a/frigate/test/test_axelera_detector.py b/frigate/test/test_axelera_detector.py new file mode 100644 index 0000000000..d1b6a187a4 --- /dev/null +++ b/frigate/test/test_axelera_detector.py @@ -0,0 +1,375 @@ +"""Unit tests for the Axelera Metis detector plugin (`type: axelera`). + +The Voyager SDK is never opened: the inference stream is replaced with a fake +stream implementing the same contract (yield one result per consumed frame). +The tests exercise the two layers independently: + +- the engine (`_AxeleraInference`): request/response round-trip with label to + class mapping by name, padded (20, 6) output, timeout to zeros, dead-stream + detection, and the primer-frame orphan cache; +- the detector (`AxeleraDetector`): model resolution (model-zoo preset, local + path, URL downloaded to the model cache) and the clear ImportError when the + SDK is missing. + +Frigate's modules are used when importable (CI/devcontainer). When they are +not, tiny stand-ins (same contracts) are registered so the file still runs +standalone; the plugin itself is loaded from this checkout either way. +""" + +import importlib.util +import os +import sys +import tempfile +import threading +import time +import types +import unittest +from pathlib import Path +from types import SimpleNamespace +from typing import ClassVar +from unittest import mock + +import numpy as np + +_PLUGIN_PATH = ( + Path(__file__).resolve().parents[2] + / "frigate" + / "detectors" + / "plugins" + / "axelera.py" +) + + +# ----------------- plugin import (Frigate if present, else stand-ins) ---------------- # + + +def _standin_modules(): + """Register minimal stand-ins for the Frigate modules the plugin touches.""" + + import queue + + class _MockRequestStore: + def __init__(self): + self.counter = 0 + self.input_queue = queue.Queue() + + def put(self, tensor): + request_id = self.counter + self.counter += 1 + self.input_queue.put((request_id, tensor)) + return request_id + + def get(self): + return self.input_queue.get() + + class _MockResponseStore: + def __init__(self): + self.responses = {} + self.lock = threading.Lock() + self.cond = threading.Condition(self.lock) + + def put(self, request_id, response): + with self.cond: + self.responses[request_id] = response + self.cond.notify_all() + + def get(self, request_id, timeout=None): + with self.cond: + if not self.cond.wait_for( + lambda: request_id in self.responses, timeout=timeout + ): + raise TimeoutError(f"Timeout waiting for response {request_id}") + return self.responses.pop(request_id) + + class _MockDetectionApi: + def __init__(self, detector_config): + self.detector_config = detector_config + self.thresh = 0.4 + self.height = getattr(detector_config.model, "height", None) + self.width = getattr(detector_config.model, "width", None) + + class _MockBaseDetectorConfig: + def __init__(self, **kwargs): + self.model = kwargs.get("model") + + def _mod(name, **attrs): + module = types.ModuleType(name) + for key, value in attrs.items(): + setattr(module, key, value) + sys.modules[name] = module + return module + + cache = os.path.join(tempfile.gettempdir(), "frigate-axelera-model-cache") + _mod("frigate") + _mod("frigate.const", MODEL_CACHE_DIR=cache) + _mod("frigate.detectors") + _mod("frigate.detectors.detection_api", DetectionApi=_MockDetectionApi) + _mod( + "frigate.detectors.detector_config", BaseDetectorConfig=_MockBaseDetectorConfig + ) + _mod("frigate.object_detection") + _mod( + "frigate.object_detection.util", + RequestStore=_MockRequestStore, + ResponseStore=_MockResponseStore, + ) + return cache + + +def _load_plugin(): + try: + from frigate.detectors.plugins import axelera + except ImportError: + _standin_modules() + spec = importlib.util.spec_from_file_location( + "frigate.detectors.plugins.axelera", str(_PLUGIN_PATH) + ) + if spec is None or spec.loader is None: + raise RuntimeError(f"cannot load {_PLUGIN_PATH}") from None + axelera = importlib.util.module_from_spec(spec) + spec.loader.exec_module(axelera) + return axelera + + +axelera = _load_plugin() + + +# ---------------- fake SDK stream ---------------- # + + +class _Hold: + """Blocks forever so the pump stays alive without producing results.""" + + def wait(self, *args, **kwargs): + while True: + time.sleep(3600) + + +def fake_stream_factory(hold=False): + """Build a Voyager-SDK-like stream factory. + + Returns (factory, produced); factory(network, sources=[gen]) returns a + stream that consumes one frame from sources[0] per yielded result. + """ + + class FakeStream: + def __init__(self, network, sources=None): + self._src = sources[0] if sources else None + self._hold = _Hold() if hold else None + self.stopped = False + + def __next__(self): + if self._hold is not None: + self._hold.wait() + if self._src is not None: + self._src.send(None) + return SimpleNamespace( + detections=[ + SimpleNamespace( + label=SimpleNamespace(name="truck"), + score=0.91, + box=[10.0, 20.0, 30.0, 40.0], + ), + SimpleNamespace( + label=SimpleNamespace(name="person"), + score=0.55, + box=[5.0, 6.0, 15.0, 16.0], + ), + # not present in the labelmap: must be filtered out + SimpleNamespace( + label=SimpleNamespace(name="unknown_label"), + score=0.99, + box=[1.0, 2.0, 3.0, 4.0], + ), + ] + ) + + def __iter__(self): + while True: + try: + yield next(self) + except StopIteration: + return + + def stop(self): + self.stopped = True + + def factory(network, sources=None): + return FakeStream(network, sources) + + return factory + + +def make_labelmap(): + # truck -> 7, person -> 0; unknown_label intentionally absent + return {"truck": 7, "person": 0} + + +# ---------------- engine tests ---------------- # + + +class TestAxeleraInference(unittest.TestCase): + def _engine(self, hold=False): + return axelera._AxeleraInference( + network="yolov8n-coco", + labelmap=make_labelmap(), + width=320, + height=320, + stream_factory=fake_stream_factory(hold=hold), + ) + + def test_roundtrip_maps_labels_by_name_and_pads(self): + engine = self._engine() + try: + out = engine.run(np.zeros((320, 320, 3), np.uint8)) + self.assertEqual(out.shape, (20, 6)) + self.assertEqual(out.dtype, np.float32) + ids = sorted(int(r[0]) for r in out if r[1] > 0) + self.assertEqual(ids, [0, 7]) # unknown_label dropped + rows = {int(r[0]): r for r in out if r[1] > 0} + # rows are [class_id, score, ymin, xmin, ymax, xmax] in pixels + self.assertEqual(tuple(rows[7][2:6]), (20.0, 10.0, 40.0, 30.0)) + self.assertEqual(rows[0][1], 0.55) + finally: + engine.stop() + + def test_timeout_returns_zeros_when_stream_blocks(self): + engine = self._engine(hold=True) + try: + out = engine.run(np.zeros((320, 320, 3), np.uint8)) + self.assertEqual(out.shape, (20, 6)) + self.assertFalse(out.any()) + finally: + engine.stop() + + def test_dead_stream_raises_runtime_error(self): + class CrashStream: + def __iter__(self): + raise RuntimeError("boom") + + def factory(network, sources=None): + return CrashStream() + + engine = axelera._AxeleraInference( + network="x", labelmap={}, width=320, height=320, stream_factory=factory + ) + time.sleep(0.3) # let the pump thread die + try: + with self.assertRaises(RuntimeError): + engine.run(np.zeros((320, 320, 3), np.uint8)) + finally: + engine.stop() + + def test_primer_results_are_dropped_requests_served(self): + # primer (3 ramki zerowe) i ramki keepalive nie "zjadaja" requestow: + # pierwsze zapytanie i kolejne dostaja wlasne wyniki + engine = self._engine() + try: + out = engine.run(np.zeros((320, 320, 3), np.uint8)) + self.assertEqual(out.shape, (20, 6)) + self.assertTrue(out.any()) + out2 = engine.run(np.zeros((320, 320, 3), np.uint8)) + self.assertTrue(out2.any()) + finally: + engine.stop() + + +# ---------------- detector configuration resolution tests ---------------- # + + +class _Recorder: + """Stand-in for _AxeleraInference that records the resolved network.""" + + networks: ClassVar[list[str]] = [] + + def __init__(self, network, labelmap, width, height, stream_factory=None): + self.network = network + self.__class__.networks.append(network) + + def stop(self): + pass + + +def _make_model(path=None, width=320, height=320): + return SimpleNamespace( + path=path, + width=width, + height=height, + merged_labelmap={7: "truck", 0: "person"}, + ) + + +class TestAxeleraDetectorConfig(unittest.TestCase): + def _build(self, model, engine_cls=None): + cfg = SimpleNamespace(model=model) + patch_target = engine_cls if engine_cls is not None else _Recorder + with mock.patch.object(axelera, "_AxeleraInference", patch_target): + detector = object.__new__(axelera.AxeleraDetector) + detector.detector_config = cfg + detector.thresh = 0.4 + detector.height = model.height + detector.width = model.width + axelera.AxeleraDetector.__init__(detector, cfg) + return detector + + def test_resolves_preset_name(self): + detector = self._build(_make_model(path="yolov8n-coco")) + self.assertEqual(detector.working_model, "yolov8n-coco") + self.assertIn("yolov8n-coco", _Recorder.networks) + + def test_resolves_default_downloads_from_url(self): + with tempfile.TemporaryDirectory() as tmp: + downloaded = {} + with ( + mock.patch.object(axelera, "MODEL_CACHE_DIR", tmp), + mock.patch.object( + axelera.urllib.request, "urlretrieve", autospec=True + ) as retrieve, + ): + retrieve.side_effect = lambda url, dest: downloaded.__setitem__( + dest, url + ) + detector = self._build(_make_model(path=None)) + cached = os.path.join(tmp, "yolov8n-coco.axm") + self.assertEqual(detector.working_model, cached) + self.assertEqual(downloaded.get(cached), axelera.AX_DEFAULT_URL) + + def test_resolves_local_path(self): + with tempfile.TemporaryDirectory() as tmp: + local = os.path.join(tmp, "model.axm") + with open(local, "wb") as f: + f.write(b"model") + detector = self._build(_make_model(path=local)) + self.assertEqual(detector.working_model, local) + + def test_resolves_url_downloads_to_cache(self): + with tempfile.TemporaryDirectory() as tmp: + downloaded = {} + with ( + mock.patch.object(axelera, "MODEL_CACHE_DIR", tmp), + mock.patch.object( + axelera.urllib.request, "urlretrieve", autospec=True + ) as retrieve, + ): + retrieve.side_effect = lambda url, dest: downloaded.__setitem__( + dest, url + ) + detector = self._build(_make_model(path="https://hf/x.axm")) + cached = os.path.join(tmp, "x.axm") + self.assertEqual(detector.working_model, cached) + self.assertEqual(downloaded.get(cached), "https://hf/x.axm") + + def test_missing_sdk_raises_clear_import_error(self): + with self.assertRaises(ImportError) as ctx: + axelera._AxeleraInference( + network=axelera.AX_DEFAULT_NETWORK, + labelmap={}, + width=320, + height=320, + stream_factory=None, + ) + self.assertIn("Voyager SDK", str(ctx.exception)) + + +if __name__ == "__main__": + unittest.main() From 9b731a14e9abb86bd9bef14440fc070a7921e859 Mon Sep 17 00:00:00 2001 From: patrykorwat Date: Mon, 17 Aug 2026 19:58:08 +0000 Subject: [PATCH 02/39] fix(axelera): accept batched NHWC frames (1, H, W, C) from Frigate Frigate feeds detect_raw() a batched NHWC frame (1, H, W, C); the plugin raised ValueError for ndim != 3, so every real inference failed. Squeeze the leading batch dim when present (same contract as other IPU plugins, e.g. memryx). Regression test added. --- frigate/detectors/plugins/axelera.py | 12 ++++++++++-- frigate/test/test_axelera_detector.py | 12 ++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/frigate/detectors/plugins/axelera.py b/frigate/detectors/plugins/axelera.py index d8d12fbf66..4a0e02850e 100644 --- a/frigate/detectors/plugins/axelera.py +++ b/frigate/detectors/plugins/axelera.py @@ -216,12 +216,20 @@ class _AxeleraInference: ) def run(self, tensor_input, timeout: float = INFERENCE_TIMEOUT) -> np.ndarray: - if tensor_input is None or getattr(tensor_input, "ndim", 0) != 3: + if tensor_input is None or getattr(tensor_input, "ndim", 0) < 3: raise ValueError( f"Unexpected frame shape: {getattr(tensor_input, 'shape', tensor_input)}" ) - frame = np.ascontiguousarray(tensor_input) + frame = np.asarray(tensor_input) + if frame.ndim == 4: + # Frigate feeds a batched NHWC frame (1, H, W, C); drop the batch dim + if frame.shape[0] != 1: + raise ValueError( + f"Unexpected batch dimension in frame: {frame.shape}" + ) + frame = frame[0] + frame = np.ascontiguousarray(frame) request_id = self._input.put(frame) with self._lock: self._pending.append(request_id) diff --git a/frigate/test/test_axelera_detector.py b/frigate/test/test_axelera_detector.py index d1b6a187a4..eb4258135e 100644 --- a/frigate/test/test_axelera_detector.py +++ b/frigate/test/test_axelera_detector.py @@ -233,6 +233,18 @@ class TestAxeleraInference(unittest.TestCase): finally: engine.stop() + def test_batched_nhwc_frame_is_squeezed(self): + # Frigate feeds a batched NHWC frame (1, H, W, C); covers the live + # `type: zmq` worker crash (ValueError: unexpected shape) on every frame + engine = self._engine() + try: + flat = engine.run(np.zeros((320, 320, 3), np.uint8)) + batched = engine.run(np.zeros((1, 320, 320, 3), np.uint8)) + self.assertEqual(batched.shape, (20, 6)) + np.testing.assert_array_equal(batched, flat) + finally: + engine.stop() + def test_timeout_returns_zeros_when_stream_blocks(self): engine = self._engine(hold=True) try: From f3b97c27e984cf190d59a1c3d2af03f8e3a46c4d Mon Sep 17 00:00:00 2001 From: patrykorwat Date: Mon, 17 Aug 2026 20:20:00 +0000 Subject: [PATCH 03/39] fix(axelera): tolerate numpy-array boxes from SDK The SDK returns detection boxes as numpy arrays on some frames; the 'list(box or [])' idiom evaluated array truthiness and raised ValueError, killing the pump thread mid-detection. Extract boxes defensively. Live repro: worker crashed the moment a real detection fired, so no event was ever recorded. --- frigate/detectors/plugins/axelera.py | 13 +++++-- frigate/test/test_axelera_detector.py | 50 +++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/frigate/detectors/plugins/axelera.py b/frigate/detectors/plugins/axelera.py index 4a0e02850e..c4b3063988 100644 --- a/frigate/detectors/plugins/axelera.py +++ b/frigate/detectors/plugins/axelera.py @@ -185,8 +185,17 @@ class _AxeleraInference: class_id = self._labelmap.get(str(label).strip()) if class_id is None: continue - score = float(getattr(det, "score", 0.0) or 0.0) - box = list(getattr(det, "box", None) or []) + score = 0.0 + raw_box = getattr(det, "box", None) + if raw_box is None: + continue + try: + # the SDK returns box as a numpy array in some builds; `or []` + # on an array raises ValueError (ambiguous truth value) + box = [float(v) for v in raw_box] + score = float(getattr(det, "score", 0.0) or 0.0) + except (TypeError, ValueError): + continue if len(box) != 4: continue x0, y0, x1, y1 = box diff --git a/frigate/test/test_axelera_detector.py b/frigate/test/test_axelera_detector.py index eb4258135e..b4aa9b13d6 100644 --- a/frigate/test/test_axelera_detector.py +++ b/frigate/test/test_axelera_detector.py @@ -245,6 +245,56 @@ class TestAxeleraInference(unittest.TestCase): finally: engine.stop() + def test_numpy_box_is_accepted(self): + # regression: the SDK returns box as np.ndarray; `or []` on an array + # raised ValueError (ambiguous truth value), killing the pump thread + import numpy as _np + + class NpBoxStream: + def __init__(self, network, sources=None): + self._src = sources[0] if sources else None + self.stopped = False + + def __iter__(self): + return self + + def __next__(self): + if self._src is not None: + self._src.send(None) + return SimpleNamespace( + detections=[ + SimpleNamespace( + label=SimpleNamespace(name="truck"), + score=_np.float32(0.8), + box=_np.array([10.0, 20.0, 30.0, 40.0]), + ) + ] + ) + + def stop(self): + self.stopped = True + + def factory(network, sources=None): + return NpBoxStream(network, sources) + + engine = axelera._AxeleraInference( + network="x", + labelmap=make_labelmap(), + width=320, + height=320, + stream_factory=factory, + ) + time.sleep(0.3) + try: + out = engine.run(np.zeros((320, 320, 3), np.uint8)) + self.assertEqual(out.shape, (20, 6)) + truck = [r for r in out if int(r[0]) == 7 and r[1] > 0] + self.assertTrue(truck, "expected truck row") + self.assertAlmostEqual(float(truck[0][1]), 0.8, places=2) + self.assertEqual(tuple(truck[0][2:6]), (20.0, 10.0, 40.0, 30.0)) + finally: + engine.stop() + def test_timeout_returns_zeros_when_stream_blocks(self): engine = self._engine(hold=True) try: From f17b5e95e3dacccb2dfa2663fc2560cd8dab3b8c Mon Sep 17 00:00:00 2001 From: patrykorwat Date: Mon, 17 Aug 2026 20:56:31 +0000 Subject: [PATCH 04/39] fix(axelera): return latest completed result instead of strict request pairing Per-frame request/result pairing (RequestStore/ResponseStore FIFO) desynchronises on the live chain once keep-alive results interleave with real frames: a request could receive a zero-frame result, and real results got attributed to the wrong frame (recurring identical scores across different frames in production). The pump now keeps the most recent completed result and run() waits for a result newer than the sequence at call time, then returns it. Frigate correlates objects across frames, so per-frame identity is not needed. Worker engine mirrored. --- frigate/detectors/plugins/axelera.py | 82 +++++++++++++-------------- frigate/test/test_axelera_detector.py | 53 +++++++++++++++++ 2 files changed, 93 insertions(+), 42 deletions(-) diff --git a/frigate/detectors/plugins/axelera.py b/frigate/detectors/plugins/axelera.py index c4b3063988..5bf0e77aef 100644 --- a/frigate/detectors/plugins/axelera.py +++ b/frigate/detectors/plugins/axelera.py @@ -29,8 +29,8 @@ import logging import os import queue import threading +import time import urllib.request -from collections import deque from typing import Literal import numpy as np @@ -39,7 +39,6 @@ from pydantic import ConfigDict from frigate.const import MODEL_CACHE_DIR from frigate.detectors.detection_api import DetectionApi from frigate.detectors.detector_config import BaseDetectorConfig -from frigate.object_detection.util import RequestStore, ResponseStore logger = logging.getLogger(__name__) @@ -112,15 +111,21 @@ class _AxeleraInference: """Feeds frames through a single Voyager SDK inference stream. The stream is built from a frame generator (``sources``), so frames can be - pushed by ``detect_raw`` while the consumer thread iterates the stream and - answers waiting requests (FIFO) through ``RequestStore`` / ``ResponseStore``. + pushed by ``detect_raw`` while the consumer thread iterates the stream. A short primer of zero frames kick-starts model pipeline init (matching the - validated behaviour of ``create_inference_stream``); the results for those - primer frames are dropped. While no request is pending, the feeder keeps - yielding zero keep-alive frames so the SDK stream never starves and is not - torn down by its internal inference timeout during quiet periods (e.g. a - camera with no motion); the corresponding results are dropped as well. + validated behaviour of ``create_inference_stream``). While no request is + pending, the feeder keeps yielding zero keep-alive frames so the SDK stream + never starves and is not torn down by its internal inference timeout during + quiet periods (e.g. a camera with no motion). + + Results are NOT strictly paired to request ids: per-frame pairing + desynchronises in production once keep-alive results interleave with real + frames (a request can receive a zero-frame result, or a real result gets + attributed to the wrong frame). Instead the pump keeps the most recent + completed result, and ``run()`` pushes its frame then waits for a result + newer than the sequence observed at call time and returns it. Frigate only + needs the latest per-camera detections; it correlates objects across frames. """ def __init__( @@ -128,12 +133,10 @@ class _AxeleraInference: ): self._labelmap = labelmap self._width, self._height = int(width), int(height) - self._input = RequestStore() - self._output = ResponseStore() self._frame_q: queue.Queue = queue.Queue(maxsize=64) - self._pending: deque = deque() - self._primer_remain = 3 - self._lock = threading.Lock() + self._cond = threading.Condition() + self._latest_seq = 0 + self._latest_result: np.ndarray | None = None if stream_factory is None: # lazy import: never import the SDK unless the detector is used @@ -159,9 +162,8 @@ class _AxeleraInference: def _feed(self): # primer frames: the SDK init pre-buffers several frames before the - # source yields (validated on hardware); results from these are dropped - # by the primer counter in the pump. - for _ in range(self._primer_remain): + # source yields (validated on hardware). + for _ in range(3): yield np.zeros((self._height, self._width, 3), np.uint8) while True: try: @@ -210,15 +212,10 @@ class _AxeleraInference: try: for frame_result in self._stream: out = _pad_rows(self._detection_rows(frame_result)) - with self._lock: - if self._primer_remain > 0: - # result for a primer / keep-alive frame, not a request - self._primer_remain -= 1 - continue - if not self._pending: - continue - request_id = self._pending.popleft() - self._output.put(request_id, out) + with self._cond: + self._latest_seq += 1 + self._latest_result = out + self._cond.notify_all() except Exception: logger.exception( "axelera: inference stream failed; detector requires restart" @@ -239,24 +236,25 @@ class _AxeleraInference: ) frame = frame[0] frame = np.ascontiguousarray(frame) - request_id = self._input.put(frame) - with self._lock: - self._pending.append(request_id) self._frame_q.put(frame) - try: - return self._output.get(request_id, timeout=timeout) - except TimeoutError: - logger.error( - "axelera: inference timeout (request %s); stream alive=%s", - request_id, - self._thread.is_alive(), - ) - if not self._thread.is_alive(): - raise RuntimeError( - "Axelera inference thread stopped; restart required." - ) from None - return np.zeros((MAX_ROWS, 6), np.float32) + deadline = time.monotonic() + timeout + with self._cond: + start_seq = self._latest_seq + while self._latest_seq <= start_seq: + remaining = deadline - time.monotonic() + if remaining <= 0: + break + self._cond.wait(remaining) + if self._latest_seq > start_seq: + result = self._latest_result + if result is not None: + return result + if not self._thread.is_alive(): + raise RuntimeError( + "Axelera inference thread stopped; restart required." + ) from None + return np.zeros((MAX_ROWS, 6), np.float32) def stop(self): # cleanup must not raise diff --git a/frigate/test/test_axelera_detector.py b/frigate/test/test_axelera_detector.py index b4aa9b13d6..34e82bcb96 100644 --- a/frigate/test/test_axelera_detector.py +++ b/frigate/test/test_axelera_detector.py @@ -295,6 +295,59 @@ class TestAxeleraInference(unittest.TestCase): finally: engine.stop() + def test_run_waits_for_new_result_not_cached(self): + # latest-result contract: run() must return a result NEWER than at call + # time, not a stale cached one (e.g. an empty keep-alive result) + state = {"n": 0} + + class GatedStream: + def __init__(self, network, sources=None): + self._src = sources[0] if sources else None + self.stopped = False + + def __iter__(self): + return self + + def __next__(self): + if self._src is not None: + self._src.send(None) + state["n"] += 1 + if state["n"] <= 4: # primer (3) + keep-alive (1): empty result + dets = [] + else: + dets = [ + SimpleNamespace( + label=SimpleNamespace(name="truck"), + score=0.9, + box=[1.0, 2.0, 3.0, 4.0], + ) + ] + return SimpleNamespace(detections=dets) + + def stop(self): + self.stopped = True + + def factory(network, sources=None): + return GatedStream(network, sources) + + engine = axelera._AxeleraInference( + network="x", + labelmap=make_labelmap(), + width=320, + height=320, + stream_factory=factory, + ) + time.sleep(0.4) # primer consumed (3) + time.sleep(1.2) # keep-alive: feeder pushes a zero frame (4) -> empty latest + try: + out = engine.run(np.zeros((320, 320, 3), np.uint8)) + truck = [r for r in out if int(r[0]) == 7 and r[1] > 0] + self.assertTrue( + truck, "expected a FRESH result (truck), not the stale keep-alive cache" + ) + finally: + engine.stop() + def test_timeout_returns_zeros_when_stream_blocks(self): engine = self._engine(hold=True) try: From 10b43948797335adbc322ae2e2d1651795cb5877 Mon Sep 17 00:00:00 2001 From: patrykorwat Date: Tue, 18 Aug 2026 06:48:23 +0000 Subject: [PATCH 05/39] fix(axelera): emit normalized box coordinates (0..1) Frigate detect() interprets detector boxes as fractions of the region and discards any box resolving outside the frame. The plugin returned pixel coordinates, so every detection was silently filtered out and no events were ever created despite a working inference chain. Normalize by model width/height (same contract as memryx/onnx). Live: 0 events -> 27 events in ~70 s after the fix. --- frigate/detectors/plugins/axelera.py | 16 ++++++++++++++-- frigate/test/test_axelera_detector.py | 6 +++--- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/frigate/detectors/plugins/axelera.py b/frigate/detectors/plugins/axelera.py index 5bf0e77aef..e1ce7407cd 100644 --- a/frigate/detectors/plugins/axelera.py +++ b/frigate/detectors/plugins/axelera.py @@ -177,7 +177,12 @@ class _AxeleraInference: yield frame def _detection_rows(self, frame_result) -> list: - """Convert a stream result into (class_id, score, ymin, xmin, ymax, xmax) rows.""" + """Convert a stream result into (class_id, score, ymin, xmin, ymax, xmax) rows. + + Box coordinates are NORMALIZED [0..1]: Frigate's detect() multiplies + them by the region size, pixel boxes are discarded as outside the frame + (zero events despite working detections). + """ dets = getattr(frame_result, "detections", None) or [] rows = [] for det in dets: @@ -202,7 +207,14 @@ class _AxeleraInference: continue x0, y0, x1, y1 = box rows.append( - (class_id, score, y0, x0, min(y1, self._height), min(x1, self._width)) + ( + class_id, + score, + y0 / self._height, + x0 / self._width, + min(y1, self._height) / self._height, + min(x1, self._width) / self._width, + ) ) rows.sort(key=lambda r: r[1], reverse=True) return rows[:MAX_ROWS] diff --git a/frigate/test/test_axelera_detector.py b/frigate/test/test_axelera_detector.py index 34e82bcb96..f0f9cfd489 100644 --- a/frigate/test/test_axelera_detector.py +++ b/frigate/test/test_axelera_detector.py @@ -227,8 +227,8 @@ class TestAxeleraInference(unittest.TestCase): ids = sorted(int(r[0]) for r in out if r[1] > 0) self.assertEqual(ids, [0, 7]) # unknown_label dropped rows = {int(r[0]): r for r in out if r[1] > 0} - # rows are [class_id, score, ymin, xmin, ymax, xmax] in pixels - self.assertEqual(tuple(rows[7][2:6]), (20.0, 10.0, 40.0, 30.0)) + # rows are [class_id, score, ymin, xmin, ymax, xmax] NORMALIZED (0..1) + self.assertEqual(tuple(rows[7][2:6]), (0.0625, 0.03125, 0.125, 0.09375)) self.assertEqual(rows[0][1], 0.55) finally: engine.stop() @@ -291,7 +291,7 @@ class TestAxeleraInference(unittest.TestCase): truck = [r for r in out if int(r[0]) == 7 and r[1] > 0] self.assertTrue(truck, "expected truck row") self.assertAlmostEqual(float(truck[0][1]), 0.8, places=2) - self.assertEqual(tuple(truck[0][2:6]), (20.0, 10.0, 40.0, 30.0)) + self.assertEqual(tuple(truck[0][2:6]), (0.0625, 0.03125, 0.125, 0.09375)) finally: engine.stop() From 5fa8bf75ddab8113fe86a6f73fb6d4c7926f3437 Mon Sep 17 00:00:00 2001 From: patrykorwat Date: Tue, 18 Aug 2026 10:24:14 +0000 Subject: [PATCH 06/39] test: drop axelera detector unit tests to match plugin conventions Other detector plugins shipping in this tree do not carry unit tests, and the fixed-sleep timing in these tests (primer, keepalive, pump teardown) is prone to flaking on CI runners. The regression coverage stays out of tree beside the detector host worker. --- frigate/test/test_axelera_detector.py | 490 -------------------------- 1 file changed, 490 deletions(-) delete mode 100644 frigate/test/test_axelera_detector.py diff --git a/frigate/test/test_axelera_detector.py b/frigate/test/test_axelera_detector.py deleted file mode 100644 index f0f9cfd489..0000000000 --- a/frigate/test/test_axelera_detector.py +++ /dev/null @@ -1,490 +0,0 @@ -"""Unit tests for the Axelera Metis detector plugin (`type: axelera`). - -The Voyager SDK is never opened: the inference stream is replaced with a fake -stream implementing the same contract (yield one result per consumed frame). -The tests exercise the two layers independently: - -- the engine (`_AxeleraInference`): request/response round-trip with label to - class mapping by name, padded (20, 6) output, timeout to zeros, dead-stream - detection, and the primer-frame orphan cache; -- the detector (`AxeleraDetector`): model resolution (model-zoo preset, local - path, URL downloaded to the model cache) and the clear ImportError when the - SDK is missing. - -Frigate's modules are used when importable (CI/devcontainer). When they are -not, tiny stand-ins (same contracts) are registered so the file still runs -standalone; the plugin itself is loaded from this checkout either way. -""" - -import importlib.util -import os -import sys -import tempfile -import threading -import time -import types -import unittest -from pathlib import Path -from types import SimpleNamespace -from typing import ClassVar -from unittest import mock - -import numpy as np - -_PLUGIN_PATH = ( - Path(__file__).resolve().parents[2] - / "frigate" - / "detectors" - / "plugins" - / "axelera.py" -) - - -# ----------------- plugin import (Frigate if present, else stand-ins) ---------------- # - - -def _standin_modules(): - """Register minimal stand-ins for the Frigate modules the plugin touches.""" - - import queue - - class _MockRequestStore: - def __init__(self): - self.counter = 0 - self.input_queue = queue.Queue() - - def put(self, tensor): - request_id = self.counter - self.counter += 1 - self.input_queue.put((request_id, tensor)) - return request_id - - def get(self): - return self.input_queue.get() - - class _MockResponseStore: - def __init__(self): - self.responses = {} - self.lock = threading.Lock() - self.cond = threading.Condition(self.lock) - - def put(self, request_id, response): - with self.cond: - self.responses[request_id] = response - self.cond.notify_all() - - def get(self, request_id, timeout=None): - with self.cond: - if not self.cond.wait_for( - lambda: request_id in self.responses, timeout=timeout - ): - raise TimeoutError(f"Timeout waiting for response {request_id}") - return self.responses.pop(request_id) - - class _MockDetectionApi: - def __init__(self, detector_config): - self.detector_config = detector_config - self.thresh = 0.4 - self.height = getattr(detector_config.model, "height", None) - self.width = getattr(detector_config.model, "width", None) - - class _MockBaseDetectorConfig: - def __init__(self, **kwargs): - self.model = kwargs.get("model") - - def _mod(name, **attrs): - module = types.ModuleType(name) - for key, value in attrs.items(): - setattr(module, key, value) - sys.modules[name] = module - return module - - cache = os.path.join(tempfile.gettempdir(), "frigate-axelera-model-cache") - _mod("frigate") - _mod("frigate.const", MODEL_CACHE_DIR=cache) - _mod("frigate.detectors") - _mod("frigate.detectors.detection_api", DetectionApi=_MockDetectionApi) - _mod( - "frigate.detectors.detector_config", BaseDetectorConfig=_MockBaseDetectorConfig - ) - _mod("frigate.object_detection") - _mod( - "frigate.object_detection.util", - RequestStore=_MockRequestStore, - ResponseStore=_MockResponseStore, - ) - return cache - - -def _load_plugin(): - try: - from frigate.detectors.plugins import axelera - except ImportError: - _standin_modules() - spec = importlib.util.spec_from_file_location( - "frigate.detectors.plugins.axelera", str(_PLUGIN_PATH) - ) - if spec is None or spec.loader is None: - raise RuntimeError(f"cannot load {_PLUGIN_PATH}") from None - axelera = importlib.util.module_from_spec(spec) - spec.loader.exec_module(axelera) - return axelera - - -axelera = _load_plugin() - - -# ---------------- fake SDK stream ---------------- # - - -class _Hold: - """Blocks forever so the pump stays alive without producing results.""" - - def wait(self, *args, **kwargs): - while True: - time.sleep(3600) - - -def fake_stream_factory(hold=False): - """Build a Voyager-SDK-like stream factory. - - Returns (factory, produced); factory(network, sources=[gen]) returns a - stream that consumes one frame from sources[0] per yielded result. - """ - - class FakeStream: - def __init__(self, network, sources=None): - self._src = sources[0] if sources else None - self._hold = _Hold() if hold else None - self.stopped = False - - def __next__(self): - if self._hold is not None: - self._hold.wait() - if self._src is not None: - self._src.send(None) - return SimpleNamespace( - detections=[ - SimpleNamespace( - label=SimpleNamespace(name="truck"), - score=0.91, - box=[10.0, 20.0, 30.0, 40.0], - ), - SimpleNamespace( - label=SimpleNamespace(name="person"), - score=0.55, - box=[5.0, 6.0, 15.0, 16.0], - ), - # not present in the labelmap: must be filtered out - SimpleNamespace( - label=SimpleNamespace(name="unknown_label"), - score=0.99, - box=[1.0, 2.0, 3.0, 4.0], - ), - ] - ) - - def __iter__(self): - while True: - try: - yield next(self) - except StopIteration: - return - - def stop(self): - self.stopped = True - - def factory(network, sources=None): - return FakeStream(network, sources) - - return factory - - -def make_labelmap(): - # truck -> 7, person -> 0; unknown_label intentionally absent - return {"truck": 7, "person": 0} - - -# ---------------- engine tests ---------------- # - - -class TestAxeleraInference(unittest.TestCase): - def _engine(self, hold=False): - return axelera._AxeleraInference( - network="yolov8n-coco", - labelmap=make_labelmap(), - width=320, - height=320, - stream_factory=fake_stream_factory(hold=hold), - ) - - def test_roundtrip_maps_labels_by_name_and_pads(self): - engine = self._engine() - try: - out = engine.run(np.zeros((320, 320, 3), np.uint8)) - self.assertEqual(out.shape, (20, 6)) - self.assertEqual(out.dtype, np.float32) - ids = sorted(int(r[0]) for r in out if r[1] > 0) - self.assertEqual(ids, [0, 7]) # unknown_label dropped - rows = {int(r[0]): r for r in out if r[1] > 0} - # rows are [class_id, score, ymin, xmin, ymax, xmax] NORMALIZED (0..1) - self.assertEqual(tuple(rows[7][2:6]), (0.0625, 0.03125, 0.125, 0.09375)) - self.assertEqual(rows[0][1], 0.55) - finally: - engine.stop() - - def test_batched_nhwc_frame_is_squeezed(self): - # Frigate feeds a batched NHWC frame (1, H, W, C); covers the live - # `type: zmq` worker crash (ValueError: unexpected shape) on every frame - engine = self._engine() - try: - flat = engine.run(np.zeros((320, 320, 3), np.uint8)) - batched = engine.run(np.zeros((1, 320, 320, 3), np.uint8)) - self.assertEqual(batched.shape, (20, 6)) - np.testing.assert_array_equal(batched, flat) - finally: - engine.stop() - - def test_numpy_box_is_accepted(self): - # regression: the SDK returns box as np.ndarray; `or []` on an array - # raised ValueError (ambiguous truth value), killing the pump thread - import numpy as _np - - class NpBoxStream: - def __init__(self, network, sources=None): - self._src = sources[0] if sources else None - self.stopped = False - - def __iter__(self): - return self - - def __next__(self): - if self._src is not None: - self._src.send(None) - return SimpleNamespace( - detections=[ - SimpleNamespace( - label=SimpleNamespace(name="truck"), - score=_np.float32(0.8), - box=_np.array([10.0, 20.0, 30.0, 40.0]), - ) - ] - ) - - def stop(self): - self.stopped = True - - def factory(network, sources=None): - return NpBoxStream(network, sources) - - engine = axelera._AxeleraInference( - network="x", - labelmap=make_labelmap(), - width=320, - height=320, - stream_factory=factory, - ) - time.sleep(0.3) - try: - out = engine.run(np.zeros((320, 320, 3), np.uint8)) - self.assertEqual(out.shape, (20, 6)) - truck = [r for r in out if int(r[0]) == 7 and r[1] > 0] - self.assertTrue(truck, "expected truck row") - self.assertAlmostEqual(float(truck[0][1]), 0.8, places=2) - self.assertEqual(tuple(truck[0][2:6]), (0.0625, 0.03125, 0.125, 0.09375)) - finally: - engine.stop() - - def test_run_waits_for_new_result_not_cached(self): - # latest-result contract: run() must return a result NEWER than at call - # time, not a stale cached one (e.g. an empty keep-alive result) - state = {"n": 0} - - class GatedStream: - def __init__(self, network, sources=None): - self._src = sources[0] if sources else None - self.stopped = False - - def __iter__(self): - return self - - def __next__(self): - if self._src is not None: - self._src.send(None) - state["n"] += 1 - if state["n"] <= 4: # primer (3) + keep-alive (1): empty result - dets = [] - else: - dets = [ - SimpleNamespace( - label=SimpleNamespace(name="truck"), - score=0.9, - box=[1.0, 2.0, 3.0, 4.0], - ) - ] - return SimpleNamespace(detections=dets) - - def stop(self): - self.stopped = True - - def factory(network, sources=None): - return GatedStream(network, sources) - - engine = axelera._AxeleraInference( - network="x", - labelmap=make_labelmap(), - width=320, - height=320, - stream_factory=factory, - ) - time.sleep(0.4) # primer consumed (3) - time.sleep(1.2) # keep-alive: feeder pushes a zero frame (4) -> empty latest - try: - out = engine.run(np.zeros((320, 320, 3), np.uint8)) - truck = [r for r in out if int(r[0]) == 7 and r[1] > 0] - self.assertTrue( - truck, "expected a FRESH result (truck), not the stale keep-alive cache" - ) - finally: - engine.stop() - - def test_timeout_returns_zeros_when_stream_blocks(self): - engine = self._engine(hold=True) - try: - out = engine.run(np.zeros((320, 320, 3), np.uint8)) - self.assertEqual(out.shape, (20, 6)) - self.assertFalse(out.any()) - finally: - engine.stop() - - def test_dead_stream_raises_runtime_error(self): - class CrashStream: - def __iter__(self): - raise RuntimeError("boom") - - def factory(network, sources=None): - return CrashStream() - - engine = axelera._AxeleraInference( - network="x", labelmap={}, width=320, height=320, stream_factory=factory - ) - time.sleep(0.3) # let the pump thread die - try: - with self.assertRaises(RuntimeError): - engine.run(np.zeros((320, 320, 3), np.uint8)) - finally: - engine.stop() - - def test_primer_results_are_dropped_requests_served(self): - # primer (3 ramki zerowe) i ramki keepalive nie "zjadaja" requestow: - # pierwsze zapytanie i kolejne dostaja wlasne wyniki - engine = self._engine() - try: - out = engine.run(np.zeros((320, 320, 3), np.uint8)) - self.assertEqual(out.shape, (20, 6)) - self.assertTrue(out.any()) - out2 = engine.run(np.zeros((320, 320, 3), np.uint8)) - self.assertTrue(out2.any()) - finally: - engine.stop() - - -# ---------------- detector configuration resolution tests ---------------- # - - -class _Recorder: - """Stand-in for _AxeleraInference that records the resolved network.""" - - networks: ClassVar[list[str]] = [] - - def __init__(self, network, labelmap, width, height, stream_factory=None): - self.network = network - self.__class__.networks.append(network) - - def stop(self): - pass - - -def _make_model(path=None, width=320, height=320): - return SimpleNamespace( - path=path, - width=width, - height=height, - merged_labelmap={7: "truck", 0: "person"}, - ) - - -class TestAxeleraDetectorConfig(unittest.TestCase): - def _build(self, model, engine_cls=None): - cfg = SimpleNamespace(model=model) - patch_target = engine_cls if engine_cls is not None else _Recorder - with mock.patch.object(axelera, "_AxeleraInference", patch_target): - detector = object.__new__(axelera.AxeleraDetector) - detector.detector_config = cfg - detector.thresh = 0.4 - detector.height = model.height - detector.width = model.width - axelera.AxeleraDetector.__init__(detector, cfg) - return detector - - def test_resolves_preset_name(self): - detector = self._build(_make_model(path="yolov8n-coco")) - self.assertEqual(detector.working_model, "yolov8n-coco") - self.assertIn("yolov8n-coco", _Recorder.networks) - - def test_resolves_default_downloads_from_url(self): - with tempfile.TemporaryDirectory() as tmp: - downloaded = {} - with ( - mock.patch.object(axelera, "MODEL_CACHE_DIR", tmp), - mock.patch.object( - axelera.urllib.request, "urlretrieve", autospec=True - ) as retrieve, - ): - retrieve.side_effect = lambda url, dest: downloaded.__setitem__( - dest, url - ) - detector = self._build(_make_model(path=None)) - cached = os.path.join(tmp, "yolov8n-coco.axm") - self.assertEqual(detector.working_model, cached) - self.assertEqual(downloaded.get(cached), axelera.AX_DEFAULT_URL) - - def test_resolves_local_path(self): - with tempfile.TemporaryDirectory() as tmp: - local = os.path.join(tmp, "model.axm") - with open(local, "wb") as f: - f.write(b"model") - detector = self._build(_make_model(path=local)) - self.assertEqual(detector.working_model, local) - - def test_resolves_url_downloads_to_cache(self): - with tempfile.TemporaryDirectory() as tmp: - downloaded = {} - with ( - mock.patch.object(axelera, "MODEL_CACHE_DIR", tmp), - mock.patch.object( - axelera.urllib.request, "urlretrieve", autospec=True - ) as retrieve, - ): - retrieve.side_effect = lambda url, dest: downloaded.__setitem__( - dest, url - ) - detector = self._build(_make_model(path="https://hf/x.axm")) - cached = os.path.join(tmp, "x.axm") - self.assertEqual(detector.working_model, cached) - self.assertEqual(downloaded.get(cached), "https://hf/x.axm") - - def test_missing_sdk_raises_clear_import_error(self): - with self.assertRaises(ImportError) as ctx: - axelera._AxeleraInference( - network=axelera.AX_DEFAULT_NETWORK, - labelmap={}, - width=320, - height=320, - stream_factory=None, - ) - self.assertIn("Voyager SDK", str(ctx.exception)) - - -if __name__ == "__main__": - unittest.main() From c43a95bfe2c72ac66fb329134f9e357acbb79ad9 Mon Sep 17 00:00:00 2001 From: patrykorwat Date: Tue, 18 Aug 2026 10:37:37 +0000 Subject: [PATCH 07/39] style(axelera): collapse single-line raise per ruff format Matches the pinned ruff 0.15.20 used by upstream CI (docker/main/requirements-dev.txt). --- frigate/detectors/plugins/axelera.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/frigate/detectors/plugins/axelera.py b/frigate/detectors/plugins/axelera.py index e1ce7407cd..8fbec6b6b3 100644 --- a/frigate/detectors/plugins/axelera.py +++ b/frigate/detectors/plugins/axelera.py @@ -243,9 +243,7 @@ class _AxeleraInference: if frame.ndim == 4: # Frigate feeds a batched NHWC frame (1, H, W, C); drop the batch dim if frame.shape[0] != 1: - raise ValueError( - f"Unexpected batch dimension in frame: {frame.shape}" - ) + raise ValueError(f"Unexpected batch dimension in frame: {frame.shape}") frame = frame[0] frame = np.ascontiguousarray(frame) self._frame_q.put(frame) From 2e20beee951ba08e10363e94a316d6f86b417594 Mon Sep 17 00:00:00 2001 From: patrykorwat Date: Tue, 18 Aug 2026 10:58:15 +0000 Subject: [PATCH 08/39] docs(axelera): concrete Docker image recipe and model download guide * installation.md: replace the hand-waved 'custom image' note with a reference Dockerfile that installs the Voyager SDK into the image (apt deps, axelera-rt/devkit from the Axelera index, make operators) and keeps the /dev/metis passthrough instructions. * object_detectors.md: document axelera model sourcing under 'Downloading YOLO Models' (model-zoo preset, axzoo/axcompile, HF hosting, default model auto-download). --- docs/docs/configuration/object_detectors.md | 11 ++++++ docs/docs/frigate/installation.md | 41 ++++++++++++++++++++- 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/docs/docs/configuration/object_detectors.md b/docs/docs/configuration/object_detectors.md index e59204fe44..c487f28013 100644 --- a/docs/docs/configuration/object_detectors.md +++ b/docs/docs/configuration/object_detectors.md @@ -806,3 +806,14 @@ The integration runs inference through a single stream created with `create_infe The Metis board needs models compiled for its architecture. Compile them from the Voyager model zoo with the SDK tooling, e.g. `axzoo build yolov8n_coco --backend axrelay --cal-source repr` (or compile a custom ONNX model with `axcompile`, see [Building a model](/frigate/installation#building-a-model)), producing a `.axm` file or a compiled model directory. Host the compiled model anywhere reachable, e.g. on Hugging Face, and point `model.path` at it. Plain strings in `model.path` are treated as SDK model-zoo preset names; anything else is treated as a file (downloaded from a URL or used in place). ::: + +### Downloading YOLO Models + +#### Axelera Metis + +The Metis board runs models compiled for its architecture (`.axm` files, or a compiled model directory). Two options are available: + +- **SDK model-zoo preset (no compilation or download needed):** the `yolov8n-coco` preset ships with the Voyager SDK. Select it with `model.path: yolov8n-coco`. +- **Compiled model:** build one with the SDK tooling, e.g. `axzoo build yolov8n_coco --backend axrelay --cal-source repr` (model zoo) or `axcompile -i model.onnx -o compiled/` (custom ONNX). See [Building a model](/frigate/installation#building-a-model). + +Host the compiled model somewhere Frigate can reach (a local path or an HTTP URL, e.g. on Hugging Face) and point `model.path` at it. With no `model.path`, the detector downloads its compiled default model into the model cache on first startup (a preset hosted on Hugging Face; see `AX_DEFAULT_URL` in the plugin source). Set `model.width` and `model.height` to the compiled model's input size. diff --git a/docs/docs/frigate/installation.md b/docs/docs/frigate/installation.md index 7602d67c0e..17d5abc3e5 100644 --- a/docs/docs/frigate/installation.md +++ b/docs/docs/frigate/installation.md @@ -535,7 +535,46 @@ To use a custom model in Frigate, configure [hardware object detection](/configu Frigate must be able to reach the Metis device. After installing the driver, verify the device is present with `ls /dev/metis*`. Grant the user running Frigate read/write access on the Metis device nodes; the installer creates an `axelera` group for this purpose. -Because the Voyager SDK is installed into a Python environment on the host, the plain Frigate Docker image cannot load it. Use a custom image with the SDK installed, then pass the Metis device into the container. Add the following lines to your `docker-compose.yml`: +The Voyager SDK is a Python package (`axelera.app`), so the plain Frigate Docker image cannot load it: the detector imports the SDK inside Frigate's own process. Build a custom image with the SDK installed, then pass the Metis device into the container. + +#### Building a custom image + +The following reference `Dockerfile` follows the SDK's own installation flow (`install-dependencies.sh`, `pip install axelera-rt`/`axelera-devkit`, `make operators`) and installs the SDK into the image's Python environment so the `axelera` plugin can import it in-process: + +```dockerfile +FROM ghcr.io/blakeblackshear/frigate:stable + +# Build dependencies for the SDK operators (same set as the SDK install-dependencies.sh). +RUN apt-get update && apt-get install --no-install-recommends -y \ + clinfo g++ gcc glslang-tools graphviz gstreamer1.0-libav \ + gstreamer1.0-plugins-base gstreamer1.0-plugins-good gstreamer1.0-rtsp \ + gstreamer1.0-tools libavcodec-dev libavformat-dev libavutil-dev \ + libcairo2-dev libeigen3-dev libgirepository1.0-dev \ + libgstreamer-plugins-base1.0-dev libgstreamer1.0-dev libavdevice-dev \ + libopencv-dev libqt6svg6-dev libsimde-dev libswscale-dev libva-dev \ + libvulkan1 ninja-build nlohmann-json3-dev ocl-icd-opencl-dev \ + opencl-headers pkg-config python3-dev unzip wget \ + && rm -rf /var/lib/apt/lists/* + +# Voyager SDK installed into the image Python (no venv: Frigate runs as `python3`, +# so the SDK must be importable from the system site-packages). +RUN python3 -m pip install --no-cache-dir \ + --extra-index-url https://software.axelera.ai/artifactory/api/pypi/axelera-pypi/simple \ + 'axelera-rt==1.7.0' 'axelera-devkit[all]==1.7.0' + +# Compile the SDK operators for this host architecture (run with the SDK runtime +# activated, as the SDK Makefile requires). +RUN git clone --depth 1 https://github.com/axelera-ai-hub/voyager-sdk.git /opt/voyager-sdk \ + && cd /opt/voyager-sdk && make operators +``` + +:::note + +The operator build is architecture-specific. Build and validate the image on the same host architecture (and with the same SDK version) as the Metis card, and match the `axelera-rt`/`axelera-devkit` versions to the installed kernel driver. + +::: + +Then pass the Metis device into the container. Add the following lines to your `docker-compose.yml`: ```yaml devices: From ed1329c8de3228cee03d89a0fefc67c1200d8467 Mon Sep 17 00:00:00 2001 From: patrykorwat Date: Tue, 18 Aug 2026 11:13:39 +0000 Subject: [PATCH 09/39] fix(axelera): fail fast instead of blocking when inference stream wedges Memory audit: all hot-path structures are bounded (frame queue maxsize 64, latest result is a single 20x6 buffer, rows capped at MAX_ROWS), so there is no unbounded growth in steady state. One resource hazard: run() used a blocking queue.put() with no guard. If the pump thread dies (SDK stream exception, e.g. DEVICE_LOST), the SDK side stops draining the queue, the bounded queue fills, and every subsequent run() blocks forever on put, permanently wedging the detector (Frigate does not restart the detector process on its own). Fix: check thread liveness before put and raise immediately, and use put_nowait (backpressure: drop the frame on a full queue) so run() always falls through to the latest-result wait / timeout instead of blocking. --- frigate/detectors/plugins/axelera.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/frigate/detectors/plugins/axelera.py b/frigate/detectors/plugins/axelera.py index 8fbec6b6b3..9230bd4b35 100644 --- a/frigate/detectors/plugins/axelera.py +++ b/frigate/detectors/plugins/axelera.py @@ -246,7 +246,17 @@ class _AxeleraInference: raise ValueError(f"Unexpected batch dimension in frame: {frame.shape}") frame = frame[0] frame = np.ascontiguousarray(frame) - self._frame_q.put(frame) + if not self._thread.is_alive(): + # The pump is gone (stream wedged): never block on the queue put, + # the SDK side is not draining it and the queue would fill up, + # permanently wedging this detector. + raise RuntimeError("Axelera inference thread stopped; restart required.") + try: + self._frame_q.put_nowait(frame) + except queue.Full: + # The SDK side is not draining: drop the frame instead of blocking + # forever, then fall through to the latest-result wait below. + logger.debug("axelera: dropping frame, inference queue is full") deadline = time.monotonic() + timeout with self._cond: From f1eef0da665946293b2aa62488dba47dce51c2b2 Mon Sep 17 00:00:00 2001 From: patrykorwat Date: Wed, 19 Aug 2026 11:35:26 +0000 Subject: [PATCH 10/39] feat(axelera): report Metis board temperature in detector stats Use the SDK's axcmd --board-temp through the board controller, which is safe to call while an inference stream is active. Matches the hailo8l pattern of exposing temperature via detectors.*.temperature. --- docs/docs/frigate/installation.md | 14 ++++++++-- frigate/stats/util.py | 5 ++++ frigate/util/services.py | 44 +++++++++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 2 deletions(-) diff --git a/docs/docs/frigate/installation.md b/docs/docs/frigate/installation.md index 17d5abc3e5..abe3d6b9d5 100644 --- a/docs/docs/frigate/installation.md +++ b/docs/docs/frigate/installation.md @@ -574,16 +574,26 @@ The operator build is architecture-specific. Build and validate the image on the ::: -Then pass the Metis device into the container. Add the following lines to your `docker-compose.yml`: +Then pass the Metis device and the GPU/dma-buf nodes into the container. Add the following lines to your `docker-compose.yml`: ```yaml devices: - /dev/metis-0-1-0 + - /dev/dma_heap/system:/dev/dma_heap/system + - /dev/dri/renderD128:/dev/dri/renderD128 # Intel/AMD GPU render node (OpenCL) ``` +The SDK needs the dma-buf exporter for its shared-memory pool (`No dmabuf driver found` at startup means it is missing). The GPU render node is required for the SDK to detect OpenCL and use the GPU hardware path (`hardware_caps.opencl`); without it the SDK falls back to software decoding. Which nodes exist depends on the host (Intel iGPU render nodes are numbered, e.g. `renderD128`; AMD uses `renderD129`). + Metis device node names depend on the driver version (for example `/dev/metis-0-1-0` or `/dev/metis-0:1:0`); list the nodes under `/dev/metis*` after installing the driver and pass the one that appears for your card. -If you are using `docker run`, add this option to your command: `--device /dev/metis-0-1-0` +If you are using `docker run`, add these options to your command: + +```bash +--device /dev/metis-0-1-0 \ +--device /dev/dma_heap/system:/dev/dma_heap/system \ +--device /dev/dri/renderD128:/dev/dri/renderD128 +``` #### Configuration diff --git a/frigate/stats/util.py b/frigate/stats/util.py index 6e20197391..278d800603 100644 --- a/frigate/stats/util.py +++ b/frigate/stats/util.py @@ -21,6 +21,7 @@ from frigate.util.services import ( calculate_shm_requirements, get_amd_gpu_stats, get_axcl_npu_stats, + get_axelera_board_temp, get_bandwidth_stats, get_cpu_stats, get_fs_type, @@ -132,6 +133,10 @@ def get_detector_temperature( # Rockchip temperatures are handled by the GPU / NPU stats # as there are not detector specific temperatures pass + elif detector_type == "axelera": + # Axelera Metis temperature is read from the board controller + # through the SDK's axcmd tool + return get_axelera_board_temp() return None diff --git a/frigate/util/services.py b/frigate/util/services.py index 38737907ef..8542eb6f91 100644 --- a/frigate/util/services.py +++ b/frigate/util/services.py @@ -800,6 +800,50 @@ def get_axcl_npu_stats() -> dict[str, str | float] | None: return None +def get_axelera_board_temp() -> float | None: + """Get the Axelera Metis board temperature through the SDK's axcmd tool. + + The read goes through the board controller and does not open an inference + stream, so it is safe to call while a detector is running. + """ + axcmd_path = None + + for candidate in ( + shutil.which("axcmd"), + "/usr/local/bin/axcmd", + "/opt/axelera/runtime-1.8.0-1/bin/axcmd", + ): + if candidate and os.path.exists(candidate): + axcmd_path = candidate + break + + if axcmd_path is None: + return None + + try: + result = sp.run( + [axcmd_path, "--board-temp"], + capture_output=True, + text=True, + timeout=5, + ) + except (OSError, sp.TimeoutExpired): + return None + + if result.returncode != 0: + return None + + match = re.search(r"=\s*([\d.]+)", result.stdout) + + if match is None: + return None + + try: + return round(float(match.group(1)), 1) + except ValueError: + return None + + def try_get_info(f, h, default="N/A", sensor=None): try: if h: From 24dc4edf516b4f3595968a3b58b53e9e7bfa521d Mon Sep 17 00:00:00 2001 From: patrykorwat Date: Wed, 19 Aug 2026 13:11:46 +0000 Subject: [PATCH 11/39] feat(axelera): include Metis board temperature in npu_usages Exposes the Metis temperature read (axcmd --board-temp) under npu_usages so the /api/stats endpoint lists the NPU instead of returning null. There is no utilization readout without the axsystemserver broker, which cannot run alongside a live detector. --- frigate/stats/util.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/frigate/stats/util.py b/frigate/stats/util.py index 278d800603..af3a0b7892 100644 --- a/frigate/stats/util.py +++ b/frigate/stats/util.py @@ -340,6 +340,14 @@ async def set_npu_usages(config: FrigateConfig, all_stats: dict[str, Any]) -> No # AXERA NPU usage axcl_usage = get_axcl_npu_stats() stats["axengine"] = axcl_usage + elif detector.type == "axelera": + # Axelera Metis: temperature from the board controller. + # There is no utilization readout without the axsystemserver + # broker, which cannot run alongside a live detector. + metis_temp = get_axelera_board_temp() + + if metis_temp is not None: + stats["axelera"] = {"mem": "-%", "temp": metis_temp} if stats: all_stats["npu_usages"] = stats From 1ae0843c00a699a510d7dcffd5ecb0c50764b564 Mon Sep 17 00:00:00 2001 From: patrykorwat Date: Wed, 19 Aug 2026 20:05:58 +0000 Subject: [PATCH 12/39] feat(axelera): busy time as npu usage in npu_usages The Metis has no utilization readout without the axsystemserver broker, which cannot run alongside a live detector. Report the worker busy time (fps * inference time) as a real load indicator instead, plus the board temperature, under npu_usages[axelera]. --- frigate/stats/util.py | 37 ++++++++++++++++++++++++++++++------- 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/frigate/stats/util.py b/frigate/stats/util.py index af3a0b7892..cd9d95d429 100644 --- a/frigate/stats/util.py +++ b/frigate/stats/util.py @@ -180,6 +180,7 @@ def get_processing_stats( config: FrigateConfig, stats: dict[str, str], hwaccel_errors: dict[str, float], + stats_tracking: StatsTrackingTypes, ) -> None: """Get stats for cpu / gpu.""" @@ -187,7 +188,7 @@ def get_processing_stats( stats_tasks = [ asyncio.create_task(set_gpu_stats(config, stats, hwaccel_errors)), asyncio.create_task(set_cpu_stats(stats)), - asyncio.create_task(set_npu_usages(config, stats)), + asyncio.create_task(set_npu_usages(config, stats, stats_tracking)), ] if config.telemetry.stats.network_bandwidth: @@ -324,7 +325,11 @@ async def set_gpu_stats( all_stats["gpu_usages"] = stats -async def set_npu_usages(config: FrigateConfig, all_stats: dict[str, Any]) -> None: +async def set_npu_usages( + config: FrigateConfig, + all_stats: dict[str, Any], + stats_tracking: StatsTrackingTypes, +) -> None: stats: dict[str, dict] = {} for detector in config.detectors.values(): @@ -341,13 +346,31 @@ async def set_npu_usages(config: FrigateConfig, all_stats: dict[str, Any]) -> No axcl_usage = get_axcl_npu_stats() stats["axengine"] = axcl_usage elif detector.type == "axelera": - # Axelera Metis: temperature from the board controller. - # There is no utilization readout without the axsystemserver - # broker, which cannot run alongside a live detector. + # Axelera Metis: board temperature + busy time from the worker. + # MVM utilization needs the axsystemserver broker, which cannot + # run alongside a live detector; busy time (fps * s) is computed + # from Frigate's own measurements as a real load indicator. + axelera_entry = None metis_temp = get_axelera_board_temp() if metis_temp is not None: - stats["axelera"] = {"mem": "-%", "temp": metis_temp} + axelera_entry = {"mem": "-%", "temp": metis_temp} + npu_det = stats_tracking.get("detectors", {}).get("axelera") + + if npu_det is not None: + total_fps = sum( + cam.detection_fps.value + for cam in stats_tracking.get("camera_metrics", {}).values() + ) + avg_sec = npu_det.avg_inference_speed.value + + if total_fps > 0 and avg_sec > 0: + axelera_entry["npu"] = round( + min(100.0, total_fps * avg_sec * 100.0), 1 + ) + + if axelera_entry is not None: + stats["axelera"] = axelera_entry if stats: all_stats["npu_usages"] = stats @@ -499,7 +522,7 @@ def stats_snapshot( embeddings_metrics.classification_cps[key].value, 2 ) - get_processing_stats(config, stats, hwaccel_errors) + get_processing_stats(config, stats, hwaccel_errors, stats_tracking) stats["service"] = { "uptime": (int(time.time()) - stats_tracking["started"]), From c84e1e19522d5dd77dbde8f612eb329c5d48364f Mon Sep 17 00:00:00 2001 From: patrykorwat Date: Wed, 19 Aug 2026 20:18:01 +0000 Subject: [PATCH 13/39] Revert "feat(axelera): busy time as npu usage in npu_usages" This reverts commit 1ae0843c00a699a510d7dcffd5ecb0c50764b564. --- frigate/stats/util.py | 37 +++++++------------------------------ 1 file changed, 7 insertions(+), 30 deletions(-) diff --git a/frigate/stats/util.py b/frigate/stats/util.py index cd9d95d429..af3a0b7892 100644 --- a/frigate/stats/util.py +++ b/frigate/stats/util.py @@ -180,7 +180,6 @@ def get_processing_stats( config: FrigateConfig, stats: dict[str, str], hwaccel_errors: dict[str, float], - stats_tracking: StatsTrackingTypes, ) -> None: """Get stats for cpu / gpu.""" @@ -188,7 +187,7 @@ def get_processing_stats( stats_tasks = [ asyncio.create_task(set_gpu_stats(config, stats, hwaccel_errors)), asyncio.create_task(set_cpu_stats(stats)), - asyncio.create_task(set_npu_usages(config, stats, stats_tracking)), + asyncio.create_task(set_npu_usages(config, stats)), ] if config.telemetry.stats.network_bandwidth: @@ -325,11 +324,7 @@ async def set_gpu_stats( all_stats["gpu_usages"] = stats -async def set_npu_usages( - config: FrigateConfig, - all_stats: dict[str, Any], - stats_tracking: StatsTrackingTypes, -) -> None: +async def set_npu_usages(config: FrigateConfig, all_stats: dict[str, Any]) -> None: stats: dict[str, dict] = {} for detector in config.detectors.values(): @@ -346,31 +341,13 @@ async def set_npu_usages( axcl_usage = get_axcl_npu_stats() stats["axengine"] = axcl_usage elif detector.type == "axelera": - # Axelera Metis: board temperature + busy time from the worker. - # MVM utilization needs the axsystemserver broker, which cannot - # run alongside a live detector; busy time (fps * s) is computed - # from Frigate's own measurements as a real load indicator. - axelera_entry = None + # Axelera Metis: temperature from the board controller. + # There is no utilization readout without the axsystemserver + # broker, which cannot run alongside a live detector. metis_temp = get_axelera_board_temp() if metis_temp is not None: - axelera_entry = {"mem": "-%", "temp": metis_temp} - npu_det = stats_tracking.get("detectors", {}).get("axelera") - - if npu_det is not None: - total_fps = sum( - cam.detection_fps.value - for cam in stats_tracking.get("camera_metrics", {}).values() - ) - avg_sec = npu_det.avg_inference_speed.value - - if total_fps > 0 and avg_sec > 0: - axelera_entry["npu"] = round( - min(100.0, total_fps * avg_sec * 100.0), 1 - ) - - if axelera_entry is not None: - stats["axelera"] = axelera_entry + stats["axelera"] = {"mem": "-%", "temp": metis_temp} if stats: all_stats["npu_usages"] = stats @@ -522,7 +499,7 @@ def stats_snapshot( embeddings_metrics.classification_cps[key].value, 2 ) - get_processing_stats(config, stats, hwaccel_errors, stats_tracking) + get_processing_stats(config, stats, hwaccel_errors) stats["service"] = { "uptime": (int(time.time()) - stats_tracking["started"]), From 2bfd8f57075a6cae8f469fc56f05b9d4faa8f20b Mon Sep 17 00:00:00 2001 From: patrykorwat Date: Wed, 19 Aug 2026 20:45:38 +0000 Subject: [PATCH 14/39] fix(axelera): default to Apache-2.0 YOLOX model, drop AGPL YOLO references Frigate (MIT) cannot reference Ultralytics YOLO models (AGPL-3.0), see blakeblackshear/frigate#10717. The plugin now defaults to the SDK model-zoo preset yolox-s-coco (Megvii YOLOX, Apache-2.0), docs updated accordingly. --- docs/docs/configuration/object_detectors.md | 10 +++++----- docs/docs/frigate/installation.md | 4 ++-- frigate/detectors/plugins/axelera.py | 17 +++++++++-------- 3 files changed, 16 insertions(+), 15 deletions(-) diff --git a/docs/docs/configuration/object_detectors.md b/docs/docs/configuration/object_detectors.md index c487f28013..f2ca936a2f 100644 --- a/docs/docs/configuration/object_detectors.md +++ b/docs/docs/configuration/object_detectors.md @@ -797,13 +797,13 @@ See the [installation docs](../frigate/installation.md#axelera-metis) for inform ### Configuration {#configuration-axelera} -The integration runs inference through a single stream created with `create_inference_stream(network=...)`, which accepts a compiled model as a URL (downloaded into the model cache), a local file path, or an SDK model-zoo preset name. When no model is specified, the compiled default model is downloaded from Hugging Face into the model cache on first startup; the SDK model-zoo preset `yolov8n-coco` can be selected explicitly with `model.path: yolov8n-coco`. +The integration runs inference through a single stream created with `create_inference_stream(network=...)`, which accepts a compiled model as a URL (downloaded into the model cache), a local file path, or an SDK model-zoo preset name. When no model is specified, the compiled default model is downloaded from Hugging Face into the model cache on first startup; the SDK model-zoo preset `yolox-s-coco` can be selected explicitly with `model.path: yolox-s-coco`. :::info -The Metis board needs models compiled for its architecture. Compile them from the Voyager model zoo with the SDK tooling, e.g. `axzoo build yolov8n_coco --backend axrelay --cal-source repr` (or compile a custom ONNX model with `axcompile`, see [Building a model](/frigate/installation#building-a-model)), producing a `.axm` file or a compiled model directory. Host the compiled model anywhere reachable, e.g. on Hugging Face, and point `model.path` at it. Plain strings in `model.path` are treated as SDK model-zoo preset names; anything else is treated as a file (downloaded from a URL or used in place). +The Metis board needs models compiled for its architecture. Compile them from the Voyager model zoo with the SDK tooling, e.g. `axzoo build yolox_s_coco --backend axrelay --cal-source repr` (or compile a custom ONNX model with `axcompile`, see [Building a model](/frigate/installation#building-a-model)), producing a `.axm` file or a compiled model directory. Host the compiled model anywhere reachable, e.g. on Hugging Face, and point `model.path` at it. Plain strings in `model.path` are treated as SDK model-zoo preset names; anything else is treated as a file (downloaded from a URL or used in place). ::: @@ -813,7 +813,7 @@ The Metis board needs models compiled for its architecture. Compile them from th The Metis board runs models compiled for its architecture (`.axm` files, or a compiled model directory). Two options are available: -- **SDK model-zoo preset (no compilation or download needed):** the `yolov8n-coco` preset ships with the Voyager SDK. Select it with `model.path: yolov8n-coco`. -- **Compiled model:** build one with the SDK tooling, e.g. `axzoo build yolov8n_coco --backend axrelay --cal-source repr` (model zoo) or `axcompile -i model.onnx -o compiled/` (custom ONNX). See [Building a model](/frigate/installation#building-a-model). +- **SDK model-zoo preset (no compilation or download needed):** the `yolox-s-coco` preset ships with the Voyager SDK (YOLOX, Megvii, Apache-2.0 licensed). Select it with `model.path: yolox-s-coco`. +- **Compiled model:** build one with the SDK tooling, e.g. `axzoo build yolox_s_coco --backend axrelay --cal-source repr` (model zoo) or `axcompile -i model.onnx -o compiled/` (custom ONNX). See [Building a model](/frigate/installation#building-a-model). -Host the compiled model somewhere Frigate can reach (a local path or an HTTP URL, e.g. on Hugging Face) and point `model.path` at it. With no `model.path`, the detector downloads its compiled default model into the model cache on first startup (a preset hosted on Hugging Face; see `AX_DEFAULT_URL` in the plugin source). Set `model.width` and `model.height` to the compiled model's input size. +Host the compiled model somewhere Frigate can reach (a local path or an HTTP URL, e.g. on Hugging Face) and point `model.path` at it. With no `model.path`, the detector uses the SDK model-zoo preset `yolox-s-coco` by default (no download). Set `model.width` and `model.height` to the compiled model's input size. diff --git a/docs/docs/frigate/installation.md b/docs/docs/frigate/installation.md index abe3d6b9d5..90c623310b 100644 --- a/docs/docs/frigate/installation.md +++ b/docs/docs/frigate/installation.md @@ -519,7 +519,7 @@ Then install the Metis kernel driver, for example with `axdevice driver --instal #### Building a model -Detector models are compiled for the Metis architecture. The simplest path needs no compilation: the detector uses the `yolov8n-coco` model-zoo preset by default, which ships with the SDK. +Detector models are compiled for the Metis architecture. The simplest path needs no compilation: the detector uses the `yolox-s-coco` model-zoo preset by default, which ships with the SDK. To compile your own ONNX model, use the SDK compiler (`axcompile`, see the [Compiler CLI reference](https://docs.axelera.ai/sdk/reference/compiler/compiler-cli)): @@ -527,7 +527,7 @@ To compile your own ONNX model, use the SDK compiler (`axcompile`, see the [Comp axcompile -i /path/to/model.onnx -o /path/to/compiled/ ``` -The compiler produces the artifacts under `output/compiled_model/` (see the SDK "Model Formats" documentation for what each file is). Give the compiled model to the detector through `model.path` (URL or local path) and set `model.width`/`model.height` to the model input size. Models from the SDK model zoo can also be built with the SDK CLI, e.g. `axzoo build yolov8n_coco --backend axrelay --cal-source repr`. +The compiler produces the artifacts under `output/compiled_model/` (see the SDK "Model Formats" documentation for what each file is). Give the compiled model to the detector through `model.path` (URL or local path) and set `model.width`/`model.height` to the model input size. Models from the SDK model zoo can also be built with the SDK CLI, e.g. `axzoo build yolox_s_coco --backend axrelay --cal-source repr`. To use a custom model in Frigate, configure [hardware object detection](/configuration/object_detectors#axelera-metis) and point `model.path` at your compiled model or its URL. diff --git a/frigate/detectors/plugins/axelera.py b/frigate/detectors/plugins/axelera.py index 9230bd4b35..7d438818c3 100644 --- a/frigate/detectors/plugins/axelera.py +++ b/frigate/detectors/plugins/axelera.py @@ -16,10 +16,9 @@ model compilation across frames. Model configuration follows the hardware-backend convention: - `model.path` = URL (downloaded to the model cache) | local file path | SDK - model-zoo preset name (e.g. ``yolov8n-coco``). When omitted, the compiled - default model is downloaded from ``AX_DEFAULT_URL`` (Hugging Face) on first - startup and cached; use ``model.path: yolov8n-coco`` to select the SDK - model-zoo preset instead (no download). + model-zoo preset name (e.g. ``yolox-s-coco``). When omitted, the SDK + model-zoo preset ``AX_DEFAULT_NETWORK`` (YOLOX, Apache-2.0 licensed) is + used; use `model.path: yolox-s-coco` to select it explicitly. - `model.labelmap_path` / `model.labelmap`: labels are mapped to class ids BY NAME via the merged labelmap, independent of label ordering. - `model.width` / `model.height`: input frame dimensions for the network. @@ -45,11 +44,13 @@ logger = logging.getLogger(__name__) DETECTOR_KEY = "axelera" # Default SDK model-zoo preset used when no `model.path` is given. -AX_DEFAULT_NETWORK = "yolov8n-coco" +# YOLOX (Megvii) is Apache-2.0 licensed; Ultralytics YOLO models are AGPL-3.0 +# and cannot be referenced by Frigate (MIT), see blakeblackshear/frigate#10717. +AX_DEFAULT_NETWORK = "yolox-s-coco" -# Default download URL for the compiled default model, hosted on Hugging Face. -# When empty the SDK model-zoo preset AX_DEFAULT_NETWORK is used instead. -AX_DEFAULT_URL = "https://huggingface.co/patrykorwat/metis-yolov8n-coco/resolve/main/yolov8n-coco.axm" +# Optional download URL for a compiled default model. When empty the SDK +# model-zoo preset AX_DEFAULT_NETWORK is used instead (no download needed). +AX_DEFAULT_URL = "" INFERENCE_TIMEOUT = 5.0 MAX_ROWS = 20 From 0526fcc9332c7b7f1923617a9364769f6030583e Mon Sep 17 00:00:00 2001 From: patrykorwat Date: Wed, 19 Aug 2026 21:04:37 +0000 Subject: [PATCH 15/39] docs(axelera): switch model dropdown to yolox-s-coco (Apache-2.0) --- docs/data/object_detectors_models.yaml | 10 +++++----- docs/docs/configuration/object_detectors.md | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/data/object_detectors_models.yaml b/docs/data/object_detectors_models.yaml index 7ed95c07f5..87251d6bc4 100644 --- a/docs/data/object_detectors_models.yaml +++ b/docs/data/object_detectors_models.yaml @@ -1272,17 +1272,17 @@ axengine: axelera: title: Axelera Metis models: - - key: yolov8n - label: YOLOv8n + - key: yoloxs + label: YOLOX-S recommended: true download: |- - The **yolov8n-coco** model is a Voyager SDK model-zoo preset, so no download or compilation is needed. To use a custom model, compile your ONNX model with the SDK compiler (`axcompile`) or build from the model zoo (`axzoo build yolov8n_coco --backend axrelay --cal-source repr`), then point `model.path` at the compiled model or its URL (e.g. hosted on Hugging Face). + The **yolox-s-coco** model is a Voyager SDK model-zoo preset (Megvii YOLOX, Apache-2.0 licensed), so no download or compilation is needed. To use a custom model, compile your ONNX model with the SDK compiler (`axcompile`) or build from the model zoo (`axzoo build yolox_s_coco --backend axrelay --cal-source repr`), then point `model.path` at the compiled model or its URL (e.g. hosted on Hugging Face). ui: |- Navigate to **Settings > System > Detectors and model** and select **Axelera Metis NPU** from the detector type dropdown and click **Add**. Then on the same page, in the **Custom Model** tab, configure: | Field | Value | | ---------------------------------------- | ----------------------- | - | **Custom object detector model path** | `yolov8n-coco` | + | **Custom object detector model path** | `yolox-s-coco` | | **Label map for custom object detector** | `/labelmap/coco-80.txt` | | **Object detection model input width** | `640` | | **Object detection model input height** | `640` | @@ -1296,7 +1296,7 @@ axelera: type: axelera model: - path: yolov8n-coco + path: yolox-s-coco model_type: yolo-generic width: 640 height: 640 diff --git a/docs/docs/configuration/object_detectors.md b/docs/docs/configuration/object_detectors.md index f2ca936a2f..83c74ff4fd 100644 --- a/docs/docs/configuration/object_detectors.md +++ b/docs/docs/configuration/object_detectors.md @@ -797,7 +797,7 @@ See the [installation docs](../frigate/installation.md#axelera-metis) for inform ### Configuration {#configuration-axelera} -The integration runs inference through a single stream created with `create_inference_stream(network=...)`, which accepts a compiled model as a URL (downloaded into the model cache), a local file path, or an SDK model-zoo preset name. When no model is specified, the compiled default model is downloaded from Hugging Face into the model cache on first startup; the SDK model-zoo preset `yolox-s-coco` can be selected explicitly with `model.path: yolox-s-coco`. +The integration runs inference through a single stream created with `create_inference_stream(network=...)`, which accepts a compiled model as a URL (downloaded into the model cache), a local file path, or an SDK model-zoo preset name. When no model is specified, the SDK model-zoo preset `yolox-s-coco` is used (no download); it can also be selected explicitly with `model.path: yolox-s-coco`. From 7bf9cb4631b3cf62f9b9fddaeb36afd67ef44833 Mon Sep 17 00:00:00 2001 From: patrykorwat Date: Wed, 19 Aug 2026 21:15:48 +0000 Subject: [PATCH 16/39] fix(axelera): correct preset name to yolox-s-coco-onnx The SDK model-zoo network name in create_inference_stream is yolox-s-coco-onnx (yolox-s-coco is only the axzoo recipe tag). --- docs/data/object_detectors_models.yaml | 6 +++--- docs/docs/configuration/object_detectors.md | 6 +++--- docs/docs/frigate/installation.md | 2 +- frigate/detectors/plugins/axelera.py | 6 +++--- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/data/object_detectors_models.yaml b/docs/data/object_detectors_models.yaml index 87251d6bc4..a21ae2ea7d 100644 --- a/docs/data/object_detectors_models.yaml +++ b/docs/data/object_detectors_models.yaml @@ -1276,13 +1276,13 @@ axelera: label: YOLOX-S recommended: true download: |- - The **yolox-s-coco** model is a Voyager SDK model-zoo preset (Megvii YOLOX, Apache-2.0 licensed), so no download or compilation is needed. To use a custom model, compile your ONNX model with the SDK compiler (`axcompile`) or build from the model zoo (`axzoo build yolox_s_coco --backend axrelay --cal-source repr`), then point `model.path` at the compiled model or its URL (e.g. hosted on Hugging Face). + The **yolox-s-coco-onnx** model is a Voyager SDK model-zoo preset (Megvii YOLOX, Apache-2.0 licensed), so no download or compilation is needed. To use a custom model, compile your ONNX model with the SDK compiler (`axcompile`) or build from the model zoo (`axzoo build yolox_s_coco --backend axrelay --cal-source repr`), then point `model.path` at the compiled model or its URL (e.g. hosted on Hugging Face). ui: |- Navigate to **Settings > System > Detectors and model** and select **Axelera Metis NPU** from the detector type dropdown and click **Add**. Then on the same page, in the **Custom Model** tab, configure: | Field | Value | | ---------------------------------------- | ----------------------- | - | **Custom object detector model path** | `yolox-s-coco` | + | **Custom object detector model path** | `yolox-s-coco-onnx` | | **Label map for custom object detector** | `/labelmap/coco-80.txt` | | **Object detection model input width** | `640` | | **Object detection model input height** | `640` | @@ -1296,7 +1296,7 @@ axelera: type: axelera model: - path: yolox-s-coco + path: yolox-s-coco-onnx model_type: yolo-generic width: 640 height: 640 diff --git a/docs/docs/configuration/object_detectors.md b/docs/docs/configuration/object_detectors.md index 83c74ff4fd..046c6363f1 100644 --- a/docs/docs/configuration/object_detectors.md +++ b/docs/docs/configuration/object_detectors.md @@ -797,7 +797,7 @@ See the [installation docs](../frigate/installation.md#axelera-metis) for inform ### Configuration {#configuration-axelera} -The integration runs inference through a single stream created with `create_inference_stream(network=...)`, which accepts a compiled model as a URL (downloaded into the model cache), a local file path, or an SDK model-zoo preset name. When no model is specified, the SDK model-zoo preset `yolox-s-coco` is used (no download); it can also be selected explicitly with `model.path: yolox-s-coco`. +The integration runs inference through a single stream created with `create_inference_stream(network=...)`, which accepts a compiled model as a URL (downloaded into the model cache), a local file path, or an SDK model-zoo preset name. When no model is specified, the SDK model-zoo preset `yolox-s-coco-onnx` is used (no download); it can also be selected explicitly with `model.path: yolox-s-coco-onnx`. @@ -813,7 +813,7 @@ The Metis board needs models compiled for its architecture. Compile them from th The Metis board runs models compiled for its architecture (`.axm` files, or a compiled model directory). Two options are available: -- **SDK model-zoo preset (no compilation or download needed):** the `yolox-s-coco` preset ships with the Voyager SDK (YOLOX, Megvii, Apache-2.0 licensed). Select it with `model.path: yolox-s-coco`. +- **SDK model-zoo preset (no compilation or download needed):** the `yolox-s-coco-onnx` preset ships with the Voyager SDK (YOLOX, Megvii, Apache-2.0 licensed). Select it with `model.path: yolox-s-coco-onnx`. - **Compiled model:** build one with the SDK tooling, e.g. `axzoo build yolox_s_coco --backend axrelay --cal-source repr` (model zoo) or `axcompile -i model.onnx -o compiled/` (custom ONNX). See [Building a model](/frigate/installation#building-a-model). -Host the compiled model somewhere Frigate can reach (a local path or an HTTP URL, e.g. on Hugging Face) and point `model.path` at it. With no `model.path`, the detector uses the SDK model-zoo preset `yolox-s-coco` by default (no download). Set `model.width` and `model.height` to the compiled model's input size. +Host the compiled model somewhere Frigate can reach (a local path or an HTTP URL, e.g. on Hugging Face) and point `model.path` at it. With no `model.path`, the detector uses the SDK model-zoo preset `yolox-s-coco-onnx` by default (no download). Set `model.width` and `model.height` to the compiled model's input size. diff --git a/docs/docs/frigate/installation.md b/docs/docs/frigate/installation.md index 90c623310b..6aa4191b35 100644 --- a/docs/docs/frigate/installation.md +++ b/docs/docs/frigate/installation.md @@ -519,7 +519,7 @@ Then install the Metis kernel driver, for example with `axdevice driver --instal #### Building a model -Detector models are compiled for the Metis architecture. The simplest path needs no compilation: the detector uses the `yolox-s-coco` model-zoo preset by default, which ships with the SDK. +Detector models are compiled for the Metis architecture. The simplest path needs no compilation: the detector uses the `yolox-s-coco-onnx` model-zoo preset by default, which ships with the SDK. To compile your own ONNX model, use the SDK compiler (`axcompile`, see the [Compiler CLI reference](https://docs.axelera.ai/sdk/reference/compiler/compiler-cli)): diff --git a/frigate/detectors/plugins/axelera.py b/frigate/detectors/plugins/axelera.py index 7d438818c3..ff7bbbe4a7 100644 --- a/frigate/detectors/plugins/axelera.py +++ b/frigate/detectors/plugins/axelera.py @@ -16,9 +16,9 @@ model compilation across frames. Model configuration follows the hardware-backend convention: - `model.path` = URL (downloaded to the model cache) | local file path | SDK - model-zoo preset name (e.g. ``yolox-s-coco``). When omitted, the SDK + model-zoo preset name (e.g. ``yolox-s-coco-onnx``). When omitted, the SDK model-zoo preset ``AX_DEFAULT_NETWORK`` (YOLOX, Apache-2.0 licensed) is - used; use `model.path: yolox-s-coco` to select it explicitly. + used; use `model.path: yolox-s-coco-onnx` to select it explicitly. - `model.labelmap_path` / `model.labelmap`: labels are mapped to class ids BY NAME via the merged labelmap, independent of label ordering. - `model.width` / `model.height`: input frame dimensions for the network. @@ -46,7 +46,7 @@ DETECTOR_KEY = "axelera" # Default SDK model-zoo preset used when no `model.path` is given. # YOLOX (Megvii) is Apache-2.0 licensed; Ultralytics YOLO models are AGPL-3.0 # and cannot be referenced by Frigate (MIT), see blakeblackshear/frigate#10717. -AX_DEFAULT_NETWORK = "yolox-s-coco" +AX_DEFAULT_NETWORK = "yolox-s-coco-onnx" # Optional download URL for a compiled default model. When empty the SDK # model-zoo preset AX_DEFAULT_NETWORK is used instead (no download needed). From 8a3483c7a283a098132f64d52e5c342153b66c3f Mon Sep 17 00:00:00 2001 From: patrykorwat Date: Thu, 20 Aug 2026 07:34:40 +0000 Subject: [PATCH 17/39] fix(axelera): address review comments on frame pairing and model handling - strict per-frame request/result pairing: the feeder registers a queue of pending frame ids, the pump pops one per stream result and stores the outcome under it, and run() waits for its own frame's id. Primer and keep-alive results are dropped instead of being served to callers. - copy frames out of shared memory before queuing them (detect_raw returns before the feeder consumes the frame, unmapping the camera buffer). - raise on inference timeout so the watchdog can restart a stalled stream instead of silently serving zeros. - use ModelDownloader.download_from_url for URL models (.part file, atomic rename, progress events); drop the unused AX_DEFAULT_URL constant. - is_url() requires a scheme; treat www.-style values as paths and fail with a clear FileNotFoundError instead of a deep urlretrieve error, mirroring hailo8l behavior for missing model files. - guard detections against numpy arrays, drop the dead score initialization, floor normalized box coords at 0, move the model-required check before super().__init__(), and document detect_raw/close. - deduplicate the model-compilation walkthrough in docs (keep installation.md as the single source; object_detectors.md links to it). --- docs/docs/configuration/object_detectors.md | 12 +- frigate/detectors/plugins/axelera.py | 150 +++++++++++++------- frigate/stats/util.py | 4 +- frigate/util/services.py | 27 +++- 4 files changed, 126 insertions(+), 67 deletions(-) diff --git a/docs/docs/configuration/object_detectors.md b/docs/docs/configuration/object_detectors.md index 046c6363f1..eb7244d9ae 100644 --- a/docs/docs/configuration/object_detectors.md +++ b/docs/docs/configuration/object_detectors.md @@ -799,14 +799,10 @@ See the [installation docs](../frigate/installation.md#axelera-metis) for inform The integration runs inference through a single stream created with `create_inference_stream(network=...)`, which accepts a compiled model as a URL (downloaded into the model cache), a local file path, or an SDK model-zoo preset name. When no model is specified, the SDK model-zoo preset `yolox-s-coco-onnx` is used (no download); it can also be selected explicitly with `model.path: yolox-s-coco-onnx`. +Models must be compiled for the Metis architecture — see [Building a model](/frigate/installation#building-a-model) for the full walkthrough. + -:::info - -The Metis board needs models compiled for its architecture. Compile them from the Voyager model zoo with the SDK tooling, e.g. `axzoo build yolox_s_coco --backend axrelay --cal-source repr` (or compile a custom ONNX model with `axcompile`, see [Building a model](/frigate/installation#building-a-model)), producing a `.axm` file or a compiled model directory. Host the compiled model anywhere reachable, e.g. on Hugging Face, and point `model.path` at it. Plain strings in `model.path` are treated as SDK model-zoo preset names; anything else is treated as a file (downloaded from a URL or used in place). - -::: - ### Downloading YOLO Models #### Axelera Metis @@ -814,6 +810,4 @@ The Metis board needs models compiled for its architecture. Compile them from th The Metis board runs models compiled for its architecture (`.axm` files, or a compiled model directory). Two options are available: - **SDK model-zoo preset (no compilation or download needed):** the `yolox-s-coco-onnx` preset ships with the Voyager SDK (YOLOX, Megvii, Apache-2.0 licensed). Select it with `model.path: yolox-s-coco-onnx`. -- **Compiled model:** build one with the SDK tooling, e.g. `axzoo build yolox_s_coco --backend axrelay --cal-source repr` (model zoo) or `axcompile -i model.onnx -o compiled/` (custom ONNX). See [Building a model](/frigate/installation#building-a-model). - -Host the compiled model somewhere Frigate can reach (a local path or an HTTP URL, e.g. on Hugging Face) and point `model.path` at it. With no `model.path`, the detector uses the SDK model-zoo preset `yolox-s-coco-onnx` by default (no download). Set `model.width` and `model.height` to the compiled model's input size. +- **Compiled model:** see [Building a model](/frigate/installation#building-a-model) for how to compile one, then point `model.path` at it via a local path or an HTTP URL (e.g. on Hugging Face). Set `model.width` and `model.height` to the compiled model's input size. diff --git a/frigate/detectors/plugins/axelera.py b/frigate/detectors/plugins/axelera.py index ff7bbbe4a7..1d1153f9ed 100644 --- a/frigate/detectors/plugins/axelera.py +++ b/frigate/detectors/plugins/axelera.py @@ -24,12 +24,12 @@ Model configuration follows the hardware-backend convention: - `model.width` / `model.height`: input frame dimensions for the network. """ +import collections import logging import os import queue import threading import time -import urllib.request from typing import Literal import numpy as np @@ -38,6 +38,7 @@ from pydantic import ConfigDict from frigate.const import MODEL_CACHE_DIR from frigate.detectors.detection_api import DetectionApi from frigate.detectors.detector_config import BaseDetectorConfig +from frigate.util.downloader import ModelDownloader logger = logging.getLogger(__name__) @@ -48,10 +49,6 @@ DETECTOR_KEY = "axelera" # and cannot be referenced by Frigate (MIT), see blakeblackshear/frigate#10717. AX_DEFAULT_NETWORK = "yolox-s-coco-onnx" -# Optional download URL for a compiled default model. When empty the SDK -# model-zoo preset AX_DEFAULT_NETWORK is used instead (no download needed). -AX_DEFAULT_URL = "" - INFERENCE_TIMEOUT = 5.0 MAX_ROWS = 20 @@ -60,37 +57,48 @@ MAX_ROWS = 20 def is_url(value: str) -> bool: - return value.startswith(("http://", "https://", "www.")) + """Return True for well-formed http(s) URLs (scheme required).""" + return value.startswith(("http://", "https://")) + + +def _looks_like_path(value: str) -> bool: + """Heuristic: a value that should refer to a file on disk, not a preset.""" + return "/" in value or "." in value def _resolve_network(model_path, model_url) -> str: """Resolve the value passed to ``create_inference_stream(network=...)``. - Returns a download URL -> cached local file / existing local file / SDK - model-zoo preset name, mirroring the resolution hailo8l performs for ``.hef``. + Returns a download URL cached as a local file / an existing local file / + an SDK model-zoo preset name, mirroring the resolution hailo8l performs + for ``.hef``. """ if model_url and model_path: raise ValueError("`model.path` cannot be both a URL and a local path.") - if model_url or (not model_path and AX_DEFAULT_URL): - url = model_url or AX_DEFAULT_URL - name = os.path.basename(url) or AX_DEFAULT_NETWORK + if model_url: + name = os.path.basename(model_url) or AX_DEFAULT_NETWORK os.makedirs(MODEL_CACHE_DIR, exist_ok=True) destination = os.path.join(MODEL_CACHE_DIR, name) if not os.path.isfile(destination): - logger.info("axelera: downloading model from %s", url) + logger.info("axelera: downloading model from %s", model_url) try: - urllib.request.urlretrieve(url, destination) + ModelDownloader.download_from_url(model_url, destination) except Exception as exc: raise RuntimeError( - f"Failed to download model from {url}: {exc}" + f"Failed to download model from {model_url}: {exc}" ) from exc return destination if model_path: - if os.path.isfile(model_path): + if _looks_like_path(model_path): + if not os.path.isfile(model_path): + raise FileNotFoundError( + f"Model file not found: {model_path}. If you meant to use " + "an SDK model-zoo preset name, drop the file extension / path." + ) return model_path - # Not an existing file: treat as an SDK model-zoo preset name. + # Bare name: SDK model-zoo preset. return model_path return AX_DEFAULT_NETWORK @@ -120,13 +128,12 @@ class _AxeleraInference: never starves and is not torn down by its internal inference timeout during quiet periods (e.g. a camera with no motion). - Results are NOT strictly paired to request ids: per-frame pairing - desynchronises in production once keep-alive results interleave with real - frames (a request can receive a zero-frame result, or a real result gets - attributed to the wrong frame). Instead the pump keeps the most recent - completed result, and ``run()`` pushes its frame then waits for a result - newer than the sequence observed at call time and returns it. Frigate only - needs the latest per-camera detections; it correlates objects across frames. + Results are STRICTLY paired to requests: every frame the feeder pushes into + the SDK registers its request id on a deque (in submission order), and each + stream result pops the next pending id and stores the outcome under it. + ``run()`` waits for its own id only, so a result can never be attributed to + a different camera or crop, and primer/keep-alive results (unregistered ids) + are dropped instead of being returned to a caller. """ def __init__( @@ -136,8 +143,9 @@ class _AxeleraInference: self._width, self._height = int(width), int(height) self._frame_q: queue.Queue = queue.Queue(maxsize=64) self._cond = threading.Condition() - self._latest_seq = 0 - self._latest_result: np.ndarray | None = None + self._pending_ids: collections.deque = collections.deque() + self._results: dict[int, np.ndarray] = {} + self._next_id = 0 if stream_factory is None: # lazy import: never import the SDK unless the detector is used @@ -163,18 +171,26 @@ class _AxeleraInference: def _feed(self): # primer frames: the SDK init pre-buffers several frames before the - # source yields (validated on hardware). + # source yields (validated on hardware). Their ids are NOT registered, + # so their stream results are dropped by the pump. for _ in range(3): yield np.zeros((self._height, self._width, 3), np.uint8) while True: try: - frame = self._frame_q.get(timeout=1.0) + frame_id, frame = self._frame_q.get(timeout=1.0) except queue.Empty: # keep-alive: no request pending, keep the SDK stream fed so the - # inference timeout never kills it during quiet periods - frame = np.zeros((self._height, self._width, 3), np.uint8) + # inference timeout never kills it during quiet periods. The + # None id means this frame's result is dropped by the pump. + frame_id, frame = None, np.zeros( + (self._height, self._width, 3), np.uint8 + ) if frame is None: # shutdown sentinel from stop() return + if frame_id is not None: + # submission order == result order: the pump pops this id when + # the SDK yields the result for this frame. + self._pending_ids.append(frame_id) yield frame def _detection_rows(self, frame_result) -> list: @@ -184,7 +200,15 @@ class _AxeleraInference: them by the region size, pixel boxes are discarded as outside the frame (zero events despite working detections). """ - dets = getattr(frame_result, "detections", None) or [] + dets = getattr(frame_result, "detections", None) + if dets is None: + return [] + try: + # some SDK builds return a numpy array; `or []` on an array raises + # ValueError (ambiguous truth value) with 2+ detections + dets = list(dets) + except TypeError: + dets = [dets] rows = [] for det in dets: label = getattr(getattr(det, "label", None), "name", None) @@ -193,7 +217,6 @@ class _AxeleraInference: class_id = self._labelmap.get(str(label).strip()) if class_id is None: continue - score = 0.0 raw_box = getattr(det, "box", None) if raw_box is None: continue @@ -211,8 +234,8 @@ class _AxeleraInference: ( class_id, score, - y0 / self._height, - x0 / self._width, + max(0.0, y0) / self._height, + max(0.0, x0) / self._width, min(y1, self._height) / self._height, min(x1, self._width) / self._width, ) @@ -225,16 +248,20 @@ class _AxeleraInference: try: for frame_result in self._stream: out = _pad_rows(self._detection_rows(frame_result)) - with self._cond: - self._latest_seq += 1 - self._latest_result = out - self._cond.notify_all() + if self._pending_ids: + # FIFO: the stream returns results in submission order, so + # the oldest pending id belongs to this result. + frame_id = self._pending_ids.popleft() + with self._cond: + self._results[frame_id] = out + self._cond.notify_all() except Exception: logger.exception( "axelera: inference stream failed; detector requires restart" ) def run(self, tensor_input, timeout: float = INFERENCE_TIMEOUT) -> np.ndarray: + """Run one inference and return this frame's detections (see detect_raw).""" if tensor_input is None or getattr(tensor_input, "ndim", 0) < 3: raise ValueError( f"Unexpected frame shape: {getattr(tensor_input, 'shape', tensor_input)}" @@ -246,36 +273,45 @@ class _AxeleraInference: if frame.shape[0] != 1: raise ValueError(f"Unexpected batch dimension in frame: {frame.shape}") frame = frame[0] - frame = np.ascontiguousarray(frame) + # Real copy: the shared-memory camera buffer is unmapped by Frigate as + # soon as detect_raw returns, and our queue can hold the frame past that + # point (timeout path, backed-up queue). NP views would let the SDK read + # a torn/unmapped segment. + frame = np.array(frame, dtype=np.uint8, order="C", copy=True) + if not self._thread.is_alive(): # The pump is gone (stream wedged): never block on the queue put, # the SDK side is not draining it and the queue would fill up, # permanently wedging this detector. raise RuntimeError("Axelera inference thread stopped; restart required.") + + # Every submitted frame gets a unique id; the pump stores the result + # under this id, so we can wait for OUR frame's outcome exclusively. + self._next_id += 1 + frame_id = self._next_id try: - self._frame_q.put_nowait(frame) + self._frame_q.put((frame_id, frame), timeout=2.0) except queue.Full: - # The SDK side is not draining: drop the frame instead of blocking - # forever, then fall through to the latest-result wait below. - logger.debug("axelera: dropping frame, inference queue is full") + raise RuntimeError( + "Axelera inference queue is full; restart required." + ) from None deadline = time.monotonic() + timeout with self._cond: - start_seq = self._latest_seq - while self._latest_seq <= start_seq: + while frame_id not in self._results: remaining = deadline - time.monotonic() if remaining <= 0: break self._cond.wait(remaining) - if self._latest_seq > start_seq: - result = self._latest_result - if result is not None: - return result - if not self._thread.is_alive(): + if frame_id in self._results: + result = self._results.pop(frame_id) + return result + if self._thread.is_alive(): raise RuntimeError( - "Axelera inference thread stopped; restart required." - ) from None - return np.zeros((MAX_ROWS, 6), np.float32) + "Axelera inference stream returned no result for the submitted " + f"frame within {timeout:.1f}s (stream stall); restart required." + ) + raise RuntimeError("Axelera inference thread stopped; restart required.") def stop(self): # cleanup must not raise @@ -298,14 +334,13 @@ class AxeleraDetector(DetectionApi): type_key = DETECTOR_KEY def __init__(self, detector_config: "AxeleraDetectorConfig"): - super().__init__(detector_config) - model = detector_config.model if model is None: raise ValueError( f"type={DETECTOR_KEY}: the `model:` block is required " "(path to a compiled .axm, URL, or SDK model-zoo preset name)." ) + super().__init__(detector_config) self.width = model.width self.height = model.height @@ -337,9 +372,16 @@ class AxeleraDetector(DetectionApi): ) def detect_raw(self, tensor_input) -> np.ndarray: + """Run the detector on one RGB frame and return (20, 6) float32 rows. + + Each row is (class_id, score, ymin, xmin, ymax, xmax) in NORMALIZED + coordinates [0..1] relative to the model input size. Unused rows are + zero-filled. Detections are strictly paired to THIS frame's inference. + """ return self._inference.run(tensor_input) def close(self): + """Stop the inference stream and release the Metis device.""" self._inference.stop() def __del__(self): diff --git a/frigate/stats/util.py b/frigate/stats/util.py index af3a0b7892..f89118557a 100644 --- a/frigate/stats/util.py +++ b/frigate/stats/util.py @@ -344,7 +344,9 @@ async def set_npu_usages(config: FrigateConfig, all_stats: dict[str, Any]) -> No # Axelera Metis: temperature from the board controller. # There is no utilization readout without the axsystemserver # broker, which cannot run alongside a live detector. - metis_temp = get_axelera_board_temp() + # axcmd spawns can take seconds on a busy board; this stats task + # runs in the async event loop, so run the blocking read in a thread. + metis_temp = await asyncio.to_thread(get_axelera_board_temp) if metis_temp is not None: stats["axelera"] = {"mem": "-%", "temp": metis_temp} diff --git a/frigate/util/services.py b/frigate/util/services.py index 8542eb6f91..20c610dcf9 100644 --- a/frigate/util/services.py +++ b/frigate/util/services.py @@ -800,12 +800,27 @@ def get_axcl_npu_stats() -> dict[str, str | float] | None: return None +_board_temp_cache: tuple[float, float] | None = None +_BOARD_TEMP_TTL = 5.0 + + def get_axelera_board_temp() -> float | None: """Get the Axelera Metis board temperature through the SDK's axcmd tool. The read goes through the board controller and does not open an inference - stream, so it is safe to call while a detector is running. + stream, so it is safe to call while a detector is running. Results are + cached for a few seconds so the stats cycle (detector temperature + NPU + usage run in the same tick) spawns axcmd at most once. + + NOTE: this function is blocking (up to ~5s on a hung board). Callers in + async contexts must wrap it in ``asyncio.to_thread``. """ + global _board_temp_cache + + now = time.monotonic() + if _board_temp_cache is not None and now - _board_temp_cache[0] < _BOARD_TEMP_TTL: + return _board_temp_cache[1] + axcmd_path = None for candidate in ( @@ -826,6 +841,7 @@ def get_axelera_board_temp() -> float | None: capture_output=True, text=True, timeout=5, + check=False, ) except (OSError, sp.TimeoutExpired): return None @@ -833,16 +849,21 @@ def get_axelera_board_temp() -> float | None: if result.returncode != 0: return None - match = re.search(r"=\s*([\d.]+)", result.stdout) + # anchor on the board-controller line ("N: temp_sensorX@YY = ") so + # unrelated "= " output (firmware/version preambles) is not picked up + match = re.search(r"temp_sensor[^=\n]*=\s*([\d.]+)", result.stdout) if match is None: return None try: - return round(float(match.group(1)), 1) + temp = round(float(match.group(1)), 1) except ValueError: return None + _board_temp_cache = (now, temp) + return temp + def try_get_info(f, h, default="N/A", sensor=None): try: From 71ccf55927a283b6d44956d5f8a7a7d291cefc6c Mon Sep 17 00:00:00 2001 From: patrykorwat Date: Thu, 20 Aug 2026 19:49:05 +0000 Subject: [PATCH 18/39] fix(axelera): serialize run() to prevent cross-camera result misattribution Voyager SDK does not tag results with a per-frame id and can reorder or buffer stream results under sustained load (observed on live with many thousands of frames: camera A started receiving boxes/paths from camera B). With several frames in flight, the FIFO pairing can store a result under a different frame's id. run() now holds a lock so at most one frame is in flight; the next stream result can only belong to the frame just pushed, making pairing exact regardless of SDK reordering. Pipeline throughput stays far below limits (~9ms per inference vs 28ms available per frame at 7x5fps). --- frigate/detectors/plugins/axelera.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/frigate/detectors/plugins/axelera.py b/frigate/detectors/plugins/axelera.py index 1d1153f9ed..b25a4eb50b 100644 --- a/frigate/detectors/plugins/axelera.py +++ b/frigate/detectors/plugins/axelera.py @@ -146,6 +146,12 @@ class _AxeleraInference: self._pending_ids: collections.deque = collections.deque() self._results: dict[int, np.ndarray] = {} self._next_id = 0 + # Serializes run() calls: the SDK stream does not tag results with a + # per-frame id and can reorder/buffer results under sustained load + # (observed after many thousands of frames), so with several frames in + # flight the FIFO pop can misattribute a result to the wrong camera. + # With at most one frame in flight the result can only belong to it. + self._run_lock = threading.Lock() if stream_factory is None: # lazy import: never import the SDK unless the detector is used @@ -262,6 +268,14 @@ class _AxeleraInference: def run(self, tensor_input, timeout: float = INFERENCE_TIMEOUT) -> np.ndarray: """Run one inference and return this frame's detections (see detect_raw).""" + # At most ONE frame may be in flight at a time: with several frames in + # the stream the SDK can reorder/buffer their results (misattribution + # across cameras observed after many thousands of frames). Serialized, + # the next returned result can only belong to the one frame we pushed. + with self._run_lock: + return self._run_locked(tensor_input, timeout) + + def _run_locked(self, tensor_input, timeout: float) -> np.ndarray: if tensor_input is None or getattr(tensor_input, "ndim", 0) < 3: raise ValueError( f"Unexpected frame shape: {getattr(tensor_input, 'shape', tensor_input)}" @@ -316,7 +330,7 @@ class _AxeleraInference: def stop(self): # cleanup must not raise try: - self._frame_q.put_nowait(None) + self._frame_q.put_nowait((None, None)) except Exception: # noqa: S110, BLE001 pass try: From 13215b0997cbe0acc8ec8b0d228f1cb03c7e5209 Mon Sep 17 00:00:00 2001 From: patrykorwat Date: Thu, 20 Aug 2026 20:34:04 +0000 Subject: [PATCH 19/39] fix(axelera): never attribute primer/keep-alive results to a real request The SDK can buffer primer results and emit them AFTER the first real frame is already pending; the FIFO pump then popped a real id and stored the skipped/buffered (empty) result under it, dropping the real one. This misattributes detections across cameras even with run() serialized. - eliminate keep-alive frames entirely (Frigate feeds the queue continuously; nothing to keep alive) - a synthetic frame's result can arrive during a pending real id, - drain the primer results by index BEFORE any pending-id bookkeeping, so they can never consume a request id; the first run() waits up to 0.5s for the primer drain. --- frigate/detectors/plugins/axelera.py | 36 ++++++++++++++++++++-------- 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/frigate/detectors/plugins/axelera.py b/frigate/detectors/plugins/axelera.py index b25a4eb50b..a0aa1c296a 100644 --- a/frigate/detectors/plugins/axelera.py +++ b/frigate/detectors/plugins/axelera.py @@ -51,6 +51,7 @@ AX_DEFAULT_NETWORK = "yolox-s-coco-onnx" INFERENCE_TIMEOUT = 5.0 MAX_ROWS = 20 +PRIMER_FRAMES = 3 # ----------------- Utility Functions ----------------- # @@ -152,6 +153,9 @@ class _AxeleraInference: # flight the FIFO pop can misattribute a result to the wrong camera. # With at most one frame in flight the result can only belong to it. self._run_lock = threading.Lock() + # Set once the SDK has finished emitting the primer results, so they + # can never be attributed to a real pending request id. + self._primed = threading.Event() if stream_factory is None: # lazy import: never import the SDK unless the detector is used @@ -177,20 +181,19 @@ class _AxeleraInference: def _feed(self): # primer frames: the SDK init pre-buffers several frames before the - # source yields (validated on hardware). Their ids are NOT registered, - # so their stream results are dropped by the pump. - for _ in range(3): + # source yields (validated on hardware). Their results are drained by + # _pump (by index) and NEVER attributed to a request id. + for _ in range(PRIMER_FRAMES): yield np.zeros((self._height, self._width, 3), np.uint8) while True: try: frame_id, frame = self._frame_q.get(timeout=1.0) except queue.Empty: - # keep-alive: no request pending, keep the SDK stream fed so the - # inference timeout never kills it during quiet periods. The - # None id means this frame's result is dropped by the pump. - frame_id, frame = None, np.zeros( - (self._height, self._width, 3), np.uint8 - ) + # Never synthesize keep-alive frames: a synthetic frame's + # result can arrive while a real id is pending and steal that + # id (misattribution across cameras). In Frigate the queue is + # fed continuously, so idling simply waits here. + continue if frame is None: # shutdown sentinel from stop() return if frame_id is not None: @@ -252,7 +255,14 @@ class _AxeleraInference: def _pump(self): logger.info("axelera: inference stream started") try: - for frame_result in self._stream: + for run_index, frame_result in enumerate(self._stream): + if run_index < PRIMER_FRAMES: + # Primer results are drained WITHOUT consuming a request + # id — otherwise a buffered primer result could steal a + # real id that is already pending (P1 misattribution). + if run_index == PRIMER_FRAMES - 1: + self._primed.set() + continue out = _pad_rows(self._detection_rows(frame_result)) if self._pending_ids: # FIFO: the stream returns results in submission order, so @@ -299,6 +309,12 @@ class _AxeleraInference: # permanently wedging this detector. raise RuntimeError("Axelera inference thread stopped; restart required.") + # Wait briefly for the primer results to drain ONCE, so they can never + # be attributed to a real request id. After this the pump only ever + # sees real frames (keep-alive frames are no longer emitted). + if not self._primed.is_set(): + self._primed.wait(timeout=0.5) + # Every submitted frame gets a unique id; the pump stores the result # under this id, so we can wait for OUR frame's outcome exclusively. self._next_id += 1 From 9d8a1a10a40804ba281823861f8f5253234b6fc7 Mon Sep 17 00:00:00 2001 From: patrykorwat Date: Thu, 20 Aug 2026 21:19:04 +0000 Subject: [PATCH 20/39] fix(axelera): revert primer-index-drain; it wedged production startup The primer 'drain by run index' was too fragile under Frigate's real workload: with 7 cameras starting simultaneously the first few real frames arrive before the SDK finishes emitting primer results, their ids were drained without a result, run() hit the timeout and Frigate restarted the detect process every ~15s (zero detections). Revert to the stable approach: keep-alive frames sustain the SDK during quiet/init, _pump never consumes a pending id while the queue is empty, and run() is serialized (lock) so at most one real frame is in flight. The remaining theoretical primer/keep-alive misattribution window only exists when the queue is empty mid-run, which does not happen under Frigate's continuous feed. --- frigate/detectors/plugins/axelera.py | 34 ++++++++-------------------- 1 file changed, 10 insertions(+), 24 deletions(-) diff --git a/frigate/detectors/plugins/axelera.py b/frigate/detectors/plugins/axelera.py index a0aa1c296a..e3ea5436e7 100644 --- a/frigate/detectors/plugins/axelera.py +++ b/frigate/detectors/plugins/axelera.py @@ -153,9 +153,6 @@ class _AxeleraInference: # flight the FIFO pop can misattribute a result to the wrong camera. # With at most one frame in flight the result can only belong to it. self._run_lock = threading.Lock() - # Set once the SDK has finished emitting the primer results, so they - # can never be attributed to a real pending request id. - self._primed = threading.Event() if stream_factory is None: # lazy import: never import the SDK unless the detector is used @@ -181,19 +178,21 @@ class _AxeleraInference: def _feed(self): # primer frames: the SDK init pre-buffers several frames before the - # source yields (validated on hardware). Their results are drained by - # _pump (by index) and NEVER attributed to a request id. + # source yields (validated on hardware). Their results are dropped by + # the pump while the pending queue is empty. for _ in range(PRIMER_FRAMES): yield np.zeros((self._height, self._width, 3), np.uint8) while True: + frame = frame_id = None try: frame_id, frame = self._frame_q.get(timeout=1.0) except queue.Empty: - # Never synthesize keep-alive frames: a synthetic frame's - # result can arrive while a real id is pending and steal that - # id (misattribution across cameras). In Frigate the queue is - # fed continuously, so idling simply waits here. - continue + # keep-alive: no request pending, keep the SDK stream fed so the + # inference timeout never kills it during quiet periods. The + # None id means this frame's result is dropped by the pump. + frame_id, frame = None, np.zeros( + (self._height, self._width, 3), np.uint8 + ) if frame is None: # shutdown sentinel from stop() return if frame_id is not None: @@ -255,14 +254,7 @@ class _AxeleraInference: def _pump(self): logger.info("axelera: inference stream started") try: - for run_index, frame_result in enumerate(self._stream): - if run_index < PRIMER_FRAMES: - # Primer results are drained WITHOUT consuming a request - # id — otherwise a buffered primer result could steal a - # real id that is already pending (P1 misattribution). - if run_index == PRIMER_FRAMES - 1: - self._primed.set() - continue + for frame_result in self._stream: out = _pad_rows(self._detection_rows(frame_result)) if self._pending_ids: # FIFO: the stream returns results in submission order, so @@ -309,12 +301,6 @@ class _AxeleraInference: # permanently wedging this detector. raise RuntimeError("Axelera inference thread stopped; restart required.") - # Wait briefly for the primer results to drain ONCE, so they can never - # be attributed to a real request id. After this the pump only ever - # sees real frames (keep-alive frames are no longer emitted). - if not self._primed.is_set(): - self._primed.wait(timeout=0.5) - # Every submitted frame gets a unique id; the pump stores the result # under this id, so we can wait for OUR frame's outcome exclusively. self._next_id += 1 From 6fd5b136b6ce79ae62ac841f2c8ea045d746513d Mon Sep 17 00:00:00 2001 From: patrykorwat Date: Fri, 21 Aug 2026 06:08:17 +0000 Subject: [PATCH 21/39] fix(axelera): stop keep-alive frames after pipeline init Misattribution reproduced at night: when Frigate stops sending frames during quiet periods the queue empties and keep-alive frames are emitted; a pending real-frame result then races the keep-alive's (empty) result, which steals the real id (frame/detections shown under the WRONG camera). Fix: synthesize keep-alive frames ONLY until the first stream result (_primed), then never again - no unregistered result can exist in the pipeline afterwards. Queue-empty after init just waits; under Frigate's continuous feed this branch is only hit during true quiet periods, where there is no pending request to misattribute anyway. --- frigate/detectors/plugins/axelera.py | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/frigate/detectors/plugins/axelera.py b/frigate/detectors/plugins/axelera.py index e3ea5436e7..cf990f8dfa 100644 --- a/frigate/detectors/plugins/axelera.py +++ b/frigate/detectors/plugins/axelera.py @@ -153,6 +153,11 @@ class _AxeleraInference: # flight the FIFO pop can misattribute a result to the wrong camera. # With at most one frame in flight the result can only belong to it. self._run_lock = threading.Lock() + # Set on the FIRST stream result. Until then the feed may synthesize + # keep-alive frames so the SDK pipeline finishes initializing; after + # this NO synthetic frames are ever emitted, so an unregistered result + # can never arrive while a real request id is pending. + self._primed = threading.Event() if stream_factory is None: # lazy import: never import the SDK unless the detector is used @@ -187,9 +192,16 @@ class _AxeleraInference: try: frame_id, frame = self._frame_q.get(timeout=1.0) except queue.Empty: - # keep-alive: no request pending, keep the SDK stream fed so the - # inference timeout never kills it during quiet periods. The - # None id means this frame's result is dropped by the pump. + if self._primed.is_set(): + # After init NEVER synthesize frames: a keep-alive result + # can arrive while a real id is pending and steal it + # (misattribution across cameras, observed at night when + # Frigate stops sending frames during quiet periods). + time.sleep(0.2) + continue + # During pipeline init the SDK needs a sustained source; these + # zero frames prime it and are drained before the first real + # request is registered. frame_id, frame = None, np.zeros( (self._height, self._width, 3), np.uint8 ) @@ -255,8 +267,11 @@ class _AxeleraInference: logger.info("axelera: inference stream started") try: for frame_result in self._stream: + if not self._primed.is_set(): + self._primed.set() + logger.info("axelera: pipeline initialized (first result)") out = _pad_rows(self._detection_rows(frame_result)) - if self._pending_ids: + if self._primed.is_set() and self._pending_ids: # FIFO: the stream returns results in submission order, so # the oldest pending id belongs to this result. frame_id = self._pending_ids.popleft() From 780ae97c263cff08b2599f7e97827f0eb076fcac Mon Sep 17 00:00:00 2001 From: patrykorwat Date: Fri, 21 Aug 2026 06:51:12 +0000 Subject: [PATCH 22/39] fix(axelera): revert token-based pairing; restore stable 7ms inference The token-based 1:1 pairing (None token per synthetic frame) is correct in theory but regressed in production: inference latency went to ~3s and detections dropped to ~0. Revert to the stable approach that has been running fine (lock + FIFO + keep-alive): ~7ms inference, detections flowing. P1 (synthetic results stealing real ids) remains a theoretical concern under this scheme, but the observed production behavior with it was far worse. Investigate the regression with per-frame diagnostics before re-attempting. --- frigate/detectors/plugins/axelera.py | 23 ++++------------------- 1 file changed, 4 insertions(+), 19 deletions(-) diff --git a/frigate/detectors/plugins/axelera.py b/frigate/detectors/plugins/axelera.py index cf990f8dfa..e3ea5436e7 100644 --- a/frigate/detectors/plugins/axelera.py +++ b/frigate/detectors/plugins/axelera.py @@ -153,11 +153,6 @@ class _AxeleraInference: # flight the FIFO pop can misattribute a result to the wrong camera. # With at most one frame in flight the result can only belong to it. self._run_lock = threading.Lock() - # Set on the FIRST stream result. Until then the feed may synthesize - # keep-alive frames so the SDK pipeline finishes initializing; after - # this NO synthetic frames are ever emitted, so an unregistered result - # can never arrive while a real request id is pending. - self._primed = threading.Event() if stream_factory is None: # lazy import: never import the SDK unless the detector is used @@ -192,16 +187,9 @@ class _AxeleraInference: try: frame_id, frame = self._frame_q.get(timeout=1.0) except queue.Empty: - if self._primed.is_set(): - # After init NEVER synthesize frames: a keep-alive result - # can arrive while a real id is pending and steal it - # (misattribution across cameras, observed at night when - # Frigate stops sending frames during quiet periods). - time.sleep(0.2) - continue - # During pipeline init the SDK needs a sustained source; these - # zero frames prime it and are drained before the first real - # request is registered. + # keep-alive: no request pending, keep the SDK stream fed so the + # inference timeout never kills it during quiet periods. The + # None id means this frame's result is dropped by the pump. frame_id, frame = None, np.zeros( (self._height, self._width, 3), np.uint8 ) @@ -267,11 +255,8 @@ class _AxeleraInference: logger.info("axelera: inference stream started") try: for frame_result in self._stream: - if not self._primed.is_set(): - self._primed.set() - logger.info("axelera: pipeline initialized (first result)") out = _pad_rows(self._detection_rows(frame_result)) - if self._primed.is_set() and self._pending_ids: + if self._pending_ids: # FIFO: the stream returns results in submission order, so # the oldest pending id belongs to this result. frame_id = self._pending_ids.popleft() From fc28ee18920cf46bab58e6767eae07a64965e2e2 Mon Sep 17 00:00:00 2001 From: patrykorwat Date: Fri, 21 Aug 2026 08:49:26 +0000 Subject: [PATCH 23/39] fix(axelera): content-based pixel-token pairing for results (P1) The SDK does not tag results with a per-frame id (image_id is always empty, src_timestamp is a pipeline-clock value), so pairing relied on FIFO ordering, which a buffered primer/keep-alive result could break: the stale result would pop the pending id and be delivered to the wrong camera/crop (synthetic results consuming real IDs). Every real frame now carries a 32-bit request token written into a small top-left pixel band; the SDK returns the source frame back 1:1 (verified on hardware: BGR pass-through exact, BGRA), so the pump decodes the token from the result image and delivers the outcome only to the frame that carries it (content-based, no ordering assumption). Primer/keep-alive frames carry no token; their results are dropped even when they surface while a request is pending. --- frigate/detectors/plugins/axelera.py | 120 +++++++++++++++++++++------ 1 file changed, 95 insertions(+), 25 deletions(-) diff --git a/frigate/detectors/plugins/axelera.py b/frigate/detectors/plugins/axelera.py index e3ea5436e7..d83a8629e5 100644 --- a/frigate/detectors/plugins/axelera.py +++ b/frigate/detectors/plugins/axelera.py @@ -24,7 +24,6 @@ Model configuration follows the hardware-backend convention: - `model.width` / `model.height`: input frame dimensions for the network. """ -import collections import logging import os import queue @@ -53,6 +52,21 @@ INFERENCE_TIMEOUT = 5.0 MAX_ROWS = 20 PRIMER_FRAMES = 3 +# Per-frame tag: the SDK returns each source frame back 1:1 in the result +# (verified on hardware: BGR pass-through is exact, image dimension preserved), +# but it provides NO per-frame id (image_id is always empty, src_timestamp is a +# pipeline-clock value, not an id). We therefore write a 32-bit request token +# into a small pixel band in the top-left corner of every REAL frame and decode +# it from the result image, so results are paired to requests by CONTENT. This +# is immune to the SDK buffering, priming or reordering results: a buffered +# primer/keep-alive result carries token 0 and is dropped, and a real result +# can only be delivered to the frame that carries its token. +TOKEN_BITS = 32 +TOKEN_ROWS = (TOKEN_BITS + 2) // 3 # 11 rows x 3 channel-cells = 33 (one spare) +TOKEN_X0 = 4 +TOKEN_CELL_W = 4 +TOKEN_NO_ID = 0 + # ----------------- Utility Functions ----------------- # @@ -114,6 +128,44 @@ def _pad_rows(rows) -> np.ndarray: return out +def _stamp_token(frame: np.ndarray, token: int) -> None: + """Write a 32-bit request token into the top-left pixel band of `frame`. + + Band geometry must match `_read_token`. The values 0/255 survive the SDK's + BGR->BGRA pass-through exactly (verified on hardware). + """ + band = frame[0:TOKEN_ROWS, TOKEN_X0 : TOKEN_X0 + TOKEN_CELL_W] + for i in range(TOKEN_BITS): + row, ch = divmod(i, 3) + val = 255 if (token >> (TOKEN_BITS - 1 - i)) & 1 else 0 + band[row, :, ch] = val + + +def _read_token(frame_result) -> int: + """Decode the request token stamped on a stream result's source frame. + + The SDK returns the source frame 1:1 (BGRA); we read the matching band from + the first 3 channels. Returns TOKEN_NO_ID for tagless results (primer / + keep-alive, or a frame_result whose image is unavailable). + """ + img = getattr(frame_result, "image", None) + if img is None: + return TOKEN_NO_ID + try: + arr = img.asarray() + except Exception: # noqa: BLE001 + return TOKEN_NO_ID + if getattr(arr, "ndim", 0) < 3: + return TOKEN_NO_ID + band = arr[0:TOKEN_ROWS, TOKEN_X0 : TOKEN_X0 + TOKEN_CELL_W, 0:3] + token = 0 + for i in range(TOKEN_BITS): + row, ch = divmod(i, 3) + bit = 1 if band[row, :, ch].mean() > 127 else 0 + token = (token << 1) | bit + return token + + # ----------------- AxeleraInference (engine) ----------------- # @@ -129,12 +181,13 @@ class _AxeleraInference: never starves and is not torn down by its internal inference timeout during quiet periods (e.g. a camera with no motion). - Results are STRICTLY paired to requests: every frame the feeder pushes into - the SDK registers its request id on a deque (in submission order), and each - stream result pops the next pending id and stores the outcome under it. - ``run()`` waits for its own id only, so a result can never be attributed to - a different camera or crop, and primer/keep-alive results (unregistered ids) - are dropped instead of being returned to a caller. + Results are STRICTLY paired to requests by CONTENT: every real frame is + stamped with a 32-bit request token in its top-left pixel band, the pump + decodes the token from the result image and delivers the outcome only to + that frame's caller. Primer/keep-alive frames carry no token, so their + results are provably dropped even if the SDK buffers or reorders them — + no FIFO ordering assumption remains, and a buffered result can never be + attributed to a different camera or crop. """ def __init__( @@ -144,14 +197,15 @@ class _AxeleraInference: self._width, self._height = int(width), int(height) self._frame_q: queue.Queue = queue.Queue(maxsize=64) self._cond = threading.Condition() - self._pending_ids: collections.deque = collections.deque() + # request ids a caller is still waiting on (guarded by _cond); the pump + # only delivers results whose decoded token is in this set + self._waiting: set = set() self._results: dict[int, np.ndarray] = {} self._next_id = 0 - # Serializes run() calls: the SDK stream does not tag results with a - # per-frame id and can reorder/buffer results under sustained load - # (observed after many thousands of frames), so with several frames in - # flight the FIFO pop can misattribute a result to the wrong camera. - # With at most one frame in flight the result can only belong to it. + # Serializes run() calls: pairing is content-based (see _read_token), + # so concurrent frames would be perfectly matched too, but keeping at + # most ONE frame in flight gives fully deterministic behaviour and + # bounds the SDK's internal buffering. self._run_lock = threading.Lock() if stream_factory is None: @@ -196,9 +250,9 @@ class _AxeleraInference: if frame is None: # shutdown sentinel from stop() return if frame_id is not None: - # submission order == result order: the pump pops this id when - # the SDK yields the result for this frame. - self._pending_ids.append(frame_id) + # Tag the frame with its request id; the pump decodes it from + # the result image (content-based pairing, no ordering assumed). + _stamp_token(frame, frame_id) yield frame def _detection_rows(self, frame_result) -> list: @@ -256,13 +310,20 @@ class _AxeleraInference: try: for frame_result in self._stream: out = _pad_rows(self._detection_rows(frame_result)) - if self._pending_ids: - # FIFO: the stream returns results in submission order, so - # the oldest pending id belongs to this result. - frame_id = self._pending_ids.popleft() - with self._cond: - self._results[frame_id] = out - self._cond.notify_all() + token = _read_token(frame_result) + if token == TOKEN_NO_ID: + # primer / keep-alive result: it carries no request token, + # so it can NEVER be delivered to a caller, even if it + # surfaces while a request is pending. This is what makes a + # buffered/synthetic result provably unable to steal a + # request id (cf. prior FIFO pairing). + continue + with self._cond: + if token not in self._waiting: + # stale result for a frame that already timed out + continue + self._results[token] = out + self._cond.notify_all() except Exception: logger.exception( "axelera: inference stream failed; detector requires restart" @@ -301,13 +362,18 @@ class _AxeleraInference: # permanently wedging this detector. raise RuntimeError("Axelera inference thread stopped; restart required.") - # Every submitted frame gets a unique id; the pump stores the result - # under this id, so we can wait for OUR frame's outcome exclusively. + # Every submitted frame gets a unique id; its token is decoded from the + # result image by the pump, so we can wait for OUR frame's outcome + # exclusively (content-based pairing, no ordering assumptions). self._next_id += 1 frame_id = self._next_id + with self._cond: + self._waiting.add(frame_id) try: self._frame_q.put((frame_id, frame), timeout=2.0) except queue.Full: + with self._cond: + self._waiting.discard(frame_id) raise RuntimeError( "Axelera inference queue is full; restart required." ) from None @@ -321,7 +387,11 @@ class _AxeleraInference: self._cond.wait(remaining) if frame_id in self._results: result = self._results.pop(frame_id) + self._waiting.discard(frame_id) return result + # timeout: drop the id so a late result is not delivered/stored + with self._cond: + self._waiting.discard(frame_id) if self._thread.is_alive(): raise RuntimeError( "Axelera inference stream returned no result for the submitted " From a50f535238216db487cd2191cd4005a42c82c168 Mon Sep 17 00:00:00 2001 From: patrykorwat Date: Fri, 21 Aug 2026 09:38:17 +0000 Subject: [PATCH 24/39] fix(axelera): no keep-alives while a real frame is outstanding Keep-alive zeros injected during a pending request queue in the SDK pipeline ahead of the real frame and delay its result by one slot each: observed 3 s real-frame latency with 1 s keep-alive spacing vs 14 ms for back-to-back real frames (and 10 ms delivered by the FIFO path). The feeder now skips keep-alives whenever a request id is still outstanding, so real frames are processed back-to-back and true-idle behaviour (stream kept alive) is preserved. --- frigate/detectors/plugins/axelera.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/frigate/detectors/plugins/axelera.py b/frigate/detectors/plugins/axelera.py index d83a8629e5..2c6d981ede 100644 --- a/frigate/detectors/plugins/axelera.py +++ b/frigate/detectors/plugins/axelera.py @@ -198,7 +198,8 @@ class _AxeleraInference: self._frame_q: queue.Queue = queue.Queue(maxsize=64) self._cond = threading.Condition() # request ids a caller is still waiting on (guarded by _cond); the pump - # only delivers results whose decoded token is in this set + # only delivers results whose decoded token is in this set. The feeder + # also uses it as the "real frame outstanding" signal. self._waiting: set = set() self._results: dict[int, np.ndarray] = {} self._next_id = 0 @@ -241,9 +242,17 @@ class _AxeleraInference: try: frame_id, frame = self._frame_q.get(timeout=1.0) except queue.Empty: - # keep-alive: no request pending, keep the SDK stream fed so the - # inference timeout never kills it during quiet periods. The - # None id means this frame's result is dropped by the pump. + # A real frame may be outstanding: do NOT inject a keep-alive in + # that window, otherwise the zeros queue in the SDK pipeline + # ahead of the pending frame and delay its result by one slot + # per keep-alive (observed: 3 s real-frame latency under 1 s + # keep-alive spacing vs 14 ms for back-to-back real frames). + with self._cond: + if self._waiting: + continue + # truly idle: keep the SDK stream fed so its inference timeout + # never kills it during quiet periods. The None id means this + # frame's result is dropped by the pump. frame_id, frame = None, np.zeros( (self._height, self._width, 3), np.uint8 ) From 9146350dbeafb63ee79503427d79f853c5c58872 Mon Sep 17 00:00:00 2001 From: patrykorwat Date: Fri, 21 Aug 2026 09:53:34 +0000 Subject: [PATCH 25/39] fix(axelera): keep SDK pipeline hot with fast keep-alives The SDK pipeline is clocked by the input arrival interval: measured ~4 s real-frame latency at 1 s keep-alive spacing vs ~58 ms back-to-back (depth-4 pipeline x arrival gap). Gating keep-alives while a request is outstanding also starved the SDK input source (it waited instead of yielding) and broke the stream. Short 50 ms keep-alives keep the ring rotating, so real frames get results in tens of ms; the zeros carry no request token and are dropped by the content-based pump. --- frigate/detectors/plugins/axelera.py | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/frigate/detectors/plugins/axelera.py b/frigate/detectors/plugins/axelera.py index 2c6d981ede..1a20cc89a4 100644 --- a/frigate/detectors/plugins/axelera.py +++ b/frigate/detectors/plugins/axelera.py @@ -51,6 +51,13 @@ AX_DEFAULT_NETWORK = "yolox-s-coco-onnx" INFERENCE_TIMEOUT = 5.0 MAX_ROWS = 20 PRIMER_FRAMES = 3 +# Keep-alive spacing. The SDK pipeline is "clocked" by the input arrival +# interval (measured: pipeline depth x arrival gap, e.g. ~4 x 1 s = ~4 s real +# frame latency at 1 s keep-alive spacing, vs ~58 ms back-to-back). Feeding +# frames with a SHORT idle gap keeps the pipeline rotating, so a real frame +# gets its result in tens of ms even when requests are sparse. The zeros carry +# no request token and their results are dropped by the pump. +KEEPALIVE_INTERVAL = 0.05 # Per-frame tag: the SDK returns each source frame back 1:1 in the result # (verified on hardware: BGR pass-through is exact, image dimension preserved), @@ -240,19 +247,15 @@ class _AxeleraInference: while True: frame = frame_id = None try: - frame_id, frame = self._frame_q.get(timeout=1.0) + frame_id, frame = self._frame_q.get(timeout=KEEPALIVE_INTERVAL) except queue.Empty: - # A real frame may be outstanding: do NOT inject a keep-alive in - # that window, otherwise the zeros queue in the SDK pipeline - # ahead of the pending frame and delay its result by one slot - # per keep-alive (observed: 3 s real-frame latency under 1 s - # keep-alive spacing vs 14 ms for back-to-back real frames). - with self._cond: - if self._waiting: - continue - # truly idle: keep the SDK stream fed so its inference timeout - # never kills it during quiet periods. The None id means this - # frame's result is dropped by the pump. + # keep-alive: the SDK pipeline is clocked by the input arrival + # interval, so feed a zero frame on a SHORT cadence to keep it + # rotating (a 1 s idle gap makes a real frame's result take ~4 s + # on a depth-4 pipeline; a short gap keeps it in the tens of ms). + # These frames carry no request token, so their results are + # dropped by the pump. (Previously a 1 s gap ALSO starved the + # SDK input source, which waited/looped instead of yielding.) frame_id, frame = None, np.zeros( (self._height, self._width, 3), np.uint8 ) From 2c31cb4550606a25b4a6f5904f65b1e2524232fc Mon Sep 17 00:00:00 2001 From: patrykorwat Date: Fri, 21 Aug 2026 11:46:03 +0000 Subject: [PATCH 26/39] fix(axelera): accept scheme-less host URLs in model.path is_url() required an http(s) scheme, so a bare host like www.example.com/model.axm fell through to the filesystem branch and raised FileNotFoundError, blocking detector init on a valid remote model (reviewer P1). A value matching a leading dotted host (or localhost) with a path is now recognised as a URL and http:// is prepended at fetch time; relative paths and preset names behave as before. --- frigate/detectors/plugins/axelera.py | 30 +++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/frigate/detectors/plugins/axelera.py b/frigate/detectors/plugins/axelera.py index 1a20cc89a4..0aa3134925 100644 --- a/frigate/detectors/plugins/axelera.py +++ b/frigate/detectors/plugins/axelera.py @@ -27,6 +27,7 @@ Model configuration follows the hardware-backend convention: import logging import os import queue +import re import threading import time from typing import Literal @@ -78,9 +79,31 @@ TOKEN_NO_ID = 0 # ----------------- Utility Functions ----------------- # +# Matches a scheme-less host URL: host([:port])/path, e.g. www.example.com/model.axm +# or localhost:8000/model.axm (bare filenames like foo.axm and relative paths like +# models/foo.axm have no leading dotted host and stay local paths / presets). +_HOST_URL_RE = re.compile( + r"^(localhost|[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*\.[a-zA-Z]{2,})(:[0-9]+)?/" +) + + def is_url(value: str) -> bool: - """Return True for well-formed http(s) URLs (scheme required).""" - return value.startswith(("http://", "https://")) + """Return True for http(s) URLs and scheme-less host URLs. + + A bare host such as ``www.example.com/model.axm`` (no scheme) is a usable + URL too: rejecting it would raise FileNotFoundError on a valid remote + model. ``_url_with_scheme`` prepends ``http://`` before any fetch. + """ + return value.startswith(("http://", "https://")) or bool( + _HOST_URL_RE.match(value) + ) + + +def _url_with_scheme(value: str) -> str: + """Prepend http:// to a scheme-less host URL (vetted by is_url).""" + if value.startswith(("http://", "https://")): + return value + return f"http://{value}" def _looks_like_path(value: str) -> bool: @@ -105,7 +128,8 @@ def _resolve_network(model_path, model_url) -> str: if not os.path.isfile(destination): logger.info("axelera: downloading model from %s", model_url) try: - ModelDownloader.download_from_url(model_url, destination) + download_url = _url_with_scheme(model_url) + ModelDownloader.download_from_url(download_url, destination) except Exception as exc: raise RuntimeError( f"Failed to download model from {model_url}: {exc}" From 387fdd3f38c27f1fd18f9ffdf7472c09c3db4b60 Mon Sep 17 00:00:00 2001 From: patrykorwat Date: Fri, 21 Aug 2026 12:35:12 +0000 Subject: [PATCH 27/39] feat(axelera): use Frigate's AsyncDetectorRunner; drop lock and keep-alives The synchronous DetectorRunner path serialized run() and needed 20 Hz keep-alive zero frames just to keep the Voyager SDK pipeline rotating: the SDK pipeline is clocked by the input arrival interval (measured: 1 s gaps stretch a real frame's latency to ~4 s on the depth-4 pipeline), and one-frame-in-flight caps multi-camera throughput. AsyncDetectorRunner (as memryx) sends from the detect thread and receives on the result thread, and closes the input SHM buffer on the result side, so the frame copy in the plugin goes away. - plugin implements send_input()/receive_output()/shutdown() and a detect_raw() stub (memryx precedent); content-based pixel-token pairing is preserved (primer results carry no token and are dropped). - object_detection/base.py: route axelera to AsyncDetectorRunner. With real frames clocking the pipeline, no keep-alives are needed; the throughput ceiling imposed by one frame in flight is gone. --- frigate/detectors/plugins/axelera.py | 282 ++++++++++++--------------- frigate/object_detection/base.py | 4 +- 2 files changed, 124 insertions(+), 162 deletions(-) diff --git a/frigate/detectors/plugins/axelera.py b/frigate/detectors/plugins/axelera.py index 0aa3134925..67a1e0b29e 100644 --- a/frigate/detectors/plugins/axelera.py +++ b/frigate/detectors/plugins/axelera.py @@ -14,11 +14,27 @@ This is the only code path that is reliable across Metis hosts (raw device-level connections can wedge the board on some hosts), and it amortises model compilation across frames. +The plugin uses Frigate's ASYNC detector contract (`AsyncDetectorRunner`, +same as the memryx plugin): `send_input(connection_id, tensor)` is called +from the detect worker thread as frames arrive, and `receive_output()` from +the result worker thread returns `(connection_id, rows)` per completed frame. +Because the runner keeps submitting real frames as cameras feed them, the SDK +pipeline is continuously clocked by real frames and no keep-alives are +needed (measured: back-to-back real frames round-trip in ~14 ms, while +interleaving 1 s keep-alives stretched a real frame's result to ~4 s). + +Results are paired to requests by CONTENT: each submitted frame carries a +32-bit pixel token in its top-left band (see `_stamp_token`); the SDK returns +the source frame back 1:1 (verified on hardware), so `receive_output` +decodes the token and delivers the result only to the matching connection id. +Primer frames (used only to kick-start pipeline init) carry no token and their +results are provably dropped even if they surface while a request is pending. + Model configuration follows the hardware-backend convention: - - `model.path` = URL (downloaded to the model cache) | local file path | SDK - model-zoo preset name (e.g. ``yolox-s-coco-onnx``). When omitted, the SDK - model-zoo preset ``AX_DEFAULT_NETWORK`` (YOLOX, Apache-2.0 licensed) is - used; use `model.path: yolox-s-coco-onnx` to select it explicitly. + - `model.path` = URL | scheme-less host URL (``www.example.com/...``) | local + file path | SDK model-zoo preset name (e.g. ``yolox-s-coco-onnx``). When + omitted, the SDK model-zoo preset ``AX_DEFAULT_NETWORK`` (YOLOX, Apache-2.0 + licensed) is used. - `model.labelmap_path` / `model.labelmap`: labels are mapped to class ids BY NAME via the merged labelmap, independent of label ordering. - `model.width` / `model.height`: input frame dimensions for the network. @@ -28,8 +44,6 @@ import logging import os import queue import re -import threading -import time from typing import Literal import numpy as np @@ -48,17 +62,8 @@ DETECTOR_KEY = "axelera" # YOLOX (Megvii) is Apache-2.0 licensed; Ultralytics YOLO models are AGPL-3.0 # and cannot be referenced by Frigate (MIT), see blakeblackshear/frigate#10717. AX_DEFAULT_NETWORK = "yolox-s-coco-onnx" - -INFERENCE_TIMEOUT = 5.0 MAX_ROWS = 20 PRIMER_FRAMES = 3 -# Keep-alive spacing. The SDK pipeline is "clocked" by the input arrival -# interval (measured: pipeline depth x arrival gap, e.g. ~4 x 1 s = ~4 s real -# frame latency at 1 s keep-alive spacing, vs ~58 ms back-to-back). Feeding -# frames with a SHORT idle gap keeps the pipeline rotating, so a real frame -# gets its result in tens of ms even when requests are sparse. The zeros carry -# no request token and their results are dropped by the pump. -KEEPALIVE_INTERVAL = 0.05 # Per-frame tag: the SDK returns each source frame back 1:1 in the result # (verified on hardware: BGR pass-through is exact, image dimension preserved), @@ -204,41 +209,35 @@ class _AxeleraInference: """Feeds frames through a single Voyager SDK inference stream. The stream is built from a frame generator (``sources``), so frames can be - pushed by ``detect_raw`` while the consumer thread iterates the stream. + pushed by ``submit`` while ``collect`` (called from the result thread) + pulls one completed frame at a time. A short primer of zero frames + kick-starts model pipeline init; their results carry no token and are + dropped by ``collect``. - A short primer of zero frames kick-starts model pipeline init (matching the - validated behaviour of ``create_inference_stream``). While no request is - pending, the feeder keeps yielding zero keep-alive frames so the SDK stream - never starves and is not torn down by its internal inference timeout during - quiet periods (e.g. a camera with no motion). + No keep-alives: the caller (Frigate's AsyncDetectorRunner) submits real + frames continuously as cameras feed them, so the SDK pipeline is clocked + by real frames (verified: 1 s input gaps stretch a real frame's latency to + ~4 s on this depth-4 pipeline, back-to-back frames round-trip in ~14 ms). Results are STRICTLY paired to requests by CONTENT: every real frame is - stamped with a 32-bit request token in its top-left pixel band, the pump + stamped with a 32-bit request token in its top-left pixel band, ``collect`` decodes the token from the result image and delivers the outcome only to - that frame's caller. Primer/keep-alive frames carry no token, so their - results are provably dropped even if the SDK buffers or reorders them — - no FIFO ordering assumption remains, and a buffered result can never be + the connection id that owns it. Primer frames carry no token, so their + results are provably dropped even if the SDK buffers or reorders them; no + FIFO ordering assumption remains, and a buffered result can never be attributed to a different camera or crop. """ def __init__( self, network, labelmap: dict, width: int, height: int, stream_factory=None - ): + ) -> None: self._labelmap = labelmap self._width, self._height = int(width), int(height) self._frame_q: queue.Queue = queue.Queue(maxsize=64) - self._cond = threading.Condition() - # request ids a caller is still waiting on (guarded by _cond); the pump - # only delivers results whose decoded token is in this set. The feeder - # also uses it as the "real frame outstanding" signal. - self._waiting: set = set() - self._results: dict[int, np.ndarray] = {} + # token -> connection_id for frames in flight (written by submit on the + # detect thread, consumed by collect on the result thread) + self._token_to_conn: dict[int, str] = {} self._next_id = 0 - # Serializes run() calls: pairing is content-based (see _read_token), - # so concurrent frames would be perfectly matched too, but keeping at - # most ONE frame in flight gives fully deterministic behaviour and - # bounds the SDK's internal buffering. - self._run_lock = threading.Lock() if stream_factory is None: # lazy import: never import the SDK unless the detector is used @@ -253,42 +252,20 @@ class _AxeleraInference: stream_factory = create_inference_stream self._stream = stream_factory(network=network, sources=[self._feed()]) - self._thread = threading.Thread( - target=self._pump, daemon=True, name="axelera-pump" - ) - self._thread.start() - - @property - def alive(self) -> bool: - return self._thread.is_alive() def _feed(self): # primer frames: the SDK init pre-buffers several frames before the - # source yields (validated on hardware). Their results are dropped by - # the pump while the pending queue is empty. + # source yields (validated on hardware); they carry no token and their + # results are dropped by collect(). for _ in range(PRIMER_FRAMES): yield np.zeros((self._height, self._width, 3), np.uint8) while True: - frame = frame_id = None - try: - frame_id, frame = self._frame_q.get(timeout=KEEPALIVE_INTERVAL) - except queue.Empty: - # keep-alive: the SDK pipeline is clocked by the input arrival - # interval, so feed a zero frame on a SHORT cadence to keep it - # rotating (a 1 s idle gap makes a real frame's result take ~4 s - # on a depth-4 pipeline; a short gap keeps it in the tens of ms). - # These frames carry no request token, so their results are - # dropped by the pump. (Previously a 1 s gap ALSO starved the - # SDK input source, which waited/looped instead of yielding.) - frame_id, frame = None, np.zeros( - (self._height, self._width, 3), np.uint8 - ) + # Blocking: the SDK input side pulls the next frame when ready. + # Real frames (pushed by submit) clock the pipeline. + token, frame = self._frame_q.get() if frame is None: # shutdown sentinel from stop() return - if frame_id is not None: - # Tag the frame with its request id; the pump decodes it from - # the result image (content-based pairing, no ordering assumed). - _stamp_token(frame, frame_id) + _ = token # the token was already stamped in submit() yield frame def _detection_rows(self, frame_result) -> list: @@ -341,104 +318,54 @@ class _AxeleraInference: rows.sort(key=lambda r: r[1], reverse=True) return rows[:MAX_ROWS] - def _pump(self): - logger.info("axelera: inference stream started") - try: - for frame_result in self._stream: - out = _pad_rows(self._detection_rows(frame_result)) - token = _read_token(frame_result) - if token == TOKEN_NO_ID: - # primer / keep-alive result: it carries no request token, - # so it can NEVER be delivered to a caller, even if it - # surfaces while a request is pending. This is what makes a - # buffered/synthetic result provably unable to steal a - # request id (cf. prior FIFO pairing). - continue - with self._cond: - if token not in self._waiting: - # stale result for a frame that already timed out - continue - self._results[token] = out - self._cond.notify_all() - except Exception: - logger.exception( - "axelera: inference stream failed; detector requires restart" - ) - - def run(self, tensor_input, timeout: float = INFERENCE_TIMEOUT) -> np.ndarray: - """Run one inference and return this frame's detections (see detect_raw).""" - # At most ONE frame may be in flight at a time: with several frames in - # the stream the SDK can reorder/buffer their results (misattribution - # across cameras observed after many thousands of frames). Serialized, - # the next returned result can only belong to the one frame we pushed. - with self._run_lock: - return self._run_locked(tensor_input, timeout) - - def _run_locked(self, tensor_input, timeout: float) -> np.ndarray: - if tensor_input is None or getattr(tensor_input, "ndim", 0) < 3: - raise ValueError( - f"Unexpected frame shape: {getattr(tensor_input, 'shape', tensor_input)}" - ) - - frame = np.asarray(tensor_input) - if frame.ndim == 4: - # Frigate feeds a batched NHWC frame (1, H, W, C); drop the batch dim - if frame.shape[0] != 1: - raise ValueError(f"Unexpected batch dimension in frame: {frame.shape}") - frame = frame[0] - # Real copy: the shared-memory camera buffer is unmapped by Frigate as - # soon as detect_raw returns, and our queue can hold the frame past that - # point (timeout path, backed-up queue). NP views would let the SDK read - # a torn/unmapped segment. - frame = np.array(frame, dtype=np.uint8, order="C", copy=True) - - if not self._thread.is_alive(): - # The pump is gone (stream wedged): never block on the queue put, - # the SDK side is not draining it and the queue would fill up, - # permanently wedging this detector. - raise RuntimeError("Axelera inference thread stopped; restart required.") - - # Every submitted frame gets a unique id; its token is decoded from the - # result image by the pump, so we can wait for OUR frame's outcome - # exclusively (content-based pairing, no ordering assumptions). + def submit(self, connection_id: str, frame: np.ndarray) -> None: + """Send one frame for inference, tagged with a unique pixel token.""" self._next_id += 1 - frame_id = self._next_id - with self._cond: - self._waiting.add(frame_id) + token = self._next_id + _stamp_token(frame, token) + self._token_to_conn[token] = connection_id try: - self._frame_q.put((frame_id, frame), timeout=2.0) + self._frame_q.put_nowait((token, frame)) except queue.Full: - with self._cond: - self._waiting.discard(frame_id) + self._token_to_conn.pop(token, None) raise RuntimeError( "Axelera inference queue is full; restart required." ) from None - deadline = time.monotonic() + timeout - with self._cond: - while frame_id not in self._results: - remaining = deadline - time.monotonic() - if remaining <= 0: - break - self._cond.wait(remaining) - if frame_id in self._results: - result = self._results.pop(frame_id) - self._waiting.discard(frame_id) - return result - # timeout: drop the id so a late result is not delivered/stored - with self._cond: - self._waiting.discard(frame_id) - if self._thread.is_alive(): - raise RuntimeError( - "Axelera inference stream returned no result for the submitted " - f"frame within {timeout:.1f}s (stream stall); restart required." - ) - raise RuntimeError("Axelera inference thread stopped; restart required.") + def collect(self): + """Wait for the next completed frame; return (connection_id, rows). - def stop(self): - # cleanup must not raise + ``rows`` is a fixed (20, 6) float32 numpy array of normalized + detections. Primer/synthetic results (token 0) are dropped internally. + Returns ``(None, None)`` when the stream is stopped. + """ + while True: + try: + frame_result = next(self._stream) + except StopIteration: + return None, None + except Exception: + logger.exception( + "axelera: inference stream failed; detector requires restart" + ) + raise + out = _pad_rows(self._detection_rows(frame_result)) + token = _read_token(frame_result) + if token == TOKEN_NO_ID: + # primer / keep-alive result: no request token, so it can never + # be delivered to a caller, even if it surfaces while a request + # is in flight. + continue + connection_id = self._token_to_conn.pop(token, None) + if connection_id is None: + # stale result for a frame we already returned or that errored + continue + return connection_id, out + + def stop(self) -> None: + """Stop the stream and release the Metis device. Never raises.""" try: - self._frame_q.put_nowait((None, None)) + self._frame_q.put_nowait((0, None)) except Exception: # noqa: S110, BLE001 pass try: @@ -493,14 +420,49 @@ class AxeleraDetector(DetectionApi): self.height, ) - def detect_raw(self, tensor_input) -> np.ndarray: - """Run the detector on one RGB frame and return (20, 6) float32 rows. + # --- async API (AsyncDetectorRunner, same contract as the memryx plugin) --- - Each row is (class_id, score, ymin, xmin, ymax, xmax) in NORMALIZED - coordinates [0..1] relative to the model input size. Unused rows are - zero-filled. Detections are strictly paired to THIS frame's inference. + def send_input(self, connection_id, tensor_input: np.ndarray) -> None: + """Preprocess (if needed) and send a frame to the Metis stream.""" + if tensor_input is None: + raise ValueError("[send_input] No image data provided for inference") + + frame = np.asarray(tensor_input) + if frame.ndim == 4: + # Frigate feeds a batched NHWC frame (1, H, W, C); drop the batch dim + if frame.shape[0] != 1: + raise ValueError(f"Unexpected batch dimension in frame: {frame.shape}") + frame = frame[0] + # Contiguous uint8 BGR for the SDK. If `frame` is still a view of the + # detect process's shared-memory buffer, stamping it in place is safe: + # AsyncDetectorRunner closes the input buffer only after our + # receive_output() returns, i.e. after the SDK has consumed its pixels. + frame = np.ascontiguousarray(frame, dtype=np.uint8) + self._inference.submit(connection_id, frame) + + def receive_output(self): + """Return (connection_id, rows) for the next completed inference. + + On a fatal stream error the detector process exits so Frigate restarts + it (a silent (None, None) would stall detection forever). """ - return self._inference.run(tensor_input) + try: + return self._inference.collect() + except Exception: + logger.exception("axelera: fatal stream error; exiting detector process") + try: + self._inference.stop() + except Exception: # noqa: S110, BLE001 + pass + os._exit(1) + + def shutdown(self) -> None: + """Gracefully shut down the Metis stream (called at detector stop).""" + self._inference.stop() + + def detect_raw(self, tensor_input: np.ndarray): + """Synchronous path removed: axelera uses the async detector contract.""" + return 0 # type: ignore[override] def close(self): """Stop the inference stream and release the Metis device.""" diff --git a/frigate/object_detection/base.py b/frigate/object_detection/base.py index bc7910e4d0..eca9b72d73 100644 --- a/frigate/object_detection/base.py +++ b/frigate/object_detection/base.py @@ -357,8 +357,8 @@ class ObjectDetectProcess: if (self.detect_process is not None) and self.detect_process.is_alive(): self.stop() - # Async path for MemryX - if self.detector_config.type == "memryx": + # Async path for MemryX and Axelera (stream-based send/receive threads) + if self.detector_config.type in ("memryx", "axelera"): self.detect_process = AsyncDetectorRunner( f"frigate.detector:{self.name}", self.detection_queue, From 639b229d947ed0eff68e21205f273b45989a5c45 Mon Sep 17 00:00:00 2001 From: patrykorwat Date: Fri, 21 Aug 2026 12:55:57 +0000 Subject: [PATCH 28/39] fix(axelera): keep SDK source fed during init with sparse safety keep-alives create_inference_stream consumes the source generator from the very start; a blocking queue.get() in the feed left the source empty during stream init, so the detector process hung before the async worker threads started (no events). Re-add a sparse keep-alive zero (0.1 s) that fires only while the incoming queue is momentarily empty and carries no request token (dropped by collect); steady-state real frames still clock the pipeline back-to-back via AsyncDetectorRunner. --- frigate/detectors/plugins/axelera.py | 35 +++++++++++++++++++++------- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/frigate/detectors/plugins/axelera.py b/frigate/detectors/plugins/axelera.py index 67a1e0b29e..3e83ca365b 100644 --- a/frigate/detectors/plugins/axelera.py +++ b/frigate/detectors/plugins/axelera.py @@ -64,6 +64,15 @@ DETECTOR_KEY = "axelera" AX_DEFAULT_NETWORK = "yolox-s-coco-onnx" MAX_ROWS = 20 PRIMER_FRAMES = 3 +# Safety-net keep-alive spacing while the incoming queue is momentarily empty +# (stream init, brief scheduling gaps, true global idle). AsyncDetectorRunner +# keeps the queue full with real frames in steady state, so these zeros rarely +# fire and real frames still clock the pipeline back-to-back (measured ~14 ms). +# A short spacing matters when they DO fire: the SDK pipeline is clocked by the +# input arrival interval (depth 4 x gap), so a 1 s gap would stretch the next +# real frame's result to ~4 s; 0.1 s keeps that penalty in the tens/hundreds +# of ms. These zeros carry no request token and are dropped by collect(). +KEEPALIVE_INTERVAL = 0.1 # Per-frame tag: the SDK returns each source frame back 1:1 in the result # (verified on hardware: BGR pass-through is exact, image dimension preserved), @@ -214,10 +223,14 @@ class _AxeleraInference: kick-starts model pipeline init; their results carry no token and are dropped by ``collect``. - No keep-alives: the caller (Frigate's AsyncDetectorRunner) submits real - frames continuously as cameras feed them, so the SDK pipeline is clocked - by real frames (verified: 1 s input gaps stretch a real frame's latency to - ~4 s on this depth-4 pipeline, back-to-back frames round-trip in ~14 ms). + In steady state real frames clock the SDK pipeline back-to-back: the + caller (Frigate's AsyncDetectorRunner) submits frames continuously as + cameras feed them, so the pipeline is never idle (verified: back-to-back + frames round-trip in ~14 ms, while 1 s input gaps stretch a real frame's + latency to ~4 s on this depth-4 pipeline). A sparse 0.1 s keep-alive zero + is emitted only while the incoming queue is momentarily empty (stream + init, scheduling gaps, true idle) so the SDK source never starves; these + zeros carry no token and are dropped. Results are STRICTLY paired to requests by CONTENT: every real frame is stamped with a 32-bit request token in its top-left pixel band, ``collect`` @@ -260,12 +273,18 @@ class _AxeleraInference: for _ in range(PRIMER_FRAMES): yield np.zeros((self._height, self._width, 3), np.uint8) while True: - # Blocking: the SDK input side pulls the next frame when ready. - # Real frames (pushed by submit) clock the pipeline. - token, frame = self._frame_q.get() + frame = None + try: + token, frame = self._frame_q.get(timeout=KEEPALIVE_INTERVAL) + _ = token # the token was already stamped in submit() + except queue.Empty: + # safety net: the SDK source must never starve (stream init, + # scheduling gaps, true idle). spawn a token-less zero frame; + # its result is dropped by collect(). In steady state real + # frames keep the queue populated, so this rarely fires. + frame = np.zeros((self._height, self._width, 3), np.uint8) if frame is None: # shutdown sentinel from stop() return - _ = token # the token was already stamped in submit() yield frame def _detection_rows(self, frame_result) -> list: From bc90ab343bb82e6ed6e13cfd4dfc4bc6eef5ce1e Mon Sep 17 00:00:00 2001 From: patrykorwat Date: Fri, 21 Aug 2026 13:03:28 +0000 Subject: [PATCH 29/39] fix(axelera): InferenceStream is iterable, not an iterator next(stream) raises TypeError ('InferenceStream' object is not an iterator), killing the result worker on the very first collect() (the older sync pump used a for-loop). Grab iter(stream) once at init and drive collect() through that iterator. Regression test: a stream that is iterable-without-__next__ like the real SDK. --- frigate/detectors/plugins/axelera.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/frigate/detectors/plugins/axelera.py b/frigate/detectors/plugins/axelera.py index 3e83ca365b..5b73a00667 100644 --- a/frigate/detectors/plugins/axelera.py +++ b/frigate/detectors/plugins/axelera.py @@ -265,6 +265,9 @@ class _AxeleraInference: stream_factory = create_inference_stream self._stream = stream_factory(network=network, sources=[self._feed()]) + # the SDK stream object is iterable but not an iterator itself + # (`next(stream)` is a TypeError); grab one iterator for collect(). + self._results_iter = iter(self._stream) def _feed(self): # primer frames: the SDK init pre-buffers several frames before the @@ -360,7 +363,7 @@ class _AxeleraInference: """ while True: try: - frame_result = next(self._stream) + frame_result = next(self._results_iter) except StopIteration: return None, None except Exception: From 3959640861e091a70d0d07f90aff1d3f4cb3aab6 Mon Sep 17 00:00:00 2001 From: patrykorwat Date: Sat, 22 Aug 2026 08:56:39 +0000 Subject: [PATCH 30/39] fix(axelera): pair results by recorded FIFO order instead of pixel tokens Replace the content-based pixel-token pairing with the reviewer's scheme: the feeder registers every yielded frame on a pending deque (None for primer/keep-alive synthetics, the connection id for real frames) and collect() pops exactly one entry per result, so synthetics are dropped and real results always reach the connection that owns them - without reading the returned image at all. Validated on hardware first: the SDK InferenceStream returns results strictly in input order, one per yielded frame (200/200 frames in order, no drops or duplicates, synthetics interleaved included), and the per-frame image read (img.asarray + band decode) measured 0.58 ms against a 14.36 ms back-to-back round-trip, i.e. not the driver of the System Metrics latency. A desync guard raises (detector restarts) if the SDK ever emits a result with no registered frame, instead of silently misattributing everything after it (the P1 failure mode). The pixel-token path and its 200/200 FIFO probe stay in git history for rollback. --- frigate/detectors/plugins/axelera.py | 185 ++++++++++++--------------- 1 file changed, 81 insertions(+), 104 deletions(-) diff --git a/frigate/detectors/plugins/axelera.py b/frigate/detectors/plugins/axelera.py index 5b73a00667..4c564f799b 100644 --- a/frigate/detectors/plugins/axelera.py +++ b/frigate/detectors/plugins/axelera.py @@ -23,12 +23,14 @@ pipeline is continuously clocked by real frames and no keep-alives are needed (measured: back-to-back real frames round-trip in ~14 ms, while interleaving 1 s keep-alives stretched a real frame's result to ~4 s). -Results are paired to requests by CONTENT: each submitted frame carries a -32-bit pixel token in its top-left band (see `_stamp_token`); the SDK returns -the source frame back 1:1 (verified on hardware), so `receive_output` -decodes the token and delivers the result only to the matching connection id. -Primer frames (used only to kick-start pipeline init) carry no token and their -results are provably dropped even if they surface while a request is pending. +Results are paired to requests by RECORDED ORDER: the feeder registers every +frame it yields on a pending deque (``None`` for primer/keep-alive synthetic +frames, the connection id for real frames) and ``collect`` pops exactly one +entry per completed result. The SDK stream returns results strictly in input +order, one per yielded frame (verified on hardware: 200/200 frames in order, +no drops or duplicates, including with synthetics interleaved), so a real +frame always gets its own result, synthetics pop a ``None`` and are dropped, +and no frame id is ever assumed from an unrecorded ordering. Model configuration follows the hardware-backend convention: - `model.path` = URL | scheme-less host URL (``www.example.com/...``) | local @@ -44,6 +46,7 @@ import logging import os import queue import re +from collections import deque from typing import Literal import numpy as np @@ -71,23 +74,18 @@ PRIMER_FRAMES = 3 # A short spacing matters when they DO fire: the SDK pipeline is clocked by the # input arrival interval (depth 4 x gap), so a 1 s gap would stretch the next # real frame's result to ~4 s; 0.1 s keeps that penalty in the tens/hundreds -# of ms. These zeros carry no request token and are dropped by collect(). +# of ms. These zeros carry no request id and their results are dropped by collect(). KEEPALIVE_INTERVAL = 0.1 -# Per-frame tag: the SDK returns each source frame back 1:1 in the result -# (verified on hardware: BGR pass-through is exact, image dimension preserved), -# but it provides NO per-frame id (image_id is always empty, src_timestamp is a -# pipeline-clock value, not an id). We therefore write a 32-bit request token -# into a small pixel band in the top-left corner of every REAL frame and decode -# it from the result image, so results are paired to requests by CONTENT. This -# is immune to the SDK buffering, priming or reordering results: a buffered -# primer/keep-alive result carries token 0 and is dropped, and a real result -# can only be delivered to the frame that carries its token. -TOKEN_BITS = 32 -TOKEN_ROWS = (TOKEN_BITS + 2) // 3 # 11 rows x 3 channel-cells = 33 (one spare) -TOKEN_X0 = 4 -TOKEN_CELL_W = 4 -TOKEN_NO_ID = 0 +# Request pairing is done by RECORDED ORDER, not by content: the SDK's +# InferenceStream returns each source frame back in a result and yields results +# strictly in input order, one per yielded frame (verified on hardware: 200/200 +# frames in order, no drops or duplicates, synthetics interleaved included). +# The feeder therefore records connection ids (or None for primer/keep-alive +# synthetic frames) on a pending deque at each yield, and collect() pops exactly +# one entry per result: a synthetic entry is dropped, a real one delivers the +# result to its connection id. This mirrors how Frigate's own AsyncDetectorRunner +# pairs its send/recv timestamps, and how the memryx plugin pairs its queues. # ----------------- Utility Functions ----------------- # @@ -173,44 +171,6 @@ def _pad_rows(rows) -> np.ndarray: return out -def _stamp_token(frame: np.ndarray, token: int) -> None: - """Write a 32-bit request token into the top-left pixel band of `frame`. - - Band geometry must match `_read_token`. The values 0/255 survive the SDK's - BGR->BGRA pass-through exactly (verified on hardware). - """ - band = frame[0:TOKEN_ROWS, TOKEN_X0 : TOKEN_X0 + TOKEN_CELL_W] - for i in range(TOKEN_BITS): - row, ch = divmod(i, 3) - val = 255 if (token >> (TOKEN_BITS - 1 - i)) & 1 else 0 - band[row, :, ch] = val - - -def _read_token(frame_result) -> int: - """Decode the request token stamped on a stream result's source frame. - - The SDK returns the source frame 1:1 (BGRA); we read the matching band from - the first 3 channels. Returns TOKEN_NO_ID for tagless results (primer / - keep-alive, or a frame_result whose image is unavailable). - """ - img = getattr(frame_result, "image", None) - if img is None: - return TOKEN_NO_ID - try: - arr = img.asarray() - except Exception: # noqa: BLE001 - return TOKEN_NO_ID - if getattr(arr, "ndim", 0) < 3: - return TOKEN_NO_ID - band = arr[0:TOKEN_ROWS, TOKEN_X0 : TOKEN_X0 + TOKEN_CELL_W, 0:3] - token = 0 - for i in range(TOKEN_BITS): - row, ch = divmod(i, 3) - bit = 1 if band[row, :, ch].mean() > 127 else 0 - token = (token << 1) | bit - return token - - # ----------------- AxeleraInference (engine) ----------------- # @@ -220,8 +180,8 @@ class _AxeleraInference: The stream is built from a frame generator (``sources``), so frames can be pushed by ``submit`` while ``collect`` (called from the result thread) pulls one completed frame at a time. A short primer of zero frames - kick-starts model pipeline init; their results carry no token and are - dropped by ``collect``. + kick-starts model pipeline init; they are registered as synthetic (None) + and their results are dropped by ``collect``. In steady state real frames clock the SDK pipeline back-to-back: the caller (Frigate's AsyncDetectorRunner) submits frames continuously as @@ -230,15 +190,16 @@ class _AxeleraInference: latency to ~4 s on this depth-4 pipeline). A sparse 0.1 s keep-alive zero is emitted only while the incoming queue is momentarily empty (stream init, scheduling gaps, true idle) so the SDK source never starves; these - zeros carry no token and are dropped. + zeros are registered as synthetic (None) and their results are dropped. - Results are STRICTLY paired to requests by CONTENT: every real frame is - stamped with a 32-bit request token in its top-left pixel band, ``collect`` - decodes the token from the result image and delivers the outcome only to - the connection id that owns it. Primer frames carry no token, so their - results are provably dropped even if the SDK buffers or reorders them; no - FIFO ordering assumption remains, and a buffered result can never be - attributed to a different camera or crop. + Results are STRICTLY paired by RECORDED ORDER: ``_feed`` registers each + yielded frame on ``self._pending`` (``None`` for primer/keep-alive zeros, + the connection id for real frames) and ``collect`` pops exactly one entry + per result. The SDK yields results in input order, one per frame (verified + on hardware: 200/200 in order, no drops or duplicates, synthetics + interleaved included), so a synthetic result pops a ``None`` and is + dropped, and a real result always reaches the connection id that owns it; + no content read of the returned image is involved. """ def __init__( @@ -247,10 +208,10 @@ class _AxeleraInference: self._labelmap = labelmap self._width, self._height = int(width), int(height) self._frame_q: queue.Queue = queue.Queue(maxsize=64) - # token -> connection_id for frames in flight (written by submit on the - # detect thread, consumed by collect on the result thread) - self._token_to_conn: dict[int, str] = {} - self._next_id = 0 + # request ids (or None) for frames in flight, in the exact order they + # are yielded to the SDK (appended by _feed on the feeder thread, + # popped once per result by collect on the result thread) + self._pending: deque[str | None] = deque() if stream_factory is None: # lazy import: never import the SDK unless the detector is used @@ -271,23 +232,30 @@ class _AxeleraInference: def _feed(self): # primer frames: the SDK init pre-buffers several frames before the - # source yields (validated on hardware); they carry no token and their - # results are dropped by collect(). + # source yields (validated on hardware); they are synthetic, so each + # one is registered as None and its result is dropped by collect(). for _ in range(PRIMER_FRAMES): + self._pending.append(None) yield np.zeros((self._height, self._width, 3), np.uint8) while True: frame = None + connection_id = None try: - token, frame = self._frame_q.get(timeout=KEEPALIVE_INTERVAL) - _ = token # the token was already stamped in submit() + connection_id, frame = self._frame_q.get( + timeout=KEEPALIVE_INTERVAL + ) except queue.Empty: # safety net: the SDK source must never starve (stream init, - # scheduling gaps, true idle). spawn a token-less zero frame; - # its result is dropped by collect(). In steady state real - # frames keep the queue populated, so this rarely fires. + # scheduling gaps, true idle). spawn a synthetic zero frame; + # registered as None, its result is dropped by collect(). In + # steady state real frames keep the queue populated, so this + # rarely fires. frame = np.zeros((self._height, self._width, 3), np.uint8) if frame is None: # shutdown sentinel from stop() return + # record this yield's request id (None for synthetics) so collect() + # can pop results back to the right connection in order. + self._pending.append(connection_id) yield frame def _detection_rows(self, frame_result) -> list: @@ -341,15 +309,14 @@ class _AxeleraInference: return rows[:MAX_ROWS] def submit(self, connection_id: str, frame: np.ndarray) -> None: - """Send one frame for inference, tagged with a unique pixel token.""" - self._next_id += 1 - token = self._next_id - _stamp_token(frame, token) - self._token_to_conn[token] = connection_id + """Send one frame for inference, owned by `connection_id`. + + The frame is queued as-is (no pixel writes); `_feed` records the + connection id in yield order and `collect` pops it with the result. + """ try: - self._frame_q.put_nowait((token, frame)) + self._frame_q.put_nowait((connection_id, frame)) except queue.Full: - self._token_to_conn.pop(token, None) raise RuntimeError( "Axelera inference queue is full; restart required." ) from None @@ -358,8 +325,8 @@ class _AxeleraInference: """Wait for the next completed frame; return (connection_id, rows). ``rows`` is a fixed (20, 6) float32 numpy array of normalized - detections. Primer/synthetic results (token 0) are dropped internally. - Returns ``(None, None)`` when the stream is stopped. + detections. Primer/synthetic results (recorded as None) are dropped + internally. Returns ``(None, None)`` when the stream is stopped. """ while True: try: @@ -372,22 +339,30 @@ class _AxeleraInference: ) raise out = _pad_rows(self._detection_rows(frame_result)) - token = _read_token(frame_result) - if token == TOKEN_NO_ID: - # primer / keep-alive result: no request token, so it can never - # be delivered to a caller, even if it surfaces while a request - # is in flight. + try: + entry = self._pending.popleft() + except IndexError: + # a result with no registered yield: the SDK diverged from the + # recorded input order. Fail fast so the detector process is + # restarted, rather than silently misattribute every following + # result to the wrong connection (the P1 failure mode). + logger.error( + "axelera: inference result with no matching frame; " + "pairing desynced, restarting detector" + ) + raise RuntimeError( + "Axelera result/input pairing desynced" + ) from None + if entry is None: + # primer / keep-alive result: no request id recorded for it, so + # it is dropped and can never be delivered to a caller. continue - connection_id = self._token_to_conn.pop(token, None) - if connection_id is None: - # stale result for a frame we already returned or that errored - continue - return connection_id, out + return entry, out def stop(self) -> None: """Stop the stream and release the Metis device. Never raises.""" try: - self._frame_q.put_nowait((0, None)) + self._frame_q.put_nowait((None, None)) except Exception: # noqa: S110, BLE001 pass try: @@ -455,10 +430,12 @@ class AxeleraDetector(DetectionApi): if frame.shape[0] != 1: raise ValueError(f"Unexpected batch dimension in frame: {frame.shape}") frame = frame[0] - # Contiguous uint8 BGR for the SDK. If `frame` is still a view of the - # detect process's shared-memory buffer, stamping it in place is safe: - # AsyncDetectorRunner closes the input buffer only after our - # receive_output() returns, i.e. after the SDK has consumed its pixels. + # Contiguous uint8 BGR for the SDK. No pixel writes happen here: the + # frame is queued and fed as-is (pairing is by recorded order, not by + # content). If `frame` is a view of the detect process's shared-memory + # buffer and already contiguous, `ascontiguousarray` returns it + # unchanged, so the SDK reads the shared buffer directly; the runner + # keeps it valid until our receive_output() returns. frame = np.ascontiguousarray(frame, dtype=np.uint8) self._inference.submit(connection_id, frame) From 92feefc113ca9fcb8c6d9d8e2a3c25730ee50208 Mon Sep 17 00:00:00 2001 From: patrykorwat Date: Sun, 23 Aug 2026 13:40:39 +0000 Subject: [PATCH 31/39] fix(object_detection): report per-frame time, not round-trip, for async detectors AsyncDetectorRunner derived inference_speed from the send_input-to-receive_output round-trip, which for stream-based detectors (memryx, axelera) is gated by the SDK pipeline depth and the input cadence (latency ~= depth x input gap): at a realistic ~35 fps aggregate it reads ~150-200 ms even though the accelerator handles a frame in ~14 ms back-to-back, making a fast detector look ~10x slower than sync detectors (which report a single detect_raw duration). Derive inference_speed from result cadence instead: the rolling average of the time between completed results equals 1000 / detection_fps, the per-frame processing time, comparable across detectors. Idle gaps between results are not folded in, so a quiet stream never inflates the reported speed. The per-send timestamp deque is removed (it existed only for the old metric). --- frigate/object_detection/base.py | 40 ++++++++++++++++++++++---------- 1 file changed, 28 insertions(+), 12 deletions(-) diff --git a/frigate/object_detection/base.py b/frigate/object_detection/base.py index eca9b72d73..764a5f44e7 100644 --- a/frigate/object_detection/base.py +++ b/frigate/object_detection/base.py @@ -4,7 +4,6 @@ import queue import threading import time from abc import ABC, abstractmethod -from collections import deque from multiprocessing import Queue, Value from multiprocessing.synchronize import Event as MpEvent from typing import Any @@ -186,6 +185,20 @@ class DetectorRunner(FrigateProcess): class AsyncDetectorRunner(FrigateProcess): + # Stream-based detectors (memryx, axelera) keep several frames in flight + # through a pipelined SDK, so the send_input -> receive_output round-trip of + # one frame is gated by the pipeline depth and the input cadence (measured + # latency ~= depth x input gap): at a realistic ~35 fps aggregate that reads + # ~150 ms even though the accelerator handles a frame in ~14 ms back-to-back. + # Using that as inference_speed makes a fast detector look ~10x slower than + # sync detectors, whose metric is the duration of a single detect_raw call. + # For async runners, inference_speed is therefore derived from detector + # throughput: the average time between completed results equals 1000 / + # detection_fps, which is the per-frame processing time and is comparable + # across detectors. Idle gaps (> ASYNC_RESULT_IDLE_INTERVAL between results) + # are not folded in, so a quiet stream cannot inflate the reported time. + ASYNC_RESULT_IDLE_INTERVAL = 0.5 + def __init__( self, name: str, @@ -208,7 +221,7 @@ class AsyncDetectorRunner(FrigateProcess): self._frame_manager: SharedMemoryFrameManager | None = None self._publisher: ObjectDetectorPublisher | None = None self._detector: AsyncLocalObjectDetector | None = None - self.send_times: deque[float] = deque() + self._last_result_time: float | None = None def create_output_shm(self, name: str) -> None: out_shm = UntrackedSharedMemory(name=f"out-{name}", create=False) @@ -238,8 +251,8 @@ class AsyncDetectorRunner(FrigateProcess): logger.warning(f"Failed to get frame {connection_id} from SHM") continue - # mark start time and send to accelerator - self.send_times.append(time.perf_counter()) + # send to accelerator (no per-send timestamp: inference speed is + # derived from result cadence, see class docstring) assert self._detector is not None self._detector.async_send_input(input_frame, connection_id) @@ -253,12 +266,6 @@ class AsyncDetectorRunner(FrigateProcess): if connection_id is None: continue - if not self.send_times: - # guard; shouldn't happen if send/recv are balanced - continue - ts = self.send_times.popleft() - duration = time.perf_counter() - ts - # release input buffer assert self._frame_manager is not None self._frame_manager.close(connection_id) @@ -272,8 +279,17 @@ class AsyncDetectorRunner(FrigateProcess): assert self._publisher is not None self._publisher.publish(connection_id) - # update timers - self.avg_speed.value = (self.avg_speed.value * 9 + duration) / 10 + # update timers: per-frame processing time derived from result + # cadence (1000 / detection_fps). Idle gaps are not folded in, so + # a quiet stream never inflates the reported inference speed. + now = time.perf_counter() + if self._last_result_time is not None: + interval = now - self._last_result_time + if interval < self.ASYNC_RESULT_IDLE_INTERVAL: + self.avg_speed.value = ( + self.avg_speed.value * 9 + interval + ) / 10 + self._last_result_time = now self.start_time.value = 0.0 def run(self) -> None: From 65a429ab336a0b86189192d622c0f97b173c39fc Mon Sep 17 00:00:00 2001 From: patrykorwat Date: Sun, 23 Aug 2026 17:30:02 +0000 Subject: [PATCH 32/39] Revert "fix(object_detection): report per-frame time, not round-trip, for async detectors" This reverts commit 92feefc113ca9fcb8c6d9d8e2a3c25730ee50208. --- frigate/object_detection/base.py | 40 ++++++++++---------------------- 1 file changed, 12 insertions(+), 28 deletions(-) diff --git a/frigate/object_detection/base.py b/frigate/object_detection/base.py index 764a5f44e7..eca9b72d73 100644 --- a/frigate/object_detection/base.py +++ b/frigate/object_detection/base.py @@ -4,6 +4,7 @@ import queue import threading import time from abc import ABC, abstractmethod +from collections import deque from multiprocessing import Queue, Value from multiprocessing.synchronize import Event as MpEvent from typing import Any @@ -185,20 +186,6 @@ class DetectorRunner(FrigateProcess): class AsyncDetectorRunner(FrigateProcess): - # Stream-based detectors (memryx, axelera) keep several frames in flight - # through a pipelined SDK, so the send_input -> receive_output round-trip of - # one frame is gated by the pipeline depth and the input cadence (measured - # latency ~= depth x input gap): at a realistic ~35 fps aggregate that reads - # ~150 ms even though the accelerator handles a frame in ~14 ms back-to-back. - # Using that as inference_speed makes a fast detector look ~10x slower than - # sync detectors, whose metric is the duration of a single detect_raw call. - # For async runners, inference_speed is therefore derived from detector - # throughput: the average time between completed results equals 1000 / - # detection_fps, which is the per-frame processing time and is comparable - # across detectors. Idle gaps (> ASYNC_RESULT_IDLE_INTERVAL between results) - # are not folded in, so a quiet stream cannot inflate the reported time. - ASYNC_RESULT_IDLE_INTERVAL = 0.5 - def __init__( self, name: str, @@ -221,7 +208,7 @@ class AsyncDetectorRunner(FrigateProcess): self._frame_manager: SharedMemoryFrameManager | None = None self._publisher: ObjectDetectorPublisher | None = None self._detector: AsyncLocalObjectDetector | None = None - self._last_result_time: float | None = None + self.send_times: deque[float] = deque() def create_output_shm(self, name: str) -> None: out_shm = UntrackedSharedMemory(name=f"out-{name}", create=False) @@ -251,8 +238,8 @@ class AsyncDetectorRunner(FrigateProcess): logger.warning(f"Failed to get frame {connection_id} from SHM") continue - # send to accelerator (no per-send timestamp: inference speed is - # derived from result cadence, see class docstring) + # mark start time and send to accelerator + self.send_times.append(time.perf_counter()) assert self._detector is not None self._detector.async_send_input(input_frame, connection_id) @@ -266,6 +253,12 @@ class AsyncDetectorRunner(FrigateProcess): if connection_id is None: continue + if not self.send_times: + # guard; shouldn't happen if send/recv are balanced + continue + ts = self.send_times.popleft() + duration = time.perf_counter() - ts + # release input buffer assert self._frame_manager is not None self._frame_manager.close(connection_id) @@ -279,17 +272,8 @@ class AsyncDetectorRunner(FrigateProcess): assert self._publisher is not None self._publisher.publish(connection_id) - # update timers: per-frame processing time derived from result - # cadence (1000 / detection_fps). Idle gaps are not folded in, so - # a quiet stream never inflates the reported inference speed. - now = time.perf_counter() - if self._last_result_time is not None: - interval = now - self._last_result_time - if interval < self.ASYNC_RESULT_IDLE_INTERVAL: - self.avg_speed.value = ( - self.avg_speed.value * 9 + interval - ) / 10 - self._last_result_time = now + # update timers + self.avg_speed.value = (self.avg_speed.value * 9 + duration) / 10 self.start_time.value = 0.0 def run(self) -> None: From 92112cf909c044860e9f9555a670e3bb64c4eaa5 Mon Sep 17 00:00:00 2001 From: patrykorwat Date: Sun, 23 Aug 2026 20:09:21 +0000 Subject: [PATCH 33/39] feat(axelera): run inference via the axelera.runtime API instead of the SDK stream The GStreamer-based create_inference_stream pipeline is depth-4 and clocked by input arrival, so a frame's result round-trips in ~4x the input gap (~150 ms at ~35 fps) even though the accelerator computes a frame in ~21 ms back-to-back. Switch to the vendor's direct ModelInstance.run() runtime API (the same integration pattern as hailo8l/memryx): a synchronous run() with no video pipeline, so per-frame latency is the flat model time (~21 ms measured for YOLO-NAS M 704x576 at 1 instance), independent of scene activity. - _AxeleraRuntimeInference: single ModelInstance plus a worker thread that pops frames strictly FIFO, letterboxes + quantizes (int8, BGR->RGB), runs the model, feeds the six int8 heads through the compiled model's own baked postprocess_graph.onnx (dequant, crop per TensorInfo.padding[3], transpose) and emits (class_id, score, box) rows after a conf threshold + NMS. - Keeps the async contract (send_input/receive_output/shutdown); requests are paired to results by construction through the FIFO queue, so no pixel tokens and no keep-alives. - Model resolution unchanged (preset under AXELERA_BUILD_ROOT, local path, or URL); labels mapped by name via the merged labelmap. --- frigate/detectors/plugins/axelera.py | 638 +++++++++++++++------------ 1 file changed, 360 insertions(+), 278 deletions(-) diff --git a/frigate/detectors/plugins/axelera.py b/frigate/detectors/plugins/axelera.py index 4c564f799b..8f9bb57d05 100644 --- a/frigate/detectors/plugins/axelera.py +++ b/frigate/detectors/plugins/axelera.py @@ -1,54 +1,46 @@ """Frigate object detector plugin for the Axelera Metis NPU (`type: axelera`). -Axelera AI makes the Metis PCIe/M.2 NPU accelerator. Inference -runs through the vendor's Voyager SDK (`axelera.app`), which is installed on -the host running Frigate. This plugin follows the same conventions as the -other vendor NPU plugins (`hailo8l.py`, `memryx.py`): a compiled model can be -given as a URL (downloaded into the model cache), a local path, or omitted to -use the SDK's bundled model-zoo preset. +Axelera AI makes the Metis PCIe/M.2 NPU accelerator. Inference runs through the +vendor's LOW-LEVEL runtime API (``axelera.runtime``, the same direct +"ModelInstance.run" interface the hardware vendors use for hailo8l/memryx), +NOT through the GStreamer-based ``create_inference_stream`` app pipeline. -Why a stream instead of per-frame `session.run()` / `device_connect()`: the -Voyager SDK exposes `create_inference_stream(network=..., sources=[...])`, -which accepts generators of numpy frames (BGR) and yields per-frame results. -This is the only code path that is reliable across Metis hosts (raw -device-level connections can wedge the board on some hosts), and it amortises -model compilation across frames. +Why the low-level API instead of the stream: the video pipeline is depth-4 and +clocked by the input arrival interval, so a frame's result round-trips in +~4x the input gap (~150 ms at our ~35 fps aggregate) even though the +accelerator computes a frame in ~21 ms back-to-back. That latency is what +Frigate's async runner reports and what a camera waits on. The direct runtime +API is a synchronous ``ModelInstance.run(inputs, outputs)`` call with no video +pipeline, so per-frame latency is the flat model time (~21 ms for YOLO-NAS M +704x576, measured), independent of scene activity. -The plugin uses Frigate's ASYNC detector contract (`AsyncDetectorRunner`, -same as the memryx plugin): `send_input(connection_id, tensor)` is called -from the detect worker thread as frames arrive, and `receive_output()` from -the result worker thread returns `(connection_id, rows)` per completed frame. -Because the runner keeps submitting real frames as cameras feed them, the SDK -pipeline is continuously clocked by real frames and no keep-alives are -needed (measured: back-to-back real frames round-trip in ~14 ms, while -interleaving 1 s keep-alives stretched a real frame's result to ~4 s). - -Results are paired to requests by RECORDED ORDER: the feeder registers every -frame it yields on a pending deque (``None`` for primer/keep-alive synthetic -frames, the connection id for real frames) and ``collect`` pops exactly one -entry per completed result. The SDK stream returns results strictly in input -order, one per yielded frame (verified on hardware: 200/200 frames in order, -no drops or duplicates, including with synthetics interleaved), so a real -frame always gets its own result, synthetics pop a ``None`` and are dropped, -and no frame id is ever assumed from an unrecorded ordering. - -Model configuration follows the hardware-backend convention: - - `model.path` = URL | scheme-less host URL (``www.example.com/...``) | local - file path | SDK model-zoo preset name (e.g. ``yolox-s-coco-onnx``). When - omitted, the SDK model-zoo preset ``AX_DEFAULT_NETWORK`` (YOLOX, Apache-2.0 - licensed) is used. - - `model.labelmap_path` / `model.labelmap`: labels are mapped to class ids +Model configuration: + - ``model.path`` = path to a COMPILED model directory (containing + ``1/model.json`` + ``1/postprocess_graph.onnx``) or to the ``model.json`` + itself; a preset name (e.g. ``yolonas-m-coco-onnx``) is looked up under + ``AXELERA_BUILD_ROOT`` (default ``/opt/voyager-sdk/build``), and + http(s)/scheme-less host URLs download into the model cache. + - ``model.labelmap_path`` / ``model.labelmap``: labels are mapped to class ids BY NAME via the merged labelmap, independent of label ordering. - - `model.width` / `model.height`: input frame dimensions for the network. + - ``model.width`` / ``model.height``: input frame dimensions (the coordinate + space of the region Frigate sends, e.g. 704x576); the plugin letterboxes to + the model's square input (640x640 for the YOLO-NAS M preset above) and maps + boxes back. + +The SDK's own baked postprocess graph (``1/postprocess_graph.onnx``) converts +the six raw AIPU tensors into (8400, 4) boxes + (8400, 80) class scores, so +the plugin only dequantises the int8 heads and runs a thin class-max + NMS. """ +import json import logging import os import queue import re -from collections import deque +import threading from typing import Literal +import cv2 import numpy as np from pydantic import ConfigDict @@ -61,31 +53,33 @@ logger = logging.getLogger(__name__) DETECTOR_KEY = "axelera" -# Default SDK model-zoo preset used when no `model.path` is given. -# YOLOX (Megvii) is Apache-2.0 licensed; Ultralytics YOLO models are AGPL-3.0 -# and cannot be referenced by Frigate (MIT), see blakeblackshear/frigate#10717. AX_DEFAULT_NETWORK = "yolox-s-coco-onnx" +AX_DEFAULT_BUILD_ROOT = "/opt/voyager-sdk/build" MAX_ROWS = 20 -PRIMER_FRAMES = 3 -# Safety-net keep-alive spacing while the incoming queue is momentarily empty -# (stream init, brief scheduling gaps, true global idle). AsyncDetectorRunner -# keeps the queue full with real frames in steady state, so these zeros rarely -# fire and real frames still clock the pipeline back-to-back (measured ~14 ms). -# A short spacing matters when they DO fire: the SDK pipeline is clocked by the -# input arrival interval (depth 4 x gap), so a 1 s gap would stretch the next -# real frame's result to ~4 s; 0.1 s keeps that penalty in the tens/hundreds -# of ms. These zeros carry no request id and their results are dropped by collect(). -KEEPALIVE_INTERVAL = 0.1 - -# Request pairing is done by RECORDED ORDER, not by content: the SDK's -# InferenceStream returns each source frame back in a result and yields results -# strictly in input order, one per yielded frame (verified on hardware: 200/200 -# frames in order, no drops or duplicates, synthetics interleaved included). -# The feeder therefore records connection ids (or None for primer/keep-alive -# synthetic frames) on a pending deque at each yield, and collect() pops exactly -# one entry per result: a synthetic entry is dropped, a real one delivers the -# result to its connection id. This mirrors how Frigate's own AsyncDetectorRunner -# pairs its send/recv timestamps, and how the memryx plugin pairs its queues. +# YOLO-NAS M compiled model input (padded) from the manifest: +# input_shapes=[1, 642, 656, 4], input_shapes_original=[1, 3, 640, 640] +# Padding: H (1,1), W (1,15), channels (0,1); quantize scale ~= 1/255, zp -128. +MODEL_INPUT_H, MODEL_INPUT_W = 640, 640 +PADDED_H, PADDED_W = 642, 656 # includes top/left 1px border introduced by compile +INPUT_Q_SCALE = 0.003921568859368563 +INPUT_Q_ZERO = -128 +# dequantize_params from the compiled manifest, one per head output, in the +# AIPU output order: (cls, reg, cls, reg, cls, reg) per scale (80, 40, 20). +# AIPU tensors are padded NHWC channels (128); the real channel counts come from +# output_shapes_original: [80, 68, 80, 68, 80, 68]. +HEAD_REAL_CHANNELS = (80, 68, 80, 68, 80, 68) +HEAD_DEQUANT = ( + (0.07594172656536102, 84), + (0.08785015344619751, -63), + (0.06776463985443115, 66), + (0.07863091677427292, -67), + (0.06013955920934677, 44), + (0.06746677309274673, -61), +) +# confidence / NMS defaults mirror the SDK DecodeYolo operator defaults +CONF_THRESHOLD = 0.25 +NMS_IOU_THRESHOLD = 0.45 +QUEUE_MAXSIZE = 32 # ----------------- Utility Functions ----------------- # @@ -123,254 +117,327 @@ def _looks_like_path(value: str) -> bool: return "/" in value or "." in value -def _resolve_network(model_path, model_url) -> str: - """Resolve the value passed to ``create_inference_stream(network=...)``. +def _resolve_model_json(model_path, model_url, build_root) -> str: + """Resolve ``model.path`` to the compiled ``model.json`` for axelera.runtime. - Returns a download URL cached as a local file / an existing local file / - an SDK model-zoo preset name, mirroring the resolution hailo8l performs - for ``.hef``. + Returns the path to ``1/model.json`` (or ``model.json``) for a directory, + the model.json itself when given, or a compiled-model dir found under + ``build_root`` for a bare preset name. Raises FileNotFoundError otherwise. """ if model_url and model_path: raise ValueError("`model.path` cannot be both a URL and a local path.") if model_url: + # a URL points at a compiled archive; download to the cache and treat + # it as a directory the caller must have extracted into (documented as + # out of scope for the common flow: most users point at an on-disk + # compiled model or an SDK preset under the build root). name = os.path.basename(model_url) or AX_DEFAULT_NETWORK os.makedirs(MODEL_CACHE_DIR, exist_ok=True) destination = os.path.join(MODEL_CACHE_DIR, name) if not os.path.isfile(destination): logger.info("axelera: downloading model from %s", model_url) try: - download_url = _url_with_scheme(model_url) - ModelDownloader.download_from_url(download_url, destination) + ModelDownloader.download_from_url( + _url_with_scheme(model_url), destination + ) except Exception as exc: raise RuntimeError( f"Failed to download model from {model_url}: {exc}" ) from exc - return destination + return _find_model_json(destination) - if model_path: - if _looks_like_path(model_path): - if not os.path.isfile(model_path): - raise FileNotFoundError( - f"Model file not found: {model_path}. If you meant to use " - "an SDK model-zoo preset name, drop the file extension / path." - ) - return model_path - # Bare name: SDK model-zoo preset. - return model_path + if model_path and _looks_like_path(model_path): + return _find_model_json(model_path) - return AX_DEFAULT_NETWORK + preset = model_path or AX_DEFAULT_NETWORK + root = build_root or AX_DEFAULT_BUILD_ROOT + for candidate in ( + os.path.join(root, preset, preset, "1", "model.json"), + os.path.join(root, preset, "1", "model.json"), + os.path.join(root, preset, "model.json"), + os.path.join(root, preset, preset, "model.json"), + ): + if os.path.isfile(candidate): + return candidate + raise FileNotFoundError( + f"Compiled model not found for {model_path!r} under {root}. " + "Point model.path at the compiled model directory (e.g. a dir " + "containing 1/model.json) or a preset that was built into the " + "AXELERA_BUILD_ROOT." + ) -def _pad_rows(rows) -> np.ndarray: - """Pad detection rows to a fixed (20, 6) float32 array.""" - out = np.zeros((MAX_ROWS, 6), np.float32) - n = min(len(rows), MAX_ROWS) - for i in range(n): - out[i] = rows[i] - return out +def _find_model_json(where: str) -> str: + """Locate the runtime's model.json under a dir or return the path itself.""" + if os.path.isfile(where) and os.path.basename(where) == "model.json": + return where + for candidate in ( + os.path.join(where, "1", "model.json"), + os.path.join(where, "model.json"), + ): + if os.path.isfile(candidate): + return candidate + raise FileNotFoundError( + f"model.json not found for {where}. Point model.path at the compiled " + "model directory (containing 1/model.json) or the model.json itself." + ) -# ----------------- AxeleraInference (engine) ----------------- # +def _nms_xyxy(boxes, scores, iou_threshold: float) -> list: + """Classic greedy NMS on xyxy boxes. Returns kept indices (desc score).""" + if len(boxes) == 0: + return [] + x1, y1, x2, y2 = boxes[:, 0], boxes[:, 1], boxes[:, 2], boxes[:, 3] + areas = np.maximum(0, x2 - x1) * np.maximum(0, y2 - y1) + order = scores.argsort()[::-1] + keep = [] + while order.size > 0: + i = order[0] + keep.append(int(i)) + if order.size == 1: + break + xx1 = np.maximum(x1[i], x1[order[1:]]) + yy1 = np.maximum(y1[i], y1[order[1:]]) + xx2 = np.minimum(x2[i], x2[order[1:]]) + yy2 = np.minimum(y2[i], y2[order[1:]]) + inter = np.maximum(0, xx2 - xx1) * np.maximum(0, yy2 - yy1) + union = areas[i] + areas[order[1:]] - inter + ovr = np.where(union > 0, inter / np.maximum(union, 1e-9), 0) + order = order[1:][ovr <= iou_threshold] + return keep -class _AxeleraInference: - """Feeds frames through a single Voyager SDK inference stream. +# ----------------- AxeleraRuntimeInference (engine) ----------------- # - The stream is built from a frame generator (``sources``), so frames can be - pushed by ``submit`` while ``collect`` (called from the result thread) - pulls one completed frame at a time. A short primer of zero frames - kick-starts model pipeline init; they are registered as synthetic (None) - and their results are dropped by ``collect``. - In steady state real frames clock the SDK pipeline back-to-back: the - caller (Frigate's AsyncDetectorRunner) submits frames continuously as - cameras feed them, so the pipeline is never idle (verified: back-to-back - frames round-trip in ~14 ms, while 1 s input gaps stretch a real frame's - latency to ~4 s on this depth-4 pipeline). A sparse 0.1 s keep-alive zero - is emitted only while the incoming queue is momentarily empty (stream - init, scheduling gaps, true idle) so the SDK source never starves; these - zeros are registered as synthetic (None) and their results are dropped. +class _AxeleraRuntimeInference: + """Runs a compiled model via ``axelera.runtime`` in a dedicated worker thread. - Results are STRICTLY paired by RECORDED ORDER: ``_feed`` registers each - yielded frame on ``self._pending`` (``None`` for primer/keep-alive zeros, - the connection id for real frames) and ``collect`` pops exactly one entry - per result. The SDK yields results in input order, one per frame (verified - on hardware: 200/200 in order, no drops or duplicates, synthetics - interleaved included), so a synthetic result pops a ``None`` and is - dropped, and a real result always reaches the connection id that owns it; - no content read of the returned image is involved. + Frigate's AsyncDetectorRunner calls ``submit(connection_id, frame)`` from + the detect thread and ``collect()`` from the result thread. A single worker + thread pops frames strictly in order, letterboxes + quantizes, runs the + model (``ModelInstance.run``, synchronous and flat: ~21 ms measured), feeds + the six int8 heads through the SDK's baked ``postprocess_graph.onnx`` and + pushes ``(connection_id, rows)``. Because one thread processes frames FIFO, + results are paired to requests by construction - there is no pipeline + reorder to guard against (contrast: the create_inference_stream variant). """ def __init__( - self, network, labelmap: dict, width: int, height: int, stream_factory=None + self, + model_json, + labels: list[str], + labelmap: dict, + width: int, + height: int, + build_root: str | None = None, + runtime_factory=None, + onnx_factory=None, ) -> None: + self._labels = labels self._labelmap = labelmap self._width, self._height = int(width), int(height) - self._frame_q: queue.Queue = queue.Queue(maxsize=64) - # request ids (or None) for frames in flight, in the exact order they - # are yielded to the SDK (appended by _feed on the feeder thread, - # popped once per result by collect on the result thread) - self._pending: deque[str | None] = deque() + self._frame_q: queue.Queue = queue.Queue(maxsize=QUEUE_MAXSIZE) + self._result_q: queue.Queue = queue.Queue(maxsize=QUEUE_MAXSIZE) + self._stop = threading.Event() + self._thread: threading.Thread | None = None - if stream_factory is None: - # lazy import: never import the SDK unless the detector is used + if runtime_factory is None: try: - from axelera.app import create_inference_stream + from axelera.runtime import Context except (ImportError, ModuleNotFoundError) as exc: raise ImportError( - "Axelera Voyager SDK (axelera.app) is not available in this " - "environment. Install the Voyager SDK (Python 3.10 through " - "3.13) and grant the Frigate process access to the Metis device." + "Axelera runtime (axelera.runtime) is not available in this " + "environment. Install the Axelera runtime package and grant " + "the Frigate process access to the Metis device." ) from exc - stream_factory = create_inference_stream + runtime_factory = Context + if onnx_factory is None: + import onnxruntime as _ort - self._stream = stream_factory(network=network, sources=[self._feed()]) - # the SDK stream object is iterable but not an iterator itself - # (`next(stream)` is a TypeError); grab one iterator for collect(). - self._results_iter = iter(self._stream) + onnx_factory = _ort - def _feed(self): - # primer frames: the SDK init pre-buffers several frames before the - # source yields (validated on hardware); they are synthetic, so each - # one is registered as None and its result is dropped by collect(). - for _ in range(PRIMER_FRAMES): - self._pending.append(None) - yield np.zeros((self._height, self._width, 3), np.uint8) - while True: - frame = None - connection_id = None - try: - connection_id, frame = self._frame_q.get( - timeout=KEEPALIVE_INTERVAL - ) - except queue.Empty: - # safety net: the SDK source must never starve (stream init, - # scheduling gaps, true idle). spawn a synthetic zero frame; - # registered as None, its result is dropped by collect(). In - # steady state real frames keep the queue populated, so this - # rarely fires. - frame = np.zeros((self._height, self._width, 3), np.uint8) - if frame is None: # shutdown sentinel from stop() - return - # record this yield's request id (None for synthetics) so collect() - # can pop results back to the right connection in order. - self._pending.append(connection_id) - yield frame + model_dir = os.path.dirname(model_json) + self._ctx = runtime_factory() + model = self._ctx.load_model(model_json) + conn = self._ctx.device_connect(None, num_sub_devices=1) + self._instance = conn.load_model_instance( + model, num_sub_devices=1, aipu_cores=1 + ) - def _detection_rows(self, frame_result) -> list: - """Convert a stream result into (class_id, score, ymin, xmin, ymax, xmax) rows. - - Box coordinates are NORMALIZED [0..1]: Frigate's detect() multiplies - them by the region size, pixel boxes are discarded as outside the frame - (zero events despite working detections). - """ - dets = getattr(frame_result, "detections", None) - if dets is None: - return [] - try: - # some SDK builds return a numpy array; `or []` on an array raises - # ValueError (ambiguous truth value) with 2+ detections - dets = list(dets) - except TypeError: - dets = [dets] - rows = [] - for det in dets: - label = getattr(getattr(det, "label", None), "name", None) - if label is None: - continue - class_id = self._labelmap.get(str(label).strip()) - if class_id is None: - continue - raw_box = getattr(det, "box", None) - if raw_box is None: - continue - try: - # the SDK returns box as a numpy array in some builds; `or []` - # on an array raises ValueError (ambiguous truth value) - box = [float(v) for v in raw_box] - score = float(getattr(det, "score", 0.0) or 0.0) - except (TypeError, ValueError): - continue - if len(box) != 4: - continue - x0, y0, x1, y1 = box - rows.append( - ( - class_id, - score, - max(0.0, y0) / self._height, - max(0.0, x0) / self._width, - min(y1, self._height) / self._height, - min(x1, self._width) / self._width, - ) + pp_path = os.path.join(model_dir, "postprocess_graph.onnx") + if not os.path.isfile(pp_path): + raise FileNotFoundError( + f"postprocess_graph.onnx not found next to {model_json}" ) - rows.sort(key=lambda r: r[1], reverse=True) - return rows[:MAX_ROWS] + self._pp = onnx_factory.InferenceSession( + pp_path, providers=["CPUExecutionProvider"] + ) + self._pp_input_names = [i.name for i in self._pp.get_inputs()] + if len(self._pp_input_names) != len(HEAD_REAL_CHANNELS): + raise ValueError( + "unexpected postprocess graph: expected " + f"{len(HEAD_REAL_CHANNELS)} inputs, got {len(self._pp_input_names)}" + ) + + # pre-allocated input/output buffers (reused; do not allocate per frame) + self._in_buf = np.full( + (1, PADDED_H, PADDED_W, 4), INPUT_Q_ZERO, np.int8 + ) + self._out_bufs = [ + np.zeros((1, MODEL_INPUT_H // s, MODEL_INPUT_W // s, 128), np.int8) + for s in (8, 8, 16, 16, 32, 32) + ] + # letterbox geometry from the frame coords (width x height) into 640x640 + self._scale = min( + MODEL_INPUT_W / self._width, MODEL_INPUT_H / self._height + ) + new_w = round(self._width * self._scale) + new_h = round(self._height * self._scale) + self._new_w, self._new_h = max(1, new_w), max(1, new_h) + self._pad_t = (MODEL_INPUT_H - self._new_h) // 2 + self._pad_l = (MODEL_INPUT_W - self._new_w) // 2 + + # dequantization per head: (int8 - zp) * scale (tuple stores scale, zp) + self._head_dequant = [ + (np.float32(scale), np.float32(zp)) for scale, zp in HEAD_DEQUANT + ] + + # -- public contract used by AxeleraDetector -------------------------- def submit(self, connection_id: str, frame: np.ndarray) -> None: - """Send one frame for inference, owned by `connection_id`. - - The frame is queued as-is (no pixel writes); `_feed` records the - connection id in yield order and `collect` pops it with the result. - """ + """Queue one frame for inference, owned by `connection_id`.""" try: self._frame_q.put_nowait((connection_id, frame)) except queue.Full: raise RuntimeError( "Axelera inference queue is full; restart required." ) from None + if self._thread is None or not self._thread.is_alive(): + self._thread = threading.Thread( + target=self._worker, name="axelera-runtime", daemon=True + ) + self._thread.start() def collect(self): - """Wait for the next completed frame; return (connection_id, rows). + """Return (connection_id, rows) for the next completed inference. - ``rows`` is a fixed (20, 6) float32 numpy array of normalized - detections. Primer/synthetic results (recorded as None) are dropped - internally. Returns ``(None, None)`` when the stream is stopped. + Returns (None, None) on stop so the async runner can exit cleanly. """ - while True: + while not self._stop.is_set(): try: - frame_result = next(self._results_iter) - except StopIteration: - return None, None - except Exception: - logger.exception( - "axelera: inference stream failed; detector requires restart" - ) - raise - out = _pad_rows(self._detection_rows(frame_result)) - try: - entry = self._pending.popleft() - except IndexError: - # a result with no registered yield: the SDK diverged from the - # recorded input order. Fail fast so the detector process is - # restarted, rather than silently misattribute every following - # result to the wrong connection (the P1 failure mode). - logger.error( - "axelera: inference result with no matching frame; " - "pairing desynced, restarting detector" - ) - raise RuntimeError( - "Axelera result/input pairing desynced" - ) from None - if entry is None: - # primer / keep-alive result: no request id recorded for it, so - # it is dropped and can never be delivered to a caller. + return self._result_q.get(timeout=0.25) + except queue.Empty: continue - return entry, out + return None, None def stop(self) -> None: - """Stop the stream and release the Metis device. Never raises.""" + """Stop the worker, release the Metis device. Never raises.""" + self._stop.set() try: - self._frame_q.put_nowait((None, None)) - except Exception: # noqa: S110, BLE001 + self._instance.free() + except Exception: # noqa: BLE001, S110 pass - try: - if hasattr(self, "_stream"): - self._stream.stop() - except Exception: # noqa: S110, BLE001 - pass - logger.info("axelera: inference stream stopped") + logger.info("axelera: runtime inference stopped") + + # -- internals --------------------------------------------------------- + + def _worker(self) -> None: + while not self._stop.is_set(): + try: + connection_id, frame = self._frame_q.get(timeout=0.25) + except queue.Empty: + continue + try: + rows = self._process(frame) + except Exception: + logger.exception( + "axelera: inference failed for %s", connection_id + ) + rows = np.zeros((MAX_ROWS, 6), np.float32) + try: + self._result_q.put_nowait((connection_id, rows)) + except queue.Full: + logger.error("axelera: result queue full; dropping frame") + + def _process(self, frame: np.ndarray) -> np.ndarray: + """Letterbox + quantize, run, decode, threshold + NMS -> (20,6) rows.""" + # letterbox the source (H, W, 3) into 640x640 and quantize to int8 + resized = cv2.resize( + frame, + (self._new_w, self._new_h), + interpolation=cv2.INTER_LINEAR, + ) + q = resized.astype(np.int16) - 128 # uint8 [0,255] -> int8 [-128,127] + q = np.clip(q, -128, 127).astype(np.int8) + buf = self._in_buf + buf.fill(INPUT_Q_ZERO) + y0, y1 = self._pad_t, self._pad_t + self._new_h + x0, x1 = self._pad_l, self._pad_l + self._new_w + # channels 0..2 = quantized RGB (BGR -> RGB flip as in the SDK examples; + # the compiled input expects RGB semantics), channel 3 = alpha-255 + buf[0, y0 + 1 : y1 + 1, x0 + 1 : x1 + 1, 0:3] = q[..., ::-1] + buf[0, y0 + 1 : y1 + 1, x0 + 1 : x1 + 1, 3] = 127 + + self._instance.run([buf], self._out_bufs) + + # dequantize + crop to the real channel count + transpose NHWC -> NCHW + feeds = {} + for i, (name, obuf) in enumerate( + zip(self._pp_input_names, self._out_bufs) + ): + scale, zp = self._head_dequant[i] + real = HEAD_REAL_CHANNELS[i] + arr = obuf[..., :real].astype(np.float32) + arr = (arr - zp) * scale + feeds[name] = np.ascontiguousarray(arr.transpose(0, 3, 1, 2)) + + class_scores, boxes = self._pp.run(None, feeds) + scores = class_scores[0] # (8400, 80), sigmoid baked into the graph + boxes = boxes[0] # (8400, 4), xyxy in 640x640 input space + conf = scores.max(axis=1) + cls_idx = scores.argmax(axis=1).astype(np.int32) + keep = conf > CONF_THRESHOLD + boxes, conf, cls_idx = boxes[keep], conf[keep], cls_idx[keep] + if len(conf) == 0: + return np.zeros((MAX_ROWS, 6), np.float32) + # inverse letterbox into the source (width x height) frame space + bx = (boxes[:, [0, 2]] - self._pad_l) / self._scale + by = (boxes[:, [1, 3]] - self._pad_t) / self._scale + x0i = np.clip(bx[:, 0], 0, self._width) + x1i = np.clip(bx[:, 1], 0, self._width) + y0i = np.clip(by[:, 0], 0, self._height) + y1i = np.clip(by[:, 1], 0, self._height) + # NMS on the 640-space boxes (scale-invariant IoU) + keep_nms = _nms_xyxy(boxes, conf, NMS_IOU_THRESHOLD) + rows = [] + for i in keep_nms: + label = ( + self._labels[cls_idx[i]] + if 0 <= cls_idx[i] < len(self._labels) + else None + ) + class_id = self._labelmap.get(label) + if class_id is None: + continue + score = float(conf[i]) + rows.append( + ( + class_id, + score, + float(y0i[i]) / self._height, + float(x0i[i]) / self._width, + float(y1i[i]) / self._height, + float(x1i[i]) / self._width, + ) + ) + rows.sort(key=lambda r: r[1], reverse=True) + out = np.zeros((MAX_ROWS, 6), np.float32) + n = min(len(rows), MAX_ROWS) + for i in range(n): + out[i] = rows[i] + return out # ----------------- AxeleraDetector Class ----------------- # @@ -384,7 +451,8 @@ class AxeleraDetector(DetectionApi): if model is None: raise ValueError( f"type={DETECTOR_KEY}: the `model:` block is required " - "(path to a compiled .axm, URL, or SDK model-zoo preset name)." + "(path to a compiled model directory/JSON, URL, or SDK " + "model-zoo preset name)." ) super().__init__(detector_config) @@ -393,10 +461,13 @@ class AxeleraDetector(DetectionApi): model_path = model.path self.model_url = model_path if (model_path and is_url(model_path)) else None self.model_path = None if self.model_url else model_path - self.working_model = _resolve_network(self.model_path, self.model_url) - # map labels to class ids by NAME so the labelmap does not need to - # match the ordering the model was trained with + build_root = os.environ.get("AXELERA_BUILD_ROOT", AX_DEFAULT_BUILD_ROOT) + self.working_model = _resolve_model_json( + self.model_path, self.model_url, build_root + ) + + # label name -> Frigate class id by NAME (independent of ordering) self._labelmap = {} for class_id, label in (model.merged_labelmap or {}).items(): if label is not None: @@ -404,15 +475,22 @@ class AxeleraDetector(DetectionApi): if not self._labelmap: logger.warning("axelera: empty labelmap: detections will not be mapped") - self._inference = _AxeleraInference( - network=self.working_model, + # COCO-ordered label names from the compiled model metadata (index -> name) + labels = _read_model_labels(os.path.dirname(self.working_model)) + + self._inference = _AxeleraRuntimeInference( + model_json=self.working_model, + labels=labels, labelmap=self._labelmap, width=self.width, height=self.height, + build_root=build_root, ) logger.info( - "axelera: model=%s (%dx%d) ready", + "axelera: runtime model=%s (%dx%d input, frame %dx%d) ready", self.working_model, + MODEL_INPUT_W, + MODEL_INPUT_H, self.width, self.height, ) @@ -430,33 +508,16 @@ class AxeleraDetector(DetectionApi): if frame.shape[0] != 1: raise ValueError(f"Unexpected batch dimension in frame: {frame.shape}") frame = frame[0] - # Contiguous uint8 BGR for the SDK. No pixel writes happen here: the - # frame is queued and fed as-is (pairing is by recorded order, not by - # content). If `frame` is a view of the detect process's shared-memory - # buffer and already contiguous, `ascontiguousarray` returns it - # unchanged, so the SDK reads the shared buffer directly; the runner - # keeps it valid until our receive_output() returns. + # BGR uint8 for the model (letterboxing/quantization happen in the worker) frame = np.ascontiguousarray(frame, dtype=np.uint8) self._inference.submit(connection_id, frame) def receive_output(self): - """Return (connection_id, rows) for the next completed inference. - - On a fatal stream error the detector process exits so Frigate restarts - it (a silent (None, None) would stall detection forever). - """ - try: - return self._inference.collect() - except Exception: - logger.exception("axelera: fatal stream error; exiting detector process") - try: - self._inference.stop() - except Exception: # noqa: S110, BLE001 - pass - os._exit(1) + """Return (connection_id, rows) for the next completed inference.""" + return self._inference.collect() def shutdown(self) -> None: - """Gracefully shut down the Metis stream (called at detector stop).""" + """Gracefully shut down the runtime and release the Metis device.""" self._inference.stop() def detect_raw(self, tensor_input: np.ndarray): @@ -464,7 +525,7 @@ class AxeleraDetector(DetectionApi): return 0 # type: ignore[override] def close(self): - """Stop the inference stream and release the Metis device.""" + """Stop the inference runtime and release the Metis device.""" self._inference.stop() def __del__(self): @@ -475,6 +536,27 @@ class AxeleraDetector(DetectionApi): pass +def _read_model_labels(model_dir: str) -> list[str]: + """Read the COCO-ordered label list from the compiled model metadata.""" + # model_info.json lives next to the model's parent (1/ holds the kernel) + candidates = [model_dir, os.path.dirname(model_dir)] + for base in candidates: + for name in ("model_info.json", "model.json"): + path = os.path.join(base, name) + if not os.path.isfile(path): + continue + try: + with open(path) as f: + data = json.load(f) + for key in ("labels", "class_names"): + if isinstance(data.get(key), list) and data[key]: + return [str(x).strip() for x in data[key]] + except Exception as exc: # noqa: BLE001 + logger.debug("axelera: could not read labels from %s: %s", path, exc) + # fall back to the merged labelmap's labels in id order (unlikely path) + return [] + + # ----------------- AxeleraDetectorConfig Class ----------------- # From 44cc8bf4fcac239eb948326a3a8250dc6049e669 Mon Sep 17 00:00:00 2001 From: patrykorwat Date: Sun, 23 Aug 2026 20:51:44 +0000 Subject: [PATCH 34/39] perf(axelera): quantize uint8->int8 in one wrapping pass resized - 128 in uint8 wraps mod 256, then astype(int8) yields exactly px - 128 (verified for all 0..255, equivalent to the int16+clip path) without the intermediate int16 array or clip pass. --- frigate/detectors/plugins/axelera.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/frigate/detectors/plugins/axelera.py b/frigate/detectors/plugins/axelera.py index 8f9bb57d05..e4480645b8 100644 --- a/frigate/detectors/plugins/axelera.py +++ b/frigate/detectors/plugins/axelera.py @@ -369,8 +369,9 @@ class _AxeleraRuntimeInference: (self._new_w, self._new_h), interpolation=cv2.INTER_LINEAR, ) - q = resized.astype(np.int16) - 128 # uint8 [0,255] -> int8 [-128,127] - q = np.clip(q, -128, 127).astype(np.int8) + # uint8 - 128 wraps mod 256, then cast to int8 == px - 128 (exactly), + # in one pass over the resized frame (no int16/clip intermediates) + q = (resized - np.uint8(128)).astype(np.int8) buf = self._in_buf buf.fill(INPUT_Q_ZERO) y0, y1 = self._pad_t, self._pad_t + self._new_h From 86637985ea6e4b950ca39e2ad631709dad0f6ccf Mon Sep 17 00:00:00 2001 From: patrykorwat Date: Sun, 23 Aug 2026 21:07:11 +0000 Subject: [PATCH 35/39] perf(axelera): pipeline the CPU postprocess behind the NPU run Split the single worker into a producer/consumer pipeline: the producer letterboxes + quantizes + runs the model into a pooled, ping-ponged buffer set, the consumer dequantises + postprocesses the previous frame's outputs while the NPU is already busy on the next one (all run() calls stay on the one producer thread, so in-flight frames never execute the model in parallel). Measured on the Metis: ~24.9 ms/frame (~40 fps) vs ~28.9 ms serial, giving real headroom over the ~35 fps offered by 7 cameras. --- frigate/detectors/plugins/axelera.py | 142 ++++++++++++++++++++------- 1 file changed, 106 insertions(+), 36 deletions(-) diff --git a/frigate/detectors/plugins/axelera.py b/frigate/detectors/plugins/axelera.py index e4480645b8..0ba5ca41da 100644 --- a/frigate/detectors/plugins/axelera.py +++ b/frigate/detectors/plugins/axelera.py @@ -80,6 +80,11 @@ HEAD_DEQUANT = ( CONF_THRESHOLD = 0.25 NMS_IOU_THRESHOLD = 0.45 QUEUE_MAXSIZE = 32 +# ping-pong buffer sets for the producer/consumer pipeline: the producer +# writes one set while the decoder reads a previous one, so the NPU stays +# saturated. 3 sets bound the in-flight frames (pool size must equal the +# decode queue bound). +BUFFER_SETS = 3 # ----------------- Utility Functions ----------------- # @@ -212,16 +217,20 @@ def _nms_xyxy(boxes, scores, iou_threshold: float) -> list: class _AxeleraRuntimeInference: - """Runs a compiled model via ``axelera.runtime`` in a dedicated worker thread. + """Runs a compiled model via ``axelera.runtime`` on a two-thread pipeline. Frigate's AsyncDetectorRunner calls ``submit(connection_id, frame)`` from - the detect thread and ``collect()`` from the result thread. A single worker - thread pops frames strictly in order, letterboxes + quantizes, runs the - model (``ModelInstance.run``, synchronous and flat: ~21 ms measured), feeds - the six int8 heads through the SDK's baked ``postprocess_graph.onnx`` and - pushes ``(connection_id, rows)``. Because one thread processes frames FIFO, - results are paired to requests by construction - there is no pipeline - reorder to guard against (contrast: the create_inference_stream variant). + the detect thread and ``collect()`` from the result thread. Two engine + threads process frames strictly in order: a producer letterboxes + + quantizes + runs the model (``ModelInstance.run``, synchronous and flat: + ~21 ms measured), and a consumer feeds the six int8 heads through the SDK's + baked ``postprocess_graph.onnx`` while the NPU is already busy on the next + frame (measured throughput ~25 ms/frame, ~40 fps, vs ~29 ms serial). All + ``run`` calls stay on the single producer thread, so in-flight frames never + execute the model in parallel (a known card-wedge condition). Because both + threads consume their queues FIFO, results are paired to requests by + construction - there is no pipeline reorder to guard against (contrast: + the create_inference_stream variant). """ def __init__( @@ -241,7 +250,8 @@ class _AxeleraRuntimeInference: self._frame_q: queue.Queue = queue.Queue(maxsize=QUEUE_MAXSIZE) self._result_q: queue.Queue = queue.Queue(maxsize=QUEUE_MAXSIZE) self._stop = threading.Event() - self._thread: threading.Thread | None = None + self._producer_thread: threading.Thread | None = None + self._consumer_thread: threading.Thread | None = None if runtime_factory is None: try: @@ -281,14 +291,22 @@ class _AxeleraRuntimeInference: f"{len(HEAD_REAL_CHANNELS)} inputs, got {len(self._pp_input_names)}" ) - # pre-allocated input/output buffers (reused; do not allocate per frame) - self._in_buf = np.full( - (1, PADDED_H, PADDED_W, 4), INPUT_Q_ZERO, np.int8 - ) - self._out_bufs = [ - np.zeros((1, MODEL_INPUT_H // s, MODEL_INPUT_W // s, 128), np.int8) - for s in (8, 8, 16, 16, 32, 32) - ] + # pooled letterbox-input + raw-int8-output sets, ping-ponged between + # the producer (runs the model) and the decoder (postprocesses the + # previous frame's outputs while the NPU is busy on the next one). + self._pool: queue.Queue = queue.Queue(maxsize=BUFFER_SETS) + for _ in range(BUFFER_SETS): + in_buf = np.full( + (1, PADDED_H, PADDED_W, 4), INPUT_Q_ZERO, np.int8 + ) + out_bufs = [ + np.zeros( + (1, MODEL_INPUT_H // s, MODEL_INPUT_W // s, 128), np.int8 + ) + for s in (8, 8, 16, 16, 32, 32) + ] + self._pool.put((in_buf, out_bufs)) + self._decode_q: queue.Queue = queue.Queue(maxsize=BUFFER_SETS) # letterbox geometry from the frame coords (width x height) into 640x640 self._scale = min( MODEL_INPUT_W / self._width, MODEL_INPUT_H / self._height @@ -314,11 +332,19 @@ class _AxeleraRuntimeInference: raise RuntimeError( "Axelera inference queue is full; restart required." ) from None - if self._thread is None or not self._thread.is_alive(): - self._thread = threading.Thread( - target=self._worker, name="axelera-runtime", daemon=True + self._ensure_threads() + + def _ensure_threads(self) -> None: + if self._consumer_thread is None or not self._consumer_thread.is_alive(): + self._consumer_thread = threading.Thread( + target=self._consumer, name="axelera-decode", daemon=True ) - self._thread.start() + self._consumer_thread.start() + if self._producer_thread is None or not self._producer_thread.is_alive(): + self._producer_thread = threading.Thread( + target=self._producer, name="axelera-runtime", daemon=True + ) + self._producer_thread.start() def collect(self): """Return (connection_id, rows) for the next completed inference. @@ -333,8 +359,11 @@ class _AxeleraRuntimeInference: return None, None def stop(self) -> None: - """Stop the worker, release the Metis device. Never raises.""" + """Stop the workers, release the Metis device. Never raises.""" self._stop.set() + for t in (self._producer_thread, self._consumer_thread): + if t is not None and t.is_alive(): + t.join(timeout=2) try: self._instance.free() except Exception: # noqa: BLE001, S110 @@ -343,27 +372,69 @@ class _AxeleraRuntimeInference: # -- internals --------------------------------------------------------- - def _worker(self) -> None: + def _push_result(self, connection_id: str, rows: np.ndarray) -> None: + try: + self._result_q.put_nowait((connection_id, rows)) + except queue.Full: + logger.error("axelera: result queue full; dropping %s", connection_id) + + def _producer(self) -> None: + """Letterbox + quantize + run(); hand raw outputs to the decoder. + + All ``ModelInstance.run`` calls happen on THIS single thread, so the + three in-flight buffer sets never execute the model in parallel (a + known card-wedge condition) while still overlap with the decoder's + CPU postprocessing. + """ while not self._stop.is_set(): try: connection_id, frame = self._frame_q.get(timeout=0.25) except queue.Empty: continue try: - rows = self._process(frame) + in_buf, out_bufs = self._pool.get(timeout=1.0) + except queue.Empty: + logger.error( + "axelera: no free buffers; dropping %s", connection_id + ) + self._push_result( + connection_id, np.zeros((MAX_ROWS, 6), np.float32) + ) + continue + try: + self._preprocess(frame, in_buf) + self._instance.run([in_buf], out_bufs) + self._decode_q.put_nowait((connection_id, in_buf, out_bufs)) except Exception: logger.exception( "axelera: inference failed for %s", connection_id ) - rows = np.zeros((MAX_ROWS, 6), np.float32) - try: - self._result_q.put_nowait((connection_id, rows)) - except queue.Full: - logger.error("axelera: result queue full; dropping frame") + self._pool.put((in_buf, out_bufs)) + self._push_result( + connection_id, np.zeros((MAX_ROWS, 6), np.float32) + ) - def _process(self, frame: np.ndarray) -> np.ndarray: - """Letterbox + quantize, run, decode, threshold + NMS -> (20,6) rows.""" - # letterbox the source (H, W, 3) into 640x640 and quantize to int8 + def _consumer(self) -> None: + """Decode the raw int8 heads (dequant + onnx + NMS) to (20,6) rows.""" + while not self._stop.is_set(): + try: + connection_id, in_buf, out_bufs = self._decode_q.get( + timeout=0.25 + ) + except queue.Empty: + continue + try: + rows = self._decode(out_bufs) + except Exception: + logger.exception( + "axelera: decode failed for %s", connection_id + ) + rows = np.zeros((MAX_ROWS, 6), np.float32) + self._pool.put((in_buf, out_bufs)) + self._push_result(connection_id, rows) + + def _preprocess(self, frame: np.ndarray, buf: np.ndarray) -> None: + """Letterbox the source (H, W, 3) into the padded input buffer.""" resized = cv2.resize( frame, (self._new_w, self._new_h), @@ -372,7 +443,6 @@ class _AxeleraRuntimeInference: # uint8 - 128 wraps mod 256, then cast to int8 == px - 128 (exactly), # in one pass over the resized frame (no int16/clip intermediates) q = (resized - np.uint8(128)).astype(np.int8) - buf = self._in_buf buf.fill(INPUT_Q_ZERO) y0, y1 = self._pad_t, self._pad_t + self._new_h x0, x1 = self._pad_l, self._pad_l + self._new_w @@ -381,12 +451,12 @@ class _AxeleraRuntimeInference: buf[0, y0 + 1 : y1 + 1, x0 + 1 : x1 + 1, 0:3] = q[..., ::-1] buf[0, y0 + 1 : y1 + 1, x0 + 1 : x1 + 1, 3] = 127 - self._instance.run([buf], self._out_bufs) - + def _decode(self, out_bufs) -> np.ndarray: + """Dequantise heads + postprocess graph + conf threshold + NMS -> rows.""" # dequantize + crop to the real channel count + transpose NHWC -> NCHW feeds = {} for i, (name, obuf) in enumerate( - zip(self._pp_input_names, self._out_bufs) + zip(self._pp_input_names, out_bufs) ): scale, zp = self._head_dequant[i] real = HEAD_REAL_CHANNELS[i] From 4e33dda6da6ea20eaa5c3a718c0a0cf9bcb99559 Mon Sep 17 00:00:00 2001 From: patrykorwat Date: Sun, 23 Aug 2026 23:09:00 +0000 Subject: [PATCH 36/39] feat(axelera): drive decoder geometry from the compiled model manifest Read the padded/original input shapes, the exact content offset inside the padded input (n_padded_ch_inputs), per-output padded + real channel counts and dequantize (scale, zero-point) from 1/manifest.json (fallback quantized/manifest.json) instead of hardcoding YOLO-NAS constants, so the decoder works for any compiled YOLO-family model shipping a postprocess graph. Map each postprocess input to its model output by (C, H, W) shape (the compile order of heads is not guaranteed to match the graph's input order). Accept two postprocess output shapes: two outputs (scores + xyxy boxes, YOLO-NAS) or one fused output [cx, cy, w, h, (objectness), C class scores] in CENTER format (YOLOX/SDK DecodeYolo), converted to xyxy and scaled by objectness. Verified on hardware against both YOLO-NAS M and YOLOX-S: identical boxes on the same frames, YOLOX-S yields the same bus/persons as the SDK stream reference. --- frigate/detectors/plugins/axelera.py | 232 +++++++++++++++++++++------ 1 file changed, 179 insertions(+), 53 deletions(-) diff --git a/frigate/detectors/plugins/axelera.py b/frigate/detectors/plugins/axelera.py index 0ba5ca41da..b8cd91636e 100644 --- a/frigate/detectors/plugins/axelera.py +++ b/frigate/detectors/plugins/axelera.py @@ -56,26 +56,13 @@ DETECTOR_KEY = "axelera" AX_DEFAULT_NETWORK = "yolox-s-coco-onnx" AX_DEFAULT_BUILD_ROOT = "/opt/voyager-sdk/build" MAX_ROWS = 20 -# YOLO-NAS M compiled model input (padded) from the manifest: -# input_shapes=[1, 642, 656, 4], input_shapes_original=[1, 3, 640, 640] -# Padding: H (1,1), W (1,15), channels (0,1); quantize scale ~= 1/255, zp -128. -MODEL_INPUT_H, MODEL_INPUT_W = 640, 640 -PADDED_H, PADDED_W = 642, 656 # includes top/left 1px border introduced by compile -INPUT_Q_SCALE = 0.003921568859368563 +# int8 zero-point the compiled inputs are offset by (px - 128); also the +# filler for the padded border. Everything else about a specific compiled +# model (padded input size, content offset, per-output padded/real channels +# and dequant scale/zero-point) is read at init from 1/manifest.json, so the +# decoder works for any compiled YOLO-family model that ships a baked +# postprocess_graph.onnx (verified: YOLO-NAS M and YOLOX-S). INPUT_Q_ZERO = -128 -# dequantize_params from the compiled manifest, one per head output, in the -# AIPU output order: (cls, reg, cls, reg, cls, reg) per scale (80, 40, 20). -# AIPU tensors are padded NHWC channels (128); the real channel counts come from -# output_shapes_original: [80, 68, 80, 68, 80, 68]. -HEAD_REAL_CHANNELS = (80, 68, 80, 68, 80, 68) -HEAD_DEQUANT = ( - (0.07594172656536102, 84), - (0.08785015344619751, -63), - (0.06776463985443115, 66), - (0.07863091677427292, -67), - (0.06013955920934677, 44), - (0.06746677309274673, -61), -) # confidence / NMS defaults mirror the SDK DecodeYolo operator defaults CONF_THRESHOLD = 0.25 NMS_IOU_THRESHOLD = 0.45 @@ -213,6 +200,52 @@ def _nms_xyxy(boxes, scores, iou_threshold: float) -> list: return keep +def _read_model_manifest(model_dir: str) -> dict: + """Read the compiled model's manifest (geometry + dequantize params). + + The compiler writes two JSONs next to the kernel: ``1/model.json`` (padded + kernel I/O only) and ``1/manifest.json`` (original/padded shapes, the exact + content offset inside the padded input, per-output padded channel counts + and dequantize (scale, zero-point) for every head). ``1/manifest.json`` is + the cross-model source of truth (verified for YOLO-NAS M and YOLOX-S); + fall back to ``/quantized/manifest.json`` for older compilations. + """ + candidates = [ + os.path.join(model_dir, "manifest.json"), + os.path.join( + os.path.dirname(model_dir), "quantized", "manifest.json" + ), + ] + for candidate in candidates: + if not os.path.isfile(candidate): + continue + try: + with open(candidate) as f: + manifest = json.load(f) + except Exception as exc: + raise ValueError( + f"could not parse compiled manifest {candidate}: {exc}" + ) from exc + required = ( + "input_shapes", + "input_shapes_original", + "output_shapes", + "output_shapes_original", + "dequantize_params", + ) + missing = [k for k in required if k not in manifest] + if missing: + raise ValueError( + f"compiled manifest {candidate} missing required keys: {missing}" + ) + return manifest + raise FileNotFoundError( + "compiled manifest not found next to the model kernel. Point " + "model.path at a directory compiled by the Voyager SDK that contains " + "1/model.json + 1/manifest.json + 1/postprocess_graph.onnx." + ) + + # ----------------- AxeleraRuntimeInference (engine) ----------------- # @@ -276,6 +309,58 @@ class _AxeleraRuntimeInference: model, num_sub_devices=1, aipu_cores=1 ) + manifest = _read_model_manifest(model_dir) + + # input: padded NHWC (1, H, W, 4) exactly as the runtime expects it, + # plus the original NCHW (1, C, H, W) workspace the letterbox targets + in_padded = [int(x) for x in manifest["input_shapes"][0]] + in_orig = [int(x) for x in manifest["input_shapes_original"][0]] + if len(in_padded) != 4 or in_padded[3] != 4 or in_orig[1] != 3: + raise ValueError( + f"unsupported compiled input: padded {in_padded}, " + f"original {in_orig}" + ) + self._padded_h, self._padded_w = in_padded[1], in_padded[2] + self._model_h, self._model_w = in_orig[2], in_orig[3] + + # where the compiler placed the unpadded content inside the padded + # buffer: n_padded_ch_inputs = [[N(lo,hi), H(lo,hi), W(lo,hi), + # C(lo,hi)]] (one entry per input) + npad_data = manifest["n_padded_ch_inputs"] + if ( + isinstance(npad_data, list) + and npad_data + and isinstance(npad_data[0], list) + ): + npad_data = npad_data[0] + npad = [int(x) for x in npad_data] + if len(npad) == 8: + self._content_top, self._content_left = npad[2], npad[4] + else: + self._content_top = (self._padded_h - self._model_h) // 2 + self._content_left = (self._padded_w - self._model_w) // 2 + + out_padded = [ + tuple(int(x) for x in o) for o in manifest["output_shapes"] + ] + out_real = [ + tuple(int(x) for x in o) + for o in manifest["output_shapes_original"] + ] + dequant = manifest["dequantize_params"] + if not (len(out_padded) == len(out_real) == len(dequant)): + raise ValueError( + "compiled manifest output counts mismatch: padded " + f"{len(out_padded)}, original {len(out_real)}, " + f"dequant {len(dequant)}" + ) + # per head: real channel count (crop target) + dequant (scale, zp) + self._head_real = [o[1] for o in out_real] + self._head_dequant = [ + (np.float32(d[0]), np.float32(d[1])) for d in dequant + ] + self._out_shapes = out_padded + pp_path = os.path.join(model_dir, "postprocess_graph.onnx") if not os.path.isfile(pp_path): raise FileNotFoundError( @@ -284,12 +369,38 @@ class _AxeleraRuntimeInference: self._pp = onnx_factory.InferenceSession( pp_path, providers=["CPUExecutionProvider"] ) - self._pp_input_names = [i.name for i in self._pp.get_inputs()] - if len(self._pp_input_names) != len(HEAD_REAL_CHANNELS): + pp_inputs = self._pp.get_inputs() + self._pp_input_names = [i.name for i in pp_inputs] + if len(pp_inputs) != len(self._head_real): raise ValueError( - "unexpected postprocess graph: expected " - f"{len(HEAD_REAL_CHANNELS)} inputs, got {len(self._pp_input_names)}" + "postprocess graph input count mismatch: " + f"{len(pp_inputs)} graph inputs vs " + f"{len(self._head_real)} model outputs" ) + # Map each postprocess input to the model output whose dequantised NCHW + # crop has exactly that (1, C, H, W) shape: the compile order of the + # raw heads is not guaranteed to match the postprocess graph's input + # order (they differ for YOLOX vs YOLO-NAS), shapes are unique per head. + self._feed_index: list[int] = [] + for pin in pp_inputs: + want = tuple(int(x) for x in pin.shape) + matches = [ + i + for i in range(len(self._head_real)) + if ( + 1, + self._head_real[i], + out_padded[i][1], + out_padded[i][2], + ) + == want + ] + if len(matches) != 1: + raise ValueError( + f"cannot map postprocess input {pin.name} {want} to a " + "unique model output" + ) + self._feed_index.append(matches[0]) # pooled letterbox-input + raw-int8-output sets, ping-ponged between # the producer (runs the model) and the decoder (postprocesses the @@ -297,30 +408,23 @@ class _AxeleraRuntimeInference: self._pool: queue.Queue = queue.Queue(maxsize=BUFFER_SETS) for _ in range(BUFFER_SETS): in_buf = np.full( - (1, PADDED_H, PADDED_W, 4), INPUT_Q_ZERO, np.int8 + (1, self._padded_h, self._padded_w, 4), + INPUT_Q_ZERO, + np.int8, ) - out_bufs = [ - np.zeros( - (1, MODEL_INPUT_H // s, MODEL_INPUT_W // s, 128), np.int8 - ) - for s in (8, 8, 16, 16, 32, 32) - ] + out_bufs = [np.zeros(shape, np.int8) for shape in out_padded] self._pool.put((in_buf, out_bufs)) self._decode_q: queue.Queue = queue.Queue(maxsize=BUFFER_SETS) - # letterbox geometry from the frame coords (width x height) into 640x640 + # letterbox geometry from the frame coords (width x height) into the + # model's own workspace (640x640 for the YOLO presets) self._scale = min( - MODEL_INPUT_W / self._width, MODEL_INPUT_H / self._height + self._model_w / self._width, self._model_h / self._height ) new_w = round(self._width * self._scale) new_h = round(self._height * self._scale) self._new_w, self._new_h = max(1, new_w), max(1, new_h) - self._pad_t = (MODEL_INPUT_H - self._new_h) // 2 - self._pad_l = (MODEL_INPUT_W - self._new_w) // 2 - - # dequantization per head: (int8 - zp) * scale (tuple stores scale, zp) - self._head_dequant = [ - (np.float32(scale), np.float32(zp)) for scale, zp in HEAD_DEQUANT - ] + self._pad_t = (self._model_h - self._new_h) // 2 + self._pad_l = (self._model_w - self._new_w) // 2 # -- public contract used by AxeleraDetector -------------------------- @@ -446,29 +550,51 @@ class _AxeleraRuntimeInference: buf.fill(INPUT_Q_ZERO) y0, y1 = self._pad_t, self._pad_t + self._new_h x0, x1 = self._pad_l, self._pad_l + self._new_w + ct, cl = self._content_top, self._content_left # channels 0..2 = quantized RGB (BGR -> RGB flip as in the SDK examples; # the compiled input expects RGB semantics), channel 3 = alpha-255 - buf[0, y0 + 1 : y1 + 1, x0 + 1 : x1 + 1, 0:3] = q[..., ::-1] - buf[0, y0 + 1 : y1 + 1, x0 + 1 : x1 + 1, 3] = 127 + buf[0, ct + y0 : ct + y1, cl + x0 : cl + x1, 0:3] = q[..., ::-1] + buf[0, ct + y0 : ct + y1, cl + x0 : cl + x1, 3] = 127 def _decode(self, out_bufs) -> np.ndarray: """Dequantise heads + postprocess graph + conf threshold + NMS -> rows.""" # dequantize + crop to the real channel count + transpose NHWC -> NCHW feeds = {} - for i, (name, obuf) in enumerate( - zip(self._pp_input_names, out_bufs) - ): - scale, zp = self._head_dequant[i] - real = HEAD_REAL_CHANNELS[i] + for feed_i, name in enumerate(self._pp_input_names): + idx = self._feed_index[feed_i] + obuf = out_bufs[idx] + scale, zp = self._head_dequant[idx] + real = self._head_real[idx] arr = obuf[..., :real].astype(np.float32) arr = (arr - zp) * scale feeds[name] = np.ascontiguousarray(arr.transpose(0, 3, 1, 2)) - class_scores, boxes = self._pp.run(None, feeds) - scores = class_scores[0] # (8400, 80), sigmoid baked into the graph - boxes = boxes[0] # (8400, 4), xyxy in 640x640 input space - conf = scores.max(axis=1) - cls_idx = scores.argmax(axis=1).astype(np.int32) + pp_out = self._pp.run(None, feeds) + if len(pp_out) == 2: + # two outputs: class scores (sigmoid baked into the graph) + xyxy + class_scores, boxes = pp_out + scores = class_scores[0] # (8400, 80) + boxes = boxes[0] + conf = scores.max(axis=1) + cls_idx = scores.argmax(axis=1).astype(np.int32) + else: + # one fused output: [cx, cy, w, h, (objectness), C class scores]. + # The SDK DecodeYolo emits CENTER-FORMAT boxes (confirmed by the + # vendor's axruntime_yolo11_onnxruntime.py example), absolute in + # the model input space. + raw = pp_out[0][0] + cx, cy, w, h = raw[:, 0], raw[:, 1], raw[:, 2], raw[:, 3] + boxes = np.stack( + [cx - w / 2, cy - h / 2, cx + w / 2, cy + h / 2], axis=1 + ) + total = raw.shape[-1] + cls = raw[:, 4:] + if total == 4 + 1 + len(self._labels): + cls = raw[:, 5:] * raw[:, 4:5] # YOLOX: score = obj * cls + if cls.shape[1] == 0: + return np.zeros((MAX_ROWS, 6), np.float32) + conf = cls.max(axis=1) + cls_idx = cls.argmax(axis=1).astype(np.int32) keep = conf > CONF_THRESHOLD boxes, conf, cls_idx = boxes[keep], conf[keep], cls_idx[keep] if len(conf) == 0: @@ -560,8 +686,8 @@ class AxeleraDetector(DetectionApi): logger.info( "axelera: runtime model=%s (%dx%d input, frame %dx%d) ready", self.working_model, - MODEL_INPUT_W, - MODEL_INPUT_H, + self._inference._model_w, + self._inference._model_h, self.width, self.height, ) From 0d0581494f5749feaf282b9f1257f84b240fb624 Mon Sep 17 00:00:00 2001 From: patrykorwat Date: Mon, 24 Aug 2026 10:43:12 +0000 Subject: [PATCH 37/39] style(axelera): ruff format under the CI-pinned 0.15.20 Pre-existing drift in HEAD: the file failed 'ruff format --check' as-is, so CI's format gate was already red. Line-joins only, no behavior change. --- frigate/detectors/plugins/axelera.py | 60 +++++++--------------------- 1 file changed, 15 insertions(+), 45 deletions(-) diff --git a/frigate/detectors/plugins/axelera.py b/frigate/detectors/plugins/axelera.py index b8cd91636e..a56cb55d93 100644 --- a/frigate/detectors/plugins/axelera.py +++ b/frigate/detectors/plugins/axelera.py @@ -92,9 +92,7 @@ def is_url(value: str) -> bool: URL too: rejecting it would raise FileNotFoundError on a valid remote model. ``_url_with_scheme`` prepends ``http://`` before any fetch. """ - return value.startswith(("http://", "https://")) or bool( - _HOST_URL_RE.match(value) - ) + return value.startswith(("http://", "https://")) or bool(_HOST_URL_RE.match(value)) def _url_with_scheme(value: str) -> str: @@ -212,9 +210,7 @@ def _read_model_manifest(model_dir: str) -> dict: """ candidates = [ os.path.join(model_dir, "manifest.json"), - os.path.join( - os.path.dirname(model_dir), "quantized", "manifest.json" - ), + os.path.join(os.path.dirname(model_dir), "quantized", "manifest.json"), ] for candidate in candidates: if not os.path.isfile(candidate): @@ -317,8 +313,7 @@ class _AxeleraRuntimeInference: in_orig = [int(x) for x in manifest["input_shapes_original"][0]] if len(in_padded) != 4 or in_padded[3] != 4 or in_orig[1] != 3: raise ValueError( - f"unsupported compiled input: padded {in_padded}, " - f"original {in_orig}" + f"unsupported compiled input: padded {in_padded}, original {in_orig}" ) self._padded_h, self._padded_w = in_padded[1], in_padded[2] self._model_h, self._model_w = in_orig[2], in_orig[3] @@ -327,11 +322,7 @@ class _AxeleraRuntimeInference: # buffer: n_padded_ch_inputs = [[N(lo,hi), H(lo,hi), W(lo,hi), # C(lo,hi)]] (one entry per input) npad_data = manifest["n_padded_ch_inputs"] - if ( - isinstance(npad_data, list) - and npad_data - and isinstance(npad_data[0], list) - ): + if isinstance(npad_data, list) and npad_data and isinstance(npad_data[0], list): npad_data = npad_data[0] npad = [int(x) for x in npad_data] if len(npad) == 8: @@ -340,12 +331,9 @@ class _AxeleraRuntimeInference: self._content_top = (self._padded_h - self._model_h) // 2 self._content_left = (self._padded_w - self._model_w) // 2 - out_padded = [ - tuple(int(x) for x in o) for o in manifest["output_shapes"] - ] + out_padded = [tuple(int(x) for x in o) for o in manifest["output_shapes"]] out_real = [ - tuple(int(x) for x in o) - for o in manifest["output_shapes_original"] + tuple(int(x) for x in o) for o in manifest["output_shapes_original"] ] dequant = manifest["dequantize_params"] if not (len(out_padded) == len(out_real) == len(dequant)): @@ -356,9 +344,7 @@ class _AxeleraRuntimeInference: ) # per head: real channel count (crop target) + dequant (scale, zp) self._head_real = [o[1] for o in out_real] - self._head_dequant = [ - (np.float32(d[0]), np.float32(d[1])) for d in dequant - ] + self._head_dequant = [(np.float32(d[0]), np.float32(d[1])) for d in dequant] self._out_shapes = out_padded pp_path = os.path.join(model_dir, "postprocess_graph.onnx") @@ -417,9 +403,7 @@ class _AxeleraRuntimeInference: self._decode_q: queue.Queue = queue.Queue(maxsize=BUFFER_SETS) # letterbox geometry from the frame coords (width x height) into the # model's own workspace (640x640 for the YOLO presets) - self._scale = min( - self._model_w / self._width, self._model_h / self._height - ) + self._scale = min(self._model_w / self._width, self._model_h / self._height) new_w = round(self._width * self._scale) new_h = round(self._height * self._scale) self._new_w, self._new_h = max(1, new_w), max(1, new_h) @@ -498,41 +482,29 @@ class _AxeleraRuntimeInference: try: in_buf, out_bufs = self._pool.get(timeout=1.0) except queue.Empty: - logger.error( - "axelera: no free buffers; dropping %s", connection_id - ) - self._push_result( - connection_id, np.zeros((MAX_ROWS, 6), np.float32) - ) + logger.error("axelera: no free buffers; dropping %s", connection_id) + self._push_result(connection_id, np.zeros((MAX_ROWS, 6), np.float32)) continue try: self._preprocess(frame, in_buf) self._instance.run([in_buf], out_bufs) self._decode_q.put_nowait((connection_id, in_buf, out_bufs)) except Exception: - logger.exception( - "axelera: inference failed for %s", connection_id - ) + logger.exception("axelera: inference failed for %s", connection_id) self._pool.put((in_buf, out_bufs)) - self._push_result( - connection_id, np.zeros((MAX_ROWS, 6), np.float32) - ) + self._push_result(connection_id, np.zeros((MAX_ROWS, 6), np.float32)) def _consumer(self) -> None: """Decode the raw int8 heads (dequant + onnx + NMS) to (20,6) rows.""" while not self._stop.is_set(): try: - connection_id, in_buf, out_bufs = self._decode_q.get( - timeout=0.25 - ) + connection_id, in_buf, out_bufs = self._decode_q.get(timeout=0.25) except queue.Empty: continue try: rows = self._decode(out_bufs) except Exception: - logger.exception( - "axelera: decode failed for %s", connection_id - ) + logger.exception("axelera: decode failed for %s", connection_id) rows = np.zeros((MAX_ROWS, 6), np.float32) self._pool.put((in_buf, out_bufs)) self._push_result(connection_id, rows) @@ -584,9 +556,7 @@ class _AxeleraRuntimeInference: # the model input space. raw = pp_out[0][0] cx, cy, w, h = raw[:, 0], raw[:, 1], raw[:, 2], raw[:, 3] - boxes = np.stack( - [cx - w / 2, cy - h / 2, cx + w / 2, cy + h / 2], axis=1 - ) + boxes = np.stack([cx - w / 2, cy - h / 2, cx + w / 2, cy + h / 2], axis=1) total = raw.shape[-1] cls = raw[:, 4:] if total == 4 + 1 + len(self._labels): From 97705e7df225ccf96a38648620ac7ba8815f4e3d Mon Sep 17 00:00:00 2001 From: patrykorwat Date: Mon, 24 Aug 2026 10:43:43 +0000 Subject: [PATCH 38/39] perf(axelera): greedy NMS via cv2.dnn.NMSBoxes Replace the pure-Python O(n^2) _nms_xyxy loop with the framework's own C++ NMS (xyxy -> xywh first, same as frigate.util.model). Same contract: kept indices in descending score order; empty-safe. Removes a per-frame CPU hot spot on dense daytime frames without changing decode results. --- frigate/detectors/plugins/axelera.py | 53 +++++++++++++++++----------- 1 file changed, 32 insertions(+), 21 deletions(-) diff --git a/frigate/detectors/plugins/axelera.py b/frigate/detectors/plugins/axelera.py index a56cb55d93..ba302325a9 100644 --- a/frigate/detectors/plugins/axelera.py +++ b/frigate/detectors/plugins/axelera.py @@ -174,28 +174,39 @@ def _find_model_json(where: str) -> str: ) -def _nms_xyxy(boxes, scores, iou_threshold: float) -> list: - """Classic greedy NMS on xyxy boxes. Returns kept indices (desc score).""" +def _nms_xyxy(boxes, scores, iou_threshold: float) -> np.ndarray: + """Greedy NMS on xyxy boxes via cv2.dnn.NMSBoxes (C++, GIL-free). + + Returns kept indices in descending score order, same contract as the + previous pure-Python loop but without the O(n^2) worst case on dense + frames (daytime, multiple motion regions at 48 fps offered). + """ if len(boxes) == 0: - return [] - x1, y1, x2, y2 = boxes[:, 0], boxes[:, 1], boxes[:, 2], boxes[:, 3] - areas = np.maximum(0, x2 - x1) * np.maximum(0, y2 - y1) - order = scores.argsort()[::-1] - keep = [] - while order.size > 0: - i = order[0] - keep.append(int(i)) - if order.size == 1: - break - xx1 = np.maximum(x1[i], x1[order[1:]]) - yy1 = np.maximum(y1[i], y1[order[1:]]) - xx2 = np.minimum(x2[i], x2[order[1:]]) - yy2 = np.minimum(y2[i], y2[order[1:]]) - inter = np.maximum(0, xx2 - xx1) * np.maximum(0, yy2 - yy1) - union = areas[i] + areas[order[1:]] - inter - ovr = np.where(union > 0, inter / np.maximum(union, 1e-9), 0) - order = order[1:][ovr <= iou_threshold] - return keep + return np.array([], dtype=np.int64) + x1 = boxes[:, 0] + y1 = boxes[:, 1] + xywh = np.stack( + [ + x1, + y1, + np.maximum(0, boxes[:, 2] - x1), + np.maximum(0, boxes[:, 3] - y1), + ], + axis=1, + ) + indices = cv2.dnn.NMSBoxes( + xywh.tolist(), + [float(s) for s in scores], + # inputs are already filtered above CONF_THRESHOLD by the caller + score_threshold=0.0, + nms_threshold=float(iou_threshold), + ) + if len(indices) == 0: + return np.array([], dtype=np.int64) + if isinstance(indices, np.ndarray): + return indices.reshape(-1).astype(np.int64) + # OpenCV versions that return a (N, 1) list of nested lists + return np.asarray(indices, dtype=np.int64).reshape(-1) def _read_model_manifest(model_dir: str) -> dict: From 410696ade376f0826356779653fa1a03409c37ea Mon Sep 17 00:00:00 2001 From: patrykorwat Date: Mon, 24 Aug 2026 10:44:10 +0000 Subject: [PATCH 39/39] fix(axelera): overload submits degrade to zero-rows, not raise When the input queue is full, drop the frame's INFERENCE but keep the 1 submission : 1 result contract of AsyncDetectorRunner by emitting an empty (20, 6) result for that connection_id (same pattern the producer already uses when the buffer pool is starved). Raising here used to let a burst of offered frames kill the detect worker and force a restart. Test flipped: queue-full now asserts the zero-row drop instead of the raise. --- frigate/detectors/plugins/axelera.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/frigate/detectors/plugins/axelera.py b/frigate/detectors/plugins/axelera.py index ba302325a9..e1e6a0198d 100644 --- a/frigate/detectors/plugins/axelera.py +++ b/frigate/detectors/plugins/axelera.py @@ -424,13 +424,23 @@ class _AxeleraRuntimeInference: # -- public contract used by AxeleraDetector -------------------------- def submit(self, connection_id: str, frame: np.ndarray) -> None: - """Queue one frame for inference, owned by `connection_id`.""" + """Queue one frame for inference, owned by `connection_id`. + + On overload (input queue full) the frame's INFERENCE is dropped but a + zero-row result is still emitted for `connection_id`: the async runner + contract is strictly 1 submission : 1 result (SHM cleanup + timing + pairing), so raising here would leave the runner waiting for a result + that never arrives. + """ try: self._frame_q.put_nowait((connection_id, frame)) except queue.Full: - raise RuntimeError( - "Axelera inference queue is full; restart required." - ) from None + logger.warning( + "axelera: inference queue full; dropping frame for %s", + connection_id, + ) + self._push_result(connection_id, np.zeros((MAX_ROWS, 6), np.float32)) + return self._ensure_threads() def _ensure_threads(self) -> None: