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