Spaces:
Paused
Paused
git push origin mainMerge remote-tracking branch 'github/main'
Browse files- app/main.py +9 -4
- app/models/llm_analyzer.py +1087 -275
- app/models/swing_analyzer.py +27 -10
- app/streamlit_app.py +43 -29
- app/utils/comparison.py +1 -0
- app/utils/video_downloader.py +82 -0
- app/utils/video_processor.py +3 -6
- app/utils/visualizer.py +1 -1
app/main.py
CHANGED
|
@@ -13,7 +13,7 @@ load_dotenv()
|
|
| 13 |
# Add the app directory to the path
|
| 14 |
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 15 |
|
| 16 |
-
from app.utils.video_downloader import download_youtube_video
|
| 17 |
from app.utils.video_processor import process_video
|
| 18 |
from app.models.pose_estimator import analyze_pose
|
| 19 |
from app.models.swing_analyzer import segment_swing, analyze_trajectory
|
|
@@ -33,11 +33,12 @@ def main():
|
|
| 33 |
"\nEnable GPT analysis? (y/n, default: y): ").lower() != 'n'
|
| 34 |
|
| 35 |
sample_rate_input = input(
|
| 36 |
-
"\nFrame
|
| 37 |
-
sample_rate =
|
| 38 |
if sample_rate_input.isdigit():
|
| 39 |
sample_rate = max(1, min(10, int(sample_rate_input)))
|
| 40 |
|
|
|
|
| 41 |
try:
|
| 42 |
# Step 3: Download the video
|
| 43 |
print("\nDownloading video...")
|
|
@@ -95,7 +96,11 @@ def main():
|
|
| 95 |
|
| 96 |
except Exception as e:
|
| 97 |
print(f"\nError: {str(e)}")
|
| 98 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
|
| 100 |
|
| 101 |
if __name__ == "__main__":
|
|
|
|
| 13 |
# Add the app directory to the path
|
| 14 |
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 15 |
|
| 16 |
+
from app.utils.video_downloader import download_youtube_video, cleanup_video_file
|
| 17 |
from app.utils.video_processor import process_video
|
| 18 |
from app.models.pose_estimator import analyze_pose
|
| 19 |
from app.models.swing_analyzer import segment_swing, analyze_trajectory
|
|
|
|
| 33 |
"\nEnable GPT analysis? (y/n, default: y): ").lower() != 'n'
|
| 34 |
|
| 35 |
sample_rate_input = input(
|
| 36 |
+
"\nFrame processing rate for YOLO (1-10, default: 1 for all frames): ")
|
| 37 |
+
sample_rate = 1 # Default value - process all frames
|
| 38 |
if sample_rate_input.isdigit():
|
| 39 |
sample_rate = max(1, min(10, int(sample_rate_input)))
|
| 40 |
|
| 41 |
+
video_path = None # Initialize video_path for cleanup
|
| 42 |
try:
|
| 43 |
# Step 3: Download the video
|
| 44 |
print("\nDownloading video...")
|
|
|
|
| 96 |
|
| 97 |
except Exception as e:
|
| 98 |
print(f"\nError: {str(e)}")
|
| 99 |
+
finally:
|
| 100 |
+
# Clean up the original downloaded video file after processing
|
| 101 |
+
if video_path:
|
| 102 |
+
print("\nCleaning up downloaded video file...")
|
| 103 |
+
cleanup_video_file(video_path)
|
| 104 |
|
| 105 |
|
| 106 |
if __name__ == "__main__":
|
app/models/llm_analyzer.py
CHANGED
|
@@ -6,6 +6,9 @@ import json
|
|
| 6 |
import httpx
|
| 7 |
from openai import OpenAI
|
| 8 |
import streamlit as st
|
|
|
|
|
|
|
|
|
|
| 9 |
|
| 10 |
|
| 11 |
def check_llm_services():
|
|
@@ -61,15 +64,14 @@ def generate_swing_analysis(pose_data, swing_phases, trajectory_data):
|
|
| 61 |
trajectory_data (dict): Dictionary mapping frame indices to trajectory data
|
| 62 |
|
| 63 |
Returns:
|
| 64 |
-
str: Detailed swing analysis and coaching tips
|
| 65 |
"""
|
| 66 |
# Check available services
|
| 67 |
services = check_llm_services()
|
| 68 |
|
| 69 |
-
# If no services are available, return
|
| 70 |
-
if not services['ollama']['available'] and not services['openai'][
|
| 71 |
-
|
| 72 |
-
return get_sample_analysis()
|
| 73 |
|
| 74 |
# Prepare data for LLM
|
| 75 |
analysis_data = prepare_data_for_llm(pose_data, swing_phases,
|
|
@@ -84,7 +86,7 @@ def generate_swing_analysis(pose_data, swing_phases, trajectory_data):
|
|
| 84 |
if analysis:
|
| 85 |
return analysis
|
| 86 |
except Exception as e:
|
| 87 |
-
print(f"Error with Ollama: {str(e)}
|
| 88 |
|
| 89 |
# Try OpenAI if available
|
| 90 |
if services['openai']['available']:
|
|
@@ -94,11 +96,10 @@ def generate_swing_analysis(pose_data, swing_phases, trajectory_data):
|
|
| 94 |
if analysis:
|
| 95 |
return analysis
|
| 96 |
except Exception as e:
|
| 97 |
-
print(
|
| 98 |
-
f"Error with OpenAI: {str(e)}. Using sample analysis instead.")
|
| 99 |
|
| 100 |
-
# If both services failed, return
|
| 101 |
-
return
|
| 102 |
|
| 103 |
|
| 104 |
def call_ollama_service(prompt, config):
|
|
@@ -164,7 +165,7 @@ def call_openai_service(prompt, config):
|
|
| 164 |
try:
|
| 165 |
# Try with GPT-4 first
|
| 166 |
response = client.chat.completions.create(
|
| 167 |
-
model="gpt-
|
| 168 |
messages=[{
|
| 169 |
"role":
|
| 170 |
"system",
|
|
@@ -211,304 +212,1115 @@ def call_openai_service(prompt, config):
|
|
| 211 |
return None
|
| 212 |
|
| 213 |
|
| 214 |
-
def
|
| 215 |
-
"""
|
| 216 |
-
Return sample analysis when no LLM services are available
|
| 217 |
-
|
| 218 |
-
Returns:
|
| 219 |
-
str: Sample swing analysis
|
| 220 |
-
"""
|
| 221 |
-
return """
|
| 222 |
-
## Swing Analysis Summary
|
| 223 |
-
|
| 224 |
-
Based on the video analysis, here are some observations about your swing:
|
| 225 |
-
|
| 226 |
-
### Setup Phase
|
| 227 |
-
- Your stance appears slightly wider than shoulder-width, which can provide good stability
|
| 228 |
-
- Your posture shows a good spine angle, though you could bend slightly more from the hips
|
| 229 |
-
- The ball position looks appropriate for the club you're using
|
| 230 |
-
|
| 231 |
-
### Backswing
|
| 232 |
-
- Your takeaway is smooth with good tempo
|
| 233 |
-
- Your wrist hinge develops appropriately in the backswing
|
| 234 |
-
- Your right elbow could be kept a bit closer to your body for better consistency
|
| 235 |
-
|
| 236 |
-
### Downswing
|
| 237 |
-
- Good weight transfer from back foot to front foot during the transition
|
| 238 |
-
- Your hips are rotating well through impact
|
| 239 |
-
- The swing plane looks consistent throughout the downswing
|
| 240 |
-
|
| 241 |
-
### Impact
|
| 242 |
-
- Club face alignment at impact appears slightly open
|
| 243 |
-
- Your head position is stable through impact
|
| 244 |
-
- The club path is on a good line toward the target
|
| 245 |
-
|
| 246 |
-
### Follow Through
|
| 247 |
-
- Good balance maintained through the finish
|
| 248 |
-
- Full extension of arms after impact
|
| 249 |
-
- Complete rotation of the body toward the target
|
| 250 |
-
|
| 251 |
-
## Areas for Improvement
|
| 252 |
-
|
| 253 |
-
1. **Club Face Control**: The slightly open club face at impact suggests you may be prone to slicing the ball. Focus on maintaining a square club face through impact.
|
| 254 |
-
|
| 255 |
-
2. **Right Elbow Position**: Keeping your right elbow closer to your body during the backswing will help create a more consistent swing plane.
|
| 256 |
-
|
| 257 |
-
3. **Hip Rotation**: While your hip rotation is good, increasing the speed of rotation could generate more power in your swing.
|
| 258 |
-
|
| 259 |
-
4. **Wrist Release**: Your wrist release could be more active through impact to generate additional club head speed.
|
| 260 |
-
|
| 261 |
-
These adjustments should help improve both consistency and distance in your swing.
|
| 262 |
-
"""
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
def prepare_data_for_llm(pose_data, swing_phases, trajectory_data):
|
| 266 |
"""
|
| 267 |
Prepare swing data for LLM analysis
|
| 268 |
|
| 269 |
Args:
|
| 270 |
pose_data (dict): Dictionary mapping frame indices to pose keypoints
|
| 271 |
swing_phases (dict): Dictionary mapping phase names to lists of frame indices
|
| 272 |
-
trajectory_data (dict):
|
| 273 |
|
| 274 |
Returns:
|
| 275 |
-
dict:
|
| 276 |
"""
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
# Get joint angles for the representative frame
|
| 286 |
-
if mid_frame in pose_data:
|
| 287 |
-
keypoints = pose_data[mid_frame]
|
| 288 |
-
|
| 289 |
-
# Calculate key metrics for each phase
|
| 290 |
-
analysis_data["swing_phases"][phase] = {
|
| 291 |
-
"frame_index": mid_frame,
|
| 292 |
-
"duration_frames": len(frames)
|
| 293 |
-
}
|
| 294 |
-
|
| 295 |
-
# Process trajectory data
|
| 296 |
-
impact_frames = swing_phases.get("impact", [])
|
| 297 |
-
if impact_frames:
|
| 298 |
-
impact_frame = impact_frames[len(impact_frames) // 2]
|
| 299 |
-
if impact_frame in trajectory_data:
|
| 300 |
-
impact_data = trajectory_data[impact_frame]
|
| 301 |
-
if "club_speed" in impact_data and impact_data["club_speed"]:
|
| 302 |
-
analysis_data["trajectory"]["club_speed_mph"] = impact_data[
|
| 303 |
-
"club_speed"]
|
| 304 |
-
|
| 305 |
-
# Calculate backswing and downswing durations if available
|
| 306 |
-
backswing_frames = swing_phases.get("backswing", [])
|
| 307 |
downswing_frames = swing_phases.get("downswing", [])
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
if backswing_frames
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
#
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 368 |
}
|
| 369 |
-
|
| 370 |
-
return
|
| 371 |
|
| 372 |
|
| 373 |
def create_llm_prompt(analysis_data):
|
| 374 |
"""
|
| 375 |
-
Create a prompt for
|
| 376 |
|
| 377 |
Args:
|
| 378 |
-
analysis_data (dict): Processed swing analysis data
|
| 379 |
|
| 380 |
Returns:
|
| 381 |
-
str:
|
| 382 |
"""
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
""
|
| 388 |
-
|
| 389 |
-
#
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 401 |
|
| 402 |
# Core body mechanics
|
| 403 |
-
prompt += "\n### Body Mechanics\n"
|
| 404 |
-
prompt += "-
|
| 405 |
-
|
| 406 |
-
prompt += "-
|
| 407 |
-
|
| 408 |
-
prompt += "- Shoulder Rotation (degrees): {}\n".format(
|
| 409 |
-
analysis_data["metrics"].get("shoulder_rotation", 0))
|
| 410 |
-
prompt += "- Posture Score: {}%\n".format(
|
| 411 |
-
int(analysis_data["metrics"].get("posture_score", 0) * 100))
|
| 412 |
|
| 413 |
# Upper body mechanics
|
| 414 |
prompt += "\n### Upper Body Mechanics\n"
|
| 415 |
-
prompt += "- Arm Extension (
|
| 416 |
-
|
| 417 |
-
prompt += "-
|
| 418 |
-
|
| 419 |
-
prompt += "-
|
| 420 |
-
|
| 421 |
-
prompt += "- Chest Rotation Efficiency: {}%\n".format(
|
| 422 |
-
int(analysis_data["metrics"].get("chest_rotation_efficiency", 0.75) *
|
| 423 |
-
100))
|
| 424 |
-
prompt += "- Head Movement (lateral): {}in\n".format(
|
| 425 |
-
analysis_data["metrics"].get("head_movement_lateral", 2.5))
|
| 426 |
-
prompt += "- Head Movement (vertical): {}in\n".format(
|
| 427 |
-
analysis_data["metrics"].get("head_movement_vertical", 1.8))
|
| 428 |
|
| 429 |
# Lower body mechanics
|
| 430 |
prompt += "\n### Lower Body Mechanics\n"
|
| 431 |
-
prompt += "-
|
| 432 |
-
|
| 433 |
-
prompt += "-
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
prompt += "
|
| 438 |
-
|
| 439 |
-
prompt += "-
|
| 440 |
-
int(analysis_data["metrics"].get("ground_force_efficiency", 0.7) *
|
| 441 |
-
100))
|
| 442 |
-
|
| 443 |
-
# Swing path and clubface metrics
|
| 444 |
-
prompt += "\n### Club Path & Face Metrics\n"
|
| 445 |
-
prompt += "- Swing Path (degrees): {} ({})\n".format(
|
| 446 |
-
analysis_data["metrics"].get("swing_path", 2.5), "Out-to-In"
|
| 447 |
-
if analysis_data["metrics"].get("swing_path", 0) > 0 else "In-to-Out")
|
| 448 |
-
prompt += "- Clubface Angle (degrees): {} ({})\n".format(
|
| 449 |
-
analysis_data["metrics"].get("clubface_angle", 2.1), "Open"
|
| 450 |
-
if analysis_data["metrics"].get("clubface_angle", 0) > 0 else "Closed")
|
| 451 |
-
prompt += "- Attack Angle (degrees): {} ({})\n".format(
|
| 452 |
-
analysis_data["metrics"].get("attack_angle", -4.2), "Descending" if
|
| 453 |
-
analysis_data["metrics"].get("attack_angle", 0) < 0 else "Ascending")
|
| 454 |
-
prompt += "- Club Path Consistency: {}%\n".format(
|
| 455 |
-
int(analysis_data["metrics"].get("club_path_consistency", 0.78) * 100))
|
| 456 |
-
|
| 457 |
-
# Tempo and timing metrics
|
| 458 |
-
prompt += "\n### Tempo & Timing\n"
|
| 459 |
-
prompt += "- Transition Smoothness: {}%\n".format(
|
| 460 |
-
int(analysis_data["metrics"].get("transition_smoothness", 0.75) * 100))
|
| 461 |
-
prompt += "- Backswing Duration: {} seconds\n".format(
|
| 462 |
-
analysis_data["metrics"].get("backswing_duration", 0.9))
|
| 463 |
-
prompt += "- Downswing Duration: {} seconds\n".format(
|
| 464 |
-
analysis_data["metrics"].get("downswing_duration", 0.3))
|
| 465 |
-
prompt += "- Sequential Kinematic Sequence: {}%\n".format(
|
| 466 |
-
int(analysis_data["metrics"].get("kinematic_sequence", 0.82) * 100))
|
| 467 |
|
| 468 |
# Efficiency and power metrics
|
| 469 |
prompt += "\n### Efficiency & Power Metrics\n"
|
| 470 |
-
prompt += "- Energy Transfer Efficiency: {}%\n"
|
| 471 |
-
|
| 472 |
-
prompt += "- Potential Distance: {} yards\n"
|
| 473 |
-
|
| 474 |
-
prompt += "- Power Accumulation: {}%\n".format(
|
| 475 |
-
int(analysis_data["metrics"].get("power_accumulation", 0.75) * 100))
|
| 476 |
-
prompt += "- Speed Generation Method: {}\n".format(
|
| 477 |
-
analysis_data["metrics"].get("speed_generation", "Arms-dominant"))
|
| 478 |
|
| 479 |
prompt += """
|
| 480 |
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 512 |
"""
|
| 513 |
|
| 514 |
return prompt
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
import httpx
|
| 7 |
from openai import OpenAI
|
| 8 |
import streamlit as st
|
| 9 |
+
import re
|
| 10 |
+
import numpy as np
|
| 11 |
+
from app.models.pose_estimator import calculate_joint_angles
|
| 12 |
|
| 13 |
|
| 14 |
def check_llm_services():
|
|
|
|
| 64 |
trajectory_data (dict): Dictionary mapping frame indices to trajectory data
|
| 65 |
|
| 66 |
Returns:
|
| 67 |
+
str: Detailed swing analysis and coaching tips, or error message
|
| 68 |
"""
|
| 69 |
# Check available services
|
| 70 |
services = check_llm_services()
|
| 71 |
|
| 72 |
+
# If no services are available, return error message
|
| 73 |
+
if not services['ollama']['available'] and not services['openai']['available']:
|
| 74 |
+
return "Error: No AI services available. Please ensure either Ollama is running or OpenAI API key is configured."
|
|
|
|
| 75 |
|
| 76 |
# Prepare data for LLM
|
| 77 |
analysis_data = prepare_data_for_llm(pose_data, swing_phases,
|
|
|
|
| 86 |
if analysis:
|
| 87 |
return analysis
|
| 88 |
except Exception as e:
|
| 89 |
+
print(f"Error with Ollama: {str(e)}")
|
| 90 |
|
| 91 |
# Try OpenAI if available
|
| 92 |
if services['openai']['available']:
|
|
|
|
| 96 |
if analysis:
|
| 97 |
return analysis
|
| 98 |
except Exception as e:
|
| 99 |
+
print(f"Error with OpenAI: {str(e)}")
|
|
|
|
| 100 |
|
| 101 |
+
# If both services failed, return error message
|
| 102 |
+
return "Error: All AI services failed. Please check your API keys and service configurations."
|
| 103 |
|
| 104 |
|
| 105 |
def call_ollama_service(prompt, config):
|
|
|
|
| 165 |
try:
|
| 166 |
# Try with GPT-4 first
|
| 167 |
response = client.chat.completions.create(
|
| 168 |
+
model="gpt-4o-mini",
|
| 169 |
messages=[{
|
| 170 |
"role":
|
| 171 |
"system",
|
|
|
|
| 212 |
return None
|
| 213 |
|
| 214 |
|
| 215 |
+
def prepare_data_for_llm(pose_data, swing_phases, trajectory_data=None):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 216 |
"""
|
| 217 |
Prepare swing data for LLM analysis
|
| 218 |
|
| 219 |
Args:
|
| 220 |
pose_data (dict): Dictionary mapping frame indices to pose keypoints
|
| 221 |
swing_phases (dict): Dictionary mapping phase names to lists of frame indices
|
| 222 |
+
trajectory_data (dict, optional): Ball trajectory data
|
| 223 |
|
| 224 |
Returns:
|
| 225 |
+
dict: Formatted swing data for LLM
|
| 226 |
"""
|
| 227 |
+
|
| 228 |
+
# Calculate actual biomechanical metrics from pose data
|
| 229 |
+
bio_metrics = calculate_biomechanical_metrics(pose_data, swing_phases)
|
| 230 |
+
|
| 231 |
+
# Calculate phase durations and timing metrics
|
| 232 |
+
setup_frames = swing_phases.get("setup", [])
|
| 233 |
+
backswing_frames = swing_phases.get("backswing", [])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 234 |
downswing_frames = swing_phases.get("downswing", [])
|
| 235 |
+
impact_frames = swing_phases.get("impact", [])
|
| 236 |
+
follow_through_frames = swing_phases.get("follow_through", [])
|
| 237 |
+
|
| 238 |
+
# Calculate tempo ratio (downswing:backswing)
|
| 239 |
+
backswing_duration = len(backswing_frames) if backswing_frames else 1
|
| 240 |
+
downswing_duration = len(downswing_frames) if downswing_frames else 1
|
| 241 |
+
tempo_ratio = downswing_duration / backswing_duration if backswing_duration > 0 else 1.0
|
| 242 |
+
|
| 243 |
+
# Calculate total swing duration and club speed estimates
|
| 244 |
+
total_frames = len(setup_frames) + len(backswing_frames) + len(downswing_frames) + len(impact_frames) + len(follow_through_frames)
|
| 245 |
+
|
| 246 |
+
# Estimate club speed based on downswing duration (faster downswing = higher speed)
|
| 247 |
+
# Professional downswings are typically 10-15 frames at 30fps
|
| 248 |
+
if downswing_duration > 0:
|
| 249 |
+
speed_factor = max(0.5, min(2.0, 12.0 / downswing_duration)) # Normalize around 12 frames
|
| 250 |
+
estimated_club_speed = 70 + (speed_factor * 40) # Base 70 mph, up to 110 mph
|
| 251 |
+
else:
|
| 252 |
+
estimated_club_speed = 85
|
| 253 |
+
|
| 254 |
+
# Process joint angles if available
|
| 255 |
+
joint_angles = {}
|
| 256 |
+
if pose_data:
|
| 257 |
+
# Get a representative frame for joint analysis
|
| 258 |
+
rep_frame = None
|
| 259 |
+
if impact_frames:
|
| 260 |
+
rep_frame = impact_frames[0]
|
| 261 |
+
elif downswing_frames:
|
| 262 |
+
rep_frame = downswing_frames[len(downswing_frames) // 2]
|
| 263 |
+
elif backswing_frames:
|
| 264 |
+
rep_frame = backswing_frames[-1]
|
| 265 |
+
|
| 266 |
+
if rep_frame and rep_frame in pose_data:
|
| 267 |
+
try:
|
| 268 |
+
from app.models.pose_estimator import calculate_joint_angles
|
| 269 |
+
joint_angles = calculate_joint_angles(pose_data[rep_frame])
|
| 270 |
+
except Exception as e:
|
| 271 |
+
print(f"Error calculating joint angles: {e}")
|
| 272 |
+
joint_angles = {}
|
| 273 |
+
|
| 274 |
+
# Prepare the structured data
|
| 275 |
+
swing_data = {
|
| 276 |
+
"swing_phases": {
|
| 277 |
+
"setup": {
|
| 278 |
+
"frame_count": len(setup_frames),
|
| 279 |
+
"duration_ms": len(setup_frames) * 33.33 # Assuming 30fps
|
| 280 |
+
},
|
| 281 |
+
"backswing": {
|
| 282 |
+
"frame_count": len(backswing_frames),
|
| 283 |
+
"duration_ms": len(backswing_frames) * 33.33
|
| 284 |
+
},
|
| 285 |
+
"downswing": {
|
| 286 |
+
"frame_count": len(downswing_frames),
|
| 287 |
+
"duration_ms": len(downswing_frames) * 33.33
|
| 288 |
+
},
|
| 289 |
+
"impact": {
|
| 290 |
+
"frame_count": len(impact_frames),
|
| 291 |
+
"duration_ms": len(impact_frames) * 33.33
|
| 292 |
+
},
|
| 293 |
+
"follow_through": {
|
| 294 |
+
"frame_count": len(follow_through_frames),
|
| 295 |
+
"duration_ms": len(follow_through_frames) * 33.33
|
| 296 |
+
}
|
| 297 |
+
},
|
| 298 |
+
|
| 299 |
+
"timing_metrics": {
|
| 300 |
+
"tempo_ratio": round(tempo_ratio, 2),
|
| 301 |
+
"total_swing_frames": total_frames,
|
| 302 |
+
"total_swing_time_ms": total_frames * 33.33,
|
| 303 |
+
"estimated_club_speed_mph": round(estimated_club_speed, 1)
|
| 304 |
+
},
|
| 305 |
+
|
| 306 |
+
"biomechanical_metrics": {
|
| 307 |
+
# Core rotation metrics
|
| 308 |
+
"hip_rotation_degrees": round(bio_metrics.get("hip_rotation", 25), 1),
|
| 309 |
+
"shoulder_rotation_degrees": round(bio_metrics.get("shoulder_rotation", 60), 1),
|
| 310 |
+
"chest_rotation_efficiency_percent": round(bio_metrics.get("chest_rotation_efficiency", 0.6) * 100, 1),
|
| 311 |
+
|
| 312 |
+
# Weight transfer and stability
|
| 313 |
+
"weight_shift_percent": round(bio_metrics.get("weight_shift", 0.5) * 100, 1),
|
| 314 |
+
"ground_force_efficiency_percent": round(bio_metrics.get("ground_force_efficiency", 0.6) * 100, 1),
|
| 315 |
+
"hip_thrust_percent": round(bio_metrics.get("hip_thrust", 0.5) * 100, 1),
|
| 316 |
+
|
| 317 |
+
# Arm and club mechanics
|
| 318 |
+
"arm_extension_percent": round(bio_metrics.get("arm_extension", 0.6) * 100, 1),
|
| 319 |
+
"wrist_hinge_degrees": round(bio_metrics.get("wrist_hinge", 60), 1),
|
| 320 |
+
"swing_plane_consistency_percent": round(bio_metrics.get("swing_plane_consistency", 0.6) * 100, 1),
|
| 321 |
+
|
| 322 |
+
# Posture and stability
|
| 323 |
+
"posture_score_percent": round(bio_metrics.get("posture_score", 0.6) * 100, 1),
|
| 324 |
+
"head_movement_lateral_inches": round(bio_metrics.get("head_movement_lateral", 3.0), 1),
|
| 325 |
+
"head_movement_vertical_inches": round(bio_metrics.get("head_movement_vertical", 2.0), 1),
|
| 326 |
+
|
| 327 |
+
# Leg mechanics
|
| 328 |
+
"knee_flexion_address_degrees": round(bio_metrics.get("knee_flexion_address", 25), 1),
|
| 329 |
+
"knee_flexion_impact_degrees": round(bio_metrics.get("knee_flexion_impact", 30), 1),
|
| 330 |
+
|
| 331 |
+
# Advanced coordination metrics
|
| 332 |
+
"transition_smoothness_percent": round(bio_metrics.get("transition_smoothness", 0.6) * 100, 1),
|
| 333 |
+
"kinematic_sequence_percent": round(bio_metrics.get("kinematic_sequence", 0.6) * 100, 1),
|
| 334 |
+
"energy_transfer_efficiency_percent": round(bio_metrics.get("energy_transfer", 0.6) * 100, 1),
|
| 335 |
+
"power_accumulation_percent": round(bio_metrics.get("power_accumulation", 0.6) * 100, 1),
|
| 336 |
+
|
| 337 |
+
# Performance estimates
|
| 338 |
+
"potential_distance_yards": round(bio_metrics.get("potential_distance", 200), 0),
|
| 339 |
+
"speed_generation_method": bio_metrics.get("speed_generation", "Mixed")
|
| 340 |
+
},
|
| 341 |
+
|
| 342 |
+
"joint_angles": joint_angles,
|
| 343 |
+
|
| 344 |
+
"trajectory_analysis": trajectory_data if trajectory_data else {
|
| 345 |
+
"estimated_carry_distance": round(bio_metrics.get("potential_distance", 200) * 0.85, 0),
|
| 346 |
+
"estimated_ball_speed": round(estimated_club_speed * 1.4, 1), # Rough conversion
|
| 347 |
+
"trajectory_type": "Mid" if bio_metrics.get("arm_extension", 0.6) > 0.7 else "Low"
|
| 348 |
+
}
|
| 349 |
}
|
| 350 |
+
|
| 351 |
+
return swing_data
|
| 352 |
|
| 353 |
|
| 354 |
def create_llm_prompt(analysis_data):
|
| 355 |
"""
|
| 356 |
+
Create a comprehensive prompt for LLM analysis with professional benchmarks
|
| 357 |
|
| 358 |
Args:
|
| 359 |
+
analysis_data (dict): Processed swing analysis data with biomechanical metrics
|
| 360 |
|
| 361 |
Returns:
|
| 362 |
+
str: Formatted prompt for LLM analysis
|
| 363 |
"""
|
| 364 |
+
|
| 365 |
+
# Extract metrics from the new data structure
|
| 366 |
+
bio_metrics = analysis_data.get("biomechanical_metrics", {})
|
| 367 |
+
timing_metrics = analysis_data.get("timing_metrics", {})
|
| 368 |
+
swing_phases = analysis_data.get("swing_phases", {})
|
| 369 |
+
|
| 370 |
+
prompt = """# Golf Swing Analysis
|
| 371 |
+
|
| 372 |
+
## PROFESSIONAL BENCHMARKS FOR CALIBRATION
|
| 373 |
+
Use these professional standards as your 100% reference for scoring. These represent elite-level golf swing mechanics based on actual LPGA Tour professional analysis:
|
| 374 |
+
|
| 375 |
+
### Professional Golfer Analysis Summary (100% Reference Standards):
|
| 376 |
+
|
| 377 |
+
**Atthaya Thitikul (LPGA Tour - Elite Level):**
|
| 378 |
+
- Hip Rotation: 63.4°, Shoulder Rotation: 120°, Posture Score: 98.2%
|
| 379 |
+
- Weight Shift: 88.4%, Arm Extension: 99.8%, Wrist Hinge: 120°
|
| 380 |
+
- Energy Transfer: 96.1%, Power Accumulation: 100%, Potential Distance: 295 yards
|
| 381 |
+
- Sequential Kinematic Sequence: 100%, Swing Plane Consistency: 85%
|
| 382 |
+
|
| 383 |
+
**Nelly Korda (LPGA Tour - Elite Level):**
|
| 384 |
+
- Hip Rotation: 90°, Shoulder Rotation: 120°, Posture Score: 97.4%
|
| 385 |
+
- Weight Shift: 73.5%, Arm Extension: 96.7%, Wrist Hinge: 114.8°
|
| 386 |
+
- Energy Transfer: 91.2%, Power Accumulation: 100%, Potential Distance: 289 yards
|
| 387 |
+
- Sequential Kinematic Sequence: 100%, Swing Plane Consistency: 85%
|
| 388 |
+
|
| 389 |
+
**Demi Runas (Professional Level):**
|
| 390 |
+
- Hip Rotation: 63.4°, Shoulder Rotation: 120°, Posture Score: 95.9%
|
| 391 |
+
- Weight Shift: 63.9%, Arm Extension: 96.6%, Wrist Hinge: 93.4°
|
| 392 |
+
- Energy Transfer: 88.0%, Power Accumulation: 100%, Potential Distance: 286 yards
|
| 393 |
+
- Sequential Kinematic Sequence: 100%, Swing Plane Consistency: 85%
|
| 394 |
+
|
| 395 |
+
### **PROFESSIONAL STANDARDS CALIBRATION (100% Level):**
|
| 396 |
+
**Core Biomechanical Metrics:**
|
| 397 |
+
- **Hip Rotation**: 60-90° (Exceptional body turn and flexibility)
|
| 398 |
+
- **Shoulder Rotation**: 120° (Full shoulder coil for maximum power)
|
| 399 |
+
- **Posture Score**: 95-98% (Exceptional spine angle consistency)
|
| 400 |
+
- **Weight Shift**: 70-88% (Excellent weight transfer to lead side)
|
| 401 |
+
|
| 402 |
+
**Upper Body Excellence:**
|
| 403 |
+
- **Arm Extension**: 96-100% (Near-perfect extension at impact)
|
| 404 |
+
- **Wrist Hinge**: 95-120° (Optimal lag and release timing)
|
| 405 |
+
- **Swing Plane Consistency**: 85% (Tour-level repeatability)
|
| 406 |
+
- **Chest Rotation Efficiency**: 100% (Perfect coordination)
|
| 407 |
+
|
| 408 |
+
**Power & Efficiency Markers:**
|
| 409 |
+
- **Energy Transfer Efficiency**: 88-96% (Elite power transfer)
|
| 410 |
+
- **Power Accumulation**: 100% (Maximum power generation)
|
| 411 |
+
- **Sequential Kinematic Sequence**: 100% (Perfect body sequencing)
|
| 412 |
+
- **Potential Distance**: 285-295 yards (Tour-level power)
|
| 413 |
+
|
| 414 |
+
**Movement Quality Standards:**
|
| 415 |
+
- **Head Movement**: 2-8 inches (Controlled, minimal excessive movement)
|
| 416 |
+
- **Ground Force Efficiency**: 70-88% (Excellent ground interaction)
|
| 417 |
+
- **Hip Thrust**: 40-100% (Strong lower body drive)
|
| 418 |
+
|
| 419 |
+
### **AMATEUR REFERENCE EXAMPLES FOR CALIBRATION:**
|
| 420 |
+
|
| 421 |
+
**70% Level Skilled Amateur (Female):**
|
| 422 |
+
- Hip Rotation: 23.0°, Shoulder Rotation: 120° (Excellent shoulder turn, limited hip mobility)
|
| 423 |
+
- Posture Score: 89.5%, Weight Shift: 90.0% (Strong fundamentals)
|
| 424 |
+
- Arm Extension: 99.8%, Wrist Hinge: 49.4° (Great extension, needs more lag)
|
| 425 |
+
- Energy Transfer: 94.5%, Power Accumulation: 82.1% (Very good coordination)
|
| 426 |
+
- Potential Distance: 273 yards, Sequential Kinematic: 93.6%
|
| 427 |
+
- Head Movement: 8.0in lateral, 6.0in vertical (Excessive movement)
|
| 428 |
+
- Speed Generation: Mixed
|
| 429 |
+
|
| 430 |
+
**50-60% Level Amateur (Male #1 - Body-Dominant):**
|
| 431 |
+
- Hip Rotation: 90°, Shoulder Rotation: 84.8° (Great hip turn, limited shoulder)
|
| 432 |
+
- Posture Score: 90.7%, Weight Shift: 90.0% (Solid fundamentals)
|
| 433 |
+
- Arm Extension: 100.0%, Wrist Hinge: 66.8° (Good extension and lag)
|
| 434 |
+
- Energy Transfer: 91.8%, Power Accumulation: 100.0% (Strong power generation)
|
| 435 |
+
- Potential Distance: 290 yards, Sequential Kinematic: 100.0%
|
| 436 |
+
- Hip Thrust: 100.0%, Ground Force: 90.0% (Excellent lower body)
|
| 437 |
+
- Speed Generation: Body-dominant
|
| 438 |
+
|
| 439 |
+
**50-60% Level Amateur (Male #2 - Body-Dominant):**
|
| 440 |
+
- Hip Rotation: 90°, Shoulder Rotation: 120° (Excellent rotation both)
|
| 441 |
+
- Posture Score: 89.3%, Weight Shift: 90.0% (Good fundamentals)
|
| 442 |
+
- Arm Extension: 99.6%, Wrist Hinge: 52.6° (Great extension, limited lag)
|
| 443 |
+
- Energy Transfer: 96.7%, Power Accumulation: 100.0% (Excellent coordination)
|
| 444 |
+
- Potential Distance: 296 yards, Sequential Kinematic: 100.0%
|
| 445 |
+
- Tempo Issues: Very fast downswing (2.86 ratio vs ideal ~0.3)
|
| 446 |
+
- Speed Generation: Body-dominant
|
| 447 |
+
|
| 448 |
+
**50-60% Level Amateur (Female - Arms-Dominant):**
|
| 449 |
+
- Hip Rotation: 25°, Shoulder Rotation: 60° (Limited body rotation)
|
| 450 |
+
- Posture Score: 80.6%, Weight Shift: 50.0% (Needs improvement)
|
| 451 |
+
- Arm Extension: 94.8%, Wrist Hinge: 116.6° (Good extension, excellent lag)
|
| 452 |
+
- Energy Transfer: 56.8%, Power Accumulation: 89.3% (Mixed efficiency)
|
| 453 |
+
- Potential Distance: 241 yards, Sequential Kinematic: 66.8%
|
| 454 |
+
- Head Movement: 3.0in lateral, 2.0in vertical (Good head control)
|
| 455 |
+
- Ground Force: 50.0%, Hip Thrust: 30.0% (Weak lower body)
|
| 456 |
+
- Speed Generation: Arms-dominant
|
| 457 |
+
|
| 458 |
+
**CRITICAL INSIGHTS FROM AMATEUR ANALYSIS:**
|
| 459 |
+
1. **Hip Rotation Varies Significantly**: From 23-90° in amateurs vs 60-90° in professionals
|
| 460 |
+
2. **Shoulder Rotation Range**: 60-120° in amateurs, professionals consistently at 120°
|
| 461 |
+
3. **Wrist Hinge Compensation**: Some amateurs (116.6°) exceed professional standards to compensate for limited body rotation
|
| 462 |
+
4. **Power Generation Methods**: Body-dominant amateurs can achieve near-professional distances despite technical limitations
|
| 463 |
+
5. **Head Movement Control**: Varies dramatically (3-8 inches) - major differentiator
|
| 464 |
+
6. **Energy Transfer Efficiency**: Ranges from 56.8-96.7% in amateurs vs 88-96% in professionals
|
| 465 |
+
7. **Weight Transfer Issues**: Some amateurs struggle with weight shift (50% vs professional 70-88%)
|
| 466 |
+
|
| 467 |
+
## CURRENT SWING ANALYSIS
|
| 468 |
+
|
| 469 |
+
### Swing Phase Breakdown
|
| 470 |
+
""".format(
|
| 471 |
+
swing_phases.get("setup", {}).get("frame_count", 44),
|
| 472 |
+
swing_phases.get("backswing", {}).get("frame_count", 7),
|
| 473 |
+
swing_phases.get("downswing", {}).get("frame_count", 12),
|
| 474 |
+
swing_phases.get("impact", {}).get("frame_count", 1),
|
| 475 |
+
swing_phases.get("follow_through", {}).get("frame_count", 37),
|
| 476 |
+
timing_metrics.get("tempo_ratio", 0.6)
|
| 477 |
+
)
|
| 478 |
+
|
| 479 |
+
# Add swing phase details
|
| 480 |
+
for phase_name, phase_data in swing_phases.items():
|
| 481 |
+
prompt += f"- {phase_name.title()}: {phase_data.get('frame_count', 0)} frames ({phase_data.get('duration_ms', 0):.0f}ms)\n"
|
| 482 |
+
|
| 483 |
+
prompt += f"- Total Swing: {timing_metrics.get('total_swing_frames', 0)} frames ({timing_metrics.get('total_swing_time_ms', 0):.0f}ms)\n"
|
| 484 |
+
prompt += f"- Tempo Ratio (down:back): {timing_metrics.get('tempo_ratio', 1.0)}\n"
|
| 485 |
+
prompt += f"- Estimated Club Speed: {timing_metrics.get('estimated_club_speed_mph', 85)} mph\n"
|
| 486 |
|
| 487 |
# Core body mechanics
|
| 488 |
+
prompt += "\n### Core Body Mechanics\n"
|
| 489 |
+
prompt += f"- Hip Rotation: {bio_metrics.get('hip_rotation_degrees', 25)}°\n"
|
| 490 |
+
prompt += f"- Shoulder Rotation: {bio_metrics.get('shoulder_rotation_degrees', 60)}°\n"
|
| 491 |
+
prompt += f"- Posture Score: {bio_metrics.get('posture_score_percent', 60)}%\n"
|
| 492 |
+
prompt += f"- Weight Shift (lead foot at impact): {bio_metrics.get('weight_shift_percent', 50)}%\n"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 493 |
|
| 494 |
# Upper body mechanics
|
| 495 |
prompt += "\n### Upper Body Mechanics\n"
|
| 496 |
+
prompt += f"- Arm Extension: {bio_metrics.get('arm_extension_percent', 60)}%\n"
|
| 497 |
+
prompt += f"- Wrist Hinge: {bio_metrics.get('wrist_hinge_degrees', 60)}°\n"
|
| 498 |
+
prompt += f"- Shoulder Plane Consistency: {bio_metrics.get('swing_plane_consistency_percent', 60)}%\n"
|
| 499 |
+
prompt += f"- Chest Rotation Efficiency: {bio_metrics.get('chest_rotation_efficiency_percent', 60)}%\n"
|
| 500 |
+
prompt += f"- Head Movement (lateral): {bio_metrics.get('head_movement_lateral_inches', 3.0)}in\n"
|
| 501 |
+
prompt += f"- Head Movement (vertical): {bio_metrics.get('head_movement_vertical_inches', 2.0)}in\n"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 502 |
|
| 503 |
# Lower body mechanics
|
| 504 |
prompt += "\n### Lower Body Mechanics\n"
|
| 505 |
+
prompt += f"- Knee Flexion (address): {bio_metrics.get('knee_flexion_address_degrees', 25)}°\n"
|
| 506 |
+
prompt += f"- Knee Flexion (impact): {bio_metrics.get('knee_flexion_impact_degrees', 30)}°\n"
|
| 507 |
+
prompt += f"- Hip Thrust (impact): {bio_metrics.get('hip_thrust_percent', 50)}%\n"
|
| 508 |
+
prompt += f"- Ground Force Efficiency: {bio_metrics.get('ground_force_efficiency_percent', 60)}%\n"
|
| 509 |
+
|
| 510 |
+
# Advanced coordination metrics
|
| 511 |
+
prompt += "\n### Movement Quality & Timing\n"
|
| 512 |
+
prompt += f"- Transition Smoothness: {bio_metrics.get('transition_smoothness_percent', 60)}%\n"
|
| 513 |
+
prompt += f"- Sequential Kinematic Sequence: {bio_metrics.get('kinematic_sequence_percent', 60)}%\n"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 514 |
|
| 515 |
# Efficiency and power metrics
|
| 516 |
prompt += "\n### Efficiency & Power Metrics\n"
|
| 517 |
+
prompt += f"- Energy Transfer Efficiency: {bio_metrics.get('energy_transfer_efficiency_percent', 60)}%\n"
|
| 518 |
+
prompt += f"- Power Accumulation: {bio_metrics.get('power_accumulation_percent', 60)}%\n"
|
| 519 |
+
prompt += f"- Potential Distance: {bio_metrics.get('potential_distance_yards', 200)} yards\n"
|
| 520 |
+
prompt += f"- Speed Generation Method: {bio_metrics.get('speed_generation_method', 'Mixed')}\n"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 521 |
|
| 522 |
prompt += """
|
| 523 |
|
| 524 |
+
## ANALYSIS INSTRUCTIONS
|
| 525 |
+
|
| 526 |
+
Using the professional benchmarks and amateur examples above as your calibration reference, provide your analysis in the following EXACT structured format:
|
| 527 |
+
|
| 528 |
+
**PERFORMANCE_CLASSIFICATION:** [XX%] (where XX is a percentage from 10% to 100%)
|
| 529 |
+
|
| 530 |
+
**STRENGTHS:**
|
| 531 |
+
• [Specific strength with direct comparison to professional/amateur benchmarks - e.g. "Hip rotation of 45° approaches professional range (60-90°) and exceeds most amateur examples (23-90°)"]
|
| 532 |
+
• [Another strength with benchmark comparison - e.g. "Energy transfer efficiency of 88% meets professional standards (88-96%) and surpasses amateur range (56.8-96.7%)"]
|
| 533 |
+
• [Third strength with specific metric comparison to benchmarks]
|
| 534 |
+
|
| 535 |
+
**WEAKNESSES:**
|
| 536 |
+
• [Specific weakness with gap from professional standard - e.g. "Wrist hinge of 35° falls significantly below professional range (95-120°) and amateur compensation patterns (116.6°)"]
|
| 537 |
+
• [Another weakness with professional/amateur comparison - e.g. "Head movement of 12 inches exceeds both professional (2-8in) and amateur examples (3-8in)"]
|
| 538 |
+
• [Third weakness with benchmark gap analysis]
|
| 539 |
+
|
| 540 |
+
**PRIORITY_IMPROVEMENTS:**
|
| 541 |
+
1. [Most Critical] Topic Name - Current metric vs professional benchmark vs amateur examples, specific target improvement to reach next level
|
| 542 |
+
2. [Important] Topic Name - Current performance vs benchmarks, actionable steps to improve toward professional standards
|
| 543 |
+
3. [Focus Area] Topic Name - Current state vs benchmark ranges, realistic improvement goals based on amateur progression examples
|
| 544 |
+
|
| 545 |
+
**MANDATORY REQUIREMENTS FOR EACH SECTION:**
|
| 546 |
+
|
| 547 |
+
**For STRENGTHS** - Must include:
|
| 548 |
+
- Specific metric values from current analysis
|
| 549 |
+
- Direct comparison to professional benchmarks (60-90° hip rotation, 95-120° wrist hinge, 88-96% energy transfer, etc.)
|
| 550 |
+
- Comparison to amateur examples where relevant
|
| 551 |
+
- Recognition when metrics meet or exceed professional standards
|
| 552 |
+
- Acknowledgment when metrics surpass typical amateur performance
|
| 553 |
+
|
| 554 |
+
**For WEAKNESSES** - Must include:
|
| 555 |
+
- Specific metric gaps from professional standards
|
| 556 |
+
- Comparison to amateur examples to show relative standing
|
| 557 |
+
- Quantified differences (e.g., "15° below professional minimum," "20% gap from professional range")
|
| 558 |
+
- Impact on overall performance potential
|
| 559 |
+
|
| 560 |
+
**For PRIORITY_IMPROVEMENTS** - Must include:
|
| 561 |
+
- Current metric value vs professional benchmark range
|
| 562 |
+
- Reference to amateur examples showing improvement potential
|
| 563 |
+
- Specific target values based on professional standards
|
| 564 |
+
- Realistic progression steps based on amateur improvement patterns
|
| 565 |
+
- Clear explanation of why this improvement would impact overall performance
|
| 566 |
+
|
| 567 |
+
**EXAMPLE ANALYSIS STRUCTURE:**
|
| 568 |
+
|
| 569 |
+
**STRENGTHS:**
|
| 570 |
+
• Shoulder rotation of 118° nearly matches professional standard (120°) and exceeds many amateur examples (60-120° range)
|
| 571 |
+
• Weight shift of 85% falls within professional range (70-88%) and surpasses amateur struggles (50-90% range)
|
| 572 |
+
|
| 573 |
+
**WEAKNESSES:**
|
| 574 |
+
• Hip rotation of 28° falls significantly below professional minimum (60°) and amateur body-dominant examples (90°)
|
| 575 |
+
• Energy transfer of 62% below professional range (88-96%) and some amateur achievements (96.7%)
|
| 576 |
+
|
| 577 |
+
**PRIORITY_IMPROVEMENTS:**
|
| 578 |
+
1. [Most Critical] Hip Mobility Development - Current 28° vs professional 60-90° and amateur body-dominant 90°. Target 45° as next milestone toward professional range.
|
| 579 |
+
2. [Important] Kinematic Sequence Optimization - Current 70% vs professional 100% and amateur range 66.8-100%. Improve to 85% through better hip-shoulder coordination.
|
| 580 |
+
|
| 581 |
+
PERFORMANCE CLASSIFICATION SCALE:
|
| 582 |
+
- **90-100%**: Professional/Tour level - Consistently meets or exceeds professional benchmarks across all metrics
|
| 583 |
+
- **80-89%**: Advanced amateur - Meets most professional standards with minor gaps in 1-2 areas
|
| 584 |
+
- **70-79%**: Skilled amateur - Solid fundamentals with some gaps from professional standards
|
| 585 |
+
- **60-69%**: Intermediate - Good basic mechanics but several areas need improvement to reach professional level
|
| 586 |
+
- **50-59%**: Developing intermediate - Basic swing structure present but multiple areas below professional standards
|
| 587 |
+
- **40-49%**: Advanced beginner - Some fundamentals in place but significant gaps in most areas
|
| 588 |
+
- **30-39%**: Beginner - Basic swing motion present but major improvements needed across most metrics
|
| 589 |
+
- **20-29%**: Novice - Limited swing fundamentals, extensive work needed on basic mechanics
|
| 590 |
+
- **10-19%**: Complete beginner - Minimal swing structure, needs comprehensive fundamental development
|
| 591 |
+
|
| 592 |
+
IMPORTANT ANALYSIS PRIORITIES (Based on Real Professional Data):
|
| 593 |
+
1. **PRIMARY FOCUS - Critical Biomechanical Differentiators**:
|
| 594 |
+
- Hip Rotation (Professional: 60-90°, Amateur Range: 23-90°) - MOST IMPORTANT
|
| 595 |
+
- Shoulder Rotation (Professional: 120°, Amateur Range: 60-120°) - MOST IMPORTANT
|
| 596 |
+
- Sequential Kinematic Sequence (Professional: 100%, Amateur Range: 66.8-100%)
|
| 597 |
+
- Energy Transfer Efficiency (Professional: 88-96%, Amateur Range: 56.8-96.7%)
|
| 598 |
+
|
| 599 |
+
2. **SECONDARY FOCUS - Power Generation Mechanics**:
|
| 600 |
+
- Power Accumulation (Professional: 100%, Amateur Range: 82.1-100%)
|
| 601 |
+
- Chest Rotation Efficiency (Professional: 100%, Amateur Range: 53.7-100%)
|
| 602 |
+
- Wrist Hinge (Professional: 95-120°, Amateur Range: 49.4-116.6°)
|
| 603 |
+
- Swing Plane Consistency (Professional: 85%, Amateur: 70-85%)
|
| 604 |
+
|
| 605 |
+
3. **TERTIARY FOCUS - Refinement Metrics**:
|
| 606 |
+
- Posture Score (Professional: 95-98%, Amateur Range: 80.6-90.7%)
|
| 607 |
+
- Arm Extension (Professional: 96-100%, Amateur Range: 94.8-100%)
|
| 608 |
+
- Weight Shift (Professional: 70-88%, Amateur Range: 50-90%)
|
| 609 |
+
- Ground Force Efficiency (Professional: 70-88%, Amateur Range: 50-90%)
|
| 610 |
+
|
| 611 |
+
4. **DE-EMPHASIZE - Timing Variables**: Frame counts, tempo ratios, and duration metrics vary significantly based on video capture rates and personal style preferences
|
| 612 |
+
|
| 613 |
+
**SCORING CALIBRATION GUIDELINES:**
|
| 614 |
+
- **Hip/Shoulder Rotation Analysis**: Compare to professional minimums (60° hip, 120° shoulder) and amateur ranges
|
| 615 |
+
- **Energy Transfer <70%**: Score below 60%, compare to amateur range (56.8-96.7%)
|
| 616 |
+
- **Sequential Kinematic <80%**: Score below 70%, reference amateur examples (66.8-100%)
|
| 617 |
+
- **Power Accumulation <90%**: Score below 80%, compare to amateur achievements (82.1-100%)
|
| 618 |
+
- **Head Movement >10 inches**: Major limitation, compare to professional (2-8in) and amateur (3-8in) ranges
|
| 619 |
+
- **Weight Shift <60%**: Significant weakness, reference amateur struggles (50%) vs successes (90%)
|
| 620 |
+
|
| 621 |
+
IMPORTANT FORMATTING RULES:
|
| 622 |
+
- Use the exact headers shown above (PERFORMANCE_CLASSIFICATION, STRENGTHS, WEAKNESSES, PRIORITY_IMPROVEMENTS)
|
| 623 |
+
- For performance classification, use format: [XX%] where XX is the percentage (10-100)
|
| 624 |
+
- For strengths and weaknesses, use bullet points (•)
|
| 625 |
+
- For priority improvements, use numbered format (1., 2., 3.) with priority level in brackets
|
| 626 |
+
- Each priority improvement must have: [Priority Level] Topic Name - Full description with benchmark comparisons
|
| 627 |
+
- **MANDATORY**: Include specific metric values and benchmark comparisons in every strength, weakness, and improvement
|
| 628 |
+
- **MANDATORY**: Reference professional standards and amateur examples in analysis content
|
| 629 |
+
- Provide complete sentences with quantified comparisons - no generic statements
|
| 630 |
+
- Focus analysis on biomechanical consistency rather than timing variations
|
| 631 |
+
- **CRITICAL**: Every analysis point must tie back to the professional benchmarks and amateur examples provided
|
| 632 |
+
|
| 633 |
+
Remember: Use the professional benchmarks (Atthaya Thitikul: 63.4° hip, 120° shoulder, 96.1% energy transfer, etc.) and amateur examples (23-90° hip rotation range, 56.8-96.7% energy transfer range, etc.) as the foundation for ALL analysis content, not just the percentage classification. Every strength, weakness, and improvement recommendation must include specific metric comparisons to these established benchmarks.
|
| 634 |
"""
|
| 635 |
|
| 636 |
return prompt
|
| 637 |
+
|
| 638 |
+
|
| 639 |
+
def parse_and_format_analysis(raw_analysis):
|
| 640 |
+
"""
|
| 641 |
+
Parse the raw LLM analysis and format it into structured components
|
| 642 |
+
|
| 643 |
+
Args:
|
| 644 |
+
raw_analysis (str): Raw analysis text from LLM
|
| 645 |
+
|
| 646 |
+
Returns:
|
| 647 |
+
dict: Structured analysis with classification, strengths/weaknesses, and priorities
|
| 648 |
+
"""
|
| 649 |
+
# Default structure
|
| 650 |
+
formatted_analysis = {
|
| 651 |
+
'classification': 50, # Default to 50%
|
| 652 |
+
'strengths': [],
|
| 653 |
+
'weaknesses': [],
|
| 654 |
+
'priority_improvements': []
|
| 655 |
+
}
|
| 656 |
+
|
| 657 |
+
# Extract percentage classification using the new structured format
|
| 658 |
+
classification_match = re.search(r'\*\*PERFORMANCE_CLASSIFICATION:\*\*\s*\[?(\d+)%?\]?', raw_analysis, re.IGNORECASE)
|
| 659 |
+
if classification_match:
|
| 660 |
+
percentage = int(classification_match.group(1))
|
| 661 |
+
# Ensure percentage is within valid range
|
| 662 |
+
formatted_analysis['classification'] = max(10, min(100, percentage))
|
| 663 |
+
else:
|
| 664 |
+
# Fallback to look for standalone percentages
|
| 665 |
+
percentage_patterns = [
|
| 666 |
+
r'(?:Performance|Classification|Level|Score).*?(\d+)%',
|
| 667 |
+
r'(\d+)%.*?(?:level|performance|classification)',
|
| 668 |
+
r'classified.*?(\d+)%',
|
| 669 |
+
r'(?:at|as)\s+(\d+)%'
|
| 670 |
+
]
|
| 671 |
+
|
| 672 |
+
for pattern in percentage_patterns:
|
| 673 |
+
match = re.search(pattern, raw_analysis, re.IGNORECASE)
|
| 674 |
+
if match:
|
| 675 |
+
percentage = int(match.group(1))
|
| 676 |
+
formatted_analysis['classification'] = max(10, min(100, percentage))
|
| 677 |
+
break
|
| 678 |
+
|
| 679 |
+
# Extract strengths using the new structured format
|
| 680 |
+
strengths_match = re.search(r'\*\*STRENGTHS:\*\*\s*(.*?)(?=\*\*WEAKNESSES:\*\*|\*\*PRIORITY_IMPROVEMENTS:\*\*|$)', raw_analysis, re.IGNORECASE | re.DOTALL)
|
| 681 |
+
if strengths_match:
|
| 682 |
+
strengths_text = strengths_match.group(1)
|
| 683 |
+
# Extract bullet points
|
| 684 |
+
strength_items = re.findall(r'•\s*([^\n•]+)', strengths_text)
|
| 685 |
+
formatted_analysis['strengths'] = [item.strip() for item in strength_items if item.strip()]
|
| 686 |
+
|
| 687 |
+
# Extract weaknesses using the new structured format
|
| 688 |
+
weaknesses_match = re.search(r'\*\*WEAKNESSES:\*\*\s*(.*?)(?=\*\*PRIORITY_IMPROVEMENTS:\*\*|$)', raw_analysis, re.IGNORECASE | re.DOTALL)
|
| 689 |
+
if weaknesses_match:
|
| 690 |
+
weaknesses_text = weaknesses_match.group(1)
|
| 691 |
+
# Extract bullet points
|
| 692 |
+
weakness_items = re.findall(r'•\s*([^\n•]+)', weaknesses_text)
|
| 693 |
+
formatted_analysis['weaknesses'] = [item.strip() for item in weakness_items if item.strip()]
|
| 694 |
+
|
| 695 |
+
# Extract priority improvements using the new structured format
|
| 696 |
+
priority_match = re.search(r'\*\*PRIORITY_IMPROVEMENTS:\*\*\s*(.*?)$', raw_analysis, re.IGNORECASE | re.DOTALL)
|
| 697 |
+
if priority_match:
|
| 698 |
+
priority_text = priority_match.group(1)
|
| 699 |
+
# Extract numbered items with priority levels and descriptions
|
| 700 |
+
priority_items = re.findall(r'(\d+)\.\s*\[(.*?)\]\s*(.*?)(?=\d+\.\s*\[|\Z)', priority_text, re.DOTALL)
|
| 701 |
+
for num, priority_level, description in priority_items[:3]: # Limit to 3
|
| 702 |
+
# Clean up the description
|
| 703 |
+
description = description.strip()
|
| 704 |
+
# Remove any trailing incomplete sentences
|
| 705 |
+
if description.endswith('...') or len(description.split('.')[-1].strip()) < 5:
|
| 706 |
+
sentences = description.split('.')
|
| 707 |
+
if len(sentences) > 1:
|
| 708 |
+
description = '.'.join(sentences[:-1]) + '.'
|
| 709 |
+
|
| 710 |
+
formatted_analysis['priority_improvements'].append({
|
| 711 |
+
'rank': int(num),
|
| 712 |
+
'priority_level': priority_level.strip(),
|
| 713 |
+
'description': f"[{priority_level.strip()}] {description}"
|
| 714 |
+
})
|
| 715 |
+
|
| 716 |
+
# Fallback parsing if structured format wasn't used
|
| 717 |
+
if not formatted_analysis['strengths']:
|
| 718 |
+
# Try original parsing methods for strengths
|
| 719 |
+
strengths_patterns = [
|
| 720 |
+
r'(?:Strengths|Strong Points|Positives|Meets.*Standards)[\s\S]*?(?=(?:Weak|Priority|Improvement|Areas|$))',
|
| 721 |
+
r'(?:Professional Level|Exceeds.*Standards)[\s\S]*?(?=(?:Below|Weak|Priority|$))'
|
| 722 |
+
]
|
| 723 |
+
|
| 724 |
+
for pattern in strengths_patterns:
|
| 725 |
+
match = re.search(pattern, raw_analysis, re.IGNORECASE)
|
| 726 |
+
if match:
|
| 727 |
+
strengths_section = match.group(0)
|
| 728 |
+
strength_items = re.findall(r'[-•]\s*([^-•\n]+)', strengths_section)
|
| 729 |
+
formatted_analysis['strengths'] = [item.strip() for item in strength_items[:4]]
|
| 730 |
+
break
|
| 731 |
+
|
| 732 |
+
if not formatted_analysis['weaknesses']:
|
| 733 |
+
# Try original parsing methods for weaknesses
|
| 734 |
+
weaknesses_patterns = [
|
| 735 |
+
r'(?:Weaknesses|Weak|Areas.*Improvement|Priority.*Areas|Below.*Standards)[\s\S]*?(?=(?:Recommendation|Priority|$))',
|
| 736 |
+
r'(?:Critical|Important|Significant.*gaps?)[\s\S]*?(?=(?:Recommendation|$))'
|
| 737 |
+
]
|
| 738 |
+
|
| 739 |
+
for pattern in weaknesses_patterns:
|
| 740 |
+
match = re.search(pattern, raw_analysis, re.IGNORECASE)
|
| 741 |
+
if match:
|
| 742 |
+
weaknesses_section = match.group(0)
|
| 743 |
+
weakness_items = re.findall(r'[-•]\s*([^-•\n]+)', weaknesses_section)
|
| 744 |
+
formatted_analysis['weaknesses'] = [item.strip() for item in weakness_items[:4]]
|
| 745 |
+
break
|
| 746 |
+
|
| 747 |
+
if not formatted_analysis['priority_improvements']:
|
| 748 |
+
# Try original parsing methods for priorities
|
| 749 |
+
priority_patterns = [
|
| 750 |
+
r'(?:Priority.*Improvement|Critical.*Areas?)[\s\S]*?(?=(?:Recommendation|$))',
|
| 751 |
+
r'(?:1\..*?2\..*?3\.)', # Numbered list
|
| 752 |
+
r'(?:Critical|Important|Fine-tuning)[\s\S]*?(?=(?:Critical|Important|Fine-tuning|$))'
|
| 753 |
+
]
|
| 754 |
+
|
| 755 |
+
for pattern in priority_patterns:
|
| 756 |
+
match = re.search(pattern, raw_analysis, re.IGNORECASE | re.DOTALL)
|
| 757 |
+
if match:
|
| 758 |
+
priority_text = match.group(0)
|
| 759 |
+
# Extract numbered items with better parsing
|
| 760 |
+
numbered_items = re.findall(r'(\d+)\.\s*([^1-9]+?)(?=\d+\.|$)', priority_text, re.DOTALL)
|
| 761 |
+
for num, item in numbered_items[:3]: # Limit to 3
|
| 762 |
+
# Clean up the item text
|
| 763 |
+
item = item.strip()
|
| 764 |
+
# Remove any trailing incomplete sentences
|
| 765 |
+
sentences = item.split('.')
|
| 766 |
+
if len(sentences) > 1 and len(sentences[-1].strip()) < 10:
|
| 767 |
+
item = '.'.join(sentences[:-1]) + '.'
|
| 768 |
+
|
| 769 |
+
formatted_analysis['priority_improvements'].append({
|
| 770 |
+
'rank': int(num),
|
| 771 |
+
'description': item
|
| 772 |
+
})
|
| 773 |
+
break
|
| 774 |
+
|
| 775 |
+
# If still no content found, use defaults based on classification
|
| 776 |
+
if not formatted_analysis['strengths']:
|
| 777 |
+
formatted_analysis['strengths'] = ['Swing analysis completed successfully']
|
| 778 |
+
|
| 779 |
+
if not formatted_analysis['weaknesses']:
|
| 780 |
+
formatted_analysis['weaknesses'] = ['Areas for improvement identified']
|
| 781 |
+
|
| 782 |
+
if not formatted_analysis['priority_improvements']:
|
| 783 |
+
percentage = formatted_analysis['classification']
|
| 784 |
+
if percentage >= 80:
|
| 785 |
+
formatted_analysis['priority_improvements'] = [
|
| 786 |
+
{'rank': 1, 'description': '[Most Critical] Technical Refinement - Fine-tune specific mechanics to achieve consistency at the highest level.'},
|
| 787 |
+
{'rank': 2, 'description': '[Important] Performance Optimization - Focus on maximizing efficiency and power transfer.'},
|
| 788 |
+
{'rank': 3, 'description': '[Focus Area] Competitive Preparation - Enhance mental game and course management skills.'}
|
| 789 |
+
]
|
| 790 |
+
elif percentage >= 60:
|
| 791 |
+
formatted_analysis['priority_improvements'] = [
|
| 792 |
+
{'rank': 1, 'description': '[Most Critical] Kinematic Sequence Enhancement - Improve body rotation coordination to generate more power and consistency.'},
|
| 793 |
+
{'rank': 2, 'description': '[Important] Clubface Control - Enhance swing path consistency for better ball striking accuracy.'},
|
| 794 |
+
{'rank': 3, 'description': '[Focus Area] Energy Transfer Efficiency - Optimize power transfer throughout the swing to maximize distance.'}
|
| 795 |
+
]
|
| 796 |
+
elif percentage >= 40:
|
| 797 |
+
formatted_analysis['priority_improvements'] = [
|
| 798 |
+
{'rank': 1, 'description': '[Most Critical] Fundamental Mechanics - Establish consistent posture, grip, and setup positions.'},
|
| 799 |
+
{'rank': 2, 'description': '[Important] Body Rotation Development - Improve hip and shoulder turn coordination.'},
|
| 800 |
+
{'rank': 3, 'description': '[Focus Area] Weight Transfer - Develop proper weight shift from back foot to front foot during swing.'}
|
| 801 |
+
]
|
| 802 |
+
else: # Below 40%
|
| 803 |
+
formatted_analysis['priority_improvements'] = [
|
| 804 |
+
{'rank': 1, 'description': '[Most Critical] Basic Setup and Posture - Focus on establishing proper spine angle and athletic stance.'},
|
| 805 |
+
{'rank': 2, 'description': '[Important] Fundamental Swing Motion - Develop basic backswing and downswing mechanics.'},
|
| 806 |
+
{'rank': 3, 'description': '[Focus Area] Balance and Stability - Improve overall balance throughout the swing motion.'}
|
| 807 |
+
]
|
| 808 |
+
|
| 809 |
+
return formatted_analysis
|
| 810 |
+
|
| 811 |
+
|
| 812 |
+
def display_formatted_analysis(analysis_data):
|
| 813 |
+
"""
|
| 814 |
+
Display the formatted analysis with performance classification, strengths/weaknesses table, and priorities
|
| 815 |
+
|
| 816 |
+
Args:
|
| 817 |
+
analysis_data (dict): Structured analysis data from parse_and_format_analysis
|
| 818 |
+
"""
|
| 819 |
+
# 1. Performance Classification with percentage-based progress bar
|
| 820 |
+
user_percentage = analysis_data['classification']
|
| 821 |
+
|
| 822 |
+
# Display classification in black bolded header
|
| 823 |
+
st.markdown(f"""
|
| 824 |
+
<h2 style='color: black; font-weight: bold; text-align: center; margin-bottom: 20px;'>
|
| 825 |
+
🎯 Performance Score: {user_percentage}%
|
| 826 |
+
</h2>
|
| 827 |
+
""", unsafe_allow_html=True)
|
| 828 |
+
|
| 829 |
+
# Create a visual progress bar
|
| 830 |
+
progress_color = "#ff4444" # Red for low scores
|
| 831 |
+
if user_percentage >= 80:
|
| 832 |
+
progress_color = "#44aa44" # Green for high scores
|
| 833 |
+
elif user_percentage >= 60:
|
| 834 |
+
progress_color = "#ffdd00" # Yellow for good scores
|
| 835 |
+
elif user_percentage >= 40:
|
| 836 |
+
progress_color = "#ff8800" # Orange for medium scores
|
| 837 |
+
|
| 838 |
+
# Progress bar with percentage labels
|
| 839 |
+
st.markdown(f"""
|
| 840 |
+
<div style='margin: 20px 0;'>
|
| 841 |
+
<div style='display: flex; justify-content: space-between; font-size: 12px; color: #666; margin-bottom: 5px;'>
|
| 842 |
+
<span>10% - Complete Beginner</span>
|
| 843 |
+
<span>50% - Intermediate</span>
|
| 844 |
+
<span>100% - Professional</span>
|
| 845 |
+
</div>
|
| 846 |
+
<div style='width: 100%; background-color: #f0f0f0; border-radius: 25px; height: 30px; position: relative;'>
|
| 847 |
+
<div style='width: {user_percentage}%; background-color: {progress_color}; height: 30px; border-radius: 25px;
|
| 848 |
+
display: flex; align-items: center; justify-content: center; color: white; font-weight: bold;'>
|
| 849 |
+
{user_percentage}%
|
| 850 |
+
</div>
|
| 851 |
+
</div>
|
| 852 |
+
<div style='display: flex; justify-content: space-between; font-size: 10px; color: #888; margin-top: 5px;'>
|
| 853 |
+
<span>10%</span>
|
| 854 |
+
<span>20%</span>
|
| 855 |
+
<span>30%</span>
|
| 856 |
+
<span>40%</span>
|
| 857 |
+
<span>50%</span>
|
| 858 |
+
<span>60%</span>
|
| 859 |
+
<span>70%</span>
|
| 860 |
+
<span>80%</span>
|
| 861 |
+
<span>90%</span>
|
| 862 |
+
<span>100%</span>
|
| 863 |
+
</div>
|
| 864 |
+
</div>
|
| 865 |
+
""", unsafe_allow_html=True)
|
| 866 |
+
|
| 867 |
+
# Performance level description based on percentage
|
| 868 |
+
if user_percentage >= 90:
|
| 869 |
+
level_desc = "🏆 **Professional/Tour Level** - Consistently meets or exceeds professional benchmarks"
|
| 870 |
+
level_color = "#44aa44"
|
| 871 |
+
elif user_percentage >= 80:
|
| 872 |
+
level_desc = "🥇 **Advanced Amateur** - Meets most professional standards with minor gaps"
|
| 873 |
+
level_color = "#66bb44"
|
| 874 |
+
elif user_percentage >= 70:
|
| 875 |
+
level_desc = "🥈 **Skilled Amateur** - Solid fundamentals with some gaps from professional standards"
|
| 876 |
+
level_color = "#88cc44"
|
| 877 |
+
elif user_percentage >= 60:
|
| 878 |
+
level_desc = "🥉 **Intermediate** - Good basic mechanics but several areas need improvement"
|
| 879 |
+
level_color = "#ffdd00"
|
| 880 |
+
elif user_percentage >= 50:
|
| 881 |
+
level_desc = "📈 **Developing Intermediate** - Basic swing structure present"
|
| 882 |
+
level_color = "#ffcc00"
|
| 883 |
+
elif user_percentage >= 40:
|
| 884 |
+
level_desc = "📚 **Advanced Beginner** - Some fundamentals in place"
|
| 885 |
+
level_color = "#ff8800"
|
| 886 |
+
elif user_percentage >= 30:
|
| 887 |
+
level_desc = "🎯 **Beginner** - Basic swing motion present but major improvements needed"
|
| 888 |
+
level_color = "#ff6600"
|
| 889 |
+
elif user_percentage >= 20:
|
| 890 |
+
level_desc = "🌱 **Novice** - Limited swing fundamentals, extensive work needed"
|
| 891 |
+
level_color = "#ff4444"
|
| 892 |
+
else:
|
| 893 |
+
level_desc = "🚀 **Complete Beginner** - Minimal swing structure, needs comprehensive fundamental development"
|
| 894 |
+
level_color = "#ff2222"
|
| 895 |
+
|
| 896 |
+
st.markdown(f"""
|
| 897 |
+
<div style='text-align: center; padding: 15px; background-color: {level_color}20;
|
| 898 |
+
border-radius: 10px; margin: 20px 0; border: 2px solid {level_color};'>
|
| 899 |
+
<div style='color: {level_color}; font-size: 16px; font-weight: bold;'>{level_desc}</div>
|
| 900 |
+
</div>
|
| 901 |
+
""", unsafe_allow_html=True)
|
| 902 |
+
|
| 903 |
+
st.markdown("---")
|
| 904 |
+
|
| 905 |
+
# 2. Strengths and Weaknesses Table
|
| 906 |
+
st.subheader("⚖️ Strengths & Areas for Improvement")
|
| 907 |
+
|
| 908 |
+
# Create two columns for the table with a visual divider
|
| 909 |
+
col_left, col_divider, col_right = st.columns([5, 1, 5])
|
| 910 |
+
|
| 911 |
+
with col_left:
|
| 912 |
+
st.markdown("""
|
| 913 |
+
<div style='background-color: #e8f5e8; padding: 15px; border-radius: 10px; height: 100%;'>
|
| 914 |
+
<h4 style='color: #2d5a2d; margin-top: 0;'>✅ Strengths</h4>
|
| 915 |
+
""", unsafe_allow_html=True)
|
| 916 |
+
for strength in analysis_data['strengths']:
|
| 917 |
+
st.markdown(f"• {strength}")
|
| 918 |
+
st.markdown("</div>", unsafe_allow_html=True)
|
| 919 |
+
|
| 920 |
+
with col_divider:
|
| 921 |
+
st.markdown("""
|
| 922 |
+
<div style='width: 2px; background-color: #ddd; height: 200px; margin: 20px auto;'></div>
|
| 923 |
+
""", unsafe_allow_html=True)
|
| 924 |
+
|
| 925 |
+
with col_right:
|
| 926 |
+
st.markdown("""
|
| 927 |
+
<div style='background-color: #fff5e6; padding: 15px; border-radius: 10px; height: 100%;'>
|
| 928 |
+
<h4 style='color: #cc6600; margin-top: 0;'>⚠️ Areas for Improvement</h4>
|
| 929 |
+
""", unsafe_allow_html=True)
|
| 930 |
+
for weakness in analysis_data['weaknesses']:
|
| 931 |
+
st.markdown(f"• {weakness}")
|
| 932 |
+
st.markdown("</div>", unsafe_allow_html=True)
|
| 933 |
+
|
| 934 |
+
st.markdown("---")
|
| 935 |
+
|
| 936 |
+
# 3. Priority Improvement Areas
|
| 937 |
+
st.subheader("🎯 Priority Improvement Areas")
|
| 938 |
+
|
| 939 |
+
for priority in sorted(analysis_data['priority_improvements'], key=lambda x: x['rank']):
|
| 940 |
+
rank = priority['rank']
|
| 941 |
+
description = priority['description']
|
| 942 |
+
|
| 943 |
+
# Better extraction of improvement area and description
|
| 944 |
+
area = ""
|
| 945 |
+
desc = description
|
| 946 |
+
|
| 947 |
+
# Try different patterns to extract the main topic
|
| 948 |
+
if '[Most Critical]' in description or '[Important]' in description or '[Focus Area]' in description:
|
| 949 |
+
# Pattern: [Priority Level] Topic - Description
|
| 950 |
+
pattern = r'\[(.*?)\]\s*(.*?)(?:\s*-\s*(.*))?$'
|
| 951 |
+
match = re.search(pattern, description)
|
| 952 |
+
if match:
|
| 953 |
+
priority_level = match.group(1)
|
| 954 |
+
area = match.group(2).strip()
|
| 955 |
+
desc = match.group(3).strip() if match.group(3) else ""
|
| 956 |
+
elif ':' in description:
|
| 957 |
+
# Pattern: Topic: Description
|
| 958 |
+
parts = description.split(':', 1)
|
| 959 |
+
area = parts[0].strip()
|
| 960 |
+
desc = parts[1].strip()
|
| 961 |
+
elif ' - ' in description:
|
| 962 |
+
# Pattern: Topic - Description
|
| 963 |
+
parts = description.split(' - ', 1)
|
| 964 |
+
area = parts[0].strip()
|
| 965 |
+
desc = parts[1].strip()
|
| 966 |
+
else:
|
| 967 |
+
# Try to extract first meaningful phrase as area
|
| 968 |
+
words = description.split()
|
| 969 |
+
if len(words) > 5:
|
| 970 |
+
# Take first 3-5 words as the area
|
| 971 |
+
area = ' '.join(words[:4])
|
| 972 |
+
desc = ' '.join(words[4:])
|
| 973 |
+
else:
|
| 974 |
+
area = description
|
| 975 |
+
desc = ""
|
| 976 |
+
|
| 977 |
+
# Clean up area and description
|
| 978 |
+
area = area.replace('[Most Critical]', '').replace('[Important]', '').replace('[Focus Area]', '').strip()
|
| 979 |
+
|
| 980 |
+
# Ensure we have meaningful content
|
| 981 |
+
if not area or len(area) < 5:
|
| 982 |
+
area = f"Priority {rank} Improvement"
|
| 983 |
+
|
| 984 |
+
if not desc or len(desc) < 10:
|
| 985 |
+
# Provide a more complete description based on the area
|
| 986 |
+
if 'posture' in area.lower():
|
| 987 |
+
desc = "Work on maintaining proper spine angle and athletic stance throughout the swing for better consistency and power transfer."
|
| 988 |
+
elif 'tempo' in area.lower() or 'timing' in area.lower():
|
| 989 |
+
desc = "Focus on developing a smooth, consistent rhythm that allows for proper sequencing of body movements."
|
| 990 |
+
elif 'rotation' in area.lower():
|
| 991 |
+
desc = "Improve the coordination and range of motion in your body turn to generate more power and accuracy."
|
| 992 |
+
elif 'weight' in area.lower() or 'shift' in area.lower():
|
| 993 |
+
desc = "Practice transferring weight from back foot to front foot during the swing for better balance and power."
|
| 994 |
+
elif 'knee' in area.lower():
|
| 995 |
+
desc = "Work on maintaining proper knee flex and stability throughout the swing for better foundation and consistency."
|
| 996 |
+
elif 'hip' in area.lower():
|
| 997 |
+
desc = "Focus on improving hip mobility and thrust timing to enhance power generation and sequencing."
|
| 998 |
+
elif 'chest' in area.lower():
|
| 999 |
+
desc = "Improve chest rotation efficiency to better coordinate upper body movement with the swing sequence."
|
| 1000 |
+
else:
|
| 1001 |
+
desc = description # Use the full description if we can't categorize it
|
| 1002 |
+
|
| 1003 |
+
# Display using Streamlit's native components
|
| 1004 |
+
if rank == 1:
|
| 1005 |
+
st.error(f"**{rank}. MOST CRITICAL: {area}**")
|
| 1006 |
+
st.write(desc)
|
| 1007 |
+
elif rank == 2:
|
| 1008 |
+
st.warning(f"**{rank}. IMPORTANT: {area}**")
|
| 1009 |
+
st.write(desc)
|
| 1010 |
+
else:
|
| 1011 |
+
st.info(f"**{rank}. FOCUS AREA: {area}**")
|
| 1012 |
+
st.write(desc)
|
| 1013 |
+
|
| 1014 |
+
st.write("") # Add spacing between items
|
| 1015 |
+
|
| 1016 |
+
|
| 1017 |
+
def calculate_biomechanical_metrics(pose_data, swing_phases):
|
| 1018 |
+
"""
|
| 1019 |
+
Calculate biomechanical metrics from pose keypoints data
|
| 1020 |
+
|
| 1021 |
+
Args:
|
| 1022 |
+
pose_data (dict): Dictionary mapping frame indices to pose keypoints
|
| 1023 |
+
swing_phases (dict): Dictionary mapping phase names to lists of frame indices
|
| 1024 |
+
|
| 1025 |
+
Returns:
|
| 1026 |
+
dict: Calculated biomechanical metrics
|
| 1027 |
+
"""
|
| 1028 |
+
metrics = {}
|
| 1029 |
+
|
| 1030 |
+
# Get key frames for analysis
|
| 1031 |
+
setup_frames = swing_phases.get("setup", [])
|
| 1032 |
+
backswing_frames = swing_phases.get("backswing", [])
|
| 1033 |
+
downswing_frames = swing_phases.get("downswing", [])
|
| 1034 |
+
impact_frames = swing_phases.get("impact", [])
|
| 1035 |
+
|
| 1036 |
+
# Get representative frames
|
| 1037 |
+
setup_frame = setup_frames[len(setup_frames) // 2] if setup_frames else None
|
| 1038 |
+
top_backswing_frame = backswing_frames[-1] if backswing_frames else None
|
| 1039 |
+
impact_frame = impact_frames[0] if impact_frames else None
|
| 1040 |
+
|
| 1041 |
+
# MediaPipe Pose landmark indices
|
| 1042 |
+
# Shoulders: left(11), right(12)
|
| 1043 |
+
# Hips: left(23), right(24)
|
| 1044 |
+
# Knees: left(25), right(26)
|
| 1045 |
+
# Ankles: left(27), right(28)
|
| 1046 |
+
# Elbows: left(13), right(14)
|
| 1047 |
+
# Wrists: left(15), right(16)
|
| 1048 |
+
|
| 1049 |
+
try:
|
| 1050 |
+
# Calculate Hip Rotation
|
| 1051 |
+
if setup_frame and top_backswing_frame and setup_frame in pose_data and top_backswing_frame in pose_data:
|
| 1052 |
+
setup_keypoints = pose_data[setup_frame]
|
| 1053 |
+
backswing_keypoints = pose_data[top_backswing_frame]
|
| 1054 |
+
|
| 1055 |
+
if len(setup_keypoints) >= 33 and len(backswing_keypoints) >= 33:
|
| 1056 |
+
# Hip rotation calculation using hip landmarks
|
| 1057 |
+
setup_left_hip = np.array(setup_keypoints[23][:2])
|
| 1058 |
+
setup_right_hip = np.array(setup_keypoints[24][:2])
|
| 1059 |
+
backswing_left_hip = np.array(backswing_keypoints[23][:2])
|
| 1060 |
+
backswing_right_hip = np.array(backswing_keypoints[24][:2])
|
| 1061 |
+
|
| 1062 |
+
# Calculate hip line angles
|
| 1063 |
+
setup_hip_vector = setup_right_hip - setup_left_hip
|
| 1064 |
+
backswing_hip_vector = backswing_right_hip - backswing_left_hip
|
| 1065 |
+
|
| 1066 |
+
setup_hip_angle = np.degrees(np.arctan2(setup_hip_vector[1], setup_hip_vector[0]))
|
| 1067 |
+
backswing_hip_angle = np.degrees(np.arctan2(backswing_hip_vector[1], backswing_hip_vector[0]))
|
| 1068 |
+
|
| 1069 |
+
hip_rotation = abs(backswing_hip_angle - setup_hip_angle)
|
| 1070 |
+
# Normalize to reasonable range (professionals typically achieve 45+ degrees)
|
| 1071 |
+
metrics["hip_rotation"] = min(hip_rotation, 90)
|
| 1072 |
+
else:
|
| 1073 |
+
metrics["hip_rotation"] = 25 # Lower default for incomplete data
|
| 1074 |
+
else:
|
| 1075 |
+
metrics["hip_rotation"] = 25
|
| 1076 |
+
|
| 1077 |
+
# Calculate Shoulder Rotation
|
| 1078 |
+
if setup_frame and top_backswing_frame and setup_frame in pose_data and top_backswing_frame in pose_data:
|
| 1079 |
+
setup_keypoints = pose_data[setup_frame]
|
| 1080 |
+
backswing_keypoints = pose_data[top_backswing_frame]
|
| 1081 |
+
|
| 1082 |
+
if len(setup_keypoints) >= 33 and len(backswing_keypoints) >= 33:
|
| 1083 |
+
# Shoulder rotation calculation
|
| 1084 |
+
setup_left_shoulder = np.array(setup_keypoints[11][:2])
|
| 1085 |
+
setup_right_shoulder = np.array(setup_keypoints[12][:2])
|
| 1086 |
+
backswing_left_shoulder = np.array(backswing_keypoints[11][:2])
|
| 1087 |
+
backswing_right_shoulder = np.array(backswing_keypoints[12][:2])
|
| 1088 |
+
|
| 1089 |
+
setup_shoulder_vector = setup_right_shoulder - setup_left_shoulder
|
| 1090 |
+
backswing_shoulder_vector = backswing_right_shoulder - backswing_left_shoulder
|
| 1091 |
+
|
| 1092 |
+
setup_shoulder_angle = np.degrees(np.arctan2(setup_shoulder_vector[1], setup_shoulder_vector[0]))
|
| 1093 |
+
backswing_shoulder_angle = np.degrees(np.arctan2(backswing_shoulder_vector[1], backswing_shoulder_vector[0]))
|
| 1094 |
+
|
| 1095 |
+
shoulder_rotation = abs(backswing_shoulder_angle - setup_shoulder_angle)
|
| 1096 |
+
metrics["shoulder_rotation"] = min(shoulder_rotation, 120)
|
| 1097 |
+
else:
|
| 1098 |
+
metrics["shoulder_rotation"] = 60 # Lower default
|
| 1099 |
+
else:
|
| 1100 |
+
metrics["shoulder_rotation"] = 60
|
| 1101 |
+
|
| 1102 |
+
# Calculate Weight Shift (using hip and ankle positions)
|
| 1103 |
+
if setup_frame and impact_frame and setup_frame in pose_data and impact_frame in pose_data:
|
| 1104 |
+
setup_keypoints = pose_data[setup_frame]
|
| 1105 |
+
impact_keypoints = pose_data[impact_frame]
|
| 1106 |
+
|
| 1107 |
+
if len(setup_keypoints) >= 33 and len(impact_keypoints) >= 33:
|
| 1108 |
+
# Use center of mass approximation
|
| 1109 |
+
setup_left_ankle = np.array(setup_keypoints[27][:2])
|
| 1110 |
+
setup_right_ankle = np.array(setup_keypoints[28][:2])
|
| 1111 |
+
impact_left_ankle = np.array(impact_keypoints[27][:2])
|
| 1112 |
+
impact_right_ankle = np.array(impact_keypoints[28][:2])
|
| 1113 |
+
|
| 1114 |
+
# Calculate weight distribution based on foot positioning
|
| 1115 |
+
setup_center = (setup_left_ankle + setup_right_ankle) / 2
|
| 1116 |
+
impact_center = (impact_left_ankle + impact_right_ankle) / 2
|
| 1117 |
+
|
| 1118 |
+
# Weight shift calculation (simplified)
|
| 1119 |
+
foot_width = np.linalg.norm(setup_right_ankle - setup_left_ankle)
|
| 1120 |
+
if foot_width > 0:
|
| 1121 |
+
weight_shift_amount = np.linalg.norm(impact_center - setup_center) / foot_width
|
| 1122 |
+
# Convert to percentage (professionals typically achieve 70%+ to front foot)
|
| 1123 |
+
weight_shift = min(0.5 + weight_shift_amount * 0.5, 0.9)
|
| 1124 |
+
else:
|
| 1125 |
+
weight_shift = 0.5
|
| 1126 |
+
metrics["weight_shift"] = weight_shift
|
| 1127 |
+
else:
|
| 1128 |
+
metrics["weight_shift"] = 0.5 # Neutral default
|
| 1129 |
+
else:
|
| 1130 |
+
metrics["weight_shift"] = 0.5
|
| 1131 |
+
|
| 1132 |
+
# Calculate Posture Score (spine angle consistency)
|
| 1133 |
+
posture_scores = []
|
| 1134 |
+
for frame_list in [setup_frames, backswing_frames, impact_frames]:
|
| 1135 |
+
if frame_list:
|
| 1136 |
+
frame = frame_list[len(frame_list) // 2]
|
| 1137 |
+
if frame in pose_data and len(pose_data[frame]) >= 33:
|
| 1138 |
+
keypoints = pose_data[frame]
|
| 1139 |
+
# Use shoulder and hip landmarks to estimate spine angle
|
| 1140 |
+
left_shoulder = np.array(keypoints[11][:2])
|
| 1141 |
+
right_shoulder = np.array(keypoints[12][:2])
|
| 1142 |
+
left_hip = np.array(keypoints[23][:2])
|
| 1143 |
+
right_hip = np.array(keypoints[24][:2])
|
| 1144 |
+
|
| 1145 |
+
shoulder_center = (left_shoulder + right_shoulder) / 2
|
| 1146 |
+
hip_center = (left_hip + right_hip) / 2
|
| 1147 |
+
|
| 1148 |
+
spine_vector = shoulder_center - hip_center
|
| 1149 |
+
spine_angle = np.degrees(np.arctan2(spine_vector[1], spine_vector[0]))
|
| 1150 |
+
posture_scores.append(abs(spine_angle))
|
| 1151 |
+
|
| 1152 |
+
if posture_scores:
|
| 1153 |
+
# Good posture = consistent spine angle across phases
|
| 1154 |
+
posture_consistency = 1.0 - (np.std(posture_scores) / 90.0) # Normalize by 90 degrees
|
| 1155 |
+
metrics["posture_score"] = max(0.3, min(posture_consistency, 1.0))
|
| 1156 |
+
else:
|
| 1157 |
+
metrics["posture_score"] = 0.6
|
| 1158 |
+
|
| 1159 |
+
# Calculate Arm Extension at Impact
|
| 1160 |
+
if impact_frame and impact_frame in pose_data and len(pose_data[impact_frame]) >= 33:
|
| 1161 |
+
keypoints = pose_data[impact_frame]
|
| 1162 |
+
right_shoulder = np.array(keypoints[12][:2])
|
| 1163 |
+
right_elbow = np.array(keypoints[14][:2])
|
| 1164 |
+
right_wrist = np.array(keypoints[16][:2])
|
| 1165 |
+
|
| 1166 |
+
# Calculate arm extension
|
| 1167 |
+
upper_arm = np.linalg.norm(right_elbow - right_shoulder)
|
| 1168 |
+
forearm = np.linalg.norm(right_wrist - right_elbow)
|
| 1169 |
+
total_arm_length = upper_arm + forearm
|
| 1170 |
+
|
| 1171 |
+
# Calculate actual distance from shoulder to wrist
|
| 1172 |
+
actual_distance = np.linalg.norm(right_wrist - right_shoulder)
|
| 1173 |
+
|
| 1174 |
+
if total_arm_length > 0:
|
| 1175 |
+
extension_ratio = actual_distance / total_arm_length
|
| 1176 |
+
metrics["arm_extension"] = min(extension_ratio, 1.0)
|
| 1177 |
+
else:
|
| 1178 |
+
metrics["arm_extension"] = 0.6
|
| 1179 |
+
else:
|
| 1180 |
+
metrics["arm_extension"] = 0.6
|
| 1181 |
+
|
| 1182 |
+
# Calculate Wrist Hinge using joint angles
|
| 1183 |
+
wrist_angles = []
|
| 1184 |
+
for frame_list in [backswing_frames, impact_frames]:
|
| 1185 |
+
if frame_list:
|
| 1186 |
+
frame = frame_list[len(frame_list) // 2]
|
| 1187 |
+
if frame in pose_data:
|
| 1188 |
+
angles = calculate_joint_angles(pose_data[frame])
|
| 1189 |
+
if "right_wrist" in angles:
|
| 1190 |
+
wrist_angles.append(angles["right_wrist"])
|
| 1191 |
+
|
| 1192 |
+
if wrist_angles:
|
| 1193 |
+
avg_wrist_angle = np.mean(wrist_angles)
|
| 1194 |
+
# Good wrist hinge is typically 80+ degrees
|
| 1195 |
+
metrics["wrist_hinge"] = min(avg_wrist_angle, 120)
|
| 1196 |
+
else:
|
| 1197 |
+
metrics["wrist_hinge"] = 60
|
| 1198 |
+
|
| 1199 |
+
# Calculate Head Movement (lateral and vertical)
|
| 1200 |
+
if setup_frame and impact_frame and setup_frame in pose_data and impact_frame in pose_data:
|
| 1201 |
+
setup_keypoints = pose_data[setup_frame]
|
| 1202 |
+
impact_keypoints = pose_data[impact_frame]
|
| 1203 |
+
|
| 1204 |
+
if len(setup_keypoints) >= 33 and len(impact_keypoints) >= 33:
|
| 1205 |
+
# Use nose landmark (index 0) for head position
|
| 1206 |
+
setup_head = np.array(setup_keypoints[0][:2])
|
| 1207 |
+
impact_head = np.array(impact_keypoints[0][:2])
|
| 1208 |
+
|
| 1209 |
+
head_movement = np.abs(impact_head - setup_head)
|
| 1210 |
+
# Convert pixel movement to approximate inches (rough estimation)
|
| 1211 |
+
# Assume average person's head is about 9 inches, use that as scale
|
| 1212 |
+
if len(setup_keypoints) > 10: # Have enough landmarks
|
| 1213 |
+
head_height_pixels = abs(setup_keypoints[0][1] - setup_keypoints[10][1]) # Nose to mouth
|
| 1214 |
+
if head_height_pixels > 0:
|
| 1215 |
+
pixel_to_inch = 4.0 / head_height_pixels # Approximate nose-to-mouth is 4 inches
|
| 1216 |
+
lateral_movement = head_movement[0] * pixel_to_inch
|
| 1217 |
+
vertical_movement = head_movement[1] * pixel_to_inch
|
| 1218 |
+
else:
|
| 1219 |
+
lateral_movement = 3.0
|
| 1220 |
+
vertical_movement = 2.0
|
| 1221 |
+
else:
|
| 1222 |
+
lateral_movement = 3.0
|
| 1223 |
+
vertical_movement = 2.0
|
| 1224 |
+
|
| 1225 |
+
metrics["head_movement_lateral"] = min(lateral_movement, 8.0)
|
| 1226 |
+
metrics["head_movement_vertical"] = min(vertical_movement, 6.0)
|
| 1227 |
+
else:
|
| 1228 |
+
metrics["head_movement_lateral"] = 3.0
|
| 1229 |
+
metrics["head_movement_vertical"] = 2.0
|
| 1230 |
+
else:
|
| 1231 |
+
metrics["head_movement_lateral"] = 3.0
|
| 1232 |
+
metrics["head_movement_vertical"] = 2.0
|
| 1233 |
+
|
| 1234 |
+
# Calculate Knee Flexion
|
| 1235 |
+
knee_flexions = {}
|
| 1236 |
+
for phase_name, frame_list in [("address", setup_frames), ("impact", impact_frames)]:
|
| 1237 |
+
if frame_list:
|
| 1238 |
+
frame = frame_list[len(frame_list) // 2]
|
| 1239 |
+
if frame in pose_data and len(pose_data[frame]) >= 33:
|
| 1240 |
+
keypoints = pose_data[frame]
|
| 1241 |
+
# Right knee angle using hip, knee, ankle
|
| 1242 |
+
right_hip = np.array(keypoints[24][:2])
|
| 1243 |
+
right_knee = np.array(keypoints[26][:2])
|
| 1244 |
+
right_ankle = np.array(keypoints[28][:2])
|
| 1245 |
+
|
| 1246 |
+
# Calculate knee angle
|
| 1247 |
+
thigh_vector = right_hip - right_knee
|
| 1248 |
+
shin_vector = right_ankle - right_knee
|
| 1249 |
+
|
| 1250 |
+
if np.linalg.norm(thigh_vector) > 0 and np.linalg.norm(shin_vector) > 0:
|
| 1251 |
+
cos_angle = np.dot(thigh_vector, shin_vector) / (np.linalg.norm(thigh_vector) * np.linalg.norm(shin_vector))
|
| 1252 |
+
cos_angle = np.clip(cos_angle, -1, 1)
|
| 1253 |
+
knee_angle = np.degrees(np.arccos(cos_angle))
|
| 1254 |
+
knee_flexions[phase_name] = min(knee_angle, 60)
|
| 1255 |
+
else:
|
| 1256 |
+
knee_flexions[phase_name] = 25
|
| 1257 |
+
else:
|
| 1258 |
+
knee_flexions[phase_name] = 25
|
| 1259 |
+
|
| 1260 |
+
metrics["knee_flexion_address"] = knee_flexions.get("address", 25)
|
| 1261 |
+
metrics["knee_flexion_impact"] = knee_flexions.get("impact", 30)
|
| 1262 |
+
|
| 1263 |
+
# Calculate derived metrics based on quality of basic metrics
|
| 1264 |
+
# These are more complex and would require additional analysis
|
| 1265 |
+
|
| 1266 |
+
# Swing Plane Consistency (based on arm and club positions across frames)
|
| 1267 |
+
if metrics["shoulder_rotation"] >= 80 and metrics["arm_extension"] >= 0.75:
|
| 1268 |
+
metrics["swing_plane_consistency"] = 0.85
|
| 1269 |
+
elif metrics["shoulder_rotation"] >= 60 and metrics["arm_extension"] >= 0.6:
|
| 1270 |
+
metrics["swing_plane_consistency"] = 0.70
|
| 1271 |
+
else:
|
| 1272 |
+
metrics["swing_plane_consistency"] = 0.55
|
| 1273 |
+
|
| 1274 |
+
# Chest Rotation Efficiency (derived from shoulder rotation and posture)
|
| 1275 |
+
chest_efficiency = (metrics["shoulder_rotation"] / 90.0) * metrics["posture_score"]
|
| 1276 |
+
metrics["chest_rotation_efficiency"] = min(chest_efficiency, 1.0)
|
| 1277 |
+
|
| 1278 |
+
# Hip Thrust (derived from weight shift and hip rotation)
|
| 1279 |
+
hip_thrust = (metrics["weight_shift"] - 0.5) * 2 * (metrics["hip_rotation"] / 45.0)
|
| 1280 |
+
metrics["hip_thrust"] = max(0.3, min(hip_thrust, 1.0))
|
| 1281 |
+
|
| 1282 |
+
# Ground Force Efficiency (derived from weight shift and knee flexion consistency)
|
| 1283 |
+
knee_consistency = 1.0 - abs(metrics["knee_flexion_impact"] - metrics["knee_flexion_address"]) / 30.0
|
| 1284 |
+
ground_force = metrics["weight_shift"] * knee_consistency
|
| 1285 |
+
metrics["ground_force_efficiency"] = max(0.4, min(ground_force, 1.0))
|
| 1286 |
+
|
| 1287 |
+
# Transition Smoothness (based on posture consistency and movement quality)
|
| 1288 |
+
head_movement_penalty = (metrics["head_movement_lateral"] + metrics["head_movement_vertical"]) / 10.0
|
| 1289 |
+
transition_smoothness = metrics["posture_score"] * (1.0 - head_movement_penalty)
|
| 1290 |
+
metrics["transition_smoothness"] = max(0.4, min(transition_smoothness, 1.0))
|
| 1291 |
+
|
| 1292 |
+
# Sequential Kinematic Sequence (based on overall coordination)
|
| 1293 |
+
coordination_score = (metrics["hip_rotation"] / 45.0 + metrics["shoulder_rotation"] / 90.0 +
|
| 1294 |
+
metrics["weight_shift"] + metrics["arm_extension"]) / 4.0
|
| 1295 |
+
metrics["kinematic_sequence"] = max(0.5, min(coordination_score, 1.0))
|
| 1296 |
+
|
| 1297 |
+
# Energy Transfer Efficiency (based on multiple factors)
|
| 1298 |
+
energy_transfer = (metrics["kinematic_sequence"] + metrics["ground_force_efficiency"] +
|
| 1299 |
+
metrics["chest_rotation_efficiency"]) / 3.0
|
| 1300 |
+
metrics["energy_transfer"] = max(0.4, min(energy_transfer, 1.0))
|
| 1301 |
+
|
| 1302 |
+
# Power Accumulation (based on body mechanics)
|
| 1303 |
+
power_accumulation = (metrics["hip_rotation"] / 45.0 + metrics["shoulder_rotation"] / 90.0 +
|
| 1304 |
+
metrics["wrist_hinge"] / 80.0) / 3.0
|
| 1305 |
+
metrics["power_accumulation"] = max(0.4, min(power_accumulation, 1.0))
|
| 1306 |
+
|
| 1307 |
+
# Potential Distance (based on power metrics and efficiency)
|
| 1308 |
+
base_distance = 180 # Base amateur distance
|
| 1309 |
+
power_multiplier = metrics["power_accumulation"] * metrics["energy_transfer"]
|
| 1310 |
+
potential_distance = base_distance + (power_multiplier * 120) # Up to 300 yards for perfect mechanics
|
| 1311 |
+
metrics["potential_distance"] = min(potential_distance, 320)
|
| 1312 |
+
|
| 1313 |
+
# Speed Generation Method (based on power sources)
|
| 1314 |
+
if metrics["hip_rotation"] >= 40 and metrics["shoulder_rotation"] >= 80:
|
| 1315 |
+
metrics["speed_generation"] = "Body-dominant"
|
| 1316 |
+
elif metrics["arm_extension"] >= 0.8 and metrics["wrist_hinge"] >= 75:
|
| 1317 |
+
metrics["speed_generation"] = "Arms-dominant"
|
| 1318 |
+
else:
|
| 1319 |
+
metrics["speed_generation"] = "Mixed"
|
| 1320 |
+
|
| 1321 |
+
except Exception as e:
|
| 1322 |
+
print(f"Error calculating biomechanical metrics: {str(e)}")
|
| 1323 |
+
# Fail here
|
| 1324 |
+
return None
|
| 1325 |
+
|
| 1326 |
+
return metrics
|
app/models/swing_analyzer.py
CHANGED
|
@@ -23,7 +23,7 @@ def find_top_of_backswing(pose_data):
|
|
| 23 |
return top_frame
|
| 24 |
|
| 25 |
|
| 26 |
-
def detect_impact_frame(pose_data, detections, sample_rate=
|
| 27 |
"""
|
| 28 |
Simple impact detection: ball movement first, wrist speed fallback
|
| 29 |
"""
|
|
@@ -42,12 +42,29 @@ def detect_impact_frame(pose_data, detections, sample_rate=2):
|
|
| 42 |
ball_detections = [d for d in detections if d.class_name == "sports ball"]
|
| 43 |
ball_positions = {}
|
| 44 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
for detection in ball_detections:
|
| 46 |
-
|
| 47 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
x1, y1, x2, y2 = detection.bbox
|
| 49 |
center_x, center_y = (x1 + x2) / 2, (y1 + y2) / 2
|
| 50 |
-
ball_positions[
|
| 51 |
|
| 52 |
# Find first significant ball movement
|
| 53 |
if len(ball_positions) >= 2:
|
|
@@ -58,7 +75,7 @@ def detect_impact_frame(pose_data, detections, sample_rate=2):
|
|
| 58 |
movement = np.sqrt((curr_pos[0] - prev_pos[0])**2 + (curr_pos[1] - prev_pos[1])**2)
|
| 59 |
|
| 60 |
if movement > 15: # Significant movement threshold
|
| 61 |
-
print(f"Impact detected via ball movement at frame {sorted_frames[i]}")
|
| 62 |
return sorted_frames[i]
|
| 63 |
|
| 64 |
# Method 2: Wrist speed fallback (simple and reliable)
|
|
@@ -80,11 +97,11 @@ def detect_impact_frame(pose_data, detections, sample_rate=2):
|
|
| 80 |
max_wrist_speed = wrist_speed
|
| 81 |
impact_frame = curr_frame
|
| 82 |
|
| 83 |
-
print(f"Impact detected via wrist speed at frame {impact_frame}")
|
| 84 |
return impact_frame or downswing_frames[len(downswing_frames) // 3]
|
| 85 |
|
| 86 |
|
| 87 |
-
def segment_swing_pose_based(pose_data, detections=None, sample_rate=
|
| 88 |
"""
|
| 89 |
Simple swing segmentation with clean impact detection
|
| 90 |
"""
|
|
@@ -117,7 +134,7 @@ def segment_swing_pose_based(pose_data, detections=None, sample_rate=2):
|
|
| 117 |
downswing_frames = [f for f in frame_indices if f > top_backswing]
|
| 118 |
impact_frame = downswing_frames[len(downswing_frames) // 3] if downswing_frames else top_backswing + 1
|
| 119 |
|
| 120 |
-
print(f"Swing phases: Setup end={setup_end}, Top backswing={top_backswing}, Impact={impact_frame}")
|
| 121 |
|
| 122 |
# 4. Assign phases
|
| 123 |
for idx in frame_indices:
|
|
@@ -136,14 +153,14 @@ def segment_swing_pose_based(pose_data, detections=None, sample_rate=2):
|
|
| 136 |
|
| 137 |
|
| 138 |
# Wrapper function to maintain compatibility with existing Streamlit app
|
| 139 |
-
def segment_swing(pose_data, detections, sample_rate=
|
| 140 |
"""
|
| 141 |
Main swing segmentation function (wrapper for pose-based approach)
|
| 142 |
"""
|
| 143 |
return segment_swing_pose_based(pose_data, detections, sample_rate)
|
| 144 |
|
| 145 |
|
| 146 |
-
def analyze_trajectory(frames, detections, swing_phases, sample_rate=
|
| 147 |
"""
|
| 148 |
Analyze ball trajectory and calculate club speed
|
| 149 |
"""
|
|
|
|
| 23 |
return top_frame
|
| 24 |
|
| 25 |
|
| 26 |
+
def detect_impact_frame(pose_data, detections, sample_rate=1):
|
| 27 |
"""
|
| 28 |
Simple impact detection: ball movement first, wrist speed fallback
|
| 29 |
"""
|
|
|
|
| 42 |
ball_detections = [d for d in detections if d.class_name == "sports ball"]
|
| 43 |
ball_positions = {}
|
| 44 |
|
| 45 |
+
# Create a mapping from original video frame indices to processed frame indices
|
| 46 |
+
original_to_processed = {}
|
| 47 |
+
for processed_idx in frame_indices:
|
| 48 |
+
original_frame_idx = processed_idx * sample_rate
|
| 49 |
+
original_to_processed[original_frame_idx] = processed_idx
|
| 50 |
+
|
| 51 |
for detection in ball_detections:
|
| 52 |
+
original_frame_idx = detection.frame_idx
|
| 53 |
+
# Find the closest processed frame index
|
| 54 |
+
processed_frame_idx = None
|
| 55 |
+
if original_frame_idx in original_to_processed:
|
| 56 |
+
processed_frame_idx = original_to_processed[original_frame_idx]
|
| 57 |
+
else:
|
| 58 |
+
# Find closest processed frame
|
| 59 |
+
closest_original = min(original_to_processed.keys(),
|
| 60 |
+
key=lambda x: abs(x - original_frame_idx))
|
| 61 |
+
if abs(closest_original - original_frame_idx) <= sample_rate:
|
| 62 |
+
processed_frame_idx = original_to_processed[closest_original]
|
| 63 |
+
|
| 64 |
+
if processed_frame_idx and processed_frame_idx > top_backswing:
|
| 65 |
x1, y1, x2, y2 = detection.bbox
|
| 66 |
center_x, center_y = (x1 + x2) / 2, (y1 + y2) / 2
|
| 67 |
+
ball_positions[processed_frame_idx] = (center_x, center_y)
|
| 68 |
|
| 69 |
# Find first significant ball movement
|
| 70 |
if len(ball_positions) >= 2:
|
|
|
|
| 75 |
movement = np.sqrt((curr_pos[0] - prev_pos[0])**2 + (curr_pos[1] - prev_pos[1])**2)
|
| 76 |
|
| 77 |
if movement > 15: # Significant movement threshold
|
| 78 |
+
print(f"Impact detected via ball movement at processed frame {sorted_frames[i]} (original frame {sorted_frames[i] * sample_rate})")
|
| 79 |
return sorted_frames[i]
|
| 80 |
|
| 81 |
# Method 2: Wrist speed fallback (simple and reliable)
|
|
|
|
| 97 |
max_wrist_speed = wrist_speed
|
| 98 |
impact_frame = curr_frame
|
| 99 |
|
| 100 |
+
print(f"Impact detected via wrist speed at processed frame {impact_frame} (original frame {impact_frame * sample_rate if impact_frame else 'N/A'})")
|
| 101 |
return impact_frame or downswing_frames[len(downswing_frames) // 3]
|
| 102 |
|
| 103 |
|
| 104 |
+
def segment_swing_pose_based(pose_data, detections=None, sample_rate=1):
|
| 105 |
"""
|
| 106 |
Simple swing segmentation with clean impact detection
|
| 107 |
"""
|
|
|
|
| 134 |
downswing_frames = [f for f in frame_indices if f > top_backswing]
|
| 135 |
impact_frame = downswing_frames[len(downswing_frames) // 3] if downswing_frames else top_backswing + 1
|
| 136 |
|
| 137 |
+
print(f"Swing phases: Setup end={setup_end} (orig {setup_end * sample_rate}), Top backswing={top_backswing} (orig {top_backswing * sample_rate}), Impact={impact_frame} (orig {impact_frame * sample_rate if impact_frame else 'N/A'})")
|
| 138 |
|
| 139 |
# 4. Assign phases
|
| 140 |
for idx in frame_indices:
|
|
|
|
| 153 |
|
| 154 |
|
| 155 |
# Wrapper function to maintain compatibility with existing Streamlit app
|
| 156 |
+
def segment_swing(pose_data, detections, sample_rate=1):
|
| 157 |
"""
|
| 158 |
Main swing segmentation function (wrapper for pose-based approach)
|
| 159 |
"""
|
| 160 |
return segment_swing_pose_based(pose_data, detections, sample_rate)
|
| 161 |
|
| 162 |
|
| 163 |
+
def analyze_trajectory(frames, detections, swing_phases, sample_rate=1):
|
| 164 |
"""
|
| 165 |
Analyze ball trajectory and calculate club speed
|
| 166 |
"""
|
app/streamlit_app.py
CHANGED
|
@@ -19,11 +19,11 @@ load_dotenv()
|
|
| 19 |
# Add the app directory to the path
|
| 20 |
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 21 |
|
| 22 |
-
from app.utils.video_downloader import download_youtube_video, download_pro_reference
|
| 23 |
from app.utils.video_processor import process_video
|
| 24 |
from app.models.pose_estimator import analyze_pose
|
| 25 |
from app.models.swing_analyzer import segment_swing, analyze_trajectory
|
| 26 |
-
from app.models.llm_analyzer import generate_swing_analysis, create_llm_prompt, prepare_data_for_llm, check_llm_services
|
| 27 |
from app.utils.visualizer import create_annotated_video
|
| 28 |
from app.utils.comparison import create_key_frame_comparison, extract_key_swing_frames
|
| 29 |
|
|
@@ -92,6 +92,7 @@ def main():
|
|
| 92 |
"""Main Streamlit application"""
|
| 93 |
st.title("Par-ity Project: Golf Swing Analysis 🏌️♀️")
|
| 94 |
st.write("Founded to address the gender gap in golf participation and access to quality coaching resources, Par-ity Project is a technology-driven initiative empowering girls in golf through innovative AI based swing analysis. This technology uses computer vision and machine learning algorithms to analyze golf swings and provide personalized feedback to improve technique and performance.")
|
|
|
|
| 95 |
# Initialize session state for storing analysis results
|
| 96 |
if 'video_analyzed' not in st.session_state:
|
| 97 |
st.session_state.video_analyzed = False
|
|
@@ -107,10 +108,32 @@ def main():
|
|
| 107 |
}
|
| 108 |
if 'pro_reference_path' not in st.session_state:
|
| 109 |
st.session_state.pro_reference_path = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
|
| 111 |
# Sidebar for configuration
|
| 112 |
st.sidebar.title("Configuration")
|
| 113 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
# Check available LLM services
|
| 115 |
llm_services = check_llm_services()
|
| 116 |
any_service_available = llm_services['ollama'][
|
|
@@ -161,14 +184,14 @@ def main():
|
|
| 161 |
else:
|
| 162 |
st.sidebar.info("Using sample analysis mode (no LLM required)")
|
| 163 |
|
| 164 |
-
# Frame
|
| 165 |
sample_rate = st.sidebar.slider(
|
| 166 |
-
"Frame
|
| 167 |
min_value=1,
|
| 168 |
max_value=10,
|
| 169 |
-
value=
|
| 170 |
help=
|
| 171 |
-
"Process every Nth frame.
|
| 172 |
|
| 173 |
# Pro reference toggle
|
| 174 |
enable_pro_comparison = st.sidebar.checkbox(
|
|
@@ -289,6 +312,10 @@ def main():
|
|
| 289 |
'prompt': prompt
|
| 290 |
}
|
| 291 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 292 |
# Present the options after analysis
|
| 293 |
st.subheader("What would you like to do next?")
|
| 294 |
options_col1, options_col2, options_col3 = st.columns(3)
|
|
@@ -311,6 +338,9 @@ def main():
|
|
| 311 |
except Exception as e:
|
| 312 |
st.error(f"Error during analysis: {str(e)}")
|
| 313 |
st.session_state.video_analyzed = False
|
|
|
|
|
|
|
|
|
|
| 314 |
|
| 315 |
# Show action buttons and their results (only if analysis is complete)
|
| 316 |
if st.session_state.video_analyzed:
|
|
@@ -421,29 +451,13 @@ def main():
|
|
| 421 |
elif llm_services['openai']['available']:
|
| 422 |
st.info("🤖 **Analysis generated using OpenAI**")
|
| 423 |
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
with drill1:
|
| 432 |
-
st.markdown("**Posture Drill**")
|
| 433 |
-
st.markdown("- Stand with your back against a wall")
|
| 434 |
-
st.markdown(
|
| 435 |
-
"- Take your golf stance while maintaining contact"
|
| 436 |
-
)
|
| 437 |
-
st.markdown(
|
| 438 |
-
"- Practice maintaining this posture during your swing"
|
| 439 |
-
)
|
| 440 |
-
|
| 441 |
-
with drill2:
|
| 442 |
-
st.markdown("**Tempo Drill**")
|
| 443 |
-
st.markdown("- Count '1-2-3' for your backswing")
|
| 444 |
-
st.markdown("- Count '1' for your downswing")
|
| 445 |
-
st.markdown("- Practice maintaining a 3:1 tempo ratio")
|
| 446 |
-
|
| 447 |
# Handle key frame analysis (new tab/option)
|
| 448 |
if keyframe_analysis_clicked:
|
| 449 |
try:
|
|
|
|
| 19 |
# Add the app directory to the path
|
| 20 |
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 21 |
|
| 22 |
+
from app.utils.video_downloader import download_youtube_video, download_pro_reference, cleanup_video_file, cleanup_downloads_directory
|
| 23 |
from app.utils.video_processor import process_video
|
| 24 |
from app.models.pose_estimator import analyze_pose
|
| 25 |
from app.models.swing_analyzer import segment_swing, analyze_trajectory
|
| 26 |
+
from app.models.llm_analyzer import generate_swing_analysis, create_llm_prompt, prepare_data_for_llm, check_llm_services, parse_and_format_analysis, display_formatted_analysis
|
| 27 |
from app.utils.visualizer import create_annotated_video
|
| 28 |
from app.utils.comparison import create_key_frame_comparison, extract_key_swing_frames
|
| 29 |
|
|
|
|
| 92 |
"""Main Streamlit application"""
|
| 93 |
st.title("Par-ity Project: Golf Swing Analysis 🏌️♀️")
|
| 94 |
st.write("Founded to address the gender gap in golf participation and access to quality coaching resources, Par-ity Project is a technology-driven initiative empowering girls in golf through innovative AI based swing analysis. This technology uses computer vision and machine learning algorithms to analyze golf swings and provide personalized feedback to improve technique and performance.")
|
| 95 |
+
|
| 96 |
# Initialize session state for storing analysis results
|
| 97 |
if 'video_analyzed' not in st.session_state:
|
| 98 |
st.session_state.video_analyzed = False
|
|
|
|
| 108 |
}
|
| 109 |
if 'pro_reference_path' not in st.session_state:
|
| 110 |
st.session_state.pro_reference_path = None
|
| 111 |
+
|
| 112 |
+
# Add session cleanup - clean up old files when starting a new session
|
| 113 |
+
if 'session_initialized' not in st.session_state:
|
| 114 |
+
cleanup_result = cleanup_downloads_directory(keep_annotated=True)
|
| 115 |
+
if cleanup_result.get('files_removed', 0) > 0:
|
| 116 |
+
st.info(f"🗑️ Cleaned up {cleanup_result['files_removed']} old files ({cleanup_result['space_freed_mb']} MB freed)")
|
| 117 |
+
st.session_state.session_initialized = True
|
| 118 |
|
| 119 |
# Sidebar for configuration
|
| 120 |
st.sidebar.title("Configuration")
|
| 121 |
|
| 122 |
+
# Add Reset Session button
|
| 123 |
+
st.sidebar.markdown("---")
|
| 124 |
+
if st.sidebar.button("🗑️ Reset Session & Clean Files", help="Clear all session data and remove downloaded files"):
|
| 125 |
+
# Clean up downloads directory
|
| 126 |
+
cleanup_result = cleanup_downloads_directory(keep_annotated=False) # Remove all files including annotated
|
| 127 |
+
|
| 128 |
+
# Clear session state
|
| 129 |
+
for key in list(st.session_state.keys()):
|
| 130 |
+
del st.session_state[key]
|
| 131 |
+
|
| 132 |
+
st.sidebar.success(f"Session reset! Cleaned {cleanup_result.get('files_removed', 0)} files ({cleanup_result.get('space_freed_mb', 0)} MB freed)")
|
| 133 |
+
st.rerun()
|
| 134 |
+
|
| 135 |
+
st.sidebar.markdown("---")
|
| 136 |
+
|
| 137 |
# Check available LLM services
|
| 138 |
llm_services = check_llm_services()
|
| 139 |
any_service_available = llm_services['ollama'][
|
|
|
|
| 184 |
else:
|
| 185 |
st.sidebar.info("Using sample analysis mode (no LLM required)")
|
| 186 |
|
| 187 |
+
# Frame processing rate for YOLO
|
| 188 |
sample_rate = st.sidebar.slider(
|
| 189 |
+
"Frame Processing Rate (YOLO)",
|
| 190 |
min_value=1,
|
| 191 |
max_value=10,
|
| 192 |
+
value=1,
|
| 193 |
help=
|
| 194 |
+
"Process every Nth frame. 1 = all frames (most accurate), higher values = faster but less accurate.")
|
| 195 |
|
| 196 |
# Pro reference toggle
|
| 197 |
enable_pro_comparison = st.sidebar.checkbox(
|
|
|
|
| 312 |
'prompt': prompt
|
| 313 |
}
|
| 314 |
|
| 315 |
+
# Clean up the original video file after processing (keep frames in memory)
|
| 316 |
+
st.info("🗑️ Cleaning up original video file to save space...")
|
| 317 |
+
cleanup_video_file(video_path)
|
| 318 |
+
|
| 319 |
# Present the options after analysis
|
| 320 |
st.subheader("What would you like to do next?")
|
| 321 |
options_col1, options_col2, options_col3 = st.columns(3)
|
|
|
|
| 338 |
except Exception as e:
|
| 339 |
st.error(f"Error during analysis: {str(e)}")
|
| 340 |
st.session_state.video_analyzed = False
|
| 341 |
+
# Clean up on error as well
|
| 342 |
+
if video_path and os.path.exists(video_path):
|
| 343 |
+
cleanup_video_file(video_path)
|
| 344 |
|
| 345 |
# Show action buttons and their results (only if analysis is complete)
|
| 346 |
if st.session_state.video_analyzed:
|
|
|
|
| 451 |
elif llm_services['openai']['available']:
|
| 452 |
st.info("🤖 **Analysis generated using OpenAI**")
|
| 453 |
|
| 454 |
+
# Parse and display the formatted analysis instead of raw markdown
|
| 455 |
+
if "Error:" not in analysis:
|
| 456 |
+
formatted_analysis = parse_and_format_analysis(analysis)
|
| 457 |
+
display_formatted_analysis(formatted_analysis)
|
| 458 |
+
else:
|
| 459 |
+
# Show error message if analysis failed
|
| 460 |
+
st.error(analysis)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 461 |
# Handle key frame analysis (new tab/option)
|
| 462 |
if keyframe_analysis_clicked:
|
| 463 |
try:
|
app/utils/comparison.py
CHANGED
|
@@ -162,6 +162,7 @@ def extract_key_swing_frames(video_path, frames, swing_phases=None):
|
|
| 162 |
impact_idx = len(frames) // 2
|
| 163 |
|
| 164 |
print(f"Key frame indices (relative to processed frames) - Setup: {setup_idx}, Backswing: {backswing_idx}, Impact: {impact_idx}")
|
|
|
|
| 165 |
|
| 166 |
# Get rotation angle from the original video file
|
| 167 |
rotation_angle = 0
|
|
|
|
| 162 |
impact_idx = len(frames) // 2
|
| 163 |
|
| 164 |
print(f"Key frame indices (relative to processed frames) - Setup: {setup_idx}, Backswing: {backswing_idx}, Impact: {impact_idx}")
|
| 165 |
+
print(f"These correspond to original video frames (approx) - Setup: ~{setup_idx * 1}, Backswing: ~{backswing_idx * 1}, Impact: ~{impact_idx * 1} (assuming sample_rate=1)")
|
| 166 |
|
| 167 |
# Get rotation angle from the original video file
|
| 168 |
rotation_angle = 0
|
app/utils/video_downloader.py
CHANGED
|
@@ -6,6 +6,88 @@ import os
|
|
| 6 |
import yt_dlp
|
| 7 |
|
| 8 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
def download_youtube_video(url, output_dir="downloads"):
|
| 10 |
"""
|
| 11 |
Download a YouTube video from the provided URL using yt-dlp
|
|
|
|
| 6 |
import yt_dlp
|
| 7 |
|
| 8 |
|
| 9 |
+
def cleanup_video_file(video_path):
|
| 10 |
+
"""
|
| 11 |
+
Delete a specific video file after processing
|
| 12 |
+
|
| 13 |
+
Args:
|
| 14 |
+
video_path (str): Path to the video file to delete
|
| 15 |
+
|
| 16 |
+
Returns:
|
| 17 |
+
bool: True if file was deleted successfully, False otherwise
|
| 18 |
+
"""
|
| 19 |
+
try:
|
| 20 |
+
if os.path.exists(video_path):
|
| 21 |
+
os.remove(video_path)
|
| 22 |
+
print(f"Cleaned up video file: {video_path}")
|
| 23 |
+
return True
|
| 24 |
+
else:
|
| 25 |
+
print(f"Video file not found for cleanup: {video_path}")
|
| 26 |
+
return False
|
| 27 |
+
except Exception as e:
|
| 28 |
+
print(f"Error cleaning up video file {video_path}: {str(e)}")
|
| 29 |
+
return False
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def cleanup_downloads_directory(output_dir="downloads", keep_annotated=True):
|
| 33 |
+
"""
|
| 34 |
+
Clean up downloaded videos from the downloads directory
|
| 35 |
+
|
| 36 |
+
Args:
|
| 37 |
+
output_dir (str): Directory containing downloaded videos
|
| 38 |
+
keep_annotated (bool): Whether to keep annotated videos (default: True)
|
| 39 |
+
|
| 40 |
+
Returns:
|
| 41 |
+
dict: Cleanup results with files removed and space freed
|
| 42 |
+
"""
|
| 43 |
+
try:
|
| 44 |
+
if not os.path.exists(output_dir):
|
| 45 |
+
return {"files_removed": 0, "space_freed_mb": 0}
|
| 46 |
+
|
| 47 |
+
files_removed = 0
|
| 48 |
+
space_freed = 0
|
| 49 |
+
|
| 50 |
+
for filename in os.listdir(output_dir):
|
| 51 |
+
file_path = os.path.join(output_dir, filename)
|
| 52 |
+
|
| 53 |
+
# Skip if not a file
|
| 54 |
+
if not os.path.isfile(file_path):
|
| 55 |
+
continue
|
| 56 |
+
|
| 57 |
+
# Skip annotated videos if keep_annotated is True
|
| 58 |
+
if keep_annotated and "_annotated" in filename:
|
| 59 |
+
continue
|
| 60 |
+
|
| 61 |
+
# Skip pro reference videos (they can be reused)
|
| 62 |
+
if "pro_reference" in filename:
|
| 63 |
+
continue
|
| 64 |
+
|
| 65 |
+
# Get file size before deletion
|
| 66 |
+
try:
|
| 67 |
+
file_size = os.path.getsize(file_path)
|
| 68 |
+
space_freed += file_size
|
| 69 |
+
|
| 70 |
+
# Remove the file
|
| 71 |
+
os.remove(file_path)
|
| 72 |
+
files_removed += 1
|
| 73 |
+
print(f"Cleaned up: {filename}")
|
| 74 |
+
|
| 75 |
+
except Exception as e:
|
| 76 |
+
print(f"Error removing {filename}: {str(e)}")
|
| 77 |
+
|
| 78 |
+
# Convert bytes to MB
|
| 79 |
+
space_freed_mb = space_freed / (1024 * 1024)
|
| 80 |
+
|
| 81 |
+
return {
|
| 82 |
+
"files_removed": files_removed,
|
| 83 |
+
"space_freed_mb": round(space_freed_mb, 2)
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
except Exception as e:
|
| 87 |
+
print(f"Error during cleanup: {str(e)}")
|
| 88 |
+
return {"error": str(e)}
|
| 89 |
+
|
| 90 |
+
|
| 91 |
def download_youtube_video(url, output_dir="downloads"):
|
| 92 |
"""
|
| 93 |
Download a YouTube video from the provided URL using yt-dlp
|
app/utils/video_processor.py
CHANGED
|
@@ -20,13 +20,13 @@ class Detection:
|
|
| 20 |
self.confidence = confidence
|
| 21 |
|
| 22 |
|
| 23 |
-
def process_video(video_path, sample_rate=
|
| 24 |
"""
|
| 25 |
Process video and detect golfer, club, and ball
|
| 26 |
|
| 27 |
Args:
|
| 28 |
video_path (str): Path to the video file
|
| 29 |
-
sample_rate (int): Process every nth frame
|
| 30 |
|
| 31 |
Returns:
|
| 32 |
tuple: (frames, detections)
|
|
@@ -50,10 +50,7 @@ def process_video(video_path, sample_rate=5):
|
|
| 50 |
|
| 51 |
frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
| 52 |
|
| 53 |
-
|
| 54 |
-
print(f"Short video detected ({frame_count} frames). Processing all frames.")
|
| 55 |
-
sample_rate = 1
|
| 56 |
-
|
| 57 |
frames = []
|
| 58 |
detections = []
|
| 59 |
|
|
|
|
| 20 |
self.confidence = confidence
|
| 21 |
|
| 22 |
|
| 23 |
+
def process_video(video_path, sample_rate=1):
|
| 24 |
"""
|
| 25 |
Process video and detect golfer, club, and ball
|
| 26 |
|
| 27 |
Args:
|
| 28 |
video_path (str): Path to the video file
|
| 29 |
+
sample_rate (int): Process every nth frame (default: 1 for all frames)
|
| 30 |
|
| 31 |
Returns:
|
| 32 |
tuple: (frames, detections)
|
|
|
|
| 50 |
|
| 51 |
frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
| 52 |
|
| 53 |
+
# Process all frames by default
|
|
|
|
|
|
|
|
|
|
| 54 |
frames = []
|
| 55 |
detections = []
|
| 56 |
|
app/utils/visualizer.py
CHANGED
|
@@ -38,7 +38,7 @@ def create_annotated_video(video_path,
|
|
| 38 |
swing_phases,
|
| 39 |
trajectory_data,
|
| 40 |
output_dir="downloads",
|
| 41 |
-
sample_rate=
|
| 42 |
"""
|
| 43 |
Create an annotated video with swing analysis visualizations
|
| 44 |
|
|
|
|
| 38 |
swing_phases,
|
| 39 |
trajectory_data,
|
| 40 |
output_dir="downloads",
|
| 41 |
+
sample_rate=1):
|
| 42 |
"""
|
| 43 |
Create an annotated video with swing analysis visualizations
|
| 44 |
|