Repository: comic/grand-challenge.org

Issue:
MHA constructed from DICOM is always float32
ITK files constructed from DICOM are always float32 type, if the rescale slope is 1 and intercept is 0 for every partial file then this can be saved as short instead.


Discussion:

Resolving patch:
diff --git a/app/grandchallenge/cases/image_builders/dicom.py b/app/grandchallenge/cases/image_builders/dicom.py
--- a/app/grandchallenge/cases/image_builders/dicom.py
+++ b/app/grandchallenge/cases/image_builders/dicom.py
@@ -1,5 +1,6 @@
 import tempfile
 from collections import namedtuple
+from math import isclose
 from pathlib import Path
 
 import SimpleITK
@@ -147,21 +148,6 @@ def _extract_direction(dicom_ds, direction):
     return direction
 
 
-def _create_sitk_image(dcm_array):
-    shape = dcm_array.shape[::-1]
-    # Write the numpy array to a file, so there is no need to keep it in memory
-    # anymore. Then create a SimpleITK image from it.
-    with tempfile.NamedTemporaryFile() as temp:
-        temp.seek(0)
-        temp.write(dcm_array.tostring())
-        temp.flush()
-        temp.seek(0)
-        del dcm_array
-        img = SimpleITK.Image(shape, SimpleITK.sitkFloat32, 1)
-        SimpleITK._SimpleITK._SetImageFromArray(temp.read(), img)
-        return img
-
-
 def _process_dicom_file(dicom_ds, session_id):  # noqa: C901
     ref_file = pydicom.dcmread(str(dicom_ds.headers[0]["file"]))
     ref_origin = tuple(
@@ -177,7 +163,6 @@ def _process_dicom_file(dicom_ds, session_id):  # noqa: C901
     )
     if dicom_ds.n_time:
         pixel_dims = (dicom_ds.n_time,) + pixel_dims
-    dcm_array = np.zeros(pixel_dims, dtype=np.float32)
 
     # Additional Meta data Contenttimes and Exposures
     content_times = []
@@ -201,29 +186,14 @@ def _process_dicom_file(dicom_ds, session_id):  # noqa: C901
     except IndexError:
         z_i = 1.0
 
-    for index, partial in enumerate(dicom_ds.headers):
-        ds = pydicom.dcmread(str(partial["file"]))
-
-        # Apply RescaleSlope and RescaleIntercept
-        pixel_array = float(
-            getattr(ds, "RescaleSlope", 1)
-        ) * ds.pixel_array + float(getattr(ds, "RescaleIntercept", 0))
-        if len(ds.pixel_array.shape) == dimensions:
-            dcm_array = pixel_array
-            break
-        z_index = index if z_i >= 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
-            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 = _create_sitk_image(dcm_array)
+    img = _create_itk_from_dcm(
+        content_times=content_times,
+        dicom_ds=dicom_ds,
+        dimensions=dimensions,
+        exposures=exposures,
+        pixel_dims=pixel_dims,
+        z_i=z_i,
+    )
 
     if origin is None:
         origin = (0.0, 0.0, 0.0)
@@ -251,7 +221,7 @@ def _process_dicom_file(dicom_ds, session_id):  # noqa: C901
         img.SetMetaData("Exposures", " ".join(exposures))
 
     for f in OPTIONAL_METADATA_FIELDS:
-        if f in ref_file:
+        if getattr(ref_file, f, False):
             img.SetMetaData(f, getattr(ref_file, f))
 
     # Convert the SimpleITK image to our internal representation
@@ -261,6 +231,72 @@ def _process_dicom_file(dicom_ds, session_id):  # noqa: C901
     )
 
 
