kinemotion 0.1.0__py3-none-any.whl → 0.4.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.

@@ -4,6 +4,11 @@
4
4
  import numpy as np
5
5
  from scipy.signal import savgol_filter
6
6
 
7
+ from .filtering import (
8
+ bilateral_temporal_filter,
9
+ reject_outliers,
10
+ )
11
+
7
12
 
8
13
  def smooth_landmarks(
9
14
  landmark_sequence: list[dict[str, tuple[float, float, float]] | None],
@@ -221,3 +226,142 @@ def compute_acceleration_from_derivative(
221
226
  )
222
227
 
223
228
  return acceleration # type: ignore[no-any-return]
229
+
230
+
231
+ def smooth_landmarks_advanced(
232
+ landmark_sequence: list[dict[str, tuple[float, float, float]] | None],
233
+ window_length: int = 5,
234
+ polyorder: int = 2,
235
+ use_outlier_rejection: bool = True,
236
+ use_bilateral: bool = False,
237
+ ransac_threshold: float = 0.02,
238
+ bilateral_sigma_spatial: float = 3.0,
239
+ bilateral_sigma_intensity: float = 0.02,
240
+ ) -> list[dict[str, tuple[float, float, float]] | None]:
241
+ """
242
+ Advanced landmark smoothing with outlier rejection and bilateral filtering.
243
+
244
+ Combines multiple techniques for robust smoothing:
245
+ 1. Outlier rejection (RANSAC + median filtering)
246
+ 2. Optional bilateral filtering (edge-preserving)
247
+ 3. Savitzky-Golay smoothing
248
+
249
+ Args:
250
+ landmark_sequence: List of landmark dictionaries from each frame
251
+ window_length: Length of filter window (must be odd, >= polyorder + 2)
252
+ polyorder: Order of polynomial used to fit samples
253
+ use_outlier_rejection: Apply outlier detection and removal
254
+ use_bilateral: Use bilateral filter instead of Savitzky-Golay
255
+ ransac_threshold: Threshold for RANSAC outlier detection
256
+ bilateral_sigma_spatial: Spatial sigma for bilateral filter
257
+ bilateral_sigma_intensity: Intensity sigma for bilateral filter
258
+
259
+ Returns:
260
+ Smoothed landmark sequence with same structure as input
261
+ """
262
+ if len(landmark_sequence) < window_length:
263
+ # Not enough frames to smooth effectively
264
+ return landmark_sequence
265
+
266
+ # Ensure window_length is odd
267
+ if window_length % 2 == 0:
268
+ window_length += 1
269
+
270
+ # Extract landmark names from first valid frame
271
+ landmark_names = None
272
+ for frame_landmarks in landmark_sequence:
273
+ if frame_landmarks is not None:
274
+ landmark_names = list(frame_landmarks.keys())
275
+ break
276
+
277
+ if landmark_names is None:
278
+ return landmark_sequence
279
+
280
+ # Build arrays for each landmark coordinate
281
+ smoothed_sequence: list[dict[str, tuple[float, float, float]] | None] = []
282
+
283
+ for landmark_name in landmark_names:
284
+ # Extract x, y coordinates for this landmark across all frames
285
+ x_coords = []
286
+ y_coords = []
287
+ valid_frames = []
288
+
289
+ for i, frame_landmarks in enumerate(landmark_sequence):
290
+ if frame_landmarks is not None and landmark_name in frame_landmarks:
291
+ x, y, vis = frame_landmarks[landmark_name]
292
+ x_coords.append(x)
293
+ y_coords.append(y)
294
+ valid_frames.append(i)
295
+
296
+ if len(x_coords) < window_length:
297
+ continue
298
+
299
+ x_array = np.array(x_coords)
300
+ y_array = np.array(y_coords)
301
+
302
+ # Step 1: Outlier rejection
303
+ if use_outlier_rejection:
304
+ x_array, _ = reject_outliers(
305
+ x_array,
306
+ use_ransac=True,
307
+ use_median=True,
308
+ ransac_threshold=ransac_threshold,
309
+ )
310
+ y_array, _ = reject_outliers(
311
+ y_array,
312
+ use_ransac=True,
313
+ use_median=True,
314
+ ransac_threshold=ransac_threshold,
315
+ )
316
+
317
+ # Step 2: Smoothing (bilateral or Savitzky-Golay)
318
+ if use_bilateral:
319
+ x_smooth = bilateral_temporal_filter(
320
+ x_array,
321
+ window_size=window_length,
322
+ sigma_spatial=bilateral_sigma_spatial,
323
+ sigma_intensity=bilateral_sigma_intensity,
324
+ )
325
+ y_smooth = bilateral_temporal_filter(
326
+ y_array,
327
+ window_size=window_length,
328
+ sigma_spatial=bilateral_sigma_spatial,
329
+ sigma_intensity=bilateral_sigma_intensity,
330
+ )
331
+ else:
332
+ # Standard Savitzky-Golay
333
+ x_smooth = savgol_filter(x_array, window_length, polyorder)
334
+ y_smooth = savgol_filter(y_array, window_length, polyorder)
335
+
336
+ # Store smoothed values back
337
+ for idx, frame_idx in enumerate(valid_frames):
338
+ if frame_idx >= len(smoothed_sequence):
339
+ smoothed_sequence.extend(
340
+ [{}] * (frame_idx - len(smoothed_sequence) + 1)
341
+ )
342
+
343
+ # Ensure smoothed_sequence[frame_idx] is a dict, not None
344
+ if smoothed_sequence[frame_idx] is None:
345
+ smoothed_sequence[frame_idx] = {}
346
+
347
+ if (
348
+ landmark_name not in smoothed_sequence[frame_idx] # type: ignore[operator]
349
+ and landmark_sequence[frame_idx] is not None
350
+ ):
351
+ # Keep original visibility
352
+ orig_vis = landmark_sequence[frame_idx][landmark_name][2] # type: ignore[index]
353
+ smoothed_sequence[frame_idx][landmark_name] = ( # type: ignore[index]
354
+ float(x_smooth[idx]),
355
+ float(y_smooth[idx]),
356
+ orig_vis,
357
+ )
358
+
359
+ # Fill in any missing frames with original data
360
+ for i in range(len(landmark_sequence)):
361
+ if i >= len(smoothed_sequence) or not smoothed_sequence[i]:
362
+ if i < len(smoothed_sequence):
363
+ smoothed_sequence[i] = landmark_sequence[i]
364
+ else:
365
+ smoothed_sequence.append(landmark_sequence[i])
366
+
367
+ return smoothed_sequence
@@ -0,0 +1,122 @@
1
+ """Generic video I/O functionality for all jump analysis types."""
2
+
3
+ import json
4
+ import subprocess
5
+
6
+ import cv2
7
+ import numpy as np
8
+
9
+
10
+ class VideoProcessor:
11
+ """
12
+ Handles video reading and processing.
13
+
14
+ IMPORTANT: This class preserves the exact aspect ratio of the source video.
15
+ No dimensions are hardcoded - all dimensions are extracted from actual frame data.
16
+ """
17
+
18
+ def __init__(self, video_path: str):
19
+ """
20
+ Initialize video processor.
21
+
22
+ Args:
23
+ video_path: Path to input video file
24
+ """
25
+ self.video_path = video_path
26
+ self.cap = cv2.VideoCapture(video_path)
27
+
28
+ if not self.cap.isOpened():
29
+ raise ValueError(f"Could not open video: {video_path}")
30
+
31
+ self.fps = self.cap.get(cv2.CAP_PROP_FPS)
32
+ self.frame_count = int(self.cap.get(cv2.CAP_PROP_FRAME_COUNT))
33
+
34
+ # Read first frame to get actual dimensions
35
+ # This is critical for preserving aspect ratio, especially with mobile videos
36
+ # that have rotation metadata. OpenCV properties (CAP_PROP_FRAME_WIDTH/HEIGHT)
37
+ # may return incorrect dimensions, so we read the actual frame data.
38
+ ret, first_frame = self.cap.read()
39
+ if ret:
40
+ # frame.shape is (height, width, channels) - extract actual dimensions
41
+ self.height, self.width = first_frame.shape[:2]
42
+ self.cap.set(cv2.CAP_PROP_POS_FRAMES, 0) # Reset to beginning
43
+ else:
44
+ # Fallback to video properties if can't read frame
45
+ self.width = int(self.cap.get(cv2.CAP_PROP_FRAME_WIDTH))
46
+ self.height = int(self.cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
47
+
48
+ # Calculate display dimensions considering SAR (Sample Aspect Ratio)
49
+ # Mobile videos often have non-square pixels encoded in SAR metadata
50
+ # OpenCV doesn't directly expose SAR, but we need to handle display correctly
51
+ self.display_width = self.width
52
+ self.display_height = self.height
53
+ self._calculate_display_dimensions()
54
+
55
+ def _calculate_display_dimensions(self) -> None:
56
+ """
57
+ Calculate display dimensions by reading SAR metadata from video file.
58
+
59
+ Many mobile videos use non-square pixels (SAR != 1:1), which means
60
+ the encoded dimensions differ from how the video should be displayed.
61
+ We use ffprobe to extract this metadata.
62
+ """
63
+ try:
64
+ # Use ffprobe to get SAR metadata
65
+ result = subprocess.run(
66
+ [
67
+ "ffprobe",
68
+ "-v",
69
+ "quiet",
70
+ "-print_format",
71
+ "json",
72
+ "-show_streams",
73
+ "-select_streams",
74
+ "v:0",
75
+ self.video_path,
76
+ ],
77
+ capture_output=True,
78
+ text=True,
79
+ timeout=5,
80
+ )
81
+
82
+ if result.returncode == 0:
83
+ data = json.loads(result.stdout)
84
+ if "streams" in data and len(data["streams"]) > 0:
85
+ stream = data["streams"][0]
86
+ sar_str = stream.get("sample_aspect_ratio", "1:1")
87
+
88
+ # Parse SAR (e.g., "270:473")
89
+ if sar_str and ":" in sar_str:
90
+ sar_parts = sar_str.split(":")
91
+ sar_width = int(sar_parts[0])
92
+ sar_height = int(sar_parts[1])
93
+
94
+ # Calculate display dimensions
95
+ # DAR = (width * SAR_width) / (height * SAR_height)
96
+ if sar_width != sar_height:
97
+ self.display_width = int(
98
+ self.width * sar_width / sar_height
99
+ )
100
+ self.display_height = self.height
101
+ except (subprocess.TimeoutExpired, FileNotFoundError, json.JSONDecodeError):
102
+ # If ffprobe fails, keep original dimensions (square pixels)
103
+ pass
104
+
105
+ def read_frame(self) -> np.ndarray | None:
106
+ """Read next frame from video."""
107
+ ret, frame = self.cap.read()
108
+ return frame if ret else None
109
+
110
+ def reset(self) -> None:
111
+ """Reset video to beginning."""
112
+ self.cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
113
+
114
+ def close(self) -> None:
115
+ """Release video capture."""
116
+ self.cap.release()
117
+
118
+ def __enter__(self) -> "VideoProcessor":
119
+ return self
120
+
121
+ def __exit__(self, exc_type, exc_val, exc_tb) -> None: # type: ignore[no-untyped-def]
122
+ self.close()
@@ -0,0 +1,29 @@
1
+ """Drop jump analysis module."""
2
+
3
+ from .analysis import (
4
+ ContactState,
5
+ calculate_adaptive_threshold,
6
+ compute_average_foot_position,
7
+ detect_ground_contact,
8
+ find_interpolated_phase_transitions_with_curvature,
9
+ interpolate_threshold_crossing,
10
+ refine_transition_with_curvature,
11
+ )
12
+ from .debug_overlay import DebugOverlayRenderer
13
+ from .kinematics import DropJumpMetrics, calculate_drop_jump_metrics
14
+
15
+ __all__ = [
16
+ # Contact detection
17
+ "ContactState",
18
+ "detect_ground_contact",
19
+ "compute_average_foot_position",
20
+ "calculate_adaptive_threshold",
21
+ "interpolate_threshold_crossing",
22
+ "refine_transition_with_curvature",
23
+ "find_interpolated_phase_transitions_with_curvature",
24
+ # Metrics
25
+ "DropJumpMetrics",
26
+ "calculate_drop_jump_metrics",
27
+ # Debug overlay
28
+ "DebugOverlayRenderer",
29
+ ]
@@ -4,7 +4,7 @@ from enum import Enum
4
4
 
5
5
  import numpy as np
6
6
 
7
- from .smoothing import (
7
+ from ..core.smoothing import (
8
8
  compute_acceleration_from_derivative,
9
9
  compute_velocity_from_derivative,
10
10
  )
@@ -18,22 +18,101 @@ class ContactState(Enum):
18
18
  UNKNOWN = "unknown"
19
19
 
20
20
 
21
+ def calculate_adaptive_threshold(
22
+ positions: np.ndarray,
23
+ fps: float,
24
+ baseline_duration: float = 3.0,
25
+ multiplier: float = 1.5,
26
+ smoothing_window: int = 5,
27
+ polyorder: int = 2,
28
+ ) -> float:
29
+ """
30
+ Calculate adaptive velocity threshold based on baseline motion characteristics.
31
+
32
+ Analyzes the first few seconds of video (assumed to be relatively stationary,
33
+ e.g., athlete standing on box) to determine the noise floor, then sets threshold
34
+ as a multiple of this baseline noise.
35
+
36
+ This adapts to:
37
+ - Different camera distances (closer = more pixel movement)
38
+ - Different lighting conditions (affects tracking quality)
39
+ - Different frame rates (higher fps = smoother motion)
40
+ - Video compression artifacts
41
+
42
+ Args:
43
+ positions: Array of vertical positions (0-1 normalized)
44
+ fps: Video frame rate
45
+ baseline_duration: Duration in seconds to analyze for baseline (default: 3.0s)
46
+ multiplier: Factor above baseline noise to set threshold (default: 1.5x)
47
+ smoothing_window: Window size for velocity computation
48
+ polyorder: Polynomial order for Savitzky-Golay filter (default: 2)
49
+
50
+ Returns:
51
+ Adaptive velocity threshold value
52
+
53
+ Example:
54
+ At 30fps with 3s baseline:
55
+ - Analyzes first 90 frames
56
+ - Computes velocity for this "stationary" period
57
+ - 95th percentile velocity = 0.012 (noise level)
58
+ - Threshold = 0.012 × 1.5 = 0.018
59
+ """
60
+ if len(positions) < 2:
61
+ return 0.02 # Fallback to default
62
+
63
+ # Calculate number of frames for baseline analysis
64
+ baseline_frames = int(fps * baseline_duration)
65
+ baseline_frames = min(baseline_frames, len(positions))
66
+
67
+ if baseline_frames < smoothing_window:
68
+ return 0.02 # Not enough data, use default
69
+
70
+ # Extract baseline period (assumed relatively stationary)
71
+ baseline_positions = positions[:baseline_frames]
72
+
73
+ # Compute velocity for baseline period using derivative
74
+ baseline_velocities = compute_velocity_from_derivative(
75
+ baseline_positions, window_length=smoothing_window, polyorder=polyorder
76
+ )
77
+
78
+ # Calculate noise floor as 95th percentile of baseline velocities
79
+ # Using 95th percentile instead of max to be robust against outliers
80
+ noise_floor = float(np.percentile(np.abs(baseline_velocities), 95))
81
+
82
+ # Set threshold as multiplier of noise floor
83
+ # Minimum threshold to avoid being too sensitive
84
+ adaptive_threshold = max(noise_floor * multiplier, 0.005)
85
+
86
+ # Maximum threshold to ensure we still detect contact
87
+ adaptive_threshold = min(adaptive_threshold, 0.05)
88
+
89
+ return adaptive_threshold
90
+
91
+
21
92
  def detect_ground_contact(
22
93
  foot_positions: np.ndarray,
23
94
  velocity_threshold: float = 0.02,
24
95
  min_contact_frames: int = 3,
25
96
  visibility_threshold: float = 0.5,
26
97
  visibilities: np.ndarray | None = None,
98
+ window_length: int = 5,
99
+ polyorder: int = 2,
27
100
  ) -> list[ContactState]:
28
101
  """
29
102
  Detect when feet are in contact with ground based on vertical motion.
30
103
 
104
+ Uses derivative-based velocity calculation via Savitzky-Golay filter for smooth,
105
+ accurate velocity estimates. This is consistent with the velocity calculation used
106
+ throughout the pipeline for sub-frame interpolation and curvature analysis.
107
+
31
108
  Args:
32
109
  foot_positions: Array of foot y-positions (normalized, 0-1, where 1 is bottom)
33
110
  velocity_threshold: Threshold for vertical velocity to consider stationary
34
111
  min_contact_frames: Minimum consecutive frames to confirm contact
35
112
  visibility_threshold: Minimum visibility score to trust landmark
36
113
  visibilities: Array of visibility scores for each frame
114
+ window_length: Window size for velocity derivative calculation (must be odd)
115
+ polyorder: Polynomial order for Savitzky-Golay filter (default: 2)
37
116
 
38
117
  Returns:
39
118
  List of ContactState for each frame
@@ -44,8 +123,12 @@ def detect_ground_contact(
44
123
  if n_frames < 2:
45
124
  return states
46
125
 
47
- # Compute vertical velocity (positive = moving down in image coordinates)
48
- velocities = np.diff(foot_positions, prepend=foot_positions[0])
126
+ # Compute vertical velocity using derivative-based method
127
+ # This provides smoother, more accurate velocity estimates than frame-to-frame differences
128
+ # and is consistent with the velocity calculation used for sub-frame interpolation
129
+ velocities = compute_velocity_from_derivative(
130
+ foot_positions, window_length=window_length, polyorder=polyorder
131
+ )
49
132
 
50
133
  # Detect potential contact frames based on low velocity
51
134
  is_stationary = np.abs(velocities) < velocity_threshold
@@ -245,6 +328,7 @@ def refine_transition_with_curvature(
245
328
  transition_type: str,
246
329
  search_window: int = 3,
247
330
  smoothing_window: int = 5,
331
+ polyorder: int = 2,
248
332
  ) -> float:
249
333
  """
250
334
  Refine phase transition timing using trajectory curvature analysis.
@@ -259,6 +343,7 @@ def refine_transition_with_curvature(
259
343
  transition_type: Type of transition ("landing" or "takeoff")
260
344
  search_window: Number of frames to search around estimate
261
345
  smoothing_window: Window size for acceleration computation
346
+ polyorder: Polynomial order for Savitzky-Golay filter (default: 2)
262
347
 
263
348
  Returns:
264
349
  Refined fractional frame index
@@ -268,7 +353,7 @@ def refine_transition_with_curvature(
268
353
 
269
354
  # Compute acceleration (second derivative)
270
355
  acceleration = compute_acceleration_from_derivative(
271
- foot_positions, window_length=smoothing_window, polyorder=2
356
+ foot_positions, window_length=smoothing_window, polyorder=polyorder
272
357
  )
273
358
 
274
359
  # Define search range around estimated transition
@@ -319,6 +404,7 @@ def find_interpolated_phase_transitions_with_curvature(
319
404
  contact_states: list[ContactState],
320
405
  velocity_threshold: float,
321
406
  smoothing_window: int = 5,
407
+ polyorder: int = 2,
322
408
  use_curvature: bool = True,
323
409
  ) -> list[tuple[float, float, ContactState]]:
324
410
  """
@@ -334,6 +420,7 @@ def find_interpolated_phase_transitions_with_curvature(
334
420
  contact_states: List of ContactState for each frame
335
421
  velocity_threshold: Threshold used for contact detection
336
422
  smoothing_window: Window size for velocity/acceleration smoothing
423
+ polyorder: Polynomial order for Savitzky-Golay filter (default: 2)
337
424
  use_curvature: Whether to apply curvature-based refinement
338
425
 
339
426
  Returns:
@@ -362,6 +449,7 @@ def find_interpolated_phase_transitions_with_curvature(
362
449
  "landing",
363
450
  search_window=3,
364
451
  smoothing_window=smoothing_window,
452
+ polyorder=polyorder,
365
453
  )
366
454
  # Refine takeoff (end of ground contact)
367
455
  refined_end = refine_transition_with_curvature(
@@ -370,6 +458,7 @@ def find_interpolated_phase_transitions_with_curvature(
370
458
  "takeoff",
371
459
  search_window=3,
372
460
  smoothing_window=smoothing_window,
461
+ polyorder=polyorder,
373
462
  )
374
463
 
375
464
  elif state == ContactState.IN_AIR:
@@ -380,6 +469,7 @@ def find_interpolated_phase_transitions_with_curvature(
380
469
  "takeoff",
381
470
  search_window=3,
382
471
  smoothing_window=smoothing_window,
472
+ polyorder=polyorder,
383
473
  )
384
474
  refined_end = refine_transition_with_curvature(
385
475
  foot_positions,
@@ -387,6 +477,7 @@ def find_interpolated_phase_transitions_with_curvature(
387
477
  "landing",
388
478
  search_window=3,
389
479
  smoothing_window=smoothing_window,
480
+ polyorder=polyorder,
390
481
  )
391
482
 
392
483
  refined_phases.append((refined_start, refined_end, state))