Skip to content

Commit 395cd10

Browse files
authored
Merge pull request #209 from bgilbert/icc
Support ICC profiles
2 parents ddaf9b6 + 17e2b08 commit 395cd10

17 files changed

+513
-26
lines changed

MANIFEST.in

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
include *.md pytest.ini
22
recursive-include doc *.py *.rst
33
recursive-include examples *.html *.js *.png *.py
4-
recursive-include tests *.png *.py *.svs *.tiff
4+
recursive-include tests *.dcm *.png *.py *.svs *.tiff

doc/conf.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@
4646

4747
# General information about the project.
4848
project = 'OpenSlide Python'
49-
copyright = '2010-2022 Carnegie Mellon University and others'
49+
copyright = '2010-2023 Carnegie Mellon University and others'
5050

5151
# The version info for the project you're documenting, acts as replacement for
5252
# |version| and |release|, also used in various other places throughout the

doc/index.rst

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,12 @@ OpenSlide objects
148148

149149
Unlike in the C interface, these images are not premultiplied.
150150

151+
.. attribute:: color_profile
152+
153+
The embedded :ref:`color profile <color-management>` for this slide,
154+
as a Pillow :class:`~PIL.ImageCms.ImageCmsProfile`, or :obj:`None` if
155+
not available.
156+
151157
.. method:: read_region(location, level, size)
152158

153159
Return an RGBA :class:`Image <PIL.Image.Image>` containing the
@@ -188,6 +194,58 @@ OpenSlide objects
188194
Close the OpenSlide object.
189195

190196

197+
.. _color-management:
198+
199+
Color management
200+
----------------
201+
202+
Every slide region, associated image, thumbnail, and Deep Zoom tile produced
203+
by OpenSlide Python includes a reference to an ICC color profile whenever a
204+
profile is available for the underlying pixel data. Profiles are stored as
205+
a :class:`bytes` object in
206+
:attr:`Image.info <PIL.Image.Image.info>`:attr:`['icc_profile']`. If no
207+
profile is available, the :attr:`icc_profile` dictionary key is absent.
208+
209+
To include the profile in an image file when saving the image to disk::
210+
211+
image.save(filename, icc_profile=image.info.get('icc_profile'))
212+
213+
To perform color conversions using the profile, import it into
214+
:mod:`ImageCms <PIL.ImageCms>`. For example, to convert an image in-place
215+
to a synthesized sRGB profile, using absolute colorimetric rendering::
216+
217+
from io import BytesIO
218+
from PIL import ImageCms
219+
220+
fromProfile = ImageCms.getOpenProfile(BytesIO(image.info['icc_profile']))
221+
toProfile = ImageCms.createProfile('sRGB')
222+
ImageCms.profileToProfile(
223+
image, fromProfile, toProfile,
224+
ImageCms.Intent.ABSOLUTE_COLORIMETRIC, 'RGBA', True, 0
225+
)
226+
227+
Absolute colorimetric rendering `maximizes the comparability`_ of images
228+
produced by different scanners. When converting Deep Zoom tiles, use
229+
``'RGB'`` instead of ``'RGBA'``.
230+
231+
All pyramid regions in a slide have the same profile, but each associated
232+
image can have its own profile. As a convenience, the former is also
233+
available as :attr:`OpenSlide.color_profile`, already parsed into an
234+
:class:`~PIL.ImageCms.ImageCmsProfile` object. You can save processing time
235+
by building an :class:`~PIL.ImageCms.ImageCmsTransform` for the slide and
236+
reusing it for multiple slide regions::
237+
238+
toProfile = ImageCms.createProfile('sRGB')
239+
transform = ImageCms.buildTransform(
240+
slide.color_profile, toProfile, 'RGBA', 'RGBA',
241+
ImageCms.Intent.ABSOLUTE_COLORIMETRIC, 0
242+
)
243+
# for each region image:
244+
ImageCms.applyTransform(image, transform, True)
245+
246+
.. _maximizes the comparability: https://www.ncbi.nlm.nih.gov/pmc/articles/PMC4478790/
247+
248+
191249
Caching
192250
-------
193251

@@ -286,8 +344,8 @@ Exceptions
286344
Subclass of :exc:`OpenSlideError`.
287345

