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("", "<\\/")
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([
"",
"",
"",
"