brainfc 0.3.0__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 (51) hide show
  1. brainfc/__init__.py +20 -0
  2. brainfc/__main__.py +3 -0
  3. brainfc/_version.py +3 -0
  4. brainfc/atlases.py +89 -0
  5. brainfc/cli.py +184 -0
  6. brainfc/demo.py +77 -0
  7. brainfc/export.py +109 -0
  8. brainfc/imaging.py +376 -0
  9. brainfc/io.py +241 -0
  10. brainfc/models.py +335 -0
  11. brainfc/pipeline.py +383 -0
  12. brainfc/plotting.py +173 -0
  13. brainfc/preprocessing.py +179 -0
  14. brainfc/presets.py +192 -0
  15. brainfc/web/__init__.py +1 -0
  16. brainfc/web/app.py +586 -0
  17. brainfc/web/schemas.py +251 -0
  18. brainfc/web/static/THIRD_PARTY_NOTICES.txt +97 -0
  19. brainfc/web/static/app.css +1 -0
  20. brainfc/web/static/app.js +4287 -0
  21. brainfc/web/static/index.html +1 -0
  22. brainfc/web/static/reference/api-inventory.json +434 -0
  23. brainfc/web/static/reference/api-reference.html +980 -0
  24. brainfc/web/static/reference/cli-reference.html +132 -0
  25. brainfc/web/static/reference/datasets.html +55 -0
  26. brainfc/web/static/reference/eight-views-v02.png +0 -0
  27. brainfc/web/static/reference/formats.html +84 -0
  28. brainfc/web/static/reference/http-api.html +210 -0
  29. brainfc/web/static/reference/http-reference.html +2147 -0
  30. brainfc/web/static/reference/index.html +60 -0
  31. brainfc/web/static/reference/interface-preview.png +0 -0
  32. brainfc/web/static/reference/openapi.json +2185 -0
  33. brainfc/web/static/reference/outputs.html +219 -0
  34. brainfc/web/static/reference/presets-and-workflow.html +133 -0
  35. brainfc/web/static/reference/processing.html +89 -0
  36. brainfc/web/static/reference/python-api.html +276 -0
  37. brainfc/web/static/reference/quickstart.html +85 -0
  38. brainfc/web/static/reference/release.html +84 -0
  39. brainfc/web/static/reference/research.html +57 -0
  40. brainfc/web/static/reference/validation-v0.2.0.html +33 -0
  41. brainfc/web/static/reference/validation-v0.2.1.html +16 -0
  42. brainfc/web/static/reference/validation-v0.3.0.html +18 -0
  43. brainfc/web/static/reference/validation.html +42 -0
  44. brainfc/workflow.py +240 -0
  45. brainfc-0.3.0.dist-info/METADATA +97 -0
  46. brainfc-0.3.0.dist-info/RECORD +51 -0
  47. brainfc-0.3.0.dist-info/WHEEL +4 -0
  48. brainfc-0.3.0.dist-info/entry_points.txt +2 -0
  49. brainfc-0.3.0.dist-info/licenses/LICENSE +201 -0
  50. brainfc-0.3.0.dist-info/licenses/NOTICE +19 -0
  51. brainfc-0.3.0.dist-info/licenses/src/brainfc/web/static/THIRD_PARTY_NOTICES.txt +97 -0
