kinemotion 0.76.3__py3-none-any.whl → 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of kinemotion might be problematic. Click here for more details.

Files changed (53) hide show
  1. kinemotion/__init__.py +3 -18
  2. kinemotion/api.py +7 -27
  3. kinemotion/cli.py +2 -4
  4. kinemotion/{countermovement_jump → cmj}/analysis.py +158 -16
  5. kinemotion/{countermovement_jump → cmj}/api.py +18 -46
  6. kinemotion/{countermovement_jump → cmj}/cli.py +46 -6
  7. kinemotion/cmj/debug_overlay.py +457 -0
  8. kinemotion/{countermovement_jump → cmj}/joint_angles.py +31 -96
  9. kinemotion/{countermovement_jump → cmj}/metrics_validator.py +271 -176
  10. kinemotion/{countermovement_jump → cmj}/validation_bounds.py +18 -1
  11. kinemotion/core/__init__.py +2 -11
  12. kinemotion/core/auto_tuning.py +107 -149
  13. kinemotion/core/cli_utils.py +0 -74
  14. kinemotion/core/debug_overlay_utils.py +15 -142
  15. kinemotion/core/experimental.py +51 -55
  16. kinemotion/core/filtering.py +56 -116
  17. kinemotion/core/pipeline_utils.py +2 -2
  18. kinemotion/core/pose.py +98 -47
  19. kinemotion/core/quality.py +6 -4
  20. kinemotion/core/smoothing.py +51 -65
  21. kinemotion/core/types.py +0 -15
  22. kinemotion/core/validation.py +7 -76
  23. kinemotion/core/video_io.py +27 -41
  24. kinemotion/{drop_jump → dropjump}/__init__.py +8 -2
  25. kinemotion/{drop_jump → dropjump}/analysis.py +120 -282
  26. kinemotion/{drop_jump → dropjump}/api.py +33 -59
  27. kinemotion/{drop_jump → dropjump}/cli.py +136 -70
  28. kinemotion/dropjump/debug_overlay.py +182 -0
  29. kinemotion/{drop_jump → dropjump}/kinematics.py +65 -175
  30. kinemotion/{drop_jump → dropjump}/metrics_validator.py +51 -25
  31. kinemotion/{drop_jump → dropjump}/validation_bounds.py +1 -1
  32. kinemotion/models/rtmpose-s_simcc-body7_pt-body7-halpe26_700e-256x192-7f134165_20230605.onnx +3 -0
  33. kinemotion/models/yolox_tiny_8xb8-300e_humanart-6f3252f9.onnx +3 -0
  34. {kinemotion-0.76.3.dist-info → kinemotion-2.0.0.dist-info}/METADATA +26 -75
  35. kinemotion-2.0.0.dist-info/RECORD +49 -0
  36. kinemotion/core/overlay_constants.py +0 -61
  37. kinemotion/core/video_analysis_base.py +0 -132
  38. kinemotion/countermovement_jump/debug_overlay.py +0 -325
  39. kinemotion/drop_jump/debug_overlay.py +0 -241
  40. kinemotion/squat_jump/__init__.py +0 -5
  41. kinemotion/squat_jump/analysis.py +0 -377
  42. kinemotion/squat_jump/api.py +0 -610
  43. kinemotion/squat_jump/cli.py +0 -309
  44. kinemotion/squat_jump/debug_overlay.py +0 -163
  45. kinemotion/squat_jump/kinematics.py +0 -342
  46. kinemotion/squat_jump/metrics_validator.py +0 -438
  47. kinemotion/squat_jump/validation_bounds.py +0 -221
  48. kinemotion-0.76.3.dist-info/RECORD +0 -57
  49. /kinemotion/{countermovement_jump → cmj}/__init__.py +0 -0
  50. /kinemotion/{countermovement_jump → cmj}/kinematics.py +0 -0
  51. {kinemotion-0.76.3.dist-info → kinemotion-2.0.0.dist-info}/WHEEL +0 -0
  52. {kinemotion-0.76.3.dist-info → kinemotion-2.0.0.dist-info}/entry_points.txt +0 -0
  53. {kinemotion-0.76.3.dist-info → kinemotion-2.0.0.dist-info}/licenses/LICENSE +0 -0
