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

Issue:
Improve support for Enhanced DICOM
Reading enhanced DICOM where all slices are stored in a single .dcm file does not work correctly yet. For example, the spacing between slices is not computed correctly.


Discussion / hints:
Can you provide a failing test case?
Yes, I've put a .dcm file into `/mnt/netcache/bodyct/temp/forMike`
We really need things that can go into the test suite. Is there not something with a liberal data use license (eg CC, CC-BY) that could be included in the repo instead?
Sure, I've put a new example into that folder, derived from https://doi.org/10.5281/zenodo.4564338
+1 for this issue. `panimg` fails on multiframe DICOMs (all slices stored in a single .dcm file) as @nlessmann reported in the first comment. 
This is using pydicom and panimg is pure python, no web stuff, I would imagine that this is a perfect issue for a student experienced with loading DICOM images to tackle.

Fix patch:
diff --git a/panimg/image_builders/dicom.py b/panimg/image_builders/dicom.py
index 98802c7..effc39e 100644
--- a/panimg/image_builders/dicom.py
+++ b/panimg/image_builders/dicom.py
@@ -41,8 +41,20 @@
 )
 
 
-def pixel_data_reached(tag, vr, length):
-    return pydicom.datadict.keyword_for_tag(tag) == "PixelData"
+def format_error(message: str) -> str:
+    return f"Dicom image builder: {message}"
+
+
+class DicomTagNotFoundError(KeyError):
+    pass
+
+
+def _find_dicom_tag(dataset: pydicom.Dataset, tag: str):
+    for header in dataset.iterall():
+        if header.keyword == tag:
+            return header.value
+
+    raise DicomTagNotFoundError
 
 
 def _get_headers_by_study(files, file_errors):
@@ -51,9 +63,11 @@ def _get_headers_by_study(files, file_errors):
 
     Parameters
     ----------
-    path
-        Path to a directory that contains all images that were uploaded during
-        an upload session.
+    files
+        Paths images that were uploaded during an upload session.
+
+    file_errors
+        Dictionary in which reading errors are recorded per file
 
     Returns
     -------
@@ -69,7 +83,8 @@ def _get_headers_by_study(files, file_errors):
         with file.open("rb") as f:
             try:
                 ds = pydicom.filereader.read_partial(
-                    f, stop_when=pixel_data_reached
+                    f,
+                    stop_when=lambda tag, vr, length: tag == (0x7FE0, 0x0010),
                 )
                 dims = f"{ds.Rows}x{ds.Columns}"
                 key = f"{ds.StudyInstanceUID}-{dims}"
@@ -98,10 +113,6 @@ def _get_headers_by_study(files, file_errors):
     return studies
 
 
-def format_error(message: str) -> str:
-    return f"Dicom image builder: {message}"
-
-
 def _validate_dicom_files(
     files: Set[Path], file_errors: DefaultDict[Path, List[str]]
 ):
