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
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
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
147 changes: 147 additions & 0 deletions src/wrappers/ivim_body_part_defaults.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
"""
Body-part specific IVIM parameter defaults.

Literature-based initial guesses, bounds, and thresholds for
different anatomical regions. Used by OsipiBase when the user
specifies a body_part parameter.

References:
Brain: Federau 2017 (DOI: 10.1002/nbm.3780)
Liver: Dyvorne 2013 (DOI: 10.1016/j.ejrad.2013.03.003),
Guiu 2012 (DOI: 10.1002/jmri.23762)
Kidney: Li 2017 (DOI: 10.1002/jmri.25571),
Ljimani 2020 (DOI: 10.1007/s10334-019-00802-0)
Prostate: Kuru 2014 (DOI: 10.1007/s00330-014-3165-y)
Pancreas: Barbieri 2020 (DOI: 10.1002/mrm.27910)
Head/Neck: Sumi 2012 (DOI: 10.1259/dmfr/15696758)
Breast: Lee 2018 (DOI: 10.1097/RCT.0000000000000661)
Placenta: Zhu 2023 (DOI: 10.1002/jmri.28858)
"""

IVIM_BODY_PART_DEFAULTS = {
"brain": {
"initial_guess": {"S0": 1.0, "f": 0.05, "Dp": 0.01, "D": 0.0008},
"bounds": {
"S0": [0.7, 1.3],
"f": [0.0, 0.15],
"Dp": [0.005, 0.05],
"D": [0.0003, 0.002],
},
"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
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.

},
"liver": {
"initial_guess": {"S0": 1.0, "f": 0.12, "Dp": 0.06, "D": 0.001},
"bounds": {
"S0": [0.7, 1.3],
Copy link
Collaborator

Choose a reason for hiding this comment

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

Generally, the S0 bounds are defined by the SNR (i.e. more noisy data will mean S0 varies more). I would be in favour to broaden these bounds (i.e. 0.5-2).

Copy link
Collaborator

Choose a reason for hiding this comment

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

(for every organ)

Copy link
Author

Choose a reason for hiding this comment

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

Ok. I made the it tighter as a conservative starting point for all organs. I will update it and broaden it for better accomodation.

"f": [0.0, 0.40],
Copy link
Collaborator

Choose a reason for hiding this comment

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

For all organs --> I have asked the IVIM experts to review these bounds :). I personally think they are somewhat tight, but lets see what the verdict is. They should be simple to adapt once we have made up our minds on what they should be

Copy link
Author

Choose a reason for hiding this comment

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

I really appreciate it. Expert's opinion will help here. I will wait to touch this part and update it after expert opinion.

Copy link
Author

Choose a reason for hiding this comment

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

Also for expert's reference, all of the bounds that i chose is based of papers and their list is given in this PR.

"Dp": [0.01, 0.15],
"D": [0.0003, 0.003],
},
"thresholds": [200],
},
"kidney": {
"initial_guess": {"S0": 1.0, "f": 0.20, "Dp": 0.03, "D": 0.0019},
"bounds": {
"S0": [0.7, 1.3],
"f": [0.0, 0.50],
"Dp": [0.01, 0.08],
"D": [0.0005, 0.004],
},
"thresholds": [200],
},
"prostate": {
"initial_guess": {"S0": 1.0, "f": 0.08, "Dp": 0.025, "D": 0.0015},
"bounds": {
"S0": [0.7, 1.3],
"f": [0.0, 0.25],
"Dp": [0.005, 0.06],
"D": [0.0003, 0.003],
},
"thresholds": [200],
},
"pancreas": {
"initial_guess": {"S0": 1.0, "f": 0.18, "Dp": 0.02, "D": 0.0012},
"bounds": {
"S0": [0.7, 1.3],
"f": [0.0, 0.50],
"Dp": [0.005, 0.06],
"D": [0.0003, 0.003],
},
"thresholds": [200],
},
"head_and_neck": {
"initial_guess": {"S0": 1.0, "f": 0.15, "Dp": 0.025, "D": 0.001},
"bounds": {
"S0": [0.7, 1.3],
"f": [0.0, 0.40],
"Dp": [0.005, 0.08],
"D": [0.0003, 0.003],
},
"thresholds": [200],
},
"breast": {
"initial_guess": {"S0": 1.0, "f": 0.10, "Dp": 0.02, "D": 0.0014},
"bounds": {
"S0": [0.7, 1.3],
"f": [0.0, 0.30],
"Dp": [0.005, 0.06],
"D": [0.0004, 0.003],
},
"thresholds": [200],
},
"placenta": {
"initial_guess": {"S0": 1.0, "f": 0.28, "Dp": 0.04, "D": 0.0017},
"bounds": {
"S0": [0.7, 1.3],
"f": [0.05, 0.60],
"Dp": [0.01, 0.1],
"D": [0.0005, 0.004],
},
"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": {
"S0": [0.7, 1.3],
"f": [0, 1.0],
"Dp": [0.005, 0.2],
"D": [0, 0.005],
},
"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., "head and neck" -> "head_and_neck").

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 +134 to +136
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
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']`

)
return 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