Skip to content

Commit 0f87455

Browse files
Extend smoothness functionality as raised in #66
1 parent ee37a2d commit 0f87455

2 files changed

Lines changed: 120 additions & 26 deletions

File tree

pyeyesweb/low_level/smoothness.py

Lines changed: 68 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
"""Movement smoothness analysis module.
22
3-
This module provides tools for quantifying the smoothness of movement velocity signals
3+
This module provides tools for quantifying the smoothness of movement signals
44
using multiple metrics including SPARC (Spectral Arc Length) and Jerk RMS.
55
Designed for real-time analysis of motion capture or sensor data.
66
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).
7+
The module can accept either position or velocity signals as input. When position
8+
is provided, velocity is automatically computed for SPARC analysis.
99
1010
Smoothness metrics are important indicators of movement quality in:
1111
1. Motor control assessment
@@ -18,17 +18,19 @@
1818

1919
from pyeyesweb.data_models.sliding_window import SlidingWindow
2020
from pyeyesweb.utils.signal_processing import apply_savgol_filter
21-
from pyeyesweb.utils.math_utils import compute_sparc, compute_jerk_rms, normalize_signal
21+
from pyeyesweb.utils.math_utils import (
22+
compute_sparc, compute_jerk_rms, normalize_signal, extract_velocity_from_position
23+
)
2224
from pyeyesweb.utils.validators import validate_numeric, validate_boolean
2325

2426

2527
class Smoothness:
26-
"""Compute movement smoothness metrics from velocity signal data.
28+
"""Compute movement smoothness metrics from position or velocity signal data.
2729
2830
This class analyzes movement smoothness using SPARC (Spectral Arc Length)
29-
and Jerk RMS metrics. This class expects velocity signals as input,
30-
not position signals. It can optionally apply Savitzky-Golay filtering
31-
to reduce noise before analysis.
31+
and Jerk RMS metrics. It can accept either position or velocity signals as input.
32+
When position is provided, velocity is automatically computed. It can optionally
33+
apply Savitzky-Golay filtering to reduce noise before analysis.
3234
3335
SPARC implementation is based on Balasubramanian et al. (2015) "On the analysis
3436
of movement smoothness" from Journal of NeuroEngineering and Rehabilitation.
@@ -44,26 +46,31 @@ class Smoothness:
4446
Sampling rate of the signal in Hz (default: 50.0).
4547
use_filter : bool, optional
4648
Whether to apply Savitzky-Golay filtering before analysis (default: True).
49+
signal_type : {'velocity', 'position'}, optional
50+
Type of input signal (default: 'velocity').
51+
- 'velocity': Direct velocity input
52+
- 'position': Position input (x, y coordinates or 1D position)
4753
4854
Attributes
4955
----------
5056
rate_hz : float
5157
Signal sampling rate.
5258
use_filter : bool
5359
Filter application flag.
60+
signal_type : str
61+
Type of input signal ('velocity' or 'position').
5462
5563
Examples
5664
--------
5765
>>> from pyeyesweb.low_level.smoothness import Smoothness
5866
>>> from pyeyesweb.data_models.sliding_window import SlidingWindow
5967
>>> import numpy as np
6068
>>>
61-
>>> # Generate sample velocity data
62-
>>> # For example, from tracking hand movement: velocity = sqrt(dx^2 + dy^2) / dt
69+
>>> # Example 1: Using velocity data (traditional approach)
6370
>>> t = np.linspace(0, 2, 200)
6471
>>> velocity_data = np.sin(2 * np.pi * t) + 0.1 * np.random.randn(200)
6572
>>>
66-
>>> smooth = Smoothness(rate_hz=100.0, use_filter=True)
73+
>>> smooth = Smoothness(rate_hz=100.0, use_filter=True, signal_type='velocity')
6774
>>> window = SlidingWindow(max_length=200, n_columns=1)
6875
>>>
6976
>>> # Add velocity data to the window
@@ -72,6 +79,22 @@ class Smoothness:
7279
>>>
7380
>>> result = smooth(window)
7481
>>> print(f"SPARC: {result['sparc']:.3f}, Jerk RMS: {result['jerk_rms']:.3f}")
82+
>>>
83+
>>> # Example 2: Using position data
84+
>>> # 2D position data (x, y coordinates)
85+
>>> t = np.linspace(0, 2, 200)
86+
>>> x_positions = 10 * np.sin(2 * np.pi * t)
87+
>>> y_positions = 5 * np.cos(2 * np.pi * t)
88+
>>>
89+
>>> smooth_pos = Smoothness(rate_hz=100.0, use_filter=True, signal_type='position')
90+
>>> window_pos = SlidingWindow(max_length=200, n_columns=2)
91+
>>>
92+
>>> # Add position data to the window
93+
>>> for x, y in zip(x_positions, y_positions):
94+
... window_pos.append([x, y])
95+
>>>
96+
>>> result = smooth_pos(window_pos)
97+
>>> print(f"SPARC: {result['sparc']:.3f}, Jerk RMS: {result['jerk_rms']:.3f}")
7598
7699
Notes
77100
-----
@@ -86,10 +109,15 @@ class Smoothness:
86109
12(1), 1-11.
87110
"""
88111

89-
def __init__(self, rate_hz=50.0, use_filter=True):
112+
def __init__(self, rate_hz=50.0, use_filter=True, signal_type='velocity'):
90113
self.rate_hz = validate_numeric(rate_hz, 'rate_hz', min_val=0.01, max_val=100000)
91114
self.use_filter = validate_boolean(use_filter, 'use_filter')
92115

