Repository: DIAGNijmegen/rse-panimg (language: python)

Issue:
ZeroDivisionError in dicom.py
Uncaught ZeroDivisionError can in L173 of dicom.py - @nlessmann could you solve this as part of #71 ?

```
  File "panimg/panimg.py", line 90, in _convert_directory
    _convert_directory(
  File "panimg/panimg.py", line 116, in _convert_directory
    builder_result = _build_files(
  File "panimg/panimg.py", line 148, in _build_files
    for result in builder(files=files):
  File "panimg/image_builders/dicom.py", line 434, in image_builder_dicom
    studies = _validate_dicom_files(files=files, file_errors=file_errors)
  File "panimg/image_builders/dicom.py", line 173, in _validate_dicom_files
    elif len(headers) % n_time > 0:
```
ZeroDivisionError in dicom.py
Uncaught ZeroDivisionError can in L173 of dicom.py - @nlessmann could you solve this as part of #71 ?

```
  File "panimg/panimg.py", line 90, in _convert_directory
    _convert_directory(
  File "panimg/panimg.py", line 116, in _convert_directory
    builder_result = _build_files(
  File "panimg/panimg.py", line 148, in _build_files
    for result in builder(files=files):
  File "panimg/image_builders/dicom.py", line 434, in image_builder_dicom
    studies = _validate_dicom_files(files=files, file_errors=file_errors)
  File "panimg/image_builders/dicom.py", line 173, in _validate_dicom_files
    elif len(headers) % n_time > 0:
```


Discussion / hints:

Fix patch:
diff --git a/panimg/image_builders/dicom.py b/panimg/image_builders/dicom.py
index 84a7881..f732b7e 100644
--- a/panimg/image_builders/dicom.py
+++ b/panimg/image_builders/dicom.py
@@ -1,8 +1,8 @@
 import logging
-from collections import defaultdict, namedtuple
+from collections import defaultdict
 from math import isclose
 from pathlib import Path
-from typing import DefaultDict, Iterator, List, Set
+from typing import Any, DefaultDict, Dict, Iterator, List, Set, Tuple
 
 import numpy as np
 import pydicom
@@ -17,20 +17,6 @@
 
 logger = logging.getLogger(__name__)
 
-NUMPY_IMAGE_TYPES = {
-    "character": SimpleITK.sitkUInt8,
-    "uint8": SimpleITK.sitkUInt8,
-    "uint16": SimpleITK.sitkUInt16,
-    "uint32": SimpleITK.sitkUInt32,
-    "uint64": SimpleITK.sitkUInt64,
-    "int8": SimpleITK.sitkInt8,
-    "int16": SimpleITK.sitkInt16,
-    "int32": SimpleITK.sitkInt32,
-    "int64": SimpleITK.sitkInt64,
-    "float32": SimpleITK.sitkFloat32,
-    "float64": SimpleITK.sitkFloat64,
-}
-
 OPTIONAL_METADATA_FIELDS = (
     # These fields will be included in the output mha file
     "Laterality",
@@ -59,7 +45,285 @@ def _find_dicom_tag(dataset: pydicom.Dataset, tag: str):
     )
 
 
