tiny-trigger / tiny_trigger /detector.py
Javier Montalvo
Decouple tracking from detection; size-relative motion; UI tuning
114ea19
Raw
History Blame Contribute Delete
9.49 kB
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