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

Issue:
Add support for .e2e and .fds
See https://github.com/comic/grand-challenge.org/issues/1689


Discussion / hints:

Fix patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index f542649..18b6262 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -24,6 +24,7 @@ repos:
           - flake8-docstrings
           - mccabe
           - yesqa
+        exclude: ^panimg/contrib/oct_converter/
   - repo: https://github.com/pre-commit/mirrors-mypy
     rev: 'v0.761'
     hooks:
diff --git a/README.md b/README.md
index 4b42cbd..cf25a01 100644
--- a/README.md
+++ b/README.md
@@ -19,6 +19,7 @@ Under the hood we use:
 * `Pillow`
 * `openslide-python`
 * `pyvips`
+* `oct-converter`
 
 ## Usage
 
@@ -60,8 +61,10 @@ To access the help test you can use `panimg -h`.
 | `.dcm`                              | `.mha`  | `dicom`    | <sup>[1](#footnote1)</sup> |
 | `.nii`                              | `.mha`  | `nifti`    |                            |
 | `.nii.gz`                           | `.mha`  | `nifti`    |                            |
-| `.png`                              | `.mha`  | `fallback` | <sup>[2](#footnote2)</sup> |
-| `.jpeg`                             | `.mha`  | `fallback` | <sup>[2](#footnote2)</sup> |
+| `.fds`                              | `.mha`  | `oct`      | <sup>[2](#footnote2)</sup> |
+| `.fda`                              | `.mha`  | `oct`      | <sup>[2](#footnote2)</sup> |
+| `.png`                              | `.mha`  | `fallback` | <sup>[3](#footnote3)</sup> |
+| `.jpeg`                             | `.mha`  | `fallback` | <sup>[3](#footnote3)</sup> |
 | `.tiff`                             | `.tiff` | `tiff`     |                            |
 | `.svs` (Aperio)                     | `.tiff` | `tiff`     |                            |
 | `.vms`, `.vmu`, `.ndpi` (Hamamatsu) | `.tiff` | `tiff`     |                            |
@@ -71,7 +74,9 @@ To access the help test you can use `panimg -h`.
 
 <a name="footnote1">1</a>: Compressed DICOM requires `gdcm`
 
-<a name="footnote2">2</a>: 2D only, unitary dimensions
+<a name="footnote2">2</a>: Only OCT volume(s), no fundus image(s) will be extracted.
+
+<a name="footnote3">3</a>: 2D only, unitary dimensions
 
 #### Post Processors
 
diff --git a/panimg/contrib/__init__.py b/panimg/contrib/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/panimg/contrib/oct_converter/LICENSE b/panimg/contrib/oct_converter/LICENSE
new file mode 100644
index 0000000..a4f1480
--- /dev/null
+++ b/panimg/contrib/oct_converter/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2021  Mark Graham
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/panimg/contrib/oct_converter/__init__.py b/panimg/contrib/oct_converter/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/panimg/contrib/oct_converter/image_types/__init__.py b/panimg/contrib/oct_converter/image_types/__init__.py
new file mode 100644
index 0000000..dee77d5
--- /dev/null
+++ b/panimg/contrib/oct_converter/image_types/__init__.py
@@ -0,0 +1,4 @@
+from .oct import OCTVolumeWithMetaData
+from .fundus import FundusImageWithMetaData
+
+__all__ = ["OCTVolumeWithMetaData", "FundusImageWithMetaData"]
diff --git a/panimg/contrib/oct_converter/image_types/fundus.py b/panimg/contrib/oct_converter/image_types/fundus.py
new file mode 100644
index 0000000..59e5c30
--- /dev/null
+++ b/panimg/contrib/oct_converter/image_types/fundus.py
@@ -0,0 +1,17 @@
+class FundusImageWithMetaData:
+    """ Class to hold the fundus image and any related metadata, and enable saving.
+
+    Attributes:
+        image (np.array): Fundus image.
+        laterality (str): Left or right eye.
+        patient_id (str): Patient ID.
+        DOB (str): Patient date of birth.
+    """
+
+    def __init__(
+        self, image, laterality=None, patient_id=None, patient_dob=None
+    ):
+        self.image = image
+        self.laterality = laterality
+        self.patient_id = patient_id
+        self.DOB = patient_dob
diff --git a/panimg/contrib/oct_converter/image_types/oct.py b/panimg/contrib/oct_converter/image_types/oct.py
new file mode 100644
index 0000000..cad34e0
--- /dev/null
+++ b/panimg/contrib/oct_converter/image_types/oct.py
@@ -0,0 +1,19 @@
+class OCTVolumeWithMetaData:
+    """ Class to hold the OCT volume and any related metadata, and enable viewing and saving.
+
+    Attributes:
+        volume (list of np.array): All the volume's b-scans.
+        laterality (str): Left or right eye.
+        patient_id (str): Patient ID.
+        DOB (str): Patient date of birth.
+        num_slices: Number of b-scans present in volume.
+    """
+
+    def __init__(
+        self, volume, laterality=None, patient_id=None, patient_dob=None
+    ):
+        self.volume = volume
+        self.laterality = laterality
+        self.patient_id = patient_id
+        self.DOB = patient_dob
+        self.num_slices = len(self.volume)
diff --git a/panimg/contrib/oct_converter/readers/__init__.py b/panimg/contrib/oct_converter/readers/__init__.py
new file mode 100644
index 0000000..1459e5e
--- /dev/null
+++ b/panimg/contrib/oct_converter/readers/__init__.py
@@ -0,0 +1,5 @@
+from .fds import FDS
+from .e2e import E2E
+from .fda import FDA
+
+__all__ = ["FDS", "E2E", "FDA"]
diff --git a/panimg/contrib/oct_converter/readers/fda.py b/panimg/contrib/oct_converter/readers/fda.py
new file mode 100644
index 0000000..87b2ee8
--- /dev/null
+++ b/panimg/contrib/oct_converter/readers/fda.py
@@ -0,0 +1,144 @@
+from construct import PaddedString, Struct, Int32un
+import numpy as np
+from panimg.contrib.oct_converter.image_types import (
+    OCTVolumeWithMetaData,
+    FundusImageWithMetaData,
+)
+from pylibjpeg import decode
+from pathlib import Path
+
+
+class FDA:
+    """ Class for extracting data from Topcon's .fda file format.
+
+        Notes:
+            Mostly based on description of .fda file format here:
+            https://bitbucket.org/uocte/uocte/wiki/Topcon%20File%20Format
+
+        Attributes:
+            filepath (str): Path to .img file for reading.
+            header (obj:Struct): Defines structure of volume's header.
+            oct_header (obj:Struct): Defines structure of OCT header.
+            fundus_header (obj:Struct): Defines structure of fundus header.
+            chunk_dict (dict): Name of data chunks present in the file, and their start locations.
+    """
+
+    def __init__(self, filepath):
+        self.filepath = Path(filepath)
+        if not self.filepath.exists():
+            raise FileNotFoundError(self.filepath)
+        self.header = Struct(
+            "FOCT" / PaddedString(4, "ascii"),
+            "FDA" / PaddedString(3, "ascii"),
+            "version_info_1" / Int32un,
+            "version_info_2" / Int32un,
+        )
+        self.oct_header = Struct(
+            "type" / PaddedString(1, "ascii"),
+            "unknown1" / Int32un,
+            "unknown2" / Int32un,
+            "width" / Int32un,
+            "height" / Int32un,
+            "number_slices" / Int32un,
+            "unknown3" / Int32un,
+        )
+
+        self.oct_header_2 = Struct(
+            "unknown" / PaddedString(1, "ascii"),
+            "width" / Int32un,
+            "height" / Int32un,
+            "bits_per_pixel" / Int32un,
+            "number_slices" / Int32un,
+            "unknown" / PaddedString(1, "ascii"),
+            "size" / Int32un,
+        )
+
+        self.fundus_header = Struct(
+            "width" / Int32un,
+            "height" / Int32un,
+            "bits_per_pixel" / Int32un,
+            "number_slices" / Int32un,
+            "unknown" / PaddedString(4, "ascii"),
+            "size" / Int32un,
+            # 'img' / Int8un,
+        )
+
+        self.chunk_dict = self.get_list_of_file_chunks()
+
+    def get_list_of_file_chunks(self):
+        """Find all data chunks present in the file.
+
+        Returns:
+            dict
+        """
+        chunk_dict = {}
+        with open(self.filepath, "rb") as f:
+            # skip header
+            raw = f.read(15)
+            header = self.header.parse(raw)
+
+            eof = False
+            while not eof:
+                chunk_name_size = np.frombuffer(f.read(1), dtype=np.uint8)[0]
+                if chunk_name_size == 0:
+                    eof = True
+                else:
+                    chunk_name = f.read(chunk_name_size)
+                    chunk_size = np.frombuffer(f.read(4), dtype=np.uint32)[0]
+                    chunk_location = f.tell()
+                    f.seek(chunk_size, 1)
+                    chunk_dict[chunk_name] = [chunk_location, chunk_size]
+        print(f"File {self.filepath} contains the following chunks:")
+        for key in chunk_dict.keys():
+            print(key)
+        return chunk_dict
+
+    def read_oct_volume(self):
+        """ Reads OCT data.
+
+            Returns:
+                obj:OCTVolumeWithMetaData
+        """
+
+        if b"@IMG_JPEG" not in self.chunk_dict:
+            raise ValueError(
+                "Could not find OCT header @IMG_JPEG in chunk list"
+            )
+        with open(self.filepath, "rb") as f:
+            chunk_location, chunk_size = self.chunk_dict[b"@IMG_JPEG"]
+            f.seek(chunk_location)  # Set the chunk’s current position.
+            raw = f.read(25)
+            oct_header = self.oct_header.parse(raw)
+            volume = np.zeros(
+                (oct_header.height, oct_header.width, oct_header.number_slices)
+            )
+            for i in range(oct_header.number_slices):
+                size = np.frombuffer(f.read(4), dtype=np.int32)[0]
+                raw_slice = f.read(size)
+                slice = decode(raw_slice)
+                volume[:, :, i] = slice
+        oct_volume = OCTVolumeWithMetaData(
+            [volume[:, :, i] for i in range(volume.shape[2])]
+        )
+        return oct_volume
+
+    def read_fundus_image(self):
+        """ Reads fundus image.
+
+            Returns:
+                obj:FundusImageWithMetaData
+        """
+        if b"@IMG_FUNDUS" not in self.chunk_dict:
+            raise ValueError(
+                "Could not find fundus header @IMG_FUNDUS in chunk list"
+            )
+        with open(self.filepath, "rb") as f:
+            chunk_location, chunk_size = self.chunk_dict[b"@IMG_FUNDUS"]
+            f.seek(chunk_location)  # Set the chunk’s current position.
+            raw = f.read(24)  # skip 24 is important
+            fundus_header = self.fundus_header.parse(raw)
+            number_pixels = fundus_header.width * fundus_header.height * 3
+            raw_image = f.read(fundus_header.size)
+            image = decode(raw_image)
+        fundus_image = FundusImageWithMetaData(image)
+        return fundus_image
diff --git a/panimg/contrib/oct_converter/readers/fds.py b/panimg/contrib/oct_converter/readers/fds.py
new file mode 100644
index 0000000..06fa5bc
--- /dev/null
+++ b/panimg/contrib/oct_converter/readers/fds.py
@@ -0,0 +1,144 @@
+from construct import PaddedString, Struct, Int32un
+import numpy as np
+from panimg.contrib.oct_converter.image_types import (
+    OCTVolumeWithMetaData,
+    FundusImageWithMetaData,
+)
+from pathlib import Path
+
+
+class FDS:
+    """ Class for extracting data from Topcon's .fds file format.
+
+        Notes:
+            Mostly based on description of .fds file format here:
+            https://bitbucket.org/uocte/uocte/wiki/Topcon%20File%20Format
+
+        Attributes:
+            filepath (str): Path to .img file for reading.
+            header (obj:Struct): Defines structure of volume's header.
+            oct_header (obj:Struct): Defines structure of OCT header.
+            fundus_header (obj:Struct): Defines structure of fundus header.
+            chunk_dict (dict): Name of data chunks present in the file, and their start locations.
+    """
+
+    def __init__(self, filepath):
+        self.filepath = Path(filepath)
+        if not self.filepath.exists():
+            raise FileNotFoundError(self.filepath)
+
+        self.header = Struct(
+            "FOCT" / PaddedString(4, "ascii"),
+            "FDA" / PaddedString(3, "ascii"),
+            "version_info_1" / Int32un,
+            "version_info_2" / Int32un,
+        )
+        self.oct_header = Struct(
+            "unknown" / PaddedString(1, "ascii"),
+            "width" / Int32un,
+            "height" / Int32un,
+            "bits_per_pixel" / Int32un,
+            "number_slices" / Int32un,
+            "unknown" / PaddedString(1, "ascii"),
+            "size" / Int32un,
+        )
+        self.fundus_header = Struct(
+            "width" / Int32un,
+            "height" / Int32un,
+            "bits_per_pixel" / Int32un,
+            "number_slices" / Int32un,
+            "unknown" / PaddedString(1, "ascii"),
+            "size" / Int32un,
+        )
+        self.chunk_dict = self.get_list_of_file_chunks()
+
+    def get_list_of_file_chunks(self):
+        """Find all data chunks present in the file.
+
+        Returns:
+            dict
+        """
+        chunk_dict = {}
+        with open(self.filepath, "rb") as f:
+            # skip header
+            raw = f.read(15)
+            header = self.header.parse(raw)
+
+            eof = False
+            while not eof:
+                chunk_name_size = np.frombuffer(f.read(1), dtype=np.uint8)[0]
+                if chunk_name_size == 0:
+                    eof = True
+                else:
+                    chunk_name = f.read(chunk_name_size)
+                    chunk_size = np.frombuffer(f.read(4), dtype=np.uint32)[0]
+                    chunk_location = f.tell()
+                    f.seek(chunk_size, 1)
+                    chunk_dict[chunk_name] = [chunk_location, chunk_size]
+        print(f"File {self.filepath} contains the following chunks:")
+        for key in chunk_dict.keys():
+            print(key)
+        return chunk_dict
+
+    def read_oct_volume(self):
+        """ Reads OCT data.
+
+            Returns:
+                obj:OCTVolumeWithMetaData
+        """
+        if b"@IMG_SCAN_03" not in self.chunk_dict:
+            raise ValueError(
+                "Could not find OCT header @IMG_SCAN_03 in chunk list"
+            )
+        with open(self.filepath, "rb") as f:
+            chunk_location, chunk_size = self.chunk_dict[b"@IMG_SCAN_03"]
+            f.seek(chunk_location)
+            raw = f.read(22)
+            oct_header = self.oct_header.parse(raw)
+            number_pixels = (
+                oct_header.width * oct_header.height * oct_header.number_slices
+            )
+            raw_volume = np.frombuffer(
+                f.read(number_pixels * 2), dtype=np.uint16
+            )
+            volume = np.array(raw_volume)
+            volume = volume.reshape(
+                oct_header.width,
+                oct_header.height,
+                oct_header.number_slices,
+                order="F",
+            )
+            volume = np.transpose(volume, [1, 0, 2])
+        oct_volume = OCTVolumeWithMetaData(
+            [volume[:, :, i] for i in range(volume.shape[2])]
+        )
+        return oct_volume
+
+    def read_fundus_image(self):
+        """ Reads fundus image.
+
+            Returns:
+                obj:FundusImageWithMetaData
+        """
+        if b"@IMG_OBS" not in self.chunk_dict:
+            raise ValueError(
+                "Could not find fundus header @IMG_OBS in chunk list"
+            )
+        with open(self.filepath, "rb") as f:
+            chunk_location, chunk_size = self.chunk_dict[b"@IMG_OBS"]
+            f.seek(chunk_location)
+            raw = f.read(21)
+            fundus_header = self.fundus_header.parse(raw)
+            # number_pixels = fundus_header.width * fundus_header.height * fundus_header.number_slices
+            raw_image = np.frombuffer(
+                f.read(fundus_header.size), dtype=np.uint8
+            )
+            # raw_image = [struct.unpack('B', f.read(1)) for pixel in range(fundus_header.size)]
+            image = np.array(raw_image)
+            image = image.reshape(
+                3, fundus_header.width, fundus_header.height, order="F"
+            )
+            image = np.transpose(image, [2, 1, 0])
+            image = image.astype(np.float32)
+        fundus_image = FundusImageWithMetaData(image)
+        return fundus_image
diff --git a/panimg/image_builders/__init__.py b/panimg/image_builders/__init__.py
index a1ae883..a6c2eb5 100644
--- a/panimg/image_builders/__init__.py
+++ b/panimg/image_builders/__init__.py
@@ -4,6 +4,7 @@
 from panimg.image_builders.fallback import image_builder_fallback
 from panimg.image_builders.metaio_mhd_mha import image_builder_mhd
 from panimg.image_builders.nifti import image_builder_nifti
+from panimg.image_builders.oct import image_builder_oct
 from panimg.image_builders.tiff import image_builder_tiff
 from panimg.types import ImageBuilder
 
@@ -12,6 +13,7 @@
     image_builder_nifti,
     image_builder_dicom,
     image_builder_tiff,
+    image_builder_oct,
     image_builder_fallback,
 ]
 
@@ -20,6 +22,7 @@
     "image_builder_nifti",
     "image_builder_dicom",
     "image_builder_tiff",
+    "image_builder_oct",
     "image_builder_fallback",
     "DEFAULT_IMAGE_BUILDERS",
 ]
diff --git a/panimg/image_builders/dicom.py b/panimg/image_builders/dicom.py
index 8e0ba2c..da8b9ab 100644
--- a/panimg/image_builders/dicom.py
+++ b/panimg/image_builders/dicom.py
@@ -364,7 +364,7 @@ def image_builder_dicom(*, files: Set[Path]) -> Iterator[SimpleITKImage]:
     if not gdcm_is_available():
         logger.warning(
             "GDCM is unavailable, conversion of DICOM with compressed "
-            "transfer syntax will fail to convert. To correct this, install"
+            "transfer syntax will fail to convert. To correct this, install "
             "GDCM."
         )
 
diff --git a/panimg/image_builders/oct.py b/panimg/image_builders/oct.py
new file mode 100644
index 0000000..58a3434
--- /dev/null
+++ b/panimg/image_builders/oct.py
@@ -0,0 +1,224 @@
+from collections import defaultdict
+from pathlib import Path
+from typing import DefaultDict, Iterable, Iterator, List, Set, Tuple, Union
+
+import SimpleITK
+import numpy as np
+from construct.core import (
+    Float64l,
+    Int8ul,
+    PaddedString,
+    StreamError,
+    Struct,
+)
+from pydantic import BaseModel
+
+from panimg.contrib.oct_converter.image_types import (
+    FundusImageWithMetaData,
+    OCTVolumeWithMetaData,
+)
+from panimg.contrib.oct_converter.readers import E2E, FDA, FDS
+from panimg.exceptions import UnconsumedFilesException, ValidationError
+from panimg.models import EyeChoice, SimpleITKImage
+
+
+def format_error(message: str) -> str:
+    return f"OCT image builder: {message}"
+
+
+LATERALITY_TO_EYE_CHOICE = defaultdict(
+    lambda: EyeChoice.UNKNOWN,
+    {"L": EyeChoice.OCULUS_SINISTER, "R": EyeChoice.OCULUS_DEXTER},
+)
+
+OCT_CONVERTER_TYPE = Union[FDS, FDA, E2E]
+
+
+class OctDimensions(BaseModel):
+    extent_x_mm: float
+    resolution_y_mm: float
+    extent_z_mm: float
+
+
+def _create_itk_images(
+    *,
+    file: Path,
+    oct_volumes: Iterable[OCTVolumeWithMetaData],
+    fundus_images: Iterable[FundusImageWithMetaData],
+    oct_slice_size: OctDimensions,
+) -> Iterator[SimpleITKImage]:
+    for volume in oct_volumes:
+        eye_choice = LATERALITY_TO_EYE_CHOICE[volume.laterality]
+        yield _create_itk_oct_volume(
+            file=file,
+            volume=volume.volume,
+            oct_slice_size=oct_slice_size,
+            eye_choice=eye_choice,
+        )
+    for image in fundus_images:
+        eye_choice = LATERALITY_TO_EYE_CHOICE[image.laterality]
+
+        if file.suffix != ".e2e":
+            img_array = image.image.astype(np.uint8)
+            img_array = img_array[:, :, ::-1]
+            is_vector = True
+        else:
+            img_array = image.image
+            is_vector = False
+
+        yield _create_itk_fundus_image(
+            file=file,
+            image=img_array,
+            eye_choice=eye_choice,
+            is_vector=is_vector,
+        )
+
+
+def _create_itk_oct_volume(
+    *,
+    file: Path,
+    volume: List[np.ndarray],
+    oct_slice_size: OctDimensions,
+    eye_choice: EyeChoice,
+) -> SimpleITKImage:
+    img_array = np.array(volume)
+    img = SimpleITK.GetImageFromArray(img_array, isVector=False)
+    [st, _, sl] = img_array.shape
+    img.SetSpacing(
+        [
+            oct_slice_size.extent_x_mm / sl,
+            oct_slice_size.resolution_y_mm,
+            oct_slice_size.extent_z_mm / st,
+        ]
+    )
+    return SimpleITKImage(
+        image=img,
+        name=file.name,
+        consumed_files={file},
+        spacing_valid=True,
+        eye_choice=eye_choice,
+    )
+
+
+def _create_itk_fundus_image(
+    *, file: Path, image: np.ndarray, eye_choice: EyeChoice, is_vector: bool
+) -> SimpleITKImage:
+    img = SimpleITK.GetImageFromArray(image, isVector=is_vector)
+    return SimpleITKImage(
+        image=img,
+        name=file.stem + "_fundus" + file.suffix,
+        consumed_files={file},
+        spacing_valid=False,
+        eye_choice=eye_choice,
+    )
+
+
+def _extract_slice_size(*, img: Union[FDS, FDA]) -> OctDimensions:
+    slice_size_meta_data = Struct(
+        "unknown" / PaddedString(12, "utf16"),
+        "xmm" / Float64l,
+        "zmm" / Float64l,
+        "y" / Float64l,
+        "unknown6" / Float64l,
+        "unknown7" / Float64l,
+        "unknown8" / Int8ul,
+    )
+    with open(img.filepath, "rb") as f:
+        chunk_location, chunk_size = img.chunk_dict[b"@PARAM_SCAN_04"]
+        f.seek(chunk_location)
+        raw = f.read(chunk_size)
+        spacing = slice_size_meta_data.parse(raw)
+        return OctDimensions(
+            extent_x_mm=spacing.xmm,
+            resolution_y_mm=spacing.y / 1000.0,
+            extent_z_mm=spacing.zmm,
+        )
+
+
+def _get_image(
+    *, file: Path
+) -> Tuple[
+    Iterable[OCTVolumeWithMetaData],
+    Iterable[FundusImageWithMetaData],
+    OctDimensions,
+]:
+    if file.suffix == ".fds":
+        fds_img = FDS(file)
+        oct_slice_size = _extract_slice_size(img=fds_img)
+        return (
+            [fds_img.read_oct_volume()],
+            [fds_img.read_fundus_image()],
+            oct_slice_size,
+        )
+    elif file.suffix == ".fda":
+        fda_img = FDA(file)
+        oct_slice_size = _extract_slice_size(img=fda_img)
+        return (
+            [fda_img.read_oct_volume()],
+            [fda_img.read_fundus_image()],
+            oct_slice_size,
+        )
+    elif file.suffix == ".e2e":
+        # support for E2E files is disabled until we have a proper E2E file to
+        # test with
+        raise ValueError
+        e2e_img = E2E(file)
+        # We were unable to retrieve slice size information from the e2e files.
+        # The following default values are taken from:
+        # https://bitbucket.org/uocte/uocte/wiki/Heidelberg%20File%20Format
+        oct_slice_size = OctDimensions(
+            extent_x_mm=6, resolution_y_mm=0.0039, extent_z_mm=4.5
+        )
+        # Note that the return types from oct_converter are different
+        # for e2e files
+        return (
+            e2e_img.read_oct_volume(),
+            e2e_img.read_fundus_image(),
+            oct_slice_size,
+        )
+    else:
+        raise ValueError
+
+
+def image_builder_oct(*, files: Set[Path]) -> Iterator[SimpleITKImage]:
+    """
+    Constructs OCT image objects by inspecting files in a directory by using
+    OCT-converter PyPI library.
+
+    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
+    """
+    file_errors: DefaultDict[Path, List[str]] = defaultdict(list)
+
+    for file in files:
+        try:
+            oct_volumes, fundus_images, oct_slice_size = _get_image(file=file)
+
+            yield from _create_itk_images(
+                file=file,
+                oct_volumes=oct_volumes,
+                # Skip fundus_images until we decide how to handle these
+                fundus_images=[],  # fundus_images,
+                oct_slice_size=oct_slice_size,
+            )
+
+        except (OSError, ValidationError, StreamError, ValueError, IndexError):
+            file_errors[file].append(
+                format_error(
+                    "Not a valid OCT file (supported formats: .fds,.fda,.e2e)"
+                )
+            )
+
+    if file_errors:
+        raise UnconsumedFilesException(file_errors=file_errors)
diff --git a/panimg/models.py b/panimg/models.py
index b445b37..eaf2b57 100644
--- a/panimg/models.py
+++ b/panimg/models.py
@@ -19,17 +19,24 @@ class ColorSpace(str, Enum):
     YCBCR = "YCBCR"
 
 
+ITK_COLOR_SPACE_MAP = {
+    1: ColorSpace.GRAY,
+    3: ColorSpace.RGB,
+    4: ColorSpace.RGBA,
+}
+
+
 class ImageType(str, Enum):
     MHD = "MHD"
     TIFF = "TIFF"
     DZI = "DZI"
 
 
-ITK_COLOR_SPACE_MAP = {
-    1: ColorSpace.GRAY,
-    3: ColorSpace.RGB,
-    4: ColorSpace.RGBA,
-}
+class EyeChoice(str, Enum):
+    OCULUS_DEXTER = "OD"
+    OCULUS_SINISTER = "OS"
+    UNKNOWN = "U"
+    NOT_APPLICABLE = "NA"
 
 
 @dataclass(frozen=True)
@@ -47,6 +54,7 @@ class PanImg:
     window_center: Optional[float]
     window_width: Optional[float]
     color_space: ColorSpace
+    eye_choice: EyeChoice
 
 
 @dataclass(frozen=True)
@@ -84,6 +92,7 @@ class SimpleITKImage(BaseModel):
     consumed_files: Set[Path]
 
     spacing_valid: bool
+    eye_choice: EyeChoice = EyeChoice.NOT_APPLICABLE
 
     class Config:
         arbitrary_types_allowed = True
@@ -182,6 +191,7 @@ def save(self, output_directory: Path) -> Tuple[PanImg, Set[PanImgFile]]:
             voxel_width_mm=self.voxel_width_mm,
             voxel_height_mm=self.voxel_height_mm,
             voxel_depth_mm=self.voxel_depth_mm,
+            eye_choice=self.eye_choice,
         )
 
         WriteImage(
@@ -212,6 +222,7 @@ class TIFFImage(BaseModel):
     voxel_height_mm: float
     resolution_levels: int
     color_space: ColorSpace
+    eye_choice: EyeChoice = EyeChoice.NOT_APPLICABLE
 
     class Config:
         allow_mutation = False
@@ -236,6 +247,7 @@ def save(self, output_directory: Path) -> Tuple[PanImg, Set[PanImgFile]]:
             timepoints=None,
             window_center=None,
             window_width=None,
+            eye_choice=self.eye_choice,
         )
 
         shutil.copy(src=self.file, dst=output_file)
diff --git a/poetry.lock b/poetry.lock
index 9114419..133bab4 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -24,17 +24,17 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
 
 [[package]]
 name = "attrs"
-version = "20.3.0"
+version = "21.2.0"
 description = "Classes Without Boilerplate"
 category = "dev"
 optional = false
-python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
 
 [package.extras]
-dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "furo", "sphinx", "pre-commit"]
-docs = ["furo", "sphinx", "zope.interface"]
-tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface"]
-tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six"]
+dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit"]
+docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"]
+tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface"]
+tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins"]
 
 [[package]]
 name = "cffi"
@@ -66,6 +66,17 @@ category = "main"
 optional = false
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
 
+[[package]]
+name = "construct"
+version = "2.10.67"
+description = "A powerful declarative symmetric parser/builder for binary data"
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[package.extras]
+extras = ["arrow", "cloudpickle", "enum34", "lz4", "numpy", "ruamel.yaml"]
+
 [[package]]
 name = "coverage"
 version = "5.5"
@@ -109,7 +120,7 @@ python-versions = "*"
 
 [[package]]
 name = "importlib-metadata"
-version = "3.7.2"
+version = "4.0.1"
 description = "Read metadata from Python packages"
 category = "dev"
 optional = false
@@ -121,7 +132,7 @@ zipp = ">=0.5"
 
 [package.extras]
 docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"]
-testing = ["pytest (>=3.5,!=3.7.3)", "pytest-checkdocs (>=1.2.3)", "pytest-flake8", "pytest-cov", "pytest-enabler", "packaging", "pep517", "pyfakefs", "flufl.flake8", "pytest-black (>=0.3.7)", "pytest-mypy", "importlib-resources (>=1.3)"]
+testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "packaging", "pep517", "pyfakefs", "flufl.flake8", "pytest-black (>=0.3.7)", "pytest-mypy", "importlib-resources (>=1.3)"]
 
 [[package]]
 name = "iniconfig"
@@ -133,7 +144,7 @@ python-versions = "*"
 
 [[package]]
 name = "numpy"
-version = "1.20.1"
+version = "1.20.3"
 description = "NumPy is the fundamental package for array computing with Python."
 category = "main"
 optional = false
@@ -163,7 +174,7 @@ pyparsing = ">=2.0.2"
 
 [[package]]
 name = "pillow"
-version = "8.1.2"
+version = "8.2.0"
 description = "Python Imaging Library (Fork)"
 category = "main"
 optional = false
@@ -209,7 +220,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
 
 [[package]]
 name = "pydantic"
-version = "1.8.1"
+version = "1.8.2"
 description = "Data validation and settings management using python 3.6 type hinting"
 category = "main"
 optional = false
@@ -230,6 +241,45 @@ category = "main"
 optional = false
 python-versions = ">=3.6.1"
 
+[[package]]
+name = "pylibjpeg"
+version = "1.3.0"
+description = "A Python framework for decoding JPEG and decoding/encoding DICOM RLE data, with a focus on supporting pydicom"
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+numpy = "*"
+pylibjpeg-openjpeg = "*"
+pylibjpeg-rle = "*"
+
+[[package]]
+name = "pylibjpeg-openjpeg"
+version = "1.1.1"
+description = "A Python wrapper for openjpeg, with a focus on use as a plugin for for pylibjpeg"
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+numpy = "*"
+
+[[package]]
+name = "pylibjpeg-rle"
+version = "1.1.0"
+description = "Python bindings for a fast RLE decoder/encoder, with a focus on use as a plugin for pylibjpeg"
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+numpy = "*"
+
+[package.extras]
+benchmarks = ["pydicom", "numpy", "asv"]
+tests = ["pytest", "pydicom", "numpy"]
+
 [[package]]
 name = "pyparsing"
 version = "2.4.7"
@@ -240,7 +290,7 @@ python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
 
 [[package]]
 name = "pytest"
-version = "6.2.2"
+version = "6.2.4"
 description = "pytest: simple powerful testing with Python"
 category = "dev"
 optional = false
@@ -289,14 +339,14 @@ pytest = ">=3.10"
 
 [[package]]
 name = "pytest-randomly"
-version = "3.5.0"
+version = "3.8.0"
 description = "Pytest plugin to randomly order tests and control random.seed."
 category = "dev"
 optional = false
-python-versions = ">=3.5"
+python-versions = ">=3.6"
 
 [package.dependencies]
-importlib-metadata = {version = "*", markers = "python_version < \"3.8\""}
+importlib-metadata = {version = ">=3.6.0", markers = "python_version < \"3.10\""}
 pytest = "*"
 
 [[package]]
@@ -342,7 +392,7 @@ python-versions = "*"
 
 [[package]]
 name = "six"
-version = "1.15.0"
+version = "1.16.0"
 description = "Python 2 and 3 compatibility utilities"
 category = "dev"
 optional = false
@@ -350,8 +400,8 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
 
 [[package]]
 name = "tifffile"
-version = "2021.3.5"
-description = "Read and write TIFF(r) files"
+version = "2021.4.8"
+description = "Read and write TIFF files"
 category = "main"
 optional = false
 python-versions = ">=3.7"
@@ -360,7 +410,7 @@ python-versions = ">=3.7"
 numpy = ">=1.15.1"
 
 [package.extras]
-all = ["imagecodecs (>=2021.1.11)", "matplotlib (>=3.2)", "lxml"]
+all = ["imagecodecs (>=2021.3.31)", "matplotlib (>=3.2)", "lxml"]
 
 [[package]]
 name = "toml"
@@ -372,7 +422,7 @@ python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
 
 [[package]]
 name = "tox"
-version = "3.23.0"
+version = "3.23.1"
 description = "tox is a generic virtualenv management and test command line tool"
 category = "dev"
 optional = false
@@ -395,7 +445,7 @@ testing = ["flaky (>=3.4.0)", "freezegun (>=0.3.11)", "psutil (>=5.6.1)", "pytes
 
 [[package]]
 name = "typing-extensions"
-version = "3.7.4.3"
+version = "3.10.0.0"
 description = "Backported and Experimental Type Hints for Python 3.5+"
 category = "main"
 optional = false
@@ -403,7 +453,7 @@ python-versions = "*"
 
 [[package]]
 name = "virtualenv"
-version = "20.4.2"
+version = "20.4.6"
 description = "Virtual Python Environment builder"
 category = "dev"
 optional = false
@@ -435,7 +485,7 @@ testing = ["pytest (>=4.6)", "pytest-checkdocs (>=1.2.3)", "pytest-flake8", "pyt
 [metadata]
 lock-version = "1.1"
 python-versions = "^3.7"
-content-hash = "cf4dc792608dc8e0522048030fb0e450118bbaa467552c3c5903f0cfb89f25c8"
+content-hash = "1e0f3081e6a12b213b2ee26905c38e774a8bc8ab92b3ff3b292556cc5077b459"
 
 [metadata.files]
 apipkg = [
@@ -451,8 +501,8 @@ atomicwrites = [
     {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"},
 ]
 attrs = [
-    {file = "attrs-20.3.0-py2.py3-none-any.whl", hash = "sha256:31b2eced602aa8423c2aea9c76a724617ed67cf9513173fd3a4f03e3a929c7e6"},
-    {file = "attrs-20.3.0.tar.gz", hash = "sha256:832aa3cde19744e49938b91fea06d69ecb9e649c93ba974535d08ad92164f700"},
+    {file = "attrs-21.2.0-py2.py3-none-any.whl", hash = "sha256:149e90d6d8ac20db7a955ad60cf0e6881a3f20d37096140088356da6c716b0b1"},
+    {file = "attrs-21.2.0.tar.gz", hash = "sha256:ef6aaac3ca6cd92904cdd0d83f629a15f18053ec84e6432106f7a4d04ae4f5fb"},
 ]
 cffi = [
     {file = "cffi-1.14.5-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:bb89f306e5da99f4d922728ddcd6f7fcebb3241fc40edebcb7284d7514741991"},
@@ -501,6 +551,9 @@ colorama = [
     {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"},
     {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"},
 ]
+construct = [
+    {file = "construct-2.10.67.tar.gz", hash = "sha256:730235fedf4f2fee5cfadda1d14b83ef1bf23790fb1cc579073e10f70a050883"},
+]
 coverage = [
     {file = "coverage-5.5-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:b6d534e4b2ab35c9f93f46229363e17f63c53ad01330df9f2d6bd1187e5eaacf"},
     {file = "coverage-5.5-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:b7895207b4c843c76a25ab8c1e866261bcfe27bfaa20c192de5190121770672b"},
@@ -568,38 +621,38 @@ filelock = [
     {file = "filelock-3.0.12.tar.gz", hash = "sha256:18d82244ee114f543149c66a6e0c14e9c4f8a1044b5cdaadd0f82159d6a6ff59"},
 ]
 importlib-metadata = [
-    {file = "importlib_metadata-3.7.2-py3-none-any.whl", hash = "sha256:407d13f55dc6f2a844e62325d18ad7019a436c4bfcaee34cda35f2be6e7c3e34"},
-    {file = "importlib_metadata-3.7.2.tar.gz", hash = "sha256:18d5ff601069f98d5d605b6a4b50c18a34811d655c55548adc833e687289acde"},
+    {file = "importlib_metadata-4.0.1-py3-none-any.whl", hash = "sha256:d7eb1dea6d6a6086f8be21784cc9e3bcfa55872b52309bc5fad53a8ea444465d"},
+    {file = "importlib_metadata-4.0.1.tar.gz", hash = "sha256:8c501196e49fb9df5df43833bdb1e4328f64847763ec8a50703148b73784d581"},
 ]
 iniconfig = [
     {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"},
     {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"},
 ]
 numpy = [
-    {file = "numpy-1.20.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:ae61f02b84a0211abb56462a3b6cd1e7ec39d466d3160eb4e1da8bf6717cdbeb"},
-    {file = "numpy-1.20.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:65410c7f4398a0047eea5cca9b74009ea61178efd78d1be9847fac1d6716ec1e"},
-    {file = "numpy-1.20.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:2d7e27442599104ee08f4faed56bb87c55f8b10a5494ac2ead5c98a4b289e61f"},
-    {file = "numpy-1.20.1-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:4ed8e96dc146e12c1c5cdd6fb9fd0757f2ba66048bf94c5126b7efebd12d0090"},
-    {file = "numpy-1.20.1-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:ecb5b74c702358cdc21268ff4c37f7466357871f53a30e6f84c686952bef16a9"},
-    {file = "numpy-1.20.1-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:b9410c0b6fed4a22554f072a86c361e417f0258838957b78bd063bde2c7f841f"},
-    {file = "numpy-1.20.1-cp37-cp37m-win32.whl", hash = "sha256:3d3087e24e354c18fb35c454026af3ed8997cfd4997765266897c68d724e4845"},
-    {file = "numpy-1.20.1-cp37-cp37m-win_amd64.whl", hash = "sha256:89f937b13b8dd17b0099c7c2e22066883c86ca1575a975f754babc8fbf8d69a9"},
-    {file = "numpy-1.20.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:a1d7995d1023335e67fb070b2fae6f5968f5be3802b15ad6d79d81ecaa014fe0"},
-    {file = "numpy-1.20.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:60759ab15c94dd0e1ed88241fd4fa3312db4e91d2c8f5a2d4cf3863fad83d65b"},
-    {file = "numpy-1.20.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:125a0e10ddd99a874fd357bfa1b636cd58deb78ba4a30b5ddb09f645c3512e04"},
-    {file = "numpy-1.20.1-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:c26287dfc888cf1e65181f39ea75e11f42ffc4f4529e5bd19add57ad458996e2"},
-    {file = "numpy-1.20.1-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:7199109fa46277be503393be9250b983f325880766f847885607d9b13848f257"},
-    {file = "numpy-1.20.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:72251e43ac426ff98ea802a931922c79b8d7596480300eb9f1b1e45e0543571e"},
-    {file = "numpy-1.20.1-cp38-cp38-win32.whl", hash = "sha256:c91ec9569facd4757ade0888371eced2ecf49e7982ce5634cc2cf4e7331a4b14"},
-    {file = "numpy-1.20.1-cp38-cp38-win_amd64.whl", hash = "sha256:13adf545732bb23a796914fe5f891a12bd74cf3d2986eed7b7eba2941eea1590"},
-    {file = "numpy-1.20.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:104f5e90b143dbf298361a99ac1af4cf59131218a045ebf4ee5990b83cff5fab"},
-    {file = "numpy-1.20.1-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:89e5336f2bec0c726ac7e7cdae181b325a9c0ee24e604704ed830d241c5e47ff"},
-    {file = "numpy-1.20.1-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:032be656d89bbf786d743fee11d01ef318b0781281241997558fa7950028dd29"},
-    {file = "numpy-1.20.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:66b467adfcf628f66ea4ac6430ded0614f5cc06ba530d09571ea404789064adc"},
-    {file = "numpy-1.20.1-cp39-cp39-win32.whl", hash = "sha256:12e4ba5c6420917571f1a5becc9338abbde71dd811ce40b37ba62dec7b39af6d"},
-    {file = "numpy-1.20.1-cp39-cp39-win_amd64.whl", hash = "sha256:9c94cab5054bad82a70b2e77741271790304651d584e2cdfe2041488e753863b"},
-    {file = "numpy-1.20.1-pp37-pypy37_pp73-manylinux2010_x86_64.whl", hash = "sha256:9eb551d122fadca7774b97db8a112b77231dcccda8e91a5bc99e79890797175e"},
-    {file = "numpy-1.20.1.zip", hash = "sha256:3bc63486a870294683980d76ec1e3efc786295ae00128f9ea38e2c6e74d5a60a"},
+    {file = "numpy-1.20.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:70eb5808127284c4e5c9e836208e09d685a7978b6a216db85960b1a112eeace8"},
+    {file = "numpy-1.20.3-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:6ca2b85a5997dabc38301a22ee43c82adcb53ff660b89ee88dded6b33687e1d8"},
+    {file = "numpy-1.20.3-cp37-cp37m-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:c5bf0e132acf7557fc9bb8ded8b53bbbbea8892f3c9a1738205878ca9434206a"},
+    {file = "numpy-1.20.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:db250fd3e90117e0312b611574cd1b3f78bec046783195075cbd7ba9c3d73f16"},
+    {file = "numpy-1.20.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:637d827248f447e63585ca3f4a7d2dfaa882e094df6cfa177cc9cf9cd6cdf6d2"},
+    {file = "numpy-1.20.3-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:8b7bb4b9280da3b2856cb1fc425932f46fba609819ee1c62256f61799e6a51d2"},
+    {file = "numpy-1.20.3-cp37-cp37m-win32.whl", hash = "sha256:67d44acb72c31a97a3d5d33d103ab06d8ac20770e1c5ad81bdb3f0c086a56cf6"},
+    {file = "numpy-1.20.3-cp37-cp37m-win_amd64.whl", hash = "sha256:43909c8bb289c382170e0282158a38cf306a8ad2ff6dfadc447e90f9961bef43"},
+    {file = "numpy-1.20.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:f1452578d0516283c87608a5a5548b0cdde15b99650efdfd85182102ef7a7c17"},
+    {file = "numpy-1.20.3-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:6e51534e78d14b4a009a062641f465cfaba4fdcb046c3ac0b1f61dd97c861b1b"},
+    {file = "numpy-1.20.3-cp38-cp38-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:e515c9a93aebe27166ec9593411c58494fa98e5fcc219e47260d9ab8a1cc7f9f"},
+    {file = "numpy-1.20.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1c09247ccea742525bdb5f4b5ceeacb34f95731647fe55774aa36557dbb5fa4"},
+    {file = "numpy-1.20.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:66fbc6fed94a13b9801fb70b96ff30605ab0a123e775a5e7a26938b717c5d71a"},
+    {file = "numpy-1.20.3-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:ea9cff01e75a956dbee133fa8e5b68f2f92175233de2f88de3a682dd94deda65"},
+    {file = "numpy-1.20.3-cp38-cp38-win32.whl", hash = "sha256:f39a995e47cb8649673cfa0579fbdd1cdd33ea497d1728a6cb194d6252268e48"},
+    {file = "numpy-1.20.3-cp38-cp38-win_amd64.whl", hash = "sha256:1676b0a292dd3c99e49305a16d7a9f42a4ab60ec522eac0d3dd20cdf362ac010"},
+    {file = "numpy-1.20.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:830b044f4e64a76ba71448fce6e604c0fc47a0e54d8f6467be23749ac2cbd2fb"},
+    {file = "numpy-1.20.3-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:55b745fca0a5ab738647d0e4db099bd0a23279c32b31a783ad2ccea729e632df"},
+    {file = "numpy-1.20.3-cp39-cp39-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:5d050e1e4bc9ddb8656d7b4f414557720ddcca23a5b88dd7cff65e847864c400"},
+    {file = "numpy-1.20.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9c65473ebc342715cb2d7926ff1e202c26376c0dcaaee85a1fd4b8d8c1d3b2f"},
+    {file = "numpy-1.20.3-cp39-cp39-win32.whl", hash = "sha256:16f221035e8bd19b9dc9a57159e38d2dd060b48e93e1d843c49cb370b0f415fd"},
+    {file = "numpy-1.20.3-cp39-cp39-win_amd64.whl", hash = "sha256:6690080810f77485667bfbff4f69d717c3be25e5b11bb2073e76bb3f578d99b4"},
+    {file = "numpy-1.20.3-pp37-pypy37_pp73-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:4e465afc3b96dbc80cf4a5273e5e2b1e3451286361b4af70ce1adb2984d392f9"},
+    {file = "numpy-1.20.3.zip", hash = "sha256:e55185e51b18d788e49fe8305fd73ef4470596b33fc2c1ceb304566b99c71a69"},
 ]
 openslide-python = [
     {file = "openslide-python-1.1.2.tar.gz", hash = "sha256:00f5e34dca56500b598454107a0acf243788ea2072b5468b3fea59f756b832a8"},
@@ -623,39 +676,39 @@ packaging = [
     {file = "packaging-20.9.tar.gz", hash = "sha256:5b327ac1320dc863dca72f4514ecc086f31186744b84a230374cc1fd776feae5"},
 ]
 pillow = [
-    {file = "Pillow-8.1.2-cp36-cp36m-macosx_10_10_x86_64.whl", hash = "sha256:5cf03b9534aca63b192856aa601c68d0764810857786ea5da652581f3a44c2b0"},
-    {file = "Pillow-8.1.2-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:f91b50ad88048d795c0ad004abbe1390aa1882073b1dca10bfd55d0b8cf18ec5"},
-    {file = "Pillow-8.1.2-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:5762ebb4436f46b566fc6351d67a9b5386b5e5de4e58fdaa18a1c83e0e20f1a8"},
-    {file = "Pillow-8.1.2-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:e2cd8ac157c1e5ae88b6dd790648ee5d2777e76f1e5c7d184eaddb2938594f34"},
-    {file = "Pillow-8.1.2-cp36-cp36m-win32.whl", hash = "sha256:72027ebf682abc9bafd93b43edc44279f641e8996fb2945104471419113cfc71"},
-    {file = "Pillow-8.1.2-cp36-cp36m-win_amd64.whl", hash = "sha256:d1d6bca39bb6dd94fba23cdb3eeaea5e30c7717c5343004d900e2a63b132c341"},
-    {file = "Pillow-8.1.2-cp37-cp37m-macosx_10_10_x86_64.whl", hash = "sha256:90882c6f084ef68b71bba190209a734bf90abb82ab5e8f64444c71d5974008c6"},
-    {file = "Pillow-8.1.2-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:89e4c757a91b8c55d97c91fa09c69b3677c227b942fa749e9a66eef602f59c28"},
-    {file = "Pillow-8.1.2-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:8c4e32218c764bc27fe49b7328195579581aa419920edcc321c4cb877c65258d"},
-    {file = "Pillow-8.1.2-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:a01da2c266d9868c4f91a9c6faf47a251f23b9a862dce81d2ff583135206f5be"},
-    {file = "Pillow-8.1.2-cp37-cp37m-win32.whl", hash = "sha256:30d33a1a6400132e6f521640dd3f64578ac9bfb79a619416d7e8802b4ce1dd55"},
-    {file = "Pillow-8.1.2-cp37-cp37m-win_amd64.whl", hash = "sha256:71b01ee69e7df527439d7752a2ce8fb89e19a32df484a308eca3e81f673d3a03"},
-    {file = "Pillow-8.1.2-cp38-cp38-macosx_10_10_x86_64.whl", hash = "sha256:5a2d957eb4aba9d48170b8fe6538ec1fbc2119ffe6373782c03d8acad3323f2e"},
-    {file = "Pillow-8.1.2-cp38-cp38-manylinux1_i686.whl", hash = "sha256:87f42c976f91ca2fc21a3293e25bd3cd895918597db1b95b93cbd949f7d019ce"},
-    {file = "Pillow-8.1.2-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:15306d71a1e96d7e271fd2a0737038b5a92ca2978d2e38b6ced7966583e3d5af"},
-    {file = "Pillow-8.1.2-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:71f31ee4df3d5e0b366dd362007740106d3210fb6a56ec4b581a5324ba254f06"},
-    {file = "Pillow-8.1.2-cp38-cp38-win32.whl", hash = "sha256:98afcac3205d31ab6a10c5006b0cf040d0026a68ec051edd3517b776c1d78b09"},
-    {file = "Pillow-8.1.2-cp38-cp38-win_amd64.whl", hash = "sha256:328240f7dddf77783e72d5ed79899a6b48bc6681f8d1f6001f55933cb4905060"},
-    {file = "Pillow-8.1.2-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:bead24c0ae3f1f6afcb915a057943ccf65fc755d11a1410a909c1fefb6c06ad1"},
-    {file = "Pillow-8.1.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:81b3716cc9744ffdf76b39afb6247eae754186838cedad0b0ac63b2571253fe6"},
-    {file = "Pillow-8.1.2-cp39-cp39-manylinux1_i686.whl", hash = "sha256:63cd413ac52ee3f67057223d363f4f82ce966e64906aea046daf46695e3c8238"},
-    {file = "Pillow-8.1.2-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:8565355a29655b28fdc2c666fd9a3890fe5edc6639d128814fafecfae2d70910"},
-    {file = "Pillow-8.1.2-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:1940fc4d361f9cc7e558d6f56ff38d7351b53052fd7911f4b60cd7bc091ea3b1"},
-    {file = "Pillow-8.1.2-cp39-cp39-win32.whl", hash = "sha256:46c2bcf8e1e75d154e78417b3e3c64e96def738c2a25435e74909e127a8cba5e"},
-    {file = "Pillow-8.1.2-cp39-cp39-win_amd64.whl", hash = "sha256:aeab4cd016e11e7aa5cfc49dcff8e51561fa64818a0be86efa82c7038e9369d0"},
-    {file = "Pillow-8.1.2-pp36-pypy36_pp73-macosx_10_10_x86_64.whl", hash = "sha256:74cd9aa648ed6dd25e572453eb09b08817a1e3d9f8d1bd4d8403d99e42ea790b"},
-    {file = "Pillow-8.1.2-pp36-pypy36_pp73-manylinux2010_i686.whl", hash = "sha256:e5739ae63636a52b706a0facec77b2b58e485637e1638202556156e424a02dc2"},
-    {file = "Pillow-8.1.2-pp36-pypy36_pp73-manylinux2010_x86_64.whl", hash = "sha256:903293320efe2466c1ab3509a33d6b866dc850cfd0c5d9cc92632014cec185fb"},
-    {file = "Pillow-8.1.2-pp37-pypy37_pp73-macosx_10_10_x86_64.whl", hash = "sha256:5daba2b40782c1c5157a788ec4454067c6616f5a0c1b70e26ac326a880c2d328"},
-    {file = "Pillow-8.1.2-pp37-pypy37_pp73-manylinux2010_i686.whl", hash = "sha256:1f93f2fe211f1ef75e6f589327f4d4f8545d5c8e826231b042b483d8383e8a7c"},
-    {file = "Pillow-8.1.2-pp37-pypy37_pp73-manylinux2010_x86_64.whl", hash = "sha256:6efac40344d8f668b6c4533ae02a48d52fd852ef0654cc6f19f6ac146399c733"},
-    {file = "Pillow-8.1.2-pp37-pypy37_pp73-win32.whl", hash = "sha256:f36c3ff63d6fc509ce599a2f5b0d0732189eed653420e7294c039d342c6e204a"},
-    {file = "Pillow-8.1.2.tar.gz", hash = "sha256:b07c660e014852d98a00a91adfbe25033898a9d90a8f39beb2437d22a203fc44"},
+    {file = "Pillow-8.2.0-cp36-cp36m-macosx_10_10_x86_64.whl", hash = "sha256:dc38f57d8f20f06dd7c3161c59ca2c86893632623f33a42d592f097b00f720a9"},
+    {file = "Pillow-8.2.0-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:a013cbe25d20c2e0c4e85a9daf438f85121a4d0344ddc76e33fd7e3965d9af4b"},
+    {file = "Pillow-8.2.0-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:8bb1e155a74e1bfbacd84555ea62fa21c58e0b4e7e6b20e4447b8d07990ac78b"},
+    {file = "Pillow-8.2.0-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:c5236606e8570542ed424849f7852a0ff0bce2c4c8d0ba05cc202a5a9c97dee9"},
+    {file = "Pillow-8.2.0-cp36-cp36m-win32.whl", hash = "sha256:12e5e7471f9b637762453da74e390e56cc43e486a88289995c1f4c1dc0bfe727"},
+    {file = "Pillow-8.2.0-cp36-cp36m-win_amd64.whl", hash = "sha256:5afe6b237a0b81bd54b53f835a153770802f164c5570bab5e005aad693dab87f"},
+    {file = "Pillow-8.2.0-cp37-cp37m-macosx_10_10_x86_64.whl", hash = "sha256:cb7a09e173903541fa888ba010c345893cd9fc1b5891aaf060f6ca77b6a3722d"},
+    {file = "Pillow-8.2.0-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:0d19d70ee7c2ba97631bae1e7d4725cdb2ecf238178096e8c82ee481e189168a"},
+    {file = "Pillow-8.2.0-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:083781abd261bdabf090ad07bb69f8f5599943ddb539d64497ed021b2a67e5a9"},
+    {file = "Pillow-8.2.0-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:c6b39294464b03457f9064e98c124e09008b35a62e3189d3513e5148611c9388"},
+    {file = "Pillow-8.2.0-cp37-cp37m-win32.whl", hash = "sha256:01425106e4e8cee195a411f729cff2a7d61813b0b11737c12bd5991f5f14bcd5"},
+    {file = "Pillow-8.2.0-cp37-cp37m-win_amd64.whl", hash = "sha256:3b570f84a6161cf8865c4e08adf629441f56e32f180f7aa4ccbd2e0a5a02cba2"},
+    {file = "Pillow-8.2.0-cp38-cp38-macosx_10_10_x86_64.whl", hash = "sha256:031a6c88c77d08aab84fecc05c3cde8414cd6f8406f4d2b16fed1e97634cc8a4"},
+    {file = "Pillow-8.2.0-cp38-cp38-manylinux1_i686.whl", hash = "sha256:66cc56579fd91f517290ab02c51e3a80f581aba45fd924fcdee01fa06e635812"},
+    {file = "Pillow-8.2.0-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:6c32cc3145928c4305d142ebec682419a6c0a8ce9e33db900027ddca1ec39178"},
+    {file = "Pillow-8.2.0-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:624b977355cde8b065f6d51b98497d6cd5fbdd4f36405f7a8790e3376125e2bb"},
+    {file = "Pillow-8.2.0-cp38-cp38-win32.whl", hash = "sha256:5cbf3e3b1014dddc45496e8cf38b9f099c95a326275885199f427825c6522232"},
+    {file = "Pillow-8.2.0-cp38-cp38-win_amd64.whl", hash = "sha256:463822e2f0d81459e113372a168f2ff59723e78528f91f0bd25680ac185cf797"},
+    {file = "Pillow-8.2.0-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:95d5ef984eff897850f3a83883363da64aae1000e79cb3c321915468e8c6add5"},
+    {file = "Pillow-8.2.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b91c36492a4bbb1ee855b7d16fe51379e5f96b85692dc8210831fbb24c43e484"},
+    {file = "Pillow-8.2.0-cp39-cp39-manylinux1_i686.whl", hash = "sha256:d68cb92c408261f806b15923834203f024110a2e2872ecb0bd2a110f89d3c602"},
+    {file = "Pillow-8.2.0-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:f217c3954ce5fd88303fc0c317af55d5e0204106d86dea17eb8205700d47dec2"},
+    {file = "Pillow-8.2.0-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:5b70110acb39f3aff6b74cf09bb4169b167e2660dabc304c1e25b6555fa781ef"},
+    {file = "Pillow-8.2.0-cp39-cp39-win32.whl", hash = "sha256:a7d5e9fad90eff8f6f6106d3b98b553a88b6f976e51fce287192a5d2d5363713"},
+    {file = "Pillow-8.2.0-cp39-cp39-win_amd64.whl", hash = "sha256:238c197fc275b475e87c1453b05b467d2d02c2915fdfdd4af126145ff2e4610c"},
+    {file = "Pillow-8.2.0-pp36-pypy36_pp73-macosx_10_10_x86_64.whl", hash = "sha256:0e04d61f0064b545b989126197930807c86bcbd4534d39168f4aa5fda39bb8f9"},
+    {file = "Pillow-8.2.0-pp36-pypy36_pp73-manylinux2010_i686.whl", hash = "sha256:63728564c1410d99e6d1ae8e3b810fe012bc440952168af0a2877e8ff5ab96b9"},
+    {file = "Pillow-8.2.0-pp36-pypy36_pp73-manylinux2010_x86_64.whl", hash = "sha256:c03c07ed32c5324939b19e36ae5f75c660c81461e312a41aea30acdd46f93a7c"},
+    {file = "Pillow-8.2.0-pp37-pypy37_pp73-macosx_10_10_x86_64.whl", hash = "sha256:4d98abdd6b1e3bf1a1cbb14c3895226816e666749ac040c4e2554231068c639b"},
+    {file = "Pillow-8.2.0-pp37-pypy37_pp73-manylinux2010_i686.whl", hash = "sha256:aac00e4bc94d1b7813fe882c28990c1bc2f9d0e1aa765a5f2b516e8a6a16a9e4"},
+    {file = "Pillow-8.2.0-pp37-pypy37_pp73-manylinux2010_x86_64.whl", hash = "sha256:22fd0f42ad15dfdde6c581347eaa4adb9a6fc4b865f90b23378aa7914895e120"},
+    {file = "Pillow-8.2.0-pp37-pypy37_pp73-win32.whl", hash = "sha256:e98eca29a05913e82177b3ba3d198b1728e164869c613d76d0de4bde6768a50e"},
+    {file = "Pillow-8.2.0.tar.gz", hash = "sha256:a787ab10d7bb5494e5f76536ac460741788f1fbce851068d73a87ca7c35fc3e1"},
 ]
 pkgconfig = [
     {file = "pkgconfig-1.5.2-py2.py3-none-any.whl", hash = "sha256:28013c66ae10363036759bae5ee85ce0ff7106f5e9e6e6fbd8ceca59330480e9"},
@@ -674,40 +727,114 @@ pycparser = [
     {file = "pycparser-2.20.tar.gz", hash = "sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0"},
 ]
 pydantic = [
-    {file = "pydantic-1.8.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:0c40162796fc8d0aa744875b60e4dc36834db9f2a25dbf9ba9664b1915a23850"},
-    {file = "pydantic-1.8.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:fff29fe54ec419338c522b908154a2efabeee4f483e48990f87e189661f31ce3"},
-    {file = "pydantic-1.8.1-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:fbfb608febde1afd4743c6822c19060a8dbdd3eb30f98e36061ba4973308059e"},
-    {file = "pydantic-1.8.1-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:eb8ccf12295113ce0de38f80b25f736d62f0a8d87c6b88aca645f168f9c78771"},
-    {file = "pydantic-1.8.1-cp36-cp36m-win_amd64.whl", hash = "sha256:20d42f1be7c7acc352b3d09b0cf505a9fab9deb93125061b376fbe1f06a5459f"},
-    {file = "pydantic-1.8.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:dde4ca368e82791de97c2ec019681ffb437728090c0ff0c3852708cf923e0c7d"},
-    {file = "pydantic-1.8.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:3bbd023c981cbe26e6e21c8d2ce78485f85c2e77f7bab5ec15b7d2a1f491918f"},
-    {file = "pydantic-1.8.1-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:830ef1a148012b640186bf4d9789a206c56071ff38f2460a32ae67ca21880eb8"},
-    {file = "pydantic-1.8.1-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:fb77f7a7e111db1832ae3f8f44203691e15b1fa7e5a1cb9691d4e2659aee41c4"},
-    {file = "pydantic-1.8.1-cp37-cp37m-win_amd64.whl", hash = "sha256:3bcb9d7e1f9849a6bdbd027aabb3a06414abd6068cb3b21c49427956cce5038a"},
-    {file = "pydantic-1.8.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2287ebff0018eec3cc69b1d09d4b7cebf277726fa1bd96b45806283c1d808683"},
-    {file = "pydantic-1.8.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:4bbc47cf7925c86a345d03b07086696ed916c7663cb76aa409edaa54546e53e2"},
-    {file = "pydantic-1.8.1-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:6388ef4ef1435364c8cc9a8192238aed030595e873d8462447ccef2e17387125"},
-    {file = "pydantic-1.8.1-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:dd4888b300769ecec194ca8f2699415f5f7760365ddbe243d4fd6581485fa5f0"},
-    {file = "pydantic-1.8.1-cp38-cp38-win_amd64.whl", hash = "sha256:8fbb677e4e89c8ab3d450df7b1d9caed23f254072e8597c33279460eeae59b99"},
-    {file = "pydantic-1.8.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:2f2736d9a996b976cfdfe52455ad27462308c9d3d0ae21a2aa8b4cd1a78f47b9"},
-    {file = "pydantic-1.8.1-cp39-cp39-manylinux1_i686.whl", hash = "sha256:3114d74329873af0a0e8004627f5389f3bb27f956b965ddd3e355fe984a1789c"},
-    {file = "pydantic-1.8.1-cp39-cp39-manylinux2014_i686.whl", hash = "sha256:258576f2d997ee4573469633592e8b99aa13bda182fcc28e875f866016c8e07e"},
-    {file = "pydantic-1.8.1-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:c17a0b35c854049e67c68b48d55e026c84f35593c66d69b278b8b49e2484346f"},
-    {file = "pydantic-1.8.1-cp39-cp39-win_amd64.whl", hash = "sha256:e8bc082afef97c5fd3903d05c6f7bb3a6af9fc18631b4cc9fedeb4720efb0c58"},
-    {file = "pydantic-1.8.1-py3-none-any.whl", hash = "sha256:e3f8790c47ac42549dc8b045a67b0ca371c7f66e73040d0197ce6172b385e520"},
-    {file = "pydantic-1.8.1.tar.gz", hash = "sha256:26cf3cb2e68ec6c0cfcb6293e69fb3450c5fd1ace87f46b64f678b0d29eac4c3"},
+    {file = "pydantic-1.8.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:05ddfd37c1720c392f4e0d43c484217b7521558302e7069ce8d318438d297739"},
+    {file = "pydantic-1.8.2-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:a7c6002203fe2c5a1b5cbb141bb85060cbff88c2d78eccbc72d97eb7022c43e4"},
+    {file = "pydantic-1.8.2-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:589eb6cd6361e8ac341db97602eb7f354551482368a37f4fd086c0733548308e"},
+    {file = "pydantic-1.8.2-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:10e5622224245941efc193ad1d159887872776df7a8fd592ed746aa25d071840"},
+    {file = "pydantic-1.8.2-cp36-cp36m-win_amd64.whl", hash = "sha256:99a9fc39470010c45c161a1dc584997f1feb13f689ecf645f59bb4ba623e586b"},
+    {file = "pydantic-1.8.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:a83db7205f60c6a86f2c44a61791d993dff4b73135df1973ecd9eed5ea0bda20"},
+    {file = "pydantic-1.8.2-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:41b542c0b3c42dc17da70554bc6f38cbc30d7066d2c2815a94499b5684582ecb"},
+    {file = "pydantic-1.8.2-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:ea5cb40a3b23b3265f6325727ddfc45141b08ed665458be8c6285e7b85bd73a1"},
+    {file = "pydantic-1.8.2-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:18b5ea242dd3e62dbf89b2b0ec9ba6c7b5abaf6af85b95a97b00279f65845a23"},
+    {file = "pydantic-1.8.2-cp37-cp37m-win_amd64.whl", hash = "sha256:234a6c19f1c14e25e362cb05c68afb7f183eb931dd3cd4605eafff055ebbf287"},
+    {file = "pydantic-1.8.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:021ea0e4133e8c824775a0cfe098677acf6fa5a3cbf9206a376eed3fc09302cd"},
+    {file = "pydantic-1.8.2-cp38-cp38-manylinux1_i686.whl", hash = "sha256:e710876437bc07bd414ff453ac8ec63d219e7690128d925c6e82889d674bb505"},
+    {file = "pydantic-1.8.2-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:ac8eed4ca3bd3aadc58a13c2aa93cd8a884bcf21cb019f8cfecaae3b6ce3746e"},
+    {file = "pydantic-1.8.2-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:4a03cbbe743e9c7247ceae6f0d8898f7a64bb65800a45cbdc52d65e370570820"},
+    {file = "pydantic-1.8.2-cp38-cp38-win_amd64.whl", hash = "sha256:8621559dcf5afacf0069ed194278f35c255dc1a1385c28b32dd6c110fd6531b3"},
+    {file = "pydantic-1.8.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8b223557f9510cf0bfd8b01316bf6dd281cf41826607eada99662f5e4963f316"},
+    {file = "pydantic-1.8.2-cp39-cp39-manylinux1_i686.whl", hash = "sha256:244ad78eeb388a43b0c927e74d3af78008e944074b7d0f4f696ddd5b2af43c62"},
+    {file = "pydantic-1.8.2-cp39-cp39-manylinux2014_i686.whl", hash = "sha256:05ef5246a7ffd2ce12a619cbb29f3307b7c4509307b1b49f456657b43529dc6f"},
+    {file = "pydantic-1.8.2-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:54cd5121383f4a461ff7644c7ca20c0419d58052db70d8791eacbbe31528916b"},
+    {file = "pydantic-1.8.2-cp39-cp39-win_amd64.whl", hash = "sha256:4be75bebf676a5f0f87937c6ddb061fa39cbea067240d98e298508c1bda6f3f3"},
+    {file = "pydantic-1.8.2-py3-none-any.whl", hash = "sha256:fec866a0b59f372b7e776f2d7308511784dace622e0992a0b59ea3ccee0ae833"},
+    {file = "pydantic-1.8.2.tar.gz", hash = "sha256:26464e57ccaafe72b7ad156fdaa4e9b9ef051f69e175dbbb463283000c05ab7b"},
 ]
 pydicom = [
     {file = "pydicom-2.1.2-py3-none-any.whl", hash = "sha256:d97f53a7b269dbd7414d18342f1b70f80d7d35dc4e479316bab146daac0e0c15"},
     {file = "pydicom-2.1.2.tar.gz", hash = "sha256:65f36820c5fec24b4e7ca45b7dae93e054ed269d55f92681863d39d30459e2fd"},
 ]
+pylibjpeg = [
+    {file = "pylibjpeg-1.3.0-py3-none-any.whl", hash = "sha256:0436dfbe1141e9ef360b9e94546d95352257f96eabdcdab638fde31c0f816e18"},
+    {file = "pylibjpeg-1.3.0.tar.gz", hash = "sha256:5115ee908301737874312d28804db5891bb8e96ea8f9caf90d34621e8ca67879"},
+]
+pylibjpeg-openjpeg = [
+    {file = "pylibjpeg-openjpeg-1.1.1.tar.gz", hash = "sha256:e7adfd8e2c63661b75219d5ae381ac50381054df41ca94d0d01ddfbe362a66cc"},
+    {file = "pylibjpeg_openjpeg-1.1.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:a56cecea6a2b369c710d7c94e3e1bc04cb22cd47a2b73ae4e3e4e6f2e54a90c0"},
+    {file = "pylibjpeg_openjpeg-1.1.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:6294be5d5e843f7fdde0093395cb0a84e168671ea75264292358f371a56d2af4"},
+    {file = "pylibjpeg_openjpeg-1.1.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:3fa56d78a47bf671a6289aa60999820c9ae0784eb02bd167cfb19f2f0202faea"},
+    {file = "pylibjpeg_openjpeg-1.1.1-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:3b3183c1693e4e4fe5ef4a4655422cc0f5dba73579d0c082da6656a2e0108c4f"},
+    {file = "pylibjpeg_openjpeg-1.1.1-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:967393980e9e98afaa48285611059ae9087144b10e8bd901ee0950838cc04c8b"},
+    {file = "pylibjpeg_openjpeg-1.1.1-cp36-cp36m-win32.whl", hash = "sha256:5de056312d312f97e07923b37ed4f898c2a88188f2f0b42264d755b288ad20ce"},
+    {file = "pylibjpeg_openjpeg-1.1.1-cp36-cp36m-win_amd64.whl", hash = "sha256:84fd3744a7377cb66ab668314e6cd418862d9b56200d17cca2b5bba196b1cdca"},
+    {file = "pylibjpeg_openjpeg-1.1.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:5589f4a693ab36597e2391c0bb6287caed2bda7c86783b76b5de2f5bb8f2a4fb"},
+    {file = "pylibjpeg_openjpeg-1.1.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:4e63734beda93662315b9ff20623431c13954937d7608d0c30b512ae29ad34ec"},
+    {file = "pylibjpeg_openjpeg-1.1.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:39bdc743446a6ff97c188d66c1a747872e83d4eeb797b486f21037c5a361b40e"},
+    {file = "pylibjpeg_openjpeg-1.1.1-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:7d459af9e9f8b54e661d99eccd02fa5ee5b2496031eaaba18b1e705fe7137ef7"},
+    {file = "pylibjpeg_openjpeg-1.1.1-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:1530b80880e87c8cdf03d17617b9afb432120d7bf79ac00463784d3bb629823b"},
+    {file = "pylibjpeg_openjpeg-1.1.1-cp37-cp37m-win32.whl", hash = "sha256:6265937a9d4a9062b2fd6e1555c76be323cc1ae24df0f0924e010620c09de675"},
+    {file = "pylibjpeg_openjpeg-1.1.1-cp37-cp37m-win_amd64.whl", hash = "sha256:f2d73fc5d5a1f55625a1372fe38acf8691347d30cc68d0ed916c4af29cc6eda6"},
+    {file = "pylibjpeg_openjpeg-1.1.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:fb829f0732c0ddd847e0519da64bead5fc5e8bb2cb833bec70c35bed486c0958"},
+    {file = "pylibjpeg_openjpeg-1.1.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:ff7f4351c52b3460ce3486eaff8ebb82d49c26a8c7a6d26c39c6d39cdbba3aac"},
+    {file = "pylibjpeg_openjpeg-1.1.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:a0e892c36a0d43c5103b35f3d04e05118ace4922c5fcc6f52be279e1e563ba84"},
+    {file = "pylibjpeg_openjpeg-1.1.1-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:9d868aea9ab67a05c7588e30d5b2bac354444c89736e84bfa8ff7cddabaa3ecc"},
+    {file = "pylibjpeg_openjpeg-1.1.1-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:16ff4a24b91c7cf511fdc4355473c34bbdfdab835edf301b93c3b1581fdbe44b"},
+    {file = "pylibjpeg_openjpeg-1.1.1-cp38-cp38-win32.whl", hash = "sha256:4d4097aec59419dc7d2168ba5f52e81b61891b07ae4f078c941d5e0ac3997fea"},
+    {file = "pylibjpeg_openjpeg-1.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:d0358ea255f767d02671bc8e5a3b6d84719316d36ff634cdb92067d90b54c45e"},
+    {file = "pylibjpeg_openjpeg-1.1.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:72622e1007a5a2acaf1ae383545e4e64b486e92a20d508e1e60f5603a4849ceb"},
+    {file = "pylibjpeg_openjpeg-1.1.1-cp39-cp39-manylinux1_i686.whl", hash = "sha256:f52f791ae4f22689987c350249836d91411b9ccbed9b1dd659004dca891ebe0a"},
+    {file = "pylibjpeg_openjpeg-1.1.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:c6ebe556d8d5090005236fbcdd5bda89177e2c8af586f7d44c5283c20bf5e1f4"},
+    {file = "pylibjpeg_openjpeg-1.1.1-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:4e920b633a80a3f6d49062c8be7b60d11edd5fed4affbda12b60ffef4343610b"},
+    {file = "pylibjpeg_openjpeg-1.1.1-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:ed263f119fae700c0e23e4818284637f47d7972212c1598deea631b58178fca2"},
+    {file = "pylibjpeg_openjpeg-1.1.1-cp39-cp39-win32.whl", hash = "sha256:daf68f749b8ae9da991d35336aa9d38851268848a21cb9f002a533abd96ed472"},
+    {file = "pylibjpeg_openjpeg-1.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:b86d518cf5f2d57b17934fca8b0e8ee0b2be7f6b997ee814c3593524c3db8a9a"},
+]
+pylibjpeg-rle = [
+    {file = "pylibjpeg-rle-1.1.0.tar.gz", hash = "sha256:aecbbf9abf6609e918174f12ada381bee21da5dd7947401d0d395e3bfa0f21be"},
+    {file = "pylibjpeg_rle-1.1.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:7f89a0130c119844a54a32bd4e2fd549df50453fa08cc5f2b990e38b542c5b17"},
+    {file = "pylibjpeg_rle-1.1.0-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:63227b0714f7ce10d25c433cf12b3c282e1e955215f944340e2f28613fc11d18"},
+    {file = "pylibjpeg_rle-1.1.0-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:be22f29c5e490b2fce48b6304f5c6ebc5a3e9ba6648bddf0a1448e92b8ddf1c1"},
+    {file = "pylibjpeg_rle-1.1.0-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:91846a615616d1f0bfb471d0aff498f9f2f6f2f13476f74bbb2166247a948ea7"},
+    {file = "pylibjpeg_rle-1.1.0-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:43be0a3cba51f8002cab001f5773fac45eafeec6f27e7f01c39295031d512cd8"},
+    {file = "pylibjpeg_rle-1.1.0-cp36-cp36m-win32.whl", hash = "sha256:25dd09c9fc34afe98017224f64de46f5b3b5f5d1cb37dc184dfdb1ece0f2f3ea"},
+    {file = "pylibjpeg_rle-1.1.0-cp36-cp36m-win_amd64.whl", hash = "sha256:73d147fee8c9231e9a14e1b2b36d7cd6de92cbded438601ee63a9a6701654049"},
+    {file = "pylibjpeg_rle-1.1.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c69444f4d18b40133ed3a5f920dad49c6fd546bc9e7b6e6ef33d080f2a20a41f"},
+    {file = "pylibjpeg_rle-1.1.0-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:15198a6d29428ae67e081eda8df5a35ee09dd645659100253060d7cbc05c1532"},
+    {file = "pylibjpeg_rle-1.1.0-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:07bda527602eeea5e795ab4d83230f8d6c8ad31c3ca013a34a775e90b6d57078"},
+    {file = "pylibjpeg_rle-1.1.0-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:df811082c635e86082eab25f23df6c2ba2511a2720cbf0c151a185dc8e3e98e3"},
+    {file = "pylibjpeg_rle-1.1.0-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:a5fd2b6a2d9bd7abaee25a587a23b98981744bdcdebe6d62c589b2a3cde1c6cb"},
+    {file = "pylibjpeg_rle-1.1.0-cp37-cp37m-win32.whl", hash = "sha256:dc68d7fe4c2375427b12de01b91fa5bfcf9ef4a40521c9db3bc35603eae90433"},
+    {file = "pylibjpeg_rle-1.1.0-cp37-cp37m-win_amd64.whl", hash = "sha256:92ede6713425145853c1d88c79abc557f20cf42ed142d63acd108ff250b79c2b"},
+    {file = "pylibjpeg_rle-1.1.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:211876e3211a68ece7f757b53b5d36c3a8c799c5cf06baabf62e2e88e3b62b26"},
+    {file = "pylibjpeg_rle-1.1.0-cp38-cp38-manylinux1_i686.whl", hash = "sha256:151a9902a5832c8fef8eba40c51ff446acc3217ba18aa6bf8f861c1cb29eb6b1"},
+    {file = "pylibjpeg_rle-1.1.0-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:160c396630d512528ff424ea25289cb49db3ad9906f083956e4521d120f515bb"},
+    {file = "pylibjpeg_rle-1.1.0-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:055b17c30ffd02021a688930a7636e33b8a6b45105d44597ac71b609635eb3a1"},
+    {file = "pylibjpeg_rle-1.1.0-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:0ea5bd62ca71a37847ed8f8b184e07ea6e9b0deebf0c1667d34eeb69a4ddabe4"},
+    {file = "pylibjpeg_rle-1.1.0-cp38-cp38-win32.whl", hash = "sha256:4ffa2cd077e0bb9737a9f8ddf71489edd6edff85baab7a86994d650062d4bd02"},
+    {file = "pylibjpeg_rle-1.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:93449d89e0b52b3a36a880316581653a4d464701a8f55cbcc9fe7ccdcd7c53e9"},
+    {file = "pylibjpeg_rle-1.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:365f5f7dd08b30f44c82511e8ac51f535b83c6fbbc07a347b051db405c87e638"},
+    {file = "pylibjpeg_rle-1.1.0-cp39-cp39-manylinux1_i686.whl", hash = "sha256:55a6074bf9858a9b872b7cc7cb3ec866b637a9f9d1a9c85aaf15e7af59e4f311"},
+    {file = "pylibjpeg_rle-1.1.0-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:0dea1ca52b5dbdb6af3a95ccd871dd59cf35e47ca7977c4cd691fd1e88ffb9f3"},
+    {file = "pylibjpeg_rle-1.1.0-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:d741f533562323657c006ad28bb4448b8a6cf28aa547133fee9d60e532f12b74"},
+    {file = "pylibjpeg_rle-1.1.0-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:3b30a47186ed7c481d941abb818b96ab9addb812114c9c766f9d21bacec1cd4d"},
+    {file = "pylibjpeg_rle-1.1.0-cp39-cp39-win32.whl", hash = "sha256:c441cb56bf18984b0eae273563d6c2e1a919f35bfd7c193fb5a53a2278d443cd"},
+    {file = "pylibjpeg_rle-1.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:bc2302be178fe0ec86c2aa2552057561e3ae56dea78b6a1f1163e426d46a1cba"},
+    {file = "pylibjpeg_rle-1.1.0-pp36-pypy36_pp73-macosx_10_9_x86_64.whl", hash = "sha256:852963024589b90ce303564e33e7c1b17320fbf19c1301c354a114bf20594c92"},
+    {file = "pylibjpeg_rle-1.1.0-pp36-pypy36_pp73-manylinux1_x86_64.whl", hash = "sha256:641cab22386c38fb465c3ba47646cc940423739c472b7d4e947f56fd9787640c"},
+    {file = "pylibjpeg_rle-1.1.0-pp36-pypy36_pp73-manylinux2010_x86_64.whl", hash = "sha256:55c0541956cadaf11457f5ff51d085f798d723bd3b25a88667233b3a38f63a6c"},
+    {file = "pylibjpeg_rle-1.1.0-pp36-pypy36_pp73-win32.whl", hash = "sha256:39a90e16c6e1cb2ea4f5302e04f0965efd38a7541720b0881d384cb66badb6be"},
+    {file = "pylibjpeg_rle-1.1.0-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:0e3d585163f1edfb44003240978f782ee49cd08a4d107ce63a885fb80bc26cd5"},
+    {file = "pylibjpeg_rle-1.1.0-pp37-pypy37_pp73-manylinux1_x86_64.whl", hash = "sha256:b616e137270adae7a3c0d2611de4a1e7895658bc63e1b5b7b43d69a8b53391f7"},
+    {file = "pylibjpeg_rle-1.1.0-pp37-pypy37_pp73-manylinux2010_x86_64.whl", hash = "sha256:89ed0ed5be8038ec0a4b2463ff6b04a58b472cad07b7a47f4d5be73676330629"},
+    {file = "pylibjpeg_rle-1.1.0-pp37-pypy37_pp73-win32.whl", hash = "sha256:10ec1917f46ba972cfb412c8c2d649335615117f1f4dafd2aff06897e5dbe020"},
+]
 pyparsing = [
     {file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"},
     {file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"},
 ]
 pytest = [
-    {file = "pytest-6.2.2-py3-none-any.whl", hash = "sha256:b574b57423e818210672e07ca1fa90aaf194a4f63f3ab909a2c67ebb22913839"},
-    {file = "pytest-6.2.2.tar.gz", hash = "sha256:9d1edf9e7d0b84d72ea3dbcdfd22b35fb543a5e8f2a60092dd578936bf63d7f9"},
+    {file = "pytest-6.2.4-py3-none-any.whl", hash = "sha256:91ef2131a9bd6be8f76f1f08eac5c5317221d6ad1e143ae03894b862e8976890"},
+    {file = "pytest-6.2.4.tar.gz", hash = "sha256:50bcad0a0b9c5a72c8e4e7c9855a3ad496ca6a881a3641b4260605450772c54b"},
 ]
 pytest-cov = [
     {file = "pytest-cov-2.11.1.tar.gz", hash = "sha256:359952d9d39b9f822d9d29324483e7ba04a3a17dd7d05aa6beb7ea01e359e5f7"},
@@ -718,8 +845,8 @@ pytest-forked = [
     {file = "pytest_forked-1.3.0-py2.py3-none-any.whl", hash = "sha256:dc4147784048e70ef5d437951728825a131b81714b398d5d52f17c7c144d8815"},
 ]
 pytest-randomly = [
-    {file = "pytest-randomly-3.5.0.tar.gz", hash = "sha256:440cec143fd9b0adeb072006c71e0294402a2bc2ccd08079c2341087ba4cf2d1"},
-    {file = "pytest_randomly-3.5.0-py3-none-any.whl", hash = "sha256:9db10d160237f3f8ee60cef72e4cb9ea88d2893c9dd5c8aa334b060cdeb67c3a"},
+    {file = "pytest-randomly-3.8.0.tar.gz", hash = "sha256:d9e21a72446757129378beea00bc9a32df1fb1cfd0bbe408be1ae9685bdf1209"},
+    {file = "pytest_randomly-3.8.0-py3-none-any.whl", hash = "sha256:f5b7a09e84ee1eabcdedbb73c51d0929ae2f582bab6941dbb513bb49296d6340"},
 ]
 pytest-xdist = [
     {file = "pytest-xdist-2.2.1.tar.gz", hash = "sha256:718887296892f92683f6a51f25a3ae584993b06f7076ce1e1fd482e59a8220a2"},
@@ -751,29 +878,29 @@ simpleitk = [
     {file = "SimpleITK-2.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:a49b02bd405a3630be5388c0cafdcee951a8e8a81351fcca9e55b6b1b76f0ff9"},
 ]
 six = [
-    {file = "six-1.15.0-py2.py3-none-any.whl", hash = "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"},
-    {file = "six-1.15.0.tar.gz", hash = "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259"},
+    {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"},
+    {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"},
 ]
 tifffile = [
-    {file = "tifffile-2021.3.5-py3-none-any.whl", hash = "sha256:8ae99b2d7a80512628dc7f7de5a178525b28cb50cc69e82578a67106c28beee6"},
-    {file = "tifffile-2021.3.5.tar.gz", hash = "sha256:f55a4c8bcbfdd28157529c2e2a7c066da1636a202144f1696092b9290f52685d"},
+    {file = "tifffile-2021.4.8-py3-none-any.whl", hash = "sha256:1cfc55f5b728e200142580a7bf108b72775c4097d007b4111876559fa1fb7432"},
+    {file = "tifffile-2021.4.8.tar.gz", hash = "sha256:55aa8baad38e1567c9fe450fff52160e4a21294a612f241c5e414da80f87209b"},
 ]
 toml = [
     {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"},
     {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"},
 ]
 tox = [
-    {file = "tox-3.23.0-py2.py3-none-any.whl", hash = "sha256:e007673f3595cede9b17a7c4962389e4305d4a3682a6c5a4159a1453b4f326aa"},
-    {file = "tox-3.23.0.tar.gz", hash = "sha256:05a4dbd5e4d3d8269b72b55600f0b0303e2eb47ad5c6fe76d3576f4c58d93661"},
+    {file = "tox-3.23.1-py2.py3-none-any.whl", hash = "sha256:b0b5818049a1c1997599d42012a637a33f24c62ab8187223fdd318fa8522637b"},
+    {file = "tox-3.23.1.tar.gz", hash = "sha256:307a81ddb82bd463971a273f33e9533a24ed22185f27db8ce3386bff27d324e3"},
 ]
 typing-extensions = [
-    {file = "typing_extensions-3.7.4.3-py2-none-any.whl", hash = "sha256:dafc7639cde7f1b6e1acc0f457842a83e722ccca8eef5270af2d74792619a89f"},
-    {file = "typing_extensions-3.7.4.3-py3-none-any.whl", hash = "sha256:7cb407020f00f7bfc3cb3e7881628838e69d8f3fcab2f64742a5e76b2f841918"},
-    {file = "typing_extensions-3.7.4.3.tar.gz", hash = "sha256:99d4073b617d30288f569d3f13d2bd7548c3a7e4c8de87db09a9d29bb3a4a60c"},
+    {file = "typing_extensions-3.10.0.0-py2-none-any.whl", hash = "sha256:0ac0f89795dd19de6b97debb0c6af1c70987fd80a2d62d1958f7e56fcc31b497"},
+    {file = "typing_extensions-3.10.0.0-py3-none-any.whl", hash = "sha256:779383f6086d90c99ae41cf0ff39aac8a7937a9283ce0a414e5dd782f4c94a84"},
+    {file = "typing_extensions-3.10.0.0.tar.gz", hash = "sha256:50b6f157849174217d0656f99dc82fe932884fb250826c18350e159ec6cdf342"},
 ]
 virtualenv = [
-    {file = "virtualenv-20.4.2-py2.py3-none-any.whl", hash = "sha256:2be72df684b74df0ea47679a7df93fd0e04e72520022c57b479d8f881485dbe3"},
-    {file = "virtualenv-20.4.2.tar.gz", hash = "sha256:147b43894e51dd6bba882cf9c282447f780e2251cd35172403745fc381a0a80d"},
+    {file = "virtualenv-20.4.6-py2.py3-none-any.whl", hash = "sha256:307a555cf21e1550885c82120eccaf5acedf42978fd362d32ba8410f9593f543"},
+    {file = "virtualenv-20.4.6.tar.gz", hash = "sha256:72cf267afc04bf9c86ec932329b7e94db6a0331ae9847576daaa7ca3c86b29a4"},
 ]
 zipp = [
     {file = "zipp-3.4.1-py3-none-any.whl", hash = "sha256:51cb66cc54621609dd593d1787f286ee42a5c0adbb4b29abea5a63edc3e03098"},
diff --git a/pyproject.toml b/pyproject.toml
index b88c27d..5dec793 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -57,6 +57,8 @@ Pillow = "*"
 openslide-python = "*"
 pyvips = "*"
 tifffile = "*"
+construct = "*"
+pylibjpeg = "*" # Required by oct-converter
 click = "*"
 
 [tool.poetry.dev-dependencies]


Test patch:
diff --git a/panimg/contrib/oct_converter/readers/e2e.py b/panimg/contrib/oct_converter/readers/e2e.py
new file mode 100644
index 0000000..d0f7273
--- /dev/null
+++ b/panimg/contrib/oct_converter/readers/e2e.py
@@ -0,0 +1,332 @@
+import numpy as np
+from construct import (
+    PaddedString,
+    Int16un,
+    Struct,
+    Int32sn,
+    Int32un,
+    Int8un,
+    Array,
+)
+from panimg.contrib.oct_converter.image_types import (
+    OCTVolumeWithMetaData,
+    FundusImageWithMetaData,
+)
+from pathlib import Path
+
+
+class E2E:
+    """ Class for extracting data from Heidelberg's .e2e file format.
+
+        Notes:
+            Mostly based on description of .e2e file format here:
+            https://bitbucket.org/uocte/uocte/wiki/Heidelberg%20File%20Format.
+
+        Attributes:
+            filepath (str): Path to .img file for reading.
+            header_structure (obj:Struct): Defines structure of volume's header.
+            main_directory_structure (obj:Struct): Defines structure of volume's main directory.
+            sub_directory_structure (obj:Struct): Defines structure of each sub directory in the volume.
+            chunk_structure (obj:Struct): Defines structure of each data chunk.
+            image_structure (obj:Struct): Defines structure of image header.
+    """
+
+    def __init__(self, filepath):
+        self.filepath = Path(filepath)
+        if not self.filepath.exists():
+            raise FileNotFoundError(self.filepath)
+        self.header_structure = Struct(
+            "magic" / PaddedString(12, "ascii"),
+            "version" / Int32un,
+            "unknown" / Array(10, Int16un),
+        )
+        self.main_directory_structure = Struct(
+            "magic" / PaddedString(12, "ascii"),
+            "version" / Int32un,
+            "unknown" / Array(10, Int16un),
+            "num_entries" / Int32un,
+            "current" / Int32un,
+            "prev" / Int32un,
+            "unknown3" / Int32un,
+        )
+        self.sub_directory_structure = Struct(
+            "pos" / Int32un,
+            "start" / Int32un,
+            "size" / Int32un,
+            "unknown" / Int32un,
+            "patient_id" / Int32un,
+            "study_id" / Int32un,
+            "series_id" / Int32un,
+            "slice_id" / Int32sn,
+            "unknown2" / Int16un,
+            "unknown3" / Int16un,
+            "type" / Int32un,
+            "unknown4" / Int32un,
+        )
+        self.chunk_structure = Struct(
+            "magic" / PaddedString(12, "ascii"),
+            "unknown" / Int32un,
+            "unknown2" / Int32un,
+            "pos" / Int32un,
+            "size" / Int32un,
+            "unknown3" / Int32un,
+            "patient_id" / Int32un,
+            "study_id" / Int32un,
+            "series_id" / Int32un,
+            "slice_id" / Int32sn,
+            "ind" / Int16un,
+            "unknown4" / Int16un,
+            "type" / Int32un,
+            "unknown5" / Int32un,
+        )
+        self.image_structure = Struct(
+            "size" / Int32un,
+            "type" / Int32un,
+            "unknown" / Int32un,
+            "width" / Int32un,
+            "height" / Int32un,
+        )
+        self.lat_structure = Struct(
+            "unknown" / PaddedString(14, "ascii"),
+            "laterality" / Int8un,
+            "unknown2" / Int8un,
+        )
+
+    def read_oct_volume(self):
+        """ Reads OCT data.
+
+            Returns:
+                obj:OCTVolumeWithMetaData
+        """
+        with open(self.filepath, "rb") as f:
+            raw = f.read(36)
+            header = self.header_structure.parse(raw)
+
+            raw = f.read(52)
+            main_directory = self.main_directory_structure.parse(raw)
+
+            # traverse list of main directories in first pass
+            directory_stack = []
+
+            current = main_directory.current
+            while current != 0:
+                directory_stack.append(current)
+                f.seek(current)
+                raw = f.read(52)
+                directory_chunk = self.main_directory_structure.parse(raw)
+                current = directory_chunk.prev
+
+            # traverse in second pass and  get all subdirectories
+            chunk_stack = []
+            volume_dict = {}
+            for position in directory_stack:
+                f.seek(position)
+                raw = f.read(52)
+                directory_chunk = self.main_directory_structure.parse(raw)
+
+                for ii in range(directory_chunk.num_entries):
+                    raw = f.read(44)
+                    chunk = self.sub_directory_structure.parse(raw)
+                    volume_string = f"{chunk.patient_id}_{chunk.study_id}_{chunk.series_id}"
+                    if volume_string not in volume_dict.keys():
+                        volume_dict[volume_string] = chunk.slice_id / 2
+                    elif chunk.slice_id / 2 > volume_dict[volume_string]:
+                        volume_dict[volume_string] = chunk.slice_id / 2
+
+                    if chunk.start > chunk.pos:
+                        chunk_stack.append([chunk.start, chunk.size])
+
+            # initalise dict to hold all the image volumes
+            volume_array_dict = {}
+            volume_array_dict_additional = (
+                {}
+            )  # for storage of slices not caught by extraction
+            for volume, num_slices in volume_dict.items():
+                if num_slices > 0:
+                    # num_slices + 1 here due to evidence that a slice was being missed off the end in extraction
+                    volume_array_dict[volume] = [0] * int(num_slices + 1)
+
+            # traverse all chunks and extract slices
+            for start, pos in chunk_stack:
+                f.seek(start)
+                raw = f.read(60)
+                chunk = self.chunk_structure.parse(raw)
+
+                if chunk.type == 11:  # laterality data
+                    raw = f.read(20)
+                    try:
+                        laterality_data = self.lat_structure.parse(raw)
+                        if laterality_data.laterality == 82:
+                            self.laterality = "R"
+                        elif laterality_data.laterality == 76:
+                            self.laterality = "L"
+                    except:
+                        self.laterality = None
+
+                if chunk.type == 1073741824:  # image data
+                    raw = f.read(20)
+                    image_data = self.image_structure.parse(raw)
+
+                    if chunk.ind == 1:  # oct data
+                        all_bits = [
+                            f.read(2)
+                            for i in range(
+                                image_data.height * image_data.width
+                            )
+                        ]
+                        raw_volume = list(
+                            map(self.read_custom_float, all_bits)
+                        )
+                        image = np.array(raw_volume).reshape(
+                            image_data.width, image_data.height
+                        )
+                        image = 256 * pow(image, 1.0 / 2.4)
+                        volume_string = f"{chunk.patient_id}_{chunk.study_id}_{chunk.series_id}"
+                        if volume_string in volume_array_dict.keys():
+                            volume_array_dict[volume_string][
+                                int(chunk.slice_id / 2) - 1
+                            ] = image
+                        else:
+                            # try to capture these additional images
+                            if (
+                                volume_string
+                                in volume_array_dict_additional.keys()
+                            ):
+                                volume_array_dict_additional[
+                                    volume_string
+                                ].append(image)
+                            else:
+                                volume_array_dict_additional[volume_string] = [
+                                    image
+                                ]
+                            # print('Failed to save image data for volume {}'.format(volume_string))
+
+            oct_volumes = []
+            for key, volume in volume_array_dict.items():
+                oct_volumes.append(
+                    OCTVolumeWithMetaData(
+                        volume=volume,
+                        patient_id=key,
+                        laterality=self.laterality,
+                    )
+                )
+            for key, volume in volume_array_dict_additional.items():
+                oct_volumes.append(
+                    OCTVolumeWithMetaData(
+                        volume=volume,
+                        patient_id=key,
+                        laterality=self.laterality,
+                    )
+                )
+
+        return oct_volumes
+
+    def read_fundus_image(self):
+        """ Reads fundus data.
+
+            Returns:
+                obj:FundusImageWithMetaData
+        """
+        with open(self.filepath, "rb") as f:
+            raw = f.read(36)
+            header = self.header_structure.parse(raw)
+
+            raw = f.read(52)
+            main_directory = self.main_directory_structure.parse(raw)
+
+            # traverse list of main directories in first pass
+            directory_stack = []
+
+            current = main_directory.current
+            while current != 0:
+                directory_stack.append(current)
+                f.seek(current)
+                raw = f.read(52)
+                directory_chunk = self.main_directory_structure.parse(raw)
+                current = directory_chunk.prev
+
+            # traverse in second pass and  get all subdirectories
+            chunk_stack = []
+            for position in directory_stack:
+                f.seek(position)
+                raw = f.read(52)
+                directory_chunk = self.main_directory_structure.parse(raw)
+
+                for ii in range(directory_chunk.num_entries):
+                    raw = f.read(44)
+                    chunk = self.sub_directory_structure.parse(raw)
+                    if chunk.start > chunk.pos:
+                        chunk_stack.append([chunk.start, chunk.size])
+
+            # initalise dict to hold all the image volumes
+            image_array_dict = {}
+
+            # traverse all chunks and extract slices
+            for start, pos in chunk_stack:
+                f.seek(start)
+                raw = f.read(60)
+                chunk = self.chunk_structure.parse(raw)
+
+                if chunk.type == 11:  # laterality data
+                    raw = f.read(20)
+                    try:
+                        laterality_data = self.lat_structure.parse(raw)
+                        if laterality_data.laterality == 82:
+                            self.laterality = "R"
+                        elif laterality_data.laterality == 76:
+                            self.laterality = "L"
+                    except:
+                        self.laterality = None
+
+                if chunk.type == 1073741824:  # image data
+                    raw = f.read(20)
+                    image_data = self.image_structure.parse(raw)
+
+                    if chunk.ind == 0:  # fundus data
+                        raw_volume = np.frombuffer(
+                            f.read(image_data.height * image_data.width),
+                            dtype=np.uint8,
+                        )
+                        image = np.array(raw_volume).reshape(
+                            image_data.height, image_data.width
+                        )
+                        image_string = f"{chunk.patient_id}_{chunk.study_id}_{chunk.series_id}"
+                        image_array_dict[image_string] = image
+
+            fundus_images = []
+            for key, image in image_array_dict.items():
+                fundus_images.append(
+                    FundusImageWithMetaData(
+                        image=image, patient_id=key, laterality=self.laterality
+                    )
+                )
+
+        return fundus_images
+
+    def read_custom_float(self, bytes):
+        """ Implementation of bespoke float type used in .e2e files.
+
+        Notes:
+            Custom float is a floating point type with no sign, 6-bit exponent, and 10-bit mantissa.
+
+        Args:
+            bytes (str): The two bytes.
+
+        Returns:
+            float
+        """
+        power = pow(2, 10)
+        # convert two bytes to 16-bit binary representation
+        bits = (
+            bin(bytes[0])[2:].zfill(8)[::-1] + bin(bytes[1])[2:].zfill(8)[::-1]
+        )
+
+        # get mantissa and exponent
+        mantissa = bits[:10]
+        exponent = bits[10:]
+
+        # convert to decimal representations
+        mantissa_sum = 1 + int(mantissa, 2) / power
+        exponent_sum = int(exponent[::-1], 2) - 63
+        decimal_value = mantissa_sum * pow(2, exponent_sum)
+        return decimal_value
diff --git a/tests/resources/oct/corrupt.fds b/tests/resources/oct/corrupt.fds
new file mode 100644
index 0000000..e69de29
diff --git a/tests/resources/oct/fda_minimized.fda b/tests/resources/oct/fda_minimized.fda
new file mode 100644
index 0000000..918469a
Binary files /dev/null and b/tests/resources/oct/fda_minimized.fda differ
diff --git a/tests/resources/oct/fds_minimized.fds b/tests/resources/oct/fds_minimized.fds
new file mode 100644
index 0000000..60f2cde
Binary files /dev/null and b/tests/resources/oct/fds_minimized.fds differ
diff --git a/tests/test_default_image_builders.py b/tests/test_default_image_builders.py
index 65c86b1..1ace602 100644
--- a/tests/test_default_image_builders.py
+++ b/tests/test_default_image_builders.py
@@ -1,7 +1,7 @@
 import pytest
 
 from panimg.exceptions import UnconsumedFilesException
-from panimg.image_builders import DEFAULT_IMAGE_BUILDERS
+from panimg.image_builders import DEFAULT_IMAGE_BUILDERS, image_builder_oct
 from panimg.panimg import _build_files
 from tests import RESOURCE_PATH
 
@@ -18,6 +18,10 @@
     ],
     "image_builder_mhd": ["Mhd image builder: Not an ITK file"],
     "image_builder_nifti": ["NifTI image builder: Not a NifTI image file"],
+    "image_builder_oct": [
+        "OCT image builder: Not a valid OCT file "
+        "(supported formats: .fds,.fda,.e2e)",
+    ],
     "image_builder_tiff": [
         "Could not open file with tifffile.",
         "Could not open file with OpenSlide.",
@@ -40,21 +44,26 @@ def test_image_builder_raises_unconsumed_file_exception(tmp_path, builder):
 
 
 @pytest.mark.parametrize("builder", DEFAULT_IMAGE_BUILDERS)
-def test_one_image_consumed_by_each_builder(tmp_path, builder):
-    # Each of these images should only be consumed by one builder
-    # The other files should error out
+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 / "test_rgb.png",
         RESOURCE_PATH / "image10x10x10.mha",
         RESOURCE_PATH / "image10x11x12.nii.gz",
         RESOURCE_PATH / "valid_tiff.tif",
+        RESOURCE_PATH / "oct/fda_minimized.fda",
+        RESOURCE_PATH / "oct/fds_minimized.fds",
     }
 
     result = _build_files(
         builder=builder, files=files, output_directory=tmp_path
     )
 
-    assert len(result.new_images) == 1
+    if image_builder_oct == builder:
+        assert len(result.new_images) == 2
+    else:
+        assert len(result.new_images) == 1
     assert len(result.consumed_files) == len(files) - len(result.file_errors)
     assert result.consumed_files | {*result.file_errors} == files
diff --git a/tests/test_oct.py b/tests/test_oct.py
new file mode 100644
index 0000000..342f361
--- /dev/null
+++ b/tests/test_oct.py
@@ -0,0 +1,116 @@
+import os
+import shutil
+from pathlib import Path
+from tempfile import TemporaryDirectory
+
+import pytest
+
+from panimg.image_builders.oct import (
+    format_error,
+    image_builder_oct,
+)
+from panimg.panimg import _build_files
+from tests import RESOURCE_PATH
+
+
+@pytest.mark.parametrize(
+    "src,expected_fundus_properties,expected_oct_properties",
+    (
+        (
+            # Minimized .fda OCT file was created by taking example OCT file at
+            # biobank.ndph.ox.ac.uk/showcase/showcase/examples/eg_oct_fda.fda
+            # and downsizing:
+            #   - OCT volume (@IMG_JPEG) to 512x650x1 J2C encoded,
+            #   - Fundus (@IMG_FUNDUS) to 2x3x1 J2C encoded
+            RESOURCE_PATH / "oct/fda_minimized.fda",
+            {
+                "width": 2,
+                "height": 3,
+                "depth": None,
+                "voxel_width_mm": None,
+                "voxel_height_mm": None,
+                "voxel_depth_mm": None,
+                "eye_choice": "U",
+            },
+            {
+                "width": 512,
+                "height": 650,
+                "depth": 1,
+                "voxel_width_mm": 0.01171875,
+                "voxel_height_mm": 0.0035,
+                "voxel_depth_mm": 6,
+                "eye_choice": "U",
+            },
+        ),
+        (
+            # Minimized .fds OCT file was created by taking example OCT file at
+            # biobank.ndph.ox.ac.uk/showcase/showcase/examples/eg_oct_fds.fds
+            # and downsizing:
+            #   - OCT volume (@IMG_SCAN_03) to 2x3x4 16bit voxels,
+            #   - OBS (@IMG_OBS) scan to 2x3x1 24bit voxels,
+            #   - MOT_COMP (@IMG_MOT_COMP_03) to 2x3x4 16bit voxels,
+            #   - TRC (@IMG_TRC_02) to 2x3x4 24bit voxels
+            RESOURCE_PATH / "oct/fds_minimized.fds",
+            {
+                "width": 2,
+                "height": 3,
+                "depth": None,
+                "voxel_width_mm": None,
+                "voxel_height_mm": None,
+                "voxel_depth_mm": None,
+                "eye_choice": "U",
+            },
+            {
+                "width": 2,
+                "height": 3,
+                "depth": 4,
+                "voxel_width_mm": 3.0,
+                "voxel_height_mm": 0.0035,
+                "voxel_depth_mm": 1.5,
+                "eye_choice": "U",
+            },
+        ),
+    ),
+)
+def test_image_builder_oct(
+    tmpdir, src, expected_fundus_properties, expected_oct_properties
+):
+    dest = Path(tmpdir) / src.name
+    shutil.copy(str(src), str(dest))
+    files = {Path(d[0]).joinpath(f) for d in os.walk(tmpdir) for f in d[2]}
+    with TemporaryDirectory() as output:
+        result = _build_files(
+            builder=image_builder_oct, files=files, output_directory=output,
+        )
+
+    assert result.consumed_files == {dest}
+    assert len(result.new_images) == 1
+    for result in result.new_images:
+        expected_values = expected_oct_properties
+        if "fundus" in result.name:
+            # expected_values = expected_fundus_properties
+            continue  # Skip fundus_images for now
+
+        for k, v in expected_values.items():
+            assert getattr(result, k) == v
+
+
+def test_image_builder_oct_corrupt_file(tmpdir):
+    src = RESOURCE_PATH / "oct/corrupt.fds"
+    dest = Path(tmpdir) / src.name
+    shutil.copy(str(src), str(dest))
+
+    files = {Path(d[0]).joinpath(f) for d in os.walk(tmpdir) for f in d[2]}
+    with TemporaryDirectory() as output:
+        result = _build_files(
+            builder=image_builder_oct, files=files, output_directory=output,
+        )
+
+    assert result.file_errors == {
+        dest: [
+            format_error(
+                "Not a valid OCT file " "(supported formats: .fds,.fda,.e2e)"
+            )
+        ],
+    }
+    assert result.consumed_files == set()