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/cmj/cli.py ADDED
@@ -0,0 +1,543 @@
1
+ """Command-line interface for counter movement jump (CMJ) analysis."""
2
+
3
+ import glob
4
+ import json
5
+ import sys
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import click
11
+ import numpy as np
12
+
13
+ from ..core.auto_tuning import (
14
+ QualityPreset,
15
+ analyze_video_sample,
16
+ auto_tune_parameters,
17
+ )
18
+ from ..core.cli_utils import (
19
+ apply_expert_param_overrides,
20
+ common_output_options,
21
+ determine_initial_confidence,
22
+ print_auto_tuned_params,
23
+ smooth_landmark_sequence,
24
+ track_all_frames,
25
+ )
26
+ from ..core.pose import PoseTracker
27
+ from ..core.video_io import VideoProcessor
28
+ from .analysis import detect_cmj_phases
29
+ from .debug_overlay import CMJDebugOverlayRenderer
30
+ from .kinematics import CMJMetrics, calculate_cmj_metrics
31
+
32
+
33
+ @dataclass
34
+ class AnalysisParameters:
35
+ """Expert parameters for CMJ analysis customization."""
36
+
37
+ smoothing_window: int | None = None
38
+ velocity_threshold: float | None = None
39
+ countermovement_threshold: float | None = None
40
+ min_contact_frames: int | None = None
41
+ visibility_threshold: float | None = None
42
+ detection_confidence: float | None = None
43
+ tracking_confidence: float | None = None
44
+
45
+
46
+ def _collect_video_files(video_path: tuple[str, ...]) -> list[str]:
47
+ """Expand glob patterns and collect all video files."""
48
+ video_files: list[str] = []
49
+ for pattern in video_path:
50
+ expanded = glob.glob(pattern)
51
+ if expanded:
52
+ video_files.extend(expanded)
53
+ elif Path(pattern).exists():
54
+ video_files.append(pattern)
55
+ else:
56
+ click.echo(f"Warning: No files found for pattern: {pattern}", err=True)
57
+ return video_files
58
+
59
+
60
+ def _generate_output_paths(
61
+ video: str, output_dir: str | None, json_output_dir: str | None
62
+ ) -> tuple[str | None, str | None]:
63
+ """Generate output paths for debug video and JSON."""
64
+ out_path = None
65
+ json_path = None
66
+ if output_dir:
67
+ out_path = str(Path(output_dir) / f"{Path(video).stem}_debug.mp4")
68
+ if json_output_dir:
69
+ json_path = str(Path(json_output_dir) / f"{Path(video).stem}.json")
70
+ return out_path, json_path
71
+
72
+
73
+ def _process_batch_videos(
74
+ video_files: list[str],
75
+ output_dir: str | None,
76
+ json_output_dir: str | None,
77
+ quality_preset: QualityPreset,
78
+ verbose: bool,
79
+ expert_params: AnalysisParameters,
80
+ workers: int,
81
+ ) -> None:
82
+ """Process multiple videos in batch mode."""
83
+ click.echo(
84
+ f"Batch mode: Processing {len(video_files)} video(s) with {workers} workers",
85
+ err=True,
86
+ )
87
+ click.echo("Note: Batch processing not yet fully implemented", err=True)
88
+ click.echo("Processing videos sequentially...", err=True)
89
+
90
+ for video in video_files:
91
+ try:
92
+ click.echo(f"\nProcessing: {video}", err=True)
93
+ out_path, json_path = _generate_output_paths(
94
+ video, output_dir, json_output_dir
95
+ )
96
+ _process_single(
97
+ video, out_path, json_path, quality_preset, verbose, expert_params
98
+ )
99
+ except Exception as e:
100
+ click.echo(f"Error processing {video}: {e}", err=True)
101
+ continue
102
+
103
+
104
+ @click.command(name="cmj-analyze")
105
+ @click.argument("video_path", nargs=-1, type=click.Path(exists=False), required=True)
106
+ @common_output_options
107
+ @click.option(
108
+ "--quality",
109
+ type=click.Choice(["fast", "balanced", "accurate"], case_sensitive=False),
110
+ default="balanced",
111
+ help=(
112
+ "Analysis quality preset: "
113
+ "fast (quick, less precise), "
114
+ "balanced (default, good for most cases), "
115
+ "accurate (research-grade, slower)"
116
+ ),
117
+ show_default=True,
118
+ )
119
+ @click.option(
120
+ "--verbose",
121
+ "-v",
122
+ is_flag=True,
123
+ help="Show auto-selected parameters and analysis details",
124
+ )
125
+ # Batch processing options
126
+ @click.option(
127
+ "--batch",
128
+ is_flag=True,
129
+ help="Enable batch processing mode for multiple videos",
130
+ )
131
+ @click.option(
132
+ "--workers",
133
+ type=int,
134
+ default=4,
135
+ help="Number of parallel workers for batch processing (default: 4)",
136
+ show_default=True,
137
+ )
138
+ @click.option(
139
+ "--output-dir",
140
+ type=click.Path(),
141
+ help="Directory for debug video outputs (batch mode only)",
142
+ )
143
+ @click.option(
144
+ "--json-output-dir",
145
+ type=click.Path(),
146
+ help="Directory for JSON metrics outputs (batch mode only)",
147
+ )
148
+ @click.option(
149
+ "--csv-summary",
150
+ type=click.Path(),
151
+ help="Path for CSV summary export (batch mode only)",
152
+ )
153
+ # Expert parameters (hidden in help, but always available for advanced users)
154
+ @click.option(
155
+ "--smoothing-window",
156
+ type=int,
157
+ default=None,
158
+ help="[EXPERT] Override auto-tuned smoothing window size",
159
+ )
160
+ @click.option(
161
+ "--velocity-threshold",
162
+ type=float,
163
+ default=None,
164
+ help="[EXPERT] Override auto-tuned velocity threshold for flight detection",
165
+ )
166
+ @click.option(
167
+ "--countermovement-threshold",
168
+ type=float,
169
+ default=None,
170
+ help="[EXPERT] Override auto-tuned countermovement threshold (negative value)",
171
+ )
172
+ @click.option(
173
+ "--min-contact-frames",
174
+ type=int,
175
+ default=None,
176
+ help="[EXPERT] Override auto-tuned minimum contact frames",
177
+ )
178
+ @click.option(
179
+ "--visibility-threshold",
180
+ type=float,
181
+ default=None,
182
+ help="[EXPERT] Override visibility threshold",
183
+ )
184
+ @click.option(
185
+ "--detection-confidence",
186
+ type=float,
187
+ default=None,
188
+ help="[EXPERT] Override pose detection confidence",
189
+ )
190
+ @click.option(
191
+ "--tracking-confidence",
192
+ type=float,
193
+ default=None,
194
+ help="[EXPERT] Override pose tracking confidence",
195
+ )
196
+ def cmj_analyze( # NOSONAR(S107) - Click CLI requires individual parameters for each option
197
+ video_path: tuple[str, ...],
198
+ output: str | None,
199
+ json_output: str | None,
200
+ quality: str,
201
+ verbose: bool,
202
+ batch: bool,
203
+ workers: int,
204
+ output_dir: str | None,
205
+ json_output_dir: str | None,
206
+ csv_summary: str | None,
207
+ smoothing_window: int | None,
208
+ velocity_threshold: float | None,
209
+ countermovement_threshold: float | None,
210
+ min_contact_frames: int | None,
211
+ visibility_threshold: float | None,
212
+ detection_confidence: float | None,
213
+ tracking_confidence: float | None,
214
+ ) -> None:
215
+ """
216
+ Analyze counter movement jump (CMJ) video(s) to estimate jump performance metrics.
217
+
218
+ Uses intelligent auto-tuning to select optimal parameters based on video characteristics.
219
+ Parameters are automatically adjusted for frame rate, tracking quality, and analysis preset.
220
+
221
+ VIDEO_PATH: Path(s) to video file(s). Supports glob patterns in batch mode.
222
+
223
+ Examples:
224
+
225
+ \\b
226
+ # Basic analysis
227
+ kinemotion cmj-analyze video.mp4
228
+
229
+ \\b
230
+ # With debug video output
231
+ kinemotion cmj-analyze video.mp4 --output debug.mp4
232
+
233
+ \\b
234
+ # Batch mode with glob pattern
235
+ kinemotion cmj-analyze videos/*.mp4 --batch --workers 4
236
+
237
+ \\b
238
+ # Batch with output directories
239
+ kinemotion cmj-analyze videos/*.mp4 --batch \\
240
+ --json-output-dir results/ --csv-summary summary.csv
241
+ """
242
+ # Expand glob patterns and collect all video files
243
+ video_files = _collect_video_files(video_path)
244
+
245
+ if not video_files:
246
+ click.echo("Error: No video files found", err=True)
247
+ sys.exit(1)
248
+
249
+ # Determine if batch mode should be used
250
+ use_batch = batch or len(video_files) > 1
251
+
252
+ quality_preset = QualityPreset(quality.lower())
253
+
254
+ # Group expert parameters
255
+ expert_params = AnalysisParameters(
256
+ smoothing_window=smoothing_window,
257
+ velocity_threshold=velocity_threshold,
258
+ countermovement_threshold=countermovement_threshold,
259
+ min_contact_frames=min_contact_frames,
260
+ visibility_threshold=visibility_threshold,
261
+ detection_confidence=detection_confidence,
262
+ tracking_confidence=tracking_confidence,
263
+ )
264
+
265
+ if use_batch:
266
+ _process_batch_videos(
267
+ video_files,
268
+ output_dir,
269
+ json_output_dir,
270
+ quality_preset,
271
+ verbose,
272
+ expert_params,
273
+ workers,
274
+ )
275
+ else:
276
+ # Single video mode
277
+ try:
278
+ _process_single(
279
+ video_files[0],
280
+ output,
281
+ json_output,
282
+ quality_preset,
283
+ verbose,
284
+ expert_params,
285
+ )
286
+ except Exception as e:
287
+ click.echo(f"Error: {e}", err=True)
288
+ sys.exit(1)
289
+
290
+
291
+ def _get_foot_position(frame_landmarks: dict | None, last_position: float) -> float:
292
+ """Extract average foot position from frame landmarks."""
293
+ if not frame_landmarks:
294
+ return last_position
295
+
296
+ # Average foot position (ankles and heels)
297
+ foot_y_values = []
298
+ for key in ["left_ankle", "right_ankle", "left_heel", "right_heel"]:
299
+ if key in frame_landmarks:
300
+ foot_y_values.append(frame_landmarks[key][1])
301
+
302
+ if foot_y_values:
303
+ return float(np.mean(foot_y_values))
304
+ return last_position
305
+
306
+
307
+ def _extract_positions_from_landmarks(
308
+ smoothed_landmarks: list,
309
+ ) -> tuple[np.ndarray, str]:
310
+ """Extract vertical foot positions from landmarks.
311
+
312
+ Args:
313
+ smoothed_landmarks: Smoothed landmark sequence
314
+
315
+ Returns:
316
+ Tuple of (positions array, tracking method name)
317
+ """
318
+ click.echo("Extracting foot positions...", err=True)
319
+ position_list: list[float] = []
320
+
321
+ for frame_landmarks in smoothed_landmarks:
322
+ last_pos = position_list[-1] if position_list else 0.5
323
+ position = _get_foot_position(frame_landmarks, last_pos)
324
+ position_list.append(position)
325
+
326
+ return np.array(position_list), "foot"
327
+
328
+
329
+ def _process_single(
330
+ video_path: str,
331
+ output: str | None,
332
+ json_output: str | None,
333
+ quality_preset: QualityPreset,
334
+ verbose: bool,
335
+ expert_params: AnalysisParameters,
336
+ ) -> None:
337
+ """Process a single CMJ video."""
338
+ try:
339
+ with VideoProcessor(video_path) as video:
340
+ click.echo(
341
+ f"Video: {video.width}x{video.height} @ {video.fps:.2f} fps, "
342
+ f"{video.frame_count} frames",
343
+ err=True,
344
+ )
345
+
346
+ # Determine confidence levels
347
+ detection_conf, tracking_conf = determine_initial_confidence(
348
+ quality_preset, expert_params
349
+ )
350
+
351
+ # Track all frames
352
+ tracker = PoseTracker(
353
+ min_detection_confidence=detection_conf,
354
+ min_tracking_confidence=tracking_conf,
355
+ )
356
+ frames, landmarks_sequence = track_all_frames(video, tracker)
357
+
358
+ if not landmarks_sequence:
359
+ click.echo("Error: No frames processed", err=True)
360
+ sys.exit(1)
361
+
362
+ # Auto-tune parameters
363
+ characteristics = analyze_video_sample(
364
+ landmarks_sequence, video.fps, video.frame_count
365
+ )
366
+ params = auto_tune_parameters(characteristics, quality_preset)
367
+ params = apply_expert_param_overrides(params, expert_params)
368
+
369
+ # Calculate countermovement threshold (FPS-adjusted)
370
+ # Base: +0.015 at 30fps (POSITIVE for downward motion in normalized coords)
371
+ countermovement_threshold = 0.015 * (30.0 / video.fps)
372
+ if expert_params.countermovement_threshold is not None:
373
+ countermovement_threshold = expert_params.countermovement_threshold
374
+
375
+ # Show parameters if verbose
376
+ if verbose:
377
+ print_auto_tuned_params(
378
+ video,
379
+ quality_preset,
380
+ params,
381
+ extra_params={
382
+ "countermovement_threshold": countermovement_threshold
383
+ },
384
+ )
385
+
386
+ # Apply smoothing
387
+ smoothed_landmarks = smooth_landmark_sequence(landmarks_sequence, params)
388
+
389
+ # Extract foot positions
390
+ vertical_positions, tracking_method = _extract_positions_from_landmarks(
391
+ smoothed_landmarks
392
+ )
393
+
394
+ # Detect CMJ phases
395
+ click.echo("Detecting CMJ phases...", err=True)
396
+ phases = detect_cmj_phases(
397
+ vertical_positions,
398
+ video.fps,
399
+ window_length=params.smoothing_window,
400
+ polyorder=params.polyorder,
401
+ )
402
+
403
+ if phases is None:
404
+ click.echo("Error: Could not detect CMJ phases", err=True)
405
+ sys.exit(1)
406
+
407
+ standing_end, lowest_point, takeoff_frame, landing_frame = phases
408
+
409
+ # Calculate metrics
410
+ click.echo("Calculating metrics...", err=True)
411
+
412
+ # Compute SIGNED velocities for CMJ metrics (need direction info)
413
+ from .analysis import compute_signed_velocity
414
+
415
+ velocities = compute_signed_velocity(
416
+ vertical_positions,
417
+ window_length=params.smoothing_window,
418
+ polyorder=params.polyorder,
419
+ )
420
+
421
+ metrics = calculate_cmj_metrics(
422
+ vertical_positions,
423
+ velocities,
424
+ standing_end,
425
+ lowest_point,
426
+ takeoff_frame,
427
+ landing_frame,
428
+ video.fps,
429
+ tracking_method=tracking_method,
430
+ )
431
+
432
+ # Output results
433
+ _output_results(metrics, json_output)
434
+
435
+ # Generate debug video if requested
436
+ if output:
437
+ _create_debug_video(output, video, frames, smoothed_landmarks, metrics)
438
+
439
+ except Exception as e:
440
+ click.echo(f"Error processing video: {e}", err=True)
441
+ import traceback
442
+
443
+ traceback.print_exc()
444
+ sys.exit(1)
445
+
446
+
447
+ def _create_debug_video(
448
+ output: str,
449
+ video: VideoProcessor,
450
+ frames: list,
451
+ smoothed_landmarks: list,
452
+ metrics: CMJMetrics,
453
+ ) -> None:
454
+ """Generate debug video with overlays.
455
+
456
+ Args:
457
+ output: Output video path
458
+ video: Video processor
459
+ frames: Video frames
460
+ smoothed_landmarks: Smoothed landmarks
461
+ metrics: Calculated metrics
462
+ """
463
+ click.echo(f"Generating debug video: {output}", err=True)
464
+ if video.display_width != video.width or video.display_height != video.height:
465
+ click.echo(f"Source video encoded: {video.width}x{video.height}", err=True)
466
+ click.echo(
467
+ f"Output dimensions: {video.display_width}x{video.display_height} "
468
+ f"(respecting display aspect ratio)",
469
+ err=True,
470
+ )
471
+ else:
472
+ click.echo(
473
+ f"Output dimensions: {video.width}x{video.height} "
474
+ f"(matching source video aspect ratio)",
475
+ err=True,
476
+ )
477
+
478
+ with CMJDebugOverlayRenderer(
479
+ output,
480
+ video.width,
481
+ video.height,
482
+ video.display_width,
483
+ video.display_height,
484
+ video.fps,
485
+ ) as renderer:
486
+ render_bar: Any
487
+ with click.progressbar(
488
+ length=len(frames), label="Rendering frames"
489
+ ) as render_bar:
490
+ for i, frame in enumerate(frames):
491
+ annotated = renderer.render_frame(
492
+ frame, smoothed_landmarks[i], i, metrics
493
+ )
494
+ renderer.write_frame(annotated)
495
+ render_bar.update(1)
496
+
497
+ click.echo(f"Debug video saved: {output}", err=True)
498
+
499
+
500
+ def _output_results(metrics: Any, json_output: str | None) -> None:
501
+ """Output analysis results."""
502
+ results = metrics.to_dict()
503
+
504
+ # Output JSON
505
+ if json_output:
506
+ with open(json_output, "w") as f:
507
+ json.dump(results, f, indent=2)
508
+ click.echo(f"Metrics saved to: {json_output}", err=True)
509
+ else:
510
+ # Output to stdout
511
+ print(json.dumps(results, indent=2))
512
+
513
+ # Print summary
514
+ click.echo("\n" + "=" * 60, err=True)
515
+ click.echo("CMJ ANALYSIS RESULTS", err=True)
516
+ click.echo("=" * 60, err=True)
517
+ click.echo(f"Jump height: {metrics.jump_height:.3f} m", err=True)
518
+ click.echo(f"Flight time: {metrics.flight_time * 1000:.1f} ms", err=True)
519
+ click.echo(
520
+ f"Countermovement depth: {metrics.countermovement_depth:.3f} m", err=True
521
+ )
522
+ click.echo(
523
+ f"Eccentric duration: {metrics.eccentric_duration * 1000:.1f} ms", err=True
524
+ )
525
+ click.echo(
526
+ f"Concentric duration: {metrics.concentric_duration * 1000:.1f} ms", err=True
527
+ )
528
+ click.echo(
529
+ f"Total movement time: {metrics.total_movement_time * 1000:.1f} ms", err=True
530
+ )
531
+ click.echo(
532
+ f"Peak eccentric velocity: {abs(metrics.peak_eccentric_velocity):.3f} m/s (downward)",
533
+ err=True,
534
+ )
535
+ click.echo(
536
+ f"Peak concentric velocity: {metrics.peak_concentric_velocity:.3f} m/s (upward)",
537
+ err=True,
538
+ )
539
+ if metrics.transition_time is not None:
540
+ click.echo(
541
+ f"Transition time: {metrics.transition_time * 1000:.1f} ms", err=True
542
+ )
543
+ click.echo("=" * 60, err=True)