The operator's category choice is a hypothesis, not truth (user, 2026-09-06): each wash
order with a vehicle read queues a package for a trusted reviewer over the private overlay
(Netbird); the verdict becomes the phase-B training label and the per-operator error rate.
wiki/concepts/vision-review-outbox.md.
- Boxes: the vision service returns the vehicle bbox; snapshot.ts stores the vehicle and
plate boxes on the read as FRACTIONS of the analysed frame (the stored snapshot is a
downscaled copy); vehicleForIdentity() returns them.
- carwash_review_outbox (migration 0031) + review-outbox.ts: crop = detector box + 8 %
margin, ≤ 640 px, plate blurred in place from the plate box; payload carries a
pseudonymous booth id and a keyed operator hash — no site name, no plate, no OSD, no
bystanders; multipart POST with a per-booth bearer; 2xx → sent (image dropped);
400/404/413/415/422 → abandoned; anything else → backoff 1 min·2^n capped 6 h; voided
orders and items older than 14 days abandoned unsent. Nothing queued while unconfigured.
- Enqueue is fire-and-forget off the intake path in createOrder; the loop runs every
CARWASH_REVIEW_INTERVAL_SEC (60) and stops on close.
- GET /api/carwash/review/status (site:read) + a "Remote review" line in Setup → Car wash.
- Env CARWASH_REVIEW_URL / _TOKEN / _BOOTH_ID (all three or off) documented in
.env.example and forwarded by compose.
- Tests: review-outbox.test.ts (crop + blur on a synthetic frame, config/pseudonyms,
queue/drain/backoff/abandon, through the app). Wiki: new concept page, index,
venue-modules As built, log. The collector is not built.
Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
Fills /analyze vehicle.body_type + confidence (car / motorcycle / bus / truck from COCO,
mapped to the shared vocabulary) for the Car Wash desk's category suggestion
(venue-modules.md §Vehicle category from vision). Advisory: the operator decides, a
confident downgrade is flagged, nothing is gated on it.
- vision_service/vehicle.py: pure numpy/cv2 letterbox (pad 114, raw BGR), stride-grid
decode, class-agnostic NMS, one vehicle per frame (the box holding the plate's centre,
else the largest); YoloxVehicleDetector on onnxruntime CPU, 2 intra-op threads.
- recognizer.py: WithVehicle composes the stage over any plate recognizer (stub included);
a failing stage yields vehicle=null + a "vehicle: …" note in /health.detail — never
costs the plate read. model_version reads "<plate>+yolox:yolox_s.onnx@640".
- settings: VISION_VEHICLE_MODEL_PATH (unset = off), _INPUT_SIZE (640), _MIN_CONFIDENCE
(0.4, the detector's floor; the flag threshold is site config).
- Dockerfile bakes yolox_s.onnx (best-effort curl at build; no network → stage off) and
sets the path; compose forwards it (empty = off); .env.example documents it.
- Measured on four real dev entry frames (DS-2CD1047G3H, 2560×1440): car at 0.83–0.88 in
~240–330 ms; empty lane with a person → none.
- tests/test_vehicle.py: decode/NMS/pick/letterbox on synthetic tensors, the composition,
and a missing-model /health. Wiki: opencv-anpr-service, venue-modules, log.
Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
The app plumbing for venue-modules.md §"Vehicle category from vision"; the model is the
open half (no bundled recognizer emits body_type yet, so the desk shows nothing until
phase A lands in the vision service).
- Shared: VEHICLE_CLASSES vocabulary, VehicleRead, CARWASH_VISION_THRESHOLD_DEFAULT,
reason code carwash.categoryDowngrade; settings/order/lookup views carry the read.
- Vision contract: /analyze vehicle.body_type + confidence (service schema); the Node
client normalises to the vocabulary and drops the rest.
- Record: snapshot.ts stores the read in the plate's device_events row (or its own when
the plate was unreadable); vehicleForIdentity() resolves it like the plate.
- Car wash: carwash_categories.vision_classes (site mapping "car, sedan → Vetura"),
carwash_config.vision_threshold (signed config_change when it moves), four vision
columns on orders — migration 0030. Lookup returns vision + suggestedCategoryId.
- Desk pre-selects the mapped category and shows the read + snapshot thumbnail; Setup
offers class chips per category and the threshold. Operator decides.
- Flag: a read at/above the threshold whose mapped category prices HIGHER than the chosen
one signs one `anomaly` (both categories/prices, operator, snapshot) and stores its id on
the order. Equal/upgrade/unsure/unmapped → nothing. Recorded only, never blocks, no
reason prompt (user, 2026-09-06).
Tests in carwash.test.ts; wiki venue-modules (As built), opencv-anpr-service, log.
Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
The dev box runs vision as bare `uv run uvicorn`, and a plain uv run/uv sync
re-resolves the venv to the lockfile DEFAULTS, stripping fast-alpr/onnxruntime.
So after any `pnpm dev` real ANPR silently degraded to "snapshot, no plate"
(diagnosed 2026-06-25: real reads through 06-22, venv frozen lean since 06-19,
no other env with fast_alpr). The BOOTH was never affected — it runs the Docker
image, which bakes `uv sync --frozen --extra alpr` at build (immutable, weights
pre-warmed); a booth ModuleNotFoundError is a STALE image (fix: booth.sh update).
Vision package.json dev/start/recognize now run `uv sync --extra alpr &&` first
so pnpm dev is self-healing; added a dev:stub escape hatch for a lean run.
Documented in wiki/decisions/vision-service-packaging.md ("Two runtimes, one
fragile") + a log entry.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
Containerize the two non-desktop apps for the booth appliance. The desktop app stays
on its own tag-only release.yml.
- apps/server/Dockerfile: multi-stage node:22-alpine. `pnpm deploy --legacy --prod`
(NOT prune — the monorepo native better-sqlite3 won't resolve under a root prune)
yields a self-contained bundle; build stage adds node-gyp toolchain, runtime adds
libstdc++; non-root, healthcheck. Migrates the mounted DB on boot via a drizzle-kit-
free runtime migrator (packages/db/scripts/migrate-runtime.mjs) — drizzle-kit is a
devDep, pruned from prod.
- apps/server/src/static-spa.ts: Fastify serves the built React SPA (one container
serves API + UI). GET-only fallback to index.html, excludes /api + /health so it never
shadows the backend; a no-op in dev (no dist). Registered last in server.ts.
- apps/vision/Dockerfile: uv base, --extra alpr, model weights PRE-WARMED into the image
as the runtime user so fast_alpr boots offline (0 downloads at runtime). Engine env-
selected (VISION_RECOGNIZER stub|fast_alpr).
- Branch-aware: docker-compose.yml (base) + .dev.yml (build local, stub, ports) +
.prod.yml (pull pinned, fast_alpr, vision internal, restart always); REGISTRY/TAG from
env so a branch deploy pulls that branch's image.
- .gitea/workflows/build-images.yml: on push to dev/main, run the full turbo build+lint+
test gate, then buildx push both images to git.infra.msai.al/mca/parking_solution with
branch + branch-<sha> tags (registry cache; optional Komodo webhook behind KOMODO_ENABLED).
- .dockerignore excludes **/parking.sqlite* so the signed ledger is NEVER baked.
Verified locally (Docker 29): server image migrates + serves API+SPA (/health 200, /
+ /booth HTML, /api/nope JSON 404, no sqlite outside /data); vision image boots fast_alpr
with 0 runtime downloads; compose stack healthy with server→vision over the private network.
Wiki: new container-deployment.md; vision-service-packaging open Qs resolved; index + log.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
The two failing apps/vision smoke tests assumed stub mode but the local .env sets
VISION_RECOGNIZER=fast_alpr (real-model work, 2026-06-19), so the app built the real
recognizer: /health reported "fast_alpr" not "stub", and /analyze on garbage bytes
422'd (real decode reject) instead of returning the empty stub contract.
Fix is test isolation: a conftest autouse fixture pins VISION_RECOGNIZER=stub for the
session (an OS env var overrides the .env in pydantic-settings), restoring it after.
vision 7/7.
Updates wiki/concepts/booth-console.md (the "no automated tests" Open note now reflects
the coverage that landed) and appends wiki/log.md.
Full workspace: shared 87, server 75, devices 18, web 17, vision 7 = 204 tests across
8 turbo test tasks, 0 failures; build/lint 14/14.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
Make the vision service genuinely configurable (was env-only).
- SetupWizard: an "ANPR" checkbox on the camera form (writes config.anpr; persisted
only when on; sq+en) — opt-in is no longer raw JSON.
- DeviceMonitor optionally takes the VisionClient and probes /health each tick, emitting
a "vision" pseudo-device → a Vision chip (ready/degraded/offline + recognizer) in the
booth footer when VISION_ENABLED, no chip when off. Widened the DeviceStatus category
union (server + web) + footer maps + devices.catVision. Verified: ready/fast_alpr when
up, 0 chips when disabled.
- apps/vision/.env.example (Python service) + a VISION_* block in apps/server/.env.example
(Node side) + a Configuration section in opencv-anpr-service.md covering all four
layers and the caveats: the two processes share the VISION_ prefix but need SEPARATE
.env files; bind /analyze to 127.0.0.1; cache model weights at deploy; an unbound anpr
camera recognizes but every read is refused.
Build + lint green.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
Add a dev CLI (uv run python -m vision_service.cli <image>) that runs a recognizer on
an image file and prints the parsed plate(s) + confidence + region — fast feedback with
no HTTP. Also a package.json `recognize` script and a vision-recognize entry point.
Verified fast-alpr for real: installed the `alpr` extra, downloaded the YOLOv9 + CCT
ONNX weights (~11MB, cached offline under ~/.cache), and ran recognition on the
project's test image → "5AU5341" at 1.000 confidence, region "Czech Republic", ~40ms
on CPU, via both the CLI and POST /analyze.
Fixes result parsing against the actual fast-alpr API: ocr.confidence is a LIST of
per-character confidences (not a scalar) — reduced to one plate confidence via the MIN
(a plate is only as trustworthy as its weakest character); also surface ocr.region.
Extracted the per-result mapping into a pure plate_from_alpr_result + _reduce_confidence
and unit-tested them (no model weights needed). 7 tests pass; ruff + mypy strict clean;
full turbo build/lint/test green.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
Skeleton of the host-side vision service per the packaging decision: a Python/FastAPI
app at apps/vision/, uv-managed, wired into the Turbo graph via a thin package.json
shim (dev/lint/test/build → uv/uvicorn/ruff/pytest). A per-package turbo.json sets
build outputs [] so the no-op build is warning-free.
Endpoints: GET /health (readiness + model version) and POST /analyze (raw
octet-stream body, so Node POSTs Snapshot.bytes directly; empty→400, oversize→413,
recognizer-not-ready→503). The recognizer is a Protocol with a StubRecognizer (no
models, boots/tests offline — the dev/CI default) and a FastAlprRecognizer (the real
MIT YOLOv9+CCT/ONNX stack, lazily imported; missing models ⇒ ready=False, not a crash)
— the device-adapter pattern applied to the model. fast-alpr + onnxruntime are an
optional `alpr` extra, so `uv sync` needs no model download.
Verified: turbo run lint|test|build includes @parking/vision and stays green; uv run
mypy strict-clean; uvicorn boots and serves /health + /analyze live; pnpm workspace
6→7. Not built yet: the Node VisionClient adapter, a Dockerfile + model fetch, and
Job 2 (vehicle verification). Updates the packaging decision (As-scaffolded) + log.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V