kinemotion 0.76.2__py3-none-any.whl → 1.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 (51) 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 +293 -184
  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-0.76.2.dist-info → kinemotion-1.0.0.dist-info}/METADATA +26 -75
  33. kinemotion-1.0.0.dist-info/RECORD +49 -0
  34. kinemotion/core/overlay_constants.py +0 -61
  35. kinemotion/core/video_analysis_base.py +0 -132
  36. kinemotion/countermovement_jump/debug_overlay.py +0 -325
  37. kinemotion/drop_jump/debug_overlay.py +0 -241
  38. kinemotion/squat_jump/__init__.py +0 -5
  39. kinemotion/squat_jump/analysis.py +0 -377
  40. kinemotion/squat_jump/api.py +0 -610
  41. kinemotion/squat_jump/cli.py +0 -309
  42. kinemotion/squat_jump/debug_overlay.py +0 -163
  43. kinemotion/squat_jump/kinematics.py +0 -342
  44. kinemotion/squat_jump/metrics_validator.py +0 -438
  45. kinemotion/squat_jump/validation_bounds.py +0 -221
  46. kinemotion-0.76.2.dist-info/RECORD +0 -59
  47. /kinemotion/{countermovement_jump → cmj}/__init__.py +0 -0
  48. /kinemotion/{countermovement_jump → cmj}/kinematics.py +0 -0
  49. {kinemotion-0.76.2.dist-info → kinemotion-1.0.0.dist-info}/WHEEL +0 -0
  50. {kinemotion-0.76.2.dist-info → kinemotion-1.0.0.dist-info}/entry_points.txt +0 -0
  51. {kinemotion-0.76.2.dist-info → kinemotion-1.0.0.dist-info}/licenses/LICENSE +0 -0
