nicolasbuitragob commited on
Commit
d1dd306
·
1 Parent(s): fccef52
Files changed (3) hide show
  1. CLAUDE.md +100 -0
  2. app.py +3 -2
  3. tasks.py +848 -467
CLAUDE.md ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ ## Development Commands
6
+
7
+ ### Running the Application
8
+ ```bash
9
+ # Install dependencies
10
+ pip install -r requirements.txt
11
+
12
+ # Start the FastAPI server
13
+ uvicorn app:app --host 0.0.0.0 --port 7860
14
+
15
+ # Alternative development server with reload
16
+ uvicorn app:app --reload
17
+ ```
18
+
19
+ ### Docker Deployment
20
+ ```bash
21
+ # Build the Docker image
22
+ docker build -t sportsai .
23
+
24
+ # Run the container
25
+ docker run -p 7860:7860 sportsai
26
+ ```
27
+
28
+ ### Environment Setup
29
+ - Create `.env` file with required environment variables:
30
+ - `API_URL`: External API endpoint URL
31
+ - `API_KEY`: Authentication key for external API
32
+ - `AI_API_TOKEN`: Token for authenticating incoming requests
33
+
34
+ ## Architecture Overview
35
+
36
+ ### Core Components
37
+
38
+ **FastAPI Application (`app.py`)**
39
+ - Main web server with two primary endpoints:
40
+ - `/upload`: General video processing with pose estimation
41
+ - `/exercise/salto_alto`: Specialized high jump exercise analysis
42
+ - Uses background tasks for asynchronous video processing
43
+ - Handles file uploads and API authentication
44
+
45
+ **Pose Estimation (`vitpose.py`)**
46
+ - Wraps the `rt-pose` library with VitPose model
47
+ - Provides pose estimation pipeline with CUDA/CPU support
48
+ - Handles video-to-frames conversion and frame annotation
49
+ - Automatically rotates landscape videos to portrait orientation
50
+
51
+ **Video Analysis (`tasks.py`)**
52
+ - Contains `process_salto_alto()` function for high jump analysis
53
+ - Implements comprehensive jump metrics calculation:
54
+ - Jump height detection using pose keypoints
55
+ - Sayer power estimation
56
+ - Repetition counting
57
+ - Metrics visualization overlay
58
+ - Sends results to external API endpoints via webhooks
59
+
60
+ **Configuration (`config.py`)**
61
+ - Manages environment variables and API credentials
62
+ - Uses python-dotenv for environment file loading
63
+
64
+ ### Key Features
65
+
66
+ **High Jump Analysis Pipeline:**
67
+ 1. Video upload and pose estimation using VitPose
68
+ 2. Calibration using person height in first frame
69
+ 3. Jump detection based on ankle movement thresholds
70
+ 4. Real-time metrics calculation and overlay visualization
71
+ 5. Results packaging and webhook delivery
72
+
73
+ **Pose Estimation:**
74
+ - Uses PekingU/rtdetr object detection + usyd-community/vitpose-plus-small
75
+ - Supports both CUDA and CPU inference
76
+ - Model compilation enabled for performance optimization
77
+
78
+ **Video Processing:**
79
+ - Automatic landscape-to-portrait rotation
80
+ - Skeleton visualization with keypoint connections
81
+ - Metrics overlay with rounded rectangles and real-time updates
82
+
83
+ ### Dependencies
84
+ - **FastAPI**: Web framework for API endpoints
85
+ - **rt-pose**: Pose estimation pipeline
86
+ - **OpenCV**: Video processing and computer vision
87
+ - **Supervision**: Keypoint visualization utilities
88
+ - **PyTorch**: Deep learning framework for pose models
89
+
90
+ ### File Structure
91
+ - `app.py`: Main FastAPI application
92
+ - `vitpose.py`: VitPose wrapper class
93
+ - `tasks.py`: Video processing and analysis functions
94
+ - `config.py`: Environment configuration
95
+ - `requirements.txt`: Python dependencies
96
+ - `Dockerfile`: Container deployment configuration
97
+ - `static/`: Directory for processed video outputs (git-ignored)
98
+
99
+ ### API Authentication
100
+ All endpoints require token-based authentication via header or body parameters. Unauthorized requests return 401 status codes.
app.py CHANGED
@@ -13,7 +13,7 @@ from tasks import process_video,process_salto_alto
13
  from fastapi.responses import JSONResponse
14
  from config import AI_API_TOKEN
15
  import logging
16
-
17
 
18
 
19
  logging.basicConfig(level=logging.INFO)
@@ -76,7 +76,7 @@ async def upload(background_tasks: BackgroundTasks,
76
  exercise_id: str = Body(...)
77
  ):
78
 
79
- import json
80
  player_data = json.loads(player_data)
81
 
82
  if token != AI_API_TOKEN:
@@ -105,3 +105,4 @@ async def upload(background_tasks: BackgroundTasks,
105
  print(f"returning response")
106
  return JSONResponse(content={"message": "Video uploaded successfully",
107
  "status": 200})
 
 
13
  from fastapi.responses import JSONResponse
14
  from config import AI_API_TOKEN
15
  import logging
16
+ import json
17
 
18
 
19
  logging.basicConfig(level=logging.INFO)
 
76
  exercise_id: str = Body(...)
77
  ):
78
 
79
+
80
  player_data = json.loads(player_data)
81
 
82
  if token != AI_API_TOKEN:
 
105
  print(f"returning response")
106
  return JSONResponse(content={"message": "Video uploaded successfully",
107
  "status": 200})
108
+
tasks.py CHANGED
@@ -6,6 +6,8 @@ from fastapi import UploadFile
6
  import logging
7
  import cv2
8
  import numpy as np
 
 
9
 
10
  import time
11
  import json
@@ -13,8 +15,108 @@ from fastapi.responses import JSONResponse
13
 
14
  logging.basicConfig(level=logging.INFO)
15
  logger = logging.getLogger(__name__)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
  def process_video(file_name: str,vitpose: VitPose,user_id: str,player_id: str):
 
 
 
 
 
17
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
  video_path = file_name
19
 
20
  contents = open(video_path, "rb").read()
@@ -59,14 +161,27 @@ def process_salto_alto(file_name: str,
59
  exercise_id: str,
60
  repetitions) -> dict:
61
  """
62
- Process a high jump exercise video using VitPose for pose estimation.
 
 
 
63
 
64
  Args:
65
- file_name: Path to the input video
66
- vitpose: VitPose instance for pose estimation
67
- player_data: Dictionary containing player information
68
- repetitions: Expected number of repetitions
69
- exercise_id: ID of the exercise
 
 
 
 
 
 
 
 
 
 
70
  """
71
  # Use the provided VitPose instance
72
 
@@ -109,7 +224,26 @@ def send_results_api(results_dict: dict,
109
  exercise_id: str,
110
  video_path: str) -> JSONResponse:
111
  """
112
- Updated function to send results to the new webhook endpoint
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
113
  """
114
  url = API_URL + "/excercises/webhooks/video-processed-results"
115
  logger.info(f"Sending video results to {url}")
