kinemotion 0.10.4__tar.gz → 0.10.5__tar.gz

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 (55) hide show
  1. {kinemotion-0.10.4 → kinemotion-0.10.5}/CHANGELOG.md +8 -0
  2. {kinemotion-0.10.4 → kinemotion-0.10.5}/PKG-INFO +1 -1
  3. {kinemotion-0.10.4 → kinemotion-0.10.5}/pyproject.toml +1 -1
  4. {kinemotion-0.10.4 → kinemotion-0.10.5}/src/kinemotion/dropjump/kinematics.py +318 -206
  5. {kinemotion-0.10.4 → kinemotion-0.10.5}/uv.lock +1 -1
  6. {kinemotion-0.10.4 → kinemotion-0.10.5}/.dockerignore +0 -0
  7. {kinemotion-0.10.4 → kinemotion-0.10.5}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  8. {kinemotion-0.10.4 → kinemotion-0.10.5}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  9. {kinemotion-0.10.4 → kinemotion-0.10.5}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  10. {kinemotion-0.10.4 → kinemotion-0.10.5}/.github/pull_request_template.md +0 -0
  11. {kinemotion-0.10.4 → kinemotion-0.10.5}/.github/workflows/release.yml +0 -0
  12. {kinemotion-0.10.4 → kinemotion-0.10.5}/.gitignore +0 -0
  13. {kinemotion-0.10.4 → kinemotion-0.10.5}/.pre-commit-config.yaml +0 -0
  14. {kinemotion-0.10.4 → kinemotion-0.10.5}/.tool-versions +0 -0
  15. {kinemotion-0.10.4 → kinemotion-0.10.5}/CLAUDE.md +0 -0
  16. {kinemotion-0.10.4 → kinemotion-0.10.5}/CODE_OF_CONDUCT.md +0 -0
  17. {kinemotion-0.10.4 → kinemotion-0.10.5}/CONTRIBUTING.md +0 -0
  18. {kinemotion-0.10.4 → kinemotion-0.10.5}/Dockerfile +0 -0
  19. {kinemotion-0.10.4 → kinemotion-0.10.5}/GEMINI.md +0 -0
  20. {kinemotion-0.10.4 → kinemotion-0.10.5}/LICENSE +0 -0
  21. {kinemotion-0.10.4 → kinemotion-0.10.5}/README.md +0 -0
  22. {kinemotion-0.10.4 → kinemotion-0.10.5}/SECURITY.md +0 -0
  23. {kinemotion-0.10.4 → kinemotion-0.10.5}/docs/BULK_PROCESSING.md +0 -0
  24. {kinemotion-0.10.4 → kinemotion-0.10.5}/docs/ERRORS_FINDINGS.md +0 -0
  25. {kinemotion-0.10.4 → kinemotion-0.10.5}/docs/FRAMERATE.md +0 -0
  26. {kinemotion-0.10.4 → kinemotion-0.10.5}/docs/IMU_METADATA_PRESERVATION.md +0 -0
  27. {kinemotion-0.10.4 → kinemotion-0.10.5}/docs/PARAMETERS.md +0 -0
  28. {kinemotion-0.10.4 → kinemotion-0.10.5}/docs/VALIDATION_PLAN.md +0 -0
  29. {kinemotion-0.10.4 → kinemotion-0.10.5}/examples/bulk/README.md +0 -0
  30. {kinemotion-0.10.4 → kinemotion-0.10.5}/examples/bulk/bulk_processing.py +0 -0
  31. {kinemotion-0.10.4 → kinemotion-0.10.5}/examples/bulk/simple_example.py +0 -0
  32. {kinemotion-0.10.4 → kinemotion-0.10.5}/examples/programmatic_usage.py +0 -0
  33. {kinemotion-0.10.4 → kinemotion-0.10.5}/src/kinemotion/__init__.py +0 -0
  34. {kinemotion-0.10.4 → kinemotion-0.10.5}/src/kinemotion/api.py +0 -0
  35. {kinemotion-0.10.4 → kinemotion-0.10.5}/src/kinemotion/cli.py +0 -0
  36. {kinemotion-0.10.4 → kinemotion-0.10.5}/src/kinemotion/core/__init__.py +0 -0
  37. {kinemotion-0.10.4 → kinemotion-0.10.5}/src/kinemotion/core/auto_tuning.py +0 -0
  38. {kinemotion-0.10.4 → kinemotion-0.10.5}/src/kinemotion/core/filtering.py +0 -0
  39. {kinemotion-0.10.4 → kinemotion-0.10.5}/src/kinemotion/core/pose.py +0 -0
  40. {kinemotion-0.10.4 → kinemotion-0.10.5}/src/kinemotion/core/smoothing.py +0 -0
  41. {kinemotion-0.10.4 → kinemotion-0.10.5}/src/kinemotion/core/video_io.py +0 -0
  42. {kinemotion-0.10.4 → kinemotion-0.10.5}/src/kinemotion/dropjump/__init__.py +0 -0
  43. {kinemotion-0.10.4 → kinemotion-0.10.5}/src/kinemotion/dropjump/analysis.py +0 -0
  44. {kinemotion-0.10.4 → kinemotion-0.10.5}/src/kinemotion/dropjump/cli.py +0 -0
  45. {kinemotion-0.10.4 → kinemotion-0.10.5}/src/kinemotion/dropjump/debug_overlay.py +0 -0
  46. {kinemotion-0.10.4 → kinemotion-0.10.5}/src/kinemotion/py.typed +0 -0
  47. {kinemotion-0.10.4 → kinemotion-0.10.5}/tests/__init__.py +0 -0
  48. {kinemotion-0.10.4 → kinemotion-0.10.5}/tests/test_adaptive_threshold.py +0 -0
  49. {kinemotion-0.10.4 → kinemotion-0.10.5}/tests/test_api.py +0 -0
  50. {kinemotion-0.10.4 → kinemotion-0.10.5}/tests/test_aspect_ratio.py +0 -0
  51. {kinemotion-0.10.4 → kinemotion-0.10.5}/tests/test_com_estimation.py +0 -0
  52. {kinemotion-0.10.4 → kinemotion-0.10.5}/tests/test_contact_detection.py +0 -0
  53. {kinemotion-0.10.4 → kinemotion-0.10.5}/tests/test_filtering.py +0 -0
  54. {kinemotion-0.10.4 → kinemotion-0.10.5}/tests/test_kinematics.py +0 -0
  55. {kinemotion-0.10.4 → kinemotion-0.10.5}/tests/test_polyorder.py +0 -0
