Files
parking_solution/wiki/concepts/vision-review-outbox.md
T
julian f7a262ac9a
Build & push images / images (push) Successful in 6m31s
feat(trainer): phase-B body-type classifier — trainer job on the collector host + the classifier stage on the booth
apps/trainer (parking-trainer): inspect / train / evaluate / publish. Reads the wash
collector's SQLite + crops read-only off its volume; time split (validation = newest
slice); thin classes dropped; damped class weights; `features` mode (frozen ImageNet
backbone, on-disk feature cache, seconds to retrain) and `finetune` mode (light
augmentation). CPU-only torch from PyTorch's wheel index. ONNX export checked against
the torch model; NO model file below the validation floor (exit 3, report still written);
exit 2 = not enough labels. `evaluate` scores a shipped model on labels reviewed after
training + the unlabelled pile; `publish` PUTs a version folder to a Gitea generic package.
Light core deps; the `train` extra is heavy — CI syncs without it, torch tests skip.

apps/vision: BodyTypeClassifier (bodytype.onnx + sidecar = the preprocessing contract:
crop margin, input size, RGB 0-255, normalisation inside the graph) and
RefinedVehicleDetector over YOLOX — refines only `car` or a class the classifier trained
on, min-confidence, `detector_class` on the result; path set but no file = phase B off
without an error; a broken file is a health detail. models/bodytype.version (tracked,
empty) pins the published version the Dockerfile fetches at build (BuildKit secret;
a pin that cannot be fetched fails the build). Verified: a trainer model gives identical
probabilities inside the vision service; both images built and smoke-tested.

Delivery: parking-trainer image in build-images.yml, the `trainer` compose profile on the
collector stack (CPU, read-only data, TRAINER_OUT), commented TRAINER_OUT/PUBLISH_TOKEN in
the wash-collector stack, .dockerignore for both Python contexts, trainer deps synced in CI.

Wiki: bodytype-classifier-training rewritten as built (+ one fleet model not per site,
secrets/access, where the crops live), opencv-anpr-service §Phase B, vision-review-outbox,
vision-service-packaging, fleet-deployment-komodo, index, log.

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-07 11:14:50 +02:00

11 KiB
Raw Blame History

title, type, status, related
title type status related
Vision review outbox — harvesting the operator's category choice for a trusted reviewer concept booth side built 2026-09-06; collector pending
venue-modules
opencv-anpr-service
threat-model
append-only-event-chain
network-isolation

Vision review outbox

The idea (user, 2026-09-06). The Car Wash desk asks the operator for the vehicle's category, and the entry camera now proposes one (venue-modules §Vehicle category from vision). The operator's choice is what we would love to train the body-type classifier on — but the operator cannot be fully trusted (mistake or intent; the threat-model). So the booth hands each decision to a trusted party who reviews the picture and the label remotely, and that verdict is the training label — and, per operator, the honest-mistake / fraud rate. The booths sit on a private zero-trust overlay (Netbird), so the hand-off can go to a very locked-down collector without exposing anything to the open internet.

