@@ -30,20 +30,23 @@ class DICOMSeriesSelectorOperator(Operator):
30
30
Named output:
31
31
study_selected_series_list: A list of StudySelectedSeries objects. Downstream receiver optional.
32
32
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
34
34
custom logics.
35
35
36
36
In its default implementation, this class
37
37
1. selects a series or all matched series within the scope of a study in a list of studies
38
38
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
40
40
4. supports multiple named selections, in the scope of each DICOM study
41
41
5. outputs a list of SutdySelectedSeries objects, as well as a flat list of SelectedSeries (to be deprecated)
42
42
43
43
The selection rules are defined in JSON,
44
44
1. attribute "selections" value is a list of selections
45
45
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
47
50
4. DICOM attribute keywords are used, and only for those defined as DICOMStudy and DICOMSeries properties
48
51
49
52
An example selection rules:
@@ -64,25 +67,46 @@ class DICOMSeriesSelectorOperator(Operator):
64
67
"BodyPartExamined": "Abdomen",
65
68
"SeriesDescription" : "Not to be matched. For illustration only."
66
69
}
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
+ }
67
79
}
68
80
]
69
81
}
70
82
"""
71
83
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 :
73
93
"""Instantiate an instance.
74
94
75
95
Args:
76
96
fragment (Fragment): An instance of the Application class which is derived from Fragment.
77
97
rules (Text): Selection rules in JSON string.
78
98
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.
79
102
"""
80
103
81
104
# rules: Text = "", all_matched: bool = False,
82
105
83
106
# Delay loading the rules as JSON string till compute time.
84
107
self ._rules_json_str = rules if rules and rules .strip () else None
85
108
self ._all_matched = all_matched # all_matched
109
+ self ._sort_by_sop_instance_count = sort_by_sop_instance_count # sort_by_sop_instance_count
86
110
self .input_name_study_list = "dicom_study_list"
87
111
self .output_name_selected_series = "study_selected_series_list"
88
112
@@ -100,23 +124,44 @@ def compute(self, op_input, op_output, context):
100
124
101
125
dicom_study_list = op_input .receive (self .input_name_study_list )
102
126
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
+
104
144
op_output .emit (study_selected_series , self .output_name_selected_series )
105
145
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 ]:
107
149
"""Selects the series with the given matching rules.
108
150
109
151
If rules object is None, all series will be returned with series instance UID as the selection name.
110
152
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
113
155
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
115
157
116
158
Args:
117
159
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.
119
161
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.
120
165
121
166
Returns:
122
167
list: A list of objects of type StudySelectedSeries.
@@ -153,7 +198,7 @@ def filter(self, selection_rules, dicom_study_list, all_matched: bool = False) -
153
198
continue
154
199
155
200
# 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 )
157
202
if series_list and len (series_list ) > 0 :
158
203
for series in series_list :
159
204
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
185
230
study_selected_series_list .append (study_selected_series )
186
231
return study_selected_series_list
187
232
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 ]:
189
236
"""Finds series whose attributes match the given attributes.
190
237
191
238
Args:
192
239
attributes (dict): Dictionary of attributes for matching
193
240
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.
194
244
195
245
Returns:
196
246
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)
236
286
237
287
if not attr_value :
238
288
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
241
300
elif isinstance (attr_value , str ):
242
301
matched = attr_value .casefold () == (value_to_match .casefold ())
243
302
if not matched :
@@ -268,6 +327,14 @@ def _select_series(self, attributes: dict, study: DICOMStudy, all_matched=False)
268
327
if not all_matched :
269
328
return found_series
270
329
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
+
271
338
return found_series
272
339
273
340
@staticmethod
@@ -353,6 +420,15 @@ def test():
353
420
"BodyPartExamined": "Abdomen",
354
421
"SeriesDescription" : "Not to be matched"
355
422
}
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
+ }
356
432
}
357
433
]
358
434
}
0 commit comments