hitsmart-cv / sam2_tracker.py
LuciPengu
Honest metric surface (repo bde7adf)
d0e989c
Raw
History Blame Contribute Delete
14.6 kB
"""
SAM2 two-fighter tracker — Space GPU path (occlusion-robust identity + crop-pose).
Validated in Colab: two-fighter coverage 23% (pose-only) -> 58% (this) on hard
footage. SAM2 segments + tracks each fighter through the clip; pose runs on each
fighter's mask CROP (isolated fighter = easier detection) and the result feeds
the UNCHANGED feature extractor + overlay renderer.
Space-specific differences from the Colab original:
- Frames are extracted to JPEGs with cv2 and fed to SAM2's init_state as a
directory (SAM2's mp4 loader needs decord, which has no py3.13 wheels).
- Identity seeds at the BEST frame (both fighters big, near — same idea as the
pose path's _seed_frame), then propagates FORWARD AND BACKWARD; the Colab
version seeded at frame 0 and inherited the bystander bug.
- fp16 autocast on pre-Ampere GPUs (T4 has no bfloat16), bf16 on Ampere+.
- Long clips are strided to ~TARGET_FRAMES like the pose path.
"""
import os
import shutil
import tempfile
import numpy as np
import cv2
import torch
import pose_features as pf
SAM2_ID = "facebook/sam2-hiera-base-plus"
SAM2_POSE_MODEL = "yolo11m-pose.pt" # bigger model is fine: it only sees small crops
# (distinct name: POSE_MODEL is the env knob for
# pose_features' full-frame model)
DET_MODEL = "yolo11n.pt"
KP_CONF = 0.30
# Frame BUDGET, not a frame RATE -- so effective fps is TARGET_FRAMES divided
# by clip length, and a long clip is measured more coarsely than a short one.
# At 300 a 30s clip was sampled at 10 fps and a 120s clip at 2.5 fps, which is
# below the floor where a punch can be detected at all: a hand travels further
# between two samples than the whole extension the detector looks for. 750
# holds every clip up to the 60s limit at >=12.5 fps.
#
# This is a GPU-cost decision as much as an accuracy one -- it is ~2.5x the
# frames of the old budget. Lower it and clips get coarser, do not get faster
# per frame. See docs/research/accuracy-for-combat-sports-athletes.md section 1.
TARGET_FRAMES = int(os.environ.get("SAM2_TARGET_FRAMES", "750"))
SEED_DET_STRIDE = 10 # run the person detector on every Nth sampled frame
# Per-process caches — constructing YOLO/SAM2 per request costs seconds each,
# and _tracks_collapsed retries were re-paying it up to 3x per clip.
_models = None
_predictor = None
def _get_models():
# Imports stay lazy (heavy CUDA init) but must live HERE: at call time this
# runs at module scope, where a function-local import in the caller is not
# visible.
global _models
if _models is None:
from ultralytics import YOLO
_models = (YOLO(DET_MODEL), YOLO(SAM2_POSE_MODEL))
return _models
def _get_predictor():
global _predictor
if _predictor is None:
from sam2.sam2_video_predictor import SAM2VideoPredictor
_predictor = SAM2VideoPredictor.from_pretrained(SAM2_ID)
return _predictor
def _extract_jpeg_frames(video_path, tmpdir):
"""Sample the clip to ~TARGET_FRAMES and write JPEGs SAM2 can read.
Returns (frame_paths, stride, fps, (w, h))."""
cap = cv2.VideoCapture(video_path)
total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
fps = cap.get(cv2.CAP_PROP_FPS) or 30.0
stride = max(1, total // TARGET_FRAMES) if total > TARGET_FRAMES else 1
paths, idx, si = [], 0, 0
size = None
while True:
ok, frame = cap.read()
if not ok:
break
if idx % stride == 0:
p = os.path.join(tmpdir, f"{si:05d}.jpg")
cv2.imwrite(p, frame, [cv2.IMWRITE_JPEG_QUALITY, 92])
paths.append(p)
size = (frame.shape[1], frame.shape[0])
si += 1
idx += 1
cap.release()
return paths, stride, fps, size
def _iou(a, b):
ix0, iy0 = max(a[0], b[0]), max(a[1], b[1])
ix1, iy1 = min(a[2], b[2]), min(a[3], b[3])
iw, ih = max(0.0, ix1 - ix0), max(0.0, iy1 - iy0)
inter = iw * ih
ua = (a[2] - a[0]) * (a[3] - a[1]) + (b[2] - b[0]) * (b[3] - b[1]) - inter
return inter / ua if ua > 0 else 0.0
MAX_SEED_IOU = 0.25 # the two seed boxes must be DIFFERENT people:
MIN_CENTER_SEP = 0.6 # low overlap AND centers > this many box-widths apart
def _valid_pair(bi, bj):
"""True if two person boxes are plausibly two DIFFERENT people. YOLO can
emit two boxes for one person (part + whole); seeding SAM2 with such a pair
tracks the same fighter twice — 'Fighter A and B are the same guy'."""
if _iou(bi, bj) > MAX_SEED_IOU:
return False
wi, wj = bi[2] - bi[0], bj[2] - bj[0]
ci = ((bi[0] + bi[2]) / 2, (bi[1] + bi[3]) / 2)
cj = ((bj[0] + bj[2]) / 2, (bj[1] + bj[3]) / 2)
sep = float(np.hypot(ci[0] - cj[0], ci[1] - cj[1]))
return sep > MIN_CENTER_SEP * (wi + wj) / 2
def _seed_candidates(det, frame_paths, k=3):
"""Ranked [(seed_idx, two boxes left->right), ...] of frames where two
genuinely DISTINCT large people are visible. Candidates are spaced apart in
time so a retry after a collapsed pair tries a different moment, not the
neighbouring frame with the same problem."""
scored = []
for i in range(0, len(frame_paths), SEED_DET_STRIDE):
frame = cv2.imread(frame_paths[i])
r = det.predict(frame, classes=[0], verbose=False,
imgsz=640, conf=0.25, **pf.predict_kwargs())[0]
if r.boxes is None or len(r.boxes) < 2:
continue
b = r.boxes.xyxy.cpu().numpy()
areas = (b[:, 2] - b[:, 0]) * (b[:, 3] - b[:, 1])
order = np.argsort(-areas)[:4] # consider the 4 largest
best = None
for x in range(len(order)):
for y in range(x + 1, len(order)):
bi, bj = b[order[x]], b[order[y]]
if not _valid_pair(bi, bj):
continue
score = float(np.sqrt(min(areas[order[x]], areas[order[y]])))
if best is None or score > best[0]:
best = (score, bi, bj)
if best is not None:
two = np.stack([best[1], best[2]])
scored.append((best[0], i, two[np.argsort(two[:, 0])]))
scored.sort(key=lambda t: -t[0])
picked = []
for s, i, boxes in scored:
if all(abs(i - j) >= SEED_DET_STRIDE * 3 for _, j, _ in picked):
picked.append((s, i, boxes))
if len(picked) >= k:
break
return [(i, boxes) for _, i, boxes in picked]
def _tracks_collapsed(poses):
"""True if the two tracks are really the same person: among frames where
both are present, their hips stay within a fraction of a shoulder-width."""
hipA = np.nanmean(poses[:, 0, [pf.L_HIP, pf.R_HIP]], axis=1)
hipB = np.nanmean(poses[:, 1, [pf.L_HIP, pf.R_HIP]], axis=1)
both = ~np.isnan(hipA).any(1) & ~np.isnan(hipB).any(1)
if both.sum() < 10:
return True # too little signal to trust
gaps = np.linalg.norm(hipA[both] - hipB[both], axis=1)
sh = np.concatenate([
np.linalg.norm(poses[:, 0, pf.L_SH] - poses[:, 0, pf.R_SH], axis=1),
np.linalg.norm(poses[:, 1, pf.L_SH] - poses[:, 1, pf.R_SH], axis=1)])
scale = np.nanmedian(sh)
return bool(np.median(gaps) < 0.6 * scale)
def _autocast_dtype():
if torch.cuda.is_available() and torch.cuda.is_bf16_supported():
return torch.bfloat16
return torch.float16 # T4 (sm_75) has no bf16
def _pose_in_mask(frame, mask, pose):
"""Pose on the crop around a fighter's mask -> full-frame keypoints or None."""
ys, xs = np.where(mask)
if len(xs) < 50:
return None
x0, x1, y0, y1 = int(xs.min()), int(xs.max()), int(ys.min()), int(ys.max())
pad = int(0.12 * max(x1 - x0, y1 - y0))
h, w = frame.shape[:2]
x0, y0 = max(0, x0 - pad), max(0, y0 - pad)
x1, y1 = min(w, x1 + pad), min(h, y1 + pad)
crop = frame[y0:y1, x0:x1]
if crop.size == 0:
return None
r = pose.predict(crop, classes=[0], verbose=False,
imgsz=640, conf=0.25, **pf.predict_kwargs())[0]
if r.keypoints is None or r.boxes is None or len(r.boxes) == 0:
return None
# pick the detection ON THIS FIGHTER'S MASK, not the largest in the crop:
# at close range both fighters appear in both crops, and largest-wins gave
# BOTH tracks the same person's pose exactly during exchanges
sub = mask[y0:y1, x0:x1]
best_i, best_ov = -1, 0.0
for j, (bx0, by0, bx1, by1) in enumerate(r.boxes.xyxy.cpu().numpy().astype(int)):
bx0, by0 = max(0, bx0), max(0, by0)
bx1, by1 = min(sub.shape[1], bx1), min(sub.shape[0], by1)
if bx1 <= bx0 or by1 <= by0:
continue
ov = float(sub[by0:by1, bx0:bx1].mean()) # share of box on the mask
if ov > best_ov:
best_ov, best_i = ov, j
if best_i < 0 or best_ov < 0.15:
return None # nobody actually on the mask
i = best_i
kp = r.keypoints.xy[i].cpu().numpy()
cf = r.keypoints.conf[i].cpu().numpy()
kp[cf < KP_CONF] = np.nan
kp[:, 0] += x0
kp[:, 1] += y0
return kp
def sam2_tracks(video_path, progress_cb=None):
"""RawTracks from SAM2 mask propagation + crop pose.
Naming, left/right ordering, features and metadata are the Tracker seam's
shared tail (trackers.finalize) — this only produces tracks. Raises on
failure; the chain logs it and falls back.
SAM2 has no idea which fighter the operator marked, so `seed_a_track` is
left None: on a tapped clip the tail recovers the subject from the taps,
and says so honestly when it can't.
progress_cb(frac_0_to_1, desc) — optional; drives the app's loading bar
through the three long stages (seed scan / SAM2 propagation / crop pose)."""
from trackers import RawTracks, TAG_SAM2
cb = progress_cb or (lambda f, d: None)
tmpdir = tempfile.mkdtemp(prefix="sam2_frames_")
try:
cb(0.01, "sampling frames from the clip")
frame_paths, stride, fps, size = _extract_jpeg_frames(video_path, tmpdir)
n = len(frame_paths)
if n < pf.MIN_PAIR_FRAMES:
raise RuntimeError(f"clip too short after sampling ({n} frames)")
print(f"[sam2] {n} frames (stride {stride}) at {size}", flush=True)
cb(0.04, "finding the two fighters")
det, pose = _get_models()
candidates = _seed_candidates(det, frame_paths)
if not candidates:
raise RuntimeError("no frame with two distinct people found to seed SAM2")
predictor = _get_predictor()
poses = cols = None
for attempt, (seed, boxes) in enumerate(candidates, 1):
print(f"[sam2] attempt {attempt}: seeding at frame {seed}/{n}", flush=True)
retry = f" (retry {attempt})" if attempt > 1 else ""
masks = {}
done = 0
with torch.inference_mode(), torch.autocast("cuda", dtype=_autocast_dtype()):
# Offload frames + inference state to CPU RAM: init_state
# otherwise loads every sampled frame onto the GPU at once —
# ~480 full-res frames is a real OOM risk on a 16 GB T4.
# Per-frame transfer cost is minor next to propagation.
state = predictor.init_state(
tmpdir,
offload_video_to_cpu=True,
offload_state_to_cpu=True,
)
for oid, box in enumerate(boxes):
predictor.add_new_points_or_box(state, frame_idx=seed, obj_id=oid, box=box)
for fidx, obj_ids, logits in predictor.propagate_in_video(state):
masks[fidx] = {int(o): (logits[i, 0] > 0).cpu().numpy()
for i, o in enumerate(obj_ids)}
done += 1
if done % 10 == 0:
cb(0.10 + 0.40 * min(done / max(n, 1), 1.0),
f"tracking both fighters{retry} — frame {done}/{n}")
for fidx, obj_ids, logits in predictor.propagate_in_video(state, reverse=True):
masks.setdefault(fidx, {}).update(
{int(o): (logits[i, 0] > 0).cpu().numpy()
for i, o in enumerate(obj_ids)})
done += 1
if done % 10 == 0:
cb(0.10 + 0.40 * min(done / max(n, 1), 1.0),
f"tracking both fighters{retry} — frame {done}/{n}")
print(f"[sam2] propagation done ({len(masks)} frames with masks)", flush=True)
poses = np.full((n, 2, 17, 2), np.nan)
overlap = np.full(n, np.nan) # per-frame mask overlap -> clinch metric
cols = ([], [])
for si, p in enumerate(frame_paths):
if si % 10 == 0:
cb(0.50 + 0.50 * si / max(n, 1),
f"reading each fighter's pose{retry} — frame {si}/{n}")
fm = masks.get(si)
if not fm:
continue
frame = cv2.imread(p)
rm = {}
for oid, m in fm.items():
if oid > 1 or m is None:
continue
if m.shape[:2] != frame.shape[:2]:
m = cv2.resize(m.astype(np.uint8), (frame.shape[1], frame.shape[0]),
interpolation=cv2.INTER_NEAREST).astype(bool)
rm[oid] = m
kp = _pose_in_mask(frame, m, pose)
if kp is not None:
poses[si, oid] = kp
cols[oid].append(pf._torso_bgr(frame, kp))
if 0 in rm and 1 in rm:
smaller = min(int(rm[0].sum()), int(rm[1].sum()))
if smaller > 50:
overlap[si] = float(np.logical_and(rm[0], rm[1]).sum()) / smaller
if not _tracks_collapsed(poses):
break
print("[sam2] tracks collapsed onto one person; retrying from another seed",
flush=True)
poses = None
if poses is None:
raise RuntimeError("every SAM2 seeding attempt tracked one person twice")
return RawTracks(poses=poses, cols=cols, fps=fps, stride=stride,
tag=TAG_SAM2, frame_size=size, mask_overlap=overlap)
finally:
shutil.rmtree(tmpdir, ignore_errors=True)