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.

@@ -0,0 +1,704 @@
1
+ """Command-line interface for drop jump analysis."""
2
+
3
+ import csv
4
+ import glob
5
+ import json
6
+ import sys
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ import click
12
+ import numpy as np
13
+
14
+ from ..api import (
15
+ DropJumpVideoConfig,
16
+ DropJumpVideoResult,
17
+ process_dropjump_videos_bulk,
18
+ )
19
+ from ..core.auto_tuning import (
20
+ QualityPreset,
21
+ analyze_video_sample,
22
+ auto_tune_parameters,
23
+ )
24
+ from ..core.cli_utils import (
25
+ apply_expert_param_overrides,
26
+ determine_initial_confidence,
27
+ print_auto_tuned_params,
28
+ smooth_landmark_sequence,
29
+ track_all_frames,
30
+ )
31
+ from ..core.pose import PoseTracker
32
+ from ..core.video_io import VideoProcessor
33
+ from .analysis import (
34
+ ContactState,
35
+ detect_ground_contact,
36
+ extract_foot_positions_and_visibilities,
37
+ )
38
+ from .debug_overlay import DebugOverlayRenderer
39
+ from .kinematics import DropJumpMetrics, calculate_drop_jump_metrics
40
+
41
+
42
+ @dataclass
43
+ class AnalysisParameters:
44
+ """Expert parameters for analysis customization."""
45
+
46
+ drop_start_frame: int | None = None
47
+ smoothing_window: int | None = None
48
+ velocity_threshold: float | None = None
49
+ min_contact_frames: int | None = None
50
+ visibility_threshold: float | None = None
51
+ detection_confidence: float | None = None
52
+ tracking_confidence: float | None = None
53
+
54
+
55
+ @click.command(name="dropjump-analyze")
56
+ @click.argument("video_path", nargs=-1, type=click.Path(exists=False), required=True)
57
+ @click.option(
58
+ "--output",
59
+ "-o",
60
+ type=click.Path(),
61
+ help="Path for debug video output (optional)",
62
+ )
63
+ @click.option(
64
+ "--json-output",
65
+ "-j",
66
+ type=click.Path(),
67
+ help="Path for JSON metrics output (default: stdout)",
68
+ )
69
+ @click.option(
70
+ "--quality",
71
+ type=click.Choice(["fast", "balanced", "accurate"], case_sensitive=False),
72
+ default="balanced",
73
+ help=(
74
+ "Analysis quality preset: "
75
+ "fast (quick, less precise), "
76
+ "balanced (default, good for most cases), "
77
+ "accurate (research-grade, slower)"
78
+ ),
79
+ show_default=True,
80
+ )
81
+ @click.option(
82
+ "--verbose",
83
+ "-v",
84
+ is_flag=True,
85
+ help="Show auto-selected parameters and analysis details",
86
+ )
87
+ # Batch processing options
88
+ @click.option(
89
+ "--batch",
90
+ is_flag=True,
91
+ help="Enable batch processing mode for multiple videos",
92
+ )
93
+ @click.option(
94
+ "--workers",
95
+ type=int,
96
+ default=4,
97
+ help="Number of parallel workers for batch processing (default: 4)",
98
+ show_default=True,
99
+ )
100
+ @click.option(
101
+ "--output-dir",
102
+ type=click.Path(),
103
+ help="Directory for debug video outputs (batch mode only)",
104
+ )
105
+ @click.option(
106
+ "--json-output-dir",
107
+ type=click.Path(),
108
+ help="Directory for JSON metrics outputs (batch mode only)",
109
+ )
110
+ @click.option(
111
+ "--csv-summary",
112
+ type=click.Path(),
113
+ help="Path for CSV summary export (batch mode only)",
114
+ )
115
+ # Expert parameters (hidden in help, but always available for advanced users)
116
+ @click.option(
117
+ "--drop-start-frame",
118
+ type=int,
119
+ default=None,
120
+ help="[EXPERT] Manually specify frame where drop begins (overrides auto-detection)",
121
+ )
122
+ @click.option(
123
+ "--smoothing-window",
124
+ type=int,
125
+ default=None,
126
+ help="[EXPERT] Override auto-tuned smoothing window size",
127
+ )
128
+ @click.option(
129
+ "--velocity-threshold",
130
+ type=float,
131
+ default=None,
132
+ help="[EXPERT] Override auto-tuned velocity threshold",
133
+ )
134
+ @click.option(
135
+ "--min-contact-frames",
136
+ type=int,
137
+ default=None,
138
+ help="[EXPERT] Override auto-tuned minimum contact frames",
139
+ )
140
+ @click.option(
141
+ "--visibility-threshold",
142
+ type=float,
143
+ default=None,
144
+ help="[EXPERT] Override visibility threshold",
145
+ )
146
+ @click.option(
147
+ "--detection-confidence",
148
+ type=float,
149
+ default=None,
150
+ help="[EXPERT] Override pose detection confidence",
151
+ )
152
+ @click.option(
153
+ "--tracking-confidence",
154
+ type=float,
155
+ default=None,
156
+ help="[EXPERT] Override pose tracking confidence",
157
+ )
158
+ def dropjump_analyze( # NOSONAR(S107) - Click CLI requires individual parameters for each option
159
+ video_path: tuple[str, ...],
160
+ output: str | None,
161
+ json_output: str | None,
162
+ quality: str,
163
+ verbose: bool,
164
+ batch: bool,
165
+ workers: int,
166
+ output_dir: str | None,
167
+ json_output_dir: str | None,
168
+ csv_summary: str | None,
169
+ drop_start_frame: int | None,
170
+ smoothing_window: int | None,
171
+ velocity_threshold: float | None,
172
+ min_contact_frames: int | None,
173
+ visibility_threshold: float | None,
174
+ detection_confidence: float | None,
175
+ tracking_confidence: float | None,
176
+ ) -> None:
177
+ """
178
+ Analyze drop-jump video(s) to estimate ground contact time, flight time, and jump height.
179
+
180
+ Uses intelligent auto-tuning to select optimal parameters based on video characteristics.
181
+ Parameters are automatically adjusted for frame rate, tracking quality, and analysis preset.
182
+
183
+ VIDEO_PATH: Path(s) to video file(s). Supports glob patterns in batch mode
184
+ (e.g., "videos/*.mp4").
185
+
186
+ Examples:
187
+
188
+ \b
189
+ # Single video
190
+ kinemotion dropjump-analyze video.mp4
191
+
192
+ \b
193
+ # Batch mode with glob pattern
194
+ kinemotion dropjump-analyze videos/*.mp4 --batch --workers 4
195
+
196
+ \b
197
+ # Batch with output directories
198
+ kinemotion dropjump-analyze videos/*.mp4 --batch \\
199
+ --json-output-dir results/ --csv-summary summary.csv
200
+ """
201
+ # Expand glob patterns and collect all video files
202
+ video_files: list[str] = []
203
+ for pattern in video_path:
204
+ expanded = glob.glob(pattern)
205
+ if expanded:
206
+ video_files.extend(expanded)
207
+ elif Path(pattern).exists():
208
+ # Direct path (not a glob pattern)
209
+ video_files.append(pattern)
210
+ else:
211
+ click.echo(f"Warning: No files found for pattern: {pattern}", err=True)
212
+
213
+ if not video_files:
214
+ click.echo("Error: No video files found", err=True)
215
+ sys.exit(1)
216
+
217
+ # Determine if batch mode should be used
218
+ use_batch = batch or len(video_files) > 1
219
+
220
+ # Group expert parameters
221
+ expert_params = AnalysisParameters(
222
+ drop_start_frame=drop_start_frame,
223
+ smoothing_window=smoothing_window,
224
+ velocity_threshold=velocity_threshold,
225
+ min_contact_frames=min_contact_frames,
226
+ visibility_threshold=visibility_threshold,
227
+ detection_confidence=detection_confidence,
228
+ tracking_confidence=tracking_confidence,
229
+ )
230
+
231
+ if use_batch:
232
+ _process_batch(
233
+ video_files,
234
+ quality,
235
+ workers,
236
+ output_dir,
237
+ json_output_dir,
238
+ csv_summary,
239
+ expert_params,
240
+ )
241
+ else:
242
+ # Single video mode (original behavior)
243
+ _process_single(
244
+ video_files[0],
245
+ output,
246
+ json_output,
247
+ quality,
248
+ verbose,
249
+ expert_params,
250
+ )
251
+
252
+
253
+ def _extract_positions_and_visibilities(
254
+ smoothed_landmarks: list,
255
+ ) -> tuple[np.ndarray, np.ndarray]:
256
+ """Extract vertical positions and visibilities from landmarks.
257
+
258
+ Args:
259
+ smoothed_landmarks: Smoothed landmark sequence
260
+
261
+ Returns:
262
+ Tuple of (vertical_positions, visibilities)
263
+ """
264
+ click.echo("Extracting foot positions...", err=True)
265
+ return extract_foot_positions_and_visibilities(smoothed_landmarks)
266
+
267
+
268
+ def _create_debug_video(
269
+ output: str,
270
+ video: VideoProcessor,
271
+ frames: list,
272
+ smoothed_landmarks: list,
273
+ contact_states: list[ContactState],
274
+ metrics: DropJumpMetrics,
275
+ ) -> None:
276
+ """Generate debug video with overlays.
277
+
278
+ Args:
279
+ output: Output video path
280
+ video: Video processor
281
+ frames: Video frames
282
+ smoothed_landmarks: Smoothed landmarks
283
+ contact_states: Contact states
284
+ metrics: Calculated metrics
285
+ """
286
+ click.echo(f"Generating debug video: {output}", err=True)
287
+ if video.display_width != video.width or video.display_height != video.height:
288
+ click.echo(f"Source video encoded: {video.width}x{video.height}", err=True)
289
+ click.echo(
290
+ f"Output dimensions: {video.display_width}x{video.display_height} "
291
+ f"(respecting display aspect ratio)",
292
+ err=True,
293
+ )
294
+ else:
295
+ click.echo(
296
+ f"Output dimensions: {video.width}x{video.height} "
297
+ f"(matching source video aspect ratio)",
298
+ err=True,
299
+ )
300
+
301
+ with DebugOverlayRenderer(
302
+ output,
303
+ video.width,
304
+ video.height,
305
+ video.display_width,
306
+ video.display_height,
307
+ video.fps,
308
+ ) as renderer:
309
+ render_bar: Any
310
+ with click.progressbar(
311
+ length=len(frames), label="Rendering frames"
312
+ ) as render_bar:
313
+ for i, frame in enumerate(frames):
314
+ annotated = renderer.render_frame(
315
+ frame,
316
+ smoothed_landmarks[i],
317
+ contact_states[i],
318
+ i,
319
+ metrics,
320
+ use_com=False,
321
+ )
322
+ renderer.write_frame(annotated)
323
+ render_bar.update(1)
324
+
325
+ click.echo(f"Debug video saved: {output}", err=True)
326
+
327
+
328
+ def _process_single(
329
+ video_path: str,
330
+ output: str | None,
331
+ json_output: str | None,
332
+ quality: str,
333
+ verbose: bool,
334
+ expert_params: AnalysisParameters,
335
+ ) -> None:
336
+ """Process a single video (original CLI behavior)."""
337
+ click.echo(f"Analyzing video: {video_path}", err=True)
338
+
339
+ quality_preset = QualityPreset(quality.lower())
340
+
341
+ try:
342
+ with VideoProcessor(video_path) as video:
343
+ click.echo(
344
+ f"Video: {video.width}x{video.height} @ {video.fps:.2f} fps, "
345
+ f"{video.frame_count} frames",
346
+ err=True,
347
+ )
348
+
349
+ # Determine confidence levels
350
+ detection_conf, tracking_conf = determine_initial_confidence(
351
+ quality_preset, expert_params
352
+ )
353
+
354
+ # Track all frames
355
+ tracker = PoseTracker(
356
+ min_detection_confidence=detection_conf,
357
+ min_tracking_confidence=tracking_conf,
358
+ )
359
+ frames, landmarks_sequence = track_all_frames(video, tracker)
360
+
361
+ if not landmarks_sequence:
362
+ click.echo("Error: No frames processed", err=True)
363
+ sys.exit(1)
364
+
365
+ # Auto-tune parameters
366
+ characteristics = analyze_video_sample(
367
+ landmarks_sequence, video.fps, video.frame_count
368
+ )
369
+ params = auto_tune_parameters(characteristics, quality_preset)
370
+ params = apply_expert_param_overrides(params, expert_params)
371
+
372
+ # Show parameters if verbose
373
+ if verbose:
374
+ print_auto_tuned_params(video, quality_preset, params, characteristics)
375
+
376
+ # Apply smoothing
377
+ smoothed_landmarks = smooth_landmark_sequence(landmarks_sequence, params)
378
+
379
+ # Extract positions
380
+ vertical_positions, visibilities = _extract_positions_and_visibilities(
381
+ smoothed_landmarks
382
+ )
383
+
384
+ # Detect ground contact
385
+ contact_states = detect_ground_contact(
386
+ vertical_positions,
387
+ velocity_threshold=params.velocity_threshold,
388
+ min_contact_frames=params.min_contact_frames,
389
+ visibility_threshold=params.visibility_threshold,
390
+ visibilities=visibilities,
391
+ window_length=params.smoothing_window,
392
+ polyorder=params.polyorder,
393
+ )
394
+
395
+ # Calculate metrics
396
+ click.echo("Calculating metrics...", err=True)
397
+ metrics = calculate_drop_jump_metrics(
398
+ contact_states,
399
+ vertical_positions,
400
+ video.fps,
401
+ drop_start_frame=expert_params.drop_start_frame,
402
+ velocity_threshold=params.velocity_threshold,
403
+ smoothing_window=params.smoothing_window,
404
+ polyorder=params.polyorder,
405
+ use_curvature=params.use_curvature,
406
+ )
407
+
408
+ # Output metrics
409
+ metrics_json = json.dumps(metrics.to_dict(), indent=2)
410
+ if json_output:
411
+ Path(json_output).write_text(metrics_json)
412
+ click.echo(f"Metrics written to: {json_output}", err=True)
413
+ else:
414
+ click.echo(metrics_json)
415
+
416
+ # Generate debug video if requested
417
+ if output:
418
+ _create_debug_video(
419
+ output, video, frames, smoothed_landmarks, contact_states, metrics
420
+ )
421
+
422
+ click.echo("Analysis complete!", err=True)
423
+
424
+ except Exception as e:
425
+ click.echo(f"Error: {str(e)}", err=True)
426
+ sys.exit(1)
427
+
428
+
429
+ def _setup_batch_output_dirs(
430
+ output_dir: str | None, json_output_dir: str | None
431
+ ) -> None:
432
+ """Create output directories for batch processing.
433
+
434
+ Args:
435
+ output_dir: Debug video output directory
436
+ json_output_dir: JSON metrics output directory
437
+ """
438
+ if output_dir:
439
+ Path(output_dir).mkdir(parents=True, exist_ok=True)
440
+ click.echo(f"Debug videos will be saved to: {output_dir}", err=True)
441
+
442
+ if json_output_dir:
443
+ Path(json_output_dir).mkdir(parents=True, exist_ok=True)
444
+ click.echo(f"JSON metrics will be saved to: {json_output_dir}", err=True)
445
+
446
+
447
+ def _create_video_configs(
448
+ video_files: list[str],
449
+ quality: str,
450
+ output_dir: str | None,
451
+ json_output_dir: str | None,
452
+ expert_params: AnalysisParameters,
453
+ ) -> list[DropJumpVideoConfig]:
454
+ """Build configuration objects for each video.
455
+
456
+ Args:
457
+ video_files: List of video file paths
458
+ quality: Quality preset
459
+ output_dir: Debug video output directory
460
+ json_output_dir: JSON metrics output directory
461
+ expert_params: Expert parameter overrides
462
+
463
+ Returns:
464
+ List of DropJumpVideoConfig objects
465
+ """
466
+ configs: list[DropJumpVideoConfig] = []
467
+ for video_file in video_files:
468
+ video_name = Path(video_file).stem
469
+
470
+ debug_video = None
471
+ if output_dir:
472
+ debug_video = str(Path(output_dir) / f"{video_name}_debug.mp4")
473
+
474
+ json_file = None
475
+ if json_output_dir:
476
+ json_file = str(Path(json_output_dir) / f"{video_name}.json")
477
+
478
+ config = DropJumpVideoConfig(
479
+ video_path=video_file,
480
+ quality=quality,
481
+ output_video=debug_video,
482
+ json_output=json_file,
483
+ drop_start_frame=expert_params.drop_start_frame,
484
+ smoothing_window=expert_params.smoothing_window,
485
+ velocity_threshold=expert_params.velocity_threshold,
486
+ min_contact_frames=expert_params.min_contact_frames,
487
+ visibility_threshold=expert_params.visibility_threshold,
488
+ detection_confidence=expert_params.detection_confidence,
489
+ tracking_confidence=expert_params.tracking_confidence,
490
+ )
491
+ configs.append(config)
492
+
493
+ return configs
494
+
495
+
496
+ def _compute_batch_statistics(results: list[DropJumpVideoResult]) -> None:
497
+ """Compute and display batch processing statistics.
498
+
499
+ Args:
500
+ results: List of video processing results
501
+ """
502
+ click.echo("\n" + "=" * 70, err=True)
503
+ click.echo("BATCH PROCESSING SUMMARY", err=True)
504
+ click.echo("=" * 70, err=True)
505
+
506
+ successful = [r for r in results if r.success]
507
+ failed = [r for r in results if not r.success]
508
+
509
+ click.echo(f"Total videos: {len(results)}", err=True)
510
+ click.echo(f"Successful: {len(successful)}", err=True)
511
+ click.echo(f"Failed: {len(failed)}", err=True)
512
+
513
+ if successful:
514
+ # Calculate average metrics
515
+ with_gct = [
516
+ r
517
+ for r in successful
518
+ if r.metrics and r.metrics.ground_contact_time is not None
519
+ ]
520
+ with_flight = [
521
+ r for r in successful if r.metrics and r.metrics.flight_time is not None
522
+ ]
523
+ with_jump = [
524
+ r for r in successful if r.metrics and r.metrics.jump_height is not None
525
+ ]
526
+
527
+ if with_gct:
528
+ avg_gct = sum(
529
+ r.metrics.ground_contact_time * 1000
530
+ for r in with_gct
531
+ if r.metrics and r.metrics.ground_contact_time is not None
532
+ ) / len(with_gct)
533
+ click.echo(f"\nAverage ground contact time: {avg_gct:.1f} ms", err=True)
534
+
535
+ if with_flight:
536
+ avg_flight = sum(
537
+ r.metrics.flight_time * 1000
538
+ for r in with_flight
539
+ if r.metrics and r.metrics.flight_time is not None
540
+ ) / len(with_flight)
541
+ click.echo(f"Average flight time: {avg_flight:.1f} ms", err=True)
542
+
543
+ if with_jump:
544
+ avg_jump = sum(
545
+ r.metrics.jump_height
546
+ for r in with_jump
547
+ if r.metrics and r.metrics.jump_height is not None
548
+ ) / len(with_jump)
549
+ click.echo(
550
+ f"Average jump height: {avg_jump:.3f} m ({avg_jump * 100:.1f} cm)",
551
+ err=True,
552
+ )
553
+
554
+
555
+ def _format_time_metric(value: float | None, multiplier: float = 1000.0) -> str:
556
+ """Format time metric for CSV output.
557
+
558
+ Args:
559
+ value: Time value in seconds
560
+ multiplier: Multiplier to convert to milliseconds (default: 1000.0)
561
+
562
+ Returns:
563
+ Formatted string or "N/A" if value is None
564
+ """
565
+ return f"{value * multiplier:.1f}" if value is not None else "N/A"
566
+
567
+
568
+ def _format_distance_metric(value: float | None) -> str:
569
+ """Format distance metric for CSV output.
570
+
571
+ Args:
572
+ value: Distance value in meters
573
+
574
+ Returns:
575
+ Formatted string or "N/A" if value is None
576
+ """
577
+ return f"{value:.3f}" if value is not None else "N/A"
578
+
579
+
580
+ def _create_csv_row_from_result(result: DropJumpVideoResult) -> list[str]:
581
+ """Create CSV row from video processing result.
582
+
583
+ Args:
584
+ result: Video processing result
585
+
586
+ Returns:
587
+ List of formatted values for CSV row
588
+ """
589
+ video_name = Path(result.video_path).name
590
+ processing_time = f"{result.processing_time:.2f}"
591
+
592
+ if result.success and result.metrics:
593
+ return [
594
+ video_name,
595
+ _format_time_metric(result.metrics.ground_contact_time),
596
+ _format_time_metric(result.metrics.flight_time),
597
+ _format_distance_metric(result.metrics.jump_height),
598
+ processing_time,
599
+ "Success",
600
+ ]
601
+ else:
602
+ return [
603
+ video_name,
604
+ "N/A",
605
+ "N/A",
606
+ "N/A",
607
+ processing_time,
608
+ f"Failed: {result.error}",
609
+ ]
610
+
611
+
612
+ def _write_csv_summary(
613
+ csv_summary: str | None,
614
+ results: list[DropJumpVideoResult],
615
+ successful: list[DropJumpVideoResult],
616
+ ) -> None:
617
+ """Write CSV summary of batch processing results.
618
+
619
+ Args:
620
+ csv_summary: Path to CSV output file
621
+ results: All processing results
622
+ successful: Successful processing results
623
+ """
624
+ if not csv_summary or not successful:
625
+ return
626
+
627
+ click.echo(f"\nExporting CSV summary to: {csv_summary}", err=True)
628
+ Path(csv_summary).parent.mkdir(parents=True, exist_ok=True)
629
+
630
+ with open(csv_summary, "w", newline="") as f:
631
+ writer = csv.writer(f)
632
+
633
+ # Header
634
+ writer.writerow(
635
+ [
636
+ "Video",
637
+ "Ground Contact Time (ms)",
638
+ "Flight Time (ms)",
639
+ "Jump Height (m)",
640
+ "Processing Time (s)",
641
+ "Status",
642
+ ]
643
+ )
644
+
645
+ # Data rows
646
+ for result in results:
647
+ writer.writerow(_create_csv_row_from_result(result))
648
+
649
+ click.echo("CSV summary written successfully", err=True)
650
+
651
+
652
+ def _process_batch(
653
+ video_files: list[str],
654
+ quality: str,
655
+ workers: int,
656
+ output_dir: str | None,
657
+ json_output_dir: str | None,
658
+ csv_summary: str | None,
659
+ expert_params: AnalysisParameters,
660
+ ) -> None:
661
+ """Process multiple videos in batch mode using parallel processing."""
662
+ click.echo(
663
+ f"\nBatch processing {len(video_files)} videos with {workers} workers", err=True
664
+ )
665
+ click.echo("=" * 70, err=True)
666
+
667
+ # Setup output directories
668
+ _setup_batch_output_dirs(output_dir, json_output_dir)
669
+
670
+ # Create video configurations
671
+ configs = _create_video_configs(
672
+ video_files, quality, output_dir, json_output_dir, expert_params
673
+ )
674
+
675
+ # Progress callback
676
+ completed = 0
677
+
678
+ def show_progress(result: DropJumpVideoResult) -> None:
679
+ nonlocal completed
680
+ completed += 1
681
+ status = "✓" if result.success else "✗"
682
+ video_name = Path(result.video_path).name
683
+ click.echo(
684
+ f"[{completed}/{len(configs)}] {status} {video_name} "
685
+ f"({result.processing_time:.1f}s)",
686
+ err=True,
687
+ )
688
+ if not result.success:
689
+ click.echo(f" Error: {result.error}", err=True)
690
+
691
+ # Process all videos
692
+ click.echo("\nProcessing videos...", err=True)
693
+ results = process_dropjump_videos_bulk(
694
+ configs, max_workers=workers, progress_callback=show_progress
695
+ )
696
+
697
+ # Display statistics
698
+ _compute_batch_statistics(results)
699
+
700
+ # Export CSV summary if requested
701
+ successful = [r for r in results if r.success]
702
+ _write_csv_summary(csv_summary, results, successful)
703
+
704
+ click.echo("\nBatch processing complete!", err=True)