Skip to content

Commit 9eb3919

Browse files
committed
examples/deepzoom: add type hints
Signed-off-by: Benjamin Gilbert <[email protected]>
1 parent 26ef392 commit 9eb3919

File tree

4 files changed

+184
-76
lines changed

4 files changed

+184
-76
lines changed

.pre-commit-config.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,8 +58,8 @@ repos:
5858
hooks:
5959
- id: mypy
6060
name: Check Python types
61-
additional_dependencies: [openslide-bin, pillow, types-setuptools]
62-
exclude: "^(doc/.*|examples/deepzoom/.*)$"
61+
additional_dependencies: [flask, openslide-bin, pillow, types-setuptools]
62+
exclude: "^doc/.*$"
6363

6464
- repo: https://github.com/rstcheck/rstcheck
6565
rev: v6.2.4

examples/deepzoom/deepzoom_multiserver.py

Lines changed: 62 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -24,18 +24,24 @@
2424
from argparse import ArgumentParser
2525
import base64
2626
from collections import OrderedDict
27+
from collections.abc import Callable
2728
from io import BytesIO
2829
import os
2930
from threading import Lock
31+
from typing import TYPE_CHECKING, Any, Literal
3032
import zlib
3133

32-
from PIL import ImageCms
33-
from flask import Flask, abort, make_response, render_template, url_for
34+
from PIL import Image, ImageCms
35+
from flask import Flask, Response, abort, make_response, render_template, url_for
36+
37+
if TYPE_CHECKING:
38+
# Python 3.10+
39+
from typing import TypeAlias
3440

3541
if os.name == 'nt':
3642
_dll_path = os.getenv('OPENSLIDE_PATH')
3743
if _dll_path is not None:
38-
with os.add_dll_directory(_dll_path):
44+
with os.add_dll_directory(_dll_path): # type: ignore[attr-defined]
3945
import openslide
4046
else:
4147
import openslide
@@ -62,10 +68,36 @@
6268
)
6369
SRGB_PROFILE = ImageCms.getOpenProfile(BytesIO(SRGB_PROFILE_BYTES))
6470

71+
if TYPE_CHECKING:
72+
ColorMode: TypeAlias = Literal[
73+
'default',
74+
'absolute-colorimetric',
75+
'perceptual',
76+
'relative-colorimetric',
77+
'saturation',
78+
'embed',
79+
'ignore',
80+
]
81+
Transform: TypeAlias = Callable[[Image.Image], None]
82+
83+
84+
class DeepZoomMultiServer(Flask):
85+
basedir: str
86+
cache: _SlideCache
87+
88+
89+
class AnnotatedDeepZoomGenerator(DeepZoomGenerator):
90+
filename: str
91+
mpp: float
92+
transform: Transform
93+
6594

66-
def create_app(config=None, config_file=None):
95+
def create_app(
96+
config: dict[str, Any] | None = None,
97+
config_file: str | None = None,
98+
) -> Flask:
6799
# Create and configure app
68-
app = Flask(__name__)
100+
app = DeepZoomMultiServer(__name__)
69101
app.config.from_mapping(
70102
SLIDE_DIR='.',
71103
SLIDE_CACHE_SIZE=10,
@@ -99,7 +131,7 @@ def create_app(config=None, config_file=None):
99131
)
100132

101133
# Helper functions
102-
def get_slide(path):
134+
def get_slide(path: str) -> AnnotatedDeepZoomGenerator:
103135
path = os.path.abspath(os.path.join(app.basedir, path))
104136
if not path.startswith(app.basedir + os.path.sep):
105137
# Directory traversal
@@ -115,11 +147,11 @@ def get_slide(path):
115147

116148
# Set up routes
117149
@app.route('/')
118-
def index():
150+
def index() -> str:
119151
return render_template('files.html', root_dir=_Directory(app.basedir))
120152

121153
@app.route('/<path:path>')
122-
def slide(path):
154+
def slide(path: str) -> str:
123155
slide = get_slide(path)
124156
slide_url = url_for('dzi', path=path)
125157
return render_template(
@@ -130,15 +162,15 @@ def slide(path):
130162
)
131163

132164
@app.route('/<path:path>.dzi')
133-
def dzi(path):
165+
def dzi(path: str) -> Response:
134166
slide = get_slide(path)
135167
format = app.config['DEEPZOOM_FORMAT']
136168
resp = make_response(slide.get_dzi(format))
137169
resp.mimetype = 'application/xml'
138170
return resp
139171

140172
@app.route('/<path:path>_files/<int:level>/<int:col>_<int:row>.<format>')
141-
def tile(path, level, col, row, format):
173+
def tile(path: str, level: int, col: int, row: int, format: str) -> Response:
142174
slide = get_slide(path)
143175
format = format.lower()
144176
if format != 'jpeg' and format != 'png':
@@ -165,19 +197,27 @@ def tile(path, level, col, row, format):
165197

