kinemotion 0.76.3__py3-none-any.whl → 2.0.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.
Files changed (53) hide show
  1. kinemotion/__init__.py +3 -18
  2. kinemotion/api.py +7 -27
  3. kinemotion/cli.py +2 -4
  4. kinemotion/{countermovement_jump → cmj}/analysis.py +158 -16
  5. kinemotion/{countermovement_jump → cmj}/api.py +18 -46
  6. kinemotion/{countermovement_jump → cmj}/cli.py +46 -6
  7. kinemotion/cmj/debug_overlay.py +457 -0
  8. kinemotion/{countermovement_jump → cmj}/joint_angles.py +31 -96
  9. kinemotion/{countermovement_jump → cmj}/metrics_validator.py +271 -176
  10. kinemotion/{countermovement_jump → cmj}/validation_bounds.py +18 -1
  11. kinemotion/core/__init__.py +2 -11
  12. kinemotion/core/auto_tuning.py +107 -149
  13. kinemotion/core/cli_utils.py +0 -74
  14. kinemotion/core/debug_overlay_utils.py +15 -142
  15. kinemotion/core/experimental.py +51 -55
  16. kinemotion/core/filtering.py +56 -116
  17. kinemotion/core/pipeline_utils.py +2 -2
  18. kinemotion/core/pose.py +98 -47
  19. kinemotion/core/quality.py +6 -4
  20. kinemotion/core/smoothing.py +51 -65
  21. kinemotion/core/types.py +0 -15
  22. kinemotion/core/validation.py +7 -76
  23. kinemotion/core/video_io.py +27 -41
  24. kinemotion/{drop_jump → dropjump}/__init__.py +8 -2
  25. kinemotion/{drop_jump → dropjump}/analysis.py +120 -282
  26. kinemotion/{drop_jump → dropjump}/api.py +33 -59
  27. kinemotion/{drop_jump → dropjump}/cli.py +136 -70
  28. kinemotion/dropjump/debug_overlay.py +182 -0
  29. kinemotion/{drop_jump → dropjump}/kinematics.py +65 -175
  30. kinemotion/{drop_jump → dropjump}/metrics_validator.py +51 -25
  31. kinemotion/{drop_jump → dropjump}/validation_bounds.py +1 -1
  32. kinemotion/models/rtmpose-s_simcc-body7_pt-body7-halpe26_700e-256x192-7f134165_20230605.onnx +3 -0
  33. kinemotion/models/yolox_tiny_8xb8-300e_humanart-6f3252f9.onnx +3 -0
  34. {kinemotion-0.76.3.dist-info → kinemotion-2.0.0.dist-info}/METADATA +26 -75
  35. kinemotion-2.0.0.dist-info/RECORD +49 -0
  36. kinemotion/core/overlay_constants.py +0 -61
  37. kinemotion/core/video_analysis_base.py +0 -132
  38. kinemotion/countermovement_jump/debug_overlay.py +0 -325
  39. kinemotion/drop_jump/debug_overlay.py +0 -241
  40. kinemotion/squat_jump/__init__.py +0 -5
  41. kinemotion/squat_jump/analysis.py +0 -377
  42. kinemotion/squat_jump/api.py +0 -610
  43. kinemotion/squat_jump/cli.py +0 -309
  44. kinemotion/squat_jump/debug_overlay.py +0 -163
  45. kinemotion/squat_jump/kinematics.py +0 -342
  46. kinemotion/squat_jump/metrics_validator.py +0 -438
  47. kinemotion/squat_jump/validation_bounds.py +0 -221
  48. kinemotion-0.76.3.dist-info/RECORD +0 -57
  49. /kinemotion/{countermovement_jump → cmj}/__init__.py +0 -0
  50. /kinemotion/{countermovement_jump → cmj}/kinematics.py +0 -0
  51. {kinemotion-0.76.3.dist-info → kinemotion-2.0.0.dist-info}/WHEEL +0 -0
  52. {kinemotion-0.76.3.dist-info → kinemotion-2.0.0.dist-info}/entry_points.txt +0 -0
  53. {kinemotion-0.76.3.dist-info → kinemotion-2.0.0.dist-info}/licenses/LICENSE +0 -0
