kinemotion 0.17.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.

@@ -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
@@ -0,0 +1,259 @@
1
+ """Pose tracking using MediaPipe Pose."""
2
+
3
+ import cv2
4
+ import mediapipe as mp
5
+ import numpy as np
6
+
7
+
8
+ class PoseTracker:
9
+ """Tracks human pose landmarks in video frames using MediaPipe."""
10
+
11
+ def __init__(
12
+ self,
13
+ min_detection_confidence: float = 0.5,
14
+ min_tracking_confidence: float = 0.5,
15
+ ):
16
+ """
17
+ Initialize the pose tracker.
18
+
19
+ Args:
20
+ min_detection_confidence: Minimum confidence for pose detection
21
+ min_tracking_confidence: Minimum confidence for pose tracking
22
+ """
23
+ self.mp_pose = mp.solutions.pose
24
+ self.pose = self.mp_pose.Pose(
25
+ min_detection_confidence=min_detection_confidence,
26
+ min_tracking_confidence=min_tracking_confidence,
27
+ model_complexity=1,
28
+ )
29
+
30
+ def process_frame(
31
+ self, frame: np.ndarray
32
+ ) -> dict[str, tuple[float, float, float]] | None:
33
+ """
34
+ Process a single frame and extract pose landmarks.
35
+
36
+ Args:
37
+ frame: BGR image frame
38
+
39
+ Returns:
40
+ Dictionary mapping landmark names to (x, y, visibility) tuples,
41
+ or None if no pose detected. Coordinates are normalized (0-1).
42
+ """
43
+ # Convert BGR to RGB
44
+ rgb_frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
45
+
46
+ # Process the frame
47
+ results = self.pose.process(rgb_frame)
48
+
49
+ if not results.pose_landmarks:
50
+ return None
51
+
52
+ # Extract key landmarks for feet tracking and CoM estimation
53
+ landmarks = {}
54
+ landmark_names = {
55
+ # Feet landmarks
56
+ self.mp_pose.PoseLandmark.LEFT_ANKLE: "left_ankle",
57
+ self.mp_pose.PoseLandmark.RIGHT_ANKLE: "right_ankle",
58
+ self.mp_pose.PoseLandmark.LEFT_HEEL: "left_heel",
59
+ self.mp_pose.PoseLandmark.RIGHT_HEEL: "right_heel",
60
+ self.mp_pose.PoseLandmark.LEFT_FOOT_INDEX: "left_foot_index",
61
+ self.mp_pose.PoseLandmark.RIGHT_FOOT_INDEX: "right_foot_index",
62
+ # Torso landmarks for CoM estimation
63
+ self.mp_pose.PoseLandmark.LEFT_HIP: "left_hip",
64
+ self.mp_pose.PoseLandmark.RIGHT_HIP: "right_hip",
65
+ self.mp_pose.PoseLandmark.LEFT_SHOULDER: "left_shoulder",
66
+ self.mp_pose.PoseLandmark.RIGHT_SHOULDER: "right_shoulder",
67
+ # Additional landmarks for better CoM estimation
68
+ self.mp_pose.PoseLandmark.NOSE: "nose",
69
+ self.mp_pose.PoseLandmark.LEFT_KNEE: "left_knee",
70
+ self.mp_pose.PoseLandmark.RIGHT_KNEE: "right_knee",
71
+ }
72
+
73
+ for landmark_id, name in landmark_names.items():
74
+ lm = results.pose_landmarks.landmark[landmark_id]
75
+ landmarks[name] = (lm.x, lm.y, lm.visibility)
76
+
77
+ return landmarks
78
+
79
+ def close(self) -> None:
80
+ """Release resources."""
81
+ self.pose.close()
82
+
83
+
84
+ def _add_head_segment(
85
+ segments: list,
86
+ weights: list,
87
+ visibilities: list,
88
+ landmarks: dict[str, tuple[float, float, float]],
89
+ vis_threshold: float,
90
+ ) -> None:
91
+ """Add head segment (8% body mass) if visible."""
92
+ if "nose" in landmarks:
93
+ x, y, vis = landmarks["nose"]
94
+ if vis > vis_threshold:
95
+ segments.append((x, y))
96
+ weights.append(0.08)
97
+ visibilities.append(vis)
98
+
99
+
100
+ def _add_trunk_segment(
101
+ segments: list,
102
+ weights: list,
103
+ visibilities: list,
104
+ landmarks: dict[str, tuple[float, float, float]],
105
+ vis_threshold: float,
106
+ ) -> None:
107
+ """Add trunk segment (50% body mass) if visible."""
108
+ trunk_keys = ["left_shoulder", "right_shoulder", "left_hip", "right_hip"]
109
+ trunk_pos = [
110
+ (x, y, vis)
111
+ for key in trunk_keys
112
+ if key in landmarks
113
+ for x, y, vis in [landmarks[key]]
114
+ if vis > vis_threshold
115
+ ]
116
+ if len(trunk_pos) >= 2:
117
+ trunk_x = float(np.mean([p[0] for p in trunk_pos]))
118
+ trunk_y = float(np.mean([p[1] for p in trunk_pos]))
119
+ trunk_vis = float(np.mean([p[2] for p in trunk_pos]))
120
+ segments.append((trunk_x, trunk_y))
121
+ weights.append(0.50)
122
+ visibilities.append(trunk_vis)
123
+
124
+
125
+ def _add_limb_segment(
126
+ segments: list,
127
+ weights: list,
128
+ visibilities: list,
129
+ landmarks: dict[str, tuple[float, float, float]],
130
+ side: str,
131
+ proximal_key: str,
132
+ distal_key: str,
133
+ segment_weight: float,
134
+ vis_threshold: float,
135
+ ) -> None:
136
+ """Add a limb segment (thigh or lower leg) if both endpoints visible."""
137
+ prox_full = f"{side}_{proximal_key}"
138
+ dist_full = f"{side}_{distal_key}"
139
+
140
+ if prox_full in landmarks and dist_full in landmarks:
141
+ px, py, pvis = landmarks[prox_full]
142
+ dx, dy, dvis = landmarks[dist_full]
143
+ if pvis > vis_threshold and dvis > vis_threshold:
144
+ seg_x = (px + dx) / 2
145
+ seg_y = (py + dy) / 2
146
+ seg_vis = (pvis + dvis) / 2
147
+ segments.append((seg_x, seg_y))
148
+ weights.append(segment_weight)
149
+ visibilities.append(seg_vis)
150
+
151
+
152
+ def _add_foot_segment(
153
+ segments: list,
154
+ weights: list,
155
+ visibilities: list,
156
+ landmarks: dict[str, tuple[float, float, float]],
157
+ side: str,
158
+ vis_threshold: float,
159
+ ) -> None:
160
+ """Add foot segment (1.5% body mass per foot) if visible."""
161
+ foot_keys = [f"{side}_ankle", f"{side}_heel", f"{side}_foot_index"]
162
+ foot_pos = [
163
+ (x, y, vis)
164
+ for key in foot_keys
165
+ if key in landmarks
166
+ for x, y, vis in [landmarks[key]]
167
+ if vis > vis_threshold
168
+ ]
169
+ if foot_pos:
170
+ foot_x = float(np.mean([p[0] for p in foot_pos]))
171
+ foot_y = float(np.mean([p[1] for p in foot_pos]))
172
+ foot_vis = float(np.mean([p[2] for p in foot_pos]))
173
+ segments.append((foot_x, foot_y))
174
+ weights.append(0.015)
175
+ visibilities.append(foot_vis)
176
+
177
+
178
+ def compute_center_of_mass(
179
+ landmarks: dict[str, tuple[float, float, float]],
180
+ visibility_threshold: float = 0.5,
181
+ ) -> tuple[float, float, float]:
182
+ """
183
+ Compute approximate center of mass (CoM) from body landmarks.
184
+
185
+ Uses biomechanical segment weights based on Dempster's body segment parameters:
186
+ - Head: 8% of body mass (represented by nose)
187
+ - Trunk (shoulders to hips): 50% of body mass
188
+ - Thighs: 2 × 10% = 20% of body mass
189
+ - Legs (knees to ankles): 2 × 5% = 10% of body mass
190
+ - Feet: 2 × 1.5% = 3% of body mass
191
+
192
+ The CoM is estimated as a weighted average of these segments, with
193
+ weights corresponding to their proportion of total body mass.
194
+
195
+ Args:
196
+ landmarks: Dictionary of landmark positions (x, y, visibility)
197
+ visibility_threshold: Minimum visibility to include landmark in calculation
198
+
199
+ Returns:
200
+ (x, y, visibility) tuple for estimated CoM position
201
+ visibility = average visibility of all segments used
202
+ """
203
+ segments: list = []
204
+ weights: list = []
205
+ visibilities: list = []
206
+
207
+ # Add body segments
208
+ _add_head_segment(segments, weights, visibilities, landmarks, visibility_threshold)
209
+ _add_trunk_segment(segments, weights, visibilities, landmarks, visibility_threshold)
210
+
211
+ # Add bilateral limb segments
212
+ for side in ["left", "right"]:
213
+ _add_limb_segment(
214
+ segments,
215
+ weights,
216
+ visibilities,
217
+ landmarks,
218
+ side,
219
+ "hip",
220
+ "knee",
221
+ 0.10,
222
+ visibility_threshold,
223
+ )
224
+ _add_limb_segment(
225
+ segments,
226
+ weights,
227
+ visibilities,
228
+ landmarks,
229
+ side,
230
+ "knee",
231
+ "ankle",
232
+ 0.05,
233
+ visibility_threshold,
234
+ )
235
+ _add_foot_segment(
236
+ segments, weights, visibilities, landmarks, side, visibility_threshold
237
+ )
238
+
239
+ # Fallback if no segments found
240
+ if not segments:
241
+ if "left_hip" in landmarks and "right_hip" in landmarks:
242
+ lh_x, lh_y, lh_vis = landmarks["left_hip"]
243
+ rh_x, rh_y, rh_vis = landmarks["right_hip"]
244
+ return ((lh_x + rh_x) / 2, (lh_y + rh_y) / 2, (lh_vis + rh_vis) / 2)
245
+ return (0.5, 0.5, 0.0)
246
+
247
+ # Normalize weights and compute weighted average
248
+ total_weight = sum(weights)
249
+ normalized_weights = [w / total_weight for w in weights]
250
+
251
+ com_x = float(
252
+ sum(p[0] * w for p, w in zip(segments, normalized_weights, strict=True))
253
+ )
254
+ com_y = float(
255
+ sum(p[1] * w for p, w in zip(segments, normalized_weights, strict=True))
256
+ )
257
+ com_visibility = float(np.mean(visibilities)) if visibilities else 0.0
258
+
259
+ return (com_x, com_y, com_visibility)