Rules (all enforced in apps/server/src/modules/carwash/review-outbox.ts)

  1. Offline-first, never on the intake path. Creating a wash order queues a package (fire and forget — a failure is a log line); a background loop drains the queue when the overlay is up. The wash never waits on the network.
  2. One-way. The booth POSTs; nothing ever comes back into the booth's decisions. The signed ledger (append-only-event-chain) stays the only record of what happened at the wash. Reviewer verdicts stay central and reach the owner as a report per site.
  3. Nothing that names the site leaves the booth.
    • Only the vehicle crop (the detector's box + 8 % margin, ≤ 640 px) — no walls, no camera OSD (date / camera name burned into the frame), no bystanders.
    • The plate is blurred inside the crop on the booth, from the plate detector's own box.
    • The booth is a pseudonymous id set at deploy (CARWASH_REVIEW_BOOTH_ID); the operator is a keyed hash (sha256(boothId:username)[:16]). The mapping back to places and people is the reviewer's, held off the collector. The dataset export drops even those.
    • Boxes are stored as fractions of the frame on the vision read, so the crop is cut from the stored (downscaled) snapshot copy.
  4. 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. Payloads are small (a crop ≈ 50–80 kB).
  5. Data minimisation. Queued only when there is a vehicle box (no box = no sample); the image is dropped from the row once delivered; a voided order is abandoned unsent; anything older than 14 days is abandoned ("expired") rather than resurfacing a fortnight in a burst.

The entry stream — the real accelerator (built 2026-09-07)

The wash stream is small; the entry camera photographs every car, in exactly the view the classifier is trained on, with zero domain shift. So the booth can also queue one in N entry vehicle reads as pure training material: the crop and the camera's class, no order, no operator, no category — same crop-and-blur pipeline, same one-way path, same privacy properties. CARWASH_REVIEW_ENTRY_SAMPLE=N = one in N entries; 1 = every entry, and that is the setting park-2 runs (user, 2026-09-07: 4 TB on the collector host, bandwidth not an issue — the only limit was ever the reviewer's time; the reviewer labels what they have time for, the rest waits and stays useful once a first model exists, as the unlabelled pile it is measured on). 0/unset = off; needs the three upload settings. A washed car arrives twice, as an entry sample and as the wash decision — intended, the kind keeps them apart. Seam: the core announces every vehicle read (deviceEvents.emitVehicleRead, snapshot.ts, entry and exit) and the Car Wash module decides — it samples entry reads in-process (sampleEntry(), exactly one in N) and calls enqueueEntry(); the core never imports the module. Packages carry kind: "wash" | "entry"; the collector stores the kind, the review screen shows an entry sample as "entry stream — label the vehicle", the export carries a kind column, and operator agreement is computed from wash items only (an entry sample has no operator decision).

An internet feed was considered the same day and kept OUT of the collector's ingest: licensed sets only, in a separate folder with provenance, used as warm-up and weighted down, and never the judge of accuracy — the evaluation set is gate crops only.

The package

multipart/form-data: meta (JSON) + image (JPEG). Meta = { v, booth, item, order, at, operator (hash), operatorCategory {id,name}, service, vision {class, confidence, categoryId}, downgraded, image {width, height, plateBlurred} }. Headers: Authorization: Bearer <token>, X-Booth-Id.

Draining

Every CARWASH_REVIEW_INTERVAL_SEC (60): due items oldest-first, 20 per pass. 2xx → sent (image cleared). 400/404/413/415/422 → abandoned (the collector refused the package itself). Anything else (auth not yet fixed, 429, 5xx, timeout, no route) → retry with backoff 1 min · 2^attempts, capped at 6 h. GET /api/carwash/review/status (site:read) and a line in Setup → Car wash show queued / delivered / abandoned + the last error.

Config

