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.
- wsi_toolbox/__init__.py +122 -0
- wsi_toolbox/app.py +874 -0
- wsi_toolbox/cli.py +599 -0
- wsi_toolbox/commands/__init__.py +66 -0
- wsi_toolbox/commands/clustering.py +198 -0
- wsi_toolbox/commands/data_loader.py +219 -0
- wsi_toolbox/commands/dzi.py +160 -0
- wsi_toolbox/commands/patch_embedding.py +196 -0
- wsi_toolbox/commands/pca.py +206 -0
- wsi_toolbox/commands/preview.py +394 -0
- wsi_toolbox/commands/show.py +171 -0
- wsi_toolbox/commands/umap_embedding.py +174 -0
- wsi_toolbox/commands/wsi.py +223 -0
- wsi_toolbox/common.py +148 -0
- wsi_toolbox/models.py +30 -0
- wsi_toolbox/utils/__init__.py +109 -0
- wsi_toolbox/utils/analysis.py +174 -0
- wsi_toolbox/utils/hdf5_paths.py +232 -0
- wsi_toolbox/utils/plot.py +227 -0
- wsi_toolbox/utils/progress.py +207 -0
- wsi_toolbox/utils/seed.py +26 -0
- wsi_toolbox/utils/st.py +55 -0
- wsi_toolbox/utils/white.py +121 -0
- wsi_toolbox/watcher.py +256 -0
- wsi_toolbox/wsi_files.py +619 -0
- wsi_toolbox-0.2.0.dist-info/METADATA +253 -0
- wsi_toolbox-0.2.0.dist-info/RECORD +30 -0
- wsi_toolbox-0.2.0.dist-info/WHEEL +4 -0
- wsi_toolbox-0.2.0.dist-info/entry_points.txt +3 -0
- wsi_toolbox-0.2.0.dist-info/licenses/LICENSE +21 -0
wsi_toolbox/wsi_files.py
ADDED
|
@@ -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}")
|