feat(vision): persist every recognized plate + snapshot as telemetry (non-blocking)
Answers "is a recognized plate saved?" — now yes, for both transient and subscriber, as
an ANPR audit trail independent of whether it matched anything.
VisionReader now stores the snapshot bytes 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 UI changes. It also records an unsigned device_events{kind:"read"}
breadcrumb (plate, confidence, region, model, snapshotId, and the dispatch outcome) as a
queryable recognition log. Switched from emitRead to calling ReadDispatcher.dispatch
directly (like qr-reader) to capture that outcome.
Non-blocking: a refused read (no session / unpaid / unknown plate) just returns
rejected — no barrier hold — and is logged with its snapshot for investigation. Plate
stays advisory (exit demands payment; subscription matches only a bound plate).
Verified e2e: a recognized AL plate with no open session signed exit.refused.noSession
(identity=plate), stored a 555KB snapshot under that plate, recorded the read breadcrumb
(accepted:false, reason "no open session"), and by-identity returned the image — the
refused read is fully investigable with its picture. Build + lint green.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
@@ -173,14 +173,26 @@ now exists: **opt-in** (`VISION_ENABLED`, default off), **fail-soft** (any error
|
||||
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 emits a `DeviceReadEvent{kind:"plate"}` onto the
|
||||
**same read bus a physical reader uses** (`deviceEvents.emitRead`), so the `ReadDispatcher` routes it
|
||||
to the subscription/exit flow **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. Verified end-to-end (live service → reader → one `AA558EE` read on the bus; debounce held it to 1
|
||||
emit over 7 polls). Plate stays **advisory** — the exit flow still demands a `payment`, the
|
||||
subscription flow only matches a **bound** plate. (3) **field-accuracy** unknown — re-benchmark/tune
|
||||
`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
|
||||
|
||||
@@ -932,3 +932,7 @@ Scaffolded the Node-side adapter to the host vision microservice per the fitness
|
||||
## [2026-06-19] feat | VisionReader — wire ANPR into the read bus (apps/server/src/vision-reader.ts)
|
||||
|
||||
Wired the vision service into the entry/exit flows via the READ BUS. 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 calls deviceEvents.emitRead({kind:"plate", value:PLATE, deviceId, driverId}) — the SAME event a physical plate reader emits, so the existing ReadDispatcher routes it to the subscription/exit flow UNCHANGED (no flow rewrite). The plate stays ADVISORY by construction: the exit flow still demands a covering payment (a plate can't bypass it), the subscription flow only matches a BOUND plate (subscriptionPlates). Guards: low-confidence reads DROPPED (a shaky read isn't an identity); DEBOUNCE (VISION_DEDUPE_MS, default 15s) so a parked car in frame doesn't re-fire the same plate; per-camera in-flight guard; idle when VISION_ENABLED off or no camera opts in; #recognizeOn is public for a future on-demand trigger (loop edge / API). Direction from directionOf (both→entry context). Constructed in server.ts, start on onReady / stop on onClose. VERIFIED END-TO-END: in-memory anpr camera returning the AL plate image + live fast_alpr service → VisionReader emitted exactly one {kind:"plate",value:"AA558EE"} read onto the bus; debounce held it to 1 emit over 7 polls. Build+lint green. Updated [[opencv-anpr-service]] (trigger-wiring gap + per-camera opt-in marked done). Remaining: SetupWizard anpr toggle, field tuning, weight-provenance, Job 2.
|
||||
|
||||
## [2026-06-19] feat | Persist every recognized plate + snapshot (ANPR audit trail, non-blocking)
|
||||
|
||||
VisionReader now PERSISTS every confident plate read so a recognition is investigable — and switched from emitRead to calling ReadDispatcher.dispatch directly (like qr-reader) to capture the OUTCOME. On a confident plate it: (1) stores the SNAPSHOT bytes 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 SnapshotStrip, which already uses e.identity) shows the car's photo against that anomaly with ZERO UI changes; (2) records an unsigned device_events{kind:"read"} breadcrumb with plate/confidence/region/modelVersion/snapshotId + the dispatch outcome (accepted + reason) = a queryable ANPR log independent of the signed ledger; (3) dispatches the read — NON-BLOCKING: a refused read just returns rejected (no barrier hold), logged with its snapshot for investigation. Plate stays advisory (exit demands payment; subscription matches only a bound plate). VERIFIED e2e: a recognized AL plate (AA558EE) with no open session → signed exit.refused.noSession anomaly (identity=plate), stored a 555KB snapshot under that plate, read-breadcrumb accepted:false reason:"no open session", and by-identity returned the image (status 200, 1 snapshot) → the refused read is investigable with its picture. Build+lint green. VisionReader constructor now takes the ReadDispatcher (wired in server.ts). Answers "is a recognized plate saved?" — now YES for both transient + subscriber, as telemetry + evidence image, regardless of match. Updated [[opencv-anpr-service]].
|
||||
|
||||
Reference in New Issue
Block a user