Repository: Project-MONAI/MONAI

Issue:
reader backend for dicom
**Is your feature request related to a problem? Please describe.**
this feature request is for adding basic Dicom support with a pydicom backend in addition to the existing readers (by extending the base class monai.data.ImageReader)
https://github.com/Project-MONAI/MONAI/blob/af0e0e9f757558d144b655c63afcea3a4e0a06f5/monai/data/image_reader.py#L45


note that as references: 
- there are deploy operators based on pydicom such as https://github.com/Project-MONAI/monai-deploy-app-sdk/tree/main/monai/deploy/operators
- the current itk backend ignores the some of the tags, but in general the new backend implementation should return a similar data structure: https://colab.research.google.com/drive/14R9RzmaKQn0Ahpxf9OwhW6U7kcH1_MRi
- the new backend will support the reading https://github.com/Project-MONAI/MONAI/discussions/4227 


Discussion:
Hi @yiheng-wang-nv ,

You can refer to the `ITKReader` for the DICOM features.

Thanks.


Resolving patch:
diff --git a/monai/data/__init__.py b/monai/data/__init__.py
--- a/monai/data/__init__.py
+++ b/monai/data/__init__.py
@@ -47,7 +47,7 @@
 from .folder_layout import FolderLayout
 from .grid_dataset import GridPatchDataset, PatchDataset, PatchIter, PatchIterd
 from .image_dataset import ImageDataset
