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,348 @@
|
|
|
1
|
+
"""Shared helpers for BrainData submodules.
|
|
2
|
+
|
|
3
|
+
These are internal utilities used by the facade and submodules — not part of the
|
|
4
|
+
public API.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import gc
|
|
8
|
+
import os
|
|
9
|
+
from contextlib import contextmanager
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
from numpy.typing import ArrayLike
|
|
13
|
+
|
|
14
|
+
from ..ownership import _copy_graph
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@contextmanager
|
|
18
|
+
def _coalesced_gc():
|
|
19
|
+
"""Collapse nilearn's forced per-copy `gc.collect()` calls into one per operation.
|
|
20
|
+
|
|
21
|
+
nilearn runs a full `gc.collect()` after every masked-array copy it makes; a
|
|
22
|
+
masking-heavy operation — a GLM fit that re-validates the same mask and
|
|
23
|
+
builds several result maps — fires dozens. With torch/nilearn/sklearn
|
|
24
|
+
resident each sweep costs ~0.1s, so the storm dominates the wall-clock of
|
|
25
|
+
otherwise-trivial numerical work.
|
|
26
|
+
|
|
27
|
+
This no-ops the interim collects and runs a single real collect on exit,
|
|
28
|
+
so peak memory stays bounded to one operation's worth of cyclic garbage
|
|
29
|
+
(nilearn's collect is a peak-memory optimization, not a correctness
|
|
30
|
+
requirement — suppressing it only defers reclamation). Opt out with
|
|
31
|
+
`NLTOOLS_NO_GC_COALESCE=1`.
|
|
32
|
+
|
|
33
|
+
Because `@contextmanager` results double as decorators, this can also be
|
|
34
|
+
used as `@_coalesced_gc()` on an operation-boundary method.
|
|
35
|
+
|
|
36
|
+
Nesting is safe: each frame restores whatever it saved, so only the
|
|
37
|
+
outermost frame restores the real `gc.collect` and runs the final sweep;
|
|
38
|
+
inner frames' exit-time collect is a no-op.
|
|
39
|
+
|
|
40
|
+
Caveat: this swaps a process-global builtin. It is safe under the default
|
|
41
|
+
loky (process) worker backend — each worker has its own `gc`. Under a
|
|
42
|
+
*threading* backend there is a brief window where a concurrent thread sees
|
|
43
|
+
the no-op collect; `NLTOOLS_NO_GC_COALESCE=1` is the escape hatch there.
|
|
44
|
+
"""
|
|
45
|
+
if os.environ.get("NLTOOLS_NO_GC_COALESCE"):
|
|
46
|
+
yield
|
|
47
|
+
return
|
|
48
|
+
saved = gc.collect # may already be the no-op if we're nested
|
|
49
|
+
gc.collect = lambda *a, **k: 0
|
|
50
|
+
try:
|
|
51
|
+
yield
|
|
52
|
+
finally:
|
|
53
|
+
gc.collect = saved # only the outermost frame restores the real collect
|
|
54
|
+
gc.collect() # no-op if still nested; one real sweep at the top
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _resolve_threshold(value: float | str | None, data: ArrayLike) -> float | None:
|
|
58
|
+
"""Resolve a threshold spec — a number or a percentile string — to a float.
|
|
59
|
+
|
|
60
|
+
The single source of truth for what `"98%"` means across the library
|
|
61
|
+
(`BrainData.threshold` and `BrainData.iplot` both route through it).
|
|
62
|
+
Numbers and None pass through unchanged. A percentile string is resolved
|
|
63
|
+
against the **finite nonzero** values of `data`: on a masked stat map
|
|
64
|
+
most voxels are exactly zero (absence of data), and including them drags
|
|
65
|
+
every percentile toward zero.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
value: A number (returned as-is), None (returned as-is), or a string
|
|
69
|
+
like `"98%"`.
|
|
70
|
+
data: Array-like the percentile is computed over. Callers choose the
|
|
71
|
+
frame of reference — e.g. `iplot` passes magnitudes
|
|
72
|
+
(`np.abs(data)`) because its window is a magnitude window, while
|
|
73
|
+
`threshold` passes signed values.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
float | None: The resolved threshold.
|
|
77
|
+
|
|
78
|
+
Raises:
|
|
79
|
+
ValueError: If `value` is a string without a trailing `%`.
|
|
80
|
+
"""
|
|
81
|
+
if value is None or not isinstance(value, str):
|
|
82
|
+
return value
|
|
83
|
+
if not value.endswith("%"):
|
|
84
|
+
raise ValueError(
|
|
85
|
+
f"string threshold must be a percentile like '98%', got {value!r}"
|
|
86
|
+
)
|
|
87
|
+
pct = float(value[:-1])
|
|
88
|
+
vals = np.asarray(data, dtype=float).ravel()
|
|
89
|
+
vals = vals[np.isfinite(vals)]
|
|
90
|
+
vals = vals[vals != 0]
|
|
91
|
+
if vals.size == 0:
|
|
92
|
+
return 0.0
|
|
93
|
+
return float(np.percentile(vals, pct))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _is_default(value, default):
|
|
97
|
+
"""Report whether a `fit` or `predict` option still holds its signature default.
|
|
98
|
+
|
|
99
|
+
The check rejects non-default *values*, not the act of passing a keyword:
|
|
100
|
+
an option explicitly given its own default is indistinguishable from an
|
|
101
|
+
untouched one and is treated as untouched. Array-like options
|
|
102
|
+
(`ridge_alpha`, `ridge_dirichlet_concentration`) make a bare `!=` return an
|
|
103
|
+
array, so equality is compared elementwise, and a sequence given as a list
|
|
104
|
+
matches a tuple default.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
value: The supplied option value.
|
|
108
|
+
default: The signature default.
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
bool: True when the option still holds its default value.
|
|
112
|
+
"""
|
|
113
|
+
if value is default:
|
|
114
|
+
return True
|
|
115
|
+
if isinstance(value, bool) != isinstance(default, bool):
|
|
116
|
+
# `0` is not `False`: a flag given an integer was supplied deliberately.
|
|
117
|
+
return False
|
|
118
|
+
if np.ndim(value) != np.ndim(default):
|
|
119
|
+
return False
|
|
120
|
+
return bool(np.array_equal(value, default))
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _check_brain_data(data, mask=None):
|
|
124
|
+
"""Return *data* as a BrainData, coercing Niimg-like inputs if needed.
|
|
125
|
+
|
|
126
|
+
If *data* is already a BrainData, the optional *mask* is applied via
|
|
127
|
+
`BrainData.apply_mask`. Otherwise *data* is passed through
|
|
128
|
+
`BrainData`, which dispatches on type (file path, list of paths,
|
|
129
|
+
URL, h5, ``nib.Nifti1Image``, numpy array). Unsupported types raise
|
|
130
|
+
``TypeError`` from
|
|
131
|
+
`_validate_data_type`.
|
|
132
|
+
"""
|
|
133
|
+
from . import BrainData
|
|
134
|
+
|
|
135
|
+
if isinstance(data, BrainData):
|
|
136
|
+
if mask is not None:
|
|
137
|
+
data = data.apply_mask(mask)
|
|
138
|
+
return data
|
|
139
|
+
return BrainData(data, mask=mask)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _check_brain_data_is_single(data):
|
|
143
|
+
"""Logical test if BrainData instance is a single image.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
data (BrainData | Nifti1Image | str): Data to test; non-BrainData
|
|
147
|
+
inputs are coerced first.
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
bool: True if the data holds a single image.
|
|
151
|
+
"""
|
|
152
|
+
data = _check_brain_data(data)
|
|
153
|
+
return len(data.shape) <= 1
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
#: Every attribute a fit may attach to a BrainData. The enumeration is
|
|
157
|
+
#: exhaustive: clearing fitted state deletes exactly these names, with no
|
|
158
|
+
#: predicates and no special cases. `predict` attaches nothing, so it
|
|
159
|
+
#: contributes no names.
|
|
160
|
+
_FIT_STATE_ATTRIBUTES = (
|
|
161
|
+
"model_",
|
|
162
|
+
"ridge_weights",
|
|
163
|
+
"ridge_fitted_values",
|
|
164
|
+
"ridge_r2",
|
|
165
|
+
"glm_betas",
|
|
166
|
+
"glm_residual",
|
|
167
|
+
"glm_predicted",
|
|
168
|
+
"glm_r2",
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _clear_fit_state(bd):
|
|
173
|
+
"""Remove state invalidated by changing a BrainData object's data."""
|
|
174
|
+
for name in _FIT_STATE_ATTRIBUTES:
|
|
175
|
+
if hasattr(bd, name):
|
|
176
|
+
delattr(bd, name)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _copy_for_fit(source):
|
|
180
|
+
"""Copy retained state without traversing obsolete fitted attributes."""
|
|
181
|
+
return _copy_graph(source, exclude=_FIT_STATE_ATTRIBUTES)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _row_values(data, X, Y):
|
|
185
|
+
"""Validate the complete replacement row state before graph construction."""
|
|
186
|
+
from ..validation import _validate_frame
|
|
187
|
+
|
|
188
|
+
data = np.asarray(data)
|
|
189
|
+
count = 0 if data.size == 0 else (1 if data.ndim == 1 else data.shape[0])
|
|
190
|
+
X, Y = _validate_frame(X, frame_type="X"), _validate_frame(Y, frame_type="Y")
|
|
191
|
+
for name, frame in (("X", X), ("Y", Y)):
|
|
192
|
+
if not frame.is_empty() and frame.height != count:
|
|
193
|
+
raise ValueError(
|
|
194
|
+
f"{name} has {frame.height} rows but result data has {count} rows"
|
|
195
|
+
)
|
|
196
|
+
return {"data": data, "_X": X, "_Y": Y}
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _result_from_rows(source, data, *, X, Y):
|
|
200
|
+
"""Construct independently owned data with complete replacement row metadata."""
|
|
201
|
+
return _copy_graph(
|
|
202
|
+
source, exclude=_FIT_STATE_ATTRIBUTES, replacements=_row_values(data, X, Y)
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _result_from_array(source, data, *, rows):
|
|
207
|
+
"""Construct a result with an explicit observation-row policy."""
|
|
208
|
+
if rows not in ("preserve", "clear"):
|
|
209
|
+
raise ValueError("rows must be 'preserve' or 'clear'")
|
|
210
|
+
return _result_from_rows(
|
|
211
|
+
source,
|
|
212
|
+
data,
|
|
213
|
+
X=source.X if rows == "preserve" else None,
|
|
214
|
+
Y=source.Y if rows == "preserve" else None,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _result_from_selection(source, index):
|
|
219
|
+
"""Apply one observation selection to data and both metadata frames."""
|
|
220
|
+
if not isinstance(index, (int, np.integer, slice)):
|
|
221
|
+
index = np.asarray(index).flatten()
|
|
222
|
+
data = source.data[index, :]
|
|
223
|
+
if not isinstance(index, slice) and data.ndim == 2 and data.shape[0] == 1:
|
|
224
|
+
data = data[0]
|
|
225
|
+
return _result_from_rows(
|
|
226
|
+
source,
|
|
227
|
+
data,
|
|
228
|
+
X=_polars_row_select(source.X, index),
|
|
229
|
+
Y=_polars_row_select(source.Y, index),
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _result_with_mask(source, data, mask, *, rows):
|
|
234
|
+
"""Install independent replacement spatial state for a changed voxel axis."""
|
|
235
|
+
from .io import _initialize_mask
|
|
236
|
+
|
|
237
|
+
data = np.asarray(data)
|
|
238
|
+
if data.size and data.shape[-1] != int(np.count_nonzero(mask.get_fdata() > 0)):
|
|
239
|
+
raise ValueError("Result voxel axis must match replacement mask support")
|
|
240
|
+
if rows not in ("preserve", "clear"):
|
|
241
|
+
raise ValueError("rows must be 'preserve' or 'clear'")
|
|
242
|
+
values = _row_values(
|
|
243
|
+
data,
|
|
244
|
+
source.X if rows == "preserve" else None,
|
|
245
|
+
source.Y if rows == "preserve" else None,
|
|
246
|
+
)
|
|
247
|
+
values.update({"mask": mask, "masker": None, "_labels": None})
|
|
248
|
+
result = _copy_graph(source, exclude=_FIT_STATE_ATTRIBUTES, replacements=values)
|
|
249
|
+
_initialize_mask(result, result.mask)
|
|
250
|
+
return result
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _perform_arithmetic(
|
|
254
|
+
bd, other, operation, operation_name, inplace=False, reverse=False
|
|
255
|
+
):
|
|
256
|
+
"""Perform an arithmetic operation with validation.
|
|
257
|
+
|
|
258
|
+
Args:
|
|
259
|
+
bd (BrainData): Left operand unless ``reverse`` is True.
|
|
260
|
+
other (float | BrainData | np.ndarray): The other operand.
|
|
261
|
+
operation (Callable): NumPy ufunc (e.g. ``np.add``, ``np.subtract``).
|
|
262
|
+
operation_name (str): Human-readable name for error messages.
|
|
263
|
+
inplace (bool): If True, mutate ``bd`` in place.
|
|
264
|
+
reverse (bool): If True, reverse operand order (for ``__rsub__`` etc.).
|
|
265
|
+
|
|
266
|
+
Returns:
|
|
267
|
+
BrainData: Result of the operation.
|
|
268
|
+
"""
|
|
269
|
+
from .validation import _validate_arithmetic_operand, _validate_brain_data_shapes
|
|
270
|
+
|
|
271
|
+
operand_type = _validate_arithmetic_operand(other, operation_name)
|
|
272
|
+
|
|
273
|
+
if operand_type == "scalar":
|
|
274
|
+
if reverse:
|
|
275
|
+
result_data = operation(other, bd.data)
|
|
276
|
+
else:
|
|
277
|
+
result_data = operation(bd.data, other)
|
|
278
|
+
elif operand_type == "brain_data":
|
|
279
|
+
_validate_brain_data_shapes(bd, other, operation_name)
|
|
280
|
+
if reverse:
|
|
281
|
+
result_data = operation(other.data, bd.data)
|
|
282
|
+
else:
|
|
283
|
+
result_data = operation(bd.data, other.data)
|
|
284
|
+
elif operand_type == "array":
|
|
285
|
+
if len(other) != len(bd):
|
|
286
|
+
raise ValueError(
|
|
287
|
+
f"Vector {operation_name} requires that the length of the vector "
|
|
288
|
+
f"({len(other)}) match the number of images ({len(bd)})"
|
|
289
|
+
)
|
|
290
|
+
result_data = np.dot(bd.data.T, other).T
|
|
291
|
+
|
|
292
|
+
if inplace:
|
|
293
|
+
_clear_fit_state(bd)
|
|
294
|
+
bd.data = result_data
|
|
295
|
+
if operand_type == "array":
|
|
296
|
+
bd.X = None
|
|
297
|
+
bd.Y = None
|
|
298
|
+
return bd
|
|
299
|
+
return _result_from_array(
|
|
300
|
+
bd, result_data, rows="clear" if operand_type == "array" else "preserve"
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _apply_func(bd, stat_func, axis=0):
|
|
305
|
+
"""Apply a statistical function to BrainData's ``.data`` attribute.
|
|
306
|
+
|
|
307
|
+
If *axis* is 0, returns a BrainData with the statistic computed across
|
|
308
|
+
samples (e.g. within a voxel over time). If *axis* is 1, returns a numpy
|
|
309
|
+
array with the statistic computed across features (e.g. across voxels
|
|
310
|
+
within a single time-point).
|
|
311
|
+
|
|
312
|
+
Args:
|
|
313
|
+
bd (BrainData): Data to reduce.
|
|
314
|
+
stat_func (Callable): Accepts an array and an ``axis`` kwarg.
|
|
315
|
+
axis (int): ``0`` = across images, ``1`` = within images.
|
|
316
|
+
|
|
317
|
+
Returns:
|
|
318
|
+
float | np.ndarray | BrainData: The reduced result; type depends on
|
|
319
|
+
whether the input is a single image and on ``axis``.
|
|
320
|
+
"""
|
|
321
|
+
if _check_brain_data_is_single(bd):
|
|
322
|
+
return stat_func(bd.data)
|
|
323
|
+
|
|
324
|
+
if axis == 1:
|
|
325
|
+
return stat_func(bd.data, axis=1)
|
|
326
|
+
if axis == 0:
|
|
327
|
+
return _result_from_array(bd, stat_func(bd.data, axis=0), rows="clear")
|
|
328
|
+
raise ValueError("axis must be 0 or 1")
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _polars_row_select(df, index):
|
|
332
|
+
"""Row-select a polars DataFrame by int / slice / int-array index.
|
|
333
|
+
|
|
334
|
+
Polars has no ``.iloc`` — this helper normalizes the three index
|
|
335
|
+
shapes BrainData's ``__getitem__`` hands it (pandas parity).
|
|
336
|
+
"""
|
|
337
|
+
import polars as pl
|
|
338
|
+
|
|
339
|
+
if df.is_empty():
|
|
340
|
+
return df
|
|
341
|
+
if isinstance(index, (int, np.integer)):
|
|
342
|
+
return df.slice(int(index), 1)
|
|
343
|
+
if isinstance(index, slice):
|
|
344
|
+
return df[index]
|
|
345
|
+
idx = np.asarray(index).flatten()
|
|
346
|
+
if idx.dtype == bool:
|
|
347
|
+
return df.filter(pl.Series(idx))
|
|
348
|
+
return df[idx.tolist()]
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""Input validation for `BrainData`.
|
|
2
|
+
|
|
3
|
+
Helpers that validate constructor inputs, array shapes, and operand
|
|
4
|
+
compatibility between `BrainData` objects and other data types.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import nibabel as nib
|
|
10
|
+
import numpy as np
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _validate_brain_data_shapes(brain1, brain2, operation="operation"):
|
|
14
|
+
"""Validate shape compatibility between two BrainData objects.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
brain1 (BrainData): First operand.
|
|
18
|
+
brain2 (BrainData): Second operand.
|
|
19
|
+
operation (str): Name of the operation for error messages.
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
tuple[bool, bool]: ``(brain1_is_single, brain2_is_single)``.
|
|
23
|
+
|
|
24
|
+
Raises:
|
|
25
|
+
ValueError: If shapes are incompatible for the operation.
|
|
26
|
+
"""
|
|
27
|
+
shape1, shape2 = brain1.shape, brain2.shape
|
|
28
|
+
brain1_is_single = len(shape1) == 1
|
|
29
|
+
brain2_is_single = len(shape2) == 1
|
|
30
|
+
|
|
31
|
+
if brain1_is_single and brain2_is_single:
|
|
32
|
+
if shape1[0] != shape2[0]:
|
|
33
|
+
raise ValueError(
|
|
34
|
+
f"Cannot {operation}: both images must have the same number of voxels. "
|
|
35
|
+
f"Image 1 has {shape1[0]} voxels, Image 2 has {shape2[0]} voxels."
|
|
36
|
+
)
|
|
37
|
+
elif brain1_is_single and not brain2_is_single:
|
|
38
|
+
raise ValueError(
|
|
39
|
+
f"Cannot {operation} multiple images to a single image. "
|
|
40
|
+
f"Image 1 is single, Image 2 has {shape2[0]} images."
|
|
41
|
+
)
|
|
42
|
+
elif not brain1_is_single and brain2_is_single:
|
|
43
|
+
if shape1[1] != shape2[0]:
|
|
44
|
+
raise ValueError(
|
|
45
|
+
f"Cannot {operation}: number of voxels must match. "
|
|
46
|
+
f"Image 1 has {shape1[1]} voxels, Image 2 has {shape2[0]} voxels."
|
|
47
|
+
)
|
|
48
|
+
elif not brain1_is_single and not brain2_is_single:
|
|
49
|
+
if shape1[0] != shape2[0] or shape1[1] != shape2[1]:
|
|
50
|
+
raise ValueError(
|
|
51
|
+
f"Cannot {operation} multiple images of different shapes. "
|
|
52
|
+
f"Image 1 shape: {shape1}, Image 2 shape: {shape2}"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
return brain1_is_single, brain2_is_single
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _validate_arithmetic_operand(other, operation_name):
|
|
59
|
+
"""Validate operand type for arithmetic operations.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
other (object): The operand to validate.
|
|
63
|
+
operation_name (str): Name of the operation (e.g. ``'add'``,
|
|
64
|
+
``'multiply'``).
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
str: Type of operand ('scalar', 'brain_data', or 'array').
|
|
68
|
+
|
|
69
|
+
Raises:
|
|
70
|
+
ValueError: If operand type is not supported.
|
|
71
|
+
"""
|
|
72
|
+
# Import here to avoid circular imports
|
|
73
|
+
from nltools.data import BrainData
|
|
74
|
+
|
|
75
|
+
if isinstance(other, (int, np.integer, float, np.floating)):
|
|
76
|
+
return "scalar"
|
|
77
|
+
if isinstance(other, BrainData):
|
|
78
|
+
return "brain_data"
|
|
79
|
+
if isinstance(other, (list, np.ndarray)) and operation_name == "multiply":
|
|
80
|
+
return "array"
|
|
81
|
+
valid_types = "int, float, or BrainData"
|
|
82
|
+
if operation_name == "multiply":
|
|
83
|
+
valid_types = "int, float, list, np.ndarray, or BrainData"
|
|
84
|
+
raise ValueError(
|
|
85
|
+
f"Cannot {operation_name} with type {type(other).__name__}. "
|
|
86
|
+
f"Operand must be {valid_types}."
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _validate_data_type(data):
|
|
91
|
+
"""Validate input data type for BrainData initialization.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
data (object): Constructor input to classify.
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
str: One of ``'brain_data'``, ``'list'``, ``'h5'``, ``'url'``,
|
|
98
|
+
``'file'``, ``'nibabel'``, ``'array'``, or ``'none'``.
|
|
99
|
+
|
|
100
|
+
Raises:
|
|
101
|
+
TypeError: If data type is not supported.
|
|
102
|
+
"""
|
|
103
|
+
# Import here to avoid circular imports
|
|
104
|
+
from nltools.data import BrainData
|
|
105
|
+
|
|
106
|
+
if data is None:
|
|
107
|
+
return "none"
|
|
108
|
+
if isinstance(data, BrainData):
|
|
109
|
+
return "brain_data"
|
|
110
|
+
if isinstance(data, list):
|
|
111
|
+
return "list"
|
|
112
|
+
if isinstance(data, (str, Path)):
|
|
113
|
+
from nltools.io.h5 import _is_h5_path
|
|
114
|
+
|
|
115
|
+
data_str = str(data)
|
|
116
|
+
if _is_h5_path(data_str):
|
|
117
|
+
return "h5"
|
|
118
|
+
if "://" in data_str:
|
|
119
|
+
return "url"
|
|
120
|
+
return "file"
|
|
121
|
+
if isinstance(data, nib.Nifti1Image):
|
|
122
|
+
return "nibabel"
|
|
123
|
+
if isinstance(data, np.ndarray):
|
|
124
|
+
return "array"
|
|
125
|
+
raise TypeError(
|
|
126
|
+
f"Data must be a BrainData, filepath (str/Path), nibabel image, "
|
|
127
|
+
f"numpy array, or list of these types. Received {type(data).__name__}"
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _validate_list_data(data_list):
|
|
132
|
+
"""Validate that all items in a list are the same type.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
data_list (list): Items to validate.
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
str: ``'brain_data'`` or ``'file'``.
|
|
139
|
+
|
|
140
|
+
Raises:
|
|
141
|
+
ValueError: If list contains mixed types or unsupported types.
|
|
142
|
+
"""
|
|
143
|
+
if not data_list:
|
|
144
|
+
raise ValueError("List is empty")
|
|
145
|
+
|
|
146
|
+
# Import here to avoid circular imports
|
|
147
|
+
from nltools.data import BrainData
|
|
148
|
+
|
|
149
|
+
first_type = type(data_list[0])
|
|
150
|
+
|
|
151
|
+
# Check if all items are the same type
|
|
152
|
+
if not all(isinstance(x, first_type) for x in data_list):
|
|
153
|
+
raise ValueError(
|
|
154
|
+
"All items in the list must be the same type. "
|
|
155
|
+
"Found mixed types in the list."
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
# Determine what type we're dealing with
|
|
159
|
+
if isinstance(data_list[0], BrainData):
|
|
160
|
+
return "brain_data"
|
|
161
|
+
if isinstance(data_list[0], (str, Path, nib.Nifti1Image)):
|
|
162
|
+
return "file"
|
|
163
|
+
raise ValueError(
|
|
164
|
+
f"List items must be BrainData objects, file paths, or nibabel images. "
|
|
165
|
+
f"Found {first_type.__name__}"
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _validate_append_shapes(data1_shape, data2_shape):
|
|
170
|
+
"""Validate shape compatibility for appending BrainData objects.
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
data1_shape (tuple[int, ...]): Shape of the first BrainData.
|
|
174
|
+
data2_shape (tuple[int, ...]): Shape of the BrainData being appended.
|
|
175
|
+
|
|
176
|
+
Raises:
|
|
177
|
+
ValueError: If shapes are incompatible for appending.
|
|
178
|
+
"""
|
|
179
|
+
data1_is_single = len(data1_shape) == 1
|
|
180
|
+
data2_is_single = len(data2_shape) == 1
|
|
181
|
+
|
|
182
|
+
error_msg = (
|
|
183
|
+
f"Cannot append: incompatible number of voxels. "
|
|
184
|
+
f"Data 1 shape: {data1_shape}, Data 2 shape: {data2_shape}"
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
if data1_is_single and data2_is_single:
|
|
188
|
+
if data1_shape[0] != data2_shape[0]:
|
|
189
|
+
raise ValueError(error_msg)
|
|
190
|
+
elif data1_is_single and not data2_is_single:
|
|
191
|
+
if data1_shape[0] != data2_shape[1]:
|
|
192
|
+
raise ValueError(error_msg)
|
|
193
|
+
elif not data1_is_single and data2_is_single:
|
|
194
|
+
if data1_shape[1] != data2_shape[0]:
|
|
195
|
+
raise ValueError(error_msg)
|
|
196
|
+
elif data1_shape[1] != data2_shape[1]:
|
|
197
|
+
raise ValueError(error_msg)
|