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 CHANGED
@@ -1,938 +1,38 @@
1
- "Public API for programmatic use of kinemotion analysis."
2
-
3
- import json
4
- import time
5
- from collections.abc import Callable
6
- from dataclasses import dataclass
7
- from pathlib import Path
8
- from typing import TYPE_CHECKING
9
-
10
- if TYPE_CHECKING:
11
- from numpy.typing import NDArray
12
-
13
- from .cmj.analysis import detect_cmj_phases
14
- from .cmj.debug_overlay import CMJDebugOverlayRenderer
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 .core.pose import PoseTracker
47
- from .core.quality import QualityAssessment, assess_jump_quality
48
- from .core.timing import NULL_TIMER, PerformanceTimer, Timer
49
- from .core.video_io import VideoProcessor
50
- from .dropjump.analysis import (
51
- detect_ground_contact,
52
- find_contact_phases,
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
- return CMJVideoResult(
934
- video_path=config.video_path,
935
- success=False,
936
- error=str(e),
937
- processing_time=processing_time,
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
+ ]