chenemii commited on
Commit
f6ccbdd
·
1 Parent(s): fb73316

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

Files changed (1) hide show
  1. 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
- def get_landmark_position(landmarks: List, landmark_idx: int) -> Optional[Tuple[float, float, float]]:
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
- Shoulder Tilt @ Impact (front-facing).
96
- +ve if trail shoulder lower (right shoulder for right-handed).
97
- FIXED: Uses image-plane only for shoulder tilt; 3D only for frame rejection.
 
 
 
 
 
 
 
 
 
98
  """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
99
 
100
- import math, numpy as np
101
- L_EYE, R_EYE = 2, 5
102
- L_HIP, R_HIP = 23, 24
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
- dx_raw = lm[iR][0] - lm[iL][0]
136
- dy_raw = lm[iR][1] - lm[iL][1]
137
- dx, dy = _scale_xy(dx_raw, dy_raw, H, W)
138
- if abs(dx) + abs(dy) > 1e-8:
139
- tilt = math.degrees(math.atan2(dy, dx))
140
- tilt = ((tilt + 90.0) % 180.0) - 90.0 # (-90,90]
141
- if abs(tilt) <= 20.0: # Allow up to 20° roll
142
- cand.append(tilt)
 
 
 
 
 
 
 
 
 
143
 
144
- # Always return median if we have candidates, don't force 0
145
- if cand:
146
- return float(np.median(cand))
147
- return 0.0
148
-
149
- def _get_HW():
150
- # prefer frames; else image_shape; else no correction
151
- if frames is not None and len(frames):
152
- H, W = frames[0].shape[:2]
153
- return float(H), float(W)
154
- if image_shape is not None and len(image_shape) >= 2:
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
- # Visibility check
184
- if lm[L_SHO][2] < 0.6 or lm[R_SHO][2] < 0.6:
185
- return False, "low_visibility"
186
 
187
- # Shoulder width check (>8% of frame width)
188
- if W is not None:
189
- dx_raw = abs(lm[R_SHO][0] - lm[L_SHO][0])
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
- return True, "ok"
195
-
196
- def _estimate_torso_yaw_3d(world_frame):
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
- dx = world_frame[R_SHO][0] - world_frame[L_SHO][0]
202
- dz = world_frame[R_SHO][2] - world_frame[L_SHO][2]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
203
 
204
- if abs(dx) + abs(dz) < 1e-8:
205
- return None
206
 
207
- # Rough yaw estimate - angle from frontal (dz=0)
208
- yaw = abs(math.degrees(math.atan2(abs(dz), abs(dx))))
209
- return yaw
210
 
211
- # ---------- pick exact impact frame ----------
212
- # B. Don't override provided impact
213
- impact_frames = swing_phases.get("impact", [])
214
- if impact_frames:
215
- impact_f = impact_frames[0]
216
- else:
217
- impact_f = _impact_frame(world_landmarks or pose_data, swing_phases)
218
- if impact_f is None:
219
- allf = sorted(pose_data.keys())
220
- if not allf:
221
- return None
222
- impact_f = allf[-1]
223
-
224
- # Try subframe refinement if available
225
- if world_landmarks and impact_f is not None:
226
- f_refined = _subframe_impact_refinement(world_landmarks, impact_f)
227
- # Use integer part for now, but structure supports fractional
228
- impact_f = int(round(f_refined))
229
-
230
- # Get the refined impact frame data
231
- if impact_f not in pose_data:
 
 
 
 
 
 
 
232
  return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
233
 
234
- lm = pose_data[impact_f]
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
- sho_widths = []
279
- for f in setup_frames:
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
- dx_ref = float(np.median(sho_widths)) if sho_widths else sho_dx_r
 
 
 
 
287
 
288
- # 2D tilt using current shoulder width
289
- tilt2d_mag = math.degrees(math.atan2(abs(sho_dy_r), max(sho_dx_r, 1e-3)))
290
- sign_2d = 1 if sho_dy_r > 0 else -1
291
- if handedness == 'left':
292
- sign_2d *= -1
293
- tilt2d = sign_2d * tilt2d_mag
294
-
295
- # ---------- 2D image-plane tilt (already computed above): tilt2d ----------
296
-
297
- use_3d = bool(world_landmarks and impact_f in world_landmarks)
298
- tilt3d_cor, path3d = None, None
299
- v_comp, l_comp = None, None
300
-
301
- if use_3d:
302
- wf = world_landmarks.get(impact_f)
303
- if wf and wf[L_SHO] and wf[R_SHO]:
304
- # World shoulder vector
305
- dx3 = wf[R_SHO][0] - wf[L_SHO][0] # X (right)
306
- dy3 = wf[R_SHO][1] - wf[L_SHO][1] # Y (up, camera-space)
307
- dz3 = wf[R_SHO][2] - wf[L_SHO][2] # Z (forward)
308
-
309
- # 1) De-roll world coords so Y ≈ gravity (use setup roll you already computed)
310
- cr, sr = math.cos(math.radians(roll)), math.sin(math.radians(roll))
311
- dx3g = cr*dx3 + sr*dy3
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
- v_comp, l_comp = None, None
374
-
375
- # ---------- Choose final ----------
376
- # Prefer the coronal 3D when yaw is meaningful; otherwise use 2D (derolled).
377
- if tilt3d_cor is not None and (yaw is None or yaw > 25.0):
378
- tilt_final, path = tilt3d_cor, path3d
379
- else:
380
- tilt_final, path = tilt2d, "2D"
381
-
382
- # Neighbor median smoothing if wild
383
- if not (8.0 <= abs(tilt_final) <= 80.0):
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
- Image pts: L_heel(31), R_heel(32), L_toe(29), R_toe(30)
 
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[31][0], lm[31][1]], # L heel
585
- [lm[32][0], lm[32][1]], # R heel
586
- [lm[29][0], lm[29][1]], # L toe
587
- [lm[30][0], lm[30][1]], # R toe
588
  ], dtype=np.float32)
589
- # A reasonable shoe: heel-to-toe depth ~11" and stance width ~ 16" default box
590
- # (We will re-scale to true inches using 3D toe spacing shortly.)
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 = 29, 30
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
- print(f"DEBUG pixel_fallback: calibrated px_per_inch={px_per_inch:.2f} from 3D toes")
682
  except Exception:
683
  pass
684
 
685
  sway_inches = sway_px / px_per_inch
686
- print(f"DEBUG pixel_fallback: sway_px={sway_px:.1f}, px_per_inch={px_per_inch:.2f}, sway_in={sway_inches:.2f}")
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
- print(f"DEBUG: subframe_refinement - parabola_coeffs=[{a:.6f}, {b:.6f}, {c:.6f}]")
758
- print(f"DEBUG: subframe_refinement - peak_frame={peak_frame_fractional:.2f}, impact_refined={impact_fractional:.2f}")
759
 
760
  return impact_fractional
761
 
762
  except Exception as e:
763
- print(f"DEBUG: subframe_refinement - failed: {e}, using integer frame")
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
- pose_data: Dict[int, List],
856
- swing_phases: Dict[str, List],
857
- world_landmarks: Optional[Dict[int, List]] = None,
858
- frames: Optional[List[np.ndarray]] = None
859
- ) -> Union[Dict[str, Union[float, None]], None]:
860
- """
861
- Hip turn @ impact: TARGET-RELATIVE degrees (positive = open to target).
862
- Returns dict with both absolute and delta (change from address) values.
863
- Fixed to use target line reference instead of camera-relative angles.
864
- """
865
- PLAUSIBLE = 70.0
866
-
867
- if not world_landmarks:
868
- # Fallback: calculate from 2D pose data only
869
- fallback_result = _calculate_hip_turn_2d_fallback(pose_data, swing_phases, _impact_frame(pose_data, swing_phases))
870
- return {
871
- "abs_deg": fallback_result,
872
- "delta_deg": None,
873
- "addr_deg": None,
874
- } if fallback_result is not None else None
875
-
876
- # Get setup frames for target axis and baseline
877
- setup_frames = swing_phases.get("setup", [])[:10]
878
- if not setup_frames:
879
- # Fallback: use first available frames as "setup"
880
- all_frames = sorted(world_landmarks.keys())
881
- if all_frames:
882
- setup_frames = all_frames[:min(5, len(all_frames))]
883
- else:
884
- fallback_result = _calculate_hip_turn_2d_fallback(pose_data, swing_phases, _impact_frame(pose_data, swing_phases))
885
- return {
886
- "abs_deg": fallback_result,
887
- "delta_deg": None,
888
- "addr_deg": None,
889
- } if fallback_result is not None else None
890
-
891
- # Build stable target axis from toe line
892
- target_hat = _target_hat_from_toes_world(world_landmarks, setup_frames, handedness='right')
893
-
894
- # Impact frame detection
895
- impact_f = _impact_frame(world_landmarks, swing_phases)
896
- if impact_f is None or impact_f not in world_landmarks:
897
- # Fallback: use 2D calculation
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
- cx = 0.5 * (wf[L_HIP][0] + wf[R_HIP][0])
911
- cz = 0.5 * (wf[L_HIP][2] + wf[R_HIP][2])
912
- return np.array([cx, cz], float)
913
 
914
- addr_ctrs = [_pelvis_ctr_xz(f) for f in setup_frames]
915
  addr_ctrs = [c for c in addr_ctrs if c is not None]
916
- imp_ctr = _pelvis_ctr_xz(impact_f)
917
-
918
  if addr_ctrs and imp_ctr is not None:
919
- addr_ctr = np.median(np.stack(addr_ctrs), axis=0)
920
- fwd_vec = imp_ctr - addr_ctr
921
- if float(np.dot(fwd_vec, np.array(target_hat, float))) < 0.0:
922
- target_hat = (-target_hat[0], -target_hat[1])
923
- print("DEBUG hip_turn: flipped target_hat by addr->impact forward motion")
924
-
925
- # Get pelvis direction at impact
926
- impact_pelvis_hat = pelvis_hat(world_landmarks[impact_f])
927
-
928
- if impact_pelvis_hat is None:
929
- # Fallback: use 2D calculation
930
- fallback_result = _calculate_hip_turn_2d_fallback(pose_data, swing_phases, impact_f)
931
- return {
932
- "abs_deg": fallback_result,
933
- "delta_deg": None,
934
- "addr_deg": None,
935
- } if fallback_result is not None else None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
936
 
937
- # ±2 frame median for stability at impact (expanded window)
938
- impact_frames_nearby = [f for f in range(impact_f-2, impact_f+3) if f in world_landmarks]
939
- if len(impact_frames_nearby) > 1:
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
- # Hip orientation (absolute) vs target at impact
953
- hip_turn_abs = wrap180(impact_rel)
 
954
 
955
- # NEW: compute address (baseline) and delta turn
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, reason = _build_ground_homography(sm, setup_frames)
1063
  if not valid_H:
1064
- print(f"DEBUG: 2D_fallback - INVALID HOMOGRAPHY: {reason}")
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
- # (1) robust address & top
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 = _pick_top_frame_hinge(sm, backswing_frames, handed='right', roll_deg=0.0) or backswing_frames[-1]
1167
 
1168
- # (2) foot-only homography (no hips); reduces circularity/instability
1169
- H, valid_H, H_frame = _build_foot_only_homography(sm, initial_setup_frames)
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
- # (3) Compute and normalize axes once - reuse for everything
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
- toe_img = [[lm[31][0], lm[31][1]], [lm[32][0], lm[32][1]]]
1194
- toe_g = warp2(toe_img)
1195
- v = toe_g[1] - toe_g[0]
1196
  n = np.linalg.norm(v)
1197
- if n > 1e-6:
1198
- toe_vecs.append(v / n)
1199
- if not toe_vecs:
1200
- return None
1201
-
1202
- # Normalize axes once and reuse
1203
- toe_hat_g = np.median(np.stack(toe_vecs), axis=0)
1204
- toe = toe_hat_g / (np.linalg.norm(toe_hat_g) + 1e-9) # lateral axis
1205
- # Target line is PERPENDICULAR to toe line (down-the-line toward the flag)
1206
- target_hat_g = np.array([-toe[1], toe[0]], dtype=float)
1207
- tgt = target_hat_g / (np.linalg.norm(target_hat_g) + 1e-9) # forward axis
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] + g[1]) / 2
 
 
 
 
 
 
 
1222
 
1223
- if candidate_fwd is not None:
1224
- addr_ctrs = [pelvis_center_ground(f) for f in setup_frames]
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
- # (6) ***Calibrate inches*** with world toes if available
1242
  inch_scale = 1.0
1243
- if world_landmarks is not None and H_frame in world_landmarks:
1244
- wf = world_landmarks[H_frame]
1245
- # 3D toe XZ spacing (meters) → inches
1246
  try:
1247
- L_FOOT_INDEX, R_FOOT_INDEX = 29, 30
1248
- Lt, Rt = wf[L_FOOT_INDEX], wf[R_FOOT_INDEX]
1249
- if Lt and Rt:
1250
- d_m = math.hypot(Rt[0]-Lt[0], Rt[2]-Lt[2]) # XZ
1251
- d_in_true = d_m * 39.3701
1252
- # 2D ground length from homography at same frame:
1253
- lmH = sm.get(H_frame)
1254
- toe_img = [[lmH[31][0], lmH[31][1]], [lmH[32][0], lmH[32][1]]]
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
- # Prefer pelvis center for UI (matches coach interpretation of "hip sway")
1292
- # Use forward/target axis (fwd_in) instead of lateral axis (lat_in) to match benchmarks
1293
- sway_inches = fwd_in # pelvis center
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
- """Grip/shaft axis vector from left-hand centroid to right-hand centroid,
1341
- falling back to wrist→wrist if needed."""
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
- idxsL = [15, 19, 21] # L wrist/index/thumb
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
- """Hinge = 180° - angle(forearm, shaft_proxy) with roll derotation."""
 
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
- # Forearm vector
1402
- u = np.array([lm[WR][0]-lm[EL][0], lm[WR][1]-lm[EL][1]], float)
1403
 
1404
- # Better shaft proxy
1405
- s_hat = _shaft_axis_hat(lm)
1406
- if s_hat is None:
1407
- return None
 
 
 
 
 
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 R(v): return np.array([cr*v[0] + sr*v[1], -sr*v[0] + cr*v[1]], float)
1412
- u, s = R(u), R(s_hat)
1413
-
1414
- nu, ns = np.linalg.norm(u), np.linalg.norm(s)
1415
- if nu < 1e-6 or ns < 1e-6:
 
 
 
1416
  return None
1417
 
1418
- c = float(np.clip(np.dot(u, s)/(nu*ns), -1, 1))
1419
- theta = math.degrees(math.acos(c)) # 0..180 (90 when perpendicular)
1420
- hinge = 180.0 - theta # 0 ~ inline, 90 ~ L-shape
1421
-
1422
- # If hinge still looks unrealistic (too large), try a fallback axis:
1423
- if hinge > 120.0:
1424
- # Use pure wrist→wrist as alternate (if we weren't already using it)
1425
- if lm[15] and lm[16] and lm[15][2] >= 0.5 and lm[16][2] >= 0.5:
1426
- ww = np.array([lm[16][0]-lm[15][0], lm[16][1]-lm[15][1]], float)
1427
- # de-roll
1428
- ww = np.array([cr*ww[0] + sr*ww[1], -sr*ww[0] + cr*ww[1]], float)
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
- def _lead_hip_sway(sm, addr_frames, top_frame, warp2, lateral_hat_g):
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
- # Estimate camera roll from setup frames using eyes/hips
1596
- def _get_roll_deg(lm):
1597
- """Camera roll from eyes/hips with unit detection"""
1598
- if not lm:
1599
- return 0.0
1600
- cand = []
1601
- for line_idxs in [(L_EYE, R_EYE), (L_HIP, R_HIP)]:
1602
- iL, iR = line_idxs
1603
- if iL < len(lm) and iR < len(lm) and lm[iL] and lm[iR]:
1604
- dx_raw = lm[iR][0] - lm[iL][0]
1605
- dy_raw = lm[iR][1] - lm[iL][1]
1606
- if abs(dx_raw) + abs(dy_raw) > 1e-8:
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
- for f in setup_frames:
1616
- lmf = sm.get(f)
1617
- if lmf:
1618
- rolls.append(_get_roll_deg(lmf))
1619
- roll_deg = float(np.median(rolls)) if rolls else 0.0
1620
- roll_deg = float(np.clip(roll_deg, -10.0, 10.0))
1621
-
1622
- # Top frame selection with multiple fallbacks (never return None)
1623
- cand_hinge = _pick_top_frame_hinge(sm, backswing_frames, handed=handedness, roll_deg=roll_deg)
1624
- cand_wrist = _pick_top_by_wrist_height(sm, backswing_frames, handed=handedness)
1625
- cand_shoulder = _pick_top_by_shoulder_turn(sm, backswing_frames, handed=handedness)
1626
- cand_fallback = _pick_top_robust_fallback(sm, backswing_frames, handed=handedness)
1627
-
1628
- # Primary: wrist height, Secondary: shoulder turn, Tertiary: hinge, Final: 2/3 point
1629
- top_frame = cand_wrist or cand_shoulder or cand_hinge or cand_fallback
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 (should never be None now)
1636
- hinge_top = _hinge2d_shaft(sm.get(top_frame), handedness, roll_deg) if top_frame is not None else None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1637
 
1638
- print(f"DEBUG top_selection: cand_wrist={cand_wrist}, cand_shoulder={cand_shoulder}, cand_hinge={cand_hinge}, selected={top_frame}")
1639
 
1640
- # NEW: P3 frame and hinge
1641
  p3_frame = _pick_p3_lead_arm_parallel(sm, backswing_frames, handedness)
1642
- hinge_p3 = _hinge2d_shaft(sm.get(p3_frame), handedness, roll_deg) if p3_frame is not None else None
1643
-
1644
- # Address baseline (use shaft-based hinge to avoid "palm vector too short")
1645
- addr_frames = _pick_still_address_by_hand(sm, swing_phases, handedness, max_frames=6) or _address_frames(swing_phases)
 
 
 
 
 
 
 
1646
  addr_vals = []
1647
  for f in addr_frames[:6]:
1648
- h = _hinge2d_shaft(sm.get(f), handedness, roll_deg)
1649
- if h is not None: addr_vals.append(h)
 
 
 
 
 
 
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
- print(f"DEBUG: hinge_top was None, borrowed from P3: {hinge_top}")
1657
  elif hinge_addr is not None:
1658
  hinge_top = hinge_addr # last resort (won't be graded as "top", but avoids None)
1659
- print(f"DEBUG: hinge_top was None, borrowed from address: {hinge_top}")
1660
 
1661
- # Compute θ (forearm-shaft angle) from radial deviation values
 
 
 
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
- print(f"[WRIST-CHECK] theta_top = {theta_top:.1f}° (angle between forearm & shaft)")
1668
-
1669
- print(f"[WRIST] roll={roll_deg:.1f}°, addr={hinge_addr}, p3={hinge_p3}, top={hinge_top}, "
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
- # Keep the old radial deviation fields if still needed internally:
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 delta if available, fallback to absolute
1714
- display_value = hip_delta if hip_delta is not None else hip_abs
1715
- value_type = "delta" if hip_delta is not None else "absolute"
 
 
 
 
 
 
 
1716
 
1717
  # Get club-aware grading
1718
- if display_value is not None:
1719
- grading = grade_hip_turn(display_value, club)
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 forearm-shaft angle θ (what coaches expect), then fallback to radial deviation
1750
- if wrist_top_theta is not None:
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
- else:
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), = 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