From 236cbfecab42f04cc3ca2a2933e8bd1596b25fa7 Mon Sep 17 00:00:00 2001 From: Julian Cuni Date: Fri, 19 Jun 2026 15:58:43 +0200 Subject: [PATCH] feat(vision): add VisionClient Node adapter (advisory, fail-soft, opt-in) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Node-side adapter to the apps/vision ANPR microservice (localhost HTTP: POST /analyze with snapshot bytes, GET /health), returning a normalised VisionResult or null. Enforces "advisory, never sole authority" at the boundary: opt-in (VISION_ENABLED, default off), fail-soft (any error/timeout/unreachable → null, never throws into the lane → ticket fallback), and re-applies the confidence floor (VISION_MIN_CONFIDENCE) on top of the service's own low_confidence flag. Per-request AbortController timeout so a slow call can't hang the barrier. Constructed in server.ts. Verified: fail-soft (disabled/unreachable → null, no throw) and live end-to-end (Node client → running fast_alpr service → AA558EE 0.999, region=Albania). NOT yet wired into the read bus — the opt-in snapshot→DeviceReadEvent{kind:"plate"} trigger is the next step. Build + lint green. Updates opencv-anpr-service (adapter gap marked done). Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V --- apps/server/src/server.ts | 10 ++ apps/server/src/vision-client.ts | 193 +++++++++++++++++++++++++++ wiki/entities/opencv-anpr-service.md | 22 +-- wiki/log.md | 4 + 4 files changed, 220 insertions(+), 9 deletions(-) create mode 100644 apps/server/src/vision-client.ts diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 22eb450..9eaeafa 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -19,6 +19,7 @@ import { DeviceMonitor } from "./device-monitor.js"; import { buildSigner, buildVerifier } from "./signer.js"; import { LogService, pinoDbStream } from "./log-service.js"; import { logRoutes } from "./routes/logs.js"; +import { VisionClient } from "./vision-client.js"; import { authRoutes } from "./routes/auth.js"; import { userRoutes } from "./routes/users.js"; import { roleRoutes } from "./routes/roles.js"; @@ -161,6 +162,15 @@ export async function buildServer(opts: BuildOptions = {}): Promise unsubscribeRead()); + // Vision (ANPR) client: the adapter to the host vision microservice (apps/vision), + // talking localhost HTTP. ADVISORY ONLY + opt-in (VISION_ENABLED) + fail-soft — a + // plate read is an identity hint/evidence, never the sole authority to open a paid + // barrier. Constructed here and available for the (separate, not-yet-wired) read + // trigger that snapshots an opt-in camera and emits a plate read. See + // wiki/entities/opencv-anpr-service.md "Fitness for the entry/exit flows". + const visionClient = new VisionClient(app.log); + if (visionClient.enabled) app.log.info("vision client enabled"); + // Credential capture ("enroll a card"): lets the operator present an RFID card to a // CHOSEN reader to populate a subscription credential, without blocking the other // reader's live flow. Single-shot + TTL. See credential-capture.ts. diff --git a/apps/server/src/vision-client.ts b/apps/server/src/vision-client.ts new file mode 100644 index 0000000..2dd04dd --- /dev/null +++ b/apps/server/src/vision-client.ts @@ -0,0 +1,193 @@ +import type { FastifyBaseLogger } from "fastify"; + +// Node-side client for the host vision service (apps/vision — the ANPR microservice). +// Calls it over LOCALHOST HTTP with a camera snapshot and gets back a plate read. The +// Python service is a separate process/failure domain; this client is the adapter the +// rest of the server talks to, so the recognizer is swappable without business-logic +// changes. See wiki/entities/opencv-anpr-service.md, decisions/vision-service*.md. +// +// ADVISORY, NEVER SOLE AUTHORITY. Per the vision decision + the fitness assessment, a +// plate read is an *identity hint + evidence*, never the lone reason a paid/access +// barrier opens. This client enforces two things at the boundary so callers can't +// misuse it: +// 1. It is FAIL-SOFT — any error (service down, timeout, decode fail) resolves to +// `null`, never throws into the entry/exit path. A missing vision result must +// degrade to the ticket/manual path, never strand or wrongly admit a car +// (fail-state-safety). +// 2. It applies the CONFIDENCE FLOOR — a read below the threshold is returned with +// `lowConfidence: true` (mirroring the service's own flag) so the caller treats it +// as advisory-only and falls back. +// +// NOT yet wired into the read bus — that (snapshot-before-decision on an opt-in camera → +// emit DeviceReadEvent{kind:"plate"}) is a separate, deliberate step. This is the +// transport + contract adapter only. + +/** Plate bounding box (pixels, top-left origin) — mirrors the service schema. */ +export interface PlateBBox { + readonly x1: number; + readonly y1: number; + readonly x2: number; + readonly y2: number; +} + +/** One plate read from the vision service. `confidence` is the MIN of the model's + * per-character confidences (a plate is only as trustworthy as its weakest char). */ +export interface VisionPlate { + readonly text: string; + readonly confidence: number; + readonly bbox?: PlateBBox | null; + /** Predicted issuing region/country (advisory; the global model emits this). */ + readonly region?: string | null; +} + +/** The raw /analyze response shape (the Python contract). `vehicle` is reserved for + * Job 2 (vehicle verification) — not yet produced. */ +interface AnalyzeResponse { + readonly plate: VisionPlate | null; + readonly plates: VisionPlate[]; + readonly vehicle: unknown | null; + readonly low_confidence: boolean; + readonly model_version: string; + readonly took_ms: number; +} + +/** What the rest of the server gets back from `analyze()`. Normalised + camelCased, + * with the advisory gate already applied. Never thrown — `null` on any failure. */ +export interface VisionResult { + /** The best plate, or null if none read. */ + readonly plate: VisionPlate | null; + /** All plates found in the frame (a frame may hold several vehicles). */ + readonly plates: VisionPlate[]; + /** True when the best plate is below the confidence floor — treat as advisory only + * and fall back to the ticket/manual path. */ + readonly lowConfidence: boolean; + readonly modelVersion: string; + readonly tookMs: number; +} + +export interface VisionHealth { + readonly ok: boolean; + readonly recognizer: string; + readonly ready: boolean; + readonly modelVersion: string; + readonly detail?: string | null; +} + +export interface VisionClientOptions { + /** Base URL of the vision service (localhost). */ + readonly baseUrl?: string; + /** Per-request timeout (ms) — a slow vision call must never hang the lane. */ + readonly timeoutMs?: number; + /** Confidence floor: a best-plate below this is flagged lowConfidence. Mirrors the + * service's own VISION_MIN_CONFIDENCE; kept here too so the gate holds even if the + * service is misconfigured. */ + readonly minConfidence?: number; + /** Master switch — when false, `analyze()` short-circuits to null (no call). Lets the + * appliance run with no vision service configured. */ + readonly enabled?: boolean; +} + +export class VisionClient { + readonly #baseUrl: string; + readonly #timeoutMs: number; + readonly #minConfidence: number; + readonly #enabled: boolean; + readonly #logger: FastifyBaseLogger; + + constructor(logger: FastifyBaseLogger, opts: VisionClientOptions = {}) { + this.#logger = logger; + this.#baseUrl = (opts.baseUrl ?? process.env.VISION_URL ?? "http://127.0.0.1:8089").replace(/\/$/, ""); + this.#timeoutMs = opts.timeoutMs ?? Number(process.env.VISION_TIMEOUT_MS ?? 1500); + this.#minConfidence = opts.minConfidence ?? Number(process.env.VISION_MIN_CONFIDENCE ?? 0.5); + // Default OFF: vision is opt-in. Enable with VISION_ENABLED=1 (or pass enabled:true). + this.#enabled = + opts.enabled ?? ["1", "true", "yes"].includes((process.env.VISION_ENABLED ?? "").toLowerCase()); + } + + get enabled(): boolean { + return this.#enabled; + } + + /** + * Analyse snapshot bytes → a plate read, or `null`. NEVER throws and NEVER blocks the + * caller's open path beyond `timeoutMs`: any failure (disabled, unreachable, timeout, + * non-2xx, bad body) logs and resolves to null, so the caller falls back to the + * ticket/manual path. The returned `lowConfidence` re-applies the floor on top of the + * service's own flag. + */ + async analyze(imageBytes: Buffer, contentType = "application/octet-stream"): Promise { + if (!this.#enabled) return null; + try { + const body = await this.#post("/analyze", imageBytes, contentType); + if (!body) return null; + const res = body as AnalyzeResponse; + const best = res.plate ?? null; + const lowConfidence = + res.low_confidence || (best != null && best.confidence < this.#minConfidence); + return { + plate: best, + plates: Array.isArray(res.plates) ? res.plates : [], + lowConfidence, + modelVersion: res.model_version ?? "unknown", + tookMs: typeof res.took_ms === "number" ? res.took_ms : 0, + }; + } catch (err) { + this.#logger.warn(`vision analyze failed (fallback to ticket path): ${(err as Error).message}`); + return null; + } + } + + /** Liveness/readiness of the vision service. Returns ok:false (never throws) when + * disabled or unreachable, so the device-status footer can show it. */ + async health(): Promise { + if (!this.#enabled) { + return { ok: false, recognizer: "disabled", ready: false, modelVersion: "-", detail: "vision disabled" }; + } + try { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), this.#timeoutMs); + try { + const r = await fetch(`${this.#baseUrl}/health`, { signal: controller.signal }); + if (!r.ok) return { ok: false, recognizer: "?", ready: false, modelVersion: "-", detail: `HTTP ${r.status}` }; + const h = (await r.json()) as { + status?: string; + recognizer?: string; + ready?: boolean; + model_version?: string; + detail?: string | null; + }; + return { + ok: h.status === "ok", + recognizer: h.recognizer ?? "?", + ready: Boolean(h.ready), + modelVersion: h.model_version ?? "-", + detail: h.detail ?? null, + }; + } finally { + clearTimeout(timer); + } + } catch (err) { + return { ok: false, recognizer: "?", ready: false, modelVersion: "-", detail: (err as Error).message }; + } + } + + /** POST raw bytes to a path, with timeout. Returns parsed JSON or throws (caught by + * the caller, which fails soft). */ + async #post(path: string, bytes: Buffer, contentType: string): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), this.#timeoutMs); + try { + const r = await fetch(`${this.#baseUrl}${path}`, { + method: "POST", + headers: { "content-type": contentType }, + // Buffer is a valid BodyInit in Node's undici fetch. + body: bytes, + signal: controller.signal, + }); + if (!r.ok) throw new Error(`vision ${path} → HTTP ${r.status}`); + return await r.json(); + } finally { + clearTimeout(timer); + } + } +} diff --git a/wiki/entities/opencv-anpr-service.md b/wiki/entities/opencv-anpr-service.md index 3bba108..e282408 100644 --- a/wiki/entities/opencv-anpr-service.md +++ b/wiki/entities/opencv-anpr-service.md @@ -165,15 +165,19 @@ the read is given** — and the answer splits by role: is NOT built**. So plate-as-identity is convenience + evidence, never the lone reason a paid barrier opens. Consistent with "advisory, never sole authority" above. -**Gaps before it's actually consumed (capable ≠ wired):** (1) the Node→service **`VisionClient`** -adapter (localhost HTTP, behind the [[device-adapter-pattern]] interface) — the real integration work; -(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 (open item below); (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.** Next concrete step is the `VisionClient` adapter -+ the opt-in trigger, not more model work. +**Gaps before it's actually consumed (capable ≠ wired):** (1) ✅ **DONE — the Node→service +`VisionClient`** adapter (`apps/server/src/vision-client.ts`, localhost HTTP to `/analyze` + `/health`) +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 +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. ## Open diff --git a/wiki/log.md b/wiki/log.md index d7f7b9a..9baf9b9 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -924,3 +924,7 @@ Benchmarked fast-alpr's four candidate fast-plate-ocr models via the FULL pipeli ## [2026-06-19] query | Vision service fitness for entry/exit flows — advisory YES, sole-authority NO Q: is the scaffolded ANPR service worthy to consume in entry/exit flows? Assessment recorded in [[opencv-anpr-service]] ("Fitness for the entry/exit flows"). Benchmark settled ACCURACY (0.99+ clean AL plates); "worthy" turns on AUTHORITY. Split verdict: (✅) worthy NOW as an ADVISORY identity source (Job 1) — the flows are ALREADY built for a plate (kind:"plate" read is first-class: exit-flow signs source:"lpr"; subscription-flow matches read plate vs subscriptionPlates), so the service just produces the plate string → DeviceReadEvent{kind:"plate"} on the existing read bus; no flow rewrite. Worthy for hands-free subscriber open + evidence enrichment. (⚠️) NOT worthy as SOLE AUTHORITY to open a TRANSIENT barrier: a plate ≠ payment (would be an unpaid-exit bypass; min_confidence floor → ticket/manual fallback is the guard) and plate-spoofing (printed plate, different car) needs Job 2 vehicle-verification which is NOT built. Gaps before consuming: (1) the Node VisionClient adapter (real integration work), (2) trigger wiring — snapshots fire AFTER open today (evidence); plate-as-identity needs a snapshot BEFORE the decision on a per-camera opt-in lane, (3) field accuracy unknown (re-tune threshold on on-site captures), (4) weight-provenance check. Next step: VisionClient adapter + opt-in trigger, not more model work. (Scaffolding VisionClient next.) + +## [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).