Repository: SINTEF/dlite (language: python)

Issue:
Copy image storage plugin from TEM example to default plugins
The image plugin in the TEM demo is general enough to be useful in many other settings.

Make it a built-in plugin by moving it to `storages/python/python-storage-plugins/`. Remember to move the test together with it (should go to `storages/python/test-python/`).



Discussion / hints:

Fix patch:
diff --git a/storages/python/CMakeLists.txt b/storages/python/CMakeLists.txt
index 75d7cec50..17a8a6f94 100644
--- a/storages/python/CMakeLists.txt
+++ b/storages/python/CMakeLists.txt
@@ -55,6 +55,7 @@ install(
 install(
   FILES
     python-storage-plugins/blob.json
+    python-storage-plugins/Image.json
   DESTINATION share/dlite/storages
   )
 
diff --git a/storages/python/python-storage-plugins/Image.json b/storages/python/python-storage-plugins/Image.json
new file mode 100644
index 000000000..5fb7b5761
--- /dev/null
+++ b/storages/python/python-storage-plugins/Image.json
@@ -0,0 +1,20 @@
+{
+  "uri": "http://onto-ns.com/meta/0.1/Image",
+  "description": "Metadata for an image.",
+  "dimensions": {
+    "height": "Image hight",
+    "width": "Image width.",
+    "channels": "Number of channels in each pixel (1 for gray-scale, 3 for RGB and 4 for RGBA)."
+  },
+  "properties": {
+    "filename": {
+      "type": "string",
+      "description": "File name."
+    },
+    "data": {
+      "type": "float32",
+      "shape": ["height", "width", "channels"],
+      "description": "Image data."
+    }
+  }
+}
diff --git a/storages/python/python-storage-plugins/image.py b/storages/python/python-storage-plugins/image.py
new file mode 100644
index 000000000..17b27a962
--- /dev/null
+++ b/storages/python/python-storage-plugins/image.py
@@ -0,0 +1,117 @@
+"""DLite storage plugin for images."""
+import numpy as np
+
+from skimage.io import imread, imsave
+from skimage.exposure import equalize_hist
+from skimage.transform import resize
+
+import dlite
+from dlite.options import Options
+
+
+class image(dlite.DLiteStorageBase):
+    """DLite storage plugin for images.
+
+    Arguments:
+        location: Path to YAML file.
+        options: Supported options:
+            - `plugin`: Name of scikit image io plugin to use for loading
+              the image.  By default, the different plugins are tried
+              (starting with imageio) until a suitable candidate is found.
+              If not given and fname is a tiff file, the 'tifffile' plugin
+              will be used.
+            - `crop`: Crop out a part of the image.  Applied before other
+              operations.  Specified as comma-separated set of ranges
+              to crop out for each dimension. Example: for a 2D image will
+              ":,50:150" keep the hight, but reduce the width to 100 pixes
+              starting from x=50.
+            - `as_gray`: Whether to convert color images to gray-scale
+              when loading.  Default is false.
+            - `equalize`: Whether to equalize histogram before saving.
+              Default is false.
+            - `resize`: Required image size when saving.  Should be given
+              as `HEIGHTxWIDTH` (ex: "256x128").
+            - `order`: Order of spline interpolation for resize. Default
+              to zero for binary images and 1 otherwise.  Should be in the
+              range 0-5.
+    """
+    meta = "http://onto-ns.com/meta/0.1/Image"
+
+    def open(self, location, options=None):
+        """Loads an image from `location`.  No options are supported."""
+        self.location = location
+        self.options = Options(
+            options, defaults="as_gray=false;equalize=false"
+        )
+
+    def load(self, id=None):
+        """Returns TEMImage instance."""
+        as_gray = dlite.asbool(self.options.as_gray)
+        data = imread(
+            self.location, as_gray=as_gray, plugin=self.options.get("plugin"),
+        )
+
+        crop = self.options.get("crop")
+        if crop:
+            # We call __getitem__() explicitly, since the preferred syntax
+            # `data[*toindex(crop)]` is not supported by Python 3.7
+            data = data.__getitem__(*toindex(crop))
+
+        # Infer dimensions
+        shape = [1]*3
+        shape[3 - len(data.shape):] = data.shape
+        dimnames = "height", "width", "channels"
+        dimensions = dict(zip(dimnames, shape))
+
+        # Create and populate DLite instance
+        Image = dlite.get_instance(self.meta)
+        image = Image(dimensions=dimensions)
+        image.filename = self.location
+        image.data = np.array(data, ndmin=3)
+
+        return image
+
+    def save(self, inst):
+        """Stores DLite instance `inst` to storage."""
+        if dlite.asbool(self.options.equalize):
+            data = equalize_hist(inst.data)
+        else:
+            data = inst.data
+
+        if data.shape[0] == 1:
+            data = data[0,:,:]
+
+        crop = self.options.get("crop")
+        if crop:
+            # We call __getitem__() explicitly, since the preferred syntax
+            # `data[*toindex(crop)]` is not supported by Python 3.7
+            data = data.__getitem__(toindex(crop))
+
+        if self.options.get("resize"):
+            size = [int(s) for s in self.options.resize.split("x")]
+            shape = list(data.shape)
+            shape[:len(size)] = size
+            kw = {}
+            if self.options.get("order"):
+                kw["order"] = int(self.options.order)
+            data = resize(data, output_shape=shape, **kw)
+
+        hi, lo = data.max(), data.min()
+        scaled = np.uint8((data - lo)/(hi - lo + 1e-3)*256)
+        imsave(
+            self.location, scaled, plugin=self.options.get("plugin")
+        )
+
+
+def toindex(crop_string):
+    """Convert a crop string to a valid numpy index.
+
+    Example usage: `arr[*toindex(":,50:150")]`
+    """
+    ranges = []
+    for dim in crop_string.split(","):
+        if dim == ":":
+            ranges.append(slice(None))
+        else:
+            ranges.append(slice(*[int(d) for d in dim.split(":")]))
+    return tuple(ranges)


Test patch:
diff --git a/storages/python/tests-python/CMakeLists.txt b/storages/python/tests-python/CMakeLists.txt
index 80d4ad473..7cc78d8be 100644
--- a/storages/python/tests-python/CMakeLists.txt
+++ b/storages/python/tests-python/CMakeLists.txt
@@ -12,8 +12,13 @@ set(python-tests
   test_template
   test_template-jinja
   test_http
+  test_image
   )
 
+# Paths
+set(plugindir "${CMAKE_CURRENT_SOURCE_DIR}/../python-storage-plugins")
+
+
 include(FindPythonModule)
 
 # Disable BSON test if pymongo is not installed
@@ -43,6 +48,7 @@ if(NOT PY_YAML)
   list(REMOVE_ITEM python-tests test_yaml_storage_python)
 endif()
 
+
 # Add tests
 foreach(test ${python-tests})
   add_test(
@@ -62,6 +68,10 @@ foreach(test ${python-tests})
   endif()
   set_property(TEST ${test} APPEND PROPERTY
     ENVIRONMENT "PYTHONPATH=${dlite_PYTHONPATH_NATIVE}")
+  set_property(TEST ${test} APPEND PROPERTY
+    ENVIRONMENT "DLITE_STORAGES=${plugindir}/*.json")
+  set_property(TEST ${test} APPEND PROPERTY
+    ENVIRONMENT "DLITE_PYDEBUG=")
   set_property(TEST ${test} APPEND PROPERTY
     ENVIRONMENT "DLITE_USE_BUILD_ROOT=YES")
   if (NOT WIN32)
diff --git a/storages/python/tests-python/input/image.png b/storages/python/tests-python/input/image.png
new file mode 100644
index 000000000..252391fc9
Binary files /dev/null and b/storages/python/tests-python/input/image.png differ
diff --git a/storages/python/tests-python/output/.gitignore b/storages/python/tests-python/output/.gitignore
new file mode 100644
index 000000000..8962d0434
--- /dev/null
+++ b/storages/python/tests-python/output/.gitignore
@@ -0,0 +1,9 @@
+*.json
+*.yaml
+*.bson
+*.csv
+*.xlsx
+*.rdf
+*.ttl
+*.txt
+*.png
diff --git a/storages/python/tests-python/test_image.py b/storages/python/tests-python/test_image.py
new file mode 100644
index 000000000..3b853419a
--- /dev/null
+++ b/storages/python/tests-python/test_image.py
@@ -0,0 +1,47 @@
+from pathlib import Path
+
+import dlite
+
+
+thisdir = Path(__file__).absolute().parent
+indir = thisdir / 'input'
+outdir = thisdir / 'output'
+#entitydir = thisdir.parent / "python-storage-plugins"
+#dlite.storage_path.append(entitydir)
+
+# Test save
+image = dlite.Instance.from_location("image", indir / "image.png")
+image.save("image", outdir / "image.png")
+image.save("image", outdir / "image-crop.png", "crop=60:120,60:120")
+image.save("image", outdir / "image-cropy.png", "crop=60:120")
+image.save("image", outdir / "image-eq.png", "equalize=true")
+image.save("image", outdir / "image-resize.png", "resize=128x128")
+image.save("image", outdir / "image-resize2.png", "resize=512x512")
+image.save("image", outdir / "image-resize3.png", "resize=512x512;order=3")
+
+assert image.filename == str(indir / "image.png")
+assert image.data.shape == (256, 256, 4)
+
+# Test load
+im = dlite.Instance.from_location("image", outdir / "image.png")
+assert im.data.shape == image.data.shape
+assert np.all(im.data == image.data)
+assert Path(im.filename).name == Path(image.filename).name
+
+im = dlite.Instance.from_location("image", outdir / "image-crop.png")
+assert im.data.shape == (60, 60, 4)
+
+im = dlite.Instance.from_location("image", outdir / "image-cropy.png")
+assert im.data.shape == (60, 256, 4)
+
+im = dlite.Instance.from_location("image", outdir / "image-eq.png")
+assert im.data.shape == (256, 256, 4)
+
+im = dlite.Instance.from_location("image", outdir / "image-resize.png")
+assert im.data.shape == (128, 128, 4)
+
+im = dlite.Instance.from_location("image", outdir / "image-resize2.png")
+assert im.data.shape == (512, 512, 4)
+
+im = dlite.Instance.from_location("image", outdir / "image-resize3.png")
+assert im.data.shape == (512, 512, 4)