Skip to content

Commit 3fd75a7

Browse files
committed
Add an example that uses linear fitting
1 parent d0a996f commit 3fd75a7

File tree

5 files changed

+274
-1
lines changed

5 files changed

+274
-1
lines changed

.github/workflows/unit_test.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,4 +32,4 @@ jobs:
3232
- name: Test with pytest
3333
run: |
3434
pip install pytest pytest-cov
35-
pytest tests.py --doctest-modules --junitxml=junit/test-results.xml --cov=com --cov-report=xml --cov-report=html
35+
python -m pytest --doctest-modules --junitxml=junit/test-results.xml --cov=com --cov-report=xml --cov-report=html

src/original/ETP_SRI/LinearFitting.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import numpy as np
2+
import numpy.polynomial.polynomial as poly
3+
4+
from utils.data_simulation.GenerateData import GenerateData
5+
6+
7+
8+
class LinearFit:
9+
"""
10+
Performs linear fits of exponential data
11+
"""
12+
def __init__(self, linear_cutoff=500):
13+
"""
14+
Parameters
15+
----------
16+
linear_cutoff : float
17+
The b-value after which it can be assumed that the perfusion value is negligible
18+
"""
19+
self.linear_cutoff = linear_cutoff
20+
21+
def linear_fit(self, bvalues, signal, weighting=None, stats=False):
22+
"""
23+
Fit a single line
24+
25+
Parameters
26+
----------
27+
bvalues : list or array of float
28+
The diffusion (b-values)
29+
signal : list or array of float
30+
The acquired signal to fit. It is assumed to be linearized at this point.
31+
weighting : list or array fo float
32+
Weights to pass into polyfit. None is no weighting.
33+
stats : boolean
34+
If true, return the polyfit statistics
35+
"""
36+
assert bvalues.size == signal.size, "Signal and b-values don't have the same number of values"
37+
if stats:
38+
D, stats = poly.polyfit(np.asarray(bvalues), signal, 1, full=True, w=weighting)
39+
return [np.exp(D[0]), *-D[1:]], stats
40+
else:
41+
D = poly.polyfit(np.asarray(bvalues), signal, 1, w=weighting)
42+
return [np.exp(D[0]), *-D[1:]]
43+
44+
def ivim_fit(self, bvalues, signal):
45+
"""
46+
Fit an IVIM curve
47+
This fits a bi-exponential curve using linear fitting only
48+
49+
50+
Parameters
51+
----------
52+
bvalues : list or array of float
53+
The diffusion (b-values)
54+
signal : list or array of float
55+
The acquired signal to fit. It is assumed to be exponential at this point
56+
"""
57+
bvalues = np.asarray(bvalues)
58+
assert bvalues.size > 1, 'Too few b-values'
59+
signal = np.asarray(signal)
60+
assert bvalues.size == signal.size, "Signal and b-values don't have the same number of values"
61+
gd = GenerateData()
62+
lt_cutoff = bvalues <= self.linear_cutoff
63+
gt_cutoff = bvalues >= self.linear_cutoff
64+
linear_signal = np.log(signal)
65+
D = self.linear_fit(bvalues[gt_cutoff], linear_signal[gt_cutoff])
66+
67+
if lt_cutoff.sum() > 0:
68+
signal_Dp = linear_signal[lt_cutoff] - gd.linear_signal(D[1], bvalues[lt_cutoff], np.log(D[0]))
69+
70+
Dp_prime = self.linear_fit(bvalues[lt_cutoff], np.log(signal_Dp))
71+
72+
if np.any(np.asarray(Dp_prime) < 0) or not np.all(np.isfinite(Dp_prime)):
73+
print('Perfusion fit failed')
74+
Dp_prime = [0, 0]
75+
f = signal[0] - D[0]
76+
else:
77+
print("This doesn't seem to be an IVIM set of b-values")
78+
f = 1
79+
Dp_prime = [0, 0]
80+
D = D[1]
81+
Dp = D + Dp_prime[1]
82+
if np.allclose(f, 0):
83+
Dp = 0
84+
elif np.allclose(f, 1):
85+
D = 0
86+
return [f, D, Dp]

