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

@@ -12,8 +12,12 @@ import click
12
12
  import numpy as np
13
13
 
14
14
  from ..api import VideoConfig, VideoResult, process_videos_bulk
15
+ from ..core.auto_tuning import (
16
+ AnalysisParameters as AutoTunedParams,
17
+ )
15
18
  from ..core.auto_tuning import (
16
19
  QualityPreset,
20
+ VideoCharacteristics,
17
21
  analyze_video_sample,
18
22
  auto_tune_parameters,
19
23
  )
@@ -21,11 +25,12 @@ from ..core.pose import PoseTracker
21
25
  from ..core.smoothing import smooth_landmarks, smooth_landmarks_advanced
22
26
  from ..core.video_io import VideoProcessor
23
27
  from .analysis import (
28
+ ContactState,
24
29
  compute_average_foot_position,
25
30
  detect_ground_contact,
26
31
  )
27
32
  from .debug_overlay import DebugOverlayRenderer
28
- from .kinematics import calculate_drop_jump_metrics
33
+ from .kinematics import DropJumpMetrics, calculate_drop_jump_metrics
29
34
 
30
35
 
31
36
  @dataclass
@@ -251,6 +256,260 @@ def dropjump_analyze(
251
256
  )
252
257
 
253
258
 
259
+ def _determine_initial_confidence(
260
+ quality_preset: QualityPreset,
261
+ expert_params: AnalysisParameters,
262
+ ) -> tuple[float, float]:
263
+ """Determine initial detection and tracking confidence levels.
264
+
265
+ Args:
266
+ quality_preset: Quality preset enum
267
+ expert_params: Expert parameter overrides
268
+
269
+ Returns:
270
+ Tuple of (detection_confidence, tracking_confidence)
271
+ """
272
+ initial_detection_conf = 0.5
273
+ initial_tracking_conf = 0.5
274
+
275
+ if quality_preset == QualityPreset.FAST:
276
+ initial_detection_conf = 0.3
277
+ initial_tracking_conf = 0.3
278
+ elif quality_preset == QualityPreset.ACCURATE:
279
+ initial_detection_conf = 0.6
280
+ initial_tracking_conf = 0.6
281
+
282
+ # Override with expert values if provided
283
+ if expert_params.detection_confidence is not None:
284
+ initial_detection_conf = expert_params.detection_confidence
285
+ if expert_params.tracking_confidence is not None:
286
+ initial_tracking_conf = expert_params.tracking_confidence
287
+
288
+ return initial_detection_conf, initial_tracking_conf
289
+
290
+
291
+ def _track_all_frames(video: VideoProcessor, tracker: PoseTracker) -> tuple[list, list]:
292
+ """Track pose landmarks in all video frames.
293
+
294
+ Args:
295
+ video: Video processor
296
+ tracker: Pose tracker
297
+
298
+ Returns:
299
+ Tuple of (frames, landmarks_sequence)
300
+ """
301
+ click.echo("Tracking pose landmarks...", err=True)
302
+ landmarks_sequence = []
303
+ frames = []
304
+
305
+ bar: Any
306
+ with click.progressbar(length=video.frame_count, label="Processing frames") as bar:
307
+ while True:
308
+ frame = video.read_frame()
309
+ if frame is None:
310
+ break
311
+
312
+ frames.append(frame)
313
+ landmarks = tracker.process_frame(frame)
314
+ landmarks_sequence.append(landmarks)
315
+
316
+ bar.update(1)
317
+
318
+ tracker.close()
319
+ return frames, landmarks_sequence
320
+
321
+
322
+ def _apply_expert_param_overrides(
323
+ params: AutoTunedParams, expert_params: AnalysisParameters
324
+ ) -> AutoTunedParams:
325
+ """Apply expert parameter overrides to auto-tuned parameters.
326
+
327
+ Args:
328
+ params: Auto-tuned parameters
329
+ expert_params: Expert overrides
330
+
331
+ Returns:
332
+ Modified params object (mutated in place)
333
+ """
334
+ if expert_params.smoothing_window is not None:
335
+ params.smoothing_window = expert_params.smoothing_window
336
+ if expert_params.velocity_threshold is not None:
337
+ params.velocity_threshold = expert_params.velocity_threshold
338
+ if expert_params.min_contact_frames is not None:
339
+ params.min_contact_frames = expert_params.min_contact_frames
340
+ if expert_params.visibility_threshold is not None:
341
+ params.visibility_threshold = expert_params.visibility_threshold
342
+ return params
343
+
344
+
345
+ def _print_auto_tuned_params(
346
+ video: VideoProcessor,
347
+ characteristics: VideoCharacteristics,
348
+ quality_preset: QualityPreset,
349
+ params: AutoTunedParams,
350
+ ) -> None:
351
+ """Print auto-tuned parameters in verbose mode.
352
+
353
+ Args:
354
+ video: Video processor
355
+ characteristics: Video characteristics
356
+ quality_preset: Quality preset
357
+ params: Auto-tuned parameters
358
+ """
359
+ click.echo("\n" + "=" * 60, err=True)
360
+ click.echo("AUTO-TUNED PARAMETERS", err=True)
361
+ click.echo("=" * 60, err=True)
362
+ click.echo(f"Video FPS: {video.fps:.2f}", err=True)
363
+ click.echo(
364
+ f"Tracking quality: {characteristics.tracking_quality} "
365
+ f"(avg visibility: {characteristics.avg_visibility:.2f})",
366
+ err=True,
367
+ )
368
+ click.echo(f"Quality preset: {quality_preset.value}", err=True)
369
+ click.echo("\nSelected parameters:", err=True)
370
+ click.echo(f" smoothing_window: {params.smoothing_window}", err=True)
371
+ click.echo(f" polyorder: {params.polyorder}", err=True)
372
+ click.echo(f" velocity_threshold: {params.velocity_threshold:.4f}", err=True)
373
+ click.echo(f" min_contact_frames: {params.min_contact_frames}", err=True)
374
+ click.echo(f" visibility_threshold: {params.visibility_threshold}", err=True)
375
+ click.echo(f" detection_confidence: {params.detection_confidence}", err=True)
376
+ click.echo(f" tracking_confidence: {params.tracking_confidence}", err=True)
377
+ click.echo(f" outlier_rejection: {params.outlier_rejection}", err=True)
378
+ click.echo(f" bilateral_filter: {params.bilateral_filter}", err=True)
379
+ click.echo(f" use_curvature: {params.use_curvature}", err=True)
380
+ click.echo("=" * 60 + "\n", err=True)
381
+
382
+
383
+ def _smooth_landmark_sequence(
384
+ landmarks_sequence: list, params: AutoTunedParams
385
+ ) -> list:
386
+ """Apply smoothing to landmark sequence.
387
+
388
+ Args:
389
+ landmarks_sequence: Raw landmark sequence
390
+ params: Auto-tuned parameters
391
+
392
+ Returns:
393
+ Smoothed landmarks
394
+ """
395
+ if params.outlier_rejection or params.bilateral_filter:
396
+ if params.outlier_rejection:
397
+ click.echo("Smoothing landmarks with outlier rejection...", err=True)
398
+ if params.bilateral_filter:
399
+ click.echo(
400
+ "Using bilateral temporal filter for edge-preserving smoothing...",
401
+ err=True,
402
+ )
403
+ return smooth_landmarks_advanced(
404
+ landmarks_sequence,
405
+ window_length=params.smoothing_window,
406
+ polyorder=params.polyorder,
407
+ use_outlier_rejection=params.outlier_rejection,
408
+ use_bilateral=params.bilateral_filter,
409
+ )
410
+ else:
411
+ click.echo("Smoothing landmarks...", err=True)
412
+ return smooth_landmarks(
413
+ landmarks_sequence,
414
+ window_length=params.smoothing_window,
415
+ polyorder=params.polyorder,
416
+ )
417
+
418
+
419
+ def _extract_positions_and_visibilities(
420
+ smoothed_landmarks: list,
421
+ ) -> tuple[np.ndarray, np.ndarray]:
422
+ """Extract vertical positions and visibilities from landmarks.
423
+
424
+ Args:
425
+ smoothed_landmarks: Smoothed landmark sequence
426
+
427
+ Returns:
428
+ Tuple of (vertical_positions, visibilities)
429
+ """
430
+ click.echo("Extracting foot positions...", err=True)
431
+
432
+ position_list: list[float] = []
433
+ visibilities_list: list[float] = []
434
+
435
+ for frame_landmarks in smoothed_landmarks:
436
+ if frame_landmarks:
437
+ _, foot_y = compute_average_foot_position(frame_landmarks)
438
+ position_list.append(foot_y)
439
+
440
+ # Average visibility of foot landmarks
441
+ foot_vis = []
442
+ for key in ["left_ankle", "right_ankle", "left_heel", "right_heel"]:
443
+ if key in frame_landmarks:
444
+ foot_vis.append(frame_landmarks[key][2])
445
+ visibilities_list.append(float(np.mean(foot_vis)) if foot_vis else 0.0)
446
+ else:
447
+ position_list.append(position_list[-1] if position_list else 0.5)
448
+ visibilities_list.append(0.0)
449
+
450
+ return np.array(position_list), np.array(visibilities_list)
451
+
452
+
453
+ def _create_debug_video(
454
+ output: str,
455
+ video: VideoProcessor,
456
+ frames: list,
457
+ smoothed_landmarks: list,
458
+ contact_states: list[ContactState],
459
+ metrics: DropJumpMetrics,
460
+ ) -> None:
461
+ """Generate debug video with overlays.
462
+
463
+ Args:
464
+ output: Output video path
465
+ video: Video processor
466
+ frames: Video frames
467
+ smoothed_landmarks: Smoothed landmarks
468
+ contact_states: Contact states
469
+ metrics: Calculated metrics
470
+ """
471
+ click.echo(f"Generating debug video: {output}", err=True)
472
+ if video.display_width != video.width or video.display_height != video.height:
473
+ click.echo(f"Source video encoded: {video.width}x{video.height}", err=True)
474
+ click.echo(
475
+ f"Output dimensions: {video.display_width}x{video.display_height} "
476
+ f"(respecting display aspect ratio)",
477
+ err=True,
478
+ )
479
+ else:
480
+ click.echo(
481
+ f"Output dimensions: {video.width}x{video.height} "
482
+ f"(matching source video aspect ratio)",
483
+ err=True,
484
+ )
485
+
486
+ with DebugOverlayRenderer(
487
+ output,
488
+ video.width,
489
+ video.height,
490
+ video.display_width,
491
+ video.display_height,
492
+ video.fps,
493
+ ) as renderer:
494
+ render_bar: Any
495
+ with click.progressbar(
496
+ length=len(frames), label="Rendering frames"
497
+ ) as render_bar:
498
+ for i, frame in enumerate(frames):
499
+ annotated = renderer.render_frame(
500
+ frame,
501
+ smoothed_landmarks[i],
502
+ contact_states[i],
503
+ i,
504
+ metrics,
505
+ use_com=False,
506
+ )
507
+ renderer.write_frame(annotated)
508
+ render_bar.update(1)
509
+
510
+ click.echo(f"Debug video saved: {output}", err=True)
511
+
512
+
254
513
  def _process_single(
255
514
  video_path: str,
256
515
  output: str | None,
@@ -263,11 +522,9 @@ def _process_single(
263
522
  """Process a single video (original CLI behavior)."""
264
523
  click.echo(f"Analyzing video: {video_path}", err=True)
265
524
 
266
- # Convert quality string to enum
267
525
  quality_preset = QualityPreset(quality.lower())
268
526
 
269
527
  try:
270
- # Initialize video processor
271
528
  with VideoProcessor(video_path) as video:
272
529
  click.echo(
273
530
  f"Video: {video.width}x{video.height} @ {video.fps:.2f} fps, "
@@ -275,180 +532,42 @@ def _process_single(
275
532
  err=True,
276
533
  )
277
534
 
278
- # ================================================================
279
- # STEP 1: Auto-tune parameters based on video characteristics
280
- # ================================================================
281
-
282
- # Analyze video characteristics from a sample to determine optimal parameters
283
- # We'll use detection/tracking confidence from quality preset for initial tracking
284
- initial_detection_conf = 0.5
285
- initial_tracking_conf = 0.5
286
-
287
- if quality_preset == QualityPreset.FAST:
288
- initial_detection_conf = 0.3
289
- initial_tracking_conf = 0.3
290
- elif quality_preset == QualityPreset.ACCURATE:
291
- initial_detection_conf = 0.6
292
- initial_tracking_conf = 0.6
293
-
294
- # Override with expert values if provided
295
- if expert_params.detection_confidence is not None:
296
- initial_detection_conf = expert_params.detection_confidence
297
- if expert_params.tracking_confidence is not None:
298
- initial_tracking_conf = expert_params.tracking_confidence
299
-
300
- # Initialize pose tracker
301
- tracker = PoseTracker(
302
- min_detection_confidence=initial_detection_conf,
303
- min_tracking_confidence=initial_tracking_conf,
535
+ # Determine confidence levels
536
+ detection_conf, tracking_conf = _determine_initial_confidence(
537
+ quality_preset, expert_params
304
538
  )
305
539
 
306
- # Process all frames
307
- click.echo("Tracking pose landmarks...", err=True)
308
- landmarks_sequence = []
309
- frames = []
310
-
311
- bar: Any
312
- with click.progressbar(
313
- length=video.frame_count, label="Processing frames"
314
- ) as bar:
315
- while True:
316
- frame = video.read_frame()
317
- if frame is None:
318
- break
319
-
320
- frames.append(frame)
321
- landmarks = tracker.process_frame(frame)
322
- landmarks_sequence.append(landmarks)
323
-
324
- bar.update(1)
325
-
326
- tracker.close()
540
+ # Track all frames
541
+ tracker = PoseTracker(
542
+ min_detection_confidence=detection_conf,
543
+ min_tracking_confidence=tracking_conf,
544
+ )
545
+ frames, landmarks_sequence = _track_all_frames(video, tracker)
327
546
 
328
547
  if not landmarks_sequence:
329
548
  click.echo("Error: No frames processed", err=True)
330
549
  sys.exit(1)
331
550
 
332
- # ================================================================
333
- # STEP 2: Analyze video characteristics and auto-tune parameters
334
- # ================================================================
335
-
551
+ # Auto-tune parameters
336
552
  characteristics = analyze_video_sample(
337
553
  landmarks_sequence, video.fps, video.frame_count
338
554
  )
339
-
340
- # Auto-tune parameters based on video characteristics
341
555
  params = auto_tune_parameters(characteristics, quality_preset)
556
+ params = _apply_expert_param_overrides(params, expert_params)
342
557
 
343
- # Apply expert overrides if provided
344
- if expert_params.smoothing_window is not None:
345
- params.smoothing_window = expert_params.smoothing_window
346
- if expert_params.velocity_threshold is not None:
347
- params.velocity_threshold = expert_params.velocity_threshold
348
- if expert_params.min_contact_frames is not None:
349
- params.min_contact_frames = expert_params.min_contact_frames
350
- if expert_params.visibility_threshold is not None:
351
- params.visibility_threshold = expert_params.visibility_threshold
352
-
353
- # Show selected parameters if verbose
558
+ # Show parameters if verbose
354
559
  if verbose:
355
- click.echo("\n" + "=" * 60, err=True)
356
- click.echo("AUTO-TUNED PARAMETERS", err=True)
357
- click.echo("=" * 60, err=True)
358
- click.echo(f"Video FPS: {video.fps:.2f}", err=True)
359
- click.echo(
360
- f"Tracking quality: {characteristics.tracking_quality} "
361
- f"(avg visibility: {characteristics.avg_visibility:.2f})",
362
- err=True,
363
- )
364
- click.echo(f"Quality preset: {quality_preset.value}", err=True)
365
- click.echo("\nSelected parameters:", err=True)
366
- click.echo(f" smoothing_window: {params.smoothing_window}", err=True)
367
- click.echo(f" polyorder: {params.polyorder}", err=True)
368
- click.echo(
369
- f" velocity_threshold: {params.velocity_threshold:.4f}", err=True
370
- )
371
- click.echo(
372
- f" min_contact_frames: {params.min_contact_frames}", err=True
373
- )
374
- click.echo(
375
- f" visibility_threshold: {params.visibility_threshold}", err=True
376
- )
377
- click.echo(
378
- f" detection_confidence: {params.detection_confidence}", err=True
379
- )
380
- click.echo(
381
- f" tracking_confidence: {params.tracking_confidence}", err=True
382
- )
383
- click.echo(f" outlier_rejection: {params.outlier_rejection}", err=True)
384
- click.echo(f" bilateral_filter: {params.bilateral_filter}", err=True)
385
- click.echo(f" use_curvature: {params.use_curvature}", err=True)
386
- click.echo("=" * 60 + "\n", err=True)
387
-
388
- # ================================================================
389
- # STEP 3: Apply smoothing with auto-tuned parameters
390
- # ================================================================
391
-
392
- # Smooth landmarks using auto-tuned parameters
393
- if params.outlier_rejection or params.bilateral_filter:
394
- if params.outlier_rejection:
395
- click.echo(
396
- "Smoothing landmarks with outlier rejection...", err=True
397
- )
398
- if params.bilateral_filter:
399
- click.echo(
400
- "Using bilateral temporal filter for edge-preserving smoothing...",
401
- err=True,
402
- )
403
- smoothed_landmarks = smooth_landmarks_advanced(
404
- landmarks_sequence,
405
- window_length=params.smoothing_window,
406
- polyorder=params.polyorder,
407
- use_outlier_rejection=params.outlier_rejection,
408
- use_bilateral=params.bilateral_filter,
409
- )
410
- else:
411
- click.echo("Smoothing landmarks...", err=True)
412
- smoothed_landmarks = smooth_landmarks(
413
- landmarks_sequence,
414
- window_length=params.smoothing_window,
415
- polyorder=params.polyorder,
416
- )
560
+ _print_auto_tuned_params(video, characteristics, quality_preset, params)
561
+
562
+ # Apply smoothing
563
+ smoothed_landmarks = _smooth_landmark_sequence(landmarks_sequence, params)
417
564
 
418
- # Extract vertical positions from feet
419
- click.echo("Extracting foot positions...", err=True)
420
-
421
- position_list: list[float] = []
422
- visibilities_list: list[float] = []
423
-
424
- for frame_landmarks in smoothed_landmarks:
425
- if frame_landmarks:
426
- # Use average foot position
427
- _, foot_y = compute_average_foot_position(frame_landmarks)
428
- position_list.append(foot_y)
429
-
430
- # Average visibility of foot landmarks
431
- foot_vis = []
432
- for key in [
433
- "left_ankle",
434
- "right_ankle",
435
- "left_heel",
436
- "right_heel",
437
- ]:
438
- if key in frame_landmarks:
439
- foot_vis.append(frame_landmarks[key][2])
440
- visibilities_list.append(
441
- float(np.mean(foot_vis)) if foot_vis else 0.0
442
- )
443
- else:
444
- # Use previous position if available, otherwise default
445
- position_list.append(position_list[-1] if position_list else 0.5)
446
- visibilities_list.append(0.0)
447
-
448
- vertical_positions: np.ndarray = np.array(position_list)
449
- visibilities: np.ndarray = np.array(visibilities_list)
450
-
451
- # Detect ground contact using auto-tuned parameters
565
+ # Extract positions
566
+ vertical_positions, visibilities = _extract_positions_and_visibilities(
567
+ smoothed_landmarks
568
+ )
569
+
570
+ # Detect ground contact
452
571
  contact_states = detect_ground_contact(
453
572
  vertical_positions,
454
573
  velocity_threshold=params.velocity_threshold,
@@ -475,67 +594,22 @@ def _process_single(
475
594
  smoothing_window=params.smoothing_window,
476
595
  polyorder=params.polyorder,
477
596
  use_curvature=params.use_curvature,
478
- kinematic_correction_factor=1.0, # Always 1.0 now (no experimental correction)
597
+ kinematic_correction_factor=1.0,
479
598
  )
480
599
 
481
- # Output metrics as JSON
482
- metrics_dict = metrics.to_dict()
483
- metrics_json = json.dumps(metrics_dict, indent=2)
484
-
600
+ # Output metrics
601
+ metrics_json = json.dumps(metrics.to_dict(), indent=2)
485
602
  if json_output:
486
- output_path = Path(json_output)
487
- output_path.write_text(metrics_json)
603
+ Path(json_output).write_text(metrics_json)
488
604
  click.echo(f"Metrics written to: {json_output}", err=True)
489
605
  else:
490
606
  click.echo(metrics_json)
491
607
 
492
608
  # Generate debug video if requested
493
609
  if output:
494
- click.echo(f"Generating debug video: {output}", err=True)
495
- if (
496
- video.display_width != video.width
497
- or video.display_height != video.height
498
- ):
499
- click.echo(
500
- f"Source video encoded: {video.width}x{video.height}",
501
- err=True,
502
- )
503
- click.echo(
504
- f"Output dimensions: {video.display_width}x{video.display_height} "
505
- f"(respecting display aspect ratio)",
506
- err=True,
507
- )
508
- else:
509
- click.echo(
510
- f"Output dimensions: {video.width}x{video.height} "
511
- f"(matching source video aspect ratio)",
512
- err=True,
513
- )
514
- with DebugOverlayRenderer(
515
- output,
516
- video.width,
517
- video.height,
518
- video.display_width,
519
- video.display_height,
520
- video.fps,
521
- ) as renderer:
522
- render_bar: Any
523
- with click.progressbar(
524
- length=len(frames), label="Rendering frames"
525
- ) as render_bar:
526
- for i, frame in enumerate(frames):
527
- annotated = renderer.render_frame(
528
- frame,
529
- smoothed_landmarks[i],
530
- contact_states[i],
531
- i,
532
- metrics,
533
- use_com=False,
534
- )
535
- renderer.write_frame(annotated)
536
- render_bar.update(1)
537
-
538
- click.echo(f"Debug video saved: {output}", err=True)
610
+ _create_debug_video(
611
+ output, video, frames, smoothed_landmarks, contact_states, metrics
612
+ )
539
613
 
540
614
  click.echo("Analysis complete!", err=True)
541
615
 
@@ -544,23 +618,15 @@ def _process_single(
544
618
  sys.exit(1)
545
619
 
546
620
 
547
- def _process_batch(
548
- video_files: list[str],
549
- drop_height: float,
550
- quality: str,
551
- workers: int,
552
- output_dir: str | None,
553
- json_output_dir: str | None,
554
- csv_summary: str | None,
555
- expert_params: AnalysisParameters,
621
+ def _setup_batch_output_dirs(
622
+ output_dir: str | None, json_output_dir: str | None
556
623
  ) -> None:
557
- """Process multiple videos in batch mode using parallel processing."""
558
- click.echo(
559
- f"\nBatch processing {len(video_files)} videos with {workers} workers", err=True
560
- )
561
- click.echo("=" * 70, err=True)
624
+ """Create output directories for batch processing.
562
625
 
563
- # Create output directories if specified
626
+ Args:
627
+ output_dir: Debug video output directory
628
+ json_output_dir: JSON metrics output directory
629
+ """
564
630
  if output_dir:
565
631
  Path(output_dir).mkdir(parents=True, exist_ok=True)
566
632
  click.echo(f"Debug videos will be saved to: {output_dir}", err=True)
@@ -569,12 +635,32 @@ def _process_batch(
569
635
  Path(json_output_dir).mkdir(parents=True, exist_ok=True)
570
636
  click.echo(f"JSON metrics will be saved to: {json_output_dir}", err=True)
571
637
 
572
- # Build configurations for each video
638
+
639
+ def _create_video_configs(
640
+ video_files: list[str],
641
+ drop_height: float,
642
+ quality: str,
643
+ output_dir: str | None,
644
+ json_output_dir: str | None,
645
+ expert_params: AnalysisParameters,
646
+ ) -> list[VideoConfig]:
647
+ """Build configuration objects for each video.
648
+
649
+ Args:
650
+ video_files: List of video file paths
651
+ drop_height: Drop height in meters
652
+ quality: Quality preset
653
+ output_dir: Debug video output directory
654
+ json_output_dir: JSON metrics output directory
655
+ expert_params: Expert parameter overrides
656
+
657
+ Returns:
658
+ List of VideoConfig objects
659
+ """
573
660
  configs: list[VideoConfig] = []
574
661
  for video_file in video_files:
575
662
  video_name = Path(video_file).stem
576
663
 
577
- # Determine output paths
578
664
  debug_video = None
579
665
  if output_dir:
580
666
  debug_video = str(Path(output_dir) / f"{video_name}_debug.mp4")
@@ -599,29 +685,15 @@ def _process_batch(
599
685
  )
600
686
  configs.append(config)
601
687
 
602
- # Progress callback
603
- completed = 0
688
+ return configs
604
689
 
605
- def show_progress(result: VideoResult) -> None:
606
- nonlocal completed
607
- completed += 1
608
- status = "✓" if result.success else "✗"
609
- video_name = Path(result.video_path).name
610
- click.echo(
611
- f"[{completed}/{len(configs)}] {status} {video_name} "
612
- f"({result.processing_time:.1f}s)",
613
- err=True,
614
- )
615
- if not result.success:
616
- click.echo(f" Error: {result.error}", err=True)
617
690
 
618
- # Process all videos
619
- click.echo("\nProcessing videos...", err=True)
620
- results = process_videos_bulk(
621
- configs, max_workers=workers, progress_callback=show_progress
622
- )
691
+ def _compute_batch_statistics(results: list[VideoResult]) -> None:
692
+ """Compute and display batch processing statistics.
623
693
 
624
- # Generate summary
694
+ Args:
695
+ results: List of video processing results
696
+ """
625
697
  click.echo("\n" + "=" * 70, err=True)
626
698
  click.echo("BATCH PROCESSING SUMMARY", err=True)
627
699
  click.echo("=" * 70, err=True)
@@ -648,7 +720,6 @@ def _process_batch(
648
720
  ]
649
721
 
650
722
  if with_gct:
651
- # Type assertion: filtering ensures metrics and ground_contact_time are not None
652
723
  avg_gct = sum(
653
724
  r.metrics.ground_contact_time * 1000
654
725
  for r in with_gct
@@ -657,7 +728,6 @@ def _process_batch(
657
728
  click.echo(f"\nAverage ground contact time: {avg_gct:.1f} ms", err=True)
658
729
 
659
730
  if with_flight:
660
- # Type assertion: filtering ensures metrics and flight_time are not None
661
731
  avg_flight = sum(
662
732
  r.metrics.flight_time * 1000
663
733
  for r in with_flight
@@ -666,7 +736,6 @@ def _process_batch(
666
736
  click.echo(f"Average flight time: {avg_flight:.1f} ms", err=True)
667
737
 
668
738
  if with_jump:
669
- # Type assertion: filtering ensures metrics and jump_height are not None
670
739
  avg_jump = sum(
671
740
  r.metrics.jump_height
672
741
  for r in with_jump
@@ -677,63 +746,129 @@ def _process_batch(
677
746
  err=True,
678
747
  )
679
748
 
680
- # Export CSV summary if requested
681
- if csv_summary and successful:
682
- click.echo(f"\nExporting CSV summary to: {csv_summary}", err=True)
683
- Path(csv_summary).parent.mkdir(parents=True, exist_ok=True)
684
-
685
- with open(csv_summary, "w", newline="") as f:
686
- writer = csv.writer(f)
687
-
688
- # Header
689
- writer.writerow(
690
- [
691
- "Video",
692
- "Ground Contact Time (ms)",
693
- "Flight Time (ms)",
694
- "Jump Height (m)",
695
- "Processing Time (s)",
696
- "Status",
697
- ]
698
- )
699
749
 
700
- # Data rows
701
- for result in results:
702
- if result.success and result.metrics:
703
- writer.writerow(
704
- [
705
- Path(result.video_path).name,
706
- (
707
- f"{result.metrics.ground_contact_time * 1000:.1f}"
708
- if result.metrics.ground_contact_time
709
- else "N/A"
710
- ),
711
- (
712
- f"{result.metrics.flight_time * 1000:.1f}"
713
- if result.metrics.flight_time
714
- else "N/A"
715
- ),
716
- (
717
- f"{result.metrics.jump_height:.3f}"
718
- if result.metrics.jump_height
719
- else "N/A"
720
- ),
721
- f"{result.processing_time:.2f}",
722
- "Success",
723
- ]
724
- )
725
- else:
726
- writer.writerow(
727
- [
728
- Path(result.video_path).name,
729
- "N/A",
730
- "N/A",
731
- "N/A",
732
- f"{result.processing_time:.2f}",
733
- f"Failed: {result.error}",
734
- ]
735
- )
736
-
737
- click.echo("CSV summary written successfully", err=True)
750
+ def _write_csv_summary(
751
+ csv_summary: str | None, results: list[VideoResult], successful: list[VideoResult]
752
+ ) -> None:
753
+ """Write CSV summary of batch processing results.
754
+
755
+ Args:
756
+ csv_summary: Path to CSV output file
757
+ results: All processing results
758
+ successful: Successful processing results
759
+ """
760
+ if not csv_summary or not successful:
761
+ return
762
+
763
+ click.echo(f"\nExporting CSV summary to: {csv_summary}", err=True)
764
+ Path(csv_summary).parent.mkdir(parents=True, exist_ok=True)
765
+
766
+ with open(csv_summary, "w", newline="") as f:
767
+ writer = csv.writer(f)
768
+
769
+ # Header
770
+ writer.writerow(
771
+ [
772
+ "Video",
773
+ "Ground Contact Time (ms)",
774
+ "Flight Time (ms)",
775
+ "Jump Height (m)",
776
+ "Processing Time (s)",
777
+ "Status",
778
+ ]
779
+ )
780
+
781
+ # Data rows
782
+ for result in results:
783
+ if result.success and result.metrics:
784
+ writer.writerow(
785
+ [
786
+ Path(result.video_path).name,
787
+ (
788
+ f"{result.metrics.ground_contact_time * 1000:.1f}"
789
+ if result.metrics.ground_contact_time
790
+ else "N/A"
791
+ ),
792
+ (
793
+ f"{result.metrics.flight_time * 1000:.1f}"
794
+ if result.metrics.flight_time
795
+ else "N/A"
796
+ ),
797
+ (
798
+ f"{result.metrics.jump_height:.3f}"
799
+ if result.metrics.jump_height
800
+ else "N/A"
801
+ ),
802
+ f"{result.processing_time:.2f}",
803
+ "Success",
804
+ ]
805
+ )
806
+ else:
807
+ writer.writerow(
808
+ [
809
+ Path(result.video_path).name,
810
+ "N/A",
811
+ "N/A",
812
+ "N/A",
813
+ f"{result.processing_time:.2f}",
814
+ f"Failed: {result.error}",
815
+ ]
816
+ )
817
+
818
+ click.echo("CSV summary written successfully", err=True)
819
+
820
+
821
+ def _process_batch(
822
+ video_files: list[str],
823
+ drop_height: float,
824
+ quality: str,
825
+ workers: int,
826
+ output_dir: str | None,
827
+ json_output_dir: str | None,
828
+ csv_summary: str | None,
829
+ expert_params: AnalysisParameters,
830
+ ) -> None:
831
+ """Process multiple videos in batch mode using parallel processing."""
832
+ click.echo(
833
+ f"\nBatch processing {len(video_files)} videos with {workers} workers", err=True
834
+ )
835
+ click.echo("=" * 70, err=True)
836
+
837
+ # Setup output directories
838
+ _setup_batch_output_dirs(output_dir, json_output_dir)
839
+
840
+ # Create video configurations
841
+ configs = _create_video_configs(
842
+ video_files, drop_height, quality, output_dir, json_output_dir, expert_params
843
+ )
844
+
845
+ # Progress callback
846
+ completed = 0
847
+
848
+ def show_progress(result: VideoResult) -> None:
849
+ nonlocal completed
850
+ completed += 1
851
+ status = "✓" if result.success else "✗"
852
+ video_name = Path(result.video_path).name
853
+ click.echo(
854
+ f"[{completed}/{len(configs)}] {status} {video_name} "
855
+ f"({result.processing_time:.1f}s)",
856
+ err=True,
857
+ )
858
+ if not result.success:
859
+ click.echo(f" Error: {result.error}", err=True)
860
+
861
+ # Process all videos
862
+ click.echo("\nProcessing videos...", err=True)
863
+ results = process_videos_bulk(
864
+ configs, max_workers=workers, progress_callback=show_progress
865
+ )
866
+
867
+ # Display statistics
868
+ _compute_batch_statistics(results)
869
+
870
+ # Export CSV summary if requested
871
+ successful = [r for r in results if r.success]
872
+ _write_csv_summary(csv_summary, results, successful)
738
873
 
739
874
  click.echo("\nBatch processing complete!", err=True)