288346

289-
Wrapping a PIL Image
290-
====================
347+
Wrapping a Pillow Image
348+
=======================
291349

292350
.. class:: ImageSlide(file)
293351

examples/deepzoom/deepzoom_multiserver.py

Lines changed: 92 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
# deepzoom_multiserver - Example web application for viewing multiple slides
44
#
55
# Copyright (c) 2010-2015 Carnegie Mellon University
6-
# Copyright (c) 2021-2022 Benjamin Gilbert
6+
# Copyright (c) 2021-2023 Benjamin Gilbert
77
#
88
# This library is free software; you can redistribute it and/or modify it
99
# under the terms of version 2.1 of the GNU Lesser General Public License
@@ -20,11 +20,14 @@
2020
#
2121

2222
from argparse import ArgumentParser
23+
import base64
2324
from collections import OrderedDict
2425
from io import BytesIO
2526
import os
2627
from threading import Lock
28+
import zlib
2729

30+
from PIL import ImageCms
2831
from flask import Flask, abort, make_response, render_template, url_for
2932

3033
if os.name == 'nt':
@@ -40,6 +43,23 @@
4043
from openslide import OpenSlide, OpenSlideCache, OpenSlideError, OpenSlideVersionError
4144
from openslide.deepzoom import DeepZoomGenerator
4245

46+
# Optimized sRGB v2 profile, CC0-1.0 license
47+
# https://github.com/saucecontrol/Compact-ICC-Profiles/blob/bdd84663/profiles/sRGB-v2-micro.icc
48+
# ImageCms.createProfile() generates a v4 profile and Firefox has problems
49+
# with those: https://littlecms.com/blog/2020/09/09/browser-check/
50+
SRGB_PROFILE_BYTES = zlib.decompress(
51+
base64.b64decode(
52+
'eNpjYGA8kZOcW8wkwMCQm1dSFOTupBARGaXA/oiBmUGEgZOBj0E2Mbm4wDfYLYQBCIoT'
53+
'y4uTS4pyGFDAt2sMjCD6sm5GYl7K3IkMtg4NG2wdSnQa5y1V6mPADzhTUouTgfQHII5P'
54+
'LigqYWBg5AGyecpLCkBsCSBbpAjoKCBbB8ROh7AdQOwkCDsErCYkyBnIzgCyE9KR2ElI'
55+
'bKhdIMBaCvQsskNKUitKQLSzswEDKAwgop9DwH5jFDuJEMtfwMBg8YmBgbkfIZY0jYFh'
56+
'eycDg8QthJgKUB1/KwPDtiPJpUVlUGu0gLiG4QfjHKZS5maWk2x+HEJcEjxJfF8Ez4t8'
57+
'k8iS0VNwVlmjmaVXZ/zacrP9NbdwX7OQshjxFNmcttKwut4OnUlmc1Yv79l0e9/MU8ev'
58+
'pz4p//jz/38AR4Nk5Q=='
59+
)
60+
)
61+
SRGB_PROFILE = ImageCms.getOpenProfile(BytesIO(SRGB_PROFILE_BYTES))
62+
4363

4464
def create_app(config=None, config_file=None):
4565
# Create and configure app
@@ -53,6 +73,7 @@ def create_app(config=None, config_file=None):
5373
DEEPZOOM_OVERLAP=1,
5474
DEEPZOOM_LIMIT_BOUNDS=True,
5575
DEEPZOOM_TILE_QUALITY=75,
76+
DEEPZOOM_COLOR_MODE='absolute-colorimetric',
5677
)
5778
app.config.from_envvar('DEEPZOOM_MULTISERVER_SETTINGS', silent=True)
5879
if config_file is not None:
@@ -69,7 +90,10 @@ def create_app(config=None, config_file=None):
6990
}
7091
opts = {v: app.config[k] for k, v in config_map.items()}
7192
app.cache = _SlideCache(
72-
app.config['SLIDE_CACHE_SIZE'], app.config['SLIDE_TILE_CACHE_MB'], opts
93+
app.config['SLIDE_CACHE_SIZE'],
94+
app.config['SLIDE_TILE_CACHE_MB'],
95+
opts,
96+
app.config['DEEPZOOM_COLOR_MODE'],
7397
)
7498

