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
+9
View File
@@ -0,0 +1,9 @@
.venv/
**/__pycache__/
.pytest_cache/
.mypy_cache/
.ruff_cache/
.env
# weights are fetched inside the build (yolox) or pinned by models/bodytype.version
models/*
!models/bodytype.version
+8
View File
@@ -29,3 +29,11 @@ VISION_MIN_CONFIDENCE=0.5
# VISION_VEHICLE_MODEL_PATH=models/yolox_s.onnx
# VISION_VEHICLE_INPUT_SIZE=640
# VISION_VEHICLE_MIN_CONFIDENCE=0.4
# Phase B: the body-type classifier (sedan/hatchback/suv/… on the detector's crop), trained
# by apps/trainer on the reviewer's labels. The Docker image bakes it at
# /app/models/bodytype.onnx (+ .json sidecar) when models/bodytype.version pins a published
# version; locally copy a trainer output folder's two files into apps/vision/models/.
# Path set but no file = stage off (the normal state before the first model).
# VISION_VEHICLE_CLASSIFIER_PATH=models/bodytype.onnx
# VISION_VEHICLE_CLASSIFIER_MIN_CONFIDENCE=0.6
+2 -1
View File
@@ -7,5 +7,6 @@ __pycache__/
.ruff_cache/
# Model weights (fetched at deploy / first run, never committed — can be large + license-scoped)
models/
models/*
!models/bodytype.version
*.onnx
+20 -1
View File
@@ -33,6 +33,24 @@ ARG YOLOX_URL=https://github.com/Megvii-BaseDetection/YOLOX/releases/download/0.
RUN mkdir -p /app/models \
&& (curl -fsSL -o /app/models/yolox_s.onnx "$YOLOX_URL" \
|| (echo "[build] yolox weights not fetched (no network) — vehicle stage off" && rm -f /app/models/yolox_s.onnx))
# Phase B body-type classifier (apps/trainer output, published to the Gitea generic package
# registry — weights are not code, they never live in git). models/bodytype.version PINS the
# version this image carries: empty = no classifier (phase B off). A pinned version that
# cannot be fetched FAILS the build — the image must carry what git says it carries. The
# registry may need auth: pass a BuildKit secret `bodytype_auth` holding "user:token".
ARG BODYTYPE_BASE_URL=https://git.infra.msai.al/api/packages/mca/generic/parking-bodytype
COPY models/bodytype.version ./models/bodytype.version
RUN --mount=type=secret,id=bodytype_auth \
v="$(tr -d '[:space:]' < /app/models/bodytype.version)"; \
if [ -n "$v" ]; then \
cfg=/tmp/curl.cfg; : > "$cfg"; \
[ -f /run/secrets/bodytype_auth ] && printf 'user = "%s"\n' "$(cat /run/secrets/bodytype_auth)" > "$cfg"; \
curl -fsSL -K "$cfg" -o /app/models/bodytype.onnx "$BODYTYPE_BASE_URL/$v/bodytype.onnx" \
&& curl -fsSL -K "$cfg" -o /app/models/bodytype.json "$BODYTYPE_BASE_URL/$v/bodytype.json" \
&& echo "[build] bodytype classifier $v baked" \
|| { echo "[build] bodytype classifier $v could not be fetched"; rm -f "$cfg"; exit 1; }; \
rm -f "$cfg"; \
else echo "[build] no bodytype version pinned — phase B off"; fi
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --extra alpr
@@ -57,7 +75,8 @@ RUN uv run python -c "from fast_alpr import ALPR; ALPR()" \
ENV VISION_RECOGNIZER=stub \
VISION_HOST=0.0.0.0 \
VISION_PORT=8089 \
VISION_VEHICLE_MODEL_PATH=/app/models/yolox_s.onnx
VISION_VEHICLE_MODEL_PATH=/app/models/yolox_s.onnx \
VISION_VEHICLE_CLASSIFIER_PATH=/app/models/bodytype.onnx
EXPOSE 8089
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8089/health').status==200 else 1)" || exit 1
View File
+132
View File
@@ -143,3 +143,135 @@ def test_app_reports_a_missing_model_file_and_keeps_serving() -> None:
assert res.json()["vehicle"] is None
finally:
os.environ.pop("VISION_VEHICLE_MODEL_PATH", None)
# ----------------------------------------------------------------------------------
# Phase B: the classifier stage over the detector
# ----------------------------------------------------------------------------------
def test_crop_vehicle_adds_the_margin_clamps_and_blurs_the_plate() -> None:
cv2 = pytest.importorskip("cv2")
from vision_service.vehicle import crop_vehicle
frame = np.zeros((100, 200, 3), dtype=np.uint8)
frame[40:50, 90:110] = (0, 255, 0) # a green "plate"
box = BBox(x1=50, y1=20, x2=150, y2=80) # 100×60 → 8 % margin = 8 / 5 px
crop = crop_vehicle(frame, box, None, 0.08)
assert crop.shape == (70, 116, 3)
edge = crop_vehicle(frame, BBox(x1=0, y1=0, x2=100, y2=60), None, 0.08)
assert edge.shape == (65, 108, 3) # clamped at the frame's top-left
assert crop_vehicle(frame, BBox(x1=10, y1=10, x2=12, y2=12), None, 0.08) is None
blurred = crop_vehicle(frame, box, BBox(x1=90, y1=40, x2=110, y2=50), 0.08)
strip = blurred[40 - 20 + 5 : 50 - 20 + 5, 90 - 50 + 8 : 110 - 50 + 8, 1] # plate strip, green channel
assert strip.mean() < 200 and crop[20 + 5 : 30 + 5, 40 + 8 : 60 + 8, 1].mean() == 255
assert cv2 is not None
class FakeClassifier:
ready = True
error = None
min_confidence = 0.6
model_version = "bodytype:vfake"
def __init__(self, classes: list[str], answer: tuple[str, float] | None) -> None:
self.classes = classes
self.answer = answer
self.calls = 0
def classify(self, frame, box, plate): # type: ignore[no-untyped-def]
self.calls += 1
if isinstance(self.answer, Exception):
raise self.answer
return self.answer
class FrameDetector:
"""A detector that answers on decoded frames (like YOLOX) with a fixed result."""
model_version = "det"
ready = True
error = None
def __init__(self, result: VehicleResult | None) -> None:
self.result = result
def detect_frame(self, frame, plate): # type: ignore[no-untyped-def]
return self.result
def detect(self, image_bytes: bytes, plate: BBox | None) -> VehicleResult | None:
raise AssertionError("the refined stage should share the decoded frame")
def _jpeg() -> bytes:
cv2 = pytest.importorskip("cv2")
ok, buf = cv2.imencode(".jpg", np.zeros((60, 80, 3), dtype=np.uint8))
assert ok
return bytes(buf)
def test_refined_detector_replaces_car_when_confident_else_keeps_the_detector() -> None:
from vision_service.vehicle import RefinedVehicleDetector
car = VehicleResult(body_type="car", confidence=0.85, bbox=BBox(x1=10, y1=10, x2=70, y2=50))
sure = FakeClassifier(["sedan", "suv"], ("suv", 0.91))
res = RefinedVehicleDetector(FrameDetector(car), sure).detect(_jpeg(), None)
assert (
res is not None and res.body_type == "suv" and res.confidence == 0.91 and res.detector_class == "car"
)
assert res.bbox == car.bbox
unsure = FakeClassifier(["sedan", "suv"], ("suv", 0.4))
res2 = RefinedVehicleDetector(FrameDetector(car), unsure).detect(_jpeg(), None)
assert (
res2 is not None
and res2.body_type == "car"
and res2.confidence == 0.85
and res2.detector_class == "car"
)
# A class the classifier never trained on is left alone (its softmax means nothing there).
bus = VehicleResult(body_type="bus", confidence=0.9, bbox=car.bbox)
skip = FakeClassifier(["sedan", "suv"], ("suv", 0.99))
res3 = RefinedVehicleDetector(FrameDetector(bus), skip).detect(_jpeg(), None)
assert res3 == bus and skip.calls == 0
# …unless it was: a classifier that knows trucks may override a truck.
knows = FakeClassifier(["sedan", "truck", "van"], ("van", 0.8))
truck = VehicleResult(body_type="truck", confidence=0.7, bbox=car.bbox)
res4 = RefinedVehicleDetector(FrameDetector(truck), knows).detect(_jpeg(), None)
assert res4 is not None and res4.body_type == "van" and res4.detector_class == "truck"
def test_refined_detector_survives_a_broken_classifier_and_reports_it() -> None:
from vision_service.vehicle import RefinedVehicleDetector
car = VehicleResult(body_type="car", confidence=0.85, bbox=BBox(x1=10, y1=10, x2=70, y2=50))
boom = FakeClassifier(["sedan"], RuntimeError("bad graph")) # type: ignore[arg-type]
ref = RefinedVehicleDetector(FrameDetector(car), boom)
assert ref.detect(_jpeg(), None) == car
assert ref.error == "classifier: RuntimeError: bad graph"
assert ref.ready is True and ref.model_version == "det+bodytype:vfake"
# No box, or a detector that found nothing → nothing to classify.
assert RefinedVehicleDetector(FrameDetector(None), boom).detect(_jpeg(), None) is None
boxless = VehicleResult(body_type="car", confidence=0.85)
assert RefinedVehicleDetector(FrameDetector(boxless), boom).detect(_jpeg(), None) == boxless
def test_classifier_without_files_is_not_ready_and_the_factory_skips_a_missing_model(tmp_path) -> None: # type: ignore[no-untyped-def]
from vision_service.recognizer import WithVehicle, build_recognizer
from vision_service.settings import Settings
from vision_service.vehicle import BodyTypeClassifier, RefinedVehicleDetector
clf = BodyTypeClassifier(str(tmp_path / "bodytype.onnx"))
assert clf.ready is False and "FileNotFoundError" in (clf.error or "")
(tmp_path / "bodytype.json").write_text('{"format": "other"}')
assert "unknown sidecar format" in (BodyTypeClassifier(str(tmp_path / "bodytype.onnx")).error or "")
# A path with no file = the normal pre-model state: phase A only, no error in health.
s = Settings(
vehicle_model_path="/nonexistent/yolox.onnx", vehicle_classifier_path=str(tmp_path / "none.onnx")
)
rec = build_recognizer(s)
assert isinstance(rec, WithVehicle)
assert not isinstance(rec._detector, RefinedVehicleDetector) # noqa: SLF001
assert "classifier" not in (rec.error or "")
+17 -2
View File
@@ -14,12 +14,16 @@ Adding a recognizer (e.g. a fine-tuned YOLO + PaddleOCR) = a new class here, no
from __future__ import annotations
import logging
import time
from pathlib import Path
from typing import Protocol
from .schemas import AnalyzeResponse, BBox, PlateResult
from .settings import Settings
from .vehicle import VehicleDetector, YoloxVehicleDetector
from .vehicle import BodyTypeClassifier, RefinedVehicleDetector, VehicleDetector, YoloxVehicleDetector
log = logging.getLogger("vision")
class Recognizer(Protocol):
@@ -215,10 +219,21 @@ def build_recognizer(settings: Settings) -> Recognizer:
else:
rec = StubRecognizer(settings)
if settings.vehicle_model_path:
detector = YoloxVehicleDetector(
detector: VehicleDetector = YoloxVehicleDetector(
settings.vehicle_model_path,
input_size=settings.vehicle_input_size,
min_confidence=settings.vehicle_min_confidence,
)
if settings.vehicle_classifier_path:
if Path(settings.vehicle_classifier_path).is_file():
detector = RefinedVehicleDetector(
detector,
BodyTypeClassifier(
settings.vehicle_classifier_path,
min_confidence=settings.vehicle_classifier_min_confidence,
),
)
else:
log.info("no body-type classifier at %s — phase B off", settings.vehicle_classifier_path)
return WithVehicle(rec, detector)
return rec
+3
View File
@@ -45,6 +45,9 @@ class VehicleResult(BaseModel):
confidence: float | None = Field(default=None, ge=0.0, le=1.0)
# The vehicle's box in frame pixels — the crop a reviewer sees / a classifier eats.
bbox: BBox | None = None
# Phase B: the detector's coarse class when the body-type classifier ran on this crop
# (body_type is then the classifier's answer if confident, else the detector's).
detector_class: str | None = None
make: str | None = None
model: str | None = None
+8
View File
@@ -42,6 +42,14 @@ class Settings(BaseSettings):
# site's own, stricter threshold before it FLAGS anything).
vehicle_min_confidence: float = 0.4
# Phase B — the body-type classifier on the detector's crop (bodytype.onnx + its .json
# sidecar, produced by apps/trainer, baked into the image when
# models/bodytype.version pins a published version). Path set but NO file = the normal
# state before the first model ships: the stage is simply off (logged, not an error).
vehicle_classifier_path: str | None = None
# Below this probability the classifier's answer is dropped and the detector's stands.
vehicle_classifier_min_confidence: float = 0.6
def get_settings() -> Settings:
return Settings()
+170
View File
@@ -229,6 +229,13 @@ class YoloxVehicleDetector:
frame = cv2.imdecode(np.frombuffer(image_bytes, dtype=np.uint8), cv2.IMREAD_COLOR)
if frame is None:
return None
return self.detect_frame(frame, plate)
def detect_frame(self, frame: Any, plate: BBox | None) -> VehicleResult | None:
"""Same as detect() on an already-decoded BGR frame (the classifier stage decodes
once and shares it)."""
if self._session is None:
return None
tensor, scale = letterbox(frame, self._size)
raw = self._session.run(None, {self._input_name: tensor})[0][0]
found = vehicles_from_output(raw, self._size, scale, self._min_confidence)
@@ -242,6 +249,169 @@ class YoloxVehicleDetector:
return VehicleResult(body_type=best.body_type, confidence=round(best.confidence, 4), bbox=box)
# ----------------------------------------------------------------------------------
# Phase B: the body-type classifier on the detector's crop
# ----------------------------------------------------------------------------------
SIDECAR_FORMAT = "parking-bodytype/1"
def crop_vehicle(frame: Any, box: BBox, plate: BBox | None, margin: float) -> Any:
"""The detector's box + margin, plate blurred — the SAME cut the collector stores
(apps/server review-outbox.ts makeReviewCrop), so the classifier sees at the booth
what it was trained on. Returns a BGR array, or None when the box is degenerate."""
import cv2
h, w = frame.shape[:2]
mw = round((box.x2 - box.x1) * margin)
mh = round((box.y2 - box.y1) * margin)
left, top = max(0, box.x1 - mw), max(0, box.y1 - mh)
right, bottom = min(w, box.x2 + mw), min(h, box.y2 + mh)
if right - left < 8 or bottom - top < 8:
return None
crop = frame[top:bottom, left:right].copy()
if plate is not None:
pad = round(max(plate.x2 - plate.x1, plate.y2 - plate.y1) * 0.25)
pl, pt = max(0, plate.x1 - pad - left), max(0, plate.y1 - pad - top)
pr, pb = min(right - left, plate.x2 + pad - left), min(bottom - top, plate.y2 + pad - top)
if pr - pl >= 2 and pb - pt >= 2:
sigma = max(6, round((pr - pl) / 6))
crop[pt:pb, pl:pr] = cv2.GaussianBlur(crop[pt:pb, pl:pr], (0, 0), sigma)
return crop
class BodyTypeClassifier:
"""`bodytype.onnx` + its `bodytype.json` sidecar (written by apps/trainer). The sidecar
carries the preprocessing contract — class list, input size, crop margin — and the graph
normalises internally, so this side only cuts, resizes (INTER_AREA, like the trainer)
and feeds raw RGB 0–255. Load failure → `error`, the stage yields nothing."""
def __init__(self, model_path: str, min_confidence: float = 0.6) -> None:
import json
self._path = Path(model_path)
self.min_confidence = min_confidence
self._session = None
self._input_name = "image"
self._error: str | None = None
self.classes: list[str] = []
self.version = "?"
self.input_size = 224
self.crop_margin = 0.08
try:
side = json.loads(self._path.with_suffix(".json").read_text())
if side.get("format") != SIDECAR_FORMAT:
raise ValueError(f"unknown sidecar format {side.get('format')!r}")
self.classes = [str(c) for c in side["classes"]]
self.version = str(side.get("version", "?"))
self.input_size = int(side.get("input_size", 224))
self.crop_margin = float(side.get("crop_margin", 0.08))
import onnxruntime as ort
opts = ort.SessionOptions()
opts.intra_op_num_threads = 2
self._session = ort.InferenceSession(
str(self._path), sess_options=opts, providers=["CPUExecutionProvider"]
)
self._input_name = self._session.get_inputs()[0].name
except Exception as exc: # noqa: BLE001 - not-ready, never fatal
self._error = f"{type(exc).__name__}: {exc}"
@property
def model_version(self) -> str:
return f"bodytype:{self.version}"
@property
def ready(self) -> bool:
return self._session is not None
@property
def error(self) -> str | None:
return self._error
def classify(self, frame: Any, box: BBox, plate: BBox | None) -> tuple[str, float] | None:
"""(class, probability) for the vehicle in `box`, or None when nothing could be cut."""
if self._session is None:
return None
import cv2
import numpy as np
crop = crop_vehicle(frame, box, plate, self.crop_margin)
if crop is None:
return None
resized = cv2.resize(crop, (self.input_size, self.input_size), interpolation=cv2.INTER_AREA)
rgb = cv2.cvtColor(resized, cv2.COLOR_BGR2RGB)
x = np.ascontiguousarray(rgb.transpose(2, 0, 1)[None].astype(np.float32))
logits = self._session.run(None, {self._input_name: x})[0][0]
z = logits - logits.max()
p = np.exp(z) / np.exp(z).sum()
i = int(p.argmax())
return self.classes[i], float(p[i])
class RefinedVehicleDetector:
"""Detector + classifier. The detector finds the vehicle (and picks WHICH one); when its
class is `car` — or one the classifier was trained on — the classifier's answer replaces
it if confident enough, else the detector's stands. A truck or bus the classifier has
never seen is left alone: its softmax on an unknown thing means nothing."""
def __init__(self, detector: Any, classifier: BodyTypeClassifier) -> None:
self._detector = detector
self._classifier = classifier
self.stage_error: str | None = None
@property
def model_version(self) -> str:
return f"{self._detector.model_version}+{self._classifier.model_version}"
@property
def ready(self) -> bool:
return bool(getattr(self._detector, "ready", True))
@property
def error(self) -> str | None:
parts = [
getattr(self._detector, "error", None),
f"classifier: {self._classifier.error}" if self._classifier.error else None,
f"classifier: {self.stage_error}" if self.stage_error else None,
]
kept = [p for p in parts if p]
return "; ".join(kept) if kept else None
def detect(self, image_bytes: bytes, plate: BBox | None) -> VehicleResult | None:
import cv2
import numpy as np
frame = cv2.imdecode(np.frombuffer(image_bytes, dtype=np.uint8), cv2.IMREAD_COLOR)
if frame is None:
return None
detect_frame = getattr(self._detector, "detect_frame", None)
base: VehicleResult | None = (
detect_frame(frame, plate) if detect_frame else self._detector.detect(image_bytes, plate)
)
if base is None or base.bbox is None or not self._classifier.ready:
return base
if not (base.body_type == "car" or base.body_type in self._classifier.classes):
return base
try:
out = self._classifier.classify(frame, base.bbox, plate)
except Exception as exc: # noqa: BLE001 - advisory stage, never fatal
self.stage_error = f"{type(exc).__name__}: {exc}"
return base
if out is None:
return base
body_type, confidence = out
if confidence < self._classifier.min_confidence:
return base.model_copy(update={"detector_class": base.body_type})
return base.model_copy(
update={
"body_type": body_type,
"confidence": round(confidence, 4),
"detector_class": base.body_type,
}
)
def time_detect(
detector: VehicleDetector, image_bytes: bytes, plate: BBox | None
) -> tuple[VehicleResult | None, float]: