Skip to content

Commit ee37a2d

Browse files
Clarify velocity input requirement for smoothness module and improve jerk computation
1 parent 487451c commit ee37a2d

3 files changed

Lines changed: 76 additions & 31 deletions

File tree

examples/smoothness.py

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,23 @@ def extract_wrist_xy(results, keypoint_idx, width, height):
2222
return None
2323

2424
def main():
25+
"""
26+
Example demonstrating smoothness analysis of hand movement.
27+
28+
This script:
29+
1. Tracks hand position using MediaPipe
30+
2. Computes velocity from position changes (dx, dy)
31+
3. Feeds velocity to the Smoothness analyzer
32+
4. Outputs SPARC and Jerk RMS metrics in real-time
33+
34+
Note: The Smoothness module expects velocity signal.
35+
"""
2536
mp_pose = mp.solutions.pose
2637
pose = mp_pose.Pose(min_detection_confidence=0.5, min_tracking_confidence=0.5)
2738

2839
# Enable filter to stabilize signal
2940
smoother = Smoothness(rate_hz=30, use_filter=True)
30-
sliding_window = SlidingWindow(50, 1)
41+
sliding_window = SlidingWindow(50, 1) # Store velocity values
3142
cap = cv2.VideoCapture(0)
3243

3344
LEFT_WRIST_IDX = 15
@@ -53,16 +64,23 @@ def main():
5364
if xy and prev_xy and prev_time:
5465
dt = now - prev_time
5566
if dt > 0:
67+
# Compute position changes
5668
dx = xy[0] - prev_xy[0]
5769
dy = xy[1] - prev_xy[1]
58-
velocity = np.sqrt(dx**2 + dy**2) / dt
70+
71+
# IMPORTANT: Smoothness module expects VELOCITY as input
72+
# We compute velocity magnitude from position changes
73+
velocity = np.sqrt(dx**2 + dy**2) / dt # pixels/second
5974

6075
# Clamp unrealistic velocity spikes (in pixels/sec)
6176
if velocity < 1000:
77+
# Feed velocity (not position!) to the smoothness analyzer
6278
sliding_window.append([velocity])
63-
sparc, jerk = smoother(sliding_window)
64-
if sparc is not None:
65-
print(f"SPARC: {sparc:.3f}, Jerk RMS: {jerk:.1f}")
79+
80+
# Get smoothness metrics (SPARC and Jerk RMS)
81+
result = smoother(sliding_window)
82+
if not np.isnan(result['sparc']):
83+
print(f"SPARC: {result['sparc']:.3f}, Jerk RMS: {result['jerk_rms']:.1f}")
6684

6785
prev_xy = xy
6886
prev_time = now

pyeyesweb/low_level/smoothness.py

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
"""Movement smoothness analysis module.
22
3-
This module provides tools for quantifying the smoothness of movement signals
3+
This module provides tools for quantifying the smoothness of movement velocity signals
44
using multiple metrics including SPARC (Spectral Arc Length) and Jerk RMS.
5-
Designed for real time analysis of motion capture or sensor data.
5+
Designed for real-time analysis of motion capture or sensor data.
6+
7+
IMPORTANT: This module expects velocity signals as input, not position signals.
8+
Velocity should be computed from position data before analysis (for example velocity = sqrt(dx^2 + dy^2) / dt).
69
710
Smoothness metrics are important indicators of movement quality in:
811
1. Motor control assessment
@@ -20,10 +23,11 @@
2023

2124

2225
class Smoothness:
23-
"""Compute movement smoothness metrics from signal data.
26+
"""Compute movement smoothness metrics from velocity signal data.
2427
2528
This class analyzes movement smoothness using SPARC (Spectral Arc Length)
26-
and Jerk RMS metrics. It can optionally apply Savitzky-Golay filtering
29+
and Jerk RMS metrics. This class expects velocity signals as input,
30+
not position signals. It can optionally apply Savitzky-Golay filtering
2731
to reduce noise before analysis.
2832
2933
SPARC implementation is based on Balasubramanian et al. (2015) "On the analysis
@@ -54,16 +58,17 @@ class Smoothness:
5458
>>> from pyeyesweb.data_models.sliding_window import SlidingWindow
5559
>>> import numpy as np
5660
>>>
57-
>>> # Generate sample movement data (simulated velocity profile)
61+
>>> # Generate sample velocity data
62+
>>> # For example, from tracking hand movement: velocity = sqrt(dx^2 + dy^2) / dt
5863
>>> t = np.linspace(0, 2, 200)
59-
>>> movement_data = np.sin(2 * np.pi * t) + 0.1 * np.random.randn(200)
64+
>>> velocity_data = np.sin(2 * np.pi * t) + 0.1 * np.random.randn(200)
6065
>>>
6166
>>> smooth = Smoothness(rate_hz=100.0, use_filter=True)
6267
>>> window = SlidingWindow(max_length=200, n_columns=1)
6368
>>>
64-
>>> # Add movement data
65-
>>> for value in movement_data:
66-
... window.append([value])
69+
>>> # Add velocity data to the window
70+
>>> for velocity in velocity_data:
71+
... window.append([velocity])
6772
>>>
6873
>>> result = smooth(window)
6974
>>> print(f"SPARC: {result['sparc']:.3f}, Jerk RMS: {result['jerk_rms']:.3f}")
@@ -103,20 +108,21 @@ def _filter_signal(self, signal):
103108
return apply_savgol_filter(signal, self.rate_hz)
104109

105110
def __call__(self, sliding_window: SlidingWindow):
106-
"""Compute smoothness metrics from windowed signal data.
111+
"""Compute smoothness metrics from windowed velocity signal data.
107112
108113
Parameters
109114
----------
110115
sliding_window : SlidingWindow
111-
Buffer containing signal data to analyze.
116+
Buffer containing velocity signal data to analyze.
117+
Input should be velocity values, not position values.
112118
113119
Returns
114120
-------
115121
dict
116122
Dictionary containing:
117123
- 'sparc': Spectral Arc Length (closer to 0 = smoother).
118124
Returns NaN if insufficient data.
119-
- 'jerk_rms': RMS of jerk (third derivative).
125+
- 'jerk_rms': RMS of jerk (computed from velocity input).
120126
Returns NaN if insufficient data.
121127
"""
122128
if len(sliding_window) < 5:
@@ -132,6 +138,7 @@ def __call__(self, sliding_window: SlidingWindow):
132138
normalized = normalize_signal(filtered)
133139

