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
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:
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 "")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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]:
|
||||
|
||||
Reference in New Issue
Block a user