feat(vision): wire ANPR into the read bus via VisionReader
A VisionReader polls each opt-in camera (config.anpr===true, off by default) every
VISION_POLL_MS, captures a snapshot, recognizes via VisionClient, and on a confident
plate emits deviceEvents.emitRead({kind:"plate", value}) — the same event a physical
plate reader sends, 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, the subscription flow only matches a bound plate. Guards: low-confidence reads
dropped; debounce (VISION_DEDUPE_MS) so a parked car doesn't re-fire; per-camera
in-flight guard; idle when vision is off or no camera opts in. #recognizeOn is public
for a future on-demand (loop-edge/API) trigger.
Verified end-to-end: an in-memory anpr camera (AL plate image) + live fast_alpr service
→ VisionReader emitted exactly one {kind:"plate",value:"AA558EE"} onto the bus; debounce
held it to 1 emit over 7 polls. Build + lint green. Updates opencv-anpr-service
(trigger-wiring + per-camera opt-in marked done).
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
@@ -170,14 +170,22 @@ the read is given** — and the answer splits by role:
|
||||
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`). Still NOT wired into the
|
||||
read bus. (2) **trigger wiring** — snapshots today fire *after* a barrier opens (evidence);
|
||||
plate-as-identity needs a snapshot *before* the decision, on a **per-camera opt-in** lane → emit
|
||||
`DeviceReadEvent{kind:"plate"}` (open item below). (3) **field-accuracy** unknown — re-benchmark/tune
|
||||
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
|
||||
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.** With the adapter
|
||||
done, the next concrete step is the **opt-in snapshot→read trigger**, not more model work.
|
||||
— 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), the SetupWizard `anpr` toggle, and Job 2.
|
||||
|
||||
## Open
|
||||
|
||||
@@ -190,5 +198,6 @@ done, the next concrete step is the **opt-in snapshot→read trigger**, not more
|
||||
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** ("optionally bound", user's word): which lanes/cameras route snapshots to
|
||||
the service.
|
||||
- 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.
|
||||
|
||||
@@ -928,3 +928,7 @@ Q: is the scaffolded ANPR service worthy to consume in entry/exit flows? Assessm
|
||||
## [2026-06-19] feat | VisionClient Node adapter (apps/server/src/vision-client.ts)
|
||||
|
||||
Scaffolded the Node-side adapter to the host vision microservice per the fitness assessment. VisionClient calls apps/vision over localhost HTTP (POST /analyze with snapshot Buffer bytes, GET /health), returning a normalised/camelCased VisionResult (best plate + all plates + lowConfidence + modelVersion + tookMs) or null. THREE guardrails enforce "advisory, never sole authority" at the boundary: (1) OPT-IN — VISION_ENABLED (default OFF), so the appliance runs with no vision service; (2) FAIL-SOFT — disabled/unreachable/timeout/non-2xx/bad-body all resolve to null and NEVER throw into the entry/exit path (→ ticket/manual fallback, never strand a car); (3) CONFIDENCE FLOOR re-applied (VISION_MIN_CONFIDENCE) on top of the service's own low_confidence flag. Per-request AbortController timeout (VISION_TIMEOUT_MS, default 1500ms) so a slow call can't hang the lane. Constructed in server.ts (logs when enabled). VERIFIED: fail-soft (disabled→null, unreachable→null no-throw) and LIVE end-to-end (Node client → running fast_alpr service → AA558EE 0.999 region=Albania, camelCased). NOT yet wired into the read bus — the opt-in snapshot-before-decision trigger that emits DeviceReadEvent{kind:"plate"} is the next deliberate step. Build+lint green. Updated [[opencv-anpr-service]] (gap 1 marked done).
|
||||
|
||||
## [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.
|
||||
|
||||
Reference in New Issue
Block a user