test(mri): add deterministic synthetic NIfTI fixture (6 subjects × 2 sites)
Browse files- tests/fixtures/build_mri_fixture.py +77 -0
- tests/fixtures/mri_sample/sites.csv +7 -0
- tests/fixtures/mri_sample/subject_0.nii.gz +0 -0
- tests/fixtures/mri_sample/subject_1.nii.gz +0 -0
- tests/fixtures/mri_sample/subject_2.nii.gz +0 -0
- tests/fixtures/mri_sample/subject_3.nii.gz +0 -0
- tests/fixtures/mri_sample/subject_4.nii.gz +0 -0
- tests/fixtures/mri_sample/subject_5.nii.gz +0 -0
tests/fixtures/build_mri_fixture.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Generate deterministic synthetic MRI fixtures for the Day-3 pipeline tests.
|
| 2 |
+
|
| 3 |
+
Six 8×8×8 NIfTI volumes split across two simulated sites. Each volume is a
|
| 4 |
+
spherical "brain" with isotropic Gaussian noise plus a per-site additive bias
|
| 5 |
+
that ComBat is expected to remove. The fixture is committed alongside this
|
| 6 |
+
script so test runs are reproducible without re-running.
|
| 7 |
+
|
| 8 |
+
Channels:
|
| 9 |
+
- Site A: subject_0, subject_1, subject_2 (bias = +0.0 a.u.)
|
| 10 |
+
- Site B: subject_3, subject_4, subject_5 (bias = +5.0 a.u.)
|
| 11 |
+
|
| 12 |
+
NOTE: byte-determinism of the .nii.gz output is coupled to nibabel==5.2.1
|
| 13 |
+
(pinned in requirements.txt) and a fixed nibabel.Nifti1Image header. If the
|
| 14 |
+
nibabel pin is upgraded, re-run this script and commit the rebuilt artifacts
|
| 15 |
+
alongside the dependency bump.
|
| 16 |
+
"""
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
import csv
|
| 20 |
+
from pathlib import Path
|
| 21 |
+
|
| 22 |
+
import nibabel as nib
|
| 23 |
+
import numpy as np
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
SITE_A_BIAS = 0.0
|
| 27 |
+
SITE_B_BIAS = 5.0
|
| 28 |
+
VOLUME_SHAPE = (8, 8, 8)
|
| 29 |
+
SUBJECTS = (
|
| 30 |
+
("subject_0", "A"),
|
| 31 |
+
("subject_1", "A"),
|
| 32 |
+
("subject_2", "A"),
|
| 33 |
+
("subject_3", "B"),
|
| 34 |
+
("subject_4", "B"),
|
| 35 |
+
("subject_5", "B"),
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _spherical_brain(rng: np.random.Generator, bias: float) -> np.ndarray:
|
| 40 |
+
"""Build an 8×8×8 volume: spherical brain (radius 3) + noise + site bias."""
|
| 41 |
+
d, h, w = VOLUME_SHAPE
|
| 42 |
+
z, y, x = np.indices((d, h, w))
|
| 43 |
+
cz, cy, cx = (d - 1) / 2.0, (h - 1) / 2.0, (w - 1) / 2.0
|
| 44 |
+
radius2 = (z - cz) ** 2 + (y - cy) ** 2 + (x - cx) ** 2
|
| 45 |
+
brain_mask = radius2 <= 3.0**2
|
| 46 |
+
# Brain intensity ~10 a.u., background ~0.1 a.u. (so default threshold splits cleanly).
|
| 47 |
+
volume = np.where(brain_mask, 10.0, 0.1).astype(np.float64)
|
| 48 |
+
volume += rng.standard_normal(VOLUME_SHAPE) * 0.5
|
| 49 |
+
volume[brain_mask] += bias
|
| 50 |
+
return volume
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def build(out_dir: Path | None = None) -> Path:
|
| 54 |
+
out = out_dir if out_dir is not None else Path(__file__).parent / "mri_sample"
|
| 55 |
+
out.mkdir(parents=True, exist_ok=True)
|
| 56 |
+
|
| 57 |
+
rng = np.random.default_rng(seed=42)
|
| 58 |
+
affine = np.eye(4)
|
| 59 |
+
|
| 60 |
+
sites_rows: list[tuple[str, str]] = []
|
| 61 |
+
for subject_id, site in SUBJECTS:
|
| 62 |
+
bias = SITE_A_BIAS if site == "A" else SITE_B_BIAS
|
| 63 |
+
volume = _spherical_brain(rng, bias=bias)
|
| 64 |
+
img = nib.Nifti1Image(volume, affine=affine)
|
| 65 |
+
nib.save(img, out / f"{subject_id}.nii.gz")
|
| 66 |
+
sites_rows.append((subject_id, site))
|
| 67 |
+
|
| 68 |
+
with (out / "sites.csv").open("w", newline="") as fh:
|
| 69 |
+
writer = csv.writer(fh)
|
| 70 |
+
writer.writerow(["subject_id", "site"])
|
| 71 |
+
writer.writerows(sites_rows)
|
| 72 |
+
return out
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
if __name__ == "__main__":
|
| 76 |
+
p = build()
|
| 77 |
+
print(f"Wrote MRI fixture to {p}")
|
tests/fixtures/mri_sample/sites.csv
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
subject_id,site
|
| 2 |
+
subject_0,A
|
| 3 |
+
subject_1,A
|
| 4 |
+
subject_2,A
|
| 5 |
+
subject_3,B
|
| 6 |
+
subject_4,B
|
| 7 |
+
subject_5,B
|
tests/fixtures/mri_sample/subject_0.nii.gz
ADDED
|
Binary file (4.08 kB). View file
|
|
|
tests/fixtures/mri_sample/subject_1.nii.gz
ADDED
|
Binary file (4.07 kB). View file
|
|
|
tests/fixtures/mri_sample/subject_2.nii.gz
ADDED
|
Binary file (4.07 kB). View file
|
|
|
tests/fixtures/mri_sample/subject_3.nii.gz
ADDED
|
Binary file (4.07 kB). View file
|
|
|
tests/fixtures/mri_sample/subject_4.nii.gz
ADDED
|
Binary file (4.07 kB). View file
|
|
|
tests/fixtures/mri_sample/subject_5.nii.gz
ADDED
|
Binary file (4.07 kB). View file
|
|
|