wsi-toolbox 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.
@@ -0,0 +1,619 @@
1
+ """
2
+ WSI (Whole Slide Image) file handling classes.
3
+
4
+ Provides unified interface for different WSI formats:
5
+ - OpenSlide compatible formats (.svs, .tiff, etc.)
6
+ - TIFF files (.ndpi, .tif)
7
+ - Standard images (.jpg, .png)
8
+
9
+ Class hierarchy:
10
+ WSIFile (base)
11
+ ├── StandardImage (DZI non-supported)
12
+ └── PyramidalWSIFile (DZI shared logic)
13
+ ├── OpenSlideFile
14
+ └── PyramidalTiffFile
15
+ """
16
+
17
+ import math
18
+ import os
19
+ from abc import ABC, abstractmethod
20
+ from dataclasses import dataclass
21
+
22
+ import cv2
23
+ import numpy as np
24
+ import tifffile
25
+ import zarr
26
+ from openslide import OpenSlide
27
+ from PIL import Image
28
+
29
+
30
+ @dataclass
31
+ class NativeLevel:
32
+ """Information about a native pyramid level."""
33
+
34
+ index: int # Level index (0 = highest resolution)
35
+ width: int
36
+ height: int
37
+ downsample: float # Downsample factor relative to level 0
38
+
39
+
40
+ class WSIFile(ABC):
41
+ """Base class for WSI file readers"""
42
+
43
+ @abstractmethod
44
+ def get_mpp(self) -> float:
45
+ """Get microns per pixel"""
46
+ pass
47
+
48
+ @abstractmethod
49
+ def get_original_size(self) -> tuple[int, int]:
50
+ """Get original image size (width, height)"""
51
+ pass
52
+
53
+ @abstractmethod
54
+ def read_region(self, xywh) -> np.ndarray:
55
+ """Read region as RGB numpy array
56
+
57
+ Args:
58
+ xywh: tuple of (x, y, width, height)
59
+
60
+ Returns:
61
+ np.ndarray: RGB image (H, W, 3)
62
+ """
63
+ pass
64
+
65
+ # === DZI (Deep Zoom Image) methods ===
66
+
67
+ def get_dzi_max_level(self) -> int:
68
+ """Get maximum DZI pyramid level.
69
+
70
+ Returns:
71
+ Maximum level (0 = 1x1, max = original resolution)
72
+ """
73
+ raise NotImplementedError("DZI not supported for this file type")
74
+
75
+ def get_dzi_xml(self, tile_size: int = 256, overlap: int = 0, format: str = "jpeg") -> str:
76
+ """Generate DZI XML metadata string.
77
+
78
+ Args:
79
+ tile_size: Tile size in pixels (default: 256)
80
+ overlap: Overlap in pixels (default: 0)
81
+ format: Image format ("jpeg" or "png")
82
+
83
+ Returns:
84
+ DZI XML string
85
+ """
86
+ width, height = self.get_original_size()
87
+ return f'''<?xml version="1.0" encoding="utf-8"?>
88
+ <Image xmlns="http://schemas.microsoft.com/deepzoom/2008"
89
+ Format="{format}"
90
+ Overlap="{overlap}"
91
+ TileSize="{tile_size}">
92
+ <Size Width="{width}" Height="{height}"/>
93
+ </Image>'''
94
+
95
+ def get_dzi_level_info(self, level: int, tile_size: int = 256) -> tuple[int, int, int, int]:
96
+ """Get DZI level dimensions and tile counts.
97
+
98
+ Args:
99
+ level: DZI pyramid level
100
+ tile_size: Tile size in pixels
101
+
102
+ Returns:
103
+ (level_width, level_height, cols, rows)
104
+ """
105
+ raise NotImplementedError("DZI not supported for this file type")
106
+
107
+ def get_dzi_tile(self, level: int, col: int, row: int, tile_size: int = 256, overlap: int = 0) -> np.ndarray:
108
+ """Get a DZI tile as numpy array.
109
+
110
+ Args:
111
+ level: DZI pyramid level (0 = lowest resolution, max = original)
112
+ col: Tile column
113
+ row: Tile row
114
+ tile_size: Tile size in pixels (default: 256)
115
+ overlap: Overlap in pixels (default: 0)
116
+
117
+ Returns:
118
+ np.ndarray: RGB image (H, W, 3), may be smaller than tile_size at edges
119
+ """
120
+ raise NotImplementedError("DZI not supported for this file type")
121
+
122
+ def iter_dzi_tiles(self, tile_size: int = 256, overlap: int = 0):
123
+ """Iterate over all DZI tiles.
124
+
125
+ Yields:
126
+ (level, col, row, tile_array) for each tile
127
+ """
128
+ raise NotImplementedError("DZI not supported for this file type")
129
+
130
+ def generate_thumbnail(
131
+ self,
132
+ width: int = -1,
133
+ height: int = -1,
134
+ ) -> np.ndarray:
135
+ """Generate thumbnail from WSI.
136
+
137
+ Args:
138
+ width: Target width. If < 0, calculated from height keeping aspect ratio.
139
+ height: Target height. If < 0, calculated from width keeping aspect ratio.
140
+ If both specified, image is center-cropped to match target aspect ratio.
141
+
142
+ Returns:
143
+ np.ndarray: RGB thumbnail image (H, W, 3)
144
+
145
+ Raises:
146
+ ValueError: If both width and height are < 0
147
+ """
148
+ if width < 0 and height < 0:
149
+ raise ValueError("Either width or height must be specified")
150
+
151
+ src_w, src_h = self.get_original_size()
152
+ src_aspect = src_w / src_h
153
+
154
+ # Determine target dimensions
155
+ if width < 0:
156
+ # Height specified, calculate width
157
+ width = int(height * src_aspect)
158
+ elif height < 0:
159
+ # Width specified, calculate height
160
+ height = int(width / src_aspect)
161
+
162
+ # Both specified: center crop to match target aspect ratio
163
+ target_aspect = width / height
164
+
165
+ if abs(src_aspect - target_aspect) < 0.01:
166
+ # Same aspect ratio, no crop needed
167
+ crop_x, crop_y, crop_w, crop_h = 0, 0, src_w, src_h
168
+ elif src_aspect > target_aspect:
169
+ # Source is wider, crop horizontally
170
+ crop_h = src_h
171
+ crop_w = int(src_h * target_aspect)
172
+ crop_x = (src_w - crop_w) // 2
173
+ crop_y = 0
174
+ else:
175
+ # Source is taller, crop vertically
176
+ crop_w = src_w
177
+ crop_h = int(src_w / target_aspect)
178
+ crop_x = 0
179
+ crop_y = (src_h - crop_h) // 2
180
+
181
+ # Read cropped region (subclasses may override for efficiency)
182
+ region = self._read_for_thumbnail(crop_x, crop_y, crop_w, crop_h, width, height)
183
+
184
+ # Resize to target
185
+ img = Image.fromarray(region)
186
+ thumbnail = img.resize((width, height), Image.Resampling.LANCZOS)
187
+ return np.array(thumbnail)
188
+
189
+ def _read_for_thumbnail(self, x: int, y: int, w: int, h: int, target_w: int, target_h: int) -> np.ndarray:
190
+ """Read region for thumbnail. Override for efficient multi-resolution reading.
191
+
192
+ Args:
193
+ x, y, w, h: Crop region in level 0 coordinates
194
+ target_w, target_h: Final target size (for downsample calculation)
195
+
196
+ Returns:
197
+ np.ndarray: RGB image (H, W, 3)
198
+ """
199
+ return self.read_region((x, y, w, h))
200
+
201
+
202
+ class PyramidalWSIFile(WSIFile):
203
+ """Base class for pyramidal WSI files with DZI support.
204
+
205
+ Subclasses must implement:
206
+ - get_mpp()
207
+ - get_original_size()
208
+ - read_region()
209
+ - _get_native_levels() -> list[NativeLevel]
210
+ - _read_native_region(level_idx, x, y, w, h) -> np.ndarray
211
+ """
212
+
213
+ @abstractmethod
214
+ def _get_native_levels(self) -> list[NativeLevel]:
215
+ """Get list of native pyramid levels.
216
+
217
+ Returns:
218
+ List of NativeLevel, sorted by downsample (level 0 first)
219
+ """
220
+ pass
221
+
222
+ @abstractmethod
223
+ def _read_native_region(self, level_idx: int, x: int, y: int, w: int, h: int) -> np.ndarray:
224
+ """Read a region from a specific native level.
225
+
226
+ Args:
227
+ level_idx: Index into _get_native_levels()
228
+ x, y: Top-left corner in native level coordinates
229
+ w, h: Size in native level coordinates
230
+
231
+ Returns:
232
+ np.ndarray: RGB image (H, W, 3)
233
+ """
234
+ pass
235
+
236
+ def get_dzi_max_level(self) -> int:
237
+ """Get maximum DZI pyramid level."""
238
+ width, height = self.get_original_size()
239
+ return math.ceil(math.log2(max(width, height)))
240
+
241
+ def get_dzi_level_info(self, level: int, tile_size: int = 256) -> tuple[int, int, int, int]:
242
+ """Get DZI level dimensions and tile counts.
243
+
244
+ Args:
245
+ level: DZI pyramid level
246
+ tile_size: Tile size in pixels
247
+
248
+ Returns:
249
+ (level_width, level_height, cols, rows)
250
+ """
251
+ width, height = self.get_original_size()
252
+ max_level = self.get_dzi_max_level()
253
+ dzi_downsample = 2 ** (max_level - level)
254
+ level_width = math.ceil(width / dzi_downsample)
255
+ level_height = math.ceil(height / dzi_downsample)
256
+ cols = math.ceil(level_width / tile_size)
257
+ rows = math.ceil(level_height / tile_size)
258
+ return level_width, level_height, cols, rows
259
+
260
+ def iter_dzi_tiles(self, tile_size: int = 256, overlap: int = 0):
261
+ """Iterate over all DZI tiles.
262
+
263
+ Yields:
264
+ (level, col, row, tile_array) for each tile
265
+ """
266
+ max_level = self.get_dzi_max_level()
267
+ for level in range(max_level, -1, -1):
268
+ _, _, cols, rows = self.get_dzi_level_info(level, tile_size)
269
+ for row in range(rows):
270
+ for col in range(cols):
271
+ tile = self.get_dzi_tile(level, col, row, tile_size, overlap)
272
+ yield level, col, row, tile
273
+
274
+ def get_dzi_tile(self, level: int, col: int, row: int, tile_size: int = 256, overlap: int = 0) -> np.ndarray:
275
+ """Get a DZI tile as numpy array."""
276
+ width, height = self.get_original_size()
277
+ max_level = self.get_dzi_max_level()
278
+
279
+ # DZI downsample factor
280
+ dzi_downsample = 2 ** (max_level - level)
281
+
282
+ # Find best native level for this DZI level
283
+ native_levels = self._get_native_levels()
284
+ native_level_idx = self._find_best_native_level(native_levels, dzi_downsample)
285
+ native_downsample = native_levels[native_level_idx].downsample
286
+
287
+ # Calculate tile position in level 0 coordinates
288
+ dzi_x = col * tile_size
289
+ dzi_y = row * tile_size
290
+ level0_x = int(dzi_x * dzi_downsample)
291
+ level0_y = int(dzi_y * dzi_downsample)
292
+
293
+ # Calculate actual tile size (clamped to image bounds)
294
+ level_width = math.ceil(width / dzi_downsample)
295
+ level_height = math.ceil(height / dzi_downsample)
296
+
297
+ tile_right = min(dzi_x + tile_size + overlap, level_width)
298
+ tile_bottom = min(dzi_y + tile_size + overlap, level_height)
299
+ actual_width = tile_right - dzi_x + (overlap if dzi_x > 0 else 0)
300
+ actual_height = tile_bottom - dzi_y + (overlap if dzi_y > 0 else 0)
301
+
302
+ # Adjust for left/top overlap
303
+ if dzi_x > 0:
304
+ level0_x -= int(overlap * dzi_downsample)
305
+ if dzi_y > 0:
306
+ level0_y -= int(overlap * dzi_downsample)
307
+
308
+ # Size to read from native level (in native level coordinates)
309
+ read_width = int(actual_width * dzi_downsample / native_downsample)
310
+ read_height = int(actual_height * dzi_downsample / native_downsample)
311
+
312
+ # Read from native level
313
+ region = self._read_native_region(
314
+ native_level_idx,
315
+ int(level0_x / native_downsample),
316
+ int(level0_y / native_downsample),
317
+ read_width,
318
+ read_height,
319
+ )
320
+
321
+ # Resize if native level doesn't match DZI level exactly
322
+ if abs(native_downsample - dzi_downsample) > 0.01:
323
+ img = Image.fromarray(region)
324
+ region = np.array(img.resize((actual_width, actual_height), Image.Resampling.LANCZOS))
325
+
326
+ return region
327
+
328
+ def _find_best_native_level(self, levels: list[NativeLevel], target_downsample: float) -> int:
329
+ """Find the native level index closest to target downsample factor."""
330
+ best_idx = 0
331
+ best_diff = float("inf")
332
+
333
+ for idx, level in enumerate(levels):
334
+ diff = abs(level.downsample - target_downsample)
335
+ if diff < best_diff:
336
+ best_diff = diff
337
+ best_idx = idx
338
+
339
+ return best_idx
340
+
341
+ def _read_for_thumbnail(self, x: int, y: int, w: int, h: int, target_w: int, target_h: int) -> np.ndarray:
342
+ """Read region using pyramid levels for efficiency.
343
+
344
+ Args:
345
+ x, y, w, h: Crop region in level 0 coordinates
346
+ target_w, target_h: Final target size (for downsample calculation)
347
+
348
+ Returns:
349
+ np.ndarray: RGB image (H, W, 3)
350
+ """
351
+ # Calculate required downsample factor
352
+ target_downsample = max(w / target_w, h / target_h)
353
+
354
+ # Find best native level
355
+ native_levels = self._get_native_levels()
356
+ best_level_idx = self._find_best_native_level(native_levels, target_downsample)
357
+ level_downsample = native_levels[best_level_idx].downsample
358
+
359
+ # Convert to native level coordinates
360
+ level_x = int(x / level_downsample)
361
+ level_y = int(y / level_downsample)
362
+ level_w = int(w / level_downsample)
363
+ level_h = int(h / level_downsample)
364
+
365
+ return self._read_native_region(best_level_idx, level_x, level_y, level_w, level_h)
366
+
367
+
368
+ class PyramidalTiffFile(PyramidalWSIFile):
369
+ """Pyramidal TIFF file reader using tifffile library
370
+
371
+ Supports multi-resolution TIFF files (e.g., .ndpi).
372
+ For single-level TIFF, use StandardImage instead.
373
+ """
374
+
375
+ def __init__(self, path):
376
+ self.tif = tifffile.TiffFile(path)
377
+ self.path = path
378
+
379
+ # Build pyramid info
380
+ self._levels = self._build_level_info()
381
+
382
+ # Zarr store for level 0 (for efficient tiled reading)
383
+ store = self.tif.pages[0].aszarr()
384
+ self._zarr_level0 = zarr.open(store, mode="r")
385
+
386
+ def _build_level_info(self) -> list[NativeLevel]:
387
+ """Build pyramid level information from TIFF pages."""
388
+ levels = []
389
+ base_width = None
390
+
391
+ for i, page in enumerate(self.tif.pages):
392
+ # Skip non-image pages (thumbnails, etc.)
393
+ if page.shape[0] < 100 or page.shape[1] < 100:
394
+ continue
395
+
396
+ h, w = page.shape[0], page.shape[1]
397
+
398
+ if base_width is None:
399
+ base_width = w
400
+ downsample = 1.0
401
+ else:
402
+ downsample = base_width / w
403
+
404
+ levels.append(NativeLevel(index=i, width=w, height=h, downsample=downsample))
405
+
406
+ return levels
407
+
408
+ def get_original_size(self):
409
+ s = self.tif.pages[0].shape
410
+ return (s[1], s[0])
411
+
412
+ def get_mpp(self):
413
+ tags = self.tif.pages[0].tags
414
+ resolution_unit = tags.get("ResolutionUnit", None)
415
+ x_resolution = tags.get("XResolution", None)
416
+
417
+ assert resolution_unit
418
+ assert x_resolution
419
+
420
+ x_res_value = x_resolution.value
421
+ if isinstance(x_res_value, tuple) and len(x_res_value) == 2:
422
+ numerator, denominator = x_res_value
423
+ resolution = numerator / denominator
424
+ else:
425
+ resolution = x_res_value
426
+
427
+ if resolution_unit.value == 2: # inch
428
+ mpp = 25400.0 / resolution
429
+ elif resolution_unit.value == 3: # cm
430
+ mpp = 10000.0 / resolution
431
+ else:
432
+ mpp = 1.0 / resolution
433
+
434
+ return mpp
435
+
436
+ def read_region(self, xywh):
437
+ x, y, width, height = xywh
438
+ page = self.tif.pages[0]
439
+
440
+ full_width = page.shape[1]
441
+ full_height = page.shape[0]
442
+
443
+ x = max(0, min(x, full_width - 1))
444
+ y = max(0, min(y, full_height - 1))
445
+ width = min(width, full_width - x)
446
+ height = min(height, full_height - y)
447
+
448
+ if page.is_tiled:
449
+ region = self._zarr_level0[y : y + height, x : x + width]
450
+ else:
451
+ full_image = page.asarray()
452
+ region = full_image[y : y + height, x : x + width]
453
+
454
+ return self._normalize_color(region)
455
+
456
+ # === PyramidalWSIFile abstract methods ===
457
+
458
+ def _get_native_levels(self) -> list[NativeLevel]:
459
+ return self._levels
460
+
461
+ def _read_native_region(self, level_idx: int, x: int, y: int, w: int, h: int) -> np.ndarray:
462
+ """Read a region from a specific TIFF level."""
463
+ level = self._levels[level_idx]
464
+ page = self.tif.pages[level.index]
465
+
466
+ # Clamp to bounds
467
+ x = max(0, min(x, level.width - 1))
468
+ y = max(0, min(y, level.height - 1))
469
+ w = min(w, level.width - x)
470
+ h = min(h, level.height - y)
471
+
472
+ if page.is_tiled:
473
+ store = page.aszarr()
474
+ zarr_data = zarr.open(store, mode="r")
475
+ region = zarr_data[y : y + h, x : x + w]
476
+ else:
477
+ full_image = page.asarray()
478
+ region = full_image[y : y + h, x : x + w]
479
+
480
+ return self._normalize_color(region)
481
+
482
+ def _normalize_color(self, region: np.ndarray) -> np.ndarray:
483
+ """Normalize color to RGB (H, W, 3)."""
484
+ if region.ndim == 2: # Grayscale
485
+ region = np.stack([region, region, region], axis=-1)
486
+ elif region.shape[2] == 4: # RGBA
487
+ region = region[:, :, :3]
488
+ return region
489
+
490
+
491
+ class OpenSlideFile(PyramidalWSIFile):
492
+ """OpenSlide compatible file reader"""
493
+
494
+ def __init__(self, path):
495
+ self.wsi = OpenSlide(path)
496
+ self.prop = dict(self.wsi.properties)
497
+
498
+ # Build level info from OpenSlide
499
+ self._levels = self._build_level_info()
500
+
501
+ def _build_level_info(self) -> list[NativeLevel]:
502
+ """Build pyramid level information from OpenSlide."""
503
+ levels = []
504
+ for i, (dim, downsample) in enumerate(zip(self.wsi.level_dimensions, self.wsi.level_downsamples)):
505
+ levels.append(NativeLevel(index=i, width=dim[0], height=dim[1], downsample=downsample))
506
+ return levels
507
+
508
+ def get_mpp(self):
509
+ return float(self.prop["openslide.mpp-x"])
510
+
511
+ def get_original_size(self):
512
+ dim = self.wsi.level_dimensions[0]
513
+ return (dim[0], dim[1])
514
+
515
+ def read_region(self, xywh):
516
+ img = self.wsi.read_region((xywh[0], xywh[1]), 0, (xywh[2], xywh[3])).convert("RGB")
517
+ return np.array(img)
518
+
519
+ # === PyramidalWSIFile abstract methods ===
520
+
521
+ def _get_native_levels(self) -> list[NativeLevel]:
522
+ return self._levels
523
+
524
+ def _read_native_region(self, level_idx: int, x: int, y: int, w: int, h: int) -> np.ndarray:
525
+ """Read a region from a specific OpenSlide level."""
526
+ level = self._levels[level_idx]
527
+
528
+ # OpenSlide read_region takes level 0 coordinates for location
529
+ level0_x = int(x * level.downsample)
530
+ level0_y = int(y * level.downsample)
531
+
532
+ region = self.wsi.read_region(
533
+ location=(level0_x, level0_y),
534
+ level=level.index,
535
+ size=(w, h),
536
+ )
537
+
538
+ # Convert RGBA to RGB
539
+ if region.mode == "RGBA":
540
+ region = region.convert("RGB")
541
+
542
+ return np.array(region)
543
+
544
+
545
+ class StandardImage(WSIFile):
546
+ """Standard image file reader (JPG, PNG, etc.)"""
547
+
548
+ def __init__(self, path, mpp):
549
+ self.image = cv2.imread(path)
550
+ self.image = cv2.cvtColor(self.image, cv2.COLOR_BGR2RGB) # OpenCVはBGR形式で読み込むのでRGBに変換
551
+ self.mpp = mpp
552
+ assert self.mpp is not None, "Specify mpp when using StandardImage"
553
+
554
+ def get_mpp(self):
555
+ return self.mpp
556
+
557
+ def get_original_size(self):
558
+ return self.image.shape[1], self.image.shape[0] # width, height
559
+
560
+ def read_region(self, xywh):
561
+ x, y, w, h = xywh
562
+ return self.image[y : y + h, x : x + w]
563
+
564
+
565
+ def _is_pyramidal_tiff(path: str) -> bool:
566
+ """Check if TIFF file has multiple resolution levels."""
567
+ try:
568
+ with tifffile.TiffFile(path) as tif:
569
+ # Count pages with reasonable size (skip thumbnails)
570
+ level_count = sum(1 for p in tif.pages if p.shape[0] >= 100 and p.shape[1] >= 100)
571
+ return level_count > 1
572
+ except Exception:
573
+ return False
574
+
575
+
576
+ def create_wsi_file(image_path: str, engine: str = "auto", mpp: float = 0.5) -> WSIFile:
577
+ """
578
+ Factory function to create appropriate WSIFile instance
579
+
580
+ Args:
581
+ image_path: Path to WSI file
582
+ engine: Engine type ('auto', 'openslide', 'tifffile', 'standard')
583
+ mpp: Default Microns Per Pixel (only used when engine == 'standard')
584
+
585
+ Returns:
586
+ WSIFile: Appropriate WSIFile subclass instance
587
+ """
588
+ ext = os.path.splitext(image_path)[1].lower()
589
+ basename = os.path.basename(image_path)
590
+
591
+ if engine == "auto":
592
+ if ext in [".tif", ".tiff"]:
593
+ # Check if pyramidal TIFF or single-level
594
+ if _is_pyramidal_tiff(image_path):
595
+ engine = "tifffile"
596
+ else:
597
+ engine = "standard"
598
+ elif ext in [".jpg", ".jpeg", ".png"]:
599
+ engine = "standard"
600
+ else:
601
+ # Default to openslide for WSI formats (.svs, .ndpi, etc.)
602
+ engine = "openslide"
603
+ print(f"using {engine} engine for {basename}")
604
+
605
+ engine = engine.lower()
606
+
607
+ if engine == "openslide":
608
+ try:
609
+ return OpenSlideFile(image_path)
610
+ except Exception as e:
611
+ # Fallback to tifffile for NDPI files that OpenSlide can't handle
612
+ print(f"OpenSlide failed for {basename}, falling back to tifffile: {e}")
613
+ return PyramidalTiffFile(image_path)
614
+ elif engine == "tifffile":
615
+ return PyramidalTiffFile(image_path)
616
+ elif engine == "standard":
617
+ return StandardImage(image_path, mpp=mpp)
618
+ else:
619
+ raise ValueError(f"Invalid engine: {engine}")