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


Discussion / hints:
Hi @martvanrijthoven 
Thank you for the report!

cuCIM currently not populating spacing metadata correctly.
With https://github.com/rapidsai/cucim/pull/349, it populates  `tiff.resolution_unit` and `tiff.x_resolution`, `tiff.y_resolution`, `spacing`, `spacing_units`.

Please let us know if it can address the issue.

Thank you!

**spacing_test.py**
```python
from pprint import pprint
from cucim import CuImage
img = CuImage("/home/gbae/Downloads/TCGA-18-3406-01Z-00-DX1_tissue.tif")
pprint(img.metadata, indent=2, compact=True)

```

```bash
❯ python spacing_test.py
{ 'cucim': { 'associated_images': [],
             'channel_names': ['R', 'G', 'B', 'A'],
             'coord_sys': 'LPS',
             'dims': 'YXC',
             'direction': [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
             'dtype': {'bits': 8, 'code': 1, 'lanes': 1},
             'ndim': 3,
             'origin': [0.0, 0.0, 0.0],
             'path': '/home/gbae/Downloads/TCGA-18-3406-01Z-00-DX1_tissue.tif',
             'resolutions': { 'level_count': 6,
                              'level_dimensions': [ [16034, 10970],
                                                    [8017, 5485], [4008, 2742],
                                                    [2004, 1371], [1002, 685],
                                                    [501, 342]],
                              'level_downsamples': [ 1.0, 2.0,
                                                     4.000614166259766,
                                                     8.001228332519531,
                                                     16.008296966552734,
                                                     32.040008544921875],
                              'level_tile_sizes': [ [512, 512], [512, 512],
                                                    [512, 512], [512, 512],
                                                    [512, 512], [512, 512]]},
             'shape': [10970, 16034, 4],
             'spacing': [2.0159945487976074, 2.0159945487976074, 1.0],
             'spacing_units': ['micrometer', 'micrometer', 'color'],
             'typestr': '|u1'},
  'tiff': { 'model': '',
            'resolution_unit': 'centimeter',
            'software': '',
            'x_resolution': 4960.3310546875,
            'y_resolution': 4960.3310546875}}
```

**Test package**
```bash
python -m pip install --force-reinstall --extra-index-url https://test.pypi.org/simple/ cucim==0.0.333
```
Dear Gigon Bae,

Thank you so much for the quick implementation. This works perfectly now!

Best wishes,
Mart

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