Skip to content

Commit 5125146

Browse files
authored
Merge branch 'dev-define-engines-abc' into dev-define-semantic-segmentor
2 parents 610d355 + 56867fc commit 5125146

File tree

8 files changed

+140
-30
lines changed

8 files changed

+140
-30
lines changed

tests/test_annotation_tilerendering.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -270,15 +270,37 @@ def sub_tile_level_count(self: MockTileGenerator) -> int:
270270
assert tile.size == (112, 112)
271271

272272

273+
def test_multi_point() -> None:
274+
"""Test multi-point rendering."""
275+
renderer = AnnotationRenderer(max_scale=8, edge_thickness=0)
276+
tile = np.zeros((256, 256, 3), dtype=np.uint8)
277+
renderer.render_by_type(
278+
tile=tile,
279+
annotation=Annotation(MultiPoint([(5.0, 5.0), (10.0, 10.0)])),
280+
top_left=(0, 0),
281+
scale=1,
282+
)
283+
# check point locations are now non-zero
284+
assert np.any(tile[5, 5, :] > 0)
285+
assert np.any(tile[10, 10, :] > 0)
286+
# check a non-point region still zero
287+
assert np.all(tile[100:150, 100:150, :] == 0)
288+
289+
273290
def test_unknown_geometry(
274-
fill_store: Callable, # noqa: ARG001
275291
caplog: pytest.LogCaptureFixture,
276292
) -> None:
277293
"""Test warning when unknown geometries cannot be rendered."""
278294
renderer = AnnotationRenderer(max_scale=8, edge_thickness=0)
295+
unknown_ann = Annotation(
296+
MultiPolygon(
297+
[Polygon.from_bounds(0, 0, 1, 1), Polygon.from_bounds(2, 2, 3, 3)]
298+
),
299+
{},
300+
)
279301
renderer.render_by_type(
280302
tile=np.zeros((256, 256, 3), dtype=np.uint8),
281-
annotation=Annotation(MultiPoint([(5.0, 5.0), (10.0, 10.0)])),
303+
annotation=unknown_ann,
282304
top_left=(0, 0),
283305
scale=1,
284306
)

tests/test_app_bokeh.py

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -285,7 +285,7 @@ def test_add_annotation_layer(doc: Document, data_path: pytest.TempPathFactory)
285285
# trigger an event to select the geojson file
286286
click = MenuItemClick(layer_drop, str(data_path["geojson_anns"]))
287287
layer_drop._trigger_event(click)
288-
assert main.UI["vstate"].types == ["annotation"]
288+
assert set(main.UI["vstate"].types) == {"nucleus", "cell", "annotation"}
289289

290290
# test the name2type function.
291291
assert main.name2type("annotation") == '"annotation"'
@@ -434,7 +434,8 @@ def test_load_img_overlay(doc: Document, data_path: pytest.TempPathFactory) -> N
434434
# trigger an event to select the image overlay
435435
click = MenuItemClick(layer_drop, str(data_path["img_overlay"]))
436436
layer_drop._trigger_event(click)
437-
layer_slider = doc.get_model_by_name("layer2_slider")
437+
l_name = data_path["img_overlay"].stem
438+
layer_slider = doc.get_model_by_name(f"{l_name}_slider")
438439
assert layer_slider is not None
439440

440441
# check alpha controls
@@ -443,14 +444,22 @@ def test_load_img_overlay(doc: Document, data_path: pytest.TempPathFactory) -> N
443444
assert type_column_list[-1].active
444445
# toggle off and check alpha is 0
445446
type_column_list[-1].active = False
446-
assert main.UI["p"].renderers[main.UI["vstate"].layer_dict["layer2"]].alpha == 0
447+
assert main.UI["p"].renderers[main.UI["vstate"].layer_dict[l_name]].alpha == 0
447448
# toggle back on and check alpha is back to default 0.75
448449
type_column_list[-1].active = True
449-
assert main.UI["p"].renderers[main.UI["vstate"].layer_dict["layer2"]].alpha == 0.75
450+
assert main.UI["p"].renderers[main.UI["vstate"].layer_dict[l_name]].alpha == 0.75
450451
# set alpha to 0.4
451452
layer_slider.value = 0.4
452453
# check that the alpha values have been set correctly
453-
assert main.UI["p"].renderers[main.UI["vstate"].layer_dict["layer2"]].alpha == 0.4
454+
assert main.UI["p"].renderers[main.UI["vstate"].layer_dict[l_name]].alpha == 0.4
455+
456+
# check loading a new layer with same stem uses full file name to disambiguate
457+
click = MenuItemClick(layer_drop, str(data_path["annotations"]))
458+
layer_drop._trigger_event(click)
459+
click = MenuItemClick(layer_drop, str(data_path["img_overlay"]))
460+
layer_drop._trigger_event(click)
461+
full_name = data_path["img_overlay"].name
462+
assert full_name in main.UI["vstate"].layer_dict
454463

