Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Rectangular ROIs #114

Merged
merged 37 commits into from
Feb 14, 2025
Merged
Show file tree
Hide file tree
Changes from 35 commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
4fdd2a6
ROI Model
gselzer Nov 14, 2024
725b0d7
WIP
gselzer Jan 9, 2025
edbc57d
Always provide a ROIModel
gselzer Jan 21, 2025
9a9acea
Work with all gui frontends
gselzer Jan 21, 2025
43a4414
Note bug with Wx+PyGFX
gselzer Jan 21, 2025
4753e5f
style(pre-commit.ci): auto fixes [...]
pre-commit-ci[bot] Jan 21, 2025
492aef0
Make test pass
gselzer Feb 3, 2025
09609c9
Minor cleanup
gselzer Feb 4, 2025
b0020e6
Weak Ref to last roi created
gselzer Feb 4, 2025
931b807
Rename RectangularROI views
gselzer Feb 4, 2025
fd8ba7b
Complete roi controller test
gselzer Feb 4, 2025
dec7a56
Merge remote-tracking branch 'upstream/main' into roi-model
gselzer Feb 4, 2025
10c0b61
Merge remote-tracking branch 'upstream/main' into roi-model
gselzer Feb 5, 2025
e5a3c9e
Remove old Mouseable class
gselzer Feb 5, 2025
37d559b
Fix pygfx+wx
gselzer Feb 5, 2025
25e9f48
Wx: Prevent repeat sliders
gselzer Feb 5, 2025
c04e263
Remove FIXME
gselzer Feb 5, 2025
1380151
Shorten docstring
gselzer Feb 5, 2025
3676490
WIP: Fix tests
gselzer Feb 5, 2025
0ed2bb7
Use cache over lru_cache
gselzer Feb 11, 2025
087c58b
Explicitly validate bounding box after
gselzer Feb 11, 2025
d55377f
style(pre-commit.ci): auto fixes [...]
pre-commit-ci[bot] Feb 11, 2025
f421e08
Correctly type bounding box docstring
gselzer Feb 11, 2025
bc6847d
sync roi in separate method
gselzer Feb 11, 2025
72af552
Check for roi_model not None
gselzer Feb 11, 2025
f64d089
Move button selection to mouse down
gselzer Feb 11, 2025
54e992a
Intercept jupyter mouse events as needed
gselzer Feb 11, 2025
42fcd9a
Move Qt import to top
gselzer Feb 11, 2025
8b09384
Patch ArrayCanvas.elements_at in roi test
gselzer Feb 11, 2025
3c566ed
Actually check around jupyter mouse button
gselzer Feb 11, 2025
fd06ce7
Merge remote-tracking branch 'upstream/main' into roi-model
gselzer Feb 11, 2025
8dc0578
Add roi interaction test
gselzer Feb 12, 2025
42ed8b3
Skip test on Jupyter+PyGFX
gselzer Feb 12, 2025
7d3ac57
Fix RectangularROIModel docstring
gselzer Feb 12, 2025
f341e7c
Merge branch 'main' into roi-model
tlambert03 Feb 13, 2025
df257d6
Update src/ndv/models/_roi_model.py
tlambert03 Feb 14, 2025
b3cfbf2
style(pre-commit.ci): auto fixes [...]
pre-commit-ci[bot] Feb 14, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 45 additions & 2 deletions src/ndv/_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
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, cast

from pydantic import PlainSerializer, PlainValidator
Expand All @@ -13,6 +14,7 @@
if TYPE_CHECKING:
from qtpy.QtCore import Qt
from qtpy.QtWidgets import QWidget
from wx import Cursor

from ndv.views.bases import Viewable

Expand Down Expand Up @@ -56,33 +58,36 @@
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 = MouseButton.LEFT
btn: MouseButton


class MouseReleaseEvent(NamedTuple):
"""Event emitted when mouse button is released."""

x: float
y: float
btn: MouseButton = MouseButton.LEFT
btn: MouseButton


