kinemotion 0.1.0__py3-none-any.whl → 0.2.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.
- kinemotion/__init__.py +3 -0
- {dropjump → kinemotion}/cli.py +141 -35
- kinemotion/core/__init__.py +40 -0
- kinemotion/core/filtering.py +345 -0
- kinemotion/core/pose.py +221 -0
- {dropjump → kinemotion/core}/smoothing.py +144 -0
- kinemotion/core/video_io.py +122 -0
- kinemotion/dropjump/__init__.py +29 -0
- dropjump/contact_detection.py → kinemotion/dropjump/analysis.py +81 -2
- dropjump/video_io.py → kinemotion/dropjump/debug_overlay.py +49 -140
- {dropjump → kinemotion/dropjump}/kinematics.py +4 -1
- {kinemotion-0.1.0.dist-info → kinemotion-0.2.0.dist-info}/METADATA +162 -26
- kinemotion-0.2.0.dist-info/RECORD +16 -0
- kinemotion-0.2.0.dist-info/entry_points.txt +2 -0
- dropjump/__init__.py +0 -3
- dropjump/pose_tracker.py +0 -74
- kinemotion-0.1.0.dist-info/RECORD +0 -12
- kinemotion-0.1.0.dist-info/entry_points.txt +0 -2
- {kinemotion-0.1.0.dist-info → kinemotion-0.2.0.dist-info}/WHEEL +0 -0
- {kinemotion-0.1.0.dist-info → kinemotion-0.2.0.dist-info}/licenses/LICENSE +0 -0
kinemotion/__init__.py
ADDED
{dropjump → kinemotion}/cli.py
RENAMED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Command-line interface for
|
|
1
|
+
"""Command-line interface for kinemotion analysis."""
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
4
|
import sys
|
|
@@ -7,20 +7,22 @@ from pathlib import Path
|
|
|
7
7
|
import click
|
|
8
8
|
import numpy as np
|
|
9
9
|
|
|
10
|
-
from .
|
|
10
|
+
from .core.pose import PoseTracker, compute_center_of_mass
|
|
11
|
+
from .core.smoothing import smooth_landmarks, smooth_landmarks_advanced
|
|
12
|
+
from .core.video_io import VideoProcessor
|
|
13
|
+
from .dropjump.analysis import (
|
|
14
|
+
calculate_adaptive_threshold,
|
|
11
15
|
compute_average_foot_position,
|
|
12
16
|
detect_ground_contact,
|
|
13
17
|
)
|
|
14
|
-
from .
|
|
15
|
-
from .
|
|
16
|
-
from .smoothing import smooth_landmarks
|
|
17
|
-
from .video_io import DebugOverlayRenderer, VideoProcessor
|
|
18
|
+
from .dropjump.debug_overlay import DebugOverlayRenderer
|
|
19
|
+
from .dropjump.kinematics import calculate_drop_jump_metrics
|
|
18
20
|
|
|
19
21
|
|
|
20
22
|
@click.group()
|
|
21
23
|
@click.version_option(package_name="dropjump-analyze")
|
|
22
24
|
def cli() -> None:
|
|
23
|
-
"""
|
|
25
|
+
"""Kinemotion: Video-based kinematic analysis for athletic performance."""
|
|
24
26
|
pass
|
|
25
27
|
|
|
26
28
|
|
|
@@ -45,6 +47,32 @@ def cli() -> None:
|
|
|
45
47
|
help="Smoothing window size (must be odd, >= 3)",
|
|
46
48
|
show_default=True,
|
|
47
49
|
)
|
|
50
|
+
@click.option(
|
|
51
|
+
"--polyorder",
|
|
52
|
+
type=int,
|
|
53
|
+
default=2,
|
|
54
|
+
help=(
|
|
55
|
+
"Polynomial order for Savitzky-Golay smoothing "
|
|
56
|
+
"(2=quadratic, 3=cubic, must be < smoothing-window)"
|
|
57
|
+
),
|
|
58
|
+
show_default=True,
|
|
59
|
+
)
|
|
60
|
+
@click.option(
|
|
61
|
+
"--outlier-rejection/--no-outlier-rejection",
|
|
62
|
+
default=True,
|
|
63
|
+
help=(
|
|
64
|
+
"Apply RANSAC and median-based outlier rejection to remove tracking glitches "
|
|
65
|
+
"(default: enabled, +1-2%% accuracy)"
|
|
66
|
+
),
|
|
67
|
+
)
|
|
68
|
+
@click.option(
|
|
69
|
+
"--bilateral-filter/--no-bilateral-filter",
|
|
70
|
+
default=False,
|
|
71
|
+
help=(
|
|
72
|
+
"Use bilateral temporal filter for edge-preserving smoothing "
|
|
73
|
+
"(default: disabled, experimental)"
|
|
74
|
+
),
|
|
75
|
+
)
|
|
48
76
|
@click.option(
|
|
49
77
|
"--velocity-threshold",
|
|
50
78
|
type=float,
|
|
@@ -91,11 +119,24 @@ def cli() -> None:
|
|
|
91
119
|
default=True,
|
|
92
120
|
help="Use trajectory curvature analysis for refining transitions (default: enabled)",
|
|
93
121
|
)
|
|
122
|
+
@click.option(
|
|
123
|
+
"--use-com/--use-feet",
|
|
124
|
+
default=False,
|
|
125
|
+
help="Track center of mass instead of feet for improved accuracy (default: feet)",
|
|
126
|
+
)
|
|
127
|
+
@click.option(
|
|
128
|
+
"--adaptive-threshold/--fixed-threshold",
|
|
129
|
+
default=False,
|
|
130
|
+
help="Auto-calibrate velocity threshold from video baseline (default: fixed)",
|
|
131
|
+
)
|
|
94
132
|
def dropjump_analyze(
|
|
95
133
|
video_path: str,
|
|
96
134
|
output: str | None,
|
|
97
135
|
json_output: str | None,
|
|
98
136
|
smoothing_window: int,
|
|
137
|
+
polyorder: int,
|
|
138
|
+
outlier_rejection: bool,
|
|
139
|
+
bilateral_filter: bool,
|
|
99
140
|
velocity_threshold: float,
|
|
100
141
|
min_contact_frames: int,
|
|
101
142
|
visibility_threshold: float,
|
|
@@ -103,6 +144,8 @@ def dropjump_analyze(
|
|
|
103
144
|
tracking_confidence: float,
|
|
104
145
|
drop_height: float | None,
|
|
105
146
|
use_curvature: bool,
|
|
147
|
+
use_com: bool,
|
|
148
|
+
adaptive_threshold: bool,
|
|
106
149
|
) -> None:
|
|
107
150
|
"""
|
|
108
151
|
Analyze drop-jump video to estimate ground contact time, flight time, and jump height.
|
|
@@ -122,6 +165,17 @@ def dropjump_analyze(
|
|
|
122
165
|
f"Adjusting smoothing-window to {smoothing_window} (must be odd)", err=True
|
|
123
166
|
)
|
|
124
167
|
|
|
168
|
+
if polyorder < 1:
|
|
169
|
+
click.echo("Error: polyorder must be >= 1", err=True)
|
|
170
|
+
sys.exit(1)
|
|
171
|
+
|
|
172
|
+
if polyorder >= smoothing_window:
|
|
173
|
+
click.echo(
|
|
174
|
+
f"Error: polyorder ({polyorder}) must be < smoothing-window ({smoothing_window})",
|
|
175
|
+
err=True,
|
|
176
|
+
)
|
|
177
|
+
sys.exit(1)
|
|
178
|
+
|
|
125
179
|
try:
|
|
126
180
|
# Initialize video processor
|
|
127
181
|
with VideoProcessor(video_path) as video:
|
|
@@ -165,47 +219,95 @@ def dropjump_analyze(
|
|
|
165
219
|
sys.exit(1)
|
|
166
220
|
|
|
167
221
|
# Smooth landmarks
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
222
|
+
if outlier_rejection or bilateral_filter:
|
|
223
|
+
if outlier_rejection:
|
|
224
|
+
click.echo(
|
|
225
|
+
"Smoothing landmarks with outlier rejection...", err=True
|
|
226
|
+
)
|
|
227
|
+
if bilateral_filter:
|
|
228
|
+
click.echo(
|
|
229
|
+
"Using bilateral temporal filter for edge-preserving smoothing...",
|
|
230
|
+
err=True,
|
|
231
|
+
)
|
|
232
|
+
smoothed_landmarks = smooth_landmarks_advanced(
|
|
233
|
+
landmarks_sequence,
|
|
234
|
+
window_length=smoothing_window,
|
|
235
|
+
polyorder=polyorder,
|
|
236
|
+
use_outlier_rejection=outlier_rejection,
|
|
237
|
+
use_bilateral=bilateral_filter,
|
|
238
|
+
)
|
|
239
|
+
else:
|
|
240
|
+
click.echo("Smoothing landmarks...", err=True)
|
|
241
|
+
smoothed_landmarks = smooth_landmarks(
|
|
242
|
+
landmarks_sequence, window_length=smoothing_window, polyorder=polyorder
|
|
243
|
+
)
|
|
172
244
|
|
|
173
|
-
# Extract
|
|
174
|
-
|
|
175
|
-
|
|
245
|
+
# Extract vertical positions (either CoM or feet)
|
|
246
|
+
if use_com:
|
|
247
|
+
click.echo("Computing center of mass positions...", err=True)
|
|
248
|
+
else:
|
|
249
|
+
click.echo("Extracting foot positions...", err=True)
|
|
250
|
+
|
|
251
|
+
position_list: list[float] = []
|
|
176
252
|
visibilities_list: list[float] = []
|
|
177
253
|
|
|
178
254
|
for frame_landmarks in smoothed_landmarks:
|
|
179
255
|
if frame_landmarks:
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
256
|
+
if use_com:
|
|
257
|
+
# Use center of mass estimation
|
|
258
|
+
com_x, com_y, com_vis = compute_center_of_mass(
|
|
259
|
+
frame_landmarks, visibility_threshold=visibility_threshold
|
|
260
|
+
)
|
|
261
|
+
position_list.append(com_y)
|
|
262
|
+
visibilities_list.append(com_vis)
|
|
263
|
+
else:
|
|
264
|
+
# Use average foot position (original method)
|
|
265
|
+
foot_x, foot_y = compute_average_foot_position(frame_landmarks)
|
|
266
|
+
position_list.append(foot_y)
|
|
267
|
+
|
|
268
|
+
# Average visibility of foot landmarks
|
|
269
|
+
foot_vis = []
|
|
270
|
+
for key in [
|
|
271
|
+
"left_ankle",
|
|
272
|
+
"right_ankle",
|
|
273
|
+
"left_heel",
|
|
274
|
+
"right_heel",
|
|
275
|
+
]:
|
|
276
|
+
if key in frame_landmarks:
|
|
277
|
+
foot_vis.append(frame_landmarks[key][2])
|
|
278
|
+
visibilities_list.append(
|
|
279
|
+
float(np.mean(foot_vis)) if foot_vis else 0.0
|
|
280
|
+
)
|
|
196
281
|
else:
|
|
197
282
|
# Use previous position if available, otherwise default
|
|
198
|
-
|
|
199
|
-
|
|
283
|
+
position_list.append(
|
|
284
|
+
position_list[-1] if position_list else 0.5
|
|
200
285
|
)
|
|
201
286
|
visibilities_list.append(0.0)
|
|
202
287
|
|
|
203
|
-
|
|
288
|
+
vertical_positions: np.ndarray = np.array(position_list)
|
|
204
289
|
visibilities: np.ndarray = np.array(visibilities_list)
|
|
205
290
|
|
|
291
|
+
# Calculate adaptive threshold if enabled
|
|
292
|
+
if adaptive_threshold:
|
|
293
|
+
click.echo("Calculating adaptive velocity threshold...", err=True)
|
|
294
|
+
velocity_threshold = calculate_adaptive_threshold(
|
|
295
|
+
vertical_positions,
|
|
296
|
+
video.fps,
|
|
297
|
+
baseline_duration=3.0,
|
|
298
|
+
multiplier=1.5,
|
|
299
|
+
smoothing_window=smoothing_window,
|
|
300
|
+
polyorder=polyorder,
|
|
301
|
+
)
|
|
302
|
+
click.echo(
|
|
303
|
+
f"Adaptive threshold: {velocity_threshold:.4f} "
|
|
304
|
+
f"(auto-calibrated from baseline)",
|
|
305
|
+
err=True,
|
|
306
|
+
)
|
|
307
|
+
|
|
206
308
|
# Detect ground contact
|
|
207
309
|
contact_states = detect_ground_contact(
|
|
208
|
-
|
|
310
|
+
vertical_positions,
|
|
209
311
|
velocity_threshold=velocity_threshold,
|
|
210
312
|
min_contact_frames=min_contact_frames,
|
|
211
313
|
visibility_threshold=visibility_threshold,
|
|
@@ -214,6 +316,8 @@ def dropjump_analyze(
|
|
|
214
316
|
|
|
215
317
|
# Calculate metrics
|
|
216
318
|
click.echo("Calculating metrics...", err=True)
|
|
319
|
+
if use_com:
|
|
320
|
+
click.echo("Using center of mass tracking for improved accuracy", err=True)
|
|
217
321
|
if drop_height:
|
|
218
322
|
click.echo(
|
|
219
323
|
f"Using drop height calibration: {drop_height}m ({drop_height*100:.0f}cm)",
|
|
@@ -221,11 +325,12 @@ def dropjump_analyze(
|
|
|
221
325
|
)
|
|
222
326
|
metrics = calculate_drop_jump_metrics(
|
|
223
327
|
contact_states,
|
|
224
|
-
|
|
328
|
+
vertical_positions,
|
|
225
329
|
video.fps,
|
|
226
330
|
drop_height_m=drop_height,
|
|
227
331
|
velocity_threshold=velocity_threshold,
|
|
228
332
|
smoothing_window=smoothing_window,
|
|
333
|
+
polyorder=polyorder,
|
|
229
334
|
use_curvature=use_curvature,
|
|
230
335
|
)
|
|
231
336
|
|
|
@@ -277,6 +382,7 @@ def dropjump_analyze(
|
|
|
277
382
|
contact_states[i],
|
|
278
383
|
i,
|
|
279
384
|
metrics,
|
|
385
|
+
use_com=use_com,
|
|
280
386
|
)
|
|
281
387
|
renderer.write_frame(annotated)
|
|
282
388
|
bar.update(1)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Core functionality shared across all jump analysis types."""
|
|
2
|
+
|
|
3
|
+
from .filtering import (
|
|
4
|
+
adaptive_smooth_window,
|
|
5
|
+
bilateral_temporal_filter,
|
|
6
|
+
detect_outliers_median,
|
|
7
|
+
detect_outliers_ransac,
|
|
8
|
+
reject_outliers,
|
|
9
|
+
remove_outliers,
|
|
10
|
+
)
|
|
11
|
+
from .pose import PoseTracker, compute_center_of_mass
|
|
12
|
+
from .smoothing import (
|
|
13
|
+
compute_acceleration_from_derivative,
|
|
14
|
+
compute_velocity,
|
|
15
|
+
compute_velocity_from_derivative,
|
|
16
|
+
smooth_landmarks,
|
|
17
|
+
smooth_landmarks_advanced,
|
|
18
|
+
)
|
|
19
|
+
from .video_io import VideoProcessor
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
# Pose tracking
|
|
23
|
+
"PoseTracker",
|
|
24
|
+
"compute_center_of_mass",
|
|
25
|
+
# Smoothing
|
|
26
|
+
"smooth_landmarks",
|
|
27
|
+
"smooth_landmarks_advanced",
|
|
28
|
+
"compute_velocity",
|
|
29
|
+
"compute_velocity_from_derivative",
|
|
30
|
+
"compute_acceleration_from_derivative",
|
|
31
|
+
# Filtering
|
|
32
|
+
"detect_outliers_ransac",
|
|
33
|
+
"detect_outliers_median",
|
|
34
|
+
"remove_outliers",
|
|
35
|
+
"reject_outliers",
|
|
36
|
+
"adaptive_smooth_window",
|
|
37
|
+
"bilateral_temporal_filter",
|
|
38
|
+
# Video I/O
|
|
39
|
+
"VideoProcessor",
|
|
40
|
+
]
|
|
@@ -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 # type: ignore[no-any-return]
|
|
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.where(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.where(~outlier_mask[:idx])[0]
|
|
139
|
+
valid_after = np.where(~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
|