Skip to content

Commit 90ab7f9

Browse files
committed
Support OpenSlide cache management API
1 parent 2eee8ba commit 90ab7f9

File tree

6 files changed

+177
-7
lines changed

6 files changed

+177
-7
lines changed

doc/index.rst

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,11 +138,35 @@ OpenSlide objects
138138
:param tuple size: the maximum size of the thumbnail as a
139139
``(width, height)`` tuple
140140

141+
.. method:: set_cache(cache)
142+
143+
Use the specified :class:`OpenSlideCache` to store recently decoded
144+
slide tiles. By default, the :class:`OpenSlide` has a private cache
145+
with a default size.
146+
147+
:param OpenSlideCache cache: a cache object
148+
:raises OpenSlideVersionError: if OpenSlide is older than version 3.5.0
149+
141150
.. method:: close()
142151

143152
Close the OpenSlide object.
144153

145154

155+
Caching
156+
-------
157+
158+
.. class:: OpenSlideCache(capacity)
159+
160+
An in-memory tile cache.
161+
162+
Tile caches can be attached to one or more :class:`OpenSlide` objects
163+
with :meth:`OpenSlide.set_cache` to cache recently-decoded tiles. By
164+
default, each :class:`OpenSlide` has its own cache with a default size.
165+
166+
:param int capacity: the cache capacity in bytes
167+
:raises OpenSlideVersionError: if OpenSlide is older than version 3.5.0
168+
169+
146170
.. _standard-properties:
147171

148172
Standard properties

openslide/__init__.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
# openslide-python - Python bindings for the OpenSlide library
33
#
44
# Copyright (c) 2010-2014 Carnegie Mellon University
5+
# Copyright (c) 2021 Benjamin Gilbert
56
#
67
# This library is free software; you can redistribute it and/or modify it
78
# under the terms of version 2.1 of the GNU Lesser General Public License
@@ -123,6 +124,12 @@ def read_region(self, location, level, size):
123124
size: (width, height) tuple giving the region size."""
124125
raise NotImplementedError
125126

127+
def set_cache(self, cache):
128+
"""Use the specified cache to store recently decoded slide tiles.
129+
130+
cache: an OpenSlideCache object."""
131+
raise NotImplementedError
132+
126133
def get_thumbnail(self, size):
127134
"""Return a PIL.Image containing an RGB thumbnail of the image.
128135
@@ -230,6 +237,18 @@ def read_region(self, location, level, size):
230237
self._osr, location[0], location[1], level, size[0], size[1]
231238
)
232239

240+
def set_cache(self, cache):
241+
"""Use the specified cache to store recently decoded slide tiles.
242+
243+
By default, the object has a private cache with a default size.
244+
245+
cache: an OpenSlideCache object."""
246+
try:
247+
llcache = cache._openslide_cache
248+
except AttributeError:
249+
raise TypeError('Not a cache object')
250+
lowlevel.set_cache(self._osr, llcache)
251+
233252

234253
class _OpenSlideMap(Mapping):
235254
def __init__(self, osr):
@@ -270,6 +289,23 @@ def __getitem__(self, key):
270289
return lowlevel.read_associated_image(self._osr, key)
271290

272291

292+
class OpenSlideCache:
293+
"""An in-memory tile cache.
294+
295+
Tile caches can be attached to one or more OpenSlide objects with
296+
OpenSlide.set_cache() to cache recently-decoded tiles. By default,
297+
each OpenSlide object has its own cache with a default size.
298+
"""
299+
300+
def __init__(self, capacity):
301+
"""Create a tile cache with the specified capacity in bytes."""
302+
self._capacity = capacity
303+
self._openslide_cache = lowlevel.cache_create(capacity)
304+
305+
def __repr__(self):
306+
return f'{self.__class__.__name__}({self._capacity!r})'
307+
308+
273309
class ImageSlide(AbstractSlide):
274310
"""A wrapper for a PIL.Image that provides the OpenSlide interface."""
275311

@@ -382,6 +418,14 @@ def read_region(self, location, level, size):
382418
tile.paste(crop, tile_offset)
383419
return tile
384420

