Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
799f8e8
Bump scipy to 1.16.3 for macOS dyld compatibility
neuromechanist Sep 17, 2026
57f83e4
Move oct2py and psutil to extras, drop pyedflib from base install
neuromechanist Sep 17, 2026
1df3fcc
Resolve ASR max_mem default without psutil
neuromechanist Sep 17, 2026
0e071bb
Raise ImportError naming the extra for missing oct2py/psutil
neuromechanist Sep 17, 2026
8c4358b
Add Pyodide base-install resolution check script
neuromechanist Sep 17, 2026
5b15d9c
Document eeglab and sys extras in install docs
neuromechanist Sep 17, 2026
417abef
Add iclabel extra for onnxruntime; torch extra becomes export-only
neuromechanist Sep 17, 2026
cba8997
Treat .onnx files as binary in pre-commit checks
neuromechanist Sep 17, 2026
41a1484
Add ICLabel ONNX export script and packaged iclabel.onnx artifact
neuromechanist Sep 17, 2026
0b96a37
Classify with onnxruntime instead of torch in iclabel(engine=None)
neuromechanist Sep 17, 2026
2c89f25
Update ICLabel tests for the onnxruntime backend and iclabel.onnx
neuromechanist Sep 17, 2026
5d92960
Document the iclabel extra and the iclabel.onnx artifact
neuromechanist Sep 17, 2026
435a828
Check wheels for iclabel.onnx instead of netICL.mat at release
neuromechanist Sep 17, 2026
e6e1b61
Document the extras split and the ASR max_mem default in the changelog
neuromechanist Sep 17, 2026
8da1648
Merge pull request #380 from sccn/phase/374-extras-split
neuromechanist Sep 17, 2026
b743e33
Merge epic/324-pyodide-browser into phase/377-iclabel-onnx
neuromechanist Sep 17, 2026
969e0bd
Merge pull request #381 from sccn/phase/377-iclabel-onnx
neuromechanist Sep 17, 2026
9057bd0
Phase 2: add Pyodide harness and ICA benchmark gate (#382)
neuromechanist Sep 17, 2026
e6a9525
Add async ICLabel browser execution (#384)
neuromechanist Sep 18, 2026
214eed5
Quantize ICLabel with a frozen parity gate (#383)
neuromechanist Sep 18, 2026
ed57242
Route runica products through dgemm (#385)
neuromechanist Sep 18, 2026
2747684
Fix Phase 5/6 epic review blockers (#387)
neuromechanist Sep 21, 2026
1e363a7
Merge develop into the browser epic
neuromechanist Sep 21, 2026
e9f1017
Merge pull request #403 from sccn/chore/merge-develop-into-epic
neuromechanist Sep 21, 2026
c78fd6b
Merge develop into the browser epic
neuromechanist Sep 21, 2026
2e9ecb0
Merge pull request #405 from sccn/chore/sync-402-into-epic
neuromechanist Sep 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ on:
pull_request:
branches:
- develop
- epic/324-pyodide-browser
paths:
- 'docs/**'
- 'src/**'
Expand Down Expand Up @@ -39,9 +40,9 @@ jobs:
- name: Install dependencies
run: |
uv python install 3.12
# The torch extra is required: gallery examples call ICLabel, which
# raises ImportError without it rather than degrading to a skip.
uv sync --python 3.12 --extra docs --extra torch
# ICLabel runs through the packaged ONNX network and raises ImportError
# without its runtime extra rather than degrading to a skip.
uv sync --python 3.12 --extra docs --extra iclabel

- name: Build HTML documentation
run: |
Expand Down
88 changes: 81 additions & 7 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -135,8 +135,32 @@ jobs:
fi

# Packaged data the runtime needs.
if [ "$(unzip -l "$whl" | grep -c 'netICL.mat' || true)" = "0" ]; then
echo "::error::ICLabel weights (netICL.mat) missing from the wheel"; exit 1
if [ "$(unzip -l "$whl" | grep -c 'eegprep/plugins/ICLabel/iclabel.onnx' || true)" = "0" ]; then
echo "::error::ICLabel network (iclabel.onnx) missing from the wheel"; exit 1
fi
if [ "$(unzip -l "$whl" | grep -c 'netICL.mat' || true)" != "0" ]; then
echo "::error::ICLabel weights (netICL.mat) should not ship in the wheel; export iclabel.onnx instead"; exit 1
fi
packaged_model_sha256=$(unzip -p "$whl" eegprep/plugins/ICLabel/iclabel.onnx | sha256sum | cut -d' ' -f1)
source_model_sha256=$(sha256sum src/eegprep/plugins/ICLabel/iclabel.onnx | cut -d' ' -f1)
if [ "$packaged_model_sha256" != "$source_model_sha256" ]; then
echo "::error::Packaged ICLabel network differs from the source artifact"; exit 1
fi
sdist_model_path=$(tar tzf "$sdist" | grep '/src/eegprep/plugins/ICLabel/iclabel.onnx$' | head -n 1)
if [ -z "$sdist_model_path" ]; then
echo "::error::ICLabel network (iclabel.onnx) missing from the sdist"; exit 1
fi
if [ "$(tar tzf "$sdist" | grep -c '/src/eegprep/plugins/ICLabel/netICL.mat$' || true)" != "0" ]; then
echo "::error::ICLabel weights (netICL.mat) should not ship in the sdist; export iclabel.onnx instead"; exit 1
fi
sdist_model_sha256=$(tar xOf "$sdist" "$sdist_model_path" | sha256sum | cut -d' ' -f1)
if [ "$sdist_model_sha256" != "$source_model_sha256" ]; then
echo "::error::Sdist ICLabel network differs from the source artifact"; exit 1
fi
report_model_sha256=$(python3 -c 'import json; print(json.load(open("tools/iclabel/parity_report.json"))["candidates"]["weight_only"]["sha256"])')
report_default_artifact=$(python3 -c 'import json; print(json.load(open("tools/iclabel/parity_report.json"))["default_artifact"])')
if [ "$report_default_artifact" != "iclabel_int8_weight_only.onnx" ] || [ "$source_model_sha256" != "$report_model_sha256" ]; then
echo "::error::Source ICLabel network does not match the committed parity report"; exit 1
fi
if [ "$(unzip -l "$whl" | grep -c 'resources/help/.*\.md' || true)" = "0" ]; then
echo "::error::Packaged help resources missing from the wheel"; exit 1
Expand All @@ -149,19 +173,69 @@ jobs:

- name: Smoke test the built wheel
run: |
# shellcheck disable=SC1087
set -euo pipefail
uv venv --python 3.12 /tmp/relcheck
VIRTUAL_ENV=/tmp/relcheck uv pip install dist/*.whl
VIRTUAL_ENV=/tmp/relcheck uv run --no-project python -c "
wheel=$(ls dist/*.whl)
VIRTUAL_ENV=/tmp/relcheck uv pip install "${wheel}[iclabel]" "onnxruntime==1.30.0"
VIRTUAL_ENV=/tmp/relcheck uv run --no-project python - <<'PY'
import eegprep
from importlib.resources import files
import numpy as np

print('installed', eegprep.__version__)
model = files('eegprep.plugins.ICLabel').joinpath('iclabel.onnx')
assert model.is_file(), 'installed ICLabel model is missing'
assert model.read_bytes(), 'installed ICLabel model is empty'
from eegprep.functions.popfunc.pop_loadset import pop_loadset
from eegprep.plugins.ICLabel.pop_iclabel import pop_iclabel

eeg = pop_loadset('sample_data/eeglab_data_with_ica_tmp.set')
labeled = pop_iclabel(eeg, 'default')
probabilities = np.asarray(labeled['etc']['ic_classification']['ICLabel']['classifications'])
assert probabilities.ndim == 2 and probabilities.shape.__getitem__(1) == 7
assert np.isfinite(probabilities).all()
print('installed-wheel ICLabel smoke test passed')

from eegprep.plugins.clean_rawdata.private.yulewalk import yulewalk
f = np.concatenate((np.array((0,2,3,13,16,40,80))*2.0/258, (1.0,)))
B, A = yulewalk(8, f, np.array((3,.75,.33,.33,1,1,3,3), float))
assert np.all(np.abs(np.roots(A)) < 1), 'unstable ASR shaping filter'
print('ASR shaping filter designs and is stable at 258 Hz')
PY

- name: Smoke test the built sdist
run: |
# shellcheck disable=SC1087
set -euo pipefail
uv venv --python 3.12 /tmp/relcheck-sdist
sdist=$(ls dist/*.tar.gz)
VIRTUAL_ENV=/tmp/relcheck-sdist uv pip install "${sdist}[iclabel]" "onnxruntime==1.30.0"
VIRTUAL_ENV=/tmp/relcheck-sdist uv run --no-project python - <<'PY'
import eegprep
from importlib.resources import files
import numpy as np
f = np.concatenate((np.array([0,2,3,13,16,40,80])*2.0/258, [1.0]))
B, A = yulewalk(8, f, np.array([3,.75,.33,.33,1,1,3,3], float))

print('installed from sdist', eegprep.__version__)
model = files('eegprep.plugins.ICLabel').joinpath('iclabel.onnx')
assert model.is_file(), 'installed ICLabel model is missing'
assert model.read_bytes(), 'installed ICLabel model is empty'
from eegprep.functions.popfunc.pop_loadset import pop_loadset
from eegprep.plugins.ICLabel.pop_iclabel import pop_iclabel

eeg = pop_loadset('sample_data/eeglab_data_with_ica_tmp.set')
labeled = pop_iclabel(eeg, 'default')
probabilities = np.asarray(labeled['etc']['ic_classification']['ICLabel']['classifications'])
assert probabilities.ndim == 2 and probabilities.shape.__getitem__(1) == 7
assert np.isfinite(probabilities).all()
print('installed-sdist ICLabel smoke test passed')

from eegprep.plugins.clean_rawdata.private.yulewalk import yulewalk
f = np.concatenate((np.array((0,2,3,13,16,40,80))*2.0/258, (1.0,)))
B, A = yulewalk(8, f, np.array((3,.75,.33,.33,1,1,3,3), float))
assert np.all(np.abs(np.roots(A)) < 1), 'unstable ASR shaping filter'
print('ASR shaping filter designs and is stable at 258 Hz')
"
PY

- uses: actions/upload-artifact@v4
with:
Expand Down
163 changes: 158 additions & 5 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ name: Tests

on:
push:
branches: [ master, develop ]
branches: [ master, develop, epic/324-pyodide-browser ]
pull_request:
branches: [ master, develop ]
branches: [ master, develop, epic/324-pyodide-browser ]

env:
# Set MATLAB batch licensing token for private repos or when using MATLAB Engine
Expand Down Expand Up @@ -142,7 +142,7 @@ jobs:

- name: Install dependencies
run: |
uv sync --python ${{ matrix.python-version }} --all-extras --group dev
uv sync --python "${{ matrix.python-version }}" --all-extras --group dev

- name: Set up MATLAB
if: steps.matlab-availability.outputs.available == 'true'
Expand All @@ -165,8 +165,8 @@ jobs:
print(candidates[-1])
PY
)
echo "MATLAB_ROOT=$MATLAB_ROOT" >> $GITHUB_ENV
echo "$MATLAB_ROOT/bin" >> $GITHUB_PATH
echo "MATLAB_ROOT=$MATLAB_ROOT" >> "$GITHUB_ENV"
echo "$MATLAB_ROOT/bin" >> "$GITHUB_PATH"

- name: Install Python MATLAB Engine
if: steps.matlab-availability.outputs.available == 'true'
Expand Down Expand Up @@ -229,3 +229,156 @@ jobs:
if: always()
run: |
uv pip list

pyodide:
name: Pyodide harness, ICA benchmark, and ICLabel browser parity
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4

- name: Install uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
cache-dependency-glob: "uv.lock"

- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 22

- name: Install Python 3.12
run: |
uv python install 3.12

- name: Install benchmark dependencies
run: |
uv sync --python 3.12 --group dev --extra iclabel --extra torch

- name: Build EEGPrep wheel from the working tree
run: |
mkdir -p pyodide-artifacts
uv build --wheel --out-dir pyodide-artifacts

- name: Regenerate and validate ICLabel quantized artifacts
run: |
set -euo pipefail
candidate_dir="$RUNNER_TEMP/iclabel-quantized"
report_path="$candidate_dir/parity_report.json"
uv run --no-sync python tools/iclabel/quantize_iclabel_onnx.py \
--output-dir "$candidate_dir" \
--report "$report_path" \
> pyodide-artifacts/iclabel-quantization.log
uv run --no-sync python - "$report_path" "$candidate_dir/iclabel_int8_weight_only.onnx" <<'PY'
import json
from pathlib import Path
import sys

report = json.loads(Path(sys.argv[1]).read_text())
selected = report["default_artifact"]
if selected != "iclabel_int8_weight_only.onnx":
raise SystemExit(f"unexpected selected ICLabel artifact: {selected}")
if not report["candidates"]["weight_only"]["gate_pass"]:
raise SystemExit("regenerated weight-only ICLabel artifact failed its parity gate")
generated = Path(sys.argv[2]).read_bytes()
shipped = Path("src/eegprep/plugins/ICLabel/iclabel.onnx").read_bytes()
if generated != shipped:
raise SystemExit("shipped iclabel.onnx differs from the regenerated selected artifact")
committed = json.loads(Path("tools/iclabel/parity_report.json").read_text())
if report["candidates"]["weight_only"]["sha256"] != committed["candidates"]["weight_only"]["sha256"]:
raise SystemExit("committed ICLabel parity report is stale relative to regenerated artifact")
PY

- name: Build the locked docopt wheel
run: |
uv run --no-sync python tools/pyodide/prepare_docopt_wheel.py \
--output-dir pyodide-artifacts/docopt

- name: Validate Node runner argument handling
run: |
if output=$(node tools/pyodide/run_pyodide.mjs --not-a-real-option 2>&1); then
echo "The runner accepted an invalid option" >&2
exit 1
fi
grep -q "Usage:" <<< "$output"

- name: Run Pyodide sample-data smoke test
run: |
set -o pipefail
tools/pyodide/run_pyodide.sh \
--wheel "$(find pyodide-artifacts -maxdepth 1 -type f -name '*.whl' -print -quit)" \
--docopt-wheel "$(find pyodide-artifacts/docopt -maxdepth 1 -type f -name 'docopt-0.6.2-*.whl' -print -quit)" \
--script tools/pyodide/smoke.py \
--sample-data-dir sample_data \
2>&1 | tee pyodide-artifacts/pyodide-smoke.log

- name: Run native ICLabel parity reference
env:
MNE_CONFIGDIR: ${{ runner.temp }}/mne
run: |
uv run --no-sync python tools/pyodide/iclabel_parity.py \
--platform native \
--sample-data-dir sample_data \
> pyodide-artifacts/iclabel-native.json

- name: Run browser ICLabel parity through Pyodide
run: |
set -o pipefail
tools/pyodide/run_pyodide.sh \
--iclabel-web \
--wheel "$(find pyodide-artifacts -maxdepth 1 -type f -name '*.whl' -print -quit)" \
--docopt-wheel "$(find pyodide-artifacts/docopt -maxdepth 1 -type f -name 'docopt-0.6.2-*.whl' -print -quit)" \
--script tools/pyodide/iclabel_parity.py \
--sample-data-dir sample_data \
--output pyodide-artifacts/iclabel-pyodide.json \
-- --platform pyodide \
2>&1 | tee pyodide-artifacts/iclabel-pyodide.log

- name: Compare ICLabel browser parity
run: |
set -o pipefail
uv run --no-sync python tools/pyodide/compare_iclabel.py \
--native pyodide-artifacts/iclabel-native.json \
--pyodide pyodide-artifacts/iclabel-pyodide.json \
--report pyodide-artifacts/iclabel-parity.md \
| tee pyodide-artifacts/iclabel-parity.json

- name: Run native ICA and matmul benchmark
env:
MNE_CONFIGDIR: ${{ runner.temp }}/mne
OMP_NUM_THREADS: "1"
OPENBLAS_NUM_THREADS: "1"
MKL_NUM_THREADS: "1"
VECLIB_MAXIMUM_THREADS: "1"
NUMEXPR_NUM_THREADS: "1"
run: |
uv run --no-sync python tools/pyodide/benchmark.py \
--platform native \
> pyodide-artifacts/benchmark-native.json

- name: Run Pyodide ICA and matmul benchmark
run: |
set -o pipefail
tools/pyodide/run_pyodide.sh \
--wheel "$(find pyodide-artifacts -maxdepth 1 -type f -name '*.whl' -print -quit)" \
--docopt-wheel "$(find pyodide-artifacts/docopt -maxdepth 1 -type f -name 'docopt-0.6.2-*.whl' -print -quit)" \
--script tools/pyodide/benchmark.py \
--output pyodide-artifacts/benchmark-pyodide.json \
-- --platform pyodide \
2>&1 | tee pyodide-artifacts/benchmark-pyodide.log

- name: Compare benchmark gates
run: |
set -o pipefail
uv run --no-sync python tools/pyodide/compare_benchmarks.py \
--native pyodide-artifacts/benchmark-native.json \
--pyodide pyodide-artifacts/benchmark-pyodide.json \
--report pyodide-artifacts/benchmark-comparison.md \
| tee pyodide-artifacts/benchmark-comparison.json

- name: Upload Pyodide raw output
if: always()
uses: actions/upload-artifact@v4
with:
name: pyodide-phase-2-through-phase-6-output
path: pyodide-artifacts
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ Primary references:
- `src/eegprep/functions/sigprocfunc/`: EEGLAB-style low-level signal processing functions such as `runica.py`, `runamica.py`, `topoplot.py`, `epoch.py`, and `eegrej.py`.
- `src/eegprep/plugins/clean_rawdata/`: Python ports of the EEGLAB clean_rawdata plugin, including `clean_*` and ASR modules.
- `src/eegprep/plugins/clean_rawdata/private/`: ports of clean_rawdata private helpers such as `fit_eeg_distribution`, `geometric_median`, FIR helpers, covariance helpers, and spherical-spline interpolation.
- `src/eegprep/plugins/ICLabel/`: Python ports of the EEGLAB ICLabel plugin and bundled `netICL.mat`.
- `src/eegprep/plugins/ICLabel/`: Python ports of the EEGLAB ICLabel plugin and the bundled `iclabel.onnx` network (exported from `netICL.mat` via `tools/iclabel/export_iclabel_onnx.py`).
- `src/eegprep/plugins/firfilt/`: Python ports of the EEGLAB firfilt plugin helpers.
- `src/eegprep/functions/miscfunc/`: EEGLAB-style miscellaneous helpers, including format conversion and numerical utilities.
- `src/eegprep/functions/eegobj/`: Python counterpart to EEGLAB's `functions/@eegobj/`.
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,8 @@ lean package with:
uv add eegprep
```

To install all optional extras, including GUI, console, docs, and classifier
dependencies, use:
To install all optional extras, including GUI, console, docs, classifier,
MATLAB/Octave parity, and system-aware job scheduling dependencies, use:

```bash
uv add "eegprep[all]"
Expand Down
2 changes: 1 addition & 1 deletion docs/parity/eeglab_final_parity_matrix.json
Original file line number Diff line number Diff line change
Expand Up @@ -441,7 +441,7 @@
"status": "implemented",
"responsible_phase": "phase_6",
"phase_issue": "#163 ICLabel, viewprops, and visual diagnostics completion",
"rationale": "EEGPrep has standalone ICLabel feature extraction, default netICL classification, ICLabel flagging, label statistics, menu wiring, and history-capable pop wrappers. The default Python network is packaged; EEGLAB lite/beta artifacts are explicit MATLAB/Octave passthrough choices and raise a clear standalone limitation instead of being silently ignored.",
"rationale": "EEGPrep has standalone ICLabel feature extraction, default network classification, ICLabel flagging, label statistics, menu wiring, and history-capable pop wrappers. The default network is packaged as iclabel.onnx and classified through onnxruntime; EEGLAB lite/beta artifacts are explicit MATLAB/Octave passthrough choices and raise a clear standalone limitation instead of being silently ignored.",
"user_facing_surface": [
"Tools > Classify components using ICLabel",
"iclabel",
Expand Down
1 change: 1 addition & 0 deletions docs/source/api/ica_and_components.rst
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ return zero-based component and sample indices.
eegprep.icaproj
eegprep.icavar
eegprep.iclabel
eegprep.iclabel_async
eegprep.optimal_kmeans
eegprep.picard
eegprep.posact
Expand Down
1 change: 1 addition & 0 deletions docs/source/api/interactive_pop_workflows.rst
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ ICA and Components
eegprep.pop_expica
eegprep.pop_icathresh
eegprep.pop_iclabel
eegprep.pop_iclabel_async
eegprep.pop_prop
eegprep.pop_prop_extended
eegprep.pop_runica
Expand Down
Loading
Loading