Spaces:
Paused
Paused
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 +1 -1
- app/models/coach_prompt.md +97 -174
- app/models/front_facing_metrics.py +687 -332
- app/models/llm_analyzer.py +319 -172
- app/models/metrics_calculator.py +749 -496
- app/models/pose_estimator.py +25 -6
- app/models/swing_analyzer.py +1 -4
- app/streamlit_app.py +253 -259
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 |
-
##
|
| 4 |
-
Use these professional
|
| 5 |
-
|
| 6 |
-
###
|
| 7 |
-
|
| 8 |
-
**
|
| 9 |
-
-
|
| 10 |
-
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
-
|
| 28 |
-
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
**
|
| 37 |
-
-
|
| 38 |
-
|
| 39 |
-
|
| 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 |
-
### **
|
| 215 |
-
**Core Biomechanical Metrics
|
| 216 |
-
- **
|
| 217 |
-
- **
|
| 218 |
-
- **
|
| 219 |
-
- **
|
| 220 |
-
- **
|
| 221 |
-
- **
|
|
|
|
| 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
|
| 244 |
-
1. First sentence: State if it's good, bad, or needs improvement compared to
|
| 245 |
-
2. Second sentence: Compare the specific value to professional ranges
|
| 246 |
3. Third sentence: Brief explanation of impact on swing performance
|
| 247 |
|
| 248 |
-
**
|
| 249 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 250 |
|
| 251 |
-
**
|
| 252 |
-
[3 sentences about head movement as percentage of shoulder width]
|
| 253 |
|
| 254 |
-
**
|
|
|
|
|
|
|
|
|
|
| 255 |
[3 sentences about spine forward tilt angle during setup position]
|
| 256 |
|
| 257 |
-
**
|
| 258 |
[3 sentences about knee flexion angle during setup position]
|
| 259 |
|
| 260 |
-
**
|
| 261 |
-
[3 sentences about
|
|
|
|
|
|
|
| 262 |
|
| 263 |
**SCORING GUIDELINES (Use to help decide % score)**
|
| 264 |
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
|
| 268 |
-
|
| 269 |
-
|
|
| 270 |
-
|
| 271 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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
|
| 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 (
|
| 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 |
-
#
|
| 425 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 435 |
-
"""
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 439 |
if not lm or not lm[23] or not lm[24]:
|
| 440 |
return None
|
| 441 |
-
return [(lm[23][0] + lm[24][0])
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
if not
|
| 447 |
-
return 0.0
|
| 448 |
-
setup_ctr = np.median(
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
top_ctr = get_pelvis_center_px(top_frame)
|
| 452 |
if top_ctr is None:
|
| 453 |
-
return 0.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 454 |
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
# Rough conversion: assume ~10 pixels per inch (typical for phone videos)
|
| 458 |
-
sway_inches = delta_px / 10.0
|
| 459 |
|
| 460 |
-
return
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 796 |
|
| 797 |
-
# ±
|
| 798 |
-
impact_frames_nearby = [f for f in range(impact_f-
|
| 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
|
| 813 |
hip_turn_abs = wrap180(impact_rel)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 814 |
|
| 815 |
-
# 3D
|
| 816 |
-
|
| 817 |
|
| 818 |
-
#
|
| 819 |
-
|
| 820 |
-
|
| 821 |
-
|
| 822 |
-
|
| 823 |
-
|
| 824 |
-
if
|
| 825 |
-
|
| 826 |
-
|
| 827 |
-
|
| 828 |
-
|
| 829 |
-
|
| 830 |
-
|
| 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 |
-
#
|
| 837 |
-
|
| 838 |
-
|
| 839 |
-
|
| 840 |
-
|
| 841 |
-
|
| 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 |
-
|
| 954 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 992 |
-
|
| 993 |
-
|
| 994 |
-
|
| 995 |
-
|
| 996 |
-
if not
|
| 997 |
-
return None
|
| 998 |
-
|
| 999 |
-
# Find top frame
|
| 1000 |
backswing_frames = swing_phases.get("backswing", [])
|
| 1001 |
-
if not backswing_frames:
|
| 1002 |
-
|
| 1003 |
-
|
| 1004 |
-
|
| 1005 |
-
|
| 1006 |
-
top_frame = backswing_frames[-1]
|
| 1007 |
|
| 1008 |
-
#
|
| 1009 |
-
|
| 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 |
-
|
| 1018 |
-
|
| 1019 |
-
|
| 1020 |
-
|
| 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 |
-
#
|
| 1026 |
toe_vecs = []
|
| 1027 |
for f in setup_frames:
|
| 1028 |
-
lm =
|
| 1029 |
-
if lm
|
| 1030 |
-
|
| 1031 |
-
|
| 1032 |
-
|
| 1033 |
-
|
| 1034 |
-
|
| 1035 |
-
|
| 1036 |
-
|
| 1037 |
-
|
| 1038 |
-
|
| 1039 |
-
|
| 1040 |
-
|
| 1041 |
-
|
| 1042 |
-
|
| 1043 |
-
|
| 1044 |
-
|
| 1045 |
-
|
| 1046 |
-
|
| 1047 |
-
|
| 1048 |
-
|
| 1049 |
-
|
| 1050 |
-
|
| 1051 |
-
|
| 1052 |
-
|
| 1053 |
-
|
| 1054 |
-
|
| 1055 |
-
|
| 1056 |
-
|
| 1057 |
-
|
| 1058 |
-
|
| 1059 |
-
|
| 1060 |
-
|
| 1061 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1062 |
|
| 1063 |
|
| 1064 |
|
|
@@ -1074,29 +1287,142 @@ def angle_between(u, v):
|
|
| 1074 |
|
| 1075 |
|
| 1076 |
|
| 1077 |
-
def
|
| 1078 |
-
"""
|
| 1079 |
-
|
|
|
|
| 1080 |
return None
|
| 1081 |
-
|
| 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 |
-
|
| 1087 |
-
|
| 1088 |
-
|
| 1089 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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
|
| 1140 |
-
top_frame = _pick_top_frame_hinge(sm, backswing_frames, handed=
|
| 1141 |
-
|
| 1142 |
-
|
| 1143 |
-
|
| 1144 |
-
|
| 1145 |
-
if
|
| 1146 |
-
|
| 1147 |
-
|
| 1148 |
-
|
| 1149 |
-
|
| 1150 |
-
|
| 1151 |
-
|
| 1152 |
-
if
|
| 1153 |
-
|
| 1154 |
-
|
| 1155 |
-
|
| 1156 |
-
|
| 1157 |
-
|
| 1158 |
-
|
| 1159 |
-
|
| 1160 |
-
|
| 1161 |
-
|
| 1162 |
-
|
| 1163 |
-
|
| 1164 |
-
|
| 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
|
| 1176 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 1188 |
-
|
|
|
|
|
|
|
|
|
|
| 1189 |
definition_suspect = wrist_hinge_result.get('definition_suspect', False) if wrist_hinge_result else False
|
| 1190 |
|
| 1191 |
-
#
|
| 1192 |
-
wrist_final =
|
| 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':
|
| 1199 |
'hip_sway_top_inches': {'value': hip_sway_top},
|
| 1200 |
-
'wrist_hinge_top_deg': {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
| 15 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
|
| 141 |
# Calculate Back Tilt @ Setup
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 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 |
-
|
| 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 |
-
|
| 199 |
if trail_flexion is not None:
|
| 200 |
-
|
| 201 |
-
if 'overall_confidence' in knee_bend_data:
|
| 202 |
-
knee_metric['overall_confidence'] = round(knee_bend_data['overall_confidence'], 2)
|
| 203 |
|
| 204 |
-
core_metrics["
|
| 205 |
|
| 206 |
# Calculate Hip Depth / Early Extension
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 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 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 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 |
-
#
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 298 |
else:
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
'
|
| 306 |
-
|
| 307 |
-
'
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 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 |
-
|
| 14 |
-
|
| 15 |
-
a = (a + 180.0) % 360.0 - 180.0
|
| 16 |
-
return a
|
| 17 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 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 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
frame_indices (list, optional): Specific frames to check (defaults to all frames)
|
| 32 |
|
| 33 |
Returns:
|
| 34 |
-
|
| 35 |
"""
|
| 36 |
-
if
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 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.
|
| 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) >
|
| 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) <=
|
| 200 |
ground_angles.append(ankle_angle)
|
| 201 |
|
| 202 |
-
# Try knee line as backup
|
| 203 |
-
if (len(kp) > 26 and kp[25][2] > 0.
|
| 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) >
|
| 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) <=
|
| 214 |
ground_angles.append(knee_angle)
|
| 215 |
|
|
|
|
| 216 |
if not ground_angles:
|
| 217 |
-
return
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 317 |
"""Calculate back tilt: forward bend vs ground using bulletproof geometry
|
| 318 |
|
| 319 |
-
|
| 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 |
-
#
|
|
|
|
| 351 |
required_points = [11, 12, 23, 24] # left shoulder, right shoulder, left hip, right hip
|
| 352 |
-
if not all(i < len(kp)
|
| 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 <
|
| 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 |
-
#
|
| 441 |
-
|
|
|
|
| 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
|
| 539 |
|
| 540 |
address_idx = setup_frames[0]
|
| 541 |
if address_idx not in pose_data or pose_data[address_idx] is None:
|
| 542 |
-
return
|
| 543 |
|
| 544 |
addr_kp = pose_data[address_idx]
|
| 545 |
if len(addr_kp) < 25:
|
| 546 |
-
return
|
| 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
|
| 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 |
-
|
| 605 |
-
|
| 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 |
-
|
| 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
|
| 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
|
| 631 |
|
| 632 |
addr_kp = pose_data[address_idx]
|
| 633 |
impact_kp = pose_data[impact_idx]
|
| 634 |
|
| 635 |
-
|
| 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 |
-
|
| 707 |
-
|
| 708 |
-
|
| 709 |
-
- Clean fallback (never return 90° silently)
|
| 710 |
-
- Returns NaN with clear message if no valid line found
|
| 711 |
|
| 712 |
-
|
| 713 |
-
|
| 714 |
-
|
| 715 |
-
|
| 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 |
-
#
|
| 725 |
-
|
| 726 |
-
|
| 727 |
-
address_idx = setup_frames[0] # Fallback to first frame
|
| 728 |
|
| 729 |
-
if
|
| 730 |
-
|
| 731 |
-
|
| 732 |
-
|
|
|
|
|
|
|
| 733 |
|
| 734 |
-
#
|
| 735 |
-
|
|
|
|
| 736 |
return None
|
| 737 |
|
| 738 |
-
|
| 739 |
-
|
| 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
|
| 788 |
-
"""
|
| 789 |
|
| 790 |
-
|
| 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 |
-
|
| 805 |
-
|
| 806 |
-
|
| 807 |
-
|
| 808 |
-
|
| 809 |
-
|
| 810 |
-
|
| 811 |
-
|
| 812 |
-
|
| 813 |
-
|
| 814 |
-
|
| 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 |
-
|
| 864 |
-
|
| 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 |
-
|
| 1137 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1138 |
|
| 1139 |
-
|
| 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 |
-
|
| 1151 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1152 |
setup_frames = swing_phases.get("setup", [])
|
| 1153 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1154 |
|
| 1155 |
-
if
|
| 1156 |
return None
|
| 1157 |
|
| 1158 |
-
#
|
| 1159 |
-
|
| 1160 |
-
|
|
|
|
| 1161 |
|
| 1162 |
-
|
| 1163 |
-
|
| 1164 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1165 |
|
| 1166 |
-
|
| 1167 |
-
|
| 1168 |
-
|
| 1169 |
-
if head_pos is not None:
|
| 1170 |
-
addr_head_positions.append(head_pos)
|
| 1171 |
|
| 1172 |
-
|
| 1173 |
-
|
| 1174 |
-
|
| 1175 |
-
|
| 1176 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1177 |
|
| 1178 |
-
|
| 1179 |
-
|
| 1180 |
|
| 1181 |
-
|
| 1182 |
-
|
| 1183 |
-
|
| 1184 |
|
| 1185 |
-
|
| 1186 |
-
|
| 1187 |
-
|
| 1188 |
-
|
| 1189 |
-
|
| 1190 |
-
|
| 1191 |
-
|
| 1192 |
-
|
| 1193 |
-
|
| 1194 |
-
|
| 1195 |
-
|
| 1196 |
-
|
| 1197 |
-
|
| 1198 |
-
|
| 1199 |
-
|
| 1200 |
-
|
| 1201 |
-
|
| 1202 |
-
|
| 1203 |
-
|
| 1204 |
-
|
| 1205 |
-
|
| 1206 |
-
|
| 1207 |
-
|
| 1208 |
-
|
| 1209 |
-
|
| 1210 |
-
|
| 1211 |
-
|
| 1212 |
-
|
| 1213 |
-
|
| 1214 |
-
|
| 1215 |
-
|
| 1216 |
-
|
| 1217 |
-
|
| 1218 |
-
|
| 1219 |
-
|
| 1220 |
-
|
| 1221 |
-
|
| 1222 |
-
'
|
| 1223 |
-
|
| 1224 |
-
|
| 1225 |
-
|
| 1226 |
-
|
| 1227 |
-
|
| 1228 |
-
|
| 1229 |
-
|
|
|
|
|
|
|
| 1230 |
|
| 1231 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1232 |
|
| 1233 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 43 |
else:
|
| 44 |
# No pose detected, return very low confidence markers
|
| 45 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
#
|
| 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
|
| 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
|
| 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
|
| 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
|
| 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
|
| 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
|
| 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
|
| 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 |
-
|
| 731 |
-
|
|
|
|
| 732 |
if value is None:
|
| 733 |
-
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 734 |
|
| 735 |
-
# Professional
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 736 |
if position == "top":
|
| 737 |
-
if
|
| 738 |
badge = '🟢'
|
| 739 |
-
label = 'Excellent
|
| 740 |
-
tip = 'Great
|
| 741 |
-
elif
|
| 742 |
badge = '🟠'
|
| 743 |
-
label = 'Good
|
| 744 |
-
tip = 'Solid
|
| 745 |
else:
|
| 746 |
badge = '🔴'
|
| 747 |
label = 'Needs improvement'
|
| 748 |
-
tip = 'Work on
|
| 749 |
else: # impact
|
| 750 |
-
if
|
| 751 |
badge = '🟢'
|
| 752 |
-
label = 'Excellent
|
| 753 |
-
tip = 'Great
|
| 754 |
-
elif
|
| 755 |
badge = '🟠'
|
| 756 |
-
label = 'Good
|
| 757 |
-
tip = 'Solid
|
| 758 |
else:
|
| 759 |
badge = '🔴'
|
| 760 |
label = 'Needs improvement'
|
| 761 |
-
tip = '
|
| 762 |
|
| 763 |
return {
|
| 764 |
-
'display_value': f
|
| 765 |
'badge': badge,
|
| 766 |
'label': label,
|
| 767 |
-
'
|
| 768 |
-
'
|
| 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
|
| 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
|
| 816 |
if value is None:
|
| 817 |
-
return
|
| 818 |
|
| 819 |
-
#
|
| 820 |
-
if value <=
|
| 821 |
badge = '🟢'
|
| 822 |
label = 'Excellent stability'
|
| 823 |
tip = 'Outstanding head stability throughout swing'
|
| 824 |
-
elif value <=
|
| 825 |
-
badge = '
|
| 826 |
label = 'Good stability'
|
| 827 |
tip = 'Solid head control with minor movement'
|
| 828 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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}
|
| 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 |
-
'
|
| 847 |
-
'wrist_hinge_top_deg', 'head_lateral_sway_cm'
|
| 848 |
])
|
|
|
|
| 849 |
|
| 850 |
if has_front_facing_metrics:
|
| 851 |
-
st.subheader("
|
| 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 |
-
|
| 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 |
-
#
|
| 947 |
-
|
| 948 |
-
#
|
| 949 |
-
if
|
| 950 |
-
#
|
| 951 |
-
|
| 952 |
-
|
| 953 |
-
|
| 954 |
-
|
| 955 |
-
|
| 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 |
-
#
|
| 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 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1035 |
if has_front_facing_metrics:
|
| 1036 |
-
#
|
| 1037 |
-
|
| 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 |
-
#
|
| 1053 |
-
|
| 1054 |
-
if
|
| 1055 |
-
confidence = 0.9
|
| 1056 |
-
grading =
|
| 1057 |
if grading:
|
| 1058 |
-
metrics_to_display.append(("
|
| 1059 |
|
| 1060 |
-
# Hip
|
| 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 |
-
#
|
| 1069 |
-
|
| 1070 |
-
if
|
| 1071 |
-
confidence = 0.
|
| 1072 |
-
grading =
|
| 1073 |
if grading:
|
| 1074 |
-
metrics_to_display.append(("
|
| 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"💡
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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
|
| 1190 |
elif "🟠" in badge:
|
| 1191 |
-
return f"Your
|
| 1192 |
else:
|
| 1193 |
-
return f"Your
|
| 1194 |
|
| 1195 |
-
|
| 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 |
-
|
| 1208 |
-
|
| 1209 |
if "🟢" in badge:
|
| 1210 |
-
|
| 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
|
| 1216 |
else:
|
| 1217 |
-
return f"Your
|
| 1218 |
|
| 1219 |
-
elif "
|
| 1220 |
-
position = "top" if "Top" in metric_name else "impact"
|
| 1221 |
if "🟢" in badge:
|
| 1222 |
-
|
| 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
|
| 1228 |
else:
|
| 1229 |
-
return f"Your
|
| 1230 |
|
| 1231 |
-
elif "Wrist Hinge
|
| 1232 |
-
position = "top" if "Top" in metric_name else "impact"
|
| 1233 |
if "🟢" in badge:
|
| 1234 |
-
|
| 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
|
| 1240 |
else:
|
| 1241 |
-
|
| 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
|
| 1349 |
-
-
|
| 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
|
| 1353 |
-
-
|
|
|
|
| 1354 |
|
| 1355 |
=== DTL-LIMITED METRICS (Approximate) ===
|
| 1356 |
- Shoulder Turn Quality: {format_metric_value(core_metrics.get('shoulder_turn_quality', {}))}
|
| 1357 |
|
| 1358 |
-
===
|
| 1359 |
-
-
|
| 1360 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 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
|
| 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("-
|
| 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 |
-
|
| 2194 |
-
|
| 2195 |
-
st.caption(f"
|
| 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 |
-
|
| 2209 |
-
|
| 2210 |
-
st.caption(f"Shoulder
|
|
|
|
|
|
|
| 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("💡
|
| 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()
|