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
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
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