0
0
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:
2025-05-20 16:39:04 +02:00
committed by Jan Wyzula
parent 41b7ca8e64
commit a939c3b1c4
9 changed files with 830 additions and 23 deletions

View File

@ -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,

View File

@ -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")

View File

@ -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_())

View File

@ -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_())

View File

@ -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):

View 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)

View File

@ -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)

View File

@ -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"