Bachstelze commited on
Commit
ffdf1ae
·
1 Parent(s): 0f97f67

add smoothing

Browse files
Files changed (3) hide show
  1. A12/__init__.py +63 -0
  2. A12/pose_interpolator.py +1009 -0
  3. app.py +31 -3
A12/__init__.py ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ A12 – Pose Interpolation & Smoothing
4
+ =====================================
5
+ Outlier-robust smoothing strategies for pose-estimation keypoint sequences.
6
+
7
+ Provides
8
+ --------
9
+ - :class:`PoseInterpolator` – low‑level pipeline with multiple strategies
10
+ - :func:`smooth_pose_sequence` – high‑level convenience for *app.py* data
11
+ - :class:`SmoothingStrategy` – enumeration of available methods
12
+ - :class:`KalmanFilter1D` – reusable 1‑D constant‑velocity Kalman filter
13
+ - Detection helpers: :func:`detect_outliers_velocity`,
14
+ :func:`detect_outliers_zscore`
15
+
16
+ Supported strategies
17
+ --------------------
18
+ =========================== ===================================================
19
+ Strategy Description
20
+ =========================== ===================================================
21
+ ``moving_average`` Sliding-window box-car average
22
+ ``gaussian`` Gaussian-weighted convolution
23
+ ``exponential`` Exponential moving average (EMA)
24
+ ``median`` Median filter – kills isolated spikes
25
+ ``savitzky_golay`` Savitzky-Golay – preserves signal shape
26
+ ``kalman`` 1‑D constant-velocity Kalman filter
27
+ ``spline`` Cubic-spline interpolation through
28
+ high-confidence points
29
+ ``hybrid`` (Default) outlier → interpolate → Savitzky‑Golay
30
+ =========================== ===================================================
31
+
32
+ Usage examples
33
+ --------------
34
+
35
+ .. code:: python
36
+
37
+ from A12 import smooth_pose_sequence, PoseInterpolator
38
+
39
+ # Quick hybrid smoothing (recommended for animation)
40
+ smoothed = smooth_pose_sequence(all_keypoints)
41
+
42
+ # Fine-grained control
43
+ interp = PoseInterpolator(strategy="kalman", process_noise=0.001,
44
+ measurement_noise=0.05)
45
+ arr = interp.keypoints_to_array(all_keypoints)
46
+ smoothed_arr = interp.fit_transform(arr)
47
+ smoothed_frames = interp.array_to_keypoints(smoothed_arr, all_keypoints)
48
+ """
49
+
50
+ from A12.pose_interpolator import ( # noqa: F401
51
+ # High-level API
52
+ smooth_pose_sequence,
53
+ # Low-level API
54
+ PoseInterpolator,
55
+ SmoothingStrategy,
56
+ KalmanFilter1D,
57
+ # Outlier detection utilities
58
+ detect_outliers_velocity,
59
+ detect_outliers_zscore,
60
+ # Constants
61
+ COCO_KEYPOINTS,
62
+ A11_JOINTS,
63
+ )
A12/pose_interpolator.py ADDED
@@ -0,0 +1,1009 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Pose Interpolation & Smoothing Module (A12)
4
+ ============================================
5
+ Provides robust smoothing strategies for pose-estimation keypoint sequences,
6
+ eliminating jitter, filling detection gaps, and removing outlier spikes
7
+ to produce clean animations.
8
+
9
+ Supported strategies
10
+ --------------------
11
+ - ``moving_average`` Box-car (sliding window) average
12
+ - ``gaussian`` Gaussian-weighted convolution
13
+ - ``exponential`` Exponential moving average (EMA) – usable online
14
+ - ``median`` Median filter – excellent against isolated spikes
15
+ - ``savitzky_golay`` Savitzky-Golay filter – preserves signal shape
16
+ - ``kalman`` 1-D constant-velocity Kalman filter (online)
17
+ - ``spline`` Cubic-spline interpolation through high-confidence
18
+ points, discarding outliers
19
+ - ``hybrid`` (Default) Outlier detection → interpolation →
20
+ Savitzky-Golay smoothing
21
+
22
+ Data formats
23
+ ------------
24
+ The module accepts two common representations:
25
+
26
+ 1. **App‑style list of dicts** (from ``app.py`` ``all_keypoints``):
27
+
28
+ .. code:: python
29
+
30
+ [
31
+ {
32
+ "poses": [{
33
+ "keypoints": [
34
+ {"x": 0.5, "y": 0.3, "score": 0.92, "name": "nose"},
35
+ ...
36
+ ]
37
+ }],
38
+ "frame_id": 0, ...
39
+ },
40
+ ...
41
+ ]
42
+
43
+ 2. **NumPy array** with shape ``(frames, joints, 3)`` where the last axis
44
+ holds ``[x, y, confidence]``.
45
+
46
+ Basic usage
47
+ -----------
48
+ .. code:: python
49
+
50
+ from A12.pose_interpolator import PoseInterpolator, smooth_pose_sequence
51
+
52
+ # High-level convenience (recommended)
53
+ smoothed = smooth_pose_sequence(all_keypoints, strategy="hybrid")
54
+
55
+ # Low-level API
56
+ interp = PoseInterpolator(strategy="kalman", process_noise=0.001,
57
+ measurement_noise=0.05)
58
+ arr = interp.keypoints_to_array(all_keypoints)
59
+ smoothed_arr = interp.fit_transform(arr)
60
+ """
61
+
62
+ from __future__ import annotations
63
+
64
+ import warnings
65
+ from collections import defaultdict
66
+ from copy import deepcopy
67
+ from enum import Enum
68
+ from typing import Any, Dict, List, Optional, Sequence, Tuple, Union
69
+
70
+ import numpy as np
71
+
72
+ # ---------------------------------------------------------------------------
73
+ # Try importing scipy – it is a transitive dependency of statsmodels /
74
+ # scikit-learn (both in requirements.txt), but we guard in case it is
75
+ # not available for some strategies.
76
+ # ---------------------------------------------------------------------------
77
+ try:
78
+ from scipy import interpolate as _scipy_interpolate, ndimage, signal as _scipy_signal
79
+
80
+ _HAS_SCIPY = True
81
+ except ImportError: # pragma: no cover
82
+ _HAS_SCIPY = False
83
+
84
+
85
+ # ---------------------------------------------------------------------------
86
+ # Standard joint-name lists (for reference / validation)
87
+ # ---------------------------------------------------------------------------
88
+
89
+ COCO_KEYPOINTS: List[str] = [
90
+ "nose",
91
+ "left_eye",
92
+ "right_eye",
93
+ "left_ear",
94
+ "right_ear",
95
+ "left_shoulder",
96
+ "right_shoulder",
97
+ "left_elbow",
98
+ "right_elbow",
99
+ "left_wrist",
100
+ "right_wrist",
101
+ "left_hip",
102
+ "right_hip",
103
+ "left_knee",
104
+ "right_knee",
105
+ "left_ankle",
106
+ "right_ankle",
107
+ ]
108
+
109
+ A11_JOINTS: List[str] = [
110
+ "head",
111
+ "left_shoulder",
112
+ "left_elbow",
113
+ "right_shoulder",
114
+ "right_elbow",
115
+ "left_hand",
116
+ "right_hand",
117
+ "left_hip",
118
+ "right_hip",
119
+ "left_knee",
120
+ "right_knee",
121
+ "left_foot",
122
+ "right_foot",
123
+ ]
124
+
125
+
126
+ class SmoothingStrategy(Enum):
127
+ """Available smoothing strategies."""
128
+
129
+ MOVING_AVERAGE = "moving_average"
130
+ GAUSSIAN = "gaussian"
131
+ EXPONENTIAL = "exponential"
132
+ MEDIAN = "median"
133
+ SAVITZKY_GOLAY = "savitzky_golay"
134
+ KALMAN = "kalman"
135
+ SPLINE = "spline"
136
+ HYBRID = "hybrid"
137
+
138
+
139
+ # ===================================================================
140
+ # Small helpers
141
+ # ===================================================================
142
+
143
+
144
+ def _validate_array(arr: np.ndarray) -> np.ndarray:
145
+ """Ensure *arr* is a contiguous float64 array of shape (F, J, 3)."""
146
+ arr = np.asarray(arr, dtype=np.float64)
147
+ if arr.ndim != 3 or arr.shape[2] != 3:
148
+ raise ValueError(
149
+ f"Expected array of shape (frames, joints, 3), got {arr.shape}"
150
+ )
151
+ return arr
152
+
153
+
154
+ def _ensure_scipy(strategy_name: str) -> None:
155
+ if not _HAS_SCIPY:
156
+ raise ImportError(
157
+ f"Strategy '{strategy_name}' requires scipy, which is not installed."
158
+ )
159
+
160
+
161
+ # ===================================================================
162
+ # 1-D Kalman filter (constant velocity) – no external dependencies
163
+ # ===================================================================
164
+
165
+
166
+ class KalmanFilter1D:
167
+ """
168
+ Simple 1-D constant-velocity Kalman filter.
169
+
170
+ State: [position, velocity]ᵀ
171
+ Measurement: position
172
+
173
+ Parameters
174
+ ----------
175
+ process_noise : float
176
+ Process noise (Q) – higher values trust the model less.
177
+ measurement_noise : float
178
+ Measurement noise (R) – higher values trust measurements less.
179
+
180
+ Notes
181
+ -----
182
+ When ``update(None)`` is called the filter performs a pure prediction
183
+ step, allowing it to bridge gaps in the detection sequence.
184
+ """
185
+
186
+ def __init__(
187
+ self, process_noise: float = 0.001, measurement_noise: float = 0.1
188
+ ) -> None:
189
+ # State transition (constant velocity, dt = 1)
190
+ self._A = np.array([[1, 1], [0, 1]], dtype=np.float64)
191
+ self._H = np.array([[1, 0]], dtype=np.float64) # observe position only
192
+ self._Q = np.eye(2, dtype=np.float64) * process_noise
193
+ self._R = np.array([[measurement_noise]], dtype=np.float64)
194
+ self._P = np.eye(2, dtype=np.float64) * 1.0
195
+ self._x = np.zeros(2, dtype=np.float64)
196
+
197
+ @property
198
+ def position(self) -> float:
199
+ """Current position estimate."""
200
+ return float(self._x[0])
201
+
202
+ def reset(self, position: float = 0.0) -> None:
203
+ """Re-initialise filter with a new position and zero velocity."""
204
+ self._x = np.array([position, 0.0], dtype=np.float64)
205
+ self._P = np.eye(2, dtype=np.float64) * 1.0
206
+
207
+ def update(self, measurement: Optional[float]) -> float:
208
+ """
209
+ Predict + (optionally) update step.
210
+
211
+ Parameters
212
+ ----------
213
+ measurement : float or None
214
+ Observed position. If ``None``, only the prediction step runs.
215
+
216
+ Returns
217
+ -------
218
+ float
219
+ Filtered position estimate.
220
+ """
221
+ # -- predict --
222
+ self._x = self._A @ self._x
223
+ self._P = self._A @ self._P @ self._A.T + self._Q
224
+
225
+ if measurement is not None:
226
+ # -- update --
227
+ y = measurement - (self._H @ self._x) # innovation
228
+ S = self._H @ self._P @ self._H.T + self._R
229
+ K = self._P @ self._H.T @ np.linalg.inv(S) # Kalman gain
230
+ self._x = self._x + (K @ y).ravel()
231
+ self._P = (np.eye(2) - K @ self._H) @ self._P
232
+
233
+ return self.position
234
+
235
+
236
+ # ===================================================================
237
+ # Outlier detection utilities
238
+ # ===================================================================
239
+
240
+
241
+ def detect_outliers_velocity(
242
+ positions: np.ndarray,
243
+ threshold: float = 3.0,
244
+ min_confidence: float = 0.2,
245
+ confidences: Optional[np.ndarray] = None,
246
+ ) -> np.ndarray:
247
+ """
248
+ Flag outliers based on inter-frame velocity.
249
+
250
+ A point is considered an outlier if its frame-to-frame displacement
251
+ exceeds *threshold* times the median absolute deviation of all
252
+ non-zero displacements across the sequence.
253
+
254
+ Parameters
255
+ ----------
256
+ positions : (F,) ndarray
257
+ 1-D coordinate signal (may contain NaN).
258
+ threshold : float
259
+ MAD multiplier.
260
+ min_confidence : float
261
+ Points with confidence below this are already treated as
262
+ missing – they are **not** flagged here (they will be
263
+ interpolated later).
264
+ confidences : (F,) ndarray or None
265
+ Confidence values (same length as *positions*).
266
+
267
+ Returns
268
+ -------
269
+ (F,) bool ndarray
270
+ ``True`` where the point is an outlier.
271
+ """
272
+ positions = np.asarray(positions, dtype=np.float64)
273
+ n = len(positions)
274
+
275
+ outlier_mask = np.zeros(n, dtype=bool)
276
+
277
+ # Low-confidence points are handled by the interpolation stage.
278
+ if confidences is not None:
279
+ confidences = np.asarray(confidences, dtype=np.float64)
280
+ low_conf = confidences < min_confidence
281
+ else:
282
+ low_conf = np.isnan(positions)
283
+
284
+ # Compute finite differences
285
+ diffs = np.abs(np.diff(positions))
286
+ valid_diffs = diffs[np.isfinite(diffs)]
287
+ if len(valid_diffs) == 0:
288
+ return outlier_mask
289
+
290
+ mad = np.median(np.abs(valid_diffs - np.median(valid_diffs)))
291
+ if mad == 0:
292
+ mad = np.mean(valid_diffs) + 1e-9 # fallback
293
+
294
+ limit = threshold * mad * 1.4826 # 1.4826 converts MAD → std for normal
295
+
296
+ for i in range(1, n):
297
+ if low_conf[i]:
298
+ continue
299
+ d = abs(positions[i] - positions[i - 1])
300
+ if np.isfinite(d) and d > limit:
301
+ outlier_mask[i] = True
302
+
303
+ return outlier_mask
304
+
305
+
306
+ def detect_outliers_zscore(
307
+ positions: np.ndarray,
308
+ threshold: float = 3.0,
309
+ min_confidence: float = 0.2,
310
+ confidences: Optional[np.ndarray] = None,
311
+ ) -> np.ndarray:
312
+ """
313
+ Flag outliers whose absolute z-score exceeds *threshold*.
314
+
315
+ Computed against the sequence mean / std (ignoring NaN).
316
+ """
317
+ positions = np.asarray(positions, dtype=np.float64)
318
+ n = len(positions)
319
+
320
+ outlier_mask = np.zeros(n, dtype=bool)
321
+
322
+ if confidences is not None:
323
+ confidences = np.asarray(confidences, dtype=np.float64)
324
+ low_conf = confidences < min_confidence
325
+ else:
326
+ low_conf = np.isnan(positions)
327
+
328
+ finite = np.isfinite(positions) & ~low_conf
329
+ if not finite.any():
330
+ return outlier_mask
331
+
332
+ mu = np.mean(positions[finite])
333
+ sigma = np.std(positions[finite])
334
+ if sigma == 0:
335
+ return outlier_mask
336
+
337
+ z = np.abs(positions - mu) / sigma
338
+ outlier_mask = (z > threshold) & ~low_conf & np.isfinite(positions)
339
+ return outlier_mask
340
+
341
+
342
+ # ===================================================================
343
+ # Core interpolator class
344
+ # ===================================================================
345
+
346
+
347
+ class PoseInterpolator:
348
+ """
349
+ Smooth a multi-joint pose trajectory using a configurable strategy.
350
+
351
+ Parameters
352
+ ----------
353
+ strategy : str or SmoothingStrategy
354
+ One of the strategies listed in :class:`SmoothingStrategy`.
355
+ window_size : int
356
+ Window length (frames) for moving‑average, gaussian, median,
357
+ savitzky_golay. Must be odd for savitzky_golay and median.
358
+ poly_order : int
359
+ Polynomial order for Savitzky‑Golay (must be < window_size).
360
+ sigma : float
361
+ Standard deviation of the Gaussian kernel.
362
+ alpha : float
363
+ Smoothing factor for exponential moving average (0 < α ≤ 1).
364
+ Larger α gives more weight to recent observations.
365
+ process_noise : float
366
+ Kalman-filter process noise.
367
+ measurement_noise : float
368
+ Kalman-filter measurement noise.
369
+ outlier_method : str
370
+ ``"velocity"`` or ``"zscore"`` – used by the hybrid strategy.
371
+ outlier_threshold : float
372
+ MAD / z-score multiplier for outlier flagging.
373
+ min_confidence : float
374
+ Keypoints with confidence below this are treated as missing and
375
+ interpolated regardless of strategy.
376
+ fill_method : str
377
+ How to fill missing / masked positions before smoothing:
378
+ ``"linear"``, ``"spline"``, or ``"forward"`` (last valid
379
+ carried forward).
380
+ """
381
+
382
+ def __init__(
383
+ self,
384
+ strategy: Union[str, SmoothingStrategy] = SmoothingStrategy.HYBRID,
385
+ window_size: int = 5,
386
+ poly_order: int = 2,
387
+ sigma: float = 1.0,
388
+ alpha: float = 0.3,
389
+ process_noise: float = 0.001,
390
+ measurement_noise: float = 0.05,
391
+ outlier_method: str = "velocity",
392
+ outlier_threshold: float = 3.0,
393
+ min_confidence: float = 0.2,
394
+ fill_method: str = "linear",
395
+ ) -> None:
396
+ if isinstance(strategy, str):
397
+ strategy = SmoothingStrategy(strategy)
398
+ self.strategy = strategy
399
+ self.window_size = window_size
400
+ self.poly_order = poly_order
401
+ self.sigma = sigma
402
+ self.alpha = float(np.clip(alpha, 0.0, 1.0))
403
+ self.process_noise = process_noise
404
+ self.measurement_noise = measurement_noise
405
+ self.outlier_method = outlier_method
406
+ self.outlier_threshold = outlier_threshold
407
+ self.min_confidence = min_confidence
408
+ self.fill_method = fill_method
409
+
410
+ # Internal state (set during fit / transform)
411
+ self._joint_names: List[str] = []
412
+ self._n_frames: int = 0
413
+ self._n_joints: int = 0
414
+
415
+ # --- public API ---------------------------------------------------
416
+
417
+ @staticmethod
418
+ def keypoints_to_array(
419
+ frames_data: List[Dict[str, Any]],
420
+ joint_names: Optional[List[str]] = None,
421
+ ) -> np.ndarray:
422
+ """
423
+ Convert *app.py* style frame dicts into a ``(F, J, 3)`` array.
424
+
425
+ Parameters
426
+ ----------
427
+ frames_data : list of dict
428
+ Each dict must have the structure produced by
429
+ ``extract_joint_positions_from_movenet()`` (see module
430
+ docstring).
431
+ joint_names : list of str, optional
432
+ Names of joints in the desired order. When ``None``,
433
+ ``COCO_KEYPOINTS`` is used.
434
+
435
+ Returns
436
+ -------
437
+ ndarray of shape ``(len(frames_data), len(joint_names), 3)``
438
+ The last axis holds ``[x, y, confidence]``. Missing values
439
+ are represented as ``NaN``.
440
+ """
441
+ if joint_names is None:
442
+ joint_names = COCO_KEYPOINTS
443
+
444
+ n_frames = len(frames_data)
445
+ n_joints = len(joint_names)
446
+ arr = np.full((n_frames, n_joints, 3), np.nan, dtype=np.float64)
447
+
448
+ for f_idx, frame in enumerate(frames_data):
449
+ poses = frame.get("poses", [])
450
+ if not poses:
451
+ continue
452
+ kps = poses[0].get("keypoints", [])
453
+ kp_map = {kp.get("name"): kp for kp in kps}
454
+ for j_idx, name in enumerate(joint_names):
455
+ kp = kp_map.get(name)
456
+ if kp is None:
457
+ continue
458
+ x, y, c = kp.get("x"), kp.get("y"), kp.get("score")
459
+ arr[f_idx, j_idx, 0] = x if x is not None else np.nan
460
+ arr[f_idx, j_idx, 1] = y if y is not None else np.nan
461
+ arr[f_idx, j_idx, 2] = (
462
+ c if c is not None else np.nan
463
+ )
464
+
465
+ return arr
466
+
467
+ @staticmethod
468
+ def array_to_keypoints(
469
+ arr: np.ndarray,
470
+ frames_data: List[Dict[str, Any]],
471
+ joint_names: Optional[List[str]] = None,
472
+ ) -> List[Dict[str, Any]]:
473
+ """
474
+ Write a smoothed ``(F, J, 3)`` array back into the original
475
+ app‑style frame dicts (returns a **deep copy** of
476
+ *frames_data* with modified keypoint coordinates).
477
+
478
+ Confidence values are preserved from the original data;
479
+ coordinates are overwritten with the smoothed values.
480
+ """
481
+ if joint_names is None:
482
+ joint_names = COCO_KEYPOINTS
483
+
484
+ arr = _validate_array(arr)
485
+ n_frames, n_joints, _ = arr.shape
486
+
487
+ out: List[Dict[str, Any]] = deepcopy(frames_data)
488
+
489
+ for f_idx in range(min(n_frames, len(out))):
490
+ poses = out[f_idx].get("poses", [])
491
+ if not poses:
492
+ continue
493
+ kps = poses[0].get("keypoints", [])
494
+ kp_map = {kp.get("name"): kp for kp in kps}
495
+ for j_idx, name in enumerate(joint_names):
496
+ kp = kp_map.get(name)
497
+ if kp is None:
498
+ continue
499
+ if not np.isnan(arr[f_idx, j_idx, 0]):
500
+ kp["x"] = float(arr[f_idx, j_idx, 0])
501
+ if not np.isnan(arr[f_idx, j_idx, 1]):
502
+ kp["y"] = float(arr[f_idx, j_idx, 1])
503
+ # confidence deliberately kept from original
504
+
505
+ return out
506
+
507
+ @staticmethod
508
+ def array_to_dataframe(
509
+ arr: np.ndarray,
510
+ joint_names: Optional[List[str]] = None,
511
+ frame_numbers: Optional[Sequence[int]] = None,
512
+ ) -> "pd.DataFrame":
513
+ """
514
+ Convert ``(F, J, 3)`` array to a DataFrame compatible with
515
+ A11 visualisation tools (columns ``<joint>_x``, ``<joint>_y``,
516
+ optionally ``<joint>_z`` and a ``FrameNo`` column).
517
+ """
518
+ import pandas as pd
519
+
520
+ if joint_names is None:
521
+ joint_names = COCO_KEYPOINTS
522
+
523
+ arr = _validate_array(arr)
524
+ n_frames, n_joints, _ = arr.shape
525
+
526
+ data: Dict[str, List[float]] = {}
527
+ for j_idx, name in enumerate(joint_names):
528
+ data[f"{name}_x"] = arr[:, j_idx, 0].tolist()
529
+ data[f"{name}_y"] = arr[:, j_idx, 1].tolist()
530
+
531
+ if frame_numbers is None:
532
+ data["FrameNo"] = list(range(n_frames))
533
+ else:
534
+ data["FrameNo"] = list(frame_numbers)
535
+
536
+ return pd.DataFrame(data)
537
+
538
+ def fit_transform(self, arr: np.ndarray) -> np.ndarray:
539
+ """
540
+ Run the full smoothing pipeline on one array.
541
+
542
+ Parameters
543
+ ----------
544
+ arr : (F, J, 3) ndarray
545
+ Raw coordinates + confidence.
546
+
547
+ Returns
548
+ -------
549
+ (F, J, 3) ndarray
550
+ Smoothed coordinates. The confidence channel is passed
551
+ through unchanged (it is used internally for filtering).
552
+ """
553
+ arr = _validate_array(arr)
554
+ self._n_frames, self._n_joints, _ = arr.shape
555
+
556
+ # Always mask low-confidence points before any processing
557
+ arr = self._mask_low_confidence(arr)
558
+
559
+ if self.strategy == SmoothingStrategy.KALMAN:
560
+ smoothed = self._apply_kalman(arr)
561
+ elif self.strategy == SmoothingStrategy.EXPONENTIAL:
562
+ smoothed = self._apply_ema(arr)
563
+ elif self.strategy == SmoothingStrategy.SPLINE:
564
+ smoothed = self._apply_spline(arr)
565
+ elif self.strategy == SmoothingStrategy.HYBRID:
566
+ smoothed = self._apply_hybrid(arr)
567
+ else:
568
+ # scipy-based windowed filters
569
+ smoothed = self._apply_windowed(arr)
570
+
571
+ return smoothed
572
+
573
+ def fit_transform_frames(
574
+ self,
575
+ frames_data: List[Dict[str, Any]],
576
+ joint_names: Optional[List[str]] = None,
577
+ ) -> List[Dict[str, Any]]:
578
+ """
579
+ High-level convenience: accept app‑style frame dicts, return
580
+ smoothed frame dicts.
581
+
582
+ Parameters
583
+ ----------
584
+ frames_data : list of dict
585
+ Raw per-frame keypoint dicts.
586
+ joint_names : list of str, optional
587
+ Ordered joint names.
588
+
589
+ Returns
590
+ -------
591
+ list of dict
592
+ Deep-copied frame dicts with smoothed coordinates.
593
+ """
594
+ if joint_names is None:
595
+ joint_names = COCO_KEYPOINTS
596
+ self._joint_names = list(joint_names)
597
+
598
+ arr = self.keypoints_to_array(frames_data, joint_names)
599
+ smoothed = self.fit_transform(arr)
600
+ return self.array_to_keypoints(smoothed, frames_data, joint_names)
601
+
602
+ # --- internal steps ------------------------------------------------
603
+
604
+ def _mask_low_confidence(self, arr: np.ndarray) -> np.ndarray:
605
+ """Set coordinates to NaN where confidence < *min_confidence*."""
606
+ arr = arr.copy()
607
+ conf = arr[:, :, 2]
608
+ low = conf < self.min_confidence
609
+ arr[low, 0] = np.nan
610
+ arr[low, 1] = np.nan
611
+ return arr
612
+
613
+ def _fill_missing(self, signal_1d: np.ndarray) -> np.ndarray:
614
+ """
615
+ Fill NaN values in a 1-D signal.
616
+
617
+ Returns a copy with NaNs replaced according to *fill_method*.
618
+ """
619
+ s = np.asarray(signal_1d, dtype=np.float64).copy()
620
+ n = len(s)
621
+ valid = np.isfinite(s)
622
+
623
+ if valid.all():
624
+ return s
625
+
626
+ if self.fill_method == "forward":
627
+ # Forward fill
628
+ last = np.nan
629
+ for i in range(n):
630
+ if np.isfinite(s[i]):
631
+ last = s[i]
632
+ elif not np.isnan(last):
633
+ s[i] = last
634
+ # Backward fill for leading NaN
635
+ first = np.nan
636
+ for i in range(n - 1, -1, -1):
637
+ if np.isfinite(s[i]):
638
+ first = s[i]
639
+ elif not np.isnan(first):
640
+ s[i] = first
641
+ return s
642
+
643
+ if self.fill_method == "spline":
644
+ _ensure_scipy("spline fill")
645
+ idx = np.arange(n)
646
+ if valid.sum() < 3:
647
+ # Not enough points for cubic spline → fall back to linear
648
+ return self._fill_linear(s, idx, valid)
649
+ try:
650
+ spl = _scipy_interpolate.UnivariateSpline(
651
+ idx[valid], s[valid], s=0, ext="const"
652
+ )
653
+ s[~valid] = spl(idx[~valid])
654
+ except Exception:
655
+ s = self._fill_linear(s, idx, valid)
656
+ return s
657
+
658
+ # Default: linear
659
+ idx = np.arange(n)
660
+ return self._fill_linear(s, idx, valid)
661
+
662
+ @staticmethod
663
+ def _fill_linear(s: np.ndarray, idx: np.ndarray, valid: np.ndarray) -> np.ndarray:
664
+ """Linear interpolation (also handles edge extrapolation)."""
665
+ n = len(s)
666
+ s_filled = s.copy()
667
+ if valid.sum() >= 2:
668
+ s_filled[~valid] = np.interp(idx[~valid], idx[valid], s[valid])
669
+ elif valid.sum() == 1:
670
+ s_filled[~valid] = s[valid][0]
671
+ else:
672
+ s_filled[:] = 0.0
673
+ return s_filled
674
+
675
+ # --- strategy implementations --------------------------------------
676
+
677
+ def _apply_windowed(self, arr: np.ndarray) -> np.ndarray:
678
+ """scipy-based sliding-window filters."""
679
+ _ensure_scipy(self.strategy.value)
680
+ result = arr.copy()
681
+ ws = self._effective_window()
682
+
683
+ for j in range(self._n_joints):
684
+ for c in [0, 1]: # x, y
685
+ sig = result[:, j, c]
686
+ valid = np.isfinite(sig)
687
+ if not valid.any():
688
+ continue
689
+
690
+ # Fill gaps first
691
+ sig_filled = self._fill_missing(sig)
692
+
693
+ if self.strategy == SmoothingStrategy.MOVING_AVERAGE:
694
+ kernel = np.ones(ws) / ws
695
+ smoothed = np.convolve(sig_filled, kernel, mode="same")
696
+ elif self.strategy == SmoothingStrategy.GAUSSIAN:
697
+ # Create Gaussian kernel
698
+ ax = np.arange(-(ws // 2), ws // 2 + 1)
699
+ kernel = np.exp(-0.5 * (ax / self.sigma) ** 2)
700
+ kernel /= kernel.sum()
701
+ smoothed = np.convolve(sig_filled, kernel, mode="same")
702
+ elif self.strategy == SmoothingStrategy.MEDIAN:
703
+ smoothed = ndimage.median_filter(sig_filled, size=ws)
704
+ elif self.strategy == SmoothingStrategy.SAVITZKY_GOLAY:
705
+ if ws >= len(sig_filled):
706
+ ws = len(sig_filled) if len(sig_filled) % 2 == 1 else len(sig_filled) - 1
707
+ if ws <= self.poly_order:
708
+ ws = self.poly_order + 2
709
+ if ws % 2 == 0:
710
+ ws += 1
711
+ try:
712
+ smoothed = _scipy_signal.savgol_filter(
713
+ sig_filled, ws, self.poly_order, mode="nearest"
714
+ )
715
+ except Exception:
716
+ smoothed = sig_filled
717
+ else:
718
+ smoothed = sig_filled
719
+
720
+ # Restore original NaN positions so downstream code
721
+ # knows which points were originally missing.
722
+ smoothed[~valid] = np.nan
723
+ result[:, j, c] = smoothed
724
+
725
+ return result
726
+
727
+ def _apply_ema(self, arr: np.ndarray) -> np.ndarray:
728
+ """Exponential moving average (online-capable)."""
729
+ result = arr.copy()
730
+ alpha = self.alpha
731
+ for j in range(self._n_joints):
732
+ for c in [0, 1]:
733
+ sig = arr[:, j, c]
734
+ out = np.empty_like(sig)
735
+ ema = np.nan
736
+ for i in range(len(sig)):
737
+ if np.isfinite(sig[i]):
738
+ if np.isnan(ema):
739
+ ema = sig[i]
740
+ else:
741
+ ema = alpha * sig[i] + (1 - alpha) * ema
742
+ out[i] = ema
743
+ result[:, j, c] = out
744
+ return result
745
+
746
+ def _apply_kalman(self, arr: np.ndarray) -> np.ndarray:
747
+ """Per-joint, per-coordinate Kalman filter (forward pass)."""
748
+ result = arr.copy()
749
+ kf = KalmanFilter1D(self.process_noise, self.measurement_noise)
750
+ for j in range(self._n_joints):
751
+ for c in [0, 1]:
752
+ kf.reset()
753
+ # Initialise with first valid point (if any)
754
+ initialized = False
755
+ for i in range(self._n_frames):
756
+ val = arr[i, j, c]
757
+ if np.isfinite(val):
758
+ kf.reset(float(val))
759
+ initialized = True
760
+ break
761
+ if not initialized:
762
+ result[:, j, c] = np.nan
763
+ continue
764
+ result[0, j, c] = kf.position
765
+ for i in range(1, self._n_frames):
766
+ meas = arr[i, j, c]
767
+ pos = kf.update(
768
+ float(meas) if np.isfinite(meas) else None
769
+ )
770
+ result[i, j, c] = pos
771
+ return result
772
+
773
+ def _apply_spline(self, arr: np.ndarray) -> np.ndarray:
774
+ """
775
+ Fit a cubic smoothing spline through high-confidence points.
776
+
777
+ Low-confidence points are excluded from the fit and replaced
778
+ by the spline evaluation.
779
+ """
780
+ _ensure_scipy("spline")
781
+ result = arr.copy()
782
+ n = self._n_frames
783
+ idx = np.arange(n, dtype=np.float64)
784
+
785
+ for j in range(self._n_joints):
786
+ for c in [0, 1]:
787
+ sig = arr[:, j, c]
788
+ valid = np.isfinite(sig)
789
+ if valid.sum() < 3:
790
+ result[:, j, c] = self._fill_missing(sig)
791
+ continue
792
+
793
+ try:
794
+ spl = _scipy_interpolate.UnivariateSpline(
795
+ idx[valid], sig[valid], s=len(valid) * 0.5
796
+ )
797
+ result[:, j, c] = spl(idx)
798
+ except Exception:
799
+ result[:, j, c] = self._fill_missing(sig)
800
+
801
+ return result
802
+
803
+ def _apply_hybrid(self, arr: np.ndarray) -> np.ndarray:
804
+ """
805
+ Hybrid pipeline:
806
+
807
+ 1. Detect positional outliers (velocity or z-score).
808
+ 2. Mask outliers + low-confidence points → NaN.
809
+ 3. Interpolate NaN gaps.
810
+ 4. Apply Savitzky-Golay smoothing.
811
+ """
812
+ result = arr.copy()
813
+
814
+ for j in range(self._n_joints):
815
+ for c in [0, 1]:
816
+ sig = arr[:, j, c]
817
+ conf = arr[:, j, 2]
818
+
819
+ # Step 1 – outlier detection
820
+ if self.outlier_method == "zscore":
821
+ outlier = detect_outliers_zscore(
822
+ sig, self.outlier_threshold, self.min_confidence, conf
823
+ )
824
+ else:
825
+ outlier = detect_outliers_velocity(
826
+ sig, self.outlier_threshold, self.min_confidence, conf
827
+ )
828
+
829
+ # Step 2 – mask
830
+ sig_clean = sig.copy()
831
+ sig_clean[outlier] = np.nan
832
+ # low-confidence already masked by _mask_low_confidence
833
+
834
+ # Step 3 – interpolate
835
+ sig_filled = self._fill_missing(sig_clean)
836
+
837
+ # Step 4 – Savitzky-Golay
838
+ _ensure_scipy("savitzky_golay")
839
+ ws = self._effective_window()
840
+ if ws >= len(sig_filled):
841
+ ws = len(sig_filled) if len(sig_filled) % 2 == 1 else len(sig_filled) - 1
842
+ if ws <= self.poly_order:
843
+ ws = self.poly_order + 2
844
+ if ws % 2 == 0:
845
+ ws += 1
846
+ try:
847
+ smoothed = _scipy_signal.savgol_filter(
848
+ sig_filled, ws, self.poly_order, mode="nearest"
849
+ )
850
+ except Exception:
851
+ smoothed = sig_filled
852
+
853
+ # Restore NaN for originally completely missing frames
854
+ orig_missing = ~np.isfinite(sig) & ~outlier
855
+ smoothed[orig_missing] = np.nan
856
+ result[:, j, c] = smoothed
857
+
858
+ return result
859
+
860
+ def _effective_window(self) -> int:
861
+ """Clamp window size to available frames and ensure odd."""
862
+ ws = min(self.window_size, self._n_frames)
863
+ if ws % 2 == 0:
864
+ ws -= 1
865
+ return max(ws, 3)
866
+
867
+
868
+ # ===================================================================
869
+ # High-level convenience function
870
+ # ===================================================================
871
+
872
+
873
+ def smooth_pose_sequence(
874
+ frames_data: List[Dict[str, Any]],
875
+ strategy: Union[str, SmoothingStrategy] = SmoothingStrategy.HYBRID,
876
+ joint_names: Optional[List[str]] = None,
877
+ **kwargs: Any,
878
+ ) -> List[Dict[str, Any]]:
879
+ """
880
+ Smooth an entire pose sequence with a single call.
881
+
882
+ Parameters
883
+ ----------
884
+ frames_data : list of dict
885
+ Per-frame keypoint dicts in the format produced by
886
+ ``extract_joint_positions_from_movenet()`` in ``app.py``.
887
+ strategy : str or SmoothingStrategy
888
+ Smoothing strategy to use (default: ``"hybrid"``).
889
+ joint_names : list of str, optional
890
+ Ordered joint names (defaults to COCO 17).
891
+ **kwargs
892
+ Passed through to :class:`PoseInterpolator` (window_size,
893
+ alpha, outlier_threshold, …).
894
+
895
+ Returns
896
+ -------
897
+ list of dict
898
+ Deep copy of *frames_data* with smoothed (x, y) coordinates.
899
+
900
+ Examples
901
+ --------
902
+ >>> # Quick hybrid smoothing (recommended for animations)
903
+ >>> smoothed = smooth_pose_sequence(all_keypoints, strategy="hybrid")
904
+
905
+ >>> # Light EMA for near-real-time use
906
+ >>> smoothed = smooth_pose_sequence(all_keypoints, strategy="exponential",
907
+ ... alpha=0.15)
908
+
909
+ >>> # Strong outlier removal for noisy recordings
910
+ >>> smoothed = smooth_pose_sequence(all_keypoints, strategy="hybrid",
911
+ ... outlier_method="zscore",
912
+ ... outlier_threshold=2.5,
913
+ ... window_size=7)
914
+ """
915
+ interpolator = PoseInterpolator(strategy=strategy, **kwargs)
916
+ return interpolator.fit_transform_frames(frames_data, joint_names=joint_names)
917
+
918
+
919
+ # ===================================================================
920
+ # Smoke test (runs when module is executed directly)
921
+ # ===================================================================
922
+
923
+ if __name__ == "__main__":
924
+ # Generate a synthetic trajectory with gaps and spikes
925
+ np.random.seed(42)
926
+ n_frames = 100
927
+ n_joints = 3 # nose, left_shoulder, right_shoulder
928
+ t = np.linspace(0, 4 * np.pi, n_frames)
929
+
930
+ # Ground truth – smooth sinusoid
931
+ true_x = np.sin(t) * 0.3 + 0.5
932
+ true_y = np.cos(t) * 0.2 + 0.5
933
+
934
+ # Build array: (F, J, 3)
935
+ raw = np.zeros((n_frames, n_joints, 3), dtype=np.float64)
936
+ for j in range(n_joints):
937
+ raw[:, j, 0] = true_x + np.random.randn(n_frames) * 0.02
938
+ raw[:, j, 1] = true_y + np.random.randn(n_frames) * 0.02
939
+ raw[:, j, 2] = 0.9 # high confidence
940
+
941
+ # Inject outliers
942
+ raw[20, 0, 0] += 0.4 # spike
943
+ raw[50, 0, 1] -= 0.3
944
+ raw[75, 0, 0] += 0.5
945
+
946
+ # Inject gaps
947
+ raw[40:45, 1, :] = np.nan
948
+ raw[60:65, 1, :] = np.nan
949
+ raw[80, 1, :] = np.nan
950
+
951
+ # --- Test each strategy -------------------------------------------
952
+ strategies = [
953
+ SmoothingStrategy.HYBRID,
954
+ SmoothingStrategy.MOVING_AVERAGE,
955
+ SmoothingStrategy.GAUSSIAN,
956
+ SmoothingStrategy.EXPONENTIAL,
957
+ SmoothingStrategy.MEDIAN,
958
+ SmoothingStrategy.SAVITZKY_GOLAY,
959
+ SmoothingStrategy.KALMAN,
960
+ SmoothingStrategy.SPLINE,
961
+ ]
962
+
963
+ print(f"{'Strategy':<22s} {'MAE (x)':>10s} {'MAE (y)':>10s}")
964
+ print("-" * 44)
965
+
966
+ for strat in strategies:
967
+ interp = PoseInterpolator(strategy=strat)
968
+ smoothed = interp.fit_transform(raw.copy())
969
+
970
+ # Mean absolute error against ground truth (only first joint)
971
+ mae_x = np.nanmean(np.abs(smoothed[:, 0, 0] - true_x))
972
+ mae_y = np.nanmean(np.abs(smoothed[:, 0, 1] - true_y))
973
+ print(f"{strat.value:<22s} {mae_x:10.6f} {mae_y:10.6f}")
974
+
975
+ # --- Test high-level convenience ----------------------------------
976
+ frames_data = PoseInterpolator.array_to_keypoints(
977
+ raw,
978
+ [
979
+ {
980
+ "poses": [
981
+ {
982
+ "pose_id": 0,
983
+ "keypoints": [
984
+ {
985
+ "x": raw[i, j, 0],
986
+ "y": raw[i, j, 1],
987
+ "score": raw[i, j, 2],
988
+ "name": COCO_KEYPOINTS[j],
989
+ }
990
+ for j in range(n_joints)
991
+ ],
992
+ }
993
+ ],
994
+ "frame_id": i,
995
+ }
996
+ for i in range(n_frames)
997
+ ],
998
+ joint_names=COCO_KEYPOINTS[:n_joints],
999
+ )
1000
+
1001
+ smoothed_frames = smooth_pose_sequence(frames_data, strategy="hybrid")
1002
+ print(f"\nHigh-level convenience: processed {len(smoothed_frames)} frames ✓")
1003
+
1004
+ # Convert to DataFrame for A11 compatibility
1005
+ df = PoseInterpolator.array_to_dataframe(
1006
+ raw, joint_names=COCO_KEYPOINTS[:n_joints]
1007
+ )
1008
+ print(f"DataFrame conversion: {df.shape} ✓")
1009
+ print("\nAll tests passed.")
app.py CHANGED
@@ -1,6 +1,7 @@
1
  from PIL import Image
2
  import gradio as gr
3
  from A8.pose_estimator import MoveNetPoseEstimator
 
4
  import json
5
  import csv
6
  import os
@@ -211,6 +212,8 @@ def process_and_display(image: Image.Image, confidence_threshold: float = 0.3) -
211
  def process_webcam_video(
212
  video_path: str,
213
  confidence_threshold: float = 0.3,
 
 
214
  progress=gr.Progress()
215
  ) -> tuple:
216
  """Process uploaded video with pose estimation."""
@@ -292,13 +295,28 @@ def process_webcam_video(
292
 
293
  print(f"Total frames processed: {frame_count}")
294
 
295
- # Save keypoints to CSV
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
296
  csv_path = os.path.join("pose_outputs", f"video_keypoints_{timestamp}.csv")
297
  with open(csv_path, 'w', newline='') as csvfile:
298
  writer = csv.writer(csvfile)
299
  writer.writerow(["Frame_ID", "Joint", "X", "Y", "Confidence", "Visible"])
300
 
301
- for frame_data in all_keypoints:
302
  frame_id = frame_data.get('frame_id', 0)
303
  for kp in frame_data['poses'][0]['keypoints']:
304
  x = kp.get('x')
@@ -374,6 +392,16 @@ with gr.Blocks(title="MoveNet Pose Estimation") as demo:
374
  step=0.05,
375
  label="Confidence Threshold"
376
  )
 
 
 
 
 
 
 
 
 
 
377
  process_video_btn = gr.Button("🎬 Process Video", variant="primary")
378
 
379
  with gr.Column():
@@ -383,7 +411,7 @@ with gr.Blocks(title="MoveNet Pose Estimation") as demo:
383
 
384
  process_video_btn.click(
385
  fn=process_webcam_video,
386
- inputs=[video_input, video_confidence],
387
  outputs=[video_output, video_result]
388
  )
389
 
 
1
  from PIL import Image
2
  import gradio as gr
3
  from A8.pose_estimator import MoveNetPoseEstimator
4
+ from A12.pose_interpolator import smooth_pose_sequence
5
  import json
6
  import csv
7
  import os
 
212
  def process_webcam_video(
213
  video_path: str,
214
  confidence_threshold: float = 0.3,
215
+ smoothing_strategy: str = "exponential",
216
+ smoothing_method: str = "zscore",
217
  progress=gr.Progress()
218
  ) -> tuple:
219
  """Process uploaded video with pose estimation."""
 
295
 
296
  print(f"Total frames processed: {frame_count}")
297
 
298
+ # Apply smoothing to the keypoints
299
+ try:
300
+ smoothed_keypoints = smooth_pose_sequence(
301
+ all_keypoints,
302
+ strategy=smoothing_strategy,
303
+ outlier_method=smoothing_method,
304
+ outlier_threshold=3.0,
305
+ window_size=7,
306
+ min_confidence=0.2,
307
+ )
308
+ except Exception as e:
309
+ print(f"Error applying smoothing: {e}")
310
+ # Fallback to original keypoints if smoothing fails
311
+ smoothed_keypoints = all_keypoints
312
+
313
+ # Save smoothed keypoints to CSV
314
  csv_path = os.path.join("pose_outputs", f"video_keypoints_{timestamp}.csv")
315
  with open(csv_path, 'w', newline='') as csvfile:
316
  writer = csv.writer(csvfile)
317
  writer.writerow(["Frame_ID", "Joint", "X", "Y", "Confidence", "Visible"])
318
 
319
+ for frame_data in smoothed_keypoints:
320
  frame_id = frame_data.get('frame_id', 0)
321
  for kp in frame_data['poses'][0]['keypoints']:
322
  x = kp.get('x')
 
392
  step=0.05,
393
  label="Confidence Threshold"
394
  )
395
+ smoothing_strategy = gr.Dropdown(
396
+ choices=["exponential", "moving_average", "gaussian", "median", "savitzky_golay", "kalman", "spline", "hybrid"],
397
+ value="exponential",
398
+ label="Smoothing Strategy"
399
+ )
400
+ smoothing_method = gr.Dropdown(
401
+ choices=["zscore", "velocity", "none"],
402
+ value="zscore",
403
+ label="Outlier Detection Method"
404
+ )
405
  process_video_btn = gr.Button("🎬 Process Video", variant="primary")
406
 
407
  with gr.Column():
 
411
 
412
  process_video_btn.click(
413
  fn=process_webcam_video,
414
+ inputs=[video_input, video_confidence, smoothing_strategy, smoothing_method],
415
  outputs=[video_output, video_result]
416
  )
417