From 7cd7cf13216d3f848efb4741bb10deb97e76e016 Mon Sep 17 00:00:00 2001 From: Katy Scott Date: Fri, 17 Jan 2025 15:53:08 -0500 Subject: [PATCH 01/47] refactor: make process code dir, move label, select, and split to it --- src/readii/{data => process}/__init__.py | 0 src/readii/{data => process}/label.py | 2 +- src/readii/{data => process}/select.py | 0 src/readii/{data => process}/split.py | 0 4 files changed, 1 insertion(+), 1 deletion(-) rename src/readii/{data => process}/__init__.py (100%) rename src/readii/{data => process}/label.py (99%) rename src/readii/{data => process}/select.py (100%) rename src/readii/{data => process}/split.py (100%) diff --git a/src/readii/data/__init__.py b/src/readii/process/__init__.py similarity index 100% rename from src/readii/data/__init__.py rename to src/readii/process/__init__.py diff --git a/src/readii/data/label.py b/src/readii/process/label.py similarity index 99% rename from src/readii/data/label.py rename to src/readii/process/label.py index 9512872..70423dd 100644 --- a/src/readii/data/label.py +++ b/src/readii/process/label.py @@ -4,7 +4,7 @@ from typing import Optional from readii.utils import logger -from readii.data.split import replaceColumnValues +from readii.process.split import replaceColumnValues def getPatientIdentifierLabel(dataframe_to_search:DataFrame) -> str: """Function to find a column in a dataframe that contains some form of patient ID or case ID (case-insensitive). diff --git a/src/readii/data/select.py b/src/readii/process/select.py similarity index 100% rename from src/readii/data/select.py rename to src/readii/process/select.py diff --git a/src/readii/data/split.py b/src/readii/process/split.py similarity index 100% rename from src/readii/data/split.py rename to src/readii/process/split.py From 4cd15e1912f2622a87aebed764d1386af9ddfd1e Mon Sep 17 00:00:00 2001 From: Katy Scott Date: Fri, 17 Jan 2025 15:55:15 -0500 Subject: [PATCH 02/47] style: remove whitespace for ruff check --- src/readii/process/label.py | 25 +++++++++++-------------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/src/readii/process/label.py b/src/readii/process/label.py index 70423dd..cda0b24 100644 --- a/src/readii/process/label.py +++ b/src/readii/process/label.py @@ -1,13 +1,15 @@ import re -from pandas import DataFrame, Series -import numpy as np from typing import Optional -from readii.utils import logger +import numpy as np +from pandas import DataFrame, Series + from readii.process.split import replaceColumnValues +from readii.utils import logger + def getPatientIdentifierLabel(dataframe_to_search:DataFrame) -> str: - """Function to find a column in a dataframe that contains some form of patient ID or case ID (case-insensitive). + """Function to find a column in a dataframe that contains some form of patient ID or case ID (case-insensitive). If multiple found, will return the first match. Current regex is: '(pat)?(ient)?(case)?(\s|.)?(id|#)' @@ -22,7 +24,6 @@ def getPatientIdentifierLabel(dataframe_to_search:DataFrame) -> str: str Label for patient identifier column from the dataframe. """ - # regex to get patient identifier column name in the dataframes # catches case-insensitive variations of patient_id, patid, pat id, case_id, case id, caseid, id id_search_term = re.compile(pattern= r'(pat)?(ient)?(case)?(\s|.)?(id|#)', flags=re.IGNORECASE) @@ -44,7 +45,7 @@ def getPatientIdentifierLabel(dataframe_to_search:DataFrame) -> str: def setPatientIdAsIndex(dataframe_to_index:DataFrame, patient_id_col:str = None): - """ Function to set the patient ID column as the index of a dataframe. + """Function to set the patient ID column as the index of a dataframe. Parameters ---------- @@ -64,7 +65,7 @@ def setPatientIdAsIndex(dataframe_to_index:DataFrame, def convertDaysToYears(dataframe_with_outcome:DataFrame, time_column_label:str, divide_by:int = 365): - """ Function to create a copy of a time outcome column mesaured in days and convert it to years. + """Function to create a copy of a time outcome column mesaured in days and convert it to years. Parameters ---------- @@ -80,7 +81,6 @@ def convertDaysToYears(dataframe_with_outcome:DataFrame, dataframe_with_outcome : DataFrame Dataframe with a copy of the specified time column converted to years. """ - # Set up the new column name for the converted time column years_column_label = time_column_label + "_years" # Make a copy of the time column with the values converted from days to years and add suffic _years to the column name @@ -95,7 +95,7 @@ def timeOutcomeColumnSetup(dataframe_with_outcome:DataFrame, standard_column_label:str, convert_to_years:bool = False, ): - """ Function to set up a time outcome column in a dataframe. Makes a copy of the specified outcome column with a standardized column name and converts it to years if specified. + """Function to set up a time outcome column in a dataframe. Makes a copy of the specified outcome column with a standardized column name and converts it to years if specified. Parameters ---------- @@ -113,7 +113,6 @@ def timeOutcomeColumnSetup(dataframe_with_outcome:DataFrame, dataframe_with_outcome : DataFrame Dataframe with a copy of the specified outcome column converted to years. """ - # Check if the outcome column is numeric if not np.issubdtype(dataframe_with_outcome[outcome_column_label].dtype, np.number): msg = f"{outcome_column_label} is not numeric. Please confirm outcome_column_label is the correct column or convert the column in the dataframe to numeric." @@ -156,7 +155,6 @@ def survivalStatusToNumericMapping(event_outcome_column:Series): event_column_value_mapping : dict Dictionary mapping the survival status values to numeric values. """ - # Create a dictionary to map event values to numeric values event_column_value_mapping = {} # Get a list of all unique event values, set NaN values to unknown, set remaining values to lower case @@ -195,7 +193,7 @@ def eventOutcomeColumnSetup(dataframe_with_outcome:DataFrame, outcome_column_label:str, standard_column_label:str, event_column_value_mapping:Optional[dict]=None): - """ Function to set up an event outcome column in a dataframe. + """Function to set up an event outcome column in a dataframe. Parameters ---------- @@ -215,7 +213,6 @@ def eventOutcomeColumnSetup(dataframe_with_outcome:DataFrame, dataframe_with_standardized_outcome : DataFrame Dataframe with a copy of the specified outcome column converted to numeric values. """ - # Get the type of the existing event column event_variable_type = dataframe_with_outcome[outcome_column_label].dtype @@ -285,7 +282,7 @@ def eventOutcomeColumnSetup(dataframe_with_outcome:DataFrame, def addOutcomeLabels(feature_data_to_label:DataFrame, clinical_data:DataFrame, outcome_labels:Optional[list] = None): - """ Function to add survival labels to a feature dataframe based on a clinical dataframe. + """Function to add survival labels to a feature dataframe based on a clinical dataframe. Parameters ---------- From e772dd0ef1668537dd8446aa6cb4966a3f8d335b Mon Sep 17 00:00:00 2001 From: Katy Scott Date: Fri, 17 Jan 2025 15:58:54 -0500 Subject: [PATCH 03/47] style: make docstrings imperative, add return type annotations --- src/readii/process/label.py | 33 +++++++++++++++++++++------------ 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/src/readii/process/label.py b/src/readii/process/label.py index cda0b24..db3db4d 100644 --- a/src/readii/process/label.py +++ b/src/readii/process/label.py @@ -9,7 +9,7 @@ def getPatientIdentifierLabel(dataframe_to_search:DataFrame) -> str: - """Function to find a column in a dataframe that contains some form of patient ID or case ID (case-insensitive). + """Find a column in a dataframe that contains some form of patient ID or case ID (case-insensitive). If multiple found, will return the first match. Current regex is: '(pat)?(ient)?(case)?(\s|.)?(id|#)' @@ -44,8 +44,9 @@ def getPatientIdentifierLabel(dataframe_to_search:DataFrame) -> str: def setPatientIdAsIndex(dataframe_to_index:DataFrame, - patient_id_col:str = None): - """Function to set the patient ID column as the index of a dataframe. + patient_id_col:str = None + ) -> DataFrame: + """Set the patient ID column as the index of a dataframe. Parameters ---------- @@ -64,8 +65,9 @@ def setPatientIdAsIndex(dataframe_to_index:DataFrame, def convertDaysToYears(dataframe_with_outcome:DataFrame, time_column_label:str, - divide_by:int = 365): - """Function to create a copy of a time outcome column mesaured in days and convert it to years. + divide_by:int = 365 + ) -> DataFrame: + """Create a copy of a time outcome column mesaured in days and convert it to years. Parameters ---------- @@ -94,8 +96,8 @@ def timeOutcomeColumnSetup(dataframe_with_outcome:DataFrame, outcome_column_label:str, standard_column_label:str, convert_to_years:bool = False, - ): - """Function to set up a time outcome column in a dataframe. Makes a copy of the specified outcome column with a standardized column name and converts it to years if specified. + ) -> DataFrame: + """Set up a time outcome column in a dataframe. Makes a copy of the specified outcome column with a standardized column name and converts it to years if specified. Parameters ---------- @@ -139,7 +141,7 @@ def timeOutcomeColumnSetup(dataframe_with_outcome:DataFrame, -def survivalStatusToNumericMapping(event_outcome_column:Series): +def survivalStatusToNumericMapping(event_outcome_column:Series) -> dict: """Convert a survival status column to a numeric column by iterating over unique values and assigning a numeric value to each. Alive values will be assigned a value of 0, and dead values will be assigned a value of 1. If "alive" is present, next event value index will start at 1. If "dead" is present, next event value index will start at 2. @@ -192,8 +194,9 @@ def survivalStatusToNumericMapping(event_outcome_column:Series): def eventOutcomeColumnSetup(dataframe_with_outcome:DataFrame, outcome_column_label:str, standard_column_label:str, - event_column_value_mapping:Optional[dict]=None): - """Function to set up an event outcome column in a dataframe. + event_column_value_mapping:Optional[dict]=None + ) -> DataFrame: + """Set up an event outcome column in a dataframe. Parameters ---------- @@ -281,8 +284,9 @@ def eventOutcomeColumnSetup(dataframe_with_outcome:DataFrame, def addOutcomeLabels(feature_data_to_label:DataFrame, clinical_data:DataFrame, - outcome_labels:Optional[list] = None): - """Function to add survival labels to a feature dataframe based on a clinical dataframe. + outcome_labels:Optional[list] = None + ) -> DataFrame: + """Add survival labels to a feature dataframe based on a clinical dataframe. Parameters ---------- @@ -292,6 +296,11 @@ def addOutcomeLabels(feature_data_to_label:DataFrame, Dataframe containing the clinical data to use for survival labels. outcome_labels : list, optional List of outcome labels to extract from the clinical dataframe. The default is ["survival_time_in_years", "survival_event_binary"]. + + Returns + ------- + outcome_labelled_feature_data : DataFrame + Dataframe containing the feature data with survival labels added. """ if outcome_labels is None: outcome_labels = ["survival_time_in_years", "survival_event_binary"] From c45ba603ef996c35d9761014c991e0f812bfca0e Mon Sep 17 00:00:00 2001 From: Katy Scott Date: Fri, 17 Jan 2025 16:04:33 -0500 Subject: [PATCH 04/47] style: docstring spacing for ruff --- src/readii/process/label.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/readii/process/label.py b/src/readii/process/label.py index db3db4d..cba81c2 100644 --- a/src/readii/process/label.py +++ b/src/readii/process/label.py @@ -9,8 +9,7 @@ def getPatientIdentifierLabel(dataframe_to_search:DataFrame) -> str: - """Find a column in a dataframe that contains some form of patient ID or case ID (case-insensitive). - If multiple found, will return the first match. + r"""Find a column in a dataframe that contains some form of patient ID or case ID (case-insensitive). If multiple found, will return the first match. Current regex is: '(pat)?(ient)?(case)?(\s|.)?(id|#)' @@ -143,6 +142,7 @@ def timeOutcomeColumnSetup(dataframe_with_outcome:DataFrame, def survivalStatusToNumericMapping(event_outcome_column:Series) -> dict: """Convert a survival status column to a numeric column by iterating over unique values and assigning a numeric value to each. + Alive values will be assigned a value of 0, and dead values will be assigned a value of 1. If "alive" is present, next event value index will start at 1. If "dead" is present, next event value index will start at 2. Any NaN values will be assigned the value "unknown", then converted to a numeric value. From 21eaee758895e6898d8db1bb6d30ad9a62a3fea5 Mon Sep 17 00:00:00 2001 From: Katy Scott Date: Fri, 17 Jan 2025 16:04:59 -0500 Subject: [PATCH 05/47] refactor: change event column value mapping to use dictionary comprehension --- src/readii/process/label.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/readii/process/label.py b/src/readii/process/label.py index cba81c2..f2fba4b 100644 --- a/src/readii/process/label.py +++ b/src/readii/process/label.py @@ -249,7 +249,7 @@ def eventOutcomeColumnSetup(dataframe_with_outcome:DataFrame, else: # Convert all dictionary keys in provided mapping to lowercase - event_column_value_mapping = dict((status.lower(), value) for status, value in event_column_value_mapping.items()) + event_column_value_mapping = {status.lower():value for status, value in event_column_value_mapping.items()} # Check if user provided dictionary handles all event values in the outcome column if set(existing_event_values) != set(event_column_value_mapping.keys()): From 932b713c79a767b48dcf4d4c5135ab8db0669e0a Mon Sep 17 00:00:00 2001 From: Katy Scott Date: Fri, 17 Jan 2025 16:11:14 -0500 Subject: [PATCH 06/47] style: fixes for ruff config, whitespace, imperative format, etc. --- src/readii/process/select.py | 93 +++++++++++++++++++----------------- 1 file changed, 48 insertions(+), 45 deletions(-) diff --git a/src/readii/process/select.py b/src/readii/process/select.py index d854d40..b6ae83b 100644 --- a/src/readii/process/select.py +++ b/src/readii/process/select.py @@ -1,15 +1,17 @@ -from pandas import DataFrame from typing import Optional +from pandas import DataFrame + from readii.utils import logger from .label import setPatientIdAsIndex + def dropUpToFeature(dataframe:DataFrame, feature_name:str, keep_feature_name_column:Optional[bool] = False - ): - """ Function to drop all columns up to and possibly including the specified feature. + ) -> DataFrame: + """Drop all columns up to and possibly including the specified feature. Parameters ---------- @@ -50,10 +52,10 @@ def dropUpToFeature(dataframe:DataFrame, def selectByColumnValue(dataframe:DataFrame, include_col_values:Optional[dict] = None, - exclude_col_values:Optional[dict] = None) -> DataFrame: + exclude_col_values:Optional[dict] = None + ) -> DataFrame: """ - Get rows of pandas DataFrame based on row values in the columns labelled as keys of the include_col_values and not in the keys of the exclude_col_values. - Include variables will be processed first, then exclude variables, in the order they are provided in the corresponding dictionaries. + Get rows of pandas DataFrame based on row values in the columns labelled as keys of the include_col_values and not in the keys of the exclude_col_values. Include variables will be processed first, then exclude variables, in the order they are provided in the corresponding dictionaries. Parameters ---------- @@ -95,9 +97,9 @@ def selectByColumnValue(dataframe:DataFrame, raise e -def getOnlyPyradiomicsFeatures(radiomic_data:DataFrame): - """ Function to get out just the features from a PyRadiomics output that includes metadata/diagnostics columns before the features. - Will look for the last diagnostics column or the first PyRadiomics feature column with the word "original" in it +def getOnlyPyradiomicsFeatures(radiomic_data:DataFrame) -> DataFrame: + """Get out just the features from a PyRadiomics output that includes metadata/diagnostics columns before the features. Will look for the last diagnostics column or the first PyRadiomics feature column with the word "original" in it. + Parameters ---------- radiomic_data : DataFrame @@ -137,8 +139,9 @@ def getOnlyPyradiomicsFeatures(radiomic_data:DataFrame): def getPatientIntersectionDataframes(dataframe_A:DataFrame, dataframe_B:DataFrame, need_pat_index_A:bool = True, - need_pat_index_B:bool = True): - """ Function to get the subset of two dataframes based on the intersection of their indices. Intersection will be based on the index of dataframe A. + need_pat_index_B:bool = True + ) -> DataFrame: + """Get the subset of two dataframes based on the intersection of their indices. Intersection will be based on the index of dataframe A. Parameters ---------- @@ -158,7 +161,6 @@ def getPatientIntersectionDataframes(dataframe_A:DataFrame, intersection_index_dataframeB : DataFrame Dataframe containing the rows of dataframe B that are in the intersection of the indices of dataframe A and dataframe B. """ - # Set the patient ID column as the index for dataframe A if needed if need_pat_index_A: dataframe_A = setPatientIdAsIndex(dataframe_A) @@ -177,46 +179,47 @@ def getPatientIntersectionDataframes(dataframe_A:DataFrame, return intersection_index_dataframeA, intersection_index_dataframeB -def validateDataframeSubsetSelection(dataframe:DataFrame, - num_rows:Optional[int] = None, - num_cols:Optional[int] = None): +# Katy (Jan 2025): I think I wrote this function for the correlation functions, but I don't think it's used. +# def validateDataframeSubsetSelection(dataframe:DataFrame, +# num_rows:Optional[int] = None, +# num_cols:Optional[int] = None +# ) -> None: - # Check if dataframe is a DataFrame - if not isinstance(dataframe, DataFrame): - msg = f"dataframe must be a pandas DataFrame, got {type(dataframe)}" - logger.error(msg) - raise TypeError(msg) +# # Check if dataframe is a DataFrame +# if not isinstance(dataframe, DataFrame): +# msg = f"dataframe must be a pandas DataFrame, got {type(dataframe)}" +# logger.error(msg) +# raise TypeError(msg) - if num_rows is not None: - # Check if num_rows is an integer - if not isinstance(num_rows, int): - msg = f"num_rows must be an integer, got {type(num_rows)}" - logger.error(msg) - raise TypeError(msg) +# if num_rows is not None: +# # Check if num_rows is an integer +# if not isinstance(num_rows, int): +# msg = f"num_rows must be an integer, got {type(num_rows)}" +# logger.error(msg) +# raise TypeError(msg) - if num_rows > dataframe.shape[0]: - msg = f"num_rows ({num_rows}) is greater than the number of rows in the dataframe ({dataframe.shape[0]})" - logger.error(msg) - raise ValueError() - else: - logger.debug("Number of rows is within the size of the dataframe.") +# if num_rows > dataframe.shape[0]: +# msg = f"num_rows ({num_rows}) is greater than the number of rows in the dataframe ({dataframe.shape[0]})" +# logger.error(msg) +# raise ValueError() +# else: +# logger.debug("Number of rows is within the size of the dataframe.") - if num_cols is not None: - # Check if num_cols is an integer - if not isinstance(num_cols, int): - msg = f"num_cols must be an integer, got {type(num_cols)}" - logger.error(msg) - raise TypeError(msg) +# if num_cols is not None: +# # Check if num_cols is an integer +# if not isinstance(num_cols, int): +# msg = f"num_cols must be an integer, got {type(num_cols)}" +# logger.error(msg) +# raise TypeError(msg) - if num_cols > dataframe.shape[1]: - msg = f"num_cols ({num_cols}) is greater than the number of columns in the dataframe ({dataframe.shape[1]})" - logger.error(msg) - raise ValueError() - else: - logger.debug("Number of columns is within the size of the dataframe.") +# if num_cols > dataframe.shape[1]: +# msg = f"num_cols ({num_cols}) is greater than the number of columns in the dataframe ({dataframe.shape[1]})" +# logger.error(msg) +# raise ValueError() +# else: +# logger.debug("Number of columns is within the size of the dataframe.") - return None From 6f75b7d55c687efaf455e6c1e8ec1e21bed7bbf9 Mon Sep 17 00:00:00 2001 From: Katy Scott Date: Fri, 17 Jan 2025 16:13:14 -0500 Subject: [PATCH 07/47] refactor: in selectByColumnValue, update error handling to include logger and dictionary iteration --- src/readii/process/select.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/readii/process/select.py b/src/readii/process/select.py index b6ae83b..050ca34 100644 --- a/src/readii/process/select.py +++ b/src/readii/process/select.py @@ -74,17 +74,19 @@ def selectByColumnValue(dataframe:DataFrame, """ try: if (include_col_values is None) and (exclude_col_values is None): - raise ValueError("Must provide one of include_col_values or exclude_col_values.") + msg = "Must provide one of include_col_values or exclude_col_values." + logger.exception(msg) + raise ValueError(msg) if include_col_values is not None: - for key in include_col_values.keys(): + for key in include_col_values: if key in ["Index", "index"]: dataframe = dataframe[dataframe.index.isin(include_col_values[key])] else: dataframe = dataframe[dataframe[key].isin(include_col_values[key])] if exclude_col_values is not None: - for key in exclude_col_values.keys(): + for key in exclude_col_values: if key in ["Index", "index"]: dataframe = dataframe[~dataframe.index.isin(exclude_col_values[key])] else: From 6e27c2e557a08668351f4e8027b92c8407aec548 Mon Sep 17 00:00:00 2001 From: Katy Scott Date: Fri, 17 Jan 2025 16:32:42 -0500 Subject: [PATCH 08/47] refactor/style: ruff config fixes --- src/readii/process/split.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/src/readii/process/split.py b/src/readii/process/split.py index 452865f..7fd96b0 100644 --- a/src/readii/process/split.py +++ b/src/readii/process/split.py @@ -1,10 +1,13 @@ +from typing import Optional + from pandas import DataFrame + def replaceColumnValues(dataframe:DataFrame, column_to_change:str, replacement_value_data:dict - ): - """Function to replace specified values in a column with a new value. + ) -> DataFrame: + """Replace specified values in a column with a new value. Parameters ---------- @@ -21,12 +24,11 @@ def replaceColumnValues(dataframe:DataFrame, dataframe : DataFrame Dataframe with values replaced. """ - # Check if the column name is a valid column in the dataframe if column_to_change not in dataframe.columns: raise ValueError(f"Column {column_to_change} not found in dataframe.") - for new_value in replacement_value_data.keys(): + for new_value in replacement_value_data: # Check if the replacement value is a valid value in the column old_values = replacement_value_data[new_value] values_not_found_in_column = set(old_values).difference(set(dataframe[column_to_change].unique())) @@ -41,9 +43,9 @@ def replaceColumnValues(dataframe:DataFrame, def splitDataByColumnValue(dataframe:DataFrame, split_col_data:dict[str,list], - impute_value = None, - ): - """Function to split a dataframe into multiple dataframes based on the values in a specified column. Optionally, impute values in the split columns. + impute_value:Optional[object | None] = None, + ) -> dict[str,DataFrame]: + """Split a dataframe into multiple dataframes based on the values in a specified column. Optionally, impute values in the split columns. Parameters ---------- @@ -59,11 +61,10 @@ def splitDataByColumnValue(dataframe:DataFrame, split_dataframes : dict Dictionary of dataframes, where the key is the split value and the value is the dataframe for that split value. """ - # Initialize dictionary to store the split dataframes split_dataframes = {} - for split_column_name in split_col_data.keys(): + for split_column_name in split_col_data: # Check if the column name is a valid column in the dataframe if split_column_name not in dataframe.columns: raise ValueError(f"Column {split_column_name} not found in dataframe.") From 97881c3d0797f43a040e49821b473a78119a0b31 Mon Sep 17 00:00:00 2001 From: Katy Scott Date: Fri, 17 Jan 2025 16:37:47 -0500 Subject: [PATCH 09/47] feat: add readii logger for errors --- src/readii/process/split.py | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/src/readii/process/split.py b/src/readii/process/split.py index 7fd96b0..6ea88ea 100644 --- a/src/readii/process/split.py +++ b/src/readii/process/split.py @@ -2,6 +2,8 @@ from pandas import DataFrame +from readii.utils import logger + def replaceColumnValues(dataframe:DataFrame, column_to_change:str, @@ -26,14 +28,20 @@ def replaceColumnValues(dataframe:DataFrame, """ # Check if the column name is a valid column in the dataframe if column_to_change not in dataframe.columns: - raise ValueError(f"Column {column_to_change} not found in dataframe.") + msg = f"Column {column_to_change} not found in dataframe." + logger.exception(msg) + raise ValueError(msg) for new_value in replacement_value_data: # Check if the replacement value is a valid value in the column old_values = replacement_value_data[new_value] values_not_found_in_column = set(old_values).difference(set(dataframe[column_to_change].unique())) + if values_not_found_in_column == set(old_values): - raise ValueError(f"All values in {values_not_found_in_column} are not found to be replaced in column {column_to_change}.") + msg = f"All values in {values_not_found_in_column} are not found to be replaced in column {column_to_change}." + logger.exception(msg) + raise ValueError(msg) + # Replace the old values with the new value dataframe = dataframe.replace(to_replace=replacement_value_data[new_value], value=new_value) @@ -67,7 +75,9 @@ def splitDataByColumnValue(dataframe:DataFrame, for split_column_name in split_col_data: # Check if the column name is a valid column in the dataframe if split_column_name not in dataframe.columns: - raise ValueError(f"Column {split_column_name} not found in dataframe.") + msg = f"Column {split_column_name} not found in dataframe." + logger.exception(msg) + raise ValueError(msg) # Get split column values for this column split_col_values = split_col_data[split_column_name] @@ -89,7 +99,9 @@ def splitDataByColumnValue(dataframe:DataFrame, for split_value in split_col_values: # Check if the split_value is a valid value in the column if split_value not in dataframe[split_column_name].unique(): - raise ValueError(f"Split value {split_value} not found in column {split_column_name}.") + msg = f"Split value {split_value} not found in column {split_column_name}." + logger.exception(msg) + raise ValueError(msg) # Split the dataframe by the specified split_value split_dataframe = dataframe[dataframe[split_column_name] == split_value] From 264f88800619ba78d0a738714b20134b26e5714a Mon Sep 17 00:00:00 2001 From: Katy Scott Date: Fri, 17 Jan 2025 16:38:36 -0500 Subject: [PATCH 10/47] refactor: update dictionary iteration in replaceColumnValues --- src/readii/process/split.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/readii/process/split.py b/src/readii/process/split.py index 6ea88ea..4e64a25 100644 --- a/src/readii/process/split.py +++ b/src/readii/process/split.py @@ -32,9 +32,8 @@ def replaceColumnValues(dataframe:DataFrame, logger.exception(msg) raise ValueError(msg) - for new_value in replacement_value_data: + for new_value, old_values in replacement_value_data.items(): # Check if the replacement value is a valid value in the column - old_values = replacement_value_data[new_value] values_not_found_in_column = set(old_values).difference(set(dataframe[column_to_change].unique())) if values_not_found_in_column == set(old_values): @@ -43,7 +42,7 @@ def replaceColumnValues(dataframe:DataFrame, raise ValueError(msg) # Replace the old values with the new value - dataframe = dataframe.replace(to_replace=replacement_value_data[new_value], + dataframe = dataframe.replace(to_replace=old_values, value=new_value) return dataframe From 3e1cb34a37a243a29f0c50dfaf36fcf097dc7aac Mon Sep 17 00:00:00 2001 From: Katy Scott Date: Fri, 17 Jan 2025 16:39:34 -0500 Subject: [PATCH 11/47] refactor: update dictionary iteration for split_col_data in splitDataByColumnValue --- src/readii/process/split.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/readii/process/split.py b/src/readii/process/split.py index 4e64a25..80d404d 100644 --- a/src/readii/process/split.py +++ b/src/readii/process/split.py @@ -71,15 +71,12 @@ def splitDataByColumnValue(dataframe:DataFrame, # Initialize dictionary to store the split dataframes split_dataframes = {} - for split_column_name in split_col_data: + for split_column_name, split_col_values in split_col_data.items(): # Check if the column name is a valid column in the dataframe if split_column_name not in dataframe.columns: msg = f"Column {split_column_name} not found in dataframe." logger.exception(msg) raise ValueError(msg) - - # Get split column values for this column - split_col_values = split_col_data[split_column_name] if impute_value is not None: # Get all values in the column that are not one of the split_col_values From 86b54819adbcd2db42d497d1778a0666e378f079 Mon Sep 17 00:00:00 2001 From: Katy Scott Date: Fri, 17 Jan 2025 16:40:51 -0500 Subject: [PATCH 12/47] feat: set up init file for process directory --- src/readii/process/__init__.py | 35 ++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/readii/process/__init__.py b/src/readii/process/__init__.py index e69de29..21be5fd 100644 --- a/src/readii/process/__init__.py +++ b/src/readii/process/__init__.py @@ -0,0 +1,35 @@ +"""Module for processing and manipulating data.""" + +from .label import ( + addOutcomeLabels, + convertDaysToYears, + eventOutcomeColumnSetup, + getPatientIdentifierLabel, + setPatientIdAsIndex, + survivalStatusToNumericMapping, + timeOutcomeColumnSetup, +) +from .select import ( + dropUpToFeature, + getOnlyPyradiomicsFeatures, + selectByColumnValue, +) +from .split import ( + replaceColumnValues, + splitDataByColumnValue, +) + +__all__ = [ + "addOutcomeLabels", + "convertDaysToYears", + "getPatientIdentifierLabel", + "setPatientIdAsIndex", + "timeOutcomeColumnSetup", + "survivalStatusToNumericMapping", + "eventOutcomeColumnSetup", + "replaceColumnValues", + "splitDataByColumnValue", + "dropUpToFeature", + "selectByColumnValue", + "getOnlyPyradiomicsFeatures", +] \ No newline at end of file From 38c38c8d6d83ce9ed909e7892bc1286f2a30a243 Mon Sep 17 00:00:00 2001 From: Katy Scott Date: Fri, 17 Jan 2025 16:41:10 -0500 Subject: [PATCH 13/47] feat: add process to the ruff config --- config/ruff.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/config/ruff.toml b/config/ruff.toml index aca0861..3e758a3 100644 --- a/config/ruff.toml +++ b/config/ruff.toml @@ -7,7 +7,8 @@ include = [ "src/readii/cli/**/*.py", "src/readii/negative_controls_refactor/**.py", "src/readii/io/**/**.py", - "src/readii/analyze/**.py" + "src/readii/analyze/**.py", + "src/readii/process/**.py" ] # extend-exclude is used to exclude directories from the flake8 checks From 727cd1a7134c29187e4c29bda647d5811fc6a2fe Mon Sep 17 00:00:00 2001 From: Katy Scott Date: Fri, 17 Jan 2025 16:41:19 -0500 Subject: [PATCH 14/47] build: latest pixi lock --- pixi.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pixi.lock b/pixi.lock index 4a3ffc1..c8845f4 100644 --- a/pixi.lock +++ b/pixi.lock @@ -11393,8 +11393,8 @@ packages: timestamp: 1728642895644 - pypi: . name: readii - version: 1.34.0 - sha256: 6c14fa7493df2bdf5246c6cc57d1889a3242305880a6fc62c7a92afee8658390 + version: 1.34.1 + sha256: d0656e0a0595cd6028eba91eb6d191d019e516a35ed3c8771972947424a36028 requires_dist: - simpleitk>=2.3.1 - matplotlib>=3.9.2,<4 From ee091eda3a02cb749a778858ea6d185ec2c87eda Mon Sep 17 00:00:00 2001 From: Katy Scott Date: Fri, 17 Jan 2025 16:52:00 -0500 Subject: [PATCH 15/47] feat: add resize/resampling function --- src/readii/process/images/crop.py | 47 +++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 src/readii/process/images/crop.py diff --git a/src/readii/process/images/crop.py b/src/readii/process/images/crop.py new file mode 100644 index 0000000..f067198 --- /dev/null +++ b/src/readii/process/images/crop.py @@ -0,0 +1,47 @@ +import numpy as np +import SimpleITK as sitk +from imgtools.ops.functional import resample + +from readii.utils import logger + + +def resizeImage(image:sitk.Image, + resized_dimensions:tuple + ) -> sitk.Image: + """Resize an image to specified dimensions via linear interpolation. + + Parameters + ---------- + image : sitk.Image + Image to resize. + resized_dimensions : tuple + Tuple of integers representing the new dimensions to resize the image to. Must have the same number of dimensions as the image. + + Returns + ------- + resized_image : sitk.Image + Resized image. + """ + # Check that the number of dimensions in the resized dimensions matches the number of dimensions in the image + if len(resized_dimensions) != image.GetDimension(): + msg = f"Number of dimensions in resized_dimensions ({len(resized_dimensions)}) does not match the number of dimensions in the image ({image.GetDimension()})." + logger.exception(msg) + raise ValueError(msg) + + # Check that the resized dimensions are integers + if not all(isinstance(dim, int) for dim in resized_dimensions): + msg = "Resized dimensions must be integers." + logger.exception(msg) + raise ValueError(msg) + + # Calculate the new spacing based on the resized dimensions + original_dimensions = np.array(image.GetSize()) + original_spacing = np.array(image.GetSpacing()) + resized_spacing = original_spacing * original_dimensions / resized_dimensions + + # Resample the image to the new dimensions and spacing + resized_image = resample(image, spacing=resized_spacing, size=resized_dimensions) + + return resized_image + + From 0ab0eb7c0e92da8b91a564d4520b4fd1e89590ee Mon Sep 17 00:00:00 2001 From: Katy Scott Date: Fri, 17 Jan 2025 17:04:35 -0500 Subject: [PATCH 16/47] feat: add dataclasses to use for bounding box stuff in crop --- .../process/images/utils/bounding_box.py | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 src/readii/process/images/utils/bounding_box.py diff --git a/src/readii/process/images/utils/bounding_box.py b/src/readii/process/images/utils/bounding_box.py new file mode 100644 index 0000000..7a5a963 --- /dev/null +++ b/src/readii/process/images/utils/bounding_box.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +from dataclasses import dataclass, field + +@dataclass +class Point3D: + """Represent a point in 3D space.""" + + x: int + y: int + z: int + + @property + def as_tuple(self): + return self.x, self.y, self.z + + def __add__(self, other: Point3D) -> Point3D: + return Point3D(x=self.x + other.x, y=self.y + other.y, z=self.z + other.z) + + def __sub__(self, other: Point3D) -> Point3D: + return Point3D(x=self.x - other.x, y=self.y - other.y, z=self.z - other.z) + + +@dataclass +class Size3D(Point3D): + """Represent the size of a 3D object using its width, height, and depth.""" + + pass + + +@dataclass +class Coordinate(Point3D): + """Represent a coordinate in 3D space.""" + + pass + + +@dataclass +class Centroid(Coordinate): + """Represent the centroid of a region in 3D space. + + A centroid is simply a coordinate in 3D space that represents + the center of mass of a region in an image. It is represented + by its x, y, and z coordinates. + + Attributes + ---------- + x : int + y : int + z : int + """ + + pass \ No newline at end of file From f89247472d1d81ed571299a193e9ea4137bacc61 Mon Sep 17 00:00:00 2001 From: Katy Scott Date: Fri, 17 Jan 2025 17:11:36 -0500 Subject: [PATCH 17/47] feat: add findBoundingBox function --- src/readii/process/images/crop.py | 33 +++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/readii/process/images/crop.py b/src/readii/process/images/crop.py index f067198..518c701 100644 --- a/src/readii/process/images/crop.py +++ b/src/readii/process/images/crop.py @@ -1,3 +1,4 @@ + import numpy as np import SimpleITK as sitk from imgtools.ops.functional import resample @@ -45,3 +46,35 @@ def resizeImage(image:sitk.Image, return resized_image +def findBoundingBox(mask:sitk.Image, + min_dim_size:int = 4) -> np.ndarray: + """Find the bounding box of a region of interest (ROI) in a given binary mask image. + + Parameters + ---------- + mask : sitk.Image + Mask image to find the bounding box within. + min_dim_size : int, optional + Minimum size of the bounding box along each dimension. The default is 4. + + Returns + ------- + bounding_box : np.ndarray + Numpy array containing the bounding box coordinates of the ROI. + """ + mask_uint = sitk.Cast(mask, sitk.sitkUInt8) + stats = sitk.LabelShapeStatisticsImageFilter() + stats.Execute(mask_uint) + xstart, ystart, zstart, xsize, ysize, zsize = stats.GetBoundingBox(1) + + # Ensure minimum size of 4 pixels along each dimension + xsize = max(xsize, min_dim_size) + ysize = max(ysize, min_dim_size) + zsize = max(zsize, min_dim_size) + + min_coord = [xstart, ystart, zstart] + max_coord = [xstart + xsize, ystart + ysize, zstart + zsize] + # min_coord = Coordinate(x=xstart, y=ystart, z=zstart) + # max_coord = Coordinate(x=xstart + xsize, y=ystart + ysize, z=zstart + zsize) + + return min_coord + max_coord From 4c283d0308e4d5672024bace418b0c6f83cac3db Mon Sep 17 00:00:00 2001 From: Katy Scott Date: Fri, 17 Jan 2025 17:15:21 -0500 Subject: [PATCH 18/47] feat/docs: add findCentroid function, add comments to findBoundingBox --- src/readii/process/images/crop.py | 32 ++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/src/readii/process/images/crop.py b/src/readii/process/images/crop.py index 518c701..e511a5b 100644 --- a/src/readii/process/images/crop.py +++ b/src/readii/process/images/crop.py @@ -60,11 +60,13 @@ def findBoundingBox(mask:sitk.Image, Returns ------- bounding_box : np.ndarray - Numpy array containing the bounding box coordinates of the ROI. + Numpy array containing the bounding box coordinates around the ROI. """ + # Convert the mask to a uint8 image mask_uint = sitk.Cast(mask, sitk.sitkUInt8) stats = sitk.LabelShapeStatisticsImageFilter() stats.Execute(mask_uint) + # Get the bounding box starting coordinates and size xstart, ystart, zstart, xsize, ysize, zsize = stats.GetBoundingBox(1) # Ensure minimum size of 4 pixels along each dimension @@ -73,8 +75,36 @@ def findBoundingBox(mask:sitk.Image, zsize = max(zsize, min_dim_size) min_coord = [xstart, ystart, zstart] + # Calculate the maximum coordinate of the bounding box by adding the size to the starting coordinate max_coord = [xstart + xsize, ystart + ysize, zstart + zsize] + + # TODO: Switch to using a class for the bounding box # min_coord = Coordinate(x=xstart, y=ystart, z=zstart) # max_coord = Coordinate(x=xstart + xsize, y=ystart + ysize, z=zstart + zsize) return min_coord + max_coord + + + +def findCentroid(mask:sitk.Image) -> np.ndarray: + """Find the centroid of a region of interest (ROI) in a given binary mask image. + + Parameters + ---------- + mask : sitk.Image + Mask image to find the centroid within. + + Returns + ------- + centroid : np.ndarray + Numpy array containing the coordinates of the ROI centroid. + """ + # Convert the mask to a uint8 image + mask_uint = sitk.Cast(mask, sitk.sitkUInt8) + stats = sitk.LabelShapeStatisticsImageFilter() + stats.Execute(mask_uint) + # Get the centroid coordinates as a physical point in the mask + centroid_coords = stats.GetCentroid(1) + # Convert the physical point to an index in the mask array + centroid_idx = mask.TransformPhysicalPointToIndex(centroid_coords) + return np.asarray(centroid_idx, dtype=np.float32) From d61e1f4e6e66ad5ca6b372b303162cf155fde501 Mon Sep 17 00:00:00 2001 From: Katy Scott Date: Fri, 17 Jan 2025 17:25:29 -0500 Subject: [PATCH 19/47] feat: add cropToCentroid function and validateNewDimensions function for quick checking dimension match between image and centroid/mask/dimensions/etc. --- src/readii/process/images/crop.py | 89 +++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/src/readii/process/images/crop.py b/src/readii/process/images/crop.py index e511a5b..85d5a2d 100644 --- a/src/readii/process/images/crop.py +++ b/src/readii/process/images/crop.py @@ -6,6 +6,37 @@ from readii.utils import logger +def validateNewDimensions(image:sitk.Image, + new_dimensions:tuple + ) -> None: + """Validate that the input new dimensions are valid for the image. + + Parameters + ---------- + image : sitk.Image + Image to validate the new dimensions for. + new_dimensions : tuple + Tuple of integers representing the new dimensions to validate. + + Raises + ------ + ValueError + If the new dimensions are not valid for the image. + """ + # Check that the number of dimensions in the new dimensions matches the number of dimensions in the image + if len(new_dimensions) != image.GetDimension(): + msg = f"Number of dimensions in new_dimensions ({len(new_dimensions)}) does not match the number of dimensions in the image ({image.GetDimension()})." + logger.exception(msg) + raise ValueError(msg) + + # Check that the new dimensions are integers + if not all(isinstance(dim, int) for dim in new_dimensions): + msg = "New dimensions must be integers." + logger.exception(msg) + raise ValueError(msg) + + + def resizeImage(image:sitk.Image, resized_dimensions:tuple ) -> sitk.Image: @@ -46,6 +77,7 @@ def resizeImage(image:sitk.Image, return resized_image + def findBoundingBox(mask:sitk.Image, min_dim_size:int = 4) -> np.ndarray: """Find the bounding box of a region of interest (ROI) in a given binary mask image. @@ -108,3 +140,60 @@ def findCentroid(mask:sitk.Image) -> np.ndarray: # Convert the physical point to an index in the mask array centroid_idx = mask.TransformPhysicalPointToIndex(centroid_coords) return np.asarray(centroid_idx, dtype=np.float32) + + + +def cropToCentroid(image:sitk.Image, + centroid:tuple, + crop_dimensions:tuple, + ) -> sitk.Image: + """Crop an image centered on the centroid with specified crop dimension. No resizing/resampling is performed. + + Parameters + ---------- + image : sitk.Image + Image to crop. + centroid : tuple + Tuple of integers representing the centroid of the image to crop. Must have the same number of dimensions as the image. + crop_dimensions : tuple + Tuple of integers representing the dimensions to crop the image to. Must have the same number of dimensions as the image. + + Returns + ------- + cropped_image : sitk.Image + Cropped image. + """ + # Check that the number of dimensions in the crop dimensions matches the number of dimensions in the image + validateNewDimensions(image, crop_dimensions) + + # Check that the centroid dimensions match the image dimensions + validateNewDimensions(image, centroid) + + min_x = int(centroid[0] - crop_dimensions[0] // 2) + max_x = int(centroid[0] + crop_dimensions[0] // 2) + min_y = int(centroid[1] - crop_dimensions[1] // 2) + max_y = int(centroid[1] + crop_dimensions[1] // 2) + min_z = int(centroid[2] - crop_dimensions[2] // 2) + max_z = int(centroid[2] + crop_dimensions[2] // 2) + + + img_x, img_y, img_z = image.GetSize() + + + if min_x < 0: + min_x, max_x = 0, crop_dimensions[0] + elif max_x > img_x: + min_x, max_x = img_x - crop_dimensions[0], img_x + + if min_y < 0: + min_y, max_y = 0, crop_dimensions[1] + elif max_y > img_y: + min_y, max_y = img_y - crop_dimensions[1], img_y + + if min_z < 0: + min_z, max_z = 0, crop_dimensions[2] + elif max_z > img_z: + min_z, max_z = img_z - crop_dimensions[2], img_z + + return image[min_x:max_x, min_y:max_y, min_z:max_z] + From 75f1adba58ba7890a359a1ee0452e2823a1f044b Mon Sep 17 00:00:00 2001 From: Katy Scott Date: Fri, 17 Jan 2025 17:26:12 -0500 Subject: [PATCH 20/47] refactor: use validateNewDimensions in resizeImage --- src/readii/process/images/crop.py | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/src/readii/process/images/crop.py b/src/readii/process/images/crop.py index 85d5a2d..34d6012 100644 --- a/src/readii/process/images/crop.py +++ b/src/readii/process/images/crop.py @@ -54,17 +54,7 @@ def resizeImage(image:sitk.Image, resized_image : sitk.Image Resized image. """ - # Check that the number of dimensions in the resized dimensions matches the number of dimensions in the image - if len(resized_dimensions) != image.GetDimension(): - msg = f"Number of dimensions in resized_dimensions ({len(resized_dimensions)}) does not match the number of dimensions in the image ({image.GetDimension()})." - logger.exception(msg) - raise ValueError(msg) - - # Check that the resized dimensions are integers - if not all(isinstance(dim, int) for dim in resized_dimensions): - msg = "Resized dimensions must be integers." - logger.exception(msg) - raise ValueError(msg) + validateNewDimensions(image, resized_dimensions) # Calculate the new spacing based on the resized dimensions original_dimensions = np.array(image.GetSize()) @@ -179,7 +169,7 @@ def cropToCentroid(image:sitk.Image, img_x, img_y, img_z = image.GetSize() - + if min_x < 0: min_x, max_x = 0, crop_dimensions[0] elif max_x > img_x: From e72bc450b76d96ec6271d199e9aeb97c7193dda9 Mon Sep 17 00:00:00 2001 From: Katy Scott Date: Mon, 20 Jan 2025 10:35:07 -0500 Subject: [PATCH 21/47] docs: add docstrings and return types for ruff --- src/readii/process/images/utils/bounding_box.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/readii/process/images/utils/bounding_box.py b/src/readii/process/images/utils/bounding_box.py index 7a5a963..5e57421 100644 --- a/src/readii/process/images/utils/bounding_box.py +++ b/src/readii/process/images/utils/bounding_box.py @@ -1,6 +1,7 @@ from __future__ import annotations -from dataclasses import dataclass, field +from dataclasses import dataclass + @dataclass class Point3D: @@ -11,13 +12,16 @@ class Point3D: z: int @property - def as_tuple(self): + def as_tuple(self) -> tuple[int, int, int]: + """Return the point as a tuple.""" return self.x, self.y, self.z def __add__(self, other: Point3D) -> Point3D: + """Add two points.""" return Point3D(x=self.x + other.x, y=self.y + other.y, z=self.z + other.z) def __sub__(self, other: Point3D) -> Point3D: + """Subtract two points.""" return Point3D(x=self.x - other.x, y=self.y - other.y, z=self.z - other.z) From 7e56d357700763f2ba539b2a702fc6cc10bb6c54 Mon Sep 17 00:00:00 2001 From: Katy Scott Date: Mon, 20 Jan 2025 11:51:05 -0500 Subject: [PATCH 22/47] refactor: remove new code from this PR --- src/readii/process/images/crop.py | 189 ------------------ .../process/images/utils/bounding_box.py | 57 ------ 2 files changed, 246 deletions(-) delete mode 100644 src/readii/process/images/crop.py delete mode 100644 src/readii/process/images/utils/bounding_box.py diff --git a/src/readii/process/images/crop.py b/src/readii/process/images/crop.py deleted file mode 100644 index 34d6012..0000000 --- a/src/readii/process/images/crop.py +++ /dev/null @@ -1,189 +0,0 @@ - -import numpy as np -import SimpleITK as sitk -from imgtools.ops.functional import resample - -from readii.utils import logger - - -def validateNewDimensions(image:sitk.Image, - new_dimensions:tuple - ) -> None: - """Validate that the input new dimensions are valid for the image. - - Parameters - ---------- - image : sitk.Image - Image to validate the new dimensions for. - new_dimensions : tuple - Tuple of integers representing the new dimensions to validate. - - Raises - ------ - ValueError - If the new dimensions are not valid for the image. - """ - # Check that the number of dimensions in the new dimensions matches the number of dimensions in the image - if len(new_dimensions) != image.GetDimension(): - msg = f"Number of dimensions in new_dimensions ({len(new_dimensions)}) does not match the number of dimensions in the image ({image.GetDimension()})." - logger.exception(msg) - raise ValueError(msg) - - # Check that the new dimensions are integers - if not all(isinstance(dim, int) for dim in new_dimensions): - msg = "New dimensions must be integers." - logger.exception(msg) - raise ValueError(msg) - - - -def resizeImage(image:sitk.Image, - resized_dimensions:tuple - ) -> sitk.Image: - """Resize an image to specified dimensions via linear interpolation. - - Parameters - ---------- - image : sitk.Image - Image to resize. - resized_dimensions : tuple - Tuple of integers representing the new dimensions to resize the image to. Must have the same number of dimensions as the image. - - Returns - ------- - resized_image : sitk.Image - Resized image. - """ - validateNewDimensions(image, resized_dimensions) - - # Calculate the new spacing based on the resized dimensions - original_dimensions = np.array(image.GetSize()) - original_spacing = np.array(image.GetSpacing()) - resized_spacing = original_spacing * original_dimensions / resized_dimensions - - # Resample the image to the new dimensions and spacing - resized_image = resample(image, spacing=resized_spacing, size=resized_dimensions) - - return resized_image - - - -def findBoundingBox(mask:sitk.Image, - min_dim_size:int = 4) -> np.ndarray: - """Find the bounding box of a region of interest (ROI) in a given binary mask image. - - Parameters - ---------- - mask : sitk.Image - Mask image to find the bounding box within. - min_dim_size : int, optional - Minimum size of the bounding box along each dimension. The default is 4. - - Returns - ------- - bounding_box : np.ndarray - Numpy array containing the bounding box coordinates around the ROI. - """ - # Convert the mask to a uint8 image - mask_uint = sitk.Cast(mask, sitk.sitkUInt8) - stats = sitk.LabelShapeStatisticsImageFilter() - stats.Execute(mask_uint) - # Get the bounding box starting coordinates and size - xstart, ystart, zstart, xsize, ysize, zsize = stats.GetBoundingBox(1) - - # Ensure minimum size of 4 pixels along each dimension - xsize = max(xsize, min_dim_size) - ysize = max(ysize, min_dim_size) - zsize = max(zsize, min_dim_size) - - min_coord = [xstart, ystart, zstart] - # Calculate the maximum coordinate of the bounding box by adding the size to the starting coordinate - max_coord = [xstart + xsize, ystart + ysize, zstart + zsize] - - # TODO: Switch to using a class for the bounding box - # min_coord = Coordinate(x=xstart, y=ystart, z=zstart) - # max_coord = Coordinate(x=xstart + xsize, y=ystart + ysize, z=zstart + zsize) - - return min_coord + max_coord - - - -def findCentroid(mask:sitk.Image) -> np.ndarray: - """Find the centroid of a region of interest (ROI) in a given binary mask image. - - Parameters - ---------- - mask : sitk.Image - Mask image to find the centroid within. - - Returns - ------- - centroid : np.ndarray - Numpy array containing the coordinates of the ROI centroid. - """ - # Convert the mask to a uint8 image - mask_uint = sitk.Cast(mask, sitk.sitkUInt8) - stats = sitk.LabelShapeStatisticsImageFilter() - stats.Execute(mask_uint) - # Get the centroid coordinates as a physical point in the mask - centroid_coords = stats.GetCentroid(1) - # Convert the physical point to an index in the mask array - centroid_idx = mask.TransformPhysicalPointToIndex(centroid_coords) - return np.asarray(centroid_idx, dtype=np.float32) - - - -def cropToCentroid(image:sitk.Image, - centroid:tuple, - crop_dimensions:tuple, - ) -> sitk.Image: - """Crop an image centered on the centroid with specified crop dimension. No resizing/resampling is performed. - - Parameters - ---------- - image : sitk.Image - Image to crop. - centroid : tuple - Tuple of integers representing the centroid of the image to crop. Must have the same number of dimensions as the image. - crop_dimensions : tuple - Tuple of integers representing the dimensions to crop the image to. Must have the same number of dimensions as the image. - - Returns - ------- - cropped_image : sitk.Image - Cropped image. - """ - # Check that the number of dimensions in the crop dimensions matches the number of dimensions in the image - validateNewDimensions(image, crop_dimensions) - - # Check that the centroid dimensions match the image dimensions - validateNewDimensions(image, centroid) - - min_x = int(centroid[0] - crop_dimensions[0] // 2) - max_x = int(centroid[0] + crop_dimensions[0] // 2) - min_y = int(centroid[1] - crop_dimensions[1] // 2) - max_y = int(centroid[1] + crop_dimensions[1] // 2) - min_z = int(centroid[2] - crop_dimensions[2] // 2) - max_z = int(centroid[2] + crop_dimensions[2] // 2) - - - img_x, img_y, img_z = image.GetSize() - - - if min_x < 0: - min_x, max_x = 0, crop_dimensions[0] - elif max_x > img_x: - min_x, max_x = img_x - crop_dimensions[0], img_x - - if min_y < 0: - min_y, max_y = 0, crop_dimensions[1] - elif max_y > img_y: - min_y, max_y = img_y - crop_dimensions[1], img_y - - if min_z < 0: - min_z, max_z = 0, crop_dimensions[2] - elif max_z > img_z: - min_z, max_z = img_z - crop_dimensions[2], img_z - - return image[min_x:max_x, min_y:max_y, min_z:max_z] - diff --git a/src/readii/process/images/utils/bounding_box.py b/src/readii/process/images/utils/bounding_box.py deleted file mode 100644 index 5e57421..0000000 --- a/src/readii/process/images/utils/bounding_box.py +++ /dev/null @@ -1,57 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - - -@dataclass -class Point3D: - """Represent a point in 3D space.""" - - x: int - y: int - z: int - - @property - def as_tuple(self) -> tuple[int, int, int]: - """Return the point as a tuple.""" - return self.x, self.y, self.z - - def __add__(self, other: Point3D) -> Point3D: - """Add two points.""" - return Point3D(x=self.x + other.x, y=self.y + other.y, z=self.z + other.z) - - def __sub__(self, other: Point3D) -> Point3D: - """Subtract two points.""" - return Point3D(x=self.x - other.x, y=self.y - other.y, z=self.z - other.z) - - -@dataclass -class Size3D(Point3D): - """Represent the size of a 3D object using its width, height, and depth.""" - - pass - - -@dataclass -class Coordinate(Point3D): - """Represent a coordinate in 3D space.""" - - pass - - -@dataclass -class Centroid(Coordinate): - """Represent the centroid of a region in 3D space. - - A centroid is simply a coordinate in 3D space that represents - the center of mass of a region in an image. It is represented - by its x, y, and z coordinates. - - Attributes - ---------- - x : int - y : int - z : int - """ - - pass \ No newline at end of file From 435da76290d6f9ffec103ad4e6f10f6d633c8d88 Mon Sep 17 00:00:00 2001 From: Katy Scott Date: Mon, 20 Jan 2025 16:20:55 -0500 Subject: [PATCH 23/47] feat: add cropping methods from FMCIB pipeline --- src/readii/process/images/crop.py | 399 ++++++++++++++++++++++++++++++ 1 file changed, 399 insertions(+) create mode 100644 src/readii/process/images/crop.py diff --git a/src/readii/process/images/crop.py b/src/readii/process/images/crop.py new file mode 100644 index 0000000..45612d6 --- /dev/null +++ b/src/readii/process/images/crop.py @@ -0,0 +1,399 @@ + +import numpy as np +import SimpleITK as sitk +from imgtools.ops.functional import resample + +from readii.utils import logger + +from typing import Literal + + +def validate_new_dimensions(image:sitk.Image, + new_dimensions:tuple | int + ) -> None: + """Validate that the input new dimensions are valid for the image. + + Parameters + ---------- + image : sitk.Image + Image to validate the new dimensions for. + new_dimensions : tuple or int + Tuple of values representing the new dimensions to validate, or a single integer representing the number of dimensions. + + Raises + ------ + ValueError + If the new dimensions are not valid for the image. + """ + # Check that the number of dimensions in the new dimensions matches the number of dimensions in the image + if type(new_dimensions) == tuple: + if len(new_dimensions) != image.GetDimension(): + msg = f"Number of dimensions in new_dimensions ({len(new_dimensions)}) does not match the number of dimensions in the image ({image.GetDimension()})." + logger.exception(msg) + raise ValueError(msg) + + elif type(new_dimensions) == int: + if new_dimensions != image.GetDimension(): + msg = f"Number of dimensions in new_dimensions ({new_dimensions}) does not match the number of dimensions in the image ({image.GetDimension()})." + logger.exception(msg) + raise ValueError(msg) + + else: + msg = "New dimensions must be a tuple of integers or a single integer." + logger.exception(msg) + raise ValueError(msg) + + + +def resize_image(image:sitk.Image, + resize_dimensions:tuple + ) -> sitk.Image: + """Resize an image to specified dimensions via linear interpolation. + + Parameters + ---------- + image : sitk.Image + Image to resize. + resize_dimensions : tuple + Tuple of integers representing the new dimensions to resize the image to. Must have the same number of dimensions as the image. + + Returns + ------- + resized_image : sitk.Image + Resized image. + """ + validate_new_dimensions(image, resize_dimensions) + + # Calculate the new spacing based on the resized dimensions + original_dimensions = np.array(image.GetSize()) + original_spacing = np.array(image.GetSpacing()) + resized_spacing = original_spacing * original_dimensions / resize_dimensions + + # Resample the image to the new dimensions and spacing + resized_image = resample(image, spacing=resized_spacing, output_size=resize_dimensions) + + return resized_image + + + +def find_bounding_box(mask:sitk.Image, + min_dim_size:int = 4 + ) -> np.ndarray: + """Find the bounding box of a region of interest (ROI) in a given binary mask image. + + Parameters + ---------- + mask : sitk.Image + Mask image to find the bounding box within. + min_dim_size : int, optional + Minimum size of the bounding box along each dimension. The default is 4. + + Returns + ------- + bounding_box : np.ndarray + Numpy array containing the bounding box coordinates around the ROI. + """ + # Convert the mask to a uint8 image + mask_uint = sitk.Cast(mask, sitk.sitkUInt8) + stats = sitk.LabelShapeStatisticsImageFilter() + stats.Execute(mask_uint) + # Get the bounding box starting coordinates and size + xstart, ystart, zstart, xsize, ysize, zsize = stats.GetBoundingBox(1) + + # Ensure minimum size of 4 pixels along each dimension + xsize = max(xsize, min_dim_size) + ysize = max(ysize, min_dim_size) + zsize = max(zsize, min_dim_size) + + + # Calculate the maximum coordinate of the bounding box by adding the size to the starting coordinate + xend, yend, zend = xstart + xsize, ystart + ysize, zstart + zsize + + # TODO: Switch to using a class for the bounding box + # min_coord = Coordinate(x=xstart, y=ystart, z=zstart) + # max_coord = Coordinate(x=xstart + xsize, y=ystart + ysize, z=zstart + zsize) + + return xstart, xend, ystart, yend, zstart, zend + + +def check_bounding_box_single_dimension(bb_min_val:int, + bb_max_val:int, + expected_dim:int, + img_dim:int + ) -> tuple[int,int]: + """Check if minimum and maximum values for a single bounding box dimension fall within the same dimension in the image the bounding box was made for. + + Parameters + ---------- + bb_min_val : int + Minimum value for the bounding box dimension. + bb_max_val : int + Maximum value for the bounding box dimension. + expected_dim : int + Expected dimension of the bounding box. + img_dim : int + Dimension of the image the bounding box was made for. + + Returns + ------- + bb_min_val : int + Updated minimum value for the bounding box dimension. + bb_max_val : int + Updated maximum value for the bounding box dimension. + + Examples + -------- + >>> check_bounding_box_single_dimension(0, 10, 20, 30) + (0, 10) + >>> check_bounding_box_single_dimension(30, 40, 10, 30) + (20, 30) + >>> check_bounding_box_single_dimension(bb_x_min, bb_x_max, expected_dim_x, img_dim_x) + """ + # Check if the minimum bounding box value is outside the image + if bb_min_val < 0: + # Set the minimum value to 0 (edge of image) and the max value to the minimum of the expected dimension or edge of image + bb_min_val, bb_max_val = 0, min(expected_dim, img_dim) + + # Check if the maximum bounding box value is outside the image + if bb_max_val > img_dim: + # Set the minimum value to the maximum of the image dimension or edge of image and the max value to the edge of image + bb_min_val, bb_max_val = max(0, img_dim - expected_dim), img_dim + + return bb_min_val, bb_max_val + + + +def apply_bounding_box_limits(image:sitk.Image, + bounding_box:tuple[int,int,int,int,int,int], + expected_dimensions:tuple[int,int,int] + ) -> np.ndarray: + """Check that bounding box coordinates are within the image dimensions. If not, move bounding box to the edge of the image and expand to expected dimension. + + Parameters + ---------- + image : sitk.Image + Image to check the bounding box coordinates against. + bounding_box : tuple[int,int,int,int,int,int] + Bounding box to check the coordinates of. + expected_dimensions : tuple[int,int,int] + Expected dimensions of the bounding box. Used if the bounding box needs to be shifted to the edge of the image. + + Returns + ------- + min_x, min_y, min_z, max_x, max_y, max_z : tuple[int,int,int,int,int,int] + Updated bounding box coordinates. + """ + # Get the size of the image to use to determine if crop dimensions are larger than the image + img_x, img_y, img_z = image.GetSize() + + # Extract the bounding box coordinates + min_x, max_x, min_y, max_y, min_z, max_z = bounding_box + + # Check each bounding box dimensions coordinates and move to image edge if not within image + min_x, max_x = check_bounding_box_single_dimension(min_x, max_x, expected_dimensions[0], img_x) + min_y, max_y = check_bounding_box_single_dimension(min_y, max_y, expected_dimensions[1], img_y) + min_z, max_z = check_bounding_box_single_dimension(min_z, max_z, expected_dimensions[2], img_z) + + return min_x, max_x, min_y, max_y, min_z, max_z + + + +def find_centroid(mask:sitk.Image) -> np.ndarray: + """Find the centroid of a region of interest (ROI) in a given binary mask image. + + Parameters + ---------- + mask : sitk.Image + Mask image to find the centroid within. + + Returns + ------- + centroid : np.ndarray + Numpy array containing the coordinates of the ROI centroid. + """ + # Convert the mask to a uint8 image + mask_uint = sitk.Cast(mask, sitk.sitkUInt8) + stats = sitk.LabelShapeStatisticsImageFilter() + stats.Execute(mask_uint) + # Get the centroid coordinates as a physical point in the mask + centroid_coords = stats.GetCentroid(1) + # Convert the physical point to an index in the mask array + centroid_idx = mask.TransformPhysicalPointToIndex(centroid_coords) + return centroid_idx + + + +def crop_to_centroid(image:sitk.Image, + centroid:tuple, + crop_dimensions:tuple, + ) -> sitk.Image: + """Crop an image centered on the centroid with specified crop dimension. No resizing/resampling is performed. + + Parameters + ---------- + image : sitk.Image + Image to crop. + centroid : tuple + Tuple of integers representing the centroid of the image to crop. Must have the same number of dimensions as the image. + crop_dimensions : tuple + Tuple of integers representing the dimensions to crop the image to. Must have the same number of dimensions as the image. + + Returns + ------- + cropped_image : sitk.Image + Cropped image. + """ + # Check that the number of dimensions in the crop dimensions matches the number of dimensions in the image + validate_new_dimensions(image, crop_dimensions) + + # Check that the centroid dimensions match the image dimensions + validate_new_dimensions(image, centroid) + + min_x = int(centroid[0] - crop_dimensions[0] // 2) + max_x = int(centroid[0] + crop_dimensions[0] // 2) + min_y = int(centroid[1] - crop_dimensions[1] // 2) + max_y = int(centroid[1] + crop_dimensions[1] // 2) + min_z = int(centroid[2] - crop_dimensions[2] // 2) + max_z = int(centroid[2] + crop_dimensions[2] // 2) + + # Test if bounding box coordinates are within the image, move to image edge if not + min_x, min_y, min_z, max_x, max_y, max_z = apply_bounding_box_limits(image, + bounding_box = [min_x, min_y, min_z, max_x, max_y, max_z], + expected_dimensions = crop_dimensions) + + return image[min_x:max_x, min_y:max_y, min_z:max_z] + + + +def crop_to_bounding_box(image:sitk.Image, + bounding_box:tuple[int,int,int,int,int,int], + resize_dimensions:tuple[int,int,int] + ) -> sitk.Image: + """Crop an image to a given bounding box and resize to a specified crop dimensions. + + Parameters + ---------- + image : sitk.Image + Image to crop. + bounding_box : tuple[int,int,int,int,int,int] + Bounding box to crop the image to. The order is (min_x, min_y, min_z, max_x, max_y, max_z). + resize_dimensions : tuple[int,int,int] + Dimensions to resize the image to. + + Returns + ------- + cropped_image : sitk.Image + Cropped image. + """ + # Check that the number of dimensions in the crop dimensions matches the number of dimensions in the image + validate_new_dimensions(image, resize_dimensions) + + # Check that the number of bounding box dimensions match the image dimensions + validate_new_dimensions(image, int(len(bounding_box)/2)) + + # Get bounding box dimensions for limit testing + bounding_box_dimensions = np.array(bounding_box[3:]) - np.array(bounding_box[:3]) + + # Test if bounding box coordinates are within the image, move to image edge if not + min_x, max_x, min_y, max_y, min_z, max_z = apply_bounding_box_limits(image, bounding_box, bounding_box_dimensions) + + # Crop image to the bounding box + img_crop = image[min_x:max_x, min_y:max_y, min_z:max_z] + # Resample the image to the new dimensions and spacing + img_crop = resize_image(img_crop, resize_dimensions) + return img_crop + + + +def crop_to_maxdim_cube(image:sitk.Image, + bounding_box:tuple[int,int,int,int,int,int], + resize_dimensions:tuple[int,int,int] + ) -> sitk.Image: + """ + Crop given image to a cube based on the max dim from a bounding box and resize to specified input size. + + Parameters + ---------- + image : sitk.Image + Image to crop. + bounding_box : tuple[int,int,int,int,int,int] + Bounding box to find maximum dimension from. The order is (min_x, min_y, min_z, max_x, max_y, max_z). + resize_dimensions : tuple[int,int,int] + Crop dimensions to resize the image to. + + Returns + ------- + sitk.Image: The cropped and resized image. + """ + # Check that the number of dimensions in the crop dimensions matches the number of dimensions in the image + validate_new_dimensions(image, resize_dimensions) + + # Check that the number of bounding box dimensions match the image dimensions + validate_new_dimensions(image, len(bounding_box)//2) + + # Extract out the bounding box coordinates + min_x, max_x, min_y, max_y, min_z, max_z = bounding_box + + # Get maximum dimension of bounding box + max_dim = max(max_x - min_x, max_y - min_y, max_z - min_z) + mean_x = int((max_x + min_x) // 2) + mean_y = int((max_y + min_y) // 2) + mean_z = int((max_z + min_z) // 2) + + # define new bounding boxes based on the maximum dimension of ROI bounding box + min_x = int(mean_x - max_dim // 2) + max_x = int(mean_x + max_dim // 2) + min_y = int(mean_y - max_dim // 2) + max_y = int(mean_y + max_dim // 2) + min_z = int(mean_z - max_dim // 2) + max_z = int(mean_z + max_dim // 2) + + # Test if bounding box coordinates are within the image, move to image edge if not + min_x, max_x, min_y, max_y, min_z, max_z = apply_bounding_box_limits(image, + bounding_box = [min_x, max_x, min_y, max_y, min_z, max_z], + expected_dimensions = [max_dim, max_dim, max_dim]) + # Crop image to the cube bounding box + img_crop = image[min_x:max_x, min_y:max_y, min_z:max_z] + # Resample the image to the new dimensions and spacing + img_crop = resize_image(img_crop, resize_dimensions) + return img_crop + + + +def crop_image_to_mask(image:sitk.Image, + mask:sitk.Image, + crop_method:Literal["bounding_box", "centroid", "cube"], + resize_dimensions:tuple[int,int,int] + ) -> sitk.Image: + """Crop an image to a mask and resize to a specified crop dimensions. + + Parameters + ---------- + image : sitk.Image + Image to crop. + mask : sitk.Image + Mask to crop the image to. + crop_method : str, optional + Method to use to crop the image to the mask. Must be one of "bounding_box", "centroid", or "cube". + resize_dimensions : tuple[int,int,int] + Dimensions to resize the image to. + + Returns + ------- + cropped_image : sitk.Image + Cropped image. + """ + match crop_method: + case "bbox": + bbox_coords = find_bounding_box(mask) + cropped_image = crop_to_bounding_box(image, bbox_coords, resize_dimensions) + + case "centroid": + centroid = find_centroid(mask) + cropped_image = crop_to_centroid(image, centroid, resize_dimensions) + + case "cube": + bbox_coords = find_bounding_box(mask) + cropped_image = crop_to_maxdim_cube(image, bbox_coords, resize_dimensions) + + return cropped_image \ No newline at end of file From 5a6fe99ac1d9a3f889b175f3fce3231a78998762 Mon Sep 17 00:00:00 2001 From: Katy Scott Date: Mon, 20 Jan 2025 16:24:04 -0500 Subject: [PATCH 24/47] style: sort imports for ruff --- src/readii/process/images/crop.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/readii/process/images/crop.py b/src/readii/process/images/crop.py index 45612d6..0e49424 100644 --- a/src/readii/process/images/crop.py +++ b/src/readii/process/images/crop.py @@ -1,12 +1,12 @@ +from typing import Literal + import numpy as np import SimpleITK as sitk from imgtools.ops.functional import resample from readii.utils import logger -from typing import Literal - def validate_new_dimensions(image:sitk.Image, new_dimensions:tuple | int From 857defe706df6fd5c58bece32b916d691d516ebb Mon Sep 17 00:00:00 2001 From: Katy Scott Date: Mon, 20 Jan 2025 16:24:16 -0500 Subject: [PATCH 25/47] feat: add init file for process/images --- src/readii/process/images/__init__.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 src/readii/process/images/__init__.py diff --git a/src/readii/process/images/__init__.py b/src/readii/process/images/__init__.py new file mode 100644 index 0000000..d598527 --- /dev/null +++ b/src/readii/process/images/__init__.py @@ -0,0 +1,27 @@ +"""Module for processing and manipulating images.""" + +from .crop import ( + apply_bounding_box_limits, + check_bounding_box_single_dimension, + crop_image_to_mask, + crop_to_bounding_box, + crop_to_centroid, + crop_to_maxdim_cube, + find_bounding_box, + find_centroid, + resize_image, + validate_new_dimensions, +) + +__all__ = [ + "crop_image_to_mask", + "find_bounding_box", + "find_centroid", + "resize_image", + "validate_new_dimensions", + "apply_bounding_box_limits", + "check_bounding_box_single_dimension", + "crop_to_maxdim_cube", + "crop_to_bounding_box", + "crop_to_centroid", +] \ No newline at end of file From 2ce580f1a3df19007ddd05fe9be67b370848237a Mon Sep 17 00:00:00 2001 From: Katy Scott Date: Mon, 20 Jan 2025 16:24:30 -0500 Subject: [PATCH 26/47] feat: add Bounding Box class for eventual use in crop methods --- .../process/images/utils/bounding_box.py | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 src/readii/process/images/utils/bounding_box.py diff --git a/src/readii/process/images/utils/bounding_box.py b/src/readii/process/images/utils/bounding_box.py new file mode 100644 index 0000000..5e57421 --- /dev/null +++ b/src/readii/process/images/utils/bounding_box.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass +class Point3D: + """Represent a point in 3D space.""" + + x: int + y: int + z: int + + @property + def as_tuple(self) -> tuple[int, int, int]: + """Return the point as a tuple.""" + return self.x, self.y, self.z + + def __add__(self, other: Point3D) -> Point3D: + """Add two points.""" + return Point3D(x=self.x + other.x, y=self.y + other.y, z=self.z + other.z) + + def __sub__(self, other: Point3D) -> Point3D: + """Subtract two points.""" + return Point3D(x=self.x - other.x, y=self.y - other.y, z=self.z - other.z) + + +@dataclass +class Size3D(Point3D): + """Represent the size of a 3D object using its width, height, and depth.""" + + pass + + +@dataclass +class Coordinate(Point3D): + """Represent a coordinate in 3D space.""" + + pass + + +@dataclass +class Centroid(Coordinate): + """Represent the centroid of a region in 3D space. + + A centroid is simply a coordinate in 3D space that represents + the center of mass of a region in an image. It is represented + by its x, y, and z coordinates. + + Attributes + ---------- + x : int + y : int + z : int + """ + + pass \ No newline at end of file From 1f023294fbb2504444ee49b9793e41ab09f9b211 Mon Sep 17 00:00:00 2001 From: Katy Scott Date: Mon, 20 Jan 2025 16:35:28 -0500 Subject: [PATCH 27/47] feat: add pyradiomics cropping and crop the mask in all crop methods as well --- src/readii/process/images/crop.py | 60 ++++++++++++++++++++++++++++--- 1 file changed, 55 insertions(+), 5 deletions(-) diff --git a/src/readii/process/images/crop.py b/src/readii/process/images/crop.py index 0e49424..027fe2e 100644 --- a/src/readii/process/images/crop.py +++ b/src/readii/process/images/crop.py @@ -4,7 +4,9 @@ import numpy as np import SimpleITK as sitk from imgtools.ops.functional import resample +from radiomics import imageoperations +from readii.image_processing import getROIVoxelLabel from readii.utils import logger @@ -360,19 +362,59 @@ def crop_to_maxdim_cube(image:sitk.Image, +def crop_with_pyradiomics(image:sitk.Image, + mask:sitk.Image, + mask_label:int = None + ) -> tuple[sitk.Image, sitk.Image]: + """Crop an image to a bounding box around a region of interest in the mask using PyRadiomics functions. + + Parameters + ---------- + image : sitk.Image + Image to crop. + mask : sitk.Image + Mask to crop the image to. + mask_label : int, optional + Label of the region of interest to crop to in the mask. If not provided, will use the label of the first non-zero voxel in the mask. + + Returns + ------- + image_crop : sitk.Image + Cropped image. + mask_crop : sitk.Image + Cropped mask. + """ + # Get the label of the region of interest in the mask if not provided + if not mask_label: + mask_label = getROIVoxelLabel(mask) + + # Check that CT and segmentation correspond, segmentationLabel is present, and dimensions match + bounding_box, corrected_mask = imageoperations.checkMask(image, mask, label=mask_label) + + # Update the mask if correction was generated by checkMask + if not corrected_mask: + mask = corrected_mask + + # Crop the image and mask to the bounding box + image_crop, mask_crop = imageoperations.cropToTumorMask(image, mask, bounding_box) + + return image_crop, mask_crop + + + def crop_image_to_mask(image:sitk.Image, mask:sitk.Image, - crop_method:Literal["bounding_box", "centroid", "cube"], + crop_method:Literal["bounding_box", "centroid", "cube", "pyradiomics"], resize_dimensions:tuple[int,int,int] - ) -> sitk.Image: - """Crop an image to a mask and resize to a specified crop dimensions. + ) -> tuple[sitk.Image, sitk.Image]: + """Crop an image and mask to an ROI in the mask and resize to a specified crop dimensions. Parameters ---------- image : sitk.Image Image to crop. mask : sitk.Image - Mask to crop the image to. + Mask to crop the image to. Will also be cropped. crop_method : str, optional Method to use to crop the image to the mask. Must be one of "bounding_box", "centroid", or "cube". resize_dimensions : tuple[int,int,int] @@ -382,18 +424,26 @@ def crop_image_to_mask(image:sitk.Image, ------- cropped_image : sitk.Image Cropped image. + cropped_mask : sitk.Image + Cropped mask. """ match crop_method: case "bbox": bbox_coords = find_bounding_box(mask) cropped_image = crop_to_bounding_box(image, bbox_coords, resize_dimensions) + cropped_mask = crop_to_bounding_box(mask, bbox_coords, resize_dimensions) case "centroid": centroid = find_centroid(mask) cropped_image = crop_to_centroid(image, centroid, resize_dimensions) + cropped_mask = crop_to_centroid(mask, centroid, resize_dimensions) case "cube": bbox_coords = find_bounding_box(mask) cropped_image = crop_to_maxdim_cube(image, bbox_coords, resize_dimensions) + cropped_mask = crop_to_maxdim_cube(mask, bbox_coords, resize_dimensions) + + case "pyradiomics": + cropped_image, cropped_mask = crop_with_pyradiomics(image, mask) - return cropped_image \ No newline at end of file + return cropped_image, cropped_mask \ No newline at end of file From 1f5fd982511474d37a3307650d3020de78afd2f4 Mon Sep 17 00:00:00 2001 From: Katy Scott Date: Mon, 20 Jan 2025 16:56:54 -0500 Subject: [PATCH 28/47] fix: apply fixes for instance checking, order of coordinates in crop to centroid, and mask update in crop with pyradiomics --- src/readii/process/images/crop.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/readii/process/images/crop.py b/src/readii/process/images/crop.py index 027fe2e..ff4223c 100644 --- a/src/readii/process/images/crop.py +++ b/src/readii/process/images/crop.py @@ -28,13 +28,13 @@ def validate_new_dimensions(image:sitk.Image, If the new dimensions are not valid for the image. """ # Check that the number of dimensions in the new dimensions matches the number of dimensions in the image - if type(new_dimensions) == tuple: + if isinstance(new_dimensions, tuple): if len(new_dimensions) != image.GetDimension(): msg = f"Number of dimensions in new_dimensions ({len(new_dimensions)}) does not match the number of dimensions in the image ({image.GetDimension()})." logger.exception(msg) raise ValueError(msg) - elif type(new_dimensions) == int: + elif isinstance(new_dimensions, int): if new_dimensions != image.GetDimension(): msg = f"Number of dimensions in new_dimensions ({new_dimensions}) does not match the number of dimensions in the image ({image.GetDimension()})." logger.exception(msg) @@ -259,8 +259,8 @@ def crop_to_centroid(image:sitk.Image, max_z = int(centroid[2] + crop_dimensions[2] // 2) # Test if bounding box coordinates are within the image, move to image edge if not - min_x, min_y, min_z, max_x, max_y, max_z = apply_bounding_box_limits(image, - bounding_box = [min_x, min_y, min_z, max_x, max_y, max_z], + min_x, max_x, min_y, max_y, min_z, max_z = apply_bounding_box_limits(image, + bounding_box = [min_x, max_x, min_y, max_y, min_z, max_z], expected_dimensions = crop_dimensions) return image[min_x:max_x, min_y:max_y, min_z:max_z] @@ -392,7 +392,7 @@ def crop_with_pyradiomics(image:sitk.Image, bounding_box, corrected_mask = imageoperations.checkMask(image, mask, label=mask_label) # Update the mask if correction was generated by checkMask - if not corrected_mask: + if corrected_mask: mask = corrected_mask # Crop the image and mask to the bounding box From 4b9b11cdf99cb981cab4be0eba7dfbd5a3128bb0 Mon Sep 17 00:00:00 2001 From: Katy Scott Date: Mon, 20 Jan 2025 17:12:48 -0500 Subject: [PATCH 29/47] test: start test functions for crop, have test for crop image to mask so far --- tests/process/images/test_crop.py | 62 +++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 tests/process/images/test_crop.py diff --git a/tests/process/images/test_crop.py b/tests/process/images/test_crop.py new file mode 100644 index 0000000..5781a8d --- /dev/null +++ b/tests/process/images/test_crop.py @@ -0,0 +1,62 @@ +from readii.process.images.crop import ( + apply_bounding_box_limits, + check_bounding_box_single_dimension, + crop_image_to_mask, + crop_to_bounding_box, + crop_to_centroid, + crop_to_maxdim_cube, + find_bounding_box, + find_centroid, + resize_image, + validate_new_dimensions, +) + +from readii.image_processing import loadSegmentation + +import pytest +import SimpleITK as sitk + + +@pytest.fixture +def nsclcCT(): + return "tests/NSCLC_Radiogenomics/R01-001/09-06-1990-NA-CT_CHEST_ABD_PELVIS_WITH_CON-98785/3.000000-THORAX_1.0_B45f-95741" + +@pytest.fixture +def nsclcSEG(): + return "tests/NSCLC_Radiogenomics/R01-001/09-06-1990-NA-CT_CHEST_ABD_PELVIS_WITH_CON-98785/1000.000000-3D_Slicer_segmentation_result-67652/1-1.dcm" + +@pytest.fixture +def lung4D_ct_path(): + return "tests/4D-Lung/113_HM10395/11-26-1999-NA-p4-13296/1.000000-P4P113S303I10349 Gated 40.0B-29543" + +@pytest.fixture +def lung4D_rt_path(): + return "tests/4D-Lung/113_HM10395/11-26-1999-NA-p4-13296/1.000000-P4P113S303I10349 Gated 40.0B-47.35/1-1.dcm" + +@pytest.fixture +def lung4D_image(lung4D_ct_path): + return sitk.ReadImage(lung4D_ct_path) + +@pytest.fixture +def lung4D_mask(lung4D_ct_path, lung4D_rt_path): + segDictionary = loadSegmentation(lung4D_rt_path, modality = 'RTSTRUCT', + baseImageDirPath = lung4D_ct_path, roiNames = 'Tumor_c.*') + return segDictionary['Tumor_c40'] + + +@pytest.mark.parametrize( + "crop_method, expected_size", + [ + ("bounding_box", (50, 50, 50)), + ("centroid", (50, 50, 50)), + ("cube", (50, 50, 50)), + ("pyradiomics", (22, 28, 14)), + ] +) +def test_crop_image_to_mask_methods(lung4d_image, lung4D_mask, crop_method, expected_size, request): + """Test cropping image to mask with different methods""" + cropped_image, cropped_mask = crop_image_to_mask(lung4d_image, lung4D_mask, crop_method, resize_dimensions=request.getfixturevalue(crop_method)) + assert cropped_image.GetSize() == expected_size, \ + f"Cropped image size is incorrect, expected {expected_size}, got {cropped_image.GetSize()}" + assert cropped_mask.GetSize() == expected_size, \ + f"Cropped mask size is incorrect, expected {expected_size}, got {cropped_mask.GetSize()}" From 23a1c34133e973fdddc29aeb82d43c5043e857c2 Mon Sep 17 00:00:00 2001 From: Jermiah Date: Tue, 21 Jan 2025 01:05:50 +0000 Subject: [PATCH 30/47] fix: add error handling for invalid crop methods in crop_image_to_mask function --- src/readii/process/images/crop.py | 4 ++ tests/process/images/test_crop.py | 102 ++++++++++++++++++++++++------ 2 files changed, 88 insertions(+), 18 deletions(-) diff --git a/src/readii/process/images/crop.py b/src/readii/process/images/crop.py index ff4223c..f19e157 100644 --- a/src/readii/process/images/crop.py +++ b/src/readii/process/images/crop.py @@ -445,5 +445,9 @@ def crop_image_to_mask(image:sitk.Image, case "pyradiomics": cropped_image, cropped_mask = crop_with_pyradiomics(image, mask) + + case _: + msg = f"Invalid crop method: {crop_method}. Must be one of 'bbox', 'centroid', 'cube', or 'pyradiomics'." + raise ValueError(msg) return cropped_image, cropped_mask \ No newline at end of file diff --git a/tests/process/images/test_crop.py b/tests/process/images/test_crop.py index 5781a8d..de355ef 100644 --- a/tests/process/images/test_crop.py +++ b/tests/process/images/test_crop.py @@ -1,3 +1,7 @@ +import pytest +import SimpleITK as sitk + +from readii.image_processing import loadDicomSITK, loadSegmentation from readii.process.images.crop import ( apply_bounding_box_limits, check_bounding_box_single_dimension, @@ -11,52 +15,114 @@ validate_new_dimensions, ) -from readii.image_processing import loadSegmentation - -import pytest -import SimpleITK as sitk - @pytest.fixture def nsclcCT(): return "tests/NSCLC_Radiogenomics/R01-001/09-06-1990-NA-CT_CHEST_ABD_PELVIS_WITH_CON-98785/3.000000-THORAX_1.0_B45f-95741" + @pytest.fixture def nsclcSEG(): return "tests/NSCLC_Radiogenomics/R01-001/09-06-1990-NA-CT_CHEST_ABD_PELVIS_WITH_CON-98785/1000.000000-3D_Slicer_segmentation_result-67652/1-1.dcm" + @pytest.fixture def lung4D_ct_path(): return "tests/4D-Lung/113_HM10395/11-26-1999-NA-p4-13296/1.000000-P4P113S303I10349 Gated 40.0B-29543" + @pytest.fixture def lung4D_rt_path(): return "tests/4D-Lung/113_HM10395/11-26-1999-NA-p4-13296/1.000000-P4P113S303I10349 Gated 40.0B-47.35/1-1.dcm" + @pytest.fixture def lung4D_image(lung4D_ct_path): - return sitk.ReadImage(lung4D_ct_path) + return loadDicomSITK(lung4D_ct_path) + @pytest.fixture def lung4D_mask(lung4D_ct_path, lung4D_rt_path): - segDictionary = loadSegmentation(lung4D_rt_path, modality = 'RTSTRUCT', - baseImageDirPath = lung4D_ct_path, roiNames = 'Tumor_c.*') - return segDictionary['Tumor_c40'] + segDictionary = loadSegmentation( + lung4D_rt_path, + modality="RTSTRUCT", + baseImageDirPath=lung4D_ct_path, + roiNames="Tumor_c.*", + ) + return segDictionary["Tumor_c40"] @pytest.mark.parametrize( "crop_method, expected_size", [ - ("bounding_box", (50, 50, 50)), + ("bbox", (50, 50, 50)), ("centroid", (50, 50, 50)), ("cube", (50, 50, 50)), - ("pyradiomics", (22, 28, 14)), - ] + # ("pyradiomics", (22, 28, 14)), + ], +) +def test_crop_image_to_mask_methods( + lung4D_image, lung4D_mask, crop_method, expected_size, resize_dimensions=(50, 50, 50) +): + """Test cropping image to mask with different methods""" + cropped_image, cropped_mask = crop_image_to_mask( + lung4D_image, + lung4D_mask, + crop_method, + resize_dimensions, + ) + assert ( + cropped_image.GetSize() == expected_size + ), f"Cropped image size is incorrect, expected {expected_size}, got {cropped_image.GetSize()}" + assert ( + cropped_mask.GetSize() == expected_size + ), f"Cropped mask size is incorrect, expected {expected_size}, got {cropped_mask.GetSize()}" + + +@pytest.fixture +def complex_image_and_mask(): + # The image and image are a 3D image with size 100x100x100 + # however, the ROI in the mask is 10x20x30 + + image = sitk.Image(100, 100, 100, sitk.sitkInt16) + mask = sitk.Image(100, 100, 100, sitk.sitkUInt8) + + mask[85:95, 70:90, 60:90] = 1 + + return image, mask + + +@pytest.mark.parametrize( + "crop_method", + [ + "bbox", + "centroid", + "cube", + # "pyradiomics", + ], +) +@pytest.mark.parametrize( + "resize_dimensions, expected_size", + [ + ((50, 50, 50), (50, 50, 50)), + ((100, 100, 100), (100, 100, 100)), + ((200, 200, 200), (200, 200, 200)), # this only fails for centroid + ], ) -def test_crop_image_to_mask_methods(lung4d_image, lung4D_mask, crop_method, expected_size, request): +def test_crop_image_to_mask_methods_complex( + complex_image_and_mask, crop_method, resize_dimensions, expected_size +): """Test cropping image to mask with different methods""" - cropped_image, cropped_mask = crop_image_to_mask(lung4d_image, lung4D_mask, crop_method, resize_dimensions=request.getfixturevalue(crop_method)) - assert cropped_image.GetSize() == expected_size, \ - f"Cropped image size is incorrect, expected {expected_size}, got {cropped_image.GetSize()}" - assert cropped_mask.GetSize() == expected_size, \ - f"Cropped mask size is incorrect, expected {expected_size}, got {cropped_mask.GetSize()}" + image, mask = complex_image_and_mask + cropped_image, cropped_mask = crop_image_to_mask( + image, + mask, + crop_method, + resize_dimensions=resize_dimensions, + ) + assert ( + cropped_image.GetSize() == expected_size + ), f"Cropped image size is incorrect, expected {expected_size}, got {cropped_image.GetSize()}" + assert ( + cropped_mask.GetSize() == expected_size + ), f"Cropped mask size is incorrect, expected {expected_size}, got {cropped_mask.GetSize()}" From b1e03b6069954e5f22c6de2e3b3a96ff7ff45020 Mon Sep 17 00:00:00 2001 From: Jermiah Date: Tue, 21 Jan 2025 14:07:40 +0000 Subject: [PATCH 31/47] test: add some parameterized tests for quick testing of the find - bounding box and centroid --- tests/process/images/test_crop.py | 110 ++++++++++++++++++++++-------- 1 file changed, 80 insertions(+), 30 deletions(-) diff --git a/tests/process/images/test_crop.py b/tests/process/images/test_crop.py index de355ef..9c07a3b 100644 --- a/tests/process/images/test_crop.py +++ b/tests/process/images/test_crop.py @@ -62,7 +62,11 @@ def lung4D_mask(lung4D_ct_path, lung4D_rt_path): ], ) def test_crop_image_to_mask_methods( - lung4D_image, lung4D_mask, crop_method, expected_size, resize_dimensions=(50, 50, 50) + lung4D_image, + lung4D_mask, + crop_method, + expected_size, + resize_dimensions=(50, 50, 50), ): """Test cropping image to mask with different methods""" cropped_image, cropped_mask = crop_image_to_mask( @@ -79,50 +83,96 @@ def test_crop_image_to_mask_methods( ), f"Cropped mask size is incorrect, expected {expected_size}, got {cropped_mask.GetSize()}" +#################################################################################################### +# Parameterized tests for find_centroid and find_bounding_box + + @pytest.fixture -def complex_image_and_mask(): - # The image and image are a 3D image with size 100x100x100 - # however, the ROI in the mask is 10x20x30 +def image_and_mask_with_roi(request, label_value: int = 1): + """ + Fixture to create a 3D image and mask with a specified region of interest (ROI). + This fixture is used indirectly in parameterized tests via the `indirect` keyword + in `pytest.mark.parametrize`. The `request.param` provides the ROI coordinates, which + are used to define the region of interest in the mask. The ROI is specified as a + 6-tuple (x_min, x_max, y_min, y_max, z_min, z_max). + + """ + # Create a 3D image image = sitk.Image(100, 100, 100, sitk.sitkInt16) mask = sitk.Image(100, 100, 100, sitk.sitkUInt8) - mask[85:95, 70:90, 60:90] = 1 + # Unpack ROI from the request parameter and apply it to the mask + roi = request.param + mask[roi[0] : roi[1], roi[2] : roi[3], roi[4] : roi[5]] = label_value return image, mask +def test_find_bounding_box_and_centroid_bad_label(): + mask = sitk.Image(100, 100, 100, sitk.sitkUInt8) + mask[10:20, 10:20, 10:20] = 2 + + with pytest.raises(RuntimeError): + find_bounding_box(mask) + + with pytest.raises(RuntimeError): + find_centroid(mask) + + +# Test cases for find_bounding_box + + @pytest.mark.parametrize( - "crop_method", + # First parameter passed indirectly via the fixture + # Second parameter is the expected bounding box + "image_and_mask_with_roi, expected_bbox", [ - "bbox", - "centroid", - "cube", - # "pyradiomics", + # + # x_min, x_max, y_min, y_max, z_min, z_max + # + # Simple case: Perfect bounding box + ((85, 95, 70, 90, 60, 90), (85, 95, 70, 90, 60, 90)), + # Complex case: Non-standard bounding box dimensions + ((32, 68, 53, 77, 10, 48), (32, 68, 53, 77, 10, 48)), + # Single-plane ROI: ROI in only one slice + # since min_dim_size is 4, the ROI is expanded to 4 in if a side is too small + # x_max is expanded to 24 + ((20, 21, 30, 60, 40, 80), (20, 24, 30, 60, 40, 80)), + # Minimum size ROI + # x_max is expanded to 49, y_max is expanded to 14, z_max is expanded to 9 + ((45, 46, 10, 12, 5, 6), (45, 49, 10, 14, 5, 9)), ], + indirect=["image_and_mask_with_roi"], # Use the fixture indirectly ) +def test_find_bounding_box(image_and_mask_with_roi, expected_bbox): + _, mask = image_and_mask_with_roi + bounding_box = find_bounding_box(mask, min_dim_size=4) + assert ( + bounding_box == expected_bbox + ), f"Bounding box is incorrect, expected {expected_bbox}, got {bounding_box}" + + +# Test cases for find_centroid + + @pytest.mark.parametrize( - "resize_dimensions, expected_size", + "image_and_mask_with_roi, expected_centroid", [ - ((50, 50, 50), (50, 50, 50)), - ((100, 100, 100), (100, 100, 100)), - ((200, 200, 200), (200, 200, 200)), # this only fails for centroid + # Simple case: Perfect centroid + ((85, 95, 70, 90, 60, 90), (90, 80, 75)), + # Complex case: Non-standard dimensions + ((32, 68, 53, 77, 10, 48), (50, 65, 29)), + # Single-plane ROI + ((20, 21, 30, 60, 40, 80), (20, 45, 60)), + # Minimum size ROI + ((45, 46, 10, 12, 5, 6), (45, 11, 5)), ], + indirect=["image_and_mask_with_roi"], # Use the fixture indirectly ) -def test_crop_image_to_mask_methods_complex( - complex_image_and_mask, crop_method, resize_dimensions, expected_size -): - """Test cropping image to mask with different methods""" - image, mask = complex_image_and_mask - cropped_image, cropped_mask = crop_image_to_mask( - image, - mask, - crop_method, - resize_dimensions=resize_dimensions, - ) - assert ( - cropped_image.GetSize() == expected_size - ), f"Cropped image size is incorrect, expected {expected_size}, got {cropped_image.GetSize()}" +def test_find_centroid(image_and_mask_with_roi, expected_centroid): + _, mask = image_and_mask_with_roi + centroid = find_centroid(mask) assert ( - cropped_mask.GetSize() == expected_size - ), f"Cropped mask size is incorrect, expected {expected_size}, got {cropped_mask.GetSize()}" + centroid == expected_centroid + ), f"Centroid is incorrect, expected {expected_centroid}, got {centroid}" From 596b497422ca5e7c6c69272f62ff36f26b69c9ad Mon Sep 17 00:00:00 2001 From: Katy Scott Date: Tue, 21 Jan 2025 14:04:19 -0500 Subject: [PATCH 32/47] refactor: replace resize_image with resize function from med-imagetools --- src/readii/process/images/__init__.py | 2 -- src/readii/process/images/crop.py | 37 +++------------------------ tests/process/images/test_crop.py | 1 - 3 files changed, 3 insertions(+), 37 deletions(-) diff --git a/src/readii/process/images/__init__.py b/src/readii/process/images/__init__.py index d598527..2d55d48 100644 --- a/src/readii/process/images/__init__.py +++ b/src/readii/process/images/__init__.py @@ -9,7 +9,6 @@ crop_to_maxdim_cube, find_bounding_box, find_centroid, - resize_image, validate_new_dimensions, ) @@ -17,7 +16,6 @@ "crop_image_to_mask", "find_bounding_box", "find_centroid", - "resize_image", "validate_new_dimensions", "apply_bounding_box_limits", "check_bounding_box_single_dimension", diff --git a/src/readii/process/images/crop.py b/src/readii/process/images/crop.py index f19e157..af1b4a0 100644 --- a/src/readii/process/images/crop.py +++ b/src/readii/process/images/crop.py @@ -3,7 +3,7 @@ import numpy as np import SimpleITK as sitk -from imgtools.ops.functional import resample +from imgtools.ops.functional import resize from radiomics import imageoperations from readii.image_processing import getROIVoxelLabel @@ -44,37 +44,6 @@ def validate_new_dimensions(image:sitk.Image, msg = "New dimensions must be a tuple of integers or a single integer." logger.exception(msg) raise ValueError(msg) - - - -def resize_image(image:sitk.Image, - resize_dimensions:tuple - ) -> sitk.Image: - """Resize an image to specified dimensions via linear interpolation. - - Parameters - ---------- - image : sitk.Image - Image to resize. - resize_dimensions : tuple - Tuple of integers representing the new dimensions to resize the image to. Must have the same number of dimensions as the image. - - Returns - ------- - resized_image : sitk.Image - Resized image. - """ - validate_new_dimensions(image, resize_dimensions) - - # Calculate the new spacing based on the resized dimensions - original_dimensions = np.array(image.GetSize()) - original_spacing = np.array(image.GetSpacing()) - resized_spacing = original_spacing * original_dimensions / resize_dimensions - - # Resample the image to the new dimensions and spacing - resized_image = resample(image, spacing=resized_spacing, output_size=resize_dimensions) - - return resized_image @@ -302,7 +271,7 @@ def crop_to_bounding_box(image:sitk.Image, # Crop image to the bounding box img_crop = image[min_x:max_x, min_y:max_y, min_z:max_z] # Resample the image to the new dimensions and spacing - img_crop = resize_image(img_crop, resize_dimensions) + img_crop = resize(img_crop, size = resize_dimensions) return img_crop @@ -357,7 +326,7 @@ def crop_to_maxdim_cube(image:sitk.Image, # Crop image to the cube bounding box img_crop = image[min_x:max_x, min_y:max_y, min_z:max_z] # Resample the image to the new dimensions and spacing - img_crop = resize_image(img_crop, resize_dimensions) + img_crop = resize(img_crop, size=resize_dimensions) return img_crop diff --git a/tests/process/images/test_crop.py b/tests/process/images/test_crop.py index 9c07a3b..51cd6dc 100644 --- a/tests/process/images/test_crop.py +++ b/tests/process/images/test_crop.py @@ -11,7 +11,6 @@ crop_to_maxdim_cube, find_bounding_box, find_centroid, - resize_image, validate_new_dimensions, ) From bcb2fbaa0a210dd7b6efc1d0447459cd59630394 Mon Sep 17 00:00:00 2001 From: Katy Scott Date: Tue, 21 Jan 2025 14:09:04 -0500 Subject: [PATCH 33/47] fix: fix return types and input bounding box types to be tuples consistently --- src/readii/process/images/crop.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/readii/process/images/crop.py b/src/readii/process/images/crop.py index af1b4a0..b29d5c2 100644 --- a/src/readii/process/images/crop.py +++ b/src/readii/process/images/crop.py @@ -49,7 +49,7 @@ def validate_new_dimensions(image:sitk.Image, def find_bounding_box(mask:sitk.Image, min_dim_size:int = 4 - ) -> np.ndarray: + ) -> tuple: """Find the bounding box of a region of interest (ROI) in a given binary mask image. Parameters @@ -137,7 +137,7 @@ def check_bounding_box_single_dimension(bb_min_val:int, def apply_bounding_box_limits(image:sitk.Image, bounding_box:tuple[int,int,int,int,int,int], expected_dimensions:tuple[int,int,int] - ) -> np.ndarray: + ) -> tuple: """Check that bounding box coordinates are within the image dimensions. If not, move bounding box to the edge of the image and expand to expected dimension. Parameters @@ -321,7 +321,7 @@ def crop_to_maxdim_cube(image:sitk.Image, # Test if bounding box coordinates are within the image, move to image edge if not min_x, max_x, min_y, max_y, min_z, max_z = apply_bounding_box_limits(image, - bounding_box = [min_x, max_x, min_y, max_y, min_z, max_z], + bounding_box = (min_x, max_x, min_y, max_y, min_z, max_z), expected_dimensions = [max_dim, max_dim, max_dim]) # Crop image to the cube bounding box img_crop = image[min_x:max_x, min_y:max_y, min_z:max_z] From 107ded7ce7c4f2e70692974d7b4d9113ecf8dea2 Mon Sep 17 00:00:00 2001 From: Katy Scott Date: Tue, 21 Jan 2025 14:10:41 -0500 Subject: [PATCH 34/47] refactor: add None type for mask_label in pyradiomics crop --- src/readii/process/images/crop.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/readii/process/images/crop.py b/src/readii/process/images/crop.py index b29d5c2..bb55a67 100644 --- a/src/readii/process/images/crop.py +++ b/src/readii/process/images/crop.py @@ -331,11 +331,11 @@ def crop_to_maxdim_cube(image:sitk.Image, -def crop_with_pyradiomics(image:sitk.Image, - mask:sitk.Image, - mask_label:int = None - ) -> tuple[sitk.Image, sitk.Image]: - """Crop an image to a bounding box around a region of interest in the mask using PyRadiomics functions. +def crop_with_pyradiomics(image:sitk.Image, + mask:sitk.Image, + mask_label: int | None = None + ) -> tuple[sitk.Image, sitk.Image]: + """Crop an image to a bounding box around a region of interest in the mask using PyRadiomics functions. Parameters ---------- From 0e0ac80f8347bfd37db5a124a13b6d01fc56f431 Mon Sep 17 00:00:00 2001 From: Katy Scott Date: Tue, 21 Jan 2025 14:12:11 -0500 Subject: [PATCH 35/47] feat: make resize_dimensions optional in crop_image_to_mask since pyradiomics doesn't require it --- src/readii/process/images/crop.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/readii/process/images/crop.py b/src/readii/process/images/crop.py index bb55a67..8cc2a1f 100644 --- a/src/readii/process/images/crop.py +++ b/src/readii/process/images/crop.py @@ -1,5 +1,5 @@ -from typing import Literal +from typing import Literal, Optional import numpy as np import SimpleITK as sitk @@ -374,7 +374,7 @@ def crop_with_pyradiomics(image:sitk.Image, def crop_image_to_mask(image:sitk.Image, mask:sitk.Image, crop_method:Literal["bounding_box", "centroid", "cube", "pyradiomics"], - resize_dimensions:tuple[int,int,int] + resize_dimensions:Optional[tuple[int,int,int]] ) -> tuple[sitk.Image, sitk.Image]: """Crop an image and mask to an ROI in the mask and resize to a specified crop dimensions. From 3edf4c4472a10b5abab005cca50a8995e617ce05 Mon Sep 17 00:00:00 2001 From: Katy Scott Date: Tue, 21 Jan 2025 14:32:48 -0500 Subject: [PATCH 36/47] fix: fixed ordering of bounding box tuple so it is consistent throughout --- src/readii/process/images/crop.py | 33 +++++++++++++++---------------- 1 file changed, 16 insertions(+), 17 deletions(-) diff --git a/src/readii/process/images/crop.py b/src/readii/process/images/crop.py index 8cc2a1f..b71826e 100644 --- a/src/readii/process/images/crop.py +++ b/src/readii/process/images/crop.py @@ -62,29 +62,29 @@ def find_bounding_box(mask:sitk.Image, Returns ------- bounding_box : np.ndarray - Numpy array containing the bounding box coordinates around the ROI. + Numpy array containing the bounding box coordinates around the ROI. Order is [min_x, max_x, min_y, max_y, min_z, max_z]. """ # Convert the mask to a uint8 image mask_uint = sitk.Cast(mask, sitk.sitkUInt8) stats = sitk.LabelShapeStatisticsImageFilter() stats.Execute(mask_uint) # Get the bounding box starting coordinates and size - xstart, ystart, zstart, xsize, ysize, zsize = stats.GetBoundingBox(1) + min_x, min_y, min_z, size_x, size_y, size_z = stats.GetBoundingBox(1) # Ensure minimum size of 4 pixels along each dimension - xsize = max(xsize, min_dim_size) - ysize = max(ysize, min_dim_size) - zsize = max(zsize, min_dim_size) + size_x = max(size_x, min_dim_size) + size_y = max(size_y, min_dim_size) + size_z = max(size_z, min_dim_size) # Calculate the maximum coordinate of the bounding box by adding the size to the starting coordinate - xend, yend, zend = xstart + xsize, ystart + ysize, zstart + zsize + max_x, max_y, max_z = min_x + size_x, min_y + size_y, min_z + size_z # TODO: Switch to using a class for the bounding box # min_coord = Coordinate(x=xstart, y=ystart, z=zstart) # max_coord = Coordinate(x=xstart + xsize, y=ystart + ysize, z=zstart + zsize) - return xstart, xend, ystart, yend, zstart, zend + return min_x, max_x, min_y, max_y, min_z, max_z def check_bounding_box_single_dimension(bb_min_val:int, @@ -145,14 +145,14 @@ def apply_bounding_box_limits(image:sitk.Image, image : sitk.Image Image to check the bounding box coordinates against. bounding_box : tuple[int,int,int,int,int,int] - Bounding box to check the coordinates of. + Bounding box to check the coordinates of. Order is [min_x, max_x, min_y, max_y, min_z, max_z]. expected_dimensions : tuple[int,int,int] Expected dimensions of the bounding box. Used if the bounding box needs to be shifted to the edge of the image. Returns ------- min_x, min_y, min_z, max_x, max_y, max_z : tuple[int,int,int,int,int,int] - Updated bounding box coordinates. + Updated bounding box coordinates. Order is [min_x, max_x, min_y, max_y, min_z, max_z]. """ # Get the size of the image to use to determine if crop dimensions are larger than the image img_x, img_y, img_z = image.GetSize() @@ -229,7 +229,7 @@ def crop_to_centroid(image:sitk.Image, # Test if bounding box coordinates are within the image, move to image edge if not min_x, max_x, min_y, max_y, min_z, max_z = apply_bounding_box_limits(image, - bounding_box = [min_x, max_x, min_y, max_y, min_z, max_z], + bounding_box = (min_x, max_x, min_y, max_y, min_z, max_z), expected_dimensions = crop_dimensions) return image[min_x:max_x, min_y:max_y, min_z:max_z] @@ -247,7 +247,7 @@ def crop_to_bounding_box(image:sitk.Image, image : sitk.Image Image to crop. bounding_box : tuple[int,int,int,int,int,int] - Bounding box to crop the image to. The order is (min_x, min_y, min_z, max_x, max_y, max_z). + Bounding box to crop the image to. Order is [min_x, max_x, min_y, max_y, min_z, max_z]. resize_dimensions : tuple[int,int,int] Dimensions to resize the image to. @@ -262,11 +262,10 @@ def crop_to_bounding_box(image:sitk.Image, # Check that the number of bounding box dimensions match the image dimensions validate_new_dimensions(image, int(len(bounding_box)/2)) - # Get bounding box dimensions for limit testing - bounding_box_dimensions = np.array(bounding_box[3:]) - np.array(bounding_box[:3]) - # Test if bounding box coordinates are within the image, move to image edge if not - min_x, max_x, min_y, max_y, min_z, max_z = apply_bounding_box_limits(image, bounding_box, bounding_box_dimensions) + min_x, max_x, min_y, max_y, min_z, max_z = apply_bounding_box_limits(image, + bounding_box, + expected_dimensions=(max_x - min_x, max_y - min_y, max_z - min_z)) # Crop image to the bounding box img_crop = image[min_x:max_x, min_y:max_y, min_z:max_z] @@ -322,7 +321,7 @@ def crop_to_maxdim_cube(image:sitk.Image, # Test if bounding box coordinates are within the image, move to image edge if not min_x, max_x, min_y, max_y, min_z, max_z = apply_bounding_box_limits(image, bounding_box = (min_x, max_x, min_y, max_y, min_z, max_z), - expected_dimensions = [max_dim, max_dim, max_dim]) + expected_dimensions = (max_dim, max_dim, max_dim)) # Crop image to the cube bounding box img_crop = image[min_x:max_x, min_y:max_y, min_z:max_z] # Resample the image to the new dimensions and spacing @@ -385,7 +384,7 @@ def crop_image_to_mask(image:sitk.Image, mask : sitk.Image Mask to crop the image to. Will also be cropped. crop_method : str, optional - Method to use to crop the image to the mask. Must be one of "bounding_box", "centroid", or "cube". + Method to use to crop the image to the mask. Must be one of "bounding_box", "centroid", "cube, or "pyradiomics". resize_dimensions : tuple[int,int,int] Dimensions to resize the image to. From 2f2386542ad59a7141b4c4b8e2ae2ccc1edf3fcd Mon Sep 17 00:00:00 2001 From: Katy Scott Date: Tue, 21 Jan 2025 14:36:10 -0500 Subject: [PATCH 37/47] feat: add check for resize_dimensions existence in crop_image_to_mask --- src/readii/process/images/crop.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/readii/process/images/crop.py b/src/readii/process/images/crop.py index b71826e..32204b1 100644 --- a/src/readii/process/images/crop.py +++ b/src/readii/process/images/crop.py @@ -395,7 +395,11 @@ def crop_image_to_mask(image:sitk.Image, cropped_mask : sitk.Image Cropped mask. """ - match crop_method: + if resize_dimensions is None and crop_method is not "pyradiomics": + msg = f"resize_dimensions is required for crop_method '{crop_method}'." + raise ValueError(msg) + + match crop_method: case "bbox": bbox_coords = find_bounding_box(mask) cropped_image = crop_to_bounding_box(image, bbox_coords, resize_dimensions) From 4630fac213ad42573fb9032fe7f03e05490c8702 Mon Sep 17 00:00:00 2001 From: Katy Scott Date: Tue, 21 Jan 2025 16:39:35 -0500 Subject: [PATCH 38/47] refactor: replace is not with != --- src/readii/process/images/crop.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/readii/process/images/crop.py b/src/readii/process/images/crop.py index 32204b1..2fdfc79 100644 --- a/src/readii/process/images/crop.py +++ b/src/readii/process/images/crop.py @@ -395,7 +395,7 @@ def crop_image_to_mask(image:sitk.Image, cropped_mask : sitk.Image Cropped mask. """ - if resize_dimensions is None and crop_method is not "pyradiomics": + if resize_dimensions is None and crop_method != "pyradiomics": msg = f"resize_dimensions is required for crop_method '{crop_method}'." raise ValueError(msg) From 2b870f9fb3af52b3b8ca488e7a3e0a8e579c7c49 Mon Sep 17 00:00:00 2001 From: Katy Scott Date: Tue, 21 Jan 2025 16:49:19 -0500 Subject: [PATCH 39/47] fix: fix bounding box case name --- src/readii/process/images/crop.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/readii/process/images/crop.py b/src/readii/process/images/crop.py index 2fdfc79..648561c 100644 --- a/src/readii/process/images/crop.py +++ b/src/readii/process/images/crop.py @@ -400,7 +400,7 @@ def crop_image_to_mask(image:sitk.Image, raise ValueError(msg) match crop_method: - case "bbox": + case "bounding_box": bbox_coords = find_bounding_box(mask) cropped_image = crop_to_bounding_box(image, bbox_coords, resize_dimensions) cropped_mask = crop_to_bounding_box(mask, bbox_coords, resize_dimensions) From 267ebd6567f41e0aa6139ea2c087b0e30c40718a Mon Sep 17 00:00:00 2001 From: Katy Scott Date: Tue, 21 Jan 2025 16:50:02 -0500 Subject: [PATCH 40/47] refactor: change is not to is None --- src/readii/process/images/crop.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/readii/process/images/crop.py b/src/readii/process/images/crop.py index 648561c..3e8c932 100644 --- a/src/readii/process/images/crop.py +++ b/src/readii/process/images/crop.py @@ -353,7 +353,7 @@ def crop_with_pyradiomics(image:sitk.Image, Cropped mask. """ # Get the label of the region of interest in the mask if not provided - if not mask_label: + if mask_label is None: mask_label = getROIVoxelLabel(mask) # Check that CT and segmentation correspond, segmentationLabel is present, and dimensions match From eca1bf34012b8a6787bb97272a7f813af5e1a03b Mon Sep 17 00:00:00 2001 From: Katy Scott Date: Tue, 21 Jan 2025 16:55:28 -0500 Subject: [PATCH 41/47] feat: make variable for current image dimensions --- src/readii/process/images/crop.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/readii/process/images/crop.py b/src/readii/process/images/crop.py index 3e8c932..731bdd1 100644 --- a/src/readii/process/images/crop.py +++ b/src/readii/process/images/crop.py @@ -262,10 +262,13 @@ def crop_to_bounding_box(image:sitk.Image, # Check that the number of bounding box dimensions match the image dimensions validate_new_dimensions(image, int(len(bounding_box)/2)) + # Current bounding box dimensions + current_image_dimensions = (max_x - min_x, max_y - min_y, max_z - min_z) + # Test if bounding box coordinates are within the image, move to image edge if not min_x, max_x, min_y, max_y, min_z, max_z = apply_bounding_box_limits(image, bounding_box, - expected_dimensions=(max_x - min_x, max_y - min_y, max_z - min_z)) + expected_dimensions=current_image_dimensions) # Crop image to the bounding box img_crop = image[min_x:max_x, min_y:max_y, min_z:max_z] From 11c846899d675a47a5382aa6333bda07b2ee1c0f Mon Sep 17 00:00:00 2001 From: Katy Scott Date: Wed, 22 Jan 2025 16:33:49 -0500 Subject: [PATCH 42/47] refactor: replace tuple of individual coordinate values with Coordinate and Size3D objects from utils --- src/readii/process/images/crop.py | 31 +++++++++++++++++-------------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/src/readii/process/images/crop.py b/src/readii/process/images/crop.py index 731bdd1..4a87a01 100644 --- a/src/readii/process/images/crop.py +++ b/src/readii/process/images/crop.py @@ -9,6 +9,7 @@ from readii.image_processing import getROIVoxelLabel from readii.utils import logger +from readii.process.images.utils.bounding_box import Coordinate, Size3D def validate_new_dimensions(image:sitk.Image, new_dimensions:tuple | int @@ -49,7 +50,7 @@ def validate_new_dimensions(image:sitk.Image, def find_bounding_box(mask:sitk.Image, min_dim_size:int = 4 - ) -> tuple: + ) -> tuple[Coordinate, Coordinate, Size3D]: """Find the bounding box of a region of interest (ROI) in a given binary mask image. Parameters @@ -61,30 +62,31 @@ def find_bounding_box(mask:sitk.Image, Returns ------- - bounding_box : np.ndarray - Numpy array containing the bounding box coordinates around the ROI. Order is [min_x, max_x, min_y, max_y, min_z, max_z]. + bounding_box : tuple[Coordinate, Coordinate, Size3D] + Tuple containing the minimum and maximum coordinates for a bounding box, along with the size of the bounding box """ # Convert the mask to a uint8 image mask_uint = sitk.Cast(mask, sitk.sitkUInt8) stats = sitk.LabelShapeStatisticsImageFilter() stats.Execute(mask_uint) - # Get the bounding box starting coordinates and size - min_x, min_y, min_z, size_x, size_y, size_z = stats.GetBoundingBox(1) - - # Ensure minimum size of 4 pixels along each dimension + # Get the bounding box starting/minimum coordinates and size + coord_x, coord_y, coord_z, size_x, size_y, size_z = stats.GetBoundingBox(1) + + # Ensure minimum size of 4 pixels along each dimension (requirement of cropping method) size_x = max(size_x, min_dim_size) size_y = max(size_y, min_dim_size) size_z = max(size_z, min_dim_size) + + # Create an object to store the bounding box size in same manner as coordinates + bbox_size = Size3D(size_x, size_y, size_z) + # Create an object to store the minimum bounding box coordinate + bbox_min_coord = Coordinate(coord_x, coord_y, coord_z) # Calculate the maximum coordinate of the bounding box by adding the size to the starting coordinate - max_x, max_y, max_z = min_x + size_x, min_y + size_y, min_z + size_z + bbox_max_coord = bbox_min_coord + bbox_size - # TODO: Switch to using a class for the bounding box - # min_coord = Coordinate(x=xstart, y=ystart, z=zstart) - # max_coord = Coordinate(x=xstart + xsize, y=ystart + ysize, z=zstart + zsize) - - return min_x, max_x, min_y, max_y, min_z, max_z + return bbox_min_coord, bbox_max_coord, bbox_size def check_bounding_box_single_dimension(bb_min_val:int, @@ -264,6 +266,7 @@ def crop_to_bounding_box(image:sitk.Image, # Current bounding box dimensions current_image_dimensions = (max_x - min_x, max_y - min_y, max_z - min_z) + # bounding_box[1] - bounding_box[0], bounding_box[] # Test if bounding box coordinates are within the image, move to image edge if not min_x, max_x, min_y, max_y, min_z, max_z = apply_bounding_box_limits(image, @@ -422,7 +425,7 @@ def crop_image_to_mask(image:sitk.Image, cropped_image, cropped_mask = crop_with_pyradiomics(image, mask) case _: - msg = f"Invalid crop method: {crop_method}. Must be one of 'bbox', 'centroid', 'cube', or 'pyradiomics'." + msg = f"Invalid crop method: {crop_method}. Must be one of 'bounding_box', 'centroid', 'cube', or 'pyradiomics'." raise ValueError(msg) return cropped_image, cropped_mask \ No newline at end of file From 2fec2fecd1651476b3be6643dce40659ac5c7159 Mon Sep 17 00:00:00 2001 From: Katy Scott Date: Wed, 22 Jan 2025 16:40:47 -0500 Subject: [PATCH 43/47] feat: when a Size3D object is added to a Coordinate, it returns another Coordinate --- src/readii/process/images/utils/bounding_box.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/readii/process/images/utils/bounding_box.py b/src/readii/process/images/utils/bounding_box.py index 5e57421..06fb263 100644 --- a/src/readii/process/images/utils/bounding_box.py +++ b/src/readii/process/images/utils/bounding_box.py @@ -35,6 +35,11 @@ class Size3D(Point3D): @dataclass class Coordinate(Point3D): """Represent a coordinate in 3D space.""" + + def __add__(self, other: Size3D) -> Coordinate: + """Add a size to a coordinate to get a second coordinate.""" + return Coordinate(x=self.x + other.x, y=self.y + other.y, z=self.z + other.z) + pass From 71f456c1b88fd761b255a5768cbb7ecfed707ed9 Mon Sep 17 00:00:00 2001 From: Katy Scott Date: Wed, 22 Jan 2025 17:31:44 -0500 Subject: [PATCH 44/47] refactor: change centroid from tuple to Centroid object --- src/readii/process/images/crop.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/readii/process/images/crop.py b/src/readii/process/images/crop.py index 4a87a01..4469c45 100644 --- a/src/readii/process/images/crop.py +++ b/src/readii/process/images/crop.py @@ -9,7 +9,7 @@ from readii.image_processing import getROIVoxelLabel from readii.utils import logger -from readii.process.images.utils.bounding_box import Coordinate, Size3D +from readii.process.images.utils.bounding_box import Centroid, Coordinate, Size3D def validate_new_dimensions(image:sitk.Image, new_dimensions:tuple | int @@ -191,8 +191,10 @@ def find_centroid(mask:sitk.Image) -> np.ndarray: # Get the centroid coordinates as a physical point in the mask centroid_coords = stats.GetCentroid(1) # Convert the physical point to an index in the mask array - centroid_idx = mask.TransformPhysicalPointToIndex(centroid_coords) - return centroid_idx + centroid_x, centroid_y, centroid_z = mask.TransformPhysicalPointToIndex(centroid_coords) + # Convert to a Centroid object + centroid = Centroid(centroid_x, centroid_y, centroid_z) + return centroid From b8419cfd9d775736f2b8677aadc7f43441800250 Mon Sep 17 00:00:00 2001 From: Katy Scott Date: Wed, 22 Jan 2025 17:46:46 -0500 Subject: [PATCH 45/47] feat: add subtraction function to Coordinate --- src/readii/process/images/utils/bounding_box.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/readii/process/images/utils/bounding_box.py b/src/readii/process/images/utils/bounding_box.py index 06fb263..5e0aceb 100644 --- a/src/readii/process/images/utils/bounding_box.py +++ b/src/readii/process/images/utils/bounding_box.py @@ -6,7 +6,6 @@ @dataclass class Point3D: """Represent a point in 3D space.""" - x: int y: int z: int @@ -39,9 +38,11 @@ class Coordinate(Point3D): def __add__(self, other: Size3D) -> Coordinate: """Add a size to a coordinate to get a second coordinate.""" return Coordinate(x=self.x + other.x, y=self.y + other.y, z=self.z + other.z) - - - pass + + def __sub__(self, other: Size3D) -> Coordinate: + """Subtract a size from a coordinate to get a second coordinate.""" + return Coordinate(x=self.x - other.x, y=self.y - other.y, z=self.z - other.z) + @dataclass From b30edc8409211aeb44c52b846cc663378770c4d8 Mon Sep 17 00:00:00 2001 From: Katy Scott Date: Wed, 22 Jan 2025 17:48:14 -0500 Subject: [PATCH 46/47] fix: update bounding box crop_method spelling --- tests/process/images/test_crop.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/process/images/test_crop.py b/tests/process/images/test_crop.py index 51cd6dc..101e081 100644 --- a/tests/process/images/test_crop.py +++ b/tests/process/images/test_crop.py @@ -54,7 +54,7 @@ def lung4D_mask(lung4D_ct_path, lung4D_rt_path): @pytest.mark.parametrize( "crop_method, expected_size", [ - ("bbox", (50, 50, 50)), + ("bounding_box", (50, 50, 50)), ("centroid", (50, 50, 50)), ("cube", (50, 50, 50)), # ("pyradiomics", (22, 28, 14)), From 67df81be1ccbd5337fa505c6421cfa0ec3b83e3c Mon Sep 17 00:00:00 2001 From: Katy Scott Date: Thu, 30 Jan 2025 13:08:27 -0500 Subject: [PATCH 47/47] fix: swap the x and y labels for the heatmap to correctly correspond with the horizontal and vertical features --- src/readii/analyze/plot_correlation.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/readii/analyze/plot_correlation.py b/src/readii/analyze/plot_correlation.py index 9cd800f..6c170d4 100644 --- a/src/readii/analyze/plot_correlation.py +++ b/src/readii/analyze/plot_correlation.py @@ -418,8 +418,8 @@ def plotCrossCorrHeatmap(correlation_matrix:pd.DataFrame, cross_corr_heatmap = plotCorrelationHeatmap(cross_corr, diagonal=False, cmap=cmap, - xlabel=vertical_feature_name, - ylabel=horizontal_feature_name, + xlabel=horizontal_feature_name, + ylabel=vertical_feature_name, title=f"{correlation_method.capitalize()} Cross Correlations", subtitle=f"{vertical_feature_name} vs {horizontal_feature_name}")