kinemotion/__init__.py CHANGED
@@ -1,26 +1,17 @@
1
- """Kinemotion: Video-based kinematic analysis for athletic performance.
2
-
3
- Supports Counter Movement Jump (CMJ), Drop Jump, and Squat Jump (SJ) analysis
4
- using MediaPipe pose estimation.
5
- """
1
+ """Kinemotion: Video-based kinematic analysis for athletic performance."""
6
2
 
7
3
  from .api import (
8
4
  CMJVideoConfig,
9
5
  CMJVideoResult,
10
6
  DropJumpVideoConfig,
11
7
  DropJumpVideoResult,
12
- SJVideoConfig,
13
- SJVideoResult,
14
8
  process_cmj_video,
15
9
  process_cmj_videos_bulk,
16
10
  process_dropjump_video,
17
11
  process_dropjump_videos_bulk,
18
- process_sj_video,
19
- process_sj_videos_bulk,
20
12
  )
21
- from .countermovement_jump.kinematics import CMJMetrics
22
- from .drop_jump.kinematics import DropJumpMetrics
23
- from .squat_jump.kinematics import SJMetrics
13
+ from .cmj.kinematics import CMJMetrics
14
+ from .dropjump.kinematics import DropJumpMetrics
24
15
 
25
16
  # Get version from package metadata (set in pyproject.toml)
26
17
  try:
@@ -44,11 +35,5 @@ __all__ = [
44
35
  "CMJVideoConfig",
45
36
  "CMJVideoResult",
46
37
  "CMJMetrics",
47
- # Squat Jump API
48
- "process_sj_video",
49
- "process_sj_videos_bulk",
50
- "SJVideoConfig",
51
- "SJVideoResult",
52
- "SJMetrics",
53
38
  "__version__",
54
39
  ]
kinemotion/api.py CHANGED
@@ -1,27 +1,26 @@
1
1
  """Public API for programmatic use of kinemotion analysis.
2
2
 
3
- This module provides a unified interface for drop jump, CMJ, and Squat Jump analysis.
3
+ This module provides a unified interface for both drop jump and CMJ video analysis.
4
4
  The actual implementations have been moved to their respective submodules:
5
- - Drop jump: kinemotion.drop_jump.api
6
- - CMJ: kinemotion.countermovement_jump.api
7
- - Squat Jump: kinemotion.squat_jump.api
5
+ - Drop jump: kinemotion.dropjump.api
6
+ - CMJ: kinemotion.cmj.api
8
7
 
9
8
  """
10
9
 
11
10
  # CMJ API
