""" Layer 1 (v5) - Pose -> structured defensive features, with caching. Identity is now by MOTION CONTINUITY, not color: we keep two tracks (the two fighters) and assign each frame's detections to whichever track was nearest a moment ago. A fighter can't teleport, so this survives them swapping sides and needs no distinguishing colors. Color (when the two fighters read clearly different) is used only to *label* the tracks "Red"/"Blue"; otherwise they're "Fighter A"/"Fighter B". Public API: extract_features(video_path) -> features dict (runs YOLO live) cache_poses(video_path, npz_path) -> saves raw per-frame keypoints (slow step) features_from_cache(npz_path) -> features dict (instant; re-derives) Every threshold is a STARTING GUESS - calibrate with eval.py. """ import os import warnings import numpy as np import cv2 from ultralytics import YOLO warnings.filterwarnings("ignore") # ---- Tunables -------------------------------------------------------------- MODEL = "yolo11n-pose.pt" VID_STRIDE = 1 # 1 = every frame (best quality); raise to speed up long clips on CPU KP_CONF_MIN = 0.30 MIN_SH_PX = 20.0 TRACK_GATE_PX = 160.0 # a track only grabs detections within this many px of its last position SIZE_RATIO_MIN = 0.55 # candidate size vs track size band — rejects the much-smaller SIZE_RATIO_MAX = 1.9 # bystander a track would otherwise drift onto # NOTE: a torso-color gate was tried here (hard and reacquisition-only) and # REMOVED: gym lighting makes per-frame color too noisy — it blocked the real # fighter more than bystanders (coverage 19.7% -> ~10%). Identity through # occlusion is SAM2's job, not a color heuristic's. MIN_COLOR_FRAMES = 10 # frames of torso color needed before naming a track by color GUARD_UP = 0.80 GUARD_DOWN = 1.60 IN_RANGE = 2.50 TAG_DIST = 0.60 TAG_REFRACTORY_S = 0.30 PUNCH_SPEED_SW_S = 3.5 # wrist speed (shoulder-widths/sec) above which a hand is "throwing" EXCHANGE_PAD_S = 0.40 # merge punches this close together into one exchange EXCHANGE_MIN_S = 0.15 # an exchange must last at least this long FLURRY_SPEED_SW_S = 5.0 # combined both-fighter arm activity for a "flurry" window FLURRY_MIN_S = 0.4 # a flurry must sustain at least this long FLURRY_HEAVY_SW_S = 9.0 # peak combined activity above this = "heavy" flurry DROP_MIN_S = 0.50 # hysteresis: seconds below guard before an episode opens CLEAR_MIN_S = 0.30 # hysteresis: seconds back up before an episode closes PRESSURE_MARGIN = 5.0 # pct-point gap between advancing/retreating to call a stance NET_RATE_SW10S = 0.7 # net drift toward/away from opponent (shoulder-widths per 10s) # that alone decides pressuring/backing up — a coach's eye # reads GROUND GIVEN, not step counts PAN_DOMINANCE = 0.50 # common (camera) motion > this x relative motion = pan-dominated BUCKET_S = 30 MIN_PAIR_FRAMES = 10 # --------------------------------------------------------------------------- NOSE, L_SH, R_SH, L_WR, R_WR, L_HIP, R_HIP = 0, 5, 6, 9, 10, 11, 12 L_KNEE, R_KNEE = 13, 14 L_ANK, R_ANK = 15, 16 # ============================ pose + tracking (slow) ======================== def _torso_bgr(frame, kp): """Median BGR over the torso box, or None. Median (not mean) so ring ropes / background bleeding into the box don't tint the read.""" pts = kp[[L_SH, R_SH, L_HIP, R_HIP]] pts = pts[~np.isnan(pts).any(axis=1)] if len(pts) < 2: return None h, w = frame.shape[:2] x0, y0 = pts.min(0) x1, y1 = pts.max(0) x0, x1 = int(max(0, x0)), int(min(w, x1)) y0, y1 = int(max(0, y0)), int(min(h, y1)) if x1 <= x0 or y1 <= y0: return None roi = frame[y0:y1, x0:x1].reshape(-1, 3) return np.median(roi, axis=0) def _color_name(bgr): """Classify a BGR color into a small palette name, or None if ambiguous. Replaces the old (R-B) 'redness' rule, which scored YELLOW gear the same as RED (yellow = high R, low B) and then force-named the other fighter 'Blue' even when nobody wore blue.""" if bgr is None or np.isnan(np.asarray(bgr, dtype=float)).any(): return None px = np.uint8([[np.asarray(bgr, dtype=np.uint8)]]) h, s, v = cv2.cvtColor(px, cv2.COLOR_BGR2HSV)[0, 0] if v < 50: return "black" if s < 45: return "white" if v > 175 else "gray" if h < 8 or h >= 170: return "red" if h < 22: return "orange" if h < 38: return "yellow" if h < 85: return "green" if h < 128: return "blue" return "purple" def _candidates(r): """All people in a frame above the size floor: list of (keypoints, hip_center, shoulder_px).""" cands = [] if r.keypoints is None or r.boxes is None or len(r.boxes) == 0: return cands xy = r.keypoints.xy.cpu().numpy() cf = (r.keypoints.conf.cpu().numpy() if r.keypoints.conf is not None else np.ones(xy.shape[:2])) xy[cf < KP_CONF_MIN] = np.nan shs = np.linalg.norm(xy[:, L_SH] - xy[:, R_SH], axis=1) for i in range(len(xy)): if not (shs[i] >= MIN_SH_PX): continue kp = xy[i] hc = np.nanmean(kp[[L_HIP, R_HIP]], axis=0) if np.isnan(hc).any(): hc = kp[NOSE] if np.isnan(hc).any(): continue cands.append((kp, hc, float(shs[i]))) return cands def _d(p, q): return float(np.linalg.norm(p - q)) def _associate(cands, lastA, lastB): """Gated nearest-neighbour tracking. Each track state is (position, size, torso color); a track grabs its closest detection within TRACK_GATE_PX that is also consistent in size (SIZE_RATIO band) and torso color (COLOR_GATE). Position gate: far-away bystanders can't steal a track. Size gate: nearby much-smaller ones can't either (a ringside spectator is much smaller on screen than the fighter whose track he'd inherit). Returns (kpA, kpB, newLastA, newLastB).""" if not cands: return None, None, lastA, lastB if lastA is None or lastB is None: # seed with the two largest if len(cands) >= 2: top = sorted(range(len(cands)), key=lambda i: -cands[i][2])[:2] top.sort(key=lambda i: cands[i][1][0]) # left -> A, right -> B iA, iB = top return (cands[iA][0], cands[iB][0], (cands[iA][1], cands[iA][2]), (cands[iB][1], cands[iB][2])) return None, None, lastA, lastB def nearest(last, exclude=-1): pos, sz = last best, bd = -1, TRACK_GATE_PX for i, c in enumerate(cands): if i == exclude: continue if not (SIZE_RATIO_MIN * sz < c[2] < SIZE_RATIO_MAX * sz): continue d = _d(c[1], pos) if d < bd: bd, best = d, i return best iA, iB = nearest(lastA), nearest(lastB) if iA != -1 and iA == iB: # both want the same person if _d(cands[iA][1], lastA[0]) <= _d(cands[iA][1], lastB[0]): iB = nearest(lastB, exclude=iA) else: iA = nearest(lastA, exclude=iB) def updated(last, i): if i == -1: return last # smooth the size so a fighter closing on the camera stays in-band return (cands[i][1], 0.7 * last[1] + 0.3 * cands[i][2]) kpA = cands[iA][0] if iA != -1 else None kpB = cands[iB][0] if iB != -1 else None return kpA, kpB, updated(lastA, iA), updated(lastB, iB) def _label_tracks(colsA, colsB): """Name tracks by their ACTUAL dominant torso color ('Yellow fighter' vs 'Red fighter'), but only when the two colors clearly differ; otherwise the neutral 'Fighter A/B' (A = starts on the left).""" def track_color(cols): cols = [c for c in cols if c is not None] if len(cols) < MIN_COLOR_FRAMES: return None return _color_name(np.median(np.stack(cols), axis=0)) ca, cb = track_color(colsA), track_color(colsB) if ca and cb and ca != cb and "gray" not in (ca, cb): return f"{ca.capitalize()} fighter", f"{cb.capitalize()} fighter" return ("Fighter A", "Fighter B") def _seed_frame(all_cands): """Pick the frame to seed identity from: where the two largest people are BOTH big (the actual fighters, mid-action) — not merely the first frame with 2 detections, which happily seeds on a coach/bystander when a fighter is off-screen at t=0 and then follows the wrong person for the whole clip. Score = size of the SECOND-largest person (both must be big), with a small proximity bonus (fighters engage; bystanders hug the edges).""" best, best_score = -1, -1.0 for i, cands in enumerate(all_cands): if len(cands) < 2: continue two = sorted(cands, key=lambda c: -c[2])[:2] pair_dist = _d(two[0][1], two[1][1]) score = two[1][2] - 0.02 * pair_dist if score > best_score: best, best_score = i, score return best def _track_span(all_cands, poses, cols, start, step, lastA, lastB): """Gated NN association from `start` moving by `step` (+1 fwd / -1 back), continuing from known track positions; writes poses + per-track colors.""" n = len(all_cands) i = start while 0 <= i < n: cands = all_cands[i] kpA, kpB, lastA, lastB = _associate(cands, lastA, lastB) if kpA is not None: poses[i, 0] = kpA cols[0].append(_cand_color(cands, kpA)) if kpB is not None: poses[i, 1] = kpB cols[1].append(_cand_color(cands, kpB)) i += step def _cand_color(cands, kp): for c in cands: if c[0] is kp: return c[3] return None def _collect_poses(video_path, vid_stride=1): """Return (poses (n,2,17,2): per frame [trackA, trackB], labels tuple). Two passes: (1) YOLO over the whole clip collecting every person candidate (+ torso color); (2) seed identity at the BEST frame (see _seed_frame) and associate outward in both directions. Backward tracking recovers the start of the clip that frame-0 seeding used to get wrong.""" model = YOLO(MODEL) all_cands = [] for r in model.predict(source=video_path, stream=True, classes=[0], verbose=False, vid_stride=vid_stride): all_cands.append([(kp, hc, sh, _torso_bgr(r.orig_img, kp)) for kp, hc, sh in _candidates(r)]) n = len(all_cands) poses = np.full((n, 2, 17, 2), np.nan) if n == 0: return np.zeros((0, 2, 17, 2)), ("Fighter A", "Fighter B") seed = _seed_frame(all_cands) if seed < 0: # never saw two people at once return poses, ("Fighter A", "Fighter B") # assign identity at the seed frame: two largest people, left -> A scands = all_cands[seed] two = sorted(range(len(scands)), key=lambda j: -scands[j][2])[:2] two.sort(key=lambda j: scands[j][1][0]) iA, iB = two poses[seed, 0], poses[seed, 1] = scands[iA][0], scands[iB][0] cols = ([scands[iA][3]], [scands[iB][3]]) lastA = (scands[iA][1], scands[iA][2]) # track state: (position, size) lastB = (scands[iB][1], scands[iB][2]) _track_span(all_cands, poses, cols, seed + 1, +1, lastA, lastB) # -> end _track_span(all_cands, poses, cols, seed - 1, -1, lastA, lastB) # -> start return poses, _label_tracks(cols[0], cols[1]) def _fps(video_path): cap = cv2.VideoCapture(video_path) fps = cap.get(cv2.CAP_PROP_FPS) or 30.0 cap.release() return fps # ============================ feature math (fast) =========================== def _episodes(mask, min_len): mask = np.nan_to_num(mask).astype(bool) count, run = 0, 0 for v in mask: run = run + 1 if v else 0 if run == min_len: count += 1 return count def _hysteresis_intervals(signal, hi, lo, enter_frames, exit_frames): """Sustained excursions of `signal` above `hi`, debounced by hysteresis. Returns a list of (start_frame, end_frame) intervals; len() = episode count. An episode OPENS only after the signal stays above `hi` for `enter_frames` in a row, and CLOSES only after it drops below `lo` for `exit_frames` in a row. NaN frames are 'no evidence': they hold the current state and don't advance either counter, so a one-frame dropout or a flicker between the thresholds can't split one real lapse into many. This replaces the old run-length count that booked a fresh episode on every brief blip (e.g. the spurious '33 guard-dropped episodes' on a 180-frame read).""" intervals = [] in_episode = False above = below = 0 start = last = 0 for i, v in enumerate(signal): if np.isnan(v): continue last = i if not in_episode: above = above + 1 if v > hi else 0 if above >= enter_frames: start = i - enter_frames + 1 in_episode = True below = 0 else: below = below + 1 if v < lo else 0 if below >= exit_frames: intervals.append((start, i - exit_frames + 1)) in_episode = False above = 0 if in_episode: intervals.append((start, last)) return intervals def _mmss(seconds): seconds = max(0, int(round(seconds))) return f"{seconds // 60}:{seconds % 60:02d}" def _wrist_speed(w, s, fps): """Per-frame wrist speed in shoulder-widths/sec (NaN where unknown).""" d = np.linalg.norm(np.diff(w, axis=0), axis=1) / s * fps return np.concatenate([[np.nan], d]) def _dilate(mask, k): """Grow True regions by k frames each side (merges near-adjacent punches).""" if k <= 0: return mask out = mask.copy() for i in np.where(mask)[0]: out[max(0, i - k):i + k + 1] = True return out def _runs(mask, min_len): """(start, end) frame intervals of True runs lasting at least min_len.""" out, start = [], None for i, v in enumerate(mask): if v and start is None: start = i elif not v and start is not None: if i - start >= min_len: out.append((start, i - 1)) start = None if start is not None and len(mask) - start >= min_len: out.append((start, len(mask) - 1)) return out def _events(mask, refractory): mask = np.nan_to_num(mask).astype(bool) frames, last = [], -10 ** 9 for i, v in enumerate(mask): if v and i - last > refractory: frames.append(i) last = i return np.array(frames, dtype=int) def _interp1(a): """Linear-fill NaNs in a 1-D signal.""" a = a.astype(float).copy() idx = np.arange(len(a)); good = ~np.isnan(a) if good.sum() >= 2: a[~good] = np.interp(idx[~good], idx[good], a[good]) elif good.sum() == 1: a[~good] = a[good][0] else: a[:] = 0.0 return a def _side_arrays(P): return { "nose": P[:, NOSE], "shm": np.nanmean(P[:, [L_SH, R_SH]], axis=1), "hip": np.nanmean(P[:, [L_HIP, R_HIP]], axis=1), "wl": P[:, L_WR], "wr": P[:, R_WR], "hl": P[:, L_HIP], "hr": P[:, R_HIP], "kl": P[:, L_KNEE], "kr": P[:, R_KNEE], "al": P[:, L_ANK], "ar": P[:, R_ANK], "sh": np.linalg.norm(P[:, L_SH] - P[:, R_SH], axis=1), } def _knee_angle(hip, knee, ank): """Per-frame joint angle AT the knee in degrees (180 = straight leg). 2D-projected, so it under-reads bend when the leg points at the camera — treat downstream labels as tendencies, not measurements.""" v1, v2 = hip - knee, ank - knee denom = np.linalg.norm(v1, axis=1) * np.linalg.norm(v2, axis=1) with np.errstate(invalid="ignore", divide="ignore"): cos = np.sum(v1 * v2, axis=1) / np.where(denom > 1e-6, denom, np.nan) return np.degrees(np.arccos(np.clip(cos, -1.0, 1.0))) def _analyze(me, opp, scale, fps): s = scale gl = (me["wl"][:, 1] - me["nose"][:, 1]) / s gr = (me["wr"][:, 1] - me["nose"][:, 1]) / s guard_min = np.nanmin(np.stack([gl, gr]), axis=0) valid = int(np.sum(~np.isnan(guard_min))) hands_up = guard_min < GUARD_UP dropped = guard_min > GUARD_DOWN head_off = (me["nose"][:, 0] - me["hip"][:, 0]) / s head_std = float(np.nanstd(head_off)) move_cat = "low" if head_std < 0.10 else "medium" if head_std < 0.20 else "high" # gap normalized by the fighters' shoulder width IN THAT FRAME (larger of # the two: robust when one blades sideways). The clip-median ruler misread # perspective on moving cameras: fighters near the lens have a bigger pixel # gap at the same real distance, so genuine in-range moments read "long". sh_pair = np.nanmax(np.stack([me["sh"], opp["sh"]]), axis=0) sh_pair = np.where(np.isnan(sh_pair) | (sh_pair < MIN_SH_PX), s, sh_pair) gap = np.abs(me["hip"][:, 0] - opp["hip"][:, 0]) / sh_pair in_range = gap < IN_RANGE danger = in_range & dropped d = np.nanmin(np.stack([ np.linalg.norm(opp["wl"] - me["nose"], axis=1) / sh_pair, np.linalg.norm(opp["wr"] - me["nose"], axis=1) / sh_pair, ]), axis=0) tag_frames = _events(d < TAG_DIST, int(TAG_REFRACTORY_S * fps)) # pressure / retreat: does this fighter walk the opponent down, or give # ground? Two honesty rules learned from coach feedback: # (1) only frame pairs where BOTH fighters were actually measured count — # interpolating across tracking gaps used to fabricate motion; # (2) a handheld pan moves both fighters across the frame together, and a # single camera cannot separate that from real footwork — when the two # fighters' motions correlate like a pan, say "unclear" instead of # printing a confident wrong label. my_raw = me["hip"][:, 0] / s op_raw = opp["hip"][:, 0] / s ok_pair = (~np.isnan(my_raw[:-1]) & ~np.isnan(my_raw[1:]) & ~np.isnan(op_raw[:-1]) & ~np.isnan(op_raw[1:])) dmy = np.diff(my_raw)[ok_pair] dop = np.diff(op_raw)[ok_pair] adv = dmy * np.sign(op_raw[:-1] - my_raw[:-1])[ok_pair] net10 = 0.0 if ok_pair.sum() < 10: pct_adv = pct_ret = 0.0 stance = "unknown (tracking too thin)" else: pct_adv = round(100 * (adv > 0.03).mean(), 1) pct_ret = round(100 * (adv < -0.03).mean(), 1) # NET ground gained/given decides the label (coach feedback: a fighter # giving ground in fast bursts while shuffling forward slowly counted # as 'holding ground' by step-counting — the eye reads net ground, so # net wins; percentages stay as supporting detail). net10 = float(adv.sum() * fps * 10 / max(len(adv), 1)) if net10 < -NET_RATE_SW10S or pct_ret - pct_adv > PRESSURE_MARGIN: stance = "backing up" elif net10 > NET_RATE_SW10S or pct_adv - pct_ret > PRESSURE_MARGIN: stance = "pressuring" else: stance = "holding ground" # decompose motion: common component (both drift together = camera pan) # vs relative (fighters actually closing/opening = real footwork). # Correlation can't catch a smooth pan (zero variance), this can. common = float(np.median(np.abs(dmy + dop) / 2)) relative = float(np.median(np.abs(dmy - dop))) if common > PAN_DOMINANCE * relative + 0.015 and common > 0.02: stance = "unclear (camera moves with the fighters)" # range profile (robust: from the distance distribution, not velocity) gv = gap[~np.isnan(gap)] close = int(round(100 * np.mean(gv < 1.8))) if len(gv) else 0 longr = int(round(100 * np.mean(gv > 3.2))) if len(gv) else 0 mid = max(0, 100 - close - longr) def pct(mask): return round(100 * np.nan_to_num(mask).astype(bool).sum() / max(valid, 1), 1) drop_iv = _hysteresis_intervals( guard_min, GUARD_DOWN, GUARD_UP, max(2, int(DROP_MIN_S * fps)), max(2, int(CLEAR_MIN_S * fps))) # exchanges: in punching range while at least one of MY hands moves at punch # speed. (Velocity metrics were a dead end at ~20% coverage; at SAM2-level # coverage they're usable — the speed threshold is still an uncalibrated guess.) spd = np.fmax(_wrist_speed(me["wl"], s, fps), _wrist_speed(me["wr"], s, fps)) throwing = _dilate(np.nan_to_num(spd) > PUNCH_SPEED_SW_S, int(EXCHANGE_PAD_S * fps)) exch_mask = np.nan_to_num(in_range).astype(bool) & throwing exch_iv = _runs(exch_mask, max(1, int(EXCHANGE_MIN_S * fps))) # in-range stretches as timeline events (pairwise: same for both fighters). # Same min length as exchanges so an exchange can never sit outside a # displayed in-range band. range_iv = _runs(np.nan_to_num(in_range).astype(bool), max(1, int(EXCHANGE_MIN_S * fps))) # stance: which foot leads toward the opponent (left lead = orthodox), and # the MATCHUP over time: same lead foot = closed stance, opposite = open. face = np.sign(_interp1(opp["hip"][:, 0]) - _interp1(me["hip"][:, 0])) my_valid = ~np.isnan(me["al"][:, 0]) & ~np.isnan(me["ar"][:, 0]) my_left_lead = (me["al"][:, 0] - me["ar"][:, 0]) * face > 0 if my_valid.sum() >= 10: orth = 100 * my_left_lead[my_valid].mean() stance_str = f"orthodox {orth:.0f}% / southpaw {100 - orth:.0f}%" else: stance_str = "unknown (feet rarely visible)" opp_valid = ~np.isnan(opp["al"][:, 0]) & ~np.isnan(opp["ar"][:, 0]) opp_left_lead = (opp["al"][:, 0] - opp["ar"][:, 0]) * (-face) > 0 both_st = my_valid & opp_valid open_iv, closed_iv = [], [] if both_st.sum() >= 10: closed = 100 * (my_left_lead[both_st] == opp_left_lead[both_st]).mean() matchup_str = f"closed {closed:.0f}% / open {100 - closed:.0f}%" # matchup stretches as timeline events (min 0.5s so one noisy ankle # frame can't flicker the band) same = my_left_lead == opp_left_lead open_iv = _runs(both_st & ~same, max(2, int(0.5 * fps))) closed_iv = _runs(both_st & same, max(2, int(0.5 * fps))) else: matchup_str = "unknown (feet rarely visible)" # leg drive: does the fighter BEND THE KNEES at punch release (sitting down # on shots) or arm-punch upright? Knee angle from hip-knee-ankle, compared # between release windows (wrist at punch speed ±0.2s) and the baseline. # 2D projection + uncalibrated thresholds — labels are coarse on purpose. knee = np.nanmean(np.stack([ _knee_angle(me["hl"], me["kl"], me["al"]), _knee_angle(me["hr"], me["kr"], me["ar"]), ]), axis=0) knee_ok = ~np.isnan(knee) release = _dilate(np.nan_to_num(spd) > PUNCH_SPEED_SW_S, max(1, int(0.2 * fps))) if knee_ok.sum() >= 30: base_deg = float(np.nanmedian(knee[knee_ok])) load = knee_ok & release if load.sum() >= 8: load_deg = float(np.nanmedian(knee[load])) dip = base_deg - load_deg lbl = ("sits down on punches" if dip >= 6 else "some knee bend into shots" if dip >= 3 else "upright when punching, little visible leg drive") leg_drive = (f"{lbl} (knees {load_deg:.0f}\N{DEGREE SIGN} at release " f"vs {base_deg:.0f}\N{DEGREE SIGN} baseline)") else: leg_drive = (f"baseline knee bend {base_deg:.0f}\N{DEGREE SIGN} " "(too few tracked punches to judge loading)") else: leg_drive = "unknown (legs rarely visible)" # weight placement: where the hips sit between the feet, signed toward the # opponent (positive = over the front foot). Only frames with a real side-on # base (ankle spread > 0.25 shoulder-widths) count — a bladed or facing- # camera stance makes the projection meaningless. mid_ank = (me["al"][:, 0] + me["ar"][:, 0]) / 2 base_w = np.abs(me["al"][:, 0] - me["ar"][:, 0]) with np.errstate(invalid="ignore", divide="ignore"): shift = (me["hip"][:, 0] - mid_ank) / np.where(base_w > 1e-6, base_w, np.nan) * face w_ok = ~np.isnan(shift) & (base_w > 0.25 * s) if w_ok.sum() >= 30: sv = shift[w_ok] front = 100 * float(np.mean(sv > 0.12)) rear = 100 * float(np.mean(sv < -0.12)) centered = max(0.0, 100 - front - rear) lbl = ("rear-loaded" if rear - front > 15 else "front-heavy" if front - rear > 15 else "balanced") weight_side = (f"{lbl} (weight over front foot {front:.0f}% / " f"centered {centered:.0f}% / rear foot {rear:.0f}%)") tw = w_ok & release if tw.sum() >= 8: ft = 100 * float(np.mean(shift[tw] > 0.12)) if ft - front >= 12: weight_side += f"; drives onto the front foot when throwing ({ft:.0f}%)" elif front - ft >= 12: weight_side += f"; stays OFF the front foot when throwing ({ft:.0f}%)" else: weight_side = "unknown (feet rarely visible or stance too bladed to read)" # ---- habit metrics (all 2D; thresholds are uncalibrated coarse labels) ---- my_thr = np.nan_to_num(spd) > PUNCH_SPEED_SW_S opp_spd = np.fmax(_wrist_speed(opp["wl"], s, fps), _wrist_speed(opp["wr"], s, fps)) opp_thr = np.nan_to_num(opp_spd) > PUNCH_SPEED_SW_S exch_b = np.nan_to_num(exch_mask).astype(bool) # guard recovery: after each throwing burst, seconds until a hand is back # above guard height ("leaves the jab hanging" is this number) rec_times, left_out = [], 0 for _a, _b in _runs(my_thr, 1): rec, t, saw_valid = None, _b + 1, False cap = min(len(guard_min), t + int(3 * fps)) while t < cap: g = guard_min[t] if not np.isnan(g): saw_valid = True if g < GUARD_UP: rec = (t - _b - 1) / fps break t += 1 if rec is not None: rec_times.append(rec) elif saw_valid: left_out += 1 # measured frames existed and the guard stayed low # all-NaN window = tracking gap, no evidence either way -> not counted if len(rec_times) + left_out >= 3: med_r = float(np.median(rec_times + [3.0] * left_out)) # failures count lbl = ("snaps the guard straight back" if med_r <= 0.3 else "adequate recovery" if med_r <= 0.7 else "leaves the hands out after punching") guard_recovery = (f"{lbl} (median {med_r:.1f}s back to guard" + (f", slowest {max(rec_times):.1f}s" if rec_times else "") + (f"; {left_out}x stayed low 3s+" if left_out else "") + ")") else: guard_recovery = "unknown (too few tracked punches)" # context of each sustained guard drop: punching, under fire, or off the ball ctx = {"while punching": 0, "under fire": 0, "off the ball": 0} for _a, _b in drop_iv: seg = slice(_a, _b + 1) own, theirs = float(np.mean(my_thr[seg])), float(np.mean(opp_thr[seg])) if theirs > 0.15 and theirs >= own: ctx["under fire"] += 1 elif own > 0.15: ctx["while punching"] += 1 else: ctx["off the ball"] += 1 guard_drop_context = (", ".join(f"{v} {k}" for k, v in ctx.items() if v) or "no sustained drops") # squaring up: torso width vs own height, exchanges vs baseline (self- # normalized, so no absolute threshold needed) body_h = np.nanmax(np.stack([me["al"][:, 1], me["ar"][:, 1]]), axis=0) - me["nose"][:, 1] with np.errstate(invalid="ignore", divide="ignore"): sq = me["sh"] / np.where(body_h > 1e-6, body_h, np.nan) sq_ok = ~np.isnan(sq) if sq_ok.sum() >= 30 and (sq_ok & exch_b).sum() >= 8: qr = float(np.nanmedian(sq[sq_ok & exch_b])) / max(float(np.nanmedian(sq[sq_ok])), 1e-6) lbl = ("squares up in exchanges" if qr > 1.15 else "stays bladed in exchanges" if qr < 0.9 else "keeps shape in exchanges") squaring_up = f"{lbl} (torso {100 * (qr - 1):+.0f}% wider than baseline when trading)" elif sq_ok.sum() >= 30: squaring_up = "unknown (no measured exchanges to compare)" else: squaring_up = "unknown (body rarely fully visible)" # chin: nose height above the shoulder line, exchanges vs baseline chin = (me["shm"][:, 1] - me["nose"][:, 1]) / s c_ok = ~np.isnan(chin) if c_ok.sum() >= 30: base_c = float(np.nanmedian(chin[c_ok])) if (c_ok & exch_b).sum() >= 8: ex_c = float(np.nanmedian(chin[c_ok & exch_b])) lbl = ("lifts the chin in exchanges" if ex_c - base_c > 0.08 else "tucks tighter in exchanges" if ex_c - base_c < -0.08 else "keeps the chin steady") chin_position = (f"{lbl} (head {base_c:.2f} SW above the shoulder line; " f"{ex_c:.2f} when trading)") else: chin_position = f"head {base_c:.2f} SW above the shoulder line" else: chin_position = "unknown" # base width (uses base_w from the weight block) wv = (base_w / s)[~np.isnan(base_w)] if len(wv) >= 30: med_w = float(np.median(wv)) lbl = "narrow base" if med_w < 0.65 else "wide base" if med_w > 1.5 else "normal base" stance_width = (f"{lbl} (median {med_w:.1f} SW between the feet; " f"narrow <0.6 on {100 * float(np.mean(wv < 0.6)):.0f}% of frames)") else: stance_width = "unknown (feet rarely visible)" # crossed feet: lead/rear ankle order flips while moving laterally fast # (speed gate keeps stationary stance switches out — mostly) hipv = np.abs(np.concatenate([[np.nan], np.diff(me["hip"][:, 0])])) / s * fps crossed, prev_i, last_hit = [], None, -10 ** 9 ax, bx = me["al"][:, 0], me["ar"][:, 0] for i in range(len(ax)): if np.isnan(ax[i]) or np.isnan(bx[i]) or abs(ax[i] - bx[i]) < 0.15 * s: continue if (prev_i is not None and np.sign(ax[i] - bx[i]) != np.sign(ax[prev_i] - bx[prev_i]) and i - prev_i <= int(0.7 * fps) and i - last_hit > fps): v = np.nanmax(hipv[max(0, prev_i):i + 1]) if v and v > 0.8: crossed.append(i) last_hit = i prev_i = i crossed_feet = (f"{len(crossed)}x while moving ({', '.join(_mmss(i / fps) for i in crossed)})" if crossed else "none seen") # reaction to incoming: head moved laterally around the opponent's bursts head_rel = (me["nose"][:, 0] - me["hip"][:, 0]) / s n_ep = n_moved = 0 for _a, _b in _runs(opp_thr, 1): w0 = max(0, _a - int(0.15 * fps)) w1 = min(len(head_rel), _b + int(0.4 * fps) + 1) seg = head_rel[w0:w1] seg = seg[~np.isnan(seg)] if len(seg) >= 3: n_ep += 1 n_moved += float(np.max(seg) - np.min(seg)) > 0.25 reacts_to_punches = (f"moves the head on {100 * n_moved / n_ep:.0f}% of the " f"opponent's punches (n={n_ep})" if n_ep >= 4 else "unknown (too few opponent punches tracked)") # round trend: first vs last 30s bucket (needs a 60s+ clip) bframes = int(BUCKET_S * fps) nb = int(np.ceil(len(guard_min) / max(bframes, 1))) round_trend = "clip too short for a trend (<60s)" if nb >= 2 and valid >= 60: per = [] for k in range(nb): seg = slice(k * bframes, (k + 1) * bframes) g = guard_min[seg] g = g[~np.isnan(g)] per.append((100 * float(np.mean(g < GUARD_UP)) if len(g) >= 10 else None, 100 * float(np.mean(my_thr[seg])))) firsts = [p for p in per if p[0] is not None] if len(firsts) >= 2: (h0, t0), (h1, t1) = firsts[0], firsts[-1] fading = (h0 - h1 > 10) or (t0 > 2 and t1 < 0.6 * t0) round_trend = (f"{'fading' if fading else 'steady'} across the round " f"(hands up {h0:.0f}%→{h1:.0f}%, throwing {t0:.0f}%→{t1:.0f}%)") metrics = { "frames_analyzed": valid, "hands_up_pct": pct(hands_up), "guard_dropped_episodes": len(drop_iv), "guard_dropped_at": [f"{_mmss(s / fps)}-{_mmss(e / fps)}" for s, e in drop_iv], "head_movement": f"{move_cat} (std={round(head_std, 3)})", "avg_distance_shoulderwidths": round(float(np.nanmean(gap)), 2), "pct_in_range_with_hands_down": pct(danger), "pressure": f"{stance} (net {net10:+.1f} SW/10s; advancing {pct_adv}% / retreating {pct_ret}%)", "range_profile": f"close {close}% / mid {mid}% / long {longr}%", "stance": stance_str, "stance_matchup": matchup_str, "leg_drive": leg_drive, "weight_side": weight_side, "guard_recovery": guard_recovery, "guard_drop_context": guard_drop_context, "squaring_up": squaring_up, "chin_position": chin_position, "stance_width": stance_width, "crossed_feet": crossed_feet, "reacts_to_punches": reacts_to_punches, "round_trend": round_trend, "open_stance_at": [f"{_mmss(a / fps)}-{_mmss(b / fps)}" for a, b in open_iv], "closed_stance_at": [f"{_mmss(a / fps)}-{_mmss(b / fps)}" for a, b in closed_iv], "in_range_at": [f"{_mmss(a / fps)}-{_mmss(b / fps)}" for a, b in range_iv], "in_range_throwing_pct": pct(exch_mask), "exchanges_at": [f"{_mmss(a / fps)}-{_mmss(b / fps)}" for a, b in exch_iv], "head_contact_events_approx_EXPERIMENTAL": int(len(tag_frames)), "head_contact_times_approx_EXPERIMENTAL": [_mmss(i / fps) for i in tag_frames], } # strided-frame indices for render_overlay / the dashboard strip overlay = {"guard_intervals": drop_iv, "exchange_intervals": exch_iv, "range_intervals": range_iv, "open_intervals": open_iv, "closed_intervals": closed_iv} return metrics, overlay def _exchange_activity(sA, sB, scale, fps): """Graded EXCHANGE INTENSITY over time, and the flurry windows it peaks in. This is the robust alternative to counting individual punches: instead of committing to 'a punch happened at instant t' (fragile), it integrates two reliable, camera-robust signals over a window — the two fighters are IN RANGE, and their arms are ACTIVE (wrist speed relative to their own hips, which cancels camera pan and footwork). High + sustained = a flurry. Even when per-punch calls are wrong, the hot windows are real exchanges. Returns (flurries [(start, end, 'light'|'heavy')], intensity series).""" sh_pair = np.nanmax(np.stack([sA["sh"], sB["sh"]]), axis=0) sh_pair = np.where(np.isnan(sh_pair) | (sh_pair < MIN_SH_PX), scale, sh_pair) gap = np.abs(sA["hip"][:, 0] - sB["hip"][:, 0]) / sh_pair in_range = gap < IN_RANGE # NaN gap -> False (not in range) def arm(s): aL = _wrist_speed(s["wl"] - s["hip"], scale, fps) aR = _wrist_speed(s["wr"] - s["hip"], scale, fps) return np.fmax(np.nan_to_num(aL), np.nan_to_num(aR)) intensity = np.where(in_range, arm(sA) + arm(sB), 0.0) k = max(1, int(0.4 * fps)) # ~0.4s smoothing smooth = np.convolve(intensity, np.ones(k) / k, mode="same") flurries = [] for a, b in _runs(smooth > FLURRY_SPEED_SW_S, max(2, int(FLURRY_MIN_S * fps))): peak = float(np.max(smooth[a:b + 1])) if b >= a else 0.0 flurries.append((a, b, "heavy" if peak > FLURRY_HEAVY_SW_S else "light")) return flurries, smooth def _confidence(frames_analyzed, two_fighter_pct): """How much to trust this fighter's read, from how much of the clip we actually measured. Surfaced to the user AND the LLM so a thin read gets hedged instead of stated as fact. Thresholds are uncalibrated guesses.""" if frames_analyzed >= 120 and two_fighter_pct >= 40: level = "high" elif frames_analyzed >= 50 and two_fighter_pct >= 20: level = "medium" else: level = "low" return (f"{level} — read from {frames_analyzed} frames; " f"both fighters were in view for {two_fighter_pct}% of the clip") def _features_from_poses(poses, fps, labels=("Fighter A", "Fighter B"), mask_overlap=None): """Returns (features_dict, overlay_info) or (None, None). mask_overlap: optional per-frame SAM2 mask overlap ratio (intersection / smaller mask). When given, it upgrades the clinch read — mask overlap catches tie-ups that keypoint distance misreads.""" if len(poses) == 0: return None, None A, B = poses[:, 0], poses[:, 1] shsA = np.linalg.norm(A[:, L_SH] - A[:, R_SH], axis=1) shsB = np.linalg.norm(B[:, L_SH] - B[:, R_SH], axis=1) both = int(np.sum(~np.isnan(shsA) & ~np.isnan(shsB))) all_sh = np.concatenate([shsA, shsB]) all_sh = all_sh[~np.isnan(all_sh)] if both < MIN_PAIR_FRAMES or len(all_sh) == 0: return None, None scale = float(np.median(all_sh)) cov = round(100 * both / max(len(poses), 1), 1) sA, sB = _side_arrays(A), _side_arrays(B) fa, ov_a = _analyze(sA, sB, scale, fps) fb, ov_b = _analyze(sB, sA, scale, fps) # Per-fighter confidence (non-underscore key so it survives build_prompt and # reaches the coach, unlike _meta which is stripped before the LLM sees it). fa["confidence"] = _confidence(fa["frames_analyzed"], cov) fb["confidence"] = _confidence(fb["frames_analyzed"], cov) # exchange intensity / flurries — pairwise, so the same list on both fighters flurries, _intensity = _exchange_activity(sA, sB, scale, fps) flur_str = [f"{_mmss(a / fps)}-{_mmss(b / fps)} ({lvl})" for a, b, lvl in flurries] fa["flurries_at"] = fb["flurries_at"] = flur_str ov_a["flurry_intervals"] = ov_b["flurry_intervals"] = flurries # Per-fighter NOT-TRACKED intervals (>=0.5s of missing pose: occluded, off # frame, or too close to the lens for keypoints). Timeline honesty layer — # shows WHERE the metrics are blind, like the overlay's "not tracked" flag. # Lives in _meta so it never reaches the LLM (coverage already hedges it). # frame-accurate event intervals for the dashboard timeline (seconds, 2dp). # The *_at strings are m:ss for the LLM/report — second-quantized, which # chopped continuous timeline bands into 1s blocks. The dashboard reads # these instead; the strings stay for the coach. def _sec(ivs): return [[round(a / fps, 2), round((b + 1) / fps, 2)] for a, b in ivs] timeline = {} for lab, ov in ((labels[0], ov_a), (labels[1], ov_b)): timeline[lab] = { "guard": _sec(ov["guard_intervals"]), "range": _sec(ov["range_intervals"]), "exch": _sec(ov["exchange_intervals"]), "open": _sec(ov["open_intervals"]), "closed": _sec(ov["closed_intervals"]), } timeline["flurries"] = [[round(a / fps, 2), round((b + 1) / fps, 2), lvl] for a, b, lvl in flurries] # clinch / tie-up: bodies close AND arms quiet (a close+busy moment is an # exchange, not a clinch). SAM2 mask overlap, when available, also counts — # overlapping silhouettes are the direct evidence of a tie-up. sh_pair = np.nanmax(np.stack([sA["sh"], sB["sh"]]), axis=0) sh_pair = np.where(np.isnan(sh_pair) | (sh_pair < MIN_SH_PX), scale, sh_pair) pair_gap = np.abs(sA["hip"][:, 0] - sB["hip"][:, 0]) / sh_pair arm_all = (np.nan_to_num(np.fmax(_wrist_speed(sA["wl"], scale, fps), _wrist_speed(sA["wr"], scale, fps))) + np.nan_to_num(np.fmax(_wrist_speed(sB["wl"], scale, fps), _wrist_speed(sB["wr"], scale, fps)))) tie = np.nan_to_num(pair_gap < 1.1) & (arm_all < 3.0) if mask_overlap is not None and len(mask_overlap) == len(tie): tie = tie | (np.nan_to_num(mask_overlap) > 0.30) clinch_iv = _runs(tie.astype(bool), max(2, int(0.6 * fps))) clinch_frames = sum(b - a + 1 for a, b in clinch_iv) clinch_str = (f"{100 * clinch_frames / max(len(poses), 1):.0f}% of the clip tied up (" + ", ".join(f"{_mmss(a / fps)}-{_mmss(b / fps)}" for a, b in clinch_iv) + ")" if clinch_iv else "no clinch time detected") fa["clinch"] = fb["clinch"] = clinch_str timeline["clinch"] = [[round(a / fps, 2), round((b + 1) / fps, 2)] for a, b in clinch_iv] # measured size hint: same-frame shoulder-width ratio (A/B). Same-frame, so # camera distance mostly cancels; still 2D, so it grounds (not replaces) the # VLM's qualitative size read. pair_mask = ~np.isnan(shsA) & ~np.isnan(shsB) with np.errstate(invalid="ignore", divide="ignore"): sw_ratio = round(float(np.nanmedian(shsA[pair_mask] / shsB[pair_mask])), 2) \ if pair_mask.sum() >= 10 else None # height ratio (A/B): nose-to-lowest-ankle vertical extent, same frame only. # Shoulder width reads BREADTH — a lanky fighter can out-height a stockier # one while reading "similar" on shoulders; this is the tallness signal. # Median over frames dampens crouching; still 2D and depth-skewed. def _body_h(P): ank_y = np.nanmax(np.stack([P[:, L_ANK, 1], P[:, R_ANK, 1]]), axis=0) return ank_y - P[:, NOSE, 1] hA, hB = _body_h(A), _body_h(B) hmask = ~np.isnan(hA) & ~np.isnan(hB) & (hA > 0) & (hB > 0) with np.errstate(invalid="ignore", divide="ignore"): height_ratio = round(float(np.nanmedian(hA[hmask] / hB[hmask])), 2) \ if hmask.sum() >= 8 else None gap_min = max(2, int(0.5 * fps)) # a fighter counts as NOT TRACKED when the shoulders are missing (no scale # ruler) OR the whole pose is too sparse to trust (<6 confident keypoints — # mostly out of frame, fully absent, or too close to the lens). Shoulder-NaN # alone missed the "half a leg visible" case. kp_count = [np.sum(~np.isnan(poses[:, i, :, 0]), axis=1) for i in range(2)] gone = [np.isnan(shsA) | (kp_count[0] < 6), np.isnan(shsB) | (kp_count[1] < 6)] gap_runs = {labels[0]: _runs(gone[0], gap_min), labels[1]: _runs(gone[1], gap_min)} not_tracked = {lab: [f"{_mmss(a / fps)}-{_mmss(b / fps)}" for a, b in runs] for lab, runs in gap_runs.items()} for lab, runs in gap_runs.items(): timeline[lab]["gaps"] = _sec(runs) # BOTH fighters gone at once = the camera lost the action entirely — shown # on the shared Round lane so total blind spots are unmissable timeline["both_gone"] = _sec(_runs(gone[0] & gone[1], gap_min)) feats = { "_meta": { "frames": int(len(poses)), "frames_with_two_fighters": both, "two_fighter_pct": cov, "scale_px": round(scale, 1), "not_tracked_at": not_tracked, "shoulder_width_ratio": sw_ratio, "height_ratio": height_ratio, "timeline": timeline, }, labels[0]: fa, labels[1]: fb, } return feats, {labels[0]: ov_a, labels[1]: ov_b} # ============================ public API ==================================== def extract_features_full(video_path, target_frames=350): """extract_features + everything render_overlay needs. Returns None on failure, else a dict: features, poses, stride, fps (original), labels, overlay (per-fighter event intervals in strided-frame indices).""" fps = _fps(video_path) or 30.0 cap = cv2.VideoCapture(video_path) total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) cap.release() stride = max(VID_STRIDE, total // target_frames) if total > target_frames else VID_STRIDE poses, labels = _collect_poses(video_path, vid_stride=stride) feats, overlay = _features_from_poses(poses, fps / stride, labels) if not feats: return None return {"features": feats, "poses": poses, "stride": stride, "fps": fps, "labels": labels, "overlay": overlay} def extract_features(video_path, target_frames=350): """Adaptively sample frames so any clip finishes in bounded time on CPU: short clips run every frame (full quality); long clips are subsampled to ~target_frames. Defensive metrics are aggregates, so a bounded sample is fine.""" full = extract_features_full(video_path, target_frames) return full["features"] if full else None def cache_poses(video_path, npz_path): poses, labels = _collect_poses(video_path) np.savez_compressed(npz_path, poses=poses, fps=_fps(video_path), labels=np.array(labels)) return npz_path def features_from_cache(npz_path): d = np.load(npz_path, allow_pickle=False) labels = tuple(str(x) for x in d["labels"]) if "labels" in d.files else ("Fighter A", "Fighter B") feats, _ = _features_from_poses(d["poses"], float(d["fps"]), labels) return feats # ============================ tracking overlay ============================== _LIMBS = [(5, 7), (7, 9), (6, 8), (8, 10), (5, 6), (5, 11), (6, 12), (11, 12), (11, 13), (13, 15), (12, 14), (14, 16), (0, 5), (0, 6)] _OVERLAY_MAX_W = 960 _DRAW_COLORS = { # BGR, chosen to stay visible over ring/canvas footage "red": (60, 60, 230), "orange": (0, 140, 255), "yellow": (40, 220, 240), "green": (80, 200, 80), "blue": (230, 140, 40), "purple": (200, 100, 200), "white": (240, 240, 240), "black": (80, 80, 80), "gray": (160, 160, 160), } def _track_color(label): """BGR color matched to the track's display name.""" n = label.lower() for name, bgr in _DRAW_COLORS.items(): if name in n: return bgr return (0, 165, 255) if n.endswith("a") else (255, 220, 0) def _draw_fighter(img, kp, color, label, guard_down): """Draw one fighter; returns True if any keypoint was visible this frame.""" pts = [None if np.isnan(p).any() else (int(p[0]), int(p[1])) for p in kp] for a, b in _LIMBS: if pts[a] and pts[b]: cv2.line(img, pts[a], pts[b], color, 2, cv2.LINE_AA) for p in pts: if p: cv2.circle(img, p, 3, color, -1, cv2.LINE_AA) vis = [p for p in pts if p] if not vis: return False top = min(vis, key=lambda p: p[1]) anchor = (max(5, top[0] - 40), max(20, top[1] - 12)) cv2.putText(img, label, anchor, cv2.FONT_HERSHEY_SIMPLEX, 0.55, (255, 255, 255), 3, cv2.LINE_AA) cv2.putText(img, label, anchor, cv2.FONT_HERSHEY_SIMPLEX, 0.55, color, 1, cv2.LINE_AA) if guard_down: fl = (anchor[0], anchor[1] + 18) cv2.putText(img, "GUARD DOWN", fl, cv2.FONT_HERSHEY_SIMPLEX, 0.5, (255, 255, 255), 3, cv2.LINE_AA) cv2.putText(img, "GUARD DOWN", fl, cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 0, 255), 1, cv2.LINE_AA) return True def render_overlay(video_path, full, out_path): """Write a tracking-overlay video: per-fighter skeletons color-matched to their labels, a running clock (matches guard_dropped_at timestamps), a GUARD DOWN flag during drop episodes, and an explicit 'not tracked' marker when a fighter has no pose that frame (so tracking gaps are visible instead of looking like a broken feature). Pure CPU; uses the poses already extracted (no second model pass). Re-encodes to H.264 via ffmpeg so browsers can play it inline. Returns (path_or_None, note): `note` explains any failure or warns about thin tracking; every stage is also print-logged for the Space logs.""" import shutil import subprocess import tempfile poses, stride, fps = full["poses"], full["stride"], full["fps"] labels, overlay = full["labels"], full["overlay"] eff_fps = max(1.0, fps / stride) # per-fighter guard-down membership over strided frames n = len(poses) down = {} for lab in labels: m = np.zeros(n, dtype=bool) for s, e in overlay.get(lab, {}).get("guard_intervals", []): m[max(0, s):min(n, e + 1)] = True down[lab] = m cap = cv2.VideoCapture(video_path) w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) if not w or not h: cap.release() print(f"[overlay] could not open/read video ({w}x{h}): {video_path}", flush=True) return None, "Couldn't re-read the uploaded video to draw on it." sc = min(1.0, _OVERLAY_MAX_W / w) ow, oh = int(w * sc) // 2 * 2, int(h * sc) // 2 * 2 # even dims for H.264 print(f"[overlay] source {w}x{h} -> {ow}x{oh}, stride={stride}, eff_fps={eff_fps:.1f}", flush=True) tmp = tempfile.mktemp(suffix=".mp4") vw = cv2.VideoWriter(tmp, cv2.VideoWriter_fourcc(*"mp4v"), eff_fps, (ow, oh)) written = 0 idx = 0 seen = {lab: 0 for lab in labels} while True: ok, frame = cap.read() if not ok: break if idx % stride == 0: si = idx // stride if si < n: if (frame.shape[1], frame.shape[0]) != (ow, oh): frame = cv2.resize(frame, (ow, oh)) missing = [] for t, lab in enumerate(labels): drawn = _draw_fighter(frame, poses[si, t] * sc, _track_color(lab), lab, bool(down[lab][si])) if drawn: seen[lab] += 1 else: missing.append(lab) for j, lab in enumerate(missing): cv2.putText(frame, f"{lab}: not tracked", (ow - 230, 22 + 18 * j), cv2.FONT_HERSHEY_SIMPLEX, 0.45, (255, 255, 255), 3, cv2.LINE_AA) cv2.putText(frame, f"{lab}: not tracked", (ow - 230, 22 + 18 * j), cv2.FONT_HERSHEY_SIMPLEX, 0.45, (0, 0, 200), 1, cv2.LINE_AA) clock = _mmss(si / eff_fps) cv2.putText(frame, clock, (10, 28), cv2.FONT_HERSHEY_SIMPLEX, 0.8, (255, 255, 255), 3, cv2.LINE_AA) cv2.putText(frame, clock, (10, 28), cv2.FONT_HERSHEY_SIMPLEX, 0.8, (30, 30, 30), 1, cv2.LINE_AA) vw.write(frame) written += 1 idx += 1 cap.release() vw.release() print(f"[overlay] wrote {written} frames; visibility: " + ", ".join(f"{lab} {100 * c // max(written, 1)}%" for lab, c in seen.items()), flush=True) if not written: return None, "No frames could be rendered from this clip." # warn when tracking was thin — the overlay will show mostly bare video thin = [f"{lab} visible in only {100 * c // written}% of frames" for lab, c in seen.items() if c < 0.3 * written] note = ("⚠️ Thin tracking: " + "; ".join(thin) + " — stretches without a skeleton " "mean the model lost that fighter (see 'not tracked' marker).") if thin else "" # mp4v doesn't play in browsers; H.264 via ffmpeg is required for inline playback if shutil.which("ffmpeg"): r = subprocess.run( ["ffmpeg", "-y", "-loglevel", "error", "-i", tmp, "-c:v", "libx264", "-pix_fmt", "yuv420p", "-movflags", "+faststart", out_path], capture_output=True) if r.returncode == 0 and os.path.exists(out_path): os.remove(tmp) print(f"[overlay] h264 ok -> {out_path}", flush=True) return out_path, note print(f"[overlay] ffmpeg re-encode failed: {r.stderr.decode(errors='replace')[:300]}", flush=True) os.remove(tmp) return None, "Overlay rendered but couldn't be converted to a browser-playable format." print("[overlay] ffmpeg not found; skipping (mp4v won't play in browsers)", flush=True) os.remove(tmp) return None, "Overlay disabled: ffmpeg isn't available on this server."