import os
import numpy as np
import matplotlib.pyplot as plt
from tqdm import tqdm
from dcmri import rel, sig, pk_inv, pk_aorta
PARAMS = {
'dt': {
'init': 1.0,
'name': 'Time step',
'unit': 'sec',
},
'ca': {
'init': None,
'name': 'Arterial Input concentration',
'unit': 'mL/sec/cm3',
},
'irf': {
'init': None,
'name': 'Impulse response function',
'unit': 'mL/sec/cm3',
},
'r1': {
'init': 5000.0,
'name': 'Contrast agent relaxivity',
'unit': 'Hz/M',
},
'R10a': {
'init': 0.7,
'name': 'Arterial precontrast R1',
'unit': 'Hz',
},
'S0a': {
'init': 1.0,
'name': 'Arterial signal scaling factor',
'unit': 'a.u.',
},
'B1corr_a': {
'init': 1,
'name': 'Arterial B1-correction factor',
'unit': '',
},
'B1corr': {
'name': 'Tissue B1-correction factor',
'unit': '',
},
'FA': {
'init': 15,
'name': 'Flip angle',
'unit': 'deg',
},
'TR': {
'init': 0.005,
'name': 'Repetition time',
'unit': 'sec',
},
'TC': {
'init': 0.2,
'name': 'Time to k-space center',
'unit': 'sec',
},
'TS': {
'init': 0,
'name': 'Sampling time',
'unit': 'sec',
},
'R10': {
'name': 'Tissue precontrast R1',
'unit': 'Hz',
},
'S0': {
'name': 'Signal scaling factor',
'unit': 'a.u.',
},
'H': {
'init': 0.45,
'name': 'Tissue Hematocrit',
'unit': '',
},
'Fb': {
'name': 'Parenchymal blood flow',
'unit': 'mL/sec/cm3',
},
've': {
'name': 'Extracellular volume',
'unit': 'mL/cm3',
},
'Te': {
'name': 'Extracellular mean transit time',
'unit': 'sec',
},
}
[docs]
class TissueLS():
"""Array of linear and stationary tissues with a single inlet.
These are generic model-free tissue types. Their response to
an indicator injection is proportional to the dose (linear) and
independent of the time of injection (stationary).
Args:
shape (array-like, required): shape of the tissue array (spatial dimensions only).
Any number of dimensions is allowed.
aif (array-like, required): Signal-time curve in the blood of the
feeding artery.
dt (float, optional): Time interval between values of the arterial
input function. Defaults to 1.0.
sequence (str, optional): imaging sequence. Possible values
are 'SS', 'SR' and 'lin' (linear). Defaults to 'SS'.
params (dict, optional): values for the parameters of the tissue,
specified as keyword parameters. Defaults are used for any that are
not provided.
See Also:
`TissueLS`, `TissueArray`
Example:
Fit a linear and stationary model to the synthetic test data:
.. plot::
:include-source:
:context: close-figs
>>> import numpy as np
>>> import dcmri as dc
Generate synthetic test data:
>>> time, aif, roi, gt = dc.fake_tissue()
The correct ground truth for ve in model-free analysis is the
extracellular part of the distribution space:
>>> gt['ve'] = gt['vp'] + gt['vi'] if gt['PS'] > 0 else gt['vp']
Build a tissue and set the constants to match the
experimental conditions of the synthetic test data.
>>> tissue = dc.TissueLS(
... dt = time[1],
... sequence = 'SS',
... r1 = dc.relaxivity(3, 'blood','gadodiamide'),
... TR = 0.005,
... FA = 15,
... R10a = 1/dc.T1(3.0,'blood'),
... R10 = 1/dc.T1(3.0,'muscle'),
... )
Train the tissue on the data. Since have noise-free synthetic
data we use a lower tolerance than the default, which is optimized
for noisy data:
>>> tissue.train(roi, aif, n0=10, tol=0.01)
Plot the reconstructed signals along with the concentrations
and the impulse response function.
>>> tissue.plot(roi)
"""
def __init__(self, sequence='SS', **kwargs):
# Configuration
if sequence not in ['SS', 'SR', 'lin']:
raise ValueError(
f"Sequence {sequence} is not recognized. "
f"Current options are 'SS', 'SR', 'lin'."
)
self.sequence = sequence
self.pars = {}
# Initialize scalar parameters
params = ['dt', 'H', 'R10a', 'S0a', 'r1']
if self.sequence == 'SR':
params += ['TC']
elif self.sequence == 'SS':
params += ['TR', 'B1corr_a', 'FA']
elif self.sequence == 'lin':
params += []
for par in params:
self.pars[par] = PARAMS[par]['init']
# Initialize array parameters
nt = 120
time = self.pars['dt'] * np.arange(nt)
Kb = 1 / 5.0
self.pars['ca'] = pk_aorta.aif_tristan(time)
self.pars['irf'] = 0.01 * np.exp(-Kb * time)
self.pars['S0'] = 1
self.pars['R10'] = 1
if self.sequence == 'SS':
self.pars['B1corr'] = 1
# Override parameter defaults
for par in kwargs:
self.pars[par] = kwargs[par]
[docs]
def predict_aif(self):
"""Predict the signal at specific time points
Returns:
np.ndarray: Array of predicted signals for each time point.
"""
# Predict arterial signal
R1a = rel.relax(self.pars['ca'], self.pars['R10a'], self.pars['r1'])
if self.sequence == 'SS':
Sa = sig.signal_ss(self.pars['S0a'], R1a, self.pars['TR'], self.pars['B1corr']*self.pars['FA'])
elif self.sequence == 'SR':
Sa = sig.signal_src(self.pars['S0a'], R1a, self.pars['TC'])
elif self.sequence == 'lin':
Sa = sig.signal_lin(self.pars['S0a'], R1a)
return Sa
[docs]
def predict_conc(self):
"""Return the tissue concentration
Returns:
np.ndarray: Concentration in M
"""
ca_mat = self.pars['dt'] * pk_inv.convmat(self.pars['ca'])
irf_mat = self.pars['irf']
conc = ca_mat @ irf_mat.T
return conc.T
[docs]
def predict(self):
"""Predict the signal at specific time points
Returns:
np.ndarray: Array of predicted signals for each time point.
"""
conc = self.predict_conc()
R1 = rel.relax(conc, self.pars['R10'], self.pars['r1'])
S0 = self.pars['S0']
if self.sequence == 'SS':
signal = sig.signal_ss(S0, R1, self.pars['TR'], self.pars['B1corr']*self.pars['FA'])
elif self.sequence == 'SR':
signal = sig.signal_src(S0, R1, self.pars['TC'])
elif self.sequence == 'lin':
signal = sig.signal_lin(S0, R1)
return signal
[docs]
def train(self, signal, signal_aif, n0=1, tol=0.1, init_s0=True):
"""Train the free parameters
Args:
signal (array-like): Array with measured signals.
tol: cut-off value for the singular values in the
computation of the matrix pseudo-inverse.
Returns:
self
"""
# Fit baselines if needed
if init_s0:
if self.sequence == 'SR':
scla = sig.signal_src(1, self.pars['R10a'], self.pars['TC'])
scl = sig.signal_src(1, self.pars['R10'], self.pars['TC'])
elif self.sequence == 'SS':
scla = sig.signal_ss(1, self.pars['R10a'], self.pars['TR'], self.pars['B1corr_a'] * self.pars['FA'])
scl = sig.signal_ss(1, self.pars['R10'], self.pars['TR'], self.pars['B1corr'] * self.pars['FA'])
elif self.sequence == 'lin':
scla = sig.signal_lin(1, self.pars['R10a'])
scl = sig.signal_lin(1, self.pars['R10'])
self.pars['S0a'] = np.mean(signal_aif[:n0]) / scla if scla > 0 else 0
self.pars['S0'] = np.mean(signal[...,:n0]) / scl if scl > 0 else 0
# Derive concentrations
T10a = 1/self.pars['R10a'] if self.pars['R10a'] > 0 else 0
T10 = 1/self.pars['R10'] if self.pars['R10'] > 0 else 0
if self.sequence == 'SR':
self.pars['ca'] = sig.conc_src(
signal_aif, self.pars['TC'], T10a,
self.pars['r1'], S0=self.pars['S0a'])
conc = sig.conc_src(
signal, self.pars['TC'], T10,
self.pars['r1'], S0=self.pars['S0'])
elif self.sequence == 'SS':
self.pars['ca'] = sig.conc_ss(
signal_aif, self.pars['TR'], self.pars['B1corr_a'] * self.pars['FA'],
T10a, self.pars['r1'], S0=self.pars['S0a'])
conc = sig.conc_ss(
signal, self.pars['TR'], self.pars['B1corr_a'] * self.pars['FA'],
T10, self.pars['r1'], S0=self.pars['S0'])
elif self.sequence == 'lin':
self.pars['ca'] = sig.conc_lin(
signal_aif, T10a,
self.pars['r1'], S0=self.pars['S0a'])
conc = sig.conc_lin(
signal, T10, self.pars['r1'], S0=self.pars['S0'])
conc[np.isnan(conc)] = 0
irf_mat = pk_inv.deconv(conc, self.pars['ca'], self.pars['dt'], tol=tol)
self.pars['irf'] = irf_mat
return self
[docs]
def params(self, *args):
"""Export the tissue parameters
Args:
args (tuple): parameters to get. If no arguments are
provided, all available parameters are returned.
Returns:
dict: Dictionary with tissue parameters.
"""
amax = np.max(self.pars['irf'])
auc = np.sum(self.pars['irf']) * self.pars['dt']
params = {
'IRF': self.pars['irf'],
'Fb': amax,
'Te': auc / amax if amax > 0 else 0,
've': auc * (1-self.pars['H']),
'S0': self.pars['S0'],
}
if args == ():
return params
elif len(args) == 1:
return params[args[0]]
else:
return {p: params[p] for p in args}
[docs]
def print_params(self, round_to=None):
"""Print the model parameters
Args:
round_to (int, optional): Round to how many digits. If this is
not provided, the values are not rounded. Defaults to None.
"""
print('')
print('----------------------------------------------------')
print('Derived parameters for linear and stationary tissues')
print('----------------------------------------------------')
print('')
p = self.params()
par = p['Fb']
par = round(par, round_to) if round_to is not None else par
print(f"Parenchymal blood flow: {par} mL/sec/cm3")
par = p['Te']
par = round(par, round_to) if round_to is not None else par
print(f"Extracellular transit time: {par} sec")
par = p['ve']
par = round(par, round_to) if round_to is not None else par
print(f"Extracellular volume fraction: {par} mL/cm3")
[docs]
def plot(self, signal=None, round_to=None, fname=None, show=True):
"""Plot the model fit against data
Args:
signal (array-like, optional): Array with measured signals.
round_to (int, optional): Rounding for the model parameters.
fname (path, optional): Filepath to save the image. If no value is
provided, the image is not saved. Defaults to None.
show (bool, optional): If True, the plot is shown. Defaults to
True.
"""
fig, ax = plt.subplots(1, 4, figsize=(20, 5))
t = self.pars['dt'] * np.arange(len(self.pars['ca']))
# Plot predicted signals and measured signals
ax[0].set_title('MRI signals')
ax[0].set(ylabel='MRI signal (a.u.)', xlabel='Time (min)')
ax[0].plot(
t / 60, self.predict(), linestyle='-',
linewidth=3.0, color='cornflowerblue',
label='Tissue (predicted)',
)
if signal is not None:
ax[0].plot(
t / 60, signal, marker='x', linestyle='None',
color='darkblue', label='Tissue (measured)',
)
ax[0].legend()
# Plot predicted concentrations and measured concentrations
ax[1].set_title('Tissue concentrations')
ax[1].set(ylabel='Concentration (mM)', xlabel='Time (min)')
ax[1].plot(
t / 60, 1000 * self.pars['ca'], linestyle='-', linewidth=5.0,
color='lightcoral', label='Arterial blood',
)
ax[1].plot(
t / 60, 1000 * self.predict_conc(), linestyle='-', linewidth=3.0,
color='cornflowerblue', label='Tissue (predicted)',
)
ax[1].legend()
# Plot impulse response
ax[2].set_title('Impulse response function')
ax[2].set(ylabel='IRF (mL/sec/cm3)', xlabel='Time (min)')
ax[2].plot(
t / 60, self.pars['irf'], linestyle='-', linewidth=3.0,
color='cornflowerblue', label='IRF',
)
ax[2].legend()
# Plot text
pars = self.params()
msg = []
for par in ['Fb', 've', 'Te', 'S0']:
value = pars[par]
if round_to is not None:
value = round(value, round_to)
msg.append(f"{PARAMS[par]['name']} ({par}): {value} {PARAMS[par]['unit']} \n")
msg = "\n".join(msg)
ax[3].set_title('Free parameters')
ax[3].axis("off") # hide axes
ax[3].text(0, 0.9, msg, fontsize=10, transform=ax[3].transAxes, ha="left", va="top")
# Show and/or save plot
if fname is not None:
plt.savefig(fname=fname)
if show:
plt.show()
else:
plt.close()