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
nltools/io/h5.py
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"""HDF5 I/O utilities for nltools data types.
|
|
2
|
+
|
|
3
|
+
Shared serialization logic for BrainData and Adjacency objects.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import io
|
|
7
|
+
from pathlib import Path, PureWindowsPath
|
|
8
|
+
|
|
9
|
+
import nibabel as nib
|
|
10
|
+
import numpy as np
|
|
11
|
+
import polars as pl
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
import h5py
|
|
15
|
+
from h5py import File as h5File
|
|
16
|
+
except ImportError as _h5_import_error:
|
|
17
|
+
h5py = None # type: ignore[assignment]
|
|
18
|
+
h5File = None # type: ignore[assignment]
|
|
19
|
+
_H5_IMPORT_ERROR: ImportError | None = _h5_import_error
|
|
20
|
+
else:
|
|
21
|
+
_H5_IMPORT_ERROR = None
|
|
22
|
+
|
|
23
|
+
#: The compression filters `_to_h5` accepts — h5py's own, needing no plugin.
|
|
24
|
+
_SUPPORTED_COMPRESSION = ("gzip", "lzf")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _require_h5():
|
|
28
|
+
"""Raise a friendly error if h5py isn't installed."""
|
|
29
|
+
if _H5_IMPORT_ERROR is not None:
|
|
30
|
+
raise ImportError(
|
|
31
|
+
"HDF5 I/O requires h5py. Install with: pip install 'nltools[h5]'"
|
|
32
|
+
) from _H5_IMPORT_ERROR
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _reject_legacy_h5(source, legacy_marker):
|
|
36
|
+
"""Raise when an open HDF5 file carries the nltools 0.5.1 deepdish layout.
|
|
37
|
+
|
|
38
|
+
0.5.1 stored each frame as a flat dataset beside a sibling
|
|
39
|
+
`<name>_columns` node; v0.6.0 reads only its own layout.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
source (h5py.File): Open HDF5 file to inspect.
|
|
43
|
+
legacy_marker (str): Top-level node that only the 0.5.1 layout has.
|
|
44
|
+
|
|
45
|
+
Raises:
|
|
46
|
+
ValueError: If the file was written by nltools 0.5.1 or earlier.
|
|
47
|
+
"""
|
|
48
|
+
if legacy_marker in source:
|
|
49
|
+
raise ValueError(
|
|
50
|
+
"This HDF5 file was written by nltools 0.5.1 or earlier, a layout "
|
|
51
|
+
"v0.6.0 no longer reads. Open it under 0.5.1 and export the data "
|
|
52
|
+
"first — BrainData.write('x.nii.gz') for images, "
|
|
53
|
+
"Adjacency.write('x.csv') for matrices — then load the export."
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _validate_compression(compression):
|
|
58
|
+
"""Reject a compression filter h5py does not provide on its own.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
compression (str): Value passed as `h5_compression`.
|
|
62
|
+
|
|
63
|
+
Raises:
|
|
64
|
+
ValueError: If `compression` is not one of `_SUPPORTED_COMPRESSION`.
|
|
65
|
+
"""
|
|
66
|
+
if compression not in _SUPPORTED_COMPRESSION:
|
|
67
|
+
raise ValueError(
|
|
68
|
+
f"h5_compression must be one of {_SUPPORTED_COMPRESSION}; "
|
|
69
|
+
f"got {compression!r}."
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _is_h5_path(file_name) -> bool:
|
|
74
|
+
"""Check if a file path indicates an HDF5 file.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
file_name (str | Path): Path to check.
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
bool: True if the file has an HDF5 extension (`.h5` or `.hdf5`).
|
|
81
|
+
|
|
82
|
+
Examples:
|
|
83
|
+
```python
|
|
84
|
+
_is_h5_path("data.h5") # → True
|
|
85
|
+
_is_h5_path("data.csv") # → False
|
|
86
|
+
_is_h5_path(Path("results.hdf5")) # → True
|
|
87
|
+
```
|
|
88
|
+
"""
|
|
89
|
+
if isinstance(file_name, Path):
|
|
90
|
+
file_name = str(file_name)
|
|
91
|
+
return file_name.lower().endswith((".h5", ".hdf5"))
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _mask_basename(stored_name):
|
|
95
|
+
"""Reduce a stored mask filename to its basename.
|
|
96
|
+
|
|
97
|
+
Files written before the basename rule — and files written on Windows,
|
|
98
|
+
whose separators mean nothing to `pathlib` on POSIX — carry a full path, so
|
|
99
|
+
both separators are stripped here.
|
|
100
|
+
"""
|
|
101
|
+
if "\\" in stored_name:
|
|
102
|
+
return PureWindowsPath(stored_name).name
|
|
103
|
+
return Path(stored_name).name
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _write_polars_frame(h5_file, name, df, compression):
|
|
107
|
+
"""Write a polars DataFrame to an h5 dataset as raw IPC bytes.
|
|
108
|
+
|
|
109
|
+
Uses polars' Arrow IPC format so every dtype — strings, booleans, nulls,
|
|
110
|
+
and mixed-type frames — round-trips exactly. h5py handles the resulting
|
|
111
|
+
byte buffer directly.
|
|
112
|
+
"""
|
|
113
|
+
buf = io.BytesIO()
|
|
114
|
+
df.write_ipc(buf)
|
|
115
|
+
h5_file.create_dataset(
|
|
116
|
+
name,
|
|
117
|
+
data=np.frombuffer(buf.getvalue(), dtype=np.uint8),
|
|
118
|
+
compression=compression,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _read_polars_frame(h5_file, name):
|
|
123
|
+
"""Read a polars DataFrame from an h5 dataset written by _write_polars_frame."""
|
|
124
|
+
if name not in h5_file:
|
|
125
|
+
return pl.DataFrame()
|
|
126
|
+
return pl.read_ipc(io.BytesIO(np.asarray(h5_file[name]).tobytes()))
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _to_h5(obj, file_name, obj_type="brain_data", h5_compression="gzip"):
|
|
130
|
+
"""Save BrainData or Adjacency objects to HDF5 files.
|
|
131
|
+
|
|
132
|
+
Uses h5py for both types; the `X`/`Y` frames (BrainData) and `Y` (Adjacency)
|
|
133
|
+
are stored as Arrow IPC byte datasets so every polars dtype round-trips
|
|
134
|
+
exactly. A BrainData mask is always stored by value (data + affine
|
|
135
|
+
datasets); the basename of its filename is stored alongside only when the
|
|
136
|
+
mask is file-backed, so in-memory masks serialize without one and round-trip
|
|
137
|
+
by value.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
obj (BrainData | Adjacency): Object to save.
|
|
141
|
+
file_name (str | Path): Path to save the file to.
|
|
142
|
+
obj_type (str): `'brain_data'` or `'adjacency'`.
|
|
143
|
+
h5_compression (str): Compression filter for h5py datasets, `'gzip'`
|
|
144
|
+
(default) or `'lzf'`.
|
|
145
|
+
|
|
146
|
+
Raises:
|
|
147
|
+
ValueError: If `obj_type` or `h5_compression` is not one of the
|
|
148
|
+
supported values.
|
|
149
|
+
"""
|
|
150
|
+
_require_h5()
|
|
151
|
+
_validate_compression(h5_compression)
|
|
152
|
+
if obj_type not in ["brain_data", "adjacency"]:
|
|
153
|
+
raise ValueError("obj_type must be one of 'brain_data' or 'adjacency'")
|
|
154
|
+
|
|
155
|
+
if obj_type == "brain_data":
|
|
156
|
+
with h5File(file_name, "w") as f:
|
|
157
|
+
f.create_dataset("data", data=obj.data, compression=h5_compression)
|
|
158
|
+
f.create_dataset(
|
|
159
|
+
"mask_affine", data=obj.mask.affine, compression=h5_compression
|
|
160
|
+
)
|
|
161
|
+
f.create_dataset(
|
|
162
|
+
"mask_data", data=obj.mask.get_fdata(), compression=h5_compression
|
|
163
|
+
)
|
|
164
|
+
mask_file_name = obj.mask.get_filename()
|
|
165
|
+
if mask_file_name is not None:
|
|
166
|
+
# In-memory masks have no filename; the mask still round-trips
|
|
167
|
+
# by value via the mask_data + mask_affine datasets above. Only
|
|
168
|
+
# the basename is stored: the writer's directory layout means
|
|
169
|
+
# nothing on another machine, and nothing reopens the name.
|
|
170
|
+
f.create_dataset("mask_file_name", data=_mask_basename(mask_file_name))
|
|
171
|
+
_write_polars_frame(f, "X", obj.X, h5_compression)
|
|
172
|
+
_write_polars_frame(f, "Y", obj.Y, h5_compression)
|
|
173
|
+
else:
|
|
174
|
+
with h5File(file_name, "w") as f:
|
|
175
|
+
f.create_dataset("data", data=obj.data, compression=h5_compression)
|
|
176
|
+
f.create_dataset("matrix_type", data=obj.matrix_type)
|
|
177
|
+
f.create_dataset("issymmetric", data=obj.issymmetric)
|
|
178
|
+
f.create_dataset("is_single_matrix", data=obj.is_single_matrix)
|
|
179
|
+
if obj.labels:
|
|
180
|
+
f.create_dataset(
|
|
181
|
+
"labels",
|
|
182
|
+
data=np.asarray(obj.labels, dtype=object)
|
|
183
|
+
if np.asarray(obj.labels).dtype.kind in "US"
|
|
184
|
+
else np.asarray(obj.labels),
|
|
185
|
+
dtype=h5py.string_dtype(encoding="utf-8")
|
|
186
|
+
if np.asarray(obj.labels).dtype.kind in "US"
|
|
187
|
+
else None,
|
|
188
|
+
)
|
|
189
|
+
else:
|
|
190
|
+
f.create_dataset("labels", data=np.array([], dtype="float64"))
|
|
191
|
+
_write_polars_frame(f, "Y", obj.Y, h5_compression)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _load_brain_data_h5(file_path, mask=None):
|
|
195
|
+
"""Load BrainData contents from an HDF5 file.
|
|
196
|
+
|
|
197
|
+
Reads the v0.6 layout only (`X`/`Y` as Arrow IPC byte datasets); a file
|
|
198
|
+
written by nltools 0.5.1 or earlier raises. A stored mask filename is
|
|
199
|
+
reduced to its basename — the embedded mask data and affine are
|
|
200
|
+
authoritative and the name is never reopened.
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
file_path (str | Path): Path to the HDF5 file.
|
|
204
|
+
mask (nibabel.Nifti1Image, optional): Mask to use. If None, the mask stored
|
|
205
|
+
in the file is loaded when present.
|
|
206
|
+
|
|
207
|
+
Returns:
|
|
208
|
+
dict: Keys `'data'` (np.ndarray), `'X'` and `'Y'` (pl.DataFrame),
|
|
209
|
+
`'load_mask'` (bool), and `'mask'` (nibabel.Nifti1Image) when a mask was
|
|
210
|
+
loaded from the file.
|
|
211
|
+
|
|
212
|
+
Raises:
|
|
213
|
+
ValueError: If the file was written by nltools 0.5.1 or earlier.
|
|
214
|
+
"""
|
|
215
|
+
_require_h5()
|
|
216
|
+
with h5File(file_path, "r") as f:
|
|
217
|
+
_reject_legacy_h5(f, "X_columns")
|
|
218
|
+
|
|
219
|
+
result = {}
|
|
220
|
+
result["data"] = np.array(f["data"])
|
|
221
|
+
result["X"] = _read_polars_frame(f, "X")
|
|
222
|
+
result["Y"] = _read_polars_frame(f, "Y")
|
|
223
|
+
|
|
224
|
+
if mask is None and "mask_data" in f:
|
|
225
|
+
if "mask_file_name" in f:
|
|
226
|
+
# Mask originally file-backed: keep the filename association,
|
|
227
|
+
# reduced to a basename so a file written before that rule
|
|
228
|
+
# stops reporting the writer's parent directory.
|
|
229
|
+
file_map = {
|
|
230
|
+
"image": nib.FileHolder(
|
|
231
|
+
filename=_mask_basename(f["mask_file_name"][()].decode())
|
|
232
|
+
)
|
|
233
|
+
}
|
|
234
|
+
else:
|
|
235
|
+
# Mask was in-memory at write time: reconstruct by value.
|
|
236
|
+
file_map = None
|
|
237
|
+
result["mask"] = nib.Nifti1Image(
|
|
238
|
+
np.array(f["mask_data"]),
|
|
239
|
+
affine=np.array(f["mask_affine"]),
|
|
240
|
+
file_map=file_map,
|
|
241
|
+
)
|
|
242
|
+
result["load_mask"] = True
|
|
243
|
+
else:
|
|
244
|
+
result["load_mask"] = False
|
|
245
|
+
|
|
246
|
+
return result
|
nltools/mask.py
ADDED
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
"""Utilities for creating and manipulating brain masks."""
|
|
2
|
+
|
|
3
|
+
__all__ = [
|
|
4
|
+
"collapse_mask",
|
|
5
|
+
"create_sphere",
|
|
6
|
+
"expand_mask",
|
|
7
|
+
"roi_to_brain",
|
|
8
|
+
"roi_to_brain_from_atlas",
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import nibabel as nib
|
|
13
|
+
from nltools.templates import get_brainspace
|
|
14
|
+
import numpy as np
|
|
15
|
+
from nilearn.masking import intersect_masks
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def create_sphere(coordinates, radius=5, mask=None):
|
|
19
|
+
"""Generate binary spheres in the space of a brain mask.
|
|
20
|
+
|
|
21
|
+
Spheres are drawn with `nilearn.maskers.NiftiSpheresMasker`, so centers are
|
|
22
|
+
world (MNI) millimeter coordinates and the radius is in millimeters — the same
|
|
23
|
+
convention as nilearn's `SearchLight` and `NiftiSpheresMasker`. The result is
|
|
24
|
+
resolution-independent: the same request covers the same physical volume on a
|
|
25
|
+
1 mm, 2 mm, or 3 mm grid, up to voxel quantization.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
coordinates (list): Sphere center `[x, y, z]` in world (MNI) millimeters, or
|
|
29
|
+
one center per sphere `[[x1, y1, z1], ...]`.
|
|
30
|
+
radius (int | float | list): Radius of the sphere(s) in millimeters. A scalar
|
|
31
|
+
applies to every center; a list gives one radius per center.
|
|
32
|
+
mask (nibabel.Nifti1Image | str, optional): Image (or path) defining the brain
|
|
33
|
+
space. Defaults to the package brain-space mask.
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
nibabel.Nifti1Image: A binary image with the requested spheres in mask space.
|
|
37
|
+
|
|
38
|
+
Raises:
|
|
39
|
+
ValueError: If `mask` is neither a nibabel image nor a readable file path, if
|
|
40
|
+
the radius list length does not match the coordinate list length, or if a
|
|
41
|
+
requested sphere contains no in-mask voxel.
|
|
42
|
+
|
|
43
|
+
Examples:
|
|
44
|
+
```python
|
|
45
|
+
from nltools.mask import create_sphere
|
|
46
|
+
|
|
47
|
+
# A 10 mm sphere centered on an MNI coordinate
|
|
48
|
+
roi = create_sphere([12, 10, -8], radius=10)
|
|
49
|
+
|
|
50
|
+
# Two spheres with different radii
|
|
51
|
+
rois = create_sphere([[12, 10, -8], [-12, 10, -8]], radius=[10, 6])
|
|
52
|
+
```
|
|
53
|
+
"""
|
|
54
|
+
if mask is not None:
|
|
55
|
+
if not isinstance(mask, nib.Nifti1Image):
|
|
56
|
+
if isinstance(mask, str) and os.path.isfile(mask):
|
|
57
|
+
mask = nib.load(mask)
|
|
58
|
+
else:
|
|
59
|
+
raise ValueError("mask is not a nibabel instance or a valid file name")
|
|
60
|
+
else:
|
|
61
|
+
mask = nib.load(get_brainspace().mask)
|
|
62
|
+
|
|
63
|
+
centers, radii = _resolve_sphere_requests(coordinates, radius)
|
|
64
|
+
|
|
65
|
+
volume = np.zeros(mask.shape, dtype=bool)
|
|
66
|
+
for sphere_radius in sorted(set(radii)):
|
|
67
|
+
seeds = [c for c, r in zip(centers, radii) if r == sphere_radius]
|
|
68
|
+
volume |= _draw_spheres(seeds, sphere_radius, mask)
|
|
69
|
+
|
|
70
|
+
return nib.Nifti1Image(
|
|
71
|
+
volume.astype(np.float64), affine=mask.affine, header=mask.header
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _resolve_sphere_requests(coordinates, radius):
|
|
76
|
+
"""Normalize the center/radius arguments into equal-length lists of floats."""
|
|
77
|
+
if any(isinstance(c, (list, tuple, np.ndarray)) for c in coordinates):
|
|
78
|
+
centers = [tuple(float(v) for v in c) for c in coordinates]
|
|
79
|
+
else:
|
|
80
|
+
centers = [tuple(float(v) for v in coordinates)]
|
|
81
|
+
|
|
82
|
+
if isinstance(radius, (list, tuple, np.ndarray)):
|
|
83
|
+
radii = [float(r) for r in radius]
|
|
84
|
+
if len(radii) != len(centers):
|
|
85
|
+
raise ValueError(
|
|
86
|
+
"Make sure length of radius list matches length of coordinate list."
|
|
87
|
+
)
|
|
88
|
+
else:
|
|
89
|
+
radii = [float(radius)] * len(centers)
|
|
90
|
+
|
|
91
|
+
return centers, radii
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _draw_spheres(seeds, radius, mask):
|
|
95
|
+
"""Return a boolean volume covering every in-mask voxel within `radius` mm of a seed."""
|
|
96
|
+
from nilearn.maskers import NiftiSpheresMasker
|
|
97
|
+
|
|
98
|
+
masker = NiftiSpheresMasker(
|
|
99
|
+
seeds=seeds, radius=radius, mask_img=mask, allow_overlap=True
|
|
100
|
+
)
|
|
101
|
+
try:
|
|
102
|
+
masker.fit()
|
|
103
|
+
drawn = masker.inverse_transform(np.ones((1, len(seeds))))
|
|
104
|
+
except ValueError as error:
|
|
105
|
+
# nilearn 0.14 raises "These spheres are empty: [...]" for a seed with no
|
|
106
|
+
# in-mask voxel in range. Every other ValueError from this path (a
|
|
107
|
+
# non-binary mask, a malformed signal vector) is a different problem and
|
|
108
|
+
# must keep its own diagnostic.
|
|
109
|
+
if "spheres are empty" not in str(error):
|
|
110
|
+
raise
|
|
111
|
+
raise ValueError(
|
|
112
|
+
f"No in-mask voxel lies within {radius}mm of one of the requested "
|
|
113
|
+
f"centers {seeds}; the center is outside the mask. Coordinates are "
|
|
114
|
+
"world (MNI) millimeters, not voxel indices."
|
|
115
|
+
) from error
|
|
116
|
+
|
|
117
|
+
return np.asarray(drawn.dataobj)[..., 0] > 0
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def expand_mask(mask, custom_mask=None):
|
|
121
|
+
"""Expand an integer-labeled mask into separate binary masks.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
mask (nibabel.Nifti1Image | BrainData): Integer-labeled mask.
|
|
125
|
+
custom_mask (nibabel.Nifti1Image | str, optional): Brain mask (or path) used
|
|
126
|
+
when converting a nibabel `mask` to `BrainData`.
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
BrainData: One binary mask per unique non-zero label.
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
from nltools.data import BrainData
|
|
133
|
+
|
|
134
|
+
if isinstance(mask, nib.Nifti1Image):
|
|
135
|
+
mask = BrainData(mask, mask=custom_mask)
|
|
136
|
+
if not isinstance(mask, BrainData):
|
|
137
|
+
raise ValueError("Make sure mask is a nibabel or BrainData instance.")
|
|
138
|
+
# int32, not the platform `int`: NIfTI tooling cannot carry 64-bit ints, so
|
|
139
|
+
# nilearn downcasts them (with a warning) the moment the mask is written or
|
|
140
|
+
# plotted.
|
|
141
|
+
mask.data = np.round(mask.data).astype(np.int32)
|
|
142
|
+
tmp = []
|
|
143
|
+
for i in np.unique(mask.data[mask.data != 0]):
|
|
144
|
+
tmp.append((mask.data == i).astype(np.int32))
|
|
145
|
+
out = mask.create_empty()
|
|
146
|
+
out.data = np.array(tmp)
|
|
147
|
+
return out
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def collapse_mask(mask, auto_label=True, custom_mask=None):
|
|
151
|
+
"""Collapse separate masks into one integer-labeled mask.
|
|
152
|
+
|
|
153
|
+
Overlapping areas are ignored.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
mask (nibabel.Nifti1Image | BrainData): Two or more separate masks stacked
|
|
157
|
+
along the first axis.
|
|
158
|
+
auto_label (bool): If True (default), label the collapsed regions with
|
|
159
|
+
sequential integers (1, 2, 3, …) in mask order. If False, keep each
|
|
160
|
+
mask's own values as its label.
|
|
161
|
+
custom_mask (nibabel.Nifti1Image | str, optional): Brain mask (or path) used
|
|
162
|
+
when converting a nibabel `mask` to `BrainData`.
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
BrainData: A single mask whose integer values identify the source masks.
|
|
166
|
+
|
|
167
|
+
Raises:
|
|
168
|
+
ValueError: If `mask` is neither a nibabel nor BrainData instance, or
|
|
169
|
+
if it holds fewer than 2 masks (nothing to collapse).
|
|
170
|
+
"""
|
|
171
|
+
|
|
172
|
+
from nltools.data import BrainData
|
|
173
|
+
|
|
174
|
+
if not isinstance(mask, BrainData):
|
|
175
|
+
if isinstance(mask, nib.Nifti1Image):
|
|
176
|
+
mask = BrainData(mask, mask=custom_mask)
|
|
177
|
+
else:
|
|
178
|
+
raise ValueError("Make sure mask is a nibabel or BrainData instance.")
|
|
179
|
+
|
|
180
|
+
if len(mask.shape) <= 1 or len(mask) <= 1:
|
|
181
|
+
raise ValueError(
|
|
182
|
+
"collapse_mask requires 2+ separate masks (stacked along the first "
|
|
183
|
+
"axis) to collapse into an integer-labeled mask; got a single mask."
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
out = mask.create_empty()
|
|
187
|
+
|
|
188
|
+
# Create list of masks and find any overlaps
|
|
189
|
+
m_list = []
|
|
190
|
+
for x in range(len(mask)):
|
|
191
|
+
m_list.append(mask[x].to_nifti())
|
|
192
|
+
intersect = intersect_masks(m_list, threshold=1, connected=False)
|
|
193
|
+
intersect = BrainData(
|
|
194
|
+
nib.Nifti1Image(np.abs(intersect.get_fdata() - 1), intersect.affine),
|
|
195
|
+
mask=custom_mask,
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
merge = []
|
|
199
|
+
if auto_label:
|
|
200
|
+
# Combine all masks into sequential order
|
|
201
|
+
# ignoring any areas of overlap
|
|
202
|
+
for i in range(len(m_list)):
|
|
203
|
+
merge.append(
|
|
204
|
+
np.multiply(BrainData(m_list[i], mask=custom_mask).data, intersect.data)
|
|
205
|
+
* (i + 1)
|
|
206
|
+
)
|
|
207
|
+
out.data = np.sum(np.array(merge).T, 1).astype(np.int32)
|
|
208
|
+
else:
|
|
209
|
+
# Collapse masks using value as label
|
|
210
|
+
for i in range(len(m_list)):
|
|
211
|
+
merge.append(
|
|
212
|
+
np.multiply(BrainData(m_list[i], mask=custom_mask).data, intersect.data)
|
|
213
|
+
)
|
|
214
|
+
out.data = np.sum(np.array(merge).T, 1).astype(np.int32)
|
|
215
|
+
return out
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def roi_to_brain(data, mask_x):
|
|
219
|
+
"""Populate an expanded binary ROI mask with a vector or matrix of per-ROI values.
|
|
220
|
+
|
|
221
|
+
Accepts lists, numpy arrays, polars DataFrame/Series, or pandas
|
|
222
|
+
DataFrame/Series. Internally coerces to a numpy array and operates on
|
|
223
|
+
it — 1-D input produces a single BrainData image; 2-D input (ROIs by
|
|
224
|
+
observations) produces a stack of BrainData images, one per
|
|
225
|
+
observation.
|
|
226
|
+
|
|
227
|
+
Args:
|
|
228
|
+
data (list | np.ndarray | pl.DataFrame | pl.Series | pd.DataFrame | pd.Series):
|
|
229
|
+
ROI values. 1-D length must equal `len(mask_x)`; 2-D shape must be
|
|
230
|
+
`(n_rois, n_obs)` or `(n_obs, n_rois)`.
|
|
231
|
+
mask_x (BrainData): An expanded binary mask with one row per ROI.
|
|
232
|
+
|
|
233
|
+
Returns:
|
|
234
|
+
BrainData: A BrainData instance with each ROI populated by the
|
|
235
|
+
provided value(s).
|
|
236
|
+
"""
|
|
237
|
+
import polars as pl
|
|
238
|
+
from nltools.data.braindata.utils import _result_from_array
|
|
239
|
+
|
|
240
|
+
if isinstance(data, (pl.DataFrame, pl.Series)):
|
|
241
|
+
arr = data.to_numpy()
|
|
242
|
+
elif isinstance(data, np.ndarray):
|
|
243
|
+
arr = data
|
|
244
|
+
elif isinstance(data, list):
|
|
245
|
+
arr = np.asarray(data)
|
|
246
|
+
else:
|
|
247
|
+
try:
|
|
248
|
+
import pandas as pd
|
|
249
|
+
except ImportError:
|
|
250
|
+
pd = None
|
|
251
|
+
if pd is not None and isinstance(data, (pd.Series, pd.DataFrame)):
|
|
252
|
+
arr = np.asarray(data)
|
|
253
|
+
else:
|
|
254
|
+
raise ValueError(
|
|
255
|
+
"Data must be a list, numpy array, polars DataFrame/Series, "
|
|
256
|
+
"or pandas DataFrame/Series."
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
if arr.ndim == 1:
|
|
260
|
+
if len(arr) != len(mask_x):
|
|
261
|
+
raise ValueError("Data must have the same number of rows as mask has ROIs.")
|
|
262
|
+
out = _result_from_array(
|
|
263
|
+
mask_x[0], np.zeros(mask_x.data.shape[1]), rows="clear"
|
|
264
|
+
)
|
|
265
|
+
for roi in range(len(mask_x)):
|
|
266
|
+
out.data[np.where(mask_x.data[roi, :])] = arr[roi]
|
|
267
|
+
return out
|
|
268
|
+
|
|
269
|
+
if arr.ndim == 2:
|
|
270
|
+
if arr.shape[0] != len(mask_x):
|
|
271
|
+
if arr.shape[1] == len(mask_x):
|
|
272
|
+
arr = arr.T
|
|
273
|
+
else:
|
|
274
|
+
raise ValueError(
|
|
275
|
+
"Data must have the same number of rows as rois in mask"
|
|
276
|
+
)
|
|
277
|
+
out = _result_from_array(
|
|
278
|
+
mask_x, np.zeros((arr.shape[1], mask_x.data.shape[1])), rows="clear"
|
|
279
|
+
)
|
|
280
|
+
for roi in range(len(mask_x)):
|
|
281
|
+
roi_data = arr[roi, :].reshape(-1, 1)
|
|
282
|
+
out.data[:, mask_x[roi].data == 1] = np.repeat(
|
|
283
|
+
roi_data.T, np.sum(mask_x[roi].data == 1), axis=0
|
|
284
|
+
).T
|
|
285
|
+
return out
|
|
286
|
+
|
|
287
|
+
raise NotImplementedError("Only 1-D and 2-D data are supported.")
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def roi_to_brain_from_atlas(
|
|
291
|
+
values,
|
|
292
|
+
atlas,
|
|
293
|
+
source_mask,
|
|
294
|
+
*,
|
|
295
|
+
roi_labels=None,
|
|
296
|
+
fill: float = np.nan,
|
|
297
|
+
):
|
|
298
|
+
"""Paint per-parcel values onto voxel space using a labeled atlas.
|
|
299
|
+
|
|
300
|
+
Sibling of `roi_to_brain`, but accepts a *labeled* atlas (one integer label
|
|
301
|
+
per voxel), not an expanded mask with
|
|
302
|
+
one binary row per ROI. Voxels whose atlas label is not in `roi_labels` (or
|
|
303
|
+
whose label is 0) receive `fill`.
|
|
304
|
+
|
|
305
|
+
Args:
|
|
306
|
+
values (np.ndarray): Per-parcel scalars, either 1-D `(n_parcels,)` for a
|
|
307
|
+
single image or 2-D `(n_images, n_parcels)` for a stack of images. The
|
|
308
|
+
trailing (parcel) axis must match `len(roi_labels)` (or the number
|
|
309
|
+
of unique non-zero atlas labels when `roi_labels` is None).
|
|
310
|
+
atlas (BrainData | nibabel.Nifti1Image | str | Path): Labeled image.
|
|
311
|
+
Resampled to `source_mask` (nearest-neighbor) if shapes/affines differ.
|
|
312
|
+
source_mask (nibabel.Nifti1Image | str | Path): Image (or path) defining the
|
|
313
|
+
output voxel grid. The returned `BrainData` is masked to this image.
|
|
314
|
+
roi_labels (array-like, optional): Integer atlas IDs in the same order as
|
|
315
|
+
`values`. If None, defaults to `np.unique` of the atlas with 0 stripped
|
|
316
|
+
(sorted ascending).
|
|
317
|
+
fill (float): Value for voxels not in any provided ROI. Default `np.nan`.
|
|
318
|
+
|
|
319
|
+
Returns:
|
|
320
|
+
BrainData: Masked to `source_mask`, with each in-atlas voxel set to its
|
|
321
|
+
parcel's scalar from `values`. Holds a single image when `values` is
|
|
322
|
+
1-D, or `n_images` images when `values` is 2-D `(n_images, n_parcels)`.
|
|
323
|
+
|
|
324
|
+
Examples:
|
|
325
|
+
```python
|
|
326
|
+
from nltools.mask import roi_to_brain_from_atlas
|
|
327
|
+
|
|
328
|
+
brain_map = roi_to_brain_from_atlas(
|
|
329
|
+
values=accuracies,
|
|
330
|
+
atlas=atlas_img,
|
|
331
|
+
source_mask=brain_mask,
|
|
332
|
+
roi_labels=[1, 2, 3],
|
|
333
|
+
)
|
|
334
|
+
```
|
|
335
|
+
"""
|
|
336
|
+
from pathlib import Path
|
|
337
|
+
|
|
338
|
+
from nilearn.image import resample_to_img
|
|
339
|
+
from nilearn.masking import apply_mask as nilearn_apply_mask
|
|
340
|
+
|
|
341
|
+
from nltools.data import BrainData
|
|
342
|
+
|
|
343
|
+
arr = np.asarray(values)
|
|
344
|
+
if arr.ndim not in (1, 2):
|
|
345
|
+
raise ValueError(
|
|
346
|
+
f"values must be 1-D ``(n_parcels,)`` or 2-D ``(n_images, n_parcels)``; "
|
|
347
|
+
f"got shape {arr.shape}"
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
# Coerce atlas + source_mask to nibabel images
|
|
351
|
+
if isinstance(atlas, BrainData):
|
|
352
|
+
atlas_img = atlas.to_nifti()
|
|
353
|
+
elif isinstance(atlas, (str, Path)):
|
|
354
|
+
atlas_img = nib.load(str(atlas))
|
|
355
|
+
else:
|
|
356
|
+
atlas_img = atlas
|
|
357
|
+
|
|
358
|
+
if isinstance(source_mask, (str, Path)):
|
|
359
|
+
mask_img = nib.load(str(source_mask))
|
|
360
|
+
else:
|
|
361
|
+
mask_img = source_mask
|
|
362
|
+
|
|
363
|
+
# Resample atlas to mask space if needed (nearest-neighbor for labels)
|
|
364
|
+
if atlas_img.shape != mask_img.shape or not np.allclose(
|
|
365
|
+
atlas_img.affine, mask_img.affine
|
|
366
|
+
):
|
|
367
|
+
atlas_img = resample_to_img(
|
|
368
|
+
atlas_img,
|
|
369
|
+
mask_img,
|
|
370
|
+
interpolation="nearest",
|
|
371
|
+
force_resample=True,
|
|
372
|
+
copy_header=True,
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
# Per-mask-voxel atlas labels — same length as the BrainData voxel axis.
|
|
376
|
+
label_vec = nilearn_apply_mask(atlas_img, mask_img).astype(np.int64)
|
|
377
|
+
|
|
378
|
+
if roi_labels is None:
|
|
379
|
+
unique_labels = np.unique(label_vec)
|
|
380
|
+
unique_labels = unique_labels[unique_labels != 0]
|
|
381
|
+
else:
|
|
382
|
+
unique_labels = np.asarray(roi_labels)
|
|
383
|
+
|
|
384
|
+
n_parcels_axis = arr.shape[-1] if arr.ndim == 2 else arr.shape[0]
|
|
385
|
+
if n_parcels_axis != len(unique_labels):
|
|
386
|
+
raise ValueError(
|
|
387
|
+
f"values trailing axis ({n_parcels_axis}) must match number of "
|
|
388
|
+
f"ROI labels ({len(unique_labels)})."
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
if arr.ndim == 1:
|
|
392
|
+
out_arr = np.full(label_vec.shape, fill, dtype=float)
|
|
393
|
+
for label, value in zip(unique_labels, arr):
|
|
394
|
+
out_arr[label_vec == label] = value
|
|
395
|
+
return BrainData(out_arr.reshape(1, -1), mask=mask_img)
|
|
396
|
+
|
|
397
|
+
# 2-D case: shape (n_images, n_parcels) → (n_images, n_voxels) BrainData.
|
|
398
|
+
n_images = arr.shape[0]
|
|
399
|
+
out_arr = np.full((n_images, label_vec.shape[0]), fill, dtype=float)
|
|
400
|
+
for col, label in enumerate(unique_labels):
|
|
401
|
+
cols = label_vec == label
|
|
402
|
+
out_arr[:, cols] = arr[:, col : col + 1]
|
|
403
|
+
return BrainData(out_arr, mask=mask_img)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Model classes for neuroimaging analysis.
|
|
3
|
+
|
|
4
|
+
Provides sklearn-compatible APIs for common neuroimaging analyses.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
# Internal package: these imports are re-exports for the rest of nltools, not
|
|
8
|
+
# an advertised surface, so there is no `__all__` to mark them as used.
|
|
9
|
+
from .results import ContrastResult # noqa: F401
|
|
10
|
+
from .ridge import _Ridge # noqa: F401
|
|
11
|
+
from .glm import _Glm # noqa: F401
|