Spaces:
Runtime error
Runtime error
| """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 | |