src/original/ETP_SRI/__init__.py

Whitespace-only changes.
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import numpy as np
2+
import numpy.testing as npt
3+
import pytest
4+
import torch
5+
6+
from utils.data_simulation.GenerateData import GenerateData
7+
from src.original.ETP_SRI.LinearFitting import LinearFit
8+
9+
10+
#run using python -m pytest from the root folder
11+
12+
test_linear_data = [
13+
pytest.param(0, np.linspace(0, 1000, 11), id='0'),
14+
pytest.param(0.01, np.linspace(0, 1000, 11), id='0.1'),
15+
pytest.param(0.02, np.linspace(0, 1000, 11), id='0.2'),
16+
pytest.param(0.03, np.linspace(0, 1000, 11), id='0.3'),
17+
pytest.param(0.04, np.linspace(0, 1000, 11), id='0.4'),
18+
pytest.param(0.05, np.linspace(0, 1000, 11), id='0.5'),
19+
pytest.param(0.08, np.linspace(0, 1000, 11), id='0.8'),
20+
pytest.param(0.1, np.linspace(0, 1000, 11), id='1'),
21+
]
22+
@pytest.mark.parametrize("D, bvals", test_linear_data)
23+
def test_linear_fit(D, bvals):
24+
gd = GenerateData()
25+
gd_signal = gd.exponential_signal(D, bvals)
26+
print(gd_signal)
27+
fit = LinearFit()
28+
D_fit = fit.linear_fit(bvals, np.log(gd_signal))
29+
npt.assert_allclose([1, D], D_fit)
30+
31+
test_ivim_data = [
32+
pytest.param(0, 0.01, 0.05, np.linspace(0, 1000, 11), id='0'),
33+
pytest.param(0.1, 0.01, 0.05, np.linspace(0, 1000, 11), id='0.1'),
34+
pytest.param(0.2, 0.01, 0.05, np.linspace(0, 1000, 11), id='0.2'),
35+
pytest.param(0.1, 0.05, 0.1, np.linspace(0, 1000, 11), id='0.3'),
36+
pytest.param(0.4, 0.001, 0.05, np.linspace(0, 1000, 11), id='0.4'),
37+
pytest.param(0.5, 0.001, 0.05, np.linspace(0, 1000, 11), id='0.5'),
38+
]
39+
@pytest.mark.parametrize("f, D, Dp, bvals", test_ivim_data)
40+
def test_ivim_fit(f, D, Dp, bvals):
41+
gd = GenerateData()
42+
gd_signal = gd.ivim_signal(D, Dp, f, 1, bvals)
43+
fit = LinearFit()
44+
[f_fit, D_fit, Dp_fit] = fit.ivim_fit(bvals, gd_signal)
45+
npt.assert_allclose([f, D], [f_fit, D_fit], atol=1e-5)
46+
if not np.allclose(f, 0):
47+
npt.assert_allclose(Dp, Dp_fit, rtol=1e-2, atol=1e-3)

