kinemotion 0.17.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 +31 -0
- kinemotion/api.py +946 -0
- kinemotion/cli.py +22 -0
- kinemotion/cmj/__init__.py +5 -0
- kinemotion/cmj/analysis.py +528 -0
- kinemotion/cmj/cli.py +543 -0
- kinemotion/cmj/debug_overlay.py +463 -0
- kinemotion/cmj/joint_angles.py +290 -0
- kinemotion/cmj/kinematics.py +191 -0
- kinemotion/core/__init__.py +40 -0
- kinemotion/core/auto_tuning.py +325 -0
- kinemotion/core/cli_utils.py +212 -0
- kinemotion/core/debug_overlay_utils.py +143 -0
- kinemotion/core/filtering.py +345 -0
- kinemotion/core/pose.py +259 -0
- kinemotion/core/smoothing.py +412 -0
- kinemotion/core/video_io.py +186 -0
- kinemotion/dropjump/__init__.py +29 -0
- kinemotion/dropjump/analysis.py +790 -0
- kinemotion/dropjump/cli.py +704 -0
- kinemotion/dropjump/debug_overlay.py +179 -0
- kinemotion/dropjump/kinematics.py +446 -0
- kinemotion/py.typed +0 -0
- kinemotion-0.17.0.dist-info/METADATA +529 -0
- kinemotion-0.17.0.dist-info/RECORD +28 -0
- kinemotion-0.17.0.dist-info/WHEEL +4 -0
- kinemotion-0.17.0.dist-info/entry_points.txt +2 -0
- kinemotion-0.17.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
"""Automatic parameter tuning based on video characteristics."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from enum import Enum
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class QualityPreset(str, Enum):
|
|
10
|
+
"""Quality presets for analysis."""
|
|
11
|
+
|
|
12
|
+
FAST = "fast" # Quick analysis, lower precision
|
|
13
|
+
BALANCED = "balanced" # Default: good balance of speed and accuracy
|
|
14
|
+
ACCURATE = "accurate" # Research-grade analysis, slower
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class VideoCharacteristics:
|
|
19
|
+
"""Characteristics extracted from video analysis."""
|
|
20
|
+
|
|
21
|
+
fps: float
|
|
22
|
+
frame_count: int
|
|
23
|
+
avg_visibility: float # Average landmark visibility (0-1)
|
|
24
|
+
position_variance: float # Variance in foot positions
|
|
25
|
+
has_stable_period: bool # Whether video has initial stationary period
|
|
26
|
+
tracking_quality: str # "low", "medium", "high"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class AnalysisParameters:
|
|
31
|
+
"""Auto-tuned parameters for drop jump analysis."""
|
|
32
|
+
|
|
33
|
+
smoothing_window: int
|
|
34
|
+
polyorder: int
|
|
35
|
+
velocity_threshold: float
|
|
36
|
+
min_contact_frames: int
|
|
37
|
+
visibility_threshold: float
|
|
38
|
+
detection_confidence: float
|
|
39
|
+
tracking_confidence: float
|
|
40
|
+
outlier_rejection: bool
|
|
41
|
+
bilateral_filter: bool
|
|
42
|
+
use_curvature: bool
|
|
43
|
+
|
|
44
|
+
def to_dict(self) -> dict:
|
|
45
|
+
"""Convert to dictionary."""
|
|
46
|
+
return {
|
|
47
|
+
"smoothing_window": self.smoothing_window,
|
|
48
|
+
"polyorder": self.polyorder,
|
|
49
|
+
"velocity_threshold": self.velocity_threshold,
|
|
50
|
+
"min_contact_frames": self.min_contact_frames,
|
|
51
|
+
"visibility_threshold": self.visibility_threshold,
|
|
52
|
+
"detection_confidence": self.detection_confidence,
|
|
53
|
+
"tracking_confidence": self.tracking_confidence,
|
|
54
|
+
"outlier_rejection": self.outlier_rejection,
|
|
55
|
+
"bilateral_filter": self.bilateral_filter,
|
|
56
|
+
"use_curvature": self.use_curvature,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def analyze_tracking_quality(avg_visibility: float) -> str:
|
|
61
|
+
"""
|
|
62
|
+
Classify tracking quality based on average landmark visibility.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
avg_visibility: Average visibility score across all tracked landmarks
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
Quality classification: "low", "medium", or "high"
|
|
69
|
+
"""
|
|
70
|
+
if avg_visibility < 0.4:
|
|
71
|
+
return "low"
|
|
72
|
+
elif avg_visibility < 0.7:
|
|
73
|
+
return "medium"
|
|
74
|
+
else:
|
|
75
|
+
return "high"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def auto_tune_parameters(
|
|
79
|
+
characteristics: VideoCharacteristics,
|
|
80
|
+
quality_preset: QualityPreset = QualityPreset.BALANCED,
|
|
81
|
+
) -> AnalysisParameters:
|
|
82
|
+
"""
|
|
83
|
+
Automatically tune analysis parameters based on video characteristics.
|
|
84
|
+
|
|
85
|
+
This function implements heuristics to select optimal parameters without
|
|
86
|
+
requiring user expertise in video analysis or kinematic tracking.
|
|
87
|
+
|
|
88
|
+
Key principles:
|
|
89
|
+
1. FPS-based scaling: Higher fps needs lower velocity thresholds
|
|
90
|
+
2. Quality-based smoothing: Noisy video needs more smoothing
|
|
91
|
+
3. Always enable proven features: outlier rejection, curvature analysis
|
|
92
|
+
4. Preset modifiers: fast/balanced/accurate adjust base parameters
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
characteristics: Analyzed video characteristics
|
|
96
|
+
quality_preset: Quality vs speed tradeoff
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
AnalysisParameters with auto-tuned values
|
|
100
|
+
"""
|
|
101
|
+
fps = characteristics.fps
|
|
102
|
+
quality = characteristics.tracking_quality
|
|
103
|
+
|
|
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
|
+
# At 30fps, feet move ~2% of frame per frame when "stationary"
|
|
112
|
+
# At 60fps, feet move ~1% of frame per frame when "stationary"
|
|
113
|
+
# Formula: threshold = 0.02 * (30 / fps)
|
|
114
|
+
base_velocity_threshold = 0.02 * (30.0 / fps)
|
|
115
|
+
|
|
116
|
+
# Min contact frames: Scale with fps to maintain same time duration
|
|
117
|
+
# Goal: ~100ms minimum contact (3 frames @ 30fps, 6 frames @ 60fps)
|
|
118
|
+
# Formula: frames = round(3 * (fps / 30))
|
|
119
|
+
base_min_contact_frames = max(2, round(3.0 * (fps / 30.0)))
|
|
120
|
+
|
|
121
|
+
# Smoothing window: Decrease with higher fps for better temporal resolution
|
|
122
|
+
# Lower fps (30fps): 5-frame window = 167ms
|
|
123
|
+
# Higher fps (60fps): 3-frame window = 50ms (same temporal resolution)
|
|
124
|
+
if fps <= 30:
|
|
125
|
+
base_smoothing_window = 5
|
|
126
|
+
elif fps <= 60:
|
|
127
|
+
base_smoothing_window = 3
|
|
128
|
+
else:
|
|
129
|
+
base_smoothing_window = 3 # Even at 120fps, 3 is minimum for Savitzky-Golay
|
|
130
|
+
|
|
131
|
+
# =================================================================
|
|
132
|
+
# STEP 2: Quality-based adjustments
|
|
133
|
+
# Adapt smoothing and filtering based on tracking quality
|
|
134
|
+
# =================================================================
|
|
135
|
+
|
|
136
|
+
smoothing_adjustment = 0
|
|
137
|
+
enable_bilateral = False
|
|
138
|
+
|
|
139
|
+
if quality == "low":
|
|
140
|
+
# Poor tracking quality: aggressive smoothing and filtering
|
|
141
|
+
smoothing_adjustment = +2
|
|
142
|
+
enable_bilateral = True
|
|
143
|
+
elif quality == "medium":
|
|
144
|
+
# Moderate quality: slight smoothing increase
|
|
145
|
+
smoothing_adjustment = +1
|
|
146
|
+
enable_bilateral = True
|
|
147
|
+
else: # high quality
|
|
148
|
+
# Good tracking: preserve detail, minimal smoothing
|
|
149
|
+
smoothing_adjustment = 0
|
|
150
|
+
enable_bilateral = False
|
|
151
|
+
|
|
152
|
+
# =================================================================
|
|
153
|
+
# STEP 3: Apply quality preset modifiers
|
|
154
|
+
# User can choose speed vs accuracy tradeoff
|
|
155
|
+
# =================================================================
|
|
156
|
+
|
|
157
|
+
if quality_preset == QualityPreset.FAST:
|
|
158
|
+
# Fast: Trade accuracy for speed
|
|
159
|
+
velocity_threshold = base_velocity_threshold * 1.5 # Less sensitive
|
|
160
|
+
min_contact_frames = max(2, int(base_min_contact_frames * 0.67))
|
|
161
|
+
smoothing_window = max(3, base_smoothing_window - 2 + smoothing_adjustment)
|
|
162
|
+
bilateral_filter = False # Skip expensive filtering
|
|
163
|
+
detection_confidence = 0.3
|
|
164
|
+
tracking_confidence = 0.3
|
|
165
|
+
|
|
166
|
+
elif quality_preset == QualityPreset.ACCURATE:
|
|
167
|
+
# Accurate: Maximize accuracy, accept slower processing
|
|
168
|
+
velocity_threshold = base_velocity_threshold * 0.5 # More sensitive
|
|
169
|
+
min_contact_frames = (
|
|
170
|
+
base_min_contact_frames # Don't increase (would miss brief)
|
|
171
|
+
)
|
|
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
|
|
189
|
+
|
|
190
|
+
# =================================================================
|
|
191
|
+
# STEP 4: Set fixed optimal values
|
|
192
|
+
# These are always the same regardless of video characteristics
|
|
193
|
+
# =================================================================
|
|
194
|
+
|
|
195
|
+
# Polyorder: Always 2 (quadratic) - optimal for jump physics (parabolic motion)
|
|
196
|
+
polyorder = 2
|
|
197
|
+
|
|
198
|
+
# Visibility threshold: Standard MediaPipe threshold
|
|
199
|
+
visibility_threshold = 0.5
|
|
200
|
+
|
|
201
|
+
# Always enable proven accuracy features
|
|
202
|
+
outlier_rejection = True # Removes tracking glitches (minimal cost)
|
|
203
|
+
use_curvature = True # Trajectory curvature analysis (minimal cost)
|
|
204
|
+
|
|
205
|
+
return AnalysisParameters(
|
|
206
|
+
smoothing_window=smoothing_window,
|
|
207
|
+
polyorder=polyorder,
|
|
208
|
+
velocity_threshold=velocity_threshold,
|
|
209
|
+
min_contact_frames=min_contact_frames,
|
|
210
|
+
visibility_threshold=visibility_threshold,
|
|
211
|
+
detection_confidence=detection_confidence,
|
|
212
|
+
tracking_confidence=tracking_confidence,
|
|
213
|
+
outlier_rejection=outlier_rejection,
|
|
214
|
+
bilateral_filter=bilateral_filter,
|
|
215
|
+
use_curvature=use_curvature,
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _collect_foot_visibility_and_positions(
|
|
220
|
+
frame_landmarks: dict[str, tuple[float, float, float]],
|
|
221
|
+
) -> tuple[list[float], list[float]]:
|
|
222
|
+
"""
|
|
223
|
+
Collect visibility scores and Y positions from foot landmarks.
|
|
224
|
+
|
|
225
|
+
Args:
|
|
226
|
+
frame_landmarks: Landmarks for a single frame
|
|
227
|
+
|
|
228
|
+
Returns:
|
|
229
|
+
Tuple of (visibility_scores, y_positions)
|
|
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
|
+
|
|
240
|
+
frame_vis = []
|
|
241
|
+
frame_y_positions = []
|
|
242
|
+
|
|
243
|
+
for key in foot_keys:
|
|
244
|
+
if key in frame_landmarks:
|
|
245
|
+
_, y, vis = frame_landmarks[key] # x not needed for analysis
|
|
246
|
+
frame_vis.append(vis)
|
|
247
|
+
frame_y_positions.append(y)
|
|
248
|
+
|
|
249
|
+
return frame_vis, frame_y_positions
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _check_stable_period(positions: list[float]) -> bool:
|
|
253
|
+
"""
|
|
254
|
+
Check if video has a stable period at the start.
|
|
255
|
+
|
|
256
|
+
A stable period (low variance in first 30 frames) indicates
|
|
257
|
+
the subject is standing on an elevated platform before jumping.
|
|
258
|
+
|
|
259
|
+
Args:
|
|
260
|
+
positions: List of average Y positions per frame
|
|
261
|
+
|
|
262
|
+
Returns:
|
|
263
|
+
True if stable period detected, False otherwise
|
|
264
|
+
"""
|
|
265
|
+
if len(positions) < 30:
|
|
266
|
+
return False
|
|
267
|
+
|
|
268
|
+
first_30_std = float(np.std(positions[:30]))
|
|
269
|
+
return first_30_std < 0.01 # Very stable = on platform
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def analyze_video_sample(
|
|
273
|
+
landmarks_sequence: list[dict[str, tuple[float, float, float]] | None],
|
|
274
|
+
fps: float,
|
|
275
|
+
frame_count: int,
|
|
276
|
+
) -> VideoCharacteristics:
|
|
277
|
+
"""
|
|
278
|
+
Analyze video characteristics from a sample of frames.
|
|
279
|
+
|
|
280
|
+
This function should be called after tracking the first 30-60 frames
|
|
281
|
+
to understand video quality and characteristics.
|
|
282
|
+
|
|
283
|
+
Args:
|
|
284
|
+
landmarks_sequence: Tracked landmarks from sample frames
|
|
285
|
+
fps: Video frame rate
|
|
286
|
+
frame_count: Total number of frames in video
|
|
287
|
+
|
|
288
|
+
Returns:
|
|
289
|
+
VideoCharacteristics with analyzed properties
|
|
290
|
+
"""
|
|
291
|
+
visibilities = []
|
|
292
|
+
positions = []
|
|
293
|
+
|
|
294
|
+
# Collect visibility and position data from all frames
|
|
295
|
+
for frame_landmarks in landmarks_sequence:
|
|
296
|
+
if not frame_landmarks:
|
|
297
|
+
continue
|
|
298
|
+
|
|
299
|
+
frame_vis, frame_y_positions = _collect_foot_visibility_and_positions(
|
|
300
|
+
frame_landmarks
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
if frame_vis:
|
|
304
|
+
visibilities.append(float(np.mean(frame_vis)))
|
|
305
|
+
if frame_y_positions:
|
|
306
|
+
positions.append(float(np.mean(frame_y_positions)))
|
|
307
|
+
|
|
308
|
+
# Compute metrics
|
|
309
|
+
avg_visibility = float(np.mean(visibilities)) if visibilities else 0.5
|
|
310
|
+
position_variance = float(np.var(positions)) if len(positions) > 1 else 0.0
|
|
311
|
+
|
|
312
|
+
# Determine tracking quality
|
|
313
|
+
tracking_quality = analyze_tracking_quality(avg_visibility)
|
|
314
|
+
|
|
315
|
+
# Check for stable period (indicates drop jump from elevated platform)
|
|
316
|
+
has_stable_period = _check_stable_period(positions)
|
|
317
|
+
|
|
318
|
+
return VideoCharacteristics(
|
|
319
|
+
fps=fps,
|
|
320
|
+
frame_count=frame_count,
|
|
321
|
+
avg_visibility=avg_visibility,
|
|
322
|
+
position_variance=position_variance,
|
|
323
|
+
has_stable_period=has_stable_period,
|
|
324
|
+
tracking_quality=tracking_quality,
|
|
325
|
+
)
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""Shared CLI utilities for drop jump and CMJ analysis."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from typing import Any, Protocol
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
|
|
8
|
+
from .auto_tuning import AnalysisParameters, QualityPreset, VideoCharacteristics
|
|
9
|
+
from .pose import PoseTracker
|
|
10
|
+
from .smoothing import smooth_landmarks, smooth_landmarks_advanced
|
|
11
|
+
from .video_io import VideoProcessor
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ExpertParameters(Protocol):
|
|
15
|
+
"""Protocol for expert parameter overrides."""
|
|
16
|
+
|
|
17
|
+
detection_confidence: float | None
|
|
18
|
+
tracking_confidence: float | None
|
|
19
|
+
smoothing_window: int | None
|
|
20
|
+
velocity_threshold: float | None
|
|
21
|
+
min_contact_frames: int | None
|
|
22
|
+
visibility_threshold: float | None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def determine_initial_confidence(
|
|
26
|
+
quality_preset: QualityPreset,
|
|
27
|
+
expert_params: ExpertParameters,
|
|
28
|
+
) -> tuple[float, float]:
|
|
29
|
+
"""Determine initial detection and tracking confidence levels.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
quality_preset: Quality preset enum
|
|
33
|
+
expert_params: Expert parameter overrides
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
Tuple of (detection_confidence, tracking_confidence)
|
|
37
|
+
"""
|
|
38
|
+
initial_detection_conf = 0.5
|
|
39
|
+
initial_tracking_conf = 0.5
|
|
40
|
+
|
|
41
|
+
if quality_preset == QualityPreset.FAST:
|
|
42
|
+
initial_detection_conf = 0.3
|
|
43
|
+
initial_tracking_conf = 0.3
|
|
44
|
+
elif quality_preset == QualityPreset.ACCURATE:
|
|
45
|
+
initial_detection_conf = 0.6
|
|
46
|
+
initial_tracking_conf = 0.6
|
|
47
|
+
|
|
48
|
+
# Override with expert values if provided
|
|
49
|
+
if expert_params.detection_confidence is not None:
|
|
50
|
+
initial_detection_conf = expert_params.detection_confidence
|
|
51
|
+
if expert_params.tracking_confidence is not None:
|
|
52
|
+
initial_tracking_conf = expert_params.tracking_confidence
|
|
53
|
+
|
|
54
|
+
return initial_detection_conf, initial_tracking_conf
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def track_all_frames(video: VideoProcessor, tracker: PoseTracker) -> tuple[list, list]:
|
|
58
|
+
"""Track pose landmarks in all video frames.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
video: Video processor
|
|
62
|
+
tracker: Pose tracker
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
Tuple of (frames, landmarks_sequence)
|
|
66
|
+
"""
|
|
67
|
+
click.echo("Tracking pose landmarks...", err=True)
|
|
68
|
+
landmarks_sequence = []
|
|
69
|
+
frames = []
|
|
70
|
+
|
|
71
|
+
bar: Any
|
|
72
|
+
with click.progressbar(length=video.frame_count, label="Processing frames") as bar:
|
|
73
|
+
while True:
|
|
74
|
+
frame = video.read_frame()
|
|
75
|
+
if frame is None:
|
|
76
|
+
break
|
|
77
|
+
|
|
78
|
+
frames.append(frame)
|
|
79
|
+
landmarks = tracker.process_frame(frame)
|
|
80
|
+
landmarks_sequence.append(landmarks)
|
|
81
|
+
bar.update(1)
|
|
82
|
+
|
|
83
|
+
tracker.close()
|
|
84
|
+
return frames, landmarks_sequence
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def apply_expert_param_overrides(
|
|
88
|
+
params: AnalysisParameters, expert_params: ExpertParameters
|
|
89
|
+
) -> AnalysisParameters:
|
|
90
|
+
"""Apply expert parameter overrides to auto-tuned parameters.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
params: Auto-tuned parameters
|
|
94
|
+
expert_params: Expert overrides
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
Modified params object (mutated in place)
|
|
98
|
+
"""
|
|
99
|
+
if expert_params.smoothing_window is not None:
|
|
100
|
+
params.smoothing_window = expert_params.smoothing_window
|
|
101
|
+
if expert_params.velocity_threshold is not None:
|
|
102
|
+
params.velocity_threshold = expert_params.velocity_threshold
|
|
103
|
+
if expert_params.min_contact_frames is not None:
|
|
104
|
+
params.min_contact_frames = expert_params.min_contact_frames
|
|
105
|
+
if expert_params.visibility_threshold is not None:
|
|
106
|
+
params.visibility_threshold = expert_params.visibility_threshold
|
|
107
|
+
return params
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def print_auto_tuned_params(
|
|
111
|
+
video: VideoProcessor,
|
|
112
|
+
quality_preset: QualityPreset,
|
|
113
|
+
params: AnalysisParameters,
|
|
114
|
+
characteristics: VideoCharacteristics | None = None,
|
|
115
|
+
extra_params: dict[str, Any] | None = None,
|
|
116
|
+
) -> None:
|
|
117
|
+
"""Print auto-tuned parameters in verbose mode.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
video: Video processor
|
|
121
|
+
quality_preset: Quality preset
|
|
122
|
+
params: Auto-tuned parameters
|
|
123
|
+
characteristics: Optional video characteristics (for tracking quality display)
|
|
124
|
+
extra_params: Optional extra parameters to display (e.g., countermovement_threshold)
|
|
125
|
+
"""
|
|
126
|
+
click.echo("\n" + "=" * 60, err=True)
|
|
127
|
+
click.echo("AUTO-TUNED PARAMETERS", err=True)
|
|
128
|
+
click.echo("=" * 60, err=True)
|
|
129
|
+
click.echo(f"Video FPS: {video.fps:.2f}", err=True)
|
|
130
|
+
|
|
131
|
+
if characteristics:
|
|
132
|
+
click.echo(
|
|
133
|
+
f"Tracking quality: {characteristics.tracking_quality} "
|
|
134
|
+
f"(avg visibility: {characteristics.avg_visibility:.2f})",
|
|
135
|
+
err=True,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
click.echo(f"Quality preset: {quality_preset.value}", err=True)
|
|
139
|
+
click.echo("\nSelected parameters:", err=True)
|
|
140
|
+
click.echo(f" smoothing_window: {params.smoothing_window}", err=True)
|
|
141
|
+
click.echo(f" polyorder: {params.polyorder}", err=True)
|
|
142
|
+
click.echo(f" velocity_threshold: {params.velocity_threshold:.4f}", err=True)
|
|
143
|
+
|
|
144
|
+
# Print extra parameters if provided
|
|
145
|
+
if extra_params:
|
|
146
|
+
for key, value in extra_params.items():
|
|
147
|
+
if isinstance(value, float):
|
|
148
|
+
click.echo(f" {key}: {value:.4f}", err=True)
|
|
149
|
+
else:
|
|
150
|
+
click.echo(f" {key}: {value}", err=True)
|
|
151
|
+
|
|
152
|
+
click.echo(f" min_contact_frames: {params.min_contact_frames}", err=True)
|
|
153
|
+
click.echo(f" visibility_threshold: {params.visibility_threshold}", err=True)
|
|
154
|
+
click.echo(f" detection_confidence: {params.detection_confidence}", err=True)
|
|
155
|
+
click.echo(f" tracking_confidence: {params.tracking_confidence}", err=True)
|
|
156
|
+
click.echo(f" outlier_rejection: {params.outlier_rejection}", err=True)
|
|
157
|
+
click.echo(f" bilateral_filter: {params.bilateral_filter}", err=True)
|
|
158
|
+
click.echo(f" use_curvature: {params.use_curvature}", err=True)
|
|
159
|
+
click.echo("=" * 60 + "\n", err=True)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def smooth_landmark_sequence(
|
|
163
|
+
landmarks_sequence: list, params: AnalysisParameters
|
|
164
|
+
) -> list:
|
|
165
|
+
"""Apply smoothing to landmark sequence.
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
landmarks_sequence: Raw landmark sequence
|
|
169
|
+
params: Auto-tuned parameters
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
Smoothed landmark sequence
|
|
173
|
+
"""
|
|
174
|
+
if params.outlier_rejection or params.bilateral_filter:
|
|
175
|
+
if params.outlier_rejection:
|
|
176
|
+
click.echo("Smoothing landmarks with outlier rejection...", err=True)
|
|
177
|
+
if params.bilateral_filter:
|
|
178
|
+
click.echo(
|
|
179
|
+
"Using bilateral temporal filter for edge-preserving smoothing...",
|
|
180
|
+
err=True,
|
|
181
|
+
)
|
|
182
|
+
return smooth_landmarks_advanced(
|
|
183
|
+
landmarks_sequence,
|
|
184
|
+
window_length=params.smoothing_window,
|
|
185
|
+
polyorder=params.polyorder,
|
|
186
|
+
use_outlier_rejection=params.outlier_rejection,
|
|
187
|
+
use_bilateral=params.bilateral_filter,
|
|
188
|
+
)
|
|
189
|
+
else:
|
|
190
|
+
click.echo("Smoothing landmarks...", err=True)
|
|
191
|
+
return smooth_landmarks(
|
|
192
|
+
landmarks_sequence,
|
|
193
|
+
window_length=params.smoothing_window,
|
|
194
|
+
polyorder=params.polyorder,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def common_output_options(func: Callable) -> Callable: # type: ignore[type-arg]
|
|
199
|
+
"""Add common output options to CLI command."""
|
|
200
|
+
func = click.option(
|
|
201
|
+
"--output",
|
|
202
|
+
"-o",
|
|
203
|
+
type=click.Path(),
|
|
204
|
+
help="Path for debug video output (optional)",
|
|
205
|
+
)(func)
|
|
206
|
+
func = click.option(
|
|
207
|
+
"--json-output",
|
|
208
|
+
"-j",
|
|
209
|
+
type=click.Path(),
|
|
210
|
+
help="Path for JSON metrics output (default: stdout)",
|
|
211
|
+
)(func)
|
|
212
|
+
return func
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Shared debug overlay utilities for video rendering."""
|
|
2
|
+
|
|
3
|
+
import cv2
|
|
4
|
+
import numpy as np
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def create_video_writer(
|
|
8
|
+
output_path: str,
|
|
9
|
+
width: int,
|
|
10
|
+
height: int,
|
|
11
|
+
display_width: int,
|
|
12
|
+
display_height: int,
|
|
13
|
+
fps: float,
|
|
14
|
+
) -> tuple[cv2.VideoWriter, bool]:
|
|
15
|
+
"""
|
|
16
|
+
Create a video writer with fallback codec support.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
output_path: Path for output video
|
|
20
|
+
width: Encoded frame width (from source video)
|
|
21
|
+
height: Encoded frame height (from source video)
|
|
22
|
+
display_width: Display width (considering SAR)
|
|
23
|
+
display_height: Display height (considering SAR)
|
|
24
|
+
fps: Frames per second
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
Tuple of (video_writer, needs_resize)
|
|
28
|
+
"""
|
|
29
|
+
needs_resize = (display_width != width) or (display_height != height)
|
|
30
|
+
|
|
31
|
+
# Try H.264 codec first (better quality/compatibility), fallback to mp4v
|
|
32
|
+
fourcc = cv2.VideoWriter_fourcc(*"avc1")
|
|
33
|
+
writer = cv2.VideoWriter(output_path, fourcc, fps, (display_width, display_height))
|
|
34
|
+
|
|
35
|
+
# Check if writer opened successfully, fallback to mp4v if not
|
|
36
|
+
if not writer.isOpened():
|
|
37
|
+
fourcc = cv2.VideoWriter_fourcc(*"mp4v")
|
|
38
|
+
writer = cv2.VideoWriter(
|
|
39
|
+
output_path, fourcc, fps, (display_width, display_height)
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
if not writer.isOpened():
|
|
43
|
+
raise ValueError(
|
|
44
|
+
f"Failed to create video writer for {output_path} with dimensions "
|
|
45
|
+
f"{display_width}x{display_height}"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
return writer, needs_resize
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def write_overlay_frame(
|
|
52
|
+
writer: cv2.VideoWriter, frame: np.ndarray, width: int, height: int
|
|
53
|
+
) -> None:
|
|
54
|
+
"""
|
|
55
|
+
Write a frame to the video writer with dimension validation.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
writer: Video writer instance
|
|
59
|
+
frame: Frame to write
|
|
60
|
+
width: Expected frame width
|
|
61
|
+
height: Expected frame height
|
|
62
|
+
|
|
63
|
+
Raises:
|
|
64
|
+
ValueError: If frame dimensions don't match expected dimensions
|
|
65
|
+
"""
|
|
66
|
+
# Validate dimensions before writing
|
|
67
|
+
if frame.shape[0] != height or frame.shape[1] != width:
|
|
68
|
+
raise ValueError(
|
|
69
|
+
f"Frame dimensions {frame.shape[1]}x{frame.shape[0]} do not match "
|
|
70
|
+
f"expected dimensions {width}x{height}"
|
|
71
|
+
)
|
|
72
|
+
writer.write(frame)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class BaseDebugOverlayRenderer:
|
|
76
|
+
"""Base class for debug overlay renderers with common functionality."""
|
|
77
|
+
|
|
78
|
+
def __init__(
|
|
79
|
+
self,
|
|
80
|
+
output_path: str,
|
|
81
|
+
width: int,
|
|
82
|
+
height: int,
|
|
83
|
+
display_width: int,
|
|
84
|
+
display_height: int,
|
|
85
|
+
fps: float,
|
|
86
|
+
):
|
|
87
|
+
"""
|
|
88
|
+
Initialize overlay renderer.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
output_path: Path for output video
|
|
92
|
+
width: Encoded frame width (from source video)
|
|
93
|
+
height: Encoded frame height (from source video)
|
|
94
|
+
display_width: Display width (considering SAR)
|
|
95
|
+
display_height: Display height (considering SAR)
|
|
96
|
+
fps: Frames per second
|
|
97
|
+
"""
|
|
98
|
+
self.width = width
|
|
99
|
+
self.height = height
|
|
100
|
+
self.display_width = display_width
|
|
101
|
+
self.display_height = display_height
|
|
102
|
+
self.writer, self.needs_resize = create_video_writer(
|
|
103
|
+
output_path, width, height, display_width, display_height, fps
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
def write_frame(self, frame: np.ndarray) -> None:
|
|
107
|
+
"""
|
|
108
|
+
Write frame to output video.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
frame: Video frame with shape (height, width, 3)
|
|
112
|
+
|
|
113
|
+
Raises:
|
|
114
|
+
ValueError: If frame dimensions don't match expected encoded dimensions
|
|
115
|
+
"""
|
|
116
|
+
# Validate frame dimensions match expected encoded dimensions
|
|
117
|
+
frame_height, frame_width = frame.shape[:2]
|
|
118
|
+
if frame_height != self.height or frame_width != self.width:
|
|
119
|
+
raise ValueError(
|
|
120
|
+
f"Frame dimensions ({frame_width}x{frame_height}) don't match "
|
|
121
|
+
f"source dimensions ({self.width}x{self.height}). "
|
|
122
|
+
f"Aspect ratio must be preserved from source video."
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
# Resize to display dimensions if needed (to handle SAR)
|
|
126
|
+
if self.needs_resize:
|
|
127
|
+
frame = cv2.resize(
|
|
128
|
+
frame,
|
|
129
|
+
(self.display_width, self.display_height),
|
|
130
|
+
interpolation=cv2.INTER_LANCZOS4,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
write_overlay_frame(self.writer, frame, self.display_width, self.display_height)
|
|
134
|
+
|
|
135
|
+
def close(self) -> None:
|
|
136
|
+
"""Release video writer."""
|
|
137
|
+
self.writer.release()
|
|
138
|
+
|
|
139
|
+
def __enter__(self) -> "BaseDebugOverlayRenderer":
|
|
140
|
+
return self
|
|
141
|
+
|
|
142
|
+
def __exit__(self, _exc_type, _exc_val, _exc_tb) -> None: # type: ignore[no-untyped-def]
|
|
143
|
+
self.close()
|