kinemotion 0.29.3__py3-none-any.whl → 0.31.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of kinemotion might be problematic. Click here for more details.

@@ -0,0 +1,346 @@
1
+ """Drop jump metrics validation using physiological bounds.
2
+
3
+ Comprehensive validation of Drop Jump metrics against biomechanical bounds,
4
+ consistency checks, and cross-validation of RSI calculation.
5
+
6
+ Provides severity levels (ERROR, WARNING, INFO) for different categories
7
+ of metric issues.
8
+ """
9
+
10
+ from dataclasses import dataclass, field
11
+ from enum import Enum
12
+
13
+ from kinemotion.core.dropjump_validation_bounds import (
14
+ AthleteProfile,
15
+ DropJumpBounds,
16
+ estimate_athlete_profile,
17
+ )
18
+
19
+
20
+ class ValidationSeverity(Enum):
21
+ """Severity level for validation issues."""
22
+
23
+ ERROR = "ERROR" # Metrics invalid, likely data corruption
24
+ WARNING = "WARNING" # Metrics valid but unusual, needs review
25
+ INFO = "INFO" # Normal variation, informational only
26
+
27
+
28
+ @dataclass
29
+ class ValidationIssue:
30
+ """Single validation issue."""
31
+
32
+ severity: ValidationSeverity
33
+ metric: str
34
+ message: str
35
+ value: float | None = None
36
+ bounds: tuple[float, float] | None = None
37
+
38
+
39
+ @dataclass
40
+ class ValidationResult:
41
+ """Complete validation result for drop jump metrics."""
42
+
43
+ issues: list[ValidationIssue] = field(default_factory=list)
44
+ status: str = "PASS" # "PASS", "PASS_WITH_WARNINGS", "FAIL"
45
+ athlete_profile: AthleteProfile | None = None
46
+ rsi: float | None = None
47
+ contact_flight_ratio: float | None = None
48
+ height_kinematic_trajectory_consistency: float | None = None # % error
49
+
50
+ def add_error(
51
+ self,
52
+ metric: str,
53
+ message: str,
54
+ value: float | None = None,
55
+ bounds: tuple[float, float] | None = None,
56
+ ) -> None:
57
+ """Add error-level issue."""
58
+ self.issues.append(
59
+ ValidationIssue(
60
+ severity=ValidationSeverity.ERROR,
61
+ metric=metric,
62
+ message=message,
63
+ value=value,
64
+ bounds=bounds,
65
+ )
66
+ )
67
+
68
+ def add_warning(
69
+ self,
70
+ metric: str,
71
+ message: str,
72
+ value: float | None = None,
73
+ bounds: tuple[float, float] | None = None,
74
+ ) -> None:
75
+ """Add warning-level issue."""
76
+ self.issues.append(
77
+ ValidationIssue(
78
+ severity=ValidationSeverity.WARNING,
79
+ metric=metric,
80
+ message=message,
81
+ value=value,
82
+ bounds=bounds,
83
+ )
84
+ )
85
+
86
+ def add_info(
87
+ self,
88
+ metric: str,
89
+ message: str,
90
+ value: float | None = None,
91
+ ) -> None:
92
+ """Add info-level issue."""
93
+ self.issues.append(
94
+ ValidationIssue(
95
+ severity=ValidationSeverity.INFO,
96
+ metric=metric,
97
+ message=message,
98
+ value=value,
99
+ )
100
+ )
101
+
102
+ def finalize_status(self) -> None:
103
+ """Determine final pass/fail status based on issues."""
104
+ has_errors = any(
105
+ issue.severity == ValidationSeverity.ERROR for issue in self.issues
106
+ )
107
+ has_warnings = any(
108
+ issue.severity == ValidationSeverity.WARNING for issue in self.issues
109
+ )
110
+
111
+ if has_errors:
112
+ self.status = "FAIL"
113
+ elif has_warnings:
114
+ self.status = "PASS_WITH_WARNINGS"
115
+ else:
116
+ self.status = "PASS"
117
+
118
+ def to_dict(self) -> dict:
119
+ """Convert validation result to JSON-serializable dictionary.
120
+
121
+ Returns:
122
+ Dictionary with status, issues, and consistency metrics.
123
+ """
124
+ return {
125
+ "status": self.status,
126
+ "issues": [
127
+ {
128
+ "severity": issue.severity.value,
129
+ "metric": issue.metric,
130
+ "message": issue.message,
131
+ "value": issue.value,
132
+ "bounds": issue.bounds,
133
+ }
134
+ for issue in self.issues
135
+ ],
136
+ "athlete_profile": (
137
+ self.athlete_profile.value if self.athlete_profile else None
138
+ ),
139
+ "rsi": self.rsi,
140
+ "contact_flight_ratio": self.contact_flight_ratio,
141
+ "height_kinematic_trajectory_consistency_percent": (
142
+ self.height_kinematic_trajectory_consistency
143
+ ),
144
+ }
145
+
146
+
147
+ class DropJumpMetricsValidator:
148
+ """Comprehensive drop jump metrics validator."""
149
+
150
+ def __init__(self, assumed_profile: AthleteProfile | None = None):
151
+ """Initialize validator.
152
+
153
+ Args:
154
+ assumed_profile: If provided, validate against this specific profile.
155
+ Otherwise, estimate from metrics.
156
+ """
157
+ self.assumed_profile = assumed_profile
158
+
159
+ def validate(self, metrics: dict) -> ValidationResult:
160
+ """Validate drop jump metrics comprehensively.
161
+
162
+ Args:
163
+ metrics: Dictionary with drop jump metric values
164
+
165
+ Returns:
166
+ ValidationResult with all issues and status
167
+ """
168
+ result = ValidationResult()
169
+
170
+ # Estimate athlete profile if not provided
171
+ if self.assumed_profile:
172
+ result.athlete_profile = self.assumed_profile
173
+ else:
174
+ result.athlete_profile = estimate_athlete_profile(metrics)
175
+
176
+ # Extract metric values (handle nested "data" structure)
177
+ data = metrics.get("data", metrics) # Support both structures
178
+
179
+ contact_time_ms = data.get("ground_contact_time_ms")
180
+ flight_time_ms = data.get("flight_time_ms")
181
+ jump_height_m = data.get("jump_height_m")
182
+ jump_height_kinematic_m = data.get("jump_height_kinematic_m")
183
+ jump_height_trajectory_m = data.get("jump_height_trajectory_normalized")
184
+
185
+ # Validate individual metrics
186
+ if contact_time_ms is not None:
187
+ self._check_contact_time(contact_time_ms, result)
188
+
189
+ if flight_time_ms is not None:
190
+ self._check_flight_time(flight_time_ms, result)
191
+
192
+ if jump_height_m is not None:
193
+ self._check_jump_height(jump_height_m, result)
194
+
195
+ # Cross-validation
196
+ if contact_time_ms is not None and flight_time_ms is not None:
197
+ self._check_rsi(contact_time_ms, flight_time_ms, result)
198
+
199
+ # Dual height validation (kinematic vs trajectory)
200
+ if jump_height_kinematic_m is not None and jump_height_trajectory_m is not None:
201
+ self._check_dual_height_consistency(
202
+ jump_height_kinematic_m, jump_height_trajectory_m, result
203
+ )
204
+
205
+ # Finalize status
206
+ result.finalize_status()
207
+
208
+ return result
209
+
210
+ def _check_contact_time(
211
+ self, contact_time_ms: float, result: ValidationResult
212
+ ) -> None:
213
+ """Validate contact time."""
214
+ contact_time_s = contact_time_ms / 1000.0
215
+ bounds = DropJumpBounds.CONTACT_TIME
216
+
217
+ if not bounds.is_physically_possible(contact_time_s):
218
+ result.add_error(
219
+ "contact_time",
220
+ f"Contact time {contact_time_s:.3f}s physically impossible",
221
+ value=contact_time_s,
222
+ bounds=(bounds.absolute_min, bounds.absolute_max),
223
+ )
224
+ elif result.athlete_profile and not bounds.contains(
225
+ contact_time_s, result.athlete_profile
226
+ ):
227
+ profile_name = result.athlete_profile.value
228
+ result.add_warning(
229
+ "contact_time",
230
+ f"Contact time {contact_time_s:.3f}s unusual for "
231
+ f"{profile_name} athlete",
232
+ value=contact_time_s,
233
+ )
234
+
235
+ def _check_flight_time(
236
+ self, flight_time_ms: float, result: ValidationResult
237
+ ) -> None:
238
+ """Validate flight time."""
239
+ flight_time_s = flight_time_ms / 1000.0
240
+ bounds = DropJumpBounds.FLIGHT_TIME
241
+
242
+ if not bounds.is_physically_possible(flight_time_s):
243
+ result.add_error(
244
+ "flight_time",
245
+ f"Flight time {flight_time_s:.3f}s physically impossible",
246
+ value=flight_time_s,
247
+ bounds=(bounds.absolute_min, bounds.absolute_max),
248
+ )
249
+ elif result.athlete_profile and not bounds.contains(
250
+ flight_time_s, result.athlete_profile
251
+ ):
252
+ profile_name = result.athlete_profile.value
253
+ result.add_warning(
254
+ "flight_time",
255
+ f"Flight time {flight_time_s:.3f}s unusual for {profile_name} athlete",
256
+ value=flight_time_s,
257
+ )
258
+
259
+ def _check_jump_height(
260
+ self, jump_height_m: float, result: ValidationResult
261
+ ) -> None:
262
+ """Validate jump height."""
263
+ bounds = DropJumpBounds.JUMP_HEIGHT
264
+
265
+ if not bounds.is_physically_possible(jump_height_m):
266
+ result.add_error(
267
+ "jump_height",
268
+ f"Jump height {jump_height_m:.3f}m physically impossible",
269
+ value=jump_height_m,
270
+ bounds=(bounds.absolute_min, bounds.absolute_max),
271
+ )
272
+ elif result.athlete_profile and not bounds.contains(
273
+ jump_height_m, result.athlete_profile
274
+ ):
275
+ profile_name = result.athlete_profile.value
276
+ result.add_warning(
277
+ "jump_height",
278
+ f"Jump height {jump_height_m:.3f}m unusual for {profile_name} athlete",
279
+ value=jump_height_m,
280
+ )
281
+
282
+ def _check_rsi(
283
+ self, contact_time_ms: float, flight_time_ms: float, result: ValidationResult
284
+ ) -> None:
285
+ """Validate RSI and cross-check consistency."""
286
+ contact_time_s = contact_time_ms / 1000.0
287
+ flight_time_s = flight_time_ms / 1000.0
288
+
289
+ if contact_time_s > 0 and flight_time_s > 0:
290
+ rsi = flight_time_s / contact_time_s
291
+ result.rsi = rsi
292
+ result.contact_flight_ratio = contact_time_s / flight_time_s
293
+
294
+ bounds = DropJumpBounds.RSI
295
+
296
+ if not bounds.is_physically_possible(rsi):
297
+ result.add_error(
298
+ "rsi",
299
+ f"RSI {rsi:.2f} physically impossible",
300
+ value=rsi,
301
+ bounds=(bounds.absolute_min, bounds.absolute_max),
302
+ )
303
+ elif result.athlete_profile and not bounds.contains(
304
+ rsi, result.athlete_profile
305
+ ):
306
+ result.add_warning(
307
+ "rsi",
308
+ f"RSI {rsi:.2f} unusual for {result.athlete_profile.value} athlete",
309
+ value=rsi,
310
+ )
311
+
312
+ def _check_dual_height_consistency(
313
+ self,
314
+ jump_height_kinematic_m: float,
315
+ jump_height_trajectory_m: float,
316
+ result: ValidationResult,
317
+ ) -> None:
318
+ """Validate consistency between kinematic and trajectory-based heights.
319
+
320
+ Kinematic height (h = g*t²/8) comes from flight time (objective).
321
+ Trajectory height comes from position tracking (subject to landmark
322
+ detection noise).
323
+
324
+ Expected correlation: r > 0.95, absolute difference < 5% for quality video.
325
+ """
326
+ if jump_height_kinematic_m <= 0 or jump_height_trajectory_m <= 0:
327
+ return # Skip if either value is missing or invalid
328
+
329
+ # Calculate percentage difference
330
+ avg_height = (jump_height_kinematic_m + jump_height_trajectory_m) / 2.0
331
+ if avg_height > 0:
332
+ abs_diff = abs(jump_height_kinematic_m - jump_height_trajectory_m)
333
+ percent_error = (abs_diff / avg_height) * 100.0
334
+ result.height_kinematic_trajectory_consistency = percent_error
335
+
336
+ # Allow 10% tolerance for typical video processing noise
337
+ if percent_error > 10.0:
338
+ result.add_warning(
339
+ "height_consistency",
340
+ f"Kinematic ({jump_height_kinematic_m:.3f}m) and trajectory "
341
+ f"({jump_height_trajectory_m:.3f}m) heights differ by "
342
+ f"{percent_error:.1f}%. May indicate landmark detection "
343
+ "issues or video quality problems.",
344
+ value=percent_error,
345
+ bounds=(0, 10),
346
+ )
@@ -0,0 +1,196 @@
1
+ """Drop jump metrics physiological bounds for validation testing.
2
+
3
+ This module defines realistic physiological bounds for Drop Jump metrics
4
+ based on biomechanical literature and real-world athlete performance.
5
+
6
+ Drop jump metrics differ from CMJ:
7
+ - Contact time (ground interaction during landing)
8
+ - Flight time (time in air after landing)
9
+ - RSI (Reactive Strength Index) = flight_time / contact_time
10
+ - Jump height (calculated from flight time)
11
+
12
+ References:
13
+ - Komi & Bosco (1978): Drop jump RSI and elastic properties
14
+ - Flanagan & Comyns (2008): RSI reliability and athlete assessment
15
+ - Covens et al. (2019): Drop jump kinetics across athletes
16
+ """
17
+
18
+ from dataclasses import dataclass
19
+ from enum import Enum
20
+
21
+
22
+ class AthleteProfile(Enum):
23
+ """Athlete performance categories for metric bounds."""
24
+
25
+ ELDERLY = "elderly" # 70+, deconditioned
26
+ UNTRAINED = "untrained" # Sedentary, no training
27
+ RECREATIONAL = "recreational" # Fitness class, moderate activity
28
+ TRAINED = "trained" # Regular athlete, 3-5 years training
29
+ ELITE = "elite" # Competitive athlete, college/professional level
30
+
31
+
32
+ @dataclass
33
+ class MetricBounds:
34
+ """Physiological bounds for a single metric.
35
+
36
+ Attributes:
37
+ absolute_min: Absolute minimum value (error threshold)
38
+ practical_min: Practical minimum for weakest athletes
39
+ recreational_min: Minimum for recreational athletes
40
+ recreational_max: Maximum for recreational athletes
41
+ elite_min: Minimum for elite athletes
42
+ elite_max: Maximum for elite athletes
43
+ absolute_max: Absolute maximum value (error threshold)
44
+ unit: Unit of measurement (e.g., "s", "m", "ratio")
45
+ """
46
+
47
+ absolute_min: float
48
+ practical_min: float
49
+ recreational_min: float
50
+ recreational_max: float
51
+ elite_min: float
52
+ elite_max: float
53
+ absolute_max: float
54
+ unit: str
55
+
56
+ def contains(self, value: float, profile: AthleteProfile) -> bool:
57
+ """Check if value is within bounds for athlete profile."""
58
+ if profile == AthleteProfile.ELDERLY:
59
+ return self.practical_min <= value <= self.recreational_max
60
+ elif profile == AthleteProfile.UNTRAINED:
61
+ return self.practical_min <= value <= self.recreational_max
62
+ elif profile == AthleteProfile.RECREATIONAL:
63
+ return self.recreational_min <= value <= self.recreational_max
64
+ elif profile == AthleteProfile.TRAINED:
65
+ # Trained athletes: midpoint between recreational and elite
66
+ trained_min = (self.recreational_min + self.elite_min) / 2
67
+ trained_max = (self.recreational_max + self.elite_max) / 2
68
+ return trained_min <= value <= trained_max
69
+ elif profile == AthleteProfile.ELITE:
70
+ return self.elite_min <= value <= self.elite_max
71
+ return False
72
+
73
+ def is_physically_possible(self, value: float) -> bool:
74
+ """Check if value is within absolute physiological limits."""
75
+ return self.absolute_min <= value <= self.absolute_max
76
+
77
+
78
+ class DropJumpBounds:
79
+ """Collection of physiological bounds for all drop jump metrics."""
80
+
81
+ # GROUND CONTACT TIME (seconds, landing interaction)
82
+ CONTACT_TIME = MetricBounds(
83
+ absolute_min=0.08, # Physiological minimum: neural delay + deceleration
84
+ practical_min=0.15, # Extreme plyometric
85
+ recreational_min=0.35, # Typical landing
86
+ recreational_max=0.70, # Slower absorption
87
+ elite_min=0.20,
88
+ elite_max=0.50,
89
+ absolute_max=1.50,
90
+ unit="s",
91
+ )
92
+
93
+ # FLIGHT TIME (seconds, after landing)
94
+ FLIGHT_TIME = MetricBounds(
95
+ absolute_min=0.30,
96
+ practical_min=0.40, # Minimal jump
97
+ recreational_min=0.50,
98
+ recreational_max=0.85,
99
+ elite_min=0.65,
100
+ elite_max=1.10,
101
+ absolute_max=1.40,
102
+ unit="s",
103
+ )
104
+
105
+ # JUMP HEIGHT (meters, calculated from flight time)
106
+ JUMP_HEIGHT = MetricBounds(
107
+ absolute_min=0.05,
108
+ practical_min=0.10,
109
+ recreational_min=0.25,
110
+ recreational_max=0.65,
111
+ elite_min=0.50,
112
+ elite_max=1.00,
113
+ absolute_max=1.30,
114
+ unit="m",
115
+ )
116
+
117
+ # REACTIVE STRENGTH INDEX (RSI) = flight_time / contact_time (ratio, no unit)
118
+ RSI = MetricBounds(
119
+ absolute_min=0.30, # Very poor reactive ability
120
+ practical_min=0.50,
121
+ recreational_min=0.70,
122
+ recreational_max=1.80,
123
+ elite_min=1.50, # Elite: fast contact, long flight
124
+ elite_max=3.50,
125
+ absolute_max=5.00,
126
+ unit="ratio",
127
+ )
128
+
129
+
130
+ def estimate_athlete_profile(
131
+ metrics: dict, gender: str | None = None
132
+ ) -> AthleteProfile:
133
+ """Estimate athlete profile from drop jump metrics.
134
+
135
+ Uses jump_height and contact_time to classify athlete level.
136
+
137
+ NOTE: Bounds are calibrated for adult males. Female athletes typically achieve
138
+ 60-70% of male heights due to lower muscle mass and strength. If analyzing
139
+ female athletes, interpret results one level lower than classification suggests.
140
+
141
+ Args:
142
+ metrics: Dictionary with drop jump metric values
143
+ gender: Optional gender for context ("M"/"F"). Currently informational only.
144
+
145
+ Returns:
146
+ Estimated AthleteProfile (ELDERLY, UNTRAINED, RECREATIONAL, TRAINED, or ELITE)
147
+ """
148
+ jump_height = metrics.get("data", {}).get("jump_height_m")
149
+ contact_time = metrics.get("data", {}).get("ground_contact_time_ms")
150
+
151
+ if jump_height is None or contact_time is None:
152
+ return AthleteProfile.RECREATIONAL # Default
153
+
154
+ # Convert contact_time from ms to seconds
155
+ contact_time_s = contact_time / 1000.0
156
+
157
+ # Decision logic: Use weighted combination to avoid over-weighting single metrics
158
+ # Calculate profile scores based on each metric
159
+ height_score = 0.0
160
+ if jump_height < 0.25:
161
+ height_score = 0 # Elderly
162
+ elif jump_height < 0.35:
163
+ height_score = 1 # Untrained
164
+ elif jump_height < 0.50:
165
+ height_score = 2 # Recreational
166
+ elif jump_height < 0.70:
167
+ height_score = 3 # Trained
168
+ else:
169
+ height_score = 4 # Elite
170
+
171
+ contact_score = 0.0
172
+ if contact_time_s > 0.60:
173
+ contact_score = 0 # Elderly
174
+ elif contact_time_s > 0.50:
175
+ contact_score = 1 # Untrained
176
+ elif contact_time_s > 0.45:
177
+ contact_score = 2 # Recreational
178
+ elif contact_time_s > 0.40:
179
+ contact_score = 3 # Trained
180
+ else:
181
+ contact_score = 4 # Elite
182
+
183
+ # Weight height more heavily (70%) than contact time (30%)
184
+ # Height is more reliable indicator across populations
185
+ combined_score = (height_score * 0.70) + (contact_score * 0.30)
186
+
187
+ if combined_score < 1.0:
188
+ return AthleteProfile.ELDERLY
189
+ elif combined_score < 1.7:
190
+ return AthleteProfile.UNTRAINED
191
+ elif combined_score < 2.7:
192
+ return AthleteProfile.RECREATIONAL
193
+ elif combined_score < 3.7:
194
+ return AthleteProfile.TRAINED
195
+ else:
196
+ return AthleteProfile.ELITE
@@ -146,7 +146,8 @@ class AlgorithmConfig:
146
146
  """Complete algorithm configuration for reproducibility.
147
147
 
148
148
  Attributes:
149
- detection_method: Algorithm used ("backward_search" for CMJ, "forward_search" for drop)
149
+ detection_method: Algorithm used ("backward_search" for CMJ,
150
+ "forward_search" for drop)
150
151
  tracking_method: Pose tracking method ("mediapipe_pose")
151
152
  model_complexity: MediaPipe model complexity (0, 1, or 2)
152
153
  smoothing: Smoothing configuration
@@ -307,8 +307,9 @@ def _generate_warnings(
307
307
  # Tracking stability warnings
308
308
  if not indicators.tracking_stable:
309
309
  warnings.append(
310
- f"Unstable landmark tracking detected (variance {indicators.position_variance:.4f}). "
311
- "This may indicate jitter or occlusion. Consider better lighting or camera position."
310
+ f"Unstable landmark tracking detected "
311
+ f"(variance {indicators.position_variance:.4f}). This may indicate "
312
+ "jitter or occlusion. Consider better lighting or camera position."
312
313
  )
313
314
 
314
315
  # Outlier warnings
@@ -349,8 +350,8 @@ def _generate_warnings(
349
350
  # Overall confidence warning
350
351
  if confidence == "low":
351
352
  warnings.append(
352
- "⚠️ LOW CONFIDENCE: Results may be unreliable. "
353
- "Review quality indicators and consider re-recording with better conditions."
353
+ "⚠️ LOW CONFIDENCE: Results may be unreliable. Review quality "
354
+ "indicators and consider re-recording with better conditions."
354
355
  )
355
356
  elif confidence == "medium":
356
357
  warnings.append(
@@ -124,7 +124,8 @@ def _store_smoothed_landmarks(
124
124
  )
125
125
 
126
126
 
127
- def _smooth_landmarks_core( # NOSONAR(S1172) - polyorder used via closure capture in smoother_fn
127
+ def _smooth_landmarks_core( # NOSONAR(S1172) - polyorder used via closure
128
+ # capture in smoother_fn
128
129
  landmark_sequence: LandmarkSequence,
129
130
  window_length: int,
130
131
  polyorder: int,
@@ -136,7 +137,8 @@ def _smooth_landmarks_core( # NOSONAR(S1172) - polyorder used via closure captu
136
137
  Args:
137
138
  landmark_sequence: List of landmark dictionaries from each frame
138
139
  window_length: Length of filter window (must be odd)
139
- polyorder: Order of polynomial used to fit samples (captured by smoother_fn closure)
140
+ polyorder: Order of polynomial used to fit samples (captured by
141
+ smoother_fn closure)
140
142
  smoother_fn: Function that takes (x_coords, y_coords, valid_frames)
141
143
  and returns (x_smooth, y_smooth)
142
144
 
@@ -46,8 +46,9 @@ class VideoProcessor:
46
46
  self.width = int(self.cap.get(cv2.CAP_PROP_FRAME_WIDTH))
47
47
  self.height = int(self.cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
48
48
 
49
- # Extract rotation metadata from video (iPhones store rotation in side_data_list)
50
- # OpenCV ignores rotation metadata, so we need to extract and apply it manually
49
+ # Extract rotation metadata from video (iPhones store rotation in
50
+ # side_data_list). OpenCV ignores rotation metadata, so we need to
51
+ # extract and apply it manually
51
52
  self.rotation = 0 # Will be set by _extract_video_metadata()
52
53
 
53
54
  # Extract codec information from video metadata
@@ -98,7 +98,8 @@ def _find_stable_baseline(
98
98
  """Find first stable period and return baseline position.
