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

Merge branch 'frontend'

This commit is contained in:
wyzula-jan
2023-08-17 16:01:25 +02:00
3 changed files with 334 additions and 97 deletions

View File

@ -11,7 +11,14 @@ class Crosshair(QObject):
coordinatesChanged2D = pyqtSignal(float, float)
coordinatesClicked2D = pyqtSignal(float, float)
def __init__(self, plot_item, precision=None, parent=None):
def __init__(self, plot_item: pg.PlotItem, precision: int = None, parent=None):
"""
Crosshair for 1D and 2D plots.
Args:
plot_item (pyqtgraph.PlotItem): The plot item to which the crosshair will be attached.
precision (int, optional): Number of decimal places to round the coordinates to. Defaults to None.
parent (QObject, optional): Parent object for the QObject. Defaults to None.
"""
super().__init__(parent)
self.is_log_y = None
self.is_log_x = None
@ -26,61 +33,93 @@ 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()
# Initialize markers
self.marker_moved_1d = []
self.marker_clicked_1d = []
self.marker_2d = None
self.update_markers()
def update_markers(self):
"""Update the markers for the crosshair, creating new ones if necessary."""
# Clear existing markers
for marker in self.marker_moved_1d + self.marker_clicked_1d:
self.plot_item.removeItem(marker)
if self.marker_2d:
self.plot_item.removeItem(self.marker_2d)
# Create new markers
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
pen = item.opts["pen"]
color = pen.color() if hasattr(pen, "color") else pg.mkColor(pen)
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)
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 get_data(self):
curves = []
def snap_to_data(self, x, y) -> tuple:
"""
Finds the nearest data points to the given x and y coordinates.
Args:
x: The x-coordinate
y: The y-coordinate
Returns:
tuple: The nearest x and y values
"""
y_values_1d = []
x_values_1d = []
image_2d = None
# Iterate through items in the plot
for item in self.plot_item.items:
if isinstance(item, pg.PlotDataItem): # 1D plot
curves.append((item.xData, item.yData))
x_data, y_data = item.xData, item.yData
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])
else:
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
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)
elif isinstance(item, pg.ImageItem): # 2D plot
return item.image, None
image_2d = item.image
return curves
# 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):
return None, None
return x, y_values_1d
# if curves:
# return curves
# else:
# return None
def snap_to_data(self, x, y):
data = self.get_data()
# if data is None: #TODO hadle if data are None
# return x, y
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)
return x, y_values
elif isinstance(data[0], np.ndarray): # 2D plot
x_idx = int(np.clip(x, 0, data[0].shape[0] - 1))
y_idx = int(np.clip(y, 0, data[0].shape[1] - 1))
# 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, y
def closest_x_y_value(self, input_value, list_x, list_y):
return None, None
def closest_x_y_value(self, input_value: float, list_x: list, list_y: list) -> tuple:
"""
Find the closest x and y value to the input value.
@ -97,6 +136,11 @@ class Crosshair(QObject):
return list_x[i], list_y[i]
def mouse_moved(self, event):
"""Handles the mouse moved event, updating the crosshair position and emitting signals.
Args:
event: The mouse moved event
"""
self.check_log()
pos = event[0]
if self.plot_item.vb.sceneBoundingRect().contains(pos):
@ -111,44 +155,61 @@ class Crosshair(QObject):
y = 10**y
x, y_values = self.snap_to_data(x, y)
if isinstance(y_values, list): # 1D plot
self.coordinatesChanged1D.emit(
round(x, self.precision), [round(y_val, self.precision) for y_val in y_values]
)
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)],
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
self.coordinatesChanged1D.emit(
round(x, self.precision),
[round(y_val, self.precision) for y_val in y_values],
)
else: # 2D plot
self.coordinatesChanged2D.emit(x, y_values)
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
self.coordinatesChanged2D.emit(x, y_values)
def mouse_clicked(self, event):
"""Handles the mouse clicked event, updating the crosshair position and emitting signals.
Args:
event: The mouse clicked event
"""
self.check_log()
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()
if self.is_log_x:
x = 10**x
if self.is_log_y:
y = 10**y
x, y_values = self.snap_to_data(x, y)
if isinstance(y_values, list): # 1D plot
self.coordinatesClicked1D.emit(
round(x, self.precision), [round(y_val, self.precision) for y_val in y_values]
)
for i, y_val in enumerate(y_values):
self.marker_clicked_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)],
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
self.coordinatesClicked1D.emit(
round(x, self.precision),
[round(y_val, self.precision) for y_val in y_values],
)
else: # 2D plot
self.coordinatesClicked2D.emit(x, y_values)
self.marker_2d.setPos([x, y_values])
for i, y_val in enumerate(y_values):
self.marker_clicked_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
self.coordinatesClicked2D.emit(x, y_values)
self.marker_2d.setPos([x, y_values])
def check_log(self):
"""
Check if x or y axis is in log scale
"""
"""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()

