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.
- kinemotion/__init__.py +3 -18
- kinemotion/api.py +7 -27
- kinemotion/cli.py +2 -4
- kinemotion/{countermovement_jump → cmj}/analysis.py +158 -16
- kinemotion/{countermovement_jump → cmj}/api.py +18 -46
- kinemotion/{countermovement_jump → cmj}/cli.py +46 -6
- kinemotion/cmj/debug_overlay.py +457 -0
- kinemotion/{countermovement_jump → cmj}/joint_angles.py +31 -96
- kinemotion/{countermovement_jump → cmj}/metrics_validator.py +293 -184
- kinemotion/{countermovement_jump → cmj}/validation_bounds.py +18 -1
- kinemotion/core/__init__.py +2 -11
- kinemotion/core/auto_tuning.py +107 -149
- kinemotion/core/cli_utils.py +0 -74
- kinemotion/core/debug_overlay_utils.py +15 -142
- kinemotion/core/experimental.py +51 -55
- kinemotion/core/filtering.py +56 -116
- kinemotion/core/pipeline_utils.py +2 -2
- kinemotion/core/pose.py +98 -47
- kinemotion/core/quality.py +6 -4
- kinemotion/core/smoothing.py +51 -65
- kinemotion/core/types.py +0 -15
- kinemotion/core/validation.py +7 -76
- kinemotion/core/video_io.py +27 -41
- kinemotion/{drop_jump → dropjump}/__init__.py +8 -2
- kinemotion/{drop_jump → dropjump}/analysis.py +120 -282
- kinemotion/{drop_jump → dropjump}/api.py +33 -59
- kinemotion/{drop_jump → dropjump}/cli.py +136 -70
- kinemotion/dropjump/debug_overlay.py +182 -0
- kinemotion/{drop_jump → dropjump}/kinematics.py +65 -175
- kinemotion/{drop_jump → dropjump}/metrics_validator.py +51 -25
- kinemotion/{drop_jump → dropjump}/validation_bounds.py +1 -1
- {kinemotion-0.76.2.dist-info → kinemotion-1.0.0.dist-info}/METADATA +26 -75
- kinemotion-1.0.0.dist-info/RECORD +49 -0
- kinemotion/core/overlay_constants.py +0 -61
- kinemotion/core/video_analysis_base.py +0 -132
- kinemotion/countermovement_jump/debug_overlay.py +0 -325
- kinemotion/drop_jump/debug_overlay.py +0 -241
- kinemotion/squat_jump/__init__.py +0 -5
- kinemotion/squat_jump/analysis.py +0 -377
- kinemotion/squat_jump/api.py +0 -610
- kinemotion/squat_jump/cli.py +0 -309
- kinemotion/squat_jump/debug_overlay.py +0 -163
- kinemotion/squat_jump/kinematics.py +0 -342
- kinemotion/squat_jump/metrics_validator.py +0 -438
- kinemotion/squat_jump/validation_bounds.py +0 -221
- kinemotion-0.76.2.dist-info/RECORD +0 -59
- /kinemotion/{countermovement_jump → cmj}/__init__.py +0 -0
- /kinemotion/{countermovement_jump → cmj}/kinematics.py +0 -0
- {kinemotion-0.76.2.dist-info → kinemotion-1.0.0.dist-info}/WHEEL +0 -0
- {kinemotion-0.76.2.dist-info → kinemotion-1.0.0.dist-info}/entry_points.txt +0 -0
- {kinemotion-0.76.2.dist-info → kinemotion-1.0.0.dist-info}/licenses/LICENSE +0 -0
kinemotion/core/__init__.py
CHANGED
|
@@ -13,6 +13,7 @@ from .pose import (
|
|
|
13
13
|
MediaPipePoseTracker,
|
|
14
14
|
PoseTrackerFactory,
|
|
15
15
|
compute_center_of_mass,
|
|
16
|
+
get_tracker_info,
|
|
16
17
|
)
|
|
17
18
|
from .pose_landmarks import KINEMOTION_LANDMARKS, LANDMARK_INDICES
|
|
18
19
|
from .quality import (
|
|
@@ -34,24 +35,14 @@ from .timing import (
|
|
|
34
35
|
PerformanceTimer,
|
|
35
36
|
Timer,
|
|
36
37
|
)
|
|
37
|
-
from .video_analysis_base import (
|
|
38
|
-
AnalysisOverrides,
|
|
39
|
-
JumpAnalysisPipeline,
|
|
40
|
-
VideoAnalysisConfig,
|
|
41
|
-
VideoAnalysisResult,
|
|
42
|
-
)
|
|
43
38
|
from .video_io import VideoProcessor
|
|
44
39
|
|
|
45
40
|
__all__ = [
|
|
46
|
-
# Video Analysis Base
|
|
47
|
-
"AnalysisOverrides",
|
|
48
|
-
"JumpAnalysisPipeline",
|
|
49
|
-
"VideoAnalysisConfig",
|
|
50
|
-
"VideoAnalysisResult",
|
|
51
41
|
# Pose tracking
|
|
52
42
|
"MediaPipePoseTracker",
|
|
53
43
|
"PoseTrackerFactory",
|
|
54
44
|
"compute_center_of_mass",
|
|
45
|
+
"get_tracker_info",
|
|
55
46
|
"LANDMARK_INDICES",
|
|
56
47
|
"KINEMOTION_LANDMARKS",
|
|
57
48
|
"get_model_path",
|
kinemotion/core/auto_tuning.py
CHANGED
|
@@ -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
|
-
#
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
#
|
|
238
|
-
|
|
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
|
-
#
|
|
241
|
-
|
|
242
|
-
|
|
190
|
+
# =================================================================
|
|
191
|
+
# STEP 4: Set fixed optimal values
|
|
192
|
+
# These are always the same regardless of video characteristics
|
|
193
|
+
# =================================================================
|
|
243
194
|
|
|
244
|
-
#
|
|
245
|
-
|
|
195
|
+
# Polyorder: Always 2 (quadratic) - optimal for jump physics (parabolic motion)
|
|
196
|
+
polyorder = 2
|
|
246
197
|
|
|
247
|
-
#
|
|
248
|
-
|
|
198
|
+
# Visibility threshold: Standard MediaPipe threshold
|
|
199
|
+
visibility_threshold = 0.5
|
|
249
200
|
|
|
250
|
-
#
|
|
251
|
-
|
|
252
|
-
|
|
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=
|
|
263
|
-
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
|
|
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)
|
kinemotion/core/cli_utils.py
CHANGED
|
@@ -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
|
-
#
|
|
97
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
218
|
-
|
|
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
|
-
|
|
323
|
+
"yuv420p",
|
|
451
324
|
"-preset",
|
|
452
|
-
|
|
325
|
+
"fast",
|
|
453
326
|
"-crf",
|
|
454
|
-
|
|
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=
|
|
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=
|
|
359
|
+
pixel_format="yuv420p",
|
|
487
360
|
)
|
|
488
361
|
except Exception as e:
|
|
489
362
|
self._handle_reencode_error(e, temp_path)
|