diff --git a/bec_widgets/utils/crosshair.py b/bec_widgets/utils/crosshair.py index d022f912..020b90a5 100644 --- a/bec_widgets/utils/crosshair.py +++ b/bec_widgets/utils/crosshair.py @@ -85,7 +85,8 @@ class Crosshair(QObject): self.items = [] self.marker_moved_1d = {} self.marker_clicked_1d = {} - self.marker_2d = None + self.marker_2d_row = None + self.marker_2d_col = None self.update_markers() self.check_log() self.check_derivatives() @@ -195,13 +196,23 @@ class Crosshair(QObject): marker_clicked_list.append(marker_clicked) self.marker_clicked_1d[name] = marker_clicked_list 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 - self.marker_2d = pg.ROI( - [0, 0], size=[1, 1], pen=pg.mkPen("r", width=2), movable=False + # Create horizontal ROI for row highlighting + 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.plot_item.addItem(self.marker_2d) + self.marker_2d_row.skip_auto_range = True + 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( self, x: float, y: float @@ -243,6 +254,8 @@ class Crosshair(QObject): elif isinstance(item, pg.ImageItem): # 2D plot name = item.config.monitor or str(id(item)) 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 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)) @@ -330,7 +343,10 @@ class Crosshair(QObject): x, y = x_snap_values[name], y_snap_values[name] if x is None or y is None: 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) self.coordinatesChanged2D.emit(coordinate_to_emit) else: @@ -384,7 +400,10 @@ class Crosshair(QObject): x, y = x_snap_values[name], y_snap_values[name] if x is None or y is None: 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) self.coordinatesClicked2D.emit(coordinate_to_emit) else: @@ -428,6 +447,8 @@ class Crosshair(QObject): for item in self.items: if isinstance(item, pg.ImageItem): image = item.image + if image is None: + continue ix = int(np.clip(x, 0, image.shape[0] - 1)) iy = int(np.clip(y, 0, image.shape[1] - 1)) intensity = image[ix, iy] @@ -450,9 +471,12 @@ class Crosshair(QObject): self.clear_markers() def cleanup(self): - if self.marker_2d is not None: - self.plot_item.removeItem(self.marker_2d) - self.marker_2d = None + if self.marker_2d_row is not None: + self.plot_item.removeItem(self.marker_2d_row) + 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.h_line) self.plot_item.removeItem(self.coord_label) diff --git a/bec_widgets/widgets/plots/image/image.py b/bec_widgets/widgets/plots/image/image.py index cad08e12..950ad1bb 100644 --- a/bec_widgets/widgets/plots/image/image.py +++ b/bec_widgets/widgets/plots/image/image.py @@ -13,8 +13,10 @@ 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.side_panel import SidePanel 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_roi_plot import ImageROIPlot 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, @@ -123,6 +125,7 @@ class Image(PlotBase): "rois", ] sync_colorbar_with_autorange = Signal() + image_updated = Signal() def __init__( self, @@ -139,6 +142,8 @@ class Image(PlotBase): self._color_bar = None self._main_image = ImageItem() self.roi_controller = ROIController(colormap="viridis") + self.x_roi = None + self.y_roi = None super().__init__( 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 self.color_map = "plasma" + # Initialize ROI plots and side panels + self._add_roi_plots() + self.roi_manager_dialog = None + # Refresh theme for ROI plots + self._update_theme() + ################################################################################ # 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): # add to the first position self.selection_bundle = MonitorSelectionToolbarBundle( 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() # Image specific changes to PlotBase toolbar 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 self.lock_aspect_ratio_action = MaterialIconAction( icon_name="aspect_ratio", tooltip="Lock Aspect Ratio", checkable=True, parent=self @@ -216,11 +257,8 @@ class Image(PlotBase): parent=self, ) - self.toolbar.add_action_to_bundle( - bundle_id="roi", - action_id="autorange_image", - action=self.autorange_switch, - target_widget=self, + self.toolbar.add_action( + action_id="autorange_image", action=self.autorange_switch, target_widget=self ) self.autorange_mean_action.action.toggled.connect( @@ -252,11 +290,8 @@ class Image(PlotBase): parent=self, ) - self.toolbar.add_action_to_bundle( - bundle_id="roi", - action_id="switch_colorbar", - action=self.colorbar_switch, - target_widget=self, + self.toolbar.add_action( + action_id="switch_colorbar", action=self.colorbar_switch, target_widget=self ) self.simple_colorbar_action.action.toggled.connect( @@ -430,6 +465,101 @@ class Image(PlotBase): else: 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 ################################################################################ @@ -984,6 +1114,7 @@ class Image(PlotBase): self._main_image.set_data(image_buffer) if self._color_bar is not None: self._color_bar.blockSignals(False) + self.image_updated.emit() 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) if self._color_bar is not None: self._color_bar.blockSignals(False) + self.image_updated.emit() ################################################################################ # Clean up @@ -1090,6 +1222,10 @@ class Image(PlotBase): self.toolbar.widgets["monitor"].widget.close() self.toolbar.widgets["monitor"].widget.deleteLater() + # ROI plots cleanup + self.x_roi.cleanup_pyqtgraph() + self.y_roi.cleanup_pyqtgraph() + super().cleanup() diff --git a/bec_widgets/widgets/plots/image/image_roi_plot.py b/bec_widgets/widgets/plots/image/image_roi_plot.py new file mode 100644 index 00000000..7d32d6cf --- /dev/null +++ b/bec_widgets/widgets/plots/image/image_roi_plot.py @@ -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() diff --git a/tests/unit_tests/test_image_view_next_gen.py b/tests/unit_tests/test_image_view_next_gen.py index 7bf9a70d..f3b1df19 100644 --- a/tests/unit_tests/test_image_view_next_gen.py +++ b/tests/unit_tests/test_image_view_next_gen.py @@ -168,7 +168,7 @@ def test_image_data_update_1d(qtbot, mocked_client): def test_toolbar_actions_presence(qtbot, 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 "processing" 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() assert view.roi_manager_dialog is None 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 ROI‑crosshair 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])