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