e67f0ccef0
The operator's category choice is a hypothesis, not truth (user, 2026-09-06): each wash order with a vehicle read queues a package for a trusted reviewer over the private overlay (Netbird); the verdict becomes the phase-B training label and the per-operator error rate. wiki/concepts/vision-review-outbox.md. - Boxes: the vision service returns the vehicle bbox; snapshot.ts stores the vehicle and plate boxes on the read as FRACTIONS of the analysed frame (the stored snapshot is a downscaled copy); vehicleForIdentity() returns them. - carwash_review_outbox (migration 0031) + review-outbox.ts: crop = detector box + 8 % margin, ≤ 640 px, plate blurred in place from the plate box; payload carries a pseudonymous booth id and a keyed operator hash — no site name, no plate, no OSD, no bystanders; multipart POST with a per-booth bearer; 2xx → sent (image dropped); 400/404/413/415/422 → abandoned; anything else → backoff 1 min·2^n capped 6 h; voided orders and items older than 14 days abandoned unsent. Nothing queued while unconfigured. - Enqueue is fire-and-forget off the intake path in createOrder; the loop runs every CARWASH_REVIEW_INTERVAL_SEC (60) and stops on close. - GET /api/carwash/review/status (site:read) + a "Remote review" line in Setup → Car wash. - Env CARWASH_REVIEW_URL / _TOKEN / _BOOTH_ID (all three or off) documented in .env.example and forwarded by compose. - Tests: review-outbox.test.ts (crop + blur on a synthetic frame, config/pseudonyms, queue/drain/backoff/abandon, through the app). Wiki: new concept page, index, venue-modules As built, log. The collector is not built. Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
213 lines
8.9 KiB
TypeScript
213 lines
8.9 KiB
TypeScript
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. */
|
|
import { isVehicleClass, type VehicleClass } from "@parking/shared";
|
|
|
|
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 vehicle attributes stage of /analyze (advisory). `body_type` is one of the shared
|
|
* VEHICLE_CLASSES vocabulary (the service's raw label is normalised there); a stub or a
|
|
* plate-only recognizer sends null. */
|
|
export interface VisionVehicle {
|
|
readonly bodyType: VehicleClass;
|
|
readonly confidence: number;
|
|
/** The vehicle's box in frame pixels, when the stage found one. */
|
|
readonly bbox?: PlateBBox | null;
|
|
}
|
|
|
|
/** The raw /analyze response shape (the Python contract). */
|
|
interface AnalyzeResponse {
|
|
readonly plate: VisionPlate | null;
|
|
readonly plates: VisionPlate[];
|
|
readonly vehicle: { body_type?: string | null; confidence?: number | null; bbox?: PlateBBox | null } | 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;
|
|
/** The vehicle's body type, when the service ran that stage and named a known class. */
|
|
readonly vehicle: VisionVehicle | null;
|
|
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<VisionResult | null> {
|
|
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);
|
|
const v = res.vehicle;
|
|
const vehicle: VisionVehicle | null =
|
|
v && isVehicleClass(v.body_type) && typeof v.confidence === "number"
|
|
? { bodyType: v.body_type, confidence: Math.max(0, Math.min(1, v.confidence)), bbox: v.bbox ?? null }
|
|
: null;
|
|
return {
|
|
plate: best,
|
|
plates: Array.isArray(res.plates) ? res.plates : [],
|
|
lowConfidence,
|
|
vehicle,
|
|
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<VisionHealth> {
|
|
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<unknown> {
|
|
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);
|
|
}
|
|
}
|
|
}
|