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.
Files changed (95) hide show
  1. nltools/__init__.py +55 -0
  2. nltools/algorithms/__init__.py +90 -0
  3. nltools/algorithms/alignment/__init__.py +21 -0
  4. nltools/algorithms/alignment/procrustes.py +565 -0
  5. nltools/algorithms/alignment/srm.py +758 -0
  6. nltools/algorithms/backends.py +1059 -0
  7. nltools/algorithms/corrections.py +177 -0
  8. nltools/algorithms/decoding.py +327 -0
  9. nltools/algorithms/inference/__init__.py +50 -0
  10. nltools/algorithms/inference/bootstrap.py +1386 -0
  11. nltools/algorithms/inference/correlation.py +373 -0
  12. nltools/algorithms/inference/intersubject.py +422 -0
  13. nltools/algorithms/inference/isc.py +1554 -0
  14. nltools/algorithms/inference/matrix.py +602 -0
  15. nltools/algorithms/inference/one_sample.py +288 -0
  16. nltools/algorithms/inference/random.py +122 -0
  17. nltools/algorithms/inference/timeseries.py +347 -0
  18. nltools/algorithms/inference/two_sample.py +212 -0
  19. nltools/algorithms/inference/utils.py +58 -0
  20. nltools/algorithms/inference/validation.py +282 -0
  21. nltools/algorithms/neighborhoods.py +207 -0
  22. nltools/algorithms/outliers.py +308 -0
  23. nltools/algorithms/regression.py +83 -0
  24. nltools/algorithms/signal.py +303 -0
  25. nltools/algorithms/similarity.py +234 -0
  26. nltools/algorithms/validation.py +151 -0
  27. nltools/cross_validation.py +72 -0
  28. nltools/data/__init__.py +30 -0
  29. nltools/data/adjacency/__init__.py +875 -0
  30. nltools/data/adjacency/io.py +111 -0
  31. nltools/data/adjacency/modeling.py +569 -0
  32. nltools/data/adjacency/plotting.py +174 -0
  33. nltools/data/adjacency/state.py +349 -0
  34. nltools/data/adjacency/stats.py +596 -0
  35. nltools/data/adjacency/utils.py +79 -0
  36. nltools/data/atlases/__init__.py +23 -0
  37. nltools/data/atlases/labeling.py +158 -0
  38. nltools/data/atlases/loading.py +76 -0
  39. nltools/data/atlases/registry.py +96 -0
  40. nltools/data/atlases/reporting.py +456 -0
  41. nltools/data/braindata/__init__.py +2170 -0
  42. nltools/data/braindata/analysis.py +1381 -0
  43. nltools/data/braindata/bootstrap.py +398 -0
  44. nltools/data/braindata/io.py +896 -0
  45. nltools/data/braindata/modeling.py +594 -0
  46. nltools/data/braindata/plotting.py +501 -0
  47. nltools/data/braindata/prediction.py +1250 -0
  48. nltools/data/braindata/utils.py +348 -0
  49. nltools/data/braindata/validation.py +197 -0
  50. nltools/data/braindata/viewer.js +266 -0
  51. nltools/data/braindata/viewer.py +770 -0
  52. nltools/data/combine.py +27 -0
  53. nltools/data/designmatrix/__init__.py +1032 -0
  54. nltools/data/designmatrix/append.py +518 -0
  55. nltools/data/designmatrix/diagnostics.py +248 -0
  56. nltools/data/designmatrix/io.py +356 -0
  57. nltools/data/designmatrix/plotting.py +291 -0
  58. nltools/data/designmatrix/regressors.py +463 -0
  59. nltools/data/designmatrix/transforms.py +200 -0
  60. nltools/data/designmatrix/utils.py +350 -0
  61. nltools/data/ownership.py +129 -0
  62. nltools/data/results.py +291 -0
  63. nltools/data/roc/__init__.py +398 -0
  64. nltools/data/simulator/__init__.py +927 -0
  65. nltools/data/simulator/haxby.py +124 -0
  66. nltools/data/validation.py +83 -0
  67. nltools/datasets.py +218 -0
  68. nltools/io/__init__.py +10 -0
  69. nltools/io/events.py +67 -0
  70. nltools/io/h5.py +246 -0
  71. nltools/mask.py +403 -0
  72. nltools/models/__init__.py +11 -0
  73. nltools/models/glm.py +543 -0
  74. nltools/models/results.py +49 -0
  75. nltools/models/ridge.py +1303 -0
  76. nltools/models/validation.py +26 -0
  77. nltools/plotting/__init__.py +32 -0
  78. nltools/plotting/adjacency.py +421 -0
  79. nltools/plotting/brain.py +669 -0
  80. nltools/plotting/decomposition.py +111 -0
  81. nltools/plotting/prediction.py +110 -0
  82. nltools/resources/covariates_example.csv +161 -0
  83. nltools/resources/onsets_example.csv +40 -0
  84. nltools/templates/__init__.py +51 -0
  85. nltools/templates/config.py +144 -0
  86. nltools/templates/fetch.py +260 -0
  87. nltools/templates/matching.py +183 -0
  88. nltools/templates/paths.py +106 -0
  89. nltools/templates/registry.py +25 -0
  90. nltools/utils.py +230 -0
  91. nltools/version.py +13 -0
  92. nltools-0.6.0.dev0.dist-info/METADATA +95 -0
  93. nltools-0.6.0.dev0.dist-info/RECORD +95 -0
  94. nltools-0.6.0.dev0.dist-info/WHEEL +4 -0
  95. 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]