Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

SE-EPI T1 Acquisition Orders #238

Merged
merged 8 commits into from
Feb 20, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 63 additions & 11 deletions ukat/mapping/t1.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@

class T1Model(fitting.Model):
def __init__(self, pixel_array, ti, parameters=2, mask=None, tss=0,
tss_axis=-2, molli=False, mag_corr=False, multithread=True):
tss_axis=-2, acq_order='ascend', molli=False, mag_corr=False,
multithread=True):
"""
A class containing the T1 fitting model

Expand Down Expand Up @@ -45,6 +46,19 @@ def __init__(self, pixel_array, ti, parameters=2, mask=None, tss=0,
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.
acq_order : str or list, optional
Default 'ascend'
The order in which the slices were acquired. 'ascend' assumes
the zeroth slice (in the tss_axis) was acquired first, 'descend'
assumes the -1 slice was acquired first and the zeroth
slice was acquired last. 'centric' assumes the centre slice was
acquired first, then the slice above the centre, then the slice
below the centre etc. In the case of an even number of slices,
the centre slice is taken as the lower of the two central slices.
Alternatively, a list of integers can be used to specify the
acquisition order. Specifying `acq_order='centric'` and
`acq_order=[4, 2, 0, 1, 3, 5]` would be equivalent for a six
slice acquisition.
mag_corr : bool, optional
Default False
If True, the data is assumed to have been magnitude corrected
Expand All @@ -60,6 +74,7 @@ def __init__(self, pixel_array, ti, parameters=2, mask=None, tss=0,
self.parameters = parameters
self.tss = tss
self.tss_axis = tss_axis
self.acq_order = acq_order
self.molli = molli

if (mag_corr is False) & (np.nanmin(pixel_array) < 0):
Expand Down Expand Up @@ -107,7 +122,23 @@ def __init__(self, pixel_array, ti, parameters=2, mask=None, tss=0,
self._tss_correct_ti()

def _tss_correct_ti(self):
slices = np.indices(self.map_shape)[self.tss_axis].ravel()
slices = np.indices(self.map_shape)[self.tss_axis]
if self.acq_order == 'ascend':
slices = slices.ravel()
elif self.acq_order == 'descend':
slices = np.flip(slices, axis=self.tss_axis).ravel()
elif self.acq_order == 'centric':
ns = self.map_shape[self.tss_axis]
# Generate the acquisition order for centric ordering. e.g. for
# a five slice acquisition, the order would be [4, 2, 0, 1, 3].
start = ns - 2 if ns % 2 == 0 else ns - 1
evens_desc = np.arange(start, -1, -2)
odds_asc = np.arange(1, ns, 2)
acq_ind = np.concatenate([evens_desc, odds_asc])
slices = np.take(slices, acq_ind, axis=self.tss_axis).ravel()
else:
slices = np.take(slices, self.acq_order, axis=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

Expand Down Expand Up @@ -144,9 +175,9 @@ class T1:
apart from TI
"""

