Skip to content

Commit 3f45191

Browse files
committed
[Add] Implement DeepZoom functionality for WSI files with support for tile retrieval and DZI generation
1 parent 3f463e3 commit 3f45191

File tree

3 files changed

+241
-1
lines changed

3 files changed

+241
-1
lines changed
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
from __future__ import annotations
2+
from collections.abc import Callable
3+
import os
4+
from pathlib import Path
5+
6+
import cv2
7+
import openslide
8+
from openslide.deepzoom import DeepZoomGenerator
9+
from typing import TYPE_CHECKING
10+
from PIL import Image
11+
import numpy as np
12+
import math
13+
14+
if TYPE_CHECKING:
15+
from typing import TypeAlias
16+
17+
Transform: TypeAlias = Callable[[Image.Image], None]
18+
19+
ENABLE_DEBUG = False
20+
21+
22+
class AnnotatedDeepZoomGenerator(DeepZoomGenerator):
23+
filename: str
24+
full_path: Path
25+
mpp: float
26+
transform: Transform
27+
28+
def __init__(
29+
self,
30+
osr,
31+
full_path: Path,
32+
tile_size: int = 254,
33+
overlap: int = 1,
34+
limit_bounds: bool = False,
35+
):
36+
super().__init__(osr, tile_size, overlap, limit_bounds)
37+
# heatmap = full_path.with_name(f"{full_path.stem}.heatmap.npy")
38+
self.is_sdpc = full_path.suffix == ".sdpc"
39+
# print(f"[HEATMAP] (exist: {os.path.isfile(heatmap)}) fetching heatmap: ", heatmap)
40+
self.visited = set()
41+
# if os.path.isfile(heatmap):
42+
# self.color_mask = np.load(heatmap)
43+
44+
# self.actual_size = self.color_mask.shape
45+
# gap = [abs(dim[0] - self.actual_size[1]) for dim in self._osr.level_dimensions]
46+
# self.ratio = gap.index(min(gap))
47+
# self.region_size = self._osr.level_dimensions[self.ratio]
48+
# largest = self._osr.level_dimensions[0]
49+
# # down_sample = self._osr.level_downsamples[self.ratio]
50+
# down_sample = 1 << round(math.log2(largest[0] / self.actual_size[1]))
51+
# offset = (
52+
# largest[0] / down_sample - self.color_mask.shape[1],
53+
# largest[1] / down_sample - self.color_mask.shape[0]
54+
# )
55+
# print(f"valid: {0 if any(offset) else 1} offset: ", offset)
56+
# else:
57+
# self.color_mask = None
58+
# self.ratio = None
59+
# self.region_size = None
60+
61+
def get_tile(
62+
self, level: int, address: tuple[int, int], heatmap=False
63+
) -> Image.Image:
64+
"""Return an RGB PIL.Image for a tile.
65+
66+
level: the Deep Zoom level.
67+
address: the address of the tile within the level as a (col, row)
68+
tuple.
69+
heatmap: need heatmap overlay"""
70+
args, z_size = self._get_tile_info(level, address)
71+
72+
if ENABLE_DEBUG and level not in self.visited:
73+
print("read_regoin: ", args)
74+
75+
tile = self._osr.read_region(*args)
76+
profile = tile.info.get("icc_profile")
77+
78+
# Apply on solid background
79+
if isinstance(self._osr, openslide.OpenSlide):
80+
bg = Image.new("RGB", tile.size, self._bg_color)
81+
tile = Image.composite(tile, bg, tile)
82+
if heatmap and self.color_mask is not None:
83+
tile = Image.fromarray(self._mask_tile(tile, *args))
84+
85+
# Scale to the correct size
86+
if tile.size != z_size:
87+
# Image.Resampling added in Pillow 9.1.0
88+
# Image.LANCZOS removed in Pillow 10
89+
tile.thumbnail(z_size, getattr(Image, "Resampling", Image).LANCZOS)
90+
91+
# Reference ICC profile
92+
if profile is not None:
93+
tile.info["icc_profile"] = profile
94+
95+
return tile
96+
97+
def _mask_tile(
98+
self, tile: Image, location: tuple[int, int], level: int, size: tuple[int, int]
99+
) -> np.ndarray:
100+
region_size = (self.actual_size[1], self.actual_size[0])
101+
if self.is_sdpc:
102+
# down_sample = self._osr.level_downsamples[self.ratio]
103+
down_sample = 1 << round(math.log2(self._osr.level_dimensions[0][0] / self.actual_size[1]))
104+
lv_downsample = self._osr.level_downsamples[level]
105+
scale = (lv_downsample / down_sample, lv_downsample / down_sample)
106+
max_scale = (1 / down_sample, 1 / down_sample)
107+
else:
108+
lv_dim = self._osr.level_dimensions[level]
109+
max_dim = self._osr.level_dimensions[0]
110+
scale = (region_size[0] / lv_dim[0], region_size[1] / lv_dim[1])
111+
max_scale = (region_size[0] / max_dim[0], region_size[1] / max_dim[1])
112+
x_img = int(location[0] * max_scale[0])
113+
y_img = int(location[1] * max_scale[1])
114+
x_end = min(int(x_img + size[0] * scale[0]), region_size[0])
115+
y_end = min(int(y_img + size[1] * scale[1]), region_size[1])
116+
117+
tile_arr = np.array(tile.convert("RGB"))
118+
119+
color_mask = self.color_mask[y_img:y_end, x_img:x_end]
120+
121+
if ENABLE_DEBUG and level not in self.visited:
122+
print("max_scale", max_scale)
123+
print("lv: ", level)
124+
print("region_size: ", region_size)
125+
print("color_mask", color_mask.shape)
126+
print("ori_color_mask", self.color_mask.shape)
127+
print("size", size)
128+
print("mask_size", color_mask.shape)
129+
print("lv_downsample", self._osr.level_downsamples)
130+
print("lv_dimension", self._osr.level_dimensions)
131+
print("get_best_level_for_downsample ", self._osr.get_best_level_for_downsample(self.ratio))
132+
print(f"taking mask ({x_img}, {y_img}) with len ({color_mask.shape[1]}, {color_mask.shape[0]})")
133+
self.visited.add(level)
134+
if color_mask.shape[0] == 0 or color_mask.shape[1] == 0:
135+
return tile_arr
136+
# color_mask = self._resize_image(color_mask, (size[1], size[0]))
137+
color_mask = cv2.resize(color_mask, size)
138+
139+
weighted = color_mask * 0.4 + tile_arr * 0.6
140+
ret = np.clip(weighted, 0, 255).astype(np.uint8)
141+
return ret
142+
143+
def _resize_image(self, image, new_size):
144+
resized_image = np.zeros(
145+
(new_size[0], new_size[1], image.shape[2]), dtype=image.dtype
146+
)
147+
row_scale = image.shape[0] / new_size[0]
148+
col_scale = image.shape[1] / new_size[1]
149+
150+
for j in range(new_size[1]):
151+
for i in range(new_size[0]):
152+
src_row = int(i * row_scale)
153+
src_col = int(j * col_scale)
154+
resized_image[i, j] = image[src_row, src_col]
155+
return resized_image

