From 20e951659558b7fc023e357bfe07d812c5fd020a Mon Sep 17 00:00:00 2001 From: wyzula-jan <133381102+wyzula-jan@users.noreply.github.com> Date: Thu, 10 Aug 2023 18:29:06 +0200 Subject: [PATCH] feat: cursor universal signals * 1D plot universal for multiple curves * 2D plot rectangular selection * signals for move/click for both 1D and 2D --- bec_widgets/qt_utils/crosshair.py | 118 +++++++++++++------ bec_widgets/qt_utils/crosshair_example.py | 67 ----------- bec_widgets/qt_utils/utils_example.py | 132 ++++++++++++++++++++++ 3 files changed, 217 insertions(+), 100 deletions(-) delete mode 100644 bec_widgets/qt_utils/crosshair_example.py create mode 100644 bec_widgets/qt_utils/utils_example.py diff --git a/bec_widgets/qt_utils/crosshair.py b/bec_widgets/qt_utils/crosshair.py index b39272ad..68ae97ae 100644 --- a/bec_widgets/qt_utils/crosshair.py +++ b/bec_widgets/qt_utils/crosshair.py @@ -1,11 +1,15 @@ import numpy as np import pyqtgraph as pg -from PyQt5.QtCore import pyqtSignal, QObject +from PyQt5.QtCore import QObject, pyqtSignal class Crosshair(QObject): - coordinatesChanged = pyqtSignal(float, float) - dataPointClicked = pyqtSignal(float, float) + # Signal for 1D plot + coordinatesChanged1D = pyqtSignal(float, list) + coordinatesClicked1D = pyqtSignal(float, list) + # Signal for 2D plot + coordinatesChanged2D = pyqtSignal(float, float) + coordinatesClicked2D = pyqtSignal(float, float) def __init__(self, plot_item, precision=None, parent=None): super().__init__(parent) @@ -20,44 +24,92 @@ class Crosshair(QObject): ) self.plot_item.scene().sigMouseClicked.connect(self.mouse_clicked) + # Add marker for clicked and selected point + data = self.get_data() + if isinstance(data, list): # 1D plot + num_curves = len(data) + self.marker_moved_1d = [] + self.marker_clicked_1d = [] + for i in range(num_curves): + color = plot_item.listDataItems()[i].opts["pen"].color() + marker_moved = pg.ScatterPlotItem( + size=10, pen=pg.mkPen(color), brush=pg.mkBrush(None) + ) # Hollow + marker_clicked = pg.ScatterPlotItem( + size=10, pen=pg.mkPen(None), brush=pg.mkBrush(color) + ) # Full + self.marker_moved_1d.append(marker_moved) + self.marker_clicked_1d.append(marker_clicked) + self.plot_item.addItem(marker_moved) + self.plot_item.addItem(marker_clicked) + else: # 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 get_data(self): - x_data, y_data = [], [] + curves = [] for item in self.plot_item.items: - if isinstance(item, pg.ImageItem): - return item.image, None # Return image data for 2D plot - elif isinstance(item, pg.PlotDataItem): - x_data.extend(item.xData) - y_data.extend(item.yData) - if x_data and y_data: - return np.array(x_data), np.array(y_data) # Return x and y data for 1D plot - return None, None + if isinstance(item, pg.PlotDataItem): # 1D plot + curves.append((item.xData, item.yData)) + elif isinstance(item, pg.ImageItem): # 2D plot + return item.image, None + return curves + + def snap_to_data(self, x, y): + data = self.get_data() + if isinstance(data, list): # 1D plot + y_values = [] + for x_data, y_data in data: + closest_x, closest_y = self.closest_x_y_value(x, x_data, y_data) + y_values.append(closest_y) + if self.precision is not None: + x = round(x, self.precision) + y_values = [round(y_val, self.precision) for y_val in y_values] + return x, y_values + elif isinstance(data[0], np.ndarray): # 2D plot + x_idx = int(np.clip(x, 0, data[0].shape[1] - 1)) + y_idx = int(np.clip(y, 0, data[0].shape[0] - 1)) + return x_idx, y_idx + return x, y + + def closest_x_y_value(self, input_value, list_x, list_y): + """ + Find the closest x and y value to the input value. + + Args: + input_value (float): Input value + list_x (list): List of x values + list_y (list): List of y values + + Returns: + tuple: Closest x and y value + """ + arr = np.asarray(list_x) + i = (np.abs(arr - input_value)).argmin() + return list_x[i], list_y[i] def mouse_moved(self, event): pos = event[0] if self.plot_item.vb.sceneBoundingRect().contains(pos): mouse_point = self.plot_item.vb.mapSceneToView(pos) - x, y = self.snap_to_data(mouse_point.x(), mouse_point.y()) - self.v_line.setPos(x) - self.h_line.setPos(y) - self.coordinatesChanged.emit(x, y) + x, y_values = self.snap_to_data(mouse_point.x(), mouse_point.y()) + self.v_line.setPos(mouse_point.x()) + self.h_line.setPos(mouse_point.y()) + if isinstance(y_values, list): # 1D plot + self.coordinatesChanged1D.emit(x, y_values) + for i, y_val in enumerate(y_values): + self.marker_moved_1d[i].setData([x], [y_val]) + else: # 2D plot + self.coordinatesChanged2D.emit(x, y_values) def mouse_clicked(self, event): if self.plot_item.vb.sceneBoundingRect().contains(event._scenePos): mouse_point = self.plot_item.vb.mapSceneToView(event._scenePos) - x, y = self.snap_to_data(mouse_point.x(), mouse_point.y()) - self.dataPointClicked.emit(x, y) - - def snap_to_data(self, x, y): - x_data, y_data = self.get_data() - if x_data is not None and y_data is not None: - distance = (x_data - x) ** 2 + (y_data - y) ** 2 - index = np.argmin(distance) - x, y = x_data[index], y_data[index] - if self.precision is not None: - x, y = round(x, self.precision), round(y, self.precision) - return x, y - elif x_data is not None and y_data is None: # For 2D plot (ImageItem) - x_idx = int(np.clip(x, 0, x_data.shape[1] - 1)) - y_idx = int(np.clip(y, 0, x_data.shape[0] - 1)) - return x_idx, y_idx - return x, y + x, y_values = self.snap_to_data(mouse_point.x(), mouse_point.y()) + if isinstance(y_values, list): # 1D plot + self.coordinatesClicked1D.emit(x, y_values) + for i, y_val in enumerate(y_values): + self.marker_clicked_1d[i].setData([x], [y_val]) + else: # 2D plot + self.coordinatesClicked2D.emit(x, y_values) + self.marker_2d.setPos([x, y_values]) diff --git a/bec_widgets/qt_utils/crosshair_example.py b/bec_widgets/qt_utils/crosshair_example.py deleted file mode 100644 index 3a478511..00000000 --- a/bec_widgets/qt_utils/crosshair_example.py +++ /dev/null @@ -1,67 +0,0 @@ -import pyqtgraph as pg -import numpy as np - -from crosshair import Crosshair - - -def add_crosshair(plot_item, is_image=False): - return Crosshair(plot_item, is_image) - - -app = pg.mkQApp() -win = pg.GraphicsLayoutWidget(show=True) -win.resize(1000, 500) - -##################### -# 1D Plot with labels -##################### -label_1d_move = win.addLabel("1D move label", row=0, col=0) -label_1d_click = win.addLabel("1D click label", row=1, col=0) -plot_item_1d = win.addPlot(row=2, col=0) -x_data = np.linspace(0, 10, 1000) -y_data_sine = np.sin(x_data) -y_data_cosine = np.cos(x_data) -plot_item_1d.plot(x_data, y_data_sine) -plot_item_1d.plot(x_data, y_data_cosine) -crosshair_1d = Crosshair(plot_item_1d, precision=2) - - -def on_coordinates_changed_1d(x, y): - label_1d_move.setText(f"1D Moved: ({x}, {y})") - - -def on_data_point_clicked_1d(x, y): - label_1d_click.setText(f"1D Clicked: ({x}, {y})") - - -crosshair_1d.coordinatesChanged.connect(on_coordinates_changed_1d) -crosshair_1d.dataPointClicked.connect(on_data_point_clicked_1d) - -##################### -# 2D Plot with labels -##################### -label_2d_move = win.addLabel("2D move label", row=0, col=1) -label_2d_click = win.addLabel("2D click label", row=1, col=1) -plot_item_2d = win.addPlot(row=2, col=1) - -img = np.random.normal(size=(100, 100)) -image_item = pg.ImageItem(img) -plot_item_2d.addItem(image_item) - -crosshair_2d = Crosshair(plot_item_2d, precision=2) - - -def on_coordinates_changed_2d(x, y): - label_2d_move.setText(f"2D Moved: ({x}, {y})") - - -def on_data_point_clicked_2d(x, y): - label_2d_click.setText(f"2D Clicked: ({x}, {y})") - - -# -crosshair_2d.coordinatesChanged.connect(on_coordinates_changed_2d) -crosshair_2d.dataPointClicked.connect(on_data_point_clicked_2d) - -if __name__ == "__main__": - pg.exec() diff --git a/bec_widgets/qt_utils/utils_example.py b/bec_widgets/qt_utils/utils_example.py new file mode 100644 index 00000000..96561da9 --- /dev/null +++ b/bec_widgets/qt_utils/utils_example.py @@ -0,0 +1,132 @@ +import numpy as np +import pyqtgraph as pg +from PyQt5.QtWidgets import ( + QApplication, + QVBoxLayout, + QLabel, + QWidget, + QHBoxLayout, + QTableWidget, + QTableWidgetItem, +) +from pyqtgraph import mkPen +from pyqtgraph.Qt import QtCore +from crosshair import Crosshair + + +class ExampleApp(QWidget): + def __init__(self): + super().__init__() + + # Layout + self.layout = QHBoxLayout() + self.setLayout(self.layout) + + ########################## + # 1D Plot + ########################## + + # PlotWidget + self.plot_widget_1d = pg.PlotWidget(title="1D PlotWidget with multiple curves") + self.plot_item_1d = self.plot_widget_1d.getPlotItem() + # 1D Datasets + self.x_data = np.linspace(0, 10, 1000) + # same convention as in line_plot.py + self.y_value_list = [ + np.sin(self.x_data), + np.cos(self.x_data), + np.sin(2 * self.x_data), + ] # List of y-values for multiple curves + self.curve_names = ["Sine", "Cosine", "Sine2x"] + + # Curves + color_list = ["#384c6b", "#e28a2b", "#5E3023", "#e41a1c", "#984e83", "#4daf4a"] + self.plot_item_1d.addLegend() + self.curves = [] + for ii, y_value in enumerate(self.y_value_list): + pen = mkPen(color=color_list[ii], width=2, style=QtCore.Qt.DashLine) + curve = pg.PlotDataItem( + self.x_data, y_value, pen=pen, skipFiniteCheck=True, name=self.curve_names[ii] + ) + self.plot_item_1d.addItem(curve) + self.curves.append(curve) + + ########################## + # 2D Plot + ########################## + self.plot_widget_2d = pg.PlotWidget(title="2D plot with crosshair and ROI square") + self.data_2D = np.random.random((100, 100)) + self.plot_item_2d = self.plot_widget_2d.getPlotItem() + self.image_item = pg.ImageItem(self.data_2D) + self.plot_item_2d.addItem(self.image_item) + + ########################## + # Table + ########################## + self.table = QTableWidget(len(self.curve_names), 2) + self.table.setHorizontalHeaderLabels(["(X, Y) - Moved", "(X, Y) - Clicked"]) + self.table.setVerticalHeaderLabels(self.curve_names) + self.table.resizeColumnsToContents() + + ########################## + # Signals & Cross-hairs + ########################## + # 1D + self.crosshair_1d = Crosshair(self.plot_item_1d, precision=2) + self.crosshair_1d.coordinatesChanged1D.connect( + lambda x, y: self.update_table(self.table, x, y, column=0) + ) + self.crosshair_1d.coordinatesClicked1D.connect( + lambda x, y: self.update_table(self.table, x, y, column=1) + ) + # 2D + self.crosshair_2d = Crosshair(self.plot_item_2d) + self.crosshair_2d.coordinatesChanged2D.connect( + lambda x, y: self.moved_label_2d.setText(f"Mouse Moved Coordinates (2D): x={x}, y={y}") + ) + self.crosshair_2d.coordinatesClicked2D.connect( + lambda x, y: self.clicked_label_2d.setText(f"Clicked Coordinates (2D): x={x}, y={y}") + ) + + ########################## + # Adding widgets to layout + ########################## + + ##### left side ##### + self.column1 = QVBoxLayout() + self.layout.addLayout(self.column1) + + # label + self.clicked_label_1d = QLabel("Clicked Coordinates (1D):") + self.column1.addWidget(self.clicked_label_1d) + + # table + self.column1.addWidget(self.table) + + # 1D plot + self.column1.addWidget(self.plot_widget_1d) + + ##### left side ##### + self.column2 = QVBoxLayout() + self.layout.addLayout(self.column2) + + # labels + self.clicked_label_2d = QLabel("Clicked Coordinates (2D):") + self.moved_label_2d = QLabel("Moved Coordinates (2D):") + self.column2.addWidget(self.clicked_label_2d) + self.column2.addWidget(self.moved_label_2d) + + # 2D plot + self.column2.addWidget(self.plot_widget_2d) + + def update_table(self, table_widget, x, y_values, column): + for i, y in enumerate(y_values): + table_widget.setItem(i, column, QTableWidgetItem(f"({x}, {y})")) + table_widget.resizeColumnsToContents() + + +if __name__ == "__main__": + app = QApplication([]) + window = ExampleApp() + window.show() + app.exec_()