CHANGELOG.md
Rama main · réplica de lectura del repositorio original.
# Changelog
All notable changes to **OpenNVR AI Adapter** (the reference adapter server in
this repo) are documented here. The standalone Python SDK has its own
changelog under [`opennvr_adapter_sdk/CHANGELOG.md`](opennvr_adapter_sdk/CHANGELOG.md).
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
the project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
- **fast-plate-ocr: the whole-graph HTP pin is off by default.** The pin
shipped in #89 compiles the OCR export whole for the Hexagon and, measured
on the Ventuno Q, answers one constant string for every crop while the
CPU path reads them all. Default back to the partially offloaded graph
that reads correctly; `OPENNVR_LPR_PIN_BATCH=1` turns the pin on for
anyone chasing the fp16 numerics.
- **fast-plate-ocr reads on the Hexagon NPU, not beside it.** The OCR
export has a symbolic batch, and the QNN HTP compiles fixed shapes only,
so on the qualcomm flavour it took 2 of the model's 377 nodes and left
the rest to the CPU: 62 ms and 501 ms of CPU per read across 6.5 cores,
measured on a Ventuno Q — enough for two reads in flight to slow the
detector. The service now pins the batch to 1 before the session is
built (one crop per call) and the SDK shim compiles a fixed-shape copy:
every node on the HTP, 10 ms and 1 ms of CPU per read. Through core and
KAI-C the per-call latency is unchanged (the frame decode and the proxy
hop, and on a shared HTP the wait for the device, set it); the saving is
the CPU. The SDK's shape pin now takes a `pathlib.Path` as well as a
`str`: the library hands its model that way, and the pin had been
silently skipped as "in-memory". The first start compiles once; a model
with no on-disk path is left as exported and the log says so.
### Fixed
- **fast-plate-ocr's docs say what it does.** The README, the Dockerfile
and the service docstring described a "YOLOv8 → crop → OCR" chain
and a "pre-cropped plate image"; the adapter has localised the plate
in a vehicle crop itself (open-image-models yolo-v9-t-384) since 0.1.5,
its default floor is 0.45 (not 0.30), and it ships in CPU, -intel and
-qualcomm flavours (the Dockerfile said "CPU only"). The qualcomm
publish job's comment no longer says "-intel suffix, amd64 only".
### Added
- **`text_embed`: sentence embeddings for search's caption arm.** A new
adapter (`adapters/text_embed`, image `text-embed-adapter`, port 9013,
amd64 + arm64) advertising a new task, `text_embed`: a sentence in, a
384-dimensional unit vector out, from `BAAI/bge-small-en-v1.5` (MIT) as
the repo's own ONNX export, run by ONNX Runtime on the CPU with two
threads — a caption costs a few milliseconds. The `embed` adapters keep
the frame arm (a frame and a query in one space); this one exists
because an image-text model's text tower is a poor judge of text
against text: on the Ventuno Q SigLIP2's text-to-text cosines for
relevant and irrelevant caption/query pairs overlap (0.76–0.87 against
0.59–0.75, 2026-11-10), where bge-small puts the matching caption at
0.76–0.82 and the rest at or under 0.63. `text` or `texts` (a batch of
up to 64), `kind` query/document (a model's query instruction, if its
card asks for one, goes on queries only), `dim` + `model` in the answer
so a store never compares vectors from two models; `TEXT_EMBED_MODEL_DIR`
takes any BERT-style export with `cls` or `mean` pooling. Weights baked
in, no egress. OpenNVR's core picks it for caption vectors and the
caption arm's query where registered, and falls back to the embed
adapter's text tower where not.
- **`whisper_qnn`: Whisper-Small on the Qualcomm Hexagon NPU.** A third ASR
adapter, for the camera agent on the Ventuno Q, where speech-to-text on
the CPU (parakeet) took 0.7–2.3 s an utterance and had no confidence
signal to gate noise. It drives Qualcomm AI Hub's precompiled QNN export
of openai/whisper-small (w8a16, QCS8275 / HTP v75; the asset names the
Arduino VENTUNO Q as its reference device) through ONNX Runtime's QNN
plugin provider: numpy log-mel front end checked against HuggingFace's
extractor to 1e-6, encoder → cached greedy decoder with the English
prefix forced, `no_speech_prob` from the first step, the §5.3 result plus
`encoder_ms`/`decoder_ms`. Hexagon only: anywhere else `/health` says
`error` and `/hardware/evaluation` `blocked` with the reason, never a
silent CPU fallback. Assets, tokenizer and mel filters are baked into the
image (`--build-arg WHISPER_QNN_CHIPSET=` for another chip in the release).
Published in the qualcomm flavour as `whisper-qnn-adapter`, port 9020.
Not yet measured on the hardware.
### Added
- **litertlm: an OpenAI-compatible chat endpoint on the NPU engine.**
`POST /v1/chat/completions` on the caption adapter, same engine, same
bearer token: `messages` with system / user / assistant / tool roles,
`tools` as OpenAI function envelopes, `tool_choice`, `temperature`,
`max_tokens`; answers `choices[0].message` with `content` and
structured `tool_calls`, so the camera agent switches to the NPU with
`llm_provider: openai` and the adapter's URL. Measured on a Ventuno Q
with the agent's real prompt and seven tool schemas: 10 of 10 right
tool choices at 0.8–2.0 s a turn (the CPU model: 20–25 s). A prompt
past the bundle's 4,096-token window is `400 context_length_exceeded`,
never truncated; captions and chat share one lock; streaming is not
supported.
- **litertlm: Gemma captions on the Qualcomm Hexagon NPU.** A new
caption/VQA adapter (`adapters/litertlm`, image `litertlm-adapter`, port
9020, arm64, published as `…-qualcomm`) runs Gemma 4 E2B compiled for the
QCS8275 through Google's LiteRT-LM, with the image encoder on the NPU
too. Same tasks and result keys as moondream/ollamavlm (`scene_caption`
→ `caption`, `visual_qa` → `answer`), so it drops into the
`caption-adapter` slot. Measured on a Ventuno Q (2026-11-05): a 124 KB
evidence photo in 2.5 s warm / 4.3 s cold, ~1.2 GiB resident, the
detector sharing the NPU slowed from 38 to 44 ms per inference; the CPU
captioner the box ran before (qwen3-vl 2B via Ollama) took 60–90 s per
photo and outran every deadline. The 2.7 GB model is downloaded once into
`/models` when `OPENNVR_LITERTLM_MODEL_URL` is set; Qualcomm's six AI
Runtime libraries are fetched once at first start by range requests
(pinned SHA-256; not redistributed in the image). Photos are downscaled
to 768 px before the model. Without `/dev/fastrpc-cdsp` the adapter
stays up and `/hardware/evaluation` is `blocked` with the reason.
### Fixed
- **litertlm chat: the agent's second iteration works.** A tool result
goes back to the engine as one `tool` turn of `tool_response` parts
(a plain-text tool turn fails the native send; a Python list fails the
entry point), a conversation that ends with tool results sends those
with the question and the call as history, and past tool calls in
history keep the OpenAI `function` envelope (a flat shape fails the
chat template). Measured on a Ventuno Q: result back in, answer out in
1.07 s. These three fixes were pushed after #83 merged and missed it.
- **QNN on the HTP is refused, out loud, when the DSP node is not in the
container.** The plugin lists an HTP device on any Qualcomm host and
compiles the graph without opening it, so an adapter recreated without the
Qualcomm overlay (no `/dev/fastrpc-cdsp`) built a session that named QNN,
reported `accelerator_in_use: true`, and ran every node on CPU — 468 ms an
inference against 15 on the HTP, health green (Ventuno Q, 2026-11-01).
`plugin_device_missing` now checks the node before the plugin is attached;
without it the adapter runs on the classic CPU list, `/hardware/evaluation`
says `accelerator_in_use: false`, and the log names the missing file and
the overlay that lends it. SDK 1.4.x.
- **ollamavlm: a failed auto-pull is a hard error with its cause.** A pull
that died ("no space left on device") left `/infer` answering
`model_not_pulled, transient, auto-pull is running — retry shortly` for an
hour. It now answers `model_pull_failed`, not transient, with Ollama's own
reason and the fix; `/hardware/evaluation` is `blocked` with the same text
until the model appears (pulled by hand, or the adapter restarted).
### Added
- **InsightFace: a recognition miss carries its evidence.** The
`face_recognition` reply for a face below the threshold now includes
`similarity` (the nearest enrolled face, below the threshold by
definition and deliberately unnamed; `null` when nobody is enrolled)
and `confidence` (the detector's score for the face). A match reply
gains `confidence` too. A caller can now hold its verdict on a
side-on frame at 0.45 instead of calling a known person a stranger;
the Smart Doorbell's per-visit decision reads both. Additive: the
wire shape for a match is unchanged, and `recognized: false` means
what it did.
## [0.1.10] — 2026-10-31
The 0.1.9 images under the number of the OpenNVR release they ship with
(open-nvr 0.1.10 carries one more detect-pipeline change; nothing changed
here). Every adapter at `0.1.10`, the five ONNX adapters also at
`0.1.10-intel` and `0.1.10-qualcomm`. SDK stays 1.4.0.
## [0.1.9] — 2026-10-31
### Added
- **OpenNVR Accelerate: Qualcomm flavour (`-qualcomm`) of the ONNX
adapters.** yolov8, yolo-pose, insightface, fast-plate-ocr,
package-detection and parakeet build with `ACCEL=qualcomm`: plain
onnxruntime plus `onnxruntime-qnn`, the Qualcomm AI Runtime *plugin*
provider (bundles the HTP user-space), arm64 only. The SDK registers it
and routes every session — the adapter's own and its libraries' — to the
Hexagon NPU for `ADAPTER_ONNX_PROVIDERS=QNNExecutionProvider:htp`;
open-nvr's `docker-compose.accel-qualcomm.yml` sets that per adapter and
lends the container the DSP. Parakeet now honours
`ADAPTER_ONNX_PROVIDERS` like the other ONNX adapters (CUDA-when-present
stays the default). Measured on an Arduino Ventuno Q (QCS8275), `/infer`
end-to-end, NPU vs the eight ARM cores: yolov8 59–67 vs 152 ms, pose
48–69 vs 113 ms, package detection 55 vs 103 ms, plate OCR 66–84 vs
272 ms. Parakeet loads on the HTP but its dynamic-length graphs fail at
inference (Expand node), so it is not published in this flavour and
stays on CPU (0.6 s per 3 s of audio).
- **Adapter SDK 1.4.0 on PyPI.** The plugin execution-provider support
above (`register_plugin_providers`, `plugin_session`, the constructor
shim, `.opennvr-accel/` caches, `static_shapes` with `*`) for adapters
built outside this repository; `opennvr_adapter_sdk/CHANGELOG.md`.
- **An accelerator that cannot start never takes an adapter down.**
`build_with_fallback()` in `opennvr_adapter_sdk.providers` builds the
session (or InsightFace / fast-plate-ocr model object) with the requested
providers and, if that raises — device not passed through, driver absent,
OpenVINO plugin failure — logs the error and builds again on CPU. All five
ONNX adapters use it; `/hardware/evaluation` then reports
`accelerator_requested: true`, `accelerator_in_use: false` and
`accelerator_fallback_error` with the reason. A failure with no
accelerator requested still propagates.
- **Static shapes on OpenVINO accelerators.** `ADAPTER_ONNX_PROVIDERS` with an
OpenVINO device other than `CPU` now compiles the graph for the shapes of
the first inference (`disable_dynamic_shapes`) instead of as a dynamic
graph; `ADAPTER_ONNX_DYNAMIC_SHAPES=1` restores the old behaviour. The
NPU plugin cannot run dynamic shapes at all, and an Intel iGPU driver was
seen to hard-reset the host while compiling one. `yolo_pose`, whose
export has dynamic axes, pins its input to `DEFAULT_IMGSZ` on such a device
(`static_shapes` / `wants_static_shapes` in `opennvr_adapter_sdk.providers`)
and reports `input_shape_pinned` in `/hardware/evaluation`; `imgsz` is then
fixed, as it already was for fixed-size exports.
- **OpenNVR Accelerate: hardware flavours for the ONNX adapters.** `yolov8`,
`yolo_pose`, `insightface`, `fast_plate_ocr` and `package_detection` build
from one Dockerfile each with `ACCEL=cpu|intel`; the `-intel` tags carry
`onnxruntime-openvino` plus Intel's OpenCL, graphics-compiler, Level Zero
and NPU user-space, so inference runs on an Intel iGPU or NPU. One SDK
resolver (`opennvr_adapter_sdk.providers`) replaces every hardcoded
`["CUDAExecutionProvider", "CPUExecutionProvider"]`: `ADAPTER_ONNX_PROVIDERS`
takes a `:device` per provider, CPU is always the fallback, ORT's silent
fallback is logged once, and `/hardware/evaluation` reports requested vs
effective providers. InsightFace no longer forces CPU on an accelerated
build (its `ctx_id` now follows the build). The §8 `gpu` permission is
declared truthfully for any accelerator provider, not only CUDA.
## [0.1.8] — 2026-10-16
Better ears and better memory. Speech to text moves to Parakeet (a third
of whisper's errors at a fifth of the time on four cores) and the
embedding adapter moves to SigLIP 2 (coco128 retrieval mAP 0.672
against 0.571); both measured with harnesses that now live beside the
camera agent in open-nvr. No SDK release: `opennvr-adapter-sdk` stays at
1.3.0.
Upgrading: the platform's `.env.example` pins `ADAPTER_TAG=0.1.8` and
`STT_ADAPTER_TAG=0.1.8` from OpenNVR's next release. The `clip` image's
vectors are 768 wide; the platform re-embeds history on its own
(open-nvr#659). Nothing else migrates.
### Changed
- **The embedding adapter is SigLIP 2** (`ViT-B-16-SigLIP2-256`, Google's
`webli` weights, Apache-2.0, 768 dimensions) in place of CLIP
`ViT-B-32`. Measured on coco128 text-to-image retrieval: mAP 0.672
against 0.571, recall@5 1.00 against 0.94, 57 ms an image against 26
on a 4-thread CPU. Vectors are 768 wide and not comparable with the
old 512; the platform re-embeds when the model changes. `CLIP_MODEL`
/ `CLIP_PRETRAINED` still choose any OpenCLIP checkpoint.
### Added
- **`parakeet-adapter`**: speech to text with NVIDIA Parakeet TDT 0.6B
v3 through onnx-asr (ONNX Runtime, int8, CPU first), port 9019. The
whisper adapter's §5.3 result shape and the same `audio_transcription`
task, so the camera agent's `whisper_url` points at either. Measured
on the agent's own phrasings: WER 1.4 % against faster-whisper
base.en's 3.3 %, 0.31 s against 1.42 s an utterance on four CPU
cores. Transcription only (no translation; 25 European languages, no
Hindi — whisper stays for those). Weights baked in, no egress.
## [0.1.7] — 2026-10-16
Detect what you can describe, and a faster detector. The open-vocabulary
adapter is published at last, with its weights baked in so it boots on a
sealed box; the yolov8 adapter learns the name of its weights file so the
platform can move to YOLO26n; and the published open-vocab image answers
`/infer` on transformers 5. No SDK release: `opennvr-adapter-sdk` stays
at 1.3.0.
Upgrading: the platform's `.env.example` pins `ADAPTER_TAG=0.1.7`,
`YOLO_POSE_ADAPTER_TAG=0.1.7` and `OPEN_VOCAB_ADAPTER_TAG=0.1.7` from
OpenNVR's YOLO26n change (open-nvr#654). Nothing migrates.
### Added
- **`YOLOV8_WEIGHTS_FILE`** names the ONNX file the yolov8 adapter loads
from its weights directory (default `yolov8n.onnx`). open-nvr's weights
image moves to YOLO26n — same `(1, 84, 8400)` tensor and decode, 17 %
faster on a 4-thread CPU and +3.6 COCO mAP — and names the file
through this variable, so an older install's `yolov8n.onnx` still
loads. The README recipe exports yolo26n.
- **`open-vocab-adapter` is published** (`adapters/vlm`, port 9012). The
OWL-ViT v2 open-vocabulary detector has been in the tree since June
with its tests, its Dockerfile and its contract conformance, and no
row in the publish matrix, so nobody could pull it. It now builds
for amd64 and arm64 with the rest: `ghcr.io/open-nvr/open-vocab-adapter`.
One task, `open_vocab_detection`: a box and a score for any phrase
the caller writes — "red truck", "forklift", "person wearing a hard
hat" — where YOLOv8 knows only its eighty COCO classes. The ~600 MB
weights are baked into the image and the runtime is pinned offline,
as blip and clip are (#79): no volume, no network, `network_egress:
[]`, boots on a sealed box. CPU inference takes a few seconds a
frame, so this is an on-demand adapter, not a per-frame one.
### Fixed
- **`open-vocab-adapter` answered every `/infer` with a 500** on the first
published image: transformers 5.0 dropped
`Owlv2Processor.post_process_object_detection`
(`post_process_grounded_object_detection` since 4.5x). The stubbed
tests never reached it; found by running the image on a photo. The
README's multipart example also used loose form fields where the SDK
reads a JSON `params` field (contract §3.5). (#61)
## [0.1.6] — 2026-10-11
Better eyes and new senses. The plate reader moves to the v2 model and
reports the plate's region; YOLOv8 draws one box per object; the voice
adapters stop taking the whole box; and three capabilities arrive: parcel
detection, human pose, and CLIP embeddings that let search match words to
frames. `opennvr-adapter-sdk` is published as 1.3.0 alongside this release
(its own changelog), with the `Adapter` front door and the `opennvr-adapter`
CLI.
Upgrading: the platform's `.env.example` pins `ADAPTER_TAG=0.1.6` from
OpenNVR 0.1.6. InsightFace face files migrate on load.
### Changed
- **Plate OCR defaults to `cct-s-v2-global-model`** (was the v1 xs
build). The v2 generation is trained on ~3x the data, recognises the
plate's region for 65+ countries, and both v2 sizes exceed 0.99
region macro-F1 on a 114k-sample held-out split, for ~0.35 ms more
per plate and 3 MB more on disk. A more accurate single look is also
what shortens core's consensus sweep. The result gains an additive
`region: {code, confidence}` (None on v1 models). Override with
`OPENNVR_LPR_MODEL`; the v1 ids still work.
- **Whisper decodes greedily by default** (`beam_size` 1, was 5). Beam 5
costs 30-50% more CPU and buys nothing on a short, VAD-trimmed
utterance — what a voice assistant sends. A request may still ask for
any beam up to 32. (open-nvr #583: 14 s of a 71 s voice turn.)
- **Piper builds a thread-capped ONNX session** — `OPENNVR_PIPER_THREADS`
(default 2) sets intra-op threads; unset by piper, one utterance took
every core (~390% of a 4-core box, 25 s for two sentences on the field
box) and fought the detector and recorder. Uses the
`PiperVoice(session, config)` constructor of piper-tts 1.2 — the version
the image pins (1.3+ also changed `synthesize`; the pin is deliberate);
a piper without it falls back to `PiperVoice.load` and logs once that
the cap is not in effect.
- **A requested Piper voice that is not on disk falls back** to the default
voice, else a complete voice in the same language, else any — said once
per missing name — instead of muting the box; the result's `voice` names
the voice that actually spoke, and the requested voice is picked up the
moment its files land. `OPENNVR_PIPER_VOICE` is validated at start-up
(a path is refused with the variable's name, not as "weights missing").
- **`OPENNVR_PIPER_VOICE`** names the voice used when a request names
none; **`OPENNVR_WHISPER_CPU_THREADS`** caps CTranslate2's threads on
CPU (ignored on CUDA).
### Fixed
- **YOLOv8 boxes are de-duplicated, letterboxed and mapped back
correctly.** The adapter returned every raw anchor over threshold — the
Ultralytics ONNX export emits 8400 of them — so one person came back as
a cluster of overlapping boxes (reported by QA as boxes "not accurate").
Now: per-class non-maximum suppression (`iou_threshold`, default 0.45,
overridable per request like `confidence_threshold`; the sibling
adapters' spellings `iou`, `nms_threshold` and `conf` are accepted
too); the frame is
letterboxed into the model's square instead of squashed 1.78x on a 16:9
camera, which the model was never trained on; a box crossing the frame
edge is cropped rather than slid inward; and the decode is vectorised
(the per-anchor Python loop cost tens of ms per frame on CPU). The
legacy `YOLOv8Adapter.infer_local` path shares the same decode.
- **YOLOv8 and YOLO-pose streaming no longer freeze the process.** Both
§6 WebSocket loops ran decode + inference inline on the event loop, so
every other stream and every HTTP request on the adapter stalled for the
duration of each frame. Inference now runs on a worker thread, as the
SDK's `/infer` route already did.
- **`WHISPER_MODEL_SIZE` is honoured.** It has been in the README, the
Dockerfile and open-nvr's compose stack since the adapter shipped, and
nothing read it: every deployment loaded `base` whatever it set.
### Added
- **`package-detection` adapter** — contract-v1 `package_detection` on
port 9010: parcels on a doorstep, the one class COCO does not have.
A YOLOv8n fine-tuned on two openly licensed doorstep sets (*package at
front door*, 1,293 images, MIT; Roboflow's *Packages*, CC0) on
`onnxruntime`, returning §5.1 `detections` with the label `package`,
normalized boxes un-letterboxed into the source frame, plus `count`
and a `labels` echo. Advertises ONLY `package_detection` — a
one-class model must not satisfy an app whose `object_detection`
means people and vehicles. On demand over HTTP `/infer`, no stream:
its consumer (open-nvr's `package-delivery` app) asks once when
Tier-0 says somebody left the doorstep and on a slow recheck cadence,
and picks this adapter over VQA and over the COCO bag-class stand-in
the moment it registers. Trained 40 epochs at 416 px on 1,242
images; on the held-out test split precision 0.974, recall 0.804,
mAP50 0.914, mAP50-95 0.631 — precise before it is complete, which
is the right way round for a consumer that re-counts on a cadence
and must not invent a delivery. CPU is the design target: 11.7 MB
ONNX, 23–34 ms per frame on two x86 cores. Per-call `conf`, `iou`, `imgsz`,
`max_detections`; multi-class fine-tunes work unchanged through
`PACKAGE_DETECTION_LABELS`. Domain metrics
`adapter_package_frames_total{result=packages|empty}` and
`adapter_packages_total{label}`. Weights are not baked: mounted, or
fetched once from `PACKAGE_DETECTION_MODEL_URL` (the release asset),
with `network_egress` derived from that setting. The training and
export recipe ships as `adapters/package_detection/train_package_model.py`,
including the hook for fine-tuning on a site's own frames.
- **`yolo-pose` adapter** — contract-v1 `pose_estimation` on port 9009,
wrapping a YOLO11n-pose ONNX model on `onnxruntime`. Returns a
COCO-17 skeleton per person (`persons[].keypoints` — 17
`[x, y, confidence]` triples in the fixed COCO order, plus
`keypoint_names` and `frame_dimensions`), over HTTP `/infer` or the
full §6 WebSocket streaming protocol, which is the path a 10 fps
camera uses. Built for apps that reason about body geometry rather
than object location: wand-compliance verification (is the guard's
detector tracking the visitor's arms?), fall detection, queue
orientation.
CPU-first by design — the nano model at a 448 px default input, one
serial session, ~47 ms/frame and ~160 MB RSS measured on an 8-core
x86 CPU. Coordinates are **pixels in the source frame**, not §5.1's
normalized boxes: normalizing a 16:9 frame to a unit square distorts
exactly the limb angles these apps measure. Per-call `conf`, `iou`,
`imgsz` and `max_persons`; per-joint visibility exported as a domain
metric (`adapter_pose_keypoints_visible_total{keypoint=…}`), which
is how a camera that has stopped seeing wrists shows up before the
app goes quiet. Weights are not baked into the image: the ONNX is
exported locally (`python download_models.py --all`) and mounted,
or fetched once from an operator-configured `YOLO_POSE_MODEL_URL` —
and `permissions.network_egress` is derived from that setting, so the
default deployment declares no egress at all and a configured fetch
declares exactly its one host.
- **CLIP embeds words as well as frames** — the `embed` task puts a
camera frame and an operator's query in the same space, which is what
lets OpenNVR's search fuse a similarity ranking with its keyword one: a
search for "truck" finds the visit a captioner described as "a lorry at
the loading bay". A request without a frame is the query half, so the
body shape is TEXT and the service decodes `frame_b64` itself, enforcing
its own size limit on the encoded length.
- **InsightFace keeps several samples per person.** A record holds a list
of embeddings and a match is the best of them, so a porch-light,
30-degree view can still match a straight-on enrolment. `POST
/faces/register` gains `append` (add a sample; name, category and
metadata change only when given); `DELETE /faces/{id}/samples/{i}`
removes one bad capture. Up to 32 samples, oldest dropped first. The file
schema moves to 2 and a schema-1 file loads as one sample, so nobody
re-enrols.
- **`PATCH /faces/{id}`** edits a person's name, category or metadata
without a new photo; absent fields are untouched, metadata merges, and a
key sent as null is removed. The embedding never changes here.
## [0.1.5] — 2026-09-10
The LPR adapter goes from unusable to correct. Everything below is the
same plate-reading path, fixed in four steps — packaging, localization,
confidence and the box's frame of reference. `opennvr-adapter-sdk` is
unchanged at 1.2.0 (already on PyPI); it is versioned by its own API
contract, not by this repo's release number.
### Fixed
- **The shipped LPR image could never read a plate.** `fast-plate-ocr`
was installed without its `[onnx]` extra, so the runtime the model
needs was absent from the published image — every read failed on a
machine that was otherwise correctly configured.
- **LPR confidence was ALWAYS 1.0**, which made every precision floor in
the adapter inert. The output parser expected a `.confidence`
attribute, but `fast-plate-ocr` 1.1.0 (the shipped version) reports
`char_probs` and has no `.confidence` — so `getattr` defaulted every
production read to certainty: garbage like `'1023'` shipped as
accepted, and raising the floor changed nothing. Confidence is now the
**minimum** per-character probability (a read is only as trustworthy as
its shakiest character); measured on the real models, a clean crop
scores 0.997, noise-scene garbage 0.071 and a tiny-crop hallucination
0.384 — min separates them where mean would blur them together. An
unrecognised confidence shape now parses as UNTRUSTED (0.0) rather than
1.0, so a read whose confidence cannot be seen can never outrank the
floor. The unit tests missed this for a release because the stub
returned `(text, conf)` tuples that parse fine — classic
stub-blindness; the regression test now uses the real 1.1.0 shape and
is sabotage-verified against the old behaviour.
### Added
- **Plate LOCALIZATION before OCR** — the missing middle stage.
`fast-plate-ocr` is a pure OCR model expecting a single plate crop, but
the platform's enrichment path sends the whole-VEHICLE best-frame crop;
pointed at an entire vehicle it reads whatever character-like texture it
finds, so nearly everything landed under the floor (silent misses) and
the occasional hallucination landed over it (false reads). The adapter
now localizes the plate with `open-image-models` (same author as the OCR
model, same ONNX/CPU footprint, ~7 MB weights cached the same way) and
OCRs the crop plus a small context margin. Measured end to end on a
synthetic scene: whole-image OCR read `'L330'` at ~15% per-character
confidence; detect → crop → OCR read the exact plate at 99.9%.
Additive and fail-soft — a plate that isn't found falls back to
whole-image OCR at a raised floor (default 0.75), a detector that fails
to load never takes the adapter down (OCR is the core, detection the
enhancer), and `OPENNVR_LPR_DETECTOR=""` selects explicit pure-OCR mode
for callers who assert they send crops. Results gain
`plate_detection {attempted, found, confidence, box, model_id}`.
- **Tiny-plate guard.** A localized plate narrower than
`OPENNVR_LPR_MIN_PLATE_PX` (default 40) returns a clean non-read
(`accepted=false`, `plate_detection.too_small`) instead of OCRing pixel
soup — a distant car should produce nothing, not a wrong plate that
fires a false unknown-vehicle alarm downstream.
- **`plate_detection.image_size`** — the box's frame of reference travels
with the box. Consumers rejecting clipped (partial) reads judge the
plate box against the image it was measured in (open-nvr#378), and
multi-frame OCR sends candidate crops whose size differs from the
visit's evidence frame, so a consumer guessing the denominator measures
in the wrong image. The adapter knows exactly what it OCR'd — the
`[width, height]` of the caller's bytes — so it now says so, and no
consumer has to parse a JPEG header or guess.
### Changed
- **LPR precision floors are env-tunable**, so operators dial precision
against recall without a rebuild: `OPENNVR_LPR_MIN_CONFIDENCE`
(default raised 0.30 → 0.45 under min-character scoring) and
`OPENNVR_LPR_MIN_PLATE_PX` (default 40).
## [0.1.4] — 2026-08-24
### Added
- **`ollamavlm` adapter** — contract-v1 visual_qa / scene_caption served
by proxying to an Ollama endpoint (``OPENNVR_OLLAMA_VLM_URL``, default
``http://host.docker.internal:11434``; model
``OPENNVR_OLLAMA_VLM_MODEL``, default ``moondream``). Drop-in for the
camera-agent's ``CAPTION_ADAPTER`` slot. Motivation: on macOS/Windows
the Docker VM has no GPU access, so in-container VQA is CPU-only —
pointing this adapter at a host-side Ollama gets Metal/GPU inference
while every call stays inside the audited Adapter Contract, and model
management collapses to ``ollama pull``. Lazy-ready by design (an
unreachable endpoint is a transient per-infer error, not a boot
failure), auto-pulls a missing model via Ollama's own API, ~80 MB
image with no weights and no ML deps.
- **Model identity + per-task + domain metrics on `/metrics`** (SDK
1.2.0, wired into every published adapter).
`adapter_model_info{adapter, adapter_version, model, model_version,
framework, fingerprint} 1` exports the served model's identity, so
§11.3 fingerprint drift is visible from one scrape and a latency
regression can be correlated with a weights change.
`adapter_infer_total` / `adapter_infer_latency_seconds` gain a
closed-set `task` label, and adapters register model-specific domain
metrics (detection counts, plate-read confidence, …) through
`load()`-time registration.
### Removed
- **The camera-agent-lite adapters** (`llamacpp`, `smolvlm`,
`whispercpp`, `pipertts`) — companion to open-nvr's camera-agent-lite
removal. llamacpp/smolvlm were amd64-only (blocked upstream on
ggml-org/llama.cpp#19177 — the ARM audience lite targeted could never
run them natively), and whispercpp/pipertts duplicated the `whisper`
and `piper` adapters. STT/TTS stay covered by whisper/piper; the
llama.cpp runtime stays covered through Ollama (`ollamavlm` + the
agent's host-Ollama path). Every remaining published adapter image is
multi-arch.
## [0.1.3] — 2026-08-17
ARM64 release. Adapter images are now `linux/amd64` + `linux/arm64`
manifest lists, fixing the Apple Silicon / Raspberry Pi 5 install
failure where a fresh OpenNVR `docker compose pull` aborted with
``no matching manifest for linux/arm64/v8`` on the first adapter image
it reached. (No 0.1.2 was ever tagged; this release follows 0.1.1.)
### Added
- **linux/arm64 images for 11 of 13 adapters** — `yolov8`, `piper`,
`pipertts`, `whisper`, `whispercpp`, `fast-plate-ocr`, `insightface`,
`blip`, `bytetrack`, `moondream`, and `voice` now publish multi-arch
manifest lists. Every pinned dependency was verified to resolve to a
pre-built ``manylinux*_aarch64`` wheel (no source builds under QEMU).
`llamacpp` and `smolvlm` remain amd64-only: they derive from
``ghcr.io/ggml-org/llama.cpp:server``, which publishes no arm64
manifest upstream (ggml-org/llama.cpp#19177).
- ``platforms`` is now a per-adapter field on the publish matrix, so
arch support is declared next to the adapter it belongs to.
- **SDK release-alignment guard** — a ``v*`` release run now fails if
the SDK version pinned in the tree is not on PyPI (the gap v0.1.1
shipped with: tree said 1.1.0, PyPI had only 1.0.0). Version numbers
stay decoupled (SDK tracks the contract major); the guard aligns the
*release*, not the number. See ``opennvr_adapter_sdk/RELEASING.md``.
### Fixed
- **blip / voice-bundle arm64 builds** — both pinned
``torch==2.9.1+cpu`` from ``download.pytorch.org/whl/cpu``, an index
with no aarch64 ``+cpu`` wheel (pytorch/pytorch#136275). The
Dockerfiles now branch on ``TARGETARCH``: arm64 installs PyPI's plain
``torch==2.9.1`` aarch64 wheel, which is already CPU-only (all CUDA
deps carry ``platform_machine == "x86_64"`` markers). The amd64 path
is unchanged.
### Documentation
- README: supported-architecture matrix and the maintainer rule that a
dependency bump must keep aarch64 wheel coverage, since a regression
only surfaces as an arm64 build timing out under QEMU.
## [0.1.1] — 2026-07-31
Aligned with the v0.1.1 cut of the OpenNVR NVR — this release supplies
the adapter set behind the new `camera-agent-lite` example.
### Added
- **Four new adapters for camera-agent-lite** — `llamacpp` (LLM via
llama.cpp), `whispercpp` (speech-to-text), `pipertts` (text-to-speech),
and `smolvlm` (vision). All four download their models on first boot
via the SDK 1.1.0 `ensure_model_file` helper (a pre-populated weights
volume always wins, so offline installs never trigger egress) and are
in the GHCR publish matrix.
- **AI Adapter Contract v1.1 (optional detector spec)** — adapters can
now declare their accelerator and expected input.
- SDK 1.1.0 — see
[`opennvr_adapter_sdk/CHANGELOG.md`](opennvr_adapter_sdk/CHANGELOG.md).
### Changed
- CI publishes adapter images from every branch push
(`:<branch>` + `:sha-<short>` tags), not just release tags.
### Fixed
- SDK `__version__` now matches `pyproject.toml` (1.1.0), unblocking the
release version check.
### Documentation
- README opening section rewritten for search discoverability.
## [0.1.0] — 2026-07-14
First public release of the reference adapter server. Aligned with the
v0.1.0 cut of the [OpenNVR](https://github.com/open-nvr/open-nvr) NVR. The
`opennvr-adapter-sdk` is structured for PyPI distribution; the upload wires
off the first `sdk-v*` tag, so until then install from source.
### Added
#### SDK & contract
- **`opennvr-adapter-sdk`.** Apache-2.0 SDK that adapter authors install to
write a new contract-compliant detector in ~30 lines. Public API:
`AdapterService` ABC, `AdapterApp` FastAPI builder, `ServiceError`
envelope, `BodyShape` enum, and re-exports of every Pydantic wire type
from the contract. PyPI publish wires off the first `sdk-v*` tag; until
then, install from source via `pip install -e ./opennvr_adapter_sdk`.
- **AI Adapter Contract v1 compliance.** All six mandatory endpoints
(`/health`, `/capabilities`, `/hardware/evaluation`, `/metrics`, `/infer`,
`/infer/stream`), the five result conventions, the WebSocket streaming
protocol, the typed failure envelope, and live model fingerprint drift
detection are exercised by the reference adapters.
- **Conformance test suite** in `conformance/` for verifying any
contract-compliant adapter, in-tree or third-party.
#### Reference adapters
The reference server ships with the following adapters auto-discovered at
startup:
- **YOLOv8** — ONNX object detection, CPU-friendly, no torch dependency.
- **YOLOv11** — PyTorch person counting with ByteTrack.
- **InsightFace** — face detection, recognition, and embedding extraction
(Buffalo-L ArcFace). SDK-based; accepts multipart / base64 frames.
Exposes face-DB CRUD (`/faces/register`, `/faces`, `/faces/{id}`) so
external apps like the Smart Doorbell example can enroll known faces
via REST without a shared volume. Embeddings live in a JSON-file
face DB at `OPENNVR_INSIGHTFACE_FACE_DB`; raw face images are never
persisted.
- **BLIP** — scene captioning via HuggingFace
``Salesforce/blip-image-captioning-base`` (default; ``-large`` available
by env override). SDK-based contract-compliant adapter — accepts JPEG /
PNG / WebP / BMP request bodies and returns ``result.caption``. The
camera-agent example's ``describe_camera`` tool calls this adapter via
KAI-C. CPU-friendly (~2-4s/image on a modern 4-core); GPU-accelerated
when ``torch.cuda.is_available()``. Operators with strict sovereignty
pre-bake the model into the image or mount a populated HF cache.
- **HuggingFace** — cloud inference proxy.
- **Whisper** — speech-to-text via faster-whisper (CPU or GPU).
- **Piper** — text-to-speech via ONNX voices. Default response shape
returns ``audio_uri`` (the internal ``opennvr://audio/...`` scheme,
suitable for shared-volume deployments). Callers without a shared
filesystem — e.g. the camera-agent example's Pipecat TTS service —
send ``inline: true`` in the request body to also receive
``audio_b64`` inline alongside the URI.
- **fast-plate-ocr** — license-plate recognition (Apache-2.0 upstream,
ONNX, CPU-only, plate-specific). New `lpr` install extra; single-
purpose OCR adapter designed to be chained downstream of YOLOv8 by
the `license-plate-recognition` example app on OpenNVR. The adapter
decodes request body bytes (JPEG / PNG / WebP / BMP via OpenCV)
into a numpy array before invoking
`LicensePlateRecognizer.run()`, since the upstream library's 1.x
API accepts paths or ndarrays but not raw bytes. Garbage bytes
surface as a typed `TRANSPORT_ERROR(invalid_image)` envelope with
HTTP 400.
- **Ollama** — local LLM access over HTTP. Supports the OpenAI-style
`tools` / `tool_choice` request fields and normalises Ollama's
native tool-call response into the OpenAI-shaped `message.tool_calls`
array (with synthesised call ids and JSON-stringified arguments)
so Pipecat / OpenAI clients work without translation. Tool support
requires a tool-capable model (Llama 3.1+, Qwen 2.5+, Mistral Nemo,
others — see https://ollama.com/blog/tool-support).
- **ByteTrack** — multi-object tracking post-processor. Takes a frame's
detections in (JSON, `BodyShape.TEXT`), returns the same detections
with persistent `track_id` populated. Stateful per camera (TTL-evicted
to keep memory bounded), tunable per-call via the `tracker_config`
block. Composes with any detection-shaped upstream adapter — chain
through KAI-C the same way the license-plate-recognition example
chains YOLOv8 → fast-plate-ocr. Wraps
[`supervision`](https://github.com/roboflow/supervision)'s
ByteTrack (pinned `>=0.21,<0.30`; 0.30 removes ByteTrack from the
package). The contract's `DetectionItem.track_id` field gains a
populated home with this adapter — alert deduplication, dwell-time
analytics, and track-stable state machines all become available
to downstream examples.
#### Architecture
- **Auto-discovery + lazy loading.** Place an adapter file under
`app/adapters/`, restart, the system finds it automatically. Models load
into memory only when their first request arrives. Idle adapters consume
zero memory.
- **Anti-bloat dependencies.** A minimal core install is roughly nine packages
(~50 MB). Adapter ML libraries live in `[project.optional-dependencies]`
groups so users only install what their deployment uses. The full install
(`uv sync --extra all --extra cpu`) is ~4 GB; most deployments need
`--extra yolo --extra face` (~750 MB).
- **Graceful adapter skipping.** If an adapter's optional dependencies are
missing, `PluginManager` skips it cleanly with a helpful install hint
rather than crashing the server.
- **Pydantic-validated response envelopes** catch invalid data at the
boundary — confidence out of range, missing fields, count mismatches.
- **Async pipeline engine.** Chain multiple tasks sequentially via
`POST /pipeline/run`. Output of step N feeds into step N+1.
- **Process isolation by default.** Each adapter can run as its own container
with its own resource limits. A crash in one adapter doesn't take down the
alerts pipeline.
#### Operations
- **Multi-stage Docker build** with CPU and GPU variants
(`docker build --build-arg USE_GPU=true`).
- Built-in `HEALTHCHECK` against `/health` every 30 seconds.
- **Opt-in `X-API-Key` authentication** via environment variables.
### Security
- **Sovereignty enforcement at the middleware boundary.** KAI-C (in the
`open-nvr` repo) refuses to register adapters that declare
`network_egress` permissions under the default `local_only` policy.
- **Operator-gated permissions.** GPU, host filesystem, network egress, and
shared-memory access are all declared in `/capabilities` and surfaced for
approval rather than implicit.
- **Live fingerprint drift detection** built into the SDK. The middleware
polls `/capabilities` every 60 seconds and emits an audit event when the
declared fingerprint changes.
- **Typed error envelope.** Every failure path raises `ServiceError`, which
the SDK translates to a wire-compatible response with category, code,
transient flag, and `retry_after_ms`.
### Changed
- Migrated FastAPI lifecycle from the deprecated `@app.on_event("startup")`
decorator to the lifespan async context manager pattern.
### License
The reference server is licensed under [GNU AGPL v3.0](LICENSE). The SDK
(`opennvr_adapter_sdk/`) is licensed under **Apache-2.0** so adapter authors
can publish under any compatible license, including proprietary. Model
weights are not AGPL-bound — ship them under whatever licence the model
permits.
---
[Unreleased]: https://github.com/open-nvr/ai-adapter/compare/v0.1.4...HEAD
[0.1.4]: https://github.com/open-nvr/ai-adapter/compare/v0.1.3...v0.1.4
[0.1.3]: https://github.com/open-nvr/ai-adapter/compare/v0.1.1...v0.1.3
[0.1.1]: https://github.com/open-nvr/ai-adapter/compare/v0.1.0...v0.1.1
[0.1.0]: https://github.com/open-nvr/ai-adapter/releases/tag/v0.1.0