@@ -142,73 +276,68 @@ def send_results_api(results_dict: dict,
142
  return response
143
 
144
 
145
-
146
-
147
-
148
-
149
- def analyze_jump_video(model: VitPose,
150
- input_video: str,
151
- output_video: str,
152
- player_height: float,
153
- body_mass_kg: float,
154
- repetitions: int) -> dict | None:
155
  """
156
- Analyze a jump video to calculate various jump metrics.
 
 
 
157
 
158
  Args:
159
- model: VitPose model instance
160
- input_video: Path to input video
161
- output_video: Path to output video
162
- reference_height: Height of the person in meters
163
- body_mass_kg: Weight of the person in kg
164
 
165
  Returns:
166
- Dictionary containing jump metrics and video analysis data
 
 
 
 
 
 
 
 
167
  """
 
 
 
168
 
 
 
 
 
169
 
170
- #TODO: REFACTOR THIS FUNCTION
171
-
172
-
173
-
174
- # Configuration parameters
175
- JUMP_THRESHOLD_PERCENT = 0.05 # Porcentaje de cambio en la altura del tobillo para detectar el inicio del salto
176
- SMOOTHING_WINDOW = 5 # Ventana para suavizar la altura de los tobillos
177
- HORIZONTAL_OFFSET_FACTOR = 0.75 # Factor para ubicar el cuadro entre el hombro y el borde
178
- VELOCITY_WINDOW = 3 # Número de frames para calcular la velocidad
179
- METRICS_BELOW_FEET_OFFSET = 20 # Offset en píxeles para colocar los cuadros debajo de los pies
180
-
181
- # Color palette
182
- BLUE = (255, 0, 0)
183
- GREEN = (0, 255, 0)
184
- YELLOW = (0, 255, 255)
185
- WHITE = (255, 255, 255)
186
- BLACK = (0, 0, 0)
187
- GRAY = (128, 128, 128)
188
- LIGHT_GRAY = (200, 200, 200)
189
-
190
- repetition_data = []
191
 
192
- # Open the video
193
- cap = cv2.VideoCapture(input_video)
194
- if not cap.isOpened():
195
- print("Error al abrir el video")
196
- return {}
197
 
198
- # Get first frame to calibrate and get initial shoulder positions
 
 
 
 
 
 
 
 
 
 
 
 
 
 
199
  ret, frame = cap.read()
200
  if not ret:
201
- print("Error al leer el video")
202
- return {}
203
 
204
- # Initialize calibration variables
205
- PX_PER_METER = None
206
- initial_person_height_px = None
207
- initial_left_shoulder_x = None
208
- initial_right_shoulder_x = None
209
-
210
- # Process first frame to calibrate
211
- output = model(frame) # Detect pose in first frame
212
  keypoints = output.keypoints_xy.float().cpu().numpy()
213
  labels = model.pose_estimator_config.label2id
214
 
@@ -218,276 +347,413 @@ def analyze_jump_video(model: VitPose,
218
  L_shoulder_keypoint = labels["L_Shoulder"]
219
  R_shoulder_keypoint = labels["R_Shoulder"]
220
 
221
- if (
222
- keypoints is not None
223
- and len(keypoints) > 0
224
- and len(keypoints[0]) > 0):
225
-
226
  kpts_first = keypoints[0]
227
- if len(kpts_first[nose_keypoint]) > 0 and len(kpts_first[L_ankle_keypoint]) > 0: # Nose and ankles
228
  initial_person_height_px = min(kpts_first[L_ankle_keypoint][1], kpts_first[R_ankle_keypoint][1]) - kpts_first[nose_keypoint][1]
229
  PX_PER_METER = initial_person_height_px / player_height
230
- if len(kpts_first[L_shoulder_keypoint]) > 0 and len(kpts_first[R_shoulder_keypoint]) > 0: # Left (5) and right (6) shoulders
231
  initial_left_shoulder_x = int(kpts_first[L_shoulder_keypoint][0])
232
  initial_right_shoulder_x = int(kpts_first[R_shoulder_keypoint][0])
233
-
234
  if PX_PER_METER is None or initial_left_shoulder_x is None or initial_right_shoulder_x is None:
235
- print("No se pudo calibrar la escala o detectar los hombros en el primer frame.")
236
- cap.release()
237
- return None
238
 
239
- # Reset video for processing
240
- cap.release()
241
- cap = cv2.VideoCapture(input_video)
242
- fps = cap.get(cv2.CAP_PROP_FPS)
243
- width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
244
- height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
245
- out = cv2.VideoWriter(output_video, cv2.VideoWriter_fourcc(*'mp4v'), fps, (width, height))
 
 
246
 
247
- # Variables for metrics and visualization
248
- ground_level = None
249
- takeoff_head_y = None
250
- max_jump_height = 0 # Maximum relative jump
251
- max_head_height_px = None # Maximum head height in pixels (lowest in y coordinates)
252
- jump_started = False
253
- head_y_history = []
254
- ankle_y_history = []
255
- last_detected_ankles_y = None
256
- head_y_buffer = []
257
- velocity_vertical = 0.0
258
- peak_power_sayer = 0.0 # Initialize Sayer power
259
- current_power = 0.0
260
- repetition_count = 0
261
- jump_peak_power = 0.0 # Peak power for current jump only
262
-
263
- # Process each frame
264
- while cap.isOpened():
265
- ret, frame = cap.read()
266
- if not ret:
267
- break
268
-
269
- annotated_frame = frame.copy()
270
- if repetition_count == repetitions:
271
- continue
272
-
273
- # Add try-except block around the model inference to catch any model errors
274
- try:
275
- output = model(annotated_frame)
276
- keypoints = output.keypoints_xy.float().cpu().numpy()
277
 
278
- # Verify that keypoints array has valid data before processing
279
- if (keypoints is not None and
280
- len(keypoints) > 0 and
281
- len(keypoints[0]) > 0 and
282
- keypoints.size > 0): # Check if array is not empty
283
- kpts = keypoints[0]
 
 
 
 
284
 
285
- # Make sure all required keypoints are detected
286
- if (nose_keypoint < len(kpts) and L_ankle_keypoint < len(kpts) and
287
- R_ankle_keypoint < len(kpts) and L_shoulder_keypoint < len(kpts) and
288
- R_shoulder_keypoint < len(kpts)):
289
 
290
- nose = kpts[nose_keypoint]
291
- ankles = [kpts[L_ankle_keypoint], kpts[R_ankle_keypoint]]
292
- left_shoulder = kpts[L_shoulder_keypoint]
293
- right_shoulder = kpts[R_shoulder_keypoint]
294
 
295
- # Check if keypoints have valid coordinates
296
- if (nose[0] > 0 and nose[1] > 0 and
297
- all(a[0] > 0 and a[1] > 0 for a in ankles) and
298
- left_shoulder[0] > 0 and left_shoulder[1] > 0 and
299
- right_shoulder[0] > 0 and right_shoulder[1] > 0):
300
-
301
- # Continue with existing processing
302
- current_ankle_y = min(a[1] for a in ankles)
303
- last_detected_ankles_y = current_ankle_y
304
- current_head_y = nose[1]
305
-
306
- # Smooth ankle and head positions
307
- ankle_y_history.append(current_ankle_y)
308
- if len(ankle_y_history) > SMOOTHING_WINDOW:
309
- ankle_y_history.pop(0)
310
- smoothed_ankle_y = np.mean(ankle_y_history)
311
-
312
- head_y_history.append(current_head_y)
313
- if len(head_y_history) > SMOOTHING_WINDOW:
314
- head_y_history.pop(0)
315
- smoothed_head_y = np.mean(head_y_history)
316
-
317
- # Calculate vertical velocity (using head position)
318
- head_y_buffer.append(smoothed_head_y)
319
- if len(head_y_buffer) > VELOCITY_WINDOW:
320
- head_y_buffer.pop(0)
321
- if PX_PER_METER is not None and fps > 0:
322
- delta_y_pixels = head_y_buffer[0] - head_y_buffer[-1]
323
- delta_y_meters = delta_y_pixels / PX_PER_METER
324
- delta_t = VELOCITY_WINDOW / fps
325
- velocity_vertical = delta_y_meters / delta_t
326
-
327
- # Set ground level in first frame where ankles are detected
328
- if ground_level is None:
329
- ground_level = smoothed_ankle_y
330
- takeoff_head_y = smoothed_head_y
331
-
332
- relative_ankle_change = (ground_level - smoothed_ankle_y) / ground_level if ground_level > 0 else 0
333
-
334
- # Detect jump start
335
- if not jump_started and relative_ankle_change > JUMP_THRESHOLD_PERCENT:
336
- jump_started = True
337
- takeoff_head_y = smoothed_head_y
338
- max_jump_height = 0
339
- max_head_height_px = smoothed_head_y
340
- jump_peak_power = 0.0 # Reset for this jump
341
-
342
- # Detect jump end
343
- if jump_started and relative_ankle_change <= JUMP_THRESHOLD_PERCENT:
344
- # Add to repetition data
345
- high_jump = calculate_high_jump(player_height, max_jump_height)
346
- repetition_data.append({
347
- "repetition": repetition_count + 1,
348
- "distancia_elevada": round(max_jump_height, 2),
349
- "salto_alto": round(high_jump, 2),
350
- "potencia_sayer": round(jump_peak_power, 2) # Use jump-specific peak
351
- })
352
- repetition_count += 1
353
- jump_started = False
354
-
355
- # Update jump metrics while in air
356
- if jump_started:
357
- relative_jump = (takeoff_head_y - smoothed_head_y) / PX_PER_METER
358
- if relative_jump > max_jump_height:
359
- max_jump_height = relative_jump
360
- if smoothed_head_y < max_head_height_px:
361
- max_head_height_px = smoothed_head_y
362
- if relative_jump:
363
- current_power = calculate_peak_power_sayer(relative_jump, body_mass_kg)
364
- if current_power > jump_peak_power: # Track peak for THIS jump
365
- jump_peak_power = current_power
366
- if current_power > peak_power_sayer: # Keep global peak too
367
- peak_power_sayer = current_power
368
- else:
369
- # Skip processing for this frame - invalid coordinates
370
- print("Skipping frame - invalid keypoint coordinates")
371
- print(f"keypoints {keypoints}")
372
- else:
373
- # Skip processing for this frame - missing required keypoints
374
- print("Skipping frame - missing required keypoints")
375
- print(f"keypoints {keypoints}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
376
  else:
377
- # Skip processing for this frame - no valid keypoints detected
378
- print("Skipping frame - no valid keypoints detected")
379
- print(f"keypoints {keypoints}")
380
  last_detected_ankles_y = None
381
- velocity_vertical = 0.0
382
- except Exception as e:
383
- # Handle any other exceptions that might occur during model inference
384
- print(f"Error processing frame: {e}")
385
- print(f"keypoints {keypoints}")
386
- last_detected_ankles_y = None
387
- velocity_vertical = 0.0
388
-
389
- # Calculate metrics and draw overlay even if keypoints weren't detected
390
- # This ensures video continues to show previous metrics
391
- high_jump = calculate_high_jump(player_height, max_jump_height)
392
-
393
- # Draw floating metric boxes
394
- annotated_frame = draw_metrics_overlay(
395
- frame=annotated_frame,
396
- max_jump_height=max_jump_height,
397
- salto_alto=high_jump,
398
- velocity_vertical=velocity_vertical,
399
- peak_power_sayer=peak_power_sayer,
400
- repetition_count=repetition_count,
401
- last_detected_ankles_y=last_detected_ankles_y,
402
- initial_left_shoulder_x=initial_left_shoulder_x,
403
- initial_right_shoulder_x=initial_right_shoulder_x,
404
- width=width,
405
- height=height,
406
- colors={
407
- "blue": BLUE,
408
- "green": GREEN,
409
- "yellow": YELLOW,
410
- "white": WHITE,
411
- "black": BLACK,
412
- "gray": GRAY,
413
- "light_gray": LIGHT_GRAY
414
  },
415
- metrics_below_feet_offset=METRICS_BELOW_FEET_OFFSET,
416
- horizontal_offset_factor=HORIZONTAL_OFFSET_FACTOR
417
- )
 
 
 
 
 
 
418
 
419
- # Draw person skeleton keypoints
420
- try:
421
- if keypoints is not None and len(keypoints) > 0 and len(keypoints[0]) > 0:
422
- # Use the exact keypoint indices
423
- keypoint_indices = {
424
- 'L_Ankle': 15, 'L_Ear': 3, 'L_Elbow': 7, 'L_Eye': 1, 'L_Hip': 11,
425
- 'L_Knee': 13, 'L_Shoulder': 5, 'L_Wrist': 9, 'Nose': 0, 'R_Ankle': 16,
426
- 'R_Ear': 4, 'R_Elbow': 8, 'R_Eye': 2, 'R_Hip': 12, 'R_Knee': 14,
427
- 'R_Shoulder': 6, 'R_Wrist': 10
428
- }
429
-
430
- # Define skeleton connections (pairs of keypoints that should be connected)
431
- skeleton_connections = [
432
- (keypoint_indices["Nose"], keypoint_indices["L_Eye"]),
433
- (keypoint_indices["Nose"], keypoint_indices["R_Eye"]),
434
- (keypoint_indices["L_Eye"], keypoint_indices["L_Ear"]),
435
- (keypoint_indices["R_Eye"], keypoint_indices["R_Ear"]),
436
- (keypoint_indices["Nose"], keypoint_indices["L_Shoulder"]),
437
- (keypoint_indices["Nose"], keypoint_indices["R_Shoulder"]),
438
- (keypoint_indices["L_Shoulder"], keypoint_indices["R_Shoulder"]),
439
- (keypoint_indices["L_Shoulder"], keypoint_indices["L_Elbow"]),
440
- (keypoint_indices["R_Shoulder"], keypoint_indices["R_Elbow"]),
441
- (keypoint_indices["L_Elbow"], keypoint_indices["L_Wrist"]),
442
- (keypoint_indices["R_Elbow"], keypoint_indices["R_Wrist"]),
443
- (keypoint_indices["L_Shoulder"], keypoint_indices["L_Hip"]),
444
- (keypoint_indices["R_Shoulder"], keypoint_indices["R_Hip"]),
445
- (keypoint_indices["L_Hip"], keypoint_indices["R_Hip"]),
446
- (keypoint_indices["L_Hip"], keypoint_indices["L_Knee"]),
447
- (keypoint_indices["R_Hip"], keypoint_indices["R_Knee"]),
448
- (keypoint_indices["L_Knee"], keypoint_indices["L_Ankle"]),
449
- (keypoint_indices["R_Knee"], keypoint_indices["R_Ankle"])
450
- ]
451
-
452
- kpts = keypoints[0]
453
- # Draw points
454
- for i, point in enumerate(kpts):
455
- if point[0] > 0 and point[1] > 0: # Only draw if keypoint is valid
456
- cv2.circle(annotated_frame, (int(point[0]), int(point[1])), 5, GREEN, -1)
457
-
458
- # Draw connections
459
- for connection in skeleton_connections:
460
- start_idx, end_idx = connection
461
- if (start_idx < len(kpts) and end_idx < len(kpts) and
462
- kpts[start_idx][0] > 0 and kpts[start_idx][1] > 0 and
463
- kpts[end_idx][0] > 0 and kpts[end_idx][1] > 0):
464
- start_point = (int(kpts[start_idx][0]), int(kpts[start_idx][1]))
465
- end_point = (int(kpts[end_idx][0]), int(kpts[end_idx][1]))
466
- cv2.line(annotated_frame, start_point, end_point, YELLOW, 2)
467
- except Exception as e:
468
- print(f"Error drawing skeleton: {e}")
469
-
470
- out.write(annotated_frame)
471
-
472
- # Prepare results dictionary
473
- results_dict = {
474
- "video_analysis": {
475
- "output_video": str(output_video),
476
- },
477
- "repetition_data": [
478
- {
479
- "repetition": int(rep["repetition"]),
480
- "distancia_elevada": float(rep["distancia_elevada"]),
481
- "salto_alto": float(rep["salto_alto"]),
482
- "potencia_sayer": float(rep["potencia_sayer"])
483
- } for rep in repetition_data
484
- ]
485
- }
486
-
487
- cap.release()
488
- out.release()
489
-
490
- return results_dict
491
 
492
 
493
  def calculate_peak_power_sayer(jump_height_m, body_mass_kg):
@@ -520,6 +786,234 @@ def calculate_high_jump(player_height:float, max_jump_height:float) -> float:
520
  return player_height + max_jump_height
521
 
522
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
523
  def draw_metrics_overlay(frame, max_jump_height, salto_alto, velocity_vertical, peak_power_sayer,
524
  repetition_count, last_detected_ankles_y, initial_left_shoulder_x,
525
  initial_right_shoulder_x, width, height, colors, metrics_below_feet_offset=20,
@@ -546,165 +1040,52 @@ def draw_metrics_overlay(frame, max_jump_height, salto_alto, velocity_vertical,
546
  Returns:
547
  Frame with metrics overlay
548
  """
 
 
549
 
550
-
551
- #TODO: REFACTOR THIS FUNCTION
552
 
 
 
 
 
553
 
554
- overlay = frame.copy()
555
- alpha = 0.7
556
- font = cv2.FONT_HERSHEY_SIMPLEX
557
- font_scale_title_metric = 0.5
558
- font_scale_value = 0.7
559
- font_scale_title_main = 1.2 # Scale for main title (larger)
560
- font_thickness_metric = 1
561
- font_thickness_title_main = 1 # Thickness for main title
562
- line_height_title_metric = int(20 * 1.2)
563
- line_height_value = int(25 * 1.2)
564
- padding_vertical = int(15 * 1.2)
565
- padding_horizontal = int(15 * 1.2)
566
- text_color_title = colors["light_gray"]
567
- text_color_value = colors["white"]
568
- text_color_title_main = colors["white"]
569
- bg_color = colors["gray"]
570
- border_color = colors["white"]
571
- border_thickness = 1
572
- corner_radius = 10
573
- spacing_horizontal = 30
574
- title_y_offset = 50 # Lower vertical position of title
575
- metrics_y_offset_alto = 80 # Adjust Salto Alto position to leave space below
576
- metrics_y_offset_relativo = None # Will be calculated dynamically
577
- metrics_y_offset_velocidad = None # Will be calculated dynamically
578
- metrics_y_offset_potencia = None # Will be calculated dynamically
579
-
580
- # Helper function to draw rounded rectangles
581
- def draw_rounded_rect(img, pt1, pt2, color, thickness=-1, lineType=cv2.LINE_AA, radius=10):
582
- x1, y1 = pt1
583
- x2, y2 = pt2
584
- w = x2 - x1
585
- h = y2 - y1
586
- if radius > 0:
587
- img = cv2.ellipse(img, (x1 + radius, y1 + radius), (radius, radius), 0, 0, 90, color, thickness, lineType)
588
- img = cv2.ellipse(img, (x2 - radius, y1 + radius), (radius, radius), 0, 90, 180, color, thickness, lineType)
589
- img = cv2.ellipse(img, (x2 - radius, y2 - radius), (radius, radius), 0, 180, 270, color, thickness, lineType)
590
- img = cv2.ellipse(img, (x1 + radius, y2 - radius), (radius, radius), 0, 270, 360, color, thickness, lineType)
591
-
592
- img = cv2.rectangle(img, (x1, y1 + radius), (x2, y2 - radius), color, thickness, lineType)
593
- img = cv2.rectangle(img, (x1 + radius, y1), (x2 - radius, y2), color, thickness, lineType)
594
- else:
595
- img = cv2.rectangle(img, pt1, pt2, color, thickness, lineType)
596
- return img
597
 
598
- # --- Main Title ---
599
- title_text = "Ejercicio de Salto"
600
- title_text_size = cv2.getTextSize(title_text, font, font_scale_title_main, font_thickness_title_main)[0]
601
- title_x = (width - title_text_size[0]) // 2
602
- title_y = title_y_offset
603
- cv2.putText(overlay, title_text, (title_x, title_y), font, font_scale_title_main, text_color_title_main, font_thickness_title_main, cv2.LINE_AA)
604
-
605
- # --- Relative Jump Box (dynamically positioned) ---
606
- relativo_text = "SALTO RELATIVO"
607
- relativo_value = f"{max(0, max_jump_height):.2f} m"
608
- relativo_text_size = cv2.getTextSize(relativo_text, font, font_scale_title_metric, font_thickness_metric)[0]
609
- relativo_value_size = cv2.getTextSize(relativo_value, font, font_scale_value, font_thickness_metric)[0]
610
- bg_width_relativo = max(relativo_text_size[0], relativo_value_size[0]) + 2 * padding_horizontal
611
- bg_height_relativo = line_height_title_metric + line_height_value + 2 * padding_vertical
612
- x_relativo = 20
613
 
614
- if last_detected_ankles_y is not None and bg_height_relativo is not None:
615
- metrics_y_offset_relativo = int(last_detected_ankles_y - bg_height_relativo - 10) # 10 pixels above ankle
616
- # Make sure box doesn't go off top
617
- if metrics_y_offset_relativo < title_y_offset + 50:
618
- metrics_y_offset_relativo = int(last_detected_ankles_y + metrics_below_feet_offset) # Show below
619
- else:
620
- metrics_y_offset_relativo = height - 150 # Default position if ankles not detected
621
-
622
- if metrics_y_offset_relativo is not None:
623
- y_relativo = metrics_y_offset_relativo
624
- pt1_relativo = (x_relativo, y_relativo)
625
- pt2_relativo = (x_relativo + bg_width_relativo, y_relativo + bg_height_relativo)
626
- overlay = draw_rounded_rect(overlay, pt1_relativo, pt2_relativo, bg_color, cv2.FILLED, cv2.LINE_AA, corner_radius)
627
- cv2.rectangle(overlay, pt1_relativo, pt2_relativo, border_color, border_thickness, cv2.LINE_AA)
628
- cv2.putText(overlay, relativo_text, (x_relativo + (bg_width_relativo - relativo_text_size[0]) // 2, y_relativo + padding_vertical + line_height_title_metric // 2 + 2), font, font_scale_title_metric, text_color_title, font_thickness_metric, cv2.LINE_AA)
629
- cv2.putText(overlay, relativo_value, (x_relativo + (bg_width_relativo - relativo_value_size[0]) // 2, y_relativo + padding_vertical + line_height_title_metric + line_height_value // 2 + 5), font, font_scale_value, text_color_value, font_thickness_metric, cv2.LINE_AA)
630
-
631
- # --- High Jump Box (stays in top right) ---
632
- alto_text = "SALTO ALTO"
633
- alto_value = f"{max(0, salto_alto):.2f} m"
634
- alto_text_size = cv2.getTextSize(alto_text, font, font_scale_title_metric, font_thickness_metric)[0]
635
- alto_value_size = cv2.getTextSize(alto_value, font, font_scale_value, font_thickness_metric)[0]
636
- bg_width_alto = max(alto_text_size[0], alto_value_size[0]) + 2 * padding_horizontal
637
- bg_height_alto = line_height_title_metric + line_height_value + 2 * padding_vertical
638
- x_alto = width - bg_width_alto - 20 # Default position near right edge
639
 
640
- if initial_right_shoulder_x is not None:
641
- available_space = width - initial_right_shoulder_x
642
- x_alto_calculated = initial_right_shoulder_x + int(available_space * (1 - horizontal_offset_factor)) - bg_width_alto
643
- # Make sure doesn't go off left edge and there's space from first box
644
- if x_alto_calculated > x_relativo + bg_width_relativo + spacing_horizontal + 10 and x_alto_calculated + bg_width_alto < width - 10:
645
- x_alto = x_alto_calculated
646
- y_alto = metrics_y_offset_alto
647
- pt1_alto = (x_alto, y_alto)
648
- pt2_alto = (x_alto + bg_width_alto, y_alto + bg_height_alto)
649
- overlay = draw_rounded_rect(overlay, pt1_alto, pt2_alto, bg_color, cv2.FILLED, cv2.LINE_AA, corner_radius)
650
- cv2.rectangle(overlay, pt1_alto, pt2_alto, border_color, border_thickness, cv2.LINE_AA)
651
- cv2.putText(overlay, alto_text, (x_alto + (bg_width_alto - alto_text_size[0]) // 2, y_alto + padding_vertical + line_height_title_metric // 2 + 2), font, font_scale_title_metric, text_color_title, font_thickness_metric, cv2.LINE_AA)
652
- cv2.putText(overlay, alto_value, (x_alto + (bg_width_alto - alto_value_size[0]) // 2, y_alto + padding_vertical + line_height_title_metric + line_height_value // 2 + 5), font, font_scale_value, text_color_value, font_thickness_metric, cv2.LINE_AA)
653
-
654
- # --- Repetitions Box ---
655
- reps_text = "REPETICIONES"
656
- reps_value = f"{repetition_count}"
657
- reps_text_size = cv2.getTextSize(reps_text, font, font_scale_title_metric, font_thickness_metric)[0]
658
- reps_value_size = cv2.getTextSize(reps_value, font, font_scale_value, font_thickness_metric)[0]
659
- bg_width_reps = max(reps_text_size[0], reps_value_size[0]) + 2 * padding_horizontal
660
- bg_height_reps = line_height_title_metric + line_height_value + 2 * padding_vertical
661
- x_reps = x_relativo
662
- y_reps = y_relativo + bg_height_relativo + 10
663
-
664
- pt1_reps = (x_reps, y_reps)
665
- pt2_reps = (x_reps + bg_width_reps, y_reps + bg_height_reps)
666
- overlay = draw_rounded_rect(overlay, pt1_reps, pt2_reps, bg_color, cv2.FILLED, cv2.LINE_AA, corner_radius)
667
- cv2.rectangle(overlay, pt1_reps, pt2_reps, border_color, border_thickness, cv2.LINE_AA)
668
- cv2.putText(overlay, reps_text, (x_reps + (bg_width_reps - reps_text_size[0]) // 2, y_reps + padding_vertical + line_height_title_metric // 2 + 2), font, font_scale_title_metric, text_color_title, font_thickness_metric, cv2.LINE_AA)
669
- cv2.putText(overlay, reps_value, (x_reps + (bg_width_reps - reps_value_size[0]) // 2, y_reps + padding_vertical + line_height_title_metric + line_height_value // 2 + 5), font, font_scale_value, text_color_value, font_thickness_metric, cv2.LINE_AA)
670
-
671
- # --- Vertical Velocity Box (below feet) ---
672
- if last_detected_ankles_y is not None:
673
- velocidad_text = "VELOCIDAD VERTICAL"
674
- velocidad_value = f"{abs(velocity_vertical):.2f} m/s" # Show absolute value
675
- velocidad_text_size = cv2.getTextSize(velocidad_text, font, font_scale_title_metric, font_thickness_metric)[0]
676
- velocidad_value_size = cv2.getTextSize(velocidad_value, font, font_scale_value, font_thickness_metric)[0]
677
- bg_width_velocidad = max(velocidad_text_size[0], velocidad_value_size[0]) + 2 * padding_horizontal
678
- bg_height_velocidad = line_height_title_metric + line_height_value + 2 * padding_vertical
679
-
680
- x_velocidad = int(width / 2 - bg_width_velocidad / 2) # Horizontally centered
681
- y_velocidad = int(last_detected_ankles_y + metrics_below_feet_offset + bg_height_velocidad)
682
-
683
- pt1_velocidad = (int(x_velocidad), int(y_velocidad - bg_height_velocidad))
684
- pt2_velocidad = (int(x_velocidad + bg_width_velocidad), int(y_velocidad))
685
- overlay = draw_rounded_rect(overlay, pt1_velocidad, pt2_velocidad, bg_color, cv2.FILLED, cv2.LINE_AA, corner_radius)
686
- cv2.rectangle(overlay, pt1_velocidad, pt2_velocidad, border_color, border_thickness, cv2.LINE_AA)
687
- cv2.putText(overlay, velocidad_text, (int(x_velocidad + (bg_width_velocidad - velocidad_text_size[0]) // 2), int(y_velocidad - bg_height_velocidad + padding_vertical + line_height_title_metric // 2 + 2)), font, font_scale_title_metric, text_color_title, font_thickness_metric, cv2.LINE_AA)
688
- cv2.putText(overlay, velocidad_value, (int(x_velocidad + (bg_width_velocidad - velocidad_value_size[0]) // 2), int(y_velocidad - bg_height_velocidad + padding_vertical + line_height_title_metric + line_height_value // 2 + 5)), font, font_scale_value, text_color_value, font_thickness_metric, cv2.LINE_AA)
689
-
690
- # --- Sayer Power Box (below velocity box) ---
691
- potencia_text = "POTENCIA SAYER"
692
  potencia_value = f"{peak_power_sayer:.2f} W"
693
- potencia_text_size = cv2.getTextSize(potencia_text, font, font_scale_title_metric, font_thickness_metric)[0]
694
- potencia_value_size = cv2.getTextSize(potencia_value, font, font_scale_value, font_thickness_metric)[0]
695
- bg_width_potencia = max(potencia_text_size[0], potencia_value_size[0]) + 2 * padding_horizontal
696
- bg_height_potencia = line_height_title_metric + line_height_value + 2 * padding_vertical
697
-
698
- x_potencia = x_velocidad # Same horizontal position as velocity
699
- y_potencia = y_velocidad + 5 # Below velocity box
700
-
701
- pt1_potencia = (int(x_potencia), int(y_potencia))
702
- pt2_potencia = (int(x_potencia + bg_width_potencia), int(y_potencia + bg_height_potencia))
703
- overlay = draw_rounded_rect(overlay, pt1_potencia, pt2_potencia, bg_color, cv2.FILLED, cv2.LINE_AA, corner_radius)
704
- cv2.rectangle(overlay, pt1_potencia, pt2_potencia, border_color, border_thickness, cv2.LINE_AA)
705
- cv2.putText(overlay, potencia_text, (int(x_potencia + (bg_width_potencia - potencia_text_size[0]) // 2), int(y_potencia + padding_vertical + line_height_title_metric // 2 + 2)), font, font_scale_title_metric, text_color_title, font_thickness_metric, cv2.LINE_AA)
706
- cv2.putText(overlay, potencia_value, (int(x_potencia + (bg_width_potencia - potencia_value_size[0]) // 2), int(y_potencia + padding_vertical + line_height_title_metric + line_height_value // 2 + 5)), font, font_scale_value, text_color_value, font_thickness_metric, cv2.LINE_AA)
707
 
708
  # Blend overlay with original frame
709
- result = cv2.addWeighted(overlay, alpha, frame, 1 - alpha, 0)
710
  return result
 
6
  import logging
7
  import cv2
8
  import numpy as np
9
+ from dataclasses import dataclass
10
+ from typing import Optional, Tuple, Dict, List
11
 
12
  import time
13
  import json
 
15
 
16
  logging.basicConfig(level=logging.INFO)
17
  logger = logging.getLogger(__name__)
18
+
19
+ # Jump Analysis Constants
20
+ JUMP_THRESHOLD_PERCENT = 0.05
21
+ SMOOTHING_WINDOW = 5
22
+ HORIZONTAL_OFFSET_FACTOR = 0.75
23
+ VELOCITY_WINDOW = 3
24
+ METRICS_BELOW_FEET_OFFSET = 20
25
+
26
+ # Color Constants
27
+ BLUE = (255, 0, 0)
28
+ GREEN = (0, 255, 0)
29
+ YELLOW = (0, 255, 255)
30
+ WHITE = (255, 255, 255)
31
+ BLACK = (0, 0, 0)
32
+ GRAY = (128, 128, 128)
33
+ LIGHT_GRAY = (200, 200, 200)
34
+
35
+ COLORS = {
36
+ "blue": BLUE,
37
+ "green": GREEN,
38
+ "yellow": YELLOW,
39
+ "white": WHITE,
40
+ "black": BLACK,
41
+ "gray": GRAY,
42
+ "light_gray": LIGHT_GRAY
43
+ }
44
+
45
+ # Keypoint indices
46
+ KEYPOINT_INDICES = {
47
+ 'L_Ankle': 15, 'L_Ear': 3, 'L_Elbow': 7, 'L_Eye': 1, 'L_Hip': 11,
48
+ 'L_Knee': 13, 'L_Shoulder': 5, 'L_Wrist': 9, 'Nose': 0, 'R_Ankle': 16,
49
+ 'R_Ear': 4, 'R_Elbow': 8, 'R_Eye': 2, 'R_Hip': 12, 'R_Knee': 14,
50
+ 'R_Shoulder': 6, 'R_Wrist': 10
51
+ }
52
+
53
+ # Skeleton connections
54
+ SKELETON_CONNECTIONS = [
55
+ ("Nose", "L_Eye"), ("Nose", "R_Eye"), ("L_Eye", "L_Ear"), ("R_Eye", "R_Ear"),
56
+ ("Nose", "L_Shoulder"), ("Nose", "R_Shoulder"), ("L_Shoulder", "R_Shoulder"),
57
+ ("L_Shoulder", "L_Elbow"), ("R_Shoulder", "R_Elbow"), ("L_Elbow", "L_Wrist"),
58
+ ("R_Elbow", "R_Wrist"), ("L_Shoulder", "L_Hip"), ("R_Shoulder", "R_Hip"),
59
+ ("L_Hip", "R_Hip"), ("L_Hip", "L_Knee"), ("R_Hip", "R_Knee"),
60
+ ("L_Knee", "L_Ankle"), ("R_Knee", "R_Ankle")
61
+ ]
62
+
63
+ @dataclass
64
+ class JumpMetrics:
65
+ max_jump_height: float = 0.0
66
+ velocity_vertical: float = 0.0
67
+ peak_power_sayer: float = 0.0
68
+ jump_peak_power: float = 0.0
69
+ repetition_count: int = 0
70
+ ground_level: Optional[float] = None
71
+ takeoff_head_y: Optional[float] = None
72
+ max_head_height_px: Optional[float] = None
73
+ jump_started: bool = False
74
+
75
+ @dataclass
76
+ class OverlayConfig:
77
+ alpha: float = 0.7
78
+ font: int = cv2.FONT_HERSHEY_SIMPLEX
79
+ font_scale_title_metric: float = 0.5
80
+ font_scale_value: float = 0.7
81
+ font_scale_title_main: float = 1.2
82
+ font_thickness_metric: int = 1
83
+ font_thickness_title_main: int = 1
84
+ line_height_title_metric: int = int(20 * 1.2)
85
+ line_height_value: int = int(25 * 1.2)
86
+ padding_vertical: int = int(15 * 1.2)
87
+ padding_horizontal: int = int(15 * 1.2)
88
+ border_thickness: int = 1
89
+ corner_radius: int = 10
90
+ spacing_horizontal: int = 30
91
+ title_y_offset: int = 50
92
+ metrics_y_offset_alto: int = 80
93
+
94
+ @dataclass
95
+ class FramePosition:
96
+ x: int
97
+ y: int
98
+ width: int
99
+ height: int
100
  def process_video(file_name: str,vitpose: VitPose,user_id: str,player_id: str):
101
+ """
102
+ Process a video file using VitPose for pose estimation and send results to webhook.
103
+
104
+ This function processes a video file by applying pose estimation, saving the annotated
105
+ video to the static directory, and sending the processed video to a webhook endpoint.
106
 
107
+ Args:
108
+ file_name (str): Path to the input video file
109
+ vitpose (VitPose): VitPose instance for pose estimation
110
+ user_id (str): ID of the user uploading the video
111
+ player_id (str): ID of the player in the video
112
+
113
+ Returns:
114
+ None
115
+
116
+ Raises:
117
+ ValueError: If video file cannot be opened or processed
118
+ requests.RequestException: If webhook request fails
119
+ """
120
  video_path = file_name
121
 
122
  contents = open(video_path, "rb").read()
 
161
  exercise_id: str,
162
  repetitions) -> dict:
163
  """
164
+ Process a high jump exercise video using VitPose for pose estimation and analyze jump metrics.
165
+
166
+ This function processes a high jump video by analyzing pose keypoints to calculate
167
+ jump metrics including height, velocity, and power. Results are sent to an API endpoint.
168
 
169
  Args:
170
+ file_name (str): Path to the input video file
171
+ vitpose (VitPose): VitPose instance for pose estimation
172
+ player_data (dict): Dictionary containing player information including:
173
+ - height: Player height in cm
174
+ - weight: Player weight in kg
175
+ - id: Player identifier
176
+ exercise_id (str): Unique identifier for the exercise
177
+ repetitions (int): Expected number of jump repetitions in the video
178
+
179
+ Returns:
180
+ dict: Dictionary containing analysis results and video information
181
+
182
+ Raises:
183
+ ValueError: If video processing fails or player data is invalid
184
+ requests.RequestException: If API request fails
185
  """
186
  # Use the provided VitPose instance
187
 
 
224
  exercise_id: str,
225
  video_path: str) -> JSONResponse:
226
  """
227
+ Send video analysis results to the API webhook endpoint.
228
+
229
+ This function uploads the analyzed video file along with the computed metrics
230
+ to the API's webhook endpoint for processing and storage.
231
+
232
+ Args:
233
+ results_dict (dict): Dictionary containing analysis results including:
234
+ - video_analysis: Information about the processed video
235
+ - repetition_data: List of metrics for each jump repetition
236
+ player_id (str): Unique identifier for the player
237
+ exercise_id (str): Unique identifier for the exercise
238
+ video_path (str): Path to the video file to upload
239
+
240
+ Returns:
241
+ JSONResponse: HTTP response from the API endpoint
242
+
243
+ Raises:
244
+ FileNotFoundError: If the video file doesn't exist
245
+ requests.RequestException: If the API request fails
246
+ json.JSONEncodeError: If results_dict cannot be serialized to JSON
247
  """
248
  url = API_URL + "/excercises/webhooks/video-processed-results"
249
  logger.info(f"Sending video results to {url}")
 
276
  return response
277
 
278
 
279
+ def setup_video_capture(input_video: str, output_video: str) -> Tuple[cv2.VideoCapture, cv2.VideoWriter, int, int]:
 
 
 
 
 
 
 
 
 
280
  """
281
+ Initialize video capture and writer objects for video processing.
282
+
283
+ This function creates OpenCV VideoCapture and VideoWriter objects with matching
284
+ properties (frame rate, dimensions) for reading from input and writing to output.
285
 
286
  Args:
287
+ input_video (str): Path to the input video file
288
+ output_video (str): Path for the output video file
 
 
 
289
 
290
  Returns:
291
+ Tuple[cv2.VideoCapture, cv2.VideoWriter, int, int]: A tuple containing:
292
+ - cap: VideoCapture object for reading input video
293
+ - out: VideoWriter object for writing output video
294
+ - width: Video frame width in pixels
295
+ - height: Video frame height in pixels
296
+
297
+ Raises:
298
+ ValueError: If the input video cannot be opened or read
299
+ cv2.error: If video writer initialization fails
300
  """
301
+ cap = cv2.VideoCapture(input_video)
302
+ if not cap.isOpened():
303
+ raise ValueError("Error al abrir el video")
304
 
305
+ fps = cap.get(cv2.CAP_PROP_FPS)
306
+ width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
307
+ height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
308
+ out = cv2.VideoWriter(output_video, cv2.VideoWriter_fourcc(*'mp4v'), fps, (width, height))
309
 
310
+ return cap, out, width, height
311
+
312
+
313
+ def calibrate_pose_detection(model, cap, player_height: float) -> Tuple[float, int, int]:
314
+ """
315
+ Calibrate pose detection scale and reference points using the first video frame.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
316
 
317
+ This function analyzes the first frame to establish the pixel-to-meter conversion
318
+ ratio based on the player's known height and detects initial shoulder positions
319
+ for reference during video processing.
 
 
320
 
321
+ Args:
322
+ model: VitPose model instance for pose estimation
323
+ cap: OpenCV VideoCapture object
324
+ player_height (float): Actual height of the player in meters
325
+
326
+ Returns:
327
+ Tuple[float, int, int]: A tuple containing:
328
+ - PX_PER_METER: Conversion factor from pixels to meters
329
+ - initial_left_shoulder_x: X-coordinate of left shoulder in pixels
330
+ - initial_right_shoulder_x: X-coordinate of right shoulder in pixels
331
+
332
+ Raises:
333
+ ValueError: If video cannot be read or pose detection fails on first frame
334
+ IndexError: If required keypoints are not detected in the first frame
335
+ """
336
  ret, frame = cap.read()
337
  if not ret:
338
+ raise ValueError("Error al leer el video")
 
339
 
340
+ output = model(frame)
 
 
 
 
 
 
 
341
  keypoints = output.keypoints_xy.float().cpu().numpy()
342
  labels = model.pose_estimator_config.label2id
343
 
 
347
  L_shoulder_keypoint = labels["L_Shoulder"]
348
  R_shoulder_keypoint = labels["R_Shoulder"]
349
 
350
+ PX_PER_METER = None
351
+ initial_left_shoulder_x = None
352
+ initial_right_shoulder_x = None
353
+
354
+ if (keypoints is not None and len(keypoints) > 0 and len(keypoints[0]) > 0):
355
  kpts_first = keypoints[0]
356
+ if len(kpts_first[nose_keypoint]) > 0 and len(kpts_first[L_ankle_keypoint]) > 0:
357
  initial_person_height_px = min(kpts_first[L_ankle_keypoint][1], kpts_first[R_ankle_keypoint][1]) - kpts_first[nose_keypoint][1]
358
  PX_PER_METER = initial_person_height_px / player_height
359
+ if len(kpts_first[L_shoulder_keypoint]) > 0 and len(kpts_first[R_shoulder_keypoint]) > 0:
360
  initial_left_shoulder_x = int(kpts_first[L_shoulder_keypoint][0])
361
  initial_right_shoulder_x = int(kpts_first[R_shoulder_keypoint][0])
362
+
363
  if PX_PER_METER is None or initial_left_shoulder_x is None or initial_right_shoulder_x is None:
364
+ raise ValueError("No se pudo calibrar la escala o detectar los hombros en el primer frame.")
 
 
365
 
366
+ return PX_PER_METER, initial_left_shoulder_x, initial_right_shoulder_x
367
+
368
+
369
+ def process_frame_keypoints(model, frame):
370
+ """
371
+ Process a video frame and extract human pose keypoints.
372
+
373
+ This function applies the pose estimation model to a frame and validates
374
+ that all required keypoints (nose, ankles, shoulders) are detected and visible.
375
 
376
+ Args:
377
+ model: VitPose model instance for pose estimation
378
+ frame: Input video frame as numpy array
379
+
380
+ Returns:
381
+ Tuple containing:
382
+ - success (bool): True if all required keypoints were detected, False otherwise
383
+ - current_ankle_y (float or None): Y-coordinate of the highest ankle point if detected
384
+ - current_head_y (float or None): Y-coordinate of the nose point if detected
385
+ - keypoints (numpy.ndarray or None): Array of detected keypoints if successful
386
+ """
387
+ try:
388
+ output = model(frame)
389
+ keypoints = output.keypoints_xy.float().cpu().numpy()
390
+ labels = model.pose_estimator_config.label2id
391
+
392
+ nose_keypoint = labels["Nose"]
393
+ L_ankle_keypoint = labels["L_Ankle"]
394
+ R_ankle_keypoint = labels["R_Ankle"]
395
+ L_shoulder_keypoint = labels["L_Shoulder"]
396
+ R_shoulder_keypoint = labels["R_Shoulder"]
397
+
398
+ if (keypoints is not None and
399
+ len(keypoints) > 0 and
400
+ len(keypoints[0]) > 0 and
401
+ keypoints.size > 0):
 
 
 
 
402
 
403
+ kpts = keypoints[0]
404
+
405
+ if (nose_keypoint < len(kpts) and L_ankle_keypoint < len(kpts) and
406
+ R_ankle_keypoint < len(kpts) and L_shoulder_keypoint < len(kpts) and
407
+ R_shoulder_keypoint < len(kpts)):
408
+
409
+ nose = kpts[nose_keypoint]
410
+ ankles = [kpts[L_ankle_keypoint], kpts[R_ankle_keypoint]]
411
+ left_shoulder = kpts[L_shoulder_keypoint]
412
+ right_shoulder = kpts[R_shoulder_keypoint]
413
 
414
+ if (nose[0] > 0 and nose[1] > 0 and
415
+ all(a[0] > 0 and a[1] > 0 for a in ankles) and
416
+ left_shoulder[0] > 0 and left_shoulder[1] > 0 and
417
+ right_shoulder[0] > 0 and right_shoulder[1] > 0):
418
 
419
+ current_ankle_y = min(a[1] for a in ankles)
420
+ current_head_y = nose[1]
 
 
421
 
422
+ return True, current_ankle_y, current_head_y, keypoints
423
+
424
+ return False, None, None, None
425
+
426
+ except Exception as e:
427
+ print(f"Error processing frame: {e}")
428
+ return False, None, None, None
429
+
430
+
431
+ def detect_jump_events(metrics: JumpMetrics, smoothed_ankle_y: float, smoothed_head_y: float,
432
+ repetition_data: List[Dict], player_height: float, body_mass_kg: float,
433
+ repetitions: int) -> bool:
434
+ """
435
+ Detect jump start and end events based on ankle position changes.
436
+
437
+ This function monitors ankle position relative to ground level to detect when
438
+ a jump begins and ends. It calculates jump metrics for completed jumps and
439
+ tracks repetition count.
440
+
441
+ Args:
442
+ metrics (JumpMetrics): Object tracking current jump state and metrics
443
+ smoothed_ankle_y (float): Current smoothed ankle Y-coordinate
444
+ smoothed_head_y (float): Current smoothed head Y-coordinate
445
+ repetition_data (List[Dict]): List to store completed jump data
446
+ player_height (float): Player height in meters
447
+ body_mass_kg (float): Player body mass in kilograms
448
+ repetitions (int): Target number of repetitions to detect
449
+
450
+ Returns:
451
+ bool: True if target number of repetitions has been reached, False otherwise
452
+
453
+ Side Effects:
454
+ - Updates metrics object with jump state
455
+ - Appends completed jump data to repetition_data list
456
+ - Modifies metrics.ground_level, metrics.jump_started, metrics.repetition_count
457
+ """
458
+ if metrics.ground_level is None:
459
+ metrics.ground_level = smoothed_ankle_y
460
+ metrics.takeoff_head_y = smoothed_head_y
461
+ return False
462
+
463
+ relative_ankle_change = (metrics.ground_level - smoothed_ankle_y) / metrics.ground_level if metrics.ground_level > 0 else 0
464
+
465
+ # Detect jump start
466
+ if not metrics.jump_started and relative_ankle_change > JUMP_THRESHOLD_PERCENT:
467
+ metrics.jump_started = True
468
+ metrics.takeoff_head_y = smoothed_head_y
469
+ metrics.max_jump_height = 0
470
+ metrics.max_head_height_px = smoothed_head_y
471
+ metrics.jump_peak_power = 0.0
472
+ return False
473
+
474
+ # Detect jump end
475
+ if metrics.jump_started and relative_ankle_change <= JUMP_THRESHOLD_PERCENT:
476
+ high_jump = calculate_high_jump(player_height, metrics.max_jump_height)
477
+ repetition_data.append({
478
+ "repetition": metrics.repetition_count + 1,
479
+ "distancia_elevada": round(metrics.max_jump_height, 2),
480
+ "salto_alto": round(high_jump, 2),
481
+ "potencia_sayer": round(metrics.jump_peak_power, 2)
482
+ })
483
+ metrics.repetition_count += 1
484
+ metrics.jump_started = False
485
+
486
+ return metrics.repetition_count >= repetitions
487
+
488
+ return False
489
+
490
+
491
+ def calculate_jump_metrics(metrics: JumpMetrics, smoothed_head_y: float, PX_PER_METER: float,
492
+ body_mass_kg: float, head_y_buffer: List[float], fps: float):
493
+ """
494
+ Calculate jump metrics during an active jump phase.
495
+
496
+ This function continuously updates jump metrics while a jump is in progress,
497
+ tracking maximum jump height, peak power, and other performance indicators.
498
+
499
+ Args:
500
+ metrics (JumpMetrics): Object containing current jump state and metrics
501
+ smoothed_head_y (float): Current smoothed head Y-coordinate in pixels
502
+ PX_PER_METER (float): Conversion factor from pixels to meters
503
+ body_mass_kg (float): Player body mass in kilograms
504
+ head_y_buffer (List[float]): Buffer of recent head positions for velocity calculation
505
+ fps (float): Video frame rate in frames per second
506
+
507
+ Returns:
508
+ None
509
+
510
+ Side Effects:
511
+ - Updates metrics.max_jump_height if current jump exceeds previous maximum
512
+ - Updates metrics.max_head_height_px with lowest Y-coordinate (highest position)
513
+ - Updates metrics.jump_peak_power and metrics.peak_power_sayer with calculated power values
514
+ """
515
+ if not metrics.jump_started:
516
+ return
517
+
518
+ relative_jump = (metrics.takeoff_head_y - smoothed_head_y) / PX_PER_METER
519
+ if relative_jump > metrics.max_jump_height:
520
+ metrics.max_jump_height = relative_jump
521
+
522
+ if smoothed_head_y < metrics.max_head_height_px:
523
+ metrics.max_head_height_px = smoothed_head_y
524
+
525
+ if relative_jump:
526
+ current_power = calculate_peak_power_sayer(relative_jump, body_mass_kg)
527
+ if current_power > metrics.jump_peak_power:
528
+ metrics.jump_peak_power = current_power
529
+ if current_power > metrics.peak_power_sayer:
530
+ metrics.peak_power_sayer = current_power
531
+
532
+
533
+ def calculate_velocity(head_y_buffer: List[float], PX_PER_METER: float, fps: float) -> float:
534
+ """
535
+ Calculate vertical velocity based on head position changes over time.
536
+
537
+ This function computes the vertical velocity by analyzing the change in head
538
+ position over a specified time window, converting from pixel coordinates to
539
+ real-world units.
540
+
541
+ Args:
542
+ head_y_buffer (List[float]): Buffer containing recent head Y-coordinates in pixels
543
+ PX_PER_METER (float): Conversion factor from pixels to meters
544
+ fps (float): Video frame rate in frames per second
545
+
546
+ Returns:
547
+ float: Vertical velocity in meters per second (positive = upward motion)
548
+ Returns 0.0 if calculation cannot be performed
549
+
550
+ Note:
551
+ - Requires at least VELOCITY_WINDOW frames in the buffer
552
+ - Velocity is calculated as the change from oldest to newest position
553
+ - Y-coordinates decrease as objects move upward in image coordinates
554
+ """
555
+ if len(head_y_buffer) < VELOCITY_WINDOW or PX_PER_METER is None or fps <= 0:
556
+ return 0.0
557
+
558
+ delta_y_pixels = head_y_buffer[0] - head_y_buffer[-1]
559
+ delta_y_meters = delta_y_pixels / PX_PER_METER
560
+ delta_t = VELOCITY_WINDOW / fps
561
+ return delta_y_meters / delta_t
562
+
563
+
564
+ def draw_skeleton(frame, keypoints):
565
+ """
566
+ Draw human pose skeleton on a video frame.
567
+
568
+ This function visualizes the detected pose by drawing keypoints as circles
569
+ and connecting them with lines according to the human body structure.
570
+
571
+ Args:
572
+ frame (numpy.ndarray): Video frame to draw on (modified in-place)
573
+ keypoints (numpy.ndarray or None): Array of detected keypoints with shape (N, 17, 2)
574
+ where N is batch size, 17 is number of keypoints,
575
+ and 2 represents (x, y) coordinates
576
+
577
+ Returns:
578
+ None
579
+
580
+ Side Effects:
581
+ - Modifies the input frame by drawing circles for keypoints
582
+ - Draws lines connecting related body parts (skeleton connections)
583
+ - Uses GREEN color for keypoints and YELLOW for connections
584
+
585
+ Note:
586
+ - Safely handles None or empty keypoints arrays
587
+ - Only draws keypoints and connections with positive coordinates
588
+ - Uses SKELETON_CONNECTIONS constant for body part relationships
589
+ """
590
+ if keypoints is None or len(keypoints) == 0 or len(keypoints[0]) == 0:
591
+ return
592
+
593
+ try:
594
+ kpts = keypoints[0]
595
+
596
+ # Draw points
597
+ for point in kpts:
598
+ if point[0] > 0 and point[1] > 0:
599
+ cv2.circle(frame, (int(point[0]), int(point[1])), 5, GREEN, -1)
600
+
601
+ # Draw connections
602
+ for connection in SKELETON_CONNECTIONS:
603
+ start_name, end_name = connection
604
+ start_idx = KEYPOINT_INDICES[start_name]
605
+ end_idx = KEYPOINT_INDICES[end_name]
606
+
607
+ if (start_idx < len(kpts) and end_idx < len(kpts) and
608
+ kpts[start_idx][0] > 0 and kpts[start_idx][1] > 0 and
609
+ kpts[end_idx][0] > 0 and kpts[end_idx][1] > 0):
610
+
611
+ start_point = (int(kpts[start_idx][0]), int(kpts[start_idx][1]))
612
+ end_point = (int(kpts[end_idx][0]), int(kpts[end_idx][1]))
613
+ cv2.line(frame, start_point, end_point, YELLOW, 2)
614
+
615
+ except Exception as e:
616
+ print(f"Error drawing skeleton: {e}")
617
+
618
+
619
+
620
+
621
+
622
+
623
+ def analyze_jump_video(model: VitPose,
624
+ input_video: str,
625
+ output_video: str,
626
+ player_height: float,
627
+ body_mass_kg: float,
628
+ repetitions: int) -> dict | None:
629
+ """
630
+ Analyze a jump video to calculate various jump metrics.
631
+
632
+ Args:
633
+ model: VitPose model instance
634
+ input_video: Path to input video
635
+ output_video: Path to output video
636
+ player_height: Height of the person in meters
637
+ body_mass_kg: Weight of the person in kg
638
+ repetitions: Expected number of repetitions
639
+
640
+ Returns:
641
+ Dictionary containing jump metrics and video analysis data
642
+ """
643
+ try:
644
+ # Setup video capture and writer
645
+ cap, out, width, height = setup_video_capture(input_video, output_video)
646
+ fps = cap.get(cv2.CAP_PROP_FPS)
647
+
648
+ # Calibrate pose detection
649
+ PX_PER_METER, initial_left_shoulder_x, initial_right_shoulder_x = calibrate_pose_detection(
650
+ model, cap, player_height)
651
+
652
+ # Reset video for processing
653
+ cap.release()
654
+ cap = cv2.VideoCapture(input_video)
655
+
656
+ # Initialize tracking variables
657
+ metrics = JumpMetrics()
658
+ repetition_data = []
659
+ head_y_history = []
660
+ ankle_y_history = []
661
+ head_y_buffer = []
662
+ last_detected_ankles_y = None
663
+
664
+ # Process each frame
665
+ while cap.isOpened():
666
+ ret, frame = cap.read()
667
+ if not ret:
668
+ break
669
+
670
+ annotated_frame = frame.copy()
671
+ if metrics.repetition_count >= repetitions:
672
+ out.write(annotated_frame)
673
+ continue
674
+
675
+ # Process frame keypoints
676
+ keypoints_valid, current_ankle_y, current_head_y, keypoints = process_frame_keypoints(model, annotated_frame)
677
+
678
+ if keypoints_valid:
679
+ last_detected_ankles_y = current_ankle_y
680
+
681
+ # Smooth positions
682
+ ankle_y_history.append(current_ankle_y)
683
+ if len(ankle_y_history) > SMOOTHING_WINDOW:
684
+ ankle_y_history.pop(0)
685
+ smoothed_ankle_y = np.mean(ankle_y_history)
686
+
687
+ head_y_history.append(current_head_y)
688
+ if len(head_y_history) > SMOOTHING_WINDOW:
689
+ head_y_history.pop(0)
690
+ smoothed_head_y = np.mean(head_y_history)
691
+
692
+ # Calculate velocity
693
+ head_y_buffer.append(smoothed_head_y)
694
+ if len(head_y_buffer) > VELOCITY_WINDOW:
695
+ head_y_buffer.pop(0)
696
+ metrics.velocity_vertical = calculate_velocity(head_y_buffer, PX_PER_METER, fps)
697
+
698
+ # Detect jump events
699
+ should_stop = detect_jump_events(metrics, smoothed_ankle_y, smoothed_head_y,
700
+ repetition_data, player_height, body_mass_kg, repetitions)
701
+ if should_stop:
702
+ break
703
+
704
+ # Calculate jump metrics during jump
705
+ calculate_jump_metrics(metrics, smoothed_head_y, PX_PER_METER, body_mass_kg, head_y_buffer, fps)
706
  else:
 
 
 
707
  last_detected_ankles_y = None
708
+ metrics.velocity_vertical = 0.0
709
+
710
+ # Draw overlay and skeleton
711
+ high_jump = calculate_high_jump(player_height, metrics.max_jump_height)
712
+ annotated_frame = draw_metrics_overlay(
713
+ frame=annotated_frame,
714
+ max_jump_height=metrics.max_jump_height,
715
+ salto_alto=high_jump,
716
+ velocity_vertical=metrics.velocity_vertical,
717
+ peak_power_sayer=metrics.peak_power_sayer,
718
+ repetition_count=metrics.repetition_count,
719
+ last_detected_ankles_y=last_detected_ankles_y,
720
+ initial_left_shoulder_x=initial_left_shoulder_x,
721
+ initial_right_shoulder_x=initial_right_shoulder_x,
722
+ width=width,
723
+ height=height,
724
+ colors=COLORS,
725
+ metrics_below_feet_offset=METRICS_BELOW_FEET_OFFSET,
726
+ horizontal_offset_factor=HORIZONTAL_OFFSET_FACTOR
727
+ )
728
+
729
+ if keypoints_valid and keypoints is not None:
730
+ draw_skeleton(annotated_frame, keypoints)
731
+
732
+ out.write(annotated_frame)
733
+
734
+ # Prepare results
735
+ results_dict = {
736
+ "video_analysis": {
737
+ "output_video": str(output_video),
 
 
 
738
  },
739
+ "repetition_data": [
740
+ {
741
+ "repetition": int(rep["repetition"]),
742
+ "distancia_elevada": float(rep["distancia_elevada"]),
743
+ "salto_alto": float(rep["salto_alto"]),
744
+ "potencia_sayer": float(rep["potencia_sayer"])
745
+ } for rep in repetition_data
746
+ ]
747
+ }
748
 
749
+ cap.release()
750
+ out.release()
751
+
752
+ return results_dict
753
+
754
+ except Exception as e:
755
+ print(f"Error in analyze_jump_video: {e}")
756
+ return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
757
 
758
 
759
  def calculate_peak_power_sayer(jump_height_m, body_mass_kg):
 
786
  return player_height + max_jump_height
787
 
788
 
789
+ def draw_rounded_rect(img, pt1, pt2, color, thickness=-1, lineType=cv2.LINE_AA, radius=10):
790
+ """
791
+ Draw a rectangle with rounded corners on an image.
792
+
793
+ This function creates a rounded rectangle by drawing four corner ellipses
794
+ and connecting them with straight rectangular sections.
795
+
796
+ Args:
797
+ img (numpy.ndarray): Image to draw on (modified in-place)
798
+ pt1 (tuple): Top-left corner coordinates (x, y)
799
+ pt2 (tuple): Bottom-right corner coordinates (x, y)
800
+ color (tuple): BGR color tuple (B, G, R)
801
+ thickness (int, optional): Line thickness. -1 for filled rectangle. Defaults to -1.
802
+ lineType (int, optional): Type of line drawing. Defaults to cv2.LINE_AA.
803
+ radius (int, optional): Corner radius in pixels. Defaults to 10.
804
+
805
+ Returns:
806
+ numpy.ndarray: The modified image with rounded rectangle drawn
807
+
808
+ Note:
809
+ - If radius is 0, draws a regular rectangle
810
+ - For filled rectangles, use thickness=-1
811
+ - Corner ellipses are drawn at each corner with specified radius
812
+ - Rectangle sections fill the gaps between ellipses
813
+ """
814
+ x1, y1 = pt1
815
+ x2, y2 = pt2
816
+ if radius > 0:
817
+ img = cv2.ellipse(img, (x1 + radius, y1 + radius), (radius, radius), 0, 0, 90, color, thickness, lineType)
818
+ img = cv2.ellipse(img, (x2 - radius, y1 + radius), (radius, radius), 0, 90, 180, color, thickness, lineType)
819
+ img = cv2.ellipse(img, (x2 - radius, y2 - radius), (radius, radius), 0, 180, 270, color, thickness, lineType)
820
+ img = cv2.ellipse(img, (x1 + radius, y2 - radius), (radius, radius), 0, 270, 360, color, thickness, lineType)
821
+
822
+ img = cv2.rectangle(img, (x1, y1 + radius), (x2, y2 - radius), color, thickness, lineType)
823
+ img = cv2.rectangle(img, (x1 + radius, y1), (x2 - radius, y2), color, thickness, lineType)
824
+ else:
825
+ img = cv2.rectangle(img, pt1, pt2, color, thickness, lineType)
826
+ return img
827
+
828
+
829
+ def draw_main_title(overlay, config: OverlayConfig, width: int, colors: Dict):
830
+ """
831
+ Draw the main title text centered at the top of the video frame.
832
+
833
+ This function renders "Ejercicio de Salto" (Jump Exercise) as the main title
834
+ using specified font configuration and centers it horizontally.
835
+
836
+ Args:
837
+ overlay (numpy.ndarray): Image overlay to draw on (modified in-place)
838
+ config (OverlayConfig): Configuration object containing font settings
839
+ width (int): Width of the video frame in pixels
840
+ colors (Dict): Dictionary containing color definitions
841
+
842
+ Returns:
843
+ None
844
+
845
+ Side Effects:
846
+ - Draws text on the overlay image using white color
847
+ - Text is positioned at the top center of the frame
848
+ - Uses config.font_scale_title_main and config.font_thickness_title_main
849
+ """
850
+ title_text = "Ejercicio de Salto"
851
+ title_text_size = cv2.getTextSize(title_text, config.font, config.font_scale_title_main, config.font_thickness_title_main)[0]
852
+ title_x = (width - title_text_size[0]) // 2
853
+ title_y = config.title_y_offset
854
+ cv2.putText(overlay, title_text, (title_x, title_y), config.font, config.font_scale_title_main,
855
+ colors["white"], config.font_thickness_title_main, cv2.LINE_AA)
856
+
857
+
858
+ def calculate_metric_box_size(title: str, value: str, config: OverlayConfig) -> Tuple[int, int]:
859
+ """
860
+ Calculate the required dimensions for a metric display box.
861
+
862
+ This function determines the width and height needed to display a metric
863
+ with its title and value, including padding and spacing requirements.
864
+
865
+ Args:
866
+ title (str): The metric title text (e.g., "SALTO ALTO")
867
+ value (str): The metric value text (e.g., "2.15 m")
868
+ config (OverlayConfig): Configuration object with font and spacing settings
869
+
870
+ Returns:
871
+ Tuple[int, int]: A tuple containing:
872
+ - bg_width: Required width in pixels for the metric box
873
+ - bg_height: Required height in pixels for the metric box
874
+
875
+ Note:
876
+ - Width is based on the maximum of title and value text widths
877
+ - Height accounts for both text lines plus vertical padding
878
+ - Includes horizontal padding on both sides
879
+ """
880
+ title_size = cv2.getTextSize(title, config.font, config.font_scale_title_metric, config.font_thickness_metric)[0]
881
+ value_size = cv2.getTextSize(value, config.font, config.font_scale_value, config.font_thickness_metric)[0]
882
+
883
+ bg_width = max(title_size[0], value_size[0]) + 2 * config.padding_horizontal
884
+ bg_height = config.line_height_title_metric + config.line_height_value + 2 * config.padding_vertical
885
+
886
+ return bg_width, bg_height
887
+
888
+
889
+ def draw_metric_box(overlay, title: str, value: str, x: int, y: int, bg_width: int, bg_height: int,
890
+ config: OverlayConfig, colors: Dict):
891
+ """
892
+ Draw a styled metric box with title and value text.
893
+
894
+ This function creates a rounded rectangle background and draws metric information
895
+ with proper text alignment and styling for video overlay display.
896
+
897
+ Args:
898
+ overlay (numpy.ndarray): Image overlay to draw on (modified in-place)
899
+ title (str): Metric title text (displayed in smaller font)
900
+ value (str): Metric value text (displayed in larger font)
901
+ x (int): X-coordinate of box top-left corner
902
+ y (int): Y-coordinate of box top-left corner
903
+ bg_width (int): Width of the background box in pixels
904
+ bg_height (int): Height of the background box in pixels
905
+ config (OverlayConfig): Configuration object with styling settings
906
+ colors (Dict): Dictionary containing color definitions
907
+
908
+ Returns:
909
+ numpy.ndarray: The modified overlay with the metric box drawn
910
+
911
+ Side Effects:
912
+ - Draws a rounded rectangle background with gray fill and white border
913
+ - Centers title text in light gray color
914
+ - Centers value text in white color below the title
915
+ - Uses different font scales for title and value
916
+ """
917
+ pt1 = (x, y)
918
+ pt2 = (x + bg_width, y + bg_height)
919
+
920
+ # Draw background
921
+ overlay = draw_rounded_rect(overlay, pt1, pt2, colors["gray"], cv2.FILLED, cv2.LINE_AA, config.corner_radius)
922
+ cv2.rectangle(overlay, pt1, pt2, colors["white"], config.border_thickness, cv2.LINE_AA)
923
+
924
+ # Draw title
925
+ title_size = cv2.getTextSize(title, config.font, config.font_scale_title_metric, config.font_thickness_metric)[0]
926
+ title_x = x + (bg_width - title_size[0]) // 2
927
+ title_y = y + config.padding_vertical + config.line_height_title_metric // 2 + 2
928
+ cv2.putText(overlay, title, (title_x, title_y), config.font, config.font_scale_title_metric,
929
+ colors["light_gray"], config.font_thickness_metric, cv2.LINE_AA)
930
+
931
+ # Draw value
932
+ value_size = cv2.getTextSize(value, config.font, config.font_scale_value, config.font_thickness_metric)[0]
933
+ value_x = x + (bg_width - value_size[0]) // 2
934
+ value_y = y + config.padding_vertical + config.line_height_title_metric + config.line_height_value // 2 + 5
935
+ cv2.putText(overlay, value, (value_x, value_y), config.font, config.font_scale_value,
936
+ colors["white"], config.font_thickness_metric, cv2.LINE_AA)
937
+
938
+ return overlay
939
+
940
+
941
+ def calculate_positions(width: int, height: int, last_detected_ankles_y: Optional[float],
942
+ initial_left_shoulder_x: Optional[int], initial_right_shoulder_x: Optional[int],
943
+ config: OverlayConfig, horizontal_offset_factor: float,
944
+ metrics_below_feet_offset: int) -> Dict[str, Tuple[int, int]]:
945
+ """
946
+ Calculate optimal positions for all metric display boxes on the video frame.
947
+
948
+ This function determines where to place metric boxes based on detected body positions
949
+ to avoid overlapping with the person while maintaining good visibility.
950
+
951
+ Args:
952
+ width (int): Video frame width in pixels
953
+ height (int): Video frame height in pixels
954
+ last_detected_ankles_y (Optional[float]): Y-coordinate of last detected ankles
955
+ initial_left_shoulder_x (Optional[int]): X-coordinate of left shoulder reference
956
+ initial_right_shoulder_x (Optional[int]): X-coordinate of right shoulder reference
957
+ config (OverlayConfig): Configuration object with layout settings
958
+ horizontal_offset_factor (float): Factor for horizontal positioning relative to shoulders
959
+ metrics_below_feet_offset (int): Vertical offset below feet for metric placement
960
+
961
+ Returns:
962
+ Dict[str, Tuple[int, int]]: Dictionary mapping metric names to (x, y) positions:
963
+ - "relativo": Position for relative jump metric
964
+ - "alto": Position for high jump metric
965
+ - "reps": Position for repetitions counter
966
+ - "velocidad": Position for velocity metric (if ankles detected)
967
+ - "potencia": Position for power metric (if ankles detected)
968
+
969
+ Note:
970
+ - Positions are calculated to avoid overlapping with the detected person
971
+ - Some metrics are positioned relative to body parts when available
972
+ - Falls back to default positions when body parts are not detected
973
+ """
974
+ positions = {}
975
+
976
+ # Relative jump box (left side, dynamically positioned)
977
+ relativo_bg_width, relativo_bg_height = calculate_metric_box_size("SALTO RELATIVO", "0.00 m", config)
978
+ x_relativo = 20
979
+
980
+ if last_detected_ankles_y is not None:
981
+ y_relativo = int(last_detected_ankles_y - relativo_bg_height - 10)
982
+ if y_relativo < config.title_y_offset + 50:
983
+ y_relativo = int(last_detected_ankles_y + metrics_below_feet_offset)
984
+ else:
985
+ y_relativo = height - 150
986
+
987
+ positions["relativo"] = (x_relativo, y_relativo)
988
+
989
+ # High jump box (top right)
990
+ alto_bg_width, alto_bg_height = calculate_metric_box_size("SALTO ALTO", "0.00 m", config)
991
+ x_alto = width - alto_bg_width - 20
992
+
993
+ if initial_right_shoulder_x is not None:
994
+ available_space = width - initial_right_shoulder_x
995
+ x_alto_calculated = initial_right_shoulder_x + int(available_space * (1 - horizontal_offset_factor)) - alto_bg_width
996
+ if (x_alto_calculated > x_relativo + relativo_bg_width + config.spacing_horizontal + 10 and
997
+ x_alto_calculated + alto_bg_width < width - 10):
998
+ x_alto = x_alto_calculated
999
+
1000
+ positions["alto"] = (x_alto, config.metrics_y_offset_alto)
1001
+
1002
+ # Repetitions box (below relative jump)
1003
+ positions["reps"] = (x_relativo, y_relativo + relativo_bg_height + 10)
1004
+
1005
+ # Velocity and power boxes (centered below feet)
1006
+ if last_detected_ankles_y is not None:
1007
+ velocidad_bg_width, velocidad_bg_height = calculate_metric_box_size("VELOCIDAD VERTICAL", "0.00 m/s", config)
1008
+ x_velocidad = int(width / 2 - velocidad_bg_width / 2)
1009
+ y_velocidad = int(last_detected_ankles_y + metrics_below_feet_offset + velocidad_bg_height)
1010
+
1011
+ positions["velocidad"] = (x_velocidad, y_velocidad - velocidad_bg_height)
1012
+ positions["potencia"] = (x_velocidad, y_velocidad + 5)
1013
+
1014
+ return positions
1015
+
1016
+
1017
  def draw_metrics_overlay(frame, max_jump_height, salto_alto, velocity_vertical, peak_power_sayer,
1018
  repetition_count, last_detected_ankles_y, initial_left_shoulder_x,
1019
  initial_right_shoulder_x, width, height, colors, metrics_below_feet_offset=20,
 
1040
  Returns:
1041
  Frame with metrics overlay
1042
  """
1043
+ overlay = frame.copy()
1044
+ config = OverlayConfig()
1045
 
1046
+ # Draw main title
1047
+ draw_main_title(overlay, config, width, colors)
1048
 
1049
+ # Calculate positions for all metric boxes
1050
+ positions = calculate_positions(width, height, last_detected_ankles_y,
1051
+ initial_left_shoulder_x, initial_right_shoulder_x,
1052
+ config, horizontal_offset_factor, metrics_below_feet_offset)
1053
 
1054
+ # Draw relative jump box
1055
+ if "relativo" in positions:
1056
+ relativo_value = f"{max(0, max_jump_height):.2f} m"
1057
+ bg_width, bg_height = calculate_metric_box_size("SALTO RELATIVO", relativo_value, config)
1058
+ x, y = positions["relativo"]
1059
+ overlay = draw_metric_box(overlay, "SALTO RELATIVO", relativo_value, x, y, bg_width, bg_height, config, colors)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1060
 
1061
+ # Draw high jump box
1062
+ if "alto" in positions:
1063
+ alto_value = f"{max(0, salto_alto):.2f} m"
1064
+ bg_width, bg_height = calculate_metric_box_size("SALTO ALTO", alto_value, config)
1065
+ x, y = positions["alto"]
1066
+ overlay = draw_metric_box(overlay, "SALTO ALTO", alto_value, x, y, bg_width, bg_height, config, colors)
 
 
 
 
 
 
 
 
 
1067
 
1068
+ # Draw repetitions box
1069
+ if "reps" in positions:
1070
+ reps_value = f"{repetition_count}"
1071
+ bg_width, bg_height = calculate_metric_box_size("REPETICIONES", reps_value, config)
1072
+ x, y = positions["reps"]
1073
+ overlay = draw_metric_box(overlay, "REPETICIONES", reps_value, x, y, bg_width, bg_height, config, colors)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1074
 
1075
+ # Draw velocity box (only if ankles detected)
1076
+ if "velocidad" in positions:
1077
+ velocidad_value = f"{abs(velocity_vertical):.2f} m/s"
1078
+ bg_width, bg_height = calculate_metric_box_size("VELOCIDAD VERTICAL", velocidad_value, config)
1079
+ x, y = positions["velocidad"]
1080
+ overlay = draw_metric_box(overlay, "VELOCIDAD VERTICAL", velocidad_value, x, y, bg_width, bg_height, config, colors)
1081
+
1082
+ # Draw power box (only if ankles detected)
1083
+ if "potencia" in positions:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1084
  potencia_value = f"{peak_power_sayer:.2f} W"
1085
+ bg_width, bg_height = calculate_metric_box_size("POTENCIA SAYER", potencia_value, config)
1086
+ x, y = positions["potencia"]
1087
+ overlay = draw_metric_box(overlay, "POTENCIA SAYER", potencia_value, x, y, bg_width, bg_height, config, colors)
 
 
 
 
 
 
 
 
 
 
 
1088
 
1089
  # Blend overlay with original frame
1090
+ result = cv2.addWeighted(overlay, config.alpha, frame, 1 - config.alpha, 0)
1091
  return result