kinemotion 0.8.3__py3-none-any.whl → 0.10.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/__init__.py CHANGED
@@ -1,3 +1,14 @@
1
1
  """Kinemotion: Video-based kinematic analysis for athletic performance."""
2
2
 
3
+ from .api import VideoConfig, VideoResult, process_video, process_videos_bulk
4
+ from .dropjump.kinematics import DropJumpMetrics
5
+
3
6
  __version__ = "0.1.0"
7
+
8
+ __all__ = [
9
+ "process_video",
10
+ "process_videos_bulk",
11
+ "VideoConfig",
12
+ "VideoResult",
13
+ "DropJumpMetrics",
14
+ ]
kinemotion/api.py ADDED
@@ -0,0 +1,428 @@
1
+ """Public API for programmatic use of kinemotion analysis."""
2
+
3
+ import time
4
+ from collections.abc import Callable
5
+ from concurrent.futures import ProcessPoolExecutor, as_completed
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+ import numpy as np
10
+
11
+ from .core.auto_tuning import (
12
+ QualityPreset,
13
+ analyze_video_sample,
14
+ auto_tune_parameters,
15
+ )
16
+ from .core.pose import PoseTracker
17
+ from .core.smoothing import smooth_landmarks, smooth_landmarks_advanced
18
+ from .core.video_io import VideoProcessor
19
+ from .dropjump.analysis import compute_average_foot_position, detect_ground_contact
20
+ from .dropjump.debug_overlay import DebugOverlayRenderer
21
+ from .dropjump.kinematics import DropJumpMetrics, calculate_drop_jump_metrics
22
+
23
+
24
+ @dataclass
25
+ class VideoResult:
26
+ """Result of processing a single video."""
27
+
28
+ video_path: str
29
+ success: bool
30
+ metrics: DropJumpMetrics | None = None
31
+ error: str | None = None
32
+ processing_time: float = 0.0
33
+
34
+
35
+ @dataclass
36
+ class VideoConfig:
37
+ """Configuration for processing a single video."""
38
+
39
+ video_path: str
40
+ drop_height: float
41
+ quality: str = "balanced"
42
+ output_video: str | None = None
43
+ json_output: str | None = None
44
+ drop_start_frame: int | None = None
45
+ smoothing_window: int | None = None
46
+ velocity_threshold: float | None = None
47
+ min_contact_frames: int | None = None
48
+ visibility_threshold: float | None = None
49
+ detection_confidence: float | None = None
50
+ tracking_confidence: float | None = None
51
+
52
+
53
+ def process_video(
54
+ video_path: str,
55
+ drop_height: float,
56
+ quality: str = "balanced",
57
+ output_video: str | None = None,
58
+ json_output: str | None = None,
59
+ drop_start_frame: int | None = None,
60
+ smoothing_window: int | None = None,
61
+ velocity_threshold: float | None = None,
62
+ min_contact_frames: int | None = None,
63
+ visibility_threshold: float | None = None,
64
+ detection_confidence: float | None = None,
65
+ tracking_confidence: float | None = None,
66
+ verbose: bool = False,
67
+ ) -> DropJumpMetrics:
68
+ """
69
+ Process a single drop jump video and return metrics.
70
+
71
+ Args:
72
+ video_path: Path to the input video file
73
+ drop_height: Height of drop box/platform in meters (e.g., 0.40 for 40cm)
74
+ quality: Analysis quality preset ("fast", "balanced", or "accurate")
75
+ output_video: Optional path for debug video output
76
+ json_output: Optional path for JSON metrics output
77
+ drop_start_frame: Optional manual drop start frame
78
+ smoothing_window: Optional override for smoothing window
79
+ velocity_threshold: Optional override for velocity threshold
80
+ min_contact_frames: Optional override for minimum contact frames
81
+ visibility_threshold: Optional override for visibility threshold
82
+ detection_confidence: Optional override for pose detection confidence
83
+ tracking_confidence: Optional override for pose tracking confidence
84
+ verbose: Print processing details
85
+
86
+ Returns:
87
+ DropJumpMetrics object containing analysis results
88
+
89
+ Raises:
90
+ ValueError: If video cannot be processed or parameters are invalid
91
+ FileNotFoundError: If video file does not exist
92
+ """
93
+ if not Path(video_path).exists():
94
+ raise FileNotFoundError(f"Video file not found: {video_path}")
95
+
96
+ # Convert quality string to enum
97
+ try:
98
+ quality_preset = QualityPreset(quality.lower())
99
+ except ValueError as e:
100
+ raise ValueError(
101
+ f"Invalid quality preset: {quality}. Must be 'fast', 'balanced', or 'accurate'"
102
+ ) from e
103
+
104
+ # Initialize video processor
105
+ with VideoProcessor(video_path) as video:
106
+ if verbose:
107
+ print(
108
+ f"Video: {video.width}x{video.height} @ {video.fps:.2f} fps, "
109
+ f"{video.frame_count} frames"
110
+ )
111
+
112
+ # Determine initial detection/tracking confidence from quality preset
113
+ initial_detection_conf = 0.5
114
+ initial_tracking_conf = 0.5
115
+
116
+ if quality_preset == QualityPreset.FAST:
117
+ initial_detection_conf = 0.3
118
+ initial_tracking_conf = 0.3
119
+ elif quality_preset == QualityPreset.ACCURATE:
120
+ initial_detection_conf = 0.6
121
+ initial_tracking_conf = 0.6
122
+
123
+ # Override with expert values if provided
124
+ if detection_confidence is not None:
125
+ initial_detection_conf = detection_confidence
126
+ if tracking_confidence is not None:
127
+ initial_tracking_conf = tracking_confidence
128
+
129
+ # Initialize pose tracker
130
+ tracker = PoseTracker(
131
+ min_detection_confidence=initial_detection_conf,
132
+ min_tracking_confidence=initial_tracking_conf,
133
+ )
134
+
135
+ # Process all frames
136
+ if verbose:
137
+ print("Tracking pose landmarks...")
138
+
139
+ landmarks_sequence = []
140
+ frames = []
141
+
142
+ while True:
143
+ frame = video.read_frame()
144
+ if frame is None:
145
+ break
146
+
147
+ frames.append(frame)
148
+ landmarks = tracker.process_frame(frame)
149
+ landmarks_sequence.append(landmarks)
150
+
151
+ tracker.close()
152
+
153
+ if not landmarks_sequence:
154
+ raise ValueError("No frames could be processed from video")
155
+
156
+ # Analyze video characteristics and auto-tune parameters
157
+ characteristics = analyze_video_sample(
158
+ landmarks_sequence, video.fps, video.frame_count
159
+ )
160
+
161
+ params = auto_tune_parameters(characteristics, quality_preset)
162
+
163
+ # Apply expert overrides if provided
164
+ if smoothing_window is not None:
165
+ params.smoothing_window = smoothing_window
166
+ if velocity_threshold is not None:
167
+ params.velocity_threshold = velocity_threshold
168
+ if min_contact_frames is not None:
169
+ params.min_contact_frames = min_contact_frames
170
+ if visibility_threshold is not None:
171
+ params.visibility_threshold = visibility_threshold
172
+
173
+ # Show selected parameters if verbose
174
+ if verbose:
175
+ print("\n" + "=" * 60)
176
+ print("AUTO-TUNED PARAMETERS")
177
+ print("=" * 60)
178
+ print(f"Video FPS: {video.fps:.2f}")
179
+ print(
180
+ f"Tracking quality: {characteristics.tracking_quality} "
181
+ f"(avg visibility: {characteristics.avg_visibility:.2f})"
182
+ )
183
+ print(f"Quality preset: {quality_preset.value}")
184
+ print("\nSelected parameters:")
185
+ print(f" smoothing_window: {params.smoothing_window}")
186
+ print(f" polyorder: {params.polyorder}")
187
+ print(f" velocity_threshold: {params.velocity_threshold:.4f}")
188
+ print(f" min_contact_frames: {params.min_contact_frames}")
189
+ print(f" visibility_threshold: {params.visibility_threshold}")
190
+ print(f" detection_confidence: {params.detection_confidence}")
191
+ print(f" tracking_confidence: {params.tracking_confidence}")
192
+ print(f" outlier_rejection: {params.outlier_rejection}")
193
+ print(f" bilateral_filter: {params.bilateral_filter}")
194
+ print(f" use_curvature: {params.use_curvature}")
195
+ print("=" * 60 + "\n")
196
+
197
+ # Apply smoothing with auto-tuned parameters
198
+ if params.outlier_rejection or params.bilateral_filter:
199
+ if verbose:
200
+ if params.outlier_rejection:
201
+ print("Smoothing landmarks with outlier rejection...")
202
+ if params.bilateral_filter:
203
+ print("Using bilateral temporal filter...")
204
+ smoothed_landmarks = smooth_landmarks_advanced(
205
+ landmarks_sequence,
206
+ window_length=params.smoothing_window,
207
+ polyorder=params.polyorder,
208
+ use_outlier_rejection=params.outlier_rejection,
209
+ use_bilateral=params.bilateral_filter,
210
+ )
211
+ else:
212
+ if verbose:
213
+ print("Smoothing landmarks...")
214
+ smoothed_landmarks = smooth_landmarks(
215
+ landmarks_sequence,
216
+ window_length=params.smoothing_window,
217
+ polyorder=params.polyorder,
218
+ )
219
+
220
+ # Extract vertical positions from feet
221
+ if verbose:
222
+ print("Extracting foot positions...")
223
+
224
+ position_list: list[float] = []
225
+ visibilities_list: list[float] = []
226
+
227
+ for frame_landmarks in smoothed_landmarks:
228
+ if frame_landmarks:
229
+ _, foot_y = compute_average_foot_position(frame_landmarks)
230
+ position_list.append(foot_y)
231
+
232
+ # Average visibility of foot landmarks
233
+ foot_vis = []
234
+ for key in ["left_ankle", "right_ankle", "left_heel", "right_heel"]:
235
+ if key in frame_landmarks:
236
+ foot_vis.append(frame_landmarks[key][2])
237
+ visibilities_list.append(float(np.mean(foot_vis)) if foot_vis else 0.0)
238
+ else:
239
+ position_list.append(position_list[-1] if position_list else 0.5)
240
+ visibilities_list.append(0.0)
241
+
242
+ vertical_positions: np.ndarray = np.array(position_list)
243
+ visibilities: np.ndarray = np.array(visibilities_list)
244
+
245
+ # Detect ground contact
246
+ contact_states = detect_ground_contact(
247
+ vertical_positions,
248
+ velocity_threshold=params.velocity_threshold,
249
+ min_contact_frames=params.min_contact_frames,
250
+ visibility_threshold=params.visibility_threshold,
251
+ visibilities=visibilities,
252
+ window_length=params.smoothing_window,
253
+ polyorder=params.polyorder,
254
+ )
255
+
256
+ # Calculate metrics
257
+ if verbose:
258
+ print("Calculating metrics...")
259
+ print(
260
+ f"Using drop height calibration: {drop_height}m ({drop_height*100:.0f}cm)"
261
+ )
262
+
263
+ metrics = calculate_drop_jump_metrics(
264
+ contact_states,
265
+ vertical_positions,
266
+ video.fps,
267
+ drop_height_m=drop_height,
268
+ drop_start_frame=drop_start_frame,
269
+ velocity_threshold=params.velocity_threshold,
270
+ smoothing_window=params.smoothing_window,
271
+ polyorder=params.polyorder,
272
+ use_curvature=params.use_curvature,
273
+ kinematic_correction_factor=1.0,
274
+ )
275
+
276
+ # Save JSON if requested
277
+ if json_output:
278
+ import json
279
+
280
+ output_path = Path(json_output)
281
+ output_path.write_text(json.dumps(metrics.to_dict(), indent=2))
282
+ if verbose:
283
+ print(f"Metrics written to: {json_output}")
284
+
285
+ # Generate debug video if requested
286
+ if output_video:
287
+ if verbose:
288
+ print(f"Generating debug video: {output_video}")
289
+
290
+ with DebugOverlayRenderer(
291
+ output_video,
292
+ video.width,
293
+ video.height,
294
+ video.display_width,
295
+ video.display_height,
296
+ video.fps,
297
+ ) as renderer:
298
+ for i, frame in enumerate(frames):
299
+ annotated = renderer.render_frame(
300
+ frame,
301
+ smoothed_landmarks[i],
302
+ contact_states[i],
303
+ i,
304
+ metrics,
305
+ use_com=False,
306
+ )
307
+ renderer.write_frame(annotated)
308
+
309
+ if verbose:
310
+ print(f"Debug video saved: {output_video}")
311
+
312
+ if verbose:
313
+ print("Analysis complete!")
314
+
315
+ return metrics
316
+
317
+
318
+ def process_videos_bulk(
319
+ configs: list[VideoConfig],
320
+ max_workers: int = 4,
321
+ progress_callback: Callable[[VideoResult], None] | None = None,
322
+ ) -> list[VideoResult]:
323
+ """
324
+ Process multiple videos in parallel using ProcessPoolExecutor.
325
+
326
+ Args:
327
+ configs: List of VideoConfig objects specifying video paths and parameters
328
+ max_workers: Maximum number of parallel workers (default: 4)
329
+ progress_callback: Optional callback function called after each video completes.
330
+ Receives VideoResult object.
331
+
332
+ Returns:
333
+ List of VideoResult objects, one per input video, in completion order
334
+
335
+ Example:
336
+ >>> configs = [
337
+ ... VideoConfig("video1.mp4", drop_height=0.40),
338
+ ... VideoConfig("video2.mp4", drop_height=0.30, quality="accurate"),
339
+ ... VideoConfig("video3.mp4", drop_height=0.50, output_video="debug3.mp4"),
340
+ ... ]
341
+ >>> results = process_videos_bulk(configs, max_workers=4)
342
+ >>> for result in results:
343
+ ... if result.success:
344
+ ... print(f"{result.video_path}: {result.metrics.jump_height_m:.3f}m")
345
+ ... else:
346
+ ... print(f"{result.video_path}: FAILED - {result.error}")
347
+ """
348
+ results: list[VideoResult] = []
349
+
350
+ # Use ProcessPoolExecutor for CPU-bound video processing
351
+ with ProcessPoolExecutor(max_workers=max_workers) as executor:
352
+ # Submit all jobs
353
+ future_to_config = {
354
+ executor.submit(_process_video_wrapper, config): config
355
+ for config in configs
356
+ }
357
+
358
+ # Process results as they complete
359
+ for future in as_completed(future_to_config):
360
+ config = future_to_config[future]
361
+ result: VideoResult
362
+
363
+ try:
364
+ result = future.result()
365
+ except Exception as exc:
366
+ # Handle unexpected errors
367
+ result = VideoResult(
368
+ video_path=config.video_path,
369
+ success=False,
370
+ error=f"Unexpected error: {str(exc)}",
371
+ )
372
+
373
+ results.append(result)
374
+
375
+ # Call progress callback if provided
376
+ if progress_callback:
377
+ progress_callback(result)
378
+
379
+ return results
380
+
381
+
382
+ def _process_video_wrapper(config: VideoConfig) -> VideoResult:
383
+ """
384
+ Wrapper function for parallel processing. Must be picklable (top-level function).
385
+
386
+ Args:
387
+ config: VideoConfig object with processing parameters
388
+
389
+ Returns:
390
+ VideoResult object with metrics or error information
391
+ """
392
+ start_time = time.time()
393
+
394
+ try:
395
+ metrics = process_video(
396
+ video_path=config.video_path,
397
+ drop_height=config.drop_height,
398
+ quality=config.quality,
399
+ output_video=config.output_video,
400
+ json_output=config.json_output,
401
+ drop_start_frame=config.drop_start_frame,
402
+ smoothing_window=config.smoothing_window,
403
+ velocity_threshold=config.velocity_threshold,
404
+ min_contact_frames=config.min_contact_frames,
405
+ visibility_threshold=config.visibility_threshold,
406
+ detection_confidence=config.detection_confidence,
407
+ tracking_confidence=config.tracking_confidence,
408
+ verbose=False, # Disable verbose in parallel mode
409
+ )
410
+
411
+ processing_time = time.time() - start_time
412
+
413
+ return VideoResult(
414
+ video_path=config.video_path,
415
+ success=True,
416
+ metrics=metrics,
417
+ processing_time=processing_time,
418
+ )
419
+
420
+ except Exception as e:
421
+ processing_time = time.time() - start_time
422
+
423
+ return VideoResult(
424
+ video_path=config.video_path,
425
+ success=False,
426
+ error=str(e),
427
+ processing_time=processing_time,
428
+ )
@@ -1,5 +1,7 @@
1
1
  """Command-line interface for drop jump analysis."""
