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
10 KiB
type, tags, sources, updated, status
| type | tags | sources | updated | status | ||||||
|---|---|---|---|---|---|---|---|---|---|---|
| decision |
|
2026-06-25 | settled |
Decision: the vision service lives in this monorepo (apps/vision/), wired into Turbo via a shim
Taken 2026-06-19, when planning how to implement the host-side [[opencv-anpr-service|vision service]] decided in vision-service. That decision settled WHAT (a separate localhost Python process) and the recognizer baseline (opencv-anpr-service); this one settles WHERE the source lives and how it joins the build.
Decision
- In THIS monorepo, at
apps/vision/— a Python/FastAPI service co-located with the Node backend, not a separate repository. One git history, atomic cross-cutting commits (the/analyzecontract + the Node-side adapter change together), one wiki. - Still a separate OS process — co-location is source-level only. It runs as its own process
(
uvicorn), called over localhost HTTP by the Node backend, with its own failure domain. Nothing about putting it inapps/vision/weakens the runtime isolation vision-service requires. - Wired into the Turbo task graph via a thin
package.jsonshim.pnpm-workspace.yamlalready globsapps/*, so anapps/vision/package.jsonauto-joins the workspace. Itsscriptsshell out to Python tooling, so the existingturbo runtasks cover it:dev→uv run uvicorn app:app --reload(matchesturbo.jsondev: persistent, uncached)lint→ruff check·test→pytest·typecheck→ruff/mypybuild→ no-op or model-fetch (Python has nodist/**; thebuildtask'soutputs: ["dist/**"]simply won't match — fine). If models are fetched/cached at build, point outputs at the model dir. Python dependencies stay managed byuv+pyproject.toml(NOT pnpm) — the shim only exposes tasks, not deps.
- Node talks to it through an interface (
VisionClientbehind a port, the device-adapter-pattern style) so the recognizer/service is swappable without touching business logic — as opencv-anpr-service already specifies.
Why co-located beats a separate repo
- Atomic changes. The service contract (
POST /analyzeshape) and its Node consumer evolve together; one repo = one PR, no two-repo version skew. uvmakes Python-in-monorepo painless — fast, lockfile-based, offline-friendly (fits offline-first); the appliance build pulls a pinned env.- Turbo still orchestrates it. The shim makes
turbo run lint/testinclude the Python service as a first-class node — one command lints front, back, AND vision — even though Turbo can't build Python. Turbo orchestrates tasks, and a task can be a Python command. - One knowledge base. The wiki + CLAUDE.md already describe the whole system; a split repo fragments that.
Why this still honors the isolation decision
The "vision-service" decision is about runtime isolation (own process +
failure domain) and license isolation (AGPL obligations don't reach the Node/React code because
it is not linked — it's a separate program over HTTP). Neither depends on a separate
repository. AGPL's reach is a linking/distribution-boundary question between programs, not a
which-folder question. A Python service in apps/vision/ that Node calls over localhost is exactly as
isolated, license-wise, as one in its own repo.
- With the opencv-anpr-service MIT-end-to-end baseline, the AGPL pressure to split the repo out largely evaporates (pending the weight-provenance caveat). Co-location is the low-friction default.
- If a true-AGPL model (Ultralytics YOLO) is later adopted, its weights live under
apps/vision/— still fine (separate process), and that dir is the natural place to document the license boundary + the[[standing-decisions|scoped exception]].
Rejected
- Separate repo — strongest separation, but loses atomic contract changes and adds coordination overhead; justified only if a different team owns it or the AGPL concern becomes acute. Kept as the fallback if either happens.
- Embed Python in the Node process (opencv4nodejs / a child-process module) — already rejected by vision-service (native-build pain, no process isolation, shares the app's failure + license surface). Unchanged.
- A Python package under
packages/—packages/is for shared JS libraries imported by other workspaces; the vision service is a deployable app, soapps/vision/is the right bucket.
As-scaffolded (2026-06-19)
The skeleton is built and wired (no recognizer models yet):
apps/vision/—pyproject.toml(+uv.lock, uv-managed), the thinpackage.jsonshim, a per-packageturbo.json(extends: ["//"],buildoutputs[]so the no-op build is warning- free),.gitignore(venv/caches/*.onnx/models/out),README.vision_service/:app.py(FastAPIGET /health+POST /analyze, raw octet-stream body so Node POSTsSnapshot.bytesdirectly; oversize→413, empty→400, recognizer-not-ready→503),settings.py(envVISION_*),schemas.py(the/analyzecontract incl. a not-yet-populatedvehiclefield for Job 2),recognizer.py(aRecognizerProtocol +StubRecognizerandFastAlprRecognizer— the device-adapter-pattern applied to the model).- CI runs WITHOUT the extra (
uv sync --frozenin ci.yml and build-images.yml): a test that imports numpy/cv2 at module level breaks collection there even though it passes in a local venv that hasalpr. Rule (2026-09-07, after three red runs): pure post-processing tests get numpy from the dev group; anything needing OpenCV usespytest.importorskip("cv2"); the service itself imports both lazily inside functions. - The same pattern, second package (2026-09-07):
apps/trainer(@parking/trainer, bodytype-classifier-training) — light core (numpy, opencv-headless, onnxruntime) + atrainextra (CPU-only torch/torchvision/onnx/onnxscript from PyTorch's wheel index viatool.uv.index); CI syncs without it, torch testsimportorskip("torch"), the module that imports torch is imported lazily by thetraincommand only. Its own image (parking-trainer, contextapps/trainer, uv base image, bakes--extra train+ the ImageNet backbone weights) is built by build-images.yml beside the other three; both Python contexts now carry a.dockerignore(venv/caches/weights out). Workspace count 7→8. - Light-core, heavy-optional: core deps boot in stub mode (no model download) so
uv sync+ tests work offline; the real stack is thealprextra (uv sync --extra alpr→ fast-alpr + onnxruntime).VISION_RECOGNIZER=fast_alprswitches it on. - Verified:
turbo run lint|test|buildincludes@parking/vision(ruff/pytest/no-op via the shim) and stays green;uv run mypystrict-clean; uvicorn boots and serves/health(ready, stub-0) +/analyze(contract shape) live. pnpm workspace count 6→7.
Still to build (next, when vision work proceeds)
- The Node-side
VisionClientadapter (localhost HTTP) + per-camera opt-in wiring (the open item in opencv-anpr-service). - A
Dockerfile/process unit for the appliance (its own image/process); model-weight fetch at deploy (thealprextra), kept out of git (opencv-anpr-service check first). - Job 2 (vehicle attributes / fingerprint) — the
vehiclefield is scaffolded but unpopulated; fast-alpr is plate-only. Built later on the same ONNX runtime.
Open
uvvs.pip-tools/poetryfor the Python env (leaninguv— speed + lockfile + offline).- Whether
buildshould fetch/cache model weights (and set Turbooutputsto the model dir) or keep weights out of the build entirely (baked into the Docker image instead). - Container/runtime supervision on the appliance (systemd unit vs. compose) — deployment detail, defer to the install/hardening pass.
Resolved 2026-06-22 → container-deployment: the vision service now ships as the
parking-visionDocker image (uv base,--extra alpr), model weights pre-warmed into the image layer at build (offline-first), and runs under docker-compose (base + per-env override).
Two runtimes, one fragile (the uv run strips-the-extra trap) — 2026-06-25
Real ANPR runs completely differently on the two machines, and only the dev path was fragile:
- Booth (deployment) = the Docker image. The
Dockerfilerunsuv sync --frozen --extra alprat build, so fast-alpr/onnxruntime are baked into an immutable image layer and the weights are pre-warmed in.docker-compose.prod.ymlforcesVISION_RECOGNIZER=fast_alpr. Nothing at runtime re-resolves the venv → the booth's real ANPR cannot silently degrade. (A boothModuleNotFoundError: fast_alpris a STALE image, not this bug — fix withbooth.sh updateto pull the current image.) - Dev machine = bare
uv run uvicorn …againstapps/vision/.venv. This is the trap: a plainuv run(oruv syncwith no--extra alpr) re-resolves the venv to the lockfile defaults and REMOVES the alpr stack — leaving the model weights orphaned in~/.cache/open-image-modelsbut no recognizer in the venv. So a dev box that ran real ANPR (weights downloaded, plate reads recorded) silently degrades to "snapshot captured but no plate" after the nextpnpm dev. This exactly explains a gap observed 2026-06-25: real reads on 06-22, then nothing — the venv (frozen since 06-19, lean) had been stripped, while the Docker/compose work (06-23) was an innocent coincidence, not the cause.
Fix (2026-06-25): the vision package.json dev/start/recognize scripts now run
uv sync --extra alpr && FIRST, so pnpm dev is self-healing — the recognizer survives every
run. A dev:stub script is the lean, model-free escape hatch. The booth (Docker) is untouched.
Implication: local real-ANPR and booth real-ANPR are now both reliable; CI/light contributors who
don't want the heavy stack use dev:stub or run the suite (tests are stub-mode, offline). See
opencv-anpr-service.