tmapslide 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tmapslide/__init__.py +56 -0
- tmapslide/_cache.py +37 -0
- tmapslide/_exceptions.py +19 -0
- tmapslide/_slide.py +553 -0
- tmapslide/_tmapformat.py +654 -0
- tmapslide-0.2.0.dist-info/METADATA +142 -0
- tmapslide-0.2.0.dist-info/RECORD +9 -0
- tmapslide-0.2.0.dist-info/WHEEL +4 -0
- tmapslide-0.2.0.dist-info/licenses/LICENSE +21 -0
tmapslide/__init__.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""tmapslide — Pure Python TMAP whole-slide image reader.
|
|
2
|
+
|
|
3
|
+
A minimal, cross-platform reader for UNIC TMAP whole-slide images
|
|
4
|
+
with an OpenSlide-compatible API.
|
|
5
|
+
|
|
6
|
+
Author: Yifan Feng <evanfeng97@gmail.com>
|
|
7
|
+
License: MIT
|
|
8
|
+
|
|
9
|
+
Drop-in usage:
|
|
10
|
+
import tmapslide
|
|
11
|
+
slide = tmapslide.OpenSlide("sample.TMAP")
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from ._exceptions import (
|
|
15
|
+
OpenSlideError,
|
|
16
|
+
OpenSlideUnsupportedFormatError,
|
|
17
|
+
TmapError,
|
|
18
|
+
TmapOpenError,
|
|
19
|
+
TmapUnsupportedFormatError,
|
|
20
|
+
)
|
|
21
|
+
from ._slide import OpenSlide, TmapSlide, open_slide
|
|
22
|
+
|
|
23
|
+
__version__ = "0.2.0"
|
|
24
|
+
|
|
25
|
+
# Standard OpenSlide property name constants
|
|
26
|
+
PROPERTY_NAME_VENDOR = "openslide.vendor"
|
|
27
|
+
PROPERTY_NAME_QUICKHASH1 = "openslide.quickhash-1"
|
|
28
|
+
PROPERTY_NAME_BACKGROUND_COLOR = "openslide.background-color"
|
|
29
|
+
PROPERTY_NAME_OBJECTIVE_POWER = "openslide.objective-power"
|
|
30
|
+
PROPERTY_NAME_MPP_X = "openslide.mpp-x"
|
|
31
|
+
PROPERTY_NAME_MPP_Y = "openslide.mpp-y"
|
|
32
|
+
PROPERTY_NAME_BOUNDS_X = "openslide.bounds-x"
|
|
33
|
+
PROPERTY_NAME_BOUNDS_Y = "openslide.bounds-y"
|
|
34
|
+
PROPERTY_NAME_BOUNDS_WIDTH = "openslide.bounds-width"
|
|
35
|
+
PROPERTY_NAME_BOUNDS_HEIGHT = "openslide.bounds-height"
|
|
36
|
+
|
|
37
|
+
__all__ = [
|
|
38
|
+
"OpenSlide",
|
|
39
|
+
"OpenSlideError",
|
|
40
|
+
"OpenSlideUnsupportedFormatError",
|
|
41
|
+
"TmapError",
|
|
42
|
+
"TmapOpenError",
|
|
43
|
+
"TmapUnsupportedFormatError",
|
|
44
|
+
"PROPERTY_NAME_VENDOR",
|
|
45
|
+
"PROPERTY_NAME_QUICKHASH1",
|
|
46
|
+
"PROPERTY_NAME_BACKGROUND_COLOR",
|
|
47
|
+
"PROPERTY_NAME_OBJECTIVE_POWER",
|
|
48
|
+
"PROPERTY_NAME_MPP_X",
|
|
49
|
+
"PROPERTY_NAME_MPP_Y",
|
|
50
|
+
"PROPERTY_NAME_BOUNDS_X",
|
|
51
|
+
"PROPERTY_NAME_BOUNDS_Y",
|
|
52
|
+
"PROPERTY_NAME_BOUNDS_WIDTH",
|
|
53
|
+
"PROPERTY_NAME_BOUNDS_HEIGHT",
|
|
54
|
+
"TmapSlide",
|
|
55
|
+
"open_slide",
|
|
56
|
+
]
|
tmapslide/_cache.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""LRU cache for decoded TMAP tiles."""
|
|
2
|
+
|
|
3
|
+
from collections import OrderedDict
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
from PIL import Image
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class _LRUCache:
|
|
10
|
+
"""OrderedDict-backed LRU cache for decoded tiles (O(1) ops)."""
|
|
11
|
+
|
|
12
|
+
__slots__ = ("capacity", "_cache")
|
|
13
|
+
|
|
14
|
+
def __init__(self, capacity: int):
|
|
15
|
+
self.capacity = max(0, capacity)
|
|
16
|
+
self._cache: OrderedDict[int, Image.Image] = OrderedDict()
|
|
17
|
+
|
|
18
|
+
def get(self, key: int) -> Optional[Image.Image]:
|
|
19
|
+
if key not in self._cache:
|
|
20
|
+
return None
|
|
21
|
+
self._cache.move_to_end(key)
|
|
22
|
+
return self._cache[key]
|
|
23
|
+
|
|
24
|
+
def put(self, key: int, value: Image.Image) -> None:
|
|
25
|
+
if self.capacity <= 0:
|
|
26
|
+
return
|
|
27
|
+
if key in self._cache:
|
|
28
|
+
self._cache.move_to_end(key)
|
|
29
|
+
elif len(self._cache) >= self.capacity:
|
|
30
|
+
self._cache.popitem(last=False)
|
|
31
|
+
self._cache[key] = value
|
|
32
|
+
|
|
33
|
+
def clear(self) -> None:
|
|
34
|
+
self._cache.clear()
|
|
35
|
+
|
|
36
|
+
def __len__(self) -> int:
|
|
37
|
+
return len(self._cache)
|
tmapslide/_exceptions.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""tmapslide / OpenSlide-compatible exceptions."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class OpenSlideError(Exception):
|
|
5
|
+
"""Base exception for OpenSlide errors."""
|
|
6
|
+
|
|
7
|
+
pass
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class OpenSlideUnsupportedFormatError(OpenSlideError):
|
|
11
|
+
"""File format not supported or file is corrupted."""
|
|
12
|
+
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# Backward/short aliases
|
|
17
|
+
TmapError = OpenSlideError
|
|
18
|
+
TmapUnsupportedFormatError = OpenSlideUnsupportedFormatError
|
|
19
|
+
TmapOpenError = OpenSlideError
|
tmapslide/_slide.py
ADDED
|
@@ -0,0 +1,553 @@
|
|
|
1
|
+
"""TmapSlide — OpenSlide-compatible API for UNIC TMAP whole-slide images."""
|
|
2
|
+
|
|
3
|
+
import io
|
|
4
|
+
import os
|
|
5
|
+
import threading
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from typing import Dict, List, Optional, Tuple
|
|
8
|
+
|
|
9
|
+
from PIL import Image
|
|
10
|
+
|
|
11
|
+
from ._cache import _LRUCache
|
|
12
|
+
from ._exceptions import OpenSlideError, OpenSlideUnsupportedFormatError
|
|
13
|
+
from ._tmapformat import TmapAssocImage, TmapFileInfo, TmapLevel, parse_tmap_file
|
|
14
|
+
|
|
15
|
+
VENDOR = "unic"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class _PropertyMap(Mapping):
|
|
19
|
+
"""Read-only mapping compatible with OpenSlide's property map."""
|
|
20
|
+
|
|
21
|
+
__slots__ = ("_data",)
|
|
22
|
+
|
|
23
|
+
def __init__(self, items: Dict[str, str]):
|
|
24
|
+
self._data = dict(items)
|
|
25
|
+
|
|
26
|
+
def __getitem__(self, key: str) -> str:
|
|
27
|
+
return self._data[key]
|
|
28
|
+
|
|
29
|
+
def __iter__(self):
|
|
30
|
+
return iter(self._data)
|
|
31
|
+
|
|
32
|
+
def __len__(self) -> int:
|
|
33
|
+
return len(self._data)
|
|
34
|
+
|
|
35
|
+
def __repr__(self) -> str:
|
|
36
|
+
return f"_PropertyMap({self._data!r})"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class _AssociatedImageMap(Mapping):
|
|
40
|
+
"""Lazy read-only mapping for associated images."""
|
|
41
|
+
|
|
42
|
+
__slots__ = (
|
|
43
|
+
"_filename",
|
|
44
|
+
"_assoc_list",
|
|
45
|
+
"_names",
|
|
46
|
+
"_cache",
|
|
47
|
+
"_fh_getter",
|
|
48
|
+
"_io_lock_getter",
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
filename: str,
|
|
54
|
+
assoc_images: List[TmapAssocImage],
|
|
55
|
+
file_handle_getter=None,
|
|
56
|
+
io_lock_getter=None,
|
|
57
|
+
):
|
|
58
|
+
self._filename = filename
|
|
59
|
+
self._assoc_list = assoc_images
|
|
60
|
+
self._names = [a.name for a in assoc_images]
|
|
61
|
+
self._cache: Dict[str, Image.Image] = {}
|
|
62
|
+
self._fh_getter = file_handle_getter
|
|
63
|
+
self._io_lock_getter = io_lock_getter
|
|
64
|
+
|
|
65
|
+
def __getitem__(self, key: str) -> Image.Image:
|
|
66
|
+
if key in self._cache:
|
|
67
|
+
return self._cache[key]
|
|
68
|
+
for assoc in self._assoc_list:
|
|
69
|
+
if assoc.name == key:
|
|
70
|
+
fh = self._fh_getter() if self._fh_getter else None
|
|
71
|
+
if fh is not None:
|
|
72
|
+
lock = self._io_lock_getter() if self._io_lock_getter else None
|
|
73
|
+
if lock is not None:
|
|
74
|
+
with lock:
|
|
75
|
+
fh.seek(assoc.offset)
|
|
76
|
+
data = fh.read(assoc.size)
|
|
77
|
+
else:
|
|
78
|
+
fh.seek(assoc.offset)
|
|
79
|
+
data = fh.read(assoc.size)
|
|
80
|
+
else:
|
|
81
|
+
with open(self._filename, "rb") as f:
|
|
82
|
+
f.seek(assoc.offset)
|
|
83
|
+
data = f.read(assoc.size)
|
|
84
|
+
img = Image.open(io.BytesIO(data)).convert("RGBA")
|
|
85
|
+
if assoc.crop:
|
|
86
|
+
img = img.crop(assoc.crop)
|
|
87
|
+
self._cache[key] = img
|
|
88
|
+
return img
|
|
89
|
+
raise KeyError(key)
|
|
90
|
+
|
|
91
|
+
def __iter__(self):
|
|
92
|
+
return iter(self._names)
|
|
93
|
+
|
|
94
|
+
def __len__(self) -> int:
|
|
95
|
+
return len(self._names)
|
|
96
|
+
|
|
97
|
+
def __repr__(self) -> str:
|
|
98
|
+
return f"_AssociatedImageMap({self._names!r})"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class OpenSlide:
|
|
102
|
+
"""UNIC TMAP whole-slide image reader with an OpenSlide-compatible API.
|
|
103
|
+
|
|
104
|
+
Interface compatible with openslide-python, implemented in pure Python
|
|
105
|
+
with Pillow as the only dependency.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
__slots__ = (
|
|
109
|
+
"_filename",
|
|
110
|
+
"_closed",
|
|
111
|
+
"_tile_cache",
|
|
112
|
+
"_file_handle",
|
|
113
|
+
"_pid",
|
|
114
|
+
"_info",
|
|
115
|
+
"_properties",
|
|
116
|
+
"_associated_images",
|
|
117
|
+
"_tile_lookup",
|
|
118
|
+
"_background_rgba",
|
|
119
|
+
"_io_lock",
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
def __init__(self, filename: str):
|
|
123
|
+
self._filename = filename
|
|
124
|
+
self._closed = False
|
|
125
|
+
self._tile_cache = _LRUCache(256)
|
|
126
|
+
self._file_handle: Optional[io.BufferedReader] = None
|
|
127
|
+
self._pid = os.getpid()
|
|
128
|
+
# Serialises seek+read on the shared file handle across threads.
|
|
129
|
+
self._io_lock = threading.Lock()
|
|
130
|
+
|
|
131
|
+
try:
|
|
132
|
+
self._info: TmapFileInfo = parse_tmap_file(filename)
|
|
133
|
+
except Exception as e:
|
|
134
|
+
raise OpenSlideUnsupportedFormatError(f"Cannot parse TMAP file: {e}")
|
|
135
|
+
|
|
136
|
+
# (level, x, y) -> tile index, for grid lookup in read_region.
|
|
137
|
+
self._tile_lookup: Dict[Tuple[int, int, int], int] = {}
|
|
138
|
+
for level in self._info.levels:
|
|
139
|
+
for idx, t in enumerate(level.tiles):
|
|
140
|
+
self._tile_lookup[(level.index, t.x, t.y)] = idx
|
|
141
|
+
|
|
142
|
+
try:
|
|
143
|
+
self._file_handle = open(filename, "rb")
|
|
144
|
+
except Exception as e:
|
|
145
|
+
raise OpenSlideError(f"Failed to open file handle: {e}")
|
|
146
|
+
|
|
147
|
+
props = {
|
|
148
|
+
"openslide.vendor": VENDOR,
|
|
149
|
+
"openslide.quickhash-1": "",
|
|
150
|
+
"tmap.version": self._info.magic,
|
|
151
|
+
}
|
|
152
|
+
props.update(self._info.properties)
|
|
153
|
+
self._properties = _PropertyMap(props)
|
|
154
|
+
if "tmap.objective_power" in self._info.properties:
|
|
155
|
+
self._properties._data["openslide.objective-power"] = self._info.properties[
|
|
156
|
+
"tmap.objective_power"
|
|
157
|
+
]
|
|
158
|
+
|
|
159
|
+
bkg = int(self._info.properties.get("tmap.bkg_color", "244"))
|
|
160
|
+
bkg = max(0, min(255, bkg))
|
|
161
|
+
self._background_rgba = (bkg, bkg, bkg, 255)
|
|
162
|
+
|
|
163
|
+
self._associated_images = _AssociatedImageMap(
|
|
164
|
+
filename,
|
|
165
|
+
self._info.assoc_images,
|
|
166
|
+
file_handle_getter=lambda: self._ensure_open_handle()
|
|
167
|
+
if not self._closed
|
|
168
|
+
else None,
|
|
169
|
+
io_lock_getter=lambda: self._io_lock if not self._closed else None,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
# ------------------------------------------------------------------
|
|
173
|
+
# Class methods
|
|
174
|
+
# ------------------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
@classmethod
|
|
177
|
+
def detect_format(cls, filename: str) -> Optional[str]:
|
|
178
|
+
"""Return "unic" if the file is a recognized TMAP slide, else None."""
|
|
179
|
+
try:
|
|
180
|
+
with open(filename, "rb") as f:
|
|
181
|
+
magic = f.read(6)
|
|
182
|
+
if magic[:4] == b"TMAP" and magic[4:6].isdigit():
|
|
183
|
+
return VENDOR
|
|
184
|
+
except Exception:
|
|
185
|
+
pass
|
|
186
|
+
return None
|
|
187
|
+
|
|
188
|
+
# ------------------------------------------------------------------
|
|
189
|
+
# Internal helpers
|
|
190
|
+
# ------------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
def _check_open(self) -> None:
|
|
193
|
+
if self._closed:
|
|
194
|
+
raise OpenSlideError("Slide is closed")
|
|
195
|
+
|
|
196
|
+
def _ensure_open_handle(self) -> io.BufferedReader:
|
|
197
|
+
"""Return a file handle valid in the current process (fork-safe)."""
|
|
198
|
+
if self._closed:
|
|
199
|
+
raise OpenSlideError("Slide is closed")
|
|
200
|
+
if self._file_handle is None or os.getpid() != self._pid:
|
|
201
|
+
if self._file_handle is not None:
|
|
202
|
+
try:
|
|
203
|
+
self._file_handle.close()
|
|
204
|
+
except Exception:
|
|
205
|
+
pass
|
|
206
|
+
try:
|
|
207
|
+
self._file_handle = open(self._filename, "rb")
|
|
208
|
+
except Exception as e:
|
|
209
|
+
raise OpenSlideError(f"Failed to reopen file handle: {e}")
|
|
210
|
+
self._pid = os.getpid()
|
|
211
|
+
return self._file_handle
|
|
212
|
+
|
|
213
|
+
def _read_decoded_tile(self, level: TmapLevel, idx: int) -> Image.Image:
|
|
214
|
+
# Key by (level index, tile index): tile idx values repeat across
|
|
215
|
+
# pyramid levels and must not share cache slots.
|
|
216
|
+
key = (level.index, idx)
|
|
217
|
+
tile = self._tile_cache.get(key)
|
|
218
|
+
if tile is not None:
|
|
219
|
+
return tile
|
|
220
|
+
fh = self._ensure_open_handle()
|
|
221
|
+
t = level.tiles[idx]
|
|
222
|
+
with self._io_lock:
|
|
223
|
+
fh.seek(t.offset)
|
|
224
|
+
jpeg = fh.read(t.size)
|
|
225
|
+
tile = Image.open(io.BytesIO(jpeg)).convert("RGB")
|
|
226
|
+
self._tile_cache.put(key, tile)
|
|
227
|
+
return tile
|
|
228
|
+
|
|
229
|
+
def _get_cached_tile(self, offset: int, length: int) -> Image.Image:
|
|
230
|
+
"""Read + decode a raw tile by file range, with caching."""
|
|
231
|
+
key = ("raw", offset, length)
|
|
232
|
+
tile = self._tile_cache.get(key)
|
|
233
|
+
if tile is not None:
|
|
234
|
+
return tile
|
|
235
|
+
fh = self._ensure_open_handle()
|
|
236
|
+
with self._io_lock:
|
|
237
|
+
fh.seek(offset)
|
|
238
|
+
data = fh.read(length)
|
|
239
|
+
tile = Image.open(io.BytesIO(data)).convert("RGB")
|
|
240
|
+
self._tile_cache.put(key, tile)
|
|
241
|
+
return tile
|
|
242
|
+
|
|
243
|
+
def __repr__(self) -> str:
|
|
244
|
+
return f"{self.__class__.__name__}({self._filename!r})"
|
|
245
|
+
|
|
246
|
+
def __enter__(self):
|
|
247
|
+
return self
|
|
248
|
+
|
|
249
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
250
|
+
self.close()
|
|
251
|
+
return False
|
|
252
|
+
|
|
253
|
+
def close(self) -> None:
|
|
254
|
+
"""Close the slide and release resources."""
|
|
255
|
+
self._closed = True
|
|
256
|
+
self._tile_cache.clear()
|
|
257
|
+
if self._file_handle is not None:
|
|
258
|
+
try:
|
|
259
|
+
self._file_handle.close()
|
|
260
|
+
except Exception:
|
|
261
|
+
pass
|
|
262
|
+
self._file_handle = None
|
|
263
|
+
|
|
264
|
+
# ------------------------------------------------------------------
|
|
265
|
+
# Properties
|
|
266
|
+
# ------------------------------------------------------------------
|
|
267
|
+
|
|
268
|
+
@property
|
|
269
|
+
def level_count(self) -> int:
|
|
270
|
+
"""Number of pyramid levels."""
|
|
271
|
+
self._check_open()
|
|
272
|
+
return len(self._info.levels)
|
|
273
|
+
|
|
274
|
+
@property
|
|
275
|
+
def dimensions(self) -> Tuple[int, int]:
|
|
276
|
+
"""Dimensions of the slide at level 0 (highest resolution)."""
|
|
277
|
+
self._check_open()
|
|
278
|
+
return self._info.levels[0].width, self._info.levels[0].height
|
|
279
|
+
|
|
280
|
+
@property
|
|
281
|
+
def level_dimensions(self) -> Tuple[Tuple[int, int], ...]:
|
|
282
|
+
"""Dimensions of each pyramid level."""
|
|
283
|
+
self._check_open()
|
|
284
|
+
return tuple((l.width, l.height) for l in self._info.levels)
|
|
285
|
+
|
|
286
|
+
@property
|
|
287
|
+
def level_downsamples(self) -> Tuple[float, ...]:
|
|
288
|
+
"""Downsample factor for each level."""
|
|
289
|
+
self._check_open()
|
|
290
|
+
return tuple(l.downsample for l in self._info.levels)
|
|
291
|
+
|
|
292
|
+
@property
|
|
293
|
+
def properties(self) -> Mapping[str, str]:
|
|
294
|
+
"""Metadata properties as a read-only mapping."""
|
|
295
|
+
self._check_open()
|
|
296
|
+
return self._properties
|
|
297
|
+
|
|
298
|
+
@property
|
|
299
|
+
def associated_images(self) -> Mapping[str, Image.Image]:
|
|
300
|
+
"""Associated images (macro, label, ...) as a lazy mapping."""
|
|
301
|
+
self._check_open()
|
|
302
|
+
return self._associated_images
|
|
303
|
+
|
|
304
|
+
@property
|
|
305
|
+
def color_profile(self) -> Optional[object]:
|
|
306
|
+
"""Embedded ICC color profile, or None if not available."""
|
|
307
|
+
self._check_open()
|
|
308
|
+
return None
|
|
309
|
+
|
|
310
|
+
# ------------------------------------------------------------------
|
|
311
|
+
# Reading operations
|
|
312
|
+
# ------------------------------------------------------------------
|
|
313
|
+
|
|
314
|
+
def get_best_level_for_downsample(self, downsample: float) -> int:
|
|
315
|
+
"""Get the best pyramid level for a given downsample factor."""
|
|
316
|
+
self._check_open()
|
|
317
|
+
best = 0
|
|
318
|
+
for i, ds in enumerate(l.downsample for l in self._info.levels):
|
|
319
|
+
if ds <= downsample:
|
|
320
|
+
best = i
|
|
321
|
+
else:
|
|
322
|
+
break
|
|
323
|
+
return best
|
|
324
|
+
|
|
325
|
+
def read_region(
|
|
326
|
+
self,
|
|
327
|
+
location: Tuple[int, int],
|
|
328
|
+
level: int,
|
|
329
|
+
size: Tuple[int, int],
|
|
330
|
+
) -> Image.Image:
|
|
331
|
+
"""Read a region from the slide.
|
|
332
|
+
|
|
333
|
+
Args:
|
|
334
|
+
location: (x, y) top-left coordinates in level 0.
|
|
335
|
+
level: Pyramid level.
|
|
336
|
+
size: (width, height) output size in level pixels.
|
|
337
|
+
|
|
338
|
+
Returns:
|
|
339
|
+
PIL.Image.Image (RGBA).
|
|
340
|
+
"""
|
|
341
|
+
self._check_open()
|
|
342
|
+
if level < 0 or level >= len(self._info.levels):
|
|
343
|
+
raise OpenSlideError(f"Invalid level {level}")
|
|
344
|
+
if self._info.version == 6:
|
|
345
|
+
region = self._read_region_v6(location, level, size)
|
|
346
|
+
else:
|
|
347
|
+
region = self._read_region_tmap07(location, level, size)
|
|
348
|
+
return region
|
|
349
|
+
|
|
350
|
+
def _read_region_tmap07(
|
|
351
|
+
self,
|
|
352
|
+
location: Tuple[int, int],
|
|
353
|
+
level: int,
|
|
354
|
+
size: Tuple[int, int],
|
|
355
|
+
) -> Image.Image:
|
|
356
|
+
lvl = self._info.levels[level]
|
|
357
|
+
ds = lvl.downsample
|
|
358
|
+
x0 = int(location[0] / ds)
|
|
359
|
+
y0 = int(location[1] / ds)
|
|
360
|
+
w, h = int(size[0]), int(size[1])
|
|
361
|
+
|
|
362
|
+
out = Image.new("RGBA", (w, h), self._background_rgba)
|
|
363
|
+
if w <= 0 or h <= 0:
|
|
364
|
+
return out
|
|
365
|
+
|
|
366
|
+
tw, th = lvl.tile_width, lvl.tile_height
|
|
367
|
+
tx_start = x0 // tw
|
|
368
|
+
ty_start = y0 // th
|
|
369
|
+
tx_end = (x0 + w - 1) // tw + 1
|
|
370
|
+
ty_end = (y0 + h - 1) // th + 1
|
|
371
|
+
|
|
372
|
+
self._ensure_open_handle()
|
|
373
|
+
|
|
374
|
+
for ty in range(ty_start, ty_end):
|
|
375
|
+
for tx in range(tx_start, tx_end):
|
|
376
|
+
tile_x = tx * tw
|
|
377
|
+
tile_y = ty * th
|
|
378
|
+
idx = self._tile_lookup.get((level, tile_x, tile_y))
|
|
379
|
+
if idx is None:
|
|
380
|
+
continue
|
|
381
|
+
tile = self._read_decoded_tile(lvl, idx)
|
|
382
|
+
crop_x0 = max(0, x0 - tile_x)
|
|
383
|
+
crop_y0 = max(0, y0 - tile_y)
|
|
384
|
+
crop_x1 = min(tile.width, x0 + w - tile_x)
|
|
385
|
+
crop_y1 = min(tile.height, y0 + h - tile_y)
|
|
386
|
+
if crop_x1 > crop_x0 and crop_y1 > crop_y0:
|
|
387
|
+
cropped = tile.crop((crop_x0, crop_y0, crop_x1, crop_y1))
|
|
388
|
+
out.paste(cropped, (tile_x - x0 + crop_x0, tile_y - y0 + crop_y0))
|
|
389
|
+
|
|
390
|
+
return out
|
|
391
|
+
|
|
392
|
+
def _read_region_v6(
|
|
393
|
+
self,
|
|
394
|
+
location: Tuple[int, int],
|
|
395
|
+
level: int,
|
|
396
|
+
size: Tuple[int, int],
|
|
397
|
+
) -> Image.Image:
|
|
398
|
+
"""TMAP06: image blocks laid out on a (img_col x img_row) grid.
|
|
399
|
+
|
|
400
|
+
Level 0 renders the layer-0 4x4 tile grid of every overlapping
|
|
401
|
+
block; level 1 uses the layer-1 whole-block tile; deeper levels use
|
|
402
|
+
ShrinkTiles when available.
|
|
403
|
+
"""
|
|
404
|
+
info = self._info
|
|
405
|
+
blocks = info.__dict__.get("_blocks") or {}
|
|
406
|
+
shrinks = info.__dict__.get("_shrinks") or []
|
|
407
|
+
ratio_step = info.__dict__.get("_ratio_step", 4)
|
|
408
|
+
props = info.properties
|
|
409
|
+
img_w = int(props.get("tmap.img_width", 2448))
|
|
410
|
+
img_h = int(props.get("tmap.img_height", 2048))
|
|
411
|
+
fh = self._ensure_open_handle()
|
|
412
|
+
|
|
413
|
+
# clamp level to what we can render
|
|
414
|
+
if shrinks:
|
|
415
|
+
max_renderable = 2
|
|
416
|
+
elif any(layer_no >= 1 for v in blocks.values() for layer_no, _, _, _, _ in v["tiles"]):
|
|
417
|
+
max_renderable = 1
|
|
418
|
+
else:
|
|
419
|
+
max_renderable = 0
|
|
420
|
+
level = min(level, max_renderable)
|
|
421
|
+
downsample = ratio_step**level
|
|
422
|
+
x0 = int(location[0] / downsample)
|
|
423
|
+
y0 = int(location[1] / downsample)
|
|
424
|
+
w, h = int(size[0]), int(size[1])
|
|
425
|
+
out = Image.new("RGBA", (w, h), self._background_rgba)
|
|
426
|
+
if w <= 0 or h <= 0:
|
|
427
|
+
return out
|
|
428
|
+
|
|
429
|
+
# level-0 footprint of the requested region
|
|
430
|
+
x0_l0 = int(location[0])
|
|
431
|
+
y0_l0 = int(location[1])
|
|
432
|
+
w0 = int(w * downsample)
|
|
433
|
+
h0 = int(h * downsample)
|
|
434
|
+
bkg = int(props.get("tmap.bkg_color", 244))
|
|
435
|
+
rgb = (bkg, bkg, bkg)
|
|
436
|
+
|
|
437
|
+
def paste_scaled(tile_img, tile_x0, tile_y0, cover_w, cover_h):
|
|
438
|
+
"""Paste tile covering cover_w x cover_h at (tile_x0, tile_y0)
|
|
439
|
+
in level-0 coords into the output region."""
|
|
440
|
+
ov_x0 = max(x0_l0, tile_x0)
|
|
441
|
+
ov_y0 = max(y0_l0, tile_y0)
|
|
442
|
+
ov_x1 = min(x0_l0 + w0, tile_x0 + cover_w)
|
|
443
|
+
ov_y1 = min(y0_l0 + h0, tile_y0 + cover_h)
|
|
444
|
+
if ov_x1 <= ov_x0 or ov_y1 <= ov_y0:
|
|
445
|
+
return
|
|
446
|
+
sx = tile_img.width / cover_w
|
|
447
|
+
sy = tile_img.height / cover_h
|
|
448
|
+
src_x = int((ov_x0 - tile_x0) * sx)
|
|
449
|
+
src_y = int((ov_y0 - tile_y0) * sy)
|
|
450
|
+
src_w = max(1, int((ov_x1 - ov_x0) * sx))
|
|
451
|
+
src_h = max(1, int((ov_y1 - ov_y0) * sy))
|
|
452
|
+
cropped = tile_img.crop(
|
|
453
|
+
(src_x, src_y, src_x + src_w, src_y + src_h)
|
|
454
|
+
)
|
|
455
|
+
dst_x = int((ov_x0 - x0_l0) / downsample)
|
|
456
|
+
dst_y = int((ov_y0 - y0_l0) / downsample)
|
|
457
|
+
dst_w = max(1, int((ov_x1 - ov_x0) / downsample))
|
|
458
|
+
dst_h = max(1, int((ov_y1 - ov_y0) / downsample))
|
|
459
|
+
if cropped.size != (dst_w, dst_h):
|
|
460
|
+
cropped = cropped.resize((dst_w, dst_h), Image.BILINEAR)
|
|
461
|
+
out.paste(cropped, (dst_x, dst_y))
|
|
462
|
+
|
|
463
|
+
if level >= 2 and shrinks:
|
|
464
|
+
# ShrinkTiles: pre-rendered tiles for deep zoom-out levels.
|
|
465
|
+
for layer_no, n_x, n_y, off, length in shrinks:
|
|
466
|
+
if layer_no != level:
|
|
467
|
+
continue
|
|
468
|
+
scale = ratio_step**layer_no
|
|
469
|
+
tile_img = self._get_cached_tile(off, length)
|
|
470
|
+
cover_w = info.__dict__["_tile_jpeg_size"][0] * scale
|
|
471
|
+
cover_h = info.__dict__["_tile_jpeg_size"][1] * scale
|
|
472
|
+
if (
|
|
473
|
+
n_x + cover_w < x0_l0
|
|
474
|
+
or n_x > x0_l0 + w0
|
|
475
|
+
or n_y + cover_h < y0_l0
|
|
476
|
+
or n_y > y0_l0 + h0
|
|
477
|
+
):
|
|
478
|
+
continue
|
|
479
|
+
paste_scaled(tile_img, n_x, n_y, cover_w, cover_h)
|
|
480
|
+
return out
|
|
481
|
+
|
|
482
|
+
# levels 0 and 1: walk blocks overlapping the region
|
|
483
|
+
has_layer1 = any(
|
|
484
|
+
layer_no == 1 for v in blocks.values() for layer_no, _, _, _, _ in v["tiles"]
|
|
485
|
+
)
|
|
486
|
+
for (n_col, n_row), blk in blocks.items():
|
|
487
|
+
n_x, n_y = blk["n_x"], blk["n_y"]
|
|
488
|
+
if n_x + img_w < x0_l0 or n_x > x0_l0 + w0 or n_y + img_h < y0_l0 or n_y > y0_l0 + h0:
|
|
489
|
+
continue
|
|
490
|
+
for layer_no, col, row, off, length in blk["tiles"]:
|
|
491
|
+
if layer_no != level:
|
|
492
|
+
continue
|
|
493
|
+
tile_img = self._get_cached_tile(off, length)
|
|
494
|
+
if layer_no > 0:
|
|
495
|
+
# one tile covers the whole block
|
|
496
|
+
tile_x0, tile_y0 = n_x, n_y
|
|
497
|
+
cover_w, cover_h = img_w, img_h
|
|
498
|
+
else:
|
|
499
|
+
tile_x0 = n_x + col * tile_img.width
|
|
500
|
+
tile_y0 = n_y + row * tile_img.height
|
|
501
|
+
cover_w, cover_h = tile_img.width, tile_img.height
|
|
502
|
+
paste_scaled(tile_img, tile_x0, tile_y0, cover_w, cover_h)
|
|
503
|
+
return out
|
|
504
|
+
|
|
505
|
+
def get_thumbnail(self, size: Tuple[int, int]) -> Image.Image:
|
|
506
|
+
"""Get a thumbnail image no larger than `size`."""
|
|
507
|
+
self._check_open()
|
|
508
|
+
# Prefer the lowest-resolution pyramid level.
|
|
509
|
+
level = self.level_count - 1
|
|
510
|
+
dims = self.level_dimensions[level]
|
|
511
|
+
region = self.read_region((0, 0), level, dims)
|
|
512
|
+
region.thumbnail(size, Image.LANCZOS)
|
|
513
|
+
return region
|
|
514
|
+
|
|
515
|
+
def iter_tiles(self, level: int = 0):
|
|
516
|
+
"""Iterate over stored tiles at the given level.
|
|
517
|
+
|
|
518
|
+
Yields ``(x, y, load)`` where ``(x, y)`` is the tile's position in
|
|
519
|
+
level-0 pixel coordinates and ``load()`` returns the decoded
|
|
520
|
+
``PIL.Image`` (RGB). Useful for tile-based pipelines when the global
|
|
521
|
+
canvas geometry is not needed.
|
|
522
|
+
|
|
523
|
+
Note: TMAP06 stores overlapping tiles (612 px wide tiles on a
|
|
524
|
+
256 px x-grid); ``x, y`` are the stored grid positions.
|
|
525
|
+
"""
|
|
526
|
+
self._check_open()
|
|
527
|
+
if level < 0 or level >= len(self._info.levels):
|
|
528
|
+
raise OpenSlideError(f"Invalid level {level}")
|
|
529
|
+
lvl = self._info.levels[level]
|
|
530
|
+
for idx, t in enumerate(lvl.tiles):
|
|
531
|
+
def load(idx=idx, lvl=lvl):
|
|
532
|
+
return self._read_decoded_tile(lvl, idx).copy()
|
|
533
|
+
yield t.x, t.y, load
|
|
534
|
+
|
|
535
|
+
def set_cache(self, cache) -> None:
|
|
536
|
+
"""Accepts a cache argument for API compatibility (no-op)."""
|
|
537
|
+
self._check_open()
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
# Convenience alias matching the kfbslide naming style.
|
|
541
|
+
TmapSlide = OpenSlide
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def open_slide(filename: str) -> OpenSlide:
|
|
545
|
+
"""Open a TMAP whole-slide image."""
|
|
546
|
+
return OpenSlide(filename)
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
__all__ = [
|
|
550
|
+
"OpenSlide",
|
|
551
|
+
"TmapSlide",
|
|
552
|
+
"open_slide",
|
|
553
|
+
]
|