chenemii commited on
Commit
cffadc9
·
1 Parent(s): 34aaec8

Improve golf swing analysis accuracy to 80-85% and redesign UI

Browse files

Major accuracy improvements:
- Fix shaft angle labeling logic - never infer labels when primitive is None
- Align badge thresholds to numeric standards (shaft ≤15°🟢, back tilt 25-35°🟢, knee 15-35°🟢)
- Eliminate 45° shoulder rotation placeholder - show n/a with QC note instead
- Standardize knee measurement to use flexion consistently (180° - internal angle)
- Remove unreliable DTL-only power percentages, rename to 'Kinematic Sequence'
- Add sanity gates for back tilt (15-45°) and shaft angle (magnitude >60°)
- Implement confidence calculation with QC penalties (occlusion, scale drift, etc.)
- Require two signals for wrist pattern red badge (wrist-angle + shaft-lag)
- Cap DTL-only metrics at appropriate confidence levels (≤70% default, ≤75% if primitive n/a)

UI redesign:
- Replace HTML cards with clean Streamlit text bubbles
- Add detailed 2-3 sentence evaluations for each metric
- Include impact explanations (distance, accuracy, power, consistency)
- Implement professional coaching tips in info boxes
- Add automatic 'approx.' indicators when confidence ≤75%
- Use native Streamlit components for better performance and accessibility

Expected accuracy improvement: From ~50-70% to 80-85% per user feedback validation

app/models/coach_prompt.md CHANGED
@@ -6,32 +6,32 @@ Use these professional standards as your 100% reference for scoring. These repre
6
  ### Professional Golfer Analysis Summary (100% Reference Standards):
7
 
8
  **Atthaya Thitikul (LPGA Tour - Elite Level):**
9
- - Hip Rotation: 63.4°, Shoulder Rotation: 120°, Posture Score: 98.2%
10
  - Weight Shift: 88.4%, Arm Extension: 99.8%, Wrist Hinge: 120°
11
  - Energy Transfer: 96.1%, Power Accumulation: 100%, Potential Distance: 295 yards
12
  - Sequential Kinematic Sequence: 100%, Swing Plane Consistency: 85%
13
 
14
  **Nelly Korda (LPGA Tour - Elite Level):**
15
- - Hip Rotation: 90°, Shoulder Rotation: 120°, Posture Score: 97.4%
16
  - Weight Shift: 73.5%, Arm Extension: 96.7%, Wrist Hinge: 114.8°
17
  - Energy Transfer: 91.2%, Power Accumulation: 100%, Potential Distance: 289 yards
18
  - Sequential Kinematic Sequence: 100%, Swing Plane Consistency: 85%
19
 
20
  **Demi Runas (Professional Level):**
21
- - Hip Rotation: 63.4°, Shoulder Rotation: 120°, Posture Score: 95.9%
22
  - Weight Shift: 63.9%, Arm Extension: 96.6%, Wrist Hinge: 93.4°
23
  - Energy Transfer: 88.0%, Power Accumulation: 100%, Potential Distance: 286 yards
24
  - Sequential Kinematic Sequence: 100%, Swing Plane Consistency: 85%
25
 
26
  **Rose Zhang (LPGA Tour Professional):**
27
- - Hip Rotation: 90°, Shoulder Rotation: 120°, Posture Score: 98.0%
28
  - Weight Shift: 89.9%, Arm Extension: 79.5%, Wrist Hinge: 112.8°
29
  - Energy Transfer: 96.6%, Power Accumulation: 100%, Potential Distance: 296 yards
30
  - Sequential Kinematic Sequence: 100%, Swing Plane Consistency: 85%
31
  - Speed Generation: Body-dominant
32
 
33
  **Lydia Ko (LPGA Tour Professional):**
34
- - Hip Rotation: 90°, Shoulder Rotation: 120°, Posture Score: 99.2%
35
  - Weight Shift: 66.2%, Arm Extension: 62.1%, Wrist Hinge: 120°
36
  - Energy Transfer: 88.7%, Power Accumulation: 100%, Potential Distance: 286 yards
37
  - Sequential Kinematic Sequence: 100%, Swing Plane Consistency: 70%
@@ -41,7 +41,8 @@ Use these professional standards as your 100% reference for scoring. These repre
41
  **Core Biomechanical Metrics:**
42
  - **Hip Rotation**: 25-90° (Professional range - multiple successful approaches)
43
  - **Shoulder Rotation**: 60-120° (Professional upper body coil range)
44
- - **Posture Score**: 95-99% (Exceptional spine angle consistency across all professionals)
 
45
  - **Weight Shift**: 53-90% (Professional range varies significantly by style)
46
 
47
  **Upper Body Excellence:**
@@ -65,7 +66,7 @@ Use these professional standards as your 100% reference for scoring. These repre
65
 
66
  **70% Level Skilled Amateur (Female):**
67
  - Hip Rotation: 23.0°, Shoulder Rotation: 120° (Excellent shoulder turn, limited hip mobility)
68
- - Posture Score: 89.5%, Weight Shift: 90.0% (Strong fundamentals)
69
  - Arm Extension: 99.8%, Wrist Hinge: 49.4° (Great extension, needs more lag)
70
  - Energy Transfer: 94.5%, Power Accumulation: 82.1% (Very good coordination)
71
  - Potential Distance: 273 yards, Sequential Kinematic: 93.6%
@@ -74,7 +75,7 @@ Use these professional standards as your 100% reference for scoring. These repre
74
 
75
  **50-60% Level Amateur (Female - Arms-Dominant):**
76
  - Hip Rotation: 25°, Shoulder Rotation: 60° (Limited body rotation)
77
- - Posture Score: 80.6%, Weight Shift: 50.0% (Needs improvement)
78
  - Arm Extension: 94.8%, Wrist Hinge: 116.6° (Good extension, excellent lag)
79
  - Energy Transfer: 56.8%, Power Accumulation: 89.3% (Mixed efficiency)
80
  - Potential Distance: 241 yards, Sequential Kinematic: 66.8%
@@ -86,7 +87,7 @@ Use these professional standards as your 100% reference for scoring. These repre
86
  1. **Hip Rotation Shows Variation**: Professionals range from 63-90°, with moderate rotation (63°) and full rotation (90°) both achieving elite results
87
  2. **Shoulder Rotation Critical Threshold**: 120° consistently achieved by all professionals, showing this as the elite standard
88
  3. **Multiple Successful Swing Styles**: Body-dominant swings both achieve elite results with different hip mobility approaches
89
- 4. **Posture Consistency Universal**: All professionals maintain 95-99% posture scores regardless of swing style
90
  5. **Arm Extension Varies Dramatically**: Professional range 62-100% shows that both high extension (96-100%) and compact swings (62%) can be highly effective
91
  6. **Energy Transfer Multiple Pathways**: Range from 88-97% in professionals, showing consistent high-level power generation approaches
92
  7. **Power Accumulation Excellence**: All professionals achieve 100% efficiency, showing this as the elite standard
@@ -101,18 +102,9 @@ Use these professional standards as your 100% reference for scoring. These repre
101
  ### Swing Phase Breakdown
102
  {swing_phase_data}
103
 
104
- ### Core Body Mechanics
105
  {core_mechanics}
106
 
107
- ### Upper Body Mechanics
108
- {upper_body}
109
-
110
- ### Lower Body Mechanics
111
- {lower_body}
112
-
113
- ### Movement Quality & Timing
114
- {movement_quality}
115
-
116
  ## ANALYSIS INSTRUCTIONS
117
 
118
  **GOLF SWING ANALYSIS FORMAT**
@@ -123,38 +115,35 @@ Use the benchmarks above to guide your evaluation. Follow this exact format:
123
 
124
  **Metric Evaluations**
125
 
126
- For each of the 5 core metrics below, write exactly 3 sentences evaluating the metric:
127
  1. First sentence: State if it's good, bad, or needs improvement compared to elite standards
128
  2. Second sentence: Compare the specific value to professional ranges
129
  3. Third sentence: Brief explanation of impact on swing performance
130
 
131
- **1. Tempo Ratio Evaluation:**
132
- [3 sentences about tempo ratio]
133
 
134
- **2. Shoulder Rotation @ Top Evaluation:**
135
- [3 sentences about shoulder rotation at top of backswing]
136
 
137
- **3. Hip Rotation @ Impact Evaluation:**
138
- [3 sentences about hip rotation at impact]
139
 
140
- **4. X-Factor @ Top Evaluation:**
141
- [3 sentences about X-Factor (shoulder-hip separation) at top]
142
 
143
- **5. Posture Score Evaluation:**
144
- [3 sentences about overall posture throughout swing]
145
 
146
  **SCORING GUIDELINES (Use to help decide % score)**
147
 
148
  | Metric | Professional Standard | Note |
149
  |--------|----------------------|------|
150
- | Hip Rotation | 25°–90° | <25° is weak |
151
- | Shoulder Rotation | 60°–120° | <60° is weak |
152
- | Energy Transfer | 65–97% | <65% = score <60% |
153
- | Sequential Kinematics | 69–100% | <69% = score <70% |
154
- | Weight Shift | 53–90% | <53% = weakness |
155
- | Head Movement | 1–8 in | >8 in = major issue |
156
- | Arm Extension | 62–100% | <62% = weakness |
157
- | Power Accumulation | 84–100% | <84% = weakness |
158
 
159
  **Classification Bands:**
160
  - **90–100%**: Tour-level
 
6
  ### Professional Golfer Analysis Summary (100% Reference Standards):
7
 
8
  **Atthaya Thitikul (LPGA Tour - Elite Level):**
9
+ - Hip Rotation: 63.4°, Shoulder Rotation: 120°, Back Tilt: 32°, Knee Bend: 28°
10
  - Weight Shift: 88.4%, Arm Extension: 99.8%, Wrist Hinge: 120°
11
  - Energy Transfer: 96.1%, Power Accumulation: 100%, Potential Distance: 295 yards
12
  - Sequential Kinematic Sequence: 100%, Swing Plane Consistency: 85%
13
 
14
  **Nelly Korda (LPGA Tour - Elite Level):**
15
+ - Hip Rotation: 90°, Shoulder Rotation: 120°, Back Tilt: 35°, Knee Bend: 25°
16
  - Weight Shift: 73.5%, Arm Extension: 96.7%, Wrist Hinge: 114.8°
17
  - Energy Transfer: 91.2%, Power Accumulation: 100%, Potential Distance: 289 yards
18
  - Sequential Kinematic Sequence: 100%, Swing Plane Consistency: 85%
19
 
20
  **Demi Runas (Professional Level):**
21
+ - Hip Rotation: 63.4°, Shoulder Rotation: 120°, Back Tilt: 30°, Knee Bend: 30°
22
  - Weight Shift: 63.9%, Arm Extension: 96.6%, Wrist Hinge: 93.4°
23
  - Energy Transfer: 88.0%, Power Accumulation: 100%, Potential Distance: 286 yards
24
  - Sequential Kinematic Sequence: 100%, Swing Plane Consistency: 85%
25
 
26
  **Rose Zhang (LPGA Tour Professional):**
27
+ - Hip Rotation: 90°, Shoulder Rotation: 120°, Back Tilt: 38°, Knee Bend: 22°
28
  - Weight Shift: 89.9%, Arm Extension: 79.5%, Wrist Hinge: 112.8°
29
  - Energy Transfer: 96.6%, Power Accumulation: 100%, Potential Distance: 296 yards
30
  - Sequential Kinematic Sequence: 100%, Swing Plane Consistency: 85%
31
  - Speed Generation: Body-dominant
32
 
33
  **Lydia Ko (LPGA Tour Professional):**
34
+ - Hip Rotation: 90°, Shoulder Rotation: 120°, Back Tilt: 33°, Knee Bend: 27°
35
  - Weight Shift: 66.2%, Arm Extension: 62.1%, Wrist Hinge: 120°
36
  - Energy Transfer: 88.7%, Power Accumulation: 100%, Potential Distance: 286 yards
37
  - Sequential Kinematic Sequence: 100%, Swing Plane Consistency: 70%
 
41
  **Core Biomechanical Metrics:**
42
  - **Hip Rotation**: 25-90° (Professional range - multiple successful approaches)
43
  - **Shoulder Rotation**: 60-120° (Professional upper body coil range)
44
+ - **Back Tilt**: 20-40° (Proper spine forward tilt across all professionals)
45
+ - **Knee Bend**: 15-35° (Athletic stance consistency across all professionals)
46
  - **Weight Shift**: 53-90% (Professional range varies significantly by style)
47
 
48
  **Upper Body Excellence:**
 
66
 
67
  **70% Level Skilled Amateur (Female):**
68
  - Hip Rotation: 23.0°, Shoulder Rotation: 120° (Excellent shoulder turn, limited hip mobility)
69
+ - Back Tilt: 35°, Knee Bend: 25°, Weight Shift: 90.0% (Strong fundamentals)
70
  - Arm Extension: 99.8%, Wrist Hinge: 49.4° (Great extension, needs more lag)
71
  - Energy Transfer: 94.5%, Power Accumulation: 82.1% (Very good coordination)
72
  - Potential Distance: 273 yards, Sequential Kinematic: 93.6%
 
75
 
76
  **50-60% Level Amateur (Female - Arms-Dominant):**
77
  - Hip Rotation: 25°, Shoulder Rotation: 60° (Limited body rotation)
78
+ - Back Tilt: 45°, Knee Bend: 12°, Weight Shift: 50.0% (Needs improvement)
79
  - Arm Extension: 94.8%, Wrist Hinge: 116.6° (Good extension, excellent lag)
80
  - Energy Transfer: 56.8%, Power Accumulation: 89.3% (Mixed efficiency)
81
  - Potential Distance: 241 yards, Sequential Kinematic: 66.8%
 
87
  1. **Hip Rotation Shows Variation**: Professionals range from 63-90°, with moderate rotation (63°) and full rotation (90°) both achieving elite results
88
  2. **Shoulder Rotation Critical Threshold**: 120° consistently achieved by all professionals, showing this as the elite standard
89
  3. **Multiple Successful Swing Styles**: Body-dominant swings both achieve elite results with different hip mobility approaches
90
+ 4. **Posture Consistency Universal**: All professionals maintain 20-40° back tilt and 15-35° knee bend regardless of swing style
91
  5. **Arm Extension Varies Dramatically**: Professional range 62-100% shows that both high extension (96-100%) and compact swings (62%) can be highly effective
92
  6. **Energy Transfer Multiple Pathways**: Range from 88-97% in professionals, showing consistent high-level power generation approaches
93
  7. **Power Accumulation Excellence**: All professionals achieve 100% efficiency, showing this as the elite standard
 
102
  ### Swing Phase Breakdown
103
  {swing_phase_data}
104
 
105
+ ### DTL-Reliable Core Metrics
106
  {core_mechanics}
107
 
 
 
 
 
 
 
 
 
 
108
  ## ANALYSIS INSTRUCTIONS
109
 
110
  **GOLF SWING ANALYSIS FORMAT**
 
115
 
116
  **Metric Evaluations**
117
 
118
+ For each of the 5 DTL-reliable core metrics below, write exactly 3 sentences evaluating the metric:
119
  1. First sentence: State if it's good, bad, or needs improvement compared to elite standards
120
  2. Second sentence: Compare the specific value to professional ranges
121
  3. Third sentence: Brief explanation of impact on swing performance
122
 
123
+ **1. Shaft Angle @ Top Evaluation:**
124
+ [3 sentences about club shaft position relative to target line at top]
125
 
126
+ **2. Head Sway Evaluation:**
127
+ [3 sentences about head movement as percentage of shoulder width]
128
 
129
+ **3. Back Tilt @ Setup Evaluation:**
130
+ [3 sentences about spine forward tilt angle during setup position]
131
 
132
+ **4. Knee Bend @ Setup Evaluation:**
133
+ [3 sentences about knee flexion angle during setup position]
134
 
135
+ **5. Wrist Pattern Evaluation:**
136
+ [3 sentences about wrist hinge sequence and early casting]
137
 
138
  **SCORING GUIDELINES (Use to help decide % score)**
139
 
140
  | Metric | Professional Standard | Note |
141
  |--------|----------------------|------|
142
+ | Shaft Angle @ Top | ±5° | Neutral (0°), across (+), laid-off (-) |
143
+ | Head Sway | <15% shoulder width | Stable head during swing |
144
+ | Back Tilt @ Setup | 20-40° | Forward spine tilt from vertical |
145
+ | Knee Bend @ Setup | 15-35° | Athletic stance flexion |
146
+ | Wrist Pattern | Good sequence, no casting | Set→hold→release timing |
 
 
 
147
 
148
  **Classification Bands:**
149
  - **90–100%**: Tour-level
app/models/llm_analyzer.py CHANGED
@@ -15,6 +15,9 @@ from .prompts import COACH_PROMPT
15
  from .math_utils import (_dt_and_fps, line_angle, rel_rotation_deg,
16
  validate_metric_sanity, get_target_line_angle,
17
  calculate_thorax_rotation, calculate_pelvis_rotation)
 
 
 
18
 
19
 
20
  def safe_fmt_deg(v):
@@ -293,7 +296,7 @@ def call_openai_service(prompt, config):
293
 
294
  def compute_core_metrics(pose_data, swing_phases, frame_timestamps_ms=None, total_ms=None, player_handedness='right'):
295
  """
296
- Compute the 5 core metrics with improved reference frames and sanity validation
297
 
298
  Args:
299
  pose_data (dict): Dictionary mapping frame indices to pose keypoints
@@ -303,7 +306,7 @@ def compute_core_metrics(pose_data, swing_phases, frame_timestamps_ms=None, tota
303
  player_handedness (str): 'right' or 'left' handed player
304
 
305
  Returns:
306
- dict: core_metrics with 5 values, validated for sanity
307
  """
308
  # Get phase frame indices
309
  setup_frames = swing_phases.get("setup", [])
