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