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

Fix hip sway axis and wrist hinge definition

Browse files

- Hip sway: Use forward/target axis (fwd_in) instead of lateral axis (lat_in) to match pro benchmarks
- Wrist hinge: Return forearm-shaft angle (θ) instead of radial deviation (180-θ)
- Add forearm_shaft_angle_top_deg and forearm_shaft_angle_p3_deg fields
- Prioritize forearm-shaft angle in UI assembly for coach-friendly display
- Keep radial deviation fields for internal consistency
- Add debug prints for both lateral and forward axis lead hip sway

Files changed (1) hide show
  1. app/models/front_facing_metrics.py +283 -96
app/models/front_facing_metrics.py CHANGED
@@ -1163,39 +1163,16 @@ def calculate_hip_sway_at_top(pose_data: Dict[int, List], swing_phases: Dict[str
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') 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
- import cv2
1174
- warp2 = lambda pts: cv2.perspectiveTransform(np.array(pts, dtype=np.float32).reshape(1, -1, 2), H)[0]
1175
-
1176
  # Improved address selection with stillness criteria
1177
- setup_frames = _pick_quiet_address(sm, swing_phases, warp2, max_frames=6)
1178
- if not setup_frames:
1179
- setup_frames = initial_setup_frames
1180
-
1181
- # Refine top frame using velocity
1182
- toe_vecs = []
1183
- for f in setup_frames:
1184
- lm = sm.get(f)
1185
- if not lm or not lm[31] or not lm[32]: continue
1186
- toe_img = [[lm[31][0], lm[31][1]], [lm[32][0], lm[32][1]]]
1187
- toe_g = warp2(toe_img)
1188
- v = toe_g[1] - toe_g[0]
1189
- n = np.linalg.norm(v)
1190
- if n > 1e-6:
1191
- toe_vecs.append(v / n)
1192
- if toe_vecs:
1193
- toe_hat_g_vel = np.median(np.stack(toe_vecs), axis=0)
1194
- toe_hat_g_vel /= (np.linalg.norm(toe_hat_g_vel) + 1e-9)
1195
- # Target line is PERPENDICULAR to toe line
1196
- target_hat_g = np.array([-toe_hat_g_vel[1], toe_hat_g_vel[0]], dtype=float)
1197
- target_hat_g /= (np.linalg.norm(target_hat_g) + 1e-9)
1198
- top_frame = _refine_top_by_lat_vel(sm, backswing_frames, warp2, target_hat_g, top_frame)
1199
  else:
1200
  setup_frames = initial_setup_frames
1201
  if not valid_H:
@@ -1208,7 +1185,7 @@ def calculate_hip_sway_at_top(pose_data: Dict[int, List], swing_phases: Dict[str
1208
  A = np.array(pts, dtype=np.float32).reshape(1, -1, 2)
1209
  return cv2.perspectiveTransform(A, H)[0]
1210
 
1211
- # (3) target axis from toe line (ground), same as before
1212
  toe_vecs = []
1213
  for f in setup_frames:
1214
  lm = sm.get(f)
@@ -1221,12 +1198,14 @@ def calculate_hip_sway_at_top(pose_data: Dict[int, List], swing_phases: Dict[str
1221
  toe_vecs.append(v / n)
1222
  if not toe_vecs:
1223
  return None
 
 
1224
  toe_hat_g = np.median(np.stack(toe_vecs), axis=0)
1225
- toe_hat_g /= (np.linalg.norm(toe_hat_g) + 1e-9)
1226
  # Target line is PERPENDICULAR to toe line (down-the-line toward the flag)
1227
- target_hat_g = np.array([-toe_hat_g[1], toe_hat_g[0]], dtype=float)
1228
- target_hat_g /= (np.linalg.norm(target_hat_g) + 1e-9)
1229
- print(f"DEBUG sway2: toe_hat_g={toe_hat_g}, target_hat_g={target_hat_g}, dot≈{float(np.dot(toe_hat_g, target_hat_g)):.3f}")
1230
 
1231
  # (4) sign disambiguation using address→impact pelvis motion (keep)
1232
  candidate_fwd = None
@@ -1248,8 +1227,8 @@ def calculate_hip_sway_at_top(pose_data: Dict[int, List], swing_phases: Dict[str
1248
  if addr_ctrs and imp_ctr is not None:
1249
  addr_ctr = np.median(np.stack(addr_ctrs), axis=0)
1250
  fwd_vec = imp_ctr - addr_ctr
1251
- if float(np.dot(fwd_vec, target_hat_g)) < 0.0:
1252
- target_hat_g = -target_hat_g
1253
 
1254
  # (5) centers at address (median) and at top
1255
  setup_centers = [pelvis_center_ground(f) for f in setup_frames]
@@ -1280,24 +1259,39 @@ def calculate_hip_sway_at_top(pose_data: Dict[int, List], swing_phases: Dict[str
1280
  except Exception:
1281
  pass
1282
 
1283
- delta = (top_ctr - setup_ctr) * inch_scale
1284
- lat = float(np.dot(delta, target_hat_g)) # toward target (lateral)
1285
- fwd = float(np.dot(delta, np.array([-target_hat_g[1], target_hat_g[0]]))) # forward/back
1286
- print(f"DEBUG sway2: lat_in={lat:.2f}, fwd_in={fwd:.2f}, inch_scale={inch_scale:.3f}")
1287
 
1288
- # Compute lead hip sway as alternative measurement
1289
- lead_sway = _lead_hip_sway(sm, setup_frames, top_frame, warp2, target_hat_g)
 
 
 
 
 
 
 
 
 
 
 
1290
  if lead_sway is not None:
1291
  lead_sway_inches = lead_sway * inch_scale
1292
- print(f"DEBUG sway2: lead_hip_sway_in={lead_sway_inches:.2f}")
1293
- # Prefer lead hip measurement if available
1294
- sway_inches = lead_sway_inches
1295
- else:
1296
- sway_inches = lat
 
 
1297
 
1298
- print(f"DEBUG sway2: H_frame={H_frame}, inch_scale={inch_scale:.3f}, "
1299
- f"setup_ctr_g={setup_ctr*inch_scale}, top_ctr_g={top_ctr*inch_scale}, "
1300
- f"sway_in={sway_inches:.2f}")
 
 
 
 
1301
 
1302
  # sanity note only
1303
  if abs(sway_inches) < 0.2:
@@ -1331,6 +1325,50 @@ def _shaft_vec_proxy(lm, handed='right'):
1331
  else (lm[L_WR][0] - lm[R_WR][0], lm[L_WR][1] - lm[R_WR][1])
1332
  return np.array(v, float)
1333
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1334
  def _hand_point(lm, handed='right'):
1335
  # Mediapipe Pose: L index=19, L pinky=17, L thumb=21; R index=20, R pinky=18, R thumb=22
1336
  if handed == 'right':
@@ -1353,8 +1391,8 @@ def _hand_point(lm, handed='right'):
1353
  xs, ys = zip(*cand)
1354
  return (float(np.median(xs)), float(np.median(ys)))
1355
 
1356
- def _hinge2d_shaft(lm, handed='right'):
1357
- """Hinge = 180° - angle(forearm, shaft_proxy)."""
1358
  if not lm: return None
1359
  EL, WR = (13, 15) if handed=='right' else (14, 16) # lead elbow/wrist
1360
  if not lm[EL] or not lm[WR] or lm[EL][2] < 0.5 or lm[WR][2] < 0.5:
@@ -1362,30 +1400,42 @@ def _hinge2d_shaft(lm, handed='right'):
1362
 
1363
  # Forearm vector
1364
  u = np.array([lm[WR][0]-lm[EL][0], lm[WR][1]-lm[EL][1]], float)
1365
- nu = np.linalg.norm(u)
1366
- if nu < 1e-6: return None
1367
-
1368
- # Shaft proxy
1369
- s = _shaft_vec_proxy(lm, handed)
1370
- if s is None:
1371
- # Fallback: keep your old hand-point method but guard against colinearity
1372
- hp = _hand_point(lm, handed)
1373
- if hp is None: return None
1374
- s = np.array([hp[0]-lm[WR][0], hp[1]-lm[WR][1]], float)
1375
- ns = np.linalg.norm(s)
1376
- if ns < 1e-6: return None
1377
-
1378
- # Angle between lines (ignore direction)
 
1379
  c = float(np.clip(np.dot(u, s)/(nu*ns), -1, 1))
1380
- theta = math.degrees(math.acos(c)) # 0..180, 90° when perpendicular
1381
- hinge = 180.0 - theta # hinge large when shaft closer to perpendicular to forearm
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1382
 
1383
- # Sanity clamp
1384
  return max(20.0, min(160.0, hinge))
1385
 
1386
- def _hinge2d(lm, handed='right'):
1387
- """Legacy function - now calls shaft-based version"""
1388
- return _hinge2d_shaft(lm, handed)
1389
 
1390
  def _pelvis_ctr_ground_series(sm, frames, warp2):
1391
  """Get pelvis centers in ground plane for given frames"""
@@ -1425,15 +1475,30 @@ def _point_ground(frame_idx, sm, warp2, i):
1425
  if not lm or not lm[i]: return None
1426
  return warp2([[lm[i][0], lm[i][1]]])[0].astype(float)
1427
 
1428
- def _lead_hip_sway(sm, addr_frames, top_frame, warp2, target_hat_g):
1429
- """Compute lead hip sway as alternative to pelvis center"""
 
 
 
 
 
 
 
 
 
 
 
 
 
1430
  L_HIP = 23
1431
  addr_pts = [_point_ground(f, sm, warp2, L_HIP) for f in addr_frames]
1432
  addr_pts = [p for p in addr_pts if p is not None]
1433
  top_pt = _point_ground(top_frame, sm, warp2, L_HIP)
1434
- if not addr_pts or top_pt is None: return None
 
1435
  addr_med = np.median(np.stack(addr_pts), axis=0)
1436
  delta = top_pt - addr_med
 
1437
  return float(np.dot(delta, target_hat_g))
1438
 
1439
  def _pick_p3_lead_arm_parallel(sm, backswing_frames, handed='right', tol_deg=15.0):
@@ -1456,13 +1521,49 @@ def _pick_p3_lead_arm_parallel(sm, backswing_frames, handed='right', tol_deg=15.
1456
  best_abs, best = ang, f
1457
  return best
1458
 
1459
- def _pick_top_frame_hinge(sm, backswing_frames, handed='right'):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1460
  """Pick frame with maximum hinge in backswing"""
1461
  if not backswing_frames:
1462
  return None
1463
 
1464
  # Top = max hinge in backswing
1465
- top_vals = [(f, _hinge2d(sm.get(f), handed)) for f in backswing_frames]
1466
  top_vals = [(f, h) for f, h in top_vals if h is not None]
1467
  if not top_vals:
1468
  return None
@@ -1471,8 +1572,12 @@ def _pick_top_frame_hinge(sm, backswing_frames, handed='right'):
1471
  return top_frame
1472
 
1473
  def calculate_wrist_hinge_at_top(pose_data: Dict[int, List], swing_phases: Dict[str, List],
1474
- frames: Optional[List[np.ndarray]] = None, handedness: str = 'right') -> Union[Dict[str, float], None]:
1475
  """Calculate wrist hinge using shaft-based method with both P3 and top measurements"""
 
 
 
 
1476
  sm = smooth_landmarks(pose_data)
1477
 
1478
  backswing_frames = swing_phases.get("backswing", [])
@@ -1487,28 +1592,94 @@ def calculate_wrist_hinge_at_top(pose_data: Dict[int, List], swing_phases: Dict[
1487
  else:
1488
  return None # Truly no data
1489
 
1490
- # Top frame and hinge
1491
- top_frame = _pick_top_frame_hinge(sm, backswing_frames, handed=handedness) or backswing_frames[-1]
1492
- hinge_top = _hinge2d_shaft(sm.get(top_frame), handedness)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1493
 
1494
  # NEW: P3 frame and hinge
1495
  p3_frame = _pick_p3_lead_arm_parallel(sm, backswing_frames, handedness)
1496
- hinge_p3 = _hinge2d_shaft(sm.get(p3_frame), handedness) if p3_frame is not None else None
1497
 
1498
  # Address baseline (use shaft-based hinge to avoid "palm vector too short")
1499
- setup_frames = _pick_still_address_by_hand(sm, swing_phases, handedness, max_frames=6) or _address_frames(swing_phases)
1500
  addr_vals = []
1501
- for f in setup_frames[:6]:
1502
- h = _hinge2d_shaft(sm.get(f), handedness)
1503
  if h is not None: addr_vals.append(h)
1504
  hinge_addr = float(np.median(addr_vals)) if addr_vals else None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1505
 
1506
  result = {
1507
- "radial_deviation_deg_top": hinge_top,
1508
- "radial_deviation_deg_p3": hinge_p3,
 
1509
  "addr_deg": hinge_addr,
1510
  "delta_deg_p3": (hinge_p3 - hinge_addr) if (hinge_p3 is not None and hinge_addr is not None) else None,
1511
  "delta_deg_top": (hinge_top - hinge_addr) if (hinge_top is not None and hinge_addr is not None) else None,
 
 
 
1512
  # Flag outside typical pro band for P3
1513
  "definition_suspect": (hinge_p3 is None) or not (50 <= hinge_p3 <= 130),
1514
  # Legacy field for backward compatibility
@@ -1530,7 +1701,7 @@ def compute_front_facing_metrics(pose_data: Dict[int, List], swing_phases: Dict[
1530
  )
1531
  hip_turn_result = calculate_hip_turn_at_impact(pose_data, swing_phases, world_landmarks, frames)
1532
  hip_sway_top = calculate_hip_sway_at_top(pose_data, swing_phases, world_landmarks)
1533
- wrist_hinge_result = calculate_wrist_hinge_at_top(pose_data, swing_phases, frames, handedness=handedness)
1534
 
1535
  # Process hip turn results
1536
  hip_turn_data = {}
@@ -1561,7 +1732,9 @@ def compute_front_facing_metrics(pose_data: Dict[int, List], swing_phases: Dict[
1561
  else:
1562
  hip_turn_data = {'value': None}
1563
 
1564
- # Extract wrist hinge values (NEW: prioritize P3 measurements)
 
 
1565
  wrist_p3 = wrist_hinge_result.get('radial_deviation_deg_p3') if wrist_hinge_result else None
1566
  wrist_top = wrist_hinge_result.get('radial_deviation_deg_top') if wrist_hinge_result else None
1567
  wrist_addr = wrist_hinge_result.get('addr_deg') if wrist_hinge_result else None
@@ -1569,17 +1742,29 @@ def compute_front_facing_metrics(pose_data: Dict[int, List], swing_phases: Dict[
1569
  delta_top = wrist_hinge_result.get('delta_deg_top') if wrist_hinge_result else None
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,9 +1772,11 @@ def compute_front_facing_metrics(pose_data: Dict[int, List], swing_phases: Dict[
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,
1594
  'delta_p3_deg': delta_p3,
1595
  'delta_top_deg': delta_top,
 
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:
 
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)
 
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
 
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]
 
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:
 
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':
 
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:
 
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):
1441
  """Get pelvis centers in ground plane for given frames"""
 
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):
 
1521
  best_abs, best = ang, f
1522
  return best
1523
 
1524
+ def _pick_top_by_wrist_height(sm, backswing, handed='right'):
1525
+ """Pick top frame by highest wrist position (most robust)"""
1526
+ WR = 15 if handed=='right' else 16
1527
+ best, best_y = None, 1e9
1528
+ for f in backswing:
1529
+ lm = sm.get(f)
1530
+ if lm and lm[WR] and lm[WR][2] >= 0.5:
1531
+ y = lm[WR][1] # smaller y = higher hand (screen coords)
1532
+ if y < best_y:
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"""
1562
  if not backswing_frames:
1563
  return None
1564
 
1565
  # Top = max hinge in backswing
1566
+ top_vals = [(f, _hinge2d_shaft(sm.get(f), handed, roll_deg)) for f in backswing_frames]
1567
  top_vals = [(f, h) for f, h in top_vals if h is not None]
1568
  if not top_vals:
1569
  return None
 
1572
  return top_frame
1573
 
1574
  def calculate_wrist_hinge_at_top(pose_data: Dict[int, List], swing_phases: Dict[str, List],
1575
+ handedness: str = 'right') -> Union[Dict[str, float], None]:
1576
  """Calculate wrist hinge using shaft-based method with both P3 and top measurements"""
1577
+ # Define indices locally to avoid hidden globals
1578
+ L_EYE, R_EYE = 2, 5
1579
+ L_HIP, R_HIP = 23, 24
1580
+
1581
  sm = smooth_landmarks(pose_data)
1582
 
1583
  backswing_frames = swing_phases.get("backswing", [])
 
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
 
1673
  result = {
1674
+ # Report θ at top and P3 (what coaches expect from screenshots)
1675
+ "forearm_shaft_angle_top_deg": theta_top,
1676
+ "forearm_shaft_angle_p3_deg": theta_p3,
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
1684
  "definition_suspect": (hinge_p3 is None) or not (50 <= hinge_p3 <= 130),
1685
  # Legacy field for backward compatibility
 
1701
  )
1702
  hip_turn_result = calculate_hip_turn_at_impact(pose_data, swing_phases, world_landmarks, frames)
1703
  hip_sway_top = calculate_hip_sway_at_top(pose_data, swing_phases, world_landmarks)
1704
+ wrist_hinge_result = calculate_wrist_hinge_at_top(pose_data, swing_phases, handedness=handedness)
1705
 
1706
  # Process hip turn results
1707
  hip_turn_data = {}
 
1732
  else:
1733
  hip_turn_data = {'value': None}
1734
 
1735
+ # Extract wrist hinge values (NEW: prioritize forearm-shaft angle measurements)
1736
+ wrist_top_theta = wrist_hinge_result.get('forearm_shaft_angle_top_deg') if wrist_hinge_result else None
1737
+ wrist_p3_theta = wrist_hinge_result.get('forearm_shaft_angle_p3_deg') if wrist_hinge_result else None
1738
  wrist_p3 = wrist_hinge_result.get('radial_deviation_deg_p3') if wrist_hinge_result else None
1739
  wrist_top = wrist_hinge_result.get('radial_deviation_deg_top') if wrist_hinge_result else None
1740
  wrist_addr = wrist_hinge_result.get('addr_deg') if wrist_hinge_result else None
 
1742
  delta_top = wrist_hinge_result.get('delta_deg_top') if wrist_hinge_result else None
1743
  definition_suspect = wrist_hinge_result.get('definition_suspect', False) if wrist_hinge_result else False
1744
 
1745
+ # Initialize to avoid undefined locals
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},
 
1772
  'hip_sway_top_inches': {'value': hip_sway_top},
1773
  'wrist_hinge_top_deg': {
1774
  'value': wrist_final,
1775
+ 'value_kind': wrist_kind,
1776
+ 'forearm_shaft_angle_top_deg': wrist_top_theta,
1777
+ 'forearm_shaft_angle_p3_deg': wrist_p3_theta,
1778
+ 'radial_deviation_p3_deg': wrist_p3,
1779
+ 'radial_deviation_top_deg': wrist_top,
1780
  'addr_deg': wrist_addr,
1781
  'delta_p3_deg': delta_p3,
1782
  'delta_top_deg': delta_top,