@@ -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}
0 commit comments