chenemii commited on
Commit
36d65da
·
1 Parent(s): e195886

Add head height change metric to replace hip turn metric

Browse files

- Implemented single head height change metric measuring address to impact movement
- Added comprehensive debug output for head height calculations
- Removed all confidence calculations from DTL metrics
- Added helper functions for head landmark detection and temporal smoothing
- Updated validation ranges for new metric
- Enhanced inch scale calculation with debug output

app/main.py CHANGED
@@ -51,7 +51,7 @@ def main():
51
 
52
  # Step 5: Analyze pose throughout the swing
53
  print("\nAnalyzing golfer's pose...")
54
- pose_data = analyze_pose(frames)
55
 
56
  # Step 6: Segment swing into phases
57
  print("\nSegmenting swing phases...")
 
51
 
52
  # Step 5: Analyze pose throughout the swing
53
  print("\nAnalyzing golfer's pose...")
54
+ pose_data, world_landmarks = analyze_pose(frames)
55
 
56
  # Step 6: Segment swing into phases
57
  print("\nSegmenting swing phases...")
app/models/coach_prompt.md CHANGED
@@ -1,150 +1,42 @@
1
  # Golf Swing Analysis
2
 
3
- ## PROFESSIONAL BENCHMARKS FOR CALIBRATION
4
- Use these professional standards as your 100% reference for scoring. These represent elite-level golf swing mechanics based on actual professional analysis:
5
-
6
- ### Professional Golfer Analysis Summary (100% Reference Standards):
7
-
8
- **Lydia Ko Iron (LPGA Tour Professional):** https://www.youtube.com/shorts/ING2iZ3M8wg
9
- - Shaft Plane @ Top: Slightly Laid-off — 🟢 Mild laid-off bias — Confidence ~75%
10
- - Back Tilt @ Setup: 33.9° — 🟢 On-plane posture — Confidence ~85%
11
- - Knee Flexion: 27.6° — 🟢 Athletic — Confidence ~60%
12
- - Wrist Pattern: Set-Hold-Release Excellent 🟢 Confidence ~75%
13
- - Power Source: Hip-Led — 🟢 60% hip power
14
- - Shoulder Turn Quality: Excellent — 🟢 Complete shoulder rotation — Confidence ~70% DTL-only
15
-
16
- Raw values for qualitative assessments - for calibration purposes:
17
- • Shaft Angle @ Top: -26.2° (status: needs_work)
18
- Hip Rotation @ Impact: None° (status: needs_face_on)
19
- • X-Factor @ Top: None° (status: needs_face_on)
20
- • Shoulder Rotation @ Top: 45.0° (status: ok)
21
-
22
- **Nelly Korda Iron (Pink):** https://www.youtube.com/shorts/GRzaXCNJq1o
23
- - Shaft Plane @ Top: Neutral — 🟢 Good plane control
24
- - Back Tilt @ Setup: 34.9° — 🟢 On-plane posture — Confidence ~85%
25
- - Knee Flexion: 18.7° — 🟢 Athletic — Confidence ~60%
26
- - Wrist Pattern: Set-Hold-Release Excellent — 🟢 — Confidence ~75%
27
- - Power Source: Hip-Led — 🟢 60% hip power
28
- - Shoulder Turn Quality: Excellent — 🟢 Complete shoulder rotation — Confidence ~70% DTL-only
29
-
30
- Raw values for qualitative assessments - for calibration purposes:
31
- Shaft Angle @ Top: None° (status: club_not_visible)
32
- • Hip Rotation @ Impact: None° (status: needs_face_on)
33
- X-Factor @ Top: None° (status: needs_face_on)
34
- Shoulder Rotation @ Top: 45.0° (status: ok)
35
-
36
- **Nelly Korda Iron (Blue):** https://www.youtube.com/shorts/M1eaHdaSzh4
37
- - Shaft Plane @ Top: Neutral — 🟢 Good plane control
38
- - Back Tilt @ Setup: 31.2° — 🟢 On-plane posture — Confidence ~85%
39
- - Knee Flexion: 16.4° 🟢 Athletic Confidence ~60%
40
- - Wrist Pattern: Set-Hold-Release — Excellent — 🟢 — Confidence ~75%
41
- - Power Source: Hip-Led — 🟢 60% hip power
42
- - Shoulder Turn Quality: Excellent — 🟢 Complete shoulder rotation — Confidence ~70% DTL-only
43
-
44
- Raw values for qualitative assessments - for calibration purposes:
45
- • Shaft Angle @ Top: None° (status: club_not_visible)
46
- • Hip Rotation @ Impact: None° (status: needs_face_on)
47
- • X-Factor @ Top: None° (status: needs_face_on)
48
- • Shoulder Rotation @ Top: 45.0° (status: ok)
49
-
50
- **Park Sunghyun Iron:** https://www.youtube.com/shorts/imtyz_n06gA
51
- - Back Tilt @ Setup: 29.2° — 🟢 On-plane posture — Confidence ~90%
52
- - Knee Flexion: 25.9° — 🟢 Athletic — Confidence ~90%
53
- - Wrist Pattern: Inconsistent Pattern — 🟠 — Confidence ~75% approx.
54
- 💡 Tip: Work on consistent wrist angles.
55
- - Kinematic Sequence (DTL-approx): Good Hip Turn — 🟢 Good hip rotation DTL-only
56
- - Shoulder Turn Quality: Excellent — 🟢 Complete shoulder rotation — Confidence ~70% approx. DTL-only
57
-
58
- Raw values for qualitative assessments - for calibration purposes:
59
- • Shaft Angle @ Top: None° (status: Unavailable (club occluded))
60
- • Hip Rotation @ Impact: None° (status: needs_face_on)
61
- • X-Factor @ Top: None° (status: needs_face_on)
62
- • Shoulder Rotation @ Top: None° (status: DTL proxy only, low confidence)
63
-
64
- **Minjee Lee Driver:** https://www.youtube.com/shorts/Ioo3VSiCUhs
65
- - Back Tilt @ Setup: 27.6° — 🟠 Slightly out of range — Confidence ~90%
66
- - Knee Flexion: 24.8° — 🟢 Athletic — Confidence ~90%
67
- - Wrist Pattern: Set-Hold-Release — Excellent — 🟢 Needs work — Confidence ~75% approx.
68
- - Kinematic Sequence (DTL-approx): Good Hip Turn — 🟢 Good hip rotation
69
- - Shoulder Turn Quality: Excellent — 🟢 Complete shoulder rotation — Confidence ~70% approx. DTL-only
70
-
71
- Raw values for qualitative assessments - for calibration purposes:
72
- • Shaft Angle @ Top: None° (status: Unavailable (club occluded))
73
- • Hip Rotation @ Impact: None° (status: needs_face_on)
74
- • X-Factor @ Top: None° (status: needs_face_on)
75
- • Shoulder Rotation @ Top: None° (status: DTL proxy only, low confidence)
76
-
77
- **Hyoo Joo Kim Iron:** https://www.youtube.com/watch?v=zDheOLEEmdU&t=7s
78
- - Back Tilt @ Setup: 27.3° — 🟠 Slightly out of range — Confidence ~90%
79
- - Knee Flexion: 26.8° — 🟢 Athletic — Confidence ~90%
80
- - Wrist Pattern: Set-Hold-Release — Excellent — 🟢 Needs work — Confidence ~75% approx.
81
- - Kinematic Sequence (DTL-approx): Good Hip Turn — 🟢 Good hip rotation
82
- - Shoulder Turn Quality: Excellent — 🟢 Complete shoulder rotation — Confidence ~70% approx. DTL-only
83
-
84
- Raw values for qualitative assessments - for calibration purposes:
85
- • Shaft Angle @ Top: None° (status: target_line_error)
86
- • Hip Rotation @ Impact: None° (status: needs_face_on)
87
- • X-Factor @ Top: None° (status: needs_face_on)
88
- • Shoulder Rotation @ Top: None° (status: DTL proxy only, low confidence)
89
-
90
- **Nasa Hataoka Iron:** https://www.youtube.com/shorts/sMqEwl1Tfus
91
- - Back Tilt @ Setup: 25.3° — 🟠 Slightly out of range — Confidence ~90%
92
- - Knee Flexion: 24.1° — 🟢 Athletic — Confidence ~90%
93
- - Wrist Pattern: Set-Hold-Release — Excellent — 🟢 Needs work — Confidence ~75% approx.
94
- - Kinematic Sequence (DTL-approx): Good Hip Turn — 🟢 Good hip rotation
95
- - Shoulder Turn Quality: Excellent — 🟢 Complete shoulder rotation — Confidence ~70% approx. DTL-only
96
-
97
- Raw values for qualitative assessments - for calibration purposes:
98
- • Shaft Angle @ Top: None° (status: target_line_error)
99
- • Hip Rotation @ Impact: None° (status: needs_face_on)
100
- • X-Factor @ Top: None° (status: needs_face_on)
101
- • Shoulder Rotation @ Top: None° (status: DTL proxy only, low confidence)
102
-
103
- ### **AMATEUR REFERENCE EXAMPLES FOR CALIBRATION:**
104
-
105
- **90% Amateur Swing:**
106
- Known problems: Wrist casting during downswing/impact, lacks powerful hip rotation, possibly too much knee flex
107
- - Shaft Plane @ Top: Neutral — 🟢 Good plane control — Confidence ~85%
108
- - Back Tilt @ Setup: 69.4° — 🔴 Likely problematic — Confidence ~60% - detected wrong
109
- 💡 Tip: Typical tour range ≈30–40°.
110
- - Knee Flexion: 26.0° — 🟢 Athletic — Confidence ~60%
111
- - Wrist Pattern: Set-Hold-Release — Excellent — 🟢 — Confidence ~75%
112
- - Power Source: Hip-Led — 🟢 60% hip power
113
- - Shoulder Turn Quality: Excellent — 🟢 Complete shoulder rotation — Confidence ~70% DTL-only
114
-
115
- Raw values for qualitative assessments - for calibration purposes:
116
- • Shaft Angle @ Top: -6.8° (status: good)
117
- • Hip Rotation @ Impact: None° (status: needs_face_on)
118
- • X-Factor @ Top: None° (status: needs_face_on)
119
- • Shoulder Rotation @ Top: 45.0° (status: ok)
120
-
121
- **40-50% Male Amateur Swing:**
122
- Known problems: Standing up during impact, head movement severe, bent arms in follow through, inconsistent swing path and ball contact, wrong stance because the club is too short for his height, some wrist casting during impact
123
- - Back Tilt @ Setup: 39.0° — 🟠 Slightly out of range — Confidence ~90%
124
- - Knee Flexion: 39.4° — 🟠 Too bent — Confidence ~90%
125
- - Wrist Pattern: Set-Hold-Release — Excellent — 🟢 Needs work — Confidence ~75% approx.
126
- - Kinematic Sequence (DTL-approx): Good Hip Turn — 🟢 Good hip rotation
127
- - Shoulder Turn Quality: Excellent — 🟢 Complete shoulder rotation — Confidence ~70% approx. DTL-only
128
-
129
- Raw values for qualitative assessments - for calibration purposes:
130
- • Shaft Angle @ Top: None° (status: target_line_error)
131
- • Hip Rotation @ Impact: None° (status: needs_face_on)
132
- • X-Factor @ Top: None° (status: needs_face_on)
133
- • Shoulder Rotation @ Top: None° (status: DTL proxy only, low confidence)
134
-
135
- **40-50% Female Amateur Swing:**
136
- Known problems: Hands keep going at the top of backswing when they should stop, wrist casting, hands leading downswing not hips, standing up during impact, head movement
137
- - Knee Flexion: 33.8° — 🟠 Slightly too bent — Confidence ~90%
138
- - Wrist Pattern: Inconsistent Pattern — 🟠 Needs work — Confidence ~75% approx.
139
- 💡 Tip: Work on consistent wrist angles.
140
- - Kinematic Sequence (DTL-approx): Good Hip Turn — 🟢 Good hip rotation
141
- - Shoulder Turn Quality: Excellent — 🟢 Complete shoulder rotation — Confidence ~70% approx. DTL-only
142
-
143
- Raw values for qualitative assessments - for calibration purposes:
144
- • Shaft Angle @ Top: None° (status: target_line_error)
145
- • Hip Rotation @ Impact: None° (status: needs_face_on)
146
- • X-Factor @ Top: None° (status: needs_face_on)
147
- • Shoulder Rotation @ Top: None° (status: DTL proxy only, low confidence)
148
 
149
  ### **KEY AMATEUR SWING PROBLEMS FOR EVALUATION REFERENCE:**
150
  **Common Issues in 40-50% Level Swings:**
@@ -211,14 +103,15 @@ Raw values for qualitative assessments - for calibration purposes:
211
  - Weight transfer issues may manifest as poor hip rotation or kinematic sequence problems
212
  - Over-the-top swing path affects shaft plane and swing consistency
213
 
214
- ### **PROFESSIONAL STANDARDS CALIBRATION (100% Level):**
215
- **Core Biomechanical Metrics (Based on 7 LPGA Tour Professionals):**
216
- - **Back Tilt @ Setup**: 25-35° (Professional range: Nasa 25.3° to Nelly Blue 34.9°)
217
- - **Knee Flexion**: 16-28° (Professional range: Nelly Blue 16.4° to Lydia Ko 27.6°)
218
- - **Shaft Plane @ Top**: Neutral to slightly laid-off (Professional plane control when visible)
219
- - **Wrist Pattern**: Set-Hold-Release sequence (Excellent timing and control across all professionals)
220
- - **Power Source**: Hip-Led 60% hip power (Professional power generation standard)
221
- - **Shoulder Turn Quality**: Excellent complete rotation (Consistent across all professionals)
 
222
 
223
  ## CURRENT SWING ANALYSIS
224
 
@@ -240,35 +133,64 @@ Use the benchmarks above to guide your evaluation. Follow this exact format:
240
 
241
  **Metric Evaluations**
242
 
243
- For each of the 5 DTL-reliable core metrics below, write exactly 3 sentences evaluating the metric:
244
- 1. First sentence: State if it's good, bad, or needs improvement compared to elite standards
245
- 2. Second sentence: Compare the specific value to professional ranges
246
  3. Third sentence: Brief explanation of impact on swing performance
247
 
248
- **1. Shaft Plane @ Top Evaluation:**
249
- [3 sentences about club shaft position relative to target line at top]
 
 
 
 
 
 
 
 
 
 
 
 
 
250
 
251
- **2. Head Sway Evaluation:**
252
- [3 sentences about head movement as percentage of shoulder width]
253
 
254
- **3. Back Tilt @ Setup Evaluation:**
 
 
 
255
  [3 sentences about spine forward tilt angle during setup position]
256
 
257
- **4. Knee Flexion @ Setup Evaluation:**
258
  [3 sentences about knee flexion angle during setup position]
259
 
260
- **5. Wrist Pattern Evaluation:**
261
- [3 sentences about wrist hinge sequence and early casting]
 
 
262
 
263
  **SCORING GUIDELINES (Use to help decide % score)**
264
 
265
- | Metric | Professional Standard | Amateur Problem Indicators | Note |
266
- |--------|----------------------|---------------------------|------|
267
- | Shaft Angle @ Top | Neutral to slightly laid-off | N/A (often not visible) | Neutral (0°), slightly laid-off (-26°) acceptable |
268
- | Head Sway | <15% shoulder width | Severe movement (40-50% level) | Stable head during swing |
269
- | Back Tilt @ Setup | 25-35° | >35° problematic (39° seen in 40-50% level) | Forward spine tilt from vertical |
270
- | Knee Flexion @ Setup | 16-28° | >35° problematic (33-39° seen in 40-50% level) | Athletic stance flexion |
271
- | Wrist Pattern | Set-Hold-Release sequence | Casting, inconsistent patterns | Excellent timing and control, no early casting |
 
 
 
 
 
 
 
 
 
 
 
 
272
 
273
  **Classification Bands:**
274
  - **90–100%**: Tour-level
@@ -280,15 +202,16 @@ For each of the 5 DTL-reliable core metrics below, write exactly 3 sentences eva
280
  - **10–39%**: Novice
281
 
282
  **STYLE & FORMATTING RULES:**
283
- - Use these headers: OVERALL_SUMMARY, PERFORMANCE_CLASSIFICATION, Metric Evaluations, and the 5 numbered metric sections
284
  - No emojis anywhere in the response
285
  - Write 1-2 sentences maximum for the overall summary - be concise and highlight main strengths/improvement areas
286
  - Write exactly 3 sentences for each metric evaluation
287
- - Tie all evaluations to professional standards and ranges based on 7 LPGA Tour professionals (Lydia Ko, Nelly Korda, Park Sunghyun, Minjee Lee, Hyoo Joo Kim, Nasa Hataoka)
288
  - Use a positive, coaching tone throughout
289
  - Avoid saying "perfect" — say "strong" or "meets standards"
290
- - Focus on biomechanics and compare actual values to professional ranges (25-35° back tilt, 16-28° knee flexion, etc.)
291
  - Consider common amateur swing issues when evaluating: over-swinging, over-the-top path, lack of weight transfer, early extension, lifting the ball, wrist casting, head movement/standing up
292
  - Look for combinations of swing problems that often compound each other in amateur golfers
 
293
 
294
 
 
1
  # Golf Swing Analysis
2
 
3
+ ## NEW METRICS CALIBRATION
4
+ Use these new professional/amateur benchmarks for scoring. These represent updated golf swing mechanics based on the latest analysis:
5
+
6
+ ### **NEW FRONT-FACING METRICS:**
7
+
8
+ **Shoulder Tilt @ Impact:**
9
+ - Professional = 39°
10
+ - 30 Handicap = 27°
11
+
12
+ # Hip Turn @ Impact metric removed from DTL analysis
13
+
14
+ **Hip Sway @ Top:**
15
+ - Professional = 3.9" towards target
16
+ - 30 Handicap = 2.5" towards target
17
+
18
+ **Hip Sway @ Impact:**
19
+ - To be measured and calibrated
20
+
21
+ **Wrist Hinge @ Top:**
22
+ - To be measured and calibrated
23
+
24
+ ### **NEW DTL METRICS:**
25
+
26
+ **Shoulder Tilt/Swing Plane Angle @ Top:**
27
+ - Professional = 36°
28
+ - 30 Handicap = 29°
29
+
30
+ **Back Tilt (°):**
31
+ - To be measured and calibrated
32
+
33
+ **Knee Flexion (°):**
34
+ - To be measured and calibrated
35
+
36
+ **Hip Depth / Early Extension (cm or %):**
37
+ - To be measured and calibrated
38
+
39
+ # Hip Turn @ Impact metric removed from DTL analysis
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
 
41
  ### **KEY AMATEUR SWING PROBLEMS FOR EVALUATION REFERENCE:**
42
  **Common Issues in 40-50% Level Swings:**
 
103
  - Weight transfer issues may manifest as poor hip rotation or kinematic sequence problems
104
  - Over-the-top swing path affects shaft plane and swing consistency
105
 
106
+ ### **UPDATED METRICS CALIBRATION:**
107
+ **New Core Biomechanical Metrics:**
108
+ - **Shoulder Tilt @ Impact**: Professional = 39°, 30 Handicap = 27°
109
+ - **Hip Turn @ Impact**: Professional = 36°, 30 Handicap = 19°
110
+ - **Hip Sway @ Top**: Professional = 3.9" towards target, 30 Handicap = 2.5" towards target
111
+ - **Shoulder Tilt/Swing Plane @ Top**: Professional = 36°, 30 Handicap = 29°
112
+ - **Back Tilt @ Setup**: To be calibrated with new data
113
+ - **Knee Flexion @ Setup**: To be calibrated with new data
114
+ - **Hip Depth / Early Extension**: To be calibrated with new data
115
 
116
  ## CURRENT SWING ANALYSIS
117
 
 
133
 
134
  **Metric Evaluations**
135
 
136
+ For each of the new metrics below, write exactly 3 sentences evaluating the metric:
137
+ 1. First sentence: State if it's good, bad, or needs improvement compared to professional standards
138
+ 2. Second sentence: Compare the specific value to professional/amateur ranges
139
  3. Third sentence: Brief explanation of impact on swing performance
140
 
141
+ **FRONT-FACING METRICS:**
142
+
143
+ **1. Shoulder Tilt @ Impact Evaluation:**
144
+ [3 sentences about shoulder tilt angle at impact - professional = 39°, 30 handicap = 27°]
145
+
146
+ # Hip Turn @ Impact evaluation removed from DTL analysis
147
+
148
+ **3. Hip Sway @ Top Evaluation:**
149
+ [3 sentences about hip sway at top - professional = 3.9" towards target, 30 handicap = 2.5" towards target]
150
+
151
+ **4. Hip Sway @ Impact Evaluation:**
152
+ [3 sentences about hip sway at impact]
153
+
154
+ **5. Wrist Hinge @ Top Evaluation:**
155
+ [3 sentences about wrist hinge angle at top of backswing]
156
 
157
+ **DTL METRICS:**
 
158
 
159
+ **1. Shoulder Tilt/Swing Plane @ Top Evaluation:**
160
+ [3 sentences about shoulder tilt/swing plane angle at top - professional = 36°, 30 handicap = 29°]
161
+
162
+ **2. Back Tilt @ Setup Evaluation:**
163
  [3 sentences about spine forward tilt angle during setup position]
164
 
165
+ **3. Knee Flexion @ Setup Evaluation:**
166
  [3 sentences about knee flexion angle during setup position]
167
 
168
+ **4. Hip Depth / Early Extension Evaluation:**
169
+ [3 sentences about hip depth maintenance and early extension]
170
+
171
+ # Hip Turn @ Impact evaluation removed from DTL analysis
172
 
173
  **SCORING GUIDELINES (Use to help decide % score)**
174
 
175
+ **FRONT-FACING METRICS:**
176
+
177
+ | Metric | Professional Standard | 30 Handicap Standard | Note |
178
+ |--------|----------------------|---------------------|------|
179
+ | Shoulder Tilt @ Impact | 39° | 27° | Shoulder tilt angle at impact |
180
+ # Hip Turn @ Impact metric removed from DTL analysis
181
+ | Hip Sway @ Top | 3.9" towards target | 2.5" towards target | Hip lateral movement at top |
182
+ | Hip Sway @ Impact | To be calibrated | To be calibrated | Hip lateral movement at impact |
183
+ | Wrist Hinge @ Top | To be calibrated | To be calibrated | Wrist angle at top of backswing |
184
+
185
+ **DTL METRICS:**
186
+
187
+ | Metric | Professional Standard | 30 Handicap Standard | Note |
188
+ |--------|----------------------|---------------------|------|
189
+ | Shoulder Tilt/Swing Plane @ Top | 36° | 29° | Shoulder tilt/swing plane angle at top |
190
+ | Back Tilt @ Setup | To be calibrated | To be calibrated | Forward spine tilt from vertical |
191
+ | Knee Flexion @ Setup | To be calibrated | To be calibrated | Athletic stance flexion |
192
+ | Hip Depth / Early Extension | To be calibrated | To be calibrated | Hip depth maintenance |
193
+ # Hip Turn @ Impact metric removed from DTL analysis
194
 
195
  **Classification Bands:**
196
  - **90–100%**: Tour-level
 
202
  - **10–39%**: Novice
203
 
204
  **STYLE & FORMATTING RULES:**
205
+ - Use these headers: OVERALL_SUMMARY, PERFORMANCE_CLASSIFICATION, Metric Evaluations, and the numbered metric sections for both Front-Facing and DTL metrics
206
  - No emojis anywhere in the response
207
  - Write 1-2 sentences maximum for the overall summary - be concise and highlight main strengths/improvement areas
208
  - Write exactly 3 sentences for each metric evaluation
209
+ - Tie all evaluations to the new professional/amateur standards and ranges provided
210
  - Use a positive, coaching tone throughout
211
  - Avoid saying "perfect" — say "strong" or "meets standards"
212
+ - Focus on biomechanics and compare actual values to the new professional ranges (39° shoulder tilt, etc.) - Hip turn removed from DTL metrics
213
  - Consider common amateur swing issues when evaluating: over-swinging, over-the-top path, lack of weight transfer, early extension, lifting the ball, wrist casting, head movement/standing up
214
  - Look for combinations of swing problems that often compound each other in amateur golfers
215
+ - Use the new calibration values: Professional = 39° shoulder tilt, 3.9" hip sway vs 30 Handicap = 27° shoulder tilt, 2.5" hip sway (Hip turn removed from DTL metrics)
216
 
217
 
app/models/front_facing_metrics.py CHANGED
@@ -398,8 +398,46 @@ def pelvis_hat(world_frame):
398
  except Exception:
399
  return None
400
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
401
  def _target_hat_from_toes_world(world_landmarks, setup_frames, handedness='right'):
402
- """Build stable target axis from toe line at address"""
403
  LEFT_TOE, RIGHT_TOE = 31, 32
404
 
405
  toe_vecs = []