455464

456465
# test_hovernet_on_box should be fixed before merge to develop.

tests/test_tileserver.py

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -100,11 +100,7 @@ def app(remote_sample: Callable, tmp_path: Path) -> TileServer:
100100
imwrite(thumb_path, thumb)
101101

102102
sample_store = Path(remote_sample("annotation_store_svs_1"))
103-
store = SQLiteStore(sample_store)
104-
geo_path = tmp_path / "test.geojson"
105-
store.to_geojson(geo_path)
106-
store.commit()
107-
store.close()
103+
geo_path = Path(remote_sample("geojson_cmu_1"))
108104

109105
# make tileserver with layers representing all the types
110106
# of things it should be able to handle
@@ -115,7 +111,7 @@ def app(remote_sample: Callable, tmp_path: Path) -> TileServer:
115111
"tile": str(thumb_path),
116112
"im_array": np.zeros(wsi.slide_dimensions(1.25, "power"), dtype=np.uint8).T,
117113
"overlay": str(sample_store),
118-
"store_geojson": tmp_path / "test.geojson",
114+
"store_geojson": str(geo_path),
119115
},
120116
)
121117
app.config.from_mapping({"TESTING": True})
@@ -446,14 +442,22 @@ def test_change_overlay( # noqa: PLR0915
446442
assert response.status_code == 200
447443
response = client.put(
448444
"/tileserver/overlay",
449-
data={"overlay_path": safe_str(geo_path)},
445+
data={"overlay_path": safe_str(sample_store)},
450446
)
451447
assert response.status_code == 200
452448
assert response.content_type == "text/html; charset=utf-8"
453449
assert set(json.loads(response.data)) == {0, 1, 2, 3, 4}
454450
# check that the annotations have been correctly loaded
455451
assert len(empty_app.pyramids[session_id]["overlay"].store) == num_annotations
456452

453+
# check loading a geojson from qupath with cells/nuclei
454+
qpath_geo_path = Path(remote_sample("geojson_cmu_1"))
455+
response = client.put(
456+
"/tileserver/overlay",
457+
data={"overlay_path": safe_str(qpath_geo_path)},
458+
)
459+
assert response.status_code == 200
460+
457461
# add another image layer
458462
response = client.put(
459463
"/tileserver/overlay",
@@ -462,10 +466,18 @@ def test_change_overlay( # noqa: PLR0915
462466
assert response.status_code == 200
463467
assert response.content_type == "text/html; charset=utf-8"
464468
# check that the overlay has been correctly added
465-
lname = f"layer{len(empty_app.pyramids[session_id]) - 1}"
469+
lname = Path(overlay_path).stem
466470
layer = empty_app.pyramids[session_id][lname]
467471
assert layer.wsi.info.file_path == overlay_path
468472

473+
# add same image again to check if duplicate names triggers disambiguation
474+
response = client.put(
475+
"/tileserver/overlay",
476+
data={"overlay_path": safe_str(overlay_path)},
477+
)
478+
assert response.status_code == 200
479+
assert Path(overlay_path).name in empty_app.pyramids[session_id]
480+
469481
# replace existing store overlay
470482
response = client.put(
471483
"/tileserver/overlay",
@@ -494,7 +506,7 @@ def test_change_overlay( # noqa: PLR0915
494506
data={"overlay_path": safe_str(jpg_path)},
495507
)
496508
# check that the overlay has been correctly added
497-
lname = f"layer{len(empty_app.pyramids[session_id]) - 1}"
509+
lname = Path(jpg_path).stem
498510
layer = empty_app.pyramids[session_id][lname]
499511
assert np.all(layer.wsi.img == imread(jpg_path))
500512

@@ -518,7 +530,7 @@ def test_change_overlay( # noqa: PLR0915
518530
data={"overlay_path": safe_str(tiff_path)},
519531
)
520532
# check that the overlay has been correctly added
521-
lname = f"layer{len(empty_app.pyramids[session_id]) - 1}"
533+
lname = Path(tiff_path).stem
522534
layer = empty_app.pyramids[session_id][lname]
523535
assert layer.wsi.info.file_path == tiff_path
524536

tiatoolbox/annotation/storage.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1995,6 +1995,20 @@ def transform_geometry(geom: Geometry) -> Geometry:
19951995
)
19961996
for feature in geojson["features"]
19971997
]
1998+
# check for presence of 'nucleusGeometry' key in features
1999+
# if present, add them (support qupath export format)
2000+
annotations += [
2001+
transform(
2002+
Annotation(
2003+
transform_geometry(
2004+
feature2geometry(feature["nucleusGeometry"]),
2005+
),
2006+
{"type": "nucleus"},
2007+
),
2008+
)
2009+
for feature in geojson["features"]
2010+
if "nucleusGeometry" in feature
2011+
]
19982012

19992013
logger.info("Adding %d annotations.", len(annotations))
20002014
self.append_many(annotations)

tiatoolbox/utils/misc.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import tempfile
99
import zipfile
1010
from pathlib import Path
11-
from typing import IO, TYPE_CHECKING
11+
from typing import IO, TYPE_CHECKING, cast
1212

1313
import cv2
1414
import dask.array as da
@@ -1341,7 +1341,7 @@ def process_contours(
13411341

13421342

13431343
def dict_to_store_semantic_segmentor(
1344-
patch_output: dict | zarr.group,
1344+
patch_output: dict | zarr.Group,
13451345
scale_factor: tuple[float, float],
13461346
class_dict: dict | None = None,
13471347
save_path: Path | None = None,
@@ -1388,6 +1388,9 @@ def dict_to_store_semantic_segmentor(
13881388
cv2.RETR_CCOMP,
13891389
cv2.CHAIN_APPROX_NONE,
13901390
)
1391+
1392+
contours = cast("list[np.ndarray]", contours)
1393+
13911394
annotations_list_ = process_contours(contours, hierarchy, scale_factor)
13921395
annotations_list.extend(annotations_list_)
13931396

tiatoolbox/utils/visualization.py

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -963,11 +963,41 @@ def render_pt(
963963
top_left,
964964
scale,
965965
)[0][0],
966-
np.maximum(self.edge_thickness, 1),
966+
np.maximum(int(16 / scale**0.5), 1),
967967
col,
968-
thickness=self.thickness,
968+
thickness=-1,
969969
)
970970

971+
def render_pts(
972+
self: AnnotationRenderer,
973+
tile: np.ndarray,
974+
annotation: Annotation,
975+
top_left: tuple[float, float],
976+
scale: float,
977+
) -> None:
978+
"""Render a multipoint annotation onto a tile using cv2.
979+
980+
Args:
981+
tile (ndarray):
982+
The rgb(a) tile image to render onto.
983+
annotation (Annotation):
984+
The annotation to render.
985+
top_left (tuple):
986+
The top left corner of the tile in wsi.
987+
scale (float):
988+
The zoom scale at which we are rendering.
989+
990+
"""
991+
col = self.get_color(annotation, edge=False)
992+
for pt in annotation.coords:
993+
cv2.circle(
994+
tile,
995+
self.to_tile_coords([pt], top_left, scale)[0][0],
996+
np.maximum(int(16 / scale**0.5), 1),
997+
col,
998+
thickness=-1,
999+
)
1000+
9711001
def render_line(
9721002
self: AnnotationRenderer,
9731003
tile: np.ndarray,
@@ -1172,5 +1202,7 @@ def render_by_type(
11721202
self.render_poly(tile, annotation, top_left, scale)
11731203
elif geom_type == GeometryType.LINE_STRING:
11741204
self.render_line(tile, annotation, top_left, scale)
1205+
elif geom_type == GeometryType.MULTI_POINT:
1206+
self.render_pts(tile, annotation, top_left, scale)
11751207
else:
11761208
logger.warning("Unknown geometry: %s", geom_type, stacklevel=3)

tiatoolbox/visualization/bokeh_app/main.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -510,7 +510,6 @@ def add_layer(lname: str) -> None:
510510
end=1,
511511
value=0.75,
512512
step=0.01,
513-
title=lname,
514513
height=40,
515514
width=100,
516515
max_width=90,
@@ -1069,7 +1068,9 @@ def layer_slider_cb(
10691068
UI["vstate"].layer_dict[obj.name.split("_")[0]]
10701069
].glyph.line_alpha = new
10711070
else:
1072-
UI["p"].renderers[UI["vstate"].layer_dict[obj.name.split("_")[0]]].alpha = new
1071+
UI["p"].renderers[
1072+
UI["vstate"].layer_dict["_".join(obj.name.split("_")[0:-1])]
1073+
].alpha = new
10731074

10741075

10751076
def color_input_cb(

tiatoolbox/visualization/tileserver.py

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
if TYPE_CHECKING: # pragma: no cover
3636
from matplotlib.colors import Colormap
3737

38+
from tiatoolbox.annotation.storage import Annotation
3839
from tiatoolbox.wsicore import WSIMeta
3940

4041

@@ -538,7 +539,7 @@ def _handle_registration_overlay(
538539
self,
539540
session_id: str,
540541
overlay_path: Path,
541-
other_session_id: str,
542+
other_session_id: str | None,
542543
) -> str:
543544
def _apply_transform(source_fp: str, target_fp: str) -> None:
544545
# loading a registration transformation
@@ -559,10 +560,11 @@ def _apply_transform(source_fp: str, target_fp: str) -> None:
559560
)
560561
return json.dumps("slide")
561562

562-
layer_keys = [k for k in self.layers[session_id] if "layer" in k]
563-
for key in sorted(
564-
layer_keys, key=lambda x: int(x.replace("layer", "")), reverse=True
565-
):
563+
layer_keys = [
564+
k for k in self.layers[session_id] if k not in ["slide", "overlay"]
565+
]
566+
layer_keys.reverse() # try newest first
567+
for key in layer_keys:
566568
target_fp = self.layers[session_id][key].info.file_path
567569
if Path(target_fp).suffix in [".tiff", ".svs", ".ndpi", ".mrxs"]:
568570
logger.warning(
@@ -585,7 +587,10 @@ def _apply_transform(source_fp: str, target_fp: str) -> None:
585587
return json.dumps("slide")
586588

587589
def _add_image_overlay(self, session_id: str, overlay_path: Path) -> str:
588-
layer = f"layer{len(self.pyramids[session_id])}"
590+
layer = overlay_path.stem
591+
if layer in self.layers[session_id]:
592+
# use full file name to disambiguate
593+
layer = overlay_path.name
589594
if overlay_path.suffix == ".tiff":
590595
self.layers[session_id][layer] = OpenSlideWSIReader(
591596
overlay_path,
@@ -608,7 +613,19 @@ def _add_image_overlay(self, session_id: str, overlay_path: Path) -> str:
608613

609614
def _add_annotation_overlay(self, session_id: str, overlay_path: Path) -> str:
610615
if overlay_path.suffix == ".geojson":
611-
sq = SQLiteStore.from_geojson(overlay_path)
616+
617+
def unpack_qupath(ann: Annotation) -> Annotation:
618+
# Helper function to unpack QuPath measurements if present.
619+
props = ann.properties
620+
if "measurements" in props:
621+
measurements = props.pop("measurements")
622+
for k, v in measurements.items():
623+
props[k] = v
624+
if "objectType" in props:
625+
props["type"] = props.pop("objectType")
626+
return ann
627+
628+
sq = SQLiteStore.from_geojson(overlay_path, transform=unpack_qupath)
612629
elif overlay_path.suffix == ".dat":
613630
sq = store_from_dat(overlay_path)
614631
if overlay_path.suffix == ".db":

0 commit comments

Comments
 (0)