refactor(vision): ANPR rides the entry/exit snapshot, drop polling reader

Rework the ANPR trigger to the real design: when a transient presses the button or a
subscriber passes QR/RFID, the entry/exit fires and takes its evidence snapshot — that
is the moment to recognize. snapshotAsync now takes the VisionClient and, after storing
each snapshot from an opt-in (config.anpr) camera, runs ANPR on the SAME image and
records the plate against the SAME session identity (device_events kind:"read" with
plate/confidence/region/snapshotId/source:"entry-exit-snapshot"). One image serves both
evidence and plate extraction; recognition fires only on a real entry/exit — no polling.

The entry/exit/subscription flows take an optional VisionClient and pass it through;
server.ts wires it. Removed the polling VisionReader and VISION_POLL_MS/VISION_DEDUPE_MS.

Advisory + fire-and-forget: a low-confidence/no-plate result records nothing, a vision
failure never delays or changes the open, and the plate does not feed the access
decision. Verified e2e: a simulated entry snapshot on an anpr camera (live fast_alpr)
stored the snapshot for the session and recorded {identity, plate:AA558EE, 0.999,
region:Albania, snapshotId}. Build + lint green.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
2026-06-19 16:53:43 +02:00
parent 4af8b56dda
commit ecaaefd899
9 changed files with 132 additions and 322 deletions
+23 -30
View File
@@ -171,32 +171,26 @@ now exists: **opt-in** (`VISION_ENABLED`, default off), **fail-soft** (any error
`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.
trigger: ANPR rides the entry/exit SNAPSHOT (`snapshot.ts`).** The real-world trigger is a **transient
button-press or a subscriber QR/RFID read** — which already fires the entry/exit and its evidence
snapshot. That is exactly the moment to recognize: `snapshotAsync` now takes the `VisionClient`, and
after storing each snapshot from an **opt-in** camera (`config.anpr === true`), it runs ANPR off the
**SAME image** and **records the plate against the SAME session `identity`** — an unsigned
`device_events{kind:"read"}` with plate / confidence / region / model / `snapshotId` /
`source:"entry-exit-snapshot"`. So you can later answer *"session X entered on plate AA558EE"*, with the
evidence image linked by `snapshotId`. **No polling — recognition fires only on a real entry/exit**,
one image serving both evidence and plate extraction. *(Superseded the earlier polling `VisionReader`,
now removed — `VISION_POLL_MS`/`VISION_DEDUPE_MS` gone.)* The flows pass the client (entry/exit/
subscription constructors). It is **advisory + fire-and-forget**: a low-confidence/no-plate result
records nothing, a vision failure never delays or changes the open, and the plate does **not** feed the
access decision (the flow already decided). *Verified end-to-end:* a simulated entry snapshot on an
`anpr` camera → stored the snapshot for the session AND recorded `{identity:"TICKET-…", plate:"AA558EE",
confidence:0.999, region:"Albania", snapshotId:…}`.
(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
**Bottom line: consume it as a gated advisory identity record off the entry/exit snapshot — not as sole
authority — and Job 2 is still required for the anti-spoofing value.** The
adapter + the snapshot-triggered ANPR 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)
@@ -213,9 +207,9 @@ the AL-benchmark winners), `VISION_MIN_CONFIDENCE`. Install the models with `uv
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).
default — nothing runs or shows without it); `VISION_URL` must match the service's host:port;
`VISION_TIMEOUT_MS` (slow-call cap so a lane never hangs) and `VISION_MIN_CONFIDENCE` (re-applied
client-side). ANPR fires on the entry/exit snapshot, so there are **no poll/dedupe knobs**.
**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
@@ -242,6 +236,5 @@ service's `/health` each tick and shows a **"Vision" chip** in the booth footer
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.
- Per-camera **opt-in** — ✅ **built**: `config.anpr === true` enables ANPR on a camera (set via the
SetupWizard checkbox); ANPR then runs on that camera's entry/exit snapshot.