chenemii commited on
Commit
830ceae
·
1 Parent(s): 7ad8b8c

Fix 3D shoulder tilt calculation with target axis refinement

Browse files

- Fix target axis to use perpendicular to toe line instead of toe line itself
- Fix sign logic so positive means trail shoulder lower for right-handed golfers
- Add ±12° micro-refinement to counter foot-flare bias and minimize lateral contamination
- Loosen roll clamp from ±6° to ±10° for better gravity alignment
- Should now produce pro-range values (32-40°) instead of mid-20s

app/models/coach_prompt.md CHANGED
@@ -24,17 +24,17 @@ Use these new professional/amateur benchmarks for scoring. These represent updat
24
  ### **NEW DTL METRICS:**
25
 
26
  **Shoulder Tilt/Swing Plane Angle @ Top:**
27
- - Professional = 36°
28
  - 30 Handicap = 29°
29
 
30
  **Back Tilt (°):**
31
- - To be measured and calibrated
32
 
33
  **Knee Flexion (°):**
34
- - To be measured and calibrated
35
 
36
- **Hip Depth / Early Extension (cm or %):**
37
- - To be measured and calibrated
38
 
39
  # Hip Turn @ Impact metric removed from DTL analysis
40
 
@@ -106,12 +106,11 @@ Use these new professional/amateur benchmarks for scoring. These represent updat
106
  ### **UPDATED METRICS CALIBRATION:**
107
  **New Core Biomechanical Metrics:**
108
  - **Shoulder Tilt @ Impact**: Professional = 39°, 30 Handicap = 27°
109
- - **Hip Turn @ Impact**: Professional = 36°, 30 Handicap = 19°
110
  - **Hip Sway @ Top**: Professional = 3.9" towards target, 30 Handicap = 2.5" towards target
111
  - **Shoulder Tilt/Swing Plane @ Top**: Professional = 36°, 30 Handicap = 29°
112
- - **Back Tilt @ Setup**: To be calibrated with new data
113
- - **Knee Flexion @ Setup**: To be calibrated with new data
114
- - **Hip Depth / Early Extension**: To be calibrated with new data
115
 
116
  ## CURRENT SWING ANALYSIS
117
 
@@ -159,14 +158,14 @@ For each of the new metrics below, write exactly 3 sentences evaluating the metr
159
  **1. Shoulder Tilt/Swing Plane @ Top Evaluation:**
160
  [3 sentences about shoulder tilt/swing plane angle at top - professional = 36°, 30 handicap = 29°]
161
 
162
- **2. Back Tilt @ Setup Evaluation:**
163
- [3 sentences about spine forward tilt angle during setup position]
164
 
165
- **3. Knee Flexion @ Setup Evaluation:**
166
- [3 sentences about knee flexion angle during setup position]
167
 
168
- **4. Hip Depth / Early Extension Evaluation:**
169
- [3 sentences about hip depth maintenance and early extension]
170
 
171
  # Hip Turn @ Impact evaluation removed from DTL analysis
172
 
@@ -187,9 +186,9 @@ For each of the new metrics below, write exactly 3 sentences evaluating the metr
187
  | Metric | Professional Standard | 30 Handicap Standard | Note |
188
  |--------|----------------------|---------------------|------|
189
  | Shoulder Tilt/Swing Plane @ Top | 36° | 29° | Shoulder tilt/swing plane angle at top |
190
- | Back Tilt @ Setup | To be calibrated | To be calibrated | Forward spine tilt from vertical |
191
- | Knee Flexion @ Setup | To be calibrated | To be calibrated | Athletic stance flexion |
192
- | Hip Depth / Early Extension | To be calibrated | To be calibrated | Hip depth maintenance |
193
  # Hip Turn @ Impact metric removed from DTL analysis
194
 
195
  **Classification Bands:**
@@ -212,6 +211,6 @@ For each of the new metrics below, write exactly 3 sentences evaluating the metr
212
  - Focus on biomechanics and compare actual values to the new professional ranges (39° shoulder tilt, etc.) - Hip turn removed from DTL metrics
213
  - Consider common amateur swing issues when evaluating: over-swinging, over-the-top path, lack of weight transfer, early extension, lifting the ball, wrist casting, head movement/standing up
214
  - Look for combinations of swing problems that often compound each other in amateur golfers
215
- - Use the new calibration values: Professional = 39° shoulder tilt, 3.9" hip sway vs 30 Handicap = 27° shoulder tilt, 2.5" hip sway (Hip turn removed from DTL metrics)
216
 
