actRecog / src /phyphox_pipeline.py
Fola-lad's picture
UI refinement
4747670
Raw
History Blame Contribute Delete
17 kB
"""Phyphox sensor pipeline for Human Activity Recognition.
Converts raw Phyphox accelerometer + gyroscope CSV exports into the
561-feature vector expected by the UCI HAR classifier.
Feature order matches features.txt exactly:
1-200 : time-domain 3-axis signals (5 signals Γ— 40 features)
201-265 : time-domain magnitudes (5 signals Γ— 13 features)
266-502 : frequency-domain 3-axis (3 signals Γ— 79 features)
503-554 : frequency-domain magnitudes (4 signals Γ— 13 features)
555-561 : angle features (7 features)
"""
import io
import numpy as np
import pandas as pd
from scipy import signal as sp_signal
from scipy.stats import skew, kurtosis as sp_kurtosis
FS = 50 # target sampling rate Hz
WINDOW = 128 # samples per window (2.56 s)
STEP = 64 # hop size β€” 50% overlap
AR_ORDER = 4 # Burg AR model order
# 14 frequency band pairs (1-indexed, inclusive) applied per axis
BANDS = [
(1, 8), (9, 16), (17, 24), (25, 32), (33, 40), (41, 48),
(49, 56), (57, 64), (1, 16), (17, 32), (33, 48), (49, 64),
(1, 24), (25, 48),
]
ACC_COLS = ["Time (s)", "X (m/s^2)", "Y (m/s^2)", "Z (m/s^2)"]
GYRO_COLS = ["Time (s)", "X (rad/s)", "Y (rad/s)", "Z (rad/s)"]
def _parse_csv(file_obj, expected_cols: list) -> pd.DataFrame:
"""Parse a Phyphox CSV export and validate required columns.
Args:
file_obj: file-like object (bytes or str) from Phyphox export
expected_cols: list of required column names
Returns:
DataFrame with numeric data, NaN rows dropped
Raises:
ValueError: if columns are missing or file cannot be parsed
"""
try:
raw = file_obj.read()
if isinstance(raw, bytes):
raw = raw.decode("utf-8")
df = pd.read_csv(io.StringIO(raw), float_precision="high")
except Exception as exc:
raise ValueError(f"Cannot parse CSV: {exc}") from exc
df.columns = [c.strip('"').strip() for c in df.columns]
missing = [c for c in expected_cols if c not in df.columns]
if missing:
raise ValueError(
f"Missing columns {missing}. Found: {list(df.columns)}. "
"Check you uploaded the correct Phyphox CSV (Accelerometer or Gyroscope)."
)
return df[expected_cols].apply(pd.to_numeric, errors="coerce").dropna()
def _butter_lp(data: np.ndarray, cutoff: float, fs: float = FS, order: int = 3) -> np.ndarray:
"""Zero-phase Butterworth low-pass filter applied along axis 0.
Args:
data: 1-D or 2-D array
cutoff: cutoff frequency in Hz
fs: sampling rate in Hz
order: filter order
Returns:
Filtered array, same shape as input
"""
b, a = sp_signal.butter(order, cutoff / (fs / 2.0), btype="low")
# Use maximum safe padding β€” critical for low cutoffs (e.g. 0.3 Hz needs
# ~167 samples to settle; scipy's default 9-sample pad is far too short).
padlen = min(len(data) - 1, max(3 * int(fs / cutoff), 9))
if data.ndim == 1:
return sp_signal.filtfilt(b, a, data, padlen=padlen)
return np.column_stack(
[sp_signal.filtfilt(b, a, data[:, i], padlen=padlen) for i in range(data.shape[1])]
)
def _median_filt(data: np.ndarray, k: int = 3) -> np.ndarray:
"""Median filter applied along axis 0.
Args:
data: 1-D or 2-D array
k: kernel size (must be odd)
Returns:
Filtered array, same shape as input
"""
if data.ndim == 1:
return sp_signal.medfilt(data, kernel_size=k)
return np.column_stack(
[sp_signal.medfilt(data[:, i], kernel_size=k) for i in range(data.shape[1])]
)
def _burg_ar(x: np.ndarray, order: int = AR_ORDER) -> np.ndarray:
"""Burg method autoregressive coefficients.
Implements the standard Burg recursion with Levinson-Durbin update.
Mean-centres the signal before fitting.
Args:
x: 1-D signal array
order: AR model order
Returns:
Array of `order` AR coefficients [a1, a2, ..., ap]
"""
x = np.asarray(x, dtype=np.float64)
x = x - x.mean()
N = len(x)
ef = x.copy()
eb = x.copy()
a = np.zeros(order)
for m in range(1, order + 1):
f = ef[m:].copy()
b = eb[m - 1: N - 1].copy()
denom = np.dot(f, f) + np.dot(b, b) + 1e-12
km = -2.0 * np.dot(f, b) / denom
# Levinson-Durbin update of AR polynomial
a_prev = a[:m - 1].copy()
for j in range(m - 1):
a[j] = a_prev[j] + km * a_prev[m - 2 - j]
a[m - 1] = km
# Update forward/backward prediction errors
ef[m:] = f + km * b
eb[m - 1: N - 1] = b + km * f
return a
def _entropy(x: np.ndarray) -> float:
"""Normalised signal entropy via absolute-value probability distribution.
Args:
x: 1-D array
Returns:
Entropy value (>= 0)
"""
total = np.abs(x).sum()
if total < 1e-12:
return 0.0
p = np.abs(x) / total
p = p[p > 0]
return float(-np.sum(p * np.log(p)))
def _bands_energy(fft_mag: np.ndarray) -> np.ndarray:
"""Energy in each of the 14 UCI HAR frequency bands (1-indexed, inclusive).
Args:
fft_mag: FFT magnitude array, must contain at least 64 values
Returns:
Array of 14 energy values
"""
m = fft_mag[:64]
return np.array([float(np.sum(m[s - 1: e] ** 2)) for s, e in BANDS])
def _safe_corr(a: np.ndarray, b: np.ndarray) -> float:
"""Pearson correlation, returns 0.0 if either signal is constant.
Args:
a: first 1-D array
b: second 1-D array
Returns:
Correlation coefficient in [-1, 1]
"""
if a.std() < 1e-10 or b.std() < 1e-10:
return 0.0
r = np.corrcoef(a, b)[0, 1]
return 0.0 if not np.isfinite(r) else float(r)
def _angle(u: np.ndarray, v: np.ndarray) -> float:
"""Angle in radians between two 3-D vectors.
Args:
u: first vector, shape (3,)
v: second vector, shape (3,)
Returns:
Angle in radians, or 0.0 if either vector is zero
"""
un, vn = np.linalg.norm(u), np.linalg.norm(v)
if un < 1e-10 or vn < 1e-10:
return 0.0
return float(np.arccos(np.clip(np.dot(u, v) / (un * vn), -1.0, 1.0)))
def _t3ax(sig: np.ndarray) -> np.ndarray:
"""40 time-domain features from a 3-axis signal (N, 3).
Order: meanΓ—3, stdΓ—3, madΓ—3, maxΓ—3, minΓ—3, sma,
energyΓ—3, iqrΓ—3, entropyΓ—3, arCoeffΓ—12, correlationΓ—3
"""
N = len(sig)
x, y, z = sig[:, 0], sig[:, 1], sig[:, 2]
out = []
out += [x.mean(), y.mean(), z.mean()]
out += [x.std(), y.std(), z.std()]
out += [
float(np.median(np.abs(x - np.median(x)))),
float(np.median(np.abs(y - np.median(y)))),
float(np.median(np.abs(z - np.median(z)))),
]
out += [x.max(), y.max(), z.max()]
out += [x.min(), y.min(), z.min()]
out += [float((np.abs(x) + np.abs(y) + np.abs(z)).sum() / N)] # sma
out += [float(np.sum(x ** 2) / N), float(np.sum(y ** 2) / N), float(np.sum(z ** 2) / N)]
out += [
float(np.percentile(x, 75) - np.percentile(x, 25)),
float(np.percentile(y, 75) - np.percentile(y, 25)),
float(np.percentile(z, 75) - np.percentile(z, 25)),
]
out += [_entropy(x), _entropy(y), _entropy(z)]
out += _burg_ar(x).tolist()
out += _burg_ar(y).tolist()
out += _burg_ar(z).tolist()
out += [_safe_corr(x, y), _safe_corr(x, z), _safe_corr(y, z)]
return np.array(out, dtype=np.float64) # 40 values
def _tmag(sig: np.ndarray) -> np.ndarray:
"""13 time-domain features from a 1-D magnitude signal.
Order: mean, std, mad, max, min, sma, energy, iqr, entropy, arCoeffΓ—4
"""
N = len(sig)
out = [
float(sig.mean()),
float(sig.std()),
float(np.median(np.abs(sig - np.median(sig)))),
float(sig.max()),
float(sig.min()),
float(np.abs(sig).sum() / N), # sma (1-D)
float(np.sum(sig ** 2) / N), # energy
float(np.percentile(sig, 75) - np.percentile(sig, 25)),
_entropy(sig),
]
out += _burg_ar(sig).tolist()
return np.array(out, dtype=np.float64) # 13 values
def _f3ax(sig: np.ndarray) -> np.ndarray:
"""79 frequency-domain features from a 3-axis signal (N, 3).
Order: meanΓ—3, stdΓ—3, madΓ—3, maxΓ—3, minΓ—3, sma,
energyΓ—3, iqrΓ—3, entropyΓ—3,
maxIndsΓ—3, meanFreqΓ—3,
(skewness, kurtosis)Γ—3 interleaved,
bandsEnergyΓ—14 per axis (Γ—3 axes = 42)
"""
x, y, z = sig[:, 0], sig[:, 1], sig[:, 2]
def _fft(s):
return np.abs(np.fft.rfft(s))[:64]
fx, fy, fz = _fft(x), _fft(y), _fft(z)
bins = np.arange(1, 65, dtype=np.float64) # 1-indexed bin numbers
def _mfreq(fm):
t = fm.sum()
return float(np.dot(bins[:len(fm)], fm) / t) if t > 1e-12 else 0.0
def _maxinds(fm):
return float(np.argmax(fm) + 1) # 1-indexed
out = []
out += [fx.mean(), fy.mean(), fz.mean()]
out += [fx.std(), fy.std(), fz.std()]
out += [
float(np.median(np.abs(fx - np.median(fx)))),
float(np.median(np.abs(fy - np.median(fy)))),
float(np.median(np.abs(fz - np.median(fz)))),
]
out += [fx.max(), fy.max(), fz.max()]
out += [fx.min(), fy.min(), fz.min()]
n = len(fx)
out += [float((fx + fy + fz).sum() / n)] # sma of FFT mags
out += [float(np.sum(fx ** 2) / n), float(np.sum(fy ** 2) / n), float(np.sum(fz ** 2) / n)]
out += [
float(np.percentile(fx, 75) - np.percentile(fx, 25)),
float(np.percentile(fy, 75) - np.percentile(fy, 25)),
float(np.percentile(fz, 75) - np.percentile(fz, 25)),
]
out += [_entropy(fx), _entropy(fy), _entropy(fz)]
out += [_maxinds(fx), _maxinds(fy), _maxinds(fz)]
out += [_mfreq(fx), _mfreq(fy), _mfreq(fz)]
# skewness/kurtosis interleaved per axis (skX,kurX, skY,kurY, skZ,kurZ)
out += [float(skew(fx)), float(sp_kurtosis(fx))]
out += [float(skew(fy)), float(sp_kurtosis(fy))]
out += [float(skew(fz)), float(sp_kurtosis(fz))]
out += _bands_energy(fx).tolist()
out += _bands_energy(fy).tolist()
out += _bands_energy(fz).tolist()
return np.array(out, dtype=np.float64) # 79 values
def _fmag(sig: np.ndarray) -> np.ndarray:
"""13 frequency-domain features from a 1-D magnitude signal.
Order: mean, std, mad, max, min, sma, energy, iqr, entropy,
maxInds, meanFreq, skewness, kurtosis
"""
fm = np.abs(np.fft.rfft(sig))[:64]
n = len(fm)
bins = np.arange(1, n + 1, dtype=np.float64)
total = fm.sum()
out = [
float(fm.mean()),
float(fm.std()),
float(np.median(np.abs(fm - np.median(fm)))),
float(fm.max()),
float(fm.min()),
float(np.abs(fm).sum() / n),
float(np.sum(fm ** 2) / n),
float(np.percentile(fm, 75) - np.percentile(fm, 25)),
_entropy(fm),
float(np.argmax(fm) + 1), # maxInds (1-indexed)
float(np.dot(bins, fm) / total) if total > 1e-12 else 0.0, # meanFreq
float(skew(fm)),
float(sp_kurtosis(fm)),
]
return np.array(out, dtype=np.float64) # 13 values
def _window_features(
body_acc: np.ndarray,
grav_acc: np.ndarray,
body_jerk: np.ndarray,
gyro: np.ndarray,
gyro_jerk: np.ndarray,
) -> np.ndarray:
"""Extract all 561 features from one pre-processed window.
Args:
body_acc: body linear acceleration (128, 3) m/sΒ²
grav_acc: gravity component (128, 3) m/sΒ²
body_jerk: body jerk (127, 3) m/sΒ³
gyro: angular velocity (128, 3) rad/s
gyro_jerk: gyro jerk (127, 3) rad/sΒ²
Returns:
1-D array of 561 features
"""
# Magnitudes
ba_mag = np.linalg.norm(body_acc, axis=1)
ga_mag = np.linalg.norm(grav_acc, axis=1)
bj_mag = np.linalg.norm(body_jerk, axis=1)
gy_mag = np.linalg.norm(gyro, axis=1)
gj_mag = np.linalg.norm(gyro_jerk, axis=1)
parts = []
# Time 3-axis (5 Γ— 40 = 200)
for sig in [body_acc, grav_acc, body_jerk, gyro, gyro_jerk]:
parts.append(_t3ax(sig))
# Time magnitudes (5 Γ— 13 = 65)
for mag in [ba_mag, ga_mag, bj_mag, gy_mag, gj_mag]:
parts.append(_tmag(mag))
# Freq 3-axis (3 Γ— 79 = 237)
for sig in [body_acc, body_jerk, gyro]:
parts.append(_f3ax(sig))
# Freq magnitudes (4 Γ— 13 = 52)
for mag in [ba_mag, bj_mag, gy_mag, gj_mag]:
parts.append(_fmag(mag))
# Angle features (7)
ba_mean = body_acc.mean(axis=0)
ga_mean = grav_acc.mean(axis=0)
bj_mean = body_jerk.mean(axis=0)
gy_mean = gyro.mean(axis=0)
gj_mean = gyro_jerk.mean(axis=0)
parts.append(np.array([
_angle(ba_mean, ga_mean),
_angle(bj_mean, ga_mean),
_angle(gy_mean, ga_mean),
_angle(gj_mean, ga_mean),
_angle(np.array([1.0, 0.0, 0.0]), ga_mean),
_angle(np.array([0.0, 1.0, 0.0]), ga_mean),
_angle(np.array([0.0, 0.0, 1.0]), ga_mean),
]))
result = np.concatenate(parts)
assert result.shape == (561,), f"Feature count error: got {result.shape[0]}, expected 561"
return result
def process_phyphox_files(
acc_file,
gyro_file,
) -> tuple:
"""Convert Phyphox CSV exports to (n_windows, 561) feature array.
Pipeline:
1. Parse + validate both CSVs
2. Convert accelerometer from m/sΒ² to g (Γ· 9.80665) to match UCI training units
3. Interpolate onto common 50 Hz grid
4. Segment: 128-sample windows, 64-sample hop (50% overlap)
5. Per window: median filter β†’ 20 Hz Butterworth β†’ gravity separation
at 0.3 Hz β†’ jerk β†’ magnitudes β†’ 561 features
Args:
acc_file: file-like object β€” Phyphox Accelerometer CSV
(columns: Time (s), X (m/s^2), Y (m/s^2), Z (m/s^2))
gyro_file: file-like object β€” Phyphox Gyroscope CSV
(columns: Time (s), X (rad/s), Y (rad/s), Z (rad/s))
Returns:
Tuple of:
np.ndarray shape (n_windows, 561) β€” raw (un-normalised) features
list[str] β€” warning messages
Raises:
ValueError: invalid format, wrong columns, or < 3 s of data
"""
warnings: list = []
acc_df = _parse_csv(acc_file, ACC_COLS)
gyro_df = _parse_csv(gyro_file, GYRO_COLS)
acc_t = acc_df["Time (s)"].values
acc_xyz = acc_df[["X (m/s^2)", "Y (m/s^2)", "Z (m/s^2)"]].values / 9.80665 # m/sΒ² β†’ g
gyro_t = gyro_df["Time (s)"].values
gyro_xyz = gyro_df[["X (rad/s)", "Y (rad/s)", "Z (rad/s)"]].values
t0 = max(acc_t[0], gyro_t[0])
t1 = min(acc_t[-1], gyro_t[-1])
duration = t1 - t0
if duration < 3.0:
raise ValueError(
f"Recording is {duration:.2f} s β€” minimum 3 seconds required. "
"Hold the phone still or walk for at least 3 seconds before exporting."
)
t_grid = np.arange(t0, t1, 1.0 / FS)
am = (acc_t >= t0) & (acc_t <= t1)
gm = (gyro_t >= t0) & (gyro_t <= t1)
acc_50 = np.column_stack(
[np.interp(t_grid, acc_t[am], acc_xyz[am, i]) for i in range(3)]
)
gyro_50 = np.column_stack(
[np.interp(t_grid, gyro_t[gm], gyro_xyz[gm, i]) for i in range(3)]
)
n = min(len(acc_50), len(gyro_50))
acc_50, gyro_50 = acc_50[:n], gyro_50[:n]
n_windows = max(0, (n - WINDOW) // STEP + 1)
if n_windows == 0:
raise ValueError(
f"Only {n} samples ({n / FS:.1f} s) after alignment β€” "
f"need at least {WINDOW} samples ({WINDOW / FS:.1f} s)."
)
if duration > 60:
warnings.append(f"Long recording ({duration:.0f} s) β€” {n_windows} windows extracted.")
# Apply noise filters to the full signal before windowing.
acc_50 = _butter_lp(_median_filt(acc_50), cutoff=20.0)
gyro_50 = _butter_lp(_median_filt(gyro_50), cutoff=20.0)
all_features = []
dt = 1.0 / FS
for start in range(0, n - WINDOW + 1, STEP):
end = start + WINDOW
aw = acc_50[start:end] # (128, 3)
gw = gyro_50[start:end] # (128, 3)
# Gravity separation: window mean as gravity estimate.
# The UCI pipeline used a 0.3 Hz LP on a full continuous recording.
# That filter needs ~167 samples to settle; a 10-second clip gives only
# ~500 samples total, so the filter corrupts all but the central window.
# The window mean is equivalent for symmetric activities (oscillations
# cancel over a stride cycle) and exact for static activities.
grav = np.tile(aw.mean(axis=0), (WINDOW, 1))
body = aw - grav
# Jerk: finite difference β†’ (127, 3)
body_jerk = np.diff(body, axis=0) / dt
gyro_jerk = np.diff(gw, axis=0) / dt
all_features.append(_window_features(body, grav, body_jerk, gw, gyro_jerk))
return np.array(all_features), warnings