From 9a7de488fd68e963a562adc7042d9dd2bfb61f45 Mon Sep 17 00:00:00 2001
From: measty <20169086+measty@users.noreply.github.com>
Date: Wed, 21 Feb 2024 16:32:14 +0000
Subject: [PATCH 01/24] add reading slide-level info from provided csv
---
tests/test_app_bokeh.py | 17 +++++++---
tiatoolbox/data/remote_samples.yaml | 2 ++
tiatoolbox/visualization/bokeh_app/main.py | 36 +++++++++++++++++++---
3 files changed, 47 insertions(+), 8 deletions(-)
diff --git a/tests/test_app_bokeh.py b/tests/test_app_bokeh.py
index b29d78188..b76f098c0 100644
--- a/tests/test_app_bokeh.py
+++ b/tests/test_app_bokeh.py
@@ -11,19 +11,19 @@
from pathlib import Path
from typing import TYPE_CHECKING
-import bokeh.models as bkmodels
import matplotlib.pyplot as plt
import numpy as np
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
+import bokeh.models as bkmodels
+from bokeh.application import Application
+from bokeh.application.handlers import FunctionHandler
+from bokeh.events import ButtonClick, DoubleTap, MenuItemClick
from tiatoolbox.data import _fetch_remote_sample
from tiatoolbox.visualization.bokeh_app import main
from tiatoolbox.visualization.tileserver import TileServer
@@ -101,6 +101,10 @@ def annotation_path(data_path: dict[str, Path]) -> dict[str, object]:
"patch-extraction-vf",
data_path["base_path"] / "slides",
)
+ data_path["meta"] = _fetch_remote_sample(
+ "test_meta",
+ data_path["base_path"] / "slides",
+ )
data_path["annotations"] = _fetch_remote_sample(
"annotation_store_svs_1",
data_path["base_path"] / "overlays",
@@ -210,6 +214,11 @@ def test_slide_select(doc: Document, data_path: pytest.TempPathFactory) -> None:
slide_select.value = ["CMU-1.ndpi"]
assert main.UI["vstate"].slide_path == data_path["slide2"]
+ # check the slide metadata is loaded from csv
+ desc = doc.get_model_by_name("description")
+ assert "valA" in desc.text
+ assert "valB" not in desc.text
+
# check selecting nothing has no effect
slide_select.value = []
assert main.UI["vstate"].slide_path == data_path["slide2"]
diff --git a/tiatoolbox/data/remote_samples.yaml b/tiatoolbox/data/remote_samples.yaml
index 364a5566e..b7fdae069 100644
--- a/tiatoolbox/data/remote_samples.yaml
+++ b/tiatoolbox/data/remote_samples.yaml
@@ -137,5 +137,7 @@ files:
url: [ *testdata, "annotation/test1_config.json"]
config_2:
url: [ *testdata, "annotation/test2_config.json"]
+ test_meta:
+ url: [ *testdata, "annotation/test_meta.csv"]
nuclick-output:
url: [*modelroot, "predictions/nuclei_mask/nuclick-output.npy"]
diff --git a/tiatoolbox/visualization/bokeh_app/main.py b/tiatoolbox/visualization/bokeh_app/main.py
index 0f29a4aea..4e83b97a8 100644
--- a/tiatoolbox/visualization/bokeh_app/main.py
+++ b/tiatoolbox/visualization/bokeh_app/main.py
@@ -12,8 +12,13 @@
from typing import TYPE_CHECKING, Any, Callable, SupportsFloat
import numpy as np
+import pandas as pd
import requests
import torch
+from matplotlib import colormaps
+from PIL import Image
+from requests.adapters import HTTPAdapter, Retry
+
from bokeh.events import ButtonClick, DoubleTap, MenuItemClick
from bokeh.io import curdoc
from bokeh.layouts import column, row
@@ -58,9 +63,6 @@
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))
@@ -131,9 +133,19 @@ def __getitem__(self: UIWrapper, key: str) -> Any: # noqa: ANN401
def format_info(info: dict[str, Any]) -> str:
"""Format the slide info for display."""
- info_str = f"Slide Name: {info.pop('file_path').name}
"
+ slide_name = info.pop("file_path").name
+ info_str = f"Slide Name: {slide_name}
"
for k, v in info.items():
info_str += f"{k}: {v}
"
+ # if there is metadata, add it
+ if doc_config.metadata is not None:
+ try:
+ row = doc_config.metadata.loc[slide_name]
+ info_str += "
Metadata:
"
+ for k, v in row.items():
+ info_str += f"{k}: {v}
"
+ except KeyError:
+ info_str += "
No metadata found.
"
return info_str
@@ -2070,6 +2082,22 @@ def setup_doc(self: DocConfig, base_doc: Document) -> tuple[Row, Tabs]:
"""
self._get_config()
+ # see if there's a metadata .csv file in slides folder
+ metadata_file = list(doc_config["slide_folder"].glob("*.csv"))
+ if len(metadata_file) > 0:
+ metadata_file = metadata_file[0]
+ with metadata_file.open() as f:
+ metadata = pd.read_csv(f, encoding="utf-8")
+ # must have an 'Image File' column to associate with slides
+ if "Image File" in metadata.columns:
+ metadata = metadata.set_index("Image File")
+ else:
+ # can't use it so set to None
+ metadata = None
+ else:
+ # no metadata file
+ metadata = None
+ self.metadata = metadata
# Set initial slide to first one in base folder
slide_list = []
From b811b6250297343e0f1ded8e0ee79e67326dc6d4 Mon Sep 17 00:00:00 2001
From: "pre-commit-ci[bot]"
<66853113+pre-commit-ci[bot]@users.noreply.github.com>
Date: Wed, 21 Feb 2024 16:40:49 +0000
Subject: [PATCH 02/24] [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
---
tests/test_app_bokeh.py | 8 ++++----
tiatoolbox/visualization/bokeh_app/main.py | 7 +++----
2 files changed, 7 insertions(+), 8 deletions(-)
diff --git a/tests/test_app_bokeh.py b/tests/test_app_bokeh.py
index b76f098c0..997a12f52 100644
--- a/tests/test_app_bokeh.py
+++ b/tests/test_app_bokeh.py
@@ -11,19 +11,19 @@
from pathlib import Path
from typing import TYPE_CHECKING
+import bokeh.models as bkmodels
import matplotlib.pyplot as plt
import numpy as np
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
-import bokeh.models as bkmodels
-from bokeh.application import Application
-from bokeh.application.handlers import FunctionHandler
-from bokeh.events import ButtonClick, DoubleTap, MenuItemClick
from tiatoolbox.data import _fetch_remote_sample
from tiatoolbox.visualization.bokeh_app import main
from tiatoolbox.visualization.tileserver import TileServer
diff --git a/tiatoolbox/visualization/bokeh_app/main.py b/tiatoolbox/visualization/bokeh_app/main.py
index 4e83b97a8..c8bf10fb0 100644
--- a/tiatoolbox/visualization/bokeh_app/main.py
+++ b/tiatoolbox/visualization/bokeh_app/main.py
@@ -15,10 +15,6 @@
import pandas as pd
import requests
import torch
-from matplotlib import colormaps
-from PIL import Image
-from requests.adapters import HTTPAdapter, Retry
-
from bokeh.events import ButtonClick, DoubleTap, MenuItemClick
from bokeh.io import curdoc
from bokeh.layouts import column, row
@@ -63,6 +59,9 @@
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 a692c590855f3c34e162de2d3e334c8acb1119cb Mon Sep 17 00:00:00 2001
From: measty <20169086+measty@users.noreply.github.com>
Date: Wed, 21 Feb 2024 16:56:54 +0000
Subject: [PATCH 03/24] add documentation entry
---
docs/visualization.rst | 8 ++++++--
tiatoolbox/visualization/bokeh_app/main.py | 5 +++--
2 files changed, 9 insertions(+), 4 deletions(-)
diff --git a/docs/visualization.rst b/docs/visualization.rst
index af237dfb6..41df6523e 100644
--- a/docs/visualization.rst
+++ b/docs/visualization.rst
@@ -218,6 +218,10 @@ Additional features can be added to nodes by adding extra keys to the dictionary
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.)
+Slide Level Information
+^^^^^^^^^^^^^^^^^^^^^^^
+
+If you have slide-level predictions, ground truth labels, or other metadata you wish to be able to see associated with slides in the interface, this can be provided as a .csv formatted table placed in the slides folder, with "Image File" as the first column. The other columns can be anything you like. When loading a side in the UI, if the slide name appears in the "image File" column of the provided .csv, any other entries in that row will be displayed in the interface below the main view window when the slide is selected.
.. _examples:
@@ -422,7 +426,7 @@ and the ability to toggle on or off specific UI elements:
::
- "UI_elements_1": { # controls which UI elements are visible
+ "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
@@ -437,7 +441,7 @@ and the ability to toggle on or off specific UI elements:
::
- "UI_elements_2": { # controls visible UI elements on second tab in UI
+ "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
diff --git a/tiatoolbox/visualization/bokeh_app/main.py b/tiatoolbox/visualization/bokeh_app/main.py
index 4e83b97a8..8aac20269 100644
--- a/tiatoolbox/visualization/bokeh_app/main.py
+++ b/tiatoolbox/visualization/bokeh_app/main.py
@@ -140,9 +140,9 @@ def format_info(info: dict[str, Any]) -> str:
# if there is metadata, add it
if doc_config.metadata is not None:
try:
- row = doc_config.metadata.loc[slide_name]
+ row_data = doc_config.metadata.loc[slide_name]
info_str += "
Metadata:
"
- for k, v in row.items():
+ for k, v in row_data.items():
info_str += f"{k}: {v}
"
except KeyError:
info_str += "
No metadata found.
"
@@ -2016,6 +2016,7 @@ def __init__(self: DocConfig) -> None:
"overlay_folder": Path("/app_data").joinpath("overlays"),
}
self.sys_args = None
+ self.metadata = None
def __getitem__(self: DocConfig, key: str) -> Any: # noqa: ANN401
"""Get an item from the config."""
From bcd5131509f785dcce8d1107b6fe4d33844ab9da Mon Sep 17 00:00:00 2001
From: measty <20169086+measty@users.noreply.github.com>
Date: Wed, 21 Feb 2024 17:00:01 +0000
Subject: [PATCH 04/24] fix typo
---
docs/visualization.rst | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/visualization.rst b/docs/visualization.rst
index 41df6523e..69d12a6ed 100644
--- a/docs/visualization.rst
+++ b/docs/visualization.rst
@@ -221,7 +221,7 @@ It will be possible to color the nodes by these features in the interface, and t
Slide Level Information
^^^^^^^^^^^^^^^^^^^^^^^
-If you have slide-level predictions, ground truth labels, or other metadata you wish to be able to see associated with slides in the interface, this can be provided as a .csv formatted table placed in the slides folder, with "Image File" as the first column. The other columns can be anything you like. When loading a side in the UI, if the slide name appears in the "image File" column of the provided .csv, any other entries in that row will be displayed in the interface below the main view window when the slide is selected.
+If you have slide-level predictions, ground truth labels, or other metadata you wish to be able to see associated with slides in the interface, this can be provided as a .csv formatted table placed in the slides folder, with "Image File" as the first column. The other columns can be anything you like. When loading a slide in the UI, if the slide name appears in the "Image File" column of the provided .csv, any other entries in that row will be displayed in the interface below the main view window when the slide is selected.
.. _examples:
From d0995cca79e4d1bba6a75c226b95f073c1d814b2 Mon Sep 17 00:00:00 2001
From: measty <20169086+measty@users.noreply.github.com>
Date: Wed, 21 Feb 2024 18:05:08 +0000
Subject: [PATCH 05/24] improve test coverage
---
tests/test_json_config_bokeh.py | 16 +++++++++++++++-
tiatoolbox/visualization/bokeh_app/main.py | 9 +++++----
2 files changed, 20 insertions(+), 5 deletions(-)
diff --git a/tests/test_json_config_bokeh.py b/tests/test_json_config_bokeh.py
index 4461cd431..cc70e5635 100644
--- a/tests/test_json_config_bokeh.py
+++ b/tests/test_json_config_bokeh.py
@@ -7,10 +7,11 @@
from threading import Thread
from typing import TYPE_CHECKING
+import pandas as pd
import pytest
import requests
-from bokeh.client.session import ClientSession, pull_session
+from bokeh.client.session import ClientSession, pull_session
from tiatoolbox.cli.visualize import run_bokeh, run_tileserver
from tiatoolbox.data import _fetch_remote_sample
@@ -29,6 +30,15 @@ def annotation_path(data_path: dict[str, Path]) -> dict[str, Path]:
"ndpi-1",
data_path["base_path"] / "slides",
)
+ data_path["meta"] = _fetch_remote_sample(
+ "test_meta",
+ data_path["base_path"] / "slides",
+ )
+ meta_df = pd.read_csv(data_path["meta"])
+ # change 'Image File' column name to 'Wrong Name'
+ meta_df = meta_df.rename(columns={"Image File": "Wrong Name"})
+ # save so we can test behaviour if required column isn't there
+ meta_df.to_csv(data_path["meta"], index=False)
data_path["annotations"] = _fetch_remote_sample(
"annotation_store_svs_1",
data_path["base_path"] / "overlays",
@@ -82,6 +92,10 @@ def test_slides_available(bk_session: ClientSession) -> None:
slide_select.value = ["CMU-1.ndpi"]
assert len(layer_drop.menu) == 2
+ # check the metadata wasnt found as the column name was wrong
+ desc = doc.get_model_by_name("description")
+ assert "Metadata:" not in desc.text
+
bk_session.document.clear()
assert len(bk_session.document.roots) == 0
bk_session.close()
diff --git a/tiatoolbox/visualization/bokeh_app/main.py b/tiatoolbox/visualization/bokeh_app/main.py
index aae4945bc..c792861f3 100644
--- a/tiatoolbox/visualization/bokeh_app/main.py
+++ b/tiatoolbox/visualization/bokeh_app/main.py
@@ -15,6 +15,10 @@
import pandas as pd
import requests
import torch
+from matplotlib import colormaps
+from PIL import Image
+from requests.adapters import HTTPAdapter, Retry
+
from bokeh.events import ButtonClick, DoubleTap, MenuItemClick
from bokeh.io import curdoc
from bokeh.layouts import column, row
@@ -59,9 +63,6 @@
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))
@@ -2101,7 +2102,7 @@ def setup_doc(self: DocConfig, base_doc: Document) -> tuple[Row, Tabs]:
# Set initial slide to first one in base folder
slide_list = []
- for ext in ["*.svs", "*ndpi", "*.tiff", "*.mrxs", "*.png", "*.jpg"]:
+ for ext in ["*.svs", "*ndpi", "*.tiff", "*.mrxs", "*.png", "*.jpg", "*.tif"]:
slide_list.extend(list(doc_config["slide_folder"].glob(ext)))
slide_list.extend(
list(doc_config["slide_folder"].glob(str(Path("*") / ext))),
From 8b24409c2cbc8283d37f997186d9eb99ad118750 Mon Sep 17 00:00:00 2001
From: "pre-commit-ci[bot]"
<66853113+pre-commit-ci[bot]@users.noreply.github.com>
Date: Wed, 21 Feb 2024 18:06:04 +0000
Subject: [PATCH 06/24] [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
---
tests/test_json_config_bokeh.py | 2 +-
tiatoolbox/visualization/bokeh_app/main.py | 7 +++----
2 files changed, 4 insertions(+), 5 deletions(-)
diff --git a/tests/test_json_config_bokeh.py b/tests/test_json_config_bokeh.py
index cc70e5635..ed103ca22 100644
--- a/tests/test_json_config_bokeh.py
+++ b/tests/test_json_config_bokeh.py
@@ -10,8 +10,8 @@
import pandas as pd
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
diff --git a/tiatoolbox/visualization/bokeh_app/main.py b/tiatoolbox/visualization/bokeh_app/main.py
index c792861f3..830c01a55 100644
--- a/tiatoolbox/visualization/bokeh_app/main.py
+++ b/tiatoolbox/visualization/bokeh_app/main.py
@@ -15,10 +15,6 @@
import pandas as pd
import requests
import torch
-from matplotlib import colormaps
-from PIL import Image
-from requests.adapters import HTTPAdapter, Retry
-
from bokeh.events import ButtonClick, DoubleTap, MenuItemClick
from bokeh.io import curdoc
from bokeh.layouts import column, row
@@ -63,6 +59,9 @@
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 ab711ffb04bdb7f7ad696354e417883d8a799749 Mon Sep 17 00:00:00 2001
From: measty <20169086+measty@users.noreply.github.com>
Date: Wed, 13 Mar 2024 16:28:30 +0000
Subject: [PATCH 07/24] add simple plugins
---
tiatoolbox/cli/visualize.py | 12 +-
tiatoolbox/visualization/bokeh_app/main.py | 50 ++++--
.../bokeh_app/templates/__init__.py | 1 +
.../bokeh_app/templates/bar_plot_grid.py | 100 +++++++++++
.../bokeh_app/templates/image_grid.py | 69 ++++++++
.../bokeh_app/templates/index.html | 5 +-
.../bokeh_app/templates/stats_plot.py | 161 ++++++++++++++++++
tiatoolbox/visualization/ui_utils.py | 57 +++++++
8 files changed, 441 insertions(+), 14 deletions(-)
create mode 100644 tiatoolbox/visualization/bokeh_app/templates/__init__.py
create mode 100644 tiatoolbox/visualization/bokeh_app/templates/bar_plot_grid.py
create mode 100644 tiatoolbox/visualization/bokeh_app/templates/image_grid.py
create mode 100644 tiatoolbox/visualization/bokeh_app/templates/stats_plot.py
diff --git a/tiatoolbox/cli/visualize.py b/tiatoolbox/cli/visualize.py
index 86810954a..31b780a77 100644
--- a/tiatoolbox/cli/visualize.py
+++ b/tiatoolbox/cli/visualize.py
@@ -77,6 +77,14 @@ def run_bokeh(img_input: list[str], port: int, *, noshow: bool) -> None:
This option must be used in conjunction with --slides.
The --base-path option should not be used in this case.""",
)
+@click.option(
+ "--plugin",
+ multiple=True,
+ help=r"""Path to a file to define an extra layout containing extra
+ resources such as graphs below each slide. Some pre-built plugins are
+ available in the tiatoolbox\visualization\templates folder. Can pass
+ multiple instances of this option to add multiple ui additions.""",
+)
@click.option(
"--port",
type=int,
@@ -88,6 +96,7 @@ def visualize(
base_path: str,
slides: str,
overlays: str,
+ plugin: list[str],
port: int,
*,
noshow: bool,
@@ -101,6 +110,7 @@ def visualize(
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.
+ plugin (list): Paths to files containing ui plugins.
port (int): Port to launch the visualization tool on.
noshow (bool): Do not launch in browser (mainly intended for testing).
@@ -109,7 +119,7 @@ def visualize(
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 = [base_path, slides, overlays, *list(plugin)]
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:
diff --git a/tiatoolbox/visualization/bokeh_app/main.py b/tiatoolbox/visualization/bokeh_app/main.py
index 830c01a55..4401295b1 100644
--- a/tiatoolbox/visualization/bokeh_app/main.py
+++ b/tiatoolbox/visualization/bokeh_app/main.py
@@ -2,6 +2,7 @@
from __future__ import annotations
+import importlib
import json
import sys
import tempfile
@@ -71,7 +72,7 @@
)
from tiatoolbox.tools.pyramid import ZoomifyGenerator
from tiatoolbox.utils.visualization import random_colors
-from tiatoolbox.visualization.ui_utils import get_level_by_extent
+from tiatoolbox.visualization.ui_utils import UIWrapper, get_level_by_extent
from tiatoolbox.wsicore.wsireader import WSIReader
if TYPE_CHECKING: # pragma: no cover
@@ -118,16 +119,10 @@ def __init__(self: DummyAttr, val: Any) -> None: # noqa: ANN401
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 to_camel(name: str) -> str:
+ """Convert a string to upper camel case."""
+ parts = name.split("_")
+ return "".join([p.capitalize() for p in parts])
def format_info(info: dict[str, Any]) -> str:
@@ -859,6 +854,13 @@ def slide_select_cb(attr: str, old: str, new: str) -> None: # noqa: ARG001
if len(new) == 0:
return
slide_path = Path(doc_config["slide_folder"]) / Path(new[0])
+ if doc_config["extra_layout"] is not None:
+ # create the extra layout if we can
+ extras = []
+ for cl in doc_config["extra_layout"]:
+ extras.extend(cl.create_extra_layout(slide_path, extra_layout.children))
+ cl.add_to_ui()
+ extra_layout.children = extras
# Reset the data sources for glyph overlays
UI["pt_source"].data = {"x": [], "y": []}
UI["box_source"].data = {"x": [], "y": [], "width": [], "height": []}
@@ -1283,6 +1285,11 @@ def segment_on_box() -> None:
height=200,
sizing_mode="stretch_width",
)
+extra_layout = column(
+ children=[],
+ name="extra_layout",
+ sizing_mode="stretch_both",
+)
def gather_ui_elements( # noqa: PLR0915
@@ -1868,10 +1875,10 @@ def make_window(vstate: ViewerState) -> dict: # noqa: PLR0915
# Main ui containers
-UI = UIWrapper()
windows = []
controls = []
win_dicts = []
+UI = UIWrapper(win_dicts)
# Popup for annotation viewing on double click
popup_div = Div(
@@ -2045,6 +2052,23 @@ def _get_config(self: DocConfig) -> None:
base_folder = slide_folder.parent
overlay_folder = Path(sys_args[2])
+ plugins = None
+ if len(sys_args) > 3: # noqa: PLR2004
+ # also passed a path to a plugin file defining extra ui elements
+ plugins = []
+ for p in sys_args[3:]:
+ plugin_path = Path(p)
+
+ # should have class named (in camel case) after the filename, that
+ # handles the extra ui elements
+ spec = importlib.util.spec_from_file_location(
+ "plugin_mod",
+ plugin_path,
+ )
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module) # Execute the module
+ plugins.append(module.__getattribute__(to_camel(plugin_path.stem))(UI))
+
# 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
@@ -2057,6 +2081,7 @@ def _get_config(self: DocConfig) -> None:
config["base_folder"] = base_folder
config["slide_folder"] = slide_folder
config["overlay_folder"] = overlay_folder
+ config["extra_layout"] = plugins
config["demo_name"] = self.config["demo_name"]
if "initial_views" not in config:
config["initial_views"] = {}
@@ -2130,6 +2155,7 @@ def setup_doc(self: DocConfig, base_doc: Document) -> tuple[Row, Tabs]:
base_doc.add_root(control_tabs)
base_doc.add_root(popup_table)
base_doc.add_root(slide_info)
+ base_doc.add_root(extra_layout)
base_doc.title = "Tiatoolbox Visualization Tool"
return slide_wins, control_tabs
diff --git a/tiatoolbox/visualization/bokeh_app/templates/__init__.py b/tiatoolbox/visualization/bokeh_app/templates/__init__.py
new file mode 100644
index 000000000..13658c8ad
--- /dev/null
+++ b/tiatoolbox/visualization/bokeh_app/templates/__init__.py
@@ -0,0 +1 @@
+"""plugins and templates for bokeh visualization app."""
diff --git a/tiatoolbox/visualization/bokeh_app/templates/bar_plot_grid.py b/tiatoolbox/visualization/bokeh_app/templates/bar_plot_grid.py
new file mode 100644
index 000000000..3d1e82acc
--- /dev/null
+++ b/tiatoolbox/visualization/bokeh_app/templates/bar_plot_grid.py
@@ -0,0 +1,100 @@
+"""This module contains a class for creating bar plots for CSV files in a folder."""
+
+import os
+from pathlib import Path
+
+import numpy as np
+import pandas as pd
+from bokeh.layouts import gridplot
+from bokeh.models import ColumnDataSource, HoverTool
+from bokeh.plotting import figure
+from bokeh.transform import dodge
+
+from tiatoolbox.utils.visualization import random_colors
+from tiatoolbox.visualization.ui_utils import UIPlugin
+
+
+class BarPlotGrid(UIPlugin):
+ """Class for creating a grid of bar plots for CSV files in a folder."""
+
+ def create_extra_layout(
+ self: UIPlugin,
+ slide_path: Path,
+ old_children: list, # noqa: ARG002
+ ) -> list:
+ """Creates a grid-like layout of bar charts for each CSV file within a folder.
+
+ Args:
+ slide_path (str): The path to the slide for which the extra layout is
+ being created
+ UI: contains the main UI elements of the bokeh app
+ old_children: contains the previous children of the layout
+
+ Returns:
+ list: A list containing the new children of the extra layout
+ """
+ folder_path = slide_path.with_name(slide_path.stem + "_files")
+ if not folder_path.is_dir():
+ return []
+ csv_files = [f for f in os.listdir(folder_path) if f.endswith(".csv")]
+ plots = []
+
+ for file in csv_files:
+ filepath = folder_path / file
+ csv_data = pd.read_csv(filepath, header=0, index_col=0)
+
+ # Get tags and labels for the x-axis
+ tags = csv_data.index.tolist()
+ x_labels = csv_data.columns.tolist()
+ colors = random_colors(len(tags), bright=True)
+ colors = [
+ f"{int(c[0]):02x}{int(c[1]):02x}{int(c[2]):02x}" for c in 255 * colors
+ ]
+
+ # Prepare data for Bokeh
+ data = {"x": x_labels}
+ for tag in tags:
+ data[tag] = csv_data.loc[tag].tolist()
+
+ source = ColumnDataSource(data=data)
+
+ # Create the figure (customize as needed)
+ p = figure(
+ x_range=x_labels,
+ title=f"Data from {file}",
+ height=700,
+ width=900,
+ toolbar_location=None,
+ tools="",
+ )
+
+ # Add bars for each tag
+ r = np.array(range(len(tags)))
+ dodge_vals = (r - np.mean(r)) / ((len(r) - 1) * 2)
+ for i, tag in enumerate(tags):
+ p.vbar(
+ x=dodge("x", dodge_vals[i], range=p.x_range),
+ top=tag,
+ width=0.6 / len(tags),
+ source=source,
+ legend_label=tag,
+ color=colors[i],
+ )
+
+ # Customize axes and appearance
+ p.x_range.range_padding = 0.1
+ p.xgrid.grid_line_color = None
+ p.axis.minor_tick_line_color = None
+ p.outline_line_color = None
+
+ # Add a hover tool for more information
+ hover = HoverTool()
+ hover.tooltips = [(tag, f"@{tag}") for tag in tags]
+ p.add_tools(hover)
+
+ plots.append(p)
+
+ # Arrange plots in a grid
+ grid = gridplot(plots, ncols=2)
+
+ return [grid]
diff --git a/tiatoolbox/visualization/bokeh_app/templates/image_grid.py b/tiatoolbox/visualization/bokeh_app/templates/image_grid.py
new file mode 100644
index 000000000..614d084f4
--- /dev/null
+++ b/tiatoolbox/visualization/bokeh_app/templates/image_grid.py
@@ -0,0 +1,69 @@
+"""Contains a class for creating a Bokeh grid layout of images from a folder."""
+
+from pathlib import Path
+
+import numpy as np
+from bokeh.layouts import gridplot
+from bokeh.plotting import figure
+from PIL import Image
+
+from tiatoolbox.visualization.ui_utils import UIPlugin
+
+
+class ImageGrid(UIPlugin):
+ """Class for creating a grid of images from a folder."""
+
+ def create_extra_layout(
+ self: UIPlugin,
+ slide_path: Path,
+ old_children: list, # noqa: ARG002
+ ) -> list:
+ """Creates a Bokeh grid layout of images from a specified folder.
+
+ Args:
+ slide_path (str): Path to slide for which the extra layout is being created
+ UI: contains the main UI elements of the bokeh app
+ old_children: contains the previous children of the layout
+
+ Returns:
+ list: A list containing the new children of the extra layout
+ """
+ image_folder_path = slide_path.with_name(slide_path.stem + "_files")
+ if not image_folder_path.is_dir():
+ return []
+ # Find supported images in the folder
+ search_patterns = ["*.jpg", "*.png"]
+ image_paths = []
+ for pattern in search_patterns:
+ image_paths.extend(image_folder_path.glob(pattern))
+
+ # Create figures for each image
+ figures = []
+ for image_path in image_paths:
+ img_pil = Image.open(image_path)
+ img_array = np.array(img_pil)
+ if img_array.shape[2] == 3: # noqa: PLR2004
+ img_array = np.dstack(
+ [
+ img_array,
+ np.full(
+ (img_array.shape[0], img_array.shape[1]),
+ 255,
+ dtype=np.uint8,
+ ),
+ ],
+ )
+ # bokeh needs as 2D uint32
+ img_rgba = img_array.view(dtype=np.uint32).reshape(
+ (img_array.shape[0], img_array.shape[1]),
+ )
+
+ p = figure(x_range=(0, 100), y_range=(0, 100), title=image_path.stem)
+ p.axis.visible = False
+ p.image_rgba(image=[img_rgba], x=0, y=0, dw=100, dh=100)
+ figures.append(p)
+
+ # Arrange figures in a grid layout
+ grid = gridplot(figures, ncols=3)
+
+ return [grid]
diff --git a/tiatoolbox/visualization/bokeh_app/templates/index.html b/tiatoolbox/visualization/bokeh_app/templates/index.html
index 863fd3a1f..2d8a9bfe3 100644
--- a/tiatoolbox/visualization/bokeh_app/templates/index.html
+++ b/tiatoolbox/visualization/bokeh_app/templates/index.html
@@ -85,7 +85,10 @@