class CursorType(Enum):
DEFAULT = "default"
CROSS = "cross"
V_ARROW = "v_arrow"
H_ARROW = "h_arrow"
ALL_ARROW = "all_arrow"
Expand All @@ -101,9 +106,47 @@

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 {

Check warning on line 119 in src/ndv/_types.py

View check run for this annotation

Codecov / codecov/patch

src/ndv/_types.py#L119

Added line #L119 was not covered by tests
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]
104 changes: 102 additions & 2 deletions src/ndv/controllers/_array_viewer.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
from ndv.controllers._channel_controller import ChannelController
from ndv.models import ArrayDisplayModel, ChannelMode, DataWrapper, LUTModel
from ndv.models._data_display_model import DataResponse, _ArrayDataDisplayModel
from ndv.models._roi_model import RectangularROIModel
from ndv.models._viewer_model import ArrayViewerModel, InteractionMode
from ndv.views import _app

if TYPE_CHECKING:
Expand All @@ -21,6 +23,7 @@
from ndv._types import MouseMoveEvent
from ndv.models._array_display_model import ArrayDisplayModelKwargs
from ndv.views.bases import HistogramCanvas
from ndv.views.bases._graphics._canvas_elements import RectangularROIHandle

LutKey: TypeAlias = int | None

Expand Down Expand Up @@ -69,6 +72,11 @@
self._data_model = _ArrayDataDisplayModel(
data_wrapper=data, display=display_model or ArrayDisplayModel(**kwargs)
)
self._viewer_model = ArrayViewerModel()
self._viewer_model.events.interaction_mode.connect(
self._on_interaction_mode_changed
)
self._roi_model: RectangularROIModel | None = None

app = _app.gui_frontend()

Expand All @@ -88,10 +96,14 @@
# 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._canvas = canvas_cls(self._viewer_model)

self._histogram: HistogramCanvas | None = None
self._view = frontend_cls(self._canvas.frontend_widget(), self._data_model)
self._view = frontend_cls(
self._canvas.frontend_widget(), self._data_model, self._viewer_model
)

self._roi_view: RectangularROIHandle | None = None

self._set_model_connected(self._data_model.display)
self._canvas.set_ndim(self.display_model.n_visible_axes)
Expand Down Expand Up @@ -163,6 +175,27 @@
self._data_model.data_wrapper = DataWrapper.create(data)
self._fully_synchronize_view()

@property
def roi(self) -> RectangularROIModel | None:
"""Return ROI being displayed."""
return self._roi_model

@roi.setter
def roi(self, roi_model: RectangularROIModel | None) -> None:
"""Set ROI being displayed."""
# Disconnect old model
if self._roi_model is not None:
self._set_roi_model_connected(self._roi_model, False)

Check warning on line 188 in src/ndv/controllers/_array_viewer.py

View check run for this annotation

Codecov / codecov/patch

src/ndv/controllers/_array_viewer.py#L188

Added line #L188 was not covered by tests

# Connect new model
if isinstance(roi_model, tuple):
self._roi_model = RectangularROIModel(bounding_box=roi_model)

Check warning on line 192 in src/ndv/controllers/_array_viewer.py

View check run for this annotation

Codecov / codecov/patch

src/ndv/controllers/_array_viewer.py#L192

Added line #L192 was not covered by tests
else:
self._roi_model = roi_model
if self._roi_model is not None:
self._set_roi_model_connected(self._roi_model)
self._synchronize_roi()

def show(self) -> None:
"""Show the viewer."""
self._view.set_visible(True)
Expand Down Expand Up @@ -239,6 +272,28 @@
]:
getattr(obj, _connect)(callback)

def _set_roi_model_connected(
self, model: RectangularROIModel, connect: bool = True
) -> None:
"""Connect or disconnect the model to/from the viewer.

We do this in a single method so that we are sure to connect and disconnect
the same events in the same order. (but it's kinda ugly)
"""
_connect = "connect" if connect else "disconnect"

for obj, callback in [
(model.events.bounding_box, self._on_roi_model_bounding_box_changed),
(model.events.visible, self._on_roi_model_visible_changed),
]:
getattr(obj, _connect)(callback)

if _connect:
self._create_roi_view()
else:
if self._roi_view:
self._roi_view.remove()

Check warning on line 295 in src/ndv/controllers/_array_viewer.py

View check run for this annotation

Codecov / codecov/patch

src/ndv/controllers/_array_viewer.py#L294-L295

Added lines #L294 - L295 were not covered by tests

# ------------------ Model callbacks ------------------

def _fully_synchronize_view(self) -> None:
Expand All @@ -261,6 +316,13 @@
for lut_ctr in self._lut_controllers.values():
lut_ctr.synchronize()
self._update_hist_domain_for_dtype()
self._synchronize_roi()

