diff --git a/bec_widgets/cli/client.py b/bec_widgets/cli/client.py index e545815f..1c282c86 100644 --- a/bec_widgets/cli/client.py +++ b/bec_widgets/cli/client.py @@ -1044,6 +1044,128 @@ class DeviceLineEdit(RPCBase): """ +class EllipticalROI(RPCBase): + """Elliptical Region of Interest with centre/width/height tracking and auto-labelling.""" + + @property + @rpc_call + def label(self) -> "str": + """ + Gets the display name of this ROI. + + Returns: + str: The current name of the ROI. + """ + + @label.setter + @rpc_call + def label(self) -> "str": + """ + Gets the display name of this ROI. + + Returns: + str: The current name of the ROI. + """ + + @property + @rpc_call + def movable(self) -> "bool": + """ + Gets whether this ROI is movable. + + Returns: + bool: True if the ROI can be moved, False otherwise. + """ + + @movable.setter + @rpc_call + def movable(self) -> "bool": + """ + Gets whether this ROI is movable. + + Returns: + bool: True if the ROI can be moved, False otherwise. + """ + + @property + @rpc_call + def line_color(self) -> "str": + """ + Gets the current line color of the ROI. + + Returns: + str: The current line color as a string (e.g., hex color code). + """ + + @line_color.setter + @rpc_call + def line_color(self) -> "str": + """ + Gets the current line color of the ROI. + + Returns: + str: The current line color as a string (e.g., hex color code). + """ + + @property + @rpc_call + def line_width(self) -> "int": + """ + Gets the current line width of the ROI. + + Returns: + int: The current line width in pixels. + """ + + @line_width.setter + @rpc_call + def line_width(self) -> "int": + """ + Gets the current line width of the ROI. + + Returns: + int: The current line width in pixels. + """ + + @rpc_call + def get_coordinates(self, typed: "bool | None" = None) -> "dict | tuple": + """ + Return the ellipse's centre and size. + + Args: + typed (bool | None): If True returns dict; otherwise tuple. + """ + + @rpc_call + def get_data_from_image( + self, image_item: "pg.ImageItem | None" = None, returnMappedCoords: "bool" = False, **kwargs + ): + """ + Wrapper around `pyqtgraph.ROI.getArrayRegion`. + + Args: + image_item (pg.ImageItem or None): The ImageItem to sample. If None, auto-detects + the first `ImageItem` in the same GraphicsScene as this ROI. + returnMappedCoords (bool): If True, also returns the coordinate array generated by + *getArrayRegion*. + **kwargs: Additional keyword arguments passed to *getArrayRegion* or *affineSlice*, + such as `axes`, `order`, `shape`, etc. + + Returns: + ndarray: Pixel data inside the ROI, or (data, coords) if *returnMappedCoords* is True. + """ + + @rpc_call + def set_position(self, x: "float", y: "float"): + """ + Sets the position of the ROI. + + Args: + x (float): The x-coordinate of the new position. + y (float): The y-coordinate of the new position. + """ + + class Image(RPCBase): """Image widget for displaying 2D data.""" @@ -1529,7 +1651,7 @@ class Image(RPCBase): @rpc_call def add_roi( self, - kind: "Literal['rect', 'circle']" = "rect", + kind: "Literal['rect', 'circle', 'ellipse']" = "rect", name: "str | None" = None, line_width: "int | None" = 5, pos: "tuple[float, float] | None" = (10, 10), diff --git a/bec_widgets/widgets/plots/image/image_base.py b/bec_widgets/widgets/plots/image/image_base.py index c2081cdc..2aa43d63 100644 --- a/bec_widgets/widgets/plots/image/image_base.py +++ b/bec_widgets/widgets/plots/image/image_base.py @@ -24,6 +24,7 @@ from bec_widgets.widgets.plots.plot_base import PlotBase from bec_widgets.widgets.plots.roi.image_roi import ( BaseROI, CircularROI, + EllipticalROI, RectangularROI, ROIController, ) @@ -554,7 +555,7 @@ class ImageBase(PlotBase): def add_roi( self, - kind: Literal["rect", "circle"] = "rect", + kind: Literal["rect", "circle", "ellipse"] = "rect", name: str | None = None, line_width: int | None = 5, pos: tuple[float, float] | None = (10, 10), @@ -599,6 +600,16 @@ class ImageBase(PlotBase): movable=movable, **pg_kwargs, ) + elif kind == "ellipse": + roi = EllipticalROI( + pos=pos, + size=size, + parent_image=self, + line_width=line_width, + label=name, + movable=movable, + **pg_kwargs, + ) else: raise ValueError("kind must be 'rect' or 'circle'") diff --git a/bec_widgets/widgets/plots/image/setting_widgets/image_roi_tree.py b/bec_widgets/widgets/plots/image/setting_widgets/image_roi_tree.py index 50f497b4..8b7db35e 100644 --- a/bec_widgets/widgets/plots/image/setting_widgets/image_roi_tree.py +++ b/bec_widgets/widgets/plots/image/setting_widgets/image_roi_tree.py @@ -24,6 +24,7 @@ from bec_widgets.utils.toolbar import MaterialIconAction, ModularToolBar from bec_widgets.widgets.plots.roi.image_roi import ( BaseROI, CircularROI, + EllipticalROI, RectangularROI, ROIController, ) @@ -126,6 +127,9 @@ class ROIPropertyTree(BECWidget, QWidget): self.add_circle_action = MaterialIconAction("add_circle", "Add Circle ROI", True, self) tb.add_action("Add Rect ROI", self.add_rect_action, self) tb.add_action("Add Circle ROI", self.add_circle_action, self) + # --- Ellipse ROI draw action --- + self.add_ellipse_action = MaterialIconAction("vignette", "Add Ellipse ROI", True, self) + tb.add_action("Add Ellipse ROI", self.add_ellipse_action, self) # Expand/Collapse toggle self.expand_toggle = MaterialIconAction( @@ -174,7 +178,7 @@ class ROIPropertyTree(BECWidget, QWidget): self.controller.paletteChanged.connect(lambda cmap: setattr(self.cmap, "colormap", cmap)) # ROI drawing state - self._roi_draw_mode = None # 'rect' | 'circle' | None + self._roi_draw_mode = None # 'rect' | 'circle' | 'ellipse' | None self._roi_start_pos = None # QPointF in image coords self._temp_roi = None # live ROI being resized while dragging @@ -185,6 +189,9 @@ class ROIPropertyTree(BECWidget, QWidget): self.add_circle_action.action.toggled.connect( lambda on: self._set_roi_draw_mode("circle" if on else None) ) + self.add_ellipse_action.action.toggled.connect( + lambda on: self._set_roi_draw_mode("ellipse" if on else None) + ) # capture mouse events on the plot scene self.plot.scene().installEventFilter(self) @@ -218,12 +225,20 @@ class ROIPropertyTree(BECWidget, QWidget): if mode == "rect": self.add_rect_action.action.setChecked(True) self.add_circle_action.action.setChecked(False) + self.add_ellipse_action.action.setChecked(False) elif mode == "circle": self.add_rect_action.action.setChecked(False) self.add_circle_action.action.setChecked(True) + self.add_ellipse_action.action.setChecked(False) + elif mode == "ellipse": + self.add_rect_action.action.setChecked(False) + self.add_circle_action.action.setChecked(False) + self.add_ellipse_action.action.setChecked(True) else: self.add_rect_action.action.setChecked(False) self.add_circle_action.action.setChecked(False) + self.add_ellipse_action.action.setChecked(False) + self._roi_draw_mode = mode self._roi_start_pos = None # remove any unfinished temp ROI @@ -243,12 +258,18 @@ class ROIPropertyTree(BECWidget, QWidget): parent_image=self.image_widget, resize_handles=False, ) - if self._roi_draw_mode == "circle": + elif self._roi_draw_mode == "circle": self._temp_roi = CircularROI( pos=[self._roi_start_pos.x() - 2.5, self._roi_start_pos.y() - 2.5], size=[5, 5], parent_image=self.image_widget, ) + elif self._roi_draw_mode == "ellipse": + self._temp_roi = EllipticalROI( + pos=[self._roi_start_pos.x() - 2.5, self._roi_start_pos.y() - 2.5], + size=[5, 5], + parent_image=self.image_widget, + ) self.plot.addItem(self._temp_roi) return True elif event.type() == QEvent.GraphicsSceneMouseMove and self._temp_roi is not None: @@ -258,13 +279,19 @@ class ROIPropertyTree(BECWidget, QWidget): if self._roi_draw_mode == "rect": self._temp_roi.setSize([dx, dy]) - if self._roi_draw_mode == "circle": + elif self._roi_draw_mode == "circle": r = max( 1, math.hypot(dx, dy) ) # radius never smaller than 1 for safety of handle mapping, otherwise SEGFAULT d = 2 * r # diameter self._temp_roi.setPos(self._roi_start_pos.x() - r, self._roi_start_pos.y() - r) self._temp_roi.setSize([d, d]) + elif self._roi_draw_mode == "ellipse": + # Safeguard: enforce a minimum ellipse width/height of 2 px + min_dim = 2.0 + w = dx if abs(dx) >= min_dim else math.copysign(min_dim, dx or 1.0) + h = dy if abs(dy) >= min_dim else math.copysign(min_dim, dy or 1.0) + self._temp_roi.setSize([w, h]) return True elif ( event.type() == QEvent.GraphicsSceneMouseRelease diff --git a/bec_widgets/widgets/plots/roi/image_roi.py b/bec_widgets/widgets/plots/roi/image_roi.py index 242e4e0d..620570a0 100644 --- a/bec_widgets/widgets/plots/roi/image_roi.py +++ b/bec_widgets/widgets/plots/roi/image_roi.py @@ -750,6 +750,92 @@ class CircularROI(BaseROI, pg.CircleROI): return None +class EllipticalROI(BaseROI, pg.EllipseROI): + """ + Elliptical Region of Interest with centre/width/height tracking and auto-labelling. + + Mirrors the behaviour of ``CircularROI`` but supports independent + horizontal and vertical radii. + """ + + centerChanged = Signal(float, float, float, float) # cx, cy, width, height + centerReleased = Signal(float, float, float, float) + + def __init__( + self, + *, + pos, + size, + pen=None, + config: ConnectionConfig | None = None, + gui_id: str | None = None, + parent_image: Image | None = None, + label: str | None = None, + line_color: str | None = None, + line_width: int = 5, + movable: bool = True, + **extra_pg, + ): + super().__init__( + config=config, + gui_id=gui_id, + parent_image=parent_image, + label=label, + line_color=line_color, + line_width=line_width, + pos=pos, + size=size, + pen=pen, + movable=movable, + **extra_pg, + ) + + self.sigRegionChanged.connect(self._on_region_changed) + self._adorner = LabelAdorner(self) + self.hoverPen = fn.mkPen(color=(255, 0, 0), width=3, style=QtCore.Qt.DashLine) + self.handleHoverPen = fn.mkPen("lime", width=4) + + def add_scale_handle(self): + """Add scale handles to the elliptical ROI.""" + self._addHandles() # delegates to pg.EllipseROI + + def _on_region_changed(self): + w = abs(self.state["size"][0]) + h = abs(self.state["size"][1]) + cx = self.pos().x() + w / 2 + cy = self.pos().y() + h / 2 + self.centerChanged.emit(cx, cy, w, h) + self.parent_plot_item.vb.update() + + def mouseDragEvent(self, ev): + super().mouseDragEvent(ev) + if ev.isFinish(): + w = abs(self.state["size"][0]) + h = abs(self.state["size"][1]) + cx = self.pos().x() + w / 2 + cy = self.pos().y() + h / 2 + self.centerReleased.emit(cx, cy, w, h) + + def get_coordinates(self, typed: bool | None = None) -> dict | tuple: + """ + Return the ellipse's centre and size. + + Args: + typed (bool | None): If True returns dict; otherwise tuple. + """ + if typed is None: + typed = self.description + + w, h = map(abs, self.state["size"]) # raw diameters + major, minor = (w, h) if w >= h else (h, w) + cx = self.pos().x() + w / 2 + cy = self.pos().y() + h / 2 + + if typed: + return {"center_x": cx, "center_y": cy, "major_axis": major, "minor_axis": minor} + return (cx, cy, major, minor) + + class ROIController(QObject): """Manages a collection of ROIs (Regions of Interest) with palette-assigned colors. diff --git a/tests/unit_tests/test_image_rois.py b/tests/unit_tests/test_image_rois.py index 01728e05..936f0a63 100644 --- a/tests/unit_tests/test_image_rois.py +++ b/tests/unit_tests/test_image_rois.py @@ -6,16 +6,21 @@ import numpy as np import pytest from bec_widgets.widgets.plots.image.image import Image -from bec_widgets.widgets.plots.roi.image_roi import CircularROI, RectangularROI, ROIController +from bec_widgets.widgets.plots.roi.image_roi import ( + CircularROI, + EllipticalROI, + RectangularROI, + ROIController, +) from tests.unit_tests.client_mocks import mocked_client from tests.unit_tests.conftest import create_widget -@pytest.fixture(params=["rect", "circle"]) +@pytest.fixture(params=["rect", "circle", "ellipse"]) def bec_image_widget_with_roi(qtbot, request, mocked_client): """Return (widget, roi, shape_label) for each ROI class.""" - roi_type: Literal["rect", "circle"] = request.param + roi_type: Literal["rect", "circle", "ellipse"] = request.param # Build an Image widget with a trivial 100×100 zeros array widget: Image = create_widget(qtbot, Image, client=mocked_client) @@ -39,7 +44,12 @@ def test_default_properties(bec_image_widget_with_roi): assert roi.line_width == 5 # concrete subclass type - assert isinstance(roi, RectangularROI) if roi_type == "rect" else isinstance(roi, CircularROI) + if roi_type == "rect": + assert isinstance(roi, RectangularROI) + elif roi_type == "circle": + assert isinstance(roi, CircularROI) + elif roi_type == "ellipse": + assert isinstance(roi, EllipticalROI) def test_coordinate_structures(bec_image_widget_with_roi): @@ -98,7 +108,7 @@ def test_color_uniqueness_across_multiple_rois(qtbot, mocked_client): widget: Image = create_widget(qtbot, Image, client=mocked_client) # add two of each ROI type - for _kind in ("rect", "circle"): + for _kind in ("rect", "circle", "ellipse"): widget.add_roi(kind=_kind) widget.add_roi(kind=_kind)