Skip to content

Commit 3252633

Browse files
authored
Merge branch 'develop' into dev-define-engines-abc
2 parents 8ba4bca + 807d8f7 commit 3252633

File tree

10 files changed

+235
-120
lines changed

10 files changed

+235
-120
lines changed

.github/workflows/python-package.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ jobs:
3030
sudo apt update
3131
sudo apt-get install -y libopenslide-dev openslide-tools libopenjp2-7 libopenjp2-tools
3232
python -m pip install --upgrade pip
33-
python -m pip install ruff==0.4.1 pytest pytest-cov pytest-runner
33+
python -m pip install ruff==0.4.3 pytest pytest-cov pytest-runner
3434
pip install -r requirements/requirements.txt
3535
- name: Cache tiatoolbox static assets
3636
uses: actions/cache@v3

.pre-commit-config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ repos:
6060
- id: rst-inline-touching-normal # Detect mistake of inline code touching normal text in rst.
6161
- repo: https://github.com/astral-sh/ruff-pre-commit
6262
# Ruff version.
63-
rev: v0.4.1
63+
rev: v0.4.3
6464
hooks:
6565
- id: ruff
6666
args: [--fix, --exit-non-zero-on-fix]

examples/inference-pipelines/idars.ipynb

Lines changed: 69 additions & 69 deletions
Large diffs are not rendered by default.

requirements/requirements_dev.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ pytest>=7.2.0
1111
pytest-cov>=4.0.0
1212
pytest-runner>=6.0
1313
pytest-xdist[psutil]
14-
ruff==0.4.1 # This will be updated by pre-commit bot to latest version
14+
ruff==0.4.3 # This will be updated by pre-commit bot to latest version
1515
toml>=0.10.2
1616
twine>=4.0.1
1717
wheel>=0.37.1

tests/conftest.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,26 @@ def sample_ome_tiff(remote_sample: Callable) -> Path:
120120
return remote_sample("ome-brightfield-pyramid-1-small")
121121

122122

123+
@pytest.fixture(scope="session")
124+
def sample_ventana_tif(remote_sample: Callable) -> Path:
125+
"""Sample pytest fixture for non-tiled tif Ventana images.
126+
127+
Download Ventana tif image for pytest.
128+
129+
"""
130+
return remote_sample("ventana-tif")
131+
132+
133+
@pytest.fixture(scope="session")
134+
def sample_regular_tif(remote_sample: Callable) -> Path:
135+
"""Sample pytest fixture for non-tiled tif Ventana images.
136+
137+
Download Ventana tif image for pytest.
138+
139+
"""
140+
return remote_sample("regular-tif")
141+
142+
123143
@pytest.fixture(scope="session")
124144
def sample_jp2(remote_sample: Callable) -> Path:
125145
"""Sample pytest fixture for JP2 images.

tests/test_annotation_tilerendering.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@
3333
def cell_grid() -> list[Polygon]:
3434
"""Generate a grid of fake cell boundary polygon annotations."""
3535
return [
36-
cell_polygon(((i + 0.5) * 100, (j + 0.5) * 100)) for i, j in np.ndindex(5, 5)
36+
cell_polygon(((i + 0.5) * 100, (j + 0.5) * 100), radius=13)
37+
for i, j in np.ndindex(5, 5)
3738
]
3839

3940

@@ -168,7 +169,7 @@ def test_filter_by_expression(fill_store: Callable, tmp_path: Path) -> None:
168169
tg = AnnotationTileGenerator(wsi.info, store, renderer, tile_size=256)
169170
thumb = tg.get_thumb_tile()
170171
_, num = label(np.array(thumb)[:, :, 1])
171-
assert num == 25 # expect 25 cell objects, as the added one is too small
172+
assert num == 25 # expect 25 cell objects
172173

173174

174175
def test_zoomed_out_rendering(fill_store: Callable, tmp_path: Path) -> None:
@@ -190,7 +191,7 @@ def test_zoomed_out_rendering(fill_store: Callable, tmp_path: Path) -> None:
190191

191192
thumb = tg.get_tile(1, 0, 0)
192193
_, num = label(np.array(thumb)[:, :, 1]) # default color is green
193-
assert num == 25 # expect 25 cells in top left quadrant
194+
assert num == 25 # expect 25 cells in top left quadrant (added one too small)
194195

195196

196197
def test_decimation(fill_store: Callable, tmp_path: Path) -> None:
@@ -434,7 +435,7 @@ def test_unfilled_polys(fill_store: Callable, tmp_path: Path) -> None:
434435
renderer = AnnotationRenderer(thickness=1)
435436
tg = AnnotationTileGenerator(wsi.info, store, renderer, tile_size=256)
436437
tile_outline = np.array(tg.get_tile(1, 0, 0))
437-
tg.renderer.edge_thickness = -1
438+
tg.renderer.thickness = -1
438439
tile_filled = np.array(tg.get_tile(1, 0, 0))
439440
# expect sum of filled polys to be much greater than sum of outlines
440441
assert np.sum(tile_filled) > 2 * np.sum(tile_outline)

tests/test_wsireader.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1472,6 +1472,8 @@ def test_wsireader_open(
14721472
sample_ndpi: Path,
14731473
sample_jp2: Path,
14741474
sample_ome_tiff: Path,
1475+
sample_ventana_tif: Path,
1476+
sample_regular_tif: Path,
14751477
source_image: Path,
14761478
tmp_path: pytest.TempPathFactory,
14771479
) -> None:
@@ -1494,6 +1496,12 @@ def test_wsireader_open(
14941496
wsi = WSIReader.open(sample_ome_tiff)
14951497
assert isinstance(wsi, wsireader.TIFFWSIReader)
14961498

1499+
wsi = WSIReader.open(sample_ventana_tif)
1500+
assert isinstance(wsi, wsireader.OpenSlideWSIReader)
1501+
1502+
wsi = WSIReader.open(sample_regular_tif)
1503+
assert isinstance(wsi, wsireader.VirtualWSIReader)
1504+
14971505
wsi = WSIReader.open(Path(source_image))
14981506
assert isinstance(wsi, wsireader.VirtualWSIReader)
14991507

@@ -2540,6 +2548,11 @@ def test_jp2_no_header(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
25402548
"sample_key": "ome-brightfield-pyramid-1-small",
25412549
"kwargs": {},
25422550
},
2551+
{
2552+
"reader_class": OpenSlideWSIReader,
2553+
"sample_key": "ventana-tif",
2554+
"kwargs": {},
2555+
},
25432556
{
25442557
"reader_class": DICOMWSIReader,
25452558
"sample_key": "dicom-1",
@@ -2565,6 +2578,7 @@ def test_jp2_no_header(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
25652578
"AnnotationReaderOverlaid",
25662579
"AnnotationReaderMaskOnly",
25672580
"TIFFReader",
2581+
"OpenSlideReader (Ventana non-tiled tif)",
25682582
"DICOMReader",
25692583
"NGFFWSIReader",
25702584
"OpenSlideWSIReader (Small SVS)",

tiatoolbox/data/remote_samples.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ files:
2929
url: [*wsis, "CMU-1-Small-Region.ome.tiff"]
3030
two-tiled-pages:
3131
url: [*wsis, "two-tiled-pages.tiff"]
32+
ventana-tif:
33+
url: [*wsis, "ventana-sample.tif"]
34+
regular-tif:
35+
url: [*wsis, "sample-regular.tif"]
3236
jp2-omnyx-small:
3337
url: [*wsis, "CMU-1-Small-Region.omnyx.jp2"]
3438
jp2-omnyx-1:

tiatoolbox/utils/visualization.py

Lines changed: 26 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -661,7 +661,10 @@ def to_tile_coords(
661661
Array of coordinates in tile space in the form [x, y].
662662
663663
"""
664-
return ((np.reshape(coords, (-1, 2)) - top_left) / scale).astype(np.int32)
664+
return [
665+
((np.reshape(ring, (-1, 2)) - top_left) / scale).astype(np.int32)
666+
for ring in coords
667+
]
665668

666669
def get_color(
667670
self: AnnotationRenderer,
@@ -756,19 +759,26 @@ def render_poly(
756759
scale,
757760
)
758761
if self.thickness > -1:
759-
cv2.drawContours(
762+
cv2.polylines(
760763
tile,
761-
[cnt],
762-
0,
763-
col,
764-
self.edge_thickness,
764+
cnt,
765+
isClosed=True,
766+
color=col,
767+
thickness=self.edge_thickness,
765768
lineType=cv2.LINE_8,
766769
)
767770
else:
768-
cv2.drawContours(tile, [cnt], 0, col, self.thickness, lineType=cv2.LINE_8)
771+
cv2.fillPoly(tile, cnt, col)
769772
if self.thickness == -1 and self.edge_thickness > 0:
770773
edge_col = self.get_color(annotation, edge=True)
771-
cv2.drawContours(tile, [cnt], 0, edge_col, 1, lineType=cv2.LINE_8)
774+
cv2.polylines(
775+
tile,
776+
cnt,
777+
isClosed=True,
778+
color=edge_col,
779+
thickness=1,
780+
lineType=cv2.LINE_8,
781+
)
772782

773783
def render_multipoly(
774784
self: AnnotationRenderer,
@@ -782,7 +792,7 @@ def render_multipoly(
782792
geoms = annotation.coords
783793
for poly in geoms:
784794
cnt = self.to_tile_coords(poly, top_left, scale)
785-
cv2.drawContours(tile, [cnt], 0, col, self.thickness, lineType=cv2.LINE_8)
795+
cv2.fillPoly(tile, cnt, col)
786796

787797
def render_pt(
788798
self: AnnotationRenderer,
@@ -811,7 +821,7 @@ def render_pt(
811821
annotation.coords,
812822
top_left,
813823
scale,
814-
)[0],
824+
)[0][0],
815825
np.maximum(self.edge_thickness, 1),
816826
col,
817827
thickness=self.thickness,
@@ -838,15 +848,14 @@ def render_line(
838848
839849
"""
840850
col = self.get_color(annotation, edge=False)
851+
cnt = self.to_tile_coords(
852+
list(annotation.coords),
853+
top_left,
854+
scale,
855+
)
841856
cv2.polylines(
842857
tile,
843-
[
844-
self.to_tile_coords(
845-
list(annotation.coords),
846-
top_left,
847-
scale,
848-
),
849-
],
858+
[np.array(cnt)],
850859
isClosed=False,
851860
color=col,
852861
thickness=3,

tiatoolbox/wsicore/wsireader.py

Lines changed: 94 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,90 @@ def is_ngff( # noqa: PLR0911
205205
return is_zarr(path)
206206

207207

208+
def _handle_virtual_wsi(
209+
last_suffix: str,
210+
input_path: Path,
211+
mpp: tuple[Number, Number] | None,
212+
power: Number | None,
213+
) -> VirtualWSIReader | None:
214+
"""Handle virtual WSI cases.
215+
216+
Args:
217+
last_suffix (str):
218+
Suffix of the file to read.
219+
input_path (Path):
220+
Input path to virtual WSI.
221+
mpp (:obj:`tuple` or :obj:`list` or :obj:`None`, optional):
222+
The MPP of the WSI. If not provided, the MPP is approximated
223+
from the objective power.
224+
power (:obj:`float` or :obj:`None`, optional):
225+
The objective power of the WSI. If not provided, the power
226+
is approximated from the MPP.
227+
228+
Returns:
229+
VirtualWSIReader | None:
230+
:class:`VirtualWSIReader` if input_path is valid path to virtual WSI
231+
otherwise None.
232+
233+
"""
234+
235+
# Handle homogeneous cases (based on final suffix)
236+
def np_virtual_wsi(
237+
input_path: np.ndarray,
238+
*args: Number | tuple | str | WSIMeta | None,
239+
**kwargs: dict,
240+
) -> VirtualWSIReader:
241+
"""Create a virtual WSI from a numpy array."""
242+
return VirtualWSIReader(input_path, *args, **kwargs)
243+
244+
suffix_to_reader = {
245+
".npy": np_virtual_wsi,
246+
".jp2": JP2WSIReader,
247+
".jpeg": VirtualWSIReader,
248+
".jpg": VirtualWSIReader,
249+
".png": VirtualWSIReader,
250+
".tif": VirtualWSIReader,
251+
".tiff": VirtualWSIReader,
252+
}
253+
254+
if last_suffix in suffix_to_reader:
255+
return suffix_to_reader[last_suffix](input_path, mpp=mpp, power=power)
256+
257+
return None
258+
259+
260+
def _handle_tiff_wsi(
261+
input_path: Path, mpp: tuple[Number, Number] | None, power: Number | None
262+
) -> TIFFWSIReader | OpenSlideWSIReader | None:
263+
"""Handle TIFF WSI cases.
264+
265+
Args:
266+
input_path (Path):
267+
Input path to virtual WSI.
268+
mpp (:obj:`tuple` or :obj:`list` or :obj:`None`, optional):
269+
The MPP of the WSI. If not provided, the MPP is approximated
270+
from the objective power.
271+
power (:obj:`float` or :obj:`None`, optional):
272+
The objective power of the WSI. If not provided, the power
273+
is approximated from the MPP.
274+
275+
Returns:
276+
OpenSlideWSIReader | TIFFWSIReader | None:
277+
:class:`OpenSlideWSIReader` or :class:`TIFFWSIReader` if input_path is
278+
valid path to tiff WSI otherwise None.
279+
280+
"""
281+
if openslide.OpenSlide.detect_format(input_path) is not None:
282+
try:
283+
return OpenSlideWSIReader(input_path, mpp=mpp, power=power)
284+
except openslide.OpenSlideError:
285+
pass
286+
if is_tiled_tiff(input_path):
287+
return TIFFWSIReader(input_path, mpp=mpp, power=power)
288+
289+
return None
290+
291+
208292
class WSIReader:
209293
"""Base whole slide image (WSI) reader class.
210294
@@ -279,7 +363,6 @@ def open( # noqa: PLR0911
279363
WSIReader.verify_supported_wsi(input_path)
280364

281365
# Handle special cases first (DICOM, Zarr/NGFF, OME-TIFF)
282-
283366
if is_dicom(input_path):
284367
return DICOMWSIReader(input_path, mpp=mpp, power=power)
285368

@@ -300,33 +383,17 @@ def open( # noqa: PLR0911
300383
if suffixes[-2:] in ([".ome", ".tiff"],):
301384
return TIFFWSIReader(input_path, mpp=mpp, power=power)
302385

303-
if last_suffix in (".tif", ".tiff") and is_tiled_tiff(input_path):
304-
try:
305-
return OpenSlideWSIReader(input_path, mpp=mpp, power=power)
306-
except openslide.OpenSlideError:
307-
return TIFFWSIReader(input_path, mpp=mpp, power=power)
308-
309-
# Handle homogeneous cases (based on final suffix)
310-
def np_virtual_wsi(
311-
input_path: np.ndarray,
312-
*args: Number | tuple | str | WSIMeta | None,
313-
**kwargs: dict,
314-
) -> VirtualWSIReader:
315-
"""Create a virtual WSI from a numpy array."""
316-
return VirtualWSIReader(input_path, *args, **kwargs)
317-
318-
suffix_to_reader = {
319-
".npy": np_virtual_wsi,
320-
".jp2": JP2WSIReader,
321-
".jpeg": VirtualWSIReader,
322-
".jpg": VirtualWSIReader,
323-
".png": VirtualWSIReader,
324-
".tif": VirtualWSIReader,
325-
".tiff": VirtualWSIReader,
326-
}
386+
if last_suffix in (".tif", ".tiff"):
387+
tiff_wsi = _handle_tiff_wsi(input_path, mpp=mpp, power=power)
388+
if tiff_wsi is not None:
389+
return tiff_wsi
390+
391+
virtual_wsi = _handle_virtual_wsi(
392+
last_suffix=last_suffix, input_path=input_path, mpp=mpp, power=power
393+
)
327394

328-
if last_suffix in suffix_to_reader:
329-
return suffix_to_reader[last_suffix](input_path, mpp=mpp, power=power)
395+
if virtual_wsi is not None:
396+
return virtual_wsi
330397

331398
# Try openslide last
332399
return OpenSlideWSIReader(input_path, mpp=mpp, power=power)

0 commit comments

Comments
 (0)