Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add color_profile property and profile to Pil images if available #83

Merged
merged 2 commits into from
Feb 13, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 33 additions & 1 deletion tiffslide/tests/test_compatibility.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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"]
42 changes: 38 additions & 4 deletions tiffslide/tiffslide.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from __future__ import annotations
import io

import math
import os.path
Expand All @@ -13,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
Expand All @@ -25,7 +25,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
Expand Down Expand Up @@ -240,6 +240,13 @@ def associated_images(self) -> _LazyAssociatedImagesDict:
series = self.ts_tifffile.series[idx + 1 :]
return _LazyAssociatedImagesDict(series)

@cached_property
def color_profile(self) -> ImageCms.ImageCmsProfile | None:
"""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:
Expand Down Expand Up @@ -411,9 +418,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
Expand Down Expand Up @@ -484,8 +494,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) -> bytes | None:
"""return the color profile of the image if present"""
parser = _IccParser(self._tifffile)
return parser.parse()


class NotTiffSlide(TiffSlide):
# noinspection PyMissingConstructor
Expand Down Expand Up @@ -771,6 +789,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]:
Expand Down Expand Up @@ -1031,6 +1050,21 @@ 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:
icc_profile = page.tags["InterColorProfile"].value
if isinstance(icc_profile, bytes):
return icc_profile
return None


# --- helper functions --------------------------------------------------------

Expand Down
Loading