# 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 (`:` + `:sha-` 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