mirror of
https://github.com/bec-project/bec_widgets.git
synced 2025-07-13 11:11:49 +02:00
feat(image_roi): added EllipticalROI
This commit is contained in:
@ -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),
|
||||
|
@ -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'")
|
||||
|
||||
|
@ -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
|
||||
|
@ -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.
|
||||
|
||||
|
@ -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)
|
||||
|
||||
|
Reference in New Issue
Block a user