1- # Copyright 2021-2023 MONAI Consortium
1+ # Copyright 2021-2025 MONAI Consortium
22# Licensed under the Apache License, Version 2.0 (the "License");
33# you may not use this file except in compliance with the License.
44# You may obtain a copy of the License at
1515from json import loads as json_loads
1616from typing import List
1717
18+ import numpy as np
19+
1820from monai .deploy .core import ConditionType , Fragment , Operator , OperatorSpec
1921from monai .deploy .core .domain .dicom_series import DICOMSeries
2022from monai .deploy .core .domain .dicom_series_selection import SelectedSeries , StudySelectedSeries
@@ -30,23 +32,24 @@ class DICOMSeriesSelectorOperator(Operator):
3032 Named output:
3133 study_selected_series_list: A list of StudySelectedSeries objects. Downstream receiver optional.
3234
33- This class can be considered a base class, and a derived class can override the 'filter' function to with
34- custom logics .
35+ This class can be considered a base class, and a derived class can override the 'filter' function with
36+ custom logic .
3537
3638 In its default implementation, this class
3739 1. selects a series or all matched series within the scope of a study in a list of studies
3840 2. uses rules defined in JSON string, see below for details
3941 3. supports DICOM Study and Series module attribute matching
4042 4. supports multiple named selections, in the scope of each DICOM study
41- 5. outputs a list of SutdySelectedSeries objects, as well as a flat list of SelectedSeries (to be deprecated)
43+ 5. outputs a list of StudySelectedSeries objects, as well as a flat list of SelectedSeries (to be deprecated)
4244
4345 The selection rules are defined in JSON,
4446 1. attribute "selections" value is a list of selections
4547 2. each selection has a "name", and its "conditions" value is a list of matching criteria
4648 3. each condition uses the implicit equal operator; in addition, the following are supported:
47- - regex and range matching for float and int types
49+ - regex, relational, and range matching for float and int types
4850 - regex matching for str type
4951 - union matching for set type
52+ - image orientation check for the ImageOrientationPatient tag
5053 4. DICOM attribute keywords are used, and only for those defined as DICOMStudy and DICOMSeries properties
5154
5255 An example selection rules:
@@ -76,6 +79,15 @@ class DICOMSeriesSelectorOperator(Operator):
7679 "ImageType": ["PRIMARY", "ORIGINAL", "AXIAL"],
7780 "SliceThickness": [3, 5]
7881 }
82+ },
83+ {
84+ "name": "CT Series 4",
85+ "conditions": {
86+ "StudyDescription": "(.*?)",
87+ "Modality": "(?i)CT",
88+ "ImageOrientationPatient": "Axial",
89+ "SliceThickness": [2, ">"]
90+ }
7991 }
8092 ]
8193 }
@@ -101,8 +113,6 @@ def __init__(
101113 of DICOM images); Defaults to False for no sorting.
102114 """
103115
104- # rules: Text = "", all_matched: bool = False,
105-
106116 # Delay loading the rules as JSON string till compute time.
107117 self ._rules_json_str = rules if rules and rules .strip () else None
108118 self ._all_matched = all_matched # all_matched
@@ -128,12 +138,12 @@ def compute(self, op_input, op_output, context):
128138 selection_rules , dicom_study_list , self ._all_matched , self ._sort_by_sop_instance_count
129139 )
130140
131- # log Series Description and Series Instance UID of the first selected DICOM Series (i.e. the one to be used for inference)
141+ # Log Series Description and Series Instance UID of the first selected DICOM Series (i.e. the one to be used for inference)
132142 if study_selected_series and len (study_selected_series ) > 0 :
133143 inference_study = study_selected_series [0 ]
134144 if inference_study .selected_series and len (inference_study .selected_series ) > 0 :
135145 inference_series = inference_study .selected_series [0 ].series
136- logging .info ("Series Selection finalized. " )
146+ logging .info ("Series Selection finalized" )
137147 logging .info (
138148 f"Series Description of selected DICOM Series for inference: { inference_series .SeriesDescription } "
139149 )
@@ -151,9 +161,10 @@ def filter(
151161 If rules object is None, all series will be returned with series instance UID as the selection name.
152162
153163 Supported matching logic:
154- Float + Int: exact matching, range matching (if a list with two numerical elements is provided) , and regex matching
164+ Float + Int: exact matching, relational matching, range matching , and regex matching
155165 String: matches case insensitive, if fails then tries RegEx search
156166 String array (set): matches as subset, case insensitive
167+ ImageOrientationPatient tag: image orientation (Axial, Coronal, Sagittal) matching
157168
158169 Args:
159170 selection_rules (object): JSON object containing the matching rules.
@@ -244,6 +255,9 @@ def _select_series(
244255
245256 Returns:
246257 List of DICOMSeries. At most one element if all_matched is False.
258+
259+ Raises:
260+ NotImplementedError: If the value_to_match type is not supported for matching.
247261 """
248262 assert isinstance (attributes , dict ), '"attributes" must be a dict.'
249263
@@ -261,13 +275,13 @@ def _select_series(
261275 matched = True
262276 # Simple matching on attribute value
263277 for key , value_to_match in attributes .items ():
264- logging .info (f"On attribute: { key !r} to match value: { value_to_match !r} " )
278+ logging .info (f" On attribute: { key !r} to match value: { value_to_match !r} " )
265279 # Ignore None
266280 if not value_to_match :
267281 continue
268282 # Try getting the attribute value from Study and current Series prop dict
269283 attr_value = series_attr .get (key , None )
270- logging .info (f" Series attribute { key } value: { attr_value } " )
284+ logging .info (f" Series attribute { key } value: { attr_value } " )
271285
272286 # If not found, try the best at the native instance level for string VR
273287 # This is mainly for attributes like ImageType
@@ -280,29 +294,24 @@ def _select_series(
280294 else :
281295 attr_value = elem .value # element's value
282296
297+ logging .info (f" Instance level attribute { key } value: { attr_value } " )
283298 series_attr .update ({key : attr_value })
284299 except Exception :
285- logging .info (f" Attribute { key } not at instance level either. " )
300+ logging .info (f" Attribute { key } not at instance level either" )
286301
287302 if not attr_value :
303+ logging .info (f" Missing attribute: { key !r} " )
288304 matched = False
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
305+ # Image orientation check
306+ elif key == "ImageOrientationPatient" :
307+ matched = self ._match_image_orientation (value_to_match , attr_value )
308+ elif isinstance (attr_value , (float , int )):
309+ matched = self ._match_numeric_condition (value_to_match , attr_value )
300310 elif isinstance (attr_value , str ):
301311 matched = attr_value .casefold () == (value_to_match .casefold ())
302312 if not matched :
303313 # For str, also try RegEx search to check for a match anywhere in the string
304314 # unless the user constrains it in the expression.
305- logging .info ("Series attribute string value did not match. Try regEx." )
306315 if re .search (value_to_match , attr_value , re .IGNORECASE ):
307316 matched = True
308317 elif isinstance (attr_value , list ):
@@ -314,10 +323,12 @@ def _select_series(
314323 elif isinstance (value_to_match , (str , numbers .Number )):
315324 matched = str (value_to_match ).lower () in meta_data_list
316325 else :
317- raise NotImplementedError (f"Not support for matching on this type: { type (value_to_match )} " )
326+ raise NotImplementedError (
327+ f"No support for matching condition { value_to_match } (type: { type (value_to_match )} )"
328+ )
318329
319330 if not matched :
320- logging .info ("This series does not match the selection conditions. " )
331+ logging .info ("This series does not match the selection conditions" )
321332 break
322333
323334 if matched :
@@ -327,16 +338,145 @@ def _select_series(
327338 if not all_matched :
328339 return found_series
329340
330- # if sorting indicated and multiple series found
341+ # If sorting indicated and multiple series found, sort series in descending SOP instance count
331342 if sort_by_sop_instance_count and len (found_series ) > 1 :
332- # sort series in descending SOP instance count
333343 logging .info (
334344 "Multiple series matched the selection criteria; choosing series with the highest number of DICOM images."
335345 )
336346 found_series .sort (key = lambda x : len (x .get_sop_instances ()), reverse = True )
337347
338348 return found_series
339349
350+ def _match_numeric_condition (self , value_to_match , attr_value ):
351+ """
352+ Helper method to match numeric conditions, supporting relational, inclusive range, regex, and exact match checks.
353+
354+ Supported formats:
355+ - [val, ">"]: match if attr_value > val
356+ - [val, ">="]: match if attr_value >= val
357+ - [val, "<"]: match if attr_value < val
358+ - [val, "<="]: match if attr_value <= val
359+ - [val, "!="]: match if attr_value != val
360+ - [min_val, max_val]: inclusive range check
361+ - "regex": regular expression match
362+ - number: exact match
363+
364+ Args:
365+ value_to_match (Union[list, str, int, float]): The condition to match against.
366+ attr_value (Union[int, float]): The attribute value from the series.
367+
368+ Returns:
369+ bool: True if the attribute value matches the condition, else False.
370+
371+ Raises:
372+ NotImplementedError: If the value_to_match condition is not supported for numeric matching.
373+ """
374+
375+ if isinstance (value_to_match , list ):
376+ # Relational operator check: >, >=, <, <=, !=
377+ if len (value_to_match ) == 2 and isinstance (value_to_match [1 ], str ):
378+ val = float (value_to_match [0 ])
379+ op = value_to_match [1 ]
380+ if op == ">" :
381+ return attr_value > val
382+ elif op == ">=" :
383+ return attr_value >= val
384+ elif op == "<" :
385+ return attr_value < val
386+ elif op == "<=" :
387+ return attr_value <= val
388+ elif op == "!=" :
389+ return attr_value != val
390+ else :
391+ raise NotImplementedError (
392+ f"Unsupported relational operator { op !r} in numeric condition. Must be one of: '>', '>=', '<', '<=', '!='"
393+ )
394+
395+ # Inclusive range check
396+ elif len (value_to_match ) == 2 and all (isinstance (v , (int , float )) for v in value_to_match ):
397+ return value_to_match [0 ] <= attr_value <= value_to_match [1 ]
398+
399+ else :
400+ raise NotImplementedError (f"No support for numeric matching condition { value_to_match } " )
401+
402+ # Regular expression match
403+ elif isinstance (value_to_match , str ):
404+ return bool (re .fullmatch (value_to_match , str (attr_value )))
405+
406+ # Exact numeric match
407+ elif isinstance (value_to_match , (int , float )):
408+ return value_to_match == attr_value
409+
410+ else :
411+ raise NotImplementedError (f"No support for numeric matching on this type: { type (value_to_match )} " )
412+
413+ def _match_image_orientation (self , value_to_match , attr_value ):
414+ """
415+ Helper method to calculate and match the image orientation using the ImageOrientationPatient tag.
416+
417+ Supported image orientation inputs for matching (case-insensitive):
418+ - "Axial"
419+ - "Coronal"
420+ - "Sagittal"
421+
422+ Args:
423+ value_to_match (str): The image orientation condition to match against.
424+ attr_value (List[str]): Raw ImageOrientationPatient tag value from the series.
425+
426+ Returns:
427+ bool: True if the computed orientation matches the expected orientation, else False.
428+
429+ Raises:
430+ ValueError: If the expected orientation is invalid or the normal vector cannot be computed.
431+ """
432+
433+ # Validate image orientation to match input
434+ value_to_match = value_to_match .strip ().lower ().capitalize ()
435+ allowed_orientations = {"Axial" , "Coronal" , "Sagittal" }
436+ if value_to_match not in allowed_orientations :
437+ raise ValueError (f"Invalid orientation string { value_to_match !r} . Must be one of: { allowed_orientations } " )
438+
439+ # Format ImageOrientationPatient tag value as an array and grab row and column cosines
440+ iop_str = attr_value [0 ].strip ("[]" )
441+ iop = [float (x .strip ()) for x in iop_str .split ("," )]
442+ row_cosines = np .array (iop [:3 ], dtype = np .float64 )
443+ col_cosines = np .array (iop [3 :], dtype = np .float64 )
444+
445+ # Validate DICOM constraints (normal row and column cosines + should be orthogonal)
446+ # Throw warnings if tolerance exceeded
447+ tolerance = 1e-4
448+ row_norm = np .linalg .norm (row_cosines )
449+ col_norm = np .linalg .norm (col_cosines )
450+ dot_product = np .dot (row_cosines , col_cosines )
451+
452+ if abs (row_norm - 1.0 ) > tolerance :
453+ logging .warn (f"Row direction cosine normal is { row_norm } , deviates from 1 by more than { tolerance } " )
454+ if abs (col_norm - 1.0 ) > tolerance :
455+ logging .warn (f"Column direction cosine normal is { col_norm } , deviates from 1 by more than { tolerance } " )
456+ if abs (dot_product ) > tolerance :
457+ logging .warn (f"Row and Column cosines are not orthogonal: dot product = { dot_product } " )
458+
459+ # Normalize row and column vectors
460+ row_cosines /= np .linalg .norm (row_cosines )
461+ col_cosines /= np .linalg .norm (col_cosines )
462+
463+ # Compute and validate slice normal
464+ normal = np .cross (row_cosines , col_cosines )
465+ if np .linalg .norm (normal ) == 0 :
466+ raise ValueError ("Invalid normal vector computed from IOP" )
467+
468+ # Normalize the slice normal
469+ normal /= np .linalg .norm (normal )
470+
471+ # Identify the dominant image orientation
472+ axis_labels = ["Sagittal" , "Coronal" , "Axial" ]
473+ major_axis = np .argmax (np .abs (normal ))
474+ computed_orientation = axis_labels [major_axis ]
475+
476+ logging .info (f" Computed orientation from ImageOrientationPatient value: { computed_orientation } " )
477+
478+ return bool (computed_orientation == value_to_match )
479+
340480 @staticmethod
341481 def _get_instance_properties (obj : object ):
342482 if not obj :
@@ -368,9 +508,9 @@ def test():
368508 study_list = loader .load_data_to_studies (data_path )
369509 sample_selection_rule = json_loads (Sample_Rules_Text )
370510 print (f"Selection rules in JSON:\n { sample_selection_rule } " )
371- study_selected_seriee_list = selector .filter (sample_selection_rule , study_list )
511+ study_selected_series_list = selector .filter (sample_selection_rule , study_list )
372512
373- for sss_obj in study_selected_seriee_list :
513+ for sss_obj in study_selected_series_list :
374514 _print_instance_properties (sss_obj , pre_fix = "" , print_val = False )
375515 study = sss_obj .study
376516 pre_fix = " "
@@ -429,6 +569,15 @@ def test():
429569 "ImageType": ["PRIMARY", "ORIGINAL", "AXIAL"],
430570 "SliceThickness": [3, 5]
431571 }
572+ },
573+ {
574+ "name": "CT Series 4",
575+ "conditions": {
576+ "StudyDescription": "(.*?)",
577+ "Modality": "(?i)MR",
578+ "ImageOrientationPatient": "Axial",
579+ "SliceThickness": [2, ">"]
580+ }
432581 }
433582 ]
434583}
0 commit comments