kinemotion 0.47.1__py3-none-any.whl → 0.47.3__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.

@@ -1,5 +1,5 @@
1
1
  """Counter Movement Jump (CMJ) analysis module."""
2
2
 
3
- from kinemotion.cmj.kinematics import CMJMetrics
3
+ from .kinematics import CMJMetrics
4
4
 
5
5
  __all__ = ["CMJMetrics"]
@@ -480,7 +480,7 @@ def compute_average_hip_position(
480
480
  def find_standing_end(
481
481
  velocities: np.ndarray,
482
482
  lowest_point: float,
483
- positions: np.ndarray | None = None,
483
+ _positions: np.ndarray | None = None,
484
484
  accelerations: np.ndarray | None = None,
485
485
  ) -> float | None:
486
486
  """
@@ -493,7 +493,7 @@ def find_standing_end(
493
493
  Args:
494
494
  velocities: Signed velocity array (for backward compatibility)
495
495
  lowest_point: Frame index of lowest point
496
- positions: Position array (unused, kept for backward compatibility)
496
+ _positions: Intentionally unused - kept for backward compatibility
497
497
  accelerations: Acceleration array (if provided, uses
498
498
  acceleration-based detection)
499
499
 
kinemotion/cmj/api.py ADDED
@@ -0,0 +1,433 @@
1
+ """Public API for CMJ (Counter Movement Jump) video 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
+ pass
12
+
13
+ from ..core.auto_tuning import (
14
+ analyze_video_sample,
15
+ auto_tune_parameters,
16
+ )
17
+ from ..core.filtering import reject_outliers
18
+ from ..core.metadata import (
19
+ AlgorithmConfig,
20
+ DetectionConfig,
21
+ ProcessingInfo,
22
+ ResultMetadata,
23
+ SmoothingConfig,
24
+ VideoInfo,
25
+ create_timestamp,
26
+ get_kinemotion_version,
27
+ )
28
+ from ..core.pipeline_utils import (
29
+ apply_expert_overrides,
30
+ apply_smoothing,
31
+ convert_timer_to_stage_names,
32
+ determine_confidence_levels,
33
+ extract_vertical_positions,
34
+ parse_quality_preset,
35
+ print_verbose_parameters,
36
+ process_all_frames,
37
+ process_videos_bulk_generic,
38
+ )
39
+ from ..core.pose import PoseTracker
40
+ from ..core.quality import assess_jump_quality
41
+ from ..core.timing import PerformanceTimer, Timer
42
+ from ..core.video_io import VideoProcessor
43
+ from .analysis import compute_signed_velocity, detect_cmj_phases
44
+ from .debug_overlay import CMJDebugOverlayRenderer
45
+ from .kinematics import CMJMetrics, calculate_cmj_metrics
46
+ from .metrics_validator import CMJMetricsValidator
47
+
48
+
49
+ @dataclass
50
+ class CMJVideoConfig:
51
+ """Configuration for processing a single CMJ video."""
52
+
53
+ video_path: str
54
+ quality: str = "balanced"
55
+ output_video: str | None = None
56
+ json_output: str | None = None
57
+ smoothing_window: int | None = None
58
+ velocity_threshold: float | None = None
59
+ min_contact_frames: int | None = None
60
+ visibility_threshold: float | None = None
61
+ detection_confidence: float | None = None
62
+ tracking_confidence: float | None = None
63
+
64
+
65
+ @dataclass
66
+ class CMJVideoResult:
67
+ """Result of processing a single CMJ video."""
68
+
69
+ video_path: str
70
+ success: bool
71
+ metrics: CMJMetrics | None = None
72
+ error: str | None = None
73
+ processing_time: float = 0.0
74
+
75
+
76
+ def process_cmj_video(
77
+ video_path: str,
78
+ quality: str = "balanced",
79
+ output_video: str | None = None,
80
+ json_output: str | None = None,
81
+ smoothing_window: int | None = None,
82
+ velocity_threshold: float | None = None,
83
+ min_contact_frames: int | None = None,
84
+ visibility_threshold: float | None = None,
85
+ detection_confidence: float | None = None,
86
+ tracking_confidence: float | None = None,
87
+ verbose: bool = False,
88
+ timer: Timer | None = None,
89
+ pose_tracker: "PoseTracker | None" = None,
90
+ ) -> CMJMetrics:
91
+ """
92
+ Process a single CMJ video and return metrics.
93
+
94
+ CMJ (Counter Movement Jump) is performed at floor level without a drop box.
95
+ Athletes start standing, perform a countermovement (eccentric phase), then
96
+ jump upward (concentric phase).
97
+
98
+ Args:
99
+ video_path: Path to the input video file
100
+ quality: Analysis quality preset ("fast", "balanced", or "accurate")
101
+ output_video: Optional path for debug video output
102
+ json_output: Optional path for JSON metrics output
103
+ smoothing_window: Optional override for smoothing window
104
+ velocity_threshold: Optional override for velocity threshold
105
+ min_contact_frames: Optional override for minimum contact frames
106
+ visibility_threshold: Optional override for visibility threshold
107
+ detection_confidence: Optional override for pose detection confidence
108
+ tracking_confidence: Optional override for pose tracking confidence
109
+ verbose: Print processing details
110
+ timer: Optional Timer for measuring operations
111
+ pose_tracker: Optional pre-initialized PoseTracker instance (reused if provided)
112
+
113
+ Returns:
114
+ CMJMetrics object containing analysis results
115
+
116
+ Raises:
117
+ ValueError: If video cannot be processed or parameters are invalid
118
+ FileNotFoundError: If video file does not exist
119
+ """
120
+ if not Path(video_path).exists():
121
+ raise FileNotFoundError(f"Video file not found: {video_path}")
122
+
123
+ start_time = time.time()
124
+ if timer is None:
125
+ timer = PerformanceTimer()
126
+
127
+ quality_preset = parse_quality_preset(quality)
128
+
129
+ with timer.measure("video_initialization"):
130
+ with VideoProcessor(video_path, timer=timer) as video:
131
+ if verbose:
132
+ print(
133
+ f"Video: {video.width}x{video.height} @ {video.fps:.2f} fps, "
134
+ f"{video.frame_count} frames"
135
+ )
136
+
137
+ det_conf, track_conf = determine_confidence_levels(
138
+ quality_preset, detection_confidence, tracking_confidence
139
+ )
140
+
141
+ if verbose:
142
+ print("Processing all frames with MediaPipe pose tracking...")
143
+
144
+ tracker = pose_tracker
145
+ should_close_tracker = False
146
+
147
+ if tracker is None:
148
+ tracker = PoseTracker(
149
+ min_detection_confidence=det_conf,
150
+ min_tracking_confidence=track_conf,
151
+ timer=timer,
152
+ )
153
+ should_close_tracker = True
154
+
155
+ frames, landmarks_sequence, frame_indices = process_all_frames(
156
+ video, tracker, verbose, timer, close_tracker=should_close_tracker
157
+ )
158
+
159
+ with timer.measure("parameter_auto_tuning"):
160
+ characteristics = analyze_video_sample(
161
+ landmarks_sequence, video.fps, video.frame_count
162
+ )
163
+ params = auto_tune_parameters(characteristics, quality_preset)
164
+
165
+ params = apply_expert_overrides(
166
+ params,
167
+ smoothing_window,
168
+ velocity_threshold,
169
+ min_contact_frames,
170
+ visibility_threshold,
171
+ )
172
+
173
+ if verbose:
174
+ print_verbose_parameters(
175
+ video, characteristics, quality_preset, params
176
+ )
177
+
178
+ smoothed_landmarks = apply_smoothing(
179
+ landmarks_sequence, params, verbose, timer
180
+ )
181
+
182
+ if verbose:
183
+ print("Extracting vertical positions (Hip and Foot)...")
184
+ with timer.measure("vertical_position_extraction"):
185
+ vertical_positions, visibilities = extract_vertical_positions(
186
+ smoothed_landmarks, target="hip"
187
+ )
188
+
189
+ foot_positions, _ = extract_vertical_positions(
190
+ smoothed_landmarks, target="foot"
191
+ )
192
+
193
+ tracking_method = "hip_hybrid"
194
+
195
+ if verbose:
196
+ print("Detecting CMJ phases...")
197
+ with timer.measure("phase_detection"):
198
+ phases = detect_cmj_phases(
199
+ vertical_positions,
200
+ video.fps,
201
+ window_length=params.smoothing_window,
202
+ polyorder=params.polyorder,
203
+ landing_positions=foot_positions,
204
+ timer=timer,
205
+ )
206
+
207
+ if phases is None:
208
+ raise ValueError("Could not detect CMJ phases in video")
209
+
210
+ standing_end, lowest_point, takeoff_frame, landing_frame = phases
211
+
212
+ if verbose:
213
+ print("Calculating metrics...")
214
+ with timer.measure("metrics_calculation"):
215
+ velocities = compute_signed_velocity(
216
+ vertical_positions,
217
+ window_length=params.smoothing_window,
218
+ polyorder=params.polyorder,
219
+ )
220
+
221
+ metrics = calculate_cmj_metrics(
222
+ vertical_positions,
223
+ velocities,
224
+ standing_end,
225
+ lowest_point,
226
+ takeoff_frame,
227
+ landing_frame,
228
+ video.fps,
229
+ tracking_method=tracking_method,
230
+ )
231
+
232
+ if verbose:
233
+ print("Assessing tracking quality...")
234
+ with timer.measure("quality_assessment"):
235
+ _, outlier_mask = reject_outliers(
236
+ vertical_positions,
237
+ use_ransac=True,
238
+ use_median=True,
239
+ interpolate=False,
240
+ )
241
+
242
+ phases_detected = True
243
+ phase_count = 4
244
+
245
+ quality_result = assess_jump_quality(
246
+ visibilities=visibilities,
247
+ positions=vertical_positions,
248
+ outlier_mask=outlier_mask,
249
+ fps=video.fps,
250
+ phases_detected=phases_detected,
251
+ phase_count=phase_count,
252
+ )
253
+
254
+ algorithm_config = AlgorithmConfig(
255
+ detection_method="backward_search",
256
+ tracking_method="mediapipe_pose",
257
+ model_complexity=1,
258
+ smoothing=SmoothingConfig(
259
+ window_size=params.smoothing_window,
260
+ polynomial_order=params.polyorder,
261
+ use_bilateral_filter=params.bilateral_filter,
262
+ use_outlier_rejection=params.outlier_rejection,
263
+ ),
264
+ detection=DetectionConfig(
265
+ velocity_threshold=params.velocity_threshold,
266
+ min_contact_frames=params.min_contact_frames,
267
+ visibility_threshold=params.visibility_threshold,
268
+ use_curvature_refinement=params.use_curvature,
269
+ ),
270
+ drop_detection=None,
271
+ )
272
+
273
+ video_info = VideoInfo(
274
+ source_path=video_path,
275
+ fps=video.fps,
276
+ width=video.width,
277
+ height=video.height,
278
+ duration_s=video.frame_count / video.fps,
279
+ frame_count=video.frame_count,
280
+ codec=video.codec,
281
+ )
282
+
283
+ if verbose and quality_result.warnings:
284
+ print("\n⚠️ Quality Warnings:")
285
+ for warning in quality_result.warnings:
286
+ print(f" - {warning}")
287
+ print()
288
+
289
+ if output_video:
290
+ if verbose:
291
+ print(f"Generating debug video: {output_video}")
292
+
293
+ debug_h, debug_w = frames[0].shape[:2]
294
+ step = max(1, int(video.fps / 30.0))
295
+ debug_fps = video.fps / step
296
+
297
+ with timer.measure("debug_video_generation"):
298
+ with CMJDebugOverlayRenderer(
299
+ output_video,
300
+ debug_w,
301
+ debug_h,
302
+ debug_w,
303
+ debug_h,
304
+ debug_fps,
305
+ timer=timer,
306
+ ) as renderer:
307
+ for frame, idx in zip(frames, frame_indices, strict=True):
308
+ annotated = renderer.render_frame(
309
+ frame, smoothed_landmarks[idx], idx, metrics
310
+ )
311
+ renderer.write_frame(annotated)
312
+
313
+ if verbose:
314
+ print(f"Debug video saved: {output_video}")
315
+
316
+ with timer.measure("metrics_validation"):
317
+ validator = CMJMetricsValidator()
318
+ validation_result = validator.validate(metrics.to_dict()) # type: ignore[arg-type]
319
+ metrics.validation_result = validation_result
320
+
321
+ processing_time = time.time() - start_time
322
+ stage_times = convert_timer_to_stage_names(timer.get_metrics())
323
+
324
+ processing_info = ProcessingInfo(
325
+ version=get_kinemotion_version(),
326
+ timestamp=create_timestamp(),
327
+ quality_preset=quality_preset.value,
328
+ processing_time_s=processing_time,
329
+ timing_breakdown=stage_times,
330
+ )
331
+
332
+ result_metadata = ResultMetadata(
333
+ quality=quality_result,
334
+ video=video_info,
335
+ processing=processing_info,
336
+ algorithm=algorithm_config,
337
+ )
338
+
339
+ metrics.result_metadata = result_metadata
340
+
341
+ if json_output:
342
+ with timer.measure("json_serialization"):
343
+ output_path = Path(json_output)
344
+ metrics_dict = metrics.to_dict()
345
+ json_str = json.dumps(metrics_dict, indent=2)
346
+ output_path.write_text(json_str)
347
+
348
+ if verbose:
349
+ print(f"Metrics written to: {json_output}")
350
+
351
+ if verbose and validation_result.issues:
352
+ print("\n⚠️ Validation Results:")
353
+ for issue in validation_result.issues:
354
+ print(f" [{issue.severity.value}] {issue.metric}: {issue.message}")
355
+
356
+ if verbose:
357
+ total_time = time.time() - start_time
358
+ stage_times = convert_timer_to_stage_names(timer.get_metrics())
359
+
360
+ print("\n=== Timing Summary ===")
361
+ for stage, duration in stage_times.items():
362
+ percentage = (duration / total_time) * 100
363
+ dur_ms = duration * 1000
364
+ print(f"{stage:. <40} {dur_ms:>6.0f}ms ({percentage:>5.1f}%)")
365
+ total_ms = total_time * 1000
366
+ print(f"{('Total'):.>40} {total_ms:>6.0f}ms (100.0%)")
367
+ print()
368
+
369
+ print(f"\nJump height: {metrics.jump_height:.3f}m")
370
+ print(f"Flight time: {metrics.flight_time * 1000:.1f}ms")
371
+ print(f"Countermovement depth: {metrics.countermovement_depth:.3f}m")
372
+
373
+ return metrics
374
+
375
+
376
+ def process_cmj_videos_bulk(
377
+ configs: list[CMJVideoConfig],
378
+ max_workers: int = 4,
379
+ progress_callback: Callable[[CMJVideoResult], None] | None = None,
380
+ ) -> list[CMJVideoResult]:
381
+ """
382
+ Process multiple CMJ videos in parallel.
383
+ """
384
+
385
+ def error_factory(video_path: str, error_msg: str) -> CMJVideoResult:
386
+ return CMJVideoResult(video_path=video_path, success=False, error=error_msg)
387
+
388
+ return process_videos_bulk_generic(
389
+ configs,
390
+ _process_cmj_video_wrapper,
391
+ error_factory,
392
+ max_workers,
393
+ progress_callback,
394
+ )
395
+
396
+
397
+ def _process_cmj_video_wrapper(config: CMJVideoConfig) -> CMJVideoResult:
398
+ """Wrapper function for parallel CMJ processing."""
399
+ start_time = time.time()
400
+
401
+ try:
402
+ metrics = process_cmj_video(
403
+ video_path=config.video_path,
404
+ quality=config.quality,
405
+ output_video=config.output_video,
406
+ json_output=config.json_output,
407
+ smoothing_window=config.smoothing_window,
408
+ velocity_threshold=config.velocity_threshold,
409
+ min_contact_frames=config.min_contact_frames,
410
+ visibility_threshold=config.visibility_threshold,
411
+ detection_confidence=config.detection_confidence,
412
+ tracking_confidence=config.tracking_confidence,
413
+ verbose=False,
414
+ )
415
+
416
+ processing_time = time.time() - start_time
417
+
418
+ return CMJVideoResult(
419
+ video_path=config.video_path,
420
+ success=True,
421
+ metrics=metrics,
422
+ processing_time=processing_time,
423
+ )
424
+
425
+ except Exception as e:
426
+ processing_time = time.time() - start_time
427
+
428
+ return CMJVideoResult(
429
+ video_path=config.video_path,
430
+ success=False,
431
+ error=str(e),
432
+ processing_time=processing_time,
433
+ )
kinemotion/cmj/cli.py CHANGED
@@ -6,13 +6,14 @@ from dataclasses import dataclass
6
6
 
7
7
  import click
8
8
 
9
- from ..api import CMJMetrics, process_cmj_video
10
9
  from ..core.auto_tuning import QualityPreset
11
10
  from ..core.cli_utils import (
12
11
  collect_video_files,
13
12
  common_output_options,
14
13
  generate_batch_output_paths,
15
14
  )
15
+ from .api import process_cmj_video
16
+ from .kinematics import CMJMetrics
16
17
 
17
18
 
18
19
  @dataclass