Repository: rapidsai/cucim (language: python)

Issue:
[BUG] incorrect spacing information from CuImage compared to OpenSlide
**Describe the bug**
It might be the case that the spacing information returned from CuImage is incorrect and always returns 1.0

**Steps/Code to reproduce bug**
I encountered this with multiple tif images with different spacings, but for example, with this tif file: https://drive.google.com/file/d/1i7Wq4HAxaoGezREmxAE7xSFj2FjQMhSw/view?usp=sharing 

```python
from cucim import CuImage
from openslide import OpenSlide

cucim_img = CuImage('./TCGA-18-3406-01Z-00-DX1_tissue.tif')
openslide_img = OpenSlide('./TCGA-18-3406-01Z-00-DX1_tissue.tif')

print("cucim spacing units =", cucim_img.spacing_units()[:2])
print("spacings cucim (um) =", cucim_img.spacing()[:2])
print("-----")
print('openslide spacing units = ', [openslide_img.properties["tiff.ResolutionUnit"], openslide_img.properties["tiff.ResolutionUnit"]])
scaling_centimeter_to_micro_meter = 10000
openslide_x_spacing_um =  scaling_centimeter_to_micro_meter/float(openslide_img.properties["tiff.XResolution"])
openslide_y_spacing_um =  scaling_centimeter_to_micro_meter/float(openslide_img.properties["tiff.YResolution"])
print("spacings openslide (um) =", [openslide_x_spacing_um,openslide_x_spacing_um])
```
Output:
```bash

cucim spacing units = ['micrometer', 'micrometer']
spacings cucim (um) = [1.0, 1.0]
-----
openslide spacing units =  ['centimeter', 'centimeter']
spacings openslide (um) = [2.0159943965300906, 2.0159943965300906]
```

**Expected behavior**
expected behavior is that cucim returns same spacing information as openslide.

**Environment details (please complete the following information):**
 - Environment location: Docker
 - Method of cuCIM install: pip3 install cupy-cuda111
Update README.md and notebooks
This patch addresses #2 

- Add GTC session and notebooks information to README.md
- Update Welcome notebook to include existing notebooks
- Remove `notebooks/Single-process_Tests.ipynb`
- Update existing notebooks, assuming that cuCIM is already installed

<!--

Thank you for contributing to ___PROJECT___ :)

Here are some guidelines to help the review process go smoothly.

1. Please write a description in this text box of the changes that are being
   made.

2. Please ensure that you have written units tests for the changes made/features
   added.

3. If you are closing an issue please use one of the automatic closing words as
   noted here: https://help.github.com/articles/closing-issues-using-keywords/

4. If your pull request is not ready for review but you want to make use of the
   continuous integration testing facilities please label it with `[WIP]`.

5. If your pull request is ready to be reviewed without requiring additional
   work on top of it, then remove the `[WIP]` label (if present) and replace
   it with `[REVIEW]`. If assistance is required to complete the functionality,
   for example when the C/C++ code of a feature is complete but Python bindings
   are still required, then add the label `[HELP-REQ]` so that others can triage
   and assist. The additional changes then can be implemented on top of the
   same PR. If the assistance is done by members of the rapidsAI team, then no
   additional actions are required by the creator of the original PR for this,
   otherwise the original author of the PR needs to give permission to the
   person(s) assisting to commit to their personal fork of the project. If that
   doesn't happen then a new PR based on the code of the original PR can be
   opened by the person assisting, which then will be the PR that will be
   merged.

6. Once all work has been done and review has taken place please do not add
   features or make changes out of the scope of those requested by the reviewer
   (doing this just add delays as already reviewed code ends up having to be
   re-reviewed/it is hard to tell what is new etc!). Further, please do not
   rebase your branch on main/force push/rewrite history, doing any of these
   causes the context of any comments made by reviewers to be lost. If
   conflicts occur against main they should be resolved by merging main
   into the branch used for making the pull request.

Many thanks in advance for your cooperation!

-->



Discussion / hints:

@jakirkham It seems that `branch-0.20` is already created and it is a default branch.

