Files
parking_solution/apps/server/src/plate-lookup.ts
T
julian e67f0ccef0 feat(carwash): review outbox, booth side — plate-blurred vehicle crop + the operator's choice, queued for a trusted remote reviewer
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
2026-09-06 22:33:43 +02:00

119 lines
4.8 KiB
TypeScript
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.
import { and, desc, deviceEvents, eq, type Db } from "@parking/db";
import { isNormBox, isVehicleClass, type VehicleRead } from "@parking/shared";
// READ-TIME plate resolution. A recognized licence plate is ADVISORY evidence — it
// lives in the unsigned, prunable `device_events` (kind="read") stream written by the
// ANPR-on-snapshot path (snapshot.ts → recognizePlate), keyed to the session `identity`.
// It is deliberately NOT on the signed ledger (a fuzzy camera read must never become a
// signed fact). To SHOW it next to a feed event or an active session we resolve it here,
// at serialize time, the same way subscriber names are resolved (see event-enrich.ts).
//
// Preference: an ENTRY read over an exit read (the plate as it arrived identifies the
// session); within a direction, the newest read wins. Returns the plate text only —
// confidence/region detail stays on the snapshot review panel, not the at-a-glance feed.
/** The best advisory plate observed for a session, for display. */
export interface PlateView {
readonly plate: string;
readonly confidence: number | null;
readonly direction: "entry" | "exit" | null;
}
interface ReadDetail {
identity?: string;
plate?: string;
confidence?: number;
direction?: string;
bodyType?: string;
bodyConfidence?: number;
snapshotId?: string;
vehicleBox?: unknown;
plateBox?: unknown;
}
/** The advisory VEHICLE read (body type) for a session — the same stream and the same
* preference as the plate (entry over exit, newest first). Null when vision never
* classified the vehicle. See venue-modules.md §Vehicle category from vision. */
export function vehicleForIdentity(db: Db, identity: string): VehicleRead | null {
const rows = db
.select({ detail: deviceEvents.detail })
.from(deviceEvents)
.where(and(eq(deviceEvents.category, "camera"), eq(deviceEvents.kind, "read")))
.orderBy(desc(deviceEvents.occurredAt))
.all();
let fallback: VehicleRead | null = null;
for (const r of rows) {
const d = (r.detail ?? {}) as ReadDetail;
if (d.identity !== identity || !isVehicleClass(d.bodyType) || typeof d.bodyConfidence !== "number") continue;
const v: VehicleRead = {
bodyType: d.bodyType,
confidence: d.bodyConfidence,
snapshotId: d.snapshotId ?? null,
box: isNormBox(d.vehicleBox) ? d.vehicleBox : null,
plateBox: isNormBox(d.plateBox) ? d.plateBox : null,
};
if (d.direction === "entry") return v;
if (!fallback) fallback = v;
}
return fallback;
}
/** Best plate for one identity, or null. Prefers an entry read, then the newest read. */
export function plateForIdentity(db: Db, identity: string): PlateView | null {
const rows = db
.select({ detail: deviceEvents.detail })
.from(deviceEvents)
.where(and(eq(deviceEvents.category, "camera"), eq(deviceEvents.kind, "read")))
.orderBy(desc(deviceEvents.occurredAt))
.all();
return pickBest(rows.map((r) => (r.detail ?? {}) as ReadDetail), identity);
}
/** Resolve plates for MANY identities in one device_events scan (used by the active-
* sessions list and the feed page, which each carry tens–hundreds of rows). */
export function platesForIdentities(db: Db, identities: Iterable<string>): Map<string, PlateView> {
const want = new Set(identities);
const out = new Map<string, PlateView>();
if (want.size === 0) return out;
// Newest first so the first acceptable read per (identity,direction) is the freshest.
const rows = db
.select({ detail: deviceEvents.detail })
.from(deviceEvents)
.where(and(eq(deviceEvents.category, "camera"), eq(deviceEvents.kind, "read")))
.orderBy(desc(deviceEvents.occurredAt))
.all();
const byId = new Map<string, ReadDetail[]>();
for (const r of rows) {
const d = (r.detail ?? {}) as ReadDetail;
if (!d.identity || !d.plate || !want.has(d.identity)) continue;
let list = byId.get(d.identity);
if (!list) byId.set(d.identity, (list = []));
list.push(d);
}
for (const [id, reads] of byId) {
const best = pickBest(reads, id);
if (best) out.set(id, best);
}
return out;
}
/** Pick the best read for `identity` from a NEWEST-FIRST list: an entry read beats an
* exit read; otherwise the first (newest) acceptable read wins. */
function pickBest(reads: ReadDetail[], identity: string): PlateView | null {
let fallback: ReadDetail | null = null;
for (const d of reads) {
if (d.identity !== identity || !d.plate) continue;
if (d.direction === "entry") return toView(d);
if (!fallback) fallback = d;
}
return fallback ? toView(fallback) : null;
}
function toView(d: ReadDetail): PlateView {
return {
plate: d.plate!.trim().toUpperCase(),
confidence: typeof d.confidence === "number" ? d.confidence : null,
direction: d.direction === "entry" || d.direction === "exit" ? d.direction : null,
};
}