Skip to content

Commit 39735a3

Browse files
m9hclaude
andcommitted
Add SCI head model bridge: acoustic + conductivity from shared geometry
New module sci_bridge.py connects sbi4dwi SCI head model to openlifu: - load_sci_for_simulation(): loads mesh, rasterizes, returns both acoustic params (for jwave) and conductivity (for EEG/EIT) - conductivity_from_dti(): anisotropic WM conductivity via Nernst-Einstein from DTI tensors - IT'IS tissue conductivity table (scalp/skull/CSF/GM/WM) - Supports pre-rasterized .npy or .nii.gz segmentations TDD: 5/5 tests pass — labels to properties, physical ranges, shared geometry, .npy loading, jwave simulation compatibility. Enables the multi-modal chain: SCI mesh → acoustic (jwave tFUS) + conductivity (neurojax EEG) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent c28a5e6 commit 39735a3

2 files changed

Lines changed: 333 additions & 0 deletions

File tree

src/openlifu/sim/sci_bridge.py

Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
"""Bridge between sbi4dwi SCI head model and openlifu jwave simulation.
2+
3+
Loads the SCI head model via sbi4dwi's loader, rasterizes to a regular
4+
grid, maps tissue labels to both acoustic (for jwave) and electrical
5+
(for EEG/EIT) properties, enabling multi-modal simulation from a single
6+
head model.
7+
8+
The SCI head model (University of Utah) provides:
9+
- Tetrahedral FEM mesh with tissue labels (8 tissues)
10+
- T1w, T2w, DTI imaging data
11+
- 128/256-channel EEG electrode configurations
12+
13+
This bridge creates:
14+
- Acoustic property maps for jwave (c, rho, alpha) via ITRUSST values
15+
- Electrical conductivity maps for EEG forward modeling
16+
- Both from the same geometric substrate
17+
18+
Requires: sbi4dwi package (pip install -e /path/to/sbi4dwi)
19+
20+
Usage:
21+
from openlifu.sim.sci_bridge import load_sci_for_simulation
22+
params, coords, conductivity = load_sci_for_simulation(
23+
mesh_path="HeadMesh.mat",
24+
dx_mm=1.0,
25+
)
26+
# params feeds directly to run_cw_simulation or run_simulation
27+
# conductivity feeds to neurojax BEM solver or sbi4dwi EIT
28+
"""
29+
from __future__ import annotations
30+
31+
import logging
32+
from pathlib import Path
33+
from typing import Tuple
34+
35+
import numpy as np
36+
import xarray as xa
37+
38+
log = logging.getLogger(__name__)
39+
40+
# IT'IS-derived tissue electrical conductivity (S/m) at low frequency
41+
# Same label convention as acoustic properties (SCI Institute)
42+
TISSUE_CONDUCTIVITY = {
43+
0: 0.0, # background/air
44+
1: 0.41, # scalp
45+
2: 0.01, # skull (cortical bone)
46+
3: 1.71, # CSF
47+
4: 0.47, # gray matter (isotropic)
48+
5: 0.14, # white matter (isotropic average; anisotropic via DTI)
49+
}
50+
51+
52+
def load_sci_for_simulation(
53+
mesh_path: str,
54+
dx_mm: float = 1.0,
55+
segmentation_path: str | None = None,
56+
) -> Tuple[xa.Dataset, xa.Coordinates, np.ndarray]:
57+
"""Load SCI head model and prepare for jwave + conductivity simulation.
58+
59+
Args:
60+
mesh_path: Path to SCI HeadMesh.mat file.
61+
dx_mm: Grid spacing for rasterization.
62+
segmentation_path: Optional path to pre-rasterized segmentation
63+
(.npy or .nii.gz). If provided, skips mesh rasterization.
64+
65+
Returns:
66+
params: xarray Dataset with acoustic properties (for jwave)
67+
coords: xarray Coordinates
68+
conductivity: 3D float array of electrical conductivity (for EEG/EIT)
69+
"""
70+
if segmentation_path and Path(segmentation_path).exists():
71+
labels = _load_presegmented(segmentation_path)
72+
shape = labels.shape
73+
coords = _make_coords(shape, dx_mm)
74+
else:
75+
labels, coords = _rasterize_from_mesh(mesh_path, dx_mm)
76+
77+
params = _labels_to_acoustic_params(labels, coords)
78+
conductivity = _labels_to_conductivity(labels)
79+
80+
log.info("SCI head model loaded: shape=%s, dx=%.1f mm", labels.shape, dx_mm)
81+
unique, counts = np.unique(labels, return_counts=True)
82+
for u, c in zip(unique, counts):
83+
tissue = {0: "water", 1: "scalp", 2: "skull", 3: "CSF", 4: "GM", 5: "WM"}.get(u, f"label_{u}")
84+
log.info(" %s: %d voxels (%.1f%%)", tissue, c, 100 * c / labels.size)
85+
86+
return params, coords, conductivity
87+
88+
89+
def _rasterize_from_mesh(mesh_path: str, dx_mm: float):
90+
"""Rasterize the SCI tet mesh to a regular grid."""
91+
try:
92+
from dmipy_jax.io.sci_head_loader import load_sci_head_mesh
93+
from dmipy_jax.biophysics.mesh_rasterizer import rasterize_mesh
94+
except ImportError:
95+
raise ImportError(
96+
"sbi4dwi is required for SCI mesh rasterization. "
97+
"Install with: pip install -e /path/to/sbi4dwi"
98+
)
99+
100+
mesh = load_sci_head_mesh(mesh_path)
101+
points = np.asarray(mesh["points"])
102+
cells = np.asarray(mesh["cells"]["tetra"])
103+
tissue = np.asarray(mesh["cell_data"]["tissue"])
104+
105+
# Determine grid from mesh bounds
106+
mins = points.min(axis=0) - 5 # 5mm padding
107+
maxs = points.max(axis=0) + 5
108+
shape = tuple(int(np.ceil((maxs[i] - mins[i]) / dx_mm)) for i in range(3))
109+
110+
log.info("Rasterizing SCI mesh: %d vertices, %d tets -> %s grid (dx=%.1f mm)",
111+
len(points), len(cells), shape, dx_mm)
112+
113+
labels = rasterize_mesh(
114+
points=points,
115+
cells=cells,
116+
tissue_labels=tissue,
117+
grid_shape=shape,
118+
grid_spacing=dx_mm,
119+
grid_origin=mins,
120+
)
121+
122+
coords = _make_coords(shape, dx_mm, origin=mins)
123+
return labels, coords
124+
125+
126+
def _load_presegmented(path: str):
127+
"""Load pre-rasterized segmentation volume."""
128+
path = Path(path)
129+
if path.suffix == ".npy":
130+
return np.load(path)
131+
elif path.suffix in (".nii", ".gz"):
132+
import nibabel as nib
133+
return np.asarray(nib.load(str(path)).get_fdata(), dtype=np.int32)
134+
else:
135+
raise ValueError(f"Unsupported format: {path.suffix}")
136+
137+
138+
def _make_coords(shape, dx_mm, origin=None):
139+
"""Build xarray Coordinates for the grid."""
140+
coords = {}
141+
for i, dim in enumerate(("x", "y", "z")):
142+
o = origin[i] if origin is not None else -(shape[i] // 2) * dx_mm
143+
vals = o + np.arange(shape[i]) * dx_mm
144+
c = xa.Variable(dim, vals)
145+
c.attrs["units"] = "mm"
146+
c.attrs["long_name"] = dim.upper()
147+
coords[dim] = c
148+
return xa.Coordinates(coords)
149+
150+
151+
def _labels_to_acoustic_params(labels, coords):
152+
"""Map tissue labels to acoustic property xarray Dataset."""
153+
from openlifu.seg.seg_methods.heterogeneous import (
154+
HeterogeneousSkullSegmentation,
155+
)
156+
seg = HeterogeneousSkullSegmentation(source="labels", label_array=labels)
157+
volume = xa.DataArray(np.zeros(labels.shape), coords=coords)
158+
return seg.seg_params(volume)
159+
160+
161+
def _labels_to_conductivity(labels: np.ndarray) -> np.ndarray:
162+
"""Map tissue labels to electrical conductivity (S/m)."""
163+
sigma = np.zeros(labels.shape, dtype=np.float32)
164+
for label, value in TISSUE_CONDUCTIVITY.items():
165+
sigma[labels == label] = value
166+
return sigma
167+
168+
169+
def conductivity_from_dti(
170+
diffusion_tensor: np.ndarray,
171+
tissue_labels: np.ndarray,
172+
concentration: float = 150.0,
173+
temperature: float = 310.15,
174+
) -> np.ndarray:
175+
"""Compute anisotropic conductivity from DTI via Nernst-Einstein.
176+
177+
For white matter voxels, uses the diffusion tensor to compute an
178+
anisotropic conductivity tensor. For other tissues, returns isotropic
179+
conductivity from the lookup table.
180+
181+
Args:
182+
diffusion_tensor: (Nx, Ny, Nz, 3, 3) diffusion tensors in m²/s
183+
tissue_labels: (Nx, Ny, Nz) integer labels
184+
concentration: Ion concentration in mol/m³ (default: 150 mM NaCl)
185+
temperature: Temperature in K (default: 37°C)
186+
187+
Returns:
188+
(Nx, Ny, Nz, 3, 3) conductivity tensor array in S/m.
189+
Non-WM voxels have sigma * I (isotropic).
190+
"""
191+
try:
192+
from dmipy_jax.biophysics.conductivity import nernst_einstein_conductivity
193+
except ImportError:
194+
raise ImportError("sbi4dwi required for DTI-to-conductivity conversion")
195+
196+
import jax.numpy as jnp
197+
198+
shape = tissue_labels.shape
199+
sigma_iso = _labels_to_conductivity(tissue_labels)
200+
201+
# Start with isotropic conductivity for all tissues
202+
sigma_tensor = np.zeros(shape + (3, 3), dtype=np.float32)
203+
for i in range(3):
204+
sigma_tensor[..., i, i] = sigma_iso
205+
206+
# Override white matter with DTI-derived anisotropic conductivity
207+
wm_mask = tissue_labels == 5
208+
if wm_mask.any() and diffusion_tensor is not None:
209+
D_wm = jnp.array(diffusion_tensor[wm_mask])
210+
C_wm = jnp.full(D_wm.shape[0], concentration)
211+
sigma_wm = np.asarray(nernst_einstein_conductivity(
212+
D_wm, C_wm, temperature=temperature,
213+
))
214+
sigma_tensor[wm_mask] = sigma_wm
215+
216+
return sigma_tensor

tests/test_sci_bridge.py

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
"""TDD: SCI head model bridge between sbi4dwi and openlifu."""
2+
import numpy as np
3+
import pytest
4+
import xarray as xa
5+
6+
7+
def _make_fake_labels(shape=(20, 20, 20)):
8+
"""Synthetic concentric tissue labels matching SCI convention."""
9+
labels = np.zeros(shape, dtype=np.int32)
10+
labels[2:-2, 2:-2, 2:-2] = 1 # scalp
11+
labels[4:-4, 4:-4, 4:-4] = 2 # skull
12+
labels[5:-5, 5:-5, 5:-5] = 3 # CSF
13+
labels[6:-6, 6:-6, 6:-6] = 4 # gray matter
14+
labels[8:-8, 8:-8, 8:-8] = 5 # white matter
15+
return labels
16+
17+
18+
def test_labels_to_acoustic_and_conductivity():
19+
"""Same labels should produce both acoustic and conductivity maps."""
20+
from openlifu.sim.sci_bridge import (
21+
_labels_to_acoustic_params, _labels_to_conductivity, _make_coords,
22+
)
23+
24+
labels = _make_fake_labels()
25+
coords = _make_coords(labels.shape, dx_mm=1.0)
26+
27+
params = _labels_to_acoustic_params(labels, coords)
28+
sigma = _labels_to_conductivity(labels)
29+
30+
# Acoustic properties
31+
assert params["sound_speed"].data[0, 0, 0] == 1500.0 # water
32+
assert params["sound_speed"].data[4, 4, 4] == 4080.0 # skull
33+
assert params["sound_speed"].data[7, 7, 7] == 1560.0 # GM
34+
35+
# Conductivity (float32 approximate)
36+
np.testing.assert_allclose(sigma[0, 0, 0], 0.0, atol=1e-6) # water/air
37+
np.testing.assert_allclose(sigma[4, 4, 4], 0.01, rtol=1e-5) # skull
38+
np.testing.assert_allclose(sigma[7, 7, 7], 0.47, rtol=1e-5) # GM
39+
np.testing.assert_allclose(sigma[9, 9, 9], 0.14, rtol=1e-5) # WM
40+
41+
# Same shape
42+
assert params["sound_speed"].shape == sigma.shape
43+
44+
45+
def test_conductivity_values_physical():
46+
"""Conductivity values should be in physical range."""
47+
from openlifu.sim.sci_bridge import TISSUE_CONDUCTIVITY
48+
49+
for tissue, sigma in TISSUE_CONDUCTIVITY.items():
50+
assert sigma >= 0, f"Negative conductivity for tissue {tissue}"
51+
assert sigma < 5, f"Conductivity too high for tissue {tissue}: {sigma}"
52+
53+
# CSF should be most conductive
54+
assert TISSUE_CONDUCTIVITY[3] > TISSUE_CONDUCTIVITY[4] # CSF > GM
55+
# Skull should be least conductive (except air)
56+
assert TISSUE_CONDUCTIVITY[2] < TISSUE_CONDUCTIVITY[4] # skull < GM
57+
58+
59+
def test_acoustic_and_conductivity_share_geometry():
60+
"""Both property maps should have identical spatial coordinates."""
61+
from openlifu.sim.sci_bridge import (
62+
_labels_to_acoustic_params, _labels_to_conductivity, _make_coords,
63+
)
64+
65+
labels = _make_fake_labels((30, 25, 20))
66+
coords = _make_coords(labels.shape, dx_mm=1.5)
67+
68+
params = _labels_to_acoustic_params(labels, coords)
69+
sigma = _labels_to_conductivity(labels)
70+
71+
assert params["sound_speed"].shape == (30, 25, 20)
72+
assert sigma.shape == (30, 25, 20)
73+
assert float(params.coords["x"].values[1] - params.coords["x"].values[0]) == 1.5
74+
75+
76+
def test_load_presegmented_npy():
77+
"""Should load pre-rasterized labels from .npy file."""
78+
from openlifu.sim.sci_bridge import _load_presegmented
79+
import tempfile, os
80+
81+
labels = _make_fake_labels()
82+
with tempfile.NamedTemporaryFile(suffix=".npy", delete=False) as f:
83+
np.save(f, labels)
84+
tmppath = f.name
85+
86+
try:
87+
loaded = _load_presegmented(tmppath)
88+
np.testing.assert_array_equal(loaded, labels)
89+
finally:
90+
os.unlink(tmppath)
91+
92+
93+
def test_bridge_compatible_with_jwave_simulation():
94+
"""Acoustic params from the bridge should work with run_cw_simulation."""
95+
from openlifu.sim.sci_bridge import (
96+
_labels_to_acoustic_params, _make_coords,
97+
)
98+
from openlifu.sim.jwave_if import run_cw_simulation
99+
from openlifu.xdc.element import Element
100+
from openlifu.xdc.transducer import Transducer
101+
102+
labels = _make_fake_labels((32, 32, 32))
103+
coords = _make_coords(labels.shape, dx_mm=1.0)
104+
params = _labels_to_acoustic_params(labels, coords)
105+
106+
el = Element(index=0, position=np.array([0., 0., 0.]),
107+
size=np.array([5e-3, 5e-3]), units="m")
108+
tx = Transducer(id="test", elements=[el], frequency=500e3, units="m")
109+
110+
ds, _ = run_cw_simulation(
111+
arr=tx, params=params, freq=500e3,
112+
amplitude=60000.0, pml_size=5, tol=1e-2, maxiter=50,
113+
)
114+
115+
assert "p_amp" in ds.data_vars
116+
assert ds.p_amp.data.max() > 0
117+
assert np.all(np.isfinite(ds.p_amp.data))

0 commit comments

Comments
 (0)