Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
efdf790
address review comments
measty Jul 31, 2024
deb1d12
Merge branch 'develop' into address-review-comments
measty Jul 31, 2024
3e7938b
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jul 31, 2024
f4b3c90
qupath measurements support
measty Aug 2, 2024
2724b26
Merge branch 'develop' into address-review-comments
shaneahmed Aug 9, 2024
b0414d5
Merge branch 'develop' into address-review-comments
shaneahmed Aug 16, 2024
e770710
Merge branch 'develop' into address-review-comments
shaneahmed Aug 23, 2024
9a2938b
Merge branch 'develop' into address-review-comments
shaneahmed Sep 20, 2024
b8b3a71
Merge branch 'develop' into address-review-comments
shaneahmed Sep 27, 2024
aca8588
Merge branch 'develop' into address-review-comments
shaneahmed Oct 25, 2024
d7e33f8
Merge branch 'develop' into address-review-comments
shaneahmed Nov 22, 2024
82b7bd1
Merge branch 'develop' into address-review-comments
shaneahmed Jan 24, 2025
b342823
Merge branch 'develop' into address-review-comments
shaneahmed Feb 7, 2025
b2ff5c1
Merge branch 'develop' into address-review-comments
shaneahmed Feb 21, 2025
768fb27
Merge branch 'develop' into address-review-comments
shaneahmed Mar 7, 2025
1efaaaf
Merge branch 'develop' into address-review-comments
shaneahmed Mar 14, 2025
126c65c
Merge branch 'develop' into address-review-comments
shaneahmed Apr 4, 2025
4ac6e61
Merge branch 'develop' into address-review-comments
shaneahmed Apr 11, 2025
f2783bf
Merge branch 'develop' into address-review-comments
shaneahmed Apr 25, 2025
6c89b1c
Merge branch 'develop' into address-review-comments
shaneahmed May 2, 2025
cbab849
Merge branch 'develop' into address-review-comments
shaneahmed May 23, 2025
5b84cf8
Merge branch 'develop' of https://github.com/TissueImageAnalytics/tia…
measty May 29, 2025
908120a
update tests
measty May 29, 2025
68b67ad
tests fix
measty May 29, 2025
71b9baa
handhold mypy through types
measty May 29, 2025
a185856
Merge branch 'develop' into address-review-comments
measty Jun 2, 2025
10ee58b
Merge branch 'develop' into address-review-comments
shaneahmed Jun 13, 2025
338e808
Merge branch 'develop' into address-review-comments
shaneahmed Jun 20, 2025
cdc2896
Merge branch 'address-review-comments' of https://github.com/TissueIm…
measty Aug 14, 2025
0414d5f
Merge branch 'develop' of https://github.com/TissueImageAnalytics/tia…
measty Aug 14, 2025
777531d
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Aug 14, 2025
d9b2ffc
address comments
measty Aug 14, 2025
174faa6
Merge branch 'address-review-comments' of https://github.com/TissueIm…
measty Aug 14, 2025
233aba3
adjust tests for new naming convention
measty Aug 15, 2025
b36581e
test update
measty Sep 2, 2025
ba006c8
Merge branch 'develop' of https://github.com/TissueImageAnalytics/tia…
measty Sep 4, 2025
9714642
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Sep 4, 2025
16ef985
update test
measty Sep 11, 2025
f31fff7
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Sep 11, 2025
578cebd
fix test
measty Sep 11, 2025
91f03d6
Merge branch 'address-review-comments' of https://github.com/TissueIm…
measty Sep 11, 2025
caab28d
add even more tests
measty Sep 12, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 24 additions & 2 deletions tests/test_annotation_tilerendering.py
Original file line number Diff line number Diff line change
Expand Up @@ -270,15 +270,37 @@ def sub_tile_level_count(self: MockTileGenerator) -> int:
assert tile.size == (112, 112)


def test_multi_point() -> None:
"""Test multi-point rendering."""
renderer = AnnotationRenderer(max_scale=8, edge_thickness=0)
tile = np.zeros((256, 256, 3), dtype=np.uint8)
renderer.render_by_type(
tile=tile,
annotation=Annotation(MultiPoint([(5.0, 5.0), (10.0, 10.0)])),
top_left=(0, 0),
scale=1,
)
# check point locations are now non-zero
assert np.any(tile[5, 5, :] > 0)
assert np.any(tile[10, 10, :] > 0)
# check a non-point region still zero
assert np.all(tile[100:150, 100:150, :] == 0)