@@ -1,61 +0,0 @@
1
- """Shared constants and type aliases for overlay renderers."""
2
-
3
- from typing import Any
4
-
5
- # Type aliases for overlay rendering
6
- Color = tuple[int, int, int]
7
- Landmark = tuple[float, float, float]
8
- LandmarkDict = dict[str, Landmark]
9
- CodecAttemptLog = list[dict[str, Any]]
10
-
11
- # Visibility thresholds
12
- VISIBILITY_THRESHOLD = 0.2
13
- VISIBILITY_THRESHOLD_HIGH = 0.3
14
- FOOT_VISIBILITY_THRESHOLD = 0.5
15
-
16
- # Video encoding constants
17
- MAX_VIDEO_DIMENSION = 720
18
- CODECS_TO_TRY = ["avc1", "mp4v"]
19
- FFMPEG_PRESET = "fast"
20
- FFMPEG_CRF = "23"
21
- FFMPEG_PIX_FMT = "yuv420p"
22
-
23
- # Common colors (BGR format for OpenCV)
24
- GREEN = (0, 255, 0)
25
- RED = (0, 0, 255)
26
- WHITE = (255, 255, 255)
27
- BLACK = (0, 0, 0)
28
- GRAY = (128, 128, 128)
29
- CYAN = (255, 255, 0)
30
- ORANGE = (0, 165, 255)
31
-
32
- # Joint colors for triple extension
33
- ANKLE_COLOR = (0, 255, 255) # Cyan
34
- KNEE_COLOR = (255, 100, 100) # Light blue
35
- HIP_COLOR = (100, 255, 100) # Light green
36
- TRUNK_COLOR = (100, 100, 255) # Light red
37
-
38
- # Angle thresholds
39
- FULL_EXTENSION_ANGLE = 160
40
- DEEP_FLEXION_ANGLE = 90
41
-
42
- # Circle sizes
43
- JOINT_CIRCLE_RADIUS = 6
44
- JOINT_OUTLINE_RADIUS = 8
45
- COM_CIRCLE_RADIUS = 15
46
- COM_OUTLINE_RADIUS = 17
47
- HIP_MARKER_RADIUS = 8
48
- FOOT_CIRCLE_RADIUS = 10
49
- FOOT_LANDMARK_RADIUS = 5
50
- ANGLE_ARC_RADIUS = 25
51
- NOSE_CIRCLE_RADIUS = 8
52
- NOSE_OUTLINE_RADIUS = 10
53
-
54
- # Box positioning
55
- JOINT_ANGLES_BOX_X_OFFSET = 180
56
- JOINT_ANGLES_BOX_HEIGHT = 150
57
- METRICS_BOX_WIDTH = 320
58
-
59
- # Phase label positioning
60
- PHASE_LABEL_START_Y = 110
61
- PHASE_LABEL_LINE_HEIGHT = 40
@@ -1,132 +0,0 @@
1
- """Base types and patterns for video analysis APIs.
2
-
3
- This module defines shared infrastructure for jump-type-specific analysis modules.
4
- Each jump type (CMJ, Drop Jump, etc.) has its own analysis algorithms, but they
5
- share common patterns for:
6
-
7
- 1. Configuration (VideoConfig dataclass)
8
- 2. Results (VideoResult dataclass)
9
- 3. Parameter overrides (AnalysisOverrides dataclass)
10
- 4. Bulk processing utilities
11
-
12
- To add a new jump type:
13
- 1. Create a new module: src/kinemotion/{jump_type}/
14
- 2. Implement analysis algorithms in {jump_type}/analysis.py
15
- 3. Use the patterns in this module for API structure
16
- 4. Import process_videos_bulk_generic from pipeline_utils for bulk processing
17
- """
18
-
19
- from abc import ABC, abstractmethod
20
- from dataclasses import dataclass
21
- from pathlib import Path
22
- from typing import TYPE_CHECKING
23
-
24
- if TYPE_CHECKING:
25
- from ..auto_tuning import QualityPreset
26
- from ..timing import Timer
27
-
28
- __all__ = [
29
- "AnalysisOverrides",
30
- "VideoAnalysisConfig",
31
- "VideoAnalysisResult",
32
- "JumpAnalysisPipeline",
33
- ]
34
-
35
-
36
- @dataclass
37
- class AnalysisOverrides:
38
- """Optional overrides for analysis parameters.
39
-
40
- Allows fine-tuning of specific analysis parameters beyond quality presets.
41
- If None, values will be determined by the quality preset.
42
-
43
- Common overrides across all jump types:
44
- - smoothing_window: Number of frames for Savitzky-Golay smoothing
45
- - velocity_threshold: Threshold for phase detection
46
- - min_contact_frames: Minimum frames for ground contact
47
- - visibility_threshold: Minimum landmark visibility (0-1)
48
- """
49
-
50
- smoothing_window: int | None = None
51
- velocity_threshold: float | None = None
52
- min_contact_frames: int | None = None
53
- visibility_threshold: float | None = None
54
-
55
-
56
- @dataclass
57
- class VideoAnalysisConfig:
58
- """Base configuration for video analysis.
59
-
60
- Subclasses should add jump-type-specific fields (e.g., drop_start_frame
61
- for Drop Jump, or additional overrides for CMJ).
62
- """
63
-
64
- video_path: str
65
- quality: str = "balanced"
66
- output_video: str | None = None
67
- json_output: str | None = None
68
- overrides: AnalysisOverrides | None = None
69
- detection_confidence: float | None = None
70
- tracking_confidence: float | None = None
71
- verbose: bool = False
72
- timer: "Timer | None" = None
73
-
74
-
75
- @dataclass
76
- class VideoAnalysisResult:
77
- """Base result for video analysis.
78
-
79
- Subclasses should add jump-type-specific fields.
80
- """
81
-
82
- video_path: str
83
- success: bool
84
- metrics: object | None = None # Will be CMJMetrics, DropJumpMetrics, etc.
85
- error: str | None = None
86
- processing_time: float = 0.0
87
-
88
-
89
- class JumpAnalysisPipeline(ABC):
90
- """Abstract base class for jump analysis pipelines.
91
-
92
- Defines the common structure for processing jump videos. Each jump type
93
- implements the specific analysis logic while following this pattern.
94
-
95
- Example:
96
- class CMJPipeline(JumpAnalysisPipeline):
97
- def analyze(self) -> CMJMetrics:
98
- # CMJ-specific analysis (backward search algorithm)
99
- ...
100
-
101
- class DropJumpPipeline(JumpAnalysisPipeline):
102
- def analyze(self) -> DropJumpMetrics:
103
- # Drop jump-specific analysis (forward search algorithm)
104
- ...
105
- """
106
-
107
- def __init__(
108
- self,
109
- video_path: str,
110
- quality_preset: "QualityPreset",
111
- overrides: AnalysisOverrides | None,
112
- timer: "Timer",
113
- ) -> None:
114
- """Initialize the analysis pipeline."""
115
- self.video_path = video_path
116
- self.quality_preset = quality_preset
117
- self.overrides = overrides
118
- self.timer = timer
119
-
120
- @abstractmethod
121
- def analyze(self) -> object:
122
- """Run the jump-specific analysis algorithm.
123
-
124
- Returns:
125
- Metrics object with jump-type-specific results.
126
- """
127
- ...
128
-
129
- def validate_video_exists(self) -> None:
130
- """Validate that the input video file exists."""
131
- if not Path(self.video_path).exists():
132
- raise FileNotFoundError(f"Video file not found: {self.video_path}")
@@ -1,325 +0,0 @@
1
- """Debug overlay rendering for CMJ analysis."""
2
-
3
- import cv2
4
- import numpy as np
5
-
6
- from ..core.debug_overlay_utils import BaseDebugOverlayRenderer
7
- from ..core.overlay_constants import (
8
- ANGLE_ARC_RADIUS,
9
- ANKLE_COLOR,
10
- BLACK,
11
- CYAN,
12
- DEEP_FLEXION_ANGLE,
13
- FOOT_LANDMARK_RADIUS,
14
- FOOT_VISIBILITY_THRESHOLD,
15
- FULL_EXTENSION_ANGLE,
16
- GRAY,
17
- GREEN,
18
- HIP_COLOR,
19
- JOINT_ANGLES_BOX_HEIGHT,
20
- JOINT_ANGLES_BOX_X_OFFSET,
21
- KNEE_COLOR,
22
- METRICS_BOX_WIDTH,
23
- ORANGE,
24
- RED,
25
- TRUNK_COLOR,
26
- VISIBILITY_THRESHOLD_HIGH,
27
- WHITE,
28
- Color,
29
- LandmarkDict,
30
- )
31
- from .analysis import CMJPhase
32
- from .joint_angles import calculate_triple_extension
33
- from .kinematics import CMJMetrics
34
-
35
-
36
- class CMJDebugOverlayRenderer(BaseDebugOverlayRenderer):
37
- """Renders debug information on CMJ video frames."""
38
-
39
- # Phase colors (BGR format)
40
- PHASE_COLORS: dict[CMJPhase, Color] = {
41
- CMJPhase.STANDING: (255, 200, 100), # Light blue
42
- CMJPhase.ECCENTRIC: (0, 165, 255), # Orange
43
- CMJPhase.TRANSITION: (255, 0, 255), # Magenta/Purple
44
- CMJPhase.CONCENTRIC: (0, 255, 0), # Green
45
- CMJPhase.FLIGHT: (0, 0, 255), # Red
46
- CMJPhase.LANDING: (255, 255, 255), # White
47
- }
48
- DEFAULT_PHASE_COLOR: Color = GRAY
49
-
50
- def _determine_phase(self, frame_idx: int, metrics: CMJMetrics) -> CMJPhase:
51
- """Determine which phase the current frame is in."""
52
- if metrics.standing_start_frame and frame_idx < metrics.standing_start_frame:
53
- return CMJPhase.STANDING
54
-
55
- if frame_idx < metrics.lowest_point_frame:
56
- return CMJPhase.ECCENTRIC
57
-
58
- # Brief transition at lowest point (within 2 frames)
59
- if abs(frame_idx - metrics.lowest_point_frame) < 2:
60
- return CMJPhase.TRANSITION
61
-
62
- if frame_idx < metrics.takeoff_frame:
63
- return CMJPhase.CONCENTRIC
64
-
65
- if frame_idx < metrics.landing_frame:
66
- return CMJPhase.FLIGHT
67
-
68
- return CMJPhase.LANDING
69
-
70
- def _get_phase_color(self, phase: CMJPhase) -> Color:
71
- """Get color for each phase."""
72
- return self.PHASE_COLORS.get(phase, self.DEFAULT_PHASE_COLOR)
73
-
74
- def _get_triple_extension_angles(
75
- self, landmarks: LandmarkDict
76
- ) -> tuple[dict[str, float | None], str] | None:
77
- """Get triple extension angles, trying right side first then left.
78
-
79
- Returns tuple of (angles_dict, side_used) or None if unavailable.
80
- """
81
- for side in ["right", "left"]:
82
- angles = calculate_triple_extension(landmarks, side=side)
83
- if angles is not None:
84
- return angles, side
85
- return None
86
-
87
- def _draw_info_box(
88
- self,
89
- frame: np.ndarray,
90
- top_left: tuple[int, int],
91
- bottom_right: tuple[int, int],
92
- border_color: Color,
93
- ) -> None:
94
- """Draw a filled box with border for displaying information."""
95
- cv2.rectangle(frame, top_left, bottom_right, BLACK, -1)
96
- cv2.rectangle(frame, top_left, bottom_right, border_color, 2)
97
-
98
- def _draw_joint_angles(
99
- self, frame: np.ndarray, landmarks: LandmarkDict, phase_color: Color
100
- ) -> None:
101
- """Draw joint angles for triple extension analysis.
102
-
103
- Args:
104
- frame: Frame to draw on (modified in place)
105
- landmarks: Pose landmarks
106
- phase_color: Current phase color
107
- """
108
- result = self._get_triple_extension_angles(landmarks)
109
- if result is None:
110
- return
111
-
112
- angles, side_used = result
113
-
114
- # Position for angle text display (right side of frame)
115
- text_x = self.width - JOINT_ANGLES_BOX_X_OFFSET
116
- text_y = 100
117
- box_height = JOINT_ANGLES_BOX_HEIGHT
118
-
119
- # Draw background box
120
- self._draw_info_box(
121
- frame,
122
- (text_x - 10, text_y - 30),
123
- (self.width - 10, text_y + box_height),
124
- phase_color,
125
- )
126
-
127
- # Title
128
- cv2.putText(
129
- frame,
130
- "TRIPLE EXTENSION",
131
- (text_x, text_y - 5),
132
- cv2.FONT_HERSHEY_SIMPLEX,
133
- 0.5,
134
- WHITE,
135
- 1,
136
- )
137
-
138
- # Angle display configuration: (label, angle_key, color, joint_suffix)
139
- angle_config = [
140
- ("Ankle", "ankle_angle", ANKLE_COLOR, "ankle"),
141
- ("Knee", "knee_angle", KNEE_COLOR, "knee"),
142
- ("Hip", "hip_angle", HIP_COLOR, "hip"),
143
- ("Trunk", "trunk_tilt", TRUNK_COLOR, None),
144
- ]
145
-
146
- y_offset = text_y + 25
147
- for label, angle_key, color, joint_suffix in angle_config:
148
- angle = angles.get(angle_key)
149
-
150
- # Draw text
151
- if angle is not None:
152
- text = f"{label}: {angle:.0f}"
153
- text_color = color
154
- else:
155
- text = f"{label}: N/A"
156
- text_color = GRAY
157
-
158
- cv2.putText(
159
- frame, text, (text_x, y_offset), cv2.FONT_HERSHEY_SIMPLEX, 0.5, text_color, 2
160
- )
161
- y_offset += 30
162
-
163
- # Draw arc at joint if angle available and has associated joint
164
- if angle is not None and joint_suffix is not None:
165
- self._draw_angle_arc(frame, landmarks, f"{side_used}_{joint_suffix}", angle)
166
-
167
- def _get_extension_color(self, angle: float) -> Color:
168
- """Get color based on joint extension angle.
169
-
170
- Green for extended (>160 deg), red for flexed (<90 deg), orange for moderate.
171
- """
172
- if angle > FULL_EXTENSION_ANGLE:
173
- return GREEN
174
- if angle < DEEP_FLEXION_ANGLE:
175
- return RED
176
- return ORANGE
177
-
178
- def _draw_angle_arc(
179
- self, frame: np.ndarray, landmarks: LandmarkDict, joint_key: str, angle: float
180
- ) -> None:
181
- """Draw a circle at a joint to visualize the angle.
182
-
183
- Args:
184
- frame: Frame to draw on (modified in place)
185
- landmarks: Pose landmarks
186
- joint_key: Key of the joint landmark
187
- angle: Angle value in degrees
188
- """
189
- if joint_key not in landmarks:
190
- return
191
- landmark = landmarks[joint_key]
192
- if not self._is_visible(landmark, VISIBILITY_THRESHOLD_HIGH):
193
- return
194
-
195
- point = self._landmark_to_pixel(landmark)
196
- arc_color = self._get_extension_color(angle)
197
- cv2.circle(frame, point, ANGLE_ARC_RADIUS, arc_color, 2)
198
-
199
- def _draw_foot_landmarks(
200
- self, frame: np.ndarray, landmarks: LandmarkDict, phase_color: Color
201
- ) -> None:
202
- """Draw foot landmarks and average position."""
203
- foot_keys = ["left_ankle", "right_ankle", "left_heel", "right_heel"]
204
- foot_positions: list[tuple[int, int]] = []
205
-
206
- for key in foot_keys:
207
- if key not in landmarks:
208
- continue
209
- landmark = landmarks[key]
210
- if landmark[2] > FOOT_VISIBILITY_THRESHOLD:
211
- point = self._landmark_to_pixel(landmark)
212
- foot_positions.append(point)
213
- cv2.circle(frame, point, FOOT_LANDMARK_RADIUS, CYAN, -1)
214
-
215
- # Draw average foot position with phase color
216
- if foot_positions:
217
- avg_x = int(np.mean([p[0] for p in foot_positions]))
218
- avg_y = int(np.mean([p[1] for p in foot_positions]))
219
- cv2.circle(frame, (avg_x, avg_y), 12, phase_color, -1)
220
- cv2.circle(frame, (avg_x, avg_y), 14, WHITE, 2)
221
-
222
- def _draw_phase_banner(
223
- self, frame: np.ndarray, phase: CMJPhase | None, phase_color: Color
224
- ) -> None:
225
- """Draw phase indicator banner."""
226
- if phase is None:
227
- return
228
-
229
- phase_text = f"Phase: {phase.value.upper()}"
230
- text_size = cv2.getTextSize(phase_text, cv2.FONT_HERSHEY_SIMPLEX, 1, 2)[0]
231
- cv2.rectangle(frame, (5, 5), (text_size[0] + 15, 45), phase_color, -1)
232
- cv2.putText(frame, phase_text, (10, 35), cv2.FONT_HERSHEY_SIMPLEX, 1, BLACK, 2)
233
-
234
- def _draw_key_frame_markers(
235
- self, frame: np.ndarray, frame_idx: int, metrics: CMJMetrics
236
- ) -> None:
237
- """Draw markers for key frames (standing start, lowest, takeoff, landing)."""
238
- # Key frame definitions: (frame_value, label)
239
- key_frames: list[tuple[float | None, str]] = [
240
- (metrics.standing_start_frame, "COUNTERMOVEMENT START"),
241
- (metrics.lowest_point_frame, "LOWEST POINT"),
242
- (metrics.takeoff_frame, "TAKEOFF"),
243
- (metrics.landing_frame, "LANDING"),
244
- ]
245
-
246
- y_offset = 120
247
- for key_frame, label in key_frames:
248
- if key_frame is not None and frame_idx == int(key_frame):
249
- cv2.putText(frame, label, (10, y_offset), cv2.FONT_HERSHEY_SIMPLEX, 0.7, CYAN, 2)
250
- y_offset += 35
251
-
252
- def _draw_metrics_summary(
253
- self, frame: np.ndarray, frame_idx: int, metrics: CMJMetrics
254
- ) -> None:
255
- """Draw metrics summary in bottom right (last 30 frames after landing)."""
256
- if frame_idx < int(metrics.landing_frame):
257
- return
258
-
259
- metrics_text = [
260
- f"Jump Height: {metrics.jump_height:.3f}m",
261
- f"Flight Time: {metrics.flight_time * 1000:.0f}ms",
262
- f"CM Depth: {metrics.countermovement_depth:.3f}m",
263
- f"Ecc Duration: {metrics.eccentric_duration * 1000:.0f}ms",
264
- f"Con Duration: {metrics.concentric_duration * 1000:.0f}ms",
265
- ]
266
-
267
- # Calculate box dimensions
268
- box_height = len(metrics_text) * 30 + 20
269
- top_left = (self.width - METRICS_BOX_WIDTH, self.height - box_height - 10)
270
- bottom_right = (self.width - 10, self.height - 10)
271
-
272
- self._draw_info_box(frame, top_left, bottom_right, GREEN)
273
-
274
- # Draw metrics text
275
- text_x = self.width - METRICS_BOX_WIDTH + 10
276
- text_y = self.height - box_height + 10
277
- for text in metrics_text:
278
- cv2.putText(frame, text, (text_x, text_y), cv2.FONT_HERSHEY_SIMPLEX, 0.6, WHITE, 1)
279
- text_y += 30
280
-
281
- def render_frame(
282
- self,
283
- frame: np.ndarray,
284
- landmarks: LandmarkDict | None,
285
- frame_idx: int,
286
- metrics: CMJMetrics | None = None,
287
- ) -> np.ndarray:
288
- """Render debug overlay on frame.
289
-
290
- Args:
291
- frame: Original video frame
292
- landmarks: Pose landmarks for this frame
293
- frame_idx: Current frame index
294
- metrics: CMJ metrics (optional)
295
-
296
- Returns:
297
- Frame with debug overlay
298
- """
299
- annotated = frame.copy()
300
-
301
- # Determine current phase and color
302
- phase: CMJPhase | None = None
303
- phase_color: Color = WHITE
304
- if metrics:
305
- phase = self._determine_phase(frame_idx, metrics)
306
- phase_color = self._get_phase_color(phase)
307
-
308
- # Draw skeleton and joint visualization if landmarks available
309
- if landmarks:
310
- self._draw_skeleton(annotated, landmarks)
311
- self._draw_joint_angles(annotated, landmarks, phase_color)
312
- self._draw_foot_landmarks(annotated, landmarks, phase_color)
313
-
314
- # Draw phase indicator and frame number
315
- self._draw_phase_banner(annotated, phase, phase_color)
316
- cv2.putText(
317
- annotated, f"Frame: {frame_idx}", (10, 80), cv2.FONT_HERSHEY_SIMPLEX, 0.7, WHITE, 2
318
- )
319
-
320
- # Draw key frame markers and metrics summary
321
- if metrics:
322
- self._draw_key_frame_markers(annotated, frame_idx, metrics)
323
- self._draw_metrics_summary(annotated, frame_idx, metrics)
324
-
325
- return annotated