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,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