0
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2025-07-13 19:21:50 +02:00

feat(image_rois): image rois with RPC can be added to Image widget

This commit is contained in:
2025-05-16 15:48:31 +02:00
committed by Jan Wyzula
parent 6ee0f5004d
commit 1d018e863c
7 changed files with 1549 additions and 8 deletions

View File

@ -0,0 +1,192 @@
from __future__ import annotations
from typing import Literal
import numpy as np
import pyqtgraph as pg
import pytest
from qtpy.QtCore import QPointF
from bec_widgets.widgets.plots.image.image import Image
from bec_widgets.widgets.plots.roi.image_roi import CircularROI, RectangularROI, ROIController
from tests.unit_tests.client_mocks import mocked_client
from tests.unit_tests.conftest import create_widget
@pytest.fixture(params=["rect", "circle"])
def bec_image_widget_with_roi(qtbot, request, mocked_client):
"""Return (widget, roi, shape_label) for each ROI class."""
roi_type: Literal["rect", "circle"] = request.param
# Build an Image widget with a trivial 100×100 zeros array
widget: Image = create_widget(qtbot, Image, client=mocked_client)
data = np.zeros((100, 100), dtype=float)
data[20:40, 20:40] = 5 # content assertion for roi to check
widget.main_image.set_data(data)
# Add a single ROI via the public API
roi = widget.add_roi(kind=roi_type)
yield widget, roi, roi_type
def test_default_properties(bec_image_widget_with_roi):
"""Label, width, type sanitycheck."""
_widget, roi, roi_type = bec_image_widget_with_roi
assert roi.label.startswith("ROI")
assert roi.line_width == 10
# concrete subclass type
assert isinstance(roi, RectangularROI) if roi_type == "rect" else isinstance(roi, CircularROI)
def test_coordinate_structures(bec_image_widget_with_roi):
"""Typed vs untyped coordinate structures are consistent."""
_widget, roi, _ = bec_image_widget_with_roi
raw = roi.get_coordinates(typed=False)
typed = roi.get_coordinates(typed=True)
# untyped is always a tuple
assert isinstance(raw, tuple)
# typed is always a dict and has same number of scalars as raw flattens to
assert isinstance(typed, dict)
assert sum(isinstance(v, (tuple, list)) and len(v) or 1 for v in typed.values()) == len(
np.ravel(raw)
)
def test_data_extraction_matches_coordinates(bec_image_widget_with_roi):
"""Pixels reported by get_data_from_image have nonzero size and match ROI extents."""
widget, roi, _ = bec_image_widget_with_roi
pixels = roi.get_data_from_image() # autodetect ImageItem
assert pixels.size > 0 # ROI covers at least one pixel
# For rectangular ROI: pixel bounding box equals coordinate bbox
if isinstance(roi, RectangularROI):
(x0, y0), (_, _), (_, _), (x1, y1) = roi.get_coordinates(typed=False)
# ensure ints inside image shape
x0, y0, x1, y1 = map(int, (x0, y0, x1, y1))
expected = widget.main_image.image[y0:y1, x0:x1]
assert pixels.shape == expected.shape
@pytest.mark.parametrize("index", [0])
def test_controller_remove_by_index(bec_image_widget_with_roi, index):
"""Image.remove_roi(index) removes the graphics item and updates controller."""
widget, roi, _ = bec_image_widget_with_roi
controller: ROIController = widget.roi_controller
assert controller.rois # nonempty before
widget.remove_roi(index)
# ROI list now empty and item no longer in scene
assert not controller.rois
assert roi not in widget.plot_item.items
def test_color_uniqueness_across_multiple_rois(qtbot, mocked_client):
widget: Image = create_widget(qtbot, Image, client=mocked_client)
# add two of each ROI type
for _kind in ("rect", "circle"):
widget.add_roi(kind=_kind)
widget.add_roi(kind=_kind)
colors = [r.line_color for r in widget.roi_controller.rois]
assert len(colors) == len(set(colors)), "Colors must be unique per ROI"
def test_roi_label_and_signals(bec_image_widget_with_roi):
widget, roi, _ = bec_image_widget_with_roi
changed = []
roi.nameChanged.connect(lambda name: changed.append(name))
roi.label = "new_label"
assert roi.label == "new_label"
assert changed and changed[0] == "new_label"
def test_roi_line_color_and_width(bec_image_widget_with_roi):
_widget, roi, _ = bec_image_widget_with_roi
changed = []
roi.penChanged.connect(lambda: changed.append(True))
roi.line_color = "#123456"
assert roi.line_color == "#123456"
roi.line_width = 5
assert roi.line_width == 5
assert changed # penChanged should have been emitted
def test_roi_controller_add_remove_multiple(qtbot, mocked_client):
widget = create_widget(qtbot, Image, client=mocked_client)
controller = widget.roi_controller
r1 = widget.add_roi(kind="rect", name="r1")
r2 = widget.add_roi(kind="circle", name="c1")
assert r1 in controller.rois and r2 in controller.rois
widget.remove_roi("r1")
assert r1 not in controller.rois and r2 in controller.rois
widget.remove_roi("c1")
assert not controller.rois
def test_roi_controller_colormap_changes(qtbot, mocked_client):
widget = create_widget(qtbot, Image, client=mocked_client)
controller = widget.roi_controller
widget.add_roi(kind="rect")
widget.add_roi(kind="circle")
old_colors = [r.line_color for r in controller.rois]
controller.colormap = "plasma"
new_colors = [r.line_color for r in controller.rois]
assert old_colors != new_colors
assert all(isinstance(c, str) for c in new_colors)
def test_roi_controller_clear(qtbot, mocked_client):
widget = create_widget(qtbot, Image, client=mocked_client)
widget.add_roi(kind="rect")
widget.add_roi(kind="circle")
controller = widget.roi_controller
controller.clear()
assert not controller.rois
def test_roi_get_data_from_image_no_image(qtbot, mocked_client):
widget = create_widget(qtbot, Image, client=mocked_client)
roi = widget.add_roi(kind="rect")
# Remove all images from scene
for item in list(widget.plot_item.items):
if hasattr(item, "image"):
widget.plot_item.removeItem(item)
import pytest
with pytest.raises(RuntimeError):
roi.get_data_from_image()
def test_roi_remove_cleans_up(bec_image_widget_with_roi):
widget, roi, _ = bec_image_widget_with_roi
roi.remove()
assert roi not in widget.roi_controller.rois
assert roi not in widget.plot_item.items
def test_roi_controller_get_roi_methods(qtbot, mocked_client):
widget = create_widget(qtbot, Image, client=mocked_client)
r1 = widget.add_roi(kind="rect", name="findme")
r2 = widget.add_roi(kind="circle")
controller = widget.roi_controller
assert controller.get_roi_by_name("findme") == r1
assert controller.get_roi(1) == r2
assert controller.get_roi(99) is None
assert controller.get_roi_by_name("notfound") is None

