from __future__ import annotations import re from typing import Any, Protocol from .models import Detection def parse_class_prompt(class_prompt: str | list[str] | tuple[str, ...]) -> list[str]: if isinstance(class_prompt, (list, tuple)): parts = [str(item) for item in class_prompt] else: parts = re.split(r"[,;\n]+", class_prompt) seen: set[str] = set() classes: list[str] = [] for part in parts: label = " ".join(part.strip().split()) key = label.lower() if label and key not in seen: seen.add(key) classes.append(label) return classes class Detector(Protocol): class_names: list[str] def detect( self, frame: Any, *, frame_index: int, timestamp_sec: float, confidence: float, image_size: int | None = None, max_detections: int | None = None, ) -> list[Detection]: ... class GreedyIoUTracker: """Assign stable track IDs to detections by greedy IoU matching across frames. Detection stays the source of truth: every detection is returned, matched to an existing track when their same-label boxes overlap enough, or given a fresh ID otherwise. Nothing is dropped for failing to match — unlike a confirm-before-emit tracker (e.g. ByteTrack), which withholds unconfirmed detections and lowers recall. A track that goes unmatched for more than ``max_age`` frames is forgotten. """ def __init__(self, *, iou_threshold: float = 0.3, max_age: int = 2) -> None: self.iou_threshold = iou_threshold self.max_age = max_age self._tracks: dict[int, dict[str, Any]] = {} self._next_id = 1 def assign(self, detections: list[Detection]) -> list[Detection]: for track in self._tracks.values(): track["age"] += 1 assigned: list[Detection] = [] used: set[int] = set() for detection in sorted(detections, key=lambda item: item.confidence, reverse=True): label = detection.label.strip().lower() best_id: int | None = None best_iou = self.iou_threshold for track_id, track in self._tracks.items(): if track_id in used or track["label"] != label: continue iou = _box_iou(detection.bbox_xyxy_norm, track["bbox"]) if iou >= best_iou: best_iou = iou best_id = track_id if best_id is None: best_id = self._next_id self._next_id += 1 used.add(best_id) self._tracks[best_id] = {"bbox": detection.bbox_xyxy_norm, "label": label, "age": 0} assigned.append(detection.model_copy(update={"track_id": best_id})) self._tracks = {track_id: track for track_id, track in self._tracks.items() if track["age"] <= self.max_age} return assigned class UltralyticsYOLOEDetector: def __init__( self, *, class_names: list[str], model_name: str = "yoloe-26s-seg.pt", device: str | None = None, tracking_enabled: bool = False, ) -> None: if not class_names: raise ValueError("YOLOE needs at least one open-vocabulary class.") try: from ultralytics import YOLOE except ImportError as exc: # pragma: no cover - optional heavy dependency raise RuntimeError("Install ultralytics to use the YOLOE detector.") from exc self.class_names = class_names self.model_name = model_name self.device = device self.tracking_enabled = tracking_enabled self._tracker = GreedyIoUTracker() if tracking_enabled else None self.model = YOLOE(model_name) self.model.set_classes(class_names) def detect( self, frame: Any, *, frame_index: int, timestamp_sec: float, confidence: float, image_size: int | None = None, max_detections: int | None = None, ) -> list[Detection]: kwargs: dict[str, Any] = {"conf": confidence, "verbose": False} if self.device: kwargs["device"] = self.device if image_size: kwargs["imgsz"] = image_size if max_detections: kwargs["max_det"] = max_detections results = self.model.predict(frame, **kwargs) if not results: return [] detections = detections_from_ultralytics_result( results[0], frame_shape=frame.shape, frame_index=frame_index, timestamp_sec=timestamp_sec, fallback_names=self.class_names, ) if self._tracker is not None: detections = self._tracker.assign(detections) return detections def detections_from_ultralytics_result( result: Any, *, frame_shape: tuple[int, int, int], frame_index: int, timestamp_sec: float, fallback_names: list[str], ) -> list[Detection]: boxes = getattr(result, "boxes", None) if boxes is None or boxes.xyxy is None: return [] height, width = frame_shape[:2] xyxy_values = boxes.xyxy.cpu().tolist() confidences = boxes.conf.cpu().tolist() class_ids = boxes.cls.cpu().tolist() raw_track_ids = getattr(boxes, "id", None) track_ids = raw_track_ids.cpu().tolist() if raw_track_ids is not None else [None] * len(xyxy_values) names = getattr(result, "names", {}) or {} detections: list[Detection] = [] for bbox, score, class_id, track_id in zip(xyxy_values, confidences, class_ids, track_ids): label = _label_from_names(names, int(class_id), fallback_names) detections.append( Detection( frame_index=frame_index, timestamp_sec=timestamp_sec, label=label, confidence=float(score), bbox_xyxy=tuple(float(value) for value in bbox), bbox_xyxy_norm=_normalize_box(bbox, width, height), track_id=int(track_id) if track_id is not None else None, ) ) return suppress_duplicate_detections(detections) def suppress_duplicate_detections( detections: list[Detection], *, iou_threshold: float = 0.8, ) -> list[Detection]: """Collapse heavily overlapping same-label boxes into one. Keeps the highest-confidence box's geometry/label/score, but carries the *oldest* track_id in the overlap group. Ultralytics assigns track IDs from an incrementing counter, so the smallest ID is the longest-lived track; preferring it keeps identity stable across frames instead of letting it flip with per-frame confidence (which otherwise caused cooldown/count misfires). """ kept: list[Detection] = [] for detection in sorted(detections, key=lambda item: item.confidence, reverse=True): match_index = next( ( index for index, existing in enumerate(kept) if _same_label(detection, existing) and _box_iou(detection.bbox_xyxy_norm, existing.bbox_xyxy_norm) >= iou_threshold ), None, ) if match_index is None: kept.append(detection) continue existing = kept[match_index] oldest_id = _oldest_track_id(existing.track_id, detection.track_id) if oldest_id != existing.track_id: kept[match_index] = existing.model_copy(update={"track_id": oldest_id}) return sorted(kept, key=lambda item: (item.frame_index, item.label, item.bbox_xyxy_norm)) def _label_from_names(names: Any, class_id: int, fallback_names: list[str]) -> str: if isinstance(names, dict) and class_id in names: return str(names[class_id]) if isinstance(names, list) and 0 <= class_id < len(names): return str(names[class_id]) if 0 <= class_id < len(fallback_names): return fallback_names[class_id] return f"class_{class_id}" def _normalize_box(bbox: list[float], width: int, height: int) -> tuple[float, float, float, float]: x1, y1, x2, y2 = bbox if width <= 0 or height <= 0: return (0.0, 0.0, 0.0, 0.0) return ( _clamp01(float(x1) / width), _clamp01(float(y1) / height), _clamp01(float(x2) / width), _clamp01(float(y2) / height), ) def _clamp01(value: float) -> float: return max(0.0, min(1.0, value)) def _same_label(left: Detection, right: Detection) -> bool: return left.label.strip().lower() == right.label.strip().lower() def _oldest_track_id(left: int | None, right: int | None) -> int | None: ids = [value for value in (left, right) if value is not None] if not ids: return None return min(ids) def _box_iou( left: tuple[float, float, float, float], right: tuple[float, float, float, float], ) -> float: ax1, ay1, ax2, ay2 = left bx1, by1, bx2, by2 = right intersection_width = max(0.0, min(ax2, bx2) - max(ax1, bx1)) intersection_height = max(0.0, min(ay2, by2) - max(ay1, by1)) intersection = intersection_width * intersection_height if intersection <= 0: return 0.0 left_area = max(0.0, ax2 - ax1) * max(0.0, ay2 - ay1) right_area = max(0.0, bx2 - bx1) * max(0.0, by2 - by1) union = left_area + right_area - intersection if union <= 0: return 0.0 return intersection / union