-def _get_headers_by_study(files, file_errors):
+class DicomDataset:
+    def __init__(self, index, headers, n_time, n_slices, n_slices_per_file):
+        self.index = index
+        self.headers = headers
+        self.n_time = n_time
+        self.n_slices = n_slices
+        self.n_slices_per_file = n_slices_per_file
+
+        self._direction = None
+        self._pixel_value_dtype = None
+
+    @property
+    def ref_header(self) -> pydicom.Dataset:
+        return self.headers[0]["data"]
+
+    @property
+    def dimensions(self) -> int:
+        # Images are either 4D or 3D (2D images get an additional axis of size 1)
+        return 4 if self.n_time and self.n_time > 1 else 3
+
+    @property
+    def direction(self) -> np.ndarray:
+        if self._direction is None:
+            # Compute rotation matrix (orientation of the image)
+            try:
+                orientation = _find_dicom_tag(
+                    self.ref_header, "ImageOrientationPatient"
+                )
+                row_cos = orientation[:3]
+                col_cos = orientation[3:]
+            except DicomTagNotFoundError:
+                # Tag can be missing in X-ray images for example
+                row_cos = (1, 0, 0)
+                col_cos = (0, 1, 0)
+
+            direction = np.eye(self.dimensions, dtype=float)
+            direction[:3, :3] = np.stack(
+                [row_cos, col_cos, np.cross(row_cos, col_cos)], axis=1
+            )
+            self._direction = direction
+
+        return self._direction
+
+    def _iter_origins(self):
+        has_frame_details = (
+            "PerFrameFunctionalGroupsSequence" in self.ref_header
+        )
+        if self.n_slices_per_file == 1 or not has_frame_details:
+            # One or multiple regular DICOM files
+            for partial in self.headers:
+                if "ImagePositionPatient" in partial["data"]:
+                    yield np.array(
+                        partial["data"].ImagePositionPatient, dtype=float
+                    )
+        else:
+            # An enhanced DICOM file with the entire volume stored in one file
+            for frame in self.ref_header.PerFrameFunctionalGroupsSequence:
+                try:
+                    file_origin = _find_dicom_tag(
+                        frame, "ImagePositionPatient"
+                    )
+                except DicomTagNotFoundError:
+                    pass
+                else:
+                    yield np.array(file_origin, dtype=float)
+
+    def _determine_slice_order(self):
+        # Compute coordinate differences between successive slices
+        origin = None
+        origin_diff = np.array((0, 0, 0), dtype=float)
+        origin_seen = []
+        n_diffs = 0
+        for file_origin in self._iter_origins():
+            if any(np.allclose(file_origin, o) for o in origin_seen):
+                continue  # ignore duplicates (seen in 4D images)
+
+            if origin is not None:
+                diff = file_origin - origin
+                origin_diff = origin_diff + diff
+                n_diffs += 1
+            origin = file_origin
+            origin_seen.append(file_origin)
+
+        if n_diffs == 0:
+            # One slice or volume only, read spacing from header or default to 1 mm
+            z_i = float(getattr(self.ref_header, "SpacingBetweenSlices", 1.0))
+            z_order = 0
+        else:
+            # Multiple slices, average spacing between slices
+            avg_origin_diff = origin_diff / n_diffs
+            z_i = np.linalg.norm(avg_origin_diff)
+
+            # Use orientation of the coordinate system to determine in which
+            # direction the origins of the individual slices should move.
+            # Use the dot product to find the angle between that direction
+            # and the spacing vector - this tells us whether the order of
+            # the slices is correct or should be reversed
+            z_direction = self.direction @ np.ones(self.dimensions)
+            if self.dimensions > 3:
+                avg_origin_diff = np.pad(
+                    avg_origin_diff,
+                    pad_width=(0, self.dimensions - avg_origin_diff.size),
+                    mode="constant",
+                )
+            z_order = np.sign(np.dot(avg_origin_diff, z_direction))
+
+        try:
+            pixel_spacing = _find_dicom_tag(self.ref_header, "PixelSpacing")
+        except DicomTagNotFoundError:
+            pixel_spacing = (1.0, 1.0)
+
+        x_i, y_i = (float(s) for s in pixel_spacing)
+        spacing = (x_i, y_i, z_i)
+
+        # Slices might require reordering, so compute the actual origin
+        if origin is None:
+            origin = (0.0, 0.0, 0.0)
+        else:
+            origin = tuple(origin_seen[0] if z_order >= 0 else origin)
+
+        # Add dummy values for 4D images
+        if self.dimensions == 4:
+            spacing += (1.0,)
+            origin += (0.0,)
+
+        return origin, spacing, z_order
+
+    def _pixel_values_need_scaling(self) -> bool:
+        apply_slope = any(
+            not isclose(float(getattr(h["data"], "RescaleSlope", 1.0)), 1.0)
+            for h in self.headers
+        )
+        apply_intercept = any(
+            not isclose(
+                float(getattr(h["data"], "RescaleIntercept", 0.0)), 0.0
+            )
+            for h in self.headers
+        )
+        return apply_slope or apply_intercept
+
+    def _read_pixel_values(self, filename: Path, rescale: bool) -> np.ndarray:
+        ds = pydicom.dcmread(str(filename))
+
+        # Read rescaling parameters already now so that we can delete
+        # the DICOM dataset instance as soon as possible
+        slope = float(getattr(ds, "RescaleSlope", 1))
+        intercept = float(getattr(ds, "RescaleIntercept", 0))
+
+        # If the data type is still unknown, use data type of this slice
+        if self._pixel_value_dtype is None:
+            if rescale:
+                self._pixel_value_dtype = np.float32
+            else:
+                self._pixel_value_dtype = ds.pixel_array.dtype
+
+        # Get pixel array and cast to desired data type if needed
+        pixel_array = ds.pixel_array.astype(
+            dtype=self._pixel_value_dtype, copy=False
+        )
+        del ds
+
+        if rescale:
+            return slope * pixel_array + intercept
+        else:
+            return pixel_array
+
+    def _shape(self, samples_per_pixel: int) -> Tuple[int, ...]:
+        pixel_dims = [
+            self.n_slices,
+            self.ref_header.Rows,
+            self.ref_header.Columns,
+        ]
+        if self.dimensions == 4:
+            pixel_dims.insert(0, self.n_time)
+        if samples_per_pixel > 1:
+            pixel_dims.append(samples_per_pixel)
+
+        return tuple(int(s) for s in pixel_dims)
+
+    def _create_itk_from_dcm(self, z_order: int) -> SimpleITK.Image:
+        samples_per_pixel = int(getattr(self.ref_header, "SamplesPerPixel", 1))
+        pixel_dims = self._shape(samples_per_pixel)
+        is_rgb = samples_per_pixel > 1
+        apply_scaling = self._pixel_values_need_scaling()
+        dcm_array: np.ndarray = None
+
+        for index, partial in enumerate(self.headers):
+            pixel_array = self._read_pixel_values(
+                filename=partial["file"], rescale=apply_scaling
+            )
+
+            if (
+                len(pixel_array.shape) == self.dimensions
+                or pixel_array.shape == pixel_dims
+            ):
+                slicing = [slice(None)] * self.dimensions
+                slicing[-3] = slice(None, None, 1 if z_order >= 0 else -1)
+                dcm_array = pixel_array[tuple(slicing)]
+                break
+
+            if dcm_array is None:
+                dcm_array = np.zeros(pixel_dims, dtype=pixel_array.dtype)
+
+            # Determine slice position in array based on slice order
+            z_index = index if z_order >= 0 else len(self.headers) - index - 1
+
+            # Add slice to volume
+            if self.dimensions == 4:
+                z = (
+                    z_index % self.n_slices
+                    if self.n_slices_per_file == 1
+                    else slice(None)
+                )
+                dcm_array[index // self.n_slices, z, :, :] = pixel_array
+            else:
+                dcm_array[z_index % self.n_slices, :, :] = pixel_array
+
+        # When images have a photometric interpretation that requires low
+        # values to be displayed in white, we invert the image values
+        photometric_interpretation = getattr(
+            self.ref_header, "PhotometricInterpretation", None
+        )
+        if not is_rgb and photometric_interpretation == "MONOCHROME1":
+            if np.issubdtype(dcm_array.dtype, np.floating):
+                dcm_array = -dcm_array
+            else:
+                dcm_array = ~dcm_array
+
+        return SimpleITK.GetImageFromArray(dcm_array, isVector=is_rgb)
+
+    def _add_optional_metadata(self, img: SimpleITK.Image):
+        for f in OPTIONAL_METADATA_FIELDS:
+            value = getattr(self.ref_header, f, "")
+            str_value = str(value)
+            if str_value != "":
+                validate_metadata_value(key=f, value=value)
+                img.SetMetaData(f, str_value)
+
+    def _add_temporal_metadata(self, img: SimpleITK.Image, z_order: int):
+        content_times = []
+        exposures = []
+
+        for index, partial in enumerate(self.headers):
+            if self.n_slices_per_file > 1 or index % self.n_slices == 0:
+                content_times.append(str(partial["data"].ContentTime))
+                exposures.append(str(partial["data"].Exposure))
+
+        if z_order < 0:
+            content_times = content_times[::-1]
+            exposures = exposures[::-1]
+
+        img.SetMetaData("ContentTimes", " ".join(content_times))
+        img.SetMetaData("Exposures", " ".join(exposures))
+
+    def read(self) -> SimpleITKImage:
+        origin, spacing, z_order = self._determine_slice_order()
+
+        # Create ITK image from DICOM
+        img = self._create_itk_from_dcm(z_order=z_order)
+        img.SetDirection(tuple(self.direction.flatten()))
+        img.SetSpacing(spacing)
+        img.SetOrigin(origin)
+
+        # Add additional metadata
+        self._add_optional_metadata(img)
+        if self.dimensions == 4:
+            self._add_temporal_metadata(img, z_order)
+
+        return SimpleITKImage(
+            image=img,
+            name=f"{self.headers[0]['data'].StudyInstanceUID}-{self.index}",
+            consumed_files={d["file"] for d in self.headers},
+            spacing_valid=True,
+        )
+
+
+def _get_headers_by_study(
+    files: Set[Path], file_errors: DefaultDict[Path, List[str]]
+):
     """
     Gets all headers from dicom files found in path.
 
@@ -76,8 +340,8 @@ def _get_headers_by_study(files, file_errors):
     A dictionary of sorted headers for all dicom image files found within path,
     grouped by study id.
     """
-    studies = {}
-    indices = {}
+    studies: Dict[str, Dict[str, Any]] = {}
+    indices: Dict[str, Dict[str, int]] = {}
 
     for file in files:
         if not file.is_file():
@@ -115,9 +379,9 @@ def _get_headers_by_study(files, file_errors):
     return studies
 
 
-def _validate_dicom_files(
+def _find_valid_dicom_files(
     files: Set[Path], file_errors: DefaultDict[Path, List[str]]
-):
+) -> List[DicomDataset]:
     """
     Gets the headers for all dicom files on path and validates them.
 
@@ -131,19 +395,12 @@ def _validate_dicom_files(
 
     Returns
     -------
-    A list of `dicom_dataset` named tuples per study, consisting of:
-     - Headers for all dicom image files for the study
-     - Number of time points
-     - Number of slices per time point
+    A list of `DicomDataset` instances
 
     Any study with an inconsistent amount of slices per time point is discarded.
     """
     studies = _get_headers_by_study(files=files, file_errors=file_errors)
     result = []
-    dicom_dataset = namedtuple(
-        "dicom_dataset",
-        ["headers", "n_time", "n_slices", "n_slices_per_file", "index"],
-    )
     for key in studies:
         headers = studies[key]["headers"]
         index = studies[key]["index"]
@@ -152,19 +409,19 @@ def _validate_dicom_files(
 
         data = headers[-1]["data"]
         n_files = len(headers)
-        n_time = getattr(data, "TemporalPositionIndex", None)
+        n_time = int(getattr(data, "TemporalPositionIndex", 0))
         try:
             n_slices_per_file = len(data.PerFrameFunctionalGroupsSequence)
         except AttributeError:
             n_slices_per_file = int(getattr(data, "NumberOfFrames", 1))
         n_slices = n_files * n_slices_per_file
 
-        if n_time is None:
-            # Not a 4d dicom file
+        if n_time < 1:
+            # Not a 4d dicom file (DICOM standard says TPI is >=1 )
             result.append(
-                dicom_dataset(
+                DicomDataset(
                     headers=headers,
-                    n_time=n_time,
+                    n_time=None,
                     n_slices=n_slices,
                     n_slices_per_file=n_slices_per_file,
                     index=index,
@@ -179,7 +436,7 @@ def _validate_dicom_files(
         else:
             # Valid 4d dicom file
             result.append(
-                dicom_dataset(
+                DicomDataset(
                     headers=headers,
                     n_time=n_time,
                     n_slices=n_slices // n_time,
@@ -192,226 +449,6 @@ def _validate_dicom_files(
     return result
 
 
-def _process_dicom_file(*, dicom_ds):  # noqa: C901
-    # Use first slice or volume as reference
-    ref = dicom_ds.headers[0]["data"]
-
-    # Images are either 4D or 3D (2D images get an additional axis of size 1)
-    dimensions = 4 if dicom_ds.n_time and dicom_ds.n_time > 1 else 3
-
-    pixel_dims = (dicom_ds.n_slices, int(ref.Rows), int(ref.Columns))
-    if dimensions == 4:
-        pixel_dims = (dicom_ds.n_time,) + pixel_dims
-
-    # Compute rotation matrix (orientation of the image)
-    try:
-        orientation = _find_dicom_tag(ref, "ImageOrientationPatient")
-        row_cos = orientation[:3]
-        col_cos = orientation[3:]
-    except DicomTagNotFoundError:
-        # Tag can be missing in X-ray images for example
-        row_cos = (1, 0, 0)
-        col_cos = (0, 1, 0)
-    direction = np.eye(dimensions, dtype=float)
-    direction[:3, :3] = np.stack(
-        [row_cos, col_cos, np.cross(row_cos, col_cos)], axis=1
-    )
-
-    # Find origin and compute offset between slices (= spacing)
-    if (
-        dicom_ds.n_slices_per_file == 1
-        or "PerFrameFunctionalGroupsSequence" not in ref
-    ):
-        # One or multiple regular DICOM files
-        file_origins = [
-            np.array(partial["data"].ImagePositionPatient, dtype=float)
-            for partial in dicom_ds.headers
-            if "ImagePositionPatient" in partial["data"]
-        ]
-    else:
-        # An enhanced DICOM file with the entire volume stored in one file
-        file_origins = []
-        for frame in ref.PerFrameFunctionalGroupsSequence:
-            try:
-                file_origin = _find_dicom_tag(frame, "ImagePositionPatient")
-            except DicomTagNotFoundError:
-                pass
-            else:
-                file_origins.append(np.array(file_origin, dtype=float))
-
-    try:
-        ref_origin = tuple(file_origins[0])
-    except IndexError:
-        ref_origin = (0, 0, 0)
-
-    origin = None
-    origin_diff = np.array((0, 0, 0), dtype=float)
-    origin_seen = []
-    n_diffs = 0
-    for file_origin in file_origins:
-        if any(np.allclose(file_origin, o) for o in origin_seen):
-            continue  # ignore duplicates (seen in 4D images)
-
-        if origin is not None:
-            diff = file_origin - origin
-            origin_diff = origin_diff + diff
-            n_diffs += 1
-        origin = file_origin
-        origin_seen.append(file_origin)
-
-    if n_diffs == 0:
-        # One slice or volume only, read spacing from header or default to 1 mm
-        z_i = float(getattr(ref, "SpacingBetweenSlices", 1.0))
-        z_order = 0
-    else:
-        # Multiple slices, average spacing between slices
-        avg_origin_diff = origin_diff / n_diffs
-        z_i = np.linalg.norm(avg_origin_diff)
-
-        # Use orientation of the coordinate system to determine in which
-        # direction the origins of the individual slices should move.
-        # Use the dot product to find the angle between that direction
-        # and the spacing vector - this tells us whether the order of
-        # the slices is correct or should be reversed
-        z_direction = direction @ np.ones(dimensions)
-        if dimensions > 3:
-            avg_origin_diff = np.pad(
-                avg_origin_diff,
-                pad_width=(0, dimensions - avg_origin_diff.size),
-                mode="constant",
-            )
-        z_order = np.sign(np.dot(avg_origin_diff, z_direction))
-
-    # Create ITK image from DICOM, collect additional metadata
-    samples_per_pixel = int(getattr(ref, "SamplesPerPixel", 1))
-    img = _create_itk_from_dcm(
-        dicom_ds=dicom_ds,
-        dimensions=dimensions,
-        pixel_dims=pixel_dims,
-        z_order=z_order,
-        samples_per_pixel=samples_per_pixel,
-    )
-
-    # Slices might have been reordered, so compute the actual origin and
-    # set the correct world coordinate system (origin, spacing, direction)
-    if origin is None:
-        origin = (0.0, 0.0, 0.0)
-    sitk_origin = ref_origin if z_order >= 0 else tuple(origin)
-
-    try:
-        pixel_spacing = _find_dicom_tag(ref, "PixelSpacing")
-    except DicomTagNotFoundError:
-        pixel_spacing = (1.0, 1.0)
-
-    x_i, y_i = (float(s) for s in pixel_spacing)
-    sitk_spacing = (x_i, y_i, z_i)
-    if dimensions == 4:
-        sitk_spacing += (1.0,)
-        sitk_origin += (0.0,)
-
-    sitk_direction = tuple(direction.flatten())
-    img.SetDirection(sitk_direction)
-    img.SetSpacing(sitk_spacing)
-    img.SetOrigin(sitk_origin)
-
-    # Add additional metadata
-    for f in OPTIONAL_METADATA_FIELDS:
-        if hasattr(ref, f):
-            value = getattr(ref, f)
-            validate_metadata_value(key=f, value=value)
-            str_value = str(value)
-            if str_value != "":
-                img.SetMetaData(f, str_value)
-
-    return SimpleITKImage(
-        image=img,
-        name=(
-            f"{dicom_ds.headers[0]['data'].StudyInstanceUID}-{dicom_ds.index}"
-        ),
-        consumed_files={d["file"] for d in dicom_ds.headers},
-        spacing_valid=True,
-    )
-
-
-def _create_itk_from_dcm(
-    *,
-    dicom_ds,
-    dimensions,
-    pixel_dims,
-    z_order,
-    samples_per_pixel,
-):
-    apply_slope = any(
-        not isclose(float(getattr(h["data"], "RescaleSlope", 1.0)), 1.0)
-        for h in dicom_ds.headers
-    )
-    apply_intercept = any(
-        not isclose(float(getattr(h["data"], "RescaleIntercept", 0.0)), 0.0)
-        for h in dicom_ds.headers
-    )
-    apply_scaling = apply_slope or apply_intercept
-    is_rgb = samples_per_pixel > 1
-    if apply_scaling:
-        np_dtype = np.float32
-    else:
-        np_dtype = np.short
-    if samples_per_pixel > 1:
-        pixel_dims += (samples_per_pixel,)
-
-    dcm_array = None
-    content_times = []
-    exposures = []
-
-    for index, partial in enumerate(dicom_ds.headers):
-        ds = pydicom.dcmread(str(partial["file"]))
-
-        if apply_scaling:
-            slope = float(getattr(ds, "RescaleSlope", 1))
-            intercept = float(getattr(ds, "RescaleIntercept", 0))
-            pixel_array = slope * ds.pixel_array + intercept
-        else:
-            pixel_array = ds.pixel_array
-
-        if (
-            len(pixel_array.shape) == dimensions
-            or pixel_array.shape == pixel_dims
-        ):
-            dcm_array = pixel_array.astype(np_dtype)
-            del ds
-
-            slicing = [slice(None)] * dimensions
-            slicing[-3] = slice(None, None, 1 if z_order >= 0 else -1)
-            dcm_array = dcm_array[tuple(slicing)]
-            break
-
-        if dcm_array is None:
-            dcm_array = np.zeros(pixel_dims, dtype=np_dtype)
-
-        z_index = index if z_order >= 0 else len(dicom_ds.headers) - index - 1
-
-        if dimensions == 4:
-            z = (
-                z_index % dicom_ds.n_slices
-                if dicom_ds.n_slices_per_file == 1
-                else slice(None)
-            )
-            dcm_array[index // dicom_ds.n_slices, z, :, :] = pixel_array
-            if index % dicom_ds.n_slices == 0:
-                content_times.append(str(ds.ContentTime))
-                exposures.append(str(ds.Exposure))
-        else:
-            dcm_array[z_index % dicom_ds.n_slices, :, :] = pixel_array
-
-        del ds
-
-    img = SimpleITK.GetImageFromArray(dcm_array, isVector=is_rgb)
-    if dimensions == 4:
-        img.SetMetaData("ContentTimes", " ".join(content_times))
-        img.SetMetaData("Exposures", " ".join(exposures))
-
-    return img
-
-
 def image_builder_dicom(*, files: Set[Path]) -> Iterator[SimpleITKImage]:
     """
     Constructs image objects by inspecting files in a directory.
@@ -431,11 +468,10 @@ def image_builder_dicom(*, files: Set[Path]) -> Iterator[SimpleITKImage]:
     """
     file_errors: DefaultDict[Path, List[str]] = defaultdict(list)
 
-    studies = _validate_dicom_files(files=files, file_errors=file_errors)
-
+    studies = _find_valid_dicom_files(files=files, file_errors=file_errors)
     for dicom_ds in studies:
         try:
-            yield _process_dicom_file(dicom_ds=dicom_ds)
+            yield dicom_ds.read()
         except Exception as e:
             for d in dicom_ds.headers:
                 file_errors[d["file"]].append(format_error(str(e)))


Test patch:
diff --git a/tests/resources/dicom_2d/xray.dcm b/tests/resources/dicom_2d/xray.dcm
new file mode 100644
index 0000000..c243e80
Binary files /dev/null and b/tests/resources/dicom_2d/xray.dcm differ
diff --git a/tests/test_dicom.py b/tests/test_dicom.py
index 31513c1..5531b23 100644
--- a/tests/test_dicom.py
+++ b/tests/test_dicom.py
@@ -6,10 +6,11 @@
 import numpy as np
 import pydicom
 import pytest
+import SimpleITK
 
 from panimg.image_builders.dicom import (
+    _find_valid_dicom_files,
     _get_headers_by_study,
-    _validate_dicom_files,
     format_error,
     image_builder_dicom,
 )
@@ -38,8 +39,8 @@ def test_get_headers_by_study():
 
 
 def test_validate_dicom_files():
-    files = [Path(d[0]).joinpath(f) for d in os.walk(DICOM_DIR) for f in d[2]]
-    studies = _validate_dicom_files(files, defaultdict(list))
+    files = {Path(d[0]).joinpath(f) for d in os.walk(DICOM_DIR) for f in d[2]}
+    studies = _find_valid_dicom_files(files, defaultdict(list))
     assert len(studies) == 1
     for study in studies:
         headers = study.headers
@@ -52,7 +53,7 @@ def test_validate_dicom_files():
         },
     ):
         errors = defaultdict(list)
-        studies = _validate_dicom_files(files, errors)
+        studies = _find_valid_dicom_files(files, errors)
         assert len(studies) == 0
         for header in headers[1:]:
             assert errors[header["file"]] == [
@@ -73,7 +74,35 @@ def test_image_builder_dicom_single_slice(tmpdir):
     assert image.voxel_depth_mm == pytest.approx(1.0)
 
 
-def test_image_builder_dicom_4dct(tmpdir):
+def test_image_builder_dicom_2d(tmpdir):
+    files = {RESOURCE_PATH / "dicom_2d" / "xray.dcm"}
+    result = _build_files(
+        builder=image_builder_dicom, files=files, output_directory=tmpdir
+    )
+    assert result.consumed_files == files
+    assert len(result.new_images) == 1
+
+    image = result.new_images.pop()
+    assert image.width == 1720
+    assert image.height == 2320
+    assert image.depth == 1
+
+    assert image.voxel_width_mm == pytest.approx(0.1)
+    assert image.voxel_height_mm == pytest.approx(0.1)
+    assert image.voxel_depth_mm == pytest.approx(1.0)
+
+    sitk_image = SimpleITK.ReadImage(str(result.new_image_files.pop().file))
+    assert sitk_image.GetDimension() == 3
+    assert np.allclose(sitk_image.GetOrigin(), (0, 0, 0))
+    assert np.allclose(sitk_image.GetDirection(), (1, 0, 0, 0, 1, 0, 0, 0, 1))
+    assert sitk_image.GetPixelID() == SimpleITK.sitkUInt16
+
+    # Raw voxel values are 1, but photometric interpretation is MONOCHROME1
+    # meaning that panimg will invert all values
+    assert np.all(SimpleITK.GetArrayViewFromImage(sitk_image) == 65534)
+
+
+def test_image_builder_dicom_4d(tmpdir):
     files = {Path(d[0]).joinpath(f) for d in os.walk(DICOM_DIR) for f in d[2]}
     result = _build_files(
         builder=image_builder_dicom, files=files, output_directory=tmpdir