diff --git a/MANIFEST.in b/MANIFEST.in index 93993c7a7..ce5320a51 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -7,6 +7,7 @@ include requirements/requirements.txt include make.bat include Makefile include conf.py +include tiatoolbox/visualization/bokeh_app/templates/index.html recursive-include tests * recursive-include docs diff --git a/docs/images/dual_win.png b/docs/images/dual_win.png new file mode 100644 index 000000000..d8acd6cbb Binary files /dev/null and b/docs/images/dual_win.png differ diff --git a/docs/images/properties_window.png b/docs/images/properties_window.png new file mode 100644 index 000000000..a1cb51ee7 Binary files /dev/null and b/docs/images/properties_window.png differ diff --git a/docs/images/type_select.png b/docs/images/type_select.png new file mode 100644 index 000000000..62598b5e0 Binary files /dev/null and b/docs/images/type_select.png differ diff --git a/docs/images/vis_gland_cmap.png b/docs/images/vis_gland_cmap.png new file mode 100644 index 000000000..d45ef7561 Binary files /dev/null and b/docs/images/vis_gland_cmap.png differ diff --git a/docs/images/vis_graph.png b/docs/images/vis_graph.png new file mode 100644 index 000000000..c47b9e86b Binary files /dev/null and b/docs/images/vis_graph.png differ diff --git a/docs/images/visualize_interface.png b/docs/images/visualize_interface.png new file mode 100644 index 000000000..a33329216 Binary files /dev/null and b/docs/images/visualize_interface.png differ diff --git a/docs/index.rst b/docs/index.rst index 29dc32ca8..0db59ede2 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -14,6 +14,7 @@ Welcome to TIA Toolbox's documentation! Pre-trained Models Jupyter Notebooks Algorithms + Visualization API Reference <_autosummary/tiatoolbox> Contributing Authors diff --git a/docs/installation.rst b/docs/installation.rst index 3f2024353..e8fe41478 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -1,5 +1,7 @@ .. highlight:: shell +.. _installation: + Installation ************ diff --git a/docs/visualization.rst b/docs/visualization.rst new file mode 100644 index 000000000..af237dfb6 --- /dev/null +++ b/docs/visualization.rst @@ -0,0 +1,448 @@ +.. _visualization: + +Visualization Interface Usage +============================= + +TIAToolbox provides a flexible visualization tool for viewing slides and overlaying associated model outputs or annotations. It is a browser-based UI built using TIAToolbox and `Bokeh `_. The following assumes TIAToolbox has been installed per the instructions here: :ref:`Installation `. + +1. Launching the interface +-------------------------- + +Start the interface using the command:: + + tiatoolbox visualize --slides path/to/slides --overlays path/to/overlays + +This should cause the interface to appear in a new browser tab. +Alternatively just one base path can be provided; in this case it is assumed that slides and overlays are in subdirectories of that provided directory called 'slides' and 'overlays' respectively:: + + tiatoolbox visualize --base-path path/to/parent_of_slides_and_overlays + +In the folder(s) that your command pointed to, should be the things that you want to visualize, following the conventions in :ref:`Data formats `. + +If you need to change the port on which the interface is launched from the default of 5006, you can do so using the --port flag:: + + tiatoolbox visualize --slides path/to/slides --overlays path/to/overlays --port 5001 + +Though in most cases this should not be necessary. + +Launching on a remote machine +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +As the UI is browser-based, you can launch the interface on a remote machine by logging in via ssh and forwarding the relevant ports so that the visualization of the remote slides and overlays can be viewed in the browser on your local machine. For example, connect via ssh to your remote machine:: + + ssh -L 5006:localhost:5006 -L 5000:localhost:5000 user@remote_machine + +This will start a ssh session where the two ports the interface uses by default (5006 and 5000) are forwarded. + +You can then launch the interface on the remote machine as above (TIAToolbox must be installed on the remote machine) and open the browser on your local machine. Navigate to ``localhost:5006`` to view the interface. + +.. _interface: + +2. General UI Controls and Options +---------------------------------- + +.. image:: images/visualize_interface.png + :width: 100% + :align: center + :alt: visualize interface + +The interface is split into two main sections. The left-hand side contains the main window, which displays the slide and overlays (or potentially a linked pair of slide views), and the right hand side contains a number of UI elements to control the display of the overlays. + +The main window can be zoomed in and out using the mouse wheel and panned by clicking and dragging. The slide can be changed using the slide dropdown menu. The overlay can be changed, or additional overlays added using the overlay dropdown menu. Note: overlays involving a large number of annotations may take a short while to load. The alpha of the slide and overlay can be controlled using the slide and overlay alpha sliders respectively. + +Information about the currently open slide can be found below the main window including slide name, dimensions, and level resolution information. + +Type and layer select +^^^^^^^^^^^^^^^^^^^^^ + +.. image:: images/type_select.png + :width: 30% + :align: right + :alt: type select example + +If annotations have a type property, this will be used to populate the type select boxes. This allows you to toggle on/off annotations of a specific type. You can also modify the default colors that each type is displayed in by using the color picker widgets next to each type name (note these will only have an effect if the property to color by is selected as 'type'). Individual image overlays or graph overlays will also get their own toggle, labelled for example 'layer_i' or 'nodes', that can be used to toggle the respective overlays on or off. + +Colormaps/coloring by property values +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Once you have selected a slide with the slide dropdown, you can add overlays by repeatedly choosing files containing overlays from the overlay drop menu. They will be overlaid on the slide as separate layers. In the case of segmentations, if your segmentations have the 'type' property as one of their properties, this can additionally be used to show/hide annotations of that specific type. colors can be individually selected for each type also if the randomly generated color scheme is not suitable. + +You can select the property that will be used to color annotations in the color_by box. The corresponding property should be either categorical (strings or ints), in which case a dict-based color mapping should be used, or a float between 0-1 in which case a matplotlib colormap should be applied. +There is also the option for the special case 'color' to be used. If your annotations have a property called color, this will be assumed to be an RGB value in the form of a tuple (R, G, B) of floats between 0-1 for each annotation which will be used directly without any mapping. + +The 'color type by property' box allows annotations of the specified type to be colored by a different property to the 'global' one. For example, this could be used to have all detections colored according to their type, but for Glands, color by some feature describing them instead (their area, for example) + +Running models +^^^^^^^^^^^^^^ + +Regions of the image can be selected, using either a box select or points, which can be sent to a model via selecting the model in the drop-down menu and then clicking go. Available so far is hovernet, and nuclick will likely be added in the future. + +To save the annotations resulting from a model, or loaded from a .geojson or .dat (will be saved as a SQLiteStore .db file which will be far quicker to load) use the save button (for the moment it is just saved in a file '{slide_name}\_saved_anns.db' in the overlays folder). + +Dual window mode +^^^^^^^^^^^^^^^^ + +.. image:: images/dual_win.png + :width: 100% + :align: center + :alt: dual window example + +A second window can be opened by selecting the 'window 2' tab in the top right. This will open the currently selected slide in a second window as illustrated above. The overlay shown in each window can be controlled independently to allow comparison of different overlays, or viewing of a model output side-by-side with the raw image (slide), or ground truth annotations. Slide navigation will be linked between both windows. +Two different slides can also be opened in the two windows, although this will only be useful in cases where the two slides are registered so that a shared coordinate space/slide navigation makes sense. + +Inspecting annotations +^^^^^^^^^^^^^^^^^^^^^^ + +.. image:: images/properties_window.png + :width: 40% + :align: right + :alt: properties window example + +Annotations can be inspected by double clicking on them. This will open a popup showing the annotation in more detail, and allowing the properties to be viewed in a sortable table. An example can be seen to the right for a patch prediction overlay where multiple targets have been predicted for each patch. + +Zoomed out plotting +^^^^^^^^^^^^^^^^^^^ + +By default, the interface is set up to show only larger annotations while zoomed out. Smaller annotations which would be too small to see clearly while zoomed out will not be displayed. The 'max-scale' value can be changed to control the zoom level at which this happens. A larger value will mean smaller annotations remain visible at more zoomed out scale. If you want all annotations to be displayed always regardless of zoom, just type in a large value (1000+) to set it to its max. In the case of very many annotations, this may result in some loading lag when zoomed out. + +Other options +^^^^^^^^^^^^^ + +There are a few options for how annotations are displayed. You can change the colormap used in the colormap field if you are coloring objects according to a continuous property (values should be between 0-1), by choosing one of the matplotlib cmaps. +The buttons 'filled', 'mpp', 'grid', respectively toggle between filled and outline only rendering of annotations, using mpp or baseline pixels as the scale for the plot, and showing a grid overlay. + +A filter can be applied to annotations using the filter box. For example, entering props\['score'\]>0.5 would show only annotations for which the 'score' property is greater than 0.5. +See the documentation in :obj:`AnnotationStore ` on valid 'where' statements for more details. + +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. + +.. _data_format: + +3. Data Format Conventions and File Structure +--------------------------------------------- + +In the slides folder should be all the slides you want to use, and the overlays folder should contain whatever graphs, segmentations, heatmaps etc you are interested in overlaying over the slides. + +When a slide is selected in the interface, any valid overlay file that can be found that *contains the same name* (not including extension) will be available to overlay upon it. + +Segmentation +^^^^^^^^^^^^ + +.. image:: images/vis_gland_cmap.png + :width: 45% + :align: right + :alt: segmentation example + +To visualize segmentation, please save your results in the AnnotationStore format (more information about the TIAToolbox annotation store can be found at :obj:`storage `). The other options are GeoJSON (.geojson), or a HoVerNet -style .dat (see :obj:`hovernet `). The GeoJSON and dat format can be loaded within the interface but will incur a delay as the data needs to be converted internally into an AnnotationStore for optimized visualization experience. + +If your annotations are in a geojson format following the sort of thing QuPath would output, that should be ok. Contours stored following hovernet-style output in a .dat file should also work. An overview of the data structure in these formats is below. + +HoVerNet style:: + + sample_dict = {nuc_id: { + box: List[], + centroid: List[], + contour: List[List[]], + prob: float, + type: int + ... #can add as many additional properties as we want... + } + ... # other instances + } + +Files in this format can be converted to an AnnotationStore using: :obj:`store_to_dat `. This utility function should also be able to handle .dats output from `Cerberus `_. + + +GeoJSON:: + + { + "type":"Feature", + "geometry":{ + "type":"Polygon", + "coordinates":[[[21741, 49174.09],[21737.84, 49175.12],[21734.76, 49175.93],[21729.85, 49179.85],[21726.12, 49184.84],[21725.69, 49187.95],[21725.08, 49191],[21725.7, 49194.04],[21726.15, 49197.15],[21727.65, 49199.92],[21729.47, 49202.53],[21731.82, 49204.74],[21747.53, 49175.23],[21741, 49174.09]]]}, + "properties":{"object_type":"detection","isLocked":false} + }} + +Files in this format can be converted to an AnnotationStore using the method: +:obj:`AnnotationStore.from_geojson() ` + +While data in these formats can be loaded directly into the interface, it is recommended to convert and save them as an annotation store outside the interface, as this will be much faster to load. + +TIAToolbox also provides a function to convert the output of PatchPredictor to an annotation store, which can be found at :obj:`dict_to_store `. + +If your data is not in one of these formats, it is usually fairly straightforward to build an annotation store out of your model outputs. A small script of 6-10 lines is usually all that is required. There are example code snippets illustrating how to create an annotation store in a variety of common scenarios in the examples section. +Most use-cases should be covered in there, or something close enough that a few tweaks to a snippet will do what is needed. + +Heatmaps +^^^^^^^^ + +These should be provided as a low-res heatmap in .jpg or .png format. It should be the same aspect ratio as the WSI it will be overlaid on. When creating the image, keep in mind that black pixels (0,0,0) will be made transparent. + +Single channel images can also be used but are not recommended; they should take values between 0 and 255 and will simply be put through a viridis colormap. 0 values will become white background. + +Whole Slide Overlays +^^^^^^^^^^^^^^^^^^^^ + +It is possible to overlay multiple WSI's on top of each other as separate layers simply by selecting them in the overlays dropdown, though if the visualization task can be achieved using another form of overlay, that would be recommended as it will usually be more flexible and faster to load. + +Graphs +^^^^^^ + +.. image:: images/vis_graph.png + :width: 45% + :align: right + :alt: graph example + +Graphs can also be overlaid. The display of nodes and edges can be toggled on/off independently in the right hand panel of the interface (note, edges will be turned off by default; they can be made visible by toggling the 'edges' toggle in the UI). An example of a graph overlay is shown to the right. Graph overlays should be provided in a dictionary format with keys as described below, saved as a .json file. + + +E.g.:: + + graph_dict = { + 'edge_index': 2 x n_edges array of indices of pairs of connected nodes + 'coordinates': n x 2 array of x, y coordinates for each graph node (at baseline resolution) + } + + +Additional features can be added to nodes by adding extra keys to the dictionary, eg: + +:: + + graph_dict = { + 'edge_index': 2 x n_edges array of indices of pairs of connected nodes + 'coordinates': n x 2 array of x, y coordinates for each graph node + 'feats': n x n_features array of features for each node + 'feat_names': list n_features names for each feature + } + + +It will be possible to color the nodes by these features in the interface, and the top 10 will appear in a tooltip when hovering over a node (you will have to turn on the hovertool in the small toolbar to the right of the main window to enable this, it is disabled by default.) + + +.. _examples: + +4. Annotation Store examples +---------------------------- + +Patch Predictions +^^^^^^^^^^^^^^^^^ + +Let's say you have patch level predictions for a model. The top left corner +of each patch, and two predicted scores are in a .csv file. Patch size is 512. + +:: + + results_path = Path("path/to/results.csv") + db = SQLiteStore() + patch_df = pd.read_csv(results_path) + annotations = [] + for i, row in patch_df.iterrows(): + x = row["x"] + y = row["y"] + properties = {"score1": row["score1"], "score2": row["score2"]} + annotations.append( + Annotation(Polygon.from_bounds(x, y, x + 512, y + 512), properties=properties) + ) + db.append_many(annotations) + db.dump("path/to/filename.db") # filename should contain its associated slides name + +When loading the above in the interface, you will be able to select any of the properties to color the overlay by. + +GeoJSON outputs +^^^^^^^^^^^^^^^ + +While .geojson files can be loaded in the interface directly, it is often more convenient to convert them to a .db file first, as this will avoid the delay while the geojson is converted to an annotation store. +The TIAToolbox AnnotationStore class provides a method to do this. + +:: + + geojson_path = Path("path/to/annotations.geojson") + db1 = SQLiteStore.from_geojson(geojson_path) + db1.dump("path/to/annotations.db") + +Raw contours and properties +^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +If you have a collection of raw centroids or detection contours with corresponding properties/scores, you can easily convert these to an annotation store. + +:: + + centroid_list = [[1, 4], [3, 2]] # etc... + # if its contours each element is a list of points instead + properties_list = [ + {"score": "some_score", "class": "some_class"}, + {"score": "other _score", "class": "other_class"}, + # etc... + ] + + annotations = [] + + for annotation, properties in zip(centroid_list, properties_list): + props = {"score": properties["score"], "type": properties["class"]} + annotations.append( + Annotation(Point(annotation), props) + ) # use Polygon() instead if it's a contour + db.append_many(annotations) + db.create_index("area", '"area"') # create index on area for faster querying + db.dump("path/to/annotations.db") + +Note that in the above we saved the 'class' property as 'type' - this is because the UI treats the 'type' property as a special property, and will allow you to toggle annotations of a specific type on/off, in addition to other functionality. + +Graphs example +^^^^^^^^^^^^^^ + +Let's say you have a graph defined by nodes and edges, +and associated node properties. The following example demonstrates how to package this into a .json file + +:: + + graph_dict = {'edge_index': 2 x n_edges array of indices of pairs of connected nodes + 'coordinates': n x 2 array of x, y coordinates for each graph node + 'feats': n x n_features array of features for each node + 'feat_names': list n_features names for each feature + } + + with open("path/to/graph.json", "w") as f: + json.dump(graph_dict, f) + +Modifying an existing annotation store +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +If you have an existing annotation store and want to add/change +properties of annotations (or can also do similarly for geometry) + +:: + + # let's assume you have calculated a score in some way, that you want to add to + # the annotations in a store + scores = [0.9, 0.5] + + db = SQLiteStore("path/to/annotations.db") + # use the SQLiteStore.patch_many method to replace the properties dict + # for each annotation. + new_props = {} + for i, (key, annotation) in enumerate(db.items()): + new_props[key] = annotation.properties # get existing props + new_props[key]["score"] = scores[i] # add the new score + + db.patch_many( + db.keys(), properties_iter=new_props + ) # replace the properties dict for each annotation + +Merging two annotation stores +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +The interface will only open one annotation store at a time. If you have annotations +belonging to the same slide in different stores that you want to display +at the same time, just put them all in the same store as follows + +:: + + db1 = SQLiteStore("path/to/annotations1.db") + db2 = SQLiteStore("path/to/annotations2.db") + anns = list(db1.items()) + db2.append_many(anns) # db2 .db file now contains all annotations from db1 too + +Shifting coordinates +^^^^^^^^^^^^^^^^^^^^ + +Let's say you have some annotations that were created on a slide, and you want to grab the annotations in a particular region and display them on a tile from that slide. You will need their coordinates to be relative to the tile. You can do this as follows + +:: + + top_left = [2048, 1024] # top left of tile + tile_size = 1024 # tile size + db1 = SQLiteStore("path/to/annotations.db") + query_geom = Polygon.from_bounds( + top_left[0], top_left[1], top_left[0] + tile_size, top_left[1] + tile_size + ) + db2 = SQLiteStore() + tile_anns = db1.query(query_geom) # get all annotations in the tile + db2.append_many(tile_anns.values(), tile_anns.keys()) # add them to a new store + + + def translate_geom(geom): + return geom.translate(-top_left[0], -top_left[1]) + + + db2.transform(translate_geom) # translate so coordinates relative to top left of tile + db2.dump("path/to/tile_annotations.db") + +.. _config: + +5. Config files +--------------- + +A JSON config file can be placed in the overlays folder, to customize various aspects of the UI and annotation display when visualizing overlays in that location. This is especially useful for customising online demos. An example .json explaining all the fields is shown below. + +There are settings to control how slides are loaded: + +:: + + { + "initial_views": { + "slideA": [0,19000,35000,44000], # if a slide with specified name is opened, initial view window will be set to this + "slideB": [44200,59100,69700,76600] + }, + "auto_load": 1, # if 1, upon opening a slide will also load all annotations associated with it + "first_slide": "slideA.svs", # initial slide to open upon launching viewer + +Settings to control how annotations are displayed, including default colors for specific types, and default properties to color by: + +:: + + "color_dict": { + "typeA": [252, 161, 3, 255], # annotations whose 'type' property matches these, will display in the specified color + "typeB": [3, 252, 40, 255] + }, + "default_cprop": "some_property", # default property to color annotations by + "default_type_cprop": { # a property to color a specific type by + "type": "Gland", + "cprop": "Explanation" + }, + +There are settings to control the initial values of some UI settings: + +:: + + "UI_settings": { + "blur_radius": 0, # applies a blur to rendered annotations + "edge_thickness": 0, # thickness of boundaries drawn around annotation geometries (0=off) + "mapper": "jet", # default color mapper to use when coloring by a continuous property + "max_scale": 32 # controls zoom level at which small annotations are no longer rendered (larger val->smaller + }, # annotations visible when zoomed out) + "opts": { + "edges_on": 0, # graph edges are shown or hidden by default + "nodes_on": 1, # graph nodes are shown or hidden by default + "colorbar_on": 1, # whether color bar is shown below main window + "hover_on": 1 + }, + +and the ability to toggle on or off specific UI elements: + +:: + + "UI_elements_1": { # controls which UI elements are visible + "slide_select": 1, # slide select box + "layer_drop": 1, # overlay select drop down + "slide_row": 1, # slide alpha toggle and slider + "overlay_row": 1, # overlay alpha toggle and slider + "filter_input": 1, # filter text input box + "cprop_input": 1, # box to select which property to color annotations by ('color by' box) + "cmap_row": 1, # row of UI elements with colormap select, blur, max_scale + "type_cmap_select": 1, # UI element to select a secondary colormap for a specific type (i.e 'color type by' box) + "model_row": 0, # UI elements to chose and run a model + "type_select_row": 1 # button group for toggling specific types of annotations on/off + }, + +:: + + "UI_elements_2": { # controls visible UI elements on second tab in UI + "opt_buttons": 1, # UI elements providing a few options including if annotations should be filled/outline only + "pt_size_spinner": 1, # control for point size and graph node size + "edge_size_spinner": 1, # control for edge thickness + "res_switch": 1, # allows to switch to lower res tiles for faster loading + } + } + +This .json filename should end in 'config.json' to be picked up by the interface. diff --git a/requirements/requirements.txt b/requirements/requirements.txt index c5bfc37fa..6e8f3d53c 100644 --- a/requirements/requirements.txt +++ b/requirements/requirements.txt @@ -1,10 +1,12 @@ # torch installation --extra-index-url https://download.pytorch.org/whl/cu118; sys_platform != "darwin" albumentations>=1.3.0 +bokeh>=3.1.1 Click>=8.1.3 defusedxml>=0.7.1 filelock>=3.9.0 flask>=2.2.2 +flask-cors>=4.0.0 glymur>=0.12.1, ~=0.12.6 # 0.12.6 is incompatible due to a private attribute imagecodecs>=2022.9.26 joblib>=1.1.1 diff --git a/tests/conftest.py b/tests/conftest.py index aebcb4772..98301eb17 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -568,3 +568,12 @@ def __exit__(self: chdir, *excinfo: object) -> None: os.chdir(self._old_cwd.pop()) return chdir + + +@pytest.fixture(scope="module") +def data_path(tmp_path_factory: pytest.TempPathFactory) -> dict[str, object]: + """Set up a temporary data directory for testing visualization UI.""" + tmp_path = tmp_path_factory.mktemp("data") + (tmp_path / "slides").mkdir() + (tmp_path / "overlays").mkdir() + return {"base_path": tmp_path} diff --git a/tests/test_annotation_tilerendering.py b/tests/test_annotation_tilerendering.py index 4d4651f06..6e5de833e 100644 --- a/tests/test_annotation_tilerendering.py +++ b/tests/test_annotation_tilerendering.py @@ -130,12 +130,12 @@ def test_correct_number_rendered(fill_store: Callable, tmp_path: Path) -> None: tg = AnnotationTileGenerator(wsi.info, store, renderer) thumb = tg.get_thumb_tile() - _, num = label(np.array(thumb)[:, :, 1]) # default colour is green + _, num = label(np.array(thumb)[:, :, 1]) # default color is green assert num == 75 # expect 75 rendered objects -def test_correct_colour_rendered(fill_store: Callable, tmp_path: Path) -> None: - """Test colour mapping.""" +def test_correct_color_rendered(fill_store: Callable, tmp_path: Path) -> None: + """Test color mapping.""" array = np.ones((1024, 1024)) wsi = wsireader.VirtualWSIReader(array, mpp=(1, 1)) _, store = fill_store(SQLiteStore, tmp_path / "test.db") @@ -188,7 +188,7 @@ def test_zoomed_out_rendering(fill_store: Callable, tmp_path: Path) -> None: tg = AnnotationTileGenerator(wsi.info, store, renderer, tile_size=256) thumb = tg.get_tile(1, 0, 0) - _, num = label(np.array(thumb)[:, :, 1]) # default colour is green + _, num = label(np.array(thumb)[:, :, 1]) # default color is green assert num == 25 # expect 25 cells in top left quadrant @@ -203,7 +203,7 @@ def test_decimation(fill_store: Callable, tmp_path: Path) -> None: thumb = tg.get_tile(1, 1, 1) plt.imshow(thumb) plt.show(block=False) - _, num = label(np.array(thumb)[:, :, 1]) # default colour is green + _, num = label(np.array(thumb)[:, :, 1]) # default color is green assert num == 17 # expect 17 pts in bottom right quadrant @@ -319,10 +319,10 @@ def test_user_provided_cm(fill_store: Callable, tmp_path: Path) -> None: def test_random_mapper() -> None: - """Test random colour map dict for list.""" + """Test random color map dict for list.""" test_list = ["line", "pt", "cell"] renderer = AnnotationRenderer(mapper=test_list) - # check all the colours are valid rgba values + # check all the colors are valid rgba values for ann_type in test_list: rgba = renderer.mapper(ann_type) assert isinstance(rgba, tuple) @@ -338,7 +338,7 @@ def test_categorical_mapper(fill_store: Callable, tmp_path: Path) -> None: _, store = fill_store(SQLiteStore, tmp_path / "test.db") renderer = AnnotationRenderer(score_prop="type", mapper="categorical") AnnotationTileGenerator(wsi.info, store, renderer, tile_size=256) - # check correct keys exist and all colours are valid rgba values + # check correct keys exist and all colors are valid rgba values for ann_type in ["line", "pt", "cell"]: rgba = renderer.mapper(ann_type) assert isinstance(rgba, tuple) @@ -347,7 +347,7 @@ def test_categorical_mapper(fill_store: Callable, tmp_path: Path) -> None: assert 0 <= val <= 1 -def test_colour_prop_warnings( +def test_color_prop_warnings( fill_store: Callable, tmp_path: Path, caplog: pytest.LogCaptureFixture, diff --git a/tests/test_app_bokeh.py b/tests/test_app_bokeh.py new file mode 100644 index 000000000..c1de6ef8b --- /dev/null +++ b/tests/test_app_bokeh.py @@ -0,0 +1,791 @@ +"""Test the tiatoolbox visualization tool.""" +from __future__ import annotations + +import io +import json +import multiprocessing +import re +import sys +import time +from pathlib import Path +from typing import TYPE_CHECKING, Generator + +import bokeh.models as bkmodels +import matplotlib.pyplot as plt +import numpy as np + +if sys.version_info >= (3, 9): # pragma: no cover + import importlib.resources as importlib_resources +else: # pragma: no cover + # To support Python 3.8 + import importlib_resources # type: ignore[import-not-found] +import pytest +import requests +from bokeh.application import Application +from bokeh.application.handlers import FunctionHandler +from bokeh.events import ButtonClick, DoubleTap, MenuItemClick +from flask_cors import CORS +from matplotlib import colormaps +from PIL import Image +from scipy.ndimage import label + +from tiatoolbox.data import _fetch_remote_sample +from tiatoolbox.visualization.bokeh_app import main +from tiatoolbox.visualization.tileserver import TileServer +from tiatoolbox.visualization.ui_utils import get_level_by_extent + +if TYPE_CHECKING: + from bokeh.document import Document + +# constants +BOKEH_PATH = importlib_resources.files("tiatoolbox.visualization.bokeh_app") +FILLED = 0 +MICRON_FORMATTER = 1 +GRIDLINES = 2 + + +# helper functions and fixtures +def get_tile(layer: str, x: float, y: float, z: float, *, show: bool) -> np.ndarray: + """Get a tile from the server. + + Args: + layer (str): + The layer to get the tile from. + x (float): + The x coordinate of the tile. + y (float): + The y coordinate of the tile. + z (float): + The zoom level of the tile. + show (bool): + Whether to show the tile. + + """ + source = main.UI["p"].renderers[main.UI["vstate"].layer_dict[layer]].tile_source + url = source.url + # replace {x}, {y}, {z} with tile coordinates + url = url.replace("{x}", str(x)).replace("{y}", str(y)).replace("{z}", str(z)) + im = io.BytesIO(requests.get(url, timeout=100).content) + if show: + plt.imshow(np.array(Image.open(im))) + plt.show() + return np.array(Image.open(im)) + + +def get_renderer_prop(prop: str) -> json: + """Get a renderer property from the server. + + Args: + prop (str): + The property to get. + + """ + resp = main.UI["s"].get(f"http://{main.host2}:5000/tileserver/renderer/{prop}") + return resp.json() + + +@pytest.fixture(scope="module", autouse=True) +def annotation_path(data_path: dict[str, Path]) -> dict[str, object]: + """Download some testing slides and overlays. + + Set up a dictionary defining the paths to the files + that can be grabbed as a fixture to refer to during tests. + + """ + data_path["slide1"] = _fetch_remote_sample( + "svs-1-small", + data_path["base_path"] / "slides", + ) + data_path["slide2"] = _fetch_remote_sample( + "ndpi-1", + data_path["base_path"] / "slides", + ) + data_path["slide3"] = _fetch_remote_sample( + "patch-extraction-vf", + data_path["base_path"] / "slides", + ) + data_path["annotations"] = _fetch_remote_sample( + "annotation_store_svs_1", + data_path["base_path"] / "overlays", + ) + data_path["graph"] = _fetch_remote_sample( + "graph_svs_1", + data_path["base_path"] / "overlays", + ) + data_path["graph_feats"] = _fetch_remote_sample( + "graph_svs_1_feats", + data_path["base_path"] / "overlays", + ) + data_path["img_overlay"] = _fetch_remote_sample( + "svs_1_rendered_annotations_jpg", + data_path["base_path"] / "overlays", + ) + data_path["geojson_anns"] = _fetch_remote_sample( + "geojson_cmu_1", + data_path["base_path"] / "overlays", + ) + data_path["dat_anns"] = _fetch_remote_sample( + "annotation_dat_svs_1", + data_path["base_path"] / "overlays", + ) + data_path["config"] = _fetch_remote_sample( + "config_2", + data_path["base_path"] / "overlays", + ) + return data_path + + +def run_app() -> None: + """Helper function to launch a tileserver.""" + app = TileServer( + title="Tiatoolbox TileServer", + layers={}, + ) + CORS(app, send_wildcard=True) + app.run(host="127.0.0.1", threaded=True) + + +@pytest.fixture(scope="module") +def doc(data_path: dict[str, object]) -> Generator[Document, object, None]: + """Create a test document for the visualization tool.""" + # start tile server + p = multiprocessing.Process(target=run_app, daemon=True) + p.start() + time.sleep(2) # allow time for server to start + + main.doc_config.set_sys_args(argv=["dummy_str", str(data_path["base_path"])]) + handler = FunctionHandler(main.doc_config.setup_doc) + app = Application(handler) + yield app.create_document() + p.terminate() + + +# test some utility functions + + +def test_to_num() -> None: + """Test the to_num function.""" + assert main.to_num("1") == 1 + assert main.to_num("1.0") == 1.0 + assert main.to_num("1.0e-3") == 1.0e-3 + assert main.to_num(2) == 2 + assert main.to_num("None") is None + + +def test_get_level_by_extent() -> None: + """Test the get_level_by_extent function.""" + max_lev = 10 + assert get_level_by_extent((1000, 1000, 1100, 1100)) == max_lev + assert get_level_by_extent((1000, 1000, 1000000, 1000000)) == 0 + + +# test the bokeh app + + +def test_roots(doc: Document) -> None: + """Test that the document has the correct number of roots.""" + # should be 4 roots: main window, controls, slide_info, popup table + assert len(doc.roots) == 4 + + +def test_config_loaded(data_path: pytest.TempPathFactory) -> None: + """Test that the config is loaded correctly.""" + # config should be loaded + loaded_config = main.doc_config.config + with Path(data_path["config"]).open() as f: + file_config = json.load(f) + + # check that all keys in file_config are in doc_config + # and that the values are the same + for key in file_config: + assert key in loaded_config + assert loaded_config[key] == file_config[key] + + +def test_slide_select(doc: Document, data_path: pytest.TempPathFactory) -> None: + """Test slide selection.""" + slide_select = doc.get_model_by_name("slide_select0") + # check there are three available slides + assert len(slide_select.options) == 3 + assert slide_select.options[0][0] == data_path["slide1"].name + + # select a slide and check it is loaded + slide_select.value = ["CMU-1.ndpi"] + assert main.UI["vstate"].slide_path == data_path["slide2"] + + # check selecting nothing has no effect + slide_select.value = [] + assert main.UI["vstate"].slide_path == data_path["slide2"] + + # select a slide and check it is loaded + slide_select.value = ["TCGA-HE-7130-01Z-00-DX1.png"] + assert main.UI["vstate"].slide_path == data_path["slide3"] + + +def test_dual_window(doc: Document, data_path: pytest.TempPathFactory) -> None: + """Test adding a second window.""" + control_tabs = doc.get_model_by_name("ui_layout") + doc.get_model_by_name("slide_windows") + control_tabs.active = 1 + slide_select = doc.get_model_by_name("slide_select1") + assert len(slide_select.options) == 3 + assert slide_select.options[0][0] == data_path["slide1"].name + + control_tabs.active = 0 + assert main.UI.active == 0 + + +def test_remove_dual_window(doc: Document, data_path: pytest.TempPathFactory) -> None: + """Test removing a second window.""" + control_tabs = doc.get_model_by_name("ui_layout") + slide_wins = doc.get_model_by_name("slide_windows") + assert len(slide_wins.children) == 2 + # remove the second window + control_tabs.tabs.pop() + assert len(slide_wins.children) == 1 + + slide_select = doc.get_model_by_name("slide_select0") + slide_select.value = [data_path["slide1"].name] + assert main.UI["vstate"].slide_path == data_path["slide1"] + + +def test_add_annotation_layer(doc: Document, data_path: pytest.TempPathFactory) -> None: + """Test adding annotation layers.""" + # test loading a geojson file. + slide_select = doc.get_model_by_name("slide_select0") + slide_select.value = [data_path["slide2"].name] + layer_drop = doc.get_model_by_name("layer_drop0") + # 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"] + + # test the name2type function. + assert main.name2type("annotation") == '"annotation"' + + # test loading an annotation store + slide_select.value = [data_path["slide1"].name] + layer_drop = doc.get_model_by_name("layer_drop0") + assert len(layer_drop.menu) == 5 + n_renderers = len(doc.get_model_by_name("slide_windows").children[0].renderers) + # trigger an event to select the annotation .db file + click = MenuItemClick(layer_drop, str(data_path["annotations"])) + layer_drop._trigger_event(click) + # should be one more renderer now + assert len(doc.get_model_by_name("slide_windows").children[0].renderers) == ( + n_renderers + 1 + ) + # we should have got the types of annotations back from the server too + assert main.UI["vstate"].types == ["0", "1", "2", "3", "4"] + + # test get_mapper function + cmap_dict = main.get_mapper_for_prop("type") + assert set(cmap_dict.keys()) == {0, 1, 2, 3, 4} + + +def test_tap_query() -> None: + """Test the double tap query functionality.""" + # trigger a tap event + assert len(main.popup_table.source.data["property"]) == 0 + main.UI["p"]._trigger_event( + DoubleTap( + main.UI["p"], + x=1138.52, + y=-1881.5, + ), + ) + # the tapped annotation has 2 properties + assert len(main.popup_table.source.data["property"]) == 2 + assert len(main.popup_table.source.data["value"]) == 2 + + +def test_cprop_input(doc: Document) -> None: + """Test changing the color property.""" + cprop_input = doc.get_model_by_name("cprop0") + cmap_select = doc.get_model_by_name("cmap0") + cprop_input.value = ["prob"] + # as prob is continuous, cmap should be set to whatever cmap is selected + assert main.UI["vstate"].cprop == "prob" + assert main.UI["color_bar"].color_mapper.palette[0] == main.rgb2hex( + colormaps[cmap_select.value](0), + ) + + # check deselecting has no effect + cprop_input.value = [] + assert main.UI["vstate"].cprop == "prob" + + cprop_input.value = ["type"] + # as type is discrete, cmap should be a dict mapping types to colors + assert isinstance(main.UI["vstate"].mapper, dict) + assert list(main.UI["vstate"].mapper.keys()) == list( + main.UI["vstate"].orig_types.values(), + ) + + main.UI["vstate"].to_update.add("layer_1") + # check update state + assert main.UI["vstate"].update_state == 1 + # simulate server ticks + main.update() + # pending change so update state should be promoted to 2 + assert main.UI["vstate"].update_state == 2 + main.update() + # no more changes added so tile update has been triggered and update state reset + assert main.UI["vstate"].update_state == 0 + + +def test_type_cmap_select(doc: Document) -> None: + """Test changing the type cmap.""" + cmap_select = doc.get_model_by_name("type_cmap0") + cmap_select.value = ["prob"] + # select a type to assign the cmap to + cmap_select.value = ["prob", "0"] + # set edge thickness to 0 so the edges don't add an extra colour + spinner = doc.get_model_by_name("edge_size0") + spinner.value = 0 + im = get_tile("overlay", 1, 2, 2, show=False) + # check there are more than just num_types unique colors in the image, + # as we have mapped type 0 to a continuous cmap on prob + assert len(np.unique(im.sum(axis=2))) > 10 + + # remove the type cmap + cmap_select.value = [] + resp = main.UI["s"].get(f"http://{main.host2}:5000/tileserver/secondary_cmap") + assert resp.json()["score_prop"] == "None" + + # check callback works regardless of order + cmap_select.value = ["0"] + cmap_select.value = ["0", "prob"] + resp = main.UI["s"].get(f"http://{main.host2}:5000/tileserver/secondary_cmap") + assert resp.json()["score_prop"] == "prob" + + +def test_load_graph(doc: Document, data_path: pytest.TempPathFactory) -> None: + """Test loading a graph.""" + layer_drop = doc.get_model_by_name("layer_drop0") + # trigger an event to select the graph file + click = MenuItemClick(layer_drop, str(data_path["graph"])) + layer_drop._trigger_event(click) + # we should have 2144 nodes in the node_source now + assert len(main.UI["node_source"].data["x_"]) == 2144 + + +def test_graph_with_feats(doc: Document, data_path: pytest.TempPathFactory) -> None: + """Test loading a graph with features.""" + layer_drop = doc.get_model_by_name("layer_drop0") + # trigger an event to select the graph .json file + click = MenuItemClick(layer_drop, str(data_path["graph_feats"])) + layer_drop._trigger_event(click) + # we should have keys for the features in node data source now + for i in range(10): + assert f"feat_{i}" in main.UI["node_source"].data + + # test setting a node feat to color by + cmap_select = doc.get_model_by_name("type_cmap0") + cmap_select.value = ["graph_overlay"] + cmap_select.value = ["graph_overlay", "feat_0"] + + node_cm = colormaps["viridis"] + assert main.UI["node_source"].data["node_color_"][0] == main.rgb2hex( + node_cm(main.UI["node_source"].data["feat_0"][0]), + ) + + # test prop that doesnt exist in graph has no effect + cmap_select.value = ["graph_overlay", "prob"] + assert main.UI["node_source"].data["node_color_"][0] == main.rgb2hex( + node_cm(main.UI["node_source"].data["feat_0"][0]), + ) + + # test graph overlay option remains on loading new overlay + click = MenuItemClick(layer_drop, str(data_path["annotations"])) + layer_drop._trigger_event(click) + assert "graph_overlay" in cmap_select.options + + +def test_load_img_overlay(doc: Document, data_path: pytest.TempPathFactory) -> None: + """Test loading an image overlay.""" + layer_drop = doc.get_model_by_name("layer_drop0") + # 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") + assert layer_slider is not None + + # check alpha controls + type_column_list = doc.get_model_by_name("type_column0").children + # last one will be image overlay controls + 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 + # 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 + # 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 + + +def test_hovernet_on_box(doc: Document, data_path: pytest.TempPathFactory) -> None: + """Test running hovernet on a box.""" + slide_select = doc.get_model_by_name("slide_select0") + slide_select.value = [data_path["slide2"].name] + run_button = doc.get_model_by_name("to_model0") + assert len(main.UI["color_column"].children) == 0 + slide_select.value = [data_path["slide1"].name] + # set up a box selection + main.UI["box_source"].data = { + "x": [1200], + "y": [-2000], + "width": [400], + "height": [400], + } + + # select hovernet model and run it on box + model_select = doc.get_model_by_name("model_drop0") + model_select.value = "hovernet" + + click = ButtonClick(run_button) + run_button._trigger_event(click) + im = get_tile("overlay", 4, 8, 4, show=False) + _, num = label(np.any(im[:, :, :3], axis=2)) + # check there are multiple cells being detected + assert len(main.UI["color_column"].children) > 3 + assert num > 10 + + # test save functionality + save_button = doc.get_model_by_name("save_button0") + click = ButtonClick(save_button) + save_button._trigger_event(click) + saved_path = ( + data_path["base_path"] + / "overlays" + / (data_path["slide1"].stem + "_saved_anns.db") + ) + assert saved_path.exists() + + # load an overlay with different types + cprop_select = doc.get_model_by_name("cprop0") + cprop_select.value = ["prob"] + layer_drop = doc.get_model_by_name("layer_drop0") + click = MenuItemClick(layer_drop, str(data_path["dat_anns"])) + layer_drop._trigger_event(click) + assert main.UI["vstate"].types == ["annotation"] + # check the per-type ui controls have been updated + assert len(main.UI["color_column"].children) == 1 + assert len(main.UI["type_column"].children) == 1 + + +def test_alpha_sliders(doc: Document) -> None: + """Test sliders for adjusting slide and overlay alpha.""" + slide_alpha = doc.get_model_by_name("slide_alpha0") + overlay_alpha = doc.get_model_by_name("overlay_alpha0") + + # set alpha to 0.5 + slide_alpha.value = 0.5 + overlay_alpha.value = 0.5 + # check that the alpha values have been set correctly + assert main.UI["p"].renderers[0].alpha == 0.5 + assert main.UI["p"].renderers[main.UI["vstate"].layer_dict["overlay"]].alpha == 0.5 + + +def test_alpha_buttons(doc: Document) -> None: + """Test buttons for toggling slide and overlay alpha.""" + slide_toggle = doc.get_model_by_name("slide_toggle0") + overlay_toggle = doc.get_model_by_name("overlay_toggle0") + # clicking the button should set alpha to 0 + slide_toggle.active = False + assert main.UI["p"].renderers[0].alpha == 0 + overlay_toggle.active = False + assert main.UI["p"].renderers[main.UI["vstate"].layer_dict["overlay"]].alpha == 0 + + # clicking again should set alpha back to previous value + slide_toggle.active = True + assert main.UI["p"].renderers[0].alpha == 0.5 + overlay_toggle.active = True + assert main.UI["p"].renderers[main.UI["vstate"].layer_dict["overlay"]].alpha == 0.5 + + +def test_type_select(doc: Document, data_path: pytest.TempPathFactory) -> None: + """Test selecting/deselecting specific types.""" + # load annotation layer + layer_drop = doc.get_model_by_name("layer_drop0") + click = MenuItemClick(layer_drop, str(data_path["annotations"])) + layer_drop._trigger_event(click) + time.sleep(1) + im = get_tile("overlay", 4, 8, 4, show=False) + _, num_before = label(np.any(im[:, :, :3], axis=2)) + type_column_list = doc.get_model_by_name("type_column0").children + # click on the first and last to deselect them + type_column_list[0].active = False + type_column_list[-1].active = False + # check that the number of cells has decreased + im = get_tile("overlay", 4, 8, 4, show=False) + _, num_after = label(np.any(im[:, :, :3], axis=2)) + assert num_after < num_before + + # turn off all the types + for type_toggle in type_column_list: + type_toggle.active = False + # check that there are no cells + im = get_tile("overlay", 4, 8, 4, show=False) + _, num_after = label(np.any(im[:, :, :3], axis=2)) + assert num_after == 0 + + # reselect them + for type_toggle in type_column_list: + type_toggle.active = True + # check we are back to original number of cells + im = get_tile("overlay", 4, 8, 4, show=False) + _, num_after = label(np.any(im[:, :, :3], axis=2)) + assert num_after == num_before + + +def test_color_boxes(doc: Document) -> None: + """Test color boxes for setting type colors.""" + color_column_list = doc.get_model_by_name("color_column0").children + # set type 0 to red + color_column_list[0].color = "#ff0000" + # set type 1 to blue + color_column_list[1].color = "#0000ff" + # check the mapper matches the new colors + assert main.UI["vstate"].mapper[0] == (1, 0, 0, 1) + assert main.UI["vstate"].mapper[1] == (0, 0, 1, 1) + + cprop_select = doc.get_model_by_name("cprop0") + cprop_select.value = ["prob"] + # set type 1 to green + color_column_list[1].color = "#00ff00" + assert main.UI["vstate"].mapper[1] == (0, 1, 0, 1) + + +def test_node_and_edge_alpha(doc: Document, data_path: pytest.TempPathFactory) -> None: + """Test sliders for adjusting graph node and edge alpha.""" + layer_drop = doc.get_model_by_name("layer_drop0") + # trigger an event to select the graph .db file + click = MenuItemClick(layer_drop, str(data_path["graph"])) + layer_drop._trigger_event(click) + + type_column_list = doc.get_model_by_name("type_column0").children + color_column_list = doc.get_model_by_name("color_column0").children + # the last 2 will be edge and node controls + # by default nodes are visible, edges are not + assert not type_column_list[-2].active + assert type_column_list[-1].active + type_column_list[-1].active = False + type_column_list[-2].active = True + # check that the alpha values have been set correctly + assert ( + main.UI["p"].renderers[main.UI["vstate"].layer_dict["nodes"]].glyph.fill_alpha + == 0 + ) + assert main.UI["p"].renderers[main.UI["vstate"].layer_dict["edges"]].visible is True + type_column_list[-1].active = True + color_column_list[-2].value = 0.3 + color_column_list[-1].value = 0.4 + # check that the alpha values have been set correctly + assert ( + main.UI["p"].renderers[main.UI["vstate"].layer_dict["nodes"]].glyph.fill_alpha + == 0.4 + ) + assert main.UI["p"].renderers[main.UI["vstate"].layer_dict["edges"]].visible is True + assert ( + main.UI["p"].renderers[main.UI["vstate"].layer_dict["edges"]].glyph.line_alpha + == 0.3 + ) + # turn edges back off and check + type_column_list[-2].active = False + assert ( + main.UI["p"].renderers[main.UI["vstate"].layer_dict["edges"]].visible is False + ) + # check changing overlay alpha doesnt affect graph alpha + overlay_alpha = doc.get_model_by_name("overlay_alpha0") + overlay_alpha.value = 0.5 + assert ( + main.UI["p"].renderers[main.UI["vstate"].layer_dict["nodes"]].glyph.fill_alpha + == 0.4 + ) + # same with overlay toggle + overlay_toggle = doc.get_model_by_name("overlay_toggle0") + overlay_toggle.active = False + assert ( + main.UI["p"].renderers[main.UI["vstate"].layer_dict["nodes"]].glyph.fill_alpha + == 0.4 + ) + + +def test_pt_size_spinner(doc: Document) -> None: + """Test setting point size for graph nodes.""" + pt_size_spinner = doc.get_model_by_name("pt_size0") + # set the point size to 10 + pt_size_spinner.value = 10 + # check that the point size has been set correctly + assert ( + main.UI["p"].renderers[main.UI["vstate"].layer_dict["nodes"]].glyph.size + == 2 * 10 + ) + + +def test_filter_box(doc: Document) -> None: + """Test annotation filter box.""" + filter_input = doc.get_model_by_name("filter0") + im = get_tile("overlay", 4, 8, 4, show=False) + _, num_before = label(np.any(im[:, :, :3], axis=2)) + # filter for cells of type 0 + filter_input.value = "(props['type'] == 0) | (props['type'] == 1)" + im = get_tile("overlay", 4, 8, 4, show=False) + _, num_after = label(np.any(im[:, :, :3], axis=2)) + # should be less than without the filter + assert num_after < num_before + + # check type toggles in combo with filter + type_column_list = doc.get_model_by_name("type_column0").children + type_column_list[0].active = False + im = get_tile("overlay", 4, 8, 4, show=False) + _, num_final = label(np.any(im[:, :, :3], axis=2)) + # should be even less + assert num_final < num_after + + # set no filter + filter_input.value = "" + type_column_list[0].active = True + im = get_tile("overlay", 4, 8, 4, show=False) + _, num_after = label(np.any(im[:, :, :3], axis=2)) + # should be back to original number + assert num_after == num_before + # set an impossible filter + filter_input.value = "props['prob'] < 0" + im = get_tile("overlay", 4, 8, 4, show=False) + _, num_after = label(np.any(im[:, :, :3], axis=2)) + # should be no cells + assert num_after == 0 + + +def test_scale_spinner(doc: Document) -> None: + """Test setting scale for rendering small annotations.""" + scale_spinner = doc.get_model_by_name("scale0") + # set the scale to 0.5 + scale_spinner.value = 8 + # check that the scale has been set correctly + assert get_renderer_prop("max_scale") == 8 + + +def test_blur_spinner(doc: Document) -> None: + """Test setting blur for annotation layer.""" + blur_spinner = doc.get_model_by_name("blur0") + # set the blur to 4 + blur_spinner.value = 4 + # check that the blur has been set correctly + assert get_renderer_prop("blur_radius") == 4 + + +def test_res_switch(doc: Document) -> None: + """Test resolution switch.""" + res_switch = doc.get_model_by_name("res0") + # set the resolution to 0 + res_switch.active = 0 + # check that the resolution has been set correctly + assert main.UI["vstate"].res == 1 + res_switch.active = 1 + assert main.UI["vstate"].res == 2 + + +def test_color_cycler() -> None: + """Test the color cycler.""" + cycler = main.ColorCycler() + colors = cycler.colors + assert cycler.get_next() == colors[0] + assert cycler.get_next() == colors[1] + + rand_color = cycler.get_random() + assert rand_color in colors + + new_color = cycler.generate_random() + # should be a valid hex color + assert re.match(r"^#[0-9a-fA-F]{6}$", new_color) + + # test instantiate with custom colors + custom_cycler = main.ColorCycler(["#ff0000", "#00ff00"]) + assert len(custom_cycler.colors) == 2 + assert custom_cycler.get_next() == "#ff0000" + + +def test_cmap_select(doc: Document) -> None: + """Test changing the cmap.""" + cmap_select = doc.get_model_by_name("cmap0") + + main.UI["cprop_input"].value = ["prob"] + # set to jet + cmap_select.value = "jet" + resp = main.UI["s"].get(f"http://{main.host2}:5000/tileserver/cmap") + assert resp.json() == "jet" + # set to dict + cmap_select.value = "dict" + resp = main.UI["s"].get(f"http://{main.host2}:5000/tileserver/cmap") + assert isinstance(resp.json(), dict) + + main.UI["cprop_input"].value = ["type"] + # should now be the type mapping + resp = main.UI["s"].get(f"http://{main.host2}:5000/tileserver/cmap") + for key in main.UI["vstate"].mapper: + assert str(key) in resp.json() + assert np.all( + np.array(resp.json()[str(key)]) == np.array(main.UI["vstate"].mapper[key]), + ) + # set the cmap to "coolwarm" + cmap_select.value = "coolwarm" + resp = main.UI["s"].get(f"http://{main.host2}:5000/tileserver/cmap") + # as cprop is type (categorical), it should have had no effect + for key in main.UI["vstate"].mapper: + assert str(key) in resp.json() + assert np.all( + np.array(resp.json()[str(key)]) == np.array(main.UI["vstate"].mapper[key]), + ) + + main.UI["cprop_input"].value = ["prob"] + resp = main.UI["s"].get(f"http://{main.host2}:5000/tileserver/cmap") + # should be coolwarm as that is the last cmap we set, and prob is continuous + assert resp.json() == "coolwarm" + + +def test_option_buttons() -> None: + """Test the option buttons.""" + # default will be [FILLED] + # test outline only + assert get_renderer_prop("thickness") == -1 + main.opt_buttons_cb(None, None, []) + assert get_renderer_prop("thickness") == 1 + # test micron formatter + assert isinstance(main.UI["p"].xaxis[0].formatter, bkmodels.BasicTickFormatter) + main.opt_buttons_cb(None, None, [MICRON_FORMATTER]) + assert isinstance(main.UI["p"].xaxis[0].formatter, bkmodels.CustomJSTickFormatter) + # test gridlines + assert main.UI["p"].xgrid.grid_line_alpha == 0 + main.opt_buttons_cb(None, None, [GRIDLINES, MICRON_FORMATTER]) + assert main.UI["p"].xgrid.grid_line_alpha == 0.6 + # test removing above options + main.opt_buttons_cb(None, None, [FILLED]) + assert main.UI["p"].xgrid.grid_line_alpha == 0 + assert isinstance(main.UI["p"].xaxis[0].formatter, bkmodels.BasicTickFormatter) + assert get_renderer_prop("thickness") == -1 + + +def test_populate_slide_list(doc: Document, data_path: pytest.TempPathFactory) -> None: + """Test populating the slide list.""" + slide_select = doc.get_model_by_name("slide_select0") + assert len(slide_select.options) == 3 + main.populate_slide_list( + data_path["base_path"] / "slides", + search_txt="TCGA-HE-7130-01Z-00-DX1", + ) + assert len(slide_select.options) == 1 + main.populate_slide_list( + data_path["base_path"] / "slides", + ) + assert len(slide_select.options) == 3 + + +def test_clearing_doc(doc: Document) -> None: + """Test that the doc can be cleared.""" + doc.clear() + assert len(doc.roots) == 0 diff --git a/tests/test_json_config_bokeh.py b/tests/test_json_config_bokeh.py new file mode 100644 index 000000000..9344a1eae --- /dev/null +++ b/tests/test_json_config_bokeh.py @@ -0,0 +1,87 @@ +"""Test the bokeh app with config.json file.""" +from __future__ import annotations + +import time +from contextlib import suppress +from threading import Thread +from typing import TYPE_CHECKING + +import pytest +import requests +from bokeh.client.session import ClientSession, pull_session + +from tiatoolbox.cli.visualize import run_bokeh, run_tileserver +from tiatoolbox.data import _fetch_remote_sample + +if TYPE_CHECKING: + from pathlib import Path + + +@pytest.fixture(scope="module", autouse=True) +def annotation_path(data_path: dict[str, Path]) -> dict[str, Path]: + """Set up a dictionary defining the paths to the annotation files.""" + data_path["slide1"] = _fetch_remote_sample( + "svs-1-small", + data_path["base_path"] / "slides", + ) + data_path["slide2"] = _fetch_remote_sample( + "ndpi-1", + data_path["base_path"] / "slides", + ) + data_path["annotations"] = _fetch_remote_sample( + "annotation_store_svs_1", + data_path["base_path"] / "overlays", + ) + data_path["graph"] = _fetch_remote_sample( + "graph_svs_1", + data_path["base_path"] / "overlays", + ) + return data_path + + +@pytest.fixture() +def bk_session(data_path: dict[str, Path]) -> ClientSession: + """Create a bokeh session.""" + run_tileserver() + time.sleep(1) # allow time for server to start + + args = [ + [ + str(data_path["base_path"] / "slides"), + str(data_path["base_path"] / "overlays"), + ], + 5006, + ] + kwargs = {"noshow": True} + proc = Thread(target=run_bokeh, daemon=True, args=args, kwargs=kwargs) + proc.start() + time.sleep(5) # allow time for server to start + + session = pull_session( + url="http://localhost:5006/bokeh_app", + arguments={"slide": "CMU-1-Small-Region.svs"}, + ) + yield session + session.close() + with suppress(requests.exceptions.ConnectionError): + requests.post("http://localhost:5000/tileserver/shutdown", timeout=2) + + +def test_slides_available(bk_session: ClientSession) -> None: + """Test that the slides and overlays are available.""" + doc = bk_session.document + slide_select = doc.get_model_by_name("slide_select0") + # check there are two available slides + assert len(slide_select.options) == 2 + assert slide_select.value[0] == "CMU-1-Small-Region.svs" + + layer_drop = doc.get_model_by_name("layer_drop0") + assert len(layer_drop.menu) == 2 + # check that the overlays are available. + slide_select.value = ["CMU-1.ndpi"] + assert len(layer_drop.menu) == 2 + + bk_session.document.clear() + assert len(bk_session.document.roots) == 0 + bk_session.close() + time.sleep(5) # allow time for hooks to trigger diff --git a/tests/test_server_bokeh.py b/tests/test_server_bokeh.py new file mode 100644 index 000000000..cc8e784f9 --- /dev/null +++ b/tests/test_server_bokeh.py @@ -0,0 +1,125 @@ +"""Test the bokeh app from command line.""" +from __future__ import annotations + +import time +from contextlib import suppress +from threading import Thread +from typing import TYPE_CHECKING + +import pytest +import requests +from bokeh.client.session import ClientSession, pull_session +from click.testing import CliRunner + +from tiatoolbox import cli +from tiatoolbox.cli.visualize import run_bokeh, run_tileserver +from tiatoolbox.data import _fetch_remote_sample + +if TYPE_CHECKING: + from pathlib import Path + + +@pytest.fixture(scope="module", autouse=True) +def annotation_path(data_path: dict[str, Path]) -> dict[str, Path]: + """Set up a dictionary defining the paths to the annotation files.""" + data_path["slide1"] = _fetch_remote_sample( + "svs-1-small", + data_path["base_path"] / "slides", + ) + data_path["slide2"] = _fetch_remote_sample( + "ndpi-1", + data_path["base_path"] / "slides", + ) + data_path["annotations"] = _fetch_remote_sample( + "annotation_store_svs_1", + data_path["base_path"] / "overlays", + ) + data_path["graph"] = _fetch_remote_sample( + "graph_svs_1", + data_path["base_path"] / "overlays", + ) + data_path["config"] = _fetch_remote_sample( + "config_1", + data_path["base_path"] / "overlays", + ) + return data_path + + +@pytest.fixture() +def bk_session(data_path: dict[str, Path]) -> ClientSession: + """Create a bokeh session.""" + run_tileserver() + time.sleep(1) # allow time for server to start + + args = [ + [str(data_path["base_path"].parent)], + 5006, + ] + kwargs = {"noshow": True} + proc = Thread(target=run_bokeh, daemon=True, args=args, kwargs=kwargs) + proc.start() + time.sleep(5) # allow time for server to start + + session = pull_session( + url="http://localhost:5006/bokeh_app", + arguments={ + "demo": str(data_path["base_path"].parts[-1]), + "slide": "CMU-1.ndpi", + "window": "[0, 0, 1000, 1000]", + }, + ) + yield session + session.close() + with suppress(requests.exceptions.ConnectionError): + requests.post("http://localhost:5000/tileserver/shutdown", timeout=2) + + +def test_slides_available(bk_session: ClientSession) -> None: + """Test that the slides and overlays are available.""" + doc = bk_session.document + slide_select = doc.get_model_by_name("slide_select0") + # check there are two available slides + assert len(slide_select.options) == 2 + + # check that the overlays are available. + slide_select.value = ["CMU-1-Small-region.svs"] + layer_drop = doc.get_model_by_name("layer_drop0") + assert len(layer_drop.menu) == 2 + + bk_session.document.clear() + assert len(bk_session.document.roots) == 0 + bk_session.close() + time.sleep(5) # allow time for hooks to trigger + + +def test_cli_errors(data_path: dict[str, Path]) -> None: + """Test that the cli raises errors when expected.""" + runner = CliRunner() + # test with no input folder + result = runner.invoke( + cli.main, + [ + "visualize", + "--noshow", + ], + ) + assert result.exit_code == 1 + assert ( + result.exc_info[1].args[0] + == "Must specify either base-path or both slides and overlays." + ) + + # test with non-existent input folder + result = runner.invoke( + cli.main, + [ + "visualize", + "--noshow", + "--slides", + str(data_path["base_path"] / "slides"), + "--overlays", + "non_existent_folder", + ], + ) + assert result.exit_code == 1 + assert result.exc_info[1].args[0] == "non_existent_folder does not exist" diff --git a/tests/test_tileserver.py b/tests/test_tileserver.py index 35fb855c2..26b06fc74 100644 --- a/tests/test_tileserver.py +++ b/tests/test_tileserver.py @@ -323,7 +323,8 @@ def test_change_cmap(app: TileServer) -> None: assert layer.renderer.mapper(0.5) == colormaps["jet"](0.5) cdict = {"type1": [1, 0, 0], "type2": [0, 1, 0]} - response = client.put("/tileserver/cmap", data={"cmap": json.dumps(cdict)}) + req_data = {"keys": list(cdict.keys()), "values": list(cdict.values())} + response = client.put("/tileserver/cmap", data={"cmap": json.dumps(req_data)}) assert layer.renderer.mapper("type2") == [0, 1, 0] # test corresponding get @@ -392,7 +393,7 @@ def test_load_annotations_empty( # test corresponding get response = client.get( - "/tileserver/annotations/", + "/tileserver/annotations", data={ "bounds": json.dumps([0, 0, 30000, 30000]), "where": json.dumps(None), @@ -688,3 +689,47 @@ def test_no_ann_layer(empty_app: TileServer, remote_sample: Callable) -> None: ) with pytest.raises(ValueError, match="No annotation layer found."): client.get("/tileserver/prop_names/all") + + +def test_point_query(app: TileServer) -> None: + """Test point query.""" + with app.test_client() as client: + response = client.get("/tileserver/tap_query/1138.52/1881.5") + + assert response.status_code == 200 + props = json.loads(response.data) + assert props["type"] == 0 + assert props["prob"] == pytest.approx(0.988, abs=0.001) + + # test tap where no annotation exists + with app.test_client() as client: + response = client.get("/tileserver/tap_query/-100.0/-100.0") + + assert response.status_code == 200 + assert json.loads(response.data) == {} + + +def test_prop_range(app: TileServer) -> None: + """Test setting range in which color mapper will operate.""" + with app.test_client() as client: + layer = app.pyramids["default"]["overlay"] + # there will be no scaling by default + assert layer.renderer.score_fn(0.5) == 0.5 + response = client.put( + "/tileserver/prop_range", + data={"range": json.dumps([1.0, 3.0])}, + ) + assert response.status_code == 200 + assert response.content_type == "text/html; charset=utf-8" + # check that the renderer has been correctly updated + # as we are mapping the range [1, 3] to [0, 1], 1.5 + # should now map to 0.25 + assert layer.renderer.score_fn(1.5) == 0.25 + + response = client.put( + "/tileserver/prop_range", + data={"range": json.dumps(None)}, + ) + assert response.status_code == 200 + # should be back to no scaling + assert layer.renderer.score_fn(0.5) == 0.5 diff --git a/tests/test_utils.py b/tests/test_utils.py index 8631ebace..0b4061d46 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1562,7 +1562,8 @@ def test_from_multi_head_dat(tmp_path: Path) -> None: data = { "A": head_a, "B": head_b, - "resolution": 0.5, + "proc_resolution": {"resolution": 0.5, "units": "mpp"}, + "base_resolution": {"resolution": 0.25, "units": "mpp"}, "other_meta_data": {"foo": "bar"}, } joblib.dump(data, tmp_path / "test.dat") @@ -1645,7 +1646,7 @@ def test_imwrite(tmp_path: Path) -> NoReturn: with pytest.raises(IOError, match="Could not write image"): utils.misc.imwrite( - tmp_path / "thisfolderdoesnotexist" / "test_imwrite.jpg", + tmp_path / "this_folder_does_not_exist" / "test_imwrite.jpg", img, ) diff --git a/tiatoolbox/annotation/storage.py b/tiatoolbox/annotation/storage.py index 414efe426..dbfe7e1b3 100644 --- a/tiatoolbox/annotation/storage.py +++ b/tiatoolbox/annotation/storage.py @@ -34,6 +34,7 @@ import struct import sys import tempfile +import threading import uuid import zlib from abc import ABC, abstractmethod @@ -45,6 +46,7 @@ from typing import ( IO, TYPE_CHECKING, + Any, Callable, ClassVar, Generator, @@ -119,7 +121,7 @@ def geometry(self: Annotation) -> Geometry: if self._geometry is None: # Lazy creation of Shapely object when first requested. This # is memoized under _geometry. object.__setattr__ must be - # used becuase the class is frozen and will disallow normal + # used because the class is frozen and will disallow normal # assignment. object.__setattr__(self, "_geometry", shapely_wkb.loads(self._wkb)) # Return memoized geometry @@ -1275,8 +1277,8 @@ def pquery( Only annotations for which this predicate is true will be returned. Defaults to None (assume always true). This may be a string, Callable, or pickled function as bytes. - Callables are called to filter each result returned the - from annotation store backend in python before being + Callables are called to filter each result returned + from the annotation store backend in python before being returned to the user. A pickle object is, where possible, hooked into the backend as a user defined function to filter results during the backend query. @@ -2127,7 +2129,7 @@ def open(cls: type[SQLiteStore], fp: Path | str) -> SQLiteStore: """Opens :class:`SQLiteStore` from file pointer or path.""" return SQLiteStore(fp) - def __init__( # noqa: PLR0915 + def __init__( self: SQLiteStore, connection: Path | str | IO = ":memory:", compression: str = "zlib", @@ -2178,7 +2180,7 @@ def __init__( # noqa: PLR0915 self.path.is_file() and self.path.stat().st_size > 0 ) - self.con = sqlite3.connect(str(self.path), isolation_level="DEFERRED") + self.cons = {} self.con.execute("BEGIN") # Set up metadata @@ -2192,80 +2194,6 @@ def __init__( # noqa: PLR0915 self.compression = self.metadata["compression"] self.compression_level = self.metadata["compression_level"] - # Register predicate functions as custom SQLite functions - def wkb_predicate( - name: str, - wkb_a: bytes, - b: bytes, - cx: float, - cy: float, - ) -> bool: - """Wrapper function to allow WKB as inputs to binary predicates.""" - a = shapely_wkb.loads(wkb_a) - b = self._unpack_geometry(b, cx, cy) - return self._geometry_predicate(name, a, b) - - def pickle_expression(pickle_bytes: bytes, properties: str) -> bool: - """Function to load and execute pickle bytes with a "properties" dict.""" - fn = pickle.loads(pickle_bytes) # skipcq: BAN-B301 # noqa: S301 - properties = json.loads(properties) - return fn(properties) - - def get_area(wkb_bytes: bytes, cx: float, cy: float) -> float: - """Function to get the area of a geometry.""" - return self._unpack_geometry( - wkb_bytes, - cx, - cy, - ).area - - # Register custom functions - def register_custom_function( - name: str, - nargs: int, - fn: Callable, - *, - deterministic: bool = False, - ) -> None: - """Register a custom SQLite function. - - Only Python >= 3.8 supports deterministic functions, - fallback to without this argument if not available. - - Args: - name: - The name of the function. - nargs: - The number of arguments the function takes. - fn: - The function to register. - deterministic: - Whether the function is deterministic. - - """ - try: - self.con.create_function(name, nargs, fn, deterministic=deterministic) - except TypeError: - self.con.create_function(name, nargs, fn) - - register_custom_function( - "geometry_predicate", - 5, - wkb_predicate, - deterministic=True, - ) - register_custom_function( - "pickle_expression", - 2, - pickle_expression, - deterministic=True, - ) - register_custom_function("REGEXP", 2, py_regexp) - register_custom_function("REGEXP", 3, py_regexp) - register_custom_function("LISTSUM", 1, json_list_sum) - register_custom_function("CONTAINS", 1, json_contains) - register_custom_function("get_area", 3, get_area) - if exists: self.table_columns = self._get_table_columns() return @@ -2299,6 +2227,96 @@ def register_custom_function( self.con.commit() self.table_columns = self._get_table_columns() + def __getattribute__(self: SQLiteStore, name: str) -> Any: # noqa: ANN401 + """If attr is con, return thread-local connection.""" + if name == "con": + return self.get_connection(threading.get_ident()) + return super().__getattribute__(name) + + def get_connection(self: SQLiteStore, thread_id: int) -> sqlite3.Connection: + """Get a connection to the database.""" + if thread_id not in self.cons: + con = sqlite3.connect(str(self.path), isolation_level="DEFERRED", uri=True) + + # Register predicate functions as custom SQLite functions + def wkb_predicate( + name: str, + wkb_a: bytes, + b: bytes, + cx: float, + cy: float, + ) -> bool: + """Wrapper function to allow WKB as inputs to binary predicates.""" + a = shapely_wkb.loads(wkb_a) + b = self._unpack_geometry(b, cx, cy) + return self._geometry_predicate(name, a, b) + + def pickle_expression(pickle_bytes: bytes, properties: str) -> bool: + """Function to load and execute pickle bytes with "properties" dict.""" + fn = pickle.loads(pickle_bytes) # skipcq: BAN-B301 # noqa: S301 + properties = json.loads(properties) + return fn(properties) + + def get_area(wkb_bytes: bytes, cx: float, cy: float) -> float: + """Function to get the area of a geometry.""" + return self._unpack_geometry( + wkb_bytes, + cx, + cy, + ).area + + # Register custom functions + def register_custom_function( + name: str, + nargs: int, + fn: Callable, + *, + deterministic: bool = False, + ) -> None: + """Register a custom SQLite function. + + Only Python >= 3.8 supports deterministic functions, + fallback to without this argument if not available. + + Args: + name: + The name of the function. + nargs: + The number of arguments the function takes. + fn: + The function to register. + deterministic: + Whether the function is deterministic. + + """ + con.create_function( + name, + nargs, + fn, + deterministic=deterministic, + ) + + register_custom_function( + "geometry_predicate", + 5, + wkb_predicate, + deterministic=True, + ) + register_custom_function( + "pickle_expression", + 2, + pickle_expression, + deterministic=True, + ) + register_custom_function("REGEXP", 2, py_regexp) + register_custom_function("REGEXP", 3, py_regexp) + register_custom_function("LISTSUM", 1, json_list_sum) + register_custom_function("CONTAINS", 1, json_contains) + register_custom_function("get_area", 3, get_area) + self.cons[thread_id] = con + return con + return self.cons[thread_id] + def serialise_geometry( # skipcq: PYL-W0221 self: SQLiteStore, geometry: Geometry, @@ -2452,7 +2470,9 @@ def close(self: SQLiteStore) -> None: if self.auto_commit: self.con.commit() self.optimize(vacuum=False, limit=1000) - self.con.close() + for con in self.cons.values(): + con.close() + self.cons = {} def _make_token(self: SQLiteStore, annotation: Annotation, key: str | None) -> dict: """Create token data dict for tokenized SQL transaction.""" @@ -3142,8 +3162,8 @@ def pquery( Only annotations for which this predicate is true will be returned. Defaults to None (assume always true). This may be a string, Callable, or pickled function as bytes. - Callables are called to filter each result returned the - from annotation store backend in python before being + Callables are called to filter each result returned + from the annotation store backend in python before being returned to the user. A pickle object is, where possible, hooked into the backend as a user defined function to filter results during the backend query. diff --git a/tiatoolbox/cli/__init__.py b/tiatoolbox/cli/__init__.py index 93d6fde01..1aacac1e7 100644 --- a/tiatoolbox/cli/__init__.py +++ b/tiatoolbox/cli/__init__.py @@ -16,6 +16,7 @@ from tiatoolbox.cli.slide_thumbnail import slide_thumbnail from tiatoolbox.cli.stain_norm import stain_norm from tiatoolbox.cli.tissue_mask import tissue_mask +from tiatoolbox.cli.visualize import visualize def version_msg() -> str: @@ -45,6 +46,7 @@ def main() -> click.BaseCommand: main.add_command(slide_thumbnail) main.add_command(tissue_mask) main.add_command(stain_norm) +main.add_command(visualize) main.add_command(show_wsi) diff --git a/tiatoolbox/cli/visualize.py b/tiatoolbox/cli/visualize.py new file mode 100644 index 000000000..04d093484 --- /dev/null +++ b/tiatoolbox/cli/visualize.py @@ -0,0 +1,127 @@ +"""Command line interface for visualization tool.""" +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path +from threading import Thread + +import click + +if sys.version_info >= (3, 9): # pragma: no cover + import importlib.resources as importlib_resources +else: # pragma: no cover + # To support Python 3.8 + import importlib_resources # type: ignore[import-not-found] +from flask_cors import CORS + +from tiatoolbox.cli.common import tiatoolbox_cli +from tiatoolbox.visualization.tileserver import TileServer + +BOKEH_PATH = importlib_resources.files("tiatoolbox.visualization.bokeh_app") + + +def run_tileserver() -> None: + """Helper function to launch a tileserver.""" + + def run_app() -> None: + """Run the tileserver app.""" + app = TileServer( + title="Tiatoolbox TileServer", + layers={}, + ) + CORS(app, send_wildcard=True) + app.run(host="127.0.0.1", threaded=True) + + proc = Thread(target=run_app, daemon=True) + proc.start() + + +def run_bokeh(img_input: list[str], port: int, *, noshow: bool) -> None: + """Start the bokeh server.""" + cmd = [ + "bokeh", + "serve", + ] + if not noshow: + cmd = [*cmd, "--show"] # pragma: no cover + cmd = [ + *cmd, + BOKEH_PATH, + "--port", + str(port), + "--unused-session-lifetime", + "1000", + "--check-unused-sessions", + "1000", + "--args", + *img_input, + ] + subprocess.run(cmd, check=True, cwd=str(Path.cwd()), env=os.environ) # noqa: S603 + + +@tiatoolbox_cli.command() +@click.option( + "--base-path", + help="""Path to base directory containing images to be displayed. + Slides and overlays to be visualized are expected in subdirectories of the + base directory named slides and overlays, respectively. It is also possible + to provide a slide and overlay path separately + (use --slides and --overlays).""", +) +@click.option( + "--slides", + help="""Path to directory containing slides to be displayed. + This option must be used in conjunction with --overlay-path. + The --base-path option should not be used in this case.""", +) +@click.option( + "--overlays", + help="""Path to directory containing overlays to be displayed. + This option must be used in conjunction with --slides. + The --base-path option should not be used in this case.""", +) +@click.option( + "--port", + type=int, + help="Port to launch the visualization tool on.", + default=5006, +) +@click.option("--noshow", is_flag=True, help="Do not launch browser.") +def visualize( + base_path: str, + slides: str, + overlays: str, + port: int, + *, + noshow: bool, +) -> None: + """Launches the visualization tool for the given directory(s). + + If only base-path is given, Slides and overlays to be visualized are expected in + subdirectories of the base directory named slides and overlays, respectively. + + Args: + base_path (str): Path to base directory containing images to be displayed. + slides (str): Path to directory containing slides to be displayed. + overlays (str): Path to directory containing overlays to be displayed. + port (int): Port to launch the visualization tool on. + noshow (bool): Do not launch in browser (mainly intended for testing). + + """ + # sanity check the input args + if base_path is None and (slides is None or overlays is None): + msg = "Must specify either base-path or both slides and overlays." + raise ValueError(msg) + img_input = [base_path, slides, overlays] + img_input = [p for p in img_input if p is not None] + # check that the input paths exist + for input_path in img_input: + if not Path(input_path).exists(): + msg = f"{input_path} does not exist" + raise FileNotFoundError(msg) + + # start servers + run_tileserver() # pragma: no cover + run_bokeh(img_input, port, noshow=noshow) # pragma: no cover diff --git a/tiatoolbox/data/remote_samples.yaml b/tiatoolbox/data/remote_samples.yaml index 2abb42c1b..364a5566e 100644 --- a/tiatoolbox/data/remote_samples.yaml +++ b/tiatoolbox/data/remote_samples.yaml @@ -123,7 +123,19 @@ files: url: [ *testdata, "registration/HE_2_level8_mask.png" ] annotation_store_svs_1: url: [ *testdata, "annotation/CMU-1-Small-Region_detections.db"] - rendered_annotations_svs_1: - url: [ *testdata, "annotation/svs_1_rendered_annotations.tiff"] + annotation_dat_svs_1: + url: [ *testdata, "annotation/CMU-1-Small-Region_anns.dat"] + svs_1_rendered_annotations_jpg: + url: [ *testdata, "annotation/CMU-1-Small-Region_rendered_annotations.jpg"] + graph_svs_1: + url: [ *testdata, "annotation/CMU-1-Small-Region_detections_gr.json"] + graph_svs_1_feats: + url: [ *testdata, "annotation/CMU-1-Small-Region_gr_feats.json"] + geojson_cmu_1: + url: [ *testdata, "annotation/CMU-1.geojson"] + config_1: + url: [ *testdata, "annotation/test1_config.json"] + config_2: + url: [ *testdata, "annotation/test2_config.json"] nuclick-output: url: [*modelroot, "predictions/nuclei_mask/nuclick-output.npy"] diff --git a/tiatoolbox/tools/pyramid.py b/tiatoolbox/tools/pyramid.py index 2529a6980..7ba48af83 100644 --- a/tiatoolbox/tools/pyramid.py +++ b/tiatoolbox/tools/pyramid.py @@ -155,6 +155,7 @@ def get_tile( res: int = 1, pad_mode: str = "constant", interpolation: str = "optimise", + transparent_value: int | None = None, ) -> Image: """Get a tile at a given level and coordinate. @@ -185,6 +186,9 @@ def get_tile( Interpolation mode to use. Defaults to optimise. Possible values are: linear, cubic, lanczos, nearest, area, optimise. Linear most closely matches OpenSlide. + transparent_value (int): + If provided, pixels with this value across all channels will + be made transparent. Defaults to None. Returns: PIL.Image: @@ -236,6 +240,12 @@ def get_tile( interpolation=interpolation, ) logger.removeFilter(duplicate_filter) + if transparent_value is not None: + # Pixels with this value across all channels will be made transparent + alph = 255 * np.logical_not( + np.all(tile == transparent_value, axis=2), + ).astype("uint8") + tile = np.dstack((tile, alph)) return Image.fromarray(tile) def tile_path(self: TilePyramidGenerator, level: int, x: int, y: int) -> Path: @@ -575,6 +585,7 @@ def get_tile( res: int = 1, pad_mode: str | None = None, interpolation: str | None = None, + transparent_value: int | None = None, # noqa: ARG002 ) -> Image: """Render a tile at a given level and coordinate. @@ -600,6 +611,8 @@ def get_tile( interpolation (str): Method of interpolation. Possible values are: nearest, linear, cubic, lanczos, area. Defaults to nearest. + transparent_value (int): + Not used by AnnotationTileGenerator. Returns: PIL.Image: diff --git a/tiatoolbox/utils/misc.py b/tiatoolbox/utils/misc.py index 7fb7031bc..9435122de 100644 --- a/tiatoolbox/utils/misc.py +++ b/tiatoolbox/utils/misc.py @@ -1004,6 +1004,8 @@ def store_from_dat( All coordinates will be multiplied by this factor to allow import of annotations saved at non-baseline resolution. Should be model_mpp/slide_mpp, where model_mpp is the resolution at which the annotations were saved. + If scale information is stored in the .dat file (as in cerberus output), + that will be used and this arg will be ignored. typedict (Dict[str, str]): A dictionary mapping annotation types to annotation keys. Annotations with a type that is a key in the dictionary, will have their type @@ -1025,6 +1027,7 @@ def store_from_dat( """ store = cls() add_from_dat(store, fp, scale_factor, typedict=typedict, origin=origin) + store.create_index("area", '"area"') return store @@ -1153,7 +1156,10 @@ def add_from_dat( scale_factor (tuple[float, float]): The scale factor to use when loading the annotations. All coordinates will be multiplied by this factor to allow import of annotations saved - at non-baseline resolution. + at non-baseline resolution. Should be model_mpp/slide_mpp, where + model_mpp is the resolution at which the annotations were saved. + If scale information is stored in the .dat file (as in cerberus output), + that will be used and this arg will be ignored. typedict (Dict[str, str]): A dictionary mapping annotation types to annotation keys. Annotations with a type that is a key in the dictionary, will have their type @@ -1169,11 +1175,23 @@ def add_from_dat( """ data = joblib.load(fp) props = list(data[next(iter(data.keys()))].keys()) + if "base_resolution" in data and "proc_resolution" in data: + # we can infer scalefactor from resolutions + scale_factor = ( + data["proc_resolution"]["resolution"] + / data["base_resolution"]["resolution"] + ) + logger.info("Scale factor inferred from resolutions: %s", scale_factor) if "contour" not in props: # assume cerberus format with objects subdivided into categories anns = [] for subcat in data: - if subcat == "resolution": + if ( + subcat == "resolution" + or subcat == "proc_dimensions" + or subcat == "base_dimensions" + or "resolution" in subcat + ): continue props = next(iter(data[subcat].values())) if not isinstance(props, dict): diff --git a/tiatoolbox/visualization/bokeh_app/__init__.py b/tiatoolbox/visualization/bokeh_app/__init__.py new file mode 100644 index 000000000..3bfd764a1 --- /dev/null +++ b/tiatoolbox/visualization/bokeh_app/__init__.py @@ -0,0 +1 @@ +"""Visualization tool for tiatoolbox.""" diff --git a/tiatoolbox/visualization/bokeh_app/app_hooks.py b/tiatoolbox/visualization/bokeh_app/app_hooks.py new file mode 100644 index 000000000..300308283 --- /dev/null +++ b/tiatoolbox/visualization/bokeh_app/app_hooks.py @@ -0,0 +1,14 @@ +"""Hooks to be executed upon specific events in bokeh app.""" +import sys +from contextlib import suppress + +import requests +from bokeh.application.application import SessionContext + + +def on_session_destroyed(session_context: SessionContext) -> None: + """Hook to be executed when a session is destroyed.""" + user = session_context.request.arguments["user"] + with suppress(requests.exceptions.ReadTimeout): + requests.get(f"http://127.0.0.1:5000/tileserver/reset/{user}", timeout=5) + sys.exit() diff --git a/tiatoolbox/visualization/bokeh_app/main.py b/tiatoolbox/visualization/bokeh_app/main.py new file mode 100644 index 000000000..fbc33be0f --- /dev/null +++ b/tiatoolbox/visualization/bokeh_app/main.py @@ -0,0 +1,2113 @@ +"""Main module for the tiatoolbox visualization bokeh app.""" +from __future__ import annotations + +import json +import sys +import tempfile +import urllib +from cmath import pi +from pathlib import Path, PureWindowsPath +from shutil import rmtree +from typing import TYPE_CHECKING, Any, Callable, SupportsFloat + +import numpy as np +import requests +import torch +from bokeh.events import ButtonClick, DoubleTap, MenuItemClick +from bokeh.io import curdoc +from bokeh.layouts import column, row +from bokeh.models import ( + BasicTickFormatter, + BoxEditTool, + Button, + CheckboxButtonGroup, + Circle, + ColorBar, + ColorPicker, + Column, + ColumnDataSource, + CustomJS, + DataTable, + Div, + Dropdown, + FuncTickFormatter, + Glyph, + HoverTool, + HTMLTemplateFormatter, + InlineStyleSheet, + LinearColorMapper, + Model, + MultiChoice, + PointDrawTool, + RadioButtonGroup, + Row, + Segment, + Select, + Slider, + Spinner, + TableColumn, + TabPanel, + Tabs, + TapTool, + TextInput, + Toggle, + Tooltip, +) +from bokeh.models.dom import HTML +from bokeh.models.tiles import WMTSTileSource +from bokeh.plotting import figure +from bokeh.util import token +from matplotlib import colormaps +from PIL import Image +from requests.adapters import HTTPAdapter, Retry + +# GitHub actions seems unable to find TIAToolbox unless this is here +sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent)) +from tiatoolbox import logger # noqa: E402 +from tiatoolbox.models.engine.nucleus_instance_segmentor import ( # noqa: E402 + NucleusInstanceSegmentor, +) +from tiatoolbox.tools.pyramid import ZoomifyGenerator # noqa: E402 +from tiatoolbox.utils.visualization import random_colors # noqa: E402 +from tiatoolbox.visualization.ui_utils import get_level_by_extent # noqa: E402 +from tiatoolbox.wsicore.wsireader import WSIReader # noqa: E402 + +if TYPE_CHECKING: # pragma: no cover + from bokeh.document import Document + +rng = np.random.default_rng() + +# Define some constants +MAX_CAT = 10 +FILLED = 0 +MICRON_FORMATTER = 1 +GRIDLINES = 2 +MAX_FEATS = 15 +N_PERMANENT_RENDERERS = 5 +NO_UPDATE = 0 +PENDING_UPDATE = 1 +DO_UPDATE = 2 + + +# Stylesheets to format some things better + +# Stylesheet for the help tooltips +help_ss = InlineStyleSheet( + css=""" + :host(.help_tt) { + width:200px; + white-space: wrap; + padding-top: 3px; + padding-bottom: 3px; + margin-top: 3px; + margin-bottom: 3px; + } + """, +) + + +# Define helper functions/classes +# region +class DummyAttr: + """Dummy class to enable triggering a callback independently of a widget.""" + + def __init__(self: DummyAttr, val: Any) -> None: # noqa: ANN401 + """Initialize the class.""" + self.item = val + + +class UIWrapper: + """Wrapper class to access ui elements.""" + + def __init__(self: UIWrapper) -> None: + """Initialize the class.""" + self.active = 0 + + def __getitem__(self: UIWrapper, key: str) -> Any: # noqa: ANN401 + """Gets ui element for the active window.""" + return win_dicts[self.active][key] + + +def format_info(info: dict[str, Any]) -> str: + """Format the slide info for display.""" + info_str = f"Slide Name: {info.pop('file_path').name}
" + for k, v in info.items(): + info_str += f"{k}: {v}
" + return info_str + + +def get_view_bounds( + dims: tuple[float, float], + plot_size: tuple[float, float], +) -> tuple[float, float, float, float]: + """Helper to get the current view bounds. + + Estimate a reasonable initial view bounds based on the image + dimensions and the size of the viewing plot. + + Args: + dims: The dimensions of the image. + plot_size: The size of the plot. + + Returns: + The view bounds. + + """ + pad = int(np.mean(dims) / 10) + aspect_ratio = plot_size[0] / plot_size[1] + large_dim = np.argmax(np.array(dims) / plot_size) + + if large_dim == 1: + x_range_start = -0.5 * (dims[1] * aspect_ratio - dims[0]) - aspect_ratio * pad + x_range_end = ( + dims[1] * aspect_ratio + - 0.5 * (dims[1] * aspect_ratio - dims[0]) + + aspect_ratio * pad + ) + y_range_start = -dims[1] - pad + y_range_end = pad + else: + x_range_start = -aspect_ratio * pad + x_range_end = dims[0] + pad * aspect_ratio + y_range_start = ( + -dims[0] / aspect_ratio + 0.5 * (dims[0] / aspect_ratio - dims[1]) - pad + ) + y_range_end = 0.5 * (dims[0] / aspect_ratio - dims[1]) + pad + return x_range_start, x_range_end, y_range_start, y_range_end + + +def to_num(x: str | SupportsFloat) -> int | float | None: + """Convert a str representation of a number to a numerical value.""" + if not isinstance(x, str): + return x + if x == "None": + return None + try: + return int(x) + except ValueError: + return float(x) + + +def get_from_config(keys: list[str], default: Any = None) -> Any: # noqa: ANN401 + """Helper to get a value from a config dict. + + Check dict values for nested keys. + The default value is returned if the key is not found. + + Args: + keys: The nested keys to look for. e.g ["a", "b"] will look for + config["a"]["b"]. + default: The default value to return if the entry is not found. + + """ + c_dict = doc_config.config + for k in keys: + if k in c_dict: + c_dict = c_dict[k] + else: + return default + return c_dict + + +def make_ts(route: str, z_levels: int, init_z: int = 4) -> WMTSTileSource: + """Helper to make a tile source.""" + sf = 2 ** (z_levels - init_z - 5) + ts = WMTSTileSource( + name="WSI provider", + url=route, + attribution="", + snap_to_zoom=False, + min_zoom=0, + max_zoom=z_levels - 1, + ) + ts.tile_size = 256 + ts.initial_resolution = 40211.5 * sf * (2 / (100 * pi)) + ts.x_origin_offset = 0 + ts.y_origin_offset = sf * 10294144.78 * (2 / (100 * pi)) + ts.wrap_around = False + return ts + + +def to_float_rgb(rgb: tuple[int, int, int]) -> tuple[float, float, float]: + """Helper to convert from int to float rgb(a) tuple.""" + return tuple(v / 255 for v in rgb) + + +def to_int_rgb(rgb: tuple[float, float, float]) -> tuple[int, int, int]: + """Helper to convert from float to int rgb(a) tuple.""" + return tuple(int(v * 255) for v in rgb) + + +def name2type(name: str) -> Any: # noqa: ANN401 + """Helper to get original type from stringified version.""" + name = UI["vstate"].orig_types[name] + if isinstance(name, str): + return f'"{name}"' + return name + + +def hex2rgb(hex_val: str) -> tuple[float, float, float]: + """Covert hex rgb string to float rgb(a) tuple.""" + return tuple(int(hex_val[i : i + 2], 16) / 255 for i in (1, 3, 5)) + + +def rgb2hex(rgb: tuple[float, float, float]) -> str: + """Covert float rgb(a) tuple to hex string.""" + int_rgb = to_int_rgb(rgb) + return f"#{int_rgb[0]:02x}{int_rgb[1]:02x}{int_rgb[2]:02x}" + + +def make_color_seq_from_cmap(cmap: str | None = None) -> list[str]: + """Helper to make a color sequence from a colormap.""" + if cmap is None: + return [ + rgb2hex((1.0, 1.0, 1.0)), + rgb2hex((1.0, 1.0, 1.0)), + ] # no colors if using dict + return [rgb2hex(cmap(v)) for v in np.linspace(0, 1, 50)] + + +def make_safe_name(name: str) -> str: + """Helper to make a name safe for use in a URL.""" + return urllib.parse.quote(str(PureWindowsPath(name)), safe="") + + +def make_color_dict(types: list[str]) -> dict[str, tuple[float, float, float]]: + """Helper to make a color dict from a list of types.""" + colors = random_colors(len(types), bright=True) + # Grab colors out of doc_config["color_dict"] if possible, otherwise use random + type_colors = {} + for i, t in enumerate(types): + if t in UI["vstate"].mapper: + # Keep existing color + type_colors[t] = UI["vstate"].mapper[t] + elif str(t) in get_from_config(["color_dict"], {}): + # Grab color from config if possible + type_colors[t] = to_float_rgb(doc_config["color_dict"][str(t)]) + else: + # Otherwise use random + type_colors[t] = (*colors[i], 1) + return type_colors + + +def set_alpha_glyph(glyph: Glyph, alpha: float) -> None: + """Set the fill and line alpha for a glyph.""" + glyph.fill_alpha = alpha + glyph.line_alpha = alpha + + +def get_mapper_for_prop(prop: str, mapper_type: str = "auto") -> str | dict[str, str]: + """Helper to get appropriate mapper for a property.""" + if prop == "type": + UI["vstate"].is_categorical = True + return UI["vstate"].mapper + # Find out the unique values of the chosen property + resp = UI["s"].get(f"http://{host2}:5000/tileserver/prop_values/{prop}/all") + prop_vals = json.loads(resp.text) + # If auto, guess what cmap should be + if ( + (len(prop_vals) > MAX_CAT or len(prop_vals) == 0) + and mapper_type == "auto" + or mapper_type == "continuous" + ): + cmap = ( + "viridis" if UI["cmap_select"].value == "dict" else UI["cmap_select"].value + ) + UI["vstate"].is_categorical = False + else: + cmap = make_color_dict(prop_vals) + return cmap + + +def update_mapper() -> None: + """Helper to update the color mapper.""" + update_renderer("mapper", UI["vstate"].mapper) + + +def update_renderer(prop: str, value: Any) -> None: # noqa: ANN401 + """Helper to update a renderer property.""" + if prop == "mapper": + if value == "dict": + value = get_mapper_for_prop( + UI["cprop_input"].value[0], + mapper_type="dict", + ) + UI["color_bar"].color_mapper.palette = make_color_seq_from_cmap(None) + if not isinstance(value, dict): + UI["color_bar"].color_mapper.palette = make_color_seq_from_cmap( + colormaps[value], + ) + UI["color_bar"].visible = True + if isinstance(value, dict): + # Send keys and values separately so types are preserved + value = {"keys": list(value.keys()), "values": list(value.values())} + UI["s"].put( + f"http://{host2}:5000/tileserver/cmap", + data={"cmap": json.dumps(value)}, + ) + return + UI["s"].put( + f"http://{host2}:5000/tileserver/renderer/{prop}", + data={"val": json.dumps(value)}, + ) + + +def build_predicate() -> str: + """Builds a predicate string. + + Builds the appropriate predicate string from the currently selected types, + and the filter input. + + """ + preds = [ + f'props["type"]=={name2type(layer.label)}' + for layer in UI["type_column"].children + if layer.active and layer.label in UI["vstate"].types + ] + combo = "None" + if len(preds) == len(UI["vstate"].types): + preds = [] + elif len(preds) == 0: + preds = ['props["type"]=="None"'] + if len(preds) > 0: + combo = "(" + ") | (".join(preds) + ")" + if UI["filter_input"].value not in ["None", ""]: + if combo == "None": + combo = UI["filter_input"].value + else: + combo = "(" + combo + ") & (" + UI["filter_input"].value + ")" + + update_renderer("where", combo) + return combo + + +def initialise_slide() -> None: + """Initialise the newly selected slide.""" + # Get some slide info + UI["vstate"].mpp = UI["vstate"].wsi.info.mpp + if UI["vstate"].mpp is None: + UI["vstate"].mpp = [1, 1] + UI["vstate"].dims = UI["vstate"].wsi.info.slide_dimensions + slide_name = UI["vstate"].wsi.info.file_path.stem + UI["vstate"].types = [] + UI["vstate"].props = [] + plot_size = np.array([UI["p"].width, UI["p"].height]) + + # Set up initial view window + UI["vstate"].micron_formatter.args["mpp"] = UI["vstate"].mpp[0] + if slide_name in get_from_config(["initial_views"], {}): + lims = doc_config["initial_views"][slide_name] + UI["p"].x_range.start = lims[0] + UI["p"].x_range.end = lims[2] + UI["p"].y_range.start = -lims[3] + UI["p"].y_range.end = -lims[1] + # If two windows open and new slide is 'related' to the other, use the same view + elif len(win_dicts) == 2 and ( # noqa: PLR2004 + win_dicts[0]["vstate"].slide_path.stem in win_dicts[1]["vstate"].slide_path.stem + or win_dicts[1]["vstate"].slide_path.stem + in win_dicts[0]["vstate"].slide_path.stem + or win_dicts[1]["vstate"].dims == win_dicts[0]["vstate"].dims + ): # PLR2004 + # View should already be correct, pass + pass + else: + x_start, x_end, y_start, y_end = get_view_bounds(UI["vstate"].dims, plot_size) + UI["p"].x_range.start = x_start + UI["p"].x_range.end = x_end + UI["p"].y_range.start = y_start + UI["p"].y_range.end = y_end + + init_z = get_level_by_extent((0, UI["p"].y_range.start, UI["p"].x_range.end, 0)) + UI["vstate"].init_z = init_z + logger.warning("slide info: %s", UI["vstate"].wsi.info.as_dict(), stacklevel=2) + slide_info.text = format_info(UI["vstate"].wsi.info.as_dict()) + + +def initialise_overlay() -> None: + """Initialise the newly selected overlay.""" + UI["vstate"].colors = list(UI["vstate"].mapper.values()) + now_active = {b.label: b.active for b in UI["type_column"].children} + # Add type toggles for any that weren't already there + for t in sorted(UI["vstate"].types): + if str(t) not in now_active: + UI["type_column"].children.append( + Toggle( + label=str(t), + active=True, + width=130, + height=30, + max_width=130, + sizing_mode="stretch_width", + ), + ) + UI["type_column"].children[-1].on_click(layer_select_cb) + try: + UI["color_column"].children.append( + ColorPicker( + color=to_int_rgb(UI["vstate"].mapper[t][0:3]), + name=str(t), + width=60, + min_width=60, + max_width=70, + height=30, + sizing_mode="stretch_width", + ), + ) + except KeyError: + UI["color_column"].children.append( + ColorPicker( + color=to_int_rgb(UI["vstate"].mapper[to_num(t)][0:3]), + name=str(t), + width=60, + height=30, + min_width=60, + max_width=70, + sizing_mode="stretch_width", + ), + ) + UI["color_column"].children[-1].on_change( + "color", + bind_cb_obj(UI["color_column"].children[-1], color_input_cb), + ) + + # Remove any that are no longer in the overlay + for b in UI["type_column"].children.copy(): + if b.label not in UI["vstate"].types and b.label not in UI["vstate"].layer_dict: + UI["type_column"].children.remove(b) + for c in UI["color_column"].children.copy(): + if c.name not in UI["vstate"].types and "slider" not in c.name: + UI["color_column"].children.remove(c) + + build_predicate() + + +def add_layer(lname: str) -> None: + """Add a new layer to the visualization.""" + UI["type_column"].children.append( + Toggle( + label=lname, + active=True, + width=130, + height=40, + max_width=130, + sizing_mode="stretch_width", + ), + ) + if lname == "nodes": + UI["type_column"].children[-1].active = ( + UI["p"].renderers[UI["vstate"].layer_dict[lname]].glyph.line_alpha > 0 + ) + if lname == "edges": + UI["type_column"].children[-1].active = ( + UI["p"].renderers[UI["vstate"].layer_dict[lname]].visible + ) + UI["type_column"].children[-1].on_click( + bind_cb_obj_tog(UI["type_column"].children[-1], fixed_layer_select_cb), + ) + UI["color_column"].children.append( + Slider( + start=0, + end=1, + value=0.75, + step=0.01, + title=lname, + height=40, + width=100, + max_width=90, + sizing_mode="stretch_width", + name=f"{lname}_slider", + ), + ) + UI["color_column"].children[-1].on_change( + "value", + bind_cb_obj(UI["color_column"].children[-1], layer_slider_cb), + ) + + +class TileGroup: + """Class to keep track of the current tile group.""" + + def __init__(self: TileGroup) -> None: + """Initialise the tile group.""" + self.group = 1 + + def get_grp(self: TileGroup) -> int: + """Get the current tile group.""" + self.group = self.group + 1 + return self.group + + +class ColorCycler: + """Class to cycle through a list of colors.""" + + def __init__(self: ColorCycler, colors: list[str] | None = None) -> None: + """Initialise the color cycler.""" + if colors is None: + colors = ["red", "blue", "lime", "yellow", "cyan", "magenta", "orange"] + self.colors = colors + self.index = -1 + + def get_next(self: ColorCycler) -> str: + """Get the next color in the list.""" + self.index = (self.index + 1) % len(self.colors) + return self.colors[self.index] + + def get_random(self: ColorCycler) -> str: + """Get a random color from the list.""" + return str(rng.choice(self.colors)) + + @staticmethod + def generate_random() -> str: + """Generate a new random color.""" + return rgb2hex(rng.choice(256, 3) / 255) + + +def change_tiles(layer_name: str = "overlay") -> None: + """Update tilesources. + + If a layer is updated/added, will update the tilesource to ensure + that the new layer is displayed. + + """ + grp = tg.get_grp() + + if layer_name == "graph" and layer_name not in UI["vstate"].layer_dict: + return + + ts = make_ts( + f"http://{host}:{port}/tileserver/layer/{layer_name}/{UI['user']}/" + f"zoomify/TileGroup{grp}" + r"/{z}-{x}-{y}" + f"@{UI['vstate'].res}x.jpg", + UI["vstate"].num_zoom_levels, + ) + if layer_name in UI["vstate"].layer_dict: + UI["p"].renderers[UI["vstate"].layer_dict[layer_name]].tile_source = ts + else: + UI["p"].add_tile( + ts, + smoothing=True, + alpha=UI["overlay_alpha"].value, + level="image", + render_parents=False, + ) + for layer_key in UI["vstate"].layer_dict: + if layer_key in ["rect", "pts", "nodes", "edges"]: + continue + grp = tg.get_grp() + ts = make_ts( + f"http://{host}:{port}/tileserver/layer/{layer_key}/{UI['user']}/" + f"zoomify/TileGroup{grp}" + r"/{z}-{x}-{y}" + f"@{UI['vstate'].res}x.jpg", + UI["vstate"].num_zoom_levels, + ) + UI["p"].renderers[UI["vstate"].layer_dict[layer_key]].tile_source = ts + UI["vstate"].layer_dict[layer_name] = len(UI["p"].renderers) - 1 + + logger.info("current layers: %s", UI["vstate"].layer_dict) + + +class ViewerState: + """Class to keep track of the current state of the viewer.""" + + def __init__(self: ViewerState, slide_path: str | Path) -> None: + """Initialise the viewer state.""" + self.wsi = WSIReader.open(slide_path) + self.slide_path = slide_path + self.mpp = self.wsi.info.mpp + if self.mpp is None: + self.mpp = [1, 1] + self.dims = self.wsi.info.slide_dimensions + self.mapper = {} + self.colors = list(self.mapper.values()) + self.cprop = None + self.init_z = None + self.types = list(self.mapper.keys()) + self.layer_dict = {"slide": 0, "rect": 1, "pts": 2} + self.update_state = 0 + self.thickness = -1 + self.model_mpp = 0 + self.init = True + self.micron_formatter = FuncTickFormatter( + args={"mpp": 0.1}, + code=""" + return Math.round(tick*mpp) + """, + ) + self.current_model = "hovernet" + self.props = [] + self.props_old = [] + self.to_update = set() + self.graph = [] + self.res = 2 + self.is_categorical = True + + def __setattr__( + self: ViewerState, + __name: str, + __value: Any, # noqa: ANN401 + ) -> None: + """Set an attribute of the viewer state.""" + if __name == "types": + self.__dict__["mapper"] = make_color_dict(__value) + self.__dict__["colors"] = list(self.mapper.values()) + if self.cprop == "type": + update_mapper() + # We will standardise the types to strings, keep dict of originals + self.__dict__["orig_types"] = {str(x): x for x in __value} + __value = [str(x) for x in __value] + + if __name == "wsi": + z = ZoomifyGenerator(__value, tile_size=256) + self.__dict__["num_zoom_levels"] = z.level_count + + self.__dict__[__name] = __value + + +# endregion + + +# Define UI callbacks +# region +def res_switch_cb(attr: str, old: int, new: int) -> None: # noqa: ARG001 + """Callback to switch between resolutions.""" + if new == 0: + UI["vstate"].res = 1 + else: + UI["vstate"].res = 2 + UI["vstate"].update_state = 1 + UI["vstate"].to_update.update(["overlay", "slide"]) + + +def slide_toggle_cb(attr: str) -> None: # noqa: ARG001 + """Callback to toggle the slide on/off.""" + if UI["p"].renderers[0].alpha == 0: + UI["p"].renderers[0].alpha = UI["slide_alpha"].value + else: + UI["p"].renderers[0].alpha = 0.0 + + +def node_select_cb(attr: str, old: int, new: int) -> None: # noqa: ARG001 + """Placeholder callback to do something on node selection.""" + # Do something on node select if desired + + +def overlay_toggle_cb(attr: str) -> None: # noqa: ARG001 + """Callback to toggle the overlay on/off.""" + for i in range(5, len(UI["p"].renderers)): + if UI["p"].renderers[i].alpha == 0: + UI["p"].renderers[i].alpha = UI["overlay_alpha"].value + else: + UI["p"].renderers[i].alpha = 0.0 + + +def populate_layer_list(slide_name: str, overlay_path: Path) -> None: + """Populate the layer list with the available overlays.""" + file_list = [] + for ext in [ + "*.db", + "*.dat", + "*.geojson", + "*.png", + "*.jpg", + "*.json", + "*.tiff", + ]: + file_list.extend(list(overlay_path.glob(str(Path("*") / ext)))) + file_list.extend(list(overlay_path.glob(ext))) + file_list = [(str(p), str(p)) for p in sorted(file_list) if slide_name in str(p)] + UI["layer_drop"].menu = file_list + + +def populate_slide_list(slide_folder: Path, search_txt: str | None = None) -> None: + """Populate the slide list with the available slides.""" + file_list = [] + len_slidepath = len(slide_folder.parts) + for ext in ["*.svs", "*ndpi", "*.tiff", "*.mrxs", "*.jpg", "*.png", "*.tif"]: + file_list.extend(list(Path(slide_folder).glob(str(Path("*") / ext)))) + file_list.extend(list(Path(slide_folder).glob(ext))) + if search_txt is None: + file_list = [ + (str(Path(*p.parts[len_slidepath:])), str(Path(*p.parts[len_slidepath:]))) + for p in sorted(file_list) + ] + else: + file_list = [ + (str(Path(*p.parts[len_slidepath:])), str(Path(*p.parts[len_slidepath:]))) + for p in sorted(file_list) + if search_txt in str(p) + ] + + UI["slide_select"].options = file_list + + +def filter_input_cb(attr: str, old: str, new: str) -> None: # noqa: ARG001 + """Change predicate to be used to filter annotations.""" + build_predicate() + UI["vstate"].update_state = 1 + UI["vstate"].to_update.update(["overlay"]) + + +def cprop_input_cb(attr: str, old: str, new: list[str]) -> None: # noqa: ARG001 + """Change property to color by.""" + if len(new) == 0: + return + cmap = get_mapper_for_prop(new[0]) + UI["vstate"].cprop = new[0] + update_renderer("mapper", cmap) + UI["s"].put( + f"http://{host2}:5000/tileserver/color_prop", + data={"prop": json.dumps(new[0])}, + ) + UI["vstate"].update_state = 1 + UI["vstate"].to_update.update(["overlay"]) + + +def slide_alpha_cb(attr: str, old: float, new: float) -> None: # noqa: ARG001 + """Callback to change the alpha of the slide.""" + UI["p"].renderers[0].alpha = new + + +def overlay_alpha_cb(attr: str, old: float, new: float) -> None: # noqa: ARG001 + """Callback to change the alpha of all overlay layers.""" + for i in range(5, len(UI["p"].renderers)): + UI["p"].renderers[i].alpha = new + + +def pt_size_cb(attr: str, old: float, new: float) -> None: # noqa: ARG001 + """Callback to change the size of the points.""" + UI["vstate"].graph_node.size = 2 * new + + +def edge_size_cb(attr: str, old: float, new: float) -> None: # noqa: ARG001 + """Callback to change the size of the edges.""" + update_renderer("edge_thickness", new) + UI["vstate"].update_state = 1 + UI["vstate"].to_update.update(["overlay"]) + + +def opt_buttons_cb(attr: str, old: list[int], new: list[int]) -> None: # noqa: ARG001 + """Callback to handle options changes in the ui widget.""" + old_thickness = UI["vstate"].thickness + if FILLED in new: + UI["vstate"].thickness = -1 + update_renderer("thickness", -1) + else: + UI["vstate"].thickness = 1 + update_renderer("thickness", 1) + if old_thickness != UI["vstate"].thickness: + UI["vstate"].update_state = 1 + UI["vstate"].to_update.update(["overlay"]) + if MICRON_FORMATTER in new: + UI["p"].xaxis[0].formatter = UI["vstate"].micron_formatter + UI["p"].yaxis[0].formatter = UI["vstate"].micron_formatter + else: + UI["p"].xaxis[0].formatter = BasicTickFormatter() + UI["p"].yaxis[0].formatter = BasicTickFormatter() + if GRIDLINES in new: + UI["p"].ygrid.grid_line_color = "gray" + UI["p"].xgrid.grid_line_color = "gray" + UI["p"].ygrid.grid_line_alpha = 0.6 + UI["p"].xgrid.grid_line_alpha = 0.6 + else: + UI["p"].ygrid.grid_line_alpha = 0 + UI["p"].xgrid.grid_line_alpha = 0 + + +def cmap_select_cb(attr: str, old: str, new: str) -> None: # noqa: ARG001 + """Callback to change the color map.""" + if not (UI["vstate"].is_categorical and new != "dict"): + update_renderer("mapper", new) + UI["vstate"].update_state = 1 + UI["vstate"].to_update.update(["overlay"]) + + +def blur_spinner_cb(attr: str, old: float, new: float) -> None: # noqa: ARG001 + """Callback to change the blur radius.""" + update_renderer("blur_radius", new) + UI["vstate"].update_state = 1 + UI["vstate"].to_update.update(["overlay"]) + + +def scale_spinner_cb(attr: str, old: float, new: float) -> None: # noqa: ARG001 + """Callback to change the max scale. + + This defines a scale above which small annotations are + no longer diplayed. + + """ + update_renderer("max_scale", new) + UI["vstate"].update_state = 1 + UI["vstate"].to_update.update(["overlay"]) + + +def slide_select_cb(attr: str, old: str, new: str) -> None: # noqa: ARG001 + """Set up the newly chosen slide.""" + if len(new) == 0: + return + slide_path = Path(doc_config["slide_folder"]) / Path(new[0]) + # Reset the data sources for glyph overlays + UI["pt_source"].data = {"x": [], "y": []} + UI["box_source"].data = {"x": [], "y": [], "width": [], "height": []} + UI["node_source"].data = {"x_": [], "y_": [], "node_color_": []} + UI["edge_source"].data = {"x0_": [], "y0_": [], "x1_": [], "y1_": []} + UI["hover"].tooltips = None + if len(UI["p"].renderers) > N_PERMANENT_RENDERERS: + for r in UI["p"].renderers[N_PERMANENT_RENDERERS:].copy(): + UI["p"].renderers.remove(r) + UI["vstate"].layer_dict = {"slide": 0, "rect": 1, "pts": 2, "nodes": 3, "edges": 4} + UI["vstate"].slide_path = slide_path + UI["color_column"].children = [] + UI["type_column"].children = [] + logger.warning("loading %s", slide_path, stacklevel=2) + populate_layer_list(slide_path.stem, doc_config["overlay_folder"]) + UI["vstate"].wsi = WSIReader.open(slide_path) + initialise_slide() + fname = make_safe_name(str(slide_path)) + UI["s"].put(f"http://{host2}:5000/tileserver/slide", data={"slide_path": fname}) + change_tiles("slide") + + # Load the overlay and graph automatically if set in config + if doc_config["auto_load"]: + for f in UI["layer_drop"].menu: + dummy_attr = DummyAttr(f[0]) + layer_drop_cb(dummy_attr) + + +def handle_graph_layer(attr: MenuItemClick) -> None: # skipcq: PY-R1000 + """Handle adding a graph layer.""" + do_feats = False + with Path(attr.item).open("rb") as f: + graph_dict = json.load(f) + # Convert the values to numpy arrays + for k, v in graph_dict.items(): + if isinstance(v, list): + graph_dict[k] = np.array(v) + node_cm = colormaps["viridis"] + num_nodes = graph_dict["coordinates"].shape[0] + if "score" in graph_dict: + UI["node_source"].data = { + "x_": graph_dict["coordinates"][:, 0], + "y_": -graph_dict["coordinates"][:, 1], + "node_color_": [rgb2hex(node_cm(to_num(v))) for v in graph_dict["score"]], + } + else: + # Default to green + UI["node_source"].data = { + "x_": graph_dict["coordinates"][:, 0], + "y_": -graph_dict["coordinates"][:, 1], + "node_color_": [rgb2hex((0, 1, 0))] * num_nodes, + } + UI["edge_source"].data = { + "x0_": [ + graph_dict["coordinates"][i, 0] for i in graph_dict["edge_index"][0, :] + ], + "y0_": [ + -graph_dict["coordinates"][i, 1] for i in graph_dict["edge_index"][0, :] + ], + "x1_": [ + graph_dict["coordinates"][i, 0] for i in graph_dict["edge_index"][1, :] + ], + "y1_": [ + -graph_dict["coordinates"][i, 1] for i in graph_dict["edge_index"][1, :] + ], + } + add_layer("edges") + add_layer("nodes") + change_tiles("graph") + if "graph_overlay" not in UI["type_cmap_select"].options: + UI["type_cmap_select"].options = [ + *UI["type_cmap_select"].options, + "graph_overlay", + ] + + # Add additional data to graph datasource + for key in graph_dict: + if key == "feat_names": + graph_feat_names = graph_dict[key] + do_feats = True + elif ( + key not in ["edge_index", "coordinates"] + and hasattr(graph_dict[key], "__len__") + and len(graph_dict[key]) == num_nodes + ): + # Valid form to add to node data + UI["node_source"].data[key] = graph_dict[key] + + if do_feats: + # Set up the node hover tooltips to show feats + for i in range(min(graph_dict["feats"].shape[1], MAX_FEATS)): + # Too many won't really fit in hover tool, ignore rest + UI["node_source"].data[graph_feat_names[i]] = graph_dict["feats"][:, i] + + tooltips = [ + ("Index", "$index"), + ("(x,y)", "($x, $y)"), + ] + tooltips.extend( + [ + (graph_feat_names[i], f"@{graph_feat_names[i]}") + for i in range(np.minimum(graph_dict["feats"].shape[1], 9)) + ], + ) + UI["hover"].tooltips = tooltips + + +def update_ui_on_new_annotations(ann_types: list[str]) -> None: + """Update the UI when new annotations are added.""" + UI["vstate"].types = ann_types + props = UI["s"].get(f"http://{host2}:5000/tileserver/prop_names/all") + UI["vstate"].props = json.loads(props.text) + # Update the color type by prop menu + UI["type_cmap_select"].options = list(UI["vstate"].types) + if len(UI["node_source"].data["x_"]) > 0: + UI["type_cmap_select"].options.append("graph_overlay") + # Update the color type by prop menu + UI["type_cmap_select"].options = list(UI["vstate"].types) + if len(UI["node_source"].data["x_"]) > 0: + UI["type_cmap_select"].options.append("graph_overlay") + UI["cprop_input"].options = UI["vstate"].props + UI["cprop_input"].options.append("None") + if UI["vstate"].props != UI["vstate"].props_old: + # If color by prop no longer exists, reset to type + if ( + len(UI["cprop_input"].value) == 0 + or UI["cprop_input"].value[0] not in UI["vstate"].props + ): + UI["cprop_input"].value = ["type"] + UI["vstate"].props_old = UI["vstate"].props + cmap = get_mapper_for_prop(UI["cprop_input"].value[0]) + update_renderer("mapper", cmap) + + initialise_overlay() + change_tiles("overlay") + + +def layer_drop_cb(attr: MenuItemClick) -> None: + """Set up the newly chosen overlay.""" + if Path(attr.item).suffix == ".json": + # It's a graph + handle_graph_layer(attr) + return + + # Otherwise it's a tile-based overlay of some form + fname = make_safe_name(attr.item) + resp = UI["s"].put( + f"http://{host2}:5000/tileserver/overlay", + data={"overlay_path": fname}, + ) + resp = json.loads(resp.text) + + if Path(attr.item).suffix in [".db", ".dat", ".geojson"]: + update_ui_on_new_annotations(resp) + else: + add_layer(resp) + change_tiles(resp) + + +def layer_select_cb(attr: ButtonClick) -> None: # noqa: ARG001 + """Callback to handle toggling specific annotation types on and off.""" + build_predicate() + UI["vstate"].update_state = 1 + UI["vstate"].to_update.update(["overlay"]) + + +def fixed_layer_select_cb(obj: Button, attr: ButtonClick) -> None: # noqa: ARG001 + """Callback to handle toggling non-annotation layers on and off.""" + key = UI["vstate"].layer_dict[obj.label] + if obj.label == "edges": + if not UI["p"].renderers[key].visible: + UI["p"].renderers[key].visible = True + else: + UI["p"].renderers[key].visible = False + elif obj.label == "nodes": + if UI["p"].renderers[key].glyph.fill_alpha == 0: + UI["p"].renderers[key].glyph.fill_alpha = UI["overlay_alpha"].value + UI["p"].renderers[key].glyph.line_alpha = UI["overlay_alpha"].value + else: + UI["p"].renderers[key].glyph.fill_alpha = 0.0 + UI["p"].renderers[key].glyph.line_alpha = 0.0 + elif UI["p"].renderers[key].alpha == 0: + UI["p"].renderers[key].alpha = float(obj.name) + else: + obj.name = str(UI["p"].renderers[key].alpha) # save old alpha + UI["p"].renderers[key].alpha = 0.0 + + +def layer_slider_cb( + obj: Slider, + attr: str, # noqa: ARG001 + old: float, # noqa: ARG001 + new: float, +) -> None: + """Callback to handle changing the alpha of a layer.""" + if obj.name.split("_")[0] == "nodes": + set_alpha_glyph( + UI["p"].renderers[UI["vstate"].layer_dict[obj.name.split("_")[0]]].glyph, + new, + ) + elif obj.name.split("_")[0] == "edges": + UI["p"].renderers[ + 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 + + +def color_input_cb( + obj: ColorPicker, + attr: str, # noqa: ARG001 + old: str, # noqa: ARG001 + new: str, +) -> None: + """Callback to handle changing the color of an annotation type.""" + UI["vstate"].mapper[UI["vstate"].orig_types[obj.name]] = (*hex2rgb(new), 1) + if UI["vstate"].cprop == "type": + update_renderer("mapper", UI["vstate"].mapper) + UI["vstate"].update_state = 1 + UI["vstate"].to_update.update(["overlay"]) + + +def bind_cb_obj(cb_obj: Model, cb: Callable[[Model, str, Any, Any], None]) -> Callable: + """Wrapper to bind a callback to a bokeh object.""" + + def wrapped(attr: str, old: Any, new: Any) -> None: # noqa: ANN401 + """Wrapper function.""" + cb(cb_obj, attr, old, new) + + return wrapped + + +def bind_cb_obj_tog(cb_obj: Model, cb: Callable[[Model, Any], None]) -> Callable: + """Wrapper to bind a callback to a bokeh toggle object.""" + + def wrapped(attr: ButtonClick) -> None: + """Wrapper function.""" + cb(cb_obj, attr) + + return wrapped + + +def model_drop_cb(attr: str, old: str, new: str) -> None: # noqa: ARG001 + """Callback to handle model selection.""" + UI["vstate"].current_model = new + + +def to_model_cb(attr: ButtonClick) -> None: # noqa: ARG001 + """Callback to run currently selected model.""" + if UI["vstate"].current_model == "hovernet": + segment_on_box() + # Add any other models here + else: # pragma: no cover + logger.warning("unknown model") + + +def type_cmap_cb(attr: str, old: list[str], new: list[str]) -> None: # noqa: ARG001 + """Callback to handle changing a type-specific color property.""" + if len(new) == 0: + # Remove type-specific coloring + UI["type_cmap_select"].options = [*UI["vstate"].types, "graph_overlay"] + UI["s"].put( + f"http://{host2}:5000/tileserver/secondary_cmap", + data={ + "type_id": json.dumps("None"), + "prop": "None", + "cmap": json.dumps("viridis"), + }, + ) + UI["vstate"].update_state = 1 + UI["vstate"].to_update.update(["overlay"]) + return + if len(new) == 1: + # Find out what still has to be selected + if new[0] in [*UI["vstate"].types, "graph_overlay"]: + if new[0] == "graph_overlay": + UI["type_cmap_select"].options = [ + key + for key in UI["node_source"].data + if key not in ["x_", "y_", "node_color_"] + ] + [new[0]] + else: + UI["type_cmap_select"].options = [*UI["vstate"].props, new[0]] + else: + UI["type_cmap_select"].options = [ + *UI["vstate"].types, + new[0], + "graph_overlay", + ] + else: + # Both are selected, update the renderer + if new[1] in UI["vstate"].types: + # Make sure the type is the first one + UI["type_cmap_select"].value = [new[1], new[0]] + return + if new[0] == "graph_overlay": + # Adjust the node color in source if prop exists + if new[1] in UI["node_source"].data: + node_cm = colormaps["viridis"] + UI["node_source"].data["node_color_"] = [ + rgb2hex(node_cm(to_num(v))) for v in UI["node_source"].data[new[1]] + ] + return + cmap = get_mapper_for_prop(new[1]) # separate cmap select ? + UI["s"].put( + f"http://{host2}:5000/tileserver/secondary_cmap", + data={ + "type_id": json.dumps(UI["vstate"].orig_types.get(new[0], new[0])), + "prop": new[1], + "cmap": json.dumps(cmap), + }, + ) + + UI["color_bar"].color_mapper.palette = make_color_seq_from_cmap( + colormaps["viridis"], + ) + UI["color_bar"].visible = True + UI["vstate"].update_state = 1 + UI["vstate"].to_update.update(["overlay"]) + + +def save_cb(attr: ButtonClick) -> None: # noqa: ARG001 + """Callback to handle saving annotations.""" + save_path = make_safe_name( + str( + doc_config["overlay_folder"] + / (UI["vstate"].slide_path.stem + "_saved_anns.db"), + ), + ) + UI["s"].post( + f"http://{host2}:5000/tileserver/commit", + data={"save_path": save_path}, + ) + + +def tap_event_cb(event: DoubleTap) -> None: + """Callback to handle double tap events to inspect annotations.""" + resp = UI["s"].get(f"http://{host2}:5000/tileserver/tap_query/{event.x}/{-event.y}") + data_dict = json.loads(resp.text) + + popup_table.source.data = { + "property": list(data_dict.keys()), + "value": list(data_dict.values()), + } + + +def segment_on_box() -> None: + """Callback to run hovernet on a region of the slide. + + Will run NucleusInstanceSegmentor on selected region of wsi defined + by the box in box_source. + + """ + # Make a mask defining the box + thumb = UI["vstate"].wsi.slide_thumbnail() + conv_mpp = UI["vstate"].dims[0] / thumb.shape[1] + msg = f'box tl: {UI["box_source"].data["x"][0]}, {UI["box_source"].data["y"][0]}' + logger.info(msg) + x = round( + (UI["box_source"].data["x"][0] - 0.5 * UI["box_source"].data["width"][0]) + / conv_mpp, + ) + y = -round( + (UI["box_source"].data["y"][0] + 0.5 * UI["box_source"].data["height"][0]) + / conv_mpp, + ) + width = round(UI["box_source"].data["width"][0] / conv_mpp) + height = round(UI["box_source"].data["height"][0] / conv_mpp) + + mask = np.zeros((thumb.shape[0], thumb.shape[1]), dtype=np.uint8) + mask[y : y + height, x : x + width] = 1 + + inst_segmentor = NucleusInstanceSegmentor( + pretrained_model="hovernet_fast-pannuke", + num_loader_workers=4, + num_postproc_workers=8, + batch_size=24, + ) + tmp_save_dir = Path(tempfile.mkdtemp()) + tmp_mask_dir = Path(tempfile.mkdtemp()) + Image.fromarray(mask).save(tmp_mask_dir / "mask.png") + + # Run hovernet inside the box + UI["vstate"].model_mpp = inst_segmentor.ioconfig.save_resolution["resolution"] + inst_segmentor.predict( + [UI["vstate"].slide_path], + [tmp_mask_dir / "mask.png"], + save_dir=tmp_save_dir / "hover_out", + mode="wsi", + on_gpu=torch.cuda.is_available(), + crash_on_exception=True, + ) + + fname = make_safe_name(tmp_save_dir / "hover_out" / "0.dat") + resp = UI["s"].put( + f"http://{host2}:5000/tileserver/annotations", + data={"file_path": fname, "model_mpp": json.dumps(UI["vstate"].model_mpp)}, + ) + ann_types = json.loads(resp.text) + update_ui_on_new_annotations(ann_types) + + # Clean up temp files + rmtree(tmp_save_dir) + rmtree(tmp_mask_dir) + + +# endregion + +# Set up main window +slide_wins = row( + children=[], + name="slide_windows", + sizing_mode="stretch_both", +) +# and the controls +control_tabs = Tabs(tabs=[], name="ui_layout") +# Slide info div +slide_info = Div( + text="", + name="description", + width=800, + height=200, + sizing_mode="stretch_width", +) + + +def gather_ui_elements( # noqa: PLR0915 + vstate: ViewerState, + win_num: int, +) -> tuple[Column, Column, dict]: + """Gather all the ui elements into a dict. + + Defines and gathers the main UI elements for a window, excluding any + elements that have been deactivated in the config file. + + Args: + vstate: the ViewerState object for the window + win_num: the window number (0 or 1) + + Returns: + A tuple containing the layouts for the main and extra options tabs of the UI, + and a dict containing all the UI elements for ease of acess. + + """ + # Define all the various widgets + res_switch = RadioButtonGroup(labels=["1x", "2x"], active=1, name=f"res{win_num}") + + slide_alpha = Slider( + title="Slide Alpha", + start=0, + end=1, + step=0.05, + value=1.0, + width=200, + sizing_mode="stretch_width", + name=f"slide_alpha{win_num}", + ) + + overlay_alpha = Slider( + title="Overlay Alpha", + start=0, + end=1, + step=0.05, + value=0.75, + width=200, + sizing_mode="stretch_width", + name=f"overlay_alpha{win_num}", + ) + + edge_size_spinner = Spinner( + title="Edge thickness:", + low=0, + high=10, + step=1, + value=1, + width=60, + height=50, + sizing_mode="stretch_width", + name=f"edge_size{win_num}", + ) + + pt_size_spinner = Spinner( + title="Pt. Size:", + low=0, + high=20, + step=1, + value=4, + width=60, + height=50, + sizing_mode="stretch_width", + name=f"pt_size{win_num}", + ) + + slide_toggle = Toggle( + label="Slide", + active=True, + button_type="success", + width=90, + sizing_mode="stretch_width", + name=f"slide_toggle{win_num}", + ) + overlay_toggle = Toggle( + label="Overlay", + active=True, + button_type="success", + width=90, + sizing_mode="stretch_width", + name=f"overlay_toggle{win_num}", + ) + filter_tooltip = Tooltip( + content=HTML( + """Enter a filter string that is a valid string for AnnotationStore + 'where' argument. It will be used to filter the annotations displayed. +
E.g: props['prob']>0.5 + """, + ), + position="right", + css_classes=["help_tt"], + stylesheets=[help_ss], + ) + filter_input = TextInput( + value="None", + title="Filter:", + sizing_mode="stretch_width", + name=f"filter{win_num}", + description=filter_tooltip, + ) + cprop_tooltip = Tooltip( + content="Choose a property to color annotations by", + position="right", + ) + cprop_input = MultiChoice( + title="color by:", + max_items=1, + options=[get_from_config(["default_cprop"], "type")], + value=[get_from_config(["default_cprop"], "type")], + search_option_limit=5000, + sizing_mode="stretch_width", + name=f"cprop{win_num}", + description=cprop_tooltip, + ) + slide_tt = Tooltip( + content=HTML( + """Select a slide. Overlays whose filenames contain the slide stem + will be available below.""", + ), + position="right", + css_classes=["help_tt"], + stylesheets=[help_ss], + ) + slide_select = MultiChoice( + title="Select Slide:", + max_items=1, + options=[get_from_config(["first_slide"], "*")], + value=[get_from_config(["first_slide"], "*")], + search_option_limit=5000, + sizing_mode="stretch_width", + name=f"slide_select{win_num}", + description=slide_tt, + ) + cmmenu = [ + ("jet", "jet"), + ("coolwarm", "coolwarm"), + ("viridis", "viridis"), + ("dict", "dict"), + ] + cmap_tooltip = Tooltip( + content=HTML( + """Choose a colormap. If the property being colored by is categorical, + dict should be used.""", + ), + position="right", + css_classes=["help_tt"], + stylesheets=[help_ss], + ) + cmap_select = Select( + title="Cmap", + options=cmmenu, + width=60, + value=get_from_config(["UI_settings", "mapper"], "coolwarm"), + height=45, + sizing_mode="stretch_width", + name=f"cmap{win_num}", + description=cmap_tooltip, + ) + blur_spinner = Spinner( + title="Blur:", + low=0, + high=20, + step=1, + value=get_from_config(["UI_settings", "blur_radius"], 0), + width=60, + height=50, + sizing_mode="stretch_width", + name=f"blur{win_num}", + ) + scale_tt = Tooltip( + content=HTML( + """Controls scale at which small annotations are no longer shown. Smaller + values -> small objects will only appear when zoomed in.""", + ), + position="right", + css_classes=["help_tt"], + stylesheets=[help_ss], + ) + scale_spinner = Spinner( + title="max scale:", + low=0, + high=540, + step=8, + value=get_from_config(["UI_settings", "max_scale"], 16), + width=60, + height=50, + sizing_mode="stretch_width", + name=f"scale{win_num}", + description=scale_tt, + ) + to_model_button = Button( + label="Run", + button_type="success", + width=80, + max_width=90, + height=35, + sizing_mode="stretch_width", + name=f"to_model{win_num}", + ) + model_tt = Tooltip( + content=HTML("""Must select a region before running model"""), + position="right", + css_classes=["help_tt"], + stylesheets=[help_ss], + ) + model_drop = Select( + title="choose model:", + options=["hovernet"], + height=25, + width=120, + max_width=120, + sizing_mode="stretch_width", + name=f"model_drop{win_num}", + description=model_tt, + ) + save_button = Button( + label="Save", + button_type="success", + max_width=90, + width=80, + height=35, + sizing_mode="stretch_width", + name=f"save_button{win_num}", + ) + type_cprop_tt = Tooltip( + content=HTML( + """Select a type of object, and a property to color by. Objects of + selected type will be colored by the selected property. + This will override the global 'color by' property for that type.""", + ), + position="right", + css_classes=["help_tt"], + stylesheets=[help_ss], + ) + type_cmap_select = MultiChoice( + title="color type by property:", + max_items=2, + options=["*"], + search_option_limit=5000, + sizing_mode="stretch_width", + name=f"type_cmap{win_num}", + description=type_cprop_tt, + ) + layer_boxes = [ + Toggle( + label=t, + active=True, + width=100, + max_width=100, + sizing_mode="stretch_width", + ) + for t in vstate.types + ] + lcolors = [ + ColorPicker(color=col[0:3], width=60, max_width=60, sizing_mode="stretch_width") + for col in vstate.colors + ] + layer_drop = Dropdown( + label="Add Overlay", + button_type="warning", + menu=[None], + sizing_mode="stretch_width", + name=f"layer_drop{win_num}", + ) + opt_buttons = CheckboxButtonGroup( + labels=["Filled", "Microns", "Grid"], + active=[0], + sizing_mode="stretch_width", + name=f"opt_buttons{win_num}", + ) + + # Associate callback functions to the widgets + slide_alpha.on_change("value", slide_alpha_cb) + overlay_alpha.on_change("value", overlay_alpha_cb) + res_switch.on_change("active", res_switch_cb) + pt_size_spinner.on_change("value", pt_size_cb) + edge_size_spinner.on_change("value", edge_size_cb) + slide_select.on_change("value", slide_select_cb) + save_button.on_click(save_cb) + cmap_select.on_change("value", cmap_select_cb) + blur_spinner.on_change("value", blur_spinner_cb) + scale_spinner.on_change("value", scale_spinner_cb) + to_model_button.on_click(to_model_cb) + model_drop.on_change("value", model_drop_cb) + layer_drop.on_click(layer_drop_cb) + opt_buttons.on_change("active", opt_buttons_cb) + slide_toggle.on_click(slide_toggle_cb) + overlay_toggle.on_click(overlay_toggle_cb) + filter_input.on_change("value", filter_input_cb) + cprop_input.on_change("value", cprop_input_cb) + type_cmap_select.on_change("value", type_cmap_cb) + + # Create some layouts + type_column = column(children=layer_boxes, name=f"type_column{win_num}") + color_column = column( + children=lcolors, + sizing_mode="stretch_width", + name=f"color_column{win_num}", + ) + + slide_row = row([slide_toggle, slide_alpha], sizing_mode="stretch_width") + overlay_row = row([overlay_toggle, overlay_alpha], sizing_mode="stretch_width") + cmap_row = row( + [cmap_select, scale_spinner, blur_spinner], + sizing_mode="stretch_width", + ) + model_row = row( + [to_model_button, save_button, model_drop], + sizing_mode="stretch_width", + ) + type_select_row = row( + children=[type_column, color_column], + sizing_mode="stretch_width", + ) + + # Make element dictionaries + ui_elements_1 = dict( + zip( + [ + "slide_select", + "layer_drop", + "slide_row", + "overlay_row", + "filter_input", + "cprop_input", + "cmap_row", + "type_cmap_select", + "model_row", + "type_select_row", + ], + [ + slide_select, + layer_drop, + slide_row, + overlay_row, + filter_input, + cprop_input, + cmap_row, + type_cmap_select, + model_row, + type_select_row, + ], + ), + ) + if "ui_elements_1" in doc_config: + # Only add the elements specified in config file + ui_layout = column( + [ + ui_elements_1[el] + for el in doc_config["ui_elements_1"] + if doc_config["ui_elements_1"][el] == 1 + ], + sizing_mode="stretch_width", + ) + else: + ui_layout = column( + list(ui_elements_1.values()), + sizing_mode="stretch_width", + ) + + # Elements in the secondary controls tab + ui_elements_2 = dict( + zip( + [ + "opt_buttons", + "pt_size_spinner", + "edge_size_spinner", + "res_switch", + ], + [ + opt_buttons, + pt_size_spinner, + edge_size_spinner, + res_switch, + ], + ), + ) + if "ui_elements_2" in doc_config: + # Only add the elements specified in config file + extra_options = column( + [ + ui_elements_2[el] + for el in doc_config["ui_elements_2"] + if doc_config["ui_elements_2"][el] == 1 + ], + ) + else: + extra_options = column( + list(ui_elements_2.values()), + ) + # Put everything together + elements_dict = { + **ui_elements_1, + **ui_elements_2, + "color_column": color_column, + "type_column": type_column, + "overlay_alpha": overlay_alpha, + "cmap_select": cmap_select, + "slide_alpha": slide_alpha, + } + + return ui_layout, extra_options, elements_dict + + +def make_window(vstate: ViewerState) -> dict: # noqa: PLR0915 + """Make a new window for a slide. + + Creates a new window for the slide, including all the UI elements and + the main viewing window. + + Args: + vstate: the ViewerState object for the window + Returns: + A dict containing the UI elements and other elements associated with the + window that we may need to reference, for ease of access. + + """ + win_num = str(len(windows)) + if len(windows) == 1: + slide_wins.children[0].width = 800 + p = figure( + x_range=slide_wins.children[0].x_range, + y_range=slide_wins.children[0].y_range, + x_axis_type="linear", + y_axis_type="linear", + width=800, + height=1000, + tools=tool_str, + active_scroll="wheel_zoom", + output_backend="webgl", + hidpi=True, + match_aspect=False, + lod_factor=200000, + sizing_mode="stretch_both", + name=f"slide_window{win_num}", + ) + init_z = first_z[0] + else: + p = figure( + x_range=(0, vstate.dims[0]), + y_range=(0, vstate.dims[1]), + x_axis_type="linear", + y_axis_type="linear", + width=1700, + height=1000, + tools=tool_str, + active_scroll="wheel_zoom", + output_backend="webgl", + hidpi=True, + match_aspect=False, + lod_factor=200000, + sizing_mode="stretch_both", + name=f"slide_window{win_num}", + ) + init_z = get_level_by_extent((0, p.y_range.start, p.x_range.end, 0)) + first_z[0] = init_z + p.axis.visible = False + p.toolbar.tools[1].zoom_on_axis = False + + # Tap query popup callbacks + js_popup_code = """ + var popupContent = document.querySelector('.popup-content'); + if (popupContent.classList.contains('hidden')) { + popupContent.classList.remove('hidden'); + } + """ + p.on_event(DoubleTap, tap_event_cb) + p.js_on_event(DoubleTap, CustomJS(code=js_popup_code)) + + # Set up a session for communicating with tile server + s = requests.Session() + retries = Retry( + total=5, + backoff_factor=0.1, + ) + s.mount("http://", HTTPAdapter(max_retries=retries)) + + resp = s.get(f"http://{host2}:5000/tileserver/session_id") + user = resp.cookies.get("session_id") + if curdoc().session_context: + curdoc().session_context.request.arguments["user"] = user + + # Set up the main slide window + vstate.init_z = init_z + ts1 = make_ts( + f"http://{host}:{port}/tileserver/layer/slide/{user}/zoomify/TileGroup1" + r"/{z}-{x}-{y}" + f"@{vstate.res}x.jpg", + vstate.num_zoom_levels, + ) + p.add_tile(ts1, smoothing=True, level="image", render_parents=True) + + p.grid.grid_line_color = None + box_source = ColumnDataSource({"x": [], "y": [], "width": [], "height": []}) + pt_source = ColumnDataSource({"x": [], "y": []}) + r = p.rect("x", "y", "width", "height", source=box_source, fill_alpha=0) + c = p.circle("x", "y", source=pt_source, color="red", size=5) + p.add_tools(BoxEditTool(renderers=[r], num_objects=1)) + p.add_tools(PointDrawTool(renderers=[c])) + p.add_tools(TapTool()) + if get_from_config(["opts", "hover_on"], 0) == 0: + p.toolbar.active_inspect = None + + p.renderers[0].tile_source.max_zoom = 10 + + # Add graph stuff + node_source = ColumnDataSource({"x_": [], "y_": [], "node_color_": []}) + edge_source = ColumnDataSource({"x0_": [], "y0_": [], "x1_": [], "y1_": []}) + vstate.graph_node = Circle(x="x_", y="y_", fill_color="node_color_", size=5) + vstate.graph_edge = Segment(x0="x0_", y0="y0_", x1="x1_", y1="y1_") + p.add_glyph(node_source, vstate.graph_node) + node_source.selected.on_change("indices", node_select_cb) + if not get_from_config(["opts", "nodes_on"], default=True): + p.renderers[-1].glyph.fill_alpha = 0 + p.renderers[-1].glyph.line_alpha = 0 + p.add_glyph(edge_source, vstate.graph_edge) + if not get_from_config(["opts", "edges_on"], default=False): + p.renderers[-1].visible = False + vstate.layer_dict["nodes"] = len(p.renderers) - 2 + vstate.layer_dict["edges"] = len(p.renderers) - 1 + hover = HoverTool(renderers=[p.renderers[-2]]) + p.add_tools(hover) + + color_bar = ColorBar( + color_mapper=LinearColorMapper( + make_color_seq_from_cmap(colormaps["viridis"]), + ), + label_standoff=12, + ) + if get_from_config(["opts", "colorbar_on"], 1) == 1: + p.add_layout(color_bar, "below") + vstate.cprop = get_from_config(["default_cprop"], "type") + + # Define UI elements + ui_layout, extra_options, elements_dict = gather_ui_elements(vstate, win_num) + + if len(windows) == 0: + # Setting up the first window + controls.append( + TabPanel( + child=Tabs( + tabs=[ + TabPanel(child=ui_layout, title="Main"), + TabPanel(child=extra_options, title="More Opts"), + ], + ), + title="window 1", + ), + ) + controls.append(TabPanel(child=Div(), title="window 2")) + windows.append(p) + else: + # Setting up a dual window + control_tabs.tabs[1] = TabPanel( + child=Tabs( + tabs=[ + TabPanel(child=ui_layout, title="Main"), + TabPanel(child=extra_options, title="More Opts"), + ], + ), + title="window 2", + closable=True, + ) + slide_wins.children.append(p) + + # Return a dictionary collecting all the things related to window + return { + **elements_dict, + "p": p, + "vstate": vstate, + "s": s, + "box_source": box_source, + "pt_source": pt_source, + "node_source": node_source, + "edge_source": edge_source, + "hover": hover, + "user": user, + "color_bar": color_bar, + } + + +# Main ui containers +UI = UIWrapper() +windows = [] +controls = [] +win_dicts = [] + +# Popup for annotation viewing on double click +popup_div = Div( + width=300, + height=300, + name="popup_div", + text="test popup", +) +template_str = r"""<% if (typeof value === 'number' || !isNaN(parseFloat(value))) + { %> <%= parseFloat(value).toFixed(3) %> <% } + else { %> <%= value %> <% } %>""" +formatter = HTMLTemplateFormatter( + template=template_str, +) +popup_table = DataTable( + source=ColumnDataSource({"property": [], "value": []}), + columns=[ + TableColumn(field="property", title="Property"), + TableColumn( + field="value", + title="Value", + formatter=formatter, + ), + ], + index_position=None, + width=300, + height=300, + name="popup_window", +) + +# Some setup + +color_cycler = ColorCycler() +tg = TileGroup() +tool_str = "pan,wheel_zoom,reset,save,fullscreen" +req_args = [] +do_doc = False +if curdoc().session_context is not None: + req_args = curdoc().session_context.request.arguments + do_doc = True + +is_deployed = False +rand_id = token.generate_session_id() +first_z = [1] + +# Set hosts and ports +host = "127.0.0.1" +host2 = "127.0.0.1" +port = "5000" + + +def update() -> None: + """Callback to ensure tiles are updated when needed.""" + if UI["vstate"].update_state == DO_UPDATE: + for layer in UI["vstate"].to_update: + if layer in UI["vstate"].layer_dict: + change_tiles(layer) + UI["vstate"].update_state = NO_UPDATE + UI["vstate"].to_update = set() + if UI["vstate"].update_state == PENDING_UPDATE: + UI["vstate"].update_state = DO_UPDATE + + +def control_tabs_cb(attr: str, old: int, new: int) -> None: # noqa: ARG001 + """Callback to handle selecting active window.""" + if new == 1 and len(slide_wins.children) == 1: + # Make new window + win_dicts.append(make_window(ViewerState(win_dicts[0]["vstate"].slide_path))) + win_dicts[1]["vstate"].thickness = win_dicts[0]["vstate"].thickness + bounds = get_view_bounds( + UI["vstate"].dims, + np.array([UI["p"].width, UI["p"].height]), + ) + UI.active = new + setup_config_ui_settings(doc_config) + win_dicts[0]["vstate"].init_z = get_level_by_extent( + (0, bounds[2], bounds[1], 0), + ) + UI["vstate"].init = False + else: + UI.active = new + slide_info.text = format_info(UI["vstate"].wsi.info.as_dict()) + + +def control_tabs_remove_cb( + attr: str, # noqa: ARG001 + old: list[int], # noqa: ARG001 + new: list[int], +) -> None: + """Callback to handle removing a window.""" + if len(new) == 1: + # Remove the second window + slide_wins.children.pop() + slide_wins.children[0].width = 1700 # set back to original size + control_tabs.tabs.append(TabPanel(child=Div(), title="window 2")) + win_dicts.pop() + UI.active = 0 + + +def setup_config_ui_settings(config: dict) -> None: + """Set up the UI settings from the config file. + + Args: + config: a dictionary of configuration options + + """ + if "UI_settings" in config: + for k in config["UI_settings"]: + update_renderer(k, config["UI_settings"][k]) + if "default_cprop" in config and config["default_cprop"] is not None: + UI["s"].put( + f"http://{host2}:5000/tileserver/color_prop", + data={"prop": json.dumps(config["default_cprop"])}, + ) + # Open up initial slide + if "default_type_cprop" in config: + UI["type_cmap_select"].value = list( + doc_config["default_type_cprop"].values(), + ) + populate_slide_list(config["slide_folder"]) + UI["slide_select"].value = [str(UI["vstate"].slide_path.name)] + slide_select_cb(None, None, new=[UI["vstate"].slide_path.name]) + populate_layer_list( + Path(UI["vstate"].slide_path).stem, + doc_config["overlay_folder"], + ) + + +class DocConfig: + """class to configure and set up a document.""" + + def __init__(self: DocConfig) -> None: + """Initialise the class.""" + self.config = { + "color_dict": {}, + "initial_views": {}, + "default_cprop": "type", + "demo_name": "TIAvis", + "base_folder": Path("/app_data"), + "slide_folder": Path("/app_data").joinpath("slides"), + "overlay_folder": Path("/app_data").joinpath("overlays"), + } + self.sys_args = None + + def __getitem__(self: DocConfig, key: str) -> Any: # noqa: ANN401 + """Get an item from the config.""" + return self.config[key] + + def __contains__(self: DocConfig, key: str) -> bool: + """Check if a key is in the config.""" + return key in self.config + + def set_sys_args(self: DocConfig, argv: list[str]) -> None: + """Set the system arguments.""" + self.sys_args = argv + + def _get_config(self: DocConfig) -> None: + """Get config info from config.json and/or request args.""" + sys_args = self.sys_args + if len(sys_args) == 2 and sys_args[1] != "None": # noqa: PLR2004 + # Only base folder given + base_folder = Path(sys_args[1]) + if "demo" in req_args: + self.config["demo_name"] = str(req_args["demo"][0], "utf-8") + base_folder = base_folder.joinpath(str(req_args["demo"][0], "utf-8")) + sys_args[1] = base_folder.joinpath("slides") + sys_args.append(base_folder.joinpath("overlays")) + + slide_folder = Path(sys_args[1]) + base_folder = slide_folder.parent + overlay_folder = Path(sys_args[2]) + + # Load a color_dict and/or slide initial view windows from a json file + config_file = list(overlay_folder.glob("*config.json")) + config = self.config + if len(config_file) > 0: + config_file = config_file[0] + with config_file.open() as f: + config = json.load(f) + logger.info("loaded config: %s", config) + + config["base_folder"] = base_folder + config["slide_folder"] = slide_folder + config["overlay_folder"] = overlay_folder + config["demo_name"] = self.config["demo_name"] + if "initial_views" not in config: + config["initial_views"] = {} + + # Get any extra info from query url + if "slide" in req_args: + config["first_slide"] = str(req_args["slide"][0], "utf-8") + if "window" in req_args: + config["initial_views"][Path(config["first_slide"]).stem] = [ + int(s) for s in str(req_args["window"][0], "utf-8")[1:-1].split(",") + ] + self.config = config + self.config["auto_load"] = get_from_config(["auto_load"], 0) == 1 + + def setup_doc(self: DocConfig, base_doc: Document) -> tuple[Row, Tabs]: + """Set up the document. + + Args: + base_doc: the document to set up + Returns: + A tuple containing a layout of the main slide window(s), and + the controls tab. + + """ + self._get_config() + + # Set initial slide to first one in base folder + slide_list = [] + for ext in ["*.svs", "*ndpi", "*.tiff", "*.mrxs", "*.png", "*.jpg"]: + slide_list.extend(list(doc_config["slide_folder"].glob(ext))) + slide_list.extend( + list(doc_config["slide_folder"].glob(str(Path("*") / ext))), + ) + first_slide_path = slide_list[0] + if "first_slide" in self.config: + first_slide_path = self.config["slide_folder"] / self.config["first_slide"] + + # Make initial window + win_dicts.append(make_window(ViewerState(first_slide_path))) + # Set up any initial ui settings from config file + setup_config_ui_settings(self.config) + UI["vstate"].init = False + + # Set up main window + slide_wins.children = windows + control_tabs.tabs = controls + + control_tabs.on_change("active", control_tabs_cb) + control_tabs.on_change("tabs", control_tabs_remove_cb) + + # Add the window and controls etc. to the document + base_doc.template_variables["demo_name"] = doc_config["demo_name"] + base_doc.add_periodic_callback(update, 220) + base_doc.add_root(slide_wins) + base_doc.add_root(control_tabs) + base_doc.add_root(popup_table) + base_doc.add_root(slide_info) + base_doc.title = "Tiatoolbox Visualization Tool" + return slide_wins, control_tabs + + +doc_config = DocConfig() +if do_doc: + # Set up the document + doc_config.set_sys_args(sys.argv) + doc = curdoc() + slide_wins, control_tabs = doc_config.setup_doc(doc) diff --git a/tiatoolbox/visualization/bokeh_app/templates/index.html b/tiatoolbox/visualization/bokeh_app/templates/index.html new file mode 100644 index 000000000..863fd3a1f --- /dev/null +++ b/tiatoolbox/visualization/bokeh_app/templates/index.html @@ -0,0 +1,121 @@ +{% extends base %} + + +{% block preamble %} + +{% endblock %} + +{% block postamble %} + +{% endblock %} + + +{% block contents %} + +
{{ embed(roots.slide_windows) }}
{{ embed(roots.ui_layout) }}
+
{{ embed(roots.description) }}
+ +{% endblock %} diff --git a/tiatoolbox/visualization/tileserver.py b/tiatoolbox/visualization/tileserver.py index d5cc7c1be..b4b35223f 100644 --- a/tiatoolbox/visualization/tileserver.py +++ b/tiatoolbox/visualization/tileserver.py @@ -6,6 +6,8 @@ import json import os import secrets +import sys +import tempfile import urllib from pathlib import Path from typing import TYPE_CHECKING @@ -15,6 +17,7 @@ from flask.templating import render_template from matplotlib import colormaps from PIL import Image +from shapely.geometry import Point from tiatoolbox import data, logger from tiatoolbox.annotation import AnnotationStore, SQLiteStore @@ -152,12 +155,15 @@ def __init__( # noqa: PLR0915 self.route("/tileserver/color_prop", methods=["GET"])(self.get_color_prop) self.route("/tileserver/slide", methods=["GET"])(self.get_slide) self.route("/tileserver/cmap", methods=["GET"])(self.get_mapper) - self.route("/tileserver/annotations/", methods=["GET"])(self.get_annotations) + self.route("/tileserver/annotations", methods=["GET"])(self.get_annotations) self.route("/tileserver/overlay", methods=["GET"])(self.get_overlay) self.route("/tileserver/renderer/", methods=["GET"])(self.get_renderer) self.route("/tileserver/secondary_cmap", methods=["GET"])( self.get_secondary_cmap, ) + self.route("/tileserver/tap_query//")(self.tap_query) + self.route("/tileserver/prop_range", methods=["PUT"])(self.prop_range) + self.route("/tileserver/shutdown", methods=["POST"])(self.shutdown) def _get_session_id(self: TileServer) -> str: """Get the session_id from the request. @@ -282,11 +288,12 @@ def zoomify( """ try: pyramid = self.pyramids[session_id][layer] - interpolation = ( - "nearest" - if isinstance(self.layers[session_id][layer], VirtualWSIReader) - else "optimise" - ) + if isinstance(self.layers[session_id][layer], VirtualWSIReader): + interpolation = "nearest" + transparent_value = 0 + else: + interpolation = "optimise" + transparent_value = None if isinstance(pyramid, AnnotationTileGenerator): interpolation = None except KeyError: @@ -298,6 +305,7 @@ def zoomify( y=y, res=res, interpolation=interpolation, + transparent_value=transparent_value, ) except IndexError: return Response("Tile not found", status=404) @@ -315,7 +323,7 @@ def update_types(sq: SQLiteStore) -> tuple: @staticmethod def decode_safe_name(name: str) -> Path: - """Decode a url-safe name.""" + """Decode a URL-safe name.""" return Path(urllib.parse.unquote(name).replace("\\", os.sep)) def get_ann_layer( @@ -403,8 +411,11 @@ def change_slide(self: TileServer) -> str: def change_mapper(self: TileServer) -> str: """Change the colour mapper for the overlay.""" session_id = self._get_session_id() - cmap = request.form["cmap"] - self.renderers[session_id].mapper = json.loads(cmap) + cmap = json.loads(request.form["cmap"]) + if isinstance(cmap, dict): + cmap = dict(zip(cmap["keys"], cmap["values"])) + self.renderers[session_id].score_fn = lambda x: x + self.renderers[session_id].mapper = cmap self.renderers[session_id].function_mapper = None return "done" @@ -423,7 +434,12 @@ def change_secondary_cmap(self: TileServer) -> str: return "done" def update_renderer(self: TileServer, prop: str) -> str: - """Update a property in the renderer.""" + """Update a property in the renderer. + + Args: + prop (str): The property to update. + + """ session_id = self._get_session_id() val = request.form["val"] val = json.loads(val) @@ -442,6 +458,9 @@ def load_annotations(self: TileServer) -> str: Adds to an existing store if one is already present, otherwise creates a new store. + Returns: + str: A jsonified list of types. + """ session_id = self._get_session_id() file_path = request.form["file_path"] @@ -462,6 +481,9 @@ def load_annotations(self: TileServer) -> str: file_path, np.array(model_mpp) / np.array(self.slide_mpps[session_id]), ) + tmp_path = Path(tempfile.gettempdir()) / "temp.db" + sq.dump(tmp_path) + sq = SQLiteStore(tmp_path) self.pyramids[session_id]["overlay"] = AnnotationTileGenerator( self.layers[session_id]["slide"].info, sq, @@ -479,6 +501,9 @@ def change_overlay(self: TileServer) -> str: is replaced with the new one. If the path points to an image, it is added as a new layer. + Returns: + str: A jsonified list of types. + """ session_id = self._get_session_id() overlay_path = request.form["overlay_path"] @@ -506,8 +531,13 @@ def change_overlay(self: TileServer) -> str: sq = SQLiteStore.from_geojson(overlay_path) elif overlay_path.suffix == ".dat": sq = store_from_dat(overlay_path) - else: + if overlay_path.suffix == ".db": sq = SQLiteStore(overlay_path, auto_commit=False) + else: + # make a temporary db for the new annotations + tmp_path = Path(tempfile.gettempdir()) / "temp.db" + sq.dump(tmp_path) + sq = SQLiteStore(tmp_path) for layer in self.pyramids[session_id].values(): if isinstance(layer, AnnotationTileGenerator): @@ -530,7 +560,15 @@ def change_overlay(self: TileServer) -> str: return json.dumps(types) def get_properties(self: TileServer, ann_type: str) -> str: - """Get all the properties of the annotations in the store.""" + """Get all the properties of the annotations in the store. + + Args: + ann_type (str): The type of annotations to get the properties for. + + Returns: + str: A jsonified list of the properties. + + """ session_id = self._get_session_id() where = None if ann_type != "all": @@ -546,7 +584,15 @@ def get_properties(self: TileServer, ann_type: str) -> str: return json.dumps(list(set(props))) def get_property_values(self: TileServer, prop: str, ann_type: str) -> str: - """Get all the values of a property in the store.""" + """Get all the values of a property in the store. + + Args: + prop (str): The property to get the values of. + ann_type (str): The type of annotations to get the values for. + + Returns: + str: A jsonified list of the values of the property. + """ session_id = self._get_session_id() where = None if ann_type != "all": @@ -572,7 +618,10 @@ def commit_db(self: TileServer) -> str: save_path = self.decode_safe_name(save_path) for layer in self.pyramids[session_id].values(): if isinstance(layer, AnnotationTileGenerator): - if layer.store.path.suffix == ".db": + if ( + layer.store.path.suffix == ".db" + and layer.store.path.name != "temp.db" + ): logger.info("%s*.db committed.", layer.store.path.stem) layer.store.commit() else: @@ -631,3 +680,44 @@ def get_secondary_cmap(self: TileServer) -> Response: mapper = self.renderers[session_id].secondary_cmap mapper["mapper"] = mapper["mapper"].__class__.__name__ return jsonify(mapper) + + def tap_query(self: TileServer, x: float, y: float) -> Response: + """Query for annotations at a point. + + Args: + x (float): The x coordinate. + y (float): The y coordinate. + + Returns: + Response: The jsonified dict of the properties of the + smallest annotation returned from the query at the point. + + """ + session_id = self._get_session_id() + anns = self.get_ann_layer(session_id).store.query( + Point(x, y), + ) + if len(anns) == 0: + return json.dumps({}) + return jsonify(list(anns.values())[-1].properties) + + def prop_range(self: TileServer) -> str: + """Set the range which the color mapper will map to. + + It will create an appropriate function to map the range to the + range [0, 1], and set the renderers score_fn to this function. + + """ + session_id = self._get_session_id() + prop_range = json.loads(request.form["range"]) + if prop_range is None: + self.renderers[session_id].score_fn = lambda x: x + return "done" + minv, maxv = prop_range + self.renderers[session_id].score_fn = lambda x: (x - minv) / (maxv - minv) + return "done" + + @staticmethod + def shutdown() -> None: + """Shutdown the tileserver.""" + sys.exit() diff --git a/tiatoolbox/visualization/tileserver_api.yml b/tiatoolbox/visualization/tileserver_api.yml index 691d2c865..3f1081c14 100644 --- a/tiatoolbox/visualization/tileserver_api.yml +++ b/tiatoolbox/visualization/tileserver_api.yml @@ -151,7 +151,7 @@ paths: properties: cmap: type: string - description: The colormap, should be a JSON encoded matplotlib colormap name or dict of possible property values as keys, and RGBA tuples as values + description: The colormap, should be a JSON encoded matplotlib colormap name or dict containing list of possible property values under "keys", and list of RGBA tuples under "values". responses: '200': description: Successful response @@ -394,3 +394,31 @@ paths: responses: '200': description: Successful response + + /tileserver/tap_query/{x}/{y}: + get: + summary: Query annotation at a point + parameters: + - name: x + in: path + description: X coordinate of the point + required: true + schema: + type: number + - name: y + in: path + description: Y coordinate of the point + required: true + schema: + type: number + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + type: string + description: JSONified dictionary of annotation properties + example: '{"type": 0, "prob": 0.988}' diff --git a/tiatoolbox/visualization/ui_utils.py b/tiatoolbox/visualization/ui_utils.py new file mode 100644 index 000000000..0ca620ff4 --- /dev/null +++ b/tiatoolbox/visualization/ui_utils.py @@ -0,0 +1,30 @@ +"""Some utilities for bokeh ui.""" +from __future__ import annotations + +from cmath import pi + +import numpy as np + +scale_factor = 2 +init_res = 40211.5 * scale_factor * (2 / (100 * pi)) +min_zoom = 0 +max_zoom = 10 +resolutions = [init_res / 2**lev for lev in range(min_zoom, max_zoom + 1)] + + +def get_level_by_extent(extent: tuple[float, float, float, float]) -> int: + """Replicate the Bokeh tile renderer `get_level_by_extent` function.""" + x_rs = (extent[2] - extent[0]) / 1700 + y_rs = (extent[3] - extent[1]) / 1000 + resolution = np.maximum(x_rs, y_rs) + + i = 0 + for r in resolutions: + if resolution > r: + if i == 0: + return 0 + return i - 1 + i += 1 + + # Otherwise return the highest available resolution + return i - 1