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,669 @@
|
|
|
1
|
+
"""Brain visualization — surface plots and flatmaps."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import matplotlib.pyplot as plt
|
|
7
|
+
import numpy as np
|
|
8
|
+
from nilearn.plotting import plot_surf_stat_map
|
|
9
|
+
from nilearn.surface import vol_to_surf
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _resolve_stat_map_defaults(data, *, cmap=None, vmin=None, vmax=None):
|
|
13
|
+
"""Resolve sign-aware stat-map defaults from finite, nonzero values.
|
|
14
|
+
|
|
15
|
+
Explicit values are preserved. Missing range endpoints follow nilearn's
|
|
16
|
+
convention: one-sided maps include zero, while mixed maps are symmetric.
|
|
17
|
+
"""
|
|
18
|
+
values = np.asarray(data, dtype=float).ravel()
|
|
19
|
+
values = values[np.isfinite(values) & (values != 0)]
|
|
20
|
+
|
|
21
|
+
has_positive = bool(np.any(values > 0))
|
|
22
|
+
has_negative = bool(np.any(values < 0))
|
|
23
|
+
if has_positive and not has_negative:
|
|
24
|
+
default_cmap = "Reds"
|
|
25
|
+
default_vmin = 0.0
|
|
26
|
+
default_vmax = float(values.max())
|
|
27
|
+
elif has_negative and not has_positive:
|
|
28
|
+
default_cmap = "Blues_r"
|
|
29
|
+
default_vmin = float(values.min())
|
|
30
|
+
default_vmax = 0.0
|
|
31
|
+
else:
|
|
32
|
+
default_cmap = "RdBu_r"
|
|
33
|
+
max_abs = float(np.max(np.abs(values))) if values.size else 1.0
|
|
34
|
+
default_vmin = -max_abs
|
|
35
|
+
default_vmax = max_abs
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
default_cmap if cmap is None else cmap,
|
|
39
|
+
default_vmin if vmin is None else vmin,
|
|
40
|
+
default_vmax if vmax is None else vmax,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _resolve_brain_input(brain):
|
|
45
|
+
"""Convert various input types to nibabel Nifti1Image.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
brain: BrainData, nibabel Nifti1Image, or file path to NIfTI image.
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
nibabel.Nifti1Image: Nifti image object.
|
|
52
|
+
|
|
53
|
+
Raises:
|
|
54
|
+
ValueError: If input cannot be converted to Nifti1Image (e.g., empty
|
|
55
|
+
BrainData or file not found).
|
|
56
|
+
TypeError: If input type is not supported.
|
|
57
|
+
"""
|
|
58
|
+
import nibabel as nib
|
|
59
|
+
from nltools.data import BrainData
|
|
60
|
+
|
|
61
|
+
if isinstance(brain, BrainData):
|
|
62
|
+
if len(brain) == 0:
|
|
63
|
+
raise ValueError("Cannot plot empty BrainData object")
|
|
64
|
+
# If multiple images, use first one
|
|
65
|
+
if len(brain.shape) == 2 and brain.shape[0] > 1:
|
|
66
|
+
brain = brain[0]
|
|
67
|
+
return brain.to_nifti()
|
|
68
|
+
if isinstance(brain, nib.Nifti1Image):
|
|
69
|
+
return brain
|
|
70
|
+
if isinstance(brain, (str, Path)):
|
|
71
|
+
if not os.path.exists(brain):
|
|
72
|
+
raise ValueError(f"File not found: {brain}")
|
|
73
|
+
return nib.load(brain)
|
|
74
|
+
raise TypeError(
|
|
75
|
+
f"Input must be BrainData, nibabel Nifti1Image, or file path, got {type(brain)}"
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _resolve_transparency(transparency, brain):
|
|
80
|
+
"""Resolve a transparency mask spec to a nibabel Nifti1Image (or None).
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
transparency: ``"auto"`` (use ``brain.mask`` if ``brain`` is a
|
|
84
|
+
BrainData, else ``None``), ``None`` (no masking), a
|
|
85
|
+
``BrainData``, a ``nibabel.Nifti1Image``, or a file path.
|
|
86
|
+
brain: The primary ``brain`` argument passed to the plotting
|
|
87
|
+
function; used only to retrieve ``.mask`` when
|
|
88
|
+
``transparency == "auto"``.
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
nibabel.Nifti1Image or None.
|
|
92
|
+
"""
|
|
93
|
+
import nibabel as nib
|
|
94
|
+
from nltools.data import BrainData
|
|
95
|
+
|
|
96
|
+
if transparency == "auto":
|
|
97
|
+
return brain.mask if isinstance(brain, BrainData) else None
|
|
98
|
+
if transparency is None:
|
|
99
|
+
return None
|
|
100
|
+
if isinstance(transparency, BrainData):
|
|
101
|
+
return transparency.to_nifti()
|
|
102
|
+
if isinstance(transparency, nib.Nifti1Image):
|
|
103
|
+
return transparency
|
|
104
|
+
if isinstance(transparency, (str, Path)):
|
|
105
|
+
if not os.path.exists(transparency):
|
|
106
|
+
raise ValueError(f"Transparency mask file not found: {transparency}")
|
|
107
|
+
return nib.load(transparency)
|
|
108
|
+
raise TypeError(
|
|
109
|
+
f"`transparency` must be BrainData, Nifti1Image, path, 'auto', or "
|
|
110
|
+
f"None; got {type(transparency)}"
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
_VALID_SURF_VIEWS = ("lateral", "medial", "dorsal", "ventral", "anterior", "posterior")
|
|
115
|
+
_VALID_SURF_HEMIS = ("left", "right")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _normalize_surf_views(view):
|
|
119
|
+
"""Return an ordered list of view names for plot_surf."""
|
|
120
|
+
if view == "montage":
|
|
121
|
+
return ["lateral", "medial"]
|
|
122
|
+
views = [view] if isinstance(view, str) else list(view)
|
|
123
|
+
bad = [v for v in views if v not in _VALID_SURF_VIEWS]
|
|
124
|
+
if not views or bad:
|
|
125
|
+
raise ValueError(
|
|
126
|
+
f"Invalid view={view!r}. Each entry must be one of "
|
|
127
|
+
f"{list(_VALID_SURF_VIEWS)}; got unknown entries {bad}."
|
|
128
|
+
)
|
|
129
|
+
return views
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _normalize_surf_hemis(hemi):
|
|
133
|
+
"""Return an ordered list of hemisphere names for plot_surf."""
|
|
134
|
+
if hemi == "both":
|
|
135
|
+
return ["left", "right"]
|
|
136
|
+
hemis = [hemi] if isinstance(hemi, str) else list(hemi)
|
|
137
|
+
bad = [h for h in hemis if h not in _VALID_SURF_HEMIS]
|
|
138
|
+
if not hemis or bad:
|
|
139
|
+
raise ValueError(
|
|
140
|
+
f"Invalid hemi={hemi!r}. Must be 'left', 'right', 'both', or a "
|
|
141
|
+
f"list thereof; got unknown entries {bad}."
|
|
142
|
+
)
|
|
143
|
+
return hemis
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _require_standard_space(bd, op_name: str, *, remedy: str) -> None:
|
|
147
|
+
"""Raise if ``bd`` is not in a standard MNI space supported by templates.
|
|
148
|
+
|
|
149
|
+
Used to gate plotting paths that draw against MNI-aligned scaffolding
|
|
150
|
+
(glass-brain outlines, fsaverage surfaces, template backgrounds).
|
|
151
|
+
Native-space data would render in misleading positions.
|
|
152
|
+
"""
|
|
153
|
+
from nltools.templates import _is_standard_space
|
|
154
|
+
|
|
155
|
+
ok, reason = _is_standard_space(bd.mask.affine)
|
|
156
|
+
if ok:
|
|
157
|
+
return
|
|
158
|
+
raise ValueError(
|
|
159
|
+
f"{op_name} requires data in standard MNI space, but {reason}. {remedy}"
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _require_plottable_brain(brain, op_name, remedy):
|
|
164
|
+
"""Reject empty or native-space `BrainData` before any surface work.
|
|
165
|
+
|
|
166
|
+
Non-`BrainData` inputs (a nifti image or a path) carry no mask to check and
|
|
167
|
+
pass straight through.
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
brain: The `brain` argument passed to a surface plotter.
|
|
171
|
+
op_name (str): Name of the calling plotter, used in the error message.
|
|
172
|
+
remedy (str): Sentence telling the user what to do instead.
|
|
173
|
+
|
|
174
|
+
Raises:
|
|
175
|
+
ValueError: If `brain` is an empty or non-standard-space `BrainData`.
|
|
176
|
+
"""
|
|
177
|
+
from nltools.data import BrainData
|
|
178
|
+
|
|
179
|
+
if not isinstance(brain, BrainData):
|
|
180
|
+
return
|
|
181
|
+
if brain.is_empty:
|
|
182
|
+
raise ValueError("Cannot plot empty BrainData object")
|
|
183
|
+
_require_standard_space(brain, op_name, remedy=remedy)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _project_to_surface(
|
|
187
|
+
nifti_img,
|
|
188
|
+
mask_img,
|
|
189
|
+
fs,
|
|
190
|
+
surf_key,
|
|
191
|
+
hemis,
|
|
192
|
+
*,
|
|
193
|
+
threshold,
|
|
194
|
+
cmap,
|
|
195
|
+
vmin,
|
|
196
|
+
vmax,
|
|
197
|
+
):
|
|
198
|
+
"""Project a volume onto fsaverage meshes and resolve the display range.
|
|
199
|
+
|
|
200
|
+
Shared by `plot_surf` and `plot_flatmap`. Both sample `vol_to_surf` with a
|
|
201
|
+
3 mm ball and linear interpolation. Vertices outside `mask_img` are set to
|
|
202
|
+
NaN so the background shows through; the `>= 0.5` cut needs a graded mask to
|
|
203
|
+
place the brain edge.
|
|
204
|
+
|
|
205
|
+
Args:
|
|
206
|
+
nifti_img (nibabel.Nifti1Image): Volume to project.
|
|
207
|
+
mask_img (nibabel.Nifti1Image | None): Transparency mask, or None.
|
|
208
|
+
fs (sklearn.utils.Bunch): fsaverage surfaces from
|
|
209
|
+
`nilearn.datasets.fetch_surf_fsaverage`.
|
|
210
|
+
surf_key (str): Mesh key prefix, e.g. `'pial'` or `'infl'`.
|
|
211
|
+
hemis (list[str]): Hemispheres to project, e.g. `['left', 'right']`.
|
|
212
|
+
threshold (float | str | None): Absolute cutoff or percentile string.
|
|
213
|
+
cmap (str | None): Explicit colormap, or None for the sign-aware default.
|
|
214
|
+
vmin (float | None): Explicit lower bound, or None for the default.
|
|
215
|
+
vmax (float | None): Explicit upper bound, or None for the default.
|
|
216
|
+
|
|
217
|
+
Returns:
|
|
218
|
+
tuple: `(textures, threshold, cmap, vmin, vmax)` where `textures` maps
|
|
219
|
+
each hemisphere to its vertex array and `threshold` is the resolved
|
|
220
|
+
absolute cutoff (None when a percentile matched no vertices).
|
|
221
|
+
"""
|
|
222
|
+
textures = {}
|
|
223
|
+
for h in hemis:
|
|
224
|
+
tex = vol_to_surf(
|
|
225
|
+
nifti_img,
|
|
226
|
+
fs[f"{surf_key}_{h}"],
|
|
227
|
+
radius=3.0,
|
|
228
|
+
interpolation="linear",
|
|
229
|
+
)
|
|
230
|
+
if mask_img is not None:
|
|
231
|
+
mk = vol_to_surf(
|
|
232
|
+
mask_img,
|
|
233
|
+
fs[f"{surf_key}_{h}"],
|
|
234
|
+
radius=3.0,
|
|
235
|
+
interpolation="linear",
|
|
236
|
+
)
|
|
237
|
+
tex = np.where(mk >= 0.5, tex, np.nan)
|
|
238
|
+
textures[h] = tex
|
|
239
|
+
|
|
240
|
+
all_vals = np.concatenate([textures[h] for h in hemis])
|
|
241
|
+
|
|
242
|
+
if isinstance(threshold, str) and threshold.endswith("%"):
|
|
243
|
+
pct = float(threshold[:-1])
|
|
244
|
+
finite_vals = all_vals[np.isfinite(all_vals)]
|
|
245
|
+
threshold = (
|
|
246
|
+
float(np.percentile(np.abs(finite_vals), pct)) if len(finite_vals) else None
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
range_vals = (
|
|
250
|
+
all_vals[np.abs(all_vals) >= threshold] if threshold is not None else all_vals
|
|
251
|
+
)
|
|
252
|
+
cmap, vmin, vmax = _resolve_stat_map_defaults(
|
|
253
|
+
range_vals, cmap=cmap, vmin=vmin, vmax=vmax
|
|
254
|
+
)
|
|
255
|
+
return textures, threshold, cmap, vmin, vmax
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _plot_surf(
|
|
259
|
+
brain,
|
|
260
|
+
*,
|
|
261
|
+
hemi="both",
|
|
262
|
+
view="montage",
|
|
263
|
+
surface="pial",
|
|
264
|
+
template="fsaverage5",
|
|
265
|
+
threshold=None,
|
|
266
|
+
cmap=None,
|
|
267
|
+
vmin=None,
|
|
268
|
+
vmax=None,
|
|
269
|
+
transparency="auto",
|
|
270
|
+
colorbar=True,
|
|
271
|
+
figsize=(10, 8),
|
|
272
|
+
title=None,
|
|
273
|
+
save=None,
|
|
274
|
+
):
|
|
275
|
+
"""Plot volumetric data on fsaverage surfaces in a tight montage.
|
|
276
|
+
|
|
277
|
+
Like nilearn's `plot_img_on_surf` but with tight framing (via
|
|
278
|
+
`Axes3D.set_box_aspect` + `set_axis_off`), an auto-applied transparency mask
|
|
279
|
+
(same convention as `plot_flatmap`), and a single shared horizontal colorbar
|
|
280
|
+
instead of one per subplot.
|
|
281
|
+
|
|
282
|
+
The grid is `len(view) × len(hemi)` — rows are views, columns are hemispheres.
|
|
283
|
+
|
|
284
|
+
Args:
|
|
285
|
+
brain (BrainData | nibabel.Nifti1Image | str | Path): MNI-space image to
|
|
286
|
+
plot.
|
|
287
|
+
hemi (str | list): `'left'`, `'right'`, `'both'` (default), or a list
|
|
288
|
+
subset like `['left']`.
|
|
289
|
+
view (str | list): `'montage'` (default, → `['lateral', 'medial']`), a
|
|
290
|
+
single view string, or any list subset of `'lateral'`, `'medial'`,
|
|
291
|
+
`'dorsal'`, `'ventral'`, `'anterior'`, `'posterior'`.
|
|
292
|
+
surface (str): fsaverage mesh to render on. One of `'pial'` (default),
|
|
293
|
+
`'inflated'`, `'white'`, `'sphere'`.
|
|
294
|
+
template (str): fsaverage resolution (`'fsaverage3'` … `'fsaverage'`).
|
|
295
|
+
Default `'fsaverage5'`.
|
|
296
|
+
threshold (float | str, optional): Absolute cutoff (`0.3`) or percentile
|
|
297
|
+
string (`'95%'`).
|
|
298
|
+
cmap (str, optional): Matplotlib colormap. By default, positive-only
|
|
299
|
+
maps use ``"Reds"``, negative-only maps use ``"Blues_r"``, and
|
|
300
|
+
mixed maps use ``"RdBu_r"``.
|
|
301
|
+
vmin (float, optional): Colormap lower bound. Defaults to zero for
|
|
302
|
+
positive-only maps, the data minimum for negative-only maps, and
|
|
303
|
+
negative max-absolute value for mixed maps.
|
|
304
|
+
vmax (float, optional): Colormap upper bound. Defaults to the data
|
|
305
|
+
maximum for positive-only maps, zero for negative-only maps, and
|
|
306
|
+
max-absolute value for mixed maps.
|
|
307
|
+
transparency (BrainData | nibabel.Nifti1Image | str | Path | None): Binary
|
|
308
|
+
mask used to NaN-out vertices outside the mask so the background shines
|
|
309
|
+
through. `'auto'` (default) uses `BrainData.mask`; None disables masking.
|
|
310
|
+
colorbar (bool): Show a single shared colorbar. Default True.
|
|
311
|
+
figsize (tuple): Figure size. Default (10, 8).
|
|
312
|
+
title (str, optional): Figure title.
|
|
313
|
+
save (str, optional): Path to save the figure.
|
|
314
|
+
|
|
315
|
+
Returns:
|
|
316
|
+
matplotlib.figure.Figure: The surface figure.
|
|
317
|
+
"""
|
|
318
|
+
from nilearn import datasets
|
|
319
|
+
from matplotlib.cm import ScalarMappable
|
|
320
|
+
from matplotlib.colors import Normalize
|
|
321
|
+
|
|
322
|
+
# --- validate input up front (before any network or surface work) ----
|
|
323
|
+
_require_plottable_brain(
|
|
324
|
+
brain,
|
|
325
|
+
"plot_surf",
|
|
326
|
+
remedy=(
|
|
327
|
+
"Surface projection samples vol_to_surf at fsaverage "
|
|
328
|
+
"(MNI-aligned) coordinates and produces garbage on "
|
|
329
|
+
"native-space data. Use bd.plot(method='slices', "
|
|
330
|
+
"bg_img=<your subject anatomical>) instead, or call "
|
|
331
|
+
"bd.resample() to bring data into standard space first."
|
|
332
|
+
),
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
views = _normalize_surf_views(view)
|
|
336
|
+
hemis = _normalize_surf_hemis(hemi)
|
|
337
|
+
|
|
338
|
+
# fsaverage stores inflated surfaces under the "infl_*" key; translate
|
|
339
|
+
# the more readable public name to the internal key.
|
|
340
|
+
surf_key_map = {
|
|
341
|
+
"pial": "pial",
|
|
342
|
+
"inflated": "infl",
|
|
343
|
+
"white": "white",
|
|
344
|
+
"sphere": "sphere",
|
|
345
|
+
}
|
|
346
|
+
if surface not in surf_key_map:
|
|
347
|
+
raise ValueError(
|
|
348
|
+
f"Invalid surface={surface!r}. Must be one of {list(surf_key_map)}."
|
|
349
|
+
)
|
|
350
|
+
surf_key = surf_key_map[surface]
|
|
351
|
+
|
|
352
|
+
# Resolve transparency *before* converting brain to nifti (we need access
|
|
353
|
+
# to BrainData.mask for the "auto" default).
|
|
354
|
+
mask_img = _resolve_transparency(transparency, brain)
|
|
355
|
+
nifti_img = _resolve_brain_input(brain)
|
|
356
|
+
|
|
357
|
+
# --- fetch surfaces and project --------------------------------------
|
|
358
|
+
fs = datasets.fetch_surf_fsaverage(template)
|
|
359
|
+
|
|
360
|
+
textures, threshold, cmap, vmin, vmax = _project_to_surface(
|
|
361
|
+
nifti_img,
|
|
362
|
+
mask_img,
|
|
363
|
+
fs,
|
|
364
|
+
surf_key,
|
|
365
|
+
hemis,
|
|
366
|
+
threshold=threshold,
|
|
367
|
+
cmap=cmap,
|
|
368
|
+
vmin=vmin,
|
|
369
|
+
vmax=vmax,
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
# --- figure / axes grid ----------------------------------------------
|
|
373
|
+
nrows, ncols = len(views), len(hemis)
|
|
374
|
+
fig, axes_arr = plt.subplots(
|
|
375
|
+
nrows,
|
|
376
|
+
ncols,
|
|
377
|
+
figsize=figsize,
|
|
378
|
+
subplot_kw={"projection": "3d"},
|
|
379
|
+
constrained_layout=True,
|
|
380
|
+
squeeze=False,
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
# --- draw each subplot -----------------------------------------------
|
|
384
|
+
for r, v in enumerate(views):
|
|
385
|
+
for c, h in enumerate(hemis):
|
|
386
|
+
ax = axes_arr[r, c]
|
|
387
|
+
plot_surf_stat_map(
|
|
388
|
+
fs[f"{surf_key}_{h}"],
|
|
389
|
+
textures[h],
|
|
390
|
+
hemi=h,
|
|
391
|
+
view=v,
|
|
392
|
+
bg_map=fs[f"curv_{h}"],
|
|
393
|
+
bg_on_data=False,
|
|
394
|
+
colorbar=False, # shared colorbar below
|
|
395
|
+
cmap=cmap,
|
|
396
|
+
threshold=threshold,
|
|
397
|
+
vmax=vmax,
|
|
398
|
+
vmin=vmin,
|
|
399
|
+
axes=ax,
|
|
400
|
+
engine="matplotlib",
|
|
401
|
+
)
|
|
402
|
+
ax.set_box_aspect((1, 1, 1), zoom=1.2)
|
|
403
|
+
ax.set_axis_off()
|
|
404
|
+
|
|
405
|
+
# --- shared colorbar --------------------------------------------------
|
|
406
|
+
if colorbar:
|
|
407
|
+
sm = ScalarMappable(cmap=cmap, norm=Normalize(vmin=vmin, vmax=vmax))
|
|
408
|
+
sm.set_array([])
|
|
409
|
+
fig.colorbar(
|
|
410
|
+
sm,
|
|
411
|
+
ax=axes_arr.ravel().tolist(),
|
|
412
|
+
orientation="horizontal",
|
|
413
|
+
fraction=0.03,
|
|
414
|
+
pad=0.02,
|
|
415
|
+
shrink=0.7,
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
if title is not None:
|
|
419
|
+
fig.suptitle(title, fontsize=14)
|
|
420
|
+
|
|
421
|
+
if save is not None:
|
|
422
|
+
fig.savefig(save, bbox_inches="tight", facecolor="white", dpi=300)
|
|
423
|
+
|
|
424
|
+
plt.close(fig)
|
|
425
|
+
return fig
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _plot_flatmap(
|
|
429
|
+
brain,
|
|
430
|
+
*,
|
|
431
|
+
threshold=None,
|
|
432
|
+
cmap=None,
|
|
433
|
+
vmax=None,
|
|
434
|
+
vmin=None,
|
|
435
|
+
template="fsaverage5",
|
|
436
|
+
transparency="auto",
|
|
437
|
+
colorbar=True,
|
|
438
|
+
figsize=(12, 6),
|
|
439
|
+
title=None,
|
|
440
|
+
save=None,
|
|
441
|
+
):
|
|
442
|
+
"""Plot brain data on cortical flatmap.
|
|
443
|
+
|
|
444
|
+
Projects MNI152 volumetric data onto an fsaverage surface and renders
|
|
445
|
+
as a 2D flattened cortical map. Uses nilearn's vol_to_surf for projection
|
|
446
|
+
and matplotlib's tripcolor for rendering.
|
|
447
|
+
|
|
448
|
+
This function provides publication-quality flatmap visualizations without
|
|
449
|
+
requiring external dependencies like pycortex.
|
|
450
|
+
|
|
451
|
+
Args:
|
|
452
|
+
brain (BrainData | nibabel.Nifti1Image | str | Path): Image to plot. Data
|
|
453
|
+
must be in MNI152 space.
|
|
454
|
+
threshold (float or str, optional): Values below this absolute
|
|
455
|
+
threshold are masked. Can be a float or percentile string
|
|
456
|
+
like '95%'. Defaults to None (no threshold).
|
|
457
|
+
cmap (str, optional): Matplotlib colormap. The default is ``"Reds"``
|
|
458
|
+
for positive-only maps, ``"Blues_r"`` for negative-only maps, and
|
|
459
|
+
``"RdBu_r"`` for mixed maps.
|
|
460
|
+
vmax (float, optional): Maximum value. Defaults to the positive data
|
|
461
|
+
maximum, zero for negative-only data, or max-absolute value for
|
|
462
|
+
mixed data.
|
|
463
|
+
vmin (float, optional): Minimum value. Defaults to zero for positive-only
|
|
464
|
+
data, the negative data minimum, or negative max-absolute value for
|
|
465
|
+
mixed data.
|
|
466
|
+
template (str, optional): fsaverage resolution. Options:
|
|
467
|
+
'fsaverage3' (642 vertices), 'fsaverage4' (2562),
|
|
468
|
+
'fsaverage5' (10242, default), 'fsaverage6' (40962),
|
|
469
|
+
'fsaverage' (163842, full resolution).
|
|
470
|
+
transparency (BrainData | nibabel.Nifti1Image | str | Path | None):
|
|
471
|
+
Binary mask used to render vertices outside the mask as
|
|
472
|
+
transparent (so the curvature shows through). `'auto'` (default)
|
|
473
|
+
uses the input `BrainData`'s `.mask` when available, matching
|
|
474
|
+
the behavior of the volumetric `.plot()`. Pass None to
|
|
475
|
+
disable masking entirely.
|
|
476
|
+
colorbar (bool, optional): Show a horizontal colorbar. Defaults to True.
|
|
477
|
+
figsize (tuple, optional): Figure size (width, height).
|
|
478
|
+
Defaults to (12, 6).
|
|
479
|
+
title (str, optional): Figure title. Defaults to None.
|
|
480
|
+
save (str, optional): File path to save figure. Defaults to None.
|
|
481
|
+
|
|
482
|
+
Returns:
|
|
483
|
+
matplotlib.figure.Figure: The figure containing the flatmap.
|
|
484
|
+
|
|
485
|
+
Examples:
|
|
486
|
+
Basic flatmap with default settings:
|
|
487
|
+
|
|
488
|
+
```python
|
|
489
|
+
from nltools.plotting import plot_flatmap
|
|
490
|
+
from nltools.data import BrainData
|
|
491
|
+
|
|
492
|
+
brain = BrainData("stats.nii.gz")
|
|
493
|
+
fig = plot_flatmap(brain)
|
|
494
|
+
```
|
|
495
|
+
|
|
496
|
+
Thresholded with custom colormap:
|
|
497
|
+
|
|
498
|
+
```python
|
|
499
|
+
fig = plot_flatmap(brain, threshold=2.5, cmap="hot")
|
|
500
|
+
```
|
|
501
|
+
|
|
502
|
+
Percentile threshold:
|
|
503
|
+
|
|
504
|
+
```python
|
|
505
|
+
fig = plot_flatmap(brain, threshold="95%")
|
|
506
|
+
```
|
|
507
|
+
|
|
508
|
+
High resolution for publication:
|
|
509
|
+
|
|
510
|
+
```python
|
|
511
|
+
fig = plot_flatmap(brain, template="fsaverage6", figsize=(16, 8))
|
|
512
|
+
fig.savefig("flatmap.pdf", dpi=300)
|
|
513
|
+
```
|
|
514
|
+
|
|
515
|
+
Note:
|
|
516
|
+
Data is projected from MNI152 space to fsaverage surface space, so small
|
|
517
|
+
alignment differences are expected at boundaries. Higher resolution
|
|
518
|
+
templates (fsaverage6, fsaverage) produce sharper images but take longer
|
|
519
|
+
to render. The flat surfaces are cached by nilearn after the first
|
|
520
|
+
download (~50MB for fsaverage5).
|
|
521
|
+
"""
|
|
522
|
+
from nilearn import datasets
|
|
523
|
+
import nibabel as nib
|
|
524
|
+
from matplotlib.colors import Normalize
|
|
525
|
+
from matplotlib.cm import ScalarMappable
|
|
526
|
+
|
|
527
|
+
# --- validate input up front (before any network or surface work) ----
|
|
528
|
+
_require_plottable_brain(
|
|
529
|
+
brain,
|
|
530
|
+
"plot_flatmap",
|
|
531
|
+
remedy=(
|
|
532
|
+
"Flatmap projection samples vol_to_surf at fsaverage (MNI-"
|
|
533
|
+
"aligned) coordinates and produces garbage on native-space "
|
|
534
|
+
"data. Use bd.plot(method='slices', bg_img=<your subject "
|
|
535
|
+
"anatomical>) instead, or call bd.resample() to bring data "
|
|
536
|
+
"into standard space first."
|
|
537
|
+
),
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
# Resolve transparency mask *before* converting input to nifti (we need
|
|
541
|
+
# access to BrainData.mask for the "auto" default).
|
|
542
|
+
mask_img = _resolve_transparency(transparency, brain)
|
|
543
|
+
|
|
544
|
+
# Resolve input to nibabel image
|
|
545
|
+
nifti_img = _resolve_brain_input(brain)
|
|
546
|
+
|
|
547
|
+
# Fetch fsaverage surfaces (cached after first download)
|
|
548
|
+
fs = datasets.fetch_surf_fsaverage(template)
|
|
549
|
+
|
|
550
|
+
# Project volume (and its transparency mask) onto both hemispheres
|
|
551
|
+
textures, threshold, cmap, vmin, vmax = _project_to_surface(
|
|
552
|
+
nifti_img,
|
|
553
|
+
mask_img,
|
|
554
|
+
fs,
|
|
555
|
+
"pial",
|
|
556
|
+
["left", "right"],
|
|
557
|
+
threshold=threshold,
|
|
558
|
+
cmap=cmap,
|
|
559
|
+
vmin=vmin,
|
|
560
|
+
vmax=vmax,
|
|
561
|
+
)
|
|
562
|
+
texture_left, texture_right = textures["left"], textures["right"]
|
|
563
|
+
|
|
564
|
+
# Load flat surface meshes
|
|
565
|
+
flat_left = nib.load(fs["flat_left"])
|
|
566
|
+
flat_right = nib.load(fs["flat_right"])
|
|
567
|
+
|
|
568
|
+
coords_left = flat_left.darrays[0].data[:, :2] # Only X, Y for flatmap
|
|
569
|
+
coords_right = flat_right.darrays[0].data[:, :2]
|
|
570
|
+
faces_left = flat_left.darrays[1].data
|
|
571
|
+
faces_right = flat_right.darrays[1].data
|
|
572
|
+
|
|
573
|
+
# Offset right hemisphere to the right of left hemisphere
|
|
574
|
+
gap = 20 # Gap between hemispheres in surface units
|
|
575
|
+
coords_right = coords_right.copy()
|
|
576
|
+
coords_right[:, 0] += coords_left[:, 0].max() - coords_right[:, 0].min() + gap
|
|
577
|
+
|
|
578
|
+
# Load curvature for background
|
|
579
|
+
curv_left = nib.load(fs["curv_left"]).darrays[0].data
|
|
580
|
+
curv_right = nib.load(fs["curv_right"]).darrays[0].data
|
|
581
|
+
|
|
582
|
+
# Apply threshold masking
|
|
583
|
+
if threshold is not None:
|
|
584
|
+
texture_left_masked = np.where(
|
|
585
|
+
np.abs(texture_left) >= threshold, texture_left, np.nan
|
|
586
|
+
)
|
|
587
|
+
texture_right_masked = np.where(
|
|
588
|
+
np.abs(texture_right) >= threshold, texture_right, np.nan
|
|
589
|
+
)
|
|
590
|
+
else:
|
|
591
|
+
texture_left_masked = texture_left
|
|
592
|
+
texture_right_masked = texture_right
|
|
593
|
+
|
|
594
|
+
fig, ax = plt.subplots(1, 1, figsize=figsize)
|
|
595
|
+
|
|
596
|
+
# Plot curvature as a mid-grey background (zorder=0)
|
|
597
|
+
curv_norm = Normalize(vmin=-0.5, vmax=0.5)
|
|
598
|
+
curv_left_display = (curv_norm(curv_left) - 0.5) * 0.5 + 0.5
|
|
599
|
+
curv_right_display = (curv_norm(curv_right) - 0.5) * 0.5 + 0.5
|
|
600
|
+
|
|
601
|
+
ax.tripcolor(
|
|
602
|
+
coords_left[:, 0],
|
|
603
|
+
coords_left[:, 1],
|
|
604
|
+
faces_left,
|
|
605
|
+
curv_left_display,
|
|
606
|
+
cmap="gray",
|
|
607
|
+
shading="gouraud",
|
|
608
|
+
vmin=0,
|
|
609
|
+
vmax=1,
|
|
610
|
+
zorder=0,
|
|
611
|
+
)
|
|
612
|
+
ax.tripcolor(
|
|
613
|
+
coords_right[:, 0],
|
|
614
|
+
coords_right[:, 1],
|
|
615
|
+
faces_right,
|
|
616
|
+
curv_right_display,
|
|
617
|
+
cmap="gray",
|
|
618
|
+
shading="gouraud",
|
|
619
|
+
vmin=0,
|
|
620
|
+
vmax=1,
|
|
621
|
+
zorder=0,
|
|
622
|
+
)
|
|
623
|
+
|
|
624
|
+
# Plot data overlay
|
|
625
|
+
ax.tripcolor(
|
|
626
|
+
coords_left[:, 0],
|
|
627
|
+
coords_left[:, 1],
|
|
628
|
+
faces_left,
|
|
629
|
+
texture_left_masked,
|
|
630
|
+
cmap=cmap,
|
|
631
|
+
shading="gouraud",
|
|
632
|
+
vmin=vmin,
|
|
633
|
+
vmax=vmax,
|
|
634
|
+
zorder=1,
|
|
635
|
+
)
|
|
636
|
+
ax.tripcolor(
|
|
637
|
+
coords_right[:, 0],
|
|
638
|
+
coords_right[:, 1],
|
|
639
|
+
faces_right,
|
|
640
|
+
texture_right_masked,
|
|
641
|
+
cmap=cmap,
|
|
642
|
+
shading="gouraud",
|
|
643
|
+
vmin=vmin,
|
|
644
|
+
vmax=vmax,
|
|
645
|
+
zorder=1,
|
|
646
|
+
)
|
|
647
|
+
|
|
648
|
+
# Clean up axes
|
|
649
|
+
ax.set_aspect("equal")
|
|
650
|
+
ax.axis("off")
|
|
651
|
+
|
|
652
|
+
# Add title
|
|
653
|
+
if title is not None:
|
|
654
|
+
ax.set_title(title, fontsize=14)
|
|
655
|
+
|
|
656
|
+
# Add colorbar
|
|
657
|
+
if colorbar:
|
|
658
|
+
sm = ScalarMappable(cmap=cmap, norm=Normalize(vmin, vmax))
|
|
659
|
+
sm.set_array([])
|
|
660
|
+
fig.colorbar(sm, ax=ax, orientation="horizontal", fraction=0.046, pad=0.04)
|
|
661
|
+
|
|
662
|
+
plt.tight_layout()
|
|
663
|
+
|
|
664
|
+
# Save if requested
|
|
665
|
+
if save is not None:
|
|
666
|
+
fig.savefig(save, bbox_inches="tight", facecolor="white", dpi=300)
|
|
667
|
+
|
|
668
|
+
plt.close(fig)
|
|
669
|
+
return fig
|