Skip to content

Commit d596f74

Browse files
committed
Added support for StudySelectedSeries in consumer operator, though not in inference yet.
1 parent a8d4232 commit d596f74

File tree

4 files changed

+242
-140
lines changed

4 files changed

+242
-140
lines changed

monai/deploy/operators/dicom_data_loader_operator.py

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
# limitations under the License.
1111

1212
import os
13+
from pathlib import Path
1314
from typing import List
1415

1516
import monai.deploy.core as md
@@ -35,23 +36,40 @@ class DICOMDataLoaderOperator(Operator):
3536
"""
3637

3738
def compute(self, op_input: InputContext, op_output: OutputContext, context: ExecutionContext):
38-
"""Performs computation for this operator.
39+
"""Performs computation for this operator and handlesI/O."""
40+
41+
input_path = op_input.get().path
42+
dicom_study_list = self.load_data_to_studies(input_path)
43+
op_output.set(dicom_study_list)
44+
45+
def load_data_to_studies(self, input_path: Path) -> List[DICOMStudy]:
46+
"""Load DICOM data from files into DICOMStudy objects in a list.
3947
4048
It scans through the input directory for all SOP instances.
4149
It groups them by a collection of studies where each study contains one or more series.
42-
This method returns a set of studies.
50+
This method returns a list of studies.
51+
52+
Args:
53+
input_path (Path): The folder containing DICOM instance files.
54+
55+
Returns:
56+
List[DICOMStudy]: List of DICOMStudy.
57+
58+
Raises:
59+
ValueError: If the folder to load files from does not exist.
4360
"""
61+
if not input_path.exists() or not input_path.is_dir():
62+
raise ValueError("Required input folder does not exist.")
63+
4464
files: List[str] = []
45-
input_path = op_input.get().path
4665
self._list_files(input_path, files)
47-
dicom_study_list = self._load_data(files)
48-
op_output.set(dicom_study_list)
66+
return self._load_data(files)
4967

5068
def _list_files(self, path, files: List[str]):
5169
"""Collects fully qualified names of all files recurvisely given a directory path.
5270
5371
Args:
54-
path: A directoty containing DICOM SOP instances. It have have nested hirerarchical directories.
72+
path: A directoty containing DICOM SOP instances. It may have nested hirerarchical directories.
5573
files: This method populates "files" with fully qualified names of files that belong to the specified directory.
5674
"""
5775
for item in os.listdir(path):
@@ -228,10 +246,9 @@ def populate_series_attributes(self, series, sop_instance):
228246

229247
def main():
230248
data_path = "../../../examples/ai_spleen_seg_data/dcm"
231-
files = []
249+
232250
loader = DICOMDataLoaderOperator()
233-
loader._list_files(data_path, files)
234-
study_list = loader._load_data(files)
251+
study_list = loader.load_data_to_studies(Path(data_path).absolute())
235252

236253
for study in study_list:
237254
print("###############################")

monai/deploy/operators/dicom_seg_writer_operator.py

Lines changed: 93 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import json
1515
import logging
1616
import os
17+
from pathlib import Path
1718
from random import randint
1819
from typing import List, Optional, Union
1920

@@ -33,12 +34,13 @@
3334
import monai.deploy.core as md
3435
from monai.deploy.core import DataPath, ExecutionContext, Image, InputContext, IOType, Operator, OutputContext
3536
from monai.deploy.core.domain.dicom_series import DICOMSeries
36-
from monai.deploy.operators.dicom_data_loader_operator import DICOMDataLoaderOperator
37-
from monai.deploy.operators.dicom_series_to_volume_operator import DICOMSeriesToVolumeOperator
37+
from monai.deploy.core.domain.dicom_series_selection import SelectedSeries, StudySelectedSeries
3838

3939

