Skip to content

Commit 22dfa11

Browse files
meastyadamshephardpre-commit-ci[bot]shaneahmed
authored
✨ Add on-the-fly Registered Slide Visualization (#875)
This PR allows registration transform to be loaded as an 'overlay', which will allow the slide to be viewed as if that registration transform had been applied to it. This will work with either an affine transformation saved as a .npy, or a non-rigid one represented by a SimpleITK displacement field saved as a .mha (such as what would be output by DeeperHistoReg, for example). It introduces a new WSIReader subclass called TransformedWSIReader, which when initialized with a slide and transformation will apply the transformation on the fly when regions are requested. It also adds '.npy' and '.mha' to the overlay types the visualization tool picks up; upon loading one of these types, the current slide will be opened as a TransformedWSIReader with that transform, allowing it to be visualized on the fly as a registered slide. Tasks: - [x] Implement read_bounds (currently only done read_rect) - [x] Correctly handle coord_space argument - [x] Check affine capabilities of read_bounds - [x] Add unit tests --------- Co-authored-by: Adam Shephard <[email protected]> Co-authored-by: adamshephard <[email protected]> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Shan E Ahmed Raza <[email protected]>
1 parent 9593cfe commit 22dfa11

File tree

9 files changed

+1250
-33
lines changed

9 files changed

+1250
-33
lines changed

docs/images/dual_win_reg.png

3.23 MB
Loading

docs/visualization.rst

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,55 @@ A filter can be applied to annotations using the filter box. For example, enteri
119119

120120
The main slide view can be made fullscreen by clicking the fullscreen icon in the small toolbar to the immediate right of the main window. This toolbar also provides a button to save the current view as a .png file.
121121

122+
Visualising Image Registration/Transformation
123+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
124+
125+
.. image:: images/dual_win_reg.png
126+
:width: 100%
127+
:align: center
128+
:alt: dual window example
129+
130+
131+
TIAToolbox provides a powerful registration visualization feature that enables intuitive alignment and comparison of histopathology images—such as H&E and IHC-stained slides—without requiring full whole-slide registration. This is particularly useful for quickly inspecting the accuracy of precomputed registration results.
132+
133+
To use this feature, you must supply a precomputed registration matrix (e.g., .mha or .npy file) generated from an affine or deformable registration process. This matrix is used to align the images visually.
134+
135+
Dual Window Mode:
136+
"""""""""""""""""
137+
138+
This mode allows side-by-side comparison of registered images.
139+
140+
**Steps:**
141+
142+
* Open **Dual Window Mode** and load the images.
143+
144+
* In one window, open the H&E (source) image.
145+
146+
* In the other window, open the IHC (target) image.
147+
148+
* Load the registration file (e.g., an .mha or .npy file) as an overlay on the source image.
149+
150+
Overlay Mode:
151+
"""""""""""""
152+
153+
This mode overlays the registered image directly on top of the source image for visual inspection.
154+
155+
156+
**Steps:**
157+
158+
* Open the H&E (source) image.
159+
160+
* Overlay the IHC (target) image on the source image.
161+
162+
* Load the registration file (e.g., an .mha or .npy file) as an overlay on the source image.
163+
164+
.. note::
165+
Always load the **target image first** when using overlays. If not, the system may incorrectly assume both images are the same, leading to inaccurate transformations. Incorrect ordering may result in misaligned overlays or misleading visualizations.
166+
167+
168+
The **order** of source and target images must remain consistent with how the registration matrix was computed. This is as most registration algorithms require the dimensions of both the source and target images to perform the registration transformation. The above examples assume that the H&E image is registered to the IHC images, but if instead you have registered the IHC to the H&E image then please change the order of image loading accordingly.
169+
170+
122171
.. _data_format:
123172

124173
3. Data Format Conventions and File Structure

tests/test_app_bokeh.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,11 @@ def annotation_path(data_path: dict[str, Path]) -> dict[str, object]:
125125
"annotation_dat_svs_1",
126126
data_path["base_path"] / "overlays",
127127
)
128+
data_path["affine_trans"] = (
129+
data_path["base_path"] / "overlays" / (data_path["slide1"].stem + ".npy")
130+
)
131+
# save eye as test identity transform
132+
np.save(data_path["affine_trans"], np.eye(3))
128133
data_path["config"] = _fetch_remote_sample(
129134
"config_2",
130135
data_path["base_path"] / "overlays",
@@ -246,6 +251,31 @@ def test_remove_dual_window(doc: Document, data_path: pytest.TempPathFactory) ->
246251
assert main.UI["vstate"].slide_path == data_path["slide1"]
247252

248253

254+
def test_add_slide_layer(doc: Document, data_path: pytest.TempPathFactory) -> None:
255+
"""Test adding a non-annotation slide layer."""
256+
slide_select = doc.get_model_by_name("slide_select0")
257+
slide_select.value = [data_path["slide1"].name]
258+
259+
layer_drop = doc.get_model_by_name("layer_drop0")
260+
slide_layer_path = str(data_path["slide1"])
261+
262+
click = MenuItemClick(layer_drop, slide_layer_path)
263+
layer_drop._trigger_event(click)
264+
265+
assert len(layer_drop.menu) == 6
266+
267+
268+
def test_transform_overlay(doc: Document, data_path: pytest.TempPathFactory) -> None:
269+
"""Test adding a transform overlay."""
270+
layer_drop = doc.get_model_by_name("layer_drop0")
271+
affine_layer_path = str(data_path["affine_trans"]) # sample .npy file
272+
273+
click = MenuItemClick(layer_drop, affine_layer_path)
274+
layer_drop._trigger_event(click)
275+
276+
assert len(layer_drop.menu) == 6
277+
278+
249279
def test_add_annotation_layer(doc: Document, data_path: pytest.TempPathFactory) -> None:
250280
"""Test adding annotation layers."""
251281
# test loading a geojson file.
@@ -263,7 +293,7 @@ def test_add_annotation_layer(doc: Document, data_path: pytest.TempPathFactory)
263293
# test loading an annotation store
264294
slide_select.value = [data_path["slide1"].name]
265295
layer_drop = doc.get_model_by_name("layer_drop0")
266-
assert len(layer_drop.menu) == 5
296+
assert len(layer_drop.menu) == 6
267297
n_renderers = len(doc.get_model_by_name("slide_windows").children[0].renderers)
268298
# trigger an event to select the annotation .db file
269299
click = MenuItemClick(layer_drop, str(data_path["annotations"]))

tests/test_tileserver.py

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
import json
6+
import logging
67
import urllib
78
from pathlib import Path, PureWindowsPath
89
from typing import TYPE_CHECKING, Callable, NoReturn
@@ -736,6 +737,132 @@ def test_prop_range(app: TileServer) -> None:
736737
assert layer.renderer.score_fn(0.5) == 0.5
737738

738739

740+
def test_registration_dual_window(
741+
empty_app: TileServer, tmp_path: Path, remote_sample: Callable
742+
) -> None:
743+
"""Test registering slides."""
744+
data = make_simple_dat()
745+
joblib.dump(data, tmp_path / "test.dat")
746+
with empty_app.test_client() as client, empty_app.test_client() as client2:
747+
setup_app(client)
748+
response = client.put(
749+
"/tileserver/slide",
750+
data={"slide_path": safe_str(remote_sample("svs-1-small"))},
751+
)
752+
assert response.status_code == 200
753+
754+
# Open new window with other slide...
755+
setup_app(client2)
756+
response = client2.put(
757+
"/tileserver/slide",
758+
data={"slide_path": safe_str(remote_sample("svs-1-small"))},
759+
)
760+
assert response.status_code == 200
761+
762+
response = client2.put(
763+
"/tileserver/overlay",
764+
data={"overlay_path": safe_str(remote_sample("reg_disp_mha_example"))},
765+
)
766+
assert response.status_code == 200
767+
768+
769+
def test_registration_single_window_same_slide(
770+
empty_app: TileServer,
771+
remote_sample: Callable,
772+
caplog: pytest.LogCaptureFixture,
773+
) -> None:
774+
"""Test registering slides."""
775+
# First is testing with a single sldie and single registration
776+
with empty_app.test_client() as client:
777+
setup_app(client)
778+
response = client.put(
779+
"/tileserver/slide",
780+
data={"slide_path": safe_str(remote_sample("svs-1-small"))},
781+
)
782+
assert response.status_code == 200
783+
784+
# Capture logger output to check for warning
785+
with caplog.at_level(logging.WARNING):
786+
response = client.put(
787+
"/tileserver/overlay",
788+
data={"overlay_path": safe_str(remote_sample("reg_disp_mha_example"))},
789+
)
790+
assert (
791+
"No suitable overlay found. Using current slide as target. "
792+
"This may display incorrectly if dimensions differ." in caplog.text
793+
)
794+
assert response.status_code == 200
795+
796+
797+
def test_registration_single_window_nonslide_overlay(
798+
empty_app: TileServer,
799+
remote_sample: Callable,
800+
caplog: pytest.LogCaptureFixture,
801+
) -> None:
802+
"""Test registering slides with non-slide overlay."""
803+
with empty_app.test_client() as client:
804+
setup_app(client)
805+
response = client.put(
806+
"/tileserver/slide",
807+
data={"slide_path": safe_str(remote_sample("svs-1-small"))},
808+
)
809+
assert response.status_code == 200
810+
# check behaviour when an overlay is there, but it isn't suitabe (e.g jpg)
811+
response = client.put(
812+
"/tileserver/overlay",
813+
data={"overlay_path": safe_str(remote_sample("wsi2_4k_4k_jpg"))},
814+
)
815+
assert response.status_code == 200
816+
817+
with caplog.at_level(logging.WARNING):
818+
response = client.put(
819+
"/tileserver/overlay",
820+
data={"overlay_path": safe_str(remote_sample("reg_disp_mha_example"))},
821+
)
822+
assert (
823+
"No suitable overlay found. Using current slide as target. "
824+
"This may display incorrectly if dimensions differ." in caplog.text
825+
)
826+
assert response.status_code == 200
827+
828+
829+
def test_registration_single_window_different_slide(
830+
empty_app: TileServer,
831+
tmp_path: Path,
832+
remote_sample: Callable,
833+
caplog: pytest.LogCaptureFixture,
834+
) -> None:
835+
"""Test registering slides."""
836+
# Repeat but provide extra overlays
837+
data = make_simple_dat()
838+
joblib.dump(data, tmp_path / "test.dat")
839+
with empty_app.test_client() as client:
840+
setup_app(client)
841+
response = client.put(
842+
"/tileserver/slide",
843+
data={"slide_path": safe_str(remote_sample("svs-1-small"))},
844+
)
845+
assert response.status_code == 200
846+
847+
# Now add extra slide (i.e. IHC slide as target to register with)
848+
response = client.put(
849+
"/tileserver/overlay",
850+
data={"overlay_path": safe_str(remote_sample("svs-1-small"))},
851+
)
852+
853+
# Capture logger output to check for warning
854+
with caplog.at_level(logging.WARNING):
855+
response = client.put(
856+
"/tileserver/overlay",
857+
data={"overlay_path": safe_str(remote_sample("reg_disp_mha_example"))},
858+
)
859+
assert (
860+
"Using slide as source and last overlay as target for registration."
861+
in caplog.text
862+
)
863+
assert response.status_code == 200
864+
865+
739866
def test_healthcheck(empty_app: TileServer) -> None:
740867
"""Test the /tileserver/healthcheck endpoint."""
741868
with empty_app.test_client() as client:

0 commit comments

Comments
 (0)