Spaces:
Running
Running
| """ | |
| π΄ AI Bike Fitting Analyzer | |
| Analyze cycling posture and get recommendations for bike adjustments. | |
| """ | |
| import gradio as gr | |
| import cv2 | |
| import mediapipe as mp | |
| import numpy as np | |
| import matplotlib.pyplot as plt | |
| import tempfile | |
| import os | |
| # Initialize MediaPipe | |
| mp_pose = mp.solutions.pose | |
| # Landmark indices | |
| LANDMARKS = { | |
| 'left_shoulder': mp_pose.PoseLandmark.LEFT_SHOULDER, | |
| 'right_shoulder': mp_pose.PoseLandmark.RIGHT_SHOULDER, | |
| 'left_elbow': mp_pose.PoseLandmark.LEFT_ELBOW, | |
| 'right_elbow': mp_pose.PoseLandmark.RIGHT_ELBOW, | |
| 'left_wrist': mp_pose.PoseLandmark.LEFT_WRIST, | |
| 'right_wrist': mp_pose.PoseLandmark.RIGHT_WRIST, | |
| 'left_hip': mp_pose.PoseLandmark.LEFT_HIP, | |
| 'right_hip': mp_pose.PoseLandmark.RIGHT_HIP, | |
| 'left_knee': mp_pose.PoseLandmark.LEFT_KNEE, | |
| 'right_knee': mp_pose.PoseLandmark.RIGHT_KNEE, | |
| 'left_ankle': mp_pose.PoseLandmark.LEFT_ANKLE, | |
| 'right_ankle': mp_pose.PoseLandmark.RIGHT_ANKLE, | |
| 'left_heel': mp_pose.PoseLandmark.LEFT_HEEL, | |
| 'right_heel': mp_pose.PoseLandmark.RIGHT_HEEL, | |
| 'left_foot_index': mp_pose.PoseLandmark.LEFT_FOOT_INDEX, | |
| 'right_foot_index': mp_pose.PoseLandmark.RIGHT_FOOT_INDEX, | |
| } | |
| # Optimal angle ranges for bike fitting | |
| OPTIMAL_RANGES = { | |
| 'torso_angle': (80, 90), | |
| 'hip_angle': (60, 100), | |
| 'knee_angle': (75, 160), | |
| 'ankle_angle': (90, 130), | |
| 'elbow_angle': (150, 175), | |
| } | |
| ANGLE_DESCRIPTIONS = { | |
| 'torso_angle': 'Torso (elbow-shoulder-hip)', | |
| 'hip_angle': 'Hip (shoulder-hip-knee)', | |
| 'knee_angle': 'Knee (hip-knee-ankle)', | |
| 'ankle_angle': 'Ankle (knee-ankle-foot)', | |
| 'elbow_angle': 'Elbow (shoulder-elbow-wrist)', | |
| } | |
| def calculate_angle(point1, point2, point3): | |
| """Calculate angle at point2 formed by point1-point2-point3.""" | |
| a = np.array(point1) | |
| b = np.array(point2) | |
| c = np.array(point3) | |
| ba = a - b | |
| bc = c - b | |
| cosine_angle = np.dot(ba, bc) / (np.linalg.norm(ba) * np.linalg.norm(bc) + 1e-6) | |
| cosine_angle = np.clip(cosine_angle, -1.0, 1.0) | |
| return np.degrees(np.arccos(cosine_angle)) | |
| def get_landmark_coords(landmarks, landmark_name, image_shape): | |
| """Extract pixel coordinates for a landmark.""" | |
| landmark = landmarks.landmark[LANDMARKS[landmark_name]] | |
| h, w = image_shape[:2] | |
| return (int(landmark.x * w), int(landmark.y * h)) | |
| def compute_angles(landmarks, image_shape, side='right'): | |
| """Compute all bike fitting angles.""" | |
| prefix = side + '_' | |
| shoulder = get_landmark_coords(landmarks, prefix + 'shoulder', image_shape) | |
| elbow = get_landmark_coords(landmarks, prefix + 'elbow', image_shape) | |
| wrist = get_landmark_coords(landmarks, prefix + 'wrist', image_shape) | |
| hip = get_landmark_coords(landmarks, prefix + 'hip', image_shape) | |
| knee = get_landmark_coords(landmarks, prefix + 'knee', image_shape) | |
| ankle = get_landmark_coords(landmarks, prefix + 'ankle', image_shape) | |
| foot = get_landmark_coords(landmarks, prefix + 'foot_index', image_shape) | |
| angles = { | |
| 'torso_angle': calculate_angle(elbow, shoulder, hip), | |
| 'hip_angle': calculate_angle(shoulder, hip, knee), | |
| 'knee_angle': calculate_angle(hip, knee, ankle), | |
| 'ankle_angle': calculate_angle(knee, ankle, foot), | |
| 'elbow_angle': calculate_angle(shoulder, elbow, wrist), | |
| '_coords': { | |
| 'shoulder': shoulder, 'elbow': elbow, 'wrist': wrist, | |
| 'hip': hip, 'knee': knee, 'ankle': ankle, 'foot': foot, | |
| } | |
| } | |
| return angles | |
| def get_status_color(angle_name, value): | |
| """Get color based on whether angle is in optimal range.""" | |
| if angle_name not in OPTIMAL_RANGES: | |
| return (255, 255, 255) | |
| min_val, max_val = OPTIMAL_RANGES[angle_name] | |
| if min_val <= value <= max_val: | |
| return (0, 255, 0) # Green | |
| elif value < min_val - 10 or value > max_val + 10: | |
| return (0, 0, 255) # Red | |
| else: | |
| return (0, 165, 255) # Orange | |
| def draw_overlay(image, angles): | |
| """Draw skeleton and angle annotations on image.""" | |
| annotated = image.copy() | |
| coords = angles['_coords'] | |
| skeleton_color = (0, 255, 0) | |
| # Draw skeleton | |
| cv2.line(annotated, coords['shoulder'], coords['elbow'], skeleton_color, 3) | |
| cv2.line(annotated, coords['elbow'], coords['wrist'], skeleton_color, 3) | |
| cv2.line(annotated, coords['shoulder'], coords['hip'], skeleton_color, 3) | |
| cv2.line(annotated, coords['hip'], coords['knee'], skeleton_color, 3) | |
| cv2.line(annotated, coords['knee'], coords['ankle'], skeleton_color, 3) | |
| cv2.line(annotated, coords['ankle'], coords['foot'], skeleton_color, 3) | |
| # Draw angle labels | |
| angle_positions = [ | |
| ('torso_angle', coords['shoulder'], (-60, -30)), | |
| ('hip_angle', coords['hip'], (40, -10)), | |
| ('knee_angle', coords['knee'], (-80, 0)), | |
| ('ankle_angle', coords['ankle'], (10, 30)), | |
| ] | |
| for angle_name, position, offset in angle_positions: | |
| value = angles[angle_name] | |
| color = get_status_color(angle_name, value) | |
| text_pos = (position[0] + offset[0], position[1] + offset[1]) | |
| # Background | |
| text = f"{value:.0f}" | |
| (tw, th), _ = cv2.getTextSize(text, cv2.FONT_HERSHEY_SIMPLEX, 0.7, 2) | |
| cv2.rectangle(annotated, (text_pos[0]-5, text_pos[1]-th-5), | |
| (text_pos[0]+tw+15, text_pos[1]+5), (0,0,0), -1) | |
| cv2.rectangle(annotated, (text_pos[0]-5, text_pos[1]-th-5), | |
| (text_pos[0]+tw+15, text_pos[1]+5), color, 2) | |
| # Text | |
| cv2.putText(annotated, text, text_pos, cv2.FONT_HERSHEY_SIMPLEX, 0.7, (255,255,255), 2) | |
| cv2.putText(annotated, "o", (text_pos[0]+tw, text_pos[1]-th+5), | |
| cv2.FONT_HERSHEY_SIMPLEX, 0.3, (255,255,255), 1) | |
| # Optimal range | |
| if angle_name in OPTIMAL_RANGES: | |
| opt = OPTIMAL_RANGES[angle_name] | |
| cv2.putText(annotated, f"{opt[0]}-{opt[1]}", | |
| (text_pos[0], text_pos[1]+20), | |
| cv2.FONT_HERSHEY_SIMPLEX, 0.4, (200,200,200), 1) | |
| # Draw joints | |
| for coord in coords.values(): | |
| cv2.circle(annotated, coord, 6, skeleton_color, -1) | |
| cv2.circle(annotated, coord, 8, (255,255,255), 2) | |
| return annotated | |
| def create_plots(angle_history): | |
| """Create time-series plots for angles.""" | |
| if not angle_history: | |
| return None | |
| fig, axes = plt.subplots(2, 2, figsize=(12, 8)) | |
| axes = axes.flatten() | |
| angle_names = ['torso_angle', 'hip_angle', 'knee_angle', 'ankle_angle'] | |
| times = [a['time'] for a in angle_history] | |
| for idx, name in enumerate(angle_names): | |
| ax = axes[idx] | |
| values = [a[name] for a in angle_history] | |
| ax.plot(times, values, 'b-', linewidth=1.5) | |
| if name in OPTIMAL_RANGES: | |
| opt_min, opt_max = OPTIMAL_RANGES[name] | |
| ax.axhspan(opt_min, opt_max, alpha=0.2, color='green', label=f'Optimal ({opt_min}-{opt_max}Β°)') | |
| ax.axhline(y=np.mean(values), color='red', linestyle='--', label=f'Mean: {np.mean(values):.1f}Β°') | |
| ax.set_xlabel('Time (s)') | |
| ax.set_ylabel('Angle (Β°)') | |
| ax.set_title(ANGLE_DESCRIPTIONS[name]) | |
| ax.legend(loc='upper right', fontsize=8) | |
| ax.grid(True, alpha=0.3) | |
| plt.suptitle('π΄ Bike Fitting Angle Analysis', fontsize=14, fontweight='bold') | |
| plt.tight_layout() | |
| # Save to temp file | |
| plot_path = tempfile.mktemp(suffix='.png') | |
| plt.savefig(plot_path, dpi=150, bbox_inches='tight') | |
| plt.close() | |
| return plot_path | |
| def process_video(video_path, side, progress=gr.Progress()): | |
| """Main processing function.""" | |
| if video_path is None: | |
| return None, None | |
| cap = cv2.VideoCapture(video_path) | |
| if not cap.isOpened(): | |
| return None, None | |
| fps = int(cap.get(cv2.CAP_PROP_FPS)) or 30 | |
| width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) | |
| height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) | |
| total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) | |
| # Output video | |
| output_path = tempfile.mktemp(suffix='.mp4') | |
| fourcc = cv2.VideoWriter_fourcc(*'mp4v') | |
| out = cv2.VideoWriter(output_path, fourcc, fps, (width, height)) | |
| angle_history = [] | |
| with mp_pose.Pose( | |
| static_image_mode=False, | |
| model_complexity=2, | |
| min_detection_confidence=0.5, | |
| min_tracking_confidence=0.5 | |
| ) as pose: | |
| frame_idx = 0 | |
| while True: | |
| ret, frame = cap.read() | |
| if not ret: | |
| break | |
| # Update progress | |
| progress(frame_idx / total_frames, desc=f"Processing frame {frame_idx}/{total_frames}") | |
| # Process frame | |
| rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) | |
| results = pose.process(rgb) | |
| if results.pose_landmarks: | |
| angles = compute_angles(results.pose_landmarks, frame.shape, side) | |
| annotated = draw_overlay(frame, angles) | |
| # Store for plotting | |
| angle_data = {k: v for k, v in angles.items() if not k.startswith('_')} | |
| angle_data['time'] = frame_idx / fps | |
| angle_history.append(angle_data) | |
| else: | |
| annotated = frame | |
| out.write(annotated) | |
| frame_idx += 1 | |
| cap.release() | |
| out.release() | |
| # Convert to H.264 for browser compatibility | |
| web_output = tempfile.mktemp(suffix='.mp4') | |
| os.system(f'ffmpeg -y -i "{output_path}" -vcodec libx264 -acodec aac "{web_output}" -hide_banner -loglevel error') | |
| # Generate plot | |
| plot_path = create_plots(angle_history) | |
| return web_output, plot_path | |
| # Build Gradio interface | |
| with gr.Blocks(title="π΄ AI Bike Fitting Analyzer", theme=gr.themes.Soft()) as demo: | |
| gr.Markdown(""" | |
| # π΄ AI Bike Fitting Analyzer | |
| Upload a video of a cyclist on a stationary trainer (side view) to analyze their position | |
| and get recommendations for bike adjustments. | |
| **Tips for best results:** | |
| - Film from the side (perpendicular to the bike) | |
| - Ensure good lighting | |
| - Keep the full body in frame | |
| - 10-30 seconds of pedaling is ideal | |
| """) | |
| with gr.Row(): | |
| with gr.Column(scale=1): | |
| video_input = gr.Video(label="πΉ Upload Video") | |
| side_select = gr.Radio( | |
| choices=["left", "right"], | |
| value="right", | |
| label="Which side of the cyclist faces the camera?" | |
| ) | |
| analyze_btn = gr.Button("π Analyze", variant="primary", size="lg") | |
| with gr.Column(scale=1): | |
| video_output = gr.Video(label="π¬ Analyzed Video") | |
| with gr.Row(): | |
| plot_output = gr.Image(label="π Angle Analysis Over Time") | |
| analyze_btn.click( | |
| fn=process_video, | |
| inputs=[video_input, side_select], | |
| outputs=[video_output, plot_output], | |
| ) | |
| if __name__ == "__main__": | |
| demo.launch() |