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/imaging.py
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
"""Memory-bounded volume extraction, CIFTI alignment and GIFTI parcellation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import nibabel as nib
|
|
6
|
+
import numpy as np
|
|
7
|
+
import pandas as pd
|
|
8
|
+
from nibabel.processing import resample_from_to
|
|
9
|
+
from .models import InputError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def roi_table(path):
|
|
13
|
+
"""Read and validate an optional CSV/TSV ROI mapping.
|
|
14
|
+
|
|
15
|
+
Returns pandas.DataFrame, or None when path is None. Requires unique nonempty
|
|
16
|
+
roi_id. Optional label_value must be unique finite integers; coordinates must
|
|
17
|
+
include all of x/y/z with finite numeric values. Empty text is preserved.
|
|
18
|
+
Exact correspondence to extracted labels is checked later by make_rois."""
|
|
19
|
+
if path is None:
|
|
20
|
+
return None
|
|
21
|
+
frame = pd.read_csv(path, sep=None, engine="python", dtype={"roi_id": str}, keep_default_na=False)
|
|
22
|
+
if "roi_id" not in frame or frame.roi_id.duplicated().any() or (frame.roi_id == "").any():
|
|
23
|
+
raise InputError("ROI table requires unique, nonempty roi_id values.")
|
|
24
|
+
if "label_value" in frame:
|
|
25
|
+
vals = pd.to_numeric(frame.label_value, errors="raise")
|
|
26
|
+
if not np.isfinite(vals).all() or not np.equal(vals, np.rint(vals)).all() or vals.duplicated().any():
|
|
27
|
+
raise InputError("label_value must contain unique finite integers.")
|
|
28
|
+
frame["label_value"] = vals.astype(int)
|
|
29
|
+
if any(x in frame for x in ("x", "y", "z")):
|
|
30
|
+
if (
|
|
31
|
+
not all(x in frame for x in ("x", "y", "z"))
|
|
32
|
+
or not np.isfinite(frame[["x", "y", "z"]].to_numpy(float)).all()
|
|
33
|
+
):
|
|
34
|
+
raise InputError("ROI coordinates require finite x, y, z columns in the declared space (mm).")
|
|
35
|
+
return frame
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def make_rois(ids, names=None, coords=None, table=None, *, by_value=True):
|
|
39
|
+
"""Construct canonical ROI records in the supplied extraction order.
|
|
40
|
+
|
|
41
|
+
Parameters
|
|
42
|
+
----------
|
|
43
|
+
ids : sequence
|
|
44
|
+
Actual integer label values when by_value=True, otherwise string IDs.
|
|
45
|
+
names : sequence of str or None
|
|
46
|
+
Fallback labels; defaults to 'ROI {value}'.
|
|
47
|
+
coords : numpy.ndarray or None
|
|
48
|
+
Fallback (R,3) coordinates in RAS+ mm.
|
|
49
|
+
table : pandas.DataFrame or None
|
|
50
|
+
Validated mapping; name, coordinates and metadata override fallbacks.
|
|
51
|
+
by_value : bool, default True
|
|
52
|
+
Match mapping on label_value, otherwise roi_id; exact ID set is required.
|
|
53
|
+
|
|
54
|
+
Returns
|
|
55
|
+
-------
|
|
56
|
+
list[dict]
|
|
57
|
+
roi_id, label_value, name, abbreviation, hemisphere, network, coordinates.
|
|
58
|
+
For already-parcellated input, label_value is a 1-based ordinal.
|
|
59
|
+
|
|
60
|
+
Raises
|
|
61
|
+
------
|
|
62
|
+
InputError
|
|
63
|
+
Missing/extra mapping IDs or duplicate final roi_id values.
|
|
64
|
+
|
|
65
|
+
Notes
|
|
66
|
+
-----
|
|
67
|
+
Input mapping row order does not reorder results. Fallback hemisphere is based
|
|
68
|
+
on x sign (L/R/M); without coordinates it is M, not an anatomical inference."""
|
|
69
|
+
records = []
|
|
70
|
+
if table is not None:
|
|
71
|
+
column = "label_value" if by_value else "roi_id"
|
|
72
|
+
if column not in table:
|
|
73
|
+
raise InputError(f"ROI table requires {column} for this input.")
|
|
74
|
+
available = set(table[column].tolist())
|
|
75
|
+
if set(ids) != available:
|
|
76
|
+
raise InputError(
|
|
77
|
+
f"ROI mapping must exactly match extracted IDs; expected {list(ids)[:12]} (total {len(ids)})."
|
|
78
|
+
)
|
|
79
|
+
lookup = table.set_index(column, drop=False)
|
|
80
|
+
else:
|
|
81
|
+
lookup = None
|
|
82
|
+
for i, value in enumerate(ids):
|
|
83
|
+
row = lookup.loc[value].to_dict() if lookup is not None else {}
|
|
84
|
+
name = str(row.get("name", names[i] if names is not None else f"ROI {value}"))
|
|
85
|
+
xyz = (
|
|
86
|
+
[float(row[a]) for a in ("x", "y", "z")]
|
|
87
|
+
if all(a in row for a in ("x", "y", "z"))
|
|
88
|
+
else (coords[i].tolist() if coords is not None else None)
|
|
89
|
+
)
|
|
90
|
+
record = {
|
|
91
|
+
"roi_id": str(row.get("roi_id", value)),
|
|
92
|
+
"label_value": int(value) if by_value else i + 1,
|
|
93
|
+
"name": name,
|
|
94
|
+
"abbreviation": str(row.get("abbreviation", name)),
|
|
95
|
+
"hemisphere": str(
|
|
96
|
+
row.get("hemisphere", "L" if xyz and xyz[0] < 0 else "R" if xyz and xyz[0] > 0 else "M")
|
|
97
|
+
),
|
|
98
|
+
"network": str(row.get("network", "")),
|
|
99
|
+
"coordinates": xyz,
|
|
100
|
+
}
|
|
101
|
+
records.append(record)
|
|
102
|
+
if len({r["roi_id"] for r in records}) != len(records):
|
|
103
|
+
raise InputError("ROI identifiers are not unique.")
|
|
104
|
+
return records
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def validate_volume(img, *, ndim=None):
|
|
108
|
+
"""Validate a NiBabel spatial image without resampling it.
|
|
109
|
+
|
|
110
|
+
img must be a SpatialImage with finite invertible affine. Optional ndim checks
|
|
111
|
+
exact dimension count. Requires mm/unknown units and consistent coded qform/
|
|
112
|
+
sform (numpy.allclose atol=1e-3, default rtol). Returns None or raises InputError.
|
|
113
|
+
When ndim=None, this helper does not independently restrict dimension count."""
|
|
114
|
+
if not isinstance(img, nib.spatialimages.SpatialImage) or (ndim is not None and len(img.shape) != ndim):
|
|
115
|
+
raise InputError(f"Expected a {ndim or '3/4'}D spatial image.")
|
|
116
|
+
if (
|
|
117
|
+
img.affine is None
|
|
118
|
+
or not np.isfinite(img.affine).all()
|
|
119
|
+
or abs(np.linalg.det(img.affine[:3, :3])) < 1e-10
|
|
120
|
+
):
|
|
121
|
+
raise InputError("Image affine is missing, nonfinite or singular.")
|
|
122
|
+
if hasattr(img.header, "get_xyzt_units") and img.header.get_xyzt_units()[0] not in {"mm", "unknown"}:
|
|
123
|
+
raise InputError("Spatial image units must be mm; convert metre/micron images before use.")
|
|
124
|
+
if hasattr(img, "get_qform"):
|
|
125
|
+
q, qc = img.get_qform(coded=True)
|
|
126
|
+
s, sc = img.get_sform(coded=True)
|
|
127
|
+
if qc and sc and not np.allclose(q, s, atol=1e-3):
|
|
128
|
+
raise InputError(
|
|
129
|
+
"Conflicting qform/sform: resolve the image's spatial mapping before extraction."
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def label_values(data):
|
|
134
|
+
"""Return sorted positive integer ROI labels from a numeric array.
|
|
135
|
+
|
|
136
|
+
Requires nonnegative, finite integer values; zero is background. Returns a
|
|
137
|
+
1D integer numpy.ndarray with 2 to 2000 labels, otherwise raises InputError.
|
|
138
|
+
Probabilistic atlases are not accepted."""
|
|
139
|
+
if not np.isfinite(data).all() or np.any(data < 0) or not np.equal(data, np.rint(data)).all():
|
|
140
|
+
raise InputError(
|
|
141
|
+
"Atlas must contain nonnegative integer labels (0 is background), not probabilities."
|
|
142
|
+
)
|
|
143
|
+
ids = np.unique(data[data > 0]).astype(int)
|
|
144
|
+
if not 2 <= len(ids) <= 2000:
|
|
145
|
+
raise InputError("Atlas must contain 2–2000 non-background ROIs.")
|
|
146
|
+
return ids
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def volume_timeseries(img, atlas_path, mask_path=None, table=None):
|
|
150
|
+
"""Compute arithmetic ROI means on one 4D NiBabel volume.
|
|
151
|
+
|
|
152
|
+
img is a loaded spatial image; atlas_path is a 3D integer-label filename.
|
|
153
|
+
mask_path optionally restricts labels by positive mask values. table is an
|
|
154
|
+
optional validated pandas ROI mapping. Atlas/mask are nearest-neighbor
|
|
155
|
+
resampled to the BOLD grid; coordinate centroids use the original atlas.
|
|
156
|
+
|
|
157
|
+
Returns (time_x_roi_array, roi_records, image_qc, aligned_atlas_image).
|
|
158
|
+
QC includes voxel counts, resampling flag, orientation, shape and units.
|
|
159
|
+
Reads BOLD in 8-frame slabs, casts slabs to float32 and accumulates means in
|
|
160
|
+
float64. All original atlas ROIs must remain after resampling/masking.
|
|
161
|
+
Raises InputError on geometry/coverage/nonfinite-ROI failures. This low-level
|
|
162
|
+
function does not check named spaces, preprocessing status or temporal settings."""
|
|
163
|
+
validate_volume(img, ndim=4)
|
|
164
|
+
atlas = nib.load(atlas_path)
|
|
165
|
+
validate_volume(atlas, ndim=3)
|
|
166
|
+
original = np.asanyarray(atlas.dataobj)
|
|
167
|
+
ids = label_values(original)
|
|
168
|
+
grid = (img.shape[:3], img.affine)
|
|
169
|
+
# Spatial resampling changes only the atlas grid. It is never registration.
|
|
170
|
+
aligned = resample_from_to(atlas, grid, order=0)
|
|
171
|
+
labels = np.asarray(aligned.dataobj, dtype=np.int32)
|
|
172
|
+
if mask_path is not None:
|
|
173
|
+
mask = nib.load(mask_path)
|
|
174
|
+
validate_volume(mask, ndim=3)
|
|
175
|
+
m = np.asarray(resample_from_to(mask, grid, order=0).dataobj)
|
|
176
|
+
if not np.isfinite(m).all():
|
|
177
|
+
raise InputError("Brain mask contains nonfinite values.")
|
|
178
|
+
labels[m <= 0] = 0
|
|
179
|
+
present = np.unique(labels[labels > 0])
|
|
180
|
+
if not np.array_equal(ids, present):
|
|
181
|
+
raise InputError(
|
|
182
|
+
f"Atlas ROIs absent from BOLD/mask: {sorted(set(ids) - set(present))}. Check coverage and registration."
|
|
183
|
+
)
|
|
184
|
+
coords = np.array(
|
|
185
|
+
[nib.affines.apply_affine(atlas.affine, np.argwhere(original == v).mean(axis=0)) for v in ids]
|
|
186
|
+
)
|
|
187
|
+
flat = labels.ravel()
|
|
188
|
+
selected = np.flatnonzero(flat > 0)
|
|
189
|
+
codes = np.searchsorted(ids, flat[selected])
|
|
190
|
+
counts = np.bincount(codes, minlength=len(ids))
|
|
191
|
+
values = np.empty((img.shape[3], len(ids)), dtype=float)
|
|
192
|
+
# Only a small slab of time points is held in memory, even for long runs.
|
|
193
|
+
for start in range(0, img.shape[3], 8):
|
|
194
|
+
chunk = np.asarray(img.dataobj[..., start : start + 8], dtype=np.float32)
|
|
195
|
+
chunk = chunk.reshape((-1, chunk.shape[-1]))[selected]
|
|
196
|
+
if not np.isfinite(chunk).all():
|
|
197
|
+
raise InputError(
|
|
198
|
+
"Nonfinite BOLD values inside an ROI; repair the input instead of imputing correlations."
|
|
199
|
+
)
|
|
200
|
+
for t in range(chunk.shape[1]):
|
|
201
|
+
values[start + t] = np.bincount(codes, weights=chunk[:, t], minlength=len(ids)) / counts
|
|
202
|
+
rois = make_rois(ids.tolist(), coords=coords, table=table)
|
|
203
|
+
qc = {
|
|
204
|
+
"roi_voxels": counts.tolist(),
|
|
205
|
+
"atlas_resampled": atlas.shape != grid[0] or not np.allclose(atlas.affine, img.affine),
|
|
206
|
+
"orientation": list(nib.aff2axcodes(img.affine)),
|
|
207
|
+
"bold_shape": list(img.shape),
|
|
208
|
+
"spatial_units": "mm (unknown NIfTI units assumed mm)"
|
|
209
|
+
if hasattr(img.header, "get_xyzt_units") and img.header.get_xyzt_units()[0] == "unknown"
|
|
210
|
+
else "mm",
|
|
211
|
+
}
|
|
212
|
+
return values, rois, qc, aligned
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def cifti_timeseries(img, atlas_path=None, table=None):
|
|
216
|
+
"""Extract a loaded CIFTI time series with exact axis matching.
|
|
217
|
+
|
|
218
|
+
img requires SeriesAxis in SECOND followed by BrainModelAxis or ParcelsAxis.
|
|
219
|
+
Dense input requires atlas_path: one LabelAxis map whose BrainModelAxis equals
|
|
220
|
+
the input axis. Parcel input is read directly in ParcelsAxis name order.
|
|
221
|
+
table optionally maps labels/parcel IDs and supplies display coordinates.
|
|
222
|
+
|
|
223
|
+
Returns (time_x_roi_array, roi_records, {'t_r': seconds, 'format':'cifti'}, None).
|
|
224
|
+
Dense data uses 16-frame slabs and unweighted grayordinate means. No CIFTI
|
|
225
|
+
display centroids or surface mesh are inferred. InputError reports invalid
|
|
226
|
+
axes/atlas/nonfinite dense data; pipeline validates parcel signals afterwards.
|
|
227
|
+
Low-level extraction does not validate named spaces/preprocessing status."""
|
|
228
|
+
from nibabel.cifti2.cifti2_axes import BrainModelAxis, LabelAxis, ParcelsAxis, SeriesAxis
|
|
229
|
+
|
|
230
|
+
axes = [img.header.get_axis(i) for i in range(img.ndim)]
|
|
231
|
+
if len(axes) != 2 or not isinstance(axes[0], SeriesAxis):
|
|
232
|
+
raise InputError(
|
|
233
|
+
"CIFTI input must be SeriesAxis × BrainModelAxis (.dtseries) or ParcelsAxis (.ptseries)."
|
|
234
|
+
)
|
|
235
|
+
if axes[0].unit != "SECOND":
|
|
236
|
+
raise InputError("CIFTI time unit must be SECOND.")
|
|
237
|
+
if isinstance(axes[1], ParcelsAxis):
|
|
238
|
+
values = np.asarray(img.dataobj, dtype=float)
|
|
239
|
+
rois = make_rois(axes[1].name.tolist(), axes[1].name.tolist(), table=table, by_value=False)
|
|
240
|
+
elif isinstance(axes[1], BrainModelAxis):
|
|
241
|
+
if atlas_path is None:
|
|
242
|
+
raise InputError(
|
|
243
|
+
"Dense CIFTI needs a matching .dlabel.nii atlas and an ROI coordinate table for 3D."
|
|
244
|
+
)
|
|
245
|
+
atlas = nib.load(atlas_path)
|
|
246
|
+
if (
|
|
247
|
+
not isinstance(atlas, nib.Cifti2Image)
|
|
248
|
+
or not isinstance(atlas.header.get_axis(0), LabelAxis)
|
|
249
|
+
or atlas.shape[0] != 1
|
|
250
|
+
):
|
|
251
|
+
raise InputError("CIFTI atlas must be a single-map .dlabel.nii.")
|
|
252
|
+
if atlas.header.get_axis(1) != axes[1]:
|
|
253
|
+
raise InputError(
|
|
254
|
+
"CIFTI brain-model axes differ; equal array lengths do not establish grayordinate alignment."
|
|
255
|
+
)
|
|
256
|
+
labels = np.asarray(atlas.dataobj).ravel()
|
|
257
|
+
ids = label_values(labels)
|
|
258
|
+
mapping = atlas.header.get_axis(0).label[0]
|
|
259
|
+
if any(int(v) not in mapping for v in ids):
|
|
260
|
+
raise InputError("CIFTI atlas label table is incomplete.")
|
|
261
|
+
values = np.empty((img.shape[0], len(ids)))
|
|
262
|
+
selections = [np.flatnonzero(labels == v) for v in ids]
|
|
263
|
+
for start in range(0, img.shape[0], 16):
|
|
264
|
+
slab = np.asarray(img.dataobj[start : start + 16], dtype=float)
|
|
265
|
+
if not np.isfinite(slab).all():
|
|
266
|
+
raise InputError("CIFTI contains nonfinite values.")
|
|
267
|
+
values[start : start + 16] = np.column_stack([slab[:, ix].mean(axis=1) for ix in selections])
|
|
268
|
+
rois = make_rois(ids.tolist(), [mapping[int(v)][0] for v in ids], table=table)
|
|
269
|
+
else:
|
|
270
|
+
raise InputError("Unsupported CIFTI axis; a connectivity matrix is not an fMRI time series.")
|
|
271
|
+
return values, rois, {"t_r": float(axes[0].step), "format": "cifti"}, None
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def gifti_timeseries(img, atlas_path, table=None):
|
|
275
|
+
"""Extract loaded GIFTI functional arrays using one LABEL-intent atlas.
|
|
276
|
+
|
|
277
|
+
atlas_path must have one label array with matching vertex count. Functional
|
|
278
|
+
arrays must have TIME_SERIES intent: one vertices x time array or one 1D
|
|
279
|
+
vertex array per frame. table optionally supplies label mapping/coordinates.
|
|
280
|
+
Returns (time_x_roi_array, roi_records, {'format':'gifti'}, None).
|
|
281
|
+
Arithmetic means use the complete dense array in memory. TR is not inferred
|
|
282
|
+
from GIFTI metadata. InputError reports incompatible intents/counts or nonfinite
|
|
283
|
+
signals. The caller must establish same hemisphere/template/vertex ordering."""
|
|
284
|
+
if atlas_path is None:
|
|
285
|
+
raise InputError(
|
|
286
|
+
"GIFTI functional data needs a label GIFTI of the SAME hemisphere, surface and vertex order."
|
|
287
|
+
)
|
|
288
|
+
atlas = nib.load(atlas_path)
|
|
289
|
+
if not isinstance(atlas, nib.gifti.GiftiImage) or len(atlas.darrays) != 1:
|
|
290
|
+
raise InputError("GIFTI atlas must contain one label array.")
|
|
291
|
+
if atlas.darrays[0].intent != nib.nifti1.intent_codes["NIFTI_INTENT_LABEL"]:
|
|
292
|
+
raise InputError("GIFTI atlas array must use LABEL intent.")
|
|
293
|
+
labels = np.asarray(atlas.darrays[0].data)
|
|
294
|
+
ids = label_values(labels)
|
|
295
|
+
arrays = [
|
|
296
|
+
np.asarray(d.data)
|
|
297
|
+
for d in img.darrays
|
|
298
|
+
if d.intent == nib.nifti1.intent_codes["NIFTI_INTENT_TIME_SERIES"]
|
|
299
|
+
]
|
|
300
|
+
if not arrays:
|
|
301
|
+
raise InputError("Functional GIFTI arrays must use TIME_SERIES intent.")
|
|
302
|
+
if len(arrays) == 1 and arrays[0].ndim == 2:
|
|
303
|
+
dense = arrays[0].T # GIFTI time series: vertices × time.
|
|
304
|
+
elif all(a.ndim == 1 for a in arrays):
|
|
305
|
+
dense = np.stack(arrays)
|
|
306
|
+
else:
|
|
307
|
+
raise InputError("Use one vertices × time GIFTI array or one vertex array per time point.")
|
|
308
|
+
if dense.shape[1] != len(labels):
|
|
309
|
+
raise InputError("GIFTI vertex counts do not match.")
|
|
310
|
+
if not np.isfinite(dense).all():
|
|
311
|
+
raise InputError("GIFTI contains nonfinite values.")
|
|
312
|
+
lookup = atlas.labeltable.get_labels_as_dict()
|
|
313
|
+
values = np.column_stack([dense[:, labels == v].mean(axis=1) for v in ids])
|
|
314
|
+
return (
|
|
315
|
+
values,
|
|
316
|
+
make_rois(ids.tolist(), [lookup.get(int(v), f"ROI {v}") for v in ids], table=table),
|
|
317
|
+
{"format": "gifti"},
|
|
318
|
+
None,
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def brain_geometry(atlas_img=None, reference=None, *, space="unknown"):
|
|
323
|
+
"""Build a display mesh from atlas coverage or an explicit reference.
|
|
324
|
+
|
|
325
|
+
atlas_img is an optional loaded 3D spatial image; reference is an optional
|
|
326
|
+
3D brain-mask/skull-stripped-reference filename taking precedence. space labels
|
|
327
|
+
the output; this function does not prove alignment to that named space.
|
|
328
|
+
|
|
329
|
+
Returns a dict with space, units='mm', orientation='RAS+', brain (flat positions/
|
|
330
|
+
indices), parcels={} and source. With no image returns an empty mesh, preserving
|
|
331
|
+
coordinate-only visualization. Positive voxels undergo closing/hole filling,
|
|
332
|
+
Gaussian interpolation (sigma=0.65 voxel), marching cubes and RAS+ transform.
|
|
333
|
+
Only display geometry changes; no ROI extraction data are modified.
|
|
334
|
+
InputError reports invalid/empty references. Atlas coverage is not a pial surface."""
|
|
335
|
+
from scipy.ndimage import binary_closing, binary_fill_holes, gaussian_filter
|
|
336
|
+
from skimage.measure import marching_cubes
|
|
337
|
+
|
|
338
|
+
img = nib.load(reference) if reference else atlas_img
|
|
339
|
+
if img is None:
|
|
340
|
+
return {
|
|
341
|
+
"space": space,
|
|
342
|
+
"units": "mm",
|
|
343
|
+
"orientation": "RAS+",
|
|
344
|
+
"brain": {"positions": [], "indices": []},
|
|
345
|
+
"parcels": {},
|
|
346
|
+
"source": "No anatomical surface supplied; ROI coordinates only",
|
|
347
|
+
}
|
|
348
|
+
validate_volume(img, ndim=3)
|
|
349
|
+
data = np.asarray(img.dataobj)
|
|
350
|
+
if not np.isfinite(data).all():
|
|
351
|
+
raise InputError("Reference image contains nonfinite values.")
|
|
352
|
+
# Reference should be a brain mask or skull-stripped T1, in the same space.
|
|
353
|
+
mask = data > 0
|
|
354
|
+
mask = binary_fill_holes(binary_closing(mask, iterations=1))
|
|
355
|
+
if not mask.any():
|
|
356
|
+
raise InputError("Anatomical reference / atlas coverage is empty.")
|
|
357
|
+
# Subvoxel display surface; the mask used for ROI extraction is never smoothed.
|
|
358
|
+
field = gaussian_filter(np.pad(mask, 2).astype(np.float32), sigma=0.65)
|
|
359
|
+
verts, faces, _, _ = marching_cubes(field, level=0.5, step_size=1)
|
|
360
|
+
verts = nib.affines.apply_affine(img.affine, verts - 2)
|
|
361
|
+
signed_volume = np.einsum(
|
|
362
|
+
"ij,ij->i", verts[faces[:, 0]], np.cross(verts[faces[:, 1]], verts[faces[:, 2]])
|
|
363
|
+
).sum()
|
|
364
|
+
if signed_volume < 0:
|
|
365
|
+
faces = faces[:, [0, 2, 1]]
|
|
366
|
+
return {
|
|
367
|
+
"space": space,
|
|
368
|
+
"units": "mm",
|
|
369
|
+
"orientation": "RAS+",
|
|
370
|
+
"brain": {"positions": verts.round(3).ravel().tolist(), "indices": faces.ravel().tolist()},
|
|
371
|
+
"parcels": {},
|
|
372
|
+
"display_surface": "Gaussian mask interpolation (0.65 voxel); display only, ROI coordinates unchanged",
|
|
373
|
+
"source": "Supplied brain mask / skull-stripped reference"
|
|
374
|
+
if reference
|
|
375
|
+
else "Atlas coverage envelope (not a cortical surface)",
|
|
376
|
+
}
|
brainfc/io.py
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""Format inspection and conservative BIDS derivative discovery."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import re
|
|
8
|
+
import nibabel as nib
|
|
9
|
+
import numpy as np
|
|
10
|
+
import pandas as pd
|
|
11
|
+
from scipy.io import loadmat
|
|
12
|
+
from .models import InputError
|
|
13
|
+
|
|
14
|
+
TABLE_SUFFIXES = {".csv", ".tsv", ".txt", ".1d", ".npy", ".npz", ".mat"}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def numeric_table(path, *, header=True, variable=None, transpose=False):
|
|
18
|
+
"""Read a real finite time x ROI table and optional column IDs.
|
|
19
|
+
|
|
20
|
+
Parameters
|
|
21
|
+
----------
|
|
22
|
+
path : str or pathlib.Path
|
|
23
|
+
CSV (comma), TSV (tab), TXT/1D (whitespace), NPY, NPZ or pre-v7.3 MAT.
|
|
24
|
+
header : bool, default True
|
|
25
|
+
Text first row is column names; '#' lines are comments. False retains the
|
|
26
|
+
first numeric row. Binary formats ignore this setting.
|
|
27
|
+
variable : str or None, default None
|
|
28
|
+
NPZ/MAT 2D numeric variable; infer only when exactly one exists.
|
|
29
|
+
transpose : bool, default False
|
|
30
|
+
Transpose values and discard inferred column names.
|
|
31
|
+
|
|
32
|
+
Returns
|
|
33
|
+
-------
|
|
34
|
+
tuple[numpy.ndarray, list[str] or None]
|
|
35
|
+
Float array and column IDs. Requires at least two rows and columns.
|
|
36
|
+
|
|
37
|
+
Raises
|
|
38
|
+
------
|
|
39
|
+
InputError
|
|
40
|
+
Complex, nonfinite, non-2D data, ambiguous variable or unsupported MAT v7.3.
|
|
41
|
+
|
|
42
|
+
Notes
|
|
43
|
+
-----
|
|
44
|
+
Only ROI columns may be supplied; numeric time/index columns are not detected
|
|
45
|
+
automatically. NPY/NPZ use allow_pickle=False. No symmetric-matrix check here."""
|
|
46
|
+
path = Path(path)
|
|
47
|
+
labels = None
|
|
48
|
+
if path.suffix.lower() == ".npy":
|
|
49
|
+
values = np.load(path, allow_pickle=False)
|
|
50
|
+
elif path.suffix.lower() in {".npz", ".mat"}:
|
|
51
|
+
if path.suffix.lower() == ".npz":
|
|
52
|
+
with np.load(path, allow_pickle=False) as archive:
|
|
53
|
+
arrays = {k: archive[k] for k in archive.files}
|
|
54
|
+
else:
|
|
55
|
+
try:
|
|
56
|
+
arrays = loadmat(path)
|
|
57
|
+
except NotImplementedError as exc:
|
|
58
|
+
raise InputError("MAT v7.3/HDF5 is not supported; export a numeric TSV or NPY.") from exc
|
|
59
|
+
keys = [
|
|
60
|
+
k
|
|
61
|
+
for k, v in arrays.items()
|
|
62
|
+
if not k.startswith("__")
|
|
63
|
+
and isinstance(v, np.ndarray)
|
|
64
|
+
and v.ndim == 2
|
|
65
|
+
and np.issubdtype(v.dtype, np.number)
|
|
66
|
+
]
|
|
67
|
+
if variable is None:
|
|
68
|
+
if len(keys) != 1:
|
|
69
|
+
raise InputError(f"Choose variable explicitly from {keys}.")
|
|
70
|
+
variable = keys[0]
|
|
71
|
+
if variable not in keys:
|
|
72
|
+
raise InputError(f"No 2D numeric variable {variable!r}; available: {keys}.")
|
|
73
|
+
values = arrays[variable]
|
|
74
|
+
else:
|
|
75
|
+
sep = "," if path.suffix.lower() == ".csv" else "\t" if path.suffix.lower() == ".tsv" else r"\s+"
|
|
76
|
+
frame = pd.read_csv(path, sep=sep, header=0 if header else None, comment="#")
|
|
77
|
+
labels = frame.columns.astype(str).tolist() if header else None
|
|
78
|
+
try:
|
|
79
|
+
values = frame.to_numpy(dtype=float)
|
|
80
|
+
except ValueError as exc:
|
|
81
|
+
raise InputError(
|
|
82
|
+
"Time-series table must contain only numeric ROI columns, without a time/index column."
|
|
83
|
+
) from exc
|
|
84
|
+
if np.iscomplexobj(values):
|
|
85
|
+
raise InputError("Complex-valued time series are unsupported.")
|
|
86
|
+
values = np.asarray(values, dtype=float)
|
|
87
|
+
if transpose:
|
|
88
|
+
values, labels = values.T, None
|
|
89
|
+
if values.ndim != 2 or min(values.shape) < 2 or not np.isfinite(values).all():
|
|
90
|
+
raise InputError("Expected a finite time × ROI matrix with at least two rows and columns.")
|
|
91
|
+
return values, labels
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def sidecar_path(path):
|
|
95
|
+
"""Return the adjacent JSON candidate for a source filename.
|
|
96
|
+
|
|
97
|
+
For *.nii[.gz], *.dtseries.nii and *.ptseries.nii replace the imaging suffix
|
|
98
|
+
with .json. For other formats append .json (for example signals.tsv.json).
|
|
99
|
+
Returns pathlib.Path without checking existence; BIDS inheritance is not used."""
|
|
100
|
+
p = Path(path)
|
|
101
|
+
return p.with_name(re.sub(r"\.(dtseries\.nii|ptseries\.nii|nii(\.gz)?)$", "", p.name) + ".json")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def metadata(path):
|
|
105
|
+
"""Read adjacent JSON RepetitionTime and the filename's BIDS space entity.
|
|
106
|
+
|
|
107
|
+
Returns a dict with sidecar (absolute path or None), t_r (seconds or None),
|
|
108
|
+
and space (str or None). Missing JSON yields no TR; malformed JSON propagates
|
|
109
|
+
JSONDecodeError. Header TR is handled by check_input/extract_connectome."""
|
|
110
|
+
path = Path(path)
|
|
111
|
+
sidecar = sidecar_path(path)
|
|
112
|
+
content = json.loads(sidecar.read_text(encoding="utf-8-sig")) if sidecar.is_file() else {}
|
|
113
|
+
space = re.search(r"(?:^|_)space-([^_]+)", path.name)
|
|
114
|
+
return {
|
|
115
|
+
"sidecar": str(sidecar.resolve()) if sidecar.is_file() else None,
|
|
116
|
+
"t_r": content.get("RepetitionTime"),
|
|
117
|
+
"space": space.group(1) if space else None,
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def inspect_input(path):
|
|
122
|
+
"""Inspect file structure or discover preprocessed volume runs in a directory.
|
|
123
|
+
|
|
124
|
+
Parameters
|
|
125
|
+
----------
|
|
126
|
+
path : str or pathlib.Path
|
|
127
|
+
Existing source file or derivative directory.
|
|
128
|
+
|
|
129
|
+
Returns
|
|
130
|
+
-------
|
|
131
|
+
dict
|
|
132
|
+
Common file keys: path, size_bytes, sidecar, t_r, space, format.
|
|
133
|
+
Volume adds shape/affine/orientation/units when available; CIFTI shape/axes;
|
|
134
|
+
GIFTI array shapes; NPZ/MAT variable shapes. Directory returns format/runs.
|
|
135
|
+
|
|
136
|
+
Raises
|
|
137
|
+
------
|
|
138
|
+
InputError
|
|
139
|
+
Missing path or unreadable image. Low-level table/JSON errors may propagate.
|
|
140
|
+
|
|
141
|
+
Notes
|
|
142
|
+
-----
|
|
143
|
+
Inspection alone does not validate numerical signals, ROI coverage or cleaning
|
|
144
|
+
feasibility. Use check_input/preflight or extract_connectome for deeper checks."""
|
|
145
|
+
path = Path(path).expanduser().resolve()
|
|
146
|
+
if not path.exists():
|
|
147
|
+
raise InputError(f"File does not exist: {path}")
|
|
148
|
+
if path.is_dir():
|
|
149
|
+
return {"format": "directory", "runs": discover_bids(path)}
|
|
150
|
+
result = {"path": str(path), "size_bytes": path.stat().st_size, **metadata(path)}
|
|
151
|
+
if path.suffix.lower() in TABLE_SUFFIXES:
|
|
152
|
+
result["format"] = "timeseries-table"
|
|
153
|
+
if path.suffix.lower() == ".npz":
|
|
154
|
+
with np.load(path, allow_pickle=False) as a:
|
|
155
|
+
result["variables"] = {k: list(a[k].shape) for k in a.files}
|
|
156
|
+
elif path.suffix.lower() == ".mat":
|
|
157
|
+
from scipy.io import whosmat
|
|
158
|
+
|
|
159
|
+
result["variables"] = {name: list(shape) for name, shape, _ in whosmat(path)}
|
|
160
|
+
return result
|
|
161
|
+
try:
|
|
162
|
+
img = nib.load(path)
|
|
163
|
+
except Exception as exc:
|
|
164
|
+
raise InputError("Cannot read image. Convert DICOM with brainfc dicom first.") from exc
|
|
165
|
+
if isinstance(img, nib.Cifti2Image):
|
|
166
|
+
result.update(
|
|
167
|
+
format="cifti",
|
|
168
|
+
shape=list(img.shape),
|
|
169
|
+
axes=[type(img.header.get_axis(i)).__name__ for i in range(img.ndim)],
|
|
170
|
+
)
|
|
171
|
+
elif isinstance(img, nib.gifti.GiftiImage):
|
|
172
|
+
result.update(format="gifti", arrays=[list(d.data.shape) for d in img.darrays])
|
|
173
|
+
else:
|
|
174
|
+
result.update(
|
|
175
|
+
format="volume",
|
|
176
|
+
shape=list(img.shape),
|
|
177
|
+
orientation=list(nib.aff2axcodes(img.affine)),
|
|
178
|
+
affine=img.affine.tolist(),
|
|
179
|
+
)
|
|
180
|
+
if hasattr(img.header, "get_xyzt_units"):
|
|
181
|
+
result["units"] = list(img.header.get_xyzt_units())
|
|
182
|
+
return result
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def discover_bids(root):
|
|
186
|
+
"""List separate fMRIPrep preprocessed volume runs below root.
|
|
187
|
+
|
|
188
|
+
Parameters
|
|
189
|
+
----------
|
|
190
|
+
root : str or pathlib.Path
|
|
191
|
+
Recursively scanned for *_desc-preproc_bold.nii* in sorted path order.
|
|
192
|
+
|
|
193
|
+
Returns
|
|
194
|
+
-------
|
|
195
|
+
list[dict]
|
|
196
|
+
Each record has bold, confounds, mask, subject, session, task, run, sidecar,
|
|
197
|
+
t_r, space. Missing companions/entities are None. No matches yields [].
|
|
198
|
+
|
|
199
|
+
Raises
|
|
200
|
+
------
|
|
201
|
+
InputError
|
|
202
|
+
More than one matching mask or confounds file makes a run ambiguous.
|
|
203
|
+
|
|
204
|
+
Notes
|
|
205
|
+
-----
|
|
206
|
+
Companions must be in the same directory with exactly matching entities after
|
|
207
|
+
excluding space/res/den/desc; masks additionally match space/res. No merging,
|
|
208
|
+
raw BIDS conversion, multi-echo combination or full BIDS metadata inheritance."""
|
|
209
|
+
root = Path(root).expanduser().resolve()
|
|
210
|
+
result = []
|
|
211
|
+
paths = sorted(set(root.rglob("*_desc-preproc_bold.nii*")))
|
|
212
|
+
for p in paths:
|
|
213
|
+
entities = dict(re.findall(r"(?:^|_)([a-zA-Z0-9]+)-([^_]+)", p.name.split(".")[0]))
|
|
214
|
+
key = {k: v for k, v in entities.items() if k not in {"space", "res", "den", "desc"}}
|
|
215
|
+
|
|
216
|
+
def candidates(pattern, spatial=False):
|
|
217
|
+
hits = []
|
|
218
|
+
for q in p.parent.glob(pattern):
|
|
219
|
+
other = dict(re.findall(r"(?:^|_)([a-zA-Z0-9]+)-([^_]+)", q.name.split(".")[0]))
|
|
220
|
+
otherkey = {k: v for k, v in other.items() if k not in {"space", "res", "den", "desc"}}
|
|
221
|
+
if key == otherkey and (
|
|
222
|
+
not spatial or all(other.get(k) == entities.get(k) for k in ("space", "res"))
|
|
223
|
+
):
|
|
224
|
+
hits.append(q)
|
|
225
|
+
if len(hits) > 1:
|
|
226
|
+
raise InputError(f"Ambiguous companions for {p.name}: {[x.name for x in hits]}")
|
|
227
|
+
return str(hits[0]) if hits else None
|
|
228
|
+
|
|
229
|
+
result.append(
|
|
230
|
+
{
|
|
231
|
+
"bold": str(p),
|
|
232
|
+
"confounds": candidates("*_desc-confounds_timeseries.tsv"),
|
|
233
|
+
"mask": candidates("*_desc-brain_mask.nii*", True),
|
|
234
|
+
"subject": entities.get("sub"),
|
|
235
|
+
"session": entities.get("ses"),
|
|
236
|
+
"task": entities.get("task"),
|
|
237
|
+
"run": entities.get("run"),
|
|
238
|
+
**metadata(p),
|
|
239
|
+
}
|
|
240
|
+
)
|
|
241
|
+
return result
|