134140
sparc = compute_sparc(normalized, self.rate_hz)
135-
jerk = compute_jerk_rms(filtered, self.rate_hz)
141+
# Note: Since the smoothness module expects velocity input (as shown in examples), we specify signal_type='velocity' to compute proper jerk
142+
jerk = compute_jerk_rms(filtered, self.rate_hz, signal_type='velocity')
136143

137144
return {"sparc": sparc, "jerk_rms": jerk}

pyeyesweb/utils/math_utils.py

Lines changed: 33 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -133,36 +133,56 @@ def compute_sparc(signal, rate_hz=50.0):
133133
return -arc
134134

135135

136-
def compute_jerk_rms(signal, rate_hz=50.0):
137-
"""Compute RMS of jerk (third derivative) from a signal.
136+
def compute_jerk_rms(signal, rate_hz=50.0, signal_type='velocity'):
137+
"""Compute RMS of jerk (rate of change of acceleration) from a signal.
138138
139-
Jerk is the rate of change of acceleration. Lower RMS jerk values
140-
indicate smoother movement.
139+
Jerk is the third derivative of position or the first derivative of acceleration. Lower RMS jerk values indicate smoother movement.
141140
142141
Parameters
143142
----------
144143
signal : ndarray
145-
1D movement signal (position or velocity).
144+
1D movement signal.
146145
rate_hz : float, optional
147146
Sampling rate in Hz (default: 50.0).
147+
signal_type : str, optional
148+
Type of input signal: 'position' or 'velocity' (default: 'velocity').
149+
- 'position': Computes third derivative to get jerk
150+
- 'velocity': Computes second derivative to get jerk
148151
149152
Returns
150153
-------
151154
float
152155
Root mean square of jerk.
153-
Returns NaN if signal has less than 2 samples.
156+
Returns NaN if signal has insufficient samples for the required derivatives.
154157
155158
Notes
156159
-----
157-
This implementation uses first-order finite differences to approximate
158-
the derivative. For position signals, this computes jerk directly.
160+
Uses numpy.gradient for smooth derivative approximation with central differences
161+
where possible, providing better accuracy than forward differences.
159162
"""
160-
if len(signal) < 2:
161-
return np.nan
162163
rate_hz = validate_numeric(rate_hz, 'rate_hz', min_val=0.0001)
163-
dt = 1.0 / rate_hz
164-
jerk = np.diff(signal) / dt
165-
return np.sqrt(np.mean(jerk ** 2))
164+
165+
# Define derivative orders needed for each signal type
166+
derivative_orders = {
167+
'velocity': 2, # if signal type is velocity we can get velocity -> acceleration -> jerk
168+
'position': 3 # if signal type is position we can get position -> velocity -> acceleration -> jerk
169+
}
170+
171+
if signal_type not in derivative_orders:
172+
raise ValueError(f"signal_type must be 'position' or 'velocity', got '{signal_type}'")
173+
174+
n_derivatives = derivative_orders[signal_type]
175+
min_samples = n_derivatives + 1
176+
177+
if len(signal) < min_samples:
178+
return np.nan
179+
180+
# Apply derivatives using numpy.gradient for better accuracy
181+
result = np.asarray(signal)
182+
for _ in range(n_derivatives):
183+
result = np.gradient(result, 1.0/rate_hz)
184+
185+
return np.sqrt(np.mean(result ** 2))
166186

167187

168188
def normalize_signal(signal):

0 commit comments

Comments
 (0)