feat(carwash): entry-stream sampling for the review outbox; park-2 wired to the collector
Build & push images / images (push) Successful in 4m22s

The wash stream is small; the entry camera photographs every car in exactly the view the
classifier is trained on. The booth can now queue entry vehicle reads as pure training
material — crop + the camera's class, no order, no operator, no category.

- Core announces every vehicle read (deviceEvents.emitVehicleRead from snapshot.ts); the
  Car Wash module listens, samples entry reads in-process (sampleEntry: exactly one in N)
  and queues them (enqueueEntry). CARWASH_REVIEW_ENTRY_SAMPLE=N; 1 = every entry (storage
  and bandwidth are not the limit — user); 0/unset = off. Forwarded by compose.
- Packages carry kind: "wash" | "entry". Collector: kind column, entry meta validated
  without the operator fields, review screen shows an entry sample as such, export has a
  kind column, operator agreement computed from wash items only. Setup line shows
  "1 in N entries sampled"; status carries entrySample.
- komodo: park-2's four review lines enabled (collector URL by Netbird DNS name, booth-2,
  the shared per-booth secret, every entry sampled) — the collector is up on the overlay.
- Tests on both sides. Wiki: vision-review-outbox (entry stream + the internet-feed
  assessment), log.

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
This commit is contained in:
2026-09-07 09:38:55 +02:00
parent 3e57af5abc
commit dbbb051ebd
18 changed files with 242 additions and 64 deletions
+16 -4
View File
@@ -109,8 +109,8 @@ describe("review + export", () => {
const stats = (await app.inject({ method: "GET", url: "/api/stats", headers: { authorization: basic } })).json();
expect(stats.booths).toEqual([
{ booth: "booth-7", received: 2, pending: 0, reviewed: 2 },
{ booth: "booth-9", received: 1, pending: 0, reviewed: 1 },
{ booth: "booth-7", received: 2, pending: 0, reviewed: 2, entries: 0 },
{ booth: "booth-9", received: 1, pending: 0, reviewed: 1, entries: 0 },
]);
expect(stats.operators).toEqual([
{ booth: "booth-7", operatorRef: "ab12cd34ef56ab12", reviewed: 2, agree: 1, disagree: 1, unusable: 0 },
@@ -120,9 +120,21 @@ describe("review + export", () => {
const csv = await app.inject({ method: "GET", url: "/export/labels.csv", headers: { authorization: basic } });
expect(csv.statusCode).toBe(200);
const lines = csv.body.trim().split("\n");
expect(lines[0]).toBe("item,booth,path,label,operator_category,operator_classes,vision_class,vision_confidence,downgraded,at,reviewed_at");
expect(lines[0]).toBe("item,booth,kind,path,label,operator_category,operator_classes,vision_class,vision_confidence,downgraded,at,reviewed_at");
expect(lines).toHaveLength(3); // header + 2 usable labels; the unusable one is left out
expect(lines[1]).toContain('"item-1","booth-7","crops/booth-7/item-1.jpg","suv","Vetura","car|sedan|hatchback","suv"');
expect(lines[1]).toContain('"item-1","booth-7","wash","crops/booth-7/item-1.jpg","suv","Vetura","car|sedan|hatchback","suv"');
// An ENTRY sample: no order, no operator — accepted, reviewable, in the export, and
// never counted in any operator's agreement.
const entry = await ingest({ v: 1, kind: "entry", booth: "booth-7", item: "entry-1", at: "2026-09-06T11:00:00.000Z", vision: { class: "car", confidence: 0.7 }, image: { width: 300, height: 180, plateBlurred: true } });
expect(entry.statusCode).toBe(201);
expect((await ingest({ v: 1, kind: "entry", booth: "booth-7", item: "entry-2", at: "x", vision: { class: "car", confidence: 0.7 }, image: { width: 1, height: 1, plateBlurred: true } })).statusCode).toBe(422);
expect((await post("entry-1", "suv")).statusCode).toBe(200);
const stats2 = (await app.inject({ method: "GET", url: "/api/stats", headers: { authorization: basic } })).json();
expect(stats2.booths[0]).toEqual({ booth: "booth-7", received: 3, pending: 0, reviewed: 3, entries: 1 });
expect(stats2.operators.find((o: { booth: string }) => o.booth === "booth-7")).toMatchObject({ reviewed: 2, agree: 1, disagree: 1 });
const csv3 = (await app.inject({ method: "GET", url: "/export/labels.csv", headers: { authorization: basic } })).body;
expect(csv3).toContain('"entry-1","booth-7","entry","crops/booth-7/entry-1.jpg","suv","","","car"');
// A booth-supplied name that looks like a spreadsheet formula is neutralised in the export.
await ingest(meta({ item: "item-4", operatorCategory: { id: "x", name: "=HYPERLINK(\"http://evil\")", classes: ["car"] } }));
+31 -22
View File
@@ -20,15 +20,18 @@ import { reviewPage } from "./review-page.js";
/** The package's `meta` part, as the booth sends it (review-outbox.ts). */
interface IngestMeta {
v: number;
/** "wash" (default when absent) = a desk decision; "entry" = a sampled entry read with
* no order and no operator — crop + the camera's class only. */
kind?: "wash" | "entry";
booth: string;
item: string;
order: string;
order?: string;
at: string;
operator: string;
operatorCategory: { id: string; name: string; classes?: string[] };
service: string;
vision: { class: string; confidence: number; categoryId: string | null };
downgraded: boolean;
operator?: string;
operatorCategory?: { id: string; name: string; classes?: string[] };
service?: string;
vision: { class: string; confidence: number; categoryId?: string | null };
downgraded?: boolean;
image: { width: number; height: number; plateBlurred: boolean };
}
@@ -46,17 +49,21 @@ function checkMeta(m: unknown, booth: string): { ok: true; meta: IngestMeta } |
if (x.v !== 1) return { ok: false, why: "unsupported meta version" };
if (x.booth !== booth) return { ok: false, why: "meta.booth does not match the token's booth" };
if (!str(x.item, 64) || !ID_RE.test(x.item as string)) return { ok: false, why: "bad item id" };
if (!str(x.order, 64)) return { ok: false, why: "bad order ref" };
if (!str(x.at, 40) || Number.isNaN(Date.parse(x.at as string))) return { ok: false, why: "bad timestamp" };
if (!str(x.operator, 64)) return { ok: false, why: "bad operator ref" };
const oc = x.operatorCategory as Record<string, unknown> | undefined;
if (!oc || !str(oc.id, 64) || !str(oc.name, 120)) return { ok: false, why: "bad operatorCategory" };
if (oc.classes !== undefined && (!Array.isArray(oc.classes) || !oc.classes.every(isVehicleClass))) return { ok: false, why: "bad operatorCategory.classes" };
if (!str(x.service, 120)) return { ok: false, why: "bad service" };
const kind = x.kind === undefined ? "wash" : x.kind;
if (kind !== "wash" && kind !== "entry") return { ok: false, why: "bad kind" };
const v = x.vision as Record<string, unknown> | undefined;
if (!v || !isVehicleClass(v.class) || typeof v.confidence !== "number" || v.confidence < 0 || v.confidence > 1) return { ok: false, why: "bad vision read" };
if (v.categoryId != null && !str(v.categoryId, 64)) return { ok: false, why: "bad vision.categoryId" };
if (typeof x.downgraded !== "boolean") return { ok: false, why: "bad downgraded" };
if (kind === "wash") {
if (!str(x.order, 64)) return { ok: false, why: "bad order ref" };
if (!str(x.operator, 64)) return { ok: false, why: "bad operator ref" };
const oc = x.operatorCategory as Record<string, unknown> | undefined;
if (!oc || !str(oc.id, 64) || !str(oc.name, 120)) return { ok: false, why: "bad operatorCategory" };
if (oc.classes !== undefined && (!Array.isArray(oc.classes) || !oc.classes.every(isVehicleClass))) return { ok: false, why: "bad operatorCategory.classes" };
if (!str(x.service, 120)) return { ok: false, why: "bad service" };
if (typeof x.downgraded !== "boolean") return { ok: false, why: "bad downgraded" };
}
const im = x.image as Record<string, unknown> | undefined;
if (!im || typeof im.width !== "number" || typeof im.height !== "number" || typeof im.plateBlurred !== "boolean") return { ok: false, why: "bad image meta" };
return { ok: true, meta: x as unknown as IngestMeta };
@@ -148,16 +155,18 @@ export async function buildCollector(cfg: CollectorConfig, opts: { dbFile?: stri
const rel = path.posix.join("crops", booth, `${meta.item}.jpg`);
await mkdir(path.join(cfg.dataDir, "crops", booth), { recursive: true });
await writeFile(path.join(cfg.dataDir, rel), image);
const kind = meta.kind ?? "wash";
db.insert({
id: meta.item,
booth,
orderRef: meta.order,
kind,
orderRef: meta.order ?? "",
at: meta.at,
operatorRef: meta.operator,
operatorCategoryId: meta.operatorCategory.id,
operatorCategoryName: meta.operatorCategory.name,
operatorClasses: JSON.stringify(meta.operatorCategory.classes ?? []),
service: meta.service,
operatorRef: meta.operator ?? "",
operatorCategoryId: meta.operatorCategory?.id ?? "",
operatorCategoryName: meta.operatorCategory?.name ?? "",
operatorClasses: JSON.stringify(meta.operatorCategory?.classes ?? []),
service: meta.service ?? "",
visionClass: meta.vision.class,
visionConfidence: meta.vision.confidence,
visionCategoryId: meta.vision.categoryId ?? null,
@@ -168,7 +177,7 @@ export async function buildCollector(cfg: CollectorConfig, opts: { dbFile?: stri
imagePath: rel,
receivedAt: new Date().toISOString(),
});
req.log.info(`ingest: ${booth} item ${meta.item} (${meta.vision.class} → ${meta.operatorCategory.name})`);
req.log.info(`ingest: ${booth} ${kind} ${meta.item} (${meta.vision.class}${kind === "wash" ? ` → ${meta.operatorCategory!.name}` : ""})`);
return reply.code(201).send({ ok: true });
});
@@ -214,9 +223,9 @@ export async function buildCollector(cfg: CollectorConfig, opts: { dbFile?: stri
if (/^[=+\-@\t\r]/.test(v)) v = `'${v}`;
return `"${v.replace(/"/g, '""')}"`;
};
const head = "item,booth,path,label,operator_category,operator_classes,vision_class,vision_confidence,downgraded,at,reviewed_at";
const head = "item,booth,kind,path,label,operator_category,operator_classes,vision_class,vision_confidence,downgraded,at,reviewed_at";
const lines = rows.map((r) =>
[r.id, r.booth, r.imagePath, r.reviewLabel, r.operatorCategoryName, JSON.parse(r.operatorClasses).join("|"), r.visionClass, r.visionConfidence, r.downgraded, r.at, r.reviewedAt].map(q).join(","),
[r.id, r.booth, r.kind, r.imagePath, r.reviewLabel, r.operatorCategoryName, JSON.parse(r.operatorClasses).join("|"), r.visionClass, r.visionConfidence, r.downgraded, r.at, r.reviewedAt].map(q).join(","),
);
return reply.type("text/csv; charset=utf-8").header("content-disposition", 'attachment; filename="labels.csv"').send([head, ...lines].join("\n") + "\n");
});
+16 -9
View File
@@ -9,6 +9,9 @@ import type { VehicleClass } from "@parking/shared";
export interface ItemRow {
id: string;
booth: string;
/** "wash" = a desk decision (operator fields set); "entry" = a sampled entry read (pure
* training material: crop + the camera's class, operator fields empty). */
kind: "wash" | "entry";
orderRef: string;
at: string;
operatorRef: string;
@@ -44,11 +47,12 @@ export class CollectorDb {
CREATE TABLE IF NOT EXISTS items (
id TEXT PRIMARY KEY,
booth TEXT NOT NULL,
kind TEXT NOT NULL DEFAULT 'wash',
order_ref TEXT NOT NULL,
at TEXT NOT NULL,
operator_ref TEXT NOT NULL,
operator_category_id TEXT NOT NULL,
operator_category_name TEXT NOT NULL,
operator_ref TEXT NOT NULL DEFAULT '',
operator_category_id TEXT NOT NULL DEFAULT '',
operator_category_name TEXT NOT NULL DEFAULT '',
operator_classes TEXT NOT NULL DEFAULT '[]',
service TEXT NOT NULL,
vision_class TEXT NOT NULL,
@@ -77,6 +81,7 @@ export class CollectorDb {
return {
id: r.id as string,
booth: r.booth as string,
kind: r.kind === "entry" ? "entry" : "wash",
orderRef: r.order_ref as string,
at: r.at as string,
operatorRef: r.operator_ref as string,
@@ -107,10 +112,10 @@ export class CollectorDb {
insert(row: Omit<ItemRow, "reviewLabel" | "reviewedAt" | "reviewer">): void {
this.#db
.prepare(
`INSERT INTO items (id, booth, order_ref, at, operator_ref, operator_category_id, operator_category_name,
`INSERT INTO items (id, booth, kind, order_ref, at, operator_ref, operator_category_id, operator_category_name,
operator_classes, service, vision_class, vision_confidence, vision_category_id, downgraded,
image_width, image_height, plate_blurred, image_path, received_at)
VALUES (@id, @booth, @orderRef, @at, @operatorRef, @operatorCategoryId, @operatorCategoryName,
VALUES (@id, @booth, @kind, @orderRef, @at, @operatorRef, @operatorCategoryId, @operatorCategoryName,
@operatorClasses, @service, @visionClass, @visionConfidence, @visionCategoryId, @downgraded,
@imageWidth, @imageHeight, @plateBlurred, @imagePath, @receivedAt)`,
)
@@ -142,19 +147,21 @@ export class CollectorDb {
* reviewer's class fell inside the operator's chosen category (agree) or outside
* (disagree) — the honest-mistake / fraud rate the outbox exists for. */
stats(): {
booths: { booth: string; received: number; pending: number; reviewed: number }[];
booths: { booth: string; received: number; pending: number; reviewed: number; entries: number }[];
operators: { booth: string; operatorRef: string; reviewed: number; agree: number; disagree: number; unusable: number }[];
} {
const booths = this.#db
.prepare(
`SELECT booth, COUNT(*) AS received,
SUM(CASE WHEN reviewed_at IS NULL THEN 1 ELSE 0 END) AS pending,
SUM(CASE WHEN reviewed_at IS NOT NULL THEN 1 ELSE 0 END) AS reviewed
SUM(CASE WHEN reviewed_at IS NOT NULL THEN 1 ELSE 0 END) AS reviewed,
SUM(CASE WHEN kind = 'entry' THEN 1 ELSE 0 END) AS entries
FROM items GROUP BY booth ORDER BY booth`,
)
.all() as { booth: string; received: number; pending: number; reviewed: number }[];
.all() as { booth: string; received: number; pending: number; reviewed: number; entries: number }[];
// Operator agreement is a WASH thing — an entry sample has no operator decision.
const reviewed = this.#db
.prepare("SELECT booth, operator_ref, operator_classes, review_label FROM items WHERE reviewed_at IS NOT NULL")
.prepare("SELECT booth, operator_ref, operator_classes, review_label FROM items WHERE reviewed_at IS NOT NULL AND kind = 'wash'")
.all() as { booth: string; operator_ref: string; operator_classes: string; review_label: string }[];
const ops = new Map<string, { booth: string; operatorRef: string; reviewed: number; agree: number; disagree: number; unusable: number }>();
for (const r of reviewed) {
+6 -3
View File
@@ -80,10 +80,13 @@ async function next() {
el.innerHTML =
'<img src="/api/items/' + encodeURIComponent(it.id) + '/image" alt="">' +
'<dl style="margin-top:.8rem">' +
'<dt>operator chose</dt><dd><b>' + esc(it.operatorCategoryName) + '</b> <span class="muted">(' + esc(opClasses.join(', ') || 'no classes mapped') + ')</span></dd>' +
(it.kind === 'entry'
? '<dt>sample</dt><dd><span class="muted">entry stream — no wash, no operator decision; label the vehicle</span></dd>'
: '<dt>operator chose</dt><dd><b>' + esc(it.operatorCategoryName) + '</b> <span class="muted">(' + esc(opClasses.join(', ') || 'no classes mapped') + ')</span></dd>') +
'<dt>camera saw</dt><dd class="mono">' + esc(it.visionClass) + ' <span class="muted">' + Math.round(it.visionConfidence * 100) + '%</span>' + (it.downgraded ? ' <span class="warn">flagged downgrade at the booth</span>' : '') + '</dd>' +
'<dt>service</dt><dd>' + esc(it.service) + '</dd>' +
'<dt>booth · operator</dt><dd class="mono">' + esc(it.booth) + ' · ' + esc(it.operatorRef) + '</dd>' +
(it.kind === 'entry' ? '<dt>booth</dt><dd class="mono">' + esc(it.booth) + '</dd>' :
'<dt>service</dt><dd>' + esc(it.service) + '</dd>' +
'<dt>booth · operator</dt><dd class="mono">' + esc(it.booth) + ' · ' + esc(it.operatorRef) + '</dd>') +
'<dt>at</dt><dd>' + esc(it.at) + '</dd>' +
'</dl>' +
'<div class="buttons" style="margin-top:.8rem">' +