Does it mean that we cannot push to `branch-0.19` branch anymore?
@gigony , that's correct.  RAPIDS is currently in code freeze and new changes are being made to the next version, in this case, 0.20. Do you think these changes are particularly important to get in to 0.19 ?  
> @gigony , that's correct. RAPIDS is currently in code freeze and new changes are being made to the next version, in this case, 0.20. Do you think these changes are particularly important to get in to 0.19 ?

@quasiben, oh, I didn't expect the code freeze happens that early.

This particular change is related to the notebooks and README.md file that the user would see on the Github page.
Since now `branch-0.20` is default, the user would see changes in `branch-0.20` so I think it is okay to merge this only into `branch-0.20` branch.


BTW, in the current code, GDS feature is disabled (it is working in compatibility mode) so, for `branch-0.19.0`, I wanted to 
address https://github.com/rapidsai/cucim/issues/3 once the public GDS package is available (it is supposed to release today).

If RAPIDS team as a whole doesn't want to change the current v0.19.0 branch, I think it is okay to release v0.19 as is because changing code can make the current build unstable.

We can enable GDS feature through nightly build and/or PyPI package (as 0.19.x patches).





RAPIDS has code freeze for a few days then release:
https://docs.rapids.ai/maintainers

We can make exceptions and since cuCIM is not a dependency we _can_ be a bit more liberal but if it's not critical is best to push to the next release.  Also, for every PR we merge a new conda package is created.  So when issue #3 is resolve, we will have a conda package in the **nightly** channel.
Thanks @quasiben for the clarification!

Yes, I also think we can push change with GDS feature into the next release.

And, I think this PR can be merged into `branch-0.20` branch.
(I would add some additional change soon to README.md as for using nightly build.)


@rapidsai/ops can you merge in this PR
rerun tests
> @rapidsai/ops can you merge in this PR

@quasiben, I added the necessary webhooks to get CI to run on this repo. It looks like there are builds happening on this PR now. you can post the standard `@gpucibot merge` command on this PR to schedule it to be merged after CI has completed. Let me know if you experience any issues
@gigony apologies, I changed the README slightly to show only the support CUDA versions.  Currently, there are no CUDA 11.1 
Thanks @ajschmidt8 

@gpucibot merge
> Thanks @ajschmidt8
> 
> @gpucibot merge

no problem! however, the comment can't contain anything other than the merge command: `@gpucibot merge`
@gpucibot merge

Fix patch:
diff --git a/cpp/plugins/cucim.kit.cuslide/src/cuslide/cuslide.cpp b/cpp/plugins/cucim.kit.cuslide/src/cuslide/cuslide.cpp
index b284e0f71..0b7cbeb21 100644
--- a/cpp/plugins/cucim.kit.cuslide/src/cuslide/cuslide.cpp
+++ b/cpp/plugins/cucim.kit.cuslide/src/cuslide/cuslide.cpp
@@ -171,16 +171,46 @@ static bool CUCIM_ABI parser_parse(CuCIMFileHandle_ptr handle_ptr, cucim::io::fo
         channel_names.emplace_back(std::string_view{ "A" });
     }
 
-    // TODO: Set correct spacing value
+    // Spacing units
+    std::pmr::vector<std::string_view> spacing_units(&resource);
+    spacing_units.reserve(ndim);
+
     std::pmr::vector<float> spacing(&resource);
     spacing.reserve(ndim);
-    spacing.insert(spacing.end(), ndim, 1.0);
+    const auto resolution_unit = level0_ifd->resolution_unit();
+    const auto x_resolution = level0_ifd->x_resolution();
+    const auto y_resolution = level0_ifd->y_resolution();
+
+    switch (resolution_unit)
+    {
+    case 1: // no absolute unit of measurement
+        spacing.emplace_back(y_resolution);
+        spacing.emplace_back(x_resolution);
+        spacing.emplace_back(1.0f);
+
+        spacing_units.emplace_back(std::string_view{ "" });
+        spacing_units.emplace_back(std::string_view{ "" });
+        break;
+    case 2: // inch
+        spacing.emplace_back(y_resolution != 0 ? 25400 / y_resolution : 1.0f);
+        spacing.emplace_back(x_resolution != 0 ? 25400 / x_resolution : 1.0f);
+        spacing.emplace_back(1.0f);
+
+        spacing_units.emplace_back(std::string_view{ "micrometer" });
+        spacing_units.emplace_back(std::string_view{ "micrometer" });
+        break;
+    case 3: // centimeter
+        spacing.emplace_back(y_resolution != 0 ? 10000 / y_resolution : 1.0f);
+        spacing.emplace_back(x_resolution != 0 ? 10000 / x_resolution : 1.0f);
+        spacing.emplace_back(1.0f);
+
+        spacing_units.emplace_back(std::string_view{ "micrometer" });
+        spacing_units.emplace_back(std::string_view{ "micrometer" });
+        break;
+    default:
+        spacing.insert(spacing.end(), ndim, 1.0f);
+    }
 