@@ -110,9 +121,11 @@ def _validate_dicom_files(
 
     Parameters
     ----------
-    path
-        Path to a directory that contains all images that were uploaded during
-        an upload session.
+    files
+        Paths images that were uploaded during an upload session.
+
+    file_errors
+        Dictionary in which reading errors are recorded per file
 
     Returns
     -------
@@ -126,87 +139,122 @@ def _validate_dicom_files(
     studies = _get_headers_by_study(files=files, file_errors=file_errors)
     result = []
     dicom_dataset = namedtuple(
-        "dicom_dataset", ["headers", "n_time", "n_slices", "index"]
+        "dicom_dataset",
+        ["headers", "n_time", "n_slices", "n_slices_per_file", "index"],
     )
     for key in studies:
         headers = studies[key]["headers"]
         index = studies[key]["index"]
         if not headers:
             continue
+
         data = headers[-1]["data"]
+        n_files = len(headers)
         n_time = getattr(data, "TemporalPositionIndex", None)
-        n_slices = max(len(headers), int(getattr(data, "NumberOfFrames", 0)))
-        # Not a 4d dicom file
+        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
             result.append(
                 dicom_dataset(
                     headers=headers,
                     n_time=n_time,
                     n_slices=n_slices,
+                    n_slices_per_file=n_slices_per_file,
                     index=index,
                 )
             )
-            continue
-        if len(headers) % n_time > 0:
+        elif len(headers) % n_time > 0:
+            # Invalid 4d dicom file
             for d in headers:
                 file_errors[d["file"]].append(
                     format_error("Number of slices per time point differs")
                 )
-            continue
-        n_slices = n_slices // n_time
-        result.append(
-            dicom_dataset(
-                headers=headers, n_time=n_time, n_slices=n_slices, index=index
+        else:
+            # Valid 4d dicom file
+            result.append(
+                dicom_dataset(
+                    headers=headers,
+                    n_time=n_time,
+                    n_slices=n_slices // n_time,
+                    n_slices_per_file=n_slices_per_file,
+                    index=index,
+                )
             )
-        )
+
     del studies
     return result
 
 
-def _extract_direction(dicom_ds, direction):
-    try:
-        # Try to extract the direction from the file
-        sitk_ref = SimpleITK.ReadImage(str(dicom_ds.headers[0]["file"]))
-        # The direction per slice is a 3x3 matrix, so we add the time
-        # dimension ourselves
-        dims = sitk_ref.GetDimension()
-        _direction = np.reshape(sitk_ref.GetDirection(), (dims, dims))
-        direction[:dims, :dims] = _direction
-    except Exception:
-        pass
-    return direction
-
-
 def _process_dicom_file(*, dicom_ds):  # noqa: C901
-    ref_file = pydicom.dcmread(str(dicom_ds.headers[0]["file"]))
-    ref_origin = tuple(
-        float(i) for i in getattr(ref_file, "ImagePositionPatient", (0, 0, 0))
-    )
+    # 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
-    direction = np.eye(dimensions, dtype=float)
-    direction = _extract_direction(dicom_ds, direction)
-    pixel_dims = (dicom_ds.n_slices, int(ref_file.Rows), int(ref_file.Columns))
+
+    pixel_dims = (dicom_ds.n_slices, int(ref.Rows), int(ref.Columns))
     if dimensions == 4:
         pixel_dims = (dicom_ds.n_time,) + pixel_dims
 
-    # Additional Meta data Contenttimes and Exposures
-    content_times = []
-    exposures = []
+    # Compute rotation matrix (orientation of the image)
+    orientation = _find_dicom_tag(ref, "ImageOrientationPatient")
+    row_cos = orientation[:3]
+    col_cos = orientation[3:]
+    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 partial in dicom_ds.headers:
-        ds = partial["data"]
-        if "ImagePositionPatient" in ds:
-            file_origin = np.array(ds.ImagePositionPatient, dtype=float)
-            if origin is not None:
-                diff = file_origin - origin
-                origin_diff = origin_diff + diff
-                n_diffs += 1
-            origin = file_origin
+    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 only, meaning there is no spacing, default to 1 mm
-        z_i = 1.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
@@ -227,26 +275,28 @@ def _process_dicom_file(*, dicom_ds):  # noqa: C901
             )
         z_order = np.sign(np.dot(avg_origin_diff, z_direction))
 
-    samples_per_pixel = int(getattr(ref_file, "SamplesPerPixel", 1))
+    # Create ITK image from DICOM, collect additional metadata
+    samples_per_pixel = int(getattr(ref, "SamplesPerPixel", 1))
     img = _create_itk_from_dcm(
-        content_times=content_times,
         dicom_ds=dicom_ds,
         dimensions=dimensions,
-        exposures=exposures,
         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)
 
-    if "PixelSpacing" in ref_file:
-        x_i, y_i = (float(x) for x in ref_file.PixelSpacing)
-    else:
-        x_i = y_i = 1.0
+    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,)
@@ -257,16 +307,14 @@ def _process_dicom_file(*, dicom_ds):  # noqa: C901
     img.SetSpacing(sitk_spacing)
     img.SetOrigin(sitk_origin)
 
-    if dimensions == 4:
-        # Set Additional Meta Data
-        img.SetMetaData("ContentTimes", " ".join(content_times))
-        img.SetMetaData("Exposures", " ".join(exposures))
-
+    # Add additional metadata
     for f in OPTIONAL_METADATA_FIELDS:
-        if hasattr(ref_file, f):
-            value = getattr(ref_file, f)
+        if hasattr(ref, f):
+            value = getattr(ref, f)
             validate_metadata_value(key=f, value=value)
-            img.SetMetaData(f, str(value))
+            str_value = str(value)
+            if str_value != "":
+                img.SetMetaData(f, str_value)
 
     return SimpleITKImage(
         image=img,
@@ -280,10 +328,8 @@ def _process_dicom_file(*, dicom_ds):  # noqa: C901
 
 def _create_itk_from_dcm(
     *,
-    content_times,
     dicom_ds,
     dimensions,
-    exposures,
     pixel_dims,
     z_order,
     samples_per_pixel,
@@ -304,16 +350,18 @@ def _create_itk_from_dcm(
         np_dtype = np.short
     if samples_per_pixel > 1:
         pixel_dims += (samples_per_pixel,)
+
     dcm_array = None
-    use_pixel_array = False
+    content_times = []
+    exposures = []
 
     for index, partial in enumerate(dicom_ds.headers):
         ds = pydicom.dcmread(str(partial["file"]))
 
         if apply_scaling:
-            pixel_array = float(
-                getattr(ds, "RescaleSlope", 1)
-            ) * ds.pixel_array + float(getattr(ds, "RescaleIntercept", 0))
+            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
 
@@ -321,18 +369,26 @@ def _create_itk_from_dcm(
             len(pixel_array.shape) == dimensions
             or pixel_array.shape == pixel_dims
         ):
-            use_pixel_array = True
+            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:
-            dcm_array[
-                index // dicom_ds.n_slices, z_index % dicom_ds.n_slices, :, :
-            ] = pixel_array
+            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))
@@ -340,10 +396,12 @@ def _create_itk_from_dcm(
             dcm_array[z_index % dicom_ds.n_slices, :, :] = pixel_array
 
         del ds
-    if use_pixel_array:
-        img = SimpleITK.GetImageFromArray(pixel_array, isVector=is_rgb)
-    else:
-        img = SimpleITK.GetImageFromArray(dcm_array, isVector=is_rgb)
+
+    img = SimpleITK.GetImageFromArray(dcm_array, isVector=is_rgb)
+    if dimensions == 4:
+        img.SetMetaData("ContentTimes", " ".join(content_times))
+        img.SetMetaData("Exposures", " ".join(exposures))
+
     return img
 
 
@@ -353,9 +411,8 @@ def image_builder_dicom(*, files: Set[Path]) -> Iterator[SimpleITKImage]:
 
     Parameters
     ----------
-    path
-        Path to a directory that contains all images that were uploaded during
-        an upload session.
+    files
+        Paths to images that were uploaded during an upload session.
 
     Returns
     -------


Test patch:
diff --git a/tests/resources/dicom/1.dcm b/tests/resources/dicom_4d/1.dcm
similarity index 100%
rename from tests/resources/dicom/1.dcm
rename to tests/resources/dicom_4d/1.dcm
diff --git a/tests/resources/dicom/10.dcm b/tests/resources/dicom_4d/10.dcm
similarity index 100%
rename from tests/resources/dicom/10.dcm
rename to tests/resources/dicom_4d/10.dcm
diff --git a/tests/resources/dicom/11.dcm b/tests/resources/dicom_4d/11.dcm
similarity index 100%
rename from tests/resources/dicom/11.dcm
rename to tests/resources/dicom_4d/11.dcm
diff --git a/tests/resources/dicom/12.dcm b/tests/resources/dicom_4d/12.dcm
similarity index 100%
rename from tests/resources/dicom/12.dcm
rename to tests/resources/dicom_4d/12.dcm
diff --git a/tests/resources/dicom/13.dcm b/tests/resources/dicom_4d/13.dcm
similarity index 100%
rename from tests/resources/dicom/13.dcm
rename to tests/resources/dicom_4d/13.dcm
diff --git a/tests/resources/dicom/14.dcm b/tests/resources/dicom_4d/14.dcm
similarity index 100%
rename from tests/resources/dicom/14.dcm
rename to tests/resources/dicom_4d/14.dcm
diff --git a/tests/resources/dicom/15.dcm b/tests/resources/dicom_4d/15.dcm
similarity index 100%
rename from tests/resources/dicom/15.dcm
rename to tests/resources/dicom_4d/15.dcm
diff --git a/tests/resources/dicom/16.dcm b/tests/resources/dicom_4d/16.dcm
similarity index 100%
rename from tests/resources/dicom/16.dcm
rename to tests/resources/dicom_4d/16.dcm
diff --git a/tests/resources/dicom/17.dcm b/tests/resources/dicom_4d/17.dcm
similarity index 100%
rename from tests/resources/dicom/17.dcm
rename to tests/resources/dicom_4d/17.dcm
diff --git a/tests/resources/dicom/18.dcm b/tests/resources/dicom_4d/18.dcm
similarity index 100%
rename from tests/resources/dicom/18.dcm
rename to tests/resources/dicom_4d/18.dcm
diff --git a/tests/resources/dicom/19.dcm b/tests/resources/dicom_4d/19.dcm
similarity index 100%
rename from tests/resources/dicom/19.dcm
rename to tests/resources/dicom_4d/19.dcm
diff --git a/tests/resources/dicom/2.dcm b/tests/resources/dicom_4d/2.dcm
similarity index 100%
rename from tests/resources/dicom/2.dcm
rename to tests/resources/dicom_4d/2.dcm
diff --git a/tests/resources/dicom/20.dcm b/tests/resources/dicom_4d/20.dcm
similarity index 100%
rename from tests/resources/dicom/20.dcm
rename to tests/resources/dicom_4d/20.dcm
diff --git a/tests/resources/dicom/21.dcm b/tests/resources/dicom_4d/21.dcm
similarity index 100%
rename from tests/resources/dicom/21.dcm
rename to tests/resources/dicom_4d/21.dcm
diff --git a/tests/resources/dicom/22.dcm b/tests/resources/dicom_4d/22.dcm
similarity index 100%
rename from tests/resources/dicom/22.dcm
rename to tests/resources/dicom_4d/22.dcm
diff --git a/tests/resources/dicom/23.dcm b/tests/resources/dicom_4d/23.dcm
similarity index 100%
rename from tests/resources/dicom/23.dcm
rename to tests/resources/dicom_4d/23.dcm
diff --git a/tests/resources/dicom/24.dcm b/tests/resources/dicom_4d/24.dcm
similarity index 100%
rename from tests/resources/dicom/24.dcm
rename to tests/resources/dicom_4d/24.dcm
diff --git a/tests/resources/dicom/25.dcm b/tests/resources/dicom_4d/25.dcm
similarity index 100%
rename from tests/resources/dicom/25.dcm
rename to tests/resources/dicom_4d/25.dcm
diff --git a/tests/resources/dicom/26.dcm b/tests/resources/dicom_4d/26.dcm
similarity index 100%
rename from tests/resources/dicom/26.dcm
rename to tests/resources/dicom_4d/26.dcm
diff --git a/tests/resources/dicom/27.dcm b/tests/resources/dicom_4d/27.dcm
similarity index 100%
rename from tests/resources/dicom/27.dcm
rename to tests/resources/dicom_4d/27.dcm
diff --git a/tests/resources/dicom/28.dcm b/tests/resources/dicom_4d/28.dcm
similarity index 100%
rename from tests/resources/dicom/28.dcm
rename to tests/resources/dicom_4d/28.dcm
diff --git a/tests/resources/dicom/29.dcm b/tests/resources/dicom_4d/29.dcm
similarity index 100%
rename from tests/resources/dicom/29.dcm
rename to tests/resources/dicom_4d/29.dcm
diff --git a/tests/resources/dicom/3.dcm b/tests/resources/dicom_4d/3.dcm
similarity index 100%
rename from tests/resources/dicom/3.dcm
rename to tests/resources/dicom_4d/3.dcm
diff --git a/tests/resources/dicom/30.dcm b/tests/resources/dicom_4d/30.dcm
similarity index 100%
rename from tests/resources/dicom/30.dcm
rename to tests/resources/dicom_4d/30.dcm
diff --git a/tests/resources/dicom/31.dcm b/tests/resources/dicom_4d/31.dcm
similarity index 100%
rename from tests/resources/dicom/31.dcm
rename to tests/resources/dicom_4d/31.dcm
diff --git a/tests/resources/dicom/32.dcm b/tests/resources/dicom_4d/32.dcm
similarity index 100%
rename from tests/resources/dicom/32.dcm
rename to tests/resources/dicom_4d/32.dcm
diff --git a/tests/resources/dicom/33.dcm b/tests/resources/dicom_4d/33.dcm
similarity index 100%
rename from tests/resources/dicom/33.dcm
rename to tests/resources/dicom_4d/33.dcm
diff --git a/tests/resources/dicom/34.dcm b/tests/resources/dicom_4d/34.dcm
similarity index 100%
rename from tests/resources/dicom/34.dcm
rename to tests/resources/dicom_4d/34.dcm
diff --git a/tests/resources/dicom/35.dcm b/tests/resources/dicom_4d/35.dcm
similarity index 100%
rename from tests/resources/dicom/35.dcm
rename to tests/resources/dicom_4d/35.dcm
diff --git a/tests/resources/dicom/36.dcm b/tests/resources/dicom_4d/36.dcm
similarity index 100%
rename from tests/resources/dicom/36.dcm
rename to tests/resources/dicom_4d/36.dcm
diff --git a/tests/resources/dicom/37.dcm b/tests/resources/dicom_4d/37.dcm
similarity index 100%
rename from tests/resources/dicom/37.dcm
rename to tests/resources/dicom_4d/37.dcm
diff --git a/tests/resources/dicom/38.dcm b/tests/resources/dicom_4d/38.dcm
similarity index 100%
rename from tests/resources/dicom/38.dcm
rename to tests/resources/dicom_4d/38.dcm
diff --git a/tests/resources/dicom/39.dcm b/tests/resources/dicom_4d/39.dcm
similarity index 100%
rename from tests/resources/dicom/39.dcm
rename to tests/resources/dicom_4d/39.dcm
diff --git a/tests/resources/dicom/4.dcm b/tests/resources/dicom_4d/4.dcm
similarity index 100%
rename from tests/resources/dicom/4.dcm
rename to tests/resources/dicom_4d/4.dcm
diff --git a/tests/resources/dicom/40.dcm b/tests/resources/dicom_4d/40.dcm
similarity index 100%
rename from tests/resources/dicom/40.dcm
rename to tests/resources/dicom_4d/40.dcm
diff --git a/tests/resources/dicom/41.dcm b/tests/resources/dicom_4d/41.dcm
similarity index 100%
rename from tests/resources/dicom/41.dcm
rename to tests/resources/dicom_4d/41.dcm
diff --git a/tests/resources/dicom/42.dcm b/tests/resources/dicom_4d/42.dcm
similarity index 100%
rename from tests/resources/dicom/42.dcm
rename to tests/resources/dicom_4d/42.dcm
diff --git a/tests/resources/dicom/43.dcm b/tests/resources/dicom_4d/43.dcm
similarity index 100%
rename from tests/resources/dicom/43.dcm
rename to tests/resources/dicom_4d/43.dcm
diff --git a/tests/resources/dicom/44.dcm b/tests/resources/dicom_4d/44.dcm
similarity index 100%
rename from tests/resources/dicom/44.dcm
rename to tests/resources/dicom_4d/44.dcm
diff --git a/tests/resources/dicom/45.dcm b/tests/resources/dicom_4d/45.dcm
similarity index 100%
rename from tests/resources/dicom/45.dcm
rename to tests/resources/dicom_4d/45.dcm
diff --git a/tests/resources/dicom/46.dcm b/tests/resources/dicom_4d/46.dcm
similarity index 100%
rename from tests/resources/dicom/46.dcm
rename to tests/resources/dicom_4d/46.dcm
diff --git a/tests/resources/dicom/47.dcm b/tests/resources/dicom_4d/47.dcm
similarity index 100%
rename from tests/resources/dicom/47.dcm
rename to tests/resources/dicom_4d/47.dcm
diff --git a/tests/resources/dicom/48.dcm b/tests/resources/dicom_4d/48.dcm
similarity index 100%
rename from tests/resources/dicom/48.dcm
rename to tests/resources/dicom_4d/48.dcm
diff --git a/tests/resources/dicom/49.dcm b/tests/resources/dicom_4d/49.dcm
similarity index 100%
rename from tests/resources/dicom/49.dcm
rename to tests/resources/dicom_4d/49.dcm
diff --git a/tests/resources/dicom/5.dcm b/tests/resources/dicom_4d/5.dcm
similarity index 100%
rename from tests/resources/dicom/5.dcm
rename to tests/resources/dicom_4d/5.dcm
diff --git a/tests/resources/dicom/50.dcm b/tests/resources/dicom_4d/50.dcm
similarity index 100%
rename from tests/resources/dicom/50.dcm
rename to tests/resources/dicom_4d/50.dcm
diff --git a/tests/resources/dicom/51.dcm b/tests/resources/dicom_4d/51.dcm
similarity index 100%
rename from tests/resources/dicom/51.dcm
rename to tests/resources/dicom_4d/51.dcm
diff --git a/tests/resources/dicom/52.dcm b/tests/resources/dicom_4d/52.dcm
similarity index 100%
rename from tests/resources/dicom/52.dcm
rename to tests/resources/dicom_4d/52.dcm
diff --git a/tests/resources/dicom/53.dcm b/tests/resources/dicom_4d/53.dcm
similarity index 100%
rename from tests/resources/dicom/53.dcm
rename to tests/resources/dicom_4d/53.dcm
diff --git a/tests/resources/dicom/54.dcm b/tests/resources/dicom_4d/54.dcm
similarity index 100%
rename from tests/resources/dicom/54.dcm
rename to tests/resources/dicom_4d/54.dcm
diff --git a/tests/resources/dicom/55.dcm b/tests/resources/dicom_4d/55.dcm
similarity index 100%
rename from tests/resources/dicom/55.dcm
rename to tests/resources/dicom_4d/55.dcm
diff --git a/tests/resources/dicom/56.dcm b/tests/resources/dicom_4d/56.dcm
similarity index 100%
rename from tests/resources/dicom/56.dcm
rename to tests/resources/dicom_4d/56.dcm
diff --git a/tests/resources/dicom/57.dcm b/tests/resources/dicom_4d/57.dcm
similarity index 100%
rename from tests/resources/dicom/57.dcm
rename to tests/resources/dicom_4d/57.dcm
diff --git a/tests/resources/dicom/58.dcm b/tests/resources/dicom_4d/58.dcm
similarity index 100%
rename from tests/resources/dicom/58.dcm
rename to tests/resources/dicom_4d/58.dcm
diff --git a/tests/resources/dicom/59.dcm b/tests/resources/dicom_4d/59.dcm
similarity index 100%
rename from tests/resources/dicom/59.dcm
rename to tests/resources/dicom_4d/59.dcm
diff --git a/tests/resources/dicom/6.dcm b/tests/resources/dicom_4d/6.dcm
similarity index 100%
rename from tests/resources/dicom/6.dcm
rename to tests/resources/dicom_4d/6.dcm
diff --git a/tests/resources/dicom/60.dcm b/tests/resources/dicom_4d/60.dcm
similarity index 100%
rename from tests/resources/dicom/60.dcm
rename to tests/resources/dicom_4d/60.dcm
diff --git a/tests/resources/dicom/61.dcm b/tests/resources/dicom_4d/61.dcm
similarity index 100%
rename from tests/resources/dicom/61.dcm
rename to tests/resources/dicom_4d/61.dcm
diff --git a/tests/resources/dicom/62.dcm b/tests/resources/dicom_4d/62.dcm
similarity index 100%
rename from tests/resources/dicom/62.dcm
rename to tests/resources/dicom_4d/62.dcm
diff --git a/tests/resources/dicom/63.dcm b/tests/resources/dicom_4d/63.dcm
similarity index 100%
rename from tests/resources/dicom/63.dcm
rename to tests/resources/dicom_4d/63.dcm
diff --git a/tests/resources/dicom/64.dcm b/tests/resources/dicom_4d/64.dcm
similarity index 100%
rename from tests/resources/dicom/64.dcm
rename to tests/resources/dicom_4d/64.dcm
diff --git a/tests/resources/dicom/65.dcm b/tests/resources/dicom_4d/65.dcm
similarity index 100%
rename from tests/resources/dicom/65.dcm
rename to tests/resources/dicom_4d/65.dcm
diff --git a/tests/resources/dicom/66.dcm b/tests/resources/dicom_4d/66.dcm
similarity index 100%
rename from tests/resources/dicom/66.dcm
rename to tests/resources/dicom_4d/66.dcm
diff --git a/tests/resources/dicom/67.dcm b/tests/resources/dicom_4d/67.dcm
similarity index 100%
rename from tests/resources/dicom/67.dcm
rename to tests/resources/dicom_4d/67.dcm
diff --git a/tests/resources/dicom/68.dcm b/tests/resources/dicom_4d/68.dcm
similarity index 100%
rename from tests/resources/dicom/68.dcm
rename to tests/resources/dicom_4d/68.dcm
diff --git a/tests/resources/dicom/69.dcm b/tests/resources/dicom_4d/69.dcm
similarity index 100%
rename from tests/resources/dicom/69.dcm
rename to tests/resources/dicom_4d/69.dcm
diff --git a/tests/resources/dicom/7.dcm b/tests/resources/dicom_4d/7.dcm
similarity index 100%
rename from tests/resources/dicom/7.dcm
rename to tests/resources/dicom_4d/7.dcm
diff --git a/tests/resources/dicom/70.dcm b/tests/resources/dicom_4d/70.dcm
similarity index 100%
rename from tests/resources/dicom/70.dcm
rename to tests/resources/dicom_4d/70.dcm
diff --git a/tests/resources/dicom/71.dcm b/tests/resources/dicom_4d/71.dcm
similarity index 100%
rename from tests/resources/dicom/71.dcm
rename to tests/resources/dicom_4d/71.dcm
diff --git a/tests/resources/dicom/72.dcm b/tests/resources/dicom_4d/72.dcm
similarity index 100%
rename from tests/resources/dicom/72.dcm
rename to tests/resources/dicom_4d/72.dcm
diff --git a/tests/resources/dicom/73.dcm b/tests/resources/dicom_4d/73.dcm
similarity index 100%
rename from tests/resources/dicom/73.dcm
rename to tests/resources/dicom_4d/73.dcm
diff --git a/tests/resources/dicom/74.dcm b/tests/resources/dicom_4d/74.dcm
similarity index 100%
rename from tests/resources/dicom/74.dcm
rename to tests/resources/dicom_4d/74.dcm
diff --git a/tests/resources/dicom/75.dcm b/tests/resources/dicom_4d/75.dcm
similarity index 100%
rename from tests/resources/dicom/75.dcm
rename to tests/resources/dicom_4d/75.dcm
diff --git a/tests/resources/dicom/76.dcm b/tests/resources/dicom_4d/76.dcm
similarity index 100%
rename from tests/resources/dicom/76.dcm
rename to tests/resources/dicom_4d/76.dcm
diff --git a/tests/resources/dicom/8.dcm b/tests/resources/dicom_4d/8.dcm
similarity index 100%
rename from tests/resources/dicom/8.dcm
rename to tests/resources/dicom_4d/8.dcm
diff --git a/tests/resources/dicom/9.dcm b/tests/resources/dicom_4d/9.dcm
similarity index 100%
rename from tests/resources/dicom/9.dcm
rename to tests/resources/dicom_4d/9.dcm
diff --git a/tests/resources/dicom_4d_enhanced/1.1.1.1.1.1.11111.1.1.1.1111.1111.900000000000000000000000000000.dcm b/tests/resources/dicom_4d_enhanced/1.1.1.1.1.1.11111.1.1.1.1111.1111.900000000000000000000000000000.dcm
new file mode 100644
index 0000000..5710c8a
Binary files /dev/null and b/tests/resources/dicom_4d_enhanced/1.1.1.1.1.1.11111.1.1.1.1111.1111.900000000000000000000000000000.dcm differ
diff --git a/tests/resources/dicom_4d_enhanced/1.1.1.1.1.1.11111.1.1.1.1111.1111.900000000000000000000000000001.dcm b/tests/resources/dicom_4d_enhanced/1.1.1.1.1.1.11111.1.1.1.1111.1111.900000000000000000000000000001.dcm
new file mode 100644
index 0000000..3c96d78
Binary files /dev/null and b/tests/resources/dicom_4d_enhanced/1.1.1.1.1.1.11111.1.1.1.1111.1111.900000000000000000000000000001.dcm differ
diff --git a/tests/resources/dicom_4d_enhanced/1.1.1.1.1.1.11111.1.1.1.1111.1111.900000000000000000000000000002.dcm b/tests/resources/dicom_4d_enhanced/1.1.1.1.1.1.11111.1.1.1.1111.1111.900000000000000000000000000002.dcm
new file mode 100644
index 0000000..8ec3eb9
Binary files /dev/null and b/tests/resources/dicom_4d_enhanced/1.1.1.1.1.1.11111.1.1.1.1111.1111.900000000000000000000000000002.dcm differ
diff --git a/tests/resources/dicom_4d_enhanced/1.1.1.1.1.1.11111.1.1.1.1111.1111.900000000000000000000000000003.dcm b/tests/resources/dicom_4d_enhanced/1.1.1.1.1.1.11111.1.1.1.1111.1111.900000000000000000000000000003.dcm
new file mode 100644
index 0000000..17f1007
Binary files /dev/null and b/tests/resources/dicom_4d_enhanced/1.1.1.1.1.1.11111.1.1.1.1111.1111.900000000000000000000000000003.dcm differ
diff --git a/tests/resources/dicom_4d_enhanced/1.1.1.1.1.1.11111.1.1.1.1111.1111.900000000000000000000000000004.dcm b/tests/resources/dicom_4d_enhanced/1.1.1.1.1.1.11111.1.1.1.1111.1111.900000000000000000000000000004.dcm
new file mode 100644
index 0000000..d1f3b34
Binary files /dev/null and b/tests/resources/dicom_4d_enhanced/1.1.1.1.1.1.11111.1.1.1.1111.1111.900000000000000000000000000004.dcm differ
diff --git a/tests/resources/dicom_4d_enhanced/1.1.1.1.1.1.11111.1.1.1.1111.1111.900000000000000000000000000005.dcm b/tests/resources/dicom_4d_enhanced/1.1.1.1.1.1.11111.1.1.1.1111.1111.900000000000000000000000000005.dcm
new file mode 100644
index 0000000..88ffe8e
Binary files /dev/null and b/tests/resources/dicom_4d_enhanced/1.1.1.1.1.1.11111.1.1.1.1111.1111.900000000000000000000000000005.dcm differ
diff --git a/tests/resources/dicom_4d_enhanced/1.1.1.1.1.1.11111.1.1.1.1111.1111.900000000000000000000000000006.dcm b/tests/resources/dicom_4d_enhanced/1.1.1.1.1.1.11111.1.1.1.1111.1111.900000000000000000000000000006.dcm
new file mode 100644
index 0000000..feb43fb
Binary files /dev/null and b/tests/resources/dicom_4d_enhanced/1.1.1.1.1.1.11111.1.1.1.1111.1111.900000000000000000000000000006.dcm differ
diff --git a/tests/resources/dicom_4d_enhanced/1.1.1.1.1.1.11111.1.1.1.1111.1111.900000000000000000000000000007.dcm b/tests/resources/dicom_4d_enhanced/1.1.1.1.1.1.11111.1.1.1.1111.1111.900000000000000000000000000007.dcm
new file mode 100644
index 0000000..1bda33e
Binary files /dev/null and b/tests/resources/dicom_4d_enhanced/1.1.1.1.1.1.11111.1.1.1.1111.1111.900000000000000000000000000007.dcm differ
diff --git a/tests/resources/dicom_4d_enhanced/1.1.1.1.1.1.11111.1.1.1.1111.1111.900000000000000000000000000008.dcm b/tests/resources/dicom_4d_enhanced/1.1.1.1.1.1.11111.1.1.1.1111.1111.900000000000000000000000000008.dcm
new file mode 100644
index 0000000..2d90bf5
Binary files /dev/null and b/tests/resources/dicom_4d_enhanced/1.1.1.1.1.1.11111.1.1.1.1111.1111.900000000000000000000000000008.dcm differ
diff --git a/tests/resources/dicom_4d_enhanced/1.1.1.1.1.1.11111.1.1.1.1111.1111.900000000000000000000000000009.dcm b/tests/resources/dicom_4d_enhanced/1.1.1.1.1.1.11111.1.1.1.1111.1111.900000000000000000000000000009.dcm
new file mode 100644
index 0000000..3983a03
Binary files /dev/null and b/tests/resources/dicom_4d_enhanced/1.1.1.1.1.1.11111.1.1.1.1111.1111.900000000000000000000000000009.dcm differ
diff --git a/tests/resources/dicom_4d_enhanced/1.1.1.1.1.1.11111.1.1.1.1111.1111.900000000000000000000000000010.dcm b/tests/resources/dicom_4d_enhanced/1.1.1.1.1.1.11111.1.1.1.1111.1111.900000000000000000000000000010.dcm
new file mode 100644
index 0000000..96586ce
Binary files /dev/null and b/tests/resources/dicom_4d_enhanced/1.1.1.1.1.1.11111.1.1.1.1111.1111.900000000000000000000000000010.dcm differ
diff --git a/tests/resources/dicom_4d_enhanced/1.1.1.1.1.1.11111.1.1.1.1111.1111.900000000000000000000000000011.dcm b/tests/resources/dicom_4d_enhanced/1.1.1.1.1.1.11111.1.1.1.1111.1111.900000000000000000000000000011.dcm
new file mode 100644
index 0000000..3b7838b
Binary files /dev/null and b/tests/resources/dicom_4d_enhanced/1.1.1.1.1.1.11111.1.1.1.1111.1111.900000000000000000000000000011.dcm differ
diff --git a/tests/resources/dicom_4d_enhanced/1.1.1.1.1.1.11111.1.1.1.1111.1111.900000000000000000000000000012.dcm b/tests/resources/dicom_4d_enhanced/1.1.1.1.1.1.11111.1.1.1.1111.1111.900000000000000000000000000012.dcm
new file mode 100644
index 0000000..eb98d2e
Binary files /dev/null and b/tests/resources/dicom_4d_enhanced/1.1.1.1.1.1.11111.1.1.1.1111.1111.900000000000000000000000000012.dcm differ
diff --git a/tests/resources/dicom_4d_enhanced/1.1.1.1.1.1.11111.1.1.1.1111.1111.900000000000000000000000000013.dcm b/tests/resources/dicom_4d_enhanced/1.1.1.1.1.1.11111.1.1.1.1111.1111.900000000000000000000000000013.dcm
new file mode 100644
index 0000000..a0844af
Binary files /dev/null and b/tests/resources/dicom_4d_enhanced/1.1.1.1.1.1.11111.1.1.1.1111.1111.900000000000000000000000000013.dcm differ
diff --git a/tests/resources/dicom_4d_enhanced/1.1.1.1.1.1.11111.1.1.1.1111.1111.900000000000000000000000000014.dcm b/tests/resources/dicom_4d_enhanced/1.1.1.1.1.1.11111.1.1.1.1111.1111.900000000000000000000000000014.dcm
new file mode 100644
index 0000000..0f8fde1
Binary files /dev/null and b/tests/resources/dicom_4d_enhanced/1.1.1.1.1.1.11111.1.1.1.1111.1111.900000000000000000000000000014.dcm differ
diff --git a/tests/resources/dicom_4d_enhanced/1.1.1.1.1.1.11111.1.1.1.1111.1111.900000000000000000000000000015.dcm b/tests/resources/dicom_4d_enhanced/1.1.1.1.1.1.11111.1.1.1.1111.1111.900000000000000000000000000015.dcm
new file mode 100644
index 0000000..d97208e
Binary files /dev/null and b/tests/resources/dicom_4d_enhanced/1.1.1.1.1.1.11111.1.1.1.1111.1111.900000000000000000000000000015.dcm differ
diff --git a/tests/resources/dicom_4d_enhanced/1.1.1.1.1.1.11111.1.1.1.1111.1111.900000000000000000000000000016.dcm b/tests/resources/dicom_4d_enhanced/1.1.1.1.1.1.11111.1.1.1.1111.1111.900000000000000000000000000016.dcm
new file mode 100644
index 0000000..1639687
Binary files /dev/null and b/tests/resources/dicom_4d_enhanced/1.1.1.1.1.1.11111.1.1.1.1111.1111.900000000000000000000000000016.dcm differ
diff --git a/tests/resources/dicom_4d_enhanced/1.1.1.1.1.1.11111.1.1.1.1111.1111.900000000000000000000000000017.dcm b/tests/resources/dicom_4d_enhanced/1.1.1.1.1.1.11111.1.1.1.1111.1111.900000000000000000000000000017.dcm
new file mode 100644
index 0000000..6a0435d
Binary files /dev/null and b/tests/resources/dicom_4d_enhanced/1.1.1.1.1.1.11111.1.1.1.1111.1111.900000000000000000000000000017.dcm differ
diff --git a/tests/resources/dicom_4d_enhanced/1.1.1.1.1.1.11111.1.1.1.1111.1111.900000000000000000000000000018.dcm b/tests/resources/dicom_4d_enhanced/1.1.1.1.1.1.11111.1.1.1.1111.1111.900000000000000000000000000018.dcm
new file mode 100644
index 0000000..0beb096
Binary files /dev/null and b/tests/resources/dicom_4d_enhanced/1.1.1.1.1.1.11111.1.1.1.1111.1111.900000000000000000000000000018.dcm differ
diff --git a/tests/resources/dicom_enhanced/DATA_SOURCE.md b/tests/resources/dicom_enhanced/DATA_SOURCE.md
new file mode 100644
index 0000000..eb59c54
--- /dev/null
+++ b/tests/resources/dicom_enhanced/DATA_SOURCE.md
@@ -0,0 +1,5 @@
+The DICOM files in this directory have been derived from
+
+> Georgia Peleka. (2021). CT_PHANTOM_HEALTHY_ORTHO_01 [Data set]. Zenodo. http://doi.org/10.5281/zenodo.4564338
+
+and are used under the Creative Commons Attribution 4.0 International license.
diff --git a/tests/resources/dicom_enhanced/slice1.dcm b/tests/resources/dicom_enhanced/slice1.dcm
new file mode 100644
index 0000000..31f3d25
Binary files /dev/null and b/tests/resources/dicom_enhanced/slice1.dcm differ
diff --git a/tests/resources/dicom_enhanced/slice2.dcm b/tests/resources/dicom_enhanced/slice2.dcm
new file mode 100644
index 0000000..37cc261
Binary files /dev/null and b/tests/resources/dicom_enhanced/slice2.dcm differ
diff --git a/tests/resources/dicom_enhanced/slice3.dcm b/tests/resources/dicom_enhanced/slice3.dcm
new file mode 100644
index 0000000..b700269
Binary files /dev/null and b/tests/resources/dicom_enhanced/slice3.dcm differ
diff --git a/tests/resources/dicom_enhanced/slice4.dcm b/tests/resources/dicom_enhanced/slice4.dcm
new file mode 100644
index 0000000..842a35b
Binary files /dev/null and b/tests/resources/dicom_enhanced/slice4.dcm differ
diff --git a/tests/resources/dicom_enhanced/slice5.dcm b/tests/resources/dicom_enhanced/slice5.dcm
new file mode 100644
index 0000000..8cbec08
Binary files /dev/null and b/tests/resources/dicom_enhanced/slice5.dcm differ
diff --git a/tests/resources/dicom_enhanced/slice6.dcm b/tests/resources/dicom_enhanced/slice6.dcm
new file mode 100644
index 0000000..d65bf5c
Binary files /dev/null and b/tests/resources/dicom_enhanced/slice6.dcm differ
diff --git a/tests/resources/dicom_enhanced/slice7.dcm b/tests/resources/dicom_enhanced/slice7.dcm
new file mode 100644
index 0000000..a7f46fd
Binary files /dev/null and b/tests/resources/dicom_enhanced/slice7.dcm differ
diff --git a/tests/resources/dicom_enhanced/volume.dcm b/tests/resources/dicom_enhanced/volume.dcm
new file mode 100644
index 0000000..bd67883
Binary files /dev/null and b/tests/resources/dicom_enhanced/volume.dcm differ
diff --git a/tests/test_default_image_builders.py b/tests/test_default_image_builders.py
index 657c3bc..250b6ba 100644
--- a/tests/test_default_image_builders.py
+++ b/tests/test_default_image_builders.py
@@ -49,7 +49,7 @@ def test_number_of_images_consumed_by_each_builder(tmp_path, builder):
     # Except for the oct builder, each of the builders should only consume one
     # image. The other files should error out.
     files = {
-        *(RESOURCE_PATH / "dicom").glob("*.dcm"),
+        *(RESOURCE_PATH / "dicom_4d").glob("*.dcm"),
         RESOURCE_PATH / "test_rgb.png",
         RESOURCE_PATH / "image10x10x10.mha",
         RESOURCE_PATH / "nifti" / "image10x11x12.nii.gz",
diff --git a/tests/test_dicom.py b/tests/test_dicom.py
index f8075e5..31513c1 100644
--- a/tests/test_dicom.py
+++ b/tests/test_dicom.py
@@ -17,7 +17,7 @@
 from panimg.panimg import _build_files
 from tests import RESOURCE_PATH
 
-DICOM_DIR = RESOURCE_PATH / "dicom"
+DICOM_DIR = RESOURCE_PATH / "dicom_4d"
 
 
 def test_get_headers_by_study():
@@ -122,10 +122,44 @@ def test_image_builder_dicom_4dct(tmpdir):
     )
 
 
+def test_image_builder_dicom_enhanced():
+    # Load reference image from individual slices
+    slices = set((RESOURCE_PATH / "dicom_enhanced").glob("slice*.dcm"))
+    ref_images = list(image_builder_dicom(files=slices))
+    assert len(ref_images) == 1
+    ref_image = ref_images.pop().image
+
+    # Load the same image but now stored as enhanced DICOM volume
+    volume = {RESOURCE_PATH / "dicom_enhanced" / "volume.dcm"}
+    images = list(image_builder_dicom(files=volume))
+    assert len(images) == 1
+    image = images.pop().image
+
+    assert image == ref_image
+
+
+def test_image_builder_dicom_4d_enhanced():
+    # Load reference image from individual slices
+    slices = set((RESOURCE_PATH / "dicom_4d").glob("*.dcm"))
+    ref_images = list(image_builder_dicom(files=slices))
+    assert len(ref_images) == 1
+    ref_image = ref_images.pop().image
+
+    # Load the same image but now stored as enhanced DICOM volume
+    volumes = set((RESOURCE_PATH / "dicom_4d_enhanced").glob("*.dcm"))
+    images = list(image_builder_dicom(files=volumes))
+    assert len(images) == 1
+    image = images.pop().image
+
+    for i in range(ref_image.GetSize()[0]):
+        # Compare in 3D because 4D comparisons are not implemented in SITK
+        assert image[i, :, :, :] == ref_image[i, :, :, :]
+
+
 @pytest.mark.parametrize(
     "folder,element_type",
     [
-        ("dicom", "MET_SHORT"),
+        ("dicom_4d", "MET_SHORT"),
         ("dicom_intercept", "MET_FLOAT"),
         ("dicom_slope", "MET_FLOAT"),
     ],
@@ -159,7 +193,7 @@ def test_dicom_rescaling(folder, element_type, tmpdir):
         (
             {
                 Path(d[0]).joinpath(f)
-                for d in os.walk(RESOURCE_PATH / "dicom")
+                for d in os.walk(RESOURCE_PATH / "dicom_4d")
                 for f in d[2]
             },
             "30",