Skip to content

Commit 4fdd2a6

Browse files
committed
ROI Model
1 parent 133f7c1 commit 4fdd2a6

File tree

17 files changed

+995
-628
lines changed

17 files changed

+995
-628
lines changed

src/ndv/_types.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,33 +56,36 @@ class MouseButton(IntFlag):
5656
LEFT = auto()
5757
MIDDLE = auto()
5858
RIGHT = auto()
59+
NONE = auto()
5960

6061

6162
class MouseMoveEvent(NamedTuple):
6263
"""Event emitted when the user moves the cursor."""
6364

6465
x: float
6566
y: float
67+
btn: MouseButton = MouseButton.NONE
6668

6769

6870
class MousePressEvent(NamedTuple):
6971
"""Event emitted when mouse button is pressed."""
7072

7173
x: float
7274
y: float
73-
btn: MouseButton = MouseButton.LEFT
75+
btn: MouseButton
7476

7577

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

7981
x: float
8082
y: float
81-
btn: MouseButton = MouseButton.LEFT
83+
btn: MouseButton
8284

8385

8486
class CursorType(Enum):
8587
DEFAULT = "default"
88+
CROSS = "cross"
8689
V_ARROW = "v_arrow"
8790
H_ARROW = "h_arrow"
8891
ALL_ARROW = "all_arrow"
@@ -101,9 +104,22 @@ def to_qt(self) -> Qt.CursorShape:
101104

102105
return {
103106
CursorType.DEFAULT: Qt.CursorShape.ArrowCursor,
107+
CursorType.CROSS: Qt.CursorShape.CrossCursor,
104108
CursorType.V_ARROW: Qt.CursorShape.SizeVerCursor,
105109
CursorType.H_ARROW: Qt.CursorShape.SizeHorCursor,
106110
CursorType.ALL_ARROW: Qt.CursorShape.SizeAllCursor,
107111
CursorType.BDIAG_ARROW: Qt.CursorShape.SizeBDiagCursor,
108112
CursorType.FDIAG_ARROW: Qt.CursorShape.SizeFDiagCursor,
109113
}[self]
114+
115+
def to_jupyter(self) -> str:
116+
"""Converts CursorType to jupyter cursor strings."""
117+
return {
118+
CursorType.DEFAULT: "default",
119+
CursorType.CROSS: "crosshair",
120+
CursorType.V_ARROW: "ns-resize",
121+
CursorType.H_ARROW: "ew-resize",
122+
CursorType.ALL_ARROW: "move",
123+
CursorType.BDIAG_ARROW: "nesw-resize",
124+
CursorType.FDIAG_ARROW: "nwse-resize",
125+
}[self]

src/ndv/controllers/_array_viewer.py

Lines changed: 71 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@
99
from ndv.controllers._channel_controller import ChannelController
1010
from ndv.models import ArrayDisplayModel, ChannelMode, DataWrapper, LUTModel
1111
from ndv.models._data_display_model import DataResponse, _ArrayDataDisplayModel
12+
from ndv.models._roi_model import RectangularROIModel
13+
from ndv.models._viewer_model import ArrayViewerModel, InteractionMode
1214
from ndv.views import _app
15+
from ndv.views.bases._graphics._canvas_elements import RectangularROI
1316

