"""
YOLOv8-pose backend for real-time human pose estimation.
Recommended model weights by speed/accuracy trade-off:
yolov8n-pose.pt — nano, ~4 MB, fastest, good for CPU preview (≥15 fps)
yolov8s-pose.pt — small, ~24 MB, balanced (CPU ≥8 fps, GPU ≥60 fps)
yolov8m-pose.pt — medium, ~52 MB, accurate (GPU recommended)
yolov8l-pose.pt — large, ~87 MB, best (GPU required)
Install: pip install ultralytics
"""
from __future__ import annotations
import time
import numpy as np
from .keypoints import PoseResult, SubjectPose
# ── lazy import: ultralytics is optional ─────────────────────────────────────
try:
from ultralytics import YOLO as _YOLO
_ULTRALYTICS_OK = True
except ImportError:
_ULTRALYTICS_OK = False
_YOLO = None # type: ignore[assignment]
# Tracker name passed to ultralytics and recorded in the .pose.json header.
# BoT-SORT over ByteTrack because it adds camera-motion compensation and
# optional ReID on top of the same association core, and ships with
# ultralytics either way — no new dependency.
TRACKER_NAME = "botsort"
TRACKER_CONFIG = "botsort.yaml"
# Confidence floor handed to the tracker, as opposed to the one applied to
# what actually gets written. Matches botsort.yaml's track_low_thresh.
TRACK_INPUT_CONF = 0.1
def resolve_subject_ids(track_ids: list[int | None] | None, detection_count: int) -> list[int]:
"""Map one frame's ultralytics track ids onto MOSAIC subject ids.
Parameters
----------
track_ids : list of (int or None), or None
``res.boxes.id`` converted to plain ints, or ``None`` when the tracker
produced nothing for this frame (it has no confirmed tracks yet).
detection_count : int
How many detections this frame actually has. The returned list is
always exactly this long.
Returns
-------
list of int
A tracker id (always >= 1) is used as-is — it means "the same physical
person" across frames, which is the entire point. Anything else falls
back to ``-(i + 1)``: negative marks "not tracked in this frame", and
it stays distinct per detection so two untracked people never collapse
onto one id. Collapsing them would be worse than the bug this replaces,
because the C++ side looks subjects up *by* id.
Length mismatches are resolved per index rather than by zipping: a
silent shift would attach one person's keypoints to another person's
id, which is exactly the failure mode being eliminated.
"""
if detection_count <= 0:
return []
ids = track_ids or []
out: list[int] = []
for i in range(detection_count):
tid = ids[i] if i < len(ids) else None
out.append(int(tid) if tid is not None and int(tid) > 0 else -(i + 1))
return out
def _reset_model_trackers(model) -> bool:
"""Reset every tracker attached to a YOLO model's predictor.
Returns ``False`` when there is nothing to reset — no ``track()`` call has
happened yet, so no predictor/trackers exist. Split out from
:meth:`HumanPoseEstimator.reset_tracker` so it can be unit-tested against a
stub model, without ultralytics installed.
"""
predictor = getattr(model, "predictor", None)
trackers = getattr(predictor, "trackers", None) if predictor is not None else None
if not trackers:
return False
for tracker in trackers:
tracker.reset()
return True
[docs]
class HumanPoseEstimator:
"""Wraps YOLOv8-pose for single-frame inference.
Parameters
----------
model_name : str, default "yolov8n-pose.pt"
YOLOv8 model variant. Downloaded automatically from the
Ultralytics CDN on first use (~4-87 MB).
device : str or None, default None
Inference device — ``"cpu"``, ``"cuda:0"``, ``"mps"`` (Apple
Silicon). ``None`` auto-detects (prefers CUDA → MPS → CPU).
conf_threshold : float, default 0.40
Minimum detection confidence to include a subject.
iou_threshold : float, default 0.70
NMS IoU threshold.
Raises
------
ImportError
If ``ultralytics`` is not installed.
"""
def __init__(
self,
model_name: str = "yolov8n-pose.pt",
device: str | None = None,
conf_threshold: float = 0.40,
iou_threshold: float = 0.70,
) -> None:
if not _ULTRALYTICS_OK:
raise ImportError("ultralytics is not installed. Run: pip install ultralytics")
self._conf = conf_threshold
self._iou = iou_threshold
self._device = device or self._auto_device()
print(f"[HumanPoseEstimator] Loading {model_name} on {self._device} …", flush=True)
self._model = _YOLO(model_name)
# Warm-up pass (avoids slow first real inference)
dummy = np.zeros((320, 320, 3), dtype=np.uint8)
self._model(dummy, verbose=False, device=self._device)
print("[HumanPoseEstimator] Ready.", flush=True)
# ── Public API ────────────────────────────────────────────────────────────
[docs]
def reset_tracker(self) -> None:
"""Drop all cross-frame tracking state, starting a fresh identity space.
MUST be called at every video boundary. :meth:`infer` passes
``persist=True``, and ultralytics' own "switched videos" auto-reset
never fires for us — it is short-circuited by ``persist=True``, and in
any case keys off the source path, which is constant for the in-memory
ndarray frames we pass. Without this, ``run_pose.py``'s session mode
(one estimator threaded through every camera's video) would hand
camera N's live tracks to camera N+1's opening frames and silently glue
two different people onto one subject id.
Resetting also restarts ids at 1 per video, which is what makes the
"ids are per-video and never comparable across cameras" contract in the
exported CSV true rather than aspirational.
"""
if not _reset_model_trackers(self._model):
# Nothing attached yet (no track() call so far), or a build that
# doesn't expose .trackers. Dropping the predictor forces
# ultralytics to rebuild it — and its trackers — on the next call.
self._model.predictor = None
[docs]
def infer(
self,
frame: np.ndarray,
frame_index: int = 0,
timestamp_ns: int = 0,
camera_index: int = 0,
) -> PoseResult:
"""Run pose estimation on one BGR frame.
Parameters
----------
frame : numpy.ndarray
BGR frame, as returned by ``cv2.imread``/``cv2.VideoCapture``.
frame_index : int, default 0
Caller-supplied frame index, echoed into the result.
timestamp_ns : int, default 0
Caller-supplied timestamp (ns), echoed into the result.
camera_index : int, default 0
Caller-supplied camera index, echoed into the result.
Returns
-------
PoseResult
Structured result containing per-subject keypoints, echoing
``frame_index``/``timestamp_ns``/``camera_index`` back
unchanged and reporting this call's own ``inference_ms``.
"""
t0 = time.perf_counter()
# track(), not a plain detection call: subject_id must mean "the same
# physical person" across frames. persist=True keeps tracker state
# between calls, which is why reset_tracker() has to run at every video
# boundary (see its docstring).
#
# inference_ms now includes the tracker update, not just the network.
results = self._model.track(
frame,
# Deliberately below self._conf. BoT-SORT's second association
# stage — the one that keeps a track alive through a weak or
# partly-occluded frame — can only work with detections the
# detector was allowed to emit, and botsort.yaml's
# track_low_thresh is 0.1. Passing our own 0.40 here would starve
# it completely (ultralytics defaults conf to 0.1 for exactly this
# reason, but only when the caller doesn't pass one).
#
# Which detections get *written* is unchanged — self._conf is
# re-applied per detection below, and new_track_thresh still stops
# weak detections from spawning their own tracks. What does change
# is that NMS (iou=self._iou) now also runs across the 0.1-0.40
# band, so a weak duplicate box of an already-tracked person can
# survive it, take that person's track, and push the strong box
# onto a fresh id — a spurious identity split in exactly the
# crowded frames this is meant to help. Not asserted away: it is
# on the room-11 verification list to check against real footage.
conf=min(self._conf, TRACK_INPUT_CONF),
iou=self._iou,
device=self._device,
persist=True,
tracker=TRACKER_CONFIG,
verbose=False,
)
inference_ms = (time.perf_counter() - t0) * 1000.0
subjects: list[SubjectPose] = []
for res in results:
if res.keypoints is None:
continue
kpts_xy = res.keypoints.xy.cpu().numpy() # (N, 17, 2)
kpts_conf = res.keypoints.conf # may be None
boxes = res.boxes
# track() pins batch=1, so `results` holds exactly one `res` and
# the -(i+1) fallbacks below cannot collide across iterations.
raw_ids: list[int] | None = None
if boxes is not None and boxes.id is not None:
raw_ids = [int(v) for v in boxes.id.int().cpu().tolist()]
subject_ids = resolve_subject_ids(raw_ids, len(kpts_xy))
for i, kxy in enumerate(kpts_xy):
vis: list[float]
if kpts_conf is not None:
vis = kpts_conf[i].cpu().numpy().tolist()
else:
vis = [1.0] * kxy.shape[0]
det_conf = float(boxes.conf[i].cpu()) if boxes is not None else 1.0
# Re-apply the caller's real confidence floor, since the
# tracker was deliberately fed a lower one above.
if det_conf < self._conf:
continue
bbox = (0.0, 0.0, 0.0, 0.0)
if boxes is not None:
b = boxes.xyxy[i].cpu().numpy()
bbox = (float(b[0]), float(b[1]), float(b[2]), float(b[3]))
subjects.append(
SubjectPose(
subject_id=subject_ids[i],
confidence=det_conf,
keypoints=[(float(pt[0]), float(pt[1])) for pt in kxy],
visibilities=vis,
bbox_xyxy=bbox,
)
)
return PoseResult(
frame_index=frame_index,
timestamp_ns=timestamp_ns,
camera_index=camera_index,
subjects=subjects,
backend=f"yolov8-pose/{self._model.ckpt_path}",
inference_ms=inference_ms,
)
# ── Helpers ───────────────────────────────────────────────────────────────
@staticmethod
def _auto_device() -> str:
try:
import torch
if torch.cuda.is_available():
return "cuda:0"
if torch.backends.mps.is_available():
return "mps"
except ImportError:
pass
return "cpu"