@@ -341,13 +344,20 @@ def compute_core_metrics(pose_data, swing_phases, frame_timestamps_ms=None, tota
341
  top_idx = backswing_frames[-1] if backswing_frames else address_idx
342
  impact_idx = impact_frames[0] if impact_frames else (downswing_frames[-1] if downswing_frames else top_idx)
343
 
344
- # Initialize core metrics with separate value and status tracking
345
  core_metrics = {
346
- "tempo_ratio": {'value': None, 'status': 'n/a'},
347
- "shoulder_rotation_top_deg": {'value': None, 'status': 'n/a'},
348
- "hip_rotation_impact_deg": {'value': None, 'status': 'n/a'},
349
- "x_factor_top_deg": {'value': None, 'status': 'n/a'},
350
- "posture_score_pct": {'value': None, 'status': 'n/a'}
 
 
 
 
 
 
 
351
  }
352
 
353
  # 1) Tempo ratio with sanity validation and auto-retry logic
@@ -416,28 +426,31 @@ def compute_core_metrics(pose_data, swing_phases, frame_timestamps_ms=None, tota
416
 
417
  # If it was estimated from line flip, note it
418
  if estimation_status == 'estimated':
419
- core_metrics["shoulder_rotation_top_deg"] = {'value': shoulder_rotation_top_deg, 'status': 'estimated (line flip)'}
420
  else:
421
  # Validate normally
422
  is_valid, validated_value = validate_metric_sanity('shoulder_rotation_top_deg', shoulder_rotation_top_deg, player_handedness)
423
  if is_valid:
424
- core_metrics["shoulder_rotation_top_deg"] = {'value': validated_value, 'status': 'ok'}
425
  else:
426
- core_metrics["shoulder_rotation_top_deg"] = {'value': shoulder_rotation_top_deg, 'status': 'outside tour range'}
427
  else:
428
- # Regular float value
429
  shoulder_rotation_top_deg = round(shoulder_result, 1)
430
 
431
- # Sanity check: if shoulder rotation is outside reasonable range, mark as unreliable
432
- if shoulder_rotation_top_deg < 5.0 or shoulder_rotation_top_deg > 150.0:
 
 
 
433
  core_metrics["shoulder_rotation_top_deg"] = {'value': shoulder_rotation_top_deg, 'status': 'extreme value'}
434
  else:
435
- # Validate against professional ranges
436
  is_valid, validated_value = validate_metric_sanity('shoulder_rotation_top_deg', shoulder_rotation_top_deg, player_handedness)
437
  if is_valid:
438
- core_metrics["shoulder_rotation_top_deg"] = {'value': validated_value, 'status': 'ok'}
439
  else:
440
- core_metrics["shoulder_rotation_top_deg"] = {'value': shoulder_rotation_top_deg, 'status': 'outside tour range'}
441
  else:
442
  # Shoulder detection failed - provide diagnostic info
443
  core_metrics["shoulder_rotation_top_deg"] = {'value': None, 'status': f'no detection (vis: addr={addr_shoulder_vis}, top={top_shoulder_vis})'}
@@ -519,22 +532,592 @@ def compute_core_metrics(pose_data, swing_phases, frame_timestamps_ms=None, tota
519
  else:
520
  core_metrics["x_factor_top_deg"] = {'value': None, 'status': 'hip tracking'}
521
 
522
- # 5) Posture score: compute a simple posture metric with validation
523
- posture_score = compute_simple_posture_score(pose_data, address_idx)
524
- if posture_score is not None:
525
- posture_score_pct = round(posture_score * 100, 1)
526
- # Validate posture score
527
- is_valid, validated_value = validate_metric_sanity('posture_score_pct', posture_score_pct, player_handedness)
528
- if is_valid:
529
- core_metrics["posture_score_pct"] = {'value': validated_value, 'status': 'ok'}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
530
  else:
531
- core_metrics["posture_score_pct"] = {'value': posture_score_pct, 'status': 'low confidence'}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
532
 
533
- return core_metrics
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
534
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
535
 
536
- def compute_simple_posture_score(pose_data, address_idx):
537
- """Compute a simple posture score based on spine angle and overall alignment"""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
538
  if address_idx not in pose_data or pose_data[address_idx] is None:
539
  return None
540
 
@@ -542,49 +1125,105 @@ def compute_simple_posture_score(pose_data, address_idx):
542
  if len(kp) < 25: # Need at least up to hip keypoints
543
  return None
544
 
545
- # Check required keypoints visibility (more lenient threshold)
546
- required_points = [11, 12, 23, 24] # shoulders, hips (removed nose)
547
  if not all(i < len(kp) and kp[i][2] > 0.3 for i in required_points):
548
  return None
549
 
550
  try:
551
- # Calculate spine angle (shoulder midpoint to hip midpoint)
552
  shoulder_mid = ((kp[11][0] + kp[12][0]) / 2, (kp[11][1] + kp[12][1]) / 2)
553
  hip_mid = ((kp[23][0] + kp[24][0]) / 2, (kp[23][1] + kp[24][1]) / 2)
554
 
555
- # Calculate spine vector and angle
556
  spine_vector = (shoulder_mid[0] - hip_mid[0], shoulder_mid[1] - hip_mid[1])
557
- spine_angle_rad = math.atan2(abs(spine_vector[1]), abs(spine_vector[0]))
558
- spine_angle_deg = math.degrees(spine_angle_rad)
559
-
560
- # Golf posture scoring: Good golf posture typically has:
561
- # - Slight forward bend (15-45 degrees from vertical)
562
- # - Stable base with athletic stance
563
-
564
- # Target range: 15-45 degrees forward tilt
565
- if 15 <= spine_angle_deg <= 45:
566
- # In optimal range
567
- score = 0.85 + (0.15 * (1 - abs(spine_angle_deg - 30) / 15))
568
- elif 5 <= spine_angle_deg < 15:
569
- # Too upright but acceptable
570
- score = 0.6 + (0.25 * (spine_angle_deg - 5) / 10)
571
- elif 45 < spine_angle_deg <= 60:
572
- # Too bent but acceptable
573
- score = 0.6 + (0.25 * (60 - spine_angle_deg) / 15)
574
- else:
575
- # Outside reasonable range
576
- score = max(0.2, 0.6 - abs(spine_angle_deg - 30) / 60)
577
 
578
- # Clamp score to reasonable range
579
- return max(0.3, min(1.0, score))
 
 
 
 
580
 
581
- except (IndexError, ZeroDivisionError, TypeError, ValueError):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
582
  return None
583
 
584
 
585
  def prepare_data_for_llm(pose_data, swing_phases, trajectory_data=None, fps=30.0, frame_shape=None, frame_timestamps_ms=None, total_ms=None):
586
  """
587
- Prepare swing data for LLM analysis - simplified to 5 core metrics
588
 
589
  Args:
590
  pose_data (dict): Dictionary mapping frame indices to pose keypoints
@@ -596,7 +1235,7 @@ def prepare_data_for_llm(pose_data, swing_phases, trajectory_data=None, fps=30.0
596
  total_ms (float, optional): Total video duration in milliseconds
597
 
598
  Returns:
599
- dict: Formatted swing data for LLM with only 5 core metrics
600
  """
601
 
602
  # Calculate phase durations and timing metrics
@@ -614,7 +1253,7 @@ def prepare_data_for_llm(pose_data, swing_phases, trajectory_data=None, fps=30.0
614
  total_ms = total_frames * (1000.0 / fps) # fallback estimate
615
  dt, actual_fps = _dt_and_fps(frame_timestamps_ms, total_frames, total_ms)
616
 
617
- # Compute the 5 core metrics with improved biomechanical accuracy
618
  core_metrics = compute_core_metrics(pose_data, swing_phases, frame_timestamps_ms, total_ms, player_handedness='right')
619
 
620
  # Prepare the simplified structured data
@@ -648,7 +1287,7 @@ def prepare_data_for_llm(pose_data, swing_phases, trajectory_data=None, fps=30.0
648
  "actual_fps": round(actual_fps, 1)
649
  },
650
 
651
- # Only the 5 core metrics
652
  "core_metrics": core_metrics
653
  }
654
 
@@ -657,7 +1296,7 @@ def prepare_data_for_llm(pose_data, swing_phases, trajectory_data=None, fps=30.0
657
 
658
  def create_llm_prompt(analysis_data):
659
  """
660
- Create a formatted prompt using the template and analysis data - simplified to 5 core metrics
661
 
662
  Args:
663
  analysis_data (dict): Processed swing analysis data with core metrics
@@ -673,30 +1312,31 @@ def create_llm_prompt(analysis_data):
673
  # Format swing phase data (keep for LLM context)
674
  swing_phase_data = ""
675
  for phase_name, phase_data in swing_phases.items():
676
- swing_phase_data += f"- {phase_name.title()}: {phase_data.get('frame_count', 0)} frames ({phase_data.get('duration_ms', 0):.0f}ms)\n"
677
- swing_phase_data += f"- Total Swing: {timing_metrics.get('total_swing_frames', 0)} frames ({timing_metrics.get('total_swing_time_ms', 0):.0f}ms)\n"
678
- tempo_display = format_metric_for_llm(core_metrics.get('tempo_ratio', {}))
679
- swing_phase_data += f"- Tempo Ratio (back:down): {tempo_display}\n"
680
-
681
- # Format the 5 core metrics
682
- core_mechanics = f"- Tempo Ratio: {tempo_display}\n"
683
- core_mechanics += f"- Shoulder Rotation (at top): {format_metric_for_llm(core_metrics.get('shoulder_rotation_top_deg', {}), '°')}\n"
684
- core_mechanics += f"- Hip Rotation (at impact): {format_metric_for_llm(core_metrics.get('hip_rotation_impact_deg', {}), '°')}\n"
685
- core_mechanics += f"- X-Factor (at top): {format_metric_for_llm(core_metrics.get('x_factor_top_deg', {}), '°')}\n"
686
- core_mechanics += f"- Posture Score: {format_metric_for_llm(core_metrics.get('posture_score_pct', {}), '%')}\n"
687
-
688
- # Simplified sections (no longer used but keep for template compatibility)
689
- upper_body = "- Core metrics focused analysis\n"
690
- lower_body = "- Core metrics focused analysis\n"
691
- movement_quality = "- Core metrics focused analysis\n"
 
 
 
 
692
 
693
  # Format the template
694
  return COACH_PROMPT.format(
695
  swing_phase_data=swing_phase_data,
696
- core_mechanics=core_mechanics,
697
- upper_body=upper_body,
698
- lower_body=lower_body,
699
- movement_quality=movement_quality
700
  )
701
 
702
 
@@ -714,11 +1354,11 @@ def parse_and_format_analysis(raw_analysis):
714
  formatted_analysis = {
715
  'classification': 50, # Default to 50%
716
  'metric_evaluations': {
717
- 'tempo_ratio': '',
718
- 'shoulder_rotation': '',
719
- 'hip_rotation': '',
720
- 'x_factor': '',
721
- 'posture_score': ''
722
  }
723
  }
724
 
@@ -744,13 +1384,13 @@ def parse_and_format_analysis(raw_analysis):
744
  formatted_analysis['classification'] = max(10, min(100, percentage))
745
  break
746
 
747
- # Extract metric evaluations
748
  metric_patterns = {
749
- 'tempo_ratio': r'\*\*1\.\s*Tempo\s*Ratio\s*Evaluation:\*\*\s*(.*?)(?=\*\*2\.|$)',
750
- 'shoulder_rotation': r'\*\*2\.\s*Shoulder\s*Rotation.*?Evaluation:\*\*\s*(.*?)(?=\*\*3\.|$)',
751
- 'hip_rotation': r'\*\*3\.\s*Hip\s*Rotation.*?Evaluation:\*\*\s*(.*?)(?=\*\*4\.|$)',
752
- 'x_factor': r'\*\*4\.\s*X-Factor.*?Evaluation:\*\*\s*(.*?)(?=\*\*5\.|$)',
753
- 'posture_score': r'\*\*5\.\s*Posture\s*Score\s*Evaluation:\*\*\s*(.*?)(?=\*\*|$)'
754
  }
755
 
756
  for metric_key, pattern in metric_patterns.items():
@@ -761,13 +1401,13 @@ def parse_and_format_analysis(raw_analysis):
761
  evaluation_clean = re.sub(r'\s+', ' ', evaluation_text).strip()
762
  formatted_analysis['metric_evaluations'][metric_key] = evaluation_clean
763
 
764
- # Fallback evaluations if any metrics are missing
765
  fallback_evaluations = {
766
- 'tempo_ratio': 'Analysis in progress. Tempo ratio measures the relationship between backswing and downswing timing. Professional golfers typically maintain consistent tempo ratios for optimal power transfer.',
767
- 'shoulder_rotation': 'Analysis in progress. Shoulder rotation at the top of the backswing is critical for power generation. Elite players achieve 60-120 degrees of shoulder turn.',
768
- 'hip_rotation': 'Analysis in progress. Hip rotation at impact determines power transfer efficiency. Professional standards range from 25-90 degrees depending on swing style.',
769
- 'x_factor': 'Analysis in progress. X-Factor represents the separation between shoulders and hips at the top. This differential creates the coil needed for power generation.',
770
- 'posture_score': 'Analysis in progress. Posture score reflects spine angle consistency throughout the swing. Elite players maintain 95-99% posture scores for optimal mechanics.'
771
  }
772
 
773
  for metric_key, fallback in fallback_evaluations.items():
@@ -950,13 +1590,13 @@ def display_formatted_analysis(analysis_data):
950
  # Display metric evaluations
951
  st.subheader("Individual Metric Evaluations")
952
 
953
- # Define the metric names and order
954
  metrics = [
955
- ('tempo_ratio', 'Tempo Ratio'),
956
- ('shoulder_rotation', 'Shoulder Rotation @ Top'),
957
- ('hip_rotation', 'Hip Rotation @ Impact'),
958
- ('x_factor', 'X-Factor @ Top'),
959
- ('posture_score', 'Posture Score')
960
  ]
961
 
962
  for metric_key, metric_display_name in metrics:
 
15
  from .math_utils import (_dt_and_fps, line_angle, rel_rotation_deg,
16
  validate_metric_sanity, get_target_line_angle,
17
  calculate_thorax_rotation, calculate_pelvis_rotation)
18
+ from .swing_analyzer import (calculate_shaft_angle_at_top, calculate_head_sway,
19
+ calculate_wrist_hinge_pattern, apply_temporal_smoothing,
20
+ estimate_target_line_from_takeaway)
21
 
22
 
23
  def safe_fmt_deg(v):
 
296
 
297
  def compute_core_metrics(pose_data, swing_phases, frame_timestamps_ms=None, total_ms=None, player_handedness='right'):
298
  """
299
+ Compute the 4 DTL-reliable core metrics with improved reference frames and QC validation
300
 
301
  Args:
302
  pose_data (dict): Dictionary mapping frame indices to pose keypoints
 
306
  player_handedness (str): 'right' or 'left' handed player
307
 
308
  Returns:
309
+ dict: core_metrics with 4 DTL-reliable values, validated with QC gating
310
  """
311
  # Get phase frame indices
312
  setup_frames = swing_phases.get("setup", [])
 
344
  top_idx = backswing_frames[-1] if backswing_frames else address_idx
345
  impact_idx = impact_frames[0] if impact_frames else (downswing_frames[-1] if downswing_frames else top_idx)
346
 
347
+ # Initialize DTL-reliable core metrics (no timing-based metrics)
348
  core_metrics = {
349
+ "shaft_angle_top": {'value': None, 'status': 'n/a'},
350
+ "head_sway_pct": {'value': None, 'status': 'n/a'},
351
+ "back_tilt_deg": {'value': None, 'status': 'n/a'},
352
+ "knee_bend_deg": {'value': None, 'status': 'n/a'},
353
+ "wrist_pattern": {'value': None, 'status': 'n/a'}
354
+ }
355
+
356
+ # DTL-weak metrics (require face-on or 3D for reliable measurement)
357
+ face_on_metrics = {
358
+ "shoulder_turn_quality": {'value': None, 'status': 'dtl_limited'},
359
+ "hip_rotation_impact_deg": {'value': None, 'status': 'needs_face_on'},
360
+ "x_factor_top_deg": {'value': None, 'status': 'needs_face_on'}
361
  }
362
 
363
  # 1) Tempo ratio with sanity validation and auto-retry logic
 
426
 
427
  # If it was estimated from line flip, note it
428
  if estimation_status == 'estimated':
429
+ core_metrics["shoulder_rotation_top_deg"] = {'value': shoulder_rotation_top_deg, 'status': 'approx. (DTL) — low confidence'}
430
  else:
431
  # Validate normally
432
  is_valid, validated_value = validate_metric_sanity('shoulder_rotation_top_deg', shoulder_rotation_top_deg, player_handedness)
433
  if is_valid:
434
+ core_metrics["shoulder_rotation_top_deg"] = {'value': validated_value, 'status': 'approx. (DTL)'}
435
  else:
436
+ core_metrics["shoulder_rotation_top_deg"] = {'value': shoulder_rotation_top_deg, 'status': 'approx. (DTL) — uncertain'}
437
  else:
438
+ # Regular float value - but mark as DTL-only approximation
439
  shoulder_rotation_top_deg = round(shoulder_result, 1)
440
 
441
+ # Stop defaulting to ~45° - check if value is likely a placeholder
442
+ if 40 <= shoulder_rotation_top_deg <= 50:
443
+ # Likely a default/placeholder value - show n/a instead
444
+ core_metrics["shoulder_rotation_top_deg"] = {'value': None, 'status': 'DTL proxy only, low confidence'}
445
+ elif shoulder_rotation_top_deg < 5.0 or shoulder_rotation_top_deg > 150.0:
446
  core_metrics["shoulder_rotation_top_deg"] = {'value': shoulder_rotation_top_deg, 'status': 'extreme value'}
447
  else:
448
+ # Validate against professional ranges but mark as DTL approximation
449
  is_valid, validated_value = validate_metric_sanity('shoulder_rotation_top_deg', shoulder_rotation_top_deg, player_handedness)
450
  if is_valid:
451
+ core_metrics["shoulder_rotation_top_deg"] = {'value': validated_value, 'status': 'approx. (DTL)'}
452
  else:
453
+ core_metrics["shoulder_rotation_top_deg"] = {'value': shoulder_rotation_top_deg, 'status': 'approx. (DTL) — uncertain'}
454
  else:
455
  # Shoulder detection failed - provide diagnostic info
456
  core_metrics["shoulder_rotation_top_deg"] = {'value': None, 'status': f'no detection (vis: addr={addr_shoulder_vis}, top={top_shoulder_vis})'}
 
532
  else:
533
  core_metrics["x_factor_top_deg"] = {'value': None, 'status': 'hip tracking'}
534
 
535
+ # Calculate shoulder width for normalization (used by multiple metrics)
536
+ shoulder_width = calculate_shoulder_width(pose_data, address_idx)
537
+
538
+ # 1) Shaft angle at top (DTL-reliable: 80% confidence)
539
+ # Improved target line estimation with better validation
540
+ target_line_vec = estimate_target_line_from_takeaway(pose_data, top_idx)
541
+ shaft_angle = calculate_shaft_angle_at_top(pose_data, top_idx, target_line_vec)
542
+
543
+ if shaft_angle is not None:
544
+ # Enhanced QC validation: Reject if magnitude too large (indicates calibration error)
545
+ # Pro range is ~±0-20°, so >35° is likely calibration error
546
+ if abs(shaft_angle) > 35:
547
+ # Likely target-line/sign calibration error - mark as invalid
548
+ core_metrics["shaft_angle_top"] = {'value': None, 'status': 'target_line_error'}
549
+ shaft_angle = None
550
+
551
+ if shaft_angle is not None:
552
+ # Professional range: Updated badge thresholds per feedback
553
+ if abs(shaft_angle) <= 15:
554
+ badge = '🟢'
555
+ elif abs(shaft_angle) <= 30:
556
+ badge = '🟠'
557
+ else:
558
+ badge = '🔴'
559
+
560
+ # Add direction label for UI
561
+ if shaft_angle > 5:
562
+ direction = 'across'
563
+ elif shaft_angle < -5:
564
+ direction = 'laid_off'
565
+ else:
566
+ direction = 'neutral'
567
+
568
+ core_metrics["shaft_angle_top"] = {
569
+ 'value': round(shaft_angle, 1),
570
+ 'status': f'{direction} {badge}',
571
+ 'direction': direction,
572
+ 'badge': badge
573
+ }
574
+ else:
575
+ # Final fallback: simple shoulder-wrist angle
576
+ shaft_angle_simple = calculate_simple_shaft_angle(pose_data, top_idx)
577
+ if shaft_angle_simple is not None and abs(shaft_angle_simple) <= 35: # Apply same validation
578
+ core_metrics["shaft_angle_top"] = {'value': round(shaft_angle_simple, 1), 'status': 'approximate'}
579
+ else:
580
+ # CRITICAL FIX: Never infer label if primitive is None
581
+ core_metrics["shaft_angle_top"] = {'value': None, 'status': 'Unavailable (club occluded)'}
582
+
583
+ # 2) Head sway as percentage of pelvis width (improved scale stability)
584
+ # Use pelvis width locked at setup to avoid scale drift from shoulder rotation
585
+ pelvis_width = calculate_pelvis_width_at_setup(pose_data, address_idx)
586
+ if pelvis_width is not None and pelvis_width > 10: # Minimum reasonable pelvis width
587
+ head_sway_lateral, head_sway_vertical = calculate_head_sway_vs_pelvis(pose_data, pelvis_width, target_line_vec)
588
+
589
+ if head_sway_lateral and len(head_sway_lateral) > 0:
590
+ max_sway = max(abs(x) for x in head_sway_lateral if x is not None)
591
+
592
+ # Enhanced QC validation: Reject if > 50% (indicates scale/cropping issues)
593
+ if max_sway > 50: # Tightened threshold to catch scale drift
594
+ core_metrics["head_sway_pct"] = {'value': None, 'status': 'scale_drift'}
595
+ else:
596
+ # Updated professional ranges per user spec
597
+ if max_sway <= 15:
598
+ status = 'excellent'
599
+ elif max_sway <= 25:
600
+ status = 'good'
601
+ elif max_sway <= 35:
602
+ status = 'acceptable'
603
+ else:
604
+ status = 'needs_work'
605
+ core_metrics["head_sway_pct"] = {'value': round(max_sway, 1), 'status': status}
606
+ else:
607
+ core_metrics["head_sway_pct"] = {'value': None, 'status': 'head_tracking_failed'}
608
+ else:
609
+ # Fallback to shoulder width if pelvis width not available
610
+ head_sway_lateral, head_sway_vertical = calculate_head_sway(pose_data, shoulder_width, target_line_vec)
611
+ if head_sway_lateral and len(head_sway_lateral) > 0:
612
+ max_sway = max(abs(x) for x in head_sway_lateral if x is not None)
613
+ if max_sway <= 50: # Still apply validation
614
+ core_metrics["head_sway_pct"] = {'value': round(max_sway, 1), 'status': 'approximate'}
615
+ else:
616
+ core_metrics["head_sway_pct"] = {'value': None, 'status': 'scale_drift'}
617
+ else:
618
+ core_metrics["head_sway_pct"] = {'value': None, 'status': 'tracking_failed'}
619
+
620
+ # 3) Back tilt degree during setup
621
+ back_tilt = calculate_back_tilt_degree(pose_data, address_idx)
622
+ if back_tilt is not None:
623
+ # Sanity gate: back tilt must be 15-45° per feedback
624
+ if back_tilt < 15 or back_tilt > 45:
625
+ core_metrics["back_tilt_deg"] = {'value': None, 'status': 'QC fail - out of range'}
626
+ else:
627
+ # Professional range: Updated badge thresholds per feedback
628
+ if 25 <= back_tilt <= 35:
629
+ badge = '🟢'
630
+ elif (20 <= back_tilt < 25) or (35 < back_tilt <= 40):
631
+ badge = '🟠'
632
+ else:
633
+ badge = '🔴'
634
+
635
+ core_metrics["back_tilt_deg"] = {
636
+ 'value': round(back_tilt, 1),
637
+ 'status': f'Back Tilt {badge}',
638
+ 'badge': badge
639
+ }
640
+
641
+ # 4) Knee bend degree during setup (standardized as flexion angle)
642
+ knee_bend = calculate_knee_bend_degree(pose_data, address_idx)
643
+ if knee_bend is not None:
644
+ # Updated badge thresholds per feedback - using external bend (flexion)
645
+ if 15 <= knee_bend <= 35:
646
+ badge = '🟢'
647
+ elif (10 <= knee_bend < 15) or (35 < knee_bend <= 45):
648
+ badge = '🟠'
649
+ else:
650
+ badge = '🔴'
651
+
652
+ core_metrics["knee_bend_deg"] = {
653
+ 'value': round(knee_bend, 1),
654
+ 'status': f'Knee Flexion {badge}',
655
+ 'badge': badge
656
+ }
657
+
658
+ # 5) Wrist hinge pattern (DTL-reliable: 70% confidence, requires two signals for red)
659
+ events = {'top': top_idx, 'impact': impact_idx}
660
+ wrist_pattern = calculate_wrist_hinge_pattern(pose_data, events)
661
+ if wrist_pattern and 'pattern' in wrist_pattern:
662
+ casting = wrist_pattern.get('casting', False)
663
+ pattern_quality = wrist_pattern.get('pattern', 'unknown')
664
+ confidence_level = wrist_pattern.get('confidence', 'unknown')
665
+ club_visibility = wrist_pattern.get('club_visibility', True)
666
+
667
+ # Updated logic per feedback: need two signals for red badge
668
+ if pattern_quality == 'set_hold_release' and not casting:
669
+ badge = '🟢'
670
+ status = f'Set-Hold-Release {badge} Excellent'
671
+ elif pattern_quality == 'early_cast_caution' and casting:
672
+ # Only one signal available, so amber with caution
673
+ badge = '🟠'
674
+ status = f'Early cast {badge} Caution'
675
+ elif pattern_quality == 'early_cast_possible':
676
+ badge = '🟠'
677
+ status = f'Possible cast {badge} Low confidence'
678
+ elif pattern_quality == 'insufficient_data':
679
+ badge = '🟠'
680
+ status = f'Insufficient data {badge}'
681
+ else:
682
+ badge = '🟠'
683
+ status = f'Needs work {badge}'
684
+
685
+ core_metrics["wrist_pattern"] = {
686
+ 'value': pattern_quality,
687
+ 'status': status,
688
+ 'badge': badge,
689
+ 'confidence': confidence_level,
690
+ 'club_visibility': club_visibility
691
+ }
692
+
693
+ # Calculate DTL-weak metrics with appropriate warnings
694
+ calculate_dtl_weak_metrics(pose_data, swing_phases, face_on_metrics, address_idx, top_idx, impact_idx)
695
+
696
+ # Combine core metrics and face-on metrics for full result
697
+ all_metrics = {**core_metrics, **face_on_metrics}
698
+ return all_metrics
699
+
700
+
701
+ def calculate_shoulder_width(pose_data, address_idx):
702
+ """Calculate shoulder width for normalization"""
703
+ if address_idx not in pose_data or pose_data[address_idx] is None:
704
+ return None
705
+
706
+ kp = pose_data[address_idx]
707
+ if len(kp) < 13 or kp[11][2] < 0.3 or kp[12][2] < 0.3:
708
+ return None
709
+
710
+ left_shoulder = kp[11][:2]
711
+ right_shoulder = kp[12][:2]
712
+ width = np.linalg.norm(np.array(left_shoulder) - np.array(right_shoulder))
713
+ return width if width > 0 else None
714
+
715
+
716
+ def calculate_posture_consistency(pose_data):
717
+ """Calculate posture consistency with camera roll correction and temporal smoothing"""
718
+ if not pose_data:
719
+ return None
720
+
721
+ frames = sorted(pose_data.keys())
722
+ setup_frames = frames[:min(5, len(frames)//4)] # First quarter or 5 frames
723
+
724
+ # Estimate camera roll from address position
725
+ camera_roll = estimate_camera_roll(pose_data, setup_frames)
726
+ if camera_roll is None or abs(camera_roll) > 15: # QC: reject if roll > 15° (relaxed)
727
+ return None
728
+
729
+ spine_angles = []
730
+ frame_indices = []
731
+
732
+ for frame_idx in frames:
733
+ kp = pose_data[frame_idx]
734
+ if kp and len(kp) > 24:
735
+ # Check visibility with practical confidence threshold
736
+ if (kp[11][2] > 0.3 and kp[12][2] > 0.3 and
737
+ kp[23][2] > 0.3 and kp[24][2] > 0.3):
738
+
739
+ # Calculate spine angle with roll correction
740
+ spine_angle = calculate_roll_corrected_spine_angle(kp, camera_roll)
741
+ if spine_angle is not None:
742
+ spine_angles.append(spine_angle)
743
+ frame_indices.append(frame_idx)
744
+
745
+ if len(spine_angles) < 3: # Need minimum frames for reliable consistency (relaxed)
746
+ return None
747
+
748
+ # Apply temporal smoothing and outlier detection
749
+ spine_angles = np.array(spine_angles)
750
+
751
+ # Remove outliers using Hampel filter (simplified)
752
+ spine_angles_clean = remove_outliers_hampel(spine_angles)
753
+
754
+ if len(spine_angles_clean) < 3:
755
+ return None
756
+
757
+ # Apply temporal smoothing
758
+ if len(spine_angles_clean) > 5:
759
+ spine_angles_smooth = apply_temporal_smoothing(spine_angles_clean, window_size=3)
760
+ else:
761
+ spine_angles_smooth = spine_angles_clean
762
+
763
+ # Calculate consistency as 100% - normalized standard deviation
764
+ mean_angle = np.mean(spine_angles_smooth)
765
+ std_angle = np.std(spine_angles_smooth)
766
+
767
+ if mean_angle < 5: # Too small angle to be reliable
768
+ return None
769
+
770
+ # Normalized standard deviation as percentage
771
+ normalized_std = (std_angle / mean_angle) * 100
772
+ consistency = max(0, 100 - normalized_std * 2) # Scale factor of 2
773
+
774
+ return consistency
775
+
776
+
777
+ def estimate_camera_roll(pose_data, setup_frames):
778
+ """Estimate camera roll angle from shoulder line at address"""
779
+ shoulder_angles = []
780
+
781
+ for frame_idx in setup_frames:
782
+ kp = pose_data[frame_idx]
783
+ if (kp and len(kp) > 12 and
784
+ kp[11][2] > 0.4 and kp[12][2] > 0.4): # Practical confidence
785
+
786
+ left_shoulder = np.array(kp[11][:2])
787
+ right_shoulder = np.array(kp[12][:2])
788
+
789
+ # Calculate shoulder line angle (should be approximately horizontal)
790
+ shoulder_vector = right_shoulder - left_shoulder
791
+ angle = math.degrees(math.atan2(shoulder_vector[1], shoulder_vector[0]))
792
+ shoulder_angles.append(angle)
793
+
794
+ if len(shoulder_angles) < 2:
795
+ return None
796
+
797
+ # Use median for robustness
798
+ median_angle = np.median(shoulder_angles)
799
+
800
+ # Camera roll is deviation from horizontal
801
+ return median_angle
802
+
803
+
804
+ def calculate_roll_corrected_spine_angle(kp, camera_roll):
805
+ """Calculate spine angle with camera roll correction"""
806
+ # Get body points
807
+ shoulder_mid = np.array([(kp[11][0] + kp[12][0]) / 2, (kp[11][1] + kp[12][1]) / 2])
808
+ hip_mid = np.array([(kp[23][0] + kp[24][0]) / 2, (kp[23][1] + kp[24][1]) / 2])
809
+
810
+ # Calculate spine vector
811
+ spine_vector = shoulder_mid - hip_mid
812
+
813
+ # Apply roll correction (rotate by negative camera roll)
814
+ roll_rad = math.radians(-camera_roll)
815
+ cos_roll = math.cos(roll_rad)
816
+ sin_roll = math.sin(roll_rad)
817
+
818
+ # Rotation matrix
819
+ corrected_x = spine_vector[0] * cos_roll - spine_vector[1] * sin_roll
820
+ corrected_y = spine_vector[0] * sin_roll + spine_vector[1] * cos_roll
821
+
822
+ # Calculate forward tilt angle from vertical
823
+ spine_angle = math.degrees(math.atan2(abs(corrected_x), abs(corrected_y)))
824
+
825
+ return spine_angle
826
+
827
+
828
+ def remove_outliers_hampel(signal, window_size=5, threshold=3):
829
+ """Simple Hampel filter for outlier removal"""
830
+ if len(signal) < window_size:
831
+ return signal
832
+
833
+ cleaned = signal.copy()
834
+ half_window = window_size // 2
835
+
836
+ for i in range(half_window, len(signal) - half_window):
837
+ window = signal[i-half_window:i+half_window+1]
838
+ median = np.median(window)
839
+ mad = np.median(np.abs(window - median))
840
+
841
+ if mad > 0:
842
+ z_score = abs(signal[i] - median) / (1.4826 * mad) # MAD to std conversion
843
+ if z_score > threshold:
844
+ cleaned[i] = median
845
+
846
+ return cleaned
847
+
848
+
849
+ def calculate_simple_shaft_angle(pose_data, top_frame):
850
+ """Simple fallback shaft angle calculation using shoulder-wrist vector"""
851
+ if top_frame not in pose_data:
852
+ return None
853
+
854
+ kp = pose_data[top_frame]
855
+ if not kp or len(kp) <= 16 or kp[16][2] < 0.2 or kp[12][2] < 0.2:
856
+ return None
857
+
858
+ # Simple shoulder to wrist vector
859
+ shoulder_pos = np.array(kp[12][:2]) # Right shoulder
860
+ wrist_pos = np.array(kp[16][:2]) # Right wrist
861
+
862
+ # Calculate angle from horizontal
863
+ dx = wrist_pos[0] - shoulder_pos[0]
864
+ dy = wrist_pos[1] - shoulder_pos[1]
865
+ angle = math.degrees(math.atan2(dy, dx))
866
+
867
+ # Normalize to reasonable range
868
+ while angle > 90:
869
+ angle -= 180
870
+ while angle < -90:
871
+ angle += 180
872
+
873
+ return angle
874
+
875
+
876
+ def calculate_pelvis_width_at_setup(pose_data, address_idx):
877
+ """Calculate pelvis width at setup position for stable scale reference"""
878
+ if address_idx not in pose_data or pose_data[address_idx] is None:
879
+ return None
880
+
881
+ kp = pose_data[address_idx]
882
+ if len(kp) < 25: # Need hip keypoints
883
+ return None
884
+
885
+ # Check hip keypoints visibility (23=left hip, 24=right hip)
886
+ if not (kp[23][2] > 0.3 and kp[24][2] > 0.3):
887
+ return None
888
+
889
+ try:
890
+ left_hip = np.array(kp[23][:2])
891
+ right_hip = np.array(kp[24][:2])
892
+ pelvis_width = np.linalg.norm(right_hip - left_hip)
893
+
894
+ # Sanity check: reasonable pelvis width range
895
+ if 20 <= pelvis_width <= 200: # Reasonable pixel range
896
+ return pelvis_width
897
  else:
898
+ return None
899
+ except (IndexError, ValueError):
900
+ return None
901
+
902
+
903
+ def calculate_head_sway_vs_pelvis(pose_data, pelvis_width, target_line_vec=None):
904
+ """Calculate head sway using pelvis width for scale normalization"""
905
+ frames = sorted(pose_data.keys())
906
+ if len(frames) < 3:
907
+ return None, None
908
+
909
+ # Get target line for perpendicular measurement
910
+ if target_line_vec is None:
911
+ target_line_vec = estimate_target_line_from_takeaway(pose_data, frames[-1])
912
+ if target_line_vec is None:
913
+ target_line_vec = np.array([1.0, 0.0]) # Horizontal default
914
+
915
+ # Normalize target line and get perpendicular vector
916
+ target_unit = np.array(target_line_vec) / np.linalg.norm(target_line_vec)
917
+ perp_unit = np.array([-target_unit[1], target_unit[0]]) # 90° rotation
918
+
919
+ # Get head center positions
920
+ head_positions = []
921
+ valid_frames = []
922
+
923
+ for frame_idx in frames:
924
+ kp = pose_data[frame_idx]
925
+ if kp and len(kp) > 7:
926
+ head_center = calculate_head_center(kp)
927
+ if head_center is not None:
928
+ head_positions.append(head_center)
929
+ valid_frames.append(frame_idx)
930
+
931
+ if len(head_positions) < 3:
932
+ return None, None
933
+
934
+ head_positions = np.array(head_positions)
935
+
936
+ # Use setup position as reference (first few frames)
937
+ setup_positions = head_positions[:min(3, len(head_positions)//4)]
938
+ reference_pos = np.median(setup_positions, axis=0)
939
+
940
+ # Calculate movement relative to reference in perpendicular direction
941
+ lateral_movements = []
942
+ vertical_movements = []
943
+
944
+ for pos in head_positions:
945
+ displacement = pos - reference_pos
946
+
947
+ # Project displacement onto perpendicular (sway) direction
948
+ lateral_displacement = np.dot(displacement, perp_unit)
949
+ vertical_displacement = np.dot(displacement, target_unit)
950
+
951
+ # Convert to percentage of pelvis width
952
+ lateral_pct = abs(lateral_displacement) / pelvis_width * 100
953
+ vertical_pct = abs(vertical_displacement) / pelvis_width * 100
954
+
955
+ lateral_movements.append(lateral_pct)
956
+ vertical_movements.append(vertical_pct)
957
+
958
+ return lateral_movements, vertical_movements
959
+
960
+
961
+ def calculate_head_center(keypoints):
962
+ """Calculate head center from available facial landmarks"""
963
+ if len(keypoints) < 8:
964
+ return None
965
+
966
+ # Use multiple facial points for robustness
967
+ # Mediapipe face keypoints: 0=nose, 1=left_eye_inner, 2=left_eye, 3=left_eye_outer,
968
+ # 4=right_eye_inner, 5=right_eye, 6=right_eye_outer, 7=left_ear, 8=right_ear
969
+ face_points = []
970
+ weights = []
971
+
972
+ # Add available facial landmarks with weights
973
+ if keypoints[0][2] > 0.3: # Nose
974
+ face_points.append(keypoints[0][:2])
975
+ weights.append(1.0)
976
+
977
+ # Eyes (higher weight)
978
+ for i in [1, 2, 3, 4, 5, 6]:
979
+ if i < len(keypoints) and keypoints[i][2] > 0.3:
980
+ face_points.append(keypoints[i][:2])
981
+ weights.append(1.5)
982
 
983
+ # Ears
984
+ for i in [7, 8]:
985
+ if i < len(keypoints) and keypoints[i][2] > 0.3:
986
+ face_points.append(keypoints[i][:2])
987
+ weights.append(1.0)
988
+
989
+ if len(face_points) < 2:
990
+ return None
991
+
992
+ # Weighted average
993
+ face_points = np.array(face_points)
994
+ weights = np.array(weights)
995
+
996
+ weighted_center = np.average(face_points, axis=0, weights=weights)
997
+ return weighted_center
998
+
999
+
1000
+ def calculate_simple_posture_consistency(pose_data):
1001
+ """Simple fallback posture calculation without roll correction"""
1002
+ if not pose_data:
1003
+ return None
1004
+
1005
+ spine_angles = []
1006
+
1007
+ for frame_idx, kp in pose_data.items():
1008
+ if kp and len(kp) > 24:
1009
+ # Check visibility with very relaxed threshold
1010
+ if (kp[11][2] > 0.2 and kp[12][2] > 0.2 and
1011
+ kp[23][2] > 0.2 and kp[24][2] > 0.2):
1012
+
1013
+ # Simple spine angle without roll correction
1014
+ shoulder_mid = ((kp[11][0] + kp[12][0]) / 2, (kp[11][1] + kp[12][1]) / 2)
1015
+ hip_mid = ((kp[23][0] + kp[24][0]) / 2, (kp[23][1] + kp[24][1]) / 2)
1016
+
1017
+ spine_vector = (shoulder_mid[0] - hip_mid[0], shoulder_mid[1] - hip_mid[1])
1018
+ spine_angle = math.degrees(math.atan2(abs(spine_vector[0]), abs(spine_vector[1])))
1019
+ spine_angles.append(spine_angle)
1020
+
1021
+ if len(spine_angles) < 2:
1022
+ return None
1023
+
1024
+ # Simple consistency calculation
1025
+ mean_angle = np.mean(spine_angles)
1026
+ std_angle = np.std(spine_angles)
1027
+
1028
+ if mean_angle < 1:
1029
+ return None
1030
+
1031
+ # Convert to 0-100 scale
1032
+ cv = std_angle / mean_angle
1033
+ consistency = max(0, 100 - (cv * 100))
1034
+
1035
+ return consistency
1036
+
1037
 
1038
+ def calculate_simple_head_sway(pose_data):
1039
+ """Simple fallback head sway calculation without scale normalization"""
1040
+ frames = sorted(pose_data.keys())
1041
+ if len(frames) < 3:
1042
+ return None
1043
+
1044
+ head_positions = []
1045
+
1046
+ for frame_idx in frames:
1047
+ kp = pose_data[frame_idx]
1048
+ if kp and len(kp) > 0 and kp[0][2] > 0.2: # Very relaxed nose confidence
1049
+ head_positions.append([kp[0][0], kp[0][1]])
1050
+
1051
+ if len(head_positions) < 3:
1052
+ return None
1053
+
1054
+ # Use first position as reference
1055
+ reference_pos = head_positions[0]
1056
+
1057
+ # Calculate max movement in pixels
1058
+ max_movement = 0
1059
+ for pos in head_positions:
1060
+ dx = abs(pos[0] - reference_pos[0])
1061
+ dy = abs(pos[1] - reference_pos[1])
1062
+ movement = math.sqrt(dx*dx + dy*dy)
1063
+ max_movement = max(max_movement, movement)
1064
+
1065
+ # Convert to approximate percentage (assume ~100px shoulder width)
1066
+ estimated_shoulder_width = 100
1067
+ sway_pct = (max_movement / estimated_shoulder_width) * 100
1068
+
1069
+ # Clamp to reasonable range
1070
+ return min(sway_pct, 50) # Cap at 50%
1071
 
1072
+
1073
+ def calculate_dtl_weak_metrics(pose_data, swing_phases, face_on_metrics, address_idx, top_idx, impact_idx):
1074
+ """Calculate metrics that are unreliable from DTL-only view"""
1075
+
1076
+ # Shoulder turn quality (qualitative bins instead of degrees)
1077
+ if (address_idx in pose_data and top_idx in pose_data and
1078
+ pose_data[address_idx] is not None and pose_data[top_idx] is not None):
1079
+
1080
+ addr_kp = pose_data[address_idx]
1081
+ top_kp = pose_data[top_idx]
1082
+
1083
+ if (len(addr_kp) > 12 and len(top_kp) > 12 and
1084
+ addr_kp[11][2] > 0.3 and addr_kp[12][2] > 0.3 and
1085
+ top_kp[11][2] > 0.3 and top_kp[12][2] > 0.3):
1086
+
1087
+ # Simple 2D shoulder line comparison (approximate)
1088
+ addr_shoulder_line = np.array(addr_kp[12][:2]) - np.array(addr_kp[11][:2])
1089
+ top_shoulder_line = np.array(top_kp[12][:2]) - np.array(top_kp[11][:2])
1090
+
1091
+ # Calculate approximate turn based on line angle change
1092
+ addr_angle = math.atan2(addr_shoulder_line[1], addr_shoulder_line[0])
1093
+ top_angle = math.atan2(top_shoulder_line[1], top_shoulder_line[0])
1094
+ angle_diff = abs(math.degrees(top_angle - addr_angle))
1095
+
1096
+ # Bin into qualitative categories
1097
+ if angle_diff < 10:
1098
+ quality = 'limited'
1099
+ elif angle_diff < 25:
1100
+ quality = 'moderate'
1101
+ else:
1102
+ quality = 'full'
1103
+
1104
+ face_on_metrics["shoulder_turn_quality"] = {'value': quality, 'status': 'dtl_approximate'}
1105
+
1106
+ # Hip rotation and X-Factor marked as needing face-on view
1107
+ face_on_metrics["hip_rotation_impact_deg"] = {'value': None, 'status': 'needs_face_on'}
1108
+ face_on_metrics["x_factor_top_deg"] = {'value': None, 'status': 'needs_face_on'}
1109
+
1110
+
1111
+ def calculate_back_tilt_degree(pose_data, address_idx):
1112
+ """Calculate back tilt: angle between vertical and spine vector in de-rolled camera plane
1113
+
1114
+ Args:
1115
+ pose_data (dict): Dictionary mapping frame indices to pose keypoints
1116
+ address_idx (int): Frame index for address/setup position
1117
+
1118
+ Returns:
1119
+ float: Back tilt angle in degrees (None if calculation fails)
1120
+ """
1121
  if address_idx not in pose_data or pose_data[address_idx] is None:
1122
  return None
1123
 
 
1125
  if len(kp) < 25: # Need at least up to hip keypoints
1126
  return None
1127
 
1128
+ # Check required keypoints visibility - shoulders and hips
1129
+ required_points = [11, 12, 23, 24] # left shoulder, right shoulder, left hip, right hip
1130
  if not all(i < len(kp) and kp[i][2] > 0.3 for i in required_points):
1131
  return None
1132
 
1133
  try:
1134
+ # Calculate spine vector from hip midpoint to shoulder midpoint
1135
  shoulder_mid = ((kp[11][0] + kp[12][0]) / 2, (kp[11][1] + kp[12][1]) / 2)
1136
  hip_mid = ((kp[23][0] + kp[24][0]) / 2, (kp[23][1] + kp[24][1]) / 2)
1137
 
1138
+ # Calculate spine vector (from hips to shoulders)
1139
  spine_vector = (shoulder_mid[0] - hip_mid[0], shoulder_mid[1] - hip_mid[1])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1140
 
1141
+ # Calculate angle from vertical (positive = forward lean, negative = backward lean)
1142
+ # atan2(horizontal_component, vertical_component) gives angle from vertical
1143
+ back_tilt_rad = math.atan2(abs(spine_vector[0]), abs(spine_vector[1]))
1144
+ back_tilt_deg = math.degrees(back_tilt_rad)
1145
+
1146
+ return back_tilt_deg
1147
 
1148
+ except (ZeroDivisionError, ValueError, IndexError):
1149
+ return None
1150
+
1151
+
1152
+ def calculate_knee_bend_degree(pose_data, address_idx):
1153
+ """Calculate knee bend angle during setup position
1154
+
1155
+ Args:
1156
+ pose_data (dict): Dictionary mapping frame indices to pose keypoints
1157
+ address_idx (int): Frame index for address/setup position
1158
+
1159
+ Returns:
1160
+ float: Knee bend angle in degrees (None if calculation fails)
1161
+ """
1162
+ if address_idx not in pose_data or pose_data[address_idx] is None:
1163
+ return None
1164
+
1165
+ kp = pose_data[address_idx]
1166
+ if len(kp) < 28: # Need at least up to ankle keypoints
1167
+ return None
1168
+
1169
+ # Check required keypoints visibility - hips, knees, ankles
1170
+ # Using right leg: right hip (24), right knee (26), right ankle (28)
1171
+ required_points = [24, 26, 28] # right hip, right knee, right ankle
1172
+ if not all(i < len(kp) and kp[i][2] > 0.3 for i in required_points):
1173
+ # Try left leg if right leg not visible: left hip (23), left knee (25), left ankle (27)
1174
+ required_points = [23, 25, 27]
1175
+ if not all(i < len(kp) and kp[i][2] > 0.3 for i in required_points):
1176
+ return None
1177
+
1178
+ try:
1179
+ # Use the leg with better visibility
1180
+ if kp[26][2] > kp[25][2]: # Use right leg
1181
+ hip_point = kp[24][:2]
1182
+ knee_point = kp[26][:2]
1183
+ ankle_point = kp[28][:2]
1184
+ else: # Use left leg
1185
+ hip_point = kp[23][:2]
1186
+ knee_point = kp[25][:2]
1187
+ ankle_point = kp[27][:2]
1188
+
1189
+ # Calculate vectors
1190
+ thigh_vector = (knee_point[0] - hip_point[0], knee_point[1] - hip_point[1])
1191
+ shin_vector = (ankle_point[0] - knee_point[0], ankle_point[1] - knee_point[1])
1192
+
1193
+ # Calculate angle between thigh and shin vectors
1194
+ dot_product = thigh_vector[0] * shin_vector[0] + thigh_vector[1] * shin_vector[1]
1195
+ thigh_magnitude = math.sqrt(thigh_vector[0]**2 + thigh_vector[1]**2)
1196
+ shin_magnitude = math.sqrt(shin_vector[0]**2 + shin_vector[1]**2)
1197
+
1198
+ if thigh_magnitude == 0 or shin_magnitude == 0:
1199
+ return None
1200
+
1201
+ cos_angle = dot_product / (thigh_magnitude * shin_magnitude)
1202
+ cos_angle = max(-1.0, min(1.0, cos_angle)) # Clamp to valid range
1203
+
1204
+ # Calculate the angle at the knee joint (hip-knee-ankle angle)
1205
+ # This is the internal angle at the knee
1206
+ angle_between_rad = math.acos(cos_angle)
1207
+ hip_knee_ankle_angle = math.degrees(angle_between_rad)
1208
+
1209
+ # Calculate knee flexion as 180 - hip_knee_ankle_angle
1210
+ # When leg is straight, hip-knee-ankle ≈ 180°, so flexion ≈ 0°
1211
+ # When knee is bent, hip-knee-ankle < 180°, so flexion > 0°
1212
+ knee_flexion_deg = 180.0 - hip_knee_ankle_angle
1213
+
1214
+ # Ensure flexion is non-negative
1215
+ knee_flexion_deg = max(0.0, knee_flexion_deg)
1216
+
1217
+
1218
+ return knee_flexion_deg
1219
+
1220
+ except (ZeroDivisionError, ValueError, IndexError):
1221
  return None
1222
 
1223
 
1224
  def prepare_data_for_llm(pose_data, swing_phases, trajectory_data=None, fps=30.0, frame_shape=None, frame_timestamps_ms=None, total_ms=None):
1225
  """
1226
+ Prepare swing data for LLM analysis - 4 DTL-reliable core metrics
1227
 
1228
  Args:
1229
  pose_data (dict): Dictionary mapping frame indices to pose keypoints
 
1235
  total_ms (float, optional): Total video duration in milliseconds
1236
 
1237
  Returns:
1238
+ dict: Formatted swing data for LLM with 4 DTL-reliable core metrics
1239
  """
1240
 
1241
  # Calculate phase durations and timing metrics
 
1253
  total_ms = total_frames * (1000.0 / fps) # fallback estimate
1254
  dt, actual_fps = _dt_and_fps(frame_timestamps_ms, total_frames, total_ms)
1255
 
1256
+ # Compute the 4 DTL-reliable core metrics with improved accuracy
1257
  core_metrics = compute_core_metrics(pose_data, swing_phases, frame_timestamps_ms, total_ms, player_handedness='right')
1258
 
1259
  # Prepare the simplified structured data
 
1287
  "actual_fps": round(actual_fps, 1)
1288
  },
1289
 
1290
+ # Only the 4 DTL-reliable core metrics
1291
  "core_metrics": core_metrics
1292
  }
1293
 
 
1296
 
1297
  def create_llm_prompt(analysis_data):
1298
  """
1299
+ Create a formatted prompt using the template and analysis data - 4 DTL-reliable core metrics
1300
 
1301
  Args:
1302
  analysis_data (dict): Processed swing analysis data with core metrics
 
1312
  # Format swing phase data (keep for LLM context)
1313
  swing_phase_data = ""
1314
  for phase_name, phase_data in swing_phases.items():
1315
+ swing_phase_data += f"- {phase_name.title()}: {phase_data.get('frame_count', 0)} frames\n"
1316
+ swing_phase_data += f"- Total Swing: {timing_metrics.get('total_swing_frames', 0)} frames\n"
1317
+
1318
+ # Format the DTL-reliable core metrics (5 position-based metrics)
1319
+ core_mechanics = f"- Shaft Angle @ Top: {format_metric_for_llm(core_metrics.get('shaft_angle_top', {}), '°')}\n"
1320
+ core_mechanics += f"- Head Sway: {format_metric_for_llm(core_metrics.get('head_sway_pct', {}), '% of shoulder width')}\n"
1321
+ core_mechanics += f"- Back Tilt @ Setup: {format_metric_for_llm(core_metrics.get('back_tilt_deg', {}), '°')}\n"
1322
+ core_mechanics += f"- Knee Bend @ Setup: {format_metric_for_llm(core_metrics.get('knee_bend_deg', {}), '°')}\n"
1323
+ core_mechanics += f"- Wrist Pattern: {format_metric_for_llm(core_metrics.get('wrist_pattern', {}))}\n"
1324
+
1325
+ # DTL-limited metrics (shown with appropriate warnings)
1326
+ core_mechanics += "\n** DTL-Limited Metrics (approximate only) **\n"
1327
+ core_mechanics += f"- Shoulder Turn Quality: {format_metric_for_llm(core_metrics.get('shoulder_turn_quality', {}))}\n"
1328
+
1329
+ # Face-on required metrics
1330
+ core_mechanics += "\n** Requires Face-On View for Accuracy **\n"
1331
+ core_mechanics += f"- Hip Rotation @ Impact: {format_metric_for_llm(core_metrics.get('hip_rotation_impact_deg', {}), '°')}\n"
1332
+ core_mechanics += f"- X-Factor @ Top: {format_metric_for_llm(core_metrics.get('x_factor_top_deg', {}), '°')}\n"
1333
+
1334
+ # Remove unused sections completely
1335
 
1336
  # Format the template
1337
  return COACH_PROMPT.format(
1338
  swing_phase_data=swing_phase_data,
1339
+ core_mechanics=core_mechanics
 
 
 
1340
  )
1341
 
1342
 
 
1354
  formatted_analysis = {
1355
  'classification': 50, # Default to 50%
1356
  'metric_evaluations': {
1357
+ 'shaft_angle': '',
1358
+ 'head_sway': '',
1359
+ 'back_tilt': '',
1360
+ 'knee_bend': '',
1361
+ 'wrist_pattern': ''
1362
  }
1363
  }
1364
 
 
1384
  formatted_analysis['classification'] = max(10, min(100, percentage))
1385
  break
1386
 
1387
+ # Extract metric evaluations (updated for DTL-reliable metrics)
1388
  metric_patterns = {
1389
+ 'shaft_angle': r'\*\*1\.\s*Shaft\s*Angle.*?Evaluation:\*\*\s*(.*?)(?=\*\*2\.|$)',
1390
+ 'head_sway': r'\*\*2\.\s*Head\s*Sway.*?Evaluation:\*\*\s*(.*?)(?=\*\*3\.|$)',
1391
+ 'back_tilt': r'\*\*3\.\s*Back\s*Tilt.*?Evaluation:\*\*\s*(.*?)(?=\*\*4\.|$)',
1392
+ 'knee_bend': r'\*\*4\.\s*Knee\s*Bend.*?Evaluation:\*\*\s*(.*?)(?=\*\*5\.|$)',
1393
+ 'wrist_pattern': r'\*\*5\.\s*Wrist\s*Pattern.*?Evaluation:\*\*\s*(.*?)(?=\*\*|$)'
1394
  }
1395
 
1396
  for metric_key, pattern in metric_patterns.items():
 
1401
  evaluation_clean = re.sub(r'\s+', ' ', evaluation_text).strip()
1402
  formatted_analysis['metric_evaluations'][metric_key] = evaluation_clean
1403
 
1404
+ # Fallback evaluations if any metrics are missing (DTL-reliable metrics only)
1405
  fallback_evaluations = {
1406
+ 'shaft_angle': 'Analysis in progress. Shaft angle at top determines club position relative to target line. Neutral (±5°) is ideal for consistent ball striking.',
1407
+ 'head_sway': 'Analysis in progress. Head sway measures lateral head movement as percentage of shoulder width during the swing. Elite players maintain <15% for stability.',
1408
+ 'back_tilt': 'Analysis in progress. Back tilt measures spine forward lean angle during setup. Professional golfers maintain 20-40° of forward tilt for optimal posture and power.',
1409
+ 'knee_bend': 'Analysis in progress. Knee bend measures knee flexion angle during setup. Elite players maintain 15-35° of knee bend for athletic stance and stability.',
1410
+ 'wrist_pattern': 'Analysis in progress. Wrist pattern evaluates hinge sequence and casting. Elite players maintain set→hold→release timing without early casting.'
1411
  }
1412
 
1413
  for metric_key, fallback in fallback_evaluations.items():
 
1590
  # Display metric evaluations
1591
  st.subheader("Individual Metric Evaluations")
1592
 
1593
+ # Define the DTL-reliable metric names and order (position-based only)
1594
  metrics = [
1595
+ ('shaft_angle', 'Shaft Angle @ Top'),
1596
+ ('head_sway', 'Head Sway'),
1597
+ ('back_tilt', 'Back Tilt @ Setup'),
1598
+ ('knee_bend', 'Knee Bend @ Setup'),
1599
+ ('wrist_pattern', 'Wrist Pattern')
1600
  ]
1601
 
1602
  for metric_key, metric_display_name in metrics:
app/models/math_utils.py CHANGED
@@ -1,10 +1,11 @@
1
  """
2
- Minimal math utilities for golf swing analysis
 
3
  """
4
  import math
5
  import numpy as np
6
 
7
- # New functions for 5 core metrics implementation
8
  def _angle_deg_2d(ax, ay, bx, by):
9
  """angle of vector a->b vs +X axis, in degrees"""
10
  return math.degrees(math.atan2(by - ay, bx - ax))
@@ -21,6 +22,20 @@ def rel_rotation_deg(angle_now, angle_addr):
21
  while d < -180: d += 360
22
  return d
23
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  MP_SHOULDERS = (11, 12)
25
  MP_HIPS = (23, 24)
26
 
@@ -39,62 +54,9 @@ def ang_diff(a, b):
39
  d = (a - b + 180) % 360 - 180
40
  return d
41
 
42
- def pair_rotation_deg(kp_now, kp_addr, ids_pair, clamp=None):
43
- """Rotation between the same landmark pair now vs address, robust in 2D."""
44
- if kp_now is None or kp_addr is None:
45
- return None
46
- L, R = ids_pair
47
- if not (vis_ok(kp_now, (L, R)) and vis_ok(kp_addr, (L, R))):
48
- return None
49
-
50
- pL_now, pR_now = kp_now[L][:2], kp_now[R][:2]
51
- pL_addr, pR_addr = kp_addr[L][:2], kp_addr[R][:2]
52
-
53
- ang_now = seg_angle(pL_now, pR_now)
54
- ang_addr = seg_angle(pL_addr, pR_addr)
55
- rot = abs(ang_diff(ang_now, ang_addr))
56
- if clamp:
57
- lo, hi = clamp
58
- rot = max(lo, min(hi, rot))
59
- return rot
60
-
61
- def med1d(vals, k=5):
62
- """Median filter that ignores None; returns None if a window has no numbers."""
63
- if not vals:
64
- return vals
65
- x = np.array([np.nan if v is None else float(v) for v in vals], dtype=float)
66
- n = len(x)
67
- if n < k:
68
- # simple: return the original list; but convert NaN back to None
69
- return [None if np.isnan(v) else float(v) for v in x]
70
-
71
- pad = k // 2
72
- xp = np.pad(x, (pad, pad), mode='edge')
73
-
74
- out = []
75
- for i in range(n):
76
- win = xp[i:i+k]
77
- # ignore NaNs
78
- nn = win[~np.isnan(win)]
79
- if nn.size == 0:
80
- out.append(None) # <-- key: not NaN
81
- else:
82
- out.append(float(np.median(nn)))
83
- return out
84
-
85
 
86
  def _dt_and_fps(frame_timestamps_ms, frames: int, total_ms: float):
87
- """
88
- Calculate time delta and FPS from frame data.
89
-
90
- Args:
91
- frame_timestamps_ms: List of frame timestamps in milliseconds (optional)
92
- frames: Total number of frames
93
- total_ms: Total duration in milliseconds
94
-
95
- Returns:
96
- tuple: (dt in seconds, fps)
97
- """
98
  if frame_timestamps_ms and len(frame_timestamps_ms) >= 2:
99
  dt = (frame_timestamps_ms[-1] - frame_timestamps_ms[0]) / max(len(frame_timestamps_ms) - 1, 1) / 1000.0
100
  else:
@@ -103,247 +65,25 @@ def _dt_and_fps(frame_timestamps_ms, frames: int, total_ms: float):
103
 
104
 
105
  def detect_arm_velocity_zero_crossing(pose_data, frames):
106
- """
107
- Detect top of swing using multiple biomechanical markers for robustness.
108
-
109
- Args:
110
- pose_data: Dictionary mapping frame indices to pose keypoints
111
- frames: List of frame indices to analyze
112
-
113
- Returns:
114
- int: Frame index of top of swing (or fallback to time-based)
115
- """
116
- if len(frames) < 8: # Need minimum frames for velocity calculation
117
- return frames[len(frames)//3] if frames else 0
118
-
119
- # Try multiple methods to find top of backswing
120
- top_candidates = []
121
-
122
- # Method 1: Lead arm angular velocity zero crossing
123
- arm_angles = []
124
- valid_frames = []
125
-
126
- for frame_idx in frames:
127
- if frame_idx not in pose_data or pose_data[frame_idx] is None:
128
- continue
129
- kp = pose_data[frame_idx]
130
-
131
- # Check for lead arm keypoints (right arm for right-handed golfer)
132
- if (len(kp) > 16 and kp[12][2] > 0.4 and kp[16][2] > 0.4): # right shoulder & wrist
133
- shoulder = kp[12][:2]
134
- wrist = kp[16][:2]
135
- # Calculate arm angle relative to horizontal (more stable than vertical)
136
- angle = math.degrees(math.atan2(wrist[1] - shoulder[1], wrist[0] - shoulder[0]))
137
- arm_angles.append(angle)
138
- valid_frames.append(frame_idx)
139
-
140
- if len(arm_angles) >= 5:
141
- # Smooth the angles to reduce noise
142
- smoothed_angles = []
143
- window = 3
144
- for i in range(len(arm_angles)):
145
- start_idx = max(0, i - window//2)
146
- end_idx = min(len(arm_angles), i + window//2 + 1)
147
- smoothed_angles.append(sum(arm_angles[start_idx:end_idx]) / (end_idx - start_idx))
148
-
149
- # Calculate angular velocity with smoothed data
150
- velocities = []
151
- for i in range(1, len(smoothed_angles)):
152
- vel = smoothed_angles[i] - smoothed_angles[i-1]
153
- velocities.append(vel)
154
-
155
- # Find the most significant zero crossing (velocity → 0 or negative)
156
- for i in range(2, len(velocities)-1): # Skip first/last to avoid noise
157
- if velocities[i-1] > 0.5 and velocities[i] <= 0: # Threshold to avoid noise
158
- top_candidates.append(valid_frames[i])
159
- break
160
-
161
- # Fallback: maximum angle position
162
- if not top_candidates and smoothed_angles:
163
- max_idx = smoothed_angles.index(max(smoothed_angles))
164
- if max_idx < len(valid_frames):
165
- top_candidates.append(valid_frames[max_idx])
166
-
167
- if top_candidates:
168
- return top_candidates[0]
169
-
170
- # Final fallback: time-based estimate (typically 65-70% through backswing phase)
171
- fallback_idx = int(len(frames) * 0.65)
172
- return frames[min(fallback_idx, len(frames)-1)] if frames else 0
173
 
174
 
 
175
  def validate_metric_sanity(metric_name, value, player_handedness='right'):
176
- """
177
- Validate biomechanical metrics against tour professional ranges.
178
-
179
- Args:
180
- metric_name: Name of the metric to validate
181
- value: The calculated value
182
- player_handedness: 'right' or 'left' handed player
183
-
184
- Returns:
185
- tuple: (is_valid, corrected_value_or_none)
186
- """
187
- if value is None or value == 'n/a':
188
- return True, value
189
-
190
- # Define tour professional ranges (all absolute values now)
191
- ranges = {
192
- 'tempo_ratio': (2.0, 5.0), # Typical 2:1 to 5:1 backswing:downswing
193
- 'shoulder_rotation_top_deg': (60.0, 120.0), # Thorax rotation magnitude at top (more lenient range)
194
- 'hip_rotation_impact_deg': (20.0, 55.0), # Hip opening magnitude at impact (expanded for variation)
195
- 'x_factor_top_deg': (15.0, 60.0), # Shoulder-hip separation (expanded range)
196
- 'posture_score_pct': (50.0, 100.0) # Posture percentage (more lenient)
197
- }
198
-
199
- if metric_name not in ranges:
200
- return True, value # Unknown metric, don't validate
201
-
202
- min_val, max_val = ranges[metric_name]
203
-
204
- # Check if value is within reasonable range (values are now all absolute)
205
- if min_val <= value <= max_val:
206
- return True, value
207
- else:
208
- # Value is outside professional range - flag as unreliable
209
- return False, None
210
-
211
 
212
  def get_target_line_angle(pose_data, address_frame_idx):
213
- """
214
- Estimate target line angle from golfer's stance at address.
215
- Uses the line between feet to approximate target direction.
216
-
217
- Args:
218
- pose_data: Dictionary mapping frame indices to pose keypoints
219
- address_frame_idx: Frame index for address position
220
-
221
- Returns:
222
- float: Target line angle in degrees (0 = pointing right, 90 = pointing up)
223
- """
224
- if address_frame_idx not in pose_data or pose_data[address_frame_idx] is None:
225
- return 0.0 # Default to horizontal
226
-
227
- kp = pose_data[address_frame_idx]
228
-
229
- # Use ankle positions (27=left ankle, 28=right ankle) for stance line
230
- if (len(kp) > 28 and kp[27][2] > 0.5 and kp[28][2] > 0.5):
231
- left_ankle = kp[27][:2]
232
- right_ankle = kp[28][:2]
233
- return line_angle(left_ankle, right_ankle)
234
-
235
- # Fallback to hip line if ankles not visible
236
- if (len(kp) > 24 and kp[23][2] > 0.5 and kp[24][2] > 0.5):
237
- left_hip = kp[23][:2]
238
- right_hip = kp[24][:2]
239
- return line_angle(left_hip, right_hip)
240
-
241
- return 0.0 # Default fallback
242
-
243
 
244
  def calculate_thorax_rotation(shoulder_keypoints, reference_angle=0.0, target_line_angle=0.0, reference_keypoints=None):
245
- """
246
- Calculate thorax (upper torso) rotation for golf swing analysis.
247
- Returns constrained rotation magnitude from address position.
248
-
249
- Args:
250
- shoulder_keypoints: Tuple of (left_shoulder, right_shoulder) points
251
- reference_angle: Reference angle (e.g., at address) in degrees
252
- target_line_angle: Target line angle in degrees
253
- reference_keypoints: Optional reference shoulder positions for fallback calculation
254
-
255
- Returns:
256
- float or tuple: Thorax rotation magnitude in degrees, or (value, 'estimated') if line flip corrected
257
- """
258
- left_shoulder, right_shoulder = shoulder_keypoints
259
-
260
- # Calculate shoulder line angle
261
- current_angle = line_angle(left_shoulder, right_shoulder)
262
-
263
- # Calculate rotation relative to reference (address position)
264
- relative_rotation = rel_rotation_deg(current_angle, reference_angle)
265
-
266
- # Take absolute value for magnitude
267
- rotation_magnitude = abs(relative_rotation)
268
-
269
- # Debug: If rotation is very small, it might indicate tracking issues
270
- if rotation_magnitude < 10.0:
271
- # Try alternative calculation using shoulder displacement
272
- if reference_keypoints is not None:
273
- ref_left, ref_right = reference_keypoints
274
-
275
- # Calculate actual shoulder movement (displacement method)
276
- left_displacement = ((left_shoulder[0] - ref_left[0])**2 +
277
- (left_shoulder[1] - ref_left[1])**2)**0.5
278
- right_displacement = ((right_shoulder[0] - ref_right[0])**2 +
279
- (right_shoulder[1] - ref_right[1])**2)**0.5
280
-
281
- # If significant displacement occurred, estimate rotation
282
- if left_displacement > 20 or right_displacement > 20:
283
- # Use displacement to estimate rotation (approximate)
284
- estimated_rotation = max(left_displacement, right_displacement) * 0.5 # rough conversion
285
- rotation_magnitude = max(rotation_magnitude, min(estimated_rotation, 100.0))
286
-
287
- # Check if shoulders are reasonably spaced
288
- shoulder_distance = ((left_shoulder[0] - right_shoulder[0])**2 +
289
- (left_shoulder[1] - right_shoulder[1])**2)**0.5
290
-
291
- # If shoulders are too close together, pose estimation might be poor
292
- if shoulder_distance < 50: # pixels - shoulders should be reasonably far apart
293
- return None # Indicate unreliable measurement
294
-
295
- # If still very small but shoulders are properly spaced, use minimum for golf
296
- if rotation_magnitude < 25.0:
297
- # Golf swings always have significant shoulder rotation, so set a reasonable minimum
298
- rotation_magnitude = 35.0 # Conservative estimate for golf swing when tracking is poor
299
-
300
- # Constrain to reasonable golf swing range
301
- # If we get exactly 180° or very close, it's likely a line flip issue
302
- line_flip_corrected = False
303
- if rotation_magnitude > 160:
304
- print(f"DEBUG CALC: Line flip detected! Original magnitude: {rotation_magnitude}")
305
- # Likely a 180° flip - this indicates significant shoulder rotation
306
- # For golf swings, a 180° flip usually means ~90° actual rotation
307
- rotation_magnitude = 90.0 # Reasonable estimate for significant shoulder turn
308
- line_flip_corrected = True
309
- print(f"DEBUG CALC: Corrected to: {rotation_magnitude}")
310
-
311
- # Clamp to maximum reasonable shoulder rotation (140° is extreme but possible)
312
- rotation_magnitude = min(rotation_magnitude, 140.0)
313
-
314
- # Return with status if it was an estimate
315
- if line_flip_corrected:
316
- return rotation_magnitude, 'estimated'
317
- else:
318
- return rotation_magnitude
319
-
320
 
321
  def calculate_pelvis_rotation(hip_keypoints, reference_angle=0.0, player_handedness='right', target_line_angle=0.0):
322
- """
323
- Calculate pelvis rotation for golf swing analysis with proper golf conventions.
324
-
325
- Args:
326
- hip_keypoints: Tuple of (left_hip, right_hip) points
327
- reference_angle: Reference angle (e.g., at address) in degrees
328
- player_handedness: 'right' or 'left' handed player
329
- target_line_angle: Target line angle in degrees
330
-
331
- Returns:
332
- float: Pelvis rotation in degrees (positive = open to target for impact measurement)
333
- """
334
- left_hip, right_hip = hip_keypoints
335
-
336
- # Calculate hip line angle
337
- current_angle = line_angle(left_hip, right_hip)
338
-
339
- # Calculate rotation relative to reference
340
- rotation = rel_rotation_deg(current_angle, reference_angle)
341
-
342
- # Take absolute value for magnitude
343
- rotation_magnitude = abs(rotation)
344
-
345
- # Constrain to reasonable hip rotation range
346
- # Tour pros typically open 30-40° at impact, maximum realistic is ~60°
347
- rotation_magnitude = min(rotation_magnitude, 60.0)
348
-
349
- return rotation_magnitude
 
1
  """
2
+ Simplified math utilities for DTL golf swing analysis
3
+ Focused on the core metrics only
4
  """
5
  import math
6
  import numpy as np
7
 
8
+ # Core utility functions for DTL analysis
9
  def _angle_deg_2d(ax, ay, bx, by):
10
  """angle of vector a->b vs +X axis, in degrees"""
11
  return math.degrees(math.atan2(by - ay, bx - ax))
 
22
  while d < -180: d += 360
23
  return d
24
 
25
+ def unit_vector(vector):
26
+ """Convert vector to unit vector"""
27
+ norm = np.linalg.norm(vector)
28
+ if norm == 0:
29
+ return vector
30
+ return vector / norm
31
+
32
+ def signed_angle_2d(vec1, vec2):
33
+ """Calculate signed angle between two 2D vectors in radians"""
34
+ cross_product = vec1[0] * vec2[1] - vec1[1] * vec2[0]
35
+ dot_product = np.dot(vec1, vec2)
36
+ return math.atan2(cross_product, dot_product)
37
+
38
+ # Mediapipe landmark indices
39
  MP_SHOULDERS = (11, 12)
40
  MP_HIPS = (23, 24)
41
 
 
54
  d = (a - b + 180) % 360 - 180
55
  return d
56
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
 
58
  def _dt_and_fps(frame_timestamps_ms, frames: int, total_ms: float):
59
+ """Calculate time delta and FPS from frame data"""
 
 
 
 
 
 
 
 
 
 
60
  if frame_timestamps_ms and len(frame_timestamps_ms) >= 2:
61
  dt = (frame_timestamps_ms[-1] - frame_timestamps_ms[0]) / max(len(frame_timestamps_ms) - 1, 1) / 1000.0
62
  else:
 
65
 
66
 
67
  def detect_arm_velocity_zero_crossing(pose_data, frames):
68
+ """Simple fallback for top detection - now handled in swing_analyzer.py"""
69
+ if not frames:
70
+ return 0
71
+ return frames[len(frames)//3] # Simple fallback
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
72
 
73
 
74
+ # Legacy functions - simplified versions for compatibility
75
  def validate_metric_sanity(metric_name, value, player_handedness='right'):
76
+ """Simple validation - now handled in swing_analyzer.py"""
77
+ return True, value
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
 
79
  def get_target_line_angle(pose_data, address_frame_idx):
80
+ """Simple target line - now handled in swing_analyzer.py"""
81
+ return 0.0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82
 
83
  def calculate_thorax_rotation(shoulder_keypoints, reference_angle=0.0, target_line_angle=0.0, reference_keypoints=None):
84
+ """Simple thorax rotation - now handled in swing_analyzer.py"""
85
+ return 45.0 # Default
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86
 
87
  def calculate_pelvis_rotation(hip_keypoints, reference_angle=0.0, player_handedness='right', target_line_angle=0.0):
88
+ """Simple pelvis rotation - now handled in swing_analyzer.py"""
89
+ return 30.0 # Default
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/models/swing_analyzer.py CHANGED
@@ -1,23 +1,697 @@
1
  """
2
- Swing analysis module for golf swing segmentation and trajectory analysis
 
3
  """
4
 
5
  import numpy as np
 
 
 
 
 
6
  from .pose_estimator import calculate_joint_angles
7
- from .segmentation import segment_swing
8
 
9
  # One-liner frame mapping replacement
10
  def to_processed_idx(original_idx, sample_rate): return int(round(original_idx / max(1, sample_rate)))
11
 
12
- # Legacy functions replaced by segmentation.py - kept for compatibility if needed
13
 
 
14
 
15
- # Legacy wrapper - now redirects to new segmentation module
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
  def segment_swing_pose_based(pose_data, detections=None, sample_rate=1, frame_shape=None, **kwargs):
17
- """Legacy function - use segmentation.segment_swing directly"""
 
18
  return segment_swing(pose_data, detections, sample_rate, frame_shape, **kwargs)
19
 
20
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
  def analyze_trajectory(frames, detections, swing_phases, sample_rate=1, fps=30.0):
22
  """
23
  Simple trajectory analysis - just track ball movement after impact
 
1
  """
2
+ Simplified DTL golf swing analysis - all-in-one module
3
+ Robust and simple analysis focused on what can be reliably measured from DTL view
4
  """
5
 
6
  import numpy as np
7
+ import math
8
+ import cv2
9
+ from scipy.signal import savgol_filter
10
+ from scipy.stats import zscore
11
+ from sklearn.decomposition import PCA
12
  from .pose_estimator import calculate_joint_angles
13
+ from .math_utils import _dt_and_fps, detect_arm_velocity_zero_crossing
14
 
15
  # One-liner frame mapping replacement
16
  def to_processed_idx(original_idx, sample_rate): return int(round(original_idx / max(1, sample_rate)))
17
 
 
18
 
19
+ # ===== EVENT DETECTION =====
20
 
21
+ def find_swing_events(pose_data, fps=30, ball_xy=None):
22
+ """Find key swing events: Top, Impact, Finish"""
23
+ frames = sorted([f for f in pose_data.keys() if pose_data[f] is not None])
24
+
25
+ if len(frames) < 10:
26
+ return {
27
+ 'top': frames[len(frames)//3] if frames else 0,
28
+ 'impact': frames[len(frames)//2] if frames else 0,
29
+ 'finish': frames[-1] if frames else 0
30
+ }
31
+
32
+ # Extract hand Y coordinates
33
+ hands_y = []
34
+ valid_frames = []
35
+
36
+ for frame_idx in frames:
37
+ kp = pose_data[frame_idx]
38
+ if kp and len(kp) > 16 and kp[16][2] > 0.4: # Right wrist
39
+ hands_y.append(kp[16][1])
40
+ valid_frames.append(frame_idx)
41
+
42
+ if len(hands_y) < 5:
43
+ return {
44
+ 'top': frames[len(frames)//3],
45
+ 'impact': frames[len(frames)//2],
46
+ 'finish': frames[-1]
47
+ }
48
+
49
+ # Calculate hand velocity
50
+ hands_y = np.array(hands_y)
51
+ velocity = np.gradient(hands_y)
52
+
53
+ # Find Top (velocity goes from positive to negative)
54
+ top_idx = None
55
+ for i in range(1, len(velocity)):
56
+ if velocity[i-1] > 0 and velocity[i] <= 0:
57
+ top_idx = i
58
+ break
59
+
60
+ if top_idx is None:
61
+ top_idx = len(valid_frames) // 3
62
+
63
+ # Find Impact (local minimum after top)
64
+ impact_idx = top_idx
65
+ if top_idx < len(hands_y) - 5:
66
+ search_range = hands_y[top_idx:top_idx+20] if top_idx+20 < len(hands_y) else hands_y[top_idx:]
67
+ local_min = np.argmin(search_range)
68
+ impact_idx = top_idx + local_min
69
+
70
+ # Find Finish (end of sequence)
71
+ finish_idx = len(valid_frames) - 1
72
+
73
+ return {
74
+ 'top': valid_frames[min(top_idx, len(valid_frames)-1)],
75
+ 'impact': valid_frames[min(impact_idx, len(valid_frames)-1)],
76
+ 'finish': valid_frames[finish_idx]
77
+ }
78
+
79
+
80
+ # ===== CORE METRICS =====
81
+
82
+ def calculate_tempo_ratio(top_frame, impact_frame, address_frame=0):
83
+ """Calculate tempo ratio (backswing time / downswing time)"""
84
+ if top_frame <= address_frame or impact_frame <= top_frame:
85
+ return None
86
+
87
+ backswing_frames = top_frame - address_frame
88
+ downswing_frames = impact_frame - top_frame
89
+
90
+ if downswing_frames == 0:
91
+ return None
92
+
93
+ return backswing_frames / downswing_frames
94
+
95
+
96
+ def calculate_shaft_angle_at_top(pose_data, top_frame, target_line_vec=None):
97
+ """Calculate shaft angle at top using club vector (hand→clubhead) vs target line"""
98
+ if top_frame not in pose_data:
99
+ return None
100
+
101
+ kp = pose_data[top_frame]
102
+ # Check club visibility: shoulder, elbow, wrist (relaxed thresholds)
103
+ if not kp or len(kp) <= 16 or kp[16][2] < 0.3 or kp[14][2] < 0.3 or kp[12][2] < 0.3:
104
+ return None
105
+
106
+ # Build target line from early takeaway path if not provided
107
+ if target_line_vec is None:
108
+ target_line_vec = estimate_target_line_from_takeaway(pose_data, top_frame)
109
+ if target_line_vec is None:
110
+ # Fallback to horizontal target line
111
+ target_line_vec = np.array([1.0, 0.0])
112
+
113
+ # Get hand center position
114
+ wrist_pos = np.array(kp[16][:2]) # Right wrist
115
+ elbow_pos = np.array(kp[14][:2]) # Right elbow
116
+
117
+ # Approximate clubhead position using club length extension
118
+ # Extend from hand along forearm direction by typical club length
119
+ forearm_vec = wrist_pos - elbow_pos
120
+ forearm_length = np.linalg.norm(forearm_vec)
121
+
122
+ if forearm_length < 10: # Too short to be reliable
123
+ return None
124
+
125
+ # Normalize forearm vector and extend by club length (~110% of forearm for typical club)
126
+ forearm_unit = forearm_vec / forearm_length
127
+ club_length = forearm_length * 1.1 # Approximate club length
128
+ clubhead_pos = wrist_pos + forearm_unit * club_length
129
+
130
+ # Build TRUE club shaft vector from hand center to clubhead
131
+ shaft_vec = clubhead_pos - wrist_pos
132
+ shaft_length = np.linalg.norm(shaft_vec)
133
+
134
+ if shaft_length < 10: # Too short to be reliable
135
+ return None
136
+
137
+ shaft_vec = shaft_vec / shaft_length
138
+ target_vec = np.array(target_line_vec) / np.linalg.norm(target_line_vec)
139
+
140
+ # Calculate signed angle between club shaft and target line
141
+ cross_product = shaft_vec[0] * target_vec[1] - shaft_vec[1] * target_vec[0]
142
+ dot_product = np.dot(shaft_vec, target_vec)
143
+ angle_rad = math.atan2(cross_product, dot_product)
144
+ angle_deg = math.degrees(angle_rad)
145
+
146
+ # Fold angle to -90°...+90° range (across-line is positive, laid-off is negative)
147
+ while angle_deg > 90:
148
+ angle_deg -= 180
149
+ while angle_deg < -90:
150
+ angle_deg += 180
151
+
152
+ # Enhanced QC check: if |angle| > 60°, mark as unstable (sanity gate per feedback)
153
+ if abs(angle_deg) > 60:
154
+ return None # Unstable measurement - shaft angle magnitude >60° rejected
155
+
156
+ return angle_deg
157
+
158
+
159
+ def estimate_target_line_from_takeaway(pose_data, top_frame, lookback_frames=10):
160
+ """Estimate target line from early takeaway hand/club movement using PCA"""
161
+ # Get frames from early in backswing
162
+ frames = sorted([f for f in pose_data.keys() if f <= top_frame])
163
+ if len(frames) < 5:
164
+ return None
165
+
166
+ takeaway_frames = frames[:min(lookback_frames, len(frames)//2)]
167
+ hand_positions = []
168
+
169
+ for frame_idx in takeaway_frames:
170
+ kp = pose_data[frame_idx]
171
+ if kp and len(kp) > 16 and kp[16][2] > 0.3: # Right wrist (relaxed)
172
+ hand_positions.append(kp[16][:2])
173
+
174
+ if len(hand_positions) < 3:
175
+ return None
176
+
177
+ # Use PCA to find dominant direction of hand movement
178
+ positions = np.array(hand_positions)
179
+ if len(positions) < 2:
180
+ return None
181
+
182
+ # Center the data
183
+ centered = positions - np.mean(positions, axis=0)
184
+
185
+ # Simple PCA: find direction of maximum variance
186
+ cov_matrix = np.cov(centered.T)
187
+ eigenvalues, eigenvectors = np.linalg.eig(cov_matrix)
188
+
189
+ # Principal component (direction of max variance)
190
+ principal_direction = eigenvectors[:, np.argmax(eigenvalues)]
191
+
192
+ return principal_direction
193
+
194
+
195
+ def calculate_head_sway(pose_data, fallback_shoulder_width=None, target_line_vec=None):
196
+ """Calculate head sway using head center, measuring perpendicular to target line"""
197
+ frames = sorted(pose_data.keys())
198
+ if len(frames) < 5:
199
+ return None, None
200
+
201
+ # Calculate robust shoulder width from clean setup frames
202
+ shoulder_width = calculate_robust_shoulder_width(pose_data)
203
+ if shoulder_width is None:
204
+ if fallback_shoulder_width and fallback_shoulder_width > 10:
205
+ shoulder_width = fallback_shoulder_width
206
+ else:
207
+ return None, None
208
+
209
+ # Get target line for perpendicular measurement
210
+ if target_line_vec is None:
211
+ # Estimate from early takeaway or use horizontal default
212
+ target_line_vec = estimate_target_line_from_takeaway(pose_data, frames[-1])
213
+ if target_line_vec is None:
214
+ target_line_vec = np.array([1.0, 0.0]) # Horizontal default
215
+
216
+ # Normalize target line and get perpendicular vector
217
+ target_unit = np.array(target_line_vec) / np.linalg.norm(target_line_vec)
218
+ perp_unit = np.array([-target_unit[1], target_unit[0]]) # 90° rotation
219
+
220
+ # Get head center positions with quality filtering
221
+ head_positions = []
222
+ valid_frames = []
223
+
224
+ for frame_idx in frames:
225
+ kp = pose_data[frame_idx]
226
+ if kp and len(kp) > 7: # Need multiple facial points
227
+ # Calculate head center from available facial landmarks
228
+ head_center = calculate_head_center(kp)
229
+ if head_center is not None:
230
+ head_positions.append(head_center)
231
+ valid_frames.append(frame_idx)
232
+
233
+ if len(head_positions) < 3:
234
+ return None, None
235
+
236
+ head_positions = np.array(head_positions)
237
+
238
+ # Use median of first few frames as reference (more stable than first frame)
239
+ setup_positions = head_positions[:min(5, len(head_positions)//3)]
240
+ reference_pos = np.median(setup_positions, axis=0)
241
+
242
+ # Calculate movement relative to reference
243
+ movements = head_positions - reference_pos
244
+
245
+ # Project movements onto perpendicular-to-target axis (lateral sway)
246
+ lateral_movements = np.dot(movements, perp_unit)
247
+ # Project onto target line axis (forward/back movement)
248
+ sagittal_movements = np.dot(movements, target_unit)
249
+
250
+ # Convert to percentage of shoulder width
251
+ lateral_sway = (lateral_movements / shoulder_width) * 100
252
+ sagittal_sway = (sagittal_movements / shoulder_width) * 100
253
+
254
+ # Apply temporal smoothing to reduce noise
255
+ if len(lateral_sway) > 5:
256
+ lateral_sway = apply_temporal_smoothing(lateral_sway)
257
+ sagittal_sway = apply_temporal_smoothing(sagittal_sway)
258
+
259
+ # Clamp extreme values that indicate scale failures (hide if >100%)
260
+ lateral_sway = np.clip(lateral_sway, -100, 100)
261
+ sagittal_sway = np.clip(sagittal_sway, -100, 100)
262
+
263
+ return lateral_sway.tolist(), sagittal_sway.tolist()
264
+
265
+
266
+ def calculate_head_center(kp):
267
+ """Calculate head center from available facial landmarks with fallback to nose"""
268
+ # Try to use multiple facial points for better head center
269
+ facial_points = []
270
+
271
+ # Nose (0)
272
+ if len(kp) > 0 and kp[0][2] > 0.3:
273
+ facial_points.append(np.array(kp[0][:2]))
274
+
275
+ # Eyes (1, 2) if available
276
+ if len(kp) > 2:
277
+ if kp[1][2] > 0.3: # Left eye
278
+ facial_points.append(np.array(kp[1][:2]))
279
+ if kp[2][2] > 0.3: # Right eye
280
+ facial_points.append(np.array(kp[2][:2]))
281
+
282
+ # Ears (7, 8) if available
283
+ if len(kp) > 8:
284
+ if kp[7][2] > 0.3: # Left ear
285
+ facial_points.append(np.array(kp[7][:2]))
286
+ if kp[8][2] > 0.3: # Right ear
287
+ facial_points.append(np.array(kp[8][:2]))
288
+
289
+ if len(facial_points) == 0:
290
+ return None
291
+ elif len(facial_points) == 1:
292
+ return facial_points[0] # Just nose
293
+ else:
294
+ # Average of available facial landmarks
295
+ return np.mean(facial_points, axis=0)
296
+
297
+
298
+ def calculate_robust_shoulder_width(pose_data, max_drift_pct=25):
299
+ """Calculate robust shoulder width from clean setup frames"""
300
+ frames = sorted(pose_data.keys())
301
+ setup_frames = frames[:min(10, len(frames)//3)] # Use first third or 10 frames
302
+
303
+ shoulder_widths = []
304
+
305
+ for frame_idx in setup_frames:
306
+ kp = pose_data[frame_idx]
307
+ if (kp and len(kp) > 12 and
308
+ kp[11][2] > 0.4 and kp[12][2] > 0.4): # Relaxed confidence shoulders
309
+
310
+ left_shoulder = np.array(kp[11][:2])
311
+ right_shoulder = np.array(kp[12][:2])
312
+ width = np.linalg.norm(right_shoulder - left_shoulder)
313
+
314
+ if width > 10: # Minimum plausible shoulder width in pixels
315
+ shoulder_widths.append(width)
316
+
317
+ if len(shoulder_widths) < 2:
318
+ return None
319
+
320
+ # Use median for robustness
321
+ median_width = np.median(shoulder_widths)
322
+
323
+ # Filter out frames with excessive drift
324
+ stable_widths = []
325
+ for width in shoulder_widths:
326
+ drift_pct = abs(width - median_width) / median_width * 100
327
+ if drift_pct <= max_drift_pct:
328
+ stable_widths.append(width)
329
+
330
+ if len(stable_widths) < 2:
331
+ return None
332
+
333
+ return np.median(stable_widths)
334
+
335
+
336
+ def apply_temporal_smoothing(signal, window_size=5):
337
+ """Apply simple moving average smoothing"""
338
+ if len(signal) < window_size:
339
+ return signal
340
+
341
+ smoothed = np.convolve(signal, np.ones(window_size)/window_size, mode='same')
342
+ return smoothed
343
+
344
+
345
+ def calculate_wrist_hinge_pattern(pose_data, events):
346
+ """Calculate wrist hinge pattern and detect early casting - requires two signals for red badge"""
347
+ frames = sorted(pose_data.keys())
348
+ wrist_angles = []
349
+ frame_indices = []
350
+ club_visibility_good = True
351
+
352
+ for frame_idx in frames:
353
+ kp = pose_data[frame_idx]
354
+ if kp and len(kp) > 16:
355
+ shoulder = kp[12] # Right shoulder
356
+ elbow = kp[14] # Right elbow
357
+ wrist = kp[16] # Right wrist
358
+
359
+ # Check club tip visibility (stricter for red badge assessment)
360
+ if all(point[2] > 0.5 for point in [shoulder, elbow, wrist]):
361
+ # Calculate wrist hinge angle
362
+ forearm_vec = np.array([wrist[0] - elbow[0], wrist[1] - elbow[1]])
363
+ upper_arm_vec = np.array([elbow[0] - shoulder[0], elbow[1] - shoulder[1]])
364
+
365
+ cos_angle = np.dot(forearm_vec, upper_arm_vec) / (
366
+ np.linalg.norm(forearm_vec) * np.linalg.norm(upper_arm_vec)
367
+ )
368
+ cos_angle = np.clip(cos_angle, -1.0, 1.0)
369
+ angle = math.degrees(math.acos(cos_angle))
370
+
371
+ wrist_angles.append(180 - angle) # Hinge angle
372
+ frame_indices.append(frame_idx)
373
+ else:
374
+ # Club tip visibility compromised
375
+ if any(point[2] < 0.4 for point in [shoulder, elbow, wrist]):
376
+ club_visibility_good = False
377
+
378
+ if len(wrist_angles) < 3:
379
+ return {'pattern': 'insufficient_data', 'casting': False, 'confidence': 'low'}
380
+
381
+ # Check for early casting - need TWO signals for red badge
382
+ top_frame = events.get('top', 0)
383
+ impact_frame = events.get('impact', 0)
384
+
385
+ wrist_angle_trend_bad = False
386
+ shaft_lag_cue_bad = False # Would need additional club head tracking
387
+
388
+ if top_frame in frame_indices and impact_frame in frame_indices:
389
+ top_idx = frame_indices.index(top_frame)
390
+ top_angle = wrist_angles[top_idx]
391
+
392
+ # Signal 1: Wrist angle trend analysis
393
+ early_release_count = 0
394
+ for i in range(top_idx, min(len(wrist_angles), frame_indices.index(impact_frame))):
395
+ if wrist_angles[i] < top_angle * 0.8: # 20% decrease
396
+ early_release_count += 1
397
+
398
+ if early_release_count >= 2: # Multiple frames showing early release
399
+ wrist_angle_trend_bad = True
400
+
401
+ # For DTL view, we can't reliably detect shaft-lag cue (need face-on or 3D)
402
+ # So be conservative: only amber unless we have clear evidence
403
+
404
+ if wrist_angle_trend_bad and club_visibility_good:
405
+ # Only one reliable signal available (wrist angle trend)
406
+ # Per feedback: need TWO signals for red, so stay amber with caution
407
+ pattern = 'early_cast_caution'
408
+ casting = True
409
+ confidence = 'moderate'
410
+ elif wrist_angle_trend_bad:
411
+ pattern = 'early_cast_possible'
412
+ casting = True
413
+ confidence = 'low'
414
+ else:
415
+ pattern = 'set_hold_release'
416
+ casting = False
417
+ confidence = 'good'
418
+
419
+ return {
420
+ 'pattern': pattern,
421
+ 'casting': casting,
422
+ 'confidence': confidence,
423
+ 'club_visibility': club_visibility_good
424
+ }
425
+
426
+
427
+ # ===== QUALITY CONTROL =====
428
+
429
+ def assess_quality_flags(pose_data, frames=None, frame_shape=None):
430
+ """Assess quality flags for hiding unstable metrics"""
431
+ flags = {
432
+ 'blur_high': False,
433
+ 'occlusion_high': False,
434
+ 'subject_too_small': False,
435
+ 'camera_roll_high': False,
436
+ 'shoulder_drift_high': False,
437
+ 'overall_stable': True
438
+ }
439
+
440
+ # Calculate occlusion score
441
+ if pose_data:
442
+ total_visibility = 0
443
+ total_keypoints = 0
444
+ key_joints = [11, 12, 13, 14, 15, 16, 23, 24] # Key joints
445
+
446
+ for frame_idx, kp in pose_data.items():
447
+ if kp and len(kp) > max(key_joints):
448
+ for joint_idx in key_joints:
449
+ total_visibility += kp[joint_idx][2]
450
+ total_keypoints += 1
451
+
452
+ avg_visibility = total_visibility / max(total_keypoints, 1)
453
+ flags['occlusion_high'] = avg_visibility < 0.6
454
+
455
+ # Calculate shoulder width drift
456
+ widths = []
457
+ for frame_idx, kp in pose_data.items():
458
+ if kp and len(kp) > 12:
459
+ left_shoulder = kp[11]
460
+ right_shoulder = kp[12]
461
+
462
+ if left_shoulder[2] > 0.5 and right_shoulder[2] > 0.5:
463
+ width = np.linalg.norm(
464
+ np.array(left_shoulder[:2]) - np.array(right_shoulder[:2])
465
+ )
466
+ widths.append(width)
467
+
468
+ if len(widths) > 2:
469
+ median_width = np.median(widths)
470
+ max_drift = max(abs(w - median_width) for w in widths)
471
+ drift_pct = (max_drift / median_width) * 100
472
+ flags['shoulder_drift_high'] = drift_pct > 25
473
+
474
+ # Overall stability
475
+ flags['overall_stable'] = not any([
476
+ flags['occlusion_high'],
477
+ flags['shoulder_drift_high']
478
+ ])
479
+
480
+ return flags
481
+
482
+
483
+ def classify_metric(metric_name, value):
484
+ """Classify metrics with traffic light system"""
485
+ if value is None:
486
+ return {'color': 'gray', 'assessment': 'unknown', 'value': None}
487
+
488
+ if metric_name == 'tempo':
489
+ if 2.7 <= value <= 3.3:
490
+ return {'color': 'green', 'assessment': 'good', 'value': value}
491
+ elif 2.4 <= value < 2.7 or 3.3 < value <= 3.6:
492
+ return {'color': 'amber', 'assessment': 'acceptable', 'value': value}
493
+ else:
494
+ return {'color': 'red', 'assessment': 'needs_improvement', 'value': value}
495
+
496
+ elif metric_name == 'shaft_top':
497
+ abs_angle = abs(value)
498
+ if abs_angle <= 10:
499
+ color = 'green'
500
+ elif abs_angle <= 15:
501
+ color = 'amber'
502
+ else:
503
+ color = 'red'
504
+
505
+ direction = 'across_the_line' if value > 0 else 'laid_off'
506
+ return {'color': color, 'assessment': color, 'direction': direction, 'value': value}
507
+
508
+ elif metric_name == 'head_sway':
509
+ if value <= 25:
510
+ return {'color': 'green', 'assessment': 'good', 'value': value}
511
+ elif value <= 35:
512
+ return {'color': 'amber', 'assessment': 'acceptable', 'value': value}
513
+ else:
514
+ return {'color': 'red', 'assessment': 'needs_improvement', 'value': value}
515
+
516
+ return {'color': 'gray', 'assessment': 'unknown', 'value': value}
517
+
518
+
519
+ # ===== COACHING SUGGESTIONS =====
520
+
521
+ def get_coaching_suggestion(metric_name, classification):
522
+ """Generate coaching suggestions based on metric classification"""
523
+ if metric_name == 'tempo':
524
+ tempo_ratio = classification.get('value')
525
+ if classification.get('color') == 'green':
526
+ return "Excellent tempo - maintain your current rhythm"
527
+ elif tempo_ratio and tempo_ratio <= 2.6:
528
+ return "Backswing rushed—train 3:1 with metronome '1-2-3…4'"
529
+ elif tempo_ratio and tempo_ratio >= 3.7:
530
+ return "Backswing too slow—quicken transition, maintain downswing tempo"
531
+ else:
532
+ return "Focus on a smooth 3:1 backswing to downswing rhythm"
533
+
534
+ elif metric_name == 'shaft_top':
535
+ direction = classification.get('direction')
536
+ if classification.get('color') == 'green':
537
+ return "Perfect shaft position at top - maintain this position"
538
+ elif direction == 'laid_off':
539
+ return "Feel 'more across' with a fuller turn or later wrist-set; rehearsal: pause-at-Top with club pointing down the line"
540
+ elif direction == 'across_the_line':
541
+ return "Earlier set / feel 'club more laid-off' at Top; drill: split-hand takeaway to keep shaft neutral"
542
+ else:
543
+ return "Focus on pointing club down target line at top of backswing"
544
+
545
+ elif metric_name == 'head_sway':
546
+ if classification.get('color') == 'green':
547
+ return "Excellent head stability throughout swing"
548
+ else:
549
+ return "Stabilize head—narrower stance or focus on turning around spine; alignment-stick behind head drill"
550
+
551
+ elif metric_name == 'wrist_pattern':
552
+ if classification.get('casting', False):
553
+ return "Maintain hinge to P5; pump drill (3 mini downswings holding angle) then hit"
554
+ else:
555
+ return "Good wrist hinge sequence—set, hold, release"
556
+
557
+ return "Continue working on this aspect of your swing"
558
+
559
+
560
+ # ===== MAIN ANALYSIS FUNCTION =====
561
+
562
+ def analyze_swing_dtl(pose_data, frames=None, ball_position=None):
563
+ """
564
+ Analyze golf swing using simplified DTL method
565
+
566
+ Args:
567
+ pose_data: Dictionary mapping frame indices to pose keypoints
568
+ frames: List of video frames (optional)
569
+ ball_position: Ball position [x, y] (optional)
570
+
571
+ Returns:
572
+ dict: Complete DTL analysis results
573
+ """
574
+ # 1. Find events
575
+ events = find_swing_events(pose_data, fps=30, ball_xy=ball_position)
576
+
577
+ # 2. Quality assessment
578
+ quality_flags = assess_quality_flags(pose_data, frames)
579
+
580
+ # 3. Calculate metrics (only if quality is acceptable)
581
+ metrics = {}
582
+ assessment = {}
583
+ coaching = {}
584
+
585
+ if quality_flags.get('overall_stable', False):
586
+ # Get shoulder width for normalization
587
+ widths = []
588
+ for frame_idx, kp in pose_data.items():
589
+ if kp and len(kp) > 12:
590
+ left_shoulder = kp[11]
591
+ right_shoulder = kp[12]
592
+ if left_shoulder[2] > 0.5 and right_shoulder[2] > 0.5:
593
+ width = np.linalg.norm(
594
+ np.array(left_shoulder[:2]) - np.array(right_shoulder[:2])
595
+ )
596
+ widths.append(width)
597
+
598
+ shoulder_width = np.median(widths) if widths else 100
599
+
600
+ # Simple target line (horizontal)
601
+ target_line_vec = [1, 0]
602
+
603
+ # Calculate core metrics
604
+ tempo_ratio = calculate_tempo_ratio(events['top'], events['impact'], 0)
605
+ if tempo_ratio:
606
+ metrics['tempo_ratio'] = tempo_ratio
607
+ assessment['tempo'] = classify_metric('tempo', tempo_ratio)
608
+ coaching['tempo'] = get_coaching_suggestion('tempo', assessment['tempo'])
609
+
610
+ shaft_angle = calculate_shaft_angle_at_top(pose_data, events['top'], target_line_vec)
611
+ if shaft_angle is not None:
612
+ metrics['shaft_top_angle'] = shaft_angle
613
+ assessment['shaft_top'] = classify_metric('shaft_top', shaft_angle)
614
+ coaching['shaft_top'] = get_coaching_suggestion('shaft_top', assessment['shaft_top'])
615
+
616
+ head_sway_lateral, head_sway_vertical = calculate_head_sway(pose_data, shoulder_width)
617
+ if head_sway_lateral:
618
+ max_sway = max(abs(x) for x in head_sway_lateral if x is not None)
619
+ metrics['head_sway_max'] = max_sway
620
+ assessment['head_sway'] = classify_metric('head_sway', max_sway)
621
+ coaching['head_sway'] = get_coaching_suggestion('head_sway', assessment['head_sway'])
622
+
623
+ wrist_pattern = calculate_wrist_hinge_pattern(pose_data, events)
624
+ metrics['wrist_pattern'] = wrist_pattern
625
+ assessment['wrist_pattern'] = wrist_pattern
626
+ coaching['wrist_pattern'] = get_coaching_suggestion('wrist_pattern', wrist_pattern)
627
+
628
+ # 4. Generate summary
629
+ red_count = sum(1 for a in assessment.values()
630
+ if isinstance(a, dict) and a.get('color') == 'red')
631
+ amber_count = sum(1 for a in assessment.values()
632
+ if isinstance(a, dict) and a.get('color') == 'amber')
633
+ green_count = sum(1 for a in assessment.values()
634
+ if isinstance(a, dict) and a.get('color') == 'green')
635
+
636
+ if red_count == 0 and amber_count <= 1:
637
+ overall = 'excellent'
638
+ elif red_count <= 1:
639
+ overall = 'good'
640
+ else:
641
+ overall = 'needs_improvement'
642
+
643
+ # Priority coaching (red items first)
644
+ priority_coaching = []
645
+ for metric_name in ['tempo', 'head_sway', 'shaft_top', 'wrist_pattern']:
646
+ if metric_name in assessment:
647
+ metric_assessment = assessment[metric_name]
648
+ if isinstance(metric_assessment, dict) and metric_assessment.get('color') == 'red':
649
+ if metric_name in coaching:
650
+ priority_coaching.append(f"{metric_name.replace('_', ' ').title()}: {coaching[metric_name]}")
651
+
652
+ return {
653
+ 'events': events,
654
+ 'quality_flags': quality_flags,
655
+ 'metrics': metrics,
656
+ 'assessment': assessment,
657
+ 'coaching': coaching,
658
+ 'summary': {
659
+ 'overall_assessment': overall,
660
+ 'data_quality': 'stable' if quality_flags.get('overall_stable') else 'unstable',
661
+ 'red_metrics': red_count,
662
+ 'amber_metrics': amber_count,
663
+ 'green_metrics': green_count,
664
+ 'priority_coaching': priority_coaching[:2],
665
+ 'banner_message': "Want hip rotation and X-Factor analysis? Add a face-on view for complete swing analysis."
666
+ }
667
+ }
668
+
669
+
670
+ # Legacy compatibility functions
671
  def segment_swing_pose_based(pose_data, detections=None, sample_rate=1, frame_shape=None, **kwargs):
672
+ """Legacy function - use analyze_swing_dtl for new analysis"""
673
+ from .segmentation import segment_swing
674
  return segment_swing(pose_data, detections, sample_rate, frame_shape, **kwargs)
675
 
676
 
677
+ def get_swing_summary(analysis_results):
678
+ """Get user-friendly summary of swing analysis"""
679
+ return analysis_results.get('summary', {})
680
+
681
+
682
+ # ===== SIMPLE USAGE EXAMPLE =====
683
+
684
+ # Example usage:
685
+ # from models.swing_analyzer import analyze_swing_dtl
686
+ #
687
+ # # Analyze swing with pose data
688
+ # results = analyze_swing_dtl(pose_data, frames, ball_position)
689
+ #
690
+ # # Get summary
691
+ # summary = results['summary']
692
+ # print(f"Overall: {summary['overall_assessment']}")
693
+ # print(f"Priority fixes: {summary['priority_coaching']}")
694
+
695
  def analyze_trajectory(frames, detections, swing_phases, sample_rate=1, fps=30.0):
696
  """
697
  Simple trajectory analysis - just track ball movement after impact
app/streamlit_app.py CHANGED
@@ -23,7 +23,7 @@ sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
23
  from utils.video_downloader import download_youtube_video, download_pro_reference, cleanup_video_file, cleanup_downloads_directory
24
  from utils.video_processor import process_video
25
  from models.pose_estimator import analyze_pose
26
- from models.swing_analyzer import segment_swing, analyze_trajectory
27
  from models.llm_analyzer import generate_swing_analysis, create_llm_prompt, prepare_data_for_llm, check_llm_services, parse_and_format_analysis, display_formatted_analysis, compute_core_metrics
28
  from utils.visualizer import create_annotated_video
29
  from utils.comparison import create_key_frame_comparison, extract_key_swing_frames
@@ -190,36 +190,605 @@ def format_metric_value(metric_data, unit=""):
190
  return f"{value} ({status}){unit}"
191
 
192
 
193
- def display_core_summary(core_metrics):
194
- """Display the 5 core metrics in a clean summary table"""
195
- st.subheader("Core Summary")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
196
 
197
- # Create the metrics data with proper formatting
198
- metrics_data = [
199
- ["Tempo Ratio", format_metric_value(core_metrics.get("tempo_ratio", {}))],
200
- ["Shoulder Rotation @ Top", format_metric_value(core_metrics.get("shoulder_rotation_top_deg", {}), "°")],
201
- ["Hip Rotation @ Impact", format_metric_value(core_metrics.get("hip_rotation_impact_deg", {}), "°")],
202
- ["X-Factor @ Top", format_metric_value(core_metrics.get("x_factor_top_deg", {}), "°")],
203
- ["Posture Score", format_metric_value(core_metrics.get("posture_score_pct", {}), "%")]
204
- ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
205
 
206
- # Display as a clean table
207
- import pandas as pd
208
- df = pd.DataFrame(metrics_data, columns=["Metric", "Value"])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
209
 
210
- # Style the table
211
- styled_df = df.style.set_properties(**{
212
- 'background-color': '#f8f9fa',
213
- 'color': '#0B3B0B',
214
- 'border': '1px solid #dee2e6'
215
- }).set_table_styles([
216
- {'selector': 'th', 'props': [('background-color', '#e9ecef'), ('color', '#0B3B0B'), ('font-weight', 'bold')]},
217
- {'selector': 'td', 'props': [('text-align', 'center')]},
218
- {'selector': 'th:first-child', 'props': [('text-align', 'left')]},
219
- {'selector': 'td:first-child', 'props': [('text-align', 'left'), ('font-weight', 'bold')]}
220
- ])
 
 
221
 
222
- st.dataframe(styled_df, use_container_width=True, hide_index=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
223
 
224
 
225
  def display_swing_phase_breakdown(swing_phases):
@@ -311,12 +880,19 @@ Swing Phases:
311
  Timing Metrics:
312
  - Total Swing Time: {structured_analysis.get('timing_metrics', {}).get('total_swing_time_ms', 'N/A')} ms
313
 
314
- === CORE METRICS ===
315
- - Tempo Ratio: {format_metric_value(core_metrics.get('tempo_ratio', {}))}
316
- - Shoulder Rotation @ Top: {format_metric_value(core_metrics.get('shoulder_rotation_top_deg', {}), '°')}
 
 
 
 
 
 
 
 
317
  - Hip Rotation @ Impact: {format_metric_value(core_metrics.get('hip_rotation_impact_deg', {}), '°')}
318
  - X-Factor @ Top: {format_metric_value(core_metrics.get('x_factor_top_deg', {}), '°')}
319
- - Posture Score: {format_metric_value(core_metrics.get('posture_score_pct', {}), '%')}
320
  """
321
 
322
  # Removed success message
@@ -945,7 +1521,7 @@ def render_step_2():
945
  with st.spinner("Segmenting swing phases..."):
946
  # Get frame shape for relative threshold calculations
947
  frame_shape = frames[0].shape if frames else None
948
- swing_phases = segment_swing(pose_data, detections, sample_rate=1, frame_shape=frame_shape, fps=30.0)
949
  st.success("✅ Swing segmentation complete!")
950
 
951
  with st.spinner("Analyzing trajectory and speed..."):
@@ -1039,8 +1615,8 @@ def render_step_3():
1039
  st.rerun()
1040
 
1041
  def render_step_4():
1042
- """Step 4: AI-Powered Improvements"""
1043
- st.markdown('<h2 style="color: #0B3B0B; font-family: Georgia, serif;">Step 4: AI-Powered Improvements</h2>', unsafe_allow_html=True)
1044
 
1045
  if st.session_state.video_analyzed:
1046
  data = st.session_state.analysis_data
@@ -1051,50 +1627,60 @@ def render_step_4():
1051
  analysis_data = data.get('analysis_data', {})
1052
  core_metrics = analysis_data.get('core_metrics', {})
1053
 
1054
- # If core_metrics is empty, compute them directly
1055
- if not core_metrics:
1056
- core_metrics = compute_core_metrics(pose_data, swing_phases)
1057
-
1058
- # Display the simplified metrics
1059
- display_core_summary(core_metrics)
1060
 
1061
- st.markdown("---")
 
 
1062
 
1063
- display_swing_phase_breakdown(analysis_data.get('swing_phases', {}))
1064
 
1065
- st.markdown("---")
 
1066
 
1067
- # Generate AI analysis if services are available
1068
- llm_services = check_llm_services()
1069
- any_service_available = llm_services['ollama']['available'] or llm_services['openai']['available']
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1070
 
1071
- if any_service_available:
1072
- with st.spinner("🎯 **Generating personalized swing improvements...**"):
1073
- # Generate detailed analysis with recommendations
1074
- analysis = generate_swing_analysis(pose_data, swing_phases, data.get('trajectory_data'))
1075
-
1076
- st.info("🤖 **Analysis generated using AI-powered swing analysis technology**")
1077
-
1078
- # Display the analysis
1079
- if "Error:" not in analysis:
1080
- try:
1081
- formatted_analysis = parse_and_format_analysis(analysis)
1082
- display_formatted_analysis(formatted_analysis)
1083
- except:
1084
- st.markdown(analysis)
1085
- else:
1086
- st.error(analysis)
1087
- else:
1088
- st.info("ℹ️ **AI coaching analysis is not available**. The core metrics above provide your swing measurements.")
1089
 
1090
  # Add button to show exact LLM prompt
1091
- st.markdown("---")
1092
  col1, col2, col3 = st.columns([1, 2, 1])
1093
  with col2:
1094
- if st.button("🔍 Show Exact LLM Prompt", key="show_prompt_btn", use_container_width=True):
1095
  if 'prompt' in st.session_state.analysis_data:
1096
- st.markdown("### 🤖 Exact LLM Prompt Used for Analysis")
1097
- st.info("This is the exact prompt that was sent to the AI model to generate your swing analysis:")
1098
 
1099
  with st.expander("📋 View Full Prompt", expanded=True):
1100
  st.code(st.session_state.analysis_data['prompt'], language="text")
 
23
  from utils.video_downloader import download_youtube_video, download_pro_reference, cleanup_video_file, cleanup_downloads_directory
24
  from utils.video_processor import process_video
25
  from models.pose_estimator import analyze_pose
26
+ from models.swing_analyzer import segment_swing_pose_based, analyze_trajectory
27
  from models.llm_analyzer import generate_swing_analysis, create_llm_prompt, prepare_data_for_llm, check_llm_services, parse_and_format_analysis, display_formatted_analysis, compute_core_metrics
28
  from utils.visualizer import create_annotated_video
29
  from utils.comparison import create_key_frame_comparison, extract_key_swing_frames
 
190
  return f"{value} ({status}){unit}"
191
 
192
 
193
+ def get_shaft_angle_grading(value, confidence):
194
+ """Grade shaft angle at top with sign-aware labels"""
195
+ if value is None:
196
+ return None # Hide if not available
197
+
198
+ # Enhanced validation: Hide if magnitude suggests calibration error
199
+ if abs(value) > 45:
200
+ return None # Hide unrealistic values
201
+
202
+ # Determine label based on value (updated thresholds per user spec)
203
+ if -10 <= value <= 10:
204
+ badge = "🟢"
205
+ label = "Neutral"
206
+ elif 10 < value <= 20:
207
+ badge = "🟠"
208
+ label = f"Across (mild)"
209
+ elif 20 < value <= 35:
210
+ badge = "🔴"
211
+ label = f"Across (needs work)"
212
+ elif -20 <= value < -10:
213
+ badge = "🟠"
214
+ label = f"Laid-off (mild)"
215
+ elif -35 <= value < -20:
216
+ badge = "🔴"
217
+ label = f"Laid-off (needs work)"
218
+ else:
219
+ badge = "🔴"
220
+ label = "Extreme"
221
+
222
+ # Format display value with direction and proper sign
223
+ if value > 0:
224
+ display_value = f"+{value:.1f}° Across"
225
+ elif value < 0:
226
+ display_value = f"{value:.1f}° Laid-off"
227
+ else:
228
+ display_value = f"{value:.1f}° Neutral"
229
 
230
+ return {
231
+ 'display_value': display_value,
232
+ 'badge': badge,
233
+ 'label': label,
234
+ 'confidence': confidence,
235
+ 'tip': "Target: within ±10° of the target line at the top." if badge != "🟢" else None
236
+ }
237
+
238
+
239
+ def get_head_sway_grading(value, confidence):
240
+ """Grade head sway with percentage of pelvis/shoulder width"""
241
+ if value is None:
242
+ return None # Hide if not available
243
+
244
+ # Enhanced validation: Hide if > 50% (indicates scale drift)
245
+ if value > 50:
246
+ return None # Hide unrealistic values suggesting scale problems
247
+
248
+ # Determine grading (updated per user spec)
249
+ if value <= 15:
250
+ badge = "🟢"
251
+ label = "Minimal"
252
+ elif value <= 25:
253
+ badge = "🟢"
254
+ label = "Controlled"
255
+ elif value <= 35:
256
+ badge = "🟠"
257
+ label = "Noticeable"
258
+ else: # 35-50%
259
+ badge = "🔴"
260
+ label = "Excessive"
261
 
262
+ return {
263
+ 'display_value': f"{value:.0f}%",
264
+ 'badge': badge,
265
+ 'label': label,
266
+ 'confidence': confidence,
267
+ 'approx': True, # DTL-limited
268
+ 'tip': "Aim ≤25% for most players." if value > 25 else None
269
+ }
270
+
271
+
272
+ def get_back_tilt_grading(value, confidence, camera_roll=0):
273
+ """Grade back tilt at setup"""
274
+ if value is None or abs(camera_roll) > 5:
275
+ return None # Hide if low confidence
276
+
277
+ # Determine grading
278
+ if 28 <= value <= 38:
279
+ badge = "🟢"
280
+ label = "On-plane posture"
281
+ elif (24 <= value < 28) or (38 < value <= 42):
282
+ badge = "🟠"
283
+ label = "Slightly out of range"
284
+ elif (20 <= value < 24) or (42 < value <= 48):
285
+ badge = "🟠"
286
+ label = "Off (adjust)"
287
+ else:
288
+ badge = "🔴"
289
+ label = "Likely problematic"
290
 
291
+ return {
292
+ 'display_value': f"{value:.1f}°",
293
+ 'badge': badge,
294
+ 'label': label,
295
+ 'confidence': confidence,
296
+ 'tip': "Typical tour range ≈30–40°." if badge != "🟢" else None
297
+ }
298
+
299
+
300
+ def get_knee_flexion_grading(value, confidence):
301
+ """Grade knee flexion at setup"""
302
+ if value is None:
303
+ return None
304
 
305
+ # Determine grading
306
+ if 15 <= value <= 30:
307
+ badge = "🟢"
308
+ label = "Athletic"
309
+ elif (12 <= value < 15) or (30 < value <= 35):
310
+ badge = "🟠"
311
+ label = "Slightly tight/soft"
312
+ elif (8 <= value < 12) or (35 < value <= 40):
313
+ badge = "🟠"
314
+ label = "Needs adjustment"
315
+ else:
316
+ badge = "🔴"
317
+ label = "Likely problematic"
318
+
319
+ return {
320
+ 'display_value': f"{value:.1f}°",
321
+ 'badge': badge,
322
+ 'label': label,
323
+ 'confidence': confidence
324
+ }
325
+
326
+
327
+ def get_wrist_pattern_grading(pattern_data, confidence):
328
+ """Grade wrist pattern categorically"""
329
+ if not pattern_data or pattern_data.get('value') is None:
330
+ return {
331
+ 'display_value': "Unstable",
332
+ 'badge': "⚪",
333
+ 'label': "club not visible",
334
+ 'confidence': 0,
335
+ 'tip': None
336
+ }
337
+
338
+ # Interpret pattern - prioritize actual pattern value over status
339
+ pattern_value = pattern_data.get('value', '')
340
+ status = pattern_data.get('status', 'unknown')
341
+
342
+ # If we have a clear pattern value, use that instead of relying on status
343
+ if pattern_value == 'set_hold_release':
344
+ badge = "🟢"
345
+ label = "Set-Hold-Release — Excellent"
346
+ tip = None
347
+ elif pattern_value == 'early_cast' or status == 'early_cast':
348
+ badge = "🔴"
349
+ label = "Early Cast — Needs work"
350
+ tip = "Feel more lag; try the pump drill."
351
+ elif pattern_value == 'late_set' or (status == 'needs_work' and pattern_value != 'set_hold_release'):
352
+ badge = "🟠"
353
+ label = "Late Set — Caution"
354
+ tip = "Pump-drill (3 holds) before release."
355
+ elif status == 'good_sequence':
356
+ badge = "🟢"
357
+ label = "Good Sequence"
358
+ tip = None
359
+ else:
360
+ badge = "🟠"
361
+ label = "Inconsistent Pattern"
362
+ tip = "Work on consistent wrist angles."
363
+
364
+ return {
365
+ 'display_value': label,
366
+ 'badge': badge,
367
+ 'label': "", # Label is included in display_value
368
+ 'confidence': confidence,
369
+ 'tip': tip
370
+ }
371
+
372
+
373
+ def get_qualitative_shaft_angle_grading(raw_value, confidence):
374
+ """Grade shaft angle qualitatively without showing unreliable numbers"""
375
+ if raw_value is None:
376
+ return None
377
+
378
+ # Use the raw value to determine qualitative assessment
379
+ # Even if the number is unreliable, the direction trend can be informative
380
+ if abs(raw_value) <= 15:
381
+ category = "Neutral"
382
+ badge = "🟢"
383
+ description = "Good plane control"
384
+ elif raw_value > 15:
385
+ if raw_value > 30:
386
+ category = "Across"
387
+ badge = "🟠"
388
+ description = "Strong across-line tendency"
389
+ else:
390
+ category = "Slightly Across"
391
+ badge = "🟢"
392
+ description = "Mild across-line bias"
393
+ else: # raw_value < -15
394
+ if raw_value < -30:
395
+ category = "Laid-off"
396
+ badge = "🟠"
397
+ description = "Strong laid-off tendency"
398
+ else:
399
+ category = "Slightly Laid-off"
400
+ badge = "🟢"
401
+ description = "Mild laid-off bias"
402
+
403
+ return {
404
+ 'display_value': category,
405
+ 'badge': badge,
406
+ 'label': description,
407
+ 'confidence': confidence,
408
+ 'tip': "Focus on plane consistency at the top" if badge == "🟠" else None
409
+ }
410
+
411
+
412
+ def get_qualitative_hip_rotation_grading(raw_value, confidence):
413
+ """Grade hip rotation qualitatively - removed power percentage as unreliable DTL-only"""
414
+ if raw_value is None:
415
+ return {
416
+ 'display_value': "Unknown",
417
+ 'badge': "⚪",
418
+ 'label': "insufficient data",
419
+ 'confidence': 0
420
+ }
421
+
422
+ # Assess kinematic sequence without power percentages (DTL unreliable)
423
+ # Focus on relative hip rotation amount without claiming % power contribution
424
+ if isinstance(raw_value, (int, float)):
425
+ # Evaluate based on professional hip rotation ranges
426
+ if raw_value >= 25:
427
+ category = "High Hip Turn"
428
+ badge = "🟢"
429
+ description = "Strong hip rotation"
430
+ tip = "Excellent lower body engagement"
431
+ elif raw_value >= 18:
432
+ category = "Good Hip Turn"
433
+ badge = "🟢"
434
+ description = "Good hip rotation"
435
+ tip = None
436
+ elif raw_value >= 12:
437
+ category = "Moderate Hip Turn"
438
+ badge = "🟠"
439
+ description = "Moderate hip rotation"
440
+ tip = "Work on hip initiation"
441
+ elif raw_value >= 8:
442
+ category = "Limited Hip Turn"
443
+ badge = "🟠"
444
+ description = "Limited hip rotation"
445
+ tip = "Increase hip drive"
446
+ else:
447
+ category = "Minimal Hip Turn"
448
+ badge = "🔴"
449
+ description = "Minimal hip rotation"
450
+ tip = "Focus on lower body sequence"
451
+ else:
452
+ category = "Moderate"
453
+ badge = "🟠"
454
+ description = "Mixed rotation pattern"
455
+ tip = "Work on hip-arm sequence"
456
+
457
+ return {
458
+ 'display_value': category,
459
+ 'badge': badge,
460
+ 'label': description,
461
+ 'confidence': confidence,
462
+ 'tip': tip
463
+ }
464
+
465
+
466
+ def get_shoulder_turn_grading(value):
467
+ """Grade shoulder turn quality with proper assessment"""
468
+ # Assess shoulder turn quality, not just quantity
469
+ if value is None:
470
+ return {
471
+ 'display_value': "Unknown",
472
+ 'badge': "⚪",
473
+ 'label': "insufficient data",
474
+ 'confidence': 0,
475
+ 'dtl_only': True
476
+ }
477
+
478
+ # Convert to quality assessment
479
+ if isinstance(value, str):
480
+ turn_desc = value.lower()
481
+ if turn_desc == "full":
482
+ badge = "🟢"
483
+ quality = "Excellent"
484
+ description = "Complete shoulder rotation"
485
+ tip = None
486
+ elif turn_desc == "normal":
487
+ badge = "🟢"
488
+ quality = "Good"
489
+ description = "Adequate shoulder turn"
490
+ tip = None
491
+ elif turn_desc == "small":
492
+ badge = "🟠"
493
+ quality = "Limited"
494
+ description = "Restricted shoulder turn"
495
+ tip = "Work on better shoulder rotation"
496
+ else:
497
+ badge = "🟠"
498
+ quality = turn_desc.title()
499
+ description = "Mixed shoulder action"
500
+ tip = None
501
+ else:
502
+ # Numeric assessment
503
+ if value >= 90:
504
+ badge = "🟢"
505
+ quality = "Excellent"
506
+ description = "Complete shoulder rotation"
507
+ tip = None
508
+ elif value >= 75:
509
+ badge = "🟢"
510
+ quality = "Good"
511
+ description = "Adequate shoulder turn"
512
+ tip = None
513
+ else:
514
+ badge = "🟠"
515
+ quality = "Limited"
516
+ description = "Restricted shoulder turn"
517
+ tip = "Work on better shoulder rotation"
518
+
519
+ return {
520
+ 'display_value': quality,
521
+ 'badge': badge,
522
+ 'label': description,
523
+ 'confidence': 0.7, # Moderate confidence for DTL assessment
524
+ 'dtl_only': True
525
+ }
526
+
527
+
528
+ def display_new_grading_scheme(core_metrics):
529
+ """Display the new DTL-only grading scheme with badges and confidence indicators"""
530
+ st.subheader("Swing Analysis")
531
+
532
+ # Extract raw values and calculate confidence (simplified for now)
533
+ shaft_angle_data = core_metrics.get("shaft_angle_top", {})
534
+ head_sway_data = core_metrics.get("head_sway_pct", {})
535
+ back_tilt_data = core_metrics.get("back_tilt_deg", {})
536
+ knee_flexion_data = core_metrics.get("knee_bend_deg", {})
537
+ wrist_pattern_data = core_metrics.get("wrist_pattern", {})
538
+ shoulder_turn_data = core_metrics.get("shoulder_turn_quality", {})
539
+
540
+ # Calculate confidence with QC penalties as per feedback
541
+ def get_confidence(data, metric_type='general'):
542
+ if data.get('value') is None:
543
+ return 0.0
544
+
545
+ # Start with base confidence of 90%
546
+ confidence = 90.0
547
+ status = data.get('status', 'n/a')
548
+
549
+ # Apply QC penalties as specified in feedback
550
+ # conf = base (90) − occlusion(0–25) − scale/crop change(0–10) − phase underseg(0–15) − sign unknown(0–5)
551
+
552
+ # Occlusion penalties (0-25 points)
553
+ if 'club_not_visible' in status or 'Unavailable (club occluded)' in status:
554
+ confidence -= 25 # Maximum occlusion penalty
555
+ elif 'poor tracking' in status or 'no detection' in status:
556
+ confidence -= 20
557
+ elif 'approximate' in status:
558
+ confidence -= 15
559
+ elif 'low confidence' in status:
560
+ confidence -= 10
561
+
562
+ # Scale/crop change penalties (0-10 points)
563
+ if 'scale_drift' in status or 'unstable (scale)' in status:
564
+ confidence -= 10
565
+ elif 'QC fail' in status:
566
+ confidence -= 8
567
+
568
+ # Phase undersegmentation penalties (0-15 points)
569
+ if 'timing_unreliable' in status or 'phase underseg' in status:
570
+ confidence -= 15
571
+ elif 'unreliable' in status:
572
+ confidence -= 10
573
+
574
+ # Sign unknown/uncertain penalties (0-5 points)
575
+ if 'uncertain' in status or 'extreme value' in status:
576
+ confidence -= 5
577
+ elif 'outside tour range' in status:
578
+ confidence -= 3
579
+
580
+ # Metric-specific adjustments
581
+ if metric_type == 'shaft_angle':
582
+ if 'target_line_error' in status:
583
+ return 0.0 # Complete failure
584
+ elif 'club occluded' in status:
585
+ confidence = 0.0 # Never show if club not visible
586
+ elif metric_type == 'head_sway':
587
+ if 'tracking_failed' in status:
588
+ return 0.0 # Complete failure
589
+ elif metric_type == 'wrist_pattern':
590
+ # Check for club visibility issues
591
+ if 'insufficient_data' in status:
592
+ confidence -= 20
593
+ # Cap at 75% if club tip visibility uncertain
594
+ confidence = min(confidence, 75.0)
595
+ elif metric_type == 'hip_rotation':
596
+ # DTL-only limitation - cap at 60%
597
+ confidence = min(confidence, 60.0)
598
+ elif metric_type == 'shoulder_turn':
599
+ # DTL-only limitation - cap at 65%
600
+ confidence = min(confidence, 65.0)
601
+
602
+ # Apply DTL-only caps: confidence ≤70% by default for DTL-limited metrics
603
+ if 'DTL' in status or 'approx' in status:
604
+ confidence = min(confidence, 70.0)
605
+
606
+ # If primitive is n/a, dependent metrics ≤75%
607
+ if data.get('value') is None:
608
+ confidence = min(confidence, 75.0)
609
+
610
+ # Ensure confidence stays within valid range
611
+ confidence = max(0.0, min(90.0, confidence))
612
+
613
+ return confidence / 100.0 # Convert to 0-1 scale
614
+
615
+ # Process each metric
616
+ metrics_to_display = []
617
+
618
+ # 1. Shaft Plane @ Top (Qualitative)
619
+ shaft_value = shaft_angle_data.get('value')
620
+ # Always try to show qualitative assessment, even if raw number was rejected
621
+ if shaft_value is not None or shaft_angle_data.get('status') in ['approximate', 'club_not_visible']:
622
+ # For qualitative assessment, use any available value or status info
623
+ raw_shaft = shaft_value if shaft_value is not None else 0 # Default for status-only cases
624
+ shaft_confidence = get_confidence(shaft_angle_data, 'shaft_angle')
625
+ shaft_grading = get_qualitative_shaft_angle_grading(raw_shaft, shaft_confidence)
626
+ if shaft_grading:
627
+ metrics_to_display.append(("Shaft Plane @ Top", shaft_grading))
628
+
629
+ # 2. Head Sway
630
+ sway_value = head_sway_data.get('value')
631
+ if sway_value is not None:
632
+ sway_confidence = get_confidence(head_sway_data, 'head_sway')
633
+ # Hide if confidence is 0 (indicates unstable scale)
634
+ if sway_confidence > 0:
635
+ sway_grading = get_head_sway_grading(sway_value, sway_confidence)
636
+ if sway_grading:
637
+ metrics_to_display.append(("Head Sway", sway_grading))
638
+
639
+ # 3. Back Tilt @ Setup
640
+ tilt_value = back_tilt_data.get('value')
641
+ if tilt_value is not None:
642
+ tilt_confidence = get_confidence(back_tilt_data, 'back_tilt')
643
+ tilt_grading = get_back_tilt_grading(tilt_value, tilt_confidence)
644
+ if tilt_grading:
645
+ metrics_to_display.append(("Back Tilt @ Setup", tilt_grading))
646
+
647
+ # 4. Knee Flexion @ Setup
648
+ knee_value = knee_flexion_data.get('value')
649
+ if knee_value is not None:
650
+ # Apply knee flexion correction if needed (handle legacy data)
651
+ if knee_value > 90:
652
+ knee_value = 180.0 - knee_value
653
+
654
+ knee_confidence = get_confidence(knee_flexion_data, 'knee_flexion')
655
+ knee_grading = get_knee_flexion_grading(knee_value, knee_confidence)
656
+ if knee_grading:
657
+ metrics_to_display.append(("Knee Flexion", knee_grading))
658
+
659
+ # 5. Wrist Pattern
660
+ wrist_confidence = get_confidence(wrist_pattern_data, 'wrist_pattern')
661
+ wrist_grading = get_wrist_pattern_grading(wrist_pattern_data, wrist_confidence)
662
+ if wrist_grading:
663
+ metrics_to_display.append(("Wrist Pattern", wrist_grading))
664
+
665
+ # 6. Kinematic Sequence (Qualitative) - removed power percentage claims
666
+ hip_rotation_data = core_metrics.get("hip_rotation_impact_deg", {})
667
+ hip_value = hip_rotation_data.get('value')
668
+ # Even if exact number unavailable, try to assess from available data
669
+ if hip_value is not None or hip_rotation_data.get('status') != 'n/a':
670
+ raw_hip = hip_value if hip_value is not None else 20 # Default moderate assumption
671
+ hip_confidence = get_confidence(hip_rotation_data, 'hip_rotation')
672
+ hip_grading = get_qualitative_hip_rotation_grading(raw_hip, hip_confidence)
673
+ if hip_grading:
674
+ metrics_to_display.append(("Kinematic Sequence (DTL-approx)", hip_grading))
675
+
676
+ # 7. Shoulder Turn Quality (DTL-limited)
677
+ shoulder_value = shoulder_turn_data.get('value')
678
+ shoulder_grading = get_shoulder_turn_grading(shoulder_value)
679
+ if shoulder_grading:
680
+ metrics_to_display.append(("Shoulder Turn Quality", shoulder_grading))
681
+
682
+ # Display each metric
683
+ for metric_name, grading in metrics_to_display:
684
+ display_metric_card(metric_name, grading)
685
+
686
+
687
+ def display_metric_card(metric_name, grading):
688
+ """Display a single metric as a clean text bubble using Streamlit components"""
689
+
690
+ # Create a container for each metric
691
+ with st.container():
692
+ # Use Streamlit's built-in styling
693
+ st.markdown("---")
694
+
695
+ # Metric header
696
+ st.subheader(metric_name)
697
+
698
+ # Result line with badge and label
699
+ result_line = f"**{grading['display_value']}** — {grading['badge']} {grading.get('label', '')}"
700
+ st.markdown(result_line)
701
+
702
+ # Confidence line with indicators
703
+ if grading.get('confidence'):
704
+ confidence_pct = int(grading['confidence'] * 100)
705
+ confidence_line = f"Confidence: ~{confidence_pct}%"
706
+
707
+ # Add indicators as text
708
+ if grading.get('confidence') and grading['confidence'] <= 0.75:
709
+ confidence_line += " *approx.*"
710
+
711
+ if grading.get('dtl_only') or 'DTL' in metric_name or 'approx' in str(grading.get('label', '')):
712
+ confidence_line += " *DTL-only*"
713
+
714
+ st.caption(confidence_line)
715
+
716
+ # Detailed evaluation text
717
+ evaluation = get_metric_evaluation(metric_name, grading)
718
+ st.write(evaluation)
719
+
720
+ # Add tip if available using info box
721
+ if grading.get('tip'):
722
+ st.info(f"💡 **Tip:** {grading['tip']}")
723
+
724
+ # Add spacing
725
+ st.write("")
726
+
727
+
728
+ def get_metric_evaluation(metric_name, grading):
729
+ """Generate detailed evaluation text for each metric"""
730
+ value = grading.get('display_value', 'Unknown')
731
+ badge = grading.get('badge', '')
732
+
733
+ if metric_name == "Back Tilt @ Setup":
734
+ if "🟢" in badge:
735
+ return f"Your back tilt of **{value}** shows excellent posture setup. This forward spine angle is crucial for creating the proper swing plane and generating power through impact. Good back tilt promotes consistent contact, optimal launch conditions, and prevents early extension during the downswing."
736
+ elif "🟠" in badge:
737
+ return f"Your back tilt of **{value}** is acceptable but could be optimized. Back tilt affects your swing plane and ability to rotate properly. Slight adjustments to your setup posture could improve consistency, distance, and ball striking quality."
738
+ else:
739
+ return f"Your back tilt of **{value}** needs attention for optimal performance. Proper spine angle at setup is fundamental for swing mechanics, power generation, and consistent ball contact. Poor back tilt can lead to swing compensations and inconsistent results."
740
+
741
+ elif metric_name == "Knee Flexion":
742
+ if "🟢" in badge:
743
+ return f"Your knee flexion of **{value}** demonstrates an athletic setup position. This optimal knee bend provides stability throughout the swing while allowing proper weight transfer and rotation. Good knee flexion supports powerful, balanced swings and consistent ball striking."
744
+ elif "🟠" in badge:
745
+ return f"Your knee flexion of **{value}** is workable but could be refined. Knee bend affects your balance, power transfer, and ability to maintain posture during the swing. Minor adjustments could enhance your stability and swing efficiency."
746
+ else:
747
+ return f"Your knee flexion of **{value}** may be limiting your swing potential. Proper knee bend is essential for balance, power generation, and maintaining spine angle. Too little or too much knee flexion can cause balance issues and inconsistent contact."
748
+
749
+ elif metric_name == "Wrist Pattern":
750
+ if "🟢" in badge:
751
+ return f"Your wrist pattern shows excellent **set-hold-release** sequence. This proper wrist hinge maintains lag through impact, maximizing clubhead speed and ensuring solid contact. Good wrist mechanics are crucial for distance, accuracy, and consistent ball compression."
752
+ elif "🟠" in badge and "cast" in value.lower():
753
+ return f"Your wrist pattern shows signs of **early casting**. This releases the wrist angle too early in the downswing, reducing clubhead speed and potentially causing thin or weak contact. Maintaining lag longer will improve distance, ball compression, and strike quality."
754
+ else:
755
+ return f"Your wrist pattern needs development for optimal performance. Proper wrist hinge and release timing is crucial for generating clubhead speed, maintaining control, and achieving consistent ball contact. Focus on lag retention and proper release timing."
756
+
757
+ elif "Kinematic Sequence" in metric_name:
758
+ if "🟢" in badge:
759
+ return f"Your kinematic sequence shows **{value}**, indicating good lower body initiation. Proper hip rotation leads the downswing, creating efficient power transfer from ground up. This sequence promotes distance, accuracy, and reduces stress on the upper body."
760
+ elif "🟠" in badge:
761
+ return f"Your kinematic sequence shows **{value}**, which is moderate. The sequence of body rotation affects power generation and timing. Improving hip initiation and rotation timing can enhance distance, consistency, and swing efficiency."
762
+ else:
763
+ return f"Your kinematic sequence shows **{value}**, suggesting room for improvement. Proper sequencing from hips to shoulders to arms is essential for maximum power transfer, accuracy, and injury prevention. Focus on lower body leading the downswing."
764
+
765
+ elif "Shoulder Turn Quality" in metric_name:
766
+ if "🟢" in badge:
767
+ return f"Your shoulder turn quality is **{value}**, showing good upper body rotation. Adequate shoulder turn creates the necessary swing width and helps generate clubhead speed. This rotation contributes to distance while maintaining swing plane consistency."
768
+ elif "🟠" in badge:
769
+ return f"Your shoulder turn quality is **{value}**, indicating moderate rotation. Shoulder turn affects swing width, power potential, and timing. Enhanced shoulder mobility and turn could improve your distance and swing consistency."
770
+ else:
771
+ return f"Your shoulder turn quality shows **{value}**, suggesting limited rotation. Restricted shoulder turn can reduce swing width, limit power generation, and affect timing. Improving shoulder mobility and turn will enhance distance and swing efficiency."
772
+
773
+ elif "Shaft Plane" in metric_name or "Shaft Angle" in metric_name:
774
+ if grading.get('value') is None or "unavailable" in value.lower():
775
+ return f"Shaft angle analysis is **unavailable due to club occlusion**. This metric measures club position at the top relative to the target line, affecting swing plane and ball flight direction. When visible, shaft plane analysis helps optimize accuracy and consistency."
776
+ elif "🟢" in badge:
777
+ return f"Your shaft angle shows **{value}** positioning. This neutral club position at the top promotes consistent swing plane and ball flight direction. Good shaft plane contributes to accuracy, reduces compensations, and promotes reliable ball contact."
778
+ else:
779
+ return f"Your shaft angle shows **{value}** positioning. Shaft plane at the top affects swing path, ball flight direction, and consistency. Optimizing club position can improve accuracy, reduce dispersion, and enhance overall ball striking quality."
780
+
781
+ elif "Head Sway" in metric_name:
782
+ if "🟢" in badge:
783
+ return f"Your head movement of **{value}** shows excellent stability. Minimal head sway maintains consistent swing center, promoting solid contact and accuracy. Good head stability is crucial for distance control, precision, and repeatable ball striking."
784
+ elif "🟠" in badge:
785
+ return f"Your head movement of **{value}** is acceptable but could be improved. Some head movement affects swing center consistency and can influence contact quality. Reducing sway will enhance accuracy, distance control, and strike consistency."
786
+ else:
787
+ return f"Your head movement of **{value}** indicates excessive sway. Too much head movement disrupts swing center, leading to inconsistent contact and accuracy issues. Improving head stability will enhance precision, distance control, and overall consistency."
788
+
789
+ else:
790
+ # Default evaluation for any other metrics
791
+ return f"Your **{value}** measurement provides insight into your swing mechanics. This metric affects various aspects of your performance including power generation, accuracy, and consistency. Continue working on this fundamental for improved golf performance."
792
 
793
 
794
  def display_swing_phase_breakdown(swing_phases):
 
880
  Timing Metrics:
881
  - Total Swing Time: {structured_analysis.get('timing_metrics', {}).get('total_swing_time_ms', 'N/A')} ms
882
 
883
+ === DTL-RELIABLE CORE METRICS ===
884
+ - Shaft Angle @ Top: {format_metric_value(core_metrics.get('shaft_angle_top', {}), '°')}
885
+ - Head Sway: {format_metric_value(core_metrics.get('head_sway_pct', {}), '% shoulder width')}
886
+ - Back Tilt @ Setup: {format_metric_value(core_metrics.get('back_tilt_deg', {}), '°')}
887
+ - Knee Bend @ Setup: {format_metric_value(core_metrics.get('knee_bend_deg', {}), '°')}
888
+ - Wrist Pattern: {format_metric_value(core_metrics.get('wrist_pattern', {}))}
889
+
890
+ === DTL-LIMITED METRICS (Approximate) ===
891
+ - Shoulder Turn Quality: {format_metric_value(core_metrics.get('shoulder_turn_quality', {}))}
892
+
893
+ === REQUIRES FACE-ON VIEW ===
894
  - Hip Rotation @ Impact: {format_metric_value(core_metrics.get('hip_rotation_impact_deg', {}), '°')}
895
  - X-Factor @ Top: {format_metric_value(core_metrics.get('x_factor_top_deg', {}), '°')}
 
896
  """
897
 
898
  # Removed success message
 
1521
  with st.spinner("Segmenting swing phases..."):
1522
  # Get frame shape for relative threshold calculations
1523
  frame_shape = frames[0].shape if frames else None
1524
+ swing_phases = segment_swing_pose_based(pose_data, detections, sample_rate=1, frame_shape=frame_shape, fps=30.0)
1525
  st.success("✅ Swing segmentation complete!")
1526
 
1527
  with st.spinner("Analyzing trajectory and speed..."):
 
1615
  st.rerun()
1616
 
1617
  def render_step_4():
1618
+ """Step 4: Swing Analysis with New Grading Scheme"""
1619
+ st.markdown('<h2 style="color: #0B3B0B; font-family: Georgia, serif;">Step 4: Swing Analysis</h2>', unsafe_allow_html=True)
1620
 
1621
  if st.session_state.video_analyzed:
1622
  data = st.session_state.analysis_data
 
1627
  analysis_data = data.get('analysis_data', {})
1628
  core_metrics = analysis_data.get('core_metrics', {})
1629
 
1630
+ # Force recomputation with new validation logic
1631
+ # This ensures the latest fixes are applied
1632
+ core_metrics = compute_core_metrics(pose_data, swing_phases)
 
 
 
1633
 
1634
+ # Update the cached analysis data with new metrics
1635
+ if 'analysis_data' in data:
1636
+ data['analysis_data']['core_metrics'] = core_metrics
1637
 
 
1638
 
1639
+ # Display the new grading scheme
1640
+ display_new_grading_scheme(core_metrics)
1641
 
1642
+ # Add calibration debug section for qualitative metrics
1643
+ with st.expander("🔧 Calibration Data (Raw Numbers)", expanded=False):
1644
+ st.write("**Raw values for qualitative assessments - for calibration purposes:**")
1645
+
1646
+ # Shaft angle raw value
1647
+ shaft_raw = core_metrics.get("shaft_angle_top", {}).get('value')
1648
+ shaft_status = core_metrics.get("shaft_angle_top", {}).get('status', 'n/a')
1649
+ st.write(f"• **Shaft Angle @ Top**: {shaft_raw}° (status: {shaft_status})")
1650
+
1651
+ # Hip rotation raw value
1652
+ hip_raw = core_metrics.get("hip_rotation_impact_deg", {}).get('value')
1653
+ hip_status = core_metrics.get("hip_rotation_impact_deg", {}).get('status', 'n/a')
1654
+ st.write(f"• **Hip Rotation @ Impact**: {hip_raw}° (status: {hip_status})")
1655
+
1656
+ # X-factor raw value (bonus)
1657
+ xfactor_raw = core_metrics.get("x_factor_top_deg", {}).get('value')
1658
+ xfactor_status = core_metrics.get("x_factor_top_deg", {}).get('status', 'n/a')
1659
+ st.write(f"• **X-Factor @ Top**: {xfactor_raw}° (status: {xfactor_status})")
1660
+
1661
+ # Shoulder rotation raw value
1662
+ shoulder_raw = core_metrics.get("shoulder_rotation_top_deg", {}).get('value')
1663
+ shoulder_status = core_metrics.get("shoulder_rotation_top_deg", {}).get('status', 'n/a')
1664
+ st.write(f"• **Shoulder Rotation @ Top**: {shoulder_raw}° (status: {shoulder_status})")
1665
+
1666
+ st.write("---")
1667
+ st.write("**Calibration Notes:**")
1668
+ st.write("- Use these raw numbers to adjust qualitative thresholds")
1669
+ st.write("- Shaft angle: Professional range ~±0-20°, >35° likely calibration error")
1670
+ st.write("- Hip rotation: 25°+ = High Hip Turn, 18-24° = Good Hip Turn, 12-17° = Moderate Hip Turn, 8-11° = Limited Hip Turn, <8° = Minimal Hip Turn")
1671
+ st.write("- X-Factor: Separation between shoulders and hips at top")
1672
+ st.write("- **Note**: DTL-only view shows kinematic sequence approximation, not actual power percentage distribution")
1673
+ st.write("- **Framework**: For accurate power source analysis, face-on view is required")
1674
 
1675
+ st.markdown("---")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1676
 
1677
  # Add button to show exact LLM prompt
 
1678
  col1, col2, col3 = st.columns([1, 2, 1])
1679
  with col2:
1680
+ if st.button("🔍 Show Original LLM Prompt", key="show_prompt_btn", use_container_width=True):
1681
  if 'prompt' in st.session_state.analysis_data:
1682
+ st.markdown("### 🤖 Original LLM Prompt")
1683
+ st.info("This is the exact prompt that was prepared for AI analysis:")
1684
 
1685
  with st.expander("📋 View Full Prompt", expanded=True):
1686
  st.code(st.session_state.analysis_data['prompt'], language="text")