Files
parking_solution/apps/trainer/tests/conftest.py
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

103 lines
3.9 KiB
Python

"""A synthetic collector volume: the collector's `items` table (same DDL as apps/collector
src/db.ts) + JPEG crops. Classes are told apart by COLOUR so even a random-init backbone's
features separate them — the tests check the plumbing (split, floor, export, sidecar),
not accuracy on real cars."""
from __future__ import annotations
import sqlite3
from datetime import datetime, timedelta, timezone
from pathlib import Path
import numpy as np
import pytest
DDL = """
CREATE TABLE IF NOT EXISTS items (
id TEXT PRIMARY KEY, booth TEXT NOT NULL, kind TEXT NOT NULL DEFAULT 'wash',
order_ref TEXT NOT NULL, at TEXT NOT NULL, operator_ref TEXT NOT NULL DEFAULT '',
operator_category_id TEXT NOT NULL DEFAULT '', operator_category_name TEXT NOT NULL DEFAULT '',
operator_classes TEXT NOT NULL DEFAULT '[]', service TEXT NOT NULL, vision_class TEXT NOT NULL,
vision_confidence REAL NOT NULL, vision_category_id TEXT, downgraded INTEGER NOT NULL DEFAULT 0,
image_width INTEGER NOT NULL, image_height INTEGER NOT NULL, plate_blurred INTEGER NOT NULL,
image_path TEXT NOT NULL, received_at TEXT NOT NULL, review_label TEXT, reviewed_at TEXT, reviewer TEXT
);
"""
COLOURS = {"sedan": (200, 40, 40), "suv": (40, 200, 40), "van": (40, 40, 200), "truck": (200, 200, 40)}
def write_jpeg(path: Path, colour: tuple[int, int, int], rng: np.random.Generator) -> None:
import cv2
path.parent.mkdir(parents=True, exist_ok=True)
h, w = int(rng.integers(120, 200)), int(rng.integers(160, 260))
img = np.empty((h, w, 3), np.uint8)
img[:] = colour[::-1] # BGR
noise = rng.integers(-20, 20, size=img.shape, dtype=np.int16)
img = np.clip(img.astype(np.int16) + noise, 0, 255).astype(np.uint8)
cv2.imwrite(str(path), img, [cv2.IMWRITE_JPEG_QUALITY, 85])
@pytest.fixture
def collector_dir(tmp_path: Path) -> Path:
"""40 labelled crops per class for sedan/suv/van, 5 for truck (below the minimum), a few
unusable, a few pending, one labelled row whose file is missing."""
rng = np.random.default_rng(1)
con = sqlite3.connect(tmp_path / "collector.sqlite")
con.executescript(DDL)
t0 = datetime(2026, 9, 1, tzinfo=timezone.utc)
n = 0
def add(label: str | None, reviewed: bool, kind: str = "wash", missing: bool = False) -> None:
nonlocal n
n += 1
item = f"item-{n:04d}"
rel = f"crops/booth-2/{item}.jpg"
colour = COLOURS.get(label or "sedan", (128, 128, 128))
if not missing:
write_jpeg(tmp_path / rel, colour, rng)
at = (t0 + timedelta(minutes=10 * n)).isoformat().replace("+00:00", "Z")
reviewed_at = (
(t0 + timedelta(days=1, minutes=n)).isoformat().replace("+00:00", "Z") if reviewed else None
)
con.execute(
"INSERT INTO items (id, booth, kind, order_ref, at, service, vision_class, vision_confidence, "
"image_width, image_height, plate_blurred, image_path, received_at, review_label, "
"reviewed_at, reviewer) "
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
(
item,
"booth-2",
kind,
"o",
at,
"wash",
"car" if label != "truck" else "truck",
0.9,
200,
150,
1,
rel,
at,
label if reviewed else None,
reviewed_at,
"reviewer" if reviewed else None,
),
)
# Interleaved in time so every class exists on both sides of the time split.
for i in range(40):
for label in ("sedan", "suv", "van"):
add(label, True)
if i % 8 == 0:
add("truck", True)
add("unusable", True)
add("unusable", True)
add("sedan", True, missing=True)
for _ in range(6):
add(None, False, kind="entry")
con.commit()
con.close()
return tmp_path