f7a262ac9a
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
99 lines
4.2 KiB
Python
99 lines
4.2 KiB
Python
"""Data rules, torch-free: labels, the time split, thin classes, weights, the report."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
from pathlib import Path
|
||
|
||
from trainer.cli import main
|
||
from trainer.data import (
|
||
class_weights,
|
||
load_labelled,
|
||
load_reviewed_since,
|
||
load_unlabelled,
|
||
make_split,
|
||
summarise,
|
||
)
|
||
from trainer.preprocess import CROP_MARGIN, Sidecar, load_input
|
||
from trainer.report import compute_metrics, render_report
|
||
|
||
|
||
def test_loads_only_reviewed_usable_rows_with_a_crop_on_disk(collector_dir: Path) -> None:
|
||
samples, missing = load_labelled(collector_dir)
|
||
assert missing == 1 # the labelled row whose file is gone
|
||
assert len(samples) == 125 # 3×40 + 5 trucks; unusable and pending excluded
|
||
assert all(s.path.is_file() for s in samples)
|
||
assert {s.label for s in samples} == {"sedan", "suv", "van", "truck"}
|
||
assert summarise(samples)["byClass"] == {"sedan": 40, "suv": 40, "van": 40, "truck": 5}
|
||
assert len(load_unlabelled(collector_dir)) == 6
|
||
assert len(load_reviewed_since(collector_dir, "2026-09-02T00:00:00Z")) == 125
|
||
assert load_reviewed_since(collector_dir, "2030-01-01T00:00:00Z") == []
|
||
|
||
|
||
def test_split_is_by_time_and_drops_thin_classes(collector_dir: Path) -> None:
|
||
samples, _ = load_labelled(collector_dir)
|
||
split = make_split(samples, val_fraction=0.2, min_per_class=20)
|
||
assert split.classes == ("sedan", "suv", "van") # canonical order, truck dropped
|
||
assert split.dropped == {"truck": 5}
|
||
assert len(split.train) + len(split.val) == 120
|
||
assert len(split.val) == 24
|
||
assert max(s.at for s in split.train) < min(s.at for s in split.val) # newest = validation
|
||
assert all(v > 0 for v in split.counts("val").values())
|
||
|
||
|
||
def test_class_weights_lean_against_imbalance_but_gently(collector_dir: Path) -> None:
|
||
samples, _ = load_labelled(collector_dir)
|
||
vans = [s for s in samples if s.label == "van"]
|
||
keep = set(vans[::10]) # 4 of 40 vans survive
|
||
split = make_split([s for s in samples if s.label != "van" or s in keep], 0.2, 3)
|
||
w = dict(zip(split.classes, class_weights(split), strict=True))
|
||
assert w["van"] > w["sedan"] > 0 # the rare class weighs more
|
||
assert w["van"] / w["sedan"] < 4 # but not the full inverse ratio (damped)
|
||
assert abs(sum(w.values()) / len(w) - 1.0) < 1e-9
|
||
|
||
|
||
def test_metrics_and_report() -> None:
|
||
classes = ("sedan", "suv")
|
||
m = compute_metrics(classes, [0, 0, 1, 1], [0, 1, 1, 1], camera=["car"] * 4)
|
||
assert m.accuracy == 0.75
|
||
assert m.per_class["sedan"].recall == 0.5 and m.per_class["suv"].precision == 2 / 3
|
||
assert m.confusion == [[1, 1], [0, 2]]
|
||
assert m.camera_agreement == 0.0
|
||
text = render_report(
|
||
version="v1",
|
||
trained_at="t",
|
||
mode="features",
|
||
backbone="resnet18",
|
||
epochs=3,
|
||
classes=classes,
|
||
train_counts={"sedan": 10, "suv": 8},
|
||
val_counts={"sedan": 2, "suv": 2},
|
||
dropped={"truck": 2},
|
||
missing_files=1,
|
||
weights=[0.9, 1.1],
|
||
metrics=m,
|
||
min_accuracy=0.85,
|
||
written=False,
|
||
)
|
||
assert "MODEL NOT WRITTEN" in text and "| **sedan** | 1 | 1 |" in text and "truck (2)" in text
|
||
|
||
|
||
def test_preprocess_contract(tmp_path: Path, collector_dir: Path) -> None:
|
||
samples, _ = load_labelled(collector_dir)
|
||
x = load_input(samples[0].path, 32)
|
||
assert x.shape == (3, 32, 32) and x.dtype.name == "float32" and 0 <= x.min() and x.max() <= 255
|
||
assert x[0].mean() > x[2].mean() # a sedan crop is red: RGB order, not BGR
|
||
assert load_input(tmp_path / "nope.jpg", 32) is None
|
||
side = Sidecar(version="v1", classes=["sedan", "suv"])
|
||
side.write(tmp_path / "s.json")
|
||
back = Sidecar.read(tmp_path / "s.json")
|
||
assert back == side and back.crop_margin == CROP_MARGIN == 0.08 and back.normalization == "in-graph"
|
||
|
||
|
||
def test_inspect_prints_the_run_shape(collector_dir: Path, capsys) -> None: # type: ignore[no-untyped-def]
|
||
assert main(["inspect", "--data", str(collector_dir)]) == 0
|
||
out = json.loads(capsys.readouterr().out)
|
||
assert out["ready"] is True and out["run"]["classes"] == ["sedan", "suv", "van"]
|
||
assert out["run"]["dropped"] == {"truck": 5} and out["missingCrops"] == 1
|
||
assert main(["inspect", "--data", str(collector_dir), "--min-per-class", "100"]) == 2
|