Skip to content

Commit 07fd50e

Browse files
authored
DICOMSeriesSelectorOperator Enhancements (#501)
* 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>
1 parent fb7e26c commit 07fd50e

File tree

2 files changed

+92
-15
lines changed

2 files changed

+92
-15
lines changed

monai/deploy/operators/dicom_series_selector_operator.py

Lines changed: 90 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -30,20 +30,23 @@ class DICOMSeriesSelectorOperator(Operator):
3030
Named output:
3131
study_selected_series_list: A list of StudySelectedSeries objects. Downstream receiver optional.
3232
33-
This class can be considered a base class, and a derived class can override the 'filer' function to with
33+
This class can be considered a base class, and a derived class can override the 'filter' function to with
3434
custom logics.
3535
3636
In its default implementation, this class
3737
1. selects a series or all matched series within the scope of a study in a list of studies
3838
2. uses rules defined in JSON string, see below for details
39-
3. supports DICOM Study and Series module attribute matching, including regex for string types
39+
3. supports DICOM Study and Series module attribute matching
4040
4. supports multiple named selections, in the scope of each DICOM study
4141
5. outputs a list of SutdySelectedSeries objects, as well as a flat list of SelectedSeries (to be deprecated)
4242
4343
The selection rules are defined in JSON,
4444
1. attribute "selections" value is a list of selections
4545
2. each selection has a "name", and its "conditions" value is a list of matching criteria
46-
3. each condition has uses the implicit equal operator, except for regex for str type and union for set type
46+
3. each condition uses the implicit equal operator; in addition, the following are supported:
47+
- regex and range matching for float and int types
48+
- regex matching for str type
49+
- union matching for set type
4750
4. DICOM attribute keywords are used, and only for those defined as DICOMStudy and DICOMSeries properties
4851
4952
An example selection rules:
@@ -64,25 +67,46 @@ class DICOMSeriesSelectorOperator(Operator):
6467
"BodyPartExamined": "Abdomen",
6568
"SeriesDescription" : "Not to be matched. For illustration only."
6669
}
70+
},
71+
{
72+
"name": "CT Series 3",
73+
"conditions": {
74+
"StudyDescription": "(.*?)",
75+
"Modality": "(?i)CT",
76+
"ImageType": ["PRIMARY", "ORIGINAL", "AXIAL"],
77+
"SliceThickness": [3, 5]
78+
}
6779
}
6880
]
6981
}
7082
"""
7183

72-
def __init__(self, fragment: Fragment, *args, rules: str = "", all_matched: bool = False, **kwargs) -> None:
84+
def __init__(
85+
self,
86+
fragment: Fragment,
87+
*args,
88+
rules: str = "",
89+
all_matched: bool = False,
90+
sort_by_sop_instance_count: bool = False,
91+
**kwargs,
92+
) -> None:
7393
"""Instantiate an instance.
7494
7595
Args:
7696
fragment (Fragment): An instance of the Application class which is derived from Fragment.
7797
rules (Text): Selection rules in JSON string.
7898
all_matched (bool): Gets all matched series in a study. Defaults to False for first match only.
99+
sort_by_sop_instance_count (bool): If all_matched = True and multiple series are matched, sorts the matched series in
100+
descending SOP instance count (i.e. the first Series in the returned List[StudySelectedSeries] will have the highest #
101+
of DICOM images); Defaults to False for no sorting.
79102
"""
80103

81104
# rules: Text = "", all_matched: bool = False,
82105

83106
# Delay loading the rules as JSON string till compute time.
84107
self._rules_json_str = rules if rules and rules.strip() else None
85108
self._all_matched = all_matched # all_matched
109+
self._sort_by_sop_instance_count = sort_by_sop_instance_count # sort_by_sop_instance_count
86110
self.input_name_study_list = "dicom_study_list"
87111
self.output_name_selected_series = "study_selected_series_list"
88112

@@ -100,23 +124,44 @@ def compute(self, op_input, op_output, context):
100124

101125
dicom_study_list = op_input.receive(self.input_name_study_list)
102126
selection_rules = self._load_rules() if self._rules_json_str else None
103-
study_selected_series = self.filter(selection_rules, dicom_study_list, self._all_matched)
127+
study_selected_series = self.filter(
128+
selection_rules, dicom_study_list, self._all_matched, self._sort_by_sop_instance_count
129+
)
130+
131+
# log Series Description and Series Instance UID of the first selected DICOM Series (i.e. the one to be used for inference)
132+
if study_selected_series and len(study_selected_series) > 0:
133+
inference_study = study_selected_series[0]
134+
if inference_study.selected_series and len(inference_study.selected_series) > 0:
135+
inference_series = inference_study.selected_series[0].series
136+
logging.info("Series Selection finalized.")
137+
logging.info(
138+
f"Series Description of selected DICOM Series for inference: {inference_series.SeriesDescription}"
139+
)
140+
logging.info(
141+
f"Series Instance UID of selected DICOM Series for inference: {inference_series.SeriesInstanceUID}"
142+
)
143+
104144
op_output.emit(study_selected_series, self.output_name_selected_series)
105145

106-
def filter(self, selection_rules, dicom_study_list, all_matched: bool = False) -> List[StudySelectedSeries]:
146+
def filter(
147+
self, selection_rules, dicom_study_list, all_matched: bool = False, sort_by_sop_instance_count: bool = False
148+
) -> List[StudySelectedSeries]:
107149
"""Selects the series with the given matching rules.
108150
109151
If rules object is None, all series will be returned with series instance UID as the selection name.
110152
111-
Simplistic matching is used for demonstration:
112-
Number: exactly matches
153+
Supported matching logic:
154+
Float + Int: exact matching, range matching (if a list with two numerical elements is provided), and regex matching
113155
String: matches case insensitive, if fails then tries RegEx search
114-
String array matches as subset, case insensitive
156+
String array (set): matches as subset, case insensitive
115157
116158
Args:
117159
selection_rules (object): JSON object containing the matching rules.
118-
dicom_study_list (list): A list of DICOMStudiy objects.
160+
dicom_study_list (list): A list of DICOMStudy objects.
119161
all_matched (bool): Gets all matched series in a study. Defaults to False for first match only.
162+
sort_by_sop_instance_count (bool): If all_matched = True and multiple series are matched, sorts the matched series in
163+
descending SOP instance count (i.e. the first Series in the returned List[StudySelectedSeries] will have the highest #
164+
of DICOM images); Defaults to False for no sorting.
120165
121166
Returns:
122167
list: A list of objects of type StudySelectedSeries.
@@ -153,7 +198,7 @@ def filter(self, selection_rules, dicom_study_list, all_matched: bool = False) -
153198
continue
154199

