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