Ronilos commited on
Commit
811ddf6
·
verified ·
1 Parent(s): dc7c7f3

Upload 22 files

Browse files
.gitignore ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .venv/
2
+ __pycache__/
3
+ *.py[cod]
4
+ .pytest_cache/
5
+ .DS_Store
6
+ Thumbs.db
7
+
8
+ # Recordings stay local. Copy them into data/raw/ to train.
9
+ data/raw/*
10
+ !data/raw/.gitkeep
11
+ !data/raw/README.md
12
+
13
+ # Each train writes a folder here. The code is the repo; the weights are a run.
14
+ runs/*
15
+ !runs/.gitkeep
README.md CHANGED
@@ -1,3 +1,258 @@
1
  ---
2
  license: mit
 
 
 
 
 
 
 
 
 
 
3
  ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
  license: mit
3
+ library_name: pytorch
4
+ tags:
5
+ - emg
6
+ - surface-emg
7
+ - gesture-recognition
8
+ - hand-pose
9
+ - wearable
10
+ - onnx
11
+ - pytorch
12
+ - pieeg
13
  ---
14
+
15
+ # AuraNet: wrist EMG gesture recognition for Aura
16
+
17
+ > **Status: architecture and training code only.** Trained weights (`model.onnx`, `model.pt`) are not published yet and will be added in a later version. Run `python train.py` to train your own.
18
+
19
+ AuraNet is a small temporal-spatial CNN for the **Aura** 8-channel surface-EMG wristband by PiEEG ([aura.pieeg.com](https://aura.pieeg.com/)).
20
+
21
+ | | |
22
+ |---|---|
23
+ | Input | `float32 [batch, 250, 8]` (1 s at 250 Hz, time then channel), filtered and scaled outside the graph |
24
+ | Output | softmax over 6 classes: `rest`, `fist`, `open`, `pinch`, `point`, `thumb-up` |
25
+ | Second head | 7-DoF hand pose in `[0, 1]` (`python train.py --pose`) |
26
+ | Export | ONNX (opset 17) + PyTorch checkpoint |
27
+
28
+ Built as the baseline for the **Aura: Calibration-Free EMG Challenge** on Kaggle: <https://www.kaggle.com/competitions/vr-calibration-free-emg-challenge-pieeg>
29
+
30
+ ---
31
+
32
+ # Aura wrist EMG training pipeline example
33
+
34
+ Baseline pipeline for the Aura hand-EMG Kaggle competition. Aura is an 8-channel surface-EMG wristband ([aura.pieeg.com](https://aura.pieeg.com/)). One session file in, one ONNX model out. Two targets from the same recordings.
35
+
36
+ `Python 3.10+` · `250 Hz` · `8 ch` · `window 1.0 s` · `ONNX`
37
+
38
+ | Target | Command | Graph | Output |
39
+ | --- | --- | --- | --- |
40
+ | Gesture, 6 class | `python train.py` | `model.onnx` | softmax: rest, fist, open, pinch, point, thumb-up |
41
+ | Hand pose, 7 DoF | `python train.py --pose` | `pose.onnx` | `[0, 1]`: five finger curls, pinch, openness |
42
+
43
+ This directory is the working tree. Run every command from here.
44
+
45
+ > Filter, per-channel scale, and the 0.5 s warmup live **outside** the graph. Do not feed raw microvolts to ONNX. Copy `infer.py`.
46
+
47
+ ```mermaid
48
+ flowchart LR
49
+ A["session .xlsx / .csv"] --> B["causal filter + scale"]
50
+ B --> C["1 s windows, hop 0.1 s"]
51
+ C --> D["AuraNet"]
52
+ D --> E["model.onnx"]
53
+ D --> F["pose.onnx"]
54
+ ```
55
+
56
+ ## Index
57
+
58
+ | | Section | Contents |
59
+ | --- | --- | --- |
60
+ | ⚡ | [Quick start](#quick-start) | venv, first train |
61
+ | ⌨️ | [Commands](#commands) | train, pose, loso, infer |
62
+ | 📁 | [Data](#data) | columns, labels, pose targets |
63
+ | ⚙️ | [Method](#method) | filter, windows, QC, AuraNet |
64
+ | 🤚 | [Pose regression](#pose-regression) | 7-DoF optical target |
65
+ | ✂️ | [Split](#split) | session-level, LOSO |
66
+ | 💻 | [Hardware](#hardware) | CPU vs T4 |
67
+ | 📦 | [Run artifacts](#run-artifacts) | `runs/<timestamp>/` |
68
+ | 🔌 | [Inference contract](#inference-contract) | `model.onnx` I/O |
69
+ | 📏 | [Pose inference contract](#pose-inference-contract) | `pose.onnx` I/O |
70
+ | 🗂️ | [Layout](#layout) | files |
71
+ | 🔧 | [Configuration](#configuration) | `config.yaml` |
72
+
73
+ ## Quick start
74
+
75
+ Python 3.10+.
76
+
77
+ ```powershell
78
+ py -3 -m venv .venv
79
+ .\.venv\Scripts\Activate.ps1
80
+ pip install -r requirements.txt
81
+ ```
82
+
83
+ Put one file per session in `data/raw/` (`.xlsx` or `.csv`). Then:
84
+
85
+ ```powershell
86
+ python train.py --qc-only
87
+ python train.py
88
+ python infer.py
89
+ ```
90
+
91
+ Dependencies: NumPy, pandas, openpyxl, SciPy, PyYAML, PyTorch, ONNX, ONNX Runtime.
92
+
93
+ `--qc-only` writes the channel table and does not train. `--self-test` trains for one epoch on synthetic tensors and checks that the ONNX file loads. It does not read `data/raw/`.
94
+
95
+ ## Commands
96
+
97
+ | Command | What it does |
98
+ | --- | --- |
99
+ | `python train.py --qc-only` | Channel QC only. No fit. |
100
+ | `python train.py` | 6-class classifier → `model.onnx` |
101
+ | `python train.py --pose` | 7-DoF pose regressor → `pose.onnx` |
102
+ | `python train.py --data D:\more-sessions` | Read sessions from another folder |
103
+ | `python train.py --test multimodal_15` | Pin the test session |
104
+ | `python train.py --loso` | Leave-one-session-out on train+val. Test stays sealed. Does not replace `model.onnx` |
105
+ | `python train.py --self-test` | One synthetic epoch. No `data/raw/` |
106
+ | `python infer.py` | One-window ONNX caller (latest `model.onnx`) |
107
+ | `python infer.py runs\RUN\model.onnx` | Same, explicit graph |
108
+
109
+ ## Data
110
+
111
+ Place one file per session in `data/raw/`. Accepted formats are `.xlsx` (first sheet) and `.csv`. Excel lock files (`~$*`) are ignored.
112
+
113
+ | Column | Role |
114
+ | --- | --- |
115
+ | `t` | Time in seconds from session start |
116
+ | `emg_new` | If present, only rows with value 1 are kept |
117
+ | `ch1` … `ch8` | EMG in microvolts |
118
+ | `label` | Class name |
119
+ | `label_source` | If present, only `guided` rows are labeled. Other rows are unlabeled |
120
+ | `optical_*` | Optical tracker metadata. Not a classifier input. See `drop_optical_disagreement` |
121
+ | `thumb_curl` … `pinky_curl`, `pinch`, `openness` | Continuous pose in `[0, 1]`. The `--pose` target, not an input |
122
+
123
+ IMU columns (`ax`, `ay`, `az`, `gx`, `gy`, `gz`, `roll`, `pitch`, `yaw`) are not read. Class names are normalized (`open hand` → `open`, `thumb up` → `thumb-up`). Anything outside the six-class list is unlabeled.
124
+
125
+ Recordings are not part of the repository. `data/raw/` is gitignored except for its note.
126
+
127
+ If `data/raw/` is empty, `fallback_data_dir` in `config.yaml` is used when that path exists.
128
+
129
+ ## Method
130
+
131
+ Sampling rate is 250 Hz. Files more than 2% off that rate are resampled. The first 0.5 s after filtering is dropped as filter warmup.
132
+
133
+ Preprocessing, applied before the network and not inside the ONNX graph:
134
+
135
+ 1. Causal bandpass 20–120 Hz, order 4.
136
+ 2. Causal bandstop 45–55 Hz, order 4.
137
+ 3. Causal notch at 100 Hz, Q = 30.
138
+ 4. Per-channel scale `median(|x|) / 0.6745`, with a floor of `1e-6`.
139
+
140
+ Windows are 1.0 s (250 samples) with a hop of 0.1 s. Samples within 0.2 s of a label change are unlabeled, so ramp intervals are not used as holds. A window is kept when at least 90% of its samples share one class and no second class is present.
141
+
142
+ Channel QC, per file, on the filtered signal:
143
+
144
+ - Drop if the channel is empty, flat (std < 1 µV or peak-to-peak < 2 µV), railed, or listed in `drop_channels`.
145
+ - Report SNR of active grips versus rest, and the fraction of 20–120 Hz power in the 49–51 Hz band. Low SNR is not an automatic drop.
146
+ - A channel is zeroed in the shipped graph only if it hard-fails in at least `global_drop_fraction` of the train files, or it is listed in `drop_channels`. The mask is estimated from the train files only, so neither validation nor test decides which channels exist.
147
+
148
+ The network is a temporal-spatial convolutional classifier (time convolution, depthwise mix across the 8 channels, separable convolution, linear head). Input layout is `(batch, time, channels)`. Output is a softmax over the six classes, index 0 = rest. Training uses class-weighted cross-entropy and AdamW. Early stopping watches validation balanced accuracy.
149
+
150
+ ## Pose regression
151
+
152
+ `python train.py --pose` trains a continuous hand-pose regressor on the same recordings and the same windows. The target is the optical hand tracker carried in each session file: seven values in `[0, 1]`, the five per-finger curls plus pinch and openness. This is an optical estimate, not motion capture, so the model distils that tracker into EMG and its ceiling is the tracker's own quality.
153
+
154
+ The target for a window is the confidence-weighted median pose over that window. A window is used when at least half its samples carry a present, valid pose and the median optical confidence clears `pose_min_conf`. Loss is Smooth L1 weighted by confidence, and early stopping watches validation mean absolute error. The network reuses the classifier backbone with a sigmoid head, so the output is a pose vector in `[0, 1]`. Reported metrics are per-DoF MAE and Pearson correlation on the test sessions.
155
+
156
+ ## Split
157
+
158
+ The split unit is the session file. Windows from one file are not placed on both sides of a split: the hop is 100 ms inside a 1 s window, so neighboring windows are the same contraction.
159
+
160
+ With the session lists in `config.yaml` empty, files are shuffled with `seed` and cut by `test_fraction` and `val_fraction` (default 20% test, 20% validation, remainder train). At least one file is required in each role, and at least three files are required in total.
161
+
162
+ | Role | Use |
163
+ | --- | --- |
164
+ | Train | Weight updates |
165
+ | Validation | Early stopping only |
166
+ | Test | One score after training. Not in the loss, not in early stopping, not in the exported weights |
167
+
168
+ `runs/<timestamp>/split.json` is the assignment from that run. Copy it into `train_sessions`, `val_sessions`, and `test_sessions` to freeze the split. Files that are not listed are appended to train.
169
+
170
+ `--loso` runs leave-one-session-out on train and validation only. It does not score or modify the test files, and it does not replace `model.onnx`.
171
+
172
+ The reported test metric is balanced accuracy. Rest is the majority class, so unweighted accuracy is not the selection metric.
173
+
174
+ ## Hardware
175
+
176
+ AuraNet is a small temporal-spatial CNN (8 channels, 1 s window). One `python train.py` run is fine on CPU. `--loso` is 12 extra trains on the train+val sessions (test stays sealed), each for the shipped epoch count, so it is slow on CPU. The network is not large; a free T4 is enough.
177
+
178
+ | Budget | Where | Notes |
179
+ | --- | --- | --- |
180
+ | $0 | Kaggle notebook, GPU on | ~30 GPU hours/week. Best free option for this competition. |
181
+ | $0 | Colab, Runtime > GPU (T4 if assigned) | Works. GPU is not guaranteed. Idle disconnect. [notebooks/loso.ipynb](notebooks/loso.ipynb) |
182
+ | $0 | This machine, CPU | Single train: yes. LOSO: hours. Do not start it here. |
183
+ | ~$1 | Any rented T4 / RTX, SSH | One LOSO job. |
184
+
185
+ Do not `pip install torch` on Colab or Kaggle. Those runtimes already ship CUDA PyTorch; a pip torch often replaces it with CPU. Install the rest from `requirements.txt`. Session files are ~104 MB and are not in git: put them in `data/raw/` (upload, Drive, or Kaggle dataset). `loso.json` is written after each fold so a dropped session still keeps completed scores.
186
+
187
+ ## Run artifacts
188
+
189
+ Each training writes `runs/<timestamp>/`. `runs/latest.txt` stores that path.
190
+
191
+ | File | Contents |
192
+ | --- | --- |
193
+ | `split.json` | Train, validation, and test session names |
194
+ | `qc.md`, `qc.json` | Per-session channel std, SNR, mains ratio, keep/drop |
195
+ | `report.md`, `report.json` | Train counts, validation score, test score, test confusion matrix |
196
+ | `model.onnx` | Exported network. Weights from the train split only |
197
+ | `model.pt` | PyTorch checkpoint, same weights, plus the split and channel mask |
198
+ | `contract.json` | Inference contract below |
199
+ | `pose.onnx` | Pose regressor. Present only after `--pose` |
200
+ | `pose.pt` | Pose checkpoint, plus DoF names, split, and channel mask. `--pose` only |
201
+ | `pose_contract.json`, `pose_report.md`, `pose_report.json` | Pose contract and scores. `--pose` only |
202
+ | `loso.json` | Present only after `--loso` |
203
+
204
+ ## Inference contract
205
+
206
+ `contract.json` is authoritative for a given run. Defaults:
207
+
208
+ | Field | Value |
209
+ | --- | --- |
210
+ | Input name | `emg` |
211
+ | Input | `float32`, `[batch, 250, 8]`, time then channel |
212
+ | Output name | `probs` |
213
+ | Output | `float32`, `[batch, 6]`, sums to 1 |
214
+ | Class order | `rest`, `fist`, `open`, `pinch`, `point`, `thumb-up` |
215
+ | Sample rate | 250 Hz |
216
+ | Graph contents | Classifier only. Filter, scale, and warmup are the caller's responsibility |
217
+ | Channel mask | Zeroed inside the graph. See `channel_keep` |
218
+
219
+ ```powershell
220
+ python infer.py
221
+ python infer.py runs\RUN\model.onnx
222
+ ```
223
+
224
+ `infer.py` loads `model.onnx`, runs a mock 1 s buffer through the same causal filter and per-channel scale as training, and prints class probabilities. With no path it uses `runs/latest.txt` if that run has `model.onnx`, otherwise the newest `runs/*/model.onnx`.
225
+
226
+ The mock buffer is synthetic EMG in microvolts. Scale is computed on that 1 s window; training uses the full recording. A live caller should keep SOS filter state across hops instead of re-applying the 0.5 s warmup every window.
227
+
228
+ ## Pose inference contract
229
+
230
+ `pose_contract.json` is authoritative for a `--pose` run. Defaults:
231
+
232
+ | Field | Value |
233
+ | --- | --- |
234
+ | Input | `float32`, `[batch, 250, 8]`, time then channel |
235
+ | Output name | `pose` |
236
+ | Output | `float32`, `[batch, 7]`, each value in `[0, 1]` |
237
+ | DoF order | `thumb_curl`, `index_curl`, `middle_curl`, `ring_curl`, `pinky_curl`, `pinch`, `openness` |
238
+ | Target source | Optical hand tracker, not motion capture |
239
+ | Preprocess | Same causal filter and per-channel scale as the classifier, applied by the caller |
240
+
241
+ ## Layout
242
+
243
+ ```
244
+ config.yaml training and split configuration
245
+ train.py entry point
246
+ infer.py one-window ONNX caller (filter and scale outside the graph)
247
+ aura_pipeline/ load, QC, windows, model, fit, export, pose
248
+ data/raw/ session files
249
+ runs/ outputs, gitignored
250
+ ```
251
+
252
+ ## Configuration
253
+
254
+ All knobs are in `config.yaml`. Do not hard-code channels, epochs, or split lists in Python.
255
+
256
+ `drop_optical_disagreement` defaults to false. Optical fields are last-value holds on the EMG time base, so a sample-level veto is not a reliable label. When enabled, a guided sample is unlabeled if `optical_present` is set, `optical_conf` is at least `optical_conf_min`, and `optical_class` names a different class. The pair rest/open is exempt.
257
+
258
+ `pose_targets`, `pose_min_conf`, and `pose_loss_beta` configure the `--pose` regressor: the DoF columns to predict, the median-confidence floor for keeping a window, and the Smooth L1 transition point.
aura_pipeline/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ """Training package for 8-channel hand EMG."""
2
+
3
+ __version__ = "1.0.0"
aura_pipeline/__main__.py ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
aura_pipeline/cli.py ADDED
@@ -0,0 +1,282 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import warnings
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+
9
+ import numpy as np
10
+ import torch
11
+
12
+ from .config import ROOT, load_config, resolve_dir
13
+ from .data import apply_mask, discover, global_mask, load_session, make_windows
14
+ from .export import export_onnx, write_contract
15
+ from .fit import (
16
+ balanced_accuracy,
17
+ confusion,
18
+ predict,
19
+ session_sample_weights,
20
+ set_seed,
21
+ train_model,
22
+ )
23
+ from .report import write_qc, write_report
24
+ from .split import assign_sessions
25
+
26
+ warnings.filterwarnings("ignore", message="Workbook contains no default style")
27
+
28
+
29
+ def _parse(argv: list[str] | None) -> argparse.Namespace:
30
+ p = argparse.ArgumentParser(description="Train the Aura hand model and write ONNX.")
31
+ p.add_argument("--config", type=Path, default=None)
32
+ p.add_argument("--data", type=Path, default=None, help="Folder of xlsx/csv sessions.")
33
+ p.add_argument("--qc-only", action="store_true", help="Channel report only. No training.")
34
+ p.add_argument("--self-test", action="store_true", help="Train on synthetic EMG and export ONNX.")
35
+ p.add_argument("--loso", action="store_true", help="Also score leave-one-session-out. Slow.")
36
+ p.add_argument("--pose", action="store_true", help="Train continuous hand-pose regression, not the classifier.")
37
+ p.add_argument("--epochs", type=int, default=None)
38
+ p.add_argument("--window-sec", type=float, default=None, help="Override config window_sec for this run.")
39
+ p.add_argument(
40
+ "--session-balanced",
41
+ action="store_true",
42
+ help="Draw train windows so each session has equal mass. Classifier only.",
43
+ )
44
+ p.add_argument("--test", action="append", default=None, help="Pin a session into the test split. Repeatable.")
45
+ return p.parse_args(argv)
46
+
47
+
48
+ def _data_dir(cfg: dict, override: Path | None) -> Path:
49
+ if override is not None:
50
+ path = override if override.is_absolute() else (Path.cwd() / override)
51
+ path = path.resolve()
52
+ if not discover(path):
53
+ raise SystemExit(f"No xlsx or csv in {path}")
54
+ return path
55
+ primary = resolve_dir(ROOT, cfg["data_dir"])
56
+ if primary and discover(primary):
57
+ return primary
58
+ fallback = resolve_dir(ROOT, cfg.get("fallback_data_dir"))
59
+ if fallback and discover(fallback):
60
+ print(f"data/raw is empty. Using {fallback}")
61
+ return fallback
62
+ raise SystemExit(
63
+ "No recordings found. Put session xlsx or csv files in data/raw, or pass --data <folder>."
64
+ )
65
+
66
+
67
+ def _run_dir() -> Path:
68
+ path = ROOT / "runs" / datetime.now().strftime("%Y%m%d_%H%M%S")
69
+ path.mkdir(parents=True, exist_ok=True)
70
+ (ROOT / "runs" / "latest.txt").write_text(str(path), encoding="utf-8")
71
+ return path
72
+
73
+
74
+ def _bundle(cfg: dict, folder: Path):
75
+ files = discover(folder)
76
+ print(f"{len(files)} session file(s) in {folder}", flush=True)
77
+ sessions = []
78
+ for path in files:
79
+ print(f" load {path.name}", flush=True)
80
+ sessions.append(load_session(path, cfg))
81
+ return sessions
82
+
83
+
84
+ def _windows_by_session(sessions, cfg, mask):
85
+ xs, ys, names = [], [], []
86
+ for session in sessions:
87
+ x, y = make_windows(session, cfg)
88
+ x = apply_mask(x, mask)
89
+ if len(x) == 0:
90
+ print(f" no clean windows in {session.name}", flush=True)
91
+ continue
92
+ print(f" {session.name}: {len(x)} windows", flush=True)
93
+ xs.append(x)
94
+ ys.append(y)
95
+ names.append(session.name)
96
+ return xs, ys, names
97
+
98
+
99
+ def _concat(xs, ys):
100
+ if not xs:
101
+ win = 250
102
+ return np.empty((0, win, 8), np.float32), np.empty((0,), np.int64)
103
+ return np.concatenate(xs), np.concatenate(ys)
104
+
105
+
106
+ def _check_classes(y: np.ndarray, classes: list[str]) -> None:
107
+ missing = [name for i, name in enumerate(classes) if np.sum(y == i) == 0]
108
+ if missing:
109
+ raise SystemExit(
110
+ "No windows for: " + ", ".join(missing) + ". Check guided labels before training."
111
+ )
112
+
113
+
114
+ def run_self_test() -> int:
115
+ from .model import AuraNet
116
+
117
+ cfg = load_config()
118
+ cfg["epochs"] = 1
119
+ cfg["patience"] = 1
120
+ cfg["batch_size"] = 16
121
+ fs = 250
122
+ win = 250
123
+ rng = np.random.default_rng(0)
124
+ xs, ys = [], []
125
+ for cls in range(len(cfg["classes"])):
126
+ block = rng.normal(0, 0.2, size=(24, win, 8)).astype(np.float32)
127
+ block[:, :, cls % 8] += 1.5 + 0.3 * cls
128
+ xs.append(block)
129
+ ys.append(np.full(len(block), cls, np.int64))
130
+ x = np.concatenate(xs)
131
+ y = np.concatenate(ys)
132
+ mask = [True] * 8
133
+ mask[7] = False
134
+ x[:, :, 7] = 0.0
135
+ set_seed(int(cfg["seed"]))
136
+ model, info = train_model(x, y, x[:32], y[:32], cfg, mask, epochs=1)
137
+ out = _run_dir()
138
+ onnx_path = out / "model.onnx"
139
+ export_onnx(model, onnx_path, cfg["classes"])
140
+ write_contract(out / "contract.json", cfg, mask, {"train": ["synthetic"], "val": [], "test": []})
141
+ probe = AuraNet(channel_mask=mask)
142
+ probe.load_state_dict(model.cpu().state_dict())
143
+ print(f"self-test ok device={info['device']} onnx={onnx_path}")
144
+ return 0
145
+
146
+
147
+ def main(argv: list[str] | None = None) -> int:
148
+ args = _parse(argv)
149
+ if args.self_test:
150
+ return run_self_test()
151
+
152
+ cfg = load_config(args.config)
153
+ if args.epochs is not None:
154
+ cfg["epochs"] = args.epochs
155
+ if args.window_sec is not None:
156
+ cfg["window_sec"] = float(args.window_sec)
157
+ if args.session_balanced:
158
+ cfg["session_balanced"] = True
159
+ if args.loso:
160
+ cfg["loso"] = True
161
+ if args.test:
162
+ cfg["test_sessions"] = list(args.test)
163
+
164
+ folder = _data_dir(cfg, args.data)
165
+ sessions = _bundle(cfg, folder)
166
+ mask = global_mask(sessions, cfg)
167
+ out = _run_dir()
168
+ rows = [row for s in sessions for row in s.qc_rows]
169
+ write_qc(out / "qc.md", rows, mask)
170
+ (out / "qc.json").write_text(json.dumps({"mask": mask, "rows": rows}, indent=2), encoding="utf-8")
171
+ print("channel keep: " + " ".join(f"ch{i+1}" for i, k in enumerate(mask) if k), flush=True)
172
+ print(f"qc report: {out / 'qc.md'}", flush=True)
173
+ if args.qc_only:
174
+ return 0
175
+
176
+ by_name = {s.name: s for s in sessions if s.name}
177
+ split = assign_sessions([s.name for s in sessions], cfg)
178
+ # Channel mask is fit on train only. Validation and test must not decide which channels exist.
179
+ mask = global_mask([by_name[n] for n in split["train"] if n in by_name], cfg)
180
+ write_qc(out / "qc.md", rows, mask)
181
+ (out / "qc.json").write_text(json.dumps({"mask": mask, "rows": rows, "split": split}, indent=2), encoding="utf-8")
182
+ print("channel keep: " + " ".join(f"ch{i+1}" for i, k in enumerate(mask) if k), flush=True)
183
+ print(
184
+ "split train={train} val={val} test={test}".format(**{k: ",".join(v) for k, v in split.items()}),
185
+ flush=True,
186
+ )
187
+ (out / "split.json").write_text(json.dumps(split, indent=2), encoding="utf-8")
188
+
189
+ if args.pose:
190
+ from .pose import run_pose
191
+
192
+ return run_pose(sessions, cfg, out, split, mask)
193
+
194
+ xs, ys, names = _windows_by_session(sessions, cfg, mask)
195
+ if not names:
196
+ raise SystemExit("No trainable windows. See the QC report.")
197
+ have = set(names)
198
+ for role, group in split.items():
199
+ missing = [n for n in group if n not in have]
200
+ if missing:
201
+ raise SystemExit(f"{role} sessions have no clean windows: {', '.join(missing)}")
202
+ groups = {}
203
+ for role in ("train", "val", "test"):
204
+ chosen = [names.index(n) for n in split[role]]
205
+ groups[role] = _concat([xs[i] for i in chosen], [ys[i] for i in chosen])
206
+ x_tr, y_tr = groups["train"]
207
+ x_val, y_val = groups["val"]
208
+ x_te, y_te = groups["test"]
209
+ _check_classes(y_tr, cfg["classes"])
210
+ counts = {name: int(np.sum(y_tr == i)) for i, name in enumerate(cfg["classes"])}
211
+ print("train windows " + ", ".join(f"{k}={v}" for k, v in counts.items()), flush=True)
212
+ sample_weights = None
213
+ if cfg.get("session_balanced"):
214
+ train_lengths = [len(ys[i]) for i in [names.index(n) for n in split["train"]]]
215
+ sample_weights = session_sample_weights(train_lengths)
216
+ print(
217
+ "session-balanced sampler on "
218
+ + ", ".join(f"{n}={k}" for n, k in zip(split["train"], train_lengths)),
219
+ flush=True,
220
+ )
221
+
222
+ set_seed(int(cfg["seed"]))
223
+ model, info = train_model(x_tr, y_tr, x_val, y_val, cfg, mask, sample_weights=sample_weights)
224
+ device = next(model.parameters()).device
225
+ val_pred = predict(model, x_val, int(cfg["batch_size"]), device)
226
+ val_score = balanced_accuracy(y_val, val_pred, len(cfg["classes"]))
227
+ test_pred = predict(model, x_te, int(cfg["batch_size"]), device)
228
+ test_score = balanced_accuracy(y_te, test_pred, len(cfg["classes"]))
229
+ conf = confusion(y_te, test_pred, len(cfg["classes"]))
230
+ print(f"val balanced accuracy {val_score:.3f} (early stop only)", flush=True)
231
+ print(f"test balanced accuracy {test_score:.3f} (not used for weights)", flush=True)
232
+ ship_epochs = max(1, int(info["best_epoch"]))
233
+
234
+ if cfg.get("loso"):
235
+ pool = split["train"] + split["val"]
236
+ print("leave-one-session-out on train+val only. Test stays sealed.", flush=True)
237
+ scores = []
238
+ for name in pool:
239
+ i = names.index(name)
240
+ others = [names.index(n) for n in pool if n != name]
241
+ tr_x, tr_y = _concat([xs[j] for j in others], [ys[j] for j in others])
242
+ # The held-out session is scored only. It is not used for early stopping.
243
+ fold, _ = train_model(tr_x, tr_y, None, None, cfg, mask, epochs=ship_epochs)
244
+ pred = predict(fold, xs[i], int(cfg["batch_size"]), next(fold.parameters()).device)
245
+ score = balanced_accuracy(ys[i], pred, len(cfg["classes"]))
246
+ scores.append({"session": name, "balanced_accuracy": score})
247
+ print(f" {name} {score:.3f}", flush=True)
248
+ (out / "loso.json").write_text(json.dumps(scores, indent=2), encoding="utf-8")
249
+
250
+ onnx_path = out / "model.onnx"
251
+ export_onnx(model, onnx_path, cfg["classes"])
252
+ torch.save(
253
+ {
254
+ "state_dict": model.cpu().state_dict(),
255
+ "classes": cfg["classes"],
256
+ "mask": mask,
257
+ "split": split,
258
+ },
259
+ out / "model.pt",
260
+ )
261
+ write_contract(out / "contract.json", cfg, mask, split)
262
+ summary = {
263
+ "n_sessions": len(names),
264
+ "n_windows": int(len(y_tr) + len(y_val) + len(y_te)),
265
+ "n_train": int(len(y_tr)),
266
+ "class_counts": counts,
267
+ "split": split,
268
+ "val_balanced_accuracy": val_score,
269
+ "test_balanced_accuracy": test_score,
270
+ "confusion": conf.tolist(),
271
+ "ship_epochs": ship_epochs,
272
+ "device": info["device"],
273
+ "onnx": str(onnx_path),
274
+ "channel_keep": mask,
275
+ "session_balanced": bool(cfg.get("session_balanced")),
276
+ "window_sec": float(cfg["window_sec"]),
277
+ "warnings": [w for s in sessions for w in s.warnings],
278
+ }
279
+ write_report(out / "report.md", summary)
280
+ print(f"wrote {onnx_path}")
281
+ print(f"contract {out / 'contract.json'}")
282
+ return 0
aura_pipeline/config.py ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import yaml
6
+
7
+ ROOT = Path(__file__).resolve().parents[1]
8
+ CLASSES_DEFAULT = ["rest", "fist", "open", "pinch", "point", "thumb-up"]
9
+
10
+
11
+ def load_config(path: Path | None = None) -> dict:
12
+ cfg_path = path or (ROOT / "config.yaml")
13
+ with cfg_path.open(encoding="utf-8") as f:
14
+ cfg = yaml.safe_load(f)
15
+ if not isinstance(cfg, dict):
16
+ raise SystemExit(f"Config is empty: {cfg_path}")
17
+ cfg["_path"] = str(cfg_path)
18
+ cfg.setdefault("classes", CLASSES_DEFAULT)
19
+ cfg.setdefault("drop_channels", [])
20
+ cfg.setdefault("fallback_data_dir", None)
21
+ return cfg
22
+
23
+
24
+ def resolve_dir(root: Path, value: str | None) -> Path | None:
25
+ if not value:
26
+ return None
27
+ path = Path(value)
28
+ if not path.is_absolute():
29
+ path = root / path
30
+ return path.resolve()
aura_pipeline/data.py ADDED
@@ -0,0 +1,298 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from pathlib import Path
5
+
6
+ import numpy as np
7
+ import pandas as pd
8
+
9
+ from .signal import causal_filter, design_sos, mains_ratio, robust_scale, to_rate
10
+
11
+ N_CH = 8
12
+ CHANNEL_NAMES = [f"ch{i}" for i in range(1, N_CH + 1)]
13
+ LABEL_ALIASES = {
14
+ "open-hand": "open",
15
+ "openhand": "open",
16
+ "thumbup": "thumb-up",
17
+ "thumbs-up": "thumb-up",
18
+ "thumb-up": "thumb-up",
19
+ }
20
+ POSE_DEFAULT = [
21
+ "thumb_curl",
22
+ "index_curl",
23
+ "middle_curl",
24
+ "ring_curl",
25
+ "pinky_curl",
26
+ "pinch",
27
+ "openness",
28
+ ]
29
+
30
+
31
+ def pose_target_names(cfg: dict) -> list[str]:
32
+ names = cfg.get("pose_targets") or POSE_DEFAULT
33
+ return [str(n) for n in names]
34
+
35
+
36
+ @dataclass
37
+ class Session:
38
+ name: str
39
+ path: str
40
+ fs: float
41
+ emg_uv: np.ndarray
42
+ emg: np.ndarray
43
+ labels: np.ndarray
44
+ scale: np.ndarray
45
+ hard_fail: list[bool]
46
+ qc_rows: list[dict] = field(default_factory=list)
47
+ n_raw: int = 0
48
+ n_used: int = 0
49
+ warnings: list[str] = field(default_factory=list)
50
+ pose: np.ndarray | None = None
51
+ pose_conf: np.ndarray | None = None
52
+ pose_names: list[str] = field(default_factory=list)
53
+
54
+
55
+ def discover(folder: Path) -> list[Path]:
56
+ files = []
57
+ for path in sorted(folder.iterdir() if folder.is_dir() else []):
58
+ if path.name.startswith("~$"):
59
+ continue
60
+ if path.suffix.lower() in {".xlsx", ".xls", ".csv"}:
61
+ files.append(path)
62
+ return files
63
+
64
+
65
+ def _norm_label(value, classes: set[str]) -> str | None:
66
+ if value is None or (isinstance(value, float) and np.isnan(value)):
67
+ return None
68
+ if pd.isna(value):
69
+ return None
70
+ text = str(value).strip().lower().replace("_", "-").replace(" ", "-")
71
+ text = LABEL_ALIASES.get(text, text)
72
+ return text if text in classes else None
73
+
74
+
75
+ def _flag(series: pd.Series | None, n: int) -> np.ndarray:
76
+ if series is None:
77
+ return np.zeros(n, dtype=bool)
78
+ num = pd.to_numeric(series, errors="coerce").to_numpy()
79
+ return np.nan_to_num(num, nan=0.0) > 0.5
80
+
81
+
82
+ def load_table(path: Path) -> pd.DataFrame:
83
+ if path.suffix.lower() == ".csv":
84
+ return pd.read_csv(path)
85
+ return pd.read_excel(path, sheet_name=0)
86
+
87
+
88
+ def _pose_arrays(df: pd.DataFrame, names: list[str]) -> tuple[np.ndarray | None, np.ndarray | None]:
89
+ """Continuous pose target and its per-sample confidence, or (None, None) if absent."""
90
+ if not names or not all(n in df.columns for n in names):
91
+ return None, None
92
+ pose = df[names].apply(pd.to_numeric, errors="coerce").to_numpy(dtype=np.float32)
93
+ if "optical_conf" in df.columns:
94
+ conf = pd.to_numeric(df["optical_conf"], errors="coerce").to_numpy(dtype=np.float32)
95
+ else:
96
+ conf = np.ones(len(df), dtype=np.float32)
97
+ if "optical_present" in df.columns:
98
+ present = pd.to_numeric(df["optical_present"], errors="coerce").to_numpy(dtype=np.float32)
99
+ conf = conf * np.nan_to_num(present, nan=0.0)
100
+ return pose, conf
101
+
102
+
103
+ def _hard_fail(ch: np.ndarray) -> tuple[bool, str]:
104
+ finite = ch[np.isfinite(ch)]
105
+ if finite.size < 16:
106
+ return True, "empty"
107
+ std = float(np.std(finite))
108
+ ptp = float(np.ptp(finite))
109
+ if std < 1.0 or ptp < 2.0:
110
+ return True, "flat"
111
+ # Stuck near one rail.
112
+ close = np.mean((np.abs(finite - finite.min()) < 1.0) | (np.abs(finite - finite.max()) < 1.0))
113
+ if ptp > 50.0 and close > 0.98:
114
+ return True, "railed"
115
+ return False, "ok"
116
+
117
+
118
+ def load_session(path: Path, cfg: dict) -> Session:
119
+ classes = set(cfg["classes"])
120
+ df = load_table(path)
121
+ missing = [c for c in ["t", *CHANNEL_NAMES, "label"] if c not in df.columns]
122
+ if missing:
123
+ raise ValueError(f"{path.name} is missing columns: {', '.join(missing)}")
124
+
125
+ if "emg_new" in df.columns:
126
+ keep = pd.to_numeric(df["emg_new"], errors="coerce").fillna(0).to_numpy() > 0.5
127
+ df = df.loc[keep].reset_index(drop=True)
128
+
129
+ raw = df[CHANNEL_NAMES].apply(pd.to_numeric, errors="coerce").to_numpy(dtype=np.float64)
130
+ finite_row = np.isfinite(raw).all(axis=1)
131
+ t = pd.to_numeric(df["t"], errors="coerce").to_numpy(dtype=np.float64)
132
+ finite_row &= np.isfinite(t)
133
+ df = df.loc[finite_row].reset_index(drop=True)
134
+ raw = raw[finite_row]
135
+ t = t[finite_row]
136
+ n_raw = int(len(df))
137
+
138
+ labels = np.array([_norm_label(v, classes) for v in df["label"]], dtype=object)
139
+ if "label_source" in df.columns:
140
+ source = df["label_source"].astype(str).str.strip().str.lower().to_numpy()
141
+ guided = source == "guided"
142
+ labels = np.where(guided, labels, None)
143
+
144
+ if cfg.get("drop_optical_disagreement", False) and "optical_class" in df.columns:
145
+ present = _flag(df["optical_present"] if "optical_present" in df.columns else None, len(df))
146
+ conf = pd.to_numeric(df["optical_conf"], errors="coerce").fillna(0).to_numpy() if "optical_conf" in df.columns else np.ones(len(df))
147
+ optical = np.array([_norm_label(v, classes) for v in df["optical_class"]], dtype=object)
148
+ disagree = present & (conf >= float(cfg.get("optical_conf_min", 0.6)))
149
+ for i in range(len(labels)):
150
+ if not disagree[i] or labels[i] is None or optical[i] is None:
151
+ continue
152
+ pair = {labels[i], optical[i]}
153
+ if pair == {"rest", "open"}:
154
+ continue
155
+ if optical[i] != labels[i]:
156
+ labels[i] = None
157
+
158
+ target_fs = float(cfg["sample_rate_hz"])
159
+ raw32, fs, resampled_n = to_rate(raw.astype(np.float32), t, target_fs)
160
+ pose_names = pose_target_names(cfg)
161
+ pose, pose_conf = _pose_arrays(df, pose_names)
162
+ warnings = []
163
+ if resampled_n is not None:
164
+ idx = np.clip(np.round(np.linspace(0, len(labels) - 1, resampled_n)).astype(int), 0, len(labels) - 1)
165
+ labels = labels[idx]
166
+ if pose is not None:
167
+ pose = pose[idx]
168
+ pose_conf = pose_conf[idx]
169
+ warnings.append(f"resampled toward {target_fs:.0f} Hz")
170
+ elif abs(fs - target_fs) / target_fs > 0.005:
171
+ warnings.append(f"sample rate {fs:.1f} Hz, filtered as {target_fs:.0f}")
172
+
173
+ mains = mains_ratio(raw32, target_fs, float(cfg.get("notch_mains_hz", 50.0)))
174
+ filtered = causal_filter(raw32, design_sos(target_fs, cfg))
175
+ warmup = int(0.5 * target_fs)
176
+ if len(filtered) > warmup + 8:
177
+ filtered = filtered[warmup:]
178
+ labels = labels[warmup:]
179
+ raw32 = raw32[warmup:]
180
+ if pose is not None:
181
+ pose = pose[warmup:]
182
+ pose_conf = pose_conf[warmup:]
183
+
184
+ forced = {int(c) for c in cfg.get("drop_channels") or []}
185
+ hard = []
186
+ rows = []
187
+ rest = labels == "rest"
188
+ active = np.array([v is not None and v != "rest" for v in labels])
189
+ for i in range(N_CH):
190
+ failed, reason = _hard_fail(filtered[:, i])
191
+ if (i + 1) in forced:
192
+ failed, reason = True, "forced"
193
+ snr = np.nan
194
+ if rest.sum() > 8 and active.sum() > 8:
195
+ snr = float(np.sqrt(np.mean(filtered[active, i] ** 2)) / (np.sqrt(np.mean(filtered[rest, i] ** 2)) + 1e-6))
196
+ hard.append(failed)
197
+ rows.append({
198
+ "session": path.stem,
199
+ "channel": i + 1,
200
+ "std_uv": float(np.std(filtered[:, i])),
201
+ "snr": None if not np.isfinite(snr) else round(snr, 3),
202
+ "mains_ratio": None if not np.isfinite(mains[i]) else round(float(mains[i]), 3),
203
+ "verdict": "drop" if failed else "keep",
204
+ "reason": reason,
205
+ })
206
+
207
+ masked = filtered.copy()
208
+ for i, failed in enumerate(hard):
209
+ if failed:
210
+ masked[:, i] = 0.0
211
+ scale = robust_scale(masked)
212
+ scaled = (masked / scale).astype(np.float32)
213
+
214
+ return Session(
215
+ name=path.stem,
216
+ path=str(path),
217
+ fs=target_fs,
218
+ emg_uv=filtered.astype(np.float32),
219
+ emg=scaled,
220
+ labels=labels,
221
+ scale=scale,
222
+ hard_fail=hard,
223
+ qc_rows=rows,
224
+ n_raw=n_raw,
225
+ n_used=int(len(scaled)),
226
+ warnings=warnings,
227
+ pose=None if pose is None else pose.astype(np.float32),
228
+ pose_conf=None if pose_conf is None else pose_conf.astype(np.float32),
229
+ pose_names=pose_names if pose is not None else [],
230
+ )
231
+
232
+
233
+ def trim_transitions(labels: np.ndarray, edge: int) -> np.ndarray:
234
+ out = labels.copy()
235
+ if edge <= 0 or len(out) < 2:
236
+ return out
237
+ change = np.zeros(len(out), dtype=bool)
238
+ change[1:] = out[1:] != out[:-1]
239
+ for i in np.flatnonzero(change):
240
+ lo = max(0, i - edge)
241
+ hi = min(len(out), i + edge)
242
+ out[lo:hi] = None
243
+ return out
244
+
245
+
246
+ def make_windows(session: Session, cfg: dict) -> tuple[np.ndarray, np.ndarray]:
247
+ fs = session.fs
248
+ win = int(round(float(cfg["window_sec"]) * fs))
249
+ hop = int(round(float(cfg["hop_sec"]) * fs))
250
+ edge = int(round(float(cfg["edge_trim_sec"]) * fs))
251
+ if win < 128 or hop < 1:
252
+ raise ValueError("window_sec must cover at least 128 samples and hop_sec must be positive")
253
+ labels = trim_transitions(session.labels, edge)
254
+ xs, ys = [], []
255
+ class_to_i = {name: i for i, name in enumerate(cfg["classes"])}
256
+ min_frac = float(cfg.get("min_label_fraction", 0.9))
257
+ for start in range(0, len(labels) - win + 1, hop):
258
+ chunk = labels[start:start + win]
259
+ named = [v for v in chunk if v is not None]
260
+ if len(named) / win < min_frac:
261
+ continue
262
+ name = max(set(named), key=named.count)
263
+ if named.count(name) / win < min_frac:
264
+ continue
265
+ if any(v not in (None, name) for v in chunk):
266
+ continue
267
+ xs.append(session.emg[start:start + win])
268
+ ys.append(class_to_i[name])
269
+ if not xs:
270
+ return np.empty((0, win, N_CH), np.float32), np.empty((0,), np.int64)
271
+ return np.stack(xs).astype(np.float32), np.asarray(ys, dtype=np.int64)
272
+
273
+
274
+ def global_mask(sessions: list[Session], cfg: dict) -> list[bool]:
275
+ """True = keep. A channel is removed only when hard-fail is common across files."""
276
+ if not sessions or not cfg.get("auto_drop_hard_failures", True):
277
+ mask = [True] * N_CH
278
+ else:
279
+ frac = float(cfg.get("global_drop_fraction", 0.5))
280
+ fails = np.mean([[1.0 if f else 0.0 for f in s.hard_fail] for s in sessions], axis=0)
281
+ mask = [bool(v < frac) for v in fails]
282
+ for ch in cfg.get("drop_channels") or []:
283
+ idx = int(ch) - 1
284
+ if 0 <= idx < N_CH:
285
+ mask[idx] = False
286
+ if not any(mask):
287
+ raise SystemExit("Channel QC dropped every channel. Clear drop_channels or check the recordings.")
288
+ return mask
289
+
290
+
291
+ def apply_mask(windows: np.ndarray, mask: list[bool]) -> np.ndarray:
292
+ if windows.size == 0:
293
+ return windows
294
+ out = windows.copy()
295
+ for i, keep in enumerate(mask):
296
+ if not keep:
297
+ out[:, :, i] = 0.0
298
+ return out
aura_pipeline/export.py ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ import torch
7
+
8
+ from .model import AuraNet, ProbModel
9
+
10
+
11
+ def export_onnx(model: AuraNet, path: Path, classes: list[str]) -> None:
12
+ model = model.cpu().eval()
13
+ wrapper = ProbModel(model).eval()
14
+ dummy = torch.zeros(1, model.n_samples, model.n_channels, dtype=torch.float32)
15
+ path.parent.mkdir(parents=True, exist_ok=True)
16
+ try:
17
+ torch.onnx.export(
18
+ wrapper,
19
+ dummy,
20
+ str(path),
21
+ input_names=["emg"],
22
+ output_names=["probs"],
23
+ dynamic_axes={"emg": {0: "batch"}, "probs": {0: "batch"}},
24
+ opset_version=17,
25
+ dynamo=False,
26
+ )
27
+ except TypeError:
28
+ torch.onnx.export(
29
+ wrapper,
30
+ dummy,
31
+ str(path),
32
+ input_names=["emg"],
33
+ output_names=["probs"],
34
+ dynamic_axes={"emg": {0: "batch"}, "probs": {0: "batch"}},
35
+ opset_version=17,
36
+ )
37
+ _check_ort(path, dummy.numpy(), len(classes))
38
+
39
+
40
+ def _check_ort(path: Path, dummy, n_classes: int) -> None:
41
+ import onnxruntime as ort
42
+
43
+ sess = ort.InferenceSession(str(path), providers=["CPUExecutionProvider"])
44
+ probs = sess.run(None, {"emg": dummy})[0]
45
+ if probs.shape != (1, n_classes):
46
+ raise RuntimeError(f"ONNX output shape {probs.shape}, expected (1, {n_classes})")
47
+ total = float(probs.sum())
48
+ if abs(total - 1.0) > 1e-3:
49
+ raise RuntimeError(f"ONNX probabilities sum to {total}, expected 1")
50
+
51
+
52
+ def write_contract(path: Path, cfg: dict, mask: list[bool], split: dict[str, list[str]]) -> None:
53
+ fs = float(cfg["sample_rate_hz"])
54
+ win = int(round(float(cfg["window_sec"]) * fs))
55
+ contract = {
56
+ "format": "aura-hand-onnx-v1",
57
+ "task": "6-class palm EMG, probabilities",
58
+ "input_name": "emg",
59
+ "input_layout": "NTC",
60
+ "input_shape": [1, win, 8],
61
+ "input_dtype": "float32",
62
+ "output_name": "probs",
63
+ "output_shape": [1, len(cfg["classes"])],
64
+ "classes": list(cfg["classes"]),
65
+ "sample_rate_hz": fs,
66
+ "window_samples": win,
67
+ "hop_samples": int(round(float(cfg["hop_sec"]) * fs)),
68
+ "channel_names": [f"ch{i}" for i in range(1, 9)],
69
+ "channel_keep": mask,
70
+ "preprocess": {
71
+ "in_graph": False,
72
+ "unit": "microvolts, then per-channel robust scale",
73
+ "scale": "median(abs(x)) / 0.6745, floor 1e-6, per recording over the filtered signal",
74
+ "filter": "causal sos: bandpass 20-120 order 4, bandstop 45-55 order 4, notch 100 Q=30",
75
+ "note": "Apply the filter and scale before this model. Dropped channels may be left as-is; the graph zeros them.",
76
+ },
77
+ "imu_used": False,
78
+ "optical_used_as_input": False,
79
+ "split_unit": "session file, not window",
80
+ "trained_on": split["train"],
81
+ "early_stop_on": split["val"],
82
+ "tested_on": split["test"],
83
+ "test_used_for_weights": False,
84
+ }
85
+ path.write_text(json.dumps(contract, indent=2), encoding="utf-8")
aura_pipeline/fit.py ADDED
@@ -0,0 +1,166 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import numpy as np
4
+ import torch
5
+ from torch import nn
6
+ from torch.utils.data import DataLoader, TensorDataset, WeightedRandomSampler
7
+
8
+ from .model import AuraNet
9
+
10
+
11
+ def set_seed(seed: int) -> None:
12
+ np.random.seed(seed)
13
+ torch.manual_seed(seed)
14
+
15
+
16
+ def class_weights(y: np.ndarray, n_classes: int) -> torch.Tensor:
17
+ counts = np.bincount(y, minlength=n_classes).astype(np.float64)
18
+ weights = np.zeros(n_classes, dtype=np.float32)
19
+ present = counts > 0
20
+ weights[present] = counts[present].sum() / (present.sum() * counts[present])
21
+ return torch.tensor(weights)
22
+
23
+
24
+ def balanced_accuracy(y_true: np.ndarray, y_pred: np.ndarray, n_classes: int) -> float:
25
+ scores = []
26
+ for c in range(n_classes):
27
+ mask = y_true == c
28
+ if mask.sum() == 0:
29
+ continue
30
+ scores.append(float(np.mean(y_pred[mask] == c)))
31
+ return float(np.mean(scores)) if scores else 0.0
32
+
33
+
34
+ def confusion(y_true: np.ndarray, y_pred: np.ndarray, n_classes: int) -> np.ndarray:
35
+ mat = np.zeros((n_classes, n_classes), dtype=np.int64)
36
+ for t, p in zip(y_true, y_pred):
37
+ mat[int(t), int(p)] += 1
38
+ return mat
39
+
40
+
41
+ def session_sample_weights(session_lengths: list[int]) -> np.ndarray:
42
+ """Per-window weights so each session has equal mass, regardless of window count."""
43
+ weights = []
44
+ for n in session_lengths:
45
+ if n <= 0:
46
+ continue
47
+ weights.append(np.full(n, 1.0 / n, dtype=np.float64))
48
+ if not weights:
49
+ return np.empty((0,), np.float64)
50
+ return np.concatenate(weights)
51
+
52
+
53
+ def _loader(
54
+ x: np.ndarray,
55
+ y: np.ndarray,
56
+ batch: int,
57
+ shuffle: bool,
58
+ sample_weights: np.ndarray | None = None,
59
+ ) -> DataLoader:
60
+ ds = TensorDataset(torch.from_numpy(x), torch.from_numpy(y))
61
+ if sample_weights is None:
62
+ return DataLoader(ds, batch_size=batch, shuffle=shuffle)
63
+ sampler = WeightedRandomSampler(
64
+ torch.as_tensor(sample_weights, dtype=torch.double),
65
+ num_samples=len(sample_weights),
66
+ replacement=True,
67
+ )
68
+ return DataLoader(ds, batch_size=batch, sampler=sampler)
69
+
70
+
71
+ @torch.no_grad()
72
+ def predict(model: nn.Module, x: np.ndarray, batch: int, device: torch.device) -> np.ndarray:
73
+ model.eval()
74
+ out = []
75
+ loader = _loader(x, np.zeros(len(x), np.int64), batch, shuffle=False)
76
+ for xb, _ in loader:
77
+ logits = model(xb.to(device))
78
+ out.append(logits.argmax(dim=1).cpu().numpy())
79
+ return np.concatenate(out) if out else np.empty((0,), np.int64)
80
+
81
+
82
+ def train_model(
83
+ x_train: np.ndarray,
84
+ y_train: np.ndarray,
85
+ x_val: np.ndarray | None,
86
+ y_val: np.ndarray | None,
87
+ cfg: dict,
88
+ channel_mask: list[bool],
89
+ epochs: int | None = None,
90
+ sample_weights: np.ndarray | None = None,
91
+ ) -> tuple[AuraNet, dict]:
92
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
93
+ n_classes = len(cfg["classes"])
94
+ win = x_train.shape[1]
95
+ model = AuraNet(
96
+ n_classes=n_classes,
97
+ n_channels=x_train.shape[2],
98
+ n_samples=win,
99
+ channel_mask=channel_mask,
100
+ ).to(device)
101
+ opt = torch.optim.AdamW(
102
+ model.parameters(),
103
+ lr=float(cfg["lr"]),
104
+ weight_decay=float(cfg["weight_decay"]),
105
+ )
106
+ loss_fn = nn.CrossEntropyLoss(weight=class_weights(y_train, n_classes).to(device))
107
+ loader = _loader(
108
+ x_train,
109
+ y_train,
110
+ int(cfg["batch_size"]),
111
+ shuffle=True,
112
+ sample_weights=sample_weights,
113
+ )
114
+ max_epochs = int(epochs if epochs is not None else cfg["epochs"])
115
+ patience = int(cfg["patience"])
116
+ best_state = None
117
+ best_score = -1.0
118
+ best_epoch = 0
119
+ stale = 0
120
+ history = []
121
+
122
+ for epoch in range(1, max_epochs + 1):
123
+ model.train()
124
+ total = 0.0
125
+ seen = 0
126
+ for xb, yb in loader:
127
+ xb = xb.to(device)
128
+ yb = yb.to(device)
129
+ opt.zero_grad(set_to_none=True)
130
+ loss = loss_fn(model(xb), yb)
131
+ loss.backward()
132
+ opt.step()
133
+ total += float(loss.item()) * len(xb)
134
+ seen += len(xb)
135
+ row = {"epoch": epoch, "loss": total / max(1, seen)}
136
+ if x_val is not None and len(x_val):
137
+ pred = predict(model, x_val, int(cfg["batch_size"]), device)
138
+ score = balanced_accuracy(y_val, pred, n_classes)
139
+ row["val_balanced_accuracy"] = score
140
+ if score > best_score + 1e-4:
141
+ best_score = score
142
+ best_epoch = epoch
143
+ best_state = {k: v.detach().cpu().clone() for k, v in model.state_dict().items()}
144
+ stale = 0
145
+ else:
146
+ stale += 1
147
+ else:
148
+ best_epoch = epoch
149
+ best_state = {k: v.detach().cpu().clone() for k, v in model.state_dict().items()}
150
+ history.append(row)
151
+ score_txt = f"{row['val_balanced_accuracy']:.3f}" if "val_balanced_accuracy" in row else "-"
152
+ print(f" epoch {epoch:02d} loss {row['loss']:.4f} val bal-acc {score_txt}", flush=True)
153
+ if x_val is not None and stale >= patience:
154
+ print(f" early stop at epoch {epoch}", flush=True)
155
+ break
156
+
157
+ if best_state is not None:
158
+ model.load_state_dict(best_state)
159
+ model.to(device)
160
+ info = {
161
+ "device": str(device),
162
+ "best_epoch": best_epoch,
163
+ "best_balanced_accuracy": None if best_score < 0 else best_score,
164
+ "history": history,
165
+ }
166
+ return model, info
aura_pipeline/model.py ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import torch
4
+ from torch import nn
5
+ from torch.nn import functional as F
6
+
7
+
8
+ class AuraNet(nn.Module):
9
+ """Temporal-spatial EMG classifier.
10
+
11
+ Input is (batch, time, channels). Dropped channels are zeroed by a mask stored in the graph.
12
+ """
13
+
14
+ def __init__(
15
+ self,
16
+ n_classes: int = 6,
17
+ n_channels: int = 8,
18
+ n_samples: int = 250,
19
+ dropout: float = 0.3,
20
+ channel_mask: list[bool] | None = None,
21
+ ):
22
+ super().__init__()
23
+ f1, depth = 8, 2
24
+ f2 = f1 * depth
25
+ self.n_samples = n_samples
26
+ self.n_channels = n_channels
27
+ mask = channel_mask if channel_mask is not None else [True] * n_channels
28
+ self.register_buffer(
29
+ "channel_mask",
30
+ torch.tensor([1.0 if k else 0.0 for k in mask], dtype=torch.float32),
31
+ )
32
+ self.temporal = nn.Conv2d(1, f1, (1, 64), padding=(0, 32), bias=False)
33
+ self.bn1 = nn.BatchNorm2d(f1)
34
+ self.spatial = nn.Conv2d(f1, f2, (n_channels, 1), groups=f1, bias=False)
35
+ self.bn2 = nn.BatchNorm2d(f2)
36
+ self.pool1 = nn.AvgPool2d((1, 4))
37
+ self.drop1 = nn.Dropout(dropout)
38
+ self.sep_dw = nn.Conv2d(f2, f2, (1, 16), padding=(0, 8), groups=f2, bias=False)
39
+ self.sep_pw = nn.Conv2d(f2, f2, (1, 1), bias=False)
40
+ self.bn3 = nn.BatchNorm2d(f2)
41
+ self.pool2 = nn.AvgPool2d((1, 8))
42
+ self.drop2 = nn.Dropout(dropout)
43
+ feat = self._probe(n_samples, n_channels)
44
+ self.head = nn.Linear(feat, n_classes)
45
+ self._reset_bn()
46
+
47
+ def _reset_bn(self) -> None:
48
+ for module in self.modules():
49
+ if isinstance(module, nn.BatchNorm2d):
50
+ module.reset_running_stats()
51
+
52
+ def _probe(self, n_samples: int, n_channels: int) -> int:
53
+ was = self.training
54
+ self.eval()
55
+ with torch.no_grad():
56
+ y = self.features(torch.zeros(1, n_samples, n_channels))
57
+ self.train(was)
58
+ if y.shape[-1] < 1:
59
+ raise ValueError("Window is too short for this network. Use at least 1 second at 250 Hz.")
60
+ return int(y.shape[-1])
61
+
62
+ def features(self, x: torch.Tensor) -> torch.Tensor:
63
+ x = x * self.channel_mask.view(1, 1, -1)
64
+ x = x.permute(0, 2, 1).unsqueeze(1)
65
+ x = self.bn1(self.temporal(x))
66
+ x = F.elu(self.bn2(self.spatial(x)))
67
+ x = self.drop1(self.pool1(x))
68
+ x = F.elu(self.bn3(self.sep_pw(self.sep_dw(x))))
69
+ x = self.drop2(self.pool2(x))
70
+ return torch.flatten(x, 1)
71
+
72
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
73
+ return self.head(self.features(x))
74
+
75
+
76
+ class ProbModel(nn.Module):
77
+ """Wraps AuraNet so the exported output is class probabilities, not logits."""
78
+
79
+ def __init__(self, net: AuraNet):
80
+ super().__init__()
81
+ self.net = net
82
+
83
+ def forward(self, emg: torch.Tensor) -> torch.Tensor:
84
+ return torch.softmax(self.net(emg), dim=-1)
aura_pipeline/pose.py ADDED
@@ -0,0 +1,405 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Continuous hand-pose regression from palm EMG.
2
+
3
+ Parallel to the classifier. The target is the optical hand tracker carried in each
4
+ session file (per-finger curls plus pinch and openness), not motion capture. The model
5
+ distils that optical stream into the 8-channel EMG, so its ceiling is the tracker's own
6
+ quality. Output is one pose vector per window, each DoF in [0, 1].
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from pathlib import Path
13
+
14
+ import numpy as np
15
+ import torch
16
+ from torch import nn
17
+ from torch.utils.data import DataLoader, TensorDataset
18
+
19
+ from .data import N_CH, apply_mask, pose_target_names
20
+ from .fit import set_seed
21
+ from .model import AuraNet
22
+
23
+
24
+ class PoseNet(nn.Module):
25
+ """AuraNet backbone with a sigmoid head. Output is a pose vector in [0, 1]."""
26
+
27
+ def __init__(
28
+ self,
29
+ n_dof: int,
30
+ n_channels: int = N_CH,
31
+ n_samples: int = 250,
32
+ channel_mask: list[bool] | None = None,
33
+ ):
34
+ super().__init__()
35
+ self.net = AuraNet(
36
+ n_classes=n_dof,
37
+ n_channels=n_channels,
38
+ n_samples=n_samples,
39
+ channel_mask=channel_mask,
40
+ )
41
+ self.n_dof = n_dof
42
+ self.n_channels = n_channels
43
+ self.n_samples = n_samples
44
+
45
+ def forward(self, emg: torch.Tensor) -> torch.Tensor:
46
+ return torch.sigmoid(self.net(emg))
47
+
48
+
49
+ def make_pose_windows(
50
+ session, cfg: dict, names: list[str]
51
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
52
+ """Windows of EMG paired with a confidence-weighted pose target.
53
+
54
+ A window is kept when at least half its samples carry a valid, present pose and the
55
+ median optical confidence clears ``pose_min_conf``. The target is the per-DoF median
56
+ over the valid samples; the sample weight is that median confidence.
57
+ """
58
+ n_dof = len(names)
59
+ win = int(round(float(cfg["window_sec"]) * session.fs))
60
+ empty = (
61
+ np.empty((0, win, N_CH), np.float32),
62
+ np.empty((0, n_dof), np.float32),
63
+ np.empty((0,), np.float32),
64
+ )
65
+ if session.pose is None or session.pose_conf is None:
66
+ return empty
67
+ hop = int(round(float(cfg["hop_sec"]) * session.fs))
68
+ min_conf = float(cfg.get("pose_min_conf", 0.3))
69
+ pose = session.pose
70
+ conf = session.pose_conf
71
+ emg = session.emg
72
+ n = min(len(emg), len(pose), len(conf))
73
+ xs, ys, ws = [], [], []
74
+ for start in range(0, n - win + 1, hop):
75
+ seg = pose[start:start + win]
76
+ c = conf[start:start + win]
77
+ valid = np.isfinite(seg).all(axis=1) & np.isfinite(c)
78
+ if valid.mean() < 0.5:
79
+ continue
80
+ cv = c[valid]
81
+ med_conf = float(np.median(cv))
82
+ if med_conf < min_conf:
83
+ continue
84
+ xs.append(emg[start:start + win])
85
+ ys.append(np.median(seg[valid], axis=0).astype(np.float32))
86
+ ws.append(med_conf)
87
+ if not xs:
88
+ return empty
89
+ return (
90
+ np.stack(xs).astype(np.float32),
91
+ np.stack(ys).astype(np.float32),
92
+ np.asarray(ws, dtype=np.float32),
93
+ )
94
+
95
+
96
+ def pose_metrics(y_true: np.ndarray, y_pred: np.ndarray, names: list[str]) -> dict:
97
+ per = []
98
+ for j, name in enumerate(names):
99
+ t = y_true[:, j]
100
+ p = y_pred[:, j]
101
+ mae = float(np.mean(np.abs(t - p))) if len(t) else float("nan")
102
+ if len(t) > 1 and np.std(t) > 1e-6 and np.std(p) > 1e-6:
103
+ r = float(np.corrcoef(t, p)[0, 1])
104
+ else:
105
+ r = float("nan")
106
+ per.append({"dof": name, "mae": mae, "r": r})
107
+ maes = [d["mae"] for d in per if np.isfinite(d["mae"])]
108
+ rs = [d["r"] for d in per if np.isfinite(d["r"])]
109
+ return {
110
+ "per_dof": per,
111
+ "mae": float(np.mean(maes)) if maes else float("nan"),
112
+ "r": float(np.mean(rs)) if rs else float("nan"),
113
+ }
114
+
115
+
116
+ @torch.no_grad()
117
+ def predict_pose(model: nn.Module, x: np.ndarray, batch: int, device: torch.device) -> np.ndarray:
118
+ model.eval()
119
+ n_dof = getattr(model, "n_dof", 0)
120
+ if len(x) == 0:
121
+ return np.empty((0, n_dof), np.float32)
122
+ loader = DataLoader(TensorDataset(torch.from_numpy(x)), batch_size=batch, shuffle=False)
123
+ out = [model(xb.to(device)).cpu().numpy() for (xb,) in loader]
124
+ return np.concatenate(out)
125
+
126
+
127
+ def train_pose(
128
+ x_tr: np.ndarray,
129
+ y_tr: np.ndarray,
130
+ w_tr: np.ndarray,
131
+ x_val: np.ndarray | None,
132
+ y_val: np.ndarray | None,
133
+ cfg: dict,
134
+ names: list[str],
135
+ channel_mask: list[bool],
136
+ epochs: int | None = None,
137
+ ) -> tuple[PoseNet, dict]:
138
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
139
+ model = PoseNet(len(names), x_tr.shape[2], x_tr.shape[1], channel_mask).to(device)
140
+ opt = torch.optim.AdamW(
141
+ model.parameters(),
142
+ lr=float(cfg["lr"]),
143
+ weight_decay=float(cfg["weight_decay"]),
144
+ )
145
+ loss_fn = nn.SmoothL1Loss(beta=float(cfg.get("pose_loss_beta", 0.1)), reduction="none")
146
+ ds = TensorDataset(torch.from_numpy(x_tr), torch.from_numpy(y_tr), torch.from_numpy(w_tr))
147
+ loader = DataLoader(ds, batch_size=int(cfg["batch_size"]), shuffle=True)
148
+ max_epochs = int(epochs if epochs is not None else cfg["epochs"])
149
+ patience = int(cfg["patience"])
150
+ best_state = None
151
+ best_mae = float("inf")
152
+ best_epoch = 0
153
+ stale = 0
154
+ history = []
155
+
156
+ for epoch in range(1, max_epochs + 1):
157
+ model.train()
158
+ total = 0.0
159
+ seen = 0
160
+ for xb, yb, wb in loader:
161
+ xb = xb.to(device)
162
+ yb = yb.to(device)
163
+ wb = wb.to(device)
164
+ opt.zero_grad(set_to_none=True)
165
+ per = loss_fn(model(xb), yb).mean(dim=1)
166
+ loss = (per * wb).sum() / (wb.sum() + 1e-8)
167
+ loss.backward()
168
+ opt.step()
169
+ total += float(loss.item()) * len(xb)
170
+ seen += len(xb)
171
+ row = {"epoch": epoch, "loss": total / max(1, seen)}
172
+ if x_val is not None and len(x_val):
173
+ metrics = pose_metrics(y_val, predict_pose(model, x_val, int(cfg["batch_size"]), device), names)
174
+ row["val_mae"] = metrics["mae"]
175
+ row["val_r"] = metrics["r"]
176
+ if metrics["mae"] < best_mae - 1e-5:
177
+ best_mae = metrics["mae"]
178
+ best_epoch = epoch
179
+ best_state = {k: v.detach().cpu().clone() for k, v in model.state_dict().items()}
180
+ stale = 0
181
+ else:
182
+ stale += 1
183
+ else:
184
+ best_epoch = epoch
185
+ best_state = {k: v.detach().cpu().clone() for k, v in model.state_dict().items()}
186
+ history.append(row)
187
+ vmae = f"{row['val_mae']:.4f}" if "val_mae" in row else "-"
188
+ vr = f"{row['val_r']:.3f}" if "val_r" in row else "-"
189
+ print(f" epoch {epoch:02d} loss {row['loss']:.4f} val mae {vmae} val r {vr}", flush=True)
190
+ if x_val is not None and stale >= patience:
191
+ print(f" early stop at epoch {epoch}", flush=True)
192
+ break
193
+
194
+ if best_state is not None:
195
+ model.load_state_dict(best_state)
196
+ model.to(device)
197
+ info = {
198
+ "device": str(device),
199
+ "best_epoch": best_epoch,
200
+ "best_val_mae": None if best_mae == float("inf") else best_mae,
201
+ "history": history,
202
+ }
203
+ return model, info
204
+
205
+
206
+ def export_pose_onnx(model: PoseNet, path: Path, names: list[str]) -> None:
207
+ model = model.cpu().eval()
208
+ dummy = torch.zeros(1, model.n_samples, model.n_channels, dtype=torch.float32)
209
+ path.parent.mkdir(parents=True, exist_ok=True)
210
+ kwargs = dict(
211
+ input_names=["emg"],
212
+ output_names=["pose"],
213
+ dynamic_axes={"emg": {0: "batch"}, "pose": {0: "batch"}},
214
+ opset_version=17,
215
+ )
216
+ try:
217
+ torch.onnx.export(model, dummy, str(path), dynamo=False, **kwargs)
218
+ except TypeError:
219
+ torch.onnx.export(model, dummy, str(path), **kwargs)
220
+ _check_ort(path, dummy.numpy(), len(names))
221
+
222
+
223
+ def _check_ort(path: Path, dummy, n_dof: int) -> None:
224
+ import onnxruntime as ort
225
+
226
+ sess = ort.InferenceSession(str(path), providers=["CPUExecutionProvider"])
227
+ out = sess.run(None, {"emg": dummy})[0]
228
+ if out.shape != (1, n_dof):
229
+ raise RuntimeError(f"ONNX pose shape {out.shape}, expected (1, {n_dof})")
230
+ if float(out.min()) < -1e-4 or float(out.max()) > 1.0 + 1e-4:
231
+ raise RuntimeError("ONNX pose outputs fall outside [0, 1]")
232
+
233
+
234
+ def write_pose_contract(path: Path, cfg: dict, names: list[str], mask: list[bool], split: dict) -> None:
235
+ fs = float(cfg["sample_rate_hz"])
236
+ win = int(round(float(cfg["window_sec"]) * fs))
237
+ contract = {
238
+ "format": "aura-pose-onnx-v1",
239
+ "task": "continuous hand-pose regression from palm EMG",
240
+ "input_name": "emg",
241
+ "input_layout": "NTC",
242
+ "input_shape": [1, win, N_CH],
243
+ "input_dtype": "float32",
244
+ "output_name": "pose",
245
+ "output_shape": [1, len(names)],
246
+ "output_range": [0, 1],
247
+ "dof": list(names),
248
+ "sample_rate_hz": fs,
249
+ "window_samples": win,
250
+ "hop_samples": int(round(float(cfg["hop_sec"]) * fs)),
251
+ "channel_names": [f"ch{i}" for i in range(1, N_CH + 1)],
252
+ "channel_keep": mask,
253
+ "preprocess": {
254
+ "in_graph": False,
255
+ "unit": "microvolts, then per-channel robust scale",
256
+ "scale": "median(abs(x)) / 0.6745, floor 1e-6, per recording over the filtered signal",
257
+ "filter": "causal sos: bandpass 20-120 order 4, bandstop 45-55 order 4, notch 100 Q=30",
258
+ "note": "Apply the filter and scale before this model. Dropped channels may be left as-is; the graph zeros them.",
259
+ },
260
+ "target_source": "optical hand tracker carried in the session file, not motion capture",
261
+ "imu_used": False,
262
+ "split_unit": "session file, not window",
263
+ "trained_on": split["train"],
264
+ "early_stop_on": split["val"],
265
+ "tested_on": split["test"],
266
+ "test_used_for_weights": False,
267
+ }
268
+ path.write_text(json.dumps(contract, indent=2), encoding="utf-8")
269
+
270
+
271
+ def _fmt(value) -> str:
272
+ return "n/a" if value is None or not np.isfinite(value) else f"{value:.4f}"
273
+
274
+
275
+ def write_pose_report(
276
+ path: Path,
277
+ names: list[str],
278
+ split: dict,
279
+ val_metrics: dict | None,
280
+ test_metrics: dict | None,
281
+ info: dict,
282
+ mask: list[bool],
283
+ counts: dict,
284
+ ) -> None:
285
+ lines = [
286
+ "# Aura hand-pose regression run",
287
+ "",
288
+ "Target is the optical hand tracker carried in the session files, not motion capture.",
289
+ "The model distils that stream into EMG, so its ceiling is the tracker's quality.",
290
+ "",
291
+ f"- Train sessions ({len(split['train'])}): {', '.join(split['train'])}",
292
+ f"- Val sessions ({len(split['val'])}): {', '.join(split['val'])}",
293
+ f"- Test sessions ({len(split['test'])}): {', '.join(split['test'])}",
294
+ f"- Windows: train={counts['train']} val={counts['val']} test={counts['test']}",
295
+ f"- Val mean MAE (early stop only): {_fmt(val_metrics['mae']) if val_metrics else 'n/a'}",
296
+ f"- Test mean MAE (never used to train): {_fmt(test_metrics['mae']) if test_metrics else 'n/a'}",
297
+ f"- Test mean r: {_fmt(test_metrics['r']) if test_metrics else 'n/a'}",
298
+ f"- Epoch kept: {info['best_epoch']}",
299
+ f"- Device: {info['device']}",
300
+ f"- Window: {counts.get('window_sec', 1.0)} s",
301
+ "",
302
+ "## Per-DoF test error",
303
+ "",
304
+ "| DoF | MAE | r |",
305
+ "| --- | --- | --- |",
306
+ ]
307
+ test_per = {d["dof"]: d for d in (test_metrics["per_dof"] if test_metrics else [])}
308
+ for name in names:
309
+ row = test_per.get(name)
310
+ mae = _fmt(row["mae"]) if row else "n/a"
311
+ r = _fmt(row["r"]) if row else "n/a"
312
+ lines.append(f"| {name} | {mae} | {r} |")
313
+ lines += [
314
+ "",
315
+ "MAE is in target units (each DoF is [0, 1]). r is Pearson correlation on the test sessions.",
316
+ "Copy `split.json` into config.yaml to freeze this split.",
317
+ "",
318
+ ]
319
+ path.write_text("\n".join(lines), encoding="utf-8")
320
+
321
+
322
+ def run_pose(sessions, cfg: dict, out: Path, split: dict, mask: list[bool]) -> int:
323
+ names = pose_target_names(cfg)
324
+ if not any(s.pose is not None for s in sessions):
325
+ raise SystemExit("No pose columns found. Expected: " + ", ".join(names))
326
+
327
+ per_session = {}
328
+ for session in sessions:
329
+ x, y, w = make_pose_windows(session, cfg, names)
330
+ x = apply_mask(x, mask)
331
+ per_session[session.name] = (x, y, w)
332
+ print(f" {session.name}: {len(x)} pose windows", flush=True)
333
+
334
+ win = int(round(float(cfg["window_sec"]) * float(cfg["sample_rate_hz"])))
335
+
336
+ def gather(role: str):
337
+ xs, ys, ws = [], [], []
338
+ for name in split[role]:
339
+ bundle = per_session.get(name)
340
+ if bundle is not None and len(bundle[0]):
341
+ xs.append(bundle[0])
342
+ ys.append(bundle[1])
343
+ ws.append(bundle[2])
344
+ if not xs:
345
+ return (
346
+ np.empty((0, win, N_CH), np.float32),
347
+ np.empty((0, len(names)), np.float32),
348
+ np.empty((0,), np.float32),
349
+ )
350
+ return np.concatenate(xs), np.concatenate(ys), np.concatenate(ws)
351
+
352
+ x_tr, y_tr, w_tr = gather("train")
353
+ x_val, y_val, w_val = gather("val")
354
+ x_te, y_te, w_te = gather("test")
355
+ if not len(x_tr):
356
+ raise SystemExit("No pose training windows. Lower pose_min_conf or check the optical columns.")
357
+ counts = {
358
+ "train": int(len(x_tr)),
359
+ "val": int(len(x_val)),
360
+ "test": int(len(x_te)),
361
+ "window_sec": float(cfg["window_sec"]),
362
+ }
363
+ print(f"pose windows train={counts['train']} val={counts['val']} test={counts['test']}", flush=True)
364
+
365
+ set_seed(int(cfg["seed"]))
366
+ model, info = train_pose(x_tr, y_tr, w_tr, x_val, y_val, cfg, names, mask)
367
+ device = next(model.parameters()).device
368
+ val_metrics = (
369
+ pose_metrics(y_val, predict_pose(model, x_val, int(cfg["batch_size"]), device), names)
370
+ if len(x_val)
371
+ else None
372
+ )
373
+ test_metrics = (
374
+ pose_metrics(y_te, predict_pose(model, x_te, int(cfg["batch_size"]), device), names)
375
+ if len(x_te)
376
+ else None
377
+ )
378
+ if val_metrics:
379
+ print(f"val mean mae {val_metrics['mae']:.4f} mean r {val_metrics['r']:.3f} (early stop only)", flush=True)
380
+ if test_metrics:
381
+ print(f"test mean mae {test_metrics['mae']:.4f} mean r {test_metrics['r']:.3f} (not used for weights)", flush=True)
382
+
383
+ onnx_path = out / "pose.onnx"
384
+ export_pose_onnx(model, onnx_path, names)
385
+ torch.save(
386
+ {"state_dict": model.cpu().state_dict(), "dof": names, "mask": mask, "split": split},
387
+ out / "pose.pt",
388
+ )
389
+ write_pose_contract(out / "pose_contract.json", cfg, names, mask, split)
390
+ write_pose_report(out / "pose_report.md", names, split, val_metrics, test_metrics, info, mask, counts)
391
+ summary = {
392
+ "dof": names,
393
+ "counts": counts,
394
+ "split": split,
395
+ "val": val_metrics,
396
+ "test": test_metrics,
397
+ "best_epoch": info["best_epoch"],
398
+ "device": info["device"],
399
+ "channel_keep": mask,
400
+ "onnx": str(onnx_path),
401
+ }
402
+ (out / "pose_report.json").write_text(json.dumps(summary, indent=2), encoding="utf-8")
403
+ print(f"wrote {onnx_path}")
404
+ print(f"contract {out / 'pose_contract.json'}")
405
+ return 0
aura_pipeline/report.py ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ import numpy as np
7
+
8
+
9
+ def write_qc(path: Path, rows: list[dict], mask: list[bool]) -> None:
10
+ lines = [
11
+ "# Channel QC",
12
+ "",
13
+ "Hard failures (flat, empty, railed, or forced) are zeroed in that file.",
14
+ "A channel is removed from the shipped model only if it fails in many files.",
15
+ "",
16
+ "Shipped channel keep mask (ch1..ch8): " + " ".join("1" if k else "0" for k in mask),
17
+ "",
18
+ "| session | ch | std uV | snr | mains | verdict | reason |",
19
+ "| --- | --- | --- | --- | --- | --- | --- |",
20
+ ]
21
+ for row in rows:
22
+ snr = "" if row["snr"] is None else f"{row['snr']:.2f}"
23
+ mains = "" if row["mains_ratio"] is None else f"{row['mains_ratio']:.2f}"
24
+ lines.append(
25
+ f"| {row['session']} | {row['channel']} | {row['std_uv']:.1f} | {snr} | {mains} | {row['verdict']} | {row['reason']} |"
26
+ )
27
+ lines.append("")
28
+ path.write_text("\n".join(lines), encoding="utf-8")
29
+
30
+
31
+ def write_report(path: Path, summary: dict) -> None:
32
+ counts = summary["class_counts"]
33
+ lines = [
34
+ "# Aura hand training run",
35
+ "",
36
+ f"- Train sessions ({len(summary['split']['train'])}): {', '.join(summary['split']['train'])}",
37
+ f"- Val sessions ({len(summary['split']['val'])}): {', '.join(summary['split']['val'])}",
38
+ f"- Test sessions ({len(summary['split']['test'])}): {', '.join(summary['split']['test'])}",
39
+ f"- Train windows: {summary['n_train']}",
40
+ f"- Val balanced accuracy (early stop only): {_pct(summary.get('val_balanced_accuracy'))}",
41
+ f"- Test balanced accuracy (never used to train): {_pct(summary.get('test_balanced_accuracy'))}",
42
+ f"- Epoch kept: {summary['ship_epochs']}",
43
+ f"- Device: {summary['device']}",
44
+ f"- ONNX: `{summary['onnx']}`",
45
+ ]
46
+ extra = []
47
+ if summary.get("session_balanced"):
48
+ extra.append("- Session-balanced sampler: yes")
49
+ if summary.get("window_sec") not in (None, 1.0):
50
+ extra.append(f"- Window: {summary['window_sec']} s")
51
+ if extra:
52
+ lines[11:11] = extra
53
+ lines += [
54
+ "",
55
+ "## Train class counts",
56
+ "",
57
+ "| class | windows |",
58
+ "| --- | --- |",
59
+ ]
60
+ for name, n in counts.items():
61
+ lines.append(f"| {name} | {n} |")
62
+ lines += [
63
+ "",
64
+ "## Test confusion",
65
+ "",
66
+ "Rows are true class, columns are predicted, order is the class list in config.yaml.",
67
+ "This matrix is the test sessions only. Those files were not in the loss and not in early stopping.",
68
+ "",
69
+ "```",
70
+ np.array2string(np.asarray(summary["confusion"])) if summary.get("confusion") is not None else "n/a",
71
+ "```",
72
+ "",
73
+ "Copy `split.json` into config.yaml (`train_sessions`, `val_sessions`, `test_sessions`) to freeze this split.",
74
+ "",
75
+ ]
76
+ if summary.get("warnings"):
77
+ lines.append("## Warnings")
78
+ lines.append("")
79
+ for w in summary["warnings"]:
80
+ lines.append(f"- {w}")
81
+ lines.append("")
82
+ path.write_text("\n".join(lines), encoding="utf-8")
83
+ path.with_suffix(".json").write_text(json.dumps(summary, indent=2), encoding="utf-8")
84
+
85
+
86
+ def _pct(value) -> str:
87
+ if value is None:
88
+ return "n/a"
89
+ return f"{float(value) * 100:.1f}%"
aura_pipeline/signal.py ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import numpy as np
4
+ from scipy.signal import butter, iirnotch, resample, sosfilt, sosfilt_zi, tf2sos
5
+
6
+
7
+ def design_sos(fs: float, cfg: dict) -> np.ndarray:
8
+ """Causal SOS: bandpass, mains bandstop, harmonic notch. Coefficients come from config."""
9
+ lo, hi = cfg["bandpass_hz"]
10
+ order = int(cfg["bandpass_order"])
11
+ mains = float(cfg["notch_mains_hz"])
12
+ parts = [
13
+ butter(order, (lo, hi), btype="bandpass", fs=fs, output="sos"),
14
+ butter(order, (mains - 5.0, mains + 5.0), btype="bandstop", fs=fs, output="sos"),
15
+ tf2sos(*iirnotch(float(cfg["notch_harmonic_hz"]), Q=float(cfg["notch_q"]), fs=fs)),
16
+ ]
17
+ return np.vstack(parts)
18
+
19
+
20
+ def causal_filter(x: np.ndarray, sos: np.ndarray) -> np.ndarray:
21
+ """Forward SOS filter. Initial state is set from the first sample."""
22
+ if len(x) == 0:
23
+ return x.astype(np.float32)
24
+ zi = np.repeat(sosfilt_zi(sos)[:, :, None], x.shape[1], axis=2)
25
+ zi = zi * x[0].astype(np.float64)[None, None, :]
26
+ y, _ = sosfilt(sos, x.astype(np.float64), axis=0, zi=zi)
27
+ return y.astype(np.float32)
28
+
29
+
30
+ def to_rate(x: np.ndarray, t: np.ndarray, target_fs: float) -> tuple[np.ndarray, float, int | None]:
31
+ """Return signal, sample rate, and new length if resampled."""
32
+ if len(t) < 3:
33
+ return x, target_fs, None
34
+ dt = float(np.median(np.diff(t)))
35
+ if not np.isfinite(dt) or dt <= 0:
36
+ return x, target_fs, None
37
+ fs = 1.0 / dt
38
+ if abs(fs - target_fs) / target_fs < 0.02:
39
+ return x, fs, None
40
+ n = int(round(len(x) * target_fs / fs))
41
+ if n < 8:
42
+ return x, fs, None
43
+ y = resample(x, n, axis=0).astype(np.float32)
44
+ return y, target_fs, n
45
+
46
+
47
+ def robust_scale(x: np.ndarray) -> np.ndarray:
48
+ scale = np.median(np.abs(x), axis=0) / 0.6745
49
+ scale = np.asarray(scale, dtype=np.float32)
50
+ scale[scale < 1e-6] = 1.0
51
+ return scale
52
+
53
+
54
+ def mains_ratio(x: np.ndarray, fs: float, mains: float = 50.0) -> np.ndarray:
55
+ """Fraction of 20-120 Hz power sitting in a 2 Hz band around mains. Per channel."""
56
+ n = x.shape[0]
57
+ if n < int(fs):
58
+ return np.full(x.shape[1], np.nan, dtype=np.float32)
59
+ spec = np.abs(np.fft.rfft(x, axis=0)) ** 2
60
+ freqs = np.fft.rfftfreq(n, d=1.0 / fs)
61
+ band = (freqs >= 20.0) & (freqs <= 120.0)
62
+ line = (freqs >= mains - 1.0) & (freqs <= mains + 1.0)
63
+ total = spec[band].sum(axis=0) + 1e-12
64
+ return (spec[line].sum(axis=0) / total).astype(np.float32)
aura_pipeline/split.py ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import numpy as np
4
+
5
+
6
+ def assign_sessions(names: list[str], cfg: dict) -> dict[str, list[str]]:
7
+ """Assign each session file to train, validation, or test.
8
+
9
+ Windows from one file stay in one role. Adjacent windows overlap.
10
+ """
11
+ known = set(names)
12
+ pinned = {
13
+ "train": list(cfg.get("train_sessions") or []),
14
+ "val": list(cfg.get("val_sessions") or []),
15
+ "test": list(cfg.get("test_sessions") or []),
16
+ }
17
+ if any(pinned.values()):
18
+ missing = [n for group in pinned.values() for n in group if n not in known]
19
+ if missing:
20
+ raise SystemExit("Split lists name unknown sessions: " + ", ".join(missing))
21
+ used = [n for group in pinned.values() for n in group]
22
+ if len(used) != len(set(used)):
23
+ raise SystemExit("A session is listed in more than one of train, val, test.")
24
+ leftover = [n for n in names if n not in set(used)]
25
+ rng = np.random.default_rng(int(cfg.get("seed", 7)))
26
+ rng.shuffle(leftover)
27
+ if not pinned["test"]:
28
+ n_test = max(1, int(round(len(names) * float(cfg.get("test_fraction", 0.2)))))
29
+ pinned["test"] = leftover[:n_test]
30
+ leftover = leftover[n_test:]
31
+ if not pinned["val"]:
32
+ n_val = max(1, int(round(len(names) * float(cfg.get("val_fraction", 0.2)))))
33
+ pinned["val"] = leftover[:n_val]
34
+ leftover = leftover[n_val:]
35
+ pinned["train"] = pinned["train"] + leftover
36
+ if not pinned["train"] or not pinned["val"] or not pinned["test"]:
37
+ raise SystemExit("Split needs at least one train, one val, and one test session.")
38
+ return pinned
39
+
40
+ if len(names) < 3:
41
+ raise SystemExit(
42
+ f"Need at least 3 session files for train / val / test. Found {len(names)}. "
43
+ "Add recordings to data/raw."
44
+ )
45
+
46
+ order = list(names)
47
+ rng = np.random.default_rng(int(cfg.get("seed", 7)))
48
+ rng.shuffle(order)
49
+ n_test = max(1, int(round(len(order) * float(cfg.get("test_fraction", 0.2)))))
50
+ n_val = max(1, int(round(len(order) * float(cfg.get("val_fraction", 0.2)))))
51
+ if n_test + n_val >= len(order):
52
+ n_test, n_val = 1, 1
53
+ return {
54
+ "test": order[:n_test],
55
+ "val": order[n_test:n_test + n_val],
56
+ "train": order[n_test + n_val:],
57
+ }
config.yaml ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Paths are relative to this directory.
2
+ # Put session xlsx or csv files in data/raw, then run: python train.py
3
+
4
+ data_dir: data/raw
5
+ # Used only when data/raw has no session files.
6
+ fallback_data_dir: ../dataset
7
+
8
+ sample_rate_hz: 250
9
+ window_sec: 1.0
10
+ hop_sec: 0.1
11
+ # Samples within this many seconds of a label change are unlabeled.
12
+ edge_trim_sec: 0.2
13
+ min_label_fraction: 0.9
14
+
15
+ # ONNX output index 0 is rest. Do not reorder.
16
+ classes:
17
+ - rest
18
+ - fist
19
+ - open
20
+ - pinch
21
+ - point
22
+ - thumb-up
23
+
24
+ # 1-based channel numbers to force off, in addition to hard QC failures.
25
+ drop_channels: []
26
+ auto_drop_hard_failures: true
27
+ # Zero a channel in the exported graph if it hard-fails in at least this fraction of train and validation files.
28
+ global_drop_fraction: 0.5
29
+
30
+ # Optical columns are last-value holds on the EMG clock. Left off unless that rate has been checked.
31
+ drop_optical_disagreement: false
32
+ optical_conf_min: 0.6
33
+
34
+ # Causal filter applied before the network. Not baked into the ONNX graph.
35
+ bandpass_hz: [20, 120]
36
+ bandpass_order: 4
37
+ notch_mains_hz: 50
38
+ notch_harmonic_hz: 100
39
+ notch_q: 30
40
+
41
+ epochs: 40
42
+ batch_size: 64
43
+ lr: 0.001
44
+ weight_decay: 0.0001
45
+ patience: 8
46
+ seed: 7
47
+
48
+ # Split unit is the session file. Fractions apply only when the three lists are empty.
49
+ # Paste names from runs/<timestamp>/split.json to freeze an assignment.
50
+ # Unlisted files are appended to train.
51
+ val_fraction: 0.2
52
+ test_fraction: 0.2
53
+ train_sessions: []
54
+ val_sessions: []
55
+ test_sessions: []
56
+ loso: false
57
+
58
+ # Continuous hand-pose regression (python train.py --pose). Parallel to the classifier.
59
+ # Target is the optical hand tracker carried in the session file, not motion capture.
60
+ # Each DoF is already scaled to [0, 1]; the model head is a sigmoid.
61
+ pose_targets:
62
+ - thumb_curl
63
+ - index_curl
64
+ - middle_curl
65
+ - ring_curl
66
+ - pinky_curl
67
+ - pinch
68
+ - openness
69
+ # Drop a window whose median optical confidence is below this.
70
+ pose_min_conf: 0.3
71
+ # SmoothL1 transition point, in target units.
72
+ pose_loss_beta: 0.1
data/raw/.gitkeep ADDED
File without changes
data/raw/README.md ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ Session files for training. One file per recording. Not committed.
2
+
3
+ Accepted: `.xlsx` (first sheet) and `.csv` with columns `t`, `ch1`..`ch8`, and `label`. Optional: `emg_new`, `label_source`, `optical_class`, `optical_conf`, `optical_present`.
4
+
5
+ IMU columns are not used. Labeling, filtering, and the train/validation/test split are specified in the pipeline README.
infer.py ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Mock one live window. Filter and scale are the caller's job; they are not in the ONNX graph."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ import numpy as np
10
+ import onnxruntime as ort
11
+
12
+ from aura_pipeline.config import ROOT, load_config
13
+ from aura_pipeline.signal import causal_filter, design_sos, robust_scale
14
+
15
+ WIN = 250
16
+ N_CH = 8
17
+ WARMUP = 125 # 0.5 s at 250 Hz. Training drops this after the causal filter.
18
+
19
+
20
+ def resolve_onnx(arg: str | None) -> Path:
21
+ if arg:
22
+ path = Path(arg)
23
+ return path.resolve() if path.is_absolute() else (Path.cwd() / path).resolve()
24
+ latest = ROOT / "runs" / "latest.txt"
25
+ if latest.is_file():
26
+ cand = Path(latest.read_text(encoding="utf-8").strip()) / "model.onnx"
27
+ if cand.is_file():
28
+ return cand
29
+ found = sorted((ROOT / "runs").glob("*/model.onnx"))
30
+ if found:
31
+ return found[-1]
32
+ raise SystemExit("No model.onnx. Train with python train.py, or pass a path.")
33
+
34
+
35
+ def main() -> int:
36
+ cfg = load_config()
37
+ onnx_path = resolve_onnx(sys.argv[1] if len(sys.argv) > 1 else None)
38
+ if not onnx_path.is_file():
39
+ raise SystemExit(f"Missing {onnx_path}")
40
+ contract_path = onnx_path.parent / "contract.json"
41
+ contract = json.loads(contract_path.read_text(encoding="utf-8")) if contract_path.is_file() else {}
42
+ classes = list(contract.get("classes") or cfg["classes"])
43
+ input_name = str(contract.get("input_name") or "emg")
44
+ win = int(contract.get("window_samples") or WIN)
45
+
46
+ # Mock live EMG in microvolts, NTC (time, channel). Extra warmup matches training.
47
+ rng = np.random.default_rng(0)
48
+ raw = rng.normal(0, 25, size=(WARMUP + win, N_CH)).astype(np.float32)
49
+ filtered = causal_filter(raw, design_sos(float(cfg["sample_rate_hz"]), cfg))[WARMUP:]
50
+ window = (filtered / robust_scale(filtered)).astype(np.float32)[None, :, :]
51
+
52
+ sess = ort.InferenceSession(str(onnx_path), providers=["CPUExecutionProvider"])
53
+ probs = sess.run(None, {input_name: window})[0][0]
54
+ print(f"onnx {onnx_path}")
55
+ print(f"input {tuple(window.shape)} float32 time x channel scaled EMG")
56
+ for name, p in zip(classes, probs):
57
+ print(f" {name:10s} {float(p):.4f}")
58
+ print(f"sum {float(probs.sum()):.4f}")
59
+ return 0
60
+
61
+
62
+ if __name__ == "__main__":
63
+ raise SystemExit(main())
notebooks/loso.ipynb ADDED
@@ -0,0 +1,152 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "id": "b3f65536",
6
+ "metadata": {},
7
+ "source": [
8
+ "# LOSO (Kaggle or Colab GPU)\n",
9
+ "\n",
10
+ "`python train.py --loso`: one classifier train, then leave-one-session-out on train+val. Test files stay sealed.\n",
11
+ "\n",
12
+ "AuraNet is small. A free T4 is enough. CPU LOSO is hours; do not run it on a laptop as the default.\n",
13
+ "\n",
14
+ "1. Runtime / accelerator: GPU.\n",
15
+ "2. Do **not** `pip install torch`. The runtime already has CUDA PyTorch.\n",
16
+ "3. Put session `.xlsx` / `.csv` in `data/raw/` (~104 MB, not in git).\n",
17
+ "4. `loso.json` is written after each fold."
18
+ ]
19
+ },
20
+ {
21
+ "cell_type": "code",
22
+ "execution_count": null,
23
+ "id": "b601de9d",
24
+ "metadata": {},
25
+ "outputs": [],
26
+ "source": [
27
+ "import os, shutil, subprocess, sys\n",
28
+ "from pathlib import Path\n",
29
+ "\n",
30
+ "import torch\n",
31
+ "\n",
32
+ "print(\"cuda\", torch.cuda.is_available(), torch.cuda.get_device_name(0) if torch.cuda.is_available() else None)\n",
33
+ "if not torch.cuda.is_available():\n",
34
+ " raise SystemExit(\"No GPU. Kaggle: Settings > Accelerator > GPU. Colab: Runtime > Change runtime type > T4 GPU.\")"
35
+ ]
36
+ },
37
+ {
38
+ "cell_type": "code",
39
+ "execution_count": null,
40
+ "id": "fdabba89",
41
+ "metadata": {},
42
+ "outputs": [],
43
+ "source": [
44
+ "# Repo root: this notebook lives in notebooks/ when run from a clone.\n",
45
+ "root = Path.cwd()\n",
46
+ "if (root / \"notebooks\").is_dir() and (root / \"train.py\").is_file():\n",
47
+ " pass\n",
48
+ "elif (root.parent / \"train.py\").is_file():\n",
49
+ " os.chdir(root.parent)\n",
50
+ "elif (root / \"train.py\").is_file():\n",
51
+ " pass\n",
52
+ "else:\n",
53
+ " raise SystemExit(\"Run from the repo (folder that contains train.py), or cd there first.\")\n",
54
+ "print(\"cwd\", Path.cwd())"
55
+ ]
56
+ },
57
+ {
58
+ "cell_type": "code",
59
+ "execution_count": null,
60
+ "id": "dbe77f48",
61
+ "metadata": {},
62
+ "outputs": [],
63
+ "source": [
64
+ "# Install extras only. Skip torch.\n",
65
+ "subprocess.check_call([\n",
66
+ " sys.executable, \"-m\", \"pip\", \"install\", \"-q\",\n",
67
+ " \"numpy>=1.26\", \"pandas>=2.2\", \"openpyxl>=3.1\", \"scipy>=1.11\", \"PyYAML>=6.0\",\n",
68
+ " \"onnx>=1.16\", \"onnxruntime>=1.18\",\n",
69
+ "])\n",
70
+ "print(\"ok\")"
71
+ ]
72
+ },
73
+ {
74
+ "cell_type": "markdown",
75
+ "id": "b7bd15e2",
76
+ "metadata": {},
77
+ "source": [
78
+ "## Data\n",
79
+ "\n",
80
+ "Copy files into `data/raw/`.\n",
81
+ "\n",
82
+ "- Kaggle: add the session dataset, then set `SRC` to that input path.\n",
83
+ "- Colab: upload the xlsx files, or copy from Drive."
84
+ ]
85
+ },
86
+ {
87
+ "cell_type": "code",
88
+ "execution_count": null,
89
+ "id": "5392ee02",
90
+ "metadata": {},
91
+ "outputs": [],
92
+ "source": [
93
+ "raw = Path(\"data/raw\")\n",
94
+ "raw.mkdir(parents=True, exist_ok=True)\n",
95
+ "\n",
96
+ "# Optional: copy from a Kaggle input or Drive folder.\n",
97
+ "SRC = os.environ.get(\"AURA_DATA\", \"\") # e.g. /kaggle/input/your-dataset\n",
98
+ "if SRC:\n",
99
+ " src = Path(SRC)\n",
100
+ " n = 0\n",
101
+ " for p in list(src.glob(\"*.xlsx\")) + list(src.glob(\"*.csv\")):\n",
102
+ " dest = raw / p.name\n",
103
+ " if not dest.exists():\n",
104
+ " shutil.copy2(p, dest)\n",
105
+ " n += 1\n",
106
+ " print(\"copied\", n, \"from\", src)\n",
107
+ "\n",
108
+ "files = sorted(p.name for p in raw.iterdir() if p.suffix.lower() in {\".xlsx\", \".csv\"} and not p.name.startswith(\"~$\"))\n",
109
+ "print(len(files), \"session files\")\n",
110
+ "print(\"\\n\".join(files))\n",
111
+ "if len(files) < 3:\n",
112
+ " raise SystemExit(\"Need at least 3 session files in data/raw/.\")"
113
+ ]
114
+ },
115
+ {
116
+ "cell_type": "code",
117
+ "execution_count": null,
118
+ "id": "6c426d06",
119
+ "metadata": {},
120
+ "outputs": [],
121
+ "source": [
122
+ "subprocess.check_call([sys.executable, \"train.py\", \"--loso\"], cwd=str(Path.cwd()))"
123
+ ]
124
+ },
125
+ {
126
+ "cell_type": "code",
127
+ "execution_count": null,
128
+ "id": "52c39220",
129
+ "metadata": {},
130
+ "outputs": [],
131
+ "source": [
132
+ "import json\n",
133
+ "\n",
134
+ "latest = Path(\"runs/latest.txt\").read_text(encoding=\"utf-8\").strip()\n",
135
+ "path = Path(latest) / \"loso.json\"\n",
136
+ "print(path)\n",
137
+ "rows = json.loads(path.read_text(encoding=\"utf-8\"))\n",
138
+ "if isinstance(rows, dict) and \"scores\" in rows:\n",
139
+ " rows = rows[\"scores\"]\n",
140
+ "for r in rows:\n",
141
+ " print(f\"{r['session']:20s} {100 * r['balanced_accuracy']:.1f}%\")"
142
+ ]
143
+ }
144
+ ],
145
+ "metadata": {
146
+ "language_info": {
147
+ "name": "python"
148
+ }
149
+ },
150
+ "nbformat": 4,
151
+ "nbformat_minor": 5
152
+ }
requirements.txt ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ numpy>=1.26
2
+ pandas>=2.2
3
+ openpyxl>=3.1
4
+ scipy>=1.11
5
+ PyYAML>=6.0
6
+ torch>=2.2
7
+ onnx>=1.16
8
+ onnxruntime>=1.18
runs/.gitkeep ADDED
File without changes
train.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ """Train from this folder: python train.py"""
2
+
3
+ from aura_pipeline.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())