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
This commit is contained in:
@@ -33,9 +33,16 @@ beforeEach(() => {
|
||||
captureSnapshot.mockClear();
|
||||
delete process.env.VISION_ENTRY_MIN_CONFIDENCE;
|
||||
delete process.env.ANPR_DEBOUNCE_MS;
|
||||
// Poll-until-confident loop: keep the window + interval tiny so a below-floor / no-plate
|
||||
// case gives up in ~one tick instead of the 8s production window (tests stay fast). Each
|
||||
// bridge reads these in its constructor, so set them before `new AnprBridge`.
|
||||
process.env.ANPR_POLL_MS = "1";
|
||||
process.env.ANPR_POLL_WINDOW_MS = "5";
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
delete process.env.ANPR_POLL_MS;
|
||||
delete process.env.ANPR_POLL_WINDOW_MS;
|
||||
});
|
||||
|
||||
/** A camera bound to an entry relay; `anpr` toggles the opt-in flag. */
|
||||
@@ -128,6 +135,31 @@ describe("AnprBridge", () => {
|
||||
expect(reads).toEqual([]);
|
||||
});
|
||||
|
||||
it("POLLS until confident: low-confidence approach frames, then a clean stop-at-barrier frame", async () => {
|
||||
// The car APPROACHES (garbage reads) then STOPS at the barrier (clean read) — the bridge
|
||||
// must re-pull until one frame clears the floor, not give up on the first bad frame.
|
||||
const cam = seedCamera({ anpr: true });
|
||||
// analyze escalates: 0.20, 0.20, then 0.97 on the 3rd pull → that one emits.
|
||||
const confs = [0.2, 0.2, 0.97];
|
||||
let i = 0;
|
||||
const vision = {
|
||||
enabled: true,
|
||||
analyze: vi.fn(async () => ({
|
||||
plate: { text: "AA111BB", confidence: confs[Math.min(i++, confs.length - 1)] },
|
||||
plates: [],
|
||||
lowConfidence: false,
|
||||
modelVersion: "test",
|
||||
tookMs: 1,
|
||||
})),
|
||||
} as unknown as VisionClient;
|
||||
const bridge = new AnprBridge(db, vision, fakeSubFlow(SUB_MATCH), silentLogger());
|
||||
|
||||
const reads = await captureReads(() => bridge.onVehicleDetected(cam));
|
||||
expect(reads).toHaveLength(1);
|
||||
expect(reads[0]).toMatchObject({ value: "AA111BB", kind: "plate" });
|
||||
expect(captureSnapshot.mock.calls.length).toBeGreaterThanOrEqual(3); // re-pulled fresh frames
|
||||
});
|
||||
|
||||
it("does NOT emit for a plate matching no subscription — records an advisory anpr-skip", async () => {
|
||||
const cam = seedCamera({ anpr: true });
|
||||
const vision = fakeVision({ plate: "ZZ999ZZ", confidence: 0.97 });
|
||||
|
||||
Reference in New Issue
Block a user