Files
parking_solution/wiki/concepts/lane-presence-and-anpr-entry.md
T
julian f77ed11782 feat(anpr): poll snapshots until a confident plate, so auto-exit works
The ANPR bridge took ONE snapshot at the camera's vehicle-alarm instant — but the
alarm fires as the car APPROACHES, so that frame's plate is small/blurry/half-in-
frame and ANPR returns a low-confidence misread ('111'@0.20). The manual test reads
the SAME car at ~100% because by then it's STOPPED at the barrier, well-framed. So
subscriber auto-exit silently never fired (read below the 0.85 floor → ignored).

Fix (the car-stops-at-the-barrier insight): the bridge now PULLS A FRESH FRAME every
ANPR_POLL_MS (1000) and re-runs ANPR until one clears VISION_ENTRY_MIN_CONFIDENCE, or
ANPR_POLL_WINDOW_MS (8000) elapses (drove off / non-subscriber → give up cleanly).
- One loop per camera (#polling set) — the camera's ~1Hz alarm re-fires JOIN the
  running loop instead of spawning N concurrent loops.
- Fresh camera.captureSnapshot each tick, NOT captureSnapshotShared (its 1.5s TTL
  would re-serve the same bad approach frame).
- Camera-level debounce stamp moved to AFTER a successful emit (suppresses re-fires
  for ANPR_DEBOUNCE_MS once we've acted), not before the loop.

VERIFIED on hardware (DS-2CD1047G3H-LIU exit lane): 7 garbage approach frames →
AA890XX@0.999 at the barrier → signed vehicle_exit. Still advisory + fail-soft; a
barrier never opens on a low-confidence read. anpr-entry.test.ts +1 (poll
escalation low→low→high); 169 server tests green. Documented in
lane-presence-and-anpr-entry + the lpr-camera camera-fault writeup.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-27 22:54:49 +02:00

144 lines
9.8 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
type: concept
tags: [parking, camera, anpr, subscription, lane, vision]
sources: []
updated: 2026-06-22
status: open
---
# Lane Presence & ANPR Subscriber Entry
Two related things a camera's vehicle detection feeds, worked out over a long field session
(2026-06-22, see [[lpr-camera]] for the camera-side saga + the corrected "it was the undrawn
detection area" conclusion):
1. **Lane busy/free** — BUILT. An advisory barrier light on the booth.
2. **ANPR subscriber entry** — BUILT (2026-06-22). A subscriber's plate, read from the lane camera,
drives their entry/exit through the EXISTING [[subscription]] flow. The "bridge" below.
The camera (Hik `DS-2CD1043G2-LIU`) only emits `VMD` events with `eventState=active` and a
`targetType` of `vehicle`/`human` — a coarse **presence** signal, never an identity. Everything
here is built on that, and on the camera's hard limits.
## What the camera actually gives us (measured)
- **Push only, no poll.** No ISAPI endpoint reports "is a car in the zone now"; the only live source
is the event push. We tried to force a steadier signal by flipping `notificationRecurrence`
`beginning → recurring` via ISAPI — the firmware **accepts the PUT but silently reverts** (locked to
`beginning` on this value line). So: notify-once-at-motion-start is all we get.
- **No leave signal.** The camera never sends an `inactive`/end event. Confirmed by config
(`notificationRecurrence: beginning`) AND a controlled in/out test.
- **Re-fire is MOVEMENT-driven, not steady.** Controlled test (call out enter/leave, correlate to the
event log): while a car MOVES, `active` repeats ~1–3 s apart; while it sits MOTIONLESS, gaps stretch
to ~15–25 s. Crucially the camera has **~no dwell lag** — it goes silent within ~1 s of the car
leaving (last event 16:15:17 vs car-left ~16:15:30).
## 1. Lane busy/free (BUILT)
`apps/server/src/lane-status.ts` (`LaneStatus`) + the hik-alarm handler + the booth WS. A `vehicle`
`active` event on a camera bound to entry/exit marks THAT lane busy and arms an auto-clear timer; the
booth shows two barrier lights beside the scan input (green=free, red=busy). **Advisory only — gates
nothing** (never blocks a ticket or opens a barrier; the standing rule).
- **"Free" is timeout-driven** (no leave signal). The TTL must exceed the still-car gap (~25 s) or a
parked car flickers free — so `LANE_BUSY_TTL_MS` default is **30 s** (started at a guessed 90 s,
briefly 5 s, then set to 30 s from the measured data). The camera's lack of dwell lag means 30 s
also clears promptly after departure.
- A `both`-direction camera marks both lanes. Pushed over the existing `/api/ws` (kind `lane-status`).
## 2. ANPR subscriber entry — THE BRIDGE (BUILT 2026-06-22)
> **"Bridge" = a HANDLER CLASS in `apps/server/src/anpr-entry.ts` (`AnprBridge`). NOT a new
> service / container / app.** It is in-process glue that calls things that ALREADY exist.
**As built:** `hikvision-alarm.ts`, on a `vehicle`/non-`inactive` push from an `anpr`-opted-in
camera, hands the deviceId to `AnprBridge.onVehicleDetected()` (fire-and-forget, never awaited on the
camera's 200). The bridge: debounce (camera-level, pre-snapshot) → `captureSnapshot` (fresh pull, via
the reused `snapshot.ts buildCamera`) → `vision.analyze` → entry confidence floor
(`VISION_ENTRY_MIN_CONFIDENCE`, 0.85) → normalize plate → **`subscriptionFlow.match()` (match BEFORE
emit)** → if a subscriber, `deviceEvents.emitRead({kind:"plate"})`; if not, record an advisory
`anpr-skip` device_event and stop. The existing `onRead → ReadDispatcher → SubscriptionFlow.run()`
then does the gated entry/exit + barrier open. Constructed in `server.ts` (the flows were reordered
above the hik-alarm registration so the bridge can take `subscriptionFlow`). Fail-soft throughout —
any snapshot/vision error degrades to the subscriber's card/QR, never throws into the push handler.
Two new env knobs: `VISION_ENTRY_MIN_CONFIDENCE` (0.85), `ANPR_DEBOUNCE_MS` (12_000). Covered by
`anpr-entry.test.ts` + `hikvision-alarm.test.ts` wiring.
> **POLL-until-confident (2026-06-27).** The single-shot capture above was upgraded to a **poll
> loop**. The camera fires its vehicle alarm the INSTANT motion starts — the car is still
> APPROACHING, so the first frame's plate is small/blurry/half-in-frame and ANPR returns a
> low-confidence misread (observed live on the exit lane: `'111'`@0.20, `'AE18671'`@0.19 …, while
> the manual `test-anpr` on the SAME stopped car read `AA890XX`@1.00). The car then STOPS at the
> barrier waiting for it to open — the stationary, well-framed moment the test reads at ~100%. So
> the bridge now **pulls a FRESH frame every `ANPR_POLL_MS` (1000) and re-runs ANPR until one clears
> the floor, or `ANPR_POLL_WINDOW_MS` (8000) elapses** (car drove off / non-subscriber → give up
> cleanly). One loop per camera (`#polling` set) so the ~1Hz alarm re-fires JOIN it, not spawn N;
> each tick is a fresh `camera.captureSnapshot` (NOT `captureSnapshotShared`, whose TTL would
> re-serve the same bad frame). VERIFIED on hardware: 7 garbage approach frames → `AA890XX`@0.999
> at the barrier → signed `vehicle_exit`. This is what made subscriber **auto-exit** actually work
> on the DS-2CD1047G3H-LIU (whose alarm fires on approach, not at the readable moment — see
> [[lpr-camera]] "auto-enter but don't auto-exit"). Still advisory + fail-soft; a barrier never
> opens on a low-confidence read.
The goal (narrowed deliberately — see Rejected below): **a subscriber's plate, read by the lane
camera, admits them through the same gated flow a QR/card scan uses.** Scope was cut to subscribers
ONLY — no queue segmentation, no per-car tracking, no make/model, no ticket-button gating.
Almost everything already exists; the bridge is the one missing wire:
| Piece | Status |
| --- | --- |
| Camera vehicle event | ✅ [[lpr-camera]] (hik-alarm) |
| Pull a snapshot | ✅ `snapshot.ts` (`captureSnapshot`) |
| Read the plate | ✅ [[opencv-anpr-service]] `/analyze` (~50 ms on the DEV PC; appliance TBD) |
| Match a plate → subscriber | ✅ `subscription-flow.ts` `match()` + `subscription_plates` (`via:"plate"`) |
| Plate read → gated entry/exit | ✅ `read-dispatch.ts` + SubscriptionFlow (active/window/blocklist/car-count) |
| **Emit the plate onto the read bus** | ✅ `anpr-entry.ts` (`AnprBridge`) — on a vehicle push from an `anpr` camera it snapshots → analyzes → matches a subscriber → `emitRead({kind:"plate"})`. (Built 2026-06-22.) |
**The bridge logic:** on a camera `vehicle`/`active` event from an **opt-in** camera (`config.anpr`),
snapshot → `vision.analyze` → if a plate clears a **HIGH** confidence floor → **debounce** → emit
`DeviceReadEvent{kind:"plate", value, deviceId}`. The existing dispatcher + flow do the rest.
### Decisions settled with the user (2026-06-22)
- **Both directions.** Entry- and exit-bound cameras both work; the dispatcher infers the verb from
the camera's bound relay direction (an entry camera → entry, exit → exit). No per-read inference.
- **High confidence required.** A barrier-driving read needs a stricter bar than the advisory-record
floor — a NEW `VISION_ENTRY_MIN_CONFIDENCE` (≈0.85) distinct from `VISION_MIN_CONFIDENCE`. (The
subscriber still holds their card/QR, so a near-miss read just falls back to that.)
- **Opt-in** per camera (`config.anpr`), so a site that didn't ask for plate-entry is unaffected.
- **Debounce is REQUIRED — for correctness, NOT CPU.** The camera re-fires ~1 Hz while a car is
present; emitting a read every second would drive REPEAT entries (a fleet sub opens a 2nd
occurrence; a single-car sub spams "already inside") or, on an exit camera, repeat exits
(`sub.refused.noSession` after the first, and FIFO could phantom-close another occurrence). So the
same plate on the same camera within ~10–15 s = ONE credential presentation.
- NB: a direction-bound camera does NOT flip entry↔exit on repeat reads (the barrier's direction
fixes the verb), so the earlier "flip-flop" fear was wrong — but repeat-same-direction is still
bad. Debounce stands.
- **Threat-model rule preserved by construction.** A plate is trivially spoofable (print it on
paper); it must NEVER be the sole reason a barrier opens. Routing through the existing
SubscriptionFlow means the plate is just another credential through the same gate (active / window
/ blocklist / car-count) — not a bypass. See [[opencv-anpr-service]], [[append-only-event-chain]].
### To validate (booth-PC test day, ~2026-06-23)
The dev-PC ANPR is ~50 ms/frame, but that says little about the hardened **booth appliance** (likely a
low-power CPU, possibly 5–15× slower). Real-hardware latency is the open number. The user is bringing
the actual booth PC to test on.
## Rejected / out of scope (and why)
- **Vision monitors the RTSP livestream continuously** for presence — rejected: rebuilds (worse) what
the camera already does (presence), pegs the appliance CPU 24/7, and the leave-detection heuristic
is no cleaner than a 30 s timeout.
- **Vision polls every ~1 s to track THE car, segment a queue, count cars, read make/model** — a
genuinely harder need (bumper-to-bumper cars the camera can't tell apart). Set ASIDE, not dismissed:
it needs a general vehicle DETECTOR ([[opencv-anpr-service]] is plate-only today) + "same-car-vs-new"
logic + make/model (a third, weak, heavy model), and its viability is **gated on appliance
inference budget we cannot measure on the dev PC**. Revisit only if real traffic + hardware justify
it. The vision feasibility was checked: `fast_alpr` live + ready; plate-only; no vehicle/presence
detector exists yet.
- **Gate the entry ticket button on lane-busy** (don't print when no car) — deferred. The lane-busy
signal supports it, but it gates a PHYSICAL action so it must FAIL-OPEN (allow when presence is
unknown). Parked pending the user's real goal (anti-spam print vs one-ticket-per-car).