mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-18 07:29:45 +00:00
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).
This commit is contained in:
parent
7bf9cb4631
commit
8a3483c7a2
@ -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.
|
||||
|
||||
<ModelConfigDropdown detectorTitle="Axelera Metis" models={objectDetectorsModels.axelera.models} />
|
||||
|
||||
:::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.
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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 = <value>") so
|
||||
# unrelated "= <number>" 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:
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user