utils/data_simulation/GenerateData.py

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import numpy as np
2+
3+
class GenerateData:
4+
"""
5+
Generate exponential and linear data
6+
"""
7+
def __init__(self, operator=None):
8+
"""
9+
Parameters
10+
----------
11+
operator : numpy or torch
12+
Must provide mathematical operators
13+
"""
14+
if operator is None:
15+
self._op = np
16+
else:
17+
self._op = operator
18+
19+
def ivim_signal(self, D, Dp, f, S0, bvalues, snr=None):
20+
"""
21+
Generates IVIM (biexponential) signal
22+
23+
Parameters
24+
----------
25+
D : float
26+
The tissue diffusion value
27+
Dp : float
28+
The pseudo perfusion value
29+
f : float
30+
The fraction of the signal from perfusion
31+
S0 : float
32+
The baseline signal (magnitude at no diffusion)
33+
bvalues : list or array of float
34+
The diffusion (b-values)
35+
"""
36+
signal = self.multiexponential_signal([D, Dp], [1 - f, f], S0, self._op.asarray(bvalues, dtype='float64'))
37+
return self.add_rician_noise(signal, snr)
38+
39+
def exponential_signal(self, D, bvalues):
40+
"""
41+
Generates exponential signal
42+
43+
Parameters
44+
----------
45+
D : float
46+
The tissue diffusion value
47+
bvalues : list or array of float
48+
The diffusion (b-values)
49+
"""
50+
assert D >= 0, 'D must be >= 0'
51+
return self._op.exp(-self._op.asarray(bvalues, dtype='float64') * D)
52+
53+
def multiexponential_signal(self, D, F, S0, bvalues):
54+
"""
55+
Generates multiexponential signal
56+
The combination of exponential signals
57+
58+
Parameters
59+
----------
60+
D : list or arrray of float
61+
The tissue diffusion value
62+
F : list or array of float
63+
The fraction of the signal from perfusion
64+
S0 : list or array of float
65+
The baseline signal (magnitude at no diffusion)
66+
bvalues : list or array of float
67+
The diffusion (b-values)
68+
"""
69+
assert len(D) == len(F), 'D and F must be the same length'
70+
signal = self._op.zeros_like(bvalues)
71+
for [d, f] in zip(D, F):
72+
signal += f * self.exponential_signal(d, bvalues)
73+
signal *= S0
74+
return signal
75+
76+
def add_rician_noise(self, real_signal, snr=None, imag_signal=None):
77+
"""
78+
Adds Rician noise to a real or complex signal
79+
80+
Parameters
81+
----------
82+
real_signal : list or array of float
83+
The real channel float
84+
snr : float
85+
The signal to noise ratio
86+
imag_signal : list or array of float
87+
The imaginary channel float
88+
"""
89+
if imag_signal is None:
90+
imag_signal = self._op.zeros_like(real_signal)
91+
if snr is None:
92+
noisy_data = self._op.sqrt(self._op.power(real_signal, 2) + self._op.power(imag_signal, 2))
93+
else:
94+
real_noise = self._op.random.normal(0, 1 / snr, real_signal.shape)
95+
imag_noise = self._op.random.normal(0, 1 / snr, imag_signal.shape)
96+
noisy_data = self._op.sqrt(self._op.power(real_signal + real_noise, 2) + self._op.power(imag_signal + imag_noise, 2))
97+
return noisy_data
98+
99+
def linear_signal(self, D, bvalues, offset=0):
100+
"""
101+
Generates linear signal
102+
103+
Parameters
104+
----------
105+
D : float
106+
The tissue diffusion value
107+
bvalues : list or array of float
108+
The diffusion (b-values)
109+
offset : float
110+
The signal offset
111+
"""
112+
assert D >= 0, 'D must be >= 0'
113+
data = -D * np.asarray(bvalues)
114+
return data + offset
115+
116+
def multilinear_signal(self, D, F, S0, bvalues, offset=0):
117+
"""
118+
Generates multilinear signal
119+
The combination of multiple linear signals
120+
121+
Parameters
122+
----------
123+
D : list or arrray of float
124+
The tissue diffusion value
125+
F : list or array of float
126+
The fraction of the signal from perfusion
127+
S0 : list or array of float
128+
The baseline signal (magnitude at no diffusion)
129+
bvalues : list or array of float
130+
The diffusion (b-values)
131+
offset : float
132+
The signal offset
133+
"""
134+
assert len(D) == len(F), 'D and F must be the same length'
135+
signal = self._op.zeros_like(bvalues)
136+
for [d, f] in zip(D, F):
137+
signal += f * self.linear_signal(d, bvalues)
138+
signal *= S0
139+
signal += offset
140+
return signal

0 commit comments

Comments
 (0)