def test_unknown_geometry(
fill_store: Callable, # noqa: ARG001
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test warning when unknown geometries cannot be rendered."""
renderer = AnnotationRenderer(max_scale=8, edge_thickness=0)
unknown_ann = Annotation(
MultiPolygon(
[Polygon.from_bounds(0, 0, 1, 1), Polygon.from_bounds(2, 2, 3, 3)]
),
{},
)
renderer.render_by_type(
tile=np.zeros((256, 256, 3), dtype=np.uint8),
annotation=Annotation(MultiPoint([(5.0, 5.0), (10.0, 10.0)])),
annotation=unknown_ann,
top_left=(0, 0),
scale=1,
)
Expand Down
19 changes: 14 additions & 5 deletions tests/test_app_bokeh.py
Original file line number Diff line number Diff line change
Expand Up @@ -285,7 +285,7 @@ def test_add_annotation_layer(doc: Document, data_path: pytest.TempPathFactory)
# trigger an event to select the geojson file
click = MenuItemClick(layer_drop, str(data_path["geojson_anns"]))
layer_drop._trigger_event(click)
assert main.UI["vstate"].types == ["annotation"]
assert set(main.UI["vstate"].types) == {"nucleus", "cell", "annotation"}

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

# check alpha controls
Expand All @@ -443,14 +444,22 @@ def test_load_img_overlay(doc: Document, data_path: pytest.TempPathFactory) -> N
assert type_column_list[-1].active
# toggle off and check alpha is 0
type_column_list[-1].active = False
assert main.UI["p"].renderers[main.UI["vstate"].layer_dict["layer2"]].alpha == 0
assert main.UI["p"].renderers[main.UI["vstate"].layer_dict[l_name]].alpha == 0
# toggle back on and check alpha is back to default 0.75
type_column_list[-1].active = True
assert main.UI["p"].renderers[main.UI["vstate"].layer_dict["layer2"]].alpha == 0.75
assert main.UI["p"].renderers[main.UI["vstate"].layer_dict[l_name]].alpha == 0.75
# set alpha to 0.4
layer_slider.value = 0.4
# check that the alpha values have been set correctly
assert main.UI["p"].renderers[main.UI["vstate"].layer_dict["layer2"]].alpha == 0.4
assert main.UI["p"].renderers[main.UI["vstate"].layer_dict[l_name]].alpha == 0.4

# check loading a new layer with same stem uses full file name to disambiguate
click = MenuItemClick(layer_drop, str(data_path["annotations"]))
layer_drop._trigger_event(click)
click = MenuItemClick(layer_drop, str(data_path["img_overlay"]))
layer_drop._trigger_event(click)
full_name = data_path["img_overlay"].name
assert full_name in main.UI["vstate"].layer_dict


def test_hovernet_on_box(doc: Document, data_path: pytest.TempPathFactory) -> None:
Expand Down
32 changes: 22 additions & 10 deletions tests/test_tileserver.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,11 +100,7 @@ def app(remote_sample: Callable, tmp_path: Path) -> TileServer:
imwrite(thumb_path, thumb)

sample_store = Path(remote_sample("annotation_store_svs_1"))
store = SQLiteStore(sample_store)
geo_path = tmp_path / "test.geojson"
store.to_geojson(geo_path)
store.commit()
store.close()
geo_path = Path(remote_sample("geojson_cmu_1"))

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

# check loading a geojson from qupath with cells/nuclei
qpath_geo_path = Path(remote_sample("geojson_cmu_1"))
response = client.put(
"/tileserver/overlay",
data={"overlay_path": safe_str(qpath_geo_path)},
)
assert response.status_code == 200

# add another image layer
response = client.put(
"/tileserver/overlay",
Expand All @@ -462,10 +466,18 @@ def test_change_overlay( # noqa: PLR0915
assert response.status_code == 200
assert response.content_type == "text/html; charset=utf-8"
# check that the overlay has been correctly added
lname = f"layer{len(empty_app.pyramids[session_id]) - 1}"
lname = Path(overlay_path).stem
layer = empty_app.pyramids[session_id][lname]
assert layer.wsi.info.file_path == overlay_path

# add same image again to check if duplicate names triggers disambiguation
response = client.put(
"/tileserver/overlay",
data={"overlay_path": safe_str(overlay_path)},
)
assert response.status_code == 200
assert Path(overlay_path).name in empty_app.pyramids[session_id]

# replace existing store overlay
response = client.put(
"/tileserver/overlay",
Expand Down Expand Up @@ -494,7 +506,7 @@ def test_change_overlay( # noqa: PLR0915
data={"overlay_path": safe_str(jpg_path)},
)
# check that the overlay has been correctly added
lname = f"layer{len(empty_app.pyramids[session_id]) - 1}"
lname = Path(jpg_path).stem
layer = empty_app.pyramids[session_id][lname]
assert np.all(layer.wsi.img == imread(jpg_path))

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

Expand Down
14 changes: 14 additions & 0 deletions tiatoolbox/annotation/storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -1995,6 +1995,20 @@ def transform_geometry(geom: Geometry) -> Geometry:
)
for feature in geojson["features"]
]
# check for presence of 'nucleusGeometry' key in features
# if present, add them (support qupath export format)
annotations += [
transform(
Annotation(
transform_geometry(
feature2geometry(feature["nucleusGeometry"]),
),
{"type": "nucleus"},
),
)
for feature in geojson["features"]
if "nucleusGeometry" in feature
]

logger.info("Adding %d annotations.", len(annotations))
self.append_many(annotations)
Expand Down
36 changes: 34 additions & 2 deletions tiatoolbox/utils/visualization.py
Original file line number Diff line number Diff line change
Expand Up @@ -963,11 +963,41 @@ def render_pt(
top_left,
scale,
)[0][0],
np.maximum(self.edge_thickness, 1),
np.maximum(int(16 / scale**0.5), 1),
col,
thickness=self.thickness,
thickness=-1,
)

def render_pts(
self: AnnotationRenderer,
tile: np.ndarray,
annotation: Annotation,
top_left: tuple[float, float],
scale: float,
) -> None:
"""Render a multipoint annotation onto a tile using cv2.

Args:
tile (ndarray):
The rgb(a) tile image to render onto.
annotation (Annotation):
The annotation to render.
top_left (tuple):
The top left corner of the tile in wsi.
scale (float):
The zoom scale at which we are rendering.

"""
col = self.get_color(annotation, edge=False)
for pt in annotation.coords:
cv2.circle(
tile,
self.to_tile_coords([pt], top_left, scale)[0][0],
np.maximum(int(16 / scale**0.5), 1),
col,
thickness=-1,
)

def render_line(
self: AnnotationRenderer,
tile: np.ndarray,
Expand Down Expand Up @@ -1172,5 +1202,7 @@ def render_by_type(
self.render_poly(tile, annotation, top_left, scale)
elif geom_type == GeometryType.LINE_STRING:
self.render_line(tile, annotation, top_left, scale)
elif geom_type == GeometryType.MULTI_POINT:
self.render_pts(tile, annotation, top_left, scale)
else:
logger.warning("Unknown geometry: %s", geom_type, stacklevel=3)
5 changes: 3 additions & 2 deletions tiatoolbox/visualization/bokeh_app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -510,7 +510,6 @@ def add_layer(lname: str) -> None:
end=1,
value=0.75,
step=0.01,
title=lname,
height=40,
width=100,
max_width=90,
Expand Down Expand Up @@ -1069,7 +1068,9 @@ def layer_slider_cb(
UI["vstate"].layer_dict[obj.name.split("_")[0]]
].glyph.line_alpha = new
else:
UI["p"].renderers[UI["vstate"].layer_dict[obj.name.split("_")[0]]].alpha = new
UI["p"].renderers[
UI["vstate"].layer_dict["_".join(obj.name.split("_")[0:-1])]
].alpha = new


def color_input_cb(
Expand Down
31 changes: 24 additions & 7 deletions tiatoolbox/visualization/tileserver.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
if TYPE_CHECKING: # pragma: no cover
from matplotlib.colors import Colormap

from tiatoolbox.annotation.storage import Annotation
from tiatoolbox.wsicore import WSIMeta


Expand Down Expand Up @@ -538,7 +539,7 @@ def _handle_registration_overlay(
self,
session_id: str,
overlay_path: Path,
other_session_id: str,
other_session_id: str | None,
) -> str:
def _apply_transform(source_fp: str, target_fp: str) -> None:
# loading a registration transformation
Expand All @@ -559,10 +560,11 @@ def _apply_transform(source_fp: str, target_fp: str) -> None:
)
return json.dumps("slide")

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

def _add_image_overlay(self, session_id: str, overlay_path: Path) -> str:
layer = f"layer{len(self.pyramids[session_id])}"
layer = overlay_path.stem
if layer in self.layers[session_id]:
# use full file name to disambiguate
layer = overlay_path.name
if overlay_path.suffix == ".tiff":
self.layers[session_id][layer] = OpenSlideWSIReader(
overlay_path,
Expand All @@ -608,7 +613,19 @@ def _add_image_overlay(self, session_id: str, overlay_path: Path) -> str:

def _add_annotation_overlay(self, session_id: str, overlay_path: Path) -> str:
if overlay_path.suffix == ".geojson":
sq = SQLiteStore.from_geojson(overlay_path)

def unpack_qupath(ann: Annotation) -> Annotation:
# Helper function to unpack QuPath measurements if present.
props = ann.properties
if "measurements" in props:
measurements = props.pop("measurements")
for k, v in measurements.items():
props[k] = v
if "objectType" in props:
props["type"] = props.pop("objectType")
return ann

sq = SQLiteStore.from_geojson(overlay_path, transform=unpack_qupath)
elif overlay_path.suffix == ".dat":
sq = store_from_dat(overlay_path)
if overlay_path.suffix == ".db":
Expand Down