Skip to content
Open
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
67 changes: 50 additions & 17 deletions src/wrappers/OsipiBase.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,14 @@ class OsipiBase:
Parameter bounds for constrained optimization. Should be a dict with keys
like "S0", "f", "Dp", "D" and values as [lower, upper] lists or arrays.
E.g. {"S0" : [0.7, 1.3], "f" : [0, 1], "Dp" : [0.005, 0.2], "D" : [0, 0.005]}.
initial_guess : dict, optional
Initial parameter estimates for the IVIM fit. Should be a dict with keys
like "S0", "f", "Dp", "D" and float values.
E.g. {"S0" : 1, "f" : 0.1, "Dp" : 0.01, "D" : 0.001}.
initial_guess : dict or str, optional
Initial parameter estimates for the IVIM fit. Can be:
- A dict with keys like "S0", "f", "Dp", "D" and float values.
E.g. {"S0" : 1, "f" : 0.1, "Dp" : 0.01, "D" : 0.001}.
- A string naming a body part (e.g., "brain", "liver", "kidney").
The string is looked up in the body-part defaults table and
replaced with the corresponding dict. If bounds are not provided,
body-part-specific bounds are also applied.
algorithm : str, optional
Name of an algorithm module in ``src/standardized`` to load dynamically.
If supplied, the instance is immediately converted to that algorithm’s
Expand All @@ -43,6 +47,14 @@ class OsipiBase:
"Dp":[0.005, 0.2], "D":[0, 0.005]}.
To prevent this, set this bool to False. Default initial guess
{"S0" : 1, "f": 0.1, "Dp": 0.01, "D": 0.001}.
body_part : str, optional
Name of the anatomical region being scanned (e.g., "brain", "liver",
"kidney", "prostate", "pancreas", "head_and_neck", "breast",
"placenta"). When provided, body-part-specific initial guesses,
bounds, and thresholds are used as defaults instead of the generic
ones. User-provided bounds/initial_guess always take priority.
See :mod:`src.wrappers.ivim_body_part_defaults` for available
body parts and their literature-sourced parameter values.
**kwargs
Additional keyword arguments forwarded to the selected algorithm’s
initializer if ``algorithm`` is provided.
Expand Down Expand Up @@ -102,7 +114,14 @@ class OsipiBase:
f_map = results["f"]
"""

def __init__(self, bvalues=None, thresholds=None, bounds=None, initial_guess=None, algorithm=None, force_default_settings=True, **kwargs):
def __init__(self, bvalues=None, thresholds=None, bounds=None, initial_guess=None, algorithm=None, force_default_settings=True, body_part=None, **kwargs):
from src.wrappers.ivim_body_part_defaults import get_body_part_defaults
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this import should be at the top of the file

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok I wlll do it.


# If initial_guess is a string, treat it as a body part name
if isinstance(initial_guess, str):
body_part = initial_guess
initial_guess = None

# Define the attributes as numpy arrays only if they are not None
self.bvalues = np.asarray(bvalues) if bvalues is not None else None
self.thresholds = np.asarray(thresholds) if thresholds is not None else None
Expand All @@ -113,20 +132,34 @@ def __init__(self, bvalues=None, thresholds=None, bounds=None, initial_guess=Non
self.deep_learning = False
self.supervised = False
self.stochastic = False
self.body_part = body_part # Store for reference

if force_default_settings:
if self.bounds is None:
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]')
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]
self.forced_default_bounds = True

if self.initial_guess is None:
print('warning, no initial guesses were defined, so default initial guesses are used of [0.001, 0.001, 0.01, 1]')
self.initial_guess = {"S0" : 1, "f" : 0.1, "Dp" : 0.01, "D" : 0.001}
self.forced_default_initial_guess = True

if self.thresholds is None:
self.thresholds = np.array([200])
if body_part is not None:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here, I'd like to see behaviour doing:

If body_part is not None: initialize with body_part-specific values, but then override any non-None values from user input. So if I use body_part = liver, threshold = 500, it takes the initial guess and bounds from the liver, but overrides the threshold by 500.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok. This adds flexibility and user control as well. I update logic so that when body_part is provided it will load body part default values first(initial_guess, bounds, thresholds) and then any non-None user-provided values will selectively override the body-part defaults. I will update this.

# Use body-part-specific defaults from the literature-sourced lookup table
bp_defaults = get_body_part_defaults(body_part)
if self.bounds is None:
self.bounds = bp_defaults["bounds"]
self.forced_default_bounds = True
if self.initial_guess is None:
self.initial_guess = bp_defaults["initial_guess"]
self.forced_default_initial_guess = True
if self.thresholds is None:
self.thresholds = np.array(bp_defaults["thresholds"])
else:
# Generic defaults (original behavior)
if self.bounds is None:
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]')
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]
self.forced_default_bounds = True

if self.initial_guess is None:
print('warning, no initial guesses were defined, so default initial guesses are used of [0.001, 0.001, 0.01, 1]')
self.initial_guess = {"S0" : 1, "f" : 0.1, "Dp" : 0.01, "D" : 0.001}
self.forced_default_initial_guess = True

if self.thresholds is None:
self.thresholds = np.array([200])

self.osipi_bounds = self.bounds # Variable that stores the original bounds before they are passed to the algorithm
self.osipi_initial_guess = self.initial_guess # Variable that stores the original initial guesses before they are passed to the algorithm
Expand Down
134 changes: 134 additions & 0 deletions src/wrappers/ivim_body_part_defaults.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
"""
Body-part specific IVIM parameter defaults.

