mirror of
https://github.com/bec-project/bec_widgets.git
synced 2025-07-14 03:31:50 +02:00
feat(image_roi_tree): gui roi manager for image widget
This commit is contained in:
@ -1438,7 +1438,7 @@ class Image(RPCBase):
|
|||||||
self,
|
self,
|
||||||
kind: "Literal['rect', 'circle']" = "rect",
|
kind: "Literal['rect', 'circle']" = "rect",
|
||||||
name: "str | None" = None,
|
name: "str | None" = None,
|
||||||
line_width: "int | None" = 10,
|
line_width: "int | None" = 5,
|
||||||
pos: "tuple[float, float] | None" = (10, 10),
|
pos: "tuple[float, float] | None" = (10, 10),
|
||||||
size: "tuple[float, float] | None" = (50, 50),
|
size: "tuple[float, float] | None" = (50, 50),
|
||||||
**pg_kwargs,
|
**pg_kwargs,
|
||||||
|
@ -114,7 +114,7 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
|
|||||||
#
|
#
|
||||||
sixth_tab = QWidget()
|
sixth_tab = QWidget()
|
||||||
sixth_tab_layout = QVBoxLayout(sixth_tab)
|
sixth_tab_layout = QVBoxLayout(sixth_tab)
|
||||||
self.im = Image(popups=False)
|
self.im = Image(popups=True)
|
||||||
self.mi = self.im.main_image
|
self.mi = self.im.main_image
|
||||||
sixth_tab_layout.addWidget(self.im)
|
sixth_tab_layout.addWidget(self.im)
|
||||||
tab_widget.addTab(sixth_tab, "Image Next Gen")
|
tab_widget.addTab(sixth_tab, "Image Next Gen")
|
||||||
|
@ -8,13 +8,14 @@ from bec_lib import bec_logger
|
|||||||
from bec_lib.endpoints import MessageEndpoints
|
from bec_lib.endpoints import MessageEndpoints
|
||||||
from pydantic import Field, ValidationError, field_validator
|
from pydantic import Field, ValidationError, field_validator
|
||||||
from qtpy.QtCore import QPointF, Signal
|
from qtpy.QtCore import QPointF, Signal
|
||||||
from qtpy.QtWidgets import QWidget
|
from qtpy.QtWidgets import QDialog, QVBoxLayout, QWidget
|
||||||
|
|
||||||
from bec_widgets.utils import ConnectionConfig
|
from bec_widgets.utils import ConnectionConfig
|
||||||
from bec_widgets.utils.colors import Colors
|
from bec_widgets.utils.colors import Colors
|
||||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||||
from bec_widgets.utils.toolbar import MaterialIconAction, SwitchableToolBarAction
|
from bec_widgets.utils.toolbar import MaterialIconAction, SwitchableToolBarAction
|
||||||
from bec_widgets.widgets.plots.image.image_item import ImageItem
|
from bec_widgets.widgets.plots.image.image_item import ImageItem
|
||||||
|
from bec_widgets.widgets.plots.image.setting_widgets.image_roi_tree import ROIPropertyTree
|
||||||
from bec_widgets.widgets.plots.image.toolbar_bundles.image_selection import (
|
from bec_widgets.widgets.plots.image.toolbar_bundles.image_selection import (
|
||||||
MonitorSelectionToolbarBundle,
|
MonitorSelectionToolbarBundle,
|
||||||
)
|
)
|
||||||
@ -149,8 +150,7 @@ class Image(PlotBase):
|
|||||||
# Default Color map to plasma
|
# Default Color map to plasma
|
||||||
self.color_map = "plasma"
|
self.color_map = "plasma"
|
||||||
|
|
||||||
# Headless controller keeps the canonical list.
|
self.roi_manager_dialog = None
|
||||||
self._roi_manager_dialog = None
|
|
||||||
|
|
||||||
################################################################################
|
################################################################################
|
||||||
# Widget Specific GUI interactions
|
# Widget Specific GUI interactions
|
||||||
@ -266,6 +266,55 @@ class Image(PlotBase):
|
|||||||
lambda checked: self.enable_colorbar(checked, style="full")
|
lambda checked: self.enable_colorbar(checked, style="full")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
########################################
|
||||||
|
# ROI Gui Manager
|
||||||
|
def add_side_menus(self):
|
||||||
|
super().add_side_menus()
|
||||||
|
|
||||||
|
roi_mgr = ROIPropertyTree(parent=self, image_widget=self)
|
||||||
|
self.side_panel.add_menu(
|
||||||
|
action_id="roi_mgr",
|
||||||
|
icon_name="view_list",
|
||||||
|
tooltip="ROI Manager",
|
||||||
|
widget=roi_mgr,
|
||||||
|
title="ROI Manager",
|
||||||
|
)
|
||||||
|
|
||||||
|
def add_popups(self):
|
||||||
|
super().add_popups() # keep Axis Settings
|
||||||
|
|
||||||
|
roi_action = MaterialIconAction(
|
||||||
|
icon_name="view_list", tooltip="ROI Manager", checkable=True, parent=self
|
||||||
|
)
|
||||||
|
# self.popup_bundle.add_action("roi_mgr", roi_action)
|
||||||
|
self.toolbar.add_action_to_bundle(
|
||||||
|
bundle_id="popup_bundle", action_id="roi_mgr", action=roi_action, target_widget=self
|
||||||
|
)
|
||||||
|
self.toolbar.widgets["roi_mgr"].action.triggered.connect(self.show_roi_manager_popup)
|
||||||
|
|
||||||
|
def show_roi_manager_popup(self):
|
||||||
|
roi_action = self.toolbar.widgets["roi_mgr"].action
|
||||||
|
if self.roi_manager_dialog is None or not self.roi_manager_dialog.isVisible():
|
||||||
|
self.roi_mgr = ROIPropertyTree(parent=self, image_widget=self)
|
||||||
|
self.roi_manager_dialog = QDialog(modal=False)
|
||||||
|
self.roi_manager_dialog.layout = QVBoxLayout(self.roi_manager_dialog)
|
||||||
|
self.roi_manager_dialog.layout.addWidget(self.roi_mgr)
|
||||||
|
self.roi_manager_dialog.finished.connect(self._roi_mgr_closed)
|
||||||
|
self.roi_manager_dialog.show()
|
||||||
|
roi_action.setChecked(True)
|
||||||
|
else:
|
||||||
|
self.roi_manager_dialog.raise_()
|
||||||
|
self.roi_manager_dialog.activateWindow()
|
||||||
|
roi_action.setChecked(True)
|
||||||
|
|
||||||
|
def _roi_mgr_closed(self):
|
||||||
|
self.roi_mgr.close()
|
||||||
|
self.roi_mgr.deleteLater()
|
||||||
|
self.roi_manager_dialog.close()
|
||||||
|
self.roi_manager_dialog.deleteLater()
|
||||||
|
self.roi_manager_dialog = None
|
||||||
|
self.toolbar.widgets["roi_mgr"].action.setChecked(False)
|
||||||
|
|
||||||
def enable_colorbar(
|
def enable_colorbar(
|
||||||
self,
|
self,
|
||||||
enabled: bool,
|
enabled: bool,
|
||||||
@ -324,7 +373,7 @@ class Image(PlotBase):
|
|||||||
self,
|
self,
|
||||||
kind: Literal["rect", "circle"] = "rect",
|
kind: Literal["rect", "circle"] = "rect",
|
||||||
name: str | None = None,
|
name: str | None = None,
|
||||||
line_width: int | None = 10,
|
line_width: int | None = 5,
|
||||||
pos: tuple[float, float] | None = (10, 10),
|
pos: tuple[float, float] | None = (10, 10),
|
||||||
size: tuple[float, float] | None = (50, 50),
|
size: tuple[float, float] | None = (50, 50),
|
||||||
**pg_kwargs,
|
**pg_kwargs,
|
||||||
@ -1032,6 +1081,11 @@ class Image(PlotBase):
|
|||||||
self._color_bar.deleteLater()
|
self._color_bar.deleteLater()
|
||||||
self._color_bar = None
|
self._color_bar = None
|
||||||
|
|
||||||
|
# Popup cleanup
|
||||||
|
if self.roi_manager_dialog is not None:
|
||||||
|
self.roi_manager_dialog.reject()
|
||||||
|
self.roi_manager_dialog = None
|
||||||
|
|
||||||
# Toolbar cleanup
|
# Toolbar cleanup
|
||||||
self.toolbar.widgets["monitor"].widget.close()
|
self.toolbar.widgets["monitor"].widget.close()
|
||||||
self.toolbar.widgets["monitor"].widget.deleteLater()
|
self.toolbar.widgets["monitor"].widget.deleteLater()
|
||||||
@ -1042,10 +1096,19 @@ class Image(PlotBase):
|
|||||||
if __name__ == "__main__": # pragma: no cover
|
if __name__ == "__main__": # pragma: no cover
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from qtpy.QtWidgets import QApplication
|
from qtpy.QtWidgets import QApplication, QHBoxLayout
|
||||||
|
|
||||||
app = QApplication(sys.argv)
|
app = QApplication(sys.argv)
|
||||||
widget = Image(popups=True)
|
win = QWidget()
|
||||||
widget.show()
|
win.setWindowTitle("Image Demo")
|
||||||
widget.resize(1000, 800)
|
ml = QHBoxLayout(win)
|
||||||
|
|
||||||
|
image_popup = Image(popups=True)
|
||||||
|
image_side_panel = Image(popups=False)
|
||||||
|
|
||||||
|
ml.addWidget(image_popup)
|
||||||
|
ml.addWidget(image_side_panel)
|
||||||
|
|
||||||
|
win.resize(1500, 800)
|
||||||
|
win.show()
|
||||||
sys.exit(app.exec_())
|
sys.exit(app.exec_())
|
||||||
|
@ -0,0 +1,375 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from bec_qthemes import material_icon
|
||||||
|
from qtpy.QtCore import QEvent, Qt
|
||||||
|
from qtpy.QtGui import QColor
|
||||||
|
from qtpy.QtWidgets import (
|
||||||
|
QColorDialog,
|
||||||
|
QHeaderView,
|
||||||
|
QSpinBox,
|
||||||
|
QToolButton,
|
||||||
|
QTreeWidget,
|
||||||
|
QTreeWidgetItem,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
from bec_widgets import BECWidget
|
||||||
|
from bec_widgets.utils import BECDispatcher, ConnectionConfig
|
||||||
|
from bec_widgets.utils.toolbar import MaterialIconAction, ModularToolBar
|
||||||
|
from bec_widgets.widgets.plots.roi.image_roi import (
|
||||||
|
BaseROI,
|
||||||
|
CircularROI,
|
||||||
|
RectangularROI,
|
||||||
|
ROIController,
|
||||||
|
)
|
||||||
|
from bec_widgets.widgets.utility.visual.color_button_native.color_button_native import (
|
||||||
|
ColorButtonNative,
|
||||||
|
)
|
||||||
|
from bec_widgets.widgets.utility.visual.colormap_widget.colormap_widget import BECColorMapWidget
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from bec_widgets.widgets.plots.image.image import Image
|
||||||
|
|
||||||
|
|
||||||
|
class ROIPropertyTree(BECWidget, QWidget):
|
||||||
|
"""
|
||||||
|
Two-column tree: [ROI] [Properties]
|
||||||
|
|
||||||
|
- Top-level: ROI name (editable) + color button.
|
||||||
|
- Children: type, line-width (spin box), coordinates (auto-updating).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
image_widget (Image): The main Image widget that displays the ImageItem.
|
||||||
|
Provides ``plot_item`` and owns an ROIController already.
|
||||||
|
controller (ROIController, optional): Optionally pass an external controller.
|
||||||
|
If None, the manager uses ``image_widget.roi_controller``.
|
||||||
|
parent (QWidget, optional): Parent widget. Defaults to None.
|
||||||
|
"""
|
||||||
|
|
||||||
|
PLUGIN = False
|
||||||
|
RPC = False
|
||||||
|
|
||||||
|
COL_ACTION, COL_ROI, COL_PROPS = range(3)
|
||||||
|
DELETE_BUTTON_COLOR = "#CC181E"
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
parent: QWidget = None,
|
||||||
|
image_widget: Image,
|
||||||
|
controller: ROIController | None = None,
|
||||||
|
):
|
||||||
|
|
||||||
|
super().__init__(
|
||||||
|
parent=parent, config=ConnectionConfig(widget_class=self.__class__.__name__)
|
||||||
|
)
|
||||||
|
|
||||||
|
if controller is None:
|
||||||
|
# Use the controller already belonging to the Image widget
|
||||||
|
controller = getattr(image_widget, "roi_controller", None)
|
||||||
|
if controller is None:
|
||||||
|
controller = ROIController()
|
||||||
|
image_widget.roi_controller = controller
|
||||||
|
|
||||||
|
self.image_widget = image_widget
|
||||||
|
self.plot = image_widget.plot_item
|
||||||
|
self.controller = controller
|
||||||
|
self.roi_items: dict[BaseROI, QTreeWidgetItem] = {}
|
||||||
|
|
||||||
|
self.layout = QVBoxLayout(self)
|
||||||
|
self._init_toolbar()
|
||||||
|
self._init_tree()
|
||||||
|
|
||||||
|
# connect controller
|
||||||
|
self.controller.roiAdded.connect(self._on_roi_added)
|
||||||
|
self.controller.roiRemoved.connect(self._on_roi_removed)
|
||||||
|
self.controller.cleared.connect(self.tree.clear)
|
||||||
|
|
||||||
|
# initial load
|
||||||
|
for r in self.controller.rois:
|
||||||
|
self._on_roi_added(r)
|
||||||
|
|
||||||
|
self.tree.collapseAll()
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------- UI
|
||||||
|
def _init_toolbar(self):
|
||||||
|
tb = ModularToolBar(self, self, orientation="horizontal")
|
||||||
|
# --- ROI draw actions (toggleable) ---
|
||||||
|
self.add_rect_action = MaterialIconAction("add_box", "Add Rect 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 Circle ROI", self.add_circle_action, self)
|
||||||
|
|
||||||
|
# Expand/Collapse toggle
|
||||||
|
self.expand_toggle = MaterialIconAction(
|
||||||
|
"unfold_more", "Expand/Collapse", checkable=True, parent=self # icon when collapsed
|
||||||
|
)
|
||||||
|
tb.add_action("Expand/Collapse", self.expand_toggle, self)
|
||||||
|
|
||||||
|
def _exp_toggled(on: bool):
|
||||||
|
if on:
|
||||||
|
# switched to expanded state
|
||||||
|
self.tree.expandAll()
|
||||||
|
new_icon = material_icon("unfold_less", size=(20, 20), convert_to_pixmap=False)
|
||||||
|
else:
|
||||||
|
# collapsed state
|
||||||
|
self.tree.collapseAll()
|
||||||
|
new_icon = material_icon("unfold_more", size=(20, 20), convert_to_pixmap=False)
|
||||||
|
self.expand_toggle.action.setIcon(new_icon)
|
||||||
|
|
||||||
|
self.expand_toggle.action.toggled.connect(_exp_toggled)
|
||||||
|
|
||||||
|
self.expand_toggle.action.setChecked(False)
|
||||||
|
# colormap widget
|
||||||
|
self.cmap = BECColorMapWidget(cmap=self.controller.colormap)
|
||||||
|
tb.addWidget(QWidget()) # spacer
|
||||||
|
tb.addWidget(self.cmap)
|
||||||
|
self.cmap.colormap_changed_signal.connect(self.controller.set_colormap)
|
||||||
|
self.layout.addWidget(tb)
|
||||||
|
self.controller.paletteChanged.connect(lambda cmap: setattr(self.cmap, "colormap", cmap))
|
||||||
|
|
||||||
|
# ROI drawing state
|
||||||
|
self._roi_draw_mode = None # 'rect' | 'circle' | None
|
||||||
|
self._roi_start_pos = None # QPointF in image coords
|
||||||
|
self._temp_roi = None # live ROI being resized while dragging
|
||||||
|
|
||||||
|
# toggle handlers
|
||||||
|
self.add_rect_action.action.toggled.connect(
|
||||||
|
lambda on: self._set_roi_draw_mode("rect" if on else None)
|
||||||
|
)
|
||||||
|
self.add_circle_action.action.toggled.connect(
|
||||||
|
lambda on: self._set_roi_draw_mode("circle" if on else None)
|
||||||
|
)
|
||||||
|
# capture mouse events on the plot scene
|
||||||
|
self.plot.scene().installEventFilter(self)
|
||||||
|
|
||||||
|
def _init_tree(self):
|
||||||
|
self.tree = QTreeWidget()
|
||||||
|
self.tree.setColumnCount(3)
|
||||||
|
self.tree.setHeaderLabels(["Actions", "ROI", "Properties"])
|
||||||
|
self.tree.header().setSectionResizeMode(self.COL_ACTION, QHeaderView.ResizeToContents)
|
||||||
|
self.tree.headerItem().setText(self.COL_ACTION, "Actions") # blank header text
|
||||||
|
self.tree.itemChanged.connect(self._on_item_edited)
|
||||||
|
self.layout.addWidget(self.tree)
|
||||||
|
|
||||||
|
################################################################################
|
||||||
|
# Helper functions
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------- formatting
|
||||||
|
@staticmethod
|
||||||
|
def _format_coord_text(value) -> str:
|
||||||
|
"""
|
||||||
|
Consistently format a coordinate value for display.
|
||||||
|
"""
|
||||||
|
if isinstance(value, (tuple, list)):
|
||||||
|
return "(" + ", ".join(f"{v:.2f}" for v in value) + ")"
|
||||||
|
if isinstance(value, (int, float)):
|
||||||
|
return f"{value:.2f}"
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
def _set_roi_draw_mode(self, mode: str | None):
|
||||||
|
# Ensure only the selected action is toggled on
|
||||||
|
if mode == "rect":
|
||||||
|
self.add_rect_action.action.setChecked(True)
|
||||||
|
self.add_circle_action.action.setChecked(False)
|
||||||
|
elif mode == "circle":
|
||||||
|
self.add_rect_action.action.setChecked(False)
|
||||||
|
self.add_circle_action.action.setChecked(True)
|
||||||
|
else:
|
||||||
|
self.add_rect_action.action.setChecked(False)
|
||||||
|
self.add_circle_action.action.setChecked(False)
|
||||||
|
self._roi_draw_mode = mode
|
||||||
|
self._roi_start_pos = None
|
||||||
|
# remove any unfinished temp ROI
|
||||||
|
if self._temp_roi is not None:
|
||||||
|
self.plot.removeItem(self._temp_roi)
|
||||||
|
self._temp_roi = None
|
||||||
|
|
||||||
|
def eventFilter(self, obj, event):
|
||||||
|
if self._roi_draw_mode is None:
|
||||||
|
return super().eventFilter(obj, event)
|
||||||
|
if event.type() == QEvent.GraphicsSceneMousePress and event.button() == Qt.LeftButton:
|
||||||
|
self._roi_start_pos = self.plot.vb.mapSceneToView(event.scenePos())
|
||||||
|
if self._roi_draw_mode == "rect":
|
||||||
|
self._temp_roi = RectangularROI(
|
||||||
|
pos=[self._roi_start_pos.x(), self._roi_start_pos.y()],
|
||||||
|
size=[5, 5],
|
||||||
|
parent_image=self.image_widget,
|
||||||
|
resize_handles=False,
|
||||||
|
)
|
||||||
|
if 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,
|
||||||
|
)
|
||||||
|
self.plot.addItem(self._temp_roi)
|
||||||
|
return True
|
||||||
|
elif event.type() == QEvent.GraphicsSceneMouseMove and self._temp_roi is not None:
|
||||||
|
pos = self.plot.vb.mapSceneToView(event.scenePos())
|
||||||
|
dx = pos.x() - self._roi_start_pos.x()
|
||||||
|
dy = pos.y() - self._roi_start_pos.y()
|
||||||
|
|
||||||
|
if self._roi_draw_mode == "rect":
|
||||||
|
self._temp_roi.setSize([dx, dy])
|
||||||
|
if 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])
|
||||||
|
return True
|
||||||
|
elif (
|
||||||
|
event.type() == QEvent.GraphicsSceneMouseRelease
|
||||||
|
and event.button() == Qt.LeftButton
|
||||||
|
and self._temp_roi is not None
|
||||||
|
):
|
||||||
|
# finalize ROI
|
||||||
|
final_roi = self._temp_roi
|
||||||
|
self._temp_roi = None
|
||||||
|
self._set_roi_draw_mode(None)
|
||||||
|
# register via controller
|
||||||
|
final_roi.add_scale_handle()
|
||||||
|
self.controller.add_roi(final_roi)
|
||||||
|
return True
|
||||||
|
return super().eventFilter(obj, event)
|
||||||
|
|
||||||
|
# --------------------------------------------------------- controller slots
|
||||||
|
def _on_roi_added(self, roi: BaseROI):
|
||||||
|
# parent row with blank action column, name in ROI column
|
||||||
|
parent = QTreeWidgetItem(self.tree, ["", "", ""])
|
||||||
|
parent.setText(self.COL_ROI, roi.label)
|
||||||
|
parent.setFlags(parent.flags() | Qt.ItemIsEditable)
|
||||||
|
# --- delete button in actions column ---
|
||||||
|
del_btn = QToolButton()
|
||||||
|
delete_icon = material_icon(
|
||||||
|
"delete",
|
||||||
|
size=(20, 20),
|
||||||
|
convert_to_pixmap=False,
|
||||||
|
filled=False,
|
||||||
|
color=self.DELETE_BUTTON_COLOR,
|
||||||
|
)
|
||||||
|
del_btn.setIcon(delete_icon)
|
||||||
|
self.tree.setItemWidget(parent, self.COL_ACTION, del_btn)
|
||||||
|
del_btn.clicked.connect(lambda _=None, r=roi: self._delete_roi(r))
|
||||||
|
# color button
|
||||||
|
color_btn = ColorButtonNative(parent=self, color=roi.line_color)
|
||||||
|
self.tree.setItemWidget(parent, self.COL_PROPS, color_btn)
|
||||||
|
color_btn.clicked.connect(lambda: self._pick_color(roi, color_btn))
|
||||||
|
|
||||||
|
# child rows (3 columns: action, ROI, properties)
|
||||||
|
QTreeWidgetItem(parent, ["", "Type", roi.__class__.__name__])
|
||||||
|
width_item = QTreeWidgetItem(parent, ["", "Line width", ""])
|
||||||
|
width_spin = QSpinBox()
|
||||||
|
width_spin.setRange(1, 50)
|
||||||
|
width_spin.setValue(roi.line_width)
|
||||||
|
self.tree.setItemWidget(width_item, self.COL_PROPS, width_spin)
|
||||||
|
width_spin.valueChanged.connect(lambda v, r=roi: setattr(r, "line_width", v))
|
||||||
|
|
||||||
|
# --- Step 2: Insert separate coordinate rows (one per value)
|
||||||
|
coord_rows = {}
|
||||||
|
coords = roi.get_coordinates(typed=True)
|
||||||
|
|
||||||
|
for key, value in coords.items():
|
||||||
|
# Human-readable label: “center x” from “center_x”, etc.
|
||||||
|
label = key.replace("_", " ").title()
|
||||||
|
val_text = self._format_coord_text(value)
|
||||||
|
row = QTreeWidgetItem(parent, ["", label, val_text])
|
||||||
|
coord_rows[key] = row
|
||||||
|
|
||||||
|
# keep dict refs
|
||||||
|
self.roi_items[roi] = parent
|
||||||
|
|
||||||
|
# --- Step 3: Update coordinates on ROI movement
|
||||||
|
def _update_coords():
|
||||||
|
c_dict = roi.get_coordinates(typed=True)
|
||||||
|
for k, row in coord_rows.items():
|
||||||
|
if k in c_dict:
|
||||||
|
val = c_dict[k]
|
||||||
|
row.setText(self.COL_PROPS, self._format_coord_text(val))
|
||||||
|
|
||||||
|
if isinstance(roi, RectangularROI):
|
||||||
|
roi.edgesChanged.connect(_update_coords)
|
||||||
|
else:
|
||||||
|
roi.centerChanged.connect(_update_coords)
|
||||||
|
|
||||||
|
# sync width edits back to spinbox
|
||||||
|
roi.penChanged.connect(lambda r=roi, sp=width_spin: sp.setValue(r.line_width))
|
||||||
|
roi.nameChanged.connect(lambda n, itm=parent: itm.setText(self.COL_ROI, n))
|
||||||
|
|
||||||
|
# color changes
|
||||||
|
roi.penChanged.connect(lambda r=roi, b=color_btn: b.set_color(r.line_color))
|
||||||
|
|
||||||
|
for c in range(3):
|
||||||
|
self.tree.resizeColumnToContents(c)
|
||||||
|
|
||||||
|
def _on_roi_removed(self, roi: BaseROI):
|
||||||
|
item = self.roi_items.pop(roi, None)
|
||||||
|
if item:
|
||||||
|
idx = self.tree.indexOfTopLevelItem(item)
|
||||||
|
self.tree.takeTopLevelItem(idx)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------- event handlers
|
||||||
|
def _pick_color(self, roi: BaseROI, btn: "ColorButtonNative"):
|
||||||
|
clr = QColorDialog.getColor(QColor(roi.line_color), self, "Select ROI Color")
|
||||||
|
if clr.isValid():
|
||||||
|
roi.line_color = clr.name()
|
||||||
|
btn.set_color(clr)
|
||||||
|
|
||||||
|
def _on_item_edited(self, item: QTreeWidgetItem, col: int):
|
||||||
|
if col != self.COL_ROI:
|
||||||
|
return
|
||||||
|
# find which roi
|
||||||
|
for r, it in self.roi_items.items():
|
||||||
|
if it is item:
|
||||||
|
r.label = item.text(self.COL_ROI)
|
||||||
|
break
|
||||||
|
|
||||||
|
def _delete_roi(self, roi):
|
||||||
|
self.controller.remove_roi(roi)
|
||||||
|
|
||||||
|
def cleanup(self):
|
||||||
|
self.cmap.close()
|
||||||
|
self.cmap.deleteLater()
|
||||||
|
super().cleanup()
|
||||||
|
|
||||||
|
|
||||||
|
# Demo
|
||||||
|
if __name__ == "__main__": # pragma: no cover
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from qtpy.QtWidgets import QApplication, QHBoxLayout, QVBoxLayout
|
||||||
|
|
||||||
|
from bec_widgets.widgets.plots.image.image import Image
|
||||||
|
|
||||||
|
app = QApplication(sys.argv)
|
||||||
|
|
||||||
|
bec_dispatcher = BECDispatcher(gui_id="roi_tree_demo")
|
||||||
|
client = bec_dispatcher.client
|
||||||
|
client.start()
|
||||||
|
|
||||||
|
image_widget = Image(popups=False)
|
||||||
|
image_widget.main_image.set_data(np.random.normal(size=(200, 200)))
|
||||||
|
|
||||||
|
win = QWidget()
|
||||||
|
win.setWindowTitle("Modular ROI Demo")
|
||||||
|
ml = QHBoxLayout(win)
|
||||||
|
|
||||||
|
# Add the image widget on the left
|
||||||
|
ml.addWidget(image_widget)
|
||||||
|
|
||||||
|
# ROI manager linked to that image
|
||||||
|
mgr = ROIPropertyTree(parent=image_widget, image_widget=image_widget)
|
||||||
|
mgr.setFixedWidth(350)
|
||||||
|
ml.addWidget(mgr)
|
||||||
|
|
||||||
|
win.resize(1500, 600)
|
||||||
|
win.show()
|
||||||
|
sys.exit(app.exec_())
|
@ -126,7 +126,7 @@ class BaseROI(BECConnector):
|
|||||||
# ROI-specific
|
# ROI-specific
|
||||||
label: str | None = None,
|
label: str | None = None,
|
||||||
line_color: str | None = None,
|
line_color: str | None = None,
|
||||||
line_width: int = 10,
|
line_width: int = 5,
|
||||||
# all remaining pg.*ROI kwargs (pos, size, pen, …)
|
# all remaining pg.*ROI kwargs (pos, size, pen, …)
|
||||||
**pg_kwargs,
|
**pg_kwargs,
|
||||||
):
|
):
|
||||||
@ -345,6 +345,11 @@ class BaseROI(BECConnector):
|
|||||||
self.setPos(x, y)
|
self.setPos(x, y)
|
||||||
|
|
||||||
def remove(self):
|
def remove(self):
|
||||||
|
# Delegate to controller first so that GUI managers stay in sync
|
||||||
|
controller = getattr(self.parent_image, "roi_controller", None)
|
||||||
|
if controller and self in controller.rois:
|
||||||
|
controller.remove_roi(self)
|
||||||
|
return # controller will call back into this method once deregistered
|
||||||
handles = self.handles
|
handles = self.handles
|
||||||
for i in range(len(handles)):
|
for i in range(len(handles)):
|
||||||
try:
|
try:
|
||||||
@ -353,9 +358,8 @@ class BaseROI(BECConnector):
|
|||||||
continue
|
continue
|
||||||
self.rpc_register.remove_rpc(self)
|
self.rpc_register.remove_rpc(self)
|
||||||
self.parent_image.plot_item.removeItem(self)
|
self.parent_image.plot_item.removeItem(self)
|
||||||
if hasattr(self.parent_image, "roi_controller"):
|
viewBox = self.parent_plot_item.vb
|
||||||
self.parent_image.roi_controller._rois.remove(self)
|
viewBox.update()
|
||||||
self.parent_image.roi_controller._rebuild_color_buffer()
|
|
||||||
|
|
||||||
|
|
||||||
class RectangularROI(BaseROI, pg.RectROI):
|
class RectangularROI(BaseROI, pg.RectROI):
|
||||||
@ -389,7 +393,7 @@ class RectangularROI(BaseROI, pg.RectROI):
|
|||||||
# ROI specifics
|
# ROI specifics
|
||||||
label: str | None = None,
|
label: str | None = None,
|
||||||
line_color: str | None = None,
|
line_color: str | None = None,
|
||||||
line_width: int = 10,
|
line_width: int = 5,
|
||||||
resize_handles: bool = True,
|
resize_handles: bool = True,
|
||||||
**extra_pg,
|
**extra_pg,
|
||||||
):
|
):
|
||||||
@ -558,7 +562,7 @@ class CircularROI(BaseROI, pg.CircleROI):
|
|||||||
parent_image: Image | None = None,
|
parent_image: Image | None = None,
|
||||||
label: str | None = None,
|
label: str | None = None,
|
||||||
line_color: str | None = None,
|
line_color: str | None = None,
|
||||||
line_width: int = 10,
|
line_width: int = 5,
|
||||||
**extra_pg,
|
**extra_pg,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
@ -739,7 +743,7 @@ class ROIController(QObject):
|
|||||||
roi.line_color = color
|
roi.line_color = color
|
||||||
# ensure line width default is at least 3 if not previously set
|
# ensure line width default is at least 3 if not previously set
|
||||||
if getattr(roi, "line_width", 0) < 1:
|
if getattr(roi, "line_width", 0) < 1:
|
||||||
roi.line_width = 10
|
roi.line_width = 5
|
||||||
self.roiAdded.emit(roi)
|
self.roiAdded.emit(roi)
|
||||||
|
|
||||||
def remove_roi(self, roi: BaseROI):
|
def remove_roi(self, roi: BaseROI):
|
||||||
@ -752,8 +756,12 @@ class ROIController(QObject):
|
|||||||
Args:
|
Args:
|
||||||
roi (BaseROI): The ROI instance to remove.
|
roi (BaseROI): The ROI instance to remove.
|
||||||
"""
|
"""
|
||||||
rois = self._rois
|
if roi in self._rois:
|
||||||
if roi not in rois:
|
self.roiRemoved.emit(roi)
|
||||||
|
self._rois.remove(roi)
|
||||||
|
roi.remove()
|
||||||
|
self._rebuild_color_buffer()
|
||||||
|
else:
|
||||||
roi.remove()
|
roi.remove()
|
||||||
|
|
||||||
def get_roi(self, index: int) -> BaseROI | None:
|
def get_roi(self, index: int) -> BaseROI | None:
|
||||||
@ -796,7 +804,7 @@ class ROIController(QObject):
|
|||||||
"""
|
"""
|
||||||
roi = self.get_roi(index)
|
roi = self.get_roi(index)
|
||||||
if roi is not None:
|
if roi is not None:
|
||||||
roi.remove()
|
self.remove_roi(roi)
|
||||||
|
|
||||||
def remove_roi_by_name(self, name: str):
|
def remove_roi_by_name(self, name: str):
|
||||||
"""
|
"""
|
||||||
@ -807,7 +815,7 @@ class ROIController(QObject):
|
|||||||
"""
|
"""
|
||||||
roi = self.get_roi_by_name(name)
|
roi = self.get_roi_by_name(name)
|
||||||
if roi is not None:
|
if roi is not None:
|
||||||
roi.remove()
|
self.remove_roi(roi)
|
||||||
|
|
||||||
def clear(self):
|
def clear(self):
|
||||||
"""
|
"""
|
||||||
@ -817,7 +825,7 @@ class ROIController(QObject):
|
|||||||
the cleared signal to notify listeners that all ROIs have been removed.
|
the cleared signal to notify listeners that all ROIs have been removed.
|
||||||
"""
|
"""
|
||||||
for roi in list(self._rois):
|
for roi in list(self._rois):
|
||||||
roi.remove()
|
self.remove_roi(roi)
|
||||||
self.cleared.emit()
|
self.cleared.emit()
|
||||||
|
|
||||||
def renormalize_colors(self):
|
def renormalize_colors(self):
|
||||||
|
333
tests/unit_tests/test_image_roi_tree.py
Normal file
333
tests/unit_tests/test_image_roi_tree.py
Normal file
@ -0,0 +1,333 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
from qtpy.QtCore import QPointF, Qt
|
||||||
|
|
||||||
|
from bec_widgets.widgets.plots.image.image import Image
|
||||||
|
from bec_widgets.widgets.plots.image.setting_widgets.image_roi_tree import ROIPropertyTree
|
||||||
|
from bec_widgets.widgets.plots.roi.image_roi import CircularROI, RectangularROI
|
||||||
|
from tests.unit_tests.client_mocks import mocked_client
|
||||||
|
from tests.unit_tests.conftest import create_widget
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def image_widget(qtbot, mocked_client):
|
||||||
|
"""Create an Image widget with some test data."""
|
||||||
|
widget = create_widget(qtbot, Image, client=mocked_client)
|
||||||
|
# Add a simple test image
|
||||||
|
data = np.zeros((100, 100), dtype=float)
|
||||||
|
data[20:40, 20:40] = 5 # A square region with value 5
|
||||||
|
widget.main_image.set_data(data)
|
||||||
|
yield widget
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def roi_tree(qtbot, image_widget):
|
||||||
|
"""Create an ROI property tree widget linked to the image widget."""
|
||||||
|
tree = create_widget(qtbot, ROIPropertyTree, image_widget=image_widget)
|
||||||
|
yield tree
|
||||||
|
|
||||||
|
|
||||||
|
def test_initialization(roi_tree, image_widget):
|
||||||
|
"""Test that the widget initializes correctly with the right components."""
|
||||||
|
# Check the widget has the right structure
|
||||||
|
assert roi_tree.image_widget == image_widget
|
||||||
|
assert roi_tree.plot == image_widget.plot_item
|
||||||
|
assert roi_tree.controller == image_widget.roi_controller
|
||||||
|
assert isinstance(roi_tree.roi_items, dict)
|
||||||
|
assert len(roi_tree.tree.findItems("", Qt.MatchContains)) == 0 # Empty tree initially
|
||||||
|
|
||||||
|
# Check toolbar actions
|
||||||
|
assert hasattr(roi_tree, "add_rect_action")
|
||||||
|
assert hasattr(roi_tree, "add_circle_action")
|
||||||
|
assert hasattr(roi_tree, "expand_toggle")
|
||||||
|
|
||||||
|
# Check tree view setup
|
||||||
|
assert roi_tree.tree.columnCount() == 3
|
||||||
|
assert roi_tree.tree.headerItem().text(roi_tree.COL_ACTION) == "Actions"
|
||||||
|
assert roi_tree.tree.headerItem().text(roi_tree.COL_ROI) == "ROI"
|
||||||
|
assert roi_tree.tree.headerItem().text(roi_tree.COL_PROPS) == "Properties"
|
||||||
|
|
||||||
|
|
||||||
|
def test_controller_connection(roi_tree, image_widget):
|
||||||
|
"""Test that controller signals/slots are properly connected."""
|
||||||
|
roi = image_widget.add_roi(kind="rect", name="test_roi")
|
||||||
|
|
||||||
|
# Verify that ROI was added to the tree
|
||||||
|
assert roi in roi_tree.roi_items
|
||||||
|
assert len(roi_tree.tree.findItems("test_roi", Qt.MatchExactly, roi_tree.COL_ROI)) == 1
|
||||||
|
|
||||||
|
# Remove ROI via controller and check that it's removed from the tree
|
||||||
|
image_widget.remove_roi(0)
|
||||||
|
assert roi not in roi_tree.roi_items
|
||||||
|
assert len(roi_tree.tree.findItems("test_roi", Qt.MatchExactly, roi_tree.COL_ROI)) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_expand_collapse_tree(roi_tree, image_widget):
|
||||||
|
"""Test that triggering the expand action expands and collapses all ROI items in the tree."""
|
||||||
|
roi1 = image_widget.add_roi(kind="rect", name="rect1")
|
||||||
|
roi2 = image_widget.add_roi(kind="circle", name="circle1")
|
||||||
|
item1 = roi_tree.roi_items[roi1]
|
||||||
|
item2 = roi_tree.roi_items[roi2]
|
||||||
|
|
||||||
|
# Initially, items should be collapsed
|
||||||
|
assert not item1.isExpanded()
|
||||||
|
assert not item2.isExpanded()
|
||||||
|
|
||||||
|
# Trigger expand
|
||||||
|
roi_tree.expand_toggle.action.trigger()
|
||||||
|
assert item1.isExpanded()
|
||||||
|
assert item2.isExpanded()
|
||||||
|
|
||||||
|
# Trigger collapse
|
||||||
|
roi_tree.expand_toggle.action.trigger()
|
||||||
|
assert not item1.isExpanded()
|
||||||
|
assert not item2.isExpanded()
|
||||||
|
|
||||||
|
|
||||||
|
def test_roi_properties_display(roi_tree, image_widget):
|
||||||
|
"""Test that ROI properties are displayed correctly in the tree."""
|
||||||
|
# Add ROI with specific properties
|
||||||
|
roi = image_widget.add_roi(kind="rect", name="prop_test", line_width=15)
|
||||||
|
roi.line_color = "#FF0000" # bright red
|
||||||
|
|
||||||
|
# Find the tree item
|
||||||
|
item = roi_tree.roi_items[roi]
|
||||||
|
|
||||||
|
# Check property display
|
||||||
|
assert item.text(roi_tree.COL_ROI) == "prop_test"
|
||||||
|
|
||||||
|
# Find the type item (first child)
|
||||||
|
type_item = item.child(0)
|
||||||
|
assert type_item.text(roi_tree.COL_ROI) == "Type"
|
||||||
|
assert type_item.text(roi_tree.COL_PROPS) == "RectangularROI"
|
||||||
|
|
||||||
|
# Find the width item (second child)
|
||||||
|
width_item = item.child(1)
|
||||||
|
assert width_item.text(roi_tree.COL_ROI) == "Line width"
|
||||||
|
width_spin = roi_tree.tree.itemWidget(width_item, roi_tree.COL_PROPS)
|
||||||
|
assert width_spin.value() == 15
|
||||||
|
|
||||||
|
|
||||||
|
def test_roi_name_edit(roi_tree, image_widget, qtbot):
|
||||||
|
"""Test editing the ROI name in the tree."""
|
||||||
|
roi = image_widget.add_roi(kind="rect", name="original_name")
|
||||||
|
item = roi_tree.roi_items[roi]
|
||||||
|
|
||||||
|
# Edit the name - simulate user editing the item
|
||||||
|
item.setFlags(item.flags() | Qt.ItemIsEditable)
|
||||||
|
roi_tree.tree.editItem(item, roi_tree.COL_ROI)
|
||||||
|
qtbot.keyClicks(roi_tree.tree.viewport().focusWidget(), "new_name")
|
||||||
|
qtbot.keyClick(roi_tree.tree.viewport().focusWidget(), Qt.Key_Return)
|
||||||
|
qtbot.wait(200)
|
||||||
|
|
||||||
|
# Check the ROI name was updated
|
||||||
|
assert roi.label == "new_name"
|
||||||
|
assert item.text(roi_tree.COL_ROI) == "new_name"
|
||||||
|
|
||||||
|
|
||||||
|
def test_roi_width_edit(roi_tree, image_widget, qtbot):
|
||||||
|
"""Test editing ROI line width via spin box."""
|
||||||
|
roi = image_widget.add_roi(kind="rect", name="width_test", line_width=5)
|
||||||
|
item = roi_tree.roi_items[roi]
|
||||||
|
|
||||||
|
# Find the width spin box
|
||||||
|
width_item = item.child(1) # Second child item (index 1)
|
||||||
|
width_spin = roi_tree.tree.itemWidget(width_item, roi_tree.COL_PROPS)
|
||||||
|
|
||||||
|
# Change the width
|
||||||
|
width_spin.setValue(25)
|
||||||
|
qtbot.wait(200)
|
||||||
|
# Check the ROI width was updated
|
||||||
|
assert roi.line_width == 25
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_roi_button(roi_tree, image_widget, qtbot):
|
||||||
|
"""Test that the delete button correctly removes the ROI."""
|
||||||
|
roi = image_widget.add_roi(kind="rect", name="to_delete")
|
||||||
|
item = roi_tree.roi_items[roi]
|
||||||
|
|
||||||
|
# Get the delete button
|
||||||
|
del_btn = roi_tree.tree.itemWidget(item, roi_tree.COL_ACTION)
|
||||||
|
|
||||||
|
# Click the delete button
|
||||||
|
del_btn.click()
|
||||||
|
qtbot.wait(200)
|
||||||
|
|
||||||
|
# Verify ROI was removed
|
||||||
|
assert roi not in roi_tree.roi_items
|
||||||
|
assert roi not in image_widget.roi_controller.rois
|
||||||
|
|
||||||
|
|
||||||
|
def test_roi_color_change_from_roi(roi_tree, image_widget):
|
||||||
|
"""Test that changing the ROI color updates the tree display."""
|
||||||
|
roi = image_widget.add_roi(kind="rect", name="color_test")
|
||||||
|
item = roi_tree.roi_items[roi]
|
||||||
|
|
||||||
|
# Change the ROI color directly
|
||||||
|
roi.line_color = "#00FF00" # bright green
|
||||||
|
|
||||||
|
# Check that the color button was updated
|
||||||
|
color_btn = roi_tree.tree.itemWidget(item, roi_tree.COL_PROPS)
|
||||||
|
assert color_btn.color == "#00FF00"
|
||||||
|
|
||||||
|
|
||||||
|
def test_colormap_change(roi_tree, image_widget):
|
||||||
|
"""Test changing the colormap affects ROI colors."""
|
||||||
|
# Add multiple ROIs
|
||||||
|
roi1 = image_widget.add_roi(kind="rect", name="r1")
|
||||||
|
roi2 = image_widget.add_roi(kind="circle", name="c1")
|
||||||
|
|
||||||
|
# Store original colors
|
||||||
|
orig_colors = [roi1.line_color, roi2.line_color]
|
||||||
|
|
||||||
|
# Change colormap to "plasma" from the color map widget
|
||||||
|
roi_tree.cmap.colormap = "plasma"
|
||||||
|
|
||||||
|
# Colors should have changed
|
||||||
|
new_colors = [roi1.line_color, roi2.line_color]
|
||||||
|
assert new_colors != orig_colors
|
||||||
|
|
||||||
|
|
||||||
|
def test_coordinates_update(roi_tree, image_widget):
|
||||||
|
"""Test that coordinates update when ROI is moved."""
|
||||||
|
# Add a rectangular ROI
|
||||||
|
roi = image_widget.add_roi(kind="rect", name="moving_roi", pos=(10, 10), size=(20, 20))
|
||||||
|
item = roi_tree.roi_items[roi]
|
||||||
|
|
||||||
|
# Find coordinate items (type and width are 0 and 1, coordinates start at 2)
|
||||||
|
coordinate_items = [item.child(i) for i in range(2, item.childCount())]
|
||||||
|
|
||||||
|
# Store initial coordinates
|
||||||
|
initial_coords = [item.text(roi_tree.COL_PROPS) for item in coordinate_items]
|
||||||
|
|
||||||
|
# Move the ROI
|
||||||
|
roi.setPos(50, 50)
|
||||||
|
|
||||||
|
# Check that coordinates were updated
|
||||||
|
new_coords = [item.text(roi_tree.COL_PROPS) for item in coordinate_items]
|
||||||
|
assert new_coords != initial_coords
|
||||||
|
|
||||||
|
|
||||||
|
def test_draw_mode_toggle(roi_tree, qtbot):
|
||||||
|
"""Test toggling draw modes."""
|
||||||
|
# Initially no draw mode
|
||||||
|
assert roi_tree._roi_draw_mode is None
|
||||||
|
|
||||||
|
# Toggle rect mode on
|
||||||
|
roi_tree.add_rect_action.action.toggle()
|
||||||
|
assert roi_tree._roi_draw_mode == "rect"
|
||||||
|
assert roi_tree.add_rect_action.action.isChecked()
|
||||||
|
assert not roi_tree.add_circle_action.action.isChecked()
|
||||||
|
|
||||||
|
# Toggle circle mode on (should turn off rect mode)
|
||||||
|
roi_tree.add_circle_action.action.toggle()
|
||||||
|
qtbot.wait(200)
|
||||||
|
assert roi_tree._roi_draw_mode == "circle"
|
||||||
|
assert not roi_tree.add_rect_action.action.isChecked()
|
||||||
|
assert roi_tree.add_circle_action.action.isChecked()
|
||||||
|
|
||||||
|
# Toggle circle mode off
|
||||||
|
roi_tree.add_circle_action.action.toggle()
|
||||||
|
assert roi_tree._roi_draw_mode is None
|
||||||
|
assert not roi_tree.add_rect_action.action.isChecked()
|
||||||
|
assert not roi_tree.add_circle_action.action.isChecked()
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_roi_from_toolbar(qtbot, mocked_client):
|
||||||
|
"""Test creating ROIs using the toolbar and mouse interactions."""
|
||||||
|
# Create Image widget with ROI tree
|
||||||
|
widget = create_widget(qtbot, Image, client=mocked_client)
|
||||||
|
data = np.zeros((100, 100), dtype=float)
|
||||||
|
widget.main_image.set_data(data)
|
||||||
|
qtbot.waitExposed(widget)
|
||||||
|
|
||||||
|
roi_tree = create_widget(qtbot, ROIPropertyTree, image_widget=widget)
|
||||||
|
|
||||||
|
# Get initial ROI count
|
||||||
|
initial_roi_count = len(widget.roi_controller.rois)
|
||||||
|
|
||||||
|
# Test rectangle ROI creation
|
||||||
|
# 1. Activate rectangle drawing mode
|
||||||
|
roi_tree.add_rect_action.action.setChecked(True)
|
||||||
|
assert roi_tree._roi_draw_mode == "rect"
|
||||||
|
|
||||||
|
# Get plot widget and view
|
||||||
|
plot_item = widget.plot_item
|
||||||
|
view = plot_item.vb.scene().views()[0]
|
||||||
|
qtbot.waitExposed(view)
|
||||||
|
|
||||||
|
# Define start and end points for the ROI (in view coordinates)
|
||||||
|
start_pos = QPointF(20, 20)
|
||||||
|
end_pos = QPointF(60, 60)
|
||||||
|
|
||||||
|
# Map view coordinates to scene coordinates
|
||||||
|
start_pos_scene = plot_item.vb.mapViewToScene(start_pos)
|
||||||
|
end_pos_scene = plot_item.vb.mapViewToScene(end_pos)
|
||||||
|
|
||||||
|
# Map scene coordinates to widget coordinates
|
||||||
|
start_pos_widget = view.mapFromScene(start_pos_scene)
|
||||||
|
end_pos_widget = view.mapFromScene(end_pos_scene)
|
||||||
|
|
||||||
|
# Using qtbot to simulate mouse actions
|
||||||
|
# First click to start drawing
|
||||||
|
qtbot.mousePress(view.viewport(), Qt.LeftButton, pos=start_pos_widget)
|
||||||
|
|
||||||
|
# Then move to end position
|
||||||
|
qtbot.mouseMove(view.viewport(), pos=end_pos_widget)
|
||||||
|
|
||||||
|
# Finally release to complete the ROI
|
||||||
|
qtbot.mouseRelease(view.viewport(), Qt.LeftButton, pos=end_pos_widget)
|
||||||
|
|
||||||
|
# Wait for signals to process
|
||||||
|
qtbot.wait(200)
|
||||||
|
|
||||||
|
# Check that a new ROI was created
|
||||||
|
assert len(widget.roi_controller.rois) == initial_roi_count + 1
|
||||||
|
|
||||||
|
# Get the newly created ROI
|
||||||
|
new_roi = widget.roi_controller.rois[-1]
|
||||||
|
|
||||||
|
# Verify it's a rectangular ROI
|
||||||
|
assert isinstance(new_roi, RectangularROI)
|
||||||
|
|
||||||
|
# Test circle ROI creation
|
||||||
|
# Reset ROI draw mode
|
||||||
|
roi_tree.add_rect_action.action.setChecked(False)
|
||||||
|
roi_tree.add_circle_action.action.setChecked(True)
|
||||||
|
assert roi_tree._roi_draw_mode == "circle"
|
||||||
|
|
||||||
|
# Define new positions for circle ROI
|
||||||
|
start_pos = QPointF(30, 30)
|
||||||
|
end_pos = QPointF(50, 50)
|
||||||
|
|
||||||
|
# Map view coordinates to scene coordinates
|
||||||
|
start_pos_scene = plot_item.vb.mapViewToScene(start_pos)
|
||||||
|
end_pos_scene = plot_item.vb.mapViewToScene(end_pos)
|
||||||
|
|
||||||
|
# Map scene coordinates to widget coordinates
|
||||||
|
start_pos_widget = view.mapFromScene(start_pos_scene)
|
||||||
|
end_pos_widget = view.mapFromScene(end_pos_scene)
|
||||||
|
|
||||||
|
# Using qtbot to simulate mouse actions
|
||||||
|
# First click to start drawing
|
||||||
|
qtbot.mousePress(view.viewport(), Qt.LeftButton, pos=start_pos_widget)
|
||||||
|
|
||||||
|
# Then move to end position
|
||||||
|
qtbot.mouseMove(view.viewport(), pos=end_pos_widget)
|
||||||
|
|
||||||
|
# Finally release to complete the ROI
|
||||||
|
qtbot.mouseRelease(view.viewport(), Qt.LeftButton, pos=end_pos_widget)
|
||||||
|
|
||||||
|
# Wait for signals to process
|
||||||
|
qtbot.wait(200)
|
||||||
|
|
||||||
|
# Check that a new ROI was created
|
||||||
|
assert len(widget.roi_controller.rois) == initial_roi_count + 2
|
||||||
|
|
||||||
|
# Get the newly created ROI
|
||||||
|
new_roi = widget.roi_controller.rois[-1]
|
||||||
|
|
||||||
|
# Verify it's a circle ROI
|
||||||
|
assert isinstance(new_roi, CircularROI)
|
@ -36,7 +36,7 @@ def test_default_properties(bec_image_widget_with_roi):
|
|||||||
|
|
||||||
assert roi.label.startswith("ROI")
|
assert roi.label.startswith("ROI")
|
||||||
|
|
||||||
assert roi.line_width == 10
|
assert roi.line_width == 5
|
||||||
|
|
||||||
# concrete subclass type
|
# concrete subclass type
|
||||||
assert isinstance(roi, RectangularROI) if roi_type == "rect" else isinstance(roi, CircularROI)
|
assert isinstance(roi, RectangularROI) if roi_type == "rect" else isinstance(roi, CircularROI)
|
||||||
|
@ -386,3 +386,31 @@ def test_roi_get_data_from_image_with_no_image(qtbot, mocked_client):
|
|||||||
|
|
||||||
with pytest.raises(RuntimeError):
|
with pytest.raises(RuntimeError):
|
||||||
roi.get_data_from_image()
|
roi.get_data_from_image()
|
||||||
|
|
||||||
|
|
||||||
|
##################################################
|
||||||
|
# Settings and popups
|
||||||
|
##################################################
|
||||||
|
def test_show_roi_manager_popup(qtbot, mocked_client):
|
||||||
|
"""
|
||||||
|
Verify that the ROI-manager dialog opens and closes correctly,
|
||||||
|
and that the matching toolbar icon stays in sync.
|
||||||
|
"""
|
||||||
|
view = create_widget(qtbot, Image, client=mocked_client, popups=True)
|
||||||
|
|
||||||
|
# ROI-manager toggle is exposed via the toolbar.
|
||||||
|
assert "roi_mgr" in view.toolbar.widgets
|
||||||
|
roi_action = view.toolbar.widgets["roi_mgr"].action
|
||||||
|
assert roi_action.isChecked() is False, "Should start unchecked"
|
||||||
|
|
||||||
|
# Open the popup.
|
||||||
|
view.show_roi_manager_popup()
|
||||||
|
|
||||||
|
assert view.roi_manager_dialog is not None
|
||||||
|
assert view.roi_manager_dialog.isVisible()
|
||||||
|
assert roi_action.isChecked() is True, "Icon should toggle on"
|
||||||
|
|
||||||
|
# Close again.
|
||||||
|
view.roi_manager_dialog.close()
|
||||||
|
assert view.roi_manager_dialog is None
|
||||||
|
assert roi_action.isChecked() is False, "Icon should toggle off"
|
||||||
|
Reference in New Issue
Block a user