Skip to content

Commit 3fa051f

Browse files
🆕 Improve Support for QuPath GeoJson and Multipoint Geometry (#841)
This PR makes a collection of changes related to reviewer comments in the TIAViz paper. Main changes are: 1. Layer names in the interface for static image overlays now are named after filename stem, instead of generic 'layer1', 'layer2' etc to make it easier to distinguish the layers. 2. Adds multipoint geometry rendering as qupath exports collections of points as multipoints not individual point annotations 3. Robustifies qupath import from geojson to handle qupath special case of `nucleusGeometry`. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 5ccbc70 commit 3fa051f

File tree

7 files changed

+135
-28
lines changed

7 files changed

+135
-28
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
def test_hovernet_on_box(doc: Document, data_path: pytest.TempPathFactory) -> None:

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/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)