Source code for pulserver.virtual._brainweb

"""BrainWeb's normal brain, sampled as the virtual scanner's tissue."""

from __future__ import annotations

import functools
import math
from collections.abc import Callable, Mapping
from pathlib import Path
from types import MappingProxyType
from typing import TYPE_CHECKING

import numpy as np
import pypulseqpp as pp

from ._coils import Coil

if TYPE_CHECKING:
    from ._tissue import Tissue
from ._phantom import Phantom

#: The tissue classes of BrainWeb's normal brain, in the order of the fuzzy
#: model brainweb-dl returns for its subject 0, each with the T1, T2 and T2*,
#: in s, and the proton density BrainWeb's MRI simulator gives it at 1.5 T, as
#: its tissue MR parameters list them
#: (https://brainweb.bic.mni.mcgill.ca/brainweb/tissue_mr_parameters.txt).
TISSUES = {
    "background": (0.0, 0.0, 0.0, 0.0),
    "CSF": (2.569, 0.329, 0.058, 1.0),
    "grey matter": (0.833, 0.083, 0.069, 0.86),
    "white matter": (0.5, 0.07, 0.061, 0.77),
    "fat": (0.35, 0.07, 0.058, 1.0),
    "muscle and skin": (0.9, 0.047, 0.030, 1.0),
    "skin": (2.569, 0.329, 0.058, 1.0),
    "skull": (0.0, 0.0, 0.0, 0.0),
    "glial matter": (0.833, 0.083, 0.069, 0.86),
    "connective tissue": (0.5, 0.07, 0.061, 0.77),
}

#: The field, in T, at which BrainWeb's simulator gives :data:`TISSUES`.
TISSUES_FIELD_T = 1.5

#: Exponents ``b`` of the power laws ``T1 ~ B0**b``, by the tissue classes
#: BrainWeb gives their relaxation: those white matter and grey matter follow
#: from 0.2 T to 7 T (Rooney et al., Magn Reson Med 57:308, 2007), and those
#: Bottomley et al. fit to skeletal muscle and adipose tissue from 1 MHz to
#: 100 MHz (Med Phys 11:425, 1984). CSF's T1 does not change with the field
#: from 0.2 T to 7 T, and neither does that of skin, to which BrainWeb gives
#: CSF's relaxation; skull and background hold no protons.
T1_EXPONENTS = {
    "white matter": 0.382,
    "connective tissue": 0.382,
    "grey matter": 0.376,
    "glial matter": 0.376,
    "muscle and skin": 0.4203,
    "fat": 0.1743,
}

#: Volume magnetic susceptibility, in ppm (SI), of air, and of water, which
#: every tissue class of the head is taken to have (Schenck, Med Phys 23:815,
#: 1996).
AIR_PPM, WATER_PPM = 0.36, -9.05

#: MNI coordinates, in mm, of the first voxel of the model, whose 1 mm voxels
#: run along z, y and x, x fastest.
_FIRST_VOXEL_MM = {"x": -90.0, "y": -126.0, "z": -72.0}


