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