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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user