mekosotto Claude Sonnet 4.6 commited on
Commit
c95fed4
·
1 Parent(s): 1a15285

test(mri): add deterministic synthetic NIfTI fixture (6 subjects × 2 sites)

Browse files
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