Files
parking_solution/wiki/entities/opencv-anpr-service.md
T
julian 4af8b56dda feat(vision): configurability — SetupWizard ANPR toggle, footer health chip, env docs
Make the vision service genuinely configurable (was env-only).

- SetupWizard: an "ANPR" checkbox on the camera form (writes config.anpr; persisted
  only when on; sq+en) — opt-in is no longer raw JSON.
- DeviceMonitor optionally takes the VisionClient and probes /health each tick, emitting
  a "vision" pseudo-device → a Vision chip (ready/degraded/offline + recognizer) in the
  booth footer when VISION_ENABLED, no chip when off. Widened the DeviceStatus category
  union (server + web) + footer maps + devices.catVision. Verified: ready/fast_alpr when
  up, 0 chips when disabled.
- apps/vision/.env.example (Python service) + a VISION_* block in apps/server/.env.example
  (Node side) + a Configuration section in opencv-anpr-service.md covering all four
  layers and the caveats: the two processes share the VISION_ prefix but need SEPARATE
  .env files; bind /analyze to 127.0.0.1; cache model weights at deploy; an unbound anpr
  camera recognizes but every read is refused.

Build + lint green.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-19 16:41:29 +02:00

248 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
type: entity
tags: [parking, vision, anpr, anti-fraud, service]
sources: []
updated: 2026-06-15
status: open
---
# OpenCV ANPR / Vision Service
A **local microservice** that analyses camera snapshots: reads the licence **plate** (ANPR) and
extracts **vehicle attributes** for verification. Built by us (decision 2026-06-15) to do
recognition **host-side on ordinary IP-camera snapshots**, replacing the dedicated edge-AI
[[lpr-camera]]. See decision [[vision-service]].
## Two jobs
1. **Identity (ANPR).** snapshot → `{ plate, confidence, bbox }`. Feeds the existing
`IdentitySource = "lpr"` ([[parking-session]]): the plate is a session/identity key and the way
a plate-bound [[subscription]] is matched.
2. **Verification (anti-fraud witness).** snapshot → vehicle attributes — at minimum
`{ make?, model?, colour, bodyType }`, ideally a compact **visual fingerprint** (an embedding).
This is the answer to **plate-spoofing**: *a fraudster prints a registered/paid plate and drives
in with a different car.* Plate-reading alone can't catch that; comparing the **vehicle** seen at
entry vs. exit (and vs. the [[subscription]]'s known car) can. A plate that entered on a red hatchback
but exits on a black SUV is a **reconciliation anomaly** — exactly the independent-witness role
the [[append-only-event-chain]] flags as the unbuilt gap. See [[reconciliation]].
> The two jobs are why this is worth building rather than just plate-OCR: the service is both an
> **identity source** and an **independent witness**, the visual analogue of the whole system's
> "two records that must reconcile" thesis.
## Architecture — separate localhost process
- A **Python service** (e.g. FastAPI) running **on the appliance**, called by the Node backend over
**localhost HTTP** (`POST /analyze` with the JPEG bytes the camera driver already pulls — see
[[lpr-camera]] "driver/storage boundary": `Snapshot.bytes`). **Source lives in this monorepo at
`apps/vision/`** (Turbo shim; `uv`-managed deps) — co-located source, separate process; see
[[vision-service-packaging]].
- **Fully offline** ([[offline-first]]): all inference is local, no cloud. Model weights ship on the
appliance.
- **Process isolation is deliberate** — it keeps a heavy Python/native/AGPL stack out of the
Node app's process and license surface (see licensing below), and gives it its own failure
domain. If the service is down/slow, the host falls back (transient ticket path) rather than
blocking the lane.
- **Request/response (first cut):**
- `POST /analyze` → `{ plate: {text, confidence, bbox}|null, vehicle: {colour, bodyType, make?, model?, embedding?}, modelVersion, tookMs }`
- `GET /health` → readiness + model versions.
- The Node side wraps it behind an internal interface (like a device adapter) so the recognizer can
be swapped without touching business logic.
## Licensing — scoped AGPL exception (amends the standing rule)
The app is strictly **MIT/Apache/BSD** ([[technology-stack]], [[standing-decisions]]). Accurate
ANPR/vehicle models were *assumed* to be mostly **AGPL** (Ultralytics YOLO detectors, OpenALPR) or
commercial — but the **fast-alpr stack (above) is MIT end-to-end**, so a permissive ANPR baseline now
looks achievable (pending the weight-provenance caveat). The exception below still matters for the
*strongest* models (Ultralytics YOLO) and for the vehicle-verification job. Decision (2026-06-15):
**allow AGPL inside this service only.** It is a **separate process**, not
linked into the app, so its obligations don't reach the Node/React codebase; the app's permissive
guarantee is preserved. Recorded as an explicit exception in [[standing-decisions]] /
[[vision-service]].
- OpenCV core itself is **Apache-2.0** (clean either way).
- AGPL note: if the appliance is ever offered as a network service to third parties, AGPL's
network-use clause could require offering the service's source — relevant only if productised
beyond the on-site appliance; flag at that point.
## Recognizer evaluation — fast-alpr is the leading baseline (2026-06-19)
`YOLO vs OpenCV` is a **category error** — they're different pipeline layers, not competitors. ANPR
is a **pipeline**: (1) plate **detection** (find the box → YOLO-family detector), (2) plate **OCR**
(read the crop → a CRNN/CCT or OCR engine), (3) **glue** (capture/crop/deskew/draw → OpenCV,
Apache-2.0, always present). So the real choice is *which end-to-end recognizer*, and **OpenCV is
used regardless** as the image-handling toolkit.
**Leading option: [fast-alpr](https://github.com/ankandrew/fast-alpr) (v0.4.0, 15 Mar 2026).** A thin
orchestrator over two **swappable** stages, both on **ONNX Runtime** — which matches THIS service's
decided architecture (separate localhost Python process, offline, swappable behind an interface)
almost exactly:
| Stage | Default model | Library | License |
| --- | --- | --- | --- |
| Plate detection | `yolo-v9-t-384-license-plate-end2end` | [open-image-models](https://github.com/ankandrew/open-image-models) | MIT |
| Plate OCR | `cct-xs-v2-global-model` | [fast-plate-ocr](https://github.com/ankandrew/fast-plate-ocr) | MIT |
- **MIT top-to-bottom** (library *and* the published model weights), one maintainer (ankandrew) across
all three repos. **The detector is open-image-models' own YOLOv9 ONNX export — NOT the Ultralytics
AGPL package** — so fast-alpr is a **permissive baseline that may not even need the scoped AGPL
exception** below. ⚠️ **Caveat (verify before relying on it):** a repo's LICENSE covers its *code*;
redistributed model *weights* can carry separate provenance (YOLOv9 upstream is GPL-3.0; Ultralytics
YOLO is AGPL). Confirm the weight training/provenance (model card) before treating "MIT weights" as
settled for compliance — the AGPL-in-service exception is the safety net if it doesn't hold.
- **CPU-only + fully offline.** No runtime ships by default; pick a backend extra — `fast-alpr[onnx]`
(CPU), or `[onnx-gpu]`/`[onnx-openvino]`/`[onnx-directml]`/`[onnx-qnn]` — which maps onto the
"CPU now, small GPU/NPU later" compute question ([[bom]], [[open-questions]]).
- **Albanian/EU plates:** fast-plate-ocr also has a **European model trained on 40+ countries** (newer
than the default global model) — benchmark it against the default for AL accuracy.
- **Modular, no lock-in:** swap either stage via `BaseDetector`/`BaseOCR` (their docs plug in
Tesseract). So fast-alpr is the baseline you keep while replacing one stage if needed.
**Scope: fast-alpr is plate-only — it does Job 1 (ANPR) but NOT Job 2 (vehicle verification).** The
anti-spoofing vehicle-attribute/fingerprint stage is still ours to build — but since fast-alpr already
standardizes on **ONNX Runtime + a YOLO-family detector**, the vehicle stage shares that runtime (the
coherent outcome). Other options, weaker: **OpenALPR** (permissive but largely unmaintained, the old
"permissive-only, weaker" path); **Ultralytics YOLO + PaddleOCR** (most accurate/tunable, but YOLO is
AGPL → needs the in-service exception; most build effort — the "scale" path if fast-alpr's accuracy
disappoints).
**Recommendation:** prototype with **fast-alpr** now (permissive, offline, ONNX, fits the decided
shape); plan a YOLO-detector fine-tune + PaddleOCR only if production accuracy demands it. Choice kept
**open** pending the weight-provenance check (the AL-plate benchmark below is now done).
### Albanian-plate OCR benchmark — keep the default (2026-06-19)
Ran the four candidate `fast-plate-ocr` models through the **full pipeline** (YOLOv9 detect → OCR) on
real AL plate photos (Wikimedia), CPU, scaffolded service:
| OCR model | `AA 558 EE` | `AA 687 KE` | Speed | Note |
| --- | --- | --- | --- | --- |
| **`cct-xs-v2-global-model`** (default) | ✓ 0.999 | ✓ **1.000** | **33–39 ms** | best accuracy + fastest; returns `region=Albania` |
| `cct-s-v2-global-model` | ✓ 0.998 | ✓ 0.999 | 50–65 ms | as accurate, ~50% slower |
| `global-plates-mobile-vit-v2-model` | ✓ 0.955 | ✓ 0.959 | 33–35 ms | fast, lower confidence |
| `european-plates-mobile-vit-v2-model` | ✓ 0.784 | ✓ 0.766 | 38–46 ms | correct but **much lower confidence**; misread a synthetic `AB123FG`→`AB123FO` |
**Finding (overturns the prior assumption):** the **default `cct-xs-v2-global-model` is the best for
Albania** — most accurate AND fastest. The "European (40+ country)" model is *worse* here (~0.77 vs
~1.0 confidence, one synthetic misread), despite the "EU model → better for AL" intuition. So **no
config change**: `VISION_OCR_MODEL` stays `cct-xs-v2-global-model`. Caveat: both test photos were
clean head-on shots; real booth captures (angled, dirty, night, motion-blur) will lower absolute
confidence — the `min_confidence=0.5` floor (→ `low_confidence` → ticket-path fallback) covers that.
The ranking should hold; re-benchmark on real on-site captures once the cameras are installed.
## Anti-fraud / threat-model fit
- **Plate spoofing** (the motivating case): vehicle-attribute / fingerprint mismatch entry↔exit or
vs. a [[subscription]]'s registered car → anomaly. Doesn't *block* on its own (recognition is
probabilistic) — it **flags for [[reconciliation]]** and is captured in the signed record.
- The recognition result and the source image both attach to the signed [[append-only-event-chain]]
entry, so the *evidence* is tamper-evident even though recognition itself is host-side and
fallible.
- Recognition is **advisory, never the sole authority** to open a barrier where money/access is at
stake — confidence thresholds + fallback to ticket/manual; a low-confidence read must not strand a
car ([[fail-state-safety]]).
## Fitness for the entry/exit flows (assessment, 2026-06-19)
Asked after the scaffold + AL benchmark: *is the service worthy to consume in the entry/exit flows?*
The benchmark settles **accuracy** (0.99+ on clean AL plates); "worthy" then turns on **what authority
the read is given** — and the answer splits by role:
- **✅ Worthy NOW — as an ADVISORY identity source (Job 1).** The flows are **already built for a
plate**: a `kind:"plate"` [[device-events|read]] is a first-class identity today — `exit-flow.ts`
signs `source:"lpr"` for it, and `subscription-flow.ts` matches a read plate against
`subscriptionPlates` ([[subscription]] plate binding). So the service just **produces** the plate
string a snapshot → `/analyze` → (if confident) a `DeviceReadEvent{kind:"plate"}` on the existing
read bus. **No flow rewrite — it feeds an existing input.** Concretely worthy for: hands-free
**subscriber** barrier open (plate-bound), and **evidence enrichment** (plate + image on the signed
entry/exit for disputes).
- **⚠️ NOT worthy as the SOLE AUTHORITY to open a TRANSIENT barrier.** Two threat-model reasons: (1) **a
plate is not a payment** — a transient still needs a ticket + `payment`; letting a plate open the
exit would be an unpaid-exit bypass. The `min_confidence` floor → `low_confidence` → ticket/manual
fallback is the guard (already in the scaffold). (2) **Plate-spoofing** (a printed plate on a
different car) — plate-only ANPR *cannot* catch it; that needs **Job 2 (vehicle verification), which
is NOT built**. So plate-as-identity is convenience + evidence, never the lone reason a paid barrier
opens. Consistent with "advisory, never sole authority" above.
**Gaps before it's actually consumed (capable ≠ wired):** (1) ✅ **DONE — the Node→service
`VisionClient`** adapter (`apps/server/src/vision-client.ts`, localhost HTTP to `/analyze` + `/health`)
now exists: **opt-in** (`VISION_ENABLED`, default off), **fail-soft** (any error/timeout/unreachable →
`null`, never throws into the lane → ticket-path fallback), and **re-applies the confidence floor**
(`VISION_MIN_CONFIDENCE`) so a low read is flagged advisory. Constructed in `server.ts`; verified
end-to-end against the live service (Node → `AA558EE` 0.999, `region=Albania`). (2) ✅ **DONE —
trigger wiring (`apps/server/src/vision-reader.ts`).** A **`VisionReader`** polls each **opt-in**
camera (`config.anpr === true`, off by default) every `VISION_POLL_MS`, captures a snapshot →
`VisionClient.analyze` → on a **confident** plate dispatches a `DeviceReadEvent{kind:"plate"}` through
the **same `ReadDispatcher` a physical reader uses** (called directly to capture the outcome, like
`qr-reader.ts`), so the subscription/exit flow consumes it **unchanged**. Guards: low-confidence reads
are dropped (not an identity); a **debounce** (`VISION_DEDUPE_MS`) stops the same plate re-firing while
a car sits in frame; an in-flight guard prevents overlapping recognizes; idle when vision is off or no
camera opts in. Plate stays **advisory + non-blocking** — the exit flow still demands a `payment`, the
subscription flow only matches a **bound** plate, and a refused read never holds a barrier.
**Every confident read is PERSISTED (the ANPR audit trail, so a read is investigable):** the
**snapshot bytes** are stored in `snapshots` keyed by **`identity = plate`** — the SAME identity the
flow signs its anomaly/event with — so `GET /api/snapshots/by-identity/:plate` (the booth event-detail
modal's snapshot strip) shows the car's photo **against that anomaly with no extra wiring**; plus an
unsigned **`device_events{kind:"read"}`** breadcrumb records plate / confidence / region / model /
`snapshotId` / the **dispatch outcome** (`accepted` + `reason`) — a queryable log of every recognition
and whether it matched, separate from the signed ledger. *Verified end-to-end:* a recognized AL plate
with no open session was non-blocking → signed a `exit.refused.noSession` anomaly (identity=plate),
stored a 555 KB snapshot under that plate, recorded the read breadcrumb with
`accepted:false, reason:"…no open session…"`, and `by-identity` returned the image — i.e. the refused
read is fully investigable with its picture. Debounce held a re-seen plate to 1 emit over 7 polls.
(3) **field-accuracy** unknown — re-benchmark/tune
the threshold on real on-site captures (angle/night/dirt). (4) the **weight-provenance** check (open).
**Bottom line: consume it as a gated advisory identity source feeding the existing `kind:"plate"` path
— not as sole authority — and Job 2 is still required for the anti-spoofing value.** The
adapter + the opt-in poll→read trigger are now **both built and verified end-to-end**; remaining is
field tuning (3), the provenance check (4), and Job 2.
## Configuration (2026-06-19)
Turning it on touches **four layers** — two env sets (one per process), per-camera data, and deploy.
The Python service and the Node server **both** read the `VISION_` prefix but are **separate
processes**, so give each its **own `.env`** (`apps/vision/.env` and `apps/server/.env`) — don't merge
them. `.env.example` files document both.
**1. The Python service (`apps/vision/.env`):** `VISION_RECOGNIZER=fast_alpr` (the default `stub`
recognizes nothing), `VISION_HOST`/`VISION_PORT` (prefer **`127.0.0.1`** — only the Node backend calls
`/analyze`, so don't expose it off-host), `VISION_OCR_MODEL`/`VISION_DETECTOR_MODEL` (leave defaults —
the AL-benchmark winners), `VISION_MIN_CONFIDENCE`. Install the models with `uv sync --extra alpr`;
weights download on first run, so **cache them at build/deploy** for the air-gapped appliance.
**2. The Node server (`apps/server/.env`):** `VISION_ENABLED=1` is the **master switch** (off by
default — nothing polls or shows without it); `VISION_URL` must match the service's host:port;
`VISION_TIMEOUT_MS` (slow-call cap so a lane never hangs), `VISION_POLL_MS`, `VISION_DEDUPE_MS`,
`VISION_MIN_CONFIDENCE` (re-applied client-side).
**3. Per-camera opt-in (device config, not env):** a camera does ANPR only when its config has **both**
`anpr: true` **and** a relay binding (`controllerId` + `relay`). The `anpr` flag is a **checkbox on the
camera form in the [[first-run-setup|SetupWizard]]** (built 2026-06-19). Without the binding the
[[entry-exit-points|dispatcher]] refuses every read ("reader not bound to a barrier") — so an
unbound ANPR camera recognizes but every read is rejected (and logged with its snapshot).
**4. Footer health:** when `VISION_ENABLED`, the [[device-status-monitoring|DeviceMonitor]] probes the
service's `/health` each tick and shows a **"Vision" chip** in the booth footer (ready/degraded/offline
+ the recognizer name); no chip when disabled. So the operator sees at a glance whether vision is up.
> **Network isolation** ([[network-isolation]]): cameras live on the isolated device VLAN, so the
> vision service must reach that VLAN to pull snapshots — but its own `/analyze` should bind
> **localhost** (Node is the only caller). Keep the AGPL/heavy stack contained to this process.
## Open
- **Recognizer choice** — **fast-alpr (MIT, YOLOv9+CCT on ONNX) is the baseline, AL-benchmarked**: the
default `cct-xs-v2-global-model` won over the EU model on real AL plates (table above). The one
remaining open item is the **model-weight-provenance check** (the MIT-weights claim). A re-benchmark
on real *on-site* captures (angled/night/dirty) is wanted once cameras are installed. See
[[vision-service]]; AGPL still permitted in-service for the stronger fallback.
- **Vehicle fingerprint**: attribute classifier vs. embedding-similarity; what threshold makes a
mismatch an anomaly without false-positiving on lighting/angle.
- **Compute footprint** on the appliance (CPU-only vs. a small GPU/NPU) — procurement input
([[bom]], [[open-questions]]).
- Per-camera **opt-in** — ✅ **mechanism built**: `config.anpr === true` on a camera enables ANPR
polling (the `VisionReader`). Remaining: expose the toggle in the **SetupWizard** (it's currently
set in raw config) and decide sensible `VISION_POLL_MS`/`VISION_DEDUPE_MS` defaults per site.