From 1f2142d9ec90301430aaf013475e84158dc470e3 Mon Sep 17 00:00:00 2001 From: bluna301 Date: Mon, 12 Aug 2024 16:24:01 -0400 Subject: [PATCH 1/5] initial commit Signed-off-by: bluna301 --- monai/deploy/operators/dicom_series_selector_operator.py | 1 + 1 file changed, 1 insertion(+) diff --git a/monai/deploy/operators/dicom_series_selector_operator.py b/monai/deploy/operators/dicom_series_selector_operator.py index c7d21ccf..c8769f5b 100644 --- a/monai/deploy/operators/dicom_series_selector_operator.py +++ b/monai/deploy/operators/dicom_series_selector_operator.py @@ -237,6 +237,7 @@ def _select_series(self, attributes: dict, study: DICOMStudy, all_matched=False) if not attr_value: matched = False elif isinstance(attr_value, numbers.Number): + # logic for numerical tag matching: matched = value_to_match == attr_value elif isinstance(attr_value, str): matched = attr_value.casefold() == (value_to_match.casefold()) From c45e9020acc6e00a37488e63f574cdc1f52a276a Mon Sep 17 00:00:00 2001 From: bluna301 Date: Tue, 13 Aug 2024 22:22:34 -0400 Subject: [PATCH 2/5] range and RegEx matching added for numerical tags; sop instance # sorting parameter implemented Signed-off-by: bluna301 --- .../dicom_series_selector_operator.py | 71 +++++++++++++++---- 1 file changed, 57 insertions(+), 14 deletions(-) diff --git a/monai/deploy/operators/dicom_series_selector_operator.py b/monai/deploy/operators/dicom_series_selector_operator.py index c8769f5b..284bb449 100644 --- a/monai/deploy/operators/dicom_series_selector_operator.py +++ b/monai/deploy/operators/dicom_series_selector_operator.py @@ -30,20 +30,23 @@ class DICOMSeriesSelectorOperator(Operator): Named output: study_selected_series_list: A list of StudySelectedSeries objects. Downstream receiver optional. - This class can be considered a base class, and a derived class can override the 'filer' function to with + This class can be considered a base class, and a derived class can override the 'filter' function to with custom logics. In its default implementation, this class 1. selects a series or all matched series within the scope of a study in a list of studies 2. uses rules defined in JSON string, see below for details - 3. supports DICOM Study and Series module attribute matching, including regex for string types + 3. supports DICOM Study and Series module attribute matching 4. supports multiple named selections, in the scope of each DICOM study 5. outputs a list of SutdySelectedSeries objects, as well as a flat list of SelectedSeries (to be deprecated) The selection rules are defined in JSON, 1. attribute "selections" value is a list of selections 2. each selection has a "name", and its "conditions" value is a list of matching criteria - 3. each condition has uses the implicit equal operator, except for regex for str type and union for set type + 3. each condition uses the implicit equal operator; in addition, the following are supported: + - regex and range matching for numerical types + - regex matching for str type + - union matching for set type 4. DICOM attribute keywords are used, and only for those defined as DICOMStudy and DICOMSeries properties An example selection rules: @@ -64,18 +67,29 @@ class DICOMSeriesSelectorOperator(Operator): "BodyPartExamined": "Abdomen", "SeriesDescription" : "Not to be matched. For illustration only." } + }, + { + "name": "CT Series 3", + "conditions": { + "StudyDescription": "(.*?)", + "Modality": "(?i)CT", + "ImageType": ["PRIMARY", "ORIGINAL", "AXIAL"], + "SliceThickness": [3, 5] + } } ] } """ - def __init__(self, fragment: Fragment, *args, rules: str = "", all_matched: bool = False, **kwargs) -> None: + def __init__(self, fragment: Fragment, *args, rules: str = "", all_matched: bool = False, sort_by_sop_instance_count: bool = False, **kwargs) -> None: """Instantiate an instance. Args: fragment (Fragment): An instance of the Application class which is derived from Fragment. rules (Text): Selection rules in JSON string. all_matched (bool): Gets all matched series in a study. Defaults to False for first match only. + sort_by_sop_instance_count (bool): If all_matched = True and multiple series are matched, sorts matched series in + descending SOP instance count. Defaults to False for no sorting. """ # rules: Text = "", all_matched: bool = False, @@ -83,6 +97,7 @@ def __init__(self, fragment: Fragment, *args, rules: str = "", all_matched: bool # Delay loading the rules as JSON string till compute time. self._rules_json_str = rules if rules and rules.strip() else None self._all_matched = all_matched # all_matched + self._sort_by_sop_instance_count = sort_by_sop_instance_count # sort_by_sop_instance_count self.input_name_study_list = "dicom_study_list" self.output_name_selected_series = "study_selected_series_list" @@ -100,23 +115,25 @@ def compute(self, op_input, op_output, context): dicom_study_list = op_input.receive(self.input_name_study_list) selection_rules = self._load_rules() if self._rules_json_str else None - study_selected_series = self.filter(selection_rules, dicom_study_list, self._all_matched) + study_selected_series = self.filter(selection_rules, dicom_study_list, self._all_matched, self._sort_by_sop_instance_count) op_output.emit(study_selected_series, self.output_name_selected_series) - def filter(self, selection_rules, dicom_study_list, all_matched: bool = False) -> List[StudySelectedSeries]: + def filter(self, selection_rules, dicom_study_list, all_matched: bool = False, sort_by_sop_instance_count: bool = False) -> List[StudySelectedSeries]: """Selects the series with the given matching rules. If rules object is None, all series will be returned with series instance UID as the selection name. - Simplistic matching is used for demonstration: - Number: exactly matches + Supported matching logic: + Number: exact matching, range matching (if a list with two numerical elements is provided), and regex matching String: matches case insensitive, if fails then tries RegEx search - String array matches as subset, case insensitive + String array (set): matches as subset, case insensitive Args: selection_rules (object): JSON object containing the matching rules. dicom_study_list (list): A list of DICOMStudiy objects. all_matched (bool): Gets all matched series in a study. Defaults to False for first match only. + sort_by_sop_instance_count (bool): If all_matched = True and multiple series are matched, sorts matched series in + descending SOP instance count. Defaults to False for no sorting. Returns: list: A list of objects of type StudySelectedSeries. @@ -153,7 +170,7 @@ def filter(self, selection_rules, dicom_study_list, all_matched: bool = False) - continue # Select only the first series that matches the conditions, list of one - series_list = self._select_series(conditions, study, all_matched) + series_list = self._select_series(conditions, study, all_matched, sort_by_sop_instance_count) if series_list and len(series_list) > 0: for series in series_list: selected_series = SelectedSeries(selection_name, series, None) # No Image obj yet. @@ -185,12 +202,14 @@ def _select_all_series(self, dicom_study_list: List[DICOMStudy]) -> List[StudySe study_selected_series_list.append(study_selected_series) return study_selected_series_list - def _select_series(self, attributes: dict, study: DICOMStudy, all_matched=False) -> List[DICOMSeries]: + def _select_series(self, attributes: dict, study: DICOMStudy, all_matched=False, sort_by_sop_instance_count=False) -> List[DICOMSeries]: """Finds series whose attributes match the given attributes. Args: attributes (dict): Dictionary of attributes for matching all_matched (bool): Gets all matched series in a study. Defaults to False for first match only. + sort_by_sop_instance_count (bool): If all_matched = True and multiple series are matched, sorts matched series in + descending SOP instance count. Defaults to False for no sorting. Returns: List of DICOMSeries. At most one element if all_matched is False. @@ -237,8 +256,16 @@ def _select_series(self, attributes: dict, study: DICOMStudy, all_matched=False) if not attr_value: matched = False elif isinstance(attr_value, numbers.Number): - # logic for numerical tag matching: - matched = value_to_match == attr_value + # range matching + if isinstance(value_to_match, list) and len(value_to_match) == 2: + lower_bound, upper_bound = map(float, value_to_match) + matched = lower_bound <= attr_value <= upper_bound + # RegEx matching + elif isinstance(value_to_match, str): + matched = re.fullmatch(value_to_match, str(attr_value)) + # exact matching + else: + matched = value_to_match == attr_value elif isinstance(attr_value, str): matched = attr_value.casefold() == (value_to_match.casefold()) if not matched: @@ -269,6 +296,11 @@ def _select_series(self, attributes: dict, study: DICOMStudy, all_matched=False) if not all_matched: return found_series + # if sorting indicated and multiple series found + if sort_by_sop_instance_count and len(found_series) > 1: + # sort series in descending SOP instance count + found_series.sort(key=lambda x: len(x.get_sop_instances()), reverse=True) + return found_series @staticmethod @@ -303,6 +335,8 @@ def test(): sample_selection_rule = json_loads(Sample_Rules_Text) print(f"Selection rules in JSON:\n{sample_selection_rule}") study_selected_seriee_list = selector.filter(sample_selection_rule, study_list) + # # multiple series match testing: + # study_selected_seriee_list = selector.filter(sample_selection_rule, study_list, all_matched=False, sort_by_sop_instance_count=False) for sss_obj in study_selected_seriee_list: _print_instance_properties(sss_obj, pre_fix="", print_val=False) @@ -354,10 +388,19 @@ def test(): "BodyPartExamined": "Abdomen", "SeriesDescription" : "Not to be matched" } + }, + { + "name": "CT Series 3", + "conditions": { + "StudyDescription": "(.*?)", + "Modality": "(?i)CT", + "ImageType": ["PRIMARY", "ORIGINAL", "AXIAL"], + "SliceThickness": [3, 5] + } } ] } """ if __name__ == "__main__": - test() + test() \ No newline at end of file From d064aff3e804a6a6175f4b9899e9a0e9e62b2cd4 Mon Sep 17 00:00:00 2001 From: bluna301 Date: Fri, 6 Sep 2024 15:23:40 -0400 Subject: [PATCH 3/5] series_selector op numerical type cleanup; fixed STL op to change temp_folder declare location per pytype check Signed-off-by: bluna301 --- .../dicom_series_selector_operator.py | 42 ++++++++++++------- .../operators/stl_conversion_operator.py | 3 +- 2 files changed, 29 insertions(+), 16 deletions(-) diff --git a/monai/deploy/operators/dicom_series_selector_operator.py b/monai/deploy/operators/dicom_series_selector_operator.py index 284bb449..bc0a36c0 100644 --- a/monai/deploy/operators/dicom_series_selector_operator.py +++ b/monai/deploy/operators/dicom_series_selector_operator.py @@ -44,7 +44,7 @@ class DICOMSeriesSelectorOperator(Operator): 1. attribute "selections" value is a list of selections 2. each selection has a "name", and its "conditions" value is a list of matching criteria 3. each condition uses the implicit equal operator; in addition, the following are supported: - - regex and range matching for numerical types + - regex and range matching for float and int types - regex matching for str type - union matching for set type 4. DICOM attribute keywords are used, and only for those defined as DICOMStudy and DICOMSeries properties @@ -81,14 +81,22 @@ class DICOMSeriesSelectorOperator(Operator): } """ - def __init__(self, fragment: Fragment, *args, rules: str = "", all_matched: bool = False, sort_by_sop_instance_count: bool = False, **kwargs) -> None: + def __init__( + self, + fragment: Fragment, + *args, + rules: str = "", + all_matched: bool = False, + sort_by_sop_instance_count: bool = False, + **kwargs, + ) -> None: """Instantiate an instance. Args: fragment (Fragment): An instance of the Application class which is derived from Fragment. rules (Text): Selection rules in JSON string. all_matched (bool): Gets all matched series in a study. Defaults to False for first match only. - sort_by_sop_instance_count (bool): If all_matched = True and multiple series are matched, sorts matched series in + sort_by_sop_instance_count (bool): If all_matched = True and multiple series are matched, sorts the matched series in descending SOP instance count. Defaults to False for no sorting. """ @@ -115,16 +123,20 @@ def compute(self, op_input, op_output, context): dicom_study_list = op_input.receive(self.input_name_study_list) selection_rules = self._load_rules() if self._rules_json_str else None - study_selected_series = self.filter(selection_rules, dicom_study_list, self._all_matched, self._sort_by_sop_instance_count) + study_selected_series = self.filter( + selection_rules, dicom_study_list, self._all_matched, self._sort_by_sop_instance_count + ) op_output.emit(study_selected_series, self.output_name_selected_series) - def filter(self, selection_rules, dicom_study_list, all_matched: bool = False, sort_by_sop_instance_count: bool = False) -> List[StudySelectedSeries]: + def filter( + self, selection_rules, dicom_study_list, all_matched: bool = False, sort_by_sop_instance_count: bool = False + ) -> List[StudySelectedSeries]: """Selects the series with the given matching rules. If rules object is None, all series will be returned with series instance UID as the selection name. Supported matching logic: - Number: exact matching, range matching (if a list with two numerical elements is provided), and regex matching + Float + Int: exact matching, range matching (if a list with two numerical elements is provided), and regex matching String: matches case insensitive, if fails then tries RegEx search String array (set): matches as subset, case insensitive @@ -132,7 +144,7 @@ def filter(self, selection_rules, dicom_study_list, all_matched: bool = False, s selection_rules (object): JSON object containing the matching rules. dicom_study_list (list): A list of DICOMStudiy objects. all_matched (bool): Gets all matched series in a study. Defaults to False for first match only. - sort_by_sop_instance_count (bool): If all_matched = True and multiple series are matched, sorts matched series in + sort_by_sop_instance_count (bool): If all_matched = True and multiple series are matched, sorts the matched series in descending SOP instance count. Defaults to False for no sorting. Returns: @@ -202,13 +214,15 @@ def _select_all_series(self, dicom_study_list: List[DICOMStudy]) -> List[StudySe study_selected_series_list.append(study_selected_series) return study_selected_series_list - def _select_series(self, attributes: dict, study: DICOMStudy, all_matched=False, sort_by_sop_instance_count=False) -> List[DICOMSeries]: + def _select_series( + self, attributes: dict, study: DICOMStudy, all_matched=False, sort_by_sop_instance_count=False + ) -> List[DICOMSeries]: """Finds series whose attributes match the given attributes. Args: attributes (dict): Dictionary of attributes for matching all_matched (bool): Gets all matched series in a study. Defaults to False for first match only. - sort_by_sop_instance_count (bool): If all_matched = True and multiple series are matched, sorts matched series in + sort_by_sop_instance_count (bool): If all_matched = True and multiple series are matched, sorts the matched series in descending SOP instance count. Defaults to False for no sorting. Returns: @@ -255,14 +269,14 @@ def _select_series(self, attributes: dict, study: DICOMStudy, all_matched=False, if not attr_value: matched = False - elif isinstance(attr_value, numbers.Number): + elif isinstance(attr_value, float) or isinstance(attr_value, int): # range matching if isinstance(value_to_match, list) and len(value_to_match) == 2: lower_bound, upper_bound = map(float, value_to_match) matched = lower_bound <= attr_value <= upper_bound # RegEx matching elif isinstance(value_to_match, str): - matched = re.fullmatch(value_to_match, str(attr_value)) + matched = bool(re.fullmatch(value_to_match, str(attr_value))) # exact matching else: matched = value_to_match == attr_value @@ -300,7 +314,7 @@ def _select_series(self, attributes: dict, study: DICOMStudy, all_matched=False, if sort_by_sop_instance_count and len(found_series) > 1: # sort series in descending SOP instance count found_series.sort(key=lambda x: len(x.get_sop_instances()), reverse=True) - + return found_series @staticmethod @@ -335,8 +349,6 @@ def test(): sample_selection_rule = json_loads(Sample_Rules_Text) print(f"Selection rules in JSON:\n{sample_selection_rule}") study_selected_seriee_list = selector.filter(sample_selection_rule, study_list) - # # multiple series match testing: - # study_selected_seriee_list = selector.filter(sample_selection_rule, study_list, all_matched=False, sort_by_sop_instance_count=False) for sss_obj in study_selected_seriee_list: _print_instance_properties(sss_obj, pre_fix="", print_val=False) @@ -403,4 +415,4 @@ def test(): """ if __name__ == "__main__": - test() \ No newline at end of file + test() diff --git a/monai/deploy/operators/stl_conversion_operator.py b/monai/deploy/operators/stl_conversion_operator.py index 12a078c7..d8940a83 100644 --- a/monai/deploy/operators/stl_conversion_operator.py +++ b/monai/deploy/operators/stl_conversion_operator.py @@ -174,6 +174,8 @@ def convert( if isinstance(output_file, Path): output_file.parent.mkdir(parents=True, exist_ok=True) + temp_folder = tempfile.mkdtemp() + s_image = self.SpatialImage(image) nda = s_image.image_array self._logger.info(f"Image ndarray shape:{nda.shape}") @@ -231,7 +233,6 @@ def convert( # Write out the STL file, and then load into trimesh try: - temp_folder = tempfile.mkdtemp() raw_stl_filename = os.path.join(temp_folder, "temp.stl") STLConverter.write_stl(verts, faces, raw_stl_filename) mesh_data = trimesh.load(raw_stl_filename) From 5d211f49855b257ba961d319e93bce06df3b30ab Mon Sep 17 00:00:00 2001 From: bluna301 Date: Tue, 1 Oct 2024 14:33:37 -0400 Subject: [PATCH 4/5] series selector op log + documentation updates Signed-off-by: bluna301 --- .../operators/dicom_series_selector_operator.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/monai/deploy/operators/dicom_series_selector_operator.py b/monai/deploy/operators/dicom_series_selector_operator.py index bc0a36c0..2836604b 100644 --- a/monai/deploy/operators/dicom_series_selector_operator.py +++ b/monai/deploy/operators/dicom_series_selector_operator.py @@ -97,7 +97,8 @@ def __init__( rules (Text): Selection rules in JSON string. all_matched (bool): Gets all matched series in a study. Defaults to False for first match only. sort_by_sop_instance_count (bool): If all_matched = True and multiple series are matched, sorts the matched series in - descending SOP instance count. Defaults to False for no sorting. + descending SOP instance count (i.e. the first Series in the returned List[StudySelectedSeries] will have the highest # + of DICOM images); Defaults to False for no sorting. """ # rules: Text = "", all_matched: bool = False, @@ -142,10 +143,11 @@ def filter( Args: selection_rules (object): JSON object containing the matching rules. - dicom_study_list (list): A list of DICOMStudiy objects. + dicom_study_list (list): A list of DICOMStudy objects. all_matched (bool): Gets all matched series in a study. Defaults to False for first match only. sort_by_sop_instance_count (bool): If all_matched = True and multiple series are matched, sorts the matched series in - descending SOP instance count. Defaults to False for no sorting. + descending SOP instance count (i.e. the first Series in the returned List[StudySelectedSeries] will have the highest # + of DICOM images); Defaults to False for no sorting. Returns: list: A list of objects of type StudySelectedSeries. @@ -223,7 +225,8 @@ def _select_series( attributes (dict): Dictionary of attributes for matching all_matched (bool): Gets all matched series in a study. Defaults to False for first match only. sort_by_sop_instance_count (bool): If all_matched = True and multiple series are matched, sorts the matched series in - descending SOP instance count. Defaults to False for no sorting. + descending SOP instance count (i.e. the first Series in the returned List[StudySelectedSeries] will have the highest # + of DICOM images); Defaults to False for no sorting. Returns: List of DICOMSeries. At most one element if all_matched is False. @@ -313,6 +316,9 @@ def _select_series( # if sorting indicated and multiple series found if sort_by_sop_instance_count and len(found_series) > 1: # sort series in descending SOP instance count + logging.info( + "Multiple series matched the selection criteria; choosing series with the highest number of DICOM images." + ) found_series.sort(key=lambda x: len(x.get_sop_instances()), reverse=True) return found_series From 49fa70a3d36800158f4deb81f6eb17ab03395b22 Mon Sep 17 00:00:00 2001 From: bluna301 Date: Tue, 3 Dec 2024 14:28:27 -0500 Subject: [PATCH 5/5] finalized selected series logs added Signed-off-by: bluna301 --- .../operators/dicom_series_selector_operator.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/monai/deploy/operators/dicom_series_selector_operator.py b/monai/deploy/operators/dicom_series_selector_operator.py index 2836604b..0a1a4545 100644 --- a/monai/deploy/operators/dicom_series_selector_operator.py +++ b/monai/deploy/operators/dicom_series_selector_operator.py @@ -127,6 +127,20 @@ def compute(self, op_input, op_output, context): study_selected_series = self.filter( selection_rules, dicom_study_list, self._all_matched, self._sort_by_sop_instance_count ) + + # log Series Description and Series Instance UID of the first selected DICOM Series (i.e. the one to be used for inference) + if study_selected_series and len(study_selected_series) > 0: + inference_study = study_selected_series[0] + if inference_study.selected_series and len(inference_study.selected_series) > 0: + inference_series = inference_study.selected_series[0].series + logging.info("Series Selection finalized.") + logging.info( + f"Series Description of selected DICOM Series for inference: {inference_series.SeriesDescription}" + ) + logging.info( + f"Series Instance UID of selected DICOM Series for inference: {inference_series.SeriesInstanceUID}" + ) + op_output.emit(study_selected_series, self.output_name_selected_series) def filter(