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
@@ -5,28 +5,6 @@ from enum import Enum
5
5
 
6
6
  import numpy as np
7
7
 
8
- from .types import FOOT_KEYS
9
-
10
-
11
- @dataclass
12
- class _PresetConfig:
13
- """Configuration modifiers for quality presets."""
14
-
15
- velocity_multiplier: float # Multiplier for velocity threshold
16
- contact_frames_multiplier: float # Multiplier for min contact frames
17
- smoothing_offset: int # Offset to smoothing window (added to base)
18
- force_bilateral: bool | None # None means use quality-based, True=force on, False=force off
19
- detection_confidence: float
20
- tracking_confidence: float
21
-
22
-
23
- @dataclass
24
- class _QualityAdjustment:
25
- """Smoothing adjustments based on tracking quality."""
26
-
27
- smoothing_add: int # Frames to add to smoothing window
28
- enable_bilateral: bool # Whether to enable bilateral filtering
29
-
30
8
 
31
9
  class QualityPreset(str, Enum):
32
10
  """Quality presets for analysis."""
@@ -36,46 +14,6 @@ class QualityPreset(str, Enum):
36
14
  ACCURATE = "accurate" # Research-grade analysis, slower
37
15
 
38
16
 
39
- # Quality preset configurations
40
- # FAST: Speed over accuracy
41
- # BALANCED: Default (uses quality-based settings)
42
- # ACCURATE: Maximum accuracy
43
- _PRESET_CONFIGS: dict[QualityPreset, _PresetConfig] = {
44
- QualityPreset.FAST: _PresetConfig(
45
- velocity_multiplier=1.5,
46
- contact_frames_multiplier=0.67,
47
- smoothing_offset=-2,
48
- force_bilateral=False,
49
- detection_confidence=0.3,
50
- tracking_confidence=0.3,
51
- ),
52
- QualityPreset.BALANCED: _PresetConfig(
53
- velocity_multiplier=1.0,
54
- contact_frames_multiplier=1.0,
55
- smoothing_offset=0,
56
- force_bilateral=None,
57
- detection_confidence=0.5,
58
- tracking_confidence=0.5,
59
- ),
60
- QualityPreset.ACCURATE: _PresetConfig(
61
- velocity_multiplier=0.5,
62
- contact_frames_multiplier=1.0,
63
- smoothing_offset=2,
64
- force_bilateral=True,
65
- detection_confidence=0.6,
66
- tracking_confidence=0.6,
67
- ),
68
- }
69
-
70
-
71
- # Quality-based adjustments
72
- _QUALITY_ADJUSTMENTS: dict[str, _QualityAdjustment] = {
73
- "low": _QualityAdjustment(smoothing_add=2, enable_bilateral=True),
74
- "medium": _QualityAdjustment(smoothing_add=1, enable_bilateral=True),
75
- "high": _QualityAdjustment(smoothing_add=0, enable_bilateral=False),
76
- }
77
-
78
-
79
17
  @dataclass
80
18
  class VideoCharacteristics:
81
19
  """Characteristics extracted from video analysis."""
@@ -137,73 +75,6 @@ def analyze_tracking_quality(avg_visibility: float) -> str:
137
75
  return "high"
138
76
 
139
77
 
