nltools 0.6.0.dev0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nltools/__init__.py +55 -0
- nltools/algorithms/__init__.py +90 -0
- nltools/algorithms/alignment/__init__.py +21 -0
- nltools/algorithms/alignment/procrustes.py +565 -0
- nltools/algorithms/alignment/srm.py +758 -0
- nltools/algorithms/backends.py +1059 -0
- nltools/algorithms/corrections.py +177 -0
- nltools/algorithms/decoding.py +327 -0
- nltools/algorithms/inference/__init__.py +50 -0
- nltools/algorithms/inference/bootstrap.py +1386 -0
- nltools/algorithms/inference/correlation.py +373 -0
- nltools/algorithms/inference/intersubject.py +422 -0
- nltools/algorithms/inference/isc.py +1554 -0
- nltools/algorithms/inference/matrix.py +602 -0
- nltools/algorithms/inference/one_sample.py +288 -0
- nltools/algorithms/inference/random.py +122 -0
- nltools/algorithms/inference/timeseries.py +347 -0
- nltools/algorithms/inference/two_sample.py +212 -0
- nltools/algorithms/inference/utils.py +58 -0
- nltools/algorithms/inference/validation.py +282 -0
- nltools/algorithms/neighborhoods.py +207 -0
- nltools/algorithms/outliers.py +308 -0
- nltools/algorithms/regression.py +83 -0
- nltools/algorithms/signal.py +303 -0
- nltools/algorithms/similarity.py +234 -0
- nltools/algorithms/validation.py +151 -0
- nltools/cross_validation.py +72 -0
- nltools/data/__init__.py +30 -0
- nltools/data/adjacency/__init__.py +875 -0
- nltools/data/adjacency/io.py +111 -0
- nltools/data/adjacency/modeling.py +569 -0
- nltools/data/adjacency/plotting.py +174 -0
- nltools/data/adjacency/state.py +349 -0
- nltools/data/adjacency/stats.py +596 -0
- nltools/data/adjacency/utils.py +79 -0
- nltools/data/atlases/__init__.py +23 -0
- nltools/data/atlases/labeling.py +158 -0
- nltools/data/atlases/loading.py +76 -0
- nltools/data/atlases/registry.py +96 -0
- nltools/data/atlases/reporting.py +456 -0
- nltools/data/braindata/__init__.py +2170 -0
- nltools/data/braindata/analysis.py +1381 -0
- nltools/data/braindata/bootstrap.py +398 -0
- nltools/data/braindata/io.py +896 -0
- nltools/data/braindata/modeling.py +594 -0
- nltools/data/braindata/plotting.py +501 -0
- nltools/data/braindata/prediction.py +1250 -0
- nltools/data/braindata/utils.py +348 -0
- nltools/data/braindata/validation.py +197 -0
- nltools/data/braindata/viewer.js +266 -0
- nltools/data/braindata/viewer.py +770 -0
- nltools/data/combine.py +27 -0
- nltools/data/designmatrix/__init__.py +1032 -0
- nltools/data/designmatrix/append.py +518 -0
- nltools/data/designmatrix/diagnostics.py +248 -0
- nltools/data/designmatrix/io.py +356 -0
- nltools/data/designmatrix/plotting.py +291 -0
- nltools/data/designmatrix/regressors.py +463 -0
- nltools/data/designmatrix/transforms.py +200 -0
- nltools/data/designmatrix/utils.py +350 -0
- nltools/data/ownership.py +129 -0
- nltools/data/results.py +291 -0
- nltools/data/roc/__init__.py +398 -0
- nltools/data/simulator/__init__.py +927 -0
- nltools/data/simulator/haxby.py +124 -0
- nltools/data/validation.py +83 -0
- nltools/datasets.py +218 -0
- nltools/io/__init__.py +10 -0
- nltools/io/events.py +67 -0
- nltools/io/h5.py +246 -0
- nltools/mask.py +403 -0
- nltools/models/__init__.py +11 -0
- nltools/models/glm.py +543 -0
- nltools/models/results.py +49 -0
- nltools/models/ridge.py +1303 -0
- nltools/models/validation.py +26 -0
- nltools/plotting/__init__.py +32 -0
- nltools/plotting/adjacency.py +421 -0
- nltools/plotting/brain.py +669 -0
- nltools/plotting/decomposition.py +111 -0
- nltools/plotting/prediction.py +110 -0
- nltools/resources/covariates_example.csv +161 -0
- nltools/resources/onsets_example.csv +40 -0
- nltools/templates/__init__.py +51 -0
- nltools/templates/config.py +144 -0
- nltools/templates/fetch.py +260 -0
- nltools/templates/matching.py +183 -0
- nltools/templates/paths.py +106 -0
- nltools/templates/registry.py +25 -0
- nltools/utils.py +230 -0
- nltools/version.py +13 -0
- nltools-0.6.0.dev0.dist-info/METADATA +95 -0
- nltools-0.6.0.dev0.dist-info/RECORD +95 -0
- nltools-0.6.0.dev0.dist-info/WHEEL +4 -0
- nltools-0.6.0.dev0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Global brain-space configuration: frozen dataclass + set/get/with API."""
|
|
2
|
+
|
|
3
|
+
from contextlib import contextmanager
|
|
4
|
+
from dataclasses import dataclass, replace
|
|
5
|
+
from collections.abc import Iterator
|
|
6
|
+
|
|
7
|
+
from .paths import _resolve_paths
|
|
8
|
+
from .registry import SUPPORTED_RESOLUTIONS, TemplateName, Resolution
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class BrainSpaceConfig:
|
|
13
|
+
"""Immutable MNI template configuration.
|
|
14
|
+
|
|
15
|
+
Attributes:
|
|
16
|
+
template (str): Template variant (`'default'`, `'nilearn'`, `'fmriprep'`).
|
|
17
|
+
resolution (int): Resolution in mm (1, 2, or 3).
|
|
18
|
+
mask (str): Path to the brain mask file.
|
|
19
|
+
brain (str): Path to the brain-extracted image.
|
|
20
|
+
plot (str): Path to the full T1 image used for plotting.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
template: TemplateName = "default"
|
|
24
|
+
resolution: Resolution = 2
|
|
25
|
+
|
|
26
|
+
def __post_init__(self) -> None:
|
|
27
|
+
if self.template not in SUPPORTED_RESOLUTIONS:
|
|
28
|
+
raise ValueError(
|
|
29
|
+
f"Unknown template: {self.template!r}. "
|
|
30
|
+
f"Supported: {sorted(SUPPORTED_RESOLUTIONS)}"
|
|
31
|
+
)
|
|
32
|
+
if self.resolution not in SUPPORTED_RESOLUTIONS[self.template]:
|
|
33
|
+
raise ValueError(
|
|
34
|
+
f"Resolution {self.resolution}mm not supported for "
|
|
35
|
+
f"{self.template!r}. "
|
|
36
|
+
f"Supported: {SUPPORTED_RESOLUTIONS[self.template]}"
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def mask(self) -> str:
|
|
41
|
+
"""Path to the brain mask file."""
|
|
42
|
+
return _resolve_paths(self.template, self.resolution)["mask"]
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def brain(self) -> str:
|
|
46
|
+
"""Path to the brain-extracted image."""
|
|
47
|
+
return _resolve_paths(self.template, self.resolution)["brain"]
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def plot(self) -> str:
|
|
51
|
+
"""Path to the full T1 image used for plotting."""
|
|
52
|
+
return _resolve_paths(self.template, self.resolution)["plot"]
|
|
53
|
+
|
|
54
|
+
def __repr__(self) -> str:
|
|
55
|
+
import os
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
f"BrainSpaceConfig(template={self.template!r}, "
|
|
59
|
+
f"resolution={self.resolution}mm)\n"
|
|
60
|
+
f" mask: {os.path.basename(self.mask)}\n"
|
|
61
|
+
f" brain: {os.path.basename(self.brain)}\n"
|
|
62
|
+
f" plot: {os.path.basename(self.plot)}"
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
_DEFAULT = BrainSpaceConfig()
|
|
67
|
+
_current: BrainSpaceConfig = _DEFAULT
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def get_brainspace() -> BrainSpaceConfig:
|
|
71
|
+
"""Return the current global brain-space configuration.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
BrainSpaceConfig: The active configuration.
|
|
75
|
+
"""
|
|
76
|
+
return _current
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def set_brainspace(
|
|
80
|
+
template: TemplateName | None = None,
|
|
81
|
+
resolution: Resolution | None = None,
|
|
82
|
+
) -> BrainSpaceConfig:
|
|
83
|
+
"""Set the global brain-space configuration.
|
|
84
|
+
|
|
85
|
+
Call with no arguments to return the current config without mutating it.
|
|
86
|
+
Call with one or both arguments to mutate the global state; unspecified
|
|
87
|
+
fields retain their current value.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
template (str, optional): Template name to set (`'default'`, `'nilearn'`,
|
|
91
|
+
`'fmriprep'`). If None, keeps the current value.
|
|
92
|
+
resolution (int, optional): Resolution in mm to set. If None, keeps the
|
|
93
|
+
current value.
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
BrainSpaceConfig: The new (or unchanged) current configuration.
|
|
97
|
+
"""
|
|
98
|
+
global _current
|
|
99
|
+
if template is None and resolution is None:
|
|
100
|
+
return _current
|
|
101
|
+
updates: dict[str, object] = {}
|
|
102
|
+
if template is not None:
|
|
103
|
+
updates["template"] = template
|
|
104
|
+
if resolution is not None:
|
|
105
|
+
updates["resolution"] = resolution
|
|
106
|
+
_current = replace(_current, **updates)
|
|
107
|
+
return _current
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def reset_brainspace() -> BrainSpaceConfig:
|
|
111
|
+
"""Reset the global brain-space configuration to defaults.
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
BrainSpaceConfig: The default configuration (`'default'` template, 2 mm).
|
|
115
|
+
"""
|
|
116
|
+
global _current
|
|
117
|
+
_current = _DEFAULT
|
|
118
|
+
return _current
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@contextmanager
|
|
122
|
+
def with_brainspace(
|
|
123
|
+
template: TemplateName | None = None,
|
|
124
|
+
resolution: Resolution | None = None,
|
|
125
|
+
) -> Iterator[BrainSpaceConfig]:
|
|
126
|
+
"""Temporarily change the global brain-space configuration.
|
|
127
|
+
|
|
128
|
+
Restores the previous configuration on exit, even if an exception is
|
|
129
|
+
raised inside the block.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
template (str, optional): Template name for the duration of the block.
|
|
133
|
+
resolution (int, optional): Resolution in mm for the duration of the block.
|
|
134
|
+
|
|
135
|
+
Yields:
|
|
136
|
+
BrainSpaceConfig: The configuration active inside the block.
|
|
137
|
+
"""
|
|
138
|
+
global _current
|
|
139
|
+
previous = _current
|
|
140
|
+
try:
|
|
141
|
+
set_brainspace(template=template, resolution=resolution)
|
|
142
|
+
yield _current
|
|
143
|
+
finally:
|
|
144
|
+
_current = previous
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"""Lazy fetcher for files hosted in the `nltools/niftis` HF dataset.
|
|
2
|
+
|
|
3
|
+
Covers MNI templates, parcellation label maps, the parcel-names CSV, and
|
|
4
|
+
any other resources living under huggingface.co/datasets/nltools/niftis.
|
|
5
|
+
First call for a given file downloads it into the local HF cache
|
|
6
|
+
(`~/.cache/huggingface/hub` by default); subsequent calls return the
|
|
7
|
+
cached path without touching the network.
|
|
8
|
+
|
|
9
|
+
In Pyodide `hf_hub_download` is unusable: it HEAD-probes the resolve URL for
|
|
10
|
+
metadata, and both Pyodide HTTP backends dereference the body of that bodiless
|
|
11
|
+
response and crash. The browser path therefore skips the client and does a
|
|
12
|
+
direct GET (`pyodide.http.pyfetch`) into an IDBFS-backed cache directory, which
|
|
13
|
+
survives a page reload. Where the browser has no JavaScript Promise Integration
|
|
14
|
+
to bridge that async fetch back to a synchronous call, a `requests` GET stands
|
|
15
|
+
in and the cache is session-only — a page reload downloads again. Same repo,
|
|
16
|
+
same revision, same memoization either way; only the transport differs.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import functools
|
|
20
|
+
import sys
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
REPO_ID = "nltools/niftis"
|
|
24
|
+
REVISION = "main"
|
|
25
|
+
|
|
26
|
+
# Mount point for the Pyodide cache. IDBFS is mounted at the parent so the
|
|
27
|
+
# whole tree lands in one IndexedDB store. Outside Pyodide this is unused;
|
|
28
|
+
# huggingface_hub manages its own cache under ~/.cache/huggingface.
|
|
29
|
+
_PYODIDE_CACHE_ROOT = Path("/nltools_cache") / REVISION
|
|
30
|
+
|
|
31
|
+
# IDBFS can only be mounted once per Pyodide instance.
|
|
32
|
+
_idbfs_mounted = False
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@functools.cache
|
|
36
|
+
def fetch_resource(relpath: str) -> str:
|
|
37
|
+
"""Return a local path to a file from the `nltools/niftis` HF dataset.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
relpath (str): Path within the dataset repo, e.g.
|
|
41
|
+
`'default/2mm-MNI152-2009fsl-mask.nii.gz'` or
|
|
42
|
+
`'masks/k88_parcel_names.csv'`. Use `list_resources`
|
|
43
|
+
to enumerate what's available.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
str: Absolute path to the cached file on disk. The returned path drops
|
|
47
|
+
straight into anything that takes a NIfTI path — nilearn plotting
|
|
48
|
+
and masking helpers, `nibabel.load`, and `BrainData(path)`.
|
|
49
|
+
|
|
50
|
+
Note:
|
|
51
|
+
Resolution is memoized per `relpath` for the session — repeated
|
|
52
|
+
calls (e.g. every default-mask `BrainData` construction) return the
|
|
53
|
+
cached path with no work. A file already in the HF cache is resolved
|
|
54
|
+
offline, so only a genuine cache miss touches the network. Under
|
|
55
|
+
Pyodide the file comes from an IndexedDB-backed cache instead, which
|
|
56
|
+
persists across page reloads.
|
|
57
|
+
"""
|
|
58
|
+
if "pyodide" in sys.modules:
|
|
59
|
+
return _fetch_pyodide(relpath)
|
|
60
|
+
|
|
61
|
+
from huggingface_hub import hf_hub_download
|
|
62
|
+
from huggingface_hub.utils import LocalEntryNotFoundError
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
return hf_hub_download(
|
|
66
|
+
repo_id=REPO_ID,
|
|
67
|
+
filename=relpath,
|
|
68
|
+
repo_type="dataset",
|
|
69
|
+
revision=REVISION,
|
|
70
|
+
local_files_only=True,
|
|
71
|
+
)
|
|
72
|
+
except LocalEntryNotFoundError:
|
|
73
|
+
return hf_hub_download(
|
|
74
|
+
repo_id=REPO_ID,
|
|
75
|
+
filename=relpath,
|
|
76
|
+
repo_type="dataset",
|
|
77
|
+
revision=REVISION,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@functools.lru_cache(maxsize=8)
|
|
82
|
+
def _list_repo_files_cached(repo_id: str, revision: str) -> tuple[str, ...]:
|
|
83
|
+
"""Single HF API hit per (repo, revision) for the session."""
|
|
84
|
+
from huggingface_hub import HfApi
|
|
85
|
+
|
|
86
|
+
files = HfApi().list_repo_files(
|
|
87
|
+
repo_id=repo_id, repo_type="dataset", revision=revision
|
|
88
|
+
)
|
|
89
|
+
return tuple(sorted(files))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def list_resources(prefix: str | None = None) -> list[str]:
|
|
93
|
+
"""List files available in the `nltools/niftis` HF dataset.
|
|
94
|
+
|
|
95
|
+
Companion to `fetch_resource` — surfaces what's downloadable
|
|
96
|
+
without forcing users to remember relpath strings or visit the HF
|
|
97
|
+
web UI.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
prefix (str, optional): Path prefix to filter by (e.g. `'masks/'`,
|
|
101
|
+
`'default/'`, `'fmriprep/'`). Matches with `str.startswith`.
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
list[str]: Sorted relative paths usable with `fetch_resource`.
|
|
105
|
+
|
|
106
|
+
Note:
|
|
107
|
+
Hits the HF API once per session (cached). Under Pyodide this needs
|
|
108
|
+
`httpcore` installed — `huggingface_hub`'s client imports it lazily and
|
|
109
|
+
nothing else in the browser environment pulls it in, so run
|
|
110
|
+
`await micropip.install("httpcore")` first.
|
|
111
|
+
"""
|
|
112
|
+
files = _list_repo_files_cached(REPO_ID, REVISION)
|
|
113
|
+
if prefix:
|
|
114
|
+
return [f for f in files if f.startswith(prefix)]
|
|
115
|
+
return list(files)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _fetch_pyodide(relpath: str) -> str:
|
|
119
|
+
"""Return a cached path in the browser, downloading on a cache miss.
|
|
120
|
+
|
|
121
|
+
Two transports, chosen by what the browser supports. With JavaScript
|
|
122
|
+
Promise Integration, `pyodide.ffi.run_sync` drives an async `pyfetch` and
|
|
123
|
+
the cache is IDBFS-backed, so it survives a page reload. Without it — an
|
|
124
|
+
older browser, or a synchronous entry point that cannot stack-switch — a
|
|
125
|
+
`requests` GET does the same job into a plain in-memory cache. Either way
|
|
126
|
+
`fetch_resource` keeps the signature it has everywhere else.
|
|
127
|
+
"""
|
|
128
|
+
from pyodide.ffi import can_run_sync, run_sync
|
|
129
|
+
|
|
130
|
+
if can_run_sync():
|
|
131
|
+
return run_sync(_download_pyodide(relpath))
|
|
132
|
+
return _download_pyodide_sync(relpath)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
async def _download_pyodide(relpath: str) -> str:
|
|
136
|
+
"""Fetch one dataset file into the IDBFS cache with a direct GET."""
|
|
137
|
+
await _ensure_idbfs_mounted()
|
|
138
|
+
|
|
139
|
+
target = _PYODIDE_CACHE_ROOT / relpath
|
|
140
|
+
if target.exists():
|
|
141
|
+
return str(target)
|
|
142
|
+
|
|
143
|
+
from pyodide.http import pyfetch
|
|
144
|
+
|
|
145
|
+
url = _resource_url(relpath)
|
|
146
|
+
response = await pyfetch(url)
|
|
147
|
+
_check_status(response.status, relpath, url)
|
|
148
|
+
path = _write_cached(target, await response.bytes())
|
|
149
|
+
await _flush_idbfs()
|
|
150
|
+
return path
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _download_pyodide_sync(relpath: str) -> str:
|
|
154
|
+
"""Fetch one dataset file with `requests`, into a session-only cache.
|
|
155
|
+
|
|
156
|
+
The fallback for a browser without Promise Integration. Mounting IDBFS
|
|
157
|
+
needs an await of its own, so this path stays on the in-memory filesystem
|
|
158
|
+
and a page reload downloads again.
|
|
159
|
+
"""
|
|
160
|
+
target = _PYODIDE_CACHE_ROOT / relpath
|
|
161
|
+
if target.exists():
|
|
162
|
+
return str(target)
|
|
163
|
+
|
|
164
|
+
try:
|
|
165
|
+
import requests
|
|
166
|
+
except ImportError as error:
|
|
167
|
+
raise RuntimeError(
|
|
168
|
+
"Downloading nltools data in this browser needs either JavaScript "
|
|
169
|
+
"Promise Integration (call from an async notebook cell) or "
|
|
170
|
+
"`requests` — run `await micropip.install('requests')`."
|
|
171
|
+
) from error
|
|
172
|
+
|
|
173
|
+
url = _resource_url(relpath)
|
|
174
|
+
response = requests.get(url, timeout=60)
|
|
175
|
+
_check_status(response.status_code, relpath, url)
|
|
176
|
+
return _write_cached(target, response.content)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _resource_url(relpath: str) -> str:
|
|
180
|
+
"""Build the HF resolve URL both browser transports GET."""
|
|
181
|
+
return f"https://huggingface.co/datasets/{REPO_ID}/resolve/{REVISION}/{relpath}"
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _check_status(status: int, relpath: str, url: str) -> None:
|
|
185
|
+
"""Raise unless the download returned 200."""
|
|
186
|
+
if status != 200:
|
|
187
|
+
raise RuntimeError(
|
|
188
|
+
f"Could not download {relpath!r} from {REPO_ID}: HTTP {status} for {url}"
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _write_cached(target: Path, payload: bytes) -> str:
|
|
193
|
+
"""Write `payload` to `target` atomically and return the path.
|
|
194
|
+
|
|
195
|
+
A write that dies partway would otherwise leave a short file that every
|
|
196
|
+
later call serves as a cache hit — and that `_flush_idbfs` may already have
|
|
197
|
+
pushed into IndexedDB, where only wiping site data clears it. Renaming a
|
|
198
|
+
fully written sibling into place is atomic on Emscripten's filesystem.
|
|
199
|
+
"""
|
|
200
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
201
|
+
partial = target.with_name(target.name + ".part")
|
|
202
|
+
try:
|
|
203
|
+
partial.write_bytes(payload)
|
|
204
|
+
partial.replace(target)
|
|
205
|
+
finally:
|
|
206
|
+
partial.unlink(missing_ok=True)
|
|
207
|
+
return str(target)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
async def _ensure_idbfs_mounted() -> None:
|
|
211
|
+
"""Mount IDBFS at the cache root and load any prior data. Idempotent."""
|
|
212
|
+
global _idbfs_mounted
|
|
213
|
+
if _idbfs_mounted:
|
|
214
|
+
return
|
|
215
|
+
|
|
216
|
+
import js
|
|
217
|
+
import pyodide_js
|
|
218
|
+
|
|
219
|
+
mount_point = str(_PYODIDE_CACHE_ROOT.parent)
|
|
220
|
+
Path(mount_point).mkdir(parents=True, exist_ok=True)
|
|
221
|
+
|
|
222
|
+
fs = pyodide_js.FS
|
|
223
|
+
# `FS.mount` wants a JS object for its options; a Python dict arrives as a
|
|
224
|
+
# proxy the Emscripten filesystem cannot read.
|
|
225
|
+
fs.mount(fs.filesystems.IDBFS, js.Object.new(), mount_point)
|
|
226
|
+
# The mount is what must not be repeated: a second mount of a live point
|
|
227
|
+
# raises a bare Emscripten error. A failed populate costs the prior cache,
|
|
228
|
+
# not the mount, so the flag flips here rather than after the sync.
|
|
229
|
+
_idbfs_mounted = True
|
|
230
|
+
await _syncfs(populate=True)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
async def _flush_idbfs() -> None:
|
|
234
|
+
"""Push MEMFS writes back to IndexedDB."""
|
|
235
|
+
await _syncfs(populate=False)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
async def _syncfs(*, populate: bool) -> None:
|
|
239
|
+
"""Wrap the Emscripten `FS.syncfs` callback in an awaitable."""
|
|
240
|
+
import asyncio
|
|
241
|
+
|
|
242
|
+
import pyodide_js
|
|
243
|
+
from pyodide.ffi import create_proxy
|
|
244
|
+
|
|
245
|
+
done: asyncio.Future = asyncio.get_running_loop().create_future()
|
|
246
|
+
|
|
247
|
+
def callback(err):
|
|
248
|
+
# Success passes JS `null`, which Pyodide surfaces as `JsNull` rather
|
|
249
|
+
# than `None` — falsy either way, and an error object is truthy.
|
|
250
|
+
if err:
|
|
251
|
+
done.set_exception(RuntimeError(f"FS.syncfs failed: {err}"))
|
|
252
|
+
else:
|
|
253
|
+
done.set_result(None)
|
|
254
|
+
|
|
255
|
+
proxy = create_proxy(callback)
|
|
256
|
+
try:
|
|
257
|
+
pyodide_js.FS.syncfs(populate, proxy)
|
|
258
|
+
await done
|
|
259
|
+
finally:
|
|
260
|
+
proxy.destroy()
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Affine-based template matching and background-image selection."""
|
|
2
|
+
|
|
3
|
+
import warnings
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
from nltools.utils import ResamplingWarning, _find_stack_level
|
|
9
|
+
|
|
10
|
+
from .config import get_brainspace
|
|
11
|
+
from .paths import _resolve_paths
|
|
12
|
+
from .registry import SUPPORTED_RESOLUTIONS, TEMPLATE_PRIORITY
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class _TemplateMatch:
|
|
17
|
+
"""Result of matching a data affine to a template.
|
|
18
|
+
|
|
19
|
+
Attributes:
|
|
20
|
+
template (str): Best-matching template name.
|
|
21
|
+
resolution (int): Best-matching resolution in mm.
|
|
22
|
+
mask_path (str): Path to the matched mask file.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
template: str
|
|
26
|
+
resolution: int
|
|
27
|
+
mask_path: str
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _detect_resolution(affine: np.ndarray) -> tuple[float, bool]:
|
|
31
|
+
"""Detect voxel resolution (mm) and isotropy from a NIfTI affine.
|
|
32
|
+
|
|
33
|
+
Voxels are treated as isotropic when the per-axis sizes agree to within
|
|
34
|
+
three decimals. The reported resolution is that shared isotropic size, or
|
|
35
|
+
the mean of the per-axis sizes when non-isotropic.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
affine (np.ndarray): 4x4 affine matrix from a NIfTI image.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
tuple[float, bool]: `(resolution_mm, is_isotropic)`.
|
|
42
|
+
"""
|
|
43
|
+
res_array = np.abs(np.diag(affine[:3, :3]))
|
|
44
|
+
voxel_dims = np.unique(np.round(res_array, 3))
|
|
45
|
+
is_isotropic = len(voxel_dims) == 1
|
|
46
|
+
resolution_mm = float(voxel_dims[0]) if is_isotropic else float(np.mean(res_array))
|
|
47
|
+
return resolution_mm, is_isotropic
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _match_resolution(
|
|
51
|
+
affine: np.ndarray,
|
|
52
|
+
warn_resample: bool = True,
|
|
53
|
+
) -> _TemplateMatch:
|
|
54
|
+
"""Find the best matching template for a given affine matrix.
|
|
55
|
+
|
|
56
|
+
Searches available templates by priority and returns the one whose
|
|
57
|
+
resolution most closely matches the data's voxel size.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
affine (np.ndarray): 4x4 affine matrix from a NIfTI image.
|
|
61
|
+
warn_resample (bool): If True, emit a `ResamplingWarning` when the data
|
|
62
|
+
resolution has no exact template and the closest one is used. Default
|
|
63
|
+
True.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
_TemplateMatch: The selected template, its resolution, and its mask path.
|
|
67
|
+
|
|
68
|
+
Raises:
|
|
69
|
+
ValueError: If detected resolution is outside a reasonable range.
|
|
70
|
+
"""
|
|
71
|
+
resolution_float, _ = _detect_resolution(affine)
|
|
72
|
+
resolution = int(np.round(resolution_float))
|
|
73
|
+
|
|
74
|
+
if resolution < 1 or resolution > 10:
|
|
75
|
+
raise ValueError(
|
|
76
|
+
f"Detected resolution ({resolution_float}mm) is outside "
|
|
77
|
+
f"reasonable range (1-10mm). Data may not be in standard MNI space."
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
best_template: str | None = None
|
|
81
|
+
best_resolution: int | None = None
|
|
82
|
+
|
|
83
|
+
for tmpl in TEMPLATE_PRIORITY:
|
|
84
|
+
if resolution in SUPPORTED_RESOLUTIONS[tmpl]:
|
|
85
|
+
best_template, best_resolution = tmpl, resolution
|
|
86
|
+
break
|
|
87
|
+
|
|
88
|
+
if best_template is None:
|
|
89
|
+
all_res = {r for res in SUPPORTED_RESOLUTIONS.values() for r in res}
|
|
90
|
+
closest = min(all_res, key=lambda x: abs(x - resolution))
|
|
91
|
+
best_distance = float(abs(closest - resolution))
|
|
92
|
+
for tmpl in TEMPLATE_PRIORITY:
|
|
93
|
+
if closest in SUPPORTED_RESOLUTIONS[tmpl]:
|
|
94
|
+
best_template, best_resolution = tmpl, closest
|
|
95
|
+
break
|
|
96
|
+
if best_distance > 0 and warn_resample:
|
|
97
|
+
warnings.warn(
|
|
98
|
+
f"Data resolution ({resolution_float:.3f}mm) does not match any "
|
|
99
|
+
f"bundled MNI template; the closest is {best_template} "
|
|
100
|
+
f"{best_resolution}mm, so the data will be resampled to that grid. "
|
|
101
|
+
"To keep the native resolution, pass mask= with a mask in the "
|
|
102
|
+
"data's own space.",
|
|
103
|
+
ResamplingWarning,
|
|
104
|
+
stacklevel=_find_stack_level(),
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
assert best_template is not None and best_resolution is not None
|
|
108
|
+
paths = _resolve_paths(best_template, best_resolution)
|
|
109
|
+
return _TemplateMatch(
|
|
110
|
+
template=best_template,
|
|
111
|
+
resolution=best_resolution,
|
|
112
|
+
mask_path=paths["mask"],
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _is_standard_space(affine: np.ndarray) -> tuple[bool, str | None]:
|
|
117
|
+
"""Check whether an affine is compatible with our MNI templates.
|
|
118
|
+
|
|
119
|
+
A "standard space" affine has isotropic voxels at one of the supported
|
|
120
|
+
template resolutions (the union of `SUPPORTED_RESOLUTIONS`). Plotting
|
|
121
|
+
surfaces (glass brain, flatmap, surface montage) and template-driven
|
|
122
|
+
background lookup all assume this — non-isotropic or off-grid data
|
|
123
|
+
would render in misleading positions.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
affine (np.ndarray): 4x4 affine matrix from a NIfTI image (typically
|
|
127
|
+
`bd.mask.affine`).
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
tuple[bool, str | None]: `(True, None)` if compatible; otherwise
|
|
131
|
+
`(False, reason)` with `reason` a one-line human-readable explanation
|
|
132
|
+
suitable for embedding in an error message.
|
|
133
|
+
"""
|
|
134
|
+
res, is_isotropic = _detect_resolution(affine)
|
|
135
|
+
if not is_isotropic:
|
|
136
|
+
res_array = np.abs(np.diag(affine[:3, :3]))
|
|
137
|
+
zooms = tuple(round(float(r), 2) for r in res_array)
|
|
138
|
+
return False, f"voxels are non-isotropic (zooms={zooms} mm)"
|
|
139
|
+
res_int = int(round(res))
|
|
140
|
+
if abs(res - res_int) > 1e-3:
|
|
141
|
+
return False, (
|
|
142
|
+
f"voxel size {res:.3f}mm is not an integer-mm template resolution"
|
|
143
|
+
)
|
|
144
|
+
all_supported = sorted({r for rs in SUPPORTED_RESOLUTIONS.values() for r in rs})
|
|
145
|
+
if res_int not in all_supported:
|
|
146
|
+
return False, (
|
|
147
|
+
f"voxel size {res_int}mm is not a supported MNI template "
|
|
148
|
+
f"resolution (supported: {all_supported}mm)"
|
|
149
|
+
)
|
|
150
|
+
return True, None
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _get_bg_image(affine: np.ndarray) -> str:
|
|
154
|
+
"""Get a brain-extracted background image path matching a data resolution.
|
|
155
|
+
|
|
156
|
+
Uses the current global brain space and finds the matching resolution from
|
|
157
|
+
the affine. Used by plotting functions to pick an appropriate background
|
|
158
|
+
anatomical.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
affine (np.ndarray): 4x4 affine matrix from a BrainData's masker.
|
|
162
|
+
|
|
163
|
+
Returns:
|
|
164
|
+
str: Path to the template image file.
|
|
165
|
+
|
|
166
|
+
Raises:
|
|
167
|
+
ValueError: If voxels are non-isotropic.
|
|
168
|
+
"""
|
|
169
|
+
cfg = get_brainspace()
|
|
170
|
+
|
|
171
|
+
resolution_float, is_isotropic = _detect_resolution(affine)
|
|
172
|
+
if not is_isotropic:
|
|
173
|
+
raise ValueError(
|
|
174
|
+
"Voxels are not isotropic and cannot be visualized in standard space"
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
resolution = int(round(resolution_float))
|
|
178
|
+
|
|
179
|
+
if resolution not in SUPPORTED_RESOLUTIONS.get(cfg.template, []):
|
|
180
|
+
return cfg.brain
|
|
181
|
+
|
|
182
|
+
paths = _resolve_paths(cfg.template, resolution)
|
|
183
|
+
return paths["brain"]
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Pure path-resolution helpers for MNI template files.
|
|
2
|
+
|
|
3
|
+
Resolves logical (template, resolution, file_type) tuples to local paths.
|
|
4
|
+
Files are fetched on first use from the `nltools/niftis` HF dataset; see
|
|
5
|
+
`nltools.templates.fetch`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .fetch import fetch_resource
|
|
9
|
+
from .registry import SUPPORTED_RESOLUTIONS, VERSION_MAP, VERSION_TO_TEMPLATE
|
|
10
|
+
|
|
11
|
+
_TEMPLATE_NAME_STEM = "mm-MNI152-2009"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _split_template_name(template_name: str) -> tuple[int, str] | None:
|
|
15
|
+
"""Split `'{res}mm-MNI152-2009{code}'` into its resolution and version code.
|
|
16
|
+
|
|
17
|
+
This is the only parser for template-name strings; callers that merely need
|
|
18
|
+
to recognize one check the result against `None`.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
template_name (str): Candidate template name, e.g. `'2mm-MNI152-2009c'`.
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
tuple[int, str] | None: `(resolution_mm, version_code)`, or None when the
|
|
25
|
+
string does not have the template-name shape. The version code is not
|
|
26
|
+
validated here; look it up in `VERSION_TO_TEMPLATE`.
|
|
27
|
+
"""
|
|
28
|
+
resolution, stem, version_code = template_name.partition(_TEMPLATE_NAME_STEM)
|
|
29
|
+
if not stem or not resolution.isdecimal() or not version_code:
|
|
30
|
+
return None
|
|
31
|
+
return int(resolution), version_code
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _resolve_paths(template: str, resolution: int) -> dict[str, str]:
|
|
35
|
+
"""Build mask/brain/plot paths for a template + resolution.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
template (str): Template name (`'default'`, `'nilearn'`, `'fmriprep'`).
|
|
39
|
+
resolution (int): Resolution in mm.
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
dict[str, str]: Local file paths keyed `'mask'`, `'brain'`, `'plot'`.
|
|
43
|
+
|
|
44
|
+
Raises:
|
|
45
|
+
ValueError: If template or resolution is invalid.
|
|
46
|
+
"""
|
|
47
|
+
if template not in VERSION_MAP:
|
|
48
|
+
raise ValueError(
|
|
49
|
+
f"Unknown template: {template!r}. Supported: {sorted(VERSION_MAP)}"
|
|
50
|
+
)
|
|
51
|
+
if resolution not in SUPPORTED_RESOLUTIONS.get(template, []):
|
|
52
|
+
raise ValueError(
|
|
53
|
+
f"Resolution {resolution}mm not supported for {template!r}. "
|
|
54
|
+
f"Supported: {SUPPORTED_RESOLUTIONS[template]}"
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
version = VERSION_MAP[template]
|
|
58
|
+
res_str = f"{resolution}mm"
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
key: fetch_resource(
|
|
62
|
+
f"{template}/{res_str}-MNI152-2009{version}-{file_type}.nii.gz"
|
|
63
|
+
)
|
|
64
|
+
for file_type, key in [("mask", "mask"), ("brain", "brain"), ("T1", "plot")]
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _resolve_template_name(template_name: str, file_type: str = "mask") -> str:
|
|
69
|
+
"""Resolve a template name string to a file path.
|
|
70
|
+
|
|
71
|
+
Supports names of the form `'{res}mm-MNI152-2009{version}'`.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
template_name (str): e.g. `'2mm-MNI152-2009c'`, `'3mm-MNI152-2009a'`.
|
|
75
|
+
file_type (str): `'mask'`, `'brain'`, or `'T1'`. Default `'mask'`.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
str: Absolute path to the requested template file.
|
|
79
|
+
|
|
80
|
+
Raises:
|
|
81
|
+
ValueError: If `file_type` or the template name format is invalid.
|
|
82
|
+
"""
|
|
83
|
+
if file_type not in ("mask", "brain", "T1"):
|
|
84
|
+
raise ValueError(
|
|
85
|
+
f"file_type must be 'mask', 'brain', or 'T1'. Got: {file_type!r}"
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
parsed = _split_template_name(template_name)
|
|
89
|
+
if parsed is None:
|
|
90
|
+
raise ValueError(
|
|
91
|
+
f"Invalid template name format: {template_name!r}. "
|
|
92
|
+
f"Expected: '{{res}}mm-MNI152-2009{{version}}' "
|
|
93
|
+
f"(e.g., '2mm-MNI152-2009c', '3mm-MNI152-2009a', '2mm-MNI152-2009fsl')"
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
resolution, version_code = parsed
|
|
97
|
+
|
|
98
|
+
if version_code not in VERSION_TO_TEMPLATE:
|
|
99
|
+
raise ValueError(
|
|
100
|
+
f"Unknown version code {version_code!r} in {template_name!r}. "
|
|
101
|
+
f"Supported: 'fsl' (default), 'a' (nilearn), 'c' (fmriprep)"
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
template = VERSION_TO_TEMPLATE[version_code]
|
|
105
|
+
key = "plot" if file_type == "T1" else file_type
|
|
106
|
+
return _resolve_paths(template, resolution)[key]
|