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 ADDED
@@ -0,0 +1,3 @@
1
+ """Kinemotion: Video-based kinematic analysis for athletic performance."""
2
+
3
+ __version__ = "0.1.0"
@@ -1,4 +1,4 @@
1
- """Command-line interface for kinemetry analysis."""
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 .contact_detection import (
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 .kinematics import calculate_drop_jump_metrics
15
- from .pose_tracker import PoseTracker
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
- """Kinemetry: Video-based kinematic analysis for athletic performance."""
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
- click.echo("Smoothing landmarks...", err=True)
169
- smoothed_landmarks = smooth_landmarks(
170
- landmarks_sequence, window_length=smoothing_window
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 foot positions
174
- click.echo("Detecting ground contact...", err=True)
175
- foot_positions_list: list[float] = []
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
- foot_x, foot_y = compute_average_foot_position(frame_landmarks)
181
- foot_positions_list.append(foot_y)
182
-
183
- # Average visibility of foot landmarks
184
- foot_vis = []
185
- for key in [
186
- "left_ankle",
187
- "right_ankle",
188
- "left_heel",
189
- "right_heel",
190
- ]:
191
- if key in frame_landmarks:
192
- foot_vis.append(frame_landmarks[key][2])
193
- visibilities_list.append(
194
- float(np.mean(foot_vis)) if foot_vis else 0.0
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
- foot_positions_list.append(
199
- foot_positions_list[-1] if foot_positions_list else 0.5
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
- foot_positions: np.ndarray = np.array(foot_positions_list)
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
- foot_positions,
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
- foot_positions,
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