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