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.
- kinemotion/__init__.py +14 -0
- kinemotion/api.py +428 -0
- kinemotion/cli.py +20 -0
- kinemotion/core/__init__.py +40 -0
- kinemotion/core/auto_tuning.py +289 -0
- kinemotion/core/filtering.py +345 -0
- kinemotion/core/pose.py +220 -0
- kinemotion/core/smoothing.py +366 -0
- kinemotion/core/video_io.py +166 -0
- kinemotion/dropjump/__init__.py +29 -0
- kinemotion/dropjump/analysis.py +639 -0
- kinemotion/dropjump/cli.py +738 -0
- kinemotion/dropjump/debug_overlay.py +252 -0
- kinemotion/dropjump/kinematics.py +439 -0
- kinemotion/py.typed +0 -0
- kinemotion-0.10.2.dist-info/METADATA +561 -0
- kinemotion-0.10.2.dist-info/RECORD +20 -0
- kinemotion-0.10.2.dist-info/WHEEL +4 -0
- kinemotion-0.10.2.dist-info/entry_points.txt +2 -0
- kinemotion-0.10.2.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|