| """YOLO detector wrapper. |
| |
| Produces class-agnostic-ish bounding boxes plus the raw COCO class YOLO thinks |
| each box is. Downstream, the taxonomy/CLIP stage decides the *hierarchical* |
| label; YOLO here is only responsible for "there is an object, here is its box". |
| """ |
| from __future__ import annotations |
|
|
| from dataclasses import dataclass |
| from functools import lru_cache |
| from typing import Optional |
|
|
| import numpy as np |
|
|
|
|
| @dataclass |
| class Box: |
| """Axis-aligned box in pixel coordinates plus YOLO's own guess.""" |
|
|
| x1: float |
| y1: float |
| x2: float |
| y2: float |
| coco_name: str |
| coco_conf: float |
|
|
| @property |
| def xyxy(self) -> tuple[int, int, int, int]: |
| return int(self.x1), int(self.y1), int(self.x2), int(self.y2) |
|
|
| def area_frac(self, img_w: int, img_h: int) -> float: |
| w = max(0.0, self.x2 - self.x1) |
| h = max(0.0, self.y2 - self.y1) |
| return (w * h) / float(max(1, img_w * img_h)) |
|
|
| def crop(self, image: np.ndarray, pad: float = 0.06) -> np.ndarray: |
| """Return the (slightly padded) image region for this box.""" |
| h, w = image.shape[:2] |
| pw, ph = (self.x2 - self.x1) * pad, (self.y2 - self.y1) * pad |
| x1 = int(max(0, self.x1 - pw)) |
| y1 = int(max(0, self.y1 - ph)) |
| x2 = int(min(w, self.x2 + pw)) |
| y2 = int(min(h, self.y2 + ph)) |
| return image[y1:y2, x1:x2] |
|
|
|
|
| class Detector: |
| """Thin wrapper around Ultralytics YOLO with lazy model loading.""" |
|
|
| def __init__(self, weights: str = "yolov8n.pt", conf: float = 0.25) -> None: |
| self.weights = weights |
| self.conf = conf |
| self._model = None |
|
|
| @property |
| def model(self): |
| if self._model is None: |
| |
| |
| from ultralytics import YOLO |
|
|
| self._model = YOLO(self.weights) |
| return self._model |
|
|
| def detect(self, image: np.ndarray, conf: Optional[float] = None) -> list[Box]: |
| """Run detection on an RGB image and return boxes. |
| |
| We use class-agnostic NMS so that a novel object which weakly activates |
| several COCO heads still yields a single box (recall matters more than |
| the COCO label here -- the taxonomy decides the real category). A low |
| confidence keeps recall on out-of-distribution objects that no COCO |
| class fits well; the safety floor and constraints filter the noise.""" |
| results = self.model.predict( |
| image, conf=conf if conf is not None else self.conf, |
| iou=0.5, agnostic_nms=True, verbose=False, |
| ) |
| names = self.model.names |
| boxes: list[Box] = [] |
| for res in results: |
| for b in res.boxes: |
| x1, y1, x2, y2 = b.xyxy[0].tolist() |
| cls_id = int(b.cls[0].item()) |
| boxes.append( |
| Box( |
| x1=x1, |
| y1=y1, |
| x2=x2, |
| y2=y2, |
| coco_name=names.get(cls_id, str(cls_id)), |
| coco_conf=float(b.conf[0].item()), |
| ) |
| ) |
| return boxes |
|
|
|
|
| @lru_cache(maxsize=2) |
| def get_detector(weights: str = "yolov8s.pt", conf: float = 0.20) -> Detector: |
| """Cached detector so the model is loaded at most once per weights file. |
| |
| Default is YOLOv8s (not the nano model): its higher recall is what lets the |
| prominent, safety-critical novel objects (an atypical tractor, an overloaded |
| truck) get a box at all. Missing a large object is the worst failure.""" |
| return Detector(weights=weights, conf=conf) |
|
|