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.
- brainfc/__init__.py +20 -0
- brainfc/__main__.py +3 -0
- brainfc/_version.py +3 -0
- brainfc/atlases.py +89 -0
- brainfc/cli.py +184 -0
- brainfc/demo.py +77 -0
- brainfc/export.py +109 -0
- brainfc/imaging.py +376 -0
- brainfc/io.py +241 -0
- brainfc/models.py +335 -0
- brainfc/pipeline.py +383 -0
- brainfc/plotting.py +173 -0
- brainfc/preprocessing.py +179 -0
- brainfc/presets.py +192 -0
- brainfc/web/__init__.py +1 -0
- brainfc/web/app.py +586 -0
- brainfc/web/schemas.py +251 -0
- brainfc/web/static/THIRD_PARTY_NOTICES.txt +97 -0
- brainfc/web/static/app.css +1 -0
- brainfc/web/static/app.js +4287 -0
- brainfc/web/static/index.html +1 -0
- brainfc/web/static/reference/api-inventory.json +434 -0
- brainfc/web/static/reference/api-reference.html +980 -0
- brainfc/web/static/reference/cli-reference.html +132 -0
- brainfc/web/static/reference/datasets.html +55 -0
- brainfc/web/static/reference/eight-views-v02.png +0 -0
- brainfc/web/static/reference/formats.html +84 -0
- brainfc/web/static/reference/http-api.html +210 -0
- brainfc/web/static/reference/http-reference.html +2147 -0
- brainfc/web/static/reference/index.html +60 -0
- brainfc/web/static/reference/interface-preview.png +0 -0
- brainfc/web/static/reference/openapi.json +2185 -0
- brainfc/web/static/reference/outputs.html +219 -0
- brainfc/web/static/reference/presets-and-workflow.html +133 -0
- brainfc/web/static/reference/processing.html +89 -0
- brainfc/web/static/reference/python-api.html +276 -0
- brainfc/web/static/reference/quickstart.html +85 -0
- brainfc/web/static/reference/release.html +84 -0
- brainfc/web/static/reference/research.html +57 -0
- brainfc/web/static/reference/validation-v0.2.0.html +33 -0
- brainfc/web/static/reference/validation-v0.2.1.html +16 -0
- brainfc/web/static/reference/validation-v0.3.0.html +18 -0
- brainfc/web/static/reference/validation.html +42 -0
- brainfc/workflow.py +240 -0
- brainfc-0.3.0.dist-info/METADATA +97 -0
- brainfc-0.3.0.dist-info/RECORD +51 -0
- brainfc-0.3.0.dist-info/WHEEL +4 -0
- brainfc-0.3.0.dist-info/entry_points.txt +2 -0
- brainfc-0.3.0.dist-info/licenses/LICENSE +201 -0
- brainfc-0.3.0.dist-info/licenses/NOTICE +19 -0
- 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
|