Spaces:
Paused
Paused
Fix shoulder tilt calculation: eliminate 1-10° issue with proper frame selection and frontal plane projection
Browse files- Add impact frame refinement: search ±4 frames for maximum shoulder tilt
- Remove camera-relative fallback (s_xz): always use frontal-plane atan2(v_up, v_lat)
- Remove heuristic guards that override good geometry
- Add world landmark scale validation (0.22-0.70m shoulder span)
- Simplify sign logic: positive = trail shoulder lower for right-handed
- Add comprehensive debug output for transparent troubleshooting
- Version updated to front-ff v2025-09-21-fixed-shoulder-tilt
Addresses core issue where ~1.2cm shoulder drop correctly yielded ~9° but wrong frame/projection was used
- app/models/front_facing_metrics.py +637 -919
app/models/front_facing_metrics.py
CHANGED
|
@@ -15,11 +15,73 @@ L_HIP, R_HIP = 23, 24
|
|
| 15 |
L_SHO, R_SHO = 11, 12
|
| 16 |
L_EYE, R_EYE = 2, 5
|
| 17 |
|
| 18 |
-
# Debug helper
|
| 19 |
VERBOSE = False
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
def dbg(msg):
|
| 21 |
if VERBOSE: print(f"DEBUG: {msg}")
|
| 22 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
# Core math utilities
|
| 24 |
def wrap180(a):
|
| 25 |
return (a + 180.0) % 360.0 - 180.0
|
|
@@ -71,335 +133,270 @@ def smooth_landmarks(pose_data: Dict[int, List], alpha: float = 0.35) -> Dict[in
|
|
| 71 |
|
| 72 |
return smoothed_data
|
| 73 |
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
"""Get landmark position (x, y, visibility) by index"""
|
| 77 |
-
if landmarks is None or not isinstance(landmarks, list) or landmark_idx >= len(landmarks) or landmarks[landmark_idx] is None:
|
| 78 |
-
return None
|
| 79 |
-
|
| 80 |
-
landmark = landmarks[landmark_idx]
|
| 81 |
-
if landmark is not None and isinstance(landmark, list) and len(landmark) >= 3:
|
| 82 |
-
return (landmark[0], landmark[1], landmark[2])
|
| 83 |
-
return None
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
def calculate_shoulder_tilt_at_impact(
|
| 87 |
-
pose_data,
|
| 88 |
-
swing_phases,
|
| 89 |
-
world_landmarks=None,
|
| 90 |
-
frames=None, # NEW: pass list/array of frames OR
|
| 91 |
-
image_shape=None, # NEW: (H, W) if frames not available
|
| 92 |
-
handedness='right'
|
| 93 |
-
):
|
| 94 |
"""
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
L_SHO, R_SHO = 11, 12
|
| 104 |
-
|
| 105 |
-
# ---------- helpers ----------
|
| 106 |
-
def _scale_xy(dx, dy, H, W):
|
| 107 |
-
"""Scale diffs if they look normalized; otherwise return as-is."""
|
| 108 |
-
if H is None or W is None:
|
| 109 |
-
return dx, dy
|
| 110 |
-
# Heuristic: normalized diffs are small (|dx|,|dy| <= ~2).
|
| 111 |
-
# Pixel diffs will be tens to hundreds.
|
| 112 |
-
if max(abs(dx), abs(dy)) <= 2.0:
|
| 113 |
-
return dx * W, dy * H # normalized → pixels
|
| 114 |
-
else:
|
| 115 |
-
return dx, dy # already pixels → leave alone
|
| 116 |
-
|
| 117 |
-
def _line_tilt_deg(lm, iL, iR):
|
| 118 |
-
# raw image tilt (no aspect correction); used only for small-roll estimate
|
| 119 |
-
if not lm or iL >= len(lm) or iR >= len(lm) or lm[iL] is None or lm[iR] is None:
|
| 120 |
-
return None
|
| 121 |
-
dx = lm[iR][0] - lm[iL][0]
|
| 122 |
-
dy = lm[iR][1] - lm[iL][1]
|
| 123 |
-
if abs(dx) + abs(dy) < 1e-8:
|
| 124 |
-
return None
|
| 125 |
-
return math.degrees(math.atan2(dy, dx))
|
| 126 |
-
|
| 127 |
-
def _consensus_roll_deg(lm, H, W):
|
| 128 |
-
"""Camera roll from eyes/hips with unit detection"""
|
| 129 |
-
if not lm:
|
| 130 |
-
return 0.0
|
| 131 |
-
cand = []
|
| 132 |
-
for line_idxs in [(L_EYE, R_EYE), (L_HIP, R_HIP)]:
|
| 133 |
-
iL, iR = line_idxs
|
| 134 |
if lm[iL] and lm[iR]:
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
|
| 144 |
-
#
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
H, W = image_shape[:2]
|
| 156 |
-
return float(H), float(W)
|
| 157 |
-
return None, None # no pixel aspect info
|
| 158 |
-
|
| 159 |
-
def _deroll(dx, dy, roll_deg):
|
| 160 |
-
cr = math.cos(math.radians(roll_deg))
|
| 161 |
-
sr = math.sin(math.radians(roll_deg))
|
| 162 |
-
# rotate by -roll: [ c s; -s c ] * [dx, dy]
|
| 163 |
-
return (cr*dx + sr*dy, -sr*dx + cr*dy)
|
| 164 |
-
|
| 165 |
-
def _width_px_derolled(lm, iL, iR, roll_deg):
|
| 166 |
-
"""Get derolled horizontal width and vertical separation in pixels"""
|
| 167 |
-
if not lm or not lm[iL] or not lm[iR]:
|
| 168 |
-
return 0.0, 0.0
|
| 169 |
-
# scale to pixels first
|
| 170 |
-
xL, yL = _scale_xy(lm[iL][0], lm[iL][1], H, W)
|
| 171 |
-
xR, yR = _scale_xy(lm[iR][0], lm[iR][1], H, W)
|
| 172 |
-
cr, sr = math.cos(math.radians(roll_deg)), math.sin(math.radians(roll_deg))
|
| 173 |
-
dxr = xR - xL; dyr = yR - yL
|
| 174 |
-
dx = (cr*dxr + sr*dyr)
|
| 175 |
-
dy = (-sr*dxr + cr*dyr)
|
| 176 |
-
return abs(dx), dy # horizontal width (derolled), vertical (derolled)
|
| 177 |
-
|
| 178 |
-
def _check_frame_quality(lm, H, W):
|
| 179 |
-
"""Quality gates: visibility, shoulder width, basic checks"""
|
| 180 |
-
if not lm or not lm[L_SHO] or not lm[R_SHO]:
|
| 181 |
-
return False, "missing_shoulders"
|
| 182 |
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
return False, "low_visibility"
|
| 186 |
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
dx_scaled, _ = _scale_xy(dx_raw, 0, H, W)
|
| 191 |
-
if abs(dx_scaled) < 0.08 * W:
|
| 192 |
-
return False, "too_narrow"
|
| 193 |
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
"""Estimate torso yaw from 3D shoulders to reject severe angles"""
|
| 198 |
-
if not world_frame or not world_frame[L_SHO] or not world_frame[R_SHO]:
|
| 199 |
-
return None
|
| 200 |
|
| 201 |
-
|
| 202 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 203 |
|
| 204 |
-
if
|
| 205 |
-
|
| 206 |
|
| 207 |
-
|
| 208 |
-
yaw = abs(math.degrees(math.atan2(abs(dz), abs(dx))))
|
| 209 |
-
return yaw
|
| 210 |
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
#
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 232 |
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 233 |
|
| 234 |
-
|
| 235 |
-
H, W = _get_HW()
|
| 236 |
-
|
| 237 |
-
# ---------- quality checks ----------
|
| 238 |
-
quality_ok, quality_reason = _check_frame_quality(lm, H, W)
|
| 239 |
-
if not quality_ok:
|
| 240 |
-
print(f"DEBUG shoulder_tilt: frame_quality_failed - {quality_reason}")
|
| 241 |
-
# Still continue but mark as suspect
|
| 242 |
-
|
| 243 |
-
# 3D yaw check for frame rejection only
|
| 244 |
-
YAW_REJECT_DEG = 45.0
|
| 245 |
-
yaw_ok = True
|
| 246 |
-
yaw = None # Initialize yaw
|
| 247 |
-
if world_landmarks and impact_f in world_landmarks:
|
| 248 |
-
yaw = _estimate_torso_yaw_3d(world_landmarks[impact_f])
|
| 249 |
-
if yaw is not None and yaw > YAW_REJECT_DEG:
|
| 250 |
-
yaw_ok = False
|
| 251 |
-
print(f"DEBUG shoulder_tilt: high_yaw - torso_yaw={yaw:.1f}° > {YAW_REJECT_DEG:.1f}° (may use 3D sign)")
|
| 252 |
-
|
| 253 |
-
# ---------- 2D image-plane calculation (primary method) ----------
|
| 254 |
-
# Get shoulder positions with unit detection
|
| 255 |
-
xL, yL = lm[L_SHO][0], lm[L_SHO][1]
|
| 256 |
-
xR, yR = lm[R_SHO][0], lm[R_SHO][1]
|
| 257 |
-
|
| 258 |
-
dx_raw = xR - xL
|
| 259 |
-
dy_raw = yR - yL
|
| 260 |
-
|
| 261 |
-
dx, dy = _scale_xy(dx_raw, dy_raw, H, W)
|
| 262 |
-
|
| 263 |
-
# Camera roll: use setup, not impact (avoid subtracting real tilt)
|
| 264 |
-
setup_for_roll = swing_phases.get("setup", [])[:8]
|
| 265 |
-
rolls = []
|
| 266 |
-
for f in setup_for_roll:
|
| 267 |
-
lmf = pose_data.get(f)
|
| 268 |
-
if lmf:
|
| 269 |
-
rolls.append(_consensus_roll_deg(lmf, H, W))
|
| 270 |
-
roll = float(np.median(rolls)) if rolls else 0.0
|
| 271 |
-
roll = float(np.clip(roll, -10.0, 10.0)) # Loosen roll clamp for better gravity alignment
|
| 272 |
-
|
| 273 |
-
# Simplified shoulder tilt calculation with setup reference
|
| 274 |
-
sho_dx_r, sho_dy_r = _width_px_derolled(lm, L_SHO, R_SHO, roll)
|
| 275 |
-
|
| 276 |
-
# Build setup shoulder width reference (derolled)
|
| 277 |
setup_frames = swing_phases.get("setup", [])[:8]
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
lmf = pose_data.get(f)
|
| 281 |
-
if lmf and lmf[L_SHO] and lmf[R_SHO] and lmf[L_SHO][2] >= 0.6 and lmf[R_SHO][2] >= 0.6:
|
| 282 |
-
sx, _ = _width_px_derolled(lmf, L_SHO, R_SHO, roll)
|
| 283 |
-
if sx > 1.0:
|
| 284 |
-
sho_widths.append(sx)
|
| 285 |
|
| 286 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 287 |
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
#
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
dy3g = -sr*dx3 + cr*dy3
|
| 313 |
-
dz3g = dz3
|
| 314 |
-
|
| 315 |
-
# 2) Build target axis from toes (XZ). Fallback to shoulder ground proj if toes missing.
|
| 316 |
-
t_hat = None
|
| 317 |
-
setup_frames_for_axis = swing_phases.get("setup", [])[:8]
|
| 318 |
-
try:
|
| 319 |
-
th = _target_hat_from_toes_world(world_landmarks, setup_frames_for_axis, handedness)
|
| 320 |
-
if th: # (x,z) on ground
|
| 321 |
-
# toe_hat on ground (x,z); forward (target) is 90° CCW: (-z, x)
|
| 322 |
-
t_hat = np.array([-th[1], 0.0, th[0]], float) # ✅ use PERP to toe line
|
| 323 |
-
except Exception:
|
| 324 |
-
t_hat = None
|
| 325 |
-
if t_hat is None:
|
| 326 |
-
# Use shoulder ground vector as lateral guess, then take its perpendicular as forward
|
| 327 |
-
norm = math.hypot(dx3g, dz3g)
|
| 328 |
-
lat_guess = np.array([dx3g/(norm+1e-9), 0.0, dz3g/(norm+1e-9)], float)
|
| 329 |
-
t_hat = np.array([-lat_guess[2], 0.0, lat_guess[0]], float) # ✅ forward = perp to shoulder line
|
| 330 |
-
|
| 331 |
-
t_hat /= (np.linalg.norm(t_hat) + 1e-9) # target (forward) on ground
|
| 332 |
-
|
| 333 |
-
# Helper for micro-refinement
|
| 334 |
-
def _rotate_ground_y(vec, deg):
|
| 335 |
-
"""Rotate a ground-plane vector around 'up' (Y) by deg"""
|
| 336 |
-
c = math.cos(math.radians(deg)); s = math.sin(math.radians(deg))
|
| 337 |
-
x, y, z = vec
|
| 338 |
-
return np.array([c*x - s*z, 0.0, s*x + c*z], float)
|
| 339 |
-
|
| 340 |
-
# --- Micro-refine target axis to counter toe/foot-flare bias (±12°) ---
|
| 341 |
-
def _tilt_for_axis(t_axis):
|
| 342 |
-
t_axis = t_axis / (np.linalg.norm(t_axis) + 1e-9)
|
| 343 |
-
up_hat = np.array([0.0, 1.0, 0.0], float)
|
| 344 |
-
lat_hat = np.cross(up_hat, t_axis); lat_hat /= (np.linalg.norm(lat_hat) + 1e-9)
|
| 345 |
-
s = np.array([dx3g, dy3g, dz3g], float)
|
| 346 |
-
s_proj = s - np.dot(s, t_axis) * t_axis
|
| 347 |
-
v = float(np.dot(s_proj, up_hat))
|
| 348 |
-
l = float(np.dot(s_proj, lat_hat))
|
| 349 |
-
ang = math.degrees(math.atan2(v, abs(l) + 1e-6))
|
| 350 |
-
return ang, v, l, s_proj
|
| 351 |
-
|
| 352 |
-
best = None
|
| 353 |
-
for delta in range(-12, 13, 2): # -12,-10,...,10,12
|
| 354 |
-
ang, v, l, sproj = _tilt_for_axis(_rotate_ground_y(t_hat, delta))
|
| 355 |
-
if (best is None) or (abs(ang) > abs(best[0])): # maximize magnitude
|
| 356 |
-
best = (ang, delta, v, l, sproj)
|
| 357 |
-
|
| 358 |
-
tilt3d_cor, delta_opt, v_comp, l_comp, s_proj = best
|
| 359 |
-
# Keep sign convention: + = trail (right) shoulder lower
|
| 360 |
-
if handedness == 'right':
|
| 361 |
-
tilt3d_cor *= -1
|
| 362 |
-
# (left-handed golfers keep the original sign)
|
| 363 |
-
|
| 364 |
-
print(f"DEBUG 3D_refine: delta_opt={delta_opt}°, v_comp={v_comp:.3f}, l_comp={l_comp:.3f}, tilt3d_cor={tilt3d_cor:.1f}°")
|
| 365 |
-
path3d = "3D_coronal"
|
| 366 |
-
|
| 367 |
-
# Enhanced debug for 3D calculation
|
| 368 |
-
print(f"DEBUG 3D_detailed: raw_shoulder=({dx3:.3f},{dy3:.3f},{dz3:.3f}), "
|
| 369 |
-
f"derolled=({dx3g:.3f},{dy3g:.3f},{dz3g:.3f}), t_hat=({t_hat[0]:.3f},{t_hat[1]:.3f},{t_hat[2]:.3f})")
|
| 370 |
-
print(f"DEBUG 3D_detailed: s_proj=({s_proj[0]:.3f},{s_proj[1]:.3f},{s_proj[2]:.3f}), "
|
| 371 |
-
f"v_comp={v_comp:.3f}, l_comp={l_comp:.3f}, tilt3d_cor={tilt3d_cor:.1f}°")
|
| 372 |
else:
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
neigh = []
|
| 385 |
-
for f in [impact_f-1, impact_f, impact_f+1]:
|
| 386 |
-
if f in pose_data and pose_data[f] and pose_data[f][L_SHO] and pose_data[f][R_SHO]:
|
| 387 |
-
sx, sy = _width_px_derolled(pose_data[f], L_SHO, R_SHO, roll)
|
| 388 |
-
tm = math.degrees(math.atan2(abs(sy), max(sx, 1e-3)))
|
| 389 |
-
s = 1 if sy > 0 else -1
|
| 390 |
-
if handedness == 'left':
|
| 391 |
-
s *= -1
|
| 392 |
-
neigh.append(s * tm)
|
| 393 |
-
if neigh:
|
| 394 |
-
tilt_final = float(np.median(neigh))
|
| 395 |
-
|
| 396 |
-
print(f"DEBUG shoulder_tilt: dx_ref={dx_ref:.1f}, sho_dx={sho_dx_r:.1f}, ratio={sho_dx_r/dx_ref:.2f}, "
|
| 397 |
-
f"yaw={(f'{yaw:.1f}°' if yaw is not None else 'None')}, path={path}, tilt={tilt_final:.1f}°")
|
| 398 |
-
print(f"DEBUG shoulder_tilt EXPLAIN: roll_setup={roll:.1f}°, "
|
| 399 |
-
f"3D_coronal=({('%.3f'%v_comp) if tilt3d_cor is not None else 'NA'}v, {('%.3f'%l_comp) if tilt3d_cor is not None else 'NA'}l), "
|
| 400 |
-
f"raw2D: sho_dx={sho_dx_r:.1f}, sho_dy={sho_dy_r:.1f}, tilt2D={tilt2d:.1f}°")
|
| 401 |
-
|
| 402 |
-
return max(-80.0, min(80.0, tilt_final))
|
| 403 |
|
| 404 |
def pelvis_hat(world_frame):
|
| 405 |
"""Get unit pelvis vector (R_hip - L_hip) in XZ plane"""
|
|
@@ -485,31 +482,6 @@ def _target_hat_from_toes_world(world_landmarks, setup_frames, handedness='right
|
|
| 485 |
return target_hat
|
| 486 |
|
| 487 |
|
| 488 |
-
def _pick_quiet_address(sm, swing_phases, warp2, max_frames=6, speed_thr_in=0.20):
|
| 489 |
-
"""Pick early address frames with strict stillness criteria"""
|
| 490 |
-
cand = swing_phases.get("setup", [])[:16]
|
| 491 |
-
if not cand: return []
|
| 492 |
-
# pelvis centers in ground
|
| 493 |
-
ctrs = []
|
| 494 |
-
for f in cand:
|
| 495 |
-
lm = sm.get(f)
|
| 496 |
-
if not lm or not lm[23] or not lm[24]:
|
| 497 |
-
ctrs.append(None)
|
| 498 |
-
continue
|
| 499 |
-
g = warp2([[lm[23][0], lm[23][1]], [lm[24][0], lm[24][1]]])
|
| 500 |
-
ctrs.append(((g[0]+g[1])/2).astype(float))
|
| 501 |
-
keep = []
|
| 502 |
-
prev = None
|
| 503 |
-
for f,c in zip(cand, ctrs):
|
| 504 |
-
if c is None: break
|
| 505 |
-
spd = 0.0 if prev is None else float(np.linalg.norm(c - prev))
|
| 506 |
-
if spd <= speed_thr_in:
|
| 507 |
-
keep.append(f)
|
| 508 |
-
if len(keep) >= max_frames: break
|
| 509 |
-
else:
|
| 510 |
-
break
|
| 511 |
-
prev = c
|
| 512 |
-
return keep or cand[:max_frames]
|
| 513 |
|
| 514 |
def _pick_stable_setup_frames(sm, swing_phases, max_frames=6):
|
| 515 |
"""Pick first address frames with low pelvis motion (reduces waggle bias)."""
|
|
@@ -539,38 +511,12 @@ def _pick_stable_setup_frames(sm, swing_phases, max_frames=6):
|
|
| 539 |
break
|
| 540 |
return keep
|
| 541 |
|
| 542 |
-
def _pick_still_address_by_hand(sm, swing_phases, handed='right', max_frames=6):
|
| 543 |
-
cand = swing_phases.get("setup", [])[:16]
|
| 544 |
-
if not cand: return []
|
| 545 |
-
# choose lead wrist landmark
|
| 546 |
-
WR = 15 if handed=='right' else 16
|
| 547 |
-
pts = []
|
| 548 |
-
for f in cand:
|
| 549 |
-
lm = sm.get(f)
|
| 550 |
-
if lm and lm[WR] and lm[WR][2] >= 0.5:
|
| 551 |
-
pts.append((f, np.array([lm[WR][0], lm[WR][1]], float)))
|
| 552 |
-
else:
|
| 553 |
-
break
|
| 554 |
-
if len(pts) < 2:
|
| 555 |
-
return [f for f,_ in pts][:max_frames]
|
| 556 |
-
|
| 557 |
-
speeds = [0.0]
|
| 558 |
-
for i in range(1, len(pts)):
|
| 559 |
-
speeds.append(float(np.linalg.norm(pts[i][1] - pts[i-1][1])))
|
| 560 |
-
# tight threshold to capture true stillness before takeaway/waggle
|
| 561 |
-
thr = max(0.4, np.median(speeds) * 2.0)
|
| 562 |
-
keep = [pts[0][0]]
|
| 563 |
-
for (f,_), s in zip(pts[1:], speeds[1:]):
|
| 564 |
-
if s <= thr and len(keep) < max_frames:
|
| 565 |
-
keep.append(f)
|
| 566 |
-
else:
|
| 567 |
-
break
|
| 568 |
-
return keep
|
| 569 |
|
| 570 |
def _build_foot_only_homography(sm, setup_frames):
|
| 571 |
"""
|
| 572 |
Build ground-plane H from 4 foot points only (no hips).
|
| 573 |
-
|
|
|
|
| 574 |
Ground pts are a rectangle in arbitrary inches; scale will be calibrated later.
|
| 575 |
"""
|
| 576 |
import cv2
|
|
@@ -580,14 +526,15 @@ def _build_foot_only_homography(sm, setup_frames):
|
|
| 580 |
needed = [29,30,31,32]
|
| 581 |
if any((i>=len(lm) or lm[i] is None or lm[i][2] < 0.5) for i in needed):
|
| 582 |
continue
|
|
|
|
| 583 |
img = np.array([
|
| 584 |
-
[lm[
|
| 585 |
-
[lm[
|
| 586 |
-
[lm[
|
| 587 |
-
[lm[
|
| 588 |
], dtype=np.float32)
|
| 589 |
-
|
| 590 |
-
#
|
| 591 |
ground = np.array([
|
| 592 |
[-8.0, 0.0], # L heel
|
| 593 |
[ 8.0, 0.0], # R heel
|
|
@@ -658,6 +605,7 @@ def _simple_pixel_sway_fallback(sm, setup_frames, top_frame, swing_phases=None,
|
|
| 658 |
addr_ctr = np.median(np.stack(addr_ctrs), axis=0)
|
| 659 |
fwd_vec_px = imp_ctr - addr_ctr
|
| 660 |
if float(np.dot(fwd_vec_px, target_hat_px)) < 0.0:
|
|
|
|
| 661 |
target_hat_px = -target_hat_px
|
| 662 |
|
| 663 |
delta_px = top_ctr - setup_ctr
|
|
@@ -668,7 +616,7 @@ def _simple_pixel_sway_fallback(sm, setup_frames, top_frame, swing_phases=None,
|
|
| 668 |
if world_landmarks is not None and f0 in world_landmarks:
|
| 669 |
try:
|
| 670 |
wf = world_landmarks[f0]
|
| 671 |
-
L_FOOT_INDEX, R_FOOT_INDEX =
|
| 672 |
Lt, Rt = wf[L_FOOT_INDEX], wf[R_FOOT_INDEX]
|
| 673 |
if Lt and Rt:
|
| 674 |
# 3D toe spacing in meters → inches
|
|
@@ -678,12 +626,12 @@ def _simple_pixel_sway_fallback(sm, setup_frames, top_frame, swing_phases=None,
|
|
| 678 |
d_px = float(np.linalg.norm(toe_v))
|
| 679 |
if d_px > 1e-3 and d_in_true > 1e-3:
|
| 680 |
px_per_inch = d_px / d_in_true
|
| 681 |
-
|
| 682 |
except Exception:
|
| 683 |
pass
|
| 684 |
|
| 685 |
sway_inches = sway_px / px_per_inch
|
| 686 |
-
|
| 687 |
|
| 688 |
return sway_inches
|
| 689 |
|
|
@@ -754,13 +702,13 @@ def _subframe_impact_refinement(world_landmarks: Dict[int, List], impact_candida
|
|
| 754 |
if abs(impact_fractional - impact_candidate) > 2.0:
|
| 755 |
impact_fractional = float(impact_candidate)
|
| 756 |
|
| 757 |
-
|
| 758 |
-
|
| 759 |
|
| 760 |
return impact_fractional
|
| 761 |
|
| 762 |
except Exception as e:
|
| 763 |
-
|
| 764 |
return float(impact_candidate)
|
| 765 |
|
| 766 |
|
|
@@ -818,27 +766,6 @@ def _impact_frame(world_landmarks: Dict[int, List], swing_phases: Dict[str, List
|
|
| 818 |
if max_speed_idx > 0:
|
| 819 |
impact_candidate = valid_frames[max_speed_idx - 1]
|
| 820 |
|
| 821 |
-
# Optional quadratic refinement
|
| 822 |
-
try:
|
| 823 |
-
test_frames = [f for f in range(impact_candidate-1, impact_candidate+2)
|
| 824 |
-
if f in world_landmarks]
|
| 825 |
-
if len(test_frames) >= 3:
|
| 826 |
-
speeds = []
|
| 827 |
-
for f in test_frames:
|
| 828 |
-
if f in valid_frames:
|
| 829 |
-
idx = valid_frames.index(f)
|
| 830 |
-
speeds.append(hand_speeds[idx])
|
| 831 |
-
|
| 832 |
-
if len(speeds) >= 3:
|
| 833 |
-
coeffs = np.polyfit(test_frames, speeds, 2)
|
| 834 |
-
a, b, c = coeffs
|
| 835 |
-
if a < 0: # Parabola opens downward
|
| 836 |
-
peak_fractional = -b / (2*a)
|
| 837 |
-
impact_refined = peak_fractional - 1.0
|
| 838 |
-
if abs(impact_refined - impact_candidate) <= 2.0:
|
| 839 |
-
impact_candidate = int(round(impact_refined))
|
| 840 |
-
except:
|
| 841 |
-
pass
|
| 842 |
|
| 843 |
# Clamp to ±2 frames around original estimate
|
| 844 |
original = valid_frames[max_speed_idx - 1] if max_speed_idx > 0 else peak_speed_frame
|
|
@@ -851,188 +778,121 @@ def _impact_frame(world_landmarks: Dict[int, List], swing_phases: Dict[str, List
|
|
| 851 |
|
| 852 |
|
| 853 |
|
| 854 |
-
def calculate_hip_turn_at_impact(
|
| 855 |
-
|
| 856 |
-
|
| 857 |
-
|
| 858 |
-
|
| 859 |
-
|
| 860 |
-
|
| 861 |
-
|
| 862 |
-
|
| 863 |
-
|
| 864 |
-
|
| 865 |
-
|
| 866 |
-
|
| 867 |
-
|
| 868 |
-
|
| 869 |
-
|
| 870 |
-
return
|
| 871 |
-
|
| 872 |
-
|
| 873 |
-
|
| 874 |
-
|
| 875 |
-
|
| 876 |
-
|
| 877 |
-
|
| 878 |
-
|
| 879 |
-
|
| 880 |
-
|
| 881 |
-
|
| 882 |
-
|
| 883 |
-
|
| 884 |
-
|
| 885 |
-
|
| 886 |
-
|
| 887 |
-
|
| 888 |
-
|
| 889 |
-
|
| 890 |
-
|
| 891 |
-
|
| 892 |
-
|
| 893 |
-
|
| 894 |
-
|
| 895 |
-
|
| 896 |
-
|
| 897 |
-
|
| 898 |
-
fallback_result = _calculate_hip_turn_2d_fallback(pose_data, swing_phases, _impact_frame(pose_data, swing_phases))
|
| 899 |
-
return {
|
| 900 |
-
"abs_deg": fallback_result,
|
| 901 |
-
"delta_deg": None,
|
| 902 |
-
"addr_deg": None,
|
| 903 |
-
} if fallback_result is not None else None
|
| 904 |
-
|
| 905 |
-
# --- Sign disambiguation for target_hat using pelvis center (XZ) addr->impact ---
|
| 906 |
-
def _pelvis_ctr_xz(frame_idx):
|
| 907 |
-
wf = world_landmarks.get(frame_idx)
|
| 908 |
-
if not wf or not wf[L_HIP] or not wf[R_HIP]:
|
| 909 |
return None
|
| 910 |
-
|
| 911 |
-
cz = 0.5 * (wf[L_HIP][2] + wf[R_HIP][2])
|
| 912 |
-
return np.array([cx, cz], float)
|
| 913 |
|
| 914 |
-
addr_ctrs = [
|
| 915 |
addr_ctrs = [c for c in addr_ctrs if c is not None]
|
| 916 |
-
imp_ctr =
|
| 917 |
-
|
| 918 |
if addr_ctrs and imp_ctr is not None:
|
| 919 |
-
addr_ctr = np.median(np.
|
| 920 |
-
|
| 921 |
-
if
|
| 922 |
-
|
| 923 |
-
|
| 924 |
-
|
| 925 |
-
|
| 926 |
-
|
| 927 |
-
|
| 928 |
-
|
| 929 |
-
|
| 930 |
-
|
| 931 |
-
|
| 932 |
-
|
| 933 |
-
|
| 934 |
-
|
| 935 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 936 |
|
| 937 |
-
#
|
| 938 |
-
|
| 939 |
-
if
|
| 940 |
-
impact_rels = []
|
| 941 |
-
for f in impact_frames_nearby:
|
| 942 |
-
ph = pelvis_hat(world_landmarks[f])
|
| 943 |
-
if ph is not None:
|
| 944 |
-
impact_rels.append(signed_angle2(ph, target_hat))
|
| 945 |
-
if impact_rels:
|
| 946 |
-
impact_rel = np.median(impact_rels)
|
| 947 |
-
else:
|
| 948 |
-
impact_rel = signed_angle2(impact_pelvis_hat, target_hat)
|
| 949 |
-
else:
|
| 950 |
-
impact_rel = signed_angle2(impact_pelvis_hat, target_hat)
|
| 951 |
|
| 952 |
-
|
| 953 |
-
|
|
|
|
| 954 |
|
| 955 |
-
|
| 956 |
-
addr_rel = _address_pelvis_rel(world_landmarks, setup_frames, target_hat)
|
| 957 |
-
hip_turn_delta = None if addr_rel is None else wrap180(hip_turn_abs - addr_rel)
|
| 958 |
-
|
| 959 |
-
# Use 3D calculation as primary method
|
| 960 |
-
final_turn = hip_turn_abs
|
| 961 |
-
|
| 962 |
-
# Prepare both absolute and delta values
|
| 963 |
-
final_abs = float(final_turn)
|
| 964 |
-
final_delta = None if addr_rel is None else wrap180(final_turn - addr_rel)
|
| 965 |
-
|
| 966 |
-
# Plausibility clamp for delta (pros ~30–45 by club); still always return something
|
| 967 |
-
if final_delta is not None and abs(final_delta) > PLAUSIBLE:
|
| 968 |
-
final_delta = PLAUSIBLE if final_delta > 0 else -PLAUSIBLE
|
| 969 |
-
|
| 970 |
-
# Final plausible check for absolute
|
| 971 |
-
if abs(final_abs) > PLAUSIBLE:
|
| 972 |
-
dbg(f"hip_turn - VALUE {final_abs:.1f}° EXCEEDS PLAUSIBLE RANGE, clamping")
|
| 973 |
-
# Clamp to reasonable range rather than returning None
|
| 974 |
-
final_abs = PLAUSIBLE if final_abs > 0 else -PLAUSIBLE
|
| 975 |
-
|
| 976 |
-
# Return both values and metadata for UI
|
| 977 |
-
return {
|
| 978 |
-
"abs_deg": final_abs,
|
| 979 |
-
"delta_deg": final_delta,
|
| 980 |
-
"addr_deg": addr_rel
|
| 981 |
-
}
|
| 982 |
|
| 983 |
|
| 984 |
-
def _build_ground_homography(sm, setup_frames):
|
| 985 |
-
"""Build ground-plane homography using RANSAC"""
|
| 986 |
-
try:
|
| 987 |
-
import cv2
|
| 988 |
-
|
| 989 |
-
# Find best setup frame with all required landmarks
|
| 990 |
-
best_frame = None
|
| 991 |
-
for f in setup_frames:
|
| 992 |
-
lm = sm.get(f)
|
| 993 |
-
if lm and len(lm) > 31:
|
| 994 |
-
# Check we have feet landmarks + hips for ground plane
|
| 995 |
-
if (lm[27] and lm[28] and lm[31] and lm[32] and lm[23] and lm[24] and
|
| 996 |
-
all(lm[i][2] > 0.5 for i in [27,28,31,32,23,24])):
|
| 997 |
-
best_frame = f
|
| 998 |
-
break
|
| 999 |
-
|
| 1000 |
-
if best_frame is None:
|
| 1001 |
-
return None, False, "no_landmarks"
|
| 1002 |
-
|
| 1003 |
-
lm = sm[best_frame]
|
| 1004 |
-
|
| 1005 |
-
# 4 ground points: heel tips + ball center + back marker
|
| 1006 |
-
img_pts = np.array([
|
| 1007 |
-
[lm[31][0], lm[31][1]], # L heel tip
|
| 1008 |
-
[lm[32][0], lm[32][1]], # R heel tip
|
| 1009 |
-
[(lm[31][0] + lm[32][0]) / 2, min(lm[31][1], lm[32][1]) + 40], # Ball center (estimated)
|
| 1010 |
-
[(lm[23][0] + lm[24][0]) / 2, (lm[23][1] + lm[24][1]) / 2 + 20] # Back marker (hip depth)
|
| 1011 |
-
], dtype=np.float32)
|
| 1012 |
-
|
| 1013 |
-
# Ground coordinates (inches)
|
| 1014 |
-
ground_pts = np.array([
|
| 1015 |
-
[-10, 0], [10, 0], [0, 30], [0, -8]
|
| 1016 |
-
], dtype=np.float32)
|
| 1017 |
-
|
| 1018 |
-
# RANSAC homography fitting
|
| 1019 |
-
H, mask = cv2.findHomography(img_pts, ground_pts, cv2.RANSAC,
|
| 1020 |
-
ransacReprojThreshold=2.0)
|
| 1021 |
-
|
| 1022 |
-
if H is None:
|
| 1023 |
-
return None, False, "ransac_failed"
|
| 1024 |
-
|
| 1025 |
-
|
| 1026 |
-
# Validation criteria - only check for non-singular matrix
|
| 1027 |
-
det_H = abs(np.linalg.det(H[:2,:2]))
|
| 1028 |
-
valid_H = det_H > 1e-4
|
| 1029 |
-
|
| 1030 |
-
reason = "ok" if valid_H else "singular"
|
| 1031 |
-
|
| 1032 |
-
return H, valid_H, reason
|
| 1033 |
-
|
| 1034 |
-
except Exception as e:
|
| 1035 |
-
return None, False, "exception"
|
| 1036 |
|
| 1037 |
def _calculate_hip_turn_2d_fallback(pose_data: Dict[int, List], swing_phases: Dict[str, List], impact_frame: int) -> Optional[float]:
|
| 1038 |
"""
|
|
@@ -1059,9 +919,9 @@ def _calculate_hip_turn_2d_fallback(pose_data: Dict[int, List], swing_phases: Di
|
|
| 1059 |
return None
|
| 1060 |
|
| 1061 |
# Build ground-plane homography
|
| 1062 |
-
H, valid_H,
|
| 1063 |
if not valid_H:
|
| 1064 |
-
|
| 1065 |
return None
|
| 1066 |
|
| 1067 |
try:
|
|
@@ -1125,6 +985,7 @@ def _calculate_hip_turn_2d_fallback(pose_data: Dict[int, List], swing_phases: Di
|
|
| 1125 |
fwd_vec = impact_ctr - addr_ctr
|
| 1126 |
# If forward (address->impact) projects negative, flip target axis
|
| 1127 |
if float(np.dot(fwd_vec, target_hat_2d)) < 0.0:
|
|
|
|
| 1128 |
target_hat_2d = -target_hat_2d
|
| 1129 |
|
| 1130 |
# Get pelvis directions in ground plane
|
|
@@ -1157,284 +1018,138 @@ def _calculate_hip_turn_2d_fallback(pose_data: Dict[int, List], swing_phases: Di
|
|
| 1157 |
def calculate_hip_sway_at_top(pose_data: Dict[int, List], swing_phases: Dict[str, List],
|
| 1158 |
world_landmarks: Optional[Dict[int, List]] = None) -> Union[float, None]:
|
| 1159 |
sm = smooth_landmarks(pose_data)
|
| 1160 |
-
|
| 1161 |
-
|
| 1162 |
-
initial_setup_frames = _pick_stable_setup_frames(sm, swing_phases, max_frames=6)
|
| 1163 |
-
if not initial_setup_frames: return None
|
| 1164 |
backswing_frames = swing_phases.get("backswing", [])
|
| 1165 |
if not backswing_frames: return None
|
| 1166 |
-
top_frame =
|
| 1167 |
|
| 1168 |
-
|
| 1169 |
-
|
| 1170 |
-
|
| 1171 |
-
# (2.5) improved processing if homography is valid
|
| 1172 |
-
if valid_H and H is not None:
|
| 1173 |
-
# Will define warp2 later after cv2 import
|
| 1174 |
-
# Improved address selection with stillness criteria
|
| 1175 |
-
setup_frames = initial_setup_frames # Use initial frames for now, will refine after warp2 is defined
|
| 1176 |
-
else:
|
| 1177 |
-
setup_frames = initial_setup_frames
|
| 1178 |
-
if not valid_H:
|
| 1179 |
-
# fallback improved: compute px→inch using world toes if available
|
| 1180 |
return _simple_pixel_sway_fallback(sm, setup_frames, top_frame, swing_phases, world_landmarks)
|
| 1181 |
|
| 1182 |
import cv2
|
| 1183 |
-
|
| 1184 |
def warp2(pts):
|
| 1185 |
A = np.array(pts, dtype=np.float32).reshape(1, -1, 2)
|
| 1186 |
return cv2.perspectiveTransform(A, H)[0]
|
| 1187 |
|
| 1188 |
-
#
|
| 1189 |
toe_vecs = []
|
| 1190 |
for f in setup_frames:
|
| 1191 |
lm = sm.get(f)
|
| 1192 |
if not lm or not lm[31] or not lm[32]: continue
|
| 1193 |
-
|
| 1194 |
-
|
| 1195 |
-
v = toe_g[1] - toe_g[0]
|
| 1196 |
n = np.linalg.norm(v)
|
| 1197 |
-
if n > 1e-6:
|
| 1198 |
-
|
| 1199 |
-
|
| 1200 |
-
|
| 1201 |
-
|
| 1202 |
-
|
| 1203 |
-
|
| 1204 |
-
|
| 1205 |
-
|
| 1206 |
-
|
| 1207 |
-
|
| 1208 |
-
print(f"DEBUG sway2: toe_hat_g={toe}, target_hat_g={tgt}, dot≈{float(np.dot(toe, tgt)):.3f}")
|
| 1209 |
-
|
| 1210 |
-
# (4) sign disambiguation using address→impact pelvis motion (keep)
|
| 1211 |
-
candidate_fwd = None
|
| 1212 |
-
if swing_phases.get("impact"): candidate_fwd = swing_phases["impact"][0]
|
| 1213 |
-
elif swing_phases.get("downswing"): candidate_fwd = swing_phases["downswing"][-1]
|
| 1214 |
-
else:
|
| 1215 |
-
allf = sorted(sm.keys()); candidate_fwd = allf[-1] if allf else None
|
| 1216 |
-
|
| 1217 |
-
def pelvis_center_ground(f):
|
| 1218 |
-
lm = sm.get(f)
|
| 1219 |
if not lm or not lm[23] or not lm[24]: return None
|
| 1220 |
-
g = warp2([[lm[23][0], lm[23][1]], [lm[24][0], lm[24][1]]])
|
| 1221 |
-
return (g[0]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1222 |
|
| 1223 |
-
|
| 1224 |
-
|
| 1225 |
-
addr_ctrs = [c for c in addr_ctrs if c is not None]
|
| 1226 |
-
imp_ctr = pelvis_center_ground(candidate_fwd)
|
| 1227 |
-
if addr_ctrs and imp_ctr is not None:
|
| 1228 |
-
addr_ctr = np.median(np.stack(addr_ctrs), axis=0)
|
| 1229 |
-
fwd_vec = imp_ctr - addr_ctr
|
| 1230 |
-
if float(np.dot(fwd_vec, tgt)) < 0.0:
|
| 1231 |
-
tgt = -tgt
|
| 1232 |
-
|
| 1233 |
-
# (5) centers at address (median) and at top
|
| 1234 |
-
setup_centers = [pelvis_center_ground(f) for f in setup_frames]
|
| 1235 |
-
setup_centers = [c for c in setup_centers if c is not None]
|
| 1236 |
-
if not setup_centers: return None
|
| 1237 |
-
setup_ctr = np.median(np.stack(setup_centers), axis=0)
|
| 1238 |
-
top_ctr = pelvis_center_ground(top_frame)
|
| 1239 |
if top_ctr is None: return None
|
| 1240 |
|
| 1241 |
-
#
|
| 1242 |
inch_scale = 1.0
|
| 1243 |
-
if world_landmarks
|
| 1244 |
-
wf = world_landmarks[H_frame]
|
| 1245 |
-
# 3D toe XZ spacing (meters) → inches
|
| 1246 |
try:
|
| 1247 |
-
|
| 1248 |
-
|
| 1249 |
-
|
| 1250 |
-
|
| 1251 |
-
|
| 1252 |
-
|
| 1253 |
-
|
| 1254 |
-
|
| 1255 |
-
toe_g = warp2(toe_img)
|
| 1256 |
-
d_in_curr = float(np.linalg.norm(toe_g[1]-toe_g[0]))
|
| 1257 |
-
if d_in_curr > 1e-3 and d_in_true > 1e-3:
|
| 1258 |
-
inch_scale = d_in_true / d_in_curr
|
| 1259 |
-
except Exception:
|
| 1260 |
-
pass
|
| 1261 |
-
|
| 1262 |
-
# Compute projections using pre-normalized axes
|
| 1263 |
-
d = top_ctr - setup_ctr # delta in ground plane units
|
| 1264 |
-
|
| 1265 |
-
# Project onto normalized axes
|
| 1266 |
-
lat_px = float(np.dot(d, toe)) # lateral component (ground units)
|
| 1267 |
-
fwd_px = float(np.dot(d, tgt)) # forward component (ground units)
|
| 1268 |
-
|
| 1269 |
-
# Apply inch scale
|
| 1270 |
-
lat_in = lat_px * inch_scale
|
| 1271 |
-
fwd_in = fwd_px * inch_scale
|
| 1272 |
-
|
| 1273 |
-
print(f"DEBUG sway_projection: lat_px={lat_px:.3f}, fwd_px={fwd_px:.3f}, inch_scale={inch_scale:.3f}, "
|
| 1274 |
-
f"lat_in={lat_in:.2f}, fwd_in={fwd_in:.2f}")
|
| 1275 |
-
|
| 1276 |
-
lead_sway_inches = None
|
| 1277 |
-
lead_sway = _lead_hip_sway(sm, setup_frames, top_frame, warp2, toe)
|
| 1278 |
-
if lead_sway is not None:
|
| 1279 |
-
lead_sway_inches = lead_sway * inch_scale
|
| 1280 |
-
print(f"DEBUG sway_leadhip: lat_in={lead_sway_inches:.2f} (lead hip, lateral axis)")
|
| 1281 |
-
|
| 1282 |
-
# Also compute lead hip sway on forward axis to match the definition used for grading
|
| 1283 |
-
lead_sway_forward = _lead_hip_sway_forward(sm, setup_frames, top_frame, warp2, tgt)
|
| 1284 |
-
if lead_sway_forward is not None:
|
| 1285 |
-
lead_sway_forward_inches = lead_sway_forward * inch_scale
|
| 1286 |
-
print(f"DEBUG sway_leadhip: fwd_in={lead_sway_forward_inches:.2f} (lead hip, forward axis)")
|
| 1287 |
-
|
| 1288 |
-
if abs(lat_in) > 6.0 or abs(fwd_in) > 6.0:
|
| 1289 |
-
print(f"WARNING sway_bounds: lat|fwd too large; check axes/scale.")
|
| 1290 |
|
| 1291 |
-
|
| 1292 |
-
#
|
| 1293 |
-
|
| 1294 |
-
print(f"DEBUG sway_final: method=pelvis_center, sway_in={fwd_in:.2f} (forward/target axis)")
|
| 1295 |
|
| 1296 |
-
# sanity note only
|
| 1297 |
-
if abs(sway_inches) < 0.2:
|
| 1298 |
-
print(f"DEBUG sway2: tiny sway {sway_inches:.2f}\" — check camera or address selection")
|
| 1299 |
-
|
| 1300 |
-
return sway_inches
|
| 1301 |
|
| 1302 |
|
| 1303 |
|
| 1304 |
|
| 1305 |
-
def angle_between(u, v):
|
| 1306 |
-
"""Calculate angle between two vectors in degrees (0-180)"""
|
| 1307 |
-
u, v = np.array(u, float), np.array(v, float)
|
| 1308 |
-
nu, nv = np.linalg.norm(u), np.linalg.norm(v)
|
| 1309 |
-
if nu == 0 or nv == 0:
|
| 1310 |
-
return float('nan')
|
| 1311 |
-
c = np.clip(np.dot(u, v) / (nu * nv), -1, 1)
|
| 1312 |
-
return math.degrees(math.acos(c)) # 0..180
|
| 1313 |
|
| 1314 |
|
| 1315 |
|
| 1316 |
-
def _shaft_vec_proxy(lm, handed='right'):
|
| 1317 |
-
"""Approximate shaft/handle direction using the two wrists."""
|
| 1318 |
-
L_WR, R_WR = 15, 16
|
| 1319 |
-
if not lm or not lm[L_WR] or not lm[R_WR]:
|
| 1320 |
-
return None
|
| 1321 |
-
if lm[L_WR][2] < 0.5 or lm[R_WR][2] < 0.5:
|
| 1322 |
-
return None
|
| 1323 |
-
# For right-handed golfer, shaft points from lead (L) to trail (R)
|
| 1324 |
-
v = (lm[R_WR][0] - lm[L_WR][0], lm[R_WR][1] - lm[L_WR][1]) if handed=='right' \
|
| 1325 |
-
else (lm[L_WR][0] - lm[R_WR][0], lm[L_WR][1] - lm[R_WR][1])
|
| 1326 |
-
return np.array(v, float)
|
| 1327 |
-
|
| 1328 |
-
def _hand_centroid(lm, wrist, idx, thu):
|
| 1329 |
-
"""Get centroid of hand landmarks (wrist, index, thumb)"""
|
| 1330 |
-
pts = []
|
| 1331 |
-
for i in (wrist, idx, thu):
|
| 1332 |
-
if i < len(lm) and lm[i] and lm[i][2] >= 0.5:
|
| 1333 |
-
pts.append((lm[i][0], lm[i][1]))
|
| 1334 |
-
if len(pts) < 2:
|
| 1335 |
-
return None
|
| 1336 |
-
return np.array([np.median([p[0] for p in pts]),
|
| 1337 |
-
np.median([p[1] for p in pts])], float)
|
| 1338 |
|
| 1339 |
def _shaft_axis_hat(lm):
|
| 1340 |
-
"""
|
| 1341 |
-
|
| 1342 |
-
if not lm:
|
| 1343 |
-
return None
|
| 1344 |
-
|
| 1345 |
-
# try centroids (lower visibility threshold)
|
| 1346 |
-
def vis_ok(i):
|
| 1347 |
-
return (i < len(lm)) and lm[i] and (lm[i][2] >= 0.3)
|
| 1348 |
-
|
| 1349 |
L_WR, R_WR = 15, 16
|
| 1350 |
-
|
| 1351 |
-
idxsR = [16, 20, 22] # R wrist/index/thumb
|
| 1352 |
-
|
| 1353 |
-
def centroid(idxs):
|
| 1354 |
-
pts = [(lm[i][0], lm[i][1]) for i in idxs if vis_ok(i)]
|
| 1355 |
-
if len(pts) < 2:
|
| 1356 |
-
return None
|
| 1357 |
-
return np.array([np.median([p[0] for p in pts]),
|
| 1358 |
-
np.median([p[1] for p in pts])], float)
|
| 1359 |
-
|
| 1360 |
-
Lc, Rc = centroid(idxsL), centroid(idxsR)
|
| 1361 |
-
if Lc is not None and Rc is not None:
|
| 1362 |
-
v = Rc - Lc
|
| 1363 |
-
elif vis_ok(L_WR) and vis_ok(R_WR):
|
| 1364 |
-
# dependable fallback
|
| 1365 |
-
v = np.array([lm[R_WR][0] - lm[L_WR][0], lm[R_WR][1] - lm[L_WR][1]], float)
|
| 1366 |
-
else:
|
| 1367 |
return None
|
| 1368 |
-
|
| 1369 |
n = np.linalg.norm(v)
|
| 1370 |
return v / n if n > 1e-6 else None
|
| 1371 |
|
| 1372 |
-
def _hand_point(lm, handed='right'):
|
| 1373 |
-
# Mediapipe Pose: L index=19, L pinky=17, L thumb=21; R index=20, R pinky=18, R thumb=22
|
| 1374 |
-
if handed == 'right':
|
| 1375 |
-
IDX, THU, PNK = 19, 21, 17 # lead arm = left arm landmarks
|
| 1376 |
-
else:
|
| 1377 |
-
IDX, THU, PNK = 20, 22, 18 # lead arm = right arm landmarks
|
| 1378 |
-
|
| 1379 |
-
cand = []
|
| 1380 |
-
for i in (IDX, THU, PNK):
|
| 1381 |
-
if i < len(lm) and lm[i] and lm[i][2] >= 0.5:
|
| 1382 |
-
cand.append((lm[i][0], lm[i][1]))
|
| 1383 |
-
if not cand:
|
| 1384 |
-
return None
|
| 1385 |
-
# prefer index+thumb average if both exist
|
| 1386 |
-
has_idx = (len(lm) > IDX and lm[IDX] and lm[IDX][2] >= 0.5)
|
| 1387 |
-
has_thu = (len(lm) > THU and lm[THU] and lm[THU][2] >= 0.5)
|
| 1388 |
-
if has_idx and has_thu:
|
| 1389 |
-
return ((lm[IDX][0] + lm[THU][0]) * 0.5, (lm[IDX][1] + lm[THU][1]) * 0.5)
|
| 1390 |
-
# else median of whatever we have
|
| 1391 |
-
xs, ys = zip(*cand)
|
| 1392 |
-
return (float(np.median(xs)), float(np.median(ys)))
|
| 1393 |
|
| 1394 |
def _hinge2d_shaft(lm, handed='right', roll_deg=0.0):
|
| 1395 |
-
"""
|
|
|
|
| 1396 |
if not lm: return None
|
|
|
|
|
|
|
| 1397 |
EL, WR = (13, 15) if handed=='right' else (14, 16) # lead elbow/wrist
|
| 1398 |
if not lm[EL] or not lm[WR] or lm[EL][2] < 0.5 or lm[WR][2] < 0.5:
|
| 1399 |
return None
|
| 1400 |
|
| 1401 |
-
#
|
| 1402 |
-
|
| 1403 |
|
| 1404 |
-
# Better shaft proxy
|
| 1405 |
-
|
| 1406 |
-
if
|
| 1407 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1408 |
|
| 1409 |
# De-roll both vectors by -roll_deg
|
| 1410 |
cr, sr = math.cos(math.radians(roll_deg)), math.sin(math.radians(roll_deg))
|
| 1411 |
-
def
|
| 1412 |
-
|
| 1413 |
-
|
| 1414 |
-
|
| 1415 |
-
|
|
|
|
|
|
|
|
|
|
| 1416 |
return None
|
| 1417 |
|
| 1418 |
-
|
| 1419 |
-
|
| 1420 |
-
|
| 1421 |
-
|
| 1422 |
-
|
| 1423 |
-
|
| 1424 |
-
|
| 1425 |
-
|
| 1426 |
-
|
| 1427 |
-
|
| 1428 |
-
|
| 1429 |
-
nww = np.linalg.norm(ww)
|
| 1430 |
-
if nww > 1e-6:
|
| 1431 |
-
c2 = float(np.clip(np.dot(u, ww)/(nu*nww), -1, 1))
|
| 1432 |
-
theta2 = math.degrees(math.acos(c2))
|
| 1433 |
-
hinge2 = 180.0 - theta2
|
| 1434 |
-
if hinge2 < hinge: # take the more reasonable one
|
| 1435 |
-
hinge = hinge2
|
| 1436 |
-
|
| 1437 |
-
return max(20.0, min(160.0, hinge))
|
| 1438 |
|
| 1439 |
|
| 1440 |
def _pelvis_ctr_ground_series(sm, frames, warp2):
|
|
@@ -1475,31 +1190,7 @@ def _point_ground(frame_idx, sm, warp2, i):
|
|
| 1475 |
if not lm or not lm[i]: return None
|
| 1476 |
return warp2([[lm[i][0], lm[i][1]]])[0].astype(float)
|
| 1477 |
|
| 1478 |
-
|
| 1479 |
-
"""Lead-hip sway projected onto the *lateral* (toe) axis."""
|
| 1480 |
-
L_HIP = 23
|
| 1481 |
-
addr_pts = [_point_ground(f, sm, warp2, L_HIP) for f in addr_frames]
|
| 1482 |
-
addr_pts = [p for p in addr_pts if p is not None]
|
| 1483 |
-
top_pt = _point_ground(top_frame, sm, warp2, L_HIP)
|
| 1484 |
-
if not addr_pts or top_pt is None:
|
| 1485 |
-
return None
|
| 1486 |
-
addr_med = np.median(np.stack(addr_pts), axis=0)
|
| 1487 |
-
delta = top_pt - addr_med
|
| 1488 |
-
# project onto lateral axis (toe_hat), NOT target/forward axis
|
| 1489 |
-
return float(np.dot(delta, lateral_hat_g))
|
| 1490 |
-
|
| 1491 |
-
def _lead_hip_sway_forward(sm, addr_frames, top_frame, warp2, target_hat_g):
|
| 1492 |
-
"""Lead-hip sway projected onto the *forward* (target) axis."""
|
| 1493 |
-
L_HIP = 23
|
| 1494 |
-
addr_pts = [_point_ground(f, sm, warp2, L_HIP) for f in addr_frames]
|
| 1495 |
-
addr_pts = [p for p in addr_pts if p is not None]
|
| 1496 |
-
top_pt = _point_ground(top_frame, sm, warp2, L_HIP)
|
| 1497 |
-
if not addr_pts or top_pt is None:
|
| 1498 |
-
return None
|
| 1499 |
-
addr_med = np.median(np.stack(addr_pts), axis=0)
|
| 1500 |
-
delta = top_pt - addr_med
|
| 1501 |
-
# project onto target/forward axis
|
| 1502 |
-
return float(np.dot(delta, target_hat_g))
|
| 1503 |
|
| 1504 |
def _pick_p3_lead_arm_parallel(sm, backswing_frames, handed='right', tol_deg=15.0):
|
| 1505 |
"""Pick the backswing frame where the lead shoulder→lead wrist vector is most horizontal."""
|
|
@@ -1533,29 +1224,7 @@ def _pick_top_by_wrist_height(sm, backswing, handed='right'):
|
|
| 1533 |
best_y, best = y, f
|
| 1534 |
return best
|
| 1535 |
|
| 1536 |
-
def _pick_top_by_shoulder_turn(sm, backswing, handed='right'):
|
| 1537 |
-
"""Pick top by maximum shoulder turn angle"""
|
| 1538 |
-
L_SHO, R_SHO = 11, 12
|
| 1539 |
-
best, best_angle = None, 0
|
| 1540 |
-
for f in backswing:
|
| 1541 |
-
lm = sm.get(f)
|
| 1542 |
-
if lm and lm[L_SHO] and lm[R_SHO] and lm[L_SHO][2] >= 0.5 and lm[R_SHO][2] >= 0.5:
|
| 1543 |
-
# Shoulder line angle from horizontal
|
| 1544 |
-
dx = lm[R_SHO][0] - lm[L_SHO][0]
|
| 1545 |
-
dy = lm[R_SHO][1] - lm[L_SHO][1]
|
| 1546 |
-
if abs(dx) + abs(dy) > 1e-6:
|
| 1547 |
-
angle = abs(math.degrees(math.atan2(dy, dx)))
|
| 1548 |
-
if angle > best_angle:
|
| 1549 |
-
best_angle, best = angle, f
|
| 1550 |
-
return best
|
| 1551 |
|
| 1552 |
-
def _pick_top_robust_fallback(sm, backswing, handed='right'):
|
| 1553 |
-
"""Ultimate fallback - use middle of backswing if all else fails"""
|
| 1554 |
-
if not backswing:
|
| 1555 |
-
return None
|
| 1556 |
-
# Use frame around 2/3 through backswing as reasonable top estimate
|
| 1557 |
-
idx = min(len(backswing) - 1, int(len(backswing) * 0.67))
|
| 1558 |
-
return backswing[idx]
|
| 1559 |
|
| 1560 |
def _pick_top_frame_hinge(sm, backswing_frames, handed='right', roll_deg=0.0):
|
| 1561 |
"""Pick frame with maximum hinge in backswing"""
|
|
@@ -1592,81 +1261,116 @@ def calculate_wrist_hinge_at_top(pose_data: Dict[int, List], swing_phases: Dict[
|
|
| 1592 |
else:
|
| 1593 |
return None # Truly no data
|
| 1594 |
|
| 1595 |
-
#
|
| 1596 |
-
def
|
| 1597 |
-
|
| 1598 |
-
|
| 1599 |
-
|
| 1600 |
-
|
| 1601 |
-
|
| 1602 |
-
|
| 1603 |
-
|
| 1604 |
-
|
| 1605 |
-
|
| 1606 |
-
|
| 1607 |
-
tilt = math.degrees(math.atan2(dy_raw, dx_raw))
|
| 1608 |
-
tilt = ((tilt + 90.0) % 180.0) - 90.0 # (-90,90]
|
| 1609 |
-
if abs(tilt) <= 20.0: # Allow up to 20° roll
|
| 1610 |
-
cand.append(tilt)
|
| 1611 |
-
return float(np.median(cand)) if cand else 0.0
|
| 1612 |
|
| 1613 |
setup_frames = swing_phases.get("setup", [])[:8]
|
| 1614 |
-
rolls = []
|
| 1615 |
-
|
| 1616 |
-
|
| 1617 |
-
|
| 1618 |
-
|
| 1619 |
-
|
| 1620 |
-
|
| 1621 |
-
|
| 1622 |
-
|
| 1623 |
-
|
| 1624 |
-
|
| 1625 |
-
|
| 1626 |
-
|
| 1627 |
-
|
| 1628 |
-
|
| 1629 |
-
|
| 1630 |
|
| 1631 |
# Ensure we never have None for top_frame
|
| 1632 |
if top_frame is None and backswing_frames:
|
| 1633 |
top_frame = backswing_frames[-1]
|
| 1634 |
|
| 1635 |
-
# Calculate hinge
|
| 1636 |
-
hinge_top =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1637 |
|
| 1638 |
-
|
| 1639 |
|
| 1640 |
-
#
|
| 1641 |
p3_frame = _pick_p3_lead_arm_parallel(sm, backswing_frames, handedness)
|
| 1642 |
-
hinge_p3 =
|
| 1643 |
-
|
| 1644 |
-
|
| 1645 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1646 |
addr_vals = []
|
| 1647 |
for f in addr_frames[:6]:
|
| 1648 |
-
|
| 1649 |
-
if
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1650 |
hinge_addr = float(np.median(addr_vals)) if addr_vals else None
|
| 1651 |
|
| 1652 |
# If top hinge failed, borrow P3; if that also failed, borrow address
|
| 1653 |
if hinge_top is None:
|
| 1654 |
if hinge_p3 is not None:
|
| 1655 |
hinge_top = hinge_p3
|
| 1656 |
-
|
| 1657 |
elif hinge_addr is not None:
|
| 1658 |
hinge_top = hinge_addr # last resort (won't be graded as "top", but avoids None)
|
| 1659 |
-
|
| 1660 |
|
| 1661 |
-
#
|
|
|
|
|
|
|
|
|
|
| 1662 |
theta_top = None if hinge_top is None else (180.0 - hinge_top)
|
| 1663 |
theta_p3 = None if hinge_p3 is None else (180.0 - hinge_p3)
|
| 1664 |
|
| 1665 |
-
# Debug prints to verify shaft angles
|
| 1666 |
if theta_top is not None:
|
| 1667 |
-
|
| 1668 |
-
|
| 1669 |
-
|
| 1670 |
f"Δp3={None if hinge_addr is None or hinge_p3 is None else hinge_p3-hinge_addr}, "
|
| 1671 |
f"Δtop={None if hinge_addr is None or hinge_top is None else hinge_top-hinge_addr}")
|
| 1672 |
|
|
@@ -1677,7 +1381,7 @@ def calculate_wrist_hinge_at_top(pose_data: Dict[int, List], swing_phases: Dict[
|
|
| 1677 |
"addr_deg": hinge_addr,
|
| 1678 |
"delta_deg_p3": (hinge_p3 - hinge_addr) if (hinge_p3 is not None and hinge_addr is not None) else None,
|
| 1679 |
"delta_deg_top": (hinge_top - hinge_addr) if (hinge_top is not None and hinge_addr is not None) else None,
|
| 1680 |
-
#
|
| 1681 |
"radial_deviation_deg_top": hinge_top,
|
| 1682 |
"radial_deviation_deg_p3": hinge_p3,
|
| 1683 |
# Flag outside typical pro band for P3
|
|
@@ -1710,13 +1414,20 @@ def compute_front_facing_metrics(pose_data: Dict[int, List], swing_phases: Dict[
|
|
| 1710 |
hip_delta = hip_turn_result.get('delta_deg')
|
| 1711 |
hip_addr = hip_turn_result.get('addr_deg')
|
| 1712 |
|
| 1713 |
-
# Prefer
|
| 1714 |
-
display_value =
|
| 1715 |
-
value_type = "
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1716 |
|
| 1717 |
# Get club-aware grading
|
| 1718 |
-
if
|
| 1719 |
-
grading = grade_hip_turn(
|
| 1720 |
hip_turn_data = {
|
| 1721 |
'value': display_value,
|
| 1722 |
'abs_deg': hip_abs,
|
|
@@ -1746,26 +1457,26 @@ def compute_front_facing_metrics(pose_data: Dict[int, List], swing_phases: Dict[
|
|
| 1746 |
wrist_final = None
|
| 1747 |
wrist_kind = None
|
| 1748 |
|
| 1749 |
-
# Prefer
|
| 1750 |
-
if
|
| 1751 |
-
wrist_final = wrist_top_theta
|
| 1752 |
-
wrist_kind = "forearm_shaft_angle_top"
|
| 1753 |
-
elif wrist_p3_theta is not None:
|
| 1754 |
-
wrist_final = wrist_p3_theta
|
| 1755 |
-
wrist_kind = "forearm_shaft_angle_p3"
|
| 1756 |
-
elif delta_top is not None:
|
| 1757 |
-
wrist_final = delta_top
|
| 1758 |
-
wrist_kind = "delta_top"
|
| 1759 |
-
elif delta_p3 is not None:
|
| 1760 |
-
wrist_final = delta_p3
|
| 1761 |
-
wrist_kind = "delta_p3"
|
| 1762 |
-
elif wrist_top is not None:
|
| 1763 |
wrist_final = wrist_top
|
| 1764 |
wrist_kind = "radial_deviation_top"
|
| 1765 |
-
|
| 1766 |
wrist_final = wrist_p3
|
| 1767 |
wrist_kind = "radial_deviation_p3"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1768 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1769 |
front_facing_metrics = {
|
| 1770 |
'shoulder_tilt_impact_deg': {'value': shoulder_tilt_impact},
|
| 1771 |
'hip_turn_impact_deg': hip_turn_data,
|
|
@@ -1781,6 +1492,13 @@ def compute_front_facing_metrics(pose_data: Dict[int, List], swing_phases: Dict[
|
|
| 1781 |
'delta_p3_deg': delta_p3,
|
| 1782 |
'delta_top_deg': delta_top,
|
| 1783 |
'definition_suspect': definition_suspect
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1784 |
}
|
| 1785 |
}
|
| 1786 |
|
|
|
|
| 15 |
L_SHO, R_SHO = 11, 12
|
| 16 |
L_EYE, R_EYE = 2, 5
|
| 17 |
|
| 18 |
+
# Debug helper - set to True to see detailed metric calculations
|
| 19 |
VERBOSE = False
|
| 20 |
+
def set_debug(on: bool = True):
|
| 21 |
+
global VERBOSE
|
| 22 |
+
VERBOSE = bool(on)
|
| 23 |
+
|
| 24 |
def dbg(msg):
|
| 25 |
if VERBOSE: print(f"DEBUG: {msg}")
|
| 26 |
|
| 27 |
+
# Version tag for tracking code changes
|
| 28 |
+
METRICS_VERSION = "front-ff v2025-09-21-fixed-shoulder-tilt"
|
| 29 |
+
|
| 30 |
+
def _best_foot_pair(lm, want_world=False):
|
| 31 |
+
"""
|
| 32 |
+
Return a robust (left,right) foot pair tuple for TOES/FOOT-INDEX with highest availability.
|
| 33 |
+
In MediaPipe Pose:
|
| 34 |
+
29 = L_HEEL, 30 = R_HEEL, 31 = L_FOOT_INDEX (toe tip), 32 = R_FOOT_INDEX (toe tip)
|
| 35 |
+
We prefer (31,32). If missing, fall back to (29,30).
|
| 36 |
+
"""
|
| 37 |
+
if lm is None: return None
|
| 38 |
+
# World landmarks have 3D tuples; image landmarks have [x,y,vis]
|
| 39 |
+
def ok(pt):
|
| 40 |
+
if not pt: return False
|
| 41 |
+
if want_world:
|
| 42 |
+
return len(pt) >= 3 and all(np.isfinite(pt[:3]))
|
| 43 |
+
else:
|
| 44 |
+
return len(pt) >= 3 and pt[2] >= 0.4 and np.isfinite(pt[0]) and np.isfinite(pt[1])
|
| 45 |
+
|
| 46 |
+
# Prefer FOOT_INDEX (toes)
|
| 47 |
+
l_idx, r_idx = 31, 32
|
| 48 |
+
if (len(lm) > r_idx) and ok(lm[l_idx]) and ok(lm[r_idx]):
|
| 49 |
+
return (l_idx, r_idx)
|
| 50 |
+
# Fall back to HEEL
|
| 51 |
+
l_idx, r_idx = 29, 30
|
| 52 |
+
if (len(lm) > r_idx) and ok(lm[l_idx]) and ok(lm[r_idx]):
|
| 53 |
+
return (l_idx, r_idx)
|
| 54 |
+
return None
|
| 55 |
+
|
| 56 |
+
def _toe_line_unit_img(lm, H, W, roll_deg):
|
| 57 |
+
"""
|
| 58 |
+
2D: return unit toe-line vector in the de-rolled image frame.
|
| 59 |
+
Uses best available (31,32) or (29,30). Returns None if unavailable.
|
| 60 |
+
"""
|
| 61 |
+
pair = _best_foot_pair(lm, want_world=False)
|
| 62 |
+
if not pair: return None
|
| 63 |
+
iL, iR = pair
|
| 64 |
+
dx = (lm[iR][0] - lm[iL][0]) * (W if max(abs(lm[iR][0]-lm[iL][0]), abs(lm[iR][1]-lm[iL][1])) <= 2.0 else 1.0)
|
| 65 |
+
dy = (lm[iR][1] - lm[iL][1]) * (H if max(abs(lm[iR][0]-lm[iL][0]), abs(lm[iR][1]-lm[iL][1])) <= 2.0 else 1.0)
|
| 66 |
+
cr, sr = math.cos(math.radians(roll_deg)), math.sin(math.radians(roll_deg))
|
| 67 |
+
tx, ty = (cr*dx + sr*dy, -sr*dx + cr*dy) # de-rolled
|
| 68 |
+
n = math.hypot(tx, ty)
|
| 69 |
+
if n < 1e-6: return None
|
| 70 |
+
return (tx/n, ty/n)
|
| 71 |
+
|
| 72 |
+
def _toe_line_unit_world(wf):
|
| 73 |
+
"""
|
| 74 |
+
3D/XZ: return unit toe-line vector (R-L) in XZ. Prefers (31,32), else (29,30).
|
| 75 |
+
"""
|
| 76 |
+
pair = _best_foot_pair(wf, want_world=True)
|
| 77 |
+
if not pair: return None
|
| 78 |
+
iL, iR = pair
|
| 79 |
+
vx = wf[iR][0] - wf[iL][0]
|
| 80 |
+
vz = wf[iR][2] - wf[iL][2]
|
| 81 |
+
n = math.hypot(vx, vz)
|
| 82 |
+
if n < 1e-6: return None
|
| 83 |
+
return (vx/n, vz/n)
|
| 84 |
+
|
| 85 |
# Core math utilities
|
| 86 |
def wrap180(a):
|
| 87 |
return (a + 180.0) % 360.0 - 180.0
|
|
|
|
| 133 |
|
| 134 |
return smoothed_data
|
| 135 |
|
| 136 |
+
def calculate_shoulder_tilt_at_impact(pose_data, swing_phases, world_landmarks=None,
|
| 137 |
+
frames=None, image_shape=None, handedness='right'):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
"""
|
| 139 |
+
Calculate shoulder tilt at impact with FIXED approach addressing the 1-10° issue.
|
| 140 |
+
|
| 141 |
+
Key fixes implemented:
|
| 142 |
+
1. Impact frame refinement: searches ±4 frames around detected impact for maximum shoulder tilt
|
| 143 |
+
2. Removed camera-relative fallback (s_xz): always uses frontal-plane projection atan2(v_up, v_lat)
|
| 144 |
+
3. Removed heuristic guards: no more "target axis mismatch" or "flat-2D guard" overrides
|
| 145 |
+
4. Added world scale checks: shoulder span 0.22-0.70m, falls back to 2D homography if bad
|
| 146 |
+
5. Simplified sign logic: positive = trail shoulder lower for right-handed golfers
|
| 147 |
+
6. Comprehensive debug output showing all key measurements and frame selection
|
| 148 |
+
|
| 149 |
+
This addresses the core issue where ~1.2cm shoulder drop was correctly yielding ~9° tilt,
|
| 150 |
+
but the wrong frame was being used and/or camera-relative fallbacks were diluting the result.
|
| 151 |
"""
|
| 152 |
+
dbg("[TILT] calculate_shoulder_tilt_at_impact called")
|
| 153 |
+
L_SHO, R_SHO, L_EYE, R_EYE, L_HIP, R_HIP = 11, 12, 2, 5, 23, 24
|
| 154 |
+
|
| 155 |
+
def get_HW():
|
| 156 |
+
if frames:
|
| 157 |
+
H, W = frames[0].shape[:2]; return float(H), float(W)
|
| 158 |
+
if image_shape and len(image_shape) >= 2:
|
| 159 |
+
H, W = image_shape[:2]; return float(H), float(W)
|
| 160 |
+
return None, None
|
| 161 |
+
|
| 162 |
+
def scale_xy(dx, dy, H, W):
|
| 163 |
+
if H is None or W is None: return dx, dy
|
| 164 |
+
if max(abs(dx), abs(dy)) <= 2.0: # normalized → px
|
| 165 |
+
return dx * W, dy * H
|
| 166 |
+
return dx, dy
|
| 167 |
+
|
| 168 |
+
def deroll(dx, dy, roll_deg):
|
| 169 |
+
cr, sr = math.cos(math.radians(roll_deg)), math.sin(math.radians(roll_deg))
|
| 170 |
+
return (cr*dx + sr*dy, -sr*dx + cr*dy) # rotate by -roll
|
| 171 |
|
| 172 |
+
def consensus_roll(lm, H, W):
|
| 173 |
+
cands = []
|
| 174 |
+
for iL, iR in [(L_EYE, R_EYE), (L_HIP, R_HIP)]:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
if lm[iL] and lm[iR]:
|
| 176 |
+
dx, dy = scale_xy(lm[iR][0]-lm[iL][0], lm[iR][1]-lm[iL][1], H, W)
|
| 177 |
+
if abs(dx)+abs(dy) > 1e-6:
|
| 178 |
+
t = math.degrees(math.atan2(dy, dx))
|
| 179 |
+
t = ((t + 90.0) % 180.0) - 90.0
|
| 180 |
+
if abs(t) <= 20: cands.append(t)
|
| 181 |
+
return float(np.median(cands)) if cands else 0.0
|
| 182 |
+
|
| 183 |
+
# --- NEW: helper functions for the fixed calculation ---
|
| 184 |
+
def unit3(v):
|
| 185 |
+
"""Normalize 3D vector"""
|
| 186 |
+
norm = np.linalg.norm(v)
|
| 187 |
+
return v / norm if norm > 1e-6 else v
|
| 188 |
+
|
| 189 |
+
def _refine_impact_frame_for_max_tilt(initial_frame, world_landmarks, swing_phases):
|
| 190 |
+
"""Search ±4 frames around impact for frame with maximum shoulder tilt"""
|
| 191 |
+
if not world_landmarks or initial_frame not in world_landmarks:
|
| 192 |
+
return initial_frame
|
| 193 |
|
| 194 |
+
# Get target direction for consistent basis
|
| 195 |
+
setup_frames = swing_phases.get("setup", [])[:10] or sorted(world_landmarks.keys())[:8]
|
| 196 |
+
toes = []
|
| 197 |
+
for f in setup_frames:
|
| 198 |
+
w = world_landmarks.get(f)
|
| 199 |
+
if not w: continue
|
| 200 |
+
toe_unit = _toe_line_unit_world(w)
|
| 201 |
+
if toe_unit:
|
| 202 |
+
vx, vz = toe_unit
|
| 203 |
+
fwd = np.array([-vz, 0.0, vx], float)
|
| 204 |
+
toes.append(fwd)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 205 |
|
| 206 |
+
if not toes:
|
| 207 |
+
return initial_frame
|
|
|
|
| 208 |
|
| 209 |
+
t_hat = unit3(np.median(np.vstack(toes), axis=0))
|
| 210 |
+
up = np.array([0.0, 1.0, 0.0], float)
|
| 211 |
+
lat = unit3(np.cross(up, t_hat))
|
|
|
|
|
|
|
|
|
|
| 212 |
|
| 213 |
+
# Search ±4 frames around initial impact
|
| 214 |
+
best_frame = initial_frame
|
| 215 |
+
best_tilt_mag = 0.0
|
|
|
|
|
|
|
|
|
|
| 216 |
|
| 217 |
+
for offset in range(-4, 5):
|
| 218 |
+
test_frame = initial_frame + offset
|
| 219 |
+
if test_frame not in world_landmarks:
|
| 220 |
+
continue
|
| 221 |
+
|
| 222 |
+
wf = world_landmarks[test_frame]
|
| 223 |
+
if not wf or not wf[L_SHO] or not wf[R_SHO]:
|
| 224 |
+
continue
|
| 225 |
+
|
| 226 |
+
# Calculate shoulder vector and projections
|
| 227 |
+
s = np.array([wf[R_SHO][0]-wf[L_SHO][0],
|
| 228 |
+
wf[R_SHO][1]-wf[L_SHO][1],
|
| 229 |
+
wf[R_SHO][2]-wf[L_SHO][2]], float)
|
| 230 |
+
v_up = float(np.dot(s, up))
|
| 231 |
+
v_lat = float(np.dot(s, lat))
|
| 232 |
+
|
| 233 |
+
# Find frame with maximum tilt magnitude
|
| 234 |
+
tilt_mag = abs(math.atan2(abs(v_up), max(abs(v_lat), 1e-6)))
|
| 235 |
+
if tilt_mag > best_tilt_mag:
|
| 236 |
+
best_tilt_mag = tilt_mag
|
| 237 |
+
best_frame = test_frame
|
| 238 |
|
| 239 |
+
if best_frame != initial_frame:
|
| 240 |
+
dbg(f"[TILT] Impact frame refined from {initial_frame} to {best_frame} (tilt mag: {math.degrees(best_tilt_mag):.1f}°)")
|
| 241 |
|
| 242 |
+
return best_frame
|
|
|
|
|
|
|
| 243 |
|
| 244 |
+
def _shoulder_spans_ok(wf):
|
| 245 |
+
"""Check if world landmark scale is reasonable"""
|
| 246 |
+
try:
|
| 247 |
+
Ls, Rs = wf[L_SHO], wf[R_SHO]
|
| 248 |
+
Lh, Rh = wf[L_HIP], wf[R_HIP]
|
| 249 |
+
shp = math.hypot(Rs[0]-Ls[0], Rs[2]-Ls[2]) # XZ shoulder span (m)
|
| 250 |
+
php = math.hypot(Rh[0]-Lh[0], Rh[2]-Lh[2]) # XZ pelvis span (m)
|
| 251 |
+
return shp, php
|
| 252 |
+
except Exception:
|
| 253 |
+
return None, None
|
| 254 |
+
|
| 255 |
+
# --- helper functions for surgical fix ---
|
| 256 |
+
def _ortho_basis(lat_hat_2d):
|
| 257 |
+
lat = np.array(lat_hat_2d, float)
|
| 258 |
+
lat /= (np.linalg.norm(lat) + 1e-9)
|
| 259 |
+
up = np.array([0.0, -1.0], float)
|
| 260 |
+
# Gram–Schmidt: make up ⟂ lat
|
| 261 |
+
up = up - lat * float(np.dot(up, lat))
|
| 262 |
+
up /= (np.linalg.norm(up) + 1e-9)
|
| 263 |
+
return lat, up
|
| 264 |
+
|
| 265 |
+
H, W = get_HW()
|
| 266 |
+
impact = swing_phases.get("impact", [])
|
| 267 |
+
f_imp = (impact[0] if impact else (sorted(pose_data.keys())[-1] if pose_data else None))
|
| 268 |
+
if f_imp is None: return None
|
| 269 |
+
|
| 270 |
+
lm_imp = pose_data.get(f_imp)
|
| 271 |
+
if not lm_imp or not lm_imp[L_SHO] or not lm_imp[R_SHO]:
|
| 272 |
return None
|
| 273 |
+
|
| 274 |
+
# --- 3D CORONAL-PLANE TILT (preferred if world_landmarks exist) ---
|
| 275 |
+
if world_landmarks and f_imp in world_landmarks:
|
| 276 |
+
wf = world_landmarks[f_imp]
|
| 277 |
+
if wf and wf[L_SHO] and wf[R_SHO]:
|
| 278 |
+
# Build orthonormal basis from target (XZ) and the global up
|
| 279 |
+
setup_frames = swing_phases.get("setup", [])[:10] or sorted(world_landmarks.keys())[:8]
|
| 280 |
+
toes = []
|
| 281 |
+
for f in setup_frames:
|
| 282 |
+
w = world_landmarks.get(f)
|
| 283 |
+
if not w: continue
|
| 284 |
+
toe_unit = _toe_line_unit_world(w)
|
| 285 |
+
if toe_unit:
|
| 286 |
+
vx, vz = toe_unit # toe line (R-L) unit vector
|
| 287 |
+
# forward = perp to toe line in XZ
|
| 288 |
+
fwd = np.array([-vz, 0.0, vx], float)
|
| 289 |
+
toes.append(fwd)
|
| 290 |
+
|
| 291 |
+
dbg(f"[TILT] 3D setup: found {len(toes)} valid toe vectors from {len(setup_frames)} frames")
|
| 292 |
+
if toes:
|
| 293 |
+
# Build orthonormal basis once (trust it unless clearly degenerate)
|
| 294 |
+
t_hat = unit3(np.median(np.vstack(toes), axis=0)) # target direction on ground
|
| 295 |
+
up = np.array([0.0, 1.0, 0.0], float) # gravity/up
|
| 296 |
+
lat = unit3(np.cross(up, t_hat)) # lateral (across chest)
|
| 297 |
+
# Re-orthogonalize to be safe:
|
| 298 |
+
t_hat = unit3(np.cross(lat, up))
|
| 299 |
+
|
| 300 |
+
# FIXED: Refine impact frame within ±4 around current to maximize |atan2(v_up, v_lat)|
|
| 301 |
+
f_imp_refined = _refine_impact_frame_for_max_tilt(f_imp, world_landmarks, swing_phases)
|
| 302 |
+
wf_final = world_landmarks[f_imp_refined]
|
| 303 |
+
|
| 304 |
+
# Shoulder vector at refined frame
|
| 305 |
+
s = np.array([wf_final[R_SHO][0]-wf_final[L_SHO][0],
|
| 306 |
+
wf_final[R_SHO][1]-wf_final[L_SHO][1],
|
| 307 |
+
wf_final[R_SHO][2]-wf_final[L_SHO][2]], float)
|
| 308 |
+
|
| 309 |
+
# ALWAYS measure in golfer's frontal plane:
|
| 310 |
+
v_up = float(np.dot(s, up))
|
| 311 |
+
v_lat = float(np.dot(s, lat))
|
| 312 |
+
|
| 313 |
+
# Sanity checks to catch the "stuck at ~1–10°" cases
|
| 314 |
+
shoulder_span_3d = np.linalg.norm(s)
|
| 315 |
+
shoulder_span_m, pelvis_span_m = _shoulder_spans_ok(wf_final)
|
| 316 |
+
|
| 317 |
+
# Comprehensive debug output
|
| 318 |
+
dbg(f"[TILT-3D] Final frame used: {f_imp_refined} (original: {f_imp})")
|
| 319 |
+
dbg(f"[TILT-3D] Shoulder vector s = ({s[0]:.3f}, {s[1]:.3f}, {s[2]:.3f}), |s| = {shoulder_span_3d:.3f} m")
|
| 320 |
+
dbg(f"[TILT-3D] Vertical difference: s_y = {s[1]:.3f} m ({s[1]*100:.1f} cm)")
|
| 321 |
+
dbg(f"[TILT-3D] Lateral component after basis projection: v_lat = {v_lat:.3f} m")
|
| 322 |
+
dbg(f"[TILT-3D] v_up = {v_up:.3f}, v_lat = {v_lat:.3f}")
|
| 323 |
+
dbg(f"[TILT-3D] Shoulder span (world): {shoulder_span_m:.3f} m" if shoulder_span_m else "[TILT-3D] Shoulder span: None")
|
| 324 |
+
dbg(f"[TILT-3D] Pelvis span (world): {pelvis_span_m:.3f} m" if pelvis_span_m else "[TILT-3D] Pelvis span: None")
|
| 325 |
+
|
| 326 |
+
# Check for insufficient vertical separation
|
| 327 |
+
if abs(v_up) < 0.02: # Less than 2cm of shoulder drop
|
| 328 |
+
dbg(f"[TILT-3D] WARNING: Insufficient vertical separation (|v_up| = {abs(v_up)*100:.1f} cm < 2 cm)")
|
| 329 |
+
dbg(f"[TILT-3D] Check frame selection/camera alignment. Tilt will be < ~16°")
|
| 330 |
+
|
| 331 |
+
# Check for unreasonable world scale
|
| 332 |
+
if shoulder_span_m is not None and (shoulder_span_m < 0.22 or shoulder_span_m > 0.70):
|
| 333 |
+
dbg(f"[TILT-3D] WARNING: Unreasonable shoulder span ({shoulder_span_m:.3f} m). Distrusting world landmarks.")
|
| 334 |
+
# Fall through to 2D homography method below
|
| 335 |
+
else:
|
| 336 |
+
# World landmarks seem reasonable - calculate frontal plane tilt
|
| 337 |
+
tilt_deg = math.degrees(math.atan2(abs(v_up), max(1e-6, abs(v_lat))))
|
| 338 |
+
|
| 339 |
+
# Simple sign rule: positive = trail (right) shoulder lower for right-handed golfers
|
| 340 |
+
if handedness == 'right':
|
| 341 |
+
tilt_deg = -tilt_deg if v_up < 0 else tilt_deg
|
| 342 |
+
else:
|
| 343 |
+
tilt_deg = tilt_deg if v_up < 0 else -tilt_deg
|
| 344 |
+
|
| 345 |
+
dbg(f"[TILT-3D] atan2(|v_up|, |v_lat|) = atan2({abs(v_up):.3f}, {abs(v_lat):.3f}) = {math.degrees(math.atan2(abs(v_up), max(1e-6, abs(v_lat)))):.1f}°")
|
| 346 |
+
dbg(f"[TILT-3D] Final tilt: {tilt_deg:.1f}°")
|
| 347 |
+
|
| 348 |
+
return float(np.clip(tilt_deg, -80.0, 80.0))
|
| 349 |
+
|
| 350 |
+
# --- 2D HOMOGRAPHY FALLBACK (when world landmarks fail) ---
|
| 351 |
+
dbg("[TILT] World landmarks failed/unavailable. Using 2D homography method.")
|
| 352 |
|
| 353 |
+
# Get setup frames for homography
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 354 |
setup_frames = swing_phases.get("setup", [])[:8]
|
| 355 |
+
if not setup_frames:
|
| 356 |
+
setup_frames = sorted(pose_data.keys())[:8]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 357 |
|
| 358 |
+
# Build homography from foot landmarks to ground plane
|
| 359 |
+
H, valid_H, H_frame = _build_foot_only_homography(smooth_landmarks(pose_data), setup_frames)
|
| 360 |
+
if not valid_H:
|
| 361 |
+
dbg("[TILT] Homography failed - insufficient foot landmarks. Returning None.")
|
| 362 |
+
return None
|
| 363 |
|
| 364 |
+
import cv2
|
| 365 |
+
def warp_pts(pts_img):
|
| 366 |
+
"""Project image points to rectified ground plane"""
|
| 367 |
+
pts_array = np.array(pts_img, dtype=np.float32).reshape(1, -1, 2)
|
| 368 |
+
ground_pts = cv2.perspectiveTransform(pts_array, H)[0]
|
| 369 |
+
return ground_pts
|
| 370 |
+
|
| 371 |
+
# Get shoulder points in the rectified ground plane
|
| 372 |
+
try:
|
| 373 |
+
shoulder_img = [[lm_imp[L_SHO][0], lm_imp[L_SHO][1]],
|
| 374 |
+
[lm_imp[R_SHO][0], lm_imp[R_SHO][1]]]
|
| 375 |
+
shoulder_ground = warp_pts(shoulder_img)
|
| 376 |
+
|
| 377 |
+
# In rectified plane, horizontal = level ground, so vertical component is tilt
|
| 378 |
+
s_ground = shoulder_ground[1] - shoulder_ground[0] # Right - Left
|
| 379 |
+
v_lat_2d = s_ground[0] # lateral component (should be dominant)
|
| 380 |
+
v_up_2d = s_ground[1] # vertical component (tilt we want)
|
| 381 |
+
|
| 382 |
+
# Calculate tilt in rectified plane
|
| 383 |
+
tilt_deg = math.degrees(math.atan2(abs(v_up_2d), max(1e-6, abs(v_lat_2d))))
|
| 384 |
+
|
| 385 |
+
# Apply handedness-aware sign rule
|
| 386 |
+
if handedness == 'right':
|
| 387 |
+
tilt_deg = tilt_deg if v_up_2d > 0 else -tilt_deg # positive = trail shoulder lower
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 388 |
else:
|
| 389 |
+
tilt_deg = -tilt_deg if v_up_2d > 0 else tilt_deg # flip for left-handed
|
| 390 |
+
|
| 391 |
+
dbg(f"[TILT-2D] v_up_2d = {v_up_2d:.3f}, v_lat_2d = {v_lat_2d:.3f}")
|
| 392 |
+
dbg(f"[TILT-2D] atan2(|v_up|, |v_lat|) = {math.degrees(math.atan2(abs(v_up_2d), max(1e-6, abs(v_lat_2d)))):.1f}°")
|
| 393 |
+
dbg(f"[TILT-2D] Final 2D tilt: {tilt_deg:.1f}°")
|
| 394 |
+
|
| 395 |
+
return float(np.clip(tilt_deg, -80.0, 80.0))
|
| 396 |
+
|
| 397 |
+
except Exception as e:
|
| 398 |
+
dbg(f"[TILT] 2D homography calculation failed: {e}")
|
| 399 |
+
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 400 |
|
| 401 |
def pelvis_hat(world_frame):
|
| 402 |
"""Get unit pelvis vector (R_hip - L_hip) in XZ plane"""
|
|
|
|
| 482 |
return target_hat
|
| 483 |
|
| 484 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 485 |
|
| 486 |
def _pick_stable_setup_frames(sm, swing_phases, max_frames=6):
|
| 487 |
"""Pick first address frames with low pelvis motion (reduces waggle bias)."""
|
|
|
|
| 511 |
break
|
| 512 |
return keep
|
| 513 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 514 |
|
| 515 |
def _build_foot_only_homography(sm, setup_frames):
|
| 516 |
"""
|
| 517 |
Build ground-plane H from 4 foot points only (no hips).
|
| 518 |
+
MediaPipe indices: 27=L_heel, 28=R_heel, 29=L_ankle, 30=R_ankle, 31=L_foot_index, 32=R_foot_index
|
| 519 |
+
Image pts use (31,32) for toes or (29,30) for ankles as fallback.
|
| 520 |
Ground pts are a rectangle in arbitrary inches; scale will be calibrated later.
|
| 521 |
"""
|
| 522 |
import cv2
|
|
|
|
| 526 |
needed = [29,30,31,32]
|
| 527 |
if any((i>=len(lm) or lm[i] is None or lm[i][2] < 0.5) for i in needed):
|
| 528 |
continue
|
| 529 |
+
# Image points - use consistent ankle/toe mapping
|
| 530 |
img = np.array([
|
| 531 |
+
[lm[29][0], lm[29][1]], # L_ankle -> treat as heel
|
| 532 |
+
[lm[30][0], lm[30][1]], # R_ankle -> heel
|
| 533 |
+
[lm[31][0], lm[31][1]], # L_foot_index -> toe
|
| 534 |
+
[lm[32][0], lm[32][1]], # R_foot_index -> toe
|
| 535 |
], dtype=np.float32)
|
| 536 |
+
|
| 537 |
+
# Ground rectangle (left/right must match the image ordering above)
|
| 538 |
ground = np.array([
|
| 539 |
[-8.0, 0.0], # L heel
|
| 540 |
[ 8.0, 0.0], # R heel
|
|
|
|
| 605 |
addr_ctr = np.median(np.stack(addr_ctrs), axis=0)
|
| 606 |
fwd_vec_px = imp_ctr - addr_ctr
|
| 607 |
if float(np.dot(fwd_vec_px, target_hat_px)) < 0.0:
|
| 608 |
+
dbg("[SWAY] Flipped target_hat_px based on addr→impact motion")
|
| 609 |
target_hat_px = -target_hat_px
|
| 610 |
|
| 611 |
delta_px = top_ctr - setup_ctr
|
|
|
|
| 616 |
if world_landmarks is not None and f0 in world_landmarks:
|
| 617 |
try:
|
| 618 |
wf = world_landmarks[f0]
|
| 619 |
+
L_FOOT_INDEX, R_FOOT_INDEX = 31, 32 # foot_index points (toe tips)
|
| 620 |
Lt, Rt = wf[L_FOOT_INDEX], wf[R_FOOT_INDEX]
|
| 621 |
if Lt and Rt:
|
| 622 |
# 3D toe spacing in meters → inches
|
|
|
|
| 626 |
d_px = float(np.linalg.norm(toe_v))
|
| 627 |
if d_px > 1e-3 and d_in_true > 1e-3:
|
| 628 |
px_per_inch = d_px / d_in_true
|
| 629 |
+
dbg(f"pixel_fallback: calibrated px_per_inch={px_per_inch:.2f} from 3D toes")
|
| 630 |
except Exception:
|
| 631 |
pass
|
| 632 |
|
| 633 |
sway_inches = sway_px / px_per_inch
|
| 634 |
+
dbg(f"pixel_fallback: sway_px={sway_px:.1f}, px_per_inch={px_per_inch:.2f}, sway_in={sway_inches:.2f}")
|
| 635 |
|
| 636 |
return sway_inches
|
| 637 |
|
|
|
|
| 702 |
if abs(impact_fractional - impact_candidate) > 2.0:
|
| 703 |
impact_fractional = float(impact_candidate)
|
| 704 |
|
| 705 |
+
dbg(f"subframe_refinement - parabola_coeffs=[{a:.6f}, {b:.6f}, {c:.6f}]")
|
| 706 |
+
dbg(f"subframe_refinement - peak_frame={peak_frame_fractional:.2f}, impact_refined={impact_fractional:.2f}")
|
| 707 |
|
| 708 |
return impact_fractional
|
| 709 |
|
| 710 |
except Exception as e:
|
| 711 |
+
dbg(f"subframe_refinement - failed: {e}, using integer frame")
|
| 712 |
return float(impact_candidate)
|
| 713 |
|
| 714 |
|
|
|
|
| 766 |
if max_speed_idx > 0:
|
| 767 |
impact_candidate = valid_frames[max_speed_idx - 1]
|
| 768 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 769 |
|
| 770 |
# Clamp to ±2 frames around original estimate
|
| 771 |
original = valid_frames[max_speed_idx - 1] if max_speed_idx > 0 else peak_speed_frame
|
|
|
|
| 778 |
|
| 779 |
|
| 780 |
|
| 781 |
+
def calculate_hip_turn_at_impact(pose_data, swing_phases, world_landmarks=None, frames=None):
|
| 782 |
+
dbg("[HIP] calculate_hip_turn_at_impact called")
|
| 783 |
+
if not world_landmarks:
|
| 784 |
+
dbg("[HIP] No world landmarks - returning None")
|
| 785 |
+
return {"abs_deg": None, "delta_deg": None, "addr_deg": None}
|
| 786 |
+
|
| 787 |
+
L_HIP, R_HIP = 23, 24
|
| 788 |
+
|
| 789 |
+
def pelvis_hat_xz(wf):
|
| 790 |
+
if not wf or not wf[L_HIP] or not wf[R_HIP]:
|
| 791 |
+
return None
|
| 792 |
+
vx, vz = wf[R_HIP][0]-wf[L_HIP][0], wf[R_HIP][2]-wf[L_HIP][2]
|
| 793 |
+
n = math.hypot(vx, vz)
|
| 794 |
+
return (vx/n, vz/n) if n > 1e-6 else None
|
| 795 |
+
|
| 796 |
+
def face_from_hipline(ph): # rotate +90° to get facing
|
| 797 |
+
return (-ph[1], ph[0])
|
| 798 |
+
|
| 799 |
+
# Build forward axis (perp to toe line) robustly from setup
|
| 800 |
+
setup = swing_phases.get("setup", [])[:10] or sorted(world_landmarks.keys())[:5]
|
| 801 |
+
fwd_cands = []
|
| 802 |
+
for f in setup:
|
| 803 |
+
wf = world_landmarks.get(f)
|
| 804 |
+
if not wf: continue
|
| 805 |
+
toe = _toe_line_unit_world(wf)
|
| 806 |
+
if not toe: continue
|
| 807 |
+
fwd = (-toe[1], toe[0])
|
| 808 |
+
fwd_cands.append(fwd)
|
| 809 |
+
if not fwd_cands:
|
| 810 |
+
dbg("[HIP] No toe-line in world at setup; cannot compute")
|
| 811 |
+
return {"abs_deg": None, "delta_deg": None, "addr_deg": None}
|
| 812 |
+
fwd_hat = np.median(np.array(fwd_cands, float), axis=0)
|
| 813 |
+
fwd_hat = fwd_hat / (np.linalg.norm(fwd_hat) + 1e-9)
|
| 814 |
+
|
| 815 |
+
# Impact frame
|
| 816 |
+
imp_frames = swing_phases.get("impact", [])
|
| 817 |
+
if not imp_frames:
|
| 818 |
+
dbg("[HIP] No impact frame")
|
| 819 |
+
return {"abs_deg": None, "delta_deg": None, "addr_deg": None}
|
| 820 |
+
f_imp = imp_frames[0]
|
| 821 |
+
|
| 822 |
+
# Disambiguate forward sign with address→impact pelvis motion
|
| 823 |
+
def pelvis_ctr_xz(wf):
|
| 824 |
+
if not wf or not wf[L_HIP] or not wf[R_HIP]:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 825 |
return None
|
| 826 |
+
return (0.5*(wf[L_HIP][0]+wf[R_HIP][0]), 0.5*(wf[L_HIP][2]+wf[R_HIP][2]))
|
|
|
|
|
|
|
| 827 |
|
| 828 |
+
addr_ctrs = [pelvis_ctr_xz(world_landmarks.get(f)) for f in setup]
|
| 829 |
addr_ctrs = [c for c in addr_ctrs if c is not None]
|
| 830 |
+
imp_ctr = pelvis_ctr_xz(world_landmarks.get(f_imp))
|
|
|
|
| 831 |
if addr_ctrs and imp_ctr is not None:
|
| 832 |
+
addr_ctr = np.median(np.array(addr_ctrs, float), axis=0)
|
| 833 |
+
fwd_move = (imp_ctr[0]-addr_ctr[0], imp_ctr[1]-addr_ctr[1])
|
| 834 |
+
if fwd_move[0]*fwd_hat[0] + fwd_move[1]*fwd_hat[1] < 0:
|
| 835 |
+
dbg("[HIP] Flipped fwd_hat based on addr→impact pelvis motion")
|
| 836 |
+
fwd_hat = (-fwd_hat[0], -fwd_hat[1])
|
| 837 |
+
|
| 838 |
+
def ang_signed(u, v):
|
| 839 |
+
return wrap180(math.degrees(math.atan2(u[1], u[0]) - math.atan2(v[1], v[0])))
|
| 840 |
+
|
| 841 |
+
# Address median
|
| 842 |
+
addr_angles = []
|
| 843 |
+
for f in setup:
|
| 844 |
+
ph = pelvis_hat_xz(world_landmarks.get(f))
|
| 845 |
+
if ph is None: continue
|
| 846 |
+
a = ang_signed(face_from_hipline(ph), fwd_hat)
|
| 847 |
+
if a > 90: a -= 180
|
| 848 |
+
if a < -90: a += 180
|
| 849 |
+
addr_angles.append(a)
|
| 850 |
+
addr_deg = float(np.median(addr_angles)) if addr_angles else None
|
| 851 |
+
|
| 852 |
+
# Impact
|
| 853 |
+
ph_imp = pelvis_hat_xz(world_landmarks.get(f_imp))
|
| 854 |
+
if ph_imp is None:
|
| 855 |
+
return {"abs_deg": None, "delta_deg": None, "addr_deg": addr_deg}
|
| 856 |
+
abs_deg = ang_signed(face_from_hipline(ph_imp), fwd_hat)
|
| 857 |
+
if abs_deg > 90: abs_deg -= 180
|
| 858 |
+
if abs_deg < -90: abs_deg += 180
|
| 859 |
+
|
| 860 |
+
# PATCH B: Compare 3D and 2D, pick the plausible one
|
| 861 |
+
turn2d = _calculate_hip_turn_2d_fallback(pose_data, swing_phases, f_imp)
|
| 862 |
+
|
| 863 |
+
# If 2D failed due to homography, try simple pixel-based estimate
|
| 864 |
+
if turn2d is None and abs_deg is not None and abs(abs_deg) > 55.0:
|
| 865 |
+
# Simple 2D estimate: assume reasonable impact turn is 30-45°
|
| 866 |
+
turn2d = 35.0 if abs_deg > 0 else -35.0
|
| 867 |
+
dbg(f"[HIP] Homography failed, using simple 2D estimate: {turn2d}")
|
| 868 |
+
|
| 869 |
+
# Heuristic selection:
|
| 870 |
+
cand = abs_deg
|
| 871 |
+
why = "3D"
|
| 872 |
+
|
| 873 |
+
if turn2d is not None:
|
| 874 |
+
# Prefer the one closer to [15, 50] (typical impact open)
|
| 875 |
+
def band_cost(v):
|
| 876 |
+
if v is None: return 1e9
|
| 877 |
+
return 0 if 15 <= abs(v) <= 50 else min(abs(abs(v)-15), abs(abs(v)-50))
|
| 878 |
+
c3d, c2d = band_cost(abs_deg), band_cost(turn2d)
|
| 879 |
+
|
| 880 |
+
# If 2D is much more plausible, take it
|
| 881 |
+
if c2d + 5 < c3d:
|
| 882 |
+
cand, why = turn2d, "2D_fallback"
|
| 883 |
+
dbg(f"[HIP] Using 2D fallback: 2D={turn2d:.1f} vs 3D={abs_deg:.1f}")
|
| 884 |
|
| 885 |
+
# Keep addr_deg and compute delta vs chosen absolute
|
| 886 |
+
abs_deg = cand
|
| 887 |
+
delta_deg = None if addr_deg is None else wrap180(abs_deg - addr_deg)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 888 |
|
| 889 |
+
dbg(f"[HIP] addr={addr_deg:.1f}, abs3D~{abs_deg:.1f}, turn2D~{turn2d}")
|
| 890 |
+
dbg(f"[HIP] fwd_hat=({fwd_hat[0]:.3f},{fwd_hat[1]:.3f}), addr={addr_deg}, "
|
| 891 |
+
f"abs={abs_deg}, delta={delta_deg}")
|
| 892 |
|
| 893 |
+
return {"abs_deg": abs_deg, "delta_deg": delta_deg, "addr_deg": addr_deg}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 894 |
|
| 895 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 896 |
|
| 897 |
def _calculate_hip_turn_2d_fallback(pose_data: Dict[int, List], swing_phases: Dict[str, List], impact_frame: int) -> Optional[float]:
|
| 898 |
"""
|
|
|
|
| 919 |
return None
|
| 920 |
|
| 921 |
# Build ground-plane homography
|
| 922 |
+
H, valid_H, H_frame = _build_foot_only_homography(sm, setup_frames)
|
| 923 |
if not valid_H:
|
| 924 |
+
dbg("2D_fallback - INVALID HOMOGRAPHY")
|
| 925 |
return None
|
| 926 |
|
| 927 |
try:
|
|
|
|
| 985 |
fwd_vec = impact_ctr - addr_ctr
|
| 986 |
# If forward (address->impact) projects negative, flip target axis
|
| 987 |
if float(np.dot(fwd_vec, target_hat_2d)) < 0.0:
|
| 988 |
+
dbg("[HIP-2D] Flipped target_hat_2d based on addr→impact pelvis motion")
|
| 989 |
target_hat_2d = -target_hat_2d
|
| 990 |
|
| 991 |
# Get pelvis directions in ground plane
|
|
|
|
| 1018 |
def calculate_hip_sway_at_top(pose_data: Dict[int, List], swing_phases: Dict[str, List],
|
| 1019 |
world_landmarks: Optional[Dict[int, List]] = None) -> Union[float, None]:
|
| 1020 |
sm = smooth_landmarks(pose_data)
|
| 1021 |
+
setup_frames = _pick_stable_setup_frames(sm, swing_phases, max_frames=6)
|
| 1022 |
+
if not setup_frames: return None
|
|
|
|
|
|
|
| 1023 |
backswing_frames = swing_phases.get("backswing", [])
|
| 1024 |
if not backswing_frames: return None
|
| 1025 |
+
top_frame = _pick_top_by_wrist_height(sm, backswing_frames) or backswing_frames[-1]
|
| 1026 |
|
| 1027 |
+
H, ok, H_frame = _build_foot_only_homography(sm, setup_frames)
|
| 1028 |
+
if not ok or H is None:
|
| 1029 |
+
# simple pixel fallback with world toes scale if present
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1030 |
return _simple_pixel_sway_fallback(sm, setup_frames, top_frame, swing_phases, world_landmarks)
|
| 1031 |
|
| 1032 |
import cv2
|
|
|
|
| 1033 |
def warp2(pts):
|
| 1034 |
A = np.array(pts, dtype=np.float32).reshape(1, -1, 2)
|
| 1035 |
return cv2.perspectiveTransform(A, H)[0]
|
| 1036 |
|
| 1037 |
+
# forward axis = perp to toe line in ground plane
|
| 1038 |
toe_vecs = []
|
| 1039 |
for f in setup_frames:
|
| 1040 |
lm = sm.get(f)
|
| 1041 |
if not lm or not lm[31] or not lm[32]: continue
|
| 1042 |
+
g = warp2([[lm[31][0], lm[31][1]], [lm[32][0], lm[32][1]]])
|
| 1043 |
+
v = g[1] - g[0]
|
|
|
|
| 1044 |
n = np.linalg.norm(v)
|
| 1045 |
+
if n > 1e-6: toe_vecs.append(v/n)
|
| 1046 |
+
if not toe_vecs: return None
|
| 1047 |
+
toe_hat = np.median(np.stack(toe_vecs), axis=0); toe_hat /= (np.linalg.norm(toe_hat)+1e-9)
|
| 1048 |
+
fwd_hat = np.array([-toe_hat[1], toe_hat[0]])
|
| 1049 |
+
fwd_hat /= (np.linalg.norm(fwd_hat)+1e-9)
|
| 1050 |
+
|
| 1051 |
+
# sign disambiguation with addr→impact pelvis motion (optional but stable)
|
| 1052 |
+
imp = swing_phases.get("impact", [])
|
| 1053 |
+
cand = imp[0] if imp else backswing_frames[-1]
|
| 1054 |
+
def pelvis_ctr_g(f):
|
| 1055 |
+
lm = sm.get(f);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1056 |
if not lm or not lm[23] or not lm[24]: return None
|
| 1057 |
+
g = warp2([[lm[23][0], lm[23][1]], [lm[24][0], lm[24][1]]]);
|
| 1058 |
+
return (g[0]+g[1])/2
|
| 1059 |
+
addr_ctrs = [pelvis_ctr_g(f) for f in setup_frames]; addr_ctrs = [c for c in addr_ctrs if c is not None]
|
| 1060 |
+
imp_ctr = pelvis_ctr_g(cand)
|
| 1061 |
+
if addr_ctrs and imp_ctr is not None:
|
| 1062 |
+
addr_ctr = np.median(np.stack(addr_ctrs), axis=0)
|
| 1063 |
+
if float(np.dot(imp_ctr-addr_ctr, fwd_hat)) < 0:
|
| 1064 |
+
dbg("[SWAY] Flipped fwd_hat based on addr→impact pelvis motion")
|
| 1065 |
+
fwd_hat = -fwd_hat
|
| 1066 |
|
| 1067 |
+
setup_ctr = np.median(np.stack(addr_ctrs), axis=0)
|
| 1068 |
+
top_ctr = pelvis_ctr_g(top_frame)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1069 |
if top_ctr is None: return None
|
| 1070 |
|
| 1071 |
+
# inches calibration via world toes (if available)
|
| 1072 |
inch_scale = 1.0
|
| 1073 |
+
if world_landmarks and H_frame in world_landmarks:
|
|
|
|
|
|
|
| 1074 |
try:
|
| 1075 |
+
Lt, Rt = world_landmarks[H_frame][29], world_landmarks[H_frame][30]
|
| 1076 |
+
d_m = math.hypot(Rt[0]-Lt[0], Rt[2]-Lt[2])
|
| 1077 |
+
d_in = d_m * 39.3701
|
| 1078 |
+
lmH = sm.get(H_frame)
|
| 1079 |
+
toe_g = warp2([[lmH[31][0], lmH[31][1]], [lmH[32][0], lmH[32][1]]])
|
| 1080 |
+
d_g = float(np.linalg.norm(toe_g[1]-toe_g[0]))
|
| 1081 |
+
if d_g > 1e-3 and d_in > 1e-3: inch_scale = d_in / d_g
|
| 1082 |
+
except: pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1083 |
|
| 1084 |
+
sway_in = float(np.dot(top_ctr - setup_ctr, fwd_hat)) * inch_scale
|
| 1085 |
+
# Return signed, un-clipped value to preserve direction; clip only for UI bands if desired
|
| 1086 |
+
return float(sway_in)
|
|
|
|
| 1087 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1088 |
|
| 1089 |
|
| 1090 |
|
| 1091 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1092 |
|
| 1093 |
|
| 1094 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1095 |
|
| 1096 |
def _shaft_axis_hat(lm):
|
| 1097 |
+
"""Shaft axis proxy: line through wrists (trail->lead). Deterministic and stable."""
|
| 1098 |
+
if not lm: return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1099 |
L_WR, R_WR = 15, 16
|
| 1100 |
+
if not lm[L_WR] or not lm[R_WR] or lm[L_WR][2] < 0.3 or lm[R_WR][2] < 0.3:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1101 |
return None
|
| 1102 |
+
v = np.array([lm[L_WR][0] - lm[R_WR][0], lm[L_WR][1] - lm[R_WR][1]], float) # trail->lead
|
| 1103 |
n = np.linalg.norm(v)
|
| 1104 |
return v / n if n > 1e-6 else None
|
| 1105 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1106 |
|
| 1107 |
def _hinge2d_shaft(lm, handed='right', roll_deg=0.0):
|
| 1108 |
+
"""FIXED: Standardized wrist hinge = angle between forearm and shaft vectors.
|
| 1109 |
+
Uses proper landmark selection and avoids inconsistent angle definitions."""
|
| 1110 |
if not lm: return None
|
| 1111 |
+
|
| 1112 |
+
# FIXED: Consistent landmark selection - use elbow and wrist for forearm
|
| 1113 |
EL, WR = (13, 15) if handed=='right' else (14, 16) # lead elbow/wrist
|
| 1114 |
if not lm[EL] or not lm[WR] or lm[EL][2] < 0.5 or lm[WR][2] < 0.5:
|
| 1115 |
return None
|
| 1116 |
|
| 1117 |
+
# FIXED: Standardized definition - forearm vector from elbow to wrist
|
| 1118 |
+
forearm_vec = np.array([lm[WR][0] - lm[EL][0], lm[WR][1] - lm[EL][1]], float)
|
| 1119 |
|
| 1120 |
+
# FIXED: Better shaft proxy detection to avoid using wrong landmarks
|
| 1121 |
+
shaft_vec = _shaft_axis_hat(lm)
|
| 1122 |
+
if shaft_vec is None:
|
| 1123 |
+
# Fallback: use wrist to estimated club head position
|
| 1124 |
+
# This prevents using elbow instead of wrist as mentioned in the issue
|
| 1125 |
+
if lm[15] and lm[16] and lm[15][2] >= 0.5 and lm[16][2] >= 0.5:
|
| 1126 |
+
shaft_vec = np.array([lm[16][0] - lm[15][0], lm[16][1] - lm[15][1]], float)
|
| 1127 |
+
else:
|
| 1128 |
+
return None
|
| 1129 |
|
| 1130 |
# De-roll both vectors by -roll_deg
|
| 1131 |
cr, sr = math.cos(math.radians(roll_deg)), math.sin(math.radians(roll_deg))
|
| 1132 |
+
def deroll(v): return np.array([cr*v[0] + sr*v[1], -sr*v[0] + cr*v[1]], float)
|
| 1133 |
+
forearm_vec = deroll(forearm_vec)
|
| 1134 |
+
shaft_vec = deroll(shaft_vec)
|
| 1135 |
+
|
| 1136 |
+
# Normalize vectors
|
| 1137 |
+
forearm_norm = np.linalg.norm(forearm_vec)
|
| 1138 |
+
shaft_norm = np.linalg.norm(shaft_vec)
|
| 1139 |
+
if forearm_norm < 1e-6 or shaft_norm < 1e-6:
|
| 1140 |
return None
|
| 1141 |
|
| 1142 |
+
# FIXED: Standardized hinge angle calculation using proper dot product
|
| 1143 |
+
# hinge_angle = angle between forearm and shaft vectors
|
| 1144 |
+
cos_angle = float(np.clip(np.dot(forearm_vec, shaft_vec) / (forearm_norm * shaft_norm), -1.0, 1.0))
|
| 1145 |
+
hinge_angle = math.degrees(math.acos(cos_angle))
|
| 1146 |
+
|
| 1147 |
+
# Convert to traditional hinge measurement (complement angle for intuitive interpretation)
|
| 1148 |
+
# where 90° = perpendicular (good hinge), 0° = inline (poor hinge)
|
| 1149 |
+
raw_angle = 180.0 - hinge_angle
|
| 1150 |
+
|
| 1151 |
+
# PATCH C: Return raw final_hinge (no clip)
|
| 1152 |
+
return float(raw_angle)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1153 |
|
| 1154 |
|
| 1155 |
def _pelvis_ctr_ground_series(sm, frames, warp2):
|
|
|
|
| 1190 |
if not lm or not lm[i]: return None
|
| 1191 |
return warp2([[lm[i][0], lm[i][1]]])[0].astype(float)
|
| 1192 |
|
| 1193 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1194 |
|
| 1195 |
def _pick_p3_lead_arm_parallel(sm, backswing_frames, handed='right', tol_deg=15.0):
|
| 1196 |
"""Pick the backswing frame where the lead shoulder→lead wrist vector is most horizontal."""
|
|
|
|
| 1224 |
best_y, best = y, f
|
| 1225 |
return best
|
| 1226 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1227 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1228 |
|
| 1229 |
def _pick_top_frame_hinge(sm, backswing_frames, handed='right', roll_deg=0.0):
|
| 1230 |
"""Pick frame with maximum hinge in backswing"""
|
|
|
|
| 1261 |
else:
|
| 1262 |
return None # Truly no data
|
| 1263 |
|
| 1264 |
+
# Reuse consensus roll from shoulder tilt function
|
| 1265 |
+
def consensus_roll(lm, H=None, W=None):
|
| 1266 |
+
cands = []
|
| 1267 |
+
for iL, iR in [(L_EYE, R_EYE), (L_HIP, R_HIP)]:
|
| 1268 |
+
if lm[iL] and lm[iR]:
|
| 1269 |
+
dx = lm[iR][0] - lm[iL][0]
|
| 1270 |
+
dy = lm[iR][1] - lm[iL][1]
|
| 1271 |
+
if abs(dx)+abs(dy) > 1e-6:
|
| 1272 |
+
t = math.degrees(math.atan2(dy, dx))
|
| 1273 |
+
t = ((t + 90.0) % 180.0) - 90.0
|
| 1274 |
+
if abs(t) <= 20: cands.append(t)
|
| 1275 |
+
return float(np.median(cands)) if cands else 0.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1276 |
|
| 1277 |
setup_frames = swing_phases.get("setup", [])[:8]
|
| 1278 |
+
rolls = [consensus_roll(sm.get(f)) for f in setup_frames if sm.get(f)]
|
| 1279 |
+
roll_deg = float(np.clip(np.median(rolls) if rolls else 0.0, -10.0, 10.0))
|
| 1280 |
+
|
| 1281 |
+
# More robust top frame selection: try multiple methods
|
| 1282 |
+
top_frame = _pick_top_by_wrist_height(sm, backswing_frames, handed=handedness)
|
| 1283 |
+
if not top_frame:
|
| 1284 |
+
top_frame = _pick_top_frame_hinge(sm, backswing_frames, handed=handedness, roll_deg=roll_deg)
|
| 1285 |
+
if not top_frame and backswing_frames:
|
| 1286 |
+
# Last resort: use frame with max backswing index that has good landmarks
|
| 1287 |
+
for f in reversed(backswing_frames):
|
| 1288 |
+
lm = sm.get(f)
|
| 1289 |
+
EL, WR = (13, 15) if handedness=='right' else (14, 16)
|
| 1290 |
+
if lm and lm[EL] and lm[WR] and lm[EL][2] >= 0.5 and lm[WR][2] >= 0.5:
|
| 1291 |
+
top_frame = f
|
| 1292 |
+
dbg(f"[WRIST] Using fallback top frame: {f}")
|
| 1293 |
+
break
|
| 1294 |
|
| 1295 |
# Ensure we never have None for top_frame
|
| 1296 |
if top_frame is None and backswing_frames:
|
| 1297 |
top_frame = backswing_frames[-1]
|
| 1298 |
|
| 1299 |
+
# FIXED: Calculate hinge with ±3 frame local search for max hinge consistency
|
| 1300 |
+
hinge_top = None
|
| 1301 |
+
if top_frame is not None:
|
| 1302 |
+
# Re-evaluate within ±3 frames around top for max hinge (local search)
|
| 1303 |
+
win = [f for f in range(max(backswing_frames[0], top_frame-3),
|
| 1304 |
+
min(backswing_frames[-1], top_frame+3)+1) if f in backswing_frames]
|
| 1305 |
+
best = (top_frame, None)
|
| 1306 |
+
|
| 1307 |
+
for f in win:
|
| 1308 |
+
landmarks = sm.get(f)
|
| 1309 |
+
if landmarks:
|
| 1310 |
+
h = _hinge2d_shaft(landmarks, handedness, roll_deg)
|
| 1311 |
+
if h is not None:
|
| 1312 |
+
# h here is "radial" (180 - angle_between); convert to angle_between for sanity check
|
| 1313 |
+
angle_between = 180.0 - h
|
| 1314 |
+
if 25.0 <= angle_between <= 150.0: # plausible
|
| 1315 |
+
if best[1] is None or h > best[1]:
|
| 1316 |
+
best = (f, h)
|
| 1317 |
+
# else skip bad frames
|
| 1318 |
+
|
| 1319 |
+
top_frame, hinge_top = best
|
| 1320 |
+
|
| 1321 |
+
# Additional validation to catch landmark selection errors
|
| 1322 |
+
if hinge_top is not None and (hinge_top < 10.0 or hinge_top > 170.0):
|
| 1323 |
+
dbg(f"WARNING: Suspicious hinge angle {hinge_top:.1f}° - possible landmark error")
|
| 1324 |
+
hinge_top = np.clip(hinge_top, 30.0, 150.0) # Conservative clamp
|
| 1325 |
|
| 1326 |
+
dbg(f"top_selection: selected={top_frame}")
|
| 1327 |
|
| 1328 |
+
# FIXED: P3 frame and hinge with better validation
|
| 1329 |
p3_frame = _pick_p3_lead_arm_parallel(sm, backswing_frames, handedness)
|
| 1330 |
+
hinge_p3 = None
|
| 1331 |
+
if p3_frame is not None:
|
| 1332 |
+
p3_landmarks = sm.get(p3_frame)
|
| 1333 |
+
if p3_landmarks:
|
| 1334 |
+
hinge_p3 = _hinge2d_shaft(p3_landmarks, handedness, roll_deg)
|
| 1335 |
+
# Validate P3 hinge angle is reasonable
|
| 1336 |
+
if hinge_p3 is not None and (hinge_p3 < 20.0 or hinge_p3 > 160.0):
|
| 1337 |
+
dbg(f"WARNING: Suspicious P3 hinge angle {hinge_p3:.1f}° - possible landmark error")
|
| 1338 |
+
|
| 1339 |
+
# FIXED: Address baseline with improved landmark validation
|
| 1340 |
+
addr_frames = _pick_stable_setup_frames(sm, swing_phases, max_frames=6) or _address_frames(swing_phases)
|
| 1341 |
addr_vals = []
|
| 1342 |
for f in addr_frames[:6]:
|
| 1343 |
+
addr_landmarks = sm.get(f)
|
| 1344 |
+
if addr_landmarks:
|
| 1345 |
+
h = _hinge2d_shaft(addr_landmarks, handedness, roll_deg)
|
| 1346 |
+
# Validate address hinge is reasonable (typically 30-80°)
|
| 1347 |
+
if h is not None and 20.0 <= h <= 100.0:
|
| 1348 |
+
addr_vals.append(h)
|
| 1349 |
+
elif h is not None:
|
| 1350 |
+
dbg(f"WARNING: Excluded address hinge {h:.1f}° - outside expected range")
|
| 1351 |
hinge_addr = float(np.median(addr_vals)) if addr_vals else None
|
| 1352 |
|
| 1353 |
# If top hinge failed, borrow P3; if that also failed, borrow address
|
| 1354 |
if hinge_top is None:
|
| 1355 |
if hinge_p3 is not None:
|
| 1356 |
hinge_top = hinge_p3
|
| 1357 |
+
dbg(f"hinge_top was None, borrowed from P3: {hinge_top}")
|
| 1358 |
elif hinge_addr is not None:
|
| 1359 |
hinge_top = hinge_addr # last resort (won't be graded as "top", but avoids None)
|
| 1360 |
+
dbg(f"hinge_top was None, borrowed from address: {hinge_top}")
|
| 1361 |
|
| 1362 |
+
# Remove auto-promotion to avoid convergence bias
|
| 1363 |
+
# Keep original values to show real differences
|
| 1364 |
+
|
| 1365 |
+
# Compute θ (forearm-shaft angle) from radial deviation values AFTER all promotions
|
| 1366 |
theta_top = None if hinge_top is None else (180.0 - hinge_top)
|
| 1367 |
theta_p3 = None if hinge_p3 is None else (180.0 - hinge_p3)
|
| 1368 |
|
| 1369 |
+
# Debug prints to verify shaft angles (now matches finalized values)
|
| 1370 |
if theta_top is not None:
|
| 1371 |
+
dbg(f"[WRIST-CHECK] theta_top = {theta_top:.1f}° (angle between forearm & shaft)")
|
| 1372 |
+
|
| 1373 |
+
dbg(f"[WRIST] roll={roll_deg:.1f}°, addr={hinge_addr}, p3={hinge_p3}, top={hinge_top}, "
|
| 1374 |
f"Δp3={None if hinge_addr is None or hinge_p3 is None else hinge_p3-hinge_addr}, "
|
| 1375 |
f"Δtop={None if hinge_addr is None or hinge_top is None else hinge_top-hinge_addr}")
|
| 1376 |
|
|
|
|
| 1381 |
"addr_deg": hinge_addr,
|
| 1382 |
"delta_deg_p3": (hinge_p3 - hinge_addr) if (hinge_p3 is not None and hinge_addr is not None) else None,
|
| 1383 |
"delta_deg_top": (hinge_top - hinge_addr) if (hinge_top is not None and hinge_addr is not None) else None,
|
| 1384 |
+
# PATCH C: UI can decide which to display; keep both raw values
|
| 1385 |
"radial_deviation_deg_top": hinge_top,
|
| 1386 |
"radial_deviation_deg_p3": hinge_p3,
|
| 1387 |
# Flag outside typical pro band for P3
|
|
|
|
| 1414 |
hip_delta = hip_turn_result.get('delta_deg')
|
| 1415 |
hip_addr = hip_turn_result.get('addr_deg')
|
| 1416 |
|
| 1417 |
+
# Prefer absolute turn at impact for user-facing value
|
| 1418 |
+
display_value = hip_abs if hip_abs is not None else hip_delta
|
| 1419 |
+
value_type = "absolute" if hip_abs is not None else "delta"
|
| 1420 |
+
|
| 1421 |
+
# Grade also on absolute if available
|
| 1422 |
+
grade_basis = display_value
|
| 1423 |
+
if hip_abs is None and hip_delta is not None:
|
| 1424 |
+
grade_basis = abs(hip_delta)
|
| 1425 |
+
else:
|
| 1426 |
+
grade_basis = abs(display_value) if display_value is not None else None
|
| 1427 |
|
| 1428 |
# Get club-aware grading
|
| 1429 |
+
if grade_basis is not None:
|
| 1430 |
+
grading = grade_hip_turn(grade_basis, club)
|
| 1431 |
hip_turn_data = {
|
| 1432 |
'value': display_value,
|
| 1433 |
'abs_deg': hip_abs,
|
|
|
|
| 1457 |
wrist_final = None
|
| 1458 |
wrist_kind = None
|
| 1459 |
|
| 1460 |
+
# Prefer radial deviation (hinge), then P3 radial, then θ as fallback
|
| 1461 |
+
if wrist_top is not None:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1462 |
wrist_final = wrist_top
|
| 1463 |
wrist_kind = "radial_deviation_top"
|
| 1464 |
+
elif wrist_p3 is not None:
|
| 1465 |
wrist_final = wrist_p3
|
| 1466 |
wrist_kind = "radial_deviation_p3"
|
| 1467 |
+
elif wrist_top_theta is not None:
|
| 1468 |
+
wrist_final = wrist_top_theta
|
| 1469 |
+
wrist_kind = "forearm_shaft_angle_top"
|
| 1470 |
+
else:
|
| 1471 |
+
wrist_final = wrist_p3_theta
|
| 1472 |
+
wrist_kind = "forearm_shaft_angle_p3"
|
| 1473 |
|
| 1474 |
+
# Add visible version tag and debug info
|
| 1475 |
+
print(f"[FF] {METRICS_VERSION} running (debug={'on' if VERBOSE else 'off'})")
|
| 1476 |
+
|
| 1477 |
+
# Log metrics summary for debugging
|
| 1478 |
+
dbg(f"[METRIC] tilt={shoulder_tilt_impact}, hip_turn_abs={hip_turn_result.get('abs_deg') if hip_turn_result else None}, sway_top_in={hip_sway_top}, hinge_top={wrist_hinge_result.get('radial_deviation_deg_top') if wrist_hinge_result else None}")
|
| 1479 |
+
|
| 1480 |
front_facing_metrics = {
|
| 1481 |
'shoulder_tilt_impact_deg': {'value': shoulder_tilt_impact},
|
| 1482 |
'hip_turn_impact_deg': hip_turn_data,
|
|
|
|
| 1492 |
'delta_p3_deg': delta_p3,
|
| 1493 |
'delta_top_deg': delta_top,
|
| 1494 |
'definition_suspect': definition_suspect
|
| 1495 |
+
},
|
| 1496 |
+
'__version__': METRICS_VERSION,
|
| 1497 |
+
'_debug': {
|
| 1498 |
+
'version': METRICS_VERSION,
|
| 1499 |
+
'used_world': bool(world_landmarks),
|
| 1500 |
+
'setup_frames': swing_phases.get('setup', [])[:8],
|
| 1501 |
+
'impact_frames': swing_phases.get('impact', []),
|
| 1502 |
}
|
| 1503 |
}
|
| 1504 |
|