mirror of
https://github.com/bec-project/bec_widgets.git
synced 2025-07-13 19:21:50 +02:00
feat(image_roi_tree): gui roi manager for image widget
This commit is contained in:
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.line_width == 10
|
||||
assert roi.line_width == 5
|
||||
|
||||
# concrete subclass type
|
||||
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):
|
||||
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