2
2
 
3
+ import csv
4
+ import glob
3
5
  import json
4
6
  import sys
5
7
  from pathlib import Path
@@ -8,6 +10,7 @@ from typing import Any
8
10
  import click
9
11
  import numpy as np
10
12
 
13
+ from ..api import VideoConfig, VideoResult, process_videos_bulk
11
14
  from ..core.auto_tuning import (
12
15
  QualityPreset,
13
16
  analyze_video_sample,
@@ -25,7 +28,7 @@ from .kinematics import calculate_drop_jump_metrics
25
28
 
26
29
 
27
30
  @click.command(name="dropjump-analyze")
28
- @click.argument("video_path", type=click.Path(exists=True))
31
+ @click.argument("video_path", nargs=-1, type=click.Path(exists=False), required=True)
29
32
  @click.option(
30
33
  "--output",
31
34
  "-o",
@@ -65,6 +68,34 @@ from .kinematics import calculate_drop_jump_metrics
65
68
  is_flag=True,
66
69
  help="Show auto-selected parameters and analysis details",
67
70
  )
71
+ # Batch processing options
72
+ @click.option(
73
+ "--batch",
74
+ is_flag=True,
75
+ help="Enable batch processing mode for multiple videos",
76
+ )
77
+ @click.option(
78
+ "--workers",
79
+ type=int,
80
+ default=4,
81
+ help="Number of parallel workers for batch processing (default: 4)",
82
+ show_default=True,
83
+ )
84
+ @click.option(
85
+ "--output-dir",
86
+ type=click.Path(),
87
+ help="Directory for debug video outputs (batch mode only)",
88
+ )
89
+ @click.option(
90
+ "--json-output-dir",
91
+ type=click.Path(),
92
+ help="Directory for JSON metrics outputs (batch mode only)",
93
+ )
94
+ @click.option(
95
+ "--csv-summary",
96
+ type=click.Path(),
97
+ help="Path for CSV summary export (batch mode only)",
98
+ )
68
99
  # Expert parameters (hidden in help, but always available for advanced users)
69
100
  @click.option(
70
101
  "--drop-start-frame",
@@ -109,12 +140,17 @@ from .kinematics import calculate_drop_jump_metrics
109
140
  help="[EXPERT] Override pose tracking confidence",
110
141
  )
111
142
  def dropjump_analyze(
112
- video_path: str,
143
+ video_path: tuple[str, ...],
113
144
  output: str | None,
114
145
  json_output: str | None,
115
146
  drop_height: float,
116
147
  quality: str,
117
148
  verbose: bool,
149
+ batch: bool,
150
+ workers: int,
151
+ output_dir: str | None,
152
+ json_output_dir: str | None,
153
+ csv_summary: str | None,
118
154
  drop_start_frame: int | None,
119
155
  smoothing_window: int | None,
120
156
  velocity_threshold: float | None,
@@ -124,13 +160,101 @@ def dropjump_analyze(
124
160
  tracking_confidence: float | None,
125
161
  ) -> None:
126
162
  """
127
- Analyze drop-jump video to estimate ground contact time, flight time, and jump height.
163
+ Analyze drop-jump video(s) to estimate ground contact time, flight time, and jump height.
128
164
 
129
165
  Uses intelligent auto-tuning to select optimal parameters based on video characteristics.
130
166
  Parameters are automatically adjusted for frame rate, tracking quality, and analysis preset.
131
167
 
132
- VIDEO_PATH: Path to the input video file
168
+ VIDEO_PATH: Path(s) to video file(s). Supports glob patterns in batch mode
169
+ (e.g., "videos/*.mp4").
170
+
171
+ Examples:
172
+
173
+ \b
174
+ # Single video
175
+ kinemotion dropjump-analyze video.mp4 --drop-height 0.40
176
+
177
+ \b
178
+ # Batch mode with glob pattern
179
+ kinemotion dropjump-analyze videos/*.mp4 --batch --drop-height 0.40 --workers 4
180
+
181
+ \b
182
+ # Batch with output directories
183
+ kinemotion dropjump-analyze videos/*.mp4 --batch --drop-height 0.40 \\
184
+ --json-output-dir results/ --csv-summary summary.csv
133
185
  """
186
+ # Expand glob patterns and collect all video files
187
+ video_files: list[str] = []
188
+ for pattern in video_path:
189
+ expanded = glob.glob(pattern)
190
+ if expanded:
191
+ video_files.extend(expanded)
192
+ elif Path(pattern).exists():
193
+ # Direct path (not a glob pattern)
194
+ video_files.append(pattern)
195
+ else:
196
+ click.echo(f"Warning: No files found for pattern: {pattern}", err=True)
197
+
198
+ if not video_files:
199
+ click.echo("Error: No video files found", err=True)
200
+ sys.exit(1)
201
+
202
+ # Determine if batch mode should be used
203
+ use_batch = batch or len(video_files) > 1
204
+
205
+ if use_batch:
206
+ _process_batch(
207
+ video_files,
208
+ drop_height,
209
+ quality,
210
+ workers,
211
+ output_dir,
212
+ json_output_dir,
213
+ csv_summary,
214
+ drop_start_frame,
215
+ smoothing_window,
216
+ velocity_threshold,
217
+ min_contact_frames,
218
+ visibility_threshold,
219
+ detection_confidence,
220
+ tracking_confidence,
221
+ verbose,
222
+ )
223
+ else:
224
+ # Single video mode (original behavior)
225
+ _process_single(
226
+ video_files[0],
227
+ output,
228
+ json_output,
229
+ drop_height,
230
+ quality,
231
+ verbose,
232
+ drop_start_frame,
233
+ smoothing_window,
234
+ velocity_threshold,
235
+ min_contact_frames,
236
+ visibility_threshold,
237
+ detection_confidence,
238
+ tracking_confidence,
239
+ )
240
+
241
+
242
+ def _process_single(
243
+ video_path: str,
244
+ output: str | None,
245
+ json_output: str | None,
246
+ drop_height: float,
247
+ quality: str,
248
+ verbose: bool,
249
+ drop_start_frame: int | None,
250
+ smoothing_window: int | None,
251
+ velocity_threshold: float | None,
252
+ min_contact_frames: int | None,
253
+ visibility_threshold: float | None,
254
+ detection_confidence: float | None,
255
+ tracking_confidence: float | None,
256
+ ) -> None:
257
+ """Process a single video (original CLI behavior)."""
134
258
  click.echo(f"Analyzing video: {video_path}", err=True)
135
259
 
136
260
  # Convert quality string to enum
@@ -414,3 +538,194 @@ def dropjump_analyze(
414
538
  except Exception as e:
415
539
  click.echo(f"Error: {str(e)}", err=True)
416
540
  sys.exit(1)
541
+
542
+
543
+ def _process_batch(
544
+ video_files: list[str],
545
+ drop_height: float,
546
+ quality: str,
547
+ workers: int,
548
+ output_dir: str | None,
549
+ json_output_dir: str | None,
550
+ csv_summary: str | None,
551
+ drop_start_frame: int | None,
552
+ smoothing_window: int | None,
553
+ velocity_threshold: float | None,
554
+ min_contact_frames: int | None,
555
+ visibility_threshold: float | None,
556
+ detection_confidence: float | None,
557
+ tracking_confidence: float | None,
558
+ verbose: bool,
559
+ ) -> None:
560
+ """Process multiple videos in batch mode using parallel processing."""
561
+ click.echo(
562
+ f"\nBatch processing {len(video_files)} videos with {workers} workers", err=True
563
+ )
564
+ click.echo("=" * 70, err=True)
565
+
566
+ # Create output directories if specified
567
+ if output_dir:
568
+ Path(output_dir).mkdir(parents=True, exist_ok=True)
569
+ click.echo(f"Debug videos will be saved to: {output_dir}", err=True)
570
+
571
+ if json_output_dir:
572
+ Path(json_output_dir).mkdir(parents=True, exist_ok=True)
573
+ click.echo(f"JSON metrics will be saved to: {json_output_dir}", err=True)
574
+
575
+ # Build configurations for each video
576
+ configs: list[VideoConfig] = []
577
+ for video_file in video_files:
578
+ video_name = Path(video_file).stem
579
+
580
+ # Determine output paths
581
+ debug_video = None
582
+ if output_dir:
583
+ debug_video = str(Path(output_dir) / f"{video_name}_debug.mp4")
584
+
585
+ json_file = None
586
+ if json_output_dir:
587
+ json_file = str(Path(json_output_dir) / f"{video_name}.json")
588
+
589
+ config = VideoConfig(
590
+ video_path=video_file,
591
+ drop_height=drop_height,
592
+ quality=quality,
593
+ output_video=debug_video,
594
+ json_output=json_file,
595
+ drop_start_frame=drop_start_frame,
596
+ smoothing_window=smoothing_window,
597
+ velocity_threshold=velocity_threshold,
598
+ min_contact_frames=min_contact_frames,
599
+ visibility_threshold=visibility_threshold,
600
+ detection_confidence=detection_confidence,
601
+ tracking_confidence=tracking_confidence,
602
+ )
603
+ configs.append(config)
604
+
605
+ # Progress callback
606
+ completed = 0
607
+
608
+ def show_progress(result: VideoResult) -> None:
609
+ nonlocal completed
610
+ completed += 1
611
+ status = "✓" if result.success else "✗"
612
+ video_name = Path(result.video_path).name
613
+ click.echo(
614
+ f"[{completed}/{len(configs)}] {status} {video_name} "
615
+ f"({result.processing_time:.1f}s)",
616
+ err=True,
617
+ )
618
+ if not result.success:
619
+ click.echo(f" Error: {result.error}", err=True)
620
+
621
+ # Process all videos
622
+ click.echo("\nProcessing videos...", err=True)
623
+ results = process_videos_bulk(
624
+ configs, max_workers=workers, progress_callback=show_progress
625
+ )
626
+
627
+ # Generate summary
628
+ click.echo("\n" + "=" * 70, err=True)
629
+ click.echo("BATCH PROCESSING SUMMARY", err=True)
630
+ click.echo("=" * 70, err=True)
631
+
632
+ successful = [r for r in results if r.success]
633
+ failed = [r for r in results if not r.success]
634
+
635
+ click.echo(f"Total videos: {len(results)}", err=True)
636
+ click.echo(f"Successful: {len(successful)}", err=True)
637
+ click.echo(f"Failed: {len(failed)}", err=True)
638
+
639
+ if successful:
640
+ # Calculate average metrics
641
+ with_gct = [
642
+ r
643
+ for r in successful
644
+ if r.metrics and r.metrics.ground_contact_time is not None
645
+ ]
646
+ with_flight = [
647
+ r for r in successful if r.metrics and r.metrics.flight_time is not None
648
+ ]
649
+ with_jump = [
650
+ r for r in successful if r.metrics and r.metrics.jump_height is not None
651
+ ]
652
+
653
+ if with_gct:
654
+ avg_gct = sum(r.metrics.ground_contact_time * 1000 for r in with_gct) / len(
655
+ with_gct
656
+ )
657
+ click.echo(f"\nAverage ground contact time: {avg_gct:.1f} ms", err=True)
658
+
659
+ if with_flight:
660
+ avg_flight = sum(r.metrics.flight_time * 1000 for r in with_flight) / len(
661
+ with_flight
662
+ )
663
+ click.echo(f"Average flight time: {avg_flight:.1f} ms", err=True)
664
+
665
+ if with_jump:
666
+ avg_jump = sum(r.metrics.jump_height for r in with_jump) / len(with_jump)
667
+ click.echo(
668
+ f"Average jump height: {avg_jump:.3f} m ({avg_jump * 100:.1f} cm)",
669
+ err=True,
670
+ )
671
+
672
+ # Export CSV summary if requested
673
+ if csv_summary and successful:
674
+ click.echo(f"\nExporting CSV summary to: {csv_summary}", err=True)
675
+ Path(csv_summary).parent.mkdir(parents=True, exist_ok=True)
676
+
677
+ with open(csv_summary, "w", newline="") as f:
678
+ writer = csv.writer(f)
679
+
680
+ # Header
681
+ writer.writerow(
682
+ [
683
+ "Video",
684
+ "Ground Contact Time (ms)",
685
+ "Flight Time (ms)",
686
+ "Jump Height (m)",
687
+ "Processing Time (s)",
688
+ "Status",
689
+ ]
690
+ )
691
+
692
+ # Data rows
693
+ for result in results:
694
+ if result.success and result.metrics:
695
+ writer.writerow(
696
+ [
697
+ Path(result.video_path).name,
698
+ (
699
+ f"{result.metrics.ground_contact_time * 1000:.1f}"
700
+ if result.metrics.ground_contact_time
701
+ else "N/A"
702
+ ),
703
+ (
704
+ f"{result.metrics.flight_time * 1000:.1f}"
705
+ if result.metrics.flight_time
706
+ else "N/A"
707
+ ),
708
+ (
709
+ f"{result.metrics.jump_height:.3f}"
710
+ if result.metrics.jump_height
711
+ else "N/A"
712
+ ),
713
+ f"{result.processing_time:.2f}",
714
+ "Success",
715
+ ]
716
+ )
717
+ else:
718
+ writer.writerow(
719
+ [
720
+ Path(result.video_path).name,
721
+ "N/A",
722
+ "N/A",
723
+ "N/A",
724
+ f"{result.processing_time:.2f}",
725
+ f"Failed: {result.error}",
726
+ ]
727
+ )
728
+
729
+ click.echo("CSV summary written successfully", err=True)
730
+
731
+ click.echo("\nBatch processing complete!", err=True)
kinemotion/py.typed ADDED
File without changes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kinemotion
3
- Version: 0.8.3
3
+ Version: 0.10.0
4
4
  Summary: Video-based kinematic analysis for athletic performance
5
5
  Project-URL: Homepage, https://github.com/feniix/kinemotion
6
6
  Project-URL: Repository, https://github.com/feniix/kinemotion
@@ -1,4 +1,5 @@
1
- kinemotion/__init__.py,sha256=JhS0ZTgcTdcMH5WcIyWxEqZJPOoBUSKX8tT8hsG-xWk,98
1
+ kinemotion/__init__.py,sha256=Z85xg29NA-r4IjrSbAkJpMFigyxACFGUb-37AiMp6YY,350
2
+ kinemotion/api.py,sha256=3Hswx5lfyWc-EanS6iV-4MPUa_uB5t8BpGe4EB4gIIQ,15453
2
3
  kinemotion/cli.py,sha256=2IFA2_TE9a5zBtmGVzv5SnX39w7yPuBlw42dL7ca25U,402
3
4
  kinemotion/core/__init__.py,sha256=3yzDhb5PekDNjydqrs8aWGneUGJBt-lB0SoB_Y2FXqU,1010
4
5
  kinemotion/core/auto_tuning.py,sha256=cvmxUI-CbahpOJQtR2r5jOx4Q6yKPe3DO1o15hOQIdw,10508
@@ -8,11 +9,12 @@ kinemotion/core/smoothing.py,sha256=VVv95auiuah_GPG3jxiQPyiYXF5i3B4fF9UGI5FLX-Q,
8
9
  kinemotion/core/video_io.py,sha256=z8Z0qbNaKbcdB40KnbNOBMzab3BbgnhBxp-mUBYeXgM,6577
9
10
  kinemotion/dropjump/__init__.py,sha256=yc1XiZ9vfo5h_n7PKVSiX2TTgaIfGL7Y7SkQtiDZj_E,838
10
11
  kinemotion/dropjump/analysis.py,sha256=HfJt2t9IsMBiBUz7apIzdxbRH9QqzlFnDVVWcKhU3ow,23291
11
- kinemotion/dropjump/cli.py,sha256=nhcqYClTx9R0XeTduJCNspltNgeaK4W8ZUT1ACB8GFI,15601
12
+ kinemotion/dropjump/cli.py,sha256=Wc0Z3w8RBaozEQ-Oc8_zTIUrfsgRwhD97kH4P8ZozzQ,25759
12
13
  kinemotion/dropjump/debug_overlay.py,sha256=hmEtadqYP8K-kGr_Q03KDQyl1152-YSPeRJzEXMyuhs,8687
13
14
  kinemotion/dropjump/kinematics.py,sha256=RceIH2HndpHQpcOQd56MmEdXQNEst-CWXfBKPJk2g3Y,17659
14
- kinemotion-0.8.3.dist-info/METADATA,sha256=BM4Um2TVjiQKdQjPY4gdIRi1x2B7DuRuZlyFNpzNpvo,16318
15
- kinemotion-0.8.3.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
16
- kinemotion-0.8.3.dist-info/entry_points.txt,sha256=zaqnAnjLvcdrk1Qvj5nvXZCZ2gp0prS7it1zTJygcIY,50
17
- kinemotion-0.8.3.dist-info/licenses/LICENSE,sha256=KZajvqsHw0NoOHOi2q0FZ4NBe9HdV6oey-IPYAtHXfg,1088
18
- kinemotion-0.8.3.dist-info/RECORD,,
15
+ kinemotion/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
16
+ kinemotion-0.10.0.dist-info/METADATA,sha256=mwCvLjVVaKjlIptGUxPKeQD5BH6IAs5luVm7OIUd1SU,16319
17
+ kinemotion-0.10.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
18
+ kinemotion-0.10.0.dist-info/entry_points.txt,sha256=zaqnAnjLvcdrk1Qvj5nvXZCZ2gp0prS7it1zTJygcIY,50
19
+ kinemotion-0.10.0.dist-info/licenses/LICENSE,sha256=KZajvqsHw0NoOHOi2q0FZ4NBe9HdV6oey-IPYAtHXfg,1088
20
+ kinemotion-0.10.0.dist-info/RECORD,,