bidsgate 0.1.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.
- bidsgate/__init__.py +8 -0
- bidsgate/bids.py +85 -0
- bidsgate/cli.py +185 -0
- bidsgate/inject_atrophy.py +89 -0
- bidsgate/inject_lesions.py +243 -0
- bidsgate/report.py +65 -0
- bidsgate/score.py +93 -0
- bidsgate-0.1.0.dist-info/METADATA +172 -0
- bidsgate-0.1.0.dist-info/RECORD +13 -0
- bidsgate-0.1.0.dist-info/WHEEL +5 -0
- bidsgate-0.1.0.dist-info/entry_points.txt +2 -0
- bidsgate-0.1.0.dist-info/licenses/LICENSE +21 -0
- bidsgate-0.1.0.dist-info/top_level.txt +1 -0
bidsgate/__init__.py
ADDED
|
@@ -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"
|
bidsgate/bids.py
ADDED
|
@@ -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)
|
bidsgate/cli.py
ADDED
|
@@ -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())
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Shrink the brain by a known factor to create a ground-truth volume change.
|
|
2
|
+
|
|
3
|
+
A smooth radial contraction about the brain's centroid, uniform inside the
|
|
4
|
+
brain mask and fading to identity over ``falloff_mm`` outside it, is
|
|
5
|
+
applied to the T1w (and FLAIR when present). The brain's volume changes by
|
|
6
|
+
exactly the requested factor inside the uniform region, so any morphometry
|
|
7
|
+
tool's reported total brain volume can be checked against the truth.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
import nibabel as nib
|
|
17
|
+
import numpy as np
|
|
18
|
+
from scipy import ndimage as ndi
|
|
19
|
+
|
|
20
|
+
from .inject_lesions import _float_header, estimate_brain
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class AtrophySpec:
|
|
25
|
+
volume_factor: float = 0.95 # 0.95 = 5 % brain volume loss
|
|
26
|
+
falloff_mm: float = 12.0
|
|
27
|
+
seed: int = 0
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _warp(image: np.ndarray, disp: np.ndarray, order: int, cval: float = 0.0) -> np.ndarray:
|
|
31
|
+
grids = np.indices(image.shape, dtype=np.float32)
|
|
32
|
+
return ndi.map_coordinates(image, grids + disp, order=order, mode="constant", cval=cval)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def displacement(shape: tuple, zooms: tuple, brain: np.ndarray, factor: float, falloff_mm: float) -> np.ndarray:
|
|
36
|
+
"""Sampling displacement (3, X, Y, Z): output(x) = input(x + u(x)).
|
|
37
|
+
|
|
38
|
+
Inside the brain the map is a radial scaling by ``factor ** (1/3)`` about
|
|
39
|
+
the centroid; a shrunk brain samples from farther out, so u points away
|
|
40
|
+
from the centre. The weight fades to zero over ``falloff_mm`` outside.
|
|
41
|
+
"""
|
|
42
|
+
lam = factor ** (1.0 / 3.0)
|
|
43
|
+
c = np.array(ndi.center_of_mass(brain), np.float32)
|
|
44
|
+
grids = np.indices(shape, dtype=np.float32)
|
|
45
|
+
d = grids - c[:, None, None, None]
|
|
46
|
+
dist_out_mm = ndi.distance_transform_edt(~brain, sampling=zooms[:3]).astype(np.float32)
|
|
47
|
+
w = np.clip(1.0 - dist_out_mm / max(falloff_mm, 1e-3), 0.0, 1.0)
|
|
48
|
+
w[brain] = 1.0
|
|
49
|
+
# output(x) = input(c + (x - c) / lam): points inside map outward for lam < 1.
|
|
50
|
+
# A linear scaling about the centroid is the same map in voxel and in mm space.
|
|
51
|
+
return d * ((1.0 / lam - 1.0) * w)[None]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def inject(t1_path: Path, flair_path: Path | None, out_t1: Path, out_flair: Path | None,
|
|
55
|
+
out_truth: Path, spec: AtrophySpec, mask_path: Path | None = None) -> dict:
|
|
56
|
+
"""Write the contracted T1w (and FLAIR) and the truth JSON; inputs are checked before anything is written."""
|
|
57
|
+
t1_img = nib.load(t1_path)
|
|
58
|
+
t1 = np.asarray(t1_img.dataobj, dtype=np.float32)
|
|
59
|
+
zooms = t1_img.header.get_zooms()
|
|
60
|
+
fl_img = fl = None
|
|
61
|
+
if flair_path is not None and out_flair is not None:
|
|
62
|
+
fl_img = nib.load(flair_path)
|
|
63
|
+
if fl_img.shape != t1_img.shape or not np.allclose(fl_img.affine, t1_img.affine, atol=1e-3):
|
|
64
|
+
raise ValueError(f"FLAIR grid {fl_img.shape} differs from T1w {t1_img.shape} (or the affines differ)")
|
|
65
|
+
fl = np.asarray(fl_img.dataobj, dtype=np.float32)
|
|
66
|
+
if mask_path is not None:
|
|
67
|
+
m_img = nib.load(mask_path)
|
|
68
|
+
if m_img.shape != t1_img.shape:
|
|
69
|
+
raise ValueError(f"brain mask grid {m_img.shape} differs from T1w {t1_img.shape}")
|
|
70
|
+
brain = np.asarray(m_img.dataobj) > 0
|
|
71
|
+
else:
|
|
72
|
+
brain = estimate_brain(t1, zooms)
|
|
73
|
+
disp = displacement(t1.shape, zooms, brain, spec.volume_factor, spec.falloff_mm)
|
|
74
|
+
nib.save(nib.Nifti1Image(_warp(t1, disp, 1).astype(np.float32), t1_img.affine, _float_header(t1_img)), out_t1)
|
|
75
|
+
if fl is not None:
|
|
76
|
+
nib.save(nib.Nifti1Image(_warp(fl, disp, 1).astype(np.float32), fl_img.affine, _float_header(fl_img)), out_flair)
|
|
77
|
+
voxel_mm3 = float(np.prod(zooms[:3]))
|
|
78
|
+
brain_after = _warp(brain.astype(np.float32), disp, 1) >= 0.5
|
|
79
|
+
truth = {
|
|
80
|
+
"kind": "atrophy", "seed": spec.seed, "volume_factor": spec.volume_factor,
|
|
81
|
+
"falloff_mm": spec.falloff_mm,
|
|
82
|
+
"brain_mask": "given" if mask_path is not None else "estimated",
|
|
83
|
+
"brain_volume_mm3_before": float(brain.sum() * voxel_mm3),
|
|
84
|
+
"brain_volume_mm3_after_measured": float(brain_after.sum() * voxel_mm3),
|
|
85
|
+
"voxel_mm": [float(z) for z in zooms[:3]],
|
|
86
|
+
}
|
|
87
|
+
with open(out_truth, "w") as fh:
|
|
88
|
+
json.dump(truth, fh, indent=2)
|
|
89
|
+
return truth
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""Insert synthetic white-matter lesions with a known mask into T1w and FLAIR images.
|
|
2
|
+
|
|
3
|
+
The lesions are ellipsoids with soft edges, placed inside a white-matter
|
|
4
|
+
estimate derived from the subject's own T1w and FLAIR (bright T1w tissue
|
|
5
|
+
deep inside a morphological brain mask, or inside a mask you supply), given
|
|
6
|
+
FLAIR hyperintensity and T1w hypointensity relative to the median of that
|
|
7
|
+
white-matter estimate. Sizes, count, contrasts and the random seed
|
|
8
|
+
are recorded next to the mask, so the truth is exact and reproducible.
|
|
9
|
+
|
|
10
|
+
This is a test of software, not a model of pathology: the point is that a
|
|
11
|
+
segmenter's recovery of these lesions is measurable, not that they look
|
|
12
|
+
like any particular disease.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
import nibabel as nib
|
|
22
|
+
import numpy as np
|
|
23
|
+
from scipy import ndimage as ndi
|
|
24
|
+
from scipy.special import erf
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class LesionSpec:
|
|
29
|
+
n: int = 12
|
|
30
|
+
volume_mm3: tuple = (30.0, 80.0, 200.0, 600.0, 1500.0) # cycled, then shuffled
|
|
31
|
+
flair_contrast: float = 0.6 # FLAIR gain over the white-matter median at the core
|
|
32
|
+
t1_contrast: float = -0.2 # T1w change over the white-matter median at the core
|
|
33
|
+
edge_mm: float = 1.0 # erf edge width, centred on the surface
|
|
34
|
+
elongation: tuple = (1.0, 2.0) # axis ratio range
|
|
35
|
+
seed: int = 0
|
|
36
|
+
min_gap_mm: float = 6.0 # between lesion surfaces
|
|
37
|
+
extra: dict = field(default_factory=dict)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _otsu(x: np.ndarray) -> float:
|
|
41
|
+
hist, edges = np.histogram(x, bins=256)
|
|
42
|
+
mids = 0.5 * (edges[:-1] + edges[1:])
|
|
43
|
+
w1 = np.cumsum(hist).astype(float)
|
|
44
|
+
w2 = w1[-1] - w1
|
|
45
|
+
hm = np.cumsum(hist * mids)
|
|
46
|
+
m1 = hm / np.maximum(w1, 1)
|
|
47
|
+
m2 = (hm[-1] - hm) / np.maximum(w2, 1)
|
|
48
|
+
var = w1[:-1] * w2[:-1] * (m1[:-1] - m2[:-1]) ** 2
|
|
49
|
+
return float(mids[var.argmax()])
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def estimate_brain(t1: np.ndarray, zooms: tuple, erode_mm: float = 8.0,
|
|
53
|
+
volume_ml: tuple = (800.0, 2000.0), min_core_ml: float = 100.0) -> np.ndarray:
|
|
54
|
+
"""Brain mask from a whole-head T1w by morphology, no atlas.
|
|
55
|
+
|
|
56
|
+
Tissue is what lies above an Otsu threshold of the smoothed image. The
|
|
57
|
+
brain core is every piece of tissue more than ``erode_mm`` from any
|
|
58
|
+
non-tissue voxel that is at least ``min_core_ml`` in size: the erosion
|
|
59
|
+
cuts the thin scalp, the optic nerves and the spinal cord, and the size
|
|
60
|
+
rule keeps both hemispheres when a deep fissure splits the core but drops
|
|
61
|
+
muscle and tongue. The core is grown back by the same distance inside
|
|
62
|
+
tissue and holes (ventricles) are filled. The result must have a
|
|
63
|
+
plausible brain volume or a ValueError is raised: it is a placement
|
|
64
|
+
mask, not a segmentation, and a wrong one would put lesions in the neck.
|
|
65
|
+
"""
|
|
66
|
+
vox = np.array(zooms[:3], dtype=float)
|
|
67
|
+
sm = ndi.gaussian_filter(t1, sigma=1.0 / vox)
|
|
68
|
+
if not (sm > 0).any():
|
|
69
|
+
raise ValueError("empty image")
|
|
70
|
+
tissue = sm > _otsu(sm.ravel()) # background and bone below, soft tissue above
|
|
71
|
+
core = ndi.distance_transform_edt(tissue, sampling=vox) > erode_mm
|
|
72
|
+
lab, n = ndi.label(core)
|
|
73
|
+
if n == 0:
|
|
74
|
+
raise ValueError("no tissue thicker than the erosion radius; supply a brain mask")
|
|
75
|
+
sizes = np.bincount(lab.ravel()) * float(np.prod(vox)) / 1000.0
|
|
76
|
+
sizes[0] = 0
|
|
77
|
+
keep = np.flatnonzero(sizes >= min_core_ml)
|
|
78
|
+
if keep.size == 0:
|
|
79
|
+
keep = np.array([sizes.argmax()])
|
|
80
|
+
core = np.isin(lab, keep)
|
|
81
|
+
brain = tissue & (ndi.distance_transform_edt(~core, sampling=vox) <= erode_mm)
|
|
82
|
+
brain = ndi.binary_fill_holes(brain)
|
|
83
|
+
for axis in range(3): # ventricles open to the outside through narrow channels: fill them slice-wise too
|
|
84
|
+
brain = np.moveaxis(np.array([ndi.binary_fill_holes(sl) for sl in np.moveaxis(brain, axis, 0)]), 0, axis)
|
|
85
|
+
ml = brain.sum() * float(np.prod(vox)) / 1000.0
|
|
86
|
+
if not volume_ml[0] <= ml <= volume_ml[1]:
|
|
87
|
+
raise ValueError(f"brain estimate is {ml:.0f} ml, outside {volume_ml[0]:.0f}-{volume_ml[1]:.0f} ml; "
|
|
88
|
+
"supply a brain mask with --mask")
|
|
89
|
+
return brain
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def brain_and_wm(t1: np.ndarray, zooms: tuple, flair: np.ndarray | None = None,
|
|
93
|
+
mask: np.ndarray | None = None, depth_mm: float = 6.0) -> tuple[np.ndarray, np.ndarray]:
|
|
94
|
+
"""Brain mask (estimated, or the one given) and a white-matter placement estimate.
|
|
95
|
+
|
|
96
|
+
White matter is bright T1w tissue deeper than ``depth_mm`` inside the
|
|
97
|
+
brain; when a FLAIR is given it must also be within 0.6-1.4 of the FLAIR
|
|
98
|
+
white-matter median, which excludes CSF and anything outside the FLAIR
|
|
99
|
+
field of view. Good enough to place lesions in deep tissue; not a
|
|
100
|
+
segmentation.
|
|
101
|
+
"""
|
|
102
|
+
vox = np.array(zooms[:3], dtype=float)
|
|
103
|
+
brain = mask.astype(bool) if mask is not None else estimate_brain(t1, zooms)
|
|
104
|
+
sm = ndi.gaussian_filter(t1, sigma=1.0 / vox)
|
|
105
|
+
deep = ndi.distance_transform_edt(brain, sampling=vox) > depth_mm
|
|
106
|
+
wm_thr = np.percentile(sm[brain], 65) # T1w: white matter is the bright part of the brain
|
|
107
|
+
wm = deep & (sm >= wm_thr)
|
|
108
|
+
if flair is not None:
|
|
109
|
+
fs = ndi.gaussian_filter(flair, sigma=1.0 / vox)
|
|
110
|
+
med = float(np.median(fs[wm])) if wm.any() else 0.0
|
|
111
|
+
wm &= (fs > 0.6 * med) & (fs < 1.4 * med)
|
|
112
|
+
wm = ndi.binary_opening(wm, iterations=1)
|
|
113
|
+
if wm.sum() * float(np.prod(vox)) < 20_000:
|
|
114
|
+
raise ValueError("white-matter estimate under 20 ml; is this a whole-head T1w?")
|
|
115
|
+
return brain, wm
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def place_lesions(wm: np.ndarray, depth_mm: np.ndarray, zooms: tuple, spec: LesionSpec,
|
|
119
|
+
rng: np.random.Generator) -> list[dict]:
|
|
120
|
+
"""Choose centres inside white matter so that no lesion touches another or leaves the brain.
|
|
121
|
+
|
|
122
|
+
``depth_mm`` is the distance from each voxel to the brain edge; a lesion's
|
|
123
|
+
centre must be deeper than its longest semi-axis. Two lesions must be at
|
|
124
|
+
least the sum of their longest semi-axes plus ``min_gap_mm`` apart.
|
|
125
|
+
"""
|
|
126
|
+
candidates = np.argwhere(wm)
|
|
127
|
+
if len(candidates) == 0:
|
|
128
|
+
raise ValueError("no white-matter estimate; is this a T1w image?")
|
|
129
|
+
vols = [spec.volume_mm3[i % len(spec.volume_mm3)] for i in range(spec.n)]
|
|
130
|
+
rng.shuffle(vols)
|
|
131
|
+
chosen: list[dict] = []
|
|
132
|
+
vox_mm = np.array(zooms[:3], dtype=float)
|
|
133
|
+
for vol in vols:
|
|
134
|
+
elong = rng.uniform(*spec.elongation)
|
|
135
|
+
# ellipsoid semi-axes (mm) with volume vol: 4/3 pi a b c, a = elong * b, b = c
|
|
136
|
+
b = (3 * vol / (4 * np.pi * elong)) ** (1 / 3)
|
|
137
|
+
axes = np.array([elong * b, b, b])
|
|
138
|
+
rng.shuffle(axes)
|
|
139
|
+
reach = float(axes.max())
|
|
140
|
+
for _ in range(5000):
|
|
141
|
+
c = candidates[rng.integers(len(candidates))]
|
|
142
|
+
if depth_mm[tuple(c)] < reach + 1.0:
|
|
143
|
+
continue
|
|
144
|
+
c = c.astype(float)
|
|
145
|
+
if any(np.linalg.norm((c - np.array(o["centre_vox"])) * vox_mm) < reach + max(o["axes_mm"]) + spec.min_gap_mm
|
|
146
|
+
for o in chosen):
|
|
147
|
+
continue
|
|
148
|
+
chosen.append({"id": len(chosen) + 1, "centre_vox": c.tolist(), "axes_mm": axes.tolist(),
|
|
149
|
+
"volume_mm3_nominal": float(vol)})
|
|
150
|
+
break
|
|
151
|
+
else:
|
|
152
|
+
raise ValueError(f"could only place {len(chosen)} of {spec.n} lesions without overlap; fewer or smaller lesions")
|
|
153
|
+
return chosen
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def render(shape: tuple, zooms: tuple, lesions: list[dict], edge_mm: float) -> tuple[np.ndarray, np.ndarray]:
|
|
157
|
+
"""Soft lesion field in [0, 1] and the integer label map.
|
|
158
|
+
|
|
159
|
+
The field is 0.5 on the ellipsoid surface and falls off over ``edge_mm``
|
|
160
|
+
on either side (an erf profile of the signed distance, scaled along the
|
|
161
|
+
shortest axis), so the label, which is the voxels inside the surface, is
|
|
162
|
+
exactly what a half-maximum segmenter would recover.
|
|
163
|
+
"""
|
|
164
|
+
grids = np.indices(shape, dtype=np.float32)
|
|
165
|
+
field_ = np.zeros(shape, np.float32)
|
|
166
|
+
labels = np.zeros(shape, np.int16)
|
|
167
|
+
vox_mm = np.array(zooms[:3], dtype=np.float32)
|
|
168
|
+
for les in lesions:
|
|
169
|
+
c = np.array(les["centre_vox"], np.float32)
|
|
170
|
+
axes = np.array(les["axes_mm"], np.float32)
|
|
171
|
+
d = (grids - c[:, None, None, None]) * vox_mm[:, None, None, None] / axes[:, None, None, None]
|
|
172
|
+
r = np.sqrt((d**2).sum(0)) # 1.0 on the ellipsoid surface
|
|
173
|
+
signed_mm = (r - 1.0) * axes.min() # negative inside
|
|
174
|
+
soft = 0.5 * (1.0 - erf(signed_mm / max(edge_mm, 1e-3)))
|
|
175
|
+
field_ = np.maximum(field_, soft.astype(np.float32))
|
|
176
|
+
labels[(r <= 1.0) & (labels == 0)] = les["id"]
|
|
177
|
+
return field_, labels
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _float_header(img):
|
|
181
|
+
hdr = img.header.copy()
|
|
182
|
+
hdr.set_data_dtype(np.float32)
|
|
183
|
+
hdr.set_slope_inter(1.0, 0.0)
|
|
184
|
+
return hdr
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def inject(t1_path: Path, flair_path: Path | None, out_t1: Path, out_flair: Path | None, out_mask: Path,
|
|
188
|
+
out_truth: Path, spec: LesionSpec, mask_path: Path | None = None) -> dict:
|
|
189
|
+
"""Write the injected T1w (and FLAIR), the truth label map and the truth JSON.
|
|
190
|
+
|
|
191
|
+
Nothing is written until every input has been checked, so a failure
|
|
192
|
+
leaves no partial subject behind.
|
|
193
|
+
"""
|
|
194
|
+
rng = np.random.default_rng(spec.seed)
|
|
195
|
+
t1_img = nib.load(t1_path)
|
|
196
|
+
t1 = np.asarray(t1_img.dataobj, dtype=np.float32)
|
|
197
|
+
zooms = t1_img.header.get_zooms()
|
|
198
|
+
fl_img = fl = None
|
|
199
|
+
if flair_path is not None and out_flair is not None:
|
|
200
|
+
fl_img = nib.load(flair_path)
|
|
201
|
+
if fl_img.shape != t1_img.shape or not np.allclose(fl_img.affine, t1_img.affine, atol=1e-3):
|
|
202
|
+
raise ValueError(f"FLAIR grid {fl_img.shape} differs from T1w {t1_img.shape} (or the affines differ); "
|
|
203
|
+
"inject expects co-registered images on one grid")
|
|
204
|
+
fl = np.asarray(fl_img.dataobj, dtype=np.float32)
|
|
205
|
+
mask = None
|
|
206
|
+
if mask_path is not None:
|
|
207
|
+
m_img = nib.load(mask_path)
|
|
208
|
+
if m_img.shape != t1_img.shape:
|
|
209
|
+
raise ValueError(f"brain mask grid {m_img.shape} differs from T1w {t1_img.shape}")
|
|
210
|
+
mask = np.asarray(m_img.dataobj) > 0
|
|
211
|
+
brain, wm = brain_and_wm(t1, zooms, fl, mask)
|
|
212
|
+
depth = ndi.distance_transform_edt(brain, sampling=zooms[:3])
|
|
213
|
+
lesions = place_lesions(wm, depth, zooms, spec, rng)
|
|
214
|
+
field_, labels = render(t1.shape, zooms, lesions, spec.edge_mm)
|
|
215
|
+
# reference intensity: median of the white-matter estimate over the whole brain
|
|
216
|
+
wm_ref_t1 = float(np.median(t1[wm]))
|
|
217
|
+
t1_out = t1 * (1.0 + spec.t1_contrast * field_)
|
|
218
|
+
nib.save(nib.Nifti1Image(t1_out.astype(np.float32), t1_img.affine, _float_header(t1_img)), out_t1)
|
|
219
|
+
flair_note = None
|
|
220
|
+
if fl is not None:
|
|
221
|
+
wm_ref_fl = float(np.median(fl[wm]))
|
|
222
|
+
fl_out = fl + spec.flair_contrast * wm_ref_fl * field_
|
|
223
|
+
nib.save(nib.Nifti1Image(fl_out.astype(np.float32), fl_img.affine, _float_header(fl_img)), out_flair)
|
|
224
|
+
flair_note = {"wm_reference": wm_ref_fl, "contrast": spec.flair_contrast}
|
|
225
|
+
nib.save(nib.Nifti1Image(labels, t1_img.affine), out_mask)
|
|
226
|
+
voxel_mm3 = float(np.prod(zooms[:3]))
|
|
227
|
+
for les in lesions:
|
|
228
|
+
les["voxels"] = int((labels == les["id"]).sum())
|
|
229
|
+
les["volume_mm3"] = les["voxels"] * voxel_mm3 # the label's volume, which is what is scored
|
|
230
|
+
truth = {
|
|
231
|
+
"kind": "lesions", "seed": spec.seed, "n": len(lesions), "edge_mm": spec.edge_mm,
|
|
232
|
+
"edge": "field is 0.5 on the ellipsoid surface, erf falloff over edge_mm; label = inside the surface",
|
|
233
|
+
"t1": {"wm_reference": wm_ref_t1, "contrast": spec.t1_contrast}, "flair": flair_note,
|
|
234
|
+
"brain_mask": "given" if mask is not None else "estimated",
|
|
235
|
+
"brain_volume_mm3": float(brain.sum() * voxel_mm3),
|
|
236
|
+
"voxel_mm": [float(z) for z in zooms[:3]],
|
|
237
|
+
"lesions": lesions,
|
|
238
|
+
"total_volume_mm3": float(sum(les["volume_mm3"] for les in lesions)),
|
|
239
|
+
"mask_voxels": int((labels > 0).sum()),
|
|
240
|
+
}
|
|
241
|
+
with open(out_truth, "w") as fh:
|
|
242
|
+
json.dump(truth, fh, indent=2)
|
|
243
|
+
return truth
|
bidsgate/report.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""A self-contained HTML scorecard, one file, no scripts, readable in any browser."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import html
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _pct(x) -> str:
|
|
11
|
+
try:
|
|
12
|
+
v = float(x)
|
|
13
|
+
except (TypeError, ValueError):
|
|
14
|
+
return "-"
|
|
15
|
+
return "-" if v != v else f"{100 * v:.0f}%"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _f(x, d=2) -> str:
|
|
19
|
+
try:
|
|
20
|
+
v = float(x)
|
|
21
|
+
except (TypeError, ValueError):
|
|
22
|
+
return "-"
|
|
23
|
+
return "-" if v != v else f"{v:.{d}f}"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def scorecard_lesions(results: list[dict], pipeline: str, out: Path) -> Path:
|
|
27
|
+
"""``results`` is a list of {"subject": ..., "score": score_lesions(...)}."""
|
|
28
|
+
rows = []
|
|
29
|
+
for r in results:
|
|
30
|
+
s = r["score"]
|
|
31
|
+
rows.append(f"<tr><td>{html.escape(r['subject'])}</td><td>{_f(s['dice'])}</td><td>{s['detected']}/{s['lesions']} ({_pct(s['sensitivity'])})</td>"
|
|
32
|
+
f"<td>{s['false_positive_components']}</td><td>{_f(s['volume_ratio'])}</td>"
|
|
33
|
+
+ "".join(f"<td>{_pct(v['sensitivity'])} (n={v['n']})</td>" for _, v in sorted(s['by_size'].items()))
|
|
34
|
+
+ "</tr>")
|
|
35
|
+
n = len(results)
|
|
36
|
+
dice = sum(r["score"]["dice"] for r in results) / n if n else float("nan")
|
|
37
|
+
sens = sum(r["score"]["sensitivity"] for r in results) / n if n else float("nan")
|
|
38
|
+
fp = sum(r["score"]["false_positive_components"] for r in results)
|
|
39
|
+
bins = sorted(results[0]["score"]["by_size"].keys()) if results else []
|
|
40
|
+
doc = f"""<!doctype html><html><head><meta charset="utf-8"><title>bidsgate scorecard: {html.escape(pipeline)}</title>
|
|
41
|
+
<style>body{{font:15px/1.4 system-ui,sans-serif;max-width:960px;margin:2rem auto;padding:0 1rem;color:#222}}
|
|
42
|
+
table{{border-collapse:collapse;width:100%}}td,th{{border-bottom:1px solid #ddd;padding:.4rem .5rem;text-align:left}}
|
|
43
|
+
th{{background:#f4f4f4}}.big{{font-size:2rem;margin:.2rem 0}}.grid{{display:flex;gap:2rem;flex-wrap:wrap}}.note{{color:#555}}</style></head><body>
|
|
44
|
+
<h1>bidsgate scorecard: {html.escape(pipeline)}</h1>
|
|
45
|
+
<p class="note">Synthetic lesions with a known mask were injected into real T1w/FLAIR images; the pipeline was run on the result; this is what it recovered. Generated {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}. Nothing here is evidence about any disease; it is a test of software.</p>
|
|
46
|
+
<div class="grid"><div><div class="big">{_f(dice)}</div>mean Dice</div><div><div class="big">{_pct(sens)}</div>lesion-wise sensitivity</div><div><div class="big">{fp}</div>false-positive components</div><div><div class="big">{n}</div>subjects</div></div>
|
|
47
|
+
<table><tr><th>subject</th><th>Dice</th><th>detected</th><th>FP comps</th><th>volume ratio</th>{''.join(f'<th>sens {html.escape(b)}</th>' for b in bins)}</tr>{''.join(rows)}</table>
|
|
48
|
+
<p class="note">Sensitivity by injected lesion volume shows the detection floor: the size below which the pipeline stops seeing lesions. Volume ratio is predicted over injected volume; under 1 means the pipeline under-segments what it does find.</p>
|
|
49
|
+
</body></html>"""
|
|
50
|
+
out.write_text(doc)
|
|
51
|
+
return out
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def scorecard_atrophy(results: list[dict], pipeline: str, out: Path) -> Path:
|
|
55
|
+
rows = "".join(f"<tr><td>{html.escape(r['subject'])}</td><td>{_f(r['score']['injected_change_pct'],1)}%</td><td>{_f(r['score']['measured_change_pct'],1)}%</td><td>{_f(r['score']['recovery'])}</td></tr>" for r in results)
|
|
56
|
+
n = len(results)
|
|
57
|
+
rec = sum(r["score"]["recovery"] for r in results) / n if n else float("nan")
|
|
58
|
+
doc = f"""<!doctype html><html><head><meta charset="utf-8"><title>bidsgate scorecard: {html.escape(pipeline)}</title>
|
|
59
|
+
<style>body{{font:15px/1.4 system-ui,sans-serif;max-width:960px;margin:2rem auto;padding:0 1rem;color:#222}}table{{border-collapse:collapse;width:100%}}td,th{{border-bottom:1px solid #ddd;padding:.4rem .5rem;text-align:left}}th{{background:#f4f4f4}}.big{{font-size:2rem}}.note{{color:#555}}</style></head><body>
|
|
60
|
+
<h1>bidsgate scorecard: {html.escape(pipeline)}</h1>
|
|
61
|
+
<p class="note">A known brain-volume change was injected into real T1w images; the pipeline measured volumes before and after. Recovery 1.0 means it reported exactly the injected change.</p>
|
|
62
|
+
<div class="big">{_f(rec)}</div>mean recovery over {n} subjects
|
|
63
|
+
<table><tr><th>subject</th><th>injected change</th><th>measured change</th><th>recovery</th></tr>{rows}</table></body></html>"""
|
|
64
|
+
out.write_text(doc)
|
|
65
|
+
return out
|
bidsgate/score.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Score what a pipeline recovered against the injected truth."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import nibabel as nib
|
|
9
|
+
import numpy as np
|
|
10
|
+
import pandas as pd
|
|
11
|
+
from scipy import ndimage as ndi
|
|
12
|
+
|
|
13
|
+
SIZE_BINS = [(0, 100), (100, 500), (500, 10**9)]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _bin(vol: float) -> str:
|
|
17
|
+
for lo, hi in SIZE_BINS:
|
|
18
|
+
if lo <= vol < hi:
|
|
19
|
+
return f"{lo}-{hi if hi < 10**9 else 'inf'}mm3"
|
|
20
|
+
return "?"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def score_lesions(truth_json: Path, truth_mask: Path, pred_mask: Path, threshold: float = 0.5,
|
|
24
|
+
fp_margin_mm: float = 2.0) -> dict:
|
|
25
|
+
"""Voxel and lesion-wise agreement between a predicted mask and the injected one.
|
|
26
|
+
|
|
27
|
+
A predicted mask may be probabilistic; it is thresholded at ``threshold``.
|
|
28
|
+
A truth lesion counts as detected when any predicted voxel overlaps it.
|
|
29
|
+
False-positive volume is every predicted voxel farther than ``fp_margin_mm``
|
|
30
|
+
from any truth lesion, and false-positive components are the connected
|
|
31
|
+
pieces (18-connectivity) of that residual, so over-segmentation that
|
|
32
|
+
happens to touch a true lesion is still counted.
|
|
33
|
+
"""
|
|
34
|
+
with open(truth_json) as fh:
|
|
35
|
+
truth = json.load(fh)
|
|
36
|
+
t_img = nib.load(truth_mask)
|
|
37
|
+
t = np.asarray(t_img.dataobj).astype(np.int32)
|
|
38
|
+
p_img = nib.load(pred_mask)
|
|
39
|
+
p = np.asarray(p_img.dataobj, dtype=np.float32)
|
|
40
|
+
if p.shape != t.shape or not np.allclose(p_img.affine, t_img.affine, atol=1e-3):
|
|
41
|
+
raise ValueError(f"prediction {p.shape} is not on the truth grid {t.shape} (shape or affine differs); "
|
|
42
|
+
"resample it onto the truth image first")
|
|
43
|
+
pb = p >= threshold
|
|
44
|
+
tb = t > 0
|
|
45
|
+
inter = float((pb & tb).sum())
|
|
46
|
+
dice = 2 * inter / (pb.sum() + tb.sum()) if (pb.sum() + tb.sum()) else float("nan")
|
|
47
|
+
voxel_mm3 = float(np.prod(t_img.header.get_zooms()[:3]))
|
|
48
|
+
rows = []
|
|
49
|
+
for les in truth["lesions"]:
|
|
50
|
+
sel = t == les["id"]
|
|
51
|
+
hit = bool(pb[sel].any())
|
|
52
|
+
rows.append({"id": les["id"], "volume_mm3": les["volume_mm3"], "voxels": int(sel.sum()),
|
|
53
|
+
"bin": _bin(les["volume_mm3"]), "detected": hit,
|
|
54
|
+
"overlap_fraction": float(pb[sel].mean()) if sel.any() else float("nan")})
|
|
55
|
+
per = pd.DataFrame(rows)
|
|
56
|
+
near_truth = ndi.distance_transform_edt(~tb, sampling=t_img.header.get_zooms()[:3]) <= fp_margin_mm
|
|
57
|
+
residual = pb & ~near_truth
|
|
58
|
+
_, fp = ndi.label(residual, structure=ndi.generate_binary_structure(3, 2))
|
|
59
|
+
fp_volume = float(residual.sum() * voxel_mm3)
|
|
60
|
+
by_bin = per.groupby("bin")["detected"].agg(["count", "mean"]).rename(columns={"count": "n", "mean": "sensitivity"})
|
|
61
|
+
return {
|
|
62
|
+
"dice": dice,
|
|
63
|
+
"sensitivity": float(per["detected"].mean()) if len(per) else float("nan"),
|
|
64
|
+
"lesions": len(per),
|
|
65
|
+
"detected": int(per["detected"].sum()),
|
|
66
|
+
"false_positive_components": int(fp),
|
|
67
|
+
"false_positive_volume_mm3": fp_volume,
|
|
68
|
+
"fp_margin_mm": fp_margin_mm,
|
|
69
|
+
"predicted_volume_mm3": float(pb.sum() * voxel_mm3),
|
|
70
|
+
"truth_volume_mm3": float(tb.sum() * voxel_mm3),
|
|
71
|
+
"volume_ratio": float(pb.sum() / tb.sum()) if tb.sum() else float("nan"),
|
|
72
|
+
"by_size": {k: {"n": int(v["n"]), "sensitivity": float(v["sensitivity"])} for k, v in by_bin.iterrows()},
|
|
73
|
+
"per_lesion": rows,
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def score_atrophy(truth_json: Path, volume_before_mm3: float, volume_after_mm3: float) -> dict:
|
|
78
|
+
"""Compare a tool's reported volumes before and after injection with the injected factor."""
|
|
79
|
+
with open(truth_json) as fh:
|
|
80
|
+
truth = json.load(fh)
|
|
81
|
+
injected = truth["volume_factor"]
|
|
82
|
+
measured = volume_after_mm3 / volume_before_mm3 if volume_before_mm3 else float("nan")
|
|
83
|
+
injected_change = (injected - 1) * 100
|
|
84
|
+
measured_change = (measured - 1) * 100
|
|
85
|
+
return {
|
|
86
|
+
"injected_factor": injected,
|
|
87
|
+
"measured_factor": measured,
|
|
88
|
+
"injected_change_pct": injected_change,
|
|
89
|
+
"measured_change_pct": measured_change,
|
|
90
|
+
"recovery": measured_change / injected_change if injected_change else float("nan"),
|
|
91
|
+
"volume_before_mm3": volume_before_mm3,
|
|
92
|
+
"volume_after_mm3": volume_after_mm3,
|
|
93
|
+
}
|
|
@@ -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,13 @@
|
|
|
1
|
+
bidsgate/__init__.py,sha256=SK_XC7inUZFQ2fzaZ-L7WFImNgW1v9o4MqtUngwL2SM,277
|
|
2
|
+
bidsgate/bids.py,sha256=eyg8bKGhOSg5xiuRff_lYlAjUdkONQFDAaFyCRtRQQ4,3069
|
|
3
|
+
bidsgate/cli.py,sha256=pzJda0p7_sII5QRpdD9T6h3pIt88wBBtL5yQzFecwPU,9650
|
|
4
|
+
bidsgate/inject_atrophy.py,sha256=yATkb8D7JOJJpS218GkhOLpbeOk04ARiP92Bf2y7DRc,4131
|
|
5
|
+
bidsgate/inject_lesions.py,sha256=np70Gh1ILtTgbyXb1xvAGVN2fP1OgqivYtavnLUus2w,11806
|
|
6
|
+
bidsgate/report.py,sha256=VIN7K56b1QAdrxVtASwfh1M3AbvteId-5dYRBu1EaFg,4379
|
|
7
|
+
bidsgate/score.py,sha256=8_sgpzaS_-jI6tihMX1zAcW4N-tc3onuYjWKCZRG8R8,4160
|
|
8
|
+
bidsgate-0.1.0.dist-info/licenses/LICENSE,sha256=8lcLWeN_AyQyu7Rc59azPb8rsdho2H380fSv3nnRoII,1070
|
|
9
|
+
bidsgate-0.1.0.dist-info/METADATA,sha256=OoFHYpvGx71xUtZYR_tlRHog_DHzPT5J1BwGl_7_zLs,9251
|
|
10
|
+
bidsgate-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
11
|
+
bidsgate-0.1.0.dist-info/entry_points.txt,sha256=DDJk7x_iZgu5Biy7r2nvvp6EuQ21MdyX_FNuXiZ7wqE,47
|
|
12
|
+
bidsgate-0.1.0.dist-info/top_level.txt,sha256=ucCM7miNa7_wAKvb9c9A5UM_5pJMiYkK1Bgw13Atulg,9
|
|
13
|
+
bidsgate-0.1.0.dist-info/RECORD,,
|
|
@@ -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 @@
|
|
|
1
|
+
bidsgate
|