View File

@ -8,6 +8,7 @@ from PyQt5.QtWidgets import (
QHBoxLayout,
QTableWidget,
QTableWidgetItem,
QSpinBox,
)
from pyqtgraph import mkPen
from pyqtgraph.Qt import QtCore
@ -40,25 +41,15 @@ class ExampleApp(QWidget):
# 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),
gauss(self.x_data, 1, 1),
gauss(self.x_data, 1.5, 3),
abs(np.sin(self.x_data)),
abs(np.cos(self.x_data)),
abs(np.sin(2 * self.x_data)),
] # List of y-values for multiple curves
self.y_value_list = [gauss(self.x_data, 1, 1), gauss(self.x_data, 1.5, 3)]
self.curve_names = ["Gauss(1,1)", "Gauss(1.5,3)"] # ,"Sine", "Cosine", "Sine2x"]
# Curves
color_list = ["#384c6b", "#e28a2b", "#5E3023", "#e41a1c", "#984e83", "#4daf4a"]
self.plot_item_1d.addLegend()
self.curve_names = ["Gauss(1,1)", "Gauss(1.5,3)", "Abs(Sine)", "Abs(Cosine)", "Abs(Sine2x)"]
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
@ -78,24 +69,13 @@ class ExampleApp(QWidget):
self.table.resizeColumnsToContents()
##########################
# Signals & Cross-hairs
# Spinbox for N curves
##########################
# 1D
self.crosshair_1d = Crosshair(self.plot_item_1d, precision=4)
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}")
)
self.spin_box = QSpinBox()
self.spin_box.setMinimum(0)
self.spin_box.setMaximum(len(self.y_value_list))
self.spin_box.setValue(2)
self.spin_box.valueChanged.connect(lambda: self.update_curves(self.spin_box.value()))
##########################
# Adding widgets to layout
@ -105,6 +85,12 @@ class ExampleApp(QWidget):
self.column1 = QVBoxLayout()
self.layout.addLayout(self.column1)
# SpinBox
self.spin_row = QHBoxLayout()
self.column1.addLayout(self.spin_row)
self.spin_row.addWidget(QLabel("Number of curves:"))
self.spin_row.addWidget(self.spin_box)
# label
self.clicked_label_1d = QLabel("Clicked Coordinates (1D):")
self.column1.addWidget(self.clicked_label_1d)
@ -128,12 +114,53 @@ class ExampleApp(QWidget):
# 2D plot
self.column2.addWidget(self.plot_widget_2d)
self.update_curves(2) # just Gaussian curves
def hook_crosshair(self):
self.crosshair_1d = Crosshair(self.plot_item_1d, precision=10)
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}")
)
def update_table(self, table_widget, x, y_values, column):
"""Update the table with the new coordinates"""
for i, y in enumerate(y_values):
table_widget.setItem(i, column, QTableWidgetItem(f"({x}, {y})"))
table_widget.resizeColumnsToContents()
def update_curves(self, num_curves):
"""Update the number of curves"""
self.plot_item_1d.clear()
# Curves
color_list = ["#384c6b", "#e28a2b", "#5E3023", "#e41a1c", "#984e83", "#4daf4a"]
self.plot_item_1d.addLegend()
self.curves = []
y_value_list = self.y_value_list[:num_curves]
for ii, y_value in enumerate(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)
self.hook_crosshair()
if __name__ == "__main__":
app = QApplication([])

