| """Detection + attribute classifiers. Heavy libs (ultralytics) are imported lazily so |
| the package (and unit tests) import fine without them. Tests inject fakes via the |
| Detector / HelmetClassifier protocols. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import sys |
| from typing import Protocol |
|
|
| from core.config import Settings, get_settings |
| from core.schemas import ( |
| BBox, |
| Detection, |
| DetectionResult, |
| Edge, |
| EvidenceGraph, |
| Person, |
| PersonRole, |
| new_id, |
| ) |
|
|
| |
| _KEEP = { |
| "person", |
| "bicycle", |
| "car", |
| "motorcycle", |
| "bus", |
| "truck", |
| "traffic light", |
| } |
|
|
|
|
| class Detector(Protocol): |
| def detect(self, image_path: str) -> DetectionResult: ... |
|
|
|
|
| class HelmetClassifier(Protocol): |
| def apply(self, graph: EvidenceGraph, image_path: str) -> None: ... |
|
|
|
|
| class YoloDetector: |
| """Ultralytics YOLO over the COCO classes we need.""" |
|
|
| def __init__(self, weights: str, conf: float = 0.25) -> None: |
| self._weights = weights |
| self._conf = conf |
| self._model = None |
|
|
| def _load(self): |
| if self._model is None: |
| from ultralytics import YOLO |
|
|
| self._model = YOLO(self._weights) |
| return self._model |
|
|
| def detect(self, image_path: str) -> DetectionResult: |
| model = self._load() |
| result = model(image_path, conf=self._conf, verbose=False)[0] |
| names = result.names |
| h, w = result.orig_shape |
| dets: list[Detection] = [] |
| for box in result.boxes: |
| label = names[int(box.cls)] |
| if label not in _KEEP: |
| continue |
| x1, y1, x2, y2 = (float(v) for v in box.xyxy[0].tolist()) |
| dets.append( |
| Detection( |
| label=label, |
| bbox=BBox(x1=x1, y1=y1, x2=x2, y2=y2), |
| confidence=float(box.conf), |
| ) |
| ) |
| return DetectionResult(image_width=int(w), image_height=int(h), detections=dets) |
|
|
|
|
| class NullHelmetClassifier: |
| """No helmet model configured -> leave helmet undetermined.""" |
|
|
| def apply(self, graph: EvidenceGraph, image_path: str) -> None: |
| return None |
|
|
|
|
| def _helmet_verdict(label: str) -> bool | None: |
| """Map a helmet-model class name to a verdict. None = rider present, helmet unknown. |
| |
| Supports the provided 7-class model (driver/passenger × with/without helmet, plus |
| bare driver/passenger/bike) and simpler 2-class helmet models. |
| """ |
| low = label.lower() |
| if "without_helmet" in low or "no_helmet" in low or "no-helmet" in low: |
| return False |
| if "with_helmet" in low or low == "helmet": |
| return True |
| return None |
|
|
|
|
| class YoloHelmetClassifier: |
| """Runs a local helmet YOLO model on the FULL image (one inference) and sets |
| ``person.helmet`` on graph riders by box overlap. No VLM / API calls — so it has |
| no rate limit. |
| |
| The model also localises riders (driver/passenger classes), so a confident rider |
| box that COCO missed is added as a new rider node — lifting both helmet recall and |
| triple-riding counts. ``bike`` boxes are ignored (COCO already has them). |
| """ |
|
|
| def __init__( |
| self, weights: str, conf: float = 0.35, match_iou: float = 0.4 |
| ) -> None: |
| self._weights = weights |
| self._conf = conf |
| self._match_iou = match_iou |
| self._model = None |
|
|
| def _load(self): |
| if self._model is None: |
| from ultralytics import YOLO |
|
|
| self._model = YOLO(self._weights) |
| return self._model |
|
|
| def apply(self, graph: EvidenceGraph, image_path: str) -> None: |
| model = self._load() |
| result = model(image_path, conf=self._conf, verbose=False)[0] |
| names = result.names |
|
|
| |
| dets: list[tuple[BBox, bool | None, float]] = [] |
| for box in result.boxes: |
| label = names[int(box.cls)] |
| if label.lower() == "bike": |
| continue |
| x1, y1, x2, y2 = (float(v) for v in box.xyxy[0].tolist()) |
| dets.append( |
| ( |
| BBox(x1=x1, y1=y1, x2=x2, y2=y2), |
| _helmet_verdict(label), |
| float(box.conf), |
| ) |
| ) |
| apply_helmet_detections(graph, dets, self._match_iou) |
|
|
|
|
| def apply_helmet_detections( |
| graph: EvidenceGraph, |
| dets: list[tuple[BBox, bool | None, float]], |
| match_iou: float = 0.4, |
| ) -> None: |
| """Pure helmet-box → rider assignment (no model/IO, so it is unit-testable). |
| |
| Each ``dets`` item is ``(bbox, verdict, conf)`` where verdict is True/False/None. |
| Sets ``helmet``/``helmet_score`` on the best-matching rider, and adds a new rider |
| node for a confident box on a motorcycle that no existing rider matched. |
| """ |
| if not dets: |
| return |
|
|
| |
| dets = sorted(dets, key=lambda d: (d[1] is not None, d[2]), reverse=True) |
| kept: list[tuple[BBox, bool | None, float]] = [] |
| for d in dets: |
| if all(d[0].iou(k[0]) < 0.5 for k in kept): |
| kept.append(d) |
|
|
| |
| |
| |
| riders = [p for p in graph.persons if p.role == PersonRole.rider] |
| assigned: set[str] = set() |
| used: set[int] = set() |
| for i, (b, verdict, conf) in enumerate(kept): |
| best_p, best_ov = None, 0.0 |
| for p in riders: |
| if p.id in assigned: |
| continue |
| ov = max( |
| b.iou(p.bbox), |
| b.intersection_over_self(p.bbox), |
| p.bbox.intersection_over_self(b), |
| ) |
| if ov > best_ov: |
| best_ov, best_p = ov, p |
| if best_p is not None and best_ov >= match_iou: |
| used.add(i) |
| assigned.add(best_p.id) |
| if verdict is not None: |
| best_p.helmet = verdict |
| best_p.helmet_score = conf |
|
|
| |
| |
| for i, (b, verdict, conf) in enumerate(kept): |
| if i in used: |
| continue |
| best_v, best_score = None, 0.0 |
| for v in graph.vehicles: |
| if v.type not in {"motorcycle", "bicycle"}: |
| continue |
| score = b.intersection_over_self(v.bbox) |
| if score > best_score: |
| best_score, best_v = score, v |
| if best_v is not None and best_score >= 0.3: |
| pid = new_id("det") |
| graph.persons.append( |
| Person( |
| id=pid, |
| role=PersonRole.rider, |
| bbox=b, |
| confidence=conf, |
| helmet=verdict, |
| helmet_score=conf if verdict is not None else None, |
| ) |
| ) |
| graph.edges.append(Edge(type="rides", src=pid, dst=best_v.id)) |
|
|
|
|
| _HELMET_PROMPT = ( |
| "Does the person wear a helmet on their head (a motorcycle or bicycle " |
| 'helmet)? Reply with ONLY JSON: {"helmet": true} if clearly wearing one, ' |
| '{"helmet": false} if clearly not, {"helmet": null} if you cannot tell.' |
| ) |
|
|
|
|
| class GeminiHelmetClassifier: |
| """Reads helmet status per rider crop using the Gemini vision model — no local model |
| file needed. Free-tier friendly with retry/backoff; any error leaves helmet |
| undetermined (that rider is then simply not flagged). |
| """ |
|
|
| def __init__(self, api_key: str, model: str) -> None: |
| self._api_key = api_key |
| self._model = model |
| self._client = None |
|
|
| def _client_obj(self): |
| if self._client is None: |
| from google import genai |
|
|
| self._client = genai.Client(api_key=self._api_key) |
| return self._client |
|
|
| def apply(self, graph: EvidenceGraph, image_path: str) -> None: |
| import io |
| import json |
|
|
| from google.genai import types |
| from PIL import Image |
|
|
| from core.llm import call_with_retry |
|
|
| riders = [p for p in graph.persons if p.role.value == "rider"] |
| if not riders: |
| return |
| img = Image.open(image_path).convert("RGB") |
| for p in riders: |
| b = p.bbox |
| buf = io.BytesIO() |
| img.crop((int(b.x1), int(b.y1), int(b.x2), int(b.y2))).save(buf, "JPEG") |
| data = buf.getvalue() |
| try: |
| resp = call_with_retry( |
| lambda d=data: self._client_obj().models.generate_content( |
| model=self._model, |
| contents=[ |
| _HELMET_PROMPT, |
| types.Part.from_bytes(data=d, mime_type="image/jpeg"), |
| ], |
| ), |
| attempts=2, |
| base_delay=3.0, |
| ) |
| text = ( |
| (resp.text or "") |
| .strip() |
| .removeprefix("```json") |
| .removeprefix("```") |
| ) |
| value = json.loads(text.removesuffix("```").strip()).get("helmet") |
| p.helmet = None if value is None else bool(value) |
| except Exception as e: |
| print(f"[gemini.helmet] {type(e).__name__}: {e}", file=sys.stderr) |
| p.helmet = None |
|
|
|
|
| def get_detector(settings: Settings | None = None) -> Detector: |
| settings = settings or get_settings() |
| return YoloDetector(settings.detector_weights, settings.detector_conf) |
|
|
|
|
| def get_helmet_classifier(settings: Settings | None = None) -> HelmetClassifier: |
| settings = settings or get_settings() |
| if settings.helmet_weights: |
| return YoloHelmetClassifier( |
| settings.helmet_weights, settings.helmet_conf, settings.helmet_match_iou |
| ) |
| if settings.llm_provider == "gemini" and settings.gemini_api_key: |
| return GeminiHelmetClassifier(settings.gemini_api_key, settings.gemini_model) |
| return NullHelmetClassifier() |
|
|
|
|
| def classify_lights(graph: EvidenceGraph, image_path: str) -> None: |
| """Set each traffic light's state via HSV colour analysis of its crop. Cheap and |
| approximate; only opens the image if lights exist. |
| """ |
| from core.schemas import LightState |
|
|
| if not graph.lights: |
| return |
| import numpy as np |
| from PIL import Image |
|
|
| hsv = np.asarray(Image.open(image_path).convert("HSV")) |
| h_ch, s_ch, v_ch = hsv[..., 0], hsv[..., 1], hsv[..., 2] |
| bright = (s_ch > 80) & (v_ch > 80) |
|
|
| for light in graph.lights: |
| b = light.bbox |
| y1, y2 = int(max(0, b.y1)), int(max(0, b.y2)) |
| x1, x2 = int(max(0, b.x1)), int(max(0, b.x2)) |
| m = bright[y1:y2, x1:x2] |
| hue = h_ch[y1:y2, x1:x2] |
| if m.size == 0 or not m.any(): |
| continue |
| red = int(((hue < 15) | (hue > 240))[m].sum()) |
| amber = int(((hue >= 15) & (hue < 45))[m].sum()) |
| green = int(((hue >= 60) & (hue < 110))[m].sum()) |
| counts = [ |
| (red, LightState.red), |
| (amber, LightState.amber), |
| (green, LightState.green), |
| ] |
| top = max(counts, key=lambda t: t[0]) |
| light.state = top[1] if top[0] > 0 else LightState.unknown |
|
|