howc / hpercept /detector.py
freshNfunky's picture
HOWC hierarchical perception: card + runnable code
a9d655b verified
Raw
History Blame Contribute Delete
3.81 kB
"""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 # YOLO's COCO label (e.g. "car")
coco_conf: float # YOLO's confidence for that label
@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:
# Imported lazily so the app can start (and YOLO-only-less modes can
# run) without paying the import cost until detection is needed.
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)