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
@@ -1,14 +1,54 @@
1
1
  """Kinematic calculations for drop-jump metrics."""
2
2
 
3
+ from typing import TYPE_CHECKING, TypedDict
4
+
3
5
  import numpy as np
6
+ from numpy.typing import NDArray
4
7
 
8
+ from ..core.formatting import format_float_metric, format_int_metric
9
+ from ..core.smoothing import compute_acceleration_from_derivative
10
+ from ..core.timing import NULL_TIMER, Timer
5
11
  from .analysis import (
6
12
  ContactState,
7
13
  detect_drop_start,
8
14
  find_contact_phases,
9
15
  find_interpolated_phase_transitions_with_curvature,
16
+ find_landing_from_acceleration,
10
17
  )
11
18
 
19
+ if TYPE_CHECKING:
20
+ from ..core.dropjump_metrics_validator import ValidationResult
21
+ from ..core.metadata import ResultMetadata
22
+ from ..core.quality import QualityAssessment
23
+
24
+
25
+ class DropJumpDataDict(TypedDict, total=False):
26
+ """Type-safe dictionary for drop jump measurement data."""
27
+
28
+ ground_contact_time_ms: float | None
29
+ flight_time_ms: float | None
30
+ jump_height_m: float | None
31
+ jump_height_kinematic_m: float | None
32
+ jump_height_trajectory_m: float | None
33
+ jump_height_trajectory_normalized: float | None
34
+ contact_start_frame: int | None
35
+ contact_end_frame: int | None
36
+ flight_start_frame: int | None
37
+ flight_end_frame: int | None
38
+ peak_height_frame: int | None
39
+ contact_start_frame_precise: float | None
40
+ contact_end_frame_precise: float | None
41
+ flight_start_frame_precise: float | None
42
+ flight_end_frame_precise: float | None
43
+
44
+
45
+ class DropJumpResultDict(TypedDict, total=False):
46
+ """Type-safe dictionary for complete drop jump result with data and metadata."""
47
+
48
+ data: DropJumpDataDict
49
+ metadata: dict # ResultMetadata.to_dict()
50
+ validation: dict # ValidationResult.to_dict()
51
+
12
52
 
13
53
  class DropJumpMetrics:
14
54
  """Container for drop-jump analysis metrics."""
@@ -18,7 +58,11 @@ class DropJumpMetrics:
18
58
  self.flight_time: float | None = None
19
59
  self.jump_height: float | None = None
20
60
  self.jump_height_kinematic: float | None = None # From flight time
21
- self.jump_height_trajectory: float | None = None # From position tracking
61
+ # From position tracking (normalized)
62
+ self.jump_height_trajectory: float | None = None
63
+ # From position tracking (meters)
64
+ self.jump_height_trajectory_m: float | None = None
65
+ self.drop_start_frame: int | None = None # Frame when athlete leaves box
22
66
  self.contact_start_frame: int | None = None
23
67
  self.contact_end_frame: int | None = None
24
68
  self.flight_start_frame: int | None = None
@@ -29,84 +73,76 @@ class DropJumpMetrics:
29
73
  self.contact_end_frame_precise: float | None = None
30
74
  self.flight_start_frame_precise: float | None = None
31
75
  self.flight_end_frame_precise: float | None = None
