Spaces:
Paused
Paused
Major code cleanup: Remove unused metrics and consolidate model files
Browse files- Remove unused DTL metrics (keep only 4): shoulder tilt/swing plane, back tilt, knee flexion, head drop/rise
- Remove unused front-facing metrics (keep only 4): shoulder tilt @ impact, hip turn @ impact, hip sway @ top, wrist hinge @ top
- Remove 12 unused grading functions from streamlit_app.py
- Consolidate model files: merge math_utils into segmentation, prompts into llm_analyzer
- Remove deprecated functions: early extension, shaft angle, diagnostic overlay
- Clean up swing_analyzer.py to keep only essential functions
- Update imports and fix circular dependencies
- All functionality preserved for 8 core metrics
- app/main.py +2 -2
- app/models/coach_prompt.md +0 -216
- app/models/front_facing_metrics.py +11 -1
- app/models/llm_analyzer.py +70 -19
- app/models/math_utils.py +0 -89
- app/models/metrics_calculator.py +2 -239
- app/models/prompts.py +0 -11
- app/models/segmentation.py +15 -1
- app/models/swing_analyzer.py +6 -678
- app/streamlit_app.py +58 -435
app/main.py
CHANGED
|
@@ -16,7 +16,7 @@ sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
| 16 |
from utils.video_downloader import download_youtube_video, cleanup_video_file
|
| 17 |
from utils.video_processor import process_video
|
| 18 |
from models.pose_estimator import analyze_pose
|
| 19 |
-
from models.swing_analyzer import
|
| 20 |
from models.llm_analyzer import generate_swing_analysis
|
| 21 |
from utils.visualizer import create_annotated_video
|
| 22 |
|
|
@@ -55,7 +55,7 @@ def main():
|
|
| 55 |
|
| 56 |
# Step 6: Segment swing into phases
|
| 57 |
print("\nSegmenting swing phases...")
|
| 58 |
-
swing_phases =
|
| 59 |
detections,
|
| 60 |
sample_rate=sample_rate,
|
| 61 |
fps=30.0)
|
|
|
|
| 16 |
from utils.video_downloader import download_youtube_video, cleanup_video_file
|
| 17 |
from utils.video_processor import process_video
|
| 18 |
from models.pose_estimator import analyze_pose
|
| 19 |
+
from models.swing_analyzer import segment_swing_pose_based, analyze_trajectory
|
| 20 |
from models.llm_analyzer import generate_swing_analysis
|
| 21 |
from utils.visualizer import create_annotated_video
|
| 22 |
|
|
|
|
| 55 |
|
| 56 |
# Step 6: Segment swing into phases
|
| 57 |
print("\nSegmenting swing phases...")
|
| 58 |
+
swing_phases = segment_swing_pose_based(pose_data,
|
| 59 |
detections,
|
| 60 |
sample_rate=sample_rate,
|
| 61 |
fps=30.0)
|
app/models/coach_prompt.md
DELETED
|
@@ -1,216 +0,0 @@
|
|
| 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° (Iron avg: 34.3°, Driver avg: 30.5°)
|
| 28 |
-
- 30 Handicap = 29°
|
| 29 |
-
|
| 30 |
-
**Back Tilt (°):**
|
| 31 |
-
- Professional = 31° (Iron avg: 30.8°, Driver avg: 32.3°)
|
| 32 |
-
|
| 33 |
-
**Knee Flexion (°):**
|
| 34 |
-
- Professional = 22° (Iron avg: 21.6°, Driver avg: 27.2°)
|
| 35 |
-
|
| 36 |
-
**Head Drop/Rise @ Top (%):**
|
| 37 |
-
- Professional = 0-5% drop (Iron: 0-7.6% range, Driver: 4.6-5.5% drop)
|
| 38 |
-
|
| 39 |
-
# Hip Turn @ Impact metric removed from DTL analysis
|
| 40 |
-
|
| 41 |
-
### **KEY AMATEUR SWING PROBLEMS FOR EVALUATION REFERENCE:**
|
| 42 |
-
**Common Issues in 40-50% Level Swings:**
|
| 43 |
-
- **Standing up during impact** (loss of posture maintenance)
|
| 44 |
-
- **Severe head movement** (stability and consistency issues)
|
| 45 |
-
- **Wrist casting** (early release, loss of lag)
|
| 46 |
-
- **Hands leading downswing instead of hips** (poor kinematic sequence)
|
| 47 |
-
- **Excessive knee flexion** (39.4° male, 33.8° female vs 16-28° professional range)
|
| 48 |
-
- **Back tilt out of professional range** (39.0° vs 25-35° professional range)
|
| 49 |
-
- **Inconsistent swing path and ball contact**
|
| 50 |
-
- **Equipment issues** (wrong club length affecting stance and mechanics)
|
| 51 |
-
- **Bent arms in follow through** (loss of extension and power)
|
| 52 |
-
- **Hands continuing at top when they should stop** (over-swing, timing issues)
|
| 53 |
-
|
| 54 |
-
**Evaluation Emphasis Points:**
|
| 55 |
-
- Knee flexion >35° should be flagged as problematic for power and stability
|
| 56 |
-
- Back tilt >35° indicates setup issues that affect entire swing
|
| 57 |
-
- Wrist casting patterns significantly impact ball striking and distance
|
| 58 |
-
- Head movement and standing up during impact are major consistency killers
|
| 59 |
-
- Poor kinematic sequence (hands before hips) reduces power generation
|
| 60 |
-
|
| 61 |
-
### **COMPREHENSIVE AMATEUR SWING ISSUES REFERENCE:**
|
| 62 |
-
|
| 63 |
-
**Swing Mechanics & Tempo Issues:**
|
| 64 |
-
1. **Over-Swinging:**
|
| 65 |
-
- Swinging with excessive force leads to loss of control and poor tempo
|
| 66 |
-
- Makes it harder to stay connected and achieve consistent contact
|
| 67 |
-
- Often results in loss of balance and timing
|
| 68 |
-
|
| 69 |
-
2. **Over-the-Top Swing Path:**
|
| 70 |
-
- Golfer starts downswing from the outside, causing club to swing across the ball
|
| 71 |
-
- Results in pulls, slices, and inconsistent ball striking
|
| 72 |
-
- Common in amateur swings due to improper sequencing
|
| 73 |
-
|
| 74 |
-
3. **Lack of Weight Transfer:**
|
| 75 |
-
- Not shifting weight from back foot to lead foot during downswing
|
| 76 |
-
- Reduces power generation and can cause inconsistent contact
|
| 77 |
-
- Often combined with poor hip rotation
|
| 78 |
-
|
| 79 |
-
4. **Poor Rotation & Early Extension:**
|
| 80 |
-
- Lack of proper body rotation through the swing
|
| 81 |
-
- Hips moving towards the ball during downswing (early extension)
|
| 82 |
-
- Leads to loss of posture and inconsistent strike patterns
|
| 83 |
-
|
| 84 |
-
5. **"Lifting" the Ball:**
|
| 85 |
-
- Attempting to lift the ball into the air rather than hitting down and through
|
| 86 |
-
- Common counter-intuitive mistake that leads to poor contact
|
| 87 |
-
- Results in topped shots and inconsistent ball flight
|
| 88 |
-
|
| 89 |
-
6. **Wrist Casting:**
|
| 90 |
-
- Early release of wrist hinge, reducing power and distance
|
| 91 |
-
- Loss of lag angle that professionals maintain
|
| 92 |
-
- Significantly impacts ball compression and distance
|
| 93 |
-
|
| 94 |
-
7. **Moving Head/Standing Up in Swing:**
|
| 95 |
-
- Loss of spine angle and posture during swing
|
| 96 |
-
- Creates inconsistent contact points
|
| 97 |
-
- Major factor in amateur swing inconsistency
|
| 98 |
-
|
| 99 |
-
**Impact on Evaluation:**
|
| 100 |
-
- Look for combinations of these issues in amateur swings
|
| 101 |
-
- Multiple issues often compound each other (e.g., over-swinging + early extension)
|
| 102 |
-
- Early extension and standing up are visible in DTL analysis as posture loss
|
| 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 Sway @ Top**: Professional = 3.9" towards target, 30 Handicap = 2.5" towards target
|
| 110 |
-
- **Shoulder Tilt/Swing Plane @ Top**: Professional = 36°, 30 Handicap = 29°
|
| 111 |
-
- **Back Tilt @ Top**: Professional = 31°
|
| 112 |
-
- **Knee Flexion @ Top**: Professional = 22°
|
| 113 |
-
- **Head Drop/Rise @ Top**: Professional = 0-5% drop
|
| 114 |
-
|
| 115 |
-
## CURRENT SWING ANALYSIS
|
| 116 |
-
|
| 117 |
-
### Swing Phase Breakdown
|
| 118 |
-
{swing_phase_data}
|
| 119 |
-
|
| 120 |
-
### DTL-Reliable Core Metrics
|
| 121 |
-
{core_mechanics}
|
| 122 |
-
|
| 123 |
-
## ANALYSIS INSTRUCTIONS
|
| 124 |
-
|
| 125 |
-
**GOLF SWING ANALYSIS FORMAT**
|
| 126 |
-
Use the benchmarks above to guide your evaluation. Follow this exact format:
|
| 127 |
-
|
| 128 |
-
**OVERALL_SUMMARY:** [1-2 sentences maximum providing a concise evaluation of the swing's overall quality and main strengths/areas for improvement]
|
| 129 |
-
|
| 130 |
-
**PERFORMANCE_CLASSIFICATION:** [XX%]
|
| 131 |
-
(XX = number from 10% to 100%)
|
| 132 |
-
|
| 133 |
-
**Metric Evaluations**
|
| 134 |
-
|
| 135 |
-
For each of the new metrics below, write exactly 3 sentences evaluating the metric:
|
| 136 |
-
1. First sentence: State if it's good, bad, or needs improvement compared to professional standards
|
| 137 |
-
2. Second sentence: Compare the specific value to professional/amateur ranges
|
| 138 |
-
3. Third sentence: Brief explanation of impact on swing performance
|
| 139 |
-
|
| 140 |
-
**FRONT-FACING METRICS:**
|
| 141 |
-
|
| 142 |
-
**1. Shoulder Tilt @ Impact Evaluation:**
|
| 143 |
-
[3 sentences about shoulder tilt angle at impact - professional = 39°, 30 handicap = 27°]
|
| 144 |
-
|
| 145 |
-
# Hip Turn @ Impact evaluation removed from DTL analysis
|
| 146 |
-
|
| 147 |
-
**3. Hip Sway @ Top Evaluation:**
|
| 148 |
-
[3 sentences about hip sway at top - professional = 3.9" towards target, 30 handicap = 2.5" towards target]
|
| 149 |
-
|
| 150 |
-
**4. Hip Sway @ Impact Evaluation:**
|
| 151 |
-
[3 sentences about hip sway at impact]
|
| 152 |
-
|
| 153 |
-
**5. Wrist Hinge @ Top Evaluation:**
|
| 154 |
-
[3 sentences about wrist hinge angle at top of backswing]
|
| 155 |
-
|
| 156 |
-
**DTL METRICS:**
|
| 157 |
-
|
| 158 |
-
**1. Shoulder Tilt/Swing Plane @ Top Evaluation:**
|
| 159 |
-
[3 sentences about shoulder tilt/swing plane angle at top - professional = 36°, 30 handicap = 29°]
|
| 160 |
-
|
| 161 |
-
**2. Back Tilt @ Top Evaluation:**
|
| 162 |
-
[3 sentences about spine forward tilt angle at top of backswing - professional = 31°]
|
| 163 |
-
|
| 164 |
-
**3. Knee Flexion @ Top Evaluation:**
|
| 165 |
-
[3 sentences about knee flexion angle at top of backswing - professional = 22°]
|
| 166 |
-
|
| 167 |
-
**4. Head Drop/Rise @ Top Evaluation:**
|
| 168 |
-
[3 sentences about head movement percentage at top of backswing - professional = 0-5% drop]
|
| 169 |
-
|
| 170 |
-
# Hip Turn @ Impact evaluation removed from DTL analysis
|
| 171 |
-
|
| 172 |
-
**SCORING GUIDELINES (Use to help decide % score)**
|
| 173 |
-
|
| 174 |
-
**FRONT-FACING METRICS:**
|
| 175 |
-
|
| 176 |
-
| Metric | Professional Standard | 30 Handicap Standard | Note |
|
| 177 |
-
|--------|----------------------|---------------------|------|
|
| 178 |
-
| Shoulder Tilt @ Impact | 39° | 27° | Shoulder tilt angle at impact |
|
| 179 |
-
# Hip Turn @ Impact metric removed from DTL analysis
|
| 180 |
-
| Hip Sway @ Top | 3.9" towards target | 2.5" towards target | Hip lateral movement at top |
|
| 181 |
-
| Hip Sway @ Impact | To be calibrated | To be calibrated | Hip lateral movement at impact |
|
| 182 |
-
| Wrist Hinge @ Top | To be calibrated | To be calibrated | Wrist angle at top of backswing |
|
| 183 |
-
|
| 184 |
-
**DTL METRICS:**
|
| 185 |
-
|
| 186 |
-
| Metric | Professional Standard | 30 Handicap Standard | Note |
|
| 187 |
-
|--------|----------------------|---------------------|------|
|
| 188 |
-
| Shoulder Tilt/Swing Plane @ Top | 36° | 29° | Shoulder tilt/swing plane angle at top |
|
| 189 |
-
| Back Tilt @ Top | 31° | To be calibrated | Forward spine tilt at top of backswing |
|
| 190 |
-
| Knee Flexion @ Top | 22° | To be calibrated | Knee flexion angle at top of backswing |
|
| 191 |
-
| Head Drop/Rise @ Top | 0-5% drop | To be calibrated | Head movement percentage at top |
|
| 192 |
-
# Hip Turn @ Impact metric removed from DTL analysis
|
| 193 |
-
|
| 194 |
-
**Classification Bands:**
|
| 195 |
-
- **90–100%**: Tour-level
|
| 196 |
-
- **80–89%**: Advanced amateur
|
| 197 |
-
- **70–79%**: Skilled
|
| 198 |
-
- **60–69%**: Intermediate
|
| 199 |
-
- **50–59%**: Developing
|
| 200 |
-
- **40–49%**: Beginner
|
| 201 |
-
- **10–39%**: Novice
|
| 202 |
-
|
| 203 |
-
**STYLE & FORMATTING RULES:**
|
| 204 |
-
- Use these headers: OVERALL_SUMMARY, PERFORMANCE_CLASSIFICATION, Metric Evaluations, and the numbered metric sections for both Front-Facing and DTL metrics
|
| 205 |
-
- No emojis anywhere in the response
|
| 206 |
-
- Write 1-2 sentences maximum for the overall summary - be concise and highlight main strengths/improvement areas
|
| 207 |
-
- Write exactly 3 sentences for each metric evaluation
|
| 208 |
-
- Tie all evaluations to the new professional/amateur standards and ranges provided
|
| 209 |
-
- Use a positive, coaching tone throughout
|
| 210 |
-
- Avoid saying "perfect" — say "strong" or "meets standards"
|
| 211 |
-
- Focus on biomechanics and compare actual values to the new professional ranges (39° shoulder tilt, etc.) - Hip turn removed from DTL metrics
|
| 212 |
-
- Consider common amateur swing issues when evaluating: over-swinging, over-the-top path, lack of weight transfer, early extension, lifting the ball, wrist casting, head movement/standing up
|
| 213 |
-
- Look for combinations of swing problems that often compound each other in amateur golfers
|
| 214 |
-
- Use the new calibration values: Professional = 39° shoulder tilt @ impact, 36° shoulder tilt @ top, 31° back tilt @ top, 22° knee flexion @ top, 0-5% head drop @ top vs 30 Handicap = 27° shoulder tilt @ impact, 29° shoulder tilt @ top
|
| 215 |
-
|
| 216 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/models/front_facing_metrics.py
CHANGED
|
@@ -10,6 +10,8 @@ import numpy as np
|
|
| 10 |
import cv2
|
| 11 |
from typing import Dict, List, Tuple, Optional, Union
|
| 12 |
|
|
|
|
|
|
|
| 13 |
# Landmark indices
|
| 14 |
L_HIP, R_HIP = 23, 24
|
| 15 |
L_SHO, R_SHO = 11, 12
|
|
@@ -422,8 +424,15 @@ def _address_pelvis_rel(world_landmarks, setup_frames, target_hat):
|
|
| 422 |
vals.append(rel)
|
| 423 |
return float(np.median(vals)) if vals else None
|
| 424 |
|
|
|
|
| 425 |
def grade_hip_turn(delta_deg: float, club: str = "iron") -> dict:
|
| 426 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 427 |
c = club.lower()
|
| 428 |
bands = {
|
| 429 |
"driver": {"excellent": (38,48), "good_low": (32,38), "good_high": (48,52)},
|
|
@@ -445,6 +454,7 @@ def grade_hip_turn(delta_deg: float, club: str = "iron") -> dict:
|
|
| 445 |
)
|
| 446 |
return {"label": label, "color": color, "tip": tip}
|
| 447 |
|
|
|
|
| 448 |
def _target_hat_from_toes_world(world_landmarks, setup_frames, handedness='right'):
|
| 449 |
"""Build stable target axis from toe line at address with outlier trimming"""
|
| 450 |
LEFT_TOE, RIGHT_TOE = 31, 32
|
|
|
|
| 10 |
import cv2
|
| 11 |
from typing import Dict, List, Tuple, Optional, Union
|
| 12 |
|
| 13 |
+
# Note: grade_hip_turn function moved to streamlit_app.py to keep all grading logic in one place
|
| 14 |
+
|
| 15 |
# Landmark indices
|
| 16 |
L_HIP, R_HIP = 23, 24
|
| 17 |
L_SHO, R_SHO = 11, 12
|
|
|
|
| 424 |
vals.append(rel)
|
| 425 |
return float(np.median(vals)) if vals else None
|
| 426 |
|
| 427 |
+
|
| 428 |
def grade_hip_turn(delta_deg: float, club: str = "iron") -> dict:
|
| 429 |
+
"""
|
| 430 |
+
Grade hip turn with club-aware bands and actionable tips
|
| 431 |
+
|
| 432 |
+
NOTE: This function has been moved to streamlit_app.py to consolidate all grading logic.
|
| 433 |
+
This copy remains here temporarily to avoid circular imports.
|
| 434 |
+
TODO: Remove this function once a better import structure is established.
|
| 435 |
+
"""
|
| 436 |
c = club.lower()
|
| 437 |
bands = {
|
| 438 |
"driver": {"excellent": (38,48), "good_low": (32,38), "good_high": (48,52)},
|
|
|
|
| 454 |
)
|
| 455 |
return {"label": label, "color": color, "tip": tip}
|
| 456 |
|
| 457 |
+
|
| 458 |
def _target_hat_from_toes_world(world_landmarks, setup_frames, handedness='right'):
|
| 459 |
"""Build stable target axis from toe line at address with outlier trimming"""
|
| 460 |
LEFT_TOE, RIGHT_TOE = 31, 32
|
app/models/llm_analyzer.py
CHANGED
|
@@ -11,9 +11,7 @@ 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 |
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
|
|
@@ -24,9 +22,9 @@ try:
|
|
| 24 |
get_shoulder_tilt_swing_plane_grading,
|
| 25 |
get_back_tilt_grading,
|
| 26 |
get_knee_flexion_grading,
|
| 27 |
-
|
| 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 |
)
|
|
@@ -447,21 +445,74 @@ def prepare_data_for_llm(pose_data, swing_phases, trajectory_data=None, fps=30.0
|
|
| 447 |
|
| 448 |
def create_llm_prompt(analysis_data):
|
| 449 |
"""Create LLM prompt from swing analysis data"""
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 465 |
|
| 466 |
# Format metrics for prompt
|
| 467 |
core_metrics = analysis_data.get('core_metrics', {})
|
|
|
|
| 11 |
from typing import Dict, Any, Optional
|
| 12 |
from .metrics_calculator import (
|
| 13 |
calculate_back_tilt_degree, calculate_knee_bend_degree,
|
|
|
|
| 14 |
calculate_shoulder_tilt_swing_plane_at_top,
|
|
|
|
| 15 |
compute_dtl_three
|
| 16 |
)
|
| 17 |
from .front_facing_metrics import compute_front_facing_metrics
|
|
|
|
| 22 |
get_shoulder_tilt_swing_plane_grading,
|
| 23 |
get_back_tilt_grading,
|
| 24 |
get_knee_flexion_grading,
|
| 25 |
+
get_head_drop_grading,
|
|
|
|
| 26 |
get_shoulder_tilt_impact_grading,
|
| 27 |
+
get_hip_turn_impact_grading,
|
| 28 |
get_hip_sway_grading,
|
| 29 |
get_wrist_hinge_grading
|
| 30 |
)
|
|
|
|
| 445 |
|
| 446 |
def create_llm_prompt(analysis_data):
|
| 447 |
"""Create LLM prompt from swing analysis data"""
|
| 448 |
+
prompt_template = """# Golf Swing Analysis
|
| 449 |
+
|
| 450 |
+
## NEW METRICS CALIBRATION
|
| 451 |
+
Use these new professional/amateur benchmarks for scoring. These represent updated golf swing mechanics based on the latest analysis:
|
| 452 |
+
|
| 453 |
+
### **NEW FRONT-FACING METRICS:**
|
| 454 |
+
|
| 455 |
+
**Shoulder Tilt @ Impact:**
|
| 456 |
+
- Professional = 39°
|
| 457 |
+
- 30 Handicap = 27°
|
| 458 |
+
|
| 459 |
+
**Hip Sway @ Top:**
|
| 460 |
+
- Professional = 3.9" towards target
|
| 461 |
+
- 30 Handicap = 2.5" towards target
|
| 462 |
+
|
| 463 |
+
**Wrist Hinge @ Top:**
|
| 464 |
+
- To be measured and calibrated
|
| 465 |
+
|
| 466 |
+
### **NEW DTL METRICS:**
|
| 467 |
+
|
| 468 |
+
**Shoulder Tilt/Swing Plane Angle @ Top:**
|
| 469 |
+
- Professional = 36° (Iron avg: 34.3°, Driver avg: 30.5°)
|
| 470 |
+
- 30 Handicap = 29°
|
| 471 |
+
|
| 472 |
+
**Back Tilt (°):**
|
| 473 |
+
- Professional = 31° (Iron avg: 30.8°, Driver avg: 32.3°)
|
| 474 |
+
|
| 475 |
+
**Knee Flexion (°):**
|
| 476 |
+
- Professional = 22° (Iron avg: 21.6°, Driver avg: 27.2°)
|
| 477 |
+
|
| 478 |
+
**Head Drop/Rise @ Top (%):**
|
| 479 |
+
- Professional = 0-5% drop (Iron: 0-7.6% range, Driver: 4.6-5.5% drop)
|
| 480 |
+
|
| 481 |
+
### **ANALYSIS INSTRUCTIONS**
|
| 482 |
+
|
| 483 |
+
**GOLF SWING ANALYSIS FORMAT**
|
| 484 |
+
Use the benchmarks above to guide your evaluation. Follow this exact format:
|
| 485 |
+
|
| 486 |
+
**OVERALL_SUMMARY:** [1-2 sentences maximum providing a concise evaluation of the swing's overall quality and main strengths/areas for improvement]
|
| 487 |
+
|
| 488 |
+
**PERFORMANCE_CLASSIFICATION:** [XX%]
|
| 489 |
+
(XX = number from 10% to 100%)
|
| 490 |
+
|
| 491 |
+
**Metric Evaluations**
|
| 492 |
+
|
| 493 |
+
For each metric below, write exactly 3 sentences evaluating the metric:
|
| 494 |
+
1. First sentence: State if it's good, bad, or needs improvement compared to professional standards
|
| 495 |
+
2. Second sentence: Compare the specific value to professional/amateur ranges
|
| 496 |
+
3. Third sentence: Brief explanation of impact on swing performance
|
| 497 |
+
|
| 498 |
+
**Classification Bands:**
|
| 499 |
+
- **90–100%**: Tour-level
|
| 500 |
+
- **80–89%**: Advanced amateur
|
| 501 |
+
- **70–79%**: Skilled
|
| 502 |
+
- **60–69%**: Intermediate
|
| 503 |
+
- **50–59%**: Developing
|
| 504 |
+
- **40–49%**: Beginner
|
| 505 |
+
- **10–39%**: Novice
|
| 506 |
+
|
| 507 |
+
**STYLE & FORMATTING RULES:**
|
| 508 |
+
- Use these headers: OVERALL_SUMMARY, PERFORMANCE_CLASSIFICATION, Metric Evaluations
|
| 509 |
+
- No emojis anywhere in the response
|
| 510 |
+
- Write 1-2 sentences maximum for the overall summary
|
| 511 |
+
- Write exactly 3 sentences for each metric evaluation
|
| 512 |
+
- Use a positive, coaching tone throughout
|
| 513 |
+
- Focus on biomechanics and compare actual values to the professional ranges provided
|
| 514 |
+
|
| 515 |
+
"""
|
| 516 |
|
| 517 |
# Format metrics for prompt
|
| 518 |
core_metrics = analysis_data.get('core_metrics', {})
|
app/models/math_utils.py
DELETED
|
@@ -1,89 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Simplified math utilities for DTL golf swing analysis
|
| 3 |
-
Focused on the core metrics only
|
| 4 |
-
"""
|
| 5 |
-
import math
|
| 6 |
-
import numpy as np
|
| 7 |
-
|
| 8 |
-
# Core utility functions for DTL analysis
|
| 9 |
-
def _angle_deg_2d(ax, ay, bx, by):
|
| 10 |
-
"""angle of vector a->b vs +X axis, in degrees"""
|
| 11 |
-
return math.degrees(math.atan2(by - ay, bx - ax))
|
| 12 |
-
|
| 13 |
-
def line_angle(points_left, points_right):
|
| 14 |
-
"""points_* are (x,y) tuples; returns angle of L->R line in degrees"""
|
| 15 |
-
(lx, ly), (rx, ry) = points_left, points_right
|
| 16 |
-
return _angle_deg_2d(lx, ly, rx, ry)
|
| 17 |
-
|
| 18 |
-
def rel_rotation_deg(angle_now, angle_addr):
|
| 19 |
-
"""normalize relative rotation to [-180,180]"""
|
| 20 |
-
d = angle_now - angle_addr
|
| 21 |
-
while d > 180: d -= 360
|
| 22 |
-
while d < -180: d += 360
|
| 23 |
-
return d
|
| 24 |
-
|
| 25 |
-
def unit_vector(vector):
|
| 26 |
-
"""Convert vector to unit vector"""
|
| 27 |
-
norm = np.linalg.norm(vector)
|
| 28 |
-
if norm == 0:
|
| 29 |
-
return vector
|
| 30 |
-
return vector / norm
|
| 31 |
-
|
| 32 |
-
def signed_angle_2d(vec1, vec2):
|
| 33 |
-
"""Calculate signed angle between two 2D vectors in radians"""
|
| 34 |
-
cross_product = vec1[0] * vec2[1] - vec1[1] * vec2[0]
|
| 35 |
-
dot_product = np.dot(vec1, vec2)
|
| 36 |
-
return math.atan2(cross_product, dot_product)
|
| 37 |
-
|
| 38 |
-
# Mediapipe landmark indices
|
| 39 |
-
MP_SHOULDERS = (11, 12)
|
| 40 |
-
MP_HIPS = (23, 24)
|
| 41 |
-
|
| 42 |
-
def vis_ok(kp, idxs, thr=0.4):
|
| 43 |
-
"""Check if keypoints at given indices have sufficient visibility"""
|
| 44 |
-
if kp is None or len(kp) <= max(idxs):
|
| 45 |
-
return False
|
| 46 |
-
return all(kp[i][2] >= thr for i in idxs)
|
| 47 |
-
|
| 48 |
-
def seg_angle(p1, p2):
|
| 49 |
-
"""Angle (deg) of segment p1->p2 vs +x axis"""
|
| 50 |
-
return math.degrees(math.atan2(p2[1]-p1[1], p2[0]-p1[0]))
|
| 51 |
-
|
| 52 |
-
def ang_diff(a, b):
|
| 53 |
-
"""Signed difference in [-180, 180]"""
|
| 54 |
-
d = (a - b + 180) % 360 - 180
|
| 55 |
-
return d
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
def _dt_and_fps(frame_timestamps_ms, frames: int, total_ms: float):
|
| 59 |
-
"""Calculate time delta and FPS from frame data"""
|
| 60 |
-
if frame_timestamps_ms and len(frame_timestamps_ms) >= 2:
|
| 61 |
-
dt = (frame_timestamps_ms[-1] - frame_timestamps_ms[0]) / max(len(frame_timestamps_ms) - 1, 1) / 1000.0
|
| 62 |
-
else:
|
| 63 |
-
dt = (total_ms / 1000.0) / max(frames, 1)
|
| 64 |
-
return dt, 1.0 / max(dt, 1e-6)
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
def detect_arm_velocity_zero_crossing(pose_data, frames):
|
| 68 |
-
"""Simple fallback for top detection - now handled in swing_analyzer.py"""
|
| 69 |
-
if not frames:
|
| 70 |
-
return 0
|
| 71 |
-
return frames[len(frames)//3] # Simple fallback
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
# Legacy functions - simplified versions for compatibility
|
| 75 |
-
def validate_metric_sanity(metric_name, value, player_handedness='right'):
|
| 76 |
-
"""Simple validation - now handled in swing_analyzer.py"""
|
| 77 |
-
return True, value
|
| 78 |
-
|
| 79 |
-
def get_target_line_angle(pose_data, address_frame_idx):
|
| 80 |
-
"""Simple target line - now handled in swing_analyzer.py"""
|
| 81 |
-
return 0.0
|
| 82 |
-
|
| 83 |
-
def calculate_thorax_rotation(shoulder_keypoints, reference_angle=0.0, target_line_angle=0.0, reference_keypoints=None):
|
| 84 |
-
"""Simple thorax rotation - now handled in swing_analyzer.py"""
|
| 85 |
-
return 45.0 # Default
|
| 86 |
-
|
| 87 |
-
def calculate_pelvis_rotation(hip_keypoints, reference_angle=0.0, player_handedness='right', target_line_angle=0.0):
|
| 88 |
-
"""Simple pelvis rotation - now handled in swing_analyzer.py"""
|
| 89 |
-
return 30.0 # Default
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/models/metrics_calculator.py
CHANGED
|
@@ -125,13 +125,7 @@ def wrap180_deg(a):
|
|
| 125 |
return a
|
| 126 |
|
| 127 |
|
| 128 |
-
#
|
| 129 |
-
def calculate_shaft_angle_at_address(*args, **kwargs):
|
| 130 |
-
"""Safe stub for removed shaft angle function to prevent NameError"""
|
| 131 |
-
return None
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
# Note: calculate_metric_confidence removed - not used by the 5 DTL metrics
|
| 135 |
# This function was defined but never called by the displayed metrics
|
| 136 |
|
| 137 |
|
|
@@ -790,89 +784,7 @@ def calculate_smoothed_hip_width(pose_data, swing_phases, window_size=5):
|
|
| 790 |
|
| 791 |
|
| 792 |
# Hip depth/early extension metric removed per user request
|
| 793 |
-
|
| 794 |
-
"""Calculate early extension percentage using posterior hip method with unit conversion
|
| 795 |
-
|
| 796 |
-
Args:
|
| 797 |
-
pose_data (dict): Dictionary mapping frame indices to pose keypoints
|
| 798 |
-
swing_phases (dict): Dictionary mapping phase names to lists of frame indices
|
| 799 |
-
frame_w (int): Frame width in pixels
|
| 800 |
-
frame_h (int): Frame height in pixels
|
| 801 |
-
camera_side (str): "trail" or "lead" - which side camera is on
|
| 802 |
-
|
| 803 |
-
Returns:
|
| 804 |
-
float: Early extension percentage (0-100%)
|
| 805 |
-
"""
|
| 806 |
-
setup_frames = swing_phases.get("setup", [])
|
| 807 |
-
impact_frames = swing_phases.get("impact", [])
|
| 808 |
-
|
| 809 |
-
if not setup_frames or not impact_frames:
|
| 810 |
-
return None
|
| 811 |
-
|
| 812 |
-
address_idx = setup_frames[0]
|
| 813 |
-
impact_idx = impact_frames[0]
|
| 814 |
-
|
| 815 |
-
if (address_idx not in pose_data or impact_idx not in pose_data or
|
| 816 |
-
pose_data[address_idx] is None or pose_data[impact_idx] is None):
|
| 817 |
-
return None
|
| 818 |
-
|
| 819 |
-
addr_kp = pose_data[address_idx]
|
| 820 |
-
impact_kp = pose_data[impact_idx]
|
| 821 |
-
|
| 822 |
-
if len(addr_kp) < 25 or len(impact_kp) < 25:
|
| 823 |
-
return None
|
| 824 |
-
|
| 825 |
-
# Ensure pixels
|
| 826 |
-
addr_kp = to_pixels_if_needed(addr_kp, frame_w, frame_h)
|
| 827 |
-
impact_kp = to_pixels_if_needed(impact_kp, frame_w, frame_h)
|
| 828 |
-
|
| 829 |
-
# Check hip keypoint validity - be more lenient
|
| 830 |
-
if (not addr_kp[23] or not addr_kp[24] or not impact_kp[23] or not impact_kp[24] or
|
| 831 |
-
len(addr_kp[23]) < 2 or len(addr_kp[24]) < 2 or
|
| 832 |
-
len(impact_kp[23]) < 2 or len(impact_kp[24]) < 2):
|
| 833 |
-
return None
|
| 834 |
-
|
| 835 |
-
# Posterior-most hip x at address/impact
|
| 836 |
-
hips_addr = [addr_kp[23][0], addr_kp[24][0]]
|
| 837 |
-
hips_impact = [impact_kp[23][0], impact_kp[24][0]]
|
| 838 |
-
|
| 839 |
-
if camera_side == "trail":
|
| 840 |
-
butt_addr = max(hips_addr)
|
| 841 |
-
butt_impact = max(hips_impact)
|
| 842 |
-
else:
|
| 843 |
-
butt_addr = min(hips_addr)
|
| 844 |
-
butt_impact = min(hips_impact)
|
| 845 |
-
|
| 846 |
-
# Hip width at address (pixels) - more lenient threshold
|
| 847 |
-
hip_width = abs(hips_addr[1] - hips_addr[0])
|
| 848 |
-
if hip_width < 5: # Very lenient sanity check
|
| 849 |
-
return None
|
| 850 |
-
|
| 851 |
-
# Hip depth/early extension metric removed per user request
|
| 852 |
-
return None
|
| 853 |
-
|
| 854 |
-
|
| 855 |
-
def calculate_hip_depth_early_extension(pose_data, swing_phases, frame_w=None, frame_h=None):
|
| 856 |
-
"""Calculate hip depth/early extension - DEPRECATED, use early_extension_pct instead
|
| 857 |
-
|
| 858 |
-
This function is kept for backward compatibility but will use the new implementation.
|
| 859 |
-
"""
|
| 860 |
-
# Try to get frame dimensions from first available frame if not provided
|
| 861 |
-
if frame_w is None or frame_h is None:
|
| 862 |
-
for frame_data in pose_data.values():
|
| 863 |
-
if frame_data:
|
| 864 |
-
# Default frame size - this is a fallback and may not be accurate
|
| 865 |
-
frame_w = frame_w or 1920
|
| 866 |
-
frame_h = frame_h or 1080
|
| 867 |
-
break
|
| 868 |
-
|
| 869 |
-
# Hip depth/early extension metric removed per user request
|
| 870 |
-
return None
|
| 871 |
-
|
| 872 |
-
|
| 873 |
-
# Note: Shaft angle functions removed - not part of the 5 core DTL metrics
|
| 874 |
-
# calculate_shaft_angle_at_address, detect_shaft_line_hough, estimate_shaft_angle_fallback
|
| 875 |
-
# These functions were not in the target 5 DTL metrics and can be moved to a separate FO module if needed later
|
| 876 |
|
| 877 |
|
| 878 |
def validate_angle_calculations(pose_data, swing_phases, frames=None):
|
|
@@ -1534,153 +1446,4 @@ def compute_dtl_three(pose_data, swing_phases, frame_w, frame_h, frames=None, ca
|
|
| 1534 |
return metrics
|
| 1535 |
|
| 1536 |
|
| 1537 |
-
def compute_dtl_five_metrics(pose_data, swing_phases, frames=None, frame_w=None, frame_h=None):
|
| 1538 |
-
"""DEPRECATED - use compute_dtl_three instead (hip depth and hip turn removed)
|
| 1539 |
-
|
| 1540 |
-
This function is kept for backward compatibility.
|
| 1541 |
-
"""
|
| 1542 |
-
# Try to get frame dimensions if not provided
|
| 1543 |
-
if frame_w is None or frame_h is None:
|
| 1544 |
-
frame_w = frame_w or 1920
|
| 1545 |
-
frame_h = frame_h or 1080
|
| 1546 |
-
|
| 1547 |
-
result = compute_dtl_three(pose_data, swing_phases, frame_w, frame_h, frames)
|
| 1548 |
-
|
| 1549 |
-
if result is None:
|
| 1550 |
-
return None
|
| 1551 |
-
|
| 1552 |
-
# Apply clamp_or_none for backward compatibility
|
| 1553 |
-
return {
|
| 1554 |
-
"shoulder_plane_top_deg": clamp_or_none(result.get("shoulder_plane_top_deg"), 20, 50),
|
| 1555 |
-
"back_tilt_setup_deg": clamp_or_none(result.get("back_tilt_setup_deg"), 25, 50),
|
| 1556 |
-
"knee_flexion_deg": clamp_or_none(result.get("knee_flexion_deg"), 15, 45),
|
| 1557 |
-
# Hip depth and hip turn metrics removed per user request
|
| 1558 |
-
}
|
| 1559 |
-
|
| 1560 |
|
| 1561 |
-
def generate_diagnostic_overlay(pose_data, swing_phases, frames=None):
|
| 1562 |
-
"""Generate diagnostic overlay information for one frame at address
|
| 1563 |
-
|
| 1564 |
-
Returns the exact format requested:
|
| 1565 |
-
addr_idx=123
|
| 1566 |
-
θ_spine= -56.7°, θ_ground= +1.4°, θ_rel_horiz= -58.1°, back_tilt= 31.9°
|
| 1567 |
-
θ_shaft= -57.9°, shaft_angle= 59.3°, ROI_ok=True, hough_lines=7
|
| 1568 |
-
|
| 1569 |
-
Args:
|
| 1570 |
-
pose_data (dict): Dictionary mapping frame indices to pose keypoints
|
| 1571 |
-
swing_phases (dict): Dictionary mapping phase names to lists of frame indices
|
| 1572 |
-
frames (list, optional): Video frames for analysis
|
| 1573 |
-
|
| 1574 |
-
Returns:
|
| 1575 |
-
dict: Diagnostic overlay data
|
| 1576 |
-
"""
|
| 1577 |
-
try:
|
| 1578 |
-
# Find stable address frame
|
| 1579 |
-
address_idx = find_stable_address_frame(pose_data, swing_phases)
|
| 1580 |
-
if address_idx is None:
|
| 1581 |
-
return None
|
| 1582 |
-
|
| 1583 |
-
if address_idx not in pose_data or pose_data[address_idx] is None:
|
| 1584 |
-
return None
|
| 1585 |
-
|
| 1586 |
-
kp = pose_data[address_idx]
|
| 1587 |
-
if len(kp) < 25:
|
| 1588 |
-
return None
|
| 1589 |
-
|
| 1590 |
-
# Calculate spine vector components
|
| 1591 |
-
shoulder_mid_x = (kp[11][0] + kp[12][0]) / 2
|
| 1592 |
-
shoulder_mid_y = (kp[11][1] + kp[12][1]) / 2
|
| 1593 |
-
hip_mid_x = (kp[23][0] + kp[24][0]) / 2
|
| 1594 |
-
hip_mid_y = (kp[23][1] + kp[24][1]) / 2
|
| 1595 |
-
|
| 1596 |
-
dx_spine = shoulder_mid_x - hip_mid_x
|
| 1597 |
-
dy_spine = shoulder_mid_y - hip_mid_y
|
| 1598 |
-
theta_spine = math.degrees(math.atan2(dy_spine, dx_spine))
|
| 1599 |
-
|
| 1600 |
-
# Get ground angle
|
| 1601 |
-
ground_angle = detect_ground_line_angle(pose_data, swing_phases, frames)
|
| 1602 |
-
theta_ground = math.degrees(ground_angle)
|
| 1603 |
-
theta_ground_wrapped = wrap180_deg(theta_ground)
|
| 1604 |
-
|
| 1605 |
-
# Calculate relative angles
|
| 1606 |
-
theta_rel_horiz = wrap180_deg(theta_spine - theta_ground)
|
| 1607 |
-
back_tilt = 90.0 - abs(theta_rel_horiz)
|
| 1608 |
-
|
| 1609 |
-
# Calculate shaft angle
|
| 1610 |
-
shaft_angle = calculate_shaft_angle_at_address(pose_data, swing_phases, frames)
|
| 1611 |
-
|
| 1612 |
-
# Get shaft angle details for diagnostics
|
| 1613 |
-
theta_shaft = None
|
| 1614 |
-
roi_ok = False
|
| 1615 |
-
hough_lines = 0
|
| 1616 |
-
|
| 1617 |
-
if frames is not None and address_idx < len(frames):
|
| 1618 |
-
# Get grip position
|
| 1619 |
-
grip_pos = np.array(kp[16][:2]) # Right wrist
|
| 1620 |
-
if len(kp) > 15 and kp[15][2] > 0.3: # Left wrist
|
| 1621 |
-
left_grip = np.array(kp[15][:2])
|
| 1622 |
-
grip_pos = (grip_pos + left_grip) / 2
|
| 1623 |
-
|
| 1624 |
-
# Try to detect shaft using Hough
|
| 1625 |
-
try:
|
| 1626 |
-
# Convert to grayscale
|
| 1627 |
-
frame = frames[address_idx]
|
| 1628 |
-
if len(frame.shape) == 3:
|
| 1629 |
-
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
|
| 1630 |
-
else:
|
| 1631 |
-
gray = frame
|
| 1632 |
-
|
| 1633 |
-
height, width = gray.shape
|
| 1634 |
-
grip_x, grip_y = int(grip_pos[0]), int(grip_pos[1])
|
| 1635 |
-
|
| 1636 |
-
# Build ROI
|
| 1637 |
-
x_min = max(0, grip_x - 60)
|
| 1638 |
-
x_max = min(width, grip_x + 100)
|
| 1639 |
-
y_min = max(0, grip_y - 60)
|
| 1640 |
-
y_max = min(height, grip_y + 200)
|
| 1641 |
-
|
| 1642 |
-
roi = gray[y_min:y_max, x_min:x_max]
|
| 1643 |
-
if roi.size > 0:
|
| 1644 |
-
roi_ok = True
|
| 1645 |
-
|
| 1646 |
-
# Apply edge detection
|
| 1647 |
-
edges = cv2.Canny(roi, 50, 150, apertureSize=3)
|
| 1648 |
-
|
| 1649 |
-
# Detect lines using Hough transform
|
| 1650 |
-
lines = cv2.HoughLines(edges, 1, np.pi/180, threshold=30)
|
| 1651 |
-
|
| 1652 |
-
if lines is not None:
|
| 1653 |
-
hough_lines = len(lines)
|
| 1654 |
-
|
| 1655 |
-
# Find shaft angle
|
| 1656 |
-
shaft_candidates = []
|
| 1657 |
-
for line in lines:
|
| 1658 |
-
rho, theta = line[0]
|
| 1659 |
-
angle_deg = math.degrees(theta)
|
| 1660 |
-
angle_wrapped = wrap180_deg(angle_deg)
|
| 1661 |
-
|
| 1662 |
-
if 40 <= abs(angle_wrapped) <= 70:
|
| 1663 |
-
shaft_candidates.append(angle_wrapped)
|
| 1664 |
-
|
| 1665 |
-
if shaft_candidates:
|
| 1666 |
-
theta_shaft = np.median(shaft_candidates)
|
| 1667 |
-
except Exception:
|
| 1668 |
-
pass
|
| 1669 |
-
|
| 1670 |
-
# Format diagnostic output
|
| 1671 |
-
diagnostic_data = {
|
| 1672 |
-
'addr_idx': address_idx,
|
| 1673 |
-
'theta_spine': round(theta_spine, 1),
|
| 1674 |
-
'theta_ground': round(theta_ground_wrapped, 1),
|
| 1675 |
-
'theta_rel_horiz': round(theta_rel_horiz, 1),
|
| 1676 |
-
'back_tilt': round(back_tilt, 1) if back_tilt is not None else None,
|
| 1677 |
-
'theta_shaft': round(theta_shaft, 1) if theta_shaft is not None else None,
|
| 1678 |
-
'shaft_angle': round(shaft_angle, 1) if shaft_angle is not None else None,
|
| 1679 |
-
'roi_ok': roi_ok,
|
| 1680 |
-
'hough_lines': hough_lines
|
| 1681 |
-
}
|
| 1682 |
-
|
| 1683 |
-
return diagnostic_data
|
| 1684 |
-
|
| 1685 |
-
except Exception as e:
|
| 1686 |
-
return {'error': str(e)}
|
|
|
|
| 125 |
return a
|
| 126 |
|
| 127 |
|
| 128 |
+
# Note: calculate_metric_confidence removed - not used by the DTL metrics
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
# This function was defined but never called by the displayed metrics
|
| 130 |
|
| 131 |
|
|
|
|
| 784 |
|
| 785 |
|
| 786 |
# Hip depth/early extension metric removed per user request
|
| 787 |
+
# Note: Hip depth/early extension and shaft angle functions removed - not part of the 4 core DTL metrics
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 788 |
|
| 789 |
|
| 790 |
def validate_angle_calculations(pose_data, swing_phases, frames=None):
|
|
|
|
| 1446 |
return metrics
|
| 1447 |
|
| 1448 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1449 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/models/prompts.py
DELETED
|
@@ -1,11 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Prompt templates for LLM analysis
|
| 3 |
-
"""
|
| 4 |
-
from pathlib import Path
|
| 5 |
-
|
| 6 |
-
def load_coach_prompt():
|
| 7 |
-
"""Load the coach prompt template from markdown file"""
|
| 8 |
-
prompt_file = Path(__file__).with_name("coach_prompt.md")
|
| 9 |
-
return prompt_file.read_text()
|
| 10 |
-
|
| 11 |
-
COACH_PROMPT = load_coach_prompt()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/models/segmentation.py
CHANGED
|
@@ -1,7 +1,21 @@
|
|
| 1 |
"""
|
| 2 |
Simple swing segmentation with downswing re-gating
|
| 3 |
"""
|
| 4 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
def segment_swing(pose_data, detections=None, sample_rate=1, frame_shape=None,
|
| 7 |
frame_timestamps_ms=None, total_ms=None, fps=30.0):
|
|
|
|
| 1 |
"""
|
| 2 |
Simple swing segmentation with downswing re-gating
|
| 3 |
"""
|
| 4 |
+
|
| 5 |
+
def _dt_and_fps(frame_timestamps_ms, frames: int, total_ms: float):
|
| 6 |
+
"""Calculate time delta and FPS from frame data"""
|
| 7 |
+
if frame_timestamps_ms and len(frame_timestamps_ms) >= 2:
|
| 8 |
+
dt = (frame_timestamps_ms[-1] - frame_timestamps_ms[0]) / max(len(frame_timestamps_ms) - 1, 1) / 1000.0
|
| 9 |
+
else:
|
| 10 |
+
dt = (total_ms / 1000.0) / max(frames, 1)
|
| 11 |
+
return dt, 1.0 / max(dt, 1e-6)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def detect_arm_velocity_zero_crossing(pose_data, frames):
|
| 15 |
+
"""Simple fallback for top detection"""
|
| 16 |
+
if not frames:
|
| 17 |
+
return 0
|
| 18 |
+
return frames[len(frames)//3] # Simple fallback
|
| 19 |
|
| 20 |
def segment_swing(pose_data, detections=None, sample_rate=1, frame_shape=None,
|
| 21 |
frame_timestamps_ms=None, total_ms=None, fps=30.0):
|
app/models/swing_analyzer.py
CHANGED
|
@@ -1,694 +1,22 @@
|
|
| 1 |
"""
|
| 2 |
-
Simplified
|
| 3 |
-
|
| 4 |
"""
|
| 5 |
|
| 6 |
import numpy as np
|
| 7 |
-
import math
|
| 8 |
-
import cv2
|
| 9 |
-
from scipy.signal import savgol_filter
|
| 10 |
-
from scipy.stats import zscore
|
| 11 |
-
from sklearn.decomposition import PCA
|
| 12 |
-
from .pose_estimator import calculate_joint_angles
|
| 13 |
-
from .math_utils import _dt_and_fps, detect_arm_velocity_zero_crossing
|
| 14 |
|
| 15 |
-
# One-liner frame mapping replacement
|
| 16 |
-
def to_processed_idx(original_idx, sample_rate): return int(round(original_idx / max(1, sample_rate)))
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
# ===== EVENT DETECTION =====
|
| 20 |
-
|
| 21 |
-
def find_swing_events(pose_data, fps=30, ball_xy=None):
|
| 22 |
-
"""Find key swing events: Top, Impact, Finish"""
|
| 23 |
-
frames = sorted([f for f in pose_data.keys() if pose_data[f] is not None])
|
| 24 |
-
|
| 25 |
-
if len(frames) < 10:
|
| 26 |
-
return {
|
| 27 |
-
'top': frames[len(frames)//3] if frames else 0,
|
| 28 |
-
'impact': frames[len(frames)//2] if frames else 0,
|
| 29 |
-
'finish': frames[-1] if frames else 0
|
| 30 |
-
}
|
| 31 |
-
|
| 32 |
-
# Extract hand Y coordinates
|
| 33 |
-
hands_y = []
|
| 34 |
-
valid_frames = []
|
| 35 |
-
|
| 36 |
-
for frame_idx in frames:
|
| 37 |
-
kp = pose_data[frame_idx]
|
| 38 |
-
if kp and len(kp) > 16 and kp[16][2] > 0.4: # Right wrist
|
| 39 |
-
hands_y.append(kp[16][1])
|
| 40 |
-
valid_frames.append(frame_idx)
|
| 41 |
-
|
| 42 |
-
if len(hands_y) < 5:
|
| 43 |
-
return {
|
| 44 |
-
'top': frames[len(frames)//3],
|
| 45 |
-
'impact': frames[len(frames)//2],
|
| 46 |
-
'finish': frames[-1]
|
| 47 |
-
}
|
| 48 |
-
|
| 49 |
-
# Calculate hand velocity
|
| 50 |
-
hands_y = np.array(hands_y)
|
| 51 |
-
velocity = np.gradient(hands_y)
|
| 52 |
-
|
| 53 |
-
# Find Top (velocity goes from positive to negative)
|
| 54 |
-
top_idx = None
|
| 55 |
-
for i in range(1, len(velocity)):
|
| 56 |
-
if velocity[i-1] > 0 and velocity[i] <= 0:
|
| 57 |
-
top_idx = i
|
| 58 |
-
break
|
| 59 |
-
|
| 60 |
-
if top_idx is None:
|
| 61 |
-
top_idx = len(valid_frames) // 3
|
| 62 |
-
|
| 63 |
-
# Find Impact (local minimum after top)
|
| 64 |
-
impact_idx = top_idx
|
| 65 |
-
if top_idx < len(hands_y) - 5:
|
| 66 |
-
search_range = hands_y[top_idx:top_idx+20] if top_idx+20 < len(hands_y) else hands_y[top_idx:]
|
| 67 |
-
local_min = np.argmin(search_range)
|
| 68 |
-
impact_idx = top_idx + local_min
|
| 69 |
-
|
| 70 |
-
# Find Finish (end of sequence)
|
| 71 |
-
finish_idx = len(valid_frames) - 1
|
| 72 |
-
|
| 73 |
-
return {
|
| 74 |
-
'top': valid_frames[min(top_idx, len(valid_frames)-1)],
|
| 75 |
-
'impact': valid_frames[min(impact_idx, len(valid_frames)-1)],
|
| 76 |
-
'finish': valid_frames[finish_idx]
|
| 77 |
-
}
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
# ===== CORE METRICS =====
|
| 81 |
-
|
| 82 |
-
def calculate_tempo_ratio(top_frame, impact_frame, address_frame=0):
|
| 83 |
-
"""Calculate tempo ratio (backswing time / downswing time)"""
|
| 84 |
-
if top_frame <= address_frame or impact_frame <= top_frame:
|
| 85 |
-
return None
|
| 86 |
-
|
| 87 |
-
backswing_frames = top_frame - address_frame
|
| 88 |
-
downswing_frames = impact_frame - top_frame
|
| 89 |
-
|
| 90 |
-
if downswing_frames == 0:
|
| 91 |
-
return None
|
| 92 |
-
|
| 93 |
-
return backswing_frames / downswing_frames
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
def calculate_shaft_angle_at_top(pose_data, top_frame, target_line_vec=None):
|
| 97 |
-
"""Calculate shaft angle at top using club vector (hand→clubhead) vs target line"""
|
| 98 |
-
if top_frame not in pose_data:
|
| 99 |
-
return None
|
| 100 |
-
|
| 101 |
-
kp = pose_data[top_frame]
|
| 102 |
-
# Check club visibility: shoulder, elbow, wrist (relaxed thresholds)
|
| 103 |
-
if not kp or len(kp) <= 16 or kp[16][2] < 0.3 or kp[14][2] < 0.3 or kp[12][2] < 0.3:
|
| 104 |
-
return None
|
| 105 |
-
|
| 106 |
-
# Build target line from early takeaway path if not provided
|
| 107 |
-
if target_line_vec is None:
|
| 108 |
-
target_line_vec = estimate_target_line_from_takeaway(pose_data, top_frame)
|
| 109 |
-
if target_line_vec is None:
|
| 110 |
-
# Fallback to horizontal target line
|
| 111 |
-
target_line_vec = np.array([1.0, 0.0])
|
| 112 |
-
|
| 113 |
-
# Get hand center position
|
| 114 |
-
wrist_pos = np.array(kp[16][:2]) # Right wrist
|
| 115 |
-
elbow_pos = np.array(kp[14][:2]) # Right elbow
|
| 116 |
-
|
| 117 |
-
# Approximate clubhead position using club length extension
|
| 118 |
-
# Extend from hand along forearm direction by typical club length
|
| 119 |
-
forearm_vec = wrist_pos - elbow_pos
|
| 120 |
-
forearm_length = np.linalg.norm(forearm_vec)
|
| 121 |
-
|
| 122 |
-
if forearm_length < 10: # Too short to be reliable
|
| 123 |
-
return None
|
| 124 |
-
|
| 125 |
-
# Normalize forearm vector and extend by club length (~110% of forearm for typical club)
|
| 126 |
-
forearm_unit = forearm_vec / forearm_length
|
| 127 |
-
club_length = forearm_length * 1.1 # Approximate club length
|
| 128 |
-
clubhead_pos = wrist_pos + forearm_unit * club_length
|
| 129 |
-
|
| 130 |
-
# Build TRUE club shaft vector from hand center to clubhead
|
| 131 |
-
shaft_vec = clubhead_pos - wrist_pos
|
| 132 |
-
shaft_length = np.linalg.norm(shaft_vec)
|
| 133 |
-
|
| 134 |
-
if shaft_length < 10: # Too short to be reliable
|
| 135 |
-
return None
|
| 136 |
-
|
| 137 |
-
shaft_vec = shaft_vec / shaft_length
|
| 138 |
-
target_vec = np.array(target_line_vec) / np.linalg.norm(target_line_vec)
|
| 139 |
-
|
| 140 |
-
# Calculate signed angle between club shaft and target line
|
| 141 |
-
cross_product = shaft_vec[0] * target_vec[1] - shaft_vec[1] * target_vec[0]
|
| 142 |
-
dot_product = np.dot(shaft_vec, target_vec)
|
| 143 |
-
angle_rad = math.atan2(cross_product, dot_product)
|
| 144 |
-
angle_deg = math.degrees(angle_rad)
|
| 145 |
-
|
| 146 |
-
# Fold angle to -90°...+90° range (across-line is positive, laid-off is negative)
|
| 147 |
-
while angle_deg > 90:
|
| 148 |
-
angle_deg -= 180
|
| 149 |
-
while angle_deg < -90:
|
| 150 |
-
angle_deg += 180
|
| 151 |
-
|
| 152 |
-
# Always return the calculated angle - no hiding based on magnitude
|
| 153 |
-
return angle_deg
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
def estimate_target_line_from_takeaway(pose_data, top_frame, lookback_frames=10):
|
| 157 |
-
"""Estimate target line from early takeaway hand/club movement using PCA"""
|
| 158 |
-
# Get frames from early in backswing
|
| 159 |
-
frames = sorted([f for f in pose_data.keys() if f <= top_frame])
|
| 160 |
-
if len(frames) < 5:
|
| 161 |
-
return None
|
| 162 |
-
|
| 163 |
-
takeaway_frames = frames[:min(lookback_frames, len(frames)//2)]
|
| 164 |
-
hand_positions = []
|
| 165 |
-
|
| 166 |
-
for frame_idx in takeaway_frames:
|
| 167 |
-
kp = pose_data[frame_idx]
|
| 168 |
-
if kp and len(kp) > 16 and kp[16][2] > 0.3: # Right wrist (relaxed)
|
| 169 |
-
hand_positions.append(kp[16][:2])
|
| 170 |
-
|
| 171 |
-
if len(hand_positions) < 3:
|
| 172 |
-
return None
|
| 173 |
-
|
| 174 |
-
# Use PCA to find dominant direction of hand movement
|
| 175 |
-
positions = np.array(hand_positions)
|
| 176 |
-
if len(positions) < 2:
|
| 177 |
-
return None
|
| 178 |
-
|
| 179 |
-
# Center the data
|
| 180 |
-
centered = positions - np.mean(positions, axis=0)
|
| 181 |
-
|
| 182 |
-
# Simple PCA: find direction of maximum variance
|
| 183 |
-
cov_matrix = np.cov(centered.T)
|
| 184 |
-
eigenvalues, eigenvectors = np.linalg.eig(cov_matrix)
|
| 185 |
-
|
| 186 |
-
# Principal component (direction of max variance)
|
| 187 |
-
principal_direction = eigenvectors[:, np.argmax(eigenvalues)]
|
| 188 |
-
|
| 189 |
-
return principal_direction
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
def calculate_head_sway(pose_data, fallback_shoulder_width=None, target_line_vec=None):
|
| 193 |
-
"""Calculate head sway using head center, measuring perpendicular to target line"""
|
| 194 |
-
frames = sorted(pose_data.keys())
|
| 195 |
-
if len(frames) < 5:
|
| 196 |
-
return None, None
|
| 197 |
-
|
| 198 |
-
# Calculate robust shoulder width from clean setup frames
|
| 199 |
-
shoulder_width = calculate_robust_shoulder_width(pose_data)
|
| 200 |
-
if shoulder_width is None:
|
| 201 |
-
if fallback_shoulder_width and fallback_shoulder_width > 10:
|
| 202 |
-
shoulder_width = fallback_shoulder_width
|
| 203 |
-
else:
|
| 204 |
-
return None, None
|
| 205 |
-
|
| 206 |
-
# Get target line for perpendicular measurement
|
| 207 |
-
if target_line_vec is None:
|
| 208 |
-
# Estimate from early takeaway or use horizontal default
|
| 209 |
-
target_line_vec = estimate_target_line_from_takeaway(pose_data, frames[-1])
|
| 210 |
-
if target_line_vec is None:
|
| 211 |
-
target_line_vec = np.array([1.0, 0.0]) # Horizontal default
|
| 212 |
-
|
| 213 |
-
# Normalize target line and get perpendicular vector
|
| 214 |
-
target_unit = np.array(target_line_vec) / np.linalg.norm(target_line_vec)
|
| 215 |
-
perp_unit = np.array([-target_unit[1], target_unit[0]]) # 90° rotation
|
| 216 |
-
|
| 217 |
-
# Get head center positions with quality filtering
|
| 218 |
-
head_positions = []
|
| 219 |
-
valid_frames = []
|
| 220 |
-
|
| 221 |
-
for frame_idx in frames:
|
| 222 |
-
kp = pose_data[frame_idx]
|
| 223 |
-
if kp and len(kp) > 7: # Need multiple facial points
|
| 224 |
-
# Calculate head center from available facial landmarks
|
| 225 |
-
head_center = calculate_head_center(kp)
|
| 226 |
-
if head_center is not None:
|
| 227 |
-
head_positions.append(head_center)
|
| 228 |
-
valid_frames.append(frame_idx)
|
| 229 |
-
|
| 230 |
-
if len(head_positions) < 3:
|
| 231 |
-
return None, None
|
| 232 |
-
|
| 233 |
-
head_positions = np.array(head_positions)
|
| 234 |
-
|
| 235 |
-
# Use median of first few frames as reference (more stable than first frame)
|
| 236 |
-
setup_positions = head_positions[:min(5, len(head_positions)//3)]
|
| 237 |
-
reference_pos = np.median(setup_positions, axis=0)
|
| 238 |
-
|
| 239 |
-
# Calculate movement relative to reference
|
| 240 |
-
movements = head_positions - reference_pos
|
| 241 |
-
|
| 242 |
-
# Project movements onto perpendicular-to-target axis (lateral sway)
|
| 243 |
-
lateral_movements = np.dot(movements, perp_unit)
|
| 244 |
-
# Project onto target line axis (forward/back movement)
|
| 245 |
-
sagittal_movements = np.dot(movements, target_unit)
|
| 246 |
-
|
| 247 |
-
# Convert to percentage of shoulder width
|
| 248 |
-
lateral_sway = (lateral_movements / shoulder_width) * 100
|
| 249 |
-
sagittal_sway = (sagittal_movements / shoulder_width) * 100
|
| 250 |
-
|
| 251 |
-
# Apply temporal smoothing to reduce noise
|
| 252 |
-
if len(lateral_sway) > 5:
|
| 253 |
-
lateral_sway = apply_temporal_smoothing(lateral_sway)
|
| 254 |
-
sagittal_sway = apply_temporal_smoothing(sagittal_sway)
|
| 255 |
-
|
| 256 |
-
# Clamp extreme values that indicate scale failures (hide if >100%)
|
| 257 |
-
lateral_sway = np.clip(lateral_sway, -100, 100)
|
| 258 |
-
sagittal_sway = np.clip(sagittal_sway, -100, 100)
|
| 259 |
-
|
| 260 |
-
return lateral_sway.tolist(), sagittal_sway.tolist()
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
def calculate_head_center(kp):
|
| 264 |
-
"""Calculate head center from available facial landmarks with fallback to nose"""
|
| 265 |
-
# Try to use multiple facial points for better head center
|
| 266 |
-
facial_points = []
|
| 267 |
-
|
| 268 |
-
# Nose (0)
|
| 269 |
-
if len(kp) > 0 and kp[0][2] > 0.3:
|
| 270 |
-
facial_points.append(np.array(kp[0][:2]))
|
| 271 |
-
|
| 272 |
-
# Eyes (1, 2) if available
|
| 273 |
-
if len(kp) > 2:
|
| 274 |
-
if kp[1][2] > 0.3: # Left eye
|
| 275 |
-
facial_points.append(np.array(kp[1][:2]))
|
| 276 |
-
if kp[2][2] > 0.3: # Right eye
|
| 277 |
-
facial_points.append(np.array(kp[2][:2]))
|
| 278 |
-
|
| 279 |
-
# Ears (7, 8) if available
|
| 280 |
-
if len(kp) > 8:
|
| 281 |
-
if kp[7][2] > 0.3: # Left ear
|
| 282 |
-
facial_points.append(np.array(kp[7][:2]))
|
| 283 |
-
if kp[8][2] > 0.3: # Right ear
|
| 284 |
-
facial_points.append(np.array(kp[8][:2]))
|
| 285 |
-
|
| 286 |
-
if len(facial_points) == 0:
|
| 287 |
-
return None
|
| 288 |
-
elif len(facial_points) == 1:
|
| 289 |
-
return facial_points[0] # Just nose
|
| 290 |
-
else:
|
| 291 |
-
# Average of available facial landmarks
|
| 292 |
-
return np.mean(facial_points, axis=0)
|
| 293 |
|
| 294 |
-
|
| 295 |
-
def
|
| 296 |
-
|
| 297 |
-
frames = sorted(pose_data.keys())
|
| 298 |
-
setup_frames = frames[:min(10, len(frames)//3)] # Use first third or 10 frames
|
| 299 |
-
|
| 300 |
-
shoulder_widths = []
|
| 301 |
-
|
| 302 |
-
for frame_idx in setup_frames:
|
| 303 |
-
kp = pose_data[frame_idx]
|
| 304 |
-
if (kp and len(kp) > 12 and
|
| 305 |
-
kp[11][2] > 0.4 and kp[12][2] > 0.4): # Relaxed confidence shoulders
|
| 306 |
-
|
| 307 |
-
left_shoulder = np.array(kp[11][:2])
|
| 308 |
-
right_shoulder = np.array(kp[12][:2])
|
| 309 |
-
width = np.linalg.norm(right_shoulder - left_shoulder)
|
| 310 |
-
|
| 311 |
-
if width > 10: # Minimum plausible shoulder width in pixels
|
| 312 |
-
shoulder_widths.append(width)
|
| 313 |
-
|
| 314 |
-
if len(shoulder_widths) < 2:
|
| 315 |
-
return None
|
| 316 |
-
|
| 317 |
-
# Use median for robustness
|
| 318 |
-
median_width = np.median(shoulder_widths)
|
| 319 |
-
|
| 320 |
-
# Filter out frames with excessive drift
|
| 321 |
-
stable_widths = []
|
| 322 |
-
for width in shoulder_widths:
|
| 323 |
-
drift_pct = abs(width - median_width) / median_width * 100
|
| 324 |
-
if drift_pct <= max_drift_pct:
|
| 325 |
-
stable_widths.append(width)
|
| 326 |
-
|
| 327 |
-
if len(stable_widths) < 2:
|
| 328 |
-
return None
|
| 329 |
-
|
| 330 |
-
return np.median(stable_widths)
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
def apply_temporal_smoothing(signal, window_size=5):
|
| 334 |
-
"""Apply simple moving average smoothing"""
|
| 335 |
-
if len(signal) < window_size:
|
| 336 |
-
return signal
|
| 337 |
-
|
| 338 |
-
smoothed = np.convolve(signal, np.ones(window_size)/window_size, mode='same')
|
| 339 |
-
return smoothed
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
def calculate_wrist_hinge_pattern(pose_data, events):
|
| 343 |
-
"""Calculate wrist hinge pattern and detect early casting - requires two signals for red badge"""
|
| 344 |
-
frames = sorted(pose_data.keys())
|
| 345 |
-
wrist_angles = []
|
| 346 |
-
frame_indices = []
|
| 347 |
-
club_visibility_good = True
|
| 348 |
-
|
| 349 |
-
for frame_idx in frames:
|
| 350 |
-
kp = pose_data[frame_idx]
|
| 351 |
-
if kp and len(kp) > 16:
|
| 352 |
-
shoulder = kp[12] # Right shoulder
|
| 353 |
-
elbow = kp[14] # Right elbow
|
| 354 |
-
wrist = kp[16] # Right wrist
|
| 355 |
-
|
| 356 |
-
# Check club tip visibility (stricter for red badge assessment)
|
| 357 |
-
if all(point[2] > 0.5 for point in [shoulder, elbow, wrist]):
|
| 358 |
-
# Calculate wrist hinge angle
|
| 359 |
-
forearm_vec = np.array([wrist[0] - elbow[0], wrist[1] - elbow[1]])
|
| 360 |
-
upper_arm_vec = np.array([elbow[0] - shoulder[0], elbow[1] - shoulder[1]])
|
| 361 |
-
|
| 362 |
-
cos_angle = np.dot(forearm_vec, upper_arm_vec) / (
|
| 363 |
-
np.linalg.norm(forearm_vec) * np.linalg.norm(upper_arm_vec)
|
| 364 |
-
)
|
| 365 |
-
cos_angle = np.clip(cos_angle, -1.0, 1.0)
|
| 366 |
-
angle = math.degrees(math.acos(cos_angle))
|
| 367 |
-
|
| 368 |
-
wrist_angles.append(180 - angle) # Hinge angle
|
| 369 |
-
frame_indices.append(frame_idx)
|
| 370 |
-
else:
|
| 371 |
-
# Club tip visibility compromised
|
| 372 |
-
if any(point[2] < 0.4 for point in [shoulder, elbow, wrist]):
|
| 373 |
-
club_visibility_good = False
|
| 374 |
-
|
| 375 |
-
if len(wrist_angles) < 3:
|
| 376 |
-
return {'pattern': 'insufficient_data', 'casting': False, 'confidence': 'low'}
|
| 377 |
-
|
| 378 |
-
# Check for early casting - need TWO signals for red badge
|
| 379 |
-
top_frame = events.get('top', 0)
|
| 380 |
-
impact_frame = events.get('impact', 0)
|
| 381 |
-
|
| 382 |
-
wrist_angle_trend_bad = False
|
| 383 |
-
shaft_lag_cue_bad = False # Would need additional club head tracking
|
| 384 |
-
|
| 385 |
-
if top_frame in frame_indices and impact_frame in frame_indices:
|
| 386 |
-
top_idx = frame_indices.index(top_frame)
|
| 387 |
-
top_angle = wrist_angles[top_idx]
|
| 388 |
-
|
| 389 |
-
# Signal 1: Wrist angle trend analysis
|
| 390 |
-
early_release_count = 0
|
| 391 |
-
for i in range(top_idx, min(len(wrist_angles), frame_indices.index(impact_frame))):
|
| 392 |
-
if wrist_angles[i] < top_angle * 0.8: # 20% decrease
|
| 393 |
-
early_release_count += 1
|
| 394 |
-
|
| 395 |
-
if early_release_count >= 2: # Multiple frames showing early release
|
| 396 |
-
wrist_angle_trend_bad = True
|
| 397 |
-
|
| 398 |
-
# For DTL view, we can't reliably detect shaft-lag cue (need face-on or 3D)
|
| 399 |
-
# So be conservative: only amber unless we have clear evidence
|
| 400 |
-
|
| 401 |
-
if wrist_angle_trend_bad and club_visibility_good:
|
| 402 |
-
# Only one reliable signal available (wrist angle trend)
|
| 403 |
-
# Per feedback: need TWO signals for red, so stay amber with caution
|
| 404 |
-
pattern = 'early_cast_caution'
|
| 405 |
-
casting = True
|
| 406 |
-
confidence = 'moderate'
|
| 407 |
-
elif wrist_angle_trend_bad:
|
| 408 |
-
pattern = 'early_cast_possible'
|
| 409 |
-
casting = True
|
| 410 |
-
confidence = 'low'
|
| 411 |
-
else:
|
| 412 |
-
pattern = 'set_hold_release'
|
| 413 |
-
casting = False
|
| 414 |
-
confidence = 'good'
|
| 415 |
-
|
| 416 |
-
return {
|
| 417 |
-
'pattern': pattern,
|
| 418 |
-
'casting': casting,
|
| 419 |
-
'confidence': confidence,
|
| 420 |
-
'club_visibility': club_visibility_good
|
| 421 |
-
}
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
# ===== QUALITY CONTROL =====
|
| 425 |
-
|
| 426 |
-
def assess_quality_flags(pose_data, frames=None, frame_shape=None):
|
| 427 |
-
"""Assess quality flags for hiding unstable metrics"""
|
| 428 |
-
flags = {
|
| 429 |
-
'blur_high': False,
|
| 430 |
-
'occlusion_high': False,
|
| 431 |
-
'subject_too_small': False,
|
| 432 |
-
'camera_roll_high': False,
|
| 433 |
-
'shoulder_drift_high': False,
|
| 434 |
-
'overall_stable': True
|
| 435 |
-
}
|
| 436 |
-
|
| 437 |
-
# Calculate occlusion score
|
| 438 |
-
if pose_data:
|
| 439 |
-
total_visibility = 0
|
| 440 |
-
total_keypoints = 0
|
| 441 |
-
key_joints = [11, 12, 13, 14, 15, 16, 23, 24] # Key joints
|
| 442 |
-
|
| 443 |
-
for frame_idx, kp in pose_data.items():
|
| 444 |
-
if kp and len(kp) > max(key_joints):
|
| 445 |
-
for joint_idx in key_joints:
|
| 446 |
-
total_visibility += kp[joint_idx][2]
|
| 447 |
-
total_keypoints += 1
|
| 448 |
-
|
| 449 |
-
avg_visibility = total_visibility / max(total_keypoints, 1)
|
| 450 |
-
flags['occlusion_high'] = avg_visibility < 0.6
|
| 451 |
-
|
| 452 |
-
# Calculate shoulder width drift
|
| 453 |
-
widths = []
|
| 454 |
-
for frame_idx, kp in pose_data.items():
|
| 455 |
-
if kp and len(kp) > 12:
|
| 456 |
-
left_shoulder = kp[11]
|
| 457 |
-
right_shoulder = kp[12]
|
| 458 |
-
|
| 459 |
-
if left_shoulder[2] > 0.5 and right_shoulder[2] > 0.5:
|
| 460 |
-
width = np.linalg.norm(
|
| 461 |
-
np.array(left_shoulder[:2]) - np.array(right_shoulder[:2])
|
| 462 |
-
)
|
| 463 |
-
widths.append(width)
|
| 464 |
-
|
| 465 |
-
if len(widths) > 2:
|
| 466 |
-
median_width = np.median(widths)
|
| 467 |
-
max_drift = max(abs(w - median_width) for w in widths)
|
| 468 |
-
drift_pct = (max_drift / median_width) * 100
|
| 469 |
-
flags['shoulder_drift_high'] = drift_pct > 25
|
| 470 |
-
|
| 471 |
-
# Overall stability
|
| 472 |
-
flags['overall_stable'] = not any([
|
| 473 |
-
flags['occlusion_high'],
|
| 474 |
-
flags['shoulder_drift_high']
|
| 475 |
-
])
|
| 476 |
-
|
| 477 |
-
return flags
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
def classify_metric(metric_name, value):
|
| 481 |
-
"""Classify metrics with traffic light system"""
|
| 482 |
-
if value is None:
|
| 483 |
-
return {'color': 'gray', 'assessment': 'unknown', 'value': None}
|
| 484 |
-
|
| 485 |
-
if metric_name == 'tempo':
|
| 486 |
-
if 2.7 <= value <= 3.3:
|
| 487 |
-
return {'color': 'green', 'assessment': 'good', 'value': value}
|
| 488 |
-
elif 2.4 <= value < 2.7 or 3.3 < value <= 3.6:
|
| 489 |
-
return {'color': 'amber', 'assessment': 'acceptable', 'value': value}
|
| 490 |
-
else:
|
| 491 |
-
return {'color': 'red', 'assessment': 'needs_improvement', 'value': value}
|
| 492 |
-
|
| 493 |
-
elif metric_name == 'shaft_top':
|
| 494 |
-
abs_angle = abs(value)
|
| 495 |
-
if abs_angle <= 10:
|
| 496 |
-
color = 'green'
|
| 497 |
-
elif abs_angle <= 15:
|
| 498 |
-
color = 'amber'
|
| 499 |
-
else:
|
| 500 |
-
color = 'red'
|
| 501 |
-
|
| 502 |
-
direction = 'across_the_line' if value > 0 else 'laid_off'
|
| 503 |
-
return {'color': color, 'assessment': color, 'direction': direction, 'value': value}
|
| 504 |
-
|
| 505 |
-
elif metric_name == 'head_sway':
|
| 506 |
-
if value <= 25:
|
| 507 |
-
return {'color': 'green', 'assessment': 'good', 'value': value}
|
| 508 |
-
elif value <= 35:
|
| 509 |
-
return {'color': 'amber', 'assessment': 'acceptable', 'value': value}
|
| 510 |
-
else:
|
| 511 |
-
return {'color': 'red', 'assessment': 'needs_improvement', 'value': value}
|
| 512 |
-
|
| 513 |
-
return {'color': 'gray', 'assessment': 'unknown', 'value': value}
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
# ===== COACHING SUGGESTIONS =====
|
| 517 |
-
|
| 518 |
-
def get_coaching_suggestion(metric_name, classification):
|
| 519 |
-
"""Generate coaching suggestions based on metric classification"""
|
| 520 |
-
if metric_name == 'tempo':
|
| 521 |
-
tempo_ratio = classification.get('value')
|
| 522 |
-
if classification.get('color') == 'green':
|
| 523 |
-
return "Excellent tempo - maintain your current rhythm"
|
| 524 |
-
elif tempo_ratio and tempo_ratio <= 2.6:
|
| 525 |
-
return "Backswing rushed—train 3:1 with metronome '1-2-3…4'"
|
| 526 |
-
elif tempo_ratio and tempo_ratio >= 3.7:
|
| 527 |
-
return "Backswing too slow—quicken transition, maintain downswing tempo"
|
| 528 |
-
else:
|
| 529 |
-
return "Focus on a smooth 3:1 backswing to downswing rhythm"
|
| 530 |
-
|
| 531 |
-
elif metric_name == 'shaft_top':
|
| 532 |
-
direction = classification.get('direction')
|
| 533 |
-
if classification.get('color') == 'green':
|
| 534 |
-
return "Perfect shaft position at top - maintain this position"
|
| 535 |
-
elif direction == 'laid_off':
|
| 536 |
-
return "Feel 'more across' with a fuller turn or later wrist-set; rehearsal: pause-at-Top with club pointing down the line"
|
| 537 |
-
elif direction == 'across_the_line':
|
| 538 |
-
return "Earlier set / feel 'club more laid-off' at Top; drill: split-hand takeaway to keep shaft neutral"
|
| 539 |
-
else:
|
| 540 |
-
return "Focus on pointing club down target line at top of backswing"
|
| 541 |
-
|
| 542 |
-
elif metric_name == 'head_sway':
|
| 543 |
-
if classification.get('color') == 'green':
|
| 544 |
-
return "Excellent head stability throughout swing"
|
| 545 |
-
else:
|
| 546 |
-
return "Stabilize head—narrower stance or focus on turning around spine; alignment-stick behind head drill"
|
| 547 |
-
|
| 548 |
-
elif metric_name == 'wrist_pattern':
|
| 549 |
-
if classification.get('casting', False):
|
| 550 |
-
return "Maintain hinge to P5; pump drill (3 mini downswings holding angle) then hit"
|
| 551 |
-
else:
|
| 552 |
-
return "Good wrist hinge sequence—set, hold, release"
|
| 553 |
-
|
| 554 |
-
return "Continue working on this aspect of your swing"
|
| 555 |
-
|
| 556 |
-
|
| 557 |
-
# ===== MAIN ANALYSIS FUNCTION =====
|
| 558 |
-
|
| 559 |
-
def analyze_swing_dtl(pose_data, frames=None, ball_position=None):
|
| 560 |
-
"""
|
| 561 |
-
Analyze golf swing using simplified DTL method
|
| 562 |
-
|
| 563 |
-
Args:
|
| 564 |
-
pose_data: Dictionary mapping frame indices to pose keypoints
|
| 565 |
-
frames: List of video frames (optional)
|
| 566 |
-
ball_position: Ball position [x, y] (optional)
|
| 567 |
-
|
| 568 |
-
Returns:
|
| 569 |
-
dict: Complete DTL analysis results
|
| 570 |
-
"""
|
| 571 |
-
# 1. Find events
|
| 572 |
-
events = find_swing_events(pose_data, fps=30, ball_xy=ball_position)
|
| 573 |
-
|
| 574 |
-
# 2. Quality assessment
|
| 575 |
-
quality_flags = assess_quality_flags(pose_data, frames)
|
| 576 |
-
|
| 577 |
-
# 3. Calculate metrics (only if quality is acceptable)
|
| 578 |
-
metrics = {}
|
| 579 |
-
assessment = {}
|
| 580 |
-
coaching = {}
|
| 581 |
-
|
| 582 |
-
if quality_flags.get('overall_stable', False):
|
| 583 |
-
# Get shoulder width for normalization
|
| 584 |
-
widths = []
|
| 585 |
-
for frame_idx, kp in pose_data.items():
|
| 586 |
-
if kp and len(kp) > 12:
|
| 587 |
-
left_shoulder = kp[11]
|
| 588 |
-
right_shoulder = kp[12]
|
| 589 |
-
if left_shoulder[2] > 0.5 and right_shoulder[2] > 0.5:
|
| 590 |
-
width = np.linalg.norm(
|
| 591 |
-
np.array(left_shoulder[:2]) - np.array(right_shoulder[:2])
|
| 592 |
-
)
|
| 593 |
-
widths.append(width)
|
| 594 |
-
|
| 595 |
-
shoulder_width = np.median(widths) if widths else 100
|
| 596 |
-
|
| 597 |
-
# Simple target line (horizontal)
|
| 598 |
-
target_line_vec = [1, 0]
|
| 599 |
-
|
| 600 |
-
# Calculate core metrics
|
| 601 |
-
tempo_ratio = calculate_tempo_ratio(events['top'], events['impact'], 0)
|
| 602 |
-
if tempo_ratio:
|
| 603 |
-
metrics['tempo_ratio'] = tempo_ratio
|
| 604 |
-
assessment['tempo'] = classify_metric('tempo', tempo_ratio)
|
| 605 |
-
coaching['tempo'] = get_coaching_suggestion('tempo', assessment['tempo'])
|
| 606 |
-
|
| 607 |
-
shaft_angle = calculate_shaft_angle_at_top(pose_data, events['top'], target_line_vec)
|
| 608 |
-
if shaft_angle is not None:
|
| 609 |
-
metrics['shaft_top_angle'] = shaft_angle
|
| 610 |
-
assessment['shaft_top'] = classify_metric('shaft_top', shaft_angle)
|
| 611 |
-
coaching['shaft_top'] = get_coaching_suggestion('shaft_top', assessment['shaft_top'])
|
| 612 |
-
|
| 613 |
-
head_sway_lateral, head_sway_vertical = calculate_head_sway(pose_data, shoulder_width)
|
| 614 |
-
if head_sway_lateral:
|
| 615 |
-
max_sway = max(abs(x) for x in head_sway_lateral if x is not None)
|
| 616 |
-
metrics['head_sway_max'] = max_sway
|
| 617 |
-
assessment['head_sway'] = classify_metric('head_sway', max_sway)
|
| 618 |
-
coaching['head_sway'] = get_coaching_suggestion('head_sway', assessment['head_sway'])
|
| 619 |
-
|
| 620 |
-
wrist_pattern = calculate_wrist_hinge_pattern(pose_data, events)
|
| 621 |
-
metrics['wrist_pattern'] = wrist_pattern
|
| 622 |
-
assessment['wrist_pattern'] = wrist_pattern
|
| 623 |
-
coaching['wrist_pattern'] = get_coaching_suggestion('wrist_pattern', wrist_pattern)
|
| 624 |
-
|
| 625 |
-
# 4. Generate summary
|
| 626 |
-
red_count = sum(1 for a in assessment.values()
|
| 627 |
-
if isinstance(a, dict) and a.get('color') == 'red')
|
| 628 |
-
amber_count = sum(1 for a in assessment.values()
|
| 629 |
-
if isinstance(a, dict) and a.get('color') == 'amber')
|
| 630 |
-
green_count = sum(1 for a in assessment.values()
|
| 631 |
-
if isinstance(a, dict) and a.get('color') == 'green')
|
| 632 |
-
|
| 633 |
-
if red_count == 0 and amber_count <= 1:
|
| 634 |
-
overall = 'excellent'
|
| 635 |
-
elif red_count <= 1:
|
| 636 |
-
overall = 'good'
|
| 637 |
-
else:
|
| 638 |
-
overall = 'needs_improvement'
|
| 639 |
-
|
| 640 |
-
# Priority coaching (red items first)
|
| 641 |
-
priority_coaching = []
|
| 642 |
-
for metric_name in ['tempo', 'head_sway', 'shaft_top', 'wrist_pattern']:
|
| 643 |
-
if metric_name in assessment:
|
| 644 |
-
metric_assessment = assessment[metric_name]
|
| 645 |
-
if isinstance(metric_assessment, dict) and metric_assessment.get('color') == 'red':
|
| 646 |
-
if metric_name in coaching:
|
| 647 |
-
priority_coaching.append(f"{metric_name.replace('_', ' ').title()}: {coaching[metric_name]}")
|
| 648 |
-
|
| 649 |
-
return {
|
| 650 |
-
'events': events,
|
| 651 |
-
'quality_flags': quality_flags,
|
| 652 |
-
'metrics': metrics,
|
| 653 |
-
'assessment': assessment,
|
| 654 |
-
'coaching': coaching,
|
| 655 |
-
'summary': {
|
| 656 |
-
'overall_assessment': overall,
|
| 657 |
-
'data_quality': 'stable' if quality_flags.get('overall_stable') else 'unstable',
|
| 658 |
-
'red_metrics': red_count,
|
| 659 |
-
'amber_metrics': amber_count,
|
| 660 |
-
'green_metrics': green_count,
|
| 661 |
-
'priority_coaching': priority_coaching[:2],
|
| 662 |
-
'banner_message': "Want hip rotation and X-Factor analysis? Add a face-on view for complete swing analysis."
|
| 663 |
-
}
|
| 664 |
-
}
|
| 665 |
|
| 666 |
|
| 667 |
-
# Legacy compatibility functions
|
| 668 |
def segment_swing_pose_based(pose_data, detections=None, sample_rate=1, frame_shape=None, **kwargs):
|
| 669 |
"""Legacy function - use analyze_swing_dtl for new analysis"""
|
| 670 |
from .segmentation import segment_swing
|
| 671 |
return segment_swing(pose_data, detections, sample_rate, frame_shape, **kwargs)
|
| 672 |
|
| 673 |
|
| 674 |
-
def get_swing_summary(analysis_results):
|
| 675 |
-
"""Get user-friendly summary of swing analysis"""
|
| 676 |
-
return analysis_results.get('summary', {})
|
| 677 |
-
|
| 678 |
-
|
| 679 |
-
# ===== SIMPLE USAGE EXAMPLE =====
|
| 680 |
-
|
| 681 |
-
# Example usage:
|
| 682 |
-
# from models.swing_analyzer import analyze_swing_dtl
|
| 683 |
-
#
|
| 684 |
-
# # Analyze swing with pose data
|
| 685 |
-
# results = analyze_swing_dtl(pose_data, frames, ball_position)
|
| 686 |
-
#
|
| 687 |
-
# # Get summary
|
| 688 |
-
# summary = results['summary']
|
| 689 |
-
# print(f"Overall: {summary['overall_assessment']}")
|
| 690 |
-
# print(f"Priority fixes: {summary['priority_coaching']}")
|
| 691 |
-
|
| 692 |
def analyze_trajectory(frames, detections, swing_phases, sample_rate=1, fps=30.0):
|
| 693 |
"""
|
| 694 |
Simple trajectory analysis - just track ball movement after impact
|
|
@@ -720,4 +48,4 @@ def analyze_trajectory(frames, detections, swing_phases, sample_rate=1, fps=30.0
|
|
| 720 |
if frame_idx in trajectory_data:
|
| 721 |
trajectory_data[frame_idx]["ball_detected"] = True
|
| 722 |
|
| 723 |
-
return trajectory_data
|
|
|
|
| 1 |
"""
|
| 2 |
+
Simplified swing analyzer - contains only essential functions
|
| 3 |
+
Cleaned up to remove unused functions per user requirements
|
| 4 |
"""
|
| 5 |
|
| 6 |
import numpy as np
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
+
# One-liner frame mapping replacement
|
| 10 |
+
def to_processed_idx(original_idx, sample_rate):
|
| 11 |
+
return int(round(original_idx / max(1, sample_rate)))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
|
| 13 |
|
|
|
|
| 14 |
def segment_swing_pose_based(pose_data, detections=None, sample_rate=1, frame_shape=None, **kwargs):
|
| 15 |
"""Legacy function - use analyze_swing_dtl for new analysis"""
|
| 16 |
from .segmentation import segment_swing
|
| 17 |
return segment_swing(pose_data, detections, sample_rate, frame_shape, **kwargs)
|
| 18 |
|
| 19 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
def analyze_trajectory(frames, detections, swing_phases, sample_rate=1, fps=30.0):
|
| 21 |
"""
|
| 22 |
Simple trajectory analysis - just track ball movement after impact
|
|
|
|
| 48 |
if frame_idx in trajectory_data:
|
| 49 |
trajectory_data[frame_idx]["ball_detected"] = True
|
| 50 |
|
| 51 |
+
return trajectory_data
|
app/streamlit_app.py
CHANGED
|
@@ -20,6 +20,41 @@ load_dotenv()
|
|
| 20 |
# Add the app directory to the path
|
| 21 |
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 22 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
from utils.video_downloader import download_youtube_video, download_pro_reference, cleanup_video_file, cleanup_downloads_directory
|
| 24 |
from utils.video_processor import process_video
|
| 25 |
from models.pose_estimator import analyze_pose
|
|
@@ -190,79 +225,8 @@ def format_metric_value(metric_data, unit=""):
|
|
| 190 |
return f"{value} ({status}){unit}"
|
| 191 |
|
| 192 |
|
| 193 |
-
def get_shaft_angle_grading(value, confidence):
|
| 194 |
-
"""Grade shaft angle at top with sign-aware labels"""
|
| 195 |
-
if value is None:
|
| 196 |
-
return {'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
|
| 200 |
-
|
| 201 |
-
# Determine label based on value (updated thresholds per user spec)
|
| 202 |
-
if -10 <= value <= 10:
|
| 203 |
-
badge = "🟢"
|
| 204 |
-
label = "Neutral"
|
| 205 |
-
elif 10 < value <= 20:
|
| 206 |
-
badge = "🟠"
|
| 207 |
-
label = f"Across (mild)"
|
| 208 |
-
elif 20 < value <= 35:
|
| 209 |
-
badge = "🔴"
|
| 210 |
-
label = f"Across (needs work)"
|
| 211 |
-
elif -20 <= value < -10:
|
| 212 |
-
badge = "🟠"
|
| 213 |
-
label = f"Laid-off (mild)"
|
| 214 |
-
elif -35 <= value < -20:
|
| 215 |
-
badge = "🔴"
|
| 216 |
-
label = f"Laid-off (needs work)"
|
| 217 |
-
else:
|
| 218 |
-
badge = "🔴"
|
| 219 |
-
label = "Extreme"
|
| 220 |
-
|
| 221 |
-
# Format display value with direction and proper sign
|
| 222 |
-
if value > 0:
|
| 223 |
-
display_value = f"+{value:.1f}° Across"
|
| 224 |
-
elif value < 0:
|
| 225 |
-
display_value = f"{value:.1f}° Laid-off"
|
| 226 |
-
else:
|
| 227 |
-
display_value = f"{value:.1f}° Neutral"
|
| 228 |
-
|
| 229 |
-
return {
|
| 230 |
-
'display_value': display_value,
|
| 231 |
-
'badge': badge,
|
| 232 |
-
'label': label,
|
| 233 |
-
'confidence': confidence,
|
| 234 |
-
}
|
| 235 |
|
| 236 |
|
| 237 |
-
def get_head_sway_grading(value, confidence):
|
| 238 |
-
"""Grade head sway with percentage of pelvis/shoulder width"""
|
| 239 |
-
if value is None:
|
| 240 |
-
return {'display_value': 'n/a', 'badge': '⚪', 'status': 'No data available'}
|
| 241 |
-
|
| 242 |
-
# Note: Not hiding any values - display all measurements
|
| 243 |
-
# Even high values might be valid for certain swings or camera setups
|
| 244 |
-
|
| 245 |
-
# Determine grading (updated per user spec)
|
| 246 |
-
if value <= 15:
|
| 247 |
-
badge = "🟢"
|
| 248 |
-
label = "Minimal"
|
| 249 |
-
elif value <= 25:
|
| 250 |
-
badge = "🟢"
|
| 251 |
-
label = "Controlled"
|
| 252 |
-
elif value <= 35:
|
| 253 |
-
badge = "🟠"
|
| 254 |
-
label = "Noticeable"
|
| 255 |
-
else: # 35-50%
|
| 256 |
-
badge = "🔴"
|
| 257 |
-
label = "Excessive"
|
| 258 |
-
|
| 259 |
-
return {
|
| 260 |
-
'display_value': f"{value:.0f}%",
|
| 261 |
-
'badge': badge,
|
| 262 |
-
'label': label,
|
| 263 |
-
'confidence': confidence,
|
| 264 |
-
'approx': True, # DTL-limited
|
| 265 |
-
}
|
| 266 |
|
| 267 |
|
| 268 |
def get_back_tilt_grading(value, confidence, camera_roll=0):
|
|
@@ -328,354 +292,14 @@ def get_knee_flexion_grading(value, confidence):
|
|
| 328 |
}
|
| 329 |
|
| 330 |
|
| 331 |
-
def get_wrist_pattern_grading(pattern_data, confidence):
|
| 332 |
-
"""Grade wrist pattern categorically"""
|
| 333 |
-
if not pattern_data or pattern_data.get('value') is None:
|
| 334 |
-
return {
|
| 335 |
-
'display_value': "Unstable",
|
| 336 |
-
'badge': "⚪",
|
| 337 |
-
'label': "club not visible",
|
| 338 |
-
'confidence': 0
|
| 339 |
-
}
|
| 340 |
-
|
| 341 |
-
# Interpret pattern - prioritize actual pattern value over status
|
| 342 |
-
pattern_value = pattern_data.get('value', '')
|
| 343 |
-
status = pattern_data.get('status', 'unknown')
|
| 344 |
-
|
| 345 |
-
# If we have a clear pattern value, use that instead of relying on status
|
| 346 |
-
if pattern_value == 'set_hold_release':
|
| 347 |
-
badge = "🟢"
|
| 348 |
-
label = "Set-Hold-Release — Excellent"
|
| 349 |
-
elif pattern_value == 'early_cast' or status == 'early_cast':
|
| 350 |
-
badge = "🔴"
|
| 351 |
-
label = "Early Cast — Needs work"
|
| 352 |
-
elif pattern_value == 'late_set' or (status == 'needs_work' and pattern_value != 'set_hold_release'):
|
| 353 |
-
badge = "🟠"
|
| 354 |
-
label = "Late Set — Caution"
|
| 355 |
-
elif status == 'good_sequence':
|
| 356 |
-
badge = "🟢"
|
| 357 |
-
label = "Good Sequence"
|
| 358 |
-
else:
|
| 359 |
-
badge = "🟠"
|
| 360 |
-
label = "Inconsistent Pattern"
|
| 361 |
-
|
| 362 |
-
return {
|
| 363 |
-
'display_value': label,
|
| 364 |
-
'badge': badge,
|
| 365 |
-
'label': "Needs work", # Add descriptive text after badge
|
| 366 |
-
'confidence': confidence
|
| 367 |
-
}
|
| 368 |
|
| 369 |
|
| 370 |
-
def get_qualitative_shaft_angle_grading(raw_value, confidence):
|
| 371 |
-
"""Grade shaft angle qualitatively without showing unreliable numbers"""
|
| 372 |
-
if raw_value is None:
|
| 373 |
-
return None
|
| 374 |
-
|
| 375 |
-
# Use the raw value to determine qualitative assessment
|
| 376 |
-
# Even if the number is unreliable, the direction trend can be informative
|
| 377 |
-
if abs(raw_value) <= 15:
|
| 378 |
-
category = "Neutral"
|
| 379 |
-
badge = "🟢"
|
| 380 |
-
description = "Good plane control"
|
| 381 |
-
elif raw_value > 15:
|
| 382 |
-
if raw_value > 30:
|
| 383 |
-
category = "Across"
|
| 384 |
-
badge = "🟠"
|
| 385 |
-
description = "Strong across-line tendency"
|
| 386 |
-
else:
|
| 387 |
-
category = "Slightly Across"
|
| 388 |
-
badge = "🟢"
|
| 389 |
-
description = "Mild across-line bias"
|
| 390 |
-
else: # raw_value < -15
|
| 391 |
-
if raw_value < -30:
|
| 392 |
-
category = "Laid-off"
|
| 393 |
-
badge = "🟠"
|
| 394 |
-
description = "Strong laid-off tendency"
|
| 395 |
-
else:
|
| 396 |
-
category = "Slightly Laid-off"
|
| 397 |
-
badge = "🟢"
|
| 398 |
-
description = "Mild laid-off bias"
|
| 399 |
-
|
| 400 |
-
return {
|
| 401 |
-
'display_value': category,
|
| 402 |
-
'badge': badge,
|
| 403 |
-
'label': description,
|
| 404 |
-
'confidence': confidence,
|
| 405 |
-
}
|
| 406 |
|
| 407 |
|
| 408 |
-
def get_qualitative_hip_rotation_grading(raw_value, confidence):
|
| 409 |
-
"""Grade hip rotation qualitatively - removed power percentage as unreliable DTL-only"""
|
| 410 |
-
if raw_value is None:
|
| 411 |
-
return {
|
| 412 |
-
'display_value': "Unknown",
|
| 413 |
-
'badge': "⚪",
|
| 414 |
-
'label': "insufficient data",
|
| 415 |
-
'confidence': 0
|
| 416 |
-
}
|
| 417 |
-
|
| 418 |
-
# Assess kinematic sequence without power percentages (DTL unreliable)
|
| 419 |
-
# Focus on relative hip rotation amount without claiming % power contribution
|
| 420 |
-
if isinstance(raw_value, (int, float)):
|
| 421 |
-
# Evaluate based on professional hip rotation ranges
|
| 422 |
-
if raw_value >= 25:
|
| 423 |
-
category = "High Hip Turn"
|
| 424 |
-
badge = "🟢"
|
| 425 |
-
description = "Strong hip rotation"
|
| 426 |
-
elif raw_value >= 18:
|
| 427 |
-
category = "Good Hip Turn"
|
| 428 |
-
badge = "🟢"
|
| 429 |
-
description = "Good hip rotation"
|
| 430 |
-
elif raw_value >= 12:
|
| 431 |
-
category = "Moderate Hip Turn"
|
| 432 |
-
badge = "🟠"
|
| 433 |
-
description = "Moderate hip rotation"
|
| 434 |
-
elif raw_value >= 8:
|
| 435 |
-
category = "Limited Hip Turn"
|
| 436 |
-
badge = "🟠"
|
| 437 |
-
description = "Limited hip rotation"
|
| 438 |
-
else:
|
| 439 |
-
category = "Minimal Hip Turn"
|
| 440 |
-
badge = "🔴"
|
| 441 |
-
description = "Minimal hip rotation"
|
| 442 |
-
else:
|
| 443 |
-
category = "Moderate"
|
| 444 |
-
badge = "🟠"
|
| 445 |
-
description = "Mixed rotation pattern"
|
| 446 |
-
|
| 447 |
-
return {
|
| 448 |
-
'display_value': category,
|
| 449 |
-
'badge': badge,
|
| 450 |
-
'label': description,
|
| 451 |
-
'confidence': confidence,
|
| 452 |
-
}
|
| 453 |
|
| 454 |
|
| 455 |
-
def get_shoulder_turn_grading(value):
|
| 456 |
-
"""Grade shoulder turn quality with proper assessment"""
|
| 457 |
-
# Assess shoulder turn quality, not just quantity
|
| 458 |
-
if value is None:
|
| 459 |
-
return {
|
| 460 |
-
'display_value': "Unknown",
|
| 461 |
-
'badge': "⚪",
|
| 462 |
-
'label': "insufficient data",
|
| 463 |
-
'confidence': 0,
|
| 464 |
-
'dtl_only': True
|
| 465 |
-
}
|
| 466 |
-
|
| 467 |
-
# Convert to quality assessment
|
| 468 |
-
if isinstance(value, str):
|
| 469 |
-
turn_desc = value.lower()
|
| 470 |
-
if turn_desc == "full":
|
| 471 |
-
badge = "🟢"
|
| 472 |
-
quality = "Excellent"
|
| 473 |
-
description = "Complete shoulder rotation"
|
| 474 |
-
elif turn_desc == "normal":
|
| 475 |
-
badge = "🟢"
|
| 476 |
-
quality = "Good"
|
| 477 |
-
description = "Adequate shoulder turn"
|
| 478 |
-
elif turn_desc == "small":
|
| 479 |
-
badge = "🟠"
|
| 480 |
-
quality = "Limited"
|
| 481 |
-
description = "Restricted shoulder turn"
|
| 482 |
-
else:
|
| 483 |
-
badge = "🟠"
|
| 484 |
-
quality = turn_desc.title()
|
| 485 |
-
description = "Mixed shoulder action"
|
| 486 |
-
else:
|
| 487 |
-
# Numeric assessment
|
| 488 |
-
if value >= 90:
|
| 489 |
-
badge = "🟢"
|
| 490 |
-
quality = "Excellent"
|
| 491 |
-
description = "Complete shoulder rotation"
|
| 492 |
-
elif value >= 75:
|
| 493 |
-
badge = "🟢"
|
| 494 |
-
quality = "Good"
|
| 495 |
-
description = "Adequate shoulder turn"
|
| 496 |
-
else:
|
| 497 |
-
badge = "🟠"
|
| 498 |
-
quality = "Limited"
|
| 499 |
-
description = "Restricted shoulder turn"
|
| 500 |
-
|
| 501 |
-
return {
|
| 502 |
-
'display_value': quality,
|
| 503 |
-
'badge': badge,
|
| 504 |
-
'label': description,
|
| 505 |
-
'confidence': 0.7, # Moderate confidence for DTL assessment
|
| 506 |
-
'dtl_only': True
|
| 507 |
-
}
|
| 508 |
|
| 509 |
|
| 510 |
-
def get_hip_depth_grading(hip_depth_data, confidence):
|
| 511 |
-
"""Generate grading for hip depth/early extension with enhanced confidence handling"""
|
| 512 |
-
if not hip_depth_data:
|
| 513 |
-
return None
|
| 514 |
-
|
| 515 |
-
depth_loss_pct = hip_depth_data.get('depth_loss_pct', 0)
|
| 516 |
-
calculation_confidence = hip_depth_data.get('confidence', 1.0)
|
| 517 |
-
adaptive_threshold = hip_depth_data.get('adaptive_threshold', 20.0)
|
| 518 |
-
smoothed_measurement = hip_depth_data.get('smoothed_measurement', False)
|
| 519 |
-
|
| 520 |
-
# Determine confidence level and add appropriate indicators
|
| 521 |
-
confidence_indicators = []
|
| 522 |
-
if confidence < 0.5:
|
| 523 |
-
confidence_indicators.append('⚠️ Low confidence')
|
| 524 |
-
elif confidence < 0.8:
|
| 525 |
-
confidence_indicators.append('⚡ Moderate confidence')
|
| 526 |
-
|
| 527 |
-
if smoothed_measurement:
|
| 528 |
-
confidence_indicators.append('📊 Smoothed')
|
| 529 |
-
|
| 530 |
-
if calculation_confidence < 0.5:
|
| 531 |
-
confidence_indicators.append('🔧 Fallback method')
|
| 532 |
-
|
| 533 |
-
confidence_note = f" ({', '.join(confidence_indicators)})" if confidence_indicators else ""
|
| 534 |
-
|
| 535 |
-
if depth_loss_pct < 5:
|
| 536 |
-
badge = '🟢'
|
| 537 |
-
label = 'Good depth maintenance'
|
| 538 |
-
elif depth_loss_pct < 15:
|
| 539 |
-
badge = '🟠'
|
| 540 |
-
label = 'Some early extension'
|
| 541 |
-
else:
|
| 542 |
-
badge = '🔴'
|
| 543 |
-
label = 'Early extension'
|
| 544 |
-
|
| 545 |
-
# Technical details removed per user request
|
| 546 |
-
|
| 547 |
-
return {
|
| 548 |
-
'value': depth_loss_pct, # Raw value for display logic
|
| 549 |
-
'display_value': f'{depth_loss_pct:.1f}% loss{confidence_note}',
|
| 550 |
-
'badge': badge,
|
| 551 |
-
'label': label,
|
| 552 |
-
'confidence': confidence,
|
| 553 |
-
'status': label, # Use label as status for legacy compatibility
|
| 554 |
-
'calculation_confidence': calculation_confidence,
|
| 555 |
-
'adaptive_threshold': adaptive_threshold
|
| 556 |
-
}
|
| 557 |
-
|
| 558 |
-
|
| 559 |
-
def get_shaft_address_grading(angle_value, confidence):
|
| 560 |
-
"""Generate grading for shaft angle at address"""
|
| 561 |
-
if angle_value is None:
|
| 562 |
-
return {'display_value': 'n/a', 'badge': '⚪', 'status': 'No data available'}
|
| 563 |
-
|
| 564 |
-
if 45 <= angle_value <= 65:
|
| 565 |
-
badge = '🟢'
|
| 566 |
-
label = 'Good setup angle'
|
| 567 |
-
elif (35 <= angle_value < 45) or (65 < angle_value <= 75):
|
| 568 |
-
badge = '🟠'
|
| 569 |
-
label = 'Acceptable angle'
|
| 570 |
-
else:
|
| 571 |
-
badge = '🔴'
|
| 572 |
-
label = 'Needs adjustment'
|
| 573 |
-
|
| 574 |
-
return {
|
| 575 |
-
'display_value': f'{angle_value:.1f}°',
|
| 576 |
-
'badge': badge,
|
| 577 |
-
'label': label,
|
| 578 |
-
'confidence': confidence,
|
| 579 |
-
}
|
| 580 |
-
|
| 581 |
-
|
| 582 |
-
def get_head_displacement_grading(displacement_data, confidence):
|
| 583 |
-
"""Generate grading for head vertical displacement"""
|
| 584 |
-
if not displacement_data:
|
| 585 |
-
return None
|
| 586 |
-
|
| 587 |
-
displacement_pct = displacement_data.get('displacement_pct', 0)
|
| 588 |
-
direction = displacement_data.get('displacement_direction', 'unknown')
|
| 589 |
-
displacement_abs = displacement_data.get('displacement_abs', 0)
|
| 590 |
-
|
| 591 |
-
if displacement_pct < 5:
|
| 592 |
-
badge = '🟢'
|
| 593 |
-
label = 'Very stable head'
|
| 594 |
-
elif displacement_pct < 15:
|
| 595 |
-
badge = '🟠'
|
| 596 |
-
label = 'Some movement'
|
| 597 |
-
else:
|
| 598 |
-
badge = '🔴'
|
| 599 |
-
label = 'Excessive movement'
|
| 600 |
-
|
| 601 |
-
display_text = f'{displacement_pct:.1f}% {direction}' if 'displacement_pct' in displacement_data else f'{displacement_abs:.1f}px {direction}'
|
| 602 |
-
|
| 603 |
-
return {
|
| 604 |
-
'display_value': display_text,
|
| 605 |
-
'badge': badge,
|
| 606 |
-
'label': label,
|
| 607 |
-
'confidence': confidence,
|
| 608 |
-
}
|
| 609 |
-
|
| 610 |
-
|
| 611 |
-
def get_shoulder_rotation_grading(value, confidence, position="top"):
|
| 612 |
-
"""Grade shoulder rotation angle"""
|
| 613 |
-
if value is None:
|
| 614 |
-
return {'display_value': 'n/a', 'badge': '⚪', 'status': 'No data available'}
|
| 615 |
-
|
| 616 |
-
# Professional ranges: Top ~90-110°, Impact ~30-50°
|
| 617 |
-
if position == "top":
|
| 618 |
-
if 85 <= value <= 115:
|
| 619 |
-
badge = '🟢'
|
| 620 |
-
label = 'Excellent rotation'
|
| 621 |
-
elif 75 <= value < 85 or 115 < value <= 125:
|
| 622 |
-
badge = '🟠'
|
| 623 |
-
label = 'Good rotation'
|
| 624 |
-
else:
|
| 625 |
-
badge = '🔴'
|
| 626 |
-
label = 'Needs improvement'
|
| 627 |
-
else: # impact
|
| 628 |
-
if 30 <= value <= 50:
|
| 629 |
-
badge = '🟢'
|
| 630 |
-
label = 'Excellent impact position'
|
| 631 |
-
elif 20 <= value < 30 or 50 < value <= 60:
|
| 632 |
-
badge = '🟠'
|
| 633 |
-
label = 'Good impact position'
|
| 634 |
-
else:
|
| 635 |
-
badge = '🔴'
|
| 636 |
-
label = 'Needs improvement'
|
| 637 |
-
|
| 638 |
-
return {
|
| 639 |
-
'display_value': f"{value:.1f}°",
|
| 640 |
-
'badge': badge,
|
| 641 |
-
'label': label,
|
| 642 |
-
'confidence': confidence,
|
| 643 |
-
}
|
| 644 |
-
|
| 645 |
-
|
| 646 |
-
def get_hip_rotation_grading(value, confidence, position="top"):
|
| 647 |
-
"""Grade hip rotation angle"""
|
| 648 |
-
if value is None:
|
| 649 |
-
return {'display_value': 'n/a', 'badge': '⚪', 'status': 'No data available'}
|
| 650 |
-
|
| 651 |
-
# Professional ranges: Top ~45-60°, Impact ~40-60°
|
| 652 |
-
if position == "top":
|
| 653 |
-
if 40 <= value <= 65:
|
| 654 |
-
badge = '🟢'
|
| 655 |
-
label = 'Excellent turn'
|
| 656 |
-
elif 30 <= value < 40 or 65 < value <= 75:
|
| 657 |
-
badge = '🟠'
|
| 658 |
-
label = 'Good turn'
|
| 659 |
-
else:
|
| 660 |
-
badge = '🔴'
|
| 661 |
-
label = 'Needs improvement'
|
| 662 |
-
else: # impact
|
| 663 |
-
if 35 <= value <= 65:
|
| 664 |
-
badge = '🟢'
|
| 665 |
-
label = 'Excellent impact position'
|
| 666 |
-
elif 25 <= value < 35 or 65 < value <= 75:
|
| 667 |
-
badge = '🟠'
|
| 668 |
-
label = 'Good impact position'
|
| 669 |
-
else:
|
| 670 |
-
badge = '🔴'
|
| 671 |
-
label = 'Needs improvement'
|
| 672 |
-
|
| 673 |
-
return {
|
| 674 |
-
'display_value': f"{value:.1f}°",
|
| 675 |
-
'badge': badge,
|
| 676 |
-
'label': label,
|
| 677 |
-
'confidence': confidence,
|
| 678 |
-
}
|
| 679 |
|
| 680 |
|
| 681 |
|
|
@@ -875,31 +499,30 @@ def get_wrist_hinge_grading(value, confidence, position="top"):
|
|
| 875 |
}
|
| 876 |
|
| 877 |
|
| 878 |
-
|
| 879 |
-
|
| 880 |
-
|
| 881 |
-
|
| 882 |
-
|
| 883 |
-
|
| 884 |
-
|
| 885 |
-
|
| 886 |
-
|
| 887 |
-
elif abs(value) <= 10.0: # <= 10% of shoulder width
|
| 888 |
-
badge = '🟡'
|
| 889 |
-
label = 'Good stability'
|
| 890 |
-
elif abs(value) <= 15.0: # <= 15% of shoulder width
|
| 891 |
-
badge = '🟠'
|
| 892 |
-
label = 'Moderate sway'
|
| 893 |
-
else: # > 15% of shoulder width
|
| 894 |
-
badge = '🔴'
|
| 895 |
-
label = 'Excessive sway'
|
| 896 |
-
|
| 897 |
-
return {
|
| 898 |
-
'display_value': f"{value:.1f}%",
|
| 899 |
-
'badge': badge,
|
| 900 |
-
'label': label,
|
| 901 |
-
'confidence': confidence,
|
| 902 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 903 |
|
| 904 |
|
| 905 |
def display_new_grading_scheme(core_metrics):
|
|
|
|
| 20 |
# Add the app directory to the path
|
| 21 |
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 22 |
|
| 23 |
+
# ===== FORCE MODULE RELOAD FOR UPDATED METRICS =====
|
| 24 |
+
# This ensures the latest front_facing_metrics.py fixes are loaded
|
| 25 |
+
import importlib
|
| 26 |
+
modules_to_reload = [
|
| 27 |
+
'models.front_facing_metrics',
|
| 28 |
+
'models.metrics_calculator',
|
| 29 |
+
'models.pose_estimator',
|
| 30 |
+
'models.swing_analyzer',
|
| 31 |
+
'models.llm_analyzer'
|
| 32 |
+
]
|
| 33 |
+
|
| 34 |
+
for module in modules_to_reload:
|
| 35 |
+
if module in sys.modules:
|
| 36 |
+
importlib.reload(sys.modules[module])
|
| 37 |
+
print(f"🔄 Reloaded {module}")
|
| 38 |
+
|
| 39 |
+
# Enable debug mode for front-facing metrics
|
| 40 |
+
try:
|
| 41 |
+
import models.front_facing_metrics as ffm
|
| 42 |
+
ffm.set_debug(True)
|
| 43 |
+
print(f"✅ Front-facing metrics version: {ffm.METRICS_VERSION}")
|
| 44 |
+
print(f"✅ Debug enabled: {ffm.VERBOSE}")
|
| 45 |
+
except Exception as e:
|
| 46 |
+
print(f"⚠️ Could not enable debug mode: {e}")
|
| 47 |
+
|
| 48 |
+
# Clear Streamlit caches to ensure fresh data
|
| 49 |
+
if hasattr(st, 'cache_data'):
|
| 50 |
+
st.cache_data.clear()
|
| 51 |
+
if hasattr(st, 'cache_resource'):
|
| 52 |
+
st.cache_resource.clear()
|
| 53 |
+
|
| 54 |
+
print("🚀 Module reloads complete - running with latest fixes!")
|
| 55 |
+
# ===== END MODULE RELOAD SECTION =====
|
| 56 |
+
|
| 57 |
+
# Import modules (will use reloaded versions from above)
|
| 58 |
from utils.video_downloader import download_youtube_video, download_pro_reference, cleanup_video_file, cleanup_downloads_directory
|
| 59 |
from utils.video_processor import process_video
|
| 60 |
from models.pose_estimator import analyze_pose
|
|
|
|
| 225 |
return f"{value} ({status}){unit}"
|
| 226 |
|
| 227 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 228 |
|
| 229 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 230 |
|
| 231 |
|
| 232 |
def get_back_tilt_grading(value, confidence, camera_roll=0):
|
|
|
|
| 292 |
}
|
| 293 |
|
| 294 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 295 |
|
| 296 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 297 |
|
| 298 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 299 |
|
| 300 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 301 |
|
| 302 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 303 |
|
| 304 |
|
| 305 |
|
|
|
|
| 499 |
}
|
| 500 |
|
| 501 |
|
| 502 |
+
|
| 503 |
+
|
| 504 |
+
def grade_hip_turn(delta_deg: float, club: str = "iron") -> dict:
|
| 505 |
+
"""Grade hip turn with club-aware bands and actionable tips"""
|
| 506 |
+
c = club.lower()
|
| 507 |
+
bands = {
|
| 508 |
+
"driver": {"excellent": (38,48), "good_low": (32,38), "good_high": (48,52)},
|
| 509 |
+
"iron": {"excellent": (32,40), "good_low": (28,32), "good_high": (40,44)},
|
| 510 |
+
"wedge": {"excellent": (25,35), "good_low": (22,25), "good_high": (35,38)},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 511 |
}
|
| 512 |
+
b = bands["driver" if "driver" in c else "wedge" if "wedge" in c else "iron"]
|
| 513 |
+
x = delta_deg
|
| 514 |
+
|
| 515 |
+
if b["excellent"][0] <= x <= b["excellent"][1]:
|
| 516 |
+
label, color, tip = "🟢 Excellent", "green", "Great rotation with control."
|
| 517 |
+
elif b["good_low"][0] <= x < b["good_low"][1]:
|
| 518 |
+
label, color, tip = "🟠 Good (slightly low)", "orange", "Open a touch more through impact."
|
| 519 |
+
elif b["good_high"][0] <= x <= b["good_high"][1]:
|
| 520 |
+
label, color, tip = "🟠 Good (slightly high)", "orange", "Post on lead leg; avoid over-spinning hips."
|
| 521 |
+
else:
|
| 522 |
+
label, color, tip = "🔴 Needs work", "red", (
|
| 523 |
+
"Too little: shift then open. Too much: trail heel down longer; post into lead glute."
|
| 524 |
+
)
|
| 525 |
+
return {"label": label, "color": color, "tip": tip}
|
| 526 |
|
| 527 |
|
| 528 |
def display_new_grading_scheme(core_metrics):
|