-    // TODO: Set correct spacing units
-    std::pmr::vector<std::string_view> spacing_units(&resource);
-    spacing_units.reserve(ndim);
-    spacing_units.emplace_back(std::string_view{ "micrometer" });
-    spacing_units.emplace_back(std::string_view{ "micrometer" });
     spacing_units.emplace_back(std::string_view{ "color" });
 
     std::pmr::vector<float> origin({ 0.0, 0.0, 0.0 }, &resource);
diff --git a/cpp/plugins/cucim.kit.cuslide/src/cuslide/tiff/ifd.cpp b/cpp/plugins/cucim.kit.cuslide/src/cuslide/tiff/ifd.cpp
index 52a66cd80..9bcacb25d 100644
--- a/cpp/plugins/cucim.kit.cuslide/src/cuslide/tiff/ifd.cpp
+++ b/cpp/plugins/cucim.kit.cuslide/src/cuslide/tiff/ifd.cpp
@@ -63,6 +63,9 @@ IFD::IFD(TIFF* tiff, uint16_t index, ifd_offset_t offset) : tiff_(tiff), ifd_ind
     model_ = std::string(model_char_ptr ? model_char_ptr : "");
     TIFFGetField(tif, TIFFTAG_IMAGEDESCRIPTION, &model_char_ptr);
     image_description_ = std::string(model_char_ptr ? model_char_ptr : "");
+    TIFFGetField(tif, TIFFTAG_RESOLUTIONUNIT, &resolution_unit_);
+    TIFFGetField(tif, TIFFTAG_XRESOLUTION, &x_resolution_);
+    TIFFGetField(tif, TIFFTAG_YRESOLUTION, &y_resolution_);
 
     TIFFDirectory& tif_dir = tif->tif_dir;
     flags_ = tif->tif_flags;
@@ -451,6 +454,18 @@ std::string& IFD::image_description()
 {
     return image_description_;
 }
