brain-zero / tests /test_build_brain_asset.py
rfick's picture
Squash history: one commit holding the current files
6bc44d6
Raw History Blame Contribute Delete
10.8 kB
"""``build_brain_asset.py``'s pieces, on tiny synthetic data: merging shells, the mean b=0, the manifest payload and
the checks sample are pure numpy/nibabel and run in every CI; the dmipy-fit MSMT-CSD fit itself needs cvxpy and is
skipped when it is not installed (the offline build's own venv; not part of the Space's requirements.txt)."""
import json
import os
import nibabel as nib
import numpy as np
import pytest
from tools import build_brain_asset as BA
def _write_shell(tmp_path, stem, shape, bval, n_dirs, affine, seed):
rng = np.random.default_rng(seed)
n = n_dirs + 1
data = rng.random(shape + (n,)).astype(np.float32) + 1.0
nib.Nifti1Image(data, affine).to_filename(os.path.join(tmp_path, stem + ".nii.gz"))
bvals = np.r_[np.full(n_dirs, float(bval)), 0.0]
dirs = rng.normal(size=(n_dirs, 3))
dirs /= np.linalg.norm(dirs, axis=1, keepdims=True)
bvecs = np.vstack([dirs, [[0.0, 0.0, 0.0]]])
np.savetxt(os.path.join(tmp_path, stem + ".bval"), bvals[None, :], fmt="%g")
np.savetxt(os.path.join(tmp_path, stem + ".bvec"), bvecs.T, fmt="%g")
return data, bvals, bvecs
def test_merge_shells_concatenates_and_orders(tmp_path):
affine = np.diag([2.5, 2.5, 2.5, 1.0])
shape = (4, 4, 3)
d1, bv1, vec1 = _write_shell(tmp_path, "a_b1000n4_dwi", shape, 1000, 4, affine, seed=0)
d2, bv2, vec2 = _write_shell(tmp_path, "b_b2000n6_dwi", shape, 2000, 6, affine, seed=1)
data, bvals, bvecs, aff, shells = BA.merge_shells(str(tmp_path))
assert data.shape == shape + (5 + 7,)
assert bvals.shape == (12,)
assert bvecs.shape == (12, 3)
np.testing.assert_allclose(aff, affine)
assert shells == [(1000.0, 4), (2000.0, 6)]
# every file's own b0 (last column) survives the concatenation
assert np.sum(bvals < BA.B0_THRESHOLD_S_MM2) == 2
def test_merge_shells_rejects_a_mismatched_grid(tmp_path):
affine = np.diag([2.5, 2.5, 2.5, 1.0])
_write_shell(tmp_path, "a_b1000n2_dwi", (4, 4, 3), 1000, 2, affine, seed=0)
_write_shell(tmp_path, "b_b2000n2_dwi", (5, 4, 3), 2000, 2, affine, seed=1)
with pytest.raises(ValueError):
BA.merge_shells(str(tmp_path))
def test_mean_b0_averages_only_the_b0_volumes():
bvals = np.array([0.0, 1000.0, 1000.0, 10.0])
data = np.zeros((2, 2, 2, 4), dtype=np.float32)
data[..., 0] = 2.0
data[..., 3] = 4.0
data[..., 1] = 999.0
data[..., 2] = 999.0
out = BA.mean_b0(data, bvals)
assert out.shape == (2, 2, 2)
np.testing.assert_allclose(out, 3.0)
def test_mean_b0_raises_without_a_b0():
with pytest.raises(ValueError):
BA.mean_b0(np.zeros((2, 2, 2, 3)), np.array([1000.0, 1000.0, 1000.0]))
def test_build_manifest_schema():
m = BA.build_manifest(
subject="s", scan="sc", source_doi="10.x/y", grid_shape=(2, 3, 4), voxel_size_mm=(2.5, 2.5, 2.5),
affine=np.eye(4), bvals=np.array([0.0, 1000.0]), bvecs=np.zeros((2, 3)), te_s=None, delta_s=None,
delta_cap_s=None, dmipy_fit_commit="abc123", disco_space_commit="def456", solver="csd_cvxpy", n_regions=84)
json.dumps(m) # every field must be JSON-serialisable
assert m["source"]["dataset"] == "OpenNeuro ds003416"
assert m["source"]["paper_doi"] == "10.1002/mrm.28926"
assert m["source"]["license"] == "CC0"
assert m["grid"]["shape"] == [2, 3, 4]
assert m["fod"]["lmax"] == BA.SH_ORDER
assert m["fractions"] == ["wm", "gm", "csf"]
assert m["reconstruction"]["tool"] == "dmipy-fit"
assert m["reconstruction"]["commit"] == "abc123"
assert "eroded 3 passes" in m["reconstruction"]["responses"] and "tournier13" in m["reconstruction"]["responses"]
assert m["parcellation"]["n_regions"] == 84
def test_save_checks_sample_is_reproducible(tmp_path):
rng = np.random.default_rng(0)
data = rng.random((5, 5, 5, 7)).astype(np.float32)
sh = rng.random((5, 5, 5, 45)).astype(np.float32)
fractions = rng.random((5, 5, 5, 3)).astype(np.float32)
mask = np.zeros((5, 5, 5), dtype=bool)
mask[1:4, 1:4, 1:4] = True
bvals = np.linspace(0, 3000, 7)
BA.save_checks_sample(str(tmp_path), data, bvals, sh, fractions, mask, n_sample=10, seed=0)
out = np.load(os.path.join(tmp_path, "checks", "msmt_sample.npz"))
assert out["voxel_index"].shape == (10, 3)
assert out["signal"].shape == (10, 7)
assert out["sh_coeff"].shape == (10, 45)
assert out["fractions"].shape == (10, 3)
for idx, sig in zip(out["voxel_index"], out["signal"]):
assert mask[tuple(idx)]
np.testing.assert_allclose(sig, data[tuple(idx)])
BA.save_checks_sample(str(tmp_path), data, bvals, sh, fractions, mask, n_sample=10, seed=0)
out2 = np.load(os.path.join(tmp_path, "checks", "msmt_sample.npz"))
np.testing.assert_array_equal(out["voxel_index"], out2["voxel_index"])
def test_fraction_sanity():
fractions = np.zeros((2, 2, 1, 3), dtype=np.float32)
fractions[0, 0, 0] = [0.7, 0.2, 0.1] # sum 1.0, WM > 0.5
fractions[0, 1, 0] = [0.3, 0.3, 0.3] # sum 0.9, WM not > 0.5
fractions[1, 0, 0] = [0.9, 0.05, 0.05] # sum 1.0, WM > 0.5
mask = np.zeros((2, 2, 1), dtype=bool)
mask[0, 0, 0] = mask[0, 1, 0] = mask[1, 0, 0] = True # (1, 1, 0) left out of the mask on purpose
out = BA.fraction_sanity(fractions, mask)
assert out["n_mask"] == 3
assert out["n_wm_gt_half"] == 2
assert out["sum_median"] == pytest.approx(1.0)
def _pure_direction_sh(sh_order, direction):
"""The real-SH coefficients of a single delta-like lobe along ``direction`` (dipy's own ``sf_to_sh`` on a
one-hot signal at a fine sphere, so the test does not depend on dmipy-fit's basis machinery)."""
from dipy.data import get_sphere
from dipy.reconst.shm import sf_to_sh
sphere = get_sphere(name="repulsion724")
cos_angle = sphere.vertices @ np.asarray(direction) / np.linalg.norm(direction)
sf = np.clip(cos_angle, 0, None) ** 16 # a sharp lobe peaked at `direction`
return sf_to_sh(sf, sphere, sh_order_max=sh_order, basis_type="tournier07")
def test_fod_peak_counts_single_vs_crossing():
sh_order = 8
single = _pure_direction_sh(sh_order, (0, 0, 1))
crossing = _pure_direction_sh(sh_order, (1, 0, 0)) + _pure_direction_sh(sh_order, (0, 1, 0))
sh = np.stack([single, crossing]).reshape(1, 2, 1, -1).astype(np.float32)
wm_mask = np.ones((1, 2, 1), dtype=bool)
counts = BA.fod_peak_counts(sh, wm_mask, sh_order=sh_order)
assert sum(counts.values()) == 2
assert counts.get(1, 0) >= 1 # the single lobe
assert counts.get(2, 0) >= 1 # the two well-separated lobes
def test_save_mean_b0_fod_png_writes_a_file(tmp_path):
shape = (4, 4, 2)
mean_b0_volume = np.random.default_rng(0).random(shape).astype(np.float32)
sh = np.zeros(shape + (45,), dtype=np.float32)
sh[..., 0] = 1.0 # an isotropic (but non-zero) FOD everywhere: a valid, if uninteresting, principal direction
wm_mask = np.ones(shape, dtype=bool)
out_path = os.path.join(tmp_path, "slice.png")
BA.save_mean_b0_fod_png(out_path, mean_b0_volume, sh, wm_mask, z=0)
assert os.path.exists(out_path) and os.path.getsize(out_path) > 0
def _single_tensor_attenuation(bvals_s_mm2, bvecs, eigenvalues_mm2_s, axis):
"""A closed-form single-tensor signal attenuation ``exp(-b g^T D g)``, no dmipy-sim/jax needed: ``D`` has its
principal eigenvector along ``axis`` (unit vector) with ``eigenvalues_mm2_s = (lambda_par, lambda_perp)``."""
axis = np.asarray(axis, dtype=np.float64)
axis = axis / np.linalg.norm(axis)
lambda_par, lambda_perp = eigenvalues_mm2_s
d_par_perp = lambda_par - lambda_perp
cos2 = (bvecs @ axis) ** 2
return np.exp(-bvals_s_mm2 * (lambda_perp + d_par_perp * cos2))
def test_fit_msmt_csd_end_to_end_on_a_tiny_synthetic_volume():
"""A smoke test of the dmipy-fit call itself (skipped if cvxpy is not installed): a few voxels of a synthetic
single-tensor-plus-isotropic signal (plain numpy, no dmipy-sim), checking only shapes and that the fractions
come out non-negative."""
pytest.importorskip("cvxpy")
rng = np.random.default_rng(0)
n_dirs = 30
dirs = rng.normal(size=(n_dirs, 3))
dirs /= np.linalg.norm(dirs, axis=1, keepdims=True)
bvals_shell = np.repeat([1000.0, 2000.0], n_dirs)
bvecs_shell = np.tile(dirs, (2, 1))
bvals = np.r_[0.0, 0.0, bvals_shell]
bvecs = np.vstack([[0, 0, 0], [0, 0, 0], bvecs_shell])
# dhollander16 classifies every voxel by FA and a signal-decay metric, then takes small percentiles (2 %, 10 %)
# of the GM-like / CSF-like populations as its candidates: a few discrete classes risk one of those percentiles
# rounding to an empty set (a NaN response, poisoning the CSD kernel), so the volume instead has a continuum of
# randomly oriented, randomly scaled tensors spanning WM-like (high FA) through GM-like to CSF-like (isotropic,
# high diffusivity) -- much closer to what real brain data actually looks like to this heuristic.
# every side 6 voxels longer than the 3-pass erosion of the response selection removes from it
shape = (16, 16, 12)
n_voxels = int(np.prod(shape))
fa_like = rng.uniform(0.0, 1.0, size=n_voxels) # 0 = isotropic, 1 = maximally anisotropic
mean_d = np.where(fa_like < 0.5, rng.uniform(0.7e-3, 1.0e-3, n_voxels), # GM/WM-ish mean diffusivity
rng.uniform(1.0e-3, 1.4e-3, n_voxels))
is_csf = rng.uniform(0, 1, n_voxels) < 0.15
mean_d = np.where(is_csf, rng.uniform(2.8e-3, 3.2e-3, n_voxels), mean_d)
fa_like = np.where(is_csf, 0.0, fa_like)
lambda_perp = mean_d * (1.0 - 0.6 * fa_like)
lambda_par = mean_d * (1.0 + 1.2 * fa_like)
axes = rng.normal(size=(n_voxels, 3))
mask = np.ones(shape, dtype=bool)
data = np.empty((n_voxels, len(bvals)), dtype=np.float64)
for i in range(n_voxels):
data[i] = _single_tensor_attenuation(bvals, bvecs, (lambda_par[i], lambda_perp[i]), axis=axes[i])
data *= 1.0 + 0.01 * rng.standard_normal(data.shape)
data = data.reshape(shape + (len(bvals),))
voxel_positions = np.zeros(shape, dtype=bool)
voxel_positions[0, 0, 0] = voxel_positions[1, 0, 0] = voxel_positions[2, 0, 0] = voxel_positions[3, 0, 0] = True
sh, fractions, responses, out_scheme = BA.fit_msmt_csd(
data, bvals, bvecs, mask, sh_order=4, voxel_positions=voxel_positions)
assert sh.shape == shape + (15,) # lmax = 4 -> 15 real-SH coefficients
assert fractions.shape == shape + (3,)
assert responses["n_voxels_fit"] == voxel_positions.sum() == 4
assert np.all(fractions[voxel_positions] >= -1e-4) # non-negative to the QP solver's feasibility tolerance
assert np.all(fractions[~voxel_positions] == 0) # only the requested voxels were fit