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 spacing_units(&resource); + spacing_units.reserve(ndim); + std::pmr::vector 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 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 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 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 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&& 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&& 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(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 origin(&resource); 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 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