kinemotion 0.47.2__py3-none-any.whl → 0.47.4__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.
- kinemotion/api.py +35 -935
- kinemotion/cmj/__init__.py +1 -1
- kinemotion/cmj/api.py +492 -0
- kinemotion/cmj/cli.py +2 -1
- kinemotion/core/__init__.py +0 -4
- kinemotion/core/timing.py +1 -137
- kinemotion/dropjump/api.py +541 -0
- kinemotion/dropjump/cli.py +5 -5
- {kinemotion-0.47.2.dist-info → kinemotion-0.47.4.dist-info}/METADATA +1 -1
- {kinemotion-0.47.2.dist-info → kinemotion-0.47.4.dist-info}/RECORD +13 -11
- {kinemotion-0.47.2.dist-info → kinemotion-0.47.4.dist-info}/WHEEL +0 -0
- {kinemotion-0.47.2.dist-info → kinemotion-0.47.4.dist-info}/entry_points.txt +0 -0
- {kinemotion-0.47.2.dist-info → kinemotion-0.47.4.dist-info}/licenses/LICENSE +0 -0
kinemotion/api.py
CHANGED
|
@@ -1,938 +1,38 @@
|
|
|
1
|
-
"Public API for programmatic use of kinemotion analysis.
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
from .cmj.kinematics import CMJMetrics, calculate_cmj_metrics
|
|
16
|
-
from .cmj.metrics_validator import CMJMetricsValidator
|
|
17
|
-
from .core.auto_tuning import (
|
|
18
|
-
AnalysisParameters,
|
|
19
|
-
QualityPreset,
|
|
20
|
-
analyze_video_sample,
|
|
21
|
-
auto_tune_parameters,
|
|
22
|
-
)
|
|
23
|
-
from .core.filtering import reject_outliers
|
|
24
|
-
from .core.metadata import (
|
|
25
|
-
AlgorithmConfig,
|
|
26
|
-
DetectionConfig,
|
|
27
|
-
DropDetectionConfig,
|
|
28
|
-
ProcessingInfo,
|
|
29
|
-
ResultMetadata,
|
|
30
|
-
SmoothingConfig,
|
|
31
|
-
VideoInfo,
|
|
32
|
-
create_timestamp,
|
|
33
|
-
get_kinemotion_version,
|
|
34
|
-
)
|
|
35
|
-
from .core.pipeline_utils import (
|
|
36
|
-
apply_expert_overrides,
|
|
37
|
-
apply_smoothing,
|
|
38
|
-
convert_timer_to_stage_names,
|
|
39
|
-
determine_confidence_levels,
|
|
40
|
-
extract_vertical_positions,
|
|
41
|
-
parse_quality_preset,
|
|
42
|
-
print_verbose_parameters,
|
|
43
|
-
process_all_frames,
|
|
44
|
-
process_videos_bulk_generic,
|
|
1
|
+
"""Public API for programmatic use of kinemotion analysis.
|
|
2
|
+
|
|
3
|
+
This module provides a unified interface for both drop jump and CMJ video analysis.
|
|
4
|
+
The actual implementations have been moved to their respective submodules:
|
|
5
|
+
- Drop jump: kinemotion.dropjump.api
|
|
6
|
+
- CMJ: kinemotion.cmj.api
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
# CMJ API
|
|
10
|
+
from .cmj.api import (
|
|
11
|
+
CMJVideoConfig,
|
|
12
|
+
CMJVideoResult,
|
|
13
|
+
process_cmj_video,
|
|
14
|
+
process_cmj_videos_bulk,
|
|
45
15
|
)
|
|
46
|
-
from .
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
from .
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
16
|
+
from .cmj.kinematics import CMJMetrics
|
|
17
|
+
|
|
18
|
+
# Drop jump API
|
|
19
|
+
from .dropjump.api import (
|
|
20
|
+
DropJumpVideoConfig,
|
|
21
|
+
DropJumpVideoResult,
|
|
22
|
+
process_dropjump_video,
|
|
23
|
+
process_dropjump_videos_bulk,
|
|
53
24
|
)
|
|
54
|
-
from .dropjump.debug_overlay import DebugOverlayRenderer
|
|
55
|
-
from .dropjump.kinematics import DropJumpMetrics, calculate_drop_jump_metrics
|
|
56
|
-
from .dropjump.metrics_validator import DropJumpMetricsValidator
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
@dataclass
|
|
60
|
-
class DropJumpVideoResult:
|
|
61
|
-
"""Result of processing a single drop jump video."""
|
|
62
|
-
|
|
63
|
-
video_path: str
|
|
64
|
-
success: bool
|
|
65
|
-
metrics: DropJumpMetrics | None = None
|
|
66
|
-
error: str | None = None
|
|
67
|
-
processing_time: float = 0.0
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
@dataclass
|
|
71
|
-
class DropJumpVideoConfig:
|
|
72
|
-
"""Configuration for processing a single drop jump video."""
|
|
73
|
-
|
|
74
|
-
video_path: str
|
|
75
|
-
quality: str = "balanced"
|
|
76
|
-
output_video: str | None = None
|
|
77
|
-
json_output: str | None = None
|
|
78
|
-
drop_start_frame: int | None = None
|
|
79
|
-
smoothing_window: int | None = None
|
|
80
|
-
velocity_threshold: float | None = None
|
|
81
|
-
min_contact_frames: int | None = None
|
|
82
|
-
visibility_threshold: float | None = None
|
|
83
|
-
detection_confidence: float | None = None
|
|
84
|
-
tracking_confidence: float | None = None
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
def _assess_dropjump_quality(
|
|
88
|
-
vertical_positions: "NDArray",
|
|
89
|
-
visibilities: "NDArray",
|
|
90
|
-
contact_states: list,
|
|
91
|
-
fps: float,
|
|
92
|
-
timer: Timer,
|
|
93
|
-
) -> tuple:
|
|
94
|
-
"""Assess tracking quality and detect phases.
|
|
95
|
-
|
|
96
|
-
Returns:
|
|
97
|
-
Tuple of (quality_result, outlier_mask, phases_detected, phase_count)
|
|
98
|
-
"""
|
|
99
|
-
_, outlier_mask = reject_outliers(
|
|
100
|
-
vertical_positions,
|
|
101
|
-
use_ransac=True,
|
|
102
|
-
use_median=True,
|
|
103
|
-
interpolate=False,
|
|
104
|
-
)
|
|
105
|
-
|
|
106
|
-
phases = find_contact_phases(contact_states)
|
|
107
|
-
phases_detected = len(phases) > 0
|
|
108
|
-
phase_count = len(phases)
|
|
109
|
-
|
|
110
|
-
quality_result = assess_jump_quality(
|
|
111
|
-
visibilities=visibilities,
|
|
112
|
-
positions=vertical_positions,
|
|
113
|
-
outlier_mask=outlier_mask,
|
|
114
|
-
fps=fps,
|
|
115
|
-
phases_detected=phases_detected,
|
|
116
|
-
phase_count=phase_count,
|
|
117
|
-
)
|
|
118
|
-
|
|
119
|
-
return quality_result, outlier_mask, phases_detected, phase_count
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
def _build_dropjump_metadata(
|
|
123
|
-
video_path: str,
|
|
124
|
-
video: "VideoProcessor",
|
|
125
|
-
params: "AnalysisParameters",
|
|
126
|
-
quality_result: QualityAssessment,
|
|
127
|
-
drop_start_frame: int | None,
|
|
128
|
-
metrics: DropJumpMetrics,
|
|
129
|
-
processing_time: float,
|
|
130
|
-
quality_preset: "QualityPreset",
|
|
131
|
-
timer: Timer,
|
|
132
|
-
) -> ResultMetadata:
|
|
133
|
-
"""Build complete result metadata."""
|
|
134
|
-
drop_frame = None
|
|
135
|
-
if drop_start_frame is None and metrics.drop_start_frame is not None:
|
|
136
|
-
drop_frame = metrics.drop_start_frame
|
|
137
|
-
elif drop_start_frame is not None:
|
|
138
|
-
drop_frame = drop_start_frame
|
|
139
|
-
|
|
140
|
-
algorithm_config = AlgorithmConfig(
|
|
141
|
-
detection_method="forward_search",
|
|
142
|
-
tracking_method="mediapipe_pose",
|
|
143
|
-
model_complexity=1,
|
|
144
|
-
smoothing=SmoothingConfig(
|
|
145
|
-
window_size=params.smoothing_window,
|
|
146
|
-
polynomial_order=params.polyorder,
|
|
147
|
-
use_bilateral_filter=params.bilateral_filter,
|
|
148
|
-
use_outlier_rejection=params.outlier_rejection,
|
|
149
|
-
),
|
|
150
|
-
detection=DetectionConfig(
|
|
151
|
-
velocity_threshold=params.velocity_threshold,
|
|
152
|
-
min_contact_frames=params.min_contact_frames,
|
|
153
|
-
visibility_threshold=params.visibility_threshold,
|
|
154
|
-
use_curvature_refinement=params.use_curvature,
|
|
155
|
-
),
|
|
156
|
-
drop_detection=DropDetectionConfig(
|
|
157
|
-
auto_detect_drop_start=(drop_start_frame is None),
|
|
158
|
-
detected_drop_frame=drop_frame,
|
|
159
|
-
min_stationary_duration_s=0.5,
|
|
160
|
-
),
|
|
161
|
-
)
|
|
162
|
-
|
|
163
|
-
video_info = VideoInfo(
|
|
164
|
-
source_path=video_path,
|
|
165
|
-
fps=video.fps,
|
|
166
|
-
width=video.width,
|
|
167
|
-
height=video.height,
|
|
168
|
-
duration_s=video.frame_count / video.fps,
|
|
169
|
-
frame_count=video.frame_count,
|
|
170
|
-
codec=video.codec,
|
|
171
|
-
)
|
|
172
|
-
|
|
173
|
-
stage_times = convert_timer_to_stage_names(timer.get_metrics())
|
|
174
|
-
|
|
175
|
-
processing_info = ProcessingInfo(
|
|
176
|
-
version=get_kinemotion_version(),
|
|
177
|
-
timestamp=create_timestamp(),
|
|
178
|
-
quality_preset=quality_preset.value,
|
|
179
|
-
processing_time_s=processing_time,
|
|
180
|
-
timing_breakdown=stage_times,
|
|
181
|
-
)
|
|
182
|
-
|
|
183
|
-
return ResultMetadata(
|
|
184
|
-
quality=quality_result,
|
|
185
|
-
video=video_info,
|
|
186
|
-
processing=processing_info,
|
|
187
|
-
algorithm=algorithm_config,
|
|
188
|
-
)
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
def _save_dropjump_json(
|
|
192
|
-
json_output: str,
|
|
193
|
-
metrics: DropJumpMetrics,
|
|
194
|
-
timer: Timer,
|
|
195
|
-
verbose: bool,
|
|
196
|
-
) -> None:
|
|
197
|
-
"""Save metrics to JSON file."""
|
|
198
|
-
with timer.measure("json_serialization"):
|
|
199
|
-
output_path = Path(json_output)
|
|
200
|
-
metrics_dict = metrics.to_dict()
|
|
201
|
-
json_str = json.dumps(metrics_dict, indent=2)
|
|
202
|
-
output_path.write_text(json_str)
|
|
203
|
-
|
|
204
|
-
if verbose:
|
|
205
|
-
print(f"Metrics written to: {json_output}")
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
def _print_dropjump_summary(
|
|
209
|
-
start_time: float,
|
|
210
|
-
timer: Timer,
|
|
211
|
-
) -> None:
|
|
212
|
-
"""Print verbose timing summary."""
|
|
213
|
-
total_time = time.time() - start_time
|
|
214
|
-
stage_times = convert_timer_to_stage_names(timer.get_metrics())
|
|
215
|
-
|
|
216
|
-
print("\n=== Timing Summary ===")
|
|
217
|
-
for stage, duration in stage_times.items():
|
|
218
|
-
percentage = (duration / total_time) * 100
|
|
219
|
-
dur_ms = duration * 1000
|
|
220
|
-
print(f"{stage:.<40} {dur_ms:>6.0f}ms ({percentage:>5.1f}%)")
|
|
221
|
-
total_ms = total_time * 1000
|
|
222
|
-
print(f"{('Total'):.>40} {total_ms:>6.0f}ms (100.0%)")
|
|
223
|
-
print()
|
|
224
|
-
print("Analysis complete!")
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
def _generate_debug_video(
|
|
228
|
-
output_video: str,
|
|
229
|
-
frames: list,
|
|
230
|
-
frame_indices: list[int],
|
|
231
|
-
video_fps: float,
|
|
232
|
-
smoothed_landmarks: list,
|
|
233
|
-
contact_states: list,
|
|
234
|
-
metrics: DropJumpMetrics,
|
|
235
|
-
timer: Timer | None,
|
|
236
|
-
verbose: bool,
|
|
237
|
-
) -> None:
|
|
238
|
-
"""Generate debug video with overlay."""
|
|
239
|
-
if verbose:
|
|
240
|
-
print(f"Generating debug video: {output_video}")
|
|
241
|
-
|
|
242
|
-
if not frames:
|
|
243
|
-
return
|
|
244
|
-
|
|
245
|
-
timer = timer or NULL_TIMER
|
|
246
|
-
debug_h, debug_w = frames[0].shape[:2]
|
|
247
|
-
|
|
248
|
-
if video_fps > 30:
|
|
249
|
-
debug_fps = video_fps / (video_fps / 30.0)
|
|
250
|
-
else:
|
|
251
|
-
debug_fps = video_fps
|
|
252
|
-
|
|
253
|
-
if len(frames) < len(smoothed_landmarks):
|
|
254
|
-
step = max(1, int(video_fps / 30.0))
|
|
255
|
-
debug_fps = video_fps / step
|
|
256
|
-
|
|
257
|
-
def _render_frames(renderer: DebugOverlayRenderer) -> None:
|
|
258
|
-
for frame, idx in zip(frames, frame_indices, strict=True):
|
|
259
|
-
annotated = renderer.render_frame(
|
|
260
|
-
frame,
|
|
261
|
-
smoothed_landmarks[idx],
|
|
262
|
-
contact_states[idx],
|
|
263
|
-
idx,
|
|
264
|
-
metrics,
|
|
265
|
-
use_com=False,
|
|
266
|
-
)
|
|
267
|
-
renderer.write_frame(annotated)
|
|
268
|
-
|
|
269
|
-
renderer_context = DebugOverlayRenderer(
|
|
270
|
-
output_video,
|
|
271
|
-
debug_w,
|
|
272
|
-
debug_h,
|
|
273
|
-
debug_w,
|
|
274
|
-
debug_h,
|
|
275
|
-
debug_fps,
|
|
276
|
-
timer=timer,
|
|
277
|
-
)
|
|
278
|
-
|
|
279
|
-
with timer.measure("debug_video_generation"):
|
|
280
|
-
with renderer_context as renderer:
|
|
281
|
-
_render_frames(renderer)
|
|
282
|
-
|
|
283
|
-
if verbose:
|
|
284
|
-
print(f"Debug video saved: {output_video}")
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
def process_dropjump_video(
|
|
288
|
-
video_path: str,
|
|
289
|
-
quality: str = "balanced",
|
|
290
|
-
output_video: str | None = None,
|
|
291
|
-
json_output: str | None = None,
|
|
292
|
-
drop_start_frame: int | None = None,
|
|
293
|
-
smoothing_window: int | None = None,
|
|
294
|
-
velocity_threshold: float | None = None,
|
|
295
|
-
min_contact_frames: int | None = None,
|
|
296
|
-
visibility_threshold: float | None = None,
|
|
297
|
-
detection_confidence: float | None = None,
|
|
298
|
-
tracking_confidence: float | None = None,
|
|
299
|
-
verbose: bool = False,
|
|
300
|
-
timer: Timer | None = None,
|
|
301
|
-
pose_tracker: "PoseTracker | None" = None,
|
|
302
|
-
) -> DropJumpMetrics:
|
|
303
|
-
"""
|
|
304
|
-
Process a single drop jump video and return metrics.
|
|
305
|
-
|
|
306
|
-
Jump height is calculated from flight time using kinematic formula (h = g*t²/8).
|
|
307
|
-
|
|
308
|
-
Args:
|
|
309
|
-
video_path: Path to the input video file
|
|
310
|
-
quality: Analysis quality preset ("fast", "balanced", or "accurate")
|
|
311
|
-
output_video: Optional path for debug video output
|
|
312
|
-
json_output: Optional path for JSON metrics output
|
|
313
|
-
drop_start_frame: Optional manual drop start frame
|
|
314
|
-
smoothing_window: Optional override for smoothing window
|
|
315
|
-
velocity_threshold: Optional override for velocity threshold
|
|
316
|
-
min_contact_frames: Optional override for minimum contact frames
|
|
317
|
-
visibility_threshold: Optional override for visibility threshold
|
|
318
|
-
detection_confidence: Optional override for pose detection confidence
|
|
319
|
-
tracking_confidence: Optional override for pose tracking confidence
|
|
320
|
-
verbose: Print processing details
|
|
321
|
-
timer: Optional Timer for measuring operations
|
|
322
|
-
pose_tracker: Optional pre-initialized PoseTracker instance (reused if provided)
|
|
323
|
-
|
|
324
|
-
Returns:
|
|
325
|
-
DropJumpMetrics object containing analysis results
|
|
326
|
-
|
|
327
|
-
Raises:
|
|
328
|
-
ValueError: If video cannot be processed or parameters are invalid
|
|
329
|
-
FileNotFoundError: If video file does not exist
|
|
330
|
-
"""
|
|
331
|
-
if not Path(video_path).exists():
|
|
332
|
-
raise FileNotFoundError(f"Video file not found: {video_path}")
|
|
333
|
-
|
|
334
|
-
from .core.determinism import set_deterministic_mode
|
|
335
|
-
|
|
336
|
-
set_deterministic_mode(seed=42)
|
|
337
|
-
|
|
338
|
-
start_time = time.time()
|
|
339
|
-
if timer is None:
|
|
340
|
-
timer = PerformanceTimer()
|
|
341
|
-
|
|
342
|
-
quality_preset = parse_quality_preset(quality)
|
|
343
|
-
|
|
344
|
-
with timer.measure("video_initialization"):
|
|
345
|
-
with VideoProcessor(video_path, timer=timer) as video:
|
|
346
|
-
detection_conf, tracking_conf = determine_confidence_levels(
|
|
347
|
-
quality_preset, detection_confidence, tracking_confidence
|
|
348
|
-
)
|
|
349
|
-
|
|
350
|
-
if verbose:
|
|
351
|
-
print("Processing all frames with MediaPipe pose tracking...")
|
|
352
|
-
|
|
353
|
-
tracker = pose_tracker
|
|
354
|
-
should_close_tracker = False
|
|
355
|
-
|
|
356
|
-
if tracker is None:
|
|
357
|
-
tracker = PoseTracker(
|
|
358
|
-
min_detection_confidence=detection_conf,
|
|
359
|
-
min_tracking_confidence=tracking_conf,
|
|
360
|
-
timer=timer,
|
|
361
|
-
)
|
|
362
|
-
should_close_tracker = True
|
|
363
|
-
|
|
364
|
-
frames, landmarks_sequence, frame_indices = process_all_frames(
|
|
365
|
-
video, tracker, verbose, timer, close_tracker=should_close_tracker
|
|
366
|
-
)
|
|
367
|
-
|
|
368
|
-
with timer.measure("parameter_auto_tuning"):
|
|
369
|
-
characteristics = analyze_video_sample(
|
|
370
|
-
landmarks_sequence, video.fps, video.frame_count
|
|
371
|
-
)
|
|
372
|
-
params = auto_tune_parameters(characteristics, quality_preset)
|
|
373
|
-
|
|
374
|
-
params = apply_expert_overrides(
|
|
375
|
-
params,
|
|
376
|
-
smoothing_window,
|
|
377
|
-
velocity_threshold,
|
|
378
|
-
min_contact_frames,
|
|
379
|
-
visibility_threshold,
|
|
380
|
-
)
|
|
381
|
-
|
|
382
|
-
if verbose:
|
|
383
|
-
print_verbose_parameters(
|
|
384
|
-
video, characteristics, quality_preset, params
|
|
385
|
-
)
|
|
386
|
-
|
|
387
|
-
smoothed_landmarks = apply_smoothing(
|
|
388
|
-
landmarks_sequence, params, verbose, timer
|
|
389
|
-
)
|
|
390
|
-
|
|
391
|
-
if verbose:
|
|
392
|
-
print("Extracting foot positions...")
|
|
393
|
-
with timer.measure("vertical_position_extraction"):
|
|
394
|
-
vertical_positions, visibilities = extract_vertical_positions(
|
|
395
|
-
smoothed_landmarks
|
|
396
|
-
)
|
|
397
|
-
|
|
398
|
-
if verbose:
|
|
399
|
-
print("Detecting ground contact...")
|
|
400
|
-
with timer.measure("ground_contact_detection"):
|
|
401
|
-
contact_states = detect_ground_contact(
|
|
402
|
-
vertical_positions,
|
|
403
|
-
velocity_threshold=params.velocity_threshold,
|
|
404
|
-
min_contact_frames=params.min_contact_frames,
|
|
405
|
-
visibility_threshold=params.visibility_threshold,
|
|
406
|
-
visibilities=visibilities,
|
|
407
|
-
window_length=params.smoothing_window,
|
|
408
|
-
polyorder=params.polyorder,
|
|
409
|
-
timer=timer,
|
|
410
|
-
)
|
|
411
|
-
|
|
412
|
-
if verbose:
|
|
413
|
-
print("Calculating metrics...")
|
|
414
|
-
with timer.measure("metrics_calculation"):
|
|
415
|
-
metrics = calculate_drop_jump_metrics(
|
|
416
|
-
contact_states,
|
|
417
|
-
vertical_positions,
|
|
418
|
-
video.fps,
|
|
419
|
-
drop_start_frame=drop_start_frame,
|
|
420
|
-
velocity_threshold=params.velocity_threshold,
|
|
421
|
-
smoothing_window=params.smoothing_window,
|
|
422
|
-
polyorder=params.polyorder,
|
|
423
|
-
use_curvature=params.use_curvature,
|
|
424
|
-
timer=timer,
|
|
425
|
-
)
|
|
426
|
-
|
|
427
|
-
if verbose:
|
|
428
|
-
print("Assessing tracking quality...")
|
|
429
|
-
with timer.measure("quality_assessment"):
|
|
430
|
-
quality_result, _, _, _ = _assess_dropjump_quality(
|
|
431
|
-
vertical_positions, visibilities, contact_states, video.fps, timer
|
|
432
|
-
)
|
|
433
|
-
|
|
434
|
-
if verbose and quality_result.warnings:
|
|
435
|
-
print("\n⚠️ Quality Warnings:")
|
|
436
|
-
for warning in quality_result.warnings:
|
|
437
|
-
print(f" - {warning}")
|
|
438
|
-
print()
|
|
439
|
-
|
|
440
|
-
if output_video:
|
|
441
|
-
_generate_debug_video(
|
|
442
|
-
output_video,
|
|
443
|
-
frames,
|
|
444
|
-
frame_indices,
|
|
445
|
-
video.fps,
|
|
446
|
-
smoothed_landmarks,
|
|
447
|
-
contact_states,
|
|
448
|
-
metrics,
|
|
449
|
-
timer,
|
|
450
|
-
verbose,
|
|
451
|
-
)
|
|
452
|
-
|
|
453
|
-
with timer.measure("metrics_validation"):
|
|
454
|
-
validator = DropJumpMetricsValidator()
|
|
455
|
-
validation_result = validator.validate(metrics.to_dict()) # type: ignore[arg-type]
|
|
456
|
-
metrics.validation_result = validation_result
|
|
457
|
-
|
|
458
|
-
if verbose and validation_result.issues:
|
|
459
|
-
print("\n⚠️ Validation Results:")
|
|
460
|
-
for issue in validation_result.issues:
|
|
461
|
-
print(f" [{issue.severity.value}] {issue.metric}: {issue.message}")
|
|
462
|
-
|
|
463
|
-
processing_time = time.time() - start_time
|
|
464
|
-
result_metadata = _build_dropjump_metadata(
|
|
465
|
-
video_path,
|
|
466
|
-
video,
|
|
467
|
-
params,
|
|
468
|
-
quality_result,
|
|
469
|
-
drop_start_frame,
|
|
470
|
-
metrics,
|
|
471
|
-
processing_time,
|
|
472
|
-
quality_preset,
|
|
473
|
-
timer,
|
|
474
|
-
)
|
|
475
|
-
metrics.result_metadata = result_metadata
|
|
476
|
-
|
|
477
|
-
if json_output:
|
|
478
|
-
_save_dropjump_json(json_output, metrics, timer, verbose)
|
|
479
|
-
|
|
480
|
-
if verbose:
|
|
481
|
-
_print_dropjump_summary(start_time, timer)
|
|
482
|
-
|
|
483
|
-
return metrics
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
def process_dropjump_videos_bulk(
|
|
487
|
-
configs: list[DropJumpVideoConfig],
|
|
488
|
-
max_workers: int = 4,
|
|
489
|
-
progress_callback: Callable[[DropJumpVideoResult], None] | None = None,
|
|
490
|
-
) -> list[DropJumpVideoResult]:
|
|
491
|
-
"""
|
|
492
|
-
Process multiple drop jump videos in parallel.
|
|
493
|
-
"""
|
|
494
|
-
|
|
495
|
-
def error_factory(video_path: str, error_msg: str) -> DropJumpVideoResult:
|
|
496
|
-
return DropJumpVideoResult(
|
|
497
|
-
video_path=video_path, success=False, error=error_msg
|
|
498
|
-
)
|
|
499
|
-
|
|
500
|
-
return process_videos_bulk_generic(
|
|
501
|
-
configs,
|
|
502
|
-
_process_dropjump_video_wrapper,
|
|
503
|
-
error_factory,
|
|
504
|
-
max_workers,
|
|
505
|
-
progress_callback,
|
|
506
|
-
)
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
def _process_dropjump_video_wrapper(config: DropJumpVideoConfig) -> DropJumpVideoResult:
|
|
510
|
-
"""Wrapper function for parallel processing."""
|
|
511
|
-
start_time = time.time()
|
|
512
|
-
|
|
513
|
-
try:
|
|
514
|
-
metrics = process_dropjump_video(
|
|
515
|
-
video_path=config.video_path,
|
|
516
|
-
quality=config.quality,
|
|
517
|
-
output_video=config.output_video,
|
|
518
|
-
json_output=config.json_output,
|
|
519
|
-
drop_start_frame=config.drop_start_frame,
|
|
520
|
-
smoothing_window=config.smoothing_window,
|
|
521
|
-
velocity_threshold=config.velocity_threshold,
|
|
522
|
-
min_contact_frames=config.min_contact_frames,
|
|
523
|
-
visibility_threshold=config.visibility_threshold,
|
|
524
|
-
detection_confidence=config.detection_confidence,
|
|
525
|
-
tracking_confidence=config.tracking_confidence,
|
|
526
|
-
verbose=False,
|
|
527
|
-
)
|
|
528
|
-
|
|
529
|
-
processing_time = time.time() - start_time
|
|
530
|
-
|
|
531
|
-
return DropJumpVideoResult(
|
|
532
|
-
video_path=config.video_path,
|
|
533
|
-
success=True,
|
|
534
|
-
metrics=metrics,
|
|
535
|
-
processing_time=processing_time,
|
|
536
|
-
)
|
|
537
|
-
|
|
538
|
-
except Exception as e:
|
|
539
|
-
processing_time = time.time() - start_time
|
|
540
|
-
|
|
541
|
-
return DropJumpVideoResult(
|
|
542
|
-
video_path=config.video_path,
|
|
543
|
-
success=False,
|
|
544
|
-
error=str(e),
|
|
545
|
-
processing_time=processing_time,
|
|
546
|
-
)
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
# ========== CMJ Analysis API ==========
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
@dataclass
|
|
553
|
-
class CMJVideoConfig:
|
|
554
|
-
"""Configuration for processing a single CMJ video."""
|
|
555
|
-
|
|
556
|
-
video_path: str
|
|
557
|
-
quality: str = "balanced"
|
|
558
|
-
output_video: str | None = None
|
|
559
|
-
json_output: str | None = None
|
|
560
|
-
smoothing_window: int | None = None
|
|
561
|
-
velocity_threshold: float | None = None
|
|
562
|
-
min_contact_frames: int | None = None
|
|
563
|
-
visibility_threshold: float | None = None
|
|
564
|
-
detection_confidence: float | None = None
|
|
565
|
-
tracking_confidence: float | None = None
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
@dataclass
|
|
569
|
-
class CMJVideoResult:
|
|
570
|
-
"""Result of processing a single CMJ video."""
|
|
571
|
-
|
|
572
|
-
video_path: str
|
|
573
|
-
success: bool
|
|
574
|
-
metrics: CMJMetrics | None = None
|
|
575
|
-
error: str | None = None
|
|
576
|
-
processing_time: float = 0.0
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
def process_cmj_video(
|
|
580
|
-
video_path: str,
|
|
581
|
-
quality: str = "balanced",
|
|
582
|
-
output_video: str | None = None,
|
|
583
|
-
json_output: str | None = None,
|
|
584
|
-
smoothing_window: int | None = None,
|
|
585
|
-
velocity_threshold: float | None = None,
|
|
586
|
-
min_contact_frames: int | None = None,
|
|
587
|
-
visibility_threshold: float | None = None,
|
|
588
|
-
detection_confidence: float | None = None,
|
|
589
|
-
tracking_confidence: float | None = None,
|
|
590
|
-
verbose: bool = False,
|
|
591
|
-
timer: Timer | None = None,
|
|
592
|
-
pose_tracker: "PoseTracker | None" = None,
|
|
593
|
-
) -> CMJMetrics:
|
|
594
|
-
"""
|
|
595
|
-
Process a single CMJ video and return metrics.
|
|
596
|
-
|
|
597
|
-
CMJ (Counter Movement Jump) is performed at floor level without a drop box.
|
|
598
|
-
Athletes start standing, perform a countermovement (eccentric phase), then
|
|
599
|
-
jump upward (concentric phase).
|
|
600
|
-
|
|
601
|
-
Args:
|
|
602
|
-
video_path: Path to the input video file
|
|
603
|
-
quality: Analysis quality preset ("fast", "balanced", or "accurate")
|
|
604
|
-
output_video: Optional path for debug video output
|
|
605
|
-
json_output: Optional path for JSON metrics output
|
|
606
|
-
smoothing_window: Optional override for smoothing window
|
|
607
|
-
velocity_threshold: Optional override for velocity threshold
|
|
608
|
-
min_contact_frames: Optional override for minimum contact frames
|
|
609
|
-
visibility_threshold: Optional override for visibility threshold
|
|
610
|
-
detection_confidence: Optional override for pose detection confidence
|
|
611
|
-
tracking_confidence: Optional override for pose tracking confidence
|
|
612
|
-
verbose: Print processing details
|
|
613
|
-
timer: Optional Timer for measuring operations
|
|
614
|
-
pose_tracker: Optional pre-initialized PoseTracker instance (reused if provided)
|
|
615
|
-
|
|
616
|
-
Returns:
|
|
617
|
-
CMJMetrics object containing analysis results
|
|
618
|
-
|
|
619
|
-
Raises:
|
|
620
|
-
ValueError: If video cannot be processed or parameters are invalid
|
|
621
|
-
FileNotFoundError: If video file does not exist
|
|
622
|
-
"""
|
|
623
|
-
if not Path(video_path).exists():
|
|
624
|
-
raise FileNotFoundError(f"Video file not found: {video_path}")
|
|
625
|
-
|
|
626
|
-
start_time = time.time()
|
|
627
|
-
if timer is None:
|
|
628
|
-
timer = PerformanceTimer()
|
|
629
|
-
|
|
630
|
-
quality_preset = parse_quality_preset(quality)
|
|
631
|
-
|
|
632
|
-
with timer.measure("video_initialization"):
|
|
633
|
-
with VideoProcessor(video_path, timer=timer) as video:
|
|
634
|
-
if verbose:
|
|
635
|
-
print(
|
|
636
|
-
f"Video: {video.width}x{video.height} @ {video.fps:.2f} fps, "
|
|
637
|
-
f"{video.frame_count} frames"
|
|
638
|
-
)
|
|
639
|
-
|
|
640
|
-
det_conf, track_conf = determine_confidence_levels(
|
|
641
|
-
quality_preset, detection_confidence, tracking_confidence
|
|
642
|
-
)
|
|
643
|
-
|
|
644
|
-
if verbose:
|
|
645
|
-
print("Processing all frames with MediaPipe pose tracking...")
|
|
646
|
-
|
|
647
|
-
tracker = pose_tracker
|
|
648
|
-
should_close_tracker = False
|
|
649
|
-
|
|
650
|
-
if tracker is None:
|
|
651
|
-
tracker = PoseTracker(
|
|
652
|
-
min_detection_confidence=det_conf,
|
|
653
|
-
min_tracking_confidence=track_conf,
|
|
654
|
-
timer=timer,
|
|
655
|
-
)
|
|
656
|
-
should_close_tracker = True
|
|
657
|
-
|
|
658
|
-
frames, landmarks_sequence, frame_indices = process_all_frames(
|
|
659
|
-
video, tracker, verbose, timer, close_tracker=should_close_tracker
|
|
660
|
-
)
|
|
661
|
-
|
|
662
|
-
with timer.measure("parameter_auto_tuning"):
|
|
663
|
-
characteristics = analyze_video_sample(
|
|
664
|
-
landmarks_sequence, video.fps, video.frame_count
|
|
665
|
-
)
|
|
666
|
-
params = auto_tune_parameters(characteristics, quality_preset)
|
|
667
|
-
|
|
668
|
-
params = apply_expert_overrides(
|
|
669
|
-
params,
|
|
670
|
-
smoothing_window,
|
|
671
|
-
velocity_threshold,
|
|
672
|
-
min_contact_frames,
|
|
673
|
-
visibility_threshold,
|
|
674
|
-
)
|
|
675
|
-
|
|
676
|
-
if verbose:
|
|
677
|
-
print_verbose_parameters(
|
|
678
|
-
video, characteristics, quality_preset, params
|
|
679
|
-
)
|
|
680
|
-
|
|
681
|
-
smoothed_landmarks = apply_smoothing(
|
|
682
|
-
landmarks_sequence, params, verbose, timer
|
|
683
|
-
)
|
|
684
|
-
|
|
685
|
-
if verbose:
|
|
686
|
-
print("Extracting vertical positions (Hip and Foot)...")
|
|
687
|
-
with timer.measure("vertical_position_extraction"):
|
|
688
|
-
vertical_positions, visibilities = extract_vertical_positions(
|
|
689
|
-
smoothed_landmarks, target="hip"
|
|
690
|
-
)
|
|
691
|
-
|
|
692
|
-
foot_positions, _ = extract_vertical_positions(
|
|
693
|
-
smoothed_landmarks, target="foot"
|
|
694
|
-
)
|
|
695
|
-
|
|
696
|
-
tracking_method = "hip_hybrid"
|
|
697
|
-
|
|
698
|
-
if verbose:
|
|
699
|
-
print("Detecting CMJ phases...")
|
|
700
|
-
with timer.measure("phase_detection"):
|
|
701
|
-
phases = detect_cmj_phases(
|
|
702
|
-
vertical_positions,
|
|
703
|
-
video.fps,
|
|
704
|
-
window_length=params.smoothing_window,
|
|
705
|
-
polyorder=params.polyorder,
|
|
706
|
-
landing_positions=foot_positions,
|
|
707
|
-
timer=timer,
|
|
708
|
-
)
|
|
709
|
-
|
|
710
|
-
if phases is None:
|
|
711
|
-
raise ValueError("Could not detect CMJ phases in video")
|
|
712
|
-
|
|
713
|
-
standing_end, lowest_point, takeoff_frame, landing_frame = phases
|
|
714
|
-
|
|
715
|
-
if verbose:
|
|
716
|
-
print("Calculating metrics...")
|
|
717
|
-
with timer.measure("metrics_calculation"):
|
|
718
|
-
from .cmj.analysis import compute_signed_velocity
|
|
719
|
-
|
|
720
|
-
velocities = compute_signed_velocity(
|
|
721
|
-
vertical_positions,
|
|
722
|
-
window_length=params.smoothing_window,
|
|
723
|
-
polyorder=params.polyorder,
|
|
724
|
-
)
|
|
725
|
-
|
|
726
|
-
metrics = calculate_cmj_metrics(
|
|
727
|
-
vertical_positions,
|
|
728
|
-
velocities,
|
|
729
|
-
standing_end,
|
|
730
|
-
lowest_point,
|
|
731
|
-
takeoff_frame,
|
|
732
|
-
landing_frame,
|
|
733
|
-
video.fps,
|
|
734
|
-
tracking_method=tracking_method,
|
|
735
|
-
)
|
|
736
|
-
|
|
737
|
-
if verbose:
|
|
738
|
-
print("Assessing tracking quality...")
|
|
739
|
-
with timer.measure("quality_assessment"):
|
|
740
|
-
_, outlier_mask = reject_outliers(
|
|
741
|
-
vertical_positions,
|
|
742
|
-
use_ransac=True,
|
|
743
|
-
use_median=True,
|
|
744
|
-
interpolate=False,
|
|
745
|
-
)
|
|
746
|
-
|
|
747
|
-
phases_detected = True
|
|
748
|
-
phase_count = 4
|
|
749
|
-
|
|
750
|
-
quality_result = assess_jump_quality(
|
|
751
|
-
visibilities=visibilities,
|
|
752
|
-
positions=vertical_positions,
|
|
753
|
-
outlier_mask=outlier_mask,
|
|
754
|
-
fps=video.fps,
|
|
755
|
-
phases_detected=phases_detected,
|
|
756
|
-
phase_count=phase_count,
|
|
757
|
-
)
|
|
758
|
-
|
|
759
|
-
algorithm_config = AlgorithmConfig(
|
|
760
|
-
detection_method="backward_search",
|
|
761
|
-
tracking_method="mediapipe_pose",
|
|
762
|
-
model_complexity=1,
|
|
763
|
-
smoothing=SmoothingConfig(
|
|
764
|
-
window_size=params.smoothing_window,
|
|
765
|
-
polynomial_order=params.polyorder,
|
|
766
|
-
use_bilateral_filter=params.bilateral_filter,
|
|
767
|
-
use_outlier_rejection=params.outlier_rejection,
|
|
768
|
-
),
|
|
769
|
-
detection=DetectionConfig(
|
|
770
|
-
velocity_threshold=params.velocity_threshold,
|
|
771
|
-
min_contact_frames=params.min_contact_frames,
|
|
772
|
-
visibility_threshold=params.visibility_threshold,
|
|
773
|
-
use_curvature_refinement=params.use_curvature,
|
|
774
|
-
),
|
|
775
|
-
drop_detection=None,
|
|
776
|
-
)
|
|
777
|
-
|
|
778
|
-
video_info = VideoInfo(
|
|
779
|
-
source_path=video_path,
|
|
780
|
-
fps=video.fps,
|
|
781
|
-
width=video.width,
|
|
782
|
-
height=video.height,
|
|
783
|
-
duration_s=video.frame_count / video.fps,
|
|
784
|
-
frame_count=video.frame_count,
|
|
785
|
-
codec=video.codec,
|
|
786
|
-
)
|
|
787
|
-
|
|
788
|
-
if verbose and quality_result.warnings:
|
|
789
|
-
print("\n⚠️ Quality Warnings:")
|
|
790
|
-
for warning in quality_result.warnings:
|
|
791
|
-
print(f" - {warning}")
|
|
792
|
-
print()
|
|
793
|
-
|
|
794
|
-
if output_video:
|
|
795
|
-
if verbose:
|
|
796
|
-
print(f"Generating debug video: {output_video}")
|
|
797
|
-
|
|
798
|
-
debug_h, debug_w = frames[0].shape[:2]
|
|
799
|
-
step = max(1, int(video.fps / 30.0))
|
|
800
|
-
debug_fps = video.fps / step
|
|
801
|
-
|
|
802
|
-
with timer.measure("debug_video_generation"):
|
|
803
|
-
with CMJDebugOverlayRenderer(
|
|
804
|
-
output_video,
|
|
805
|
-
debug_w,
|
|
806
|
-
debug_h,
|
|
807
|
-
debug_w,
|
|
808
|
-
debug_h,
|
|
809
|
-
debug_fps,
|
|
810
|
-
timer=timer,
|
|
811
|
-
) as renderer:
|
|
812
|
-
for frame, idx in zip(frames, frame_indices, strict=True):
|
|
813
|
-
annotated = renderer.render_frame(
|
|
814
|
-
frame, smoothed_landmarks[idx], idx, metrics
|
|
815
|
-
)
|
|
816
|
-
renderer.write_frame(annotated)
|
|
817
|
-
|
|
818
|
-
if verbose:
|
|
819
|
-
print(f"Debug video saved: {output_video}")
|
|
820
|
-
|
|
821
|
-
with timer.measure("metrics_validation"):
|
|
822
|
-
validator = CMJMetricsValidator()
|
|
823
|
-
validation_result = validator.validate(metrics.to_dict()) # type: ignore[arg-type]
|
|
824
|
-
metrics.validation_result = validation_result
|
|
825
|
-
|
|
826
|
-
processing_time = time.time() - start_time
|
|
827
|
-
stage_times = convert_timer_to_stage_names(timer.get_metrics())
|
|
828
|
-
|
|
829
|
-
processing_info = ProcessingInfo(
|
|
830
|
-
version=get_kinemotion_version(),
|
|
831
|
-
timestamp=create_timestamp(),
|
|
832
|
-
quality_preset=quality_preset.value,
|
|
833
|
-
processing_time_s=processing_time,
|
|
834
|
-
timing_breakdown=stage_times,
|
|
835
|
-
)
|
|
836
|
-
|
|
837
|
-
result_metadata = ResultMetadata(
|
|
838
|
-
quality=quality_result,
|
|
839
|
-
video=video_info,
|
|
840
|
-
processing=processing_info,
|
|
841
|
-
algorithm=algorithm_config,
|
|
842
|
-
)
|
|
843
|
-
|
|
844
|
-
metrics.result_metadata = result_metadata
|
|
845
|
-
|
|
846
|
-
if json_output:
|
|
847
|
-
with timer.measure("json_serialization"):
|
|
848
|
-
output_path = Path(json_output)
|
|
849
|
-
metrics_dict = metrics.to_dict()
|
|
850
|
-
json_str = json.dumps(metrics_dict, indent=2)
|
|
851
|
-
output_path.write_text(json_str)
|
|
852
|
-
|
|
853
|
-
if verbose:
|
|
854
|
-
print(f"Metrics written to: {json_output}")
|
|
855
|
-
|
|
856
|
-
if verbose and validation_result.issues:
|
|
857
|
-
print("\n⚠️ Validation Results:")
|
|
858
|
-
for issue in validation_result.issues:
|
|
859
|
-
print(f" [{issue.severity.value}] {issue.metric}: {issue.message}")
|
|
860
|
-
|
|
861
|
-
if verbose:
|
|
862
|
-
total_time = time.time() - start_time
|
|
863
|
-
stage_times = convert_timer_to_stage_names(timer.get_metrics())
|
|
864
|
-
|
|
865
|
-
print("\n=== Timing Summary ===")
|
|
866
|
-
for stage, duration in stage_times.items():
|
|
867
|
-
percentage = (duration / total_time) * 100
|
|
868
|
-
dur_ms = duration * 1000
|
|
869
|
-
print(f"{stage:. <40} {dur_ms:>6.0f}ms ({percentage:>5.1f}%)")
|
|
870
|
-
total_ms = total_time * 1000
|
|
871
|
-
print(f"{('Total'):.>40} {total_ms:>6.0f}ms (100.0%)")
|
|
872
|
-
print()
|
|
873
|
-
|
|
874
|
-
print(f"\nJump height: {metrics.jump_height:.3f}m")
|
|
875
|
-
print(f"Flight time: {metrics.flight_time * 1000:.1f}ms")
|
|
876
|
-
print(f"Countermovement depth: {metrics.countermovement_depth:.3f}m")
|
|
877
|
-
|
|
878
|
-
return metrics
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
def process_cmj_videos_bulk(
|
|
882
|
-
configs: list[CMJVideoConfig],
|
|
883
|
-
max_workers: int = 4,
|
|
884
|
-
progress_callback: Callable[[CMJVideoResult], None] | None = None,
|
|
885
|
-
) -> list[CMJVideoResult]:
|
|
886
|
-
"""
|
|
887
|
-
Process multiple CMJ videos in parallel.
|
|
888
|
-
"""
|
|
889
|
-
|
|
890
|
-
def error_factory(video_path: str, error_msg: str) -> CMJVideoResult:
|
|
891
|
-
return CMJVideoResult(video_path=video_path, success=False, error=error_msg)
|
|
892
|
-
|
|
893
|
-
return process_videos_bulk_generic(
|
|
894
|
-
configs,
|
|
895
|
-
_process_cmj_video_wrapper,
|
|
896
|
-
error_factory,
|
|
897
|
-
max_workers,
|
|
898
|
-
progress_callback,
|
|
899
|
-
)
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
def _process_cmj_video_wrapper(config: CMJVideoConfig) -> CMJVideoResult:
|
|
903
|
-
"""Wrapper function for parallel CMJ processing."""
|
|
904
|
-
start_time = time.time()
|
|
905
|
-
|
|
906
|
-
try:
|
|
907
|
-
metrics = process_cmj_video(
|
|
908
|
-
video_path=config.video_path,
|
|
909
|
-
quality=config.quality,
|
|
910
|
-
output_video=config.output_video,
|
|
911
|
-
json_output=config.json_output,
|
|
912
|
-
smoothing_window=config.smoothing_window,
|
|
913
|
-
velocity_threshold=config.velocity_threshold,
|
|
914
|
-
min_contact_frames=config.min_contact_frames,
|
|
915
|
-
visibility_threshold=config.visibility_threshold,
|
|
916
|
-
detection_confidence=config.detection_confidence,
|
|
917
|
-
tracking_confidence=config.tracking_confidence,
|
|
918
|
-
verbose=False,
|
|
919
|
-
)
|
|
920
|
-
|
|
921
|
-
processing_time = time.time() - start_time
|
|
922
|
-
|
|
923
|
-
return CMJVideoResult(
|
|
924
|
-
video_path=config.video_path,
|
|
925
|
-
success=True,
|
|
926
|
-
metrics=metrics,
|
|
927
|
-
processing_time=processing_time,
|
|
928
|
-
)
|
|
929
|
-
|
|
930
|
-
except Exception as e:
|
|
931
|
-
processing_time = time.time() - start_time
|
|
932
25
|
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
26
|
+
__all__ = [
|
|
27
|
+
# Drop jump
|
|
28
|
+
"DropJumpVideoConfig",
|
|
29
|
+
"DropJumpVideoResult",
|
|
30
|
+
"process_dropjump_video",
|
|
31
|
+
"process_dropjump_videos_bulk",
|
|
32
|
+
# CMJ
|
|
33
|
+
"CMJMetrics",
|
|
34
|
+
"CMJVideoConfig",
|
|
35
|
+
"CMJVideoResult",
|
|
36
|
+
"process_cmj_video",
|
|
37
|
+
"process_cmj_videos_bulk",
|
|
38
|
+
]
|