155200
# Select only the first series that matches the conditions, list of one
156-
series_list = self._select_series(conditions, study, all_matched)
201+
series_list = self._select_series(conditions, study, all_matched, sort_by_sop_instance_count)
157202
if series_list and len(series_list) > 0:
158203
for series in series_list:
159204
selected_series = SelectedSeries(selection_name, series, None) # No Image obj yet.
@@ -185,12 +230,17 @@ def _select_all_series(self, dicom_study_list: List[DICOMStudy]) -> List[StudySe
185230
study_selected_series_list.append(study_selected_series)
186231
return study_selected_series_list
187232

188-
def _select_series(self, attributes: dict, study: DICOMStudy, all_matched=False) -> List[DICOMSeries]:
233+
def _select_series(
234+
self, attributes: dict, study: DICOMStudy, all_matched=False, sort_by_sop_instance_count=False
235+
) -> List[DICOMSeries]:
189236
"""Finds series whose attributes match the given attributes.
190237
191238
Args:
192239
attributes (dict): Dictionary of attributes for matching
193240
all_matched (bool): Gets all matched series in a study. Defaults to False for first match only.
241+
sort_by_sop_instance_count (bool): If all_matched = True and multiple series are matched, sorts the matched series in
242+
descending SOP instance count (i.e. the first Series in the returned List[StudySelectedSeries] will have the highest #
243+
of DICOM images); Defaults to False for no sorting.
194244
195245
Returns:
196246
List of DICOMSeries. At most one element if all_matched is False.
@@ -236,8 +286,17 @@ def _select_series(self, attributes: dict, study: DICOMStudy, all_matched=False)
236286

237287
if not attr_value:
238288
matched = False
239-
elif isinstance(attr_value, numbers.Number):
240-
matched = value_to_match == attr_value
289+
elif isinstance(attr_value, float) or isinstance(attr_value, int):
290+
# range matching
291+
if isinstance(value_to_match, list) and len(value_to_match) == 2:
292+
lower_bound, upper_bound = map(float, value_to_match)
293+
matched = lower_bound <= attr_value <= upper_bound
294+
# RegEx matching
295+
elif isinstance(value_to_match, str):
296+
matched = bool(re.fullmatch(value_to_match, str(attr_value)))
297+
# exact matching
298+
else:
299+
matched = value_to_match == attr_value
241300
elif isinstance(attr_value, str):
242301
matched = attr_value.casefold() == (value_to_match.casefold())
243302
if not matched:
@@ -268,6 +327,14 @@ def _select_series(self, attributes: dict, study: DICOMStudy, all_matched=False)
268327
if not all_matched:
269328
return found_series
270329

330+
# if sorting indicated and multiple series found
331+
if sort_by_sop_instance_count and len(found_series) > 1:
332+
# sort series in descending SOP instance count
333+
logging.info(
334+
"Multiple series matched the selection criteria; choosing series with the highest number of DICOM images."
335+
)
336+
found_series.sort(key=lambda x: len(x.get_sop_instances()), reverse=True)
337+
271338
return found_series
272339

273340
@staticmethod
@@ -353,6 +420,15 @@ def test():
353420
"BodyPartExamined": "Abdomen",
354421
"SeriesDescription" : "Not to be matched"
355422
}
423+
},
424+
{
425+
"name": "CT Series 3",
426+
"conditions": {
427+
"StudyDescription": "(.*?)",
428+
"Modality": "(?i)CT",
429+
"ImageType": ["PRIMARY", "ORIGINAL", "AXIAL"],
430+
"SliceThickness": [3, 5]
431+
}
356432
}
357433
]
358434
}

monai/deploy/operators/stl_conversion_operator.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,8 @@ def convert(
174174
if isinstance(output_file, Path):
175175
output_file.parent.mkdir(parents=True, exist_ok=True)
176176

177+
temp_folder = tempfile.mkdtemp()
178+
177179
s_image = self.SpatialImage(image)
178180
nda = s_image.image_array
179181
self._logger.info(f"Image ndarray shape:{nda.shape}")
@@ -231,7 +233,6 @@ def convert(
231233

232234
# Write out the STL file, and then load into trimesh
233235
try:
234-
temp_folder = tempfile.mkdtemp()
235236
raw_stl_filename = os.path.join(temp_folder, "temp.stl")
236237
STLConverter.write_stl(verts, faces, raw_stl_filename)
237238
mesh_data = trimesh.load(raw_stl_filename)

0 commit comments

Comments
 (0)