Source code for cmrsim.analytic.contrast.sequences

""" This module contains the analytical solutions to the bloch equation for commonly used
sequences """
__all__ = ["SpinEcho", "SaturationRecoveryGRE", "BSSFP"]

from typing import Union, Sequence

import tensorflow as tf
import numpy as np

from cmrsim.analytic.contrast.base import BaseSignalModel


# pylint: disable=abstract-method, disable=anomalous-backslash-in-string
[docs] class SpinEcho(BaseSignalModel): # pylint: disable=invalid-name """ Analytical Solution operator for a Single echo Spin echo sequence Evaluates the signal for the simple Spin-Echo sequence: .. math:: M_{xy} = M_z \cdot e^{- TE / T_2} (1 - e^{-TR / T1}) :raises: ValueError if the arguments echo and repetition time do not have the same length :param echo_time: in milliseconds :param repetition_time: in milliseconds :param expand_repetitions: See BaseSignalModel for explanation """ required_quantities = ('T1', 'T2') #: Echo time in ms TE: tf.Variable = None #: Repetition time in ms TR: tf.Variable = None def __init__(self, echo_time: Union[float, tf.Tensor], repetition_time: Union[float, tf.Tensor], expand_repetitions: bool, **kwargs): """ :param echo_time: in milliseconds :param repetition_time: in milliseconds :param expand_repetitions: See BaseSignalModel for explanation """ if isinstance(echo_time, (float, int)): echo_time = tf.constant(echo_time, shape=(1, ), dtype=tf.float32) if isinstance(repetition_time, (float, int)): repetition_time = tf.constant(repetition_time, shape=(1, ), dtype=tf.float32) if echo_time.shape[0] != repetition_time.shape[0]: raise ValueError("Arguments echo_time and repetition_time must have same number " f"of elements. Got {echo_time.shape} and {repetition_time.shape}") super().__init__(expand_repetitions=expand_repetitions, name="spin_echo", **kwargs) with tf.device(self.device): self.TE = tf.Variable(echo_time, shape=(None, ), dtype=tf.float32, name='TE') self.TR = tf.Variable(repetition_time, shape=(None, ), dtype=tf.float32, name='TR') self.expansion_factor = self.TE.read_value().shape[0] self.update()
[docs] @tf.function(jit_compile=True) def __call__(self, signal_tensor: tf.Tensor, T1: tf.Tensor, T2: tf.Tensor, **kwargs): # noqa """ Evaluates spin echo operator :param signal_tensor: (#batch, #voxel, #repetitions, #k-space-samples) last two dimensions can be 1 :param T1: (#batch, #voxel, 1, 1) in milliseconds :param T2: (#batch, #voxel, 1, 1) in milliseconds :return: """ with tf.device(self.device): # All Cases --> repetitions-axis of argument T1 and T2 must be either 1 or equal to # self.expansion_factor tf.Assert(tf.shape(T1)[1] == 1 or tf.shape(T1)[1] == self.expansion_factor, ["Shape missmatch for input argument T1"]) tf.Assert(tf.shape(T2)[1] == 1 or tf.shape(T2)[1] == self.expansion_factor, ["Shape missmatch for input argument T2"]) te = tf.abs(tf.reshape(self.TE, (1, -1, 1))) tr = tf.abs(tf.reshape(self.TR, (1, -1, 1))) t1_term = 1 - tf.exp(-tf.math.divide_no_nan(tr, T1)) t2_term = tf.exp(-tf.math.divide_no_nan(te, T2)) factor = tf.cast(t1_term * t2_term, tf.complex64) input_shape = tf.shape(signal_tensor) # Case 1: expand-dimensions if self.expand_repetitions or self.expansion_factor == 1: temp = tf.einsum('vrk, vnk -> vrnk', signal_tensor, factor) result = tf.reshape(temp, (input_shape[0], -1, input_shape[2])) # Case 2: repetition-axis of signal_tensor must match self.expansion_factor else: tf.Assert(input_shape[1] == self.expansion_factor, ["Shape missmatch for input argument signal_tensor for case no-expand"]) result = tf.einsum('vrk, vrk -> vrk', signal_tensor, factor) return result
[docs] def update(self): assert all((s1 == s2 for s1, s2 in zip(self.TE.read_value().shape, self.TR.read_value().shape))) self.expansion_factor = self.TE.read_value().shape[0]
# pylint: disable=abstract-method, disable=anomalous-backslash-in-string
[docs] class BSSFP(BaseSignalModel): """ Evaluates bssfp steady state contrast for :math:`TE/TR \\approx 1/2` according to: ` Ganter, MRM 56:687 (2006)` .. math:: M(x \\cdot TR) = E_2^x e^{ix\\theta} \\left[ (E_2^{\prime})^x [1 + e^{i\\theta} E_2^{\\prime} A]^{-1} + (E_2^{\prime})^{1-x} e^{-i\\theta} \\Lambda/E_2 [1 + e^{-i\\theta} E_2^{\\prime} A]^{-1} \\right] Off-resonance theta results in banding according to the alternating RF phase scheme: .. dropdown:: Off-resonance Banding .. image:: ../../_static/api/bssfp_banding.png :raises: ValueError if the arguments echo, repetition time and flip angle do not have the same length :param flip_angle: in degree :param echo_time: im ms :param repetition_time: in ms :param expand_repetitions: See BaseSignalModel for explanation """ # pylint: disable=invalid-name required_quantities = ('T1', 'T2', 'T2star', 'off_res') #: Echo time in ms TE: tf.Variable = None #: Repetition time in ms TR: tf.Variable = None #: Flip angle in degree FA: tf.Variable = None def __init__(self, flip_angle: Union[int, float, Sequence], echo_time: Union[int, float, Sequence], repetition_time: Union[int, float, Sequence], expand_repetitions: bool, **kwargs): """ :param flip_angle: in degree :param echo_time: im ms :param repetition_time: in ms :param expand_repetitions: See BaseSignalModel for explanation """ if isinstance(echo_time, (float, int)): echo_time = tf.constant(echo_time, shape=(1, ), dtype=tf.float32) if isinstance(repetition_time, (float, int)): repetition_time = tf.constant(repetition_time, shape=(1, ), dtype=tf.float32) if isinstance(flip_angle, (float, int)): flip_angle = tf.constant(flip_angle, shape=(1, ), dtype=tf.float32) if len(echo_time) != len(repetition_time) or len(echo_time) != len(flip_angle): raise ValueError("Arguments echo_time and repetition_time must have same number " f"of elements. Got {echo_time.shape} and {repetition_time.shape}") super().__init__(expand_repetitions=expand_repetitions, name="bssfp", **kwargs) self.FA = tf.Variable(flip_angle/180. * np.pi, shape=(None, ), dtype=tf.float32, name='FA') self.TE = tf.Variable(echo_time, shape=(None, ), dtype=tf.float32, name='TE') self.TR = tf.Variable(repetition_time, shape=(None, ), dtype=tf.float32, name='TR') self.expansion_factor = self.FA.read_value().shape[0] # pylint: disable=too-many-arguments
[docs] def __call__(self, signal_tensor: tf.Tensor, T1: tf.Tensor, T2: tf.Tensor, T2star: tf.Tensor, off_res: tf.Tensor, **kwargs): # pylint: disable=invalid-name, too-many-locals, """ Evaluates the signal Equation. :param signal_tensor: (#voxel, #repetitions, #k-space-samples) last two dimensions can be 1 :param T1: in ms :param T2: in ms :param T2star: in ms :param off_res: b0 inhomogeneity in rad/ms (angular frequency difference) :return: (#voxel, #repetitions, #k-space-samples) """ # phase in radians acquired over one TR due to off-resonance theta = off_res * tf.reshape(self.TR, [1, -1, 1]) T2p = 1. / (1. / T2star - 1./T2) E1 = tf.exp(-tf.reshape(self.TR, [1, -1, 1]) / T1) E2 = tf.exp(-tf.reshape(self.TR, [1, -1, 1]) / T2) E2p = tf.exp(-tf.reshape(self.TR, [1, -1, 1]) / T2p) # Equation 6 a = 1 - E1 * tf.math.cos(tf.reshape(self.FA, [1, -1, 1])) b = tf.math.cos(tf.reshape(self.FA, [1, -1, 1])) - E1 # Equation 5 Lambda = (a - E2**2 * b - tf.sqrt((a**2 - E2**2 * b**2) * (1 - E2**2))) / (a - b) # Equation 3 A = ((a + b) * E2 / 2.) / (a - Lambda * (a - b) / 2.) # Equation 7/8 Mxy = (1 - E1) / (a + b * Lambda) * tf.math.sin(tf.reshape(self.FA, [1, -1, 1])) Mxy = tf.complex(0., -Mxy) # Text right before Equation 10 x = tf.reshape(self.TE / self.TR, [1, -1, 1]) # Lorentzian distribution from Equation 13 factor_1 = tf.math.exp(tf.complex(0., x * theta)) * tf.cast(E2 ** x, tf.complex64) # Equation 14 exp_theta = tf.exp(tf.complex(0., theta)) exp_theta_m = tf.exp(tf.complex(0., -theta)) E2p_x = tf.cast(E2p ** x, tf.complex64) E2p_A = tf.cast(E2p * A, tf.complex64) summand_1 = E2p_x / (1. + exp_theta * E2p_A) factor_21 = tf.cast(E2p ** (1. - x) * (Lambda / E2), tf.complex64) * exp_theta_m factor_22 = 1. + exp_theta_m * E2p_A bfe = factor_1 * (summand_1 + factor_21 / factor_22) * Mxy return bfe * signal_tensor
# pylint: disable=abstract-method
[docs] class SaturationRecoveryGRE(BaseSignalModel): # pylint: disable=invalid-name """ Analytical solution for a Saturation Recovery GRE sequence used in 1st-pass-perfusion imaging Evaluates the signal model for a saturation recovery spoiled gradient echo sequence. This modules assumes the cartesian line-by line k-space sampling. This yields the signal equation for a single k-space line: .. math:: m_{xy} = \\rho(r) \\left[(1 - e^{-T_D \cdot R_1}) (\cos(\\alpha)e^{-T_R \cdot R_1})^{n-1} + (1-e^{-T_R \cdot R_1}) \\frac{1 - (\cos(\\alpha)e^{-T_R \cdot R_1})^{n-1}} {1 - (\cos(\\alpha)e^{-T_R \cdot R_1})} \\right] Where T_D is the saturation_delay, n is the number of profiles left until the k-space-center, alpha is the flip_angle and TR is the repetition time. for more info refer to https://gitlab.ethz.ch/jweine/cmrsim/-/issues/62 **Note** This module assumes, that the keyword argument 'segment_index' is used in \_\_call\_\_`, such that each segment contains all k-space point acquired in one TR, otherwise the signal equation does not hold! :param repetition_time: in ms :param flip_angle: in degree :param saturation_delay: (float) time in ms from saturation pulse (t=0.) to the time of acquisition of the k-space center profile. :param n_profiles_to_k0: (int) profiles (=k-space lines) prior to acquisition of k0 """ required_quantities = ('T1', ) #: Echo time in ms TE: tf.Variable = None #: Repetition time in ms TR: tf.Variable = None def __init__(self, repetition_time: Union[float, tf.Tensor], flip_angle: Union[float, tf.Tensor], saturation_delay: float, n_profiles_to_k0: int, expand_repetitions: bool, **kwargs): # pylint: disable=anomalous-backslash-in-string """ :param repetition_time: in ms :param flip_angle: in degree :param saturation_delay: (float) time in ms from saturation pulse (t=0.) to the time of acquisition of the k-space center profile. :param n_profiles_to_k0: (int) profiles (=k-space lines) prior to acquisition of k0 """ super().__init__(expand_repetitions=expand_repetitions, name="saturation_recovery_gre", **kwargs) self.TR = tf.Variable(repetition_time, dtype=tf.float32, name='TR') self.flip_angle = tf.Variable(flip_angle, dtype=tf.float32, name='flip_angle') self.saturation_delay = tf.Variable(saturation_delay, shape=(), dtype=tf.float32, name='TD') self._n_profiles_to_k0 = tf.Variable(n_profiles_to_k0, shape=(), dtype=tf.float32, name='n_tok0') self.expansion_factor=1
[docs] def __call__(self, signal_tensor: tf.Tensor, T1: tf.Tensor, segment_index: tf.Tensor = 0., **kwargs): # noqa # pylint: disable=invalid-name """ Evaluation of SaturationRecoveryGRE equation""" with tf.device(self.device): n = tf.maximum(tf.math.floor(self._n_profiles_to_k0 - tf.cast(segment_index, tf.float32)), 1.) a = tf.math.exp(- self.TR / T1) cos_alpha = tf.math.cos(self.flip_angle.read_value() / 180 * \ tf.constant(np.pi, dtype=tf.float32)) b = cos_alpha * a c = 1 - tf.math.exp(-self.saturation_delay / T1) d = tf.pow(b, n - 1.) factor = c * d + (1 - a) * (1 - d) / (1 - b) return signal_tensor * tf.cast(factor, tf.complex64)
[docs] def update(self): return