+def _create_itk_from_dcm(
+    *, content_times, dicom_ds, dimensions, exposures, pixel_dims, z_i
+):
+    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
+
+    if apply_scaling:
+        np_dtype = np.float32
+        sitk_dtype = SimpleITK.sitkFloat32
+    else:
+        np_dtype = np.short
+        sitk_dtype = SimpleITK.sitkInt16
+
+    dcm_array = np.zeros(pixel_dims, dtype=np_dtype)
+
+    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))
+        else:
+            pixel_array = ds.pixel_array
+
+        if len(ds.pixel_array.shape) == dimensions:
+            dcm_array = pixel_array
+            break
+
+        z_index = index if z_i >= 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
+            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
+
+    shape = dcm_array.shape[::-1]
+    # Write the numpy array to a file, so there is no need to keep it in memory
+    # anymore. Then create a SimpleITK image from it.
+    with tempfile.NamedTemporaryFile() as temp:
+        temp.seek(0)
+        temp.write(dcm_array.tostring())
+        temp.flush()
+        temp.seek(0)
+
+        del dcm_array
+
+        img = SimpleITK.Image(shape, sitk_dtype, 1)
+        SimpleITK._SimpleITK._SetImageFromArray(temp.read(), img)
+
+    return img
+
+
 def image_builder_dicom(path: Path, session_id=None) -> ImageBuilderResult:
     """
     Constructs image objects by inspecting files in a directory.


Test patch:
diff --git a/app/tests/cases_tests/resources/dicom_intercept/1.dcm b/app/tests/cases_tests/resources/dicom_intercept/1.dcm
new file mode 100644
Binary files /dev/null and b/app/tests/cases_tests/resources/dicom_intercept/1.dcm differ
diff --git a/app/tests/cases_tests/resources/dicom_intercept/2.dcm b/app/tests/cases_tests/resources/dicom_intercept/2.dcm
new file mode 100644
Binary files /dev/null and b/app/tests/cases_tests/resources/dicom_intercept/2.dcm differ
diff --git a/app/tests/cases_tests/resources/dicom_slope/1.dcm b/app/tests/cases_tests/resources/dicom_slope/1.dcm
new file mode 100644
Binary files /dev/null and b/app/tests/cases_tests/resources/dicom_slope/1.dcm differ
diff --git a/app/tests/cases_tests/resources/dicom_slope/2.dcm b/app/tests/cases_tests/resources/dicom_slope/2.dcm
new file mode 100644
Binary files /dev/null and b/app/tests/cases_tests/resources/dicom_slope/2.dcm differ
diff --git a/app/tests/cases_tests/test_dicom.py b/app/tests/cases_tests/test_dicom.py
--- a/app/tests/cases_tests/test_dicom.py
+++ b/app/tests/cases_tests/test_dicom.py
@@ -2,6 +2,7 @@
 
 import numpy as np
 import pydicom
+import pytest
 
 from grandchallenge.cases.image_builders.dicom import (
     _get_headers_by_study,
@@ -82,3 +83,27 @@ def test_image_builder_dicom_4dct():
         list(map(float, origin)),
         list(map(float, dcm_ref.ImagePositionPatient)) + [0.0],
     )
+
+
+@pytest.mark.parametrize(
+    "folder,element_type",
+    [
+        ("dicom", "MET_SHORT"),
+        ("dicom_intercept", "MET_FLOAT"),
+        ("dicom_slope", "MET_FLOAT"),
+    ],
+)
+def test_dicom_rescaling(folder, element_type):
+    """
+    2.dcm in dicom_intercept and dicom_slope has been modified to add a
+    small intercept (0.01) or slope (1.001) respectively.
+    """
+    result = image_builder_dicom(RESOURCE_PATH / folder)
+
+    assert len(result.new_image_files) == 1
+    mha_file_obj = [
+        x for x in result.new_image_files if x.file.name.endswith("mha")
+    ][0]
+
+    headers = parse_mh_header(mha_file_obj.file)
+    assert headers["ElementType"] == element_type
diff --git a/app/tests/cases_tests/test_tiff.py b/app/tests/cases_tests/test_tiff.py
--- a/app/tests/cases_tests/test_tiff.py
+++ b/app/tests/cases_tests/test_tiff.py
@@ -170,7 +170,7 @@ def test_image_builder_tiff(tmpdir_factory):
     # Copy resource files to writable temp folder
     temp_dir = Path(tmpdir_factory.mktemp("temp") / "resources")
     shutil.copytree(
-        RESOURCE_PATH, temp_dir, ignore=shutil.ignore_patterns("dicom")
+        RESOURCE_PATH, temp_dir, ignore=shutil.ignore_patterns("dicom*")
     )
 
     image_builder_result = image_builder_tiff(temp_dir)