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,896 @@
1
+ """Loading, resampling, writing, and uploading for `BrainData`.
2
+
3
+ Functions that resolve a mask, load data (from files, lists, URLs, HDF5, or other
4
+ `BrainData` objects), resample to a target grid, write NIfTI/HDF5, and upload to
5
+ NeuroVault. `BrainData` methods delegate here.
6
+ """
7
+
8
+ import os
9
+ import shutil
10
+ import tempfile
11
+ import warnings
12
+
13
+ import numpy as np
14
+ from pathlib import Path
15
+
16
+ from nltools.templates.paths import _split_template_name
17
+ from nltools.utils import ResamplingWarning, _find_stack_level
18
+
19
+
20
+ def _detect_interpolation(img):
21
+ """Detect appropriate interpolation method based on image data type.
22
+
23
+ Determines whether an image contains discrete (atlas/label) or continuous
24
+ data by checking if values are integers and counting unique values. For a
25
+ 4-D image only the first volume is inspected: it decides label-vs-signal as
26
+ well as the whole run does, without materializing a float64 copy of every
27
+ volume (nearly 2 GB for a typical BOLD run).
28
+
29
+ Args:
30
+ img: nibabel Nifti1Image or similar image object with a ``dataobj``.
31
+
32
+ Returns:
33
+ str: 'nearest' for discrete/atlas data, 'continuous' for continuous data
34
+
35
+ Notes:
36
+ - Returns 'nearest' if all non-NaN values are integers AND unique count < 1000
37
+ - Atlases typically have < 500 unique integer labels
38
+ - Statistical maps have continuous floating-point values
39
+ """
40
+ if img.ndim >= 4:
41
+ data = np.asanyarray(img.dataobj[..., 0])
42
+ else:
43
+ data = np.asanyarray(img.dataobj)
44
+
45
+ # Handle empty or all-NaN data
46
+ valid_data = data[~np.isnan(data)]
47
+ if valid_data.size == 0:
48
+ return "continuous"
49
+
50
+ # Check if all values are effectively integers (trivially true for an
51
+ # integer dtype; nibabel applies any header scaling, so a scaled int image
52
+ # arrives here as float and is checked value by value).
53
+ is_integer_valued = valid_data.dtype.kind in "iu" or np.allclose(
54
+ valid_data, np.round(valid_data), rtol=1e-10
55
+ )
56
+
57
+ if is_integer_valued:
58
+ n_unique = len(np.unique(valid_data))
59
+ # Atlases typically have < 1000 unique labels (most have < 500)
60
+ # Continuous data would have many more unique values
61
+ if n_unique < 1000:
62
+ return "nearest"
63
+
64
+ return "continuous"
65
+
66
+
67
+ def _initialize_mask(bd, mask):
68
+ """Initialize the mask image.
69
+
70
+ Args:
71
+ bd (BrainData): Instance whose mask is being set.
72
+ mask (Nifti1Image | str | Path | None): Brain mask as a nibabel image, file
73
+ path, template name string, or None. Template name strings follow
74
+ `'{res}mm-MNI152-2009{version}'` (e.g. `'2mm-MNI152-2009c'`,
75
+ `'3mm-MNI152-2009a'`, `'2mm-MNI152-2009fsl'`).
76
+ """
77
+ import nibabel as nib
78
+ from nltools.templates import get_brainspace
79
+
80
+ # Store whether mask was None (for auto-detection later)
81
+ bd._mask_was_none = mask is None
82
+
83
+ if mask is None:
84
+ # For empty BrainData or when data not yet loaded, use default template
85
+ # Template will be auto-detected during data loading if data is provided
86
+ bd.mask = nib.load(get_brainspace().mask)
87
+ elif isinstance(mask, (str, Path)):
88
+ mask_str = str(mask)
89
+ # A template name string ({res}mm-MNI152-2009{version}) resolves through
90
+ # the templates registry; anything else is a plain file path.
91
+ if _split_template_name(mask_str) is not None:
92
+ from nltools.templates import _resolve_template_name
93
+
94
+ mask_path = _resolve_template_name(mask_str, file_type="mask")
95
+ bd.mask = nib.load(mask_path)
96
+ else:
97
+ bd.mask = nib.load(mask_str)
98
+ elif isinstance(mask, nib.Nifti1Image):
99
+ bd.mask = mask
100
+ else:
101
+ raise TypeError(
102
+ f"mask must be a nibabel instance, file path, template name string, or None. "
103
+ f"Received {type(mask).__name__}"
104
+ )
105
+
106
+ # Extract voxel resolution from mask affine matrix
107
+ # The diagonal elements of the affine matrix (excluding translation) give voxel sizes
108
+ affine = bd.mask.affine
109
+ bd._voxel_resolution = np.abs(np.diag(affine[:3, :3]))
110
+
111
+ # Determine space (MNI or native) based on mask
112
+ bd._space = _detect_space(bd.mask)
113
+
114
+
115
+ def _get_interpolation(bd, img):
116
+ """Get the interpolation method to use for a given image.
117
+
118
+ Resolves 'auto' to either 'nearest' or 'continuous' based on data type.
119
+
120
+ Args:
121
+ bd (BrainData): Instance whose interpolation setting is consulted.
122
+ img (Nifti1Image): Image to inspect when the setting is 'auto'.
123
+
124
+ Returns:
125
+ str: Interpolation method. When the instance setting is 'auto', resolves
126
+ to 'nearest' or 'continuous' based on data type; otherwise the
127
+ instance's configured interpolation setting.
128
+ """
129
+ if bd._interpolation == "auto":
130
+ return _detect_interpolation(img)
131
+ return bd._interpolation
132
+
133
+
134
+ def _resample_img_to_mask(bd, data_img):
135
+ """Resample ``data_img`` onto ``bd.mask``'s grid with the resolved interpolation.
136
+
137
+ Integer-typed voxel data (int16 BOLD is the common case) is cast to float32
138
+ first when the interpolation is continuous. nilearn performs exactly that
139
+ cast itself inside ``resample_img`` — and warns about it on every load —
140
+ so doing it here removes the notice without changing the result. Nearest
141
+ interpolation keeps the integer dtype (labels stay labels). A header with
142
+ no sform (haxby's, for one) gets the same code-2 sform `resample`
143
+ assigns, for the same reason (see `_ensure_sform`).
144
+ """
145
+ import nibabel as nib
146
+ from nilearn.image import resample_to_img
147
+
148
+ interpolation = _get_interpolation(bd, data_img)
149
+ if interpolation != "nearest":
150
+ data = np.asanyarray(data_img.dataobj)
151
+ if data.dtype.kind in "iu":
152
+ data_img = nib.Nifti1Image(
153
+ data.astype(np.float32), data_img.affine, data_img.header
154
+ )
155
+ data_img.set_data_dtype(np.float32)
156
+ return resample_to_img(
157
+ _ensure_sform(data_img), bd.mask, interpolation=interpolation
158
+ )
159
+
160
+
161
+ def _resample_to_mask(bd, data_img, context=""):
162
+ """Resample data_img to bd.mask if spaces differ and bd._resample is True.
163
+
164
+ Returns data_img unchanged if spaces already match or resampling is disabled.
165
+ """
166
+ if _check_space_match(data_img, bd.mask) or not bd._resample:
167
+ return data_img
168
+
169
+ _warn_if_resampling(bd, context)
170
+ return _resample_img_to_mask(bd, data_img)
171
+
172
+
173
+ def _detect_and_update_mask(bd, data_img):
174
+ """Detect best matching template from data and update mask if mask was None.
175
+
176
+ Also handles resampling if needed based on the resample kwarg.
177
+
178
+ This function is called during data loading to auto-detect template when mask=None.
179
+ After detecting or falling back to a template, it checks if resampling is needed
180
+ and resamples the data_img accordingly.
181
+
182
+ Args:
183
+ bd (BrainData): Instance whose mask may be updated.
184
+ data_img (Nifti1Image): Image from which to detect the template.
185
+
186
+ Returns:
187
+ Nifti1Image: The input image, resampled to the mask grid if needed.
188
+ """
189
+ import nibabel as nib
190
+
191
+ if not bd._mask_was_none:
192
+ return _resample_to_mask(bd, data_img)
193
+
194
+ try:
195
+ from nltools.templates import _match_resolution
196
+
197
+ template_info = _match_resolution(
198
+ data_img.affine,
199
+ warn_resample=bd._resample,
200
+ )
201
+
202
+ detected_mask = nib.load(template_info.mask_path)
203
+ current_mask_path = bd.mask.get_filename()
204
+ detected_mask_path = template_info.mask_path
205
+
206
+ if current_mask_path != detected_mask_path:
207
+ bd.mask = detected_mask
208
+ affine = bd.mask.affine
209
+ bd._voxel_resolution = np.abs(np.diag(affine[:3, :3]))
210
+ bd._space = _detect_space(bd.mask)
211
+
212
+ return _resample_to_mask(
213
+ bd,
214
+ data_img,
215
+ f"Detected template ({template_info.template} "
216
+ f"{template_info.resolution}mm) differs from data resolution.",
217
+ )
218
+
219
+ except Exception as e:
220
+ warnings.warn(
221
+ f"Failed to auto-detect template from data: {e}. "
222
+ f"Using default template (get_brainspace().mask).",
223
+ UserWarning,
224
+ stacklevel=_find_stack_level(),
225
+ )
226
+ return _resample_to_mask(
227
+ bd,
228
+ data_img,
229
+ "Template auto-detection failed; using default template.",
230
+ )
231
+
232
+
233
+ def _detect_space(mask):
234
+ """Detect if mask is in MNI space or native space.
235
+
236
+ Args:
237
+ mask (Nifti1Image): Mask image to classify.
238
+
239
+ Returns:
240
+ str: 'mni' if the mask matches the MNI template, 'native' otherwise.
241
+ """
242
+ import nibabel as nib
243
+ from nltools.templates import get_brainspace
244
+
245
+ # Get mask filename if available
246
+ mask_filename = mask.get_filename()
247
+
248
+ # Check if mask is None (uses default MNI template)
249
+ # This is handled in _initialize_mask, but check here for safety
250
+ if mask_filename is None:
251
+ # Compare affine matrix with MNI template
252
+ try:
253
+ mni_mask = nib.load(get_brainspace().mask)
254
+ if np.allclose(mask.affine, mni_mask.affine, rtol=1e-3):
255
+ return "mni"
256
+ except Exception:
257
+ pass
258
+ return "native"
259
+
260
+ # Normalize paths for comparison
261
+ mask_path = str(Path(mask_filename).resolve())
262
+ mni_mask_path = str(Path(get_brainspace().mask).resolve())
263
+
264
+ # Check if mask path matches MNI template path
265
+ if mask_path == mni_mask_path:
266
+ return "mni"
267
+
268
+ # Check if affine matches MNI template affine (for cases where mask is loaded differently)
269
+ try:
270
+ mni_mask = nib.load(get_brainspace().mask)
271
+ if np.allclose(mask.affine, mni_mask.affine, rtol=1e-3):
272
+ return "mni"
273
+ except Exception:
274
+ pass
275
+
276
+ # Default to native if not matching MNI
277
+ return "native"
278
+
279
+
280
+ def _check_space_match(data_img, mask_img):
281
+ """Check if data and mask are in same space.
282
+
283
+ Args:
284
+ data_img (Nifti1Image): Data image.
285
+ mask_img (Nifti1Image): Mask image.
286
+
287
+ Returns:
288
+ bool: True if affines and spatial shapes match (no resampling needed).
289
+ """
290
+ # Compare affine matrices
291
+ affine_match = np.allclose(data_img.affine, mask_img.affine, rtol=1e-3)
292
+
293
+ # Compare spatial shapes
294
+ shape_match = data_img.shape[:3] == mask_img.shape[:3]
295
+
296
+ return affine_match and shape_match
297
+
298
+
299
+ def _warn_if_resampling(bd, context=""):
300
+ """Emit a `ResamplingWarning` if ``verbose=True`` and ``resample=True``.
301
+
302
+ Sibling of the template-mismatch notice in `_match_resolution`: that one
303
+ fires when a template is chosen for data at another resolution; this one
304
+ fires when the data is actually resampled to the mask's grid.
305
+
306
+ Args:
307
+ bd (BrainData): Instance whose `verbose` and resample settings apply.
308
+ context (str): Why the spaces differ, appended to the message.
309
+ Default: empty string.
310
+ """
311
+ if bd._resample and bd.verbose:
312
+ resolution = "x".join(f"{r:g}" for r in bd._voxel_resolution)
313
+ msg = (
314
+ f"Data does not match the mask space; resampling it to the mask's "
315
+ f"{resolution}mm grid (resample=True)."
316
+ )
317
+ if context:
318
+ msg = f"{msg} {context}"
319
+ warnings.warn(msg, ResamplingWarning, stacklevel=_find_stack_level())
320
+
321
+
322
+ def _mask_images(mask, imgs):
323
+ """Mask a list of space-aligned images with a single fitted masker.
324
+
325
+ Validates ``mask`` exactly ONCE — one ``load_mask_img`` — and reuses the
326
+ binarized mask across every image in ``imgs``, instead of re-running
327
+ nilearn's costly ``load_mask_img`` (binarization checks + ``safe_get_data``,
328
+ which each trigger nilearn's forced ``gc.collect``) per image.
329
+
330
+ ``nilearn.masking.apply_mask`` is exactly ``load_mask_img`` (validate) ->
331
+ ``new_img_like`` (build binary mask) -> ``apply_mask_fmri`` (extract), with
332
+ ``dtype='f'``, ``smoothing_fwhm=None``, ``ensure_finite=True``. This hoists
333
+ the first two out of the per-image loop and calls the lower-level
334
+ ``apply_mask_fmri`` (which "assumes mask_img contains only two different
335
+ values") per image, so the result is byte-equivalent to
336
+ ``np.vstack([apply_mask(im, mask) for im in imgs])`` for space-aligned data.
337
+
338
+ Images must already share ``mask``'s space (callers resample first); no
339
+ resampling is done here. Falls back to the per-image functional
340
+ ``apply_mask`` if the fast path raises for any reason.
341
+
342
+ Args:
343
+ mask (Nifti1Image): Boolean/binary mask image.
344
+ imgs (list[Nifti1Image]): Space-aligned images to mask.
345
+
346
+ Returns:
347
+ np.ndarray: Masked data of shape ``(len(imgs), n_voxels)``.
348
+ """
349
+ from nilearn.masking import apply_mask as nilearn_apply_mask
350
+
351
+ try:
352
+ return _mask_images_fast(mask, imgs)
353
+ except Exception:
354
+ # Functional fallback — one load_mask_img per image, but always correct.
355
+ return np.vstack([nilearn_apply_mask(im, mask) for im in imgs])
356
+
357
+
358
+ def _mask_images_fast(mask, imgs):
359
+ """Validate ``mask`` once, then extract every image via ``apply_mask_fmri``.
360
+
361
+ Reproduces ``nilearn.masking.apply_mask``'s internals with the
362
+ validate-and-binarize step (``load_mask_img`` + ``new_img_like``) hoisted
363
+ out of the per-image loop. Split out so the fallback in `_mask_images`
364
+ is testable in isolation.
365
+ """
366
+ from nilearn.image import new_img_like
367
+ from nilearn.masking import apply_mask_fmri, load_mask_img
368
+
369
+ mask_arr, mask_affine = load_mask_img(mask) # validate + binarize ONCE
370
+ binary_mask = new_img_like(mask, mask_arr, mask_affine)
371
+ return np.vstack([apply_mask_fmri(im, binary_mask) for im in imgs])
372
+
373
+
374
+ def _load_from_list(bd, data_list):
375
+ """Load data from a list of BrainData objects or file paths.
376
+
377
+ Args:
378
+ bd (BrainData): Instance to populate.
379
+ data_list (list[BrainData] | list[str | Path | Nifti1Image]): Items to load
380
+ and stack.
381
+ """
382
+ import nibabel as nib
383
+ from ..combine import concatenate
384
+ from nltools.data.braindata.validation import _validate_list_data
385
+
386
+ list_type = _validate_list_data(data_list)
387
+
388
+ if list_type == "brain_data":
389
+ tmp = concatenate(data_list)
390
+ for item in ["data", "mask"]:
391
+ setattr(bd, item, getattr(tmp, item))
392
+ return
393
+
394
+ bd.data = []
395
+
396
+ # Auto-detect template from first item if mask was None
397
+ if bd._mask_was_none and len(data_list) > 0:
398
+ first_item = data_list[0]
399
+ if isinstance(first_item, (str, Path)):
400
+ first_img = nib.load(str(first_item))
401
+ elif isinstance(first_item, nib.Nifti1Image):
402
+ first_img = first_item
403
+ else:
404
+ first_img = None
405
+
406
+ if first_img is not None:
407
+ _detect_and_update_mask(bd, first_img)
408
+
409
+ # Prepare (load + space-align) each item, then mask them all with a single
410
+ # fitted masker so the mask is validated once per call rather than per item.
411
+ prepared_imgs = []
412
+ for idx, item in enumerate(data_list):
413
+ if isinstance(item, (str, Path)):
414
+ item_img = nib.load(str(item))
415
+ elif isinstance(item, nib.Nifti1Image):
416
+ item_img = item
417
+ else:
418
+ raise TypeError(
419
+ f"List items must be file paths or nibabel Nifti1Image. "
420
+ f"Received {type(item).__name__}"
421
+ )
422
+
423
+ if not _check_space_match(item_img, bd.mask):
424
+ if not bd._resample:
425
+ raise ValueError(
426
+ f"Data item and mask are in different spaces. "
427
+ f"Set resample=True to automatically resample data to mask space, "
428
+ f"or ensure all data items are already in the same space as the mask.\n"
429
+ f"Item affine:\n{item_img.affine}\n"
430
+ f"Mask affine:\n{bd.mask.affine}\n"
431
+ f"Item shape: {item_img.shape[:3]}\n"
432
+ f"Mask shape: {bd.mask.shape[:3]}"
433
+ )
434
+ if idx == 0:
435
+ _warn_if_resampling(bd)
436
+ item_img = _resample_to_mask(bd, item_img)
437
+
438
+ prepared_imgs.append(item_img)
439
+
440
+ # Byte-equivalent to per-item apply_mask + vstack, but validates the mask
441
+ # once (see _mask_images). vstack for nilearn 0.12+ compat (transforms
442
+ # 3D -> 1D instead of 3D -> 2D).
443
+ bd.data = _mask_images(bd.mask, prepared_imgs)
444
+
445
+
446
+ def _load_from_brain_data(bd, brain_data, mask=None):
447
+ """Load data from another BrainData object.
448
+
449
+ Args:
450
+ bd (BrainData): Instance to populate.
451
+ brain_data (BrainData): Object to copy from.
452
+ mask (Nifti1Image | str | Path | None): Mask to use. If None, uses the mask
453
+ from `brain_data`.
454
+ """
455
+ import nibabel as nib
456
+ from nilearn.image import resample_to_img
457
+ from nilearn.masking import apply_mask as nilearn_apply_mask
458
+
459
+ # Copy data array
460
+ bd.data = brain_data.data.copy() if brain_data.data is not None else np.array([])
461
+
462
+ # Handle mask: use provided mask if given, otherwise use source mask
463
+ if mask is not None:
464
+ # User provided mask - re-initialize with it
465
+ # This will trigger mask initialization but we already have data
466
+ # Need to handle resampling if mask differs
467
+ if isinstance(mask, (str, Path)):
468
+ mask_str = str(mask)
469
+ if _split_template_name(mask_str) is not None:
470
+ from nltools.templates import _resolve_template_name
471
+
472
+ new_mask = nib.load(_resolve_template_name(mask_str, file_type="mask"))
473
+ else:
474
+ new_mask = nib.load(mask_str)
475
+ elif isinstance(mask, nib.Nifti1Image):
476
+ new_mask = mask
477
+ else:
478
+ raise TypeError(
479
+ f"mask must be a nibabel instance, file path, template name string, or None. "
480
+ f"Received {type(mask).__name__}"
481
+ )
482
+
483
+ # Check if mask differs from source
484
+ if not _check_space_match(brain_data.mask, new_mask):
485
+ # Need to resample data to new mask space
486
+ if bd._resample:
487
+ _warn_if_resampling(bd, "New mask differs from source BrainData mask.")
488
+ source_nifti = brain_data.to_nifti()
489
+ resampled_nifti = resample_to_img(
490
+ source_nifti,
491
+ new_mask,
492
+ interpolation=_get_interpolation(bd, source_nifti),
493
+ )
494
+ # Update mask
495
+ bd.mask = new_mask
496
+ # Extract data via functional apply_mask
497
+ bd.data = nilearn_apply_mask(resampled_nifti, bd.mask)
498
+ # Update voxel resolution and space
499
+ affine = bd.mask.affine
500
+ bd._voxel_resolution = np.abs(np.diag(affine[:3, :3]))
501
+ bd._space = _detect_space(bd.mask)
502
+ else:
503
+ raise ValueError(
504
+ "Source BrainData mask and provided mask are in different spaces. "
505
+ "Set resample=True to automatically resample data to new mask space."
506
+ )
507
+ else:
508
+ # Masks match - just update mask reference
509
+ bd.mask = new_mask
510
+ affine = bd.mask.affine
511
+ bd._voxel_resolution = np.abs(np.diag(affine[:3, :3]))
512
+ bd._space = _detect_space(bd.mask)
513
+ else:
514
+ # Use source mask
515
+ bd.mask = brain_data.mask
516
+ bd._voxel_resolution = brain_data._voxel_resolution
517
+ bd._space = brain_data._space
518
+
519
+ if hasattr(brain_data, "_mask_was_none"):
520
+ bd._mask_was_none = brain_data._mask_was_none
521
+
522
+
523
+ def _load_from_h5(bd, file_path, mask):
524
+ """Load data from HDF5 file.
525
+
526
+ Args:
527
+ bd (BrainData): Instance to populate.
528
+ file_path (str | Path): Path to the HDF5 file.
529
+ mask (Nifti1Image | str | Path | None): User-specified mask; when None the
530
+ mask stored in the file is used.
531
+ """
532
+ from nltools.io.h5 import _load_brain_data_h5
533
+
534
+ # Load data using utility function
535
+ h5_data = _load_brain_data_h5(file_path, mask)
536
+ bd.data = h5_data["data"]
537
+
538
+ # Load X and Y if present (for backward compatibility)
539
+ if "X" in h5_data:
540
+ bd.X = h5_data["X"]
541
+ if "Y" in h5_data:
542
+ bd.Y = h5_data["Y"]
543
+
544
+ # Handle mask if loaded from file
545
+ if h5_data.get("load_mask", False):
546
+ bd.mask = h5_data["mask"]
547
+ # Extract voxel resolution from mask affine matrix
548
+ affine = bd.mask.affine
549
+ bd._voxel_resolution = np.abs(np.diag(affine[:3, :3]))
550
+ # Determine space (MNI or native) based on mask
551
+ bd._space = _detect_space(bd.mask)
552
+ elif mask is not None and not h5_data.get("load_mask", True):
553
+ warnings.warn(
554
+ "Existing mask found in HDF5 file but is being ignored because "
555
+ "you passed a value for mask. Set mask=None to use existing "
556
+ "mask in the HDF5 file",
557
+ UserWarning,
558
+ stacklevel=_find_stack_level(),
559
+ )
560
+
561
+
562
+ def _load_from_url(bd, url):
563
+ """Load data from URL.
564
+
565
+ Args:
566
+ bd (BrainData): Instance to populate.
567
+ url (str): URL of a NIfTI file to download.
568
+ """
569
+ import nibabel as nib
570
+ from nltools.datasets import download_nifti
571
+
572
+ # TemporaryDirectory guarantees a unique name and removes the download
573
+ # (avoids the os.times()-based collision + leak in the old code).
574
+ with tempfile.TemporaryDirectory() as tmp_dir:
575
+ downloaded_file = nib.load(download_nifti(url, data_dir=tmp_dir))
576
+ _load_from_file(bd, downloaded_file)
577
+
578
+
579
+ def _load_from_file(bd, data):
580
+ """Load data from file path or nibabel object.
581
+
582
+ Args:
583
+ bd (BrainData): Instance to populate.
584
+ data (str | Path | Nifti1Image): File path or nibabel image.
585
+ """
586
+ import nibabel as nib
587
+ from nilearn.masking import apply_mask as nilearn_apply_mask
588
+
589
+ if isinstance(data, (str, Path)):
590
+ data_img = nib.load(str(data))
591
+ elif isinstance(data, nib.Nifti1Image):
592
+ data_img = data
593
+ else:
594
+ raise TypeError(
595
+ f"data must be a file path or nibabel Nifti1Image. "
596
+ f"Received {type(data).__name__}"
597
+ )
598
+
599
+ # Auto-detect template from data if mask was None; also handles resampling.
600
+ data_img = _detect_and_update_mask(bd, data_img)
601
+
602
+ # When resample=False but spaces still mismatch, warn and resample anyway
603
+ # (required for correct masking).
604
+ if not bd._resample and not _check_space_match(data_img, bd.mask):
605
+ if bd.verbose:
606
+ warnings.warn(
607
+ f"Data and mask are in different spaces (affine or shape mismatch). "
608
+ f"Resampling data to match mask space despite resample=False. "
609
+ f"Set resample=True to explicitly enable resampling, or ensure data "
610
+ f"is already in the same space as the mask.\n"
611
+ f"Data affine:\n{data_img.affine}\n"
612
+ f"Mask affine:\n{bd.mask.affine}\n"
613
+ f"Data shape: {data_img.shape[:3]}\n"
614
+ f"Mask shape: {bd.mask.shape[:3]}",
615
+ ResamplingWarning,
616
+ stacklevel=_find_stack_level(),
617
+ )
618
+ data_img = _resample_img_to_mask(bd, data_img)
619
+
620
+ bd.data = nilearn_apply_mask(data_img, bd.mask)
621
+
622
+
623
+ def _to_nifti(bd):
624
+ """Convert BrainData instance to a nibabel NIfTI image.
625
+
626
+ Args:
627
+ bd (BrainData): Instance to convert.
628
+
629
+ Returns:
630
+ Nifti1Image: Brain data in volumetric NIfTI format.
631
+ """
632
+ from nilearn.masking import unmask
633
+
634
+ img = unmask(bd.data, bd.mask)
635
+ # unmask inherits the mask's dtype (often int8) for the output header, which
636
+ # would scale-quantize float data to ~1 LSB on save — silently lossy for stat
637
+ # maps and betas. Pin the on-disk dtype to the
638
+ # data's own so writes are lossless (integer masks stay integer, maps stay float).
639
+ img.set_data_dtype(bd.data.dtype)
640
+ return img
641
+
642
+
643
+ def _ensure_sform(img):
644
+ """Return a Nifti1Image with sform_code set, copying first if needed.
645
+
646
+ nilearn emits a warning during resampling when sform_code==0. We set
647
+ code=2 (NIFTI_XFORM_ALIGNED_ANAT) — the same value nilearn assigns to
648
+ resampled outputs. The copy avoids mutating caller-owned objects
649
+ (e.g. ``bd.mask`` or an image passed in by the user).
650
+ """
651
+ import nibabel as nib
652
+
653
+ if img.header.get_sform(coded=True)[1] != 0:
654
+ return img
655
+ out = nib.Nifti1Image(img.dataobj, img.affine, img.header.copy())
656
+ out.header.set_sform(img.affine, code=2)
657
+ return out
658
+
659
+
660
+ def _resample(bd, *, img=None, resolution=None, interpolation=None):
661
+ """Resample BrainData onto a new voxel grid.
662
+
663
+ Exactly one of `img` or `resolution` must be given. An `img` supplies only
664
+ the target grid; its intensity values never define the output mask. The
665
+ source mask is resampled onto that grid with nearest-neighbor interpolation
666
+ and installed on the result, which preserves row-aligned `X` and `Y` and
667
+ carries no fitted state.
668
+
669
+ Args:
670
+ bd (BrainData): Instance to resample.
671
+ img (Nifti1Image | str | Path | None): Target image whose grid to match,
672
+ as a nibabel image or a path to a `.nii`/`.nii.gz` file.
673
+ resolution (float | int | None): Target isotropic voxel size in mm
674
+ (e.g. `2.0` for 2 mm³ voxels).
675
+ interpolation (str | None): Interpolation method for the data:
676
+ `'nearest'` (atlases, masks, labels), `'linear'`, or `'continuous'`
677
+ (higher-order spline, for stat maps). None uses the instance's
678
+ interpolation setting.
679
+
680
+ Returns:
681
+ BrainData: New instance with resampled data and mask.
682
+
683
+ Raises:
684
+ ValueError: If both `img` and `resolution` are None, both are provided,
685
+ `resolution` is not positive, or the instance is empty.
686
+ TypeError: If `img` is not a valid image type.
687
+ """
688
+ import nibabel as nib
689
+ from nilearn.image import resample_to_img, resample_img
690
+ from nilearn.masking import apply_mask as nilearn_apply_mask
691
+
692
+ from .utils import _result_with_mask
693
+
694
+ if img is None and resolution is None:
695
+ raise ValueError(
696
+ "Must provide either 'img' or 'resolution' parameter. "
697
+ "Provide exactly one of them."
698
+ )
699
+ if img is not None and resolution is not None:
700
+ raise ValueError(
701
+ "Cannot provide both 'img' and 'resolution' parameters. "
702
+ "Provide exactly one of them."
703
+ )
704
+
705
+ # Check the target argument itself, then the object, before touching disk
706
+ # or doing any resampling work.
707
+ target_affine = None
708
+ if resolution is not None:
709
+ resolution = float(resolution)
710
+ if resolution <= 0:
711
+ raise ValueError(f"resolution must be positive. Got {resolution}")
712
+ target_affine = np.eye(4)
713
+ target_affine[:3, :3] = np.diag([resolution, resolution, resolution])
714
+ target_description = f"resolution={resolution}"
715
+ else:
716
+ if not isinstance(img, (str, Path, nib.Nifti1Image)):
717
+ raise TypeError(
718
+ f"img must be nibabel Nifti1Image, file path (str/Path), or None. "
719
+ f"Got {type(img).__name__}"
720
+ )
721
+ target_description = f"img={img if isinstance(img, (str, Path)) else 'image'}"
722
+
723
+ if len(bd) == 0:
724
+ raise ValueError("Cannot resample empty BrainData object")
725
+
726
+ target_img = None
727
+ if target_affine is None:
728
+ if isinstance(img, (str, Path)):
729
+ img = nib.load(str(img))
730
+ # Copy-on-write via _ensure_sform avoids mutating the caller's image.
731
+ target_img = _ensure_sform(img)
732
+
733
+ source_nifti = _to_nifti(bd)
734
+ if interpolation is None:
735
+ interpolation = _get_interpolation(bd, source_nifti)
736
+ source_nifti = _ensure_sform(source_nifti)
737
+
738
+ # Both branches clip spline overshoot to the source range; nilearn's own
739
+ # defaults disagree between the two calls, so state the rule here.
740
+ # The mask always uses nearest interpolation so that it stays binary.
741
+ source_mask = _ensure_sform(bd.mask)
742
+ if target_img is not None:
743
+ resampled_nifti = resample_to_img(
744
+ source_nifti, target_img, interpolation=interpolation, clip=True
745
+ )
746
+ resampled_mask = resample_to_img(
747
+ source_mask, target_img, interpolation="nearest", clip=True
748
+ )
749
+ else:
750
+ resampled_nifti = resample_img(
751
+ source_nifti,
752
+ target_affine=target_affine,
753
+ interpolation=interpolation,
754
+ clip=True,
755
+ )
756
+ resampled_mask = resample_img(
757
+ source_mask,
758
+ target_affine=target_affine,
759
+ interpolation="nearest",
760
+ clip=True,
761
+ )
762
+
763
+ if not np.any(resampled_mask.get_fdata() > 0):
764
+ raise ValueError(
765
+ f"Resampling to {target_description} leaves no voxels: the mask's "
766
+ "support does not survive on the target grid. Choose a finer "
767
+ "resolution or a target grid that overlaps the data."
768
+ )
769
+
770
+ resampled_data = nilearn_apply_mask(resampled_nifti, resampled_mask)
771
+ return _result_with_mask(bd, resampled_data, resampled_mask, rows="preserve")
772
+
773
+
774
+ def _write_brain_data(bd, file_name):
775
+ """Write out BrainData object to Nifti or HDF5 File.
776
+
777
+ Args:
778
+ bd (BrainData): Instance to write.
779
+ file_name (str | Path): Output file path. Supports `.nii`/`.nii.gz` (NIfTI)
780
+ and `.h5`/`.hdf5` (HDF5) formats.
781
+ """
782
+ from nltools.io.h5 import _is_h5_path, _to_h5
783
+
784
+ if isinstance(file_name, Path):
785
+ file_name = str(file_name)
786
+
787
+ if _is_h5_path(file_name):
788
+ _to_h5(
789
+ bd,
790
+ file_name,
791
+ obj_type="brain_data",
792
+ h5_compression=bd._h5_compression,
793
+ )
794
+ else:
795
+ _to_nifti(bd).to_filename(file_name)
796
+
797
+
798
+ def _upload_neurovault( # nosemgrep: kwargs-internal-forwarding # forwards to the NeuroVault API
799
+ bd,
800
+ *,
801
+ access_token=None,
802
+ collection_name=None,
803
+ collection_id=None,
804
+ img_type=None,
805
+ img_modality=None,
806
+ **kwargs,
807
+ ):
808
+ """Upload data to NeuroVault.
809
+
810
+ Adds any columns in `bd.X` to image metadata. Index will be used as image name.
811
+
812
+ Args:
813
+ bd (BrainData): Images to upload.
814
+ access_token (str): NeuroVault API access token. Required.
815
+ collection_name (str | None): Name of a new collection to create.
816
+ collection_id (int | None): NeuroVault collection ID when adding images
817
+ to an existing collection.
818
+ img_type (str): NeuroVault map type (e.g. `'Z'`, `'T'`). Required.
819
+ img_modality (str): NeuroVault image modality (e.g. `'fMRI-BOLD'`). Required.
820
+ **kwargs (dict): Additional image metadata forwarded to
821
+ `pynv.Client.add_image`.
822
+
823
+ Returns:
824
+ dict: NeuroVault collection information.
825
+ """
826
+ from pynv import Client
827
+
828
+ if access_token is None:
829
+ raise ValueError("You must supply a valid neurovault access token")
830
+
831
+ if img_type is None:
832
+ raise ValueError(
833
+ "You must supply img_type (the NeuroVault map type, e.g. 'Z' or 'T')"
834
+ )
835
+
836
+ if img_modality is None:
837
+ raise ValueError(
838
+ "You must supply img_modality (the NeuroVault image modality, e.g. 'fMRI-BOLD')"
839
+ )
840
+
841
+ api = Client(access_token=access_token)
842
+
843
+ # Check if collection exists
844
+ if collection_id is not None:
845
+ collection = api.get_collection(collection_id)
846
+ else:
847
+ try:
848
+ collection = api.create_collection(collection_name)
849
+ except ValueError as e:
850
+ raise ValueError(
851
+ "Collection Name already exists. Pick a "
852
+ "different name or specify an existing collection id"
853
+ ) from e
854
+
855
+ # mkdtemp guarantees a unique dir (the old os.times() name could collide).
856
+ tmp_dir = tempfile.mkdtemp()
857
+
858
+ def add_image_to_collection( # nosemgrep: kwargs-internal-forwarding # forwards to the NeuroVault API
859
+ api, collection, dat, tmp_dir, index_id=0, **kwargs
860
+ ):
861
+ """Upload an image to a NeuroVault collection.
862
+
863
+ Args:
864
+ api (pynv.Client): Authenticated NeuroVault client.
865
+ collection (dict): Collection the image is added to.
866
+ dat (BrainData): Single-image BrainData instance to upload.
867
+ tmp_dir (str): Directory the image is written to before upload.
868
+ index_id (int): Index used to name the uploaded file.
869
+ """
870
+ if (len(dat.shape) > 1) & (dat.shape[0] > 1):
871
+ raise ValueError('"dat" must be a single image.')
872
+ img_name = collection["name"] + "_" + str(index_id) + ".nii.gz"
873
+ f_path = os.path.join(tmp_dir, img_name)
874
+ dat.write(f_path)
875
+ if not dat.X.is_empty():
876
+ # .X is a 1-row polars DataFrame of per-image metadata; expand
877
+ # its columns into the Neurovault upload kwargs.
878
+ row = dat.X.row(0)
879
+ kwargs.update(dict(zip(dat.X.columns, row)))
880
+ api.add_image(
881
+ collection["id"],
882
+ f_path,
883
+ name=img_name,
884
+ modality=img_modality,
885
+ map_type=img_type,
886
+ **kwargs,
887
+ )
888
+
889
+ if len(bd.shape) == 1:
890
+ add_image_to_collection(api, collection, bd, tmp_dir, index_id=0, **kwargs)
891
+ else:
892
+ for i, x in enumerate(bd):
893
+ add_image_to_collection(api, collection, x, tmp_dir, index_id=i, **kwargs)
894
+
895
+ shutil.rmtree(tmp_dir, ignore_errors=True)
896
+ return collection