Literature-based initial guesses for different anatomical regions,
using expected healthy-tissue means from the upcoming Sigmund et al.
IVIM consensus recommendations paper ("Towards Clinical Translation
of Intravoxel Incoherent Motion MRI: Acquisition and Analysis
Consensus Recommendations", JMRI).

Bounds are intentionally set to broad physical limits for ALL organs
to preserve sensitivity to lesions (which deviate from healthy tissue).
The organ-specific bounds structure is retained so that organ-specific
bounds can be introduced at a later stage by changing these numbers.

References:
[1] Vieni 2020 – Brain (DOI: 10.1016/j.neuroimage.2019.116228)
[2] Ljimani 2020 – Kidney (DOI: 10.1007/s10334-019-00790-y)
[3] Li 2017 – Liver (DOI: 10.21037/qims.2017.02.03)
[4] Englund 2022 – Muscle (DOI: 10.1002/jmri.27876)
[5] Liang 2020 – Breast (DOI: 10.3389/fonc.2020.585486)
[6] Zhu 2021 – Pancreas (DOI: 10.1007/s00330-021-07891-9)
"""

import warnings
import copy

# Broad physical bounds applied to every organ.
# These are intentionally wide to avoid restricting lesion contrast.
_BROAD_BOUNDS = {
"S0": [0.5, 1.5],
"f": [0, 1.0],
"Dp": [0.005, 0.2],
"D": [0, 0.005],
}

def _broad_bounds():
"""Return a fresh deep copy of the broad bounds dict."""
return copy.deepcopy(_BROAD_BOUNDS)

IVIM_BODY_PART_DEFAULTS = {
"brain": {
"initial_guess": {"S0": 1.0, "f": 0.0764, "Dp": 0.01088, "D": 0.00083},
"bounds": _broad_bounds(),
"thresholds": [200],
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In lieu of the IVIM review paper, a cut-off of 300 is adviced for brain

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

all other organs have 200 adviced

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I made the threshold of 200 based on general practice like this paper Federau 2017, DOI: 10.1002/nbm.3780), which uses b-value cutoffs around 200 s/mm² in their segmented fittings.

},
"kidney": {
"initial_guess": {"S0": 1.0, "f": 0.1888, "Dp": 0.04053, "D": 0.00189},
"bounds": _broad_bounds(),
"thresholds": [200],
},
"liver": {
"initial_guess": {"S0": 1.0, "f": 0.2305, "Dp": 0.07002, "D": 0.00109},
"bounds": _broad_bounds(),
"thresholds": [200],
},
"muscle": {
"initial_guess": {"S0": 1.0, "f": 0.1034, "Dp": 0.03088, "D": 0.00147},
"bounds": _broad_bounds(),
"thresholds": [200],
},
"breast_benign": {
"initial_guess": {"S0": 1.0, "f": 0.0700, "Dp": 0.05233, "D": 0.00143},
"bounds": _broad_bounds(),
"thresholds": [200],
},
"breast_malignant": {
"initial_guess": {"S0": 1.0, "f": 0.1131, "Dp": 0.03776, "D": 0.00097},
"bounds": _broad_bounds(),
"thresholds": [200],
},
"pancreas_benign": {
"initial_guess": {"S0": 1.0, "f": 0.2003, "Dp": 0.02539, "D": 0.00141},
"bounds": _broad_bounds(),
"thresholds": [200],
},
"pancreas_malignant": {
"initial_guess": {"S0": 1.0, "f": 0.1239, "Dp": 0.02216, "D": 0.00140},
"bounds": _broad_bounds(),
"thresholds": [200],
},
}

# Keep the current universal defaults as "generic"
IVIM_BODY_PART_DEFAULTS["generic"] = {
"initial_guess": {"S0": 1.0, "f": 0.1, "Dp": 0.01, "D": 0.001},
"bounds": dict(_BROAD_BOUNDS),
"thresholds": [200],
}


def get_body_part_defaults(body_part):
"""Get IVIM default parameters for a given body part.

Args:
body_part (str): Name of the body part (e.g., "brain", "liver", "kidney").
Case-insensitive. Spaces and hyphens are normalized to
underscores (e.g., "breast benign" -> "breast_benign").

Returns:
dict: Dictionary with keys "initial_guess", "bounds", and "thresholds".

Raises:
ValueError: If the body part is not in the lookup table.
"""
key = body_part.lower().replace(" ", "_").replace("-", "_")
if key not in IVIM_BODY_PART_DEFAULTS:
available = ", ".join(sorted(IVIM_BODY_PART_DEFAULTS.keys()))
raise ValueError(
f"Unknown body part '{body_part}'. "
f"Available body parts: {available}"
Comment on lines +108 to +110
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be nice to also provide the user with a list of the available/implemented options here

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this could be achieved easily by using the function below

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have implemented this in lines 134 to 136 of ivim_body_part_defaults.py file. it has this function get_available_body_parts(). This function lists all available options in the error message. It is like this -

available = sorted(IVIM_BODY_PART_DEFAULTS.keys())
raise ValueError(
    f"Unknown body part '{body_part}'. "
    f"Available body parts: {available}"
)

So the user sees:

>`ValueError: Unknown body part 'elbow'. Available body parts: ['brain', 'breast', 'head_and_neck', 'kidney', 'liver', 'pancreas', 'placenta', 'prostate']`

)

# Emit warning when organ-specific preset is selected (not for "generic")
if key != "generic":
warnings.warn(
f"Organ-specific preset '{body_part}' selected. "
"Initial guesses are based on healthy tissue means from the "
"IVIM consensus recommendations (Sigmund et al.) and references "
"therein. Fitting bounds are currently set to broad physical "
"limits and are not organ-specific.",
UserWarning,
stacklevel=2,
)

return copy.deepcopy(IVIM_BODY_PART_DEFAULTS[key])


def get_available_body_parts():
"""Return a sorted list of all available body part names.

Returns:
list: Sorted list of body part name strings.
"""
return sorted(IVIM_BODY_PART_DEFAULTS.keys())
Loading
Loading