@@ -421,8 +459,12 @@ def _target_hat_from_toes_world(world_landmarks, setup_frames, handedness='right
421
  dbg("target_axis - NO VALID TOE VECTORS, using default")
422
  return (1.0, 0.0)
423
 
424
- # Target axis is parallel to toe line (not perpendicular)
425
- target_hat = unit2(np.median(toe_vecs, axis=0))
 
 
 
 
426
 
427
  # Optional: flip sign for consistency if you want +X toward target for left-handed
428
  if handedness == 'left':
@@ -431,33 +473,207 @@ def _target_hat_from_toes_world(world_landmarks, setup_frames, handedness='right
431
  return target_hat
432
 
433
 
434
- def _simple_pixel_sway_fallback(sm, setup_frames, top_frame):
435
- """Simple pixel-based sway fallback that ALWAYS returns a value"""
436
- # Get pelvis centers in pixels
437
- def get_pelvis_center_px(frame_idx):
438
- lm = sm.get(frame_idx)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
439
  if not lm or not lm[23] or not lm[24]:
440
  return None
441
- return [(lm[23][0] + lm[24][0]) / 2, (lm[23][1] + lm[24][1]) / 2]
442
-
443
- # Setup center (median)
444
- setup_centers = [get_pelvis_center_px(f) for f in setup_frames]
445
- setup_centers = [c for c in setup_centers if c is not None]
446
- if not setup_centers:
447
- return 0.0 # Default value if no setup data
448
- setup_ctr = np.median(setup_centers, axis=0)
449
-
450
- # Top center
451
- top_ctr = get_pelvis_center_px(top_frame)
452
  if top_ctr is None:
453
- return 0.0 # Default value if no top data
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
454
 
455
- # Simple horizontal movement in pixels, convert with rough estimate
456
- delta_px = top_ctr[0] - setup_ctr[0]
457
- # Rough conversion: assume ~10 pixels per inch (typical for phone videos)
458
- sway_inches = delta_px / 10.0
459
 
460
- return float(sway_inches)
461
 
462
 
463
  def _subframe_impact_refinement(world_landmarks: Dict[int, List], impact_candidate: int) -> float:
@@ -621,152 +837,29 @@ def _impact_frame(world_landmarks: Dict[int, List], swing_phases: Dict[str, List
621
  return impact_frames[0] if impact_frames else None
622
 
623
 
624
- def _conf3d(world_landmarks, impact_frame, setup_frames):
625
- """Compute 3D confidence and pelvis change in one function"""
626
- if not world_landmarks or impact_frame not in world_landmarks:
627
- return 0.0, 100.0
628
-
629
- try:
630
- # Check frames around impact for stability
631
- test_frames = [f for f in range(impact_frame-2, impact_frame+3) if f in world_landmarks]
632
- if len(test_frames) < 3:
633
- return 0.3, 100.0
634
-
635
- yaws = []
636
- z_positions = []
637
-
638
- for f in test_frames:
639
- frame = world_landmarks[f]
640
- if frame and len(frame) > 24 and frame[23] and frame[24]:
641
- ph = pelvis_hat(frame)
642
- if ph is not None:
643
- yaw_approx = math.degrees(math.atan2(ph[1], ph[0]))
644
- yaws.append(yaw_approx)
645
-
646
- # Z position (depth stability)
647
- lz, rz = frame[23][2], frame[24][2]
648
- z_positions.append((lz + rz) / 2)
649
-
650
- if len(yaws) < 3:
651
- return 0.3, 100.0
652
-
653
- # Compute stability metrics
654
- yaw_std = np.std(yaws)
655
- z_std = np.std(z_positions)
656
-
657
- # Pelvis length change from setup
658
- setup_pelvis_lens = []
659
- for f in setup_frames[:5]:
660
- if f in world_landmarks:
661
- frame = world_landmarks[f]
662
- if frame and len(frame) > 24 and frame[23] and frame[24]:
663
- lx, lz = frame[23][0], frame[23][2]
664
- rx, rz = frame[24][0], frame[24][2]
665
- setup_pelvis_lens.append(math.sqrt((rx-lx)**2 + (rz-lz)**2))
666
-
667
- pelvis_change_pct = 100.0
668
- if setup_pelvis_lens:
669
- impact_frame_data = world_landmarks[impact_frame]
670
- if (impact_frame_data and len(impact_frame_data) > 24 and
671
- impact_frame_data[23] and impact_frame_data[24]):
672
- lx, lz = impact_frame_data[23][0], impact_frame_data[23][2]
673
- rx, rz = impact_frame_data[24][0], impact_frame_data[24][2]
674
- impact_pelvis_len = math.sqrt((rx-lx)**2 + (rz-lz)**2)
675
-
676
- avg_setup = np.mean(setup_pelvis_lens)
677
- if avg_setup > 0:
678
- pelvis_change_pct = abs(impact_pelvis_len - avg_setup) / avg_setup * 100
679
-
680
- # Score components (higher = better)
681
- yaw_score = max(0, 1.0 - yaw_std / 5.0) # Good if <5° std
682
- pelvis_score = max(0, 1.0 - pelvis_change_pct / 15.0) # Good if <15% change
683
- z_score = max(0, 1.0 - z_std / 0.05) # Good if <5cm Z std
684
-
685
- # Combined confidence
686
- conf_3d = (yaw_score * 0.4 + pelvis_score * 0.4 + z_score * 0.2)
687
-
688
- if pelvis_change_pct > 15.0:
689
- dbg(f"3D_conf - pelvis_change={pelvis_change_pct:.1f}% suggests occlusion")
690
-
691
- return max(0.0, min(1.0, conf_3d)), pelvis_change_pct
692
-
693
- except Exception:
694
- return 0.3, 100.0
695
 
696
- def _compute_2d_confidence(pose_data, swing_phases, setup_frames):
697
- """
698
- Compute 2D confidence based on homography quality and geometric consistency
699
- Returns confidence score 0.0-1.0
700
- """
701
- try:
702
- sm = smooth_landmarks(pose_data)
703
- H, valid_H, reason = _build_ground_homography(sm, setup_frames)
704
-
705
- if not valid_H:
706
- return 0.0
707
-
708
- # Check target/toe angle consistency
709
- target_vecs_2d = []
710
- toe_vecs_2d = []
711
-
712
- for f in setup_frames[:3]:
713
- lm = sm.get(f)
714
- if lm and len(lm) > 32 and lm[31] and lm[32]:
715
- # Toe vector in ground plane
716
- toe_img = [[lm[31][0], lm[31][1]], [lm[32][0], lm[32][1]]]
717
- try:
718
- import cv2
719
- toe_ground = cv2.perspectiveTransform(np.array(toe_img, dtype=np.float32).reshape(1,-1,2), H)[0]
720
- toe_vec = toe_ground[1] - toe_ground[0]
721
- norm = np.linalg.norm(toe_vec)
722
- if norm > 1e-6:
723
- toe_vecs_2d.append(toe_vec / norm)
724
- # Target perpendicular to toe
725
- target_vec = np.array([-toe_vec[1], toe_vec[0]]) / norm
726
- target_vecs_2d.append(target_vec)
727
- except:
728
- continue
729
-
730
- if not target_vecs_2d:
731
- return 0.2
732
-
733
- # Check consistency of target direction
734
- target_consistency = 1.0 - np.std([math.degrees(math.atan2(tv[1], tv[0])) for tv in target_vecs_2d]) / 10.0
735
-
736
- # Check toe-target perpendicularity
737
- toe_target_angles = []
738
- for toe_vec, target_vec in zip(toe_vecs_2d, target_vecs_2d):
739
- angle = abs(math.degrees(math.acos(np.clip(np.dot(toe_vec, target_vec), -1, 1))))
740
- toe_target_angles.append(abs(angle - 90.0))
741
-
742
- perp_score = max(0, 1.0 - np.mean(toe_target_angles) / 5.0) # Good if within 5° of 90°
743
-
744
- # Homography quality score (det and error already checked in valid_H)
745
- h_score = 0.8 if valid_H else 0.0
746
-
747
- conf_2d = (target_consistency * 0.3 + perp_score * 0.4 + h_score * 0.3)
748
-
749
-
750
- return max(0.0, min(1.0, conf_2d))
751
-
752
- except Exception:
753
- return 0.0
754
 
755
  def calculate_hip_turn_at_impact(
756
  pose_data: Dict[int, List],
757
  swing_phases: Dict[str, List],
758
  world_landmarks: Optional[Dict[int, List]] = None,
759
  frames: Optional[List[np.ndarray]] = None
760
- ) -> Union[float, None]:
761
  """
762
  Hip turn @ impact: TARGET-RELATIVE degrees (positive = open to target).
 
763
  Fixed to use target line reference instead of camera-relative angles.
764
  """
765
  PLAUSIBLE = 70.0
766
 
767
  if not world_landmarks:
768
  # Fallback: calculate from 2D pose data only
769
- return _calculate_hip_turn_2d_fallback(pose_data, swing_phases, _impact_frame(pose_data, swing_phases))
 
 
 
 
 
770
 
771
  # Get setup frames for target axis and baseline
772
  setup_frames = swing_phases.get("setup", [])[:10]
@@ -776,7 +869,12 @@ def calculate_hip_turn_at_impact(
776
  if all_frames:
777
  setup_frames = all_frames[:min(5, len(all_frames))]
778
  else:
779
- return _calculate_hip_turn_2d_fallback(pose_data, swing_phases, _impact_frame(pose_data, swing_phases))
 
 
 
 
 
780
 
781
  # Build stable target axis from toe line
782
  target_hat = _target_hat_from_toes_world(world_landmarks, setup_frames, handedness='right')
@@ -785,17 +883,27 @@ def calculate_hip_turn_at_impact(
785
  impact_f = _impact_frame(world_landmarks, swing_phases)
786
  if impact_f is None or impact_f not in world_landmarks:
787
  # Fallback: use 2D calculation
788
- return _calculate_hip_turn_2d_fallback(pose_data, swing_phases, _impact_frame(pose_data, swing_phases))
 
 
 
 
 
789
 
790
  # Get pelvis direction at impact
791
  impact_pelvis_hat = pelvis_hat(world_landmarks[impact_f])
792
 
793
  if impact_pelvis_hat is None:
794
  # Fallback: use 2D calculation
795
- return _calculate_hip_turn_2d_fallback(pose_data, swing_phases, impact_f)
 
 
 
 
 
796
 
797
- # ±1 frame median for stability at impact
798
- impact_frames_nearby = [f for f in range(impact_f-1, impact_f+2) if f in world_landmarks]
799
  if len(impact_frames_nearby) > 1:
800
  impact_rels = []
801
  for f in impact_frames_nearby:
@@ -809,37 +917,36 @@ def calculate_hip_turn_at_impact(
809
  else:
810
  impact_rel = signed_angle2(target_hat, impact_pelvis_hat)
811
 
812
- # Hip turn = absolute open angle vs target at impact (not delta from setup)
813
  hip_turn_abs = wrap180(impact_rel)
 
 
 
 
814
 
815
- # 3D confidence check
816
- conf_3d, pelvis_change_pct = _conf3d(world_landmarks, impact_f, setup_frames)
817
 
818
- # Use 3D if high confidence and stable pelvis
819
- if conf_3d >= 0.75 and pelvis_change_pct <= 15.0:
820
- final_turn = hip_turn_abs
821
- else:
822
- # Try 2D fallback
823
- turn_2d = _calculate_hip_turn_2d_fallback(pose_data, swing_phases, impact_f)
824
- if turn_2d is not None:
825
- _, valid_H, _ = _build_ground_homography(smooth_landmarks(pose_data), setup_frames)
826
- if valid_H and abs(hip_turn_abs - turn_2d) <= 20.0:
827
- final_turn = turn_2d
828
- dbg(f"hip_turn - using 2D fallback: {turn_2d:.1f}°")
829
- else:
830
- final_turn = hip_turn_abs
831
- else:
832
- final_turn = hip_turn_abs
833
- if conf_3d < 0.75 or pelvis_change_pct > 15.0:
834
- dbg(f"hip_turn - 3D unreliable (conf={conf_3d:.3f}, pelvis_change={pelvis_change_pct:.1f}%)")
835
 
836
- # Plausible check - but ALWAYS return something
837
- if abs(final_turn) > PLAUSIBLE:
838
- if conf_3d < 0.8:
839
- dbg(f"hip_turn - VALUE {final_turn:.1f}° EXCEEDS PLAUSIBLE RANGE, clamping")
840
- # Clamp to reasonable range rather than returning None
841
- final_turn = PLAUSIBLE if final_turn > 0 else -PLAUSIBLE
842
- return float(final_turn)
843
 
844
 
845
  def _build_ground_homography(sm, setup_frames):
@@ -950,8 +1057,43 @@ def _calculate_hip_turn_2d_fallback(pose_data: Dict[int, List], swing_phases: Di
950
  return None
951
 
952
  toe_hat_2d = np.median(toe_vecs_ground, axis=0)
953
- # Target is parallel to toe line (not perpendicular)
954
- target_hat_2d = toe_hat_2d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
955
 
956
  # Get pelvis directions in ground plane
957
  def pelvis_hat_ground(frame_idx):
@@ -980,85 +1122,156 @@ def _calculate_hip_turn_2d_fallback(pose_data: Dict[int, List], swing_phases: Di
980
  except Exception:
981
  return None
982
 
983
-
984
-
985
-
986
-
987
-
988
-
989
-
990
  def calculate_hip_sway_at_top(pose_data: Dict[int, List], swing_phases: Dict[str, List],
991
- world_landmarks: Optional[Dict[int, List]] = None) -> Union[float, None]:
992
- """Calculate hip sway at top using 2D homography projection along toe/target axis"""
993
- smoothed_pose_data = smooth_landmarks(pose_data)
994
-
995
- setup_frames = _address_frames(swing_phases)
996
- if not setup_frames:
997
- return None
998
-
999
- # Find top frame
1000
  backswing_frames = swing_phases.get("backswing", [])
1001
- if not backswing_frames:
1002
- return None
1003
-
1004
- top_frame = _pick_top_frame_hinge(smoothed_pose_data, backswing_frames, handed='right')
1005
- if top_frame is None:
1006
- top_frame = backswing_frames[-1]
1007
 
1008
- # Use 2D homography as primary method (world landmarks can re-center per frame)
1009
- H, valid_H, _ = _build_ground_homography(smoothed_pose_data, setup_frames)
1010
- if not valid_H:
1011
- # ALWAYS DISPLAY SOMETHING - fallback to simple pixel measurement if homography fails
1012
- return _simple_pixel_sway_fallback(smoothed_pose_data, setup_frames, top_frame)
1013
-
1014
- try:
1015
  import cv2
 
1016
 
1017
- def pelvis_center_ground(frame_idx):
1018
- lm = smoothed_pose_data.get(frame_idx)
1019
- if not lm or not lm[23] or not lm[24]:
1020
- return None
1021
- hip_img = np.array([[lm[23][0], lm[23][1]], [lm[24][0], lm[24][1]]], dtype=np.float32)
1022
- g = cv2.perspectiveTransform(hip_img.reshape(1,-1,2), H)[0]
1023
- return (g[0] + g[1]) / 2
1024
 
1025
- # Toe direction (ground) = target axis
1026
  toe_vecs = []
1027
  for f in setup_frames:
1028
- lm = smoothed_pose_data.get(f)
1029
- if lm and lm[31] and lm[32]:
1030
- toe_img = np.array([[lm[31][0], lm[31][1]], [lm[32][0], lm[32][1]]], dtype=np.float32)
1031
- toe_g = cv2.perspectiveTransform(toe_img.reshape(1,-1,2), H)[0]
1032
- v = toe_g[1] - toe_g[0]
1033
- n = np.linalg.norm(v)
1034
- if n > 1e-6:
1035
- toe_vecs.append(v/n)
1036
-
1037
- if not toe_vecs:
1038
- return None
1039
-
1040
- target_hat_g = np.median(toe_vecs, axis=0) # unit 2D vector in ground coords
1041
-
1042
- # Setup center (median)
1043
- setup_centers = [pelvis_center_ground(f) for f in setup_frames]
1044
- setup_centers = [c for c in setup_centers if c is not None]
1045
- if not setup_centers:
1046
- return None
1047
- setup_ctr = np.median(setup_centers, axis=0)
1048
-
1049
- # Top center
1050
- top_ctr = pelvis_center_ground(top_frame)
1051
- if top_ctr is None:
1052
- return None
1053
-
1054
- # Project movement onto toe/target axis
1055
- delta = top_ctr - setup_ctr
1056
- sway_inches = float(np.dot(delta, target_hat_g)) # ground_pts are in inches already
1057
- return sway_inches
1058
-
1059
- except Exception:
1060
- # ALWAYS DISPLAY SOMETHING - fallback if homography projection fails
1061
- return _simple_pixel_sway_fallback(smoothed_pose_data, setup_frames, top_frame)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1062
 
1063
 
1064
 
@@ -1074,29 +1287,142 @@ def angle_between(u, v):
1074
 
1075
 
1076
 
1077
- def _hinge2d(lm, handed='right'):
1078
- """Simple 2D hinge angle: elbow-wrist vs wrist-index (robust, repeatable)"""
1079
- if not lm:
 
1080
  return None
1081
- EL, WR = (13, 15) if handed=='right' else (14, 16) # Lead arm
1082
- IM = 19 if handed=='right' else 20
1083
- if not lm[EL] or not lm[WR] or not lm[IM]:
1084
  return None
 
 
 
 
 
 
 
 
 
 
 
1085
 
1086
- u = np.array([lm[WR][0]-lm[EL][0], lm[WR][1]-lm[EL][1]], float) # forearm
1087
- v = np.array([lm[IM][0]-lm[WR][0], lm[IM][1]-lm[WR][1]], float) # hand
1088
- nu, nv = np.linalg.norm(u), np.linalg.norm(v)
1089
- if nu < 1e-6 or nv < 1e-6:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1090
  return None
1091
-
1092
- ang = math.degrees(math.acos(np.clip(np.dot(u,v)/(nu*nv), -1, 1))) # 0..180
1093
- return 180.0 - ang # more hinge → larger value
1094
-
1095
-
1096
-
1097
-
1098
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1099
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1100
 
1101
  def _pick_top_frame_hinge(sm, backswing_frames, handed='right'):
1102
  """Pick frame with maximum hinge in backswing"""
@@ -1112,16 +1438,9 @@ def _pick_top_frame_hinge(sm, backswing_frames, handed='right'):
1112
  top_frame, hinge_top = max(top_vals, key=lambda x: x[1])
1113
  return top_frame
1114
 
1115
-
1116
-
1117
-
1118
-
1119
-
1120
-
1121
-
1122
  def calculate_wrist_hinge_at_top(pose_data: Dict[int, List], swing_phases: Dict[str, List],
1123
- frames: Optional[List[np.ndarray]] = None) -> Union[Dict[str, float], None]:
1124
- """Calculate wrist hinge using simple 2D angle method"""
1125
  sm = smooth_landmarks(pose_data)
1126
 
1127
  backswing_frames = swing_phases.get("backswing", [])
@@ -1136,68 +1455,104 @@ def calculate_wrist_hinge_at_top(pose_data: Dict[int, List], swing_phases: Dict[
1136
  else:
1137
  return None # Truly no data
1138
 
1139
- # Top = max hinge in backswing
1140
- top_frame = _pick_top_frame_hinge(sm, backswing_frames, handed='right')
1141
- if top_frame is None:
1142
- top_frame = backswing_frames[-1] # Use last frame as fallback
1143
-
1144
- hinge_top = _hinge2d(sm.get(top_frame), handed='right')
1145
- if hinge_top is None:
1146
- # Try to find any frame with valid hinge data
1147
- for frame_idx in backswing_frames:
1148
- test_hinge = _hinge2d(sm.get(frame_idx), handed='right')
1149
- if test_hinge is not None:
1150
- hinge_top = test_hinge
1151
- break
1152
- if hinge_top is None:
1153
- return None # Truly no wrist data available
1154
-
1155
- # Address median for delta calculation
1156
- setup_frames = _address_frames(swing_phases)
1157
- addr_vals = [_hinge2d(sm.get(f), handed='right') for f in setup_frames[:5]]
1158
- addr_vals = [h for h in addr_vals if h is not None]
1159
- hinge_addr = np.median(addr_vals) if addr_vals else None
1160
-
1161
- result = {"radial_deviation_deg": hinge_top}
1162
- if hinge_addr is not None:
1163
- delta = hinge_top - hinge_addr
1164
- result["delta_deg"] = delta
1165
-
1166
- # Quality check for typical range
1167
- if not (15.0 <= delta <= 50.0):
1168
- result["definition_suspect"] = True
1169
-
1170
  return result
1171
 
1172
 
1173
  def compute_front_facing_metrics(pose_data: Dict[int, List], swing_phases: Dict[str, List],
1174
  world_landmarks: Optional[Dict[int, List]] = None,
1175
- frames: Optional[List[np.ndarray]] = None) -> Dict[str, Dict[str, Union[float, None]]]:
1176
- """Compute the 4 required front-facing golf swing metrics"""
 
 
1177
 
1178
  # Calculate individual metrics
1179
  shoulder_tilt_impact = calculate_shoulder_tilt_at_impact(
1180
- pose_data, swing_phases, world_landmarks, frames=frames
1181
  )
1182
- hip_turn_impact = calculate_hip_turn_at_impact(pose_data, swing_phases, world_landmarks, frames)
1183
  hip_sway_top = calculate_hip_sway_at_top(pose_data, swing_phases, world_landmarks)
1184
- wrist_hinge_result = calculate_wrist_hinge_at_top(pose_data, swing_phases, frames)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1185
 
1186
- # Extract wrist hinge values
1187
- wrist_radial_dev = wrist_hinge_result.get('radial_deviation_deg') if wrist_hinge_result else None
1188
- wrist_hinge_delta = wrist_hinge_result.get('delta_deg') if wrist_hinge_result else None
 
 
 
1189
  definition_suspect = wrist_hinge_result.get('definition_suspect', False) if wrist_hinge_result else False
1190
 
1191
- # ALWAYS DISPLAY A METRIC - use delta first, then absolute as fallback
1192
- wrist_final = wrist_hinge_delta
1193
- if wrist_final is None and wrist_radial_dev is not None:
1194
- wrist_final = wrist_radial_dev # Use absolute as fallback
1195
 
1196
  front_facing_metrics = {
1197
  'shoulder_tilt_impact_deg': {'value': shoulder_tilt_impact},
1198
- 'hip_turn_impact_deg': {'value': hip_turn_impact},
1199
  'hip_sway_top_inches': {'value': hip_sway_top},
1200
- 'wrist_hinge_top_deg': {'value': wrist_final}
 
 
 
 
 
 
 
 
1201
  }
1202
 
1203
  return front_facing_metrics
 
398
  except Exception:
399
  return None
400
 
401
+ def _pelvis_rel_to_target(world_frame, target_hat):
402
+ """Get pelvis orientation relative to target axis"""
403
+ ph = pelvis_hat(world_frame)
404
+ return None if ph is None else wrap180(signed_angle2(target_hat, ph))
405
+
406
+ def _address_pelvis_rel(world_landmarks, setup_frames, target_hat):
407
+ """Calculate median pelvis orientation relative to target at address"""
408
+ vals = []
409
+ for f in setup_frames[:8]:
410
+ if f in world_landmarks:
411
+ rel = _pelvis_rel_to_target(world_landmarks[f], target_hat)
412
+ if rel is not None:
413
+ vals.append(rel)
414
+ return float(np.median(vals)) if vals else None
415
+
416
+ def grade_hip_turn(delta_deg: float, club: str = "iron") -> dict:
417
+ """Grade hip turn with club-aware bands and actionable tips"""
418
+ c = club.lower()
419
+ bands = {
420
+ "driver": {"excellent": (38,48), "good_low": (32,38), "good_high": (48,52)},
421
+ "iron": {"excellent": (32,40), "good_low": (28,32), "good_high": (40,44)},
422
+ "wedge": {"excellent": (25,35), "good_low": (22,25), "good_high": (35,38)},
423
+ }
424
+ b = bands["driver" if "driver" in c else "wedge" if "wedge" in c else "iron"]
425
+ x = delta_deg
426
+
427
+ if b["excellent"][0] <= x <= b["excellent"][1]:
428
+ label, color, tip = "🟢 Excellent", "green", "Great rotation with control."
429
+ elif b["good_low"][0] <= x < b["good_low"][1]:
430
+ label, color, tip = "🟠 Good (slightly low)", "orange", "Open a touch more through impact."
431
+ elif b["good_high"][0] <= x <= b["good_high"][1]:
432
+ label, color, tip = "🟠 Good (slightly high)", "orange", "Post on lead leg; avoid over-spinning hips."
433
+ else:
434
+ label, color, tip = "🔴 Needs work", "red", (
435
+ "Too little: shift then open. Too much: trail heel down longer; post into lead glute."
436
+ )
437
+ return {"label": label, "color": color, "tip": tip}
438
+
439
  def _target_hat_from_toes_world(world_landmarks, setup_frames, handedness='right'):
440
+ """Build stable target axis from toe line at address with outlier trimming"""
441
  LEFT_TOE, RIGHT_TOE = 31, 32
442
 
443
  toe_vecs = []
 
459
  dbg("target_axis - NO VALID TOE VECTORS, using default")
460
  return (1.0, 0.0)
461
 
462
+ # Drop 20% widest-angle outliers before median
463
+ toe_vecs = np.array(toe_vecs, dtype=float)
464
+ angles = np.degrees(np.arctan2(toe_vecs[:,1], toe_vecs[:,0]))
465
+ med, dev = np.median(angles), np.abs(angles - np.median(angles))
466
+ keep = dev <= np.percentile(dev, 80)
467
+ target_hat = unit2(np.median(toe_vecs[keep], axis=0))
468
 
469
  # Optional: flip sign for consistency if you want +X toward target for left-handed
470
  if handedness == 'left':
 
473
  return target_hat
474
 
475
 
476
+ def _pick_quiet_address(sm, swing_phases, warp2, max_frames=6, speed_thr_in=0.20):
477
+ """Pick early address frames with strict stillness criteria"""
478
+ cand = swing_phases.get("setup", [])[:16]
479
+ if not cand: return []
480
+ # pelvis centers in ground
481
+ ctrs = []
482
+ for f in cand:
483
+ lm = sm.get(f)
484
+ if not lm or not lm[23] or not lm[24]:
485
+ ctrs.append(None)
486
+ continue
487
+ g = warp2([[lm[23][0], lm[23][1]], [lm[24][0], lm[24][1]]])
488
+ ctrs.append(((g[0]+g[1])/2).astype(float))
489
+ keep = []
490
+ prev = None
491
+ for f,c in zip(cand, ctrs):
492
+ if c is None: break
493
+ spd = 0.0 if prev is None else float(np.linalg.norm(c - prev))
494
+ if spd <= speed_thr_in:
495
+ keep.append(f)
496
+ if len(keep) >= max_frames: break
497
+ else:
498
+ break
499
+ prev = c
500
+ return keep or cand[:max_frames]
501
+
502
+ def _pick_stable_setup_frames(sm, swing_phases, max_frames=6):
503
+ """Pick first address frames with low pelvis motion (reduces waggle bias)."""
504
+ cand = swing_phases.get("setup", [])[:12]
505
+ if not cand:
506
+ return []
507
+ def pelvis_ctr_px(f):
508
+ lm = sm.get(f)
509
+ if not lm or not lm[23] or not lm[24]: return None
510
+ return np.array([(lm[23][0]+lm[24][0])*0.5, (lm[23][1]+lm[24][1])*0.5], float)
511
+ centers = [(f, pelvis_ctr_px(f)) for f in cand]
512
+ centers = [(f, c) for f,c in centers if c is not None]
513
+ if len(centers) < 2: return [f for f,_ in centers][:max_frames]
514
+ # speed threshold: keep earliest frames whose step-to-step pelvis motion is small
515
+ speeds = []
516
+ for i in range(1, len(centers)):
517
+ f0,c0 = centers[i-1]; f1,c1 = centers[i]
518
+ speeds.append((f1, float(np.linalg.norm(c1-c0))))
519
+ if not speeds: return [centers[0][0]]
520
+ # pick prefix until motion inflates (waggle begins)
521
+ thr = max(1.5, np.median([s for _,s in speeds])*2.5)
522
+ keep = [centers[0][0]]
523
+ for f,s in speeds:
524
+ if s <= thr and len(keep) < max_frames:
525
+ keep.append(f)
526
+ else:
527
+ break
528
+ return keep
529
+
530
+ def _pick_still_address_by_hand(sm, swing_phases, handed='right', max_frames=6):
531
+ cand = swing_phases.get("setup", [])[:16]
532
+ if not cand: return []
533
+ # choose lead wrist landmark
534
+ WR = 15 if handed=='right' else 16
535
+ pts = []
536
+ for f in cand:
537
+ lm = sm.get(f)
538
+ if lm and lm[WR] and lm[WR][2] >= 0.5:
539
+ pts.append((f, np.array([lm[WR][0], lm[WR][1]], float)))
540
+ else:
541
+ break
542
+ if len(pts) < 2:
543
+ return [f for f,_ in pts][:max_frames]
544
+
545
+ speeds = [0.0]
546
+ for i in range(1, len(pts)):
547
+ speeds.append(float(np.linalg.norm(pts[i][1] - pts[i-1][1])))
548
+ # tight threshold to capture true stillness before takeaway/waggle
549
+ thr = max(0.4, np.median(speeds) * 2.0)
550
+ keep = [pts[0][0]]
551
+ for (f,_), s in zip(pts[1:], speeds[1:]):
552
+ if s <= thr and len(keep) < max_frames:
553
+ keep.append(f)
554
+ else:
555
+ break
556
+ return keep
557
+
558
+ def _build_foot_only_homography(sm, setup_frames):
559
+ """
560
+ Build ground-plane H from 4 foot points only (no hips).
561
+ Image pts: L_heel(31), R_heel(32), L_toe(29), R_toe(30)
562
+ Ground pts are a rectangle in arbitrary inches; scale will be calibrated later.
563
+ """
564
+ import cv2
565
+ for f in setup_frames:
566
+ lm = sm.get(f)
567
+ if not lm: continue
568
+ needed = [29,30,31,32]
569
+ if any((i>=len(lm) or lm[i] is None or lm[i][2] < 0.5) for i in needed):
570
+ continue
571
+ img = np.array([
572
+ [lm[31][0], lm[31][1]], # L heel
573
+ [lm[32][0], lm[32][1]], # R heel
574
+ [lm[29][0], lm[29][1]], # L toe
575
+ [lm[30][0], lm[30][1]], # R toe
576
+ ], dtype=np.float32)
577
+ # A reasonable shoe: heel-to-toe depth ~11" and stance width ~ 16" default box
578
+ # (We will re-scale to true inches using 3D toe spacing shortly.)
579
+ ground = np.array([
580
+ [-8.0, 0.0], # L heel
581
+ [ 8.0, 0.0], # R heel
582
+ [-8.0, 11.0], # L toe
583
+ [ 8.0, 11.0], # R toe
584
+ ], dtype=np.float32)
585
+ H, mask = cv2.findHomography(img, ground, cv2.RANSAC, 2.0)
586
+ if H is None:
587
+ continue
588
+ det = abs(np.linalg.det(H[:2,:2]))
589
+ if det > 1e-5:
590
+ return H, True, f
591
+ return None, False, None
592
+
593
+ def _simple_pixel_sway_fallback(sm, setup_frames, top_frame, swing_phases=None, world_landmarks=None):
594
+ """Improved pixel-based sway fallback with world landmark scale calibration"""
595
+ def get_lm(frame_idx):
596
+ return sm.get(frame_idx)
597
+
598
+ # pick a setup frame that has feet + hips
599
+ setup_ok = [f for f in setup_frames
600
+ if get_lm(f) and get_lm(f)[23] and get_lm(f)[24] and get_lm(f)[31] and get_lm(f)[32]]
601
+ if not setup_ok:
602
+ return 0.0
603
+
604
+ f0 = setup_ok[0]
605
+ lm0 = get_lm(f0)
606
+
607
+ # pelvis centers (pixels)
608
+ def pelvis_ctr_px(f):
609
+ lm = get_lm(f)
610
  if not lm or not lm[23] or not lm[24]:
611
  return None
612
+ return np.array([(lm[23][0] + lm[24][0]) * 0.5,
613
+ (lm[23][1] + lm[24][1]) * 0.5], dtype=float)
614
+
615
+ setup_ctrs = [pelvis_ctr_px(f) for f in setup_ok]
616
+ setup_ctrs = [c for c in setup_ctrs if c is not None]
617
+ if not setup_ctrs:
618
+ return 0.0
619
+ setup_ctr = np.median(np.stack(setup_ctrs), axis=0)
620
+
621
+ top_ctr = pelvis_ctr_px(top_frame)
 
622
  if top_ctr is None:
623
+ return 0.0
624
+
625
+ # toe vector in pixels from the same setup frame
626
+ toe_v = np.array([lm0[32][0] - lm0[31][0],
627
+ lm0[32][1] - lm0[31][1]], dtype=float)
628
+ n = np.linalg.norm(toe_v)
629
+ if n < 1e-6:
630
+ # fallback to horizontal approximation if toes are missing
631
+ toe_hat_px = np.array([1.0, 0.0])
632
+ else:
633
+ toe_hat_px = toe_v / n
634
+
635
+ # Target axis (toe line itself) in pixel space
636
+ target_hat_px = toe_hat_px / (np.linalg.norm(toe_hat_px) + 1e-9)
637
+
638
+ # --- NEW: sign disambiguation using address -> impact pelvis centers in pixel space ---
639
+ if swing_phases and swing_phases.get("impact"):
640
+ f_imp = swing_phases["impact"][0]
641
+ # median address pelvis center in px
642
+ addr_ctrs = [pelvis_ctr_px(f) for f in setup_ok]
643
+ addr_ctrs = [c for c in addr_ctrs if c is not None]
644
+ imp_ctr = pelvis_ctr_px(f_imp)
645
+ if addr_ctrs and imp_ctr is not None:
646
+ addr_ctr = np.median(np.stack(addr_ctrs), axis=0)
647
+ fwd_vec_px = imp_ctr - addr_ctr
648
+ if float(np.dot(fwd_vec_px, target_hat_px)) < 0.0:
649
+ target_hat_px = -target_hat_px
650
+
651
+ delta_px = top_ctr - setup_ctr
652
+ sway_px = float(np.dot(delta_px, target_hat_px))
653
+
654
+ # Improved px→inch scale using world landmarks if available
655
+ px_per_inch = 10.0 # default fallback
656
+ if world_landmarks is not None and f0 in world_landmarks:
657
+ try:
658
+ wf = world_landmarks[f0]
659
+ L_FOOT_INDEX, R_FOOT_INDEX = 29, 30
660
+ Lt, Rt = wf[L_FOOT_INDEX], wf[R_FOOT_INDEX]
661
+ if Lt and Rt:
662
+ # 3D toe spacing in meters → inches
663
+ d_m = math.hypot(Rt[0]-Lt[0], Rt[2]-Lt[2])
664
+ d_in_true = d_m * 39.3701
665
+ # Pixel toe spacing
666
+ d_px = float(np.linalg.norm(toe_v))
667
+ if d_px > 1e-3 and d_in_true > 1e-3:
668
+ px_per_inch = d_px / d_in_true
669
+ print(f"DEBUG pixel_fallback: calibrated px_per_inch={px_per_inch:.2f} from 3D toes")
670
+ except Exception:
671
+ pass
672
 
673
+ sway_inches = sway_px / px_per_inch
674
+ print(f"DEBUG pixel_fallback: sway_px={sway_px:.1f}, px_per_inch={px_per_inch:.2f}, sway_in={sway_inches:.2f}")
 
 
675
 
676
+ return sway_inches
677
 
678
 
679
  def _subframe_impact_refinement(world_landmarks: Dict[int, List], impact_candidate: int) -> float:
 
837
  return impact_frames[0] if impact_frames else None
838
 
839
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
840
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
841
 
842
  def calculate_hip_turn_at_impact(
843
  pose_data: Dict[int, List],
844
  swing_phases: Dict[str, List],
845
  world_landmarks: Optional[Dict[int, List]] = None,
846
  frames: Optional[List[np.ndarray]] = None
847
+ ) -> Union[Dict[str, Union[float, None]], None]:
848
  """
849
  Hip turn @ impact: TARGET-RELATIVE degrees (positive = open to target).
850
+ Returns dict with both absolute and delta (change from address) values.
851
  Fixed to use target line reference instead of camera-relative angles.
852
  """
853
  PLAUSIBLE = 70.0
854
 
855
  if not world_landmarks:
856
  # Fallback: calculate from 2D pose data only
857
+ fallback_result = _calculate_hip_turn_2d_fallback(pose_data, swing_phases, _impact_frame(pose_data, swing_phases))
858
+ return {
859
+ "abs_deg": fallback_result,
860
+ "delta_deg": None,
861
+ "addr_deg": None,
862
+ } if fallback_result is not None else None
863
 
864
  # Get setup frames for target axis and baseline
865
  setup_frames = swing_phases.get("setup", [])[:10]
 
869
  if all_frames:
870
  setup_frames = all_frames[:min(5, len(all_frames))]
871
  else:
872
+ fallback_result = _calculate_hip_turn_2d_fallback(pose_data, swing_phases, _impact_frame(pose_data, swing_phases))
873
+ return {
874
+ "abs_deg": fallback_result,
875
+ "delta_deg": None,
876
+ "addr_deg": None,
877
+ } if fallback_result is not None else None
878
 
879
  # Build stable target axis from toe line
880
  target_hat = _target_hat_from_toes_world(world_landmarks, setup_frames, handedness='right')
 
883
  impact_f = _impact_frame(world_landmarks, swing_phases)
884
  if impact_f is None or impact_f not in world_landmarks:
885
  # Fallback: use 2D calculation
886
+ fallback_result = _calculate_hip_turn_2d_fallback(pose_data, swing_phases, _impact_frame(pose_data, swing_phases))
887
+ return {
888
+ "abs_deg": fallback_result,
889
+ "delta_deg": None,
890
+ "addr_deg": None,
891
+ } if fallback_result is not None else None
892
 
893
  # Get pelvis direction at impact
894
  impact_pelvis_hat = pelvis_hat(world_landmarks[impact_f])
895
 
896
  if impact_pelvis_hat is None:
897
  # Fallback: use 2D calculation
898
+ fallback_result = _calculate_hip_turn_2d_fallback(pose_data, swing_phases, impact_f)
899
+ return {
900
+ "abs_deg": fallback_result,
901
+ "delta_deg": None,
902
+ "addr_deg": None,
903
+ } if fallback_result is not None else None
904
 
905
+ # ±2 frame median for stability at impact (expanded window)
906
+ impact_frames_nearby = [f for f in range(impact_f-2, impact_f+3) if f in world_landmarks]
907
  if len(impact_frames_nearby) > 1:
908
  impact_rels = []
909
  for f in impact_frames_nearby:
 
917
  else:
918
  impact_rel = signed_angle2(target_hat, impact_pelvis_hat)
919
 
920
+ # Hip orientation (absolute) vs target at impact
921
  hip_turn_abs = wrap180(impact_rel)
922
+
923
+ # NEW: compute address (baseline) and delta turn
924
+ addr_rel = _address_pelvis_rel(world_landmarks, setup_frames, target_hat)
925
+ hip_turn_delta = None if addr_rel is None else wrap180(hip_turn_abs - addr_rel)
926
 
927
+ # Use 3D calculation as primary method
928
+ final_turn = hip_turn_abs
929
 
930
+ # Prepare both absolute and delta values
931
+ final_abs = float(final_turn)
932
+ final_delta = None if addr_rel is None else wrap180(final_turn - addr_rel)
933
+
934
+ # Plausibility clamp for delta (pros ~30–45 by club); still always return something
935
+ if final_delta is not None and abs(final_delta) > PLAUSIBLE:
936
+ final_delta = PLAUSIBLE if final_delta > 0 else -PLAUSIBLE
937
+
938
+ # Final plausible check for absolute
939
+ if abs(final_abs) > PLAUSIBLE:
940
+ dbg(f"hip_turn - VALUE {final_abs:.1f}° EXCEEDS PLAUSIBLE RANGE, clamping")
941
+ # Clamp to reasonable range rather than returning None
942
+ final_abs = PLAUSIBLE if final_abs > 0 else -PLAUSIBLE
 
 
 
 
943
 
944
+ # Return both values and metadata for UI
945
+ return {
946
+ "abs_deg": final_abs,
947
+ "delta_deg": final_delta,
948
+ "addr_deg": addr_rel
949
+ }
 
950
 
951
 
952
  def _build_ground_homography(sm, setup_frames):
 
1057
  return None
1058
 
1059
  toe_hat_2d = np.median(toe_vecs_ground, axis=0)
1060
+ toe_hat_2d = toe_hat_2d / (np.linalg.norm(toe_hat_2d) + 1e-9)
1061
+
1062
+ # Target is PERPENDICULAR to toe line (raw)
1063
+ target_hat_2d = np.array([-toe_hat_2d[1], toe_hat_2d[0]])
1064
+ target_hat_2d /= (np.linalg.norm(target_hat_2d) + 1e-9)
1065
+
1066
+ # --- NEW: sign disambiguation using address -> impact movement ---
1067
+ candidate_fwd = None
1068
+ if "impact" in swing_phases and swing_phases["impact"]:
1069
+ candidate_fwd = swing_phases["impact"][0]
1070
+ elif "downswing" in swing_phases and swing_phases["downswing"]:
1071
+ candidate_fwd = swing_phases["downswing"][-1]
1072
+ else:
1073
+ all_frames_sorted = sorted(pose_data.keys())
1074
+ candidate_fwd = all_frames_sorted[-1] if all_frames_sorted else None
1075
+
1076
+ if candidate_fwd is not None:
1077
+ # Get address and impact pelvis centers in ground plane
1078
+ addr_ctrs = []
1079
+ for f in setup_frames:
1080
+ lm = sm.get(f)
1081
+ if lm and len(lm) > 32 and lm[23] and lm[24]:
1082
+ hip_img = [[lm[23][0], lm[23][1]], [lm[24][0], lm[24][1]]]
1083
+ hip_ground = warp_pts(hip_img)
1084
+ addr_ctrs.append((hip_ground[0] + hip_ground[1]) / 2)
1085
+
1086
+ lm_impact = sm.get(candidate_fwd)
1087
+ if addr_ctrs and lm_impact and lm_impact[23] and lm_impact[24]:
1088
+ hip_img = [[lm_impact[23][0], lm_impact[23][1]], [lm_impact[24][0], lm_impact[24][1]]]
1089
+ impact_ground = warp_pts(hip_img)
1090
+ impact_ctr = (impact_ground[0] + impact_ground[1]) / 2
1091
+
1092
+ addr_ctr = np.median(np.stack(addr_ctrs), axis=0)
1093
+ fwd_vec = impact_ctr - addr_ctr
1094
+ # If forward (address->impact) projects negative, flip target axis
1095
+ if float(np.dot(fwd_vec, target_hat_2d)) < 0.0:
1096
+ target_hat_2d = -target_hat_2d
1097
 
1098
  # Get pelvis directions in ground plane
1099
  def pelvis_hat_ground(frame_idx):
 
1122
  except Exception:
1123
  return None
1124
 
 
 
 
 
 
 
 
1125
  def calculate_hip_sway_at_top(pose_data: Dict[int, List], swing_phases: Dict[str, List],
1126
+ world_landmarks: Optional[Dict[int, List]] = None) -> Union[float, None]:
1127
+ sm = smooth_landmarks(pose_data)
1128
+
1129
+ # (1) robust address & top
1130
+ initial_setup_frames = _pick_stable_setup_frames(sm, swing_phases, max_frames=6)
1131
+ if not initial_setup_frames: return None
 
 
 
1132
  backswing_frames = swing_phases.get("backswing", [])
1133
+ if not backswing_frames: return None
1134
+ top_frame = _pick_top_frame_hinge(sm, backswing_frames, handed='right') or backswing_frames[-1]
1135
+
1136
+ # (2) foot-only homography (no hips); reduces circularity/instability
1137
+ H, valid_H, H_frame = _build_foot_only_homography(sm, initial_setup_frames)
 
1138
 
1139
+ # (2.5) improved processing if homography is valid
1140
+ if valid_H and H is not None:
 
 
 
 
 
1141
  import cv2
1142
+ warp2 = lambda pts: cv2.perspectiveTransform(np.array(pts, dtype=np.float32).reshape(1, -1, 2), H)[0]
1143
 
1144
+ # Improved address selection with stillness criteria
1145
+ setup_frames = _pick_quiet_address(sm, swing_phases, warp2, max_frames=6)
1146
+ if not setup_frames:
1147
+ setup_frames = initial_setup_frames
 
 
 
1148
 
1149
+ # Refine top frame using velocity
1150
  toe_vecs = []
1151
  for f in setup_frames:
1152
+ lm = sm.get(f)
1153
+ if not lm or not lm[31] or not lm[32]: continue
1154
+ toe_img = [[lm[31][0], lm[31][1]], [lm[32][0], lm[32][1]]]
1155
+ toe_g = warp2(toe_img)
1156
+ v = toe_g[1] - toe_g[0]
1157
+ n = np.linalg.norm(v)
1158
+ if n > 1e-6:
1159
+ toe_vecs.append(v / n)
1160
+ if toe_vecs:
1161
+ toe_hat_g_vel = np.median(np.stack(toe_vecs), axis=0)
1162
+ toe_hat_g_vel /= (np.linalg.norm(toe_hat_g_vel) + 1e-9)
1163
+ # Target line is PERPENDICULAR to toe line
1164
+ target_hat_g = np.array([-toe_hat_g_vel[1], toe_hat_g_vel[0]], dtype=float)
1165
+ target_hat_g /= (np.linalg.norm(target_hat_g) + 1e-9)
1166
+ top_frame = _refine_top_by_lat_vel(sm, backswing_frames, warp2, target_hat_g, top_frame)
1167
+ else:
1168
+ setup_frames = initial_setup_frames
1169
+ if not valid_H:
1170
+ # fallback improved: compute px→inch using world toes if available
1171
+ return _simple_pixel_sway_fallback(sm, setup_frames, top_frame, swing_phases, world_landmarks)
1172
+
1173
+ import cv2
1174
+
1175
+ def warp2(pts):
1176
+ A = np.array(pts, dtype=np.float32).reshape(1, -1, 2)
1177
+ return cv2.perspectiveTransform(A, H)[0]
1178
+
1179
+ # (3) target axis from toe line (ground), same as before
1180
+ toe_vecs = []
1181
+ for f in setup_frames:
1182
+ lm = sm.get(f)
1183
+ if not lm or not lm[31] or not lm[32]: continue
1184
+ toe_img = [[lm[31][0], lm[31][1]], [lm[32][0], lm[32][1]]]
1185
+ toe_g = warp2(toe_img)
1186
+ v = toe_g[1] - toe_g[0]
1187
+ n = np.linalg.norm(v)
1188
+ if n > 1e-6:
1189
+ toe_vecs.append(v / n)
1190
+ if not toe_vecs:
1191
+ return None
1192
+ toe_hat_g = np.median(np.stack(toe_vecs), axis=0)
1193
+ toe_hat_g /= (np.linalg.norm(toe_hat_g) + 1e-9)
1194
+ # Target line is PERPENDICULAR to toe line (down-the-line toward the flag)
1195
+ target_hat_g = np.array([-toe_hat_g[1], toe_hat_g[0]], dtype=float)
1196
+ target_hat_g /= (np.linalg.norm(target_hat_g) + 1e-9)
1197
+ print(f"DEBUG sway2: toe_hat_g={toe_hat_g}, target_hat_g={target_hat_g}, dot≈{float(np.dot(toe_hat_g, target_hat_g)):.3f}")
1198
+
1199
+ # (4) sign disambiguation using address→impact pelvis motion (keep)
1200
+ candidate_fwd = None
1201
+ if swing_phases.get("impact"): candidate_fwd = swing_phases["impact"][0]
1202
+ elif swing_phases.get("downswing"): candidate_fwd = swing_phases["downswing"][-1]
1203
+ else:
1204
+ allf = sorted(sm.keys()); candidate_fwd = allf[-1] if allf else None
1205
+
1206
+ def pelvis_center_ground(f):
1207
+ lm = sm.get(f)
1208
+ if not lm or not lm[23] or not lm[24]: return None
1209
+ g = warp2([[lm[23][0], lm[23][1]], [lm[24][0], lm[24][1]]])
1210
+ return (g[0] + g[1]) / 2
1211
+
1212
+ if candidate_fwd is not None:
1213
+ addr_ctrs = [pelvis_center_ground(f) for f in setup_frames]
1214
+ addr_ctrs = [c for c in addr_ctrs if c is not None]
1215
+ imp_ctr = pelvis_center_ground(candidate_fwd)
1216
+ if addr_ctrs and imp_ctr is not None:
1217
+ addr_ctr = np.median(np.stack(addr_ctrs), axis=0)
1218
+ fwd_vec = imp_ctr - addr_ctr
1219
+ if float(np.dot(fwd_vec, target_hat_g)) < 0.0:
1220
+ target_hat_g = -target_hat_g
1221
+
1222
+ # (5) centers at address (median) and at top
1223
+ setup_centers = [pelvis_center_ground(f) for f in setup_frames]
1224
+ setup_centers = [c for c in setup_centers if c is not None]
1225
+ if not setup_centers: return None
1226
+ setup_ctr = np.median(np.stack(setup_centers), axis=0)
1227
+ top_ctr = pelvis_center_ground(top_frame)
1228
+ if top_ctr is None: return None
1229
+
1230
+ # (6) ***Calibrate inches*** with world toes if available
1231
+ inch_scale = 1.0
1232
+ if world_landmarks is not None and H_frame in world_landmarks:
1233
+ wf = world_landmarks[H_frame]
1234
+ # 3D toe XZ spacing (meters) → inches
1235
+ try:
1236
+ L_FOOT_INDEX, R_FOOT_INDEX = 29, 30
1237
+ Lt, Rt = wf[L_FOOT_INDEX], wf[R_FOOT_INDEX]
1238
+ if Lt and Rt:
1239
+ d_m = math.hypot(Rt[0]-Lt[0], Rt[2]-Lt[2]) # XZ
1240
+ d_in_true = d_m * 39.3701
1241
+ # 2D ground length from homography at same frame:
1242
+ lmH = sm.get(H_frame)
1243
+ toe_img = [[lmH[31][0], lmH[31][1]], [lmH[32][0], lmH[32][1]]]
1244
+ toe_g = warp2(toe_img)
1245
+ d_in_curr = float(np.linalg.norm(toe_g[1]-toe_g[0]))
1246
+ if d_in_curr > 1e-3 and d_in_true > 1e-3:
1247
+ inch_scale = d_in_true / d_in_curr
1248
+ except Exception:
1249
+ pass
1250
+
1251
+ delta = (top_ctr - setup_ctr) * inch_scale
1252
+ lat = float(np.dot(delta, target_hat_g)) # toward target (lateral)
1253
+ fwd = float(np.dot(delta, np.array([-target_hat_g[1], target_hat_g[0]]))) # forward/back
1254
+ print(f"DEBUG sway2: lat_in={lat:.2f}, fwd_in={fwd:.2f}, inch_scale={inch_scale:.3f}")
1255
+
1256
+ # Compute lead hip sway as alternative measurement
1257
+ lead_sway = _lead_hip_sway(sm, setup_frames, top_frame, warp2, target_hat_g)
1258
+ if lead_sway is not None:
1259
+ lead_sway_inches = lead_sway * inch_scale
1260
+ print(f"DEBUG sway2: lead_hip_sway_in={lead_sway_inches:.2f}")
1261
+ # Prefer lead hip measurement if available
1262
+ sway_inches = lead_sway_inches
1263
+ else:
1264
+ sway_inches = lat
1265
+
1266
+ print(f"DEBUG sway2: H_frame={H_frame}, inch_scale={inch_scale:.3f}, "
1267
+ f"setup_ctr_g={setup_ctr*inch_scale}, top_ctr_g={top_ctr*inch_scale}, "
1268
+ f"sway_in={sway_inches:.2f}")
1269
+
1270
+ # sanity note only
1271
+ if abs(sway_inches) < 0.2:
1272
+ print(f"DEBUG sway2: tiny sway {sway_inches:.2f}\" — check camera or address selection")
1273
+
1274
+ return sway_inches
1275
 
1276
 
1277
 
 
1287
 
1288
 
1289
 
1290
+ def _shaft_vec_proxy(lm, handed='right'):
1291
+ """Approximate shaft/handle direction using the two wrists."""
1292
+ L_WR, R_WR = 15, 16
1293
+ if not lm or not lm[L_WR] or not lm[R_WR]:
1294
  return None
1295
+ if lm[L_WR][2] < 0.5 or lm[R_WR][2] < 0.5:
 
 
1296
  return None
1297
+ # For right-handed golfer, shaft points from lead (L) to trail (R)
1298
+ v = (lm[R_WR][0] - lm[L_WR][0], lm[R_WR][1] - lm[L_WR][1]) if handed=='right' \
1299
+ else (lm[L_WR][0] - lm[R_WR][0], lm[L_WR][1] - lm[R_WR][1])
1300
+ return np.array(v, float)
1301
+
1302
+ def _hand_point(lm, handed='right'):
1303
+ # Mediapipe Pose: L index=19, L pinky=17, L thumb=21; R index=20, R pinky=18, R thumb=22
1304
+ if handed == 'right':
1305
+ IDX, THU, PNK = 19, 21, 17 # lead arm = left arm landmarks
1306
+ else:
1307
+ IDX, THU, PNK = 20, 22, 18 # lead arm = right arm landmarks
1308
 
1309
+ cand = []
1310
+ for i in (IDX, THU, PNK):
1311
+ if i < len(lm) and lm[i] and lm[i][2] >= 0.5:
1312
+ cand.append((lm[i][0], lm[i][1]))
1313
+ if not cand:
1314
+ return None
1315
+ # prefer index+thumb average if both exist
1316
+ has_idx = (len(lm) > IDX and lm[IDX] and lm[IDX][2] >= 0.5)
1317
+ has_thu = (len(lm) > THU and lm[THU] and lm[THU][2] >= 0.5)
1318
+ if has_idx and has_thu:
1319
+ return ((lm[IDX][0] + lm[THU][0]) * 0.5, (lm[IDX][1] + lm[THU][1]) * 0.5)
1320
+ # else median of whatever we have
1321
+ xs, ys = zip(*cand)
1322
+ return (float(np.median(xs)), float(np.median(ys)))
1323
+
1324
+ def _hinge2d_shaft(lm, handed='right'):
1325
+ """Hinge = 180° - angle(forearm, shaft_proxy)."""
1326
+ if not lm: return None
1327
+ EL, WR = (13, 15) if handed=='right' else (14, 16) # lead elbow/wrist
1328
+ if not lm[EL] or not lm[WR] or lm[EL][2] < 0.5 or lm[WR][2] < 0.5:
1329
  return None
 
 
 
 
 
 
 
1330
 
1331
+ # Forearm vector
1332
+ u = np.array([lm[WR][0]-lm[EL][0], lm[WR][1]-lm[EL][1]], float)
1333
+ nu = np.linalg.norm(u)
1334
+ if nu < 1e-6: return None
1335
+
1336
+ # Shaft proxy
1337
+ s = _shaft_vec_proxy(lm, handed)
1338
+ if s is None:
1339
+ # Fallback: keep your old hand-point method but guard against colinearity
1340
+ hp = _hand_point(lm, handed)
1341
+ if hp is None: return None
1342
+ s = np.array([hp[0]-lm[WR][0], hp[1]-lm[WR][1]], float)
1343
+ ns = np.linalg.norm(s)
1344
+ if ns < 1e-6: return None
1345
+
1346
+ # Angle between lines (ignore direction)
1347
+ c = float(np.clip(np.dot(u, s)/(nu*ns), -1, 1))
1348
+ theta = math.degrees(math.acos(c)) # 0..180, 90° when perpendicular
1349
+ hinge = 180.0 - theta # hinge large when shaft closer to perpendicular to forearm
1350
+
1351
+ # Sanity clamp
1352
+ return max(20.0, min(160.0, hinge))
1353
 
1354
+ def _hinge2d(lm, handed='right'):
1355
+ """Legacy function - now calls shaft-based version"""
1356
+ return _hinge2d_shaft(lm, handed)
1357
+
1358
+ def _pelvis_ctr_ground_series(sm, frames, warp2):
1359
+ """Get pelvis centers in ground plane for given frames"""
1360
+ ctrs = []
1361
+ for f in frames:
1362
+ lm = sm.get(f)
1363
+ if not lm or not lm[23] or not lm[24]:
1364
+ ctrs.append(None)
1365
+ continue
1366
+ g = warp2([[lm[23][0], lm[23][1]], [lm[24][0], lm[24][1]]])
1367
+ ctrs.append(((g[0]+g[1])/2).astype(float))
1368
+ return ctrs
1369
+
1370
+ def _refine_top_by_lat_vel(sm, backswing_frames, warp2, target_hat_g, init_top):
1371
+ """Refine top frame using lateral pelvis velocity zero-crossing"""
1372
+ # compute pelvis centers along backswing and project onto target axis
1373
+ ctrs = _pelvis_ctr_ground_series(sm, backswing_frames, warp2)
1374
+ ts = [i for i,c in enumerate(ctrs) if c is not None]
1375
+ if len(ts) < 5:
1376
+ return init_top # not enough samples
1377
+ proj = np.array([float(np.dot(ctrs[i], target_hat_g)) if ctrs[i] is not None else np.nan for i in range(len(ctrs))])
1378
+ # smooth (5-pt)
1379
+ k = 5
1380
+ proj_s = np.convolve(np.nan_to_num(proj), np.ones(k)/k, mode='same')
1381
+ # finite diff
1382
+ vel = np.gradient(proj_s)
1383
+ # take frame near init_top where vel≈0 and |vel| is locally minimal (change of direction)
1384
+ idx0 = backswing_frames.index(init_top) if init_top in backswing_frames else len(backswing_frames)-1
1385
+ win = range(max(0, idx0-4), min(len(vel), idx0+5))
1386
+ # find zero-cross or min |vel|
1387
+ cand = min(win, key=lambda i: abs(vel[i]))
1388
+ return backswing_frames[cand]
1389
+
1390
+ def _point_ground(frame_idx, sm, warp2, i):
1391
+ """Get ground-plane position of landmark i in frame frame_idx"""
1392
+ lm = sm.get(frame_idx)
1393
+ if not lm or not lm[i]: return None
1394
+ return warp2([[lm[i][0], lm[i][1]]])[0].astype(float)
1395
+
1396
+ def _lead_hip_sway(sm, addr_frames, top_frame, warp2, target_hat_g):
1397
+ """Compute lead hip sway as alternative to pelvis center"""
1398
+ L_HIP = 23
1399
+ addr_pts = [_point_ground(f, sm, warp2, L_HIP) for f in addr_frames]
1400
+ addr_pts = [p for p in addr_pts if p is not None]
1401
+ top_pt = _point_ground(top_frame, sm, warp2, L_HIP)
1402
+ if not addr_pts or top_pt is None: return None
1403
+ addr_med = np.median(np.stack(addr_pts), axis=0)
1404
+ delta = top_pt - addr_med
1405
+ return float(np.dot(delta, target_hat_g))
1406
+
1407
+ def _pick_p3_lead_arm_parallel(sm, backswing_frames, handed='right', tol_deg=15.0):
1408
+ """Pick the backswing frame where the lead shoulder→lead wrist vector is most horizontal."""
1409
+ L_SHO, WR = (11, 15) if handed=='right' else (12, 16)
1410
+ best = None
1411
+ best_abs = 1e9
1412
+ for f in backswing_frames:
1413
+ lm = sm.get(f)
1414
+ if not lm or not lm[L_SHO] or not lm[WR]:
1415
+ continue
1416
+ if lm[L_SHO][2] < 0.5 or lm[WR][2] < 0.5:
1417
+ continue
1418
+ dx = lm[WR][0] - lm[L_SHO][0]
1419
+ dy = lm[WR][1] - lm[L_SHO][1]
1420
+ if abs(dx) + abs(dy) < 1e-6:
1421
+ continue
1422
+ ang = abs(math.degrees(math.atan2(dy, dx))) # 0° is perfectly horizontal
1423
+ if ang < best_abs:
1424
+ best_abs, best = ang, f
1425
+ return best
1426
 
1427
  def _pick_top_frame_hinge(sm, backswing_frames, handed='right'):
1428
  """Pick frame with maximum hinge in backswing"""
 
1438
  top_frame, hinge_top = max(top_vals, key=lambda x: x[1])
1439
  return top_frame
1440
 
 
 
 
 
 
 
 
1441
  def calculate_wrist_hinge_at_top(pose_data: Dict[int, List], swing_phases: Dict[str, List],
1442
+ frames: Optional[List[np.ndarray]] = None, handedness: str = 'right') -> Union[Dict[str, float], None]:
1443
+ """Calculate wrist hinge using shaft-based method with both P3 and top measurements"""
1444
  sm = smooth_landmarks(pose_data)
1445
 
1446
  backswing_frames = swing_phases.get("backswing", [])
 
1455
  else:
1456
  return None # Truly no data
1457
 
1458
+ # Top frame and hinge
1459
+ top_frame = _pick_top_frame_hinge(sm, backswing_frames, handed=handedness) or backswing_frames[-1]
1460
+ hinge_top = _hinge2d_shaft(sm.get(top_frame), handedness)
1461
+
1462
+ # NEW: P3 frame and hinge
1463
+ p3_frame = _pick_p3_lead_arm_parallel(sm, backswing_frames, handedness)
1464
+ hinge_p3 = _hinge2d_shaft(sm.get(p3_frame), handedness) if p3_frame is not None else None
1465
+
1466
+ # Address baseline (use shaft-based hinge to avoid "palm vector too short")
1467
+ setup_frames = _pick_still_address_by_hand(sm, swing_phases, handedness, max_frames=6) or _address_frames(swing_phases)
1468
+ addr_vals = []
1469
+ for f in setup_frames[:6]:
1470
+ h = _hinge2d_shaft(sm.get(f), handedness)
1471
+ if h is not None: addr_vals.append(h)
1472
+ hinge_addr = float(np.median(addr_vals)) if addr_vals else None
1473
+
1474
+ result = {
1475
+ "radial_deviation_deg_top": hinge_top,
1476
+ "radial_deviation_deg_p3": hinge_p3,
1477
+ "addr_deg": hinge_addr,
1478
+ "delta_deg_p3": (hinge_p3 - hinge_addr) if (hinge_p3 is not None and hinge_addr is not None) else None,
1479
+ "delta_deg_top": (hinge_top - hinge_addr) if (hinge_top is not None and hinge_addr is not None) else None,
1480
+ # Flag outside typical pro band for P3
1481
+ "definition_suspect": (hinge_p3 is None) or not (50 <= hinge_p3 <= 130),
1482
+ # Legacy field for backward compatibility
1483
+ "radial_deviation_deg": hinge_p3 if hinge_p3 is not None else hinge_top
1484
+ }
 
 
 
 
1485
  return result
1486
 
1487
 
1488
  def compute_front_facing_metrics(pose_data: Dict[int, List], swing_phases: Dict[str, List],
1489
  world_landmarks: Optional[Dict[int, List]] = None,
1490
+ frames: Optional[List[np.ndarray]] = None,
1491
+ club: str = "iron",
1492
+ handedness: str = "right") -> Dict[str, Dict[str, Union[float, str, None]]]:
1493
+ """Compute the 4 required front-facing golf swing metrics with club-aware grading"""
1494
 
1495
  # Calculate individual metrics
1496
  shoulder_tilt_impact = calculate_shoulder_tilt_at_impact(
1497
+ pose_data, swing_phases, world_landmarks, frames=frames, handedness=handedness
1498
  )
1499
+ hip_turn_result = calculate_hip_turn_at_impact(pose_data, swing_phases, world_landmarks, frames)
1500
  hip_sway_top = calculate_hip_sway_at_top(pose_data, swing_phases, world_landmarks)
1501
+ wrist_hinge_result = calculate_wrist_hinge_at_top(pose_data, swing_phases, frames, handedness=handedness)
1502
+
1503
+ # Process hip turn results
1504
+ hip_turn_data = {}
1505
+ if hip_turn_result:
1506
+ hip_abs = hip_turn_result.get('abs_deg')
1507
+ hip_delta = hip_turn_result.get('delta_deg')
1508
+ hip_addr = hip_turn_result.get('addr_deg')
1509
+
1510
+ # Prefer delta if available, fallback to absolute
1511
+ display_value = hip_delta if hip_delta is not None else hip_abs
1512
+ value_type = "delta" if hip_delta is not None else "absolute"
1513
+
1514
+ # Get club-aware grading
1515
+ if display_value is not None:
1516
+ grading = grade_hip_turn(display_value, club)
1517
+ hip_turn_data = {
1518
+ 'value': display_value,
1519
+ 'abs_deg': hip_abs,
1520
+ 'delta_deg': hip_delta,
1521
+ 'addr_deg': hip_addr,
1522
+ 'value_type': value_type,
1523
+ 'grade_label': grading['label'],
1524
+ 'grade_color': grading['color'],
1525
+ 'grade_tip': grading['tip'],
1526
+ }
1527
+ else:
1528
+ hip_turn_data = {'value': None}
1529
+ else:
1530
+ hip_turn_data = {'value': None}
1531
 
1532
+ # Extract wrist hinge values (NEW: prioritize P3 measurements)
1533
+ wrist_p3 = wrist_hinge_result.get('radial_deviation_deg_p3') if wrist_hinge_result else None
1534
+ wrist_top = wrist_hinge_result.get('radial_deviation_deg_top') if wrist_hinge_result else None
1535
+ wrist_addr = wrist_hinge_result.get('addr_deg') if wrist_hinge_result else None
1536
+ delta_p3 = wrist_hinge_result.get('delta_deg_p3') if wrist_hinge_result else None
1537
+ delta_top = wrist_hinge_result.get('delta_deg_top') if wrist_hinge_result else None
1538
  definition_suspect = wrist_hinge_result.get('definition_suspect', False) if wrist_hinge_result else False
1539
 
1540
+ # Prefer P3 delta, then P3 absolute, then top values
1541
+ wrist_final = delta_p3 if delta_p3 is not None else (wrist_p3 if wrist_p3 is not None else wrist_top)
 
 
1542
 
1543
  front_facing_metrics = {
1544
  'shoulder_tilt_impact_deg': {'value': shoulder_tilt_impact},
1545
+ 'hip_turn_impact_deg': hip_turn_data,
1546
  'hip_sway_top_inches': {'value': hip_sway_top},
1547
+ 'wrist_hinge_top_deg': {
1548
+ 'value': wrist_final,
1549
+ 'p3_deg': wrist_p3,
1550
+ 'top_deg': wrist_top,
1551
+ 'addr_deg': wrist_addr,
1552
+ 'delta_p3_deg': delta_p3,
1553
+ 'delta_top_deg': delta_top,
1554
+ 'definition_suspect': definition_suspect
1555
+ }
1556
  }
1557
 
1558
  return front_facing_metrics
app/models/llm_analyzer.py CHANGED
@@ -11,9 +11,42 @@ import re
11
  from typing import Dict, Any, Optional
12
  from .metrics_calculator import (
13
  calculate_back_tilt_degree, calculate_knee_bend_degree,
14
- calculate_hip_depth_early_extension, calculate_shaft_angle_at_address,
15
- calculate_head_vertical_displacement, calculate_metric_confidence
 
 
16
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
 
18
 
19
  def safe_fmt_deg(v):
@@ -104,7 +137,7 @@ def call_ollama_service(prompt, config):
104
  return None
105
 
106
 
107
- def compute_core_metrics(pose_data, swing_phases, frame_timestamps_ms=None, total_ms=None, player_handedness='right', is_front_facing=False, frames=None):
108
  """Compute core golf swing metrics
109
 
110
  Args:
@@ -114,10 +147,88 @@ def compute_core_metrics(pose_data, swing_phases, frame_timestamps_ms=None, tota
114
  total_ms (float, optional): Total video duration in milliseconds
115
  player_handedness (str): 'right' or 'left' handed player
116
  is_front_facing (bool): True for front-facing camera view
 
117
 
118
  Returns:
119
  dict: Core metrics with confidence scores and status
120
  """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
121
  # Get phase frame indices
122
  setup_frames = swing_phases.get("setup", [])
123
  backswing_frames = swing_phases.get("backswing", [])
@@ -129,192 +240,119 @@ def compute_core_metrics(pose_data, swing_phases, frame_timestamps_ms=None, tota
129
  top_idx = backswing_frames[-1] if backswing_frames else address_idx
130
  impact_idx = impact_frames[0] if impact_frames else top_idx
131
 
132
- # Initialize core metrics
133
- core_metrics = {
134
- "back_tilt_deg": {'value': None, 'status': 'n/a'},
135
- "knee_bend_deg": {'value': None, 'status': 'n/a'},
136
- "hip_depth_early_extension": {'value': None, 'status': 'n/a'},
137
- "shaft_angle_address": {'value': None, 'status': 'n/a'},
138
- "head_vertical_displacement": {'value': None, 'status': 'n/a'}
139
- }
 
 
 
 
 
 
 
140
 
141
  # Calculate Back Tilt @ Setup
142
- back_tilt = calculate_back_tilt_degree(pose_data, swing_phases, address_idx, frames)
143
- if back_tilt is not None:
144
- confidence = calculate_metric_confidence(
145
- pose_data, 'back_tilt', [11, 12, 23, 24], setup_frames[:3]
146
- )
147
-
148
- if 25 <= back_tilt <= 35:
149
- badge = '🟢'
150
- status = 'On-plane posture'
151
- elif (20 <= back_tilt < 25) or (35 < back_tilt <= 40):
152
- badge = '🟠'
153
- status = 'Acceptable posture'
154
- else:
155
- badge = '🔴'
156
- status = 'Needs adjustment'
157
-
158
- core_metrics["back_tilt_deg"] = {
159
- 'value': round(back_tilt, 1),
160
- 'confidence': round(confidence, 2),
161
- 'status': f'{status} — {badge}',
162
- 'badge': badge
163
- }
164
 
165
- # Calculate Knee Flexion
166
  knee_bend_data = calculate_knee_bend_degree(pose_data, address_idx)
167
  if knee_bend_data is not None:
168
- # Use the new structure with separate lead and trail measurements
169
- lead_flexion = knee_bend_data.get('lead_knee_flexion')
170
- trail_flexion = knee_bend_data.get('trail_knee_flexion')
171
  primary_value = knee_bend_data.get('primary_value') # Average or single value
172
-
173
  if primary_value is not None:
174
- confidence = calculate_metric_confidence(
175
- pose_data, 'knee_flexion', [23, 24, 25, 26, 27, 28], setup_frames[:3]
176
- )
177
-
178
- if 15 <= primary_value <= 35:
179
- badge = '🟢'
180
- status = 'Athletic'
181
- elif (10 <= primary_value < 15) or (35 < primary_value <= 45):
182
- badge = '🟠'
183
- status = 'Acceptable'
184
- else:
185
- badge = '🔴'
186
- status = 'Needs adjustment'
187
-
188
- # Include detailed breakdown if available
189
- knee_metric = {
190
- 'value': round(primary_value, 1),
191
- 'confidence': round(confidence, 2),
192
- 'status': f'{status} — {badge}',
193
- 'badge': badge
194
- }
195
 
196
  # Add separate lead/trail values if available
 
 
197
  if lead_flexion is not None:
198
- knee_metric['lead_knee_flexion'] = round(lead_flexion, 1)
199
  if trail_flexion is not None:
200
- knee_metric['trail_knee_flexion'] = round(trail_flexion, 1)
201
- if 'overall_confidence' in knee_bend_data:
202
- knee_metric['overall_confidence'] = round(knee_bend_data['overall_confidence'], 2)
203
 
204
- core_metrics["knee_bend_deg"] = knee_metric
205
 
206
  # Calculate Hip Depth / Early Extension
207
- hip_depth_data = calculate_hip_depth_early_extension(pose_data, swing_phases)
208
- if hip_depth_data:
209
- # Check if it's an error message
210
- if 'error' in hip_depth_data:
211
- core_metrics["hip_depth_early_extension"] = {
212
- 'value': None,
213
- 'detailed_data': hip_depth_data,
214
- 'confidence': 0.0,
215
- 'status': f'Error: {hip_depth_data["error"]}',
216
- 'badge': '🔴',
217
- 'error': True
218
- }
219
- else:
220
- depth_loss_pct = hip_depth_data.get('depth_loss_pct', 0)
221
- # Use the confidence from the enhanced calculation, with fallback to traditional method
222
- calculation_confidence = hip_depth_data.get('confidence', 1.0)
223
- traditional_confidence = calculate_metric_confidence(
224
- pose_data, 'hip_depth', [23, 24], [address_idx, impact_idx]
225
- )
226
- # Combine both confidence measures, weighted toward the calculation confidence
227
- combined_confidence = (calculation_confidence * 0.7) + (traditional_confidence * 0.3)
228
-
229
- # Adjust badge based on confidence level
230
- if combined_confidence < 0.5:
231
- confidence_badge = '⚠️'
232
- confidence_note = ' (low confidence)'
233
- elif combined_confidence < 0.8:
234
- confidence_badge = '⚡'
235
- confidence_note = ' (moderate confidence)'
236
- else:
237
- confidence_badge = ''
238
- confidence_note = ''
239
-
240
- if depth_loss_pct < 5:
241
- badge = '🟢'
242
- status = 'Good depth maintenance'
243
- elif depth_loss_pct < 15:
244
- badge = '🟠'
245
- status = 'Some early extension'
246
  else:
247
- badge = '🔴'
248
- status = 'Early extension'
249
-
250
- core_metrics["hip_depth_early_extension"] = {
251
- 'value': depth_loss_pct,
252
- 'detailed_data': hip_depth_data,
253
- 'confidence': round(combined_confidence, 2),
254
- 'status': f'{status}{confidence_note} {badge}{confidence_badge}',
255
- 'badge': badge,
256
- 'calculation_confidence': round(calculation_confidence, 2),
257
- 'traditional_confidence': round(traditional_confidence, 2)
258
- }
259
-
260
- # Calculate Shaft Angle at Address
261
- shaft_address_angle = calculate_shaft_angle_at_address(pose_data, swing_phases, frames)
262
- if shaft_address_angle is not None:
263
- confidence = calculate_metric_confidence(
264
- pose_data, 'shaft_angle_address', [15, 16, 12, 14], setup_frames[:3]
265
- )
266
-
267
- if 45 <= shaft_address_angle <= 65:
268
- badge = '🟢'
269
- status = 'Good setup'
270
- elif (35 <= shaft_address_angle < 45) or (65 < shaft_address_angle <= 75):
271
- badge = '🟠'
272
- status = 'Acceptable'
273
- else:
274
- badge = '🔴'
275
- status = 'Needs adjustment'
276
-
277
- core_metrics["shaft_angle_address"] = {
278
- 'value': round(shaft_address_angle, 1),
279
- 'confidence': round(confidence, 2),
280
- 'status': f'{status} — {badge}',
281
- 'badge': badge
282
  }
283
 
284
- # Calculate Head Vertical Displacement
285
- head_displacement_data = calculate_head_vertical_displacement(pose_data, swing_phases)
286
- if head_displacement_data:
287
- displacement_pct = head_displacement_data.get('displacement_pct', 0)
288
- confidence = calculate_metric_confidence(
289
- pose_data, 'head_displacement', [0, 1, 2, 3, 4], [address_idx, impact_idx]
290
- )
291
-
292
- if displacement_pct < 5:
293
- badge = '🟢'
294
- status = 'Very stable head'
295
- elif displacement_pct < 15:
296
- badge = '🟠'
297
- status = 'Some movement'
 
 
 
 
 
298
  else:
299
- badge = '🔴'
300
- status = 'Excessive movement'
301
-
302
- # Include the documented denominator and method information
303
- head_metric = {
304
- 'value': round(displacement_pct, 1),
305
- 'confidence': round(confidence, 2),
306
- 'status': f'{status} {badge}',
307
- 'badge': badge,
308
- 'detailed_data': head_displacement_data
309
- }
310
-
311
- # Add documented denominator information if available
312
- if 'height_reference_method' in head_displacement_data:
313
- head_metric['height_reference_method'] = head_displacement_data['height_reference_method']
314
- if 'denominator_documented' in head_displacement_data:
315
- head_metric['denominator_documented'] = head_displacement_data['denominator_documented']
316
-
317
- core_metrics["head_vertical_displacement"] = head_metric
318
 
319
  return core_metrics
320
 
@@ -487,4 +525,113 @@ def display_formatted_analysis(analysis_data):
487
  if not analysis_data:
488
  return "No analysis data available"
489
 
490
- return analysis_data.get('formatted_analysis', analysis_data.get('raw_analysis', 'No analysis available'))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  from typing import Dict, Any, Optional
12
  from .metrics_calculator import (
13
  calculate_back_tilt_degree, calculate_knee_bend_degree,
14
+ calculate_hip_depth_early_extension,
15
+ calculate_shoulder_tilt_swing_plane_at_top,
16
+ compute_dtl_five_metrics,
17
+ compute_dtl_three
18
  )
19
+ from .front_facing_metrics import compute_front_facing_metrics
20
+
21
+ # Import grading functions to avoid duplication
22
+ try:
23
+ from ..streamlit_app import (
24
+ get_shoulder_tilt_swing_plane_grading,
25
+ get_back_tilt_grading,
26
+ get_knee_flexion_grading,
27
+ get_hip_depth_grading,
28
+ # get_hip_turn_impact_grading, # Removed hip turn metric
29
+ get_shoulder_tilt_impact_grading,
30
+ get_hip_sway_grading,
31
+ get_wrist_hinge_grading
32
+ )
33
+ except ImportError:
34
+ # Fallback if imports fail - define minimal grading functions
35
+ def get_shoulder_tilt_swing_plane_grading(value, confidence):
36
+ return {'value': value, 'status': 'n/a', 'badge': '⚪'}
37
+ def get_back_tilt_grading(value, confidence):
38
+ return {'value': value, 'status': 'n/a', 'badge': '⚪'}
39
+ def get_knee_flexion_grading(value, confidence):
40
+ return {'value': value, 'status': 'n/a', 'badge': '⚪'}
41
+ def get_hip_depth_grading(value, confidence):
42
+ return {'value': value, 'status': 'n/a', 'badge': '⚪'}
43
+ # Hip turn grading function removed per user request
44
+ def get_shoulder_tilt_impact_grading(value, confidence):
45
+ return {'value': value, 'status': 'n/a', 'badge': '⚪'}
46
+ def get_hip_sway_grading(value, confidence, position):
47
+ return {'value': value, 'status': 'n/a', 'badge': '⚪'}
48
+ def get_wrist_hinge_grading(value, confidence, position):
49
+ return {'value': value, 'status': 'n/a', 'badge': '⚪'}
50
 
51
 
52
  def safe_fmt_deg(v):
 
137
  return None
138
 
139
 
140
+ def compute_core_metrics(pose_data, swing_phases, frame_timestamps_ms=None, total_ms=None, player_handedness='right', is_front_facing=False, frames=None, world_landmarks=None, club="iron"):
141
  """Compute core golf swing metrics
142
 
143
  Args:
 
147
  total_ms (float, optional): Total video duration in milliseconds
148
  player_handedness (str): 'right' or 'left' handed player
149
  is_front_facing (bool): True for front-facing camera view
150
+ club (str): Club type for grading ("driver", "iron", "wedge")
151
 
152
  Returns:
153
  dict: Core metrics with confidence scores and status
154
  """
155
+ if is_front_facing:
156
+ # Use front-facing metrics
157
+ return compute_front_facing_core_metrics(pose_data, swing_phases, frames, world_landmarks, club)
158
+ else:
159
+ # Use DTL metrics (existing implementation)
160
+ return compute_dtl_core_metrics(pose_data, swing_phases, frame_timestamps_ms, total_ms, player_handedness, frames)
161
+
162
+
163
+ def compute_dtl_core_metrics(pose_data, swing_phases, frame_timestamps_ms=None, total_ms=None, player_handedness='right', frames=None):
164
+ """Compute DTL (Down-the-Line) golf swing metrics using new unified function"""
165
+
166
+ # Extract frame dimensions from frames if available
167
+ frame_w, frame_h = 1920, 1080 # Default values
168
+ if frames and len(frames) > 0:
169
+ if hasattr(frames[0], 'shape'):
170
+ frame_h, frame_w = frames[0].shape[:2]
171
+
172
+ # Use the new unified DTL metrics function
173
+ try:
174
+ dtl_metrics = compute_dtl_three(pose_data, swing_phases, frame_w, frame_h, frames)
175
+ if dtl_metrics is None:
176
+ # Fallback to individual calculations if unified function fails
177
+ return compute_dtl_core_metrics_fallback(pose_data, swing_phases, frame_timestamps_ms, total_ms, player_handedness, frames)
178
+
179
+ # Convert to the expected format with grading
180
+ core_metrics = {}
181
+
182
+ # Map the new metric names to the expected format (hip depth and hip turn removed)
183
+ metric_mapping = {
184
+ 'shoulder_plane_top_deg': ('shoulder_tilt_swing_plane_top_deg', get_shoulder_tilt_swing_plane_grading),
185
+ 'back_tilt_setup_deg': ('back_tilt_deg', get_back_tilt_grading),
186
+ 'knee_flexion_deg': ('knee_flexion_deg', get_knee_flexion_grading),
187
+ # Hip turn mapping removed per user request
188
+ }
189
+
190
+ for new_key, (old_key, grading_func) in metric_mapping.items():
191
+ value = dtl_metrics.get(new_key)
192
+ if value is not None:
193
+ try:
194
+ grading = grading_func(value, 0.8)
195
+ if grading:
196
+ # Hip turn debug info removed per user request
197
+ core_metrics[old_key] = grading
198
+ except Exception:
199
+ # Fallback format if grading fails
200
+ core_metrics[old_key] = {
201
+ 'value': value,
202
+ 'status': 'Calculated',
203
+ 'badge': '✅'
204
+ }
205
+
206
+ # Add validation info if available
207
+ validation = dtl_metrics.get('_validation')
208
+ if validation:
209
+ core_metrics['_validation'] = validation
210
+
211
+ # Add debug info if available
212
+ debug_info = dtl_metrics.get('_debug')
213
+ if debug_info:
214
+ core_metrics['_debug'] = debug_info
215
+
216
+ return core_metrics
217
+
218
+ except Exception as e:
219
+ print(f"Error in compute_dtl_three: {e}")
220
+ # Fallback to individual calculations
221
+ return compute_dtl_core_metrics_fallback(pose_data, swing_phases, frame_timestamps_ms, total_ms, player_handedness, frames)
222
+
223
+
224
+ def compute_dtl_core_metrics_fallback(pose_data, swing_phases, frame_timestamps_ms=None, total_ms=None, player_handedness='right', frames=None):
225
+ """Fallback function using individual metric calculations"""
226
+ # Extract frame dimensions from frames if available
227
+ frame_w, frame_h = 1920, 1080 # Default values
228
+ if frames and len(frames) > 0:
229
+ if hasattr(frames[0], 'shape'):
230
+ frame_h, frame_w = frames[0].shape[:2]
231
+
232
  # Get phase frame indices
233
  setup_frames = swing_phases.get("setup", [])
234
  backswing_frames = swing_phases.get("backswing", [])
 
240
  top_idx = backswing_frames[-1] if backswing_frames else address_idx
241
  impact_idx = impact_frames[0] if impact_frames else top_idx
242
 
243
+ # Initialize core metrics with new DTL metrics - don't set default status, let grading functions handle it
244
+ core_metrics = {}
245
+
246
+ # Calculate Shoulder Tilt / Swing Plane Angle at Top - professional = 36°, 30 handicap = 29°
247
+ try:
248
+ # Ensure we have valid indices and data
249
+ if backswing_frames and top_idx in pose_data and pose_data[top_idx] is not None:
250
+ shoulder_tilt_swing_plane = calculate_shoulder_tilt_swing_plane_at_top(pose_data, swing_phases, top_idx, frames)
251
+ if shoulder_tilt_swing_plane is not None and isinstance(shoulder_tilt_swing_plane, (int, float)):
252
+ grading = get_shoulder_tilt_swing_plane_grading(shoulder_tilt_swing_plane, 0.8)
253
+ if grading and isinstance(grading, dict):
254
+ core_metrics["shoulder_tilt_swing_plane_top_deg"] = grading
255
+ except Exception as e:
256
+ # Silently continue if calculation fails
257
+ pass
258
 
259
  # Calculate Back Tilt @ Setup
260
+ try:
261
+ # Ensure we have valid indices and data
262
+ if setup_frames and address_idx in pose_data and pose_data[address_idx] is not None:
263
+ back_tilt = calculate_back_tilt_degree(pose_data, swing_phases, address_idx, frames)
264
+ if back_tilt is not None and isinstance(back_tilt, (int, float)):
265
+ grading = get_back_tilt_grading(back_tilt, 0.8)
266
+ if grading and isinstance(grading, dict):
267
+ core_metrics["back_tilt_deg"] = grading
268
+ except Exception as e:
269
+ # Silently continue if calculation fails
270
+ pass
 
 
 
 
 
 
 
 
 
 
 
271
 
272
+ # Calculate Knee Flexion @ Setup
273
  knee_bend_data = calculate_knee_bend_degree(pose_data, address_idx)
274
  if knee_bend_data is not None:
 
 
 
275
  primary_value = knee_bend_data.get('primary_value') # Average or single value
 
276
  if primary_value is not None:
277
+ grading = get_knee_flexion_grading(primary_value, 0.8)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
278
 
279
  # Add separate lead/trail values if available
280
+ lead_flexion = knee_bend_data.get('lead_knee_flexion')
281
+ trail_flexion = knee_bend_data.get('trail_knee_flexion')
282
  if lead_flexion is not None:
283
+ grading['lead_knee_flexion'] = round(lead_flexion, 1)
284
  if trail_flexion is not None:
285
+ grading['trail_knee_flexion'] = round(trail_flexion, 1)
 
 
286
 
287
+ core_metrics["knee_flexion_deg"] = grading
288
 
289
  # Calculate Hip Depth / Early Extension
290
+ try:
291
+ hip_depth_data = calculate_hip_depth_early_extension(pose_data, swing_phases)
292
+ if hip_depth_data:
293
+ # Check if it's an error message
294
+ if 'error' in hip_depth_data:
295
+ # Store error but still try to display it
296
+ core_metrics["hip_depth_early_extension"] = {
297
+ 'value': None,
298
+ 'detailed_data': hip_depth_data,
299
+ 'status': f'Error: {hip_depth_data["error"]}',
300
+ 'badge': '🔴',
301
+ 'error': True
302
+ }
303
+ elif isinstance(hip_depth_data, dict) and 'depth_loss_pct' in hip_depth_data:
304
+ # Valid data structure - proceed with grading
305
+ grading = get_hip_depth_grading(hip_depth_data, 0.8)
306
+ if grading:
307
+ core_metrics["hip_depth_early_extension"] = grading
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
308
  else:
309
+ # Unexpected data structure - create a fallback entry
310
+ core_metrics["hip_depth_early_extension"] = {
311
+ 'value': None,
312
+ 'status': 'Data format error',
313
+ 'badge': '🔴'
314
+ }
315
+ except Exception as e:
316
+ # Create a fallback entry for display
317
+ core_metrics["hip_depth_early_extension"] = {
318
+ 'value': None,
319
+ 'status': 'Calculation failed',
320
+ 'badge': '🔴'
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
321
  }
322
 
323
+ # Hip turn calculation removed per user request
324
+
325
+ return core_metrics
326
+
327
+
328
+ def compute_front_facing_core_metrics(pose_data, swing_phases, frames=None, world_landmarks=None, club="iron"):
329
+ """Compute front-facing golf swing metrics"""
330
+ # Get front-facing metrics from the new module
331
+ front_metrics = compute_front_facing_metrics(pose_data, swing_phases, world_landmarks=world_landmarks, frames=frames, club=club, handedness="right")
332
+
333
+ # Convert to the expected format with status and badges
334
+ core_metrics = {}
335
+
336
+
337
+ # Process each front-facing metric with new calibration values
338
+ for metric_name, metric_data in front_metrics.items():
339
+ if 'hip_turn_impact' in metric_name and isinstance(metric_data, dict) and 'grade_label' in metric_data:
340
+ # New hip turn data structure with club-aware grading - pass through directly
341
+ core_metrics[metric_name] = metric_data
342
  else:
343
+ # Handle other metrics with traditional processing
344
+ value = metric_data.get('value') if isinstance(metric_data, dict) else metric_data
345
+
346
+ if 'shoulder_tilt_impact' in metric_name:
347
+ core_metrics[metric_name] = get_shoulder_tilt_impact_grading(value, 0.9)
348
+ # Hip turn metric processing removed per user request
349
+ elif 'hip_sway_top' in metric_name:
350
+ core_metrics[metric_name] = get_hip_sway_grading(value, 0.8, "top")
351
+ elif 'wrist_hinge_top' in metric_name:
352
+ core_metrics[metric_name] = get_wrist_hinge_grading(value, 0.8, "top")
353
+ else:
354
+ # Default case
355
+ core_metrics[metric_name] = {'value': value, 'status': 'n/a'}
 
 
 
 
 
 
356
 
357
  return core_metrics
358
 
 
525
  if not analysis_data:
526
  return "No analysis data available"
527
 
528
+ return analysis_data.get('formatted_analysis', analysis_data.get('raw_analysis', 'No analysis available'))
529
+
530
+
531
+ def test_dtl_five_metrics_fixes(pose_data, swing_phases, frames=None):
532
+ """Test function to validate the DTL metric fixes
533
+
534
+ This function tests the fixes for:
535
+ 1. Shoulder tilt no longer returning ~90° due to yaw correction
536
+ 2. Hip turn no longer returning ~90° due to width-ratio method
537
+ 3. Hip depth properly comparing posterior hip positions
538
+ 4. All 5 metrics being computed and returned
539
+
540
+ Args:
541
+ pose_data (dict): Dictionary mapping frame indices to pose keypoints
542
+ swing_phases (dict): Dictionary mapping phase names to lists of frame indices
543
+ frames (list, optional): Video frames for analysis
544
+
545
+ Returns:
546
+ dict: Test results showing before/after values and validation
547
+ """
548
+ print("\n=== Testing DTL Five Metrics Fixes ===")
549
+
550
+ # Test the new aggregator
551
+ metrics = compute_dtl_five_metrics(pose_data, swing_phases, frames)
552
+
553
+ if metrics is None:
554
+ print("❌ Failed: compute_dtl_five_metrics returned None")
555
+ return None
556
+
557
+ print(f"✅ Success: Got {len(metrics)} metrics")
558
+
559
+ # Validate each metric
560
+ expected_metrics = [
561
+ "shoulder_plane_top_deg",
562
+ "back_tilt_setup_deg",
563
+ "knee_flexion_deg",
564
+ "hip_depth_pct",
565
+ # "hip_turn_impact_deg" # Removed hip turn metric
566
+ ]
567
+
568
+ results = {
569
+ "total_metrics": len(metrics),
570
+ "expected_metrics": len(expected_metrics),
571
+ "metric_values": {},
572
+ "validation_results": {}
573
+ }
574
+
575
+ for metric_name in expected_metrics:
576
+ value = metrics.get(metric_name)
577
+ results["metric_values"][metric_name] = value
578
+
579
+ if value is None:
580
+ print(f"⚠️ {metric_name}: None (calculation failed or outside sanity bounds)")
581
+ results["validation_results"][metric_name] = "failed_or_clamped"
582
+ elif metric_name.endswith("_deg") and value >= 80:
583
+ print(f"❌ {metric_name}: {value}° (unrealistic high value - check calculation)")
584
+ results["validation_results"][metric_name] = "unrealistic_high"
585
+ else:
586
+ print(f"✅ {metric_name}: {value}{'%' if 'pct' in metric_name else '°'} (reasonable value)")
587
+ results["validation_results"][metric_name] = "success"
588
+
589
+ # Summary
590
+ successful_metrics = sum(1 for v in results["validation_results"].values() if v == "success")
591
+ print(f"\n📊 Summary: {successful_metrics}/{len(expected_metrics)} metrics returning reasonable values")
592
+
593
+ if successful_metrics >= 4:
594
+ print("🎉 Overall: GOOD - Most metrics working properly")
595
+ elif successful_metrics >= 2:
596
+ print("⚠️ Overall: FAIR - Some metrics still need work")
597
+ else:
598
+ print("❌ Overall: POOR - Major issues remain")
599
+
600
+ results["overall_status"] = "good" if successful_metrics >= 4 else "fair" if successful_metrics >= 2 else "poor"
601
+
602
+ return results
603
+
604
+
605
+ def get_hip_depth_grading_from_value(value, confidence):
606
+ """Helper function to create hip depth grading from just the percentage value"""
607
+ if value is None:
608
+ return None
609
+
610
+ # Create a mock hip_depth_data structure for the existing grading function
611
+ mock_data = {
612
+ 'depth_loss_pct': value,
613
+ 'confidence': confidence
614
+ }
615
+
616
+ # Try to import the grading function from streamlit_app
617
+ try:
618
+ from ..streamlit_app import get_hip_depth_grading
619
+ return get_hip_depth_grading(mock_data, confidence)
620
+ except ImportError:
621
+ # Fallback grading logic
622
+ if value <= 5:
623
+ status = "Excellent - minimal early extension"
624
+ badge = "🟢"
625
+ elif value <= 15:
626
+ status = "Good - slight early extension"
627
+ badge = "🟡"
628
+ else:
629
+ status = "Needs work - significant early extension"
630
+ badge = "🔴"
631
+
632
+ return {
633
+ 'value': value,
634
+ 'status': status,
635
+ 'badge': badge,
636
+ 'detailed_data': mock_data
637
+ }
app/models/metrics_calculator.py CHANGED
@@ -9,136 +9,77 @@ import math
9
  import numpy as np
10
  import cv2
11
 
 
 
 
 
12
 
13
- def wrap180_deg(a):
14
- """Returns angle in (-180, 180] range - bulletproof implementation"""
15
- a = (a + 180.0) % 360.0 - 180.0
16
- return a
17
 
 
 
 
 
18
 
19
- def calculate_metric_confidence(pose_data, metric_name, keypoint_indices, frame_indices=None):
20
- """Calculate confidence for a specific metric based on keypoint visibility and geometry checks
21
-
22
- Fixed implementation:
23
- - Computes per metric from the lowest keypoint confidence used + line length / geometry checks
24
- - Avoids blanket confidence values
25
- - Metric-specific confidence calculations
26
 
27
  Args:
28
- pose_data (dict): Dictionary mapping frame indices to pose keypoints
29
- metric_name (str): Name of the metric for specific adjustments
30
- keypoint_indices (list): List of keypoint indices used in the calculation
31
- frame_indices (list, optional): Specific frames to check (defaults to all frames)
32
 
33
  Returns:
34
- float: Confidence score between 0.0 and 1.0
35
  """
36
- if frame_indices is None:
37
- frame_indices = list(pose_data.keys())
38
-
39
- if not frame_indices:
40
- return 0.0
41
-
42
- # Calculate base confidence from keypoint visibility
43
- keypoint_confidences = []
44
- geometry_checks = []
45
-
46
- for frame_idx in frame_indices:
47
- if frame_idx not in pose_data or pose_data[frame_idx] is None:
48
- continue
49
-
50
- kp = pose_data[frame_idx]
51
-
52
- # Get minimum keypoint confidence (weakest link)
53
- frame_keypoint_confidences = []
54
- for kp_idx in keypoint_indices:
55
- if kp_idx >= len(kp):
56
- frame_keypoint_confidences.append(0.0) # Missing keypoint
57
- else:
58
- visibility = kp[kp_idx][2]
59
- frame_keypoint_confidences.append(visibility)
60
-
61
- if frame_keypoint_confidences:
62
- min_confidence = min(frame_keypoint_confidences)
63
- keypoint_confidences.append(min_confidence)
64
-
65
- # Geometry checks based on metric type
66
- geometry_confidence = 1.0
67
-
68
- if metric_name == 'shaft_angle_address':
69
- # Check if grip and estimated clubhead positions are reasonable
70
- if len(kp) > 16 and kp[16][2] > 0.3: # Right wrist
71
- grip_pos = np.array(kp[16][:2])
72
- if len(kp) > 14 and kp[14][2] > 0.3: # Elbow for forearm direction
73
- elbow_pos = np.array(kp[14][:2])
74
- forearm_length = np.linalg.norm(grip_pos - elbow_pos)
75
- if forearm_length < 10 or forearm_length > 100: # Unreasonable
76
- geometry_confidence *= 0.5
77
- else:
78
- geometry_confidence *= 0.7 # No elbow reference
79
- else:
80
- geometry_confidence *= 0.0 # No grip reference
81
-
82
- elif metric_name == 'back_tilt':
83
- # Check if shoulder and hip positions are reasonable
84
- if len(kp) > 24:
85
- shoulder_mid = np.array([(kp[11][0] + kp[12][0]) / 2, (kp[11][1] + kp[12][1]) / 2])
86
- hip_mid = np.array([(kp[23][0] + kp[24][0]) / 2, (kp[23][1] + kp[24][1]) / 2])
87
- spine_length = np.linalg.norm(shoulder_mid - hip_mid)
88
- if spine_length < 15 or spine_length > 200: # Unreasonable spine length
89
- geometry_confidence *= 0.5
90
-
91
- elif metric_name == 'knee_flexion':
92
- # Check if leg segments are reasonable lengths
93
- for leg_points in [(23, 25, 27), (24, 26, 28)]: # Left and right legs
94
- if all(i < len(kp) and kp[i][2] > 0.3 for i in leg_points):
95
- hip_pos = np.array(kp[leg_points[0]][:2])
96
- knee_pos = np.array(kp[leg_points[1]][:2])
97
- ankle_pos = np.array(kp[leg_points[2]][:2])
98
-
99
- thigh_length = np.linalg.norm(hip_pos - knee_pos)
100
- shin_length = np.linalg.norm(knee_pos - ankle_pos)
101
-
102
- if thigh_length < 10 or thigh_length > 80 or shin_length < 10 or shin_length > 80:
103
- geometry_confidence *= 0.6
104
-
105
- elif metric_name == 'head_displacement':
106
- # Check if head position is reasonable
107
- head_pos = calculate_head_center(kp)
108
- if head_pos is not None:
109
- # Check if head is within reasonable bounds
110
- if head_pos[1] < 0 or head_pos[1] > 1000: # Unreasonable Y position
111
- geometry_confidence *= 0.5
112
- else:
113
- geometry_confidence *= 0.3
114
-
115
- geometry_checks.append(geometry_confidence)
116
-
117
- if not keypoint_confidences:
118
- return 0.0
119
-
120
- # Calculate final confidence
121
- avg_keypoint_confidence = np.mean(keypoint_confidences)
122
- avg_geometry_confidence = np.mean(geometry_checks) if geometry_checks else 1.0
123
-
124
- # Combine keypoint and geometry confidence
125
- final_confidence = avg_keypoint_confidence * avg_geometry_confidence
126
-
127
- # Apply metric-specific adjustments
128
- if metric_name == 'shaft_angle_address':
129
- final_confidence *= 0.8 # Inherently less reliable due to club estimation
130
- elif metric_name == 'head_displacement':
131
- final_confidence *= 0.85 # Head tracking can be noisy
132
- elif metric_name == 'hip_depth':
133
- final_confidence *= 0.75 # Hip depth is challenging to measure accurately
134
- elif metric_name in ['back_tilt', 'knee_flexion']:
135
- final_confidence *= 0.95 # These are more reliable measurements
136
-
137
- # Frame span penalty - fewer frames = lower confidence
138
- frame_span_penalty = min(1.0, len(frame_indices) / 5.0) # Full confidence with 5+ frames
139
- final_confidence *= frame_span_penalty
140
-
141
- return min(1.0, max(0.0, final_confidence))
142
 
143
 
144
  def detect_ground_line_angle(pose_data, swing_phases, frames=None):
@@ -185,36 +126,37 @@ def detect_ground_line_angle(pose_data, swing_phases, frames=None):
185
 
186
  kp = pose_data[frame_idx]
187
 
188
- # Try ankle line (most reliable for ground reference)
189
- if (len(kp) > 28 and kp[27][2] > 0.4 and kp[28][2] > 0.4):
190
  left_ankle = np.array(kp[27][:2])
191
  right_ankle = np.array(kp[28][:2])
192
  ankle_vec = right_ankle - left_ankle
193
- if np.linalg.norm(ankle_vec) > 20: # Reasonable distance
194
  ankle_angle = math.atan2(ankle_vec[1], ankle_vec[0])
195
  ankle_angle_deg = math.degrees(ankle_angle)
196
  ankle_angle_wrapped = wrap180_deg(ankle_angle_deg)
197
 
198
- # Validate ankle line is horizontal-ish
199
- if abs(ankle_angle_wrapped) <= 15.0:
200
  ground_angles.append(ankle_angle)
201
 
202
- # Try knee line as backup
203
- if (len(kp) > 26 and kp[25][2] > 0.4 and kp[26][2] > 0.4):
204
  left_knee = np.array(kp[25][:2])
205
  right_knee = np.array(kp[26][:2])
206
  knee_vec = right_knee - left_knee
207
- if np.linalg.norm(knee_vec) > 15:
208
  knee_angle = math.atan2(knee_vec[1], knee_vec[0])
209
  knee_angle_deg = math.degrees(knee_angle)
210
  knee_angle_wrapped = wrap180_deg(knee_angle_deg)
211
 
212
- # Validate knee line is horizontal-ish
213
- if abs(knee_angle_wrapped) <= 15.0:
214
  ground_angles.append(knee_angle)
215
 
 
216
  if not ground_angles:
217
- return None # No ground line detected
218
 
219
  # Use median for robustness
220
  return np.median(ground_angles)
@@ -313,23 +255,208 @@ def calculate_head_center(keypoints):
313
  return None
314
 
315
 
316
- def calculate_back_tilt_degree(pose_data, swing_phases, address_idx=None, frames=None):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
317
  """Calculate back tilt: forward bend vs ground using bulletproof geometry
318
 
319
- Fixed implementation:
320
- - Uses exact math with degrees everywhere
321
- - dx_spine = shoulder_mid_x - hip_mid_x
322
- - dy_spine = shoulder_mid_y - hip_mid_y (usually negative at setup)
323
- - theta_spine = atan2(dy_spine, dx_spine) in degrees
324
- - theta_ground = atan2(y2-y1, x2-x1) from detected ground line
325
- - theta_rel_horiz = wrap180_deg(theta_spine - theta_ground)
326
- - back_tilt = 90.0 - abs(theta_rel_horiz)
327
 
328
  Args:
329
  pose_data (dict): Dictionary mapping frame indices to pose keypoints
330
  swing_phases (dict): Dictionary mapping phase names to lists of frame indices
331
  address_idx (int, optional): Frame index for address/setup position (auto-detected if None)
332
  frames (list, optional): Video frames for Hough line detection
 
 
333
 
334
  Returns:
335
  float: Back tilt angle in degrees (None if calculation fails)
@@ -347,12 +474,21 @@ def calculate_back_tilt_degree(pose_data, swing_phases, address_idx=None, frames
347
  if len(kp) < 25: # Need at least up to hip keypoints
348
  return None
349
 
350
- # Check required keypoints visibility - shoulders and hips
 
351
  required_points = [11, 12, 23, 24] # left shoulder, right shoulder, left hip, right hip
352
- if not all(i < len(kp) and kp[i][2] > 0.3 for i in required_points):
353
  return None
354
 
 
 
 
 
 
355
  try:
 
 
 
356
  # Get ground line angle using improved Hough detection
357
  ground_angle = detect_ground_line_angle(pose_data, swing_phases, frames)
358
 
@@ -371,7 +507,7 @@ def calculate_back_tilt_degree(pose_data, swing_phases, address_idx=None, frames
371
  dy_spine = shoulder_mid_y - hip_mid_y # usually negative at setup (shoulders above hips)
372
 
373
  spine_length = math.sqrt(dx_spine**2 + dy_spine**2)
374
- if spine_length < 15: # Too short to be reliable
375
  return None
376
 
377
  # Calculate spine angle using atan2 (degrees everywhere)
@@ -389,9 +525,6 @@ def calculate_back_tilt_degree(pose_data, swing_phases, address_idx=None, frames
389
  # Clamp to 0-90° range
390
  back_tilt = max(0.0, min(back_tilt, 90.0))
391
 
392
- # Note: Not rejecting values based on ranges - let UI display all measurements
393
- # Even unusual values might be valid for certain body types or setups
394
-
395
  return back_tilt
396
 
397
  except (ZeroDivisionError, ValueError, IndexError):
@@ -437,8 +570,9 @@ def calculate_knee_bend_degree(pose_data, address_idx):
437
  if len(kp) < max(hip_idx, knee_idx, ankle_idx) + 1:
438
  continue
439
 
440
- # Check keypoint visibility
441
- if not all(kp[i][2] > 0.3 for i in [hip_idx, knee_idx, ankle_idx]):
 
442
  continue
443
 
444
  try:
@@ -535,15 +669,15 @@ def calculate_scale_aware_hip_width_threshold(pose_data, swing_phases):
535
  """
536
  setup_frames = swing_phases.get("setup", [])
537
  if not setup_frames:
538
- return 15.0 # Default fallback
539
 
540
  address_idx = setup_frames[0]
541
  if address_idx not in pose_data or pose_data[address_idx] is None:
542
- return 15.0
543
 
544
  addr_kp = pose_data[address_idx]
545
  if len(addr_kp) < 25:
546
- return 15.0
547
 
548
  # Use shoulder width as a reference for body scale
549
  # Keypoints: 11=left shoulder, 12=right shoulder
@@ -562,7 +696,7 @@ def calculate_scale_aware_hip_width_threshold(pose_data, swing_phases):
562
  adaptive_threshold = max(8.0, min(25.0, head_hip_distance * 0.15))
563
  return adaptive_threshold
564
 
565
- return 15.0 # Final fallback
566
 
567
 
568
  def calculate_smoothed_hip_width(pose_data, swing_phases, window_size=5):
@@ -601,305 +735,90 @@ def calculate_smoothed_hip_width(pose_data, swing_phases, window_size=5):
601
  return np.median(hip_widths)
602
 
603
 
604
- def calculate_hip_depth_early_extension(pose_data, swing_phases):
605
- """Calculate hip depth/early extension: distance from pelvis to butt line at address vs impact
606
-
607
- Enhanced implementation with three-layer robustness:
608
- 1. Scale-aware thresholding based on body proportions
609
- 2. Temporal smoothing across frames for stability
610
- 3. Graceful degradation with confidence scoring instead of hard failure
611
 
612
  Args:
613
  pose_data (dict): Dictionary mapping frame indices to pose keypoints
614
  swing_phases (dict): Dictionary mapping phase names to lists of frame indices
 
 
 
615
 
616
  Returns:
617
- dict: Hip depth data with value as percentage of hip width and confidence
618
  """
619
  setup_frames = swing_phases.get("setup", [])
620
  impact_frames = swing_phases.get("impact", [])
621
 
622
  if not setup_frames or not impact_frames:
623
- return {'error': 'No setup or impact frames', 'setup_frames': len(setup_frames), 'impact_frames': len(impact_frames)}
624
 
625
  address_idx = setup_frames[0]
626
  impact_idx = impact_frames[0]
627
 
628
  if (address_idx not in pose_data or impact_idx not in pose_data or
629
  pose_data[address_idx] is None or pose_data[impact_idx] is None):
630
- return {'error': 'Missing pose data', 'address_idx': address_idx, 'impact_idx': impact_idx, 'address_in_data': address_idx in pose_data, 'impact_in_data': impact_idx in pose_data}
631
 
632
  addr_kp = pose_data[address_idx]
633
  impact_kp = pose_data[impact_idx]
634
 
635
- # Need hip keypoints (23=left hip, 24=right hip)
636
- if (len(addr_kp) < 25 or len(impact_kp) < 25 or
637
- not all(addr_kp[i][2] > 0.3 for i in [23, 24]) or
638
- not all(impact_kp[i][2] > 0.3 for i in [23, 24])):
639
- return {'error': 'Insufficient hip keypoints', 'addr_kp_len': len(addr_kp), 'impact_kp_len': len(impact_kp), 'addr_hip_vis': [addr_kp[i][2] if i < len(addr_kp) else 0 for i in [23, 24]], 'impact_hip_vis': [impact_kp[i][2] if i < len(impact_kp) else 0 for i in [23, 24]]}
640
-
641
- try:
642
- # Layer 1: Scale-aware thresholding
643
- adaptive_threshold = calculate_scale_aware_hip_width_threshold(pose_data, swing_phases)
644
-
645
- # Layer 2: Temporal smoothing for hip width
646
- smoothed_hip_width = calculate_smoothed_hip_width(pose_data, swing_phases)
647
- if smoothed_hip_width is None:
648
- # Fallback to single-frame measurement
649
- hip_width = np.linalg.norm(np.array(addr_kp[24][:2]) - np.array(addr_kp[23][:2]))
650
- else:
651
- hip_width = smoothed_hip_width
652
-
653
- # Layer 3: Graceful degradation with confidence scoring
654
- confidence = 1.0
655
- if hip_width < adaptive_threshold:
656
- # Calculate confidence based on how far below threshold we are
657
- confidence = max(0.1, hip_width / adaptive_threshold)
658
-
659
- # For very narrow measurements, use a fallback normalization
660
- if hip_width < adaptive_threshold * 0.5:
661
- # Use shoulder width as fallback normalization
662
- if (len(addr_kp) >= 25 and addr_kp[11][2] > 0.3 and addr_kp[12][2] > 0.3):
663
- fallback_width = np.linalg.norm(np.array(addr_kp[12][:2]) - np.array(addr_kp[11][:2]))
664
- # Scale hip width proportionally to shoulder width
665
- hip_width = fallback_width * 0.7 # Typical hip-to-shoulder ratio
666
- confidence = 0.3 # Low confidence for fallback
667
- else:
668
- # Last resort: use a reasonable default
669
- hip_width = 30.0 # Reasonable default hip width
670
- confidence = 0.1 # Very low confidence
671
-
672
- # Calculate hip centers at address and impact
673
- addr_hip_center = np.array([(addr_kp[23][0] + addr_kp[24][0]) / 2,
674
- (addr_kp[23][1] + addr_kp[24][1]) / 2])
675
- impact_hip_center = np.array([(impact_kp[23][0] + impact_kp[24][0]) / 2,
676
- (impact_kp[23][1] + impact_kp[24][1]) / 2])
677
-
678
- # Butt line = posterior-most x at address (if camera sees trail side)
679
- # Use the maximum x-coordinate of the hips as the butt line reference
680
- butt_line_x = max(addr_kp[23][0], addr_kp[24][0])
681
-
682
- # Calculate depth loss: positive = pelvis moved TOWARD the ball (deeper into screen)
683
- # Note: x+ direction depends on camera orientation; adjust if needed
684
- depth_loss_px = max(0.0, butt_line_x - impact_hip_center[0])
685
- depth_loss_pct = 100.0 * depth_loss_px / hip_width
686
-
687
- return {
688
- 'depth_loss_pixels': round(depth_loss_px, 2),
689
- 'depth_loss_pct': round(depth_loss_pct, 1),
690
- 'hip_width_ref': round(hip_width, 2),
691
- 'butt_line_x': round(butt_line_x, 2),
692
- 'addr_hip_x': round(addr_hip_center[0], 2),
693
- 'impact_hip_x': round(impact_hip_center[0], 2),
694
- 'confidence': round(confidence, 2),
695
- 'adaptive_threshold': round(adaptive_threshold, 2),
696
- 'smoothed_measurement': smoothed_hip_width is not None
697
- }
698
-
699
- except (ZeroDivisionError, ValueError, IndexError):
700
  return None
701
-
702
-
703
- def calculate_shaft_angle_at_address(pose_data, swing_phases, frames=None):
704
- """Calculate shaft angle at address using actual shaft detection vs ground
705
 
706
- Fixed implementation:
707
- - ROI built from hands and ball positions
708
- - Hough filtering tailored to tilted line (40°-70°)
709
- - Clean fallback (never return 90° silently)
710
- - Returns NaN with clear message if no valid line found
711
 
712
- Args:
713
- pose_data (dict): Dictionary mapping frame indices to pose keypoints
714
- swing_phases (dict): Dictionary mapping phase names to lists of frame indices
715
- frames (list, optional): Video frames for Hough line detection
716
-
717
- Returns:
718
- float: Shaft angle at address in degrees (None if calculation fails)
719
- """
720
- setup_frames = swing_phases.get("setup", [])
721
- if not setup_frames:
722
  return None
723
 
724
- # Use improved address frame detection for true address position
725
- address_idx = find_stable_address_frame(pose_data, swing_phases)
726
- if address_idx is None:
727
- address_idx = setup_frames[0] # Fallback to first frame
728
 
729
- if address_idx not in pose_data or pose_data[address_idx] is None:
730
- return None
731
-
732
- kp = pose_data[address_idx]
 
 
733
 
734
- # Check required keypoints visibility - need grip
735
- if len(kp) < 16 or kp[16][2] < 0.3: # Right wrist (grip)
 
736
  return None
737
 
738
- try:
739
- # Get grip position (use both hands if available for better accuracy)
740
- grip_pos = np.array(kp[16][:2]) # Right wrist
741
-
742
- # Try to use both hands for more accurate grip center
743
- if len(kp) > 15 and kp[15][2] > 0.3: # Left wrist
744
- left_grip = np.array(kp[15][:2])
745
- grip_pos = (grip_pos + left_grip) / 2
746
-
747
- # Try to detect actual shaft using improved Hough line detection
748
- shaft_angle = None
749
- if frames is not None and address_idx < len(frames):
750
- shaft_angle = detect_shaft_line_hough(frames[address_idx], grip_pos, kp)
751
-
752
- # Clean fallback - never return 90° silently
753
- if shaft_angle is None:
754
- # Try fallback estimation using body proportions
755
- shaft_angle = estimate_shaft_angle_fallback(grip_pos, kp, None)
756
- if shaft_angle is None:
757
- # Return None with clear indication that shaft was not detected
758
- return None # UI should show "Not detected — check ROI/lighting"
759
-
760
- # Calculate shaft angle vs horizontal (not vs ground)
761
- # The shaft_angle from Hough detection is already the line direction angle
762
- # We need to convert this to angle vs horizontal
763
-
764
- # Convert shaft line direction to angle vs horizontal
765
- shaft_line_angle = math.radians(shaft_angle)
766
-
767
- # Calculate angle vs horizontal (0° = horizontal, 90° = vertical)
768
- # For a line going from grip down to clubhead, we want the acute angle
769
- shaft_angle_vs_horizontal = abs(math.degrees(shaft_line_angle))
770
-
771
- # Ensure we get the acute angle (shaft should be 50-70° from horizontal at address)
772
- if shaft_angle_vs_horizontal > 90:
773
- shaft_angle_vs_horizontal = 180 - shaft_angle_vs_horizontal
774
-
775
- # Clamp to reasonable range
776
- shaft_angle_vs_horizontal = max(0.0, min(shaft_angle_vs_horizontal, 90.0))
777
-
778
- # Note: Not rejecting any values - let UI display all measurements
779
- # Even unusual values might be valid for certain clubs or setups
780
-
781
- return shaft_angle_vs_horizontal
782
-
783
- except (ZeroDivisionError, ValueError, IndexError):
784
- return None
785
 
786
 
787
- def detect_shaft_line_hough(frame, grip_pos, keypoints):
788
- """Detect shaft line using Hough line detection with proper line direction filtering
789
 
790
- Fixed implementation:
791
- - Uses HoughLinesP to get actual line segments
792
- - Converts θ (normal angle) to φ (line direction angle)
793
- - Filters lines by 40°-70° range (typical shaft angle at address)
794
- - Weights by actual segment length
795
-
796
- Args:
797
- frame: Video frame (numpy array)
798
- grip_pos: Grip position (numpy array)
799
- keypoints: Pose keypoints for the frame
800
-
801
- Returns:
802
- float: Shaft angle in degrees (None if detection fails)
803
  """
804
- try:
805
- # Convert to grayscale
806
- if len(frame.shape) == 3:
807
- gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
808
- else:
809
- gray = frame
810
-
811
- H, W = gray.shape
812
- gx, gy = map(int, grip_pos)
813
-
814
- # ROI: around hands, extend downward toward ball
815
- x1 = max(0, gx - 80)
816
- x2 = min(W, gx + 120)
817
- y1 = max(0, gy - 80)
818
- y2 = min(H, gy + 240)
819
- roi = gray[y1:y2, x1:x2]
820
-
821
- if roi.size == 0:
822
- return None
823
-
824
- # Apply edge detection
825
- edges = cv2.Canny(roi, 50, 150, apertureSize=3)
826
-
827
- # Use probabilistic Hough to get actual line segments
828
- lines = cv2.HoughLinesP(edges, 1, np.pi/180, threshold=35,
829
- minLineLength=max(20, (x2-x1)//5), maxLineGap=8)
830
-
831
- if lines is not None:
832
- shaft_candidates = []
833
- for xA, yA, xB, yB in lines[:, 0]:
834
- # Convert ROI coordinates back to full-frame
835
- XA, YA = xA + x1, yA + y1
836
- XB, YB = xB + x1, yB + y1
837
-
838
- # Calculate line DIRECTION angle (not normal angle)
839
- phi_deg = wrap180_deg(math.degrees(math.atan2(YB - YA, XB - XA)))
840
-
841
- # Filter for typical shaft line direction at address
842
- # Shaft line direction should be roughly 55-65° from horizontal
843
- # But we need to check the actual angle vs horizontal, not line direction
844
- if 40.0 <= abs(phi_deg) <= 70.0:
845
- # Calculate actual segment length
846
- length = math.hypot(XB - XA, YB - YA)
847
- shaft_candidates.append((phi_deg, length))
848
-
849
- if shaft_candidates:
850
- # Sort by length and use median of top candidates
851
- shaft_candidates.sort(key=lambda t: -t[1])
852
- top_angles = [ang for ang, _ in shaft_candidates[:3]]
853
- median_phi_deg = float(np.median(top_angles))
854
-
855
- return median_phi_deg # Return line direction angle in degrees
856
-
857
- return None # No valid shaft line found
858
-
859
- except Exception:
860
- return None
861
 
862
 
863
- def estimate_shaft_angle_fallback(grip_pos, keypoints, ground_angle=None):
864
- """Fallback shaft angle estimation using body proportions
865
-
866
- Args:
867
- grip_pos: Grip position (numpy array)
868
- keypoints: Pose keypoints for the frame
869
- ground_angle: Ground line angle in radians (unused, kept for compatibility)
870
-
871
- Returns:
872
- float: Estimated shaft angle in degrees (None if estimation fails)
873
- """
874
- try:
875
- # Use body proportions for shaft length estimation
876
- body_height = None
877
- if (len(keypoints) > 28 and keypoints[12][2] > 0.3 and keypoints[28][2] > 0.3): # Shoulder to ankle
878
- shoulder_pos = np.array(keypoints[12][:2])
879
- ankle_pos = np.array(keypoints[28][:2])
880
- body_height = np.linalg.norm(shoulder_pos - ankle_pos)
881
-
882
- if body_height and body_height > 50:
883
- # Typical driver length is ~0.6x body height
884
- club_length = body_height * 0.6
885
- # At address, club points down and slightly forward (~60° from horizontal)
886
- typical_shaft_angle = math.radians(60)
887
-
888
- # Calculate clubhead position (down and slightly forward)
889
- shaft_dx = club_length * math.cos(typical_shaft_angle)
890
- shaft_dy = club_length * math.sin(typical_shaft_angle)
891
- clubhead_pos = grip_pos + np.array([shaft_dx, shaft_dy])
892
-
893
- # Calculate shaft vector and angle
894
- shaft_vec = clubhead_pos - grip_pos
895
- shaft_angle = math.degrees(math.atan2(shaft_vec[1], shaft_vec[0]))
896
-
897
- return shaft_angle
898
-
899
- return None
900
-
901
- except Exception:
902
- return None
903
 
904
 
905
  def validate_angle_calculations(pose_data, swing_phases, frames=None):
@@ -1133,105 +1052,439 @@ def find_stable_address_frame(pose_data, swing_phases, min_frames=5):
1133
  return setup_frames[0] # Ultimate fallback
1134
 
1135
 
1136
- def calculate_head_vertical_displacement(pose_data, swing_phases):
1137
- """Calculate head vertical displacement from address to impact as percentage of body height
 
 
 
 
1138
 
1139
- Fixed implementation:
1140
- - Percent should be Δy / body-height-at-setup (or head–ankle distance)
1141
- - Confirms and documents the denominator (body height reference)
1142
- - Ensures y-axis sign is handled (image origin top-left)
1143
- - Verifies roll correction didn't leak into vertical
1144
 
1145
  Args:
1146
  pose_data (dict): Dictionary mapping frame indices to pose keypoints
1147
  swing_phases (dict): Dictionary mapping phase names to lists of frame indices
 
 
 
 
1148
 
1149
  Returns:
1150
- dict: Head displacement data with value in percentage of body height
1151
  """
 
 
 
 
1152
  setup_frames = swing_phases.get("setup", [])
1153
- impact_frames = swing_phases.get("impact", [])
 
 
 
 
 
 
 
 
1154
 
1155
- if not setup_frames or not impact_frames:
1156
  return None
1157
 
1158
- # Use median filtering around address and impact for stability
1159
- address_range = setup_frames[:min(3, len(setup_frames))]
1160
- impact_range = impact_frames[:min(3, len(impact_frames))]
 
1161
 
1162
- # Get head positions over multiple frames for stability
1163
- addr_head_positions = []
1164
- impact_head_positions = []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1165
 
1166
- for frame_idx in address_range:
1167
- if frame_idx in pose_data and pose_data[frame_idx] is not None:
1168
- head_pos = calculate_head_center(pose_data[frame_idx])
1169
- if head_pos is not None:
1170
- addr_head_positions.append(head_pos)
1171
 
1172
- for frame_idx in impact_range:
1173
- if frame_idx in pose_data and pose_data[frame_idx] is not None:
1174
- head_pos = calculate_head_center(pose_data[frame_idx])
1175
- if head_pos is not None:
1176
- impact_head_positions.append(head_pos)
 
 
 
 
 
1177
 
1178
- if not addr_head_positions or not impact_head_positions:
1179
- return None
1180
 
1181
- # Use median position for robustness
1182
- addr_head = np.median(addr_head_positions, axis=0)
1183
- impact_head = np.median(impact_head_positions, axis=0)
1184
 
1185
- try:
1186
- # Calculate body height reference from head to ankle at address
1187
- # Documented denominator: body-height-at-setup (head–ankle distance)
1188
- addr_kp = pose_data[setup_frames[0]]
1189
- body_height = None
1190
- height_reference_method = None
1191
-
1192
- # Try multiple body height references (documented)
1193
- if (len(addr_kp) > 28 and addr_kp[28][2] > 0.3): # Head to right ankle
1194
- ankle_pos = np.array(addr_kp[28][:2])
1195
- body_height = abs(addr_head[1] - ankle_pos[1])
1196
- height_reference_method = "head_to_right_ankle"
1197
- elif (len(addr_kp) > 27 and addr_kp[27][2] > 0.3): # Head to left ankle
1198
- ankle_pos = np.array(addr_kp[27][:2])
1199
- body_height = abs(addr_head[1] - ankle_pos[1])
1200
- height_reference_method = "head_to_left_ankle"
1201
- elif (len(addr_kp) > 24 and addr_kp[24][2] > 0.3): # Head to hip as fallback
1202
- hip_pos = np.array(addr_kp[24][:2])
1203
- body_height = abs(addr_head[1] - hip_pos[1]) * 1.8 # Approximate full body
1204
- height_reference_method = "head_to_hip_scaled"
1205
-
1206
- if body_height is None or body_height < 50:
1207
- return None
1208
-
1209
- # Calculate vertical displacement
1210
- # Note: In image coordinates, y increases downward (image origin top-left)
1211
- # Positive displacement = head moved down, negative = head moved up
1212
- vertical_displacement = impact_head[1] - addr_head[1]
1213
-
1214
- # Convert to percentage of body height (documented formula: Δy / body-height-at-setup)
1215
- displacement_pct = (abs(vertical_displacement) / body_height) * 100
1216
-
1217
- # Always return the calculated value - let the UI handle display warnings
1218
- # Sanity check: typical head drop is 2-10% of body height
1219
- # Values outside typical range will be flagged in the UI but still displayed
1220
-
1221
- displacement_data = {
1222
- 'displacement_pixels': round(vertical_displacement, 2),
1223
- 'displacement_direction': 'down' if vertical_displacement > 0 else 'up',
1224
- 'displacement_abs': round(abs(vertical_displacement), 2),
1225
- 'displacement_pct': round(displacement_pct, 1),
1226
- 'body_height_ref': round(body_height, 2),
1227
- 'height_reference_method': height_reference_method, # Document the denominator
1228
- 'denominator_documented': True # Confirms denominator is documented
1229
- }
 
 
1230
 
1231
- return displacement_data
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1232
 
1233
- except (ZeroDivisionError, ValueError, IndexError):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1234
  return None
 
 
 
 
 
 
 
 
1235
 
1236
 
1237
  def generate_diagnostic_overlay(pose_data, swing_phases, frames=None):
@@ -1359,4 +1612,4 @@ def generate_diagnostic_overlay(pose_data, swing_phases, frames=None):
1359
  return diagnostic_data
1360
 
1361
  except Exception as e:
1362
- return {'error': str(e)}
 
9
  import numpy as np
10
  import cv2
11
 
12
+ # Head landmark indices for head height calculation
13
+ L_EYE, R_EYE = 2, 5
14
+ L_EAR, R_EAR = 7, 8
15
+ NOSE = 0
16
 
17
+ # Enable debug output for head height calculations
18
+ VERBOSE = True
 
 
19
 
20
+ def dbg(msg):
21
+ """Debug print helper"""
22
+ if VERBOSE:
23
+ print(f"[DEBUG] {msg}")
24
 
25
+
26
+ def to_pixels_if_needed(kp, frame_w=None, frame_h=None):
27
+ """Convert keypoints to pixels if they appear normalized (0..1).
28
+ Works even if frame_w/frame_h are None by assuming a sane default size.
 
 
 
29
 
30
  Args:
31
+ kp: list of (x, y, vis) keypoints
32
+ frame_w: frame width in pixels (optional)
33
+ frame_h: frame height in pixels (optional)
 
34
 
35
  Returns:
36
+ list: keypoints converted to pixel coordinates
37
  """
38
+ if not kp:
39
+ return kp
40
+
41
+ # Extract coords
42
+ xs = [p[0] for p in kp if p and len(p) >= 2 and p[0] is not None]
43
+ ys = [p[1] for p in kp if p and len(p) >= 2 and p[1] is not None]
44
+ if not xs or not ys:
45
+ return kp
46
+
47
+ # Heuristic: normalized if 90th percentile < ~2.0
48
+ looks_normalized = (np.percentile(xs, 90) < 2.0 and np.percentile(ys, 90) < 2.0)
49
+
50
+ # If we don't know frame size but coords look normalized, assume 1920x1080
51
+ if (frame_w is None or frame_h is None) and looks_normalized:
52
+ frame_w = frame_w or 1920
53
+ frame_h = frame_h or 1080
54
+
55
+ # If we still don't have dims, or they already look like pixels, return as-is
56
+ if frame_w is None or frame_h is None or not looks_normalized:
57
+ return kp
58
+
59
+ # Convert normalized → pixels
60
+ kp_pixels = []
61
+ for p in kp:
62
+ if p and len(p) >= 3 and p[0] is not None and p[1] is not None:
63
+ kp_pixels.append([p[0] * frame_w, p[1] * frame_h, p[2]])
64
+ else:
65
+ kp_pixels.append(p)
66
+ return kp_pixels
67
+
68
+
69
+ def wrap180_deg(a):
70
+ """Returns angle in (-180, 180] range - bulletproof implementation"""
71
+ a = (a + 180.0) % 360.0 - 180.0
72
+ return a
73
+
74
+
75
+ # Safe no-op to avoid NameError in validation/overlay paths
76
+ def calculate_shaft_angle_at_address(*args, **kwargs):
77
+ """Safe stub for removed shaft angle function to prevent NameError"""
78
+ return None
79
+
80
+
81
+ # Note: calculate_metric_confidence removed - not used by the 5 DTL metrics
82
+ # This function was defined but never called by the displayed metrics
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
 
84
 
85
  def detect_ground_line_angle(pose_data, swing_phases, frames=None):
 
126
 
127
  kp = pose_data[frame_idx]
128
 
129
+ # Try ankle line (most reliable for ground reference) - looser thresholds
130
+ if (len(kp) > 28 and kp[27][2] > 0.2 and kp[28][2] > 0.2):
131
  left_ankle = np.array(kp[27][:2])
132
  right_ankle = np.array(kp[28][:2])
133
  ankle_vec = right_ankle - left_ankle
134
+ if np.linalg.norm(ankle_vec) > 10: # Reduced minimum distance
135
  ankle_angle = math.atan2(ankle_vec[1], ankle_vec[0])
136
  ankle_angle_deg = math.degrees(ankle_angle)
137
  ankle_angle_wrapped = wrap180_deg(ankle_angle_deg)
138
 
139
+ # Validate ankle line is horizontal-ish (looser tolerance)
140
+ if abs(ankle_angle_wrapped) <= 20.0:
141
  ground_angles.append(ankle_angle)
142
 
143
+ # Try knee line as backup - looser thresholds
144
+ if (len(kp) > 26 and kp[25][2] > 0.2 and kp[26][2] > 0.2):
145
  left_knee = np.array(kp[25][:2])
146
  right_knee = np.array(kp[26][:2])
147
  knee_vec = right_knee - left_knee
148
+ if np.linalg.norm(knee_vec) > 10: # Reduced minimum distance
149
  knee_angle = math.atan2(knee_vec[1], knee_vec[0])
150
  knee_angle_deg = math.degrees(knee_angle)
151
  knee_angle_wrapped = wrap180_deg(knee_angle_deg)
152
 
153
+ # Validate knee line is horizontal-ish (looser tolerance)
154
+ if abs(knee_angle_wrapped) <= 20.0:
155
  ground_angles.append(knee_angle)
156
 
157
+ # Final fallback: assume level ground (0 rad) rather than None
158
  if not ground_angles:
159
+ return 0.0 # Horizontal ground assumption
160
 
161
  # Use median for robustness
162
  return np.median(ground_angles)
 
255
  return None
256
 
257
 
258
+ def _pick_head_y(lm, vis_thr=0.5):
259
+ """Return y in pixels for best-available head landmark; else approximate from shoulders.
260
+
261
+ Args:
262
+ lm: pose landmarks for a single frame
263
+ vis_thr: minimum visibility threshold
264
+
265
+ Returns:
266
+ float: head y coordinate in pixels, or None if unavailable
267
+ """
268
+ cand = []
269
+ for idx in (L_EYE, R_EYE, L_EAR, R_EAR):
270
+ if idx < len(lm) and lm[idx] and len(lm[idx]) >= 3:
271
+ visibility = getattr(lm[idx], "visibility", lm[idx][2] if len(lm[idx]) > 2 else 1.0)
272
+ if visibility >= vis_thr:
273
+ cand.append((lm[idx][1], visibility)) # y coordinate, visibility
274
+
275
+ if cand:
276
+ # weighted by vis^2 for stability
277
+ wsum = sum(v*v for _, v in cand)
278
+ return sum(y*(v*v) for y, v in cand) / (wsum if wsum else len(cand))
279
+
280
+ # Fallback: estimate from shoulders (keeps relative motion consistent)
281
+ if len(lm) > 24 and lm[11] and lm[12] and lm[23] and lm[24]:
282
+ # Use shoulder keypoints (11=left shoulder, 12=right shoulder)
283
+ # and hip keypoints (23=left hip, 24=right hip)
284
+ y_sho = 0.5 * (lm[11][1] + lm[12][1])
285
+ y_hip = 0.5 * (lm[23][1] + lm[24][1])
286
+ return y_sho - 0.25 * (y_sho - y_hip) # small offset above shoulders
287
+
288
+ return None
289
+
290
+
291
+ def _median_head_y(frames_lm, idx, k=5, vis_thr=0.5):
292
+ """Median head y over a small temporal window.
293
+
294
+ Args:
295
+ frames_lm: list of pose landmarks for all frames
296
+ idx: center frame index
297
+ k: window size (frames to consider around center)
298
+ vis_thr: visibility threshold
299
+
300
+ Returns:
301
+ float: median head y coordinate, or None if unavailable
302
+ """
303
+ n = len(frames_lm)
304
+ hys = []
305
+ a = max(0, idx - k//2)
306
+ b = min(n, idx + k//2 + 1)
307
+
308
+ for i in range(a, b):
309
+ try:
310
+ hy = _pick_head_y(frames_lm[i], vis_thr)
311
+ if hy is not None:
312
+ hys.append(hy)
313
+ except Exception:
314
+ continue
315
+
316
+ if not hys:
317
+ return None
318
+ return float(np.median(hys))
319
+
320
+
321
+ def head_height_change(frames_lm, addr_idx, top_idx, imp_idx, inch_scale, vis_thr=0.5):
322
+ """Calculate single head height change metric from address to impact.
323
+
324
+ This measures head stability during the swing - positive values mean head moved DOWN.
325
+
326
+ Args:
327
+ frames_lm: list of pose landmarks for all frames (pose_data as list)
328
+ addr_idx: address frame index
329
+ top_idx: top of backswing frame index (not used for final metric, but kept for debug)
330
+ imp_idx: impact frame index
331
+ inch_scale: pixels per inch conversion factor
332
+ vis_thr: visibility threshold
333
+
334
+ Returns:
335
+ float: head change from address to impact in inches (positive = moved DOWN)
336
+ """
337
+ dbg(f"=== HEAD HEIGHT CHANGE CALCULATION ===")
338
+ dbg(f"Frame indices - Address: {addr_idx}, Top: {top_idx}, Impact: {imp_idx}")
339
+ dbg(f"Inch scale: {inch_scale:.3f} px/in, Visibility threshold: {vis_thr}")
340
+
341
+ # Convert pose_data dict to list format for compatibility
342
+ if isinstance(frames_lm, dict):
343
+ max_frame = max(frames_lm.keys()) if frames_lm else 0
344
+ frames_list = [None] * (max_frame + 1)
345
+ for frame_idx, landmarks in frames_lm.items():
346
+ frames_list[frame_idx] = landmarks
347
+ frames_lm = frames_list
348
+ dbg(f"Converted pose_data dict to list format ({len(frames_list)} frames)")
349
+
350
+ y_addr = _median_head_y(frames_lm, addr_idx, vis_thr=vis_thr)
351
+ y_top = _median_head_y(frames_lm, top_idx, vis_thr=vis_thr)
352
+ y_imp = _median_head_y(frames_lm, imp_idx, vis_thr=vis_thr)
353
+
354
+ dbg(f"Head Y coordinates - Address: {y_addr}, Top: {y_top}, Impact: {y_imp}")
355
+
356
+ if None in (y_addr, y_imp):
357
+ dbg(f"❌ Missing head Y coordinates - cannot calculate head height change")
358
+ return None
359
+
360
+ if not inch_scale or inch_scale <= 0:
361
+ dbg(f"❌ Invalid inch scale ({inch_scale}) - cannot calculate head height change")
362
+ return None
363
+
364
+ # Calculate primary metric: address to impact change
365
+ head_change_in = (y_imp - y_addr) / inch_scale
366
+
367
+ # Also calculate top drop for debug info
368
+ drop_top_in = (y_top - y_addr) / inch_scale if y_top is not None else None
369
+
370
+ dbg(f"📏 Head movement calculations:")
371
+ if drop_top_in is not None:
372
+ dbg(f" Address → Top: {y_top - y_addr:+.1f} px = {drop_top_in:+.2f} inches (debug only)")
373
+ dbg(f" Address → Impact: {y_imp - y_addr:+.1f} px = {head_change_in:+.2f} inches (PRIMARY METRIC)")
374
+
375
+ # Interpret primary metric (address to impact)
376
+ if abs(head_change_in) <= 1.0:
377
+ dbg(f"✅ Excellent head stability ({head_change_in:+.2f}\") - within ±1\"")
378
+ elif abs(head_change_in) <= 2.5:
379
+ dbg(f"⚠️ Moderate head movement ({head_change_in:+.2f}\") - caution zone")
380
+ else:
381
+ dbg(f"❌ Excessive head movement ({head_change_in:+.2f}\") - likely EE/low-point issues")
382
+
383
+ # Debug info for top drop
384
+ if drop_top_in is not None:
385
+ if drop_top_in > 4.0:
386
+ dbg(f"⚠️ Excessive head drop at top ({drop_top_in:.2f}\") - too much 'sit' (debug)")
387
+ elif 1.0 <= drop_top_in <= 3.0:
388
+ dbg(f"✅ Good head drop at top ({drop_top_in:.2f}\") - within typical range (debug)")
389
+ elif drop_top_in < 0:
390
+ dbg(f"⚠️ Head rose at top ({drop_top_in:.2f}\") - unusual movement (debug)")
391
+
392
+ dbg(f"=== HEAD HEIGHT CALCULATION COMPLETE ===")
393
+
394
+ return head_change_in
395
+
396
+
397
+ def calculate_inch_scale_from_shoulders(pose_data, address_idx, frame_w=None, frame_h=None, assumed_shoulder_width_in=15.5):
398
+ """Calculate inch scale using shoulder width as reference.
399
+
400
+ Args:
401
+ pose_data: dictionary mapping frame indices to pose keypoints
402
+ address_idx: address frame index
403
+ frame_w: frame width in pixels
404
+ frame_h: frame height in pixels
405
+ assumed_shoulder_width_in: assumed shoulder width in inches (default 15.5" average)
406
+
407
+ Returns:
408
+ float: pixels per inch scale factor, or None if calculation fails
409
+ """
410
+ dbg(f"=== CALCULATING INCH SCALE FROM SHOULDERS ===")
411
+ dbg(f"Address frame: {address_idx}, Assumed shoulder width: {assumed_shoulder_width_in}\"")
412
+
413
+ if address_idx not in pose_data or pose_data[address_idx] is None:
414
+ dbg(f"❌ Address frame {address_idx} not found in pose data")
415
+ return None
416
+
417
+ kp = pose_data[address_idx]
418
+ if len(kp) < 13: # Need shoulder keypoints
419
+ dbg(f"❌ Insufficient keypoints ({len(kp)}) - need at least 13 for shoulders")
420
+ return None
421
+
422
+ # Ensure pixels
423
+ kp = to_pixels_if_needed(kp, frame_w, frame_h)
424
+
425
+ # Calculate shoulder width in pixels
426
+ if kp[11] and kp[12] and len(kp[11]) >= 2 and len(kp[12]) >= 2:
427
+ left_shoulder = kp[11][:2]
428
+ right_shoulder = kp[12][:2]
429
+ shoulder_width_px = abs(kp[12][0] - kp[11][0])
430
+
431
+ dbg(f"Shoulder positions - Left: ({left_shoulder[0]:.1f}, {left_shoulder[1]:.1f}), Right: ({right_shoulder[0]:.1f}, {right_shoulder[1]:.1f})")
432
+ dbg(f"Shoulder width: {shoulder_width_px:.1f} pixels")
433
+
434
+ if shoulder_width_px > 10: # Reasonable minimum
435
+ inch_scale = shoulder_width_px / assumed_shoulder_width_in
436
+ dbg(f"✅ Calculated inch scale: {inch_scale:.3f} pixels per inch")
437
+ dbg(f"=== INCH SCALE CALCULATION COMPLETE ===")
438
+ return inch_scale
439
+ else:
440
+ dbg(f"❌ Shoulder width too small ({shoulder_width_px:.1f} px) - unreliable scale")
441
+ else:
442
+ dbg(f"❌ Missing or invalid shoulder keypoints")
443
+
444
+ dbg(f"❌ Inch scale calculation failed")
445
+ return None
446
+
447
+
448
+ def calculate_back_tilt_degree(pose_data, swing_phases, address_idx=None, frames=None, frame_w=None, frame_h=None):
449
  """Calculate back tilt: forward bend vs ground using bulletproof geometry
450
 
451
+ Target values: 30-45° depending on club (driver vs wedge)
 
 
 
 
 
 
 
452
 
453
  Args:
454
  pose_data (dict): Dictionary mapping frame indices to pose keypoints
455
  swing_phases (dict): Dictionary mapping phase names to lists of frame indices
456
  address_idx (int, optional): Frame index for address/setup position (auto-detected if None)
457
  frames (list, optional): Video frames for Hough line detection
458
+ frame_w (int, optional): Frame width in pixels
459
+ frame_h (int, optional): Frame height in pixels
460
 
461
  Returns:
462
  float: Back tilt angle in degrees (None if calculation fails)
 
474
  if len(kp) < 25: # Need at least up to hip keypoints
475
  return None
476
 
477
+ # Always calculate even with low visibility - no hiding metrics
478
+ # Check if keypoints exist but don't hide based on visibility
479
  required_points = [11, 12, 23, 24] # left shoulder, right shoulder, left hip, right hip
480
+ if not all(i < len(kp) for i in required_points):
481
  return None
482
 
483
+ # Try to get frame dimensions if not provided
484
+ if frame_w is None or frame_h is None:
485
+ frame_w = frame_w or 1920
486
+ frame_h = frame_h or 1080
487
+
488
  try:
489
+ # Ensure pixels
490
+ kp = to_pixels_if_needed(kp, frame_w, frame_h)
491
+
492
  # Get ground line angle using improved Hough detection
493
  ground_angle = detect_ground_line_angle(pose_data, swing_phases, frames)
494
 
 
507
  dy_spine = shoulder_mid_y - hip_mid_y # usually negative at setup (shoulders above hips)
508
 
509
  spine_length = math.sqrt(dx_spine**2 + dy_spine**2)
510
+ if spine_length < 30: # Adjusted for pixel coordinates
511
  return None
512
 
513
  # Calculate spine angle using atan2 (degrees everywhere)
 
525
  # Clamp to 0-90° range
526
  back_tilt = max(0.0, min(back_tilt, 90.0))
527
 
 
 
 
528
  return back_tilt
529
 
530
  except (ZeroDivisionError, ValueError, IndexError):
 
570
  if len(kp) < max(hip_idx, knee_idx, ankle_idx) + 1:
571
  continue
572
 
573
+ # Always calculate even with low visibility - no hiding metrics
574
+ # Check if keypoints exist but don't hide based on visibility
575
+ if not all(i < len(kp) for i in [hip_idx, knee_idx, ankle_idx]):
576
  continue
577
 
578
  try:
 
669
  """
670
  setup_frames = swing_phases.get("setup", [])
671
  if not setup_frames:
672
+ return None # No fallback values - show actual result
673
 
674
  address_idx = setup_frames[0]
675
  if address_idx not in pose_data or pose_data[address_idx] is None:
676
+ return None # No fallback values - show actual result
677
 
678
  addr_kp = pose_data[address_idx]
679
  if len(addr_kp) < 25:
680
+ return None # No fallback values - show actual result
681
 
682
  # Use shoulder width as a reference for body scale
683
  # Keypoints: 11=left shoulder, 12=right shoulder
 
696
  adaptive_threshold = max(8.0, min(25.0, head_hip_distance * 0.15))
697
  return adaptive_threshold
698
 
699
+ return None # No fallback values - show actual result
700
 
701
 
702
  def calculate_smoothed_hip_width(pose_data, swing_phases, window_size=5):
 
735
  return np.median(hip_widths)
736
 
737
 
738
+ # Hip depth/early extension metric removed per user request
739
+ def early_extension_pct(pose_data, swing_phases, frame_w, frame_h, camera_side="trail"):
740
+ """Calculate early extension percentage using posterior hip method with unit conversion
 
 
 
 
741
 
742
  Args:
743
  pose_data (dict): Dictionary mapping frame indices to pose keypoints
744
  swing_phases (dict): Dictionary mapping phase names to lists of frame indices
745
+ frame_w (int): Frame width in pixels
746
+ frame_h (int): Frame height in pixels
747
+ camera_side (str): "trail" or "lead" - which side camera is on
748
 
749
  Returns:
750
+ float: Early extension percentage (0-100%)
751
  """
752
  setup_frames = swing_phases.get("setup", [])
753
  impact_frames = swing_phases.get("impact", [])
754
 
755
  if not setup_frames or not impact_frames:
756
+ return None
757
 
758
  address_idx = setup_frames[0]
759
  impact_idx = impact_frames[0]
760
 
761
  if (address_idx not in pose_data or impact_idx not in pose_data or
762
  pose_data[address_idx] is None or pose_data[impact_idx] is None):
763
+ return None
764
 
765
  addr_kp = pose_data[address_idx]
766
  impact_kp = pose_data[impact_idx]
767
 
768
+ if len(addr_kp) < 25 or len(impact_kp) < 25:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
769
  return None
 
 
 
 
770
 
771
+ # Ensure pixels
772
+ addr_kp = to_pixels_if_needed(addr_kp, frame_w, frame_h)
773
+ impact_kp = to_pixels_if_needed(impact_kp, frame_w, frame_h)
 
 
774
 
775
+ # Check hip keypoint validity - be more lenient
776
+ if (not addr_kp[23] or not addr_kp[24] or not impact_kp[23] or not impact_kp[24] or
777
+ len(addr_kp[23]) < 2 or len(addr_kp[24]) < 2 or
778
+ len(impact_kp[23]) < 2 or len(impact_kp[24]) < 2):
 
 
 
 
 
 
779
  return None
780
 
781
+ # Posterior-most hip x at address/impact
782
+ hips_addr = [addr_kp[23][0], addr_kp[24][0]]
783
+ hips_impact = [impact_kp[23][0], impact_kp[24][0]]
 
784
 
785
+ if camera_side == "trail":
786
+ butt_addr = max(hips_addr)
787
+ butt_impact = max(hips_impact)
788
+ else:
789
+ butt_addr = min(hips_addr)
790
+ butt_impact = min(hips_impact)
791
 
792
+ # Hip width at address (pixels) - more lenient threshold
793
+ hip_width = abs(hips_addr[1] - hips_addr[0])
794
+ if hip_width < 5: # Very lenient sanity check
795
  return None
796
 
797
+ # Hip depth/early extension metric removed per user request
798
+ return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
799
 
800
 
801
+ def calculate_hip_depth_early_extension(pose_data, swing_phases, frame_w=None, frame_h=None):
802
+ """Calculate hip depth/early extension - DEPRECATED, use early_extension_pct instead
803
 
804
+ This function is kept for backward compatibility but will use the new implementation.
 
 
 
 
 
 
 
 
 
 
 
 
805
  """
806
+ # Try to get frame dimensions from first available frame if not provided
807
+ if frame_w is None or frame_h is None:
808
+ for frame_data in pose_data.values():
809
+ if frame_data:
810
+ # Default frame size - this is a fallback and may not be accurate
811
+ frame_w = frame_w or 1920
812
+ frame_h = frame_h or 1080
813
+ break
814
+
815
+ # Hip depth/early extension metric removed per user request
816
+ return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
817
 
818
 
819
+ # Note: Shaft angle functions removed - not part of the 5 core DTL metrics
820
+ # calculate_shaft_angle_at_address, detect_shaft_line_hough, estimate_shaft_angle_fallback
821
+ # These functions were not in the target 5 DTL metrics and can be moved to a separate FO module if needed later
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
822
 
823
 
824
  def validate_angle_calculations(pose_data, swing_phases, frames=None):
 
1052
  return setup_frames[0] # Ultimate fallback
1053
 
1054
 
1055
+ # Note: Head vertical displacement removed - not part of the 5 core DTL metrics
1056
+ # This function was not in the target 5 DTL metrics
1057
+
1058
+
1059
+ def calculate_shoulder_tilt_swing_plane_at_top(pose_data, swing_phases, top_idx, frames=None, frame_w=None, frame_h=None):
1060
+ """Calculate shoulder tilt/swing plane angle at top using yaw-corrected width-ratio method
1061
 
1062
+ Target values: Professional = 36°, 30 handicap = 29°
 
 
 
 
1063
 
1064
  Args:
1065
  pose_data (dict): Dictionary mapping frame indices to pose keypoints
1066
  swing_phases (dict): Dictionary mapping phase names to lists of frame indices
1067
+ top_idx (int): Frame index for top of backswing
1068
+ frames (list, optional): Video frames for analysis
1069
+ frame_w (int, optional): Frame width in pixels
1070
+ frame_h (int, optional): Frame height in pixels
1071
 
1072
  Returns:
1073
+ float: Shoulder tilt/swing plane angle in degrees (None if calculation fails)
1074
  """
1075
+ if top_idx not in pose_data or pose_data[top_idx] is None:
1076
+ return None
1077
+
1078
+ # Get address frame for baseline width
1079
  setup_frames = swing_phases.get("setup", [])
1080
+ if not setup_frames:
1081
+ return None
1082
+
1083
+ address_idx = setup_frames[0]
1084
+ if address_idx not in pose_data or pose_data[address_idx] is None:
1085
+ return None
1086
+
1087
+ kp_top = pose_data[top_idx]
1088
+ kp_addr = pose_data[address_idx]
1089
 
1090
+ if len(kp_top) < 13 or len(kp_addr) < 13: # Need shoulder keypoints
1091
  return None
1092
 
1093
+ # Try to get frame dimensions if not provided
1094
+ if frame_w is None or frame_h is None:
1095
+ frame_w = frame_w or 1920
1096
+ frame_h = frame_h or 1080
1097
 
1098
+ try:
1099
+ # Ensure pixels
1100
+ kp_top = to_pixels_if_needed(kp_top, frame_w, frame_h)
1101
+ kp_addr = to_pixels_if_needed(kp_addr, frame_w, frame_h)
1102
+
1103
+ # Get shoulder positions
1104
+ LS_top = np.array(kp_top[11][:2]) # Left shoulder at top
1105
+ RS_top = np.array(kp_top[12][:2]) # Right shoulder at top
1106
+ LS_addr = np.array(kp_addr[11][:2]) # Left shoulder at address
1107
+ RS_addr = np.array(kp_addr[12][:2]) # Right shoulder at address
1108
+
1109
+ # Calculate shoulder widths
1110
+ dx_top = abs(RS_top[0] - LS_top[0])
1111
+ dy_top = abs(RS_top[1] - LS_top[1])
1112
+ dx_addr = max(1e-3, abs(RS_addr[0] - LS_addr[0])) # baseline width, avoid division by zero
1113
+
1114
+ # Scale-aware guard - don't block computation for small values
1115
+ min_baseline = 20 if frame_w is None else max(0.02 * frame_w, 20)
1116
+ if dx_addr < min_baseline:
1117
+ # Allow computation to proceed with warning rather than blocking
1118
+ pass
1119
+
1120
+ # Estimate yaw using width ratio: yaw ≈ acos(dx_top / dx_addr)
1121
+ # Correct horizontal component by removing cos(yaw) effect
1122
+ yaw_cos = np.clip(dx_top / dx_addr, 0.0, 1.0)
1123
+ dx_corrected = dx_top / max(yaw_cos, 1e-3) # "unskew" the horizontal component
1124
+
1125
+ # Now calculate the tilt angle using corrected dx
1126
+ angle = math.degrees(math.atan2(dy_top, dx_corrected))
1127
+
1128
+ # Return the shoulder plane angle (clamped to reasonable range)
1129
+ swing_plane_angle = max(0.0, min(abs(angle), 90.0))
1130
+
1131
+ return round(swing_plane_angle, 1)
1132
+
1133
+ except (ZeroDivisionError, ValueError, IndexError):
1134
+ return None
1135
+
1136
+
1137
+ def _median_width(pose_data, center_idx, idx_a, idx_b, window=5, min_vis=0.35, frame_w=None, frame_h=None):
1138
+ """Median horizontal width between two keypoints over a window, in *pixels*.
1139
+ More lenient about visibility so we don't drop valid geometry."""
1140
+ half = window // 2
1141
+ vals, vis_vals = [], []
1142
+
1143
+ for f in range(center_idx - half, center_idx + half + 1):
1144
+ kp = pose_data.get(f)
1145
+ if kp is None or len(kp) <= max(idx_a, idx_b):
1146
+ continue
1147
+
1148
+ # Per-frame pixel conversion (handles normalized coords)
1149
+ kp = to_pixels_if_needed(kp, frame_w, frame_h)
1150
+
1151
+ va, vb = kp[idx_a], kp[idx_b]
1152
+ if va is None or vb is None or len(va) < 2 or len(vb) < 2:
1153
+ continue
1154
+
1155
+ # Use real vis if present; otherwise assume a reasonable default
1156
+ vis_a = (va[2] if len(va) >= 3 and va[2] is not None else 0.6)
1157
+ vis_b = (vb[2] if len(vb) >= 3 and vb[2] is not None else 0.6)
1158
+
1159
+ # Accept based on geometry only; vis is too flaky for hips/shoulders
1160
+ if len(va) >= 2 and len(vb) >= 2:
1161
+ w = abs(vb[0] - va[0])
1162
+ if w > 1.0: # ignore tiny/degenerate values
1163
+ vals.append(w)
1164
+
1165
+ # keep vis if available, but don't gate on it
1166
+ vis_vals.append(0.5 * (vis_a + vis_b))
1167
+
1168
+ if not vals:
1169
+ return None, 0.0
1170
+ return float(np.median(vals)), (float(np.median(vis_vals)) if vis_vals else 0.0)
1171
+
1172
+
1173
+ def _line_angle(kp, i, j):
1174
+ """Calculate angle of line between two keypoints"""
1175
+ if kp is None or len(kp) <= max(i,j) or kp[i] is None or kp[j] is None:
1176
+ return None
1177
+ xi, yi = kp[i][:2]
1178
+ xj, yj = kp[j][:2]
1179
+ return math.atan2(yj - yi, xj - xi)
1180
+
1181
+
1182
+ def _pelvis_width_at(pose_data, frame_idx, frame_w, frame_h):
1183
+ """Get pelvis width at a specific frame, or None if unavailable"""
1184
+ kp = pose_data.get(frame_idx)
1185
+ if not kp or len(kp) <= 24:
1186
+ return None
1187
+ kp = to_pixels_if_needed(kp, frame_w, frame_h)
1188
+ a, b = kp[23], kp[24]
1189
+ if not a or not b or len(a) < 2 or len(b) < 2:
1190
+ return None
1191
+ w = abs(b[0] - a[0])
1192
+ return w if w > 1.0 else None
1193
+
1194
+
1195
+ def _median_pelvis_angle_deg(pose_data, center_idx, frame_w, frame_h, window=7, min_width_px=12.0):
1196
+ """Median pelvis line angle over a window, ignoring frames with collapsed hip width."""
1197
+ half = window // 2
1198
+ angles = []
1199
+ for f in range(center_idx - half, center_idx + half + 1):
1200
+ kp = pose_data.get(f)
1201
+ if not kp:
1202
+ continue
1203
+ kp = to_pixels_if_needed(kp, frame_w, frame_h)
1204
+
1205
+ # Require a minimally wide pelvis in this frame
1206
+ a = kp[23] if len(kp) > 23 else None
1207
+ b = kp[24] if len(kp) > 24 else None
1208
+ if not a or not b or len(a) < 2 or len(b) < 2:
1209
+ continue
1210
+ w = abs(b[0] - a[0])
1211
+ if w < min_width_px:
1212
+ continue
1213
+
1214
+ ang = _line_angle(kp, 23, 24) # radians
1215
+ if ang is not None:
1216
+ angles.append(wrap180_deg(math.degrees(ang)))
1217
+
1218
+ return (float(np.median(angles)) if angles else None)
1219
+
1220
+
1221
+ def _find_addr_baseline(pose_data, swing_phases, frame_w, frame_h, max_scan=20):
1222
+ """Choose a robust address baseline: largest valid pelvis width in early setup,
1223
+ with a dynamic minimum tied to shoulder width to avoid collapsed baselines."""
1224
+ setup = swing_phases.get("setup", [])
1225
+ if not setup:
1226
+ return None, None
1227
+
1228
+ # Estimate shoulder width scale across early setup
1229
+ sh_ws = []
1230
+ for f in setup[:max_scan]:
1231
+ kp = pose_data.get(f)
1232
+ if not kp or len(kp) <= 12:
1233
+ continue
1234
+ kp = to_pixels_if_needed(kp, frame_w, frame_h)
1235
+ a, b = kp[11], kp[12]
1236
+ if a and b and len(a) >= 2 and len(b) >= 2:
1237
+ w = abs(b[0] - a[0])
1238
+ if w > 1.0:
1239
+ sh_ws.append(w)
1240
+ sh_med = float(np.median(sh_ws)) if sh_ws else None
1241
+
1242
+ # Dynamic pelvis min width: at least 12 px, or 25% of shoulder width if known
1243
+ MIN_W = 12.0
1244
+ if sh_med is not None:
1245
+ MIN_W = max(MIN_W, 0.25 * sh_med)
1246
+
1247
+ # Collect pelvis widths and pick the largest valid one
1248
+ cands = []
1249
+ for f in setup[:max_scan]:
1250
+ w = _pelvis_width_at(pose_data, f, frame_w, frame_h)
1251
+ if w is not None and w >= MIN_W:
1252
+ cands.append((f, w))
1253
+ if not cands:
1254
+ return None, None
1255
+
1256
+ best = max(cands, key=lambda t: t[1]) # <- widest pelvis = best baseline
1257
+ return best # (addr_idx_used, dx_addr_used)
1258
+
1259
+
1260
+ # Hip turn calculation function removed per user request
1261
+
1262
+
1263
+ # Hip turn calculation function removed per user request
1264
+
1265
+
1266
+ def clamp_or_none(val, lo, hi):
1267
+ """Clamp value to range or return None if outside bounds"""
1268
+ if val is None:
1269
+ return None
1270
+ return val if lo <= val <= hi else None
1271
+
1272
+
1273
+ def validate_pixel_coordinates(pose_data, frame_w=1920, frame_h=1080):
1274
+ """Validate that keypoints appear to be in pixel coordinates with reasonable scales
1275
 
1276
+ This function can be used to verify the hip turn fix is working correctly.
1277
+ If hip_width values are still <1 (e.g. '0.6px'), it means coordinates are still normalized.
1278
+ After the fix, hip widths should be in the 30-400px range.
 
 
1279
 
1280
+ Args:
1281
+ pose_data (dict): Dictionary mapping frame indices to pose keypoints
1282
+ frame_w (int): Expected frame width
1283
+ frame_h (int): Expected frame height
1284
+
1285
+ Returns:
1286
+ dict: Validation results with scale estimates
1287
+ """
1288
+ if not pose_data:
1289
+ return {'valid': False, 'reason': 'no_data'}
1290
 
1291
+ # Sample a few frames for analysis
1292
+ sample_frames = list(pose_data.keys())[:min(5, len(pose_data))]
1293
 
1294
+ scale_issues = []
1295
+ hip_widths = []
1296
+ shoulder_widths = []
1297
 
1298
+ for frame_idx in sample_frames:
1299
+ kp = pose_data[frame_idx]
1300
+ if not kp or len(kp) < 25:
1301
+ continue
1302
+
1303
+ # Convert to pixels if needed
1304
+ kp = to_pixels_if_needed(kp, frame_w, frame_h)
1305
+
1306
+ # Check hip width
1307
+ if kp[23] and kp[24]:
1308
+ hip_width = abs(kp[24][0] - kp[23][0])
1309
+ hip_widths.append(hip_width)
1310
+ if not (30 <= hip_width <= 400):
1311
+ scale_issues.append(f'hip_width={hip_width:.1f}px (expected 30-400px)')
1312
+
1313
+ # Check shoulder width
1314
+ if kp[11] and kp[12]:
1315
+ shoulder_width = abs(kp[12][0] - kp[11][0])
1316
+ shoulder_widths.append(shoulder_width)
1317
+ if not (50 <= shoulder_width <= 500):
1318
+ scale_issues.append(f'shoulder_width={shoulder_width:.1f}px (expected 50-500px)')
1319
+
1320
+ result = {
1321
+ 'valid': len(scale_issues) == 0,
1322
+ 'scale_issues': scale_issues,
1323
+ 'hip_width_median': np.median(hip_widths) if hip_widths else None,
1324
+ 'shoulder_width_median': np.median(shoulder_widths) if shoulder_widths else None,
1325
+ 'frame_dimensions': (frame_w, frame_h)
1326
+ }
1327
+
1328
+ # Add diagnostic message for hip turn fix verification
1329
+ if result['hip_width_median'] is not None:
1330
+ if result['hip_width_median'] < 5:
1331
+ result['coordinate_status'] = 'likely_normalized'
1332
+ result['hip_turn_fix_needed'] = True
1333
+ else:
1334
+ result['coordinate_status'] = 'likely_pixels'
1335
+ result['hip_turn_fix_needed'] = False
1336
+
1337
+ return result
1338
+
1339
+
1340
+ def validate_metric_ranges(metrics):
1341
+ """Validate that computed metrics fall within realistic golf ranges
1342
+
1343
+ Args:
1344
+ metrics (dict): Dictionary of computed metrics
1345
 
1346
+ Returns:
1347
+ dict: Validation results with range checks
1348
+ """
1349
+ validations = {}
1350
+
1351
+ # Define realistic ranges for golf metrics
1352
+ ranges = {
1353
+ 'shoulder_plane_top_deg': (10, 70),
1354
+ 'back_tilt_setup_deg': (15, 60),
1355
+ 'knee_flexion_deg': (5, 45),
1356
+ 'head_height_change_in': (-3, 3), # Address → Impact: within ±1″ of address is great; 1–2.5″ = caution; > 2.5″ = likely EE/low-point issues
1357
+ # Hip depth and hip turn metrics removed
1358
+ }
1359
+
1360
+ for metric_name, (min_val, max_val) in ranges.items():
1361
+ if metric_name in metrics:
1362
+ value = metrics[metric_name]
1363
+ if value is not None:
1364
+ in_range = min_val <= value <= max_val
1365
+ validations[metric_name] = {
1366
+ 'value': value,
1367
+ 'range': (min_val, max_val),
1368
+ 'valid': in_range,
1369
+ 'reason': 'ok' if in_range else f'outside_range_{min_val}-{max_val}'
1370
+ }
1371
+ else:
1372
+ validations[metric_name] = {
1373
+ 'value': None,
1374
+ 'valid': False,
1375
+ 'reason': 'null_value'
1376
+ }
1377
+
1378
+ overall_valid = all(v['valid'] for v in validations.values())
1379
+
1380
+ return {
1381
+ 'overall_valid': overall_valid,
1382
+ 'metric_validations': validations
1383
+ }
1384
+
1385
+
1386
+ def compute_dtl_three(pose_data, swing_phases, frame_w, frame_h, frames=None, camera_side="trail"):
1387
+ """Compute DTL metrics with proper unit handling including head height change
1388
+
1389
+ Metrics included:
1390
+ - Shoulder plane at top (degrees)
1391
+ - Back tilt at address (degrees)
1392
+ - Knee flexion at address (degrees)
1393
+ - Head height change: drop at top and change at impact (inches)
1394
+
1395
+ Args:
1396
+ pose_data (dict): Dictionary mapping frame indices to pose keypoints
1397
+ swing_phases (dict): Dictionary mapping phase names to lists of frame indices
1398
+ frame_w (int): Frame width in pixels
1399
+ frame_h (int): Frame height in pixels
1400
+ frames (list, optional): Video frames for analysis
1401
+ camera_side (str): "trail" or "lead" - which side camera is on
1402
 
1403
+ Returns:
1404
+ dict: DTL metrics including head height change (replaces hip turn)
1405
+ """
1406
+ setup_frames = swing_phases.get("setup", [])
1407
+ backswing_frames = swing_phases.get("backswing", [])
1408
+ impact_frames = swing_phases.get("impact", [])
1409
+
1410
+ if not setup_frames:
1411
+ return None
1412
+
1413
+ addr_idx = setup_frames[0]
1414
+ top_idx = backswing_frames[-1] if backswing_frames else addr_idx
1415
+ impact_idx = impact_frames[0] if impact_frames else addr_idx
1416
+
1417
+ if None in (addr_idx, top_idx, impact_idx):
1418
+ return None
1419
+
1420
+ # Calculate each metric
1421
+ shoulder_top = calculate_shoulder_tilt_swing_plane_at_top(pose_data, swing_phases, top_idx, frames)
1422
+ back_tilt = calculate_back_tilt_degree(pose_data, swing_phases, address_idx=addr_idx, frames=frames)
1423
+
1424
+ knee_data = calculate_knee_bend_degree(pose_data, addr_idx)
1425
+ knee_avg = knee_data.get("average_flexion") if knee_data else None
1426
+
1427
+ # Calculate head height change (replacing hip turn metric)
1428
+ # Get inch scale using shoulder width or fallback
1429
+ inch_scale = calculate_inch_scale_from_shoulders(pose_data, addr_idx, frame_w, frame_h)
1430
+ head_height_change_in = None
1431
+ if inch_scale is not None:
1432
+ head_height_change_in = head_height_change(
1433
+ pose_data, addr_idx, top_idx, impact_idx, inch_scale
1434
+ )
1435
+
1436
+ # Hip depth/early extension metric removed per user request
1437
+ ee_pct = None
1438
+ # Hip turn metric removed per user request
1439
+ # All confidence calculations removed per user request
1440
+
1441
+ metrics = {
1442
+ "shoulder_plane_top_deg": shoulder_top,
1443
+ "back_tilt_setup_deg": back_tilt,
1444
+ "knee_flexion_deg": knee_avg,
1445
+ "head_height_change_in": round(head_height_change_in, 2) if head_height_change_in is not None else None,
1446
+ "inch_scale": round(inch_scale, 3) if inch_scale is not None else None,
1447
+ # Hip depth and hip turn metrics removed per user request
1448
+ # All confidence calculations removed per user request
1449
+ }
1450
+
1451
+ # Validate coordinate scales
1452
+ coord_validation = validate_pixel_coordinates(pose_data, frame_w, frame_h)
1453
+
1454
+ # Validate metric ranges
1455
+ metric_validation = validate_metric_ranges(metrics)
1456
+
1457
+ # Add validation info to result
1458
+ metrics['_validation'] = {
1459
+ 'coordinates': coord_validation,
1460
+ 'metrics': metric_validation
1461
+ }
1462
+
1463
+ return metrics
1464
+
1465
+
1466
+ def compute_dtl_five_metrics(pose_data, swing_phases, frames=None, frame_w=None, frame_h=None):
1467
+ """DEPRECATED - use compute_dtl_three instead (hip depth and hip turn removed)
1468
+
1469
+ This function is kept for backward compatibility.
1470
+ """
1471
+ # Try to get frame dimensions if not provided
1472
+ if frame_w is None or frame_h is None:
1473
+ frame_w = frame_w or 1920
1474
+ frame_h = frame_h or 1080
1475
+
1476
+ result = compute_dtl_three(pose_data, swing_phases, frame_w, frame_h, frames)
1477
+
1478
+ if result is None:
1479
  return None
1480
+
1481
+ # Apply clamp_or_none for backward compatibility
1482
+ return {
1483
+ "shoulder_plane_top_deg": clamp_or_none(result.get("shoulder_plane_top_deg"), 20, 50),
1484
+ "back_tilt_setup_deg": clamp_or_none(result.get("back_tilt_setup_deg"), 25, 50),
1485
+ "knee_flexion_deg": clamp_or_none(result.get("knee_flexion_deg"), 15, 45),
1486
+ # Hip depth and hip turn metrics removed per user request
1487
+ }
1488
 
1489
 
1490
  def generate_diagnostic_overlay(pose_data, swing_phases, frames=None):
 
1612
  return diagnostic_data
1613
 
1614
  except Exception as e:
1615
+ return {'error': str(e)}
app/models/pose_estimator.py CHANGED
@@ -26,23 +26,37 @@ class PoseEstimator:
26
 
27
  if results.pose_landmarks:
28
  keypoints = []
 
29
  total_visibility = 0
 
 
30
  for landmark in results.pose_landmarks.landmark:
31
  x, y = int(landmark.x * w), int(landmark.y * h)
32
  visibility = landmark.visibility
33
  keypoints.append([x, y, visibility])
34
  total_visibility += visibility
35
 
 
 
 
 
 
 
 
 
36
  # Check if this is a reasonable pose detection
37
  avg_visibility = total_visibility / len(results.pose_landmarks.landmark)
38
  if avg_visibility > 0.3: # Only return if average visibility is decent
39
- return keypoints
40
  else:
41
  # Poor quality detection, return fallback
42
- return [[w//2, h//2, 0.05] for _ in range(33)]
 
 
43
  else:
44
  # No pose detected, return very low confidence markers
45
- return [[w//2, h//2, 0.05] for _ in range(33)]
 
46
 
47
  def close(self):
48
  self.pose.close()
@@ -55,18 +69,23 @@ def analyze_pose(frames):
55
  frames (list): List of video frames
56
 
57
  Returns:
58
- dict: Dictionary mapping frame indices to pose keypoints
 
 
59
  """
60
  pose_estimator = PoseEstimator()
61
  pose_data = {}
 
62
 
63
  for i, frame in enumerate(tqdm(frames, desc="Analyzing pose")):
64
- keypoints = pose_estimator.process_frame(frame)
65
  # Always store keypoints (never None due to fallback in process_frame)
66
  pose_data[i] = keypoints
 
 
67
 
68
  pose_estimator.close()
69
- return pose_data
70
 
71
 
72
  # Deprecated - joint angle calculations removed (not part of 5 core metrics)
 
26
 
27
  if results.pose_landmarks:
28
  keypoints = []
29
+ world_landmarks = []
30
  total_visibility = 0
31
+
32
+ # Extract 2D keypoints
33
  for landmark in results.pose_landmarks.landmark:
34
  x, y = int(landmark.x * w), int(landmark.y * h)
35
  visibility = landmark.visibility
36
  keypoints.append([x, y, visibility])
37
  total_visibility += visibility
38
 
39
+ # Extract 3D world landmarks if available
40
+ if results.pose_world_landmarks:
41
+ for landmark in results.pose_world_landmarks.landmark:
42
+ # World landmarks are in meters relative to hip center
43
+ world_landmarks.append([landmark.x, landmark.y, landmark.z])
44
+ else:
45
+ world_landmarks = None
46
+
47
  # Check if this is a reasonable pose detection
48
  avg_visibility = total_visibility / len(results.pose_landmarks.landmark)
49
  if avg_visibility > 0.3: # Only return if average visibility is decent
50
+ return keypoints, world_landmarks
51
  else:
52
  # Poor quality detection, return fallback
53
+ fallback_2d = [[w//2, h//2, 0.05] for _ in range(33)]
54
+ fallback_3d = [[0.0, 0.0, 0.0] for _ in range(33)] if world_landmarks else None
55
+ return fallback_2d, fallback_3d
56
  else:
57
  # No pose detected, return very low confidence markers
58
+ fallback_2d = [[w//2, h//2, 0.05] for _ in range(33)]
59
+ return fallback_2d, None
60
 
61
  def close(self):
62
  self.pose.close()
 
69
  frames (list): List of video frames
70
 
71
  Returns:
72
+ tuple: (pose_data, world_landmarks) where:
73
+ - pose_data: Dictionary mapping frame indices to 2D pose keypoints
74
+ - world_landmarks: Dictionary mapping frame indices to 3D world landmarks
75
  """
76
  pose_estimator = PoseEstimator()
77
  pose_data = {}
78
+ world_landmarks = {}
79
 
80
  for i, frame in enumerate(tqdm(frames, desc="Analyzing pose")):
81
+ keypoints, world_lm = pose_estimator.process_frame(frame)
82
  # Always store keypoints (never None due to fallback in process_frame)
83
  pose_data[i] = keypoints
84
+ if world_lm is not None:
85
+ world_landmarks[i] = world_lm
86
 
87
  pose_estimator.close()
88
+ return pose_data, world_landmarks
89
 
90
 
91
  # Deprecated - joint angle calculations removed (not part of 5 core metrics)
app/models/swing_analyzer.py CHANGED
@@ -149,10 +149,7 @@ def calculate_shaft_angle_at_top(pose_data, top_frame, target_line_vec=None):
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
 
 
149
  while angle_deg < -90:
150
  angle_deg += 180
151
 
152
+ # Always return the calculated angle - no hiding based on magnitude
 
 
 
153
  return angle_deg
154
 
155
 
app/streamlit_app.py CHANGED
@@ -193,7 +193,7 @@ def format_metric_value(metric_data, unit=""):
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
  # Note: Not hiding any values - display all measurements
199
  # Even extreme values might be valid for certain swings or camera angles
@@ -238,7 +238,7 @@ def get_shaft_angle_grading(value, confidence):
238
  def get_head_sway_grading(value, confidence):
239
  """Grade head sway with percentage of pelvis/shoulder width"""
240
  if value is None:
241
- return None # Hide if not available
242
 
243
  # Note: Not hiding any values - display all measurements
244
  # Even high values might be valid for certain swings or camera setups
@@ -270,7 +270,7 @@ def get_head_sway_grading(value, confidence):
270
  def get_back_tilt_grading(value, confidence, camera_roll=0):
271
  """Grade back tilt at setup"""
272
  if value is None:
273
- return None # Hide if not available
274
  # Note: Not hiding based on camera roll - display all measurements
275
 
276
  # Determine grading
@@ -288,10 +288,12 @@ def get_back_tilt_grading(value, confidence, camera_roll=0):
288
  label = "Likely problematic"
289
 
290
  return {
 
291
  'display_value': f"{value:.1f}°",
292
  'badge': badge,
293
  'label': label,
294
  'confidence': confidence,
 
295
  'tip': "Typical tour range ≈30–40°." if badge != "🟢" else None
296
  }
297
 
@@ -299,7 +301,7 @@ def get_back_tilt_grading(value, confidence, camera_roll=0):
299
  def get_knee_flexion_grading(value, confidence):
300
  """Grade knee flexion at setup"""
301
  if value is None:
302
- return None
303
 
304
  # Determine grading
305
  if 15 <= value <= 30:
@@ -573,10 +575,12 @@ def get_hip_depth_grading(hip_depth_data, confidence):
573
  tip += f"\n\nTechnical: Hip width {hip_depth_data.get('hip_width_ref', 0):.1f}px, threshold {adaptive_threshold:.1f}px"
574
 
575
  return {
 
576
  'display_value': f'{depth_loss_pct:.1f}% loss{confidence_note}',
577
  'badge': badge,
578
  'label': label,
579
  'confidence': confidence,
 
580
  'tip': tip,
581
  'calculation_confidence': calculation_confidence,
582
  'adaptive_threshold': adaptive_threshold
@@ -586,7 +590,7 @@ def get_hip_depth_grading(hip_depth_data, confidence):
586
  def get_shaft_address_grading(angle_value, confidence):
587
  """Generate grading for shaft angle at address"""
588
  if angle_value is None:
589
- return None
590
 
591
  if 45 <= angle_value <= 65:
592
  badge = '🟢'
@@ -646,7 +650,7 @@ def get_head_displacement_grading(displacement_data, confidence):
646
  def get_shoulder_rotation_grading(value, confidence, position="top"):
647
  """Grade shoulder rotation angle"""
648
  if value is None:
649
- return None
650
 
651
  # Professional ranges: Top ~90-110°, Impact ~30-50°
652
  if position == "top":
@@ -688,7 +692,7 @@ def get_shoulder_rotation_grading(value, confidence, position="top"):
688
  def get_hip_rotation_grading(value, confidence, position="top"):
689
  """Grade hip rotation angle"""
690
  if value is None:
691
- return None
692
 
693
  # Professional ranges: Top ~45-60°, Impact ~40-60°
694
  if position == "top":
@@ -727,52 +731,114 @@ def get_hip_rotation_grading(value, confidence, position="top"):
727
  }
728
 
729
 
730
- def get_x_factor_grading(value, confidence, position="top"):
731
- """Grade X-Factor (shoulder-hip separation)"""
 
732
  if value is None:
733
- return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
734
 
735
- # Professional ranges: Top ~45-60°, Impact ~10-25°
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
736
  if position == "top":
737
- if 40 <= value <= 65:
738
  badge = '🟢'
739
- label = 'Excellent separation'
740
- tip = 'Great coil between shoulders and hips for power'
741
- elif 30 <= value < 40 or 65 < value <= 75:
742
  badge = '🟠'
743
- label = 'Good separation'
744
- tip = 'Solid differential with room for improvement'
745
  else:
746
  badge = '🔴'
747
  label = 'Needs improvement'
748
- tip = 'Work on creating more separation for power'
749
  else: # impact
750
- if 10 <= value <= 30:
751
  badge = '🟢'
752
- label = 'Excellent timing'
753
- tip = 'Great sequence of unwinding through impact'
754
- elif 5 <= value < 10 or 30 < value <= 40:
755
  badge = '🟠'
756
- label = 'Good timing'
757
- tip = 'Solid sequence with room for optimization'
758
  else:
759
  badge = '🔴'
760
  label = 'Needs improvement'
761
- tip = 'Focus on proper kinematic sequence timing'
762
 
763
  return {
764
- 'display_value': f"{value:.1f}°",
765
  'badge': badge,
766
  'label': label,
767
- 'confidence': confidence,
768
- 'tip': tip
769
  }
770
 
771
 
772
  def get_wrist_hinge_grading(value, confidence, position="top"):
773
  """Grade wrist hinge angle"""
774
  if value is None:
775
- return None
776
 
777
  # Professional ranges: Top ~90-120°, Impact ~15-35°
778
  if position == "top":
@@ -812,26 +878,30 @@ def get_wrist_hinge_grading(value, confidence, position="top"):
812
 
813
 
814
  def get_head_lateral_sway_grading(value, confidence):
815
- """Grade head lateral sway in centimeters"""
816
  if value is None:
817
- return None
818
 
819
- # Professional ranges: <3cm excellent, 3-6cm good, >6cm needs work
820
- if value <= 3:
821
  badge = '🟢'
822
  label = 'Excellent stability'
823
  tip = 'Outstanding head stability throughout swing'
824
- elif value <= 6:
825
- badge = '🟠'
826
  label = 'Good stability'
827
  tip = 'Solid head control with minor movement'
828
- else:
 
 
 
 
829
  badge = '🔴'
830
  label = 'Excessive sway'
831
  tip = 'Work on keeping head stable for consistent contact'
832
 
833
  return {
834
- 'display_value': f"{value:.1f}cm",
835
  'badge': badge,
836
  'label': label,
837
  'confidence': confidence,
@@ -842,28 +912,24 @@ def get_head_lateral_sway_grading(value, confidence):
842
  def display_new_grading_scheme(core_metrics):
843
  """Display the swing analysis with badges and confidence indicators"""
844
  # Check if we have front-facing metrics
 
845
  has_front_facing_metrics = any(key in core_metrics for key in [
846
- 'shoulder_rotation_top_deg', 'hip_rotation_top_deg', 'x_factor_top_deg',
847
- 'wrist_hinge_top_deg', 'head_lateral_sway_cm'
848
  ])
 
849
 
850
  if has_front_facing_metrics:
851
- st.subheader("Front-Facing Swing Analysis - Enhanced Metrics")
852
- st.info("🎯 **Front-Facing View Detected**: You're getting the full 5-metric analysis with precise angular measurements!")
853
  else:
854
  st.subheader("Down-the-Line Swing Analysis")
855
 
856
  # Extract raw values and calculate confidence (simplified for now)
857
- shaft_angle_data = core_metrics.get("shaft_angle_top", {})
858
- head_sway_data = core_metrics.get("head_sway_pct", {})
859
- back_tilt_data = core_metrics.get("back_tilt_deg", {})
860
- knee_flexion_data = core_metrics.get("knee_bend_deg", {})
861
- wrist_pattern_data = core_metrics.get("wrist_pattern", {})
862
- shoulder_turn_data = core_metrics.get("shoulder_turn_quality", {})
863
  # New DTL metrics
 
 
 
864
  hip_depth_data = core_metrics.get("hip_depth_early_extension", {})
865
- shaft_address_data = core_metrics.get("shaft_angle_address", {})
866
- head_displacement_data = core_metrics.get("head_vertical_displacement", {})
867
 
868
  # Calculate confidence with QC penalties as per feedback
869
  def get_confidence(data, metric_type='general'):
@@ -943,26 +1009,17 @@ def display_new_grading_scheme(core_metrics):
943
  # Process each metric
944
  metrics_to_display = []
945
 
946
- # 1. Shaft Plane @ Top (Qualitative)
947
- shaft_value = shaft_angle_data.get('value')
948
- # Always try to show qualitative assessment, even if raw number was rejected
949
- if shaft_value is not None or shaft_angle_data.get('status') in ['approximate', 'club_not_visible']:
950
- # For qualitative assessment, use any available value or status info
951
- raw_shaft = shaft_value if shaft_value is not None else 0 # Default for status-only cases
952
- shaft_confidence = get_confidence(shaft_angle_data, 'shaft_angle')
953
- shaft_grading = get_qualitative_shaft_angle_grading(raw_shaft, shaft_confidence)
954
- if shaft_grading:
955
- metrics_to_display.append(("Shaft Plane @ Top", shaft_grading))
956
-
957
- # 2. Head Sway
958
- sway_value = head_sway_data.get('value')
959
- if sway_value is not None:
960
- sway_confidence = get_confidence(head_sway_data, 'head_sway')
961
- # Hide if confidence is 0 (indicates unstable scale)
962
- if sway_confidence > 0:
963
- sway_grading = get_head_sway_grading(sway_value, sway_confidence)
964
- if sway_grading:
965
- metrics_to_display.append(("Head Sway", sway_grading))
966
 
967
  # 3. Back Tilt @ Setup
968
  tilt_value = back_tilt_data.get('value')
@@ -1011,75 +1068,35 @@ def display_new_grading_scheme(core_metrics):
1011
  }
1012
  metrics_to_display.append(("Hip Depth / Early Extension", error_grading))
1013
  else:
1014
- # Debug: Check why hip depth value is None
1015
- st.caption(f"Debug: Hip depth data: {hip_depth_data}")
1016
-
1017
- # 9. Shaft Angle at Address (New DTL metric)
1018
- shaft_address_value = shaft_address_data.get('value')
1019
- if shaft_address_value is not None:
1020
- shaft_address_confidence = get_confidence(shaft_address_data, 'shaft_address')
1021
- shaft_address_grading = get_shaft_address_grading(shaft_address_value, shaft_address_confidence)
1022
- if shaft_address_grading:
1023
- metrics_to_display.append(("Shaft Angle @ Address", shaft_address_grading))
1024
-
1025
- # 10. Head Vertical Displacement (New DTL metric)
1026
- if head_displacement_data.get('value') is not None:
1027
- head_displacement_confidence = get_confidence(head_displacement_data, 'head_displacement')
1028
- # Pass the detailed_data (full dictionary) instead of just the value
1029
- detailed_data = head_displacement_data.get('detailed_data', {})
1030
- head_displacement_grading = get_head_displacement_grading(detailed_data, head_displacement_confidence)
1031
- if head_displacement_grading:
1032
- metrics_to_display.append(("Head Vertical Displacement", head_displacement_grading))
1033
 
1034
- # Front-facing metrics (only displayed when available)
 
 
 
 
1035
  if has_front_facing_metrics:
1036
- # Shoulder Rotation at Top
1037
- shoulder_rotation_top_data = core_metrics.get("shoulder_rotation_top_deg", {})
1038
- if shoulder_rotation_top_data.get('value') is not None:
1039
- confidence = 0.9 # High confidence for front-facing measurements
1040
- grading = get_shoulder_rotation_grading(shoulder_rotation_top_data['value'], confidence, "top")
1041
- if grading:
1042
- metrics_to_display.append(("Shoulder Rotation @ Top", grading))
1043
-
1044
- # Shoulder Rotation at Impact
1045
- shoulder_rotation_impact_data = core_metrics.get("shoulder_rotation_impact_deg", {})
1046
- if shoulder_rotation_impact_data.get('value') is not None:
1047
- confidence = 0.9
1048
- grading = get_shoulder_rotation_grading(shoulder_rotation_impact_data['value'], confidence, "impact")
1049
- if grading:
1050
- metrics_to_display.append(("Shoulder Rotation @ Impact", grading))
1051
 
1052
- # Hip Rotation at Top
1053
- hip_rotation_top_data = core_metrics.get("hip_rotation_top_deg", {})
1054
- if hip_rotation_top_data.get('value') is not None:
1055
- confidence = 0.9
1056
- grading = get_hip_rotation_grading(hip_rotation_top_data['value'], confidence, "top")
1057
  if grading:
1058
- metrics_to_display.append(("Hip Rotation @ Top", grading))
1059
 
1060
- # Hip Rotation at Impact
1061
- hip_rotation_impact_data = core_metrics.get("hip_rotation_impact_deg", {})
1062
- if hip_rotation_impact_data.get('value') is not None:
1063
- confidence = 0.9
1064
- grading = get_hip_rotation_grading(hip_rotation_impact_data['value'], confidence, "impact")
1065
- if grading:
1066
- metrics_to_display.append(("Hip Rotation @ Impact", grading))
1067
 
1068
- # X-Factor at Top
1069
- x_factor_top_data = core_metrics.get("x_factor_top_deg", {})
1070
- if x_factor_top_data.get('value') is not None:
1071
- confidence = 0.85 # Slightly lower as it's a derived metric
1072
- grading = get_x_factor_grading(x_factor_top_data['value'], confidence, "top")
1073
  if grading:
1074
- metrics_to_display.append(("X-Factor @ Top", grading))
1075
 
1076
- # X-Factor at Impact
1077
- x_factor_impact_data = core_metrics.get("x_factor_impact_deg", {})
1078
- if x_factor_impact_data.get('value') is not None:
1079
- confidence = 0.85
1080
- grading = get_x_factor_grading(x_factor_impact_data['value'], confidence, "impact")
1081
- if grading:
1082
- metrics_to_display.append(("X-Factor @ Impact", grading))
1083
 
1084
  # Wrist Hinge at Top
1085
  wrist_hinge_top_data = core_metrics.get("wrist_hinge_top_deg", {})
@@ -1088,22 +1105,6 @@ def display_new_grading_scheme(core_metrics):
1088
  grading = get_wrist_hinge_grading(wrist_hinge_top_data['value'], confidence, "top")
1089
  if grading:
1090
  metrics_to_display.append(("Wrist Hinge @ Top", grading))
1091
-
1092
- # Wrist Hinge at Impact
1093
- wrist_hinge_impact_data = core_metrics.get("wrist_hinge_impact_deg", {})
1094
- if wrist_hinge_impact_data.get('value') is not None:
1095
- confidence = 0.8
1096
- grading = get_wrist_hinge_grading(wrist_hinge_impact_data['value'], confidence, "impact")
1097
- if grading:
1098
- metrics_to_display.append(("Wrist Hinge @ Impact", grading))
1099
-
1100
- # Head Lateral Sway
1101
- head_lateral_sway_data = core_metrics.get("head_lateral_sway_cm", {})
1102
- if head_lateral_sway_data.get('value') is not None:
1103
- confidence = 0.85
1104
- grading = get_head_lateral_sway_grading(head_lateral_sway_data['value'], confidence)
1105
- if grading:
1106
- metrics_to_display.append(("Head Lateral Sway", grading))
1107
 
1108
  # Display each metric
1109
  for metric_name, grading in metrics_to_display:
@@ -1121,6 +1122,11 @@ def display_metric_card(metric_name, grading):
1121
  # Metric header
1122
  st.subheader(metric_name)
1123
 
 
 
 
 
 
1124
  # Result line with badge and label
1125
  result_line = f"**{grading['display_value']}** — {grading['badge']} {grading.get('label', '')}"
1126
  st.markdown(result_line)
@@ -1145,18 +1151,41 @@ def display_metric_card(metric_name, grading):
1145
 
1146
  # Add tip if available using info box
1147
  if grading.get('tip'):
1148
- st.info(f"💡 **Tip:** {grading['tip']}")
1149
 
1150
  # Add spacing
1151
  st.write("")
1152
 
1153
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1154
  def get_metric_evaluation(metric_name, grading):
1155
  """Generate detailed evaluation text for each metric"""
1156
  value = grading.get('display_value', 'Unknown')
1157
  badge = grading.get('badge', '')
1158
 
1159
- if metric_name == "Back Tilt @ Setup":
 
 
 
 
 
 
 
 
 
1160
  if "🟢" in badge:
1161
  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."
1162
  elif "🟠" in badge:
@@ -1172,84 +1201,41 @@ def get_metric_evaluation(metric_name, grading):
1172
  else:
1173
  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."
1174
 
1175
- # Removed evaluation text for: Wrist Pattern, Kinematic Sequence, and Shoulder Turn Quality
1176
-
1177
- elif "Shaft Plane" in metric_name or "Shaft Angle" in metric_name:
1178
- if grading.get('value') is None or str(value) == "None" or "unavailable" in str(value).lower():
1179
- return f"Shaft angle analysis is **unavailable due to insufficient data**. This metric measures the club shaft angle at address relative to the ground, affecting swing plane and setup position. Proper shaft angle promotes consistent ball striking and accuracy."
1180
- elif "🟢" in badge:
1181
- return f"Your shaft angle of **{value}** shows excellent setup position. This optimal shaft angle at address promotes proper swing plane, consistent ball striking, and accuracy. Good shaft positioning contributes to repeatable swing mechanics and shot control."
1182
- elif "🟠" in badge:
1183
- return f"Your shaft angle of **{value}** is workable but could be optimized. Shaft angle at address affects swing plane, ball flight direction, and contact quality. Minor adjustments to your setup could enhance consistency and shot quality."
1184
- else:
1185
- return f"Your shaft angle of **{value}** needs attention for better performance. Proper shaft angle at address is crucial for swing plane, ball flight control, and consistency. Improving shaft positioning will enhance accuracy and overall shot quality."
1186
-
1187
- elif "Head Sway" in metric_name:
1188
  if "🟢" in badge:
1189
- 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."
1190
  elif "🟠" in badge:
1191
- 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."
1192
  else:
1193
- 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."
1194
 
1195
- elif "Shoulder Rotation" in metric_name:
1196
- position = "top" if "Top" in metric_name else "impact"
1197
- if "🟢" in badge:
1198
- if position == "top":
1199
- return f"Your shoulder rotation of **{value}** at the top shows excellent turn. This full shoulder rotation creates proper swing width and sets up powerful clubhead speed generation. Great shoulder mobility contributes significantly to distance and consistency."
1200
- else:
1201
- return f"Your shoulder rotation of **{value}** at impact shows excellent unwinding. This proper rotation through impact promotes square contact, accuracy, and efficient power transfer to the ball."
1202
- elif "🟠" in badge:
1203
- return f"Your shoulder rotation of **{value}** is adequate but has room for improvement. Better shoulder rotation enhances swing width, power potential, and timing consistency."
1204
- else:
1205
- return f"Your shoulder rotation of **{value}** needs attention. Limited shoulder rotation restricts power generation and can affect swing plane consistency. Work on shoulder mobility and turn."
1206
 
1207
- elif "Hip Rotation" in metric_name:
1208
- position = "top" if "Top" in metric_name else "impact"
1209
  if "🟢" in badge:
1210
- if position == "top":
1211
- return f"Your hip rotation of **{value}** at the top shows excellent coil. This hip turn creates the foundation for powerful unwinding and proper kinematic sequencing from the ground up."
1212
- else:
1213
- return f"Your hip rotation of **{value}** at impact shows excellent clearing. This hip action leads the downswing sequence, creating space for the arms and promoting consistent ball striking."
1214
  elif "🟠" in badge:
1215
- return f"Your hip rotation of **{value}** is solid with room for optimization. Improved hip rotation enhances power generation and sequence timing."
1216
  else:
1217
- return f"Your hip rotation of **{value}** needs development. Limited hip rotation restricts power potential and can disrupt proper swing sequence. Focus on hip mobility and turn."
1218
 
1219
- elif "X-Factor" in metric_name:
1220
- position = "top" if "Top" in metric_name else "impact"
1221
  if "🟢" in badge:
1222
- if position == "top":
1223
- return f"Your X-Factor of **{value}** at the top shows excellent separation. This differential between shoulder and hip rotation creates powerful coil energy that translates to clubhead speed and distance."
1224
- else:
1225
- return f"Your X-Factor of **{value}** at impact shows excellent sequence timing. This separation indicates proper unwinding sequence from hips to shoulders, optimizing power transfer and accuracy."
1226
  elif "🟠" in badge:
1227
- return f"Your X-Factor of **{value}** is good with room for improvement. Better separation between shoulders and hips can enhance power generation and sequence efficiency."
1228
  else:
1229
- return f"Your X-Factor of **{value}** needs attention. Limited separation reduces power potential and may indicate sequence timing issues. Work on creating more differential between hip and shoulder rotation."
1230
 
1231
- elif "Wrist Hinge" in metric_name:
1232
- position = "top" if "Top" in metric_name else "impact"
1233
  if "🟢" in badge:
1234
- if position == "top":
1235
- return f"Your wrist hinge of **{value}** at the top shows excellent set. This proper wrist angle stores energy and sets up lag for powerful release through impact."
1236
- else:
1237
- return f"Your wrist hinge of **{value}** at impact shows excellent lag retention. This delayed release maximizes clubhead speed and promotes solid ball compression."
1238
  elif "🟠" in badge:
1239
- return f"Your wrist hinge of **{value}** is adequate but could be optimized. Better wrist action enhances lag, power, and strike consistency."
1240
  else:
1241
- if position == "top":
1242
- return f"Your wrist hinge of **{value}** at the top needs improvement. Proper wrist set is crucial for creating lag and power. Work on wrist mobility and hinge timing."
1243
- else:
1244
- return f"Your wrist hinge of **{value}** at impact suggests early release. Focus on maintaining lag longer through the downswing for more power and better ball compression."
1245
 
1246
- elif "Head Lateral Sway" in metric_name:
1247
- if "🟢" in badge:
1248
- return f"Your head lateral sway of **{value}** shows outstanding stability. Minimal head movement maintains consistent swing center and promotes precise ball contact, distance control, and accuracy."
1249
- elif "🟠" in badge:
1250
- return f"Your head lateral sway of **{value}** shows good control with minor movement. This level of head stability supports consistent contact while allowing natural body rotation."
1251
- else:
1252
- return f"Your head lateral sway of **{value}** indicates excessive movement. Too much head sway disrupts swing center consistency, leading to contact issues and directional problems. Focus on head stability."
1253
 
1254
  else:
1255
  # Default evaluation for any other metrics
@@ -1345,19 +1331,21 @@ Swing Phases:
1345
  Timing Metrics:
1346
  - Total Swing Time: {structured_analysis.get('timing_metrics', {}).get('total_swing_time_ms', 'N/A')} ms
1347
 
1348
- === DTL-RELIABLE CORE METRICS ===
1349
- - Shaft Angle @ Top: {format_metric_value(core_metrics.get('shaft_angle_top', {}), '°')}
1350
- - Head Sway: {format_metric_value(core_metrics.get('head_sway_pct', {}), '% shoulder width')}
1351
  - Back Tilt @ Setup: {format_metric_value(core_metrics.get('back_tilt_deg', {}), '°')}
1352
- - Knee Bend @ Setup: {format_metric_value(core_metrics.get('knee_bend_deg', {}), '°')}
1353
- - Wrist Pattern: {format_metric_value(core_metrics.get('wrist_pattern', {}))}
 
1354
 
1355
  === DTL-LIMITED METRICS (Approximate) ===
1356
  - Shoulder Turn Quality: {format_metric_value(core_metrics.get('shoulder_turn_quality', {}))}
1357
 
1358
- === REQUIRES FACE-ON VIEW ===
1359
- - Hip Rotation @ Impact: {format_metric_value(core_metrics.get('hip_rotation_impact_deg', {}), '°')}
1360
- - X-Factor @ Top: {format_metric_value(core_metrics.get('x_factor_top_deg', {}), '°')}
 
 
1361
  """
1362
 
1363
  # Removed success message
@@ -1591,17 +1579,7 @@ def generate_enhanced_fallback_response(query, context_chunks, user_swing_contex
1591
  found_relevant_measurement = True
1592
  break
1593
 
1594
- elif "hip" in question_lower and ("rotation" in question_lower or "turn" in question_lower):
1595
- # Look for hip rotation measurements (only if asking about hip rotation/turn)
1596
- lines = analysis_content.split('\n')
1597
- for line in lines:
1598
- if 'hip rotation' in line.lower() and '°' in line:
1599
- import re
1600
- user_hip_match = re.search(r'-\s*hip rotation[:\s]*(\d+\.?\d*°)', line.lower())
1601
- if user_hip_match:
1602
- response_parts.append(f"I notice that your hip rotation is {user_hip_match.group(1)} during your swing.")
1603
- found_relevant_measurement = True
1604
- break
1605
 
1606
  elif "weight" in question_lower and ("transfer" in question_lower or "shift" in question_lower):
1607
  # Look for weight transfer measurements (only if asking about weight transfer/shift)
@@ -1725,14 +1703,6 @@ def main():
1725
  font-weight: bold;
1726
  }
1727
 
1728
-
1729
-
1730
-
1731
-
1732
-
1733
-
1734
-
1735
-
1736
  /* Fix text color visibility for mobile and all devices - EXCEPT buttons */
1737
  .stMarkdown, .stMarkdown p, .stMarkdown h1, .stMarkdown h2, .stMarkdown h3, .stMarkdown h4 {
1738
  color: #0B3B0B !important;
@@ -1919,20 +1889,37 @@ def render_step_1():
1919
  st.markdown('<h2 style="color: #0B3B0B; font-family: Georgia, serif;">Step 1: Upload Your Video</h2>', unsafe_allow_html=True)
1920
 
1921
  # Camera view selection
1922
- st.markdown("### 📹 Select Camera View")
1923
  camera_view = st.radio(
1924
  "Choose the camera angle of your video:",
1925
- options=["Down the Line (DTL)", "Front Facing (Face-On)"],
1926
- index=0, # Default to DTL
1927
  key="camera_view",
1928
  horizontal=True,
1929
  help="DTL: Camera positioned behind/in front of golfer along target line. Face-On: Camera positioned to the side of golfer."
1930
  )
1931
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1932
  st.markdown("**Choose your input method below.**")
1933
  st.markdown("💡 **Tips:**")
1934
  st.markdown("- Aim for a video of 5 seconds or less")
1935
- st.markdown("- Make sure the video is in a down the line view, not face forward")
1936
 
1937
  col1, col2 = st.columns(2)
1938
 
@@ -1948,6 +1935,11 @@ def render_step_1():
1948
 
1949
  # Analyze button
1950
  if st.button("🏌️ Start Analysis", key="start_analysis", use_container_width=True):
 
 
 
 
 
1951
  video_path = None
1952
 
1953
  if uploaded_file is not None:
@@ -1996,7 +1988,7 @@ def render_step_2():
1996
  st.success("✅ Video processing complete!")
1997
 
1998
  with st.spinner("Analyzing golfer's pose..."):
1999
- pose_data = analyze_pose(frames)
2000
  st.success("✅ Pose analysis complete!")
2001
 
2002
  with st.spinner("Segmenting swing phases..."):
@@ -2022,6 +2014,7 @@ def render_step_2():
2022
  'frames': frames,
2023
  'detections': detections,
2024
  'pose_data': pose_data,
 
2025
  'swing_phases': swing_phases,
2026
  'trajectory_data': trajectory_data,
2027
  'sample_rate': 1,
@@ -2116,9 +2109,12 @@ def render_step_4():
2116
  camera_view = st.session_state.analysis_data.get('camera_view', 'Down the Line (DTL)')
2117
  is_front_facing = camera_view == "Front Facing (Face-On)"
2118
 
 
 
 
2119
  # Force recomputation with new validation logic
2120
  # This ensures the latest fixes are applied
2121
- core_metrics = compute_core_metrics(pose_data, swing_phases, is_front_facing=is_front_facing, frames=data.get('frames'))
2122
 
2123
  # Update the cached analysis data with new metrics
2124
  if 'analysis_data' in data:
@@ -2190,29 +2186,27 @@ def render_step_4():
2190
  st.caption("Raw values for qualitative assessments:")
2191
 
2192
  # Shaft angle raw value
2193
- shaft_raw = core_metrics.get("shaft_angle_top", {}).get('value')
2194
- shaft_status = core_metrics.get("shaft_angle_top", {}).get('status', 'n/a')
2195
- st.caption(f"Shaft Angle @ Top: {shaft_raw}° ({shaft_status})")
2196
-
2197
- # Hip rotation raw value
2198
- hip_raw = core_metrics.get("hip_rotation_impact_deg", {}).get('value')
2199
- hip_status = core_metrics.get("hip_rotation_impact_deg", {}).get('status', 'n/a')
2200
- st.caption(f"Hip Rotation @ Impact: {hip_raw}° ({hip_status})")
2201
-
2202
- # X-factor raw value (bonus)
2203
- xfactor_raw = core_metrics.get("x_factor_top_deg", {}).get('value')
2204
- xfactor_status = core_metrics.get("x_factor_top_deg", {}).get('status', 'n/a')
2205
- st.caption(f"X-Factor @ Top: {xfactor_raw}° ({xfactor_status})")
2206
 
2207
  # Shoulder rotation raw value
2208
- shoulder_raw = core_metrics.get("shoulder_rotation_top_deg", {}).get('value')
2209
- shoulder_status = core_metrics.get("shoulder_rotation_top_deg", {}).get('status', 'n/a')
2210
- st.caption(f"Shoulder Rotation @ Top: {shoulder_raw}° ({shoulder_status})")
 
 
2211
 
2212
  # Hip depth raw value
2213
  hip_depth_raw = core_metrics.get("hip_depth_early_extension", {}).get('value')
2214
  hip_depth_status = core_metrics.get("hip_depth_early_extension", {}).get('status', 'n/a')
2215
  st.caption(f"Hip Depth / Early Extension: {hip_depth_raw} ({hip_depth_status})")
 
 
 
 
 
 
2216
 
2217
  with dev_col3:
2218
  if st.button("🔍 Prompt", key="show_prompt_btn", help="View LLM prompt"):
@@ -2235,7 +2229,7 @@ def render_step_5():
2235
  has_analysis = st.session_state.get('video_analyzed', False) and 'analysis_data' in st.session_state
2236
 
2237
  if not has_analysis:
2238
- st.info("💡 **Tip**: For personalized answers about your swing, complete the video analysis first. You can still ask general golf questions!")
2239
 
2240
  if RAG_AVAILABLE:
2241
  render_rag_interface()
 
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 {'display_value': 'n/a', 'badge': '⚪', 'status': 'No data available'}
197
 
198
  # Note: Not hiding any values - display all measurements
199
  # Even extreme values might be valid for certain swings or camera angles
 
238
  def get_head_sway_grading(value, confidence):
239
  """Grade head sway with percentage of pelvis/shoulder width"""
240
  if value is None:
241
+ return {'display_value': 'n/a', 'badge': '⚪', 'status': 'No data available'}
242
 
243
  # Note: Not hiding any values - display all measurements
244
  # Even high values might be valid for certain swings or camera setups
 
270
  def get_back_tilt_grading(value, confidence, camera_roll=0):
271
  """Grade back tilt at setup"""
272
  if value is None:
273
+ return {'value': None, 'display_value': 'n/a', 'badge': '⚪', 'status': 'No data available'}
274
  # Note: Not hiding based on camera roll - display all measurements
275
 
276
  # Determine grading
 
288
  label = "Likely problematic"
289
 
290
  return {
291
+ 'value': value, # Raw value for display logic
292
  'display_value': f"{value:.1f}°",
293
  'badge': badge,
294
  'label': label,
295
  'confidence': confidence,
296
+ 'status': label, # Use label as status for legacy compatibility
297
  'tip': "Typical tour range ≈30–40°." if badge != "🟢" else None
298
  }
299
 
 
301
  def get_knee_flexion_grading(value, confidence):
302
  """Grade knee flexion at setup"""
303
  if value is None:
304
+ return {'display_value': 'n/a', 'badge': '⚪', 'status': 'No data available'}
305
 
306
  # Determine grading
307
  if 15 <= value <= 30:
 
575
  tip += f"\n\nTechnical: Hip width {hip_depth_data.get('hip_width_ref', 0):.1f}px, threshold {adaptive_threshold:.1f}px"
576
 
577
  return {
578
+ 'value': depth_loss_pct, # Raw value for display logic
579
  'display_value': f'{depth_loss_pct:.1f}% loss{confidence_note}',
580
  'badge': badge,
581
  'label': label,
582
  'confidence': confidence,
583
+ 'status': label, # Use label as status for legacy compatibility
584
  'tip': tip,
585
  'calculation_confidence': calculation_confidence,
586
  'adaptive_threshold': adaptive_threshold
 
590
  def get_shaft_address_grading(angle_value, confidence):
591
  """Generate grading for shaft angle at address"""
592
  if angle_value is None:
593
+ return {'display_value': 'n/a', 'badge': '⚪', 'status': 'No data available'}
594
 
595
  if 45 <= angle_value <= 65:
596
  badge = '🟢'
 
650
  def get_shoulder_rotation_grading(value, confidence, position="top"):
651
  """Grade shoulder rotation angle"""
652
  if value is None:
653
+ return {'display_value': 'n/a', 'badge': '⚪', 'status': 'No data available'}
654
 
655
  # Professional ranges: Top ~90-110°, Impact ~30-50°
656
  if position == "top":
 
692
  def get_hip_rotation_grading(value, confidence, position="top"):
693
  """Grade hip rotation angle"""
694
  if value is None:
695
+ return {'display_value': 'n/a', 'badge': '⚪', 'status': 'No data available'}
696
 
697
  # Professional ranges: Top ~45-60°, Impact ~40-60°
698
  if position == "top":
 
731
  }
732
 
733
 
734
+
735
+ def get_shoulder_tilt_swing_plane_grading(value, confidence):
736
+ """Grade shoulder tilt/swing plane at top - professional = 36°, 30 handicap = 29°"""
737
  if value is None:
738
+ return {'value': None, 'display_value': 'n/a', 'badge': '⚪', 'status': 'No data available'}
739
+
740
+ # Professional = 36°, 30 handicap = 29°
741
+ if 30 <= value <= 40:
742
+ badge = '🟢'
743
+ label = 'Excellent plane'
744
+ tip = 'Great shoulder tilt creating proper swing plane'
745
+ elif 25 <= value < 30 or 40 < value <= 45:
746
+ badge = '🟠'
747
+ label = 'Good plane'
748
+ tip = 'Solid shoulder tilt with room for optimization'
749
+ else:
750
+ badge = '🔴'
751
+ label = 'Needs adjustment'
752
+ tip = 'Work on shoulder tilt for better swing plane'
753
+
754
+ return {
755
+ 'value': value, # Raw value for display logic
756
+ 'display_value': f'{value:.1f}°',
757
+ 'badge': badge,
758
+ 'label': label,
759
+ 'tip': tip,
760
+ 'confidence': confidence,
761
+ 'status': label # Use label as status for legacy compatibility
762
+ }
763
+
764
+
765
+ def get_shoulder_tilt_impact_grading(value, confidence):
766
+ """Grade shoulder tilt at impact - professional = 39°, 30 handicap = 27°"""
767
+ if value is None:
768
+ return {'display_value': 'n/a', 'badge': '⚪', 'status': 'No data available'}
769
 
770
+ # Professional = 39°, 30 handicap = 27°
771
+ if 35 <= value <= 45:
772
+ badge = '🟢'
773
+ label = 'Excellent tilt'
774
+ tip = 'Great shoulder tilt creating proper impact position'
775
+ elif 25 <= value < 35 or 45 < value <= 55:
776
+ badge = '🟠'
777
+ label = 'Good tilt'
778
+ tip = 'Solid shoulder tilt with room for optimization'
779
+ else:
780
+ badge = '🔴'
781
+ label = 'Needs improvement'
782
+ tip = 'Work on shoulder tilt for better impact position'
783
+
784
+ return {
785
+ 'display_value': f'{value:.1f}°',
786
+ 'badge': badge,
787
+ 'label': label,
788
+ 'tip': tip,
789
+ 'confidence': confidence
790
+ }
791
+
792
+
793
+ # Hip turn grading function removed per user request
794
+
795
+
796
+ def get_hip_sway_grading(value, confidence, position="top"):
797
+ """Grade hip sway - professional = 3.9" towards target, 30 handicap = 2.5" towards target"""
798
+ if value is None:
799
+ return {'display_value': 'n/a', 'badge': '⚪', 'status': 'No data available'}
800
+
801
+ # Professional = 3.9" towards target, 30 handicap = 2.5" towards target
802
  if position == "top":
803
+ if 3.0 <= value <= 5.0:
804
  badge = '🟢'
805
+ label = 'Excellent sway'
806
+ tip = 'Great hip movement creating proper coil'
807
+ elif 2.0 <= value < 3.0 or 5.0 < value <= 6.0:
808
  badge = '🟠'
809
+ label = 'Good sway'
810
+ tip = 'Solid hip movement with room for optimization'
811
  else:
812
  badge = '🔴'
813
  label = 'Needs improvement'
814
+ tip = 'Work on hip movement for better coil and power'
815
  else: # impact
816
+ if 2.0 <= value <= 4.0:
817
  badge = '🟢'
818
+ label = 'Excellent sway'
819
+ tip = 'Great hip movement through impact'
820
+ elif 1.0 <= value < 2.0 or 4.0 < value <= 5.0:
821
  badge = '🟠'
822
+ label = 'Good sway'
823
+ tip = 'Solid hip movement with room for optimization'
824
  else:
825
  badge = '🔴'
826
  label = 'Needs improvement'
827
+ tip = 'Work on hip movement for better impact position'
828
 
829
  return {
830
+ 'display_value': f'{value:.1f}"',
831
  'badge': badge,
832
  'label': label,
833
+ 'tip': tip,
834
+ 'confidence': confidence
835
  }
836
 
837
 
838
  def get_wrist_hinge_grading(value, confidence, position="top"):
839
  """Grade wrist hinge angle"""
840
  if value is None:
841
+ return {'display_value': 'n/a', 'badge': '⚪', 'status': 'No data available'}
842
 
843
  # Professional ranges: Top ~90-120°, Impact ~15-35°
844
  if position == "top":
 
878
 
879
 
880
  def get_head_lateral_sway_grading(value, confidence):
881
+ """Grade head lateral sway as percentage of shoulder width"""
882
  if value is None:
883
+ return {'display_value': 'n/a', 'badge': '⚪', 'status': 'No data available'}
884
 
885
+ # Grade based on percentage of shoulder width
886
+ if abs(value) <= 5.0: # <= 5% of shoulder width
887
  badge = '🟢'
888
  label = 'Excellent stability'
889
  tip = 'Outstanding head stability throughout swing'
890
+ elif abs(value) <= 10.0: # <= 10% of shoulder width
891
+ badge = '🟡'
892
  label = 'Good stability'
893
  tip = 'Solid head control with minor movement'
894
+ elif abs(value) <= 15.0: # <= 15% of shoulder width
895
+ badge = '🟠'
896
+ label = 'Moderate sway'
897
+ tip = 'Some head movement - focus on stability'
898
+ else: # > 15% of shoulder width
899
  badge = '🔴'
900
  label = 'Excessive sway'
901
  tip = 'Work on keeping head stable for consistent contact'
902
 
903
  return {
904
+ 'display_value': f"{value:.1f}%",
905
  'badge': badge,
906
  'label': label,
907
  'confidence': confidence,
 
912
  def display_new_grading_scheme(core_metrics):
913
  """Display the swing analysis with badges and confidence indicators"""
914
  # Check if we have front-facing metrics
915
+ # Front-facing metrics have unique keys that DTL doesn't have
916
  has_front_facing_metrics = any(key in core_metrics for key in [
917
+ 'shoulder_tilt_impact_deg', 'hip_sway_top_inches', 'wrist_hinge_top_deg'
 
918
  ])
919
+ # Hip turn check removed per user request
920
 
921
  if has_front_facing_metrics:
922
+ st.subheader("Swing Analysis")
 
923
  else:
924
  st.subheader("Down-the-Line Swing Analysis")
925
 
926
  # Extract raw values and calculate confidence (simplified for now)
 
 
 
 
 
 
927
  # New DTL metrics
928
+ shoulder_tilt_swing_plane_data = core_metrics.get("shoulder_tilt_swing_plane_top_deg", {})
929
+ back_tilt_data = core_metrics.get("back_tilt_deg", {})
930
+ knee_flexion_data = core_metrics.get("knee_flexion_deg", {})
931
  hip_depth_data = core_metrics.get("hip_depth_early_extension", {})
932
+ # Hip turn data removed per user request
 
933
 
934
  # Calculate confidence with QC penalties as per feedback
935
  def get_confidence(data, metric_type='general'):
 
1009
  # Process each metric
1010
  metrics_to_display = []
1011
 
1012
+ # Old metrics removed - now using new 5-metric system
1013
+
1014
+ # DTL Metrics - All 5 new metrics
1015
+ if not has_front_facing_metrics:
1016
+ # 1. Shoulder Tilt/Swing Plane @ Top
1017
+ shoulder_tilt_swing_plane_value = shoulder_tilt_swing_plane_data.get('value')
1018
+ if shoulder_tilt_swing_plane_value is not None:
1019
+ confidence = get_confidence(shoulder_tilt_swing_plane_data, 'shoulder_tilt_swing_plane')
1020
+ grading = get_shoulder_tilt_swing_plane_grading(shoulder_tilt_swing_plane_value, confidence)
1021
+ if grading:
1022
+ metrics_to_display.append(("Shoulder Tilt/Swing Plane @ Top", grading))
 
 
 
 
 
 
 
 
 
1023
 
1024
  # 3. Back Tilt @ Setup
1025
  tilt_value = back_tilt_data.get('value')
 
1068
  }
1069
  metrics_to_display.append(("Hip Depth / Early Extension", error_grading))
1070
  else:
1071
+ pass # Hip depth calculation succeeded but no special handling needed
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1072
 
1073
+ # Hip Turn @ Impact metric removed per user request
1074
+
1075
+ # Additional old metrics removed - focusing on new 4-metric system
1076
+
1077
+ # Front-facing metrics (only displayed when available) - 4 required metrics only
1078
  if has_front_facing_metrics:
1079
+ # Front-facing metrics are now always calculated
1080
+ pass
 
 
 
 
 
 
 
 
 
 
 
 
 
1081
 
1082
+ # Shoulder Tilt at Impact
1083
+ shoulder_tilt_impact_data = core_metrics.get("shoulder_tilt_impact_deg", {})
1084
+ if shoulder_tilt_impact_data.get('value') is not None:
1085
+ confidence = 0.9 # High confidence for front-facing measurements
1086
+ grading = get_shoulder_tilt_impact_grading(shoulder_tilt_impact_data['value'], confidence)
1087
  if grading:
1088
+ metrics_to_display.append(("Shoulder Tilt @ Impact", grading))
1089
 
1090
+ # Hip Turn at Impact metric removed per user request
 
 
 
 
 
 
1091
 
1092
+ # Hip Sway at Top
1093
+ hip_sway_top_data = core_metrics.get("hip_sway_top_inches", {})
1094
+ if hip_sway_top_data.get('value') is not None:
1095
+ confidence = 0.8
1096
+ grading = get_hip_sway_grading(hip_sway_top_data['value'], confidence, "top")
1097
  if grading:
1098
+ metrics_to_display.append(("Hip Sway @ Top", grading))
1099
 
 
 
 
 
 
 
 
1100
 
1101
  # Wrist Hinge at Top
1102
  wrist_hinge_top_data = core_metrics.get("wrist_hinge_top_deg", {})
 
1105
  grading = get_wrist_hinge_grading(wrist_hinge_top_data['value'], confidence, "top")
1106
  if grading:
1107
  metrics_to_display.append(("Wrist Hinge @ Top", grading))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1108
 
1109
  # Display each metric
1110
  for metric_name, grading in metrics_to_display:
 
1122
  # Metric header
1123
  st.subheader(metric_name)
1124
 
1125
+ # Add definition
1126
+ definition = get_metric_definition(metric_name)
1127
+ if definition:
1128
+ st.caption(definition)
1129
+
1130
  # Result line with badge and label
1131
  result_line = f"**{grading['display_value']}** — {grading['badge']} {grading.get('label', '')}"
1132
  st.markdown(result_line)
 
1151
 
1152
  # Add tip if available using info box
1153
  if grading.get('tip'):
1154
+ st.info(f"💡 {grading['tip']}")
1155
 
1156
  # Add spacing
1157
  st.write("")
1158
 
1159
 
1160
+ def get_metric_definition(metric_name):
1161
+ """Get a short definition for each metric"""
1162
+ definitions = {
1163
+ "Shoulder Tilt/Swing Plane @ Top": "Measures shoulder swing plane angle at the top of backswing.",
1164
+ "Back Tilt @ Setup": "Measures spine angle from vertical at address position.",
1165
+ "Knee Flexion": "Measures knee bend angle at address position.",
1166
+ "Hip Depth / Early Extension": "Tracks loss of hip flexion through impact.",
1167
+ "Hip Turn @ Impact": "Measures hip rotation at ball contact.",
1168
+ "Shoulder Tilt @ Impact": "Measures shoulder angle at ball contact.",
1169
+ "Hip Sway @ Top": "Measures lateral hip movement at the top of backswing.",
1170
+ "Wrist Hinge @ Top": "Measures wrist hinge angle at the top of backswing."
1171
+ }
1172
+ return definitions.get(metric_name, "")
1173
+
1174
  def get_metric_evaluation(metric_name, grading):
1175
  """Generate detailed evaluation text for each metric"""
1176
  value = grading.get('display_value', 'Unknown')
1177
  badge = grading.get('badge', '')
1178
 
1179
+ # DTL METRICS (5 current metrics)
1180
+ if metric_name == "Shoulder Tilt/Swing Plane @ Top":
1181
+ if "🟢" in badge:
1182
+ return f"Your shoulder tilt/swing plane of **{value}** at the top shows excellent position. This optimal swing plane angle promotes powerful, on-plane delivery and consistent ball striking. Professional golfers typically maintain 36° while 30-handicappers average 29°. Your measurement indicates proper shoulder turn and swing plane control."
1183
+ elif "🟠" in badge:
1184
+ return f"Your shoulder tilt/swing plane of **{value}** at the top is good with room for improvement. This measurement affects your swing plane consistency and power generation. Refining your shoulder turn and spine angle can enhance ball striking and distance control."
1185
+ else:
1186
+ return f"Your shoulder tilt/swing plane of **{value}** at the top needs attention. This metric is crucial for swing plane consistency and power generation. Work on proper shoulder rotation and maintaining spine angle throughout the backswing for better results."
1187
+
1188
+ elif metric_name == "Back Tilt @ Setup":
1189
  if "🟢" in badge:
1190
  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."
1191
  elif "🟠" in badge:
 
1201
  else:
1202
  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."
1203
 
1204
+ elif metric_name == "Hip Depth / Early Extension":
 
 
 
 
 
 
 
 
 
 
 
 
1205
  if "🟢" in badge:
1206
+ return f"Your hip depth of **{value}** shows excellent posture maintenance. This indicates you're maintaining proper spine angle and avoiding early extension through impact. Good hip depth promotes solid contact, power transfer, and consistent ball striking patterns."
1207
  elif "🟠" in badge:
1208
+ return f"Your hip depth of **{value}** is acceptable but could be improved. This measurement indicates some early extension tendencies. Working on maintaining spine angle and hip position through impact can enhance consistency and power transfer."
1209
  else:
1210
+ return f"Your hip depth of **{value}** indicates early extension issues. This movement pattern reduces power transfer and can cause inconsistent contact. Focus on maintaining spine angle and proper hip position throughout the downswing and impact."
1211
 
1212
+ # Hip Turn @ Impact metric removed per user request
 
 
 
 
 
 
 
 
 
 
1213
 
1214
+ # FRONT-FACING METRICS (4 current metrics)
1215
+ elif metric_name == "Shoulder Tilt @ Impact":
1216
  if "🟢" in badge:
1217
+ return f"Your shoulder tilt of **{value}** at impact shows excellent position. This proper shoulder angle indicates ideal impact dynamics and power transfer. Good shoulder tilt at impact promotes solid contact, optimal ball flight, and consistent distance control."
 
 
 
1218
  elif "🟠" in badge:
1219
+ return f"Your shoulder tilt of **{value}** at impact is acceptable with room for improvement. Shoulder position at impact affects power transfer and ball flight characteristics. Refining your impact position can enhance consistency and distance."
1220
  else:
1221
+ return f"Your shoulder tilt of **{value}** at impact needs attention. Proper shoulder angle at impact is crucial for power transfer and ball flight control. Work on impact position for better contact and consistency."
1222
 
1223
+ elif metric_name == "Hip Sway @ Top":
 
1224
  if "🟢" in badge:
1225
+ return f"Your hip sway of **{value}** at the top shows excellent stability. Minimal lateral movement maintains proper balance and swing center, promoting consistent contact and accuracy. This stable foundation supports powerful, controlled swings."
 
 
 
1226
  elif "🟠" in badge:
1227
+ return f"Your hip sway of **{value}** at the top shows moderate movement. Some lateral sway can affect balance and consistency. Working on stability and weight transfer can improve ball striking and accuracy."
1228
  else:
1229
+ return f"Your hip sway of **{value}** at the top indicates excessive lateral movement. Too much sway disrupts balance and swing center, leading to inconsistent contact. Focus on stability and proper weight transfer for better results."
1230
 
1231
+ elif metric_name == "Wrist Hinge @ Top":
 
1232
  if "🟢" in badge:
1233
+ return f"Your wrist hinge of **{value}** at the top shows excellent set. This proper wrist angle stores energy effectively and sets up lag for powerful release through impact. Good wrist hinge contributes significantly to clubhead speed and distance."
 
 
 
1234
  elif "🟠" in badge:
1235
+ return f"Your wrist hinge of **{value}** at the top is adequate but could be optimized. Better wrist action can enhance lag, power generation, and strike consistency. Work on wrist mobility and proper hinge timing."
1236
  else:
1237
+ return f"Your wrist hinge of **{value}** at the top needs improvement. Proper wrist set is crucial for creating lag and power. Limited wrist hinge reduces potential clubhead speed and distance. Focus on wrist mobility and hinge mechanics."
 
 
 
1238
 
 
 
 
 
 
 
 
1239
 
1240
  else:
1241
  # Default evaluation for any other metrics
 
1331
  Timing Metrics:
1332
  - Total Swing Time: {structured_analysis.get('timing_metrics', {}).get('total_swing_time_ms', 'N/A')} ms
1333
 
1334
+ === DTL METRICS ===
1335
+ - Shoulder Tilt/Swing Plane @ Top: {format_metric_value(core_metrics.get('shoulder_tilt_swing_plane_top_deg', {}), '°')}
 
1336
  - Back Tilt @ Setup: {format_metric_value(core_metrics.get('back_tilt_deg', {}), '°')}
1337
+ - Knee Flexion @ Setup: {format_metric_value(core_metrics.get('knee_flexion_deg', {}), '°')}
1338
+ - Hip Depth / Early Extension: {format_metric_value(core_metrics.get('hip_depth_early_extension', {}), '%')}
1339
+ # Hip Turn @ Impact metric removed per user request
1340
 
1341
  === DTL-LIMITED METRICS (Approximate) ===
1342
  - Shoulder Turn Quality: {format_metric_value(core_metrics.get('shoulder_turn_quality', {}))}
1343
 
1344
+ === FRONT-FACING METRICS ===
1345
+ - Shoulder Tilt @ Impact: {format_metric_value(core_metrics.get('shoulder_tilt_impact_deg', {}), '°')}
1346
+ # Hip Turn @ Impact metric removed per user request
1347
+ - Hip Sway @ Top: {format_metric_value(core_metrics.get('hip_sway_top_inches', {}), '"')}
1348
+ - Wrist Hinge @ Top: {format_metric_value(core_metrics.get('wrist_hinge_top_deg', {}), '°')}
1349
  """
1350
 
1351
  # Removed success message
 
1579
  found_relevant_measurement = True
1580
  break
1581
 
1582
+ # Hip rotation question handling removed per user request
 
 
 
 
 
 
 
 
 
 
1583
 
1584
  elif "weight" in question_lower and ("transfer" in question_lower or "shift" in question_lower):
1585
  # Look for weight transfer measurements (only if asking about weight transfer/shift)
 
1703
  font-weight: bold;
1704
  }
1705
 
 
 
 
 
 
 
 
 
1706
  /* Fix text color visibility for mobile and all devices - EXCEPT buttons */
1707
  .stMarkdown, .stMarkdown p, .stMarkdown h1, .stMarkdown h2, .stMarkdown h3, .stMarkdown h4 {
1708
  color: #0B3B0B !important;
 
1889
  st.markdown('<h2 style="color: #0B3B0B; font-family: Georgia, serif;">Step 1: Upload Your Video</h2>', unsafe_allow_html=True)
1890
 
1891
  # Camera view selection
1892
+ st.markdown("### 📹 Select Camera View <span style='color: red;'>*</span>", unsafe_allow_html=True)
1893
  camera_view = st.radio(
1894
  "Choose the camera angle of your video:",
1895
+ options=["", "Down the Line (DTL)", "Front Facing (Face-On)"],
1896
+ index=0, # Default to empty selection
1897
  key="camera_view",
1898
  horizontal=True,
1899
  help="DTL: Camera positioned behind/in front of golfer along target line. Face-On: Camera positioned to the side of golfer."
1900
  )
1901
 
1902
+ # Show validation message if no camera view selected
1903
+ if not camera_view or camera_view == "":
1904
+ st.warning("⚠️ Please select a camera view to continue.")
1905
+
1906
+ # Club selection (always show)
1907
+ st.markdown("### ⛳ Club Type")
1908
+ club_type = st.radio(
1909
+ "Select club type for accurate grading:",
1910
+ options=["iron", "driver"],
1911
+ index=0, # Default to iron
1912
+ key="club_type_selector",
1913
+ horizontal=True,
1914
+ help="Driver allows more hip rotation than irons. This affects grading thresholds for optimal swing metrics."
1915
+ )
1916
+ # Store in session state
1917
+ st.session_state.club_type = club_type
1918
+
1919
  st.markdown("**Choose your input method below.**")
1920
  st.markdown("💡 **Tips:**")
1921
  st.markdown("- Aim for a video of 5 seconds or less")
1922
+ st.markdown("- Select the correct camera view above for accurate analysis")
1923
 
1924
  col1, col2 = st.columns(2)
1925
 
 
1935
 
1936
  # Analyze button
1937
  if st.button("🏌️ Start Analysis", key="start_analysis", use_container_width=True):
1938
+ # Validate camera view selection
1939
+ if not camera_view or camera_view == "":
1940
+ st.error("⚠️ Please select a camera view before starting analysis.")
1941
+ return
1942
+
1943
  video_path = None
1944
 
1945
  if uploaded_file is not None:
 
1988
  st.success("✅ Video processing complete!")
1989
 
1990
  with st.spinner("Analyzing golfer's pose..."):
1991
+ pose_data, world_landmarks = analyze_pose(frames)
1992
  st.success("✅ Pose analysis complete!")
1993
 
1994
  with st.spinner("Segmenting swing phases..."):
 
2014
  'frames': frames,
2015
  'detections': detections,
2016
  'pose_data': pose_data,
2017
+ 'world_landmarks': world_landmarks,
2018
  'swing_phases': swing_phases,
2019
  'trajectory_data': trajectory_data,
2020
  'sample_rate': 1,
 
2109
  camera_view = st.session_state.analysis_data.get('camera_view', 'Down the Line (DTL)')
2110
  is_front_facing = camera_view == "Front Facing (Face-On)"
2111
 
2112
+ # Get club selection from session state (set in Step 1)
2113
+ club_selection = st.session_state.get('club_type', 'iron')
2114
+
2115
  # Force recomputation with new validation logic
2116
  # This ensures the latest fixes are applied
2117
+ core_metrics = compute_core_metrics(pose_data, swing_phases, is_front_facing=is_front_facing, frames=data.get('frames'), world_landmarks=data.get('world_landmarks'), club=club_selection)
2118
 
2119
  # Update the cached analysis data with new metrics
2120
  if 'analysis_data' in data:
 
2186
  st.caption("Raw values for qualitative assessments:")
2187
 
2188
  # Shaft angle raw value
2189
+ shoulder_swing_plane_raw = core_metrics.get("shoulder_tilt_swing_plane_top_deg", {}).get('value')
2190
+ shoulder_swing_plane_status = core_metrics.get("shoulder_tilt_swing_plane_top_deg", {}).get('status', 'n/a')
2191
+ st.caption(f"Shoulder Tilt/Swing Plane @ Top: {shoulder_swing_plane_raw}° ({shoulder_swing_plane_status})")
 
 
 
 
 
 
 
 
 
 
2192
 
2193
  # Shoulder rotation raw value
2194
+ shoulder_tilt_raw = core_metrics.get("shoulder_tilt_impact_deg", {}).get('value')
2195
+ shoulder_tilt_status = core_metrics.get("shoulder_tilt_impact_deg", {}).get('status', 'n/a')
2196
+ st.caption(f"Shoulder Tilt @ Impact: {shoulder_tilt_raw}° ({shoulder_tilt_status})")
2197
+
2198
+ # Hip turn data removed per user request
2199
 
2200
  # Hip depth raw value
2201
  hip_depth_raw = core_metrics.get("hip_depth_early_extension", {}).get('value')
2202
  hip_depth_status = core_metrics.get("hip_depth_early_extension", {}).get('status', 'n/a')
2203
  st.caption(f"Hip Depth / Early Extension: {hip_depth_raw} ({hip_depth_status})")
2204
+
2205
+ # Hip turn debug info removed per user request
2206
+ if has_front_facing_metrics:
2207
+ st.caption("Front-facing metrics detected")
2208
+ else:
2209
+ st.caption("DTL metrics detected")
2210
 
2211
  with dev_col3:
2212
  if st.button("🔍 Prompt", key="show_prompt_btn", help="View LLM prompt"):
 
2229
  has_analysis = st.session_state.get('video_analyzed', False) and 'analysis_data' in st.session_state
2230
 
2231
  if not has_analysis:
2232
+ st.info("💡 For personalized answers about your swing, complete the video analysis first. You can still ask general golf questions!")
2233
 
2234
  if RAG_AVAILABLE:
2235
  render_rag_interface()