99
99
 
100
100
  Returns:
101
- Tuple of (baseline_start_frame, baseline_position). Returns (-1, 0.0) if not found.
101
+ Tuple of (baseline_start_frame, baseline_position). Returns (-1, 0.0)
102
+ if not found.
102
103
  """
103
104
  stable_window = min_stable_frames
104
105
 
@@ -159,8 +160,8 @@ def _find_drop_from_baseline(
159
160
  f"{position_change_threshold:.4f}"
160
161
  )
161
162
  print(
162
- f" avg_position: {avg_position:.4f} vs "
163
- f"baseline: {baseline_position:.4f}"
163
+ f" avg_position: {avg_position:.4f} vs baseline: "
164
+ f"{baseline_position:.4f}"
164
165
  )
165
166
 
166
167
  return drop_frame
@@ -179,7 +180,8 @@ def detect_drop_start(
179
180
  debug: bool = False,
180
181
  ) -> int:
181
182
  """
182
- Detect when the drop jump actually starts by finding stable period then detecting drop.
183
+ Detect when the drop jump actually starts by finding stable period then
184
+ detecting drop.
183
185
 
184
186
  Strategy:
185
187
  1. Scan forward to find first STABLE period (low variance over N frames)
@@ -191,7 +193,8 @@ def detect_drop_start(
191
193
  Args:
192
194
  positions: Array of vertical positions (0-1 normalized, y increases downward)
193
195
  fps: Video frame rate
194
- min_stationary_duration: Minimum duration (seconds) of stable period (default: 1.0s)
196
+ min_stationary_duration: Minimum duration (seconds) of stable period
197
+ (default: 1.0s)
195
198
  position_change_threshold: Position change indicating start of drop
196
199
  (default: 0.02 = 2% of frame)
197
200
  smoothing_window: Window for computing position variance
@@ -832,9 +835,11 @@ def extract_foot_positions_and_visibilities(
832
835
  smoothed_landmarks: list[dict[str, tuple[float, float, float]] | None],
833
836
  ) -> tuple[np.ndarray, np.ndarray]:
834
837
  """
835
- Extract vertical positions and average visibilities from smoothed landmarks.
838
+ Extract vertical positions and average visibilities from smoothed
839
+ landmarks.
836
840
 
837
- This utility function eliminates code duplication between CLI and programmatic usage.
841
+ This utility function eliminates code duplication between CLI and
842
+ programmatic usage.
838
843
 
839
844
  Args:
840
845
  smoothed_landmarks: Smoothed landmark sequence from tracking
@@ -133,7 +133,8 @@ class AnalysisParameters:
133
133
  default=None,
134
134
  help="[EXPERT] Override pose tracking confidence",
135
135
  )
136
- def dropjump_analyze( # NOSONAR(S107) - Click CLI requires individual parameters for each option
136
+ def dropjump_analyze( # NOSONAR(S107) - Click CLI requires individual
137
+ # parameters for each option
137
138
  video_path: tuple[str, ...],
138
139
  output: str | None,
139
140
  json_output: str | None,
@@ -153,10 +154,12 @@ def dropjump_analyze( # NOSONAR(S107) - Click CLI requires individual parameter
153
154
  tracking_confidence: float | None,
154
155
  ) -> None:
155
156
  """
156
- Analyze drop-jump video(s) to estimate ground contact time, flight time, and jump height.
157
+ Analyze drop-jump video(s) to estimate ground contact time, flight time,
158
+ and jump height.
157
159
 
158
- Uses intelligent auto-tuning to select optimal parameters based on video characteristics.
159
- Parameters are automatically adjusted for frame rate, tracking quality, and analysis preset.
160
+ Uses intelligent auto-tuning to select optimal parameters based on video
161
+ characteristics. Parameters are automatically adjusted for frame rate,
162
+ tracking quality, and analysis preset.
160
163
 
161
164
  VIDEO_PATH: Path(s) to video file(s). Supports glob patterns in batch mode
162
165
  (e.g., "videos/*.mp4").