def __init__(self, pixel_array, inversion_list, affine, tss=0, tss_axis=-2,
mask=None, parameters=2, mag_corr=False, molli=False,
multithread=True, mdr=False):
def __init__(self, pixel_array, inversion_list, affine, tss=0,
tss_axis=-2, acq_order='ascend', mask=None, parameters=2,
mag_corr=False, molli=False, multithread=True, mdr=False):
"""Initialise a T1 class instance.

Parameters
Expand All @@ -171,6 +202,19 @@ def __init__(self, pixel_array, inversion_list, affine, tss=0, tss_axis=-2,
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.
acq_order : str or list, optional
Default 'ascend'
The order in which the slices were acquired. 'ascend' assumes
the zeroth slice (in the tss_axis) was acquired first, 'descend'
assumes the -1 slice was acquired first and the zeroth
slice was acquired last. 'centric' assumes the centre slice was
acquired first, then the slice above the centre, then the slice
below the centre etc. In the case of an even number of slices,
the centre slice is taken as the lower of the two central slices.
Alternatively, a list of integers can be used to specify the
acquisition order. Specifying `acq_order='centric'` and
`acq_order=[4, 2, 0, 1, 3, 5]` would be equivalent for a six
slice acquisition.
affine : np.ndarray
A matrix giving the relationship between voxel coordinates and
world coordinates.
Expand Down Expand Up @@ -272,6 +316,15 @@ def __init__(self, pixel_array, inversion_list, affine, tss=0, tss_axis=-2,
raise ValueError('Temporal slice spacing only supported '
'along the z direction when using '
'model-driven registration.')
if type(acq_order) == list:
assert len(acq_order) == self.shape[self.tss_axis], \
'acq_order must have the same length as the number of ' \
'slices in the tss_axis.'
assert type(acq_order[0]) == int, \
'acq_order must be a list of integers.'
elif acq_order not in ['ascend', 'descend', 'centric']:
raise ValueError('acq_order must be a list of integers or '
'one of "ascend", "descend" or "centric".')

if self.molli:
if self.parameters == 2:
Expand All @@ -285,7 +338,7 @@ def __init__(self, pixel_array, inversion_list, affine, tss=0, tss_axis=-2,
# are the same.
if self.tss == 0:
pixel_array, deform, _, _ = mdreg.fit(
self.pixel_array,
np.nan_to_num(self.pixel_array),
force_2d=True,
verbose=1,
fit_image={
Expand Down Expand Up @@ -325,7 +378,7 @@ def __init__(self, pixel_array, inversion_list, affine, tss=0, tss_axis=-2,
+ self.tss * slice)
(pixel_array[..., slice, :], deform[..., slice, :, :], _,
_) = mdreg.fit(
self.pixel_array[..., slice, :],
np.nan_to_num(self.pixel_array[..., slice, :]),
force_2d=True,
verbose=1,
fit_image={
Expand Down Expand Up @@ -357,8 +410,8 @@ def __init__(self, pixel_array, inversion_list, affine, tss=0, tss_axis=-2,
# Fit Data
self.fitting_model = T1Model(self.pixel_array, self.inversion_list,
self.parameters, self.mask, self.tss,
self.tss_axis, self.molli, self.mag_corr,
self.multithread)
self.tss_axis, acq_order, self.molli,
self.mag_corr, self.multithread)
self.mag_corr = self.fitting_model.mag_corr
popt, error, r2 = fitting.fit_image(self.fitting_model)
self.t1_map = popt[0]
Expand Down Expand Up @@ -390,8 +443,7 @@ def __init__(self, pixel_array, inversion_list, affine, tss=0, tss_axis=-2,

# Do MOLLI correction
if self.molli:
correction_factor = (((self.m0_map * self.eff_map) / self.m0_map)
- 1)
correction_factor = -(1 - self.eff_map)
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)
Expand Down
121 changes: 92 additions & 29 deletions ukat/mapping/tests/test_t1.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,37 +33,37 @@ class TestT1:
1689.08502946])
# The ideal signal produced by the equation M0 * (1 - 2 * exp(-t / T1))
# where M0 = 5000 and T1 = 1000 acquired over three slices at 9 t
# between 200 and 1000 ms + a temporal slice spacing of 10 ms
# between 200 and 1000 ms + a temporal slice spacing of 100 ms
correct_signal_two_param_tss = np.array([[[-3187.30753078, -2408.18220682,
-1703.20046036, -1065.30659713,
-488.11636094, 34.14696209,
506.71035883, 934.30340259,
1321.20558829],
[-3105.8424597, -2334.46956224,
-1636.50250136, -1004.95578812,
-433.50869074, 83.55802539,
551.41933777, 974.75775966,
1357.81020428],
[-3025.18797962, -2261.49037074,
-1570.46819815, -945.2054797,
-379.44437595, 132.4774404,
595.68345494, 1014.80958915,
1394.05059827]],
[-2408.18220682, -1703.20046036,
-1065.30659713, -488.11636094,
34.14696209, 506.71035883,
934.30340259, 1321.20558829,
1671.28916302],
[-1703.20046036, -1065.30659713,
-488.11636094, 34.14696209,
506.71035883, 934.30340259,
1321.20558829, 1671.28916302,
1988.05788088]],
[[-3187.30753078, -2408.18220682,
-1703.20046036, -1065.30659713,
-488.11636094, 34.14696209,
506.71035883, 934.30340259,
1321.20558829],
[-3105.8424597, -2334.46956224,
-1636.50250136, -1004.95578812,
-433.50869074, 83.55802539,
551.41933777, 974.75775966,
1357.81020428],
[-3025.18797962, -2261.49037074,
-1570.46819815, -945.2054797,
-379.44437595, 132.4774404,
595.68345494, 1014.80958915,
1394.05059827]]
[-2408.18220682, -1703.20046036,
-1065.30659713, -488.11636094,
34.14696209, 506.71035883,
934.30340259, 1321.20558829,
1671.28916302],
[-1703.20046036, -1065.30659713,
-488.11636094, 34.14696209,
506.71035883, 934.30340259,
1321.20558829, 1671.28916302,
1988.05788088]]
])
# Make some silly data that the code won't be able to fit any values to.
signal_fail_fit = np.arange(0, 9) % 2
Expand Down Expand Up @@ -147,20 +147,61 @@ def test_three_param_fit(self):
def test_tss(self):

mapper = T1(self.correct_signal_two_param_tss, self.t, self.affine,
tss=10, mag_corr=True)
tss=100, mag_corr=True)
assert mapper.shape == self.correct_signal_two_param_tss.shape[:-1]
npt.assert_almost_equal(mapper.t1_map.mean(), self.t1)
npt.assert_almost_equal(mapper.m0_map.mean(), self.m0)
npt.assert_almost_equal(mapper.r1_map().mean(), 1 / self.t1)
npt.assert_almost_equal(mapper.t1_map.mean(), self.t1, 4)
npt.assert_almost_equal(mapper.m0_map.mean(), self.m0, 4)
npt.assert_almost_equal(mapper.r1_map().mean(), 1 / self.t1, 4)
npt.assert_almost_equal(mapper.r2.mean(), 1)

def test_tss_axis(self):
signal_array = np.swapaxes(self.correct_signal_two_param_tss, 0, 1)
mapper = T1(signal_array, self.t, self.affine, tss=10, tss_axis=0,
mapper = T1(signal_array, self.t, self.affine, tss=100, tss_axis=0,
mag_corr=True)
npt.assert_almost_equal(mapper.t1_map.mean(), self.t1)
npt.assert_almost_equal(mapper.m0_map.mean(), self.m0)
npt.assert_almost_equal(mapper.r1_map().mean(), 1 / self.t1)
npt.assert_almost_equal(mapper.t1_map.mean(), self.t1, 4)
npt.assert_almost_equal(mapper.m0_map.mean(), self.m0, 4)
npt.assert_almost_equal(mapper.r1_map().mean(), 1 / self.t1, 4)
npt.assert_almost_equal(mapper.r2.mean(), 1)

def test_acq_order(self):
# Descending order
signal_array = np.tile(self.correct_signal_two_param_tss, (10, 10, 1, 1))
signal_array = signal_array[:, :, ::-1, :]
mapper = T1(signal_array, self.t, self.affine, tss=100,
acq_order='descend', mag_corr=True)
npt.assert_almost_equal(mapper.t1_map.mean(), self.t1, 4)
npt.assert_almost_equal(mapper.m0_map.mean(), self.m0, 4)
npt.assert_almost_equal(mapper.r1_map().mean(), 1 / self.t1, 4)
npt.assert_almost_equal(mapper.r2.mean(), 1)

# Centric order
signal_array_asc = np.tile(self.correct_signal_two_param_tss,
(10, 10, 1, 1))
signal_array = np.zeros(signal_array_asc.shape)
signal_array[:, :, 0, :] = signal_array_asc[:, :, 2, :]
signal_array[:, :, 1, :] = signal_array_asc[:, :, 0, :]
signal_array[:, :, 2, :] = signal_array_asc[:, :, 1, :]

mapper = T1(signal_array, self.t, self.affine, tss=100, tss_axis=2,
acq_order='centric', mag_corr=True)
npt.assert_almost_equal(mapper.t1_map.mean(), self.t1, 4)
npt.assert_almost_equal(mapper.m0_map.mean(), self.m0, 4)
npt.assert_almost_equal(mapper.r1_map().mean(), 1 / self.t1, 4)
npt.assert_almost_equal(mapper.r2.mean(), 1)

# Custom order
signal_array_asc = np.tile(self.correct_signal_two_param_tss,
(10, 10, 1, 1))
signal_array = np.zeros(signal_array_asc.shape)
signal_array[:, :, 1, :] = signal_array_asc[:, :, 0, :]
signal_array[:, :, 0, :] = signal_array_asc[:, :, 1, :]
signal_array[:, :, 2, :] = signal_array_asc[:, :, 2, :]
acq_order = [1, 0, 2]
mapper = T1(signal_array, self.t, self.affine, tss=100, tss_axis=2,
acq_order=acq_order, mag_corr=True)
npt.assert_almost_equal(mapper.t1_map.mean(), self.t1, 4)
npt.assert_almost_equal(mapper.m0_map.mean(), self.m0, 4)
npt.assert_almost_equal(mapper.r1_map().mean(), 1 / self.t1, 4)
npt.assert_almost_equal(mapper.r2.mean(), 1)

def test_failed_fit(self):
Expand Down Expand Up @@ -268,6 +309,28 @@ def test_tss_valid_axis(self):
inversion_list=np.linspace(0, 2000, 10),
affine=self.affine, tss=1, tss_axis=2)

def test_acq_order_options(self):
# Invalid string
with pytest.raises(ValueError):
mapper = T1(pixel_array=np.zeros((5, 5, 5, 10)),
inversion_list=np.linspace(0, 2000, 10),
affine=self.affine, tss=1, tss_axis=2,
acq_order='invalid')

# List length doesn't match number of slices
with pytest.raises(AssertionError):
mapper = T1(pixel_array=np.zeros((5, 5, 5, 4)),
inversion_list=np.linspace(0, 2000, 10),
affine=self.affine, tss=1, tss_axis=2,
acq_order=[0, 1, 2])

# List type not int
with pytest.raises(AssertionError):
mapper = T1(pixel_array=np.zeros((5, 5, 5, 4)),
inversion_list=np.linspace(0, 2000, 10),
affine=self.affine, tss=1, tss_axis=2,
acq_order=[0.0, 1.0, 2.0, 3.0])

def test_mag_corr_options(self):
# Test that the mag_corr option can be set to True, False, auto is
# checked more thoroughly in the next test
Expand Down