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):
|
class Image(RPCBase):
|
||||||
"""Image widget for displaying 2D data."""
|
"""Image widget for displaying 2D data."""
|
||||||
|
|
||||||
@ -1529,7 +1651,7 @@ class Image(RPCBase):
|
|||||||
@rpc_call
|
@rpc_call
|
||||||
def add_roi(
|
def add_roi(
|
||||||
self,
|
self,
|
||||||
kind: "Literal['rect', 'circle']" = "rect",
|
kind: "Literal['rect', 'circle', 'ellipse']" = "rect",
|
||||||
name: "str | None" = None,
|
name: "str | None" = None,
|
||||||
line_width: "int | None" = 5,
|
line_width: "int | None" = 5,
|
||||||
pos: "tuple[float, float] | None" = (10, 10),
|
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 (
|
from bec_widgets.widgets.plots.roi.image_roi import (
|
||||||
BaseROI,
|
BaseROI,
|
||||||
CircularROI,
|
CircularROI,
|
||||||
|
EllipticalROI,
|
||||||
RectangularROI,
|
RectangularROI,
|
||||||
ROIController,
|
ROIController,
|
||||||
)
|
)
|
||||||
@ -554,7 +555,7 @@ class ImageBase(PlotBase):
|
|||||||
|
|
||||||
def add_roi(
|
def add_roi(
|
||||||
self,
|
self,
|
||||||
kind: Literal["rect", "circle"] = "rect",
|
kind: Literal["rect", "circle", "ellipse"] = "rect",
|
||||||
name: str | None = None,
|
name: str | None = None,
|
||||||
line_width: int | None = 5,
|
line_width: int | None = 5,
|
||||||
pos: tuple[float, float] | None = (10, 10),
|
pos: tuple[float, float] | None = (10, 10),
|
||||||
@ -599,6 +600,16 @@ class ImageBase(PlotBase):
|
|||||||
movable=movable,
|
movable=movable,
|
||||||
**pg_kwargs,
|
**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:
|
else:
|
||||||
raise ValueError("kind must be 'rect' or 'circle'")
|
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 (
|
from bec_widgets.widgets.plots.roi.image_roi import (
|
||||||
BaseROI,
|
BaseROI,
|
||||||
CircularROI,
|
CircularROI,
|
||||||
|
EllipticalROI,
|
||||||
RectangularROI,
|
RectangularROI,
|
||||||
ROIController,
|
ROIController,
|
||||||
)
|
)
|
||||||
@ -126,6 +127,9 @@ class ROIPropertyTree(BECWidget, QWidget):
|
|||||||
self.add_circle_action = MaterialIconAction("add_circle", "Add Circle ROI", True, self)
|
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 Rect ROI", self.add_rect_action, self)
|
||||||
tb.add_action("Add Circle ROI", self.add_circle_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
|
# Expand/Collapse toggle
|
||||||
self.expand_toggle = MaterialIconAction(
|
self.expand_toggle = MaterialIconAction(
|
||||||
@ -174,7 +178,7 @@ class ROIPropertyTree(BECWidget, QWidget):
|
|||||||
self.controller.paletteChanged.connect(lambda cmap: setattr(self.cmap, "colormap", cmap))
|
self.controller.paletteChanged.connect(lambda cmap: setattr(self.cmap, "colormap", cmap))
|
||||||
|
|
||||||
# ROI drawing state
|
# 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._roi_start_pos = None # QPointF in image coords
|
||||||
self._temp_roi = None # live ROI being resized while dragging
|
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(
|
self.add_circle_action.action.toggled.connect(
|
||||||
lambda on: self._set_roi_draw_mode("circle" if on else None)
|
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
|
# capture mouse events on the plot scene
|
||||||
self.plot.scene().installEventFilter(self)
|
self.plot.scene().installEventFilter(self)
|
||||||
|
|
||||||
@ -218,12 +225,20 @@ class ROIPropertyTree(BECWidget, QWidget):
|
|||||||
if mode == "rect":
|
if mode == "rect":
|
||||||
self.add_rect_action.action.setChecked(True)
|
self.add_rect_action.action.setChecked(True)
|
||||||
self.add_circle_action.action.setChecked(False)
|
self.add_circle_action.action.setChecked(False)
|
||||||
|
self.add_ellipse_action.action.setChecked(False)
|
||||||
elif mode == "circle":
|
elif mode == "circle":
|
||||||
self.add_rect_action.action.setChecked(False)
|
self.add_rect_action.action.setChecked(False)
|
||||||
self.add_circle_action.action.setChecked(True)
|
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:
|
else:
|
||||||
self.add_rect_action.action.setChecked(False)
|
self.add_rect_action.action.setChecked(False)
|
||||||
self.add_circle_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_draw_mode = mode
|
||||||
self._roi_start_pos = None
|
self._roi_start_pos = None
|
||||||
# remove any unfinished temp ROI
|
# remove any unfinished temp ROI
|
||||||
@ -243,12 +258,18 @@ class ROIPropertyTree(BECWidget, QWidget):
|
|||||||
parent_image=self.image_widget,
|
parent_image=self.image_widget,
|
||||||
resize_handles=False,
|
resize_handles=False,
|
||||||
)
|
)
|
||||||
if self._roi_draw_mode == "circle":
|
elif self._roi_draw_mode == "circle":
|
||||||
self._temp_roi = CircularROI(
|
self._temp_roi = CircularROI(
|
||||||
pos=[self._roi_start_pos.x() - 2.5, self._roi_start_pos.y() - 2.5],
|
pos=[self._roi_start_pos.x() - 2.5, self._roi_start_pos.y() - 2.5],
|
||||||
size=[5, 5],
|
size=[5, 5],
|
||||||
parent_image=self.image_widget,
|
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)
|
self.plot.addItem(self._temp_roi)
|
||||||
return True
|
return True
|
||||||
elif event.type() == QEvent.GraphicsSceneMouseMove and self._temp_roi is not None:
|
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":
|
if self._roi_draw_mode == "rect":
|
||||||
self._temp_roi.setSize([dx, dy])
|
self._temp_roi.setSize([dx, dy])
|
||||||
if self._roi_draw_mode == "circle":
|
elif self._roi_draw_mode == "circle":
|
||||||
r = max(
|
r = max(
|
||||||
1, math.hypot(dx, dy)
|
1, math.hypot(dx, dy)
|
||||||
) # radius never smaller than 1 for safety of handle mapping, otherwise SEGFAULT
|
) # radius never smaller than 1 for safety of handle mapping, otherwise SEGFAULT
|
||||||
d = 2 * r # diameter
|
d = 2 * r # diameter
|
||||||
self._temp_roi.setPos(self._roi_start_pos.x() - r, self._roi_start_pos.y() - r)
|
self._temp_roi.setPos(self._roi_start_pos.x() - r, self._roi_start_pos.y() - r)
|
||||||
self._temp_roi.setSize([d, d])
|
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
|
return True
|
||||||
elif (
|
elif (
|
||||||
event.type() == QEvent.GraphicsSceneMouseRelease
|
event.type() == QEvent.GraphicsSceneMouseRelease
|
||||||
|
@ -750,6 +750,92 @@ class CircularROI(BaseROI, pg.CircleROI):
|
|||||||
return None
|
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):
|
class ROIController(QObject):
|
||||||
"""Manages a collection of ROIs (Regions of Interest) with palette-assigned colors.
|
"""Manages a collection of ROIs (Regions of Interest) with palette-assigned colors.
|
||||||
|
|
||||||
|
@ -6,16 +6,21 @@ import numpy as np
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from bec_widgets.widgets.plots.image.image import Image
|
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.client_mocks import mocked_client
|
||||||
from tests.unit_tests.conftest import create_widget
|
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):
|
def bec_image_widget_with_roi(qtbot, request, mocked_client):
|
||||||
"""Return (widget, roi, shape_label) for each ROI class."""
|
"""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
|
# Build an Image widget with a trivial 100×100 zeros array
|
||||||
widget: Image = create_widget(qtbot, Image, client=mocked_client)
|
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
|
assert roi.line_width == 5
|
||||||
|
|
||||||
# concrete subclass type
|
# 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):
|
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)
|
widget: Image = create_widget(qtbot, Image, client=mocked_client)
|
||||||
|
|
||||||
# add two of each ROI type
|
# 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)
|
||||||
widget.add_roi(kind=_kind)
|
widget.add_roi(kind=_kind)
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user