CARWASH_REVIEW_URL, CARWASH_REVIEW_TOKEN, CARWASH_REVIEW_BOOTH_ID — all three or the outbox is off and nothing is queued (an unbounded queue nobody drains is worse than none). Set per booth in the Komodo stack env; compose forwards them.

  • URL by Netbird DNS name (http://docker-station.nb.infra:8090/ingest): the server container runs on the host network in prod, so it uses the booth's resolver and Netbird's DNS answers *.nb.infra; a collector that moves address costs no booth change. A failed lookup behaves like a collector outage (defer, backoff). The collector's own COLLECTOR_BIND must be the raw overlay IP — Docker port bindings take no hostname.
  • Secrets: one per booth, two consumers. wash_review_token_booth_2 is referenced by the booth's stack as its CARWASH_REVIEW_TOKEN and by the collector's stack inside COLLECTOR_BOOTH_TOKENS=booth-2:[[wash_review_token_booth_2]],booth-3:[[…]] — one value, nothing to keep in sync, rotating a booth touches one secret. (A first cut had one combined secret for the whole list; replaced the same day — rotation was all-or-nothing and the value lived twice.) Token format: opaque, openssl rand -hex 32; the collector only demands ≥ 16 chars and the list splits on commas/whitespace, which hex never contains. Never share a token between booths — it is what names the booth. Total Komodo secrets for one booth + the collector: two (the booth's token, the reviewer's password).
  • The operator hash needs no variable: sha256(boothId + ":" + username)[:16], computed on the booth from values already set; the owner recomputes it from the booth's usernames to map a hash back, the collector never can.

The collector — skeleton built 2026-09-06 (apps/collector)

A deliberately small Fastify + SQLite service in this monorepo (so it imports the payload contract and the class vocabulary from @parking/shared — the two ends cannot drift), delivered to the reviewer's host by its own Komodo stack (wash-collector in komodo/resources.toml → docker-compose.collector.yml only; the booth stacks never see it and it never sees booth services). Image parking-collector:<branch>-<sha> from the same workflow as the others. Three surfaces, nothing else — it must not grow into a fleet console:

  • POST /ingest — bearer token per booth (COLLECTOR_BOOTH_TOKENS, boothId:token pairs; constant-time compare), X-Booth-Id must match the token's booth, multipart meta + image (JPEG magic checked, 2 MB cap), meta validated field by field against the contract above (unknown vision class, non-id item, wrong booth → 422), idempotent on the item id (a retry after a lost 2xx → 200 duplicate). Stored: crops/<booth>/<item>.jpg on the volume + one items row. The booth now also sends operatorCategory.classes (the classes the chosen category covers at that site) so a reviewer's CLASS can be judged against the operator's CATEGORY without the site's setup.
  • /review (+ /api/items, /api/items/:id/image, /api/items/:id/review, /api/stats) — the reviewer's screen, served by the process itself (no build, no framework): one pending crop at a time, the operator's pick and the camera's pick beside it, one button (and one key) per vocabulary class + unusable + skip. HTTP Basic, one login (COLLECTOR_REVIEWER_USER/PASS), over the overlay. Stats: per booth received / pending / reviewed; per operator (booth + hash) agree / disagree / unusable — disagree = the reviewer's class is outside the operator's chosen category. That column is the honest-mistake / fraud rate.
  • GET /export/labels.csv — reviewed, usable rows: item, booth, crop path, the reviewer's label, the operator's category + classes, the camera's class + confidence, downgraded, at. Crops are not packaged: the phase-B trainer runs on the same host and reads the SQLite
    • crops straight off the volume, read-only (bodytype-classifier-training: CPU-only, the Xeon is enough) — docker-compose.collector.yml carries it as the trainer service under profiles: ["train"], a one-off job never started by a deploy (built 2026-09-07; the CSV export stays for a human with a spreadsheet).

Where the data lives. The collector writes to /data in its container: collector.sqlite and one JPEG per item at crops/<booth-id>/<item-id>.jpg. /data is the named Docker volume collector-data (compose), on the host under Docker's volume directory — normally /var/lib/docker/volumes/wash-collector_collector-data/_data/ (docker volume inspect wash-collector_collector-data confirms). The trainer mounts the same volume read-only at its own /data; nothing is copied or exported for training.

Deploy notes. Bind the published port to the host's Netbird address (COLLECTOR_BIND), never 0.0.0.0 on a host with a public interface; Netbird policy: booths → this host:8090 and nothing else. The host must be onboarded as a Komodo server like the booths. TAG is pinned and promoted with the booths (one sha for all stacks) — fine while the collector stays small; its own repo the day it needs its own cadence. Deploy the collector BEFORE a booth that sends a package kind it does not know (a 422 is abandoned, not retried). The export neutralises cells that start like a spreadsheet formula (category/service names are booth-supplied text).

Status (2026-09-07). Live: the collector runs on art-docker-station and park-2 is wired to it (stage-dbbb051 on both stacks, every entry sampled). The review screen at http://docker-station.nb.infra:8090/review is filling; no labels reviewed yet.