import os
import time
import numpy as np
import webbrowser
import pyperclip
import imgui
import glfw
import OpenGL.GL as gl
from contextlib import contextmanager
from typing import List, Any, Optional, Dict, Union, Tuple
from os.path import join, dirname, abspath
from PIL import Image
from tkinter import Tk
from tkinter.filedialog import askopenfilename, askdirectory
import slideflow as sf
from slideflow import log
from .gui import imgui_utils
from .gui import gl_utils
from .gui import text_utils
from .gui.theme import StudioTheme
from .gui.window import ImguiWindow
from .gui.viewer import SlideViewer
from .widgets import (
ProjectWidget, SlideWidget, ModelWidget, HeatmapWidget, PerformanceWidget,
CaptureWidget, SettingsWidget, ExtensionsWidget, Widget
)
from .utils import EasyDict, prediction_to_string, StatusMessage
from ._renderer import Renderer
from ._render_manager import AsyncRenderManager, Renderer, CapturedException
OVERLAY_GRID = 0
OVERLAY_WSI = 1
OVERLAY_VIEW = 2
# -----------------------------------------------------------------------------
[docs]class Studio(ImguiWindow):
[docs] def __init__(
self,
low_memory: bool = False,
widgets: Optional[List[Any]] = None,
skip_tk_init: bool = False,
theme: Optional[StudioTheme] = None,
) -> None:
"""Create the main Studio window.
Slideflow Studio is started by running the studio module.
.. code-block:: bash
python -m slideflow.studio
Args:
low_memory (bool): Enable low memory mode, which uses thread pools
instead of multiprocessing pools when applicable to reduce
memory footprint, at the cost of decreased performance.
widgets (list(Any), optional): Additional widgets to render.
"""
# Initialize TK window in background (for file dialogs)
if not skip_tk_init:
Tk().withdraw()
if theme is None:
theme = StudioTheme()
super().__init__(
title=f'Slideflow Studio',
background=theme.main_background
)
# Internals.
self._dx = 0
self._dy = 0
self._last_error_print = None
self._render_manager = AsyncRenderManager()
self._addl_renderers = dict()
self._defer_rendering = 0
self._tex_img = None
self._tex_obj = None
self._norm_tex_img = None
self._norm_tex_obj = None
self._heatmap_tex_img = None
self._heatmap_tex_obj = None
self._wsi_tex_obj = None
self._wsi_tex_img = None
self._about_tex_obj = None
self._predictions = None
self._model_path = None
self._model_config = None
self._normalizer = None
self._normalize_wsi = False
self._uncertainty = None
self._content_width = None
self._content_height = None
self._pane_w = None
self._refresh_view = False
self._overlay_wsi_dim = None
self._overlay_offset_wsi_dim = (0, 0)
self._thumb_params = None
self._use_model = None
self._use_uncertainty = None
self._use_saliency = None
self._use_model_img_fmt = False
self._tex_to_delete = []
self._defer_tile_refresh = None
self._should_close_slide = False
self._should_close_model = False
self._bg_logo = None
self._message = None
self._pred_message = None
self.low_memory = low_memory
self._suspend_mouse_input = False
self._suspend_keyboard_input = False
self._status_message = None
self._force_enable_tile_preview = False
# Interface.
self._show_about = False
self._show_tile_preview = True
self._tile_preview_is_new = True
self._tile_preview_image_is_new = True
self._show_overlays = True
self._show_mpp_zoom_popup = False
self._input_mpp = 1.
self._box_color = [1, 0, 0]
self.theme = theme
# Widget interface.
self.wsi = None
self.wsi_thumb = None
self.viewer = None
self.saliency = None
self.box_x = None
self.box_y = None
self.tile_px = None
self.tile_um = None
self.tile_zoom = 1
self.heatmap = None
self.rendered_heatmap = None
self.overlay = None
self.overlay_original = None
self.rendered_qc = None
self.overlay_qc = None
self.args = EasyDict(use_model=False, use_uncertainty=False, use_saliency=False)
self.result = EasyDict(predictions=None, uncertainty=None)
self.message = None
self.pane_w = 0
self.label_w = 0
self.button_w = 0
self.x = None
self.y = None
self.mouse_x = None
self.mouse_y = None
self._mouse_screen_x = 0
self._mouse_screen_y = 0
self.menu_bar_height = self.font_size + self.spacing
# Control sidebar.
self.sidebar = Sidebar(self)
# Core widgets.
self.project_widget = ProjectWidget(self)
self.slide_widget = SlideWidget(self)
self.model_widget = ModelWidget(self)
self.heatmap_widget = HeatmapWidget(self)
self.performance_widget = PerformanceWidget(self)
self.capture_widget = CaptureWidget(self)
self.settings_widget = SettingsWidget(self)
# User-defined widgets.
self.widgets = []
if widgets is None:
widgets = self.get_default_widgets()
self.add_widgets(widgets)
# Extensions widget.
self.extensions_widget = ExtensionsWidget(self)
# Initialize window.
self.set_window_icon(imgui_utils.logo_image())
self.set_position(0, 0)
self._update_window_limits()
self._set_default_font_size()
self.skip_frame() # Layout may change after first frame.
self.load_slide('')
@property
def show_overlay(self):
"""An overlay (e.g. tile filter or heatmap) is currently being shown
over the main view.
"""
return ((self.slide_widget.show_overlay or self.heatmap_widget.show)
and self._show_overlays)
@property
def model(self):
"""Tensorflow/PyTorch model currently in use."""
return self._render_manager._model
@property
def P(self):
"""Slideflow project currently in use."""
return self.project_widget.P
@property
def offset_x(self):
"""Main window offset (x), in points."""
return self.pane_w
@property
def offset_y(self):
"""Main window offset (y), in points."""
return self.menu_bar_height
@property
def offset_x_pixels(self):
"""Main window offset (x), in pixels."""
return int(self.offset_x * self.pixel_ratio)
@property
def offset_y_pixels(self):
"""Main window offset (y), in pixels."""
return int(self.offset_y * self.pixel_ratio)
@property
def status_bar_height(self):
return self.font_size + self.spacing
@property
def mouse_is_over_viewer(self):
"""Mouse is currently over the main viewer."""
cx, cy = imgui.get_mouse_pos()
cx -= self.offset_x
cy -= self.offset_y
return (self.viewer is not None
and self.viewer.is_in_view(cx, cy))
@property
def tile_preview_enabled(self):
"""Show a tile preview when right clicking."""
return self._model_path or self._force_enable_tile_preview
# --- Internals -----------------------------------------------------------
def _set_default_font_size(self) -> None:
"""Change the interface font size."""
old = self.font_size
self.set_font_size(int(18 / self.pixel_ratio))
if self.font_size != old:
self.skip_frame() # Layout changed.
def _clear_textures(self) -> None:
"""Remove all textures."""
for tex in self._tex_to_delete:
tex.delete()
self._tex_to_delete = []
def _close_model_now(self) -> None:
"""Close the currently loaded model now."""
self._render_manager.clear_result()
self._use_model = False
self._use_uncertainty = False
self._use_saliency = False
self._model_path = None
self._model_config = None
self._normalizer = None
self.tile_px = None
self.tile_um = None
self.heatmap = None
self.x = None
self.y = None
self._render_manager.clear_model()
self.clear_model_results()
self.heatmap_widget.reset()
def _close_slide_now(self) -> None:
"""Close the currently loaded slide now."""
self.wsi = None
self.viewer = None
self.wsi_thumb = None
self.x = None
self.y = None
self.mouse_x = None
self.mouse_y = None
self.clear_result()
self._render_manager._live_updates = False
self._heatmap_tex_img = None
self._heatmap_tex_obj = None
self.heatmap_widget.reset()
self.set_title("Slideflow Studio")
def _draw_about_dialog(self) -> None:
"""Draw the About dialog."""
if self._show_about:
import platform
try:
import pyvips
from pyvips.base import version as lv
libvips_version = f'{lv(0)}.{lv(1)}.{lv(2)}'
pyvips_version = pyvips.__version__
except Exception:
libvips_version = 'NA'
pyvips_version = 'NA'
imgui.open_popup('about_popup')
version_width = imgui.calc_text_size("Version: " + sf.__version__).x
width = max(200, version_width + self.spacing)
height = 315
imgui.set_next_window_content_size(width, 0)
imgui.set_next_window_position(self.content_width/2 - width/2, self.content_height/2 - height/2)
about_text = f"Version: {sf.__version__}\n"
about_text += f"Python: {platform.python_version()}\n"
about_text += f"Slide Backend: {sf.slide_backend()}\n"
about_text += f"Libvips: {libvips_version}\n"
about_text += f"Pyvips: {pyvips_version}\n"
about_text += f"OS: {platform.system()} {platform.release()}\n"
if imgui.begin_popup('about_popup'):
if self._about_tex_obj is None:
about_img = imgui_utils.logo_image().resize((96, 96))
self._about_tex_obj = gl_utils.Texture(image=about_img)
imgui.text('')
imgui.text('')
imgui.same_line(imgui.get_content_region_max()[0]/2 - 48 + self.spacing)
imgui.image(self._about_tex_obj.gl_id, 96, 96)
imgui.text('')
with self.bold_font():
self.center_text('Slideflow Studio')
imgui.text('')
for line in about_text.split('\n'):
self.center_text(line)
imgui.text('')
imgui.same_line(self.spacing)
if imgui_utils.button('Copy', width=self.button_w/2):
pyperclip.copy(about_text)
imgui.same_line(imgui.get_content_region_max()[0] + self.spacing - self.button_w/2)
if imgui_utils.button('Close', width=self.button_w/2):
self._show_about = False
imgui.end_popup()
def _draw_mpp_zoom_dialog(self):
"""Show a dialog that prompts the user to specify microns-per-pixel."""
if not self._show_mpp_zoom_popup:
return
window_size = (self.font_size * 18, self.font_size * 7)
self.center_next_window(*window_size)
imgui.set_next_window_size(*window_size)
_, opened = imgui.begin('Zoom to Microns-Per-Pixel (MPP)', closable=True, flags=imgui.WINDOW_NO_RESIZE)
if not opened:
self._show_mpp_zoom_popup = False
imgui.text("Zoom the current view to a given MPP.")
imgui.separator()
imgui.text('')
imgui.same_line(self.font_size*4)
with imgui_utils.item_width(self.font_size*4):
_changed, self._input_mpp = imgui.input_float('MPP##input_mpp', self._input_mpp, format='%.3f')
imgui.same_line()
if self._input_mpp:
mag = f'{10/self._input_mpp:.1f}x'
else:
mag = '-'
imgui.text(mag)
if self.sidebar.full_button("Zoom", width=-1):
self.viewer.zoom_to_mpp(window_size[0] / 2, window_size[1] / 2, self._input_mpp)
self._show_mpp_zoom_popup = False
imgui.end()
def _draw_control_pane(self) -> None:
"""Draw the control pane and widgets."""
self.sidebar.draw()
def _draw_empty_background(self):
"""Render an empty background with the Studio logo."""
if self._bg_logo is None:
bg_path = join(dirname(abspath(__file__)), 'gui', 'logo_dark_outline.png')
img = np.array(Image.open(bg_path))
self._bg_logo = gl_utils.Texture(image=img, bilinear=True)
self._bg_logo.draw(
pos=(self.content_frame_width//2, self.content_frame_height//2),
zoom=0.75,
align=0.5,
rint=True,
anchor='center'
)
def _draw_main_view(self, inp: EasyDict, window_changed: bool) -> None:
"""Update the main window view.
Draws the slide / picam view, overlay heatmap, overlay box, and ROIs.
Args:
inp (EasyDict): Dictionary of user input.
window_changed (bool): Window size has changed (force refresh).
"""
max_w = self.content_frame_width - self.offset_x_pixels
max_h = self.content_frame_height - self.offset_y_pixels
# Update the viewer in response to user input.
if self.viewer and self.viewer.movable:
# Update WSI focus location & zoom values
# If shift-dragging or scrolling.
dz = None
if not inp.dragging:
inp.dx, inp.dy = None, None
if inp.wheel > 0:
dz = 1/1.5
if inp.wheel < 0:
dz = 1.5
if inp.wheel or inp.dragging or self._refresh_view:
if inp.dx is not None:
self.viewer.move(inp.dx, inp.dy)
if inp.wheel:
self.viewer.zoom(inp.cx, inp.cy, dz)
if self._refresh_view and inp.dx is None and not inp.wheel:
self.viewer.refresh_view()
self._refresh_view = False
self.mouse_x, self.mouse_y = self.viewer.display_coords_to_wsi_coords(inp.cx, inp.cy, offset=False)
# Render slide view.
self.viewer.render(max_w, max_h)
# Render overlay heatmap.
if self.overlay is not None and self.show_overlay:
self.viewer.render_overlay(
self.overlay,
dim=self._overlay_wsi_dim,
offset=self._overlay_offset_wsi_dim)
# Render overlay tooltip, if hovered.
if self.overlay_original is not None and self.show_overlay:
self.viewer.render_overlay_tooltip(self.overlay_original)
# Calculate location for model display.
if (self.tile_preview_enabled
and inp.clicking
and not inp.dragging
and self.viewer.is_in_view(inp.cx, inp.cy)):
wsi_x, wsi_y = self.viewer.display_coords_to_wsi_coords(inp.cx, inp.cy, offset=False)
self.x = wsi_x - (self.viewer.full_extract_px/2)
self.y = wsi_y - (self.viewer.full_extract_px/2)
# Show box around location that a tile is being extracted for preview.
if self.x is not None and self.y is not None:
if inp.clicking or inp.dragging or inp.wheel or window_changed:
self.box_x, self.box_y = self.viewer.wsi_coords_to_display_coords(self.x, self.y)
tw = self.viewer.full_extract_px / self.viewer.view_zoom
# Draw box on main display.
gl.glPolygonMode(gl.GL_FRONT_AND_BACK, gl.GL_LINE)
gl.glLineWidth(3)
box_pos = np.array([self.box_x, self.box_y])
gl_utils.draw_rect(pos=box_pos, size=np.array([tw, tw]), color=self._box_color, mode=gl.GL_LINE_LOOP)
gl.glPolygonMode(gl.GL_FRONT_AND_BACK, gl.GL_FILL)
gl.glLineWidth(1)
# Render ROIs.
self.viewer.late_render()
def _draw_menu_bar(self) -> None:
"""Draw the main menu bar (File, View, Help)"""
if imgui.begin_main_menu_bar():
# --- File --------------------------------------------------------
if imgui.begin_menu('File', True):
if imgui.menu_item('New Project...', 'Ctrl+N')[1]:
self.project_widget.new_project()
imgui.separator()
if imgui.menu_item('Open Project...', 'Ctrl+P')[1]:
self.ask_load_project()
if imgui.menu_item('Open Slide...', 'Ctrl+O')[1]:
self.ask_load_slide()
if imgui.menu_item('Load Model...', 'Ctrl+M')[1]:
self.ask_load_model()
if imgui.menu_item('Load Heatmap...', 'Ctrl+H', enabled=self._model_path is not None)[1]:
self.ask_load_heatmap()
# Widgets with "Open" menu options.
for w in self.widgets:
if hasattr(w, 'open_menu_options'):
w.open_menu_options()
imgui.separator()
if imgui.begin_menu('Export...', True):
if imgui.menu_item('Main view')[1]:
self.capture_widget.save_view()
if imgui.menu_item('Tile view')[1]:
self.capture_widget.save_tile()
if imgui.menu_item('GUI view')[1]:
self.capture_widget.save_gui()
if imgui.menu_item('Heatmap (PNG)', enabled=(self.rendered_heatmap is not None))[0]:
h_img = Image.fromarray(self.rendered_heatmap)
h_img.resize(np.array(h_img.size) * 16, Image.NEAREST).save(f'{self.heatmap.slide.name}.png')
self.create_toast(f"Saved heatmap image to {self.heatmap.slide.name}.png", icon='success')
if imgui.menu_item('Heatmap (NPZ)', enabled=(self.heatmap is not None))[0]:
loc = self.heatmap.save_npz()
self.create_toast(f"Saved heatmap .npz to {loc}", icon='success')
imgui.end_menu()
imgui.separator()
if imgui.menu_item('Close Slide')[1]:
self.close_slide(True)
if imgui.menu_item('Close Model')[1]:
self.close_model(True)
# Widgets with "File" menu.
for w in self.widgets:
if hasattr(w, 'file_menu_options'):
imgui.separator()
w.file_menu_options()
imgui.separator()
if imgui.menu_item('Exit', 'Ctrl+Q')[1]:
self._exit_trigger = True
imgui.end_menu()
# --- View --------------------------------------------------------
has_wsi = self.viewer and isinstance(self.viewer, SlideViewer)
if imgui.begin_menu('View', True):
if imgui.menu_item('Fullscreen', 'Alt+Enter')[0]:
self.toggle_fullscreen()
imgui.separator()
# --- Show sub-menu -------------------------------------------
if imgui.begin_menu('Show', True):
if imgui.menu_item('Tile Preview', 'Ctrl+Shift+T', selected=self._show_tile_preview)[0]:
self._show_tile_preview = not self._show_tile_preview
imgui.separator()
if imgui.menu_item('Thumbnail', selected=(has_wsi and self.viewer.show_thumbnail), enabled=has_wsi)[0]:
self.viewer.show_thumbnail = not self.viewer.show_thumbnail
if imgui.menu_item('Scale', selected=(has_wsi and self.viewer.show_scale), enabled=has_wsi)[0]:
self.viewer.show_scale = not self.viewer.show_scale
# Widgets with "View" menu.
for w in self.widgets:
if hasattr(w, 'show_menu_options'):
imgui.separator()
w.show_menu_options()
imgui.end_menu()
# -------------------------------------------------------------
imgui.separator()
if imgui.menu_item('Increase Font Size', 'Ctrl+=')[1]:
self.increase_font_size()
if imgui.menu_item('Decrease Font Size', 'Ctrl+-')[1]:
self.decrease_font_size()
imgui.separator()
if imgui.menu_item('Increase Tile Zoom', 'Ctrl+]')[1]:
self.increase_tile_zoom()
if imgui.menu_item('Decrease Tile Zoom', 'Ctrl+[')[1]:
self.decrease_tile_zoom()
if imgui.menu_item('Zoom to MPP', 'Ctrl+/')[1]:
self.ask_zoom_to_mpp()
if imgui.menu_item('Reset Tile Zoom', 'Ctrl+\\')[1]:
self.reset_tile_zoom()
# Widgets with "View" menu.
for w in self.widgets:
if hasattr(w, 'view_menu_options'):
imgui.separator()
w.view_menu_options()
imgui.end_menu()
# --- Help --------------------------------------------------------
if imgui.begin_menu('Help', True):
if imgui.menu_item('Get Started')[1]:
webbrowser.open('https://slideflow.dev/studio')
if imgui.menu_item('Documentation')[1]:
webbrowser.open('https://slideflow.dev')
# Widgets with "Help" menu.
for w in self.widgets:
if hasattr(w, 'help_menu_options'):
imgui.separator()
w.help_menu_options()
imgui.separator()
if imgui.menu_item('Release Notes')[1]:
webbrowser.open(join(sf.__github__, 'releases/tag', sf.__version__))
if imgui.menu_item('Report Issue')[1]:
webbrowser.open(join(sf.__github__, 'issues'))
imgui.separator()
if imgui.menu_item('View License')[1]:
webbrowser.open(join(sf.__github__, 'blob/master/LICENSE'))
if imgui.menu_item('About')[1]:
self._show_about = True
imgui.end_menu()
version_text = f'slideflow {sf.__version__}'
imgui_utils.right_aligned_text(version_text, spacing=self.spacing)
imgui.end_main_menu_bar()
def _draw_status_bar(self) -> None:
"""Draw the bottom status bar."""
h = self.status_bar_height
r = self.pixel_ratio
y_pos = int((self.content_frame_height - (h * r)) / r)
imgui.set_next_window_position(0-2, y_pos)
imgui.set_next_window_size(self.content_width+4, h)
imgui.push_style_color(imgui.COLOR_WINDOW_BACKGROUND, *self.theme.main_background)
imgui.push_style_var(imgui.STYLE_WINDOW_PADDING, [10, 5])
imgui.begin('Status bar', closable=True, flags=(imgui.WINDOW_NO_RESIZE
| imgui.WINDOW_NO_COLLAPSE
| imgui.WINDOW_NO_TITLE_BAR
| imgui.WINDOW_NO_MOVE
| imgui.WINDOW_NO_SCROLLBAR))
# Backend
backend = sf.slide_backend()
if backend == 'cucim':
tex = self.sidebar._button_tex[f'small_cucim'].gl_id
imgui.image(tex, self.font_size, self.font_size)
imgui.same_line()
imgui.text_colored('cuCIM', 0.55, 1, 0.47, 1)
elif backend == 'libvips':
tex = self.sidebar._button_tex[f'small_vips'].gl_id
imgui.image(tex, self.font_size, self.font_size)
imgui.same_line()
imgui.text_colored('VIPS', 0.47, 0.65, 1, 1)
else:
imgui.text(backend)
if imgui.is_item_hovered():
imgui.set_tooltip("Slide backend")
# Low memory mode
if self.low_memory:
tex = self.sidebar._button_tex[f'small_lowmem'].gl_id
imgui.same_line()
imgui.image(tex, self.font_size, self.font_size)
imgui.same_line()
imgui.text_colored("Low memory mode", 0.99, 0.75, 0.42, 1)
# Status messages
if self._status_message:
self._status_message.render()
# Location / MPP
if self.viewer and hasattr(self.viewer, 'mpp') and self.mouse_x is not None:
imgui_utils.right_aligned_text('x={:<8} y={:<8} mpp={:.3f}'.format(
int(self.mouse_x), int(self.mouse_y), self.viewer.mpp)
)
elif self.viewer and self.mouse_x is not None:
imgui_utils.right_aligned_text(
'x={:<8} y={:<8}'.format(int(self.mouse_x), int(self.mouse_y))
)
imgui.end()
imgui.pop_style_color(1)
imgui.pop_style_var(1)
def _draw_tile_view(self):
"""Draw the tile view window, displaying the currently rendered tile(s).
This window will show images rendered by a whole-slide viewer (image
tile extracted at some x/y location from the slide), or potentially an
image rendered via some other rendering mechanism as determined through
renderes set via ``.add_to_render_pipeline()``. For example, images
rendered by StyleGAN will be shown in this view. This view also shows
a post-processed, post-normalized rendered image, if available.
Rendered images are expected to be stored in the OpenGL objects
``.tex_obj`` and ``._norm_tex_obj``.
"""
if self._show_tile_preview:
has_raw_image = self._tex_obj is not None
has_norm_image = 'normalized' in self.result and self._norm_tex_obj is not None # self.model_widget.use_model and self._normalizer is not None and self._norm_tex_obj is not None and self.tile_px
if not has_raw_image:
return
if not (has_raw_image or has_norm_image):
width = self.font_size * 8
height = self.font_size * 3
else:
raw_img_w = 0 if not has_raw_image else self._tex_img.shape[0] * self.tile_zoom
norm_img_w = 0 if not has_norm_image else self._norm_tex_img.shape[0] * self.tile_zoom
height = self.font_size * 2 + max(raw_img_w, norm_img_w)
width = raw_img_w + norm_img_w + self.spacing*2
imgui.set_next_window_size(width, height)
if self._tile_preview_is_new:
imgui.set_next_window_position(
self.content_width - width - self.spacing,
self.content_height - height - self.spacing - self.status_bar_height
)
self._tile_preview_is_new = False
if self._tile_preview_image_is_new and (has_raw_image or has_norm_image):
imgui.set_next_window_position(
self.content_width - width - self.spacing,
self.content_height - height - self.spacing - self.status_bar_height
)
self._tile_preview_image_is_new = False
_, self._show_tile_preview = imgui.begin(
"##tile view",
closable=True,
flags=(imgui.WINDOW_NO_COLLAPSE
| imgui.WINDOW_NO_RESIZE
| imgui.WINDOW_NO_SCROLLBAR)
)
# Image preview ===================================================
dim_color = list(imgui.get_style().colors[imgui.COLOR_TEXT])
dim_color[-1] *= 0.5
imgui.begin_child('##pred_image', border=False)
imgui.image(self._tex_obj.gl_id, raw_img_w, raw_img_w)
if imgui.is_item_hovered():
imgui.set_tooltip("Raw image")
imgui.same_line()
if has_norm_image:
imgui.image(self._norm_tex_obj.gl_id, norm_img_w, norm_img_w)
if imgui.is_item_hovered():
imgui.set_tooltip("Stain-normalized image")
elif self._tex_obj is not None and self.tile_px:
imgui.text_colored('Normalizer not used', *dim_color)
imgui.end_child()
imgui.same_line()
imgui.end()
def _glfw_key_callback(self, _window, key, _scancode, action, _mods):
"""Callback for handling keyboard input."""
super()._glfw_key_callback(_window, key, _scancode, action, _mods)
if self._suspend_keyboard_input:
return
if self._control_down and action == glfw.PRESS and key == glfw.KEY_N:
self.project_widget.new_project()
if self._control_down and self._shift_down and action == glfw.PRESS and key == glfw.KEY_T:
self._show_tile_preview = not self._show_tile_preview
if self._control_down and action == glfw.PRESS and key == glfw.KEY_Q:
self._exit_trigger = True
if self._control_down and action == glfw.PRESS and key == glfw.KEY_O:
self.ask_load_slide()
if self._control_down and not self._shift_down and action == glfw.PRESS and key == glfw.KEY_P:
self.ask_load_project()
if self._control_down and action == glfw.PRESS and key == glfw.KEY_M:
self.ask_load_model()
if self._control_down and action == glfw.PRESS and key == glfw.KEY_H:
self.ask_load_heatmap()
if self._control_down and action == glfw.PRESS and key == glfw.KEY_SPACE:
self.heatmap_widget.show = True
if self._control_down and action == glfw.RELEASE and key == glfw.KEY_SPACE:
self.heatmap_widget.show = False
if self._control_down and action == glfw.PRESS and key == glfw.KEY_LEFT_BRACKET:
self.decrease_tile_zoom()
if self._control_down and action == glfw.PRESS and key == glfw.KEY_RIGHT_BRACKET:
self.increase_tile_zoom()
if self._control_down and action == glfw.PRESS and key == glfw.KEY_SLASH:
self.ask_zoom_to_mpp()
if self._control_down and action == glfw.PRESS and key == glfw.KEY_BACKSLASH:
self.reset_tile_zoom()
self.slide_widget.keyboard_callback(key, action)
self.project_widget.keyboard_callback(key, action)
for widget in self.widgets:
if hasattr(widget, 'keyboard_callback'):
widget.keyboard_callback(key, action)
[docs] def is_mouse_down(self, mouse_idx: int = 0) -> bool:
"""Check if the mouse is currently down."""
if self._suspend_mouse_input:
return False
return imgui.is_mouse_down(mouse_idx)
[docs] def is_mouse_released(self, mouse_idx: int = 0) -> bool:
"""Check if the mouse was released."""
if self._suspend_mouse_input:
return False
return imgui.is_mouse_released(mouse_idx)
def _handle_user_input(self):
"""Handle user input to support clicking/dragging the main viewer."""
self._mouse_screen_x, self._mouse_screen_y = imgui.get_mouse_pos()
# Detect right mouse click in the main display.
clicking, cx, cy, wheel = imgui_utils.click_hidden_window(
'##result_area',
x=self.offset_x,
y=self.offset_y,
width=self.content_width - self.offset_x,
height=self.content_height - self.offset_y,
mouse_idx=1)
# Ignore right click if the slide widget
# is capturing an ROI.
if self.slide_widget.editing_rois:
clicking = False
# Detect dragging with left mouse in the main display.
dragging, dx, dy = imgui_utils.drag_hidden_window(
'##result_area',
x=self.offset_x,
y=self.offset_y,
width=self.content_width - self.offset_x,
height=self.content_height - self.offset_y)
# Suspend mouse input handling if the user is interacting with a widget.
if self._suspend_mouse_input:
clicking, dragging, wheel = False, False, 0
dx, dy = 0, 0
return EasyDict(
clicking=clicking,
dragging=dragging,
wheel=wheel,
cx=int(cx * self.pixel_ratio),
cy=int(cy * self.pixel_ratio),
dx=int(dx * self.pixel_ratio),
dy=int(dy * self.pixel_ratio)
)
def _load_and_return_wsi(
self,
path: Optional[str] = None,
stride: Optional[int] = None,
use_rois: bool = True,
tile_px: Optional[int] = None,
tile_um: Optional[Union[str, int]] = None,
**kwargs
) -> Optional[sf.WSI]:
"""Load and return a Whole-Slide Image, with modified parameters.
Args:
path (str, optional): Path to the slide to reload. If not provided,
will reload the currently loaded slide.
stride (int, optional): Stride to use for the loaded slide. If not
provided, will use the stride value from the currently loaded
slide.
use_rois (bool): Use ROIs from the loaded project, if available.
Returns:
slideflow.WSI: Reloaded slide.
"""
if self.wsi is None and path is None:
return None
# Path to slide.
if path is None:
path = self.wsi.path
# Stride.
if stride is None and self.wsi is None:
stride = 1
elif stride is None:
stride = self.wsi.stride_div
# ROI filter method.
if self.wsi is None:
roi_filter_method = 'center'
else:
roi_filter_method = self.wsi.roi_filter_method
# ROIs.
if self.wsi is not None and path == self.wsi.path:
roi_method = self.wsi.roi_method
prior_rois = self.wsi.rois
rois = None
elif self.P and use_rois:
rois = self.P.dataset().rois()
roi_method, prior_rois = None, None
else:
roi_method, prior_rois, rois = None, None, None
# Cap the number of workers in the CUCIM backend.
if sf.slide_backend() == 'cucim':
kwargs['num_workers'] = sf.util.num_cpu(default=4)
# Tile size.
if tile_px is None:
tile_px = (self.tile_px if self.tile_px else 256)
if tile_um is None:
tile_um = (self.tile_um if self.tile_um else 512)
# Pass through QC mask if the slide is already loaded.
qc_mask = None if not self.wsi else self.wsi.get_qc_mask(roi=False)
try:
wsi = sf.WSI(
path,
tile_px=tile_px,
tile_um=tile_um,
stride_div=stride,
rois=rois,
cache_kw=dict(
tile_width=512,
tile_height=512,
max_tiles=-1,
threaded=True,
persistent=True
),
verbose=False,
mpp=self.slide_widget.manual_mpp,
use_bounds=self.settings_widget.use_bounds,
roi_filter_method=roi_filter_method,
simplify_roi_tolerance=self.settings_widget.simplify_tolerance,
**kwargs)
except sf.errors.IncompatibleBackendError:
self.create_toast(
title="Incompatbile slide",
message='Slide type "{}" is incompatible with the {} backend.'.format(
sf.util.path_to_ext(path), sf.slide_backend()),
icon='error'
)
return None
else:
# Reapply QC
if qc_mask is not None:
wsi.apply_qc_mask(qc_mask)
# Reapply ROIs
if prior_rois is not None:
wsi.rois = prior_rois
wsi.roi_method = roi_method
wsi.process_rois()
return wsi
[docs] def reload_wsi(
self,
slide: Optional[Union[str, sf.WSI]] = None,
stride: Optional[int] = None,
use_rois: bool = True,
tile_px: Optional[int] = None,
tile_um: Optional[Union[str, int]] = None,
**kwargs
) -> bool:
"""Reload the currently loaded Whole-Slide Image.
Args:
path (str or sf.WSI, optional): Slide to reload. May be a path
or a sf.WSI object. If not provided, will reload the
currently loaded slide.
stride (int, optional): Stride to use for the loaded slide. If not
provided, will use the stride value from the currently loaded
slide.
use_rois (bool): Use ROIs from the loaded project, if available.
Returns:
bool: True if slide loaded successfully, False otherwise.
"""
if isinstance(slide, sf.WSI):
wsi = slide
else:
wsi = self._load_and_return_wsi(
slide, stride, use_rois, tile_px, tile_um, **kwargs
)
if wsi:
self.wsi = wsi
old_viewer = self.viewer
self.set_viewer(SlideViewer(wsi, **self._viewer_kwargs()))
self.set_title(os.path.basename(wsi.path))
if isinstance(old_viewer, SlideViewer):
self.viewer.show_thumbnail = old_viewer.show_thumbnail
self.viewer.show_scale = old_viewer.show_scale
return True
else:
return False
def _render_prediction_message(self, message: str) -> None:
"""Render a prediction string to below the tile bounding box.
Args:
message (str): Message to render.
"""
max_w = self.content_frame_width - self.offset_x_pixels
max_h = self.content_frame_height - self.offset_y_pixels
tex = text_utils.get_texture(
message,
size=self.gl_font_size,
max_width=max_w,
max_height=max_h,
outline=2
)
box_w = self.viewer.full_extract_px / self.viewer.view_zoom
text_pos = np.array([self.box_x + (box_w/2), self.box_y + box_w + self.font_size])
tex.draw(pos=text_pos, align=0.5, rint=True, color=1)
def _render_control_pane_contents(self) -> None:
"""Perform rendering of control panel contents, such as WSI thumbnails,
widgets, and heatmaps."""
# Render WSI thumbnail in the widget.
if self.wsi_thumb is not None:
if self._wsi_tex_img is not self.wsi_thumb:
self._wsi_tex_img = self.wsi_thumb
if self._wsi_tex_obj is None or not self._wsi_tex_obj.is_compatible(image=self._wsi_tex_img):
if self._wsi_tex_obj is not None:
self._tex_to_delete += [self._wsi_tex_obj]
self._wsi_tex_obj = gl_utils.Texture(image=self._wsi_tex_img, bilinear=True, mipmap=True)
else:
self._wsi_tex_obj.update(self._wsi_tex_img)
# Display rendered (non-transparent) heatmap in widget.
# Render overlay heatmap.
if self.heatmap:
if self._heatmap_tex_img is not self.rendered_heatmap:
self._heatmap_tex_img = self.rendered_heatmap
if self._heatmap_tex_obj is None or not self._heatmap_tex_obj.is_compatible(image=self._heatmap_tex_img):
if self._heatmap_tex_obj is not None:
self._tex_to_delete += [self._heatmap_tex_obj]
self._heatmap_tex_obj = gl_utils.Texture(image=self._heatmap_tex_img, bilinear=False, mipmap=False)
else:
self._heatmap_tex_obj.update(self._heatmap_tex_img)
def _viewer_kwargs(self) -> Dict[str, Any]:
"""Keyword arguments to use for loading a Viewer."""
return dict(
width=self.content_frame_width - self.offset_x_pixels,
height=self.content_frame_height - self.offset_y_pixels,
x_offset=self.offset_x_pixels,
y_offset=self.offset_y_pixels,
normalizer=(self._normalizer if self._normalize_wsi else None),
viz=self
)
def _update_window_limits(self):
"""Update the minimum window size limits based on loaded widgets."""
minheight = (((len(self.sidebar.navbuttons) + 3)
* (self.sidebar.navbutton_width / (self.font_size / 22)))
+ self.status_bar_height
+ self.menu_bar_height)
glfw.set_window_size_limits(
self._glfw_window,
minwidth=int(self.sidebar.content_width+100),
minheight=int(minheight),
maxwidth=-1,
maxheight=-1)
# --- Imgui methods -------------------------------------------------------
[docs] @contextmanager
def dim_text(self, dim=True):
"""Render dim text.
Examples
Render dim text.
.. code-block:: python
with studio.dim_text():
imgui.text('This is dim')
"""
if dim:
imgui.push_style_color(imgui.COLOR_TEXT, *self.theme.dim)
yield
if dim:
imgui.pop_style_color(1)
[docs] @contextmanager
def highlighted(self, enable: bool = True):
"""Render highlighted text.
Args:
enable (bool): Whether to enable highlighting.
Examples
Render highlighted text.
.. code-block:: python
with studio.highlighted(True):
imgui.text('This is highlighted')
"""
if enable:
imgui.push_style_color(imgui.COLOR_BUTTON, *self.theme.button_active)
yield
if enable:
imgui.pop_style_color(1)
[docs] def center_next_window(self, width, height):
"""Center the next imgui window.
Args:
width (int): Width of the next window.
height (int): Height of the next window.
"""
imgui.set_next_window_position(
(self.content_width - width) / 2,
(self.content_height - height - self.status_bar_height) / 2
)
# --- Public methods ------------------------------------------------------
[docs] def reset_background(self):
"""Reset the Studio background to the default theme color."""
self._background_color = self.theme.main_background
[docs] def add_to_render_pipeline(
self,
renderer: Any,
name: Optional[str] = None
) -> None:
"""Add a renderer to the rendering pipeline."""
if name is not None:
self._addl_renderers[name] = renderer
self._render_manager.add_to_render_pipeline(renderer)
[docs] def remove_from_render_pipeline(self, name: str):
"""Remove a renderer from the render pipeline.
Remove a renderer added with ``.add_to_render_pipeline()``.
Args:
name (str): Name of the renderer to remove.
"""
if name not in self._addl_renderers:
raise ValueError(f'Could not find renderer "{name}"')
renderer = self._addl_renderers[name]
if self._render_manager is not None:
self._render_manager.remove_from_render_pipeline(renderer)
del self._addl_renderers[name]
[docs] def ask_load_heatmap(self):
"""Prompt user for location of exported heatmap (\*.npz) and load."""
npz_path = askopenfilename(title="Load heatmap...", filetypes=[("*.npz", "*.npz")])
if npz_path:
self.load_heatmap(npz_path)
[docs] def ask_load_model(self):
"""Prompt user for location of a model and load."""
if sf.backend() == 'tensorflow':
model_path = askdirectory(title="Load model (directory)...")
else:
model_path = askopenfilename(title="Load model...", filetypes=[("zip", ".zip"), ("All files", ".*")])
if model_path:
self.load_model(model_path, ignore_errors=True)
[docs] def ask_load_project(self):
"""Prompt user for location of a project and load."""
project_path = askdirectory(title="Load project (directory)...")
if project_path:
self.load_project(project_path, ignore_errors=True)
[docs] def ask_load_slide(self):
"""Prompt user for location of a slide and load."""
slide_path = askopenfilename(title="Load slide...", filetypes=[("All files", ".*"),
("Aperio ScanScope", ("*.svs", "*.svslide")),
("Hamamatsu", ("*.ndpi", "*.vms", "*.vmu")),
("Leica", "*.scn"),
("MIRAX", "*.mrxs"),
("Roche, Ventana", "*.bif"),
("Pyramid TIFF", ("*.tiff", "*.tif")),
("JPEG", (".jpg", "*.jpeg"))])
if slide_path:
self.load_slide(slide_path, ignore_errors=True)
[docs] def autoload(self, path, ignore_errors=False):
"""Automatically load a path, detecting the type of object to load.
Supports slides, models, projects, and other items supported by
widgets if the widget has implemented a `.drag_and_drop_hook` function.
Args:
path (str): Path to file to load.
ignore_errors (bool): Gracefully handle errors.
"""
sf.log.info(f"Auto-loading [green]{path}[/]")
if sf.util.is_project(path):
self.load_project(path, ignore_errors=ignore_errors)
elif sf.util.is_slide(path):
self.load_slide(path, ignore_errors=ignore_errors)
elif sf.util.is_model(path) or path.endswith('tflite'):
self.load_model(path, ignore_errors=ignore_errors)
elif path.endswith('npz'):
self.load_heatmap(path)
else:
# See if any widgets implement a drag_and_drop_hook() method
handled = False
for widget in self.widgets:
sf.log.info(f"Attempting load with widget {widget}")
if hasattr(widget, 'drag_and_drop_hook'):
if widget.drag_and_drop_hook(path):
handled = True
break
if not handled:
self.create_toast(f"No loading handler found for {path}", icon="error")
[docs] def clear_overlay(self) -> None:
"""Remove the current overlay image, include heatmaps and masks."""
self.overlay = None
self.overlay_original = None
if self.viewer is not None:
self.viewer.clear_overlay()
[docs] def clear_result(self) -> None:
"""Clear all shown results and images."""
self.clear_model_results()
self.clear_overlay()
self.result = EasyDict()
self.args = EasyDict()
self._wsi_tex_img = None
if self.viewer:
self.viewer.clear()
[docs] def clear_message(self, msg: str = None) -> bool:
"""Clear a specific message from display, if the message is being shown.
Args:
msg (str): Message to clear.
Returns:
bool: Whether message was cleared from display.
"""
if msg is None or self._message == msg:
self._message = None
return True
return False
def clear_prediction_message(self) -> None:
self._pred_message = None
[docs] def clear_model_results(self) -> None:
"""Clear all model results and associated images."""
if self._render_manager is not None:
self._render_manager.clear_result()
self._predictions = None
self._norm_tex_img = None
self._norm_tex_obj = None
self._heatmap_tex_img = None
self._heatmap_tex_obj = None
if self.viewer is not None:
self.viewer.clear_overlay()
[docs] def close(self) -> None:
"""Close the application and renderer."""
super().close()
if self._render_manager is not None:
self._render_manager.close()
self._render_manager = None
if hasattr(self.viewer, 'close'):
self.viewer.close()
for w in self.widgets:
if hasattr(w, 'close'):
w.close()
[docs] def close_model(self, now: bool = False) -> None:
"""Close the currently loaded model.
Args:
now (bool): Close the model now, instead of at the end of the frame.
Defaults to False (closes model at frame end).
"""
if now:
self._close_model_now()
self._should_close_model = False
else:
self._should_close_model = True
[docs] def close_slide(self, now: bool = False) -> None:
"""Close the currently loaded slide.
Args:
now (bool): Close the slide now, instead of at the end of the frame.
Defaults to False (closes slide at frame end).
"""
if now:
self._close_slide_now()
self._should_close_slide = False
else:
self._should_close_slide = True
[docs] def defer_rendering(self, num_frames: int = 1) -> None:
"""Defer rendering for a number of frames."""
self._defer_rendering = max(self._defer_rendering, num_frames)
[docs] def draw_frame(self) -> None:
"""Main draw loop."""
self.begin_frame()
self.args = EasyDict(use_model=False, use_uncertainty=False, use_saliency=False)
self.button_w = self.font_size * 5
self.label_w = round(self.font_size * 4.5)
self.menu_bar_height = self.font_size + self.spacing/2
max_w = self.content_frame_width - self.offset_x_pixels
max_h = self.content_frame_height - self.offset_y_pixels
window_changed = (self._content_width != self.content_width
or self._content_height != self.content_height
or self._pane_w != self.pane_w)
# Process drag-and-drop files
paths = self.pop_drag_and_drop_paths()
if paths is not None and len(paths) >= 1:
self.autoload(paths[0], ignore_errors=True)
self._clear_textures()
self._draw_control_pane()
self._draw_menu_bar()
self._draw_about_dialog()
self._draw_mpp_zoom_dialog()
user_input = self._handle_user_input()
# Re-generate WSI view if the window size changed, or if we don't
# yet have a SlideViewer initialized.
if window_changed:
self._content_width = self.content_width
self._content_height = self.content_height
self._pane_w = self.pane_w
for widget in self.widgets:
if hasattr(widget, '_on_window_change'):
widget._on_window_change()
# Main display.
if self.viewer:
self.viewer.update(**self._viewer_kwargs())
self._draw_main_view(user_input, window_changed)
else:
self._draw_empty_background()
# --- Render arguments ------------------------------------------------
self.args.x = self.x
self.args.y = self.y
self.args.tile_px = self.tile_px
self.args.tile_um = self.tile_um
if (self._model_config is not None and self._use_model):
self.args.tile_px = self._model_config['tile_px']
self.args.tile_um = self._model_config['tile_um']
if 'img_format' in self._model_config and self._use_model_img_fmt:
self.args.img_format = self._model_config['img_format']
self.args.use_model = self._use_model
self.args.use_uncertainty = (self.has_uq() and self._use_uncertainty)
self.args.use_saliency = self._use_saliency
self.args.normalizer = self._normalizer
# Buffer tile view if using a live viewer.
if self.has_live_viewer() and self.args.x and self.args.y:
if (self._render_manager.is_async
and self._render_manager._args_queue.qsize() > 2):
if self._defer_tile_refresh is None:
self._defer_tile_refresh = time.time()
self.defer_rendering()
elif time.time() - self._defer_tile_refresh < 2:
self.defer_rendering()
else:
self._defer_tile_refresh = None
self.viewer.x = self.x
self.viewer.y = self.y
self.args.full_image = self.viewer.tile_view
self.args.tile_px = self.viewer.tile_px
self.args.tile_um = self.viewer.tile_um
self.viewer.apply_args(self.args)
if self.has_live_viewer():
self.args.viewer = None
else:
self.args.viewer = self.viewer
# ---------------------------------------------------------------------
# Render control pane contents.
self._render_control_pane_contents()
if self.is_skipping_frames():
pass
elif self._defer_rendering > 0:
self._defer_rendering -= 1
else:
self._render_manager.set_args(**self.args)
result = self._render_manager.get_result()
if result is not None:
self.result = result
if 'predictions' in result:
self._predictions = result.predictions
self._uncertainty = result.uncertainty
# Update input image textures (tile view).
middle_pos = np.array([self.offset_x_pixels + max_w/2, max_h/2])
if 'image' in self.result:
if self._tex_img is not self.result.image:
self._tex_img = self.result.image
if self._tex_obj is None or not self._tex_obj.is_compatible(image=self._tex_img):
if self._tex_obj is not None:
self._tex_to_delete += [self._tex_obj]
self._tex_obj = gl_utils.Texture(image=self._tex_img, bilinear=False, mipmap=False)
else:
self._tex_obj.update(self._tex_img)
if 'normalized' in self.result:
if self._norm_tex_img is not self.result.normalized:
self._norm_tex_img = self.result.normalized
if self._norm_tex_obj is None or not self._norm_tex_obj.is_compatible(image=self._norm_tex_img):
if self._norm_tex_obj is not None:
self._tex_to_delete += [self._norm_tex_obj]
self._norm_tex_obj = gl_utils.Texture(image=self._norm_tex_img, bilinear=False, mipmap=False)
else:
self._norm_tex_obj.update(self._norm_tex_img)
if 'error' in self.result:
self.print_error(self.result.error)
if 'message' not in self.result:
self.result.message = str(self.result.error)
if 'message' in self.result or self.message:
_msg = self.message if 'message' not in self.result else self.result['message']
tex = text_utils.get_texture(_msg, size=self.gl_font_size, max_width=max_w, max_height=max_h, outline=2)
tex.draw(pos=middle_pos, align=0.5, rint=True, color=1)
# Render user widgets.
for widget in self.widgets:
if hasattr(widget, 'render'):
widget.render()
# Render slide widget tile boxes (for tile extraction preview)
self.slide_widget.early_render()
# Render the tile view and status bar.
self._draw_tile_view()
self._draw_status_bar()
# Draw prediction message next to box on main display.
if self._pred_message and self.viewer is not None:
self._render_prediction_message(self._pred_message)
elif (self._use_model
and self._predictions is not None
and not isinstance(self._predictions, list)
and self.viewer is not None):
if not hasattr(self.result, 'in_focus') or self.result.in_focus:
pred_str = prediction_to_string(
predictions=self._predictions,
outcomes=self._model_config['outcome_labels'],
is_classification=(self._model_config['model_type'] == 'classification')
)
self._render_prediction_message(pred_str)
# End frame.
if self._should_close_model:
self.close_model(True)
if self._should_close_slide:
self.close_slide(True)
self.end_frame()
[docs] def get_renderer(self, name: Optional[str] = None) -> Optional[Renderer]:
"""Check for the given additional renderer in the rendering pipeline.
Args:
name (str): Name of the renderer to check for. If None,
returns the main renderer.
Returns:
Renderer if name is a recognized renderer, otherwise None
"""
if name is None:
if (self._render_manager is not None
and self._render_manager._renderer_obj is not None):
return self._render_manager._renderer_obj
else:
return None
elif name in self._addl_renderers:
return self._addl_renderers[name]
else:
return None
[docs] def get_extension(self, tag: str) -> Optional[Widget]:
"""Returns a given widget (extension) by tag.
Args:
tag (str): Tag of the widget to search for.
Returns:
slideflow.studio.widgets.Widget if found, else None
"""
for w in self.widgets:
if w.tag == tag:
return w
return None
[docs] def has_live_viewer(self) -> bool:
"""Check if the current viewer is a live viewer (e.g. camera feed)."""
return (self.viewer is not None and self.viewer.live)
[docs] def has_uq(self) -> bool:
"""Check if the current model supports uncertainty quantification."""
return (self._model_path is not None
and self._model_config is not None
and 'uq' in self._model_config['hp']
and self._model_config['hp']['uq'])
[docs] def ask_zoom_to_mpp(self) -> None:
"""Prompt the user to zoom to a specific microns-per-pixel (MPP)."""
if self.viewer and isinstance(self.viewer, SlideViewer):
self._show_mpp_zoom_popup = True
[docs] def increase_tile_zoom(self) -> None:
"""Increase zoom of tile view two-fold."""
self.tile_zoom *= 2
[docs] def decrease_tile_zoom(self) -> None:
"""Decrease zoom of tile view by half."""
self.tile_zoom /= 2
[docs] def reset_tile_zoom(self) -> None:
"""Reset tile zoom level."""
self.tile_zoom = 1
[docs] def load_heatmap(self, path: Union[str, "sf.Heatmap"]) -> None:
"""Load a saved heatmap (\*.npz).
Args:
path (str): Path to exported heatmap in \*.npz format, as generated
by Heatmap.save() or Heatmap.save_npz().
"""
if self._model_config is None:
self.create_toast(
"Unable to load heatmap; model must also be loaded.",
icon="error"
)
return
try:
self.heatmap_widget.load(path)
self.create_toast(f"Loaded heatmap at {path}", icon="success")
except Exception as e:
log.warn("Exception raised loading heatmap: {}".format(e))
self.create_toast(f"Error loading heatmap at {path}", icon="error")
[docs] def load_model(self, model: str, ignore_errors: bool = False) -> None:
"""Load the given model.
Args:
model (str): Path to Slideflow model (in either backend).
ignore_errors (bool): Do not fail if an error is encountered.
Defaults to False.
"""
log.debug("Loading model from Studio")
self.close_model(True)
log.debug("Model closed")
self.clear_result()
log.debug("Model result cleared")
self.skip_frame() # The input field will change on next frame.
self._render_manager.get_result() # Flush prior result
self._render_manager.clear_result()
try:
# Trigger user widgets
for widget in self.widgets:
if hasattr(widget, '_before_model_load'):
widget._before_model_load()
self.defer_rendering()
self.model_widget.user_model = model
# Read model configuration
config = sf.util.get_model_config(model)
normalizer = sf.util.get_model_normalizer(model)
self.result.message = f'Loading {config["model_name"]}...'
self.defer_rendering()
self._use_model = True
self._model_path = model
self._model_config = config
self._normalizer = normalizer
self._predictions = None
self._uncertainty = None
self._use_uncertainty = 'uq' in config['hp'] and config['hp']['uq']
self.tile_um = config['tile_um']
self.tile_px = config['tile_px']
self._render_manager.load_model(model)
if sf.util.torch_available and sf.util.path_to_ext(model) == 'zip':
self.model_widget.backend = 'torch'
else:
self.model_widget.backend = 'tensorflow'
# Update widgets
log.debug("Updating widgets")
self.model_widget.reset()
self.model_widget.cur_model = model
self.model_widget.use_model = True
self.model_widget.use_uncertainty = 'uq' in config['hp'] and config['hp']['uq']
if normalizer is not None and hasattr(self, 'slide_widget'):
self.slide_widget.add_model_normalizer_option()
self.slide_widget.norm_idx = len(self.slide_widget._normalizer_methods)-1
if self.wsi:
log.debug(f"Loading slide... tile_px={self.tile_px}, tile_um={self.tile_um}")
self.slide_widget.load(
self.wsi.path,
mpp=self.slide_widget.manual_mpp,
ignore_errors=ignore_errors
)
if hasattr(self, 'heatmap_widget'):
log.debug("Resetting heatmap")
self.heatmap_widget.reset()
if not self.sidebar.expanded:
self.sidebar.selected = 'model'
self.sidebar.expanded = True
# Update viewer
self._show_tile_preview = True
log.debug("Updating viewer with tile_px={}, tile_um={}".format(self.tile_px, self.tile_um))
if self.viewer and not isinstance(self.viewer, SlideViewer):
self.viewer.set_tile_px(self.tile_px)
self.viewer.set_tile_um(self.tile_um)
# Trigger user widgets
for widget in self.widgets:
if hasattr(widget, '_on_model_load'):
widget._on_model_load()
self.create_toast(f"Loaded model at {model}", icon="success")
except Exception as e:
self.model_widget.cur_model = None
if model == '':
log.debug("Exception raised: no model loaded.")
self.result = EasyDict(message='No model loaded')
else:
log.warn("Exception raised (ignore_errors={}): {}".format(ignore_errors, e))
self.create_toast(f"Error loading model at {model}", icon="error")
self.result = EasyDict(error=CapturedException())
if not ignore_errors:
raise
log.debug("Model loading complete (path={})".format(self._model_path))
[docs] def load_project(self, project: str, ignore_errors: bool = False) -> None:
"""Load the given project.
Args:
project (str): Path to Slideflow project.
ignore_errors (bool): Do not fail if an error is encountered.
Defaults to False.
"""
self.project_widget.load(project, ignore_errors=ignore_errors)
[docs] def load_slide(self, slide: str, **kwargs) -> None:
"""Load the given slide.
Args:
slide (str): Path to whole-slide image.
stride (int, optional): Stride for tiles. 1 is non-overlapping
tiles, 2 is tiles with 50% overlap, etc. Defaults to 1.
ignore_errors (bool): Do not fail if an error is encountered.
Defaults to False.
"""
self.slide_widget.load(slide, **kwargs)
# Trigger user widgets
for widget in self.widgets:
if hasattr(widget, '_on_slide_load'):
widget._on_slide_load()
[docs] def print_error(self, error: str) -> None:
"""Print the given error message."""
error = str(error)
if error != self._last_error_print:
print('\n' + error + '\n')
self._last_error_print = error
[docs] def reload_model(self) -> None:
"""Reload the current model."""
self._render_manager.load_model(self._model_path)
[docs] def reload_viewer(self) -> None:
"""Reload the current main viewer."""
if self.viewer is not None:
self.viewer.close()
if isinstance(self.viewer, SlideViewer):
old_viewer = self.viewer
self.set_viewer(SlideViewer(self.wsi, **self._viewer_kwargs()))
self.viewer.show_thumbnail = old_viewer.show_thumbnail
self.viewer.show_scale = old_viewer.show_scale
else:
self.viewer.reload(**self._viewer_kwargs())
[docs] def set_message(self, msg: str) -> None:
"""Set a message for display."""
self._message = msg
[docs] def set_status_message(
self,
message: str,
description: Optional[str] = None,
*,
color: Optional[Tuple[float, float, float]] = (0.7, 0, 0, 1),
text_color: Tuple[float, float, float, float] = (1, 1, 1, 1),
rounding: int = 0
) -> None:
"""Set the status message to display in the status bar."""
if not message:
self.clear_status_message()
return
self._status_message = StatusMessage(
self,
message,
description=description,
color=color,
text_color=text_color,
rounding=rounding
)
[docs] def clear_status_message(self) -> None:
"""Clear the status message from the status bar."""
self._status_message = None
[docs] def set_prediction_message(self, msg: str) -> None:
"""Set the prediction message to display under the tile outline."""
self._pred_message = msg
[docs] def set_overlay(
self,
overlay: np.ndarray,
method: int,
*,
original: Optional[np.ndarray] = None
) -> None:
"""Configure the overlay to be applied to the current view screen.
Overlay is a numpy array, and method is a flag indicating the
method to use when showing the overlay.
If ``method`` is ``sf.studio.OVERLAY_WSI``, the array will be mapped
to the entire whole-slide image, without offsets.
If ``method`` is ``sf.studio.OVERLAY_GRID``, the array is interpreted
as having been generated from the slide's grid, meaning that an offset
will be applied to ensure that the overlay is aligned properly.
If ``method`` is ``sf.studio.OVERLAY_VIEW``, the array is interpreted
as an overlay that is applied only to the area of the slide
currently in view.
Args:
overlay (np.ndarray): Overlay to render.
method (int): Mapping method for linking the overlay to the
whole-slide image.
Keyword args:
original (np.ndarray, optional): Original grid values before any
colorization or other modifications. Used for displaying the
tooltip when alt-hovering. Defaults to None.
"""
if self.viewer is None:
raise ValueError("Unable to set overlay; viewer not loaded.")
if original is not None and original.shape != overlay.shape:
raise ValueError("Unable to set grid overlay; original grid shape "
"does not match grid shape.")
self.overlay = overlay
self.overlay_original = original
if method == OVERLAY_WSI:
# Overlay maps to the entire whole-slide image,
# with no offset needed.
self._overlay_wsi_dim = self.wsi.dimensions
self._overlay_offset_wsi_dim = (0, 0)
elif method == OVERLAY_GRID:
# Overlay was generated from the slide's grid, meaning
# that we need to apply an offset to ensure the overlay
# lines up apppropriately.
self.set_grid_overlay(overlay)
elif method == OVERLAY_VIEW:
# Overlay should only apply to the area of the WSI
# currently in view.
self._overlay_wsi_dim = self.viewer.wsi_window_size
self._overlay_offset_wsi_dim = self.viewer.origin
else:
raise ValueError(f"Unrecognized method {method}")
[docs] def set_grid_overlay(
self,
grid: np.ndarray,
*,
tile_um: Optional[int] = None,
stride_div: Optional[int] = None,
mpp: Optional[float] = None,
original: Optional[np.ndarray] = None
) -> None:
"""Set the grid overlay to the given grid.
Args:
grid (np.ndarray): Grid to render as an overlay.
Keyword args:
tile_um (int, optional): Tile size, in microns. If None, uses
the tile size of the currently loaded slide.
stride_div (int, optional): Stride divisor. If None, uses
the stride divisor of the currently loaded slide.
mpp (float, optional): Microns per pixel. If None, uses
the MPP of the currently loaded slide.
original (np.ndarray, optional): Original grid values before any
colorization or other modifications. Used for displaying the
tooltip when alt-hovering. Defaults to None.
"""
if self.viewer is None:
raise ValueError("Unable to set grid overlay; viewer not loaded.")
if any(x is None for x in (tile_um, stride_div, mpp)) and self.wsi is None:
raise ValueError("Unable to set grid overlay; no slide loaded.")
if original is not None and original.shape[0:2] != grid.shape[0:2]:
raise ValueError("Unable to set grid overlay; original grid shape "
"({}) does not match grid shape ({}).".format(
original.shape, grid.shape
))
self.overlay = grid
self.overlay_original = original
if tile_um is None:
tile_um = self.wsi.tile_um
if stride_div is None:
stride_div = self.wsi.stride_div
if mpp is None:
mpp = self.wsi.mpp
full_extract = int(tile_um / mpp)
wsi_stride = int(full_extract / stride_div)
self._overlay_wsi_dim = (wsi_stride * (grid.shape[1]),
wsi_stride * (grid.shape[0]))
self._overlay_offset_wsi_dim = (full_extract/2 - wsi_stride/2,
full_extract/2 - wsi_stride/2)
[docs] def set_viewer(self, viewer: Any) -> None:
"""Set the main viewer.
Args:
viewer (:class:`slideflow.studio.gui.viewer.Viewer`): Viewer to use.
"""
log.debug("Setting viewer to {}".format(viewer))
if self.viewer is not None:
self.viewer.close()
self.viewer = viewer
self._render_manager._live_updates = viewer.live
self._render_manager.set_async(viewer.live)
# -----------------------------------------------------------------------------
# -----------------------------------------------------------------------------