kinemotion 0.71.0__py3-none-any.whl → 0.71.1__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.
@@ -181,6 +181,18 @@ def _smooth_landmarks_core( # NOSONAR(S1172) - polyorder used via closure
181
181
  return smoothed_sequence
182
182
 
183
183
 
184
+ def _ensure_odd_window_length(window_length: int) -> int:
185
+ """Ensure window_length is odd (required for Savitzky-Golay filter).
186
+
187
+ Args:
188
+ window_length: Desired window length
189
+
190
+ Returns:
191
+ Odd window length (increments by 1 if even)
192
+ """
193
+ return window_length + 1 if window_length % 2 == 0 else window_length
194
+
195
+
184
196
  def smooth_landmarks(
185
197
  landmark_sequence: LandmarkSequence,
186
198
  window_length: int = 5,
@@ -200,9 +212,7 @@ def smooth_landmarks(
200
212
  if len(landmark_sequence) < window_length:
201
213
  return landmark_sequence
202
214
 
203
- # Ensure window_length is odd
204
- if window_length % 2 == 0:
205
- window_length += 1
215
+ window_length = _ensure_odd_window_length(window_length)
206
216
 
207
217
  def savgol_smoother(
208
218
  x_coords: list[float], y_coords: list[float], _valid_frames: list[int]
@@ -231,61 +241,87 @@ def compute_velocity(positions: np.ndarray, fps: float, smooth_window: int = 3)
231
241
 
232
242
  # Smooth velocity if we have enough data
233
243
  if len(velocity) >= smooth_window and smooth_window > 1:
234
- if smooth_window % 2 == 0:
235
- smooth_window += 1
244
+ smooth_window = _ensure_odd_window_length(smooth_window)
236
245
  for dim in range(velocity.shape[1]):
237
246
  velocity[:, dim] = savgol_filter(velocity[:, dim], smooth_window, 1)
238
247
 
239
248
  return velocity
240
249
 
241
250
 
242
- def compute_velocity_from_derivative(
251
+ def _compute_derivative(
243
252
  positions: np.ndarray,
253
+ deriv_order: int,
244
254
  window_length: int = 5,
245
255
  polyorder: int = 2,
246
256
  ) -> np.ndarray:
247
257
  """
248
- Compute velocity as derivative of smoothed position trajectory.
249
-
250
- Uses Savitzky-Golay filter to compute the derivative directly, which provides
251
- a much smoother and more accurate velocity estimate than frame-to-frame differences.
258
+ Compute nth derivative using Savitzky-Golay filter.
252
259
 
253
- This method:
254
- 1. Fits a polynomial to the position data in a sliding window
255
- 2. Analytically computes the derivative of that polynomial
256
- 3. Returns smooth velocity values
260
+ This unified function handles both velocity (first derivative) and
261
+ acceleration (second derivative) computation with a single implementation.
257
262
 
258
263
  Args:
259
264
  positions: 1D array of position values (e.g., foot y-positions)
265
+ deriv_order: Order of derivative (1 for velocity, 2 for acceleration)
260
266
  window_length: Window size for smoothing (must be odd, >= polyorder + 2)
261
267
  polyorder: Polynomial order for Savitzky-Golay filter (typically 2 or 3)
262
268
 
263
269
  Returns:
264
- Array of absolute velocity values (magnitude of derivative)
270
+ Array of derivative values
265
271
  """
266
272
  if len(positions) < window_length:
267
273
  # Fallback to simple differences for short sequences
268
- return np.abs(np.diff(positions, prepend=positions[0]))
274
+ if deriv_order == 1:
275
+ return np.abs(np.diff(positions, prepend=positions[0]))
276
+ # Second derivative fallback
277
+ velocity = np.diff(positions, prepend=positions[0])
278
+ return np.diff(velocity, prepend=velocity[0])
269
279
 
270
- # Ensure window_length is odd
271
- if window_length % 2 == 0:
272
- window_length += 1
280
+ window_length = _ensure_odd_window_length(window_length)
273
281
 
274
282
  # Compute derivative using Savitzky-Golay filter
275
- # deriv=1: compute first derivative
276
- # delta=1.0: frame spacing (velocity per frame)
283
+ # delta=1.0: frame spacing
277
284
  # mode='interp': interpolate at boundaries
278
- velocity = savgol_filter(
285
+ result = savgol_filter(
279
286
  positions,
280
287
  window_length,
281
288
  polyorder,
282
- deriv=1, # First derivative
283
- delta=1.0, # Frame spacing
289
+ deriv=deriv_order,
290
+ delta=1.0,
284
291
  mode="interp",
285
292
  )
286
293
 
287
- # Return absolute velocity (magnitude only)
288
- return np.abs(velocity)
294
+ # Return absolute values for velocity (first derivative)
295
+ return np.abs(result) if deriv_order == 1 else result
296
+
297
+
298
+ def compute_velocity_from_derivative(
299
+ positions: np.ndarray,
300
+ window_length: int = 5,
301
+ polyorder: int = 2,
302
+ ) -> np.ndarray:
303
+ """
304
+ Compute velocity as derivative of smoothed position trajectory.
305
+
306
+ Uses Savitzky-Golay filter to compute the derivative directly, which provides
307
+ a much smoother and more accurate velocity estimate than frame-to-frame differences.
308
+
309
+ This method:
310
+ 1. Fits a polynomial to the position data in a sliding window
311
+ 2. Analytically computes the derivative of that polynomial
312
+ 3. Returns smooth velocity values
313
+
314
+ Args:
315
+ positions: 1D array of position values (e.g., foot y-positions)
316
+ window_length: Window size for smoothing (must be odd, >= polyorder + 2)
317
+ polyorder: Polynomial order for Savitzky-Golay filter (typically 2 or 3)
318
+
319
+ Returns:
320
+ Array of absolute velocity values (magnitude of derivative)
321
+ """
322
+ return _compute_derivative(
323
+ positions, deriv_order=1, window_length=window_length, polyorder=polyorder
324
+ )
289
325
 
290
326
 
291
327
  def compute_acceleration_from_derivative(
@@ -314,30 +350,10 @@ def compute_acceleration_from_derivative(
314
350
  Returns:
315
351
  Array of acceleration values (second derivative of position)
316
352
  """
317
- if len(positions) < window_length:
318
- # Fallback to simple second differences for short sequences
319
- velocity = np.diff(positions, prepend=positions[0])
320
- return np.diff(velocity, prepend=velocity[0])
321
-
322
- # Ensure window_length is odd
323
- if window_length % 2 == 0:
324
- window_length += 1
325
-
326
- # Compute second derivative using Savitzky-Golay filter
327
- # deriv=2: compute second derivative (acceleration/curvature)
328
- # delta=1.0: frame spacing
329
- # mode='interp': interpolate at boundaries
330
- acceleration = savgol_filter(
331
- positions,
332
- window_length,
333
- polyorder,
334
- deriv=2, # Second derivative
335
- delta=1.0, # Frame spacing
336
- mode="interp",
353
+ return _compute_derivative(
354
+ positions, deriv_order=2, window_length=window_length, polyorder=polyorder
337
355
  )
338
356
 
339
- return acceleration
340
-
341
357
 
342
358
  def smooth_landmarks_advanced(
343
359
  landmark_sequence: LandmarkSequence,
@@ -376,9 +392,7 @@ def smooth_landmarks_advanced(
376
392
  if len(landmark_sequence) < window_length:
377
393
  return landmark_sequence
378
394
 
379
- # Ensure window_length is odd
380
- if window_length % 2 == 0:
381
- window_length += 1
395
+ window_length = _ensure_odd_window_length(window_length)
382
396
 
383
397
  def advanced_smoother(
384
398
  x_coords: list[float], y_coords: list[float], _valid_frames: list[int]
kinemotion/core/types.py CHANGED
@@ -29,6 +29,19 @@ LandmarkSequence: TypeAlias = list[LandmarkFrame]
29
29
  # - Wrapper structures: e.g. {"data": {...actual metrics...}}
30
30
  MetricsDict: TypeAlias = dict[str, Any]
31
31
 
32
+ # MediaPipe foot landmark names used for position and visibility tracking
33
+ FOOT_KEYS: tuple[str, ...] = (
34
+ "left_ankle",
35
+ "right_ankle",
36
+ "left_heel",
37
+ "right_heel",
38
+ "left_foot_index",
39
+ "right_foot_index",
40
+ )
41
+
42
+ # MediaPipe hip landmark names used for position tracking
43
+ HIP_KEYS: tuple[str, ...] = ("left_hip", "right_hip")
44
+
32
45
  __all__ = [
33
46
  "FloatArray",
34
47
  "Float64Array",
@@ -39,4 +52,6 @@ __all__ = [
39
52
  "LandmarkFrame",
40
53
  "LandmarkSequence",
41
54
  "MetricsDict",
55
+ "FOOT_KEYS",
56
+ "HIP_KEYS",
42
57
  ]
@@ -78,19 +78,18 @@ class MetricBounds:
78
78
 
79
79
  def contains(self, value: float, profile: AthleteProfile) -> bool:
80
80
  """Check if value is within bounds for athlete profile."""
81
- if profile == AthleteProfile.ELDERLY:
81
+ # ELDERLY and UNTRAINED use same bounds (practical to recreational)
82
+ if profile in (AthleteProfile.ELDERLY, AthleteProfile.UNTRAINED):
82
83
  return self.practical_min <= value <= self.recreational_max
83
- elif profile == AthleteProfile.UNTRAINED:
84
- return self.practical_min <= value <= self.recreational_max
85
- elif profile == AthleteProfile.RECREATIONAL:
84
+ if profile == AthleteProfile.RECREATIONAL:
86
85
  return self.recreational_min <= value <= self.recreational_max
87
- elif profile == AthleteProfile.TRAINED:
86
+ if profile == AthleteProfile.ELITE:
87
+ return self.elite_min <= value <= self.elite_max
88
+ if profile == AthleteProfile.TRAINED:
88
89
  # Trained athletes: midpoint between recreational and elite
89
90
  trained_min = (self.recreational_min + self.elite_min) / 2
90
91
  trained_max = (self.recreational_max + self.elite_max) / 2
91
92
  return trained_min <= value <= trained_max
92
- elif profile == AthleteProfile.ELITE:
93
- return self.elite_min <= value <= self.elite_max
94
93
  return False
95
94
 
96
95
  def is_physically_possible(self, value: float) -> bool:
@@ -18,6 +18,17 @@ class VideoProcessor:
18
18
  No dimensions are hardcoded - all dimensions are extracted from actual frame data.
19
19
  """
20
20
 
21
+ # Mapping of rotation angles to OpenCV rotation operations
22
+ # Keys are normalized angles (equivalent angles grouped)
23
+ _ROTATION_OPS: dict[int, int] = {
24
+ -90: cv2.ROTATE_90_CLOCKWISE,
25
+ 270: cv2.ROTATE_90_CLOCKWISE,
26
+ 90: cv2.ROTATE_90_COUNTERCLOCKWISE,
27
+ -270: cv2.ROTATE_90_COUNTERCLOCKWISE,
28
+ 180: cv2.ROTATE_180,
29
+ -180: cv2.ROTATE_180,
30
+ }
31
+
21
32
  def __init__(self, video_path: str, timer: Timer | None = None) -> None:
22
33
  """
23
34
  Initialize video processor.
@@ -216,15 +227,9 @@ class VideoProcessor:
216
227
 
217
228
  # Apply rotation if video has rotation metadata
218
229
  with self.timer.measure("frame_rotation"):
219
- if self.rotation == -90 or self.rotation == 270:
220
- # -90 degrees = rotate 90 degrees clockwise
221
- frame = cv2.rotate(frame, cv2.ROTATE_90_CLOCKWISE)
222
- elif self.rotation == 90 or self.rotation == -270:
223
- # 90 degrees = rotate 90 degrees counter-clockwise
224
- frame = cv2.rotate(frame, cv2.ROTATE_90_COUNTERCLOCKWISE)
225
- elif self.rotation == 180 or self.rotation == -180:
226
- # 180 degrees rotation
227
- frame = cv2.rotate(frame, cv2.ROTATE_180)
230
+ rotation_op = self._ROTATION_OPS.get(self.rotation)
231
+ if rotation_op is not None:
232
+ frame = cv2.rotate(frame, rotation_op)
228
233
 
229
234
  self._frame_index += 1
230
235
  return frame
@@ -9,7 +9,7 @@ from .analysis import (
9
9
  find_interpolated_phase_transitions_with_curvature,
10
10
  refine_transition_with_curvature,
11
11
  )
12
- from .debug_overlay import DebugOverlayRenderer
12
+ from .debug_overlay import DropJumpDebugOverlayRenderer
13
13
  from .kinematics import DropJumpMetrics, calculate_drop_jump_metrics
14
14
 
15
15
  __all__ = [
@@ -25,5 +25,5 @@ __all__ = [
25
25
  "DropJumpMetrics",
26
26
  "calculate_drop_jump_metrics",
27
27
  # Debug overlay
28
- "DebugOverlayRenderer",
28
+ "DropJumpDebugOverlayRenderer",
29
29
  ]
@@ -11,7 +11,7 @@ from ..core.smoothing import (
11
11
  interpolate_threshold_crossing,
12
12
  )
13
13
  from ..core.timing import NULL_TIMER, Timer
14
- from ..core.types import BoolArray, FloatArray
14
+ from ..core.types import FOOT_KEYS, BoolArray, FloatArray
15
15
 
16
16
 
17
17
  class ContactState(Enum):
@@ -424,6 +424,57 @@ def find_contact_phases(
424
424
  return phases
425
425
 
426
426
 
427
+ def _interpolate_phase_boundary(
428
+ boundary_idx: int,
429
+ state: ContactState,
430
+ velocities: FloatArray,
431
+ velocity_threshold: float,
432
+ is_start: bool,
433
+ ) -> float:
434
+ """Interpolate phase boundary with sub-frame precision.
435
+
436
+ Args:
437
+ boundary_idx: Index of the boundary frame
438
+ state: Contact state of the phase
439
+ velocities: Velocity array
440
+ velocity_threshold: Threshold value for crossing detection
441
+ is_start: True for phase start, False for phase end
442
+
443
+ Returns:
444
+ Fractional frame index, or float(boundary_idx) if no interpolation.
445
+ """
446
+ n_velocities = len(velocities)
447
+
448
+ if is_start:
449
+ # For start boundary, look at velocity before and at the boundary
450
+ if boundary_idx <= 0 or boundary_idx >= n_velocities:
451
+ return float(boundary_idx)
452
+ vel_before = velocities[boundary_idx - 1]
453
+ vel_at = velocities[boundary_idx]
454
+ # Check threshold crossing based on state
455
+ is_crossing = (
456
+ state == ContactState.ON_GROUND and vel_before > velocity_threshold > vel_at
457
+ ) or (state == ContactState.IN_AIR and vel_before < velocity_threshold < vel_at)
458
+ if is_crossing:
459
+ offset = interpolate_threshold_crossing(vel_before, vel_at, velocity_threshold)
460
+ return (boundary_idx - 1) + offset
461
+ return float(boundary_idx)
462
+
463
+ # For end boundary, look at velocity at and after the boundary
464
+ if boundary_idx + 1 >= n_velocities:
465
+ return float(boundary_idx)
466
+ vel_at = velocities[boundary_idx]
467
+ vel_after = velocities[boundary_idx + 1]
468
+ # Check threshold crossing based on state
469
+ is_crossing = (
470
+ state == ContactState.ON_GROUND and vel_at < velocity_threshold < vel_after
471
+ ) or (state == ContactState.IN_AIR and vel_at > velocity_threshold > vel_after)
472
+ if is_crossing:
473
+ offset = interpolate_threshold_crossing(vel_at, vel_after, velocity_threshold)
474
+ return boundary_idx + offset
475
+ return float(boundary_idx)
476
+
477
+
427
478
  def _interpolate_phase_start(
428
479
  start_idx: int,
429
480
  state: ContactState,
@@ -435,21 +486,9 @@ def _interpolate_phase_start(
435
486
  Returns:
436
487
  Fractional start frame, or float(start_idx) if no interpolation.
437
488
  """
438
- if start_idx <= 0 or start_idx >= len(velocities):
439
- return float(start_idx)
440
-
441
- vel_before = velocities[start_idx - 1]
442
- vel_at = velocities[start_idx]
443
-
444
- # Check threshold crossing based on state
445
- is_landing = state == ContactState.ON_GROUND and vel_before > velocity_threshold > vel_at
446
- is_takeoff = state == ContactState.IN_AIR and vel_before < velocity_threshold < vel_at
447
-
448
- if is_landing or is_takeoff:
449
- offset = interpolate_threshold_crossing(vel_before, vel_at, velocity_threshold)
450
- return (start_idx - 1) + offset
451
-
452
- return float(start_idx)
489
+ return _interpolate_phase_boundary(
490
+ start_idx, state, velocities, velocity_threshold, is_start=True
491
+ )
453
492
 
454
493
 
455
494
  def _interpolate_phase_end(
@@ -457,28 +496,16 @@ def _interpolate_phase_end(
457
496
  state: ContactState,
458
497
  velocities: FloatArray,
459
498
  velocity_threshold: float,
460
- max_idx: int,
499
+ _max_idx: int,
461
500
  ) -> float:
462
501
  """Interpolate end boundary of a phase with sub-frame precision.
463
502
 
464
503
  Returns:
465
504
  Fractional end frame, or float(end_idx) if no interpolation.
466
505
  """
467
- if end_idx >= max_idx - 1 or end_idx + 1 >= len(velocities):
468
- return float(end_idx)
469
-
470
- vel_at = velocities[end_idx]
471
- vel_after = velocities[end_idx + 1]
472
-
473
- # Check threshold crossing based on state
474
- is_takeoff = state == ContactState.ON_GROUND and vel_at < velocity_threshold < vel_after
475
- is_landing = state == ContactState.IN_AIR and vel_at > velocity_threshold > vel_after
476
-
477
- if is_takeoff or is_landing:
478
- offset = interpolate_threshold_crossing(vel_at, vel_after, velocity_threshold)
479
- return end_idx + offset
480
-
481
- return float(end_idx)
506
+ return _interpolate_phase_boundary(
507
+ end_idx, state, velocities, velocity_threshold, is_start=False
508
+ )
482
509
 
483
510
 
484
511
  def find_interpolated_phase_transitions(
@@ -747,19 +774,10 @@ def compute_average_foot_position(
747
774
  Returns:
748
775
  (x, y) average foot position in normalized coordinates
749
776
  """
750
- foot_keys = [
751
- "left_ankle",
752
- "right_ankle",
753
- "left_heel",
754
- "right_heel",
755
- "left_foot_index",
756
- "right_foot_index",
757
- ]
758
-
759
777
  x_positions = []
760
778
  y_positions = []
761
779
 
762
- for key in foot_keys:
780
+ for key in FOOT_KEYS:
763
781
  if key in landmarks:
764
782
  x, y, visibility = landmarks[key]
765
783
  if visibility > 0.5: # Only use visible landmarks
@@ -783,8 +801,13 @@ def _calculate_average_visibility(
783
801
  Returns:
784
802
  Average visibility of foot landmarks (0.0 if none visible)
785
803
  """
786
- foot_keys = ["left_ankle", "right_ankle", "left_heel", "right_heel"]
787
- foot_vis = [frame_landmarks[key][2] for key in foot_keys if key in frame_landmarks]
804
+ # Only use ankles and heels for visibility (foot_index can be noisy)
805
+ visibility_keys = ("left_ankle", "right_ankle", "left_heel", "right_heel")
806
+ foot_vis = [
807
+ frame_landmarks[key][2]
808
+ for key in FOOT_KEYS
809
+ if key in frame_landmarks and key in visibility_keys
810
+ ]
788
811
  return float(np.mean(foot_vis)) if foot_vis else 0.0
789
812
 
790
813
 
@@ -48,7 +48,7 @@ from .analysis import (
48
48
  detect_ground_contact,
49
49
  find_contact_phases,
50
50
  )
51
- from .debug_overlay import DebugOverlayRenderer
51
+ from .debug_overlay import DropJumpDebugOverlayRenderer
52
52
  from .kinematics import DropJumpMetrics, calculate_drop_jump_metrics
53
53
  from .metrics_validator import DropJumpMetricsValidator
54
54
 
@@ -305,7 +305,6 @@ def _tune_and_smooth(
305
305
  characteristics = analyze_video_sample(landmarks_sequence, video_fps, frame_count)
306
306
  params = auto_tune_parameters(characteristics, quality_preset)
307
307
 
308
- # Apply overrides if provided
309
308
  if overrides:
310
309
  params = apply_expert_overrides(
311
310
  params,
@@ -314,14 +313,6 @@ def _tune_and_smooth(
314
313
  overrides.min_contact_frames,
315
314
  overrides.visibility_threshold,
316
315
  )
317
- else:
318
- params = apply_expert_overrides(
319
- params,
320
- None,
321
- None,
322
- None,
323
- None,
324
- )
325
316
 
326
317
  smoothed_landmarks = apply_smoothing(landmarks_sequence, params, verbose, timer)
327
318
 
@@ -440,16 +431,13 @@ def _generate_debug_video(
440
431
  timer = timer or NULL_TIMER
441
432
  debug_h, debug_w = frames[0].shape[:2]
442
433
 
443
- if video_fps > 30:
444
- debug_fps = video_fps / (video_fps / 30.0)
445
- else:
446
- debug_fps = video_fps
447
-
434
+ # Calculate debug FPS: cap at 30 for high-fps videos, use step if frame-sparse
435
+ debug_fps = min(video_fps, 30.0)
448
436
  if len(frames) < len(smoothed_landmarks):
449
437
  step = max(1, int(video_fps / 30.0))
450
438
  debug_fps = video_fps / step
451
439
 
452
- def _render_frames(renderer: DebugOverlayRenderer) -> None:
440
+ def _render_frames(renderer: DropJumpDebugOverlayRenderer) -> None:
453
441
  for frame, idx in zip(frames, frame_indices, strict=True):
454
442
  annotated = renderer.render_frame(
455
443
  frame,
@@ -461,7 +449,7 @@ def _generate_debug_video(
461
449
  )
462
450
  renderer.write_frame(annotated)
463
451
 
464
- renderer_context = DebugOverlayRenderer(
452
+ renderer_context = DropJumpDebugOverlayRenderer(
465
453
  output_video,
466
454
  debug_w,
467
455
  debug_h,