From 4a18520630d1ee0967742cd8f7f8714dd3512e47 Mon Sep 17 00:00:00 2001 From: Gabriel Selzer Date: Wed, 12 Jun 2024 14:13:59 -0500 Subject: [PATCH 1/6] Add ROIButton --- src/ndv/viewer/_backends/_protocols.py | 57 +++- src/ndv/viewer/_backends/_pygfx.py | 377 +++++++++++++++++++++- src/ndv/viewer/_backends/_vispy.py | 414 ++++++++++++++++++++++++- src/ndv/viewer/_components.py | 8 + src/ndv/viewer/_viewer.py | 165 +++++++++- tests/test_nd_viewer.py | 5 + 6 files changed, 1003 insertions(+), 23 deletions(-) diff --git a/src/ndv/viewer/_backends/_protocols.py b/src/ndv/viewer/_backends/_protocols.py index cdba7636..c45ab248 100755 --- a/src/ndv/viewer/_backends/_protocols.py +++ b/src/ndv/viewer/_backends/_protocols.py @@ -3,29 +3,61 @@ from typing import TYPE_CHECKING, Any, Literal, Protocol if TYPE_CHECKING: + from typing import Sequence + import cmap import numpy as np + from qtpy.QtCore import Qt from qtpy.QtWidgets import QWidget -class PImageHandle(Protocol): - @property - def data(self) -> np.ndarray: ... - @data.setter - def data(self, data: np.ndarray) -> None: ... +class CanvasElement(Protocol): + """Protocol defining an interactive element on the Canvas.""" + @property def visible(self) -> bool: ... @visible.setter def visible(self, visible: bool) -> None: ... @property + def can_select(self) -> bool: ... + @property + def selected(self) -> bool: ... + @selected.setter + def selected(self, selected: bool) -> None: ... + def cursor_at(self, pos: Sequence[float]) -> Qt.CursorShape | None: ... + def start_move(self, pos: Sequence[float]) -> None: ... + def move(self, pos: Sequence[float]) -> None: ... + def remove(self) -> None: ... + + +class PImageHandle(CanvasElement, Protocol): + @property + def data(self) -> np.ndarray: ... + @data.setter + def data(self, data: np.ndarray) -> None: ... + @property def clim(self) -> Any: ... @clim.setter def clim(self, clims: tuple[float, float]) -> None: ... @property - def cmap(self) -> Any: ... + def cmap(self) -> cmap.Colormap: ... @cmap.setter - def cmap(self, cmap: Any) -> None: ... - def remove(self) -> None: ... + def cmap(self, cmap: cmap.Colormap) -> None: ... + + +class PRoiHandle(CanvasElement, Protocol): + @property + def vertices(self) -> Sequence[Sequence[float]]: ... + @vertices.setter + def vertices(self, data: Sequence[Sequence[float]]) -> None: ... + @property + def color(self) -> Any: ... + @color.setter + def color(self, color: cmap.Color) -> None: ... + @property + def border_color(self) -> Any: ... + @border_color.setter + def border_color(self, color: cmap.Color) -> None: ... class PCanvas(Protocol): @@ -46,8 +78,15 @@ def add_image( def add_volume( self, data: np.ndarray | None = ..., cmap: cmap.Colormap | None = ... ) -> PImageHandle: ... - def canvas_to_world( self, pos_xy: tuple[float, float] ) -> tuple[float, float, float]: """Map XY canvas position (pixels) to XYZ coordinate in world space.""" + + def elements_at(self, pos_xy: Sequence[float]) -> list[CanvasElement]: ... + def add_roi( + self, + vertices: Sequence[Sequence[float]] | None = ..., + color: Any = ..., + border_color: Any | None = ..., + ) -> PRoiHandle: ... diff --git a/src/ndv/viewer/_backends/_pygfx.py b/src/ndv/viewer/_backends/_pygfx.py index 4114bd66..2154be9d 100755 --- a/src/ndv/viewer/_backends/_pygfx.py +++ b/src/ndv/viewer/_backends/_pygfx.py @@ -2,20 +2,34 @@ import warnings from typing import TYPE_CHECKING, Any, Callable, Literal, cast +from weakref import WeakKeyDictionary +import cmap import numpy as np import pygfx import pylinalg as la -from qtpy.QtCore import QSize +from qtpy.QtCore import QSize, Qt from wgpu.gui.qt import QWgpuCanvas if TYPE_CHECKING: - import cmap + from typing import Sequence + from pygfx.materials import ImageBasicMaterial from pygfx.resources import Texture from qtpy.QtCore import QEvent from qtpy.QtWidgets import QWidget + from ._protocols import CanvasElement + + +def _is_inside(bounding_box: np.ndarray, pos: Sequence[float]) -> bool: + return bool( + bounding_box[0, 0] + 0.5 <= pos[0] + and pos[0] <= bounding_box[1, 0] + 0.5 + and bounding_box[0, 1] + 0.5 <= pos[1] + and pos[1] <= bounding_box[1, 1] + 0.5 + ) + class PyGFXImageHandle: def __init__(self, image: pygfx.Image | pygfx.Volume, render: Callable) -> None: @@ -42,6 +56,18 @@ def visible(self, visible: bool) -> None: self._image.visible = visible self._render() + @property + def can_select(self) -> bool: + return False + + @property + def selected(self) -> bool: + return False + + @selected.setter + def selected(self, selected: bool) -> None: + raise NotImplementedError("Images cannot be selected") + @property def clim(self) -> Any: return self._material.clim @@ -61,25 +87,333 @@ def cmap(self, cmap: cmap.Colormap) -> None: self._material.map = cmap.to_pygfx() self._render() + def start_move(self, pos: Sequence[float]) -> None: + pass + + def move(self, pos: Sequence[float]) -> None: + pass + def remove(self) -> None: if (par := self._image.parent) is not None: par.remove(self._image) + def cursor_at(self, pos: Sequence[float]) -> Qt.CursorShape | None: + return None + + +class PyGFXRoiHandle(pygfx.WorldObject): + _render: Callable = lambda _: None + + def __init__(self, render: Callable, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, *kwargs) + self._fill = self._create_fill() + if self._fill: + self.add(self._fill) + self._outline = self._create_outline() + if self._outline: + self.add(self._outline) + self._handles = self._create_handles() + if self._handles: + self.add(self._handles) + + self._render = render + + def _create_fill(self) -> pygfx.Mesh | None: + # To be implemented by subclasses needing a fill + return None + + def _create_outline(self) -> pygfx.Line | None: + # To be implemented by subclasses needing an outline + return None + + def _create_handles(self) -> pygfx.Points | None: + # To be implemented by subclasses needing handles + return None + + @property + def vertices(self) -> Sequence[Sequence[float]]: + # To be implemented by subclasses + raise NotImplementedError("Must be implemented in subclasses") + + @vertices.setter + def vertices(self, data: Sequence[Sequence[float]]) -> None: + # To be implemented by subclasses + raise NotImplementedError("Must be implemented in subclasses") + + @property + def visible(self) -> bool: + if self._outline: + return bool(self._outline.visible) + if self._fill: + return bool(self._fill.visible) + # Nothing to see + return False + + @visible.setter + def visible(self, visible: bool) -> None: + if fill := getattr(self, "_fill", None): + fill.visible = visible + if outline := getattr(self, "_outline", None): + outline.visible = visible + if handles := getattr(self, "_handles", None): + handles.visible = self.selected + self._render() + + @property + def can_select(self) -> bool: + return True + + @property + def selected(self) -> bool: + if self._handles: + return bool(self._handles.visible) + # Can't be selected without handles + return False + + @selected.setter + def selected(self, selected: bool) -> None: + if self._handles: + self._handles.visible = selected + + @property + def color(self) -> Any: + if self._fill: + return cmap.Color(self._fill.material.color) + return cmap.Color("transparent") + + @color.setter + def color(self, color: cmap.Color | None = None) -> None: + if self._fill: + if color is None: + color = cmap.Color("transparent") + if not isinstance(color, cmap.Color): + color = cmap.Color(color) + self._fill.material.color = color.rgba + self._render() + + @property + def border_color(self) -> Any: + if self._outline: + return cmap.Color(self._outline.material.color) + return cmap.Color("transparent") + + @border_color.setter + def border_color(self, color: cmap.Color | None = None) -> None: + if self._outline: + if color is None: + color = cmap.Color("yellow") + if not isinstance(color, cmap.Color): + color = cmap.Color(color) + self._outline.material.color = color.rgba + self._render() + + def start_move(self, pos: Sequence[float]) -> None: + # To be implemented by subclasses + raise NotImplementedError("Must be implemented in subclasses") + + def move(self, pos: Sequence[float]) -> None: + # To be implemented by subclasses + raise NotImplementedError("Must be implemented in subclasses") + + def remove(self) -> None: + if (par := self.parent) is not None: + par.remove(self) + + def cursor_at(self, pos: Sequence[float]) -> Qt.CursorShape | None: + # To be implemented by subclasses + raise NotImplementedError("Must be implemented in subclasses") + + +class RectangularROIHandle(PyGFXRoiHandle): + def __init__( + self, render: Callable, canvas_to_world: Callable, *args: Any, **kwargs: Any + ) -> None: + self._point_rad = 5 # PIXELS + self._positions: np.ndarray = np.zeros((5, 3), dtype=np.float32) + + super().__init__(render, *args, *kwargs) + self._canvas_to_world = canvas_to_world + + # drag_reference defines the offset between where the user clicks and the center + # of the rectangle + self._drag_idx: int | None = None + self._offset = np.zeros((5, 2)) + self._on_drag = [ + self._move_handle_0, + self._move_handle_1, + self._move_handle_2, + self._move_handle_3, + ] + + @property + def vertices(self) -> Sequence[Sequence[float]]: + # Buffer object + return [p[:2] for p in self._positions] + + @vertices.setter + def vertices(self, vertices: Sequence[Sequence[float]]) -> None: + if len(vertices) != 4 or any(len(v) != 2 for v in vertices): + raise Exception("Only 2D rectangles are currently supported") + is_aligned = ( + vertices[0][1] == vertices[1][1] + and vertices[1][0] == vertices[2][0] + and vertices[2][1] == vertices[3][1] + and vertices[3][0] == vertices[0][0] + ) + if not is_aligned: + raise Exception( + "Only rectangles aligned with the axes are currently supported" + ) + + # Update each handle + self._positions[:-1, :2] = vertices + self._positions[-1, :2] = vertices[0] + self._refresh() + + def start_move(self, pos: Sequence[float]) -> None: + self._drag_idx = self._handle_hover_idx(pos) + + if self._drag_idx is None: + self._offset[:, :] = self._positions[:, :2] - pos[:2] + + def move(self, pos: Sequence[float]) -> None: + if self._drag_idx is not None: + self._on_drag[self._drag_idx](pos) + else: + # TODO: We could potentially do this smarter via transforms + self._positions[:, :2] = self._offset[:, :2] + pos[:2] + self._refresh() + + def _move_handle_0(self, pos: Sequence[float]) -> None: + # NB pygfx requires (idx 0) = (idx 4) + self._positions[0, :2] = pos[:2] + self._positions[4, :2] = pos[:2] + + self._positions[3, 0] = pos[0] + self._positions[1, 1] = pos[1] + + def _move_handle_1(self, pos: Sequence[float]) -> None: + self._positions[1, :2] = pos[:2] + + self._positions[2, 0] = pos[0] + # NB pygfx requires (idx 0) = (idx 4) + self._positions[0, 1] = pos[1] + self._positions[4, 1] = pos[1] + + def _move_handle_2(self, pos: Sequence[float]) -> None: + self._positions[2, :2] = pos[:2] + + self._positions[1, 0] = pos[0] + self._positions[3, 1] = pos[1] + + def _move_handle_3(self, pos: Sequence[float]) -> None: + self._positions[3, :2] = pos[:2] + + # NB pygfx requires (idx 0) = (idx 4) + self._positions[0, 0] = pos[0] + self._positions[4, 0] = pos[0] + self._positions[2, 1] = pos[1] + + def _create_fill(self) -> pygfx.Mesh | None: + fill = pygfx.Mesh( + geometry=pygfx.Geometry( + positions=self._positions, + indices=np.array([[0, 1, 2, 3]], dtype=np.int32), + ), + material=pygfx.MeshBasicMaterial(color=(0, 0, 0, 0)), + ) + return fill + + def _create_outline(self) -> pygfx.Line | None: + outline = pygfx.Line( + geometry=pygfx.Geometry( + positions=self._positions, + indices=np.array([[0, 1, 2, 3]], dtype=np.int32), + ), + material=pygfx.LineMaterial(color=(0, 0, 0, 0)), + ) + return outline + + def _create_handles(self) -> pygfx.Points | None: + geometry = pygfx.Geometry(positions=self._positions[:-1]) + handles = pygfx.Points( + geometry=geometry, + # FIXME Size in pixels is not ideal for selection. + # TODO investigate what size_mode = vertex does... + material=pygfx.PointsMaterial(color=(1, 1, 1), size=2 * self._point_rad), + ) + + # NB: Default bounding box for points does not consider the radius of + # those points. We need to HACK it for handle selection + def get_handle_bb(old: Callable[[], np.ndarray]) -> Callable[[], np.ndarray]: + def new_get_bb() -> np.ndarray: + bb = old().copy() + bb[0, :2] -= self._point_rad + bb[1, :2] += self._point_rad + return bb + + return new_get_bb + + geometry.get_bounding_box = get_handle_bb(geometry.get_bounding_box) + return handles + + def _refresh(self) -> None: + if self._fill: + self._fill.geometry.positions.data[:, :] = self._positions + self._fill.geometry.positions.update_range() + if self._outline: + self._outline.geometry.positions.data[:, :] = self._positions + self._outline.geometry.positions.update_range() + if self._handles: + self._handles.geometry.positions.data[:, :] = self._positions[:-1] + self._handles.geometry.positions.update_range() + self._render() + + def _handle_hover_idx(self, pos: Sequence[float]) -> int | None: + # FIXME: Ideally, Renderer.get_pick_info would do this for us. But it + # seems broken. + for i, p in enumerate(self._positions[:-1]): + if (p[0] - pos[0]) ** 2 + (p[1] - pos[1]) ** 2 <= self._point_rad**2: + return i + return None + + def cursor_at(self, canvas_pos: Sequence[float]) -> Qt.CursorShape | None: + # Convert canvas -> world + world_pos = self._canvas_to_world(canvas_pos) + # Step 1: Check if over handle + if (idx := self._handle_hover_idx(world_pos)) is not None: + if np.array_equal( + self._positions[idx], self._positions.min(axis=0) + ) or np.array_equal(self._positions[idx], self._positions.max(axis=0)): + return Qt.CursorShape.SizeFDiagCursor + return Qt.CursorShape.SizeBDiagCursor + + # Step 2: Check if over ROI + if self._outline: + roi_bb = self._outline.geometry.get_bounding_box() + if _is_inside(roi_bb, world_pos): + return Qt.CursorShape.SizeAllCursor + return None + class _QWgpuCanvas(QWgpuCanvas): def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) self._sup_mouse_event = self._subwidget._mouse_event self._subwidget._mouse_event = self._mouse_event + self._filter: Any | None = None def sizeHint(self) -> QSize: return QSize(512, 512) + def installEventFilter(self, filter: Any) -> None: + self._filter = filter + def _mouse_event( self, event_type: str, event: QEvent, *args: Any, **kwargs: Any ) -> None: - self._sup_mouse_event(event_type, event, *args, **kwargs) - event.ignore() + if self._filter and not self._filter.eventFilter(self, event): + self._sup_mouse_event(event_type, event, *args, **kwargs) class PyGFXViewerCanvas: @@ -105,6 +439,8 @@ def __init__(self) -> None: self._camera: pygfx.Camera | None = None self._ndim: Literal[2, 3] | None = None + self._elements: WeakKeyDictionary = WeakKeyDictionary() + def qwidget(self) -> QWidget: return cast("QWidget", self._canvas) @@ -163,6 +499,7 @@ def add_image( handle = PyGFXImageHandle(image, self.refresh) if cmap is not None: handle.cmap = cmap + self._elements[image] = handle return handle def add_volume( @@ -187,6 +524,25 @@ def add_volume( handle = PyGFXImageHandle(vol, self.refresh) if cmap is not None: handle.cmap = cmap + self._elements[vol] = handle + return handle + + def add_roi( + self, + vertices: list[tuple[float, float]] | None = None, + color: cmap.Color | None = None, + border_color: cmap.Color | None = None, + ) -> PyGFXRoiHandle: + """Add a new Rectangular ROI node to the scene.""" + handle = RectangularROIHandle(self.refresh, self.canvas_to_world) + handle.visible = False + self._scene.add(handle) + if vertices: + handle.vertices = vertices + handle.color = color + handle.border_color = border_color + + self._elements[handle] = handle return handle def set_range( @@ -257,3 +613,16 @@ def canvas_to_world( return (pos_world[0] + 0.5, pos_world[1] + 0.5, pos_world[2] + 0.5) else: return (-1, -1, -1) + + def elements_at(self, pos: Sequence[float]) -> list[CanvasElement]: + """Obtains all elements located at pos.""" + # FIXME: Ideally, Renderer.get_pick_info would do this and + # canvas_to_world for us. But it seems broken. + elements: list[CanvasElement] = [] + pos = self.canvas_to_world((pos[0], pos[1])) + for c in self._scene.children: + bb = c.get_bounding_box() + if _is_inside(bb, pos): + element = cast("CanvasElement", self._elements.get(c)) + elements.append(element) + return elements diff --git a/src/ndv/viewer/_backends/_vispy.py b/src/ndv/viewer/_backends/_vispy.py index b8a670a1..99bed1f5 100755 --- a/src/ndv/viewer/_backends/_vispy.py +++ b/src/ndv/viewer/_backends/_vispy.py @@ -3,22 +3,268 @@ import warnings from contextlib import suppress from typing import TYPE_CHECKING, Any, Literal, cast +from weakref import WeakKeyDictionary +import cmap import numpy as np import vispy import vispy.scene import vispy.visuals +from qtpy.QtCore import Qt from vispy import scene +from vispy.color import Color from vispy.util.quaternion import Quaternion if TYPE_CHECKING: - import cmap + from typing import Callable, Sequence + from qtpy.QtWidgets import QWidget + from ._protocols import CanvasElement + turn = np.sin(np.pi / 4) DEFAULT_QUATERNION = Quaternion(turn, turn, 0, 0) +class Handle(scene.visuals.Markers): + """A Marker that allows specific ROI alterations.""" + + def __init__( + self, + parent: EditableROI, + on_move: Callable[[Sequence[float]], None] | None = None, + cursor: Qt.CursorShape + | Callable[[Sequence[float]], Qt.CursorShape] = Qt.CursorShape.SizeAllCursor, + ) -> None: + super().__init__(parent=parent) + self.unfreeze() + self.parent = parent + # on_move function(s) + self.on_move: list[Callable[[Sequence[float]], None]] = [] + if on_move: + self.on_move.append(on_move) + # cusror preference function + if isinstance(cursor, Qt.CursorShape): + self._cursor_at = cast( + "Callable[[Sequence[float]], Qt.CursorShape]", lambda _: cursor + ) + else: + self._cursor_at = cursor + self._selected = False + # NB VisPy asks that the data is a 2D array + self._pos = np.array([[0, 0]], dtype=np.float32) + self.interactive = True + self.freeze() + + def start_move(self, pos: Sequence[float]) -> None: + pass + + def move(self, pos: Sequence[float]) -> None: + for func in self.on_move: + func(pos) + + @property + def pos(self) -> Sequence[float]: + return cast("Sequence[float]", self._pos[0, :]) + + @pos.setter + def pos(self, pos: Sequence[float]) -> None: + self._pos[:] = pos[:2] + self.set_data(self._pos) + + @property + def selected(self) -> bool: + return self._selected + + @selected.setter + def selected(self, selected: bool) -> None: + self._selected = selected + self.parent.selected = selected + + def cursor_at(self, pos: Sequence[float]) -> Qt.CursorShape | None: + return self._cursor_at(self.pos) + + +class EditableROI(scene.visuals.Polygon): + """Base Class defining behavior all editable ROIs should have.""" + + @property + def vertices(self) -> Sequence[Sequence[float]]: + raise NotImplementedError("Must be implemented in subclass") + + @vertices.setter + def vertices(self, vertices: Sequence[Sequence[float]]) -> None: + raise NotImplementedError("Must be implemented in subclass") + + @property + def selected(self) -> bool: + raise NotImplementedError("Must be implemented in subclass") + + @selected.setter + def selected(self, selected: bool) -> None: + raise NotImplementedError("Must be implemented in subclass") + + def start_move(self, pos: Sequence[float]) -> None: + raise NotImplementedError("Must be implemented in subclass") + + def move(self, pos: Sequence[float]) -> None: + raise NotImplementedError("Must be implemented in subclass") + + def cursor_at(self, pos: Sequence[float]) -> Qt.CursorShape | None: + raise NotImplementedError("Must be implemented in subclass") + + +class RectangularROI(scene.visuals.Rectangle, EditableROI): + """A VisPy Rectangle visual whose attributes can be edited.""" + + def __init__( + self, + parent: scene.visuals.Visual, + center: list[float] | None = None, + width: float = 1e-6, + height: float = 1e-6, + ) -> None: + if center is None: + center = [0, 0] + scene.visuals.Rectangle.__init__( + self, center=center, width=width, height=height, radius=0, parent=parent + ) + self.unfreeze() + self.parent = parent + self.interactive = True + + self._handles = [ + Handle( + self, + on_move=self.move_top_left, + cursor=self._handle_cursor_pref, + ), + Handle( + self, + on_move=self.move_top_right, + cursor=self._handle_cursor_pref, + ), + Handle( + self, + on_move=self.move_bottom_right, + cursor=self._handle_cursor_pref, + ), + Handle( + self, + on_move=self.move_bottom_left, + cursor=self._handle_cursor_pref, + ), + ] + + # drag_reference defines the offset between where the user clicks and the center + # of the rectangle + self.drag_reference = [0.0, 0.0] + self.interactive = True + self._selected = False + self.freeze() + + def _handle_cursor_pref(self, handle_pos: Sequence[float]) -> Qt.CursorShape: + # Bottom left handle + if handle_pos[0] < self.center[0] and handle_pos[1] < self.center[1]: + return Qt.CursorShape.SizeFDiagCursor + # Top right handle + if handle_pos[0] > self.center[0] and handle_pos[1] > self.center[1]: + return Qt.CursorShape.SizeFDiagCursor + # Top left, bottom right + return Qt.CursorShape.SizeBDiagCursor + + def move_top_left(self, pos: Sequence[float]) -> None: + self._handles[3].pos = [pos[0], self._handles[3].pos[1]] + self._handles[0].pos = pos + self._handles[1].pos = [self._handles[1].pos[0], pos[1]] + self.redraw() + + def move_top_right(self, pos: Sequence[float]) -> None: + self._handles[0].pos = [self._handles[0].pos[0], pos[1]] + self._handles[1].pos = pos + self._handles[2].pos = [pos[0], self._handles[2].pos[1]] + self.redraw() + + def move_bottom_right(self, pos: Sequence[float]) -> None: + self._handles[1].pos = [pos[0], self._handles[1].pos[1]] + self._handles[2].pos = pos + self._handles[3].pos = [self._handles[3].pos[0], pos[1]] + self.redraw() + + def move_bottom_left(self, pos: Sequence[float]) -> None: + self._handles[2].pos = [self._handles[2].pos[0], pos[1]] + self._handles[3].pos = pos + self._handles[0].pos = [pos[0], self._handles[0].pos[1]] + self.redraw() + + def cursor_at(self, pos: Sequence[float]) -> Qt.CursorShape | None: + return Qt.CursorShape.SizeAllCursor + + def start_move(self, pos: Sequence[float]) -> None: + self.drag_reference = [ + pos[0] - self.center[0], + pos[1] - self.center[1], + ] + + def redraw(self) -> None: + left, top, *_ = self._handles[0].pos + right, bottom, *_ = self._handles[2].pos + + self.center = [(left + right) / 2, (top + bottom) / 2] + self.width = max(abs(left - right), 1e-6) + self.height = max(abs(top - bottom), 1e-6) + + def move(self, pos: Sequence[float]) -> None: + new_center = [ + pos[0] - self.drag_reference[0], + pos[1] - self.drag_reference[1], + ] + old_center = self.center + # TODO: Simplify + for h in self._handles: + existing_pos = h.pos + h.pos = [ + existing_pos[0] + new_center[0] - old_center[0], + existing_pos[1] + new_center[1] - old_center[1], + ] + self.center = new_center + + @property + def selected(self) -> bool: + return self._selected + + @selected.setter + def selected(self, selected: bool) -> None: + self._selected = selected + for h in self._handles: + h.visible = selected + + @property + def vertices(self) -> Sequence[Sequence[float]]: + return [h.pos for h in self._handles] + + @vertices.setter + def vertices(self, vertices: Sequence[Sequence[float]]) -> None: + if len(vertices) != 4 or any(len(v) != 2 for v in vertices): + raise Exception("Only 2D rectangles are currently supported") + is_aligned = ( + vertices[0][1] == vertices[1][1] + and vertices[1][0] == vertices[2][0] + and vertices[2][1] == vertices[3][1] + and vertices[3][0] == vertices[0][0] + ) + if not is_aligned: + raise Exception( + "Only rectangles aligned with the axes are currently supported" + ) + + # Update each handle + for i, handle in enumerate(self._handles): + handle.pos = vertices[i] + # Redraw + self.redraw() + + class VispyImageHandle: def __init__(self, visual: scene.visuals.Image | scene.visuals.Volume) -> None: self._visual = visual @@ -50,6 +296,18 @@ def visible(self) -> bool: def visible(self, visible: bool) -> None: self._visual.visible = visible + @property + def can_select(self) -> bool: + return False + + @property + def selected(self) -> bool: + return False + + @selected.setter + def selected(self, selected: bool) -> None: + raise NotImplementedError("Images cannot be selected") + @property def clim(self) -> Any: return self._visual.clim @@ -76,9 +334,129 @@ def transform(self) -> np.ndarray: def transform(self, transform: np.ndarray) -> None: raise NotImplementedError + def start_move(self, pos: Sequence[float]) -> None: + pass + + def move(self, pos: Sequence[float]) -> None: + pass + def remove(self) -> None: self._visual.parent = None + def cursor_at(self, pos: Sequence[float]) -> Qt.CursorShape | None: + return None + + +# FIXME: Unfortunate naming :) +class VispyHandleHandle: + def __init__(self, handle: Handle, parent: CanvasElement) -> None: + self._handle = handle + self._parent = parent + + @property + def visible(self) -> bool: + return cast("bool", self._handle.visible) + + @visible.setter + def visible(self, visible: bool) -> None: + self._handle.visible = visible + + @property + def can_select(self) -> bool: + return True + + @property + def selected(self) -> bool: + return self._handle.selected + + @selected.setter + def selected(self, selected: bool) -> None: + self._handle.selected = selected + + def start_move(self, pos: Sequence[float]) -> None: + self._handle.start_move(pos) + + def move(self, pos: Sequence[float]) -> None: + self._handle.move(pos) + + def remove(self) -> None: + self._parent.remove() + + def cursor_at(self, pos: Sequence[float]) -> Qt.CursorShape | None: + return self._handle.cursor_at(pos) + + +class VispyRoiHandle: + def __init__(self, roi: EditableROI) -> None: + self._roi = roi + + @property + def vertices(self) -> Sequence[Sequence[float]]: + return self._roi.vertices + + @vertices.setter + def vertices(self, vertices: Sequence[Sequence[float]]) -> None: + self._roi.vertices = vertices + + @property + def visible(self) -> bool: + return bool(self._roi.visible) + + @visible.setter + def visible(self, visible: bool) -> None: + self._roi.visible = visible + + @property + def can_select(self) -> bool: + return True + + @property + def selected(self) -> bool: + return self._roi.selected + + @selected.setter + def selected(self, selected: bool) -> None: + self._roi.selected = selected + + def start_move(self, pos: Sequence[float]) -> None: + self._roi.start_move(pos) + + def move(self, pos: Sequence[float]) -> None: + self._roi.move(pos) + + @property + def color(self) -> Any: + return self._roi.color + + @color.setter + def color(self, color: Any | None = None) -> None: + if color is None: + color = cmap.Color("transparent") + if not isinstance(color, cmap.Color): + color = cmap.Color(color) + # NB: To enable dragging the shape within the border, + # we require a positive alpha. + alpha = max(color.alpha, 1e-6) + self._roi.color = Color(color.hex, alpha=alpha) + + @property + def border_color(self) -> Any: + return self._roi.border_color + + @border_color.setter + def border_color(self, color: Any | None = None) -> None: + if color is None: + color = cmap.Color("yellow") + if not isinstance(color, cmap.Color): + color = cmap.Color(color) + self._roi.border_color = Color(color.hex, alpha=color.alpha) + + def remove(self) -> None: + self._roi.parent = None + + def cursor_at(self, pos: Sequence[float]) -> Qt.CursorShape | None: + return self._roi.cursor_at(pos) + class VispyViewerCanvas: """Vispy-based viewer for data. @@ -96,6 +474,8 @@ def __init__(self) -> None: self._view: scene.ViewBox = central_wdg.add_view() self._ndim: Literal[2, 3] | None = None + self._elements: WeakKeyDictionary = WeakKeyDictionary() + @property def _camera(self) -> vispy.scene.cameras.BaseCamera: return self._view.camera @@ -139,6 +519,7 @@ def add_image( if not prev_shape: self.set_range() handle = VispyImageHandle(img) + self._elements[img] = handle if cmap is not None: handle.cmap = cmap return handle @@ -156,10 +537,31 @@ def add_volume( if len(prev_shape) != 3: self.set_range() handle = VispyImageHandle(vol) + self._elements[vol] = handle if cmap is not None: handle.cmap = cmap return handle + def add_roi( + self, + vertices: list[tuple[float, float]] | None = None, + color: Any | None = None, + border_color: Any | None = None, + ) -> VispyRoiHandle: + """Add a new Rectangular ROI node to the scene.""" + roi = RectangularROI( + parent=self._view.scene, + ) + handle = VispyRoiHandle(roi) + self._elements[roi] = handle + for h in roi._handles: + self._elements[h] = VispyHandleHandle(h, handle) + if vertices: + handle.vertices = vertices + handle.color = color + handle.border_color = border_color + return handle + def set_range( self, x: tuple[float, float] | None = None, @@ -190,4 +592,12 @@ def canvas_to_world( self, pos_xy: tuple[float, float] ) -> tuple[float, float, float]: """Map XY canvas position (pixels) to XYZ coordinate in world space.""" - return self._view.camera.transform.imap(pos_xy)[:3] # type: ignore [no-any-return] + return self._view.scene.transform.imap(pos_xy)[:3] # type: ignore [no-any-return] + + def elements_at(self, pos_xy: tuple[float, float, float]) -> list[CanvasElement]: + elements = [] + visuals = self._canvas.visuals_at(pos_xy[:2]) + for vis in visuals: + if (handle := self._elements.get(vis)) is not None: + elements.append(handle) + return elements diff --git a/src/ndv/viewer/_components.py b/src/ndv/viewer/_components.py index 68d06757..9dc7450b 100644 --- a/src/ndv/viewer/_components.py +++ b/src/ndv/viewer/_components.py @@ -64,3 +64,11 @@ def setMode(self, mode: ChannelMode) -> None: other = ChannelMode.COMPOSITE if mode is ChannelMode.MONO else ChannelMode.MONO self.setText(str(other)) self.setChecked(mode == ChannelMode.MONO) + + +class ROIButton(QPushButton): + def __init__(self, parent: QWidget | None = None): + super().__init__(parent) + self.setCheckable(True) + self.setToolTip("Add ROI") + self.setIcon(QIconifyIcon("mdi:vector-rectangle")) diff --git a/src/ndv/viewer/_viewer.py b/src/ndv/viewer/_viewer.py index 1f8ec4b5..14cbc1e3 100755 --- a/src/ndv/viewer/_viewer.py +++ b/src/ndv/viewer/_viewer.py @@ -6,7 +6,7 @@ import cmap import numpy as np -from qtpy.QtCore import QEvent +from qtpy.QtCore import QEvent, Qt from qtpy.QtWidgets import QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget from superqt import QCollapsible, QElidingLabel, QIconifyIcon, ensure_main_thread from superqt.utils import qthrottled, signals_blocked @@ -16,6 +16,7 @@ ChannelModeButton, DimToggleButton, QSpinner, + ROIButton, ) from ._backends import get_canvas_class @@ -27,10 +28,10 @@ from concurrent.futures import Future from typing import Any, Callable, Hashable, Iterable, Sequence, TypeAlias - from qtpy.QtCore import QObject, QPointF - from qtpy.QtGui import QCloseEvent, QMouseEvent + from qtpy.QtCore import QObject + from qtpy.QtGui import QCloseEvent, QKeyEvent, QMouseEvent - from ._backends._protocols import PCanvas, PImageHandle + from ._backends._protocols import CanvasElement, PCanvas, PImageHandle, PRoiHandle from ._dims_slider import DimKey, Indices, Sizes ImgKey: TypeAlias = Hashable @@ -143,6 +144,29 @@ def __init__( # number of dimensions to display self._ndims: Literal[2, 3] = 2 + # Canvas selection + self._selection: CanvasElement | None = None + # ROI + self._roi: PRoiHandle | None = None + + # List of functions to be invoked on a canvas mouse press + # This list can be altered as necessary during execution + self._on_mouse_press: list[Callable[[QMouseEvent], bool]] = [ + self._select_element, + ] + # List of functions to be invoked on a canvas mouse movement + # This list can be altered as necessary during execution + self._on_mouse_move: list[Callable[[QMouseEvent], bool]] = [ + self._move_selection, + self._update_hover_info, + self._update_cursor, + ] + # List of functions to be invoked on a canvas mouse release + # This list can be altered as necessary during execution + self._on_mouse_release: list[Callable[[QMouseEvent], bool]] = [ + self._update_roi_button, + ] + # WIDGETS ---------------------------------------------------- # the button that controls the display mode of the channels @@ -153,6 +177,9 @@ def __init__( QIconifyIcon("fluent:full-screen-maximize-24-filled"), "", self ) self._set_range_btn.clicked.connect(self._on_set_range_clicked) + # button to draw ROIs + self._add_roi_btn = ROIButton() + self._add_roi_btn.toggled.connect(self._on_add_roi_clicked) # button to change number of displayed dimensions self._ndims_btn = DimToggleButton(self) @@ -200,6 +227,7 @@ def __init__( btns.addWidget(self._channel_mode_btn) btns.addWidget(self._ndims_btn) btns.addWidget(self._set_range_btn) + btns.addWidget(self._add_roi_btn) info_widget = QWidget() info = QHBoxLayout(info_widget) @@ -290,6 +318,31 @@ def set_data( # update the data info label self._data_info_label.setText(self._data_wrapper.summary_info()) + def set_roi( + self, + vertices: list[tuple[float, float]] | None = None, + color: Any = None, + border_color: Any = None, + ) -> None: + """Set the properties of the ROI overlaid on the displayed data. + + Properties + ---------- + vertices : list[tuple[float, float]] | None + The vertices of the ROI. + color : str, tuple, list, array, Color, or int + The fill color. Can be any "ColorLike". + border_color : str, tuple, list, array, Color, or int + The border color. Can be any "ColorLike". + """ + # Remove the old ROI + if self._roi: + self._roi.remove() + + self._roi = self._canvas.add_roi( + vertices=vertices, color=color, border_color=border_color + ) + def set_visualized_dims(self, dims: Iterable[DimKey]) -> None: """Set the dimensions that will be visualized. @@ -383,6 +436,12 @@ def set_current_index(self, index: Indices | None = None) -> None: def _toggle_3d(self) -> None: self.set_ndim(3 if self._ndims == 2 else 2) + # Disable ROIs in 3D (for now) + self._add_roi_btn.setEnabled(self._ndims == 2) + # FIXME: When toggling 2D again, ROIs cannot be selected + if self._roi: + self._roi.visible = self._ndims == 2 + def _update_slider_ranges(self) -> None: """Set the maximum values of the sliders. @@ -399,6 +458,23 @@ def _on_set_range_clicked(self) -> None: # using method to swallow the parameter passed by _set_range_btn.clicked self._canvas.set_range() + # FIXME: This is ugly + def _on_first_mouse_press(self, event: QMouseEvent) -> bool: + if self._roi: + ev_pos = event.position() + pos = self._canvas.canvas_to_world((ev_pos.x(), ev_pos.y())) + self._roi.move(pos) + self._roi.visible = True + return False + + def _on_add_roi_clicked(self, checked: bool) -> None: + if checked: + # Add new roi + self.set_roi() + self._on_mouse_press.insert(0, self._on_first_mouse_press) + else: + self._on_mouse_press.remove(self._on_first_mouse_press) + def _image_key(self, index: Indices) -> ImgKey: """Return the key for image handle(s) corresponding to `index`.""" if self._channel_mode == ChannelMode.COMPOSITE: @@ -558,21 +634,81 @@ def eventFilter(self, obj: QObject | None, event: QEvent | None) -> bool: # here is where we get a chance to intercept mouse events before passing them # to the canvas. Return `True` to prevent the event from being passed to # the backend widget. + intercepted = False + if event.type() == QEvent.Type.MouseButtonPress and obj is self._qcanvas: + ev = cast("QMouseEvent", event) + for func in self._on_mouse_press: + intercepted |= func(ev) if event.type() == QEvent.Type.MouseMove and obj is self._qcanvas: - self._update_hover_info(cast("QMouseEvent", event).position()) + ev = cast("QMouseEvent", event) + for func in self._on_mouse_move: + intercepted |= func(ev) + if event.type() == QEvent.Type.MouseButtonRelease and obj is self._qcanvas: + ev = cast("QMouseEvent", event) + for func in self._on_mouse_release: + intercepted |= func(ev) + if event.type() == QEvent.Type.KeyPress and obj is self._qcanvas: + self.keyPressEvent(cast("QKeyEvent", event)) + return intercepted + + def _select_element(self, event: QMouseEvent) -> bool: + ev_pos = (event.position().x(), event.position().y()) + pos = self._canvas.canvas_to_world(ev_pos) + # TODO why does the canvas need this point untransformed?? + elements = self._canvas.elements_at(ev_pos) + # Deselect prior selection before editing new selection + if self._selection: + self._selection.selected = False + for e in elements: + if e.can_select: + e.start_move(pos) + # Select new selection + self._selection = e + self._selection.selected = True + return False + return False + + def _move_selection(self, event: QMouseEvent) -> bool: + if event.buttons() == Qt.MouseButton.LeftButton: + if self._selection and self._selection.selected: + ev_pos = event.pos() + pos = self._canvas.canvas_to_world((ev_pos.x(), ev_pos.y())) + self._selection.move(pos) + # If we are moving the object, we don't want to move the camera + return True + return False + + def _update_cursor(self, event: QMouseEvent) -> bool: + # Avoid changing the cursor when dragging + if event.buttons() != Qt.MouseButton.NoButton: + return False + # When "creating" a ROI, use CrossCursor + if self._add_roi_btn.isChecked(): + self._qcanvas.setCursor(Qt.CursorShape.CrossCursor) + return False + # If any local elements have a preference, use it + pos = (event.pos().x(), event.pos().y()) + for e in self._canvas.elements_at(pos): + if (pref := e.cursor_at(pos)) is not None: + self._qcanvas.setCursor(pref) + return False + # Otherwise, normal cursor + self._qcanvas.setCursor(Qt.CursorShape.ArrowCursor) return False - def _update_hover_info(self, point: QPointF) -> None: + def _update_hover_info(self, event: QMouseEvent) -> bool: """Update text of hover_info_label with data value(s) at point.""" + point = event.pos() x, y, _z = self._canvas.canvas_to_world((point.x(), point.y())) # TODO: handle 3D data if (x < 0 or y < 0) or self._ndims == 3: # pragma: no cover self._hover_info_label.setText("") - return + return False x = int(x) y = int(y) text = f"[{y}, {x}]" + # TODO: Can we use self._canvas.elements_at? for n, handles in enumerate(self._img_handles.values()): channels = [] for handle in handles: @@ -593,7 +729,20 @@ def _update_hover_info(self, point: QPointF) -> None: # if we eventually have multiple image sources with different # extents, this will need to be handled. here, we just skip self._hover_info_label.setText("") - return + return False break # only getting one handle per channel text += ",".join(channels) self._hover_info_label.setText(text) + return False + + def keyPressEvent(self, a0: QKeyEvent | None) -> None: + if a0 is None: + return + if a0.key() == Qt.Key.Key_Delete and self._selection is not None: + self._selection.remove() + self._selection = None + + def _update_roi_button(self, event: QMouseEvent) -> bool: + if self._add_roi_btn.isChecked(): + self._add_roi_btn.click() + return False diff --git a/tests/test_nd_viewer.py b/tests/test_nd_viewer.py index 035350dc..b7a4e759 100644 --- a/tests/test_nd_viewer.py +++ b/tests/test_nd_viewer.py @@ -39,6 +39,11 @@ def test_ndviewer(qtbot: QtBot, backend: str, monkeypatch: pytest.MonkeyPatch) - v.set_ndim(3) v.set_channel_mode("composite") v.set_current_index({0: 2, 1: 1, 2: 1}) + v.set_roi( + [(10, 10), (30, 10), (30, 30), (10, 30)], + color="blue", + border_color="light blue", + ) # wait until there are no running jobs, because the callbacks # in the futures hold a strong reference to the viewer From fbf82cc93428adcd7ecbc17121dd4ca65b36016e Mon Sep 17 00:00:00 2001 From: Gabriel Selzer Date: Wed, 3 Jul 2024 09:36:23 -0500 Subject: [PATCH 2/6] CanvasElement: Add docstrings --- src/ndv/viewer/_backends/_protocols.py | 45 ++++++++++++++++++++------ 1 file changed, 36 insertions(+), 9 deletions(-) diff --git a/src/ndv/viewer/_backends/_protocols.py b/src/ndv/viewer/_backends/_protocols.py index c45ab248..564cd835 100755 --- a/src/ndv/viewer/_backends/_protocols.py +++ b/src/ndv/viewer/_backends/_protocols.py @@ -15,19 +15,46 @@ class CanvasElement(Protocol): """Protocol defining an interactive element on the Canvas.""" @property - def visible(self) -> bool: ... + def visible(self) -> bool: + """Defines whether the element is visible on the canvas.""" + @visible.setter - def visible(self, visible: bool) -> None: ... + def visible(self, visible: bool) -> None: + """Sets element visibility.""" + @property - def can_select(self) -> bool: ... + def can_select(self) -> bool: + """Defines whether the element can be selected.""" + @property - def selected(self) -> bool: ... + def selected(self) -> bool: + """Returns element selection status.""" + @selected.setter - def selected(self, selected: bool) -> None: ... - def cursor_at(self, pos: Sequence[float]) -> Qt.CursorShape | None: ... - def start_move(self, pos: Sequence[float]) -> None: ... - def move(self, pos: Sequence[float]) -> None: ... - def remove(self) -> None: ... + def selected(self, selected: bool) -> None: + """Sets element selection status.""" + + def cursor_at(self, pos: Sequence[float]) -> Qt.CursorShape | None: + """Returns the element's cursor preference at the provided position.""" + + def start_move(self, pos: Sequence[float]) -> None: + """ + Behavior executed at the beginning of a "move" operation. + + In layman's terms, this is the behavior executed during the the "click" + of a "click-and-drag". + """ + + def move(self, pos: Sequence[float]) -> None: + """ + Behavior executed throughout a "move" operation. + + In layman's terms, this is the behavior executed during the "drag" + of a "click-and-drag". + """ + + def remove(self) -> None: + """Removes the element from the canvas.""" class PImageHandle(CanvasElement, Protocol): From 0479f71b4390f2526345c8783fea594f8ca12c2a Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Thu, 4 Jul 2024 12:48:30 -0400 Subject: [PATCH 3/6] wip --- src/ndv/viewer/_backends/_pygfx.py | 27 +++---- src/ndv/viewer/_backends/_vispy.py | 111 +++++++++++------------------ src/ndv/viewer/_viewer.py | 85 ++++++++++------------ 3 files changed, 94 insertions(+), 129 deletions(-) diff --git a/src/ndv/viewer/_backends/_pygfx.py b/src/ndv/viewer/_backends/_pygfx.py index 2154be9d..1a4ac3e7 100755 --- a/src/ndv/viewer/_backends/_pygfx.py +++ b/src/ndv/viewer/_backends/_pygfx.py @@ -16,7 +16,6 @@ from pygfx.materials import ImageBasicMaterial from pygfx.resources import Texture - from qtpy.QtCore import QEvent from qtpy.QtWidgets import QWidget from ._protocols import CanvasElement @@ -399,21 +398,25 @@ def cursor_at(self, canvas_pos: Sequence[float]) -> Qt.CursorShape | None: class _QWgpuCanvas(QWgpuCanvas): def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) - self._sup_mouse_event = self._subwidget._mouse_event - self._subwidget._mouse_event = self._mouse_event - self._filter: Any | None = None + # self._sup_mouse_event = self._subwidget._mouse_event + # self._subwidget._mouse_event = self._mouse_event + + def installEventFilter(self, filter: Any) -> None: + print("installing filter") + super().installEventFilter(filter) + self._subwidget.installEventFilter(filter) def sizeHint(self) -> QSize: return QSize(512, 512) - def installEventFilter(self, filter: Any) -> None: - self._filter = filter - - def _mouse_event( - self, event_type: str, event: QEvent, *args: Any, **kwargs: Any - ) -> None: - if self._filter and not self._filter.eventFilter(self, event): - self._sup_mouse_event(event_type, event, *args, **kwargs) + # def _mouse_event( + # self, event_type: str, event: QEvent, *args: Any, **kwargs: Any + # ) -> None: + # breakpoint() + # self.eventFilter() + # ... + # # if self._filter and not self._filter.eventFilter(self, event): + # # self._sup_mouse_event(event_type, event, *args, **kwargs) class PyGFXViewerCanvas: diff --git a/src/ndv/viewer/_backends/_vispy.py b/src/ndv/viewer/_backends/_vispy.py index 99bed1f5..82d2e0e6 100755 --- a/src/ndv/viewer/_backends/_vispy.py +++ b/src/ndv/viewer/_backends/_vispy.py @@ -31,7 +31,7 @@ class Handle(scene.visuals.Markers): def __init__( self, - parent: EditableROI, + parent: RectangularROI, on_move: Callable[[Sequence[float]], None] | None = None, cursor: Qt.CursorShape | Callable[[Sequence[float]], Qt.CursorShape] = Qt.CursorShape.SizeAllCursor, @@ -85,36 +85,7 @@ def cursor_at(self, pos: Sequence[float]) -> Qt.CursorShape | None: return self._cursor_at(self.pos) -class EditableROI(scene.visuals.Polygon): - """Base Class defining behavior all editable ROIs should have.""" - - @property - def vertices(self) -> Sequence[Sequence[float]]: - raise NotImplementedError("Must be implemented in subclass") - - @vertices.setter - def vertices(self, vertices: Sequence[Sequence[float]]) -> None: - raise NotImplementedError("Must be implemented in subclass") - - @property - def selected(self) -> bool: - raise NotImplementedError("Must be implemented in subclass") - - @selected.setter - def selected(self, selected: bool) -> None: - raise NotImplementedError("Must be implemented in subclass") - - def start_move(self, pos: Sequence[float]) -> None: - raise NotImplementedError("Must be implemented in subclass") - - def move(self, pos: Sequence[float]) -> None: - raise NotImplementedError("Must be implemented in subclass") - - def cursor_at(self, pos: Sequence[float]) -> Qt.CursorShape | None: - raise NotImplementedError("Must be implemented in subclass") - - -class RectangularROI(scene.visuals.Rectangle, EditableROI): +class RectangularROI(scene.visuals.Rectangle): """A VisPy Rectangle visual whose attributes can be edited.""" def __init__( @@ -197,15 +168,6 @@ def move_bottom_left(self, pos: Sequence[float]) -> None: self._handles[0].pos = [pos[0], self._handles[0].pos[1]] self.redraw() - def cursor_at(self, pos: Sequence[float]) -> Qt.CursorShape | None: - return Qt.CursorShape.SizeAllCursor - - def start_move(self, pos: Sequence[float]) -> None: - self.drag_reference = [ - pos[0] - self.center[0], - pos[1] - self.center[1], - ] - def redraw(self) -> None: left, top, *_ = self._handles[0].pos right, bottom, *_ = self._handles[2].pos @@ -214,30 +176,9 @@ def redraw(self) -> None: self.width = max(abs(left - right), 1e-6) self.height = max(abs(top - bottom), 1e-6) - def move(self, pos: Sequence[float]) -> None: - new_center = [ - pos[0] - self.drag_reference[0], - pos[1] - self.drag_reference[1], - ] - old_center = self.center - # TODO: Simplify - for h in self._handles: - existing_pos = h.pos - h.pos = [ - existing_pos[0] + new_center[0] - old_center[0], - existing_pos[1] + new_center[1] - old_center[1], - ] - self.center = new_center - - @property - def selected(self) -> bool: - return self._selected - - @selected.setter - def selected(self, selected: bool) -> None: - self._selected = selected - for h in self._handles: - h.visible = selected + # --------------------- EditableROI interface -------------------------- + # In the future, if any other objects implement these same methods, this + # could be extracted into an ABC. @property def vertices(self) -> Sequence[Sequence[float]]: @@ -264,6 +205,42 @@ def vertices(self, vertices: Sequence[Sequence[float]]) -> None: # Redraw self.redraw() + @property + def selected(self) -> bool: + return self._selected + + @selected.setter + def selected(self, selected: bool) -> None: + self._selected = selected + for h in self._handles: + h.visible = selected + + def start_move(self, pos: Sequence[float]) -> None: + self.drag_reference = [ + pos[0] - self.center[0], + pos[1] - self.center[1], + ] + + def move(self, pos: Sequence[float]) -> None: + new_center = [ + pos[0] - self.drag_reference[0], + pos[1] - self.drag_reference[1], + ] + old_center = self.center + # TODO: Simplify + for h in self._handles: + existing_pos = h.pos + h.pos = [ + existing_pos[0] + new_center[0] - old_center[0], + existing_pos[1] + new_center[1] - old_center[1], + ] + self.center = new_center + + def cursor_at(self, pos: Sequence[float]) -> Qt.CursorShape | None: + return Qt.CursorShape.SizeAllCursor + + # ------------------- End EditableROI interface ------------------------- + class VispyImageHandle: def __init__(self, visual: scene.visuals.Image | scene.visuals.Volume) -> None: @@ -387,7 +364,7 @@ def cursor_at(self, pos: Sequence[float]) -> Qt.CursorShape | None: class VispyRoiHandle: - def __init__(self, roi: EditableROI) -> None: + def __init__(self, roi: RectangularROI) -> None: self._roi = roi @property @@ -549,9 +526,7 @@ def add_roi( border_color: Any | None = None, ) -> VispyRoiHandle: """Add a new Rectangular ROI node to the scene.""" - roi = RectangularROI( - parent=self._view.scene, - ) + roi = RectangularROI(parent=self._view.scene) handle = VispyRoiHandle(roi) self._elements[roi] = handle for h in roi._handles: diff --git a/src/ndv/viewer/_viewer.py b/src/ndv/viewer/_viewer.py index 14cbc1e3..1dd5e105 100755 --- a/src/ndv/viewer/_viewer.py +++ b/src/ndv/viewer/_viewer.py @@ -7,6 +7,7 @@ import cmap import numpy as np from qtpy.QtCore import QEvent, Qt +from qtpy.QtGui import QMouseEvent from qtpy.QtWidgets import QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget from superqt import QCollapsible, QElidingLabel, QIconifyIcon, ensure_main_thread from superqt.utils import qthrottled, signals_blocked @@ -29,7 +30,7 @@ from typing import Any, Callable, Hashable, Iterable, Sequence, TypeAlias from qtpy.QtCore import QObject - from qtpy.QtGui import QCloseEvent, QKeyEvent, QMouseEvent + from qtpy.QtGui import QCloseEvent, QKeyEvent from ._backends._protocols import CanvasElement, PCanvas, PImageHandle, PRoiHandle from ._dims_slider import DimKey, Indices, Sizes @@ -149,24 +150,6 @@ def __init__( # ROI self._roi: PRoiHandle | None = None - # List of functions to be invoked on a canvas mouse press - # This list can be altered as necessary during execution - self._on_mouse_press: list[Callable[[QMouseEvent], bool]] = [ - self._select_element, - ] - # List of functions to be invoked on a canvas mouse movement - # This list can be altered as necessary during execution - self._on_mouse_move: list[Callable[[QMouseEvent], bool]] = [ - self._move_selection, - self._update_hover_info, - self._update_cursor, - ] - # List of functions to be invoked on a canvas mouse release - # This list can be altered as necessary during execution - self._on_mouse_release: list[Callable[[QMouseEvent], bool]] = [ - self._update_roi_button, - ] - # WIDGETS ---------------------------------------------------- # the button that controls the display mode of the channels @@ -458,22 +441,10 @@ def _on_set_range_clicked(self) -> None: # using method to swallow the parameter passed by _set_range_btn.clicked self._canvas.set_range() - # FIXME: This is ugly - def _on_first_mouse_press(self, event: QMouseEvent) -> bool: - if self._roi: - ev_pos = event.position() - pos = self._canvas.canvas_to_world((ev_pos.x(), ev_pos.y())) - self._roi.move(pos) - self._roi.visible = True - return False - def _on_add_roi_clicked(self, checked: bool) -> None: if checked: # Add new roi self.set_roi() - self._on_mouse_press.insert(0, self._on_first_mouse_press) - else: - self._on_mouse_press.remove(self._on_first_mouse_press) def _image_key(self, index: Indices) -> ImgKey: """Return the key for image handle(s) corresponding to `index`.""" @@ -634,24 +605,40 @@ def eventFilter(self, obj: QObject | None, event: QEvent | None) -> bool: # here is where we get a chance to intercept mouse events before passing them # to the canvas. Return `True` to prevent the event from being passed to # the backend widget. - intercepted = False - if event.type() == QEvent.Type.MouseButtonPress and obj is self._qcanvas: - ev = cast("QMouseEvent", event) - for func in self._on_mouse_press: - intercepted |= func(ev) - if event.type() == QEvent.Type.MouseMove and obj is self._qcanvas: - ev = cast("QMouseEvent", event) - for func in self._on_mouse_move: - intercepted |= func(ev) - if event.type() == QEvent.Type.MouseButtonRelease and obj is self._qcanvas: - ev = cast("QMouseEvent", event) - for func in self._on_mouse_release: - intercepted |= func(ev) - if event.type() == QEvent.Type.KeyPress and obj is self._qcanvas: - self.keyPressEvent(cast("QKeyEvent", event)) - return intercepted - - def _select_element(self, event: QMouseEvent) -> bool: + intercept = False + if obj is self._qcanvas: + if isinstance(event, QMouseEvent): + intercept |= self._canvas_mouse_event(event) + if event.type() == QEvent.Type.KeyPress: + self.keyPressEvent(cast("QKeyEvent", event)) + return intercept + + def _canvas_mouse_event(self, ev: QMouseEvent) -> bool: + intercept = False + if ev.type() == QEvent.Type.MouseButtonPress: + if self._add_roi_btn.isChecked(): + intercept |= self._begin_roi(ev) + intercept |= self._press_element(ev) + return intercept + if ev.type() == QEvent.Type.MouseMove: + intercept = self._move_selection(ev) + intercept |= self._update_hover_info(ev) + intercept |= self._update_cursor(ev) + return intercept + if ev.type() == QEvent.Type.MouseButtonRelease: + return self._update_roi_button(ev) + return False + + # FIXME: This is ugly + def _begin_roi(self, event: QMouseEvent) -> bool: + if self._roi: + ev_pos = event.position() + pos = self._canvas.canvas_to_world((ev_pos.x(), ev_pos.y())) + self._roi.move(pos) + self._roi.visible = True + return False + + def _press_element(self, event: QMouseEvent) -> bool: ev_pos = (event.position().x(), event.position().y()) pos = self._canvas.canvas_to_world(ev_pos) # TODO why does the canvas need this point untransformed?? From 0e4625c353cd3593d4e28b755f37d77dd0d0b446 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Thu, 4 Jul 2024 13:10:30 -0400 Subject: [PATCH 4/6] working with pygfx, fix lint --- src/ndv/viewer/_backends/_protocols.py | 8 +++---- src/ndv/viewer/_backends/_pygfx.py | 30 +++++++------------------- src/ndv/viewer/_backends/_vispy.py | 16 ++++++++------ src/ndv/viewer/_viewer.py | 3 ++- 4 files changed, 23 insertions(+), 34 deletions(-) diff --git a/src/ndv/viewer/_backends/_protocols.py b/src/ndv/viewer/_backends/_protocols.py index 564cd835..c928db96 100755 --- a/src/ndv/viewer/_backends/_protocols.py +++ b/src/ndv/viewer/_backends/_protocols.py @@ -110,10 +110,10 @@ def canvas_to_world( ) -> tuple[float, float, float]: """Map XY canvas position (pixels) to XYZ coordinate in world space.""" - def elements_at(self, pos_xy: Sequence[float]) -> list[CanvasElement]: ... + def elements_at(self, pos_xy: tuple[float, float]) -> list[CanvasElement]: ... def add_roi( self, - vertices: Sequence[Sequence[float]] | None = ..., - color: Any = ..., - border_color: Any | None = ..., + vertices: Sequence[tuple[float, float]] | None = None, + color: cmap.Color | None = None, + border_color: cmap.Color | None = None, ) -> PRoiHandle: ... diff --git a/src/ndv/viewer/_backends/_pygfx.py b/src/ndv/viewer/_backends/_pygfx.py index 1a4ac3e7..724cc4a6 100755 --- a/src/ndv/viewer/_backends/_pygfx.py +++ b/src/ndv/viewer/_backends/_pygfx.py @@ -11,6 +11,8 @@ from qtpy.QtCore import QSize, Qt from wgpu.gui.qt import QWgpuCanvas +from ._protocols import PCanvas + if TYPE_CHECKING: from typing import Sequence @@ -396,37 +398,21 @@ def cursor_at(self, canvas_pos: Sequence[float]) -> Qt.CursorShape | None: class _QWgpuCanvas(QWgpuCanvas): - def __init__(self, *args: Any, **kwargs: Any) -> None: - super().__init__(*args, **kwargs) - # self._sup_mouse_event = self._subwidget._mouse_event - # self._subwidget._mouse_event = self._mouse_event - def installEventFilter(self, filter: Any) -> None: - print("installing filter") - super().installEventFilter(filter) self._subwidget.installEventFilter(filter) def sizeHint(self) -> QSize: - return QSize(512, 512) - - # def _mouse_event( - # self, event_type: str, event: QEvent, *args: Any, **kwargs: Any - # ) -> None: - # breakpoint() - # self.eventFilter() - # ... - # # if self._filter and not self._filter.eventFilter(self, event): - # # self._sup_mouse_event(event_type, event, *args, **kwargs) + return QSize(self.width(), self.height()) -class PyGFXViewerCanvas: +class PyGFXViewerCanvas(PCanvas): """pygfx-based canvas wrapper.""" def __init__(self) -> None: self._current_shape: tuple[int, ...] = () self._last_state: dict[Literal[2, 3], Any] = {} - self._canvas = _QWgpuCanvas(size=(512, 512)) + self._canvas = _QWgpuCanvas(size=(600, 600)) self._renderer = pygfx.renderers.WgpuRenderer(self._canvas) try: # requires https://github.com/pygfx/pygfx/pull/752 @@ -532,7 +518,7 @@ def add_volume( def add_roi( self, - vertices: list[tuple[float, float]] | None = None, + vertices: Sequence[tuple[float, float]] | None = None, color: cmap.Color | None = None, border_color: cmap.Color | None = None, ) -> PyGFXRoiHandle: @@ -617,12 +603,12 @@ def canvas_to_world( else: return (-1, -1, -1) - def elements_at(self, pos: Sequence[float]) -> list[CanvasElement]: + def elements_at(self, pos_xy: tuple[float, float]) -> list[CanvasElement]: """Obtains all elements located at pos.""" # FIXME: Ideally, Renderer.get_pick_info would do this and # canvas_to_world for us. But it seems broken. elements: list[CanvasElement] = [] - pos = self.canvas_to_world((pos[0], pos[1])) + pos = self.canvas_to_world((pos_xy[0], pos_xy[1])) for c in self._scene.children: bb = c.get_bounding_box() if _is_inside(bb, pos): diff --git a/src/ndv/viewer/_backends/_vispy.py b/src/ndv/viewer/_backends/_vispy.py index 82d2e0e6..7b46634b 100755 --- a/src/ndv/viewer/_backends/_vispy.py +++ b/src/ndv/viewer/_backends/_vispy.py @@ -15,6 +15,8 @@ from vispy.color import Color from vispy.util.quaternion import Quaternion +from ._protocols import PCanvas + if TYPE_CHECKING: from typing import Callable, Sequence @@ -435,7 +437,7 @@ def cursor_at(self, pos: Sequence[float]) -> Qt.CursorShape | None: return self._roi.cursor_at(pos) -class VispyViewerCanvas: +class VispyViewerCanvas(PCanvas): """Vispy-based viewer for data. All vispy-specific code is encapsulated in this class (and non-vispy canvases @@ -443,7 +445,7 @@ class VispyViewerCanvas: """ def __init__(self) -> None: - self._canvas = scene.SceneCanvas() + self._canvas = scene.SceneCanvas(size=(600, 600)) self._current_shape: tuple[int, ...] = () self._last_state: dict[Literal[2, 3], Any] = {} @@ -521,9 +523,9 @@ def add_volume( def add_roi( self, - vertices: list[tuple[float, float]] | None = None, - color: Any | None = None, - border_color: Any | None = None, + vertices: Sequence[tuple[float, float]] | None = None, + color: cmap.Color | None = None, + border_color: cmap.Color | None = None, ) -> VispyRoiHandle: """Add a new Rectangular ROI node to the scene.""" roi = RectangularROI(parent=self._view.scene) @@ -569,9 +571,9 @@ def canvas_to_world( """Map XY canvas position (pixels) to XYZ coordinate in world space.""" return self._view.scene.transform.imap(pos_xy)[:3] # type: ignore [no-any-return] - def elements_at(self, pos_xy: tuple[float, float, float]) -> list[CanvasElement]: + def elements_at(self, pos_xy: tuple[float, float]) -> list[CanvasElement]: elements = [] - visuals = self._canvas.visuals_at(pos_xy[:2]) + visuals = self._canvas.visuals_at(pos_xy) for vis in visuals: if (handle := self._elements.get(vis)) is not None: elements.append(handle) diff --git a/src/ndv/viewer/_viewer.py b/src/ndv/viewer/_viewer.py index 1dd5e105..5788de19 100755 --- a/src/ndv/viewer/_viewer.py +++ b/src/ndv/viewer/_viewer.py @@ -606,7 +606,8 @@ def eventFilter(self, obj: QObject | None, event: QEvent | None) -> bool: # to the canvas. Return `True` to prevent the event from being passed to # the backend widget. intercept = False - if obj is self._qcanvas: + # use children in case backend has a subwidget stealing events. + if obj in self._qcanvas.children(): if isinstance(event, QMouseEvent): intercept |= self._canvas_mouse_event(event) if event.type() == QEvent.Type.KeyPress: From 0fad530d602e1230fbd79a2aa9a93e6fd67d8098 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Thu, 4 Jul 2024 13:21:28 -0400 Subject: [PATCH 5/6] fix vispy --- src/ndv/viewer/_viewer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ndv/viewer/_viewer.py b/src/ndv/viewer/_viewer.py index 5788de19..34a091ae 100755 --- a/src/ndv/viewer/_viewer.py +++ b/src/ndv/viewer/_viewer.py @@ -607,7 +607,7 @@ def eventFilter(self, obj: QObject | None, event: QEvent | None) -> bool: # the backend widget. intercept = False # use children in case backend has a subwidget stealing events. - if obj in self._qcanvas.children(): + if obj is self._qcanvas or obj in (self._qcanvas.children()): if isinstance(event, QMouseEvent): intercept |= self._canvas_mouse_event(event) if event.type() == QEvent.Type.KeyPress: From 436fa7cad918cb30fbbaa5472eb02baeb6d350d9 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Thu, 4 Jul 2024 14:47:44 -0400 Subject: [PATCH 6/6] match colors --- src/ndv/viewer/_backends/_pygfx.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ndv/viewer/_backends/_pygfx.py b/src/ndv/viewer/_backends/_pygfx.py index 724cc4a6..29886434 100755 --- a/src/ndv/viewer/_backends/_pygfx.py +++ b/src/ndv/viewer/_backends/_pygfx.py @@ -331,7 +331,7 @@ def _create_outline(self) -> pygfx.Line | None: positions=self._positions, indices=np.array([[0, 1, 2, 3]], dtype=np.int32), ), - material=pygfx.LineMaterial(color=(0, 0, 0, 0)), + material=pygfx.LineMaterial(thickness=1, color=(0, 0, 0, 0)), ) return outline @@ -341,7 +341,7 @@ def _create_handles(self) -> pygfx.Points | None: geometry=geometry, # FIXME Size in pixels is not ideal for selection. # TODO investigate what size_mode = vertex does... - material=pygfx.PointsMaterial(color=(1, 1, 1), size=2 * self._point_rad), + material=pygfx.PointsMaterial(color=(1, 1, 1), size=1.5 * self._point_rad), ) # NB: Default bounding box for points does not consider the radius of