Module ukat.mapping.t2star

Functions

def two_param_eq(t, t2star, m0)

Calculate the expected signal from the equation signal = M0 * exp(-t / T2*)

Parameters

t : list
The times the signal will be calculated at
t2star : float
The T2* of the signal
m0 : float
The M0 of the signal

Returns

signal : np.ndarray
 

Classes

class T2Star (pixel_array, echo_list, affine, mask=None, method='loglin', multithread='auto')

Attributes

t2star_map : np.ndarray
The estimated T2* values in ms
t2star_err : np.ndarray
The certainty in the fit of t2star_map in ms. Only returned if 2p_exp method is used, otherwise is an array of nan
m0_map : np.ndarray
The estimated M0 values
m0_err : np.ndarray
The certainty in the fit of m0_map. Only returned if 2p_exp method is used, otherwise is an array of nan
r2 : np.ndarray
The R-Squared value of the fit, values close to 1 indicate a good fit, lower values indicate a poorer fit
shape : tuple
The shape of the T2* map
n_te : int
The number of TE used to calculate the map
n_vox : int
The number of voxels in the map i.e. the product of all dimensions apart from TE

Initialise a T2Star class instance.

Parameters

pixel_array : np.ndarray
A array containing the signal from each voxel at each echo time with the last dimension being time i.e. the array needed to generate a 3D T2* map would have dimensions [x, y, z, TE].
echo_list : list()
An array of the echo times used for the last dimension of the raw data. In milliseconds.
affine : np.ndarray
A matrix giving the relationship between voxel coordinates and world coordinates.
mask : np.ndarray, optional
A boolean mask of the voxels to fit. Should be the shape of the desired T2* map rather than the raw data i.e. omit the time dimension.
method : {'loglin', '2p_exp'}, optional
Default loglin The method used to estimate T2 values. 'loglin' uses a weighted linear fit to the natural logarithm of the signal. '2p_exp' fits the signal to a two parameter exponential (S = S0 * exp(-t / T2)). loglin is far quicker but produces inaccurate results for T2* below 20 ms. 2p_exp is accurate below 20 ms however this comes at the expense of run time.
multithread : bool or 'auto', optional
Default 'auto'. If True, fitting will be distributed over all cores available on the node. If False, fitting will be carried out on a single thread. 'auto' attempts to apply multithreading where appropriate based on the number of voxels being fit and the method being used. Generally 'loglin' is quicker running single threaded due to the additional overheads of multithreading while '2p_exp' is quicker running multithreaded for anything but small numbers of voxels.
Expand source code
class T2Star:
    """
    Attributes
    ----------
    t2star_map : np.ndarray
        The estimated T2* values in ms
    t2star_err : np.ndarray
        The certainty in the fit of `t2star_map` in ms. Only returned if
        `2p_exp` method is used, otherwise is an array of nan
    m0_map : np.ndarray
        The estimated M0 values
    m0_err : np.ndarray
        The certainty in the fit of `m0_map`. Only returned if `2p_exp`
        method is used, otherwise is an array of nan
    r2 : np.ndarray
        The R-Squared value of the fit, values close to 1 indicate a good
        fit, lower values indicate a poorer fit
    shape : tuple
        The shape of the T2* map
    n_te : int
        The number of TE used to calculate the map
    n_vox : int
        The number of voxels in the map i.e. the product of all dimensions
        apart from TE
    """

    def __init__(self, pixel_array, echo_list, affine, mask=None,
                 method='loglin', multithread='auto'):
        """Initialise a T2Star class instance.

        Parameters
        ----------
        pixel_array : np.ndarray
            A array containing the signal from each voxel at each echo
            time with the last dimension being time i.e. the array needed to
            generate a 3D T2* map would have dimensions [x, y, z, TE].
        echo_list : list()
            An array of the echo times used for the last dimension of the
            raw data. In milliseconds.
        affine : np.ndarray
            A matrix giving the relationship between voxel coordinates and
            world coordinates.
        mask : np.ndarray, optional
            A boolean mask of the voxels to fit. Should be the shape of the
            desired T2* map rather than the raw data i.e. omit the time
            dimension.
        method : {'loglin', '2p_exp'}, optional
            Default `loglin`
            The method used to estimate T2* values. 'loglin' uses a
            weighted linear fit to the natural logarithm of the
            signal. '2p_exp' fits the signal to a two parameter
            exponential (S = S0 * exp(-t / T2*)). `loglin` is far quicker
            but produces inaccurate results for T2* below 20 ms. `2p_exp` is
            accurate below 20 ms however this comes at the expense of run time.
        multithread : bool or 'auto', optional
            Default 'auto'.
            If True, fitting will be distributed over all cores available on
            the node. If False, fitting will be carried out on a single thread.
            'auto' attempts to apply multithreading where appropriate based
            on the number of voxels being fit and the method being used.
            Generally 'loglin' is quicker running single threaded  due to
            the additional overheads of multithreading while '2p_exp' is
            quicker running multithreaded for anything but small numbers of
            voxels.
        """
        # Some sanity checks
        assert (pixel_array.shape[-1]
                == len(echo_list)), 'Number of echoes does not match the ' \
                                    'number of time frames on the last axis ' \
                                    'of pixel_array'
        assert method == 'loglin' \
            or method == '2p_exp', f'method must be loglin or 2p_exp. You ' \
                                   f'entered {method}'
        assert multithread is True \
            or multithread is False \
            or multithread == 'auto', f'multithreaded must be True, False ' \
                                      f'or auto. You entered {multithread}'

        # Normalise the data so its roughly in the same range across vendors
        self.scale = np.nanmax(pixel_array)
        self.pixel_array = pixel_array / self.scale

        self.shape = pixel_array.shape[:-1]
        self.n_te = pixel_array.shape[-1]
        self.n_vox = np.prod(self.shape)
        self.affine = affine

        # Generate a mask if there isn't one specified
        if mask is None:
            self.mask = np.ones(self.shape, dtype=bool)
        else:
            self.mask = mask.astype(bool)

        # Don't process any nan values
        self.mask[np.isnan(np.sum(pixel_array, axis=-1))] = False
        self.echo_list = echo_list
        self.method = method

        # Auto multithreading conditions
        if multithread == 'auto':
            if self.method == '2p_exp' and self.n_vox > 20:
                multithread = True
            else:
                multithread = False
        self.multithread = multithread

        # Fit data

        # Initialise an exponential model, even if we're using loglin fit,
        # so we're using the same limits etc
        self._exp_model = T2StarExpModel(self.pixel_array, self.echo_list,
                                         self.mask, self.multithread)
        if self.method == 'loglin':
            popt, error, r2 = self._loglin_fit()
        else:
            popt, error, r2 = fitting.fit_image(self._exp_model)

        self.t2star_map = popt[0]
        self.m0_map = popt[1]
        self.t2star_err = error[0]
        self.m0_err = error[1]
        self.r2 = r2

        # Filter values that are very close to models upper bounds of T2* or
        # M0 out.
        threshold = 0.999  # 99.9% of the upper bound
        bounds_mask = ((self.t2star_map >
                        self._exp_model.bounds[1][0] * threshold) |
                       (self.m0_map > self._exp_model.bounds[1][1] * threshold))
        self.t2star_map[bounds_mask] = 0
        self.m0_map[bounds_mask] = 0
        self.t2star_err[bounds_mask] = 0
        self.m0_err[bounds_mask] = 0
        self.r2[bounds_mask] = 0

        # Warn if using loglin method to produce a map with a large
        # proportion of T2* < 20 ms i.e. where loglin isn't as accurate.
        if self.method == 'loglin':
            proportion_less_than_20 = np.sum((self.t2star_map > 0) &
                                             (self.t2star_map < 20)) \
                                      / np.prod(self.n_vox)
            warn_thresh = 0.3
            if proportion_less_than_20 > warn_thresh:
                warnings.warn('{:%} of voxels in this map have a T2* less '
                              'than 20 ms. The loglin method is not accurate '
                              'in this regime. If these voxels are of '
                              'interest, consider using the 2p_exp fitting'
                              ' method'.format(proportion_less_than_20))

        # Scale the data back to the original range
        self.m0_map *= self.scale
        self.m0_err *= self.scale

    def _loglin_fit(self):
        if self.multithread:
            with ProcessPool() as executor:
                results = executor.map(self._fit_loglin_signal,
                                       self._exp_model.signal_list,
                                       self._exp_model.x_list,
                                       self._exp_model.mask_list,
                                       [self._exp_model] * self.n_vox)
        else:
            results = list(tqdm(map(self._fit_loglin_signal,
                                    self._exp_model.signal_list,
                                    self._exp_model.x_list,
                                    self._exp_model.mask_list,
                                    [self._exp_model] * self.n_vox),
                                total=self.n_vox))
        popt_array = np.array([result[0] for result in results])
        popt_list = [popt_array[:, p].reshape(self._exp_model.map_shape) for p
                     in range(self._exp_model.n_params)]
        error_array = np.array([result[1] for result in results])
        error_list = [error_array[:, p].reshape(self._exp_model.map_shape)
                      for p in range(self._exp_model.n_params)]
        r2 = np.array([result[2] for result in results]).reshape(
            self._exp_model.map_shape)
        return popt_list, error_list, r2

    @staticmethod
    def _fit_loglin_signal(sig, te, mask, model):
        if mask is True:
            with np.errstate(divide='ignore', invalid='ignore'):
                sig = np.array(sig)
                te = np.array(te)
                s_w = 0.0
                s_wx = 0.0
                s_wx2 = 0.0
                s_wy = 0.0
                s_wxy = 0.0
                n_te = len(sig)

                noise = sig.sum() / n_te
                sd = np.abs(np.sum(sig ** 2) / n_te - noise ** 2)
                if sd > 1e-10:
                    for t in range(n_te):
                        if sig[t] > 0:
                            te_tmp = te[t]
                            if sig[t] > sd:
                                sigma = np.log(sig[t] / (sig[t] - sd))
                            else:
                                sigma = np.log(sig[t] / 0.0001)
                            logsig = np.log(sig[t])
                            weight = 1 / sigma ** 2

                            s_w += weight
                            s_wx += weight * te_tmp
                            s_wx2 += weight * te_tmp ** 2
                            s_wy += weight * logsig
                            s_wxy += weight * te_tmp * logsig

                    delta = (s_w * s_wx2) - (s_wx ** 2)
                    if delta > 1e-5:
                        a = (1 / delta) * (s_wx2 * s_wy - s_wx * s_wxy)
                        b = (1 / delta) * (s_w * s_wxy - s_wx * s_wy)
                        t2star = np.real(-1 / b)
                        m0 = np.real(np.exp(a))
                        if t2star < 0 or t2star > model.bounds[1][0] or \
                           np.isnan(t2star):
                            t2star = 0
                            m0 = 0
                    else:
                        t2star = 0
                        m0 = 0
                else:
                    t2star = 0
                    m0 = 0
        else:
            t2star = 0
            m0 = 0

        fit_sig = two_param_eq(te, t2star, m0)
        r2 = r2_score(sig, fit_sig)
        t2star_err = np.nan
        m0_err = np.nan
        return (t2star, m0), (t2star_err, m0_err), r2

    def r2star_map(self):
        """
        Generates the R2* map from the T2* map output by initialising this
        class.

        Parameters
        ----------
        See class attributes in __init__

        Returns
        -------
        r2star_map : np.ndarray
            An array containing the R2* map generated
            by the function with R2* measured in ms^-1.
        """
        with np.errstate(divide='ignore'):
            r2star = np.nan_to_num(np.reciprocal(self.t2star_map),
                                   posinf=0, neginf=0)
        return r2star

    def to_nifti(self, output_directory=os.getcwd(), base_file_name='Output',
                 maps='all'):
        """Exports some of the T2Star class attributes to NIFTI.

        Parameters
        ----------
        output_directory : string, optional
            Path to the folder where the NIFTI files will be saved.
        base_file_name : string, optional
            Filename of the resulting NIFTI. This code appends the extension.
            Eg., base_file_name = 'Output' will result in 'Output.nii.gz'.
        maps : list or 'all', optional
            List of maps to save to NIFTI. This should either the string "all"
            or a list of maps from ["t2star", "t2star_err", "m0",
            "m0_err", "r2star", "r2", "mask"].
        """
        os.makedirs(output_directory, exist_ok=True)
        base_path = os.path.join(output_directory, base_file_name)
        if maps == 'all' or maps == ['all']:
            maps = ['t2star', 'm0', 'r2star', 'r2', 'mask']
            if self.method == '2p_exp':
                maps += ['t2star_err', 'm0_err']
        if isinstance(maps, list):
            for result in maps:
                if result == 't2star' or result == 't2star_map':
                    t2star_nifti = nib.Nifti1Image(self.t2star_map,
                                                   affine=self.affine)
                    nib.save(t2star_nifti, base_path + '_t2star_map.nii.gz')
                elif result == 't2star_err' or result == 't2star_err_map':
                    t2star_err_nifti = nib.Nifti1Image(self.t2star_err,
                                                       affine=self.affine)
                    nib.save(t2star_err_nifti, base_path +
                             '_t2star_err.nii.gz')
                    if self.method == 'loglin':
                        warnings.warn('Saving t2star_error however, '
                                      'the loglin method does not produce '
                                      'confidence intervals. As such the '
                                      'resulting nifti will be all zeros.')
                elif result == 'm0' or result == 'm0_map':
                    m0_nifti = nib.Nifti1Image(self.m0_map, affine=self.affine)
                    nib.save(m0_nifti, base_path + '_m0_map.nii.gz')
                elif result == 'm0_err' or result == 'm0_err_map':
                    m0_err_nifti = nib.Nifti1Image(self.m0_err,
                                                   affine=self.affine)
                    nib.save(m0_err_nifti, base_path + '_m0_err.nii.gz')
                    if self.method == 'loglin':
                        warnings.warn('Saving m0_error however, the loglin '
                                      'method does not produce confidence '
                                      'intervals. As such the resulting nifti'
                                      ' will be all zeros.')
                elif result == 'r2star' or result == 'r2star_map':
                    r2star_nifti = nib.Nifti1Image(T2Star.r2star_map(self),
                                                   affine=self.affine)
                    nib.save(r2star_nifti, base_path + '_r2star_map.nii.gz')
                elif result == 'r2' or result == 'r2_map':
                    r2_nifti = nib.Nifti1Image(self.r2,
                                               affine=self.affine)
                    nib.save(r2_nifti, base_path + '_r2.nii.gz')
                elif result == 'mask':
                    mask_nifti = nib.Nifti1Image(self.mask.astype(np.uint16),
                                                 affine=self.affine)
                    nib.save(mask_nifti, base_path + '_mask.nii.gz')
        else:
            raise ValueError('No NIFTI file saved. The variable "maps" '
                             'should be "all" or a list of maps from '
                             '"["t2star", "t2star_err", "m0", "m0_err", '
                             '"r2star", "mask"]".')

        return

    def get_fit_signal(self):
        """
        Get the fit signal from the model used to fit the data i.e. the
        simulated signal at each echo time given the estimated T2* and M0.

        Returns
        -------
        fit_signal : np.ndarray
            An array containing the fit signal generated by the model
        """
        fit_signal = np.zeros((self.n_vox, self.n_te))
        params = np.array([self.t2star_map.reshape(-1),
                           self.m0_map.reshape(-1)])

        for n in range(self.n_vox):
            fit_signal[n, :] = two_param_eq(self.echo_list,
                                            params[0, n],
                                            params[1, n])
        fit_signal = fit_signal.reshape((*self.shape, self.n_te))
        return fit_signal

