feat(carwash): entry-stream sampling for the review outbox; park-2 wired to the collector
Build & push images / images (push) Successful in 4m22s
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:
@@ -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
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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">' +
|
||||
|
||||
Reference in New Issue
Block a user