@@ -8,11 +8,8 @@ from pathlib import Path
8
8
  from typing import TYPE_CHECKING
9
9
 
10
10
  if TYPE_CHECKING:
11
- import numpy as np
12
11
  from numpy.typing import NDArray
13
12
 
14
- from .analysis import ContactState
15
-
16
13
  from ..core.auto_tuning import (
17
14
  AnalysisParameters,
18
15
  QualityPreset,
@@ -51,19 +48,10 @@ from .analysis import (
51
48
  detect_ground_contact,
52
49
  find_contact_phases,
53
50
  )
54
- from .debug_overlay import DropJumpDebugOverlayRenderer
51
+ from .debug_overlay import DebugOverlayRenderer
55
52
  from .kinematics import DropJumpMetrics, calculate_drop_jump_metrics
56
53
  from .metrics_validator import DropJumpMetricsValidator
57
54
 
58
- __all__ = [
59
- "AnalysisOverrides",
60
- "DropJumpVideoConfig",
61
- "DropJumpVideoResult",
62
- "process_dropjump_video",
63
- "process_dropjump_video_from_config",
64
- "process_dropjump_videos_bulk",
65
- ]
66
-
67
55
 
68
56
  @dataclass
69
57
  class AnalysisOverrides:
@@ -102,33 +90,14 @@ class DropJumpVideoConfig:
102
90
  overrides: AnalysisOverrides | None = None
103
91
  detection_confidence: float | None = None
104
92
  tracking_confidence: float | None = None
105
- verbose: bool = False
106
- timer: Timer | None = None
107
- pose_tracker: "MediaPipePoseTracker | None" = None
108
-
109
- def to_kwargs(self) -> dict:
110
- """Convert config to kwargs dict for process_dropjump_video."""
111
- return {
112
- "video_path": self.video_path,
113
- "quality": self.quality,
114
- "output_video": self.output_video,
115
- "json_output": self.json_output,
116
- "drop_start_frame": self.drop_start_frame,
117
- "overrides": self.overrides,
118
- "detection_confidence": self.detection_confidence,
119
- "tracking_confidence": self.tracking_confidence,
120
- "verbose": self.verbose,
121
- "timer": self.timer,
122
- "pose_tracker": self.pose_tracker,
123
- }
124
93
 
125
94
 
126
95
  def _assess_dropjump_quality(
127
- vertical_positions: "NDArray[np.float64]",
128
- visibilities: "NDArray[np.float64]",
129
- contact_states: list["ContactState"],
96
+ vertical_positions: "NDArray",
97
+ visibilities: "NDArray",
98
+ contact_states: list,
130
99
  fps: float,
131
- ) -> tuple[QualityAssessment, "NDArray[np.bool_]", bool, int]:
100
+ ) -> tuple:
132
101
  """Assess tracking quality and detect phases.
133
102
 
134
103
  Returns:
@@ -336,6 +305,7 @@ def _tune_and_smooth(
336
305
  characteristics = analyze_video_sample(landmarks_sequence, video_fps, frame_count)
337
306
  params = auto_tune_parameters(characteristics, quality_preset)
338
307
 
308
+ # Apply overrides if provided
339
309
  if overrides:
340
310
  params = apply_expert_overrides(
341
311
  params,
@@ -344,6 +314,14 @@ def _tune_and_smooth(
344
314
  overrides.min_contact_frames,
345
315
  overrides.visibility_threshold,
346
316
  )
317
+ else:
318
+ params = apply_expert_overrides(
319
+ params,
320
+ None,
321
+ None,
322
+ None,
323
+ None,
324
+ )
347
325
 
348
326
  smoothed_landmarks = apply_smoothing(landmarks_sequence, params, verbose, timer)
349
327
 
@@ -462,13 +440,16 @@ def _generate_debug_video(
462
440
  timer = timer or NULL_TIMER
463
441
  debug_h, debug_w = frames[0].shape[:2]
464
442
 
465
- # Calculate debug FPS: cap at 30 for high-fps videos, use step if frame-sparse
466
- debug_fps = min(video_fps, 30.0)
443
+ if video_fps > 30:
444
+ debug_fps = video_fps / (video_fps / 30.0)
445
+ else:
446
+ debug_fps = video_fps
447
+
467
448
  if len(frames) < len(smoothed_landmarks):
468
449
  step = max(1, int(video_fps / 30.0))
469
450
  debug_fps = video_fps / step
470
451
 
471
- def _render_frames(renderer: DropJumpDebugOverlayRenderer) -> None:
452
+ def _render_frames(renderer: DebugOverlayRenderer) -> None:
472
453
  for frame, idx in zip(frames, frame_indices, strict=True):
473
454
  annotated = renderer.render_frame(
474
455
  frame,
@@ -480,7 +461,7 @@ def _generate_debug_video(
480
461
  )
481
462
  renderer.write_frame(annotated)
482
463
 
483
- renderer_context = DropJumpDebugOverlayRenderer(
464
+ renderer_context = DebugOverlayRenderer(
484
465
  output_video,
485
466
  debug_w,
486
467
  debug_h,
@@ -630,23 +611,6 @@ def process_dropjump_video(
630
611
  return metrics
631
612
 
632
613
 
633
- def process_dropjump_video_from_config(
634
- config: DropJumpVideoConfig,
635
- ) -> DropJumpMetrics:
636
- """Process a drop jump video using a configuration object.
637
-
638
- This is a convenience wrapper around process_dropjump_video that
639
- accepts a DropJumpVideoConfig instead of individual parameters.
640
-
641
- Args:
642
- config: Configuration object containing all analysis parameters
643
-
644
- Returns:
645
- DropJumpMetrics object containing analysis results
646
- """
647
- return process_dropjump_video(**config.to_kwargs())
648
-
649
-
650
614
  def process_dropjump_videos_bulk(
651
615
  configs: list[DropJumpVideoConfig],
652
616
  max_workers: int = 4,
@@ -673,8 +637,18 @@ def _process_dropjump_video_wrapper(config: DropJumpVideoConfig) -> DropJumpVide
673
637
  start_time = time.perf_counter()
674
638
 
675
639
  try:
676
- # Use convenience wrapper to avoid parameter unpacking
677
- metrics = process_dropjump_video_from_config(config)
640
+ metrics = process_dropjump_video(
641
+ video_path=config.video_path,
642
+ quality=config.quality,
643
+ output_video=config.output_video,
644
+ json_output=config.json_output,
645
+ drop_start_frame=config.drop_start_frame,
646
+ overrides=config.overrides,
647
+ detection_confidence=config.detection_confidence,
648
+ tracking_confidence=config.tracking_confidence,
649
+ verbose=False,
650
+ )
651
+
678
652
  processing_time = time.perf_counter() - start_time
679
653
 
680
654
  return DropJumpVideoResult(
@@ -5,17 +5,12 @@ import json
5
5
  import sys
6
6
  from dataclasses import dataclass
7
7
  from pathlib import Path
8
- from typing import TYPE_CHECKING
9
8
 
10
9
  import click
11
10
 
12
11
  from ..core.cli_utils import (
13
- batch_processing_options,
14
12
  collect_video_files,
15
- common_output_options,
16
13
  generate_batch_output_paths,
17
- quality_option,
18
- verbose_option,
19
14
  )
20
15
  from .api import (
21
16
  DropJumpVideoConfig,
@@ -24,9 +19,6 @@ from .api import (
24
19
  process_dropjump_videos_bulk,
25
20
  )
26
21
 
27
- if TYPE_CHECKING:
28
- from .api import AnalysisOverrides
29
-
30
22
 
31
23
  @dataclass
32
24
  class AnalysisParameters:
@@ -43,10 +35,64 @@ class AnalysisParameters:
43
35
 
44
36
  @click.command(name="dropjump-analyze")
45
37
  @click.argument("video_path", nargs=-1, type=click.Path(exists=False), required=True)
46
- @common_output_options
47
- @quality_option
48
- @verbose_option
49
- @batch_processing_options
38
+ @click.option(
39
+ "--output",
40
+ "-o",
41
+ type=click.Path(),
42
+ help="Path for debug video output (optional)",
43
+ )
44
+ @click.option(
45
+ "--json-output",
46
+ "-j",
47
+ type=click.Path(),
48
+ help="Path for JSON metrics output (default: stdout)",
49
+ )
50
+ @click.option(
51
+ "--quality",
52
+ type=click.Choice(["fast", "balanced", "accurate"], case_sensitive=False),
53
+ default="balanced",
54
+ help=(
55
+ "Analysis quality preset: "
56
+ "fast (quick, less precise), "
57
+ "balanced (default, good for most cases), "
58
+ "accurate (research-grade, slower)"
59
+ ),
60
+ show_default=True,
61
+ )
62
+ @click.option(
63
+ "--verbose",
64
+ "-v",
65
+ is_flag=True,
66
+ help="Show auto-selected parameters and analysis details",
67
+ )
68
+ # Batch processing options
69
+ @click.option(
70
+ "--batch",
71
+ is_flag=True,
72
+ help="Enable batch processing mode for multiple videos",
73
+ )
74
+ @click.option(
75
+ "--workers",
76
+ type=int,
77
+ default=4,
78
+ help="Number of parallel workers for batch processing (default: 4)",
79
+ show_default=True,
80
+ )
81
+ @click.option(
82
+ "--output-dir",
83
+ type=click.Path(),
84
+ help="Directory for debug video outputs (batch mode only)",
85
+ )
86
+ @click.option(
87
+ "--json-output-dir",
88
+ type=click.Path(),
89
+ help="Directory for JSON metrics outputs (batch mode only)",
90
+ )
91
+ @click.option(
92
+ "--csv-summary",
93
+ type=click.Path(),
94
+ help="Path for CSV summary export (batch mode only)",
95
+ )
50
96
  # Expert parameters (hidden in help, but always available for advanced users)
51
97
  @click.option(
52
98
  "--drop-start-frame",
@@ -179,34 +225,6 @@ def dropjump_analyze( # NOSONAR(S107) - Click CLI requires individual
179
225
  )
180
226
 
181
227
 
182
- def _create_overrides_if_needed(params: AnalysisParameters) -> "AnalysisOverrides | None":
183
- """Create AnalysisOverrides if any override parameters are set.
184
-
185
- Args:
186
- params: Expert parameters from CLI
187
-
188
- Returns:
189
- AnalysisOverrides if any relevant parameters are non-None, else None
190
- """
191
- from .api import AnalysisOverrides
192
-
193
- if any(
194
- [
195
- params.smoothing_window is not None,
196
- params.velocity_threshold is not None,
197
- params.min_contact_frames is not None,
198
- params.visibility_threshold is not None,
199
- ]
200
- ):
201
- return AnalysisOverrides(
202
- smoothing_window=params.smoothing_window,
203
- velocity_threshold=params.velocity_threshold,
204
- min_contact_frames=params.min_contact_frames,
205
- visibility_threshold=params.visibility_threshold,
206
- )
207
- return None
208
-
209
-
210
228
  def _process_single(
211
229
  video_path: str,
212
230
  output: str | None,
@@ -219,7 +237,24 @@ def _process_single(
219
237
  click.echo(f"Analyzing video: {video_path}", err=True)
220
238
 
221
239
  try:
222
- overrides = _create_overrides_if_needed(expert_params)
240
+ # Create AnalysisOverrides if any expert parameters are set
241
+ from .api import AnalysisOverrides
242
+
243
+ overrides = None
244
+ if any(
245
+ [
246
+ expert_params.smoothing_window is not None,
247
+ expert_params.velocity_threshold is not None,
248
+ expert_params.min_contact_frames is not None,
249
+ expert_params.visibility_threshold is not None,
250
+ ]
251
+ ):
252
+ overrides = AnalysisOverrides(
253
+ smoothing_window=expert_params.smoothing_window,
254
+ velocity_threshold=expert_params.velocity_threshold,
255
+ min_contact_frames=expert_params.min_contact_frames,
256
+ visibility_threshold=expert_params.visibility_threshold,
257
+ )
223
258
 
224
259
  # Call the API function (handles all processing logic)
225
260
  metrics = process_dropjump_video(
@@ -293,7 +328,24 @@ def _create_video_configs(
293
328
  video_file, output_dir, json_output_dir
294
329
  )
295
330
 
296
- overrides = _create_overrides_if_needed(expert_params)
331
+ # Create AnalysisOverrides if any expert parameters are set
332
+ from .api import AnalysisOverrides
333
+
334
+ overrides = None
335
+ if any(
336
+ [
337
+ expert_params.smoothing_window is not None,
338
+ expert_params.velocity_threshold is not None,
339
+ expert_params.min_contact_frames is not None,
340
+ expert_params.visibility_threshold is not None,
341
+ ]
342
+ ):
343
+ overrides = AnalysisOverrides(
344
+ smoothing_window=expert_params.smoothing_window,
345
+ velocity_threshold=expert_params.velocity_threshold,
346
+ min_contact_frames=expert_params.min_contact_frames,
347
+ visibility_threshold=expert_params.visibility_threshold,
348
+ )
297
349
 
298
350
  config = DropJumpVideoConfig(
299
351
  video_path=video_file,
@@ -328,33 +380,35 @@ def _compute_batch_statistics(results: list[DropJumpVideoResult]) -> None:
328
380
  click.echo(f"Failed: {len(failed)}", err=True)
329
381
 
330
382
  if successful:
331
- # Calculate average metrics from results with non-None values
332
- gct_values = [
333
- r.metrics.ground_contact_time * 1000
334
- for r in successful
335
- if r.metrics and r.metrics.ground_contact_time is not None
336
- ]
337
- flight_values = [
338
- r.metrics.flight_time * 1000
339
- for r in successful
340
- if r.metrics and r.metrics.flight_time is not None
341
- ]
342
- jump_values = [
343
- r.metrics.jump_height
344
- for r in successful
345
- if r.metrics and r.metrics.jump_height is not None
383
+ # Calculate average metrics
384
+ with_gct = [
385
+ r for r in successful if r.metrics and r.metrics.ground_contact_time is not None
346
386
  ]
347
-
348
- if gct_values:
349
- avg_gct = sum(gct_values) / len(gct_values)
387
+ with_flight = [r for r in successful if r.metrics and r.metrics.flight_time is not None]
388
+ with_jump = [r for r in successful if r.metrics and r.metrics.jump_height is not None]
389
+
390
+ if with_gct:
391
+ avg_gct = sum(
392
+ r.metrics.ground_contact_time * 1000
393
+ for r in with_gct
394
+ if r.metrics and r.metrics.ground_contact_time is not None
395
+ ) / len(with_gct)
350
396
  click.echo(f"\nAverage ground contact time: {avg_gct:.1f} ms", err=True)
351
397
 
352
- if flight_values:
353
- avg_flight = sum(flight_values) / len(flight_values)
398
+ if with_flight:
399
+ avg_flight = sum(
400
+ r.metrics.flight_time * 1000
401
+ for r in with_flight
402
+ if r.metrics and r.metrics.flight_time is not None
403
+ ) / len(with_flight)
354
404
  click.echo(f"Average flight time: {avg_flight:.1f} ms", err=True)
355
405
 
356
- if jump_values:
357
- avg_jump = sum(jump_values) / len(jump_values)
406
+ if with_jump:
407
+ avg_jump = sum(
408
+ r.metrics.jump_height
409
+ for r in with_jump
410
+ if r.metrics and r.metrics.jump_height is not None
411
+ ) / len(with_jump)
358
412
  click.echo(
359
413
  f"Average jump height: {avg_jump:.3f} m ({avg_jump * 100:.1f} cm)",
360
414
  err=True,
@@ -399,27 +453,38 @@ def _create_csv_row_from_result(result: DropJumpVideoResult) -> list[str]:
399
453
  processing_time = f"{result.processing_time:.2f}"
400
454
 
401
455
  if result.success and result.metrics:
402
- metrics_data = [
456
+ return [
457
+ video_name,
403
458
  _format_time_metric(result.metrics.ground_contact_time),
404
459
  _format_time_metric(result.metrics.flight_time),
405
460
  _format_distance_metric(result.metrics.jump_height),
461
+ processing_time,
462
+ "Success",
463
+ ]
464
+ else:
465
+ return [
466
+ video_name,
467
+ "N/A",
468
+ "N/A",
469
+ "N/A",
470
+ processing_time,
471
+ f"Failed: {result.error}",
406
472
  ]
407
- return [video_name, *metrics_data, processing_time, "Success"]
408
-
409
- return [video_name, "N/A", "N/A", "N/A", processing_time, f"Failed: {result.error}"]
410
473
 
411
474
 
412
475
  def _write_csv_summary(
413
476
  csv_summary: str | None,
414
477
  results: list[DropJumpVideoResult],
478
+ successful: list[DropJumpVideoResult],
415
479
  ) -> None:
416
480
  """Write CSV summary of batch processing results.
