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.
+4
View File
@@ -940,3 +940,7 @@ VisionReader now PERSISTS every confident plate read so a recognition is investi
## [2026-06-19] feat | Vision service configuration — SetupWizard ANPR toggle, footer health chip, .env.example
Made the vision service genuinely configurable (was env-only). THREE additions: (1) SetupWizard CAMERA form now has an "ANPR / Njohja e targave" checkbox (writes config.anpr; only persisted when on; sq+en) — opt-in is no longer raw JSON. (2) DeviceMonitor now optionally takes the VisionClient and probes its /health each tick, emitting a "vision" pseudo-device status (id vision-service, category "vision" — widened the DeviceStatusEvent + frontend DeviceStatus category unions + the footer CATEGORY_KEY/ORDER maps + devices.catVision sq/en) → a "Vision · ready/degraded/offline" chip in the booth footer; NO chip when VISION_ENABLED off. Verified: emits ready/fast_alpr when up, 0 chips when disabled. (3) apps/vision/.env.example (the Python service env) + VISION_* block appended to apps/server/.env.example (the Node side) + a "Configuration" section in [[opencv-anpr-service]] documenting all FOUR layers (python env / node env / per-camera anpr+binding / footer health) and the caveats: the two processes SHARE the VISION_ prefix but need SEPARATE .env files; bind /analyze to 127.0.0.1 (Node is the only caller); models download on first run so cache at deploy; an unbound anpr camera recognizes but every read is refused. Build+lint green. Updated [[opencv-anpr-service]] (Configuration section; SetupWizard-toggle gap closed), vision README.
## [2026-06-19] refactor | ANPR rides the entry/exit snapshot (replaces polling VisionReader)
Reworked the ANPR TRIGGER per the real design goal: when a transient pushes the button or a subscriber passes QR/RFID, the entry/exit fires and takes the evidence snapshot — THAT is the moment to recognize the plate, off the SAME image, tied to the SAME session. So snapshotAsync now takes the VisionClient and, after storing each snapshot from an opt-in (config.anpr) camera, runs ANPR on shot.bytes and records the plate against the session identity (device_events kind:"read" with plate/confidence/region/snapshotId/source:"entry-exit-snapshot"). One image serves both evidence + plate extraction; recognition fires ONLY on a real entry/exit — NO POLLING. The flows (entry/exit/subscription) now take an optional VisionClient and pass it through; server.ts wires it. REMOVED the polling VisionReader (vision-reader.ts deleted) + VISION_POLL_MS/VISION_DEDUPE_MS env. Advisory + fire-and-forget: low-confidence/no-plate records nothing, a vision failure never delays/changes the open, the plate does NOT feed the access decision (the flow already decided) — it's a record ("session X entered on plate AA558EE"). VERIFIED e2e: a simulated entry snapshot on an anpr camera (live fast_alpr) stored the snapshot for session TICKET-SG-1 AND recorded {identity:TICKET-SG-1, plate:AA558EE, confidence:0.999, region:Albania, snapshotId:…}. Build+lint green. Updated [[opencv-anpr-service]] (trigger section + Configuration, polling refs removed) + both .env.example.