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
This commit is contained in:
@@ -87,3 +87,15 @@ WS_ALLOWED_ORIGINS=http://localhost:5173,tauri://localhost,http://tauri.localhos
|
||||
# is never entitled to a module its Komodo stack env does not name. Required modules
|
||||
# (parking) are always on. See wiki/decisions/venue-modules.md.
|
||||
#MODULES_ENTITLED=parking,validation
|
||||
|
||||
# Car Wash review outbox (wiki/concepts/vision-review-outbox.md) -------------------------
|
||||
# The operator's category choice is a hypothesis: each wash order with a vehicle read queues
|
||||
# the vehicle CROP (plate blurred) + the choice for a trusted remote reviewer, drained one-way
|
||||
# over the private overlay (Netbird). All three or off. URL = the collector's ingest endpoint
|
||||
# (reachable only over the overlay); TOKEN = this booth's own bearer token; BOOTH_ID = a
|
||||
# pseudonymous label the reviewer maps to a site (NEVER the site name — it travels with every
|
||||
# item). Set in the Komodo stack env, per booth. Nothing is queued while off.
|
||||
# CARWASH_REVIEW_URL=
|
||||
# CARWASH_REVIEW_TOKEN=
|
||||
# CARWASH_REVIEW_BOOTH_ID=
|
||||
# CARWASH_REVIEW_INTERVAL_SEC=60
|
||||
|
||||
@@ -597,7 +597,7 @@ describe("vision category — advisory, flagged, never authoritative", () => {
|
||||
await openSession("T-V1");
|
||||
seeVehicle("T-V1", "suv", 0.91);
|
||||
const look = (await app.inject({ method: "GET", url: "/api/carwash/session/T-V1", headers: { cookie: a.cookie } })).json();
|
||||
expect(look.vision).toEqual({ bodyType: "suv", confidence: 0.91, snapshotId: "snap-1" });
|
||||
expect(look.vision).toMatchObject({ bodyType: "suv", confidence: 0.91, snapshotId: "snap-1" });
|
||||
expect(look.suggestedCategoryId).toBe(ids.suv);
|
||||
// Unmapped class → shown, nothing suggested.
|
||||
await openSession("T-V2");
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ServerModule } from "../index.js";
|
||||
import { ReviewOutbox, reviewUploadConfigFromEnv } from "./review-outbox.js";
|
||||
import { carwashRoutes } from "./routes.js";
|
||||
import { CarwashService } from "./service.js";
|
||||
|
||||
@@ -10,10 +11,18 @@ import { CarwashService } from "./service.js";
|
||||
export const carwashModule: ServerModule = {
|
||||
id: "carwash",
|
||||
async register(app, deps) {
|
||||
const service = new CarwashService(deps, app.log);
|
||||
// The review outbox (wiki/concepts/vision-review-outbox.md): on when the stack env
|
||||
// names a collector URL, a per-booth token and a pseudonymous booth id; off = no
|
||||
// queueing at all. One-way, background, never on the intake path.
|
||||
const cfg = reviewUploadConfigFromEnv();
|
||||
const outbox = new ReviewOutbox(deps.db, app.log, cfg);
|
||||
app.log.info(cfg ? `carwash review upload: on → ${new URL(cfg.url).host} as ${cfg.boothId}` : "carwash review upload: off");
|
||||
outbox.start();
|
||||
app.addHook("onClose", async () => outbox.stop());
|
||||
const service = new CarwashService(deps, app.log, outbox);
|
||||
// A wash ordered with payAt = "booth" is a charge line on the parking settlement;
|
||||
// the core calls back after the payment is signed so the order is marked paid.
|
||||
deps.payStation.registerChargeProvider(service.chargeProvider());
|
||||
await carwashRoutes(app, deps, service);
|
||||
await carwashRoutes(app, deps, service, outbox);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import sharp from "sharp";
|
||||
import { createTestDb } from "@parking/db/testing";
|
||||
import { carwashOrders, carwashReviewOutbox, deviceEvents, snapshots, type Db } from "@parking/db";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { buildServer } from "../../server.js";
|
||||
import { login, makeLog, minutesAgo, seedTariff, seedUser, silentLogger } from "../../test-helpers.js";
|
||||
import { EXPIRE_DAYS, ReviewOutbox, makeReviewCrop, operatorRef, reviewUploadConfigFromEnv } from "./review-outbox.js";
|
||||
|
||||
// The review outbox, booth side (wiki/concepts/vision-review-outbox.md): a plate-blurred
|
||||
// vehicle crop + the operator's choice, queued off the intake path, drained one-way with
|
||||
// backoff, never blocking the wash, never naming the site.
|
||||
|
||||
/** A 400×300 frame: grey ground, a red "car" block, a white "plate" strip inside it. */
|
||||
async function frame(): Promise<Buffer> {
|
||||
return sharp({ create: { width: 400, height: 300, channels: 3, background: { r: 90, g: 90, b: 90 } } })
|
||||
.composite([
|
||||
{ input: { create: { width: 200, height: 120, channels: 3, background: { r: 200, g: 30, b: 30 } } }, left: 100, top: 100 },
|
||||
{ input: { create: { width: 60, height: 16, channels: 3, background: { r: 255, g: 255, b: 255 } } }, left: 170, top: 190 },
|
||||
])
|
||||
.jpeg()
|
||||
.toBuffer();
|
||||
}
|
||||
const CAR = { x1: 100 / 400, y1: 100 / 300, x2: 300 / 400, y2: 220 / 300 };
|
||||
const PLATE = { x1: 170 / 400, y1: 190 / 300, x2: 230 / 400, y2: 206 / 300 };
|
||||
|
||||
/** Mean GREEN over a region — the white plate reads 255, the red car around it 30, so a
|
||||
* blurred plate drops far below 255 as the red bleeds in. */
|
||||
async function meanGreen(buf: Buffer, region: { left: number; top: number; width: number; height: number }): Promise<number> {
|
||||
const { data, info } = await sharp(buf).extract(region).raw().toBuffer({ resolveWithObject: true });
|
||||
let sum = 0;
|
||||
for (let i = 1; i < data.length; i += info.channels) sum += data[i]!;
|
||||
return sum / (data.length / info.channels);
|
||||
}
|
||||
|
||||
describe("makeReviewCrop", () => {
|
||||
it("cuts the vehicle (with margin), blurs the plate inside it, caps the edge", async () => {
|
||||
const shot = await frame();
|
||||
const crop = await makeReviewCrop(shot, CAR, PLATE);
|
||||
expect(crop.plateBlurred).toBe(true);
|
||||
// Box 200×120 + 8 % margin each side ≈ 232×139; no upscaling.
|
||||
expect(crop.width).toBeGreaterThanOrEqual(228);
|
||||
expect(crop.width).toBeLessThanOrEqual(236);
|
||||
expect(crop.height).toBeGreaterThanOrEqual(135);
|
||||
// The white plate is gone: over the plate strip (crop coords: the frame's 170..230 ×
|
||||
// 190..206 shifted by the crop origin 84,90) the same region cut straight from the
|
||||
// frame is white, the review crop is the red bleeding in.
|
||||
const plain = await sharp(shot).extract({ left: 84, top: 90, width: crop.width, height: crop.height }).jpeg().toBuffer();
|
||||
const strip = { left: 170 - 84, top: 190 - 90, width: 60, height: 16 };
|
||||
expect(await meanGreen(plain, strip)).toBeGreaterThan(240);
|
||||
expect(await meanGreen(crop.bytes, strip)).toBeLessThan(180);
|
||||
// Without a plate box: same crop, nothing blurred.
|
||||
const noPlate = await makeReviewCrop(shot, CAR, null);
|
||||
expect(noPlate.plateBlurred).toBe(false);
|
||||
// A big frame is capped to the max edge.
|
||||
const big = await sharp({ create: { width: 2560, height: 1440, channels: 3, background: "#444" } }).jpeg().toBuffer();
|
||||
const capped = await makeReviewCrop(big, { x1: 0, y1: 0, x2: 1, y2: 1 }, null);
|
||||
expect(Math.max(capped.width, capped.height)).toBe(640);
|
||||
});
|
||||
});
|
||||
|
||||
describe("config + pseudonyms", () => {
|
||||
it("needs url, token and booth id together; the operator ref is a keyed hash", () => {
|
||||
expect(reviewUploadConfigFromEnv({})).toBeNull();
|
||||
expect(reviewUploadConfigFromEnv({ CARWASH_REVIEW_URL: "https://c/ingest", CARWASH_REVIEW_TOKEN: "t" })).toBeNull();
|
||||
const cfg = reviewUploadConfigFromEnv({ CARWASH_REVIEW_URL: "https://c/ingest", CARWASH_REVIEW_TOKEN: "t", CARWASH_REVIEW_BOOTH_ID: "b7", CARWASH_REVIEW_INTERVAL_SEC: "5" });
|
||||
expect(cfg).toMatchObject({ boothId: "b7", intervalSec: 60 }); // below the 10 s floor → default
|
||||
expect(operatorRef("b7", "lavazhier")).toHaveLength(16);
|
||||
expect(operatorRef("b7", "lavazhier")).not.toBe(operatorRef("b8", "lavazhier"));
|
||||
expect(operatorRef("b7", "lavazhier")).not.toContain("lavazhier");
|
||||
});
|
||||
});
|
||||
|
||||
describe("queue + drain", () => {
|
||||
let db: Db;
|
||||
let close: () => void;
|
||||
beforeEach(() => {
|
||||
const t = createTestDb();
|
||||
db = t.db;
|
||||
close = t.close;
|
||||
});
|
||||
afterEach(() => close());
|
||||
|
||||
const cfg = { url: "https://collector.overlay/ingest", token: "secret-1", boothId: "booth-7", intervalSec: 60 };
|
||||
const read = { bodyType: "car" as const, confidence: 0.86, snapshotId: "snap-1", box: CAR, plateBox: PLATE };
|
||||
const item = { orderId: "o-1", createdAt: "2026-09-06T10:00:00.000Z", createdBy: "lavazhier", categoryId: "car", categoryName: "Vetura", serviceName: "Standard", visionCategoryId: "car", downgraded: false };
|
||||
|
||||
async function seed(): Promise<void> {
|
||||
db.insert(snapshots).values({ id: "snap-1", direction: "entry", identity: "T-1", contentType: "image/jpeg", bytes: await frame(), capturedAt: new Date().toISOString() }).run();
|
||||
db.insert(carwashOrders).values({
|
||||
id: "o-1", identity: "T-1", plate: null, categoryId: "car", categoryName: "Vetura", serviceId: "std", serviceName: "Standard",
|
||||
priceMinor: 100, currency: "ALL", payAt: "booth", status: "open", createdAt: item.createdAt, createdBy: "lavazhier",
|
||||
}).run();
|
||||
}
|
||||
|
||||
it("enqueues a crop + a payload with no site name, no plate, no operator name; drains with a multipart POST; drops the image once sent", async () => {
|
||||
await seed();
|
||||
const calls: { url: string; init: RequestInit }[] = [];
|
||||
const fetchFn = vi.fn(async (url: string, init: RequestInit) => {
|
||||
calls.push({ url, init });
|
||||
return new Response("ok", { status: 200 });
|
||||
});
|
||||
const ob = new ReviewOutbox(db, silentLogger(), cfg, fetchFn);
|
||||
expect(await ob.enqueue(item, read)).toBe(true);
|
||||
const row = db.select().from(carwashReviewOutbox).all()[0]!;
|
||||
expect(row.status).toBe("queued");
|
||||
expect(row.image!.length).toBeGreaterThan(500);
|
||||
expect(row.payload).toMatchObject({ v: 1, booth: "booth-7", order: "o-1", operatorCategory: { id: "car", name: "Vetura" }, vision: { class: "car", confidence: 0.86 }, downgraded: false, image: { plateBlurred: true } });
|
||||
expect(JSON.stringify(row.payload)).not.toContain("lavazhier");
|
||||
|
||||
expect(await ob.drain()).toEqual({ sent: 1, failed: 0, deferred: 0 });
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]!.url).toBe(cfg.url);
|
||||
expect((calls[0]!.init.headers as Record<string, string>).authorization).toBe("Bearer secret-1");
|
||||
const form = calls[0]!.init.body as FormData;
|
||||
expect(JSON.parse(form.get("meta") as string).item).toBe(row.id);
|
||||
expect((form.get("image") as File).type).toBe("image/jpeg");
|
||||
const after = db.select().from(carwashReviewOutbox).all()[0]!;
|
||||
expect(after.status).toBe("sent");
|
||||
expect(after.image).toBeNull();
|
||||
expect(after.sentAt).toBeTruthy();
|
||||
expect(ob.status()).toMatchObject({ enabled: true, boothId: "booth-7", queued: 0, sent: 1, failed: 0 });
|
||||
});
|
||||
|
||||
it("defers with backoff on collector/network trouble, abandons on a rejection, a void or expiry, skips without a box", async () => {
|
||||
await seed();
|
||||
let status = 503;
|
||||
const fetchFn = vi.fn(async () => (status === 0 ? Promise.reject(new Error("ECONNREFUSED")) : new Response("", { status })));
|
||||
const ob = new ReviewOutbox(db, silentLogger(), cfg, fetchFn);
|
||||
await ob.enqueue(item, read);
|
||||
expect(await ob.drain()).toEqual({ sent: 0, failed: 0, deferred: 1 });
|
||||
let row = db.select().from(carwashReviewOutbox).all()[0]!;
|
||||
expect(row).toMatchObject({ status: "queued", attempts: 1, lastError: "HTTP 503" });
|
||||
expect(Date.parse(row.nextAttemptAt!)).toBeGreaterThan(Date.now() + 60_000);
|
||||
// Not due yet → untouched.
|
||||
expect(await ob.drain()).toEqual({ sent: 0, failed: 0, deferred: 0 });
|
||||
// Due again: a network error defers too; a 422 abandons.
|
||||
db.update(carwashReviewOutbox).set({ nextAttemptAt: null }).run();
|
||||
status = 0;
|
||||
expect(await ob.drain()).toEqual({ sent: 0, failed: 0, deferred: 1 });
|
||||
db.update(carwashReviewOutbox).set({ nextAttemptAt: null }).run();
|
||||
status = 422;
|
||||
expect(await ob.drain()).toEqual({ sent: 0, failed: 1, deferred: 0 });
|
||||
row = db.select().from(carwashReviewOutbox).all()[0]!;
|
||||
expect(row).toMatchObject({ status: "failed", lastError: "rejected: HTTP 422" });
|
||||
expect(row.image).toBeNull();
|
||||
|
||||
// A voided order is not a sample.
|
||||
status = 200;
|
||||
await ob.enqueue({ ...item, orderId: "o-1" }, read);
|
||||
db.update(carwashOrders).set({ status: "void" }).run();
|
||||
expect(await ob.drain()).toEqual({ sent: 0, failed: 1, deferred: 0 });
|
||||
// Expired items are abandoned without a request.
|
||||
await ob.enqueue(item, read);
|
||||
db.update(carwashReviewOutbox).set({ createdAt: new Date(Date.now() - (EXPIRE_DAYS + 1) * 86_400_000).toISOString() }).where(eq(carwashReviewOutbox.status, "queued")).run();
|
||||
db.update(carwashOrders).set({ status: "open" }).run();
|
||||
const before = fetchFn.mock.calls.length;
|
||||
expect(await ob.drain()).toEqual({ sent: 0, failed: 1, deferred: 0 });
|
||||
expect(fetchFn.mock.calls.length).toBe(before);
|
||||
expect(ob.status().failed).toBe(3);
|
||||
|
||||
// No vehicle box, no snapshot, or upload off → nothing queued.
|
||||
expect(await ob.enqueue(item, { ...read, box: null })).toBe(false);
|
||||
expect(await ob.enqueue(item, { ...read, snapshotId: "gone" })).toBe(false);
|
||||
expect(await new ReviewOutbox(db, silentLogger(), null, fetchFn).enqueue(item, read)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
import { eq } from "@parking/db";
|
||||
|
||||
describe("through the app", () => {
|
||||
let db: Db;
|
||||
let close: () => void;
|
||||
let app: FastifyInstance;
|
||||
const saved = { ...process.env };
|
||||
beforeEach(async () => {
|
||||
delete process.env.MODULES_ENTITLED;
|
||||
process.env.CARWASH_REVIEW_URL = "https://collector.overlay/ingest";
|
||||
process.env.CARWASH_REVIEW_TOKEN = "tok";
|
||||
process.env.CARWASH_REVIEW_BOOTH_ID = "booth-9";
|
||||
const t = createTestDb();
|
||||
db = t.db;
|
||||
close = t.close;
|
||||
app = await buildServer({ db });
|
||||
await app.ready();
|
||||
});
|
||||
afterEach(async () => {
|
||||
await app.close();
|
||||
close();
|
||||
for (const k of ["CARWASH_REVIEW_URL", "CARWASH_REVIEW_TOKEN", "CARWASH_REVIEW_BOOTH_ID"]) {
|
||||
if (saved[k] === undefined) delete process.env[k];
|
||||
else process.env[k] = saved[k];
|
||||
}
|
||||
});
|
||||
|
||||
it("a wash intake with a vehicle read queues a review item; the status route reports it", async () => {
|
||||
const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" });
|
||||
const a = await login(app, username, password);
|
||||
const hdrs = { cookie: a.cookie, "x-csrf-token": a.csrf };
|
||||
seedTariff(db);
|
||||
const s = (await app.inject({ method: "PUT", url: "/api/carwash/settings", headers: hdrs, payload: { categories: [{ name: "Vetura", visionClasses: ["car"] }], services: [{ name: "Standard" }], prices: [] } })).json();
|
||||
const cat = s.categories[0].id, svc = s.services[0].id;
|
||||
await app.inject({ method: "PUT", url: "/api/carwash/settings", headers: hdrs, payload: { prices: [{ categoryId: cat, serviceId: svc, priceMinor: 500 }] } });
|
||||
await makeLog(db).append({ type: "vehicle_entry", source: "manual", identity: "T-R", occurredAt: minutesAgo(30), payload: { sessionRef: "T-R", category: "default" } });
|
||||
db.insert(snapshots).values({ id: "snap-r", direction: "entry", identity: "T-R", contentType: "image/jpeg", bytes: await frame(), capturedAt: new Date().toISOString() }).run();
|
||||
db.insert(deviceEvents).values({
|
||||
id: "read-r", deviceId: "cam-1", category: "camera", kind: "read",
|
||||
detail: { identity: "T-R", direction: "entry", bodyType: "car", bodyConfidence: 0.9, snapshotId: "snap-r", vehicleBox: CAR, plateBox: PLATE },
|
||||
occurredAt: new Date().toISOString(),
|
||||
}).run();
|
||||
|
||||
const order = await app.inject({ method: "POST", url: "/api/carwash/orders", headers: hdrs, payload: { identity: "T-R", categoryId: cat, serviceId: svc } });
|
||||
expect(order.statusCode).toBe(201);
|
||||
// Enqueue is fire-and-forget: give the crop a moment.
|
||||
await vi.waitFor(() => expect(db.select().from(carwashReviewOutbox).all()).toHaveLength(1));
|
||||
const status = (await app.inject({ method: "GET", url: "/api/carwash/review/status", headers: { cookie: a.cookie } })).json();
|
||||
expect(status).toMatchObject({ enabled: true, boothId: "booth-9", queued: 1, sent: 0 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,327 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import sharp from "sharp";
|
||||
import { and, asc, carwashOrders, carwashReviewOutbox, eq, isNull, lte, or, snapshots, sql, type Db } from "@parking/db";
|
||||
import type { NormBox, VehicleRead } from "@parking/shared";
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
|
||||
// The Car Wash REVIEW OUTBOX — booth side (wiki/concepts/vision-review-outbox.md).
|
||||
//
|
||||
// The operator's category choice at intake is a HYPOTHESIS, not truth (the threat model:
|
||||
// the operator may err or cheat). So every wash order that has a vehicle read queues a
|
||||
// small package for a trusted remote reviewer: the vehicle CROP cut out of the entry
|
||||
// snapshot with the plate BLURRED, the operator's choice, and what the camera thought.
|
||||
// The reviewer's verdict becomes the training label for the body-type classifier (phase
|
||||
// B) and, per operator, the honest-mistake / fraud rate.
|
||||
//
|
||||
// Rules that shape this file:
|
||||
// - OFFLINE-FIRST: the wash never waits. Enqueue is fire-and-forget off the intake path;
|
||||
// a background loop drains the queue when the private overlay (Netbird) is up, with
|
||||
// backoff, and gives up loudly after EXPIRE_DAYS.
|
||||
// - ONE-WAY: the booth POSTs; nothing ever comes back into the booth's decisions. The
|
||||
// signed ledger stays the only record of what happened at the wash.
|
||||
// - NOTHING THAT NAMES THE SITE LEAVES: only the crop (no walls, no camera OSD, no
|
||||
// bystanders), the plate blurred in place, a per-booth pseudonymous id set at deploy,
|
||||
// the operator as a keyed hash. The mapping back to people and places stays with the
|
||||
// reviewer, off the collector.
|
||||
// - THE NETWORK IS NOT THE AUTH: a per-booth bearer token on top of the overlay; the
|
||||
// booth can do nothing at the collector but this one POST.
|
||||
|
||||
export interface ReviewUploadConfig {
|
||||
/** The collector's ingest URL (reachable only over the overlay). */
|
||||
readonly url: string;
|
||||
/** Per-booth bearer token. */
|
||||
readonly token: string;
|
||||
/** Pseudonymous booth id — a label the reviewer maps to a site; never the site name. */
|
||||
readonly boothId: string;
|
||||
readonly intervalSec: number;
|
||||
}
|
||||
|
||||
/** From the server env (Komodo stack env). All three of URL, token and booth id, or off. */
|
||||
export function reviewUploadConfigFromEnv(env: NodeJS.ProcessEnv = process.env): ReviewUploadConfig | null {
|
||||
const url = (env.CARWASH_REVIEW_URL ?? "").trim();
|
||||
const token = (env.CARWASH_REVIEW_TOKEN ?? "").trim();
|
||||
const boothId = (env.CARWASH_REVIEW_BOOTH_ID ?? "").trim();
|
||||
if (!url || !token || !boothId) return null;
|
||||
const raw = Number(env.CARWASH_REVIEW_INTERVAL_SEC ?? 60);
|
||||
return { url, token, boothId, intervalSec: Number.isFinite(raw) && raw >= 10 ? raw : 60 };
|
||||
}
|
||||
|
||||
/** The crop's longest edge, in pixels — enough for a reviewer and a classifier, small
|
||||
* enough that a day of washes is a few megabytes. */
|
||||
export const CROP_MAX_EDGE = 640;
|
||||
/** Margin around the detector's box, as a fraction of the box (context for the reviewer). */
|
||||
const CROP_MARGIN = 0.08;
|
||||
/** Items older than this are abandoned (failed "expired") — a booth cut off for two weeks
|
||||
* should not resurface a fortnight of crops in one burst. */
|
||||
export const EXPIRE_DAYS = 14;
|
||||
/** Backoff: 1 min · 2^attempts, capped. */
|
||||
const BACKOFF_BASE_MS = 60_000;
|
||||
const BACKOFF_CAP_MS = 6 * 60 * 60 * 1000;
|
||||
const UPLOAD_TIMEOUT_MS = 20_000;
|
||||
|
||||
/** What one order contributes to the package (the service hands this over at intake). */
|
||||
export interface ReviewItemInput {
|
||||
readonly orderId: string;
|
||||
readonly createdAt: string;
|
||||
readonly createdBy: string;
|
||||
readonly categoryId: string;
|
||||
readonly categoryName: string;
|
||||
readonly serviceName: string;
|
||||
readonly visionCategoryId: string | null;
|
||||
readonly downgraded: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cut the vehicle out of the snapshot and blur the plate inside it. Boxes are fractions
|
||||
* of the frame, so this works on the stored (downscaled) copy. Returns a JPEG.
|
||||
*/
|
||||
export async function makeReviewCrop(
|
||||
snapshotBytes: Buffer,
|
||||
box: NormBox,
|
||||
plateBox: NormBox | null | undefined,
|
||||
): Promise<{ bytes: Buffer; width: number; height: number; plateBlurred: boolean }> {
|
||||
const img = sharp(snapshotBytes, { failOn: "none" }).rotate();
|
||||
const meta = await img.metadata();
|
||||
const W = meta.width ?? 0;
|
||||
const H = meta.height ?? 0;
|
||||
if (!W || !H) throw new Error("snapshot has no dimensions");
|
||||
const px = (b: NormBox) => ({
|
||||
left: Math.round(b.x1 * W), top: Math.round(b.y1 * H),
|
||||
right: Math.round(b.x2 * W), bottom: Math.round(b.y2 * H),
|
||||
});
|
||||
const v = px(box);
|
||||
const mw = Math.round((v.right - v.left) * CROP_MARGIN);
|
||||
const mh = Math.round((v.bottom - v.top) * CROP_MARGIN);
|
||||
const left = Math.max(0, v.left - mw);
|
||||
const top = Math.max(0, v.top - mh);
|
||||
const right = Math.min(W, v.right + mw);
|
||||
const bottom = Math.min(H, v.bottom + mh);
|
||||
const width = right - left;
|
||||
const height = bottom - top;
|
||||
if (width < 8 || height < 8) throw new Error("vehicle box too small to crop");
|
||||
|
||||
let crop = img.clone().extract({ left, top, width, height });
|
||||
let plateBlurred = false;
|
||||
if (plateBox) {
|
||||
// The plate region, in CROP coordinates, padded a little so the blur eats the edges.
|
||||
const p = px(plateBox);
|
||||
const pad = Math.round(Math.max(p.right - p.left, p.bottom - p.top) * 0.25);
|
||||
const pl = Math.max(0, p.left - pad - left);
|
||||
const pt = Math.max(0, p.top - pad - top);
|
||||
const pr = Math.min(width, p.right + pad - left);
|
||||
const pb = Math.min(height, p.bottom + pad - top);
|
||||
if (pr - pl >= 2 && pb - pt >= 2) {
|
||||
const region = await sharp(await crop.clone().toBuffer())
|
||||
.extract({ left: pl, top: pt, width: pr - pl, height: pb - pt })
|
||||
.blur(Math.max(6, Math.round((pr - pl) / 6)))
|
||||
.toBuffer();
|
||||
crop = sharp(await crop.toBuffer()).composite([{ input: region, left: pl, top: pt }]);
|
||||
plateBlurred = true;
|
||||
}
|
||||
}
|
||||
const out = await crop
|
||||
.resize({ width: CROP_MAX_EDGE, height: CROP_MAX_EDGE, fit: "inside", withoutEnlargement: true })
|
||||
.jpeg({ quality: 85, mozjpeg: true })
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
return { bytes: out.data, width: out.info.width, height: out.info.height, plateBlurred };
|
||||
}
|
||||
|
||||
/** The operator as a keyed hash — stable per booth so the reviewer can count per person,
|
||||
* meaningless anywhere else. */
|
||||
export function operatorRef(boothId: string, username: string): string {
|
||||
return createHash("sha256").update(`${boothId}:${username}`).digest("hex").slice(0, 16);
|
||||
}
|
||||
|
||||
type FetchLike = (input: string, init: RequestInit) => Promise<Response>;
|
||||
|
||||
export interface OutboxStatus {
|
||||
readonly enabled: boolean;
|
||||
readonly boothId: string | null;
|
||||
readonly queued: number;
|
||||
readonly sent: number;
|
||||
readonly failed: number;
|
||||
readonly lastSentAt: string | null;
|
||||
readonly lastError: string | null;
|
||||
}
|
||||
|
||||
export class ReviewOutbox {
|
||||
readonly #db: Db;
|
||||
readonly #logger: FastifyBaseLogger;
|
||||
readonly #cfg: ReviewUploadConfig | null;
|
||||
readonly #fetch: FetchLike;
|
||||
#timer: NodeJS.Timeout | null = null;
|
||||
#draining = false;
|
||||
|
||||
constructor(db: Db, logger: FastifyBaseLogger, cfg: ReviewUploadConfig | null, fetchFn?: FetchLike) {
|
||||
this.#db = db;
|
||||
this.#logger = logger;
|
||||
this.#cfg = cfg;
|
||||
this.#fetch = fetchFn ?? ((input, init) => fetch(input, init));
|
||||
}
|
||||
|
||||
get enabled(): boolean {
|
||||
return this.#cfg != null;
|
||||
}
|
||||
|
||||
/** Queue one order's package. Fire-and-forget: the caller does NOT await this on the
|
||||
* intake path; every failure is logged, none is thrown. Skipped when there is no
|
||||
* vehicle box (nothing to crop — a frame without a detected vehicle is no training
|
||||
* sample) or when upload is not configured (an unbounded queue nobody drains). */
|
||||
async enqueue(item: ReviewItemInput, read: VehicleRead): Promise<boolean> {
|
||||
if (!this.#cfg) return false;
|
||||
if (!read.box || !read.snapshotId) return false;
|
||||
try {
|
||||
const snap = this.#db.select().from(snapshots).where(eq(snapshots.id, read.snapshotId)).get();
|
||||
if (!snap) {
|
||||
this.#logger.info(`carwash review: snapshot ${read.snapshotId} gone (pruned) — order ${item.orderId} not queued`);
|
||||
return false;
|
||||
}
|
||||
const crop = await makeReviewCrop(snap.bytes, read.box, read.plateBox);
|
||||
const id = randomUUID();
|
||||
const payload = {
|
||||
v: 1,
|
||||
booth: this.#cfg.boothId,
|
||||
item: id,
|
||||
order: item.orderId,
|
||||
at: item.createdAt,
|
||||
operator: operatorRef(this.#cfg.boothId, item.createdBy),
|
||||
operatorCategory: { id: item.categoryId, name: item.categoryName },
|
||||
service: item.serviceName,
|
||||
vision: { class: read.bodyType, confidence: read.confidence, categoryId: item.visionCategoryId },
|
||||
downgraded: item.downgraded,
|
||||
image: { width: crop.width, height: crop.height, plateBlurred: crop.plateBlurred },
|
||||
};
|
||||
this.#db
|
||||
.insert(carwashReviewOutbox)
|
||||
.values({ id, orderId: item.orderId, createdAt: new Date().toISOString(), status: "queued", attempts: 0, nextAttemptAt: null, image: crop.bytes, payload })
|
||||
.run();
|
||||
return true;
|
||||
} catch (err) {
|
||||
this.#logger.warn(`carwash review: could not queue order ${item.orderId}: ${(err as Error).message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
start(): void {
|
||||
if (!this.#cfg || this.#timer) return;
|
||||
const tick = () => {
|
||||
void this.drain().catch((err) => this.#logger.warn(`carwash review: drain failed: ${(err as Error).message}`));
|
||||
};
|
||||
this.#timer = setInterval(tick, this.#cfg.intervalSec * 1000);
|
||||
this.#timer.unref?.();
|
||||
setTimeout(tick, 5_000).unref?.();
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.#timer) clearInterval(this.#timer);
|
||||
this.#timer = null;
|
||||
}
|
||||
|
||||
/** Send what is due, oldest first. Returns the tally; never throws for a single item. */
|
||||
async drain(limit = 20): Promise<{ sent: number; failed: number; deferred: number }> {
|
||||
const tally = { sent: 0, failed: 0, deferred: 0 };
|
||||
if (!this.#cfg || this.#draining) return tally;
|
||||
this.#draining = true;
|
||||
try {
|
||||
const now = new Date().toISOString();
|
||||
const due = this.#db
|
||||
.select()
|
||||
.from(carwashReviewOutbox)
|
||||
.where(and(eq(carwashReviewOutbox.status, "queued"), or(isNull(carwashReviewOutbox.nextAttemptAt), lte(carwashReviewOutbox.nextAttemptAt, now))))
|
||||
.orderBy(asc(carwashReviewOutbox.createdAt))
|
||||
.limit(limit)
|
||||
.all();
|
||||
for (const row of due) {
|
||||
const outcome = await this.#send(row);
|
||||
tally[outcome] += 1;
|
||||
}
|
||||
if (tally.sent || tally.failed) this.#logger.info(`carwash review: sent ${tally.sent}, failed ${tally.failed}, deferred ${tally.deferred}`);
|
||||
} finally {
|
||||
this.#draining = false;
|
||||
}
|
||||
return tally;
|
||||
}
|
||||
|
||||
async #send(row: typeof carwashReviewOutbox.$inferSelect): Promise<"sent" | "failed" | "deferred"> {
|
||||
const cfg = this.#cfg!;
|
||||
const ageMs = Date.now() - Date.parse(row.createdAt);
|
||||
if (ageMs > EXPIRE_DAYS * 24 * 60 * 60 * 1000) return this.#fail(row, `expired after ${EXPIRE_DAYS} days`);
|
||||
// A wash voided before delivery is not a sample (and not a decision to review).
|
||||
const order = this.#db.select({ status: carwashOrders.status }).from(carwashOrders).where(eq(carwashOrders.id, row.orderId)).get();
|
||||
if (order?.status === "void") return this.#fail(row, "order voided");
|
||||
if (!row.image) return this.#fail(row, "image missing");
|
||||
|
||||
const form = new FormData();
|
||||
form.set("meta", JSON.stringify(row.payload));
|
||||
form.set("image", new Blob([new Uint8Array(row.image)], { type: "image/jpeg" }), `${row.id}.jpg`);
|
||||
const ac = new AbortController();
|
||||
const t = setTimeout(() => ac.abort(), UPLOAD_TIMEOUT_MS);
|
||||
try {
|
||||
const res = await this.#fetch(cfg.url, {
|
||||
method: "POST",
|
||||
headers: { authorization: `Bearer ${cfg.token}`, "x-booth-id": cfg.boothId },
|
||||
body: form,
|
||||
signal: ac.signal,
|
||||
});
|
||||
if (res.ok) {
|
||||
this.#db
|
||||
.update(carwashReviewOutbox)
|
||||
.set({ status: "sent", sentAt: new Date().toISOString(), image: null, lastError: null, attempts: row.attempts + 1 })
|
||||
.where(eq(carwashReviewOutbox.id, row.id))
|
||||
.run();
|
||||
return "sent";
|
||||
}
|
||||
// The collector refused the package itself → no retry will help.
|
||||
if ([400, 404, 413, 415, 422].includes(res.status)) return this.#fail(row, `rejected: HTTP ${res.status}`);
|
||||
// Everything else (auth not yet fixed, throttled, collector down) → try again later.
|
||||
return this.#defer(row, `HTTP ${res.status}`);
|
||||
} catch (err) {
|
||||
return this.#defer(row, (err as Error).name === "AbortError" ? "timeout" : (err as Error).message);
|
||||
} finally {
|
||||
clearTimeout(t);
|
||||
}
|
||||
}
|
||||
|
||||
#fail(row: typeof carwashReviewOutbox.$inferSelect, why: string): "failed" {
|
||||
this.#db
|
||||
.update(carwashReviewOutbox)
|
||||
.set({ status: "failed", lastError: why, image: null, attempts: row.attempts + 1 })
|
||||
.where(eq(carwashReviewOutbox.id, row.id))
|
||||
.run();
|
||||
this.#logger.warn(`carwash review: item ${row.id} (order ${row.orderId}) abandoned — ${why}`);
|
||||
return "failed";
|
||||
}
|
||||
|
||||
#defer(row: typeof carwashReviewOutbox.$inferSelect, why: string): "deferred" {
|
||||
const attempts = row.attempts + 1;
|
||||
const wait = Math.min(BACKOFF_BASE_MS * 2 ** Math.min(attempts, 20), BACKOFF_CAP_MS);
|
||||
this.#db
|
||||
.update(carwashReviewOutbox)
|
||||
.set({ attempts, lastError: why, nextAttemptAt: new Date(Date.now() + wait).toISOString() })
|
||||
.where(eq(carwashReviewOutbox.id, row.id))
|
||||
.run();
|
||||
return "deferred";
|
||||
}
|
||||
|
||||
status(): OutboxStatus {
|
||||
const count = (s: "queued" | "sent" | "failed") =>
|
||||
this.#db.select({ n: sql<number>`count(*)` }).from(carwashReviewOutbox).where(eq(carwashReviewOutbox.status, s)).get()?.n ?? 0;
|
||||
const lastSent = this.#db.select({ at: sql<string | null>`max(${carwashReviewOutbox.sentAt})` }).from(carwashReviewOutbox).get()?.at ?? null;
|
||||
const lastErr = this.#db
|
||||
.select({ e: carwashReviewOutbox.lastError })
|
||||
.from(carwashReviewOutbox)
|
||||
.where(sql`${carwashReviewOutbox.lastError} is not null`)
|
||||
.orderBy(sql`coalesce(${carwashReviewOutbox.sentAt}, ${carwashReviewOutbox.nextAttemptAt}, ${carwashReviewOutbox.createdAt}) desc`)
|
||||
.limit(1)
|
||||
.get()?.e ?? null;
|
||||
return {
|
||||
enabled: this.enabled,
|
||||
boothId: this.#cfg?.boothId ?? null,
|
||||
queued: count("queued"),
|
||||
sent: count("sent"),
|
||||
failed: count("failed"),
|
||||
lastSentAt: lastSent,
|
||||
lastError: lastErr,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { requireAnyPermission, requirePermission } from "../../auth.js";
|
||||
import { requireModule } from "../../modules.js";
|
||||
import { NoShiftOpenError } from "../../shift-service.js";
|
||||
import type { ServerModuleDeps } from "../index.js";
|
||||
import type { ReviewOutbox } from "./review-outbox.js";
|
||||
import { CarwashError, CarwashService, isPayAt, type SettingsBody } from "./service.js";
|
||||
|
||||
// HTTP surface of the Car Wash module. Every route is behind the venue-module gate
|
||||
@@ -27,7 +28,7 @@ function sendError(reply: FastifyReply, err: unknown): FastifyReply {
|
||||
throw err;
|
||||
}
|
||||
|
||||
export async function carwashRoutes(app: FastifyInstance, deps: ServerModuleDeps, service: CarwashService): Promise<void> {
|
||||
export async function carwashRoutes(app: FastifyInstance, deps: ServerModuleDeps, service: CarwashService, outbox?: ReviewOutbox): Promise<void> {
|
||||
const moduleOn = requireModule(deps.db, "carwash");
|
||||
// The price list is the desk's working data as much as Setup's: the wash operator
|
||||
// reads it under the module's own permission (the Wash operator job holds no site:*).
|
||||
@@ -38,6 +39,11 @@ export async function carwashRoutes(app: FastifyInstance, deps: ServerModuleDeps
|
||||
const update = [moduleOn, requirePermission("carwash:update")];
|
||||
|
||||
app.get("/api/carwash/settings", { preHandler: settingsRead }, async () => service.settings());
|
||||
// The review outbox's health (Setup → Car wash): how many decisions wait for the
|
||||
// reviewer, how many went, the last error. Site admin's read.
|
||||
app.get("/api/carwash/review/status", { preHandler: settingsRead }, async () =>
|
||||
outbox?.status() ?? { enabled: false, boothId: null, queued: 0, sent: 0, failed: 0, lastSentAt: null, lastError: null },
|
||||
);
|
||||
|
||||
app.put<{ Body: SettingsBody }>("/api/carwash/settings", { preHandler: settingsWrite }, async (req, reply) => {
|
||||
try {
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
} from "@parking/shared";
|
||||
import type { EventLog } from "../../event-log.js";
|
||||
import { vehicleForIdentity } from "../../plate-lookup.js";
|
||||
import type { ReviewOutbox } from "./review-outbox.js";
|
||||
import { effectiveModulesFor } from "../../modules.js";
|
||||
import type { ChargeProvider, PayStation } from "../../pay-station.js";
|
||||
import type { ShiftService } from "../../shift-service.js";
|
||||
@@ -116,13 +117,15 @@ export class CarwashService {
|
||||
readonly #pay: PayStation;
|
||||
readonly #shift: ShiftService;
|
||||
readonly #logger: FastifyBaseLogger;
|
||||
readonly #outbox: ReviewOutbox | null;
|
||||
|
||||
constructor(deps: ServerModuleDeps, logger: FastifyBaseLogger) {
|
||||
constructor(deps: ServerModuleDeps, logger: FastifyBaseLogger, outbox: ReviewOutbox | null = null) {
|
||||
this.#db = deps.db;
|
||||
this.#log = deps.eventLog;
|
||||
this.#pay = deps.payStation;
|
||||
this.#shift = deps.shiftService;
|
||||
this.#logger = logger;
|
||||
this.#outbox = outbox;
|
||||
}
|
||||
|
||||
#enabled(): boolean {
|
||||
@@ -523,6 +526,22 @@ export class CarwashService {
|
||||
downgradeEventId,
|
||||
};
|
||||
this.#db.insert(carwashOrders).values(row).run();
|
||||
// Hand the decision to the remote reviewer (crop + choice), off the intake path.
|
||||
if (vision && this.#outbox?.enabled) {
|
||||
void this.#outbox.enqueue(
|
||||
{
|
||||
orderId: row.id,
|
||||
createdAt: now,
|
||||
createdBy: input.actor,
|
||||
categoryId: category.id,
|
||||
categoryName: category.name,
|
||||
serviceName: service.name,
|
||||
visionCategoryId: visionCategory?.id ?? null,
|
||||
downgraded: downgradeEventId != null,
|
||||
},
|
||||
vision,
|
||||
);
|
||||
}
|
||||
await this.#log.append({
|
||||
type: "carwash_order",
|
||||
source: "manual",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { and, desc, deviceEvents, eq, type Db } from "@parking/db";
|
||||
import { isVehicleClass, type VehicleRead } from "@parking/shared";
|
||||
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
|
||||
@@ -27,6 +27,8 @@ interface ReadDetail {
|
||||
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
|
||||
@@ -43,7 +45,13 @@ export function vehicleForIdentity(db: Db, identity: string): VehicleRead | 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 };
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -168,13 +168,26 @@ async function recognizePlate(
|
||||
try {
|
||||
const result = await vision.analyze(shot.bytes, shot.contentType);
|
||||
if (!result) return;
|
||||
// Boxes are kept as FRACTIONS of the analysed frame (the stored snapshot is a
|
||||
// downscaled copy — see reencodeForStorage), so the wash's review crop can cut the
|
||||
// vehicle out of whatever copy survives and blur the plate inside it.
|
||||
const frame = await frameSize(shot.bytes);
|
||||
const norm = (b: { x1: number; y1: number; x2: number; y2: number } | null | undefined) =>
|
||||
b && frame
|
||||
? {
|
||||
x1: clamp01(b.x1 / frame.w), y1: clamp01(b.y1 / frame.h),
|
||||
x2: clamp01(b.x2 / frame.w), y2: clamp01(b.y2 / frame.h),
|
||||
}
|
||||
: null;
|
||||
// The vehicle's body type (advisory; the wash desk's category suggestion — see
|
||||
// venue-modules.md §Vehicle category). Rides the plate's read row when there is one,
|
||||
// else a row of its own: a car with an unreadable plate is still a car of some class.
|
||||
const vehicleBox = norm(result.vehicle?.bbox);
|
||||
const vehicle = result.vehicle
|
||||
? { bodyType: result.vehicle.bodyType, bodyConfidence: result.vehicle.confidence }
|
||||
? { bodyType: result.vehicle.bodyType, bodyConfidence: result.vehicle.confidence, ...(vehicleBox ? { vehicleBox } : {}) }
|
||||
: {};
|
||||
const plate = !result.plate || result.lowConfidence ? "" : result.plate.text.trim().toUpperCase();
|
||||
const plateBox = plate ? norm(result.plate?.bbox) : null;
|
||||
if (!plate && !result.vehicle) return; // nothing trustworthy to record
|
||||
db.insert(deviceEventsTable)
|
||||
.values({
|
||||
@@ -187,7 +200,7 @@ async function recognizePlate(
|
||||
identity,
|
||||
direction,
|
||||
...(plate
|
||||
? { plate, confidence: result.plate!.confidence, region: result.plate!.region ?? null }
|
||||
? { plate, confidence: result.plate!.confidence, region: result.plate!.region ?? null, ...(plateBox ? { plateBox } : {}) }
|
||||
: {}),
|
||||
...vehicle,
|
||||
modelVersion: result.modelVersion,
|
||||
@@ -215,6 +228,20 @@ async function recognizePlate(
|
||||
}
|
||||
}
|
||||
|
||||
function clamp01(v: number): number {
|
||||
return Math.max(0, Math.min(1, v));
|
||||
}
|
||||
|
||||
/** Pixel size of the analysed frame (JPEG header only — cheap). Null when unreadable. */
|
||||
async function frameSize(bytes: Buffer): Promise<{ w: number; h: number } | null> {
|
||||
try {
|
||||
const m = await sharp(bytes, { failOn: "none" }).metadata();
|
||||
return m.width && m.height ? { w: m.width, h: m.height } : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** How far back a recognized entry plate is compared against other OPEN sessions'
|
||||
* entry plates. Short on purpose: the duplicate-ticket scenario is the same car
|
||||
* re-pressing within minutes; a long window would flag legit re-visits. */
|
||||
|
||||
@@ -48,13 +48,15 @@ export interface VisionPlate {
|
||||
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 } | null;
|
||||
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;
|
||||
@@ -138,7 +140,7 @@ export class VisionClient {
|
||||
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)) }
|
||||
? { bodyType: v.body_type, confidence: Math.max(0, Math.min(1, v.confidence)), bbox: v.bbox ?? null }
|
||||
: null;
|
||||
return {
|
||||
plate: best,
|
||||
|
||||
@@ -43,6 +43,8 @@ class VehicleResult(BaseModel):
|
||||
body_type: str | None = None
|
||||
# Confidence of `body_type` (0–1). Node compares it to the site's threshold.
|
||||
confidence: float | None = Field(default=None, ge=0.0, le=1.0)
|
||||
# The vehicle's box in frame pixels — the crop a reviewer sees / a classifier eats.
|
||||
bbox: BBox | None = None
|
||||
make: str | None = None
|
||||
model: str | None = None
|
||||
|
||||
|
||||
@@ -235,7 +235,11 @@ class YoloxVehicleDetector:
|
||||
best = pick_vehicle(found, plate)
|
||||
if best is None:
|
||||
return None
|
||||
return VehicleResult(body_type=best.body_type, confidence=round(best.confidence, 4))
|
||||
h, w = frame.shape[:2]
|
||||
box = BBox(
|
||||
x1=max(0, int(best.x1)), y1=max(0, int(best.y1)), x2=min(w, int(best.x2)), y2=min(h, int(best.y2))
|
||||
)
|
||||
return VehicleResult(body_type=best.body_type, confidence=round(best.confidence, 4), bbox=box)
|
||||
|
||||
|
||||
def time_detect(
|
||||
|
||||
@@ -140,6 +140,10 @@ export const en: Catalog = {
|
||||
visionSaw: "Camera saw",
|
||||
visionUnmapped: "not mapped to a category",
|
||||
visionClasses: "Camera classes",
|
||||
reviewTitle: "Remote review",
|
||||
reviewOff: "off — no collector configured for this booth",
|
||||
reviewCounts: "{{queued}} waiting · {{sent}} delivered · {{failed}} abandoned",
|
||||
reviewHint: "Each wash order sends the vehicle crop (plate blurred) and the chosen category to a trusted reviewer over the private network. One-way; nothing that names this site leaves.",
|
||||
visionClassesHint: "The camera's fixed vocabulary (set in code, not here). Tick the classes this category covers.",
|
||||
visionThreshold: "Camera confidence to flag a downgrade",
|
||||
visionThresholdHint: "When the camera is at least this sure and the operator picks a cheaper category than the one its class maps to, the order is flagged for review. It is never blocked.",
|
||||
|
||||
@@ -142,6 +142,10 @@ export const sq = {
|
||||
visionSaw: "Kamera pa",
|
||||
visionUnmapped: "pa kategori të lidhur",
|
||||
visionClasses: "Klasat e kamerës",
|
||||
reviewTitle: "Shqyrtim në distancë",
|
||||
reviewOff: "joaktiv — asnjë mbledhës i konfiguruar për këtë kabinë",
|
||||
reviewCounts: "{{queued}} në pritje · {{sent}} të dërguara · {{failed}} të braktisura",
|
||||
reviewHint: "Çdo porosi lavazhi dërgon prerjen e mjetit (targa e turbulluar) dhe kategorinë e zgjedhur te një shqyrtues i besuar përmes rrjetit privat. Njëkahësh; asgjë që emërton këtë vend nuk del.",
|
||||
visionClassesHint: "Fjalori i fiksuar i kamerës (vendoset në kod, jo këtu). Shëno klasat që mbulon kjo kategori.",
|
||||
visionThreshold: "Siguria e kamerës për të shënuar një ulje kategorie",
|
||||
visionThresholdHint: "Kur kamera është të paktën kaq e sigurt dhe operatori zgjedh një kategori më të lirë se ajo ku lidhet klasa, porosia shënohet për shqyrtim. Nuk bllokohet kurrë.",
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { CARWASH_PAY_AT, CARWASH_PROGRAM_ID, CARWASH_VALIDATION_MODES, VEHICLE_CLASSES, type CarWashPayAt, type VehicleClass } from "@parking/shared";
|
||||
import { fetchValidationPrograms, type ValidationProgramView } from "../../api.js";
|
||||
import { StationForm, defaultProgram } from "../../ValidationSetup.js";
|
||||
import { fetchCarwashSettings, saveCarwashSettings, type CarwashSettingsView } from "./api.js";
|
||||
import { fetchCarwashReviewStatus, fetchCarwashSettings, saveCarwashSettings, type CarwashReviewStatus, type CarwashSettingsView } from "./api.js";
|
||||
|
||||
// Setup → Car wash: the master data (vehicle categories, services, the category ×
|
||||
// service price matrix) and the parking SPONSORSHIP a wash grants — the latter is a
|
||||
@@ -113,6 +113,7 @@ export function CarWashSetup({ canEdit }: { canEdit: boolean }) {
|
||||
const [threshold, setThreshold] = useState("80");
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
const [program, setProgram] = useState<ValidationProgramView | null>(null);
|
||||
const [review, setReview] = useState<CarwashReviewStatus | null>(null);
|
||||
|
||||
function load() {
|
||||
fetchCarwashSettings()
|
||||
@@ -127,6 +128,7 @@ export function CarWashSetup({ canEdit }: { canEdit: boolean }) {
|
||||
setThreshold(String(Math.round(s.visionThreshold * 100)));
|
||||
})
|
||||
.catch((e) => setMsg((e as Error).message));
|
||||
fetchCarwashReviewStatus().then(setReview).catch(() => {});
|
||||
fetchValidationPrograms()
|
||||
.then((r) => {
|
||||
const existing = r.programs.find((p) => p.id === CARWASH_PROGRAM_ID);
|
||||
@@ -265,6 +267,18 @@ export function CarWashSetup({ canEdit }: { canEdit: boolean }) {
|
||||
</div>
|
||||
<span className="hint">{t("wash.visionThresholdHint")}</span>
|
||||
</div>
|
||||
{review && (
|
||||
<div className="field">
|
||||
<span className="label">{t("wash.reviewTitle")}</span>
|
||||
<span className="text-[0.75rem] tabular-nums">
|
||||
{review.enabled
|
||||
? t("wash.reviewCounts", { queued: review.queued, sent: review.sent, failed: review.failed })
|
||||
: t("wash.reviewOff")}
|
||||
{review.enabled && review.lastError && <span className="ml-2 text-term-amber">{review.lastError}</span>}
|
||||
</span>
|
||||
<span className="hint">{t("wash.reviewHint")}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canEdit && (
|
||||
<div className="flex items-center gap-3">
|
||||
|
||||
@@ -31,6 +31,20 @@ export interface CarwashSettingsBody {
|
||||
visionThreshold?: number;
|
||||
}
|
||||
|
||||
/** The review outbox's health (Setup → Car wash). */
|
||||
export interface CarwashReviewStatus {
|
||||
enabled: boolean;
|
||||
boothId: string | null;
|
||||
queued: number;
|
||||
sent: number;
|
||||
failed: number;
|
||||
lastSentAt: string | null;
|
||||
lastError: string | null;
|
||||
}
|
||||
export function fetchCarwashReviewStatus(): Promise<CarwashReviewStatus> {
|
||||
return apiFetch("/api/carwash/review/status");
|
||||
}
|
||||
|
||||
export function fetchCarwashSettings(): Promise<CarwashSettingsView> {
|
||||
return apiFetch("/api/carwash/settings");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user