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/__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
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
brainfc/_version.py ADDED
@@ -0,0 +1,3 @@
1
+ """Single source for package, CLI, HTTP and provenance versions."""
2
+
3
+ __version__ = "0.3.0"
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