Repository: comic/grand-challenge.org

Issue:
Add a JPEG/PNG importer
Some people have medical images stored as PNGs or JPEGs, add an importer for these, saving them as MHDs (1x1x1 mm in the mhd header, None in the database to indicate that this was a guess). 


Discussion:

Resolving patch:
diff --git a/app/grandchallenge/cases/image_builders/fallback.py b/app/grandchallenge/cases/image_builders/fallback.py
new file mode 100644
--- /dev/null
+++ b/app/grandchallenge/cases/image_builders/fallback.py
@@ -0,0 +1,54 @@
+from pathlib import Path
+
+import SimpleITK
+import numpy as np
+from PIL import Image
+
+from grandchallenge.cases.image_builders import ImageBuilderResult
+from grandchallenge.cases.image_builders.utils import convert_itk_to_internal
+
+
+def image_builder_fallback(path: Path) -> ImageBuilderResult:
+    """
+    Constructs image objects by inspecting files in a directory.
+
+    Parameters
+    ----------
+    path
+        Path to a directory that contains all images that were uploaded during
+        an upload session.
+
+    Returns
+    -------
+    An `ImageBuilder` object consisting of:
+     - a list of filenames for all files consumed by the image builder
+     - a list of detected images
+     - a list files associated with the detected images
+     - path->error message map describing what is wrong with a given file
+    """
+    errors = {}
+    new_images = []
+    new_image_files = []
+    consumed_files = []
+    for file_path in path.iterdir():
+        try:
+            img = Image.open(file_path)
+            img_array = np.array(img)
+            is_vector = img.mode != "L"
+            img = SimpleITK.GetImageFromArray(img_array, isVector=is_vector)
+            n_image, n_image_files = convert_itk_to_internal(
+                img, name=file_path.name, use_spacing=False
+            )
+            new_images.append(n_image)
+            new_image_files += n_image_files
+            consumed_files.append(file_path.name)
+        except IOError:
+            errors[file_path.name] = "Not a valid image file"
+
+    return ImageBuilderResult(
+        consumed_files=consumed_files,
+        file_errors_map=errors,
+        new_images=new_images,
+        new_image_files=new_image_files,
+        new_folder_upload=[],
+    )
diff --git a/app/grandchallenge/cases/image_builders/utils.py b/app/grandchallenge/cases/image_builders/utils.py
--- a/app/grandchallenge/cases/image_builders/utils.py
+++ b/app/grandchallenge/cases/image_builders/utils.py
@@ -10,7 +10,9 @@
 
 
 def convert_itk_to_internal(
-    simple_itk_image: SimpleITK.Image, name: Optional[AnyStr] = None,
+    simple_itk_image: SimpleITK.Image,
+    name: Optional[AnyStr] = None,
+    use_spacing: Optional[bool] = True,
 ) -> Tuple[Image, Sequence[ImageFile]]:
     color_space = simple_itk_image.GetNumberOfComponentsPerPixel()
     color_space = {
@@ -45,8 +47,12 @@ def convert_itk_to_internal(
             timepoints=timepoints,
             resolution_levels=None,
             color_space=color_space,
-            voxel_width_mm=simple_itk_image.GetSpacing()[0],
-            voxel_height_mm=simple_itk_image.GetSpacing()[1],
+            voxel_width_mm=simple_itk_image.GetSpacing()[0]
+            if use_spacing
+            else None,
+            voxel_height_mm=simple_itk_image.GetSpacing()[1]
+            if use_spacing
+            else None,
             voxel_depth_mm=simple_itk_image.GetSpacing()[2] if depth else None,
         )
         db_image_files = []
diff --git a/app/grandchallenge/cases/tasks.py b/app/grandchallenge/cases/tasks.py
--- a/app/grandchallenge/cases/tasks.py
+++ b/app/grandchallenge/cases/tasks.py
@@ -20,6 +20,7 @@
 from grandchallenge.cases.image_builders.dicom_4dct import (
     image_builder_dicom_4dct,
 )
+from grandchallenge.cases.image_builders.fallback import image_builder_fallback
 from grandchallenge.cases.image_builders.metaio_mhd_mha import (
     image_builder_mhd,
 )
@@ -148,6 +149,7 @@ def store_image(
     image_builder_mhd,
     image_builder_tiff,
     image_builder_dicom_4dct,
+    image_builder_fallback,
 ]
 
 


Test patch:
diff --git a/app/tests/cases_tests/resources/corrupt.png b/app/tests/cases_tests/resources/corrupt.png
new file mode 100644
Binary files /dev/null and b/app/tests/cases_tests/resources/corrupt.png differ
diff --git a/app/tests/cases_tests/resources/test_grayscale.jpg b/app/tests/cases_tests/resources/test_grayscale.jpg
new file mode 100644
Binary files /dev/null and b/app/tests/cases_tests/resources/test_grayscale.jpg differ
diff --git a/app/tests/cases_tests/resources/test_grayscale.png b/app/tests/cases_tests/resources/test_grayscale.png
new file mode 100644
Binary files /dev/null and b/app/tests/cases_tests/resources/test_grayscale.png differ
diff --git a/app/tests/cases_tests/resources/test_rgb.jpg b/app/tests/cases_tests/resources/test_rgb.jpg
new file mode 100644
Binary files /dev/null and b/app/tests/cases_tests/resources/test_rgb.jpg differ
diff --git a/app/tests/cases_tests/resources/test_rgb.png b/app/tests/cases_tests/resources/test_rgb.png
new file mode 100644
Binary files /dev/null and b/app/tests/cases_tests/resources/test_rgb.png differ
diff --git a/app/tests/cases_tests/resources/test_rgba.png b/app/tests/cases_tests/resources/test_rgba.png
new file mode 100644
Binary files /dev/null and b/app/tests/cases_tests/resources/test_rgba.png differ
diff --git a/app/tests/cases_tests/test_background_tasks.py b/app/tests/cases_tests/test_background_tasks.py
--- a/app/tests/cases_tests/test_background_tasks.py
+++ b/app/tests/cases_tests/test_background_tasks.py
@@ -93,7 +93,7 @@ def test_image_file_creation(settings):
         "invalid_tiles_tiff.tif",
     ]
 