217
 
 
24
  ### **NEW DTL METRICS:**
25
 
26
  **Shoulder Tilt/Swing Plane Angle @ Top:**
27
+ - Professional = 36° (Iron avg: 34.3°, Driver avg: 30.5°)
28
  - 30 Handicap = 29°
29
 
30
  **Back Tilt (°):**
31
+ - Professional = 31° (Iron avg: 30.8°, Driver avg: 32.3°)
32
 
33
  **Knee Flexion (°):**
34
+ - Professional = 22° (Iron avg: 21.6°, Driver avg: 27.2°)
35
 
36
+ **Head Drop/Rise @ Top (%):**
37
+ - Professional = 0-5% drop (Iron: 0-7.6% range, Driver: 4.6-5.5% drop)
38
 
39
  # Hip Turn @ Impact metric removed from DTL analysis
40
 
 
106
  ### **UPDATED METRICS CALIBRATION:**
107
  **New Core Biomechanical Metrics:**
108
  - **Shoulder Tilt @ Impact**: Professional = 39°, 30 Handicap = 27°
 
109
  - **Hip Sway @ Top**: Professional = 3.9" towards target, 30 Handicap = 2.5" towards target
110
  - **Shoulder Tilt/Swing Plane @ Top**: Professional = 36°, 30 Handicap = 29°
111
+ - **Back Tilt @ Top**: Professional = 31°
112
+ - **Knee Flexion @ Top**: Professional = 22°
113
+ - **Head Drop/Rise @ Top**: Professional = 0-5% drop
114
 
115
  ## CURRENT SWING ANALYSIS
116
 
 
158
  **1. Shoulder Tilt/Swing Plane @ Top Evaluation:**
159
  [3 sentences about shoulder tilt/swing plane angle at top - professional = 36°, 30 handicap = 29°]
160
 
161
+ **2. Back Tilt @ Top Evaluation:**
162
+ [3 sentences about spine forward tilt angle at top of backswing - professional = 31°]
163
 
164
+ **3. Knee Flexion @ Top Evaluation:**
165
+ [3 sentences about knee flexion angle at top of backswing - professional = 22°]
166
 
167
+ **4. Head Drop/Rise @ Top Evaluation:**
168
+ [3 sentences about head movement percentage at top of backswing - professional = 0-5% drop]
169
 
170
  # Hip Turn @ Impact evaluation removed from DTL analysis
171
 
 
186
  | Metric | Professional Standard | 30 Handicap Standard | Note |
187
  |--------|----------------------|---------------------|------|
188
  | Shoulder Tilt/Swing Plane @ Top | 36° | 29° | Shoulder tilt/swing plane angle at top |
189
+ | Back Tilt @ Top | 31° | To be calibrated | Forward spine tilt at top of backswing |
190
+ | Knee Flexion @ Top | 22° | To be calibrated | Knee flexion angle at top of backswing |
191
+ | Head Drop/Rise @ Top | 0-5% drop | To be calibrated | Head movement percentage at top |
192
  # Hip Turn @ Impact metric removed from DTL analysis
193
 
194
  **Classification Bands:**
 
211
  - Focus on biomechanics and compare actual values to the new professional ranges (39° shoulder tilt, etc.) - Hip turn removed from DTL metrics
212
  - Consider common amateur swing issues when evaluating: over-swinging, over-the-top path, lack of weight transfer, early extension, lifting the ball, wrist casting, head movement/standing up
213
  - Look for combinations of swing problems that often compound each other in amateur golfers
214
+ - Use the new calibration values: Professional = 39° shoulder tilt @ impact, 36° shoulder tilt @ top, 31° back tilt @ top, 22° knee flexion @ top, 0-5% head drop @ top vs 30 Handicap = 27° shoulder tilt @ impact, 29° shoulder tilt @ top
215
 
216
 
