Skip to content

Commit

Permalink
DICOMSeriesSelectorOperator Enhancements (#501)
Browse files Browse the repository at this point in the history
* initial commit

Signed-off-by: bluna301 <luna.bryanr@gmail.com>

* range and RegEx matching added for numerical tags; sop instance # sorting parameter implemented

Signed-off-by: bluna301 <luna.bryanr@gmail.com>

* series_selector op numerical type cleanup; fixed STL op to change temp_folder declare location per pytype check

Signed-off-by: bluna301 <luna.bryanr@gmail.com>

* series selector op log + documentation updates

Signed-off-by: bluna301 <luna.bryanr@gmail.com>

* finalized selected series logs added

Signed-off-by: bluna301 <luna.bryanr@gmail.com>

---------

Signed-off-by: bluna301 <luna.bryanr@gmail.com>
  • Loading branch information
bluna301 authored Feb 4, 2025
1 parent fb7e26c commit 07fd50e
Show file tree
Hide file tree
Showing 2 changed files with 92 additions and 15 deletions.
104 changes: 90 additions & 14 deletions monai/deploy/operators/dicom_series_selector_operator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 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
An example selection rules:
Expand All @@ -64,25 +67,46 @@ 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 the matched series in
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,

# 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"

Expand All @@ -100,23 +124,44 @@ 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
)

# 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(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:
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 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.
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 (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.
Expand Down Expand Up @@ -153,7 +198,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.
Expand Down Expand Up @@ -185,12 +230,17 @@ 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 the matched series in
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.
Expand Down Expand Up @@ -236,8 +286,17 @@ def _select_series(self, attributes: dict, study: DICOMStudy, all_matched=False)

if not attr_value:
matched = False
elif isinstance(attr_value, numbers.Number):
matched = value_to_match == attr_value
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 = bool(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:
Expand Down Expand Up @@ -268,6 +327,14 @@ 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
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

@staticmethod
Expand Down Expand Up @@ -353,6 +420,15 @@ 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]
}
}
]
}
Expand Down
3 changes: 2 additions & 1 deletion monai/deploy/operators/stl_conversion_operator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}")
Expand Down Expand Up @@ -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)
Expand Down

0 comments on commit 07fd50e

Please sign in to comment.