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

Files changed (48) hide show
  1. kinemotion/__init__.py +31 -6
  2. kinemotion/api.py +39 -598
  3. kinemotion/cli.py +2 -0
  4. kinemotion/cmj/__init__.py +5 -0
  5. kinemotion/cmj/analysis.py +621 -0
  6. kinemotion/cmj/api.py +563 -0
  7. kinemotion/cmj/cli.py +324 -0
  8. kinemotion/cmj/debug_overlay.py +457 -0
  9. kinemotion/cmj/joint_angles.py +307 -0
  10. kinemotion/cmj/kinematics.py +360 -0
  11. kinemotion/cmj/metrics_validator.py +767 -0
  12. kinemotion/cmj/validation_bounds.py +341 -0
  13. kinemotion/core/__init__.py +28 -0
  14. kinemotion/core/auto_tuning.py +71 -37
  15. kinemotion/core/cli_utils.py +60 -0
  16. kinemotion/core/debug_overlay_utils.py +385 -0
  17. kinemotion/core/determinism.py +83 -0
  18. kinemotion/core/experimental.py +103 -0
  19. kinemotion/core/filtering.py +9 -6
  20. kinemotion/core/formatting.py +75 -0
  21. kinemotion/core/metadata.py +231 -0
  22. kinemotion/core/model_downloader.py +172 -0
  23. kinemotion/core/pipeline_utils.py +433 -0
  24. kinemotion/core/pose.py +298 -141
  25. kinemotion/core/pose_landmarks.py +67 -0
  26. kinemotion/core/quality.py +393 -0
  27. kinemotion/core/smoothing.py +250 -154
  28. kinemotion/core/timing.py +247 -0
  29. kinemotion/core/types.py +42 -0
  30. kinemotion/core/validation.py +201 -0
  31. kinemotion/core/video_io.py +135 -50
  32. kinemotion/dropjump/__init__.py +1 -1
  33. kinemotion/dropjump/analysis.py +367 -182
  34. kinemotion/dropjump/api.py +665 -0
  35. kinemotion/dropjump/cli.py +156 -466
  36. kinemotion/dropjump/debug_overlay.py +136 -206
  37. kinemotion/dropjump/kinematics.py +232 -255
  38. kinemotion/dropjump/metrics_validator.py +240 -0
  39. kinemotion/dropjump/validation_bounds.py +157 -0
  40. kinemotion/models/__init__.py +0 -0
  41. kinemotion/models/pose_landmarker_lite.task +0 -0
  42. kinemotion-0.67.0.dist-info/METADATA +726 -0
  43. kinemotion-0.67.0.dist-info/RECORD +47 -0
  44. {kinemotion-0.10.6.dist-info → kinemotion-0.67.0.dist-info}/WHEEL +1 -1
  45. kinemotion-0.10.6.dist-info/METADATA +0 -561
  46. kinemotion-0.10.6.dist-info/RECORD +0 -20
  47. {kinemotion-0.10.6.dist-info → kinemotion-0.67.0.dist-info}/entry_points.txt +0 -0
  48. {kinemotion-0.10.6.dist-info → kinemotion-0.67.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,341 @@
1
+ """CMJ metrics physiological bounds for validation testing.
2
+
3
+ This module defines realistic physiological bounds for Counter Movement Jump (CMJ)
4
+ metrics based on biomechanical literature and real-world athlete performance.
5
+
6
+ These bounds are used to:
7
+ 1. Prevent false positives from measurement noise
8
+ 2. Catch real errors in video processing and phase detection
9
+ 3. Provide athlete-profile-appropriate validation
10
+ 4. Enable cross-validation of metric consistency
11
+
12
+ References:
13
+ - Nordez et al. (2009): CMJ depth-height relationships
14
+ - Cormie et al. (2011): Power generation characteristics
15
+ - Bogdanis (2012): Plyometric training effects
16
+ """
17
+
18
+ from kinemotion.core.types import MetricsDict
19
+ from kinemotion.core.validation import AthleteProfile, MetricBounds
20
+
21
+
22
+ class CMJBounds:
23
+ """Collection of physiological bounds for all CMJ metrics."""
24
+
25
+ # FLIGHT TIME (seconds)
26
+ FLIGHT_TIME = MetricBounds(
27
+ absolute_min=0.08, # Frame rate resolution limit
28
+ practical_min=0.15, # Minimum effort jump
29
+ recreational_min=0.25, # Untrained ~10cm
30
+ recreational_max=0.70, # Recreational ~30-50cm
31
+ elite_min=0.65, # Trained ~60cm
32
+ elite_max=1.10, # Elite >100cm
33
+ absolute_max=1.30,
34
+ unit="s",
35
+ )
36
+
37
+ # JUMP HEIGHT (meters)
38
+ JUMP_HEIGHT = MetricBounds(
39
+ absolute_min=0.02,
40
+ practical_min=0.05,
41
+ recreational_min=0.15, # Untrained with effort
42
+ recreational_max=0.60, # Good recreational
43
+ elite_min=0.65,
44
+ elite_max=1.00,
45
+ absolute_max=1.30,
46
+ unit="m",
47
+ )
48
+
49
+ # COUNTERMOVEMENT DEPTH (meters)
50
+ COUNTERMOVEMENT_DEPTH = MetricBounds(
51
+ absolute_min=0.05,
52
+ practical_min=0.08, # Minimal squat
53
+ recreational_min=0.20, # Shallow to parallel
54
+ recreational_max=0.55, # Normal to deep squat
55
+ elite_min=0.40,
56
+ elite_max=0.75,
57
+ absolute_max=1.10, # Only extreme tall athletes
58
+ unit="m",
59
+ )
60
+
61
+ # CONCENTRIC DURATION / CONTACT TIME (seconds)
62
+ CONCENTRIC_DURATION = MetricBounds(
63
+ absolute_min=0.08,
64
+ practical_min=0.10, # Extreme plyometric
65
+ recreational_min=0.40, # Moderate propulsion
66
+ recreational_max=0.90, # Slow push-off
67
+ elite_min=0.25,
68
+ elite_max=0.50,
69
+ absolute_max=1.80,
70
+ unit="s",
71
+ )
72
+
73
+ # ECCENTRIC DURATION (seconds)
74
+ ECCENTRIC_DURATION = MetricBounds(
75
+ absolute_min=0.15,
76
+ practical_min=0.25,
77
+ recreational_min=0.35,
78
+ recreational_max=0.75,
79
+ elite_min=0.30,
80
+ elite_max=0.65,
81
+ absolute_max=1.30,
82
+ unit="s",
83
+ )
84
+
85
+ # TOTAL MOVEMENT TIME (seconds)
86
+ TOTAL_MOVEMENT_TIME = MetricBounds(
87
+ absolute_min=0.25,
88
+ practical_min=0.35,
89
+ recreational_min=0.75,
90
+ recreational_max=1.50,
91
+ elite_min=0.55,
92
+ elite_max=1.10,
93
+ absolute_max=2.20,
94
+ unit="s",
95
+ )
96
+
97
+ # PEAK ECCENTRIC VELOCITY (m/s, downward)
98
+ PEAK_ECCENTRIC_VELOCITY = MetricBounds(
99
+ absolute_min=0.10,
100
+ practical_min=0.20,
101
+ recreational_min=0.80,
102
+ recreational_max=2.00,
103
+ elite_min=2.00,
104
+ elite_max=3.50,
105
+ absolute_max=4.50,
106
+ unit="m/s",
107
+ )
108
+
109
+ # PEAK CONCENTRIC VELOCITY (m/s, upward)
110
+ PEAK_CONCENTRIC_VELOCITY = MetricBounds(
111
+ absolute_min=0.30,
112
+ practical_min=0.50,
113
+ recreational_min=1.80,
114
+ recreational_max=2.80,
115
+ elite_min=3.00,
116
+ elite_max=4.20,
117
+ absolute_max=5.00,
118
+ unit="m/s",
119
+ )
120
+
121
+
122
+ class TripleExtensionBounds:
123
+ """Physiological bounds for triple extension angles (degrees)."""
124
+
125
+ # HIP ANGLE at takeoff (close to 180° = full extension)
126
+ @staticmethod
127
+ def hip_angle_valid(angle: float | None, profile: AthleteProfile) -> bool:
128
+ """Check if hip angle is valid for profile."""
129
+ if angle is None:
130
+ return True # May not be detectable
131
+ if angle < 120 or angle > 195:
132
+ return False # Outside physiological limits
133
+ if profile == AthleteProfile.ELDERLY:
134
+ return 150 <= angle <= 175
135
+ elif profile in (AthleteProfile.UNTRAINED, AthleteProfile.RECREATIONAL):
136
+ return 160 <= angle <= 180
137
+ elif profile in (AthleteProfile.TRAINED, AthleteProfile.ELITE):
138
+ return 170 <= angle <= 185
139
+ return True
140
+
141
+ # KNEE ANGLE at takeoff (close to 180° = full extension)
142
+ @staticmethod
143
+ def knee_angle_valid(angle: float | None, profile: AthleteProfile) -> bool:
144
+ """Check if knee angle is valid for profile."""
145
+ if angle is None:
146
+ return True
147
+ if angle < 130 or angle > 200:
148
+ return False # Outside physiological limits
149
+ if profile == AthleteProfile.ELDERLY:
150
+ return 155 <= angle <= 175
151
+ elif profile in (AthleteProfile.UNTRAINED, AthleteProfile.RECREATIONAL):
152
+ return 165 <= angle <= 182
153
+ elif profile in (AthleteProfile.TRAINED, AthleteProfile.ELITE):
154
+ return 173 <= angle <= 190
155
+ return True
156
+
157
+ # ANKLE ANGLE at takeoff (120-155° = plantarflexion, 90° = neutral)
158
+ @staticmethod
159
+ def ankle_angle_valid(angle: float | None, profile: AthleteProfile) -> bool:
160
+ """Check if ankle angle is valid for profile."""
161
+ if angle is None:
162
+ return True # Often not detectable in side view
163
+ if angle < 90 or angle > 165:
164
+ return False # Outside physiological limits
165
+ if profile == AthleteProfile.ELDERLY:
166
+ return 100 <= angle <= 125
167
+ elif profile in (AthleteProfile.UNTRAINED, AthleteProfile.RECREATIONAL):
168
+ return 110 <= angle <= 140
169
+ elif profile in (AthleteProfile.TRAINED, AthleteProfile.ELITE):
170
+ return 125 <= angle <= 155
171
+ return True
172
+
173
+ # TRUNK TILT (forward lean from vertical, degrees)
174
+ @staticmethod
175
+ def trunk_tilt_valid(angle: float | None, profile: AthleteProfile) -> bool:
176
+ """Check if trunk tilt is valid for profile."""
177
+ if angle is None:
178
+ return True
179
+ if angle < -15 or angle > 60:
180
+ return False # Outside reasonable range
181
+ # Most athletes show 10-30° forward lean during takeoff
182
+ return -10 <= angle <= 45
183
+
184
+
185
+ class RSIBounds:
186
+ """Reactive Strength Index bounds."""
187
+
188
+ # Calculated as: RSI = flight_time / contact_time
189
+ MIN_VALID = 0.30 # Below this: invalid metrics
190
+ MAX_VALID = 4.00 # Above this: invalid metrics
191
+
192
+ ELDERLY_RANGE = (0.15, 0.30)
193
+ UNTRAINED_RANGE = (0.30, 0.80)
194
+ RECREATIONAL_RANGE = (0.80, 1.50)
195
+ TRAINED_RANGE = (1.50, 2.40)
196
+ ELITE_RANGE = (2.20, 3.50)
197
+
198
+ @staticmethod
199
+ def get_rsi_range(profile: AthleteProfile) -> tuple[float, float]:
200
+ """Get expected RSI range for athlete profile."""
201
+ if profile == AthleteProfile.ELDERLY:
202
+ return RSIBounds.ELDERLY_RANGE
203
+ elif profile == AthleteProfile.UNTRAINED:
204
+ return RSIBounds.UNTRAINED_RANGE
205
+ elif profile == AthleteProfile.RECREATIONAL:
206
+ return RSIBounds.RECREATIONAL_RANGE
207
+ elif profile == AthleteProfile.TRAINED:
208
+ return RSIBounds.TRAINED_RANGE
209
+ elif profile == AthleteProfile.ELITE:
210
+ return RSIBounds.ELITE_RANGE
211
+ return (RSIBounds.MIN_VALID, RSIBounds.MAX_VALID)
212
+
213
+ @staticmethod
214
+ def is_valid(rsi: float) -> bool:
215
+ """Check if RSI is within physiological bounds."""
216
+ return RSIBounds.MIN_VALID <= rsi <= RSIBounds.MAX_VALID
217
+
218
+ @staticmethod
219
+ def in_range_for_profile(rsi: float, profile: AthleteProfile) -> bool:
220
+ """Check if RSI is in expected range for profile."""
221
+ min_rsi, max_rsi = RSIBounds.get_rsi_range(profile)
222
+ return min_rsi <= rsi <= max_rsi
223
+
224
+
225
+ class MetricConsistency:
226
+ """Cross-validation tolerance for metric consistency checks."""
227
+
228
+ # Jump height from flight time: h = g*t²/8
229
+ # Allow 10% deviation for measurement noise
230
+ HEIGHT_FLIGHT_TIME_TOLERANCE = 0.10
231
+
232
+ # Peak velocity from jump height: v = sqrt(2*g*h)
233
+ # Allow 15% deviation (velocity harder to detect precisely)
234
+ VELOCITY_HEIGHT_TOLERANCE = 0.15
235
+
236
+ # Countermovement depth to jump height ratio
237
+ # Typically 0.5-1.2, flag if outside 0.3-1.5
238
+ DEPTH_HEIGHT_RATIO_MIN = 0.30
239
+ DEPTH_HEIGHT_RATIO_MAX = 1.50
240
+
241
+ # Contact time to countermovement depth ratio
242
+ # Should be roughly 1.0-1.5 s/m
243
+ CONTACT_DEPTH_RATIO_MIN = 0.50
244
+ CONTACT_DEPTH_RATIO_MAX = 2.50
245
+
246
+
247
+ # Athlete profile examples with expected metric ranges
248
+ ATHLETE_PROFILES = {
249
+ "elderly_deconditioned": {
250
+ "label": "Elderly/Deconditioned (70+, sedentary)",
251
+ "profile": AthleteProfile.ELDERLY,
252
+ "expected": {
253
+ "jump_height_m": (0.10, 0.18),
254
+ "flight_time_s": (0.14, 0.19),
255
+ "countermovement_depth_m": (0.12, 0.20),
256
+ "concentric_duration_s": (0.80, 1.20),
257
+ "eccentric_duration_s": (0.60, 0.95),
258
+ "peak_eccentric_velocity_ms": (0.4, 0.7),
259
+ "peak_concentric_velocity_ms": (1.0, 1.5),
260
+ "rsi": (0.15, 0.25),
261
+ "hip_angle_deg": (150, 165),
262
+ "knee_angle_deg": (155, 170),
263
+ "ankle_angle_deg": (105, 125),
264
+ },
265
+ },
266
+ "recreational": {
267
+ "label": "Recreational Athlete (fitness participant, 30-45 yrs)",
268
+ "profile": AthleteProfile.RECREATIONAL,
269
+ "expected": {
270
+ "jump_height_m": (0.35, 0.55),
271
+ "flight_time_s": (0.53, 0.67),
272
+ "countermovement_depth_m": (0.28, 0.45),
273
+ "concentric_duration_s": (0.45, 0.65),
274
+ "eccentric_duration_s": (0.40, 0.65),
275
+ "peak_eccentric_velocity_ms": (1.3, 1.9),
276
+ "peak_concentric_velocity_ms": (2.6, 3.3),
277
+ "rsi": (0.85, 1.25),
278
+ "hip_angle_deg": (168, 178),
279
+ "knee_angle_deg": (170, 182),
280
+ "ankle_angle_deg": (120, 138),
281
+ },
282
+ },
283
+ "elite_male": {
284
+ "label": "Elite Male Athlete (college/pro volleyball/basketball)",
285
+ "profile": AthleteProfile.ELITE,
286
+ "expected": {
287
+ "jump_height_m": (0.68, 0.88),
288
+ "flight_time_s": (0.74, 0.84),
289
+ "countermovement_depth_m": (0.42, 0.62),
290
+ "concentric_duration_s": (0.28, 0.42),
291
+ "eccentric_duration_s": (0.35, 0.55),
292
+ "peak_eccentric_velocity_ms": (2.1, 3.2),
293
+ "peak_concentric_velocity_ms": (3.6, 4.2),
294
+ "rsi": (1.85, 2.80),
295
+ "hip_angle_deg": (173, 185),
296
+ "knee_angle_deg": (176, 188),
297
+ "ankle_angle_deg": (132, 148),
298
+ },
299
+ },
300
+ }
301
+
302
+
303
+ def estimate_athlete_profile(
304
+ metrics_dict: MetricsDict, gender: str | None = None
305
+ ) -> AthleteProfile:
306
+ """Estimate athlete profile from metrics.
307
+
308
+ Uses jump height as primary classifier:
309
+ - <0.20m: Elderly
310
+ - 0.20-0.35m: Untrained
311
+ - 0.35-0.65m: Recreational
312
+ - 0.65-0.85m: Trained
313
+ - >0.85m: Elite
314
+
315
+ NOTE: Bounds are calibrated for adult males. Female athletes typically achieve
316
+ 60-70% of male heights due to lower muscle mass and strength. If analyzing
317
+ female athletes, interpret results one level lower than classification suggests.
318
+ Example: Female athlete with 0.45m jump = Recreational male = Trained female.
319
+
320
+ Args:
321
+ metrics_dict: Dictionary with CMJ metric values
322
+ gender: Optional gender for context ("M"/"F"). Currently informational only.
323
+
324
+ Returns:
325
+ Estimated AthleteProfile
326
+ """
327
+ # Support both nested "data" structure and flat structure
328
+ # Extract with unit suffix as used in serialization, or without suffix (legacy)
329
+ data = metrics_dict.get("data", metrics_dict)
330
+ jump_height = data.get("jump_height_m") or data.get("jump_height", 0)
331
+
332
+ if jump_height < 0.20:
333
+ return AthleteProfile.ELDERLY
334
+ elif jump_height < 0.35:
335
+ return AthleteProfile.UNTRAINED
336
+ elif jump_height < 0.65:
337
+ return AthleteProfile.RECREATIONAL
338
+ elif jump_height < 0.85:
339
+ return AthleteProfile.TRAINED
340
+ else:
341
+ return AthleteProfile.ELITE
@@ -8,7 +8,15 @@ from .filtering import (
8
8
  reject_outliers,
9
9
  remove_outliers,
10
10
  )
11
+ from .model_downloader import get_model_cache_dir, get_model_path
11
12
  from .pose import PoseTracker, compute_center_of_mass
13
+ from .pose_landmarks import KINEMOTION_LANDMARKS, LANDMARK_INDICES
14
+ from .quality import (
15
+ QualityAssessment,
16
+ QualityIndicators,
17
+ assess_jump_quality,
18
+ calculate_position_stability,
19
+ )
12
20
  from .smoothing import (
13
21
  compute_acceleration_from_derivative,
14
22
  compute_velocity,
@@ -16,12 +24,22 @@ from .smoothing import (
16
24
  smooth_landmarks,
17
25
  smooth_landmarks_advanced,
18
26
  )
27
+ from .timing import (
28
+ NULL_TIMER,
29
+ NullTimer,
30
+ PerformanceTimer,
31
+ Timer,
32
+ )
19
33
  from .video_io import VideoProcessor
20
34
 
21
35
  __all__ = [
22
36
  # Pose tracking
23
37
  "PoseTracker",
24
38
  "compute_center_of_mass",
39
+ "LANDMARK_INDICES",
40
+ "KINEMOTION_LANDMARKS",
41
+ "get_model_path",
42
+ "get_model_cache_dir",
25
43
  # Smoothing
26
44
  "smooth_landmarks",
27
45
  "smooth_landmarks_advanced",
@@ -35,6 +53,16 @@ __all__ = [
35
53
  "reject_outliers",
36
54
  "adaptive_smooth_window",
37
55
  "bilateral_temporal_filter",
56
+ # Quality Assessment
57
+ "QualityAssessment",
58
+ "QualityIndicators",
59
+ "assess_jump_quality",
60
+ "calculate_position_stability",
61
+ # Timing
62
+ "PerformanceTimer",
63
+ "Timer",
64
+ "NullTimer",
65
+ "NULL_TIMER",
38
66
  # Video I/O
39
67
  "VideoProcessor",
40
68
  ]
@@ -108,10 +108,12 @@ def auto_tune_parameters(
108
108
  # =================================================================
109
109
 
110
110
  # Velocity threshold: Scale inversely with fps
111
- # At 30fps, feet move ~2% of frame per frame when "stationary"
112
- # At 60fps, feet move ~1% of frame per frame when "stationary"
113
- # Formula: threshold = 0.02 * (30 / fps)
114
- base_velocity_threshold = 0.02 * (30.0 / fps)
111
+ # Empirically validated with 45° oblique videos at 60fps:
112
+ # - Standing (stationary): ~0.001 mean, 0.0011 max
113
+ # - Flight/drop (moving): ~0.005-0.009
114
+ # Target threshold: 0.002 at 60fps for clear separation
115
+ # Formula: threshold = 0.004 * (30 / fps)
116
+ base_velocity_threshold = 0.004 * (30.0 / fps)
115
117
 
116
118
  # Min contact frames: Scale with fps to maintain same time duration
117
119
  # Goal: ~100ms minimum contact (3 frames @ 30fps, 6 frames @ 60fps)
@@ -166,9 +168,7 @@ def auto_tune_parameters(
166
168
  elif quality_preset == QualityPreset.ACCURATE:
167
169
  # Accurate: Maximize accuracy, accept slower processing
168
170
  velocity_threshold = base_velocity_threshold * 0.5 # More sensitive
169
- min_contact_frames = (
170
- base_min_contact_frames # Don't increase (would miss brief)
171
- )
171
+ min_contact_frames = base_min_contact_frames # Don't increase (would miss brief)
172
172
  smoothing_window = min(11, base_smoothing_window + 2 + smoothing_adjustment)
173
173
  bilateral_filter = True # Always use for best accuracy
174
174
  detection_confidence = 0.6
@@ -216,6 +216,59 @@ def auto_tune_parameters(
216
216
  )
217
217
 
218
218
 
219
+ def _collect_foot_visibility_and_positions(
220
+ frame_landmarks: dict[str, tuple[float, float, float]],
221
+ ) -> tuple[list[float], list[float]]:
222
+ """
223
+ Collect visibility scores and Y positions from foot landmarks.
224
+
225
+ Args:
226
+ frame_landmarks: Landmarks for a single frame
227
+
228
+ Returns:
229
+ Tuple of (visibility_scores, y_positions)
230
+ """
231
+ foot_keys = [
232
+ "left_ankle",
233
+ "right_ankle",
234
+ "left_heel",
235
+ "right_heel",
236
+ "left_foot_index",
237
+ "right_foot_index",
238
+ ]
239
+
240
+ frame_vis = []
241
+ frame_y_positions = []
242
+
243
+ for key in foot_keys:
244
+ if key in frame_landmarks:
245
+ _, y, vis = frame_landmarks[key] # x not needed for analysis
246
+ frame_vis.append(vis)
247
+ frame_y_positions.append(y)
248
+
249
+ return frame_vis, frame_y_positions
250
+
251
+
252
+ def _check_stable_period(positions: list[float]) -> bool:
253
+ """
254
+ Check if video has a stable period at the start.
255
+
256
+ A stable period (low variance in first 30 frames) indicates
257
+ the subject is standing on an elevated platform before jumping.
258
+
259
+ Args:
260
+ positions: List of average Y positions per frame
261
+
262
+ Returns:
263
+ True if stable period detected, False otherwise
264
+ """
265
+ if len(positions) < 30:
266
+ return False
267
+
268
+ first_30_std = float(np.std(positions[:30]))
269
+ return first_30_std < 0.01 # Very stable = on platform
270
+
271
+
219
272
  def analyze_video_sample(
220
273
  landmarks_sequence: list[dict[str, tuple[float, float, float]] | None],
221
274
  fps: float,
@@ -235,35 +288,20 @@ def analyze_video_sample(
235
288
  Returns:
236
289
  VideoCharacteristics with analyzed properties
237
290
  """
238
- # Calculate average landmark visibility
239
291
  visibilities = []
240
292
  positions = []
241
293
 
294
+ # Collect visibility and position data from all frames
242
295
  for frame_landmarks in landmarks_sequence:
243
- if frame_landmarks:
244
- # Collect visibility scores from foot landmarks
245
- foot_keys = [
246
- "left_ankle",
247
- "right_ankle",
248
- "left_heel",
249
- "right_heel",
250
- "left_foot_index",
251
- "right_foot_index",
252
- ]
253
-
254
- frame_vis = []
255
- frame_y_positions = []
256
-
257
- for key in foot_keys:
258
- if key in frame_landmarks:
259
- _, y, vis = frame_landmarks[key] # x not needed for analysis
260
- frame_vis.append(vis)
261
- frame_y_positions.append(y)
262
-
263
- if frame_vis:
264
- visibilities.append(float(np.mean(frame_vis)))
265
- if frame_y_positions:
266
- positions.append(float(np.mean(frame_y_positions)))
296
+ if not frame_landmarks:
297
+ continue
298
+
299
+ frame_vis, frame_y_positions = _collect_foot_visibility_and_positions(frame_landmarks)
300
+
301
+ if frame_vis:
302
+ visibilities.append(float(np.mean(frame_vis)))
303
+ if frame_y_positions:
304
+ positions.append(float(np.mean(frame_y_positions)))
267
305
 
268
306
  # Compute metrics
269
307
  avg_visibility = float(np.mean(visibilities)) if visibilities else 0.5
@@ -273,11 +311,7 @@ def analyze_video_sample(
273
311
  tracking_quality = analyze_tracking_quality(avg_visibility)
274
312
 
275
313
  # Check for stable period (indicates drop jump from elevated platform)
276
- # Simple check: do first 30 frames have low variance?
277
- has_stable_period = False
278
- if len(positions) >= 30:
279
- first_30_std = float(np.std(positions[:30]))
280
- has_stable_period = first_30_std < 0.01 # Very stable = on platform
314
+ has_stable_period = _check_stable_period(positions)
281
315
 
282
316
  return VideoCharacteristics(
283
317
  fps=fps,
@@ -0,0 +1,60 @@
1
+ """Shared CLI utilities for drop jump and CMJ analysis."""
2
+
3
+ import glob
4
+ from collections.abc import Callable
5
+ from pathlib import Path
6
+
7
+ import click
8
+
9
+
10
+ def common_output_options(func: Callable) -> Callable: # type: ignore[type-arg]
11
+ """Add common output options to CLI command."""
12
+ func = click.option(
13
+ "--output",
14
+ "-o",
15
+ type=click.Path(),
16
+ help="Path for debug video output (optional)",
17
+ )(func)
18
+ func = click.option(
19
+ "--json-output",
20
+ "-j",
21
+ type=click.Path(),
22
+ help="Path for JSON metrics output (default: stdout)",
23
+ )(func)
24
+ return func
25
+
26
+
27
+ def collect_video_files(video_path: tuple[str, ...]) -> list[str]:
28
+ """Expand glob patterns and collect all video files."""
29
+ video_files: list[str] = []
30
+ for pattern in video_path:
31
+ expanded = glob.glob(pattern)
32
+ if expanded:
33
+ video_files.extend(expanded)
34
+ elif Path(pattern).exists():
35
+ video_files.append(pattern)
36
+ else:
37
+ click.echo(f"Warning: No files found for pattern: {pattern}", err=True)
38
+ return video_files
39
+
40
+
41
+ def generate_batch_output_paths(
42
+ video_path: str, output_dir: str | None, json_output_dir: str | None
43
+ ) -> tuple[str | None, str | None]:
44
+ """Generate output paths for debug video and JSON in batch mode.
45
+
46
+ Args:
47
+ video_path: Path to source video
48
+ output_dir: Directory for debug video output (optional)
49
+ json_output_dir: Directory for JSON metrics output (optional)
50
+
51
+ Returns:
52
+ Tuple of (debug_video_path, json_output_path)
53
+ """
54
+ out_path = None
55
+ json_path = None
56
+ if output_dir:
57
+ out_path = str(Path(output_dir) / f"{Path(video_path).stem}_debug.mp4")
58
+ if json_output_dir:
59
+ json_path = str(Path(json_output_dir) / f"{Path(video_path).stem}.json")
60
+ return out_path, json_path