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
103 lines
3.9 KiB
Python
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
|