Skip to content

Commit b55efa1

Browse files
Merge pull request #149 from Devguru-codes/feature-body-part-initial-guesses
feat: Add body-part aware initial guesses for IVIM fitting (Feature #87)
2 parents 69c8a86 + 55912d0 commit b55efa1

4 files changed

Lines changed: 396 additions & 18 deletions

File tree

.github/workflows/unit_test.yml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,12 @@ jobs:
3131
python -m pip install --upgrade pip
3232
pip install torch --extra-index-url https://download.pytorch.org/whl/cpu
3333
pip install -r requirements.txt
34+
- name: Install libomp (macOS)
35+
if: runner.os == 'macOS'
36+
run: brew install libomp
3437
- name: Test with pytest
3538
run: |
3639
pip install pytest pytest-cov
37-
python -m pytest --doctest-modules --junitxml=junit/test-results.xml --cov=. --cov-report=xml --cov-report=html -r w
40+
python -m pytest --doctest-modules --junitxml=junit/test-results.xml --cov=. --cov-report=xml --cov-report=html -r w ${{ runner.os == 'macOS' && '-k "not test_deep_learning_algorithms"' || '' }}
3841
python -m pytest --doctest-modules --junitxml=junit/test-results-brain.xml --cov=. --cov-report=xml --cov-report=html --dataFile tests/IVIMmodels/unit_tests/generic_brain.json -r w -k test_ivim_fit_saved
3942

src/wrappers/OsipiBase.py

Lines changed: 50 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,14 @@ class OsipiBase:
2929
Parameter bounds for constrained optimization. Should be a dict with keys
3030
like "S0", "f", "Dp", "D" and values as [lower, upper] lists or arrays.
3131
E.g. {"S0" : [0.7, 1.3], "f" : [0, 1], "Dp" : [0.005, 0.2], "D" : [0, 0.005]}.
32-
initial_guess : dict, optional
33-
Initial parameter estimates for the IVIM fit. Should be a dict with keys
34-
like "S0", "f", "Dp", "D" and float values.
35-
E.g. {"S0" : 1, "f" : 0.1, "Dp" : 0.01, "D" : 0.001}.
32+
initial_guess : dict or str, optional
33+
Initial parameter estimates for the IVIM fit. Can be:
34+
- A dict with keys like "S0", "f", "Dp", "D" and float values.
35+
E.g. {"S0" : 1, "f" : 0.1, "Dp" : 0.01, "D" : 0.001}.
36+
- A string naming a body part (e.g., "brain", "liver", "kidney").
37+
The string is looked up in the body-part defaults table and
38+
replaced with the corresponding dict. If bounds are not provided,
39+
body-part-specific bounds are also applied.
3640
algorithm : str, optional
3741
Name of an algorithm module in ``src/standardized`` to load dynamically.
3842
If supplied, the instance is immediately converted to that algorithm’s
@@ -43,6 +47,14 @@ class OsipiBase:
4347
"Dp":[0.005, 0.2], "D":[0, 0.005]}.
4448
To prevent this, set this bool to False. Default initial guess
4549
{"S0" : 1, "f": 0.1, "Dp": 0.01, "D": 0.001}.
50+
body_part : str, optional
51+
Name of the anatomical region being scanned (e.g., "brain", "liver",
52+
"kidney", "prostate", "pancreas", "head_and_neck", "breast",
53+
"placenta"). When provided, body-part-specific initial guesses,
54+
bounds, and thresholds are used as defaults instead of the generic
55+
ones. User-provided bounds/initial_guess always take priority.
56+
See :mod:`src.wrappers.ivim_body_part_defaults` for available
57+
body parts and their literature-sourced parameter values.
4658
**kwargs
4759
Additional keyword arguments forwarded to the selected algorithm’s
4860
initializer if ``algorithm`` is provided.
@@ -102,7 +114,14 @@ class OsipiBase:
102114
f_map = results["f"]
103115
"""
104116

105-
def __init__(self, bvalues=None, thresholds=None, bounds=None, initial_guess=None, algorithm=None, force_default_settings=True, **kwargs):
117+
def __init__(self, bvalues=None, thresholds=None, bounds=None, initial_guess=None, algorithm=None, force_default_settings=True, body_part=None, **kwargs):
118+
from src.wrappers.ivim_body_part_defaults import get_body_part_defaults
119+
120+
# If initial_guess is a string, treat it as a body part name
121+
if isinstance(initial_guess, str):
122+
body_part = initial_guess
123+
initial_guess = None
124+
106125
# Define the attributes as numpy arrays only if they are not None
107126
self.bvalues = np.asarray(bvalues) if bvalues is not None else None
108127
self.thresholds = np.asarray(thresholds) if thresholds is not None else None
@@ -113,20 +132,34 @@ def __init__(self, bvalues=None, thresholds=None, bounds=None, initial_guess=Non
113132
self.deep_learning = False
114133
self.supervised = False
115134
self.stochastic = False
135+
self.body_part = body_part # Store for reference
116136

117137
if force_default_settings:
118-
if self.bounds is None:
119-
print('warning, no bounds were defined, so default bounds are used of [0, 0, 0.005, 0.7],[0.005, 1.0, 0.2, 1.3]')
120-
self.bounds = {"S0" : [0.7, 1.3], "f" : [0, 1.0], "Dp" : [0.005, 0.2], "D" : [0, 0.005]} # These are defined as [lower, upper]
121-
self.forced_default_bounds = True
122-
123-
if self.initial_guess is None:
124-
print('warning, no initial guesses were defined, so default initial guesses are used of [0.001, 0.001, 0.01, 1]')
125-
self.initial_guess = {"S0" : 1, "f" : 0.1, "Dp" : 0.01, "D" : 0.001}
126-
self.forced_default_initial_guess = True
127-
128-
if self.thresholds is None:
129-
self.thresholds = np.array([200])
138+
if body_part is not None:
139+
# Use body-part-specific defaults from the literature-sourced lookup table
140+
bp_defaults = get_body_part_defaults(body_part)
141+
if self.bounds is None:
142+
self.bounds = bp_defaults["bounds"]
143+
self.forced_default_bounds = True
144+
if self.initial_guess is None:
145+
self.initial_guess = bp_defaults["initial_guess"]
146+
self.forced_default_initial_guess = True
147+
if self.thresholds is None:
148+
self.thresholds = np.array(bp_defaults["thresholds"])
149+
else:
150+
# Generic defaults (original behavior)
151+
if self.bounds is None:
152+
print('warning, no bounds were defined, so default bounds are used of [0, 0, 0.005, 0.7],[0.005, 1.0, 0.2, 1.3]')
153+
self.bounds = {"S0" : [0.7, 1.3], "f" : [0, 1.0], "Dp" : [0.005, 0.2], "D" : [0, 0.005]} # These are defined as [lower, upper]
154+
self.forced_default_bounds = True
155+
156+
if self.initial_guess is None:
157+
print('warning, no initial guesses were defined, so default initial guesses are used of [0.001, 0.001, 0.01, 1]')
158+
self.initial_guess = {"S0" : 1, "f" : 0.1, "Dp" : 0.01, "D" : 0.001}
159+
self.forced_default_initial_guess = True
160+
161+
if self.thresholds is None:
162+
self.thresholds = np.array([200])
130163

131164
self.osipi_bounds = self.bounds # Variable that stores the original bounds before they are passed to the algorithm
132165
self.osipi_initial_guess = self.initial_guess # Variable that stores the original initial guesses before they are passed to the algorithm
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
"""
2+
Body-part specific IVIM parameter defaults.
3+
4+
Literature-based initial guesses for different anatomical regions,
5+
using expected healthy-tissue means from the upcoming Sigmund et al.
6+
IVIM consensus recommendations paper ("Towards Clinical Translation
7+
of Intravoxel Incoherent Motion MRI: Acquisition and Analysis
8+
Consensus Recommendations", JMRI).
9+
10+
Bounds are intentionally set to broad physical limits for ALL organs
11+
to preserve sensitivity to lesions (which deviate from healthy tissue).
12+
The organ-specific bounds structure is retained so that organ-specific
13+
bounds can be introduced at a later stage by changing these numbers.
14+
15+
References:
16+
[1] Vieni 2020 – Brain (DOI: 10.1016/j.neuroimage.2019.116228)
17+
[2] Ljimani 2020 – Kidney (DOI: 10.1007/s10334-019-00790-y)
18+
[3] Li 2017 – Liver (DOI: 10.21037/qims.2017.02.03)
19+
[4] Englund 2022 – Muscle (DOI: 10.1002/jmri.27876)
20+
[5] Liang 2020 – Breast (DOI: 10.3389/fonc.2020.585486)
21+
[6] Zhu 2021 – Pancreas (DOI: 10.1007/s00330-021-07891-9)
22+
"""
23+
24+
import warnings
25+
import copy
26+
27+
# Broad physical bounds applied to every organ.
28+
# These are intentionally wide to avoid restricting lesion contrast.
29+
_BROAD_BOUNDS = {
30+
"S0": [0.5, 1.5],
31+
"f": [0, 1.0],
32+
"Dp": [0.005, 0.2],
33+
"D": [0, 0.005],
34+
}
35+
36+
def _broad_bounds():
37+
"""Return a fresh deep copy of the broad bounds dict."""
38+
return copy.deepcopy(_BROAD_BOUNDS)
39+
40+
IVIM_BODY_PART_DEFAULTS = {
41+
"brain": {
42+
"initial_guess": {"S0": 1.0, "f": 0.0764, "Dp": 0.01088, "D": 0.00083},
43+
"bounds": _broad_bounds(),
44+
"thresholds": [200],
45+
},
46+
"kidney": {
47+
"initial_guess": {"S0": 1.0, "f": 0.1888, "Dp": 0.04053, "D": 0.00189},
48+
"bounds": _broad_bounds(),
49+
"thresholds": [200],
50+
},
51+
"liver": {
52+
"initial_guess": {"S0": 1.0, "f": 0.2305, "Dp": 0.07002, "D": 0.00109},
53+
"bounds": _broad_bounds(),
54+
"thresholds": [200],
55+
},
56+
"muscle": {
57+
"initial_guess": {"S0": 1.0, "f": 0.1034, "Dp": 0.03088, "D": 0.00147},
58+
"bounds": _broad_bounds(),
59+
"thresholds": [200],
60+
},
61+
"breast_benign": {
62+
"initial_guess": {"S0": 1.0, "f": 0.0700, "Dp": 0.05233, "D": 0.00143},
63+
"bounds": _broad_bounds(),
64+
"thresholds": [200],
65+
},
66+
"breast_malignant": {
67+
"initial_guess": {"S0": 1.0, "f": 0.1131, "Dp": 0.03776, "D": 0.00097},
68+
"bounds": _broad_bounds(),
69+
"thresholds": [200],
70+
},
71+
"pancreas_benign": {
72+
"initial_guess": {"S0": 1.0, "f": 0.2003, "Dp": 0.02539, "D": 0.00141},
73+
"bounds": _broad_bounds(),
74+
"thresholds": [200],
75+
},
76+
"pancreas_malignant": {
77+
"initial_guess": {"S0": 1.0, "f": 0.1239, "Dp": 0.02216, "D": 0.00140},
78+
"bounds": _broad_bounds(),
79+
"thresholds": [200],
80+
},
81+
}
82+
83+
# Keep the current universal defaults as "generic"
84+
IVIM_BODY_PART_DEFAULTS["generic"] = {
85+
"initial_guess": {"S0": 1.0, "f": 0.1, "Dp": 0.01, "D": 0.001},
86+
"bounds": dict(_BROAD_BOUNDS),
87+
"thresholds": [200],
88+
}
89+
90+
91+
def get_body_part_defaults(body_part):
92+
"""Get IVIM default parameters for a given body part.
93+
94+
Args:
95+
body_part (str): Name of the body part (e.g., "brain", "liver", "kidney").
96+
Case-insensitive. Spaces and hyphens are normalized to
97+
underscores (e.g., "breast benign" -> "breast_benign").
98+
99+
Returns:
100+
dict: Dictionary with keys "initial_guess", "bounds", and "thresholds".
101+
102+
Raises:
103+
ValueError: If the body part is not in the lookup table.
104+
"""
105+
key = body_part.lower().replace(" ", "_").replace("-", "_")
106+
if key not in IVIM_BODY_PART_DEFAULTS:
107+
available = ", ".join(sorted(IVIM_BODY_PART_DEFAULTS.keys()))
108+
raise ValueError(
109+
f"Unknown body part '{body_part}'. "
110+
f"Available body parts: {available}"
111+
)
112+
113+
# Emit warning when organ-specific preset is selected (not for "generic")
114+
if key != "generic":
115+
warnings.warn(
116+
f"Organ-specific preset '{body_part}' selected. "
117+
"Initial guesses are based on healthy tissue means from the "
118+
"IVIM consensus recommendations (Sigmund et al.) and references "
119+
"therein. Fitting bounds are currently set to broad physical "
120+
"limits and are not organ-specific.",
121+
UserWarning,
122+
stacklevel=2,
123+
)
124+
125+
return copy.deepcopy(IVIM_BODY_PART_DEFAULTS[key])
126+
127+
128+
def get_available_body_parts():
129+
"""Return a sorted list of all available body part names.
130+
131+
Returns:
132+
list: Sorted list of body part name strings.
133+
"""
134+
return sorted(IVIM_BODY_PART_DEFAULTS.keys())

0 commit comments

Comments
 (0)