4040
@md.input("seg_image", Image, IOType.IN_MEMORY)
41-
@md.input("dicom_series", DICOMSeries, IOType.IN_MEMORY)
41+
@md.input(
42+
"study_selected_series_list", List[StudySelectedSeries], IOType.IN_MEMORY
43+
) # "dicom_series", DICOMSeries, IOType.IN_MEMORY)
4244
@md.output("dicom_seg_instance", DataPath, IOType.DISK)
4345
@md.env(pip_packages=["pydicom >= 1.4.2", "SimpleITK >= 2.0.0"])
4446
class DICOMSegmentationWriterOperator(Operator):
@@ -48,7 +50,7 @@ class DICOMSegmentationWriterOperator(Operator):
4850

4951
# Supported input image format, based on extension.
5052
SUPPORTED_EXTENSIONS = [".nii", ".nii.gz", ".mhd"]
51-
# DICOM instance file extension. Case insentiive in string comaprision.
53+
# DICOM instance file extension. Case insensitive in string comparison.
5254
DCM_EXTENSION = ".dcm"
5355
# Suffix to add to file name to indicate DICOM Seg dcm file.
5456
DICOMSEG_SUFFIX = "-DICOMSEG"
@@ -79,53 +81,94 @@ def __init__(self, seg_labels: Optional[Union[List[str], str]] = None, *args, **
7981
if isinstance(seg_labels, str):
8082
self._seg_labels = [seg_labels]
8183
elif isinstance(seg_labels, list):
84+
self._seg_labels = []
8285
for label in seg_labels:
83-
if not isinstance(label, str):
86+
if isinstance(label, str) or isinstance(label, int):
87+
self._seg_labels.append(label)
88+
else:
8489
raise ValueError(f"List of strings expected, but contains {label} of type {type(label)}.")
85-
self._seg_labels = seg_labels
86-
else:
87-
raise ValueError("List of strings expected.")
8890

8991
def compute(self, op_input: InputContext, op_output: OutputContext, context: ExecutionContext):
90-
dicom_series = op_input.get("dicom_series")
92+
"""Performs computation for this operator and handles I/O.
93+
94+
For now, only a single segmentation image object or file is supported and the selected DICOM
95+
series for inference is required, because the DICOM Seg IOD needs to refer to original instance.
96+
When there are multiple selected series in the input, the first series' containing study will
97+
be used for retrieving DICOM Study module attributes, e.g. StudyInstanceUID.
98+
99+
Raises:
100+
FileNotFoundError: When image object not in the input, and segmentation image file not found either.
101+
ValueError: Neither image object nor image file's folder is in the input, or no selected series.
102+
"""
91103

92-
# Get the seg image in numpy
104+
# Gets the input, prepares the output folder, and then delegates the processing.
105+
study_selected_series_list = op_input.get("study_selected_series_list")
106+
if not study_selected_series_list or len(study_selected_series_list) < 1:
107+
raise ValueError("Missing input, list of 'StudySelectedSeries'.")
108+
for study_selected_series in study_selected_series_list:
109+
if not isinstance(study_selected_series, StudySelectedSeries):
110+
raise ValueError("Element in input is not expected type, 'StudySelectedSeries'.")
111+
112+
seg_image = op_input.get("seg_image")
113+
# In case the Image object is not in the input, and input is the seg image file folder path.
114+
if not isinstance(seg_image, Image):
115+
if isinstance(seg_image, DataPath):
116+
input_path = op_input.get("segmentation_image").path
117+
seg_image, _ = self.select_input_file(input_path)
118+
else:
119+
raise ValueError("Input 'seg_image' is not Image or DataPath.")
120+
121+
output_dir = op_output.get().path
122+
output_dir.mkdir(parents=True, exist_ok=True)
123+
124+
self.process_images(seg_image_numpy, dicom_series, output_path)
125+
126+
def process_images(
127+
self, image: Union[Image, Path], study_selected_series_list: StudySelectedSeries, output_dir: Path
128+
):
129+
""" """
130+
# Get the seg image in numpy, and if the image is passed in as object, need to fake a input path.
93131
seg_image_numpy = None
94132
input_path = "dicom_seg"
95-
seg_image = op_input.get("seg_image")
96-
if isinstance(seg_image, Image):
97-
seg_image_numpy = seg_image.asnumpy()
98-
elif isinstance(seg_image, DataPath):
99-
input_path = op_input.get("segmentation_image").path
100-
input_path, _ = self.select_input_file(input_path)
133+
134+
if isinstance(image, Image):
135+
seg_image_numpy = image.asnumpy()
136+
elif isinstance(image, Path):
137+
input_path = image # It is expected that this is the image file path.
101138
seg_image_numpy = self._image_file_to_numpy(input_path)
102139
else:
103-
# What else
104-
raise ValueError("seg_image is not Image or DataPath")
140+
raise ValueError("'image' is not an Image object or a supported image file.")
105141

106-
# Create the output path for created DICOM Seg instance
107-
output_dir = op_output.get().path
108-
output_dir.mkdir(parents=True, exist_ok=True)
142+
# The output DICOM Seg instance file name is based on the actual or made-up input image file name.
109143
output_filename = "{0}{1}{2}".format(
110144
os.path.splitext(os.path.basename(input_path))[0],
111145
DICOMSegmentationWriterOperator.DICOMSEG_SUFFIX,
112146
DICOMSegmentationWriterOperator.DCM_EXTENSION,
113147
)
114-
output_path = os.path.join(output_dir, output_filename)
148+
output_path = output_dir / output_filename
115149

116-
self.create_dicom_seg(seg_image_numpy, dicom_series, output_path)
150+
# Pick DICOM Series that was used as input for getting the seg image.
151+
# For now, first one in the list.
152+
for study_selected_series in study_selected_series_list:
153+
if not isinstance(study_selected_series, StudySelectedSeries):
154+
raise ValueError("Element in input is not expected type, 'StudySelectedSeries'.")
155+
selected_series = study_selected_series.selected_series[0]
156+
dicom_series = selected_series.series
157+
self.create_dicom_seg(seg_image_numpy, dicom_series, output_path)
158+
break
117159

118-
def create_dicom_seg(self, image, dicom_series: DICOMSeries, file_path: str):
119-
dicom_dataset_list = [i.get_native_sop_instance() for i in dicom_series.get_sop_instances()]
160+
def create_dicom_seg(self, image: Image, dicom_series: DICOMSeries, file_path: Path):
161+
file_path.parent.absolute().mkdir(parents=True, exist_ok=True)
120162

163+
dicom_dataset_list = [i.get_native_sop_instance() for i in dicom_series.get_sop_instances()]
121164
# DICOM Seg creation
122165
self._seg_writer = DICOMSegWriter()
123166
try:
124-
self._seg_writer.write(image, dicom_dataset_list, file_path, self._seg_labels)
167+
self._seg_writer.write(image, dicom_dataset_list, file_path.name, self._seg_labels)
125168
# TODO: get a class to encapsulate the seg label information.
126169

127170
# Test reading back
128-
_ = self._read_from_dcm(file_path)
171+
_ = self._read_from_dcm(file_path.name)
129172
except Exception as ex:
130173
print("DICOMSeg creation failed. Error:\n{}".format(ex))
131174
raise
@@ -193,7 +236,7 @@ def _get_label_list(self, stringfied_list_of_labels: str = ""):
193236
"""
194237

195238
# Use json.loads as a convenience method to convert string to list of strings
196-
assert isinstance(stringfied_list_of_labels, str), "Expected stringfiled list pf labels."
239+
assert isinstance(stringfied_list_of_labels, str), "Expected stringfied list pf labels."
197240

198241
label_list = ["default-label"] # Use this as default if empty string
199242
if stringfied_list_of_labels:
@@ -212,7 +255,7 @@ def write(self, seg_img, input_ds, outfile, seg_labels):
212255
"""Write DICOM Segmentation object for the segmentation image
213256
214257
Args:
215-
seg_img (numpy array): numpy array of the segmentatin image.
258+
seg_img (numpy array): numpy array of the segmentation image.
216259
input_ds (list): list of Pydicom datasets of the original DICOM instances.
217260
outfile (str): path for the output DICOM instance file.
218261
seg_labels: list of labels for the segments
@@ -571,7 +614,7 @@ def segslice_from_mhd(dcm_output, seg_img, input_ds, num_labels):
571614
img_slice, label, out_frame_counter, safe_get(input_ds[img_slice], 0x00200032)
572615
)
573616
)
574-
seg_slice = np.zeros((1, seg_img.shape[1], seg_img.shape[2]), dtype=np.bool)
617+
seg_slice = np.zeros((1, seg_img.shape[1], seg_img.shape[2]), dtype=bool)
575618

