Skip to content

Commit 456c3b0

Browse files
committed
series selector improvements
Signed-off-by: bluna301 <[email protected]>
1 parent c3100f5 commit 456c3b0

File tree

1 file changed

+180
-31
lines changed

1 file changed

+180
-31
lines changed

monai/deploy/operators/dicom_series_selector_operator.py

Lines changed: 180 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
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
@@ -15,6 +15,8 @@
1515
from json import loads as json_loads
1616
from typing import List
1717

18+
import numpy as np
19+
1820
from monai.deploy.core import ConditionType, Fragment, Operator, OperatorSpec
1921
from monai.deploy.core.domain.dicom_series import DICOMSeries
2022
from 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

Comments
 (0)