Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

DICOMSeriesSelectorOperator Enhancements #501

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading