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
+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");
});