140
- def _compute_fps_baseline_parameters(fps: float) -> tuple[float, int, int]:
141
- """Compute FPS-based baseline parameters.
142
-
143
- Args:
144
- fps: Video frame rate
145
-
146
- Returns:
147
- Tuple of (base_velocity_threshold, base_min_contact_frames, base_smoothing_window)
148
- """
149
- # Base velocity threshold: 0.012 at 30fps, scaled inversely by fps
150
- # Must exceed typical MediaPipe landmark jitter (0.5-2% per frame)
151
- # Previous value of 0.004 was below noise floor, causing false IN_AIR detections
152
- base_velocity_threshold = 0.012 * (30.0 / fps)
153
- base_min_contact_frames = max(2, round(3.0 * (fps / 30.0)))
154
-
155
- # Smoothing window: Decrease with higher fps for better temporal resolution
156
- base_smoothing_window = 3 if fps > 30 else 5
157
-
158
- return base_velocity_threshold, base_min_contact_frames, base_smoothing_window
159
-
160
-
161
- def _compute_smoothing_window(
162
- fps: float,
163
- preset: _PresetConfig,
164
- quality_adj: _QualityAdjustment,
165
- ) -> int:
166
- """Compute smoothing window from FPS, preset, and quality adjustments.
167
-
168
- Args:
169
- fps: Video frame rate
170
- preset: Quality preset configuration
171
- quality_adj: Quality-based adjustments
172
-
173
- Returns:
174
- Odd smoothing window size (required for Savitzky-Golay filter)
175
- """
176
- _, _, base_smoothing_window = _compute_fps_baseline_parameters(fps)
177
-
178
- # Smoothing window = base + preset offset + quality adjustment
179
- smoothing_window = base_smoothing_window + preset.smoothing_offset + quality_adj.smoothing_add
180
- smoothing_window = max(3, min(11, smoothing_window))
181
-
182
- # Ensure smoothing window is odd (required for Savitzky-Golay)
183
- if smoothing_window % 2 == 0:
184
- smoothing_window += 1
185
-
186
- return smoothing_window
187
-
188
-
189
- def _resolve_bilateral_filter(
190
- preset: _PresetConfig,
191
- quality_adj: _QualityAdjustment,
192
- ) -> bool:
193
- """Resolve whether to enable bilateral filtering.
194
-
195
- Args:
196
- preset: Quality preset configuration
197
- quality_adj: Quality-based adjustments
198
-
199
- Returns:
200
- True if bilateral filtering should be enabled
201
- """
202
- if preset.force_bilateral is not None:
203
- return preset.force_bilateral
204
- return quality_adj.enable_bilateral
205
-
206
-
207
78
  def auto_tune_parameters(
208
79
  characteristics: VideoCharacteristics,
209
80
  quality_preset: QualityPreset = QualityPreset.BALANCED,
@@ -230,28 +101,106 @@ def auto_tune_parameters(
230
101
  fps = characteristics.fps
231
102
  quality = characteristics.tracking_quality
232
103
 
233
- # Get preset configuration and quality-based adjustments
234
- preset = _PRESET_CONFIGS[quality_preset]
235
- quality_adj = _QUALITY_ADJUSTMENTS[quality]
104
+ # =================================================================
105
+ # STEP 1: FPS-based baseline parameters
106
+ # These scale automatically with frame rate to maintain consistent
107
+ # temporal resolution and sensitivity
108
+ # =================================================================
109
+
110
+ # Velocity threshold: Scale inversely with fps
111
+ # Empirically validated with 45° oblique videos at 60fps:
112
+ # - Standing (stationary): ~0.001 mean, 0.0011 max
113
+ # - Flight/drop (moving): ~0.005-0.009
114
+ # Target threshold: 0.002 at 60fps for clear separation
115
+ # Formula: threshold = 0.004 * (30 / fps)
116
+ base_velocity_threshold = 0.004 * (30.0 / fps)
117
+
118
+ # Min contact frames: Scale with fps to maintain same time duration
119
+ # Goal: ~100ms minimum contact (3 frames @ 30fps, 6 frames @ 60fps)
120
+ # Formula: frames = round(3 * (fps / 30))
121
+ base_min_contact_frames = max(2, round(3.0 * (fps / 30.0)))
236
122
 
237
- # Compute FPS-based baseline parameters
238
- base_velocity_threshold, base_min_contact_frames, _ = _compute_fps_baseline_parameters(fps)
123
+ # Smoothing window: Decrease with higher fps for better temporal resolution
124
+ # Lower fps (30fps): 5-frame window = 167ms
125
+ # Higher fps (60fps): 3-frame window = 50ms (same temporal resolution)
126
+ if fps <= 30:
127
+ base_smoothing_window = 5
128
+ elif fps <= 60:
129
+ base_smoothing_window = 3
130
+ else:
131
+ base_smoothing_window = 3 # Even at 120fps, 3 is minimum for Savitzky-Golay
132
+
133
+ # =================================================================
134
+ # STEP 2: Quality-based adjustments
135
+ # Adapt smoothing and filtering based on tracking quality
136
+ # =================================================================
137
+
138
+ smoothing_adjustment = 0
139
+ enable_bilateral = False
140
+
141
+ if quality == "low":
142
+ # Poor tracking quality: aggressive smoothing and filtering
143
+ smoothing_adjustment = +2
144
+ enable_bilateral = True
145
+ elif quality == "medium":
146
+ # Moderate quality: slight smoothing increase
147
+ smoothing_adjustment = +1
148
+ enable_bilateral = True
149
+ else: # high quality
150
+ # Good tracking: preserve detail, minimal smoothing
151
+ smoothing_adjustment = 0
152
+ enable_bilateral = False
153
+
154
+ # =================================================================
155
+ # STEP 3: Apply quality preset modifiers
156
+ # User can choose speed vs accuracy tradeoff
157
+ # =================================================================
158
+
159
+ if quality_preset == QualityPreset.FAST:
160
+ # Fast: Trade accuracy for speed
161
+ velocity_threshold = base_velocity_threshold * 1.5 # Less sensitive
162
+ min_contact_frames = max(2, int(base_min_contact_frames * 0.67))
163
+ smoothing_window = max(3, base_smoothing_window - 2 + smoothing_adjustment)
164
+ bilateral_filter = False # Skip expensive filtering
165
+ detection_confidence = 0.3
166
+ tracking_confidence = 0.3
167
+
168
+ elif quality_preset == QualityPreset.ACCURATE:
169
+ # Accurate: Maximize accuracy, accept slower processing
170
+ velocity_threshold = base_velocity_threshold * 0.5 # More sensitive
171
+ min_contact_frames = base_min_contact_frames # Don't increase (would miss brief)
172
+ smoothing_window = min(11, base_smoothing_window + 2 + smoothing_adjustment)
173
+ bilateral_filter = True # Always use for best accuracy
174
+ detection_confidence = 0.6
175
+ tracking_confidence = 0.6
176
+
177
+ else: # QualityPreset.BALANCED (default)
178
+ # Balanced: Good accuracy, reasonable speed
179
+ velocity_threshold = base_velocity_threshold
180
+ min_contact_frames = base_min_contact_frames
181
+ smoothing_window = max(3, base_smoothing_window + smoothing_adjustment)
182
+ bilateral_filter = enable_bilateral
183
+ detection_confidence = 0.5
184
+ tracking_confidence = 0.5
185
+
186
+ # Ensure smoothing window is odd (required for Savitzky-Golay)
187
+ if smoothing_window % 2 == 0:
188
+ smoothing_window += 1
239
189
 
240
- # Apply preset modifiers
241
- velocity_threshold = base_velocity_threshold * preset.velocity_multiplier
242
- min_contact_frames = max(2, int(base_min_contact_frames * preset.contact_frames_multiplier))
190
+ # =================================================================
191
+ # STEP 4: Set fixed optimal values
192
+ # These are always the same regardless of video characteristics
193
+ # =================================================================
243
194
 
244
- # Compute smoothing window with preset and quality adjustments
245
- smoothing_window = _compute_smoothing_window(fps, preset, quality_adj)
195
+ # Polyorder: Always 2 (quadratic) - optimal for jump physics (parabolic motion)
196
+ polyorder = 2
246
197
 
247
- # Resolve bilateral filtering setting
248
- bilateral_filter = _resolve_bilateral_filter(preset, quality_adj)
198
+ # Visibility threshold: Standard MediaPipe threshold
199
+ visibility_threshold = 0.5
249
200
 
250
- # Fixed optimal values
251
- polyorder = 2 # Quadratic - optimal for parabolic motion
252
- visibility_threshold = 0.5 # Standard MediaPipe threshold
253
- outlier_rejection = True # Removes tracking glitches
254
- use_curvature = True # Trajectory curvature analysis
201
+ # Always enable proven accuracy features
202
+ outlier_rejection = True # Removes tracking glitches (minimal cost)
203
+ use_curvature = True # Trajectory curvature analysis (minimal cost)
255
204
 
256
205
  return AnalysisParameters(
257
206
  smoothing_window=smoothing_window,
@@ -259,8 +208,8 @@ def auto_tune_parameters(
259
208
  velocity_threshold=velocity_threshold,
260
209
  min_contact_frames=min_contact_frames,
261
210
  visibility_threshold=visibility_threshold,
262
- detection_confidence=preset.detection_confidence,
263
- tracking_confidence=preset.tracking_confidence,
211
+ detection_confidence=detection_confidence,
212
+ tracking_confidence=tracking_confidence,
264
213
  outlier_rejection=outlier_rejection,
265
214
  bilateral_filter=bilateral_filter,
266
215
  use_curvature=use_curvature,
@@ -279,10 +228,19 @@ def _collect_foot_visibility_and_positions(
279
228
  Returns:
280
229
  Tuple of (visibility_scores, y_positions)
281
230
  """
231
+ foot_keys = [
232
+ "left_ankle",
233
+ "right_ankle",
234
+ "left_heel",
235
+ "right_heel",
236
+ "left_foot_index",
237
+ "right_foot_index",
238
+ ]
239
+
282
240
  frame_vis = []
283
241
  frame_y_positions = []
284
242
 
285
- for key in FOOT_KEYS:
243
+ for key in foot_keys:
286
244
  if key in frame_landmarks:
287
245
  _, y, vis = frame_landmarks[key] # x not needed for analysis
288
246
  frame_vis.append(vis)
@@ -24,80 +24,6 @@ def common_output_options(func: Callable) -> Callable: # type: ignore[type-arg]
24
24
  return func
25
25
 
26
26
 
27
- def quality_option(func: Callable) -> Callable: # type: ignore[type-arg]
28
- """Add quality preset option to CLI command."""
29
- return click.option(
30
- "--quality",
31
- type=click.Choice(["fast", "balanced", "accurate"], case_sensitive=False),
32
- default="balanced",
33
- help=(
34
- "Analysis quality preset: "
35
- "fast (quick, less precise), "
36
- "balanced (default, good for most cases), "
37
- "accurate (research-grade, slower)"
38
- ),
39
- show_default=True,
40
- )(func)
41
-
42
-
43
- def verbose_option(func: Callable) -> Callable: # type: ignore[type-arg]
44
- """Add verbose flag to CLI command."""
45
- return click.option(
46
- "--verbose",
47
- "-v",
48
- is_flag=True,
49
- help="Show auto-selected parameters and analysis details",
50
- )(func)
51
-
52
-
53
- def batch_processing_options(func: Callable) -> Callable: # type: ignore[type-arg]
54
- """Add batch processing options to CLI command."""
55
- func = click.option(
56
- "--batch",
57
- is_flag=True,
58
- help="Enable batch processing mode for multiple videos",
59
- )(func)
60
- func = click.option(
61
- "--workers",
62
- type=int,
63
- default=4,
64
- help="Number of parallel workers for batch processing (default: 4)",
65
- show_default=True,
66
- )(func)
67
- func = click.option(
68
- "--output-dir",
69
- type=click.Path(),
70
- help="Directory for debug video outputs (batch mode only)",
71
- )(func)
72
- func = click.option(
73
- "--json-output-dir",
74
- type=click.Path(),
75
- help="Directory for JSON metrics outputs (batch mode only)",
76
- )(func)
77
- func = click.option(
78
- "--csv-summary",
79
- type=click.Path(),
80
- help="Path for CSV summary export (batch mode only)",
81
- )(func)
82
- return func
83
-
84
-
85
- def common_analysis_options(func: Callable) -> Callable: # type: ignore[type-arg]
86
- """Add all common analysis options (output, quality, verbose, batch).
87
-
88
- Combines:
89
- - common_output_options (--output, --json-output)
90
- - quality_option (--quality)
91
- - verbose_option (--verbose)
92
- - batch_processing_options (--batch, --workers, --output-dir, etc.)
93
- """
94
- func = common_output_options(func)
95
- func = quality_option(func)
96
- func = verbose_option(func)
97
- func = batch_processing_options(func)
98
- return func
99
-
100
-
101
27
  def collect_video_files(video_path: tuple[str, ...]) -> list[str]:
102
28
  """Expand glob patterns and collect all video files."""
103
29
  video_files: list[str] = []
@@ -6,34 +6,12 @@ import shutil
6
6
  import subprocess
7
7
  import time
8
8
  from pathlib import Path
9
+ from typing import Any
9
10
 
10
11
  import cv2
11
12
  import numpy as np
12
13
  from typing_extensions import Self
13
14
 
14
- from .overlay_constants import (
15
- ANKLE_COLOR,
16
- BLACK,
17
- CODECS_TO_TRY,
18
- CYAN,
19
- FFMPEG_CRF,
20
- FFMPEG_PIX_FMT,
21
- FFMPEG_PRESET,
22
- HIP_COLOR,
23
- JOINT_CIRCLE_RADIUS,
24
- JOINT_OUTLINE_RADIUS,
25
- KNEE_COLOR,
26
- MAX_VIDEO_DIMENSION,
27
- NOSE_CIRCLE_RADIUS,
28
- NOSE_OUTLINE_RADIUS,
29
- TRUNK_COLOR,
30
- VISIBILITY_THRESHOLD,
31
- WHITE,
32
- CodecAttemptLog,
33
- Color,
34
- Landmark,
35
- LandmarkDict,
36
- )
37
15
  from .timing import NULL_TIMER, Timer
38
16
 
39
17
  # Setup logging with structlog support for backend, fallback to standard logging for CLI
@@ -93,10 +71,12 @@ def create_video_writer(
93
71
  # Try browser-compatible codecs first
94
72
  # avc1: H.264 (Most compatible, including iOS)
95
73
  # mp4v: MPEG-4 (Poor browser support, will trigger ffmpeg re-encoding for H.264)
96
- # See overlay_constants.py for codec list and critical VP9 exclusion notes
97
- codec_attempt_log: CodecAttemptLog = []
74
+ # ⚠️ CRITICAL: VP9 (vp09) is EXCLUDED - not supported on iOS/iPhone/iPad browsers!
75
+ # Adding VP9 will break debug video playback on all iOS devices.
76
+ codecs_to_try = ["avc1", "mp4v"]
77
+ codec_attempt_log: list[dict[str, Any]] = []
98
78
 
99
- for codec in CODECS_TO_TRY:
79
+ for codec in codecs_to_try:
100
80
  writer = _try_open_video_writer(
101
81
  output_path, codec, fps, display_width, display_height, codec_attempt_log
102
82
  )
@@ -123,7 +103,7 @@ def _try_open_video_writer(
123
103
  fps: float,
124
104
  width: int,
125
105
  height: int,
126
- attempt_log: CodecAttemptLog,
106
+ attempt_log: list[dict[str, Any]],
127
107
  ) -> cv2.VideoWriter | None:
128
108
  """Attempt to open a video writer with a specific codec."""
129
109
  try:
@@ -214,8 +194,9 @@ class BaseDebugOverlayRenderer:
214
194
  # Optimize debug video resolution: Cap max dimension to 720p
215
195
  # Reduces software encoding time on single-core Cloud Run instances.
216
196
  # while keeping sufficient quality for visual debugging.
217
- if max(display_width, display_height) > MAX_VIDEO_DIMENSION:
218
- scale = MAX_VIDEO_DIMENSION / max(display_width, display_height)
197
+ max_dimension = 720
198
+ if max(display_width, display_height) > max_dimension:
199
+ scale = max_dimension / max(display_width, display_height)
219
200
  # Ensure dimensions are even for codec compatibility
220
201
  self.display_width = int(display_width * scale) // 2 * 2
221
202
  self.display_height = int(display_height * scale) // 2 * 2
@@ -255,114 +236,6 @@ class BaseDebugOverlayRenderer:
255
236
  output_path, width, height, self.display_width, self.display_height, fps
256
237
  )
257
238
 
258
- def _normalize_to_pixels(self, x: float, y: float) -> tuple[int, int]:
259
- """Convert normalized coordinates (0-1) to pixel coordinates.
260
-
261
- Args:
262
- x: Normalized x coordinate (0-1)
263
- y: Normalized y coordinate (0-1)
264
-
265
- Returns:
266
- Tuple of (pixel_x, pixel_y)
267
- """
268
- return int(x * self.width), int(y * self.height)
269
-
270
- def _landmark_to_pixel(self, landmark: Landmark) -> tuple[int, int]:
271
- """Convert normalized landmark coordinates to pixel coordinates."""
272
- return self._normalize_to_pixels(landmark[0], landmark[1])
273
-
274
- def _is_visible(self, landmark: Landmark, threshold: float = VISIBILITY_THRESHOLD) -> bool:
275
- """Check if a landmark has sufficient visibility."""
276
- return landmark[2] > threshold
277
-
278
- def _get_skeleton_segments(self, side_prefix: str) -> list[tuple[str, str, Color, int]]:
279
- """Get skeleton segments for one side of the body.
280
-
281
- Returns list of (start_key, end_key, color, thickness) tuples.
282
- """
283
- p = side_prefix # Shorter alias for readability
284
- return [
285
- (f"{p}heel", f"{p}ankle", ANKLE_COLOR, 3), # Foot
286
- (f"{p}heel", f"{p}foot_index", ANKLE_COLOR, 2), # Alt foot
287
- (f"{p}ankle", f"{p}knee", KNEE_COLOR, 4), # Shin
288
- (f"{p}knee", f"{p}hip", HIP_COLOR, 4), # Femur
289
- (f"{p}hip", f"{p}shoulder", TRUNK_COLOR, 4), # Trunk
290
- (f"{p}shoulder", "nose", (150, 150, 255), 2), # Neck
291
- ]
292
-
293
- def _draw_segment(
294
- self,
295
- frame: np.ndarray,
296
- landmarks: LandmarkDict,
297
- start_key: str,
298
- end_key: str,
299
- color: Color,
300
- thickness: int,
301
- ) -> None:
302
- """Draw a single skeleton segment if both endpoints are visible."""
303
- if start_key not in landmarks or end_key not in landmarks:
304
- return
305
-
306
- start_landmark = landmarks[start_key]
307
- end_landmark = landmarks[end_key]
308
-
309
- if not (self._is_visible(start_landmark) and self._is_visible(end_landmark)):
310
- return
311
-
312
- start_pt = self._landmark_to_pixel(start_landmark)
313
- end_pt = self._landmark_to_pixel(end_landmark)
314
- cv2.line(frame, start_pt, end_pt, color, thickness)
315
-
316
- def _draw_joints(
317
- self,
318
- frame: np.ndarray,
319
- landmarks: LandmarkDict,
320
- side_prefix: str,
321
- ) -> None:
322
- """Draw joint circles for one side of the body."""
323
- p = side_prefix
324
- joint_keys = [
325
- f"{p}heel",
326
- f"{p}foot_index",
327
- f"{p}ankle",
328
- f"{p}knee",
329
- f"{p}hip",
330
- f"{p}shoulder",
331
- ]
332
-
333
- for key in joint_keys:
334
- if key not in landmarks:
335
- continue
336
- landmark = landmarks[key]
337
- if not self._is_visible(landmark):
338
- continue
339
-
340
- point = self._landmark_to_pixel(landmark)
341
- cv2.circle(frame, point, JOINT_CIRCLE_RADIUS, WHITE, -1)
342
- cv2.circle(frame, point, JOINT_OUTLINE_RADIUS, BLACK, 2)
343
-
344
- def _draw_skeleton(self, frame: np.ndarray, landmarks: LandmarkDict) -> None:
345
- """Draw skeleton segments showing body landmarks.
346
-
347
- Draws whatever landmarks are visible. In side-view videos, ankle/knee
348
- may have low visibility, so we draw available segments.
349
-
350
- Args:
351
- frame: Frame to draw on (modified in place)
352
- landmarks: Pose landmarks
353
- """
354
- # Draw segments and joints for both sides
355
- for side_prefix in ["right_", "left_"]:
356
- for start_key, end_key, color, thickness in self._get_skeleton_segments(side_prefix):
357
- self._draw_segment(frame, landmarks, start_key, end_key, color, thickness)
358
- self._draw_joints(frame, landmarks, side_prefix)
359
-
360
- # Draw nose (head position) if visible
361
- if "nose" in landmarks and self._is_visible(landmarks["nose"]):
362
- point = self._landmark_to_pixel(landmarks["nose"])
363
- cv2.circle(frame, point, NOSE_CIRCLE_RADIUS, CYAN, -1)
364
- cv2.circle(frame, point, NOSE_OUTLINE_RADIUS, BLACK, 2)
365
-
366
239
  def write_frame(self, frame: np.ndarray) -> None:
367
240
  """
368
241
  Write frame to output video.
@@ -447,11 +320,11 @@ class BaseDebugOverlayRenderer:
447
320
  "-vcodec",
448
321
  "libx264",
449
322
  "-pix_fmt",
450
- FFMPEG_PIX_FMT,
323
+ "yuv420p",
451
324
  "-preset",
452
- FFMPEG_PRESET,
325
+ "fast",
453
326
  "-crf",
454
- FFMPEG_CRF,
327
+ "23",
455
328
  "-an",
456
329
  temp_path,
457
330
  ]
@@ -462,7 +335,7 @@ class BaseDebugOverlayRenderer:
462
335
  input_file=self.output_path,
463
336
  output_file=temp_path,
464
337
  output_codec="libx264",
465
- pixel_format=FFMPEG_PIX_FMT,
338
+ pixel_format="yuv420p",
466
339
  reason="iOS_compatibility",
467
340
  )
468
341
 
@@ -483,7 +356,7 @@ class BaseDebugOverlayRenderer:
483
356
  "debug_video_reencoded_file_replaced",
484
357
  output_path=self.output_path,
485
358
  final_codec="libx264",
486
- pixel_format=FFMPEG_PIX_FMT,
359
+ pixel_format="yuv420p",
487
360
  )
488
361
  except Exception as e:
489
362
  self._handle_reencode_error(e, temp_path)