@@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  <!-- version list -->
9
9
 
10
+ ## v0.10.5 (2025-11-03)
11
+
12
+ ### Bug Fixes
13
+
14
+ - **kinematics**: Reduce cognitive complexity in calculate_drop_jump_metrics
15
+ ([`d6a06f3`](https://github.com/feniix/kinemotion/commit/d6a06f3671eb370a971c73c98270668d5aefe9b1))
16
+
17
+
10
18
  ## v0.10.4 (2025-11-03)
11
19
 
12
20
  ### Bug Fixes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kinemotion
3
- Version: 0.10.4
3
+ Version: 0.10.5
4
4
  Summary: Video-based kinematic analysis for athletic performance
5
5
  Project-URL: Homepage, https://github.com/feniix/kinemotion
6
6
  Project-URL: Repository, https://github.com/feniix/kinemotion
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "kinemotion"
3
- version = "0.10.4"
3
+ version = "0.10.5"
4
4
  description = "Video-based kinematic analysis for athletic performance"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10,<3.13"
@@ -104,113 +104,89 @@ class DropJumpMetrics:
104
104
  }
105
105
 
106
106
 
107
- def calculate_drop_jump_metrics(
108
- contact_states: list[ContactState],
107
+ def _determine_drop_start_frame(
108
+ drop_start_frame: int | None,
109
109
  foot_y_positions: np.ndarray,
110
110
  fps: float,
111
- drop_height_m: float | None = None,
112
- drop_start_frame: int | None = None,
113
- velocity_threshold: float = 0.02,
114
- smoothing_window: int = 5,
115
- polyorder: int = 2,
116
- use_curvature: bool = True,
117
- kinematic_correction_factor: float = 1.0,
118
- ) -> DropJumpMetrics:
119
- """
120
- Calculate drop-jump metrics from contact states and positions.
111
+ smoothing_window: int,
112
+ ) -> int:
113
+ """Determine the drop start frame for analysis.
121
114
 
122
115
  Args:
123
- contact_states: Contact state for each frame
124
- foot_y_positions: Vertical positions of feet (normalized 0-1)
116
+ drop_start_frame: Manual drop start frame or None for auto-detection
117
+ foot_y_positions: Vertical positions array
125
118
  fps: Video frame rate
126
- drop_height_m: Known drop box/platform height in meters for calibration (optional)
127
- velocity_threshold: Velocity threshold used for contact detection (for interpolation)
128
- smoothing_window: Window size for velocity/acceleration smoothing (must be odd)
129
- polyorder: Polynomial order for Savitzky-Golay filter (default: 2)
130
- use_curvature: Whether to use curvature analysis for refining transitions
131
- kinematic_correction_factor: Correction factor for kinematic jump height calculation
132
- (default: 1.0 = no correction). Historical testing suggested 1.35, but this is
133
- unvalidated. Use calibrated measurement (--drop-height) for validated results.
119
+ smoothing_window: Smoothing window size
134
120
 
135
121
  Returns:
136
- DropJumpMetrics object with calculated values
122
+ Drop start frame (0 if not detected/provided)
137
123
  """
138
- metrics = DropJumpMetrics()
139
-
140
- # Detect or use manually specified drop jump start frame
141
124
  if drop_start_frame is None:
142
125
  # Auto-detect where drop jump actually starts (skip initial stationary period)
143
- drop_start_frame = detect_drop_start(
126
+ detected_frame = detect_drop_start(
144
127
  foot_y_positions,
145
128
  fps,
146
- min_stationary_duration=0.5, # 0.5s stable period (~30 frames @ 60fps)
147
- position_change_threshold=0.005, # 0.5% of frame height - sensitive to drop start
129
+ min_stationary_duration=0.5,
130
+ position_change_threshold=0.005,
148
131
  smoothing_window=smoothing_window,
149
132
  )
150
- # If manually specified or auto-detected, use it; otherwise start from frame 0
151
- drop_start_frame_value: int
152
- if drop_start_frame is None: # pyright: ignore[reportUnnecessaryComparison]
153
- drop_start_frame_value = 0
154
- else:
155
- drop_start_frame_value = drop_start_frame
133
+ return detected_frame if detected_frame is not None else 0
134
+ return drop_start_frame
156
135
 
157
- phases = find_contact_phases(contact_states)
158
136
 
159
- # Get interpolated phases with curvature-based refinement
160
- # Combines velocity interpolation + acceleration pattern analysis
161
- interpolated_phases = find_interpolated_phase_transitions_with_curvature(
162
- foot_y_positions,
163
- contact_states,
164
- velocity_threshold,
165
- smoothing_window,
166
- polyorder,
167
- use_curvature,
168
- )
169
-
170
- if not phases:
171
- return metrics
137
+ def _filter_phases_after_drop(
138
+ phases: list[tuple[int, int, ContactState]],
139
+ interpolated_phases: list[tuple[float, float, ContactState]],
140
+ drop_start_frame: int,
141
+ ) -> tuple[
142
+ list[tuple[int, int, ContactState]], list[tuple[float, float, ContactState]]
143
+ ]:
144
+ """Filter phases to only include those after drop start.
172
145
 
173
- # Filter phases to only include those after drop start
174
- # This removes the initial stationary period where athlete is standing on box
175
- if drop_start_frame_value > 0:
176
- phases = [
177
- (start, end, state)
178
- for start, end, state in phases
179
- if end >= drop_start_frame_value
180
- ]
181
- interpolated_phases = [
182
- (start, end, state)
183
- for start, end, state in interpolated_phases
184
- if end >= drop_start_frame_value
185
- ]
146
+ Args:
147
+ phases: Integer frame phases
148
+ interpolated_phases: Sub-frame precision phases
149
+ drop_start_frame: Frame where drop starts
186
150
 
187
- if not phases:
188
- return metrics
151
+ Returns:
152
+ Tuple of (filtered_phases, filtered_interpolated_phases)
153
+ """
154
+ if drop_start_frame <= 0:
155
+ return phases, interpolated_phases
189
156
 
190
- # Find the main contact phase
191
- # For drop jumps: find first ON_GROUND after first IN_AIR (the landing after drop)
192
- # For regular jumps: use longest ON_GROUND phase
193
- ground_phases = [
194
- (start, end, i)
195
- for i, (start, end, state) in enumerate(phases)
196
- if state == ContactState.ON_GROUND
157
+ filtered_phases = [
158
+ (start, end, state) for start, end, state in phases if end >= drop_start_frame
197
159
  ]
198
- air_phases_indexed = [
199
- (start, end, i)
200
- for i, (start, end, state) in enumerate(phases)
201
- if state == ContactState.IN_AIR
160
+ filtered_interpolated = [
161
+ (start, end, state)
162
+ for start, end, state in interpolated_phases
163
+ if end >= drop_start_frame
202
164
  ]
165
+ return filtered_phases, filtered_interpolated
203
166
 
204
- if not ground_phases:
205
- return metrics
206
167
 
207
- # Initialize contact variables with first ground phase as fallback
208
- # (will be overridden by drop jump or regular jump detection logic)
168
+ def _identify_main_contact_phase(
169
+ phases: list[tuple[int, int, ContactState]],
170
+ ground_phases: list[tuple[int, int, int]],
171
+ air_phases_indexed: list[tuple[int, int, int]],
172
+ foot_y_positions: np.ndarray,
173
+ ) -> tuple[int, int, bool]:
174
+ """Identify the main contact phase and determine if it's a drop jump.
175
+
176
+ Args:
177
+ phases: All phase tuples
178
+ ground_phases: Ground phases with indices
179
+ air_phases_indexed: Air phases with indices
180
+ foot_y_positions: Vertical position array
181
+
182
+ Returns:
183
+ Tuple of (contact_start, contact_end, is_drop_jump)
184
+ """
185
+ # Initialize with first ground phase as fallback
209
186
  contact_start, contact_end = ground_phases[0][0], ground_phases[0][1]
187
+ is_drop_jump = False
210
188
 
211
189
  # Detect if this is a drop jump or regular jump
212
- # Drop jump: first ground phase is elevated (lower y), followed by drop, then landing (higher y)
213
- is_drop_jump = False
214
190
  if air_phases_indexed and len(ground_phases) >= 2:
215
191
  first_ground_start, first_ground_end, first_ground_idx = ground_phases[0]
216
192
  first_air_idx = air_phases_indexed[0][2]
@@ -243,17 +219,29 @@ def calculate_drop_jump_metrics(
243
219
  [(s, e) for s, e, _ in ground_phases], key=lambda p: p[1] - p[0]
244
220
  )
245
221
 
246
- # Store integer frame indices (for visualization)
247
- metrics.contact_start_frame = contact_start
248
- metrics.contact_end_frame = contact_end
222
+ return contact_start, contact_end, is_drop_jump
223
+
249
224
 
250
- # Find corresponding interpolated phase for precise timing
225
+ def _find_precise_phase_timing(
226
+ contact_start: int,
227
+ contact_end: int,
228
+ interpolated_phases: list[tuple[float, float, ContactState]],
229
+ ) -> tuple[float, float]:
230
+ """Find precise sub-frame timing for contact phase.
231
+
232
+ Args:
233
+ contact_start: Integer contact start frame
234
+ contact_end: Integer contact end frame
235
+ interpolated_phases: Sub-frame precision phases
236
+
237
+ Returns:
238
+ Tuple of (contact_start_frac, contact_end_frac)
239
+ """
251
240
  contact_start_frac = float(contact_start)
252
241
  contact_end_frac = float(contact_end)
253
242
 
254
243
  # Find the matching ground phase in interpolated_phases
255
244
  for start_frac, end_frac, state in interpolated_phases:
256
- # Match by checking if integer frames are within this phase
257
245
  if (
258
246
  state == ContactState.ON_GROUND
259
247
  and int(start_frac) <= contact_start <= int(end_frac) + 1
@@ -263,43 +251,82 @@ def calculate_drop_jump_metrics(
263
251
  contact_end_frac = end_frac
264
252
  break
265
253
 
266
- # Calculate ground contact time using fractional frames
267
- contact_frames_precise = contact_end_frac - contact_start_frac
268
- metrics.ground_contact_time = contact_frames_precise / fps
269
- metrics.contact_start_frame_precise = contact_start_frac
270
- metrics.contact_end_frame_precise = contact_end_frac
254
+ return contact_start_frac, contact_end_frac
255
+
271
256
 
272
- # Calculate calibration scale factor from drop height if provided
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
+ """
273
274
  scale_factor = 1.0
274
- if drop_height_m is not None and len(phases) >= 2:
275
- # Find the initial drop by looking for first IN_AIR phase
276
- # This represents the drop from the box
277
-
278
- if air_phases_indexed and ground_phases:
279
- # Get first air phase (the drop)
280
- first_air_start, first_air_end, _ = air_phases_indexed[0]
281
-
282
- # Initial position: at start of drop (on the box)
283
- # Look back a few frames to get stable position on box
284
- lookback_start = max(0, first_air_start - 5)
285
- if lookback_start < first_air_start:
286
- initial_position = float(
287
- np.mean(foot_y_positions[lookback_start:first_air_start])
288
- )
289
- else:
290
- initial_position = float(foot_y_positions[first_air_start])
291
-
292
- # Landing position: at the ground after drop
293
- # Use position at end of first air phase
294
- landing_position = float(foot_y_positions[first_air_end])
295
-
296
- # Drop distance in normalized coordinates (y increases downward)
297
- drop_normalized = landing_position - initial_position
298
-
299
- if drop_normalized > 0.01: # Sanity check (at least 1% of frame height)
300
- # Calculate scale factor: real_meters / normalized_distance
301
- scale_factor = drop_height_m / drop_normalized
302
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
+ def _analyze_flight_phase(
307
+ metrics: DropJumpMetrics,
308
+ phases: list[tuple[int, int, ContactState]],
309
+ interpolated_phases: list[tuple[float, float, ContactState]],
310
+ contact_end: int,
311
+ foot_y_positions: np.ndarray,
312
+ fps: float,
313
+ drop_height_m: float | None,
314
+ scale_factor: float,
315
+ kinematic_correction_factor: float,
316
+ ) -> None:
317
+ """Analyze flight phase and calculate jump height metrics.
318
+
319
+ Args:
320
+ metrics: DropJumpMetrics object to populate
321
+ phases: All phase tuples
322
+ interpolated_phases: Sub-frame precision phases
323
+ contact_end: End of contact phase
324
+ foot_y_positions: Vertical position array
325
+ 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
329
+ """
303
330
  # Find flight phase after ground contact
304
331
  flight_phases = [
305
332
  (start, end)
@@ -307,94 +334,179 @@ def calculate_drop_jump_metrics(
307
334
  if state == ContactState.IN_AIR and start > contact_end
308
335
  ]
309
336
 
310
- if flight_phases:
311
- flight_start, flight_end = flight_phases[0]
312
-
313
- # Store integer frame indices (for visualization)
314
- metrics.flight_start_frame = flight_start
315
- metrics.flight_end_frame = flight_end
316
-
317
- # Find corresponding interpolated phase for precise timing
318
- flight_start_frac = float(flight_start)
319
- flight_end_frac = float(flight_end)
320
-
321
- # Find the matching air phase in interpolated_phases
322
- for start_frac, end_frac, state in interpolated_phases:
323
- # Match by checking if integer frames are within this phase
324
- if (
325
- state == ContactState.IN_AIR
326
- and int(start_frac) <= flight_start <= int(end_frac) + 1
327
- and int(start_frac) <= flight_end <= int(end_frac) + 1
328
- ):
329
- flight_start_frac = start_frac
330
- flight_end_frac = end_frac
331
- break
332
-
333
- # Calculate flight time using fractional frames
334
- flight_frames_precise = flight_end_frac - flight_start_frac
335
- metrics.flight_time = flight_frames_precise / fps
336
- metrics.flight_start_frame_precise = flight_start_frac
337
- metrics.flight_end_frame_precise = flight_end_frac
338
-
339
- # Calculate jump height using flight time (kinematic method)
340
- # h = (g * t^2) / 8, where t is total flight time
341
- g = 9.81 # m/s^2
342
- jump_height_kinematic = (g * metrics.flight_time**2) / 8
343
-
344
- # Calculate jump height from trajectory (position-based method)
345
- # This measures actual vertical displacement from takeoff to peak
346
- takeoff_position = foot_y_positions[flight_start]
347
- flight_positions = foot_y_positions[flight_start : flight_end + 1]
348
-
349
- if len(flight_positions) > 0:
350
- peak_idx = np.argmin(flight_positions)
351
- metrics.peak_height_frame = int(flight_start + peak_idx)
352
- peak_position = np.min(flight_positions)
353
-
354
- # Height in normalized coordinates (0-1 range)
355
- height_normalized = float(takeoff_position - peak_position)
356
-
357
- # Store trajectory value (in normalized coordinates)
358
- metrics.jump_height_trajectory = height_normalized
359
-
360
- # Choose measurement method based on calibration availability
361
- if drop_height_m is not None and scale_factor > 1.0:
362
- # Use calibrated trajectory measurement (most accurate)
363
- metrics.jump_height = height_normalized * scale_factor
364
- metrics.jump_height_kinematic = jump_height_kinematic
365
- else:
366
- # Apply kinematic correction factor to kinematic method
367
- # ⚠️ WARNING: Kinematic correction factor is EXPERIMENTAL and UNVALIDATED
368
- #
369
- # The kinematic method h = (g × t²) / 8 may underestimate jump height due to:
370
- # 1. Contact detection timing (may detect landing slightly early/late)
371
- # 2. Frame rate limitations (30 fps = 33ms intervals between samples)
372
- # 3. Foot position vs center of mass difference (feet land before CoM peak)
373
- #
374
- # Default correction factor is 1.0 (no correction). Historical testing
375
- # suggested 1.35 could improve accuracy, but:
376
- # - This value has NOT been validated against gold standards
377
- # (force plates, motion capture)
378
- # - The actual correction needed may vary by athlete, jump type, and video quality
379
- # - Using a correction factor without validation is experimental
380
- #
381
- # For validated measurements, use:
382
- # - Calibrated measurement with --drop-height parameter
383
- # - Or compare against validated measurement systems
384
- metrics.jump_height = (
385
- jump_height_kinematic * kinematic_correction_factor
386
- )
387
- metrics.jump_height_kinematic = jump_height_kinematic
337
+ if not flight_phases:
338
+ return
339
+
340
+ flight_start, flight_end = flight_phases[0]
341
+
342
+ # Store integer frame indices
343
+ metrics.flight_start_frame = flight_start
344
+ metrics.flight_end_frame = flight_end
345
+
346
+ # Find precise timing
347
+ flight_start_frac = float(flight_start)
348
+ flight_end_frac = float(flight_end)
349
+
350
+ for start_frac, end_frac, state in interpolated_phases:
351
+ if (
352
+ state == ContactState.IN_AIR
353
+ and int(start_frac) <= flight_start <= int(end_frac) + 1
354
+ and int(start_frac) <= flight_end <= int(end_frac) + 1
355
+ ):
356
+ flight_start_frac = start_frac
357
+ flight_end_frac = end_frac
358
+ break
359
+
360
+ # Calculate flight time
361
+ flight_frames_precise = flight_end_frac - flight_start_frac
362
+ metrics.flight_time = flight_frames_precise / fps
363
+ metrics.flight_start_frame_precise = flight_start_frac
364
+ metrics.flight_end_frame_precise = flight_end_frac
365
+
366
+ # Calculate jump height using kinematic method
367
+ g = 9.81 # m/s^2
368
+ jump_height_kinematic = (g * metrics.flight_time**2) / 8
369
+
370
+ # Calculate jump height from trajectory
371
+ takeoff_position = foot_y_positions[flight_start]
372
+ flight_positions = foot_y_positions[flight_start : flight_end + 1]
373
+
374
+ if len(flight_positions) > 0:
375
+ peak_idx = np.argmin(flight_positions)
376
+ metrics.peak_height_frame = int(flight_start + peak_idx)
377
+ peak_position = np.min(flight_positions)
378
+
379
+ height_normalized = float(takeoff_position - peak_position)
380
+ metrics.jump_height_trajectory = height_normalized
381
+
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
388
386
  else:
389
- # Fallback to kinematic if no position data
390
- if drop_height_m is None:
391
- # Apply kinematic correction factor (see detailed comment above)
392
- metrics.jump_height = (
393
- jump_height_kinematic * kinematic_correction_factor
394
- )
395
- else:
396
- metrics.jump_height = jump_height_kinematic
387
+ metrics.jump_height = jump_height_kinematic * kinematic_correction_factor
397
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
396
+
397
+
398
+ def calculate_drop_jump_metrics(
399
+ contact_states: list[ContactState],
400
+ foot_y_positions: np.ndarray,
401
+ fps: float,
402
+ drop_height_m: float | None = None,
403
+ drop_start_frame: int | None = None,
404
+ velocity_threshold: float = 0.02,
405
+ smoothing_window: int = 5,
406
+ polyorder: int = 2,
407
+ use_curvature: bool = True,
408
+ kinematic_correction_factor: float = 1.0,
409
+ ) -> DropJumpMetrics:
410
+ """
411
+ Calculate drop-jump metrics from contact states and positions.
412
+
413
+ Args:
414
+ contact_states: Contact state for each frame
415
+ foot_y_positions: Vertical positions of feet (normalized 0-1)
416
+ 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)
420
+ polyorder: Polynomial order for Savitzky-Golay filter (default: 2)
421
+ 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.
425
+
426
+ Returns:
427
+ DropJumpMetrics object with calculated values
428
+ """
429
+ metrics = DropJumpMetrics()
430
+
431
+ # Determine drop start frame
432
+ drop_start_frame_value = _determine_drop_start_frame(
433
+ drop_start_frame, foot_y_positions, fps, smoothing_window
434
+ )
435
+
436
+ # 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
+ )
446
+
447
+ if not phases:
448
+ return metrics
449
+
450
+ # Filter phases to only include those after drop start
451
+ phases, interpolated_phases = _filter_phases_after_drop(
452
+ phases, interpolated_phases, drop_start_frame_value
453
+ )
454
+
455
+ if not phases:
456
+ return metrics
457
+
458
+ # Separate ground and air phases
459
+ ground_phases = [
460
+ (start, end, i)
461
+ for i, (start, end, state) in enumerate(phases)
462
+ if state == ContactState.ON_GROUND
463
+ ]
464
+ air_phases_indexed = [
465
+ (start, end, i)
466
+ for i, (start, end, state) in enumerate(phases)
467
+ if state == ContactState.IN_AIR
468
+ ]
469
+
470
+ if not ground_phases:
471
+ return metrics
472
+
473
+ # Identify main contact phase
474
+ contact_start, contact_end, _ = _identify_main_contact_phase(
475
+ phases, ground_phases, air_phases_indexed, foot_y_positions
476
+ )
477
+
478
+ # Store integer frame indices
479
+ metrics.contact_start_frame = contact_start
480
+ metrics.contact_end_frame = contact_end
481
+
482
+ # Find precise timing for contact phase
483
+ contact_start_frac, contact_end_frac = _find_precise_phase_timing(
484
+ contact_start, contact_end, interpolated_phases
485
+ )
486
+
487
+ # Calculate ground contact time
488
+ contact_frames_precise = contact_end_frac - contact_start_frac
489
+ metrics.ground_contact_time = contact_frames_precise / fps
490
+ metrics.contact_start_frame_precise = contact_start_frac
491
+ metrics.contact_end_frame_precise = contact_end_frac
492
+
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
+ # 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
+ )
398
510
 
399
511
  return metrics
400
512
 
@@ -603,7 +603,7 @@ wheels = [
603
603
 
604
604
  [[package]]
605
605
  name = "kinemotion"
606
- version = "0.10.3"
606
+ version = "0.10.4"
607
607
  source = { editable = "." }
608
608
  dependencies = [
609
609
  { name = "click" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes