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.
Files changed (18) hide show
  1. {wsi_tile_processor-0.1.2/src/wsi_tile_processor.egg-info → wsi_tile_processor-0.2.0}/PKG-INFO +1 -1
  2. {wsi_tile_processor-0.1.2 → wsi_tile_processor-0.2.0}/pyproject.toml +3 -4
  3. wsi_tile_processor-0.2.0/src/wsi_tile_processor/__init__.py +22 -0
  4. wsi_tile_processor-0.2.0/src/wsi_tile_processor/base.py +220 -0
  5. wsi_tile_processor-0.2.0/src/wsi_tile_processor/filters.py +343 -0
  6. wsi_tile_processor-0.2.0/src/wsi_tile_processor/processors.py +562 -0
  7. wsi_tile_processor-0.2.0/src/wsi_tile_processor/slide.py +235 -0
  8. wsi_tile_processor-0.2.0/src/wsi_tile_processor/utils.py +141 -0
  9. {wsi_tile_processor-0.1.2 → wsi_tile_processor-0.2.0/src/wsi_tile_processor.egg-info}/PKG-INFO +1 -1
  10. {wsi_tile_processor-0.1.2 → wsi_tile_processor-0.2.0}/src/wsi_tile_processor.egg-info/SOURCES.txt +6 -1
  11. wsi_tile_processor-0.1.2/src/wsi_tile_processor.py +0 -1313
  12. {wsi_tile_processor-0.1.2 → wsi_tile_processor-0.2.0}/LICENSE +0 -0
  13. {wsi_tile_processor-0.1.2 → wsi_tile_processor-0.2.0}/README.md +0 -0
  14. {wsi_tile_processor-0.1.2 → wsi_tile_processor-0.2.0}/setup.cfg +0 -0
  15. {wsi_tile_processor-0.1.2 → wsi_tile_processor-0.2.0}/src/wsi_tile_processor.egg-info/dependency_links.txt +0 -0
  16. {wsi_tile_processor-0.1.2 → wsi_tile_processor-0.2.0}/src/wsi_tile_processor.egg-info/requires.txt +0 -0
  17. {wsi_tile_processor-0.1.2 → wsi_tile_processor-0.2.0}/src/wsi_tile_processor.egg-info/top_level.txt +0 -0
  18. {wsi_tile_processor-0.1.2 → wsi_tile_processor-0.2.0}/tests/test_smoke.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wsi-tile-processor
3
- Version: 0.1.2
3
+ Version: 0.2.0
4
4
  Summary: Tile-by-tile inference on Whole Slide Images (WSI) with pyramidal OME-TIFF output
5
5
  Author: Federico Carollo
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "wsi-tile-processor"
7
- version = "0.1.2"
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
- package-dir = {"" = "src"}
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
+