diff --git a/docs/visualization.rst b/docs/visualization.rst index 527f23973..82515b4dc 100644 --- a/docs/visualization.rst +++ b/docs/visualization.rst @@ -265,6 +265,11 @@ 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 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: 4. Annotation Store Examples @@ -460,7 +465,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 @@ -475,7 +480,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/tests/conftest.py b/tests/conftest.py index aab4b374c..b59df920e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -622,6 +622,7 @@ 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 / "slides" / "CMU-1_files").mkdir() (tmp_path / "overlays").mkdir() return {"base_path": tmp_path} diff --git a/tests/test_app_bokeh.py b/tests/test_app_bokeh.py index ce97fb2fd..db16f3ba2 100644 --- a/tests/test_app_bokeh.py +++ b/tests/test_app_bokeh.py @@ -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", @@ -134,6 +138,14 @@ def annotation_path(data_path: dict[str, Path]) -> dict[str, object]: "config_2", data_path["base_path"] / "overlays", ) + data_path["plugin_img"] = _fetch_remote_sample( + "stainnorm-source", + data_path["base_path"] / "slides" / "CMU-1_files", + ) + data_path["plugin_csv"] = _fetch_remote_sample( + "test_csv", + data_path["base_path"] / "slides" / "CMU-1_files", + ) return data_path @@ -186,8 +198,8 @@ def test_get_level_by_extent() -> None: 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 + # should be 5 roots: main window, controls, slide_info, popup, extra_layout + assert len(doc.roots) == 5 def test_config_loaded(data_path: pytest.TempPathFactory) -> None: @@ -215,6 +227,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/tests/test_json_config_bokeh.py b/tests/test_json_config_bokeh.py index 2c3a16e7b..cc8604743 100644 --- a/tests/test_json_config_bokeh.py +++ b/tests/test_json_config_bokeh.py @@ -7,6 +7,7 @@ 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 @@ -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/cli/visualize.py b/tiatoolbox/cli/visualize.py index 30627dcf2..b78c75185 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/data/remote_samples.yaml b/tiatoolbox/data/remote_samples.yaml index 1b7bf2bf1..22c90d8eb 100644 --- a/tiatoolbox/data/remote_samples.yaml +++ b/tiatoolbox/data/remote_samples.yaml @@ -143,6 +143,10 @@ files: url: [*testdata, "annotation/test1_config.json"] config_2: url: [*testdata, "annotation/test2_config.json"] + test_meta: + url: [*testdata, "annotation/test_meta.csv"] + test_csv: + url: [*testdata, "annotation/metrics_10.csv"] patch_annotations: url: [*testdata, "annotation/sample_wsi_patch_preds.db"] nuclick-output: diff --git a/tiatoolbox/visualization/bokeh_app/main.py b/tiatoolbox/visualization/bokeh_app/main.py index 5df7acb04..4a77a5ed2 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 @@ -12,6 +13,7 @@ from typing import TYPE_CHECKING, Any, SupportsFloat import numpy as np +import pandas as pd import requests import torch from bokeh.events import ButtonClick, DoubleTap, MenuItemClick @@ -71,7 +73,7 @@ from tiatoolbox.tools.pyramid import ZoomifyGenerator from tiatoolbox.utils.misc import select_device 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 @@ -120,23 +122,27 @@ 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: """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_data = doc_config.metadata.loc[slide_name] + info_str += "
Metadata:
" + for k, v in row_data.items(): + info_str += f"{k}: {v}
" + except KeyError: + info_str += "
No metadata found.
" return info_str @@ -734,7 +740,7 @@ def populate_slide_list(slide_folder: Path, search_txt: str | None = None) -> No len_slidepath = len(slide_folder.parts) for ext in [ "*.svs", - "*ndpi", + "*.ndpi", "*.tiff", "*.mrxs", "*.jpg", @@ -742,7 +748,6 @@ def populate_slide_list(slide_folder: Path, search_txt: str | None = None) -> No "*.tif", "*.dcm", ]: - 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 = [ @@ -864,6 +869,14 @@ 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]) + # add any extra per-slide elements from plugins + 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": []} @@ -892,10 +905,10 @@ def slide_select_cb(attr: str, old: str, new: str) -> None: # noqa: ARG001 layer_drop_cb(dummy_attr) -def handle_graph_layer(attr: MenuItemClick) -> None: # skipcq: PY-R1000 +def handle_graph_layer(graph_path: str) -> None: # skipcq: PY-R1000 """Handle adding a graph layer.""" do_feats = False - with Path(attr.item).open("rb") as f: + with Path(graph_path).open("rb") as f: graph_dict = json.load(f) # Convert the values to numpy arrays for k, v in graph_dict.items(): @@ -1005,7 +1018,7 @@ 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) + handle_graph_layer(attr.item) return # Otherwise it's a tile-based overlay of some form @@ -1291,6 +1304,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 @@ -1553,7 +1571,6 @@ def gather_ui_elements( # noqa: PLR0915 ] layer_drop = Dropdown( label="Add Overlay", - button_type="warning", menu=[None], sizing_mode="stretch_width", name=f"layer_drop{win_num}", @@ -1874,27 +1891,42 @@ def make_window(vstate: ViewerState) -> dict: # noqa: PLR0915 ) 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, - } + # Add the dictionary collecting all the things related to window to UI + win_dicts.append( + { + **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, + "ui_layout": ui_layout, + "extra_options": extra_options, + }, + ) + + # add in any extra one-time elements from plugins + 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_once(vstate.slide_path, extra_layout.children), + ) + cl.add_to_ui_once() + extra_layout.children = extras # Main ui containers -UI = UIWrapper() windows = [] controls = [] win_dicts = [] +UI = UIWrapper(win_dicts) # Popup for annotation viewing on double click popup_div = Div( @@ -1962,13 +1994,13 @@ 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))) + UI.active = new + 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), @@ -2038,6 +2070,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.""" @@ -2067,6 +2100,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 @@ -2079,6 +2129,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"] = {} @@ -2104,6 +2155,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 = [] @@ -2126,7 +2193,7 @@ def setup_doc(self: DocConfig, base_doc: Document) -> tuple[Row, Tabs]: first_slide_path = self.config["slide_folder"] / self.config["first_slide"] # Make initial window - win_dicts.append(make_window(ViewerState(first_slide_path))) + 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 @@ -2145,6 +2212,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..8b18eeece --- /dev/null +++ b/tiatoolbox/visualization/bokeh_app/templates/bar_plot_grid.py @@ -0,0 +1,107 @@ +"""This module contains a class for creating bar plots for CSV files in a folder.""" + +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.name for f in folder_path.iterdir() if f.suffix == ".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] + + def create_extra_layout_once( + self: UIPlugin, + slide_path: str, # noqa: ARG002 + old_children: list, # noqa: ARG002 + ) -> list: + """Create extra layout elements on window initialization.""" + return [] 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..a6127852b --- /dev/null +++ b/tiatoolbox/visualization/bokeh_app/templates/image_grid.py @@ -0,0 +1,83 @@ +"""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), + width=400, + height=400, + 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] + + def create_extra_layout_once( + self: UIPlugin, + slide_path: str, # noqa: ARG002 + old_children: list, # noqa: ARG002 + ) -> list: + """Create extra layout elements on widow initialization.""" + return [] 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 @@

Annotation Properties

{{ embed(roots.slide_windows) }}
{{ embed(roots.ui_layout) }}
-
{{ embed(roots.description) }}
+
+ {{ embed(roots.description) }} + {{ embed(roots.extra_layout) }} +