1417
if TYPE_CHECKING:
1518
from concurrent.futures import Future
@@ -68,6 +71,9 @@ def __init__(
6871
self._data_model = _ArrayDataDisplayModel(
6972
data_wrapper=data, display=display_model or ArrayDisplayModel(**kwargs)
7073
)
74+
self._viewer_model = ArrayViewerModel()
75+
self._viewer_model.events.interaction_mode.connect(self._on_interaction_mode_changed)
76+
self._roi_model: RectangularROIModel | None = None
7177

7278
app = _app.gui_frontend()
7379

@@ -87,10 +93,12 @@ def __init__(
8793
# get and create the front-end and canvas classes
8894
frontend_cls = _app.get_array_view_class()
8995
canvas_cls = _app.get_array_canvas_class()
90-
self._canvas = canvas_cls()
96+
self._canvas = canvas_cls(self._viewer_model)
9197

9298
self._histogram: HistogramCanvas | None = None
93-
self._view = frontend_cls(self._canvas.frontend_widget(), self._data_model)
99+
self._view = frontend_cls(self._canvas.frontend_widget(), self._data_model, self._viewer_model)
100+
101+
self._roi_view: RectangularROI | None = None
94102

95103
self._set_model_connected(self._data_model.display)
96104
self._canvas.set_ndim(self.display_model.n_visible_axes)
@@ -161,6 +169,19 @@ def data(self, data: Any) -> None:
161169
else:
162170
self._data_model.data_wrapper = DataWrapper.create(data)
163171
self._fully_synchronize_view()
172+
173+
@property
174+
def roi(self) -> RectangularROIModel | None:
175+
return self._roi_model
176+
177+
@roi.setter
178+
def roi(self, roi_model: RectangularROIModel | None) -> None:
179+
if self._roi_model is not None:
180+
self._set_roi_model_connected(self._roi_model, False)
181+
self._roi_model = roi_model
182+
if self._roi_model is not None:
183+
self._set_roi_model_connected(self._roi_model)
184+
self._fully_synchronize_view()
164185

165186
def show(self) -> None:
166187
"""Show the viewer."""
@@ -238,6 +259,22 @@ def _set_model_connected(
238259
]:
239260
getattr(obj, _connect)(callback)
240261

262+
def _set_roi_model_connected(
263+
self, model: RectangularROIModel, connect: bool = True
264+
) -> None:
265+
"""Connect or disconnect the model to/from the viewer.
266+
267+
We do this in a single method so that we are sure to connect and disconnect
268+
the same events in the same order. (but it's kinda ugly)
269+
"""
270+
_connect = "connect" if connect else "disconnect"
271+
272+
for obj, callback in [
273+
(model.events.bounding_box, self._on_roi_model_bounding_box_changed),
274+
(model.events.visible, self._on_roi_model_visible_changed),
275+
]:
276+
getattr(obj, _connect)(callback)
277+
241278
# ------------------ Model callbacks ------------------
242279

243280
def _fully_synchronize_view(self) -> None:
@@ -261,6 +298,9 @@ def _fully_synchronize_view(self) -> None:
261298
for lut_ctr in self._lut_controllers.values():
262299
lut_ctr._update_view_from_model()
263300
self._update_hist_domain_for_dtype()
301+
if self.roi is not None:
302+
self._on_roi_model_bounding_box_changed(self.roi.bounding_box)
303+
self._on_roi_model_visible_changed(self.roi.visible)
264304

265305
def _on_model_visible_axes_changed(self) -> None:
266306
self._view.set_visible_axes(self._data_model.normed_visible_axes)
@@ -287,6 +327,31 @@ def _on_model_channel_mode_changed(self, mode: ChannelMode) -> None:
287327
# redraw
288328
self._clear_canvas()
289329
self._request_data()
330+
331+
def _on_roi_model_bounding_box_changed(self, bb: tuple[tuple[float, float], tuple[float, float]]) -> None:
332+
if self._roi_view is None:
333+
self._roi_view = self._canvas.add_bounding_box()
334+
# HACK
335+
self._roi_view.set_visible(True)
336+
self._roi_view.boundingBoxChanged.connect(self._on_roi_view_bounding_box_changed)
337+
self._roi_view.set_bounding_box(*bb)
338+
339+
def _on_roi_model_visible_changed(self, visible: bool) -> None:
340+
if self._roi_view is None:
341+
self._roi_view = self._canvas.add_bounding_box()
342+
# HACK
343+
self._roi_view.set_visible(True)
344+
self._roi_view.boundingBoxChanged.connect(self._on_roi_view_bounding_box_changed)
345+
self._roi_view.set_visible(visible)
346+
347+
def _on_interaction_mode_changed(self, mode: InteractionMode) -> None:
348+
# TODO: Unify with _on_roi_model_bounding_box_changed
349+
if mode == InteractionMode.CREATE_ROI:
350+
if self._roi_view:
351+
self._roi_view.remove()
352+
self._roi_view = self._canvas.add_bounding_box()
353+
# HACK
354+
self._roi_view.boundingBoxChanged.connect(self._on_roi_view_bounding_box_changed)
290355

291356
def _clear_canvas(self) -> None:
292357
for lut_ctrl in self._lut_controllers.values():
@@ -308,6 +373,10 @@ def _on_view_visible_axes_changed(self) -> None:
308373
def _on_view_reset_zoom_clicked(self) -> None:
309374
"""Reset the zoom level of the canvas."""
310375
self._canvas.set_range()
376+
377+
def _on_roi_view_bounding_box_changed(self, bb: tuple[tuple[float, float], tuple[float, float]]) -> None:
378+
if self._roi_model:
379+
self._roi_model.bounding_box = bb
311380

312381
def _on_canvas_mouse_moved(self, event: MouseMoveEvent) -> None:
313382
"""Respond to a mouse move event in the view."""

src/ndv/models/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
ClimsStdDev,
1212
LUTModel,
1313
)
14+
from ._roi_model import RectangularROIModel
1415

1516
__all__ = [
1617
"ArrayDisplayModel",
@@ -23,4 +24,5 @@
2324
"DataWrapper",
2425
"LUTModel",
2526
"NDVModel",
27+
"RectangularROIModel",
2628
]

src/ndv/models/_roi_model.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
from __future__ import annotations
2+
3+
from typing import TYPE_CHECKING
4+
5+
from pydantic import field_validator
6+
7+
from ndv.models._base_model import NDVModel
8+
9+
if TYPE_CHECKING:
10+
from typing import Any
11+
12+
13+
class RectangularROIModel(NDVModel):
14+
"""Representation of how to display an axis-aligned rectangular Region of Interest (ROI).
15+
16+
Parameters
17+
----------
18+
visible : bool
19+
Whether to display this roi.
20+
bounding_box: tuple[Sequence[float], Sequence[float]]
21+
The minimum point and the maximum point contained within the region.
22+
Using these two points, an axis-aligned bounding box can be constructed.
23+
"""
24+
25+
visible: bool = True
26+
bounding_box: tuple[tuple[float, float], tuple[float, float]] = ((0, 0), (0, 0))
27+
28+
@field_validator("bounding_box")
29+
@classmethod
30+
def _validate_bounding_box(
31+
cls, bb: Any
32+
) -> tuple[tuple[float, float], tuple[float, float]]:
33+
if not isinstance(bb, tuple):
34+
raise ValueError(f"{bb} not a tuple of points!")
35+
x1 = min(bb[0][0], bb[1][0])
36+
y1 = min(bb[0][1], bb[1][1])
37+
x2 = max(bb[0][0], bb[1][0])
38+
y2 = max(bb[0][1], bb[1][1])
39+
return ((x1, y1), (x2, y2))

src/ndv/models/_viewer_model.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
from enum import Enum, auto
2+
3+
from ndv.models._base_model import NDVModel
4+
5+
6+
class InteractionMode(Enum):
7+
"""An enum defining graphical interaction mechanisms with an array Viewer."""
8+
9+
PAN_ZOOM = auto() # Mode allowing the user to pan and zoom
10+
CREATE_ROI = auto() # Mode where user clicks create ROIs
11+
12+
13+
class ArrayViewerModel(NDVModel):
14+
"""Representation of an array viewer.
15+
16+
TODO: This will likely contain other fields including:
17+
* Dimensionality
18+
* Camera position
19+
* Camera frustum
20+
21+
Parameters
22+
----------
23+
interaction_mode : InteractionMode
24+
Describes the current interaction mode of the Viewer.
25+
"""
26+
27+
interaction_mode: InteractionMode = InteractionMode.PAN_ZOOM

0 commit comments

Comments
 (0)