View File

@ -329,3 +329,60 @@ def test_image_toggle_action_reset(qtbot, mocked_client):
assert bec_image_view.main_image.log is False
assert bec_image_view.transpose is False
assert bec_image_view.main_image.transpose is False
def test_roi_add_remove_and_properties(qtbot, mocked_client):
view = create_widget(qtbot, Image, client=mocked_client)
# Add ROIs
rect = view.add_roi(kind="rect", name="rect_roi", line_width=7)
circ = view.add_roi(kind="circle", name="circ_roi", line_width=5)
assert rect in view.roi_controller.rois
assert circ in view.roi_controller.rois
assert rect.label == "rect_roi"
assert circ.label == "circ_roi"
assert rect.line_width == 7
assert circ.line_width == 5
# Change properties
rect.label = "rect_roi2"
circ.line_color = "#ff0000"
assert rect.label == "rect_roi2"
assert circ.line_color == "#ff0000"
# Remove by name
view.remove_roi("rect_roi2")
assert rect not in view.roi_controller.rois
# Remove by index
view.remove_roi(0)
assert not view.roi_controller.rois
def test_roi_controller_palette_signal(qtbot, mocked_client):
view = create_widget(qtbot, Image, client=mocked_client)
controller = view.roi_controller
changed = []
controller.paletteChanged.connect(lambda cmap: changed.append(cmap))
view.add_roi(kind="rect")
controller.colormap = "plasma"
assert changed and changed[0] == "plasma"
def test_roi_controller_clear_and_get_methods(qtbot, mocked_client):
view = create_widget(qtbot, Image, client=mocked_client)
r1 = view.add_roi(kind="rect", name="r1")
r2 = view.add_roi(kind="circle", name="c1")
controller = view.roi_controller
assert controller.get_roi_by_name("r1") == r1
assert controller.get_roi(1) == r2
controller.clear()
assert not controller.rois
def test_roi_get_data_from_image_with_no_image(qtbot, mocked_client):
view = create_widget(qtbot, Image, client=mocked_client)
roi = view.add_roi(kind="rect")
# Remove all images from scene
for item in list(view.plot_item.items):
if hasattr(item, "image"):
view.plot_item.removeItem(item)
with pytest.raises(RuntimeError):
roi.get_data_from_image()