576619
seg_slice[np.expand_dims(seg_img[img_slice, ...] == label, 0)] = 1
577620

@@ -584,7 +627,7 @@ def segslice_from_mhd(dcm_output, seg_img, input_ds, num_labels):
584627

585628
dcm_output.add_new(0x52009230, "SQ", out_frames) # PerFrameFunctionalGroupsSequence
586629
dcm_output.NumberOfFrames = out_frame_counter
587-
dcm_output.PixelData = np.packbits(np.flip(np.reshape(out_pixels.astype(np.bool), (-1, 8)), 1)).tostring()
630+
dcm_output.PixelData = np.packbits(np.flip(np.reshape(out_pixels.astype(bool), (-1, 8)), 1)).tobytes()
588631

589632
dcm_output.get(0x00081115)[0].add_new(0x0008114A, "SQ", referenceInstances) # ReferencedInstanceSequence
590633

@@ -616,24 +659,36 @@ def segslice_from_mhd(dcm_output, seg_img, input_ds, num_labels):
616659

617660

618661
def test():
619-
data_path = "/home/mqin/src/monai-deploy-app-sdk/examples/apps/ai_spleen_seg_app/input"
620-
out_path = "/home/mqin/src/monai-deploy-app-sdk/examples/apps/ai_spleen_seg_app/output/dcm_seg_test.dcm"
662+
from monai.deploy.operators.dicom_data_loader_operator import DICOMDataLoaderOperator
663+
from monai.deploy.operators.dicom_series_selector_operator import DICOMSeriesSelectorOperator
664+
from monai.deploy.operators.dicom_series_to_volume_operator import DICOMSeriesToVolumeOperator
665+
666+
data_path = "../../../examples/ai_spleen_seg_data/dcm"
667+
out_path = "../../../examples/output_seg_op/dcm_seg_test.dcm"
621668