166198

167199
class _SlideCache:
168-
def __init__(self, cache_size, tile_cache_mb, dz_opts, color_mode):
200+
def __init__(
201+
self,
202+
cache_size: int,
203+
tile_cache_mb: int,
204+
dz_opts: dict[str, Any],
205+
color_mode: ColorMode,
206+
):
169207
self.cache_size = cache_size
170208
self.dz_opts = dz_opts
171209
self.color_mode = color_mode
172210
self._lock = Lock()
173-
self._cache = OrderedDict()
211+
self._cache: OrderedDict[str, AnnotatedDeepZoomGenerator] = OrderedDict()
174212
# Share a single tile cache among all slide handles, if supported
175213
try:
176-
self._tile_cache = OpenSlideCache(tile_cache_mb * 1024 * 1024)
214+
self._tile_cache: OpenSlideCache | None = OpenSlideCache(
215+
tile_cache_mb * 1024 * 1024
216+
)
177217
except OpenSlideVersionError:
178218
self._tile_cache = None
179219

180-
def get(self, path):
220+
def get(self, path: str) -> AnnotatedDeepZoomGenerator:
181221
with self._lock:
182222
if path in self._cache:
183223
# Move to end of LRU
@@ -188,7 +228,7 @@ def get(self, path):
188228
osr = OpenSlide(path)
189229
if self._tile_cache is not None:
190230
osr.set_cache(self._tile_cache)
191-
slide = DeepZoomGenerator(osr, **self.dz_opts)
231+
slide = AnnotatedDeepZoomGenerator(osr, **self.dz_opts)
192232
try:
193233
mpp_x = osr.properties[openslide.PROPERTY_NAME_MPP_X]
194234
mpp_y = osr.properties[openslide.PROPERTY_NAME_MPP_Y]
@@ -204,7 +244,7 @@ def get(self, path):
204244
self._cache[path] = slide
205245
return slide
206246

207-
def _get_transform(self, image):
247+
def _get_transform(self, image: OpenSlide) -> Transform:
208248
if image.color_profile is None:
209249
return lambda img: None
210250
mode = self.color_mode
@@ -215,7 +255,7 @@ def _get_transform(self, image):
215255
# embed ICC profile in tiles
216256
return lambda img: None
217257
elif mode == 'default':
218-
intent = ImageCms.getDefaultIntent(image.color_profile)
258+
intent = ImageCms.Intent(ImageCms.getDefaultIntent(image.color_profile))
219259
elif mode == 'absolute-colorimetric':
220260
intent = ImageCms.Intent.ABSOLUTE_COLORIMETRIC
221261
elif mode == 'relative-colorimetric':
@@ -232,10 +272,10 @@ def _get_transform(self, image):
232272
'RGB',
233273
'RGB',
234274
intent,
235-
0,
275+
ImageCms.Flags(0),
236276
)
237277

238-
def xfrm(img):
278+
def xfrm(img: Image.Image) -> None:
239279
ImageCms.applyTransform(img, transform, True)
240280
# Some browsers assume we intend the display's color space if we
241281
# don't embed the profile. Pillow's serialization is larger, so
@@ -246,9 +286,9 @@ def xfrm(img):
246286

247287

248288
class _Directory:
249-
def __init__(self, basedir, relpath=''):
289+
def __init__(self, basedir: str, relpath: str = ''):
250290
self.name = os.path.basename(relpath)
251-
self.children = []
291+
self.children: list[_Directory | _SlideFile] = []
252292
for name in sorted(os.listdir(os.path.join(basedir, relpath))):
253293
cur_relpath = os.path.join(relpath, name)
254294
cur_path = os.path.join(basedir, cur_relpath)
@@ -261,7 +301,7 @@ def __init__(self, basedir, relpath=''):
261301

262302

263303
class _SlideFile:
264-
def __init__(self, relpath):
304+
def __init__(self, relpath: str):
265305
self.name = os.path.basename(relpath)
266306
self.url_path = relpath
267307

examples/deepzoom/deepzoom_server.py

Lines changed: 43 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -23,26 +23,32 @@
2323

2424
from argparse import ArgumentParser
2525
import base64
26+
from collections.abc import Callable
2627
from io import BytesIO
2728
import os
2829
import re
30+
from typing import TYPE_CHECKING, Any, Literal, Mapping
2931
from unicodedata import normalize
3032
import zlib
3133

32-
from PIL import ImageCms
33-
from flask import Flask, abort, make_response, render_template, url_for
34+
from PIL import Image, ImageCms
35+
from flask import Flask, Response, abort, make_response, render_template, url_for
36+
37+
if TYPE_CHECKING:
38+
# Python 3.10+
39+
from typing import TypeAlias
3440

