-
Notifications
You must be signed in to change notification settings - Fork 46
feat: Add body-part aware initial guesses for IVIM fitting (Feature #87) #149
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
|
@@ -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. | ||
|
|
@@ -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 | ||
|
|
||
| # 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 | ||
|
|
@@ -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: | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| # 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 | ||
|
|
||
| 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], | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. all other organs have 200 adviced
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this could be achieved easily by using the function below
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 available = sorted(IVIM_BODY_PART_DEFAULTS.keys())
raise ValueError(
f"Unknown body part '{body_part}'. "
f"Available body parts: {available}"
)So the user sees: |
||
| ) | ||
|
|
||
| # 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()) | ||
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.