32
-
33
- def to_dict(self) -> dict:
34
- """Convert metrics to dictionary for JSON output."""
76
+ # Quality assessment
77
+ self.quality_assessment: QualityAssessment | None = None
78
+ # Complete metadata
79
+ self.result_metadata: ResultMetadata | None = None
80
+ # Validation result
81
+ self.validation_result: ValidationResult | None = None
82
+
83
+ def _build_data_dict(self) -> DropJumpDataDict:
84
+ """Build the data portion of the result dictionary.
85
+
86
+ Returns:
87
+ Dictionary containing formatted metric values.
88
+ """
35
89
  return {
36
- "ground_contact_time_ms": (
37
- round(self.ground_contact_time * 1000, 2)
38
- if self.ground_contact_time is not None
39
- else None
40
- ),
41
- "flight_time_ms": (
42
- round(self.flight_time * 1000, 2)
43
- if self.flight_time is not None
44
- else None
45
- ),
46
- "jump_height_m": (
47
- round(self.jump_height, 3) if self.jump_height is not None else None
48
- ),
49
- "jump_height_kinematic_m": (
50
- round(self.jump_height_kinematic, 3)
51
- if self.jump_height_kinematic is not None
52
- else None
53
- ),
54
- "jump_height_trajectory_normalized": (
55
- round(self.jump_height_trajectory, 4)
56
- if self.jump_height_trajectory is not None
57
- else None
90
+ "ground_contact_time_ms": format_float_metric(self.ground_contact_time, 1000, 2),
91
+ "flight_time_ms": format_float_metric(self.flight_time, 1000, 2),
92
+ "jump_height_m": format_float_metric(self.jump_height, 1, 3),
93
+ "jump_height_kinematic_m": format_float_metric(self.jump_height_kinematic, 1, 3),
94
+ "jump_height_trajectory_m": format_float_metric(self.jump_height_trajectory_m, 1, 3),
95
+ "jump_height_trajectory_normalized": format_float_metric(
96
+ self.jump_height_trajectory, 1, 4
58
97
  ),
59
- "contact_start_frame": (
60
- int(self.contact_start_frame)
61
- if self.contact_start_frame is not None
62
- else None
98
+ "contact_start_frame": format_int_metric(self.contact_start_frame),
99
+ "contact_end_frame": format_int_metric(self.contact_end_frame),
100
+ "flight_start_frame": format_int_metric(self.flight_start_frame),
101
+ "flight_end_frame": format_int_metric(self.flight_end_frame),
102
+ "peak_height_frame": format_int_metric(self.peak_height_frame),
103
+ "contact_start_frame_precise": format_float_metric(
104
+ self.contact_start_frame_precise, 1, 3
63
105
  ),
64
- "contact_end_frame": (
65
- int(self.contact_end_frame)
66
- if self.contact_end_frame is not None
67
- else None
68
- ),
69
- "flight_start_frame": (
70
- int(self.flight_start_frame)
71
- if self.flight_start_frame is not None
72
- else None
73
- ),
74
- "flight_end_frame": (
75
- int(self.flight_end_frame)
76
- if self.flight_end_frame is not None
77
- else None
78
- ),
79
- "peak_height_frame": (
80
- int(self.peak_height_frame)
81
- if self.peak_height_frame is not None
82
- else None
83
- ),
84
- "contact_start_frame_precise": (
85
- round(self.contact_start_frame_precise, 3)
86
- if self.contact_start_frame_precise is not None
87
- else None
88
- ),
89
- "contact_end_frame_precise": (
90
- round(self.contact_end_frame_precise, 3)
91
- if self.contact_end_frame_precise is not None
92
- else None
93
- ),
94
- "flight_start_frame_precise": (
95
- round(self.flight_start_frame_precise, 3)
96
- if self.flight_start_frame_precise is not None
97
- else None
98
- ),
99
- "flight_end_frame_precise": (
100
- round(self.flight_end_frame_precise, 3)
101
- if self.flight_end_frame_precise is not None
102
- else None
106
+ "contact_end_frame_precise": format_float_metric(self.contact_end_frame_precise, 1, 3),
107
+ "flight_start_frame_precise": format_float_metric(
108
+ self.flight_start_frame_precise, 1, 3
103
109
  ),
110
+ "flight_end_frame_precise": format_float_metric(self.flight_end_frame_precise, 1, 3),
111
+ }
112
+
113
+ def _build_metadata_dict(self) -> dict:
114
+ """Build the metadata portion of the result dictionary.
115
+
116
+ Returns:
117
+ Metadata dictionary from available sources.
118
+ """
119
+ if self.result_metadata is not None:
120
+ return self.result_metadata.to_dict()
121
+ if self.quality_assessment is not None:
122
+ return {"quality": self.quality_assessment.to_dict()}
123
+ return {}
124
+
125
+ def to_dict(self) -> DropJumpResultDict:
126
+ """Convert metrics to JSON-serializable dictionary with data/metadata structure.
127
+
128
+ Returns:
129
+ Dictionary with nested data and metadata structure.
130
+ """
131
+ result: DropJumpResultDict = {
132
+ "data": self._build_data_dict(),
133
+ "metadata": self._build_metadata_dict(),
104
134
  }
105
135
 
136
+ # Include validation results if available
137
+ if self.validation_result is not None:
138
+ result["validation"] = self.validation_result.to_dict()
139
+
140
+ return result
141
+
106
142
 
107
143
  def _determine_drop_start_frame(
108
144
  drop_start_frame: int | None,
109
- foot_y_positions: np.ndarray,
145
+ foot_y_positions: NDArray[np.float64],
110
146
  fps: float,
111
147
  smoothing_window: int,
112
148
  ) -> int:
@@ -123,14 +159,13 @@ def _determine_drop_start_frame(
123
159
  """
124
160
  if drop_start_frame is None:
125
161
  # Auto-detect where drop jump actually starts (skip initial stationary period)
126
- detected_frame = detect_drop_start(
162
+ return detect_drop_start(
127
163
  foot_y_positions,
128
164
  fps,
129
165
  min_stationary_duration=0.5,
130
- position_change_threshold=0.005,
166
+ position_change_threshold=0.01, # Improved from 0.005 for better accuracy
131
167
  smoothing_window=smoothing_window,
132
168
  )
133
- return detected_frame if detected_frame is not None else 0
134
169
  return drop_start_frame
135
170
 
136
171
 
@@ -138,9 +173,7 @@ def _filter_phases_after_drop(
138
173
  phases: list[tuple[int, int, ContactState]],
139
174
  interpolated_phases: list[tuple[float, float, ContactState]],
140
175
  drop_start_frame: int,
141
- ) -> tuple[
142
- list[tuple[int, int, ContactState]], list[tuple[float, float, ContactState]]
143
- ]:
176
+ ) -> tuple[list[tuple[int, int, ContactState]], list[tuple[float, float, ContactState]]]:
144
177
  """Filter phases to only include those after drop start.
145
178
 
146
179
  Args:
@@ -158,18 +191,46 @@ def _filter_phases_after_drop(
158
191
  (start, end, state) for start, end, state in phases if end >= drop_start_frame
159
192
  ]
160
193
  filtered_interpolated = [
161
- (start, end, state)
162
- for start, end, state in interpolated_phases
163
- if end >= drop_start_frame
194
+ (start, end, state) for start, end, state in interpolated_phases if end >= drop_start_frame
164
195
  ]
165
196
  return filtered_phases, filtered_interpolated
166
197
 
167
198
 
199
+ def _compute_robust_phase_position(
200
+ foot_y_positions: NDArray[np.float64],
201
+ phase_start: int,
202
+ phase_end: int,
203
+ temporal_window: int = 11,
204
+ ) -> float:
205
+ """Compute robust position estimate using temporal averaging.
206
+
207
+ Uses median over a fixed temporal window to reduce sensitivity to
208
+ MediaPipe landmark noise, improving reproducibility.
209
+
210
+ Args:
211
+ foot_y_positions: Vertical position array
212
+ phase_start: Start frame of phase
213
+ phase_end: End frame of phase
214
+ temporal_window: Number of frames to average (default: 11)
215
+
216
+ Returns:
217
+ Robust position estimate using median
218
+ """
219
+ # Center the temporal window on the phase midpoint
220
+ phase_mid = (phase_start + phase_end) // 2
221
+ window_start = max(0, phase_mid - temporal_window // 2)
222
+ window_end = min(len(foot_y_positions), phase_mid + temporal_window // 2 + 1)
223
+
224
+ # Use median for robustness to outliers
225
+ window_positions = foot_y_positions[window_start:window_end]
226
+ return float(np.median(window_positions))
227
+
228
+
168
229
  def _identify_main_contact_phase(
169
230
  phases: list[tuple[int, int, ContactState]],
170
231
  ground_phases: list[tuple[int, int, int]],
171
232
  air_phases_indexed: list[tuple[int, int, int]],
172
- foot_y_positions: np.ndarray,
233
+ foot_y_positions: NDArray[np.float64],
173
234
  ) -> tuple[int, int, bool]:
174
235
  """Identify the main contact phase and determine if it's a drop jump.
175
236
 
@@ -193,23 +254,25 @@ def _identify_main_contact_phase(
193
254
 
194
255
  # Find ground phase after first air phase
195
256
  ground_after_air = [
196
- (start, end, idx)
197
- for start, end, idx in ground_phases
198
- if idx > first_air_idx
257
+ (start, end, idx) for start, end, idx in ground_phases if idx > first_air_idx
199
258
  ]
200
259
 
201
260
  if ground_after_air and first_ground_idx < first_air_idx:
202
- # Check if first ground is at higher elevation (lower y) than ground after air
203
- first_ground_y = float(
204
- np.mean(foot_y_positions[first_ground_start : first_ground_end + 1])
261
+ # Check if first ground is at higher elevation (lower y) than
262
+ # ground after air using robust temporal averaging
263
+ first_ground_y = _compute_robust_phase_position(
264
+ foot_y_positions, first_ground_start, first_ground_end
205
265
  )
206
266
  second_ground_start, second_ground_end, _ = ground_after_air[0]
207
- second_ground_y = float(
208
- np.mean(foot_y_positions[second_ground_start : second_ground_end + 1])
267
+ second_ground_y = _compute_robust_phase_position(
268
+ foot_y_positions, second_ground_start, second_ground_end
209
269
  )
210
270
 
211
- # If first ground is significantly higher (>5% of frame), it's a drop jump
212
- if second_ground_y - first_ground_y > 0.05:
271
+ # If first ground is significantly higher (>7% of frame), it's a drop jump
272
+ # Increased from 0.05 to 0.07 with 11-frame temporal averaging
273
+ # for reproducibility (balances detection sensitivity with noise robustness)
274
+ # Note: MediaPipe has inherent non-determinism (Google issue #3945)
275
+ if second_ground_y - first_ground_y > 0.07:
213
276
  is_drop_jump = True
214
277
  contact_start, contact_end = second_ground_start, second_ground_end
215
278
 
@@ -254,68 +317,21 @@ def _find_precise_phase_timing(
254
317
  return contact_start_frac, contact_end_frac
255
318
 
256
319
 
257
- def _calculate_calibration_scale(
258
- drop_height_m: float | None,
259
- phases: list[tuple[int, int, ContactState]],
260
- air_phases_indexed: list[tuple[int, int, int]],
261
- foot_y_positions: np.ndarray,
262
- ) -> float:
263
- """Calculate calibration scale factor from known drop height.
264
-
265
- Args:
266
- drop_height_m: Known drop height in meters
267
- phases: All phase tuples
268
- air_phases_indexed: Air phases with indices
269
- foot_y_positions: Vertical position array
270
-
271
- Returns:
272
- Scale factor (1.0 if no calibration possible)
273
- """
274
- scale_factor = 1.0
275
-
276
- if drop_height_m is None or len(phases) < 2:
277
- return scale_factor
278
-
279
- if not air_phases_indexed:
280
- return scale_factor
281
-
282
- # Get first air phase (the drop)
283
- first_air_start, first_air_end, _ = air_phases_indexed[0]
284
-
285
- # Initial position: at start of drop (on the box)
286
- lookback_start = max(0, first_air_start - 5)
287
- if lookback_start < first_air_start:
288
- initial_position = float(
289
- np.mean(foot_y_positions[lookback_start:first_air_start])
290
- )
291
- else:
292
- initial_position = float(foot_y_positions[first_air_start])
293
-
294
- # Landing position: at the ground after drop
295
- landing_position = float(foot_y_positions[first_air_end])
296
-
297
- # Drop distance in normalized coordinates (y increases downward)
298
- drop_normalized = landing_position - initial_position
299
-
300
- if drop_normalized > 0.01: # Sanity check
301
- scale_factor = drop_height_m / drop_normalized
302
-
303
- return scale_factor
304
-
305
-
306
320
  def _analyze_flight_phase(
307
321
  metrics: DropJumpMetrics,
308
322
  phases: list[tuple[int, int, ContactState]],
309
323
  interpolated_phases: list[tuple[float, float, ContactState]],
310
324
  contact_end: int,
311
- foot_y_positions: np.ndarray,
325
+ foot_y_positions: NDArray[np.float64],
312
326
  fps: float,
313
- drop_height_m: float | None,
314
- scale_factor: float,
315
- kinematic_correction_factor: float,
327
+ smoothing_window: int,
328
+ polyorder: int,
316
329
  ) -> None:
317
330
  """Analyze flight phase and calculate jump height metrics.
318
331
 
332
+ Uses acceleration-based landing detection (like CMJ) for accurate flight time,
333
+ then calculates jump height using kinematic formula h = g*t²/8.
334
+
319
335
  Args:
320
336
  metrics: DropJumpMetrics object to populate
321
337
  phases: All phase tuples
@@ -323,38 +339,38 @@ def _analyze_flight_phase(
323
339
  contact_end: End of contact phase
324
340
  foot_y_positions: Vertical position array
325
341
  fps: Video frame rate
326
- drop_height_m: Known drop height (optional)
327
- scale_factor: Calibration scale factor
328
- kinematic_correction_factor: Correction for kinematic method
342
+ smoothing_window: Window size for acceleration computation
343
+ polyorder: Polynomial order for Savitzky-Golay filter
329
344
  """
330
- # Find flight phase after ground contact
331
- flight_phases = [
332
- (start, end)
333
- for start, end, state in phases
334
- if state == ContactState.IN_AIR and start > contact_end
335
- ]
345
+ # Find takeoff frame (end of ground contact)
346
+ flight_start = contact_end
336
347
 
337
- if not flight_phases:
338
- return
348
+ # Compute accelerations for landing detection
349
+ accelerations = compute_acceleration_from_derivative(
350
+ foot_y_positions, window_length=smoothing_window, polyorder=polyorder
351
+ )
339
352
 
340
- flight_start, flight_end = flight_phases[0]
353
+ # Use acceleration-based landing detection (like CMJ)
354
+ # This finds the actual ground impact, not just when velocity drops
355
+ flight_end = find_landing_from_acceleration(
356
+ foot_y_positions, accelerations, flight_start, fps, search_duration=0.7
357
+ )
341
358
 
342
359
  # Store integer frame indices
343
360
  metrics.flight_start_frame = flight_start
344
361
  metrics.flight_end_frame = flight_end
345
362
 
346
- # Find precise timing
363
+ # Find precise sub-frame timing for takeoff
347
364
  flight_start_frac = float(flight_start)
348
365
  flight_end_frac = float(flight_end)
349
366
 
350
367
  for start_frac, end_frac, state in interpolated_phases:
351
368
  if (
352
- state == ContactState.IN_AIR
369
+ state == ContactState.ON_GROUND
353
370
  and int(start_frac) <= flight_start <= int(end_frac) + 1
354
- and int(start_frac) <= flight_end <= int(end_frac) + 1
355
371
  ):
356
- flight_start_frac = start_frac
357
- flight_end_frac = end_frac
372
+ # Use end of ground contact as precise takeoff
373
+ flight_start_frac = end_frac
358
374
  break
359
375
 
360
376
  # Calculate flight time
@@ -363,11 +379,16 @@ def _analyze_flight_phase(
363
379
  metrics.flight_start_frame_precise = flight_start_frac
364
380
  metrics.flight_end_frame_precise = flight_end_frac
365
381
 
366
- # Calculate jump height using kinematic method
382
+ # Calculate jump height using kinematic method (like CMJ)
383
+ # h = g * t² / 8
367
384
  g = 9.81 # m/s^2
368
385
  jump_height_kinematic = (g * metrics.flight_time**2) / 8
369
386
 
370
- # Calculate jump height from trajectory
387
+ # Always use kinematic method for jump height (like CMJ)
388
+ metrics.jump_height = jump_height_kinematic
389
+ metrics.jump_height_kinematic = jump_height_kinematic
390
+
391
+ # Calculate trajectory-based height for reference
371
392
  takeoff_position = foot_y_positions[flight_start]
372
393
  flight_positions = foot_y_positions[flight_start : flight_end + 1]
373
394
 
@@ -379,70 +400,70 @@ def _analyze_flight_phase(
379
400
  height_normalized = float(takeoff_position - peak_position)
380
401
  metrics.jump_height_trajectory = height_normalized
381
402
 
382
- # Choose measurement method based on calibration availability
383
- if drop_height_m is not None and scale_factor > 1.0:
384
- metrics.jump_height = height_normalized * scale_factor
385
- metrics.jump_height_kinematic = jump_height_kinematic
403
+ # Calculate scale factor and metric height
404
+ # Scale factor = kinematic height / normalized height
405
+ if height_normalized > 0.001:
406
+ scale_factor = metrics.jump_height_kinematic / height_normalized
407
+ metrics.jump_height_trajectory_m = height_normalized * scale_factor
386
408
  else:
387
- metrics.jump_height = jump_height_kinematic * kinematic_correction_factor
388
- metrics.jump_height_kinematic = jump_height_kinematic
389
- else:
390
- # Fallback to kinematic if no position data
391
- if drop_height_m is None:
392
- metrics.jump_height = jump_height_kinematic * kinematic_correction_factor
393
- else:
394
- metrics.jump_height = jump_height_kinematic
395
- metrics.jump_height_kinematic = jump_height_kinematic
409
+ metrics.jump_height_trajectory_m = 0.0
396
410
 
397
411
 
398
412
  def calculate_drop_jump_metrics(
399
413
  contact_states: list[ContactState],
400
- foot_y_positions: np.ndarray,
414
+ foot_y_positions: NDArray[np.float64],
401
415
  fps: float,
402
- drop_height_m: float | None = None,
403
416
  drop_start_frame: int | None = None,
404
417
  velocity_threshold: float = 0.02,
405
418
  smoothing_window: int = 5,
406
419
  polyorder: int = 2,
407
420
  use_curvature: bool = True,
408
- kinematic_correction_factor: float = 1.0,
421
+ timer: Timer | None = None,
409
422
  ) -> DropJumpMetrics:
410
423
  """
411
424
  Calculate drop-jump metrics from contact states and positions.
412
425
 
426
+ Jump height is calculated from flight time using kinematic formula: h = g × t² / 8
427
+
413
428
  Args:
414
429
  contact_states: Contact state for each frame
415
430
  foot_y_positions: Vertical positions of feet (normalized 0-1)
416
431
  fps: Video frame rate
417
- drop_height_m: Known drop box/platform height in meters for calibration (optional)
418
- velocity_threshold: Velocity threshold used for contact detection (for interpolation)
419
- smoothing_window: Window size for velocity/acceleration smoothing (must be odd)
432
+ drop_start_frame: Optional manual drop start frame
433
+ velocity_threshold: Velocity threshold used for contact detection
434
+ (for interpolation)
435
+ smoothing_window: Window size for velocity/acceleration smoothing
436
+ (must be odd)
420
437
  polyorder: Polynomial order for Savitzky-Golay filter (default: 2)
421
438
  use_curvature: Whether to use curvature analysis for refining transitions
422
- kinematic_correction_factor: Correction factor for kinematic jump height calculation
423
- (default: 1.0 = no correction). Historical testing suggested 1.35, but this is
424
- unvalidated. Use calibrated measurement (--drop-height) for validated results.
439
+ timer: Optional Timer for measuring operations
425
440
 
426
441
  Returns:
427
442
  DropJumpMetrics object with calculated values
428
443
  """
444
+ timer = timer or NULL_TIMER
429
445
  metrics = DropJumpMetrics()
430
446
 
431
447
  # Determine drop start frame
432
- drop_start_frame_value = _determine_drop_start_frame(
433
- drop_start_frame, foot_y_positions, fps, smoothing_window
434
- )
448
+ with timer.measure("dj_detect_drop_start"):
449
+ drop_start_frame_value = _determine_drop_start_frame(
450
+ drop_start_frame, foot_y_positions, fps, smoothing_window
451
+ )
452
+
453
+ # Store drop start frame in metrics
454
+ metrics.drop_start_frame = drop_start_frame_value if drop_start_frame_value > 0 else None
435
455
 
436
456
  # Find contact phases
437
- phases = find_contact_phases(contact_states)
438
- interpolated_phases = find_interpolated_phase_transitions_with_curvature(
439
- foot_y_positions,
440
- contact_states,
441
- velocity_threshold,
442
- smoothing_window,
443
- polyorder,
444
- use_curvature,
445
- )
457
+ with timer.measure("dj_find_phases"):
458
+ phases = find_contact_phases(contact_states)
459
+ interpolated_phases = find_interpolated_phase_transitions_with_curvature(
460
+ foot_y_positions,
461
+ contact_states,
462
+ velocity_threshold,
463
+ smoothing_window,
464
+ polyorder,
465
+ use_curvature,
466
+ )
446
467
 
447
468
  if not phases:
448
469
  return metrics
@@ -471,9 +492,10 @@ def calculate_drop_jump_metrics(
471
492
  return metrics
472
493
 
473
494
  # Identify main contact phase
474
- contact_start, contact_end, _ = _identify_main_contact_phase(
475
- phases, ground_phases, air_phases_indexed, foot_y_positions
476
- )
495
+ with timer.measure("dj_identify_contact"):
496
+ contact_start, contact_end, _ = _identify_main_contact_phase(
497
+ phases, ground_phases, air_phases_indexed, foot_y_positions
498
+ )
477
499
 
478
500
  # Store integer frame indices
479
501
  metrics.contact_start_frame = contact_start
@@ -490,62 +512,17 @@ def calculate_drop_jump_metrics(
490
512
  metrics.contact_start_frame_precise = contact_start_frac
491
513
  metrics.contact_end_frame_precise = contact_end_frac
492
514
 
493
- # Calculate calibration scale factor
494
- scale_factor = _calculate_calibration_scale(
495
- drop_height_m, phases, air_phases_indexed, foot_y_positions
496
- )
497
-
498
515
  # Analyze flight phase and calculate jump height
499
- _analyze_flight_phase(
500
- metrics,
501
- phases,
502
- interpolated_phases,
503
- contact_end,
504
- foot_y_positions,
505
- fps,
506
- drop_height_m,
507
- scale_factor,
508
- kinematic_correction_factor,
509
- )
516
+ with timer.measure("dj_analyze_flight"):
517
+ _analyze_flight_phase(
518
+ metrics,
519
+ phases,
520
+ interpolated_phases,
521
+ contact_end,
522
+ foot_y_positions,
523
+ fps,
524
+ smoothing_window,
525
+ polyorder,
526
+ )
510
527
 
511
528
  return metrics
512
-
513
-
514
- def estimate_jump_height_from_trajectory(
515
- foot_y_positions: np.ndarray,
516
- flight_start: int,
517
- flight_end: int,
518
- pixel_to_meter_ratio: float | None = None,
519
- ) -> float:
520
- """
521
- Estimate jump height from position trajectory.
522
-
523
- Args:
524
- foot_y_positions: Vertical positions of feet (normalized or pixels)
525
- flight_start: Frame where flight begins
526
- flight_end: Frame where flight ends
527
- pixel_to_meter_ratio: Conversion factor from pixels to meters
528
-
529
- Returns:
530
- Estimated jump height in meters (or normalized units if no calibration)
531
- """
532
- if flight_end < flight_start:
533
- return 0.0
534
-
535
- # Get position at takeoff (end of contact) and peak (minimum y during flight)
536
- takeoff_position = foot_y_positions[flight_start]
537
- flight_positions = foot_y_positions[flight_start : flight_end + 1]
538
-
539
- if len(flight_positions) == 0:
540
- return 0.0
541
-
542
- peak_position = np.min(flight_positions)
543
-
544
- # Height difference (in normalized coordinates, y increases downward)
545
- height_diff = takeoff_position - peak_position
546
-
547
- # Convert to meters if calibration available
548
- if pixel_to_meter_ratio is not None:
549
- return float(height_diff * pixel_to_meter_ratio)
550
-
551
- return float(height_diff)