Module ukat.mapping.t1
Functions
def magnitude_correct(pixel_array)
-
Sign corrects the magnitude of inversion recovery data using the complex component of the signal.
This function uses the methods of Jerzy Szumowski et al (https://doi.org/10.1002/jmri.23705).
Parameters
pixel_array
:ndarray
- Can either be a complex array or have the real and imaginary parts of the image as the final dimension e.g. a complex 3D image could have the dimensions [x, y, z, ti] where [0, 0, 0, 0] = 1 + 2j or the dimensions [x, y, z, ti, type] where [0, 0, 0, 0, 0] = 1 and [0, 0, 0, 0, 1] = 2.
Returns
corrected_array
:ndarray
- An array of the magnitude intensities with signs corrected.
def three_param_abs_eq(t, t1, m0, eff)
-
Calculate the expected signal from the equation signal = abs(M0 * (1 - eff * exp(-t / T1)))
Parameters
t
:list
- The times the signal will be calculated at
t1
:float
- The T1 of the signal
m0
:float
- The M0 of the signal
eff
:float
- The inversion efficiency (where 0 is no inversion and 2 is a 180 degree inversion)
Returns
signal
:ndarray
def three_param_eq(t, t1, m0, eff)
-
Calculate the expected signal from the equation signal = M0 * (1 - eff * exp(-t / T1)))
Parameters
t
:list
- The times the signal will be calculated at
t1
:float
- The T1 of the signal
m0
:float
- The M0 of the signal
eff
:float
- The inversion efficiency (where 0 is no inversion and 2 is a 180 degree inversion)
Returns
signal
:ndarray
def two_param_abs_eq(t, t1, m0)
-
Calculate the expected signal from the equation signal = abs(M0 * (1 - 2 * exp(-t / T1)))
Parameters
t
:list
- The times the signal will be calculated at
t1
:float
- The T1 of the signal
m0
:float
- The M0 of the signal
Returns
signal
:ndarray
def two_param_eq(t, t1, m0)
-
Calculate the expected signal from the equation signal = M0 * (1 - 2 * exp(-t / T1))
Parameters
t
:list
- The times the signal will be calculated at
t1
:float
- The T1 of the signal
m0
:float
- The M0 of the signal
Returns
signal
:ndarray
Classes
class T1 (pixel_array, inversion_list, affine, tss=0, tss_axis=-2, mask=None, parameters=2, molli=False, multithread=True)
-
Attributes
t1_map
:np.ndarray
- The estimated T1 values in ms
t1_err
:np.ndarray
- The certainty in the fit of
t1
in ms m0_map
:np.ndarray
- The estimated M0 values
m0_err
:np.ndarray
- The certainty in the fit of
m0
eff_map
:np.ndarray
- The estimated inversion efficiency where 0 represents no inversion pulse and 2 represents a 180 degree inversion
eff_err
:np.ndarray
- The certainty in the fit of
eff
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 T1 map
n_ti
:int
- The number of TI used to calculate the map
n_vox
:int
- The number of voxels in the map i.e. the product of all dimensions apart from TI
Initialise a T1 class instance.
Parameters
pixel_array
:np.ndarray
- A array containing the signal from each voxel at each inversion time with the last dimension being time i.e. the array needed to generate a 3D T1 map would have dimensions [x, y, z, TI].
inversion_list
:list()
- An array of the inversion times used for the last dimension of the raw data. In milliseconds.
tss
:float
, optional- Default 0 The temporal slice spacing is the delay between acquisition of slices in a T1 map. Including this information means the inversion time is correct for each slice in a multi-slice T1 map. In milliseconds.
tss_axis
:int
, optional- Default -2 i.e. last spatial axis
The axis over which the temporal slice spacing is applied. This
axis is relative to the full 4D pixel array i.e. tss_axis=-1
would be along the TI axis and would be meaningless.
If
pixel_array
is single slice (dimensions [x, y, TI]), then this should be set to None. 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 T1 map rather than the raw data i.e. omit the time dimension.
parameters
:{2, 3}
, optional- Default
2
The number of parameters to fit the data to. A two parameter fit will estimate S0 and T1 while a three parameter fit will also estimate the inversion efficiency. molli
:bool
, optional- Default False. Apply MOLLI corrections to T1.
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. Multithreading is useful when calculating the T1 for a large number of voxels e.g. generating a multi-slice abdominal T1 map. Turning off multithreading can be useful when fitting very small amounts of data e.g. a mean T1 signal decay over a ROI when the overheads of multithreading are more of a hindrance than the increase in speed distributing the calculation would generate. 'auto' attempts to apply multithreading where appropriate based on the number of voxels being fit.
Expand source code
class T1: """ Attributes ---------- t1_map : np.ndarray The estimated T1 values in ms t1_err : np.ndarray The certainty in the fit of `t1` in ms m0_map : np.ndarray The estimated M0 values m0_err : np.ndarray The certainty in the fit of `m0` eff_map : np.ndarray The estimated inversion efficiency where 0 represents no inversion pulse and 2 represents a 180 degree inversion eff_err : np.ndarray The certainty in the fit of `eff` 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 T1 map n_ti : int The number of TI used to calculate the map n_vox : int The number of voxels in the map i.e. the product of all dimensions apart from TI """ def __init__(self, pixel_array, inversion_list, affine, tss=0, tss_axis=-2, mask=None, parameters=2, molli=False, multithread=True): """Initialise a T1 class instance. Parameters ---------- pixel_array : np.ndarray A array containing the signal from each voxel at each inversion time with the last dimension being time i.e. the array needed to generate a 3D T1 map would have dimensions [x, y, z, TI]. inversion_list : list() An array of the inversion times used for the last dimension of the raw data. In milliseconds. tss : float, optional Default 0 The temporal slice spacing is the delay between acquisition of slices in a T1 map. Including this information means the inversion time is correct for each slice in a multi-slice T1 map. In milliseconds. tss_axis : int, optional Default -2 i.e. last spatial axis The axis over which the temporal slice spacing is applied. This axis is relative to the full 4D pixel array i.e. tss_axis=-1 would be along the TI axis and would be meaningless. If `pixel_array` is single slice (dimensions [x, y, TI]), then this should be set to None. 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 T1 map rather than the raw data i.e. omit the time dimension. parameters : {2, 3}, optional Default `2` The number of parameters to fit the data to. A two parameter fit will estimate S0 and T1 while a three parameter fit will also estimate the inversion efficiency. molli : bool, optional Default False. Apply MOLLI corrections to T1. 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. Multithreading is useful when calculating the T1 for a large number of voxels e.g. generating a multi-slice abdominal T1 map. Turning off multithreading can be useful when fitting very small amounts of data e.g. a mean T1 signal decay over a ROI when the overheads of multithreading are more of a hindrance than the increase in speed distributing the calculation would generate. 'auto' attempts to apply multithreading where appropriate based on the number of voxels being fit. """ assert multithread is True \ or multithread is False \ or multithread == 'auto', f'multithreaded must be True,' \ f'False or auto. You entered ' \ f'{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.dimensions = len(pixel_array.shape) self.n_ti = 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.inversion_list = inversion_list self.tss = tss if tss_axis is not None: self.tss_axis = tss_axis % self.dimensions else: self.tss_axis = None self.tss = 0 self.parameters = parameters self.molli = molli if multithread == 'auto': if self.n_vox > 20: multithread = True else: multithread = False self.multithread = multithread # Some sanity checks assert (pixel_array.shape[-1] == len(inversion_list)), 'Number of inversions does not ' \ 'match the number of time frames ' \ 'on the last axis of pixel_array' if self.tss != 0: assert (self.tss_axis != self.dimensions - 1), \ 'Temporal slice spacing can\'t be applied to the TI axis.' assert (tss_axis < self.dimensions), \ 'tss_axis must be less than the number of spatial dimensions' if self.molli: if self.parameters == 2: self.parameters = 3 warnings.warn('MOLLI requires a three parameter fit, ' 'using parameters=3.') # Fit Data self.fitting_model = T1Model(self.pixel_array, self.inversion_list, self.parameters, self.mask, self.tss, self.tss_axis, self.molli, self.multithread) popt, error, r2 = fitting.fit_image(self.fitting_model) self.t1_map = popt[0] self.m0_map = popt[1] self.t1_err = error[0] self.m0_err = error[1] self.r2 = r2 if self.parameters == 3: self.eff_map = popt[2] self.eff_err = error[2] # Filter values that are very close to models upper bounds of T1 or # M0 out. Not filtering based on eff as this should ideally be at # the upper bound! threshold = 0.999 # 99.9% of the upper bound bounds_mask = ((self.t1_map > self.fitting_model.bounds[1][0] * threshold) | (self.m0_map > self.fitting_model.bounds[1][1] * threshold)) self.t1_map[bounds_mask] = 0 self.m0_map[bounds_mask] = 0 self.t1_err[bounds_mask] = 0 self.m0_err[bounds_mask] = 0 self.r2[bounds_mask] = 0 if self.parameters == 3: self.eff_map[bounds_mask] = 0 self.eff_err[bounds_mask] = 0 # Do MOLLI correction if self.molli: correction_factor = (((self.m0_map * self.eff_map) / self.m0_map) - 1) percentage_error = self.t1_err / self.t1_map self.t1_map = np.nan_to_num(self.t1_map * correction_factor) self.t1_err = np.nan_to_num(self.t1_map * percentage_error) # Scale the data back to the original scale self.m0_map *= self.scale self.m0_err *= self.scale def r1_map(self): """ Generates the R1 map from the T1 map output by initialising this class. Parameters ---------- See class attributes in __init__ Returns ------- r1_map : np.ndarray An array containing the R1 map generated by the function with R1 measured in ms. """ with np.errstate(divide='ignore'): r1_map = np.nan_to_num(np.reciprocal(self.t1_map), posinf=0, neginf=0) return r1_map def to_nifti(self, output_directory=os.getcwd(), base_file_name='Output', maps='all'): """Exports some of the T1 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 ["t1", "t1_err", "m0", "m0_err", "eff", "eff_err", "r1", "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 = ['t1', 't1_err', 'm0', 'm0_err', 'eff', 'eff_err', 'r1_map', 'r2', 'mask'] if isinstance(maps, list): for result in maps: if result == 't1' or result == 't1_map': t1_nifti = nib.Nifti1Image(self.t1_map, affine=self.affine) nib.save(t1_nifti, base_path + '_t1_map.nii.gz') elif result == 't1_err': t1_err_nifti = nib.Nifti1Image(self.t1_err, affine=self.affine) nib.save(t1_err_nifti, base_path + '_t1_err.nii.gz') 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': m0_err_nifti = nib.Nifti1Image(self.m0_err, affine=self.affine) nib.save(m0_err_nifti, base_path + '_m0_err.nii.gz') elif (self.parameters == 3) and \ (result == 'eff' or result == 'eff_map'): eff_nifti = nib.Nifti1Image(self.eff_map, affine=self.affine) nib.save(eff_nifti, base_path + '_eff_map.nii.gz') elif self.parameters == 3 and result == 'eff_err': eff_err_nifti = nib.Nifti1Image(self.eff_err, affine=self.affine) nib.save(eff_err_nifti, base_path + '_eff_err.nii.gz') elif result == 'r1' or result == 'r1_map': r1_nifti = nib.Nifti1Image(T1.r1_map(self), affine=self.affine) nib.save(r1_nifti, base_path + '_r1_map.nii.gz') elif result == 'r2': 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 ' '"["t1", "t1_err", "m0", "m0_err", "eff", ' '"eff_err", "r1", "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 inversion time given the estimated T1, M0 (and inversion efficiency if applicable). Returns ------- fit_signal : np.ndarray An array containing the fit signal generated by the model """ if self.molli: t1 = self.t1_map / ((self.m0_map * self.eff_map) / self.m0_map - 1) else: t1 = self.t1_map t1_lin = t1.reshape(-1) m0_lin = self.m0_map.reshape(-1) if self.parameters == 3: eff_lin = self.eff_map.reshape(-1) fit_signal = np.zeros((self.n_vox, self.n_ti)) if self.parameters == 2: for n, (ti, t1, m0) in enumerate(zip(self.fitting_model.x_list, t1_lin, m0_lin)): fit_signal[n, :] = self.fitting_model.t1_eq(ti, t1, m0) else: for n, (ti, t1, m0, eff) in ( enumerate(zip(self.fitting_model.x_list, t1_lin, m0_lin, eff_lin))): fit_signal[n, :] = self.fitting_model.t1_eq(ti, t1, m0, eff) fit_signal = fit_signal.reshape((*self.shape, self.n_ti)) 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 inversion time given the estimated T1, M0 (and inversion efficiency if applicable).
Returns
fit_signal
:np.ndarray
- An array containing the fit signal generated by the model
def r1_map(self)
-
Generates the R1 map from the T1 map output by initialising this class.
Parameters
See class attributes in init
Returns
r1_map
:np.ndarray
- An array containing the R1 map generated by the function with R1 measured in ms.
def to_nifti(self, output_directory='/home/runner/work/ukat/ukat', base_file_name='Output', maps='all')
-
Exports some of the T1 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 ["t1", "t1_err", "m0", "m0_err", "eff", "eff_err", "r1", "r2", "mask"]
class T1Model (pixel_array, ti, parameters=2, mask=None, tss=0, tss_axis=-2, molli=False, multithread=True)
-
A class containing the T1 fitting 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 T1 map would have dimensions [x, y, z, TE].
ti
:np.ndarray
- An array of the inversion times used for the last dimension of the pixel_array. In milliseconds.
parameters
:{2, 3}
, optional- Default
2
The number of parameters to fit the data to. A two parameter fit will estimate S0 and T1 while a three parameter fit will also estimate the inversion efficiency. mask
:np.ndarray
, optional- A boolean mask of the voxels to fit. Should be the shape of the desired T1 map rather than the raw data i.e. omit the time dimension.
tss
:float
, optional- Default 0 The temporal slice spacing is the delay between acquisition of slices in a T1 map. Including this information means the inversion time is correct for each slice in a multi-slice T1 map. In milliseconds.
tss_axis
:int
, optional- Default -2 i.e. last spatial axis
The axis over which the temporal slice spacing is applied. This
axis is relative to the full 4D pixel array i.e. tss_axis=-1
would be along the TI axis and would be meaningless.
If
pixel_array
is single slice (dimensions [x, y, TI]), then this should be set to None. multithread
:bool
, optional- Default True If True, the fitting will be performed in parallel using all available cores
Expand source code
class T1Model(fitting.Model): def __init__(self, pixel_array, ti, parameters=2, mask=None, tss=0, tss_axis=-2, molli=False, multithread=True): """ A class containing the T1 fitting 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 T1 map would have dimensions [x, y, z, TE]. ti : np.ndarray An array of the inversion times used for the last dimension of the pixel_array. In milliseconds. parameters : {2, 3}, optional Default `2` The number of parameters to fit the data to. A two parameter fit will estimate S0 and T1 while a three parameter fit will also estimate the inversion efficiency. mask : np.ndarray, optional A boolean mask of the voxels to fit. Should be the shape of the desired T1 map rather than the raw data i.e. omit the time dimension. tss : float, optional Default 0 The temporal slice spacing is the delay between acquisition of slices in a T1 map. Including this information means the inversion time is correct for each slice in a multi-slice T1 map. In milliseconds. tss_axis : int, optional Default -2 i.e. last spatial axis The axis over which the temporal slice spacing is applied. This axis is relative to the full 4D pixel array i.e. tss_axis=-1 would be along the TI axis and would be meaningless. If `pixel_array` is single slice (dimensions [x, y, TI]), then this should be set to None. multithread : bool, optional Default True If True, the fitting will be performed in parallel using all available cores """ self.parameters = parameters self.tss = tss self.tss_axis = tss_axis self.molli = molli # Assume the data has been magnitude corrected if the first # percentile of the first inversion time is negative. if np.percentile(pixel_array[..., 0], 1) < 0: self.mag_corr = True neg_percent = (np.sum(pixel_array[..., 0] < 0) / pixel_array[..., 0].size) if neg_percent < 0.05: warnings.warn('Fitting data to a magnitude corrected ' 'inversion recovery curve however, less than 5% ' 'of the data from the first inversion is ' 'negative. If you have performed magnitude ' 'correction ignore this warning, otherwise the ' 'negative values could be due to noise or ' 'preprocessing steps such as EPI distortion ' 'correction and registration.\n' f'Percentage of first inversion data that is ' f'negative = {neg_percent:.2%}') else: self.mag_corr = False if np.nanmin(pixel_array) < 0: warnings.warn('Negative values found in data from the first ' 'inversion but as the first percentile is not ' 'negative, it is assumed these are negative ' 'due to noise or preprocessing steps such as ' 'EPI distortion correction and registration. ' 'As such the data will be fit to the modulus of ' 'the recovery curve.\n' f'Min value = {np.nanmin(pixel_array[..., 0])}\n' '1st percentile = ' f'{np.percentile(pixel_array[..., 0], 1)}') if self.parameters == 2: if self.mag_corr: self.t1_eq = two_param_eq super().__init__(pixel_array, ti, self.t1_eq, mask, multithread) else: self.t1_eq = two_param_abs_eq super().__init__(pixel_array, ti, self.t1_eq, mask, multithread) self.bounds = ([0, 0], [5000, 100]) self.initial_guess = [1000, 1] elif self.parameters == 3: if self.mag_corr: self.t1_eq = three_param_eq super().__init__(pixel_array, ti, self.t1_eq, mask, multithread) else: self.t1_eq = three_param_abs_eq super().__init__(pixel_array, ti, self.t1_eq, mask, multithread) if self.molli: self.bounds = ([0, 0, 0], [5000, 100, 3]) self.initial_guess = [1000, 1, 2] else: self.bounds = ([0, 0, 1], [5000, 100, 2]) self.initial_guess = [1000, 1, 2] else: raise ValueError(f'Parameters can be 2 or 3 only. You specified ' f'{parameters}.') self.generate_lists() if self.tss != 0: self._tss_correct_ti() def _tss_correct_ti(self): slices = np.indices(self.map_shape)[self.tss_axis].ravel() for ind, (ti, slice) in enumerate(zip(self.x_list, slices)): self.x_list[ind] = np.array(ti) + self.tss * slice
Ancestors
Inherited members