Repository: comic/grand-challenge.org

Issue:
Upload of Phillips tiff files fails 
When uploading tiff files from a Philps scanner, there are a number of errors that occur while uploading. The files are also not shown properly by MeVisLab


Discussion:
Will adding OpenSlide with their python bindings fix this? 
Yes, jusr have to get some info about the image in a different way. 

Resolving patch:
diff --git a/app/grandchallenge/cases/image_builders/fallback.py b/app/grandchallenge/cases/image_builders/fallback.py
--- a/app/grandchallenge/cases/image_builders/fallback.py
+++ b/app/grandchallenge/cases/image_builders/fallback.py
@@ -3,6 +3,7 @@
 import SimpleITK
 import numpy as np
 from PIL import Image
+from PIL.Image import DecompressionBombError
 from django.core.exceptions import ValidationError
 
 from grandchallenge.cases.image_builders import ImageBuilderResult
@@ -49,7 +50,7 @@ def image_builder_fallback(path: Path, session_id=None) -> ImageBuilderResult:
             new_images.append(n_image)
             new_image_files += n_image_files
             consumed_files.append(file_path.name)
-        except (IOError, ValidationError):
+        except (IOError, ValidationError, DecompressionBombError):
             errors[file_path.name] = "Not a valid image file"
 
     return ImageBuilderResult(
diff --git a/app/grandchallenge/cases/image_builders/tiff.py b/app/grandchallenge/cases/image_builders/tiff.py
--- a/app/grandchallenge/cases/image_builders/tiff.py
+++ b/app/grandchallenge/cases/image_builders/tiff.py
@@ -4,6 +4,7 @@
 from typing import Optional
 from uuid import UUID, uuid4
 
+import openslide
 import pyvips
 import tifffile
 from django.conf import settings
@@ -37,30 +38,14 @@ def validate(self):
         if not self.voxel_width_mm:
             raise ValidationError("Voxel width could not be determined")
         if not self.voxel_height_mm:
-            raise ValidationError("Voxel heigth could not be determined")
+            raise ValidationError("Voxel height could not be determined")
 
 
-def load_tiff_file(path: Path) -> GrandChallengeTiffFile:
-    """
-    Loads and validates a file using tifffile
-    :param path: The path to the potential tiff file
-    :return: A tiff file that can be used in the rest of grand challenge
-    """
-    try:
-        file = tifffile.TiffFile(str(path.absolute()))
-        gc_file = GrandChallengeTiffFile(path)
-    except ValueError:
-        raise ValidationError("Image isn't a TIFF file")
-
-    return _validate_tiff_file(gc_file=gc_file, pages=file.pages)
-
-
-def _get_tag_value(tags, tag, required=True):
+def _get_tag_value(tags, tag):
     try:
         return tags[tag].value
-    except KeyError:
-        if required:
-            raise ValidationError(f"Tiff file is missing required tag {tag}")
+    except (KeyError, AttributeError):
+        return None
 
 
 def _get_voxel_spacing_mm(tags, tag):
@@ -101,42 +86,39 @@ def _get_voxel_spacing_mm(tags, tag):
         raise ValidationError(
             f"Invalid resolution unit {resolution_unit}" f" in tiff file"
         )
-    except ZeroDivisionError:
+    except (ZeroDivisionError, TypeError, IndexError):
         raise ValidationError(f"Invalid resolution in tiff file")
 
 
-def _validate_tiff_file(
+def _extract_openslide_properties(
+    *, gc_file: GrandChallengeTiffFile, image: any
+):
+    if not gc_file.voxel_width_mm and "openslide.mpp-x" in image.properties:
+        gc_file.voxel_width_mm = (
+            float(image.properties["openslide.mpp-x"]) / 1000
+        )
+    if not gc_file.voxel_height_mm and "openslide.mpp-y" in image.properties:
+        gc_file.voxel_height_mm = (
+            float(image.properties["openslide.mpp-y"]) / 1000
+        )
+    return gc_file
+
+
+def _extract_tags(
     *, gc_file: GrandChallengeTiffFile, pages: tifffile.tifffile.TiffPages
 ) -> GrandChallengeTiffFile:
     """
-    Validates a tiff file loaded with tifffile for use in grand challenge
-    :param pages: The pages and tags from tiffile
-    :return: The extracted tags that are needed by the rest of the framework
+    Extracts tags form a tiff file loaded with tifffile for use in grand challenge
+    :param pages: The pages and tags from tifffile
+    :return: The GrandChallengeTiffFile with the properties defined in the tags
     """
-
-    required_tile_tags = ("TileOffsets", "TileByteCounts")
+    if not pages:
+        return gc_file
 
     tags = pages[0].tags
 
-    # Fails if the image doesn't have all required tile tags
-    for tag in required_tile_tags:
-        _get_tag_value(tags, tag, True)
-
-    # Fails if the image only has a single resolution page
     gc_file.resolution_levels = len(pages)
-    if gc_file.resolution_levels == 1:
-        raise ValidationError("Image only has a single resolution level")
-
-    # Fails if the image doesn't have the chunky format
-    if (
-        str(_get_tag_value(tags, "PlanarConfiguration"))
-        != "PLANARCONFIG.CONTIG"
-    ):
-        raise ValidationError(
-            "Image planar configuration isn't configured as 'Chunky' format"
-        )
 
-    # Fails if the color space isn't supported
     gc_file.color_space = _get_color_space(
         color_space_string=str(
             _get_tag_value(tags, "PhotometricInterpretation")
@@ -150,6 +132,7 @@ def _validate_tiff_file(
     if "XResolution" in tags:
         gc_file.voxel_width_mm = _get_voxel_spacing_mm(tags, "XResolution")
         gc_file.voxel_height_mm = _get_voxel_spacing_mm(tags, "YResolution")
+
     gc_file.voxel_depth_mm = None
 
     return gc_file
@@ -164,7 +147,7 @@ def _get_color_space(*, color_space_string) -> Image.COLOR_SPACES:
         try:
             color_space = dict(Image.COLOR_SPACES)[color_space_string]
         except KeyError:
-            raise ValidationError("Invalid color space")
+            return None
 
     return color_space
 
@@ -191,6 +174,46 @@ def _create_image_file(*, path: str, image: Image):
         )
 
 
+def _load_with_tiff(*, gc_file: GrandChallengeTiffFile):
+    tiff_file = tifffile.TiffFile(str(gc_file.path.absolute()))
+    gc_file = _extract_tags(gc_file=gc_file, pages=tiff_file.pages)
+    return tiff_file, gc_file
+
+
+def _load_with_open_slide(*, gc_file: GrandChallengeTiffFile, pk: UUID):
+    open_slide_file = openslide.open_slide(str(gc_file.path.absolute()))
+    gc_file = _extract_openslide_properties(
+        gc_file=gc_file, image=open_slide_file
+    )
+    gc_file.validate()
+    dzi_output = _create_dzi_images(gc_file=gc_file, pk=pk)
+    return dzi_output, gc_file
+
+
+def _add_image_files(
+    *, tiff_file, gc_file, dzi_output, image, new_image_files
+):
+    if tiff_file:
+        new_image_files.append(
+            _create_image_file(path=str(gc_file.path.absolute()), image=image)
+        )
+
+    if dzi_output:
+        new_image_files.append(
+            _create_image_file(path=dzi_output + ".dzi", image=image)
+        )
+    return new_image_files
+
+
+def _add_folder_uploads(*, dzi_output, image, new_folder_upload):
+    if dzi_output:
+        dzi_folder_upload = FolderUpload(
+            folder=dzi_output + "_files", image=image
+        )
+        new_folder_upload.append(dzi_folder_upload)
+    return new_folder_upload
+
+
 def image_builder_tiff(path: Path, session_id=None) -> ImageBuilderResult:
     new_images = []
     new_image_files = []
@@ -201,40 +224,46 @@ def image_builder_tiff(path: Path, session_id=None) -> ImageBuilderResult:
     for file_path in path.iterdir():
         pk = uuid4()
         dzi_output = None
+        tiff_file = None
+        gc_file = GrandChallengeTiffFile(file_path)
+
+        # try and load image with tiff file
         try:
-            gc_file = load_tiff_file(file_path)
-        except ValidationError as e:
-            invalid_file_errors[file_path.name] = e.message  # noqa: B306
-            continue
+            tiff_file, gc_file = _load_with_tiff(gc_file=gc_file)
+        except Exception as e:
+            invalid_file_errors[file_path.name] = e  # noqa: B306
 
+        # try and load image with open_slide
         try:
-            dzi_output, gc_file = _create_dzi_images(gc_file=gc_file, pk=pk)
-        except ValidationError as e:
-            invalid_file_errors[file_path.name] = e.message  # noqa: B306
+            dzi_output, gc_file = _load_with_open_slide(gc_file=gc_file, pk=pk)
+        except Exception as e:
+            invalid_file_errors[file_path.name] = e  # noqa: B306
 
-        # make sure all requirements are met
+        # validate
         try:
             gc_file.validate()
+            if not tiff_file and not dzi_output:
+                raise ValidationError(
+                    "File could not be opened by either TIFFILE or OpenSlide"
+                )
         except ValidationError as e:
             invalid_file_errors[file_path.name] = e.message  # noqa: B306
             continue
 
         image = _create_tiff_image_entry(tiff_file=gc_file, pk=pk)
-
-        new_image_files.append(
-            _create_image_file(path=str(gc_file.path.absolute()), image=image)
+        new_image_files = _add_image_files(
+            tiff_file=tiff_file,
+            gc_file=gc_file,
+            dzi_output=dzi_output,
+            image=image,
+            new_image_files=new_image_files,
         )
 
-        if dzi_output:
-            new_image_files.append(
-                _create_image_file(path=dzi_output + ".dzi", image=image)
-            )
-
-            dzi_folder_upload = FolderUpload(
-                folder=dzi_output + "_files", image=image
-            )
-            new_folder_upload.append(dzi_folder_upload)
-
+        new_folder_upload = _add_folder_uploads(
+            dzi_output=dzi_output,
+            image=image,
+            new_folder_upload=new_folder_upload,
+        )
         new_images.append(image)
         consumed_files.add(gc_file.path.name)
 
@@ -268,27 +297,17 @@ def _create_tiff_image_entry(
     )
 
 
-def _create_dzi_images(
-    *, gc_file: GrandChallengeTiffFile, pk: UUID
-) -> (str, GrandChallengeTiffFile):
+def _create_dzi_images(*, gc_file: GrandChallengeTiffFile, pk: UUID) -> str:
     # Creates a dzi file(out.dzi) and corresponding tiles in folder {pk}_files
     dzi_output = str(gc_file.path.parent / str(pk))
     try:
         image = pyvips.Image.new_from_file(
             str(gc_file.path.absolute()), access="sequential"
         )
-        if not gc_file.voxel_width_mm:
-            gc_file.voxel_width_mm = (
-                float(image.get("openslide.mpp-x")) / 1000,
-            )
-            gc_file.voxel_height_mm = (
-                float(image.get("openslide.mpp-y")) / 1000,
-            )
-
         pyvips.Image.dzsave(
             image, dzi_output, tile_size=settings.DZI_TILE_SIZE
         )
     except Exception as e:
-        raise ValidationError("Image can't be converted to dzi: " + str(e))
+        raise ValidationError(f"Image can't be converted to dzi: {e}")
 
-    return dzi_output, gc_file
+    return dzi_output


Test patch:
diff --git a/app/tests/cases_tests/resources/dzi_tiff/no_image b/app/tests/cases_tests/resources/dzi_tiff/no_image
new file mode 100644
--- /dev/null
+++ b/app/tests/cases_tests/resources/dzi_tiff/no_image
@@ -0,0 +1 @@
+just_some_text
diff --git a/app/tests/cases_tests/resources/dzi_tiff/valid_tiff.tif b/app/tests/cases_tests/resources/dzi_tiff/valid_tiff.tif
new file mode 100644
Binary files /dev/null and b/app/tests/cases_tests/resources/dzi_tiff/valid_tiff.tif differ
diff --git a/app/tests/cases_tests/resources/no_dzi.tif b/app/tests/cases_tests/resources/no_dzi.tif
new file mode 100644
Binary files /dev/null and b/app/tests/cases_tests/resources/no_dzi.tif 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
@@ -95,10 +95,14 @@ def test_image_file_creation(settings):
         "invalid_utf8.mhd",
         "no_image",
         "valid_tiff.tif",
-        "invalid_tiles_tiff.tif",
+        "invalid_resolutions_tiff.tif",
     ]
 
-    invalid_images = ("no_image", "invalid_utf8.mhd", "invalid_tiles_tiff.tif")
+    invalid_images = (
+        "no_image",
+        "invalid_utf8.mhd",
+        "invalid_resolutions_tiff.tif",
+    )
     session, uploaded_images = create_raw_upload_image_session(images)
 
     session.refresh_from_db()
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
@@ -7,13 +7,17 @@
 import tifffile as tiff_lib
 from django.core.exceptions import ValidationError
 from pytest import approx
+from tifffile import tifffile
 
 from grandchallenge.cases.image_builders.tiff import (
+    GrandChallengeTiffFile,
     _create_dzi_images,
     _create_tiff_image_entry,
+    _extract_tags,
     _get_color_space,
+    _load_with_open_slide,
+    _load_with_tiff,
     image_builder_tiff,
-    load_tiff_file,
 )
 from grandchallenge.cases.models import Image
 from tests.cases_tests import RESOURCE_PATH
@@ -43,25 +47,83 @@ def test_get_color_space(color_space_string, expected):
 
 
 @pytest.mark.parametrize(
-    "resource, expected_error_message",
+    "path, color_space, resolution_levels, image_height, image_width, voxel_height_mm, voxel_width_mm, expected_error_message",
     [
-        (RESOURCE_PATH / "valid_tiff.tif", ""),
-        (RESOURCE_PATH / "image5x6x7.mhd", "Image isn't a TIFF file"),
+        ("dummy.tiff", 1, 1, 10, 10, 0.1, 0.1, ""),
+        ("dummy.tiff", None, 1, 10, 10, 0.1, 0.1, "ColorSpace not valid"),
         (
-            RESOURCE_PATH / "invalid_resolutions_tiff.tif",
-            "Image only has a single resolution level",
+            "dummy.tiff",
+            1,
+            None,
+            10,
+            10,
+            0.1,
+            0.1,
+            "Resolution levels not valid",
         ),
         (
-            RESOURCE_PATH / "invalid_tiles_tiff.tif",
-            "Tiff file is missing required tag TileOffsets",
+            "dummy.tiff",
+            1,
+            1,
+            None,
+            10,
+            0.1,
+            0.1,
+            "ImageHeigth could not be determined",
+        ),
+        (
+            "dummy.tiff",
+            1,
+            1,
+            10,
+            None,
+            0.1,
+            0.1,
+            "ImageWidth could not be determined",
+        ),
+        (
+            "dummy.tiff",
+            1,
+            1,
+            10,
+            10,
+            None,
+            0.1,
+            "Voxel height could not be determined",
+        ),
+        (
+            "dummy.tiff",
+            1,
+            1,
+            10,
+            10,
+            0.1,
+            None,
+            "Voxel width could not be determined",
         ),
     ],
 )
-def test_tiff_validation(resource, expected_error_message):
+def test_grandchallengetifffile_validation(
+    path,
+    color_space,
+    resolution_levels,
+    image_height,
+    image_width,
+    voxel_height_mm,
+    voxel_width_mm,
+    expected_error_message,
+):
     error_message = ""
 
     try:
-        load_tiff_file(resource)
+        gc_file = GrandChallengeTiffFile(path)
+        gc_file.color_space = color_space
+        gc_file.resolution_levels = resolution_levels
+        gc_file.image_height = image_height
+        gc_file.image_width = image_width
+        gc_file.voxel_height_mm = voxel_height_mm
+        gc_file.voxel_width_mm = voxel_width_mm
+        gc_file.validate()
     except ValidationError as e:
         error_message = str(e)
 
@@ -74,32 +136,55 @@ def test_tiff_validation(resource, expected_error_message):
     "source_dir, filename, expected_error_message",
     [
         (RESOURCE_PATH, "valid_tiff.tif", ""),
-        (RESOURCE_PATH, "image5x6x7.mhd", "Image isn't a TIFF file"),
         (
             RESOURCE_PATH,
             "invalid_resolutions_tiff.tif",
-            "Image only has a single resolution level",
+            "Invalid resolution unit RESUNIT.NONE in tiff file",
         ),
+    ],
+)
+def test_load_with_tiff(
+    source_dir, filename, expected_error_message, tmpdir_factory
+):
+    error_message = ""
+    # Copy resource file to writable temp folder
+    temp_file = Path(tmpdir_factory.mktemp("temp") / filename)
+    shutil.copy(source_dir / filename, temp_file)
+    gc_file = GrandChallengeTiffFile(temp_file)
+    try:
+        _load_with_tiff(gc_file=gc_file)
+    except ValidationError as e:
+        error_message = str(e)
+
+    assert expected_error_message in error_message
+    if not expected_error_message:
+        assert not error_message
+
+
+@pytest.mark.parametrize(
+    "source_dir, filename, expected_error_message",
+    [
+        (RESOURCE_PATH, "valid_tiff.tif", ""),
         (
             RESOURCE_PATH,
-            "invalid_tiles_tiff.tif",
-            "Tiff file is missing required tag TileOffsets",
+            "no_dzi.tif",
+            "Image can't be converted to dzi: unable to call dzsave",
         ),
     ],
 )
-def test_dzi_creation(
+def test_load_with_open_slide(
     source_dir, filename, expected_error_message, tmpdir_factory
 ):
     error_message = ""
     # Copy resource file to writable temp folder
     temp_file = Path(tmpdir_factory.mktemp("temp") / filename)
     shutil.copy(source_dir / filename, temp_file)
-
+    gc_file = GrandChallengeTiffFile(temp_file)
+    pk = uuid4()
     try:
-        # Load the tiff file and create dzi
-        tiff_file = load_tiff_file(temp_file)
-        _create_dzi_images(gc_file=tiff_file, pk=uuid4())
-    except ValidationError as e:
+        _, gc_file = _load_with_tiff(gc_file=gc_file)
+        _load_with_open_slide(gc_file=gc_file, pk=pk)
+    except Exception as e:
         error_message = str(e)
 
     assert expected_error_message in error_message
@@ -107,27 +192,52 @@ def test_dzi_creation(
         assert not error_message
 
 
-@pytest.mark.django_db
 @pytest.mark.parametrize(
-    "resource, expected_error_message, voxel_size",
+    "source_dir, filename, expected_error_message",
     [
-        (RESOURCE_PATH / "valid_tiff.tif", "", [1, 1, None],),
+        (RESOURCE_PATH, "valid_tiff.tif", ""),
         (
-            RESOURCE_PATH / "image5x6x7.mhd",
-            "Image isn't a TIFF file",
-            [0, 0, 0],
+            RESOURCE_PATH,
+            "no_dzi.tif",
+            "Image can't be converted to dzi: unable to call dzsave",
         ),
     ],
 )
+def test_dzi_creation(
+    source_dir, filename, expected_error_message, tmpdir_factory
+):
+    error_message = ""
+    # Copy resource file to writable temp folder
+    temp_file = Path(tmpdir_factory.mktemp("temp") / filename)
+    shutil.copy(source_dir / filename, temp_file)
+    gc_file = GrandChallengeTiffFile(temp_file)
+    pk = uuid4()
+    try:
+        _create_dzi_images(gc_file=gc_file, pk=pk)
+    except ValidationError as e:
+        error_message = str(e)
+
+    assert expected_error_message in error_message
+    if not expected_error_message:
+        assert not error_message
+
+
+@pytest.mark.django_db
+@pytest.mark.parametrize(
+    "resource, expected_error_message, voxel_size",
+    [(RESOURCE_PATH / "valid_tiff.tif", "", [1, 1, None])],
+)
 def test_tiff_image_entry_creation(
     resource, expected_error_message, voxel_size
 ):
     error_message = ""
     image_entry = None
     pk = uuid4()
+    gc_file = GrandChallengeTiffFile(resource)
     try:
-        tiff_file = load_tiff_file(resource)
-        image_entry = _create_tiff_image_entry(tiff_file=tiff_file, pk=pk)
+        tiff_file = tifffile.TiffFile(str(gc_file.path.absolute()))
+        gc_file = _extract_tags(gc_file=gc_file, pages=tiff_file.pages)
+        image_entry = _create_tiff_image_entry(tiff_file=gc_file, pk=pk)
     except ValidationError as e:
         error_message = str(e)
 
@@ -159,23 +269,32 @@ def test_tiff_image_entry_creation(
 
 # Integration test of all features being accessed through the image builder
 @pytest.mark.django_db
-def test_image_builder_tiff(tmpdir_factory):
+@pytest.mark.parametrize(
+    "resource, expected_files, expected_image_files, expected_dzi_files",
+    [(RESOURCE_PATH, 4, 7, 31)],
+)
+def test_image_builder_tiff(
+    resource,
+    expected_files,
+    expected_image_files,
+    expected_dzi_files,
+    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, temp_dir, ignore=shutil.ignore_patterns("dicom*"),
     )
 
     image_builder_result = image_builder_tiff(path=temp_dir)
 
-    # Assumes the RESOURCE_PATH folder only contains a single correct TIFF file
-    assert len(image_builder_result.consumed_files) == 1
-    assert len(image_builder_result.new_images) == 1
-    assert len(image_builder_result.new_image_files) == 2
+    assert len(image_builder_result.consumed_files) == expected_files
+    assert len(image_builder_result.new_images) == expected_files
+    assert len(image_builder_result.new_image_files) == expected_image_files
 
     # Asserts successful creation of files
     new_image_pk = image_builder_result.new_images[0].pk
     assert os.path.isfile(temp_dir / f"{new_image_pk}.dzi")
     assert os.path.isdir(temp_dir / f"{new_image_pk}_files")
 
-    assert len(list(temp_dir.glob("**/*.jpeg"))) == 9
+    assert len(list(temp_dir.glob("**/*.jpeg"))) == expected_dzi_files