kinemotion 0.10.2__py3-none-any.whl

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

Potentially problematic release.


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

@@ -0,0 +1,289 @@
1
+ """Automatic parameter tuning based on video characteristics."""
2
+
3
+ from dataclasses import dataclass
4
+ from enum import Enum
5
+
6
+ import numpy as np
7
+
8
+
9
+ class QualityPreset(str, Enum):
10
+ """Quality presets for analysis."""
11
+
12
+ FAST = "fast" # Quick analysis, lower precision
13
+ BALANCED = "balanced" # Default: good balance of speed and accuracy
14
+ ACCURATE = "accurate" # Research-grade analysis, slower
15
+
16
+
17
+ @dataclass
18
+ class VideoCharacteristics:
19
+ """Characteristics extracted from video analysis."""
20
+
21
+ fps: float
22
+ frame_count: int
23
+ avg_visibility: float # Average landmark visibility (0-1)
24
+ position_variance: float # Variance in foot positions
25
+ has_stable_period: bool # Whether video has initial stationary period
26
+ tracking_quality: str # "low", "medium", "high"
27
+
28
+
29
+ @dataclass
30
+ class AnalysisParameters:
31
+ """Auto-tuned parameters for drop jump analysis."""
32
+
33
+ smoothing_window: int
34
+ polyorder: int
35
+ velocity_threshold: float
36
+ min_contact_frames: int
37
+ visibility_threshold: float
38
+ detection_confidence: float
39
+ tracking_confidence: float
40
+ outlier_rejection: bool
41
+ bilateral_filter: bool
42
+ use_curvature: bool
43
+
44
+ def to_dict(self) -> dict:
45
+ """Convert to dictionary."""
46
+ return {
47
+ "smoothing_window": self.smoothing_window,
48
+ "polyorder": self.polyorder,
49
+ "velocity_threshold": self.velocity_threshold,
50
+ "min_contact_frames": self.min_contact_frames,
51
+ "visibility_threshold": self.visibility_threshold,
52
+ "detection_confidence": self.detection_confidence,
53
+ "tracking_confidence": self.tracking_confidence,
54
+ "outlier_rejection": self.outlier_rejection,
55
+ "bilateral_filter": self.bilateral_filter,
56
+ "use_curvature": self.use_curvature,
57
+ }
58
+
59
+
60
+ def analyze_tracking_quality(avg_visibility: float) -> str:
61
+ """
62
+ Classify tracking quality based on average landmark visibility.
63
+
64
+ Args:
65
+ avg_visibility: Average visibility score across all tracked landmarks
66
+
67
+ Returns:
68
+ Quality classification: "low", "medium", or "high"
69
+ """
70
+ if avg_visibility < 0.4:
71
+ return "low"
72
+ elif avg_visibility < 0.7:
73
+ return "medium"
74
+ else:
75
+ return "high"
76
+
77
+
78
+ def auto_tune_parameters(
79
+ characteristics: VideoCharacteristics,
80
+ quality_preset: QualityPreset = QualityPreset.BALANCED,
81
+ ) -> AnalysisParameters:
82
+ """
83
+ Automatically tune analysis parameters based on video characteristics.
84
+
85
+ This function implements heuristics to select optimal parameters without
86
+ requiring user expertise in video analysis or kinematic tracking.
87
+
88
+ Key principles:
89
+ 1. FPS-based scaling: Higher fps needs lower velocity thresholds
90
+ 2. Quality-based smoothing: Noisy video needs more smoothing
91
+ 3. Always enable proven features: outlier rejection, curvature analysis
92
+ 4. Preset modifiers: fast/balanced/accurate adjust base parameters
93
+
94
+ Args:
95
+ characteristics: Analyzed video characteristics
96
+ quality_preset: Quality vs speed tradeoff
97
+
98
+ Returns:
99
+ AnalysisParameters with auto-tuned values
100
+ """
101
+ fps = characteristics.fps
102
+ quality = characteristics.tracking_quality
103
+
104
+ # =================================================================
105
+ # STEP 1: FPS-based baseline parameters
106
+ # These scale automatically with frame rate to maintain consistent
107
+ # temporal resolution and sensitivity
108
+ # =================================================================
109
+
110
+ # Velocity threshold: Scale inversely with fps
111
+ # At 30fps, feet move ~2% of frame per frame when "stationary"
112
+ # At 60fps, feet move ~1% of frame per frame when "stationary"
113
+ # Formula: threshold = 0.02 * (30 / fps)
114
+ base_velocity_threshold = 0.02 * (30.0 / fps)
115
+
116
+ # Min contact frames: Scale with fps to maintain same time duration
117
+ # Goal: ~100ms minimum contact (3 frames @ 30fps, 6 frames @ 60fps)
118
+ # Formula: frames = round(3 * (fps / 30))
119
+ base_min_contact_frames = max(2, round(3.0 * (fps / 30.0)))
120
+
121
+ # Smoothing window: Decrease with higher fps for better temporal resolution
122
+ # Lower fps (30fps): 5-frame window = 167ms
123
+ # Higher fps (60fps): 3-frame window = 50ms (same temporal resolution)
124
+ if fps <= 30:
125
+ base_smoothing_window = 5
126
+ elif fps <= 60:
127
+ base_smoothing_window = 3
128
+ else:
129
+ base_smoothing_window = 3 # Even at 120fps, 3 is minimum for Savitzky-Golay
130
+
131
+ # =================================================================
132
+ # STEP 2: Quality-based adjustments
133
+ # Adapt smoothing and filtering based on tracking quality
134
+ # =================================================================
135
+
136
+ smoothing_adjustment = 0
137
+ enable_bilateral = False
138
+
139
+ if quality == "low":
140
+ # Poor tracking quality: aggressive smoothing and filtering
141
+ smoothing_adjustment = +2
142
+ enable_bilateral = True
143
+ elif quality == "medium":
144
+ # Moderate quality: slight smoothing increase
145
+ smoothing_adjustment = +1
146
+ enable_bilateral = True
147
+ else: # high quality
148
+ # Good tracking: preserve detail, minimal smoothing
149
+ smoothing_adjustment = 0
150
+ enable_bilateral = False
151
+
152
+ # =================================================================
153
+ # STEP 3: Apply quality preset modifiers
154
+ # User can choose speed vs accuracy tradeoff
155
+ # =================================================================
156
+
157
+ if quality_preset == QualityPreset.FAST:
158
+ # Fast: Trade accuracy for speed
159
+ velocity_threshold = base_velocity_threshold * 1.5 # Less sensitive
160
+ min_contact_frames = max(2, int(base_min_contact_frames * 0.67))
161
+ smoothing_window = max(3, base_smoothing_window - 2 + smoothing_adjustment)
162
+ bilateral_filter = False # Skip expensive filtering
163
+ detection_confidence = 0.3
164
+ tracking_confidence = 0.3
165
+
166
+ elif quality_preset == QualityPreset.ACCURATE:
167
+ # Accurate: Maximize accuracy, accept slower processing
168
+ velocity_threshold = base_velocity_threshold * 0.5 # More sensitive
169
+ min_contact_frames = (
170
+ base_min_contact_frames # Don't increase (would miss brief)
171
+ )
172
+ smoothing_window = min(11, base_smoothing_window + 2 + smoothing_adjustment)
173
+ bilateral_filter = True # Always use for best accuracy
174
+ detection_confidence = 0.6
175
+ tracking_confidence = 0.6
176
+
177
+ else: # QualityPreset.BALANCED (default)
178
+ # Balanced: Good accuracy, reasonable speed
179
+ velocity_threshold = base_velocity_threshold
180
+ min_contact_frames = base_min_contact_frames
181
+ smoothing_window = max(3, base_smoothing_window + smoothing_adjustment)
182
+ bilateral_filter = enable_bilateral
183
+ detection_confidence = 0.5
184
+ tracking_confidence = 0.5
185
+
186
+ # Ensure smoothing window is odd (required for Savitzky-Golay)
187
+ if smoothing_window % 2 == 0:
188
+ smoothing_window += 1
189
+
190
+ # =================================================================
191
+ # STEP 4: Set fixed optimal values
192
+ # These are always the same regardless of video characteristics
193
+ # =================================================================
194
+
195
+ # Polyorder: Always 2 (quadratic) - optimal for jump physics (parabolic motion)
196
+ polyorder = 2
197
+
198
+ # Visibility threshold: Standard MediaPipe threshold
199
+ visibility_threshold = 0.5
200
+
201
+ # Always enable proven accuracy features
202
+ outlier_rejection = True # Removes tracking glitches (minimal cost)
203
+ use_curvature = True # Trajectory curvature analysis (minimal cost)
204
+
205
+ return AnalysisParameters(
206
+ smoothing_window=smoothing_window,
207
+ polyorder=polyorder,
208
+ velocity_threshold=velocity_threshold,
209
+ min_contact_frames=min_contact_frames,
210
+ visibility_threshold=visibility_threshold,
211
+ detection_confidence=detection_confidence,
212
+ tracking_confidence=tracking_confidence,
213
+ outlier_rejection=outlier_rejection,
214
+ bilateral_filter=bilateral_filter,
215
+ use_curvature=use_curvature,
216
+ )
217
+
218
+
219
+ def analyze_video_sample(
220
+ landmarks_sequence: list[dict[str, tuple[float, float, float]] | None],
221
+ fps: float,
222
+ frame_count: int,
223
+ ) -> VideoCharacteristics:
224
+ """
225
+ Analyze video characteristics from a sample of frames.
226
+
227
+ This function should be called after tracking the first 30-60 frames
228
+ to understand video quality and characteristics.
229
+
230
+ Args:
231
+ landmarks_sequence: Tracked landmarks from sample frames
232
+ fps: Video frame rate
233
+ frame_count: Total number of frames in video
234
+
235
+ Returns:
236
+ VideoCharacteristics with analyzed properties
237
+ """
238
+ # Calculate average landmark visibility
239
+ visibilities = []
240
+ positions = []
241
+
242
+ for frame_landmarks in landmarks_sequence:
243
+ if frame_landmarks:
244
+ # Collect visibility scores from foot landmarks
245
+ foot_keys = [
246
+ "left_ankle",
247
+ "right_ankle",
248
+ "left_heel",
249
+ "right_heel",
250
+ "left_foot_index",
251
+ "right_foot_index",
252
+ ]
253
+
254
+ frame_vis = []
255
+ frame_y_positions = []
256
+
257
+ for key in foot_keys:
258
+ if key in frame_landmarks:
259
+ _, y, vis = frame_landmarks[key] # x not needed for analysis
260
+ frame_vis.append(vis)
261
+ frame_y_positions.append(y)
262
+
263
+ if frame_vis:
264
+ visibilities.append(float(np.mean(frame_vis)))
265
+ if frame_y_positions:
266
+ positions.append(float(np.mean(frame_y_positions)))
267
+
268
+ # Compute metrics
269
+ avg_visibility = float(np.mean(visibilities)) if visibilities else 0.5
270
+ position_variance = float(np.var(positions)) if len(positions) > 1 else 0.0
271
+
272
+ # Determine tracking quality
273
+ tracking_quality = analyze_tracking_quality(avg_visibility)
274
+
275
+ # Check for stable period (indicates drop jump from elevated platform)
276
+ # Simple check: do first 30 frames have low variance?
277
+ has_stable_period = False
278
+ if len(positions) >= 30:
279
+ first_30_std = float(np.std(positions[:30]))
280
+ has_stable_period = first_30_std < 0.01 # Very stable = on platform
281
+
282
+ return VideoCharacteristics(
283
+ fps=fps,
284
+ frame_count=frame_count,
285
+ avg_visibility=avg_visibility,
286
+ position_variance=position_variance,
287
+ has_stable_period=has_stable_period,
288
+ tracking_quality=tracking_quality,
289
+ )
@@ -0,0 +1,345 @@
1
+ """Advanced filtering techniques for robust trajectory processing."""
2
+
3
+ import numpy as np
4
+ from scipy.signal import medfilt
5
+
6
+
7
+ def detect_outliers_ransac(
8
+ positions: np.ndarray,
9
+ window_size: int = 15,
10
+ threshold: float = 0.02,
11
+ min_inliers: float = 0.7,
12
+ ) -> np.ndarray:
13
+ """
14
+ Detect outlier positions using RANSAC-based polynomial fitting.
15
+
16
+ Uses a sliding window approach to detect positions that deviate significantly
17
+ from a polynomial fit of nearby points. This catches MediaPipe tracking glitches
18
+ where landmarks jump to incorrect positions.
19
+
20
+ Args:
21
+ positions: 1D array of position values (e.g., y-coordinates)
22
+ window_size: Size of sliding window for local fitting
23
+ threshold: Distance threshold to consider a point an inlier
24
+ min_inliers: Minimum fraction of points that must be inliers
25
+
26
+ Returns:
27
+ Boolean array: True for outliers, False for valid points
28
+ """
29
+ n = len(positions)
30
+ is_outlier = np.zeros(n, dtype=bool)
31
+
32
+ if n < window_size:
33
+ return is_outlier
34
+
35
+ # Ensure window size is odd
36
+ if window_size % 2 == 0:
37
+ window_size += 1
38
+
39
+ half_window = window_size // 2
40
+
41
+ for i in range(n):
42
+ # Define window around current point
43
+ start = max(0, i - half_window)
44
+ end = min(n, i + half_window + 1)
45
+ window_positions = positions[start:end]
46
+ window_indices = np.arange(start, end)
47
+
48
+ if len(window_positions) < 3:
49
+ continue
50
+
51
+ # Fit polynomial (quadratic) to window
52
+ # Use polyfit with degree 2 (parabolic motion)
53
+ try:
54
+ coeffs = np.polyfit(window_indices, window_positions, deg=2)
55
+ predicted = np.polyval(coeffs, window_indices)
56
+
57
+ # Calculate residuals
58
+ residuals = np.abs(window_positions - predicted)
59
+
60
+ # Point is outlier if its residual is large
61
+ local_idx = i - start
62
+ if local_idx < len(residuals) and residuals[local_idx] > threshold:
63
+ # Also check if most other points are inliers (RANSAC criterion)
64
+ inliers = np.sum(residuals <= threshold)
65
+ if inliers / len(residuals) >= min_inliers:
66
+ is_outlier[i] = True
67
+ except np.linalg.LinAlgError:
68
+ # Polyfit failed, skip this window
69
+ continue
70
+
71
+ return is_outlier
72
+
73
+
74
+ def detect_outliers_median(
75
+ positions: np.ndarray, window_size: int = 5, threshold: float = 0.03
76
+ ) -> np.ndarray:
77
+ """
78
+ Detect outliers using median filtering.
79
+
80
+ Points that deviate significantly from the local median are marked as outliers.
81
+ More robust to noise than mean-based methods.
82
+
83
+ Args:
84
+ positions: 1D array of position values
85
+ window_size: Size of median filter window (must be odd)
86
+ threshold: Deviation threshold to mark as outlier
87
+
88
+ Returns:
89
+ Boolean array: True for outliers, False for valid points
90
+ """
91
+ if len(positions) < window_size:
92
+ return np.zeros(len(positions), dtype=bool)
93
+
94
+ # Ensure window size is odd
95
+ if window_size % 2 == 0:
96
+ window_size += 1
97
+
98
+ # Apply median filter
99
+ median_filtered = medfilt(positions, kernel_size=window_size)
100
+
101
+ # Calculate absolute deviation from median
102
+ deviations = np.abs(positions - median_filtered)
103
+
104
+ # Mark as outlier if deviation exceeds threshold
105
+ is_outlier = deviations > threshold
106
+
107
+ return is_outlier
108
+
109
+
110
+ def remove_outliers(
111
+ positions: np.ndarray,
112
+ outlier_mask: np.ndarray,
113
+ method: str = "interpolate",
114
+ ) -> np.ndarray:
115
+ """
116
+ Replace outlier values with interpolated or median values.
117
+
118
+ Args:
119
+ positions: Original position array
120
+ outlier_mask: Boolean array indicating outliers
121
+ method: "interpolate" or "median"
122
+ - interpolate: Linear interpolation from neighboring valid points
123
+ - median: Replace with local median of valid points
124
+
125
+ Returns:
126
+ Position array with outliers replaced
127
+ """
128
+ positions_clean = positions.copy()
129
+
130
+ if not np.any(outlier_mask):
131
+ return positions_clean
132
+
133
+ outlier_indices = np.nonzero(outlier_mask)[0]
134
+
135
+ for idx in outlier_indices:
136
+ if method == "interpolate":
137
+ # Find nearest valid points before and after
138
+ valid_before = np.nonzero(~outlier_mask[:idx])[0]
139
+ valid_after = np.nonzero(~outlier_mask[idx + 1 :])[0]
140
+
141
+ if len(valid_before) > 0 and len(valid_after) > 0:
142
+ # Linear interpolation between nearest valid points
143
+ idx_before = valid_before[-1]
144
+ idx_after = valid_after[0] + idx + 1
145
+
146
+ # Interpolate
147
+ t = (idx - idx_before) / (idx_after - idx_before)
148
+ positions_clean[idx] = (
149
+ positions[idx_before] * (1 - t) + positions[idx_after] * t
150
+ )
151
+ elif len(valid_before) > 0:
152
+ # Use last valid value
153
+ positions_clean[idx] = positions[valid_before[-1]]
154
+ elif len(valid_after) > 0:
155
+ # Use next valid value
156
+ positions_clean[idx] = positions[valid_after[0] + idx + 1]
157
+
158
+ elif method == "median":
159
+ # Replace with median of nearby valid points
160
+ window_size = 5
161
+ start = max(0, idx - window_size)
162
+ end = min(len(positions), idx + window_size + 1)
163
+
164
+ window_valid = ~outlier_mask[start:end]
165
+ if np.any(window_valid):
166
+ positions_clean[idx] = np.median(positions[start:end][window_valid])
167
+
168
+ return positions_clean
169
+
170
+
171
+ def reject_outliers(
172
+ positions: np.ndarray,
173
+ use_ransac: bool = True,
174
+ use_median: bool = True,
175
+ ransac_window: int = 15,
176
+ ransac_threshold: float = 0.02,
177
+ median_window: int = 5,
178
+ median_threshold: float = 0.03,
179
+ interpolate: bool = True,
180
+ ) -> tuple[np.ndarray, np.ndarray]:
181
+ """
182
+ Comprehensive outlier rejection using multiple methods.
183
+
184
+ Combines RANSAC-based and median-based outlier detection for robust
185
+ identification of tracking glitches.
186
+
187
+ Args:
188
+ positions: 1D array of position values
189
+ use_ransac: Enable RANSAC-based outlier detection
190
+ use_median: Enable median-based outlier detection
191
+ ransac_window: Window size for RANSAC
192
+ ransac_threshold: Deviation threshold for RANSAC
193
+ median_window: Window size for median filter
194
+ median_threshold: Deviation threshold for median
195
+ interpolate: Replace outliers with interpolated values
196
+
197
+ Returns:
198
+ Tuple of (cleaned_positions, outlier_mask)
199
+ - cleaned_positions: Positions with outliers replaced
200
+ - outlier_mask: Boolean array marking outliers
201
+ """
202
+ outlier_mask = np.zeros(len(positions), dtype=bool)
203
+
204
+ # Detect outliers using RANSAC
205
+ if use_ransac:
206
+ ransac_outliers = detect_outliers_ransac(
207
+ positions, window_size=ransac_window, threshold=ransac_threshold
208
+ )
209
+ outlier_mask |= ransac_outliers
210
+
211
+ # Detect outliers using median filtering
212
+ if use_median:
213
+ median_outliers = detect_outliers_median(
214
+ positions, window_size=median_window, threshold=median_threshold
215
+ )
216
+ outlier_mask |= median_outliers
217
+
218
+ # Remove/replace outliers
219
+ if interpolate:
220
+ cleaned_positions = remove_outliers(
221
+ positions, outlier_mask, method="interpolate"
222
+ )
223
+ else:
224
+ cleaned_positions = positions.copy()
225
+
226
+ return cleaned_positions, outlier_mask
227
+
228
+
229
+ def adaptive_smooth_window(
230
+ positions: np.ndarray,
231
+ base_window: int = 5,
232
+ velocity_threshold: float = 0.02,
233
+ min_window: int = 3,
234
+ max_window: int = 11,
235
+ ) -> np.ndarray:
236
+ """
237
+ Determine adaptive smoothing window size based on local motion velocity.
238
+
239
+ Uses larger windows during slow motion (ground contact) and smaller windows
240
+ during fast motion (flight) to preserve details where needed while smoothing
241
+ where safe.
242
+
243
+ Args:
244
+ positions: 1D array of position values
245
+ base_window: Base window size (default: 5)
246
+ velocity_threshold: Velocity below which to use larger window
247
+ min_window: Minimum window size (for fast motion)
248
+ max_window: Maximum window size (for slow motion)
249
+
250
+ Returns:
251
+ Array of window sizes for each frame
252
+ """
253
+ n = len(positions)
254
+ windows = np.full(n, base_window, dtype=int)
255
+
256
+ if n < 2:
257
+ return windows
258
+
259
+ # Compute local velocity (simple diff)
260
+ velocities = np.abs(np.diff(positions, prepend=positions[0]))
261
+
262
+ # Smooth velocity to avoid spurious changes
263
+ if n >= 5:
264
+ from scipy.signal import medfilt
265
+
266
+ velocities = medfilt(velocities, kernel_size=5)
267
+
268
+ # Assign window sizes based on velocity
269
+ for i in range(n):
270
+ if velocities[i] < velocity_threshold / 2:
271
+ # Very slow motion - use maximum window
272
+ windows[i] = max_window
273
+ elif velocities[i] < velocity_threshold:
274
+ # Slow motion - use larger window
275
+ windows[i] = (base_window + max_window) // 2
276
+ else:
277
+ # Fast motion - use smaller window
278
+ windows[i] = min_window
279
+
280
+ # Ensure windows are odd
281
+ windows = np.where(windows % 2 == 0, windows + 1, windows)
282
+
283
+ return windows
284
+
285
+
286
+ def bilateral_temporal_filter(
287
+ positions: np.ndarray,
288
+ window_size: int = 9,
289
+ sigma_spatial: float = 3.0,
290
+ sigma_intensity: float = 0.02,
291
+ ) -> np.ndarray:
292
+ """
293
+ Apply bilateral filter in temporal domain for edge-preserving smoothing.
294
+
295
+ Unlike Savitzky-Golay which smooths uniformly across all frames, the bilateral
296
+ filter preserves sharp transitions (like landing/takeoff) while smoothing within
297
+ smooth regions (flight phase, ground contact).
298
+
299
+ The filter weights each neighbor by both:
300
+ 1. Temporal distance (like regular smoothing)
301
+ 2. Intensity similarity (preserves edges)
302
+
303
+ Args:
304
+ positions: 1D array of position values
305
+ window_size: Temporal window size (must be odd)
306
+ sigma_spatial: Std dev for spatial (temporal) Gaussian kernel
307
+ sigma_intensity: Std dev for intensity (position difference) kernel
308
+
309
+ Returns:
310
+ Filtered position array
311
+ """
312
+ n = len(positions)
313
+ filtered = np.zeros(n)
314
+
315
+ # Ensure window size is odd
316
+ if window_size % 2 == 0:
317
+ window_size += 1
318
+
319
+ half_window = window_size // 2
320
+
321
+ for i in range(n):
322
+ # Define window
323
+ start = max(0, i - half_window)
324
+ end = min(n, i + half_window + 1)
325
+
326
+ # Get window positions
327
+ window_pos = positions[start:end]
328
+ center_pos = positions[i]
329
+
330
+ # Compute spatial (temporal) weights
331
+ temporal_indices = np.arange(start - i, end - i)
332
+ spatial_weights = np.exp(-(temporal_indices**2) / (2 * sigma_spatial**2))
333
+
334
+ # Compute intensity (position difference) weights
335
+ intensity_diff = window_pos - center_pos
336
+ intensity_weights = np.exp(-(intensity_diff**2) / (2 * sigma_intensity**2))
337
+
338
+ # Combined weights (bilateral)
339
+ weights = spatial_weights * intensity_weights
340
+ weights /= np.sum(weights) # Normalize
341
+
342
+ # Weighted average
343
+ filtered[i] = np.sum(weights * window_pos)
344
+
345
+ return filtered