Upload 22 files
Browse files- .gitignore +15 -0
- README.md +255 -0
- aura_pipeline/__init__.py +3 -0
- aura_pipeline/__main__.py +4 -0
- aura_pipeline/cli.py +282 -0
- aura_pipeline/config.py +30 -0
- aura_pipeline/data.py +298 -0
- aura_pipeline/export.py +85 -0
- aura_pipeline/fit.py +166 -0
- aura_pipeline/model.py +84 -0
- aura_pipeline/pose.py +405 -0
- aura_pipeline/report.py +89 -0
- aura_pipeline/signal.py +64 -0
- aura_pipeline/split.py +57 -0
- config.yaml +72 -0
- data/raw/.gitkeep +0 -0
- data/raw/README.md +5 -0
- infer.py +63 -0
- notebooks/loso.ipynb +152 -0
- requirements.txt +8 -0
- runs/.gitkeep +0 -0
- train.py +6 -0
.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())
|