label_studio/core/deepzoom_util.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import os
2+
from pathlib import Path
3+
4+
import openslide
5+
import opensdpc
6+
7+
from .annotated_deepzoom_generator import AnnotatedDeepZoomGenerator
8+
9+
class DeepZoomWrapper:
10+
def __init__(self, full_path, tile_size:int = 254, overlap:int = 1, limit_bounds:bool = False):
11+
full_path = str(full_path)
12+
_, ext = os.path.splitext(full_path)
13+
14+
if ext == ".sdpc":
15+
self._osr = opensdpc.OpenSdpc(full_path)
16+
else:
17+
self._osr = openslide.OpenSlide(full_path)
18+
19+
self._dzg = AnnotatedDeepZoomGenerator(
20+
self._osr,
21+
full_path=Path(full_path),
22+
tile_size=tile_size,
23+
overlap=overlap,
24+
limit_bounds=limit_bounds,
25+
)
26+
27+
def get_tile(self, level: int, tile: tuple[int, int]):
28+
return self._dzg.get_tile(level, tile)
29+
30+
def get_dzi(self, format:str = "jpeg"):
31+
return self._dzg.get_dzi(format)
32+
33+
@property
34+
def level_dimensions(self):
35+
return self._dzg.level_dimensions
36+
37+
@property
38+
def level_count(self):
39+
return self._dzg.level_count

