Files
parking_solution/wiki/decisions/vision-service-packaging.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

156 lines
10 KiB
Markdown

---
type: decision
tags: [parking, decisions, vision, anpr, monorepo, packaging]
sources: []
updated: 2026-06-25
status: 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|fast-alpr]]); this one settles WHERE the
source lives and how it joins the build.
## Decision
1. **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
`/analyze` contract + the Node-side adapter change together), one wiki.
2. **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 in `apps/vision/` weakens the runtime isolation [[vision-service]]
requires.
3. **Wired into the Turbo task graph via a thin `package.json` shim.** `pnpm-workspace.yaml` already
globs `apps/*`, so an `apps/vision/package.json` auto-joins the workspace. Its `scripts` shell out
to Python tooling, so the existing `turbo run` tasks cover it:
- `dev` → `uv run uvicorn app:app --reload` (matches `turbo.json` `dev`: persistent, uncached)
- `lint` → `ruff check` · `test` → `pytest` · `typecheck` → `ruff`/`mypy`
- `build` → **no-op or model-fetch** (Python has no `dist/**`; the `build` task's `outputs:
["dist/**"]` simply won't match — fine). If models are fetched/cached at build, point outputs at
the model dir.
Python **dependencies** stay managed by `uv` + `pyproject.toml` (NOT pnpm) — the shim only exposes
*tasks*, not deps.
4. **Node talks to it through an interface** (`VisionClient` behind 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 /analyze` shape) and its Node consumer evolve
together; one repo = one PR, no two-repo version skew.
- **`uv` makes 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`/`test` include 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|separate process]]" 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|fast-alpr]] 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, so `apps/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 thin `package.json` shim, a
per-package `turbo.json` (`extends: ["//"]`, `build` outputs `[]` so the no-op build is warning-
free), `.gitignore` (venv/caches/`*.onnx`/`models/` out), `README`.
- `vision_service/`: `app.py` (FastAPI `GET /health` + `POST /analyze`, raw octet-stream body so Node
POSTs `Snapshot.bytes` directly; oversize→413, empty→400, recognizer-not-ready→503), `settings.py`
(env `VISION_*`), `schemas.py` (the `/analyze` contract incl. a not-yet-populated `vehicle` field
for Job 2), `recognizer.py` (a `Recognizer` **Protocol** + `StubRecognizer` and `FastAlprRecognizer`
— the [[device-adapter-pattern]] applied to the model).
- **CI runs WITHOUT the extra** (`uv sync --frozen` in 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 has `alpr`. Rule (2026-09-07, after three red runs): pure post-processing tests get numpy
from the **dev group**; anything needing OpenCV uses `pytest.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) + a
`train` extra (CPU-only torch/torchvision/onnx/onnxscript from PyTorch's wheel index via
`tool.uv.index`); CI syncs without it, torch tests `importorskip("torch")`, the module that
imports torch is imported lazily by the `train` command only. Its own image
(`parking-trainer`, context `apps/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 the `alpr` extra (`uv sync --extra alpr` →
fast-alpr + onnxruntime). `VISION_RECOGNIZER=fast_alpr` switches it on.
- **Verified:** `turbo run lint|test|build` includes `@parking/vision` (ruff/pytest/no-op via the
shim) and stays green; `uv run mypy` strict-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 **`VisionClient`** adapter (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 (the `alpr` extra), kept out of git ([[opencv-anpr-service|weight-provenance]] check first).
- **Job 2** (vehicle attributes / fingerprint) — the `vehicle` field is scaffolded but unpopulated;
fast-alpr is plate-only. Built later on the same ONNX runtime.
## Open
- `uv` vs. `pip-tools`/`poetry` for the Python env (leaning `uv` — speed + lockfile + offline).
- Whether `build` should fetch/cache model weights (and set Turbo `outputs` to 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-vision` Docker 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 `Dockerfile` runs `uv sync --frozen --extra alpr`
at build, so fast-alpr/onnxruntime are **baked into an immutable image layer** and the weights are
pre-warmed in. `docker-compose.prod.yml` forces `VISION_RECOGNIZER=fast_alpr`. Nothing at runtime
re-resolves the venv → **the booth's real ANPR cannot silently degrade.** (A booth
`ModuleNotFoundError: fast_alpr` is a STALE image, not this bug — fix with `booth.sh update` to pull
the current image.)
- **Dev machine = bare `uv run uvicorn …`** against `apps/vision/.venv`. **This is the trap:** a plain
`uv run` (or `uv sync` with 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-models` but
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 next `pnpm 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]].