brainfc/pipeline.py ADDED
@@ -0,0 +1,383 @@
1
+ """One scientific core for Python, CLI and the local UI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import replace
6
+ from datetime import datetime, timezone
7
+ from importlib.metadata import version
8
+ from pathlib import Path
9
+ import hashlib
10
+ import warnings
11
+ import nibabel as nib
12
+ import numpy as np
13
+ import pandas as pd
14
+ from nilearn.signal import clean
15
+ from scipy.stats import rankdata
16
+ from sklearn.covariance import LedoitWolf
17
+ from .io import TABLE_SUFFIXES, metadata, numeric_table
18
+ from .imaging import (
19
+ roi_table,
20
+ make_rois,
21
+ volume_timeseries,
22
+ cifti_timeseries,
23
+ gifti_timeseries,
24
+ brain_geometry,
25
+ )
26
+ from .models import Config, Connectome, InputError
27
+ from ._version import __version__
28
+
29
+
30
+ def fingerprint(path):
31
+ """Hash one input file in 4 MiB chunks.
32
+
33
+ Parameters
34
+ ----------
35
+ path : str or pathlib.Path
36
+ Existing file, expanded and resolved.
37
+
38
+ Returns
39
+ -------
40
+ dict
41
+ Absolute path, lowercase SHA-256 and size_bytes. Does not copy the file.
42
+ Compound image partner files are not automatically enumerated."""
43
+ p = Path(path).expanduser().resolve()
44
+ h = hashlib.sha256()
45
+ with p.open("rb") as f:
46
+ for chunk in iter(lambda: f.read(4 * 1024 * 1024), b""):
47
+ h.update(chunk)
48
+ return {"path": str(p), "sha256": h.hexdigest(), "size_bytes": p.stat().st_size}
49
+
50
+
51
+ def _confounds(path, n, config):
52
+ """Build the regression design and original-frame keep mask.
53
+
54
+ Parameters
55
+ ----------
56
+ path : str, pathlib.Path or None
57
+ TSV/CSV with exactly n original rows; None means no regression design.
58
+ n : int
59
+ Number of original time points, before discard/censoring.
60
+ config : Config
61
+ Controls initial discard, FD cutoff and explicit/default regression columns.
62
+
63
+ Returns
64
+ -------
65
+ tuple
66
+ (design or None, keep_mask of shape (n,), quality_dict).
67
+
68
+ Notes
69
+ -----
70
+ Nonsteady outlier columns censor nonzero rows. FD means/max use all finite
71
+ original FD rows, including rows later excluded. Counts may overlap; use the
72
+ combined keep mask for unique exclusions. Only first-row derivative1 NaNs
73
+ are filled with zero, and each fill is recorded. An intercept-inclusive design
74
+ rank/residual check occurs later in extract_connectome."""
75
+ keep = np.ones(n, dtype=bool)
76
+ keep[: config.discard] = False
77
+ qc = {
78
+ "confound_columns": [],
79
+ "fd_mean": None,
80
+ "fd_max": None,
81
+ "fd_censored": 0,
82
+ "nonsteady_censored": 0,
83
+ "confound_nan_fill": [],
84
+ }
85
+ if path is None:
86
+ if config.fd_threshold is not None or config.confound_columns:
87
+ raise InputError("FD censoring / selected confounds require a confounds TSV/CSV.")
88
+ return None, keep, qc
89
+ p = Path(path)
90
+ frame = pd.read_csv(p, sep=None, engine="python", na_values=["n/a"])
91
+ if len(frame) != n:
92
+ raise InputError(f"Confounds rows ({len(frame)}) differ from original time points ({n}).")
93
+ for column in frame.columns:
94
+ if column.startswith("non_steady_state_outlier"):
95
+ v = pd.to_numeric(frame[column], errors="raise").to_numpy(float)
96
+ if not np.isfinite(v).all():
97
+ raise InputError(f"Nonfinite values in {column}.")
98
+ keep[v != 0] = False
99
+ qc["nonsteady_censored"] = int((~keep[config.discard :]).sum())
100
+ if "framewise_displacement" in frame:
101
+ fd = pd.to_numeric(frame.framewise_displacement, errors="raise").to_numpy(float)
102
+ # The first FD is mathematically undefined. No other missing FD is silently filled.
103
+ invalid = ~np.isfinite(fd)
104
+ if invalid[1:].any() or np.isinf(fd[0]) or np.any(fd[np.isfinite(fd)] < 0):
105
+ raise InputError("FD must be nonnegative and finite except for the first sample.")
106
+ valid = fd[np.isfinite(fd)]
107
+ qc.update(
108
+ fd_mean=float(valid.mean()) if len(valid) else None,
109
+ fd_max=float(valid.max()) if len(valid) else None,
110
+ )
111
+ if config.fd_threshold:
112
+ bad = invalid | (fd > config.fd_threshold)
113
+ keep[bad] = False
114
+ qc["fd_censored"] = int(bad.sum())
115
+ elif config.fd_threshold:
116
+ raise InputError("Confounds lack framewise_displacement; FD is not inferred from motion parameters.")
117
+ if config.confound_columns:
118
+ columns = list(config.confound_columns)
119
+ else:
120
+ motion = [f"trans_{a}" for a in "xyz"] + [f"rot_{a}" for a in "xyz"]
121
+ if any(c in frame for c in motion):
122
+ if not all(c in frame for c in motion):
123
+ raise InputError("Incomplete motion6 confounds; specify confound_columns explicitly.")
124
+ columns = motion + [c for c in ("white_matter", "csf") if c in frame]
125
+ else:
126
+ raise InputError(
127
+ "For generic confounds, specify confound_columns. No automatic regression of every column."
128
+ )
129
+ missing = set(columns) - set(frame.columns)
130
+ if missing:
131
+ raise InputError(f"Missing confound columns: {sorted(missing)}")
132
+ matrix = frame[columns].to_numpy(float)
133
+ # fMRIPrep temporal derivative columns use n/a on the first frame.
134
+ for j, c in enumerate(columns):
135
+ if np.isnan(matrix[0, j]) and "derivative1" in c:
136
+ matrix[0, j] = 0
137
+ qc["confound_nan_fill"].append({"column": c, "row": 0, "value": 0})
138
+ if not np.isfinite(matrix).all():
139
+ raise InputError("Selected confounds contain missing / nonfinite values.")
140
+ qc["confound_columns"] = columns
141
+ return matrix, keep, qc
142
+
143
+
144
+ def extract_connectome(
145
+ source, *, atlas=None, rois=None, mask=None, reference=None, confounds=None, config=None, progress=None
146
+ ):
147
+ """Extract, clean and correlate one fMRI run or an ROI time-series file.
148
+
149
+ Parameters
150
+ ----------
151
+ source : str or pathlib.Path
152
+ One supported time-series file. Directories and DICOM are not accepted.
153
+ atlas : str, pathlib.Path or None, default None
154
+ 3D nonnegative integer label image for volume; matching single-map dlabel
155
+ for dense CIFTI; label.gii for GIFTI. Not used for tables/ptseries.
156
+ rois : str, pathlib.Path or None, default None
157
+ CSV/TSV mapping. Label images match by label_value; tables/ptseries by
158
+ roi_id. Must exactly cover extracted IDs. Optional x/y/z are RAS+ mm.
159
+ mask : str, pathlib.Path or None, default None
160
+ Same-space volume mask. Positive values retain voxels; nearest-neighbor
161
+ resampled. Not accepted for tables, CIFTI or GIFTI. Not used as reference
162
+ unless also passed to reference.
163
+ reference : str, pathlib.Path or None, default None
164
+ Same-space 3D brain mask or skull-stripped reference for display only.
165
+ The caller establishes registration; no transform is estimated.
166
+ confounds : str, pathlib.Path or None, default None
167
+ Headered TSV/CSV aligned to all original frames. See Config for selection.
168
+ config : Config or None, default None
169
+ None constructs Config(). Does not accept a configuration dict directly.
170
+ progress : callable or None, default None
171
+ Called synchronously with a human-readable str at processing milestones.
172
+
173
+ Returns
174
+ -------
175
+ Connectome
176
+ Cleaned time x ROI signals, signed R x R connectivity, Fisher-z, ROI order,
177
+ retained original indices, QC, provenance and optional display geometry.
178
+
179
+ Raises
180
+ ------
181
+ InputError
182
+ Incompatible dimensions/axes/spaces/TR, missing ROIs, invalid confounds,
183
+ insufficient retained frames/residual rank, nonfinite/constant ROI signals.
184
+ OSError, ValueError
185
+ Underlying file/parser failures may also propagate.
186
+
187
+ Notes
188
+ -----
189
+ Image input requires preprocessed=True. Grid resampling is not registration.
190
+ Processing: ROI means -> initial discard -> Nilearn joint filtering/regression/
191
+ censoring -> correlation -> Fisher-z -> geometry/provenance. No files are
192
+ written and no atlas is downloaded. Table numeric content is interpreted as
193
+ time series; check_input additionally rejects suspected symmetric matrices.
194
+ No group inference, disease classification or raw spatial preprocessing occurs."""
195
+ config = config or Config()
196
+ say = progress or (lambda message: None)
197
+ source = Path(source).expanduser().resolve()
198
+ if not source.is_file():
199
+ raise InputError("source must be a file representing a single run.")
200
+ if config.discard < 0:
201
+ raise InputError("discard cannot be negative.")
202
+ meta = metadata(source)
203
+ table = roi_table(rois)
204
+ messages = []
205
+ atlas_img = None
206
+ say("Reading input and checking spatial alignment")
207
+ if source.suffix.lower() in TABLE_SUFFIXES:
208
+ ts, names = numeric_table(
209
+ source, header=config.table_header, variable=config.variable, transpose=config.transpose
210
+ )
211
+ ids = names if names else [str(i + 1) for i in range(ts.shape[1])]
212
+ regions = make_rois(ids, names, table=table, by_value=False)
213
+ image_qc = {"format": "timeseries-table"}
214
+ if atlas is not None or mask is not None:
215
+ raise InputError(
216
+ "Table input is already parcellated; supply a ROI table, not a volume atlas/mask."
217
+ )
218
+ else:
219
+ if not config.preprocessed:
220
+ raise InputError(
221
+ "Image extraction requires preprocessed=True after motion correction / registration. Use the raw BIDS → fMRIPrep route for unprocessed scans."
222
+ )
223
+ space = config.data_space or meta["space"]
224
+ if config.data_space and meta["space"] and config.data_space != meta["space"]:
225
+ raise InputError("Declared data_space conflicts with the input's BIDS space entity.")
226
+ if not space or (atlas is not None and (not config.atlas_space or space != config.atlas_space)):
227
+ raise InputError("Declare matching data_space and atlas_space. Resampling is not registration.")
228
+ config = replace(config, data_space=space)
229
+ img = nib.load(source)
230
+ if isinstance(img, nib.Cifti2Image):
231
+ if mask is not None:
232
+ raise InputError("Volume masks cannot be applied to CIFTI input.")
233
+ ts, regions, image_qc, atlas_img = cifti_timeseries(img, atlas, table)
234
+ elif isinstance(img, nib.gifti.GiftiImage):
235
+ if mask is not None:
236
+ raise InputError("Volume masks cannot be applied to GIFTI input.")
237
+ ts, regions, image_qc, atlas_img = gifti_timeseries(img, atlas, table)
238
+ messages.append(
239
+ "GIFTI vertex alignment relies on the declared same surface/hemisphere and vertex order."
240
+ )
241
+ else:
242
+ if atlas is None:
243
+ raise InputError("4D volume requires a 3D integer label atlas.")
244
+ ts, regions, image_qc, atlas_img = volume_timeseries(img, atlas, mask, table)
245
+ image_qc["format"] = "volume"
246
+ if len(img.header.get_zooms()) == 4:
247
+ unit = img.header.get_xyzt_units()[1] if hasattr(img.header, "get_xyzt_units") else "unknown"
248
+ scale = {"sec": 1, "msec": 0.001, "usec": 1e-6}.get(unit)
249
+ if scale:
250
+ image_qc["t_r"] = float(img.header.get_zooms()[3]) * scale
251
+ if image_qc.get("spatial_units", "").startswith("mm (unknown"):
252
+ messages.append(
253
+ "NIfTI spatial units were unspecified; coordinates interpreted as mm. Verify source metadata."
254
+ )
255
+ if not np.isfinite(ts).all() or ts.ndim != 2 or ts.shape[1] < 2:
256
+ raise InputError("Extracted time series must be finite with at least two ROIs.")
257
+ tr = config.t_r or meta["t_r"] or image_qc.get("t_r")
258
+ for inferred in (meta["t_r"], image_qc.get("t_r")):
259
+ if tr and inferred and not np.isclose(tr, float(inferred), atol=1e-4, rtol=1e-4):
260
+ raise InputError(f"TR metadata conflict: {tr} versus {inferred} seconds.")
261
+ config = replace(config, t_r=float(tr) if tr else None)
262
+ if config.high_pass or config.low_pass:
263
+ if not config.t_r:
264
+ raise InputError("Temporal filtering requires a known TR in seconds.")
265
+ nyquist = 0.5 / config.t_r
266
+ if any(v is not None and v >= nyquist for v in (config.low_pass, config.high_pass)):
267
+ raise InputError(f"Filter frequencies must be below Nyquist ({nyquist:g} Hz).")
268
+ n = len(ts)
269
+ c, keep, cq = _confounds(confounds, n, config)
270
+ if keep.sum() < config.min_samples:
271
+ raise InputError(f"Only {keep.sum()} retained time points; min_samples={config.min_samples}.")
272
+ say("Regressing confounds and applying temporal settings")
273
+ start = config.discard
274
+ sample_mask = np.flatnonzero(keep[start:])
275
+ conf = c[start:] if c is not None else None
276
+ if conf is not None:
277
+ rank = int(np.linalg.matrix_rank(np.column_stack((np.ones(len(sample_mask)), conf[sample_mask]))))
278
+ if len(sample_mask) - rank < 3:
279
+ raise InputError("Too few residual degrees of freedom after confound regression.")
280
+ cq["confound_design_rank_with_intercept"] = rank
281
+ with warnings.catch_warnings(record=True) as emitted:
282
+ warnings.simplefilter("always")
283
+ try:
284
+ cleaned = clean(
285
+ ts[start:],
286
+ confounds=conf,
287
+ t_r=config.t_r,
288
+ detrend=config.detrend,
289
+ standardize="zscore_sample" if config.standardize else False,
290
+ standardize_confounds=True,
291
+ high_pass=config.high_pass,
292
+ low_pass=config.low_pass,
293
+ sample_mask=sample_mask,
294
+ extrapolate=True,
295
+ filter="butterworth" if (config.high_pass or config.low_pass) else False,
296
+ )
297
+ except ValueError as exc:
298
+ raise InputError(f"Temporal cleaning failed (check scan length / filters): {exc}") from exc
299
+ messages.extend(
300
+ str(w.message) for w in emitted if not issubclass(w.category, (DeprecationWarning, FutureWarning))
301
+ )
302
+ scales = cleaned.std(axis=0)
303
+ bad = np.flatnonzero(~np.isfinite(scales) | (scales <= 1e-10))
304
+ if len(bad):
305
+ raise InputError(
306
+ f"Zero-variance / invalid ROIs after cleaning: {[regions[i]['roi_id'] for i in bad]}. No fabricated zero correlations returned."
307
+ )
308
+ say("Calculating connectivity and anatomical geometry")
309
+ if config.method == "partial":
310
+ estimator = LedoitWolf().fit(cleaned)
311
+ precision = estimator.precision_
312
+ matrix = -precision / np.sqrt(np.outer(np.diag(precision), np.diag(precision)))
313
+ cq["shrinkage"] = float(estimator.shrinkage_)
314
+ else:
315
+ values = rankdata(cleaned, axis=0) if config.method == "spearman" else cleaned
316
+ matrix = np.corrcoef(values, rowvar=False)
317
+ matrix = np.clip((matrix + matrix.T) / 2, -1, 1)
318
+ np.fill_diagonal(matrix, 1)
319
+ if not np.isfinite(matrix).all():
320
+ raise InputError("Connectivity contains nonfinite values.")
321
+ z = np.arctanh(np.clip(matrix, -1 + 1e-7, 1 - 1e-7))
322
+ np.fill_diagonal(z, 0)
323
+ if confounds is None:
324
+ messages.append(
325
+ "No confound regression supplied; verify upstream denoising before using this run in a study."
326
+ )
327
+ if not any(r.get("coordinates") is None for r in regions):
328
+ if not config.data_space:
329
+ raise InputError("ROI coordinates require a declared data_space.")
330
+ geometry = brain_geometry(atlas_img, reference, space=config.data_space)
331
+ else:
332
+ geometry = None
333
+ messages.append(
334
+ "No complete ROI coordinate table: matrix is available; supply coordinates to enable eight views and 3D."
335
+ )
336
+ say("Recording provenance")
337
+ inputs = {
338
+ name: fingerprint(p)
339
+ for name, p in {
340
+ "source": source,
341
+ "atlas": atlas,
342
+ "rois": rois,
343
+ "mask": mask,
344
+ "reference": reference,
345
+ "confounds": confounds,
346
+ "sidecar": meta["sidecar"],
347
+ }.items()
348
+ if p
349
+ }
350
+ qc = {
351
+ **image_qc,
352
+ **cq,
353
+ "n_input": n,
354
+ "n_retained": len(cleaned),
355
+ "n_censored": int(n - keep.sum()),
356
+ "retained_fraction": float(keep.mean()),
357
+ "n_rois": len(regions),
358
+ "t_r": config.t_r,
359
+ "duration_retained_seconds": len(cleaned) * config.t_r if config.t_r else None,
360
+ "warnings": messages,
361
+ }
362
+ provenance = {
363
+ "software": "brainfc",
364
+ "version": __version__,
365
+ "created_utc": datetime.now(timezone.utc).isoformat(),
366
+ "inputs": inputs,
367
+ "config": config.to_dict(),
368
+ "versions": {p: version(p) for p in ("numpy", "scipy", "nibabel", "nilearn", "scikit-learn")},
369
+ "method": "Ledoit-Wolf shrinkage partial correlation"
370
+ if config.method == "partial"
371
+ else config.method,
372
+ "roi_order": [r["roi_id"] for r in regions],
373
+ "fisher_z": "atanh(clip(r, -1+1e-7, 1-1e-7)); diagonal=0",
374
+ "operations": [
375
+ "explicit spatial-space check",
376
+ "ROI arithmetic means",
377
+ "initial-volume removal",
378
+ "Nilearn joint filtering and confound regression with sample mask",
379
+ "connectivity",
380
+ ],
381
+ "filter_censoring": "Nilearn Butterworth uses interpolation with extrapolate=True before censor removal; retained samples keep original indices",
382
+ }
383
+ return Connectome(cleaned, matrix, z, regions, np.flatnonzero(keep), qc, provenance, geometry)
brainfc/plotting.py ADDED
@@ -0,0 +1,173 @@
1
+ """Signed matrix and reproducible anatomical projections from the same result."""
2
+
3
+ from pathlib import Path
4
+ import threading
5
+ import numpy as np
6
+ from .models import InputError
7
+
8
+ PLOT_LOCK = threading.RLock()
9
+ VIEWS = [
10
+ ("Left", 0, 180),
11
+ ("Right", 0, 0),
12
+ ("Anterior", 0, 90),
13
+ ("Posterior", 0, -90),
14
+ ("Superior", 90, -90),
15
+ ("Inferior", -90, 90),
16
+ ("Left oblique", 28, 135),
17
+ ("Right oblique", 28, 45),
18
+ ]
19
+
20
+
21
+ def edges_of(result, threshold=0.3, max_edges=200, selection=None):
22
+ """Return display edges as (zero_based_row, zero_based_column, signed_weight).
23
+
24
+ result must expose rois and connectivity. threshold (default .3) is inclusive
25
+ in [0,1], but zero edges are omitted. max_edges (default 200) is nonnegative.
26
+ selection is None, {'kind':'node','id':ROI_ID}, or {'kind':'edge','id':'i:j'}.
27
+ Selection and threshold precede a stable descending-absolute-weight sort and
28
+ cap; ties use upper-triangle row-major order. Original arrays are unchanged.
29
+ Invalid settings or unknown selections raise InputError. An empty list is valid."""
30
+ if (
31
+ not np.isfinite(threshold)
32
+ or not 0 <= threshold <= 1
33
+ or not isinstance(max_edges, int)
34
+ or max_edges < 0
35
+ ):
36
+ raise InputError("Display threshold must be in [0,1]; max_edges must be a nonnegative integer.")
37
+ a, b = np.triu_indices(len(result.rois), 1)
38
+ weights = result.connectivity[a, b]
39
+ eligible_mask = (np.abs(weights) >= threshold) & (weights != 0)
40
+ if selection:
41
+ kind, selected = selection.get("kind"), str(selection.get("id"))
42
+ ids = [str(r["roi_id"]) for r in result.rois]
43
+ if kind == "node" and selected in ids:
44
+ index = ids.index(selected)
45
+ eligible_mask &= (a == index) | (b == index)
46
+ elif kind == "edge" and selected in {f"{i}:{j}" for i, j in zip(a, b)}:
47
+ i, j = map(int, selected.split(":"))
48
+ eligible_mask &= (a == i) & (b == j)
49
+ else:
50
+ raise InputError("Unknown selected node or edge.")
51
+ eligible = np.flatnonzero(eligible_mask)
52
+ chosen = eligible[np.argsort(-np.abs(weights[eligible]), kind="stable")[:max_edges]]
53
+ return [(int(a[i]), int(b[i]), float(weights[i])) for i in chosen]
54
+
55
+
56
+ def _pyplot():
57
+ import matplotlib
58
+
59
+ matplotlib.use("Agg")
60
+ import matplotlib.pyplot as plt
61
+
62
+ return plt
63
+
64
+
65
+ def plot_matrix(result, path=None):
66
+ """Functional form of Connectome.plot_matrix(result, path=None).
67
+
68
+ Accepts a Connectome or compatible result object with connectivity, rois and
69
+ provenance['method']. Returns a matplotlib Figure; optional path is saved at
70
+ 300 dpi and may overwrite an existing image. Uses a process-wide rendering lock.
71
+ See Connectome.plot_matrix for display and figure-lifecycle details."""
72
+ with PLOT_LOCK:
73
+ plt = _pyplot()
74
+ fig, ax = plt.subplots(figsize=(7.4, 6.4), layout="constrained")
75
+ im = ax.imshow(result.connectivity, vmin=-1, vmax=1, cmap="RdBu_r", interpolation="nearest")
76
+ ax.set(
77
+ title=f"Functional connectivity · {result.provenance['method']}",
78
+ xlabel="ROI column",
79
+ ylabel="ROI row",
80
+ )
81
+ if len(result.rois) <= 20:
82
+ labels = [r["roi_id"] for r in result.rois]
83
+ ax.set_xticks(range(len(labels)), labels, rotation=90, fontsize=8)
84
+ ax.set_yticks(range(len(labels)), labels, fontsize=8)
85
+ fig.colorbar(im, ax=ax, label="Correlation coefficient", shrink=0.85)
86
+ if path:
87
+ fig.savefig(path, dpi=300)
88
+ plt.close(fig)
89
+ return fig
90
+
91
+
92
+ def plot_views(
93
+ result, path=None, *, threshold=0.3, max_edges=200, selection=None, opacity=0.28, theme="paper"
94
+ ):
95
+ """Functional form of Connectome.plot_views with identical display options.
96
+
97
+ Accepts a Connectome or compatible object with rois/connectivity/qc/geometry.
98
+ Requires coordinates for every ROI. Returns a matplotlib Figure; path saves
99
+ PNG/SVG/PDF (or other Matplotlib format) and closes it, potentially overwriting.
100
+ See Connectome.plot_views for selection ID conventions, limits, opacity,
101
+ theme, rasterized shell and figure-lifecycle details."""
102
+ if not all(r.get("coordinates") is not None for r in result.rois):
103
+ raise InputError("Eight anatomical views require an explicit ROI coordinate table.")
104
+ with PLOT_LOCK:
105
+ from mpl_toolkits.mplot3d.art3d import Line3DCollection, Poly3DCollection
106
+
107
+ plt = _pyplot()
108
+ xyz = np.array([r["coordinates"] for r in result.rois])
109
+ edges = edges_of(result, threshold, max_edges, selection)
110
+ if not np.isfinite(opacity) or not 0 <= opacity <= 1 or theme not in {"paper", "midnight"}:
111
+ raise InputError("Invalid opacity or theme.")
112
+ shown = set(range(len(xyz))) if not selection else {n for a, b, _ in edges for n in (a, b)}
113
+ if selection and selection["kind"] == "node":
114
+ shown.add(next(i for i, r in enumerate(result.rois) if str(r["roi_id"]) == selection["id"]))
115
+ shown = sorted(shown)
116
+ dark = theme == "midnight"
117
+ background, foreground = ("#111d2e", "#deebf7") if dark else ("#f8fafc", "#23364b")
118
+ fig = plt.figure(figsize=(16, 8.8), layout="constrained")
119
+ fig.set_facecolor(background)
120
+ mesh = (result.geometry or {}).get("brain", {})
121
+ vertices = np.array(mesh.get("positions", [])).reshape(-1, 3)
122
+ faces = np.array(mesh.get("indices", []), dtype=int).reshape(-1, 3)
123
+ allpts = np.vstack([xyz, vertices]) if len(vertices) else xyz
124
+ center = (allpts.min(axis=0) + allpts.max(axis=0)) / 2
125
+ radius = max(np.ptp(allpts, axis=0)) * 0.55
126
+ for i, (name, elevation, azimuth) in enumerate(VIEWS):
127
+ ax = fig.add_subplot(2, 4, i + 1, projection="3d", proj_type="ortho")
128
+ ax.set_facecolor(background)
129
+ if len(faces):
130
+ surface = Poly3DCollection(
131
+ vertices[faces],
132
+ facecolors="#8da9b5",
133
+ edgecolors="none",
134
+ alpha=opacity * 0.15,
135
+ rasterized=True,
136
+ )
137
+ ax.add_collection3d(surface)
138
+ if edges:
139
+ ax.add_collection3d(
140
+ Line3DCollection(
141
+ [[xyz[a], xyz[b]] for a, b, _ in edges],
142
+ colors=["#c46c38" if w > 0 else "#397ab8" for _, _, w in edges],
143
+ linewidths=[0.45 + abs(w) * 1.1 for _, _, w in edges],
144
+ alpha=0.72,
145
+ )
146
+ )
147
+ ax.scatter(
148
+ *xyz[shown].T,
149
+ c=[result.rois[j].get("color") or ("#307e90" if xyz[j, 0] < 0 else "#997abd") for j in shown],
150
+ s=24,
151
+ depthshade=True,
152
+ edgecolors="white",
153
+ linewidths=0.3,
154
+ )
155
+ for k, setter in enumerate([ax.set_xlim, ax.set_ylim, ax.set_zlim]):
156
+ setter(center[k] - radius, center[k] + radius)
157
+ ax.set_box_aspect((1, 1, 1))
158
+ ax.view_init(elev=elevation, azim=azimuth, roll=0)
159
+ ax.set_title(name, fontsize=11, pad=0, color=foreground)
160
+ ax.set_axis_off()
161
+ fig.suptitle(
162
+ f"Eight anatomical views | {result.qc['n_rois']} ROIs | |r| ≥ {threshold:g} | {len(edges)} displayed edges",
163
+ fontsize=14,
164
+ color=foreground,
165
+ )
166
+ note = (
167
+ "Orange: positive · Blue: negative · Same edges in all views; full matrix remains unthresholded"
168
+ )
169
+ fig.text(0.5, 0.025, note, ha="center", fontsize=9, color="#425466")
170
+ if path:
171
+ fig.savefig(Path(path), dpi=300)
172
+ plt.close(fig)
173
+ return fig