Skip to content

Commit 855d04f

Browse files
authored
Merge pull request #134 from ElinaPetersson/main
Preprocessig script for outlier removal
2 parents 3cd6b50 + 8c2d866 commit 855d04f

2 files changed

Lines changed: 169 additions & 0 deletions

File tree

src/original/EP_GU/info.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"developers": "Oscar Jalnefjord",
3+
"institutions": ["University of Gothenburg, Gothenburg, Sweden", "Sahlgrenska University Hospital, Gothenburg, Sweden"]
4+
}
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
""" Methods for outlier removal.
2+
Reproduced with permission from Oscar Jalngefjord: https://github.com/oscarjalnefjord/ivim/tree/outlier by Elina Petersson
3+
"""
4+
5+
import numpy as np
6+
import nibabel as nib
7+
import numpy.typing as npt
8+
import matplotlib.pyplot as plt
9+
from scipy.optimize import minimize
10+
from scipy.stats import norm
11+
12+
NO_REGIME = 'no'
13+
DIFFUSIVE_REGIME = 'diffusive'
14+
BALLISTIC_REGIME = 'ballistic'
15+
Db = 1.75e-3 # mm2/s, see Ahlgren et al 2016 NMR in Biomedicine
16+
17+
def roi_based(im_file: str, bval_file: str, roi_file: str, outbase: str, regime:str , fig: bool = False, cval_file: str | None = None):
18+
"""
19+
Identify outliers by fit to ROI average.
20+
21+
Arguments:
22+
im_file: path to nifti image file
23+
bval_file: path to .bval file
24+
roi_file: path to nifti file defining a region-of-interest (ROI) in which the correction is calculated and applied
25+
outbase: basis for output filenames, i.e. filename without file extension to which .nii.gz, .bval, etc. is added
26+
regime: IVIM regime to model: no (= sIVIM), diffusive (long encoding time) or ballistic (short encoding time)
27+
fig: (optional) if True, a diagnostic figure is output
28+
cval_file: (optional) path to .cval file
29+
"""
30+
31+
check_regime(regime)
32+
Y = nib.load(im_file).get_fdata()
33+
if roi_file is not None:
34+
roi = nib.load(roi_file).get_fdata().astype(bool)
35+
else:
36+
roi = np.full(Y.shape[:-1], True)
37+
Y = Y[roi,:]
38+
b = np.atleast_1d(np.loadtxt(bval_file))
39+
if regime == BALLISTIC_REGIME:
40+
c = np.atleast_1d(np.loadtxt(cval_file))
41+
42+
y_avg = np.median(Y, axis=0)
43+
44+
if regime == NO_REGIME:
45+
def model(x, idx):
46+
return sIVIM(b[idx], x[0], x[1], x[2])
47+
x0 = [1e-3, 0.1, np.max(y_avg)]
48+
bounds = ((0, 3e-3), (0, 1), (0, 2*np.max(y_avg)))
49+
elif regime == BALLISTIC_REGIME:
50+
def model(x, idx):
51+
return ballistic(b[idx], c[idx], x[0], x[1], x[2], x[3])
52+
x0 = [1e-3, 0.1, 2, np.max(y_avg)]
53+
bounds = ((0, 3e-3), (0, 1), (0, 5), (0, 2*np.max(y_avg)))
54+
else: # diffusive
55+
def model(x, idx):
56+
return diffusive(b[idx], x[0], x[1], x[2], x[3])
57+
x0 = [1e-3, 0.1, 10e-3, np.max(y_avg)]
58+
bounds = ((0, 3e-3), (0, 1), (0, 1), (0, 2*np.max(y_avg)))
59+
60+
61+
has_outliers = True
62+
idx = np.full_like(b, True, dtype=bool)
63+
while has_outliers:
64+
def fun(x):
65+
return np.sum(np.abs(model(x, idx)-y_avg[idx]))
66+
67+
x = minimize(fun, x0, bounds=bounds).x
68+
res = np.squeeze(model(x, idx)) - y_avg[idx]
69+
iqr = (np.quantile(res, 0.75) - np.quantile(res, 0.25))
70+
tmp = np.full_like(b, False, dtype=bool)
71+
tmp[idx] = np.abs(res) < 3*iqr
72+
if np.all(tmp == idx):
73+
has_outliers = False
74+
else:
75+
idx = tmp
76+
77+
if fig:
78+
fig, ax = plt.subplots(1, 1)
79+
b_plot = np.linspace(np.min(b), np.max(b))
80+
if regime == NO_REGIME:
81+
y = np.squeeze(sIVIM(b_plot, x[0], x[1], x[2]))
82+
83+
elif regime == BALLISTIC_REGIME:
84+
c_plot = b_plot * (c/b)[c>0][np.argmax(b[c>0])]
85+
y = np.squeeze(ballistic(b_plot, c_plot, x[0], x[1], x[2], x[3]))
86+
if np.any((b>0)&(c==0)):
87+
ax.plot(b_plot, np.squeeze(ballistic(b_plot, np.zeros_like(b_plot), x[0], x[1], x[2], x[3])))
88+
else:
89+
y = np.squeeze(diffusive(b_plot, x[0], x[1], x[2], x[3]))
90+
ax.plot(b_plot, y)
91+
ax.plot(b, y_avg, 'ko')
92+
ax.plot(b[~idx], y_avg[~idx], 'rx')
93+
ax.set_xlabel(r'b [s/mm$^2$]')
94+
ax.set_ylabel('Signal [a.u]')
95+
fig.savefig(outbase+'.png')
96+
plt.close(fig)
97+
98+
sz = roi.shape
99+
100+
if Y[:,idx].ndim > 1:
101+
im_new = np.full(list(sz) + [Y[:,idx].shape[1]], np.nan)
102+
im_new[roi, :] = Y[:,idx]
103+
else:
104+
im_new = np.full(sz, np.nan)
105+
im_new[roi] = Y[:,idx]
106+
nib.save(nib.Nifti1Image(im_new, nib.load(im_file).affine, nib.load(im_file).header), outbase+'.nii.gz')
107+
np.savetxt(outbase+'.bval', b[idx], fmt='%.1f', newline=' ')
108+
if regime == BALLISTIC_REGIME:
109+
np.savetxt(outbase+'.cval', c[idx], fmt='%.1f', newline=' ')
110+
111+
112+
113+
def at_least_1d(pars: list) -> list:
114+
""" Check that each parameter is atleast one dimension in shape. """
115+
for i, par in enumerate(pars):
116+
pars[i] = np.atleast_1d(par)
117+
return pars
118+
119+
def check_regime(regime: str) -> None:
120+
""" Check that the regime is valid. """
121+
if regime not in [NO_REGIME, DIFFUSIVE_REGIME, BALLISTIC_REGIME]:
122+
raise ValueError(f'Invalid regime "{regime}". Valid regimes are "{NO_REGIME}", "{DIFFUSIVE_REGIME}" and "{BALLISTIC_REGIME}".')
123+
124+
def monoexp(b: npt.NDArray[np.float64], D: npt.NDArray[np.float64]) -> npt.NDArray[np.float64]:
125+
"""
126+
Return the monoexponential e^(-b*D).
127+
"""
128+
129+
[b, D] = at_least_1d([b, D])
130+
S = np.exp(-np.outer(D, b))
131+
return np.reshape(S, list(D.shape) + [b.size]) # reshape as np.outer flattens D is ndim > 1
132+
133+
def kurtosis(b: npt.NDArray[np.float64], D: npt.NDArray[np.float64], K: npt.NDArray[np.float64]) -> npt.NDArray[np.float64]:
134+
"""
135+
Return the kurtosis signal representation.
136+
"""
137+
138+
[b, D, K] = at_least_1d([b, D, K])
139+
Slin = monoexp(b, D)
140+
Squad = np.exp(np.reshape(np.outer(D, b)**2, list(D.shape) + [b.size]) * K[..., np.newaxis]/6)
141+
return Slin * Squad
142+
143+
def sIVIM(b: npt.NDArray[np.float64], D: npt.NDArray[np.float64], f: npt.NDArray[np.float64], S0: npt.NDArray[np.float64] = 1, K: npt.NDArray[np.float64] = 0) -> npt.NDArray[np.float64]:
144+
"""
145+
Return MR signal based on the simplified IVIM (sIVIM) model.
146+
"""
147+
148+
[b, D, f, S0] = at_least_1d([b, D, f, S0])
149+
return S0[..., np.newaxis] * ((1-f[..., np.newaxis]) * kurtosis(b, D, K) + np.reshape(np.outer(f, b==0), list(f.shape) + [b.size]))
150+
151+
def ballistic(b: npt.NDArray[np.float64], c: npt.NDArray[np.float64], D: npt.NDArray[np.float64], f: npt.NDArray[np.float64], vd: npt.NDArray[np.float64], S0: npt.NDArray[np.float64] = 1, K: npt.NDArray[np.float64] = 0) -> npt.NDArray[np.float64]:
152+
"""
153+
Return MR signal based on the ballistic IVIM model.
154+
"""
155+
156+
[b, c, D, f, vd, S0] = at_least_1d([b, c, D, f, vd, S0])
157+
return S0[..., np.newaxis] * ((1-f[..., np.newaxis])*kurtosis(b, D, K) + f[..., np.newaxis]*monoexp(b, Db)*monoexp(c**2, vd**2))
158+
159+
def diffusive(b: npt.NDArray[np.float64], D: npt.NDArray[np.float64], f: npt.NDArray[np.float64], Dstar: npt.NDArray[np.float64], S0: npt.NDArray[np.float64] = 1, K: npt.NDArray[np.float64] = 0) -> npt.NDArray[np.float64]:
160+
"""
161+
Return MR signal based on the diffusive IVIM model.
162+
"""
163+
164+
[b, D, f, Dstar, S0] = at_least_1d([b, D, f, Dstar, S0])
165+
return S0[..., np.newaxis] * ((1-f[..., np.newaxis])*kurtosis(b, D, K) + f[..., np.newaxis]*monoexp(b, Dstar))

0 commit comments

Comments
 (0)