wsi-tile-processor 0.1.2__tar.gz → 0.2.0__tar.gz
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_tile_processor-0.1.2/src/wsi_tile_processor.egg-info → wsi_tile_processor-0.2.0}/PKG-INFO +1 -1
- {wsi_tile_processor-0.1.2 → wsi_tile_processor-0.2.0}/pyproject.toml +3 -4
- wsi_tile_processor-0.2.0/src/wsi_tile_processor/__init__.py +22 -0
- wsi_tile_processor-0.2.0/src/wsi_tile_processor/base.py +220 -0
- wsi_tile_processor-0.2.0/src/wsi_tile_processor/filters.py +343 -0
- wsi_tile_processor-0.2.0/src/wsi_tile_processor/processors.py +562 -0
- wsi_tile_processor-0.2.0/src/wsi_tile_processor/slide.py +235 -0
- wsi_tile_processor-0.2.0/src/wsi_tile_processor/utils.py +141 -0
- {wsi_tile_processor-0.1.2 → wsi_tile_processor-0.2.0/src/wsi_tile_processor.egg-info}/PKG-INFO +1 -1
- {wsi_tile_processor-0.1.2 → wsi_tile_processor-0.2.0}/src/wsi_tile_processor.egg-info/SOURCES.txt +6 -1
- wsi_tile_processor-0.1.2/src/wsi_tile_processor.py +0 -1313
- {wsi_tile_processor-0.1.2 → wsi_tile_processor-0.2.0}/LICENSE +0 -0
- {wsi_tile_processor-0.1.2 → wsi_tile_processor-0.2.0}/README.md +0 -0
- {wsi_tile_processor-0.1.2 → wsi_tile_processor-0.2.0}/setup.cfg +0 -0
- {wsi_tile_processor-0.1.2 → wsi_tile_processor-0.2.0}/src/wsi_tile_processor.egg-info/dependency_links.txt +0 -0
- {wsi_tile_processor-0.1.2 → wsi_tile_processor-0.2.0}/src/wsi_tile_processor.egg-info/requires.txt +0 -0
- {wsi_tile_processor-0.1.2 → wsi_tile_processor-0.2.0}/src/wsi_tile_processor.egg-info/top_level.txt +0 -0
- {wsi_tile_processor-0.1.2 → wsi_tile_processor-0.2.0}/tests/test_smoke.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "wsi-tile-processor"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.0"
|
|
8
8
|
description = "Tile-by-tile inference on Whole Slide Images (WSI) with pyramidal OME-TIFF output"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "MIT" }
|
|
@@ -42,6 +42,5 @@ Homepage = "https://github.com/FedeCarollo/wsi-tile-processor"
|
|
|
42
42
|
Repository = "https://github.com/FedeCarollo/wsi-tile-processor"
|
|
43
43
|
Issues = "https://github.com/FedeCarollo/wsi-tile-processor/issues"
|
|
44
44
|
|
|
45
|
-
[tool.setuptools]
|
|
46
|
-
|
|
47
|
-
py-modules = ["wsi_tile_processor"]
|
|
45
|
+
[tool.setuptools.packages.find]
|
|
46
|
+
where = ["src"]
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""
|
|
2
|
+
wsi-tile-processor
|
|
3
|
+
"""
|
|
4
|
+
from .base import WSIProcessor
|
|
5
|
+
from .filters import (BackgroundFilter, BrightnessBackgroundFilter,
|
|
6
|
+
BrightnessTissueMaskDetector, OtsuBackgroundFilter,
|
|
7
|
+
SaturationBackgroundFilter, TissueMaskDetector)
|
|
8
|
+
from .processors import FastWSIProcessor, GaussianWSIProcessor
|
|
9
|
+
|
|
10
|
+
__version__ = "0.2.0"
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"BackgroundFilter",
|
|
14
|
+
"BrightnessBackgroundFilter",
|
|
15
|
+
"OtsuBackgroundFilter",
|
|
16
|
+
"SaturationBackgroundFilter",
|
|
17
|
+
"TissueMaskDetector",
|
|
18
|
+
"BrightnessTissueMaskDetector",
|
|
19
|
+
"WSIProcessor",
|
|
20
|
+
"FastWSIProcessor",
|
|
21
|
+
"GaussianWSIProcessor",
|
|
22
|
+
]
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""
|
|
2
|
+
WSI tile processor — sequential tiled processing with pyramidal OME-TIFF output.
|
|
3
|
+
|
|
4
|
+
Backends
|
|
5
|
+
--------
|
|
6
|
+
* ``"openslide"`` (default for SVS/NDPI/...) — uses openslide.
|
|
7
|
+
* ``"tifffile"`` — for OME-TIFF files saved with sub-IFD pyramid levels
|
|
8
|
+
(e.g. output of valis ``warp_and_save_slides(..., pyramid=True)``),
|
|
9
|
+
which openslide may expose as a single level.
|
|
10
|
+
* ``"auto"`` — opens with openslide first; if the file has more tifffile
|
|
11
|
+
pyramid levels than openslide levels, switches to tifffile.
|
|
12
|
+
|
|
13
|
+
Architecture
|
|
14
|
+
------------
|
|
15
|
+
The primary processing contract is defined by the abstract base class:
|
|
16
|
+
* :class:`WSIProcessor`
|
|
17
|
+
|
|
18
|
+
And implemented by two concrete classes:
|
|
19
|
+
* :class:`FastWSIProcessor` — Direct streaming for non-overlapping tiles
|
|
20
|
+
(stride == tile_size), using a uint8 memmap and pyvips pyramidization.
|
|
21
|
+
Highly efficient for RGB virtual staining or any per-tile inference.
|
|
22
|
+
* :class:`GaussianWSIProcessor` — Overlapping tiles with smooth Gaussian
|
|
23
|
+
blending across boundaries, using a float16 memmap accumulator before
|
|
24
|
+
normalizing and pyramidizing with pyvips.
|
|
25
|
+
|
|
26
|
+
Background Filtering
|
|
27
|
+
--------------------
|
|
28
|
+
Tile-level background detection is handled by :class:`BackgroundFilter`
|
|
29
|
+
subclasses (or any callable with the same signature). Built-in filters:
|
|
30
|
+
* :class:`BrightnessBackgroundFilter` — brightness/saturation heuristic
|
|
31
|
+
tuned for H&E and IHC slides (default).
|
|
32
|
+
* :class:`OtsuBackgroundFilter` — grayscale Otsu thresholding via scikit-image.
|
|
33
|
+
* :class:`SaturationBackgroundFilter` — purely saturation-based filter.
|
|
34
|
+
|
|
35
|
+
Tissue Mask Detection
|
|
36
|
+
---------------------
|
|
37
|
+
Coarse-level background skipping is handled by :class:`TissueMaskDetector`
|
|
38
|
+
subclasses. Built-in:
|
|
39
|
+
* :class:`BrightnessTissueMaskDetector` — low-res thumbnail thresholding
|
|
40
|
+
+ morphological cleanup.
|
|
41
|
+
|
|
42
|
+
A convenience factory :func:`process_wsi_to_ome_tiff` is provided for
|
|
43
|
+
backward compatibility.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
from __future__ import annotations
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
from abc import ABC, abstractmethod
|
|
50
|
+
from pathlib import Path
|
|
51
|
+
from typing import Callable
|
|
52
|
+
|
|
53
|
+
import numpy as np
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
# Background Filter ABCs and built-in implementations
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
from .filters import (BackgroundFilter, BrightnessBackgroundFilter,
|
|
63
|
+
TissueMaskDetector, BrightnessTissueMaskDetector)
|
|
64
|
+
from .utils import pyramidize_with_pyvips
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class WSIProcessor(ABC):
|
|
68
|
+
"""Abstract base class for tile-by-tile WSI processing into pyramidal OME-TIFF.
|
|
69
|
+
|
|
70
|
+
Parameters
|
|
71
|
+
----------
|
|
72
|
+
wsi_path:
|
|
73
|
+
Path to the input whole slide image.
|
|
74
|
+
tiff_path:
|
|
75
|
+
Destination path for the output OME-TIFF.
|
|
76
|
+
level:
|
|
77
|
+
Pyramid level of *wsi_path* to process (0 = full resolution).
|
|
78
|
+
tile_size:
|
|
79
|
+
Side length (px) of each square tile fed to *generate*.
|
|
80
|
+
generate:
|
|
81
|
+
Callable that maps an HxWx3 uint8 numpy array (or a batch BxHxWx3)
|
|
82
|
+
to an output array. Float outputs in [0, 1] are scaled to uint8.
|
|
83
|
+
output_channels:
|
|
84
|
+
Number of channels in the output (e.g. 3 for RGB, 1 for grayscale).
|
|
85
|
+
background_filter:
|
|
86
|
+
A :class:`BackgroundFilter` instance, a plain ``callable(tile_rgb)
|
|
87
|
+
-> bool``, or ``None``. When ``None``, a
|
|
88
|
+
:class:`BrightnessBackgroundFilter` is constructed from the
|
|
89
|
+
*background_threshold*, *min_variance*, *min_sat*, and
|
|
90
|
+
*tissue_threshold* keyword arguments (backward-compatible defaults).
|
|
91
|
+
background_threshold:
|
|
92
|
+
Passed to :class:`BrightnessBackgroundFilter` when
|
|
93
|
+
*background_filter* is ``None``. Ignored otherwise.
|
|
94
|
+
background_value:
|
|
95
|
+
Fill value for background tiles. Float in [0, 1] is scaled to
|
|
96
|
+
uint8; integer is used directly. Default 1.0 (white).
|
|
97
|
+
min_variance:
|
|
98
|
+
Passed to :class:`BrightnessBackgroundFilter` when
|
|
99
|
+
*background_filter* is ``None``.
|
|
100
|
+
min_sat:
|
|
101
|
+
Passed to :class:`BrightnessBackgroundFilter` when
|
|
102
|
+
*background_filter* is ``None``.
|
|
103
|
+
tissue_threshold:
|
|
104
|
+
Passed to :class:`BrightnessBackgroundFilter` when
|
|
105
|
+
*background_filter* is ``None``.
|
|
106
|
+
tissue_mask_detector:
|
|
107
|
+
A :class:`TissueMaskDetector` instance for coarse background
|
|
108
|
+
skipping, or ``None``. When not ``None``, the mask is computed
|
|
109
|
+
once before tile iteration and used to skip tiles that fall
|
|
110
|
+
entirely within masked-out regions. Supersedes *use_tissue_mask*.
|
|
111
|
+
use_tissue_mask:
|
|
112
|
+
Convenience flag: when ``True`` and *tissue_mask_detector* is
|
|
113
|
+
``None``, a :class:`BrightnessTissueMaskDetector` is used.
|
|
114
|
+
jpeg_quality:
|
|
115
|
+
JPEG compression quality for the output OME-TIFF. Default 95.
|
|
116
|
+
tiff_chunk:
|
|
117
|
+
Internal tile size for the TIFF writer and pyvips pyramid.
|
|
118
|
+
Default 512.
|
|
119
|
+
batch_size:
|
|
120
|
+
Number of tiles per *generate* call. 1 disables batching.
|
|
121
|
+
tmp_dir:
|
|
122
|
+
Directory for temporary memmap files. Uses the system temp dir
|
|
123
|
+
when ``None``.
|
|
124
|
+
backend:
|
|
125
|
+
Slide reading backend: ``"openslide"``, ``"tifffile"``, or
|
|
126
|
+
``"auto"`` (default).
|
|
127
|
+
verbose:
|
|
128
|
+
Print progress messages. Default False.
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
def __init__(
|
|
132
|
+
self,
|
|
133
|
+
wsi_path: str | Path,
|
|
134
|
+
tiff_path: str | Path,
|
|
135
|
+
level: int,
|
|
136
|
+
tile_size: int,
|
|
137
|
+
generate: Callable[[np.ndarray], np.ndarray],
|
|
138
|
+
output_channels: int = 3,
|
|
139
|
+
background_filter: BackgroundFilter | Callable[[np.ndarray], bool] | None = None,
|
|
140
|
+
background_threshold: float = 0.70,
|
|
141
|
+
background_value: float = 1.0,
|
|
142
|
+
min_variance: float = 0.0,
|
|
143
|
+
min_sat: float = 0.0,
|
|
144
|
+
tissue_threshold: float = 0.2,
|
|
145
|
+
tissue_mask_detector: TissueMaskDetector | None = None,
|
|
146
|
+
use_tissue_mask: bool = False,
|
|
147
|
+
jpeg_quality: int = 95,
|
|
148
|
+
tiff_chunk: int = 512,
|
|
149
|
+
batch_size: int = 1,
|
|
150
|
+
tmp_dir: str | Path | None = None,
|
|
151
|
+
backend: str = "auto",
|
|
152
|
+
verbose: bool = False,
|
|
153
|
+
):
|
|
154
|
+
self.wsi_path = Path(wsi_path)
|
|
155
|
+
self.tiff_path = Path(tiff_path)
|
|
156
|
+
self.level = level
|
|
157
|
+
self.tile_size = tile_size
|
|
158
|
+
self.generate = generate
|
|
159
|
+
self.output_channels = output_channels
|
|
160
|
+
|
|
161
|
+
# Resolve background filter
|
|
162
|
+
if background_filter is not None:
|
|
163
|
+
self.background_filter: Callable[[np.ndarray], bool] = background_filter
|
|
164
|
+
else:
|
|
165
|
+
self.background_filter = BrightnessBackgroundFilter(
|
|
166
|
+
threshold=background_threshold,
|
|
167
|
+
min_variance=min_variance,
|
|
168
|
+
min_sat=min_sat,
|
|
169
|
+
tissue_threshold=tissue_threshold,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
# Keep legacy params accessible for subclass use / backward compat
|
|
173
|
+
self.background_threshold = background_threshold
|
|
174
|
+
self.background_value = background_value
|
|
175
|
+
self.min_variance = min_variance
|
|
176
|
+
self.min_sat = min_sat
|
|
177
|
+
self.tissue_threshold = tissue_threshold
|
|
178
|
+
|
|
179
|
+
# Resolve tissue mask detector
|
|
180
|
+
if tissue_mask_detector is not None:
|
|
181
|
+
self._tissue_mask_detector: TissueMaskDetector | None = tissue_mask_detector
|
|
182
|
+
elif use_tissue_mask:
|
|
183
|
+
self._tissue_mask_detector = BrightnessTissueMaskDetector(verbose=verbose)
|
|
184
|
+
else:
|
|
185
|
+
self._tissue_mask_detector = None
|
|
186
|
+
|
|
187
|
+
self.use_tissue_mask = use_tissue_mask
|
|
188
|
+
self.jpeg_quality = jpeg_quality
|
|
189
|
+
self.tiff_chunk = tiff_chunk
|
|
190
|
+
self.batch_size = batch_size
|
|
191
|
+
self.tmp_dir = tmp_dir
|
|
192
|
+
self.backend = backend
|
|
193
|
+
self.verbose = verbose
|
|
194
|
+
|
|
195
|
+
@abstractmethod
|
|
196
|
+
def process(self) -> None:
|
|
197
|
+
"""Execute tile-by-tile processing and write the output OME-TIFF."""
|
|
198
|
+
|
|
199
|
+
def _is_tile_background(self, tile_rgb: np.ndarray) -> bool:
|
|
200
|
+
"""Return ``True`` if *tile_rgb* should be treated as background."""
|
|
201
|
+
return self.background_filter(tile_rgb)
|
|
202
|
+
|
|
203
|
+
def _compute_tissue_mask(self, slide: object) -> tuple[np.ndarray, float]:
|
|
204
|
+
"""Compute and return the global tissue mask using the configured detector."""
|
|
205
|
+
if self._tissue_mask_detector is None:
|
|
206
|
+
raise RuntimeError(
|
|
207
|
+
"_compute_tissue_mask called but no tissue_mask_detector is configured."
|
|
208
|
+
)
|
|
209
|
+
return self._tissue_mask_detector(slide)
|
|
210
|
+
|
|
211
|
+
def _build_pyramid_pyvips(self) -> None:
|
|
212
|
+
"""Build the JPEG OME-TIFF pyramid in-place using pyvips."""
|
|
213
|
+
if self.verbose:
|
|
214
|
+
print(" building OME-TIFF pyramid with pyvips ...")
|
|
215
|
+
pyramidize_with_pyvips(
|
|
216
|
+
self.tiff_path,
|
|
217
|
+
Q=self.jpeg_quality,
|
|
218
|
+
tile_size=self.tiff_chunk,
|
|
219
|
+
verbose=self.verbose,
|
|
220
|
+
)
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
"""
|
|
2
|
+
WSI tile processor — sequential tiled processing with pyramidal OME-TIFF output.
|
|
3
|
+
|
|
4
|
+
Backends
|
|
5
|
+
--------
|
|
6
|
+
* ``"openslide"`` (default for SVS/NDPI/...) — uses openslide.
|
|
7
|
+
* ``"tifffile"`` — for OME-TIFF files saved with sub-IFD pyramid levels
|
|
8
|
+
(e.g. output of valis ``warp_and_save_slides(..., pyramid=True)``),
|
|
9
|
+
which openslide may expose as a single level.
|
|
10
|
+
* ``"auto"`` — opens with openslide first; if the file has more tifffile
|
|
11
|
+
pyramid levels than openslide levels, switches to tifffile.
|
|
12
|
+
|
|
13
|
+
Architecture
|
|
14
|
+
------------
|
|
15
|
+
The primary processing contract is defined by the abstract base class:
|
|
16
|
+
* :class:`WSIProcessor`
|
|
17
|
+
|
|
18
|
+
And implemented by two concrete classes:
|
|
19
|
+
* :class:`FastWSIProcessor` — Direct streaming for non-overlapping tiles
|
|
20
|
+
(stride == tile_size), using a uint8 memmap and pyvips pyramidization.
|
|
21
|
+
Highly efficient for RGB virtual staining or any per-tile inference.
|
|
22
|
+
* :class:`GaussianWSIProcessor` — Overlapping tiles with smooth Gaussian
|
|
23
|
+
blending across boundaries, using a float16 memmap accumulator before
|
|
24
|
+
normalizing and pyramidizing with pyvips.
|
|
25
|
+
|
|
26
|
+
Background Filtering
|
|
27
|
+
--------------------
|
|
28
|
+
Tile-level background detection is handled by :class:`BackgroundFilter`
|
|
29
|
+
subclasses (or any callable with the same signature). Built-in filters:
|
|
30
|
+
* :class:`BrightnessBackgroundFilter` — brightness/saturation heuristic
|
|
31
|
+
tuned for H&E and IHC slides (default).
|
|
32
|
+
* :class:`OtsuBackgroundFilter` — grayscale Otsu thresholding via scikit-image.
|
|
33
|
+
* :class:`SaturationBackgroundFilter` — purely saturation-based filter.
|
|
34
|
+
|
|
35
|
+
Tissue Mask Detection
|
|
36
|
+
---------------------
|
|
37
|
+
Coarse-level background skipping is handled by :class:`TissueMaskDetector`
|
|
38
|
+
subclasses. Built-in:
|
|
39
|
+
* :class:`BrightnessTissueMaskDetector` — low-res thumbnail thresholding
|
|
40
|
+
+ morphological cleanup.
|
|
41
|
+
|
|
42
|
+
A convenience factory :func:`process_wsi_to_ome_tiff` is provided for
|
|
43
|
+
backward compatibility.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
from __future__ import annotations
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
from abc import ABC, abstractmethod
|
|
50
|
+
|
|
51
|
+
import numpy as np
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class BackgroundFilter(ABC):
|
|
56
|
+
"""Abstract base class for tile-level background detection.
|
|
57
|
+
|
|
58
|
+
Subclass this to implement custom background rejection logic.
|
|
59
|
+
The ``__call__`` method receives an RGB uint8 tile and returns
|
|
60
|
+
``True`` if the tile should be treated as background (skipped).
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
@abstractmethod
|
|
64
|
+
def __call__(self, tile_rgb: np.ndarray) -> bool:
|
|
65
|
+
"""Return ``True`` if *tile_rgb* is background.
|
|
66
|
+
|
|
67
|
+
Parameters
|
|
68
|
+
----------
|
|
69
|
+
tile_rgb:
|
|
70
|
+
HxWx3 uint8 numpy array in RGB order.
|
|
71
|
+
|
|
72
|
+
Returns
|
|
73
|
+
-------
|
|
74
|
+
bool
|
|
75
|
+
``True`` → tile is background and will be filled with
|
|
76
|
+
``background_value``.
|
|
77
|
+
``False`` → tile is tissue and will be passed to ``generate``.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class BrightnessBackgroundFilter(BackgroundFilter):
|
|
82
|
+
"""Brightness + optional saturation heuristic for H&E / IHC slides.
|
|
83
|
+
|
|
84
|
+
This is the default filter used by :class:`WSIProcessor` when no
|
|
85
|
+
explicit ``background_filter`` is provided. It was tuned for adrenal
|
|
86
|
+
gland H&E and IHC slides but works well for most brightfield histology.
|
|
87
|
+
|
|
88
|
+
A tile is classified as background when **any** of the following holds:
|
|
89
|
+
|
|
90
|
+
1. **Brightness** — the fraction of pixels where all three channels
|
|
91
|
+
exceed *threshold_intensity* (default 215) is greater than
|
|
92
|
+
*threshold* (default 0.70). This rejects both pure white background
|
|
93
|
+
(>95 % bright) and adipose tissue vacuoles (75-90 % bright).
|
|
94
|
+
2. **Low variance** (optional, off by default) — the grayscale variance
|
|
95
|
+
is below *min_variance*.
|
|
96
|
+
3. **Low saturation** (optional, off by default) — the fraction of pixels
|
|
97
|
+
with HSV saturation above *min_sat* is below *tissue_threshold*.
|
|
98
|
+
|
|
99
|
+
Parameters
|
|
100
|
+
----------
|
|
101
|
+
threshold:
|
|
102
|
+
Fraction of pixels that must be "bright" to call the tile background.
|
|
103
|
+
Default 0.70 (70 %).
|
|
104
|
+
threshold_intensity:
|
|
105
|
+
Per-channel intensity cutoff to call a pixel "bright". Default 215.
|
|
106
|
+
min_variance:
|
|
107
|
+
Minimum grayscale variance. 0 disables the check.
|
|
108
|
+
min_sat:
|
|
109
|
+
Minimum per-pixel saturation (0-255 scale) for the saturation check.
|
|
110
|
+
0 disables it.
|
|
111
|
+
tissue_threshold:
|
|
112
|
+
Minimum fraction of saturated pixels required to keep the tile.
|
|
113
|
+
Only used when *min_sat* > 0.
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
def __init__(
|
|
117
|
+
self,
|
|
118
|
+
threshold: float = 0.70,
|
|
119
|
+
threshold_intensity: int = 215,
|
|
120
|
+
min_variance: float = 0.0,
|
|
121
|
+
min_sat: float = 0.0,
|
|
122
|
+
tissue_threshold: float = 0.2,
|
|
123
|
+
) -> None:
|
|
124
|
+
self.threshold = threshold
|
|
125
|
+
self.threshold_intensity = threshold_intensity
|
|
126
|
+
self.min_variance = min_variance
|
|
127
|
+
self.min_sat = min_sat
|
|
128
|
+
self.tissue_threshold = tissue_threshold
|
|
129
|
+
|
|
130
|
+
def __call__(self, tile_rgb: np.ndarray) -> bool:
|
|
131
|
+
if tile_rgb.size == 0:
|
|
132
|
+
return True
|
|
133
|
+
|
|
134
|
+
# 1. Brightness check
|
|
135
|
+
bright_mask = np.all(tile_rgb > self.threshold_intensity, axis=-1)
|
|
136
|
+
if float(bright_mask.mean()) > self.threshold:
|
|
137
|
+
return True
|
|
138
|
+
|
|
139
|
+
# 2. Variance check (optional)
|
|
140
|
+
if self.min_variance > 0:
|
|
141
|
+
gray = np.dot(tile_rgb[..., :3], [0.299, 0.587, 0.114])
|
|
142
|
+
if float(np.var(gray)) < self.min_variance:
|
|
143
|
+
return True
|
|
144
|
+
|
|
145
|
+
# 3. Saturation check (optional)
|
|
146
|
+
if self.min_sat > 0 and self.tissue_threshold > 0:
|
|
147
|
+
rgb = tile_rgb[..., :3]
|
|
148
|
+
cmax = rgb.max(axis=-1)
|
|
149
|
+
cmin = rgb.min(axis=-1)
|
|
150
|
+
delta = cmax - cmin
|
|
151
|
+
sat = np.where(cmax > 0, (delta.astype(np.float32) / cmax) * 255.0, 0.0)
|
|
152
|
+
sat_ratio = float((sat > self.min_sat).mean())
|
|
153
|
+
if sat_ratio < self.tissue_threshold:
|
|
154
|
+
return True
|
|
155
|
+
|
|
156
|
+
return False
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class OtsuBackgroundFilter(BackgroundFilter):
|
|
160
|
+
"""Background detection via Otsu thresholding on the grayscale image.
|
|
161
|
+
|
|
162
|
+
Requires ``scikit-image`` (``pip install scikit-image`` or
|
|
163
|
+
``pip install wsi-tile-processor[full]``).
|
|
164
|
+
|
|
165
|
+
A tile is classified as background if the fraction of pixels above the
|
|
166
|
+
Otsu threshold exceeds *background_fraction* (default 0.70).
|
|
167
|
+
|
|
168
|
+
Parameters
|
|
169
|
+
----------
|
|
170
|
+
background_fraction:
|
|
171
|
+
Fraction of pixels classified as "light" (above Otsu threshold) for
|
|
172
|
+
the tile to be considered background. Default 0.70.
|
|
173
|
+
"""
|
|
174
|
+
|
|
175
|
+
def __init__(self, background_fraction: float = 0.70) -> None:
|
|
176
|
+
self.background_fraction = background_fraction
|
|
177
|
+
|
|
178
|
+
def __call__(self, tile_rgb: np.ndarray) -> bool:
|
|
179
|
+
if tile_rgb.size == 0:
|
|
180
|
+
return True
|
|
181
|
+
try:
|
|
182
|
+
from skimage.filters import threshold_otsu
|
|
183
|
+
except ImportError as exc:
|
|
184
|
+
raise ImportError(
|
|
185
|
+
"OtsuBackgroundFilter requires scikit-image. "
|
|
186
|
+
"Install it with: pip install scikit-image"
|
|
187
|
+
) from exc
|
|
188
|
+
|
|
189
|
+
gray = np.dot(tile_rgb[..., :3].astype(np.float32), [0.299, 0.587, 0.114])
|
|
190
|
+
# Degenerate case: uniform tile (zero variance) — Otsu is undefined.
|
|
191
|
+
# Fall back to a simple mean brightness check.
|
|
192
|
+
if gray.std() < 1.0:
|
|
193
|
+
return float(gray.mean()) > 200.0
|
|
194
|
+
thresh = threshold_otsu(gray)
|
|
195
|
+
return float((gray > thresh).mean()) > self.background_fraction
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
class SaturationBackgroundFilter(BackgroundFilter):
|
|
199
|
+
"""Background detection based purely on HSV saturation.
|
|
200
|
+
|
|
201
|
+
A tile is background when the mean saturation (0-255 scale) is below
|
|
202
|
+
*min_mean_saturation*. Fast and simple; works best on stained tissue
|
|
203
|
+
where background is nearly achromatic.
|
|
204
|
+
|
|
205
|
+
Parameters
|
|
206
|
+
----------
|
|
207
|
+
min_mean_saturation:
|
|
208
|
+
Mean saturation threshold (0-255 scale). Tiles with mean saturation
|
|
209
|
+
below this value are classified as background. Default 15.
|
|
210
|
+
"""
|
|
211
|
+
|
|
212
|
+
def __init__(self, min_mean_saturation: float = 15.0) -> None:
|
|
213
|
+
self.min_mean_saturation = min_mean_saturation
|
|
214
|
+
|
|
215
|
+
def __call__(self, tile_rgb: np.ndarray) -> bool:
|
|
216
|
+
if tile_rgb.size == 0:
|
|
217
|
+
return True
|
|
218
|
+
rgb = tile_rgb[..., :3].astype(np.float32)
|
|
219
|
+
cmax = rgb.max(axis=-1)
|
|
220
|
+
cmin = rgb.min(axis=-1)
|
|
221
|
+
delta = cmax - cmin
|
|
222
|
+
sat = np.where(cmax > 0, (delta / cmax) * 255.0, 0.0)
|
|
223
|
+
return float(sat.mean()) < self.min_mean_saturation
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
# ---------------------------------------------------------------------------
|
|
227
|
+
# Tissue Mask Detector ABCs and built-in implementations
|
|
228
|
+
# ---------------------------------------------------------------------------
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
class TissueMaskDetector(ABC):
|
|
232
|
+
"""Abstract base class for coarse-level tissue mask computation.
|
|
233
|
+
|
|
234
|
+
The tissue mask is computed once on a low-resolution thumbnail of the
|
|
235
|
+
slide and used to skip entire tiles that fall entirely within background
|
|
236
|
+
regions before reading the full-resolution tile.
|
|
237
|
+
|
|
238
|
+
Subclass this and implement ``__call__`` to provide custom tissue
|
|
239
|
+
detection logic (e.g. deep learning-based segmentation).
|
|
240
|
+
"""
|
|
241
|
+
|
|
242
|
+
@abstractmethod
|
|
243
|
+
def __call__(self, slide: object) -> tuple[np.ndarray, float]:
|
|
244
|
+
"""Compute a boolean tissue mask from a slide object.
|
|
245
|
+
|
|
246
|
+
Parameters
|
|
247
|
+
----------
|
|
248
|
+
slide:
|
|
249
|
+
An openslide-compatible slide object (has ``level_count``,
|
|
250
|
+
``level_dimensions``, ``level_downsamples``, ``read_region``).
|
|
251
|
+
|
|
252
|
+
Returns
|
|
253
|
+
-------
|
|
254
|
+
mask : np.ndarray
|
|
255
|
+
2-D boolean array (H × W) at a reduced resolution.
|
|
256
|
+
``True`` → tissue present, ``False`` → background.
|
|
257
|
+
downsample : float
|
|
258
|
+
The downsample factor of the mask relative to level-0 pixel
|
|
259
|
+
coordinates. Used to map level-0 coordinates into mask
|
|
260
|
+
coordinates.
|
|
261
|
+
"""
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
class BrightnessTissueMaskDetector(TissueMaskDetector):
|
|
265
|
+
"""Tissue mask via brightness thresholding on a low-res thumbnail.
|
|
266
|
+
|
|
267
|
+
Reads the lowest pyramid level that fits within *max_side* × *max_side*
|
|
268
|
+
pixels, converts to grayscale, thresholds at *intensity_threshold*, and
|
|
269
|
+
applies morphological opening + closing to remove noise and close gaps.
|
|
270
|
+
|
|
271
|
+
Requires ``scikit-image`` for the morphological operations
|
|
272
|
+
(``pip install scikit-image`` or ``pip install wsi-tile-processor[full]``).
|
|
273
|
+
|
|
274
|
+
Parameters
|
|
275
|
+
----------
|
|
276
|
+
intensity_threshold:
|
|
277
|
+
Grayscale intensity below which a pixel is considered tissue.
|
|
278
|
+
Default 215 (pixels brighter than this are background).
|
|
279
|
+
max_side:
|
|
280
|
+
Maximum side length (px) for the thumbnail used for mask computation.
|
|
281
|
+
Default 4000.
|
|
282
|
+
open_radius:
|
|
283
|
+
Disk radius for morphological opening (noise removal). Default 2.
|
|
284
|
+
close_radius:
|
|
285
|
+
Disk radius for morphological closing (gap filling). Default 4.
|
|
286
|
+
verbose:
|
|
287
|
+
Print progress information. Default False.
|
|
288
|
+
"""
|
|
289
|
+
|
|
290
|
+
def __init__(
|
|
291
|
+
self,
|
|
292
|
+
intensity_threshold: int = 215,
|
|
293
|
+
max_side: int = 4000,
|
|
294
|
+
open_radius: int = 2,
|
|
295
|
+
close_radius: int = 4,
|
|
296
|
+
verbose: bool = False,
|
|
297
|
+
) -> None:
|
|
298
|
+
self.intensity_threshold = intensity_threshold
|
|
299
|
+
self.max_side = max_side
|
|
300
|
+
self.open_radius = open_radius
|
|
301
|
+
self.close_radius = close_radius
|
|
302
|
+
self.verbose = verbose
|
|
303
|
+
|
|
304
|
+
def __call__(self, slide: object) -> tuple[np.ndarray, float]:
|
|
305
|
+
try:
|
|
306
|
+
from skimage import morphology
|
|
307
|
+
except ImportError as exc:
|
|
308
|
+
raise ImportError(
|
|
309
|
+
"BrightnessTissueMaskDetector requires scikit-image. "
|
|
310
|
+
"Install it with: pip install scikit-image"
|
|
311
|
+
) from exc
|
|
312
|
+
|
|
313
|
+
target_level = slide.level_count - 1
|
|
314
|
+
for i, (w, h) in enumerate(slide.level_dimensions):
|
|
315
|
+
if w <= self.max_side and h <= self.max_side:
|
|
316
|
+
target_level = i
|
|
317
|
+
break
|
|
318
|
+
|
|
319
|
+
downsample = slide.level_downsamples[target_level]
|
|
320
|
+
dims = slide.level_dimensions[target_level]
|
|
321
|
+
|
|
322
|
+
if self.verbose:
|
|
323
|
+
print(
|
|
324
|
+
f" computing tissue mask at level {target_level} "
|
|
325
|
+
f"(dims={dims}, downsample={downsample:.2f}) ..."
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
thumb = slide.read_region((0, 0), target_level, dims).convert("RGB")
|
|
329
|
+
thumb_np = np.array(thumb)
|
|
330
|
+
|
|
331
|
+
gray = np.dot(thumb_np[..., :3], [0.299, 0.587, 0.114])
|
|
332
|
+
mask = gray < self.intensity_threshold
|
|
333
|
+
|
|
334
|
+
mask = morphology.opening(mask, morphology.disk(self.open_radius))
|
|
335
|
+
mask = morphology.closing(mask, morphology.disk(self.close_radius))
|
|
336
|
+
|
|
337
|
+
return mask, downsample
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
# ---------------------------------------------------------------------------
|
|
341
|
+
# Slide backend abstraction
|
|
342
|
+
# ---------------------------------------------------------------------------
|
|
343
|
+
|