--- a +++ b/slideflow/studio/widgets/roi.py @@ -0,0 +1,2004 @@ +import imgui +import numpy as np +import glfw +import os +import copy +import OpenGL.GL as gl +from collections import defaultdict +from os.path import join, exists, dirname +from shapely.geometry import Point, Polygon +from shapely.ops import unary_union, polygonize +from tkinter.filedialog import askopenfilename +from typing import Optional, Tuple, List, Union, Any, Dict + +from ..gui import imgui_utils, text_utils, gl_utils +from ..gui.hover_button import HoverButton +from ..gui.annotator import SlideAnnotationCapture +from ..gui.viewer import SlideViewer +from ..utils import LEFT_MOUSE_BUTTON, RIGHT_MOUSE_BUTTON + +import slideflow as sf + +#---------------------------------------------------------------------------- + +class ROIWidget: + def __init__(self, viz: "sf.studio.Studio") -> None: + """Widget for ROI processing control and information display. + + Args: + viz (:class:`slideflow.studio.Studio`): The parent Slideflow Studio + object. + + """ + self.viz = viz + self.editing = False + self.capturing = False + self.capture_type = 'freehand' + self.subtracting = False + self.roi_toast = None + self.annotator = SlideAnnotationCapture(viz, named=False) + self.hover_button = HoverButton(viz) + self.roi_grid = [] # Rasterized grid of ROIs in view. + self.unique_roi_labels = [] + self.use_rois = True + self._fill_rois = True + + # Internals + self._showed_toast_for_freehand = False + self._showed_toast_for_polygon = False + self._showed_toast_for_point = False + self._showed_toast_for_subtract = False + self._showed_toast_for_edit = False + self._late_render = [] + self._should_show_roi_ctx_menu = False + self._roi_ctx_menu_items = [] + self._show_roi_label_menu = None + self._ctx_mouse_pos = None + self._is_clicking_ctx_menu = False + self._roi_hovering = False + self._selected_rois = [] + self._show_roi_new_label_popup = None + self._new_label_popup_is_new = True + self._input_new_label = '' + self._roi_colors = {None: (0.278, 0.592, 0.808)} + self._last_view_params = None + self._editing_label = None + self._editing_label_is_new = True + self._should_show_advanced_editing_window = True + self._should_deselect_roi_on_mouse_up = True + self._mouse_is_down = False + self._advanced_editor_is_new = True + self._roi_filter_perc = 0.5 + self._roi_filter_center = True + self._capturing_roi_filter_perc = False + self._vertex_editor = None + self._showing = False + self._last_colored_list_hovered = None + + @property + def roi_filter_method(self) -> Union[str, float]: + """Get the current ROI filter method.""" + return ('center' if self._roi_filter_center else self._roi_filter_perc) + + # --- Internal ------------------------------------------------------------ + + def reset_edit_state(self) -> None: + """Reset the state of the ROI editor.""" + self.disable_roi_capture() + self.disable_subtracting() + self._should_show_advanced_editing_window = True + if self.roi_toast is not None: + self.roi_toast.done() + + def _get_rois_at_mouse(self) -> List[int]: + """Get indices of ROI(s) at the current mouse position.""" + + # There are two ways to get the ROIs at the mouse position. + # First, we can iterate through each ROI and check if the mouse point + # is contained within the ROI polygon. + # We do this if the rasterized ROI grid is not available. + if self.roi_grid is None: + mouse_point = Point(self.viz.get_mouse_pos()) + possible_rois = [] + for roi_id, roi_array in self.viz.viewer.scaled_rois_in_view.items(): + try: + roi_poly = sf.slide.ROI(None, roi_array).poly + except sf.errors.InvalidROIError: + continue + try: + # Apply holes + for hole_array in self.viz.viewer.scaled_holes_in_view[roi_id].values(): + try: + hole_roi = sf.slide.ROI(None, hole_array) + except sf.errors.InvalidROIError: + continue + else: + roi_poly = roi_poly.difference(hole_roi.poly) + except ValueError: + continue + if roi_poly.contains(mouse_point): + possible_rois.append(roi_id) + return possible_rois + + # However, it is also possible to do this more efficiently by + # rasterizing the ROIs and checking if the mouse point is contained + # within the ROI mask. We do this if the rasterized ROI grid is + # available. The ROI grid rasterization is done in the background + # thread, so we need to check if it is available. + else: + mx, my = map(int, self.viz.get_mouse_pos()) + mx -= self.viz.viewer.x_offset + my -= self.viz.viewer.y_offset + if (mx >= self.roi_grid.shape[0] + or my >= self.roi_grid.shape[1] + or mx < 0 + or my < 0): + return [] + all_rois = self.roi_grid[mx, my, :] + return (all_rois[all_rois.nonzero()] - 1).tolist() + + def _process_capture(self) -> None: + """Process a newly captured ROI. + + If the ROI is valid, it is added to the slide and rendered. + + """ + viz = self.viz + + if self.capture_type == 'freehand': + new_annotation, annotation_name = self.annotator.capture() + elif self.capture_type == 'polygon': + new_annotation, annotation_name = self.annotator.capture_polygon() + elif self.capture_type == 'point': + new_annotation, annotation_name = self.annotator.capture_point() + else: + raise ValueError(f"Invalid capture type '{self.capture_type}'.") + + # Render in-progress annotations + if new_annotation is not None and not annotation_name: + self.render_annotation(new_annotation, origin=(viz.viewer.x_offset, viz.viewer.y_offset)) + if annotation_name: + if len(new_annotation) > 2: + # Verify that the annotation is a valid polygon + try: + roi_idx = viz.wsi.load_roi_array(new_annotation) + except sf.errors.InvalidROIError: + viz.create_toast('Invalid shape, unable to add ROI.', icon='error') + return + # Simplify the ROI. + if self.capture_type == 'freehand': + self.simplify_roi([roi_idx], tolerance=5) + # Refresh the ROI view. + viz.viewer.refresh_view() + # Show a label popup if the user has just created a new ROI. + self._show_roi_label_menu = roi_idx + # Update the ROI colors + self.refresh_roi_colors() + + def _process_subtract(self) -> None: + """Process a subtracting ROI.""" + viz = self.viz + + new_annotation, annotation_name = self.annotator.capture() + + # Render in-progress subtraction annotation + if new_annotation is not None and not annotation_name: + self.render_annotation(new_annotation, origin=(viz.viewer.x_offset, viz.viewer.y_offset)) + if annotation_name and len(new_annotation) > 2: + self.subtract_roi_from_selected(new_annotation) + + def _set_button_style(self) -> None: + """Set the style for the ROI buttons.""" + imgui.push_style_color(imgui.COLOR_BUTTON, 0, 0, 0, 0) + imgui.push_style_var(imgui.STYLE_ITEM_SPACING, [0, 0]) + + def _end_button_style(self) -> None: + """End the style for the ROI buttons.""" + imgui.pop_style_color(1) + imgui.pop_style_var(1) + + def _update_grid(self) -> None: + """Update the rasterized ROI grid.""" + view = self.viz.viewer + if view is None or self.viz.wsi is None: + return + + if not view.is_moving() and (view.view_params != self._last_view_params): + self.roi_grid = view.rasterize_rois_in_view() + self._last_view_params = view.view_params + elif view.is_moving(): + self.roi_grid = None + + def get_roi_dest(self, slide: str, create: bool = False) -> Optional[str]: + """Get the destination for a ROI file.""" + viz = self.viz + if viz.P is None: + return None + dataset = viz.P.dataset() + source = dataset.get_slide_source(slide) + if dataset._roi_set(dataset.get_slide_source(slide)): + filename = (dataset.find_rois(slide) + or join(dataset.sources[source]['roi'], f'{slide}.csv')) + if dirname(filename) and not exists(dirname(filename)) and create: + os.makedirs(dirname(filename)) + return filename + else: + return None + + def ask_load_rois(self) -> None: + """Ask the user to load ROIs from a CSV file.""" + viz = self.viz + path = askopenfilename(title="Load ROIs...", filetypes=[("CSV", "*.csv",)]) + if path: + viz.wsi.load_csv_roi(path) + viz.viewer.refresh_view() + self.reset_edit_state() + + # --- Callbacks ----------------------------------------------------------- + + def keyboard_callback(self, key: int, action: int) -> None: + """Handle keyboard events. + + Args: + key (int): The key that was pressed. See ``glfw.KEY_*``. + action (int): The action that was performed (e.g. ``glfw.PRESS``, + ``glfw.RELEASE``, ``glfw.REPEAT``). + + """ + if (key == glfw.KEY_DELETE and action == glfw.PRESS): + if (self.editing + and self.viz.viewer is not None + and self._selected_rois + and not (self.is_vertex_editing() and self._vertex_editor.any_vertex_selected)): + self.remove_rois(self._selected_rois) + + if self.is_vertex_editing() and self.editing: + self._vertex_editor.keyboard_callback(key, action) + + if key == glfw.KEY_S and action == glfw.PRESS and self.viz._control_down: + self.save_rois() + + # Only process the following shortcuts if the ROI editor pane is showing. + if self._showing: + if key == glfw.KEY_A and action == glfw.PRESS and not self.viz._control_down: + self.toggle_add_roi('freehand') + + if key == glfw.KEY_P and action == glfw.PRESS and not self.viz._control_down: + self.toggle_add_roi('polygon') + + if key == glfw.KEY_E and action == glfw.PRESS: + self.toggle_edit_roi() + + if key == glfw.KEY_L and action == glfw.PRESS and self.viz._control_down: + self.ask_load_rois() + + if key == glfw.KEY_M and action == glfw.PRESS: + self.merge_roi(self._selected_rois) + + if key == glfw.KEY_S and action == glfw.PRESS and not self.viz._shift_down: + self.simplify_roi(self._selected_rois) + + if key == glfw.KEY_S and action == glfw.PRESS and self.viz._shift_down and bool(self._selected_rois): + self.toggle_subtracting() + + if key == glfw.KEY_A and action == glfw.PRESS and self.viz._control_down: + self.select_all() + + if key == glfw.KEY_ESCAPE and action == glfw.PRESS: + self.deselect_all() + self.reset_edit_state() + + def early_render(self) -> None: + """Render elements with OpenGL (before other UI elements are drawn).""" + if self.is_vertex_editing() and self.editing: + self._vertex_editor.draw() + self.annotator.render() + + def late_render(self) -> None: + """Render elements with OpenGL (after other UI elements are drawn). + + Triggers after the slide has been rendered and all other UI elements + are drawn. + + """ + for _ in range(len(self._late_render)): + annotation, name, kwargs = self._late_render.pop() + gl_utils.draw_roi(annotation, **kwargs) + if isinstance(name, str): + tex = text_utils.get_texture( + name, + size=self.viz.gl_font_size, + max_width=self.viz.viewer.width, + max_height=self.viz.viewer.height, + outline=2 + ) + text_pos = (annotation.mean(axis=0)) + tex.draw(pos=text_pos, align=0.5, rint=True, color=1) + + # --- ROI selection and coloring ------------------------------------------ + + def select_rois(self, rois: Union[int, List[int]]) -> None: + """Select ROI(s).""" + if isinstance(rois, int): + rois = [rois] + self._selected_rois = rois + self.viz.viewer.highlight_roi(self._selected_rois) + + def deselect_rois(self, rois: Union[int, List[int]]) -> None: + """Deselect ROI(s).""" + if isinstance(rois, int): + rois = [rois] + self._selected_rois = [r for r in self._selected_rois if r not in rois] + self.viz.viewer.higlight_roi(self._selected_rois) + self.disable_vertex_editing() + + def select_all(self) -> None: + """Select all ROIs.""" + all_rois = list(range(len(self.viz.wsi.rois))) + self.select_rois(all_rois) + + def deselect_all(self) -> None: + """Deselect all ROIs.""" + self._selected_rois = [] + self.viz.viewer.reset_roi_highlight() + self.disable_vertex_editing() + + def get_rois_by_label(self, label: str) -> List[int]: + """Get the indices of ROIs with the given label.""" + return [i for i, r in enumerate(self.viz.wsi.rois) if r.label == label] + + # --- Drawing ------------------------------------------------------------- + + def colored_label_list( + self, + label_list: List[Tuple[str, Tuple[float, float, float], int]], + ) -> Optional[int]: + """Draw a list of colored labels.""" + viz = self.viz + draw_list = imgui.get_window_draw_list() + hovered = None + with imgui.begin_group(): + for i, (label, color, counts) in enumerate(label_list): + r, g, b = color + with imgui.begin_group(): + _color_changed, _color = imgui.color_edit3( + f"##roi_color{i}", + r, g, b, + flags=(imgui.COLOR_EDIT_NO_INPUTS + | imgui.COLOR_EDIT_NO_LABEL + | imgui.COLOR_EDIT_NO_SIDE_PREVIEW + | imgui.COLOR_EDIT_NO_TOOLTIP + | imgui.COLOR_EDIT_NO_DRAG_DROP) + ) + if _color_changed: + self._roi_colors[label] = _color + self.refresh_roi_colors() + _color_highlighted = imgui.is_item_hovered() + imgui.same_line() + if self._editing_label and self._editing_label[0] == i: + if self._editing_label_is_new: + imgui.set_keyboard_focus_here() + self._editing_label_is_new = False + _changed, self._editing_label[1] = imgui.input_text( + f"##edit_roi_label{i}", + self._editing_label[1], + flags=imgui.INPUT_TEXT_ENTER_RETURNS_TRUE + ) + if ((viz.is_mouse_down(LEFT_MOUSE_BUTTON) + or viz.is_mouse_down(RIGHT_MOUSE_BUTTON)) + and not imgui.is_item_hovered()): + self._editing_label = None + self._editing_label_is_new = True + self.viz.resume_keyboard_input() + if _changed: + self.update_label_name( + label, + (self._editing_label[1] if self._editing_label[1] else None) + ) + self._editing_label = None + self._editing_label_is_new = True + self.viz.resume_keyboard_input() + else: + with viz.dim_text(not label): + imgui.text(str(label) if label else '<Unlabeled>') + if imgui.is_item_clicked(): + self.viz.suspend_keyboard_input() + self._editing_label = [i, (str(label) if label else '')] + imgui_utils.right_aligned_text(str(counts), spacing=viz.spacing) + if imgui.is_item_hovered() or self._last_colored_list_hovered == i: + x, y = imgui.get_cursor_screen_position() + y -= (viz.font_size * 1.4) + draw_list.add_rect_filled( + x-viz.spacing, + y-viz.spacing, + x+imgui.get_content_region_max()[0], + y+viz.font_size+(viz.spacing*0.7), + imgui.get_color_u32_rgba(1, 1, 1, 0.05), + int(viz.font_size*0.3)) + self._last_colored_list_hovered = hovered = i + + if (viz.is_mouse_down(LEFT_MOUSE_BUTTON) + and not (_color_changed or _color_highlighted) + and not self._editing_label + and self.editing): + self.select_rois(self.get_rois_by_label(label)) + + if imgui.is_item_hovered() and hovered is None: + hovered = self._last_colored_list_hovered + elif not imgui.is_item_hovered(): + self._last_colored_list_hovered = None + + return hovered + + def draw_roi_filter_capture(self) -> Optional[Union[str, float]]: + """Draw a widget that captures the ROI filter method.""" + + viz = self.viz + capture_success = False + with imgui_utils.grayed_out(not (self.use_rois and viz.wsi.has_rois())): + imgui.text('ROI filter') + imgui.same_line(imgui.get_content_region_max()[0] - 1 - viz.font_size*7) + _center_clicked, self._roi_filter_center = imgui.checkbox('center', self._roi_filter_center) + if _center_clicked: + capture_success = True + imgui.same_line() + with imgui_utils.item_width(viz.font_size * 3), imgui_utils.grayed_out(self._roi_filter_center): + _percent_changed, self._roi_filter_perc = imgui.slider_float( + f'##percent_roi', + self._roi_filter_perc, + min_value=0.01, + max_value=0.99, + format='%.2f' + ) + if _percent_changed and not self._roi_filter_center: + self._capturing_roi_filter_perc = True + + if viz.is_mouse_released() and self._capturing_roi_filter_perc: + capture_success = True + self._capturing_roi_filter_perc = False + if capture_success: + return self.roi_filter_method + else: + return None + + def draw_new_label_popup(self) -> None: + """Prompt the user for a new ROI label.""" + viz = self.viz + window_size = (viz.font_size * 12, viz.font_size * 5.25) + viz.center_next_window(*window_size) + imgui.set_next_window_size(*window_size) + _, opened = imgui.begin('Add New ROI Label', closable=True, flags=imgui.WINDOW_NO_RESIZE) + self.viz.suspend_keyboard_input() + + if not opened: + self._show_roi_new_label_popup = None + self._new_label_popup_is_new = True + self.viz.resume_keyboard_input() + + with imgui_utils.item_width(imgui.get_content_region_max()[0] - viz.spacing*2): + if self._new_label_popup_is_new: + imgui.set_keyboard_focus_here() + self._new_label_popup_is_new = False + _changed, self._input_new_label = imgui.input_text( + '##new_roi_label', + self._input_new_label, + flags=imgui.INPUT_TEXT_ENTER_RETURNS_TRUE + ) + if viz.sidebar.full_button("Add", width=-1) or _changed: + roi_idx = self._show_roi_new_label_popup + viz.wsi.rois[roi_idx].label = self._input_new_label + self.refresh_rois() + self._show_roi_new_label_popup = None + self._input_new_label = '' + self._new_label_popup_is_new = True + self.viz.resume_keyboard_input() + self.viz.viewer.reset_roi_highlight() + imgui.end() + + def should_hide_context_menu(self) -> bool: + viz = self.viz + return (viz.viewer is None # Slide not loaded. + or not self._should_show_roi_ctx_menu # No ROIs to show context menu for. + or not viz.viewer.show_rois # ROIs are not being shown. + or not self.editing # Must be editing ROIs. + or (viz.overlay is not None and viz.show_overlay)) # Overlay is being shown. + + def hide_and_reset_context_menu(self) -> None: + """Hide and reset the ROI context menu.""" + self._should_show_roi_ctx_menu = False + self._roi_ctx_menu_items = [] + if self._show_roi_label_menu is None: + self._ctx_mouse_pos = None + + def remove_context_menu_if_clicked(self, clicked: bool) -> None: + """Remove the ROI context menu if an item has been clicked.""" + viz = self.viz + # Check if the user is currently clicking on the context menu. + if clicked or (viz.is_mouse_down(LEFT_MOUSE_BUTTON) and not imgui.is_window_hovered()): + self._is_clicking_ctx_menu = True + + # Cleanup the window if the user has finished clicking on a context menu item. + if (self._is_clicking_ctx_menu and viz.is_mouse_released(LEFT_MOUSE_BUTTON)): + self.hide_and_reset_context_menu() + self._is_clicking_ctx_menu = False + self._ctx_mouse_pos = None + self.viz.viewer.reset_roi_highlight() + + def draw_context_menu(self) -> None: + """Show the context menu for a ROI.""" + viz = self.viz + if self.should_hide_context_menu(): + self.hide_and_reset_context_menu() + return + + # Update the context menu mouse position and window destination. + if self._ctx_mouse_pos is None: + self._ctx_mouse_pos = self.viz.get_mouse_pos(scale=False) + imgui.set_next_window_position(*self._ctx_mouse_pos) + imgui.begin( + "##roi_context_menu-{}".format('-'.join(map(str, self._roi_ctx_menu_items))), + flags=(imgui.WINDOW_NO_TITLE_BAR | imgui.WINDOW_NO_RESIZE | imgui.WINDOW_NO_MOVE) + ) + + # Draw the context menu. + clicked = False + if len(self._roi_ctx_menu_items) == 1: + with viz.bold_font(): + imgui.text(viz.wsi.rois[self._roi_ctx_menu_items[0]].name) + imgui.separator() + clicked = self._draw_ctx_submenu(self._roi_ctx_menu_items[0]) or clicked + self.viz.viewer.highlight_roi(self._roi_ctx_menu_items[0]) + else: + for roi_idx in self._roi_ctx_menu_items: + if roi_idx < len(viz.wsi.rois): + if imgui.begin_menu(viz.wsi.rois[roi_idx].name): + clicked = self._draw_ctx_submenu(roi_idx) or clicked + imgui.end_menu() + self.viz.viewer.highlight_roi(roi_idx) + + # Cleanup the context menu if the user has clicked on an item. + self.remove_context_menu_if_clicked(clicked) + + imgui.end() + + def draw_label_menu(self) -> None: + """Show the label menu for a ROI.""" + viz = self.viz + if self._show_roi_label_menu is None: + return + if self._ctx_mouse_pos is None: + self._ctx_mouse_pos = self.viz.get_mouse_pos(scale=False) + imgui.set_next_window_position(*self._ctx_mouse_pos) + imgui.begin( + "##roi_label_menu-{}".format((str(self._show_roi_label_menu))), + flags=(imgui.WINDOW_NO_TITLE_BAR | imgui.WINDOW_NO_MOVE) + ) + with viz.bold_font(): + imgui.text("Label") + imgui.separator() + clicked = self._draw_label_submenu(self._show_roi_label_menu, False) + self.viz.viewer.highlight_roi(self._show_roi_label_menu) + + # Cleanup window + if (viz.is_mouse_down(LEFT_MOUSE_BUTTON) and not imgui.is_window_hovered()) or clicked: + self._is_clicking_ctx_menu = True + if (self._is_clicking_ctx_menu and viz.is_mouse_released(LEFT_MOUSE_BUTTON)): + self._is_clicking_ctx_menu = False + self._show_roi_label_menu = None + self._ctx_mouse_pos = None + self.viz.viewer.reset_roi_highlight() + + imgui.end() + + def render_annotation( + self, + annotation: np.ndarray, + origin: np.ndarray, + name: Optional[str] = None, + color: float = 0, + alpha: float = 1, + linewidth: int = 3 + ): + """Render an annotation with OpenGL. + + Annotation is prepared and appended to a list of annotations to be + rendered at the end of frame generation. + + Args: + annotation (np.ndarray): An array of shape (N, 2) containing the + coordinates of the vertices of the annotation. + origin (np.ndarray): An array of shape (2,) containing the + coordinates of the origin of the annotation. + name (str): A name to display with the annotation. + color (float, tuple[float, float, float]): The color of the + annotation. Defaults to 1 (white). + alpha (float): The opacity of the annotation. Defaults to 1. + linewidth (int): The width of the annotation. Defaults to 3. + + """ + kwargs = dict(color=color, linewidth=linewidth, alpha=alpha) + self._late_render.append((np.array(annotation) + origin, name, kwargs)) + + def _draw_ctx_submenu(self, index: int) -> bool: + """Draw the context menu for a single ROI.""" + with imgui.begin_menu(f"Label##roi_{index}") as label_menu: + if label_menu.opened: + if self._draw_label_submenu(index): + return True + if imgui.menu_item(f"Delete##roi_{index}")[0]: + self.remove_rois(index) + self.refresh_rois() + return True + return False + + def _draw_label_submenu(self, index: int, show_remove: bool = True) -> bool: + """Draw the label submenu for an ROI.""" + for label in self.unique_roi_labels: + if label is None: + continue + if imgui.menu_item(f"{label}##roi_{index}")[0]: + self.viz.wsi.rois[index].label = label + self.refresh_rois() + return True + if len([l for l in self.unique_roi_labels if l is not None]): + imgui.separator() + if imgui.menu_item(f"New...##roi_{index}")[0]: + self._show_roi_new_label_popup = index + return True + if show_remove: + if imgui.menu_item(f"Remove##roi_{index}")[0]: + self.viz.wsi.rois[index].label = None + self.refresh_rois() + self.viz.viewer.reset_roi_highlight() + return True + return False + + def show_roi_tooltip(self, hovered_rois: List[int]) -> None: + """Show a tooltip if hovering over a ROI and no overlays are being shown.""" + viz = self.viz + if viz.viewer.is_moving() or viz.mouse_input_is_suspended(): + return + if (hovered_rois + and (viz.overlay is None or not viz.show_overlay) + and viz.viewer.show_rois + and not self._should_show_roi_ctx_menu + and not self._show_roi_label_menu): + + imgui.set_tooltip( + '\n'.join( + [f'{viz.wsi.rois[r].name} (label: {viz.wsi.rois[r].label})' + for r in hovered_rois] + ) + ) + + def _process_roi_left_click(self, hovered_rois: List[int]) -> None: + """If editing, hovering over an ROI, and left clicking, select the ROI(s).""" + viz = self.viz + + if viz.is_mouse_down(LEFT_MOUSE_BUTTON) and viz.mouse_is_over_viewer: + if viz.viewer.is_moving(): + self._should_deselect_roi_on_mouse_up = False + elif viz.is_mouse_down(LEFT_MOUSE_BUTTON): + self._should_deselect_roi_on_mouse_up = False + + # Mouse is newly released; check if ROI needs selected or deelected. + if (not viz.is_mouse_down(LEFT_MOUSE_BUTTON) + and self._mouse_is_down + and not viz._shift_down + and self._should_deselect_roi_on_mouse_up + and not hovered_rois + and not viz.mouse_input_is_suspended() + ): + # Deselect ROIs if no ROIs are hovered and the mouse is released. + self.deselect_all() + elif (not viz.is_mouse_down(LEFT_MOUSE_BUTTON) + and self._mouse_is_down + and hovered_rois + and self._should_deselect_roi_on_mouse_up + and not viz.mouse_input_is_suspended() + ): + # Select ROIs if ROIs are hovered and the mouse is released. + # If shift is down, then select multiple ROIs. + if viz._shift_down: + for h in hovered_rois: + if h in self._selected_rois: + self._selected_rois.remove(h) + else: + self._selected_rois.append(h) + else: + self._selected_rois = hovered_rois + # If one ROI is selected, enable vertex editing. + if len(self._selected_rois) == 1: + self.set_roi_vertex_editing(self._selected_rois[0]) + else: + self.disable_vertex_editing() + self.viz.viewer.highlight_roi(self._selected_rois) + + self._mouse_is_down = viz.is_mouse_down(LEFT_MOUSE_BUTTON) + if not self._mouse_is_down: + self._should_deselect_roi_on_mouse_up = True + + def _process_roi_right_click(self, hovered_rois: List[int]) -> None: + """If editing, hovering over an ROI, and right clicking, show a context menu.""" + + if self.viz.is_mouse_down(RIGHT_MOUSE_BUTTON) and hovered_rois: + if not all([r in self._selected_rois for r in hovered_rois]): + self._selected_rois = hovered_rois + self._should_show_roi_ctx_menu = True + self._roi_ctx_menu_items = self._selected_rois + + # --- ROI tools ----------------------------------------------------------- + + def remove_rois( + self, + roi_indices: Union[int, List[int]], + *, + refresh_view: bool = True + ) -> None: + + """Remove ROIs by the given index or indices.""" + if not self.viz.wsi: + return + + if not isinstance(roi_indices, (list, np.ndarray, tuple)): + roi_indices = [roi_indices] + + # Remove the old ROIs. + self.viz.wsi.remove_roi(roi_indices) + for idx in roi_indices: + if self.is_vertex_editing(idx): + self.disable_vertex_editing() + self._selected_rois = [] + self.refresh_labels() + + if refresh_view and isinstance(self.viz.viewer, SlideViewer): + # Update the ROI grid. + self.viz.viewer.refresh_rois() + self.roi_grid = self.viz.viewer.rasterize_rois_in_view() + + # Reset ROI colors. + self.viz.viewer.reset_roi_highlight() + + def simplify_roi(self, roi_indices: List[int], tolerance: float = 5) -> None: + """Simplify the given ROIs.""" + if isinstance(roi_indices, int): + roi_indices = [roi_indices] + + # Disable vertex editing. + if len(roi_indices) == 1 and self.is_vertex_editing(roi_indices[0]): + is_vertex_editing = True + else: + is_vertex_editing = False + self.disable_vertex_editing() + + for idx in sorted(roi_indices, reverse=True): + self.viz.wsi.rois[idx].simplify(tolerance=tolerance) + + self.viz.viewer.refresh_view() + self._selected_rois = roi_indices + + # Update the ROI grid. + self.viz.viewer.highlight_roi(self._selected_rois) + self.viz.viewer.refresh_rois() + self.roi_grid = self.viz.viewer.rasterize_rois_in_view() + if is_vertex_editing: + self.set_roi_vertex_editing(self._selected_rois[0]) + + def subtract_roi_from_selected(self, roi_coords: np.ndarray) -> None: + """Subtract the given ROI from the currently selected ROIs.""" + + if not len(self._selected_rois) > 0: + return + if not len(roi_coords) > 2: + return + + for idx in self._selected_rois: + poly = Polygon(self.viz.wsi.rois[idx].coordinates) + poly_to_subtract = Polygon(roi_coords) + poly_to_subtract = poly_to_subtract.simplify(tolerance=5) + # Verify the line is non-intersecting. + polygons = list(polygonize(unary_union(poly_to_subtract))) + if len(polygons) == 0: + sf.log.error("Error subtracting from ROI: drawn polygon is self-intersecting.") + self.viz.create_toast('Error subtracting from ROI: drawn polygon is self-intersecting.', icon='error') + continue + if poly.contains(poly_to_subtract): + roi = sf.slide.ROI( + self.viz.wsi.get_next_roi_name(), + roi_coords, + label=self.viz.wsi.rois[idx].label + ) + self.viz.wsi.rois[idx].add_hole(roi) + self.refresh_rois() + else: + try: + poly_s = poly.difference(poly_to_subtract) + except Exception as e: + sf.log.error("Error subtracting from ROI: {}".format(e)) + continue + if isinstance(poly_s, Polygon): + coords_s = np.stack(poly_s.exterior.coords.xy, axis=-1) + self.viz.wsi.rois[idx].coordinates = coords_s + self.viz.wsi.rois[idx].update_polygon() + self.refresh_rois() + + def merge_roi(self, roi_indices: List[int]) -> None: + """Merge the given ROIs together.""" + + if not len(roi_indices) > 1: + return + + # Disable vertex editing. + self.disable_vertex_editing() + + # Merge the polygons. + try: + merged_poly = unary_union([ + Polygon(self.viz.wsi.rois[idx].coordinates) + for idx in roi_indices + ]) + except Exception as e: + merged_poly = None + + if not isinstance(merged_poly, Polygon): + self.viz.create_toast('ROIs could not be merged.', icon='error') + return + + # First, store the holes of all ROIs. + holes = [] + for idx in roi_indices: + holes.extend(self.viz.wsi.rois[idx].holes.values()) + + # Get the coordinates of the merged ROI. + if merged_poly.geom_type == 'Polygon': + new_roi_coords = np.stack( + merged_poly.exterior.coords.xy, + axis=-1 + ) + elif merged_poly.geom_type in ('MultiPolygon', 'GeometryCollection'): + valid_polys = [p for p in merged_poly.geoms if p.geom_type == 'Polygon'] + if not valid_polys: + self.viz.create_toast('ROIs could not be merged.', icon='error') + sf.log.error(f"Error merging ROIs: merged polygon is of type {merged_poly.geom_type}.") + return + new_roi_coords = np.concatenate([ + np.stack(p.exterior.coords.xy, axis=-1) + for p in valid_polys + ]) + else: + self.viz.create_toast('ROIs could not be merged.', icon='error') + sf.log.error(f"Error merging ROIs: merged polygon is of type {merged_poly.geom_type}.") + return + + # Infer the ROI label. + first_label = self.viz.wsi.rois[roi_indices[0]].label + if all([self.viz.wsi.rois[idx].label == first_label for idx in roi_indices]): + new_label = self.viz.wsi.rois[roi_indices[0]].label + else: + new_label = None + + # Remove the old ROIs. + self.remove_rois(roi_indices, refresh_view=False) + + # Load the merged ROI into the slide. + try: + roi_idx = self.viz.wsi.load_roi_array( + new_roi_coords, + label=new_label, + ) + except sf.errors.InvalidROIError: + self.viz.create_toast('ROIs could not be merged.', icon='error') + return + self._selected_rois = [roi_idx] + + # Add the holes back to the merged ROI. + for hole in holes: + self.viz.wsi.rois[roi_idx].add_hole(hole) + + # Update the view. + if isinstance(self.viz.viewer, SlideViewer): + self.viz.viewer.refresh_rois() + self.roi_grid = self.viz.viewer.rasterize_rois_in_view() + + def save_rois(self) -> None: + """Save ROIs to a CSV file.""" + viz = self.viz + roi_file = self.get_roi_dest(viz.wsi.name, create=True) + if roi_file is None: + if viz.P is not None: + source = viz.P.dataset().get_slide_source(viz.wsi.name) + viz.create_toast( + 'Project does not have a configured ROI folder for dataset ' + f'source {source}. Configure this folder to auto-load ROIs.', + icon='warn' + ) + if not exists('roi'): + os.makedirs('roi') + roi_file = join('roi', f'{viz.wsi.name}.csv') + dest = viz.wsi.export_rois(roi_file) + viz.create_toast(f'ROIs saved to {dest}', icon='success') + + # --- ROI vertex editing -------------------------------------------------- + + def is_vertex_editing(self, roi_id: Optional[int] = None) -> bool: + """Check if vertex editing is enabled for a ROI.""" + if roi_id is not None: + return self._vertex_editor is not None and self._vertex_editor.roi_id == roi_id + else: + return self._vertex_editor is not None + + def set_roi_vertex_editing(self, roi_id: int) -> None: + """Enable vertex editing for the given ROIs""" + self._vertex_editor = VertexEditor(self.viz, roi_id) + + def disable_vertex_editing(self) -> None: + """Disable vertex editing for all ROIs.""" + if self.is_vertex_editing(): + self._vertex_editor.close() + self._vertex_editor = None + + # --- Control & interface ------------------------------------------------- + + def set_fill_rois(self, fill: bool) -> None: + """Set whether to fill ROIs.""" + self._fill_rois = fill + self.refresh_roi_colors() + + def refresh_rois(self) -> None: + """Refresh ROIs in the WSI object, and rendering.""" + # Process the ROIs. This may convert some ROIs to holes. + self.viz.wsi.process_rois() + # Update the ROI selection, as the indices may have changed. + prior_selected_rois = [self.viz.wsi.rois[idx] for idx in self._selected_rois] + self._selected_rois = [idx for idx, roi in enumerate(self.viz.wsi.rois) + if roi in prior_selected_rois] + # Update the view. This will recalculate ROI scaling, determine + # which ROIs are in view, update VBOs, and regenerate triangles. + self.viz.viewer.refresh_rois() + # Update the rasterized ROI grid. + self.roi_grid = self.viz.viewer.rasterize_rois_in_view() + # Refresh the colors of the ROIs, as the labels may have changed. + self.refresh_labels() + + def refresh_roi_colors(self) -> None: + """Refresh the colors of the ROIs.""" + viz = self.viz + viz.viewer.reset_roi_color() + for label in self.unique_roi_labels: + label_rois = [ + r for r in range(len(viz.wsi.rois)) + if viz.wsi.rois[r].label == label + ] + viz.viewer.set_roi_color( + label_rois, + outline=self.get_roi_color(label), + fill=(None if not self._fill_rois else self.get_roi_color(label)) + ) + + def reset(self) -> None: + self.reset_edit_state() + self.disable_vertex_editing() + self._selected_rois = [] + + def toggle_add_roi(self, kind: str = 'freehand') -> None: + """Toggle ROI capture mode.""" + print("setting roi method", kind) + if self.capturing and kind != self.capture_type: + self.disable_roi_capture() + self.enable_roi_capture(kind) + elif self.capturing: + self.disable_roi_capture() + else: + self.enable_roi_capture(kind) + + def enable_roi_capture(self, kind: str = 'freehand') -> None: + """Enable capture of ROIs with right-click and drag.""" + self.disable_edit_roi() + self.capturing = True + self.capture_type = kind + if self.roi_toast is not None: + self.roi_toast.done() + if self.capture_type == 'freehand': + self.viz.set_status_message("Drawing ROI", "Freehand capture: right click and drag to create a new ROI.") + if not self._showed_toast_for_freehand: + message = f'Capturing new ROIs (freehand). Right click and drag to create a new ROI.' + self.roi_toast = self.viz.create_toast(message, icon='info', sticky=False) + self._showed_toast_for_freehand = True + elif self.capture_type == 'polygon': + self.viz.set_status_message("Adding Polygon", "Polygon mode: right click to add vertex, press Enter to finish the ROI.") + if not self._showed_toast_for_polygon: + message = f'Capturing new ROIs (polygon). Right click to add a new vertex, press Enter to finish.' + self.roi_toast = self.viz.create_toast(message, icon='info', sticky=False) + self._showed_toast_for_polygon = True + elif self.capture_type == 'point': + self.viz.set_status_message("Adding Point", "Point mode: right click to add a new point.") + if not self._showed_toast_for_point: + message = f'Capturing new ROIs (point). Right click to add a new point.' + self.roi_toast = self.viz.create_toast(message, icon='info', sticky=False) + self._showed_toast_for_point = True + + def disable_roi_capture(self) -> None: + """Disable capture of ROIs with right-click and drag.""" + if self.capturing: + if self.roi_toast is not None: + self.roi_toast.done() + self.viz.clear_status_message() + self.disable_edit_roi() + self.capturing = False + self.annotator.reset() + + def disable_edit_roi(self) -> None: + """Disable ROI editing mode.""" + self.disable_subtracting() + self._should_show_advanced_editing_window = True + if self.editing: + self.viz.clear_status_message() + if self.roi_toast is not None: + self.roi_toast.done() + if isinstance(self.viz.viewer, SlideViewer): + self.deselect_all() + self.capturing = False + self.editing = False + + def enable_edit_roi(self) -> None: + """Enable ROI editing mode.""" + if self.roi_toast is not None: + self.roi_toast.done() + if not self._showed_toast_for_edit: + self.roi_toast = self.viz.create_toast(f'Left click to select, right click to label. Press control to edit vertices.', title='Editing ROIs', icon='info', sticky=False) + self._showed_toast_for_edit = True + self.viz.set_status_message("Editing ROIs", "Left click to select, right click to label. Hold control to edit vertices.") + self.capturing = False + self.editing = True + + def toggle_edit_roi(self) -> None: + """Toggle ROI editing mode.""" + if self.editing: + self.disable_edit_roi() + else: + self.enable_edit_roi() + + def enable_subtracting(self) -> None: + """Enable ROI subtraction mode.""" + if self.capturing: + self.disable_roi_capture() + if not self._showed_toast_for_subtract: + self.roi_toast = self.viz.create_toast(f'Right click and drag to subtract from the selected ROIs.', title='Subtracting', icon='info', sticky=False) + self._showed_toast_for_subtract = True + self.viz.set_status_message("Subtracting", "Right click and drag to subtract from selected ROIs.") + self.subtracting = True + + def disable_subtracting(self) -> None: + """Disable ROI subtraction mode.""" + self.annotator.reset() + if self.subtracting: + if self.roi_toast is not None: + self.roi_toast.done() + self.viz.clear_status_message() + self.subtracting = False + + def toggle_subtracting(self) -> None: + """Toggle ROI subtraction mode.""" + if self.subtracting: + self.disable_subtracting() + else: + self.enable_subtracting() + + def update(self, show: bool) -> None: + """Update the widget.""" + + self._showing = show + + # Reset the widget if the slide has changed. + if self.viz.wsi is None or not show: + self.reset_edit_state() + + # No further updates are needed if a slide is not loaded. + if not (isinstance(self.viz.viewer, SlideViewer) and self.viz.wsi): + return + + # Update the rasterized ROI grid. + self._update_grid() + + # Process ROI capture. + if self.capturing: + self._process_capture() + + if self.subtracting: + self._process_subtract() + + # Draw the advanced ROI editing window. + self.draw_advanced_editing_window() + + # Process ROI hovering and clicking. + hovered_rois = self._get_rois_at_mouse() + if self.editing and not self.subtracting: + self._process_roi_left_click(hovered_rois) + self._process_roi_right_click(hovered_rois) + if not self.subtracting: + self.show_roi_tooltip(hovered_rois) + + # Update ROI vertex editing. + if self.is_vertex_editing(): + if not self.editing: + self.disable_vertex_editing() + else: + self._vertex_editor.update() + + def get_unique_roi_label_counts(self) -> Tuple[np.ndarray, np.ndarray]: + """Get the unique ROI labels and their counts.""" + all_labels = [r.label for r in self.viz.wsi.rois] + unique_labels, counts = np.unique( + [label for label in all_labels if label], return_counts=True + ) + if None in all_labels: + unique_labels = np.append(unique_labels, None) + counts = np.append(counts, len([l for l in all_labels if l is None])) + return unique_labels, counts + + def refresh_labels(self): + """Refresh ROI labels & colors after a slide has been loaded.""" + self.unique_roi_labels, _ = self.get_unique_roi_label_counts() + self.refresh_roi_colors() + + def draw(self): + """Draw the widget.""" + self.draw_options() + self.draw_context_menu() + self.draw_label_menu() + if self._show_roi_new_label_popup is not None: + self.draw_new_label_popup() + + def draw_options(self): + viz = self.viz + + # --- Large buttons --------------------------------------------------- + self._set_button_style() + + # Add button. + _clicked, _hover_clicked = self.hover_button( + main_icon='circle_plus_highlighted', + menu_icons=['add_freehand', 'add_polygon'], + menu_labels=['Add ROI (Freehand)', 'Add ROI (Polygon)'] + ) + if _clicked and _hover_clicked == 0: + self.toggle_add_roi('freehand') + elif _clicked and _hover_clicked == 1: + self.toggle_add_roi('polygon') + elif _clicked and _hover_clicked == 2: + self.toggle_add_roi('point') + + imgui.same_line() + + # Edit button. + if viz.sidebar.large_image_button('pencil', size=viz.font_size*3): + self.toggle_edit_roi() + if imgui.is_item_hovered(): + imgui.set_tooltip("Edit ROIs (E)") + imgui.same_line() + + # Save button. + if viz.sidebar.large_image_button('floppy', size=viz.font_size*3): + self.save_rois() + self.reset_edit_state() + if imgui.is_item_hovered(): + imgui.set_tooltip("Save ROIs (Ctrl+S)") + imgui.same_line() + + # Load button. + if viz.sidebar.large_image_button('folder', size=viz.font_size*3): + self.ask_load_rois() + if imgui.is_item_hovered(): + imgui.set_tooltip("Load ROIs (Ctrl+L)") + self._end_button_style() + + imgui_utils.vertical_break() + + # --- ROI labels ------------------------------------------------------ + self.unique_roi_labels, counts = self.get_unique_roi_label_counts() + if len(self.unique_roi_labels): + hovered = self.colored_label_list( + [(label, self.get_roi_color(label), count) + for label, count in zip(self.unique_roi_labels, counts)] + ) + if hovered is not None: + self.viz.viewer.reset_roi_color() + self.viz.viewer.set_roi_color( + [r for r in range(len(viz.wsi.rois)) + if viz.wsi.rois[r].label == self.unique_roi_labels[hovered]], + outline=self.get_roi_color(self.unique_roi_labels[hovered]), + fill=(None if not self._fill_rois else self.get_roi_color(self.unique_roi_labels[hovered])) + ) + self._roi_hovering = hovered + elif self._roi_hovering is not None: + self._roi_hovering = None + self.refresh_roi_colors() + + imgui.separator() + + imgui.text_colored('Total ROIs', *viz.theme.dim) + imgui_utils.right_aligned_text(str(len(self.viz.wsi.rois))) + imgui_utils.vertical_break() + + def draw_advanced_editing_window(self): + """Draw a window with advanced editing options.""" + + if not (self.editing and self._should_show_advanced_editing_window): + return + + # Prepare window parameters (size, position) + imgui.set_next_window_size(self.viz.font_size*13.5, 0) + if self._advanced_editor_is_new: + imgui.set_next_window_position( + self.viz.offset_x + 20, + self.viz.offset_y + 20 + ) + self._advanced_editor_is_new = False + + _, self._should_show_advanced_editing_window = imgui.begin( + 'Edit ROIs', + closable=True, + flags=imgui.WINDOW_NO_RESIZE + ) + imgui.text('{} ROI selected ({} vertices).'.format( + len(self._selected_rois), + sum([len(self.viz.wsi.rois[r].coordinates) for r in self._selected_rois]) + )) + if imgui_utils.button('Merge'): + self.merge_roi(self._selected_rois) + if imgui.is_item_hovered(): + imgui.set_tooltip('Merge selected ROIs into a single ROI. <M>') + imgui.same_line() + if imgui_utils.button('Simplify'): + self.simplify_roi(self._selected_rois, tolerance=5) + if imgui.is_item_hovered(): + imgui.set_tooltip('Simplify the selected ROIs. <S>') + imgui.same_line() + if imgui_utils.button('Delete'): + self.remove_rois(self._selected_rois) + if imgui.is_item_hovered(): + imgui.set_tooltip('Delete the selected ROIs. <Delete>') + imgui.same_line() + if imgui_utils.button('Subtract', enabled=bool(self._selected_rois)): + self.toggle_subtracting() + if imgui.is_item_hovered(): + imgui.set_tooltip('Subtract the selected ROIs from other ROIs. <Shift+S>') + + if imgui.is_window_hovered(): + self._should_deselect_roi_on_mouse_up = False + + imgui.end() + + def get_roi_color(self, label: str) -> Tuple[float, float, float, float]: + """Get the color of an ROI label.""" + if label not in self._roi_colors: + self._roi_colors[label] = imgui_utils.get_random_color('bright') + return self._roi_colors[label] + + def update_label_name(self, old_name: str, new_name: str) -> None: + """Update the name of a ROI label.""" + if old_name == new_name: + return + self._roi_colors[new_name] = self._roi_colors.pop(old_name) + for roi in self.viz.wsi.rois: + if roi.label == old_name: + roi.label = new_name + +#---------------------------------------------------------------------------- + +class VertexEditor: + + def __init__(self, viz: "sf.studio.Studio", roi_id: int) -> None: + self.viz = viz + self.wsi = viz.wsi + self.roi_id = roi_id + + # --- Properties ------------------------------------------------------ + self._box_vertex_width = 5 + + # --- User input ------------------------------------------------------ + self._left_mouse_down = False + self._mouse_coords_at_down = None + self._last_mouse_coords = None + self._mouse_down_at_vertex = False + self._roi_is_edited = False + self._selection_box = None + self._force_edit_vertex = False + self._selected_vertices_at_mouse_down = { + 'outer': [], + 'holes': defaultdict(list) + } + self._select_on_release = None + self._process_rois_on_release = False + + # Vertex indices as stored in the viewer. These are not the same as + # the full ROI coordinates, as they are both scaled and culled. + self.selected_vertices = { + 'outer': [], + 'holes': defaultdict(list) + } + + # Last vertices used to calculate boxes. + self._last_vertices = { + 'outer': None, + 'holes': dict() + } + self._last_box_vertices = { + 'outer': None, + 'holes': dict() + } + + # Vertices of the boxes around each ROI vertex. + # Organized as a dictionary with keys 'outer' and 'holes'. + self.update_box_vertices() + self.update_vertices() + + # VBOs for the boxes of outer vertices and holes. + self.vbo = { + 'outer': None, + 'holes': dict() + } + self.update_box_vbo(full=True) + + # --- Properties ---------------------------------------------------------- + + @property + def outer_vertices(self) -> Optional[np.ndarray]: + """Get the vertices of the ROI.""" + return self.viz.viewer.get_scaled_roi_vertices(self.roi_id) + + @property + def hole_vertices(self) -> Dict[int, np.ndarray]: + """Get the vertices of the holes in the ROI. + + Returns: + Dict[int, np.ndarray]: A dictionary with keys as the hole IDs and + values as the vertices of the holes. + + """ + return self.viz.viewer.scaled_holes_in_view[self.roi_id] + + @property + def selected_vertex_indices(self) -> List[int]: + """Get the indices of the selected vertices w.r.t. the full ROI coordinates. + + These indices are not the same as the indices stored in the viewer + (``.selected_vertices``). The viewer-stored indices are scaled and culled, + while these indices are not. These indices represent all vertices of the ROI. + + """ + selected_outer = self.selected_vertices['outer'] + selected_holes = self.selected_vertices['holes'] + scaled_roi_indices = self.viz.viewer._scaled_roi_ind[self.roi_id] + scaled_holes_indices = self.viz.viewer._scaled_roi_holes_ind[self.roi_id] + return { + 'outer': scaled_roi_indices[selected_outer], + 'holes': { + hole_id: scaled_holes_indices[hole_id][selected_holes[hole_id]] + for hole_id in selected_holes + if hole_id in scaled_holes_indices + } + } + + @property + def any_vertex_selected(self) -> bool: + return bool(self.selected_vertices['outer']) or any( + bool(h) for h in self.selected_vertices['holes'].values() + ) + + @property + def is_editing_vertices(self) -> bool: + return self.any_vertex_selected or self.viz._control_down or self._force_edit_vertex + + @property + def num_vertex_selected(self) -> int: + return (len(self.selected_vertices['outer']) + + sum(len(h) for h in self.selected_vertices['holes'].values())) + + # --- User input ---------------------------------------------------------- + + def keyboard_callback(self, key: int, action: int) -> None: + """Handle keyboard events. + + Args: + key (int): The key that was pressed. See ``glfw.KEY_*``. + action (int): The action that was performed (e.g. ``glfw.PRESS``, + ``glfw.RELEASE``, ``glfw.REPEAT``). + + """ + if key == glfw.KEY_DELETE and action == glfw.PRESS: + if self.any_vertex_selected: + self.remove_selected_vertices() + + def check_if_mouse_newly_down_over_vertex(self) -> Tuple[Optional[str], + Optional[int], + Optional[int]]: + """Check if the mouse is newly down over a vertex. + + Returns: + A tuple containing: + - The type of vertex that was clicked (either 'outer' or 'holes'). + - The ID of the hole that was clicked (if a hole was clicked). + - The index of the vertex that was clicked (if a vertex was clicked). + + """ + mouse_down_and_editing = ( + imgui.is_mouse_down(LEFT_MOUSE_BUTTON) + and not self._left_mouse_down + and self.is_editing_vertices + ) + if not mouse_down_and_editing: + return None, None, None + + # Mouse is newly down. Check if the mouse is over one of the box vertices. + x, y = self.viz.get_mouse_pos() + + if self.outer_vertices is not None: + in_outer = self._is_position_inside_vertex_box(x, y, self.outer_vertices) + if in_outer is not None: + return 'outer', None, in_outer + if self.hole_vertices is not None: + for hole_id, hole in self.hole_vertices.items(): + in_hole = self._is_position_inside_vertex_box(x, y, hole) + if in_hole is not None: + return 'holes', hole_id, in_hole + return None, None, None + + def handle_mouse_input(self): + """Handle mouse input for editing vertices of an ROI.""" + + # First, check if the mouse has newly clicked over a vertex. + outer_or_hole, hole_id, newly_clicked_vertex = self.check_if_mouse_newly_down_over_vertex() + + # === Suspend or resume mouse input handling ========================== + # If the user is pressing control (forcing vertex view), then we should + # suspend the mouse input handling for the viewer. + if self.viz._control_down or (self.viz._shift_down and self.is_editing_vertices): + self.viz.suspend_mouse_input_handling() + # If the user is editing vertices and a vertex has been newly clicked, + # then we should handle the mouse input instead of the viewer. + elif self.is_editing_vertices and newly_clicked_vertex is not None: + self.viz.suspend_mouse_input_handling() + # Finally, if the user is editing vertices and they are currently being dragged, + # then we should handle the mouse input instead of the viewer. + elif self.is_editing_vertices and self._mouse_down_at_vertex: + self.viz.suspend_mouse_input_handling() + # Otherwise, we should resume the mouse input handling for the viewer. + else: + self.viz.resume_mouse_input_handling() + return + + # === Handle mouse input ============================================== + # Check if the mouse is newly down over a vertex. + if newly_clicked_vertex is not None: + # Mouse is newly down over a vertex. + self._last_mouse_coords = None + self._selected_vertices_at_mouse_down = self.get_selected_vertices() + self._mouse_coords_at_down = self.viz.get_mouse_pos() + self._mouse_down_at_vertex = True + if self.viz._shift_down and not self.vertex_is_selected(outer_or_hole, hole_id, newly_clicked_vertex): + self.select_vertex(outer_or_hole, hole_id, newly_clicked_vertex) + elif self.viz._shift_down: + self.deselect_vertex(outer_or_hole, hole_id, newly_clicked_vertex) + elif self.num_vertex_selected > 1 and self.vertex_is_selected(outer_or_hole, hole_id, newly_clicked_vertex): + self._select_on_release = (outer_or_hole, hole_id, newly_clicked_vertex) + else: + self.reset_selected_vertices() + self.select_vertex(outer_or_hole, hole_id, newly_clicked_vertex) + + elif imgui.is_mouse_down(LEFT_MOUSE_BUTTON): + if not self._left_mouse_down: + # Mouse is newly down, but not over a vertex. + self._mouse_down_at_vertex = False + self._mouse_coords_at_down = self.viz.get_mouse_pos() + self._selected_vertices_at_mouse_down = self.get_selected_vertices() + if not self.viz._shift_down: + self.reset_selected_vertices() + + # Mouse is still down. + mouse_x, mouse_y = self.viz.get_mouse_pos() + # Check if the mouse is moving. + if (self._mouse_coords_at_down is not None + and (np.abs(mouse_x - self._mouse_coords_at_down[0]) > 5 + or np.abs(mouse_y - self._mouse_coords_at_down[1]) > 5)): + # Check if the mouse started over a vertex and is moving. + # If so, we should drag the vertex. + if self._mouse_down_at_vertex: + if self._last_mouse_coords is None: + self._last_mouse_coords = self._mouse_coords_at_down + dx = int(np.round((mouse_x - self._last_mouse_coords[0]) * self.viz.viewer.view_zoom)) + dy = int(np.round((mouse_y - self._last_mouse_coords[1]) * self.viz.viewer.view_zoom)) + self.move_selected_vertices(dx, dy) + self.viz.viewer.refresh_rois() + self._select_on_release = None + self._last_mouse_coords = (mouse_x, mouse_y) + # Otherwise, we should draw a selection box. + else: + ## Update selection box coordinates. + self._selection_box = [self._mouse_coords_at_down, (mouse_x, mouse_y)] + ## Check if any vertices are inside the selection box. + min_x, max_x = np.sort([self._selection_box[0][0], self._selection_box[1][0]]) + min_y, max_y = np.sort([self._selection_box[0][1], self._selection_box[1][1]]) + selected_by_box = self.get_vertices_in_bounding_box(min_x, max_x, min_y, max_y) + + # If nothing is in the box, then pass. + if not len(selected_by_box['outer']) and not any(len(v) for v in selected_by_box['holes'].values()): + pass + # If shift is down and vertices are all already selected, + # then we should deselect the vertices. + elif (self.viz._shift_down + and self.all_vertices_are_selected( + selected_by_box, + reference=self._selected_vertices_at_mouse_down + )): + self.deselect_vertices(selected_by_box) + # If shift is down and vertices are not all already selected, + # then we should select the vertices. + elif self.viz._shift_down: + self.reset_selected_vertices() + self.select_vertices(self._selected_vertices_at_mouse_down) + self.select_vertices(selected_by_box) + # If shift is not down, then we should select only these vertices. + else: + self.reset_selected_vertices() + self.select_vertices(selected_by_box) + + elif imgui.is_mouse_released(LEFT_MOUSE_BUTTON): + if self._select_on_release: + self.reset_selected_vertices() + self.select_vertex(*self._select_on_release) + self._select_on_release = None + self._last_mouse_coords = None + + self._left_mouse_down = imgui.is_mouse_down(LEFT_MOUSE_BUTTON) + + if not self._left_mouse_down: + "Mouse is not down; resetting vertex editing state." + self._mouse_down_at_vertex = False + self._selection_box = None + + # --- Vertex logic -------------------------------------------------------- + + def _is_position_inside_vertex_box( + self, + x: int, + y: int, + vertices: np.ndarray + ) -> Optional[int]: + """Check if a position is inside a vertex box.""" + # Get min/max X and Y values for the box vertices. + w = self._box_vertex_width + 2 + min_x = vertices[:, 0] - w + max_x = vertices[:, 0] + w + min_y = vertices[:, 1] - w + max_y = vertices[:, 1] + w + + # Check if the mouse is over any of the box vertices. + inside_x = (x >= min_x) & (x <= max_x) + inside_y = (y >= min_y) & (y <= max_y) + inside_boxes = inside_x & inside_y + + if np.any(inside_boxes): + to_return = np.where(inside_boxes)[0][0] + return to_return + else: + return None + + def _index_of_vertices_in_box(self, min_x, max_x, min_y, max_y, vertices): + """Get the indices of the vertices that are inside a bounding box.""" + inside_x = (vertices[:, 0] >= min_x) & (vertices[:, 0] <= max_x) + inside_y = (vertices[:, 1] >= min_y) & (vertices[:, 1] <= max_y) + inside_boxes = inside_x & inside_y + return np.where(inside_boxes)[0] + + def _calculate_box_vertices(self, vertices: Optional[np.ndarray]) -> Optional[np.ndarray]: + """Calculate box outlines for each vertex in the ROI.""" + # Convert the ROI vertices (n_vertex, 2) to (n_vertex, 4, 2) for the box. + if vertices is None: + return None + box_vertices = np.zeros((len(vertices), 4, 2)).astype(np.float32) + w = self._box_vertex_width + box_vertices[:, :, 0] = vertices[:, np.newaxis, 0] + np.array([-w, w, w, -w]) + box_vertices[:, :, 1] = vertices[:, np.newaxis, 1] + np.array([-w, -w, w, w]) + return box_vertices + + def get_selected_vertices(self) -> Dict[str, Union[List[int], Dict[int, List[int]]]]: + """Get the indices of the selected vertices.""" + return copy.deepcopy(self.selected_vertices) + + def get_vertices_in_bounding_box( + self, + min_x: int, + max_x: int, + min_y: int, + max_y: int + ) -> Dict[str, Union[List[int], Dict[int, List[int]]]]: + """Get the vertices that are inside a bounding box.""" + if self.outer_vertices is not None: + outer_selected = self._index_of_vertices_in_box(min_x, max_x, min_y, max_y, self.outer_vertices) + if self.hole_vertices is not None: + hole_selected = { + hole_id: self._index_of_vertices_in_box(min_x, max_x, min_y, max_y, hole) + for hole_id, hole in self.hole_vertices.items() + } + return { + 'outer': outer_selected, + 'holes': hole_selected + } + + def get_box_vertices(self) -> Optional[np.ndarray]: + """Get the vertices of the boxes around each ROI vertex.""" + + updated = False + + # -- First, start with the outer vertices. ---------------------------- + if self.outer_vertices is None: + # The ROI is not in view. + self._last_vertices['outer'] = None + self._last_box_vertices['outer'] = None + if not (self.outer_vertices.shape == self._last_vertices['outer'].shape) or not (np.all(self.outer_vertices == self._last_vertices['outer'])): + # The ROI has changed since the last calculation. + self.update_box_vertices(outer=True) # This updates the ._last_box_vertices. + self.update_box_vbo(outer=True, box_vertices=self._last_box_vertices) + updated = True + + # -- Next, calculate the box vertices for the holes. ------------------- + for hole_id, hole_coords in self.hole_vertices.items(): + if hole_coords is None: + # The hole is not in view. + self._last_vertices['holes'][hole_id] = dict() + self._last_box_vertices['holes'][hole_id] = dict() + if ((hole_id not in self._last_vertices['holes']) or + (hole_coords.shape != self._last_vertices['holes'][hole_id].shape) or + (not np.all(hole_coords == self._last_vertices['holes'][hole_id]))): + # The hole has changed since the last calculation. + self.update_box_vertices(holes=[hole_id]) # This updates the ._last_box_vertices. + self.update_box_vbo(holes=[hole_id], box_vertices=self._last_box_vertices) + updated = True + for hole_id in list(self._last_box_vertices['holes'].keys()): + if hole_id not in self.hole_vertices: + # The hole is no longer in view. + self._last_vertices['holes'].pop(hole_id, None) + self._last_box_vertices['holes'].pop(hole_id, None) + self.update_box_vbo(holes=[hole_id], box_vertices=self._last_box_vertices) + updated = True + + if updated: + self.update_vertices() # This updates ._last_vertices. + + return self._last_box_vertices + + # --- Vertex selection and manipulation ----------------------------------- + + def vertex_is_selected( + self, + outer_or_hole: str, + hole_id: Optional[int], + vertex_id: int + ) -> bool: + """Check if a vertex is selected.""" + if outer_or_hole == 'outer': + return vertex_id in self.selected_vertices['outer'] + elif outer_or_hole == 'holes': + return vertex_id in self.selected_vertices['holes'][hole_id] + else: + raise ValueError(f'Invalid outer_or_hole: {outer_or_hole}') + + def all_vertices_are_selected( + self, + vertices: Dict[str, Union[List[int], Dict[int, List[int]]]], + *, + reference: Optional[Dict[str, Union[List[int], Dict[int, List[int]]]]] = None + ) -> bool: + """Check if all of the vertices are selected.""" + + if reference is None: + reference = self.selected_vertices + + if 'outer' in vertices: + all_outer_selected = all([ + v in reference['outer'] + for v in vertices['outer'] + ]) + if not all_outer_selected: + return False + if 'holes' in vertices: + for hole_id, h in vertices['holes'].items(): + all_hole_selected = all([ + v in reference['holes'][hole_id] + for v in h + ]) + if not all_hole_selected: + return False + return True + + def select_vertex( + self, + outer_or_hole: str, + hole_id: Optional[int], + vertex_id: int + ) -> None: + """Select a vertex.""" + if outer_or_hole == 'outer': + self.selected_vertices['outer'].append(vertex_id) + elif outer_or_hole == 'holes': + self.selected_vertices['holes'][hole_id].append(vertex_id) + else: + raise ValueError(f'Invalid outer_or_hole: {outer_or_hole}') + + def select_vertices( + self, + vertices: Dict[str, Union[List[int], Dict[int, List[int]]]] + ) -> None: + """Select vertices.""" + # Avoid duplicates by using sets. + if isinstance(vertices['outer'], np.ndarray): + to_select = vertices['outer'].tolist() + else: + to_select = vertices['outer'] + self.selected_vertices['outer'] = list(set( + self.selected_vertices['outer'] + to_select + )) + for hole_id, h in vertices['holes'].items(): + if isinstance(vertices['holes'][hole_id], np.ndarray): + to_select = vertices['holes'][hole_id].tolist() + else: + to_select = vertices['holes'][hole_id] + self.selected_vertices['holes'][hole_id] = list(set( + self.selected_vertices['holes'][hole_id] + to_select + )) + + def deselect_vertex( + self, + outer_or_hole: str, + hole_id: Optional[int], + vertex_id: int + ) -> None: + """Deselect a vertex.""" + if outer_or_hole == 'outer': + self.selected_vertices['outer'].remove(vertex_id) + elif outer_or_hole == 'holes': + self.selected_vertices['holes'][hole_id].remove(vertex_id) + else: + raise ValueError(f'Invalid outer_or_hole: {outer_or_hole}') + + def deselect_vertices( + self, + vertices: Dict[str, Union[List[int], Dict[int, List[int]]]] + ) -> None: + """Deselect vertices.""" + for v in vertices['outer']: + if v in self.selected_vertices['outer']: + self.selected_vertices['outer'].remove(v) + for hole_id, h in vertices['holes'].items(): + for v in h: + if v in self.selected_vertices['holes'][hole_id]: + self.selected_vertices['holes'][hole_id].remove(v) + + def move_selected_vertices(self, dx: int, dy: int) -> None: + """Move the selected vertices by a given amount.""" + roi = self.viz.wsi.rois[self.roi_id] + delta = np.array([dx, dy]) + svi = self.selected_vertex_indices + + # First, move the coordinates. + if len(svi['outer']): + roi.coordinates[svi['outer']] += delta + for hole_id, svi_hole in svi['holes'].items(): + # Need to check that the hole is still in the ROI, + # as it may have been removed if it was reduced to less than 3 vertices + # or is no longer contained within the outer ROI. + if hole_id in roi.holes: + roi.holes[hole_id].coordinates[svi_hole] += delta + roi.holes[hole_id].update_polygon() + roi.update_polygon() + + # Then, update the polygons. + # We update the polygons after moving the coordinates + # to ensure that the polygons are updated correctly. + to_update = False + for hole_id, svi_hole in svi['holes'].items(): + if hole_id in roi.holes: + roi.holes[hole_id].update_polygon() + to_update = True + if len(svi['outer']) or to_update: + roi.update_polygon() + + def remove_selected_vertices(self) -> None: + """Remove the selected vertices from the ROI.""" + roi = self.viz.wsi.rois[self.roi_id] + svi = self.selected_vertex_indices + + if len(svi['outer']): + coords = np.delete(roi.coordinates, svi['outer'], axis=0) + if coords.shape[0] < 4: + # ROI cannot be less than 3 vertices. + # First and last vertices are the same, so we need at least 4. + self.viz.slide_widget.roi_widget.remove_rois(self.roi_id) + self.viz.slide_widget.roi_widget.disable_vertex_editing() + else: + roi.coordinates = coords + roi.update_polygon() + holes_to_delete = [] + for hole_id, svi_hole in svi['holes'].items(): + # Need to check that the hole is still in the ROI, + # as it may have been removed if it was reduced to less than 3 vertices + # or is no longer contained within the outer ROI. + if hole_id not in roi.holes: + continue + coords = np.delete(roi.holes[hole_id].coordinates, svi_hole, axis=0) + if coords.shape[0] < 4: + # Hole cannot be less than 3 vertices. + # First and last vertices are the same, so we need at least 4. + holes_to_delete.append(hole_id) + else: + roi.holes[hole_id].coordinates = coords + roi.holes[hole_id].update_polygon() + roi.update_polygon() + + for hole_id in sorted(holes_to_delete, reverse=True): + del roi.holes[hole_id] + roi.update_polygon() + + # Refresh the view and update the selected vertices. + self.viz.viewer.refresh_view() + self.reset_selected_vertices() + self.update_box_vertices() + self.update_box_vbo() + + def reset_selected_vertices(self) -> None: + """Reset the selected vertices.""" + self.selected_vertices = { + 'outer': [], + 'holes': defaultdict(list) + } + + # --- Updates ------------------------------------------------------------- + + def update_box_vertices( + self, + full: Optional[bool] = None, + outer: bool = False, + holes: Optional[List[int]] = None + ) -> None: + """Update the box vertices. + + Args: + full (bool): If True, update box vertices for both the outer and holes. + If ``outer`` and ``holes`` are not provided, defaults to True. + outer (bool): If True, update box vertices for the outer vertices. + holes (Optional[List[int]]): If provided, update box vertices for the + holes with the given IDs. + + """ + full = full if full is not None else (outer is False and holes is None) + if full or outer: + self._last_box_vertices['outer'] = self._calculate_box_vertices(self.outer_vertices) + if full or holes: + if holes is None: + holes = self.hole_vertices.keys() + for hole_id in holes: + self._last_box_vertices['holes'][hole_id] = self._calculate_box_vertices(self.hole_vertices[hole_id]) + + def update_vertices(self) -> None: + """Update vertices of the outer ROI and holes.""" + self._last_vertices = { + 'outer': self.outer_vertices, + 'holes': self.hole_vertices + } + + def update_box_vbo( + self, + full: Optional[bool] = None, + outer: bool = False, + holes: Optional[List[int]] = None, + box_vertices: Optional[np.ndarray] = None + ) -> None: + """Update the VBO for the box vertices, both outer and holes. + + Args: + full (bool): If True, update the VBO for both the outer and holes. + If ``outer`` and ``holes`` are not provided, defaults to True. + outer (bool): If True, update the VBO for the outer vertices. + holes (Optional[List[int]]): If provided, update the VBO for the + holes with the given IDs. + + """ + full = full if full is not None else (outer is False and holes is None) + if box_vertices is None: + box_vertices = self.get_box_vertices() + if (full or outer): + if box_vertices['outer'] is not None: + self.vbo['outer'] = gl_utils.create_buffer(box_vertices['outer']) + else: + self.vbo['outer'] = None + if (full or holes): + if holes is None: + holes = box_vertices['holes'].keys() + for hole_id in holes: + if hole_id in box_vertices['holes'] and box_vertices['holes'][hole_id] is not None: + self.vbo['holes'][hole_id] = gl_utils.create_buffer(box_vertices['holes'][hole_id]) + else: + self.vbo['holes'][hole_id] = None + + def update(self) -> None: + """Update the ROI vertex editor.""" + self.handle_mouse_input() + if self.is_editing_vertices: + self.update_box_vertices(full=True) + + def close(self) -> None: + """Close the ROI vertex editor.""" + self.viz.viewer.reset_roi_highlight() + if self.viz._control_down: + self.viz.resume_mouse_input_handling() + + # --- Drawing ------------------------------------------------------------- + + def draw_selection_box(self) -> None: + """Draw the selection box, if it exists.""" + if self._selection_box is not None: + selection_box_vertices = np.array([ + self._selection_box[0], + (self._selection_box[0][0], self._selection_box[1][1]), + self._selection_box[1], + (self._selection_box[1][0], self._selection_box[0][1]) + ]) + gl_utils.draw_roi( + selection_box_vertices, + color=(0, 0, 0), + linewidth=3, + mode=gl.GL_LINE_LOOP + ) + + def draw_vertex_boxes( + self, + vertices: Optional[np.ndarray], + vbo: Any, + selected: Optional[List[int]] = None, + ) -> None: + """Draw boxes at each vertex.""" + # Draw the box vertices, if the ROI is in view. + if vertices is not None: + gl_utils.draw_boxes( + vertices, + vbo=vbo, + color=(1, 1, 1), + linewidth=2, + mode=gl.GL_POLYGON + ) + gl_utils.draw_boxes( + vertices, + vbo=vbo, + color=(1, 0, 0), + linewidth=2 + ) + # Fill in the boxes for the selected vertices. + if vertices is not None and selected: + for v in selected: + gl_utils.draw_roi( + vertices[v], + color=(1, 0, 0), + linewidth=4, + mode=gl.GL_POLYGON + ) + + def draw(self) -> None: + """Draw the ROI vertex editor.""" + if self.is_editing_vertices: + box_vertices = self.get_box_vertices() + self.draw_vertex_boxes( + box_vertices['outer'], + self.vbo['outer'], + selected=self.selected_vertices['outer'] + ) + for hole_id, hole_vertices in box_vertices['holes'].items(): + if hole_id not in self.vbo['holes']: + continue + self.draw_vertex_boxes( + hole_vertices, + self.vbo['holes'][hole_id], + selected=self.selected_vertices['holes'][hole_id] + ) + self.draw_selection_box() +