7599
# Helper functions
@@ -123,8 +147,14 @@ def tile(path, level, col, row, format):
123147
except ValueError:
124148
# Invalid level or coordinates
125149
abort(404)
150+
slide.transform(tile)
126151
buf = BytesIO()
127-
tile.save(buf, format, quality=app.config['DEEPZOOM_TILE_QUALITY'])
152+
tile.save(
153+
buf,
154+
format,
155+
quality=app.config['DEEPZOOM_TILE_QUALITY'],
156+
icc_profile=tile.info.get('icc_profile'),
157+
)
128158
resp = make_response(buf.getvalue())
129159
resp.mimetype = 'image/%s' % format
130160
return resp
@@ -133,9 +163,10 @@ def tile(path, level, col, row, format):
133163

134164

135165
class _SlideCache:
136-
def __init__(self, cache_size, tile_cache_mb, dz_opts):
166+
def __init__(self, cache_size, tile_cache_mb, dz_opts, color_mode):
137167
self.cache_size = cache_size
138168
self.dz_opts = dz_opts
169+
self.color_mode = color_mode
139170
self._lock = Lock()
140171
self._cache = OrderedDict()
141172
# Share a single tile cache among all slide handles, if supported
@@ -162,6 +193,7 @@ def get(self, path):
162193
slide.mpp = (float(mpp_x) + float(mpp_y)) / 2
163194
except (KeyError, ValueError):
164195
slide.mpp = 0
196+
slide.transform = self._get_transform(osr)
165197

166198
with self._lock:
167199
if path not in self._cache:
@@ -170,6 +202,44 @@ def get(self, path):
170202
self._cache[path] = slide
171203
return slide
172204

205+
def _get_transform(self, image):
206+
if image.color_profile is None:
207+
return lambda img: None
208+
mode = self.color_mode
209+
if mode == 'ignore':
210+
# drop ICC profile from tiles
211+
return lambda img: img.info.pop('icc_profile')
212+
elif mode == 'embed':
213+
# embed ICC profile in tiles
214+
return lambda img: None
215+
elif mode == 'absolute-colorimetric':
216+
intent = ImageCms.Intent.ABSOLUTE_COLORIMETRIC
217+
elif mode == 'relative-colorimetric':
218+
intent = ImageCms.Intent.RELATIVE_COLORIMETRIC
219+
elif mode == 'perceptual':
220+
intent = ImageCms.Intent.PERCEPTUAL
221+
elif mode == 'saturation':
222+
intent = ImageCms.Intent.SATURATION
223+
else:
224+
raise ValueError(f'Unknown color mode {mode}')
225+
transform = ImageCms.buildTransform(
226+
image.color_profile,
227+
SRGB_PROFILE,
228+
'RGB',
229+
'RGB',
230+
intent,
231+
0,
232+
)
233+
234+
def xfrm(img):
235+
ImageCms.applyTransform(img, transform, True)
236+
# Some browsers assume we intend the display's color space if we
237+
# don't embed the profile. Pillow's serialization is larger, so
238+
# use ours.
239+
img.info['icc_profile'] = SRGB_PROFILE_BYTES
240+
241+
return xfrm
242+
173243

174244
class _Directory:
175245
def __init__(self, basedir, relpath=''):
@@ -202,6 +272,24 @@ def __init__(self, relpath):
202272
action='store_false',
203273
help='display entire scan area',
204274
)
275+
parser.add_argument(
276+
'--color-mode',
277+
dest='DEEPZOOM_COLOR_MODE',
278+
choices=[
279+
'absolute-colorimetric',
280+
'perceptual',
281+
'relative-colorimetric',
282+
'saturation',
283+
'embed',
284+
'ignore',
285+
],
286+
default='absolute-colorimetric',
287+
help=(
288+
'convert tiles to sRGB using specified rendering intent, or '
289+
'embed original ICC profile, or ignore ICC profile (compat) '
290+
'[absolute-colorimetric]'
291+
),
292+
)
205293
parser.add_argument(
206294
'-c', '--config', metavar='FILE', dest='config', help='config file'
207295
)

0 commit comments

Comments
 (0)