From b65b4b5e6812844b7076515012ec5ab3af576b34 Mon Sep 17 00:00:00 2001 From: Erik O Gabrielsson Date: Mon, 12 Feb 2024 22:56:06 +0100 Subject: [PATCH 1/2] Add color_profile property and profile to Pil images if available --- tiffslide/tests/test_compatibility.py | 34 ++++++++++++++++++++++- tiffslide/tiffslide.py | 39 ++++++++++++++++++++++++--- 2 files changed, 69 insertions(+), 4 deletions(-) diff --git a/tiffslide/tests/test_compatibility.py b/tiffslide/tests/test_compatibility.py index 578b431..86e9904 100644 --- a/tiffslide/tests/test_compatibility.py +++ b/tiffslide/tests/test_compatibility.py @@ -84,7 +84,14 @@ def ts_slide(file_name): @pytest.fixture() def os_slide(file_name): - from openslide import OpenSlide + import os + if hasattr(os, 'add_dll_directory'): + openslide_path = os.getenv("OPENSLIDE_PATH") + if openslide_path is not None: + with os.add_dll_directory(openslide_path): + from openslide import OpenSlide + else: + from openslide import OpenSlide yield OpenSlide(file_name) @@ -233,3 +240,28 @@ def test_read_region_equality_level_common_max(ts_slide, os_slide, file_name): max_difference = np.max(np.abs(ts_arr.astype(int) - os_arr.astype(int))) assert max_difference <= (0 if exact else 1) + +def test_color_profile_property(ts_slide, os_slide): + if os_slide.color_profile is None: + assert ts_slide.color_profile is None + else: + assert ts_slide.color_profile.tobytes() == os_slide.color_profile.tobytes() + +def test_icc_profile_in_thumbnail(ts_slide, os_slide): + ts_slide_thumbnail = ts_slide.get_thumbnail((200, 200)) + os_slide_thumbnail = os_slide.get_thumbnail((200, 200)) + + if os_slide_thumbnail.info.get("icc_profile") is None: + assert ts_slide_thumbnail.info.get("icc_profile") is None + else: + assert os_slide_thumbnail.info["icc_profile"] == ts_slide_thumbnail.info["icc_profile"] + + +def test_icc_profile_in_region(ts_slide, os_slide): + ts_slide_region = ts_slide.read_region((0, 0), 0, (200, 200)) + os_slide_region = os_slide.read_region((0, 0), 0, (200, 200)) + + if os_slide_region.info.get("icc_profile") is None: + assert ts_slide_region.info.get("icc_profile") is None + else: + assert os_slide_region.info["icc_profile"] == ts_slide_region.info["icc_profile"] \ No newline at end of file diff --git a/tiffslide/tiffslide.py b/tiffslide/tiffslide.py index 9127c83..63151f5 100644 --- a/tiffslide/tiffslide.py +++ b/tiffslide/tiffslide.py @@ -1,4 +1,5 @@ from __future__ import annotations +import io import math import os.path @@ -25,7 +26,7 @@ from fsspec.core import url_to_fs from fsspec.implementations.local import LocalFileSystem from fsspec.implementations.reference import ReferenceFileSystem -from PIL import Image +from PIL import Image, ImageCms from tifffile import TiffFile from tifffile import TiffFileError as TiffFileError from tifffile import TiffPage @@ -240,6 +241,13 @@ def associated_images(self) -> _LazyAssociatedImagesDict: series = self.ts_tifffile.series[idx + 1 :] return _LazyAssociatedImagesDict(series) + @cached_property + def color_profile(self) -> Optional[ImageCms.ImageCmsProfile]: + """return the color profile of the image if present""" + if self._profile is None: + return None + return ImageCms.getOpenProfile(io.BytesIO(self._profile)) + def get_best_level_for_downsample(self, downsample: float) -> int: """return the best level for a given downsampling factor""" if downsample <= 1.0: @@ -411,9 +419,12 @@ def read_region( if as_array: return arr elif axes == "YX": - return Image.fromarray(arr[..., 0]) + image = Image.fromarray(arr[..., 0]) else: - return Image.fromarray(arr) + image = Image.fromarray(arr) + if self._profile is not None: + image.info['icc_profile'] = self._profile + return image def _read_region_loc_transform( self, location: tuple[int, int], level: int @@ -484,8 +495,16 @@ def get_thumbnail( except ValueError: # see: https://github.com/python-pillow/Pillow/blob/95cff6e959/src/libImaging/Resample.c#L559-L588 thumb.thumbnail(size, _NEAREST) + if self._profile is not None: + thumb.info['icc_profile'] = self._profile return thumb + @cached_property + def _profile(self) -> Optional[bytes]: + """return the color profile of the image if present""" + parser = _IccParser(self._tifffile) + return parser.parse() + class NotTiffSlide(TiffSlide): # noinspection PyMissingConstructor @@ -771,6 +790,7 @@ def parse_aperio(self) -> dict[str, Any]: # collect level info md.update(self.collect_level_info(series0)) + return md def parse_leica(self) -> dict[str, Any]: @@ -1031,6 +1051,19 @@ def _parse_metadata_leica(image_description: str) -> dict[str, Any]: return md +class _IccParser: + """parse ICC profile from tiff tags""" + + def __init__(self, tf: TiffFile) -> None: + self._tf = tf + + def parse(self) -> bytes | None: + """return the ICC profile if present""" + page = self._tf.pages[0] + if isinstance(page, TiffPage) and "InterColorProfile" in page.tags: + return page.tags["InterColorProfile"].value + return None + # --- helper functions -------------------------------------------------------- From 9e3c7f9948d9dc6dc8629d33953ae2a843ceb428 Mon Sep 17 00:00:00 2001 From: Erik O Gabrielsson Date: Tue, 13 Feb 2024 08:41:34 +0100 Subject: [PATCH 2/2] Fix mypy type analysis --- tiffslide/tiffslide.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tiffslide/tiffslide.py b/tiffslide/tiffslide.py index 63151f5..60e16da 100644 --- a/tiffslide/tiffslide.py +++ b/tiffslide/tiffslide.py @@ -14,7 +14,6 @@ from typing import Iterator from typing import Literal from typing import Mapping -from typing import Optional from typing import TypeVar from typing import overload from warnings import warn @@ -242,7 +241,7 @@ def associated_images(self) -> _LazyAssociatedImagesDict: return _LazyAssociatedImagesDict(series) @cached_property - def color_profile(self) -> Optional[ImageCms.ImageCmsProfile]: + def color_profile(self) -> ImageCms.ImageCmsProfile | None: """return the color profile of the image if present""" if self._profile is None: return None @@ -500,7 +499,7 @@ def get_thumbnail( return thumb @cached_property - def _profile(self) -> Optional[bytes]: + def _profile(self) -> bytes | None: """return the color profile of the image if present""" parser = _IccParser(self._tifffile) return parser.parse() @@ -1061,7 +1060,9 @@ def parse(self) -> bytes | None: """return the ICC profile if present""" page = self._tf.pages[0] if isinstance(page, TiffPage) and "InterColorProfile" in page.tags: - return page.tags["InterColorProfile"].value + icc_profile = page.tags["InterColorProfile"].value + if isinstance(icc_profile, bytes): + return icc_profile return None