chenemii commited on
Commit
95ad9d6
·
1 Parent(s): f6ccbdd

Major code cleanup: Remove unused metrics and consolidate model files

Browse files

- Remove unused DTL metrics (keep only 4): shoulder tilt/swing plane, back tilt, knee flexion, head drop/rise
- Remove unused front-facing metrics (keep only 4): shoulder tilt @ impact, hip turn @ impact, hip sway @ top, wrist hinge @ top
- Remove 12 unused grading functions from streamlit_app.py
- Consolidate model files: merge math_utils into segmentation, prompts into llm_analyzer
- Remove deprecated functions: early extension, shaft angle, diagnostic overlay
- Clean up swing_analyzer.py to keep only essential functions
- Update imports and fix circular dependencies
- All functionality preserved for 8 core metrics

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