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
89 lines
3.5 KiB
Python
89 lines
3.5 KiB
Python
"""Crop → model input. THE CONTRACT between the trainer and the vision service's classifier
|
||
stage: what the network sees at training time must be exactly what it sees on the booth.
|
||
|
||
The trainer does not share code with the vision service (different packages, different
|
||
images), so the contract is DATA: every constant here is written into the model's sidecar
|
||
(`bodytype.json`) and the vision side reads and applies them from there — nothing is
|
||
assumed on either side. Both use OpenCV with the same interpolation so the pixels match.
|
||
|
||
- input: the collector's crop (the detector's vehicle box + margin, plate blurred), or on
|
||
the booth the same cut made live from the frame (vehicle.py mirrors `makeReviewCrop`).
|
||
- resize: squash to input_size × input_size with INTER_AREA (the crop IS the vehicle; no
|
||
centre-crop that would lose a bumper or a roofline — the shape is the signal).
|
||
- colour: RGB, float32, 0–255. Normalisation (/255, ImageNet mean/std) lives INSIDE the
|
||
ONNX graph, so a consumer feeds raw pixels and cannot get the constants wrong.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
from dataclasses import asdict, dataclass, field
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
SIDECAR_FORMAT = "parking-bodytype/1"
|
||
IMAGENET_MEAN = (0.485, 0.456, 0.406)
|
||
IMAGENET_STD = (0.229, 0.224, 0.225)
|
||
CROP_MARGIN = 0.08 # must equal CROP_MARGIN in apps/server review-outbox.ts
|
||
|
||
|
||
@dataclass
|
||
class Sidecar:
|
||
"""`bodytype.json` beside `bodytype.onnx`."""
|
||
|
||
version: str
|
||
classes: list[str]
|
||
input_size: int = 224
|
||
color: str = "rgb"
|
||
resize: str = "area"
|
||
crop_margin: float = CROP_MARGIN
|
||
normalization: str = "in-graph" # the ONNX divides by 255 and applies mean/std itself
|
||
mean: list[float] = field(default_factory=lambda: list(IMAGENET_MEAN))
|
||
std: list[float] = field(default_factory=lambda: list(IMAGENET_STD))
|
||
backbone: str = ""
|
||
mode: str = ""
|
||
trained_at: str = ""
|
||
labels: dict[str, int] = field(default_factory=dict) # train / val counts
|
||
metrics: dict[str, Any] = field(default_factory=dict) # accuracy, macro, per-class
|
||
format: str = SIDECAR_FORMAT
|
||
|
||
def write(self, path: Path) -> None:
|
||
path.write_text(json.dumps(asdict(self), indent=2) + "\n")
|
||
|
||
@classmethod
|
||
def read(cls, path: Path) -> Sidecar:
|
||
d = json.loads(path.read_text())
|
||
if d.get("format") != SIDECAR_FORMAT:
|
||
raise ValueError(f"{path}: unknown sidecar format {d.get('format')!r}")
|
||
known = {f for f in cls.__dataclass_fields__}
|
||
return cls(**{k: v for k, v in d.items() if k in known})
|
||
|
||
|
||
def load_input(path: Path, input_size: int) -> Any:
|
||
"""Decode a crop and produce the network input: RGB float32 CHW, 0–255, squashed to
|
||
input_size. Returns None when the file cannot be decoded."""
|
||
import cv2
|
||
|
||
img = cv2.imread(str(path), cv2.IMREAD_COLOR)
|
||
if img is None:
|
||
return None
|
||
return array_to_input(img, input_size)
|
||
|
||
|
||
def array_to_input(bgr: Any, input_size: int) -> Any:
|
||
"""BGR uint8 HWC (OpenCV's native) → RGB float32 CHW 0–255 at input_size."""
|
||
import cv2
|
||
import numpy as np
|
||
|
||
resized = cv2.resize(bgr, (input_size, input_size), interpolation=cv2.INTER_AREA)
|
||
rgb = cv2.cvtColor(resized, cv2.COLOR_BGR2RGB)
|
||
return np.ascontiguousarray(rgb.transpose(2, 0, 1).astype(np.float32))
|
||
|
||
|
||
def softmax(logits: Any) -> Any:
|
||
import numpy as np
|
||
|
||
z = logits - logits.max(axis=-1, keepdims=True)
|
||
e = np.exp(z)
|
||
return e / e.sum(axis=-1, keepdims=True)
|