app/models/front_facing_metrics.py CHANGED
@@ -103,14 +103,16 @@ def calculate_shoulder_tilt_at_impact(
103
  L_SHO, R_SHO = 11, 12
104
 
105
  # ---------- helpers ----------
106
- def _scale_xy(x, y, H, W):
107
- """Unit detection guard to prevent double-scaling"""
108
  if H is None or W is None:
109
- return x, y
110
- # If values look like pixels (>>2), don't rescale
111
- if max(abs(x), abs(y), 1.0) > 2.0:
112
- return x, y
113
- return x * W, y * H
 
 
114
 
115
  def _line_tilt_deg(lm, iL, iR):
116
  # raw image tilt (no aspect correction); used only for small-roll estimate
@@ -136,12 +138,13 @@ def calculate_shoulder_tilt_at_impact(
136
  if abs(dx) + abs(dy) > 1e-8:
137
  tilt = math.degrees(math.atan2(dy, dx))
138
  tilt = ((tilt + 90.0) % 180.0) - 90.0 # (-90,90]
139
- if abs(tilt) <= 8.0: # Only small rolls
140
  cand.append(tilt)
141
 
142
- if len(cand) < 2 or np.std(cand) > 3.0:
143
- return 0.0
144
- return float(np.median(cand))
 
145
 
146
  def _get_HW():
147
  # prefer frames; else image_shape; else no correction
@@ -163,12 +166,13 @@ def calculate_shoulder_tilt_at_impact(
163
  """Get derolled horizontal width and vertical separation in pixels"""
164
  if not lm or not lm[iL] or not lm[iR]:
165
  return 0.0, 0.0
166
- xL, yL = lm[iL][0], lm[iL][1]
167
- xR, yR = lm[iR][0], lm[iR][1]
 
168
  cr, sr = math.cos(math.radians(roll_deg)), math.sin(math.radians(roll_deg))
169
- # rotate by -roll
170
- dx = (cr*(xR-xL) + sr*(yR-yL))
171
- dy = (-sr*(xR-xL) + cr*(yR-yL))
172
  return abs(dx), dy # horizontal width (derolled), vertical (derolled)
173
 
174
  def _check_frame_quality(lm, H, W):
@@ -205,28 +209,23 @@ def calculate_shoulder_tilt_at_impact(
205
  return yaw
206
 
207
  # ---------- pick exact impact frame ----------
208
- if world_landmarks:
209
- setup_frames = swing_phases.get("setup", [])
210
- impact_f = _impact_frame(world_landmarks, swing_phases)
211
- if impact_f is None:
212
- impact_frames = swing_phases.get("impact", [])
213
- if impact_frames:
214
- impact_f = impact_frames[0]
215
-
216
- # Try subframe refinement if available
217
- if impact_f is not None:
218
- f_refined = _subframe_impact_refinement(world_landmarks, impact_f)
219
- # Use integer part for now, but structure supports fractional
220
- impact_f = int(round(f_refined))
221
  else:
222
- impact_frames = swing_phases.get("impact", [])
223
- if not impact_frames:
224
  allf = sorted(pose_data.keys())
225
  if not allf:
226
  return None
227
  impact_f = allf[-1]
228
- else:
229
- impact_f = impact_frames[0]
 
 
 
 
230
 
231
  # Get the refined impact frame data
232
  if impact_f not in pose_data:
@@ -242,12 +241,14 @@ def calculate_shoulder_tilt_at_impact(
242
  # Still continue but mark as suspect
243
 
244
  # 3D yaw check for frame rejection only
 
245
  yaw_ok = True
 
246
  if world_landmarks and impact_f in world_landmarks:
247
  yaw = _estimate_torso_yaw_3d(world_landmarks[impact_f])
248
- if yaw is not None and yaw > 30.0:
249
  yaw_ok = False
250
- print(f"DEBUG shoulder_tilt: rejecting_frame - torso_yaw={yaw:.1f}° > 30°")
251
 
252
  # ---------- 2D image-plane calculation (primary method) ----------
253
  # Get shoulder positions with unit detection
@@ -259,135 +260,146 @@ def calculate_shoulder_tilt_at_impact(
259
 
260
  dx, dy = _scale_xy(dx_raw, dy_raw, H, W)
261
 
262
- # Camera roll correction (minimal smoothing - use single frame)
263
- alpha_micro = 0.8 # Much lighter smoothing for impact
264
- lm_for_roll = [lm[i][:] if lm[i] else None for i in range(len(lm))] # Copy landmarks
265
- if impact_f > 0 and (impact_f - 1) in pose_data:
266
- prev_lm = pose_data[impact_f - 1]
267
- # Light smoothing only if previous frame available
268
- if prev_lm and prev_lm[L_SHO] and prev_lm[R_SHO]:
269
- for i in [L_SHO, R_SHO, L_EYE, R_EYE, L_HIP, R_HIP]:
270
- if lm_for_roll[i] and prev_lm[i]:
271
- lm_for_roll[i] = [
272
- alpha_micro * lm[i][0] + (1 - alpha_micro) * prev_lm[i][0],
273
- alpha_micro * lm[i][1] + (1 - alpha_micro) * prev_lm[i][1],
274
- lm[i][2] # Keep original visibility
275
- ]
276
-
277
- roll = _consensus_roll_deg(lm_for_roll, H, W)
278
 
279
- # Hybrid de-yaw shoulder tilt calculation
280
- # Derolled shoulder and pelvis widths (pixels)
281
  sho_dx_r, sho_dy_r = _width_px_derolled(lm, L_SHO, R_SHO, roll)
282
- pel_dx_r, _ = _width_px_derolled(lm, L_HIP, R_HIP, roll)
283
-
284
- # Get setup pelvis width median as scale anchor (use only good frames)
285
  setup_frames = swing_phases.get("setup", [])[:8]
286
- setup_pelvis_widths = []
287
  for f in setup_frames:
288
  lmf = pose_data.get(f)
289
- if not lmf or not lmf[L_HIP] or not lmf[R_HIP]:
290
- continue
291
- # prefer low-yaw frames if world_landmarks are available
292
- if world_landmarks and f in world_landmarks:
293
- _yaw = _estimate_torso_yaw_3d(world_landmarks[f]) or 0.0
294
- if _yaw > 25.0:
295
- continue
296
- w, _ = _width_px_derolled(lmf, L_HIP, R_HIP, roll)
297
- if w > 1.0:
298
- setup_pelvis_widths.append(w)
299
-
300
- pel_setup_med = float(np.median(setup_pelvis_widths)) if setup_pelvis_widths else None
301
-
302
- # Decide if we must de-yaw: narrow shoulders or big yaw
303
- need_deyaw = (W is not None and sho_dx_r < 0.08*W) or (world_landmarks and not yaw_ok)
304
-
305
- # Build corrected horizontal width
306
- dx_corr = sho_dx_r
307
- deyaw_ratio = 1.0
308
- if need_deyaw and pel_setup_med and pel_dx_r > 1.0:
309
- # De-yaw using pelvis scale ratio; clamp to avoid blowups
310
- deyaw_ratio = pel_setup_med / pel_dx_r
311
- deyaw_ratio = max(1.0, min(deyaw_ratio, 8.0)) # 1×..8×
312
- dx_corr = sho_dx_r * deyaw_ratio
313
-
314
- # As an optional second path (if 3D exists & stable), use 3D shoulder length
315
- if need_deyaw and world_landmarks and impact_f in world_landmarks:
316
- wf = world_landmarks[impact_f]
317
- if wf and wf[L_SHO] and wf[R_SHO]:
318
- Ls, Rs = wf[L_SHO], wf[R_SHO]
319
- L3D = math.hypot(Rs[0]-Ls[0], Rs[2]-Ls[2]) # horizontal shoulder sep in XZ (meters, scale-free wrt yaw)
320
- # Calibrate px-per-meter from setup shoulders (robust to yaw via pelvis ratio)
321
- if L3D > 1e-6 and setup_frames:
322
- px_per_m = None
323
- for f in setup_frames:
324
- if f in world_landmarks and pose_data.get(f):
325
- wf_s, lm_s = world_landmarks[f], pose_data[f]
326
- if wf_s and lm_s and lm_s[L_SHO] and lm_s[R_SHO]:
327
- sx, _ = _width_px_derolled(lm_s, L_SHO, R_SHO, roll)
328
- L3D_s = None
329
- if wf_s[L_SHO] and wf_s[R_SHO]:
330
- L3D_s = math.hypot(wf_s[R_SHO][0]-wf_s[L_SHO][0], wf_s[R_SHO][2]-wf_s[L_SHO][2])
331
- if sx > 1.0 and L3D_s and L3D_s > 1e-6:
332
- px_per_m = sx / L3D_s
333
- break
334
- if px_per_m:
335
- dx_corr_3d = L3D * px_per_m
336
- dx_corr = max(dx_corr, dx_corr_3d) # take the more conservative (larger) width
337
-
338
- # Now compute tilt using corrected horizontal width and derolled vertical
339
- if abs(dx_corr) + abs(sho_dy_r) < 1e-8:
340
- return None
341
 
342
- tilt_mag = math.degrees(math.atan2(abs(sho_dy_r), max(dx_corr, 1e-3)))
343
- trail_lower = (sho_dy_r > 0) if handedness=='right' else (sho_dy_r < 0)
344
- tilt_final = tilt_mag if trail_lower else -tilt_mag
 
 
 
 
 
 
 
345
 
346
- # Plausibility clamp for front-facing tilt (pros ~30-45°)
347
- if abs(tilt_final) < 10.0 or abs(tilt_final) > 70.0:
348
- # If we still look crazy, median over ±1 frame
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
349
  neigh = []
350
  for f in [impact_f-1, impact_f, impact_f+1]:
351
  if f in pose_data and pose_data[f] and pose_data[f][L_SHO] and pose_data[f][R_SHO]:
352
  sx, sy = _width_px_derolled(pose_data[f], L_SHO, R_SHO, roll)
353
- px, _ = _width_px_derolled(pose_data[f], L_HIP, R_HIP, roll)
354
- if pel_setup_med and px > 1.0:
355
- r = max(1.0, min(pel_setup_med/px, 8.0))
356
- t = math.degrees(math.atan2(abs(sy), max(sx*r, 1e-3)))
357
- sgn = (sy > 0) if handedness=='right' else (sy < 0)
358
- neigh.append(t if sgn else -t)
359
  if neigh:
360
  tilt_final = float(np.median(neigh))
361
-
362
- # Hard reroute for problematic cases (already handled by hybrid path above)
363
- if not yaw_ok or (W is not None and sho_dx_r < 0.08*W):
364
- # force hybrid path (already handled above). Also avoid labeling as "final"
365
- pass
366
-
367
- # Sanity clamp and flags
368
- definition_suspect = False
369
- if not (10.0 <= abs(tilt_final) <= 70.0):
370
- definition_suspect = True
371
-
372
- if not quality_ok or not yaw_ok:
373
- definition_suspect = True
374
-
375
- # Debug output with de-yaw info
376
- unit_note = " (units_detected_as_pixels)" if max(abs(dx_raw), abs(dy_raw)) > 2.0 else ""
377
- yaw_note = f" yaw_ok={yaw_ok}" if world_landmarks else ""
378
- deyaw_note = f" hybrid_deyaw r={deyaw_ratio:.1f}, dx_corr={dx_corr:.1f}" if need_deyaw else ""
379
- print(f"DEBUG shoulder_tilt: sho_dx_r={sho_dx_r:.1f}, sho_dy_r={sho_dy_r:.1f}, roll={roll:.1f}°{deyaw_note}, "
380
- f"tilt={tilt_final:.1f}°{unit_note}{yaw_note}, suspect={definition_suspect}")
381
-
382
- if pel_setup_med and pel_dx_r > 1.0:
383
- print(f"DEBUG shoulder_tilt: pel_setup_med={pel_setup_med:.1f}, pel_dx_r={pel_dx_r:.1f}")
384
-
385
- return max(-70.0, min(70.0, tilt_final))
386
-
387
-
388
-
389
 
 
 
 
 
 
390
 
 
391
 
392
  def pelvis_hat(world_frame):
393
  """Get unit pelvis vector (R_hip - L_hip) in XZ plane"""
@@ -890,6 +902,26 @@ def calculate_hip_turn_at_impact(
890
  "addr_deg": None,
891
  } if fallback_result is not None else None
892
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
893
  # Get pelvis direction at impact
894
  impact_pelvis_hat = pelvis_hat(world_landmarks[impact_f])
895
 
@@ -1538,7 +1570,16 @@ def compute_front_facing_metrics(pose_data: Dict[int, List], swing_phases: Dict[
1538
  definition_suspect = wrist_hinge_result.get('definition_suspect', False) if wrist_hinge_result else False
1539
 
1540
  # Prefer P3 delta, then P3 absolute, then top values
1541
- wrist_final = delta_p3 if delta_p3 is not None else (wrist_p3 if wrist_p3 is not None else wrist_top)
 
 
 
 
 
 
 
 
 
1542
 
1543
  front_facing_metrics = {
1544
  'shoulder_tilt_impact_deg': {'value': shoulder_tilt_impact},
@@ -1546,6 +1587,7 @@ def compute_front_facing_metrics(pose_data: Dict[int, List], swing_phases: Dict[
1546
  'hip_sway_top_inches': {'value': hip_sway_top},
1547
  'wrist_hinge_top_deg': {
1548
  'value': wrist_final,
 
1549
  'p3_deg': wrist_p3,
1550
  'top_deg': wrist_top,
1551
  'addr_deg': wrist_addr,
 
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
 
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
 
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):
 
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:
 
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
 
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"""
 
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
 
 
1570
  definition_suspect = wrist_hinge_result.get('definition_suspect', False) if wrist_hinge_result else False
1571
 
1572
  # Prefer P3 delta, then P3 absolute, then top values
1573
+ wrist_kind = None
1574
+ if delta_p3 is not None:
1575
+ wrist_final = delta_p3
1576
+ wrist_kind = "delta_p3"
1577
+ elif wrist_p3 is not None:
1578
+ wrist_final = wrist_p3
1579
+ wrist_kind = "absolute_p3"
1580
+ else:
1581
+ wrist_final = wrist_top
1582
+ wrist_kind = "absolute_top"
1583
 
1584
  front_facing_metrics = {
1585
  'shoulder_tilt_impact_deg': {'value': shoulder_tilt_impact},
 
1587
  'hip_sway_top_inches': {'value': hip_sway_top},
1588
  'wrist_hinge_top_deg': {
1589
  'value': wrist_final,
1590
+ 'value_kind': wrist_kind, # <-- add this
1591
  'p3_deg': wrist_p3,
1592
  'top_deg': wrist_top,
1593
  'addr_deg': wrist_addr,
app/streamlit_app.py CHANGED
@@ -781,7 +781,28 @@ def get_shoulder_tilt_impact_grading(value, confidence):
781
  }
782
 
783
 
784
- # Hip turn grading function removed per user request
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
785
 
786
 
787
  def get_hip_sway_grading(value, confidence, position="top"):
@@ -888,7 +909,7 @@ def display_new_grading_scheme(core_metrics):
888
  has_front_facing_metrics = any(key in core_metrics for key in [
889
  'shoulder_tilt_impact_deg', 'hip_sway_top_inches', 'wrist_hinge_top_deg'
890
  ])
891
- # Hip turn check removed per user request
892
 
893
  if has_front_facing_metrics:
894
  st.subheader("Swing Analysis")
@@ -902,7 +923,7 @@ def display_new_grading_scheme(core_metrics):
902
  knee_flexion_data = core_metrics.get("knee_flexion_deg", {})
903
  head_drop_data = core_metrics.get("head_drop_top_pct", {})
904
  hip_depth_data = core_metrics.get("hip_depth_early_extension", {})
905
- # Hip turn data removed per user request
906
 
907
  # Calculate confidence with QC penalties as per feedback
908
  def get_confidence(data, metric_type='general'):
@@ -1050,11 +1071,9 @@ def display_new_grading_scheme(core_metrics):
1050
  else:
1051
  pass # Hip depth calculation succeeded but no special handling needed
1052
 
1053
- # Hip Turn @ Impact metric removed per user request
1054
-
1055
  # Additional old metrics removed - focusing on new 4-metric system
1056
 
1057
- # Front-facing metrics (only displayed when available) - 4 required metrics only
1058
  if has_front_facing_metrics:
1059
  # Front-facing metrics are now always calculated
1060
  pass
@@ -1067,7 +1086,13 @@ def display_new_grading_scheme(core_metrics):
1067
  if grading:
1068
  metrics_to_display.append(("Shoulder Tilt @ Impact", grading))
1069
 
1070
- # Hip Turn at Impact metric removed per user request
 
 
 
 
 
 
1071
 
1072
  # Hip Sway at Top
1073
  hip_sway_top_data = core_metrics.get("hip_sway_top_inches", {})
@@ -1184,8 +1209,6 @@ def get_metric_evaluation(metric_name, grading):
1184
  else:
1185
  return f"Your hip depth of **{value}** indicates early extension issues. This movement pattern reduces power transfer and can cause inconsistent contact. Focus on maintaining spine angle and proper hip position throughout the downswing and impact."
1186
 
1187
- # Hip Turn @ Impact metric removed per user request
1188
-
1189
  # FRONT-FACING METRICS (4 current metrics)
1190
  elif metric_name == "Shoulder Tilt @ Impact":
1191
  if "🟢" in badge:
@@ -1195,6 +1218,14 @@ def get_metric_evaluation(metric_name, grading):
1195
  else:
1196
  return f"Your shoulder tilt of **{value}** at impact needs attention. Proper shoulder angle at impact is crucial for power transfer and ball flight control. Work on impact position for better contact and consistency."
1197
 
 
 
 
 
 
 
 
 
1198
  elif metric_name == "Hip Sway @ Top":
1199
  if "🟢" in badge:
1200
  return f"Your hip sway of **{value}** at the top shows excellent stability. Minimal lateral movement maintains proper balance and swing center, promoting consistent contact and accuracy. This stable foundation supports powerful, controlled swings."
@@ -1312,14 +1343,14 @@ Timing Metrics:
1312
  - Knee Flexion @ Setup: {format_metric_value(core_metrics.get('knee_flexion_deg', {}), '°')}
1313
  - Head Movement @ Top: {format_metric_value(core_metrics.get('head_drop_top_pct', {}), '%')}
1314
  - Hip Depth / Early Extension: {format_metric_value(core_metrics.get('hip_depth_early_extension', {}), '%')}
1315
- # Hip Turn @ Impact metric removed per user request
1316
 
1317
  === DTL-LIMITED METRICS (Approximate) ===
1318
  - Shoulder Turn Quality: {format_metric_value(core_metrics.get('shoulder_turn_quality', {}))}
1319
 
1320
  === FRONT-FACING METRICS ===
1321
  - Shoulder Tilt @ Impact: {format_metric_value(core_metrics.get('shoulder_tilt_impact_deg', {}), '°')}
1322
- # Hip Turn @ Impact metric removed per user request
1323
  - Hip Sway @ Top: {format_metric_value(core_metrics.get('hip_sway_top_inches', {}), '"')}
1324
  - Wrist Hinge @ Top: {format_metric_value(core_metrics.get('wrist_hinge_top_deg', {}), '°')}
1325
  """
@@ -2171,14 +2202,14 @@ def render_step_4():
2171
  shoulder_tilt_status = core_metrics.get("shoulder_tilt_impact_deg", {}).get('status', 'n/a')
2172
  st.caption(f"Shoulder Tilt @ Impact: {shoulder_tilt_raw}° ({shoulder_tilt_status})")
2173
 
2174
- # Hip turn data removed per user request
2175
 
2176
  # Hip depth raw value
2177
  hip_depth_raw = core_metrics.get("hip_depth_early_extension", {}).get('value')
2178
  hip_depth_status = core_metrics.get("hip_depth_early_extension", {}).get('status', 'n/a')
2179
  st.caption(f"Hip Depth / Early Extension: {hip_depth_raw} ({hip_depth_status})")
2180
 
2181
- # Hip turn debug info removed per user request
2182
  if has_front_facing_metrics:
2183
  st.caption("Front-facing metrics detected")
2184
  else:
 
781
  }
782
 
783
 
784
+ def get_hip_turn_impact_grading(value, confidence):
785
+ """Grade hip turn at impact - professional = 36 degrees, 30 handicap = 19 degrees"""
786
+ if value is None:
787
+ return {'display_value': 'n/a', 'badge': '⚪', 'status': 'No data available'}
788
+
789
+ # Professional = 36°, 30 handicap = 19°
790
+ if 30 <= value <= 45:
791
+ badge = '🟢'
792
+ label = 'Excellent turn'
793
+ elif 20 <= value < 30 or 45 < value <= 55:
794
+ badge = '🟠'
795
+ label = 'Good turn'
796
+ else:
797
+ badge = '🔴'
798
+ label = 'Needs improvement'
799
+
800
+ return {
801
+ 'display_value': f'{value:.1f}°',
802
+ 'badge': badge,
803
+ 'label': label,
804
+ 'confidence': confidence
805
+ }
806
 
807
 
808
  def get_hip_sway_grading(value, confidence, position="top"):
 
909
  has_front_facing_metrics = any(key in core_metrics for key in [
910
  'shoulder_tilt_impact_deg', 'hip_sway_top_inches', 'wrist_hinge_top_deg'
911
  ])
912
+ # Hip turn check for front-facing metrics
913
 
914
  if has_front_facing_metrics:
915
  st.subheader("Swing Analysis")
 
923
  knee_flexion_data = core_metrics.get("knee_flexion_deg", {})
924
  head_drop_data = core_metrics.get("head_drop_top_pct", {})
925
  hip_depth_data = core_metrics.get("hip_depth_early_extension", {})
926
+ # Hip turn data for front-facing metrics
927
 
928
  # Calculate confidence with QC penalties as per feedback
929
  def get_confidence(data, metric_type='general'):
 
1071
  else:
1072
  pass # Hip depth calculation succeeded but no special handling needed
1073
 
 
 
1074
  # Additional old metrics removed - focusing on new 4-metric system
1075
 
1076
+ # Front-facing metrics (only displayed when available) - 4 required metrics
1077
  if has_front_facing_metrics:
1078
  # Front-facing metrics are now always calculated
1079
  pass
 
1086
  if grading:
1087
  metrics_to_display.append(("Shoulder Tilt @ Impact", grading))
1088
 
1089
+ # Hip Turn at Impact
1090
+ hip_turn_impact_data = core_metrics.get("hip_turn_impact_deg", {})
1091
+ if hip_turn_impact_data.get('value') is not None:
1092
+ confidence = 0.8 # High confidence for front-facing measurements
1093
+ grading = get_hip_turn_impact_grading(hip_turn_impact_data['value'], confidence)
1094
+ if grading:
1095
+ metrics_to_display.append(("Hip Turn @ Impact", grading))
1096
 
1097
  # Hip Sway at Top
1098
  hip_sway_top_data = core_metrics.get("hip_sway_top_inches", {})
 
1209
  else:
1210
  return f"Your hip depth of **{value}** indicates early extension issues. This movement pattern reduces power transfer and can cause inconsistent contact. Focus on maintaining spine angle and proper hip position throughout the downswing and impact."
1211
 
 
 
1212
  # FRONT-FACING METRICS (4 current metrics)
1213
  elif metric_name == "Shoulder Tilt @ Impact":
1214
  if "🟢" in badge:
 
1218
  else:
1219
  return f"Your shoulder tilt of **{value}** at impact needs attention. Proper shoulder angle at impact is crucial for power transfer and ball flight control. Work on impact position for better contact and consistency."
1220
 
1221
+ elif metric_name == "Hip Turn @ Impact":
1222
+ if "🟢" in badge:
1223
+ return f"Your hip turn of **{value}** at impact shows excellent rotation. This proper hip movement indicates ideal power transfer and body sequencing. Good hip turn at impact promotes solid contact, optimal ball flight, and consistent distance control."
1224
+ elif "🟠" in badge:
1225
+ return f"Your hip turn of **{value}** at impact is acceptable with room for improvement. Hip rotation at impact affects power transfer and ball flight characteristics. Refining your hip movement can enhance consistency and distance."
1226
+ else:
1227
+ return f"Your hip turn of **{value}** at impact needs attention. Proper hip rotation at impact is crucial for power transfer and ball flight control. Work on hip movement for better contact and consistency."
1228
+
1229
  elif metric_name == "Hip Sway @ Top":
1230
  if "🟢" in badge:
1231
  return f"Your hip sway of **{value}** at the top shows excellent stability. Minimal lateral movement maintains proper balance and swing center, promoting consistent contact and accuracy. This stable foundation supports powerful, controlled swings."
 
1343
  - Knee Flexion @ Setup: {format_metric_value(core_metrics.get('knee_flexion_deg', {}), '°')}
1344
  - Head Movement @ Top: {format_metric_value(core_metrics.get('head_drop_top_pct', {}), '%')}
1345
  - Hip Depth / Early Extension: {format_metric_value(core_metrics.get('hip_depth_early_extension', {}), '%')}
1346
+ # Hip Turn @ Impact metric restored for front-facing analysis
1347
 
1348
  === DTL-LIMITED METRICS (Approximate) ===
1349
  - Shoulder Turn Quality: {format_metric_value(core_metrics.get('shoulder_turn_quality', {}))}
1350
 
1351
  === FRONT-FACING METRICS ===
1352
  - Shoulder Tilt @ Impact: {format_metric_value(core_metrics.get('shoulder_tilt_impact_deg', {}), '°')}
1353
+ # Hip Turn @ Impact metric restored for front-facing analysis
1354
  - Hip Sway @ Top: {format_metric_value(core_metrics.get('hip_sway_top_inches', {}), '"')}
1355
  - Wrist Hinge @ Top: {format_metric_value(core_metrics.get('wrist_hinge_top_deg', {}), '°')}
1356
  """
 
2202
  shoulder_tilt_status = core_metrics.get("shoulder_tilt_impact_deg", {}).get('status', 'n/a')
2203
  st.caption(f"Shoulder Tilt @ Impact: {shoulder_tilt_raw}° ({shoulder_tilt_status})")
2204
 
2205
+ # Hip turn data for front-facing metrics
2206
 
2207
  # Hip depth raw value
2208
  hip_depth_raw = core_metrics.get("hip_depth_early_extension", {}).get('value')
2209
  hip_depth_status = core_metrics.get("hip_depth_early_extension", {}).get('status', 'n/a')
2210
  st.caption(f"Hip Depth / Early Extension: {hip_depth_raw} ({hip_depth_status})")
2211
 
2212
+ # Hip turn debug info for front-facing metrics
2213
  if has_front_facing_metrics:
2214
  st.caption("Front-facing metrics detected")
2215
  else: