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 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)
@@ -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
+ ]