-from .image_reader import ImageReader, ITKReader, NibabelReader, NrrdReader, NumpyReader, PILReader
+from .image_reader import ImageReader, ITKReader, NibabelReader, NrrdReader, NumpyReader, PILReader, PydicomReader
 from .image_writer import (
     SUPPORTED_WRITERS,
     ImageWriter,
diff --git a/monai/data/image_reader.py b/monai/data/image_reader.py
--- a/monai/data/image_reader.py
+++ b/monai/data/image_reader.py
@@ -9,6 +9,8 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+import glob
+import os
 import warnings
 from abc import ABC, abstractmethod
 from dataclasses import dataclass
@@ -19,7 +21,12 @@
 from torch.utils.data._utils.collate import np_str_obj_array_pattern
 
 from monai.config import DtypeLike, KeysCollection, PathLike
-from monai.data.utils import correct_nifti_header_if_necessary, is_supported_format, orientation_ras_lps
+from monai.data.utils import (
+    affine_to_spacing,
+    correct_nifti_header_if_necessary,
+    is_supported_format,
+    orientation_ras_lps,
+)
 from monai.transforms.utility.array import EnsureChannelFirst
 from monai.utils import ensure_tuple, ensure_tuple_rep, optional_import, require_pkg
 
@@ -27,22 +34,33 @@
     import itk
     import nibabel as nib
     import nrrd
+    import pydicom
     from nibabel.nifti1 import Nifti1Image
     from PIL import Image as PILImage
 
-    has_nrrd = has_itk = has_nib = has_pil = True
+    has_nrrd = has_itk = has_nib = has_pil = has_pydicom = True
 else:
     itk, has_itk = optional_import("itk", allow_namespace_pkg=True)
     nib, has_nib = optional_import("nibabel")
     Nifti1Image, _ = optional_import("nibabel.nifti1", name="Nifti1Image")
     PILImage, has_pil = optional_import("PIL.Image")
+    pydicom, has_pydicom = optional_import("pydicom")
     nrrd, has_nrrd = optional_import("nrrd", allow_namespace_pkg=True)
 
 OpenSlide, _ = optional_import("openslide", name="OpenSlide")
 CuImage, _ = optional_import("cucim", name="CuImage")
 TiffFile, _ = optional_import("tifffile", name="TiffFile")
 
-__all__ = ["ImageReader", "ITKReader", "NibabelReader", "NumpyReader", "PILReader", "WSIReader", "NrrdReader"]
+__all__ = [
+    "ImageReader",
+    "ITKReader",
+    "NibabelReader",
+    "NumpyReader",
+    "PILReader",
+    "PydicomReader",
+    "WSIReader",
+    "NrrdReader",
+]
 
 
 class ImageReader(ABC):
@@ -354,6 +372,317 @@ def _get_array_data(self, img):
         return np_img if self.reverse_indexing else np.moveaxis(np_img.T, 0, -1)
 
 
+@require_pkg(pkg_name="pydicom")
+class PydicomReader(ImageReader):
+    """
+    Load medical images based on Pydicom library.
+    All the supported image formats can be found at:
+    https://dicom.nema.org/medical/dicom/current/output/chtml/part10/chapter_7.html
+
+    This class refers to:
+    https://nipy.org/nibabel/dicom/dicom_orientation.html#dicom-affine-formula
+    https://github.com/pydicom/contrib-pydicom/blob/master/input-output/pydicom_series.py
+
+    Args:
+        channel_dim: the channel dimension of the input image, default is None.
+            This is used to set original_channel_dim in the metadata, EnsureChannelFirstD reads this field.
+            If None, `original_channel_dim` will be either `no_channel` or `-1`.
+        affine_lps_to_ras: whether to convert the affine matrix from "LPS" to "RAS". Defaults to ``True``.
+            Set to ``True`` to be consistent with ``NibabelReader``,
+            otherwise the affine matrix remains in the Dicom convention.
+        swap_ij: whether to swap the first two spatial axes. Default to ``True``, so that the outputs
+            are consistent with the other readers.
+        kwargs: additional args for `pydicom.dcmread` API. more details about available args:
+            https://pydicom.github.io/pydicom/stable/reference/generated/pydicom.filereader.dcmread.html#pydicom.filereader.dcmread
+            If the `get_data` function will be called
+            (for example, when using this reader with `monai.transforms.LoadImage`), please ensure that the argument
+            `stop_before_pixels` is `True`, and `specific_tags` covers all necessary tags, such as `PixelSpacing`,
+            `ImagePositionPatient`, `ImageOrientationPatient` and all `pixel_array` related tags.
+
+    Note::
+
+        the current
+
+    """
+
+    def __init__(
+        self, channel_dim: Optional[int] = None, affine_lps_to_ras: bool = True, swap_ij: bool = True, **kwargs
+    ):
+        super().__init__()
+        self.kwargs = kwargs
+        self.channel_dim = channel_dim
+        self.affine_lps_to_ras = affine_lps_to_ras
+        self.swap_ij = swap_ij
+
+    def verify_suffix(self, filename: Union[Sequence[PathLike], PathLike]) -> bool:
+        """
+        Verify whether the specified file or files format is supported by Pydicom reader.
+
+        Args:
+            filename: file name or a list of file names to read.
+                if a list of files, verify all the suffixes.
+
+        """
+        return has_pydicom
+
+    def read(self, data: Union[Sequence[PathLike], PathLike], **kwargs):
+        """
+        Read image data from specified file or files, it can read a list of images
+        and stack them together as multi-channel data in `get_data()`.
+        If passing directory path instead of file path, will treat it as DICOM images series and read.
+
+        Args:
+            data: file name or a list of file names to read,
+            kwargs: additional args for `pydicom.dcmread` API, will override `self.kwargs` for existing keys.
+
+        Returns:
+            If `data` represents a filename: return a pydicom dataset object.
+            If `data` represents a list of filenames or a directory: return a list of pydicom dataset object.
+            If `data` represents a list of directories: return a list of list of pydicom dataset object.
+
+        """
+        img_ = []
+
+        filenames: Sequence[PathLike] = ensure_tuple(data)
+        kwargs_ = self.kwargs.copy()
+        kwargs_.update(kwargs)
+
+        self.has_series = False
+
+        for name in filenames:
+            name = f"{name}"
+            if Path(name).is_dir():
+                # read DICOM series
+                slices = [pydicom.dcmread(fp=slc, **kwargs_) for slc in glob.glob(os.path.join(name, "**"))]
+                img_.append(slices if len(slices) > 1 else slices[0])
+                self.has_series = True
+            else:
+                ds = pydicom.dcmread(fp=name, **kwargs_)
+                img_.append(ds)
+        return img_ if len(filenames) > 1 else img_[0]
+
+    def _combine_dicom_series(self, data):
+        """
+        Combine dicom series (a list of pydicom dataset objects). Their data arrays will be stacked together at a new
+        dimension as the last dimension.
+
+        The stack order depends on Instance Number. The metadata will be based on the
+        first slice's metadata, and some new items will be added:
+
+        "spacing": the new spacing of the stacked slices.
+        "lastImagePositionPatient": `ImagePositionPatient` for the last slice, it will be used to achieve the affine
+            matrix.
+        "spatial_shape": the spatial shape of the stacked slices.
+
+        Args:
+            data: a list of pydicom dataset objects.
+        Returns:
+            a tuple that consisted with data array and metadata.
+        """
+        slices = []
+        # for a dicom series
+        for slc_ds in data:
+            if hasattr(slc_ds, "InstanceNumber"):
+                slices.append(slc_ds)
+            else:
+                warnings.warn(f"slice: {slc_ds.filename} does not have InstanceNumber tag, skip it.")
+        slices = sorted(slices, key=lambda s: s.InstanceNumber)
+
+        if len(slices) == 0:
+            raise ValueError("the input does not have valid slices.")
+
+        first_slice = slices[0]
+        average_distance = 0.0
+        first_array = self._get_array_data(first_slice)
+        shape = first_array.shape
+        spacing = getattr(first_slice, "PixelSpacing", (1.0, 1.0, 1.0))
+        pos = getattr(first_slice, "ImagePositionPatient", (0.0, 0.0, 0.0))[2]
+        stack_array = [first_array]
+        for idx in range(1, len(slices)):
+            slc_array = self._get_array_data(slices[idx])
+            slc_shape = slc_array.shape
+            slc_spacing = getattr(first_slice, "PixelSpacing", (1.0, 1.0, 1.0))
+            slc_pos = getattr(first_slice, "ImagePositionPatient", (0.0, 0.0, float(idx)))[2]
+            if spacing != slc_spacing:
+                warnings.warn(f"the list contains slices that have different spacings {spacing} and {slc_spacing}.")
+            if shape != slc_shape:
+                warnings.warn(f"the list contains slices that have different shapes {shape} and {slc_shape}.")
+            average_distance += abs(pos - slc_pos)
+            pos = slc_pos
+            stack_array.append(slc_array)
+
+        if len(slices) > 1:
+            average_distance /= len(slices) - 1
+            spacing.append(average_distance)
+            stack_array = np.stack(stack_array, axis=-1)
+            stack_metadata = self._get_meta_dict(first_slice)
+            stack_metadata["spacing"] = np.asarray(spacing)
+            if hasattr(slices[-1], "ImagePositionPatient"):
+                stack_metadata["lastImagePositionPatient"] = np.asarray(slices[-1].ImagePositionPatient)
+            stack_metadata["spatial_shape"] = shape + (len(slices),)
+        else:
+            stack_array = stack_array[0]
+            stack_metadata = self._get_meta_dict(first_slice)
+            stack_metadata["spacing"] = np.asarray(spacing)
+            stack_metadata["spatial_shape"] = shape
+
+        return stack_array, stack_metadata
+
+    def get_data(self, data):
+        """
+        Extract data array and metadata from loaded image and return them.
+        This function returns two objects, first is numpy array of image data, second is dict of metadata.
+        It constructs `affine`, `original_affine`, and `spatial_shape` and stores them in meta dict.
+        For dicom series within the input, all slices will be stacked first,
+        When loading a list of files (dicom file, or stacked dicom series), they are stacked together at a new
+        dimension as the first dimension, and the metadata of the first image is used to represent the output metadata.
+
+        To use this function, all pydicom dataset objects should contain: `pixel_array`, `PixelSpacing`,
+        `ImagePositionPatient` and `ImageOrientationPatient`.
+
+        Args:
+            data: a pydicom dataset object, or a list of pydicom dataset objects, or a list of list of
+                pydicom dataset objects.
+
+        """
+
+        dicom_data = []
+        # combine dicom series if exists
+        if self.has_series is True:
+            # a list, all objects within a list belong to one dicom series
+            if not isinstance(data[0], List):
+                dicom_data.append(self._combine_dicom_series(data))
+            # a list of list, each inner list represents a dicom series
+            else:
+                for series in data:
+                    dicom_data.append(self._combine_dicom_series(series))
+        else:
+            # a single pydicom dataset object
+            if not isinstance(data, List):
+                data = [data]
+            for d in data:
+                data_array = self._get_array_data(d)
+                metadata = self._get_meta_dict(d)
+                metadata["spatial_shape"] = data_array.shape
+                metadata["spacing"] = np.asarray(metadata.get("00280030", {}).get("Value", (1.0, 1.0, 1.0)))
+                dicom_data.append((data_array, metadata))
+
+        img_array: List[np.ndarray] = []
+        compatible_meta: Dict = {}
+
+        for (data_array, metadata) in ensure_tuple(dicom_data):
+            img_array.append(np.ascontiguousarray(np.swapaxes(data_array, 0, 1) if self.swap_ij else data_array))
+            affine = self._get_affine(metadata, self.affine_lps_to_ras)
+            if self.swap_ij:
+                affine = affine @ np.array([[0, 1, 0, 0], [1, 0, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]])
+                sp_size = list(metadata["spatial_shape"])
+                sp_size[0], sp_size[1] = sp_size[1], sp_size[0]
+                metadata["spatial_shape"] = ensure_tuple(sp_size)
+            metadata["original_affine"] = affine
+            metadata["affine"] = affine.copy()
+            if self.channel_dim is None:  # default to "no_channel" or -1
+                metadata["original_channel_dim"] = (
+                    "no_channel" if len(data_array.shape) == len(metadata["spatial_shape"]) else -1
+                )
+            else:
+                metadata["original_channel_dim"] = self.channel_dim
+            metadata["spacing"] = affine_to_spacing(metadata["original_affine"], r=len(metadata["spatial_shape"]))
+            _copy_compatible_dict(metadata, compatible_meta)
+
+        return _stack_images(img_array, compatible_meta), compatible_meta
+
+    def _get_meta_dict(self, img) -> Dict:
+        """
+        Get all the metadata of the image and convert to dict type.
+
+        Args:
+            img: a Pydicom dataset object.
+
+        """
+        if not hasattr(img, "ImagePositionPatient"):
+            raise ValueError(f"dicom data: {img.filename} does not have ImagePositionPatient.")
+        if not hasattr(img, "ImageOrientationPatient"):
+            raise ValueError(f"dicom data: {img.filename} does not have ImageOrientationPatient.")
+
+        meta_dict = img.to_json_dict()
+        # remove Pixel Data "7FE00008" or "7FE00009" or "7FE00010"
+        # remove Data Set Trailing Padding "FFFCFFFC"
+        for key in ["7FE00008", "7FE00009", "7FE00010", "FFFCFFFC"]:
+            if key in meta_dict.keys():
+                meta_dict.pop(key)
+
+        return meta_dict  # type: ignore
+
+    def _get_affine(self, metadata: Dict, lps_to_ras: bool = True):
+        """
+        Get or construct the affine matrix of the image, it can be used to correct
+        spacing, orientation or execute spatial transforms.
+
+        Args:
+            metadata: metadata with dict type.
+            lps_to_ras: whether to convert the affine matrix from "LPS" to "RAS". Defaults to True.
+
+        """
+        affine: np.ndarray = np.eye(4)
+        if not ("00200037" in metadata and "00200032" in metadata):
+            return affine
+        # "00200037" is the tag of `ImageOrientationPatient`
+        rx, ry, rz, cx, cy, cz = metadata["00200037"]["Value"]
+        # "00200032" is the tag of `ImagePositionPatient`
+        sx, sy, sz = metadata["00200032"]["Value"]
+        dr, dc = metadata.get("spacing", (1.0, 1.0))[:2]
+        affine[0, 0] = cx * dr
+        affine[0, 1] = rx * dc
+        affine[0, 3] = sx
+        affine[1, 0] = cy * dr
+        affine[1, 1] = ry * dc
+        affine[1, 3] = sy
+        affine[2, 0] = cz * dr
+        affine[2, 1] = rz * dc
+        affine[2, 2] = 0
+        affine[2, 3] = sz
+
+        # 3d
+        if "lastImagePositionPatient" in metadata:
+            t1n, t2n, t3n = metadata["lastImagePositionPatient"]
+            n = metadata["spatial_shape"][-1]
+            k1, k2, k3 = (t1n - sx) / (n - 1), (t2n - sy) / (n - 1), (t3n - sz) / (n - 1)
+            affine[0, 2] = k1
+            affine[1, 2] = k2
+            affine[2, 2] = k3
+
+        if lps_to_ras:
+            affine = orientation_ras_lps(affine)
+        return affine
+
+    def _get_array_data(self, img):
+        """
+        Get the array data of the image. If `RescaleSlope` and `RescaleIntercept` are available, the raw array data
+        will be rescaled. The output data has the dtype np.float32 if the rescaling is applied.
+
+        Args:
+            img: a Pydicom dataset object.
+
+        """
+        # process Dicom series
+        if not hasattr(img, "pixel_array"):
+            raise ValueError(f"dicom data: {img.filename} does not have pixel_array.")
+        data = img.pixel_array
+
+        slope, offset = 1.0, 0.0
+        rescale_flag = False
+        if hasattr(img, "RescaleSlope"):
+            slope = img.RescaleSlope
+            rescale_flag = True
+        if hasattr(img, "RescaleIntercept"):
+            offset = img.RescaleIntercept
+            rescale_flag = True
+        if rescale_flag:
+            data = data.astype(np.float32) * slope + offset
+
+        return data
+
+
 @require_pkg(pkg_name="nibabel")
 class NibabelReader(ImageReader):
     """
diff --git a/monai/transforms/io/array.py b/monai/transforms/io/array.py
--- a/monai/transforms/io/array.py
+++ b/monai/transforms/io/array.py
@@ -28,7 +28,15 @@
 from monai.config import DtypeLike, NdarrayOrTensor, PathLike
 from monai.data import image_writer
 from monai.data.folder_layout import FolderLayout
-from monai.data.image_reader import ImageReader, ITKReader, NibabelReader, NrrdReader, NumpyReader, PILReader
+from monai.data.image_reader import (
+    ImageReader,
+    ITKReader,
+    NibabelReader,
+    NrrdReader,
+    NumpyReader,
+    PILReader,
+    PydicomReader,
+)
 from monai.transforms.transform import Transform
 from monai.transforms.utility.array import EnsureChannelFirst
 from monai.utils import GridSampleMode, GridSamplePadMode
@@ -42,6 +50,7 @@
 __all__ = ["LoadImage", "SaveImage", "SUPPORTED_READERS"]
 
 SUPPORTED_READERS = {
+    "pydicomreader": PydicomReader,
     "itkreader": ITKReader,
     "nrrdreader": NrrdReader,
     "numpyreader": NumpyReader,
@@ -110,7 +119,7 @@ def __init__(
                 - if `reader` is None, a default set of `SUPPORTED_READERS` will be used.
                 - if `reader` is a string, it's treated as a class name or dotted path
                 (such as ``"monai.data.ITKReader"``), the supported built-in reader classes are
-                ``"ITKReader"``, ``"NibabelReader"``, ``"NumpyReader"``.
+                ``"ITKReader"``, ``"NibabelReader"``, ``"NumpyReader"``, ``"PydicomReader"``.
                 a reader instance will be constructed with the `*args` and `**kwargs` parameters.
                 - if `reader` is a reader class/instance, it will be registered to this loader accordingly.
             image_only: if True return only the image volume, otherwise return image data array and header dict.


Test patch:
diff --git a/tests/test_init_reader.py b/tests/test_init_reader.py
--- a/tests/test_init_reader.py
+++ b/tests/test_init_reader.py
@@ -11,7 +11,7 @@
 
 import unittest
 
-from monai.data import ITKReader, NibabelReader, NrrdReader, NumpyReader, PILReader
+from monai.data import ITKReader, NibabelReader, NrrdReader, NumpyReader, PILReader, PydicomReader
 from monai.transforms import LoadImage, LoadImaged
 from tests.utils import SkipIfNoModule
 
@@ -23,7 +23,7 @@ def test_load_image(self):
         self.assertIsInstance(instance1, LoadImage)
         self.assertIsInstance(instance2, LoadImage)
 
-        for r in ["NibabelReader", "PILReader", "ITKReader", "NumpyReader", "NrrdReader", None]:
+        for r in ["NibabelReader", "PILReader", "ITKReader", "NumpyReader", "NrrdReader", "PydicomReader", None]:
             inst = LoadImaged("image", reader=r)
             self.assertIsInstance(inst, LoadImaged)
 
@@ -31,6 +31,7 @@ def test_load_image(self):
     @SkipIfNoModule("nibabel")
     @SkipIfNoModule("PIL")
     @SkipIfNoModule("nrrd")
+    @SkipIfNoModule("Pydicom")
     def test_readers(self):
         inst = ITKReader()
         self.assertIsInstance(inst, ITKReader)
@@ -40,6 +41,9 @@ def test_readers(self):
         inst = NibabelReader(as_closest_canonical=True)
         self.assertIsInstance(inst, NibabelReader)
 
+        inst = PydicomReader()
+        self.assertIsInstance(inst, PydicomReader)
+
         inst = NumpyReader()
         self.assertIsInstance(inst, NumpyReader)
         inst = NumpyReader(npz_keys="test")
diff --git a/tests/test_load_image.py b/tests/test_load_image.py
--- a/tests/test_load_image.py
+++ b/tests/test_load_image.py
@@ -20,7 +20,7 @@
 from parameterized import parameterized
 from PIL import Image
 
-from monai.data import ITKReader, NibabelReader
+from monai.data import ITKReader, NibabelReader, PydicomReader
 from monai.transforms import LoadImage
 
 
@@ -134,6 +134,31 @@ def get_data(self, _obj):
     (128, 128, 3, 128),
 ]
 
+# test same dicom data with PydicomReader
+TEST_CASE_19 = [
+    {"image_only": False, "reader": PydicomReader()},
+    "tests/testing_data/CT_DICOM",
+    (16, 16, 4),
+    (16, 16, 4),
+]
+
+TEST_CASE_20 = [
+    {"image_only": False, "reader": "PydicomReader", "ensure_channel_first": True},
+    "tests/testing_data/CT_DICOM",
+    (16, 16, 4),
+    (1, 16, 16, 4),
+]
+
+TEST_CASE_21 = [
+    {"image_only": False, "reader": "PydicomReader", "affine_lps_to_ras": True, "defer_size": "2 MB"},
+    "tests/testing_data/CT_DICOM",
+    (16, 16, 4),
+    (16, 16, 4),
+]
+
+# test reader consistency between PydicomReader and ITKReader on dicom data
+TEST_CASE_22 = ["tests/testing_data/CT_DICOM"]
+
 
 class TestLoadImage(unittest.TestCase):
     @parameterized.expand(
@@ -174,7 +199,7 @@ def test_itk_reader(self, input_param, filenames, expected_shape):
                 np.testing.assert_allclose(header["original_affine"], np_diag)
             self.assertTupleEqual(result.shape, expected_shape)
 
-    @parameterized.expand([TEST_CASE_10, TEST_CASE_11, TEST_CASE_12])
+    @parameterized.expand([TEST_CASE_10, TEST_CASE_11, TEST_CASE_12, TEST_CASE_19, TEST_CASE_20, TEST_CASE_21])
     def test_itk_dicom_series_reader(self, input_param, filenames, expected_shape, expected_np_shape):
         result, header = LoadImage(**input_param)(filenames)
         self.assertTrue("affine" in header)
@@ -208,6 +233,18 @@ def test_itk_reader_multichannel(self):
                 np.testing.assert_allclose(result[:, :, 1], test_image[:, :, 1])
                 np.testing.assert_allclose(result[:, :, 2], test_image[:, :, 2])
 
+    @parameterized.expand([TEST_CASE_22])
+    def test_dicom_reader_consistency(self, filenames):
+        itk_param = {"reader": "ITKReader"}
+        pydicom_param = {"reader": "PydicomReader"}
+        for affine_flag in [True, False]:
+            itk_param["affine_lps_to_ras"] = affine_flag
+            pydicom_param["affine_lps_to_ras"] = affine_flag
+            itk_result, itk_header = LoadImage(**itk_param)(filenames)
+            pydicom_result, pydicom_header = LoadImage(**pydicom_param)(filenames)
+            np.testing.assert_allclose(pydicom_result, itk_result)
+            np.testing.assert_allclose(itk_header["affine"], pydicom_header["affine"])
+
     def test_load_nifti_multichannel(self):
         test_image = np.random.randint(0, 256, size=(31, 64, 16, 2)).astype(np.float32)
         with tempfile.TemporaryDirectory() as tempdir: