Skip to content

Commit b207444

Browse files
committed
Made DICOMSeriesToVolume consistent with ITK in serving NumPy array and metadata with loaded dcm instances
Signed-off-by: mmelqin <mingmelvinq@nvidia.com>
1 parent cd8c9fb commit b207444

File tree

2 files changed

+44
-15
lines changed

2 files changed

+44
-15
lines changed

monai/deploy/operators/dicom_series_to_volume_operator.py

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Copyright 2021 MONAI Consortium
1+
# Copyright 2021-2022 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
@@ -26,6 +26,8 @@ class DICOMSeriesToVolumeOperator(Operator):
2626
"""This operator converts an instance of DICOMSeries into an Image object.
2727
2828
The loaded Image Object can be used for further processing via other operators.
29+
The data array will be a 3D image NumPy array with index order of `DHW`.
30+
Channel is limited to 1 as of now, and `C` is absent in the NumPy array.
2931
"""
3032

3133
def compute(self, op_input: InputContext, op_output: OutputContext, context: ExecutionContext):
@@ -80,10 +82,14 @@ def generate_voxel_data(self, series):
8082
A 3D numpy tensor representing the volumetric data.
8183
"""
8284
slices = series.get_sop_instances()
83-
# Need to transpose the DICOM pixel_array and pack the slice as the last dim.
84-
# This is to have the same numpy ndarray as from Monai ImageReader (ITK, NiBabel etc).
85-
vol_data = np.stack([np.transpose(s.get_pixel_array()) for s in slices], axis=-1)
85+
# The sop_instance get_pixel_array() returns a 2D NumPy array with index order
86+
# of `HW`. The pixel array of all instances will be stacked along the first axis,
87+
# so the final 3D NumPy array will have index order of [DHW]. This is consistent
88+
# with the NumPy array returned from the ITK GetArrayViewFromImage on the image
89+
# loaded from the same DICOM series.
90+
vol_data = np.stack([s.get_pixel_array() for s in slices], axis=0)
8691
vol_data = vol_data.astype(np.int16)
92+
8793
# Rescale Intercept and Slope attributes might be missing, but safe to assume defaults.
8894
try:
8995
intercept = slices[0][0x0028, 0x1052].value
@@ -126,6 +132,7 @@ def prepare_series(self, series):
126132
"""
127133

128134
if len(series._sop_instances) <= 1:
135+
series.depth_pixel_spacing = 1.0 # Default to 1, e.g. for CR image, similar to (Simple) ITK
129136
return
130137

131138
slice_indices_to_be_removed = []
@@ -355,7 +362,7 @@ def test():
355362
from monai.deploy.operators.dicom_series_selector_operator import DICOMSeriesSelectorOperator
356363

357364
current_file_dir = Path(__file__).parent.resolve()
358-
data_path = current_file_dir.joinpath("../../../examples/ai_spleen_seg_data/dcm")
365+
data_path = current_file_dir.joinpath("../../../examples/ai_spleen_seg_data/dcm") # current_file_dir.joinpath("../../../examples/ai_spleen_seg_data/dcm")
359366
loader = DICOMDataLoaderOperator()
360367
study_list = loader.load_data_to_studies(Path(data_path).absolute())
361368

@@ -365,6 +372,7 @@ def test():
365372
op = DICOMSeriesToVolumeOperator()
366373
image = op.convert_to_image(study_selected_series_list)
367374

375+
print(f"Image NumPy array shape (index order DHW): {image.asnumpy().shape}")
368376
for k, v in image.metadata().items():
369377
print(f"{(k)}: {(v)}")
370378

