0
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2025-07-14 03:31:50 +02:00

feat(image): roi plots with crosshair cuts added

This commit is contained in:
2025-05-01 16:56:23 +02:00
committed by Jan Wyzula
parent e12e9e534d
commit ce88787e88
4 changed files with 289 additions and 23 deletions

View File

@ -85,7 +85,8 @@ class Crosshair(QObject):
self.items = [] self.items = []
self.marker_moved_1d = {} self.marker_moved_1d = {}
self.marker_clicked_1d = {} self.marker_clicked_1d = {}
self.marker_2d = None self.marker_2d_row = None
self.marker_2d_col = None
self.update_markers() self.update_markers()
self.check_log() self.check_log()
self.check_derivatives() self.check_derivatives()
@ -195,13 +196,23 @@ class Crosshair(QObject):
marker_clicked_list.append(marker_clicked) marker_clicked_list.append(marker_clicked)
self.marker_clicked_1d[name] = marker_clicked_list self.marker_clicked_1d[name] = marker_clicked_list
elif isinstance(item, pg.ImageItem): # 2D plot elif isinstance(item, pg.ImageItem): # 2D plot
if self.marker_2d is not None: if self.marker_2d_row is not None and self.marker_2d_col is not None:
continue continue
self.marker_2d = pg.ROI( # Create horizontal ROI for row highlighting
[0, 0], size=[1, 1], pen=pg.mkPen("r", width=2), movable=False if item.image is None:
continue
self.marker_2d_row = pg.ROI(
[0, 0], size=[item.image.shape[0], 1], pen=pg.mkPen("r", width=2), movable=False
) )
self.marker_2d.skip_auto_range = True self.marker_2d_row.skip_auto_range = True
self.plot_item.addItem(self.marker_2d) self.plot_item.addItem(self.marker_2d_row)
# Create vertical ROI for column highlighting
self.marker_2d_col = pg.ROI(
[0, 0], size=[1, item.image.shape[1]], pen=pg.mkPen("r", width=2), movable=False
)
self.marker_2d_col.skip_auto_range = True
self.plot_item.addItem(self.marker_2d_col)
def snap_to_data( def snap_to_data(
self, x: float, y: float self, x: float, y: float
@ -243,6 +254,8 @@ class Crosshair(QObject):
elif isinstance(item, pg.ImageItem): # 2D plot elif isinstance(item, pg.ImageItem): # 2D plot
name = item.config.monitor or str(id(item)) name = item.config.monitor or str(id(item))
image_2d = item.image image_2d = item.image
if image_2d is None:
continue
# Clip the x and y values to the image dimensions to avoid out of bounds errors # Clip the x and y values to the image dimensions to avoid out of bounds errors
y_values[name] = int(np.clip(y, 0, image_2d.shape[1] - 1)) y_values[name] = int(np.clip(y, 0, image_2d.shape[1] - 1))
x_values[name] = int(np.clip(x, 0, image_2d.shape[0] - 1)) x_values[name] = int(np.clip(x, 0, image_2d.shape[0] - 1))
@ -330,7 +343,10 @@ class Crosshair(QObject):
x, y = x_snap_values[name], y_snap_values[name] x, y = x_snap_values[name], y_snap_values[name]
if x is None or y is None: if x is None or y is None:
continue continue
self.marker_2d.setPos([x, y]) # Set position of horizontal ROI (row)
self.marker_2d_row.setPos([0, y])
# Set position of vertical ROI (column)
self.marker_2d_col.setPos([x, 0])
coordinate_to_emit = (name, x, y) coordinate_to_emit = (name, x, y)
self.coordinatesChanged2D.emit(coordinate_to_emit) self.coordinatesChanged2D.emit(coordinate_to_emit)
else: else:
@ -384,7 +400,10 @@ class Crosshair(QObject):
x, y = x_snap_values[name], y_snap_values[name] x, y = x_snap_values[name], y_snap_values[name]
if x is None or y is None: if x is None or y is None:
continue continue
self.marker_2d.setPos([x, y]) # Set position of horizontal ROI (row)
self.marker_2d_row.setPos([0, y])
# Set position of vertical ROI (column)
self.marker_2d_col.setPos([x, 0])
coordinate_to_emit = (name, x, y) coordinate_to_emit = (name, x, y)
self.coordinatesClicked2D.emit(coordinate_to_emit) self.coordinatesClicked2D.emit(coordinate_to_emit)
else: else:
@ -428,6 +447,8 @@ class Crosshair(QObject):
for item in self.items: for item in self.items:
if isinstance(item, pg.ImageItem): if isinstance(item, pg.ImageItem):
image = item.image image = item.image
if image is None:
continue
ix = int(np.clip(x, 0, image.shape[0] - 1)) ix = int(np.clip(x, 0, image.shape[0] - 1))
iy = int(np.clip(y, 0, image.shape[1] - 1)) iy = int(np.clip(y, 0, image.shape[1] - 1))
intensity = image[ix, iy] intensity = image[ix, iy]
@ -450,9 +471,12 @@ class Crosshair(QObject):
self.clear_markers() self.clear_markers()
def cleanup(self): def cleanup(self):
if self.marker_2d is not None: if self.marker_2d_row is not None:
self.plot_item.removeItem(self.marker_2d) self.plot_item.removeItem(self.marker_2d_row)
self.marker_2d = None self.marker_2d_row = None
if self.marker_2d_col is not None:
self.plot_item.removeItem(self.marker_2d_col)
self.marker_2d_col = None
self.plot_item.removeItem(self.v_line) self.plot_item.removeItem(self.v_line)
self.plot_item.removeItem(self.h_line) self.plot_item.removeItem(self.h_line)
self.plot_item.removeItem(self.coord_label) self.plot_item.removeItem(self.coord_label)

View File

@ -13,8 +13,10 @@ 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.side_panel import SidePanel
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.image_roi_plot import ImageROIPlot
from bec_widgets.widgets.plots.image.setting_widgets.image_roi_tree import ROIPropertyTree 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,
@ -123,6 +125,7 @@ class Image(PlotBase):
"rois", "rois",
] ]
sync_colorbar_with_autorange = Signal() sync_colorbar_with_autorange = Signal()
image_updated = Signal()
def __init__( def __init__(
self, self,
@ -139,6 +142,8 @@ class Image(PlotBase):
self._color_bar = None self._color_bar = None
self._main_image = ImageItem() self._main_image = ImageItem()
self.roi_controller = ROIController(colormap="viridis") self.roi_controller = ROIController(colormap="viridis")
self.x_roi = None
self.y_roi = None
super().__init__( super().__init__(
parent=parent, config=config, client=client, gui_id=gui_id, popups=popups, **kwargs parent=parent, config=config, client=client, gui_id=gui_id, popups=popups, **kwargs
) )
@ -150,24 +155,60 @@ class Image(PlotBase):
# Default Color map to plasma # Default Color map to plasma
self.color_map = "plasma" self.color_map = "plasma"
# Initialize ROI plots and side panels
self._add_roi_plots()
self.roi_manager_dialog = None self.roi_manager_dialog = None
# Refresh theme for ROI plots
self._update_theme()
################################################################################ ################################################################################
# Widget Specific GUI interactions # Widget Specific GUI interactions
################################################################################ ################################################################################
def apply_theme(self, theme: str):
super().apply_theme(theme)
if self.x_roi is not None and self.y_roi is not None:
self.x_roi.apply_theme(theme)
self.y_roi.apply_theme(theme)
def _init_toolbar(self): def _init_toolbar(self):
# add to the first position # add to the first position
self.selection_bundle = MonitorSelectionToolbarBundle( self.selection_bundle = MonitorSelectionToolbarBundle(
bundle_id="selection", target_widget=self bundle_id="selection", target_widget=self
) )
self.toolbar.add_bundle(self.selection_bundle, self) self.toolbar.add_bundle(bundle=self.selection_bundle, target_widget=self)
super()._init_toolbar() super()._init_toolbar()
# Image specific changes to PlotBase toolbar # Image specific changes to PlotBase toolbar
self.toolbar.widgets["reset_legend"].action.setVisible(False) self.toolbar.widgets["reset_legend"].action.setVisible(False)
# ROI Bundle replacement with switchable crosshair
self.toolbar.remove_bundle("roi")
crosshair = MaterialIconAction(
icon_name="point_scan", tooltip="Show Crosshair", checkable=True
)
crosshair_roi = MaterialIconAction(
icon_name="my_location",
tooltip="Show Crosshair with ROI plots",
checkable=True,
parent=self,
)
crosshair_roi.action.toggled.connect(self.toggle_roi_panels)
crosshair.action.toggled.connect(self.toggle_crosshair)
switch_crosshair = SwitchableToolBarAction(
actions={"crosshair_simple": crosshair, "crosshair_roi": crosshair_roi},
initial_action="crosshair_simple",
tooltip="Crosshair",
checkable=True,
parent=self,
)
self.toolbar.add_action(
action_id="switch_crosshair", action=switch_crosshair, target_widget=self
)
# Lock aspect ratio button # Lock aspect ratio button
self.lock_aspect_ratio_action = MaterialIconAction( self.lock_aspect_ratio_action = MaterialIconAction(
icon_name="aspect_ratio", tooltip="Lock Aspect Ratio", checkable=True, parent=self icon_name="aspect_ratio", tooltip="Lock Aspect Ratio", checkable=True, parent=self
@ -216,11 +257,8 @@ class Image(PlotBase):
parent=self, parent=self,
) )
self.toolbar.add_action_to_bundle( self.toolbar.add_action(
bundle_id="roi", action_id="autorange_image", action=self.autorange_switch, target_widget=self
action_id="autorange_image",
action=self.autorange_switch,
target_widget=self,
) )
self.autorange_mean_action.action.toggled.connect( self.autorange_mean_action.action.toggled.connect(
@ -252,11 +290,8 @@ class Image(PlotBase):
parent=self, parent=self,
) )
self.toolbar.add_action_to_bundle( self.toolbar.add_action(
bundle_id="roi", action_id="switch_colorbar", action=self.colorbar_switch, target_widget=self
action_id="switch_colorbar",
action=self.colorbar_switch,
target_widget=self,
) )
self.simple_colorbar_action.action.toggled.connect( self.simple_colorbar_action.action.toggled.connect(
@ -430,6 +465,101 @@ class Image(PlotBase):
else: else:
raise ValueError("roi must be an int index or str name") raise ValueError("roi must be an int index or str name")
def _add_roi_plots(self):
"""
Initialize the ROI plots and side panels.
"""
# Create ROI plot widgets
self.x_roi = ImageROIPlot(parent=self)
self.y_roi = ImageROIPlot(parent=self)
self.x_roi.apply_theme("dark")
self.y_roi.apply_theme("dark")
# Set titles for the plots
self.x_roi.plot_item.setTitle("X ROI")
self.y_roi.plot_item.setTitle("Y ROI")
# Create side panels
self.side_panel_x = SidePanel(
parent=self, orientation="bottom", panel_max_width=200, show_toolbar=False
)
self.side_panel_y = SidePanel(
parent=self, orientation="left", panel_max_width=200, show_toolbar=False
)
# Add ROI plots to side panels
self.x_panel_index = self.side_panel_x.add_menu(widget=self.x_roi)
self.y_panel_index = self.side_panel_y.add_menu(widget=self.y_roi)
# # Add side panels to the layout
self.layout_manager.add_widget_relative(
self.side_panel_x, self.round_plot_widget, position="bottom", shift_direction="down"
)
self.layout_manager.add_widget_relative(
self.side_panel_y, self.round_plot_widget, position="left", shift_direction="right"
)
def toggle_roi_panels(self, checked: bool):
"""
Show or hide the ROI panels based on the test action toggle state.
Args:
checked (bool): Whether the test action is checked.
"""
if checked:
# Show the ROI panels
self.hook_crosshair()
self.side_panel_x.show_panel(self.x_panel_index)
self.side_panel_y.show_panel(self.y_panel_index)
self.crosshair.coordinatesChanged2D.connect(self.update_image_slices)
self.image_updated.connect(self.update_image_slices)
else:
self.unhook_crosshair()
# Hide the ROI panels
self.side_panel_x.hide_panel()
self.side_panel_y.hide_panel()
self.image_updated.disconnect(self.update_image_slices)
@SafeSlot()
def update_image_slices(self, coordinates: tuple[int, int, int] = None):
"""
Update the image slices based on the crosshair position.
Args:
coordinates(tuple): The coordinates of the crosshair.
"""
if coordinates is None:
# Try to get coordinates from crosshair position (like in crosshair mouse_moved)
if (
hasattr(self, "crosshair")
and hasattr(self.crosshair, "v_line")
and hasattr(self.crosshair, "h_line")
):
x = int(round(self.crosshair.v_line.value()))
y = int(round(self.crosshair.h_line.value()))
else:
return
else:
x = coordinates[1]
y = coordinates[2]
image = self._main_image.image
if image is None:
return
max_row, max_col = image.shape[0] - 1, image.shape[1] - 1
row, col = x, y
if not (0 <= row <= max_row and 0 <= col <= max_col):
return
# Horizontal slice
h_slice = image[:, col]
x_axis = np.arange(h_slice.shape[0])
self.x_roi.plot_item.clear()
self.x_roi.plot_item.plot(x_axis, h_slice, pen=pg.mkPen(self.x_roi.curve_color, width=3))
# Vertical slice
v_slice = image[row, :]
y_axis = np.arange(v_slice.shape[0])
self.y_roi.plot_item.clear()
self.y_roi.plot_item.plot(v_slice, y_axis, pen=pg.mkPen(self.y_roi.curve_color, width=3))
################################################################################ ################################################################################
# Widget Specific Properties # Widget Specific Properties
################################################################################ ################################################################################
@ -984,6 +1114,7 @@ class Image(PlotBase):
self._main_image.set_data(image_buffer) self._main_image.set_data(image_buffer)
if self._color_bar is not None: if self._color_bar is not None:
self._color_bar.blockSignals(False) self._color_bar.blockSignals(False)
self.image_updated.emit()
def adjust_image_buffer(self, image: ImageItem, new_data: np.ndarray) -> np.ndarray: def adjust_image_buffer(self, image: ImageItem, new_data: np.ndarray) -> np.ndarray:
""" """
@ -1035,6 +1166,7 @@ class Image(PlotBase):
self._main_image.set_data(data) self._main_image.set_data(data)
if self._color_bar is not None: if self._color_bar is not None:
self._color_bar.blockSignals(False) self._color_bar.blockSignals(False)
self.image_updated.emit()
################################################################################ ################################################################################
# Clean up # Clean up
@ -1090,6 +1222,10 @@ class Image(PlotBase):
self.toolbar.widgets["monitor"].widget.close() self.toolbar.widgets["monitor"].widget.close()
self.toolbar.widgets["monitor"].widget.deleteLater() self.toolbar.widgets["monitor"].widget.deleteLater()
# ROI plots cleanup
self.x_roi.cleanup_pyqtgraph()
self.y_roi.cleanup_pyqtgraph()
super().cleanup() super().cleanup()

View File

@ -0,0 +1,37 @@
import pyqtgraph as pg
from bec_widgets.utils.round_frame import RoundedFrame
from bec_widgets.widgets.plots.plot_base import BECViewBox
class ImageROIPlot(RoundedFrame):
"""
A widget for displaying an image with a region of interest (ROI) overlay.
"""
def __init__(self, parent=None):
super().__init__(parent=parent)
self.content_widget = pg.GraphicsLayoutWidget(self)
self.layout.addWidget(self.content_widget)
self.plot_item = pg.PlotItem(viewBox=BECViewBox(enableMenu=True))
self.content_widget.addItem(self.plot_item)
self.curve_color = "w"
self.apply_plot_widget_style()
def apply_theme(self, theme: str):
if theme == "dark":
self.curve_color = "w"
else:
self.curve_color = "k"
for curve in self.plot_item.curves:
curve.setPen(pg.mkPen(self.curve_color, width=3))
super().apply_theme(theme)
def cleanup_pyqtgraph(self):
"""Cleanup pyqtgraph items."""
self.plot_item.vb.menu.close()
self.plot_item.vb.menu.deleteLater()
self.plot_item.ctrlMenu.close()
self.plot_item.ctrlMenu.deleteLater()

View File

@ -168,7 +168,7 @@ def test_image_data_update_1d(qtbot, mocked_client):
def test_toolbar_actions_presence(qtbot, mocked_client): def test_toolbar_actions_presence(qtbot, mocked_client):
bec_image_view = create_widget(qtbot, Image, client=mocked_client) bec_image_view = create_widget(qtbot, Image, client=mocked_client)
assert "autorange_image" in bec_image_view.toolbar.bundles["roi"] assert "autorange_image" in bec_image_view.toolbar.widgets
assert "lock_aspect_ratio" in bec_image_view.toolbar.bundles["mouse_interaction"] assert "lock_aspect_ratio" in bec_image_view.toolbar.bundles["mouse_interaction"]
assert "processing" in bec_image_view.toolbar.bundles assert "processing" in bec_image_view.toolbar.bundles
assert "selection" in bec_image_view.toolbar.bundles assert "selection" in bec_image_view.toolbar.bundles
@ -414,3 +414,72 @@ def test_show_roi_manager_popup(qtbot, mocked_client):
view.roi_manager_dialog.close() view.roi_manager_dialog.close()
assert view.roi_manager_dialog is None assert view.roi_manager_dialog is None
assert roi_action.isChecked() is False, "Icon should toggle off" assert roi_action.isChecked() is False, "Icon should toggle off"
###################################
# ROI Plots & Crosshair Switch
###################################
def test_crosshair_roi_panels_visibility(qtbot, mocked_client):
"""
Verify that enabling the ROIcrosshair shows ROI panels and disabling hides them.
"""
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
switch = bec_image_view.toolbar.widgets["switch_crosshair"]
# Initially panels should be hidden
assert bec_image_view.side_panel_x.panel_height == 0
assert bec_image_view.side_panel_y.panel_width == 0
# Enable ROI crosshair
switch.actions["crosshair_roi"].action.trigger()
qtbot.wait(500)
# Panels must be visible
assert bec_image_view.side_panel_x.panel_height > 0
assert bec_image_view.side_panel_y.panel_width > 0
# Disable ROI crosshair
switch.actions["crosshair_roi"].action.trigger()
qtbot.wait(500)
# Panels hidden again
assert bec_image_view.side_panel_x.panel_height == 0
assert bec_image_view.side_panel_y.panel_width == 0
def test_roi_plot_data_from_image(qtbot, mocked_client):
"""
Check that ROI plots receive correct slice data from the 2D image.
"""
import numpy as np
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
# Provide deterministic 2D data
test_data = np.arange(25).reshape(5, 5)
bec_image_view.on_image_update_2d({"data": test_data}, {})
# Activate ROI crosshair
switch = bec_image_view.toolbar.widgets["switch_crosshair"]
switch.actions["crosshair_roi"].action.trigger()
qtbot.wait(50)
# Simulate crosshair at row 2, col 3
bec_image_view.update_image_slices((0, 2, 3))
# Extract plotted data
x_items = bec_image_view.x_roi.plot_item.listDataItems()
y_items = bec_image_view.y_roi.plot_item.listDataItems()
assert len(x_items) == 1
assert len(y_items) == 1
# Vertical slice (column)
_, v_slice = x_items[0].getData()
np.testing.assert_array_equal(v_slice, test_data[:, 3])
# Horizontal slice (row)
h_slice, _ = y_items[0].getData()
np.testing.assert_array_equal(h_slice, test_data[2])