label_studio/core/views.py

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import posixpath
99
from pathlib import Path
1010
from wsgiref.util import FileWrapper
11+
from io import BytesIO
1112

1213
import pandas as pd
1314
import requests
@@ -36,6 +37,8 @@
3637
from rest_framework.permissions import IsAuthenticated
3738
from rest_framework.views import APIView
3839

40+
from .deepzoom_util import DeepZoomWrapper
41+
3942
logger = logging.getLogger(__name__)
4043

4144

@@ -200,6 +203,11 @@ def localfiles_data(request):
200203
"""Serving files for LocalFilesImportStorage"""
201204
user = request.user
202205
path = request.GET.get('d')
206+
207+
level = request.GET.get('level')
208+
col = request.GET.get('col')
209+
row = request.GET.get('row')
210+
203211
if settings.LOCAL_FILES_SERVING_ENABLED is False:
204212
return HttpResponseForbidden(
205213
"Serving local files can be dangerous, so it's disabled by default. "
@@ -211,17 +219,55 @@ def localfiles_data(request):
211219
if path and request.user.is_authenticated:
212220
path = posixpath.normpath(path).lstrip('/')
213221
full_path = Path(safe_join(local_serving_document_root, path))
214-
user_has_permissions = False
222+
223+
# Check if the file is a WSI file
224+
ext = os.path.splitext(full_path)[1].lower()
225+
is_wsi = ext in ['.svs', '.sdpc', '.tif', '.tiff', '.csp', '.kfb']
215226

216227
# Try to find Local File Storage connection based prefix:
217228
# storage.path=/home/user, full_path=/home/user/a/b/c/1.jpg =>
218229
# full_path.startswith(path) => True
219230
localfiles_storage = LocalFilesImportStorage.objects.annotate(
220231
_full_path=Value(os.path.dirname(full_path), output_field=CharField())
221232
).filter(_full_path__startswith=F('path'))
233+
234+
# Check if user has permissions to access this storage
235+
user_has_permissions = False
222236
if localfiles_storage.exists():
223237
user_has_permissions = any(storage.project.has_permission(user) for storage in localfiles_storage)
224238

239+
if not user_has_permissions or not os.path.exists(full_path):
240+
return HttpResponseNotFound()
241+
242+
# Check if the file is a WSI file and has level, col, and row parameters
243+
if is_wsi and level and col and row:
244+
try:
245+
level = int(level)
246+
col = int(col)
247+
row = int(row)
248+
except ValueError:
249+
return HttpResponseForbidden('Invalid level, col, or row parameter')
250+
251+
# Get the tile image
252+
dz = DeepZoomWrapper(full_path)
253+
tile_image = dz.get_tile(level, (col, row))
254+
255+
# Create a response with the tile image
256+
buf = BytesIO()
257+
tile_image.save(buf, 'jpeg')
258+
buf.seek(0)
259+
return HttpResponse(buf, content_type='image/jpeg')
260+
261+
# Check if the file is a WSI file without level, col, and row parameters
262+
if is_wsi:
263+
# Create a DeepZoomWrapper instance
264+
dz = DeepZoomWrapper(full_path)
265+
266+
# Get the DZI (Deep Zoom Image) for the WSI file
267+
dzi_xml = dz.get_dzi(format='jpeg')
268+
return HttpResponse(dzi_xml, content_type='application/xml')
269+
270+
# If the file is not a WSI file, serve it as a regular file
225271
if user_has_permissions and os.path.exists(full_path):
226272
content_type, encoding = mimetypes.guess_type(str(full_path))
227273
content_type = content_type or 'application/octet-stream'

0 commit comments

Comments
 (0)