3541
if os.name == 'nt':
3642
_dll_path = os.getenv('OPENSLIDE_PATH')
3743
if _dll_path is not None:
38-
with os.add_dll_directory(_dll_path):
44+
with os.add_dll_directory(_dll_path): # type: ignore[attr-defined]
3945
import openslide
4046
else:
4147
import openslide
4248
else:
4349
import openslide
4450

45-
from openslide import ImageSlide, open_slide
51+
from openslide import AbstractSlide, ImageSlide, open_slide
4652
from openslide.deepzoom import DeepZoomGenerator
4753

4854
SLIDE_NAME = 'slide'
@@ -64,10 +70,33 @@
6470
)
6571
SRGB_PROFILE = ImageCms.getOpenProfile(BytesIO(SRGB_PROFILE_BYTES))
6672

73+
if TYPE_CHECKING:
74+
ColorMode: TypeAlias = Literal[
75+
'default',
76+
'absolute-colorimetric',
77+
'perceptual',
78+
'relative-colorimetric',
79+
'saturation',
80+
'embed',
81+
'ignore',
82+
]
83+
Transform: TypeAlias = Callable[[Image.Image], None]
84+
85+
86+
class DeepZoomServer(Flask):
87+
slides: dict[str, DeepZoomGenerator]
88+
transforms: dict[str, Transform]
89+
slide_properties: Mapping[str, str]
90+
associated_images: list[str]
91+
slide_mpp: float
92+
6793

68-
def create_app(config=None, config_file=None):
94+
def create_app(
95+
config: dict[str, Any] | None = None,
96+
config_file: str | None = None,
97+
) -> Flask:
6998
# Create and configure app
70-
app = Flask(__name__)
99+
app = DeepZoomServer(__name__)
71100
app.config.from_mapping(
72101
DEEPZOOM_SLIDE=None,
73102
DEEPZOOM_FORMAT='jpeg',
@@ -117,7 +146,7 @@ def create_app(config=None, config_file=None):
117146

118147
# Set up routes
119148
@app.route('/')
120-
def index():
149+
def index() -> str:
121150
slide_url = url_for('dzi', slug=SLIDE_NAME)
122151
associated_urls = {
123152
name: url_for('dzi', slug=slugify(name)) for name in app.associated_images
@@ -131,7 +160,7 @@ def index():
131160
)
132161

133162
@app.route('/<slug>.dzi')
134-
def dzi(slug):
163+
def dzi(slug: str) -> Response:
135164
format = app.config['DEEPZOOM_FORMAT']
136165
try:
137166
resp = make_response(app.slides[slug].get_dzi(format))
@@ -142,7 +171,7 @@ def dzi(slug):
142171
abort(404)
143172

144173
@app.route('/<slug>_files/<int:level>/<int:col>_<int:row>.<format>')
145-
def tile(slug, level, col, row, format):
174+
def tile(slug: str, level: int, col: int, row: int, format: str) -> Response:
146175
format = format.lower()
147176
if format != 'jpeg' and format != 'png':
148177
# Not supported by Deep Zoom
@@ -170,12 +199,12 @@ def tile(slug, level, col, row, format):
170199
return app
171200

172201

173-
def slugify(text):
202+
def slugify(text: str) -> str:
174203
text = normalize('NFKD', text.lower()).encode('ascii', 'ignore').decode()
175204
return re.sub('[^a-z0-9]+', '-', text)
176205

177206

178-
def get_transform(image, mode):
207+
def get_transform(image: AbstractSlide, mode: ColorMode) -> Transform:
179208
if image.color_profile is None:
180209
return lambda img: None
181210
if mode == 'ignore':
@@ -185,7 +214,7 @@ def get_transform(image, mode):
185214
# embed ICC profile in tiles
186215
return lambda img: None
187216
elif mode == 'default':
188-
intent = ImageCms.getDefaultIntent(image.color_profile)
217+
intent = ImageCms.Intent(ImageCms.getDefaultIntent(image.color_profile))
189218
elif mode == 'absolute-colorimetric':
190219
intent = ImageCms.Intent.ABSOLUTE_COLORIMETRIC
191220
elif mode == 'relative-colorimetric':
@@ -202,10 +231,10 @@ def get_transform(image, mode):
202231
'RGB',
203232
'RGB',
204233
intent,
205-
0,
234+
ImageCms.Flags(0),
206235
)
207236

208-
def xfrm(img):
237+
def xfrm(img: Image.Image) -> None:
209238
ImageCms.applyTransform(img, transform, True)
210239
# Some browsers assume we intend the display's color space if we don't
211240
# embed the profile. Pillow's serialization is larger, so use ours.

0 commit comments

Comments
 (0)