def _synchronize_roi(self) -> None:
"""Fully re-synchronize the ROI view with the model."""
if self.roi is not None:
self._on_roi_model_bounding_box_changed(self.roi.bounding_box)
self._on_roi_model_visible_changed(self.roi.visible)

def _on_model_visible_axes_changed(self) -> None:
self._view.set_visible_axes(self._data_model.normed_visible_axes)
Expand Down Expand Up @@ -288,6 +350,38 @@
self._clear_canvas()
self._request_data()

def _on_roi_model_bounding_box_changed(
self, bb: tuple[tuple[float, float], tuple[float, float]]
) -> None:
if self._roi_view is not None:
self._roi_view.set_bounding_box(*bb)

def _on_roi_model_visible_changed(self, visible: bool) -> None:
if self._roi_view is not None:
self._roi_view.set_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)

Check warning on line 367 in src/ndv/controllers/_array_viewer.py

View check run for this annotation

Codecov / codecov/patch

src/ndv/controllers/_array_viewer.py#L367

Added line #L367 was not covered by tests

# 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
)

def _clear_canvas(self) -> None:
for lut_ctrl in self._lut_controllers.values():
# self._view.remove_lut_view(lut_ctrl.lut_view)
Expand All @@ -309,6 +403,12 @@
"""Reset the zoom level of the canvas."""
self._canvas.set_range()

def _on_roi_view_bounding_box_changed(
self, bb: tuple[tuple[float, float], tuple[float, float]]
) -> None:
if self._roi_model:
self._roi_model.bounding_box = bb

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))
Expand Down
2 changes: 2 additions & 0 deletions src/ndv/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
ClimsStdDev,
LUTModel,
)
from ._roi_model import RectangularROIModel

__all__ = [
"ArrayDisplayModel",
Expand All @@ -23,4 +24,5 @@
"DataWrapper",
"LUTModel",
"NDVModel",
"RectangularROIModel",
]
40 changes: 40 additions & 0 deletions src/ndv/models/_roi_model.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
from __future__ import annotations

from typing import TYPE_CHECKING

from pydantic import field_validator

from ndv.models._base_model import NDVModel

if TYPE_CHECKING:
from typing import Any


class RectangularROIModel(NDVModel):
"""Representation of an axis-aligned rectangular Region of Interest (ROI).

Attributes
----------
visible : bool
Whether to display this roi.
bounding_box : tuple[tuple[float, float], tuple[float, float]]
The minimum (2D) point and the maximum (2D) point contained within the
region. Using these two points, an axis-aligned bounding box can be
constructed.
"""

visible: bool = True
bounding_box: tuple[tuple[float, float], tuple[float, float]] = ((0, 0), (0, 0))

@field_validator("bounding_box", mode="after")
@classmethod
def _validate_bounding_box(
cls, bb: Any
) -> tuple[tuple[float, float], tuple[float, float]]:
if not isinstance(bb, tuple):
raise ValueError(f"{bb} not a tuple of points!")

Check warning on line 35 in src/ndv/models/_roi_model.py

View check run for this annotation

Codecov / codecov/patch

src/ndv/models/_roi_model.py#L35

Added line #L35 was not covered by tests
x1 = min(bb[0][0], bb[1][0])
y1 = min(bb[0][1], bb[1][1])
x2 = max(bb[0][0], bb[1][0])
y2 = max(bb[0][1], bb[1][1])
return ((x1, y1), (x2, y2))
27 changes: 27 additions & 0 deletions src/ndv/models/_viewer_model.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
from enum import Enum, auto

from ndv.models._base_model import NDVModel


class InteractionMode(Enum):
"""An enum defining graphical interaction mechanisms with an array Viewer."""

PAN_ZOOM = auto() # Mode allowing the user to pan and zoom
CREATE_ROI = auto() # Mode where user clicks create ROIs


class ArrayViewerModel(NDVModel):
"""Representation of an array viewer.

TODO: This will likely contain other fields including:
* Dimensionality
* Camera position
* Camera frustum

Parameters
----------
interaction_mode : InteractionMode
Describes the current interaction mode of the Viewer.
"""

interaction_mode: InteractionMode = InteractionMode.PAN_ZOOM
Loading
Loading