116+
# Validate signal_type
117+
if signal_type not in ['velocity', 'position']:
118+
raise ValueError(f"signal_type must be 'velocity' or 'position', got '{signal_type}'")
119+
self.signal_type = signal_type
120+
93121
def _filter_signal(self, signal):
94122
"""Apply Savitzky-Golay filter if enabled and enough data.
95123
@@ -108,37 +136,52 @@ def _filter_signal(self, signal):
108136
return apply_savgol_filter(signal, self.rate_hz)
109137

110138
def __call__(self, sliding_window: SlidingWindow):
111-
"""Compute smoothness metrics from windowed velocity signal data.
139+
"""Compute smoothness metrics from windowed signal data.
112140
113141
Parameters
114142
----------
115143
sliding_window : SlidingWindow
116-
Buffer containing velocity signal data to analyze.
117-
Input should be velocity values, not position values.
144+
Buffer containing signal data to analyze.
145+
- If signal_type='velocity': expects velocity values
146+
- If signal_type='position': expects position values (1D or 2D)
118147
119148
Returns
120149
-------
121150
dict
122151
Dictionary containing:
123152
- 'sparc': Spectral Arc Length (closer to 0 = smoother).
124153
Returns NaN if insufficient data.
125-
- 'jerk_rms': RMS of jerk (computed from velocity input).
154+
- 'jerk_rms': RMS of jerk.
126155
Returns NaN if insufficient data.
127156
"""
128157
if len(sliding_window) < 5:
129158
return {"sparc": np.nan, "jerk_rms": np.nan}
130159

131160
signal, _ = sliding_window.to_array()
132161

133-
# If multi-channel, compute for first channel only
134-
if signal.ndim > 1 and signal.shape[1] > 1:
135-
signal = signal[:, 0]
136-
137-
filtered = self._filter_signal(signal.squeeze())
138-
normalized = normalize_signal(filtered)
139-
162+
# Extract or compute velocity based on signal type
163+
if self.signal_type == 'position':
164+
# For position: compute velocity for SPARC
165+
velocity = extract_velocity_from_position(signal, self.rate_hz)
166+
# For jerk: use first dimension of position
167+
signal_for_jerk = signal[:, 0] if signal.ndim > 1 else signal.squeeze()
168+
else: # self.signal_type == 'velocity'
169+
# For velocity: use directly
170+
if signal.ndim > 1 and signal.shape[1] > 1:
171+
velocity = signal[:, 0]
172+
else:
173+
velocity = signal.squeeze()
174+
signal_for_jerk = velocity
175+
176+
# Apply filtering to velocity for SPARC
177+
filtered_velocity = self._filter_signal(velocity)
178+
normalized = normalize_signal(filtered_velocity)
179+
180+
# Compute SPARC (always uses velocity)
140181
sparc = compute_sparc(normalized, 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')
182+
183+
# Compute jerk with appropriate signal type
184+
filtered_for_jerk = self._filter_signal(signal_for_jerk)
185+
jerk = compute_jerk_rms(filtered_for_jerk, self.rate_hz, signal_type=self.signal_type)
143186

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

pyeyesweb/utils/math_utils.py

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -203,4 +203,55 @@ def normalize_signal(signal):
203203
Returns original signal if max absolute value is 0.
204204
"""
205205
max_val = np.max(np.abs(signal))
206-
return signal / max_val if max_val != 0 else signal
206+
return signal / max_val if max_val != 0 else signal
207+
208+
209+
def extract_velocity_from_position(position, rate_hz=50.0):
210+
"""Extract velocity from position data.
211+
212+
Computes velocity magnitude from position data of any dimensionality.
213+
For 1D position, returns absolute velocity. For multi-dimensional position,
214+
returns the Euclidean norm of the velocity vector.
215+
216+
Parameters
217+
----------
218+
position : ndarray
219+
Position data. Can be:
220+
- 1D array: single position coordinate
221+
- 2D array with shape (n_samples, n_dims): multi-dimensional positions
222+
rate_hz : float, optional
223+
Sampling rate in Hz (default: 50.0).
224+
225+
Returns
226+
-------
227+
ndarray
228+
1D array of velocity magnitudes.
229+
230+
Examples
231+
--------
232+
>>> # 1D position data
233+
>>> position_1d = np.array([0, 1, 2, 3, 4])
234+
>>> velocity = extract_velocity_from_position(position_1d, rate_hz=100)
235+
236+
>>> # 2D position data (x, y coordinates)
237+
>>> position_2d = np.array([[0, 0], [1, 0], [1, 1], [2, 1]])
238+
>>> velocity = extract_velocity_from_position(position_2d, rate_hz=100)
239+
"""
240+
rate_hz = validate_numeric(rate_hz, 'rate_hz', min_val=0.0001)
241+
dt = 1.0 / rate_hz
242+
243+
position = np.asarray(position)
244+
245+
# Handle 1D position
246+
if position.ndim == 1 or (position.ndim == 2 and position.shape[1] == 1):
247+
signal_1d = position.squeeze()
248+
return np.abs(np.gradient(signal_1d, dt))
249+
250+
# Handle multi-dimensional position
251+
if position.ndim == 2:
252+
# Compute derivatives along time axis (axis=0)
253+
derivatives = np.gradient(position, dt, axis=0)
254+
# Return Euclidean norm of velocity vector
255+
return np.linalg.norm(derivatives, axis=1)
256+
257+
raise ValueError(f"Position must be 1D or 2D array, got shape {position.shape}")

0 commit comments

Comments
 (0)