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/__init__.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""An explicit, reproducible fMRI-to-connectome API."""
|
|
2
|
+
|
|
3
|
+
from .models import Config, Connectome, InputError
|
|
4
|
+
from .pipeline import extract_connectome
|
|
5
|
+
from .io import inspect_input, discover_bids
|
|
6
|
+
from .atlases import fetch_atlas
|
|
7
|
+
from .presets import dataset_presets, dataset_preset
|
|
8
|
+
from ._version import __version__ as __version__
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"Config",
|
|
12
|
+
"Connectome",
|
|
13
|
+
"InputError",
|
|
14
|
+
"extract_connectome",
|
|
15
|
+
"inspect_input",
|
|
16
|
+
"discover_bids",
|
|
17
|
+
"fetch_atlas",
|
|
18
|
+
"dataset_presets",
|
|
19
|
+
"dataset_preset",
|
|
20
|
+
]
|
brainfc/__main__.py
ADDED
brainfc/_version.py
ADDED
brainfc/atlases.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Explicit atlas downloads, with label values and template spaces preserved."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import json
|
|
5
|
+
import numpy as np
|
|
6
|
+
import pandas as pd
|
|
7
|
+
from .models import InputError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def fetch_atlas(name="schaefer100", *, data_dir=None):
|
|
11
|
+
"""Explicitly download/cache one supported integer-label atlas.
|
|
12
|
+
|
|
13
|
+
Parameters
|
|
14
|
+
----------
|
|
15
|
+
name : {'schaefer100','schaefer200','schaefer400','aal116'}
|
|
16
|
+
Default 'schaefer100'. Schaefer: 7 networks, 2 mm, MNI152NLin6Asym.
|
|
17
|
+
AAL: SPM12 116 regions, MNIColin27. These spaces are not interchangeable.
|
|
18
|
+
data_dir : str, pathlib.Path or None, default None
|
|
19
|
+
Cache root; default ~/.cache/brainfc/atlases.
|
|
20
|
+
|
|
21
|
+
Returns
|
|
22
|
+
-------
|
|
23
|
+
dict
|
|
24
|
+
atlas (image path), rois (TSV path), space, name, source URL, n_rois.
|
|
25
|
+
|
|
26
|
+
Notes
|
|
27
|
+
-----
|
|
28
|
+
Creates cache directories; Nilearn downloads only as required. Regenerates
|
|
29
|
+
name_rois.tsv and name.json in that cache. ROI centroid coordinates are mm
|
|
30
|
+
from the atlas affine. Network/download failures propagate. No download at
|
|
31
|
+
import time; dataset/atlas licenses remain those of their providers."""
|
|
32
|
+
from nilearn.datasets import fetch_atlas_schaefer_2018, fetch_atlas_aal
|
|
33
|
+
|
|
34
|
+
root = Path(data_dir or Path.home() / ".cache" / "brainfc" / "atlases").resolve()
|
|
35
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
36
|
+
if name in {"schaefer100", "schaefer200", "schaefer400"}:
|
|
37
|
+
n = int(name.replace("schaefer", ""))
|
|
38
|
+
atlas = fetch_atlas_schaefer_2018(n_rois=n, yeo_networks=7, resolution_mm=2, data_dir=root, verbose=0)
|
|
39
|
+
space = "MNI152NLin6Asym"
|
|
40
|
+
labels = [v.decode() if isinstance(v, bytes) else str(v) for v in atlas.labels]
|
|
41
|
+
# New Nilearn LUTs include background; use the LUT as the source of IDs.
|
|
42
|
+
if hasattr(atlas, "lut"):
|
|
43
|
+
lut = atlas.lut
|
|
44
|
+
rows = [
|
|
45
|
+
{
|
|
46
|
+
"label_value": int(r["index"]),
|
|
47
|
+
"roi_id": str(int(r["index"])),
|
|
48
|
+
"name": str(r["name"]),
|
|
49
|
+
"network": str(r["name"]).split("_")[2] if len(str(r["name"]).split("_")) > 2 else "",
|
|
50
|
+
}
|
|
51
|
+
for _, r in lut.iterrows()
|
|
52
|
+
if int(r["index"]) != 0
|
|
53
|
+
]
|
|
54
|
+
else:
|
|
55
|
+
labels = [v for v in labels if v.lower() != "background"]
|
|
56
|
+
rows = [{"label_value": i + 1, "roi_id": str(i + 1), "name": v} for i, v in enumerate(labels)]
|
|
57
|
+
source = "https://github.com/ThomasYeoLab/CBIG/tree/master/stable_projects/brain_parcellation/Schaefer2018_LocalGlobal"
|
|
58
|
+
elif name == "aal116":
|
|
59
|
+
atlas = fetch_atlas_aal(version="SPM12", data_dir=root, verbose=0)
|
|
60
|
+
space = "MNIColin27"
|
|
61
|
+
rows = [
|
|
62
|
+
{"label_value": int(i), "roi_id": str(i), "name": str(v)}
|
|
63
|
+
for i, v in zip(atlas.indices, atlas.labels)
|
|
64
|
+
if int(i) != 0
|
|
65
|
+
]
|
|
66
|
+
source = "https://www.gin.cnrs.fr/en/tools/aal/"
|
|
67
|
+
else:
|
|
68
|
+
raise InputError("Available atlases: schaefer100, schaefer200, schaefer400, aal116.")
|
|
69
|
+
frame = pd.DataFrame(rows)
|
|
70
|
+
import nibabel as nib
|
|
71
|
+
|
|
72
|
+
img = nib.load(atlas.maps)
|
|
73
|
+
data = np.asarray(img.dataobj)
|
|
74
|
+
xyz = [
|
|
75
|
+
nib.affines.apply_affine(img.affine, np.argwhere(data == v).mean(axis=0)) for v in frame.label_value
|
|
76
|
+
]
|
|
77
|
+
frame[["x", "y", "z"]] = np.asarray(xyz)
|
|
78
|
+
labels_path = root / f"{name}_rois.tsv"
|
|
79
|
+
frame.to_csv(labels_path, sep="\t", index=False)
|
|
80
|
+
result = {
|
|
81
|
+
"atlas": str(Path(atlas.maps).resolve()),
|
|
82
|
+
"rois": str(labels_path),
|
|
83
|
+
"space": space,
|
|
84
|
+
"name": name,
|
|
85
|
+
"source": source,
|
|
86
|
+
"n_rois": len(frame),
|
|
87
|
+
}
|
|
88
|
+
(root / f"{name}.json").write_text(json.dumps(result, indent=2), encoding="utf-8")
|
|
89
|
+
return result
|
brainfc/cli.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import argparse
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import threading
|
|
6
|
+
import webbrowser
|
|
7
|
+
from .models import Config, InputError
|
|
8
|
+
from ._version import __version__
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _parser():
|
|
12
|
+
parser = argparse.ArgumentParser(
|
|
13
|
+
prog="brainfc", description="fMRI → ROI time series → functional connectivity"
|
|
14
|
+
)
|
|
15
|
+
parser.add_argument("--version", action="version", version=f"brainfc {__version__}")
|
|
16
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
17
|
+
serve = sub.add_parser("serve", help="Start the local graphical interface")
|
|
18
|
+
serve.add_argument("--port", type=int, default=8766)
|
|
19
|
+
serve.add_argument("--workspace", type=Path)
|
|
20
|
+
serve.add_argument("--no-browser", action="store_true")
|
|
21
|
+
inspect = sub.add_parser("inspect", help="Inspect an image or discover BIDS derivatives")
|
|
22
|
+
inspect.add_argument("source")
|
|
23
|
+
fetch = sub.add_parser("atlas", help="Explicitly download a supported standard atlas")
|
|
24
|
+
fetch.add_argument("name", choices=["schaefer100", "schaefer200", "schaefer400", "aal116"])
|
|
25
|
+
fetch.add_argument("--data-dir", type=Path)
|
|
26
|
+
demo = sub.add_parser("demo", help="Create synthetic NIfTI and extract a complete example report")
|
|
27
|
+
demo.add_argument("--output", type=Path, required=True)
|
|
28
|
+
extract = sub.add_parser("extract", help="Extract one run")
|
|
29
|
+
extract.add_argument("source")
|
|
30
|
+
for key in ("atlas", "rois", "confounds", "mask", "reference"):
|
|
31
|
+
extract.add_argument("--" + key)
|
|
32
|
+
extract.add_argument("--config", type=Path, help="Config JSON")
|
|
33
|
+
extract.add_argument("--output", type=Path, required=True)
|
|
34
|
+
extract.add_argument("--preprocessed", action="store_true", default=None)
|
|
35
|
+
extract.add_argument("--data-space")
|
|
36
|
+
extract.add_argument("--atlas-space")
|
|
37
|
+
extract.add_argument("--tr", type=float)
|
|
38
|
+
extract.add_argument("--no-report", action="store_true")
|
|
39
|
+
extract.add_argument("--no-figures", action="store_true")
|
|
40
|
+
batch = sub.add_parser("batch", help="Extract each fMRIPrep run separately; failed runs are recorded")
|
|
41
|
+
batch.add_argument("bids_dir", type=Path)
|
|
42
|
+
batch.add_argument("--atlas", required=True)
|
|
43
|
+
batch.add_argument("--rois")
|
|
44
|
+
batch.add_argument("--config", type=Path, required=True)
|
|
45
|
+
batch.add_argument("--output", type=Path, required=True)
|
|
46
|
+
dicom = sub.add_parser("dicom", help="Plan or run dcm2niix conversion")
|
|
47
|
+
dicom.add_argument("source")
|
|
48
|
+
dicom.add_argument("--output", required=True)
|
|
49
|
+
dicom.add_argument("--run", action="store_true")
|
|
50
|
+
preproc = sub.add_parser("preprocess", help="Plan or run external fMRIPrep (requires Docker)")
|
|
51
|
+
preproc.add_argument("bids_dir")
|
|
52
|
+
preproc.add_argument("--output", required=True)
|
|
53
|
+
preproc.add_argument("--license", required=True)
|
|
54
|
+
preproc.add_argument("--participant")
|
|
55
|
+
preproc.add_argument("--space", default="MNI152NLin6Asym")
|
|
56
|
+
preproc.add_argument("--run", action="store_true")
|
|
57
|
+
return parser
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def main(argv=None):
|
|
61
|
+
"""Run the CLI with a list of arguments, or sys.argv when argv is None.
|
|
62
|
+
|
|
63
|
+
Returns 0 on success. argparse raises SystemExit(0) for help/version and
|
|
64
|
+
SystemExit(2) for usage, InputError and common path errors. Unexpected
|
|
65
|
+
library/external-process errors propagate; a failed batch exits with 2
|
|
66
|
+
after preserving successful run outputs and batch.json.
|
|
67
|
+
"""
|
|
68
|
+
parser = _parser()
|
|
69
|
+
args = parser.parse_args(argv)
|
|
70
|
+
try:
|
|
71
|
+
if args.command == "serve":
|
|
72
|
+
if not 1 <= args.port <= 65535:
|
|
73
|
+
raise InputError("Port must be between 1 and 65535.")
|
|
74
|
+
try:
|
|
75
|
+
import uvicorn
|
|
76
|
+
from .web.app import create_app
|
|
77
|
+
except ImportError as exc:
|
|
78
|
+
raise InputError(
|
|
79
|
+
"The installation is incomplete. Reinstall the BrainFC wheel or source "
|
|
80
|
+
"with dependencies in this Python environment."
|
|
81
|
+
) from exc
|
|
82
|
+
url = f"http://127.0.0.1:{args.port}"
|
|
83
|
+
if not args.no_browser:
|
|
84
|
+
timer = threading.Timer(1.5, lambda: webbrowser.open(url))
|
|
85
|
+
timer.daemon = True
|
|
86
|
+
timer.start()
|
|
87
|
+
print(f"BrainFC: {url}", flush=True)
|
|
88
|
+
uvicorn.run(create_app(args.workspace), host="127.0.0.1", port=args.port)
|
|
89
|
+
elif args.command == "inspect":
|
|
90
|
+
from .io import inspect_input
|
|
91
|
+
|
|
92
|
+
print(json.dumps(inspect_input(args.source), ensure_ascii=False, indent=2))
|
|
93
|
+
elif args.command == "atlas":
|
|
94
|
+
from .atlases import fetch_atlas
|
|
95
|
+
|
|
96
|
+
print(json.dumps(fetch_atlas(args.name, data_dir=args.data_dir), ensure_ascii=False, indent=2))
|
|
97
|
+
elif args.command == "demo":
|
|
98
|
+
from .demo import create_demo
|
|
99
|
+
from .pipeline import extract_connectome
|
|
100
|
+
|
|
101
|
+
args.output.mkdir(parents=True, exist_ok=False)
|
|
102
|
+
spec = create_demo(args.output / "input")
|
|
103
|
+
config = Config(**spec.pop("config"))
|
|
104
|
+
result = extract_connectome(**spec, config=config, progress=print)
|
|
105
|
+
result.provenance["synthetic"] = True
|
|
106
|
+
print(result.save(args.output / "result"))
|
|
107
|
+
elif args.command == "extract":
|
|
108
|
+
from .pipeline import extract_connectome
|
|
109
|
+
|
|
110
|
+
config = json.loads(args.config.read_text(encoding="utf-8-sig")) if args.config else {}
|
|
111
|
+
for key in ("preprocessed", "data_space", "atlas_space"):
|
|
112
|
+
if getattr(args, key) is not None:
|
|
113
|
+
config[key] = getattr(args, key)
|
|
114
|
+
if args.tr is not None:
|
|
115
|
+
config["t_r"] = args.tr
|
|
116
|
+
result = extract_connectome(
|
|
117
|
+
args.source,
|
|
118
|
+
config=Config(**config),
|
|
119
|
+
progress=print,
|
|
120
|
+
**{k: getattr(args, k) for k in ("atlas", "rois", "confounds", "mask", "reference")},
|
|
121
|
+
)
|
|
122
|
+
print(result.save(args.output, figures=not args.no_figures, report=not args.no_report))
|
|
123
|
+
elif args.command == "batch":
|
|
124
|
+
from .io import discover_bids
|
|
125
|
+
from .pipeline import extract_connectome
|
|
126
|
+
|
|
127
|
+
runs = discover_bids(args.bids_dir)
|
|
128
|
+
if not runs:
|
|
129
|
+
raise InputError("No fMRIPrep preprocessed volume runs found.")
|
|
130
|
+
args.output.mkdir(parents=True, exist_ok=False)
|
|
131
|
+
config = Config(**json.loads(args.config.read_text(encoding="utf-8-sig")))
|
|
132
|
+
records = []
|
|
133
|
+
for i, run in enumerate(runs):
|
|
134
|
+
record = {
|
|
135
|
+
"index": i,
|
|
136
|
+
"source": run["bold"],
|
|
137
|
+
"subject": run["subject"],
|
|
138
|
+
"session": run["session"],
|
|
139
|
+
"run": run["run"],
|
|
140
|
+
}
|
|
141
|
+
try:
|
|
142
|
+
result = extract_connectome(
|
|
143
|
+
run["bold"],
|
|
144
|
+
atlas=args.atlas,
|
|
145
|
+
rois=args.rois,
|
|
146
|
+
confounds=run["confounds"],
|
|
147
|
+
mask=run["mask"],
|
|
148
|
+
config=config,
|
|
149
|
+
progress=print,
|
|
150
|
+
)
|
|
151
|
+
destination = args.output / f"run-{i:04d}"
|
|
152
|
+
result.save(destination)
|
|
153
|
+
record.update(status="complete", output=str(destination.resolve()))
|
|
154
|
+
except Exception as exc:
|
|
155
|
+
record.update(status="failed", error=str(exc))
|
|
156
|
+
records.append(record)
|
|
157
|
+
(args.output / "batch.json").write_text(
|
|
158
|
+
json.dumps(records, ensure_ascii=False, indent=2), encoding="utf-8"
|
|
159
|
+
)
|
|
160
|
+
if any(r["status"] == "failed" for r in records):
|
|
161
|
+
raise InputError("Some runs failed. Inspect batch.json; successful results are preserved.")
|
|
162
|
+
elif args.command == "dicom":
|
|
163
|
+
from .preprocessing import dicom_plan, convert_dicom
|
|
164
|
+
|
|
165
|
+
plan = dicom_plan(args.source, args.output)
|
|
166
|
+
print(json.dumps(plan.to_dict(), ensure_ascii=False, indent=2))
|
|
167
|
+
if args.run:
|
|
168
|
+
convert_dicom(args.source, args.output)
|
|
169
|
+
else:
|
|
170
|
+
from .preprocessing import fmriprep_plan
|
|
171
|
+
|
|
172
|
+
plan = fmriprep_plan(
|
|
173
|
+
args.bids_dir, args.output, args.license, participant=args.participant, space=args.space
|
|
174
|
+
)
|
|
175
|
+
print(json.dumps(plan.to_dict(), ensure_ascii=False, indent=2))
|
|
176
|
+
if args.run:
|
|
177
|
+
plan.run()
|
|
178
|
+
return 0
|
|
179
|
+
except (InputError, FileExistsError, FileNotFoundError) as exc:
|
|
180
|
+
parser.error(str(exc))
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
if __name__ == "__main__":
|
|
184
|
+
raise SystemExit(main())
|
brainfc/demo.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Deterministic synthetic volume, never represented as human subject data."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import json
|
|
5
|
+
import nibabel as nib
|
|
6
|
+
import numpy as np
|
|
7
|
+
import pandas as pd
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def create_demo(directory):
|
|
11
|
+
"""Create deterministic synthetic NIfTI inputs in a new directory.
|
|
12
|
+
|
|
13
|
+
Returns a dict of source/atlas/rois/confounds paths plus a plain config dict.
|
|
14
|
+
Use Config(**spec.pop('config')) before passing spec to extract_connectome.
|
|
15
|
+
Seed 42, 160 frames, 12 artificial ROIs, TR=2 s and synthetic-demo space.
|
|
16
|
+
Creates input files only; does not extract a result or download human data.
|
|
17
|
+
Existing directory raises FileExistsError. Mark provenance['synthetic']=True
|
|
18
|
+
when exporting an extracted demo (the CLI/GUI demo commands already do so)."""
|
|
19
|
+
root = Path(directory).resolve()
|
|
20
|
+
root.mkdir(parents=True, exist_ok=False)
|
|
21
|
+
rng = np.random.default_rng(42)
|
|
22
|
+
shape, n = (32, 40, 32), 160
|
|
23
|
+
affine = np.diag([5.0, 5.0, 5.0, 1.0])
|
|
24
|
+
affine[:3, 3] = [-80, -110, -75]
|
|
25
|
+
ijk = np.indices(shape).transpose(1, 2, 3, 0)
|
|
26
|
+
xyz = nib.affines.apply_affine(affine, ijk)
|
|
27
|
+
centres = np.array(
|
|
28
|
+
[
|
|
29
|
+
[x, y, z]
|
|
30
|
+
for x in [-35, 35]
|
|
31
|
+
for y, z in [(-60, 0), (-30, 35), (15, 45), (40, 0), (-10, -20), (-55, 40)]
|
|
32
|
+
]
|
|
33
|
+
)
|
|
34
|
+
atlas = np.zeros(shape, dtype=np.int16)
|
|
35
|
+
latent = rng.normal(size=(n, 3))
|
|
36
|
+
for i, center in enumerate(centres):
|
|
37
|
+
atlas[np.linalg.norm(xyz - center, axis=-1) < 15] = (i + 1) * 10
|
|
38
|
+
data = np.zeros((*shape, n), dtype=np.float32)
|
|
39
|
+
motion = rng.normal(0, 0.04, (n, 6))
|
|
40
|
+
for i in range(len(centres)):
|
|
41
|
+
signal = latent[:, i % 3] * (1 if i < 6 else -0.7) + rng.normal(0, 0.55, n) + motion[:, 0] * 4
|
|
42
|
+
index = atlas == (i + 1) * 10
|
|
43
|
+
data[index] = 100 + signal + rng.normal(0, 0.12, (index.sum(), n))
|
|
44
|
+
for name, a in [("demo_bold.nii.gz", data), ("demo_atlas.nii.gz", atlas)]:
|
|
45
|
+
image = nib.Nifti1Image(a, affine)
|
|
46
|
+
image.header.set_xyzt_units("mm", "sec")
|
|
47
|
+
if a.ndim == 4:
|
|
48
|
+
image.header.set_zooms((5, 5, 5, 2))
|
|
49
|
+
nib.save(image, root / name)
|
|
50
|
+
pd.DataFrame(
|
|
51
|
+
{
|
|
52
|
+
"label_value": [(i + 1) * 10 for i in range(len(centres))],
|
|
53
|
+
"roi_id": [f"Demo{i + 1:02d}" for i in range(len(centres))],
|
|
54
|
+
"name": [f"Synthetic ROI {i + 1:02d}" for i in range(len(centres))],
|
|
55
|
+
"network": [f"Synthetic group {i % 3 + 1}" for i in range(len(centres))],
|
|
56
|
+
}
|
|
57
|
+
).to_csv(root / "rois.tsv", sep="\t", index=False)
|
|
58
|
+
frame = pd.DataFrame(motion, columns=[f"trans_{a}" for a in "xyz"] + [f"rot_{a}" for a in "xyz"])
|
|
59
|
+
frame["framewise_displacement"] = np.abs(rng.normal(0.08, 0.02, n))
|
|
60
|
+
frame.loc[[23, 74, 105], "framewise_displacement"] = 0.8
|
|
61
|
+
frame.to_csv(root / "confounds.tsv", sep="\t", index=False)
|
|
62
|
+
(root / "demo_bold.json").write_text(
|
|
63
|
+
json.dumps({"RepetitionTime": 2, "Synthetic": True}), encoding="utf-8"
|
|
64
|
+
)
|
|
65
|
+
return {
|
|
66
|
+
"source": str(root / "demo_bold.nii.gz"),
|
|
67
|
+
"atlas": str(root / "demo_atlas.nii.gz"),
|
|
68
|
+
"rois": str(root / "rois.tsv"),
|
|
69
|
+
"confounds": str(root / "confounds.tsv"),
|
|
70
|
+
"config": {
|
|
71
|
+
"preprocessed": True,
|
|
72
|
+
"data_space": "synthetic-demo",
|
|
73
|
+
"atlas_space": "synthetic-demo",
|
|
74
|
+
"t_r": 2,
|
|
75
|
+
"fd_threshold": 0.5,
|
|
76
|
+
},
|
|
77
|
+
}
|
brainfc/export.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import base64
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
import hashlib
|
|
5
|
+
from importlib.resources import files
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import shutil
|
|
9
|
+
import tempfile
|
|
10
|
+
import numpy as np
|
|
11
|
+
import pandas as pd
|
|
12
|
+
from .models import InputError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _json(data):
|
|
16
|
+
return json.dumps(data, ensure_ascii=False, indent=2, allow_nan=False)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def write_report(result, path, *, view_image=None):
|
|
20
|
+
"""Write self-contained HTML and return its absolute pathlib.Path.
|
|
21
|
+
|
|
22
|
+
result is a Connectome; path must not exist. Optional view_image is a PNG path
|
|
23
|
+
embedded as the initial static preview. Parent directories are created.
|
|
24
|
+
Interactive JS/CSS and result data are embedded, with JSON escaped for safe
|
|
25
|
+
script embedding. Raises FileExistsError on overwrite or InputError if bundled
|
|
26
|
+
assets are missing. Does not open a browser; see Connectome.view."""
|
|
27
|
+
target = Path(path).expanduser().resolve()
|
|
28
|
+
if target.exists():
|
|
29
|
+
raise FileExistsError(f"Refusing to overwrite {target}")
|
|
30
|
+
static = files("brainfc").joinpath("web", "static")
|
|
31
|
+
js = static.joinpath("app.js")
|
|
32
|
+
if not js.is_file():
|
|
33
|
+
raise InputError("Packaged UI is missing. Build frontend assets before building the wheel.")
|
|
34
|
+
payload = result.to_dict()
|
|
35
|
+
if view_image:
|
|
36
|
+
payload["views_image"] = "data:image/png;base64," + base64.b64encode(
|
|
37
|
+
Path(view_image).read_bytes()
|
|
38
|
+
).decode("ascii")
|
|
39
|
+
data = _json(payload).replace("<", "\\u003c").replace("\u2028", "\\u2028").replace("\u2029", "\\u2029")
|
|
40
|
+
script = js.read_text(encoding="utf-8").replace("</script", "<\\/script")
|
|
41
|
+
css = static.joinpath("app.css").read_text(encoding="utf-8")
|
|
42
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
target.write_text(
|
|
44
|
+
'<!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">'
|
|
45
|
+
'<link rel="icon" href="data:,"><title>BrainFC · 连接分析报告</title><style>'
|
|
46
|
+
+ css
|
|
47
|
+
+ '</style><div id="root"></div>'
|
|
48
|
+
"<script>window.__FMRI_REPORT__=" + data + ";</script><script>" + script + "</script></html>",
|
|
49
|
+
encoding="utf-8",
|
|
50
|
+
)
|
|
51
|
+
return target
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def save_result(result, directory, *, figures=True, report=True):
|
|
55
|
+
"""Export a Connectome to a new directory and return its absolute Path.
|
|
56
|
+
|
|
57
|
+
directory, figures=True and report=True have exactly the same contract as
|
|
58
|
+
Connectome.save. Writes a temporary sibling directory, removes it on errors,
|
|
59
|
+
and renames it on success. No ZIP is created. manifest.json hashes all other
|
|
60
|
+
exported files, excluding itself. See docs/outputs.md for schemas and file list."""
|
|
61
|
+
target = Path(directory).expanduser().resolve()
|
|
62
|
+
if target.exists():
|
|
63
|
+
raise FileExistsError(f"Result directory already exists: {target}. Choose a new directory.")
|
|
64
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
65
|
+
temp = Path(tempfile.mkdtemp(prefix=f".{target.name}-", dir=target.parent))
|
|
66
|
+
try:
|
|
67
|
+
ids = [r["roi_id"] for r in result.rois]
|
|
68
|
+
for name, values in [("connectivity", result.connectivity), ("fisher_z", result.fisher_z)]:
|
|
69
|
+
np.save(temp / f"{name}.npy", values, allow_pickle=False)
|
|
70
|
+
pd.DataFrame(values, index=ids, columns=ids).to_csv(temp / f"{name}.csv", index_label="roi_id")
|
|
71
|
+
pd.DataFrame(result.timeseries, columns=ids).to_csv(temp / "timeseries.tsv", sep="\t", index=False)
|
|
72
|
+
np.save(temp / "timeseries.npy", result.timeseries, allow_pickle=False)
|
|
73
|
+
rows = [
|
|
74
|
+
{k: v for k, v in r.items() if k != "coordinates"}
|
|
75
|
+
| (dict(zip(("x", "y", "z"), r["coordinates"])) if r["coordinates"] else {})
|
|
76
|
+
for r in result.rois
|
|
77
|
+
]
|
|
78
|
+
pd.DataFrame(rows).to_csv(temp / "rois.tsv", sep="\t", index=False)
|
|
79
|
+
pd.DataFrame({"original_volume_index": result.sample_indices}).to_csv(
|
|
80
|
+
temp / "samples.tsv", sep="\t", index=False
|
|
81
|
+
)
|
|
82
|
+
for name, data in [
|
|
83
|
+
("qc", result.qc),
|
|
84
|
+
("provenance", result.provenance),
|
|
85
|
+
("result", result.to_dict()),
|
|
86
|
+
]:
|
|
87
|
+
(temp / f"{name}.json").write_text(_json(data), encoding="utf-8")
|
|
88
|
+
view_image = None
|
|
89
|
+
if figures:
|
|
90
|
+
for extension in ("png", "svg", "pdf"):
|
|
91
|
+
result.plot_matrix(temp / f"matrix.{extension}")
|
|
92
|
+
if all(r.get("coordinates") for r in result.rois):
|
|
93
|
+
result.plot_views(temp / f"eight_views.{extension}")
|
|
94
|
+
if (temp / "eight_views.png").is_file():
|
|
95
|
+
view_image = temp / "eight_views.png"
|
|
96
|
+
if report:
|
|
97
|
+
write_report(result, temp / "report.html", view_image=view_image)
|
|
98
|
+
manifest = {
|
|
99
|
+
"created_utc": datetime.now(timezone.utc).isoformat(),
|
|
100
|
+
"files": {
|
|
101
|
+
p.name: hashlib.sha256(p.read_bytes()).hexdigest() for p in temp.iterdir() if p.is_file()
|
|
102
|
+
},
|
|
103
|
+
}
|
|
104
|
+
(temp / "manifest.json").write_text(_json(manifest), encoding="utf-8")
|
|
105
|
+
temp.rename(target)
|
|
106
|
+
except BaseException:
|
|
107
|
+
shutil.rmtree(temp)
|
|
108
|
+
raise
|
|
109
|
+
return target
|