dbbb051ebd
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
187 lines
7.8 KiB
TypeScript
187 lines
7.8 KiB
TypeScript
import Database from "better-sqlite3";
|
|
import type { VehicleClass } from "@parking/shared";
|
|
|
|
// One table. Each row is one booth decision: what the camera saw, what the operator
|
|
// chose, and (once reviewed) what a trusted person says the vehicle is. The crop itself
|
|
// lives on disk beside the DB (crops/<booth>/<item>.jpg) so the trainer on the same host
|
|
// reads it straight off the volume.
|
|
|
|
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;
|
|
operatorCategoryId: string;
|
|
operatorCategoryName: string;
|
|
/** The vision classes the operator's category covers at that site (its mapping) — what
|
|
* lets a reviewer's CLASS be compared with an operator's CATEGORY. JSON array. */
|
|
operatorClasses: string;
|
|
service: string;
|
|
visionClass: string;
|
|
visionConfidence: number;
|
|
visionCategoryId: string | null;
|
|
downgraded: number;
|
|
imageWidth: number;
|
|
imageHeight: number;
|
|
plateBlurred: number;
|
|
imagePath: string;
|
|
receivedAt: string;
|
|
reviewLabel: string | null; // a VehicleClass, or "unusable"
|
|
reviewedAt: string | null;
|
|
reviewer: string | null;
|
|
}
|
|
|
|
export type ReviewVerdict = VehicleClass | "unusable";
|
|
|
|
export class CollectorDb {
|
|
readonly #db: Database.Database;
|
|
|
|
constructor(file: string) {
|
|
this.#db = new Database(file);
|
|
this.#db.pragma("journal_mode = WAL");
|
|
this.#db.exec(`
|
|
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 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,
|
|
vision_confidence REAL NOT NULL,
|
|
vision_category_id TEXT,
|
|
downgraded INTEGER NOT NULL DEFAULT 0,
|
|
image_width INTEGER NOT NULL,
|
|
image_height INTEGER NOT NULL,
|
|
plate_blurred INTEGER NOT NULL,
|
|
image_path TEXT NOT NULL,
|
|
received_at TEXT NOT NULL,
|
|
review_label TEXT,
|
|
reviewed_at TEXT,
|
|
reviewer TEXT
|
|
);
|
|
CREATE INDEX IF NOT EXISTS items_pending ON items (reviewed_at, received_at);
|
|
CREATE INDEX IF NOT EXISTS items_booth ON items (booth, received_at);
|
|
`);
|
|
}
|
|
|
|
close(): void {
|
|
this.#db.close();
|
|
}
|
|
|
|
static #map(r: Record<string, unknown>): ItemRow {
|
|
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,
|
|
operatorCategoryId: r.operator_category_id as string,
|
|
operatorCategoryName: r.operator_category_name as string,
|
|
operatorClasses: r.operator_classes as string,
|
|
service: r.service as string,
|
|
visionClass: r.vision_class as string,
|
|
visionConfidence: r.vision_confidence as number,
|
|
visionCategoryId: (r.vision_category_id as string | null) ?? null,
|
|
downgraded: r.downgraded as number,
|
|
imageWidth: r.image_width as number,
|
|
imageHeight: r.image_height as number,
|
|
plateBlurred: r.plate_blurred as number,
|
|
imagePath: r.image_path as string,
|
|
receivedAt: r.received_at as string,
|
|
reviewLabel: (r.review_label as string | null) ?? null,
|
|
reviewedAt: (r.reviewed_at as string | null) ?? null,
|
|
reviewer: (r.reviewer as string | null) ?? null,
|
|
};
|
|
}
|
|
|
|
get(id: string): ItemRow | null {
|
|
const r = this.#db.prepare("SELECT * FROM items WHERE id = ?").get(id) as Record<string, unknown> | undefined;
|
|
return r ? CollectorDb.#map(r) : null;
|
|
}
|
|
|
|
insert(row: Omit<ItemRow, "reviewLabel" | "reviewedAt" | "reviewer">): void {
|
|
this.#db
|
|
.prepare(
|
|
`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, @kind, @orderRef, @at, @operatorRef, @operatorCategoryId, @operatorCategoryName,
|
|
@operatorClasses, @service, @visionClass, @visionConfidence, @visionCategoryId, @downgraded,
|
|
@imageWidth, @imageHeight, @plateBlurred, @imagePath, @receivedAt)`,
|
|
)
|
|
.run(row);
|
|
}
|
|
|
|
list(status: "pending" | "reviewed", limit: number, booth?: string): ItemRow[] {
|
|
const where = [status === "pending" ? "reviewed_at IS NULL" : "reviewed_at IS NOT NULL"];
|
|
const params: unknown[] = [];
|
|
if (booth) {
|
|
where.push("booth = ?");
|
|
params.push(booth);
|
|
}
|
|
const order = status === "pending" ? "received_at ASC" : "reviewed_at DESC";
|
|
const rows = this.#db
|
|
.prepare(`SELECT * FROM items WHERE ${where.join(" AND ")} ORDER BY ${order} LIMIT ?`)
|
|
.all(...params, limit) as Record<string, unknown>[];
|
|
return rows.map((r) => CollectorDb.#map(r));
|
|
}
|
|
|
|
review(id: string, label: ReviewVerdict, reviewer: string): ItemRow | null {
|
|
this.#db
|
|
.prepare("UPDATE items SET review_label = ?, reviewed_at = ?, reviewer = ? WHERE id = ?")
|
|
.run(label, new Date().toISOString(), reviewer, id);
|
|
return this.get(id);
|
|
}
|
|
|
|
/** Per booth: received / pending / reviewed. Per operator (booth + hash): how often the
|
|
* 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; 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 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; 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 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) {
|
|
const key = `${r.booth} ${r.operator_ref}`;
|
|
let o = ops.get(key);
|
|
if (!o) ops.set(key, (o = { booth: r.booth, operatorRef: r.operator_ref, reviewed: 0, agree: 0, disagree: 0, unusable: 0 }));
|
|
o.reviewed += 1;
|
|
if (r.review_label === "unusable") o.unusable += 1;
|
|
else if ((JSON.parse(r.operator_classes) as string[]).includes(r.review_label)) o.agree += 1;
|
|
else o.disagree += 1;
|
|
}
|
|
return { booths, operators: [...ops.values()].sort((a, b) => b.disagree - a.disagree) };
|
|
}
|
|
|
|
/** Reviewed, usable rows — the training set. */
|
|
labelled(): ItemRow[] {
|
|
const rows = this.#db
|
|
.prepare("SELECT * FROM items WHERE reviewed_at IS NOT NULL AND review_label != 'unusable' ORDER BY reviewed_at")
|
|
.all() as Record<string, unknown>[];
|
|
return rows.map((r) => CollectorDb.#map(r));
|
|
}
|
|
}
|