417
481
 
418
482
  Args:
419
483
  csv_summary: Path to CSV output file
420
484
  results: All processing results
485
+ successful: Successful processing results
421
486
  """
422
- if not csv_summary:
487
+ if not csv_summary or not successful:
423
488
  return
424
489
 
425
490
  click.echo(f"\nExporting CSV summary to: {csv_summary}", err=True)
@@ -493,6 +558,7 @@ def _process_batch(
493
558
  _compute_batch_statistics(results)
494
559
 
495
560
  # Export CSV summary if requested
496
- _write_csv_summary(csv_summary, results)
561
+ successful = [r for r in results if r.success]
562
+ _write_csv_summary(csv_summary, results, successful)
497
563
 
498
564
  click.echo("\nBatch processing complete!", err=True)
@@ -0,0 +1,182 @@
1
+ """Debug overlay rendering for drop jump analysis."""
2
+
3
+ import cv2
4
+ import numpy as np
5
+
6
+ from ..core.debug_overlay_utils import BaseDebugOverlayRenderer
7
+ from ..core.pose import compute_center_of_mass
8
+ from .analysis import ContactState, compute_average_foot_position
9
+ from .kinematics import DropJumpMetrics
10
+
11
+
12
+ class DebugOverlayRenderer(BaseDebugOverlayRenderer):
13
+ """Renders debug information on video frames."""
14
+
15
+ def _draw_com_visualization(
16
+ self,
17
+ frame: np.ndarray,
18
+ landmarks: dict[str, tuple[float, float, float]],
19
+ contact_state: ContactState,
20
+ ) -> None:
21
+ """Draw center of mass visualization on frame."""
22
+ com_x, com_y, _ = compute_center_of_mass(landmarks)
23
+ px = int(com_x * self.width)
24
+ py = int(com_y * self.height)
25
+
26
+ color = (0, 255, 0) if contact_state == ContactState.ON_GROUND else (0, 0, 255)
27
+ cv2.circle(frame, (px, py), 15, color, -1)
28
+ cv2.circle(frame, (px, py), 17, (255, 255, 255), 2)
29
+
30
+ # Draw hip midpoint reference
31
+ if "left_hip" in landmarks and "right_hip" in landmarks:
32
+ lh_x, lh_y, _ = landmarks["left_hip"]
33
+ rh_x, rh_y, _ = landmarks["right_hip"]
34
+ hip_x = int((lh_x + rh_x) / 2 * self.width)
35
+ hip_y = int((lh_y + rh_y) / 2 * self.height)
36
+ cv2.circle(frame, (hip_x, hip_y), 8, (255, 165, 0), -1)
37
+ cv2.line(frame, (hip_x, hip_y), (px, py), (255, 165, 0), 2)
38
+
39
+ def _draw_foot_visualization(
40
+ self,
41
+ frame: np.ndarray,
42
+ landmarks: dict[str, tuple[float, float, float]],
43
+ contact_state: ContactState,
44
+ ) -> None:
45
+ """Draw foot position visualization on frame."""
46
+ foot_x, foot_y = compute_average_foot_position(landmarks)
47
+ px = int(foot_x * self.width)
48
+ py = int(foot_y * self.height)
49
+
50
+ color = (0, 255, 0) if contact_state == ContactState.ON_GROUND else (0, 0, 255)
51
+ cv2.circle(frame, (px, py), 10, color, -1)
52
+
53
+ # Draw individual foot landmarks
54
+ foot_keys = ["left_ankle", "right_ankle", "left_heel", "right_heel"]
55
+ for key in foot_keys:
56
+ if key in landmarks:
57
+ x, y, vis = landmarks[key]
58
+ if vis > 0.5:
59
+ lx = int(x * self.width)
60
+ ly = int(y * self.height)
61
+ cv2.circle(frame, (lx, ly), 5, (255, 255, 0), -1)
62
+
63
+ def _draw_phase_labels(
64
+ self,
65
+ frame: np.ndarray,
66
+ frame_idx: int,
67
+ metrics: DropJumpMetrics,
68
+ ) -> None:
69
+ """Draw phase labels (ground contact, flight, peak) on frame."""
70
+ y_offset = 110
71
+
72
+ # Ground contact phase
73
+ if (
74
+ metrics.contact_start_frame
75
+ and metrics.contact_end_frame
76
+ and metrics.contact_start_frame <= frame_idx <= metrics.contact_end_frame
77
+ ):
78
+ cv2.putText(
79
+ frame,
80
+ "GROUND CONTACT",
81
+ (10, y_offset),
82
+ cv2.FONT_HERSHEY_SIMPLEX,
83
+ 0.7,
84
+ (0, 255, 0),
85
+ 2,
86
+ )
87
+ y_offset += 40
88
+
89
+ # Flight phase
90
+ if (
91
+ metrics.flight_start_frame
92
+ and metrics.flight_end_frame
93
+ and metrics.flight_start_frame <= frame_idx <= metrics.flight_end_frame
94
+ ):
95
+ cv2.putText(
96
+ frame,
97
+ "FLIGHT PHASE",
98
+ (10, y_offset),
99
+ cv2.FONT_HERSHEY_SIMPLEX,
100
+ 0.7,
101
+ (0, 0, 255),
102
+ 2,
103
+ )
104
+ y_offset += 40
105
+
106
+ # Peak height
107
+ if metrics.peak_height_frame == frame_idx:
108
+ cv2.putText(
109
+ frame,
110
+ "PEAK HEIGHT",
111
+ (10, y_offset),
112
+ cv2.FONT_HERSHEY_SIMPLEX,
113
+ 0.7,
114
+ (255, 0, 255),
115
+ 2,
116
+ )
117
+
118
+ def render_frame(
119
+ self,
120
+ frame: np.ndarray,
121
+ landmarks: dict[str, tuple[float, float, float]] | None,
122
+ contact_state: ContactState,
123
+ frame_idx: int,
124
+ metrics: DropJumpMetrics | None = None,
125
+ use_com: bool = False,
126
+ ) -> np.ndarray:
127
+ """
128
+ Render debug overlay on frame.
129
+
130
+ Args:
131
+ frame: Original video frame
132
+ landmarks: Pose landmarks for this frame
133
+ contact_state: Ground contact state
134
+ frame_idx: Current frame index
135
+ metrics: Drop-jump metrics (optional)
136
+ use_com: Whether to visualize CoM instead of feet (optional)
137
+
138
+ Returns:
139
+ Frame with debug overlay
140
+ """
141
+ with self.timer.measure("debug_video_copy"):
142
+ annotated = frame.copy()
143
+
144
+ def _draw_overlays() -> None:
145
+ # Draw landmarks
146
+ if landmarks:
147
+ if use_com:
148
+ self._draw_com_visualization(annotated, landmarks, contact_state)
149
+ else:
150
+ self._draw_foot_visualization(annotated, landmarks, contact_state)
151
+
152
+ # Draw contact state
153
+ state_color = (0, 255, 0) if contact_state == ContactState.ON_GROUND else (0, 0, 255)
154
+ cv2.putText(
155
+ annotated,
156
+ f"State: {contact_state.value}",
157
+ (10, 30),
158
+ cv2.FONT_HERSHEY_SIMPLEX,
159
+ 1,
160
+ state_color,
161
+ 2,
162
+ )
163
+
164
+ # Draw frame number
165
+ cv2.putText(
166
+ annotated,
167
+ f"Frame: {frame_idx}",
168
+ (10, 70),
169
+ cv2.FONT_HERSHEY_SIMPLEX,
170
+ 0.7,
171
+ (255, 255, 255),
172
+ 2,
173
+ )
174
+
175
+ # Draw phase labels
176
+ if metrics:
177
+ self._draw_phase_labels(annotated, frame_idx, metrics)
178
+
179
+ with self.timer.measure("debug_video_draw"):
180
+ _draw_overlays()
181
+
182
+ return annotated