[docs] class BrainWeb: """BrainWeb's normal brain, received by one coil or several. The fuzzy tissue model of BrainWeb's normal brain (Collins et al., IEEE Trans Med Imaging 17:463, 1998) gives each 1 mm voxel the fraction of it each tissue class fills. brainweb-dl downloads it on first use into its cache, which the ``brainweb`` extra installs. The brain lies as a subject lying head first and supine, with the origin of its MNI coordinates, the anterior commissure, at the isocentre: the physical x axis points to the subject's left, y posterior and z superior. Each tissue class relaxes with the T1, T2 and T2' :meth:`relaxation` derives at the field it is scanned at from the parameters BrainWeb's simulator gives it at 1.5 T (Kwan et al., IEEE Trans Med Imaging 18:1085, 1999), and has the proton density the simulator gives it. Fat precesses at pypulseqpp's fat shift, and every voxel at the field :attr:`field_ppm` its head adds. The coils are those of :class:`~pulserver.virtual.Phantom`, fixed in the physical frame. Parameters ---------- coils Receive channels. period Spatial period of the sensitivities, in metres. depth Their modulation depth. directory brainweb-dl's cache; ``BRAINWEB_DIR``, or ``~/.cache/brainweb``, without one. susceptibility Whether the field the head's susceptibility adds acts on it. t2_prime T2', in s, by tissue class, in place of the one :meth:`relaxation` derives, at any field. diffusion Isotropic diffusion coefficient, in m²/s, by tissue class, as :attr:`DIFFUSION` gives them; a class without one does not diffuse. """ #: Isotropic diffusion coefficients, in m²/s, of the tissue classes whose #: water diffusion is measured: the apparent diffusion coefficients of #: cortical grey matter and of white matter in adults (Helenius et al., #: AJNR Am J Neuroradiol 23:194, 2002), which glial matter and connective #: tissue take as BrainWeb gives them those tissues' relaxation, and that #: of free water at 37 degrees C for CSF (Holz et al., Phys Chem Chem Phys #: 2:4740, 2000). DIFFUSION = MappingProxyType( { "CSF": 3.0e-9, "grey matter": 0.89e-9, "white matter": 0.70e-9, "glial matter": 0.89e-9, "connective tissue": 0.70e-9, } ) def __init__( self, *, coils: int = 1, period: float = 0.5, depth: float = 0.5, directory: Path | str | None = None, susceptibility: bool = True, t2_prime: Mapping[str, float] | None = None, diffusion: Mapping[str, float] | None = None, ) -> None: for given in (t2_prime, diffusion): unknown = set(given or ()) - set(TISSUES) if unknown: raise ValueError(f"BrainWeb has no tissue class {sorted(unknown)}") self._coils = Phantom((), coils=coils, period=period, depth=depth) self.coils = coils self.directory = directory self.susceptibility = susceptibility self.t2_prime = dict(t2_prime or {}) self.diffusion = dict(diffusion or {}) @functools.cached_property def fractions(self) -> np.ndarray: """The fraction of each voxel each tissue fills, ``(z, y, x, tissue)``, downloaded on first use. Raises ------ ImportError If brainweb-dl is not installed. ValueError If the model does not hold one fraction per tissue class. """ try: import brainweb_dl except ImportError as error: raise ImportError( "BrainWeb is downloaded by brainweb-dl: pip install 'pulserver[brainweb]'" ) from error # C order, as every sampling of the model reshapes it: brainweb-dl # returns the NIfTI file's Fortran order. fractions = np.ascontiguousarray( brainweb_dl.get_mri(0, "fuzzy", brainweb_dir=self.directory), dtype=np.float32, ) if fractions.ndim != 4 or fractions.shape[3] != len(TISSUES): raise ValueError( f"BrainWeb's fuzzy model holds {len(TISSUES)} tissues per voxel, " f"not the shape {fractions.shape}" ) return fractions @functools.cached_property def field_ppm(self) -> np.ndarray: """The field the head adds to B0 at each voxel, ``(z, y, x)``, in ppm of B0, as a first-order shim leaves it. The head is water, and the background air, each voxel in proportion to the fraction of it the background fills. The field along B0, the physical z axis, is the susceptibility convolved with the dipole kernel, the Lorentz sphere's third included (Marques and Bowtell, Concepts Magn Reson B 25:65, 2005), whose constant and linear terms over the voxels at least half head are then removed. """ return _susceptibility_field(self.fractions[..., 0])
[docs] def relaxation(self, field_t: float) -> dict[str, tuple[float, float, float]]: """Return the T1, T2 and T2', in s, of each tissue class at ``field_t`` T. T1 is BrainWeb's, at 1.5 T, times ``(field_t / 1.5)**b`` with the class's exponent in :data:`T1_EXPONENTS`, or BrainWeb's without one; T2 is BrainWeb's. T2' is the one BrainWeb's T2 and T2* leave at 1.5 T, ``1 / (1/T2* - 1/T2)``, times ``1.5 / field_t``, as the static dephasing regime makes R2' proportional to the field (Yablonskiy and Haacke, Magn Reson Med 32:749, 1994), or the one :attr:`t2_prime` gives the class; infinite for a class without T2 or T2*. Raises ------ ValueError If ``field_t`` is not above zero. """ if not field_t > 0.0: raise ValueError(f"tissues relax at a field above zero, not {field_t} T") ratio = field_t / TISSUES_FIELD_T relaxed = {} for name, (t1, t2, t2_star, _) in TISSUES.items(): t2_prime = self.t2_prime.get(name, _t2_prime(t2, t2_star) / ratio) relaxed[name] = (t1 * ratio ** T1_EXPONENTS.get(name, 0.0), t2, t2_prime) return relaxed
[docs] def proton_density( self, points: np.ndarray, *, normal: np.ndarray, thickness: float ) -> np.ndarray: """Return the proton density of a slab ``thickness`` thick along ``normal`` at each of ``(n, 3)`` physical points, in metres. The density is each tissue's times the fraction of the nearest voxel it fills, averaged in 1 mm steps across the slab; zero outside the model. """ return _slab_density( self.fractions, np.asarray(points, dtype=float), normal, thickness )
[docs] def tissue( self, spacing: float = 1e-3, *, field_t: float | None = None, off_resonance_hz: float = 0.0, region: Callable[[np.ndarray, np.ndarray], np.ndarray] | None = None, coil: Coil | None = None, ) -> Tissue: """Return the brain sampled for the Fourier engine: each tissue of each cube ``spacing`` wide, at the cube's centre. Raises ------ ValueError If ``spacing`` is not a whole number of millimetres, ``field_t`` is not given, or the brain has coils of its own and ``coil`` is given. """ from ._tissue import Tissue, transmitted _whole_millimetres(spacing) if coil is not None and self.coils > 1: raise ValueError( "a phantom received by coils of its own is not scanned with a coil" ) positions, density, t1, t2, frequency, t2_prime, diffusion = self._sampled( spacing, field_t, off_resonance_hz, region ) return Tissue( positions=positions, density=density, t1=t1, t2=t2, t2_prime=t2_prime, frequency=frequency, spacing=spacing, axes=np.eye(3), transmit=None if coil is None else transmitted(coil, positions), receive=self._coils._received if coil is None else coil.receive, coils=self.coils if coil is None else coil.receive_channels, diffusion=diffusion if np.any(diffusion) else None, )
def _sampled( self, spacing: float, field_t: float | None, off_resonance_hz: float, region: Callable[[np.ndarray, np.ndarray], np.ndarray] | None, ) -> tuple[np.ndarray, ...]: """Return the centres, proton densities, T1, T2, frequencies, T2' and diffusion coefficients of the tissues of the cubes ``spacing`` wide.""" from pypulseqpp.sequences.preparation.fatsat import FAT_SHIFT_PPM if field_t is None: raise ValueError( "BrainWeb's fat has a chemical shift: scan it at a field_t" ) step = round(spacing / 1e-3) cubes = _cubes(self.fractions, step) field = np.zeros(cubes.shape[:3], dtype=np.float32) if self.susceptibility: field = _cubes(self.field_ppm[..., None], step)[..., 0] per_ppm = 1e-6 * pp.Opts().gamma * field_t shifts = [per_ppm * FAT_SHIFT_PPM if name == "fat" else 0.0 for name in TISSUES] fractions = cubes.reshape(-1, len(TISSUES)) inhomogeneity = per_ppm * field.reshape(-1) index = np.indices(cubes.shape[:3]).reshape(3, -1).T voxel = step * index + 0.5 * (step - 1) z, y, x = ( voxel[:, axis] + _FIRST_VOXEL_MM[name] for axis, name in enumerate("zyx") ) positions = 1e-3 * np.column_stack([-x, -y, z]) relaxed = self.relaxation(field_t) points, rows = [], [] for tissue, (name, (*_, density)) in enumerate(TISSUES.items()): t1, t2, t2_prime = relaxed[name] fraction = fractions[:, tissue] kept = np.flatnonzero((fraction > 0.0) & (density > 0.0)) shift = shifts[tissue] frequency = shift + off_resonance_hz + inhomogeneity[kept] if region is not None: inside = region(positions[kept], frequency) kept, frequency = kept[inside], frequency[inside] points.append(positions[kept]) rows.append( np.column_stack( [ density * fraction[kept] * (step * 1e-3) ** 3, np.full(kept.size, t1), np.full(kept.size, t2), frequency, np.full(kept.size, t2_prime), np.full(kept.size, self.diffusion.get(name, 0.0)), ] ) ) return (np.concatenate(points), *np.concatenate(rows).T)
def _t2_prime(t2: float, t2_star: float) -> float: """Return the T2' that T2 and T2*, in s, leave, R2' = R2* - R2; infinite for a tissue without either.""" if not 0.0 < t2_star < t2: return math.inf return 1.0 / (1.0 / t2_star - 1.0 / t2) def _whole_millimetres(spacing: float) -> None: step = round(spacing / 1e-3) if step < 1 or not math.isclose(step * 1e-3, spacing, rel_tol=1e-6): raise ValueError(f"BrainWeb is sampled in whole millimetres, not {spacing} m") def _slab_density( fractions: np.ndarray, points: np.ndarray, normal: np.ndarray, thickness: float ) -> np.ndarray: """Return the proton density at ``(n, 3)`` physical points, averaged over 1 mm steps across the slab.""" densities = np.array([density for *_, density in TISSUES.values()]) first = np.array([_FIRST_VOXEL_MM[axis] for axis in "xyz"]) size = np.array(fractions.shape[2::-1]) steps = max(1, round(thickness / 1e-3)) total = np.zeros(len(points)) for offset in (np.arange(steps) - 0.5 * (steps - 1)) * thickness / steps: shifted = points + offset * np.asarray(normal, dtype=float) mni = 1e3 * shifted * np.array([-1.0, -1.0, 1.0]) voxel = np.rint(mni - first).astype(int) inside = np.all((voxel >= 0) & (voxel < size), axis=1) x, y, z = voxel[inside].T total[inside] += fractions[z, y, x] @ densities return total / steps def _susceptibility_field(background: np.ndarray) -> np.ndarray: """Return the shimmed field, in ppm, of a head whose voxels ``(z, y, x)`` the background fills the fraction ``background`` of.""" from scipy import fft contrast = ((1.0 - background) * (WATER_PPM - AIR_PPM)).astype(np.float32) shape = [fft.next_fast_len(3 * size // 2, real=True) for size in contrast.shape] kz = np.fft.fftfreq(shape[0]).astype(np.float32)[:, None, None] ky = np.fft.fftfreq(shape[1]).astype(np.float32)[None, :, None] kx = np.fft.rfftfreq(shape[2]).astype(np.float32)[None, None, :] squared = kz**2 + ky**2 + kx**2 squared[0, 0, 0] = 1.0 kernel = np.float32(1.0 / 3.0) - kz**2 / squared kernel[0, 0, 0] = 0.0 spectrum = fft.rfftn(contrast, shape, workers=-1) spectrum *= kernel field = fft.irfftn(spectrum, shape, workers=-1) field = field[tuple(slice(0, size) for size in contrast.shape)] grid = np.indices(field.shape, dtype=np.float32) head = background <= 0.5 basis = np.column_stack([np.ones(head.sum()), *(axis[head] for axis in grid)]) shim, *_ = np.linalg.lstsq(basis, field[head], rcond=None) return (field - shim[0] - np.tensordot(shim[1:], grid, axes=1)).astype(np.float32) def _cubes(fractions: np.ndarray, step: int) -> np.ndarray: """Return the fractions averaged in cubes of ``step`` voxels, the voxels past the last whole cube left out.""" if step == 1: return fractions whole = [size // step for size in fractions.shape[:3]] kept = fractions[: whole[0] * step, : whole[1] * step, : whole[2] * step] blocks = kept.reshape(whole[0], step, whole[1], step, whole[2], step, -1) return blocks.mean(axis=(1, 3, 5))