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