-    invalid_images = ("no_image", "invalid_utf8.mhd", "invalid_tiles_tiff.tif")
+    invalid_images = ("no_image", "invalid_utf8.mhd")
     session, uploaded_images = create_raw_upload_image_session(images)
 
     session.refresh_from_db()
@@ -101,7 +101,7 @@ def test_image_file_creation(settings):
     for image_name in invalid_images:
         assert image_name in session.error_message
 
-    assert Image.objects.filter(origin=session).count() == 5
+    assert Image.objects.filter(origin=session).count() == 6
 
     for name, db_object in uploaded_images.items():
         name: str
diff --git a/app/tests/cases_tests/test_fallback.py b/app/tests/cases_tests/test_fallback.py
new file mode 100644
--- /dev/null
+++ b/app/tests/cases_tests/test_fallback.py
@@ -0,0 +1,43 @@
+import shutil
+from pathlib import Path
+
+import pytest
+
+from grandchallenge.cases.image_builders.fallback import image_builder_fallback
+from grandchallenge.cases.models import Image
+from tests.cases_tests import RESOURCE_PATH
+
+
+@pytest.mark.parametrize(
+    "src,colorspace",
+    (
+        (RESOURCE_PATH / "test_grayscale.jpg", Image.COLOR_SPACE_GRAY),
+        (RESOURCE_PATH / "test_rgb.jpg", Image.COLOR_SPACE_RGB),
+        (RESOURCE_PATH / "test_grayscale.png", Image.COLOR_SPACE_GRAY),
+        (RESOURCE_PATH / "test_rgb.png", Image.COLOR_SPACE_RGB),
+        (RESOURCE_PATH / "test_rgba.png", Image.COLOR_SPACE_RGBA),
+    ),
+)
+def test_image_builder_fallback(tmpdir, src, colorspace):
+    dest = Path(tmpdir) / src.name
+    shutil.copy(str(src), str(dest))
+
+    result = image_builder_fallback(Path(tmpdir))
+    assert result.consumed_files == [src.name]
+    image = result.new_images[0]
+    assert image.color_space == colorspace
+    assert image.voxel_width_mm is None
+    assert image.voxel_height_mm is None
+    assert image.voxel_depth_mm is None
+
+
+def test_image_builder_fallback_corrupt_file(tmpdir):
+    src = RESOURCE_PATH / "corrupt.png"
+    dest = Path(tmpdir) / src.name
+    shutil.copy(str(src), str(dest))
+
+    result = image_builder_fallback(Path(tmpdir))
+    assert result.file_errors_map == {
+        src.name: "Not a valid image file",
+    }
+    assert result.consumed_files == []