Source code for mrinufft.extras.field_map

"""Field map Module.

This module provides methods to generate dummy B0map as well as estimation of the
bilinearization of the off-resonance model (See :ref:`nufft-orc` for a detailed
explanation).
"""

import numpy as np
from collections.abc import Callable

from numpy.typing import ArrayLike, NDArray

from mrinufft._array_compat import (
    get_array_module,
    with_numpy,
    with_numpy_cupy,
)
from mrinufft._utils import MethodRegister

#######################
# Generate Dummy Data #
#######################


[docs] def make_b0map(shape, b0range=(-300, 300), mask=None): """ Make radial B0 map. Parameters ---------- shape: tuple[int] Matrix size. b0range: tuple[float], optional Frequency shift range in [Hz]. The default is (-300, 300). mask: np.ndarray Spatial support of the object. If not provided, build a radial mask with radius ``radius=0.3*shape`` Returns ------- np.ndarray B0 map in Hz, values included in ``b0range``, and shape ``shape`` np.ndarray Spatial support binary mask """ ndim = len(shape) if ndim == 2: radial_mask, fieldmap = _make_disk(shape) elif ndim == 3: radial_mask, fieldmap = _make_sphere(shape) if mask is None: mask = radial_mask # build map fieldmap *= mask fieldmap = (b0range[1] - b0range[0]) * fieldmap / fieldmap.max() + b0range[0] # Hz fieldmap *= mask # remove nan fieldmap = np.nan_to_num(fieldmap, neginf=0.0, posinf=0.0) return fieldmap.astype(np.float32), mask
[docs] def make_t2smap(shape, t2svalue=15.0, mask=None): """ Make homogeneous T2* map. Parameters ---------- shape: tuple[int] Matrix size. t2svalue: float, optional Object T2* in ms. The default is 15.0. mask: np.ndarray Spatial support of the object. If not provided, build a radial mask with ``radius = 0.3 * shape`` Returns ------- np.ndarray T2* map of shape (*shape) in [ms]. np.ndarray, optional Spatial support binary mask. """ ndim = len(shape) if ndim == 2: radial_mask, fieldmap = _make_disk(shape) elif ndim == 3: radial_mask, fieldmap = _make_sphere(shape) if mask is None: mask = radial_mask # build map fieldmap = t2svalue * mask # ms # remove nan fieldmap = np.nan_to_num(fieldmap, neginf=0.0, posinf=0.0) return fieldmap.astype(np.float32), mask
def _make_disk(shape, frac_radius=0.3): """Make circular binary mask.""" ny, nx = shape yy, xx = np.mgrid[:ny, :nx] yy, xx = yy - ny // 2, xx - nx // 2 yy, xx = yy / ny, xx / nx rr = (xx**2 + yy**2) ** 0.5 return rr < frac_radius, rr def _make_sphere(shape, frac_radius=0.3): """Make spherical binary mask.""" nz, ny, nx = shape zz, yy, xx = np.mgrid[:nz, :ny, :nx] zz, yy, xx = zz - nz // 2, yy - ny // 2, xx - nx // 2 zz, yy, xx = zz / nz, yy / ny, xx / nx rr = (xx**2 + yy**2 + zz**2) ** 0.5 return rr < frac_radius, rr ######################################################## # Estimation of Off-Resonance-Correction Interpolators # ######################################################## _field_map_docs = dict( base_params=r""" field_map : NDArray The field map (off-resonance map) in rad/s, If complex-valued, the real part is interpreted as R2* mapping. If real-valued this is the field inhomogeneities in Hz. and will be multiplied by :math:`2j\pi` readout_time : NDArray The vector of time points (in seconds) at which to compute phase evolution. mask : NDArray Binary mask indicating object region for field map/statistics. L : int, optional Number of virtual centers or basis functions retained (default is -1, automatically estimated). n_bins : int, optional Number of histogram bins for off-resonance value clustering (default is 1000). lazy: bool, default False If True, use a lazy evaluation scheme for the space interpolator C, saving memory. """, returns=""" Returns ------- B: NDArray [L, nbins] phase basis matrix in the time domain. C: NDArray [L, nt] interpolation matrix to transform weighted basis to phase at the time points; nt = len(readout_time). E: NDArray [nbins, nt] exponential off-resonance phase matrix at input histogram bins. See Also -------- :ref:`nufft-orc` """, ) register_orc = MethodRegister("orc_factorization", _field_map_docs) get_orc_factorization = register_orc.make_getter()
[docs] @with_numpy_cupy def get_complex_fieldmap_rad( b0_map: NDArray, r2star_map: NDArray | None = None ) -> NDArray[np.complex64]: r"""Create a complex-valued field-map in rad/s. Parameters ---------- b0_map: NDArray If complex-valued, returned as is. If real valued, it is the off-resonance field map in Hz. r2star_map: NDArray, optional :math:`R_2^*` mapping, in Hz Returns ------- NDArray The complex valued field-map in radian, :math:`-\Delta=R_2^* + 2j\pi\Delta f`, in [rad/s]. This is used in the inhomogeneity term :math:`\exp(\Delta(\boldsymbol{r})t)\exp(2j\pi\boldsymbol{r}\boldsymbol{k}(t))` See Also -------- :ref:`nufft-orc` """ xp = get_array_module(b0_map) if xp.iscomplexobj(b0_map): return b0_map field_map = xp.complex64(2j * xp.pi) * b0_map.astype(xp.float32, copy=False) if r2star_map is not None: r2star_map = xp.asarray(r2star_map, dtype=xp.float32) field_map += r2star_map return field_map
def _create_histogram( field_map: NDArray[np.complex64], mask: NDArray | None, n_bins: int | tuple[int, int] = 1000, ) -> tuple[NDArray, NDArray]: """ Quantize the field map in n_bins values. Parameters ---------- field_map : NDArray """ xp = get_array_module(field_map) masked_field_map = field_map[mask] if isinstance(n_bins, int): deltaR = xp.max(masked_field_map.real) - xp.min(masked_field_map.real) deltaI = xp.max(masked_field_map.imag) - xp.min(masked_field_map.imag) if deltaI == 0: n_bins = (n_bins, 1) elif deltaR == 0: n_bins = (1, n_bins) else: n_bins_r = np.max(1, int(xp.around(n_bins * deltaR / deltaI))) n_bins_i = np.around(n_bins / n_bins_r) n_bins = (int(n_bins_r), int(n_bins_i)) z = field_map[mask].view(xp.float32).reshape(-1, 2) # create histograms h_counts, h_edges = xp.histogramdd(z, bins=n_bins) # get center of bins for real and imaginary part h_centers = [e[1:] - (e[1] - e[0]) / 2 for e in h_edges] if len(h_centers) == 2: h_centers_cpx = np.add.outer( h_centers[0], 1j * h_centers[1], dtype=np.complex64 ) else: h_centers_cpx = np.complex64(1j) * h_centers[0] return h_counts, h_centers_cpx
[docs] class C_lazy: """A lazy version of the C interpolator. Instead of storing C as a ``(L, Nx, Ny, Nz)`` array, we store the quantize version of shape L, N_bins, and the indexes mapping each voxel of the complex-valued field-map to the bins values. When accessing the L-th interpolator, the full ``(Nx, Ny, Nz)`` associated array is generated. Parameters ---------- C_sr: NDArray of shape (L, Nbinsreal, Nbinsimag) idxr: NDArray mapping field-map real value to real bins idxi: NDArray mapping field-map imag value to imag bins Methods ------- __getitem__ Return the l-th interpolator, with the same shape as field-map. """ def __init__( self, C_sr: NDArray[np.complex64], idxr: ArrayLike, idxi: ArrayLike, img_shape: tuple[int, int, int], ): self.C_small = C_sr self.idxr = idxr self.idxi = idxi self.img_shape = img_shape def __len__(self) -> int: """Get number of interpolators.""" return len(self.C_small) @property def shape(self): """Overall shape of the lazy array.""" return (len(self), *self.img_shape)
[docs] def __getitem__(self, i: int) -> NDArray: """Return the original array, with proper space indexing.""" return self.C_small[i][self.idxr, self.idxi].reshape(self.img_shape)
@with_numpy_cupy def _full_C( field_map: NDArray[np.complex64], C_small: NDArray, n_bins: tuple[int, int], lazy: bool = False, ): """ Generate a full spatial interpolator C from a quantized version. If `lazy=True` uses the an array like structure to generate the interpolators on the fly. Parameters ---------- field_map: NDArray Complex-valued field-map in rad/s. C_small: NDArray Small C matrix computed at histogram centers. n_bins: tuple[int, int] Number of bins for the real and imaginary part lazy: bool, default False If True, returns a lazy version of the C matrix. Returns ------- NDArray if lazy = False C_Lazy if lazy = True """ xp = get_array_module(field_map) fr = field_map.real.ravel() fi = field_map.imag.ravel() minr, maxr = xp.min(fr), xp.max(fi) mini, maxi = xp.min(fi), xp.max(fi) dr = (maxr - minr) / n_bins[0] di = (maxi - mini) / n_bins[1] idxr, idxi = 0, 0 if dr != 0: idxr = (xp.around((fr - minr) / dr)).astype(int) idxr = xp.clip(idxr, 0, n_bins[0] - 1) if di != 0: idxi = (xp.around((fi - mini) / di)).astype(int) idxi = xp.clip(idxi, 0, n_bins[1] - 1) C_sr = C_small.reshape(-1, *n_bins) if lazy: C_big = C_lazy(C_sr, idxr, idxi, field_map.shape) return C_big else: C_big = C_sr[:, idxr, idxi] return xp.ascontiguousarray(C_big.reshape(-1, *field_map.shape)) def _get_svds( xp, partial_svd ) -> Callable[[NDArray, int], tuple[NDArray, NDArray, NDArray]]: if partial_svd: if xp.__name__ == "cupy": from cupyx.scipy.sparse.linalg import svds else: from scipy.sparse.linalg import svds else: def svds(x, n): u, s, v = xp.linalg.svd(x, compute_uv=True, full_matrices=False) return u[:, :n], s[:n], v[:n, :] return svds
[docs] @register_orc("svd") @with_numpy_cupy def compute_svd_coefficients( field_map: NDArray, readout_time: NDArray, mask: NDArray, L: int = -1, n_bins: int | tuple[int, int] = 1024, lazy: bool = False, partial_svd: bool = True, ): r""" Compute off-resonance correction coefficients using an SVD. Solves :math:`\arg\min_{B,C} = \|E - BC \|_{F}^2` In practise it utilizes truncated SVD to extract L leading singular vectors from the weighted exponentiated phase matrix sampled at field map histogram centers. Parameters ---------- ${base_params} partial_svd: bool, default True If True, only compute the L components required for the estimation, not the full SVD. ${returns} References ---------- Sutton BP, Noll DC, Fessler JA. Fast, iterative image reconstruction for MRI in the presence of field inhomogeneities. IEEE Trans Med Imaging. 2003 Feb;22(2):178-88. doi: 10.1109/tmi.2002.808360. PMID: 12715994. """ field_map = get_complex_fieldmap_rad(field_map) xp = get_array_module(field_map) # Format the input and apply the weight option h_k, w_k = _create_histogram(field_map, mask, n_bins) hist_shape = h_k.shape w_k = w_k.ravel() h_k = h_k.ravel() # Compute B with a Singular Value Decomposition E = xp.exp(xp.outer(readout_time, w_k)) Ew = xp.sqrt(h_k) * E svds = _get_svds(xp, partial_svd) if L == -1: # Empirically: maximum frequency in the E matrix. L = max( 1, int(xp.ceil(abs(w_k[-1] - w_k[0]) * xp.max(readout_time) / 2 * (xp.pi))) ) B, S, D = svds(Ew, L) # Compute C with a Least Square interpolation # (Redundant with C=DV from E=BSD using L singular values # but more robust to histogram with 0 weights. C, _, _, _ = xp.linalg.lstsq(B, E, rcond=None) C = _full_C(field_map, C, hist_shape, lazy) return B, C, E
[docs] @register_orc("mti") @with_numpy_cupy def compute_mti_coefficients( field_map: NDArray, readout_time: NDArray, mask: NDArray, L: int = -1, n_bins: int | tuple[int, int] = 1024, lazy: bool = False, ): """ Compute off-resonance correction coefficients using Mixed Time interpolator (MTI). Parameters ---------- ${base_params} ${returns} References ---------- D. C. Noll, C. H. Meyer, J. M. Pauly, D. G. Nishimura and A. Macovski, "A homogeneity correction method for magnetic resonance imaging with time-varying gradients," in IEEE Transactions on Medical Imaging, vol. 10, no. 4, pp. 629-637, Dec. 1991, doi: 10.1109/42.108599 """ field_map = get_complex_fieldmap_rad(field_map) xp = get_array_module(field_map) # Format the input and apply the weight option h_k, w_k = _create_histogram(field_map, mask, n_bins) hist_shape = h_k.shape h_k = h_k.ravel() w_k = w_k.ravel() if L == -1: # from Douglas Noll PhD Thesis # and XXX L = max( 1, int(xp.ceil(2 * abs(w_k[-1] - w_k[0]) * xp.max(readout_time) / (xp.pi))) ) t_l = xp.linspace(xp.min(readout_time), xp.max(readout_time), L, dtype=xp.float32) # Compute C as a phase shift C = xp.exp(xp.outer(t_l, w_k)) E = xp.exp(xp.outer(readout_time, w_k)) Ch = xp.sqrt(h_k) * C Eh = xp.sqrt(h_k) * E # Compute B with a Least Square interpolation B, _, _, _ = xp.linalg.lstsq(Ch.T, Eh.T, rcond=None) C = _full_C(field_map, C, hist_shape, lazy) return B.T, C, E
@with_numpy def _get_cluster_centers(hist_locs: NDArray, counts: NDArray, L: int) -> NDArray: """Quantized the fine histogram to L interpolators.""" try: from sklearn.cluster import KMeans except ImportError as err: raise ImportError( "The scikit-learn module is not available. Please install " "it along with the [extra] dependencies " "or using `pip install scikit-learn`." ) from err return ( KMeans(n_clusters=L) .fit(hist_locs.view(np.float32).reshape(-1, 2), sample_weight=counts) .cluster_centers_.view(np.complex64) )
[docs] @register_orc("mfi") @with_numpy_cupy def compute_mfi_coefficients( field_map: NDArray, readout_time: NDArray, mask: NDArray, L: int = 9, n_bins: int | tuple[int, int] = 1024, lazy: bool = True, ): """ Compute off-resonance correction coefficients using Mixed-Frequency-Interpolator. Parameters ---------- ${base_params} ${returns} References ---------- Man, L.-C., Pauly, J.M. and Macovski, A. (1997), Multifrequency interpolation for fast off-resonance correction. Magn. Reson. Med., 37: 785-792. https://doi.org/10.1002/mrm.1910370523 """ # Format the input and apply the weight option field_map = get_complex_fieldmap_rad(field_map) xp = get_array_module(field_map) # Format the input and apply the weight option h_k, w_k = _create_histogram(field_map, mask, n_bins) hist_shape = h_k.shape h_k = h_k.ravel() w_k = w_k.ravel() if L == -1: # From Doug Noll PhD Thesis L = max( 1, int(xp.ceil(4 * abs(w_k[-1] - w_k[0])) * xp.max(readout_time) / (xp.pi)) ) w_l = _get_cluster_centers(w_k, h_k, L) # Compute B as a phase shift B = xp.exp(xp.outer(readout_time, w_l)) E = xp.exp(xp.outer(readout_time, w_k)) # Compute C with a Least Square interpolation C, _, _, _ = xp.linalg.lstsq(B, E, rcond=None) C = _full_C(field_map, C, hist_shape, lazy) return B, C, E