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,456 @@
|
|
|
1
|
+
"""Cluster reports — peak/cluster geometry plus atlas labels.
|
|
2
|
+
|
|
3
|
+
The peak/sub-peak geometry comes from `get_clusters_table`;
|
|
4
|
+
the cluster masks and mass-weighted labels are computed locally so we can
|
|
5
|
+
attribute every voxel of every cluster to one or more atlases.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from collections.abc import Sequence
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
import nibabel as nb
|
|
14
|
+
import nibabel.affines as nb_affines
|
|
15
|
+
import numpy as np
|
|
16
|
+
import polars as pl
|
|
17
|
+
from scipy import ndimage
|
|
18
|
+
|
|
19
|
+
from nltools.utils import _HORIZONTAL_CONCAT
|
|
20
|
+
|
|
21
|
+
from .labeling import _clip_to_box, _label_lookup, _xyz_to_ijk, label_coords
|
|
22
|
+
from .loading import _Atlas, load_atlas
|
|
23
|
+
from .registry import DEFAULT_ATLASES
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from matplotlib.figure import Figure
|
|
27
|
+
|
|
28
|
+
from nltools.data import BrainData
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class _ClusterReport:
|
|
33
|
+
"""Result of `BrainData.cluster_report`.
|
|
34
|
+
|
|
35
|
+
Attributes:
|
|
36
|
+
peaks (pl.DataFrame): One row per peak (incl. sub-peaks). Columns
|
|
37
|
+
`cluster_id`, `x`, `y`, `z` (mm), `peak_stat`, `volume_mm3`,
|
|
38
|
+
`n_voxels`, then one Utf8 column per atlas. `cluster_id` shares the
|
|
39
|
+
integer id space of `clusters` (they are joinable); sub-peaks carry
|
|
40
|
+
their parent cluster's id.
|
|
41
|
+
clusters (pl.DataFrame): One row per cluster. Columns `cluster_id`,
|
|
42
|
+
`peak_x`, `peak_y`, `peak_z`, `mean_stat`, `volume_mm3`, `n_voxels`,
|
|
43
|
+
then one Utf8 column per atlas (mass-weighted top regions).
|
|
44
|
+
stat_img (BrainData): The thresholded stat map (sub-threshold voxels and
|
|
45
|
+
clusters smaller than `cluster_threshold` zeroed).
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
peaks: pl.DataFrame
|
|
49
|
+
clusters: pl.DataFrame
|
|
50
|
+
stat_img: "BrainData"
|
|
51
|
+
|
|
52
|
+
def to_csv(self, output_dir: str | Path) -> None:
|
|
53
|
+
"""Write `peaks.csv` and `clusters.csv` into `output_dir`.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
output_dir (str | Path): Directory to write into (created if missing).
|
|
57
|
+
"""
|
|
58
|
+
output_dir = Path(output_dir)
|
|
59
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
60
|
+
self.peaks.write_csv(output_dir / "peaks.csv")
|
|
61
|
+
self.clusters.write_csv(output_dir / "clusters.csv")
|
|
62
|
+
|
|
63
|
+
def plot(
|
|
64
|
+
self,
|
|
65
|
+
*,
|
|
66
|
+
output_dir: str | Path | None = None,
|
|
67
|
+
) -> list[tuple[str, "Figure"]] | None:
|
|
68
|
+
"""Render an overview glass brain + one slice figure per cluster.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
output_dir (str | Path, optional): If given, save `overview.png` and
|
|
72
|
+
`cluster_NN.png` files into the directory and return None. If
|
|
73
|
+
omitted, return the figures without writing to disk.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
list[tuple[str, matplotlib.figure.Figure]] | None: `(label, figure)`
|
|
77
|
+
tuples, or None when `output_dir` is set.
|
|
78
|
+
"""
|
|
79
|
+
from matplotlib import pyplot as plt
|
|
80
|
+
from nilearn.plotting import plot_glass_brain, plot_stat_map
|
|
81
|
+
|
|
82
|
+
thr_img = self.stat_img.to_nifti()
|
|
83
|
+
figures: list[tuple[str, Figure]] = []
|
|
84
|
+
|
|
85
|
+
fig_overview = plt.figure(figsize=(10, 4))
|
|
86
|
+
plot_glass_brain(
|
|
87
|
+
thr_img,
|
|
88
|
+
figure=fig_overview,
|
|
89
|
+
display_mode="lyrz",
|
|
90
|
+
colorbar=True,
|
|
91
|
+
plot_abs=False,
|
|
92
|
+
)
|
|
93
|
+
figures.append(("overview", fig_overview))
|
|
94
|
+
|
|
95
|
+
for row in self.clusters.iter_rows(named=True):
|
|
96
|
+
cid = int(row["cluster_id"])
|
|
97
|
+
cut = (row["peak_x"], row["peak_y"], row["peak_z"])
|
|
98
|
+
fig = plt.figure(figsize=(10, 3))
|
|
99
|
+
plot_stat_map(
|
|
100
|
+
thr_img,
|
|
101
|
+
figure=fig,
|
|
102
|
+
cut_coords=cut,
|
|
103
|
+
display_mode="ortho",
|
|
104
|
+
colorbar=True,
|
|
105
|
+
title=f"Cluster {cid}: peak ({cut[0]:.0f}, {cut[1]:.0f}, {cut[2]:.0f})",
|
|
106
|
+
)
|
|
107
|
+
figures.append((f"cluster_{cid:02d}", fig))
|
|
108
|
+
|
|
109
|
+
if output_dir is not None:
|
|
110
|
+
output_dir = Path(output_dir)
|
|
111
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
112
|
+
for label, fig in figures:
|
|
113
|
+
fig.savefig(output_dir / f"{label}.png", dpi=120, bbox_inches="tight")
|
|
114
|
+
plt.close(fig)
|
|
115
|
+
return None
|
|
116
|
+
return figures
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# ---------------------------------------------------------------------------
|
|
120
|
+
# Internals
|
|
121
|
+
# ---------------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _build_threshold_mask(
|
|
125
|
+
data: np.ndarray, stat_threshold: float | None, two_sided: bool
|
|
126
|
+
) -> np.ndarray:
|
|
127
|
+
"""Return a boolean mask of voxels surviving the voxel-level threshold.
|
|
128
|
+
|
|
129
|
+
If ``stat_threshold is None``, treat the input as already thresholded
|
|
130
|
+
(only zero vs non-zero matters).
|
|
131
|
+
"""
|
|
132
|
+
if stat_threshold is None:
|
|
133
|
+
return data != 0 if two_sided else data > 0
|
|
134
|
+
if two_sided:
|
|
135
|
+
return np.abs(data) >= stat_threshold
|
|
136
|
+
return data >= stat_threshold
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _label_clusters(data: np.ndarray, mask: np.ndarray, two_sided: bool) -> np.ndarray:
|
|
140
|
+
"""Sign-aware 26-connected component labeling.
|
|
141
|
+
|
|
142
|
+
For ``two_sided=True``, label positive and negative regions
|
|
143
|
+
independently so a positive blob touching a negative blob never
|
|
144
|
+
merges. Negative-cluster IDs are offset above positive IDs.
|
|
145
|
+
"""
|
|
146
|
+
structure = np.ones((3, 3, 3), dtype=int)
|
|
147
|
+
if not two_sided:
|
|
148
|
+
labels, _ = ndimage.label(mask, structure=structure)
|
|
149
|
+
return labels
|
|
150
|
+
pos_mask = mask & (data > 0)
|
|
151
|
+
neg_mask = mask & (data < 0)
|
|
152
|
+
pos_labels, n_pos = ndimage.label(pos_mask, structure=structure)
|
|
153
|
+
neg_labels, _ = ndimage.label(neg_mask, structure=structure)
|
|
154
|
+
neg_labels = np.where(neg_labels > 0, neg_labels + n_pos, 0)
|
|
155
|
+
return pos_labels + neg_labels
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _filter_by_size(
|
|
159
|
+
labels: np.ndarray, cluster_threshold: int
|
|
160
|
+
) -> tuple[np.ndarray, list[int]]:
|
|
161
|
+
"""Zero out clusters smaller than ``cluster_threshold`` voxels.
|
|
162
|
+
|
|
163
|
+
Returns the filtered label volume and the list of surviving IDs in
|
|
164
|
+
descending size order.
|
|
165
|
+
"""
|
|
166
|
+
if labels.max() == 0:
|
|
167
|
+
return labels, []
|
|
168
|
+
sizes = np.bincount(labels.ravel())
|
|
169
|
+
keep = [i for i in range(1, len(sizes)) if sizes[i] >= cluster_threshold]
|
|
170
|
+
keep.sort(key=lambda i: -sizes[i])
|
|
171
|
+
keep_set = set(keep)
|
|
172
|
+
filtered = np.where(np.isin(labels, list(keep_set)), labels, 0)
|
|
173
|
+
return filtered, keep
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _renumber_labels(
|
|
177
|
+
labels: np.ndarray, ordered_ids: list[int]
|
|
178
|
+
) -> tuple[np.ndarray, dict[int, int]]:
|
|
179
|
+
"""Remap labels to 1..K following ``ordered_ids`` (largest cluster = 1)."""
|
|
180
|
+
out = np.zeros_like(labels)
|
|
181
|
+
id_map: dict[int, int] = {}
|
|
182
|
+
for new_id, old_id in enumerate(ordered_ids, start=1):
|
|
183
|
+
out[labels == old_id] = new_id
|
|
184
|
+
id_map[old_id] = new_id
|
|
185
|
+
return out, id_map
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _cluster_label_string(
|
|
189
|
+
atlas: _Atlas, ijk: np.ndarray, prob_threshold: float, top_k: int = 5
|
|
190
|
+
) -> str:
|
|
191
|
+
"""Tally regions across all voxels in a cluster, return a formatted string.
|
|
192
|
+
|
|
193
|
+
For deterministic atlases, each voxel contributes exactly one region.
|
|
194
|
+
For probabilistic atlases, each voxel contributes its argmax region
|
|
195
|
+
(or ``no_label`` if its top probability is below ``prob_threshold``).
|
|
196
|
+
Output: ``"38.2% Foo; 12.1% Bar; ..."`` sorted by descending share.
|
|
197
|
+
"""
|
|
198
|
+
data = atlas.image.get_fdata()
|
|
199
|
+
lut = _label_lookup(atlas)
|
|
200
|
+
n = ijk.shape[0]
|
|
201
|
+
if n == 0:
|
|
202
|
+
return ""
|
|
203
|
+
|
|
204
|
+
if atlas.kind == "deterministic":
|
|
205
|
+
ids = data[ijk[:, 0], ijk[:, 1], ijk[:, 2]].astype(int)
|
|
206
|
+
names = [lut.get(int(i), "no_label") for i in ids]
|
|
207
|
+
else: # probabilistic
|
|
208
|
+
probs = data[ijk[:, 0], ijk[:, 1], ijk[:, 2]] # (M, K)
|
|
209
|
+
best = probs.argmax(axis=1)
|
|
210
|
+
max_prob = probs.max(axis=1)
|
|
211
|
+
names = [
|
|
212
|
+
"no_label" if p < prob_threshold else lut.get(int(b), "no_label")
|
|
213
|
+
for b, p in zip(best, max_prob, strict=True)
|
|
214
|
+
]
|
|
215
|
+
|
|
216
|
+
counts: dict[str, int] = {}
|
|
217
|
+
for name in names:
|
|
218
|
+
counts[name] = counts.get(name, 0) + 1
|
|
219
|
+
items = sorted(counts.items(), key=lambda kv: -kv[1])[:top_k]
|
|
220
|
+
return "; ".join(f"{100 * c / n:.1f}% {name}" for name, c in items)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _peak_voxel(
|
|
224
|
+
data: np.ndarray, cluster_mask: np.ndarray, two_sided: bool
|
|
225
|
+
) -> tuple[int, int, int]:
|
|
226
|
+
"""Return ijk of the voxel with the largest |value| (or value) in cluster."""
|
|
227
|
+
masked = np.where(cluster_mask, data, np.nan)
|
|
228
|
+
if two_sided:
|
|
229
|
+
flat = np.nanargmax(np.abs(masked))
|
|
230
|
+
else:
|
|
231
|
+
flat = np.nanargmax(masked)
|
|
232
|
+
i, j, k = np.unravel_index(flat, data.shape)
|
|
233
|
+
return int(i), int(j), int(k)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _build_clusters_dataframe(
|
|
237
|
+
data: np.ndarray,
|
|
238
|
+
labels: np.ndarray,
|
|
239
|
+
affine: np.ndarray,
|
|
240
|
+
*,
|
|
241
|
+
atlas_objs: list[_Atlas],
|
|
242
|
+
prob_threshold: float,
|
|
243
|
+
two_sided: bool,
|
|
244
|
+
voxel_volume_mm3: float,
|
|
245
|
+
) -> pl.DataFrame:
|
|
246
|
+
"""Build the per-cluster summary DataFrame with mass-weighted labels."""
|
|
247
|
+
rows: list[dict] = []
|
|
248
|
+
n_clusters = int(labels.max())
|
|
249
|
+
for cid in range(1, n_clusters + 1):
|
|
250
|
+
cluster_mask = labels == cid
|
|
251
|
+
ijk = np.argwhere(cluster_mask)
|
|
252
|
+
n_vox = ijk.shape[0]
|
|
253
|
+
peak_ijk = _peak_voxel(data, cluster_mask, two_sided)
|
|
254
|
+
peak_xyz = nb_affines.apply_affine(affine, np.asarray(peak_ijk))
|
|
255
|
+
cluster_vals = data[cluster_mask]
|
|
256
|
+
row: dict = {
|
|
257
|
+
"cluster_id": cid,
|
|
258
|
+
"peak_x": float(peak_xyz[0]),
|
|
259
|
+
"peak_y": float(peak_xyz[1]),
|
|
260
|
+
"peak_z": float(peak_xyz[2]),
|
|
261
|
+
"mean_stat": float(cluster_vals.mean()),
|
|
262
|
+
"volume_mm3": float(n_vox * voxel_volume_mm3),
|
|
263
|
+
"n_voxels": int(n_vox),
|
|
264
|
+
}
|
|
265
|
+
# World (mm) coords are atlas-independent; compute once per cluster
|
|
266
|
+
# rather than re-running the transform for each atlas.
|
|
267
|
+
world_xyz = nb_affines.apply_affine(affine, ijk)
|
|
268
|
+
for atlas in atlas_objs:
|
|
269
|
+
atlas_ijk = _xyz_to_ijk(world_xyz, atlas.image.affine)
|
|
270
|
+
atlas_ijk = _clip_to_box(atlas_ijk, atlas.image.shape)
|
|
271
|
+
row[atlas.name] = _cluster_label_string(atlas, atlas_ijk, prob_threshold)
|
|
272
|
+
rows.append(row)
|
|
273
|
+
|
|
274
|
+
if not rows:
|
|
275
|
+
# Build empty schema-compatible frame
|
|
276
|
+
schema = {
|
|
277
|
+
"cluster_id": pl.Int64,
|
|
278
|
+
"peak_x": pl.Float64,
|
|
279
|
+
"peak_y": pl.Float64,
|
|
280
|
+
"peak_z": pl.Float64,
|
|
281
|
+
"mean_stat": pl.Float64,
|
|
282
|
+
"volume_mm3": pl.Float64,
|
|
283
|
+
"n_voxels": pl.Int64,
|
|
284
|
+
**{a.name: pl.Utf8 for a in atlas_objs},
|
|
285
|
+
}
|
|
286
|
+
return pl.DataFrame(schema=schema)
|
|
287
|
+
return pl.DataFrame(rows)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _build_peaks_dataframe(
|
|
291
|
+
thr_img: nb.Nifti1Image,
|
|
292
|
+
*,
|
|
293
|
+
renumbered: np.ndarray,
|
|
294
|
+
stat_threshold: float | None,
|
|
295
|
+
two_sided: bool,
|
|
296
|
+
min_distance: float,
|
|
297
|
+
atlas_names: list[str],
|
|
298
|
+
prob_threshold: float,
|
|
299
|
+
voxel_volume_mm3: float,
|
|
300
|
+
) -> pl.DataFrame:
|
|
301
|
+
"""Use nilearn's get_clusters_table for peaks/sub-peaks, then add labels.
|
|
302
|
+
|
|
303
|
+
``cluster_id`` is looked up in the ``renumbered`` label volume (the same
|
|
304
|
+
size-ordered integer labelling used by the clusters table) so the two tables
|
|
305
|
+
share one id space and can be joined; sub-peaks inherit their parent
|
|
306
|
+
cluster's id automatically.
|
|
307
|
+
"""
|
|
308
|
+
import pandas as pd
|
|
309
|
+
from nilearn.reporting import get_clusters_table
|
|
310
|
+
|
|
311
|
+
thresh = 0.0 if stat_threshold is None else float(stat_threshold)
|
|
312
|
+
table = get_clusters_table(
|
|
313
|
+
thr_img,
|
|
314
|
+
stat_threshold=thresh,
|
|
315
|
+
cluster_threshold=0,
|
|
316
|
+
two_sided=two_sided,
|
|
317
|
+
min_distance=min_distance,
|
|
318
|
+
)
|
|
319
|
+
if len(table) == 0:
|
|
320
|
+
schema = {
|
|
321
|
+
"cluster_id": pl.Int64,
|
|
322
|
+
"x": pl.Float64,
|
|
323
|
+
"y": pl.Float64,
|
|
324
|
+
"z": pl.Float64,
|
|
325
|
+
"peak_stat": pl.Float64,
|
|
326
|
+
"volume_mm3": pl.Float64,
|
|
327
|
+
"n_voxels": pl.Int64,
|
|
328
|
+
**dict.fromkeys(atlas_names, pl.Utf8),
|
|
329
|
+
}
|
|
330
|
+
return pl.DataFrame(schema=schema)
|
|
331
|
+
|
|
332
|
+
# Rename pandas → polars-friendly names. nilearn returns columns:
|
|
333
|
+
# 'Cluster ID', 'X', 'Y', 'Z', 'Peak Stat', 'Cluster Size (mm3)'
|
|
334
|
+
coords = table[["X", "Y", "Z"]].to_numpy(dtype=float)
|
|
335
|
+
labels = label_coords(
|
|
336
|
+
coords, atlas=atlas_names, prob_threshold=prob_threshold
|
|
337
|
+
).drop(["x", "y", "z"])
|
|
338
|
+
|
|
339
|
+
# nilearn emits one row per peak AND per sub-peak; sub-peak rows carry an
|
|
340
|
+
# empty string '' in 'Cluster Size (mm3)'. Coerce to numeric (sub-peaks ->
|
|
341
|
+
# NaN) and forward-fill so each sub-peak inherits its parent peak's cluster
|
|
342
|
+
# size (nilearn lists the parent peak row immediately before its sub-peaks).
|
|
343
|
+
cluster_size = pd.to_numeric(table["Cluster Size (mm3)"], errors="coerce").ffill()
|
|
344
|
+
volume_mm3 = cluster_size.to_numpy(dtype=float)
|
|
345
|
+
# Guard .astype(int) against any residual NaN (would yield platform garbage).
|
|
346
|
+
n_voxels = np.rint(np.nan_to_num(volume_mm3, nan=0.0) / voxel_volume_mm3).astype(
|
|
347
|
+
int
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
# Look up each peak's (sub-peak's) voxel in the renumbered label volume so
|
|
351
|
+
# cluster_id shares the clusters table's size-ordered integer id space
|
|
352
|
+
# (F043). Sub-peaks fall inside their parent cluster, so they inherit its id.
|
|
353
|
+
peak_ijk = _clip_to_box(_xyz_to_ijk(coords, thr_img.affine), renumbered.shape)
|
|
354
|
+
peak_cluster_ids = renumbered[
|
|
355
|
+
peak_ijk[:, 0], peak_ijk[:, 1], peak_ijk[:, 2]
|
|
356
|
+
].astype(np.int64)
|
|
357
|
+
|
|
358
|
+
base = pl.DataFrame(
|
|
359
|
+
{
|
|
360
|
+
"cluster_id": peak_cluster_ids,
|
|
361
|
+
"x": coords[:, 0],
|
|
362
|
+
"y": coords[:, 1],
|
|
363
|
+
"z": coords[:, 2],
|
|
364
|
+
"peak_stat": table["Peak Stat"].to_numpy(dtype=float),
|
|
365
|
+
"volume_mm3": volume_mm3,
|
|
366
|
+
"n_voxels": n_voxels,
|
|
367
|
+
}
|
|
368
|
+
)
|
|
369
|
+
return pl.concat([base, labels], how=_HORIZONTAL_CONCAT)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
# ---------------------------------------------------------------------------
|
|
373
|
+
# Public entry point
|
|
374
|
+
# ---------------------------------------------------------------------------
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _cluster_report_data(
|
|
378
|
+
bd: "BrainData",
|
|
379
|
+
*,
|
|
380
|
+
stat_threshold: float | None = 3.0,
|
|
381
|
+
cluster_threshold: int = 10,
|
|
382
|
+
two_sided: bool = True,
|
|
383
|
+
min_distance: float = 8.0,
|
|
384
|
+
atlas: str | Sequence[str] = DEFAULT_ATLASES,
|
|
385
|
+
prob_threshold: float = 5.0,
|
|
386
|
+
) -> tuple[pl.DataFrame, pl.DataFrame, "BrainData"]:
|
|
387
|
+
"""Compute cluster report DataFrames + thresholded BrainData.
|
|
388
|
+
|
|
389
|
+
Pure function — the BrainData facade `BrainData.cluster_report`
|
|
390
|
+
wraps the result in a `_ClusterReport`.
|
|
391
|
+
|
|
392
|
+
Args:
|
|
393
|
+
bd (BrainData): A single 3-D stat map.
|
|
394
|
+
stat_threshold (float, optional): Voxel-level threshold. None means treat
|
|
395
|
+
`bd` as already thresholded (skip voxel filtering, keep all non-zero
|
|
396
|
+
voxels). Default 3.0.
|
|
397
|
+
cluster_threshold (int): Minimum cluster size in voxels. Default 10.
|
|
398
|
+
two_sided (bool): Report negative clusters as separate clusters. Default
|
|
399
|
+
True.
|
|
400
|
+
min_distance (float): Minimum distance (mm) between sub-peaks. Passed to
|
|
401
|
+
`get_clusters_table`. Default 8.0.
|
|
402
|
+
atlas (str | Sequence[str]): Atlas name or list of names from
|
|
403
|
+
`list_atlases`. Default `DEFAULT_ATLASES`.
|
|
404
|
+
prob_threshold (float): Drop probabilistic-atlas regions below this
|
|
405
|
+
percentage. Default 5.0.
|
|
406
|
+
|
|
407
|
+
Returns:
|
|
408
|
+
tuple[pl.DataFrame, pl.DataFrame, BrainData]: `(peaks, clusters,
|
|
409
|
+
thresholded_bd)` — see `_ClusterReport` for the frame layouts.
|
|
410
|
+
"""
|
|
411
|
+
from nltools.data import BrainData
|
|
412
|
+
|
|
413
|
+
img = bd.to_nifti()
|
|
414
|
+
data = np.asarray(img.get_fdata(), dtype=float)
|
|
415
|
+
if data.ndim == 4 and data.shape[3] == 1:
|
|
416
|
+
data = data[..., 0]
|
|
417
|
+
if data.ndim != 3:
|
|
418
|
+
raise ValueError(
|
|
419
|
+
f"cluster_report requires a single 3D stat map; got shape {data.shape}"
|
|
420
|
+
)
|
|
421
|
+
affine = img.affine
|
|
422
|
+
voxel_volume_mm3 = float(abs(np.linalg.det(affine[:3, :3])))
|
|
423
|
+
|
|
424
|
+
mask = _build_threshold_mask(data, stat_threshold, two_sided)
|
|
425
|
+
raw_labels = _label_clusters(data, mask, two_sided)
|
|
426
|
+
filtered, kept = _filter_by_size(raw_labels, cluster_threshold)
|
|
427
|
+
renumbered, _ = _renumber_labels(filtered, kept)
|
|
428
|
+
|
|
429
|
+
thr_data = np.where(renumbered > 0, data, 0.0).astype(np.float32)
|
|
430
|
+
thr_img = nb.Nifti1Image(thr_data, affine)
|
|
431
|
+
thr_bd = BrainData(thr_img, mask=bd.mask)
|
|
432
|
+
|
|
433
|
+
atlas_names = [atlas] if isinstance(atlas, str) else list(atlas)
|
|
434
|
+
atlas_objs = [load_atlas(name) for name in atlas_names]
|
|
435
|
+
|
|
436
|
+
peaks = _build_peaks_dataframe(
|
|
437
|
+
thr_img,
|
|
438
|
+
renumbered=renumbered,
|
|
439
|
+
stat_threshold=stat_threshold,
|
|
440
|
+
two_sided=two_sided,
|
|
441
|
+
min_distance=min_distance,
|
|
442
|
+
atlas_names=atlas_names,
|
|
443
|
+
prob_threshold=prob_threshold,
|
|
444
|
+
voxel_volume_mm3=voxel_volume_mm3,
|
|
445
|
+
)
|
|
446
|
+
clusters = _build_clusters_dataframe(
|
|
447
|
+
data,
|
|
448
|
+
renumbered,
|
|
449
|
+
affine,
|
|
450
|
+
atlas_objs=atlas_objs,
|
|
451
|
+
prob_threshold=prob_threshold,
|
|
452
|
+
two_sided=two_sided,
|
|
453
|
+
voxel_volume_mm3=voxel_volume_mm3,
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
return peaks, clusters, thr_bd
|