From 37835cbf76ca3ba1081f514ee7793244ac500e7f Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Mon, 19 Aug 2024 13:44:07 +0200 Subject: [PATCH] fix(crosshair): fixed crosshair for image and waveforms --- bec_widgets/qt_utils/toolbar.py | 30 ++- bec_widgets/utils/crosshair.py | 175 +++++++++++------- bec_widgets/widgets/figure/plots/plot_base.py | 39 +++- .../widgets/figure/plots/waveform/waveform.py | 2 +- .../widgets/waveform/waveform_widget.py | 15 +- tests/unit_tests/test_crosshair.py | 87 ++++----- 6 files changed, 230 insertions(+), 118 deletions(-) diff --git a/bec_widgets/qt_utils/toolbar.py b/bec_widgets/qt_utils/toolbar.py index 51a675a2..31798d16 100644 --- a/bec_widgets/qt_utils/toolbar.py +++ b/bec_widgets/qt_utils/toolbar.py @@ -3,8 +3,9 @@ import os from abc import ABC, abstractmethod from collections import defaultdict +from bec_qthemes import material_icon from qtpy.QtCore import QSize -from qtpy.QtGui import QAction, QIcon +from qtpy.QtGui import QAction, QGuiApplication, QIcon from qtpy.QtWidgets import QHBoxLayout, QLabel, QMenu, QToolBar, QToolButton, QWidget import bec_widgets @@ -70,6 +71,33 @@ class IconAction(ToolBarAction): toolbar.addAction(self.action) +class MaterialIconAction: + """ + Abstract base class for toolbar actions. + + Args: + icon_path (str, optional): The name of the icon file from `assets/toolbar_icons`. Defaults to None. + tooltip (bool, optional): The tooltip for the action. Defaults to None. + checkable (bool, optional): Whether the action is checkable. Defaults to False. + """ + + def __init__(self, icon_name: str = None, tooltip: str = None, checkable: bool = False): + self.icon_name = icon_name + self.tooltip = tooltip + self.checkable = checkable + self.action = None + + def add_to_toolbar(self, toolbar: QToolBar, target: QWidget): + palette = QGuiApplication.palette() + color = "#FFFFFF" # FIXME: This should be a theme color but the toolbar doesn't respect the theme atm + # one fixed, change it to palette.toolTipBase().color() + + icon = material_icon(self.icon_name, size=(20, 20), color=color) + self.action = QAction(QIcon(icon), self.tooltip, target) + self.action.setCheckable(self.checkable) + toolbar.addAction(self.action) + + class DeviceSelectionAction(ToolBarAction): """ Action for selecting a device in a combobox. diff --git a/bec_widgets/utils/crosshair.py b/bec_widgets/utils/crosshair.py index 6b40fa28..46b61efb 100644 --- a/bec_widgets/utils/crosshair.py +++ b/bec_widgets/utils/crosshair.py @@ -1,8 +1,10 @@ +from collections import defaultdict + import numpy as np import pyqtgraph as pg # from qtpy.QtCore import QObject, pyqtSignal -from qtpy.QtCore import QObject +from qtpy.QtCore import QObject, Qt from qtpy.QtCore import Signal as pyqtSignal @@ -26,10 +28,13 @@ class Crosshair(QObject): super().__init__(parent) self.is_log_y = None self.is_log_x = None + self.is_derivative = None self.plot_item = plot_item self.precision = precision self.v_line = pg.InfiniteLine(angle=90, movable=False) + self.v_line.skip_auto_range = True self.h_line = pg.InfiniteLine(angle=0, movable=False) + self.h_line.skip_auto_range = True self.plot_item.addItem(self.v_line, ignoreBounds=True) self.plot_item.addItem(self.h_line, ignoreBounds=True) self.proxy = pg.SignalProxy( @@ -37,6 +42,10 @@ class Crosshair(QObject): ) self.plot_item.scene().sigMouseClicked.connect(self.mouse_clicked) + self.plot_item.ctrl.derivativeCheck.checkStateChanged.connect(self.check_derivatives) + self.plot_item.ctrl.logXCheck.checkStateChanged.connect(self.check_log) + self.plot_item.ctrl.logYCheck.checkStateChanged.connect(self.check_log) + # Initialize markers self.marker_moved_1d = [] self.marker_clicked_1d = [] @@ -53,8 +62,8 @@ class Crosshair(QObject): self.plot_item.removeItem(self.marker_2d) # Create new markers - self.marker_moved_1d = [] - self.marker_clicked_1d = [] + self.marker_moved_1d = {} + self.marker_clicked_1d = {} self.marker_2d = None for item in self.plot_item.items: if isinstance(item, pg.PlotDataItem): # 1D plot @@ -63,48 +72,48 @@ class Crosshair(QObject): marker_moved = pg.ScatterPlotItem( size=10, pen=pg.mkPen(color), brush=pg.mkBrush(None) ) - marker_clicked = pg.ScatterPlotItem( - size=10, pen=pg.mkPen(None), brush=pg.mkBrush(color) - ) - self.marker_moved_1d.append(marker_moved) + marker_moved.skip_auto_range = True + self.marker_moved_1d[item.name()] = marker_moved self.plot_item.addItem(marker_moved) + # Create glowing effect markers for clicked events - marker_clicked_list = [] for size, alpha in [(18, 64), (14, 128), (10, 255)]: marker_clicked = pg.ScatterPlotItem( size=size, pen=pg.mkPen(None), brush=pg.mkBrush(color.red(), color.green(), color.blue(), alpha), ) - marker_clicked_list.append(marker_clicked) + marker_clicked.skip_auto_range = True + self.marker_clicked_1d[item.name()] = marker_clicked self.plot_item.addItem(marker_clicked) - self.marker_clicked_1d.append(marker_clicked_list) elif isinstance(item, pg.ImageItem): # 2D plot self.marker_2d = pg.ROI( [0, 0], size=[1, 1], pen=pg.mkPen("r", width=2), movable=False ) self.plot_item.addItem(self.marker_2d) - def snap_to_data(self, x, y) -> tuple: + def snap_to_data(self, x, y) -> tuple[defaultdict[list], defaultdict[list]]: """ Finds the nearest data points to the given x and y coordinates. Args: - x: The x-coordinate - y: The y-coordinate + x: The x-coordinate of the mouse cursor + y: The y-coordinate of the mouse cursor Returns: - tuple: The nearest x and y values + tuple: x and y values snapped to the nearest data """ - y_values_1d = [] - x_values_1d = [] + y_values = defaultdict(list) + x_values = defaultdict(list) image_2d = None # Iterate through items in the plot for item in self.plot_item.items: if isinstance(item, pg.PlotDataItem): # 1D plot - x_data, y_data = item.xData, item.yData + name = item.name() + plot_data = item._getDisplayDataset() + x_data, y_data = plot_data.x, plot_data.y if x_data is not None and y_data is not None: if self.is_log_x: min_x_data = np.min(x_data[x_data > 0]) @@ -112,25 +121,25 @@ class Crosshair(QObject): min_x_data = np.min(x_data) max_x_data = np.max(x_data) if x < min_x_data or x > max_x_data: - return None, None + y_values[name] = None + x_values[name] = None + continue closest_x, closest_y = self.closest_x_y_value(x, x_data, y_data) - y_values_1d.append(closest_y) - x_values_1d.append(closest_x) + y_values[name] = closest_y + x_values[name] = closest_x elif isinstance(item, pg.ImageItem): # 2D plot + name = item.config.monitor image_2d = item.image + # 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)) - # Handle 1D plot - if y_values_1d: - if all(v is None for v in x_values_1d) or all(v is None for v in y_values_1d): + if x_values and y_values: + if all(v is None for v in x_values.values()) or all( + v is None for v in y_values.values() + ): return None, None - closest_x = min(x_values_1d, key=lambda xi: abs(xi - x)) # Snap x to closest data point - return closest_x, y_values_1d - - # Handle 2D plot - if image_2d is not None: - x_idx = int(np.clip(x, 0, image_2d.shape[0] - 1)) - y_idx = int(np.clip(y, 0, image_2d.shape[1] - 1)) - return x_idx, y_idx + return x_values, y_values return None, None @@ -156,7 +165,6 @@ class Crosshair(QObject): Args: event: The mouse moved event """ - self.check_log() pos = event[0] if self.plot_item.vb.sceneBoundingRect().contains(pos): mouse_point = self.plot_item.vb.mapSceneToView(pos) @@ -168,27 +176,34 @@ class Crosshair(QObject): x = 10**x if self.is_log_y: y = 10**y - x, y_values = self.snap_to_data(x, y) + x_snap_values, y_snap_values = self.snap_to_data(x, y) + if x_snap_values is None or y_snap_values is None: + return + if all(v is None for v in x_snap_values.values()) or all( + v is None for v in y_snap_values.values() + ): + # not sure how we got here, but just to be safe... + return for item in self.plot_item.items: if isinstance(item, pg.PlotDataItem): - if x is None or all(v is None for v in y_values): - return - coordinate_to_emit = ( - round(x, self.precision), - [round(y_val, self.precision) for y_val in y_values], - ) + name = item.name() + x, y = x_snap_values[name], y_snap_values[name] + if x is None or y is None: + continue + self.marker_moved_1d[name].setData([x], [y]) + coordinate_to_emit = (name, round(x, self.precision), round(y, self.precision)) self.coordinatesChanged1D.emit(coordinate_to_emit) - for i, y_val in enumerate(y_values): - self.marker_moved_1d[i].setData( - [x if not self.is_log_x else np.log10(x)], - [y_val if not self.is_log_y else np.log10(y_val)], - ) elif isinstance(item, pg.ImageItem): - if x is None or y_values is None: - return - coordinate_to_emit = (x, y_values) + name = item.config.monitor + 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]) + coordinate_to_emit = (name, x, y) self.coordinatesChanged2D.emit(coordinate_to_emit) + else: + continue def mouse_clicked(self, event): """Handles the mouse clicked event, updating the crosshair position and emitting signals. @@ -196,7 +211,11 @@ class Crosshair(QObject): Args: event: The mouse clicked event """ - self.check_log() + + # we only accept left mouse clicks + if event.button() != Qt.MouseButton.LeftButton: + return + if self.plot_item.vb.sceneBoundingRect().contains(event._scenePos): mouse_point = self.plot_item.vb.mapSceneToView(event._scenePos) x, y = mouse_point.x(), mouse_point.y() @@ -205,31 +224,57 @@ class Crosshair(QObject): x = 10**x if self.is_log_y: y = 10**y - x, y_values = self.snap_to_data(x, y) + x_snap_values, y_snap_values = self.snap_to_data(x, y) + + if x_snap_values is None or y_snap_values is None: + return + if all(v is None for v in x_snap_values.values()) or all( + v is None for v in y_snap_values.values() + ): + # not sure how we got here, but just to be safe... + return for item in self.plot_item.items: if isinstance(item, pg.PlotDataItem): - if x is None or all(v is None for v in y_values): - return - coordinate_to_emit = ( - round(x, self.precision), - [round(y_val, self.precision) for y_val in y_values], - ) + name = item.name() + x, y = x_snap_values[name], y_snap_values[name] + if x is None or y is None: + continue + self.marker_clicked_1d[name].setData([x], [y]) + coordinate_to_emit = (name, round(x, self.precision), round(y, self.precision)) self.coordinatesClicked1D.emit(coordinate_to_emit) - for i, y_val in enumerate(y_values): - for marker in self.marker_clicked_1d[i]: - marker.setData( - [x if not self.is_log_x else np.log10(x)], - [y_val if not self.is_log_y else np.log10(y_val)], - ) elif isinstance(item, pg.ImageItem): - if x is None or y_values is None: - return - coordinate_to_emit = (x, y_values) + name = item.config.monitor + 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]) + coordinate_to_emit = (name, x, y) self.coordinatesClicked2D.emit(coordinate_to_emit) - self.marker_2d.setPos([x, y_values]) + else: + continue + + def clear_markers(self): + """Clears the markers from the plot.""" + for marker in self.marker_moved_1d.values(): + marker.clear() + # marker.deleteLater() + for marker in self.marker_clicked_1d.values(): + marker.clear() + # marker.deleteLater() def check_log(self): """Checks if the x or y axis is in log scale and updates the internal state accordingly.""" self.is_log_x = self.plot_item.ctrl.logXCheck.isChecked() self.is_log_y = self.plot_item.ctrl.logYCheck.isChecked() + self.clear_markers() + + def check_derivatives(self): + """Checks if the derivatives are enabled and updates the internal state accordingly.""" + self.is_derivative = self.plot_item.ctrl.derivativeCheck.isChecked() + self.clear_markers() + + def cleanup(self): + self.v_line.deleteLater() + self.h_line.deleteLater() + self.clear_markers() diff --git a/bec_widgets/widgets/figure/plots/plot_base.py b/bec_widgets/widgets/figure/plots/plot_base.py index 0b130be7..c9f17f6e 100644 --- a/bec_widgets/widgets/figure/plots/plot_base.py +++ b/bec_widgets/widgets/figure/plots/plot_base.py @@ -7,6 +7,7 @@ from pydantic import BaseModel, Field from qtpy.QtWidgets import QWidget from bec_widgets.utils import BECConnector, ConnectionConfig +from bec_widgets.utils.crosshair import Crosshair class AxisConfig(BaseModel): @@ -41,6 +42,18 @@ class SubplotConfig(ConnectionConfig): ) +class BECViewBox(pg.ViewBox): + + def itemBoundsChanged(self, item): + self._itemBoundsCache.pop(item, None) + if (self.state["autoRange"][0] is not False) or (self.state["autoRange"][1] is not False): + # check if the call is coming from a mouse-move event + if hasattr(item, "skip_auto_range") and item.skip_auto_range: + return + self._autoRangeNeedsUpdate = True + self.update() + + class BECPlotBase(BECConnector, pg.GraphicsLayout): USER_ACCESS = [ "_config_dict", @@ -73,9 +86,13 @@ class BECPlotBase(BECConnector, pg.GraphicsLayout): pg.GraphicsLayout.__init__(self, parent) self.figure = parent_figure - self.plot_item = self.addPlot(row=0, col=0) + + # self.plot_item = self.addPlot(row=0, col=0) + self.plot_item = pg.PlotItem(viewBox=BECViewBox(parent=self, enableMenu=True), parent=self) + self.addItem(self.plot_item, row=0, col=0) self.add_legend() + self.crosshair = None def set(self, **kwargs) -> None: """ @@ -304,6 +321,25 @@ class BECPlotBase(BECConnector, pg.GraphicsLayout): """ self.plot_item.enableAutoRange(axis, enabled) + def hook_crosshair(self) -> None: + """Hook the crosshair to all plots.""" + if self.crosshair is None: + self.crosshair = Crosshair(self.plot_item, precision=3) + + def unhook_crosshair(self) -> None: + """Unhook the crosshair from all plots.""" + if self.crosshair is not None: + self.crosshair.cleanup() + self.crosshair.deleteLater() + self.crosshair = None + + def toggle_crosshair(self) -> None: + """Toggle the crosshair on all plots.""" + if self.crosshair is None: + return self.hook_crosshair() + + self.unhook_crosshair() + def export(self): """Show the Export Dialog of the plot widget.""" scene = self.plot_item.scene() @@ -317,6 +353,7 @@ class BECPlotBase(BECConnector, pg.GraphicsLayout): def cleanup_pyqtgraph(self): """Cleanup pyqtgraph items.""" + self.unhook_crosshair() item = self.plot_item item.vb.menu.close() item.vb.menu.deleteLater() diff --git a/bec_widgets/widgets/figure/plots/waveform/waveform.py b/bec_widgets/widgets/figure/plots/waveform/waveform.py index 400686d9..6fe9714c 100644 --- a/bec_widgets/widgets/figure/plots/waveform/waveform.py +++ b/bec_widgets/widgets/figure/plots/waveform/waveform.py @@ -14,7 +14,7 @@ from qtpy.QtCore import Signal as pyqtSignal from qtpy.QtWidgets import QWidget from bec_widgets.qt_utils.error_popups import SafeSlot as Slot -from bec_widgets.utils import Colors, EntryValidator +from bec_widgets.utils import Colors, Crosshair, EntryValidator from bec_widgets.widgets.figure.plots.plot_base import BECPlotBase, SubplotConfig from bec_widgets.widgets.figure.plots.waveform.waveform_curve import ( BECCurve, diff --git a/bec_widgets/widgets/waveform/waveform_widget.py b/bec_widgets/widgets/waveform/waveform_widget.py index e02a6391..4ddc304e 100644 --- a/bec_widgets/widgets/waveform/waveform_widget.py +++ b/bec_widgets/widgets/waveform/waveform_widget.py @@ -9,7 +9,12 @@ from qtpy.QtWidgets import QVBoxLayout, QWidget from bec_widgets.qt_utils.error_popups import SafeSlot, WarningPopupUtility from bec_widgets.qt_utils.settings_dialog import SettingsDialog -from bec_widgets.qt_utils.toolbar import IconAction, ModularToolBar, SeparatorAction +from bec_widgets.qt_utils.toolbar import ( + IconAction, + MaterialIconAction, + ModularToolBar, + SeparatorAction, +) from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.widgets.figure import BECFigure from bec_widgets.widgets.figure.plots.axis_settings import AxisSettings @@ -93,8 +98,11 @@ class BECWaveformWidget(BECWidget, QWidget): "fit_params": IconAction( icon_path="fitting_parameters.svg", tooltip="Open Fitting Parameters" ), - "axis_settings": IconAction( - icon_path="settings.svg", tooltip="Open Configuration Dialog" + "axis_settings": MaterialIconAction( + icon_name="settings", tooltip="Open Configuration Dialog" + ), + "crosshair": MaterialIconAction( + icon_name="grid_goldenratio", tooltip="Show Crosshair" ), }, target_widget=self, @@ -123,6 +131,7 @@ class BECWaveformWidget(BECWidget, QWidget): self.toolbar.widgets["curves"].action.triggered.connect(self.show_curve_settings) self.toolbar.widgets["fit_params"].action.triggered.connect(self.show_fit_summary_dialog) self.toolbar.widgets["axis_settings"].action.triggered.connect(self.show_axis_settings) + self.toolbar.widgets["crosshair"].action.triggered.connect(self.waveform.toggle_crosshair) # self.toolbar.widgets["import"].action.triggered.connect( # lambda: self.load_config(path=None, gui=True) # ) diff --git a/tests/unit_tests/test_crosshair.py b/tests/unit_tests/test_crosshair.py index b823dd5c..e30ff483 100644 --- a/tests/unit_tests/test_crosshair.py +++ b/tests/unit_tests/test_crosshair.py @@ -1,19 +1,40 @@ # pylint: disable = no-name-in-module,missing-class-docstring, missing-module-docstring import numpy as np -import pyqtgraph as pg +import pytest from qtpy.QtCore import QPointF -from bec_widgets.utils import Crosshair +from bec_widgets.widgets.image.image_widget import BECImageWidget +from bec_widgets.widgets.waveform.waveform_widget import BECWaveformWidget + +from .client_mocks import mocked_client + +# pylint: disable = redefined-outer-name -def test_mouse_moved_lines(qtbot): - # Create a PlotWidget and add a PlotItem - plot_widget = pg.PlotWidget(title="1D PlotWidget with multiple curves") - plot_item = plot_widget.getPlotItem() - plot_item.plot([1, 2, 3], [4, 5, 6]) +@pytest.fixture +def plot_widget_with_crosshair(qtbot, mocked_client): + widget = BECWaveformWidget(client=mocked_client()) + widget.plot(x=[1, 2, 3], y=[4, 5, 6]) + widget.waveform.hook_crosshair() + qtbot.addWidget(widget) + qtbot.waitExposed(widget) - # Create a Crosshair instance - crosshair = Crosshair(plot_item=plot_item, precision=2) + yield widget.waveform.crosshair, widget.waveform.plot_item + + +@pytest.fixture +def image_widget_with_crosshair(qtbot, mocked_client): + widget = BECImageWidget(client=mocked_client()) + widget._image.add_custom_image(name="test", data=np.random.random((100, 200))) + widget._image.hook_crosshair() + qtbot.addWidget(widget) + qtbot.waitExposed(widget) + + yield widget._image.crosshair, widget._image.plot_item + + +def test_mouse_moved_lines(plot_widget_with_crosshair): + crosshair, plot_item = plot_widget_with_crosshair # Connect the signals to slots that will store the emitted values emitted_values_1D = [] @@ -32,14 +53,8 @@ def test_mouse_moved_lines(qtbot): assert crosshair.h_line.pos().y() == 5 -def test_mouse_moved_signals(qtbot): - # Create a PlotWidget and add a PlotItem - plot_widget = pg.PlotWidget(title="1D PlotWidget with multiple curves") - plot_item = plot_widget.getPlotItem() - plot_item.plot([1, 2, 3], [4, 5, 6]) - - # Create a Crosshair instance - crosshair = Crosshair(plot_item=plot_item, precision=2) +def test_mouse_moved_signals(plot_widget_with_crosshair): + crosshair, plot_item = plot_widget_with_crosshair # Create a slot that will store the emitted values as tuples emitted_values_1D = [] @@ -59,17 +74,11 @@ def test_mouse_moved_signals(qtbot): crosshair.mouse_moved(event_mock) # Assert the expected behavior - assert emitted_values_1D == [(2, [5])] + assert emitted_values_1D == [("Curve 1", 2, 5)] -def test_mouse_moved_signals_outside(qtbot): - # Create a PlotWidget and add a PlotItem - plot_widget = pg.PlotWidget(title="1D PlotWidget with multiple curves") - plot_item = plot_widget.getPlotItem() - plot_item.plot([1, 2, 3], [4, 5, 6]) - - # Create a Crosshair instance - crosshair = Crosshair(plot_item=plot_item, precision=2) +def test_mouse_moved_signals_outside(plot_widget_with_crosshair): + crosshair, plot_item = plot_widget_with_crosshair # Create a slot that will store the emitted values as tuples emitted_values_1D = [] @@ -92,17 +101,9 @@ def test_mouse_moved_signals_outside(qtbot): assert emitted_values_1D == [] -def test_mouse_moved_signals_2D(qtbot): - # write similar test for 2D plot +def test_mouse_moved_signals_2D(image_widget_with_crosshair): + crosshair, plot_item = image_widget_with_crosshair - # Create a PlotWidget and add a PlotItem - plot_widget = pg.PlotWidget(title="2D plot with crosshair and ROI square") - data_2D = np.random.random((100, 200)) - plot_item = plot_widget.getPlotItem() - image_item = pg.ImageItem(data_2D) - plot_item.addItem(image_item) - # Create a Crosshair instance - crosshair = Crosshair(plot_item=plot_item) # Create a slot that will store the emitted values as tuples emitted_values_2D = [] @@ -118,20 +119,12 @@ def test_mouse_moved_signals_2D(qtbot): # Call the mouse_moved method crosshair.mouse_moved(event_mock) # Assert the expected behavior - assert emitted_values_2D == [(22.0, 55.0)] + assert emitted_values_2D == [("test", 22.0, 55.0)] -def test_mouse_moved_signals_2D_outside(qtbot): - # write similar test for 2D plot +def test_mouse_moved_signals_2D_outside(image_widget_with_crosshair): + crosshair, plot_item = image_widget_with_crosshair - # Create a PlotWidget and add a PlotItem - plot_widget = pg.PlotWidget(title="2D plot with crosshair and ROI square") - data_2D = np.random.random((100, 200)) - plot_item = plot_widget.getPlotItem() - image_item = pg.ImageItem(data_2D) - plot_item.addItem(image_item) - # Create a Crosshair instance - crosshair = Crosshair(plot_item=plot_item, precision=2) # Create a slot that will store the emitted values as tuples emitted_values_2D = []