622669
files = []
623670
loader = DICOMDataLoaderOperator()
624-
loader._list_files(data_path, files)
625-
study_list = loader._load_data(files)
671+
series_selector = DICOMSeriesSelectorOperator()
672+
dcm_to_volume_op = DICOMSeriesToVolumeOperator()
673+
seg_writer = DICOMSegmentationWriterOperator()
674+
675+
# Testing with more granular functions
676+
study_list = loader.load_data_to_studies(Path(data_path).absolute())
626677
series = study_list[0].get_all_series()[0]
627678

628-
dcm_to_volume_op = DICOMSeriesToVolumeOperator()
629679
dcm_to_volume_op.prepare_series(series)
630680
voxels = dcm_to_volume_op.generate_voxel_data(series)
631681
metadata = dcm_to_volume_op.create_metadata(series)
632682
image = dcm_to_volume_op.create_volumetric_image(voxels, metadata)
633683
image_numpy = image.asnumpy()
634684

635-
seg_writer = DICOMSegmentationWriterOperator()
636-
seg_writer.create_dicom_seg(image_numpy, series, out_path)
685+
seg_writer.create_dicom_seg(image_numpy, series, Path(out_path).absolute())
686+
687+
# Testing with the main entry functions
688+
study_list = loader.load_data_to_studies(Path(data_path).absolute())
689+
_, study_selected_series_list = series_selector.filter(None, study_list)
690+
image = dcm_to_volume_op.convert_to_image(study_selected_series_list)
691+
seg_writer.process_images(image, study_selected_series_list, Path(out_path).parent.absolute())
637692

638693

639694
if __name__ == "__main__":

0 commit comments

Comments
 (0)