diff --git a/bec_widgets/cli/client.py b/bec_widgets/cli/client.py index d9cc8d48..6457a18f 100644 --- a/bec_widgets/cli/client.py +++ b/bec_widgets/cli/client.py @@ -1438,7 +1438,7 @@ class Image(RPCBase): self, kind: "Literal['rect', 'circle']" = "rect", name: "str | None" = None, - line_width: "int | None" = 10, + line_width: "int | None" = 5, pos: "tuple[float, float] | None" = (10, 10), size: "tuple[float, float] | None" = (50, 50), **pg_kwargs, diff --git a/bec_widgets/examples/jupyter_console/jupyter_console_window.py b/bec_widgets/examples/jupyter_console/jupyter_console_window.py index c2cfa5a3..0e9037f6 100644 --- a/bec_widgets/examples/jupyter_console/jupyter_console_window.py +++ b/bec_widgets/examples/jupyter_console/jupyter_console_window.py @@ -114,7 +114,7 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover: # sixth_tab = QWidget() sixth_tab_layout = QVBoxLayout(sixth_tab) - self.im = Image(popups=False) + self.im = Image(popups=True) self.mi = self.im.main_image sixth_tab_layout.addWidget(self.im) tab_widget.addTab(sixth_tab, "Image Next Gen") diff --git a/bec_widgets/widgets/plots/image/image.py b/bec_widgets/widgets/plots/image/image.py index eeff43a9..cad08e12 100644 --- a/bec_widgets/widgets/plots/image/image.py +++ b/bec_widgets/widgets/plots/image/image.py @@ -8,13 +8,14 @@ from bec_lib import bec_logger from bec_lib.endpoints import MessageEndpoints from pydantic import Field, ValidationError, field_validator 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.colors import Colors from bec_widgets.utils.error_popups import SafeProperty, SafeSlot from bec_widgets.utils.toolbar import MaterialIconAction, SwitchableToolBarAction 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 ( MonitorSelectionToolbarBundle, ) @@ -149,8 +150,7 @@ class Image(PlotBase): # Default Color map to 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 @@ -266,6 +266,55 @@ class Image(PlotBase): 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( self, enabled: bool, @@ -324,7 +373,7 @@ class Image(PlotBase): self, kind: Literal["rect", "circle"] = "rect", name: str | None = None, - line_width: int | None = 10, + line_width: int | None = 5, pos: tuple[float, float] | None = (10, 10), size: tuple[float, float] | None = (50, 50), **pg_kwargs, @@ -1032,6 +1081,11 @@ class Image(PlotBase): self._color_bar.deleteLater() 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 self.toolbar.widgets["monitor"].widget.close() self.toolbar.widgets["monitor"].widget.deleteLater() @@ -1042,10 +1096,19 @@ class Image(PlotBase): if __name__ == "__main__": # pragma: no cover import sys - from qtpy.QtWidgets import QApplication + from qtpy.QtWidgets import QApplication, QHBoxLayout app = QApplication(sys.argv) - widget = Image(popups=True) - widget.show() - widget.resize(1000, 800) + win = QWidget() + win.setWindowTitle("Image Demo") + 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_()) diff --git a/bec_widgets/widgets/plots/image/setting_widgets/__init__.py b/bec_widgets/widgets/plots/image/setting_widgets/__init__.py new file mode 100644 index 00000000..e69de29b 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 new file mode 100644 index 00000000..ea01af09 --- /dev/null +++ b/bec_widgets/widgets/plots/image/setting_widgets/image_roi_tree.py @@ -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_()) diff --git a/bec_widgets/widgets/plots/roi/image_roi.py b/bec_widgets/widgets/plots/roi/image_roi.py index 388e8dd1..ae177731 100644 --- a/bec_widgets/widgets/plots/roi/image_roi.py +++ b/bec_widgets/widgets/plots/roi/image_roi.py @@ -126,7 +126,7 @@ class BaseROI(BECConnector): # ROI-specific label: str | None = None, line_color: str | None = None, - line_width: int = 10, + line_width: int = 5, # all remaining pg.*ROI kwargs (pos, size, pen, …) **pg_kwargs, ): @@ -345,6 +345,11 @@ class BaseROI(BECConnector): self.setPos(x, y) 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 for i in range(len(handles)): try: @@ -353,9 +358,8 @@ class BaseROI(BECConnector): continue self.rpc_register.remove_rpc(self) self.parent_image.plot_item.removeItem(self) - if hasattr(self.parent_image, "roi_controller"): - self.parent_image.roi_controller._rois.remove(self) - self.parent_image.roi_controller._rebuild_color_buffer() + viewBox = self.parent_plot_item.vb + viewBox.update() class RectangularROI(BaseROI, pg.RectROI): @@ -389,7 +393,7 @@ class RectangularROI(BaseROI, pg.RectROI): # ROI specifics label: str | None = None, line_color: str | None = None, - line_width: int = 10, + line_width: int = 5, resize_handles: bool = True, **extra_pg, ): @@ -558,7 +562,7 @@ class CircularROI(BaseROI, pg.CircleROI): parent_image: Image | None = None, label: str | None = None, line_color: str | None = None, - line_width: int = 10, + line_width: int = 5, **extra_pg, ): """ @@ -739,7 +743,7 @@ class ROIController(QObject): roi.line_color = color # ensure line width default is at least 3 if not previously set if getattr(roi, "line_width", 0) < 1: - roi.line_width = 10 + roi.line_width = 5 self.roiAdded.emit(roi) def remove_roi(self, roi: BaseROI): @@ -752,8 +756,12 @@ class ROIController(QObject): Args: roi (BaseROI): The ROI instance to remove. """ - rois = self._rois - if roi not in rois: + if roi in self._rois: + self.roiRemoved.emit(roi) + self._rois.remove(roi) + roi.remove() + self._rebuild_color_buffer() + else: roi.remove() def get_roi(self, index: int) -> BaseROI | None: @@ -796,7 +804,7 @@ class ROIController(QObject): """ roi = self.get_roi(index) if roi is not None: - roi.remove() + self.remove_roi(roi) def remove_roi_by_name(self, name: str): """ @@ -807,7 +815,7 @@ class ROIController(QObject): """ roi = self.get_roi_by_name(name) if roi is not None: - roi.remove() + self.remove_roi(roi) def clear(self): """ @@ -817,7 +825,7 @@ class ROIController(QObject): the cleared signal to notify listeners that all ROIs have been removed. """ for roi in list(self._rois): - roi.remove() + self.remove_roi(roi) self.cleared.emit() def renormalize_colors(self): diff --git a/tests/unit_tests/test_image_roi_tree.py b/tests/unit_tests/test_image_roi_tree.py new file mode 100644 index 00000000..6a6f6a7d --- /dev/null +++ b/tests/unit_tests/test_image_roi_tree.py @@ -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) diff --git a/tests/unit_tests/test_image_rois.py b/tests/unit_tests/test_image_rois.py index df8c9e0b..646a8a9d 100644 --- a/tests/unit_tests/test_image_rois.py +++ b/tests/unit_tests/test_image_rois.py @@ -36,7 +36,7 @@ def test_default_properties(bec_image_widget_with_roi): assert roi.label.startswith("ROI") - assert roi.line_width == 10 + assert roi.line_width == 5 # concrete subclass type assert isinstance(roi, RectangularROI) if roi_type == "rect" else isinstance(roi, CircularROI) diff --git a/tests/unit_tests/test_image_view_next_gen.py b/tests/unit_tests/test_image_view_next_gen.py index 758825dd..7bf9a70d 100644 --- a/tests/unit_tests/test_image_view_next_gen.py +++ b/tests/unit_tests/test_image_view_next_gen.py @@ -386,3 +386,31 @@ def test_roi_get_data_from_image_with_no_image(qtbot, mocked_client): with pytest.raises(RuntimeError): 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"