149
tests/test_crosshair.py Normal file
View File

@ -0,0 +1,149 @@
import numpy as np
import pyqtgraph as pg
from PyQt5.QtCore import QPointF
from bec_widgets.qt_utils import Crosshair
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])
# Create a Crosshair instance
crosshair = Crosshair(plot_item=plot_item, precision=2)
# Connect the signals to slots that will store the emitted values
emitted_values_1D = []
crosshair.coordinatesChanged1D.connect(emitted_values_1D.append)
# Simulate a mouse moved event at a specific position
pos_in_view = QPointF(2, 5)
pos_in_scene = plot_item.vb.mapViewToScene(pos_in_view)
event_mock = [pos_in_scene]
# Call the mouse_moved method
crosshair.mouse_moved(event_mock)
# Assert the expected behavior
assert crosshair.v_line.pos().x() == 2
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)
# Create a slot that will store the emitted values as tuples
emitted_values_1D = []
def slot(x, y_values):
emitted_values_1D.append((x, y_values))
# Connect the signal to the custom slot
crosshair.coordinatesChanged1D.connect(slot)
# Simulate a mouse moved event at a specific position
pos_in_view = QPointF(2, 5)
pos_in_scene = plot_item.vb.mapViewToScene(pos_in_view)
event_mock = [pos_in_scene]
# Call the mouse_moved method
crosshair.mouse_moved(event_mock)
# Assert the expected behavior
assert emitted_values_1D == [(2.0, [5.0])]
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)
# Create a slot that will store the emitted values as tuples
emitted_values_1D = []
def slot(x, y_values):
emitted_values_1D.append((x, y_values))
# Connect the signal to the custom slot
crosshair.coordinatesChanged1D.connect(slot)
# Simulate a mouse moved event at a specific position
pos_in_view = QPointF(22, 55)
pos_in_scene = plot_item.vb.mapViewToScene(pos_in_view)
event_mock = [pos_in_scene]
# Call the mouse_moved method
crosshair.mouse_moved(event_mock)
# Assert the expected behavior
assert emitted_values_1D == []
def test_mouse_moved_signals_2D(qtbot):
# write similar test for 2D plot
# 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 = []
def slot(x, y):
emitted_values_2D.append((x, y))
# Connect the signal to the custom slot
crosshair.coordinatesChanged2D.connect(slot)
# Simulate a mouse moved event at a specific position
pos_in_view = QPointF(22.0, 55.0)
pos_in_scene = plot_item.vb.mapViewToScene(pos_in_view)
event_mock = [pos_in_scene]
# Call the mouse_moved method
crosshair.mouse_moved(event_mock)
# Assert the expected behavior
assert emitted_values_2D == [(22.0, 55.0)]
def test_mouse_moved_signals_2D_outside(qtbot):
# write similar test for 2D plot
# 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 = []
def slot(x, y):
emitted_values_2D.append((x, y))
# Connect the signal to the custom slot
crosshair.coordinatesChanged2D.connect(slot)
# Simulate a mouse moved event at a specific position
pos_in_view = QPointF(220.0, 555.0)
pos_in_scene = plot_item.vb.mapViewToScene(pos_in_view)
event_mock = [pos_in_scene]
# Call the mouse_moved method
crosshair.mouse_moved(event_mock)
# Assert the expected behavior
assert emitted_values_2D == []