monai/deploy/operators/monai_seg_inference_operator.py

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,7 @@ def compute(self, op_input: InputContext, op_output: OutputContext, context: Exe
232232
# the resultant ndarray for each slice needs to be transposed back, and the depth
233233
# dim moved back to first, otherwise the seg ndarray would be flipped compared to
234234
# the DICOM pixel array, causing the DICOM Seg Writer generate flipped seg images.
235-
out_ndarray = np.swapaxes(out_ndarray, 2, 0).astype(np.uint8)
235+
out_ndarray = out_ndarray.T.astype(np.uint8) # np.swapaxes(out_ndarray, 2, 0).astype(np.uint8)
236236
print(f"Output Seg image numpy array shaped: {out_ndarray.shape}")
237237
print(f"Output Seg image pixel max value: {np.amax(out_ndarray)}")
238238
out_image = Image(out_ndarray, input_img_metadata)
@@ -278,6 +278,10 @@ class InMemImageReader(ImageReader):
278278
279279
This is derived from MONAI ImageReader. Instead of reading image from file system, this
280280
class simply converts a in-memory SDK Image object to the expected formats from ImageReader.
281+
282+
The loaded data array will be in C order, for example, a 3D image NumPy array index order
283+
will be `CDWH`. The actual data array loaded is to be the same as that from the
284+
MONAI ITKReader, which can also load DICOM series.
281285
"""
282286

283287
def __init__(self, input_image: Image, channel_dim: Optional[int] = None, **kwargs):
@@ -290,23 +294,39 @@ def verify_suffix(self, filename: Union[Sequence[str], str]) -> bool:
290294
return True
291295

292296
def read(self, data: Union[Sequence[str], str], **kwargs) -> Union[Sequence[Any], Any]:
293-
# Really does not have anything to do.
294-
return self.input_image.asnumpy()
297+
# Really does not have anything to do. Simply return the Image object
298+
return self.input_image
295299

296300
def get_data(self, input_image):
297301
"""Extracts data array and meta data from loaded image and return them.
298302
299303
This function returns two objects, first is numpy array of image data, second is dict of meta data.
300304
It constructs `affine`, `original_affine`, and `spatial_shape` and stores them in meta dict.
301-
A single image is loaded with a single set of metadata as of now."""
305+
A single image is loaded with a single set of metadata as of now.
306+
307+
The App SDK Image asnumpy function is expected to return a NumPy array of index order `DHW`.
308+
This is because in the DICOM serie to volume operator pydicom Dataset pixel_array is used to
309+
to get per instance pixel NumPy array, with index order of `HW`. When all instances are stacked,
310+
along the first axis, the Image NumPy array's index order is `DHW`. ITK array_view_from_image
311+
and SimpleITK GetArrayViewFromImage also returns a Numpy array with index order of `DHW`.
312+
This NumPy array is then transposed to be consistent with the NumPy array from NibabelReader,
313+
which loads NIfTI image and gets NumPy array using the image's get_fdata() function.
314+
315+
Args:
316+
input_image (Image): an App SDK Image object.
317+
"""
302318

303319
img_array: List[np.ndarray] = []
304320
compatible_meta: Dict = {}
305321

306-
for i in ensure_tuple(self.input_image):
322+
for i in ensure_tuple(input_image):
307323
if not isinstance(i, Image):
308324
raise TypeError("Only object of Image type is supported.")
309-
data = i.asnumpy()
325+
326+
# The Image asnumpy() retruns NumPy array similar to ITK array_view_from_image
327+
# The array then needs to be transposed, as does in MONAI ITKReader, to align
328+
# with the output from Nibabel reader loading NIfTI files.
329+
data = i.asnumpy().T
310330
img_array.append(data)
311331
header = self._get_meta_dict(i)
312332
_copy_compatible_dict(header, compatible_meta)
@@ -328,18 +348,19 @@ def _get_meta_dict(self, img: Image) -> Dict:
328348
# So, for now have to get to the Image generator, namely DICOMSeriesToVolumeOperator, and
329349
# rely on its published metadata.
330350

331-
# Recall that the column and row data for pydicom pixel_array had been switched, and the depth
332-
# is the last dim in DICOMSeriesToVolumeOperator
351+
# Referring to the MONAI ITKReader, the spacing is simply a NumPy array from the ITK image
352+
# GetSpacing, in WHD.
333353
meta_dict["spacing"] = np.asarray(
334354
[
335-
img_meta_dict["col_pixel_spacing"],
336355
img_meta_dict["row_pixel_spacing"],
356+
img_meta_dict["col_pixel_spacing"],
337357
img_meta_dict["depth_pixel_spacing"],
338358
]
339359
)
340360
meta_dict["original_affine"] = np.asarray(img_meta_dict["nifti_affine_transform"])
341361
meta_dict["affine"] = meta_dict["original_affine"]
342-
meta_dict["spatial_shape"] = np.asarray(img.asnumpy().shape)
362+
# The spatial shape, again, referring to ITKReader, it is the WHD
363+
meta_dict["spatial_shape"] = np.asarray(img.asnumpy().T.shape)
343364
# Well, no channel as the image data shape is forced to the the same as spatial shape
344365
meta_dict["original_channel_dim"] = "no_channel"
345366

0 commit comments

Comments
 (0)