"""Inference core shared by the Streamlit app, scripts, and tests. Keeps every consumer on one code path: one weights default, one device-selection rule, one filtering behavior. Deliberately imports no Streamlit — this module must stay importable headless (tests, batch scripts, CI). """ from __future__ import annotations import logging from collections.abc import Sequence from pathlib import Path import numpy as np import torch from PIL import Image from ultralytics import YOLO from ultralytics.engine.results import Results logger = logging.getLogger(__name__) DEFAULT_WEIGHTS = "runs/detect/yolov8n_v1_train/weights/best.pt" # Anything Ultralytics accepts as a single-image source. ImageSource = str | Path | np.ndarray | Image.Image def available_devices() -> list[str]: """Inference devices usable on this machine, preferred-first after cpu.""" devices = ["cpu"] if torch.cuda.is_available(): devices.append("cuda") if torch.backends.mps.is_available(): devices.append("mps") return devices def pick_device() -> str: """Fastest available device: cuda > mps > cpu.""" devices = available_devices() return devices[-1] if len(devices) > 1 else "cpu" def load_model(weights: str | Path = DEFAULT_WEIGHTS, device: str | None = None) -> YOLO: """Load YOLO weights onto `device` (auto-picks the fastest when None).""" device = device or pick_device() model = YOLO(weights) model.to(device) logger.info("Loaded %s on %s", weights, device) return model def detect( model: YOLO, image: ImageSource, conf: float = 0.25, classes: Sequence[int] | None = None, imgsz: int | None = None, ) -> Results: """Run detection on one image, filtered to `conf` and (optionally) `classes`. Filtering uses Ultralytics' native predict-time `conf`/`classes` arguments, which apply before NMS — unlike post-hoc box masking, low-confidence boxes can't suppress real ones first. `imgsz` overrides the model's inference resolution (default 640). Lowering it to 480/320 trades accuracy for a large CPU speedup — the difference between frozen and watchable video on weak hardware like HF Spaces cpu-basic. """ kwargs = {"imgsz": imgsz} if imgsz else {} results = model.predict( image, conf=conf, classes=list(classes) if classes else None, verbose=False, **kwargs ) return results[0] def track( model: YOLO, frame: ImageSource, conf: float = 0.25, classes: Sequence[int] | None = None, imgsz: int | None = None, ) -> Results: """Like detect(), but with ByteTrack identity persistence across calls. Feed consecutive frames of ONE stream to ONE model instance: `persist=True` carries tracker state on the model object, so interleaving streams (or switching videos without a fresh model) corrupts identities. Detections carry `.boxes.id` (may be None for not-yet-confirmed tracks). """ kwargs = {"imgsz": imgsz} if imgsz else {} results = model.track( frame, persist=True, tracker="bytetrack.yaml", conf=conf, classes=list(classes) if classes else None, verbose=False, **kwargs, ) return results[0] def reset_tracker(model: YOLO) -> None: """Clear ByteTrack state so a new video starts with fresh identities. A cached/reused model carries tracker state on its predictor; without a reset, track ids from the previous stream bleed into the next one. No-op if the model has never tracked (predictor/trackers don't exist yet). """ trackers = getattr(getattr(model, "predictor", None), "trackers", None) or [] for t in trackers: if hasattr(t, "reset"): t.reset() def sightings(result: Results) -> list[tuple[int | None, str, float]]: """(track_id, class_name, confidence) triples for one tracked frame.""" out: list[tuple[int | None, str, float]] = [] boxes = result.boxes if boxes is None: return out ids = boxes.id.int().tolist() if boxes.id is not None else [None] * len(boxes) for track_id, cls_idx, conf in zip(ids, boxes.cls.int().tolist(), boxes.conf.tolist(), strict=True): out.append((track_id, result.names[cls_idx], float(conf))) return out def annotate(result: Results) -> np.ndarray: """Draw the detections onto the image and return it as an ndarray. Channel order follows the input: ndarray sources come back as given (the app passes RGB and displays RGB); path sources come back BGR per Ultralytics. """ return result.plot() def detect_and_annotate( model: YOLO, image: ImageSource, conf: float = 0.25, classes: Sequence[int] | None = None, imgsz: int | None = None, ) -> np.ndarray: """Convenience wrapper: detect() then annotate().""" return annotate(detect(model, image, conf=conf, classes=classes, imgsz=imgsz))