diff --git a/examples/notebook.ipynb b/examples/notebook.ipynb index 8e8270ff..c5be5b3f 100644 --- a/examples/notebook.ipynb +++ b/examples/notebook.ipynb @@ -2,7 +2,7 @@ "cells": [ { "cell_type": "code", - "execution_count": null, + "execution_count": 1, "id": "aa695816", "metadata": { "jupyter": { @@ -25,116 +25,14 @@ }, { "cell_type": "code", - "execution_count": 1, + "execution_count": 2, "id": "461399b0-e02d-43d9-9ede-c1aa6c180338", "metadata": {}, "outputs": [ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "ab365ae7b1ff463dab87b8d77a3e09a5", - "version_major": 2, - "version_minor": 0 - }, - "text/plain": [ - "RFBOutputContext()" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "application/javascript": [ - "\n", - " (function() {\n", - " function setup_rightclick() {\n", - " // Get all buttons with the right-click-button class\n", - " var button = document.getElementsByClassName(\n", - " 'right-click-buttonNone'\n", - " )[0];\n", - " if (!button) {\n", - " return;\n", - " }\n", - "\n", - " // For each button, add a contextmenu listener\n", - " button.addEventListener('contextmenu', function(e) {\n", - " // Prevent default context menu\n", - " e.preventDefault();\n", - " e.stopPropagation();\n", - "\n", - " // Get button position\n", - " var rect = this.getBoundingClientRect();\n", - " var scrollLeft = window.pageXOffset ||\n", - " document.documentElement.scrollLeft;\n", - " var scrollTop = window.pageYOffset ||\n", - " document.documentElement.scrollTop;\n", - "\n", - " // Position the popup above the button\n", - " var popup = document.getElementsByClassName(\n", - " 'ipywidget-popupNone'\n", - " )[0];\n", - " popup.style.display = '';\n", - " popup.style.position = 'absolute';\n", - " popup.style.top = (rect.bottom + scrollTop) + 'px';\n", - " popup.style.left = (rect.left + scrollLeft) + 'px';\n", - "\n", - " // Style the popup\n", - " popup.style.background = 'white';\n", - " popup.style.border = '1px solid #ccc';\n", - " popup.style.borderRadius = '3px';\n", - " popup.style.padding = '8px';\n", - " popup.style.boxShadow = '0 2px 5px rgba(0,0,0,0.2)';\n", - " popup.style.zIndex = '1000';\n", - "\n", - " // Add to body\n", - " document.body.appendChild(popup);\n", - "\n", - " // Close popup when clicking elsewhere\n", - " document.addEventListener('click', function closePopup(event) {\n", - " var popup = document.getElementsByClassName(\n", - " 'ipywidget-popupNone'\n", - " )[0];\n", - " if (popup && !popup.contains(event.target)) {\n", - " popup.style.display = 'none';\n", - " document.removeEventListener('click', closePopup);\n", - " }\n", - " });\n", - "\n", - " return false;\n", - " });\n", - " }\n", - "\n", - " // Make sure it works even after widget is redrawn/updated\n", - " setTimeout(setup_rightclick, 1000);\n", - " })();\n", - " " - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "application/vnd.jupyter.widget-view+json": { - "model_id": "1e07c07e8f5b4a6784b1f3fc1743e505", - "version_major": 2, - "version_minor": 0 - }, - "text/plain": [ - "VBox(children=(BoundedFloatText(value=0.0, description='Ignore Lower Tail:', step=0.1, style=DescriptionStyle(…" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "application/vnd.jupyter.widget-view+json": { - "model_id": "6dee7c200c7843c1b6643ff256cd60ae", + "model_id": "1833857155da4b69878bfb6d83523d67", "version_major": 2, "version_minor": 0 }, @@ -222,7 +120,7 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "efade88644164099b6f47c3f97d53cfa", + "model_id": "1b98cbfd0d69426db4b715190733d729", "version_major": 2, "version_minor": 0 }, @@ -310,7 +208,7 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "2c80fd0c49104d2195a3fc3875f19b0e", + "model_id": "437387a825bd46cf8adee59f18cc577b", "version_major": 2, "version_minor": 0 }, @@ -368,7 +266,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.12.12" + "version": "3.14.4" } }, "nbformat": 4, diff --git a/pyproject.toml b/pyproject.toml index b887e5fa..2b21b311 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,6 +7,9 @@ build-backend = "hatchling.build" [tool.hatch.version] source = "vcs" +[tool.hatch.metadata] +allow-direct-references = true + # https://peps.python.org/pep-0621/ [project] name = "ndv" @@ -34,7 +37,8 @@ dependencies = [ "cmap >=0.3", "numpy >=2.1.0; python_version >= '3.13'", "numpy >=1.26.0; python_version >= '3.12'", - "psygnal >=0.12", + # Gives us SignalGroupDescriptor.connect_child_events + "psygnal >=0.14", "pydantic >=2.10", "typing_extensions >= 4.10", "ihist>=0.1.3", @@ -59,9 +63,9 @@ pyside = [ ] wxpython = ["pyconify>=0.2.1", "wxpython >=4.2.2"] -# Supported Canavs backends -vispy = ["vispy>=0.16", "pyopengl >=3.1"] -pygfx = ["pygfx>=0.16.0", "rendercanvas>=2.6.2", "wgpu>=0.31.0"] +# Supported Canvas backends +vispy = ["scenex[vispy] @ git+https://github.com/pyapp-kit/scenex.git"] +pygfx = ["scenex[pygfx] @ git+https://github.com/pyapp-kit/scenex.git"] # ready to go bundles with pygfx qt = ["ndv[pygfx,pyqt]", "imageio[tifffile] >=2.20"] diff --git a/scripts/profile_viewer.py b/scripts/profile_viewer.py index 006cfdfb..44523063 100644 --- a/scripts/profile_viewer.py +++ b/scripts/profile_viewer.py @@ -17,13 +17,13 @@ from unittest.mock import MagicMock, patch import numpy as np +from ndv.views.bases._graphics._canvas import ArrayCanvas +from ndv.views.bases._graphics._canvas_elements import ImageHandle from ndv.controllers import ArrayViewer from ndv.models._array_display_model import ChannelMode from ndv.views import _app from ndv.views.bases import ArrayView, LUTView -from ndv.views.bases._graphics._canvas import ArrayCanvas -from ndv.views.bases._graphics._canvas_elements import ImageHandle # --------------- mock setup (same pattern as test_controller) --------------- diff --git a/src/ndv/_keybindings.py b/src/ndv/_keybindings.py index 96190626..5da18a46 100644 --- a/src/ndv/_keybindings.py +++ b/src/ndv/_keybindings.py @@ -5,9 +5,12 @@ from enum import Enum, auto from typing import TYPE_CHECKING, cast -from ndv._types import KeyCode, KeyMod, KeyPressEvent +from app_model.types import KeyBinding, KeyCode, SimpleKeyBinding +from scenex.app.events import MouseButton, WheelEvent if TYPE_CHECKING: + from scenex.app.events import KeyPressEvent + from ndv._types import AxisKey from ndv.controllers._array_viewer import ArrayViewer @@ -21,21 +24,22 @@ class Action(Enum): ZOOM_OUT = auto() -_DEFAULT_KEYBINDINGS: dict[tuple[KeyCode | str, KeyMod], Action] = { - (KeyCode.RIGHT, KeyMod.NONE): Action.STEP_FORWARD, - (KeyCode.LEFT, KeyMod.NONE): Action.STEP_BACKWARD, - (KeyCode.UP, KeyMod.NONE): Action.FOCUS_PREV_AXIS, - (KeyCode.DOWN, KeyMod.NONE): Action.FOCUS_NEXT_AXIS, - ("+", KeyMod.SHIFT): Action.ZOOM_IN, - ("=", KeyMod.NONE): Action.ZOOM_IN, - ("-", KeyMod.NONE): Action.ZOOM_OUT, - ("_", KeyMod.SHIFT): Action.ZOOM_OUT, +_DEFAULT_KEYBINDINGS: dict[KeyBinding, Action] = { + KeyBinding(parts=[SimpleKeyBinding(key=KeyCode.RightArrow)]): Action.STEP_FORWARD, + KeyBinding(parts=[SimpleKeyBinding(key=KeyCode.LeftArrow)]): Action.STEP_BACKWARD, + KeyBinding(parts=[SimpleKeyBinding(key=KeyCode.UpArrow)]): Action.FOCUS_PREV_AXIS, + KeyBinding(parts=[SimpleKeyBinding(key=KeyCode.DownArrow)]): Action.FOCUS_NEXT_AXIS, + KeyBinding(parts=[SimpleKeyBinding(key=KeyCode.Equal)]): Action.ZOOM_IN, + KeyBinding(parts=[SimpleKeyBinding(key=KeyCode.Equal, shift=True)]): Action.ZOOM_IN, + KeyBinding(parts=[SimpleKeyBinding(key=KeyCode.NumpadAdd)]): Action.ZOOM_IN, + KeyBinding(parts=[SimpleKeyBinding(key=KeyCode.Minus)]): Action.ZOOM_OUT, + KeyBinding(parts=[SimpleKeyBinding(key=KeyCode.NumpadSubtract)]): Action.ZOOM_OUT, } def handle_key_press(event: KeyPressEvent, viewer: ArrayViewer) -> None: """Dispatch a key press event to the appropriate action.""" - action = _DEFAULT_KEYBINDINGS.get((event.key, event.mods)) + action = _DEFAULT_KEYBINDINGS.get(event.key) if action is Action.STEP_FORWARD: _step_focused_slider(viewer, 1) elif action is Action.STEP_BACKWARD: @@ -44,10 +48,13 @@ def handle_key_press(event: KeyPressEvent, viewer: ArrayViewer) -> None: _cycle_focused_axis(viewer, 1) elif action is Action.FOCUS_PREV_AXIS: _cycle_focused_axis(viewer, -1) - elif action is Action.ZOOM_IN and viewer._highlight_pos is not None: - viewer._canvas.zoom(factor=0.667, center=viewer._highlight_pos) - elif action is Action.ZOOM_OUT and viewer._highlight_pos is not None: - viewer._canvas.zoom(factor=1.5, center=viewer._highlight_pos) + elif action in [Action.ZOOM_IN, Action.ZOOM_OUT]: + size = viewer._canvas._canvas.size + canvas_pos = (size[0] / 2, size[1] / 2) + angle_delta = (0, 120) if action is Action.ZOOM_IN else (0, -120) + mouse_event = WheelEvent(canvas_pos, MouseButton.NONE, angle_delta=angle_delta) + view = viewer._canvas.view + view.camera.controller.handle_event(mouse_event, view) def _steppable_axes(viewer: ArrayViewer) -> list[AxisKey]: diff --git a/src/ndv/_types.py b/src/ndv/_types.py index 7d82f355..76612e78 100644 --- a/src/ndv/_types.py +++ b/src/ndv/_types.py @@ -4,19 +4,10 @@ from collections.abc import Hashable, Sequence from contextlib import suppress -from enum import Enum, IntFlag, auto -from functools import cache -from typing import TYPE_CHECKING, Annotated, Any, NamedTuple, TypeAlias, cast +from typing import Annotated, Any, TypeAlias from pydantic import PlainSerializer, PlainValidator -if TYPE_CHECKING: - from qtpy.QtCore import Qt - from qtpy.QtWidgets import QWidget - from wx import Cursor - - from ndv.views.bases import Viewable - def _maybe_int(val: Any) -> Any: # try to convert to int if possible @@ -57,124 +48,3 @@ def _to_slice(val: Any) -> slice: ChannelKey: TypeAlias = Annotated[ Hashable, PlainValidator(_maybe_int), PlainSerializer(str, return_type=str) ] - - -class MouseButton(IntFlag): - LEFT = auto() - MIDDLE = auto() - RIGHT = auto() - NONE = auto() - - -class MouseMoveEvent(NamedTuple): - """Event emitted when the user moves the cursor.""" - - x: float - y: float - btn: MouseButton = MouseButton.NONE - - -class MousePressEvent(NamedTuple): - """Event emitted when mouse button is pressed.""" - - x: float - y: float - btn: MouseButton - - -class MouseReleaseEvent(NamedTuple): - """Event emitted when mouse button is released.""" - - x: float - y: float - btn: MouseButton - - -class KeyMod(IntFlag): - NONE = 0 - SHIFT = auto() - CTRL = auto() - ALT = auto() - META = auto() - - -class KeyCode(Enum): - UP = "up" - DOWN = "down" - LEFT = "left" - RIGHT = "right" - SPACE = "space" - HOME = "home" - END = "end" - - -class KeyPressEvent(NamedTuple): - key: KeyCode | str - mods: KeyMod = KeyMod.NONE - - -class CursorType(Enum): - DEFAULT = "default" - CROSS = "cross" - V_ARROW = "v_arrow" - H_ARROW = "h_arrow" - ALL_ARROW = "all_arrow" - BDIAG_ARROW = "bdiag_arrow" - FDIAG_ARROW = "fdiag_arrow" - - def apply_to(self, widget: Viewable) -> None: - """Applies the cursor type to the given widget.""" - native = widget.frontend_widget() - if hasattr(native, "setCursor"): - cast("QWidget", native).setCursor(self.to_qt()) - - def to_qt(self) -> Qt.CursorShape: - """Converts CursorType to Qt.CursorShape.""" - from qtpy.QtCore import Qt - - return { - CursorType.DEFAULT: Qt.CursorShape.ArrowCursor, - CursorType.CROSS: Qt.CursorShape.CrossCursor, - CursorType.V_ARROW: Qt.CursorShape.SizeVerCursor, - CursorType.H_ARROW: Qt.CursorShape.SizeHorCursor, - CursorType.ALL_ARROW: Qt.CursorShape.SizeAllCursor, - CursorType.BDIAG_ARROW: Qt.CursorShape.SizeBDiagCursor, - CursorType.FDIAG_ARROW: Qt.CursorShape.SizeFDiagCursor, - }[self] - - def to_jupyter(self) -> str: - """Converts CursorType to jupyter cursor strings.""" - return { - CursorType.DEFAULT: "default", - CursorType.CROSS: "crosshair", - CursorType.V_ARROW: "ns-resize", - CursorType.H_ARROW: "ew-resize", - CursorType.ALL_ARROW: "move", - CursorType.BDIAG_ARROW: "nesw-resize", - CursorType.FDIAG_ARROW: "nwse-resize", - }[self] - - # Note a new object must be created every time. We should cache it! - @cache - def to_wx(self) -> Cursor: - """Converts CursorType to jupyter cursor strings.""" - from wx import ( - CURSOR_ARROW, - CURSOR_CROSS, - CURSOR_SIZENESW, - CURSOR_SIZENS, - CURSOR_SIZENWSE, - CURSOR_SIZEWE, - CURSOR_SIZING, - Cursor, - ) - - return { - CursorType.DEFAULT: Cursor(CURSOR_ARROW), - CursorType.CROSS: Cursor(CURSOR_CROSS), - CursorType.V_ARROW: Cursor(CURSOR_SIZENS), - CursorType.H_ARROW: Cursor(CURSOR_SIZEWE), - CursorType.ALL_ARROW: Cursor(CURSOR_SIZING), - CursorType.BDIAG_ARROW: Cursor(CURSOR_SIZENESW), - CursorType.FDIAG_ARROW: Cursor(CURSOR_SIZENWSE), - }[self] diff --git a/src/ndv/controllers/_array_viewer.py b/src/ndv/controllers/_array_viewer.py index 1ef60630..a4fc4c34 100644 --- a/src/ndv/controllers/_array_viewer.py +++ b/src/ndv/controllers/_array_viewer.py @@ -5,10 +5,14 @@ from concurrent.futures import Future from contextlib import suppress from itertools import count +from math import floor from typing import TYPE_CHECKING, Any, Literal, cast import numpy as np +import scenex as snx +import scenex.app.events as events from psygnal import Signal +from scenex.model import BlendMode from ndv._keybindings import handle_key_press from ndv.controllers._channel_controller import ChannelController @@ -26,19 +30,20 @@ from ndv.models._roi_model import RectangularROIModel from ndv.models._viewer_model import ArrayViewerModel, InteractionMode from ndv.views import _app +from ndv.views._data_canvas import DataCanvas +from ndv.views._histogram import Histogram +from ndv.views._shared_histogram import SharedHistogram if TYPE_CHECKING: from typing import Any - import cmap as cmap_mod + import cmap import numpy.typing as npt from typing_extensions import Unpack - from ndv._types import AxisKey, ChannelKey, KeyPressEvent, MouseMoveEvent + from ndv._types import AxisKey, ChannelKey from ndv.models._array_display_model import ArrayDisplayModelKwargs from ndv.models._viewer_model import ArrayViewerModelKwargs - from ndv.views.bases import HistogramCanvas, SharedHistogramCanvas - from ndv.views.bases._graphics._canvas_elements import RectangularROIHandle class ArrayViewer: @@ -100,7 +105,6 @@ def __init__( self._viewer_model.events.interaction_mode.connect( self._on_interaction_mode_changed ) - self._roi_model: RectangularROIModel | None = None app = _app.gui_frontend() @@ -121,36 +125,38 @@ def __init__( # get and create the front-end and canvas classes frontend_cls = _app.get_array_view_class() - canvas_cls = _app.get_array_canvas_class() - self._canvas = canvas_cls(self._viewer_model) + self._canvas = DataCanvas(self._viewer_model) + # NOTE that by receiving events through a signal, we do not provide the canvas + # with a strong reference to this controller. This helps avoid reference cycles + # preventing garbage collection. The tradeoff, though, is that we cannot consume + # events at this point. If future needs require event consumption, we may need + # to switch approaches. + self._canvas.eventCaptured.connect(self._view_event) + self._canvas.roi_view.events.bb.connect(self._on_roi_view_bounding_box_changed) + + self._roi_model: RectangularROIModel | None = None # TODO: Is this necessary? - self._histograms: dict[ChannelKey, HistogramCanvas] = {} - self._shared_histogram: SharedHistogramCanvas | None = None + self._histograms: dict[ChannelKey, Histogram] = {} + self._shared_histogram: SharedHistogram | None = None self._shared_histogram_links: dict[ChannelKey, _SharedHistogramLink] = {} - self._view = frontend_cls(self._canvas.frontend_widget(), self._viewer_model) - - self._roi_view: RectangularROIHandle | None = None + self._view = frontend_cls(self._canvas.widget(), self._viewer_model) + self._hover_ray: events.Ray | None = None self._set_model_connected(self._display_model) - self._canvas.set_ndim(self._display_model.n_visible_axes) + self._canvas.ndims = self._display_model.n_visible_axes self._view.currentIndexChanged.connect(self._on_view_current_index_changed) self._view.resetZoomClicked.connect(self._on_view_reset_zoom_clicked) self._view.histogramRequested.connect(self._add_histogram) self._view.sharedHistogramRequested.connect(self._add_shared_histogram) + self._view.sharedHistogramLogRequested.connect( + self._on_view_shared_histogram_log_requested + ) self._view.channelModeChanged.connect(self._on_view_channel_mode_changed) self._view.ndimToggleRequested.connect(self._on_view_ndim_toggle_requested) - self._highlight_pos: tuple[float, float] | None = None - self._canvas.mouseMoved.connect(self._on_canvas_mouse_moved) - self._canvas.mouseLeft.connect(self._on_canvas_mouse_left) - self._focused_slider_axis: AxisKey | None = None - self._disconnect_key_events = _app.filter_key_events( - self._view.frontend_widget(), self._view - ) - self._view.keyPressed.connect(self._on_key_pressed) if self._data_wrapper is not None: self._fully_synchronize_view() @@ -230,9 +236,12 @@ def roi(self, roi_model: RectangularROIModel | tuple | None) -> None: self._set_roi_model_connected(self._roi_model) self._synchronize_roi() - def show(self) -> None: + def show(self, zoom_to_fit: bool = True) -> None: """Show the viewer.""" + self._canvas._canvas.visible = True self._view.set_visible(True) + if zoom_to_fit: + self._canvas.reset_zoom() def hide(self) -> None: """Hide the viewer.""" @@ -240,7 +249,8 @@ def hide(self) -> None: def close(self) -> None: """Close the viewer.""" - self._disconnect_key_events() + self._canvas._canvas.set_event_filter(None) + self._canvas._canvas.close() self._view.set_visible(False) def clone(self) -> ArrayViewer: @@ -264,7 +274,7 @@ def refresh_stats(self) -> None: for key, ctrl in self._lut_controllers.items(): if ctrl.handles: stats = compute_image_stats( - ctrl.handles[0].data(), + ctrl.handles[0].img.data, ctrl.lut_model.clims, need_histogram=True, significant_bits=sig_bits, @@ -307,18 +317,17 @@ def _default_display_model( return ArrayDisplayModel(**kwargs) def _add_histogram(self, channel: ChannelKey = None) -> None: - histogram_cls = _app.get_histogram_canvas_class() # will raise if not supported - hist = histogram_cls() + hist = Histogram() self._histograms[channel] = hist if ctrl := self._lut_controllers.get(channel, None): + # Add histogram to ArrayView for display self._view.add_histogram(channel, hist) + # Add histogram to channel controller for updates ctrl.add_lut_view(hist) self._connect_histogram(ctrl, hist) - def _connect_histogram( - self, ctrl: ChannelController, hist: HistogramCanvas - ) -> None: + def _connect_histogram(self, ctrl: ChannelController, hist: Histogram) -> None: """Connect a histogram to a channel controller's stats signal.""" def _on_stats(stats: ImageStats) -> None: @@ -329,17 +338,17 @@ def _on_stats(stats: ImageStats) -> None: # Trigger initial data from existing handle if handles := ctrl.handles: sig_bits = wrp.significant_bits if (wrp := self._data_wrapper) else None - ctrl.update_texture_data(handles[0].data(), significant_bits=sig_bits) + ctrl.update_texture_data(handles[0].img.data, significant_bits=sig_bits) hist.set_range() def _add_shared_histogram(self) -> None: """Create and connect the shared multi-channel histogram.""" if self._shared_histogram is not None: return - hist_cls = _app.get_shared_histogram_canvas_class() - hist = hist_cls() + hist = SharedHistogram() self._shared_histogram = hist - self._view.add_shared_histogram(hist) + self._view.add_shared_histogram(hist.widget()) + hist.canvas.visible = True # Connect clim/gamma changes from shared histogram back to models hist.climsChanged.connect(self._on_shared_histogram_clims_changed) @@ -353,6 +362,10 @@ def _add_shared_histogram(self) -> None: self._update_lut_visibility(self._resolved.channel_mode) hist.set_range() + def _on_view_shared_histogram_log_requested(self, log_base: int | None) -> None: + if self._shared_histogram is not None: + self._shared_histogram.set_log_base(log_base) + def _connect_shared_histogram_channel( self, key: ChannelKey, ctrl: ChannelController ) -> None: @@ -431,12 +444,6 @@ def _set_roi_model_connected( ]: getattr(obj, _connect)(callback) - if _connect: - self._create_roi_view() - else: - if self._roi_view: - self._roi_view.remove() - # ------------------ Resolve / Apply ------------------ def _re_resolve(self) -> None: @@ -462,7 +469,7 @@ def _apply_changes( if old.visible_axes != new.visible_axes: self._view.set_visible_axes(new.visible_axes) ndim = len(new.visible_axes) - self._canvas.set_ndim(cast("Literal[2, 3]", ndim)) + self._canvas.ndims = cast("Literal[2, 3]", ndim) self._clear_canvas() if old.hidden_sliders != new.hidden_sliders: self._view.hide_sliders(new.hidden_sliders, show_remainder=True) @@ -512,13 +519,15 @@ def _push_fallback_channel_names(self) -> None: def _update_lut_visibility(self, mode: ChannelMode) -> None: """Update LUT view visibility based on channel mode.""" for lut_ctrl in self._lut_controllers.values(): + key = lut_ctrl.key + if key is None: + visible = mode == ChannelMode.GRAYSCALE + elif key == "RGB": + visible = mode == ChannelMode.RGBA + else: + visible = mode in {ChannelMode.COLOR, ChannelMode.COMPOSITE} for view in lut_ctrl.lut_views: - if lut_ctrl.key is None: - view.set_visible(mode == ChannelMode.GRAYSCALE) - elif lut_ctrl.key == "RGB": - view.set_visible(mode == ChannelMode.RGBA) - else: - view.set_visible(mode in {ChannelMode.COLOR, ChannelMode.COMPOSITE}) + view.set_visible(visible) # Mirror visibility on the shared histogram if hist := self._shared_histogram: @@ -604,36 +613,70 @@ def _synchronize_roi(self) -> None: def _on_roi_model_bounding_box_changed( self, bb: tuple[tuple[float, float], tuple[float, float]] ) -> None: - if self._roi_view is not None: - world_min = self._data_point_to_world(*bb[0]) - world_max = self._data_point_to_world(*bb[1]) - self._roi_view.set_bounding_box(world_min, world_max) + if self._canvas.roi_view is not None: + self._canvas.roi_view.bb = bb def _on_roi_model_visible_changed(self, visible: bool) -> None: - if self._roi_view is not None: - self._roi_view.set_visible(visible) + if self._canvas.roi_view is not None: + self._canvas.roi_view.rect_mesh.visible = visible def _on_interaction_mode_changed(self, mode: InteractionMode) -> None: if mode == InteractionMode.CREATE_ROI: - # Create ROI model if needed to store ROI state - if self.roi is None: - self.roi = RectangularROIModel(visible=False) - - # Create a new ROI - self._create_roi_view() - - def _create_roi_view(self) -> None: - # Remove old ROI view - # TODO: Enable multiple ROIs - if self._roi_view: - self._roi_view.remove() - - # Create new ROI view - self._roi_view = self._canvas.add_bounding_box() - # Connect view signals - self._roi_view.boundingBoxChanged.connect( - self._on_roi_view_bounding_box_changed - ) + self._canvas.roi_view.rect_mesh.visible = False + + def _view_event(self, event: events.Event) -> None: + """Handle canvas events requiring controller-level access.""" + self._hover_event(event) + if isinstance(event, events.KeyPressEvent): + handle_key_press(event, self) + + def _hover_event(self, event: events.Event) -> None: + """Update hover info and cursor based on mouse position.""" + if isinstance(event, events.MouseMoveEvent): + if not (ray := self._canvas.view.to_ray(event.pos)): + return + if self.display_model.n_visible_axes == 2: + self._hover_ray = ray + else: + self._hover_ray = None + self._update_hover() + elif isinstance(event, events.MouseLeaveEvent): + self._hover_ray = None + self._update_hover() + + def _update_hover(self) -> None: + channel_values: dict[ChannelKey, float] = {} + highlight_pos: tuple[int, int] | None = None + if self._hover_ray is not None: + for node, distance in self._hover_ray.intersections( + self._canvas.view.scene + ): + if not isinstance(node, snx.Image): + continue + intersect_pos = self._hover_ray.point_at_distance(distance) + # TODO: Recursive mapping from node to scene + local_pos = node.transform.imap(intersect_pos) + # Note the addition - scenex nodes put pixel centers at integer + # coordinates. + # TODO: A __getitem___ on the image node could encapsulate this logic + highlight_pos = ( + floor(local_pos[1] + 0.5), + floor(local_pos[0] + 0.5), + ) + channel_values[self._channel_of(node)] = node.data[highlight_pos] + + # update highlight display + self._highlight_values(channel_values, highlight_pos) + + def _channel_of(self, node: snx.Image) -> ChannelKey | None: + """Return the channel key for the given image node.""" + # FIXME: This is horrible, and we're likely going to want to query based on e.g. + # chunk idx, dataset id in more complicated scenarios. + for channel, ctrl in self._lut_controllers.items(): + for handle in ctrl.handles: + if node is handle.img: + return channel + return None def _clear_canvas(self) -> None: for lut_ctrl in self._lut_controllers.values(): @@ -641,7 +684,7 @@ def _clear_canvas(self) -> None: handle = lut_ctrl.handles.pop() # disconnect model signals handle.model = None - handle.remove() + handle.close() # handles are also added as lut_views via add_handle(); # remove them so old GPU textures can be garbage-collected with suppress(ValueError): @@ -679,32 +722,13 @@ def _on_view_ndim_toggle_requested(self, is_3d: bool) -> None: def _on_view_reset_zoom_clicked(self) -> None: """Reset the zoom level of the canvas.""" - self._canvas.set_range() + self._canvas.reset_zoom() def _on_roi_view_bounding_box_changed( self, bb: tuple[tuple[float, float], tuple[float, float]] ) -> None: if self._roi_model: - data_min = self._world_point_to_data(*bb[0]) - data_max = self._world_point_to_data(*bb[1]) - self._roi_model.bounding_box = (data_min, data_max) - - def _on_canvas_mouse_moved(self, event: MouseMoveEvent) -> None: - """Respond to a mouse move event in the view.""" - x, y, _z = self._canvas.canvas_to_world((event.x, event.y)) - self._highlight_pos = (x, y) - - # update highlight display - data_pos, channel_values = self._get_values_at_world_point(*self._highlight_pos) - self._highlight_values(channel_values, data_pos) - - def _on_canvas_mouse_left(self) -> None: - """Respond to a mouse leaving the canvas in the view.""" - self._highlight_pos = None - self._highlight_values({}, self._highlight_pos) - - def _on_key_pressed(self, event: KeyPressEvent) -> None: - handle_key_press(event, self) + self._roi_model.bounding_box = bb def _on_view_channel_mode_changed(self, mode: ChannelMode) -> None: self._display_model.channel_mode = mode @@ -721,8 +745,6 @@ def _highlight_values( # in channel_values, the highlight will be set to None (i.e. hidden) for ch, hist in self._histograms.items(): hist.highlight(channel_values.get(ch, None)) - - # Also forward to shared histogram if self._shared_histogram is not None: self._shared_histogram.highlight(channel_values) @@ -839,12 +861,29 @@ def _on_data_response_ready(self, future: Future[DataResponse]) -> None: if not lut_ctrl.handles: # we don't yet have any handles for this channel if response.n_visible_axes == 2: - handle = self._canvas.add_image(data) - lut_ctrl.add_handle(handle) + img = snx.Image( + name=str(key), + data=data, + blending=BlendMode.ADDITIVE, + interactive=True, + ) elif response.n_visible_axes == 3: - handle = self._canvas.add_volume(data) - lut_ctrl.add_handle(handle) + img = snx.Volume( + name=str(key), + data=data, + blending=BlendMode.ADDITIVE, + interactive=True, + ) + else: + n_axes = response.n_visible_axes + raise ValueError(f"Unsupported number of visible axes: {n_axes}") + self._canvas.view.scene.add_child(img) + lut_ctrl.add_image(img) self._canvas.set_scales(self._resolved.visible_scales) + # FIXME: This was previously done in the canvas impls whenever a new + # image was added. We probably don't actually want to do this every + # time a new dataset is added. + self._canvas.reset_zoom() sig_bits = wrp.significant_bits if (wrp := self._data_wrapper) else None has_broadcast = len(self.stats_updated) > 0 @@ -856,72 +895,8 @@ def _on_data_response_ready(self, future: Future[DataResponse]) -> None: if has_broadcast and stats is not None: self.stats_updated.emit(key, stats) - self._canvas.refresh() # update highlight display - if self._highlight_pos is not None: - data_pos, channel_values = self._get_values_at_world_point( - *self._highlight_pos - ) - self._highlight_values(channel_values, data_pos) - - def _world_to_data(self, x: float, y: float) -> tuple[int, int]: - """Convert world (x, y) to data (row, col) indices using visible scales.""" - scales = self._resolved.visible_scales - if len(scales) >= 2: - sx, sy = scales[-1], scales[-2] - data_x = int(x / sx) if sx != 0 else int(x) - data_y = int(y / sy) if sy != 0 else int(y) - else: - data_x, data_y = int(x), int(y) - return data_y, data_x - - def _world_point_to_data(self, x: float, y: float) -> tuple[float, float]: - """Convert world (x, y) to data (x, y) as floats using visible scales.""" - scales = self._resolved.visible_scales - if len(scales) >= 2: - sx, sy = scales[-1], scales[-2] - data_x = x / sx if sx != 0 else x - data_y = y / sy if sy != 0 else y - else: - data_x, data_y = x, y - return data_x, data_y - - def _data_point_to_world(self, x: float, y: float) -> tuple[float, float]: - """Convert data (x, y) to world (x, y) using visible scales.""" - scales = self._resolved.visible_scales - if len(scales) >= 2: - sx, sy = scales[-1], scales[-2] - return x * sx, y * sy - return x, y - - def _get_values_at_world_point( - self, x: float, y: float - ) -> tuple[tuple[int, int], dict[ChannelKey, float]]: - """Return (data_pos, channel_values) for world point (x, y).""" - # TODO: handle 3D data - n_vis = len(self._resolved.visible_axes) - if n_vis != 2: # pragma: no cover - return (0, 0), {} - - data_y, data_x = self._world_to_data(x, y) - - if data_x < 0 or data_y < 0: - return (data_y, data_x), {} - - values: dict[ChannelKey, float] = {} - for key, ctrl in self._lut_controllers.items(): - if (value := ctrl.get_value_at_index((data_y, data_x))) is not None: - # Handle RGB - if key == "RGB" and isinstance(value, np.ndarray): - values["R"] = value[0] - values["G"] = value[1] - values["B"] = value[2] - if value.shape[0] > 3: - values["A"] = value[3] - else: - values[key] = cast("float", value) - - return (data_y, data_x), values + self._update_hover() class _SharedHistogramLink: @@ -931,7 +906,7 @@ def __init__( self, key: ChannelKey, ctrl: ChannelController, - hist: SharedHistogramCanvas, + hist: SharedHistogram, fallback_name: str = "", significant_bits: int | None = None, ) -> None: @@ -960,7 +935,9 @@ def __init__( if handles := self._ctrl.handles: self._ctrl.update_texture_data( - handles[0].data(), significant_bits=significant_bits + handles[0].img.data, + significant_bits=significant_bits, + need_histogram=True, ) def _on_stats(self, stats: ImageStats) -> None: @@ -970,7 +947,7 @@ def _on_stats(self, stats: ImageStats) -> None: def _on_clims_resolved(self, clims: tuple[float, float]) -> None: self._hist.set_channel_clims(self._key, clims) - def _on_cmap(self, cmap: cmap_mod.Colormap) -> None: + def _on_cmap(self, cmap: cmap.Colormap) -> None: self._hist.set_channel_color(self._key, cmap.color_stops[-1].color.rgba) def _on_visible(self, visible: bool) -> None: diff --git a/src/ndv/controllers/_channel_controller.py b/src/ndv/controllers/_channel_controller.py index af903c0e..1a56d819 100644 --- a/src/ndv/controllers/_channel_controller.py +++ b/src/ndv/controllers/_channel_controller.py @@ -1,21 +1,21 @@ from __future__ import annotations -from contextlib import suppress -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from psygnal import Signal from ndv.controllers._image_stats import ImageStats, compute_image_stats +from ndv.views.bases import LUTView if TYPE_CHECKING: from collections.abc import Iterable, Sequence + import cmap import numpy as np + import scenex as snx from ndv._types import ChannelKey - from ndv.models._lut_model import LUTModel - from ndv.views.bases import LUTView - from ndv.views.bases._graphics._canvas_elements import ImageHandle + from ndv.models._lut_model import ClimPolicy, LUTModel class ChannelController: @@ -42,12 +42,23 @@ def __init__( self.lut_views: list[LUTView] = [] self.lut_model = lut_model self.lut_model.events.clims.connect(self._auto_scale) - self.handles: list[ImageHandle] = [] + self.handles: list[SnxLUTView] = [] self._last_clims: tuple[float, float] | None = None for v in views: self.add_lut_view(v) + def clear_channel_data(self) -> None: + """Clear the image/volume handles associated with this channel.""" + new_lut_views: list[LUTView] = [] + for view in self.lut_views: + if isinstance(view, SnxLUTView): + view.close() + else: + new_lut_views.append(view) + self.lut_views = new_lut_views + self.handles = [] + def add_lut_view(self, view: LUTView) -> None: """Add a LUT view to the controller.""" view.model = self.lut_model @@ -58,8 +69,10 @@ def add_lut_view(self, view: LUTView) -> None: def synchronize(self, *views: LUTView) -> None: """Aligns all views against the backing model.""" _views: Iterable[LUTView] = views or self.lut_views + name = str(self.key) if self.key is not None else "" for view in _views: view.synchronize() + view.set_channel_name(name) def update_texture_data( self, @@ -74,7 +87,7 @@ def update_texture_data( # for multiple handles, we'll just update the first one if not (handles := self.handles): return None - handles[0].set_data(data) + handles[0].img.data = data need_histogram = need_histogram or self.needs_histogram stats = compute_image_stats( data, @@ -87,43 +100,73 @@ def update_texture_data( self.stats_updated.emit(stats) return stats - def add_handle(self, handle: ImageHandle) -> None: - """Add an image texture handle to the controller.""" + def add_image( + self, + image: snx.Image, + *, + need_histogram: bool = False, + significant_bits: int | None = None, + ) -> ImageStats | None: + """Add an image (or volume) texture handle to the controller.""" + handle = SnxLUTView(image) self.handles.append(handle) self.add_lut_view(handle) - def get_value_at_index(self, idx: tuple[int, ...]) -> np.ndarray | float | None: - """Get the value of the data at the given index.""" - if not (handles := self.handles): - return None - # only getting one handle per channel for now - handle = handles[0] - if not handle.visible(): - return None - with suppress(IndexError): # skip out of bounds - # here, we're retrieving the value from the in-memory data - # stored by the backend visual, rather than querying the data itself - # this is a quick workaround to get the value without having to - # worry about other dimensions in the data source (since the - # texture has already been reduced to RGB/RGBA/2D). But a more complete - # implementation would gather the full current nD index and query - # the data source directly. - return handle.data()[idx] # type: ignore [no-any-return] - return None - - def _set_clims(self, clims: tuple[float, float]) -> None: - self._last_clims = clims - for view in self.lut_views: - view.set_clims(clims) - self.clims_resolved.emit(clims) + stats = compute_image_stats( + image.data, + self.lut_model.clims, + need_histogram=need_histogram, + significant_bits=significant_bits, + ) + self._set_clims(stats.clims) + return stats def _auto_scale(self) -> None: - if self.lut_model and self.handles: + if self.lut_model and len(self.handles): policy = self.lut_model.clims all_clims = [ - compute_image_stats(h.data(), policy, need_histogram=False).clims + compute_image_stats(h.img.data, policy, need_histogram=False).clims for h in self.handles ] mi = min(c[0] for c in all_clims) ma = max(c[1] for c in all_clims) self._set_clims((mi, ma)) + + def _set_clims(self, clims: tuple[float, float]) -> None: + self._last_clims = clims + for view in self.lut_views: + view.set_clims(clims) + self.clims_resolved.emit(clims) + + +class SnxLUTView(LUTView): + def __init__(self, img: snx.Image) -> None: + self.img = img + + def close(self) -> None: + self.img.parent = None + + def frontend_widget(self) -> Any: + return None + + def set_channel_name(self, name: str) -> None: + self.img.name = name + + def set_clim_policy(self, policy: ClimPolicy) -> None: + pass + + def set_channel_visible(self, visible: bool) -> None: + self.set_visible(visible) + + def set_clims(self, clims: tuple[float, float]) -> None: + self.img.clims = clims + + def set_colormap(self, cmap: cmap.Colormap) -> None: + self.img.cmap = cmap + + def set_visible(self, visible: bool) -> None: + self.img.visible = visible + + def set_gamma(self, gamma: float) -> None: + # These bounds coerce the gamma into the range allowed by scenex + self.img.gamma = max(1e-6, min(gamma, 2)) diff --git a/src/ndv/views/__init__.py b/src/ndv/views/__init__.py index 89f9ee5f..deb53d4a 100644 --- a/src/ndv/views/__init__.py +++ b/src/ndv/views/__init__.py @@ -5,12 +5,9 @@ """ from ._app import ( - CanvasBackend, GuiFrontend, call_later, - get_array_canvas_class, get_array_view_class, - get_histogram_canvas_class, gui_frontend, process_events, run_app, @@ -19,12 +16,9 @@ ) __all__ = [ - "CanvasBackend", "GuiFrontend", "call_later", - "get_array_canvas_class", "get_array_view_class", - "get_histogram_canvas_class", "gui_frontend", "process_events", "run_app", diff --git a/src/ndv/views/_app.py b/src/ndv/views/_app.py index f533dd11..f11659eb 100644 --- a/src/ndv/views/_app.py +++ b/src/ndv/views/_app.py @@ -5,7 +5,10 @@ import sys from enum import Enum from functools import wraps -from typing import TYPE_CHECKING, Any, Protocol, cast +from typing import TYPE_CHECKING, cast + +from scenex.adaptors import use +from scenex.app import app if TYPE_CHECKING: from collections.abc import Callable, Iterator @@ -15,14 +18,8 @@ from IPython.core.interactiveshell import InteractiveShell from typing_extensions import ParamSpec, TypeVar - from ndv.views.bases import ( - ArrayCanvas, - ArrayView, - HistogramCanvas, - SharedHistogramCanvas, - ) + from ndv.views.bases import ArrayView from ndv.views.bases._app import NDVApp - from ndv.views.bases._graphics._mouseable import Mouseable T = TypeVar("T") P = ParamSpec("P") @@ -55,105 +52,6 @@ class GuiFrontend(str, Enum): WX = "wx" -class CanvasBackend(str, Enum): - """Enum of available canvas backends. - - Attributes - ---------- - VISPY : str - [Vispy](https://vispy.org) - PYGFX : str - [Pygfx](https://github.com/pygfx/pygfx) - """ - - VISPY = "vispy" - PYGFX = "pygfx" - - -class CanvasProvider(Protocol): - @staticmethod - def is_imported() -> bool: ... - @staticmethod - def is_available() -> bool: ... - @staticmethod - def array_canvas_class() -> type[ArrayCanvas]: ... - @staticmethod - def histogram_canvas_class() -> type[HistogramCanvas]: ... - @staticmethod - def shared_histogram_canvas_class() -> type[SharedHistogramCanvas]: ... - - -class VispyProvider(CanvasProvider): - @staticmethod - def is_imported() -> bool: - return "vispy" in sys.modules - - @staticmethod - def is_available() -> bool: - return importlib.util.find_spec("vispy") is not None - - @staticmethod - def array_canvas_class() -> type[ArrayCanvas]: - from vispy.app import use_app - - from ndv.views._vispy._array_canvas import VispyArrayCanvas - - # these may not be necessary, since we likely have already called - # create_app by this point and vispy will autodetect that. - # it's an extra precaution - _frontend = gui_frontend() - if _frontend == GuiFrontend.JUPYTER: - use_app("jupyter_rfb") - elif _frontend == GuiFrontend.WX: - use_app("wx") - elif _frontend == GuiFrontend.QT: - from qtpy import API_NAME - - use_app(API_NAME.lower()) - - return VispyArrayCanvas - - @staticmethod - def histogram_canvas_class() -> type[HistogramCanvas]: - from ndv.views._vispy._histogram import VispyHistogramCanvas - - return VispyHistogramCanvas - - @staticmethod - def shared_histogram_canvas_class() -> type[SharedHistogramCanvas]: - from ndv.views._vispy._shared_histogram import VispySharedHistogramCanvas - - return VispySharedHistogramCanvas - - -class PygfxProvider(CanvasProvider): - @staticmethod - def is_imported() -> bool: - return "pygfx" in sys.modules - - @staticmethod - def is_available() -> bool: - return importlib.util.find_spec("pygfx") is not None - - @staticmethod - def array_canvas_class() -> type[ArrayCanvas]: - from ndv.views._pygfx._array_canvas import GfxArrayCanvas - - return GfxArrayCanvas - - @staticmethod - def histogram_canvas_class() -> type[HistogramCanvas]: - from ndv.views._pygfx._histogram import PyGFXHistogramCanvas - - return PyGFXHistogramCanvas - - @staticmethod - def shared_histogram_canvas_class() -> type[SharedHistogramCanvas]: - from ndv.views._pygfx._shared_histogram import PyGFXSharedHistogramCanvas - - return PyGFXSharedHistogramCanvas - - # -------------------- Provider selection -------------------- # list of available GUI frontends and canvas backends, tried in order @@ -164,10 +62,6 @@ def shared_histogram_canvas_class() -> type[SharedHistogramCanvas]: GuiFrontend.JUPYTER: ("ndv.views._jupyter._app", "JupyterAppWrap"), } MOD_TO_KEY = {mod: key for key, (mod, _) in GUI_PROVIDERS.items()} -CANVAS_PROVIDERS: dict[CanvasBackend, CanvasProvider] = { - CanvasBackend.VISPY: VispyProvider, - CanvasBackend.PYGFX: PygfxProvider, -} def _running_apps() -> Iterator[GuiFrontend]: @@ -243,14 +137,11 @@ def ndv_app() -> NDVApp: ) -def set_canvas_backend(backend: Literal["pygfx", "vispy"] | None = None) -> None: +def set_canvas_backend( + backend: Literal["vispy", "pygfx"] | None = None, +) -> None: """Sets the preferred canvas backend. Cannot be set after the GUI is running.""" - if _APP: - raise RuntimeError("Cannot change the backend once the app is running") - if backend is None: - os.environ.pop(CANVAS_ENV_VAR) - else: - os.environ[CANVAS_ENV_VAR] = CanvasBackend(backend).value # validate + use(backend) def set_gui_backend(backend: Literal["jupyter", "qt", "wx"] | None = None) -> None: @@ -268,94 +159,11 @@ def gui_frontend() -> GuiFrontend: return MOD_TO_KEY[ndv_app().__module__] -def canvas_backend(requested: str | None = None) -> CanvasBackend: - """Return the preferred canvas backend. - - This is determined first by the NDV_CANVAS_BACKEND environment variable, after which - CANVAS_PROVIDERS are tried in order until one is found that is either already - imported or available - """ - backend = requested or os.getenv(CANVAS_ENV_VAR, "").lower() - - valid = {x.value for x in CanvasBackend} - if backend: - if backend not in valid: - raise ValueError( - f"Invalid canvas backend: {backend!r}. Valid options: {valid}" - ) - return CanvasBackend(backend) - - for key, provider in CANVAS_PROVIDERS.items(): - if provider.is_imported(): - return key - errors: list[tuple[CanvasBackend, BaseException]] = [] - for key, provider in CANVAS_PROVIDERS.items(): - try: - if provider.is_available(): - return key - except Exception as e: - errors.append((key, e)) - - raise RuntimeError( # pragma: no cover - f"Could not find an appropriate canvas backend: {valid!r}. Tried:\n\n" - + "\n".join(f"- {key.value}: {err}" for key, err in errors) - ) - - def get_array_view_class() -> type[ArrayView]: """Return [`ArrayView`][ndv.views.bases.ArrayView] class for current GUI frontend.""" # noqa: E501 return ndv_app().array_view_class() -def get_array_canvas_class(backend: str | None = None) -> type[ArrayCanvas]: - """Return [`ArrayCanvas`][ndv.views.bases.ArrayCanvas] class for current canvas backend.""" # noqa: E501 - _backend = canvas_backend(backend) - if _backend not in CANVAS_PROVIDERS: # pragma: no cover - raise NotImplementedError(f"No canvas backend found for {_backend}") - return CANVAS_PROVIDERS[_backend].array_canvas_class() - - -def get_histogram_canvas_class(backend: str | None = None) -> type[HistogramCanvas]: - """Return [`HistogramCanvas`][ndv.views.bases.HistogramCanvas] class for current canvas backend.""" # noqa: E501 - _backend = canvas_backend(backend) - if _backend not in CANVAS_PROVIDERS: # pragma: no cover - raise NotImplementedError(f"No canvas backend found for {_backend}") - return CANVAS_PROVIDERS[_backend].histogram_canvas_class() - - -def get_shared_histogram_canvas_class( - backend: str | None = None, -) -> type[SharedHistogramCanvas]: - """Return SharedHistogramCanvas class for current canvas backend.""" - _backend = canvas_backend(backend) - if _backend not in CANVAS_PROVIDERS: # pragma: no cover - raise NotImplementedError(f"No canvas backend found for {_backend}") - return CANVAS_PROVIDERS[_backend].shared_histogram_canvas_class() - - -def filter_mouse_events(canvas: Any, receiver: Mouseable) -> Callable[[], None]: - """Intercept mouse events on `scene_canvas` and forward them to `receiver`. - - Parameters - ---------- - canvas : Any - The front-end canvas widget to intercept mouse events from. - receiver : Mouseable - The object to forward mouse events to. - - Returns - ------- - Callable[[], None] - A function that can be called to remove the event filter. - """ - return ndv_app().filter_mouse_events(canvas, receiver) - - -def filter_key_events(widget: Any, receiver: ArrayView) -> Callable[[], None]: - """Intercept key events on `widget` and emit `receiver.keyPressed`.""" - return ndv_app().filter_key_events(widget, receiver) - - def call_later(msec: int, func: Callable[[], None]) -> None: """Call `func` after `msec` milliseconds. @@ -370,17 +178,17 @@ def call_later(msec: int, func: Callable[[], None]) -> None: func : Callable[[], None] The function to call. """ - ndv_app().call_later(msec, func) + app().call_later(msec, func) def process_events() -> None: """Force processing of events for the application.""" - ndv_app().process_events() + app().process_events() def run_app() -> None: """Start the active GUI application event loop.""" - ndv_app().run() + app().run() def ensure_main_thread(func: Callable[P, T]) -> Callable[P, Future[T]]: diff --git a/src/ndv/views/_data_canvas.py b/src/ndv/views/_data_canvas.py new file mode 100644 index 00000000..0bbb9ed5 --- /dev/null +++ b/src/ndv/views/_data_canvas.py @@ -0,0 +1,310 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from psygnal import Signal + +if TYPE_CHECKING: + from ndv.models._viewer_model import ArrayViewerModel + +import cmap +import numpy as np +import scenex.app.events as events +from pydantic import Field, field_validator +from scenex import ( + Camera, + Canvas, + Image, + Letterbox, + Line, + Mesh, + Orbit, + PanZoom, + Points, + Scene, + Transform, + UniformColor, + View, + Volume, + native, + set_cursor, +) +from scenex.app import CursorType +from scenex.model import EventedBase +from scenex.utils import projections + + +class DataCanvas: + eventCaptured = Signal(events.Event) + + def __init__(self, viewer_model: ArrayViewerModel) -> None: + self.viewer_model = viewer_model + # We have one view + self.view = View( + scene=Scene(interactive=True), + camera=Camera(interactive=True), + on_resize=Letterbox(), + ) + # On a canvas + # NOTE: Keep the canvas hidden until we're ready to show it. + # More than anything else, this prevents the GL context from being created + # on the vispy backends before we're ready, leading to some nasty segfaults + self._canvas = Canvas(width=600, height=600, views=[self.view], visible=False) + + self.roi_view = RectangularROI() + self.roi_view.rect_mesh.visible = False + self.roi_view.rect_mesh.parent = self.view.scene + + # Showing two dimensions + self.ndims = 2 + + self._canvas.set_event_filter(self._on_event) + + def _on_event(self, event: events.Event) -> bool: + """Filter events from the canvas and re-emit them as a signal.""" + if self.roi_view.handle_event( + event, + self.view, + self._canvas, + self.viewer_model, + ): + return True + self.eventCaptured.emit(event) + return False # don't consume the event, allow normal processing to continue + + def widget(self) -> Any: + return native(self._canvas) + + @property + def ndims(self) -> int: + return 2 if isinstance(self.view.camera.controller, PanZoom) else 3 + + @ndims.setter + def ndims(self, ndim: int) -> None: + if ndim == 2: + if not isinstance(self.view.camera.controller, PanZoom): + self.view.camera.controller = PanZoom() + elif ndim == 3: + if not isinstance(self.view.camera.controller, Orbit): + # FIXME: This logs a warning because child events are still being + # propagated to the parent Camera. + self.view.camera.controller = Orbit() + else: + raise ValueError("n_axes must be 2 or 3") + self.reset_zoom() + + def reset_zoom(self) -> None: + controller = self.view.camera.controller + if isinstance(controller, PanZoom): + projections.zoom_to_fit( + self.view, + type="orthographic", + zoom_factor=0.9, + letterbox=True, + ) + elif isinstance(controller, Orbit): + projections.zoom_to_fit( + self.view, + type="perspective", + zoom_factor=0.9, + letterbox=True, + ) + if bb := self.view.scene.bounding_box: + controller.center = np.mean(bb, axis=0) + else: + controller.center = (0, 0, 0) + + # FIXME: This doesn't really belong here. + def set_scales(self, scales: tuple[float, ...]) -> None: + """Set per-visible-axis scale factors for rendering.""" + if not scales: + return + # scales are in data order (slowest-to-fastest, e.g. ZYX) + # vispy images use row,col -> y,x mapping, so reverse for XY + vis_scales = list(reversed(scales)) + # pad to 3 components + while len(vis_scales) < 3: + vis_scales.append(1.0) + sx, sy, sz = vis_scales[0], vis_scales[1], vis_scales[2] + for node in self.view.scene.children: + if not isinstance(node, Image | Volume): + continue + # FIXME: This might ignore downsampling. We will have to test. + node.transform = Transform().scaled((sx, sy, sz)) + + +def _dummy_mesh() -> Mesh: + return Mesh( + vertices=np.array([[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0]], dtype=float), + faces=np.array([[0, 1, 2], [0, 2, 3]]), + color=UniformColor(color=cmap.Color("royalblue")), + opacity=0.25, + order=1, + ) + + +class RectangularROI(EventedBase): + bb: tuple[tuple[float, float], tuple[float, float]] = ((0, 0), (0, 0)) + handle_color: cmap.Color = cmap.Color("white") + + outline_color: cmap.Color = cmap.Color("royalblue") + fill_color: cmap.Color = cmap.Color("green") + anchor: tuple[float, float] | None = None + drag_start: tuple[float, float] | None = None + + rect_mesh: Mesh = Field(default_factory=_dummy_mesh) + rect_line: Line = Field(default_factory=Line) + handles: Points = Field(default_factory=Points) + + def __init__(self) -> None: + super().__init__() + vertices = np.array([[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0]], dtype=float) + + self.rect_mesh = Mesh( + vertices=vertices, + faces=np.array([[0, 1, 2], [0, 2, 3]]), + color=UniformColor(color=cmap.Color((0, 0, 0, 0))), + opacity=0.25, + order=1, + ) + + self.rect_line = Line( + parent=self.rect_mesh, + vertices=vertices[[0, 1, 2, 3, 0]], + color=UniformColor(color=cmap.Color("yellow")), + width=2.0, + order=2, + ) + + self.handles = Points( + parent=self.rect_mesh, + vertices=vertices, + size=14, + face_color=UniformColor(color=cmap.Color("white")), + symbol="disc", + scaling="fixed", + order=3, + ) + self.events.bb.connect(self._on_bounding_box_changed) + self.events.handle_color.connect(self._on_handle_color_changed) + self.events.outline_color.connect(self._on_outline_color_changed) + self.events.fill_color.connect(self._on_fill_color_changed) + + @field_validator("bb", mode="before") + @classmethod + def _normalize_bb(cls, v: Any) -> tuple[tuple[float, float], tuple[float, float]]: + (x0, y0), (x1, y1) = v + return ((min(x0, x1), min(y0, y1)), (max(x0, x1), max(y0, y1))) + + def _on_bounding_box_changed( + self, bb: tuple[tuple[float, float], tuple[float, float]] + ) -> None: + x1, y1 = bb[0] + # avoid zero-size which can cause rendering issues + x2 = max(bb[1][0], bb[0][0] + 1e-5) + y2 = max(bb[1][1], bb[0][1] + 1e-5) + vertices = np.array( + [[x1, y1, 0], [x2, y1, 0], [x2, y2, 0], [x1, y2, 0]], dtype=float + ) + self.rect_mesh.vertices = vertices + self.rect_line.vertices = vertices[[0, 1, 2, 3, 0]] + self.handles.vertices = vertices + + def _on_handle_color_changed(self, color: cmap.Color) -> None: + self.handles.face_color = UniformColor(color=color) + + def _on_outline_color_changed(self, color: cmap.Color) -> None: + self.rect_line.color = UniformColor(color=color) + + def _on_fill_color_changed(self, color: cmap.Color) -> None: + self.rect_mesh.color = UniformColor(color=color) + + def _nearest_corner(self, wx: float, wy: float) -> int: + """Index of the corner handle nearest to world position (wx, wy).""" + world = self.rect_mesh.transform.map(self.rect_mesh.vertices)[:, :2] + return int(np.argmin(np.linalg.norm(world - [wx, wy], axis=1))) + + def _cursor_for_pos(self, wx: float, wy: float) -> CursorType: + # Even corners (BL=0, TR=2) are on the main diagonal, + # odd corners (BR=1, TL=3) on the anti-diagonal. + return ( + CursorType.BDIAG_ARROW + if self._nearest_corner(wx, wy) % 2 == 0 + else CursorType.FDIAG_ARROW + ) + + def handle_event( + self, + event: events.Event, + view: View, + canvas: Canvas, + viewer_model: ArrayViewerModel, + ) -> bool: + """Handle ROI creation, dragging, and cursor updates. + + Returns True if the event was consumed. + """ + from ndv.models._viewer_model import InteractionMode + + if isinstance(event, events.MouseMoveEvent): + if not (ray := view.to_ray(event.pos)): + return False + pos = ray.origin[:2] + # -- Dragging a handle -- # + if self.anchor is not None: + self.bb = (pos, self.anchor) + set_cursor(canvas, self._cursor_for_pos(*pos)) + return True + # -- Dragging the whole rectangle -- # + if self.drag_start is not None: + delta = np.subtract(pos, self.drag_start) + # NOTE we just need two opposite corners, doesn't matter which two. + v0 = self.rect_mesh.vertices[0, :2] + delta + v2 = self.rect_mesh.vertices[2, :2] + delta + self.bb = (v0, v2) + self.drag_start = pos + return True + # -- Hover cursor -- # + if viewer_model.interaction_mode == InteractionMode.CREATE_ROI: + set_cursor(canvas, CursorType.CROSS) + elif ray.intersections(self.handles): + set_cursor(canvas, self._cursor_for_pos(*pos)) + elif ray.intersections(self.rect_mesh): + set_cursor(canvas, CursorType.ALL_ARROW) + else: + set_cursor(canvas, CursorType.DEFAULT) + + elif isinstance(event, events.MousePressEvent): + if not (ray := view.to_ray(event.pos)): + return False + if event.buttons & events.MouseButton.LEFT: + pos = ray.origin[:2] + if viewer_model.interaction_mode == InteractionMode.CREATE_ROI: + self.rect_mesh.visible = True + self.bb = ( + (ray.origin[0], ray.origin[1]), + (ray.origin[0] + 1, ray.origin[1] + 1), + ) + viewer_model.interaction_mode = InteractionMode.PAN_ZOOM + # -- Start a handle drag -- # + if ray.intersections(self.handles): + clicked = self._nearest_corner(*pos) + opp = (clicked + 2) % 4 + self.anchor = self.rect_mesh.vertices[opp, :2] + return True + # -- Start a rectangle drag -- # + elif ray.intersections(self.rect_mesh): + self.drag_start = pos + return True + + elif isinstance(event, events.MouseReleaseEvent): + self.anchor = None + self.drag_start = None + return True + + elif isinstance(event, events.MouseLeaveEvent): + self.anchor = None + self.drag_start = None + set_cursor(canvas, CursorType.DEFAULT) + + return False diff --git a/src/ndv/views/_histogram.py b/src/ndv/views/_histogram.py new file mode 100644 index 00000000..de2dfb5f --- /dev/null +++ b/src/ndv/views/_histogram.py @@ -0,0 +1,601 @@ +from __future__ import annotations + +from math import ceil, floor, log10 +from typing import TYPE_CHECKING, Any + +import cmap +import numpy as np +import numpy.typing as npt +import scenex as snx +from scenex.app import CursorType, events +from scenex.utils import projections + +from ndv.models._lut_model import ClimsManual +from ndv.views.bases import LUTView + +if TYPE_CHECKING: + from collections.abc import Sequence + + from ndv.models._lut_model import ClimPolicy + +Y_AXIS = 40 # width (pixels) reserved for y axis view +X_AXIS = 25 # height (pixels) reserved for x axis view + + +def _calc_hist_bins(data: np.ndarray) -> tuple[np.ndarray, np.ndarray]: + maxval = np.iinfo(data.dtype).max + counts = np.bincount(data.flatten(), minlength=maxval + 1) + bin_edges = np.arange(maxval + 2) - 0.5 + return counts, bin_edges + + +class Histogram(LUTView): + def __init__(self) -> None: + self._clims: tuple[float, float] = (0, 65535) + self._gamma = 1.0 + self._grabbed: snx.Node | None = None + self._initialized = False + + # State variables - will be used when creating objects + self._containers: dict[int, tuple[np.ndarray, np.ndarray]] = {} + self._values: np.ndarray | None = None + self._bins: np.ndarray | None = None + self._log_base: float | None = None + self._max_bin: float | None = None + + # Create canvas early so it's available before set_data + self.canvas = snx.Canvas() + self.canvas.visible = True + + # Create views with empty scenes in constructor + self.x_view = snx.View( + scene=snx.Scene(name="x axis scene"), + camera=snx.Camera(), + ) + self.view = snx.View( + scene=snx.Scene(name="main scene"), + camera=snx.Camera(interactive=True), + ) + self.y_view = snx.View( + scene=snx.Scene(name="y axis scene"), + camera=snx.Camera(), + ) + + # Layout (pixel-based) + self.canvas.views.append(self.x_view) + self.canvas.views.append(self.y_view) + self.canvas.views.append(self.view) + # Define barrier between x axis and main/y view + self.x_view.layout.y_start = f"-{X_AXIS}px" + self.y_view.layout.y_end = f"-{X_AXIS}px" + self.view.layout.y_end = f"-{X_AXIS}px" + # Define barrier between y axis and main view + self.view.layout.x_start = f"{Y_AXIS}px" + self.y_view.layout.x_end = f"{Y_AXIS}px" + + # Scene contents will be created on first set_data call + # FIXME: We do this because there's a VisPy bug that causes a blank canvas when + # there is a non-empty scene at first render. + # (RuntimeError: OpenGL got errors (periodic check): GL_INVALID_OPERATION) + # The same thing actually happens + + self.x_axis: snx.Line | None = None + self._tick_objects: list[snx.Text] = [] + self.y_axis: snx.Line | None = None + self.y_max: snx.Text | None = None + self.mesh: snx.Mesh | None = None + self.highlight_line: snx.Line | None = None + self.left_clim: snx.Line | None = None + self.gamma_curve: snx.Line | None = None + self.right_clim: snx.Line | None = None + self.gamma_handle: snx.Points | None = None + self.controls: snx.Scene | None = None + + def _initialize_views(self) -> None: + """Lazy initialization of scene contents on first set_data call.""" + if self._initialized: + return + + # 1. Populate x axis view scene + self.x_axis = snx.Line( + vertices=np.array([[0, 0, 0], [1, 0, 0]]), + width=2, + color=snx.UniformColor(color=cmap.Color("white")), + ) + self.x_view.scene.add_child(self.x_axis) + + # Pre-create 10 tick objects with line children (enough for min, max, and ticks) + for _ in range(10): + tick_line = snx.Line( + vertices=np.array([[0, 0, 0], [0, -0.1, 0]]), + width=1, + color=snx.UniformColor(color=cmap.Color("white")), + transform=snx.Transform().translated((0, 0.4, 0)), + ) + tick_text = snx.Text(text="0", children=[tick_line], antialias=True) + self._tick_objects.append(tick_text) + + # 2. Populate y axis view scene + self.y_axis = snx.Line( + vertices=np.array([[0, 0, 0], [0, 1, 0]]), + width=2, + color=snx.UniformColor(color=cmap.Color("white")), + ) + self.y_max = snx.Text( + text="1", + transform=snx.Transform().translated((-0.5, 0.95)), + antialias=True, + ) + self.y_view.scene.add_child(self.y_axis) + self.y_view.scene.add_child(self.y_max) + + # 3. Populate main histogram view scene + self.mesh = snx.Mesh( + vertices=np.zeros((1, 3), dtype=np.float32), + faces=np.zeros((1, 3), dtype=np.uint16), + color=snx.UniformColor(color=cmap.Color("steelblue")), + ) + + self.highlight_line = snx.Line( + vertices=np.array([[0, 0, 0], [0, 1, 0]]), + width=2, + color=snx.UniformColor(color=cmap.Color("yellow")), + visible=False, # Start hidden + ) + + # Split LUT line into three interactive components + self.left_clim = snx.Line( + name="left clim", + interactive=True, + ) + self.gamma_curve = snx.Line( + name="gamma curve", + interactive=False, + ) + self.right_clim = snx.Line( + name="right clim", + interactive=True, + ) + self.gamma_handle = snx.Points( + name="gamma handle", + vertices=np.array([[0.5, 0.5, 0]]), + size=8, + scaling="fixed", + face_color=snx.UniformColor(color=cmap.Color("white")), + edge_color=snx.UniformColor(color=cmap.Color("black")), + interactive=True, + ) + + self._create_static_clim_lines() + self._update_lut_line() + + self.controls = snx.Scene( + name="controls scene", + # NOTE: This ordering determines selection on overlapping nodes + children=[ + self.right_clim, + self.left_clim, + self.gamma_curve, + self.gamma_handle, + ], + interactive=True, + ) + + # Draw order (from bottom to top): + # 0: histogram mesh + self.mesh.order = 0 + self.view.scene.add_child(self.mesh) + # 1: controls (clim lines, gamma curve, handle) + self.controls.order = 1 + self.view.scene.add_child(self.controls) + # 2: highlight line + self.highlight_line.order = 2 + self.view.scene.add_child(self.highlight_line) + + # Set up event handlers and controllers + self.view.camera.controller = snx.PanZoom(lock_y=True) + + self.view.camera.events.transform.connect(self._update_x_axis) + self.view.camera.events.projection.connect(self._update_x_axis) + self.canvas.events.width.connect(self._update_x_axis) + self.view.set_event_filter(self._on_main_view) + + self._initialized = True + + self.synchronize() + self.set_clims(self._clims) + + def _on_main_view(self, event: events.Event) -> bool: + if not self._initialized: + return False + + if isinstance(event, events.MousePressEvent): + if not (ray := self.view.to_ray(event.pos)): + return False + intersections = [ + node + for node, _dist in ray.intersections(self.controls) + if node.interactive + ] + if len(intersections): + self._grabbed = intersections[0] + self.view.camera.interactive = False + elif isinstance(event, events.MouseDoublePressEvent): + if not (ray := self.view.to_ray(event.pos)): + return False + intersections = [ + node + for node, _dist in ray.intersections(self.controls) + if node.interactive + ] + if self.gamma_handle in intersections and (model := self.model): + model.gamma = 1 + if isinstance(event, events.MouseMoveEvent): + if not (ray := self.view.to_ray(event.pos)): + return False + if self._grabbed is self.left_clim: + # The left clim must stay to the left of the right clim + new_left = min(ray.origin[0], self._clims[1]) + # ...and no less than the minimum value + if self._bins is not None: + new_left = max(new_left, self._bins[0]) + # Set it + if model := self.model: + model.clims = ClimsManual(min=new_left, max=self._clims[1]) + elif self._grabbed is self.right_clim: + # The right clim must stay to the right of the left clim + new_right = max(self._clims[0], ray.origin[0]) + # ...and no more than the minimum value + if self._bins is not None: + new_right = min(new_right, self._bins[-1]) + # Set it + if model := self.model: + model.clims = ClimsManual(min=self._clims[0], max=new_right) + elif self._grabbed is self.gamma_handle: + # Set it + if model := self.model: + model.gamma = -np.log2(ray.origin[1]) + elif self._grabbed is None: + intersections = [ + node + for node, _dist in ray.intersections(self.controls) + if node.interactive + ] + if self.right_clim in intersections or self.left_clim in intersections: + snx.set_cursor(self.canvas, CursorType.H_ARROW) + elif self.gamma_handle in intersections: + snx.set_cursor(self.canvas, CursorType.V_ARROW) + else: + snx.set_cursor(self.canvas, CursorType.DEFAULT) + + elif isinstance(event, events.WheelEvent): + # Pan on horizontal mouse scroll + dx, dy = event.angle_delta + if abs(dx) > abs(dy): + # FIXME: 5000 is a magic number + cam = self.view.camera + left, *_ = cam.transform.map(cam.projection.imap((-1, 0))) + right, *_ = cam.transform.map(cam.projection.imap((1, 0))) + pan_dist = -dx / 5000 * (right - left) + cam.transform = cam.transform.translated((pan_dist, 0, 0)) + + if isinstance(event, events.MouseReleaseEvent | events.MouseLeaveEvent): + self._grabbed = None + self.view.camera.interactive = True + return False + + def _create_static_clim_lines(self) -> None: + """Create the static left and right clim lines that don't change with gamma.""" + # Left clim line (vertical line) + left_x = np.array([0, 0, 0]) + left_y = np.array([1, 0.5, 0]) + left_z = np.zeros(3) + if line := self.left_clim: + line.vertices = np.column_stack((left_x, left_y, left_z)) + + # Right clim line (vertical line) + right_x = np.array([1, 1, 1]) + right_y = np.array([1, 0.5, 0]) + right_z = np.zeros(3) + if line := self.right_clim: + line.vertices = np.column_stack((right_x, right_y, right_z)) + + # Color the clim lines + dark_clim_color = cmap.Color((0.4, 0.4, 0.4)) + light_clim_color = cmap.Color((0.7, 0.7, 0.7)) + if line := self.left_clim: + line.color = snx.VertexColors( + color=[dark_clim_color, light_clim_color, dark_clim_color], + ) + if line := self.right_clim: + line.color = snx.VertexColors( + color=[dark_clim_color, light_clim_color, dark_clim_color], + ) + + def _update_lut_line(self) -> None: + """Updates the gamma curve vertices and colors.""" + if self.gamma_curve is None or self.gamma_handle is None: + return + + npoints = 256 + # Gamma curve (non-interactive) - updates when gamma changes + gamma_x = np.linspace(0, 1, npoints) + gamma_y = np.linspace(0, 1, npoints) ** ( + self.model.gamma if self.model is not None else 1 + ) + gamma_z = np.zeros(npoints) + self.gamma_curve.vertices = np.column_stack((gamma_x, gamma_y, gamma_z)) + + # Gamma curve gets gradient colors + gamma_colors = [ + cmap.Color(c) + for c in np.linspace(0.2, 0.8, npoints).repeat(3).reshape(-1, 3) + ] + self.gamma_curve.color = snx.VertexColors(color=gamma_colors) + gamma = self.model.gamma if self.model is not None else 1 + self.gamma_handle.transform = snx.Transform().translated((0, 0.5**gamma - 0.5)) + + def set_data(self, values: np.ndarray, bin_edges: np.ndarray) -> None: + # Initialize views on first call + self._initialize_views() + + uninitialized = self._values is None + # Update the histogram mesh + self._values = values + self._bins = bin_edges + + self._max_bin = np.max(self._values) + if mesh := self.mesh: + mesh.vertices, mesh.faces = self._hist_counts_to_mesh( + self._values, self._bins, False + ) + # Reapply log scaling if necessary + if log := self._log_base: + self._log_base = None + self.set_log_base(log) + + # Rescale the y axis + self._update_y_axis() + + if uninitialized: + self.set_range() + + def _has_data(self) -> bool: + return self.mesh is not None and self.mesh.vertices.shape[0] > 1 + + # ---- LutView interface implementations ---- + + def set_channel_name(self, name: str) -> None: + pass + + def set_clim_policy(self, policy: ClimPolicy) -> None: + if isinstance(policy, ClimsManual): + self.set_clims((policy.min, policy.max)) + + def set_colormap(self, lut: cmap.Colormap) -> None: + if self.mesh is not None: + self.mesh.color = snx.UniformColor(color=lut.color_stops[-1].color) + + def set_clims(self, clims: tuple[float, float]) -> None: + self._clims = clims + if self.controls is not None: + self.controls.transform = ( + snx.Transform() + .scaled((self._clims[1] - self._clims[0], 1, 1)) + .translated((self._clims[0], 0, 0)) + ) + + def set_clim_bounds( + self, bounds: tuple[float | None, float | None] = (None, None) + ) -> None: + # TODO Implement + pass + + def set_channel_visible(self, visible: bool) -> None: + # TODO Implement + pass + + def set_gamma(self, gamma: float) -> None: + self._update_lut_line() + + def set_log_base(self, base: float | None) -> None: + if self.mesh is None: + return + + old_log, new_log = self._log_base, base + verts = np.zeros_like(self.mesh.vertices) + verts[:, :] = self.mesh.vertices[:, :] + if old_log is not None: + verts[:, 1] = np.power(old_log, verts[:, 1]) - 1 + # use a count+1 histogram to gracefully handle 0, 1 + self._log_base = base + if new_log is not None: + verts[:, 1] = np.log(verts[:, 1] + 1) / np.log(new_log) + # FIXME: Just telling scenex to refresh would be great + verts[:, 0] = self.mesh.vertices[:, 0] + self.mesh.vertices = verts + + self._update_y_axis() + + # ---- Viewable interface implementations ---- + + def set_range( + self, + x: tuple[float, float] | None = None, + y: tuple[float, float] | None = None, + ) -> None: + if not self._initialized: + return + + bb = self.view.scene.bounding_box + center = np.mean(bb, axis=0) if bb else (0, 0, 0) + w, h, d = np.maximum(np.ptp(bb, axis=0) if bb else (1, 1, 1), 1e-6) + if x is not None: + center = (np.mean(x), center[1], center[2]) + w = x[1] - x[0] + if y is not None: + center = (center[0], np.mean(y), center[2]) + h = y[1] - y[0] + self.view.camera.transform = snx.Transform().translated(center) + self.view.camera.projection = projections.orthographic(w, h, d) + self.x_view.camera.projection = projections.orthographic(1, 1, 1) + self.y_view.camera.projection = projections.orthographic(1, 1, 1) + self.x_view.camera.transform = snx.Transform().translated((0.5, -0.5, 0)) + self.y_view.camera.transform = snx.Transform().translated((-0.5, 0.5, 0)) + + def set_visible(self, visible: bool) -> None: + self.canvas.visible = visible + + def frontend_widget(self) -> Any: + return snx.native(self.canvas) + + def close(self) -> None: + # TODO Implement + pass + + def highlight(self, value: float | None) -> None: + """Highlight a specific value on the histogram.""" + if self.highlight_line is None: + return + self.highlight_line.visible = value is not None + self.highlight_line.transform = ( + self.highlight_line.transform + if value is None + else snx.Transform().translated((value, 0, 0)) + ) + + def _calculate_tick_step( + self, min_val: float, max_val: float, target_ticks: int = 5 + ) -> float: + """Calculate a nice tick step for the given range.""" + if max_val <= min_val: + return 1.0 + + range_val = max_val - min_val + approx_step = range_val / target_ticks + + # Find a "nice" step size + power10 = 10.0 ** floor(log10(approx_step)) + for multiplier in [1.0, 2.0, 2.5, 5.0, 10.0]: + step = multiplier * power10 + if step >= approx_step: + return step + + return power10 + + def _get_tick_positions( + self, min_val: float, max_val: float, step: float + ) -> list[float]: + """Get tick positions within range, including min/max and culling overlaps.""" + if step <= 0: + return [min_val, max_val] + + # Calculate intermediate tick positions + first_tick = ceil(min_val / step) * step + last_tick = floor(max_val / step) * step + + intermediate_ticks: list[float] = [] + current = first_tick + while current <= last_tick and len(intermediate_ticks) < 20: # Safety limit + intermediate_ticks.append(current) + current += step + + # Filter out ticks too close to min/max to avoid overlap + min_distance = step * 0.15 + filtered_ticks = [ + t + for t in intermediate_ticks + if abs(t - min_val) >= min_distance and abs(t - max_val) >= min_distance + ] + + # Always include min and max, deduplicate while preserving order + seen: set[float] = set() + unique_ticks: list[float] = [] + for tick in [min_val, *filtered_ticks, max_val]: + if tick not in seen: + seen.add(tick) + unique_ticks.append(tick) + + return unique_ticks + + def _clear_ticks(self) -> None: + """Remove all existing tick marks and labels from the scene.""" + for tick_obj in self._tick_objects: + if tick_obj in self.x_view.scene.children: + self.x_view.scene.remove_child(tick_obj) + + def _update_x_axis(self) -> None: + # Update the x-axis labels based on the current camera projection + if not self._initialized: + return + + cam = self.view.camera + left, *_others = cam.transform.map(cam.projection.imap((-1, 0))) + right, *_others = cam.transform.map(cam.projection.imap((1, 0))) + + # Clear existing ticks and labels + self._clear_ticks() + + # Calculate tick positions (includes min/max and culling logic) + tick_step = self._calculate_tick_step(left, right) + unique_positions = self._get_tick_positions(left, right, tick_step) + + _x, _y, w, _h = self.canvas.rect_for(self.x_view) + start = Y_AXIS / w + + # Use cached tick objects for all positions + for tick_idx, tick_val in enumerate(unique_positions): + if tick_idx >= len(self._tick_objects): + break + + norm_pos = ( + start + (tick_val - left) / (right - left) * (1 - start) + if right != left + else 0.5 + ) + + tick_obj = self._tick_objects[tick_idx] + tick_obj.text = f"{tick_val:.0f}" + tick_obj.transform = snx.Transform().translated((norm_pos, -0.5, 0)) + + self.x_view.scene.add_child(tick_obj) + + def _update_y_axis(self) -> None: + if self.mesh is None or self.y_max is None: + return + + max_val = self.mesh.bounding_box[1][1] + # Scale the y-axis to [0, 1] with a small top margin + self.mesh.transform = snx.Transform().scaled((1, 0.95 / max(max_val, 1), 1)) + # Resize the y-axis against the new data + self.y_max.text = f"{max_val:.2f}" + + def _hist_counts_to_mesh( + self, + values: Sequence[float] | npt.NDArray, + bin_edges: Sequence[float] | npt.NDArray, + vertical: bool = False, + ) -> tuple[npt.NDArray[np.float32], npt.NDArray[np.uint32]]: + """Convert histogram counts to mesh vertices and faces for plotting.""" + n_edges = len(bin_edges) + X, Y = (1, 0) if vertical else (0, 1) + + # 4-5 + # | | + # 1-2/7-8 + # |/| | | + # 0-3-6-9 + # construct vertices + # TODO: Reusing the arrays would be nice. + vertices = np.zeros((3 * n_edges - 2, 3), np.float32) + vertices[:, X] = np.repeat(bin_edges, 3)[1:-1] + vertices[1::3, Y] = values + vertices[2::3, Y] = values + vertices[vertices == float("-inf")] = 0 + + # construct triangles + faces = np.zeros((2 * n_edges - 2, 3), np.uint32) + offsets = 3 * np.arange(n_edges - 1, dtype=np.uint32)[:, np.newaxis] + faces[::2] = np.array([0, 2, 1]) + offsets + faces[1::2] = np.array([2, 0, 3]) + offsets + return vertices, faces diff --git a/src/ndv/views/_jupyter/_app.py b/src/ndv/views/_jupyter/_app.py index bae8d0d4..5293948d 100644 --- a/src/ndv/views/_jupyter/_app.py +++ b/src/ndv/views/_jupyter/_app.py @@ -1,27 +1,12 @@ from __future__ import annotations import os -from types import MethodType from typing import TYPE_CHECKING, Any -from jupyter_rfb import RemoteFrameBuffer - -from ndv._types import ( - KeyCode, - KeyMod, - KeyPressEvent, - MouseButton, - MouseMoveEvent, - MousePressEvent, - MouseReleaseEvent, -) from ndv.views.bases._app import NDVApp if TYPE_CHECKING: - from collections.abc import Callable - from ndv.views.bases import ArrayView - from ndv.views.bases._graphics._mouseable import Mouseable class JupyterAppWrap(NDVApp): @@ -50,126 +35,3 @@ def array_view_class(self) -> type[ArrayView]: from ._array_view import JupyterArrayView return JupyterArrayView - - @staticmethod - def mouse_btn(btn: Any) -> MouseButton: - if btn == 1: - return MouseButton.LEFT - if btn == 2: - return MouseButton.RIGHT - if btn == 3: - return MouseButton.MIDDLE - return MouseButton.NONE - - def filter_mouse_events( - self, canvas: Any, receiver: Mouseable - ) -> Callable[[], None]: - if not isinstance(canvas, RemoteFrameBuffer): - raise TypeError( - f"Expected canvas to be RemoteFrameBuffer, got {type(canvas)}" - ) - - # patch the handle_event from _jupyter_rfb.CanvasBackend - # to intercept various mouse events. - super_handle_event = canvas.handle_event - active_btn: MouseButton = MouseButton.NONE - - def handle_event(self: RemoteFrameBuffer, ev: dict) -> None: - nonlocal active_btn - - intercepted = False - etype = ev["event_type"] - if etype == "pointer_move": - mme = MouseMoveEvent(x=ev["x"], y=ev["y"], btn=active_btn) - intercepted |= receiver.on_mouse_move(mme) - if cursor := receiver.get_cursor(mme): - canvas.cursor = cursor.to_jupyter() - receiver.mouseMoved.emit(mme) - elif etype == "pointer_down": - if "button" in ev: - active_btn = JupyterAppWrap.mouse_btn(ev["button"]) - else: - active_btn = MouseButton.NONE - mpe = MousePressEvent(x=ev["x"], y=ev["y"], btn=active_btn) - intercepted |= receiver.on_mouse_press(mpe) - receiver.mousePressed.emit(mpe) - elif etype == "double_click": - # Note that in Jupyter, a double_click event is not a pointer event - # and as such, we need to handle both press and release. See - # https://github.com/vispy/jupyter_rfb/blob/62831dd5a87bc19b4fd5f921d802ed21141e61ec/js/lib/widget.js#L270 - btn = JupyterAppWrap.mouse_btn(ev["button"]) - mpe = MousePressEvent(x=ev["x"], y=ev["y"], btn=btn) - intercepted |= receiver.on_mouse_double_press(mpe) - receiver.mouseDoublePressed.emit(mpe) - # Release - mre = MouseReleaseEvent(x=ev["x"], y=ev["y"], btn=btn) - intercepted |= receiver.on_mouse_release(mre) - receiver.mouseReleased.emit(mre) - elif etype == "pointer_up": - mre = MouseReleaseEvent(x=ev["x"], y=ev["y"], btn=active_btn) - active_btn = MouseButton.NONE - intercepted |= receiver.on_mouse_release(mre) - receiver.mouseReleased.emit(mre) - - if not intercepted: - super_handle_event(ev) - - canvas.handle_event = MethodType(handle_event, canvas) - return lambda: setattr(canvas, "handle_event", super_handle_event) - - def filter_key_events(self, widget: Any, receiver: ArrayView) -> Callable[[], None]: - # In Jupyter, key events must go through the RemoteFrameBuffer canvas - # (which uses a hidden element to capture keys), not the - # ipywidgets container. Walk the widget tree to find it. - target = _find_rfb(widget) - if target is None: - return lambda: None - - super_handle_event = target.handle_event - - def handle_event(self: RemoteFrameBuffer, ev: dict) -> None: - if ev["event_type"] == "key_down": - key_str = ev.get("key", "") - key: KeyCode | str - if key_str in _JUPYTER_KEY_MAP: - key = _JUPYTER_KEY_MAP[key_str] - elif len(key_str) == 1: - key = key_str - else: - super_handle_event(ev) - return - mods = KeyMod.NONE - if ev.get("shiftKey"): - mods |= KeyMod.SHIFT - if ev.get("ctrlKey"): - mods |= KeyMod.CTRL - if ev.get("altKey"): - mods |= KeyMod.ALT - if ev.get("metaKey"): - mods |= KeyMod.META - receiver.keyPressed.emit(KeyPressEvent(key, mods)) - super_handle_event(ev) - - target.handle_event = MethodType(handle_event, target) - return lambda: setattr(target, "handle_event", super_handle_event) - - -_JUPYTER_KEY_MAP: dict[str, KeyCode] = { - "ArrowUp": KeyCode.UP, - "ArrowDown": KeyCode.DOWN, - "ArrowLeft": KeyCode.LEFT, - "ArrowRight": KeyCode.RIGHT, - " ": KeyCode.SPACE, - "Home": KeyCode.HOME, - "End": KeyCode.END, -} - - -def _find_rfb(widget: Any) -> RemoteFrameBuffer | None: - """Walk the ipywidgets tree to find a RemoteFrameBuffer child.""" - if isinstance(widget, RemoteFrameBuffer): - return widget - for child in getattr(widget, "children", ()): - if found := _find_rfb(child): - return found - return None diff --git a/src/ndv/views/_jupyter/_array_view.py b/src/ndv/views/_jupyter/_array_view.py index d8caee82..ecd5ed10 100644 --- a/src/ndv/views/_jupyter/_array_view.py +++ b/src/ndv/views/_jupyter/_array_view.py @@ -18,12 +18,12 @@ if TYPE_CHECKING: from collections.abc import Container, Hashable, Iterator, Mapping, Sequence + from jupyter_rfb import RemoteFrameBuffer from psygnal import EmissionInfo from traitlets import HasTraits - from vispy.app.backends import _jupyter_rfb from ndv._types import AxisKey, ChannelKey - from ndv.views.bases._graphics._canvas import HistogramCanvas + from ndv.views._histogram import Histogram # not entirely sure why it's necessary to specifically annotat signals as : PSignal # i think it has to do with type variance? @@ -159,7 +159,7 @@ def __init__( default_luts: Sequence[Any] = ("gray", "green", "magenta", "red", "blue"), ) -> None: self._channel = channel - self._histogram: HistogramCanvas | None = None + self._histogram: Any | None = None # WIDGETS self._visible = widgets.Checkbox(value=True, indent=False) self._visible.layout.width = "60px" @@ -361,7 +361,7 @@ def frontend_widget(self) -> Any: # ------------------ private methods --------------- - def add_histogram(self, histogram: HistogramCanvas) -> None: + def add_histogram(self, histogram: Histogram) -> None: widget = histogram.frontend_widget() # Resize widget to a respectable size widget.set_trait("css_height", "auto") @@ -385,13 +385,14 @@ def __init__(self, channel: ChannelKey = None) -> None: class JupyterArrayView(ArrayView): def __init__( self, - canvas_widget: _jupyter_rfb.CanvasBackend, + canvas_widget: RemoteFrameBuffer, viewer_model: ArrayViewerModel, ) -> None: self._viewer_model = viewer_model self._viewer_model.events.connect(self._on_viewer_model_event) # WIDGETS self._canvas_widget = canvas_widget + self._canvas_widget.layout.display = "flex" self._visible_axes: Sequence[AxisKey] = [] self._luts: dict[ChannelKey, JupyterLUTView] = {} @@ -629,7 +630,7 @@ def _on_channel_mode_changed(self, change: dict[str, Any]) -> None: """Emit signal when the channel mode changes.""" self.channelModeChanged.emit(ChannelMode(change["new"])) - def add_histogram(self, channel: ChannelKey, histogram: HistogramCanvas) -> None: + def add_histogram(self, channel: ChannelKey, histogram: Histogram) -> None: if lut := self._luts.get(channel, None): lut.add_histogram(histogram) @@ -647,16 +648,18 @@ def _on_shared_histogram_toggled(self, change: dict[str, Any]) -> None: self._shared_hist_log_btn.layout.display = "block" if toggled else "none" def _on_shared_hist_log_toggled(self, change: dict[str, Any]) -> None: - if self._shared_histogram is not None: - self._shared_histogram.set_log_base(10 if change["new"] else None) + self.sharedHistogramLogRequested.emit(10 if change["new"] else None) def remove_histogram(self, widget: Any) -> None: """Remove a histogram widget from the viewer.""" def add_shared_histogram(self, widget: Any) -> None: self._shared_histogram = widget - frontend = widget.frontend_widget() - self._shared_histogram_container.children = (frontend,) + widget.layout.display = "flex" + # HACK: + height = self._shared_histogram_container.layout.height + widget.set_trait("css_height", height) + self._shared_histogram_container.children = (widget,) def remove_shared_histogram(self) -> None: self._shared_histogram_container.children = () @@ -669,10 +672,12 @@ def set_visible(self, visible: bool) -> None: # show or hide the actual widget itself from IPython import display + # HACK: This used to be in an else clause, but this actually is useful here now + # because the setting of the canvas to visible in _array_viewer shows the + # canvas. Since we can't really reparent that widget, let's just clear it. + display.clear_output() # type: ignore [no-untyped-call] if visible: display.display(self.layout) # type: ignore [no-untyped-call] - else: - display.clear_output() # type: ignore [no-untyped-call] def visible_axes(self) -> Sequence[AxisKey]: return self._visible_axes diff --git a/src/ndv/views/_pygfx/__init__.py b/src/ndv/views/_pygfx/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/ndv/views/_pygfx/_array_canvas.py b/src/ndv/views/_pygfx/_array_canvas.py deleted file mode 100755 index 5aebafe3..00000000 --- a/src/ndv/views/_pygfx/_array_canvas.py +++ /dev/null @@ -1,814 +0,0 @@ -from __future__ import annotations - -from contextlib import suppress -from functools import lru_cache -from typing import TYPE_CHECKING, Any, Literal, TypeVar, cast -from weakref import ReferenceType, WeakValueDictionary, ref - -import cmap as _cmap -import numpy as np -import pygfx -import pylinalg as la - -from ndv._types import ( - CursorType, - MouseButton, - MouseMoveEvent, - MousePressEvent, - MouseReleaseEvent, -) -from ndv.models._viewer_model import ArrayViewerModel, InteractionMode -from ndv.views._app import filter_mouse_events -from ndv.views._util import downsample_data -from ndv.views.bases import ArrayCanvas, CanvasElement, ImageHandle -from ndv.views.bases._graphics._canvas_elements import RectangularROIHandle, ROIMoveMode - -from ._util import rendercanvas_class - -if TYPE_CHECKING: - from collections.abc import Callable, Sequence - - from pygfx.materials import ImageBasicMaterial - from pygfx.resources import Texture - - -def _destroy_pygfx_gpu_resources(world_obj: pygfx.WorldObject) -> None: - """Explicitly destroy wgpu GPU objects to free VRAM. - - pygfx relies on Python GC to release wgpu objects, but wgpu's release() - alone doesn't free GPU memory on Metal — destroy() must be called first. - """ - geo = getattr(world_obj, "geometry", None) - if geo is not None: - for attr in ("grid", "positions", "indices"): - resource = getattr(geo, attr, None) - wgpu_obj = getattr(resource, "_wgpu_object", None) - if wgpu_obj is not None: - with suppress(Exception): - wgpu_obj.destroy() - - -def _is_inside(bounding_box: np.ndarray | None, pos: Sequence[float]) -> bool: - if bounding_box is None: - return False - 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(ImageHandle): - def __init__(self, image: pygfx.Image | pygfx.Volume, render: Callable) -> None: - self._image = image - self._render = render - self._grid = cast("Texture", image.geometry.grid) - self._material = cast("ImageBasicMaterial", image.material) - # per-axis downsample strides applied to fit GPU texture limits - self._downsample_factors: tuple[int, ...] = () - - def data(self) -> np.ndarray: - return self._grid.data # type: ignore [no-any-return] - - def set_data(self, data: np.ndarray) -> None: - is_three_d = isinstance(self._image, pygfx.Volume) - data, self._downsample_factors = _downcast_and_downsample( - data, - three_d=is_three_d, - warn=False, - copy=False, - ) - # If dimensions are unchanged, reuse the buffer - if data.shape == self._grid.data.shape: - self._grid.data[:] = data # pyright: ignore[reportOptionalSubscript] - self._grid.update_range((0, 0, 0), self._grid.size) - # Otherwise, the size (and maybe number of dimensions) changed - # - we need a new buffer - else: - dim = 3 if is_three_d else 2 - self._grid = pygfx.Texture(data, dim=dim) - self._image.geometry = pygfx.Geometry(grid=self._grid) - # RGB images (i.e. 3D datasets) cannot have a colormap - if not is_three_d: - self._material.map = None if self._is_rgb() else self._cmap.to_pygfx() - - def visible(self) -> bool: - return bool(self._image.visible) - - def set_visible(self, visible: bool) -> None: - self._image.visible = visible - self._render() - - def can_select(self) -> bool: - return False - - def selected(self) -> bool: - return False - - def set_selected(self, selected: bool) -> None: - raise NotImplementedError("Images cannot be selected") - - def clims(self) -> Any: - return self._material.clim - - def set_clims(self, clims: tuple[float, float]) -> None: - self._material.clim = clims - self._render() - - def gamma(self) -> float: - return float(self._material.gamma) - - def set_gamma(self, gamma: float) -> None: - self._material.gamma = gamma - self._render() - - def colormap(self) -> _cmap.Colormap: - return self._cmap - - def set_colormap(self, cmap: _cmap.Colormap) -> None: - self._cmap = cmap - # RGB (i.e. 3D) images should not have a colormap - if not self._is_rgb(): - 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) - # Explicitly destroy wgpu GPU objects to free Metal VRAM. - # pygfx does not call destroy() on its own, relying on GC alone, - # but wgpu's release() doesn't free GPU memory without destroy(). - _destroy_pygfx_gpu_resources(self._image) - - def get_cursor(self, mme: MouseMoveEvent) -> CursorType | None: - return None - - def _is_rgb(self) -> bool: - return self.data().ndim == 3 and isinstance(self._image, pygfx.Image) - - -class PyGFXRectangle(RectangularROIHandle): - def __init__( - self, - render: Callable, - canvas_to_world: Callable, - world_to_canvas: Callable, - parent: pygfx.WorldObject | None = None, - *args: Any, - **kwargs: Any, - ) -> None: - # Positional array backing visual objects - # NB we need five points for the outline - # The first and last rows should be identical - self._positions: np.ndarray = np.zeros((5, 3), dtype=np.float32) - - # Visual objects - self._fill = self._create_fill() - self._outline = self._create_outline() - - # Handles used for ROI manipulation - self._handle_rad = 5 # PIXELS - self._handles = self._create_handles() - - # containing all ROI objects makes selection easier. - self._container = pygfx.WorldObject(*args, **kwargs) - self._container.add(self._fill, self._outline, self._handles) - if parent: - parent.add(self._container) - - # Utilities for moving ROI - self._selected = False - self._move_mode: ROIMoveMode | None = None - # NB _move_anchor has different meanings depending on _move_mode - self._move_anchor: tuple[float, float] = (0, 0) - self._render: Callable = render - self._canvas_to_world: Callable = canvas_to_world - self._world_to_canvas: Callable = world_to_canvas - - # Initialize - self.set_fill(_cmap.Color("transparent")) - self.set_border(_cmap.Color("yellow")) - self.set_handles(_cmap.Color("white")) - self.set_visible(False) - - # -- BoundingBox methods -- # - - def set_bounding_box( - self, minimum: tuple[float, float], maximum: tuple[float, float] - ) -> None: - # NB: Support two diagonal points, not necessarily true min/max - x1 = float(min(minimum[0], maximum[0])) - y1 = float(min(minimum[1], maximum[1])) - x2 = float(max(minimum[0], maximum[0])) - y2 = float(max(minimum[1], maximum[1])) - - # Update each handle - self._positions[0, :2] = [x1, y1] - self._positions[1, :2] = [x2, y1] - self._positions[2, :2] = [x2, y2] - self._positions[3, :2] = [x1, y2] - self._positions[4, :2] = [x1, y1] - self._refresh() - - def set_fill(self, color: _cmap.Color) -> None: - if self._fill: - self._fill.material.color = color.rgba - self._render() - - def set_border(self, color: _cmap.Color) -> None: - if self._outline: - self._outline.material.color = color.rgba - self._render() - - # TODO: Misleading name? - def set_handles(self, color: _cmap.Color) -> None: - if self._handles: - self._handles.material.color = color.rgba - self._render() - - def _create_fill(self) -> pygfx.Mesh: - fill = pygfx.Mesh( - geometry=pygfx.Geometry( - positions=self._positions, - indices=np.array([[0, 1, 2, 3]], dtype=np.int32), - ), - material=pygfx.MeshBasicMaterial(), - ) - return fill - - def _create_outline(self) -> pygfx.Line: - outline = pygfx.Line( - geometry=pygfx.Geometry( - positions=self._positions, - indices=np.array([[0, 1, 2, 3]], dtype=np.int32), - ), - material=pygfx.LineMaterial(thickness=1), - ) - return outline - - def _create_handles(self) -> pygfx.Points: - 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(size=1.5 * self._handle_rad), # pyright: ignore[reportArgumentType] - ) - - # 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 | None], - ) -> Callable[[], np.ndarray | None]: - def new_get_bb() -> np.ndarray | None: - if (bb := old()) is None: - return None - bb = bb.copy() - bb[0, :2] -= self._handle_rad - bb[1, :2] += self._handle_rad - return bb - - return new_get_bb - - handles.get_bounding_box = get_handle_bb(handles.get_bounding_box) - return handles - - def can_select(self) -> bool: - return True - - def selected(self) -> bool: - return self._selected - - def set_selected(self, selected: bool) -> None: - self._selected = selected - if self._handles: - self._handles.visible = selected - - 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 on_mouse_move(self, event: MouseMoveEvent) -> bool: - # Convert canvas -> world - world_pos = tuple(self._canvas_to_world((event.x, event.y))[:2]) - # moving a handle - if self._move_mode == ROIMoveMode.HANDLE: - # The anchor is set to the opposite handle, which never moves. - self.boundingBoxChanged.emit((world_pos, self._move_anchor)) - # translating the whole roi - elif self._move_mode == ROIMoveMode.TRANSLATE: - # The anchor is the mouse position reported in the previous mouse event. - dx = world_pos[0] - self._move_anchor[0] - dy = world_pos[1] - self._move_anchor[1] - # If the mouse moved (dx, dy) between events, the whole ROI needs to be - # translated that amount. - new_min = (self._positions[0, 0] + dx, self._positions[0, 1] + dy) - new_max = (self._positions[2, 0] + dx, self._positions[2, 1] + dy) - self.boundingBoxChanged.emit((new_min, new_max)) - self._move_anchor = world_pos - - return False - - def on_mouse_press(self, event: MousePressEvent) -> bool: - # Convert canvas -> world - world_pos = self._canvas_to_world((event.x, event.y)) - drag_idx = self._handle_under((event.x, event.y)) - # If a marker is pressed - if drag_idx is not None: - self.set_selected(True) - opposite_idx = (drag_idx + 2) % 4 - self._move_mode = ROIMoveMode.HANDLE - self._move_anchor = tuple(self._positions[opposite_idx, :2].copy()) - # If the click is inside the rectangle, translate - elif self._is_inside_roi(world_pos): - self.set_selected(True) - self._move_mode = ROIMoveMode.TRANSLATE - self._move_anchor = world_pos - return False - - def on_mouse_release(self, event: MouseReleaseEvent) -> bool: - return False - - 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 - - def set_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 = visible and self.selected() - self._render() - - def _is_inside_roi(self, world_pos: Sequence[float]) -> bool: - """Check if a raw world position is inside the ROI rectangle.""" - p0 = self._positions[0] # min corner - p2 = self._positions[2] # max corner - return bool(p0[0] <= world_pos[0] <= p2[0] and p0[1] <= world_pos[1] <= p2[1]) - - def _handle_under(self, canvas_pos: Sequence[float]) -> int | None: - """Returns an int in [0, 3], or None. - - canvas_pos should be in canvas (screen pixel) coordinates. - """ - rad2 = self._handle_rad**2 - for i, p in enumerate(self._positions[:-1]): - hp = self._world_to_canvas((p[0], p[1], 0)) - if (hp[0] - canvas_pos[0]) ** 2 + (hp[1] - canvas_pos[1]) ** 2 <= rad2: - return i - return None - - def get_cursor(self, mme: MouseMoveEvent) -> CursorType | None: - canvas_pos = (mme.x, mme.y) - # Step 1: Handles - # Preferred over the rectangle - # Can only be moved if ROI is selected - if (idx := self._handle_under(canvas_pos)) is not None and self.selected(): - # Idx 0 is top left, 2 is bottom right - if idx % 2 == 0: - return CursorType.FDIAG_ARROW - # Idx 1 is bottom left, 3 is top right - return CursorType.BDIAG_ARROW - # Step 2: Entire ROI - world_pos = self._canvas_to_world(canvas_pos) - if self._is_inside_roi(world_pos): - return CursorType.ALL_ARROW - return None - - def remove(self) -> None: - if (par := self._container.parent) is not None: - par.remove(self._container) - - -class GfxArrayCanvas(ArrayCanvas): - """pygfx-based canvas wrapper.""" - - def __init__(self, viewer_model: ArrayViewerModel) -> None: - self._viewer = viewer_model - - self._current_shape: tuple[int, ...] = () - self._last_state: dict[Literal[2, 3], Any] = {} - - cls = rendercanvas_class() - self._canvas = cls(size=(600, 600)) - - # this filter needs to remain in scope for the lifetime of the canvas - # or mouse events will not be intercepted - # the returned function can be called to remove the filter, (and it also - # closes on the event filter and keeps it in scope). - self._disconnect_mouse_events = filter_mouse_events(self._canvas, self) - - self._renderer = pygfx.renderers.WgpuRenderer(self._canvas) - - self._scene = pygfx.Scene() - self._scene.add(pygfx.Background(None, pygfx.BackgroundMaterial("black"))) - self._camera: pygfx.Camera | None = None - self._ndim: Literal[2, 3] | None = None - - # Maps pygfx WorldObjects (scene children) → CanvasElement handles. - # Entries are added by add_image/add_volume/add_bounding_box. - # Nobody explicitly removes entries: the controller owns handle - # lifetimes via ChannelController.handles/lut_views (for images) and - # _roi_view (for ROIs). When the controller calls handle.remove() - # (in _clear_canvas) those refs are dropped, the handle is GC'd, and - # the WeakValueDictionary entry is automatically removed. - # NB: a WeakKeyDictionary would create a ref cycle here because - # each handle (value) holds a strong ref back to its WorldObject (key). - self._elements = WeakValueDictionary[pygfx.WorldObject, CanvasElement]() - self._selection: CanvasElement | None = None - # Maintain a weak reference to the last ROI created. - self._last_roi_created: ReferenceType[PyGFXRectangle] | None = None - # Per-axis world-space scales (x, y, z) used for coordinate conversion - self._world_scales: tuple[float, float, float] = (1.0, 1.0, 1.0) - - def frontend_widget(self) -> Any: - return self._canvas - - def set_ndim(self, ndim: Literal[2, 3]) -> None: - """Set the number of dimensions of the displayed data.""" - if ndim == self._ndim: - return - elif self._ndim is not None and self._camera is not None: - # remember the current state before switching to the new camera - self._last_state[self._ndim] = self._camera.get_state() - - self._ndim = ndim - if ndim == 3: - self._camera = cam = pygfx.PerspectiveCamera(0, 1) - with suppress(ValueError): - # if the scene has no children yet, this will raise a ValueErrors - # FIXME: there's a bit of order-of-call problem here: - # this method needs to be called *after* the scene is constructed... - # that's what controller._on_model_visible_axes_changed does, but - # it seems fragile and should be fixed. - cam.show_object(self._scene, up=(0, -1, 0), view_dir=(0, 0, 1)) - controller = pygfx.OrbitController(cam, register_events=self._renderer) - zoom = "zoom" - # FIXME: there is still an issue with rotational centration. - # the controller is not rotating around the middle of the volume... - # but I think it might actually be a pygfx issue... the critical state - # seems to be somewhere outside of the camera's get_state dict. - else: - self._camera = cam = pygfx.OrthographicCamera(512, 512) - cam.local.scale_y = -1 - cam.local.position = (256, 256, 0) - controller = pygfx.PanZoomController(cam, register_events=self._renderer) - zoom = "zoom_to_point" - - self._controller = controller - # increase zoom wheel gain - self._controller.controls.update({"wheel": (zoom, "push", -0.005)}) - - # restore the previous state if it exists - if state := self._last_state.get(ndim): - cam.set_state(state) - - def add_image(self, data: np.ndarray | None = None) -> PyGFXImageHandle: - """Add a new Image node to the scene.""" - data, downsample_factors = _downcast_and_downsample(data, three_d=False) - tex = pygfx.Texture(data, dim=2) - image = pygfx.Image( - pygfx.Geometry(grid=tex), - pygfx.ImageBasicMaterial(depth_test=False, alpha_mode="add"), - ) - self._scene.add(image) - - if data is not None: - self._current_shape, prev_shape = data.shape, self._current_shape - if not prev_shape: - self.set_range() - - # FIXME: I suspect there are more performant ways to refresh the canvas - # look into it. - handle = PyGFXImageHandle(image, self.refresh) - handle._downsample_factors = downsample_factors - self._elements[image] = handle - return handle - - def add_volume(self, data: np.ndarray | None = None) -> PyGFXImageHandle: - data, downsample_factors = _downcast_and_downsample(data, three_d=True) - tex = pygfx.Texture(data, dim=3) - vol = pygfx.Volume( - pygfx.Geometry(grid=tex), - pygfx.VolumeRayMaterial( - interpolation="nearest", depth_test=False, alpha_mode="add" - ), - ) - self._scene.add(vol) - - if data is not None: - vol.local_position = [-0.5 * i for i in data.shape[::-1]] - self._current_shape, prev_shape = data.shape, self._current_shape - if len(prev_shape) != 3: - self.set_range() - - # FIXME: I suspect there are more performant ways to refresh the canvas - # look into it. - handle = PyGFXImageHandle(vol, self.refresh) - handle._downsample_factors = downsample_factors - self._elements[vol] = handle - return handle - - def add_bounding_box(self) -> PyGFXRectangle: - """Add a new Rectangular ROI node to the scene.""" - roi = PyGFXRectangle( - render=self.refresh, - canvas_to_world=self._canvas_to_world_raw, - world_to_canvas=self.world_to_canvas, - parent=self._scene, - ) - roi.set_visible(False) - self._elements[roi._container] = roi - self._last_roi_created = ref(roi) - return roi - - def set_scales(self, scales: tuple[float, ...]) -> None: - """Set per-visible-axis scale factors for rendering.""" - if not scales: - return - # scales are in data order (slowest-to-fastest, e.g. ZYX) - # pygfx uses XYZ, so reverse - gfx_scales = list(reversed(scales)) - # pad to 3 components - while len(gfx_scales) < 3: - gfx_scales.append(1.0) - - (sx, sy, sz) = gfx_scales[:3] - self._world_scales = (sx, sy, sz) - has_visuals = False - for handle in self._elements.values(): - if not isinstance(handle, PyGFXImageHandle): - continue - child = handle._image - if not isinstance(child, (pygfx.Image, pygfx.Volume)): - continue - _sx, _sy, _sz = sx, sy, sz - # compensate for downsampling so coordinates stay correct - # factors are in data order; pygfx order is (x, y, z) = reversed - factors = handle._downsample_factors - if factors and any(f > 1 for f in factors): - rev = list(reversed(factors)) - _sx *= rev[0] - _sy *= rev[1] if len(rev) > 1 else 1 - _sz *= rev[2] if len(rev) > 2 else 1 - child.local.scale = (_sx, _sy, _sz) - has_visuals = True - if has_visuals: - self.set_range() - - def set_range( - self, - x: tuple[float, float] | None = None, - y: tuple[float, float] | None = None, - z: tuple[float, float] | None = None, - margin: float = 0.05, - ) -> None: - """Update the range of the PanZoomCamera. - - When called with no arguments, the range is set to the full extent of the data. - """ - if not self._scene.children or self._camera is None: - return - - cam = self._camera - cam.show_object(self._scene) - - if (bb := self._scene.get_world_bounding_box()) is not None: - width, height, _depth = np.ptp(bb, axis=0) - if width < 0.01: - width = 1 - if height < 0.01: - height = 1 - cam.width = width - cam.height = height - cam.zoom = 1 - margin - self.refresh() - - def zoom(self, factor: float | tuple, center: tuple[float, float] = (0, 0)) -> None: - """Zoom in (or out) at the given center (world coordinates).""" - if (cam := self._camera) is None: - return - - cx, cy = center - px, py, pz = cam.local.position - cam.local.position = ( - cx + (px - cx) * factor, - cy + (py - cy) * factor, - pz, - ) - cam.zoom /= factor - self._canvas.force_draw() - - def refresh(self) -> None: - with suppress(AttributeError): - self._canvas.update() - self._canvas.request_draw(self._animate) - - def _animate(self) -> None: - if self._camera is not None: - self._renderer.render(self._scene, self._camera) - - def _canvas_to_world_raw( - self, pos_xy: tuple[float, float] - ) -> tuple[float, float, float]: - """Map canvas position to world space without pixel-center offset. - - Returns the raw scene coordinates where pygfx objects live. - """ - viewport = pygfx.Viewport.from_viewport_or_renderer(self._renderer) - if not viewport.is_inside(*pos_xy): - return (-1, -1, -1) - - pos_rel = ( - pos_xy[0] - viewport.rect[0], - pos_xy[1] - viewport.rect[1], - ) - vs = viewport.logical_size - x = pos_rel[0] / vs[0] * 2 - 1 - y = -(pos_rel[1] / vs[1] * 2 - 1) - pos_ndc = (x, y, 0) - - if self._camera: - pos_ndc += la.vec_transform( - self._camera.world.position, self._camera.camera_matrix - ) - pos_world = la.vec_unproject(pos_ndc[:2], self._camera.camera_matrix) - return (pos_world[0], pos_world[1], pos_world[2]) - else: - return (-1, -1, -1) - - 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. - - Includes a 0.5*scale pixel-center offset so that int(world / scale) - gives the correct data index at pixel boundaries. In pygfx, pixel n - is centered at world n*scale; in vispy it is at (n+0.5)*scale. The - offset aligns both backends so controller code works identically. - """ - pos_world = self._canvas_to_world_raw(pos_xy) - if pos_world == (-1, -1, -1): - return pos_world - wsx, wsy, wsz = self._world_scales - return ( - pos_world[0] + 0.5 * wsx, - pos_world[1] + 0.5 * wsy, - pos_world[2] + 0.5 * wsz, - ) - - def world_to_canvas( - self, pos_xyz: tuple[float, float, float] - ) -> tuple[float, float]: - """Map XYZ coordinate in world space to XY canvas position (pixels).""" - viewport = pygfx.Viewport.from_viewport_or_renderer(self._renderer) - if self._camera is None: - return (-1.0, -1.0) - - # Build NDC-to-screen matrix - screen_space = pygfx.utils.transform.AffineTransform() - screen_space.position = (-1, 1, 0) - x_d, y_d = viewport.logical_size - screen_space.scale = (2 / x_d, -2 / y_d, 1) - ndc_to_screen = screen_space.inverse_matrix - - canvas_pos = la.vec_transform( - pos_xyz, ndc_to_screen @ self._camera.camera_matrix - ) - return ( - canvas_pos[0] + viewport.rect[0], - canvas_pos[1] + viewport.rect[1], - ) - - 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_xy[0], pos_xy[1])) - for c in self._scene.children: - bb = c.get_bounding_box() - if _is_inside(bb, pos) and (elem := self._elements.get(c)) is not None: - elements.append(elem) - return elements - - def set_visible(self, visible: bool) -> None: - """Set the visibility of the canvas.""" - self._canvas.visible = visible - - def close(self) -> None: - self._disconnect_mouse_events() - self._canvas.close() - - def on_mouse_press(self, event: MousePressEvent) -> bool: - if self._selection: - self._selection.set_selected(False) - self._selection = None - canvas_pos = (event.x, event.y) - world_pos = self._canvas_to_world_raw(canvas_pos)[:2] - - # If in CREATE_ROI mode, the new ROI should "start" here. - if self._viewer.interaction_mode == InteractionMode.CREATE_ROI: - if self._last_roi_created is None: - raise ValueError("No ROI to create!") - if new_roi := self._last_roi_created(): - self._last_roi_created = None - # HACK: Provide a non-zero starting size so that if the user clicks - # and immediately releases, it's visible and can be selected again - _min = world_pos - _max = (world_pos[0] + 1, world_pos[1] + 1) - # Put the ROI where the user clicked - new_roi.boundingBoxChanged.emit((_min, _max)) - # Make it visible - new_roi.set_visible(True) - # Select it so the mouse press event below triggers ROIMoveMode.HANDLE - # TODO: Make behavior more direct - new_roi.set_selected(True) - - # All done - exit the mode - self._viewer.interaction_mode = InteractionMode.PAN_ZOOM - - # Select first selectable object at clicked point - for vis in self.elements_at(canvas_pos): - if vis.can_select(): - self._selection = vis - self._selection.on_mouse_press(event) - return False - - return False - - def on_mouse_move(self, event: MouseMoveEvent) -> bool: - if event.btn == MouseButton.LEFT: - if self._selection and self._selection.selected(): - self._selection.on_mouse_move(event) - # If we are moving the object, we don't want to move the camera - return True - return False - - def on_mouse_release(self, event: MouseReleaseEvent) -> bool: - if self._selection: - self._selection.on_mouse_release(event) - return False - - def get_cursor(self, event: MouseMoveEvent) -> CursorType: - if self._viewer.interaction_mode == InteractionMode.CREATE_ROI: - return CursorType.CROSS - for vis in self.elements_at((event.x, event.y)): - if cursor := vis.get_cursor(event): - return cursor - return CursorType.DEFAULT - - -T = TypeVar("T", bound=np.ndarray | None) - - -@lru_cache(maxsize=1) -def _get_max_texture_sizes() -> tuple[int | None, int | None]: - """Return (max_2d, max_3d) texture dimensions from the wgpu adapter.""" - try: - import wgpu - - adapter = wgpu.gpu.request_adapter_sync() - limits = adapter.limits - max_2d = limits.get("max-texture-dimension-2d") - max_3d = limits.get("max-texture-dimension-3d") - return max_2d, max_3d - except Exception: - return None, None - - -def _downcast_and_downsample( - data: T, three_d: bool, *, warn: bool = True, copy: bool = True -) -> tuple[T, tuple[int, ...]]: - downsample_factors: tuple[int, ...] = () - if data is not None: - if copy: - # pygfx uses a view of the data without copy, so if we don't - # copy it here, the original data will be modified when the - # texture changes. - data = data.copy() - maxd = _get_max_texture_sizes()[1 if three_d else 0] - if maxd is not None: - data, downsample_factors = downsample_data(data, maxd, warn=warn) # type: ignore[assignment] - return data, downsample_factors # pyright: ignore[reportReturnType] diff --git a/src/ndv/views/_pygfx/_histogram.py b/src/ndv/views/_pygfx/_histogram.py deleted file mode 100644 index 70ba8ea6..00000000 --- a/src/ndv/views/_pygfx/_histogram.py +++ /dev/null @@ -1,805 +0,0 @@ -from __future__ import annotations - -from contextlib import suppress -from enum import Enum, auto -from typing import TYPE_CHECKING, Any - -import numpy as np -import pygfx -import pylinalg as la - -from ndv._types import CursorType, MouseMoveEvent, MousePressEvent, MouseReleaseEvent -from ndv.models._lut_model import ClimPolicy, ClimsManual -from ndv.views._app import filter_mouse_events -from ndv.views.bases import HistogramCanvas - -from ._util import rendercanvas_class - -if TYPE_CHECKING: - from collections.abc import Sequence - - import cmap - import numpy.typing as npt - -MIN_GAMMA: np.float64 = np.float64(1e-6) - - -class Grabbable(Enum): - NONE = auto() - LEFT_CLIM = auto() - RIGHT_CLIM = auto() - GAMMA = auto() - - -class _OrthographicCamera(pygfx.OrthographicCamera): - xbounds: tuple[float | None, float | None] = (None, None) - ybounds: tuple[float | None, float | None] = (None, None) - - def set_state(self, state: dict[str, Any]) -> None: - """Set the state of the camera from a dict. - - Accepted fields are the same as in ``get_state()``. In addition, - the fields ``x``, ``y``, and ``z`` are also accepted to set the - position along a singular dimension. - - """ - # Note that this code preserves camera width so long as the - # desired width is possible given the bounds. This is why - # width clamping must come before the checks against each bound. - - # Constrain width within bounds - if None not in self.xbounds: - max_width = self.xbounds[1] - self.xbounds[0] # type: ignore[operator] - state["width"] = min(state["width"], max_width) - - # Constrain position+/-radius within bounds - x = state["x"] - rad = state.get("width", 0) / 2 - if self.xbounds[0] is not None: - x = max(x, self.xbounds[0] + rad) - if self.xbounds[1] is not None: - x = min(x, self.xbounds[1] - rad) - state["x"] = x - - super().set_state(state) - - -class _Controller(pygfx.PanZoomController): - def handle_event( - self, event: pygfx.objects.Event, viewport: pygfx.Viewport - ) -> None: - # Replace horizontal mouse scroll with panning - if isinstance(event, pygfx.objects.WheelEvent): - if abs(event.dx) > abs(event.dy): - # FIXME: 5000 is a magic number - pan_dist = -event.dx / 5000 * viewport.rect[2] - self.pan((pan_dist, 0), viewport.rect) - viewport.renderer.request_draw() - return - # Vertical scroll: zoom pinned at current minimum - # Use same gain as default pygfx zoom_to_point (-0.005) - factor = 2 ** (event.dy * 0.005) - state = self._get_camera_state() - pos = list(state.get("position", (0, 0, 0))) - width = state.get("width", 1) - left = pos[0] - width / 2 - new_width = width * factor - pos[0] = left + new_width / 2 - state["position"] = tuple(pos) - state["width"] = new_width - self._set_camera_state(state) - self._update_cameras() - viewport.renderer.request_draw() - return - super().handle_event(event, viewport) - return None - - -class PyGFXHistogramCanvas(HistogramCanvas): - """A HistogramCanvas utilizing VisPy.""" - - def __init__(self, *, vertical: bool = False) -> None: - # ------------ data and state ------------ # - - self._values: np.ndarray | None = None - self._bin_edges: Sequence[float] | np.ndarray | None = None - self._clims: tuple[float, float] | None = None - self._gamma: float = 1 - - # the currently grabbed object - self._grabbed: Grabbable = Grabbable.NONE - # whether the y-axis is logarithmic - self._log_base: float | None = None - # whether the histogram is vertical - self._vertical: bool = vertical - # The values of the left and right edges on the canvas (respectively) - self._domain: tuple[float, float] | None = None - # The values of the bottom and top edges on the canvas (respectively) - self._range: tuple[float, float] | None = None - # Canvas Margins, in pixels (around the data) - # TODO: Computation might better support different displays - self.margin_left = 14 # Room for y-axis line + tick marks - self.margin_bottom = 20 # Provide room for x-axis ticks - self.margin_right = 10 - self.margin_top = 20 - - # ------------ PyGFX Canvas ------------ # - cls = rendercanvas_class() - self._size = (600, 600) - self._canvas = cls(size=self._size) - - # this filter needs to remain in scope for the lifetime of the canvas - # or mouse events will not be intercepted - # the returned function can be called to remove the filter, (and it also - # closes on the event filter and keeps it in scope). - self._disconnect_mouse_events = filter_mouse_events(self._canvas, self) - - self._renderer = pygfx.renderers.WgpuRenderer(self._canvas) - - # Note that we split the view up into multiple scenes, each with their - # own camera and renderer. - # - # One scene handles all of the things in the plot, and is rendered - # to a rectangle contained within the margins defined above. - # This greatly simplifies the clipping of nodes on the plot. - self._scene = pygfx.Scene() - self._scene.add(pygfx.Background(None, pygfx.BackgroundMaterial("black"))) - self._plot_view = pygfx.Viewport(self._renderer) - self._controller = _Controller(register_events=self._plot_view) - # increase zoom wheel gain - self._controller.controls.update({"wheel": ("zoom_to_point", "push", -0.005)}) - self._camera = _OrthographicCamera(maintain_aspect=False) - self._controller.add_camera(self._camera, include_state={"x", "width"}) - - # A second scene handles the horizontal axis specifically. It still - # pans and zooms, but it renders to a different rectangle than the - # plot itself to avoid margin math. - self._x_scene = pygfx.Scene() - self._x_scene.add(pygfx.Background(None, pygfx.BackgroundMaterial("black"))) - self._x_cam = _OrthographicCamera(maintain_aspect=False, width=1, height=1) - self._controller.add_camera(self._x_cam, include_state={"x", "width"}) - - # A third scene handles all static nodes (including the vertical axis). - # It renders to the entire canvas. - self._y_scene = pygfx.Scene() - self._y_scene.add(pygfx.Background(None, pygfx.BackgroundMaterial("black"))) - self._y_cam = pygfx.OrthographicCamera(maintain_aspect=False, width=1, height=1) - self._y_cam.local.position = [0.5, 0.5, 0] - - # ------------ Nodes ------------ # - - self._histogram = pygfx.Mesh( - geometry=pygfx.Geometry( - # NB placeholder arrays - positions=np.zeros((1, 3), dtype=np.float32), - indices=np.zeros((1, 3), dtype=np.uint16), - ), - material=pygfx.MeshBasicMaterial(color=(1, 1, 1, 1)), - ) - - self._clim_handles = pygfx.Line( - geometry=pygfx.Geometry( - # Note that these are placeholders soon to be overwritten - positions=np.ndarray((1, 3), dtype=np.float32), - colors=np.ndarray((1, 4), dtype=np.float32), - ), - material=pygfx.LineMaterial( - color_mode="vertex", - thickness=1, - ), - render_order=-9, - ) - self._gamma_handle = pygfx.Points( - geometry=pygfx.Geometry( - positions=np.array([[0.5, 0.5, 0]], dtype=np.float32), - ), - material=pygfx.PointsMaterial( - size=6, - color=(1, 1, 1), - color_mode="uniform", - ), - render_order=-10, - ) - # The highlight is a line that draws attention to a particular domain value. - # e.g. identifying the value under the mouse cursor on an array canvas. - self._highlight = pygfx.Line( - geometry=pygfx.Geometry( - positions=np.array([[0, 0, 0], [0, 1, 0]], dtype=np.float32), - ), - material=pygfx.LineMaterial( - color=(1.0, 1.0, 0.2, 0.75), - dash_pattern=[4, 4], - thickness=1.5, - ), - visible=False, - ) - - self._update_clims() - self._scene.add( - self._histogram, self._clim_handles, self._gamma_handle, self._highlight - ) - - self._x = pygfx.Ruler( - start_pos=(0, 0, 0), - end_pos=(1, 0, 0), - start_value=0, - tick_format="", # Avoid scientific notation - tick_side="right", - tick_size=4, - line_width=1, - ) - self._x.text.font_size = 10 - self._x.text.material.weight_offset = -300 - self._x_scene.add(self._x) - - self._y = pygfx.Ruler( - start_pos=(0, 0, 0), - end_pos=(0, 1, 0), - start_value=0, - tick_side="left", - tick_size=4, - line_width=1, - ) - self._y_scene.add(self._y) - - self._y_max_label = pygfx.MultiText( - text="", - material=pygfx.TextMaterial(color="white", aa=True, weight_offset=-300), - screen_space=True, - font_size=10, - anchor="bottom-left", - ) - - self._y_scene.add(self._y_max_label) - - self.refresh() - - def refresh(self) -> None: - with suppress(AttributeError): - self._canvas.update() - self._canvas.request_draw(self._animate) - - def close(self) -> None: - self._disconnect_mouse_events() - self._canvas.close() - - def _resize( - self, x: tuple[float, float] | None = None, y: tuple[float, float] | None = None - ) -> None: - # Construct the bounding box - bb = np.zeros([2, 2]) - # Priority is given to user range specifications - # If the user does not specify the data display range, - # display the extent of the data if it exists - if x: - bb[:, 0] = x - elif self._domain: - # User-specified - bb[:, 0] = self._domain - elif self._bin_edges is not None: - # Data-specified - bb[:, 0] = (self._bin_edges[0], self._bin_edges[-1]) - else: - # Default - bb[:, 0] = (0, 1) - needs_log_transform = False - if y: - bb[:, 1] = y - needs_log_transform = True - elif self._range: - # User-specified (raw counts) - bb[:, 1] = self._range - needs_log_transform = True - else: - # Data-specified/default (already log-transformed via scale_y) - bb[:, 1] = (0, self._clim_handles.local.scale_y) - - # Transform the count-axis range to match log-transformed mesh data - if needs_log_transform and self._log_base: - bb[1, 1] = np.log(bb[1, 1] + 1) / np.log(self._log_base) - - # Update cameras - # 2D Plot layout: - # - # c0 c1 c2 - # +-------------+-----------------+---------------+ - # r0 | | margin_top | | - # |-------------+-----------------+---------------+ - # r1 | margin_left | data | margin_right | - # |-------------+-----------------+---------------+ - # r2 | | margin_bottom | | - # |-------------+-----------------+---------------+ - # - self._x_cam.width = self._camera.width = bb[1, 0] - bb[0, 0] - self._camera.height = bb[1, 1] - bb[0, 1] - - self._x_cam.local.position = [(bb[0, 0] + bb[1, 0]) / 2, 0, 0] - - self._camera.local.position = [ - (bb[0, 0] + bb[1, 0]) / 2, - (bb[0, 1] + bb[1, 1]) / 2, - 0, - ] - - # NB: Prevent errors for invisible canvases - c_w = max(self._canvas.get_logical_size()[0], 1) - c_h = max(self._canvas.get_logical_size()[1], 1) - - self._update_y_ruler(c_w, c_h, bb[1, 1]) - - def _update_y_ruler(self, canvas_w: float, canvas_h: float, max_val: float) -> None: - """Update y-axis ruler positions and ticks for the current canvas size.""" - x0 = self.margin_left / canvas_w - y0 = self.margin_bottom / canvas_h - y1 = (canvas_h - self.margin_top) / canvas_h - self._y.start_pos = [x0, y0, 0] - self._y.end_pos = [x0, y1, 0] - # No tick labels on the ruler itself - self._y.ticks = {} - # Show max count as a label above the y-axis - if max_val > 0: - count = self._log_base**max_val - 1 if self._log_base else max_val - self._y_max_label.set_text(f"{count:.0f}") - self._y_max_label.local.position = (x0 - 0.01, y1 + 0.005, 0) - else: - self._y_max_label.set_text("") - - def _animate(self) -> None: - # Dynamically rescale the graph when canvas size changes - rect = self._canvas.get_logical_size() - if rect != self._size: - # Update plot viewport - self._plot_view.rect = ( - self.margin_left, - self.margin_top, - max(0, rect[0] - self.margin_left - self.margin_right), - max(0, rect[1] - self.margin_top - self.margin_bottom), - ) - self._size = rect - max_val = ( - float(self._values.max()) - if self._values is not None - else self._clim_handles.local.scale_y - ) - self._update_y_ruler(max(rect[0], 1), max(rect[1], 1), max_val) - - self._x.update(self._x_cam, self._canvas.get_logical_size()) - self._y.update(self._y_cam, self._canvas.get_logical_size()) - - # Render the plot - self._plot_view.render(self._scene, self._camera, flush=False) - # Render the x-axis - self._renderer.render( - self._x_scene, - self._x_cam, - rect=( - self.margin_left, # x - self.margin_top + self._plot_view.rect[3] - self.margin_bottom, # y - self._plot_view.rect[2], # w - 2 * self.margin_bottom, # h - ), - flush=False, - ) - # Render the y-axis - self._renderer.render(self._y_scene, self._y_cam, flush=False) - # Flush all three onto the canvas - self._renderer.flush() - - def set_visible(self, visible: bool) -> None: ... - - # ------------- LUTView Protocol methods ------------- # - - def set_channel_name(self, name: str) -> None: - # Nothing to do - # TODO: maybe show text somewhere - pass - - def set_channel_visible(self, visible: bool) -> None: - self._clim_handles.visible = visible - self._gamma_handle.visible = visible - self.refresh() - - def set_colormap(self, lut: cmap.Colormap) -> None: - self._histogram.material.color = lut.color_stops[-1].color.hex - self.refresh() - - def set_gamma(self, gamma: float) -> None: - if gamma < 0: - raise ValueError("gamma must be non-negative!") - self._gamma = gamma - self._update_clims() - self.refresh() - - def set_clims(self, clims: tuple[float, float]) -> None: - self._clims = clims - # Move clims line via translate/scale - # NB relies on position data lying within [0, 1] - # Translate by minimum - _, off_y, off_z = self._clim_handles.local.position - self._clim_handles.local.position = clims[0], off_y, off_z - self._gamma_handle.local.position = clims[0], off_y, off_z - # Scale by (maximum - minimum) - diff = clims[1] - clims[0] - diff = diff if abs(diff) > 1e-6 else 1e-6 - self._clim_handles.local.scale_x = diff - self._gamma_handle.local.scale_x = diff - - # Redraw - self.refresh() - - def set_clim_policy(self, policy: ClimPolicy) -> None: - if isinstance(policy, ClimsManual): - self.set_clims((policy.min, policy.max)) - # Nothing to do (yet) - pass - - # ------------- HistogramView Protocol methods ------------- # - - def set_data(self, values: np.ndarray, bin_edges: np.ndarray) -> None: - """Set the histogram values and bin edges. - - These inputs follow the same format as the return value of numpy.histogram. - """ - # Update the histogram mesh - self._values, self._bin_edges = values, bin_edges - self._update_histogram() - # Resize, preserving x-range but autoscaling y to new data - camera_x = self._camera.local.x - rad_x = self._camera.width / 2 - y_max = float(np.max(values)) if len(values) > 0 else 1.0 - self._resize(x=(camera_x - rad_x, camera_x + rad_x), y=(0, y_max)) - - def _update_histogram(self) -> None: - """Set the histogram values and bin edges. - - These inputs follow the same format as the return value of numpy.histogram. - """ - if self._values is None or self._bin_edges is None: - return # pragma: no cover - values = self._values - if self._log_base: - # use a count+1 histogram to gracefully handle 0, 1 - values = np.log(values + 1) / np.log(self._log_base) - - verts, faces = _hist_counts_to_mesh(values, self._bin_edges, self._vertical) - - # Number of bins unchanged - reuse existing geometry for performance - if ( - verts.shape == self._histogram.geometry.positions.data.shape - and faces.shape == self._histogram.geometry.indices.data.shape - ): - self._histogram.geometry.positions.data[:, :] = verts - self._histogram.geometry.positions.update_range() - - self._histogram.geometry.indices.data[:, :] = faces - self._histogram.geometry.indices.update_range() - # Number of bins changed - must create new geometry - else: - self._histogram.geometry = pygfx.Geometry(positions=verts, indices=faces) - - self._clim_handles.local.scale_y = values.max() / 0.98 - self._gamma_handle.local.scale_y = values.max() / 0.98 - self._highlight.local.scale_y = values.max() / 0.98 - - self.refresh() - - def set_clim_bounds( - self, - bounds: tuple[float | None, float | None] = (None, None), - ) -> None: - self._x_cam.xbounds = bounds - self._camera.xbounds = bounds - # FIXME what to do if None? - self._x.start_pos = [0 if bounds[0] is None else int(bounds[0]), 0, 0] - self._x.end_pos = [65535 if bounds[1] is None else int(bounds[1]), 0, 0] - - def set_range( - self, - x: tuple[float, float] | None = None, - y: tuple[float, float] | None = None, - z: tuple[float, float] | None = None, - margin: float = 0, - ) -> None: - """Update the range of the PanZoomCamera. - - When called with no arguments, the range is set to the full extent of the data. - """ - if not self._scene.children or self._camera is None: - return - self._domain = x - self._range = y - if margin != 0: - raise NotImplementedError("Nonzero margins not currently implemented") - - self._resize() - self.refresh() - return - - def set_vertical(self, vertical: bool) -> None: - # TODO: - raise NotImplementedError() - - def set_log_base(self, base: float | None) -> None: - if base != self._log_base: - self._log_base = base - self._update_histogram() - - # Resize along the y dimension only - r = self._camera.width / 2 - x = self._camera.local.position[0] - self._resize(x=(x - r, x + r)) - - def frontend_widget(self) -> Any: - return self._canvas - - def elements_at(self, pos_xy: tuple[float, float]) -> list: - raise NotImplementedError() - - def highlight(self, value: float | None) -> None: - self._highlight.visible = value is not None - self._highlight.local.x = value - self.refresh() - - return super().highlight(value) - - # ------------- Private methods ------------- # - - def _update_clims(self, npoints: int = 256) -> None: - clims = [0, 1] - - # 2 additional points for each of the two vertical clims lines - X = np.empty(npoints + 4) - Y = np.empty(npoints + 4) - Z = np.zeros(npoints + 4) - if self._vertical: - # clims lines - X[0:2], Y[0:2] = (1, 0.5), clims[0] - X[-2:], Y[-2:] = (0.5, 0), clims[1] - # gamma line - X[2:-2] = np.linspace(0, 1, npoints) ** self._gamma - Y[2:-2] = np.linspace(clims[0], clims[1], npoints) - np.array([(2**-self._gamma, np.mean(clims))]) - else: - # clims lines - X[0:2], Y[0:2] = clims[0], (1, 0.5) - X[-2:], Y[-2:] = clims[1], (0.5, 0) - # gamma line - X[2:-2] = np.linspace(clims[0], clims[1], npoints) - Y[2:-2] = np.linspace(0, 1, npoints) ** self._gamma - np.array([(np.mean(clims), 2**-self._gamma)]) - - self._gamma_handle.geometry.positions.data[0, 1] = 2**-self._gamma - self._gamma_handle.geometry.positions.update_range() - - clim_positions = np.vstack((X, Y, Z)).astype(np.float32).transpose() - positions = self._clim_handles.geometry.positions - if clim_positions.shape == positions.data.shape: - positions.data[:, :] = clim_positions - positions.update_range() - else: - self._clim_handles.geometry = pygfx.Geometry( - positions=clim_positions, colors=self._generate_clim_colors(npoints) - ) - - def _generate_clim_colors(self, npoints: int) -> np.ndarray: - # Gamma curve intensity between 0.2 and 0.8 - color = ( - np.linspace(0.2, 0.8, npoints + 4, dtype=np.float32) - .repeat(4) - .reshape(-1, 4) - ) - # The entire line should be opaque - color[:, 3] = 1 - # Clims intensity between 0.4 and 0.7 - c1, c2 = [0.4] * 3, [0.7] * 3 - color[0:3, :3] = [c1, c2, c1] - color[-3:, :3] = [c1, c2, c1] - - return color - - def get_cursor(self, mme: MouseMoveEvent) -> CursorType: - pos = mme.x, mme.y - nearby = self._find_nearby_node(pos) - - if nearby in [Grabbable.LEFT_CLIM, Grabbable.RIGHT_CLIM]: - return CursorType.V_ARROW if self._vertical else CursorType.H_ARROW - elif nearby is Grabbable.GAMMA: - return CursorType.H_ARROW if self._vertical else CursorType.V_ARROW - else: - x, y = pos - x_max, y_max = self._plot_view.logical_size - if (0 < x <= x_max) and (0 <= y <= y_max): - return CursorType.ALL_ARROW - else: - return CursorType.DEFAULT - - def on_mouse_press(self, event: MousePressEvent) -> bool: - pos = event.x, event.y - # pos = event.x - self.margin_left, event.y - self.margin_top - # check whether the user grabbed a node - self._grabbed = self._find_nearby_node(pos) - if self._grabbed != Grabbable.NONE: - # disconnect pan/zoom events until handle is dropped - self._controller.enabled = False - return False - - def on_mouse_double_press(self, event: MousePressEvent) -> bool: - pos = event.x, event.y - # check whether the user grabbed a node - self._grabbed = self._find_nearby_node(pos) - if self._grabbed == Grabbable.GAMMA: - if self.model: - self.model.gamma = 1 - return False - - def on_mouse_release(self, event: MouseReleaseEvent) -> bool: - self._grabbed = Grabbable.NONE - self._controller.enabled = True - return False - - def on_mouse_move(self, event: MouseMoveEvent) -> bool: - """Called whenever mouse moves over canvas.""" - pos = event.x, event.y - # pos = event.x - self.margin_left, event.y - self.margin_top - if self._clims is None: - return False # pragma: no cover - - if self._grabbed in [Grabbable.LEFT_CLIM, Grabbable.RIGHT_CLIM]: - c = self.canvas_to_world(pos)[1 if self._vertical else 0] - if self._grabbed is Grabbable.LEFT_CLIM: - # The left clim must stay to the left of the right clim - new_left = min(c, self._clims[1]) - # ...and no less than the minimum value - if self._bin_edges is not None: - new_left = max(new_left, self._bin_edges[0]) - newlims = (new_left, self._clims[1]) - elif self._grabbed is Grabbable.RIGHT_CLIM: - # The right clim must stay to the right of the left clim - new_right = max(self._clims[0], c) - # ...and no more than the minimum value - if self._bin_edges is not None: - new_right = min(new_right, self._bin_edges[-1]) - newlims = (self._clims[0], new_right) - else: - newlims = (self._clims[0], self._clims[1]) - if self.model: - self.model.clims = ClimsManual(min=newlims[0], max=newlims[1]) - return False - - if self._grabbed is Grabbable.GAMMA: - y0 = 0 - rect = self._plot_view.logical_size - y1 = ( - rect[0] - self.margin_right - if self._vertical - else rect[1] - self.margin_top - ) - y = self.canvas_to_world(pos)[0 if self._vertical else 1] - if y < np.maximum(y0, 0) or y > y1: - return False - if self.model: - gamma = -np.log2(y / self._gamma_handle.local.scale_y) - self.model.gamma = max(MIN_GAMMA, gamma) - return False - - self.get_cursor(event).apply_to(self) - return False - - def _find_nearby_node( - self, pos: tuple[float, float], tolerance: int = 5 - ) -> Grabbable: - """Describes whether the event is near a clim.""" - click_x, click_y = pos - - # NB Computations are performed in canvas-space - # for easier tolerance computation. - plot_to_canvas = self.world_to_canvas - # gamma_to_plot = self._handle_transform.map - - if self._clims is not None: - if self._vertical: - click = click_y - right = plot_to_canvas((0, self._clims[1], 0))[1] - left = plot_to_canvas((0, self._clims[0], 0))[1] - else: - click = click_x - right = plot_to_canvas((self._clims[1], 0, 0))[0] - left = plot_to_canvas((self._clims[0], 0, 0))[0] - - # Right bound always selected on overlap - if bool(abs(right - click) < tolerance): - return Grabbable.RIGHT_CLIM - if bool(abs(left - click) < tolerance): - return Grabbable.LEFT_CLIM - - gamma_pos = self._gamma_handle.geometry.positions.data[ - 0, 0 if self._vertical else 1 - ] - if self._vertical: - gx = gamma_pos * self._gamma_handle.local.scale_x - gy = (self._clims[0] + self._clims[1]) / 2 - else: - gx = (self._clims[0] + self._clims[1]) / 2 - gy = gamma_pos * self._gamma_handle.local.scale_y - gx, gy, *_ = plot_to_canvas((gx, gy, 0)) - if bool(abs(gx - click_x) < tolerance and abs(gy - click_y) < tolerance): - return Grabbable.GAMMA - - return Grabbable.NONE - - def world_to_canvas( - self, pos_xyz: tuple[float, float, float] - ) -> tuple[float, float]: - """Map XYZ coordinate in world space to XY canvas position (pixels).""" - # Code adapted from: - # https://github.com/pygfx/pygfx/pull/753/files#diff-173d643434d575e67f8c0a5bf2d7ea9791e6e03a4e7a64aa5fa2cf4172af05cdR420 - screen_space = pygfx.utils.transform.AffineTransform() - screen_space.position = (-1, 1, 0) - x_d, y_d = self._plot_view.logical_size - screen_space.scale = (2 / x_d, -2 / y_d, 1) - ndc_to_screen = screen_space.inverse_matrix - canvas_pos = la.vec_transform( - pos_xyz, ndc_to_screen @ self._camera.camera_matrix - ) - return ( - canvas_pos[0] + self._plot_view.rect[0], - canvas_pos[1] + self._plot_view.rect[1], - ) - - 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.""" - # Code adapted from: - # https://github.com/pygfx/pygfx/pull/753/files#diff-173d643434d575e67f8c0a5bf2d7ea9791e6e03a4e7a64aa5fa2cf4172af05cdR395 - # Get position relative to viewport - pos_rel = ( - pos_xy[0] - self._plot_view.rect[0], - pos_xy[1] - self._plot_view.rect[1], - ) - - vs = self._plot_view.logical_size - - # Convert position to NDC - x = pos_rel[0] / vs[0] * 2 - 1 - y = -(pos_rel[1] / vs[1] * 2 - 1) - pos_ndc = (x, y, 0) - - if self._camera: - pos_ndc += la.vec_transform( - self._camera.world.position, self._camera.camera_matrix - ) - pos_world = la.vec_unproject(pos_ndc[:2], self._camera.camera_matrix) - - return (pos_world[0], pos_world[1], pos_world[2]) - else: - return (-1, -1, -1) - - -def _hist_counts_to_mesh( - values: Sequence[float] | npt.NDArray, - bin_edges: Sequence[float] | npt.NDArray, - vertical: bool = False, -) -> tuple[npt.NDArray[np.float32], npt.NDArray[np.uint32]]: - """Convert histogram counts to mesh vertices and faces for plotting.""" - n_edges = len(bin_edges) - X, Y = (1, 0) if vertical else (0, 1) - - # 4-5 - # | | - # 1-2/7-8 - # |/| | | - # 0-3-6-9 - # construct vertices - vertices = np.zeros((3 * n_edges - 2, 3), np.float32) - vertices[:, X] = np.repeat(bin_edges, 3)[1:-1] - vertices[1::3, Y] = values - vertices[2::3, Y] = values - vertices[vertices == float("-inf")] = 0 - - # construct triangles - faces = np.zeros((2 * n_edges - 2, 3), np.uint32) - offsets = 3 * np.arange(n_edges - 1, dtype=np.uint32)[:, np.newaxis] - faces[::2] = np.array([0, 2, 1]) + offsets - faces[1::2] = np.array([2, 0, 3]) + offsets - return vertices, faces diff --git a/src/ndv/views/_pygfx/_shared_histogram.py b/src/ndv/views/_pygfx/_shared_histogram.py deleted file mode 100644 index 7b7b1bc1..00000000 --- a/src/ndv/views/_pygfx/_shared_histogram.py +++ /dev/null @@ -1,765 +0,0 @@ -from __future__ import annotations - -from contextlib import suppress -from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Any - -import numpy as np -import pygfx -import pylinalg as la - -from ndv._types import CursorType -from ndv.views._app import filter_mouse_events -from ndv.views.bases import SharedHistogramCanvas -from ndv.views.bases._graphics._histogram_utils import ( - _NO_KEY, - LUT_LINE_ALPHA, - Grabbable, - apply_log_counts, - area_to_mesh, - clamp_clim_drag, - compute_x_range, - compute_y_range, - downsample_histogram, - find_nearest_grabbable, - gamma_from_mouse_y, - gamma_handle_pos, - y_top_from_range, -) - -from ._histogram import _Controller, _OrthographicCamera -from ._util import rendercanvas_class - -if TYPE_CHECKING: - from ndv._types import ( - ChannelKey, - MouseMoveEvent, - MousePressEvent, - MouseReleaseEvent, - ) - - -# PyGFX's blending produces brighter fills than Vispy at the same alpha; -# use a lower value here so both backends look visually similar. -FILL_ALPHA = 0.08 - - -@dataclass -class _ChannelVisuals: - """All visuals for a single channel on the shared histogram.""" - - area_mesh: pygfx.Mesh - outline: pygfx.Line - left_clim: pygfx.Line - right_clim: pygfx.Line - gamma_line: pygfx.Line - gamma_handle: pygfx.Points - # per-channel state - color: tuple = (1, 1, 1, 1) - clims: tuple[float, float] | None = None - gamma: float = 1.0 - counts: np.ndarray | None = None - bin_edges: np.ndarray | None = None - visible: bool = True - name: str = "" - _display_centers: np.ndarray | None = field(default=None, repr=False) - _display_counts: np.ndarray | None = field(default=None, repr=False) - - -class PyGFXSharedHistogramCanvas(SharedHistogramCanvas): - """Shared multi-channel histogram using PyGFX.""" - - def __init__(self) -> None: - self._channels: dict[object, _ChannelVisuals] = {} - self._log_base: float | None = None - self._grabbed: Grabbable = Grabbable.NONE - self._grabbed_key: object = _NO_KEY - self._clim_bounds: tuple[float | None, float | None] = (None, None) - self._has_initial_range = False - self._last_cam_state: tuple[float, float] = (0.0, 0.0) # (x, width) - - # Margins (pixels) - self.margin_left = 10 - self.margin_bottom = 20 - self.margin_right = 4 - self.margin_top = 14 # room for legend text - - # ------------ PyGFX Canvas ------------ # - cls = rendercanvas_class() - self._size = (600, 600) - self._canvas = cls(size=self._size) - self._disconnect_mouse_events = filter_mouse_events(self._canvas, self) - self._renderer = pygfx.renderers.WgpuRenderer(self._canvas) - - # Scene 0: full-canvas black background (rendered first) - self._bg_scene = pygfx.Scene() - self._bg_scene.add(pygfx.Background(None, pygfx.BackgroundMaterial("black"))) - self._bg_cam = pygfx.OrthographicCamera() - - # Scene 1: main plot (data, clim handles, highlight) — no background - self._scene = pygfx.Scene() - self._plot_view = pygfx.Viewport(self._renderer) - self._controller = _Controller(register_events=self._plot_view) - self._controller.controls.update({"wheel": ("zoom_to_point", "push", -0.005)}) - self._camera = _OrthographicCamera(maintain_aspect=False) - self._controller.add_camera(self._camera, include_state={"x", "width"}) - - # Scene 2: x-axis (synced camera for pan/zoom with main) - # No background — layers on top of the main scene's background - self._x_scene = pygfx.Scene() - self._x_cam = _OrthographicCamera(maintain_aspect=False, width=1, height=1) - self._controller.add_camera(self._x_cam, include_state={"x", "width"}) - - # Scene 3: static overlays (y-label, legend) rendered full-canvas - # No background — this renders on top of the plot and x-axis - self._y_scene = pygfx.Scene() - self._y_cam = pygfx.OrthographicCamera(maintain_aspect=False, width=1, height=1) - self._y_cam.local.position = [0.5, 0.5, 0] - - # ------------ Nodes ------------ # - - # Per-channel highlight lines (created on demand) - self._highlight_lines: dict[object, pygfx.Line] = {} - self._highlight_unit_pos = np.array([[0, 0, 0], [0, 1, 0]], dtype=np.float32) - - # X-axis ruler - self._x = pygfx.Ruler( - start_pos=(0, 0, 0), - end_pos=(1, 0, 0), - start_value=0, - tick_format=lambda v, *_: f"{v:g}", - tick_side="right", - tick_size=4, - line_width=1, - ) - self._x.text.font_size = 10 - self._x.text.material.weight_offset = -300 - self._x_scene.add(self._x) - - # Y-axis: just a max label (no ruler to save space) - self._y_max_label = pygfx.MultiText( - text="", - material=pygfx.TextMaterial(color="white", aa=True, weight_offset=-300), - screen_space=True, - font_size=10, - anchor="bottom-left", - ) - self._y_scene.add(self._y_max_label) - - # Legend labels (also in y_scene for screen-space rendering) - self._legend_labels: list[pygfx.MultiText] = [] - - self.refresh() - - # ------------ GraphicsCanvas methods ------------ # - - def refresh(self) -> None: - with suppress(AttributeError): - self._canvas.update() - self._canvas.request_draw(self._animate) - - def set_range( - self, - x: tuple[float, float] | None = None, - y: tuple[float, float] | None = None, - z: tuple[float, float] | None = None, - margin: float = 0, - ) -> None: - if x is None: - x = self._compute_x_range() - if y is None: - y = self._compute_y_range() - if x and y: - self._resize(x, y) - - def canvas_to_world( - self, pos_xy: tuple[float, float] - ) -> tuple[float, float, float]: - pos_rel = ( - pos_xy[0] - self._plot_view.rect[0], - pos_xy[1] - self._plot_view.rect[1], - ) - vs = self._plot_view.logical_size - x = pos_rel[0] / vs[0] * 2 - 1 - y = -(pos_rel[1] / vs[1] * 2 - 1) - pos_ndc = (x, y, 0) - if self._camera: - pos_ndc += la.vec_transform( - self._camera.world.position, self._camera.camera_matrix - ) - pos_world = la.vec_unproject(pos_ndc[:2], self._camera.camera_matrix) - return (pos_world[0], pos_world[1], pos_world[2]) - return (-1, -1, -1) - - def world_to_canvas( - self, pos_xyz: tuple[float, float, float] - ) -> tuple[float, float]: - screen_space = pygfx.utils.transform.AffineTransform() - screen_space.position = (-1, 1, 0) - x_d, y_d = self._plot_view.logical_size - screen_space.scale = (2 / x_d, -2 / y_d, 1) - ndc_to_screen = screen_space.inverse_matrix - canvas_pos = la.vec_transform( - pos_xyz, ndc_to_screen @ self._camera.camera_matrix - ) - return ( - canvas_pos[0] + self._plot_view.rect[0], - canvas_pos[1] + self._plot_view.rect[1], - ) - - def elements_at(self, pos_xy: tuple[float, float]) -> list: - raise NotImplementedError - - def set_visible(self, visible: bool) -> None: ... - - def close(self) -> None: - self._disconnect_mouse_events() - self._canvas.close() - - def frontend_widget(self) -> Any: - return self._canvas - - # ------------ SharedHistogramCanvas methods ------------ # - - def set_channel_data( - self, key: ChannelKey, counts: np.ndarray, bin_edges: np.ndarray - ) -> None: - ch = self._ensure_channel(key) - ch.counts = counts - ch.bin_edges = bin_edges - self._update_channel_area(key) - if not self._has_initial_range: - self._has_initial_range = True - self._auto_range() - else: - self._auto_range_y_only() - - def set_channel_color(self, key: ChannelKey, color: tuple) -> None: - ch = self._ensure_channel(key) - ch.color = color - self._apply_channel_colors(key) - - def set_channel_visible(self, key: ChannelKey, visible: bool) -> None: - ch = self._channels.get(key) - if ch is None: - return - ch.visible = visible - for obj in ( - ch.area_mesh, - ch.outline, - ch.left_clim, - ch.right_clim, - ch.gamma_line, - ch.gamma_handle, - ): - obj.visible = visible - self._update_legend() - self._auto_range_y_only() - - def set_channel_clims(self, key: ChannelKey, clims: tuple[float, float]) -> None: - ch = self._channels.get(key) - if ch is None: - return - ch.clims = clims - self._update_lut_visuals(key) - - def set_channel_gamma(self, key: ChannelKey, gamma: float) -> None: - ch = self._channels.get(key) - if ch is None: - return - ch.gamma = gamma - self._update_lut_visuals(key) - - def remove_channel(self, key: ChannelKey) -> None: - ch = self._channels.pop(key, None) - if ch is None: - return - for obj in ( - ch.area_mesh, - ch.outline, - ch.left_clim, - ch.right_clim, - ch.gamma_line, - ch.gamma_handle, - ): - self._scene.remove(obj) - if (hl := self._highlight_lines.pop(key, None)) is not None: - self._scene.remove(hl) - self._update_legend() - self._auto_range() - - def set_channel_name(self, key: ChannelKey, name: str) -> None: - ch = self._channels.get(key) - if ch is None: - return - ch.name = name - self._update_legend() - - def set_clim_bounds(self, bounds: tuple[float | None, float | None]) -> None: - self._clim_bounds = bounds - self._camera.xbounds = bounds - self._x_cam.xbounds = bounds - # Update x-axis ruler domain - self._x.start_pos = [0 if bounds[0] is None else int(bounds[0]), 0, 0] - self._x.end_pos = [65535 if bounds[1] is None else int(bounds[1]), 0, 0] - - def set_log_base(self, base: float | None) -> None: - if base == self._log_base: - return - self._log_base = base - for key in self._channels: - self._update_channel_area(key) - self._auto_range_y_only() - - def highlight(self, channel_values: dict[object, float]) -> None: - y_range = self._compute_y_range() - y_scale = y_range[1] * 0.5 if y_range else 1.0 - active_keys = set() - for key, value in channel_values.items(): - active_keys.add(key) - line = self._highlight_lines.get(key) - if line is None: - ch = self._channels.get(key) - color = (*ch.color[:3], 0.5) if ch else (1.0, 1.0, 0.2, 0.5) - line = pygfx.Line( - geometry=pygfx.Geometry(positions=self._highlight_unit_pos), - material=pygfx.LineMaterial( - color=color, dash_pattern=[4, 4], thickness=1 - ), - ) - self._scene.add(line) - self._highlight_lines[key] = line - line.visible = True - line.local.x = value - line.local.scale_y = y_scale - for key, line in self._highlight_lines.items(): - if key not in active_keys: - line.visible = False - self.refresh() - - # ------------ Mouse interaction ------------ # - - def get_cursor(self, event: MouseMoveEvent) -> CursorType: - pos = (event.x, event.y) - _key, nearby = self._find_nearest_grabbable(pos) - if nearby in (Grabbable.LEFT_CLIM, Grabbable.RIGHT_CLIM): - return CursorType.H_ARROW - elif nearby is Grabbable.GAMMA: - return CursorType.V_ARROW - else: - x, y = pos - x_max, y_max = self._plot_view.logical_size - if (0 < x <= x_max) and (0 <= y <= y_max): - return CursorType.ALL_ARROW - return CursorType.DEFAULT - - def on_mouse_press(self, event: MousePressEvent) -> bool: - pos = event.x, event.y - self._grabbed_key, self._grabbed = self._find_nearest_grabbable(pos) - if self._grabbed != Grabbable.NONE: - self._controller.enabled = False - return False - - def on_mouse_double_press(self, event: MousePressEvent) -> bool: - pos = event.x, event.y - key, nearby = self._find_nearest_grabbable(pos) - if nearby == Grabbable.GAMMA and key is not _NO_KEY: - self.gammaChanged.emit(key, 1.0) - return False - - def on_mouse_release(self, event: MouseReleaseEvent) -> bool: - self._grabbed = Grabbable.NONE - self._grabbed_key = _NO_KEY - self._controller.enabled = True - return False - - def on_mouse_move(self, event: MouseMoveEvent) -> bool: - pos = event.x, event.y - key = self._grabbed_key - if key is _NO_KEY or self._grabbed == Grabbable.NONE: - self.get_cursor(event).apply_to(self) - return False - - ch = self._channels.get(key) - if ch is None or ch.clims is None: - return False - - if self._grabbed in (Grabbable.LEFT_CLIM, Grabbable.RIGHT_CLIM): - c = self.canvas_to_world(pos)[0] - new_clims = clamp_clim_drag(self._grabbed, c, ch.clims, self._clim_bounds) - self.climsChanged.emit(key, new_clims) - return False - - if self._grabbed is Grabbable.GAMMA: - y = self.canvas_to_world(pos)[1] - gamma = gamma_from_mouse_y(y, self._compute_y_range()) - if gamma is None: - return False - self.gammaChanged.emit(key, gamma) - return False - - self.get_cursor(event).apply_to(self) - return False - - # ------------ Private helpers ------------ # - - def _ensure_channel(self, key: object) -> _ChannelVisuals: - if key in self._channels: - return self._channels[key] - - area_mesh = pygfx.Mesh( - geometry=pygfx.Geometry( - positions=np.zeros((1, 3), dtype=np.float32), - indices=np.zeros((1, 3), dtype=np.uint32), - ), - material=pygfx.MeshBasicMaterial( - color=(0.5, 0.5, 0.5, FILL_ALPHA), - color_mode="uniform", - depth_test=False, - alpha_mode="blend", - ), - ) - - outline = pygfx.Line( - geometry=pygfx.Geometry( - positions=np.array([[0, 0, 0]], dtype=np.float32), - ), - material=pygfx.LineMaterial(color="white", thickness=1), - ) - - left_clim = pygfx.Line( - geometry=pygfx.Geometry( - positions=np.array([[0, 0, 0], [0, 1, 0]], dtype=np.float32), - ), - material=pygfx.LineMaterial(color=(1, 1, 1, LUT_LINE_ALPHA), thickness=1), - visible=False, - render_order=-9, - ) - - right_clim = pygfx.Line( - geometry=pygfx.Geometry( - positions=np.array([[0, 0, 0], [0, 1, 0]], dtype=np.float32), - ), - material=pygfx.LineMaterial(color=(1, 1, 1, LUT_LINE_ALPHA), thickness=1), - visible=False, - render_order=-9, - ) - - gamma_line = pygfx.Line( - geometry=pygfx.Geometry( - positions=np.array([[0, 0, 0]], dtype=np.float32), - ), - material=pygfx.LineMaterial(color=(1, 1, 1, LUT_LINE_ALPHA), thickness=1), - visible=False, - render_order=-9, - ) - - gamma_handle = pygfx.Points( - geometry=pygfx.Geometry( - positions=np.array([[0, 0, 0]], dtype=np.float32), - ), - material=pygfx.PointsMaterial( - size=6, color=(1, 1, 1), color_mode="uniform" - ), - visible=False, - render_order=-10, - ) - - self._scene.add(area_mesh) - self._scene.add(outline) - self._scene.add(left_clim) - self._scene.add(right_clim) - self._scene.add(gamma_line) - self._scene.add(gamma_handle) - - ch = _ChannelVisuals( - area_mesh=area_mesh, - outline=outline, - left_clim=left_clim, - right_clim=right_clim, - gamma_line=gamma_line, - gamma_handle=gamma_handle, - ) - self._channels[key] = ch - self._update_legend() - return ch - - def _apply_channel_colors(self, key: object) -> None: - ch = self._channels[key] - r, g, b = ch.color[:3] - a = ch.color[3] if len(ch.color) > 3 else 1.0 - - ch.area_mesh.material.color = (r, g, b, FILL_ALPHA) - ch.outline.material.color = (r, g, b, a) - ch.left_clim.material.color = (r, g, b, LUT_LINE_ALPHA) - ch.right_clim.material.color = (r, g, b, LUT_LINE_ALPHA) - ch.gamma_line.material.color = (r, g, b, LUT_LINE_ALPHA) - ch.gamma_handle.material.color = (r, g, b, a) - - self._update_channel_area(key) - self._update_lut_visuals(key) - - def _update_channel_area(self, key: object) -> None: - ch = self._channels.get(key) - if ch is None or ch.counts is None or ch.bin_edges is None: - return - - canvas_w = max(int(self._canvas.get_logical_size()[0]), 64) - visible = self._visible_x_range() - centers, display_counts = downsample_histogram( - ch.counts, - ch.bin_edges, - max_display_bins=canvas_w, - visible_range=visible, - ) - ch._display_centers = centers - ch._display_counts = display_counts - counts = apply_log_counts(display_counts, self._log_base) - - verts, faces = area_to_mesh(centers, counts) - if len(centers) < 2: - return - if ( - verts.shape == ch.area_mesh.geometry.positions.data.shape - and faces.shape == ch.area_mesh.geometry.indices.data.shape - ): - ch.area_mesh.geometry.positions.data[:] = verts - ch.area_mesh.geometry.positions.update_range() - ch.area_mesh.geometry.indices.data[:] = faces - ch.area_mesh.geometry.indices.update_range() - else: - ch.area_mesh.geometry = pygfx.Geometry(positions=verts, indices=faces) - - outline_pos = np.zeros((len(centers), 3), dtype=np.float32) - outline_pos[:, 0] = centers - outline_pos[:, 1] = counts - self._update_line_positions(ch.outline, outline_pos) - - self.refresh() - - def _update_lut_visuals(self, key: object, npoints: int = 64) -> None: - ch = self._channels.get(key) - if ch is None or ch.clims is None: - return - - clims = ch.clims - gamma = ch.gamma - y_top = y_top_from_range(self._compute_y_range()) - - # Left clim line (full height) - left_pos = np.array([[clims[0], 0, 0], [clims[0], y_top, 0]], dtype=np.float32) - self._update_line_positions(ch.left_clim, left_pos) - ch.left_clim.visible = ch.visible - - # Right clim line (full height) - right_pos = np.array([[clims[1], 0, 0], [clims[1], y_top, 0]], dtype=np.float32) - self._update_line_positions(ch.right_clim, right_pos) - ch.right_clim.visible = ch.visible - - # Gamma curve - t = np.linspace(0, 1, npoints) - gx = np.linspace(clims[0], clims[1], npoints) - gy = t**gamma * y_top - gamma_pos = np.zeros((npoints, 3), dtype=np.float32) - gamma_pos[:, 0] = gx - gamma_pos[:, 1] = gy - self._update_line_positions(ch.gamma_line, gamma_pos) - ch.gamma_line.visible = ch.visible - - # Gamma handle - mid_x, mid_y = gamma_handle_pos(clims, gamma, y_top) - handle_pos = np.array([[mid_x, mid_y, 0]], dtype=np.float32) - ch.gamma_handle.geometry.positions.data[:] = handle_pos - ch.gamma_handle.geometry.positions.update_range() - ch.gamma_handle.visible = ch.visible - - self.refresh() - - def _update_line_positions(self, line: pygfx.Line, pos: np.ndarray) -> None: - if pos.shape == line.geometry.positions.data.shape: - line.geometry.positions.data[:] = pos - line.geometry.positions.update_range() - else: - line.geometry = pygfx.Geometry(positions=pos) - - def _compute_x_range(self) -> tuple[float, float] | None: - return compute_x_range(self._channels) - - def _compute_y_range(self) -> tuple[float, float] | None: - return compute_y_range(self._channels, self._log_base) - - def _auto_range(self) -> None: - x = self._compute_x_range() - y = self._compute_y_range() - if x and y: - self._resize(x, y) - self._refresh_all_lut_visuals() - self._update_legend() - - def _auto_range_y_only(self) -> None: - """Update y range only, preserving current x pan/zoom.""" - y = self._compute_y_range() - if y: - cx = self._camera.local.x - cw = self._camera.width - self._camera.height = y[1] - y[0] - self._camera.local.position = [cx, (y[0] + y[1]) / 2, 0] - self._camera.width = cw - c_w = max(self._canvas.get_logical_size()[0], 1) - c_h = max(self._canvas.get_logical_size()[1], 1) - self._update_y_ruler(c_w, c_h, y[1]) - self._refresh_all_lut_visuals() - self._update_legend() - self.refresh() - - def _visible_x_range(self) -> tuple[float, float] | None: - """Get the currently visible x-range from the camera.""" - if not self._has_initial_range: - return None - cx = self._camera.local.x - hw = self._camera.width / 2 - return (cx - hw, cx + hw) - - def _redownsample_all(self) -> None: - """Re-downsample all channels for the current visible range.""" - for key in self._channels: - self._update_channel_area(key) - # Refit y-axis to the visible data - y = self._compute_y_range() - if y: - cx = self._camera.local.x - cw = self._camera.width - self._camera.height = y[1] - y[0] - self._camera.local.position = [cx, (y[0] + y[1]) / 2, 0] - self._camera.width = cw - c_w = max(self._canvas.get_logical_size()[0], 1) - c_h = max(self._canvas.get_logical_size()[1], 1) - self._update_y_ruler(c_w, c_h, y[1]) - self._refresh_all_lut_visuals() - - def _refresh_all_lut_visuals(self) -> None: - for key in self._channels: - self._update_lut_visuals(key) - - def _resize(self, x: tuple[float, float], y: tuple[float, float]) -> None: - self._x_cam.width = self._camera.width = x[1] - x[0] - self._camera.height = y[1] - y[0] - - self._x_cam.local.position = [(x[0] + x[1]) / 2, 0, 0] - self._camera.local.position = [ - (x[0] + x[1]) / 2, - (y[0] + y[1]) / 2, - 0, - ] - - c_w = max(self._canvas.get_logical_size()[0], 1) - c_h = max(self._canvas.get_logical_size()[1], 1) - self._update_y_ruler(c_w, c_h, y[1]) - self.refresh() - - def _update_y_ruler(self, canvas_w: float, canvas_h: float, max_val: float) -> None: - """Update the y-axis max label position and text.""" - x0 = self.margin_left / canvas_w - y1 = (canvas_h - self.margin_top) / canvas_h - if max_val > 0: - count = self._log_base**max_val - 1 if self._log_base else max_val - self._y_max_label.set_text(f"{count:.0f}") - self._y_max_label.local.position = (x0, y1 + 0.005, 0) - else: - self._y_max_label.set_text("") - - def _update_legend(self) -> None: - """Position legend entries horizontally at the top-right.""" - c_w = max(self._canvas.get_logical_size()[0], 1) - c_h = max(self._canvas.get_logical_size()[1], 1) - - # Collect visible channel entries - entries: list[tuple[str, tuple]] = [] - for ch in self._channels.values(): - if ch.visible and ch.name: - r, g, b = ch.color[:3] - a = ch.color[3] if len(ch.color) > 3 else 1.0 - entries.append((f"{ch.name}", (r, g, b, a))) - - # Ensure we have enough legend labels - while len(self._legend_labels) < len(entries): - label = pygfx.MultiText( - text="", - material=pygfx.TextMaterial(color="white", aa=True, weight_offset=-300), - screen_space=True, - font_size=10, - anchor="bottom-right", - ) - self._y_scene.add(label) - self._legend_labels.append(label) - - # Position entries right-to-left, inline with y-max label - x_frac = (c_w - 8) / c_w - # Same y as y_max_label (both use "bottom-*" anchor now) - y_frac = (c_h - self.margin_top) / c_h + 0.005 - for i, label in enumerate(self._legend_labels): - if i < len(entries): - text, color = entries[len(entries) - 1 - i] - label.set_text(text) - label.material.color = color - label.local.position = (x_frac, y_frac, 0) - label.visible = True - x_frac -= (len(text) * 6 + 6) / c_w - else: - label.visible = False - - def _animate(self) -> None: - rect = self._canvas.get_logical_size() - if rect != self._size: - self._plot_view.rect = ( - self.margin_left, - self.margin_top, - max(0, rect[0] - self.margin_left - self.margin_right), - max(0, rect[1] - self.margin_top - self.margin_bottom), - ) - self._size = rect - - y_range = self._compute_y_range() - max_val = y_range[1] if y_range else 0 - self._update_y_ruler(max(rect[0], 1), max(rect[1], 1), max_val) - self._update_legend() - - # Re-downsample when camera pans/zooms - cam_state = (self._camera.local.x, self._camera.width) - if cam_state != self._last_cam_state: - self._last_cam_state = cam_state - self._redownsample_all() - - self._x.update(self._x_cam, self._canvas.get_logical_size()) - - # Render background full-canvas first - self._renderer.render(self._bg_scene, self._bg_cam, flush=False) - # Render the plot on top - self._plot_view.render(self._scene, self._camera, flush=False) - # Render the x-axis - self._renderer.render( - self._x_scene, - self._x_cam, - rect=( - self.margin_left, - self.margin_top + self._plot_view.rect[3] - self.margin_bottom, - self._plot_view.rect[2], - 2 * self.margin_bottom, - ), - flush=False, - ) - # Render the y-axis / legend overlay - self._renderer.render(self._y_scene, self._y_cam, flush=False) - self._renderer.flush() - - def _find_nearest_grabbable( - self, pos: tuple[float, float], tolerance: int = 5 - ) -> tuple[object, Grabbable]: - w2c = self.world_to_canvas - return find_nearest_grabbable( - self._channels, - pos, - lambda x, y: w2c((x, y, 0))[:2], - self._compute_y_range(), - tolerance, - ) diff --git a/src/ndv/views/_pygfx/_util.py b/src/ndv/views/_pygfx/_util.py deleted file mode 100644 index 3a73801b..00000000 --- a/src/ndv/views/_pygfx/_util.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING, Any - -if TYPE_CHECKING: - from rendercanvas import BaseRenderCanvas - - -def rendercanvas_class() -> "type[BaseRenderCanvas]": - from ndv.views._app import GuiFrontend, gui_frontend - - frontend = gui_frontend() - if frontend == GuiFrontend.QT: - import rendercanvas.qt - from qtpy.QtCore import QSize - - class QRenderWidget(rendercanvas.qt.QRenderWidget): - def sizeHint(self) -> QSize: - return QSize(self.width(), self.height()) - - def keyPressEvent(self, event: Any) -> None: - super().keyPressEvent(event) - event.ignore() # pass event to parent for global shortcuts - - def keyReleaseEvent(self, event: Any) -> None: - super().keyReleaseEvent(event) - event.ignore() # pass event to parent for global shortcuts - - return QRenderWidget - - if frontend == GuiFrontend.JUPYTER: - import rendercanvas.jupyter - - class JupyterRenderCanvas(rendercanvas.jupyter.JupyterRenderCanvas): - def get_frame(self) -> Any: - # Workaround for async GPU readback in rendercanvas: - # _time_to_draw() calls _draw_and_present(force_sync=False), - # which may complete the present asynchronously, meaning - # _last_image still holds the previous frame when get_frame() - # returns. Force a synchronous present so the returned image - # is always up-to-date. - self._process_events() - self._draw_and_present(force_sync=True) - return self._last_image - - return JupyterRenderCanvas - if frontend == GuiFrontend.WX: - import rendercanvas.wx - import wx - - class WxRenderWidget(rendercanvas.wx.WxRenderWidget): - def __init__(self, *args: Any, **kwargs: Any) -> None: - # wx.Window requires a parent on macOS to avoid segfaults. - # Create a temporary hidden frame if no parent is provided, - # which will be destroyed when the widget is reparented. - if "parent" not in kwargs and (not args or args[0] is None): - kwargs["parent"] = parent = wx.Frame(None) - parent.Hide() - super().__init__(*args, **kwargs) - - def _rc_close(self) -> None: - # Guard against accessing self.Parent on a deleted C++ object - try: - super()._rc_close() - except RuntimeError: - self._is_closed = True - - return WxRenderWidget - - raise ValueError(f"Unsupported frontend: {frontend}") # pragma: no cover diff --git a/src/ndv/views/_qt/_app.py b/src/ndv/views/_qt/_app.py index 07d7d809..c0a00aa4 100644 --- a/src/ndv/views/_qt/_app.py +++ b/src/ndv/views/_qt/_app.py @@ -1,31 +1,19 @@ from __future__ import annotations import sys -from typing import TYPE_CHECKING, Any, ClassVar, cast - -from qtpy.QtCore import QEvent, QObject, Qt, QTimer -from qtpy.QtGui import QKeyEvent, QMouseEvent -from qtpy.QtWidgets import QApplication, QWidget - -from ndv._types import ( - CursorType, - KeyCode, - KeyMod, - KeyPressEvent, - MouseButton, - MouseMoveEvent, - MousePressEvent, - MouseReleaseEvent, -) +from typing import TYPE_CHECKING, Any, ClassVar + +from qtpy.QtCore import Qt, QTimer +from qtpy.QtWidgets import QApplication + from ndv.views.bases._app import NDVApp if TYPE_CHECKING: - from collections.abc import Callable, Container + from collections.abc import Callable from concurrent.futures import Future from ndv.views.bases import ArrayView from ndv.views.bases._app import P, T - from ndv.views.bases._graphics._mouseable import Mouseable class QtAppWrap(NDVApp): @@ -73,24 +61,6 @@ def array_view_class(self) -> type[ArrayView]: return QtArrayView - def filter_mouse_events( - self, canvas: Any, receiver: Mouseable - ) -> Callable[[], None]: - if not isinstance(canvas, QWidget): - raise TypeError(f"Expected canvas to be QWidget, got {type(canvas)}") - - f = MouseEventFilter(canvas, receiver) - canvas.installEventFilter(f) - return lambda: canvas.removeEventFilter(f) - - def filter_key_events(self, widget: Any, receiver: ArrayView) -> Callable[[], None]: - if not isinstance(widget, QWidget): - raise TypeError(f"Expected widget to be QWidget, got {type(widget)}") - - f = KeyEventFilter(receiver) - widget.installEventFilter(f) - return lambda: widget.removeEventFilter(f) - def process_events(self) -> None: """Process events for the application.""" QApplication.processEvents() @@ -98,128 +68,3 @@ def process_events(self) -> None: def call_later(self, msec: int, func: Callable[[], None]) -> None: """Call `func` after `msec` milliseconds.""" QTimer.singleShot(msec, Qt.TimerType.PreciseTimer, func) - - -class MouseEventFilter(QObject): - def __init__(self, canvas: QWidget, receiver: Mouseable): - super().__init__() - self.canvas = canvas - self.receiver = receiver - self.active_button = MouseButton.NONE - - def mouse_btn(self, btn: Any) -> MouseButton: - if btn == Qt.MouseButton.LeftButton: - return MouseButton.LEFT - if btn == Qt.MouseButton.MiddleButton: - return MouseButton.MIDDLE - if btn == Qt.MouseButton.RightButton: - return MouseButton.RIGHT - return MouseButton.NONE - - def set_cursor(self, type: CursorType) -> None: - self.canvas.setCursor(type.to_qt()) - - def eventFilter(self, obj: QObject | None, qevent: QEvent | None) -> bool: - """Event filter installed on the canvas to handle mouse events. - - here is where we get a chance to intercept mouse events before allowing - the canvas to respond to them. Return `True` to prevent the event from - being passed to the canvas. - """ - if qevent is None: - return False # pragma: no cover - - try: - # use children in case backend has a subwidget stealing events. - children: Container = self.canvas.children() - except RuntimeError: - # native is likely dead - return False - - intercept = False - receiver = self.receiver - if ( - qevent.type() == qevent.Type.ContextMenu - and type(obj).__name__ == "CanvasBackendDesktop" - ): - return False # pragma: no cover - if obj is self.canvas or obj in children: - if isinstance(qevent, QMouseEvent): - pos = qevent.pos() - etype = qevent.type() - btn = self.mouse_btn(qevent.button()) - if etype == QEvent.Type.MouseMove: - mme = MouseMoveEvent(x=pos.x(), y=pos.y(), btn=self.active_button) - intercept |= receiver.on_mouse_move(mme) - if cursor := receiver.get_cursor(mme): - self.set_cursor(cursor) - receiver.mouseMoved.emit(mme) - elif etype == QEvent.Type.MouseButtonDblClick: - self.active_button = btn - mpe = MousePressEvent(x=pos.x(), y=pos.y(), btn=self.active_button) - intercept |= receiver.on_mouse_double_press(mpe) - receiver.mouseDoublePressed.emit(mpe) - elif etype == QEvent.Type.MouseButtonPress: - self.active_button = btn - mpe = MousePressEvent(x=pos.x(), y=pos.y(), btn=self.active_button) - intercept |= receiver.on_mouse_press(mpe) - receiver.mousePressed.emit(mpe) - elif etype == QEvent.Type.MouseButtonRelease: - mre = MouseReleaseEvent( - x=pos.x(), y=pos.y(), btn=self.active_button - ) - self.active_button = MouseButton.NONE - intercept |= receiver.on_mouse_release(mre) - receiver.mouseReleased.emit(mre) - elif qevent.type() == QEvent.Type.Leave: - intercept |= receiver.on_mouse_leave() - receiver.mouseLeft.emit() - return intercept - - -_QT_KEY_MAP: dict[int, KeyCode] = { - Qt.Key.Key_Up: KeyCode.UP, - Qt.Key.Key_Down: KeyCode.DOWN, - Qt.Key.Key_Left: KeyCode.LEFT, - Qt.Key.Key_Right: KeyCode.RIGHT, - Qt.Key.Key_Space: KeyCode.SPACE, - Qt.Key.Key_Home: KeyCode.HOME, - Qt.Key.Key_End: KeyCode.END, -} - - -def _qt_mods_to_keymods(modifiers: Qt.KeyboardModifier) -> KeyMod: - mods = KeyMod.NONE - if modifiers & Qt.KeyboardModifier.ShiftModifier: - mods |= KeyMod.SHIFT - if modifiers & Qt.KeyboardModifier.ControlModifier: - mods |= KeyMod.CTRL - if modifiers & Qt.KeyboardModifier.AltModifier: - mods |= KeyMod.ALT - if modifiers & Qt.KeyboardModifier.MetaModifier: - mods |= KeyMod.META - return mods - - -class KeyEventFilter(QObject): - def __init__(self, receiver: ArrayView) -> None: - super().__init__() - self.receiver = receiver - - def eventFilter(self, obj: QObject | None, qevent: QEvent | None) -> bool: - if qevent is None or qevent.type() != QEvent.Type.KeyPress: - return False - - key_event = cast("QKeyEvent", qevent) - qt_key = key_event.key() - key: KeyCode | str - if qt_key in _QT_KEY_MAP: - key = _QT_KEY_MAP[qt_key] - else: - text = key_event.text() - if not text: - return False - key = text - mods = _qt_mods_to_keymods(key_event.modifiers()) - self.receiver.keyPressed.emit(KeyPressEvent(key, mods)) - return False diff --git a/src/ndv/views/_qt/_array_view.py b/src/ndv/views/_qt/_array_view.py index 1225e1d9..00d99939 100644 --- a/src/ndv/views/_qt/_array_view.py +++ b/src/ndv/views/_qt/_array_view.py @@ -51,11 +51,7 @@ from qtpy.QtGui import QIcon from ndv._types import AxisKey, ChannelKey - from ndv.views.bases._graphics._canvas import HistogramCanvas - from ndv.views.bases._graphics._canvas_elements import ( - CanvasElement, - RectangularROIHandle, - ) + from ndv.views._histogram import Histogram SLIDER_STYLE = """ QSlider::groove:horizontal { @@ -286,7 +282,7 @@ def __init__( super().__init__() self._qwidget = _QLUTWidget(default_luts) self._channel = channel - self.histogram: HistogramCanvas | None = None + self.histogram: Histogram | None = None # TODO: use emit_fast self._qwidget.histogram_btn.toggled.connect(self._on_q_histogram_toggled) self._qwidget.hist_log.toggled.connect(self._on_log_btn_toggled) @@ -407,7 +403,7 @@ def _on_set_histogram_range_clicked(self) -> None: if hist := self.histogram: hist.set_range() - def _add_histogram(self, histogram: HistogramCanvas) -> None: + def _add_histogram(self, histogram: Histogram) -> None: # Add widget to view self.histogram = histogram widget = cast("QWidget", histogram.frontend_widget()) @@ -710,8 +706,6 @@ def __init__(self, canvas_widget: QWidget, parent: QWidget | None = None): self.set_range_btn = QPushButton(set_range_icon, "", self) # button to draw ROIs - self._roi_handle: RectangularROIHandle | None = None - self._selection: CanvasElement | None = None self.add_roi_btn = ROIButton() self.luts = _UpCollapsible( @@ -867,7 +861,7 @@ def remove_lut_view(self, view: LUTView) -> None: def _on_channel_mode_changed(self, text: str) -> None: self.channelModeChanged.emit(ChannelMode(text)) - def add_histogram(self, channel: ChannelKey, widget: HistogramCanvas) -> None: + def add_histogram(self, channel: ChannelKey, widget: Histogram) -> None: if lut := self._luts.get(channel, None): lut._add_histogram(widget) @@ -877,7 +871,7 @@ def remove_histogram(self, widget: QWidget) -> None: def add_shared_histogram(self, widget: Any) -> None: self._shared_histogram = widget - qwidget = cast("QWidget", widget.frontend_widget()) + qwidget = cast("QWidget", widget) qwidget.setFixedHeight(120) qwidget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) self._qwidget._shared_histogram_widget = qwidget @@ -901,8 +895,7 @@ def _on_shared_histogram_toggled(self, toggled: bool) -> None: self._qwidget.shared_hist_log_btn.setVisible(toggled) def _on_shared_hist_log_toggled(self, toggled: bool) -> None: - if self._shared_histogram is not None: - self._shared_histogram.set_log_base(10 if toggled else None) + self.sharedHistogramLogRequested.emit(10 if toggled else None) def create_sliders(self, coords: Mapping[Hashable, Sequence]) -> None: """Update sliders with the given coordinate ranges.""" diff --git a/src/ndv/views/_shared_histogram.py b/src/ndv/views/_shared_histogram.py new file mode 100644 index 00000000..26be4032 --- /dev/null +++ b/src/ndv/views/_shared_histogram.py @@ -0,0 +1,620 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from math import ceil, floor, log10 +from typing import TYPE_CHECKING, Any + +import cmap +import numpy as np +import scenex as snx +from psygnal import Signal +from scenex.app import CursorType, events +from scenex.util import projections + +from ndv.views._util import ( + apply_log_counts, + area_to_mesh, + downsample_histogram, +) + +if TYPE_CHECKING: + from ndv._types import ChannelKey + +# PyGFX's blending produces brighter fills than Vispy at the same alpha; +# use a lower value here so both backends look visually similar. +FILL_ALPHA = 0.08 + + +@dataclass +class _ChannelVisuals: + """All visuals for a single channel on the shared histogram.""" + + controls: snx.Scene + area_mesh: snx.Mesh + outline: snx.Line + left_clim: snx.Line + right_clim: snx.Line + gamma_line: snx.Line + highlight: snx.Line + gamma_handle: snx.Points + legend_text: snx.Text + # per-channel state + color: cmap.Color + clims: tuple[float, float] | None = None + gamma: float = 1.0 + counts: np.ndarray | None = None + bin_edges: np.ndarray | None = None + visible: bool = True + name: str = "" + _display_centers: np.ndarray | None = field(default=None, repr=False) + _display_counts: np.ndarray | None = field(default=None, repr=False) + + +Y_AXIS = 40 # pixels reserved for y-axis strip +X_AXIS = 25 # pixels reserved for x-axis strip +LEGEND_W = 50 # width of legend area +LEGEND_H = 20 # height of legend area + + +class SharedHistogram: + """Shared multi-channel histogram using Scenex.""" + + climsChanged = Signal(object, tuple) + gammaChanged = Signal(object, float) + + def __init__(self) -> None: + self._channels: dict[object, _ChannelVisuals] = {} + self._log_base: float | None = None + self._grabbed: snx.Node | None = None + self._grabbed_key: object = None + self._clim_bounds: tuple[float | None, float | None] = (None, None) + self._has_initial_range = False + self._last_cam_state: tuple[float, float] = (0.0, 0.0) # (x, width) + + # Margins (pixels) + self.margin_left = 10 + self.margin_bottom = 20 + self.margin_right = 4 + self.margin_top = 14 # room for legend text + + # ------------ Scenex setup ------------ # + + # NOTE: Keep the canvas hidden until we're ready to show it. + # More than anything else, this prevents the GL context from being created + # on the vispy backends before we're ready, leading to some nasty segfaults + self.canvas = snx.Canvas(visible=False) + + self.x_view = snx.View(scene=snx.Scene(), camera=snx.Camera()) + self.view = snx.View( + scene=snx.Scene(name="main scene"), + camera=snx.Camera(interactive=True), + ) + self.y_view = snx.View(scene=snx.Scene(), camera=snx.Camera()) + + self.legend_view = snx.View( + scene=snx.Scene(name="legend"), + camera=snx.Camera(), + ) + self.legend_view.layout.background_color = cmap.Color((0, 0, 0, 0)) + + self.canvas.views.append(self.x_view) + self.canvas.views.append(self.y_view) + self.canvas.views.append(self.view) + self.canvas.views.append(self.legend_view) + + self.x_view.layout.y_start = f"-{X_AXIS}px" + self.y_view.layout.y_end = f"-{X_AXIS}px" + self.view.layout.y_end = f"-{X_AXIS}px" + self.view.layout.x_start = f"{Y_AXIS}px" + self.y_view.layout.x_end = f"{Y_AXIS}px" + self.legend_view.layout.x_start = f"-{LEGEND_W}px" + self.legend_view.layout.y_end = f"{LEGEND_H}px" + + # ------------ Nodes ------------ # + + self.x_axis = snx.Line( + vertices=np.array([[0, 0, 0], [1, 0, 0]]), + width=2, + color=snx.UniformColor(color=cmap.Color("white")), + ) + self.x_view.scene.add_child(self.x_axis) + self._tick_objects: list[snx.Text] = [] + for _ in range(10): + tick_line = snx.Line( + vertices=np.array([[0, 0, 0], [0, -0.1, 0]]), + width=1, + color=snx.UniformColor(color=cmap.Color("white")), + transform=snx.Transform().translated((0, 0.4, 0)), + ) + tick_text = snx.Text(text="0", children=[tick_line], antialias=True) + self._tick_objects.append(tick_text) + + self.y_axis = snx.Line( + vertices=np.array([[0, 0, 0], [0, 1, 0]]), + width=2, + color=snx.UniformColor(color=cmap.Color("white")), + ) + self.y_max = snx.Text( + text="1", + transform=snx.Transform().translated((-0.5, 0.95)), + antialias=True, + ) + self.y_view.scene.add_child(self.y_axis) + self.y_view.scene.add_child(self.y_max) + + # Legend labels (also in y_scene for screen-space rendering) + self._legend_labels: list[snx.Text] = [] + + self.view.camera.controller = snx.PanZoom(lock_y=True) + self.view.camera.events.transform.connect(self._update_x_axis) + self.view.camera.events.projection.connect(self._update_x_axis) + self.canvas.events.width.connect(self._update_x_axis) + self.view.set_event_filter(self._on_main_view) + + # ------------ SharedHistogramCanvas methods ------------ # + + def widget(self) -> Any: + return snx.native(self.canvas) + + def set_channel_data( + self, key: ChannelKey, counts: np.ndarray, bin_edges: np.ndarray + ) -> None: + ch = self._ensure_channel(key) + ch.counts = counts + ch.bin_edges = bin_edges + self._update_channel_area(key) + + if not self._has_initial_range: + self._has_initial_range = True + self.set_range() + else: + self.set_range(skip_x=True) + self._update_y_ruler() + + def set_channel_color(self, key: ChannelKey, color: tuple) -> None: + ch = self._ensure_channel(key) + ch.color = color + self._apply_channel_colors(key) + + def set_channel_visible(self, key: ChannelKey, visible: bool) -> None: + ch = self._channels.get(key) + if ch is None: + return + ch.visible = visible + for obj in ( + ch.area_mesh, + ch.outline, + ch.left_clim, + ch.right_clim, + ch.gamma_line, + ch.gamma_handle, + ch.legend_text, + ): + obj.visible = visible + self._update_legend() + self.set_range(skip_x=True) + + def set_channel_clims(self, key: ChannelKey, clims: tuple[float, float]) -> None: + ch = self._channels.get(key) + if ch is None: + return + ch.clims = clims + self._update_lut_visuals(key) + + def set_channel_gamma(self, key: ChannelKey, gamma: float) -> None: + ch = self._channels.get(key) + if ch is None: + return + ch.gamma = gamma + self._update_lut_visuals(key) + + def remove_channel(self, key: ChannelKey) -> None: + ch = self._channels.pop(key, None) + if ch is None: + return + for obj in ( + ch.area_mesh, + ch.outline, + ch.left_clim, + ch.right_clim, + ch.gamma_line, + ch.gamma_handle, + ch.controls, + ): + self.view.scene.remove_child(obj) + self._update_legend() + self.set_range() + + def set_channel_name(self, key: ChannelKey, name: str) -> None: + ch = self._channels.get(key) + if ch is None: + return + ch.name = name + ch.legend_text.text = f"● {name}" + self._update_legend() + + def set_clim_bounds(self, bounds: tuple[float | None, float | None]) -> None: + self._clim_bounds = bounds + # self._camera.xbounds = bounds + # self._x_cam.xbounds = bounds + # # Update x-axis ruler domain + # self._x.start_pos = [0 if bounds[0] is None else int(bounds[0]), 0, 0] + # self._x.end_pos = [65535 if bounds[1] is None else int(bounds[1]), 0, 0] + + def set_log_base(self, base: float | None) -> None: + if base == self._log_base: + return + self._log_base = base + for key in self._channels: + self._update_channel_area(key) + self._update_y_ruler() + + def highlight(self, channel_values: dict[object, float]) -> None: + # NOTE: This behavior differs from the previous version. + # Previously, if a channel in the dict has not yet been added to the histogram, + # a line would be added in. This is not the case anymore. + for key, vis in self._channels.items(): + vis.highlight.visible = key in channel_values + vis.highlight.transform = snx.Transform().translated( + (channel_values.get(key, 0), 0, 0) + ) + + # ------------ Private helpers ------------ # + + def _ensure_channel(self, key: object) -> _ChannelVisuals: + if key in self._channels: + return self._channels[key] + + area_mesh = snx.Mesh( + parent=self.view.scene, + vertices=np.zeros((1, 3), dtype=np.float32), + faces=np.zeros((1, 3), dtype=np.uint32), + opacity=0.3, + ) + + outline = snx.Line( + parent=self.view.scene, + vertices=np.array([[0, 0, 0]], dtype=np.float32), + ) + + left_clim = snx.Line( + vertices=np.array([[0, 0, 0], [0, 1, 0]], dtype=np.float32), + interactive=True, + ) + + right_clim = snx.Line( + vertices=np.array([[0, 0, 0], [0, 1, 0]], dtype=np.float32), + interactive=True, + ) + + gamma_line = snx.Line( + parent=self.view.scene, + vertices=np.array([[0, 0, 0]], dtype=np.float32), + ) + + gamma_handle = snx.Points( + vertices=np.array([[0, 0, 0]], dtype=np.float32), + scaling="fixed", + size=12, + interactive=True, + ) + + highlight = snx.Line( + parent=self.view.scene, + vertices=np.array([[0, 0, 0], [0, 1, 0]], dtype=np.float32), + visible=False, # initially hidden until a channel is highlighted + ) + + legend_text = snx.Text( + parent=self.legend_view.scene, + text="", + size=10, + ) + + controls = snx.Scene( + parent=self.view.scene, children=[left_clim, right_clim, gamma_handle] + ) + + ch = _ChannelVisuals( + controls=controls, + area_mesh=area_mesh, + outline=outline, + left_clim=left_clim, + right_clim=right_clim, + gamma_line=gamma_line, + gamma_handle=gamma_handle, + highlight=highlight, + legend_text=legend_text, + color=cmap.Color("white"), + ) + self._channels[key] = ch + self._update_legend() + return ch + + def _apply_channel_colors(self, key: object) -> None: + ch = self._channels[key] + color = snx.UniformColor(color=ch.color) + ch.area_mesh.color = color + ch.outline.color = color + ch.left_clim.color = color + ch.right_clim.color = color + ch.gamma_line.color = color + ch.gamma_handle.edge_color = color + ch.highlight.color = color + ch.legend_text.color = ch.color + + self._update_channel_area(key) + self._update_lut_visuals(key) + + def _update_channel_area(self, key: object) -> None: + ch = self._channels.get(key) + if ch is None or ch.counts is None or ch.bin_edges is None: + return + + canvas_w = max(int(self.canvas.content_rect_for(self.view)[0]), 64) + visible = self._visible_x_range() + centers, display_counts = downsample_histogram( + ch.counts, + ch.bin_edges, + max_display_bins=canvas_w, + visible_range=visible, + ) + ch._display_centers = centers + ch._display_counts = display_counts + counts = apply_log_counts(display_counts, self._log_base) + + verts, faces = area_to_mesh(centers, counts) + if len(centers) < 2: + return + ch.area_mesh.vertices = verts + ch.area_mesh.faces = faces + + outline_pos = np.zeros((len(centers), 3), dtype=np.float32) + outline_pos[:, 0] = centers + outline_pos[:, 1] = counts + ch.outline.vertices = outline_pos + + def _update_lut_visuals(self, key: object, npoints: int = 64) -> None: + ch = self._channels.get(key) + if ch is None or ch.clims is None: + return + + clims = ch.clims + gamma = ch.gamma + + # Left clim line (full height) + left_pos = np.array([[clims[0], 0, 0], [clims[0], 1, 0]], dtype=np.float32) + ch.left_clim.vertices = left_pos + ch.left_clim.visible = ch.visible + + # Right clim line (full height) + right_pos = np.array([[clims[1], 0, 0], [clims[1], 1, 0]], dtype=np.float32) + ch.right_clim.vertices = right_pos + ch.right_clim.visible = ch.visible + + # Gamma curve + t = np.linspace(0, 1, npoints) + gx = np.linspace(clims[0], clims[1], npoints) + gy = t**gamma * 1 + gamma_pos = np.zeros((npoints, 3), dtype=np.float32) + gamma_pos[:, 0] = gx + gamma_pos[:, 1] = gy + ch.gamma_line.vertices = gamma_pos + ch.gamma_line.visible = ch.visible + + # Gamma handle + mid_x, mid_y = float(np.mean(clims)), (2 ** (-gamma)) + handle_pos = np.array([[mid_x, mid_y, 0]], dtype=np.float32) + ch.gamma_handle.vertices = handle_pos + ch.gamma_handle.visible = ch.visible + + def set_range( + self, + skip_x: bool = False, + skip_y: bool = False, + x: tuple[float, float] | None = None, + y: tuple[float, float] | None = None, + ) -> None: + bb = self.view.scene.bounding_box + center = np.mean(bb, axis=0) if bb else (0, 0, 0) + w, h, d = np.maximum(np.ptp(bb, axis=0) if bb else (1, 1, 1), 1e-6) + if skip_x: + center = (self.view.camera.transform.root[3, 0], center[1], center[2]) + w = 2 / self.view.camera.projection.root[0, 0] + if skip_y: + center = (center[0], self.view.camera.transform.root[3, 1], center[2]) + h = 2 / self.view.camera.projection.root[1, 1] + if x is not None: + center = (np.mean(x), center[1], center[2]) + w = x[1] - x[0] + if y is not None: + center = (center[0], np.mean(y), center[2]) + h = y[1] - y[0] + self.view.camera.transform = snx.Transform().translated(center) + self.view.camera.projection = projections.orthographic(w, h, d) + + self.x_view.camera.projection = projections.orthographic(1, 1, 1) + self.y_view.camera.projection = projections.orthographic(1, 1, 1) + self.x_view.camera.transform = snx.Transform().translated((0.5, -0.5, 0)) + self.y_view.camera.transform = snx.Transform().translated((-0.5, 0.5, 0)) + + def _visible_x_range(self) -> tuple[float, float] | None: + """Get the currently visible x-range from the camera.""" + if not self._has_initial_range: + return None + cx = self.view.camera.transform.root[3, 0] + w = 1 / self.view.camera.projection.root[0, 0] + return (cx - w, cx + w) + + def _redownsample_all(self) -> None: + """Re-downsample all channels for the current visible range.""" + for key in self._channels: + self._update_channel_area(key) + + def _refresh_all_lut_visuals(self) -> None: + for key in self._channels: + self._update_lut_visuals(key) + + def _update_y_ruler(self) -> None: + """Update the y-axis max label position and text.""" + max_val = max( + ch.counts.max() for ch in self._channels.values() if ch.counts is not None + ) + if self._log_base: + max_val = np.log(max_val + 1) / np.log(self._log_base) + self.y_max.text = f"{max_val:.0f}" if max_val > 0 else "" + tform = snx.Transform().scaled((1, 1 / max_val, 1)) + for ch in self._channels.values(): + ch.area_mesh.transform = ch.outline.transform = tform + + def _update_legend(self) -> None: + """Position legend entries horizontally at the top-right.""" + # Collect visible channel entries + visible_channels: list[snx.Text] = [] + for ch in self._channels.values(): + should_display = ch.visible and bool(ch.name) + ch.legend_text.visible = should_display + if should_display: + visible_channels.append(ch.legend_text) + self.legend_view.layout.y_end = f"{len(visible_channels) * 20}px" + + # Position entries top-to-bottom + for i, text in enumerate(visible_channels): + text.transform = snx.Transform().translated( + (0, i / len(visible_channels), 0) + ) + + def _on_main_view(self, event: events.Event) -> bool: + if isinstance(event, events.MousePressEvent): + if not (ray := self.view.to_ray(event.pos)): + return False + for key, ch in self._channels.items(): + hits = [n for n, _ in ray.intersections(ch.controls) if n.interactive] + if hits: + self._grabbed_key = key + self._grabbed = hits[0] + self.view.camera.interactive = False + break + + elif isinstance(event, events.MouseDoublePressEvent): + if not (ray := self.view.to_ray(event.pos)): + return False + for key, ch in self._channels.items(): + hits = [n for n, _ in ray.intersections(ch.controls) if n.interactive] + if ch.gamma_handle in hits: + self.gammaChanged.emit(key, 1.0) + break + + if isinstance(event, events.MouseMoveEvent): + if not (ray := self.view.to_ray(event.pos)): + return False + grabbed_ch = self._channels.get(self._grabbed_key, None) + if grabbed_ch is not None and self._grabbed is not None: + if ( + clims := grabbed_ch.clims + ) and self._grabbed is grabbed_ch.left_clim: + new_left = min(ray.origin[0], clims[1]) + if grabbed_ch.bin_edges is not None: + new_left = max(new_left, float(grabbed_ch.bin_edges[0])) + self.climsChanged.emit(self._grabbed_key, (new_left, clims[1])) + return True + elif ( + clims := grabbed_ch.clims + ) and self._grabbed is grabbed_ch.right_clim: + new_right = max(clims[0], ray.origin[0]) + if grabbed_ch.bin_edges is not None: + new_right = min(new_right, float(grabbed_ch.bin_edges[-1])) + self.climsChanged.emit(self._grabbed_key, (clims[0], new_right)) + return True + elif self._grabbed is grabbed_ch.gamma_handle: + y = ray.origin[1] + self.gammaChanged.emit(self._grabbed_key, max(-np.log2(y), 0.1)) + return True + else: + cursor = CursorType.DEFAULT + for ch in self._channels.values(): + if ray.intersections(ch.right_clim) or ray.intersections( + ch.left_clim + ): + cursor = CursorType.H_ARROW + break + elif ray.intersections(ch.gamma_handle): + cursor = CursorType.V_ARROW + break + snx.set_cursor(self.canvas, cursor) + + elif isinstance(event, events.MouseReleaseEvent | events.MouseLeaveEvent): + self._grabbed_key = None + self._grabbed = None + self.view.camera.interactive = True + + return False + + # ---- x-axis ticks ---- + + def _calculate_tick_step( + self, min_val: float, max_val: float, target_ticks: int = 5 + ) -> float: + if max_val <= min_val: + return 1.0 + approx_step = (max_val - min_val) / target_ticks + power10 = 10.0 ** floor(log10(approx_step)) + for m in [1.0, 2.0, 2.5, 5.0, 10.0]: + if m * power10 >= approx_step: + return m * power10 + return power10 + + def _get_tick_positions( + self, min_val: float, max_val: float, step: float + ) -> list[float]: + if step <= 0: + return [min_val, max_val] + first = ceil(min_val / step) * step + last = floor(max_val / step) * step + ticks: list[float] = [] + cur = first + while cur <= last and len(ticks) < 20: + ticks.append(cur) + cur += step + min_dist = step * 0.15 + filtered = [ + t + for t in ticks + if abs(t - min_val) >= min_dist and abs(t - max_val) >= min_dist + ] + seen: set[float] = set() + result: list[float] = [] + for t in [min_val, *filtered, max_val]: + if t not in seen: + seen.add(t) + result.append(t) + return result + + def _clear_ticks(self) -> None: + for tick in self._tick_objects: + if tick in self.x_view.scene.children: + self.x_view.scene.remove_child(tick) + + def _update_x_axis(self) -> None: + cam = self.view.camera + left, *_ = cam.transform.map(cam.projection.imap((-1, 0))) + right, *_ = cam.transform.map(cam.projection.imap((1, 0))) + self._clear_ticks() + step = self._calculate_tick_step(left, right) + positions = self._get_tick_positions(left, right, step) + _x, _y, w, _h = self.canvas.rect_for(self.x_view) + start = Y_AXIS / w + for idx, val in enumerate(positions): + if idx >= len(self._tick_objects): + break + norm = ( + start + (val - left) / (right - left) * (1 - start) + if right != left + else 0.5 + ) + tick = self._tick_objects[idx] + tick.text = f"{val:.0f}" + tick.transform = snx.Transform().translated((norm, -0.5, 0)) + self.x_view.scene.add_child(tick) + + self._redownsample_all() diff --git a/src/ndv/views/_util.py b/src/ndv/views/_util.py index 73cbc96a..ee579a06 100644 --- a/src/ndv/views/_util.py +++ b/src/ndv/views/_util.py @@ -3,11 +3,17 @@ from __future__ import annotations import logging +from typing import TYPE_CHECKING import numpy as np +if TYPE_CHECKING: + import numpy.typing as npt + logger = logging.getLogger("ndv") +LUT_LINE_ALPHA = 0.6 + def downsample_data( data: np.ndarray, max_size: int, *, warn: bool = True @@ -31,3 +37,103 @@ def downsample_data( slices = tuple(slice(None, None, f) for f in factors) data = data[slices] return data, factors + + +# ------------ Histogram data helpers ------------ # + + +def downsample_histogram( + counts: np.ndarray, + bin_edges: np.ndarray, + max_display_bins: int = 800, + visible_range: tuple[float, float] | None = None, +) -> tuple[np.ndarray, np.ndarray]: + """Downsample histogram for display, optionally cropping to visible range. + + Parameters + ---------- + counts : np.ndarray + Raw histogram counts. + bin_edges : np.ndarray + Raw bin edges (len = len(counts) + 1). + max_display_bins : int + Target number of bins after downsampling. + visible_range : tuple[float, float] | None + If provided, crop to this (x_lo, x_hi) range before downsampling. + A small margin of extra bins is kept so panning feels seamless. + """ + if visible_range is not None: + counts, bin_edges = _crop_histogram(counts, bin_edges, *visible_range) + + n = len(counts) + if n > max_display_bins: + factor = n // max_display_bins + trim = n - (n % factor) + counts = counts[:trim].reshape(-1, factor).mean(axis=1) + bin_edges = np.concatenate( + [bin_edges[:trim:factor], bin_edges[trim : trim + 1]] + ) + centers = (bin_edges[:-1] + bin_edges[1:]) / 2 + return centers, counts + + +def _crop_histogram( + counts: np.ndarray, + bin_edges: np.ndarray, + x_lo: float, + x_hi: float, + margin_frac: float = 0.1, +) -> tuple[np.ndarray, np.ndarray]: + """Crop histogram to the visible range plus a margin for panning.""" + span = x_hi - x_lo + margin = span * margin_frac + lo = x_lo - margin + hi = x_hi + margin + + centers = (bin_edges[:-1] + bin_edges[1:]) * 0.5 + mask = (centers >= lo) & (centers <= hi) + if not np.any(mask): + return counts, bin_edges + + idx = np.nonzero(mask)[0] + i0, i1 = idx[0], idx[-1] + 1 + return counts[i0:i1], bin_edges[i0 : i1 + 1] + + +def area_to_mesh( + centers: np.ndarray, + counts: np.ndarray, +) -> tuple[npt.NDArray[np.float32], npt.NDArray[np.uint32]]: + """Convert area plot data to mesh vertices and faces (triangle strip).""" + n = len(centers) + if n == 0: + return np.zeros((0, 3), np.float32), np.zeros((0, 3), np.uint32) + + # 2 vertices per point: one on the curve, one on the baseline + vertices = np.zeros((2 * n, 3), np.float32) + vertices[0::2, 0] = centers + vertices[0::2, 1] = counts + vertices[1::2, 0] = centers + # vertices[1::2, 1] = 0 (baseline, already 0) + + # Vectorized face generation + idx = np.arange(n - 1, dtype=np.uint32) + faces = np.zeros((2 * (n - 1), 3), np.uint32) + faces[0::2, 0] = 2 * idx # top_left + faces[0::2, 1] = 2 * idx + 1 # bot_left + faces[0::2, 2] = 2 * idx + 2 # top_right + faces[1::2, 0] = 2 * idx + 1 # bot_left + faces[1::2, 1] = 2 * idx + 3 # bot_right + faces[1::2, 2] = 2 * idx + 2 # top_right + + return vertices, faces + + +# ------------ Log scale ------------ # + + +def apply_log_counts(counts: np.ndarray, log_base: float | None) -> np.ndarray: + """Apply log transform to counts if log_base is set.""" + if log_base: + return np.log(counts + 1) / np.log(log_base) # type: ignore[no-any-return] + return counts diff --git a/src/ndv/views/_vispy/__init__.py b/src/ndv/views/_vispy/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/ndv/views/_vispy/_array_canvas.py b/src/ndv/views/_vispy/_array_canvas.py deleted file mode 100755 index 74c27369..00000000 --- a/src/ndv/views/_vispy/_array_canvas.py +++ /dev/null @@ -1,616 +0,0 @@ -# pyright: reportOptionalSubscript=none -from __future__ import annotations - -import warnings -from contextlib import suppress -from typing import TYPE_CHECKING, Any, Literal, TypeVar, cast -from weakref import ReferenceType, WeakValueDictionary - -import cmap as _cmap -import numpy as np -import vispy -import vispy.color -import vispy.scene -from vispy import scene, visuals -from vispy.util.quaternion import Quaternion - -from ndv._types import ( - CursorType, - MouseButton, - MouseMoveEvent, - MousePressEvent, - MouseReleaseEvent, -) -from ndv.models._viewer_model import ArrayViewerModel, InteractionMode -from ndv.views._app import filter_mouse_events -from ndv.views._util import downsample_data -from ndv.views._vispy._util import get_max_texture_sizes -from ndv.views.bases import ArrayCanvas -from ndv.views.bases._graphics._canvas_elements import ( - CanvasElement, - ImageHandle, - RectangularROIHandle, - ROIMoveMode, -) - -if TYPE_CHECKING: - from collections.abc import Sequence - - -turn = np.sin(np.pi / 4) -DEFAULT_QUATERNION = Quaternion(turn, turn, 0, 0) - - -class VispyImageHandle(ImageHandle): - def __init__(self, visual: visuals.ImageVisual | visuals.VolumeVisual) -> None: - self._visual = visual - self._allowed_dims = {2, 3} if isinstance(visual, visuals.ImageVisual) else {3} - # per-axis downsample strides applied to fit GPU texture limits - self._downsample_factors: tuple[int, ...] = () - - def data(self) -> np.ndarray: - try: - return self._visual._data # type: ignore [no-any-return] - except AttributeError: - return self._visual._last_data # type: ignore [no-any-return] - - def set_data(self, data: np.ndarray) -> None: - if data.ndim not in self._allowed_dims: - warnings.warn( - f"Got wrong number of dimensions ({data.ndim}) for vispy " - f"visual of type {type(self._visual)}.", - stacklevel=2, - ) - return - - data, downsample_factors = _downcast_and_downsample( - data, - three_d=isinstance(self._visual, visuals.VolumeVisual), - warn=False, - ) - self._downsample_factors = downsample_factors - self._visual.set_data(data) - - def visible(self) -> bool: - return bool(self._visual.visible) - - def set_visible(self, visible: bool) -> None: - self._visual.visible = visible - - # TODO: shouldn't be needed - def can_select(self) -> bool: - return False - - def selected(self) -> bool: - return False - - def set_selected(self, selected: bool) -> None: - raise NotImplementedError("Images cannot be selected") - - def clims(self) -> Any: - return self._visual.clim - - def set_clims(self, clims: tuple[float, float]) -> None: - with suppress(ZeroDivisionError): - self._visual.clim = clims - - def gamma(self) -> float: - return self._visual.gamma # type: ignore [no-any-return] - - def set_gamma(self, gamma: float) -> None: - self._visual.gamma = gamma - - def colormap(self) -> _cmap.Colormap: - return self._cmap # FIXME - - def set_colormap(self, cmap: _cmap.Colormap) -> None: - self._cmap = cmap - self._visual.cmap = cmap.to_vispy() - - def transform(self) -> np.ndarray: - raise NotImplementedError - - def set_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 get_cursor(self, event: MouseMoveEvent) -> CursorType | None: - return None - - -class VispyRectangle(RectangularROIHandle): - def __init__(self, parent: Any) -> None: - self._selected = False - self._move_mode: ROIMoveMode | None = None - # NB _move_anchor has different meanings depending on _move_mode - self._move_anchor: tuple[float, float] = (0, 0) - - # Rectangle handles both fill and border - self._rect = scene.Rectangle(center=[0, 0], width=1, height=1, parent=parent) - # NB: Should be greater than image orders BUT NOT handle order - self._rect.order = 10 - self._rect.interactive = True - - self._handle_data = np.zeros((4, 2)) - self._handle_size = 10 # px - self._handles = scene.Markers( - pos=self._handle_data, - size=self._handle_size, - scaling="fixed", - parent=parent, - ) - # NB: Should be greater than image orders and rect order - self._handles.order = 100 - self._handles.interactive = True - - self.set_fill(_cmap.Color("transparent")) - self.set_border(_cmap.Color("yellow")) - self.set_handles(_cmap.Color("white")) - self.set_visible(False) - - def _tform(self) -> scene.transforms.BaseTransform: - return self._rect.transforms.get_transform("canvas", "scene") - - def can_select(self) -> bool: - return True - - def selected(self) -> bool: - return self._selected - - def set_selected(self, selected: bool) -> None: - self._selected = selected - self._handles.visible = selected and self.visible() - - def set_fill(self, color: _cmap.Color) -> None: - _vis_color = vispy.color.Color(color.hex) - # NB We need alpha>0 for selection - _vis_color.alpha = max(color.alpha, 1e-6) - self._rect.color = _vis_color - - def set_border(self, color: _cmap.Color) -> None: - _vis_color = vispy.color.Color(color.hex) - _vis_color.alpha = color.alpha - self._rect.border_color = _vis_color - - # TODO: Misleading name? - def set_handles(self, color: _cmap.Color) -> None: - _vis_color = vispy.color.Color(color.hex) - _vis_color.alpha = color.alpha - self._handles.set_data(face_color=_vis_color) # pyright: ignore[reportArgumentType] - - def set_bounding_box( - self, minimum: tuple[float, float], maximum: tuple[float, float] - ) -> None: - # NB: Support two diagonal points, not necessarily true min/max - x1 = float(min(minimum[0], maximum[0])) - y1 = float(min(minimum[1], maximum[1])) - x2 = float(max(minimum[0], maximum[0])) - y2 = float(max(minimum[1], maximum[1])) - - # Update rectangle - self._rect.center = [(x1 + x2) / 2, (y1 + y2) / 2] - self._rect.width = max(float(x2 - x1), 1e-30) - self._rect.height = max(float(y2 - y1), 1e-30) - - # Update handles - self._handle_data[0] = x1, y1 - self._handle_data[1] = x2, y1 - self._handle_data[2] = x2, y2 - self._handle_data[3] = x1, y2 - self._handles.set_data(pos=self._handle_data) - - # FIXME: These should be called internally upon set_data, right? - # Looks like https://github.com/vispy/vispy/issues/1899 - self._rect._bounds_changed() - for v in self._rect._subvisuals: - v._bounds_changed() - self._handles._bounds_changed() - - def on_mouse_move(self, event: MouseMoveEvent) -> bool: - # Convert canvas -> world - canvas_pos = (event.x, event.y) - world_pos = self._tform().map(canvas_pos)[:2] - # moving a handle - if self._move_mode == ROIMoveMode.HANDLE: - # The anchor is set to the opposite handle, which never moves. - self.boundingBoxChanged.emit((world_pos, self._move_anchor)) - # translating the whole roi - elif self._move_mode == ROIMoveMode.TRANSLATE: - # The anchor is the mouse position reported in the previous mouse event. - dx = world_pos[0] - self._move_anchor[0] - dy = world_pos[1] - self._move_anchor[1] - # If the mouse moved (dx, dy) between events, the whole ROI needs to be - # translated that amount. - new_min = (self._handle_data[0, 0] + dx, self._handle_data[0, 1] + dy) - new_max = (self._handle_data[2, 0] + dx, self._handle_data[2, 1] + dy) - self.boundingBoxChanged.emit((new_min, new_max)) - self._move_anchor = world_pos - - return False - - def on_mouse_press(self, event: MousePressEvent) -> bool: - self.set_selected(True) - # Convert canvas -> world - canvas_pos = (event.x, event.y) - world_pos = self._tform().map(canvas_pos)[:2] - drag_idx = self._handle_under(canvas_pos) - # If a marker is pressed - if drag_idx is not None: - opposite_idx = (drag_idx + 2) % 4 - self._move_mode = ROIMoveMode.HANDLE - self._move_anchor = tuple(self._handle_data[opposite_idx].copy()) - # If the rectangle is pressed - else: - self._move_mode = ROIMoveMode.TRANSLATE - self._move_anchor = world_pos - return False - - def on_mouse_release(self, event: MouseReleaseEvent) -> bool: - return False - - def get_cursor(self, event: MouseMoveEvent) -> CursorType | None: - canvas_pos = (event.x, event.y) - pos = self._tform().map(canvas_pos)[:2] - if self._handle_under(canvas_pos) is not None: - center = self._rect.center - if pos[0] < center[0] and pos[1] < center[1]: - return CursorType.FDIAG_ARROW - if pos[0] > center[0] and pos[1] > center[1]: - return CursorType.FDIAG_ARROW - return CursorType.BDIAG_ARROW - return CursorType.ALL_ARROW - - def visible(self) -> bool: - return bool(self._rect.visible) - - def set_visible(self, visible: bool) -> None: - self._rect.visible = visible - self._handles.visible = visible and self.selected() - - def remove(self) -> None: - self._rect.parent = None - self._handles.parent = None - - def _handle_under(self, canvas_pos: Sequence[float]) -> int | None: - """Returns an int in [0, 3], or None. - - canvas_pos should be in canvas (screen pixel) coordinates. - """ - rad2 = (self._handle_size / 2) ** 2 - tform = self._rect.transforms.get_transform("scene", "canvas") - for i, p in enumerate(self._handle_data): - hp = tform.map(p)[:2] - if (hp[0] - canvas_pos[0]) ** 2 + (hp[1] - canvas_pos[1]) ** 2 <= rad2: - return i - return None - - -class VispyArrayCanvas(ArrayCanvas): - """Vispy-based viewer for data. - - All vispy-specific code is encapsulated in this class (and non-vispy canvases - could be swapped in if needed as long as they implement the same interface). - """ - - def __init__(self, viewer_model: ArrayViewerModel) -> None: - self._viewer = viewer_model - - self._canvas = scene.SceneCanvas(size=(600, 600)) - - # this filter needs to remain in scope for the lifetime of the canvas - # or mouse events will not be intercepted - # the returned function can be called to remove the filter, (and it also - # closes on the event filter and keeps it in scope). - self._disconnect_mouse_events = filter_mouse_events(self._canvas.native, self) - - self._last_state: dict[Literal[2, 3], Any] = {} - - central_wdg: scene.Widget = self._canvas.central_widget - self._view: scene.ViewBox = central_wdg.add_view() - self._ndim: Literal[2, 3] | None = None - - # Maps vispy visuals (scene children) → CanvasElement handles. - # Entries are added by add_image/add_volume/add_bounding_box. - # Nobody explicitly removes entries: the controller owns handle - # lifetimes via ChannelController.handles/lut_views (for images) and - # _roi_view (for ROIs). When the controller calls handle.remove() - # (in _clear_canvas) those refs are dropped, the handle is GC'd, and - # the WeakValueDictionary entry is automatically removed. - # NB: a WeakKeyDictionary would create a ref cycle here because - # each handle (value) holds a strong ref back to its visual (key). - self._elements = WeakValueDictionary[scene.Node, CanvasElement]() - self._selection: CanvasElement | None = None - # Maintain weak reference to last ROI created - self._last_roi_created: ReferenceType[VispyRectangle] | None = None - - @property - def _camera(self) -> vispy.scene.cameras.BaseCamera: - return self._view.camera - - def set_ndim(self, ndim: Literal[2, 3]) -> None: - """Set the number of dimensions of the displayed data.""" - if ndim == self._ndim: - return - elif self._ndim is not None: - # remember the current state before switching to the new camera - self._last_state[self._ndim] = self._camera.get_state() - - self._ndim = ndim - if ndim == 3: - cam = scene.ArcballCamera(fov=0) - # this sets the initial view similar to what the panzoom view would have. - cam._quaternion = DEFAULT_QUATERNION - else: - cam = scene.PanZoomCamera(aspect=1, flip=(0, 1)) - - # restore the previous state if it exists - if state := self._last_state.get(ndim): - cam.set_state(state) - self._view.camera = cam - - def frontend_widget(self) -> Any: - return self._canvas.native - - def set_visible(self, visible: bool) -> None: ... - - def close(self) -> None: - self._disconnect_mouse_events() - self._canvas.close() - - def refresh(self) -> None: - self._canvas.update() - - def add_image(self, data: np.ndarray | None = None) -> VispyImageHandle: - """Add a new Image node to the scene.""" - data, downsample_factors = _downcast_and_downsample(data, three_d=False) - try: - img = scene.visuals.Image( - data, parent=self._view.scene, texture_format="auto" - ) - except ValueError as e: - warnings.warn(f"{e}. Falling back to CPUScaledTexture", stacklevel=2) - img = scene.visuals.Image(data, parent=self._view.scene) - - img.set_gl_state("additive", depth_test=False) - img.interactive = True - handle = VispyImageHandle(img) - handle._downsample_factors = downsample_factors - self._elements[img] = handle - if data is not None: - self.set_range() - return handle - - def add_volume(self, data: np.ndarray | None = None) -> VispyImageHandle: - data, downsample_factors = _downcast_and_downsample(data, three_d=True) - try: - vol = scene.visuals.Volume( - data, - parent=self._view.scene, - interpolation="nearest", - texture_format="auto", - ) - except ValueError as e: - warnings.warn(f"{e}. Falling back to CPUScaledTexture", stacklevel=2) - vol = scene.visuals.Volume( - data, parent=self._view.scene, interpolation="nearest" - ) - - vol.set_gl_state("additive", depth_test=False) - vol.interactive = True - handle = VispyImageHandle(vol) - handle._downsample_factors = downsample_factors - self._elements[vol] = handle - if data is not None: - self.set_range() - return handle - - def add_bounding_box(self) -> VispyRectangle: - """Add a new Rectangular ROI node to the scene.""" - roi = VispyRectangle(parent=self._view.scene) - roi.set_visible(False) - self._elements[roi._handles] = roi - self._elements[roi._rect] = roi - self._last_roi_created = ReferenceType(roi) - return roi - - def set_scales(self, scales: tuple[float, ...]) -> None: - """Set per-visible-axis scale factors for rendering.""" - if not scales: - return - # scales are in data order (slowest-to-fastest, e.g. ZYX) - # vispy images use row,col -> y,x mapping, so reverse for XY - vis_scales = list(reversed(scales)) - # pad to 3 components - while len(vis_scales) < 3: - vis_scales.append(1.0) - sx, sy, sz = vis_scales[0], vis_scales[1], vis_scales[2] - for handle in self._elements.values(): - if not isinstance(handle, VispyImageHandle): - continue - child = handle._visual - if not isinstance(child, (visuals.ImageVisual, visuals.VolumeVisual)): - continue - _sx, _sy, _sz = sx, sy, sz - # compensate for downsampling so coordinates stay correct - # factors are in data order; scene order is (x, y, z) = reversed - factors = handle._downsample_factors - if factors and any(f > 1 for f in factors): - rev = list(reversed(factors)) - _sx *= rev[0] - _sy *= rev[1] if len(rev) > 1 else 1 - _sz *= rev[2] if len(rev) > 2 else 1 - child.transform = vispy.visuals.transforms.STTransform( - scale=(_sx, _sy, _sz) - ) - self.set_range() - - def set_range( - self, - x: tuple[float, float] | None = None, - y: tuple[float, float] | None = None, - z: tuple[float, float] | None = None, - margin: float = 0.01, - ) -> None: - """Update the range of the PanZoomCamera. - - When called with no arguments, the range is set to the full extent of - the data, accounting for any STTransform scales on image visuals. - """ - # Compute scaled bounds from image/volume visuals - has_images = False - _x = [0.0, 0.0] - _y = [0.0, 0.0] - _z = [0.0, 0.0] - - for handle in self._elements.values(): - if isinstance(handle, VispyImageHandle): - data = handle.data() - if data is None: - continue - has_images = True - shape = data.shape - sx, sy, sz = 1.0, 1.0, 1.0 - tform = handle._visual.transform - if isinstance(tform, vispy.visuals.transforms.STTransform): - sx, sy, sz = tform.scale[:3] - if isinstance(handle._visual, visuals.VolumeVisual): - # VolumeVisual: data (D,H,W) maps to scene (x=W, y=H, z=D) - _x[1] = max(_x[1], shape[2] * sx) - _y[1] = max(_y[1], shape[1] * sy) - _z[1] = max(_z[1], shape[0] * sz) - else: - # ImageVisual: data (H,W[,C]) maps to scene (x=W, y=H) - _x[1] = max(_x[1], shape[1] * sx) - _y[1] = max(_y[1], shape[0] * sy) - - if not has_images: - # No image data — fall back to vispy's auto-detection - self._camera.set_range() - return - - x = cast("tuple[float, float]", _x) if x is None else x - y = cast("tuple[float, float]", _y) if y is None else y - z = cast("tuple[float, float]", _z) if z is None else z - - is_3d = isinstance(self._camera, scene.ArcballCamera) - if is_3d: - self._camera._quaternion = DEFAULT_QUATERNION - self._view.camera.set_range(x=x, y=y, z=z, margin=margin) - if is_3d: - # vispy computes scale_factor from the 3D diagonal, which over-zooms - # the initial top-down view. Override to match the 2D view extent. - xy_max = max(x[1] - x[0], y[1] - y[0]) - self._camera.scale_factor = xy_max * (1 + 2 * margin) - - def zoom(self, factor: float | tuple, center: tuple[float, float] = (0, 0)) -> None: - """Zoom in (or out) at the given center (world coordinates).""" - self._camera.zoom(factor=factor, center=center) - - 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.scene.transform.imap(pos_xy)[:3] # type: ignore [no-any-return] - - def elements_at(self, pos_xy: tuple[float, float]) -> list[CanvasElement]: - elements = [] - visuals = self._canvas.visuals_at(pos_xy) - for vis in visuals: - if (handle := self._elements.get(vis)) is not None: - elements.append(handle) - return elements - - def on_mouse_press(self, event: MousePressEvent) -> bool: - if self._selection: - self._selection.set_selected(False) - self._selection = None - canvas_pos = (event.x, event.y) - world_pos = self.canvas_to_world(canvas_pos)[:2] - - # If in CREATE_ROI mode, the new ROI should "start" here. - if self._viewer.interaction_mode == InteractionMode.CREATE_ROI: - if self._last_roi_created is None: - raise ValueError("No ROI to create!") - if new_roi := self._last_roi_created(): - self._last_roi_created = None - # HACK: Provide a non-zero starting size so that if the user clicks - # and immediately releases, it's visible and can be selected again - _min = world_pos - _max = (world_pos[0] + 1, world_pos[1] + 1) - # Put the ROI where the user clicked - new_roi.boundingBoxChanged.emit((_min, _max)) - # new_roi.set_bounding_box(_min, _max) - # Make it visible - new_roi.set_visible(True) - # Select it so the mouse press event below triggers ROIMoveMode.HANDLE - # TODO: Make behavior more direct - new_roi.set_selected(True) - - # All done - exit the mode - self._viewer.interaction_mode = InteractionMode.PAN_ZOOM - - # Select first selectable object at clicked point - for vis in self.elements_at(canvas_pos): - if vis.can_select(): - self._selection = vis - self._selection.on_mouse_press(event) - return False - - return False - - def on_mouse_move(self, event: MouseMoveEvent) -> bool: - if event.btn == MouseButton.LEFT: - if self._selection and self._selection.selected(): - self._selection.on_mouse_move(event) - # If we are moving the object, we don't want to move the camera - return True - return False - - def on_mouse_release(self, event: MouseReleaseEvent) -> bool: - if self._selection: - self._selection.on_mouse_release(event) - return False - - def get_cursor(self, event: MouseMoveEvent) -> CursorType: - if self._viewer.interaction_mode == InteractionMode.CREATE_ROI: - return CursorType.CROSS - for vis in self.elements_at((event.x, event.y)): - if cursor := vis.get_cursor(event): - return cursor - return CursorType.DEFAULT - - -T = TypeVar("T", bound="np.ndarray | None") - - -def _downcast(data: T) -> T: - """Downcast >32bit data to 32bit.""" - # downcast to 32bit, preserving int/float - if data is not None: - if np.issubdtype(data.dtype, np.integer) and data.dtype.itemsize > 2: - warnings.warn("Downcasting integer data to uint16.", stacklevel=2) - data = data.astype(np.uint16) # type: ignore[assignment] - elif np.issubdtype(data.dtype, np.floating) and data.dtype.itemsize > 4: - data = data.astype(np.float32) # type: ignore[assignment] - return data - - -def _downcast_and_downsample( - data: T, three_d: bool, warn: bool = True -) -> tuple[T, tuple[int, ...]]: - """Downcast >32bit data to 32bit, and downsample GPU texture limits are exceeded.""" - data = _downcast(data) - downsample_factors: tuple[int, ...] = () - if data is not None: - maxd = get_max_texture_sizes()[1 if three_d else 0] - if maxd is not None: - data, downsample_factors = downsample_data(data, maxd, warn=warn) # type: ignore[assignment] - return data, downsample_factors diff --git a/src/ndv/views/_vispy/_histogram.py b/src/ndv/views/_vispy/_histogram.py deleted file mode 100644 index 1941013f..00000000 --- a/src/ndv/views/_vispy/_histogram.py +++ /dev/null @@ -1,523 +0,0 @@ -# pyright: reportGeneralTypeIssues=none, reportOptionalSubscript=none -# pyright: reportOptionalMemberAccess=none, reportIndexIssue=none -from __future__ import annotations - -from enum import Enum, auto -from typing import TYPE_CHECKING, Any, cast - -import numpy as np -import vispy -import vispy.scene -from vispy import scene -from vispy.visuals.axis import Ticker - -from ndv._types import CursorType -from ndv.models._lut_model import ClimPolicy, ClimsManual -from ndv.views._app import filter_mouse_events -from ndv.views.bases import HistogramCanvas - -from ._plot_widget import LogTicker, PlotWidget - -if TYPE_CHECKING: - from collections.abc import Sequence - - import cmap - import numpy.typing as npt - - from ndv._types import MouseMoveEvent, MousePressEvent, MouseReleaseEvent - -MIN_GAMMA: np.float64 = np.float64(1e-6) - - -class Grabbable(Enum): - NONE = auto() - LEFT_CLIM = auto() - RIGHT_CLIM = auto() - GAMMA = auto() - - -class VispyHistogramCanvas(HistogramCanvas): - """A HistogramCanvas utilizing VisPy.""" - - def __init__(self, *, vertical: bool = False) -> None: - # ------------ data and state ------------ # - - self._values: np.ndarray | None = None - self._bin_edges: np.ndarray | None = None - self._clims: tuple[float, float] | None = None - self._gamma: float = 1 - - # the currently grabbed object - self._grabbed: Grabbable = Grabbable.NONE - # whether the y-axis is logarithmic - self._log_base: float | None = None - # whether the histogram is vertical - self._vertical: bool = vertical - # The values of the left and right edges on the canvas (respectively) - self._domain: tuple[float, float] | None = None - # The values of the bottom and top edges on the canvas (respectively) - self._range: tuple[float, float] | None = None - - # ------------ VisPy Canvas ------------ # - - self._canvas = scene.SceneCanvas() - self._disconnect_mouse_events = filter_mouse_events(self._canvas.native, self) - - ## -- Visuals -- ## - - # NB We directly use scene.Mesh, instead of scene.Histogram, - # so that we can control the calculation of the histogram ourselves - self._hist_mesh = scene.Mesh(color="#888888") - - # The Lut Line visualizes both the clims (vertical line segments connecting the - # first two and last two points, respectively) and the gamma curve - # (the polyline between all remaining points) - self._lut_line = scene.LinePlot( - data=(0), # Dummy value to prevent resizing errors - color="k", - connect="strip", - symbol=None, - line_kind="-", - width=1.5, - marker_size=10.0, - edge_color="k", - face_color="b", - edge_width=1.0, - ) - self._lut_line.visible = False - self._lut_line.order = -1 - - # The gamma handle appears halfway between the clims - self._gamma_handle_pos: np.ndarray = np.ndarray((1, 2)) - self._gamma_handle = scene.Markers( - pos=self._gamma_handle_pos, - size=6, - edge_width=0, - ) - self._gamma_handle.visible = False - self._gamma_handle.order = -2 - - # The highlight draws attention to a particular domain value. - # TODO: Can we make this easier on the eyes? MMStudio uses cmap color, dashed - self._highlight = scene.Line( - pos=np.array([[0, 0], [0, 1]]), - color=(1, 1, 0.2, 0.75), - connect="strip", - width=1, - ) - self._highlight_tform = scene.transforms.STTransform() - self._highlight.visible = False - self._highlight.order = -2 - - # One transform to rule them all! - self._handle_transform = scene.transforms.STTransform() - self._lut_line.transform = self._handle_transform - self._gamma_handle.transform = self._handle_transform - self._highlight.transform = self._highlight_tform - self._highlight.transform = scene.transforms.ChainTransform( - self._handle_transform, self._highlight_tform - ) - - ## -- Plot -- ## - self.plot = PlotWidget() - self.plot.lock_axis("y") - self._canvas.central_widget.add_widget(self.plot) - self.node_tform = cast("vispy.scene.Node", self.plot).node_transform( - self.plot._view.scene - ) - - self.plot._view.add(self._hist_mesh) - self.plot._view.add(self._lut_line) - self.plot._view.add(self._gamma_handle) - self.plot._view.add(self._highlight) - - self.set_vertical(vertical) - - def refresh(self) -> None: - self._canvas.update() - - def set_visible(self, visible: bool) -> None: ... - - def close(self) -> None: - self._disconnect_mouse_events() - self._canvas.close() - - # ------------- LUTView Protocol methods ------------- # - - def set_channel_name(self, name: str) -> None: - # Nothing to do - # TODO: maybe show text somewhere - pass - - def set_channel_visible(self, visible: bool) -> None: - self._lut_line.visible = visible - self._gamma_handle.visible = visible - - def set_colormap(self, cmap: cmap.Colormap) -> None: - if self._hist_mesh is not None: - self._hist_mesh.color = cmap.color_stops[-1].color.hex - - def set_gamma(self, gamma: float) -> None: - if gamma < 0: - raise ValueError("gamma must be non-negative!") - self._gamma = gamma - self._update_lut_ctrls() - - def set_clims(self, clims: tuple[float, float]) -> None: - if clims[1] < clims[0]: - clims = (clims[1], clims[0]) - self._clims = clims - self._update_lut_ctrls() - - def set_clim_policy(self, policy: ClimPolicy) -> None: - if isinstance(policy, ClimsManual): - self.set_clims((policy.min, policy.max)) - # Otherwise, nothing to do (yet) - return - - # ------------- HistogramView Protocol methods ------------- # - - def set_data(self, values: np.ndarray, bin_edges: np.ndarray) -> None: - """Set the histogram values and bin edges. - - These inputs follow the same format as the return value of numpy.histogram. - """ - self._values, self._bin_edges = values, bin_edges - self._update_histogram() - camera_rect = self.plot.camera.rect - y_max = float(np.max(values)) if len(values) > 0 else 1.0 - self._resize(x=(camera_rect.left, camera_rect.right), y=(0, y_max)) - - def set_clim_bounds( - self, - bounds: tuple[float | None, float | None] = (None, None), - ) -> None: - self.plot.camera.xbounds = bounds - - def set_range( - self, - x: tuple[float, float] | None = None, - y: tuple[float, float] | None = None, - z: tuple[float, float] | None = None, - margin: float = 0, - ) -> None: - if x: - x = (min(x), max(x)) - elif self._bin_edges is not None: - x = self._bin_edges[0], self._bin_edges[-1] - if y: - y = (min(y), max(y)) - elif self._values is not None: - y = (0, np.max(self._values)) - self._range = y - self._domain = x - self._resize() - - def set_vertical(self, vertical: bool) -> None: - self._vertical = vertical - self._update_histogram() - self.plot.lock_axis("x" if vertical else "y") - # When vertical, smaller values should appear at the top of the canvas - self.plot.camera.flip = [False, vertical, False] - self._update_lut_ctrls() - self._resize() - - def set_log_base(self, base: float | None) -> None: - if base != self._log_base: - self._log_base = base - # Update histogram - self._update_histogram() - # Resize vertical axis - camera_rect = self.plot.camera.rect - self._resize(x=(camera_rect.left, camera_rect.right)) - - # Swap ticker for log-scale y-axis labels - count_axis = self.plot.yaxis if not self._vertical else self.plot.xaxis - if base is not None: - count_axis.axis.ticker = LogTicker(count_axis.axis, base=base) - else: - count_axis.axis.ticker = Ticker(count_axis.axis) - - def frontend_widget(self) -> Any: - return self._canvas.native - - 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.plot._view.scene.transform.imap(pos_xy)[:3] # type: ignore [no-any-return] - - def elements_at(self, pos_xy: tuple[float, float]) -> list: - raise NotImplementedError - - def highlight(self, value: float | None) -> None: - self._highlight.visible = value is not None - self._highlight_tform.translate = (value,) - - return super().highlight(value) - - # ------------- Private methods ------------- # - - def _update_histogram(self) -> None: - """ - Updates the displayed histogram with current View parameters. - - NB: Much of this code is graciously borrowed from: - - https://github.com/vispy/vispy/blob/af847424425d4ce51f144a4d1c75ab4033fe39be/vispy/visuals/histogram.py#L28 - """ - if self._values is None or self._bin_edges is None: - return # pragma: no cover - values = self._values - if self._log_base: - # Use a count+1 histogram to gracefully handle 0, 1 - values = np.log(values + 1) / np.log(self._log_base) - - verts, faces = _hist_counts_to_mesh(values, self._bin_edges, self._vertical) - self._hist_mesh.set_data(vertices=verts, faces=faces) - - # FIXME: This should be called internally upon set_data, right? - # Looks like https://github.com/vispy/vispy/issues/1899 - self._hist_mesh._bounds_changed() - - if self._vertical: - scale = values.max() / 0.98 - self._handle_transform.scale = (scale, 1) - else: - scale = values.max() / 0.98 - self._handle_transform.scale = (1, scale) - - def _update_lut_ctrls(self, npoints: int = 256) -> None: - """ - Updates the DOMAIN of the lut controls. - - Note that the RANGE is automatically scaled in _resize() - """ - if self._clims is None or self._gamma is None: - return # pragma: no cover - - # 2 additional points for each of the two vertical clims lines - X = np.empty(npoints + 4) - Y = np.empty(npoints + 4) - if self._vertical: - # clims lines - X[0:2], Y[0:2] = (1, 0.5), self._clims[0] - X[-2:], Y[-2:] = (0.5, 0), self._clims[1] - # gamma line - X[2:-2] = np.linspace(0, 1, npoints) ** self._gamma - Y[2:-2] = np.linspace(self._clims[0], self._clims[1], npoints) - midpoint = np.array([(2**-self._gamma, np.mean(self._clims))]) - else: - # clims lines - X[0:2], Y[0:2] = self._clims[0], (1, 0.5) - X[-2:], Y[-2:] = self._clims[1], (0.5, 0) - # gamma line - X[2:-2] = np.linspace(self._clims[0], self._clims[1], npoints) - Y[2:-2] = np.linspace(0, 1, npoints) ** self._gamma - midpoint = np.array([(np.mean(self._clims), 2**-self._gamma)]) - - # TODO: Move to self.edit_cmap - color = np.linspace(0.2, 0.8, npoints + 4).repeat(4).reshape(-1, 4) - c1, c2 = [0.4] * 4, [0.7] * 4 - color[0:3] = [c1, c2, c1] - color[-3:] = [c1, c2, c1] - - self._lut_line.set_data((X, Y), marker_size=0, color=color) - - self._gamma_handle_pos[:] = midpoint[0] - self._gamma_handle.set_data(pos=self._gamma_handle_pos) - - # FIXME: These should be called internally upon set_data, right? - # Looks like https://github.com/vispy/vispy/issues/1899 - self._lut_line._bounds_changed() - for v in self._lut_line._subvisuals: - v._bounds_changed() - self._gamma_handle._bounds_changed() - - def get_cursor(self, event: MouseMoveEvent) -> CursorType: - pos = (event.x, event.y) - nearby = self._find_nearby_node(pos) - - if nearby in [Grabbable.LEFT_CLIM, Grabbable.RIGHT_CLIM]: - return CursorType.V_ARROW if self._vertical else CursorType.H_ARROW - elif nearby is Grabbable.GAMMA: - return CursorType.H_ARROW if self._vertical else CursorType.V_ARROW - else: - x, y = self._to_plot_coords(pos) - x1, x2 = self.plot.xaxis.axis.domain - y1, y2 = self.plot.yaxis.axis.domain - if (x1 < x <= x2) and (y1 <= y <= y2): - return CursorType.ALL_ARROW - else: - return CursorType.DEFAULT - - def on_mouse_press(self, event: MousePressEvent) -> bool: - pos = event.x, event.y - # check whether the user grabbed a node - self._grabbed = self._find_nearby_node(pos) - if self._grabbed != Grabbable.NONE: - # disconnect the pan/zoom mouse events until handle is dropped - self.plot.camera.interactive = False - return False - - def on_mouse_double_press(self, event: MousePressEvent) -> bool: - pos = event.x, event.y - # check whether the user grabbed a node - self._grabbed = self._find_nearby_node(pos) - if self._grabbed == Grabbable.GAMMA: - if self.model: - self.model.gamma = 1 - return False - - def on_mouse_release(self, event: MouseReleaseEvent) -> bool: - self._grabbed = Grabbable.NONE - self.plot.camera.interactive = True - return False - - def on_mouse_move(self, event: MouseMoveEvent) -> bool: - """Called whenever mouse moves over canvas.""" - pos = event.x, event.y - if self._clims is None: - return False # pragma: no cover - - if self._grabbed in [Grabbable.LEFT_CLIM, Grabbable.RIGHT_CLIM]: - if self._vertical: - c = self._to_plot_coords(pos)[1] - else: - c = self._to_plot_coords(pos)[0] - if self._grabbed in [Grabbable.LEFT_CLIM, Grabbable.RIGHT_CLIM]: - # determine new contrast limits - if self._grabbed is Grabbable.LEFT_CLIM: - newlims = [min(self._clims[1], c), self._clims[1]] - else: # RIGHT_CLIM - newlims = [self._clims[0], max(self._clims[0], c)] - # Update model - if self.model: - # The model may impose bounds on the contrast limits - if min_bound := self.model.clim_bounds[0]: - newlims[0] = max(newlims[0], min_bound) - if max_bound := self.model.clim_bounds[1]: - newlims[1] = min(newlims[1], max_bound) - # Set clims - self.model.clims = ClimsManual(min=newlims[0], max=newlims[1]) - return False - - if self._grabbed is Grabbable.GAMMA: - y0, y1 = ( - self.plot.xaxis.axis.domain - if self._vertical - else self.plot.yaxis.axis.domain - ) - y = self._to_plot_coords(pos)[0 if self._vertical else 1] - if y < np.maximum(y0, 0) or y > y1: - return False - if self.model: - self.model.gamma = max(MIN_GAMMA, -np.log2(y / y1)) - return False - - self.get_cursor(event).apply_to(self) - return False - - def _find_nearby_node( - self, pos: tuple[float, float], tolerance: int = 5 - ) -> Grabbable: - """Describes whether the event is near a clim.""" - click_x, click_y = pos - - # NB Computations are performed in canvas-space - # for easier tolerance computation. - plot_to_canvas = self.node_tform.imap - gamma_to_plot = self._handle_transform.map - - if self._clims is not None: - if self._vertical: - click = click_y - right = plot_to_canvas([0, self._clims[1]])[1] - left = plot_to_canvas([0, self._clims[0]])[1] - else: - click = click_x - right = plot_to_canvas([self._clims[1], 0])[0] - left = plot_to_canvas([self._clims[0], 0])[0] - - # Right bound always selected on overlap - if bool(abs(right - click) < tolerance): - return Grabbable.RIGHT_CLIM - if bool(abs(left - click) < tolerance): - return Grabbable.LEFT_CLIM - - if self._gamma_handle_pos is not None: - gx, gy = plot_to_canvas(gamma_to_plot(self._gamma_handle_pos[0]))[:2] - if bool(abs(gx - click_x) < tolerance and abs(gy - click_y) < tolerance): - return Grabbable.GAMMA - - return Grabbable.NONE - - def _to_plot_coords(self, pos: Sequence[float]) -> tuple[float, float]: - """Return the plot coordinates of the given position.""" - x, y = self.node_tform.map(pos)[:2] - return x, y - - def _resize( - self, x: tuple[float, float] | None = None, y: tuple[float, float] | None = None - ) -> None: - if x is None: - # User specified - x = self._range if self._vertical else self._domain - if x is None and self._bin_edges is not None: - # Data-specified - x = (self._bin_edges[0], self._bin_edges[-1]) - - if y is None: - # User specified - y = self._domain if self._vertical else self._range - if y is None: - # Data-specified - y = (0, self._handle_transform.scale[0 if self._vertical else 1]) - - # Transform the count-axis range to match log-transformed mesh data - if self._log_base and y is not None: - y = (y[0], np.log(y[1] + 1) / np.log(self._log_base)) - if self._log_base and x is not None and self._vertical: - x = (x[0], np.log(x[1] + 1) / np.log(self._log_base)) - - self.plot.camera.set_range( - x=x, - y=y, - # FIXME: Bitten by https://github.com/vispy/vispy/issues/1483 - # It's pretty visible in logarithmic mode - margin=1e-30, - ) - # Grow y-axis width if the count labels need more space - if not self._vertical and y is not None: - self.plot.update_yaxis_width(y) - - def setVisible(self, visible: bool) -> None: ... - - -def _hist_counts_to_mesh( - values: Sequence[float] | npt.NDArray, - bin_edges: Sequence[float] | npt.NDArray, - vertical: bool = False, -) -> tuple[npt.NDArray[np.float32], npt.NDArray[np.uint32]]: - """Convert histogram counts to mesh vertices and faces for plotting.""" - n_edges = len(bin_edges) - X, Y = (1, 0) if vertical else (0, 1) - - # 4-5 - # | | - # 1-2/7-8 - # |/| | | - # 0-3-6-9 - # construct vertices - vertices = np.zeros((3 * n_edges - 2, 3), np.float32) - vertices[:, X] = np.repeat(bin_edges, 3)[1:-1] - vertices[1::3, Y] = values - vertices[2::3, Y] = values - vertices[vertices == float("-inf")] = 0 - - # construct triangles - faces = np.zeros((2 * n_edges - 2, 3), np.uint32) - offsets = 3 * np.arange(n_edges - 1, dtype=np.uint32)[:, np.newaxis] - faces[::2] = np.array([0, 2, 1]) + offsets - faces[1::2] = np.array([2, 0, 3]) + offsets - return vertices, faces diff --git a/src/ndv/views/_vispy/_plot_widget.py b/src/ndv/views/_vispy/_plot_widget.py deleted file mode 100644 index 872262f3..00000000 --- a/src/ndv/views/_vispy/_plot_widget.py +++ /dev/null @@ -1,500 +0,0 @@ -from __future__ import annotations - -from enum import Enum -from typing import TYPE_CHECKING, Any, Generic, Literal, TypedDict, cast - -import numpy as np -from vispy import geometry, scene -from vispy.visuals.axis import Ticker - -if TYPE_CHECKING: - from typing import TypeVar - - from vispy.scene.events import SceneMouseEvent - - # just here cause vispy has poor type hints - T = TypeVar("T") - - class Grid(scene.Grid, Generic[T]): - def add_view( - self, - row: int | None = None, - col: int | None = None, - row_span: int = 1, - col_span: int = 1, - **kwargs: Any, - ) -> scene.ViewBox: - return super().add_view(...) # pyright: ignore[reportReturnType] - - def add_widget( - self, - widget: None | scene.Widget = None, - row: int | None = None, - col: int | None = None, - row_span: int = 1, - col_span: int = 1, - **kwargs: Any, - ) -> scene.Widget: - return super().add_widget(...) - - def __getitem__(self, idxs: int | tuple[int, int]) -> T: - return super().__getitem__(idxs) # type: ignore [no-any-return] - - class WidgetKwargs(TypedDict, total=False): - pos: tuple[float, float] - size: tuple[float, float] - border_color: str - border_width: float - bgcolor: str - padding: float - margin: float - - class TextVisualKwargs(TypedDict, total=False): - text: str - color: str - bold: bool - italic: bool - face: str - font_size: float - pos: tuple[float, float] | tuple[float, float, float] - rotation: float - method: Literal["cpu", "gpu"] - depth_test: bool - - class AxisWidgetKwargs(TypedDict, total=False): - orientation: Literal["left", "bottom"] - tick_direction: tuple[int, int] - axis_color: str - tick_color: str - text_color: str - minor_tick_length: float - major_tick_length: float - tick_width: float - tick_label_margin: float - tick_font_size: float - axis_width: float - axis_label: str - axis_label_margin: float - axis_font_size: float - font_size: float # overrides tick_font_size and axis_font_size - - -__all__ = ["LogTicker", "PlotWidget"] - -# Quantized width steps for axis sizing (only grows, snaps to these values) -_AXIS_WIDTH_STEPS = (24, 34, 44, 54) - - -class LogTicker(Ticker): - """Ticker that displays log-scale labels on a log-transformed axis. - - The axis domain is assumed to be in log-transformed space (i.e., values are - already log(count+1)/log(base)). This ticker places major ticks at positions - corresponding to powers of 10 in the original count space, and labels them - with the original counts (e.g., 1, 10, 100, 1000). - """ - - def __init__(self, axis: Any, base: float = 2, anchors: Any = None) -> None: - super().__init__(axis, anchors=anchors) - self._log_base = base - - def _get_tick_frac_labels(self) -> tuple[Any, Any, list[str]]: - domain = self.axis.domain - if domain[1] < domain[0]: - flip = True - domain = domain[::-1] - else: - flip = False - - d_min, d_max = domain - scale = d_max - d_min - if scale == 0: - return np.array([]), np.array([]), [] - - # Convert domain back to original counts - # domain is in log_base space: val = log(count+1)/log(base) - # so count = base^val - 1 - log_b = np.log(self._log_base) - count_min = self._log_base**d_min - 1 - count_max = self._log_base**d_max - 1 - - # Generate major ticks at powers of 10 - if count_max <= 0: - return np.array([]), np.array([]), [] - - min_exp = int(np.floor(np.log10(max(count_min, 1)))) - max_exp = int(np.ceil(np.log10(max(count_max, 1)))) - # Always include 0 - major_counts = [0.0] - for exp in range(min_exp, max_exp + 1): - val = 10.0**exp - if val > count_min and val <= count_max * 1.01: - major_counts.append(val) - - major_counts_arr = np.array(major_counts) - # Convert counts to log-transformed positions - major_pos = np.log(major_counts_arr + 1) / log_b - # Normalize to fractions - major_frac = (major_pos - d_min) / scale - - labels = [f"{c:g}" for c in major_counts_arr] - - # Minor ticks: place at 2, 3, ..., 9 within each decade - minor_list: list[float] = [] - for exp in range(min_exp, max_exp + 1): - for mult in [2, 3, 4, 5, 6, 7, 8, 9]: - val = mult * 10.0**exp - if count_min < val <= count_max: - pos = np.log(val + 1) / log_b - frac = (pos - d_min) / scale - minor_list.append(frac) - minor_frac = np.array(minor_list) if minor_list else np.array([]) - - # Filter to visible range - mask = (major_frac > -0.0001) & (major_frac < 1.0001) - major_frac = major_frac[mask] - labels = [lb for i, lb in enumerate(labels) if mask[i]] - if len(minor_frac) > 0: - minor_frac = minor_frac[(minor_frac > -0.0001) & (minor_frac < 1.0001)] - - if flip: - major_frac = 1 - major_frac - minor_frac = 1 - minor_frac - - return major_frac, minor_frac, labels - - -DEFAULT_AXIS_KWARGS: AxisWidgetKwargs = { - "text_color": "w", - "axis_color": "w", - "tick_color": "w", - "tick_width": 1, - "tick_font_size": 6, - "tick_label_margin": 6, - "axis_label_margin": 50, - "minor_tick_length": 2, - "major_tick_length": 4, - "axis_width": 1, - "axis_font_size": 8, -} - - -class Component(str, Enum): - PAD_LEFT = "pad_left" - PAD_RIGHT = "pad_right" - PAD_BOTTOM = "pad_bottom" - TITLE = "title" - CBAR_TOP = "cbar_top" - CBAR_LEFT = "cbar_left" - CBAR_RIGHT = "cbar_right" - CBAR_BOTTOM = "cbar_bottom" - YAXIS = "yaxis" - XAXIS = "xaxis" - XLABEL = "xlabel" - YLABEL = "ylabel" - - def __str__(self) -> str: - return self.value - - -class PlotWidget(scene.Widget): - """Widget to facilitate plotting. - - Parameters - ---------- - fg_color : str - The default color for the plot. - xlabel : str - The x-axis label. - ylabel : str - The y-axis label. - title : str - The title of the plot. - lock_axis : {'x', 'y', None} - Prevent panning and zooming along a particular axis. - **widget_kwargs : dict - Keyword arguments to pass to the parent class. - """ - - def __init__( - self, - fg_color: str = "k", - xlabel: str = "", - ylabel: str = "", - title: str = "", - lock_axis: Literal["x", "y", None] = None, - **widget_kwargs: Any, - ) -> None: - self._fg_color = fg_color - self._visuals: list[scene.VisualNode] = [] - super().__init__(**widget_kwargs) - self.unfreeze() - self.grid = cast("Grid", self.add_grid(spacing=0, margin=0)) - - title_kwargs: TextVisualKwargs = {"font_size": 14, "color": "w"} - label_kwargs: TextVisualKwargs = {"font_size": 10, "color": "w"} - self._title = scene.Label(str(title), **title_kwargs) - self._xlabel = scene.Label(str(xlabel), **label_kwargs) - self._ylabel = scene.Label(str(ylabel), rotation=-90, **label_kwargs) - - axis_kwargs: AxisWidgetKwargs = DEFAULT_AXIS_KWARGS - self.yaxis = scene.AxisWidget(orientation="left", **axis_kwargs) - self.xaxis = scene.AxisWidget( - orientation="bottom", **{**axis_kwargs, "tick_label_margin": 12} - ) - - # 2D Plot layout: - # - # c0 c1 c2 c3 c4 c5 c6 - # +----------+-------+-------+-------+---------+---------+-----------+ - # r0 | | | title | | | - # | +-----------------------+---------+---------+ | - # r1 | | | cbar | | | - # |----------+-------+-------+-------+---------+---------+ ----------| - # r2 | pad_left | cbar | ylabel| yaxis | view | cbar | pad_right | - # |----------+-------+-------+-------+---------+---------+ ----------| - # r3 | | | xaxis | | | - # | +-----------------------+---------+---------+ | - # r4 | | | xlabel | | | - # | +-----------------------+---------+---------+ | - # r5 | | | cbar | | | - # |---------+------------------------+---------+---------+-----------| - # r6 | | pad_bottom | | - # +---------+------------------------+---------+---------+-----------+ - - self._grid_wdgs: dict[Component, scene.Widget] = {} - for name, row, col, widget in [ - (Component.PAD_LEFT, 2, 0, None), - (Component.PAD_RIGHT, 2, 6, None), - (Component.PAD_BOTTOM, 6, 4, None), - (Component.TITLE, 0, 4, self._title), - (Component.CBAR_TOP, 1, 4, None), - (Component.CBAR_LEFT, 2, 1, None), - (Component.CBAR_RIGHT, 2, 5, None), - (Component.CBAR_BOTTOM, 5, 4, None), - (Component.YAXIS, 2, 3, self.yaxis), - (Component.XAXIS, 3, 4, self.xaxis), - (Component.XLABEL, 4, 4, self._xlabel), - (Component.YLABEL, 2, 2, self._ylabel), - ]: - self._grid_wdgs[name] = wdg = self.grid.add_widget(widget, row=row, col=col) - # If we don't set max size, they will expand to fill the entire grid - # occluding pretty much everything else. - if str(name).startswith(("cbar", "pad")): - if name in { - Component.PAD_LEFT, - Component.PAD_RIGHT, - Component.CBAR_LEFT, - Component.CBAR_RIGHT, - }: - wdg.width_max = 2 - else: - wdg.height_max = 2 - - # The main view into which plots are added - self._view = self.grid.add_view(row=2, col=4) - - # NOTE: `width_max` and `height_max` of 2 is actually *less* visible - # than 0 for some reason. They should also be extracted into some sort - # of `hide/show` logic for each component - self._yaxis_width = _AXIS_WIDTH_STEPS[0] - self._grid_wdgs[Component.YAXIS].width_max = self._yaxis_width - self._grid_wdgs[Component.PAD_LEFT].width_max = 2 - self._grid_wdgs[Component.XAXIS].height_max = 14 - self.ylabel = ylabel - self.xlabel = xlabel - self.title = title - - # VIEWBOX (this has to go last, see vispy #1748) - self.camera = self._view.camera = PanZoom1DCamera(lock_axis) - # this has to come after camera is set - self.xaxis.link_view(self._view) - self.yaxis.link_view(self._view) - self.freeze() - - @property - def title(self) -> str: - """The title label.""" - return self._title.text # type: ignore [no-any-return] - - @title.setter - def title(self, text: str) -> None: - """Set the title of the plot.""" - self._title.text = text - wdg = self._grid_wdgs[Component.TITLE] - wdg.height_min = wdg.height_max = 30 if text else 2 - - @property - def xlabel(self) -> str: - """The x-axis label.""" - return self._xlabel.text # type: ignore [no-any-return] - - @xlabel.setter - def xlabel(self, text: str) -> None: - """Set the x-axis label.""" - self._xlabel.text = text - wdg = self._grid_wdgs[Component.XLABEL] - wdg.height_min = wdg.height_max = 40 if text else 2 - - @property - def ylabel(self) -> str: - """The y-axis label.""" - return self._ylabel.text # type: ignore [no-any-return] - - @ylabel.setter - def ylabel(self, text: str) -> None: - """Set the x-axis label.""" - self._ylabel.text = text - wdg = self._grid_wdgs[Component.YLABEL] - wdg.width_min = wdg.width_max = 20 if text else 2 - - def update_yaxis_width(self, domain: tuple[float, float] | None = None) -> None: - """Update y-axis width to fit tick labels.""" - if domain is None: - domain = cast("tuple[float, float]", self.yaxis.axis.domain) - # Estimate the widest tick label (ticks are integers on a histogram) - max_val = round(max(abs(domain[0]), abs(domain[1]))) - label = str(max_val) - # ~5px per character + padding for tick marks - needed = len(label) * 5 + 10 - # Snap to the nearest quantized step - for step in _AXIS_WIDTH_STEPS: - if step >= needed: - needed = step - break - else: - needed = _AXIS_WIDTH_STEPS[-1] - if needed != self._yaxis_width: - self._yaxis_width = needed - self._grid_wdgs[Component.YAXIS].width_max = needed - - def lock_axis(self, axis: Literal["x", "y", None]) -> None: - """Prevent panning and zooming along a particular axis.""" - self.camera._axis = axis - # self.camera.set_range() - - -class PanZoom1DCamera(scene.cameras.PanZoomCamera): - """Camera that allows panning and zooming along one axis only. - - Parameters - ---------- - axis : {'x', 'y', None} - The axis along which to allow panning and zooming. - *args : tuple - Positional arguments to pass to the parent class. - **kwargs : dict - Keyword arguments to pass to the parent class. - """ - - def __init__( - self, axis: Literal["x", "y", None] = None, *args: Any, **kwargs: Any - ) -> None: - self._axis: Literal["x", "y", None] = axis - # Domain bounds - user can specify min/max for both axes - self.xbounds: tuple[float | None, float | None] = (None, None) - self.ybounds: tuple[float | None, float | None] = (None, None) - super().__init__(*args, **kwargs) - - @property - def axis_index(self) -> Literal[0, 1, None]: - """Return the index of the axis along which to pan and zoom.""" - if self._axis in ("x", 0): - return 0 - elif self._axis in ("y", 1): - return 1 - return None - - @scene.cameras.PanZoomCamera.rect.setter # type: ignore[untyped-decorator] - def rect(self, args: Any) -> None: - """Setter for the camera rect.""" - # Convert 4-tuple (x, y, w, h) to Rect - if isinstance(args, tuple): - args = geometry.Rect(*args) - if isinstance(args, geometry.Rect): - # Note that this code preserves camera width so long as the - # desired width is possible given the bounds. This is why - # width clamping must come before the checks against each bound. - - # Constrain width and height within bounds - if None not in self.xbounds: - max_width = self.xbounds[1] - self.xbounds[0] # type: ignore[operator] - args.width = min(args.width, max_width) - if None not in self.ybounds: - max_height = self.ybounds[1] - self.ybounds[0] # type: ignore[operator] - args.height = min(args.height, max_height) - - # Constrain position+/-radius within bounds - x, y = args.pos - if self.xbounds[0] is not None: - x = max(x, self.xbounds[0]) - if self.xbounds[1] is not None: - x = min(x, self.xbounds[1] - args.width) - if self.ybounds[0] is not None: - y = max(y, self.ybounds[0]) - if self.ybounds[1] is not None: - y = min(y, self.ybounds[1] - args.height) - - args.pos = (x, y) - super(PanZoom1DCamera, type(self)).rect.fset(self, args) # pyright: ignore[reportAttributeAccessIssue] - - def zoom( - self, - factor: float | tuple[float, float], - center: tuple[float, ...] | None = None, - ) -> None: - """Zoom the camera by `factor` around `center`.""" - if self.axis_index is None: - super().zoom(factor, center=center) - return - - if isinstance(factor, (float, int)): - factor = (factor, factor) - _factor = list(factor) - _factor[self.axis_index] = 1 - super().zoom(_factor, center=center) - - def pan(self, *pan: float) -> None: - """Pan the camera by `pan`.""" - if self.axis_index is None: - super().pan(*pan) - return - _pan = list(np.ravel(pan)) - if self.axis_index < len(_pan): - _pan[self.axis_index] = 0 - super().pan(*_pan) - - def set_range( - self, - x: tuple | None = None, - y: tuple | None = None, - z: tuple | None = None, - margin: float = 0, # overriding to create a different default from super() - ) -> None: - """Reset the camera view to the specified range.""" - super().set_range(x, y, z, margin) - - def viewbox_mouse_event(self, event: SceneMouseEvent) -> None: - if event.type == "mouse_wheel": - dx, dy = event.delta - if abs(dx) > abs(dy): - # Horizontal scroll -> pan - pan_dist = 0.1 * self.rect.width - self.pan(*[pan_dist if dx < 0 else -pan_dist, 0]) - event.handled = True - return - # Vertical scroll -> zoom anchored at the current minimum - # (only scale the max end of the free axis) - s = 1.1 ** (-dy) - rect = self.rect - if self._axis in ("y", 1): - # Free axis is x: keep left, scale width - new_w = rect.width * s - self.rect = geometry.Rect(rect.left, rect.bottom, new_w, rect.height) - else: - # Free axis is y (or None): keep bottom, scale height - new_h = rect.height * s - self.rect = geometry.Rect(rect.left, rect.bottom, rect.width, new_h) - event.handled = True - return - super().viewbox_mouse_event(event) diff --git a/src/ndv/views/_vispy/_shared_histogram.py b/src/ndv/views/_vispy/_shared_histogram.py deleted file mode 100644 index 3cab3c0a..00000000 --- a/src/ndv/views/_vispy/_shared_histogram.py +++ /dev/null @@ -1,578 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Any, cast - -import numpy as np -from vispy import scene, visuals - -from ndv._types import CursorType -from ndv.views._app import filter_mouse_events -from ndv.views.bases import SharedHistogramCanvas -from ndv.views.bases._graphics._histogram_utils import ( - _NO_KEY, - LUT_LINE_ALPHA, - Grabbable, - apply_log_counts, - area_to_mesh, - clamp_clim_drag, - compute_x_range, - compute_y_range, - downsample_histogram, - find_nearest_grabbable, - gamma_from_mouse_y, - gamma_handle_pos, - y_top_from_range, -) - -from ._plot_widget import LogTicker, PlotWidget - -if TYPE_CHECKING: - from collections.abc import Sequence - - from ndv._types import ( - ChannelKey, - MouseMoveEvent, - MousePressEvent, - MouseReleaseEvent, - ) - -FILL_ALPHA = 0.3 - - -@dataclass -class _ChannelVisuals: - """All visuals for a single channel on the shared histogram.""" - - area_mesh: scene.Mesh # pyright: ignore[reportInvalidTypeForm] - outline: scene.LinePlot # pyright: ignore[reportInvalidTypeForm] - lut_line: scene.LinePlot # pyright: ignore[reportInvalidTypeForm] - gamma_handle: scene.Markers # pyright: ignore[reportInvalidTypeForm] - legend_text: scene.Text # pyright: ignore[reportInvalidTypeForm] - # per-channel state - color: tuple = (1, 1, 1, 1) - clims: tuple[float, float] | None = None - gamma: float = 1.0 - counts: np.ndarray | None = None - bin_edges: np.ndarray | None = None - visible: bool = True - name: str = "" - # downsampled data for display - _display_centers: np.ndarray | None = field(default=None, repr=False) - _display_counts: np.ndarray | None = field(default=None, repr=False) - - -class VispySharedHistogramCanvas(SharedHistogramCanvas): - """Shared multi-channel histogram using VisPy.""" - - def __init__(self) -> None: - self._channels: dict[object, _ChannelVisuals] = {} - self._log_base: float | None = None - self._grabbed: Grabbable = Grabbable.NONE - self._grabbed_key: object = _NO_KEY - self._clim_bounds: tuple[float | None, float | None] = (None, None) - - # VisPy canvas and plot - self._canvas = scene.SceneCanvas() - self._disconnect_mouse_events = filter_mouse_events(self._canvas.native, self) - - # Per-channel highlight lines (created on demand) - self._highlight_lines: dict[object, visuals.LineVisual] = {} - self._highlight_unit_pos = np.array([[0, 0], [0, 1]], dtype=np.float32) - - self.plot = PlotWidget() - self.plot.lock_axis("y") - # Minimize left-side spacing - from ._plot_widget import Component - - self.plot._grid_wdgs[Component.YLABEL].width_max = 2 - self.plot._grid_wdgs[Component.PAD_LEFT].width_max = 0 - # Start with a narrow y-axis (will grow as needed via update_yaxis_width) - self.plot._yaxis_width = 14 - self.plot._grid_wdgs[Component.YAXIS].width_max = 14 - self._canvas.central_widget.add_widget(self.plot) - self.node_tform = cast("scene.Node", self.plot).node_transform( - self.plot._view.scene - ) - - self._has_initial_range = False - self._redownsampling = False - self._canvas.events.resize.connect(self._on_canvas_resize) - self._canvas.events.draw.connect(self._on_draw) - self._last_cam_rect: tuple[float, float] = (0.0, 0.0) # (left, right) - - # ------------ GraphicsCanvas methods ------------ # - - def refresh(self) -> None: - self._canvas.update() - - def set_range( - self, - x: tuple[float, float] | None = None, - y: tuple[float, float] | None = None, - z: tuple[float, float] | None = None, - margin: float = 0, - ) -> None: - if x is None: - x = self._compute_x_range() - if y is None: - y = self._compute_y_range() - if x and y: - self.plot.camera.set_range(x=x, y=y, margin=1e-30) - self.plot.update_yaxis_width(y) - - def canvas_to_world( - self, pos_xy: tuple[float, float] - ) -> tuple[float, float, float]: - return self.plot._view.scene.transform.imap(pos_xy)[:3] # type: ignore[no-any-return] - - def elements_at(self, pos_xy: tuple[float, float]) -> list: - raise NotImplementedError - - def set_visible(self, visible: bool) -> None: ... - - def close(self) -> None: - self._disconnect_mouse_events() - self._canvas.close() - - def frontend_widget(self) -> Any: - return self._canvas.native - - # ------------ SharedHistogramCanvas methods ------------ # - - def set_channel_data( - self, key: ChannelKey, counts: np.ndarray, bin_edges: np.ndarray - ) -> None: - ch = self._ensure_channel(key) - ch.counts = counts - ch.bin_edges = bin_edges - self._update_channel_area(key) - if not self._has_initial_range: - self._has_initial_range = True - self._auto_range() - else: - self._auto_range_y_only() - - def set_channel_color(self, key: ChannelKey, color: tuple) -> None: - ch = self._ensure_channel(key) - ch.color = color - self._apply_channel_colors(key) - - def set_channel_visible(self, key: ChannelKey, visible: bool) -> None: - ch = self._channels.get(key) - if ch is None: - return - ch.visible = visible - ch.area_mesh.visible = visible - ch.outline.visible = visible - ch.lut_line.visible = visible - ch.gamma_handle.visible = visible - ch.legend_text.visible = visible - self._update_legend_positions() - self._auto_range_y_only() - - def set_channel_clims(self, key: ChannelKey, clims: tuple[float, float]) -> None: - ch = self._channels.get(key) - if ch is None: - return - ch.clims = clims - self._update_lut_visuals(key) - - def set_channel_gamma(self, key: ChannelKey, gamma: float) -> None: - ch = self._channels.get(key) - if ch is None: - return - ch.gamma = gamma - self._update_lut_visuals(key) - - def remove_channel(self, key: ChannelKey) -> None: - ch = self._channels.pop(key, None) - if ch is None: - return - for visual in ( - ch.area_mesh, - ch.outline, - ch.lut_line, - ch.gamma_handle, - ch.legend_text, - ): - visual.parent = None - if (hl := self._highlight_lines.pop(key, None)) is not None: - hl.parent = None - self._update_legend_positions() - self._auto_range() - - def set_channel_name(self, key: ChannelKey, name: str) -> None: - ch = self._channels.get(key) - if ch is None: - return - ch.name = name - ch.legend_text.text = name - self._update_legend_positions() - - def set_log_base(self, base: float | None) -> None: - if base == self._log_base: - return - self._log_base = base - # Re-render all channels - for key in self._channels: - self._update_channel_area(key) - # Swap ticker - count_axis = self.plot.yaxis - if base is not None: - count_axis.axis.ticker = LogTicker(count_axis.axis, base=base) - else: - from vispy.visuals.axis import Ticker - - count_axis.axis.ticker = Ticker(count_axis.axis) - self._auto_range_y_only() - - def set_clim_bounds(self, bounds: tuple[float | None, float | None]) -> None: - self._clim_bounds = bounds - self.plot.camera.xbounds = bounds - - def highlight(self, channel_values: dict[object, float]) -> None: - y_range = self._compute_y_range() - y_scale = y_range[1] * 0.5 if y_range else 1.0 - for key, line in self._highlight_lines.items(): - if key not in channel_values: - line.visible = False - for key, value in channel_values.items(): - if (line := self._highlight_lines.get(key)) is None: - ch = self._channels.get(key) - color = (*ch.color[:3], 0.5) if ch else (1, 1, 0.2, 0.5) - line = scene.Line(pos=self._highlight_unit_pos, color=color, width=1) - self.plot._view.add(line) - line.transform = scene.transforms.STTransform() - self._highlight_lines[key] = line - line.visible = True - line.transform.translate = (value, 0, 0, 0) - line.transform.scale = (1, y_scale, 1, 1) - - # ------------ Mouse interaction ------------ # - - def get_cursor(self, event: MouseMoveEvent) -> CursorType: - pos = (event.x, event.y) - _key, nearby = self._find_nearest_grabbable(pos) - if nearby in (Grabbable.LEFT_CLIM, Grabbable.RIGHT_CLIM): - return CursorType.H_ARROW - elif nearby is Grabbable.GAMMA: - return CursorType.V_ARROW - else: - x, y = self._to_plot_coords(pos) - x1, x2 = cast("tuple[float, float]", self.plot.xaxis.axis.domain) - y1, y2 = cast("tuple[float, float]", self.plot.yaxis.axis.domain) - if (x1 < x <= x2) and (y1 <= y <= y2): - return CursorType.ALL_ARROW - return CursorType.DEFAULT - - def on_mouse_press(self, event: MousePressEvent) -> bool: - pos = event.x, event.y - self._grabbed_key, self._grabbed = self._find_nearest_grabbable(pos) - if self._grabbed != Grabbable.NONE: - self.plot.camera.interactive = False - return False - - def on_mouse_double_press(self, event: MousePressEvent) -> bool: - pos = event.x, event.y - key, nearby = self._find_nearest_grabbable(pos) - if nearby == Grabbable.GAMMA and key is not _NO_KEY: - self.gammaChanged.emit(key, 1.0) - return False - - def on_mouse_release(self, event: MouseReleaseEvent) -> bool: - self._grabbed = Grabbable.NONE - self._grabbed_key = _NO_KEY - self.plot.camera.interactive = True - return False - - def on_mouse_move(self, event: MouseMoveEvent) -> bool: - pos = event.x, event.y - key = self._grabbed_key - if key is _NO_KEY or self._grabbed == Grabbable.NONE: - self.get_cursor(event).apply_to(self) - return False - - ch = self._channels.get(key) - if ch is None or ch.clims is None: - return False - - if self._grabbed in (Grabbable.LEFT_CLIM, Grabbable.RIGHT_CLIM): - c = self._to_plot_coords(pos)[0] - new_clims = clamp_clim_drag(self._grabbed, c, ch.clims, self._clim_bounds) - self.climsChanged.emit(key, new_clims) - return False - - if self._grabbed is Grabbable.GAMMA: - y = self._to_plot_coords(pos)[1] - gamma = gamma_from_mouse_y(y, self._compute_y_range()) - if gamma is None: - return False - self.gammaChanged.emit(key, gamma) - return False - - self.get_cursor(event).apply_to(self) - return False - - # ------------ Private helpers ------------ # - - def _ensure_channel(self, key: object) -> _ChannelVisuals: - """Get or create channel visuals.""" - if key in self._channels: - return self._channels[key] - - area_mesh = scene.Mesh(color=(0.5, 0.5, 0.5, FILL_ALPHA)) - area_mesh.set_gl_state("translucent", depth_test=False) - outline = scene.LinePlot( - data=(0,), - color="w", - connect="strip", - symbol=None, - line_kind="-", - width=1.2, - marker_size=0, - ) - lut_line = scene.LinePlot( - data=(0,), - color="w", - connect="strip", - symbol=None, - line_kind="-", - width=1.0, - marker_size=0, - ) - lut_line.visible = False - lut_line.order = -1 - - gamma_handle = scene.Markers( - pos=np.array([[0, 0]]), - size=6, - edge_width=0, - ) - gamma_handle.visible = False - gamma_handle.order = -2 - - legend_text = scene.Text( - text="", - color="w", - font_size=8, - anchor_x="right", - anchor_y="top", - parent=self._canvas.scene, - ) - legend_text.order = -4 - - self.plot._view.add(area_mesh) - self.plot._view.add(outline) - self.plot._view.add(lut_line) - self.plot._view.add(gamma_handle) - - ch = _ChannelVisuals( - area_mesh=area_mesh, - outline=outline, - lut_line=lut_line, - gamma_handle=gamma_handle, - legend_text=legend_text, - ) - self._channels[key] = ch - self._update_legend_positions() - return ch - - def _apply_channel_colors(self, key: object) -> None: - """Apply color to all visuals for a channel.""" - ch = self._channels[key] - r, g, b = ch.color[:3] - a = ch.color[3] if len(ch.color) > 3 else 1.0 - ch.legend_text.color = (r, g, b, a) - # Re-render area and LUT visuals with new color - self._update_channel_area(key) - self._update_lut_visuals(key) - - def _update_channel_area(self, key: object) -> None: - """Re-render area fill + outline for a channel.""" - ch = self._channels.get(key) - if ch is None or ch.counts is None or ch.bin_edges is None: - return - - canvas_w = max(self._canvas.size[0], 64) - visible = self._visible_x_range() - centers, display_counts = downsample_histogram( - ch.counts, - ch.bin_edges, - max_display_bins=canvas_w, - visible_range=visible, - ) - ch._display_centers = centers - ch._display_counts = display_counts - counts = apply_log_counts(display_counts, self._log_base) - - r, g, b = ch.color[:3] - - # Area fill mesh: triangle strip from centers to baseline - verts, faces = area_to_mesh(centers, counts) - ch.area_mesh.set_data(vertices=verts, faces=faces, color=(r, g, b, FILL_ALPHA)) - ch.area_mesh._bounds_changed() - - # Outline - outline_data = np.column_stack([centers, counts]) - ch.outline.set_data(outline_data, color=(r, g, b, 1.0), marker_size=0) - ch.outline._bounds_changed() - for v in ch.outline._subvisuals: - v._bounds_changed() - - def _update_lut_visuals(self, key: object, npoints: int = 64) -> None: - """Update clim lines and gamma curve for a channel.""" - ch = self._channels.get(key) - if ch is None or ch.clims is None: - return - - r, g, b = ch.color[:3] - clims = ch.clims - gamma = ch.gamma - y_top = y_top_from_range(self._compute_y_range()) - - # Build the LUT line: left clim line + gamma curve + right clim line - # 2 points for each vertical clim line + npoints for gamma curve - X = np.empty(npoints + 4) - Y = np.empty(npoints + 4) - - # Left clim line (vertical, full height) - X[0:2] = clims[0] - Y[0:2] = (y_top, y_top * 0.5) - # Gamma curve - X[2:-2] = np.linspace(clims[0], clims[1], npoints) - Y[2:-2] = np.linspace(0, 1, npoints) ** gamma * y_top - # Right clim line (vertical, full height) - X[-2:] = clims[1] - Y[-2:] = (y_top * 0.5, 0) - - color = np.full((npoints + 4, 4), (r, g, b, LUT_LINE_ALPHA)) - ch.lut_line.set_data((X, Y), color=color, marker_size=0) - ch.lut_line.visible = ch.visible - ch.lut_line._bounds_changed() - for v in ch.lut_line._subvisuals: - v._bounds_changed() - - # Gamma handle at midpoint - mid_x, mid_y = gamma_handle_pos(clims, gamma, y_top) - ch.gamma_handle.set_data( - pos=np.array([[mid_x, mid_y]]), - face_color=(r, g, b, 1.0), - size=6, - edge_width=0, - ) - ch.gamma_handle.visible = ch.visible - ch.gamma_handle._bounds_changed() - - def _compute_x_range(self) -> tuple[float, float] | None: - return compute_x_range(self._channels) - - def _compute_y_range(self) -> tuple[float, float] | None: - return compute_y_range(self._channels, self._log_base) - - def _auto_range(self) -> None: - """Auto-fit camera to encompass all visible data.""" - x = self._compute_x_range() - y = self._compute_y_range() - if x and y: - self.plot.camera.set_range(x=x, y=y, margin=1e-30) - self.plot.update_yaxis_width(y) - self._refresh_all_lut_visuals() - self._update_legend_positions() - - def _auto_range_y_only(self) -> None: - """Update y range only, preserving current x pan/zoom.""" - y = self._compute_y_range() - if y: - camera_rect = self.plot.camera.rect - self.plot.camera.set_range( - x=(camera_rect.left, camera_rect.right), y=y, margin=1e-30 - ) - self.plot.update_yaxis_width(y) - self._refresh_all_lut_visuals() - self._update_legend_positions() - - def _visible_x_range(self) -> tuple[float, float] | None: - """Get the currently visible x-range from the camera.""" - if not self._has_initial_range: - return None - r = self.plot.camera.rect - return (r.left, r.right) - - def _redownsample_all(self) -> None: - """Re-downsample all channels for the current visible range.""" - for key in self._channels: - self._update_channel_area(key) - # Refit y-axis to the visible data - y = self._compute_y_range() - if y: - camera_rect = self.plot.camera.rect - self.plot.camera.set_range( - x=(camera_rect.left, camera_rect.right), y=y, margin=1e-30 - ) - self.plot.update_yaxis_width(y) - self._refresh_all_lut_visuals() - - def _refresh_all_lut_visuals(self) -> None: - """Re-render clim/gamma visuals for all channels.""" - for key in self._channels: - self._update_lut_visuals(key) - - def _on_draw(self, event: Any = None) -> None: - """Re-downsample when camera pans/zooms. - - Guard against an infinite draw loop caused by vispy's set_range - adding a tiny margin on every call (https://github.com/vispy/vispy/issues/1483). - We use margin=1e-30 to avoid the 0.1 fallback, but each set_range - still shifts the rect by ~1e-28. Without the guard, this creates: - _on_draw -> _redownsample_all -> set_range (shifts rect) -> draw -> ... - The _redownsampling flag blocks synchronous re-entrant draws (wx), - and re-reading the rect after redownsampling absorbs the drift so - deferred draws (Qt) don't see it as a change. - """ - if self._redownsampling: - return - r = self.plot.camera.rect - cam_rect = (r.left, r.right) - if cam_rect != self._last_cam_rect: - self._last_cam_rect = cam_rect - self._redownsampling = True - try: - self._redownsample_all() - finally: - r = self.plot.camera.rect - self._last_cam_rect = (r.left, r.right) - self._redownsampling = False - - def _on_canvas_resize(self, event: Any = None) -> None: - self._update_legend_positions() - - def _update_legend_positions(self) -> None: - """Position legend entries horizontally at the top-right.""" - # Build entries right-to-left so last channel is rightmost - canvas_w = self._canvas.size[0] - x_offset = canvas_w - 8 - for ch in reversed(list(self._channels.values())): - if not ch.visible or not ch.name: - ch.legend_text.visible = False - continue - ch.legend_text.visible = True - ch.legend_text.text = f"● {ch.name}" - ch.legend_text.pos = (x_offset, 14) - x_offset -= len(ch.name) * 7 + 18 # approximate width - - def _find_nearest_grabbable( - self, pos: tuple[float, float], tolerance: int = 5 - ) -> tuple[object, Grabbable]: - imap = self.node_tform.imap - return find_nearest_grabbable( - self._channels, - pos, - lambda x, y: tuple(imap([x, y])[:2]), - self._compute_y_range(), - tolerance, - ) - - def _to_plot_coords(self, pos: Sequence[float]) -> tuple[float, float]: - x, y = self.node_tform.map(pos)[:2] - return x, y diff --git a/src/ndv/views/_vispy/_util.py b/src/ndv/views/_vispy/_util.py deleted file mode 100644 index ab7ead76..00000000 --- a/src/ndv/views/_vispy/_util.py +++ /dev/null @@ -1,55 +0,0 @@ -from __future__ import annotations - -from contextlib import contextmanager -from functools import lru_cache -from typing import TYPE_CHECKING - -from vispy.app import Canvas -from vispy.gloo import gl -from vispy.gloo.context import get_current_canvas - -if TYPE_CHECKING: - from collections.abc import Generator - - -@contextmanager -def _opengl_context() -> Generator[None, None, None]: - """Assure we are running with a valid OpenGL context. - - Only create a Canvas is one doesn't exist. Creating and closing a - Canvas causes vispy to process Qt events which can cause problems. - """ - canvas = Canvas(show=False) if get_current_canvas() is None else None - try: - yield - finally: - if canvas is not None: - canvas.close() - - -@lru_cache -def get_max_texture_sizes() -> tuple[int | None, int | None]: - """Return the maximum texture sizes for 2D and 3D rendering. - - Returns - ------- - Tuple[int | None, int | None] - The max textures sizes for (2d, 3d) rendering. - """ - with _opengl_context(): - max_size_2d = gl.glGetParameter(gl.GL_MAX_TEXTURE_SIZE) - - if not max_size_2d: - max_size_2d = None - - # vispy/gloo doesn't provide the GL_MAX_3D_TEXTURE_SIZE location, - # but it can be found in this list of constants - # http://pyopengl.sourceforge.net/documentation/pydoc/OpenGL.GL.html - with _opengl_context(): - GL_MAX_3D_TEXTURE_SIZE = 32883 - max_size_3d = gl.glGetParameter(GL_MAX_3D_TEXTURE_SIZE) - - if not max_size_3d: - max_size_3d = None - - return max_size_2d, max_size_3d diff --git a/src/ndv/views/_wx/_app.py b/src/ndv/views/_wx/_app.py index 05ed3425..7be3b657 100644 --- a/src/ndv/views/_wx/_app.py +++ b/src/ndv/views/_wx/_app.py @@ -3,25 +3,7 @@ from typing import TYPE_CHECKING, Any import wx -from wx import ( - EVT_LEAVE_WINDOW, - EVT_LEFT_DCLICK, - EVT_LEFT_DOWN, - EVT_LEFT_UP, - EVT_MOTION, - EVT_MOUSE_CAPTURE_LOST, - MouseEvent, -) - -from ndv._types import ( - KeyCode, - KeyMod, - KeyPressEvent, - MouseButton, - MouseMoveEvent, - MousePressEvent, - MouseReleaseEvent, -) + from ndv.views.bases._app import NDVApp from ._main_thread import call_in_main_thread @@ -32,7 +14,6 @@ from ndv.views.bases import ArrayView from ndv.views.bases._app import P, T - from ndv.views.bases._graphics._mouseable import Mouseable _app = None @@ -71,131 +52,6 @@ def array_view_class(self) -> type[ArrayView]: return WxArrayView - def filter_mouse_events( - self, canvas: Any, receiver: Mouseable - ) -> Callable[[], None]: - if not isinstance(canvas, wx.Window): - raise TypeError(f"Expected canvas to be wx.Window, got {type(canvas)}") - - # TIP: event.Skip() allows the event to propagate to other handlers. - - active_button: MouseButton = MouseButton.NONE - - def on_mouse_move(event: MouseEvent) -> None: - nonlocal active_button - nonlocal canvas - - mme = MouseMoveEvent(x=event.GetX(), y=event.GetY(), btn=active_button) - if not receiver.on_mouse_move(mme): - receiver.mouseMoved.emit(mme) - event.Skip() - # FIXME: get_cursor is VERY slow, unsure why. - if cursor := receiver.get_cursor(mme): - canvas.SetCursor(cursor.to_wx()) - - def on_mouse_leave(event: MouseEvent) -> None: - nonlocal active_button - nonlocal canvas - - if not receiver.on_mouse_leave(): - event.Skip() - receiver.mouseLeft.emit() - - def on_mouse_press(event: MouseEvent) -> None: - nonlocal active_button - - # NB This function is bound to the left mouse button press - active_button = MouseButton.LEFT - mpe = MousePressEvent(x=event.GetX(), y=event.GetY(), btn=active_button) - if not receiver.on_mouse_press(mpe): - receiver.mousePressed.emit(mpe) - event.Skip() - # Capture mouse so we receive the button-up even if the cursor - # leaves the canvas (fixes stuck-pan on Windows). - if not canvas.HasCapture(): - canvas.CaptureMouse() - - def on_mouse_double_press(event: MouseEvent) -> None: - nonlocal active_button - - # NB This function is bound to the left mouse button press - active_button = MouseButton.LEFT - mpe = MousePressEvent(x=event.GetX(), y=event.GetY(), btn=active_button) - if not receiver.on_mouse_double_press(mpe): - receiver.mouseDoublePressed.emit(mpe) - event.Skip() - if not canvas.HasCapture(): - canvas.CaptureMouse() - - def on_mouse_release(event: MouseEvent) -> None: - nonlocal active_button - - mre = MouseReleaseEvent(x=event.GetX(), y=event.GetY(), btn=active_button) - active_button = MouseButton.NONE - if not receiver.on_mouse_release(mre): - receiver.mouseReleased.emit(mre) - event.Skip() - if canvas.HasCapture(): - canvas.ReleaseMouse() - - def on_mouse_capture_lost(event: wx.MouseCaptureLostEvent) -> None: - nonlocal active_button - active_button = MouseButton.NONE - - canvas.Bind(EVT_MOTION, handler=on_mouse_move) - canvas.Bind(EVT_LEAVE_WINDOW, handler=on_mouse_leave) - canvas.Bind(EVT_LEFT_DOWN, handler=on_mouse_press) - canvas.Bind(EVT_LEFT_DCLICK, handler=on_mouse_double_press) - canvas.Bind(EVT_LEFT_UP, handler=on_mouse_release) - canvas.Bind(EVT_MOUSE_CAPTURE_LOST, handler=on_mouse_capture_lost) - - def _unbind() -> None: - if canvas.HasCapture(): - canvas.ReleaseMouse() - canvas.Unbind(EVT_MOTION, handler=on_mouse_move) - canvas.Unbind(EVT_LEAVE_WINDOW, handler=on_mouse_leave) - canvas.Unbind(EVT_LEFT_DOWN, handler=on_mouse_press) - canvas.Unbind(EVT_LEFT_DCLICK, handler=on_mouse_double_press) - canvas.Unbind(EVT_LEFT_UP, handler=on_mouse_release) - canvas.Unbind(EVT_MOUSE_CAPTURE_LOST, handler=on_mouse_capture_lost) - - return _unbind - - def filter_key_events(self, widget: Any, receiver: ArrayView) -> Callable[[], None]: - if not isinstance(widget, wx.Window): - raise TypeError(f"Expected widget to be wx.Window, got {type(widget)}") - - def on_key_down(event: wx.KeyEvent) -> None: - key_code = event.GetKeyCode() - key: KeyCode | str - if key_code in _WX_KEY_MAP: - key = _WX_KEY_MAP[key_code] - else: - uchar = event.GetUnicodeKey() - if uchar != wx.WXK_NONE: - key = chr(uchar) - else: - event.Skip() - return - mods = KeyMod.NONE - if event.ShiftDown(): - mods |= KeyMod.SHIFT - if event.ControlDown(): - mods |= KeyMod.CTRL - if event.AltDown(): - mods |= KeyMod.ALT - if event.MetaDown(): - mods |= KeyMod.META - receiver.keyPressed.emit(KeyPressEvent(key, mods)) - event.Skip() - - widget.Bind(wx.EVT_CHAR_HOOK, handler=on_key_down) - - def _unbind() -> None: - widget.Unbind(wx.EVT_CHAR_HOOK, handler=on_key_down) - - return _unbind - def process_events(self) -> None: """Process events.""" wx.SafeYield() @@ -203,14 +59,3 @@ def process_events(self) -> None: def call_later(self, msec: int, func: Callable[[], None]) -> None: """Call `func` after `msec` milliseconds.""" wx.CallLater(msec, func) - - -_WX_KEY_MAP: dict[int, KeyCode] = { - wx.WXK_UP: KeyCode.UP, - wx.WXK_DOWN: KeyCode.DOWN, - wx.WXK_LEFT: KeyCode.LEFT, - wx.WXK_RIGHT: KeyCode.RIGHT, - wx.WXK_SPACE: KeyCode.SPACE, - wx.WXK_HOME: KeyCode.HOME, - wx.WXK_END: KeyCode.END, -} diff --git a/src/ndv/views/_wx/_array_view.py b/src/ndv/views/_wx/_array_view.py index 228a6bb5..3ee23b5b 100644 --- a/src/ndv/views/_wx/_array_view.py +++ b/src/ndv/views/_wx/_array_view.py @@ -25,7 +25,7 @@ from collections.abc import Container, Hashable, Mapping, Sequence from ndv._types import AxisKey, ChannelKey - from ndv.views.bases._graphics._canvas import HistogramCanvas + from ndv.views._histogram import Histogram ToggleBtnEvent = cast("int", wx.EVT_TOGGLEBUTTON.typeId) # type: ignore[attr-defined] @@ -338,7 +338,7 @@ def __init__( super().__init__() self._wxwidget = wdg = _WxLUTWidget(parent, default_luts) self.channel = channel - self.histogram: HistogramCanvas | None = None + self.histogram: Histogram | None = None self._displayed = True # whether shown in channel selector wdg.visible.Bind(wx.EVT_CHECKBOX, self._on_visible_changed) @@ -411,7 +411,7 @@ def _on_set_histogram_range_clicked(self, event: wx.CommandEvent) -> None: if hist := self.histogram: hist.set_range() - def _add_histogram(self, histogram: HistogramCanvas) -> None: + def _add_histogram(self, histogram: Histogram) -> None: widget = cast("wx.Window", histogram.frontend_widget()) if (parent := widget.GetParent()) and parent is not self._wxwidget: @@ -466,7 +466,10 @@ def set_clims(self, clims: tuple[float, float]) -> None: # Block signals from changing clims with wx.EventBlocker(self._wxwidget.clims, SliderEvent): self._wxwidget.clims.SetValue(*clims) - wx.SafeYield() + # FIXME: Is this used for anything? + # Seems to result in MouseLeaveEvents being propagated to the histogram + # during drags + # wx.SafeYield() def set_clim_bounds( self, @@ -833,7 +836,7 @@ def add_lut_view(self, channel: ChannelKey) -> WxLUTView: return view # TODO: Fix type - def add_histogram(self, channel: ChannelKey, widget: HistogramCanvas) -> None: + def add_histogram(self, channel: ChannelKey, widget: Histogram) -> None: if lut := self._luts.get(channel, None): # Add the histogram widget on the LUT lut._add_histogram(widget) @@ -841,7 +844,7 @@ def add_histogram(self, channel: ChannelKey, widget: HistogramCanvas) -> None: def add_shared_histogram(self, widget: Any) -> None: self._shared_histogram = widget - frontend = cast("wx.Window", widget.frontend_widget()) + frontend = cast("wx.Window", widget) old_parent = frontend.GetParent() if old_parent and old_parent is not self._wxwidget: old_parent.Hide() diff --git a/src/ndv/views/bases/__init__.py b/src/ndv/views/bases/__init__.py index bc3b453e..05f8f5f5 100644 --- a/src/ndv/views/bases/__init__.py +++ b/src/ndv/views/bases/__init__.py @@ -4,23 +4,13 @@ from ._app import NDVApp from ._array_view import ArrayView -from ._graphics._canvas import ArrayCanvas, HistogramCanvas, SharedHistogramCanvas -from ._graphics._canvas_elements import CanvasElement, ImageHandle, RectangularROIHandle -from ._graphics._mouseable import Mouseable from ._lut_view import LUTView from ._view_base import Viewable __all__ = [ - "ArrayCanvas", "ArrayView", - "CanvasElement", - "HistogramCanvas", - "ImageHandle", "LUTView", - "Mouseable", "NDVApp", - "RectangularROIHandle", - "SharedHistogramCanvas", "Viewable", ] diff --git a/src/ndv/views/bases/_app.py b/src/ndv/views/bases/_app.py index a87b3d57..be46df58 100644 --- a/src/ndv/views/bases/_app.py +++ b/src/ndv/views/bases/_app.py @@ -19,7 +19,6 @@ from typing_extensions import ParamSpec, TypeVar from ndv.views.bases import ArrayView - from ndv.views.bases._graphics._mouseable import Mouseable T = TypeVar("T") P = ParamSpec("P") @@ -46,16 +45,6 @@ def run(self) -> None: """Run the application.""" pass - def filter_mouse_events( - self, canvas: Any, receiver: Mouseable - ) -> Callable[[], None]: - """Install mouse event filter on `canvas`, redirecting events to `receiver`.""" - raise NotImplementedError - - def filter_key_events(self, widget: Any, receiver: ArrayView) -> Callable[[], None]: - """Install key event filter on `widget`, emitting `receiver.keyPressed`.""" - raise NotImplementedError - def call_in_main_thread( self, func: Callable[P, T], *args: P.args, **kwargs: P.kwargs ) -> Future[T]: diff --git a/src/ndv/views/bases/_array_view.py b/src/ndv/views/bases/_array_view.py index 472aa80b..da28aaad 100644 --- a/src/ndv/views/bases/_array_view.py +++ b/src/ndv/views/bases/_array_view.py @@ -6,7 +6,6 @@ from psygnal import Signal -from ndv._types import KeyPressEvent from ndv.models._array_display_model import ChannelMode from ._view_base import Viewable @@ -16,6 +15,7 @@ from ndv._types import AxisKey, ChannelKey from ndv.models._viewer_model import ArrayViewerModel + from ndv.views._histogram import Histogram from ndv.views.bases import LUTView @@ -31,9 +31,9 @@ class ArrayView(Viewable): resetZoomClicked = Signal() histogramRequested = Signal(int) sharedHistogramRequested = Signal() + sharedHistogramLogRequested = Signal(int) ndimToggleRequested = Signal(bool) channelModeChanged = Signal(ChannelMode) - keyPressed = Signal(KeyPressEvent) @abstractmethod def __init__( @@ -73,7 +73,7 @@ def add_lut_view(self, key: ChannelKey) -> LUTView: ... @abstractmethod def remove_lut_view(self, view: LUTView) -> None: ... - def add_histogram(self, channel: ChannelKey, widget: Any) -> None: + def add_histogram(self, channel: ChannelKey, widget: Histogram) -> None: raise NotImplementedError def remove_histogram(self, widget: Any) -> None: diff --git a/src/ndv/views/bases/_graphics/__init__.py b/src/ndv/views/bases/_graphics/__init__.py deleted file mode 100644 index 37e488c6..00000000 --- a/src/ndv/views/bases/_graphics/__init__.py +++ /dev/null @@ -1,14 +0,0 @@ -"""Base classes for graphics elements.""" - -from ._canvas import ArrayCanvas, HistogramCanvas -from ._canvas_elements import CanvasElement, ImageHandle, RectangularROIHandle -from ._mouseable import Mouseable - -__all__ = [ - "ArrayCanvas", - "CanvasElement", - "HistogramCanvas", - "ImageHandle", - "Mouseable", - "RectangularROIHandle", -] diff --git a/src/ndv/views/bases/_graphics/_canvas.py b/src/ndv/views/bases/_graphics/_canvas.py deleted file mode 100644 index fe6c5619..00000000 --- a/src/ndv/views/bases/_graphics/_canvas.py +++ /dev/null @@ -1,141 +0,0 @@ -from __future__ import annotations - -from abc import abstractmethod -from typing import TYPE_CHECKING, Literal - -import numpy as np -from psygnal import Signal - -from ndv.views.bases._lut_view import LUTView -from ndv.views.bases._view_base import Viewable - -from ._mouseable import Mouseable - -if TYPE_CHECKING: - import numpy as np - - from ndv._types import ChannelKey - from ndv.models._viewer_model import ArrayViewerModel - - from ._canvas_elements import CanvasElement, ImageHandle, RectangularROIHandle - - -class GraphicsCanvas(Viewable, Mouseable): - """ABC for graphics canvas providers.""" - - @abstractmethod - def refresh(self) -> None: ... - @abstractmethod - def set_range( - self, - x: tuple[float, float] | None = None, - y: tuple[float, float] | None = None, - z: tuple[float, float] | None = None, - margin: float = ..., - ) -> None: - """Sets the bounds of the camera.""" - ... - - def zoom(self, factor: float | tuple, center: tuple[float, float]) -> None: - """Zoom in (or out) at the given center (world coordinates).""" - - @abstractmethod - 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.""" - - @abstractmethod - def elements_at(self, pos_xy: tuple[float, float]) -> list[CanvasElement]: ... - - -# TODO: These classes will probably be merged and refactored in the future. - - -class ArrayCanvas(GraphicsCanvas): - """ABC for canvases that show array data.""" - - @abstractmethod - def __init__(self, viewer_model: ArrayViewerModel | None = ...) -> None: ... - @abstractmethod - def set_ndim(self, ndim: Literal[2, 3]) -> None: ... - @abstractmethod - @abstractmethod - def add_image(self, data: np.ndarray | None = ...) -> ImageHandle: ... - @abstractmethod - def add_volume(self, data: np.ndarray | None = ...) -> ImageHandle: ... - @abstractmethod - def add_bounding_box(self) -> RectangularROIHandle: ... - - def set_scales(self, scales: tuple[float, ...]) -> None: - """Set per-visible-axis scale factors for rendering.""" - - -class HistogramCanvas(GraphicsCanvas, LUTView): - """A histogram-based view for LookUp Table (LUT) adjustment.""" - - def set_vertical(self, vertical: bool) -> None: - """If True, orient axes vertically (x-axis on left).""" - - def set_log_base(self, base: float | None) -> None: - """Sets the axis scale of the range. - - Properties - ---------- - enabled : bool - If true, the range will be displayed with a logarithmic (base 10) - scale. If false, the range will be displayed with a linear scale. - """ - - def set_data(self, values: np.ndarray, bin_edges: np.ndarray) -> None: - """Sets the histogram data. - - Properties - ---------- - values : np.ndarray - The histogram values. - bin_edges : np.ndarray - The bin edges of the histogram. - """ - - def highlight(self, value: float | None) -> None: - """Highlights a domain value on the histogram.""" - - -class SharedHistogramCanvas(GraphicsCanvas): - """Multi-channel overlay histogram with per-channel clim/gamma controls.""" - - climsChanged = Signal(object, tuple) - gammaChanged = Signal(object, float) - - def set_channel_data( - self, key: ChannelKey, counts: np.ndarray, bin_edges: np.ndarray - ) -> None: - """Set or update histogram data for a channel.""" - - def set_channel_color(self, key: ChannelKey, color: tuple) -> None: - """Set the display color (RGBA) for a channel.""" - - def set_channel_visible(self, key: ChannelKey, visible: bool) -> None: - """Show or hide a channel on the histogram.""" - - def set_channel_clims(self, key: ChannelKey, clims: tuple[float, float]) -> None: - """Update the clim line positions for a channel.""" - - def set_channel_gamma(self, key: ChannelKey, gamma: float) -> None: - """Update the gamma curve for a channel.""" - - def remove_channel(self, key: ChannelKey) -> None: - """Remove a channel from the histogram.""" - - def set_channel_name(self, key: ChannelKey, name: str) -> None: - """Set the display name for a channel (used in legend).""" - - def set_clim_bounds(self, bounds: tuple[float | None, float | None]) -> None: - """Set global bounds for clim values and x-axis range.""" - - def set_log_base(self, base: float | None) -> None: - """Set logarithmic scale base, or None for linear.""" - - def highlight(self, channel_values: dict[object, float]) -> None: - """Highlight domain values across channels.""" diff --git a/src/ndv/views/bases/_graphics/_canvas_elements.py b/src/ndv/views/bases/_graphics/_canvas_elements.py deleted file mode 100644 index 2b346eb0..00000000 --- a/src/ndv/views/bases/_graphics/_canvas_elements.py +++ /dev/null @@ -1,108 +0,0 @@ -from __future__ import annotations - -from abc import abstractmethod -from enum import Enum, auto -from typing import TYPE_CHECKING - -from psygnal import Signal - -from ndv.views.bases._lut_view import LUTView - -from ._mouseable import Mouseable - -if TYPE_CHECKING: - from typing import Any - - import cmap as _cmap - import numpy as np - - from ndv.models._lut_model import ClimPolicy - - -class CanvasElement(Mouseable): - """Protocol defining an interactive element on the Canvas.""" - - @abstractmethod - def visible(self) -> bool: - """Defines whether the element is visible on the canvas.""" - - @abstractmethod - def set_visible(self, visible: bool) -> None: - """Sets element visibility.""" - - @abstractmethod - def can_select(self) -> bool: - """Defines whether the element can be selected.""" - - @abstractmethod - def selected(self) -> bool: - """Returns element selection status.""" - - @abstractmethod - def set_selected(self, selected: bool) -> None: - """Sets element selection status.""" - - def remove(self) -> None: - """Removes the element from the canvas.""" - - -class ImageHandle(CanvasElement, LUTView): - @abstractmethod - def data(self) -> np.ndarray: ... - @abstractmethod - def set_data(self, data: np.ndarray) -> None: ... - @abstractmethod - def clims(self) -> tuple[float, float]: ... - @abstractmethod - def set_clims(self, clims: tuple[float, float]) -> None: ... - @abstractmethod - def gamma(self) -> float: ... - @abstractmethod - def set_gamma(self, gamma: float) -> None: ... - @abstractmethod - def colormap(self) -> _cmap.Colormap: ... - @abstractmethod - def set_colormap(self, cmap: _cmap.Colormap) -> None: ... - - # -- LUTView methods -- # - def close(self) -> None: - self.remove() - - def frontend_widget(self) -> Any: - return None - - def set_channel_name(self, name: str) -> None: - pass - - def set_clim_policy(self, policy: ClimPolicy) -> None: - pass - - def set_channel_visible(self, visible: bool) -> None: - self.set_visible(visible) - - -class ROIMoveMode(Enum): - """Describes graphical mechanisms for ROI translation.""" - - HANDLE = auto() # Moving one handle (but not all) - TRANSLATE = auto() # Translating everything - - -class RectangularROIHandle(CanvasElement): - """An axis-aligned rectanglular ROI.""" - - boundingBoxChanged = Signal(tuple[tuple[float, float], tuple[float, float]]) - - def set_bounding_box( - self, minimum: tuple[float, float], maximum: tuple[float, float] - ) -> None: - """Sets the bounding box.""" - - def set_fill(self, color: _cmap.Color) -> None: - """Sets the fill color.""" - - def set_border(self, color: _cmap.Color) -> None: - """Sets the border color.""" - - def set_handles(self, color: _cmap.Color) -> None: - """Sets the handle face color.""" diff --git a/src/ndv/views/bases/_graphics/_histogram_utils.py b/src/ndv/views/bases/_graphics/_histogram_utils.py deleted file mode 100644 index 288ce18d..00000000 --- a/src/ndv/views/bases/_graphics/_histogram_utils.py +++ /dev/null @@ -1,290 +0,0 @@ -"""Shared utilities for histogram canvas implementations.""" - -from __future__ import annotations - -from enum import Enum, auto -from typing import TYPE_CHECKING - -import numpy as np - -if TYPE_CHECKING: - from collections.abc import Callable, Mapping - - import numpy.typing as npt - -LUT_LINE_ALPHA = 0.6 - - -class Grabbable(Enum): - NONE = auto() - LEFT_CLIM = auto() - RIGHT_CLIM = auto() - GAMMA = auto() - - -# ------------ Histogram data helpers ------------ # - - -def downsample_histogram( - counts: np.ndarray, - bin_edges: np.ndarray, - max_display_bins: int = 800, - visible_range: tuple[float, float] | None = None, -) -> tuple[np.ndarray, np.ndarray]: - """Downsample histogram for display, optionally cropping to visible range. - - Parameters - ---------- - counts : np.ndarray - Raw histogram counts. - bin_edges : np.ndarray - Raw bin edges (len = len(counts) + 1). - max_display_bins : int - Target number of bins after downsampling. - visible_range : tuple[float, float] | None - If provided, crop to this (x_lo, x_hi) range before downsampling. - A small margin of extra bins is kept so panning feels seamless. - """ - if visible_range is not None: - counts, bin_edges = _crop_histogram(counts, bin_edges, *visible_range) - - n = len(counts) - if n > max_display_bins: - factor = n // max_display_bins - trim = n - (n % factor) - counts = counts[:trim].reshape(-1, factor).mean(axis=1) - bin_edges = np.concatenate( - [bin_edges[:trim:factor], bin_edges[trim : trim + 1]] - ) - centers = (bin_edges[:-1] + bin_edges[1:]) / 2 - return centers, counts - - -def _crop_histogram( - counts: np.ndarray, - bin_edges: np.ndarray, - x_lo: float, - x_hi: float, - margin_frac: float = 0.1, -) -> tuple[np.ndarray, np.ndarray]: - """Crop histogram to the visible range plus a margin for panning.""" - span = x_hi - x_lo - margin = span * margin_frac - lo = x_lo - margin - hi = x_hi + margin - - centers = (bin_edges[:-1] + bin_edges[1:]) * 0.5 - mask = (centers >= lo) & (centers <= hi) - if not np.any(mask): - return counts, bin_edges - - idx = np.nonzero(mask)[0] - i0, i1 = idx[0], idx[-1] + 1 - return counts[i0:i1], bin_edges[i0 : i1 + 1] - - -def area_to_mesh( - centers: np.ndarray, - counts: np.ndarray, -) -> tuple[npt.NDArray[np.float32], npt.NDArray[np.uint32]]: - """Convert area plot data to mesh vertices and faces (triangle strip).""" - n = len(centers) - if n == 0: - return np.zeros((0, 3), np.float32), np.zeros((0, 3), np.uint32) - - # 2 vertices per point: one on the curve, one on the baseline - vertices = np.zeros((2 * n, 3), np.float32) - vertices[0::2, 0] = centers - vertices[0::2, 1] = counts - vertices[1::2, 0] = centers - # vertices[1::2, 1] = 0 (baseline, already 0) - - # Vectorized face generation - idx = np.arange(n - 1, dtype=np.uint32) - faces = np.zeros((2 * (n - 1), 3), np.uint32) - faces[0::2, 0] = 2 * idx # top_left - faces[0::2, 1] = 2 * idx + 1 # bot_left - faces[0::2, 2] = 2 * idx + 2 # top_right - faces[1::2, 0] = 2 * idx + 1 # bot_left - faces[1::2, 1] = 2 * idx + 3 # bot_right - faces[1::2, 2] = 2 * idx + 2 # top_right - - return vertices, faces - - -# ------------ Log scale ------------ # - - -def apply_log_counts(counts: np.ndarray, log_base: float | None) -> np.ndarray: - """Apply log transform to counts if log_base is set.""" - if log_base: - return np.log(counts + 1) / np.log(log_base) # type: ignore[no-any-return] - return counts - - -# ------------ Range computation ------------ # - - -def channel_y_max(display_counts: np.ndarray | None, log_base: float | None) -> float: - """Get the max displayed count for channel data.""" - if display_counts is None: - return 1.0 - counts = apply_log_counts(display_counts, log_base) - return float(np.max(counts)) if len(counts) > 0 else 1.0 - - -def compute_x_range( - channels: Mapping, -) -> tuple[float, float] | None: - """Compute x range across visible channels with bin_edges. - - Channels must have `.visible` and `.bin_edges` attributes. - """ - x_min, x_max = float("inf"), float("-inf") - for ch in channels.values(): - if not ch.visible or ch.bin_edges is None: - continue - x_min = min(x_min, ch.bin_edges[0]) - x_max = max(x_max, ch.bin_edges[-1]) - if x_min == float("inf"): - return None - return (float(x_min), float(x_max)) - - -def compute_y_range( - channels: Mapping, - log_base: float | None, - y_headroom: float = 1.05, -) -> tuple[float, float] | None: - """Compute y range across visible channels, with headroom. - - Channels must have `.visible` and `._display_counts` attributes. - """ - y_max = 0.0 - for ch in channels.values(): - if not ch.visible: - continue - y_max = max(y_max, channel_y_max(ch._display_counts, log_base)) - if y_max == 0: - return None - return (0, y_max * y_headroom) - - -def y_top_from_range( - y_range: tuple[float, float] | None, y_top_fraction: float = 0.98 -) -> float: - """Get the effective y_top for clim/gamma handle positioning. - - y_top_frac is Fraction of y_max used as upper bound for clim handles and gamma curve - """ - y_max = y_range[1] if y_range else 1.0 - if y_max == 0: - y_max = 1.0 - return y_max * y_top_fraction - - -# ------------ LUT geometry ------------ # - - -def gamma_handle_pos( - clims: tuple[float, float], gamma: float, y_top: float -) -> tuple[float, float]: - """Compute the (x, y) position of the gamma drag handle.""" - return float(np.mean(clims)), (2 ** (-gamma)) * y_top - - -# ------------ Mouse interaction helpers ------------ # - - -def clamp_clim_drag( - grabbed: Grabbable, - data_x: float, - current_clims: tuple[float, float], - bounds: tuple[float | None, float | None], -) -> tuple[float, float]: - """Compute new clims from a clim-handle drag at data_x.""" - lo, hi = bounds - c = data_x - if lo is not None: - c = max(c, lo) - if hi is not None: - c = min(c, hi) - if grabbed is Grabbable.LEFT_CLIM: - return (min(current_clims[1], c), current_clims[1]) - return (current_clims[0], max(current_clims[0], c)) - - -def gamma_from_mouse_y( - data_y: float, - y_range: tuple[float, float] | None, - min_gamma: float = np.float64(1e-6), -) -> float | None: - """Compute gamma from a mouse y position. Returns None if out of bounds.""" - y_top = y_top_from_range(y_range) - if data_y <= 0 or data_y > y_top: - return None - if y_top == 0: - return 1.0 - return max(float(min_gamma), -np.log2(data_y / y_top)) # type: ignore[no-any-return] - - -_NO_KEY = object() # sentinel for "no channel grabbed" - - -def find_nearest_grabbable( - channels: Mapping, - click_xy: tuple[float, float], - world_to_canvas: Callable[[float, float], tuple[float, float]], - y_range: tuple[float, float] | None, - tolerance: int = 5, -) -> tuple[object, Grabbable]: - """Find the nearest grabbable handle across all visible channels. - - Parameters - ---------- - channels : Mapping - Mapping of channel keys to objects with `.visible`, `.clims`, `.gamma`. - click_xy : tuple - Click position in canvas coordinates. - world_to_canvas : callable - Maps (data_x, data_y) to (canvas_x, canvas_y). - y_range : tuple or None - Current y range from compute_y_range. - tolerance : int - Max pixel distance to consider a handle "nearby". - """ - click_x, click_y = click_xy - best_dist = float("inf") - best_key: object = _NO_KEY - best_grab = Grabbable.NONE - - y_top = y_top_from_range(y_range) - - for key, ch in channels.items(): - if not ch.visible or ch.clims is None: - continue - - left_cx = world_to_canvas(ch.clims[0], 0)[0] - right_cx = world_to_canvas(ch.clims[1], 0)[0] - - d_right = abs(right_cx - click_x) - if d_right < tolerance and d_right < best_dist: - best_dist = d_right - best_key = key - best_grab = Grabbable.RIGHT_CLIM - - d_left = abs(left_cx - click_x) - if d_left < tolerance and d_left < best_dist: - best_dist = d_left - best_key = key - best_grab = Grabbable.LEFT_CLIM - - mid_x, mid_y = gamma_handle_pos(ch.clims, ch.gamma, y_top) - gx, gy = world_to_canvas(mid_x, mid_y) - d_gamma = ((gx - click_x) ** 2 + (gy - click_y) ** 2) ** 0.5 - if d_gamma < tolerance and d_gamma < best_dist: - best_dist = d_gamma - best_key = key - best_grab = Grabbable.GAMMA - - return best_key, best_grab diff --git a/src/ndv/views/bases/_graphics/_mouseable.py b/src/ndv/views/bases/_graphics/_mouseable.py deleted file mode 100644 index 9c469bb5..00000000 --- a/src/ndv/views/bases/_graphics/_mouseable.py +++ /dev/null @@ -1,41 +0,0 @@ -from __future__ import annotations - -from psygnal import Signal - -from ndv._types import CursorType, MouseMoveEvent, MousePressEvent, MouseReleaseEvent - - -class Mouseable: - """Mixin class for objects that can be interacted with using the mouse. - - The signals here are to be emitted by the view object that inherits this class; - usually by intercepting native mouse events with `filter_mouse_events`. - - The methods allow the object to handle its own mouse events before emitting the - signals. If the method returns `True`, the event is considered handled and should - not be passed to the next receiver in the chain. - """ - - mouseMoved = Signal(MouseMoveEvent) - mouseLeft = Signal() - mousePressed = Signal(MousePressEvent) - mouseDoublePressed = Signal(MousePressEvent) - mouseReleased = Signal(MouseReleaseEvent) - - def on_mouse_move(self, event: MouseMoveEvent) -> bool: - return False - - def on_mouse_leave(self) -> bool: - return False - - def on_mouse_double_press(self, event: MousePressEvent) -> bool: - return False - - def on_mouse_press(self, event: MousePressEvent) -> bool: - return False - - def on_mouse_release(self, event: MouseReleaseEvent) -> bool: - return False - - def get_cursor(self, event: MouseMoveEvent) -> CursorType | None: - return None diff --git a/tests/conftest.py b/tests/conftest.py index f4f44609..25af45ad 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,9 +11,11 @@ from unittest.mock import patch import pytest +from scenex.adaptors._auto import determine_backend +from scenex.app import GuiFrontend, determine_app from ndv.views import gui_frontend -from ndv.views._app import GUI_ENV_VAR, GuiFrontend +from ndv.views._app import GUI_ENV_VAR if TYPE_CHECKING: from asyncio import AbstractEventLoop @@ -23,6 +25,11 @@ from pytest import FixtureRequest from qtpy.QtWidgets import QApplication +# HACK: Enable tests inside vispy +if determine_app() == GuiFrontend.JUPYTER: + if determine_backend() == "vispy": + os.environ["_VISPY_TESTING_APP"] = "jupyter_rfb" + @pytest.fixture def asyncio_app() -> Iterator[AbstractEventLoop]: diff --git a/tests/test_app.py b/tests/test_app.py index 216129ec..0ba88836 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -22,11 +22,11 @@ def test_set_gui_backend() -> None: def test_set_canvas_backend() -> None: - backends = [ - ndv.views._app.CanvasBackend.VISPY, - ndv.views._app.CanvasBackend.PYGFX, - ] + """Ensure that ndv.set_canvas_backend passes through to scenex.""" + backends = ["pygfx", "vispy"] for backend in backends: - ndv.set_canvas_backend(backend.value) - assert ndv.views._app.canvas_backend() == backend + ndv.set_canvas_backend(backend) + from scenex.adaptors._auto import determine_backend + + assert determine_backend() == backend ndv.set_canvas_backend() diff --git a/tests/test_controller.py b/tests/test_controller.py index 0a6826d4..3446c3ef 100644 --- a/tests/test_controller.py +++ b/tests/test_controller.py @@ -11,26 +11,31 @@ import numpy as np import pytest - -from ndv._types import ( - CursorType, +import scenex as snx +from app_model.types import KeyBinding, KeyCode, KeyMod, SimpleKeyBinding +from scenex.adaptors._auto import determine_backend +from scenex.app import CursorType +from scenex.app.events import ( + KeyPressEvent, MouseButton, + MouseLeaveEvent, MouseMoveEvent, MousePressEvent, MouseReleaseEvent, + WheelEvent, ) +from scenex.utils import projections + from ndv.controllers import ArrayViewer from ndv.controllers._channel_controller import ChannelController -from ndv.models import DataWrapper +from ndv.models import DataWrapper, RectangularROIModel from ndv.models._array_display_model import ArrayDisplayModel, ChannelMode from ndv.models._lut_model import ClimsManual, ClimsMinMax, LUTModel from ndv.models._resolve import DataResponse, resolve -from ndv.models._roi_model import RectangularROIModel from ndv.models._viewer_model import InteractionMode from ndv.views import _app, gui_frontend +from ndv.views._histogram import Histogram from ndv.views.bases import ArrayView, LUTView -from ndv.views.bases._graphics._canvas import ArrayCanvas, HistogramCanvas -from ndv.views.bases._graphics._canvas_elements import ImageHandle if TYPE_CHECKING: from collections.abc import Callable @@ -42,30 +47,11 @@ IS_WIN = os.name == "nt" IS_PYSIDE6 = API_NAME == "PySide6" -IS_PYGFX = _app.canvas_backend(None) == "pygfx" - - -def _make_img_handle() -> MagicMock: - handle = MagicMock(spec=ImageHandle) - handle.data.return_value = np.zeros((10, 10)).astype(np.uint8) - return handle - - -def _make_vol_handle() -> MagicMock: - handle = MagicMock(spec=ImageHandle) - handle.data.return_value = np.zeros((10, 10, 10)).astype(np.uint8) - return handle +IS_PYGFX = determine_backend() == "pygfx" -def _get_mock_canvas(*_: Any) -> ArrayCanvas: - mock = MagicMock(spec=ArrayCanvas) - mock.add_image.side_effect = lambda *a, **k: _make_img_handle() - mock.add_volume.side_effect = lambda *a, **k: _make_vol_handle() - return mock - - -def _get_mock_hist_canvas() -> HistogramCanvas: - return MagicMock(spec=HistogramCanvas) +def _get_mock_hist_canvas() -> Histogram: + return MagicMock(spec=Histogram) def _get_mock_view(*_: Any) -> ArrayView: @@ -75,15 +61,13 @@ def _get_mock_view(*_: Any) -> ArrayView: def _patch_views(f: Callable) -> Callable: - f = patch.object(_app, "get_array_canvas_class", lambda: _get_mock_canvas)(f) f = patch.object(_app, "get_array_view_class", lambda: _get_mock_view)(f) - f = patch.object(_app, "get_histogram_canvas_class", lambda: _get_mock_hist_canvas)(f) # fmt: skip # noqa - f = patch.object(_app, "filter_key_events", lambda *a, **k: lambda: None)(f) return f @no_type_check @_patch_views +@pytest.mark.usefixtures("any_app") def test_controller() -> None: SHAPE = (10, 4, 10, 10) ctrl = ArrayViewer() @@ -150,67 +134,65 @@ def test_controller() -> None: @no_type_check @_patch_views +@pytest.mark.usefixtures("any_app") def test_canvas_interaction() -> None: SHAPE = (10, 4, 10, 10) - data = np.empty(SHAPE) + data = np.zeros(SHAPE) ctrl = ArrayViewer() ctrl._async = False - mock_canvas = ctrl._canvas + canvas = ctrl._canvas mock_view = ctrl._view ctrl.data = data ctrl._add_histogram(None) - mock_histogram = ctrl._histograms[None] + histogram = ctrl._histograms[None] - # clicking the reset zoom button calls set_range on the canvas - ctrl._on_view_reset_zoom_clicked() - mock_canvas.set_range.assert_called_once_with() + # clicking the reset zoom button calls zoom_to_fit... + with patch.object(projections, "zoom_to_fit") as mock_zoom_to_fit: + ctrl._on_view_reset_zoom_clicked() + mock_zoom_to_fit.assert_called_once() + # ...with the canvas' view + assert mock_zoom_to_fit.call_args_list[0][0][0] == ctrl._canvas.view # hovering on the image updates the hover info in the view - mock_canvas.canvas_to_world.return_value = (1, 2, 3) - ctrl._on_canvas_mouse_moved(MouseMoveEvent(1, 2)) - mock_canvas.canvas_to_world.assert_called_once_with((1, 2)) - mock_view.set_hover_info.assert_called_once_with("[2, 1] 0") - mock_histogram.highlight.assert_called_once_with(0) - - mock_canvas.reset_mock() mock_view.reset_mock() - mock_histogram.reset_mock() + pos = (canvas._canvas.width // 2, canvas._canvas.height // 2) + ctrl._view_event(MouseMoveEvent(pos=pos, buttons=MouseButton.NONE)) + world_pos = canvas.view.to_ray(pos).origin[:2] + y, x = int(world_pos[1]), int(world_pos[0]) + mock_view.set_hover_info.assert_called_once_with(f"[{y}, {x}] 0") + assert histogram.highlight_line.transform.root[3, 0] == 0 + mock_view.reset_mock() # updating the image also updates the hover info in the view - # NB Since the image handle is a mock, the data won't be updated. - ctrl.data = np.empty(SHAPE, dtype=np.uint8) + ctrl.data = np.ones(SHAPE, dtype=np.uint8) # FIXME: These methods are actually called twice, both within # _fully_synchronize_view. The first time is on # ArrayViewer._on_view_current_index_change, and the second on # ArrayViewer._request_data - mock_view.set_hover_info.assert_called_with("[2, 1] 0") - mock_histogram.highlight.assert_called_with(0) + mock_view.set_hover_info.assert_called_once_with(f"[{y}, {x}] 1") + assert histogram.highlight_line.transform.root[3, 0] == 1 - mock_canvas.reset_mock() mock_view.reset_mock() - mock_histogram.reset_mock() # hovering off the image clears the hover info in the view - mock_canvas.canvas_to_world.return_value = (-1, -1, 3) - ctrl._on_canvas_mouse_moved(MouseMoveEvent(-1, -1)) - mock_canvas.canvas_to_world.assert_called_once_with((-1, -1)) + ctrl._view_event(MouseMoveEvent(pos=(0, 0), buttons=MouseButton.NONE)) mock_view.set_hover_info.assert_called_once_with("") - mock_histogram.highlight.assert_called_once_with(None) + assert not histogram.highlight_line.visible - mock_canvas.reset_mock() mock_view.reset_mock() - mock_histogram.reset_mock() # leaving the canvas clears the hover info as well - ctrl._on_canvas_mouse_left() + ctrl._view_event(MouseLeaveEvent()) mock_view.set_hover_info.assert_called_once_with("") - mock_histogram.highlight.assert_called_once_with(None) + assert not histogram.highlight_line.visible @no_type_check @_patch_views +@patch("ndv.controllers._array_viewer.Histogram", _get_mock_hist_canvas) +@pytest.mark.usefixtures("any_app") def test_histogram_controller() -> None: ctrl = ArrayViewer() ctrl._async = False @@ -236,13 +218,12 @@ def test_histogram_controller() -> None: @no_type_check -@_patch_views +@pytest.mark.usefixtures("any_app") def test_histogram_updates_on_first_draw() -> None: """Histogram should update even when the first response creates the handle.""" ctrl = ArrayViewer() - hist = MagicMock(spec=HistogramCanvas) - ctrl._histograms[None] = hist + ctrl._histograms[None] = hist = MagicMock(spec=Histogram) lut_ctrl = ChannelController( key=None, lut_model=LUTModel(), views=[MagicMock(spec=LUTView), hist] ) @@ -295,6 +276,7 @@ def test_array_viewer_with_app() -> None: viewer.display_model.visible_axes = (0, -2, -1) visax_mock.assert_called_once() assert viewer.display_model.visible_axes == (0, -2, -1) + viewer.close() @pytest.mark.usefixtures("any_app") @@ -304,9 +286,7 @@ def test_channel_autoscale() -> None: # NB: Use a planar dataset so we can manually compute the min/max data = np.random.randint(0, 255, size=(10, 10), dtype="uint8") mi, ma = np.nanmin(data), np.nanmax(data) - handle = MagicMock(spec=ImageHandle) - handle.data.return_value = data - ctrl.add_handle(handle) + ctrl.add_image(snx.Image(data=data)) # Test some random LutController lut_model = ctrl.lut_model @@ -337,9 +317,7 @@ def test_array_viewer_histogram() -> None: assert histogram is not None # change views - if "pygfx" not in type(histogram).__name__.lower(): - histogram.set_vertical(True) - histogram.set_log_base(10) + histogram.set_log_base(10) # update data np.random.seed(0) @@ -350,6 +328,7 @@ def test_array_viewer_histogram() -> None: histogram.set_data(counts, bin_edges) histogram.close() + viewer.close() @no_type_check @@ -369,27 +348,21 @@ def test_roi_controller() -> None: # Clicking the ROI button and then clicking the canvas creates a ROI viewer.interaction_mode = InteractionMode.CREATE_ROI canvas_pos = (5, 5) - mpe = MousePressEvent(canvas_pos[0], canvas_pos[1], MouseButton.LEFT) + mpe = MousePressEvent(pos=canvas_pos, buttons=MouseButton.LEFT) # Note - avoid diving into rendering logic here - just identify view - with patch.object(ctrl._canvas, "elements_at", return_value=[ctrl._roi_view]): - ctrl._canvas.on_mouse_press(mpe) + ctrl._canvas._canvas.handle(mpe) # The creation code emits raw world coords (no pixel-center offset). # Compute expected data-space bounding box via the same pipeline: # canvas -> raw world -> _world_point_to_data - w2d = ctrl._world_point_to_data - # pygfx exposes _canvas_to_world_raw; vispy's canvas_to_world is already raw - raw_c2w = getattr( - ctrl._canvas, "_canvas_to_world_raw", ctrl._canvas.canvas_to_world - ) - raw_world = raw_c2w(canvas_pos) - expected_min = w2d(raw_world[0], raw_world[1]) - expected_max = w2d(raw_world[0] + 1, raw_world[1] + 1) + raw_world = ctrl._canvas.view.to_ray(canvas_pos).origin[:2] + expected_min = (raw_world[0], raw_world[1]) + expected_max = (raw_world[0] + 1, raw_world[1] + 1) assert roi.bounding_box[0] == pytest.approx(expected_min) assert roi.bounding_box[1] == pytest.approx(expected_max) assert viewer.interaction_mode == InteractionMode.PAN_ZOOM - ctrl._canvas.close() + ctrl.close() @no_type_check @@ -404,7 +377,7 @@ def test_roi_interaction() -> None: _app.process_events() roi = RectangularROIModel() ctrl.roi = roi - roi_view = ctrl._roi_view + roi_view = ctrl._canvas.roi_view assert roi_view is not None # FIXME: We need a large world space on the canvas, but @@ -412,7 +385,7 @@ def test_roi_interaction() -> None: # sets the range to the extent of the data i.e. the extent of the ROI. # bounding_box is in data space. roi.bounding_box = ((0, 0), (500, 500)) - ctrl._canvas.set_range() + ctrl._canvas.reset_zoom() # Set the ROI to known data-space coordinates via two canvas positions. # Note that these positions are far apart to satisfy sufficient distance @@ -424,84 +397,123 @@ def test_roi_interaction() -> None: # Use the ROI view's own boundingBoxChanged to set positions in the same # coordinate space the mouse handler uses (raw world for pygfx, scene # for vispy). This ensures the test works regardless of backend. - world_start = ctrl._canvas.canvas_to_world(canvas_roi_start)[:2] - world_end = ctrl._canvas.canvas_to_world(canvas_roi_end)[:2] - roi_view.boundingBoxChanged.emit((world_start, world_end)) + world_start = ctrl._canvas.view.to_ray(canvas_roi_start).origin[:2] + world_end = ctrl._canvas.view.to_ray(canvas_roi_end).origin[:2] + world_new_start = ctrl._canvas.view.to_ray(canvas_new_start).origin[:2] + roi_view.bb = (world_start, world_end) bb_initial = roi.bounding_box roi_size = ( bb_initial[1][0] - bb_initial[0][0], bb_initial[1][1] - bb_initial[0][1], ) - # Note - avoid diving into rendering logic here - just identify view - with patch.object(ctrl._canvas, "elements_at", return_value=[ctrl._roi_view]): - # Test moving handle: drag top-left corner to canvas_new_start - assert not roi_view.selected() - mpe = MousePressEvent( - canvas_roi_start[0], canvas_roi_start[1], MouseButton.LEFT - ) - ctrl._canvas.on_mouse_press(mpe) - assert roi_view.selected() - mme = MouseMoveEvent(canvas_new_start[0], canvas_new_start[1], MouseButton.LEFT) - ctrl._canvas.on_mouse_move(mme) - # The opposite (max) corner should not have moved - assert roi.bounding_box[1] == pytest.approx(bb_initial[1], 1e-6) - # The dragged (min) corner should have moved - assert roi.bounding_box[0] != pytest.approx(bb_initial[0], 1e-6) - mre = MouseReleaseEvent( - canvas_new_start[0], canvas_new_start[1], MouseButton.LEFT - ) - ctrl._canvas.on_mouse_release(mre) - - # Test translation: reset ROI, then drag the body - roi_view.boundingBoxChanged.emit((world_start, world_end)) - assert roi.bounding_box[0] == pytest.approx(bb_initial[0]) - assert roi.bounding_box[1] == pytest.approx(bb_initial[1]) - mpe = MousePressEvent( - (canvas_roi_start[0] + canvas_roi_end[0]) / 2, - (canvas_roi_start[1] + canvas_roi_end[1]) / 2, - MouseButton.LEFT, - ) - ctrl._canvas.on_mouse_press(mpe) - assert roi_view.selected() - mme = MouseMoveEvent( - (canvas_roi_start[0] + canvas_new_start[0]) / 2, - (canvas_roi_start[1] + canvas_new_start[1]) / 2, - MouseButton.LEFT, + # Test moving handle: drag top-left corner to canvas_new_start + assert roi_view.anchor is None + ctrl._canvas._canvas.handle( + MousePressEvent(pos=canvas_roi_start, buttons=MouseButton.LEFT) + ) + # The anchor should then be the opposite corner, i.e. world_end + np.testing.assert_array_equal(roi_view.anchor, world_end) + # Move to new position + ctrl._canvas._canvas.handle( + MouseMoveEvent(pos=canvas_new_start, buttons=MouseButton.LEFT) + ) + # The roi should now be between world_new_start and world_end (order doesn't matter) + np.testing.assert_allclose( + roi.bounding_box, + np.asarray( + ( + ( + min(world_end[0], world_new_start[0]), + min(world_end[1], world_new_start[1]), + ), + ( + max(world_end[0], world_new_start[0]), + max(world_end[1], world_new_start[1]), + ), + ) + ), + ) + ctrl._canvas._canvas.handle( + MouseReleaseEvent(pos=canvas_new_start, buttons=MouseButton.LEFT) + ) + + # Test translation: reset ROI, then drag the body + roi_view.bb = (world_start, world_end) + assert roi.bounding_box[0] == pytest.approx(bb_initial[0]) + assert roi.bounding_box[1] == pytest.approx(bb_initial[1]) + ctrl._canvas._canvas.handle( + MousePressEvent( + pos=( + (canvas_roi_start[0] + canvas_roi_end[0]) / 2, + (canvas_roi_start[1] + canvas_roi_end[1]) / 2, + ), + buttons=MouseButton.LEFT, ) - ctrl._canvas.on_mouse_move(mme) - # Translation should preserve size - bb_translated = roi.bounding_box - translated_size = ( - bb_translated[1][0] - bb_translated[0][0], - bb_translated[1][1] - bb_translated[0][1], + ) + assert roi_view.drag_start is not None + ctrl._canvas._canvas.handle( + MouseMoveEvent( + pos=( + (canvas_roi_start[0] + canvas_new_start[0]) / 2, + (canvas_roi_start[1] + canvas_new_start[1]) / 2, + ), + buttons=MouseButton.LEFT, ) - assert translated_size == pytest.approx(roi_size, 1e-6) - # Both corners should have moved - assert bb_translated[0] != pytest.approx(bb_initial[0], 1e-6) - assert bb_translated[1] != pytest.approx(bb_initial[1], 1e-6) - mre = MouseReleaseEvent( - (canvas_roi_start[0] + canvas_new_start[0]) / 2, - (canvas_roi_start[1] + canvas_new_start[1]) / 2, - MouseButton.LEFT, + ) + # Translation should preserve size + bb_translated = roi.bounding_box + translated_size = ( + bb_translated[1][0] - bb_translated[0][0], + bb_translated[1][1] - bb_translated[0][1], + ) + assert translated_size == pytest.approx(roi_size, 1e-6) + # Both corners should have moved + assert bb_translated[0] != pytest.approx(bb_initial[0], 1e-6) + assert bb_translated[1] != pytest.approx(bb_initial[1], 1e-6) + ctrl._canvas._canvas.handle( + MouseReleaseEvent( + pos=( + (canvas_roi_start[0] + canvas_new_start[0]) / 2, + (canvas_roi_start[1] + canvas_new_start[1]) / 2, + ), + buttons=MouseButton.LEFT, ) - ctrl._canvas.on_mouse_release(mre) + ) # Test cursors - roi_view.boundingBoxChanged.emit((world_start, world_end)) + roi_view.bb = (world_start, world_end) # Top-Left corner - mme = MouseMoveEvent(canvas_roi_start[0], canvas_roi_start[1]) - assert roi_view.get_cursor(mme) == CursorType.FDIAG_ARROW + with patch("ndv.views._data_canvas.set_cursor") as mock_set_cursor: + ctrl._canvas._canvas.handle( + MouseMoveEvent(pos=canvas_roi_start, buttons=MouseButton.NONE) + ) + mock_set_cursor.assert_called_once_with( + ctrl._canvas._canvas, CursorType.FDIAG_ARROW + ) # Top-Right corner - mme = MouseMoveEvent(canvas_roi_start[0], canvas_roi_end[1]) - assert roi_view.get_cursor(mme) == CursorType.BDIAG_ARROW - # Middle - mme = MouseMoveEvent( - (canvas_roi_start[0] + canvas_roi_end[0]) / 2, - (canvas_roi_start[1] + canvas_roi_end[1]) / 2, + with patch("ndv.views._data_canvas.set_cursor") as mock_set_cursor: + ctrl._canvas._canvas.handle( + MouseMoveEvent( + pos=(canvas_roi_start[0], canvas_roi_end[1]), buttons=MouseButton.NONE + ) + ) + mock_set_cursor.assert_called_once_with( + ctrl._canvas._canvas, CursorType.BDIAG_ARROW ) - assert roi_view.get_cursor(mme) == CursorType.ALL_ARROW - ctrl._canvas.close() + # Middle + with patch("ndv.views._data_canvas.set_cursor") as mock_set_cursor: + ctrl._canvas._canvas.handle( + MouseMoveEvent( + pos=( + (canvas_roi_start[0] + canvas_roi_end[0]) / 2, + (canvas_roi_start[1] + canvas_roi_end[1]) / 2, + ), + buttons=MouseButton.NONE, + ) + ) + mock_set_cursor.assert_called_once_with(ctrl._canvas._canvas, CursorType.ALL_ARROW) + ctrl.close() @pytest.mark.allow_leaks @@ -541,26 +553,31 @@ def test_resolve_is_pure() -> None: @no_type_check @_patch_views +@pytest.mark.usefixtures("any_app") def test_stale_response_discard() -> None: """Test that responses from old request generations are discarded.""" ctrl = ArrayViewer() # simulate a stale response from generation 1 when we're on generation 2 - old_response = DataResponse(n_visible_axes=2, data={None: np.zeros((8, 8))}) - future = Future() - future.set_result(old_response) + future: Future[DataResponse] = Future() + future.result = Mock( # type: ignore[method-assign] + side_effect=AssertionError("result() must not be called on stale future") + ) ctrl._current_gen = 2 ctrl._futures[future] = 1 # old generation ctrl._on_data_response_ready(future) - # stale response should be ignored — no LUT controllers created, no refresh + # stale response should be ignored — no LUT controllers created, no images assert len(ctrl._lut_controllers) == 0 - ctrl._canvas.refresh.assert_not_called() + scene = ctrl._canvas.view.scene + data_nodes = [node for node in scene.children if isinstance(node, snx.Image)] + assert len(data_nodes) == 0 @no_type_check @_patch_views +@pytest.mark.usefixtures("any_app") def test_rgba_3d_fallback_warns() -> None: """Test that RGBA mode with 3D view reverts to GRAYSCALE with a warning.""" ctrl = ArrayViewer(np.zeros((10, 4, 10, 10))) @@ -574,6 +591,7 @@ def test_rgba_3d_fallback_warns() -> None: @no_type_check @_patch_views +@pytest.mark.usefixtures("any_app") def test_rgba_invalid_channel_count_falls_back_to_composite() -> None: """Invalid RGBA channel widths should warn and fall back to COMPOSITE.""" ctrl = ArrayViewer( @@ -594,21 +612,21 @@ def test_rgba_invalid_channel_count_falls_back_to_composite() -> None: @no_type_check @_patch_views +@pytest.mark.usefixtures("any_app") def test_set_scales_called_on_apply() -> None: """set_scales is called on the canvas when scales change.""" ctrl = ArrayViewer(np.empty((3, 100, 200))) ctrl._async = False - mock_canvas = ctrl._canvas - mock_canvas.set_scales.reset_mock() - ctrl.display_model.scales[1] = 0.5 - mock_canvas.set_scales.assert_called() - args = mock_canvas.set_scales.call_args[0][0] - assert args[0] == 0.5 # axis 1 + with patch.object(ctrl._canvas, "set_scales") as mock_set_scales: + ctrl.display_model.scales[1] = 0.5 + mock_set_scales.assert_called() + args = mock_set_scales.call_args[0][0] + assert args[0] == 0.5 # axis 1 @no_type_check -@_patch_views +@pytest.mark.usefixtures("any_app") def test_fallback_channel_names_pushed() -> None: """Fallback channel names are pushed to LUT views.""" ctrl = ArrayViewer( @@ -618,17 +636,19 @@ def test_fallback_channel_names_pushed() -> None: ), ) ctrl._async = False - ctrl.data = np.empty((3, 10, 10)) + ctrl.data = np.empty((3, 10, 10), dtype=np.float32) # fallback names default to str(key) for plain numpy arrays for key, lut_ctrl in ctrl._lut_controllers.items(): if isinstance(key, int): for view in lut_ctrl.lut_views: - view.set_fallback_name.assert_called_with(str(key)) + assert view._fallback_name == str(key) + # assert view.set_fallback_name.assert_called_with(str(key)) @no_type_check @_patch_views +@pytest.mark.usefixtures("any_app") def test_scales_applied_after_async_data_response() -> None: """Scales must be applied after handles are created in async data response. @@ -637,101 +657,30 @@ def test_scales_applied_after_async_data_response() -> None: handle creation in _on_data_response_ready(). """ ctrl = ArrayViewer(display_model=ArrayDisplayModel(scales={-2: 0.5, -1: 2.0})) - mock_canvas = ctrl._canvas # Set up data wrapper and resolve state without triggering data fetch ctrl._data_wrapper = DataWrapper.create(np.empty((10, 100, 200))) ctrl._resolved = resolve(ctrl._display_model, ctrl._data_wrapper) - mock_canvas.set_scales.reset_mock() - # Simulate the async data response arriving (creates handles) - response = DataResponse( - n_visible_axes=2, data={None: np.zeros((100, 200), dtype=np.uint8)} - ) - future: Future[DataResponse] = Future() - future.set_result(response) - ctrl._futures[future] = ctrl._current_gen - ctrl._on_data_response_ready(future) + with patch.object(ctrl._canvas, "set_scales") as mock_set_scales: + # Simulate the async data response arriving (creates handles) + response = DataResponse( + n_visible_axes=2, data={None: np.zeros((100, 200), dtype=np.uint8)} + ) + future: Future[DataResponse] = Future() + future.set_result(response) + ctrl._futures[future] = ctrl._current_gen + ctrl._on_data_response_ready(future) # set_scales must be called AFTER handles are created - mock_canvas.set_scales.assert_called() - last_scales = mock_canvas.set_scales.call_args[0][0] + mock_set_scales.assert_called() + last_scales = mock_set_scales.call_args[0][0] assert last_scales == (0.5, 2.0) @no_type_check @_patch_views -def test_hover_with_scaled_axes() -> None: - """Hover correctly maps world coords to data indices with non-unit scales. - - With scales (sy=0.5, sx=2.0), world coord (4.0, 3.0) should map to - data indices: data_x = 4.0/2.0 = 2, data_y = 3.0/0.5 = 6. - The controller should sample data[6, 2], not data[3, 4]. - """ - ctrl = ArrayViewer(scales={-2: 0.5, -1: 2.0}) - ctrl._async = False - ctrl.data = np.zeros((10, 20), dtype=np.uint8) - - # Spy on the ChannelController.get_value_at_index to capture the index - for lut_ctrl in ctrl._lut_controllers.values(): - lut_ctrl.get_value_at_index = Mock(wraps=lut_ctrl.get_value_at_index) - - data_pos, _ = ctrl._get_values_at_world_point(4.0, 3.0) - assert data_pos == (6, 2) - - for lut_ctrl in ctrl._lut_controllers.values(): - lut_ctrl.get_value_at_index.assert_called_once_with((6, 2)) - - -@no_type_check -@_patch_views -def test_hover_info_shows_data_indices_not_world_coords() -> None: - """Hover info label should display data indices, not scaled world coords.""" - ctrl = ArrayViewer(scales={-2: 0.5, -1: 2.0}) - ctrl._async = False - ctrl.data = np.zeros((10, 20), dtype=np.uint8) - - mock_canvas = ctrl._canvas - mock_view = ctrl._view - - # world (4.0, 3.0) -> data (row=6, col=2) with scales (sy=0.5, sx=2.0) - mock_canvas.canvas_to_world.return_value = (4.0, 3.0, 0) - ctrl._on_canvas_mouse_moved(MouseMoveEvent(100, 100)) - - hover_text = mock_view.set_hover_info.call_args[0][0] - # must show data indices [6, 2], NOT world coords [3, 4] - assert hover_text.startswith("[6, 2]"), f"got {hover_text!r}" - - -@no_type_check -@_patch_views -def test_hover_with_negative_scales() -> None: - """Hover should work with negative scales (descending coordinates). - - Regression: _get_values_at_world_point rejects negative world coordinates, - but negative scales produce negative world coords for valid data positions. - """ - ctrl = ArrayViewer(scales={-2: -1.0, -1: 1.0}) - ctrl._async = False - ctrl.data = np.ones((5, 10), dtype=np.uint8) - - mock_canvas = ctrl._canvas - mock_view = ctrl._view - - # With scale_y=-1.0, valid world y coords are negative (e.g. y=-2.0 -> row 2) - mock_canvas.canvas_to_world.return_value = (3.0, -2.0, 0) - - _, vals = ctrl._get_values_at_world_point(3.0, -2.0) - assert vals, f"expected values, scales={ctrl._resolved.visible_scales}" - - ctrl._on_canvas_mouse_moved(MouseMoveEvent(100, 100)) - hover_call = mock_view.set_hover_info.call_args[0][0] - # Should show valid data, not empty string (which means hover was rejected) - assert hover_call != "" - - -@no_type_check -@_patch_views +@pytest.mark.usefixtures("any_app") def test_data_replacement_with_stale_index() -> None: """Replacing data with fewer dims should not crash due to stale current_index.""" # Start with 4D data — axes 0, 1, 2, 3 are all valid @@ -753,6 +702,7 @@ def test_data_replacement_with_stale_index() -> None: @no_type_check @_patch_views +@pytest.mark.usefixtures("any_app") def test_remove_lut_view_with_non_gui_view() -> None: """remove_lut_view should handle non-GUI LUTViews (e.g. ImageHandle). @@ -771,6 +721,7 @@ def test_remove_lut_view_with_non_gui_view() -> None: @no_type_check @_patch_views +@pytest.mark.usefixtures("any_app") def test_user_current_index_preserved_on_init() -> None: """User-provided current_index must not be overwritten by slider defaults.""" user_index = {0: 5} @@ -783,10 +734,10 @@ def test_user_current_index_preserved_on_init() -> None: @no_type_check @_patch_views +@pytest.mark.usefixtures("any_app") def test_keybinding_slice_navigation() -> None: """Arrow keys step focused slider and cycle focused axis.""" from ndv._keybindings import _ensure_focused_axis - from ndv._types import KeyCode, KeyMod, KeyPressEvent SHAPE = (5, 10, 128, 128) ctrl = ArrayViewer() @@ -802,40 +753,41 @@ def test_keybinding_slice_navigation() -> None: assert ctrl._focused_slider_axis == 1 def press(key: KeyCode | str, mods: KeyMod = KeyMod.NONE) -> None: - ctrl._on_key_pressed(KeyPressEvent(key, mods)) + kb = KeyBinding(parts=[SimpleKeyBinding(key=key, mods=mods)]) + ctrl._view_event(KeyPressEvent(key=kb)) # RIGHT arrow steps forward on focused axis (1) - press(KeyCode.RIGHT) + press(KeyCode.RightArrow) assert ctrl.display_model.current_index[1] == 1 # Another RIGHT - press(KeyCode.RIGHT) + press(KeyCode.RightArrow) assert ctrl.display_model.current_index[1] == 2 # LEFT arrow steps backward - press(KeyCode.LEFT) + press(KeyCode.LeftArrow) assert ctrl.display_model.current_index[1] == 1 # UP arrow cycles to previous axis (0) - press(KeyCode.UP) + press(KeyCode.UpArrow) assert ctrl._focused_slider_axis == 0 # RIGHT now steps axis 0 - press(KeyCode.RIGHT) + press(KeyCode.RightArrow) assert ctrl.display_model.current_index[0] == 1 # DOWN cycles back to axis 1 - press(KeyCode.DOWN) + press(KeyCode.DownArrow) assert ctrl._focused_slider_axis == 1 # LEFT doesn't go below 0 ctrl.display_model.current_index[1] = 0 - press(KeyCode.LEFT) + press(KeyCode.LeftArrow) assert ctrl.display_model.current_index[1] == 0 # RIGHT doesn't go above max ctrl.display_model.current_index[1] = 9 # max for shape 10 - press(KeyCode.RIGHT) + press(KeyCode.RightArrow) assert ctrl.display_model.current_index[1] == 9 # Unrecognized key does nothing @@ -845,51 +797,45 @@ def press(key: KeyCode | str, mods: KeyMod = KeyMod.NONE) -> None: @no_type_check @_patch_views +@pytest.mark.usefixtures("any_app") def test_keybinding_zoom() -> None: """Plus/minus keys should call canvas.zoom when mouse is over canvas.""" - from ndv._types import KeyMod, KeyPressEvent ctrl = ArrayViewer() ctrl._async = False ctrl.data = np.empty((10, 10)) + canvas_pos = (ctrl._canvas._canvas.width // 2, ctrl._canvas._canvas.height // 2) def press(key: str, mods: KeyMod = KeyMod.NONE) -> None: - ctrl._on_key_pressed(KeyPressEvent(key, mods)) - - # When mouse is not over the canvas, zoom should not be called - assert ctrl._highlight_pos is None - ctrl._canvas.zoom.reset_mock() - press("=") - ctrl._canvas.zoom.assert_not_called() - press("-") - ctrl._canvas.zoom.assert_not_called() + kb = KeyBinding(parts=[SimpleKeyBinding(key=key, mods=mods)]) + ctrl._view_event(KeyPressEvent(key=kb)) - # Simulate mouse over canvas - ctrl._highlight_pos = (5.0, 5.0) + mouse_in = WheelEvent(canvas_pos, MouseButton.NONE, angle_delta=(0, 120)) + mouse_out = WheelEvent(canvas_pos, MouseButton.NONE, angle_delta=(0, -120)) # = key (zoom in) - ctrl._canvas.zoom.reset_mock() - press("=") - ctrl._canvas.zoom.assert_called_once_with(factor=0.667, center=(5.0, 5.0)) + with patch.object(snx.PanZoom, "handle_event") as mock_handle_event: + press("=") + mock_handle_event.assert_called_once_with(mouse_in, ctrl._canvas.view) # - key (zoom out) - ctrl._canvas.zoom.reset_mock() - press("-") - ctrl._canvas.zoom.assert_called_once_with(factor=1.5, center=(5.0, 5.0)) + with patch.object(snx.PanZoom, "handle_event") as mock_handle_event: + press("-") + mock_handle_event.assert_called_once_with(mouse_out, ctrl._canvas.view) # + (shift+=) should also zoom in - ctrl._canvas.zoom.reset_mock() - press("+", KeyMod.SHIFT) - ctrl._canvas.zoom.assert_called_once_with(factor=0.667, center=(5.0, 5.0)) + with patch.object(snx.PanZoom, "handle_event") as mock_handle_event: + press("=", KeyMod.Shift) + mock_handle_event.assert_called_once_with(mouse_in, ctrl._canvas.view) # _ (shift+-) should also zoom out - ctrl._canvas.zoom.reset_mock() - press("_", KeyMod.SHIFT) - ctrl._canvas.zoom.assert_called_once_with(factor=1.5, center=(5.0, 5.0)) + with patch.object(snx.PanZoom, "handle_event") as mock_handle_event: + press("-", KeyMod.Shift) + mock_handle_event.assert_called_once_with(mouse_out, ctrl._canvas.view) @no_type_check -@_patch_views +@pytest.mark.usefixtures("any_app") def test_stats_signals() -> None: """Test that stats_updated signals fire on data updates and refresh_stats.""" from ndv.controllers._image_stats import ImageStats @@ -953,3 +899,4 @@ def test_handle_gc_on_data_reassign() -> None: gc.collect() assert handle_ref() is None + viewer.close() diff --git a/tests/test_shared_histogram.py b/tests/test_shared_histogram.py index 6a996fca..559abf77 100644 --- a/tests/test_shared_histogram.py +++ b/tests/test_shared_histogram.py @@ -1,66 +1,32 @@ -"""Tests for shared histogram controller wiring and behavior.""" +"""Tests for shared histogram controller wiring and behavior. + +TODO: Move this to tests/views +""" from __future__ import annotations -from typing import TYPE_CHECKING, Any, no_type_check -from unittest.mock import MagicMock, patch +from typing import Any, no_type_check +from unittest.mock import patch import numpy as np +import pytest +from pytest import fixture +from scenex.app.events import ( + MouseButton, + MouseDoublePressEvent, + MouseMoveEvent, + MousePressEvent, + MouseReleaseEvent, +) from ndv.controllers import ArrayViewer from ndv.models._array_display_model import ChannelMode from ndv.models._lut_model import ClimsManual, ClimsMinMax -from ndv.views import _app -from ndv.views.bases import ArrayView, LUTView -from ndv.views.bases._graphics._canvas import ( - ArrayCanvas, - HistogramCanvas, - SharedHistogramCanvas, -) -from ndv.views.bases._graphics._canvas_elements import ImageHandle - -if TYPE_CHECKING: - from collections.abc import Callable +from ndv.views._shared_histogram import SharedHistogram SHAPE = (10, 3, 10, 10) -def _make_img_handle() -> MagicMock: - handle = MagicMock(spec=ImageHandle) - handle.data.return_value = np.zeros((10, 10)).astype(np.uint8) - return handle - - -def _get_mock_canvas(*_: Any) -> ArrayCanvas: - mock = MagicMock(spec=ArrayCanvas) - mock.add_image.side_effect = lambda *a, **k: _make_img_handle() - mock.add_volume.side_effect = lambda *a, **k: _make_img_handle() - return mock - - -def _get_mock_view(*_: Any) -> ArrayView: - mock = MagicMock(spec=ArrayView) - mock.add_lut_view.side_effect = lambda *a, **k: MagicMock(spec=LUTView) - return mock - - -def _get_mock_hist_canvas() -> HistogramCanvas: - return MagicMock(spec=HistogramCanvas) - - -def _get_mock_shared_hist_canvas() -> SharedHistogramCanvas: - return MagicMock(spec=SharedHistogramCanvas) - - -def _patch_views(f: Callable) -> Callable: - f = patch.object(_app, "get_array_canvas_class", lambda: _get_mock_canvas)(f) - f = patch.object(_app, "get_array_view_class", lambda: _get_mock_view)(f) - f = patch.object(_app, "get_histogram_canvas_class", lambda: _get_mock_hist_canvas)(f) # fmt: skip # noqa: E501 - f = patch.object(_app, "get_shared_histogram_canvas_class", lambda: _get_mock_shared_hist_canvas)(f) # fmt: skip # noqa: E501 - f = patch.object(_app, "filter_key_events", lambda *a, **k: lambda: None)(f) - return f - - def _make_ctrl_with_data( channel_mode: str = "composite", ) -> ArrayViewer: @@ -75,20 +41,21 @@ def _make_ctrl_with_data( @no_type_check -@_patch_views +@pytest.mark.usefixtures("any_app") def test_shared_histogram_creation() -> None: """Shared histogram is created and added to view on demand.""" ctrl = _make_ctrl_with_data() assert ctrl._shared_histogram is None - ctrl._add_shared_histogram() + with patch.object(ctrl._view, "add_shared_histogram") as mock_add_hist: + ctrl._add_shared_histogram() assert ctrl._shared_histogram is not None - ctrl._view.add_shared_histogram.assert_called_once_with(ctrl._shared_histogram) + mock_add_hist.assert_called_once_with(ctrl._shared_histogram.widget()) @no_type_check -@_patch_views +@pytest.mark.usefixtures("any_app") def test_shared_histogram_not_created_at_init() -> None: """use_shared_histogram=True only controls style, not visibility.""" ctrl = ArrayViewer(viewer_options={"use_shared_histogram": True}) @@ -100,53 +67,58 @@ def test_shared_histogram_not_created_at_init() -> None: @no_type_check -@_patch_views +@pytest.mark.usefixtures("any_app") def test_shared_histogram_idempotent() -> None: """Calling _add_shared_histogram twice doesn't create a second one.""" ctrl = _make_ctrl_with_data() - ctrl._add_shared_histogram() - first = ctrl._shared_histogram - ctrl._add_shared_histogram() + with patch.object(ctrl._view, "add_shared_histogram") as mock_add_hist: + ctrl._add_shared_histogram() + first = ctrl._shared_histogram + ctrl._add_shared_histogram() assert ctrl._shared_histogram is first - ctrl._view.add_shared_histogram.assert_called_once() + mock_add_hist.assert_called_once() # ---------- Data flow tests ---------- @no_type_check -@_patch_views +@pytest.mark.usefixtures("any_app") def test_channel_data_flows_to_shared_histogram() -> None: """Stats updates propagate channel data to shared histogram.""" - ctrl = _make_ctrl_with_data() - ctrl._add_shared_histogram() - mock_hist = ctrl._shared_histogram + with patch( + "ndv.controllers._array_viewer.SharedHistogram.set_channel_data" + ) as mock_set_channel_data: + ctrl = _make_ctrl_with_data() + ctrl._add_shared_histogram() # Each channel key should appear in set_channel_data calls - called_keys = {call[0][0] for call in mock_hist.set_channel_data.call_args_list} + called_keys = {call[0][0] for call in mock_set_channel_data.call_args_list} for key in ctrl._lut_controllers: if ctrl._lut_controllers[key].handles: assert key in called_keys, f"Channel {key} never got data" @no_type_check -@_patch_views -def test_initial_state_set_on_connection() -> None: +@patch("ndv.controllers._array_viewer.SharedHistogram.set_channel_gamma") +@patch("ndv.controllers._array_viewer.SharedHistogram.set_channel_visible") +@patch("ndv.controllers._array_viewer.SharedHistogram.set_channel_color") +@pytest.mark.usefixtures("any_app") +def test_initial_state_set_on_connection(mock_color, mock_visible, mock_gamma) -> None: """Color, visibility, gamma, and name are set when channel connects.""" ctrl = _make_ctrl_with_data() ctrl._add_shared_histogram() - mock_hist = ctrl._shared_histogram # set_channel_color should have been called for each channel - assert mock_hist.set_channel_color.call_count >= len(ctrl._lut_controllers) + assert mock_color.call_count >= len(ctrl._lut_controllers) # set_channel_visible should have been called - assert mock_hist.set_channel_visible.call_count >= len(ctrl._lut_controllers) + assert mock_visible.call_count >= len(ctrl._lut_controllers) # set_channel_gamma should have been called - assert mock_hist.set_channel_gamma.call_count >= len(ctrl._lut_controllers) + assert mock_gamma.call_count >= len(ctrl._lut_controllers) @no_type_check -@_patch_views +@pytest.mark.usefixtures("any_app") def test_new_channel_connects_to_existing_shared_histogram() -> None: """When a new channel appears after shared histogram exists, it connects.""" ctrl = ArrayViewer(channel_mode="composite") @@ -155,20 +127,20 @@ def test_new_channel_connects_to_existing_shared_histogram() -> None: # Create shared histogram first (no data yet) ctrl._add_shared_histogram() mock_hist = ctrl._shared_histogram - mock_hist.set_channel_data.reset_mock() # Now load data — channels get created - ctrl.data = np.random.randint(0, 255, SHAPE, dtype=np.uint8) + with patch.object(mock_hist, "set_channel_data") as mock_set_channel_data: + ctrl.data = np.random.randint(0, 255, SHAPE, dtype=np.uint8) # New channels should have sent data to the shared histogram - assert mock_hist.set_channel_data.call_count > 0 + assert mock_set_channel_data.call_count == len(ctrl._lut_controllers) # ---------- Bidirectional sync tests ---------- @no_type_check -@_patch_views +@pytest.mark.usefixtures("any_app") def test_clims_from_shared_histogram_update_model() -> None: """Dragging clims on shared histogram updates the LUT model.""" ctrl = _make_ctrl_with_data() @@ -188,7 +160,7 @@ def test_clims_from_shared_histogram_update_model() -> None: @no_type_check -@_patch_views +@pytest.mark.usefixtures("any_app") def test_gamma_from_shared_histogram_updates_model() -> None: """Dragging gamma on shared histogram updates the LUT model.""" ctrl = _make_ctrl_with_data() @@ -202,26 +174,26 @@ def test_gamma_from_shared_histogram_updates_model() -> None: @no_type_check -@_patch_views +@pytest.mark.usefixtures("any_app") def test_model_clims_sync_to_shared_histogram() -> None: """Manual clim changes on the model propagate to shared histogram.""" ctrl = _make_ctrl_with_data() ctrl._add_shared_histogram() mock_hist = ctrl._shared_histogram - mock_hist.set_channel_clims.reset_mock() key = next(iter(ctrl._lut_controllers)) lut_model = ctrl._lut_controllers[key].lut_model # Set manual clims on the model - lut_model.clims = ClimsManual(min=50, max=150) + with patch.object(mock_hist, "set_channel_clims") as mock_set_channel_clims: + lut_model.clims = ClimsManual(min=50, max=150) # Should propagate to shared histogram - mock_hist.set_channel_clims.assert_called_with(key, (50, 150)) + mock_set_channel_clims.assert_called_once_with(key, (50, 150)) @no_type_check -@_patch_views +@pytest.mark.usefixtures("any_app") def test_model_cmap_sync_to_shared_histogram() -> None: """Colormap changes on the model propagate to shared histogram.""" import cmap @@ -229,41 +201,42 @@ def test_model_cmap_sync_to_shared_histogram() -> None: ctrl = _make_ctrl_with_data() ctrl._add_shared_histogram() mock_hist = ctrl._shared_histogram - mock_hist.set_channel_color.reset_mock() key = next(iter(ctrl._lut_controllers)) lut_model = ctrl._lut_controllers[key].lut_model - lut_model.cmap = cmap.Colormap("red") - mock_hist.set_channel_color.assert_called_once() - call_key = mock_hist.set_channel_color.call_args[0][0] + with patch.object(mock_hist, "set_channel_color") as mock_set_channel_color: + lut_model.cmap = cmap.Colormap("red") + mock_set_channel_color.assert_called_once() + call_key = mock_set_channel_color.call_args[0][0] assert call_key == key @no_type_check -@_patch_views +@pytest.mark.usefixtures("any_app") def test_model_visibility_sync_to_shared_histogram() -> None: """Visibility changes on the model propagate to shared histogram.""" ctrl = _make_ctrl_with_data() ctrl._add_shared_histogram() mock_hist = ctrl._shared_histogram - mock_hist.set_channel_visible.reset_mock() key = next(iter(ctrl._lut_controllers)) lut_model = ctrl._lut_controllers[key].lut_model - lut_model.visible = False - mock_hist.set_channel_visible.assert_called_with(key, False) + with patch.object(mock_hist, "set_channel_visible") as mock_set_channel_visible: + lut_model.visible = False + mock_set_channel_visible.assert_called_once_with(key, False) - lut_model.visible = True - mock_hist.set_channel_visible.assert_called_with(key, True) + with patch.object(mock_hist, "set_channel_visible") as mock_set_channel_visible: + lut_model.visible = True + mock_set_channel_visible.assert_called_once_with(key, True) # ---------- Autoscale sync tests ---------- @no_type_check -@_patch_views +@pytest.mark.usefixtures("any_app") def test_autoscale_syncs_clims_to_shared_histogram() -> None: """When autoscale recomputes clims, shared histogram clim lines update.""" ctrl = _make_ctrl_with_data() @@ -275,36 +248,34 @@ def test_autoscale_syncs_clims_to_shared_histogram() -> None: # First set manual clims lut_model.clims = ClimsManual(min=10, max=200) - mock_hist.set_channel_clims.reset_mock() # Switch back to autoscale - lut_model.clims = ClimsMinMax() - - # Should have synced resolved clims - assert mock_hist.set_channel_clims.call_count >= 1 + with patch.object(mock_hist, "set_channel_clims") as mock_set_channel_clims: + lut_model.clims = ClimsMinMax() + mock_set_channel_clims.assert_called_once() @no_type_check -@_patch_views +@pytest.mark.usefixtures("any_app") def test_stats_update_syncs_clims() -> None: """When new data arrives, resolved clims from stats reach the histogram.""" ctrl = _make_ctrl_with_data() ctrl._add_shared_histogram() mock_hist = ctrl._shared_histogram - mock_hist.set_channel_clims.reset_mock() # Trigger a data update by changing index - ctrl.display_model.current_index.assign({0: 1}) + with patch.object(mock_hist, "set_channel_clims") as mock_set_channel_clims: + ctrl.display_model.current_index.assign({0: 1}) # Should have called set_channel_clims with resolved values - assert mock_hist.set_channel_clims.call_count >= 1 + assert mock_set_channel_clims.call_count == SHAPE[1] # ---------- Channel mode visibility tests ---------- @no_type_check -@_patch_views +@pytest.mark.usefixtures("any_app") def test_grayscale_mode_shows_only_default_channel() -> None: """In grayscale mode, only key=None channel is visible on shared histogram.""" ctrl = _make_ctrl_with_data() @@ -313,15 +284,13 @@ def test_grayscale_mode_shows_only_default_channel() -> None: # Switch to grayscale ctrl.display_model.channel_mode = ChannelMode.GRAYSCALE - mock_hist.set_channel_visible.reset_mock() # Trigger visibility update again to capture calls - ctrl._update_lut_visibility(ChannelMode.GRAYSCALE) + with patch.object(mock_hist, "set_channel_visible") as mock_set_channel_visible: + ctrl._update_lut_visibility(ChannelMode.GRAYSCALE) # key=None should be visible, numbered keys should be hidden - calls = { - args[0][0]: args[0][1] for args in mock_hist.set_channel_visible.call_args_list - } + calls = {args[0][0]: args[0][1] for args in mock_set_channel_visible.call_args_list} assert calls.get(None) is True for key in ctrl._lut_controllers: if key is not None: @@ -329,7 +298,7 @@ def test_grayscale_mode_shows_only_default_channel() -> None: @no_type_check -@_patch_views +@pytest.mark.usefixtures("any_app") def test_composite_mode_shows_numbered_channels() -> None: """In composite mode, numbered channels are visible.""" ctrl = _make_ctrl_with_data() @@ -338,20 +307,18 @@ def test_composite_mode_shows_numbered_channels() -> None: # Ensure composite mode ctrl.display_model.channel_mode = ChannelMode.COMPOSITE - mock_hist.set_channel_visible.reset_mock() - ctrl._update_lut_visibility(ChannelMode.COMPOSITE) + with patch.object(mock_hist, "set_channel_visible") as mock_set_channel_visible: + ctrl._update_lut_visibility(ChannelMode.COMPOSITE) - calls = { - args[0][0]: args[0][1] for args in mock_hist.set_channel_visible.call_args_list - } + calls = {args[0][0]: args[0][1] for args in mock_set_channel_visible.call_args_list} for key in ctrl._lut_controllers: if key is not None and key != "RGB": assert calls.get(key) is True @no_type_check -@_patch_views +@pytest.mark.usefixtures("any_app") def test_opening_histogram_in_grayscale_respects_mode() -> None: """If histogram is opened while in grayscale, only default channel shows.""" ctrl = _make_ctrl_with_data() @@ -359,12 +326,14 @@ def test_opening_histogram_in_grayscale_respects_mode() -> None: # Switch to grayscale before opening histogram ctrl.display_model.channel_mode = ChannelMode.GRAYSCALE - ctrl._add_shared_histogram() - mock_hist = ctrl._shared_histogram + with patch( + "ndv.controllers._array_viewer.SharedHistogram.set_channel_visible" + ) as mock_set_channel_visible: + ctrl._add_shared_histogram() # Check the last set_channel_visible call for each key visible_calls: dict[Any, bool] = {} - for call in mock_hist.set_channel_visible.call_args_list: + for call in mock_set_channel_visible.call_args_list: visible_calls[call[0][0]] = call[0][1] assert visible_calls.get(None) is True @@ -377,39 +346,291 @@ def test_opening_histogram_in_grayscale_respects_mode() -> None: @no_type_check -@_patch_views +@pytest.mark.usefixtures("any_app") def test_clim_bounds_propagate_to_shared_histogram() -> None: """clim_bounds from LUT model are forwarded to shared histogram.""" ctrl = _make_ctrl_with_data() - ctrl._add_shared_histogram() - mock_hist = ctrl._shared_histogram + with patch( + "ndv.controllers._array_viewer.SharedHistogram.set_clim_bounds" + ) as mock_set_clim_bounds: + ctrl._add_shared_histogram() # For uint8 data, clim_bounds should be (0, 255) - mock_hist.set_clim_bounds.assert_called() + mock_set_clim_bounds.assert_called() # ---------- Highlight tests ---------- @no_type_check -@_patch_views +@pytest.mark.usefixtures("any_app") def test_highlight_forwarded_to_shared_histogram() -> None: """Mouse hover values are forwarded to shared histogram.""" ctrl = _make_ctrl_with_data() ctrl._add_shared_histogram() - mock_hist = ctrl._shared_histogram - ctrl._highlight_values({None: 42.0}, (5, 5)) - mock_hist.highlight.assert_called_once_with({None: 42.0}) + with patch.object(ctrl._shared_histogram, "highlight") as mock_highlight: + ctrl._highlight_values({None: 42.0}, (5, 5)) + mock_highlight.assert_called_once_with({None: 42.0}) @no_type_check -@_patch_views +@pytest.mark.usefixtures("any_app") def test_highlight_clears_on_empty_values() -> None: """Empty channel values forward None to shared histogram.""" ctrl = _make_ctrl_with_data() ctrl._add_shared_histogram() - mock_hist = ctrl._shared_histogram - ctrl._highlight_values({}, (5, 5)) - mock_hist.highlight.assert_called_with({}) + with patch.object(ctrl._shared_histogram, "highlight") as mock_highlight: + ctrl._highlight_values({}, (5, 5)) + mock_highlight.assert_called_with({}) + + +# ---------- Visual behavior tests ---------- + + +@fixture +def hist() -> SharedHistogram: + canvas = SharedHistogram() + canvas.set_range(x=(0, 100), y=(0, 1)) + return canvas + + +@pytest.mark.usefixtures("any_app") +def test_channel_creation(hist: SharedHistogram) -> None: + """Channels are created lazily on first data/color set.""" + assert len(hist._channels) == 0 + + counts = np.array([1, 2, 3, 2, 1]) + edges = np.array([0, 20, 40, 60, 80, 100], dtype=float) + hist.set_channel_data(0, counts, edges) + assert 0 in hist._channels + + hist.set_channel_data(1, counts, edges) + assert 1 in hist._channels + assert len(hist._channels) == 2 + + +@pytest.mark.usefixtures("any_app") +def test_channel_removal(hist: SharedHistogram) -> None: + """Removed channels are cleaned up.""" + counts = np.array([1, 2, 3]) + edges = np.array([0, 33, 66, 100], dtype=float) + hist.set_channel_data(0, counts, edges) + hist.set_channel_data(1, counts, edges) + + hist.remove_channel(0) + assert 0 not in hist._channels + assert 1 in hist._channels + + +@pytest.mark.usefixtures("any_app") +def test_channel_visibility(hist: SharedHistogram) -> None: + """Channel visibility controls all visual elements.""" + counts = np.array([1, 2, 3]) + edges = np.array([0, 33, 66, 100], dtype=float) + hist.set_channel_data(0, counts, edges) + hist.set_channel_clims(0, (10, 90)) + + ch = hist._channels[0] + assert ch.visible is True + assert ch.area_mesh.visible is True + assert ch.outline.visible is True + + hist.set_channel_visible(0, False) + assert ch.visible is False + assert ch.area_mesh.visible is False + assert ch.outline.visible is False + assert ch.left_clim.visible is False + assert ch.right_clim.visible is False + assert ch.gamma_line.visible is False + assert ch.gamma_handle.visible is False + + hist.set_channel_visible(0, True) + assert ch.visible is True + assert ch.area_mesh.visible is True + + +@pytest.mark.usefixtures("any_app") +def test_none_key_channel() -> None: + """key=None (grayscale default channel) works correctly.""" + hist = SharedHistogram() + counts = np.array([5, 10, 15, 10, 5]) + edges = np.linspace(0, 100, 6) + hist.set_channel_data(None, counts, edges) + + assert None in hist._channels + ch = hist._channels[None] + assert ch.visible is True + + hist.set_channel_clims(None, (20, 80)) + assert ch.clims == (20, 80) + + hist.set_channel_visible(None, False) + assert ch.visible is False + + +@pytest.mark.usefixtures("any_app") +def test_clim_drag_emits_signal() -> None: + """Dragging a clim handle emits climsChanged with correct key.""" + hist = SharedHistogram() + counts = np.array([5, 10, 15, 10, 5]) + edges = np.linspace(0, 100, 6) + hist.set_channel_data(0, counts, edges) + hist.set_range(x=(0, 100)) + hist.set_channel_clims(0, (10, 90)) + hist.set_channel_color(0, (0, 1, 0, 1)) + + received: list = [] + hist.climsChanged.connect(lambda key, clims: received.append((key, clims))) + + x_start, y_start, width, height = hist.canvas.content_rect_for(hist.view) + left_clim_pos = (x_start + (width / 10), y_start + height - 1) + hist.canvas.handle(MousePressEvent(pos=left_clim_pos, buttons=MouseButton.LEFT)) + new_left_clim_pos = (x_start + (width * 3 / 10), y_start + height - 1) + hist.canvas.handle(MouseMoveEvent(pos=new_left_clim_pos, buttons=MouseButton.LEFT)) + hist.canvas.handle( + MouseReleaseEvent(pos=new_left_clim_pos, buttons=MouseButton.LEFT) + ) + + assert len(received) >= 1 + assert received[-1][0] == 0 + assert received[-1][1][0] >= 25 + + +@pytest.mark.usefixtures("any_app") +def test_none_key_clim_drag() -> None: + """Clim dragging works for key=None (grayscale channel).""" + hist = SharedHistogram() + counts = np.array([5, 10, 15, 10, 5]) + edges = np.linspace(0, 100, 6) + hist.set_channel_data(None, counts, edges) + hist.set_range(x=(0, 100)) + hist.set_channel_clims(None, (10, 90)) + hist.set_channel_color(None, (0.5, 0.5, 0.5, 1)) + + received: list = [] + hist.climsChanged.connect(lambda key, clims: received.append((key, clims))) + + x_start, y_start, width, height = hist.canvas.content_rect_for(hist.view) + right_clim_pos = (x_start + (width * 9 / 10), y_start + height - 1) + hist.canvas.handle(MousePressEvent(pos=right_clim_pos, buttons=MouseButton.LEFT)) + new_right_clim_pos = (x_start + (width * 7 / 10), y_start + height - 1) + hist.canvas.handle(MouseMoveEvent(pos=new_right_clim_pos, buttons=MouseButton.LEFT)) + hist.canvas.handle( + MouseReleaseEvent(pos=new_right_clim_pos, buttons=MouseButton.LEFT) + ) + + assert len(received) >= 1 + assert received[-1][0] is None + + +@pytest.mark.usefixtures("any_app") +def test_gamma_double_click_resets() -> None: + """Double-clicking gamma handle emits gammaChanged with 1.0.""" + hist = SharedHistogram() + counts = np.array([5, 10, 15, 10, 5]) + edges = np.linspace(0, 100, 6) + hist.set_channel_data(0, counts, edges) + hist.set_range(x=(0, 100)) + hist.set_channel_clims(0, (0, 100)) + hist.set_channel_gamma(0, 2.0) + hist.set_channel_color(0, (0, 1, 0, 1)) + + received: list = [] + hist.gammaChanged.connect(lambda key, gamma: received.append((key, gamma))) + + x_start, y_start, width, height = hist.canvas.content_rect_for(hist.view) + gamma_height = 2 ** (-2.0) + gamma_pos = (x_start + (width / 2), y_start + height * (1 - gamma_height)) + hist.canvas.handle(MouseDoublePressEvent(pos=gamma_pos, buttons=MouseButton.LEFT)) + + assert len(received) == 1 + assert received[0] == (0, 1.0) + + +@pytest.mark.usefixtures("any_app") +def test_clim_bounds_constrain_drag() -> None: + """Clim drag respects clim_bounds.""" + hist = SharedHistogram() + counts = np.array([5, 10, 15, 10, 5]) + edges = np.linspace(0, 100, 6) + hist.set_channel_data(0, counts, edges) + hist.set_range(x=(-50, 150)) + hist.set_channel_clims(0, (10, 90)) + hist.set_channel_color(0, (1, 0, 0, 1)) + hist.set_clim_bounds((0, 255)) + + received: list = [] + hist.climsChanged.connect(lambda key, clims: received.append((key, clims))) + + x_start, y_start, width, height = hist.canvas.content_rect_for(hist.view) + left_clim_pos = (x_start + (6 * width / 20), y_start + height - 1) + hist.canvas.handle(MousePressEvent(pos=left_clim_pos, buttons=MouseButton.LEFT)) + new_left_clim_pos = (x_start + (2 * width / 20), y_start + height - 1) + hist.canvas.handle(MouseMoveEvent(pos=new_left_clim_pos, buttons=MouseButton.LEFT)) + hist.canvas.handle( + MouseReleaseEvent(pos=new_left_clim_pos, buttons=MouseButton.LEFT) + ) + + if received: + assert received[-1][1][0] >= 0 + hist.climsChanged.disconnect() + + +@pytest.mark.usefixtures("any_app") +def test_log_scale() -> None: + """Log scale can be toggled without errors.""" + hist = SharedHistogram() + counts = np.array([5, 10, 15, 10, 5]) + edges = np.linspace(0, 100, 6) + hist.set_channel_data(0, counts, edges) + + hist.set_log_base(10) + assert hist._log_base == 10 + + hist.set_log_base(None) + assert hist._log_base is None + + +@pytest.mark.usefixtures("any_app") +def test_highlight() -> None: + """Highlight line shows and hides correctly.""" + hist = SharedHistogram() + counts = np.array([5, 10, 15, 10, 5]) + edges = np.linspace(0, 100, 6) + hist.set_channel_data(0, counts, edges) + for channel in hist._channels.values(): + assert not channel.highlight.visible + + hist.highlight({"ch0": 50}) + for key, channel in hist._channels.items(): + assert channel.highlight.visible == (key == "ch0") + + hist.highlight({}) + for channel in hist._channels.values(): + assert not channel.highlight.visible + + +@pytest.mark.usefixtures("any_app") +def test_legend_visibility() -> None: + """Legend entries match channel visibility and names.""" + hist = SharedHistogram() + counts = np.array([5, 10, 15]) + edges = np.array([0, 33, 66, 100], dtype=float) + + hist.set_channel_data(0, counts, edges) + hist.set_channel_name(0, "FITC") + hist.set_channel_data(1, counts, edges) + hist.set_channel_name(1, "DAPI") + + ch0 = hist._channels[0] + ch1 = hist._channels[1] + assert ch0.legend_text.text == "● FITC" + assert ch1.legend_text.text == "● DAPI" + + hist.set_channel_visible(0, False) + assert not ch0.legend_text.visible + + hist.set_channel_visible(0, True) + assert ch0.legend_text.visible diff --git a/tests/views/_jupyter/test_array_view.py b/tests/views/_jupyter/test_array_view.py index 1b0e8507..53d8b842 100644 --- a/tests/views/_jupyter/test_array_view.py +++ b/tests/views/_jupyter/test_array_view.py @@ -58,28 +58,3 @@ def test_histogram(viewer: JupyterArrayView) -> None: # histogram = get_histogram_canvas_class()() # will raise if not supported # histogram_wdg = histogram.frontend_widget() # viewer.add_histogram(channel, histogram_wdg) - - -def test_find_rfb() -> None: - from jupyter_rfb import RemoteFrameBuffer - - from ndv.views._jupyter._app import _find_rfb - - class FakeRFB(RemoteFrameBuffer): - pass - - rfb = FakeRFB() - - # Direct match - assert _find_rfb(rfb) is rfb - - # Nested in container - container = ipywidgets.VBox(children=[ipywidgets.Label(), rfb]) - assert _find_rfb(container) is rfb - - # Not found - container2 = ipywidgets.VBox(children=[ipywidgets.Label()]) - assert _find_rfb(container2) is None - - # No children attribute - assert _find_rfb(object()) is None diff --git a/tests/views/_jupyter/test_lut_view.py b/tests/views/_jupyter/test_lut_view.py index a59f9835..28e61761 100644 --- a/tests/views/_jupyter/test_lut_view.py +++ b/tests/views/_jupyter/test_lut_view.py @@ -7,8 +7,8 @@ from pytest import fixture from ndv.models._lut_model import ClimsManual, ClimsMinMax, ClimsPercentile, LUTModel +from ndv.views._histogram import Histogram from ndv.views._jupyter._array_view import JupyterLUTView -from ndv.views.bases._graphics._canvas import HistogramCanvas @fixture @@ -88,7 +88,7 @@ def test_JupyterLUTView_update_view(model: LUTModel, view: JupyterLUTView) -> No def test_JupyterLUTView_histogram_controls(view: JupyterLUTView) -> None: # Mock up a histogram - hist_mock = MagicMock(spec=HistogramCanvas) + hist_mock = MagicMock(spec=Histogram) hist_frontend = RemoteFrameBuffer() hist_mock.frontend_widget.return_value = hist_frontend diff --git a/tests/views/_pygfx/__init__.py b/tests/views/_pygfx/__init__.py deleted file mode 100644 index fb345302..00000000 --- a/tests/views/_pygfx/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -"""Tests pertaining to Pygfx components""" - -from importlib.util import find_spec - -import pytest - -if not find_spec("pygfx"): - pytest.skip( - "Skipping Pygfx tests as Pygfx is not installed", allow_module_level=True - ) diff --git a/tests/views/_pygfx/test_array_canvas.py b/tests/views/_pygfx/test_array_canvas.py deleted file mode 100644 index 476ec6ae..00000000 --- a/tests/views/_pygfx/test_array_canvas.py +++ /dev/null @@ -1,112 +0,0 @@ -from __future__ import annotations - -import gc -from typing import TYPE_CHECKING, cast - -import numpy as np -import pytest -import wgpu - -from ndv.models._viewer_model import ArrayViewerModel -from ndv.views._pygfx._array_canvas import GfxArrayCanvas - -if TYPE_CHECKING: - from wgpu._diagnostics import ObjectCountDiagnostics - - -def _force_canvas_size(canvas: GfxArrayCanvas, w: int = 600, h: int = 600) -> None: - """Force the rendercanvas to report a valid size (needed before show).""" - rc = canvas._canvas - rc._size_info.set_physical_size(w, h, 1.0) - - -@pytest.mark.usefixtures("any_app") -def test_zoom_center() -> None: - """Zoom should keep the center point fixed in world space.""" - canvas = GfxArrayCanvas(ArrayViewerModel()) - _force_canvas_size(canvas) - canvas.set_ndim(2) - canvas.add_image(np.random.rand(100, 100).astype(np.float32)) - canvas.set_range() - - cam = canvas._camera - assert cam is not None - - # Record initial state (copy position since it's mutable) - initial_pos = tuple(cam.local.position) - initial_zoom = cam.zoom - - # Zoom in at a specific world point - center = (30.0, 70.0) - canvas.zoom(factor=0.5, center=center) - - # Camera zoom should have increased (zoomed in) - assert cam.zoom > initial_zoom - - # Camera should have moved toward the center point - new_pos = tuple(cam.local.position) - assert abs(center[0] - new_pos[0]) < abs(center[0] - initial_pos[0]) - assert abs(center[1] - new_pos[1]) < abs(center[1] - initial_pos[1]) - - # Zoom back out by the inverse factor - canvas.zoom(factor=2.0, center=center) - - # Should return approximately to initial state - restored_pos = tuple(cam.local.position) - assert restored_pos[:2] == pytest.approx(initial_pos[:2]) - assert cam.zoom == pytest.approx(initial_zoom) - - canvas.close() - - -@pytest.mark.usefixtures("any_app") -def test_canvas_to_world_scale_aware_offset() -> None: - """canvas_to_world pixel-center offset must scale with pixel size. - - Regression: a constant +0.5 world-space offset (instead of +0.5*scale) - caused data-index errors when scales != 1. - """ - for sx, sy in [(1.0, 1.0), (0.2, 0.2), (5.0, 3.0)]: - canvas = GfxArrayCanvas(ArrayViewerModel()) - _force_canvas_size(canvas) - canvas.set_ndim(2) - # must keep a reference: _elements is a WeakValueDictionary - handle = canvas.add_image(np.zeros((100, 100), dtype=np.float32)) - canvas.set_scales((sy, sx)) - canvas.set_range() - - wx, wy, _ = canvas.canvas_to_world((300, 300)) - # canvas center should map to approximately the image center - data_x, data_y = int(wx / sx), int(wy / sy) - assert 49 <= data_x <= 51, f"scale=({sx},{sy}): data_x={data_x}" - assert 49 <= data_y <= 51, f"scale=({sx},{sy}): data_y={data_y}" - - del handle - canvas.close() - - -@pytest.mark.usefixtures("any_app") -def test_no_gpu_memory_leak_on_remove() -> None: - """GPU resource memory should not grow when image handles are removed.""" - canvas = GfxArrayCanvas(ArrayViewerModel()) - _force_canvas_size(canvas) - canvas.set_ndim(2) - - tracker = cast("ObjectCountDiagnostics", wgpu.diagnostics.object_counts).tracker - - # create and remove an image to warm up - handle = canvas.add_image(np.zeros((64, 64), dtype=np.float32)) - handle.remove() - gc.collect() - - baseline = sum(tracker.amounts.values()) - - for _ in range(5): - handle = canvas.add_image(np.zeros((64, 64), dtype=np.float32)) - handle.remove() - gc.collect() - - # assert that no new GPU resources are still alive after the add/remove cycles - assert sum(tracker.amounts.values()) <= baseline - - canvas.close() diff --git a/tests/views/_pygfx/test_histogram.py b/tests/views/_pygfx/test_histogram.py deleted file mode 100644 index ee40126b..00000000 --- a/tests/views/_pygfx/test_histogram.py +++ /dev/null @@ -1,174 +0,0 @@ -from __future__ import annotations - -import numpy as np -import pytest -from pygfx.objects import WheelEvent - -from ndv._types import ( - CursorType, - MouseButton, - MouseMoveEvent, - MousePressEvent, - MouseReleaseEvent, -) -from ndv.models._lut_model import ClimsManual, LUTModel -from ndv.views._pygfx._histogram import PyGFXHistogramCanvas - - -# ugly hack -def _force_canvas_size( - canvas: PyGFXHistogramCanvas, w: int = 600, h: int = 600 -) -> None: - """Force the rendercanvas to report a valid size (needed before show).""" - rc = canvas._canvas - target = rc - target._size_info.set_physical_size(w, h, 1.0) - - -@pytest.mark.usefixtures("any_app") -def test_hscroll() -> None: - model = LUTModel( - visible=True, - cmap="red", - # gamma=2, - ) - histogram = PyGFXHistogramCanvas() - _force_canvas_size(histogram) - histogram.set_range(x=(0, 10), y=(0, 1)) - histogram.model = model - left, right = 0, 10 - histogram.set_clims((left, right)) - - old_x = histogram._camera.local.position[0] - old_width = histogram._camera.width - evt = WheelEvent(type="wheel", x=5, y=5, dx=-120, dy=0) - histogram._controller.handle_event(evt, histogram._plot_view) - new_x = histogram._camera.local.position[0] - new_width = histogram._camera.width - assert new_x < old_x - assert abs(new_width - old_width) <= 1e-6 - - evt = WheelEvent(type="wheel", x=5, y=5, dx=120, dy=0) - histogram._controller.handle_event(evt, histogram._plot_view) - new_x = histogram._camera.local.position[0] - new_width = histogram._camera.width - assert abs(new_x - old_x) <= 1e-6 - assert abs(new_width - old_width) <= 1e-6 - - histogram.close() - - -@pytest.mark.usefixtures("any_app") -def test_highlight() -> None: - # Set up a histogram - histogram = PyGFXHistogramCanvas() - assert not histogram._highlight.visible - assert histogram._highlight.local.x == 0 - assert histogram._highlight.local.scale_y == 1 - - # Add some data... - values = np.random.randint(0, 100, (100)) - bin_edges = np.linspace(0, 10, values.size + 1) - histogram.set_data(values, bin_edges) - # ...and ensure the scale is updated - assert histogram._highlight.local.scale_y == values.max() / 0.98 - - # Highlight a value... - histogram.highlight(5) - # ...and ensure the highlight is shown in the right place - assert histogram._highlight.visible - assert histogram._highlight.local.x == 5 - - # Remove the highlight... - histogram.highlight(None) - # ...and ensure the highlight is hidden - assert not histogram._highlight.visible - - histogram.close() - - -@pytest.mark.usefixtures("any_app") -def test_interaction() -> None: - """Checks basic histogram functionality.""" - model = LUTModel( - visible=True, - cmap="red", - # gamma=2, - ) - histogram = PyGFXHistogramCanvas() - _force_canvas_size(histogram) - histogram.set_range(x=(0, 10), y=(0, 1)) - histogram.model = model - left, right = 0, 10 - histogram.set_clims((left, right)) - - def world_to_canvas(x: float, y: float) -> tuple[float, float]: - return histogram.world_to_canvas((x, y, 0)) - - # Test cursors - x, y = world_to_canvas((left + right) / 2, 0.5) - assert ( - histogram.get_cursor(MouseMoveEvent(x=x, y=y, btn=MouseButton.NONE)) - == CursorType.V_ARROW - ) - x, y = world_to_canvas(left, 0) - assert ( - histogram.get_cursor(MouseMoveEvent(x=x, y=y, btn=MouseButton.NONE)) - == CursorType.H_ARROW - ) - x, y = world_to_canvas(right, 0) - assert ( - histogram.get_cursor(MouseMoveEvent(x=x, y=y, btn=MouseButton.NONE)) - == CursorType.H_ARROW - ) - - # Select and move gamma - x, y = world_to_canvas((left + right) / 2, 0.5) - histogram.on_mouse_press(MousePressEvent(x=x, y=y, btn=MouseButton.LEFT)) - x, y = world_to_canvas((left + right) / 2, 0.75) - histogram.on_mouse_move(MouseMoveEvent(x=x, y=y, btn=MouseButton.LEFT)) - histogram.on_mouse_release(MouseReleaseEvent(x=x, y=y, btn=MouseButton.LEFT)) - assert model.gamma == -np.log2(0.75) - - # Double clicking gamma resets to 1. - x, y = world_to_canvas((left + right) / 2, 0.75) - histogram.on_mouse_double_press(MousePressEvent(x=x, y=y, btn=MouseButton.LEFT)) - assert model.gamma == 1 - - # Select and move the left clim - x, y = world_to_canvas(left, 0) - histogram.on_mouse_press(MousePressEvent(x=x, y=y, btn=MouseButton.LEFT)) - left = 1 - x, y = world_to_canvas(left, 0) - histogram.on_mouse_move(MouseMoveEvent(x=x, y=y, btn=MouseButton.LEFT)) - histogram.on_mouse_release(MouseReleaseEvent(x=x, y=y, btn=MouseButton.LEFT)) - assert model.clims == ClimsManual(min=left, max=right) - - # Select and move the right clim - x, y = world_to_canvas(right, 0) - histogram.on_mouse_press(MousePressEvent(x=x, y=y, btn=MouseButton.LEFT)) - right = 9 - x, y = world_to_canvas(right, 0) - histogram.on_mouse_move(MouseMoveEvent(x=x, y=y, btn=MouseButton.LEFT)) - histogram.on_mouse_release(MouseReleaseEvent(x=x, y=y, btn=MouseButton.LEFT)) - assert model.clims == ClimsManual(min=left, max=right) - - # Ensure the right clim cannot move beyond the left clim - x, y = world_to_canvas(right, 0) - histogram.on_mouse_press(MousePressEvent(x=x, y=y, btn=MouseButton.LEFT)) - right = 0 - x, y = world_to_canvas(right, 0) - histogram.on_mouse_move(MouseMoveEvent(x=x, y=y, btn=MouseButton.LEFT)) - histogram.on_mouse_release(MouseReleaseEvent(x=x, y=y, btn=MouseButton.LEFT)) - assert model.clims == ClimsManual(min=left, max=left) - - # Ensure right clim is chosen when overlapping - x, y = world_to_canvas(left, 0) - histogram.on_mouse_press(MousePressEvent(x=x, y=y, btn=MouseButton.LEFT)) - right = 9 - x, y = world_to_canvas(right, 0) - histogram.on_mouse_move(MouseMoveEvent(x=x, y=y, btn=MouseButton.LEFT)) - histogram.on_mouse_release(MouseReleaseEvent(x=x, y=y, btn=MouseButton.LEFT)) - assert model.clims == ClimsManual(min=left, max=right) - - histogram.close() diff --git a/tests/views/_pygfx/test_shared_histogram.py b/tests/views/_pygfx/test_shared_histogram.py deleted file mode 100644 index 51c750a8..00000000 --- a/tests/views/_pygfx/test_shared_histogram.py +++ /dev/null @@ -1,299 +0,0 @@ -"""Tests for PyGFXSharedHistogramCanvas visual behavior.""" - -from __future__ import annotations - -import numpy as np -import pytest -from pytest import fixture - -from ndv._types import ( - MouseButton, - MouseMoveEvent, - MousePressEvent, - MouseReleaseEvent, -) -from ndv.views._pygfx._shared_histogram import PyGFXSharedHistogramCanvas - - -def _force_canvas_size( - canvas: PyGFXSharedHistogramCanvas, w: int = 600, h: int = 600 -) -> None: - """Force the rendercanvas to report a valid size (needed before show).""" - rc = canvas._canvas - rc._size_info.set_physical_size(w, h, 1.0) - - -@fixture -def hist() -> PyGFXSharedHistogramCanvas: - canvas = PyGFXSharedHistogramCanvas() - _force_canvas_size(canvas) - canvas.set_range(x=(0, 100), y=(0, 1)) - return canvas - - -def _world_to_canvas( - hist: PyGFXSharedHistogramCanvas, x: float, y: float -) -> tuple[float, float]: - return hist.world_to_canvas((x, y, 0)) - - -# ---------- Channel lifecycle ---------- - - -@pytest.mark.usefixtures("any_app") -def test_channel_creation(hist: PyGFXSharedHistogramCanvas) -> None: - """Channels are created lazily on first data/color set.""" - assert len(hist._channels) == 0 - - counts = np.array([1, 2, 3, 2, 1]) - edges = np.array([0, 20, 40, 60, 80, 100], dtype=float) - hist.set_channel_data(0, counts, edges) - assert 0 in hist._channels - - hist.set_channel_data(1, counts, edges) - assert 1 in hist._channels - assert len(hist._channels) == 2 - - -@pytest.mark.usefixtures("any_app") -def test_channel_removal(hist: PyGFXSharedHistogramCanvas) -> None: - """Removed channels are cleaned up.""" - counts = np.array([1, 2, 3]) - edges = np.array([0, 33, 66, 100], dtype=float) - hist.set_channel_data(0, counts, edges) - hist.set_channel_data(1, counts, edges) - - hist.remove_channel(0) - assert 0 not in hist._channels - assert 1 in hist._channels - - -# ---------- Visibility ---------- - - -@pytest.mark.usefixtures("any_app") -def test_channel_visibility(hist: PyGFXSharedHistogramCanvas) -> None: - """Channel visibility controls all visual elements.""" - counts = np.array([1, 2, 3]) - edges = np.array([0, 33, 66, 100], dtype=float) - hist.set_channel_data(0, counts, edges) - hist.set_channel_clims(0, (10, 90)) - - ch = hist._channels[0] - assert ch.visible is True - assert ch.area_mesh.visible is True - assert ch.outline.visible is True - - hist.set_channel_visible(0, False) - assert ch.visible is False - assert ch.area_mesh.visible is False - assert ch.outline.visible is False - assert ch.left_clim.visible is False - assert ch.right_clim.visible is False - assert ch.gamma_line.visible is False - assert ch.gamma_handle.visible is False - - hist.set_channel_visible(0, True) - assert ch.visible is True - assert ch.area_mesh.visible is True - - -# ---------- key=None (grayscale channel) ---------- - - -@pytest.mark.usefixtures("any_app") -def test_none_key_channel() -> None: - """key=None (grayscale default channel) works correctly.""" - hist = PyGFXSharedHistogramCanvas() - _force_canvas_size(hist) - counts = np.array([5, 10, 15, 10, 5]) - edges = np.linspace(0, 100, 6) - hist.set_channel_data(None, counts, edges) - - assert None in hist._channels - ch = hist._channels[None] - assert ch.visible is True - - hist.set_channel_clims(None, (20, 80)) - assert ch.clims == (20, 80) - - hist.set_channel_visible(None, False) - assert ch.visible is False - - -# ---------- Clim/gamma interaction ---------- - - -@pytest.mark.usefixtures("any_app") -def test_clim_drag_emits_signal() -> None: - """Dragging a clim handle emits climsChanged with correct key.""" - hist = PyGFXSharedHistogramCanvas() - _force_canvas_size(hist) - counts = np.array([5, 10, 15, 10, 5]) - edges = np.linspace(0, 100, 6) - hist.set_channel_data(0, counts, edges) - hist.set_channel_clims(0, (10, 90)) - hist.set_channel_color(0, (0, 1, 0, 1)) - hist.set_range(x=(0, 100)) - - received: list = [] - hist.climsChanged.connect(lambda key, clims: received.append((key, clims))) - - # Grab the left clim - lx, ly = _world_to_canvas(hist, 10, 0) - hist.on_mouse_press(MousePressEvent(x=lx, y=ly, btn=MouseButton.LEFT)) - # Drag to new position - nx, ny = _world_to_canvas(hist, 30, 0) - hist.on_mouse_move(MouseMoveEvent(x=nx, y=ny, btn=MouseButton.LEFT)) - hist.on_mouse_release(MouseReleaseEvent(x=nx, y=ny, btn=MouseButton.LEFT)) - - assert len(received) >= 1 - assert received[-1][0] == 0 # correct channel key - assert received[-1][1][0] >= 25 # left clim moved right (approx) - - -@pytest.mark.usefixtures("any_app") -def test_none_key_clim_drag() -> None: - """Clim dragging works for key=None (grayscale channel).""" - hist = PyGFXSharedHistogramCanvas() - _force_canvas_size(hist) - counts = np.array([5, 10, 15, 10, 5]) - edges = np.linspace(0, 100, 6) - hist.set_channel_data(None, counts, edges) - hist.set_channel_clims(None, (10, 90)) - hist.set_channel_color(None, (0.5, 0.5, 0.5, 1)) - hist.set_range(x=(0, 100)) - - received: list = [] - hist.climsChanged.connect(lambda key, clims: received.append((key, clims))) - - # Grab the right clim - rx, ry = _world_to_canvas(hist, 90, 0) - hist.on_mouse_press(MousePressEvent(x=rx, y=ry, btn=MouseButton.LEFT)) - nx, ny = _world_to_canvas(hist, 70, 0) - hist.on_mouse_move(MouseMoveEvent(x=nx, y=ny, btn=MouseButton.LEFT)) - hist.on_mouse_release(MouseReleaseEvent(x=nx, y=ny, btn=MouseButton.LEFT)) - - assert len(received) >= 1 - assert received[-1][0] is None # key is None, not _NO_KEY - - -@pytest.mark.usefixtures("any_app") -def test_gamma_double_click_resets() -> None: - """Double-clicking gamma handle emits gammaChanged with 1.0.""" - hist = PyGFXSharedHistogramCanvas() - _force_canvas_size(hist) - counts = np.array([5, 10, 15, 10, 5]) - edges = np.linspace(0, 100, 6) - hist.set_channel_data(0, counts, edges) - hist.set_channel_clims(0, (0, 100)) - hist.set_channel_gamma(0, 2.0) - hist.set_channel_color(0, (0, 1, 0, 1)) - hist.set_range(x=(0, 100)) - - received: list = [] - hist.gammaChanged.connect(lambda key, gamma: received.append((key, gamma))) - - # Find gamma handle position: midpoint of clims, y = 2^(-gamma) * y_top - y_range = hist._compute_y_range() - y_top = (y_range[1] if y_range else 1.0) * 0.98 - mid_x = 50.0 - mid_y = (2 ** (-2.0)) * y_top - gx, gy = _world_to_canvas(hist, mid_x, mid_y) - hist.on_mouse_double_press(MousePressEvent(x=gx, y=gy, btn=MouseButton.LEFT)) - - assert len(received) == 1 - assert received[0] == (0, 1.0) - - -# ---------- Clim bounds ---------- - - -@pytest.mark.usefixtures("any_app") -def test_clim_bounds_constrain_drag() -> None: - """Clim drag respects clim_bounds.""" - hist = PyGFXSharedHistogramCanvas() - _force_canvas_size(hist) - counts = np.array([5, 10, 15, 10, 5]) - edges = np.linspace(0, 100, 6) - hist.set_channel_data(0, counts, edges) - hist.set_channel_clims(0, (10, 90)) - hist.set_channel_color(0, (1, 0, 0, 1)) - hist.set_clim_bounds((0, 255)) - hist.set_range(x=(0, 100)) - - received: list = [] - hist.climsChanged.connect(lambda key, clims: received.append((key, clims))) - - # Try to drag left clim below 0 - lx, ly = _world_to_canvas(hist, 10, 0) - hist.on_mouse_press(MousePressEvent(x=lx, y=ly, btn=MouseButton.LEFT)) - nx, ny = _world_to_canvas(hist, -50, 0) - hist.on_mouse_move(MouseMoveEvent(x=nx, y=ny, btn=MouseButton.LEFT)) - hist.on_mouse_release(MouseReleaseEvent(x=nx, y=ny, btn=MouseButton.LEFT)) - - if received: - # Left clim should be clamped to 0, not -50 - assert received[-1][1][0] >= 0 - - -# ---------- Log scale ---------- - - -@pytest.mark.usefixtures("any_app") -def test_log_scale() -> None: - """Log scale can be toggled without errors.""" - hist = PyGFXSharedHistogramCanvas() - counts = np.array([5, 10, 15, 10, 5]) - edges = np.linspace(0, 100, 6) - hist.set_channel_data(0, counts, edges) - - hist.set_log_base(10) - assert hist._log_base == 10 - - hist.set_log_base(None) - assert hist._log_base is None - - -# ---------- Highlight ---------- - - -@pytest.mark.usefixtures("any_app") -def test_highlight() -> None: - """Highlight line shows and hides correctly.""" - hist = PyGFXSharedHistogramCanvas() - assert not hist._highlight_lines - - hist.highlight({"ch0": 50}) - assert hist._highlight_lines["ch0"].visible - - hist.highlight({}) - assert not hist._highlight_lines["ch0"].visible - - -# ---------- Legend ---------- - - -@pytest.mark.usefixtures("any_app") -def test_legend_names() -> None: - """Legend entries track channel names.""" - hist = PyGFXSharedHistogramCanvas() - counts = np.array([5, 10, 15]) - edges = np.array([0, 33, 66, 100], dtype=float) - - hist.set_channel_data(0, counts, edges) - hist.set_channel_name(0, "FITC") - hist.set_channel_data(1, counts, edges) - hist.set_channel_name(1, "DAPI") - - ch0 = hist._channels[0] - ch1 = hist._channels[1] - assert ch0.name == "FITC" - assert ch1.name == "DAPI" - - # Hiding a channel updates state - hist.set_channel_visible(0, False) - assert ch0.visible is False - - hist.set_channel_visible(0, True) - assert ch0.visible is True diff --git a/tests/views/_pygfx/test_volume_downsample.py b/tests/views/_pygfx/test_volume_downsample.py deleted file mode 100644 index d4730c57..00000000 --- a/tests/views/_pygfx/test_volume_downsample.py +++ /dev/null @@ -1,149 +0,0 @@ -from __future__ import annotations - -from unittest.mock import patch - -import numpy as np -import pygfx -import pytest - -from ndv.models._viewer_model import ArrayViewerModel -from ndv.views._pygfx._array_canvas import GfxArrayCanvas - -PATCH_TARGET = "ndv.views._pygfx._array_canvas._get_max_texture_sizes" - - -def _force_canvas_size(canvas: GfxArrayCanvas, w: int = 600, h: int = 600) -> None: - rc = canvas._canvas - rc._size_info.set_physical_size(w, h, 1.0) - - -@pytest.mark.usefixtures("any_app") -def test_volume_downsampled_when_exceeding_texture_limit() -> None: - """Volume data should be stride-downsampled to fit GPU texture limits.""" - canvas = GfxArrayCanvas(ArrayViewerModel()) - _force_canvas_size(canvas) - canvas.set_ndim(3) - - data = np.zeros((10, 100, 100), dtype=np.float32) - with patch(PATCH_TARGET, return_value=(None, 64)): - handle = canvas.add_volume(data) - - # shape (10, 100, 100) with max 64 -> strides (1, 2, 2) - assert handle._downsample_factors == (1, 2, 2) - assert handle.data().shape == (10, 50, 50) - - # set_data with the same original shape should also downsample - with patch(PATCH_TARGET, return_value=(None, 64)): - handle.set_data(data) - assert handle.data().shape == (10, 50, 50) - - canvas.close() - - -@pytest.mark.usefixtures("any_app") -def test_volume_no_downsample_when_within_limit() -> None: - """Volume data within texture limits should not be downsampled.""" - canvas = GfxArrayCanvas(ArrayViewerModel()) - _force_canvas_size(canvas) - canvas.set_ndim(3) - - data = np.zeros((10, 50, 50), dtype=np.float32) - with patch(PATCH_TARGET, return_value=(None, 64)): - handle = canvas.add_volume(data) - - assert handle._downsample_factors == (1, 1, 1) - assert handle.data().shape == (10, 50, 50) - - canvas.close() - - -@pytest.mark.usefixtures("any_app") -def test_image_downsampled_when_exceeding_2d_texture_limit() -> None: - """2D image data should be stride-downsampled to fit GPU texture limits.""" - canvas = GfxArrayCanvas(ArrayViewerModel()) - _force_canvas_size(canvas) - canvas.set_ndim(2) - - data = np.zeros((100, 100), dtype=np.float32) - with patch(PATCH_TARGET, return_value=(64, None)): - handle = canvas.add_image(data) - - assert handle._downsample_factors == (2, 2) - assert handle.data().shape == (50, 50) - - # set_data should also downsample - with patch(PATCH_TARGET, return_value=(64, None)): - handle.set_data(data) - assert handle.data().shape == (50, 50) - - canvas.close() - - -@pytest.mark.usefixtures("any_app") -def test_set_scales_compensates_for_volume_downsample() -> None: - """set_scales should multiply by downsample factors so world coords stay correct.""" - canvas = GfxArrayCanvas(ArrayViewerModel()) - _force_canvas_size(canvas) - canvas.set_ndim(3) - - data = np.zeros((10, 100, 100), dtype=np.float32) - with patch(PATCH_TARGET, return_value=(None, 64)): - handle = canvas.add_volume(data) - - assert handle._downsample_factors == (1, 2, 2) - - # scales in data order (Z, Y, X) = (0.4, 0.2, 0.2) - with patch(PATCH_TARGET, return_value=(None, 64)): - canvas.set_scales((0.4, 0.2, 0.2)) - - vol = handle._image - assert isinstance(vol, pygfx.Volume) - sx, sy, sz = vol.local.scale - # pygfx order is (x=W, y=H, z=D), reversed from data order - # x scale: 0.2 (X) * 2 (fw) = 0.4 - # y scale: 0.2 (Y) * 2 (fh) = 0.4 - # z scale: 0.4 (Z) * 1 (fd) = 0.4 - assert sx == pytest.approx(0.4) - assert sy == pytest.approx(0.4) - assert sz == pytest.approx(0.4) - - canvas.close() - - -@pytest.mark.usefixtures("any_app") -def test_no_downsample_when_limits_none() -> None: - """When GPU limits are unavailable, data should pass through unchanged.""" - canvas = GfxArrayCanvas(ArrayViewerModel()) - _force_canvas_size(canvas) - canvas.set_ndim(3) - - data = np.zeros((10, 100, 100), dtype=np.float32) - with patch(PATCH_TARGET, return_value=(None, None)): - handle = canvas.add_volume(data) - - assert handle._downsample_factors == () - assert handle.data().shape == (10, 100, 100) - - canvas.close() - - -@pytest.mark.usefixtures("any_app") -def test_set_data_with_different_shape() -> None: - """set_data with a new shape should re-downsample correctly.""" - canvas = GfxArrayCanvas(ArrayViewerModel()) - _force_canvas_size(canvas) - canvas.set_ndim(3) - - data1 = np.zeros((10, 100, 100), dtype=np.float32) - with patch(PATCH_TARGET, return_value=(None, 64)): - handle = canvas.add_volume(data1) - assert handle.data().shape == (10, 50, 50) - - # now set_data with a larger volume - data2 = np.zeros((200, 100, 100), dtype=np.float32) - with patch(PATCH_TARGET, return_value=(None, 64)): - handle.set_data(data2) - assert handle._downsample_factors == (4, 2, 2) - assert handle.data().shape == (50, 50, 50) - - canvas.close() diff --git a/tests/views/_qt/test_array_view.py b/tests/views/_qt/test_array_view.py index f1f405fc..ef19dffa 100644 --- a/tests/views/_qt/test_array_view.py +++ b/tests/views/_qt/test_array_view.py @@ -4,14 +4,10 @@ from unittest.mock import Mock from pytest import fixture -from qtpy.QtCore import QEvent, Qt -from qtpy.QtGui import QKeyEvent -from qtpy.QtWidgets import QApplication, QWidget +from qtpy.QtWidgets import QWidget -from ndv._types import KeyCode, KeyMod, KeyPressEvent from ndv.models._viewer_model import ArrayViewerModel -from ndv.views._app import get_histogram_canvas_class -from ndv.views._qt._app import QtAppWrap +from ndv.views._histogram import Histogram from ndv.views._qt._array_view import PlayButton, QtArrayView if TYPE_CHECKING: @@ -76,7 +72,7 @@ def test_histogram(viewer: QtArrayView) -> None: # Test adding the histogram widget puts it on the relevant lut assert lut.histogram is None - histogram = get_histogram_canvas_class()() # will raise if not supported + histogram = Histogram() # will raise if not supported viewer.add_histogram(channel, histogram) assert lut.histogram is not None @@ -92,39 +88,3 @@ def test_play_btn(viewer: QtArrayView, qtbot: QtBot) -> None: with qtbot.waitSignal(dims_wdg.currentIndexChanged, timeout=1000): play_btn.click() play_btn.click() # stop it - - -def test_key_event_filter(qtbot: QtBot) -> None: - app = QtAppWrap() - view = QtArrayView(QWidget(), ArrayViewerModel()) - qtbot.addWidget(view.frontend_widget()) - - received: list[KeyPressEvent] = [] - view.keyPressed.connect(received.append) - - disconnect = app.filter_key_events(view.frontend_widget(), view) - - widget = view.frontend_widget() - - # Simulate a Right arrow key press on the widget - event = QKeyEvent( - QEvent.Type.KeyPress, Qt.Key.Key_Right, Qt.KeyboardModifier.NoModifier - ) - QApplication.sendEvent(widget, event) - assert len(received) == 1 - assert received[0].key == KeyCode.RIGHT - assert received[0].mods == KeyMod.NONE - - # Simulate Shift+Left - event = QKeyEvent( - QEvent.Type.KeyPress, - Qt.Key.Key_Left, - Qt.KeyboardModifier.ShiftModifier, - ) - QApplication.sendEvent(widget, event) - assert len(received) == 2 - assert received[1].key == KeyCode.LEFT - assert received[1].mods == KeyMod.SHIFT - - # Cleanup - disconnect() diff --git a/tests/views/_qt/test_lut_view.py b/tests/views/_qt/test_lut_view.py index 7835a11a..30b51bfd 100644 --- a/tests/views/_qt/test_lut_view.py +++ b/tests/views/_qt/test_lut_view.py @@ -8,8 +8,8 @@ from qtpy.QtWidgets import QWidget from ndv.models._lut_model import ClimsManual, ClimsMinMax, ClimsPercentile, LUTModel +from ndv.views._histogram import Histogram from ndv.views._qt._array_view import QLUTView -from ndv.views.bases._graphics._canvas import HistogramCanvas if TYPE_CHECKING: from pytestqt.qtbot import QtBot @@ -98,7 +98,7 @@ def test_QLUTView_update_view(model: LUTModel, view: QLUTView) -> None: def test_QLUTView_histogram_controls(model: LUTModel, view: QLUTView) -> None: # Mock up a histogram - hist_mock = MagicMock(spec=HistogramCanvas) + hist_mock = MagicMock(spec=Histogram) hist_frontend = QWidget() hist_mock.frontend_widget.return_value = hist_frontend diff --git a/tests/views/_vispy/__init__.py b/tests/views/_vispy/__init__.py deleted file mode 100644 index 087fdb61..00000000 --- a/tests/views/_vispy/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -"""Tests pertaining to VisPy components""" - -from importlib.util import find_spec - -import pytest - -if not find_spec("vispy"): - pytest.skip( - "Skipping vispy tests as vispy is not installed", allow_module_level=True - ) diff --git a/tests/views/_vispy/test_array_canvas.py b/tests/views/_vispy/test_array_canvas.py deleted file mode 100644 index bf24e71f..00000000 --- a/tests/views/_vispy/test_array_canvas.py +++ /dev/null @@ -1,43 +0,0 @@ -from __future__ import annotations - -import numpy as np -import pytest - -from ndv.models._viewer_model import ArrayViewerModel -from ndv.views._vispy._array_canvas import VispyArrayCanvas - - -@pytest.mark.usefixtures("any_app") -def test_zoom_center() -> None: - """Zoom should keep the center point fixed in world space.""" - canvas = VispyArrayCanvas(ArrayViewerModel()) - canvas.set_ndim(2) - canvas.add_image(np.random.rand(100, 100).astype(np.float32)) - canvas.set_range() - - cam = canvas._camera - initial_rect = cam.rect - - # Zoom in at a specific world point - center = (30.0, 70.0) - canvas.zoom(factor=0.5, center=center) - - # Camera rect should have changed (smaller = zoomed in) - new_rect = cam.rect - assert new_rect.width < initial_rect.width - - # The center point should still be inside the rect - assert new_rect.left <= center[0] <= new_rect.right - assert new_rect.bottom <= center[1] <= new_rect.top - - # Zoom back out by the inverse factor - canvas.zoom(factor=2.0, center=center) - - # Should return approximately to initial state - restored_rect = cam.rect - assert restored_rect.width == pytest.approx(initial_rect.width) - assert restored_rect.height == pytest.approx(initial_rect.height) - assert restored_rect.left == pytest.approx(initial_rect.left) - assert restored_rect.bottom == pytest.approx(initial_rect.bottom) - - canvas.close() diff --git a/tests/views/_vispy/test_histogram.py b/tests/views/_vispy/test_histogram.py deleted file mode 100644 index 38eff224..00000000 --- a/tests/views/_vispy/test_histogram.py +++ /dev/null @@ -1,163 +0,0 @@ -from __future__ import annotations - -import numpy as np -import pytest -from pytest import fixture -from vispy.app.canvas import MouseEvent -from vispy.scene.events import SceneMouseEvent - -from ndv._types import ( - CursorType, - MouseButton, - MouseMoveEvent, - MousePressEvent, - MouseReleaseEvent, -) -from ndv.models._lut_model import ClimsManual, LUTModel -from ndv.views._vispy._histogram import VispyHistogramCanvas - - -@fixture -def model() -> LUTModel: - return LUTModel( - visible=True, - cmap="red", - gamma=2, - ) - - -@fixture -def histogram() -> VispyHistogramCanvas: - canvas = VispyHistogramCanvas() - canvas.set_range(x=(0, 10), y=(0, 1)) - return canvas - - -@pytest.mark.usefixtures("any_app") -def test_hscroll(histogram: VispyHistogramCanvas) -> None: - old_rect = histogram.plot.camera.rect - evt = SceneMouseEvent( - MouseEvent(type="mouse_wheel", delta=[1, 0]), histogram.plot.camera.viewbox - ) - histogram.plot.camera.viewbox_mouse_event(evt) - new_rect = histogram.plot.camera.rect - assert new_rect.left < old_rect.left - assert abs(new_rect.width - old_rect.width) <= 1e-6 - - evt = SceneMouseEvent( - MouseEvent(type="mouse_wheel", delta=[-1, 0]), histogram.plot.camera.viewbox - ) - histogram.plot.camera.viewbox_mouse_event(evt) - new_rect = histogram.plot.camera.rect - assert abs(new_rect.left - old_rect.left) <= 1e-6 - assert abs(new_rect.width - old_rect.width) <= 1e-6 - - -@pytest.mark.usefixtures("any_app") -def test_highlight() -> None: - # Set up a histogram - histogram = VispyHistogramCanvas() - assert not histogram._highlight.visible - tform = histogram._highlight.transform - assert np.allclose(tform.map(histogram._highlight.pos)[:, :2], ((0, 0), (0, 1))) - - # Add some data... - values = np.random.randint(0, 100, (100)) - bin_edges = np.linspace(0, 10, values.size + 1) - histogram.set_data(values, bin_edges) - # ...and ensure the scale is updated - assert np.allclose( - tform.map(histogram._highlight.pos)[:, :2], ((0, 0), (0, values.max() / 0.98)) - ) - - # Highlight a value... - histogram.highlight(5) - # ...and ensure the highlight is shown in the right place - assert histogram._highlight.visible - assert np.allclose( - tform.map(histogram._highlight.pos)[:, :2], ((5, 0), (5, values.max() / 0.98)) - ) - - # Remove the highlight... - histogram.highlight(None) - # ...and ensure the highlight is hidden - assert not histogram._highlight.visible - - histogram.close() - - -@pytest.mark.usefixtures("any_app") -def test_interaction(model: LUTModel, histogram: VispyHistogramCanvas) -> None: - """Checks basic histogram functionality.""" - histogram.model = model - left, right = 0, 10 - histogram.set_clims((left, right)) - - def world_to_canvas(x: float, y: float) -> tuple[float, float]: - return tuple(histogram.node_tform.imap((x, y))[:2]) # pyright: ignore[reportReturnType] - - # Test cursors - x, y = world_to_canvas((left + right) / 2, 0.5) - assert ( - histogram.get_cursor(MouseMoveEvent(x=x, y=y, btn=MouseButton.NONE)) - == CursorType.V_ARROW - ) - x, y = world_to_canvas(left, 0) - assert ( - histogram.get_cursor(MouseMoveEvent(x=x, y=y, btn=MouseButton.NONE)) - == CursorType.H_ARROW - ) - x, y = world_to_canvas(right, 0) - assert ( - histogram.get_cursor(MouseMoveEvent(x=x, y=y, btn=MouseButton.NONE)) - == CursorType.H_ARROW - ) - - # Select and move gamma - x, y = world_to_canvas((left + right) / 2, 0.5) - histogram.on_mouse_press(MousePressEvent(x=x, y=y, btn=MouseButton.LEFT)) - x, y = world_to_canvas((left + right) / 2, 0.75) - histogram.on_mouse_move(MouseMoveEvent(x=x, y=y, btn=MouseButton.LEFT)) - histogram.on_mouse_release(MouseReleaseEvent(x=x, y=y, btn=MouseButton.LEFT)) - assert model.gamma == -np.log2(0.75) - - # Double clicking gamma resets to 1. - x, y = world_to_canvas((left + right) / 2, 0.75) - histogram.on_mouse_double_press(MousePressEvent(x=x, y=y, btn=MouseButton.LEFT)) - assert model.gamma == 1 - - # Select and move the left clim - x, y = world_to_canvas(left, 0) - histogram.on_mouse_press(MousePressEvent(x=x, y=y, btn=MouseButton.LEFT)) - left = 1 - x, y = world_to_canvas(left, 0) - histogram.on_mouse_move(MouseMoveEvent(x=x, y=y, btn=MouseButton.LEFT)) - histogram.on_mouse_release(MouseReleaseEvent(x=x, y=y, btn=MouseButton.LEFT)) - assert model.clims == ClimsManual(min=left, max=right) - - # Select and move the right clim - x, y = world_to_canvas(right, 0) - histogram.on_mouse_press(MousePressEvent(x=x, y=y, btn=MouseButton.LEFT)) - right = 9 - x, y = world_to_canvas(right, 0) - histogram.on_mouse_move(MouseMoveEvent(x=x, y=y, btn=MouseButton.LEFT)) - histogram.on_mouse_release(MouseReleaseEvent(x=x, y=y, btn=MouseButton.LEFT)) - assert model.clims == ClimsManual(min=left, max=right) - - # Ensure the right clim cannot move beyond the left clim - x, y = world_to_canvas(right, 0) - histogram.on_mouse_press(MousePressEvent(x=x, y=y, btn=MouseButton.LEFT)) - right = 0 - x, y = world_to_canvas(right, 0) - histogram.on_mouse_move(MouseMoveEvent(x=x, y=y, btn=MouseButton.LEFT)) - histogram.on_mouse_release(MouseReleaseEvent(x=x, y=y, btn=MouseButton.LEFT)) - assert model.clims == ClimsManual(min=left, max=left) - - # Ensure right clim is chosen when overlapping - x, y = world_to_canvas(left, 0) - histogram.on_mouse_press(MousePressEvent(x=x, y=y, btn=MouseButton.LEFT)) - right = 9 - x, y = world_to_canvas(right, 0) - histogram.on_mouse_move(MouseMoveEvent(x=x, y=y, btn=MouseButton.LEFT)) - histogram.on_mouse_release(MouseReleaseEvent(x=x, y=y, btn=MouseButton.LEFT)) - assert model.clims == ClimsManual(min=left, max=right) diff --git a/tests/views/_vispy/test_shared_histogram.py b/tests/views/_vispy/test_shared_histogram.py deleted file mode 100644 index df84d484..00000000 --- a/tests/views/_vispy/test_shared_histogram.py +++ /dev/null @@ -1,279 +0,0 @@ -"""Tests for VispySharedHistogramCanvas visual behavior.""" - -from __future__ import annotations - -import numpy as np -import pytest -from pytest import fixture - -from ndv._types import ( - MouseButton, - MouseMoveEvent, - MousePressEvent, - MouseReleaseEvent, -) -from ndv.views._vispy._shared_histogram import VispySharedHistogramCanvas - - -@fixture -def hist() -> VispySharedHistogramCanvas: - canvas = VispySharedHistogramCanvas() - canvas.set_range(x=(0, 100), y=(0, 1)) - return canvas - - -def _world_to_canvas( - hist: VispySharedHistogramCanvas, x: float, y: float -) -> tuple[float, float]: - return tuple(hist.node_tform.imap((x, y))[:2]) # type: ignore[return-value] - - -# ---------- Channel lifecycle ---------- - - -@pytest.mark.usefixtures("any_app") -def test_channel_creation(hist: VispySharedHistogramCanvas) -> None: - """Channels are created lazily on first data/color set.""" - assert len(hist._channels) == 0 - - counts = np.array([1, 2, 3, 2, 1]) - edges = np.array([0, 20, 40, 60, 80, 100], dtype=float) - hist.set_channel_data(0, counts, edges) - assert 0 in hist._channels - - hist.set_channel_data(1, counts, edges) - assert 1 in hist._channels - assert len(hist._channels) == 2 - - -@pytest.mark.usefixtures("any_app") -def test_channel_removal(hist: VispySharedHistogramCanvas) -> None: - """Removed channels are cleaned up.""" - counts = np.array([1, 2, 3]) - edges = np.array([0, 33, 66, 100], dtype=float) - hist.set_channel_data(0, counts, edges) - hist.set_channel_data(1, counts, edges) - - hist.remove_channel(0) - assert 0 not in hist._channels - assert 1 in hist._channels - - -# ---------- Visibility ---------- - - -@pytest.mark.usefixtures("any_app") -def test_channel_visibility(hist: VispySharedHistogramCanvas) -> None: - """Channel visibility controls all visual elements.""" - counts = np.array([1, 2, 3]) - edges = np.array([0, 33, 66, 100], dtype=float) - hist.set_channel_data(0, counts, edges) - hist.set_channel_clims(0, (10, 90)) - - ch = hist._channels[0] - assert ch.visible is True - assert ch.area_mesh.visible is True - assert ch.outline.visible is True - - hist.set_channel_visible(0, False) - assert ch.visible is False - assert ch.area_mesh.visible is False - assert ch.outline.visible is False - assert ch.lut_line.visible is False - assert ch.gamma_handle.visible is False - - hist.set_channel_visible(0, True) - assert ch.visible is True - assert ch.area_mesh.visible is True - - -# ---------- key=None (grayscale channel) ---------- - - -@pytest.mark.usefixtures("any_app") -def test_none_key_channel() -> None: - """key=None (grayscale default channel) works correctly.""" - hist = VispySharedHistogramCanvas() - counts = np.array([5, 10, 15, 10, 5]) - edges = np.linspace(0, 100, 6) - hist.set_channel_data(None, counts, edges) - - assert None in hist._channels - ch = hist._channels[None] - assert ch.visible is True - - hist.set_channel_clims(None, (20, 80)) - assert ch.clims == (20, 80) - - hist.set_channel_visible(None, False) - assert ch.visible is False - - -# ---------- Clim/gamma interaction ---------- - - -@pytest.mark.usefixtures("any_app") -def test_clim_drag_emits_signal() -> None: - """Dragging a clim handle emits climsChanged with correct key.""" - hist = VispySharedHistogramCanvas() - counts = np.array([5, 10, 15, 10, 5]) - edges = np.linspace(0, 100, 6) - hist.set_channel_data(0, counts, edges) - hist.set_channel_clims(0, (10, 90)) - hist.set_channel_color(0, (0, 1, 0, 1)) - - received: list = [] - hist.climsChanged.connect(lambda key, clims: received.append((key, clims))) - - # Grab the left clim - lx, ly = _world_to_canvas(hist, 10, 0) - hist.on_mouse_press(MousePressEvent(x=lx, y=ly, btn=MouseButton.LEFT)) - # Drag to new position - nx, ny = _world_to_canvas(hist, 30, 0) - hist.on_mouse_move(MouseMoveEvent(x=nx, y=ny, btn=MouseButton.LEFT)) - hist.on_mouse_release(MouseReleaseEvent(x=nx, y=ny, btn=MouseButton.LEFT)) - - assert len(received) >= 1 - assert received[-1][0] == 0 # correct channel key - assert received[-1][1][0] >= 25 # left clim moved right (approx) - - -@pytest.mark.usefixtures("any_app") -def test_none_key_clim_drag() -> None: - """Clim dragging works for key=None (grayscale channel).""" - hist = VispySharedHistogramCanvas() - counts = np.array([5, 10, 15, 10, 5]) - edges = np.linspace(0, 100, 6) - hist.set_channel_data(None, counts, edges) - hist.set_channel_clims(None, (10, 90)) - hist.set_channel_color(None, (0.5, 0.5, 0.5, 1)) - - received: list = [] - hist.climsChanged.connect(lambda key, clims: received.append((key, clims))) - - # Grab the right clim - rx, ry = _world_to_canvas(hist, 90, 0) - hist.on_mouse_press(MousePressEvent(x=rx, y=ry, btn=MouseButton.LEFT)) - nx, ny = _world_to_canvas(hist, 70, 0) - hist.on_mouse_move(MouseMoveEvent(x=nx, y=ny, btn=MouseButton.LEFT)) - hist.on_mouse_release(MouseReleaseEvent(x=nx, y=ny, btn=MouseButton.LEFT)) - - assert len(received) >= 1 - assert received[-1][0] is None # key is None, not _NO_KEY - - -@pytest.mark.usefixtures("any_app") -def test_gamma_double_click_resets() -> None: - """Double-clicking gamma handle emits gammaChanged with 1.0.""" - hist = VispySharedHistogramCanvas() - counts = np.array([5, 10, 15, 10, 5]) - edges = np.linspace(0, 100, 6) - hist.set_channel_data(0, counts, edges) - hist.set_channel_clims(0, (0, 100)) - hist.set_channel_gamma(0, 2.0) - hist.set_channel_color(0, (0, 1, 0, 1)) - - received: list = [] - hist.gammaChanged.connect(lambda key, gamma: received.append((key, gamma))) - - # Find gamma handle position: midpoint of clims, y = 2^(-gamma) * y_top - y_range = hist._compute_y_range() - y_top = (y_range[1] if y_range else 1.0) * 0.98 - mid_x = 50.0 - mid_y = (2 ** (-2.0)) * y_top - gx, gy = _world_to_canvas(hist, mid_x, mid_y) - hist.on_mouse_double_press(MousePressEvent(x=gx, y=gy, btn=MouseButton.LEFT)) - - assert len(received) == 1 - assert received[0] == (0, 1.0) - - -# ---------- Clim bounds ---------- - - -@pytest.mark.usefixtures("any_app") -def test_clim_bounds_constrain_drag() -> None: - """Clim drag respects clim_bounds.""" - hist = VispySharedHistogramCanvas() - counts = np.array([5, 10, 15, 10, 5]) - edges = np.linspace(0, 100, 6) - hist.set_channel_data(0, counts, edges) - hist.set_channel_clims(0, (10, 90)) - hist.set_channel_color(0, (1, 0, 0, 1)) - hist.set_clim_bounds((0, 255)) - - received: list = [] - hist.climsChanged.connect(lambda key, clims: received.append((key, clims))) - - # Try to drag left clim below 0 - lx, ly = _world_to_canvas(hist, 10, 0) - hist.on_mouse_press(MousePressEvent(x=lx, y=ly, btn=MouseButton.LEFT)) - nx, ny = _world_to_canvas(hist, -50, 0) - hist.on_mouse_move(MouseMoveEvent(x=nx, y=ny, btn=MouseButton.LEFT)) - hist.on_mouse_release(MouseReleaseEvent(x=nx, y=ny, btn=MouseButton.LEFT)) - - if received: - # Left clim should be clamped to 0, not -50 - assert received[-1][1][0] >= 0 - - -# ---------- Log scale ---------- - - -@pytest.mark.usefixtures("any_app") -def test_log_scale() -> None: - """Log scale can be toggled without errors.""" - hist = VispySharedHistogramCanvas() - counts = np.array([5, 10, 15, 10, 5]) - edges = np.linspace(0, 100, 6) - hist.set_channel_data(0, counts, edges) - - hist.set_log_base(10) - assert hist._log_base == 10 - - hist.set_log_base(None) - assert hist._log_base is None - - -# ---------- Highlight ---------- - - -@pytest.mark.usefixtures("any_app") -def test_highlight() -> None: - """Highlight line shows and hides correctly.""" - hist = VispySharedHistogramCanvas() - assert not hist._highlight_lines - - hist.highlight({"ch0": 50}) - assert hist._highlight_lines["ch0"].visible - - hist.highlight({}) - assert not hist._highlight_lines["ch0"].visible - - -# ---------- Legend ---------- - - -@pytest.mark.usefixtures("any_app") -def test_legend_visibility() -> None: - """Legend entries match channel visibility and names.""" - hist = VispySharedHistogramCanvas() - counts = np.array([5, 10, 15]) - edges = np.array([0, 33, 66, 100], dtype=float) - - hist.set_channel_data(0, counts, edges) - hist.set_channel_name(0, "FITC") - hist.set_channel_data(1, counts, edges) - hist.set_channel_name(1, "DAPI") - - ch0 = hist._channels[0] - ch1 = hist._channels[1] - assert ch0.legend_text.text == "● FITC" - assert ch1.legend_text.text == "● DAPI" - - # Hiding a channel hides its legend - hist.set_channel_visible(0, False) - assert not ch0.legend_text.visible - - hist.set_channel_visible(0, True) - assert ch0.legend_text.visible diff --git a/tests/views/_vispy/test_volume_downsample.py b/tests/views/_vispy/test_volume_downsample.py deleted file mode 100644 index 19ea0ec4..00000000 --- a/tests/views/_vispy/test_volume_downsample.py +++ /dev/null @@ -1,168 +0,0 @@ -from __future__ import annotations - -from unittest.mock import patch - -import numpy as np -import pytest -import vispy.visuals.transforms - -from ndv.models._viewer_model import ArrayViewerModel -from ndv.views._vispy._array_canvas import VispyArrayCanvas - -PATCH_TARGET = "ndv.views._vispy._array_canvas.get_max_texture_sizes" - - -@pytest.mark.usefixtures("any_app") -def test_volume_downsampled_when_exceeding_texture_limit() -> None: - """Volume data should be stride-downsampled to fit GPU texture limits.""" - canvas = VispyArrayCanvas(ArrayViewerModel()) - canvas.set_ndim(3) - - data = np.zeros((10, 100, 100), dtype=np.float32) - with patch(PATCH_TARGET, return_value=(None, 64)): - handle = canvas.add_volume(data) - - # shape (10, 100, 100) with max 64 -> strides (1, 2, 2) - assert handle._downsample_factors == (1, 2, 2) - assert handle.data().shape == (10, 50, 50) - - # set_data with the same original shape should also downsample - with patch(PATCH_TARGET, return_value=(None, 64)): - handle.set_data(data) - assert handle.data().shape == (10, 50, 50) - - canvas.close() - - -@pytest.mark.usefixtures("any_app") -def test_volume_no_downsample_when_within_limit() -> None: - """Volume data within texture limits should not be downsampled.""" - canvas = VispyArrayCanvas(ArrayViewerModel()) - canvas.set_ndim(3) - - data = np.zeros((10, 50, 50), dtype=np.float32) - with patch(PATCH_TARGET, return_value=(None, 64)): - handle = canvas.add_volume(data) - - assert handle._downsample_factors == (1, 1, 1) - assert handle.data().shape == (10, 50, 50) - - canvas.close() - - -@pytest.mark.usefixtures("any_app") -def test_image_downsampled_when_exceeding_2d_texture_limit() -> None: - """2D image data should be stride-downsampled to fit GPU texture limits.""" - canvas = VispyArrayCanvas(ArrayViewerModel()) - canvas.set_ndim(2) - - data = np.zeros((100, 100), dtype=np.float32) - with patch(PATCH_TARGET, return_value=(64, None)): - handle = canvas.add_image(data) - - assert handle._downsample_factors == (2, 2) - assert handle.data().shape == (50, 50) - - # set_data should also downsample - with patch(PATCH_TARGET, return_value=(64, None)): - handle.set_data(data) - assert handle.data().shape == (50, 50) - - canvas.close() - - -@pytest.mark.usefixtures("any_app") -def test_set_scales_compensates_for_volume_downsample() -> None: - """set_scales should multiply by downsample factors so world coords stay correct.""" - canvas = VispyArrayCanvas(ArrayViewerModel()) - canvas.set_ndim(3) - - # original shape (400, 2200, 2200), factors (1, 2, 2) - data = np.zeros((10, 100, 100), dtype=np.float32) - with patch(PATCH_TARGET, return_value=(None, 64)): - handle = canvas.add_volume(data) - - assert handle._downsample_factors == (1, 2, 2) - - # scales in data order (Z, Y, X) = (0.4, 0.2, 0.2) - with patch(PATCH_TARGET, return_value=(None, 64)): - canvas.set_scales((0.4, 0.2, 0.2)) - - tform = handle._visual.transform - assert isinstance(tform, vispy.visuals.transforms.STTransform) - sx, sy, sz = tform.scale[:3] - # scene order is (x=W, y=H, z=D), reversed from data order - # x scale: 0.2 (X) * 2 (fw) = 0.4 - # y scale: 0.2 (Y) * 2 (fh) = 0.4 - # z scale: 0.4 (Z) * 1 (fd) = 0.4 - assert sx == pytest.approx(0.4) - assert sy == pytest.approx(0.4) - assert sz == pytest.approx(0.4) - - canvas.close() - - -@pytest.mark.usefixtures("any_app") -def test_set_range_correct_bounds_after_downsample() -> None: - """set_range should compute world bounds as if data were full-resolution.""" - canvas = VispyArrayCanvas(ArrayViewerModel()) - canvas.set_ndim(3) - - # shape (10, 100, 80) with max 64 -> factors (1, 2, 2) - # downsampled shape: (10, 50, 40) - data = np.zeros((10, 100, 80), dtype=np.float32) - with patch(PATCH_TARGET, return_value=(None, 64)): - handle = canvas.add_volume(data) - canvas.set_scales((1.0, 1.0, 1.0)) - - # After set_scales with (1,1,1), the transform should be (2, 2, 1) - # set_range reads downsampled shape and multiplies by transform scale: - # x = shape[2] * sx = 40 * 2 = 80 (matches original W) - # y = shape[1] * sy = 50 * 2 = 100 (matches original H) - # z = shape[0] * sz = 10 * 1 = 10 (matches original D) - tform = handle._visual.transform - assert isinstance(tform, vispy.visuals.transforms.STTransform) - ds_shape = handle.data().shape - sx, sy, sz = tform.scale[:3] - assert ds_shape[2] * sx == pytest.approx(80.0) - assert ds_shape[1] * sy == pytest.approx(100.0) - assert ds_shape[0] * sz == pytest.approx(10.0) - - canvas.close() - - -@pytest.mark.usefixtures("any_app") -def test_no_downsample_when_limits_none() -> None: - """When GPU limits are unavailable, data should pass through unchanged.""" - canvas = VispyArrayCanvas(ArrayViewerModel()) - canvas.set_ndim(3) - - data = np.zeros((10, 100, 100), dtype=np.float32) - with patch(PATCH_TARGET, return_value=(None, None)): - handle = canvas.add_volume(data) - - assert handle._downsample_factors == () - assert handle.data().shape == (10, 100, 100) - - canvas.close() - - -@pytest.mark.usefixtures("any_app") -def test_set_data_with_different_shape() -> None: - """set_data with a new shape should re-downsample correctly.""" - canvas = VispyArrayCanvas(ArrayViewerModel()) - canvas.set_ndim(3) - - data1 = np.zeros((10, 100, 100), dtype=np.float32) - with patch(PATCH_TARGET, return_value=(None, 64)): - handle = canvas.add_volume(data1) - assert handle.data().shape == (10, 50, 50) - - # now set_data with a larger volume - data2 = np.zeros((200, 100, 100), dtype=np.float32) - with patch(PATCH_TARGET, return_value=(None, 64)): - handle.set_data(data2) - assert handle._downsample_factors == (4, 2, 2) - assert handle.data().shape == (50, 50, 50) - - canvas.close() diff --git a/tests/views/_wx/test_array_view.py b/tests/views/_wx/test_array_view.py index fed2469d..e26f1a45 100644 --- a/tests/views/_wx/test_array_view.py +++ b/tests/views/_wx/test_array_view.py @@ -6,7 +6,7 @@ from pytest import fixture from ndv.models._viewer_model import ArrayViewerModel -from ndv.views._app import get_histogram_canvas_class +from ndv.views._histogram import Histogram from ndv.views._wx._array_view import WxArrayView @@ -77,7 +77,7 @@ def test_histogram(wxapp: wx.App, viewer: WxArrayView) -> None: # Test adding the histogram widget puts it on the relevant lut assert len(lut._wxwidget._histogram_sizer.GetChildren()) == 1 - histogram = get_histogram_canvas_class()() # will raise if not supported + histogram = Histogram() viewer.add_histogram(channel, histogram) assert len(lut._wxwidget._histogram_sizer.GetChildren()) == 2 @@ -228,31 +228,3 @@ def test_none_all(wxapp: wx.App, viewer: WxArrayView) -> None: for ch, lut_view in viewer._luts.items(): if type(ch) is int or (type(ch) is str and ch.isdigit()): assert lut_view._wxwidget.IsShown() - - -def test_key_event_filter(wxapp: wx.App) -> None: - from ndv._types import KeyCode, KeyMod, KeyPressEvent - from ndv.views._wx._app import WxAppWrap - - app = WxAppWrap() - view = WxArrayView(MagicMock(), ArrayViewerModel()) - widget = view.frontend_widget() - - received: list[KeyPressEvent] = [] - view.keyPressed.connect(received.append) - - disconnect = app.filter_key_events(widget, view) - - # Simulate a Right arrow key press - event = wx.KeyEvent(wx.wxEVT_CHAR_HOOK) - event.SetKeyCode(wx.WXK_RIGHT) - wx.PostEvent(widget.GetEventHandler(), event) - evtLoop = wxapp.GetTraits().CreateEventLoop() - wx.EventLoopActivator(evtLoop) - evtLoop.YieldFor(wx.EVT_CATEGORY_ALL) - - assert len(received) == 1 - assert received[0].key == KeyCode.RIGHT - assert received[0].mods == KeyMod.NONE - - disconnect() diff --git a/tests/views/_wx/test_lut_view.py b/tests/views/_wx/test_lut_view.py index 4a481e22..7c72812a 100644 --- a/tests/views/_wx/test_lut_view.py +++ b/tests/views/_wx/test_lut_view.py @@ -7,8 +7,8 @@ from pytest import fixture from ndv.models._lut_model import ClimsManual, ClimsMinMax, ClimsPercentile, LUTModel +from ndv.views._histogram import Histogram from ndv.views._wx._array_view import WxLUTView -from ndv.views.bases._graphics._canvas import HistogramCanvas @fixture @@ -128,7 +128,7 @@ def processEvent(evt: wx.PyEventBinder | int, wdg: wx.Window) -> None: evtLoop.YieldFor(wx.EVT_CATEGORY_ALL) # Mock up a histogram - hist_mock = MagicMock(spec=HistogramCanvas) + hist_mock = MagicMock(spec=Histogram) # Note that containing the frontend widget within a frame prevents segfaults frame = wx.Frame(None) hist_frontend = wx.Window(frame) diff --git a/tests/views/test_histogram.py b/tests/views/test_histogram.py new file mode 100644 index 00000000..e4d4e7e1 --- /dev/null +++ b/tests/views/test_histogram.py @@ -0,0 +1,206 @@ +from __future__ import annotations + +from unittest.mock import patch + +import numpy as np +import pytest +from pytest import fixture +from scenex.app import CursorType +from scenex.app.events import ( + MouseButton, + MouseDoublePressEvent, + MouseMoveEvent, + MousePressEvent, + MouseReleaseEvent, + WheelEvent, +) + +from ndv.models._lut_model import ClimsManual, LUTModel +from ndv.views._histogram import Histogram + + +@fixture +def model() -> LUTModel: + return LUTModel( + visible=True, + cmap="red", + gamma=1, + ) + + +@fixture +def histogram() -> Histogram: + histogram = Histogram() + + values = np.random.randint(0, 100, (100)) + bin_edges = np.linspace(0, 10, values.size + 1) + histogram.set_data(values, bin_edges) + histogram.set_range(x=(-2, 12), y=(0, 1)) + return histogram + + +@pytest.mark.usefixtures("any_app") +def test_hscroll(histogram: Histogram) -> None: + """Test scrolling horizontally pans the histogram""" + cam = histogram.view.camera + + def get_extents() -> tuple[float, float]: + left, *_ = cam.transform.map(cam.projection.imap((-1, 0))) + right, *_ = cam.transform.map(cam.projection.imap((1, 0))) + return float(left), float(right) + + old_left, old_right = get_extents() + old_width = old_right - old_left + + x, y, w, h = histogram.canvas.content_rect_for(histogram.view) + center = (x + w / 2, y + h / 2) + + histogram.canvas.handle( + WheelEvent(pos=center, buttons=MouseButton.NONE, angle_delta=(1, 0)) + ) + new_left, new_right = get_extents() + assert new_left < old_left + assert abs((new_right - new_left) - old_width) <= 1e-6 + + histogram.canvas.handle( + WheelEvent(pos=center, buttons=MouseButton.NONE, angle_delta=(-1, 0)) + ) + new_left, new_right = get_extents() + assert abs(new_left - old_left) <= 1e-6 + assert abs((new_right - new_left) - old_width) <= 1e-6 + + +@pytest.mark.usefixtures("any_app") +def test_highlight(histogram: Histogram) -> None: + """Test that the highlight line is shown, moved, and hidden correctly.""" + line = histogram.highlight_line + assert line is not None + assert not line.visible + + histogram.highlight(5) + assert line.visible + assert 5 == line.transform.root[3, 0] + + histogram.highlight(None) + assert not line.visible + + +def world_to_canvas(histogram: Histogram, x: float, y: float) -> tuple[float, float]: + cam = histogram.view.camera + view_pos_ndc = cam.projection.map(cam.transform.imap((x, y, 0))) + rx, ry, w, h = histogram.canvas.content_rect_for(histogram.view) + view_pos = (view_pos_ndc[0] * w / 2 + w / 2, -view_pos_ndc[1] * h / 2 + h / 2) + return (rx + view_pos[0], ry + view_pos[1]) + + +@pytest.mark.usefixtures("any_app") +def test_gamma_cursor(model: LUTModel, histogram: Histogram) -> None: + """Tests that the cursor changes when hovering over the gamma handle.""" + histogram.model = model + model.clims = ClimsManual(min=0, max=10) + + pos = world_to_canvas(histogram, 5, 0.5) + with patch("ndv.views._histogram.snx.set_cursor") as mock: + histogram.canvas.handle(MouseMoveEvent(pos=pos, buttons=MouseButton.NONE)) + mock.assert_called_once_with(histogram.canvas, CursorType.V_ARROW) + + +@pytest.mark.usefixtures("any_app") +def test_clims_cursor(model: LUTModel, histogram: Histogram) -> None: + """Tests that the cursor changes when hovering over the clim handles.""" + histogram.model = model + model.clims = ClimsManual(min=0, max=10) + + pos = world_to_canvas(histogram, 0, 0.5) + with patch("ndv.views._histogram.snx.set_cursor") as mock: + histogram.canvas.handle(MouseMoveEvent(pos=pos, buttons=MouseButton.NONE)) + mock.assert_called_once_with(histogram.canvas, CursorType.H_ARROW) + + pos = world_to_canvas(histogram, 10, 0.5) + with patch("ndv.views._histogram.snx.set_cursor") as mock: + histogram.canvas.handle(MouseMoveEvent(pos=pos, buttons=MouseButton.NONE)) + mock.assert_called_once_with(histogram.canvas, CursorType.H_ARROW) + + +@pytest.mark.usefixtures("any_app") +def test_gamma_drag(model: LUTModel, histogram: Histogram) -> None: + """Tests that dragging the gamma handle changes the gamma on the LUTModel.""" + histogram.model = model + model.clims = ClimsManual(min=0, max=10) + + pos = world_to_canvas(histogram, 5, 0.5) + histogram.canvas.handle(MousePressEvent(pos=pos, buttons=MouseButton.LEFT)) + pos = world_to_canvas(histogram, 5, 0.75) + histogram.canvas.handle(MouseMoveEvent(pos=pos, buttons=MouseButton.LEFT)) + histogram.canvas.handle(MouseReleaseEvent(pos=pos, buttons=MouseButton.LEFT)) + assert model.gamma == -np.log2(0.75) + + +@pytest.mark.usefixtures("any_app") +def test_gamma_double_click_resets(model: LUTModel, histogram: Histogram) -> None: + """Tests that double-clicking the gamma handle resets the gamma to 1.""" + histogram.model = model + model.clims = ClimsManual(min=0, max=10) + model.gamma = 2.0 + + pos = world_to_canvas(histogram, 5, 2 ** (-model.gamma)) + histogram.canvas.handle(MouseDoublePressEvent(pos=pos, buttons=MouseButton.LEFT)) + assert model.gamma == 1 + + +@pytest.mark.usefixtures("any_app") +def test_left_clim_drag(model: LUTModel, histogram: Histogram) -> None: + """Tests dragging the left clim handle to change the left contrast limit""" + histogram.model = model + model.clims = ClimsManual(min=0, max=10) + + pos = world_to_canvas(histogram, 0, 0.5) + histogram.canvas.handle(MousePressEvent(pos=pos, buttons=MouseButton.LEFT)) + pos = world_to_canvas(histogram, 1, 0.5) + histogram.canvas.handle(MouseMoveEvent(pos=pos, buttons=MouseButton.LEFT)) + histogram.canvas.handle(MouseReleaseEvent(pos=pos, buttons=MouseButton.LEFT)) + assert model.clims == ClimsManual(min=1, max=10) + + +@pytest.mark.usefixtures("any_app") +def test_right_clim_drag(model: LUTModel, histogram: Histogram) -> None: + """Tests dragging the right clim handle to change the right contrast limit""" + histogram.model = model + model.clims = ClimsManual(min=0, max=10) + + pos = world_to_canvas(histogram, 10, 0.5) + histogram.canvas.handle(MousePressEvent(pos=pos, buttons=MouseButton.LEFT)) + pos = world_to_canvas(histogram, 9, 0.5) + histogram.canvas.handle(MouseMoveEvent(pos=pos, buttons=MouseButton.LEFT)) + histogram.canvas.handle(MouseReleaseEvent(pos=pos, buttons=MouseButton.LEFT)) + assert model.clims == ClimsManual(min=0, max=9) + + +@pytest.mark.usefixtures("any_app") +def test_right_clim_clamped_to_left(model: LUTModel, histogram: Histogram) -> None: + """Tests the right contrast limit cannot be dragged past the left contrast limit.""" + histogram.model = model + model.clims = ClimsManual(min=1, max=10) + + pos = world_to_canvas(histogram, 10, 0.5) + histogram.canvas.handle(MousePressEvent(pos=pos, buttons=MouseButton.LEFT)) + pos = world_to_canvas(histogram, 0, 0.5) + histogram.canvas.handle(MouseMoveEvent(pos=pos, buttons=MouseButton.LEFT)) + histogram.canvas.handle(MouseReleaseEvent(pos=pos, buttons=MouseButton.LEFT)) + assert model.clims == ClimsManual(min=1, max=1) + + +@pytest.mark.usefixtures("any_app") +def test_right_clim_priority_when_overlapping( + model: LUTModel, histogram: Histogram +) -> None: + """Tests the right contrast limit is selected when left=right.""" + histogram.model = model + model.clims = ClimsManual(min=1, max=1) + + pos = world_to_canvas(histogram, 1, 0.5) + histogram.canvas.handle(MousePressEvent(pos=pos, buttons=MouseButton.LEFT)) + pos = world_to_canvas(histogram, 9, 0.5) + histogram.canvas.handle(MouseMoveEvent(pos=pos, buttons=MouseButton.LEFT)) + histogram.canvas.handle(MouseReleaseEvent(pos=pos, buttons=MouseButton.LEFT)) + assert model.clims == ClimsManual(min=1, max=9)