""" 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"""

Self-Calibration Demo

3D biomechanics  ·  RTMPose Wholebody  ·  Stereo calibration

""" def _section_label(text): return (f'

' f'{text}

') def _divider(): return f'
' 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"""
{text} {pct}%
""" _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 = ("
" "Process a video pair to see the 3D skeleton
") 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("", "", "", "
", "", "
", "
", "
", f" ", f" ", f" ", " frame 0", "
", "", ]) safe = inner.replace("&", "&").replace('"', """) return ('
' f'
') # ══════════════════════════════════════════════════════════════════════════════ # 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"""

{meta['action_label']} — frame {meta['actual_touch_frame']}

Active leg: {meta['active_leg']}  ·  -{meta['pre_frames_saved']} / +{meta['post_frames_saved']} frames  ·  Saved {meta['saved_at'][:10]}

""" # 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'
' f'Select a saved action from the list
' ) 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()