421+
def set_cache(self, cache):
422+
"""Use the specified cache to store recently decoded slide tiles.
423+
424+
ImageSlide does not support caching, so this method does nothing.
425+
426+
cache: an OpenSlideCache object."""
427+
pass
428+
385429

386430
def open_slide(filename):
387431
"""Open a whole-slide or regular image.

openslide/lowlevel.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
c_double,
3838
c_int32,
3939
c_int64,
40+
c_size_t,
4041
c_uint32,
4142
c_void_p,
4243
cdll,
@@ -122,6 +123,27 @@ def from_param(cls, obj):
122123
return obj
123124

124125

126+
class _OpenSlideCache:
127+
"""Wrapper class to make sure we correctly pass an OpenSlide cache."""
128+
129+
def __init__(self, ptr):
130+
self._as_parameter_ = ptr
131+
# Retain a reference to cache_release() to avoid GC problems during
132+
# interpreter shutdown
133+
self._cache_release = cache_release
134+
135+
def __del__(self):
136+
self._cache_release(self)
137+
138+
@classmethod
139+
def from_param(cls, obj):
140+
if obj.__class__ != cls:
141+
raise ValueError("Not an OpenSlide cache reference")
142+
if not obj._as_parameter_:
143+
raise ValueError("Passing undefined cache object")
144+
return obj
145+
146+
125147
class _utf8_p:
126148
"""Wrapper class to convert string arguments to bytes."""
127149

@@ -135,6 +157,18 @@ def from_param(cls, obj):
135157
raise TypeError('Incorrect type')
136158

137159

160+
class _size_t:
161+
"""Wrapper class to convert size_t arguments to c_size_t."""
162+
163+
@classmethod
164+
def from_param(cls, obj):
165+
if not isinstance(obj, int):
166+
raise TypeError('Incorrect type')
167+
if obj < 0:
168+
raise ValueError('Value out of range')
169+
return c_size_t(obj)
170+
171+
138172
def _load_image(buf, size):
139173
'''buf must be a mutable buffer.'''
140174
_convert.argb2rgba(buf)
@@ -157,6 +191,11 @@ def _check_close(_result, _func, args):
157191
args[0].invalidate()
158192

159193

194+
# wrap the handle returned when creating a cache
195+
def _check_cache_create(result, _func, _args):
196+
return _OpenSlideCache(c_void_p(result))
197+
198+
160199
# Convert returned byte array, if present, into a string
161200
def _check_string(result, func, _args):
162201
if func.restype is c_char_p and result is not None:
@@ -302,3 +341,23 @@ def read_associated_image(slide, name):
302341

303342

304343
get_version = _func('openslide_get_version', c_char_p, [], _check_string)
344+
345+
cache_create = _func(
346+
'openslide_cache_create',
347+
c_void_p,
348+
[_size_t],
349+
_check_cache_create,
350+
minimum_version='3.5.0',
351+
)
352+
353+
set_cache = _func(
354+
'openslide_set_cache',
355+
None,
356+
[_OpenSlide, _OpenSlideCache],
357+
None,
358+
minimum_version='3.5.0',
359+
)
360+
361+
cache_release = _func(
362+
'openslide_cache_release', None, [_OpenSlideCache], None, minimum_version='3.5.0'
363+
)

tests/__init__.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
#
22
# openslide-python - Python bindings for the OpenSlide library
33
#
4-
# Copyright (c) 2016 Benjamin Gilbert
4+
# Copyright (c) 2016-2021 Benjamin Gilbert
55
#
66
# This library is free software; you can redistribute it and/or modify it
77
# under the terms of version 2.1 of the GNU Lesser General Public License
@@ -17,6 +17,7 @@
1717
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
1818
#
1919

20+
from functools import wraps
2021
import os
2122
from pathlib import Path
2223

@@ -42,6 +43,7 @@
4243

4344
os.environ['PATH'] = _orig_path
4445

46+
from openslide import OpenSlideVersionError
4547

4648
# PIL.Image cannot have zero width or height on Pillow 3.4.0 - 3.4.2
4749
# https://github.com/python-pillow/Pillow/issues/2259
@@ -54,3 +56,17 @@
5456

5557
def file_path(name):
5658
return Path(__file__).parent / name
59+
60+
61+
def maybe_supported(f):
62+
'''Decorator to ignore test failures caused by an OpenSlide version that
63+
doesn't support the tested functionality.'''
64+
65+
@wraps(f)
66+
def wrapper(*args, **kwargs):
67+
try:
68+
return f(*args, **kwargs)
69+
except OpenSlideVersionError:
70+
pass
71+
72+
return wrapper

tests/test_imageslide.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
#
22
# openslide-python - Python bindings for the OpenSlide library
33
#
4-
# Copyright (c) 2016 Benjamin Gilbert
4+
# Copyright (c) 2016-2021 Benjamin Gilbert
55
#
66
# This library is free software; you can redistribute it and/or modify it
77
# under the terms of version 2.1 of the GNU Lesser General Public License
@@ -22,9 +22,9 @@
2222

2323
from PIL import Image
2424

25-
from openslide import ImageSlide, OpenSlideError
25+
from openslide import ImageSlide, OpenSlideCache, OpenSlideError
2626

27-
from . import file_path, image_dimensions_cannot_be_zero
27+
from . import file_path, image_dimensions_cannot_be_zero, maybe_supported
2828

2929

3030
@contextmanager
@@ -118,3 +118,8 @@ def test_read_region_bad_size(self):
118118

119119
def test_thumbnail(self):
120120
self.assertEqual(self.osr.get_thumbnail((100, 100)).size, (100, 83))
121+
122+
@maybe_supported
123+
def test_set_cache(self):
124+
self.osr.set_cache(OpenSlideCache(64 << 10))
125+
self.assertEqual(self.osr.read_region((0, 0), 0, (400, 400)).size, (400, 400))

tests/test_openslide.py

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
#
22
# openslide-python - Python bindings for the OpenSlide library
33
#
4-
# Copyright (c) 2016 Benjamin Gilbert
4+
# Copyright (c) 2016-2021 Benjamin Gilbert
55
#
66
# This library is free software; you can redistribute it and/or modify it
77
# under the terms of version 2.1 of the GNU Lesser General Public License
@@ -24,9 +24,24 @@
2424

2525
from PIL import Image
2626

27-
from openslide import OpenSlide, OpenSlideError, OpenSlideUnsupportedFormatError
27+
from openslide import (
28+
OpenSlide,
29+
OpenSlideCache,
30+
OpenSlideError,
31+
OpenSlideUnsupportedFormatError,
32+
)
2833

29-
from . import file_path, image_dimensions_cannot_be_zero
34+
from . import file_path, image_dimensions_cannot_be_zero, maybe_supported
35+
36+
37+
class TestCache(unittest.TestCase):
38+
@maybe_supported
39+
def test_create_cache(self):
40+
OpenSlideCache(0)
41+
OpenSlideCache(1)
42+
OpenSlideCache(4 << 20)
43+
self.assertRaises(ArgumentError, lambda: OpenSlideCache(-1))
44+
self.assertRaises(ArgumentError, lambda: OpenSlideCache(1.3))
3045

3146

3247
class TestSlideWithoutOpening(unittest.TestCase):
@@ -138,6 +153,13 @@ def _test_read_region_2GB(self):
138153
def test_thumbnail(self):
139154
self.assertEqual(self.osr.get_thumbnail((100, 100)).size, (100, 83))
140155

156+
@maybe_supported
157+
def test_set_cache(self):
158+
self.osr.set_cache(OpenSlideCache(64 << 10))
159+
self.assertEqual(self.osr.read_region((0, 0), 0, (400, 400)).size, (400, 400))
160+
self.assertRaises(TypeError, lambda: self.osr.set_cache(None))
161+
self.assertRaises(TypeError, lambda: self.osr.set_cache(3))
162+
141163

142164
class TestAperioSlide(_SlideTest, unittest.TestCase):
143165
FILENAME = 'small.svs'

0 commit comments

Comments
 (0)