Methods

def get_fit_signal(self)

Get the fit signal from the model used to fit the data i.e. the simulated signal at each echo time given the estimated T2* and M0.

Returns

fit_signal : np.ndarray
An array containing the fit signal generated by the model
def r2star_map(self)

Generates the R2 map from the T2 map output by initialising this class.

Parameters

See class attributes in init

Returns

r2star_map : np.ndarray
An array containing the R2 map generated by the function with R2 measured in ms^-1.
def to_nifti(self, output_directory='/home/runner/work/ukat/ukat', base_file_name='Output', maps='all')

Exports some of the T2Star class attributes to NIFTI.

Parameters

output_directory : string, optional
Path to the folder where the NIFTI files will be saved.
base_file_name : string, optional
Filename of the resulting NIFTI. This code appends the extension. Eg., base_file_name = 'Output' will result in 'Output.nii.gz'.
maps : list or 'all', optional
List of maps to save to NIFTI. This should either the string "all" or a list of maps from ["t2star", "t2star_err", "m0", "m0_err", "r2star", "r2", "mask"].
class T2StarExpModel (pixel_array, te, mask=None, multithread=True)

A class for fitting T2* data to a mono-exponential model.

Parameters

pixel_array : np.ndarray
An array containing the signal from each voxel at each echo time with the last dimension being time i.e. the array needed to generate a 3D T2* map would have dimensions [x, y, z, TE].
te : np.ndarray
An array of the echo times used for the last dimension of the pixel_array. In milliseconds.
mask : np.ndarray, optional
A boolean mask of the voxels to fit. Should be the shape of the desired T2* map rather than the raw data i.e. omit the time dimension.
multithread : bool, optional
Default True If True, the fitting will be performed in parallel using all available cores
Expand source code
class T2StarExpModel(fitting.Model):
    def __init__(self, pixel_array, te, mask=None, multithread=True):
        """
        A class for fitting T2* data to a mono-exponential model.

        Parameters
        ----------
        pixel_array : np.ndarray
            An array containing the signal from each voxel at each echo
            time with the last dimension being time i.e. the array needed to
            generate a 3D T2* map would have dimensions [x, y, z, TE].
        te : np.ndarray
            An array of the echo times used for the last dimension of the
            pixel_array. In milliseconds.
        mask : np.ndarray, optional
            A boolean mask of the voxels to fit. Should be the shape of the
            desired T2* map rather than the raw data i.e. omit the time
            dimension.
        multithread : bool, optional
            Default True
            If True, the fitting will be performed in parallel using all
            available cores
        """

        super().__init__(pixel_array, te, two_param_eq, mask, multithread)
        self.bounds = ([0, 0], [700, 100])
        self.initial_guess = [20, 1]
        self.generate_lists()

Ancestors

Inherited members