12
- from .countermovement_jump.api import (
11
+ from .cmj.api import (
13
12
  AnalysisOverrides as CMJAnalysisOverrides,
14
13
  )
15
- from .countermovement_jump.api import (
14
+ from .cmj.api import (
16
15
  CMJVideoConfig,
17
16
  CMJVideoResult,
18
17
  process_cmj_video,
19
18
  process_cmj_videos_bulk,
20
19
  )
21
- from .countermovement_jump.kinematics import CMJMetrics
20
+ from .cmj.kinematics import CMJMetrics
22
21
 
23
22
  # Drop jump API
24
- from .drop_jump.api import (
23
+ from .dropjump.api import (
25
24
  AnalysisOverrides,
26
25
  DropJumpVideoConfig,
27
26
  DropJumpVideoResult,
@@ -29,18 +28,6 @@ from .drop_jump.api import (
29
28
  process_dropjump_videos_bulk,
30
29
  )
31
30
 
32
- # Squat Jump API
33
- from .squat_jump.api import (
34
- AnalysisOverrides as SJAnalysisOverrides,
35
- )
36
- from .squat_jump.api import (
37
- SJVideoConfig,
38
- SJVideoResult,
39
- process_sj_video,
40
- process_sj_videos_bulk,
41
- )
42
- from .squat_jump.kinematics import SJMetrics
43
-
44
31
  __all__ = [
45
32
  # Drop jump
46
33
  "AnalysisOverrides",
@@ -55,11 +42,4 @@ __all__ = [
55
42
  "CMJVideoResult",
56
43
  "process_cmj_video",
57
44
  "process_cmj_videos_bulk",
58
- # Squat Jump
59
- "SJAnalysisOverrides",
60
- "SJMetrics",
61
- "SJVideoConfig",
62
- "SJVideoResult",
63
- "process_sj_video",
64
- "process_sj_videos_bulk",
65
45
  ]
kinemotion/cli.py CHANGED
@@ -2,9 +2,8 @@
2
2
 
3
3
  import click
4
4
 
5
- from .countermovement_jump.cli import cmj_analyze
6
- from .drop_jump.cli import dropjump_analyze
7
- from .squat_jump.cli import sj_analyze
5
+ from .cmj.cli import cmj_analyze
6
+ from .dropjump.cli import dropjump_analyze
8
7
 
9
8
 
10
9
  @click.group()
@@ -18,7 +17,6 @@ def cli() -> None: # type: ignore[return]
18
17
  # Type ignore needed because @click.group() transforms cli into a click.Group
19
18
  cli.add_command(dropjump_analyze) # type: ignore[attr-defined]
20
19
  cli.add_command(cmj_analyze) # type: ignore[attr-defined]
21
- cli.add_command(sj_analyze) # type: ignore[attr-defined]
22
20
 
23
21
 
24
22
  if __name__ == "__main__":
@@ -8,7 +8,7 @@ from scipy.signal import savgol_filter
8
8
  from ..core.experimental import unused
9
9
  from ..core.smoothing import compute_acceleration_from_derivative
10
10
  from ..core.timing import NULL_TIMER, Timer
11
- from ..core.types import HIP_KEYS, FloatArray
11
+ from ..core.types import FloatArray
12
12
 
13
13
 
14
14
  def compute_signed_velocity(
@@ -55,7 +55,147 @@ class CMJPhase(Enum):
55
55
  UNKNOWN = "unknown"
56
56
 
57
57
 
58
- def _find_cmj_takeoff_from_velocity_peak(
58
+ @unused(
59
+ reason="Alternative implementation not called by pipeline",
60
+ since="0.34.0",
61
+ )
62
+ def find_standing_phase(
63
+ positions: FloatArray,
64
+ velocities: FloatArray,
65
+ fps: float,
66
+ min_standing_duration: float = 0.5,
67
+ velocity_threshold: float = 0.01,
68
+ ) -> int | None:
69
+ """
70
+ Find the end of standing phase (start of countermovement).
71
+
72
+ Looks for a period of low velocity (standing) followed by consistent
73
+ downward motion.
74
+
75
+ Args:
76
+ positions: Array of vertical positions (normalized 0-1)
77
+ velocities: Array of vertical velocities
78
+ fps: Video frame rate
79
+ min_standing_duration: Minimum standing duration in seconds (default: 0.5s)
80
+ velocity_threshold: Velocity threshold for standing detection
81
+
82
+ Returns:
83
+ Frame index where countermovement begins, or None if not detected.
84
+ """
85
+ min_standing_frames = int(fps * min_standing_duration)
86
+
87
+ if len(positions) < min_standing_frames:
88
+ return None
89
+
90
+ # Find periods of low velocity (standing)
91
+ is_standing = np.abs(velocities) < velocity_threshold
92
+
93
+ # Look for first sustained standing period
94
+ standing_count = 0
95
+ standing_end = None
96
+
97
+ for i in range(len(is_standing)):
98
+ if is_standing[i]:
99
+ standing_count += 1
100
+ if standing_count >= min_standing_frames:
101
+ standing_end = i
102
+ else:
103
+ if standing_end is not None:
104
+ # Found end of standing phase
105
+ return standing_end
106
+ standing_count = 0
107
+
108
+ return None
109
+
110
+
111
+ @unused(
112
+ reason="Alternative implementation not called by pipeline",
113
+ since="0.34.0",
114
+ )
115
+ def find_countermovement_start(
116
+ velocities: FloatArray,
117
+ countermovement_threshold: float = 0.015,
118
+ min_eccentric_frames: int = 3,
119
+ standing_start: int | None = None,
120
+ ) -> int | None:
121
+ """
122
+ Find the start of countermovement (eccentric phase).
123
+
124
+ Detects when velocity becomes consistently positive (downward motion in
125
+ normalized coords).
126
+
127
+ Args:
128
+ velocities: Array of SIGNED vertical velocities
129
+ countermovement_threshold: Velocity threshold for detecting downward
130
+ motion (POSITIVE)
131
+ min_eccentric_frames: Minimum consecutive frames of downward motion
132
+ standing_start: Optional frame where standing phase ended
133
+
134
+ Returns:
135
+ Frame index where countermovement begins, or None if not detected.
136
+ """
137
+ start_frame = standing_start if standing_start is not None else 0
138
+
139
+ # Look for sustained downward velocity (POSITIVE in normalized coords)
140
+ is_downward = velocities[start_frame:] > countermovement_threshold
141
+ consecutive_count = 0
142
+
143
+ for i in range(len(is_downward)):
144
+ if is_downward[i]:
145
+ consecutive_count += 1
146
+ if consecutive_count >= min_eccentric_frames:
147
+ # Found start of eccentric phase
148
+ return start_frame + i - consecutive_count + 1
149
+ else:
150
+ consecutive_count = 0
151
+
152
+ return None
153
+
154
+
155
+ def find_lowest_point(
156
+ positions: FloatArray,
157
+ velocities: FloatArray,
158
+ min_search_frame: int = 80,
159
+ ) -> int:
160
+ """
161
+ Find the lowest point of countermovement (transition from eccentric to concentric).
162
+
163
+ The lowest point occurs BEFORE the peak height (the jump apex). It's where
164
+ velocity crosses from positive (downward/squatting) to negative (upward/jumping).
165
+
166
+ Args:
167
+ positions: Array of vertical positions (higher value = lower in video)
168
+ velocities: Array of SIGNED vertical velocities (positive=down, negative=up)
169
+ min_search_frame: Minimum frame to start searching (default: frame 80)
170
+
171
+ Returns:
172
+ Frame index of lowest point.
173
+ """
174
+ # First, find the peak height (minimum y value = highest jump point)
175
+ peak_height_frame = int(np.argmin(positions))
176
+
177
+ # Lowest point MUST be before peak height
178
+ # Search from min_search_frame to peak_height_frame
179
+ start_frame = min_search_frame
180
+ end_frame = peak_height_frame
181
+
182
+ if end_frame <= start_frame:
183
+ start_frame = int(len(positions) * 0.3)
184
+ end_frame = int(len(positions) * 0.7)
185
+
186
+ search_positions = positions[start_frame:end_frame]
187
+
188
+ if len(search_positions) == 0:
189
+ return start_frame
190
+
191
+ # Find maximum position value in this range (lowest point in video)
192
+ lowest_idx = int(np.argmax(search_positions))
193
+ lowest_frame = start_frame + lowest_idx
194
+
195
+ return lowest_frame
196
+
197
+
198
+ def find_cmj_takeoff_from_velocity_peak(
59
199
  positions: FloatArray,
60
200
  velocities: FloatArray,
61
201
  lowest_point_frame: int,
@@ -92,7 +232,7 @@ def _find_cmj_takeoff_from_velocity_peak(
92
232
  return float(takeoff_frame)
93
233
 
94
234
 
95
- def _find_cmj_landing_from_position_peak(
235
+ def find_cmj_landing_from_position_peak(
96
236
  positions: FloatArray,
97
237
  velocities: FloatArray,
98
238
  accelerations: FloatArray,
@@ -150,7 +290,7 @@ def _find_cmj_landing_from_position_peak(
150
290
  reason="Experimental alternative superseded by backward search algorithm",
151
291
  since="0.34.0",
152
292
  )
153
- def _find_interpolated_takeoff_landing(
293
+ def find_interpolated_takeoff_landing(
154
294
  positions: FloatArray,
155
295
  velocities: FloatArray,
156
296
  lowest_point_frame: int,
@@ -183,19 +323,19 @@ def _find_interpolated_takeoff_landing(
183
323
  )
184
324
 
185
325
  # Find takeoff using peak velocity method (CMJ-specific)
186
- takeoff_frame = _find_cmj_takeoff_from_velocity_peak(
326
+ takeoff_frame = find_cmj_takeoff_from_velocity_peak(
187
327
  positions, velocities, lowest_point_frame, fps
188
328
  )
189
329
 
190
330
  # Find landing using position peak and impact detection
191
- landing_frame = _find_cmj_landing_from_position_peak(
331
+ landing_frame = find_cmj_landing_from_position_peak(
192
332
  positions, velocities, accelerations, int(takeoff_frame), fps
193
333
  )
194
334
 
195
335
  return (takeoff_frame, landing_frame)
196
336
 
197
337
 
198
- def _find_takeoff_frame(
338
+ def find_takeoff_frame(
199
339
  velocities: FloatArray,
200
340
  peak_height_frame: int,
201
341
  fps: float,
@@ -258,7 +398,7 @@ def _find_takeoff_frame(
258
398
  return float(peak_vel_frame)
259
399
 
260
400
 
261
- def _find_lowest_frame(
401
+ def find_lowest_frame(
262
402
  velocities: FloatArray, positions: FloatArray, takeoff_frame: float, fps: float
263
403
  ) -> float:
264
404
  """Find lowest point frame before takeoff."""
@@ -327,7 +467,7 @@ def _find_landing_impact(
327
467
  return float(landing_frame)
328
468
 
329
469
 
330
- def _find_landing_frame(
470
+ def find_landing_frame(
331
471
  accelerations: FloatArray,
332
472
  velocities: FloatArray,
333
473
  peak_height_frame: int,
@@ -363,10 +503,12 @@ def compute_average_hip_position(
363
503
  Returns:
364
504
  (x, y) average hip position in normalized coordinates
365
505
  """
506
+ hip_keys = ["left_hip", "right_hip"]
507
+
366
508
  x_positions: list[float] = []
367
509
  y_positions: list[float] = []
368
510
 
369
- for key in HIP_KEYS:
511
+ for key in hip_keys:
370
512
  if key in landmarks:
371
513
  x, y, visibility = landmarks[key]
372
514
  if visibility > 0.5: # Only use visible landmarks
@@ -379,7 +521,7 @@ def compute_average_hip_position(
379
521
  return (float(np.mean(x_positions)), float(np.mean(y_positions)))
380
522
 
381
523
 
382
- def _find_standing_end(
524
+ def find_standing_end(
383
525
  velocities: FloatArray,
384
526
  lowest_point: float,
385
527
  _positions: FloatArray | None = None,
@@ -490,12 +632,12 @@ def detect_cmj_phases(
490
632
 
491
633
  # Step 2-4: Find all phases using helper functions
492
634
  with timer.measure("cmj_find_takeoff"):
493
- takeoff_frame = _find_takeoff_frame(
635
+ takeoff_frame = find_takeoff_frame(
494
636
  velocities, peak_height_frame, fps, accelerations=accelerations
495
637
  )
496
638
 
497
639
  with timer.measure("cmj_find_lowest_point"):
498
- lowest_point = _find_lowest_frame(velocities, positions, takeoff_frame, fps)
640
+ lowest_point = find_lowest_frame(velocities, positions, takeoff_frame, fps)
499
641
 
500
642
  # Determine landing frame
501
643
  with timer.measure("cmj_find_landing"):
@@ -509,7 +651,7 @@ def detect_cmj_phases(
509
651
  )
510
652
  # We still reference peak_height_frame from Hips, as Feet peak
511
653
  # might be different/noisy but generally they align in time.
512
- landing_frame = _find_landing_frame(
654
+ landing_frame = find_landing_frame(
513
655
  landing_accelerations,
514
656
  landing_velocities,
515
657
  peak_height_frame,
@@ -517,7 +659,7 @@ def detect_cmj_phases(
517
659
  )
518
660
  else:
519
661
  # Use primary signal (Hips)
520
- landing_frame = _find_landing_frame(
662
+ landing_frame = find_landing_frame(
521
663
  accelerations,
522
664
  velocities,
523
665
  peak_height_frame,
@@ -525,6 +667,6 @@ def detect_cmj_phases(
525
667
  )
526
668
 
527
669
  with timer.measure("cmj_find_standing_end"):
528
- standing_end = _find_standing_end(velocities, lowest_point, positions, accelerations)
670
+ standing_end = find_standing_end(velocities, lowest_point, positions, accelerations)
529
671
 
530
672
  return (standing_end, lowest_point, takeoff_frame, landing_frame)
@@ -262,15 +262,13 @@ def _get_tuned_parameters(
262
262
  with timer.measure("parameter_auto_tuning"):
263
263
  characteristics = analyze_video_sample(landmarks_sequence, video.fps, video.frame_count)
264
264
  params = auto_tune_parameters(characteristics, quality_preset)
265
-
266
- if overrides:
267
- params = apply_expert_overrides(
268
- params,
269
- overrides.smoothing_window,
270
- overrides.velocity_threshold,
271
- overrides.min_contact_frames,
272
- overrides.visibility_threshold,
273
- )
265
+ params = apply_expert_overrides(
266
+ params,
267
+ overrides.smoothing_window if overrides else None,
268
+ overrides.velocity_threshold if overrides else None,
269
+ overrides.min_contact_frames if overrides else None,
270
+ overrides.visibility_threshold if overrides else None,
271
+ )
274
272
 
275
273
  if verbose:
276
274
  print_verbose_parameters(video, characteristics, quality_preset, params)
@@ -393,24 +391,6 @@ class CMJVideoConfig:
393
391
  overrides: AnalysisOverrides | None = None
394
392
  detection_confidence: float | None = None
395
393
  tracking_confidence: float | None = None
396
- verbose: bool = False
397
- timer: Timer | None = None
398
- pose_tracker: "MediaPipePoseTracker | None" = None
399
-
400
- def to_kwargs(self) -> dict:
401
- """Convert config to kwargs dict for process_cmj_video."""
402
- return {
403
- "video_path": self.video_path,
404
- "quality": self.quality,
405
- "output_video": self.output_video,
406
- "json_output": self.json_output,
407
- "overrides": self.overrides,
408
- "detection_confidence": self.detection_confidence,
409
- "tracking_confidence": self.tracking_confidence,
410
- "verbose": self.verbose,
411
- "timer": self.timer,
412
- "pose_tracker": self.pose_tracker,
413
- }
414
394
 
415
395
 
416
396
  @dataclass
@@ -529,23 +509,6 @@ def process_cmj_video(
529
509
  return metrics
530
510
 
531
511
 
532
- def process_cmj_video_from_config(
533
- config: CMJVideoConfig,
534
- ) -> CMJMetrics:
535
- """Process a CMJ video using a configuration object.
536
-
537
- This is a convenience wrapper around process_cmj_video that
538
- accepts a CMJVideoConfig instead of individual parameters.
539
-
540
- Args:
541
- config: Configuration object containing all analysis parameters
542
-
543
- Returns:
544
- CMJMetrics object containing analysis results
545
- """
546
- return process_cmj_video(**config.to_kwargs())
547
-
548
-
549
512
  def process_cmj_videos_bulk(
550
513
  configs: list[CMJVideoConfig],
551
514
  max_workers: int = 4,
@@ -572,8 +535,17 @@ def _process_cmj_video_wrapper(config: CMJVideoConfig) -> CMJVideoResult:
572
535
  start_time = time.perf_counter()
573
536
 
574
537
  try:
575
- # Use convenience wrapper to avoid parameter unpacking
576
- metrics = process_cmj_video_from_config(config)
538
+ metrics = process_cmj_video(
539
+ video_path=config.video_path,
540
+ quality=config.quality,
541
+ output_video=config.output_video,
542
+ json_output=config.json_output,
543
+ overrides=config.overrides,
544
+ detection_confidence=config.detection_confidence,
545
+ tracking_confidence=config.tracking_confidence,
546
+ verbose=False,
547
+ )
548
+
577
549
  processing_time = time.perf_counter() - start_time
578
550
 
579
551
  return CMJVideoResult(
@@ -8,12 +8,9 @@ import click
8
8
 
9
9
  from ..core.auto_tuning import QualityPreset
10
10
  from ..core.cli_utils import (
11
- batch_processing_options,
12
11
  collect_video_files,
13
12
  common_output_options,
14
13
  generate_batch_output_paths,
15
- quality_option,
16
- verbose_option,
17
14
  )
18
15
  from .api import AnalysisOverrides, process_cmj_video
19
16
  from .kinematics import CMJMetrics
@@ -62,9 +59,52 @@ def _process_batch_videos(
62
59
  @click.command(name="cmj-analyze")
63
60
  @click.argument("video_path", nargs=-1, type=click.Path(exists=False), required=True)
64
61
  @common_output_options
65
- @quality_option
66
- @verbose_option
67
- @batch_processing_options
62
+ @click.option(
63
+ "--quality",
64
+ type=click.Choice(["fast", "balanced", "accurate"], case_sensitive=False),
65
+ default="balanced",
66
+ help=(
67
+ "Analysis quality preset: "
68
+ "fast (quick, less precise), "
69
+ "balanced (default, good for most cases), "
70
+ "accurate (research-grade, slower)"
71
+ ),
72
+ show_default=True,
73
+ )
74
+ @click.option(
75
+ "--verbose",
76
+ "-v",
77
+ is_flag=True,
78
+ help="Show auto-selected parameters and analysis details",
79
+ )
80
+ # Batch processing options
81
+ @click.option(
82
+ "--batch",
83
+ is_flag=True,
84
+ help="Enable batch processing mode for multiple videos",
85
+ )
86
+ @click.option(
87
+ "--workers",
88
+ type=int,
89
+ default=4,
90
+ help="Number of parallel workers for batch processing (default: 4)",
91
+ show_default=True,
92
+ )
93
+ @click.option(
94
+ "--output-dir",
95
+ type=click.Path(),
96
+ help="Directory for debug video outputs (batch mode only)",
97
+ )
98
+ @click.option(
99
+ "--json-output-dir",
100
+ type=click.Path(),
101
+ help="Directory for JSON metrics outputs (batch mode only)",
102
+ )
103
+ @click.option(
104
+ "--csv-summary",
105
+ type=click.Path(),
106
+ help="Path for CSV summary export (batch mode only)",
107
+ )
68
108
  # Expert parameters (hidden in help, but always available for advanced users)
69
109
  @click.option(
70
110
  "--smoothing-window",