feat(trainer): phase-B body-type classifier — trainer job on the collector host + the classifier stage on the booth
Build & push images / images (push) Successful in 6m31s

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
This commit is contained in:
2026-09-07 11:14:50 +02:00
parent f9cb973fe9
commit f7a262ac9a
41 changed files with 3797 additions and 76 deletions
+12 -4
View File
@@ -128,10 +128,18 @@ Three surfaces, nothing else — it must not grow into a fleet console:
/ 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** (its GPU) and reads them
off the volume ([[bodytype-classifier-training]]: CPU-only, the Xeon is enough) —
`docker-compose.collector.yml` carries the `trainer` seam as a commented
`profiles: [train]` one-off job (next increment).
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
+136 -48
View File
@@ -1,52 +1,116 @@
---
title: Body-type classifier (phase B) — training path and hardware
type: decision
status: decided 2026-09-07; NOT built (user: "no build just yet")
related: [vision-review-outbox, opencv-anpr-service, venue-modules, fleet-deployment-komodo, technology-stack]
status: decided 2026-09-07; BUILT 2026-09-07 (trainer + vision stage); first real run waits for labels
related: [vision-review-outbox, opencv-anpr-service, venue-modules, fleet-deployment-komodo, vision-service-packaging, technology-stack]
---
# Body-type classifier (phase B) — training path and hardware
The Car Wash category suggestion needs SUV vs sedan, which the phase-A COCO detector cannot give
([[opencv-anpr-service]] §Vehicle body type). Phase B is a **classifier over the detector's crop**,
trained on the reviewer's labels gathered through the [[vision-review-outbox]]. This page records
what the loop looks like, what it runs on, and what is deliberately not done. Discussed and decided
with the user on 2026-09-07; **nothing here is built yet** — the user will say when.
trained on the reviewer's labels gathered through the [[vision-review-outbox]]. Decided with the
user on 2026-09-07 (morning), **built the same day** once the user said "build the trainer for the
Xeon". This page is the loop as built; what is still outstanding is at the end.
## The loop (as designed)
## The loop (as built)
Today the loop stops at the reviewer's verdict: the collector holds labels + crops and can export
`labels.csv`. Nothing trains, nothing updates a booth. The rest of the path, each step a place
where a person decides:
Each step is a place where a person decides. Nothing here runs on its own.
1. **Train** — a one-off job (`apps/trainer`, Python/uv like the vision service) on the
collector's host reads the export and the crops straight off the collector volume, splits by
TIME (validation = newer cars than training, so the number reflects tomorrow's traffic), and
fine-tunes a small **BSD-licensed torchvision backbone** (the licence rule applies to weights as
much as code; timm/ImageNet weights only if their terms are checked). Outputs three files: the
ONNX classifier, a sidecar (class list, preprocessing constants, version), and a metrics report
(accuracy per class + confusion matrix). It **refuses to write the model** below a validation
floor the owner sets — a bad model never becomes a file. Class imbalance (nine sedans in ten)
is weighted in the loss and reported; classes with too few labels are dropped from that run.
2. **Evaluate before anything ships** — the owner reads the report. 85–95 % on frontal gate views
is the expectation once tuned; enough to *flag*, never to *bill* (the flag records, the site
threshold exists for exactly this).
3. **Publish** — weights are not code and do not live in git: a versioned file in the Gitea
package registry / a release asset, fetched by URL like the YOLOX weights.
4. **Bake and build** — the vision Dockerfile fetches that version at build time; a second stage in
`vehicle.py` runs the classifier on the detector's box and replaces `car` with the finer class
when confident, else keeps YOLOX's answer. One model path setting like the YOLOX one; off when
unset. The contract, the mapping chips and the flag do not change — the vocabulary already holds
sedan/hatchback/suv/minivan/pickup.
1. **Train** — `apps/trainer` (`parking-trainer`, Python/uv like the vision service; its own
image `parking-trainer`, a one-off job on the collector's host — never a booth service).
`train` reads the collector's `collector.sqlite` and `crops/` **straight off the volume**
(read-only), takes only reviewed, usable rows (the operator's pick and the camera's class are
never labels), **splits by TIME** (validation = the newest 20 % by *time seen*, so the number
reflects tomorrow's traffic), drops classes with fewer than `--min-per-class` (20) labels from
that run and reports them, weighs the loss by damped inverse frequency (√, mean 1 — full
inverse over-corrects on small sets), trains, and exports. Two modes:
- `--mode features` (default): the ImageNet backbone is **frozen**; every crop's feature
vector is cached on disk (`<out>/cache/features-<backbone>-<size>.npz`, keyed by item id),
and only a linear head is trained — minutes for thousands of crops, **seconds** to retrain
when labels arrive (only new crops go through the backbone).
- `--mode finetune`: warm-starts the head the same way, then unfreezes everything with light
label-preserving augmentation (flip, mild zoom, brightness/contrast) — the step when the
cheap mode plateaus.
Backbones: `resnet18` (default), `mobilenet_v3_small`, `efficientnet_b0` — torchvision,
BSD-3, and the ImageNet weights ship under the same licence (the licence rule applies to
weights as much as code). CPU-only PyTorch from PyTorch's own wheel index (`tool.uv.index`).
2. **Evaluate before anything ships** — every run writes `report.md` (accuracy, macro recall,
per-class recall/precision, confusion matrix, dropped classes, loss weights, agreement with the
detector's coarse class) and `metrics.json`. The job **refuses to write the model** below
`--min-accuracy` (default 0.85) — exit 3, report still written — and also withholds it if the
exported ONNX disagrees with the torch model on validation (< 99 % argmax agreement). Exit 2 =
not enough labels (fewer than two classes clear the minimum). Later, `evaluate --model …`
scores a shipped model against labels **reviewed after it was trained** (a clean held-out
check) and prints its class histogram + detector agreement over the **unlabelled** pile — the
ongoing drift check without labelling everything, which is why every entry is sent
([[vision-review-outbox]] §The entry stream). 85–95 % on frontal gate views is the
expectation once tuned; enough to *flag*, never to *bill*.
3. **Publish** — weights are not code and do not live in git. `publish <version dir> --url
https://git.infra.msai.al/api/packages/mca/generic/parking-bodytype` PUTs the four files as a
**Gitea generic package** version (token with `package:write`, `TRAINER_PUBLISH_TOKEN`).
4. **Bake** — `apps/vision/models/bodytype.version` (tracked in git, empty today) **pins** the
version the vision image carries. The Dockerfile fetches `bodytype.onnx` + `bodytype.json`
from the package registry at build (auth via a BuildKit secret `bodytype_auth` = the
registry user's credentials, never a layer); **a pinned version that cannot be fetched fails
the build** — the image must carry what git says it carries; empty pin = no classifier, phase
B off, build passes. The pin is a normal commit: reviewable, revertible.
5. **Deploy** — a TAG bump on the booth's stack. **A booth gets a model the way it gets code**: a
pinned release you can see and roll back. No runtime model fetch (air-gapped appliance,
read-only model path — [[vision-service-hardening]]).
Retrain when the labels have grown meaningfully (every few hundred new verdicts at first). First
run needs roughly **200 reviewed crops per class that matters** (Vetura and SUV at least). Once a
model exists, its predictions on the *unlabelled* pile checked against a small reviewed sample are
the ongoing accuracy check without labelling everything — which is why every entry is sent, not a
sample ([[vision-review-outbox]] §The entry stream).
run needs roughly **200 reviewed crops per class that matters** (Vetura and SUV at least) — until
then `inspect` says `ready: false` and `train` exits 2.
## The contract between trainer and booth
The trainer and the vision service share **no code** (different packages, different images), so
the preprocessing contract is **data**: the `bodytype.json` sidecar (`format:
parking-bodytype/1`) carries version, the class list (a subset of the shared vocabulary, in
vocabulary order), `input_size` (224), `crop_margin` (0.08 — the same as the outbox's
`makeReviewCrop`), colour order, resize method, backbone, mode, label counts and the validation
metrics. Both sides cut the detector's box + margin, blur the plate strip, squash-resize with
OpenCV `INTER_AREA` (the crop *is* the vehicle; no centre-crop that loses a bumper), and feed raw
RGB 0–255 float; **normalisation lives inside the ONNX graph**, so a consumer cannot get the
constants wrong. Verified on 2026-09-07: a trainer-produced model loaded by the vision service's
`BodyTypeClassifier` gives identical classes and probabilities (< 1e-4) to the trainer's own
`OnnxClassifier` on the same crops.
On the booth ([[opencv-anpr-service]] §Phase B): `RefinedVehicleDetector` runs the classifier
only when the detector said `car` **or** a class the classifier trained on; a truck or bus it
never saw is left alone (its softmax on an unknown thing means nothing). Below
`VISION_VEHICLE_CLASSIFIER_MIN_CONFIDENCE` (0.6) the detector's class stands. `vehicle.
detector_class` records the coarse class whenever the stage ran; `model_version` reads
`…+yolox:…+bodytype:<version>`. The flag on the desk, the mapping chips, the threshold — nothing
downstream changed: the vocabulary already held sedan/hatchback/suv/minivan/pickup.
## One model for the fleet, not one per site (user asked, 2026-09-07)
The trainer pools **every booth's** reviewed labels into one training set (no per-booth filter),
and one `bodytype.version` pin bakes one model into the one vision image every booth runs. Body
type is a property of the car, not the site; pooling is what makes 200 crops per class reachable;
and a single pinned version is the whole "a booth gets a model the way it gets code" idea. What
*is* per site stays in Setup: the class→category mapping and the flag threshold — the model says
"suv", the site decides what an SUV costs and when a downgrade is worth flagging.
Where a site can still differ is the **camera** (mount height, angle, lens), not the cars. Every
crop carries its booth id, so the report can break accuracy down per booth — not in the report
today; add it once a second site sends labels. A booth filter in the trainer and a second pin
would only be built on evidence that a site's view needs its own model.
## Secrets and access (2026-09-07)
- **`TRAINER_PUBLISH_TOKEN`** — a Gitea access token with the `write:package` scope, used by the
`publish` command and nothing else, to PUT a passing model's files into the generic package
`mca/parking-bodytype`. Training, `inspect` and `evaluate` need no token; leave it unset until
the first model passes the floor. Create it under a user who can write packages in the `mca`
org, store it as the Komodo secret `gitea_package_write_token`, uncomment the line in the
`wash-collector` stack.
- **Read side** — the CI build fetches the pinned version with the existing registry
credentials (`REGISTRY_USERNAME:PASSWORD` as the BuildKit secret `bodytype_auth`); if the
package is org-private that user needs package read, which the Docker-registry user already
has in Gitea.
## Hardware (decided 2026-09-07)
@@ -60,25 +124,49 @@ What the owner has: an **NVIDIA Quadro FX 3800** (in hand, not installed), and i
- **HD P530 — not for training.** Usable for *inference* via OpenVINO, irrelevant here: inference
runs on the booths' CPUs, which already do YOLOX in ~250 ms.
- **The Xeon does the job.** The problem is small (a few thousand 224-px crops, ten classes, a
small pretrained backbone). Two modes the trainer should offer:
- *Feature extraction + a small head* — run every crop once through the frozen backbone, cache
the feature vectors, train a classifier on top: minutes for a few thousand crops, seconds to
retrain when labels arrive. Expected to carry most of the accuracy on frontal gate views.
- *Full fine-tune* — unfreeze and train end to end: roughly an hour per run on four cores with
a mobile-sized backbone. The step to take when the cheap mode plateaus.
Training is occasional and unattended, so an hour on a CPU is a non-issue; the data is already
on that host, so nothing moves.
- **Consequences for the build:** the trainer image is **CPU-only PyTorch** (< 1 GB, not the 5 GB
CUDA build); the `trainer` seam in `docker-compose.collector.yml` drops the NVIDIA device
reservation (one-line change if a modern card ever lands in the host; the trainer should pick
up CUDA when present).
small pretrained backbone): features mode in minutes, a full fine-tune in roughly an hour with a
mobile-sized backbone. Training is occasional and unattended, and the data is already on that
host, so nothing moves.
- **Consequences for the build (done):** the trainer image is **CPU-only PyTorch** (torch
2.14+cpu, ~200 MB of wheels, not the ~5 GB CUDA build); the `trainer` service in
`docker-compose.collector.yml` is real now — `profiles: ["train"]`, no device reservation
(one block to add if a modern card ever lands; the trainer would pick up CUDA), the collector
volume mounted read-only, output to `TRAINER_OUT` on the host (default `./models` beside the
compose file).
- **If faster is ever wanted:** a used mid-range card of the last few generations (~€200) turns
the hour into a minute, given a slot and a PSU. **Renting a cloud GPU is rejected**: the crops
would leave the premises, and even scrubbed of plates and site that runs against the whole
privacy design of the outbox.
## Not built
## Running it (on the collector host)
`apps/trainer`, the classifier stage in `vehicle.py`, the publish step, the compose `trainer`
profile (still commented, still with the GPU reservation to remove). First real run waits for
the first few hundred reviewed labels on the collector.
```
docker compose -f docker-compose.collector.yml --profile train run --rm trainer inspect
docker compose -f docker-compose.collector.yml --profile train run --rm trainer train --min-accuracy 0.85
docker compose -f docker-compose.collector.yml --profile train run --rm trainer evaluate --model /out/<version>/bodytype.onnx
docker compose -f docker-compose.collector.yml --profile train run --rm trainer publish /out/<version> --url https://git.infra.msai.al/api/packages/mca/generic/parking-bodytype
```
Then: write the version into `apps/vision/models/bodytype.version`, commit, let the build produce
the image, bump the booth's `TAG`. The trainer is never started by a deploy (a profile), and the
`wash-collector` stack's `TRAINER_OUT` / `TRAINER_PUBLISH_TOKEN` lines stay commented until the
first publish.
## Packaging rule (same as the vision service)
Core deps are light (numpy, opencv-headless, onnxruntime): `inspect`, `evaluate`, the data and
report code, and the tests run with `uv sync --frozen` alone — **CI syncs without the `train`
extra** ([[vision-service-packaging]]); the torch tests `importorskip`. The image bakes
`--extra train` and pre-warms the resnet18 + mobilenet_v3_small ImageNet weights so a run needs
no network. `pnpm turbo run lint test` covers `@parking/trainer` through the same package.json
shim pattern (workspace count 7→8).
## Outstanding
- **The first real run** — waits for ~200 reviewed crops per class on the collector (reviewing
is the bottleneck now, not code).
- **Secrets on the reviewer's host** — a Gitea token with `package:write`
(`gitea_package_write_token`) for `publish`; the CI registry user must be able to *read* the
generic package (it passes its credentials as the build secret).
- **Tuning knobs after the first report** — the floor, `--min-per-class`, whether finetune beats
features on this camera. The report decides, not a guess.
@@ -158,6 +158,11 @@ collector ([[vision-review-outbox]]) runs on the reviewer's GPU host as its own
(`wash-collector`, `server = "art-docker-station"`, `file_paths = ["docker-compose.collector.yml"]`).
Same repo, branch and pinned `TAG` promotion, its own secret references, and — because a stack
names its compose files — nothing booth-side lands on that host and nothing of it on a booth.
The same stack carries the phase-B **trainer** as a compose *profile* (`train`,
[[bodytype-classifier-training]]): a deploy never starts it; the owner runs it by hand on the host
with `docker compose … --profile train run --rm trainer …`. Its two env lines (`TRAINER_OUT`, the
`TRAINER_PUBLISH_TOKEN` secret reference) stay commented in `resources.toml` until the first
publish.
## Open / not yet done
@@ -92,6 +92,14 @@ The skeleton is **built and wired** (no recognizer models yet):
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.
+30 -1
View File
@@ -252,7 +252,8 @@ service's `/health` each tick and shows a **"Vision" chip** in the booth footer
## Vehicle body type (advisory) — the vehicle stage, phase A (2026-09-06)
> Phase B (the classifier that knows SUV from sedan), its training loop and the hardware it runs on
> are decided on [[bodytype-classifier-training]] — not built yet.
> are on [[bodytype-classifier-training]] — built 2026-09-07, see §Phase B below; no model is
> pinned yet (the stage is off until the first published version).
`/analyze` populates `vehicle.body_type` + `vehicle.confidence` from the shared vocabulary
(car, sedan, hatchback, suv, minivan, pickup, van, truck, bus, motorcycle). Node records it beside
@@ -293,3 +294,31 @@ read. Composed `model_version` reads `<plate>+yolox:yolox_s.onnx@640`.
operator's picks (untrusted — [[threat-model]]) but a trusted reviewer's, gathered through the
[[vision-review-outbox]]. Expect 85–95 % on frontal gate views once tuned — enough to flag,
never to bill, which is why the flag records and the site threshold exists.
### Phase B — the body-type classifier stage (built 2026-09-07)
`vehicle.py` gained a second stage: `BodyTypeClassifier` loads `bodytype.onnx` + its
`bodytype.json` sidecar (produced by `apps/trainer`, [[bodytype-classifier-training]] §The
contract) and `RefinedVehicleDetector` composes it over the YOLOX detector — the detector still
finds and picks the vehicle, the classifier answers on its crop. `crop_vehicle` mirrors the
outbox's `makeReviewCrop` (box + the sidecar's margin, plate strip Gaussian-blurred) so the
booth sees what the model was trained on; resize is OpenCV `INTER_AREA` at the sidecar's
`input_size`, raw RGB 0–255 in, normalisation inside the graph.
- **Rule:** the classifier runs only when the detector said `car` **or** a class the classifier
trained on; a truck/bus/motorcycle it never saw is left alone. Below
`VISION_VEHICLE_CLASSIFIER_MIN_CONFIDENCE` (0.6) the detector's class stands. When the stage
ran, `vehicle.detector_class` carries the coarse class (Node ignores it today; the collector
could show it). `model_version` reads `<plate>+yolox:…+bodytype:<version>`.
- **Config:** `VISION_VEHICLE_CLASSIFIER_PATH` (the image sets `/app/models/bodytype.onnx`) and
the min-confidence. **Path set but no file = the normal state before the first model** —
phase A only, one log line, *no* `/health.detail` error. A file that fails to load IS an error
in `detail` (`classifier: …`), and a classifier that throws per frame is caught, noted, and the
detector's answer returned — the plate read is never at risk.
- **Bake:** `apps/vision/models/bodytype.version` (tracked; empty) pins the published version the
Dockerfile fetches from the Gitea generic package registry (BuildKit secret `bodytype_auth`);
a pin that cannot be fetched fails the build, an empty pin passes with phase B off.
- **Tests** (`tests/test_vehicle.py`): crop margin/clamp/blur, the refine rule (car → suv when
confident; unsure → detector's class; unknown bus untouched; a classifier that knows trucks may
override a truck), a throwing classifier survives and is reported, missing files → not ready,
and the factory skips a missing model without an error.
+1 -1
View File
@@ -133,7 +133,7 @@ Counts: 4 sources · 19 entities · 47 concepts · 8 decision records.
- [[dingtian-vs-mqtt]] — transport choice: direct HTTP/UDP now, MQTT parked until multi-lane scale.
- [[session-model]] — business layer start: session = projection; transient-first; pay-on-foot. New event types.
- [[vision-service]] — build a host-side ANPR + vehicle-verification service; replaces edge-LPR; scoped AGPL exception.
- [[bodytype-classifier-training]] — phase B (SUV vs sedan) training path: trainer on the collector host → evaluate → publish weights → bake into the vision image → TAG bump; runs on the Xeon E3-1225 v5 CPU (feature-extraction head first, full fine-tune later), Quadro FX 3800 unusable, cloud GPU rejected (crops stay on premises). Decided 2026-09-07, NOT built.
- [[bodytype-classifier-training]] — phase B (SUV vs sedan): `apps/trainer` trains on the collector host (time split, floor, features/finetune modes, feature cache) → report → publish to the Gitea generic package → `models/bodytype.version` pin bakes it into the vision image → TAG bump; sidecar = the preprocessing contract; CPU-only torch on the Xeon E3-1225 v5, Quadro FX 3800 unusable, cloud GPU rejected. Built 2026-09-07; first run waits for labels.
- [[vision-service-packaging]] — the vision service lives in this monorepo (apps/vision/), separate process, wired into Turbo via a package.json shim; uv-managed Python.
- [[event-streams-split]] — split the signed business ledger (ledger_events) from unsigned device telemetry (device_events).
- [[desktop-shell-tauri]] — ✅ Tauri v2 chosen over Electron for the desktop kiosk shell; thin wrapper, server keeps all logic. Best case Ubuntu 26.04 LTS (resolves WebKitGTK); worst case Windows+WSL → kiosk browser, no native shell. Auto-updater mirrors signed releases to public `mca/public_releases` (source repo is private — field appliances have no Gitea creds).
+17
View File
@@ -3144,6 +3144,23 @@ run; the Quadro FX 3800 is unusable (cc 1.3), the HD P530 irrelevant, the Xeon E
compose seam drops the GPU reservation; cloud GPU rejected (crops stay on premises). Linked from
[[opencv-anpr-service]], [[vision-review-outbox]], index. User: "No build just yet."
## [2026-09-07] build | Phase B trainer + the classifier stage on the booth
User: "Shall we go and build the trainer for the Xeon?" Built `apps/trainer` (`parking-trainer`:
`inspect` / `train` / `evaluate` / `publish`; reads the collector volume read-only, time split,
thin classes dropped, damped class weights, `features` mode with an on-disk feature cache and
`finetune` mode with light augmentation, CPU-only torch from PyTorch's wheel index, ONNX export
checked against the torch model, **no model file below the floor** — exit 3 with the report; exit
2 = not enough labels), its image + a `.dockerignore`, and the `trainer` compose profile on the
collector stack (CPU, read-only data volume, `TRAINER_OUT`). Vision side: `BodyTypeClassifier` +
`RefinedVehicleDetector` (car or a known class only; min-confidence; `detector_class`; missing
file = off without an error, broken file = health detail), `models/bodytype.version` pin fetched
at build from the Gitea generic package (BuildKit secret; a pin that cannot be fetched fails the
build). The sidecar is the preprocessing contract; verified a trainer model gives identical
probabilities inside the vision service. CI: trainer synced without the `train` extra, torch tests
skip. Tests: trainer 10 (6 in CI mode), vision 18. Pages: [[bodytype-classifier-training]]
rewritten as built, [[opencv-anpr-service]] §Phase B, [[vision-review-outbox]],
[[vision-service-packaging]], [[fleet-deployment-komodo]], index.
## [2026-09-07] ingest | Collector live on park-2; secrets shape, DNS vs bind, token format, CI rule
Deployed: collector on art-docker-station + park-2 at stage-dbbb051, every entry sampled; review
screen filling. Recorded on [[vision-review-outbox]]: one secret per booth referenced by both stacks