bidsgate 0.1.0__tar.gz

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.
bidsgate-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Cedric Conday
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,172 @@
1
+ Metadata-Version: 2.4
2
+ Name: bidsgate
3
+ Version: 0.1.0
4
+ Summary: Recovery gate for neuroimaging pipelines: inject known lesions or atrophy into real BIDS data, run any BIDS app, score what it recovered.
5
+ Author: Cedric Conday
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/CedricConday/bidsgate
8
+ Keywords: BIDS,neuroimaging,validation,synthetic,ground truth,MRI,lesion,atrophy
9
+ Classifier: Intended Audience :: Science/Research
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Topic :: Scientific/Engineering :: Medical Science Apps.
13
+ Requires-Python: >=3.10
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Requires-Dist: numpy>=1.24
17
+ Requires-Dist: scipy>=1.10
18
+ Requires-Dist: nibabel>=5
19
+ Requires-Dist: pandas>=2
20
+ Provides-Extra: dev
21
+ Requires-Dist: pytest; extra == "dev"
22
+ Requires-Dist: ruff; extra == "dev"
23
+ Dynamic: license-file
24
+
25
+ # bidsgate
26
+
27
+ **A recovery gate for neuroimaging pipelines.** Inject a known truth into real BIDS data,
28
+ run any BIDS app on the result, and score what it recovered. Every pipeline claims to
29
+ segment lesions or measure atrophy; this is the test that says by how much.
30
+
31
+ ```bash
32
+ pip install bidsgate
33
+ bidsgate inject-lesions /data/bids --out /data/derivatives/bidsgate-lesions
34
+ # run your lesion segmenter on /data/derivatives/bidsgate-lesions
35
+ bidsgate score-lesions --truth /data/derivatives/bidsgate-lesions \
36
+ --pred "/data/derivatives/mytool/{subject}/{base}_seg.nii.gz" --pipeline mytool
37
+ ```
38
+
39
+ > Nothing in this repository is evidence about any disease. It is a test of software:
40
+ > the injected lesions and volume changes are synthetic, and the only claim made is
41
+ > about what a given pipeline recovered from them.
42
+
43
+ ## Why
44
+
45
+ There is no public ground truth for most of what neuroimaging pipelines report. Lesion
46
+ segmenters are compared to expert masks that disagree with each other; morphometry tools
47
+ report volumes nobody can check; when a new release shifts the numbers, the changelog
48
+ says "improved" and the user has no way to tell. bidsgate gives every pipeline the same
49
+ question: here is a scan with a known change in it, what did you find?
50
+
51
+ This generalises the synthetic backtest of [lesiontrack](https://github.com/CedricConday/lesiontrack),
52
+ where injecting known lesion expansions showed a published method recovering a third of
53
+ the injected change and firing on noise. The same discipline applies to any pipeline.
54
+
55
+ ## First result: LST-AI v2 on a healthy control
56
+
57
+ [LST-AI](https://github.com/CompImg/LST-AI) v2.0.0rc1 (CPU image, fast mode) was run on
58
+ OpenNeuro [ds007908](https://openneuro.org/datasets/ds007908) control sub-9000 after
59
+ twelve lesions (30 to 1500 mm3) were injected into its T1w and FLAIR. Scorecard and JSON
60
+ are in `results/lst-ai-v2/`.
61
+
62
+ | | |
63
+ |---|---|
64
+ | Lesions detected | 9 of 12 |
65
+ | Dice | 0.75 |
66
+ | Volume ratio (predicted / injected) | 1.15 |
67
+ | Predicted components farther than 2 mm from any injected lesion | 16, totalling 733 mm3 |
68
+
69
+ What the per-lesion table shows: every lesion in the cerebral white matter was found,
70
+ down to 26 mm3, with overlap fractions of 0.76 to 1.0. The three misses (29, 194 and
71
+ 611 mm3) are the three lesions the placement put lowest in the brain, at cerebellum and
72
+ brainstem level, where this subject's own FLAIR is already brightest. Whether that is a
73
+ weakness of the model or a weakness of injecting supratentorial-looking lesions into
74
+ infratentorial tissue is exactly the question the gate raises and a per-region breakdown
75
+ would answer; it is on the list below.
76
+
77
+ The 16 extra components on a healthy control are not necessarily wrong: a control can
78
+ carry real incidental white-matter hyperintensities, and the gate cannot tell those from
79
+ false positives. It can only say how much the pipeline reported beyond what was injected.
80
+
81
+ The remaining controls of ds007908 are being run and the table will be extended. Two of
82
+ the eight were refused by the input checks: sub-9005's FLAIR is on a different grid from
83
+ its T1w, and sub-9006's FLAIR shares the grid but not the affine (17 mm apart), so it was
84
+ never co-registered. A shape-only check had accepted it. The gate refusing an input is a
85
+ result too.
86
+
87
+ ## Injections
88
+
89
+ **Lesions** (`inject-lesions`): ellipsoidal lesions with soft edges, placed inside a
90
+ white-matter estimate, FLAIR-hyperintense and T1w-hypointense relative to the median of
91
+ that estimate (gain 0.6 and −0.2 at the core by default). Sizes cycle through 30, 80,
92
+ 200, 600 and 1500 mm3 so that the scorecard shows a detection floor by lesion size. No two
93
+ lesions touch, and every lesion lies deeper inside the brain than its own longest axis.
94
+
95
+ The brain mask is estimated from the T1w by morphology (tissue above an Otsu threshold,
96
+ eroded by 8 mm to cut scalp, optic nerves and cord, every remaining piece over 100 ml
97
+ grown back inside tissue, ventricles filled) and must land between 800 and 2000 ml or
98
+ the subject is refused. Pass your own mask with `--mask "{subject}_brainmask.nii.gz"`
99
+ if you have a better one. White matter is bright T1w tissue more than 6 mm inside that
100
+ mask whose FLAIR is within 0.6 to 1.4 of the FLAIR white-matter median, which excludes
101
+ CSF and anything outside the FLAIR field of view.
102
+
103
+ The soft field is 0.5 on the ellipsoid surface and falls off over 1 mm on either side,
104
+ so the truth label (the voxels inside the surface) is exactly what a half-maximum
105
+ segmenter would recover; a perfect segmenter scores Dice 1 and volume ratio 1, not 2.
106
+ The truth is the label map plus a JSON with every lesion's centre, axes, label volume,
107
+ nominal volume and voxel count, the seed, the contrasts and the brain volume.
108
+
109
+ T1w and FLAIR must share grid and affine; a subject that does not is skipped with a
110
+ message and nothing is written for it. Every image gets its own seed (a hash of its name
111
+ mixed with `--seed`), so `--subject` selection and dataset growth do not change what a
112
+ subject receives, and run or acquisition entities are kept in the derivative names.
113
+
114
+ **Atrophy** (`inject-atrophy`): a smooth radial contraction of the brain by a known volume
115
+ factor (default 0.95, five percent loss) about its centroid, fading to identity over 12 mm
116
+ outside the brain mask. The same mask estimate and `--mask` option apply. The truth JSON
117
+ records the factor and the brain volume before and after as measured on the mask itself.
118
+ Note that the skull contracts with the brain inside the falloff zone, so a tool that
119
+ normalises to intracranial volume will see less change than was injected; compare raw
120
+ volumes.
121
+
122
+ Both write a BIDS derivative dataset: `dataset_description.json`, the modified images with
123
+ their sidecars carrying what was done, and the truth files next to them.
124
+
125
+ ## Scoring
126
+
127
+ `score-lesions` compares a predicted mask (binary or probabilistic, thresholded at 0.5)
128
+ with the truth, which must be on the same grid and affine: Dice, lesion-wise sensitivity
129
+ (a lesion is detected when any predicted voxel overlaps it), sensitivity by size bin, and
130
+ the predicted-over-injected volume ratio. False positives are every predicted voxel
131
+ farther than 2 mm (`--fp-margin`) from any injected lesion, reported as volume and as
132
+ 18-connected components, so over-segmentation that happens to touch a true lesion still
133
+ counts. `score-atrophy` takes the volumes your tool reported before and after injection
134
+ and gives recovery: measured change over injected change, 1.0 being exact.
135
+
136
+ Both write JSON and a single-file HTML scorecard.
137
+
138
+ ## Limits, stated plainly
139
+
140
+ * Synthetic lesions are not real lesions. They have the contrast and shape the spec
141
+ says, no more; a pipeline that finds them may still miss real ones, and a pipeline
142
+ that misses them has a problem it cannot blame on pathology.
143
+ * The white-matter estimate is intensity-based, not a segmentation, and it does not
144
+ know cerebrum from cerebellum. Lesions land anywhere in deep bright tissue; a
145
+ per-region breakdown (and a `--region` mask) is the next scoring feature.
146
+ * On real subjects, extra predicted components may be genuine findings. The gate
147
+ reports them; it cannot judge them.
148
+ * Atrophy is global and radial. Regional atrophy needs a region mask; that is the next
149
+ injector.
150
+ * Activation injection for fMRI is not built yet.
151
+
152
+ ## Development
153
+
154
+ ```bash
155
+ pip install -e ".[dev]"
156
+ pytest -q
157
+ ```
158
+
159
+ The tests build a head-shaped phantom (brain, skull gap, scalp) and check that the brain
160
+ estimate excludes the scalp, keeps both hemispheres across a fissure and fills
161
+ ventricles; that a slab of tissue with no plausible brain volume is refused; that injected
162
+ lesions have the recorded volumes and contrasts, sit entirely in white matter and never
163
+ touch; that the half-maximum set of the added contrast is the label; that a perfect
164
+ prediction scores Dice 1, a slab through a lesion counts as a false positive and a
165
+ shifted affine is refused; that atrophy shrinks the brain by the requested factor; that
166
+ run entities survive into derivative names with distinct seeds; that a subject with a
167
+ mismatched FLAIR leaves no partial output; and that the CLI runs end to end.
168
+
169
+ `scripts/` holds the LST-AI runner used for the result above (`run_lst_ai.sh`, detached
170
+ Docker container per subject; `overnight_demo.sh` for the whole cohort).
171
+
172
+ MIT. Written by Cedric Conday with Claude (Anthropic) as coding partner.
@@ -0,0 +1,148 @@
1
+ # bidsgate
2
+
3
+ **A recovery gate for neuroimaging pipelines.** Inject a known truth into real BIDS data,
4
+ run any BIDS app on the result, and score what it recovered. Every pipeline claims to
5
+ segment lesions or measure atrophy; this is the test that says by how much.
6
+
7
+ ```bash
8
+ pip install bidsgate
9
+ bidsgate inject-lesions /data/bids --out /data/derivatives/bidsgate-lesions
10
+ # run your lesion segmenter on /data/derivatives/bidsgate-lesions
11
+ bidsgate score-lesions --truth /data/derivatives/bidsgate-lesions \
12
+ --pred "/data/derivatives/mytool/{subject}/{base}_seg.nii.gz" --pipeline mytool
13
+ ```
14
+
15
+ > Nothing in this repository is evidence about any disease. It is a test of software:
16
+ > the injected lesions and volume changes are synthetic, and the only claim made is
17
+ > about what a given pipeline recovered from them.
18
+
19
+ ## Why
20
+
21
+ There is no public ground truth for most of what neuroimaging pipelines report. Lesion
22
+ segmenters are compared to expert masks that disagree with each other; morphometry tools
23
+ report volumes nobody can check; when a new release shifts the numbers, the changelog
24
+ says "improved" and the user has no way to tell. bidsgate gives every pipeline the same
25
+ question: here is a scan with a known change in it, what did you find?
26
+
27
+ This generalises the synthetic backtest of [lesiontrack](https://github.com/CedricConday/lesiontrack),
28
+ where injecting known lesion expansions showed a published method recovering a third of
29
+ the injected change and firing on noise. The same discipline applies to any pipeline.
30
+
31
+ ## First result: LST-AI v2 on a healthy control
32
+
33
+ [LST-AI](https://github.com/CompImg/LST-AI) v2.0.0rc1 (CPU image, fast mode) was run on
34
+ OpenNeuro [ds007908](https://openneuro.org/datasets/ds007908) control sub-9000 after
35
+ twelve lesions (30 to 1500 mm3) were injected into its T1w and FLAIR. Scorecard and JSON
36
+ are in `results/lst-ai-v2/`.
37
+
38
+ | | |
39
+ |---|---|
40
+ | Lesions detected | 9 of 12 |
41
+ | Dice | 0.75 |
42
+ | Volume ratio (predicted / injected) | 1.15 |
43
+ | Predicted components farther than 2 mm from any injected lesion | 16, totalling 733 mm3 |
44
+
45
+ What the per-lesion table shows: every lesion in the cerebral white matter was found,
46
+ down to 26 mm3, with overlap fractions of 0.76 to 1.0. The three misses (29, 194 and
47
+ 611 mm3) are the three lesions the placement put lowest in the brain, at cerebellum and
48
+ brainstem level, where this subject's own FLAIR is already brightest. Whether that is a
49
+ weakness of the model or a weakness of injecting supratentorial-looking lesions into
50
+ infratentorial tissue is exactly the question the gate raises and a per-region breakdown
51
+ would answer; it is on the list below.
52
+
53
+ The 16 extra components on a healthy control are not necessarily wrong: a control can
54
+ carry real incidental white-matter hyperintensities, and the gate cannot tell those from
55
+ false positives. It can only say how much the pipeline reported beyond what was injected.
56
+
57
+ The remaining controls of ds007908 are being run and the table will be extended. Two of
58
+ the eight were refused by the input checks: sub-9005's FLAIR is on a different grid from
59
+ its T1w, and sub-9006's FLAIR shares the grid but not the affine (17 mm apart), so it was
60
+ never co-registered. A shape-only check had accepted it. The gate refusing an input is a
61
+ result too.
62
+
63
+ ## Injections
64
+
65
+ **Lesions** (`inject-lesions`): ellipsoidal lesions with soft edges, placed inside a
66
+ white-matter estimate, FLAIR-hyperintense and T1w-hypointense relative to the median of
67
+ that estimate (gain 0.6 and −0.2 at the core by default). Sizes cycle through 30, 80,
68
+ 200, 600 and 1500 mm3 so that the scorecard shows a detection floor by lesion size. No two
69
+ lesions touch, and every lesion lies deeper inside the brain than its own longest axis.
70
+
71
+ The brain mask is estimated from the T1w by morphology (tissue above an Otsu threshold,
72
+ eroded by 8 mm to cut scalp, optic nerves and cord, every remaining piece over 100 ml
73
+ grown back inside tissue, ventricles filled) and must land between 800 and 2000 ml or
74
+ the subject is refused. Pass your own mask with `--mask "{subject}_brainmask.nii.gz"`
75
+ if you have a better one. White matter is bright T1w tissue more than 6 mm inside that
76
+ mask whose FLAIR is within 0.6 to 1.4 of the FLAIR white-matter median, which excludes
77
+ CSF and anything outside the FLAIR field of view.
78
+
79
+ The soft field is 0.5 on the ellipsoid surface and falls off over 1 mm on either side,
80
+ so the truth label (the voxels inside the surface) is exactly what a half-maximum
81
+ segmenter would recover; a perfect segmenter scores Dice 1 and volume ratio 1, not 2.
82
+ The truth is the label map plus a JSON with every lesion's centre, axes, label volume,
83
+ nominal volume and voxel count, the seed, the contrasts and the brain volume.
84
+
85
+ T1w and FLAIR must share grid and affine; a subject that does not is skipped with a
86
+ message and nothing is written for it. Every image gets its own seed (a hash of its name
87
+ mixed with `--seed`), so `--subject` selection and dataset growth do not change what a
88
+ subject receives, and run or acquisition entities are kept in the derivative names.
89
+
90
+ **Atrophy** (`inject-atrophy`): a smooth radial contraction of the brain by a known volume
91
+ factor (default 0.95, five percent loss) about its centroid, fading to identity over 12 mm
92
+ outside the brain mask. The same mask estimate and `--mask` option apply. The truth JSON
93
+ records the factor and the brain volume before and after as measured on the mask itself.
94
+ Note that the skull contracts with the brain inside the falloff zone, so a tool that
95
+ normalises to intracranial volume will see less change than was injected; compare raw
96
+ volumes.
97
+
98
+ Both write a BIDS derivative dataset: `dataset_description.json`, the modified images with
99
+ their sidecars carrying what was done, and the truth files next to them.
100
+
101
+ ## Scoring
102
+
103
+ `score-lesions` compares a predicted mask (binary or probabilistic, thresholded at 0.5)
104
+ with the truth, which must be on the same grid and affine: Dice, lesion-wise sensitivity
105
+ (a lesion is detected when any predicted voxel overlaps it), sensitivity by size bin, and
106
+ the predicted-over-injected volume ratio. False positives are every predicted voxel
107
+ farther than 2 mm (`--fp-margin`) from any injected lesion, reported as volume and as
108
+ 18-connected components, so over-segmentation that happens to touch a true lesion still
109
+ counts. `score-atrophy` takes the volumes your tool reported before and after injection
110
+ and gives recovery: measured change over injected change, 1.0 being exact.
111
+
112
+ Both write JSON and a single-file HTML scorecard.
113
+
114
+ ## Limits, stated plainly
115
+
116
+ * Synthetic lesions are not real lesions. They have the contrast and shape the spec
117
+ says, no more; a pipeline that finds them may still miss real ones, and a pipeline
118
+ that misses them has a problem it cannot blame on pathology.
119
+ * The white-matter estimate is intensity-based, not a segmentation, and it does not
120
+ know cerebrum from cerebellum. Lesions land anywhere in deep bright tissue; a
121
+ per-region breakdown (and a `--region` mask) is the next scoring feature.
122
+ * On real subjects, extra predicted components may be genuine findings. The gate
123
+ reports them; it cannot judge them.
124
+ * Atrophy is global and radial. Regional atrophy needs a region mask; that is the next
125
+ injector.
126
+ * Activation injection for fMRI is not built yet.
127
+
128
+ ## Development
129
+
130
+ ```bash
131
+ pip install -e ".[dev]"
132
+ pytest -q
133
+ ```
134
+
135
+ The tests build a head-shaped phantom (brain, skull gap, scalp) and check that the brain
136
+ estimate excludes the scalp, keeps both hemispheres across a fissure and fills
137
+ ventricles; that a slab of tissue with no plausible brain volume is refused; that injected
138
+ lesions have the recorded volumes and contrasts, sit entirely in white matter and never
139
+ touch; that the half-maximum set of the added contrast is the label; that a perfect
140
+ prediction scores Dice 1, a slab through a lesion counts as a false positive and a
141
+ shifted affine is refused; that atrophy shrinks the brain by the requested factor; that
142
+ run entities survive into derivative names with distinct seeds; that a subject with a
143
+ mismatched FLAIR leaves no partial output; and that the CLI runs end to end.
144
+
145
+ `scripts/` holds the LST-AI runner used for the result above (`run_lst_ai.sh`, detached
146
+ Docker container per subject; `overnight_demo.sh` for the whole cohort).
147
+
148
+ MIT. Written by Cedric Conday with Claude (Anthropic) as coding partner.
@@ -0,0 +1,8 @@
1
+ """bidsgate: a recovery gate for neuroimaging pipelines.
2
+
3
+ Inject a known truth (lesions, atrophy) into real BIDS data, run any BIDS app on
4
+ the result, and score what it recovered. Nothing here is evidence about any
5
+ disease; it is a test of software.
6
+ """
7
+
8
+ __version__ = "0.1.0"
@@ -0,0 +1,85 @@
1
+ """Minimal BIDS walking: enough to find anatomical images and write derivatives."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+
10
+ from . import __version__
11
+
12
+ ENTITY = re.compile(r"(sub-[A-Za-z0-9]+)(?:_(ses-[A-Za-z0-9]+))?.*_(T1w|FLAIR|T2w)\.nii(\.gz)?$")
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class Anat:
17
+ subject: str
18
+ session: str | None
19
+ suffix: str
20
+ path: Path
21
+
22
+ @property
23
+ def stem(self) -> str:
24
+ return self.path.name.split(".")[0]
25
+
26
+ @property
27
+ def base(self) -> str:
28
+ """The filename with the suffix and extension removed, every entity kept (sub-01_ses-1_run-2)."""
29
+ return self.stem.removesuffix(f"_{self.suffix}")
30
+
31
+
32
+ def find_anat(root: Path, suffix: str = "T1w", subjects: list[str] | None = None) -> list[Anat]:
33
+ out = []
34
+ for p in sorted(root.glob("sub-*/**/anat/*.nii*")):
35
+ m = ENTITY.match(p.name)
36
+ if not m or m.group(3) != suffix:
37
+ continue
38
+ sub, ses = m.group(1), m.group(2)
39
+ if subjects and sub not in subjects and sub.removeprefix("sub-") not in subjects:
40
+ continue
41
+ out.append(Anat(sub, ses, suffix, p))
42
+ return out
43
+
44
+
45
+ def sibling(anat: Anat, suffix: str) -> Path | None:
46
+ """The same subject/session's image of another suffix, if present."""
47
+ cand = anat.path.with_name(anat.path.name.replace(f"_{anat.suffix}.", f"_{suffix}."))
48
+ return cand if cand.exists() else None
49
+
50
+
51
+ def derivative_path(out_root: Path, anat: Anat, suffix: str, desc: str | None = None, ext: str = ".nii.gz") -> Path:
52
+ """Output path that keeps every entity of the source file, so run-1 and run-2 never collide."""
53
+ d = out_root / anat.subject / (anat.session or "") / "anat"
54
+ d.mkdir(parents=True, exist_ok=True)
55
+ name = "_".join([anat.base] + ([f"desc-{desc}"] if desc else []) + [suffix]) + ext
56
+ return d / name
57
+
58
+
59
+ def write_dataset_description(out_root: Path, name: str, source_root: Path, kind: str) -> None:
60
+ out_root.mkdir(parents=True, exist_ok=True)
61
+ desc = {
62
+ "Name": name,
63
+ "BIDSVersion": "1.9.0",
64
+ "DatasetType": "derivative",
65
+ "GeneratedBy": [{"Name": "bidsgate", "Version": __version__, "Description": f"synthetic {kind} injection with known truth"}],
66
+ "SourceDatasets": [{"URL": str(source_root)}],
67
+ }
68
+ with open(out_root / "dataset_description.json", "w") as fh:
69
+ json.dump(desc, fh, indent=2)
70
+
71
+
72
+ def copy_json_sidecar(src_nii: Path, dst_nii: Path, extra: dict) -> None:
73
+ """Carry the acquisition sidecar over and add what was done."""
74
+ src = src_nii.with_suffix("").with_suffix(".json") if src_nii.name.endswith(".nii.gz") else src_nii.with_suffix(".json")
75
+ meta = {}
76
+ if src.exists():
77
+ try:
78
+ with open(src) as fh:
79
+ meta = json.load(fh)
80
+ except json.JSONDecodeError:
81
+ meta = {}
82
+ meta.update(extra)
83
+ dst = Path(str(dst_nii).replace(".nii.gz", ".json").replace(".nii", ".json"))
84
+ with open(dst, "w") as fh:
85
+ json.dump(meta, fh, indent=2)
@@ -0,0 +1,185 @@
1
+ """bidsgate: inject a known truth into BIDS data, then score what a pipeline recovered."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ import zlib
9
+ from pathlib import Path
10
+
11
+ import pandas as pd
12
+
13
+ from . import __version__
14
+ from .bids import copy_json_sidecar, derivative_path, find_anat, sibling, write_dataset_description
15
+ from .inject_atrophy import AtrophySpec
16
+ from .inject_atrophy import inject as inject_atrophy
17
+ from .inject_lesions import LesionSpec
18
+ from .inject_lesions import inject as inject_lesions
19
+ from .report import scorecard_atrophy, scorecard_lesions
20
+ from .score import score_atrophy, score_lesions
21
+
22
+
23
+ def subject_seed(base: str, seed: int) -> int:
24
+ """One seed per image, stable under --subject selection and dataset growth."""
25
+ return zlib.crc32(base.encode()) ^ (seed & 0xFFFFFFFF)
26
+
27
+
28
+ def _mask_for(pattern: str | None, anat) -> Path | None:
29
+ if not pattern:
30
+ return None
31
+ p = Path(pattern.format(base=anat.base, subject=anat.subject))
32
+ if not p.exists():
33
+ raise SystemExit(f"{anat.base}: brain mask not found at {p}")
34
+ return p
35
+
36
+
37
+ def cmd_inject_lesions(a) -> int:
38
+ root, out = Path(a.bids), Path(a.out)
39
+ anats = find_anat(root, "T1w", a.subject)
40
+ if not anats:
41
+ raise SystemExit(f"no T1w images under {root}")
42
+ write_dataset_description(out, "bidsgate lesions", root, "lesion")
43
+ for anat in anats:
44
+ flair = sibling(anat, "FLAIR")
45
+ spec = LesionSpec(n=a.n, seed=subject_seed(anat.base, a.seed), flair_contrast=a.flair_contrast, t1_contrast=a.t1_contrast)
46
+ out_t1 = derivative_path(out, anat, "T1w")
47
+ out_fl = derivative_path(out, anat, "FLAIR") if flair else None
48
+ mask = derivative_path(out, anat, "mask", desc="lesionTruth")
49
+ truth = derivative_path(out, anat, "truth", desc="lesion", ext=".json")
50
+ try:
51
+ t = inject_lesions(anat.path, flair, out_t1, out_fl, mask, truth, spec, _mask_for(a.mask, anat))
52
+ except ValueError as e:
53
+ print(f"{anat.base}: skipped: {e}", file=sys.stderr)
54
+ continue
55
+ note = {"BidsgateInjection": "lesions", "BidsgateSeed": spec.seed, "BidsgateTruth": truth.name}
56
+ copy_json_sidecar(anat.path, out_t1, note)
57
+ if flair and out_fl:
58
+ copy_json_sidecar(flair, out_fl, note)
59
+ print(f"{anat.base}: {t['n']} lesions, {t['total_volume_mm3']:.0f} mm3, {'T1w+FLAIR' if flair else 'T1w only'} -> {out_t1.parent}")
60
+ print(f"derivative dataset written to {out}; run your pipeline on it, then `bidsgate score-lesions`")
61
+ return 0
62
+
63
+
64
+ def cmd_inject_atrophy(a) -> int:
65
+ root, out = Path(a.bids), Path(a.out)
66
+ anats = find_anat(root, "T1w", a.subject)
67
+ if not anats:
68
+ raise SystemExit(f"no T1w images under {root}")
69
+ write_dataset_description(out, "bidsgate atrophy", root, "atrophy")
70
+ for anat in anats:
71
+ flair = sibling(anat, "FLAIR")
72
+ out_t1 = derivative_path(out, anat, "T1w")
73
+ out_fl = derivative_path(out, anat, "FLAIR") if flair else None
74
+ truth = derivative_path(out, anat, "truth", desc="atrophy", ext=".json")
75
+ try:
76
+ t = inject_atrophy(anat.path, flair, out_t1, out_fl, truth, AtrophySpec(volume_factor=a.factor, falloff_mm=a.falloff),
77
+ _mask_for(a.mask, anat))
78
+ except ValueError as e:
79
+ print(f"{anat.base}: skipped: {e}", file=sys.stderr)
80
+ continue
81
+ copy_json_sidecar(anat.path, out_t1, {"BidsgateInjection": "atrophy", "BidsgateVolumeFactor": a.factor, "BidsgateTruth": truth.name})
82
+ if flair and out_fl:
83
+ copy_json_sidecar(flair, out_fl, {"BidsgateInjection": "atrophy", "BidsgateVolumeFactor": a.factor})
84
+ print(f"{anat.base}: brain {t['brain_volume_mm3_before']/1000:.0f} ml -> factor {a.factor} (measured on the mask: {t['brain_volume_mm3_after_measured']/t['brain_volume_mm3_before']:.3f})")
85
+ print(f"derivative dataset written to {out}; run your morphometry on {root} and on {out}, then `bidsgate score-atrophy`")
86
+ return 0
87
+
88
+
89
+ def cmd_score_lesions(a) -> int:
90
+ truth_root = Path(a.truth)
91
+ results = []
92
+ for truth_json in sorted(truth_root.glob("sub-*/**/anat/*desc-lesion_truth.json")):
93
+ mask = Path(str(truth_json).replace("desc-lesion_truth.json", "desc-lesionTruth_mask.nii.gz"))
94
+ base = truth_json.name.replace("_desc-lesion_truth.json", "")
95
+ pred = Path(a.pred.format(base=base, subject=base.split("_")[0]))
96
+ if not pred.exists():
97
+ print(f"{base}: prediction not found at {pred}", file=sys.stderr)
98
+ continue
99
+ s = score_lesions(truth_json, mask, pred, a.threshold, a.fp_margin)
100
+ results.append({"subject": base, "score": s})
101
+ print(f"{base}: Dice {s['dice']:.2f} detected {s['detected']}/{s['lesions']} FP {s['false_positive_components']} volume ratio {s['volume_ratio']:.2f}")
102
+ if not results:
103
+ raise SystemExit("nothing scored")
104
+ out = Path(a.out)
105
+ out.mkdir(parents=True, exist_ok=True)
106
+ with open(out / "scores_lesions.json", "w") as fh:
107
+ json.dump({"pipeline": a.pipeline, "results": results}, fh, indent=2)
108
+ page = scorecard_lesions(results, a.pipeline, out / "scorecard_lesions.html")
109
+ print(f"scorecard: {page}")
110
+ return 0
111
+
112
+
113
+ def cmd_score_atrophy(a) -> int:
114
+ """Volumes come from the user's tool as a TSV: subject, volume_before_mm3, volume_after_mm3."""
115
+ truth_root = Path(a.truth)
116
+ vols = pd.read_csv(a.volumes, sep="\t")
117
+ results = []
118
+ for _, row in vols.iterrows():
119
+ base = str(row["subject"])
120
+ hits = sorted(truth_root.glob(f"{base.split('_')[0]}/**/anat/{base}_desc-atrophy_truth.json"))
121
+ if not hits:
122
+ print(f"{base}: no truth found (the subject column must be the full base, e.g. sub-01_ses-1)", file=sys.stderr)
123
+ continue
124
+ if len(hits) > 1:
125
+ raise SystemExit(f"{base}: {len(hits)} truth files match: " + ", ".join(str(h) for h in hits))
126
+ s = score_atrophy(hits[0], float(row["volume_before_mm3"]), float(row["volume_after_mm3"]))
127
+ results.append({"subject": base, "score": s})
128
+ print(f"{base}: injected {s['injected_change_pct']:+.1f}% measured {s['measured_change_pct']:+.1f}% recovery {s['recovery']:.2f}")
129
+ if not results:
130
+ raise SystemExit("nothing scored")
131
+ out = Path(a.out)
132
+ out.mkdir(parents=True, exist_ok=True)
133
+ with open(out / "scores_atrophy.json", "w") as fh:
134
+ json.dump({"pipeline": a.pipeline, "results": results}, fh, indent=2)
135
+ print(f"scorecard: {scorecard_atrophy(results, a.pipeline, out / 'scorecard_atrophy.html')}")
136
+ return 0
137
+
138
+
139
+ def main(argv=None) -> int:
140
+ ap = argparse.ArgumentParser(prog="bidsgate", description=__doc__)
141
+ ap.add_argument("--version", action="version", version=f"bidsgate {__version__}")
142
+ sub = ap.add_subparsers(dest="cmd", required=True)
143
+
144
+ p = sub.add_parser("inject-lesions", help="write a derivative dataset with synthetic lesions and their truth")
145
+ p.add_argument("bids")
146
+ p.add_argument("--out", required=True)
147
+ p.add_argument("--subject", action="append", help="restrict to these subjects (repeatable)")
148
+ p.add_argument("--n", type=int, default=12, help="lesions per subject")
149
+ p.add_argument("--seed", type=int, default=0, help="mixed with a hash of each image's name, so every subject gets its own stable seed")
150
+ p.add_argument("--mask", help="brain-mask path pattern with {subject} or {base}; default is a morphological estimate from the T1w")
151
+ p.add_argument("--flair-contrast", type=float, default=0.6, help="FLAIR gain over local white matter at the core")
152
+ p.add_argument("--t1-contrast", type=float, default=-0.2, help="T1w change over local white matter at the core")
153
+ p.set_defaults(func=cmd_inject_lesions)
154
+
155
+ p = sub.add_parser("inject-atrophy", help="write a derivative dataset with a known brain-volume change")
156
+ p.add_argument("bids")
157
+ p.add_argument("--out", required=True)
158
+ p.add_argument("--subject", action="append")
159
+ p.add_argument("--factor", type=float, default=0.95, help="brain volume factor, 0.95 = 5 %% loss")
160
+ p.add_argument("--mask", help="brain-mask path pattern with {subject} or {base}; default is a morphological estimate from the T1w")
161
+ p.add_argument("--falloff", type=float, default=12.0, help="mm over which the deformation fades outside the brain")
162
+ p.set_defaults(func=cmd_inject_atrophy)
163
+
164
+ p = sub.add_parser("score-lesions", help="score predicted lesion masks against the injected truth")
165
+ p.add_argument("--truth", required=True, help="the inject-lesions output directory")
166
+ p.add_argument("--pred", required=True, help="path pattern with {base} or {subject}, e.g. derivatives/lst/{subject}/{base}_seg.nii.gz")
167
+ p.add_argument("--pipeline", required=True, help="name for the scorecard")
168
+ p.add_argument("--threshold", type=float, default=0.5)
169
+ p.add_argument("--fp-margin", type=float, default=2.0, help="mm from a truth lesion beyond which predicted voxels count as false positive")
170
+ p.add_argument("--out", default="bidsgate-scores")
171
+ p.set_defaults(func=cmd_score_lesions)
172
+
173
+ p = sub.add_parser("score-atrophy", help="score a tool's reported volumes against the injected change")
174
+ p.add_argument("--truth", required=True)
175
+ p.add_argument("--volumes", required=True, help="TSV: subject, volume_before_mm3, volume_after_mm3")
176
+ p.add_argument("--pipeline", required=True)
177
+ p.add_argument("--out", default="bidsgate-scores")
178
+ p.set_defaults(func=cmd_score_atrophy)
179
+
180
+ a = ap.parse_args(argv)
181
+ return a.func(a)
182
+
183
+
184
+ if __name__ == "__main__":
185
+ sys.exit(main())