Spaces:
Running
Running
Bachstelze commited on
Commit ·
ffdf1ae
1
Parent(s): 0f97f67
add smoothing
Browse files- A12/__init__.py +63 -0
- A12/pose_interpolator.py +1009 -0
- 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 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
|