Spaces:
Sleeping
Sleeping
Download app.py from abdelkader9090/analysis: direct link, hf CLI and curl.
- Browser
- Download file 68 kB
-
https://huggingface.co/spaces/abdelkader9090/analysis/resolve/main/app.py
- Command line
-
hf download hf://spaces/abdelkader9090/analysis/app.py
-
curl -L -o app.py https://huggingface.co/spaces/abdelkader9090/analysis/resolve/main/app.py
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 Β· RTMPose Wholebody Β· 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'>▶ 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("&", "&").replace('"', """) | |
| 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> | |
| Β· | |
| -{meta['pre_frames_saved']} / +{meta['post_frames_saved']} frames | |
| Β· | |
| 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() | |