Skip to content

Commit 17e2b08

Browse files
committed
examples/deepzoom: convert to sRGB via selectable "color modes"
By default, use absolute colorimetric rendering intent, since that preserves comparability between slides. Allow configuring other rendering intents, or embedding the slide's ICC profile in individual tiles so the viewer can do its own color management, or ignoring the ICC profile (the legacy behavior). Embed our own ICC v2 sRGB profile instead of using the v4 one generated by Pillow, for reproducibility and because Firefox apparently doesn't fully support v4 profiles. Signed-off-by: Benjamin Gilbert <[email protected]>
1 parent f40148b commit 17e2b08

File tree

3 files changed

+286
-14
lines changed

3 files changed

+286
-14
lines changed

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
)

examples/deepzoom/deepzoom_server.py

Lines changed: 92 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
# deepzoom_server - Example web application for serving whole-slide images
44
#
55
# Copyright (c) 2010-2015 Carnegie Mellon University
6+
# Copyright (c) 2023 Benjamin Gilbert
67
#
78
# This library is free software; you can redistribute it and/or modify it
89
# under the terms of version 2.1 of the GNU Lesser General Public License
@@ -19,11 +20,14 @@
1920
#
2021

2122
from argparse import ArgumentParser
23+
import base64
2224
from io import BytesIO
2325
import os
2426
import re
2527
from unicodedata import normalize
28+
import zlib
2629

30+
from PIL import ImageCms
2731
from flask import Flask, abort, make_response, render_template, url_for
2832

2933
if os.name == 'nt':
@@ -41,6 +45,23 @@
4145

4246
SLIDE_NAME = 'slide'
4347

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

4566
def create_app(config=None, config_file=None):
4667
# Create and configure app
@@ -52,6 +73,7 @@ def create_app(config=None, config_file=None):
5273
DEEPZOOM_OVERLAP=1,
5374
DEEPZOOM_LIMIT_BOUNDS=True,
5475
DEEPZOOM_TILE_QUALITY=75,
76+
DEEPZOOM_COLOR_MODE='absolute-colorimetric',
5577
)
5678
app.config.from_envvar('DEEPZOOM_TILER_SETTINGS', silent=True)
5779
if config_file is not None:
@@ -71,12 +93,19 @@ def create_app(config=None, config_file=None):
7193
opts = {v: app.config[k] for k, v in config_map.items()}
7294
slide = open_slide(slidefile)
7395
app.slides = {SLIDE_NAME: DeepZoomGenerator(slide, **opts)}
96+
app.transforms = {
97+
SLIDE_NAME: get_transform(slide, app.config['DEEPZOOM_COLOR_MODE'])
98+
}
7499
app.associated_images = []
75100
app.slide_properties = slide.properties
76101
for name, image in slide.associated_images.items():
77102
app.associated_images.append(name)
78103
slug = slugify(name)
79-
app.slides[slug] = DeepZoomGenerator(ImageSlide(image), **opts)
104+
image_slide = ImageSlide(image)
105+
app.slides[slug] = DeepZoomGenerator(image_slide, **opts)
106+
app.transforms[slug] = get_transform(
107+
image_slide, app.config['DEEPZOOM_COLOR_MODE']
108+
)
80109
try:
81110
mpp_x = slide.properties[openslide.PROPERTY_NAME_MPP_X]
82111
mpp_y = slide.properties[openslide.PROPERTY_NAME_MPP_Y]
@@ -124,8 +153,14 @@ def tile(slug, level, col, row, format):
124153
except ValueError:
125154
# Invalid level or coordinates
126155
abort(404)
156+
app.transforms[slug](tile)
127157
buf = BytesIO()
128-
tile.save(buf, format, quality=app.config['DEEPZOOM_TILE_QUALITY'])
158+
tile.save(
159+
buf,
160+
format,
161+
quality=app.config['DEEPZOOM_TILE_QUALITY'],
162+
icc_profile=tile.info.get('icc_profile'),
163+
)
129164
resp = make_response(buf.getvalue())
130165
resp.mimetype = 'image/%s' % format
131166
return resp
@@ -138,6 +173,43 @@ def slugify(text):
138173
return re.sub('[^a-z0-9]+', '-', text)
139174

140175

176+
def get_transform(image, mode):
177+
if image.color_profile is None:
178+
return lambda img: None
179+
if mode == 'ignore':
180+
# drop ICC profile from tiles
181+
return lambda img: img.info.pop('icc_profile')
182+
elif mode == 'embed':
183+
# embed ICC profile in tiles
184+
return lambda img: None
185+
elif mode == 'absolute-colorimetric':
186+
intent = ImageCms.Intent.ABSOLUTE_COLORIMETRIC
187+
elif mode == 'relative-colorimetric':
188+
intent = ImageCms.Intent.RELATIVE_COLORIMETRIC
189+
elif mode == 'perceptual':
190+
intent = ImageCms.Intent.PERCEPTUAL
191+
elif mode == 'saturation':
192+
intent = ImageCms.Intent.SATURATION
193+
else:
194+
raise ValueError(f'Unknown color mode {mode}')
195+
transform = ImageCms.buildTransform(
196+
image.color_profile,
197+
SRGB_PROFILE,
198+
'RGB',
199+
'RGB',
200+
intent,
201+
0,
202+
)
203+
204+
def xfrm(img):
205+
ImageCms.applyTransform(img, transform, True)
206+
# Some browsers assume we intend the display's color space if we don't
207+
# embed the profile. Pillow's serialization is larger, so use ours.
208+
img.info['icc_profile'] = SRGB_PROFILE_BYTES
209+
210+
return xfrm
211+
212+
141213
if __name__ == '__main__':
142214
parser = ArgumentParser(usage='%(prog)s [options] [SLIDE]')
143215
parser.add_argument(
@@ -148,6 +220,24 @@ def slugify(text):
148220
action='store_false',
149221
help='display entire scan area',
150222
)
223+
parser.add_argument(
224+
'--color-mode',
225+
dest='DEEPZOOM_COLOR_MODE',
226+
choices=[
227+
'absolute-colorimetric',
228+
'perceptual',
229+
'relative-colorimetric',
230+
'saturation',
231+
'embed',
232+
'ignore',
233+
],
234+
default='absolute-colorimetric',
235+
help=(
236+
'convert tiles to sRGB using specified rendering intent, or '
237+
'embed original ICC profile, or ignore ICC profile (compat) '
238+
'[absolute-colorimetric]'
239+
),
240+
)
151241
parser.add_argument(
152242
'-c', '--config', metavar='FILE', dest='config', help='config file'
153243
)

0 commit comments

Comments
 (0)