+uint16_t IFD::resolution_unit() const
+{
+    return resolution_unit_;
+}
+float IFD::x_resolution() const
+{
+    return x_resolution_;
+}
+float IFD::y_resolution() const
+{
+    return y_resolution_;
+}
 uint32_t IFD::width() const
 {
     return width_;
diff --git a/cpp/plugins/cucim.kit.cuslide/src/cuslide/tiff/ifd.h b/cpp/plugins/cucim.kit.cuslide/src/cuslide/tiff/ifd.h
index e15724782..5737d82d5 100644
--- a/cpp/plugins/cucim.kit.cuslide/src/cuslide/tiff/ifd.h
+++ b/cpp/plugins/cucim.kit.cuslide/src/cuslide/tiff/ifd.h
@@ -72,6 +72,9 @@ class EXPORT_VISIBLE IFD : public std::enable_shared_from_this<IFD>
     std::string& software();
     std::string& model();
     std::string& image_description();
+    uint16_t resolution_unit() const;
+    float x_resolution() const;
+    float y_resolution() const;
     uint32_t width() const;
     uint32_t height() const;
     uint32_t tile_width() const;
@@ -109,6 +112,10 @@ class EXPORT_VISIBLE IFD : public std::enable_shared_from_this<IFD>
     std::string software_;
     std::string model_;
     std::string image_description_;
+    uint16_t resolution_unit_ = 1; // 1 = No absolute unit of measurement, 2 = Inch, 3 = Centimeter
+    float x_resolution_ = 1.0f;
+    float y_resolution_ = 1.0f;
+
     uint32_t flags_ = 0;
     uint32_t width_ = 0;
     uint32_t height_ = 0;
diff --git a/cpp/plugins/cucim.kit.cuslide/src/cuslide/tiff/tiff.cpp b/cpp/plugins/cucim.kit.cuslide/src/cuslide/tiff/tiff.cpp
index cf9d38e3e..1f1ac6ab6 100644
--- a/cpp/plugins/cucim.kit.cuslide/src/cuslide/tiff/tiff.cpp
+++ b/cpp/plugins/cucim.kit.cuslide/src/cuslide/tiff/tiff.cpp
@@ -387,6 +387,9 @@ void TIFF::resolve_vendor_format()
     auto& first_ifd = ifds_[0];
     std::string& model = first_ifd->model();
     std::string& software = first_ifd->software();
+    const uint16_t resolution_unit = first_ifd->resolution_unit();
+    const float x_resolution = first_ifd->x_resolution();
+    const float y_resolution = first_ifd->y_resolution();
 
     // Detect Aperio SVS format
     {
@@ -416,6 +419,20 @@ void TIFF::resolve_vendor_format()
 
         tiff_metadata.emplace("model", model);
         tiff_metadata.emplace("software", software);
+        switch (resolution_unit)
+        {
+        case 2:
+            tiff_metadata.emplace("resolution_unit", "inch");
+            break;
+        case 3:
+            tiff_metadata.emplace("resolution_unit", "centimeter");
+            break;
+        default:
+            tiff_metadata.emplace("resolution_unit", "");
+            break;
+        }
+        tiff_metadata.emplace("x_resolution", x_resolution);
+        tiff_metadata.emplace("y_resolution", y_resolution);
 
         (*json_metadata).emplace("tiff", std::move(tiff_metadata));
     }
diff --git a/cpp/src/cuimage.cpp b/cpp/src/cuimage.cpp
index 3ba8fc18b..d9c0ee4dc 100644
--- a/cpp/src/cuimage.cpp
+++ b/cpp/src/cuimage.cpp
@@ -680,10 +680,11 @@ CuImage CuImage::read_region(std::vector<int64_t>&& location,
         location.emplace_back(0);
         location.emplace_back(0);
     }
+
+    const ResolutionInfo& res_info = resolutions();
     // If `size` is not specified, size would be (width, height) of the image at the specified `level`.
     if (size.empty())
     {
-        const ResolutionInfo& res_info = resolutions();
         const auto level_count = res_info.level_count();
         if (level_count == 0)
         {
@@ -853,19 +854,36 @@ CuImage CuImage::read_region(std::vector<int64_t>&& location,
         // The first dimension is for 'batch' ('N')
         spacing_units.emplace_back(std::string_view{ "batch" });
     }
+    const auto& level_downsample = res_info.level_downsample(level);
     for (; index < ndim; ++index)
     {
-        int64_t dim_char = dim_indices_.index(dims[index]);
+        int64_t dim_index = dim_indices_.index(dims[index]);
+        if (dim_index < 0)
+        {
+            throw std::runtime_error(fmt::format("[Error] Invalid dimension name: {}", dims[index]));
+        }
 
-        const char* str_ptr = image_metadata_->spacing_units[dim_char];
-        size_t str_len = strlen(image_metadata_->spacing_units[dim_char]);
+        const char* str_ptr = image_metadata_->spacing_units[dim_index];
+        size_t str_len = strlen(image_metadata_->spacing_units[dim_index]);
 
         char* spacing_unit = static_cast<char*>(resource.allocate(str_len + 1));
         memcpy(spacing_unit, str_ptr, str_len);
         spacing_unit[str_len] = '\0';
-        // std::pmr::string spacing_unit{ image_metadata_->spacing_units[dim_char], &resource };
+        // std::pmr::string spacing_unit{ image_metadata_->spacing_units[dim_index], &resource };
 
         spacing_units.emplace_back(std::string_view{ spacing_unit });
+
+        // Update spacing based on level_downsample
+        char dim_char = image_metadata_->dims[dim_index];
+        switch (dim_char)
+        {
+        case 'X':
+        case 'Y':
+            spacing[index] /= level_downsample;
+            break;
+        default:
+            break;
+        }
     }
 
     std::pmr::vector<float> origin(&resource);
diff --git a/run b/run
index 8033eaf18..6fe8ce919 100755
--- a/run
+++ b/run
@@ -806,7 +806,9 @@ test() {
 
 install_python_test_deps_() {
     if [ -n "${CONDA_PREFIX}" ]; then
-        run_command conda install -c conda-forge -y \
+        # https://github.com/rapidsai/cucim/pull/349#issuecomment-1203335731
+        # Do not update or change already-installed dependencies.
+        run_command conda install -c conda-forge -y --freeze-installed \
             --file ${TOP}/python/cucim/requirements-test.txt
     else
         if [ -n "${VIRTUAL_ENV}" ]; then


Test patch:
diff --git a/python/cucim/requirements-test.txt b/python/cucim/requirements-test.txt
index ffdcf7092..6d62d5a29 100644
--- a/python/cucim/requirements-test.txt
+++ b/python/cucim/requirements-test.txt
@@ -1,8 +1,8 @@
-GPUtil==1.4.0
-imagecodecs==2021.6.8
-openslide-python==1.1.2
-psutil==5.8.0
-pytest==6.2.4
-pytest-cov==2.12.1
-pytest-lazy-fixture==0.6.3
-tifffile==2021.7.2
+GPUtil>=1.4.0
+imagecodecs>=2021.6.8
+openslide-python>=1.1.2
+psutil>=5.8.0
+pytest>=6.2.4
+pytest-cov>=2.12.1
+pytest-lazy-fixture>=0.6.3
+tifffile>=2022.7.28
diff --git a/python/cucim/tests/fixtures/testimage.py b/python/cucim/tests/fixtures/testimage.py
index c50b8eb6f..343698676 100644
--- a/python/cucim/tests/fixtures/testimage.py
+++ b/python/cucim/tests/fixtures/testimage.py
@@ -21,9 +21,9 @@
 from ..util.gen_image import ImageGenerator
 
 
-def gen_image(tmpdir_factory, recipe):
+def gen_image(tmpdir_factory, recipe, resolution=None):
     dataset_path = tmpdir_factory.mktemp('datasets').strpath
-    dataset_gen = ImageGenerator(dataset_path, [recipe])
+    dataset_gen = ImageGenerator(dataset_path, [recipe], [resolution])
     image_path = dataset_gen.gen()
     return (dataset_path, image_path[0])
 
@@ -63,9 +63,8 @@ def testimg_tiff_stripe_32x24_16_raw(tmpdir_factory):
 def testimg_tiff_stripe_32x24_16(request):
     return request.param
 
-# tiff_stripe_4096x4096_256
-
 
+# tiff_stripe_4096x4096_256
 @pytest.fixture(scope='session')
 def testimg_tiff_stripe_4096x4096_256_jpeg(tmpdir_factory):
     dataset_path, image_path = gen_image(
@@ -137,3 +136,45 @@ def testimg_tiff_stripe_100000x100000_256_raw(tmpdir_factory):
 ])
 def testimg_tiff_stripe_100000x100000_256(request):
     return request.param
+
+
+# testimg_tiff_stripe_4096_4096_256_jpeg_resolution
+@pytest.fixture(scope='session')
+def testimg_tiff_stripe_4096_4096_256_jpeg_resolution_3_5_centimeter(
+        tmpdir_factory):
+    resolution = (0.3, 0.5, "CENTIMETER")
+    dataset_path, image_path = gen_image(
+        tmpdir_factory, 'tiff::stripe:4096x4096:256:jpeg', resolution)
+    yield image_path, resolution
+    # Clean up fake dataset folder
+    shutil.rmtree(dataset_path)
+
+
+@pytest.fixture(scope='session')
+def testimg_tiff_stripe_4096_4096_256_jpeg_resolution_4_7_inch(tmpdir_factory):
+    resolution = (0.4, 0.7, "INCH")
+    dataset_path, image_path = gen_image(
+        tmpdir_factory, 'tiff::stripe:4096x4096:256:jpeg', resolution)
+    yield image_path, resolution
+    # Clean up fake dataset folder
+    shutil.rmtree(dataset_path)
+
+
+@pytest.fixture(scope='session')
+def testimg_tiff_stripe_4096_4096_256_jpeg_resolution_9_1_none(tmpdir_factory):
+    resolution = (9, 1, "NONE")
+    dataset_path, image_path = gen_image(
+        tmpdir_factory, 'tiff::stripe:4096x4096:256:jpeg', resolution)
+    yield image_path, resolution
+    # Clean up fake dataset folder
+    shutil.rmtree(dataset_path)
+
+
+@pytest.fixture(scope='session', params=[
+    lazy_fixture(
+        'testimg_tiff_stripe_4096_4096_256_jpeg_resolution_3_5_centimeter'),
+    lazy_fixture('testimg_tiff_stripe_4096_4096_256_jpeg_resolution_4_7_inch'),
+    lazy_fixture('testimg_tiff_stripe_4096_4096_256_jpeg_resolution_9_1_none'),
+])
+def testimg_tiff_stripe_4096_4096_256_jpeg_resolution(request):
+    return request.param
diff --git a/python/cucim/tests/unit/clara/test_load_image_metadata.py b/python/cucim/tests/unit/clara/test_load_image_metadata.py
index 65e7d926b..2452d4324 100644
--- a/python/cucim/tests/unit/clara/test_load_image_metadata.py
+++ b/python/cucim/tests/unit/clara/test_load_image_metadata.py
@@ -14,6 +14,7 @@
 #
 
 from ...util.io import open_image_cucim
+import math
 
 
 def test_load_image_metadata(testimg_tiff_stripe_32x24_16):
@@ -45,7 +46,7 @@ def test_load_image_metadata(testimg_tiff_stripe_32x24_16):
     # Returns physical size in tuple.
     assert img.spacing() == [1.0, 1.0, 1.0]
     # Units for each spacing element (size is same with `ndim`).
-    assert img.spacing_units() == ['micrometer', 'micrometer', 'color']
+    assert img.spacing_units() == ['', '', 'color']
     # Physical location of (0, 0, 0) (size is always 3).
     assert img.origin == [0.0, 0.0, 0.0]
     # Direction cosines (size is always 3x3).
@@ -71,6 +72,54 @@ def test_load_image_metadata(testimg_tiff_stripe_32x24_16):
     assert img.raw_metadata == '{"axes": "YXC", "shape": [24, 32, 3]}'
 
 
+def test_load_image_resolution_metadata(testimg_tiff_stripe_4096_4096_256_jpeg_resolution):  # noqa: E501
+    image, resolution = testimg_tiff_stripe_4096_4096_256_jpeg_resolution
+    img = open_image_cucim(image)
+
+    x_resolution, y_resolution, resolution_unit = resolution
+
+    if resolution_unit == "CENTIMETER":
+        x_spacing = 10000.0 / x_resolution
+        y_spacing = 10000.0 / y_resolution
+        spacing_unit = "micrometer"
+    elif resolution_unit == "INCH":
+        x_spacing = 25400.0 / x_resolution
+        y_spacing = 25400.0 / y_resolution
+        spacing_unit = "micrometer"
+    else:
+        x_spacing = x_resolution
+        y_spacing = y_resolution
+        spacing_unit = ""
+
+    # Returns physical size in tuple.
+    assert all(map(lambda a, b: math.isclose(a, b, rel_tol=0.1),
+                   img.spacing(), (y_spacing, x_spacing, 1.0)))
+    # Units for each spacing element (size is same with `ndim`).
+    assert img.spacing_units() == [spacing_unit, spacing_unit, 'color']
+
+    # A metadata object as `dict`
+    metadata = img.metadata
+    print(metadata)
+    assert isinstance(metadata, dict)
+    assert len(metadata) == 2  # 'cucim' and 'tiff'
+    assert math.isclose(metadata['tiff']['x_resolution'],
+                        x_resolution, rel_tol=0.00001)
+    assert math.isclose(metadata['tiff']['y_resolution'],
+                        y_resolution, rel_tol=0.00001)
+    unit_value = resolution_unit.lower() if resolution_unit != "NONE" else ""
+    assert metadata['tiff']['resolution_unit'] == unit_value
+
+    # Check if lower resolution image's metadata has lower physical spacing.
+    num_levels = img.resolutions['level_count']
+    for level in range(num_levels):
+        lowres_img = img.read_region((0, 0), (100, 100), level=level)
+        lowres_downsample = img.resolutions["level_downsamples"][level]
+        assert all(map(lambda a, b: math.isclose(a, b, rel_tol=0.1),
+                       lowres_img.spacing(),
+                       (y_spacing / lowres_downsample,
+                        x_spacing / lowres_downsample, 1.0)))
+
+
 def test_load_rgba_image_metadata(tmpdir):
     """Test accessing RGBA image's metadata.
 
diff --git a/python/cucim/tests/util/gen_image.py b/python/cucim/tests/util/gen_image.py
index 8eea5dc81..645db1f79 100644
--- a/python/cucim/tests/util/gen_image.py
+++ b/python/cucim/tests/util/gen_image.py
@@ -16,6 +16,7 @@
 import argparse
 import logging
 import os
+import tifffile
 
 try:
     from .gen_tiff import TiffGenerator
@@ -28,16 +29,23 @@
 
 
 class ImageGenerator:
-    def __init__(self, dest, recipes, logger=None):
+    def __init__(self, dest, recipes, resolutions=None, logger=None):
         self.logger = logger or logging.getLogger(__name__)
         self.dest = dest
         self.recipes = recipes
 
+        if resolutions is None:
+            resolutions = [(1, 1, "CENTIMETER")] * len(recipes)
+        if len(resolutions) != len(recipes):
+            raise RuntimeError(
+                'Number of resolutions must be equal to number of recipes')
+        self.resolutions = resolutions
+
     def gen(self):
 
         results = []
 
-        for recipe in self.recipes:
+        for recipe, resolution in zip(self.recipes, self.resolutions):
             items = recipe.split(':')
             item_len = len(items)
             if not (1 <= item_len <= 6):
@@ -69,10 +77,16 @@ def gen(self):
                 raise RuntimeError(
                     f'No data generated from [pattern={pattern},'
                     + f' image_size={image_size}, tile_size={tile_size},'
-                    + f' compression={compression}].')
+                    + f' compression={compression}, resolution={resolution}].')
 
             file_name = f'{kind}_{pattern}_{image_size_str}_{tile_size}'
-
+            if resolution is None or len(resolution) == 2:
+                unit = None
+            elif len(resolution) == 3:
+                unit = resolution[2]
+                resolution = resolution[:2]
+            if unit is None:
+                unit = tifffile.RESUNIT.NONE
             image_path = generator_obj.save_image(image_data,
                                                   dest_folder,
                                                   file_name=file_name,
@@ -81,7 +95,9 @@ def gen(self):
                                                   pattern=pattern,
                                                   image_size=image_size,
                                                   tile_size=tile_size,
-                                                  compression=compression)
+                                                  compression=compression,
+                                                  resolution=resolution,
+                                                  resolutionunit=unit)
             self.logger.info('  Generated %s...', image_path)
             results.append(image_path)
 
diff --git a/python/cucim/tests/util/gen_tiff.py b/python/cucim/tests/util/gen_tiff.py
index 713679657..324f8db14 100644
--- a/python/cucim/tests/util/gen_tiff.py
+++ b/python/cucim/tests/util/gen_tiff.py
@@ -38,7 +38,8 @@ def get_image(self, pattern, image_size):
         return None
 
     def save_image(self, image_data, dest_folder, file_name, kind, subpath,
-                   pattern, image_size, tile_size, compression):
+                   pattern, image_size, tile_size, compression, resolution,
+                   resolutionunit):
         # You can add pyramid images (0: largest resolution)
         if isinstance(image_data, list):
             arr_stack = image_data
@@ -55,10 +56,15 @@ def save_image(self, image_data, dest_folder, file_name, kind, subpath,
         tiff_file_name = str(
             (Path(dest_folder) / f'{file_name}.tif').absolute())
 
+        level_resolution = None
         with TiffWriter(tiff_file_name, bigtiff=True) as tif:
             for level in range(len(arr_stack)):  # save from the largest image
                 src_arr = arr_stack[level]
 
+                if resolution:
+                    level_resolution = (resolution[0] / (level + 1),
+                                        resolution[1] / (level + 1))
+
                 tif.write(
                     src_arr,
                     software="tifffile",
@@ -68,6 +74,8 @@ def save_image(self, image_data, dest_folder, file_name, kind, subpath,
                     planarconfig="CONTIG",
                     compression=compression,  # requires imagecodecs
                     subfiletype=1 if level else 0,
+                    resolution=level_resolution,
+                    resolutionunit=resolutionunit,
                 )
         return tiff_file_name