analysis / app.py
abdelkader9090's picture
Initial release
e67ded9
Raw History Blame Contribute Delete
68 kB
"""
app.py β€” SPORALIZE HF Space
Β· Stereo pose processing from a calibrated camera pair
Β· Ball detection toggle (save ~40% CPU when disabled)
Β· Save action to archive (configurable pre / post frames, no error if short)
Β· Archive tab β€” browse saved clips with full skeleton viewer
Β· Delete action from archive
"""
import json
import os
import tempfile
import cv2
import gradio as gr
import matplotlib
import numpy as np
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from pipeline import (
compute_all_metrics,
process_video_pair_stream,
save_action,
load_archive,
load_action,
delete_action,
ARCHIVE_DIR,
)
# ══════════════════════════════════════════════════════════════════════════════
# DEMO DATA (sample cam pair + calibration for one-click try-out)
# ══════════════════════════════════════════════════════════════════════════════
DEMO_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "demo_data")
DEMO_CAM0 = os.path.join(DEMO_DIR, "cam1.mp4")
DEMO_CAM1 = os.path.join(DEMO_DIR, "cam2.mp4")
DEMO_CALIB = os.path.join(DEMO_DIR, "stereo_calibration_hybrid.pkl")
# ══════════════════════════════════════════════════════════════════════════════
# THEME PALETTE
# ══════════════════════════════════════════════════════════════════════════════
BG = "#0a0f1d"
PANEL = "#141b2d"
PANEL_ALT = "#1a2238"
BORDER = "#2d3750"
TEXT = "#e2e8f0"
MUTED = "#94a3b8"
CYAN = "#22d3ee"
AMBER = "#f59e0b"
VIOLET = "#a78bfa"
EMERALD = "#34d399"
PINK = "#f472b6"
BLUE = "#60a5fa"
ROSE = "#f43f5e"
YELLOW = "#fbbf24"
SKEL_EDGES_3D = [
("left_shoulder", "right_shoulder"),
("left_shoulder", "left_hip"),
("right_shoulder", "right_hip"),
("left_hip", "right_hip"),
("left_shoulder", "left_elbow"),
("left_elbow", "left_wrist"),
("right_shoulder", "right_elbow"),
("right_elbow", "right_wrist"),
("left_hip", "left_knee"),
("left_knee", "left_ankle"),
("right_hip", "right_knee"),
("right_knee", "right_ankle"),
("left_ankle", "left_heel"),
("left_heel", "left_big_toe"),
("left_big_toe", "left_small_toe"),
("right_ankle", "right_heel"),
("right_heel", "right_big_toe"),
("right_big_toe", "right_small_toe"),
# Head: RTMPose face triangle β€” nose + eyes only, no ears
("left_eye", "right_eye"),
("nose", "left_eye"),
("nose", "right_eye"),
("nose", "left_shoulder"),
("nose", "right_shoulder"),
]
# ══════════════════════════════════════════════════════════════════════════════
# CSS
# ══════════════════════════════════════════════════════════════════════════════
CUSTOM_CSS = f"""
body, .gradio-container, .main {{
background: {BG} !important;
color: {TEXT} !important;
font-family: system-ui, -apple-system, 'Segoe UI', sans-serif !important;
}}
.gradio-container {{
background-image:
radial-gradient(ellipse 80% 50% at 50% -10%, rgba(34,211,238,0.10), transparent 60%),
radial-gradient(ellipse 60% 40% at 90% 10%, rgba(167,139,250,0.07), transparent 60%) !important;
background-attachment: fixed !important;
max-width: 100% !important;
padding: 0 !important;
}}
.gr-block, .gr-box, .block, .panel {{
background: linear-gradient(180deg,
rgba(20,27,45,0.85), rgba(21,27,40,0.75)) !important;
border: 1px solid rgba(45,55,80,0.6) !important;
border-radius: 12px !important;
backdrop-filter: blur(12px) !important;
}}
label span, .label-wrap span, .gr-form label {{
color: {MUTED} !important;
font-size: 11px !important;
text-transform: uppercase !important;
letter-spacing: 0.08em !important;
font-weight: 500 !important;
}}
input[type=text], input[type=number], textarea, select {{
background: {PANEL_ALT} !important;
border-color: {BORDER} !important;
color: {TEXT} !important;
border-radius: 8px !important;
}}
button.primary, .gr-button-primary {{
background: linear-gradient(135deg, {CYAN}, #7c3aed) !important;
border: none !important;
color: #fff !important;
font-weight: 700 !important;
letter-spacing: 0.08em !important;
border-radius: 10px !important;
box-shadow: 0 0 20px rgba(34,211,238,0.25) !important;
}}
button.secondary, .gr-button-secondary {{
background: {PANEL} !important;
border: 1px solid {BORDER} !important;
color: {TEXT} !important;
border-radius: 10px !important;
}}
input[type=range] {{ accent-color: {CYAN} !important; }}
.tab-nav button {{ color: {MUTED} !important; border-color: transparent !important; }}
.tab-nav button.selected {{
color: {CYAN} !important;
border-bottom: 2px solid {CYAN} !important;
background: transparent !important;
}}
.progress-bar {{ background: {CYAN} !important; }}
.upload-area, .file-drop {{
background: rgba(20,27,45,0.6) !important;
border: 1.5px dashed {BORDER} !important;
border-radius: 10px !important;
}}
.upload-area:hover {{
border-color: rgba(34,211,238,0.5) !important;
background: rgba(34,211,238,0.04) !important;
}}
input[type=checkbox], input[type=radio] {{ accent-color: {CYAN} !important; }}
.status-box textarea {{
background: rgba(20,27,45,0.7) !important;
border-color: {BORDER} !important;
color: {MUTED} !important;
font-size: 12px !important;
border-radius: 8px !important;
}}
::-webkit-scrollbar {{ width: 6px; height: 6px; }}
::-webkit-scrollbar-track {{ background: {BG}; }}
::-webkit-scrollbar-thumb {{ background: {BORDER}; border-radius: 3px; }}
.badge-group .wrap {{
display: flex !important; flex-wrap: wrap !important; gap: 8px !important;
}}
.badge-group .wrap label {{
background: rgba(34,211,238,0.1) !important;
border: 1.5px solid rgba(34,211,238,0.4) !important;
border-radius: 20px !important; padding: 4px 14px !important;
cursor: pointer !important; font-size: 12px !important;
font-weight: 600 !important; color: {CYAN} !important;
transition: all 0.15s !important;
}}
.badge-group .wrap label:has(input:checked) {{
background: {CYAN} !important; color: #0a0f1d !important;
border-color: {CYAN} !important;
box-shadow: 0 0 12px rgba(34,211,238,0.4) !important;
}}
.ball-badge-group .wrap label {{
background: rgba(245,158,11,0.1) !important;
border-color: rgba(245,158,11,0.4) !important; color: {AMBER} !important;
}}
.ball-badge-group .wrap label:has(input:checked) {{
background: {AMBER} !important; color: #0a0f1d !important;
border-color: {AMBER} !important;
box-shadow: 0 0 12px rgba(245,158,11,0.4) !important;
}}
.touch-events-group .wrap {{
display: flex !important; flex-wrap: wrap !important; gap: 8px !important;
}}
.touch-events-group .wrap label {{
background: rgba(167,139,250,0.12) !important;
border: 1.5px solid rgba(167,139,250,0.4) !important;
border-radius: 20px !important; padding: 5px 14px !important;
cursor: pointer !important; font-size: 12px !important;
font-weight: 600 !important; color: {VIOLET} !important;
transition: all 0.15s !important; text-transform: capitalize !important;
}}
.touch-events-group .wrap label:has(input:checked) {{
background: {VIOLET} !important; color: #0a0f1d !important;
border-color: {VIOLET} !important;
box-shadow: 0 0 12px rgba(167,139,250,0.4) !important;
}}
/* Active-leg pill */
.active-leg-group .wrap {{
display: flex !important; flex-wrap: wrap !important; gap: 8px !important;
}}
.active-leg-group .wrap label {{
background: rgba(52,211,153,0.1) !important;
border: 1.5px solid rgba(52,211,153,0.4) !important;
border-radius: 20px !important; padding: 4px 14px !important;
cursor: pointer !important; font-size: 12px !important;
font-weight: 600 !important; color: {EMERALD} !important;
transition: all 0.15s !important;
}}
.active-leg-group .wrap label:has(input:checked) {{
background: {EMERALD} !important; color: #0a0f1d !important;
border-color: {EMERALD} !important;
box-shadow: 0 0 12px rgba(52,211,153,0.4) !important;
}}
/* Archive list */
.archive-list-group .wrap {{
display: flex !important; flex-direction: column !important; gap: 6px !important;
}}
.archive-list-group .wrap label {{
background: rgba(20,27,45,0.8) !important;
border: 1px solid {BORDER} !important;
border-radius: 8px !important; padding: 8px 14px !important;
cursor: pointer !important; font-size: 12px !important;
color: {TEXT} !important; transition: all 0.15s !important;
}}
.archive-list-group .wrap label:has(input:checked) {{
background: rgba(34,211,238,0.08) !important;
border-color: {CYAN} !important; color: {CYAN} !important;
}}
"""
# ══════════════════════════════════════════════════════════════════════════════
# HTML HELPERS
# ══════════════════════════════════════════════════════════════════════════════
HEADER_HTML = f"""
<div style="
display:flex; align-items:center; gap:16px;
padding:20px 32px;
background:{PANEL};
border-bottom:1px solid {BORDER};
backdrop-filter:blur(20px);
position:sticky; top:0; z-index:100;
">
<div>
<h1 style="
font-size:26px; font-weight:900; margin:0; line-height:1;
letter-spacing:0.15em;
background:linear-gradient(135deg,{CYAN},{VIOLET});
-webkit-background-clip:text; background-clip:text; color:transparent;
">Self-Calibration Demo</h1>
<p style="color:{MUTED}; font-size:11px; margin:5px 0 0;
letter-spacing:0.05em; font-weight:500;">
3D biomechanics &nbsp;Β·&nbsp; RTMPose Wholebody &nbsp;Β·&nbsp; Stereo calibration
</p>
</div>
</div>
"""
def _section_label(text):
return (f'<p style="color:{MUTED};font-size:10px;font-weight:600;'
f'text-transform:uppercase;letter-spacing:0.1em;margin:10px 0 4px;">'
f'{text}</p>')
def _divider():
return f'<div style="height:1px;background:{BORDER};margin:8px 0;"></div>'
def _progress_bar_html(current=0, total=0, label=None):
pct = 0 if not total else max(0, min(100, round(current / total * 100)))
text = label or (f"Processing frame {current} / {total}" if total else "Ready")
return f"""
<div style="margin:10px 0 4px;">
<div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:6px;">
<span style="font-size:11px;color:{MUTED};font-weight:500;">{text}</span>
<span style="font-size:11px;color:{CYAN};font-weight:700;">{pct}%</span>
</div>
<div style="width:100%;height:8px;border-radius:999px;background:{PANEL_ALT};
border:1px solid {BORDER};overflow:hidden;">
<div style="width:{pct}%;height:100%;border-radius:999px;
background:linear-gradient(90deg,{CYAN},{VIOLET});
box-shadow:0 0 12px -2px {CYAN};
transition:width 0.25s ease;"></div>
</div>
</div>"""
_SCENE_CFG = dict(
bgcolor="#0f172a",
xaxis=dict(backgroundcolor="#0f172a", gridcolor="#1e293b", showbackground=True, color=MUTED),
yaxis=dict(backgroundcolor="#0f172a", gridcolor="#1e293b", showbackground=True, color=MUTED),
zaxis=dict(backgroundcolor="#0f172a", gridcolor="#1e293b", showbackground=True, color=MUTED),
camera=dict(eye=dict(x=1.5, y=1.5, z=0.7)),
)
_FOOT_KPTS = ["left_big_toe","left_small_toe","left_heel",
"right_big_toe","right_small_toe","right_heel"]
# ══════════════════════════════════════════════════════════════════════════════
# VIDEO EXPORT
# ══════════════════════════════════════════════════════════════════════════════
def frames_to_video(ann_frames, fps=25):
"""Convert list of (img0, img1) RGB numpy arrays β†’ two temp MP4 paths."""
if not ann_frames:
return None, None
h0, w0 = ann_frames[0][0].shape[:2]
h1, w1 = ann_frames[0][1].shape[:2]
tmp0 = tempfile.NamedTemporaryFile(suffix=".mp4", delete=False)
tmp1 = tempfile.NamedTemporaryFile(suffix=".mp4", delete=False)
tmp0.close(); tmp1.close()
fourcc = cv2.VideoWriter_fourcc(*"mp4v")
vw0 = cv2.VideoWriter(tmp0.name, fourcc, fps, (w0, h0))
vw1 = cv2.VideoWriter(tmp1.name, fourcc, fps, (w1, h1))
for img0, img1 in ann_frames:
vw0.write(cv2.cvtColor(np.asarray(img0), cv2.COLOR_RGB2BGR))
vw1.write(cv2.cvtColor(np.asarray(img1), cv2.COLOR_RGB2BGR))
vw0.release(); vw1.release()
return tmp0.name, tmp1.name
def _pick_first_point(kp: dict, names: list[str]):
for name in names:
v = kp.get(name)
if v is not None:
return np.asarray(v, float)
return None
def _frame_root_point(kp: dict):
left_hip = kp.get("left_hip")
right_hip = kp.get("right_hip")
if left_hip is not None and right_hip is not None:
return (np.asarray(left_hip, float) + np.asarray(right_hip, float)) / 2.0
pts = [np.asarray(v, float) for v in kp.values() if v is not None]
if not pts:
return np.zeros(3, dtype=float)
return np.mean(np.stack(pts, axis=0), axis=0)
def _active_foot_anchor(kp: dict, active_side: str):
side = active_side if active_side in ("left", "right") else "left"
ankle = _pick_first_point(kp, [f"{side}_ankle"])
if ankle is not None:
return ankle
heel = _pick_first_point(kp, [f"{side}_heel"])
toe = _pick_first_point(kp, [f"{side}_big_toe", f"{side}_small_toe"])
if heel is not None and toe is not None:
return (heel + toe) / 2.0
return toe if toe is not None else heel
def _foot_floor_y(kp: dict):
foot_names = [
"left_ankle", "right_ankle",
"left_heel", "right_heel",
"left_big_toe", "right_big_toe",
"left_small_toe", "right_small_toe",
]
values = []
for name in foot_names:
v = kp.get(name)
if v is not None:
values.append(float(v[1]))
if not values:
return None
return max(values)
# Rendered ball is drawn 1.15x the real ball size -- purely a display/visibility
# choice, does not affect any triangulated position or distance calculation.
BALL_DISPLAY_SCALE = 1.15
def _ball_radius_raw_units(json_data, radius_cm=11.0):
scale_unit = str(json_data.get("metadata", {}).get("scale_unit") or "m").lower().strip()
if scale_unit == "m":
raw = radius_cm / 100.0
elif scale_unit == "cm":
raw = radius_cm
elif scale_unit == "mm":
raw = radius_cm * 10.0
else:
raw = radius_cm / 100.0
return raw * BALL_DISPLAY_SCALE
def _pose_scene_payload(json_data, player_id=0, metrics=None, active_foot="auto"):
if json_data is None:
return [], []
frame_keys = sorted(int(k) for k in json_data.get("frames", {}).keys())
if not frame_keys:
return frame_keys, []
metric_map = {}
for item in metrics or []:
try:
metric_map[int(item.get("frame"))] = item.get("active_foot", active_foot or "auto")
except Exception:
continue
try:
pid = int(player_id) if player_id is not None else 0
except (ValueError, TypeError):
pid = 0
frames_payload = []
for fk in frame_keys:
frame = json_data["frames"].get(str(fk), {})
players = frame.get("players", [])
balls = frame.get("balls_3d") or {}
ball_entry = next(iter(balls.values()), None) if balls else None
if not players and not ball_entry:
frames_payload.append(None)
continue
player = next((p for p in players if p.get("player_id") == pid), players[0]) if players else None
# keypoints_3d_world is world-frame (Y-up, floor=0 -- see
# pipeline._apply_world). The renderer below (npForPoint/floorForKp)
# was tuned around the OLD always-broken world transform, which
# silently fell back to keypoints_3d_cam's raw camera-frame Y
# (effectively Y-down for this rig) and unconditionally flips Y to
# compensate. Negating world-frame Y here makes properly-calibrated
# points "look like" that same convention, so the existing
# (unchanged) floor-heuristic/flip JS below renders correctly
# against real calibration instead of the old fallback data.
#
# Prefer the display-only smoothed skeleton (2D EMA, see
# pipeline.EMA_ALPHA_2D_SKELETON) for this viewer -- metrics
# (compute_all_metrics) intentionally keep reading the unsmoothed
# keypoints_3d_world/cam below, untouched.
#
# No player this frame (e.g. ball visible before the player is fully
# in view) still yields a payload entry -- kp={} -- so the ball isn't
# silently dropped along with the (absent) skeleton.
if player is None:
kp = {}
else:
kp_world = (player.get("keypoints_3d_world_smooth")
or player.get("keypoints_3d_world"))
if kp_world:
kp = {n: (p[0], -p[1], p[2]) for n, p in kp_world.items() if p}
else:
kp = (player.get("keypoints_3d_cam_smooth")
or player.get("keypoints_3d_cam") or {})
bc_world = ball_entry.get("center_xyz_world") if ball_entry else None
if bc_world:
bc = (bc_world[0], -bc_world[1], bc_world[2])
else:
bc = ball_entry.get("center_xyz_cam") if ball_entry else None
active_side = metric_map.get(fk, active_foot or "auto")
trail = _active_foot_anchor(kp, active_side)
# kp/bc are both world-frame meters (see pipeline._apply_world), so a
# plain Euclidean distance here is a real physical distance -- used to
# sanity-check the visual "gap" at touch frames against the actual
# ball radius rather than eyeballing pixels in the 3D viewer.
foot_ball_cm = None
if bc:
_bc_arr = np.asarray(bc, dtype=float)
_ball_r_cm = float(ball_entry.get("radius_m", 0.11)) * 100.0
foot_ball_cm = {}
for _fn in _FOOT_KPTS + ["left_ankle", "right_ankle"]:
_fp = kp.get(_fn)
if _fp:
_d_cm = float(np.linalg.norm(np.asarray(_fp, dtype=float) - _bc_arr)) * 100.0
foot_ball_cm[_fn] = {"center_cm": round(_d_cm, 1),
"surface_cm": round(_d_cm - _ball_r_cm, 1)}
frames_payload.append({
"kp": {n: [round(v, 4) for v in p] for n, p in kp.items() if p},
"ball": [round(v, 4) for v in bc] if bc else None,
"ball_detected": bool(ball_entry.get("detected", True)) if ball_entry else None,
"foot_ball_cm": foot_ball_cm,
"active_foot": active_side,
"trail": [round(v, 4) for v in trail] if trail is not None else None,
})
return frame_keys, frames_payload
def build_pose_3d_html(json_data, player_id=0, metrics=None, active_foot="auto"):
_EMPTY = ("<div style='color:#94a3b8;padding:40px;text-align:center;"
"background:#0f172a;border-radius:12px;font-family:system-ui'>"
"Process a video pair to see the 3D skeleton</div>")
if json_data is None:
return _EMPTY
frame_keys = sorted(int(k) for k in json_data.get("frames", {}).keys())
if not frame_keys:
return _EMPTY
pid = int(player_id) if player_id is not None else 0
# ── Build compact per-frame payload ──────────────────────────────────────
frame_keys, frames_payload = _pose_scene_payload(
json_data,
player_id=pid,
metrics=metrics,
active_foot=active_foot,
)
frames_json = json.dumps(frames_payload).replace("</", "<\\/")
fkeys_json = json.dumps(frame_keys)
bones_json = json.dumps([[a, b] for a, b in SKEL_EDGES_3D])
foot_json = json.dumps(_FOOT_KPTS)
trail_json = json.dumps([None if not fd else fd.get("trail") for fd in frames_payload])
ball_radius_raw = _ball_radius_raw_units(json_data)
n_frames = len(frame_keys)
# First non-null frame index for initial display
first_idx = next((i for i, f in enumerate(frames_payload) if f and f.get("kp")), 0)
inner = "\n".join([
"<!DOCTYPE html><html><head><meta charset='utf-8'>",
"<script src='https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js'></script>",
"<style>",
"* {box-sizing:border-box;margin:0;padding:0}",
"body {background:#0a0f1d;font-family:system-ui,sans-serif;overflow:hidden}",
"#c {width:100%;height:390px;display:block;cursor:grab}",
"#c:active{cursor:grabbing}",
"#ctrl {display:flex;align-items:center;gap:10px;padding:8px 14px;",
" background:#141b2d;border-top:1px solid #2d3750}",
"#btn {background:#22d3ee;border:none;color:#0f172a;font-weight:700;",
" padding:6px 18px;border-radius:7px;cursor:pointer;font-size:13px}",
"#viewbtn {background:#1a2238;border:1px solid #2d3750;color:#e2e8f0;font-weight:600;",
" padding:6px 14px;border-radius:7px;cursor:pointer;font-size:12px}",
"#sl {flex:1;accent-color:#22d3ee;cursor:pointer}",
"#lbl {color:#94a3b8;font-size:11px;min-width:70px;text-align:right}",
"#err {display:none;position:absolute;top:4px;left:4px;right:4px;",
" background:rgba(220,38,38,.85);color:#fff;font-size:11px;",
" padding:4px 8px;border-radius:5px;word-break:break-all}",
"</style></head><body>",
"<div style='position:relative'>",
"<canvas id='c'></canvas>",
"<div id='err'></div>",
"</div>",
"<div id='ctrl'>",
f" <button id='btn'>&#9654; Play</button>",
f" <button id='viewbtn'>World view</button>",
f" <input id='sl' type='range' min='0' max='{n_frames-1}' value='{first_idx}'>",
" <span id='lbl'>frame 0</span>",
"</div>",
"<script>",
"window.onerror=function(msg,src,line){",
" var e=document.getElementById('err');",
" e.style.display='block';e.textContent='JS error: '+msg+' (line '+line+')';",
"};",
"(function(){",
f" var FRAMES={frames_json};",
f" var FKEYS={fkeys_json};",
f" var BONE_LIST={bones_json};",
f" var FOOT_SET=new Set({foot_json});",
f" var TRAIL={trail_json};",
f" var BALL_RADIUS_RAW={ball_radius_raw};",
" var LARGE=new Set(['left_hip','right_hip','left_shoulder','right_shoulder',",
" 'left_knee','right_knee','neck','nose']);",
" function rootForKp(kp){",
" if(kp['left_hip']&&kp['right_hip']){",
" return [(kp['left_hip'][0]+kp['right_hip'][0])*0.5,",
" (kp['left_hip'][1]+kp['right_hip'][1])*0.5,",
" (kp['left_hip'][2]+kp['right_hip'][2])*0.5];",
" }",
" var pts=[];",
" for(var k in kp){pts.push(kp[k]);}",
" if(!pts.length) return [_cx,_cy,_cz];",
" var rx=0,ry=0,rz=0;",
" for(var i=0;i<pts.length;i++){rx+=pts[i][0];ry+=pts[i][1];rz+=pts[i][2];}",
" return [rx/pts.length,ry/pts.length,rz/pts.length];",
" }",
" function floorForKp(kp){",
" var names=['left_ankle','right_ankle','left_heel','right_heel',",
" 'left_big_toe','right_big_toe','left_small_toe','right_small_toe'];",
" var vals=[];",
" for(var i=0;i<names.length;i++){if(kp[names[i]]) vals.push(kp[names[i]][1]);}",
" if(vals.length) return Math.max.apply(null, vals);",
" return _cy;",
" }",
" function npForPoint(p,kp){",
" var r=rootForKp(kp);",
" var fy=floorForKp(kp);",
" return [(p[0]-r[0])*_nscale,-(p[1]-fy)*_nscale,(p[2]-r[2])*_nscale];",
" }",
"",
" // --- Gap-fill: linear-interpolate missing joints across frames ---",
" (function fillGaps(){",
" var N=FRAMES.length;",
" // Collect all joint names that ever appear",
" var allJoints={};",
" for(var i=0;i<N;i++){ if(FRAMES[i]&&FRAMES[i].kp) for(var j in FRAMES[i].kp) allJoints[j]=1; }",
" for(var jn in allJoints){",
" // Walk frames; fill gaps between last-known and next-known",
" var lastIdx=-1, lastVal=null;",
" // First pass: forward-fill to find segments",
" for(var i=0;i<N;i++){",
" var fd=FRAMES[i];",
" if(fd&&fd.kp&&fd.kp[jn]){",
" if(lastIdx>=0 && i-lastIdx>1){",
" // Interpolate frames lastIdx+1 … i-1",
" var a=lastVal, b=fd.kp[jn], gap=i-lastIdx;",
" for(var g=1;g<gap;g++){",
" var t=g/gap;",
" if(!FRAMES[lastIdx+g]) FRAMES[lastIdx+g]={kp:{}};",
" if(!FRAMES[lastIdx+g].kp) FRAMES[lastIdx+g].kp={};",
" FRAMES[lastIdx+g].kp[jn]=[",
" a[0]*(1-t)+b[0]*t,",
" a[1]*(1-t)+b[1]*t,",
" a[2]*(1-t)+b[2]*t",
" ];",
" }",
" }",
" lastIdx=i; lastVal=fd.kp[jn];",
" }",
" }",
" }",
" })();",
"",
" // --- EMA smooth for display only (metrics/graphs use raw pipeline data) ---",
" // Applied to TRAIL too (same alpha, same reset-on-gap rule) -- it used to",
" // only smooth fd.kp, leaving TRAIL raw/unlagged, so the foot trail visibly",
" // ran ahead of the (smoothed, lagging) rendered skeleton during fast swings.",
" (function smoothDisplay(){",
" var alpha=0.45; // higher = more responsive, lower = smoother",
" var prev={};",
" var prevTrail=null;",
" for(var i=0;i<FRAMES.length;i++){",
" var fd=FRAMES[i];",
" if(!fd||!fd.kp){prev={};prevTrail=null;continue;} // reset on gap",
" for(var jn in fd.kp){",
" var p=fd.kp[jn];",
" if(prev[jn]){",
" var q=prev[jn];",
" fd.kp[jn]=[(alpha*p[0]+(1-alpha)*q[0]),",
" (alpha*p[1]+(1-alpha)*q[1]),",
" (alpha*p[2]+(1-alpha)*q[2])];",
" }",
" prev[jn]=fd.kp[jn];",
" }",
" if(TRAIL[i]){",
" if(prevTrail){",
" var tp=TRAIL[i], tq=prevTrail;",
" TRAIL[i]=[(alpha*tp[0]+(1-alpha)*tq[0]),",
" (alpha*tp[1]+(1-alpha)*tq[1]),",
" (alpha*tp[2]+(1-alpha)*tq[2])];",
" }",
" prevTrail=TRAIL[i];",
" }",
" }",
" })();",
"",
" // --- Auto-center: compute centroid + radius from first valid frame ---",
f" var _fi={first_idx};",
" var _fd=FRAMES[_fi];",
" var _cx=0,_cy=0,_cz=0,_nc=0,_mn=[1e9,1e9,1e9],_mx=[-1e9,-1e9,-1e9];",
" if(_fd&&_fd.kp){",
" for(var _jn in _fd.kp){",
" var _p=_fd.kp[_jn];",
" _cx+=_p[0];_cy+=_p[1];_cz+=_p[2];_nc++;",
" _mn[0]=Math.min(_mn[0],_p[0]);_mn[1]=Math.min(_mn[1],_p[1]);_mn[2]=Math.min(_mn[2],_p[2]);",
" _mx[0]=Math.max(_mx[0],_p[0]);_mx[1]=Math.max(_mx[1],_p[1]);_mx[2]=Math.max(_mx[2],_p[2]);",
" }",
" _cx/=_nc;_cy/=_nc;_cz/=_nc;",
" }",
"",
" // --- World-path bounding box (root position across ALL frames) ---",
" // Used by the 'World view' toggle so real translation (walking) is",
" // visible instead of always re-centering the player at the origin.",
" var _pathMinX=1e9,_pathMaxX=-1e9,_pathMinZ=1e9,_pathMaxZ=-1e9,_pathN=0;",
" for(var _wi=0;_wi<FRAMES.length;_wi++){",
" var _wfd=FRAMES[_wi];",
" if(!_wfd||!_wfd.kp) continue;",
" var _wr=rootForKp(_wfd.kp);",
" _pathMinX=Math.min(_pathMinX,_wr[0]); _pathMaxX=Math.max(_pathMaxX,_wr[0]);",
" _pathMinZ=Math.min(_pathMinZ,_wr[2]); _pathMaxZ=Math.max(_pathMaxZ,_wr[2]);",
" _pathN++;",
" }",
" var _pathCenterX=_pathN>0?(_pathMinX+_pathMaxX)/2:0;",
" var _pathCenterZ=_pathN>0?(_pathMinZ+_pathMaxZ)/2:0;",
" var _pathSpan=_pathN>0?Math.max(_pathMaxX-_pathMinX,_pathMaxZ-_pathMinZ,0.5):0.5;",
# debug banner showing raw coords
" (function dbg(){",
" var filled=FRAMES.filter(function(f){return f&&f.kp&&Object.keys(f.kp).length>0;}).length;",
" var ballN=FRAMES.filter(function(f){return f&&f.ball;}).length;",
" var fd=FRAMES[_fi];",
" var keys=fd&&fd.kp?Object.keys(fd.kp):[];",
" var e=document.getElementById('err');",
" e.style.display='block'; e.style.background='rgba(15,30,60,.92)';",
f" e.textContent='frames={n_frames} | with_kp='+filled+' | balls='+ballN+' (r_raw={ball_radius_raw:.4f}) | fi='+_fi+' | joints='+keys.length",
" +(keys.length?(' | ctr=['+[_cx,_cy,_cz].map(function(v){return v.toFixed(2);}).join(',')+']'",
" +' range=['+[_mx[0]-_mn[0],_mx[1]-_mn[1],_mx[2]-_mn[2]].map(function(v){return v.toFixed(2);}).join(',')+']'):'');",
" })();",
"",
" var canvas=document.getElementById('c');",
" var W=canvas.parentElement.offsetWidth||600, H=390;",
" var renderer=new THREE.WebGLRenderer({canvas:canvas,antialias:true,alpha:false});",
" renderer.setSize(W,H); renderer.setPixelRatio(Math.min(window.devicePixelRatio||1,2));",
" var scene=new THREE.Scene(); scene.background=new THREE.Color(0x0a0f1d);",
" var camera=new THREE.PerspectiveCamera(45,W/H,0.001,100);",
"",
" // --- Lights ---",
" scene.add(new THREE.AmbientLight(0x1e3a5f,1.2));",
" var rl=new THREE.DirectionalLight(0x22d3ee,2.0); rl.position.set(-3,3,-1); scene.add(rl);",
" var kl=new THREE.DirectionalLight(0xa78bfa,1.2); kl.position.set(3,4,2); scene.add(kl);",
" var fl=new THREE.PointLight(0xf59e0b,0.8,20); fl.position.set(0,-1,0); scene.add(fl);",
"",
" // --- Materials ---",
" var boneMat=new THREE.MeshStandardMaterial({color:0x22d3ee,metalness:0.85,roughness:0.12,emissive:0x052a38,emissiveIntensity:0.4});",
" var jntMat =new THREE.MeshStandardMaterial({color:0x7c3aed,metalness:0.9, roughness:0.08,emissive:0x1a0538,emissiveIntensity:0.5});",
" var ftMat =new THREE.MeshStandardMaterial({color:0xf59e0b,metalness:0.8, roughness:0.15,emissive:0x3d2700,emissiveIntensity:0.4});",
" var ballMat=new THREE.MeshStandardMaterial({color:0x22c55e,metalness:0.15, roughness:0.25, transparent:true, opacity:0.35, emissive:0x0a2e14,emissiveIntensity:0.35});",
"",
# Normalize: map raw coords β†’ ~1.8-unit person centred at origin
# This fixes any scale/unit issues (pixels, mm, m β€” all handled)
" var _span=Math.max(_mx[0]-_mn[0],_mx[1]-_mn[1],_mx[2]-_mn[2],0.001);",
" var _nscale=_nc>0?(1.8/_span):1.0;",
# npt(p) = normalized point: centre on centroid, scale to ~1.8 m, flip Y so up=up
" function npt(p){return [(p[0]-_cx)*_nscale,-(p[1]-_cy)*_nscale,(p[2]-_cz)*_nscale];}",
" var BALL_RADIUS = BALL_RADIUS_RAW * _nscale;",
"",
" // --- World view: fixed path-centered point, TRUE calibrated floor (Y=0) ---",
" // instead of npForPoint's per-frame hip/foot re-centering. This is what",
" // makes walking/translation across the ground actually visible.",
" var worldMode=false;",
" function npWorldPoint(p){",
" return [(p[0]-_pathCenterX)*_nscale, -(p[1]-0)*_nscale, (p[2]-_pathCenterZ)*_nscale];",
" }",
" var FOLLOW_GRID=6, FOLLOW_RADIUS=3.2;",
" var WORLD_GRID=Math.max(FOLLOW_GRID, _pathSpan*_nscale*1.6);",
" var WORLD_RADIUS=Math.max(4.0, _pathSpan*_nscale*1.3);",
"",
" var gridHelper=new THREE.GridHelper(FOLLOW_GRID,24,0x1e293b,0x1e293b);",
" scene.add(gridHelper);",
" function setGridSize(size){",
" scene.remove(gridHelper);",
" if(gridHelper.geometry) gridHelper.geometry.dispose();",
" var divisions=Math.max(8,Math.round(size*4));",
" gridHelper=new THREE.GridHelper(size,divisions,0x1e293b,0x1e293b);",
" scene.add(gridHelper);",
" }",
"",
" // --- Built-in orbit β€” skeleton always centred at origin after normalisation ---",
" var theta=0.6, phi=1.1, radius=FOLLOW_RADIUS;",
" var tgt=new THREE.Vector3(0,0,0);",
" function camUpdate(){",
" camera.position.set(",
" tgt.x+radius*Math.sin(phi)*Math.sin(theta),",
" tgt.y+radius*Math.cos(phi),",
" tgt.z+radius*Math.sin(phi)*Math.cos(theta));",
" camera.lookAt(tgt);",
" }",
" camUpdate();",
" var drag=false,px=0,py=0;",
" canvas.addEventListener('mousedown',function(e){drag=true;px=e.clientX;py=e.clientY;});",
" window.addEventListener('mouseup', function(){drag=false;});",
" window.addEventListener('mousemove',function(e){",
" if(!drag) return;",
" theta-=(e.clientX-px)*0.012; px=e.clientX;",
" phi=Math.max(0.05,Math.min(Math.PI-0.05,phi-(e.clientY-py)*0.012)); py=e.clientY;",
" camUpdate();",
" });",
" canvas.addEventListener('wheel',function(e){",
" e.preventDefault();",
" radius=Math.max(0.4,Math.min(12,radius+e.deltaY*0.005));",
" camUpdate();",
" },{passive:false});",
" // Touch support",
" var lastTouchDist=0;",
" canvas.addEventListener('touchstart',function(e){",
" if(e.touches.length===1){drag=true;px=e.touches[0].clientX;py=e.touches[0].clientY;}",
" if(e.touches.length===2){lastTouchDist=Math.hypot(e.touches[0].clientX-e.touches[1].clientX,e.touches[0].clientY-e.touches[1].clientY);}",
" });",
" canvas.addEventListener('touchend',function(){drag=false;});",
" canvas.addEventListener('touchmove',function(e){",
" e.preventDefault();",
" if(e.touches.length===1 && drag){",
" theta-=(e.touches[0].clientX-px)*0.012; px=e.touches[0].clientX;",
" phi=Math.max(0.05,Math.min(Math.PI-0.05,phi-(e.touches[0].clientY-py)*0.012)); py=e.touches[0].clientY;",
" camUpdate();",
" }",
" if(e.touches.length===2){",
" var d=Math.hypot(e.touches[0].clientX-e.touches[1].clientX,e.touches[0].clientY-e.touches[1].clientY);",
" radius=Math.max(0.4,Math.min(12,radius-(d-lastTouchDist)*0.01)); lastTouchDist=d;",
" camUpdate();",
" }",
" },{passive:false});",
"",
" var grp=new THREE.Group(); scene.add(grp);",
" var trailGroup=new THREE.Group(); scene.add(trailGroup);",
" var trailMat=new THREE.LineBasicMaterial({vertexColors:true,transparent:true,opacity:0.96,linewidth:2});",
" var trailDotMat=new THREE.MeshStandardMaterial({color:0xfbbf24,emissive:0x5a3b00,emissiveIntensity:0.5,roughness:0.2,metalness:0.1});",
" var trailLine=new THREE.Line(new THREE.BufferGeometry(), trailMat);",
" var trailDot=new THREE.Mesh(new THREE.SphereGeometry(0.035,12,8), trailDotMat);",
" trailGroup.add(trailLine); trailGroup.add(trailDot);",
" var ballGeo=new THREE.SphereGeometry(BALL_RADIUS,18,14);",
"",
" // --- Ball-path trail: traces the ball's own 3D center across frames ---",
" var ballTrailGroup=new THREE.Group(); scene.add(ballTrailGroup);",
" var ballTrailMat=new THREE.LineBasicMaterial({color:0xffffff,transparent:true,opacity:0.9,linewidth:2});",
" var ballTrailLine=new THREE.Line(new THREE.BufferGeometry(), ballTrailMat);",
" ballTrailGroup.add(ballTrailLine);",
" function drawBallTrail(limitIdx){",
" var pts=[];",
" for(var i=0;i<=limitIdx && i<FRAMES.length;i++){",
" var fd=FRAMES[i];",
" if(!fd||!fd.ball) continue;",
" var p=npAny(fd.ball, fd.kp||{});",
" pts.push(new THREE.Vector3(p[0],p[1],p[2]));",
" }",
" if(ballTrailLine.geometry){ballTrailLine.geometry.dispose();}",
" ballTrailLine.geometry=new THREE.BufferGeometry();",
" if(pts.length<2){ballTrailLine.visible=false; return;}",
" ballTrailLine.visible=true;",
" ballTrailLine.geometry.setFromPoints(pts);",
" }",
"",
" function mkBone(ax,ay,az,bx,by,bz){",
" var a=new THREE.Vector3(ax,ay,az), b=new THREE.Vector3(bx,by,bz);",
" var dir=new THREE.Vector3().subVectors(b,a), len=dir.length();",
" if(len<0.001) return null;",
" var g=new THREE.CylinderGeometry(0.018,0.018,len,8,1);",
" var m=new THREE.Mesh(g,boneMat);",
" m.position.copy(new THREE.Vector3().addVectors(a,b).multiplyScalar(0.5));",
" m.quaternion.setFromUnitVectors(new THREE.Vector3(0,1,0),dir.normalize());",
" return m;",
" }",
" function mkJoint(x,y,z,ft,lg){",
" var r=lg?0.042:(ft?0.030:0.022);",
" var m=new THREE.Mesh(new THREE.SphereGeometry(r,10,7),ft?ftMat:jntMat);",
" m.position.set(x,y,z); return m;",
" }",
" function npAny(p,kp){return worldMode ? npWorldPoint(p) : npForPoint(p,kp);}",
" var TRAIL_COLOR=[0.984,0.749,0.141];",
" function drawTrail(limitIdx){",
" var pts=[], fks=[];",
" for(var i=0;i<=limitIdx && i<FRAMES.length;i++){",
" var fd=FRAMES[i];",
" if(!fd||!fd.kp||!TRAIL[i]) continue;",
" var p=npAny(TRAIL[i], fd.kp);",
" pts.push(new THREE.Vector3(p[0],p[1],p[2]));",
" fks.push(FKEYS[i]);",
" }",
" if(trailLine.geometry){trailLine.geometry.dispose();}",
" trailLine.geometry=new THREE.BufferGeometry();",
" if(!pts.length){trailLine.visible=false; trailDot.visible=false; return;}",
" trailLine.visible=true; trailDot.visible=true;",
" trailLine.geometry.setFromPoints(pts);",
" var colors=[];",
" for(var pi=0;pi<fks.length;pi++){",
" colors.push(TRAIL_COLOR[0],TRAIL_COLOR[1],TRAIL_COLOR[2]);",
" }",
" trailLine.geometry.setAttribute('color', new THREE.Float32BufferAttribute(colors,3));",
" var last=pts[pts.length-1];",
" trailDot.position.set(last.x,last.y,last.z);",
" }",
"",
" function renderSkel(fd){",
" while(grp.children.length>0){var c=grp.children[0];if(c.geometry)c.geometry.dispose();grp.remove(c);}",
" if(!fd||!fd.kp) return;",
" var kp=fd.kp;",
" function np(p){return npAny(p,kp);}",
" for(var i=0;i<BONE_LIST.length;i++){",
" var na=BONE_LIST[i][0],nb=BONE_LIST[i][1];",
" if(!kp[na]||!kp[nb]) continue;",
" var a=np(kp[na]),b=np(kp[nb]);",
" var bn=mkBone(a[0],a[1],a[2],b[0],b[1],b[2]);",
" if(bn) grp.add(bn);",
" }",
" for(var jn in kp){",
" var pp=np(kp[jn]);",
" grp.add(mkJoint(pp[0],pp[1],pp[2],FOOT_SET.has(jn),LARGE.has(jn)));",
" }",
" var _dbgEl=document.getElementById('err');",
" if(fd.ball){",
" var bp=np(fd.ball);",
" var bm=new THREE.Mesh(ballGeo,ballMat);",
" bm.position.set(bp[0],bp[1],bp[2]); grp.add(bm);",
" var fbcTxt='';",
" if(fd.foot_ball_cm){",
" var _names=Object.keys(fd.foot_ball_cm);",
" _names.sort(function(a,b){return fd.foot_ball_cm[a].center_cm-fd.foot_ball_cm[b].center_cm;});",
" if(_names.length){",
" var _n=_names[0], _fb=fd.foot_ball_cm[_n];",
" fbcTxt=' | nearest='+_n+' center='+_fb.center_cm+'cm surface='+_fb.surface_cm+'cm';",
" }",
" }",
" _dbgEl.style.display='block';",
" _dbgEl.textContent='ball raw='+JSON.stringify(fd.ball)+' | ball scene=['+",
" bp.map(function(v){return v.toFixed(3);}).join(',')+'] | BALL_RADIUS='+BALL_RADIUS.toFixed(4)+",
" ' | _nscale='+_nscale.toFixed(4)+",
" ' | '+(fd.ball_detected===false?'EXTRAPOLATED (occluded)':'real detection')+fbcTxt;",
" } else {",
" _dbgEl.style.display='block';",
" _dbgEl.textContent='no ball this frame';",
" }",
" }",
"",
f" var idx={first_idx},playing=false,timer=null;",
f" renderSkel(FRAMES[{first_idx}]); drawTrail({first_idx}); drawBallTrail({first_idx});",
"",
" function render(i){",
" idx=((i%FRAMES.length)+FRAMES.length)%FRAMES.length;",
" renderSkel(FRAMES[idx]);",
" drawTrail(idx);",
" drawBallTrail(idx);",
" document.getElementById('sl').value=idx;",
" document.getElementById('lbl').textContent='f '+FKEYS[idx]+' | '+(FRAMES[idx]&&FRAMES[idx].active_foot?FRAMES[idx].active_foot:'auto');",
" }",
" document.getElementById('btn').addEventListener('click',function(){",
" playing=!playing; this.textContent=playing?'\\u23F8 Pause':'\\u25B6 Play';",
" if(playing){timer=setInterval(function(){render(idx+1);},80);}",
" else{clearInterval(timer);}",
" });",
" document.getElementById('viewbtn').addEventListener('click',function(){",
" worldMode=!worldMode;",
" this.textContent=worldMode?'Follow player':'World view';",
" if(worldMode){ setGridSize(WORLD_GRID); radius=WORLD_RADIUS; tgt.set(0,0.9*_nscale,0); }",
" else { setGridSize(FOLLOW_GRID); radius=FOLLOW_RADIUS; tgt.set(0,0,0); }",
" camUpdate(); render(idx);",
" });",
" document.getElementById('sl').addEventListener('input',function(){",
" if(playing){clearInterval(timer);playing=false;document.getElementById('btn').textContent='\\u25B6 Play';}",
" render(parseInt(this.value));",
" });",
" function loop(){requestAnimationFrame(loop);renderer.render(scene,camera);}",
" loop();",
" window.addEventListener('resize',function(){",
" var W2=canvas.parentElement.offsetWidth||600;",
" camera.aspect=W2/H; camera.updateProjectionMatrix(); renderer.setSize(W2,H);",
" });",
"})();",
"</script></body></html>",
])
safe = inner.replace("&", "&amp;").replace('"', "&quot;")
return ('<div style="background:#0a0f1d;border-radius:12px;overflow:hidden;'
'border:1px solid #2d3750;">'
f'<iframe srcdoc="{safe}" '
'style="width:100%;height:450px;border:none;display:block;" '
'sandbox="allow-scripts"></iframe></div>')
# ══════════════════════════════════════════════════════════════════════════════
# HELPER FUNCTIONS
# ══════════════════════════════════════════════════════════════════════════════
def _metrics_for_player(json_data, player_id, active_foot="auto", angle_ref="heel"):
if json_data is None:
return []
try:
pid = int(player_id) if player_id is not None else 0
except (ValueError, TypeError):
pid = 0
return compute_all_metrics(json_data, active_foot=active_foot,
player_id=pid, angle_ref=angle_ref)
# ══════════════════════════════════════════════════════════════════════════════
# EVENT HANDLERS
# ══════════════════════════════════════════════════════════════════════════════
_N_OUTPUTS = 11 # process yields 11 values
_NOOP = tuple(gr.update() for _ in range(_N_OUTPUTS))
def handle_process(cam0_path, cam1_path, calib_path,
max_frames_val,
det_thr_val, kp_thr_val, epi_max_val,
ball_conf_val, ball_epi_val,
enable_ball_val):
# ── Reset UI immediately so old results don't linger on re-run ───────────
reset = list(_NOOP)
reset[3] = gr.update(value=None, visible=False) # cam0_vid β€” hide
reset[4] = gr.update(value=None, visible=False) # cam1_vid β€” hide
reset[8] = gr.update(value=None, visible=True) # cam0_img β€” show live preview
reset[9] = gr.update(value=None, visible=True) # cam1_img β€” show live preview
reset[10] = _progress_bar_html(label="Starting…") # progress_html
yield tuple(reset)
if cam0_path is None or cam1_path is None or calib_path is None:
out = list(_NOOP)
out[10] = _progress_bar_html(label="Please upload cam0, cam1, and calibration files.")
yield tuple(out)
return
calib_str = calib_path if isinstance(calib_path, str) else calib_path.name
ann_frames = []; json_data = None
try:
for ann_frames, json_data, img0, img1, fidx, total in process_video_pair_stream(
cam0_path, cam1_path, calib_str,
max_frames=int(max_frames_val),
score_thr=float(kp_thr_val),
det_thr=float(det_thr_val),
epi_max=float(epi_max_val),
ball_conf=float(ball_conf_val),
ball_epi=float(ball_epi_val),
enable_ball=bool(enable_ball_val),
frame_indices=None,
):
out = list(_NOOP)
out[8] = img0 # live preview β†’ gr.Image (cam0_img)
out[9] = img1 # live preview β†’ gr.Image (cam1_img)
out[10] = _progress_bar_html(fidx, total)
yield tuple(out)
except Exception as e:
print(f"[handle_process] Error: {e}")
out = list(_NOOP)
out[10] = _progress_bar_html(label=f"Error: {e}")
yield tuple(out)
return
if not ann_frames or json_data is None:
print("[handle_process] No frames processed β€” check your video files.")
out = list(_NOOP)
out[10] = _progress_bar_html(label="No frames processed β€” check your video files.")
yield tuple(out)
return
metrics = _metrics_for_player(json_data, 0, active_foot="right", angle_ref="heel")
tmp = tempfile.NamedTemporaryFile(suffix=".json", delete=False,
mode="w", encoding="utf-8")
json.dump(json_data, tmp, allow_nan=False, default=lambda x: None)
tmp.close()
n = len(ann_frames)
pose_html_widget = build_pose_3d_html(
json_data,
player_id=0,
metrics=metrics,
active_foot="right",
)
player_ids_found = sorted({
p["player_id"]
for f in json_data["frames"].values()
for p in f.get("players", [])
})
player_choices = [(f"Player {pid}", str(pid)) for pid in player_ids_found]
player_defaults = str(player_ids_found[0]) if player_ids_found else None
# ── Render annotated videos ───────────────────────────────────────────────
vid0_path, vid1_path = frames_to_video(ann_frames, fps=25)
yield (
ann_frames, # 0 state_frames
json_data, # 1 state_json
metrics, # 2 state_metrics
gr.update(value=vid0_path, visible=True), # 3 cam0_vid β€” show video
gr.update(value=vid1_path, visible=True), # 4 cam1_vid β€” show video
pose_html_widget, # 5 pose_html
tmp.name, # 6 download_file
gr.update(choices=player_choices, value=player_defaults), # 7 player_id_group
gr.update(visible=False), # 8 cam0_img β€” hide live preview
gr.update(visible=False), # 9 cam1_img β€” hide live preview
_progress_bar_html(n, n, label=f"Done β€” {n} frames"), # 10 progress_html
)
def handle_player_change(player_id_sel, json_data):
if json_data is None:
return [], build_pose_3d_html(None)
try:
pid = int(player_id_sel) if player_id_sel is not None else 0
except (ValueError, TypeError):
pid = 0
metrics = _metrics_for_player(json_data, pid, active_foot="right")
pose_widget = build_pose_3d_html(
json_data,
player_id=pid,
metrics=metrics,
active_foot="right",
)
return metrics, pose_widget
def handle_save_click(ann_frames, json_data, metrics, player_id):
if not ann_frames or json_data is None:
return "No processed data to save. Run processing first."
frame_keys = sorted(int(k) for k in json_data.get("frames", {}).keys())
if not frame_keys:
return "No frame data available."
touch_frame = frame_keys[len(frame_keys) // 2]
label = "action"
active_leg = "auto"
try:
pid = int(player_id) if player_id is not None else 0
except (ValueError, TypeError):
pid = 0
try:
action_id = save_action(
touch_frame=touch_frame,
action_label=label,
ann_frames=ann_frames,
json_data=json_data,
metrics=metrics,
pre_frames=15,
post_frames=15,
active_leg=active_leg,
player_id=pid,
)
# Refresh archive list
arc_upd, _ = handle_refresh_archive()
return arc_upd, f"Saved: {label} (frame {touch_frame}, ID: {action_id[:18]}...)"
except Exception as e:
return gr.update(), f"Save failed: {e}"
# ── Archive handlers ──────────────────────────────────────────────────────────
def handle_refresh_archive():
actions = load_archive()
if not actions:
return gr.update(choices=[], value=None), "Archive is empty."
choices = [
(f"{m['action_label']} Β· frame {m['actual_touch_frame']} Β· {m['saved_at'][:10]}",
m["action_id"])
for m in actions
]
return gr.update(choices=choices, value=None), f"{len(actions)} saved actions"
def handle_archive_select(action_id):
_none = ([], None, [], "", None, None,
build_pose_3d_html(None),
gr.update(value=None, visible=False))
if not action_id:
return _none
try:
data = load_action(action_id)
meta = data["meta"]
json_data = data["json_data"]
metrics = data["metrics"]
ann_frames = data["ann_frames"]
except Exception as e:
return _none[:3] + (f"Load error: {e}",) + _none[4:]
# Generate annotated videos for archive viewer
vid0_path, vid1_path = frames_to_video(ann_frames, fps=25)
pose_widget = build_pose_3d_html(json_data, metrics=metrics)
leg_col = EMERALD if meta.get("active_leg") == "left" else AMBER
info_html = f"""
<div style="padding:14px 18px;background:{PANEL};border:1px solid {BORDER};
border-radius:10px;margin-bottom:12px;">
<p style="color:{CYAN};font-weight:700;font-size:15px;margin:0 0 6px;">
{meta['action_label']}
<span style="font-size:12px;color:{MUTED};font-weight:400;"> β€” frame {meta['actual_touch_frame']}</span>
</p>
<p style="color:{MUTED};font-size:12px;margin:0;">
Active leg: <span style="color:{leg_col};font-weight:600;">{meta['active_leg']}</span>
&nbsp;Β·&nbsp;
-{meta['pre_frames_saved']} / +{meta['post_frames_saved']} frames
&nbsp;Β·&nbsp;
Saved {meta['saved_at'][:10]}
</p>
</div>"""
# Load mesh if it was saved with this action
mesh_glb = os.path.join(ARCHIVE_DIR, action_id, "mesh.glb")
mesh_upd = gr.update(value=mesh_glb, visible=True) if os.path.isfile(mesh_glb) \
else gr.update(value=None, visible=False)
return (ann_frames, json_data, metrics,
info_html, vid0_path, vid1_path,
pose_widget, mesh_upd)
def handle_archive_delete(action_id):
if not action_id:
return gr.update(), gr.update(), "Select an action to delete."
try:
deleted = delete_action(action_id)
except Exception as e:
return gr.update(), gr.update(), f"Delete failed: {e}"
if not deleted:
return gr.update(), gr.update(), "Action not found."
# Refresh list
actions = load_archive()
if not actions:
return gr.update(choices=[], value=None), gr.update(choices=[], value=None), \
"Deleted. Archive is now empty."
choices = [
(f"{m['action_label']} Β· frame {m['actual_touch_frame']} Β· {m['saved_at'][:10]}",
m["action_id"])
for m in actions
]
upd = gr.update(choices=choices, value=None)
return upd, upd, f"Deleted. {len(actions)} actions remain."
# ══════════════════════════════════════════════════════════════════════════════
# GRADIO LAYOUT
# ══════════════════════════════════════════════════════════════════════════════
with gr.Blocks(css=CUSTOM_CSS, title="Self-Calibration Demo") as demo:
# ── Persistent state ──────────────────────────────────────────────────────
state_frames = gr.State([])
state_json = gr.State(None)
state_metrics = gr.State([])
# Archive state
state_arc_frames = gr.State([])
state_arc_json = gr.State(None)
state_arc_metrics = gr.State([])
# ── Header ────────────────────────────────────────────────────────────────
gr.HTML(HEADER_HTML)
with gr.Tabs():
# ══════════════════════════════════════════════════════════════════════
# TAB 1 β€” ANALYSIS
# ══════════════════════════════════════════════════════════════════════
with gr.Tab("β–Ά Analysis"):
with gr.Row(equal_height=False):
# ── SIDEBAR ──────────────────────────────────────────────────
with gr.Column(scale=1, min_width=300):
gr.HTML(_section_label("Data sources"))
demo_btn = gr.Button("🎬 Try Default Data", variant="secondary")
progress_html = gr.HTML(_progress_bar_html())
cam0_input = gr.Video(label="Camera 0 (cam0.mp4)")
cam1_input = gr.Video(label="Camera 1 (cam1.mp4)")
calib_input = gr.File(label="Calibration (.pkl)",
file_types=[".pkl"])
max_frames_sl = gr.Number(
label="Max frames to process (0 = whole video)",
value=20, minimum=0, precision=0,
info="Set 0 to process the entire video.",
)
enable_ball_check = gr.Checkbox(
label="Enable ball detection (disable to save ~40% CPU)",
value=False,
)
process_btn = gr.Button("β–Ά PROCESS", variant="primary", size="lg")
# ── Save to archive ───────────────────────────────────────
gr.HTML(_divider())
gr.HTML(_section_label("Save action to archive"))
save_btn = gr.Button("πŸ’Ύ Save to Archive", variant="secondary")
save_status = gr.Textbox(label="", interactive=False, lines=1,
elem_classes=["status-box"])
gr.HTML(_divider())
gr.HTML(_section_label("Detection controls"))
det_thr_sl = gr.Slider(label="Detection threshold",
minimum=0.1, maximum=0.9, value=0.3, step=0.05)
kp_thr_sl = gr.Slider(label="Keypoint score threshold",
minimum=0.1, maximum=0.9, value=0.3, step=0.05)
epi_max_sl = gr.Slider(label="Epipolar max distance (px)",
minimum=2.0, maximum=100.0, value=20.0, step=1.0)
ball_conf_sl = gr.Slider(label="Ball detection confidence (YOLO)",
minimum=0.05, maximum=0.9, value=0.15, step=0.05)
ball_epi_sl = gr.Slider(label="Ball epipolar max distance (px)",
minimum=2.0, maximum=100.0, value=30.0, step=1.0)
gr.HTML(_divider())
download_file = gr.File(label="Download SPORALIZE JSON",
interactive=False)
# ── MAIN AREA ─────────────────────────────────────────────────
with gr.Column(scale=3):
# Live preview during processing (gr.Image, visible by default)
with gr.Row():
cam0_img = gr.Image(label="Camera 0 β€” processing…",
interactive=False, visible=True)
cam1_img = gr.Image(label="Camera 1 β€” processing…",
interactive=False, visible=True)
# Final annotated videos after processing (gr.Video, hidden by default)
with gr.Row():
cam0_vid = gr.Video(label="Camera 0 β€” annotated",
interactive=False, autoplay=True,
show_download_button=False, visible=False)
cam1_vid = gr.Video(label="Camera 1 β€” annotated",
interactive=False, autoplay=True,
show_download_button=False, visible=False)
# 3D Mech Skeleton β€” directly under videos
gr.HTML(_section_label("3D Pose viewer"))
pose_html = gr.HTML(build_pose_3d_html(None))
gr.HTML(_section_label("Player IDs"))
player_id_group = gr.Radio(choices=[], value=None, label="",
elem_classes=["badge-group"])
gr.HTML(_section_label("Ball IDs"))
ball_id_group = gr.CheckboxGroup(choices=[], value=[], label="",
elem_classes=["ball-badge-group"],
interactive=False)
# ══════════════════════════════════════════════════════════════════════
# TAB 2 β€” ARCHIVE
# ══════════════════════════════════════════════════════════════════════
with gr.Tab("πŸ“ Archive"):
with gr.Row(equal_height=False):
# ── Archive list (left) ───────────────────────────────────────
with gr.Column(scale=1, min_width=280):
gr.HTML(_section_label("Saved actions"))
arc_list_radio = gr.Radio(
choices=[], value=None,
label="", elem_classes=["archive-list-group"],
)
with gr.Row():
arc_refresh_btn = gr.Button("πŸ”„ Refresh", variant="secondary")
arc_delete_btn = gr.Button("πŸ—‘ Delete", variant="secondary")
arc_list_status = gr.Textbox(label="", interactive=False, lines=1,
elem_classes=["status-box"])
# ── Archive viewer (right) ────────────────────────────────────
with gr.Column(scale=3):
arc_info_html = gr.HTML(
f'<div style="color:{MUTED};padding:32px;text-align:center;'
f'border:1.5px dashed {BORDER};border-radius:12px;">'
f'Select a saved action from the list</div>'
)
with gr.Row():
arc_cam0_vid = gr.Video(label="Camera 0",
interactive=False, autoplay=True,
show_download_button=False)
arc_cam1_vid = gr.Video(label="Camera 1",
interactive=False, autoplay=True,
show_download_button=False)
gr.HTML(_section_label("3D Pose viewer"))
arc_pose_html = gr.HTML(build_pose_3d_html(None))
gr.HTML(_section_label("3D Mesh"))
arc_mesh_model = gr.Model3D(label="3D Mesh",
clear_color=[0.05, 0.08, 0.15, 1.0],
interactive=False, visible=False)
# =========================================================================
# WIRING -- ANALYSIS TAB
# =========================================================================
process_outputs = [
state_frames, state_json, state_metrics, # 0-2
cam0_vid, cam1_vid, # 3-4
pose_html, # 5
download_file, # 6
player_id_group, # 7
cam0_img, cam1_img, # 8-9
progress_html, # 10
]
process_btn.click(
fn=handle_process,
inputs=[cam0_input, cam1_input, calib_input,
max_frames_sl,
det_thr_sl, kp_thr_sl, epi_max_sl,
ball_conf_sl, ball_epi_sl,
enable_ball_check],
outputs=process_outputs,
api_name=False,
)
demo_btn.click(
fn=lambda: (DEMO_CAM0, DEMO_CAM1, DEMO_CALIB, 20),
outputs=[cam0_input, cam1_input, calib_input, max_frames_sl],
api_name=False,
).then(
fn=handle_process,
inputs=[cam0_input, cam1_input, calib_input,
max_frames_sl,
det_thr_sl, kp_thr_sl, epi_max_sl,
ball_conf_sl, ball_epi_sl,
enable_ball_check],
outputs=process_outputs,
api_name=False,
)
player_id_group.change(
fn=handle_player_change,
inputs=[player_id_group, state_json],
outputs=[state_metrics, pose_html],
api_name=False,
)
save_btn.click(
fn=handle_save_click,
inputs=[state_frames, state_json, state_metrics, player_id_group],
outputs=[arc_list_radio, save_status],
api_name=False,
)
# =========================================================================
# WIRING -- ARCHIVE TAB
# =========================================================================
arc_list_radio.change(
fn=handle_archive_select,
inputs=[arc_list_radio],
outputs=[state_arc_frames, state_arc_json, state_arc_metrics,
arc_info_html, arc_cam0_vid, arc_cam1_vid,
arc_pose_html, arc_mesh_model],
api_name=False,
)
arc_refresh_btn.click(
fn=lambda: handle_refresh_archive()[0],
outputs=[arc_list_radio],
api_name=False,
)
arc_delete_btn.click(
fn=handle_archive_delete,
inputs=[arc_list_radio],
outputs=[arc_list_radio, arc_list_radio, arc_list_status],
api_name=False,
)
if __name__ == "__main__":
demo.launch()