mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-07-25 04:34:07 +02:00
feat(crosshair): add click-to-pin marker with frozen ROI profiles
This commit is contained in:
@@ -7,7 +7,7 @@ import numpy as np
|
||||
import pyqtgraph as pg
|
||||
from qtpy.QtCore import QObject, QPointF, Qt, Signal
|
||||
from qtpy.QtGui import QCursor, QTransform
|
||||
from qtpy.QtWidgets import QApplication
|
||||
from qtpy.QtWidgets import QApplication, QMenu
|
||||
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.widgets.plots.image.image_item import ImageItem
|
||||
@@ -24,6 +24,31 @@ class CrosshairScatterItem(pg.ScatterPlotItem):
|
||||
pass
|
||||
|
||||
|
||||
class PinScatterItem(CrosshairScatterItem):
|
||||
"""Scatter item for the pinned marker, owning its removal context menu.
|
||||
|
||||
Right-clicks must be handled at the item level: item click events are
|
||||
delivered before the ViewBox's, so accepting the event here is the only way
|
||||
to keep pyqtgraph's plot context menu from opening on top of the pin menu.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, on_remove=None, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self._on_remove = on_remove
|
||||
|
||||
def mouseClickEvent(self, ev):
|
||||
if ev.button() == Qt.MouseButton.RightButton and self._on_remove is not None:
|
||||
ev.accept()
|
||||
menu = QMenu()
|
||||
remove_action = menu.addAction("Remove pinned marker")
|
||||
chosen = menu.exec_(QCursor.pos())
|
||||
menu.deleteLater()
|
||||
if chosen == remove_action:
|
||||
self._on_remove()
|
||||
return
|
||||
super().mouseClickEvent(ev)
|
||||
|
||||
|
||||
class Crosshair(QObject):
|
||||
# QT Position of mouse cursor
|
||||
positionChanged = Signal(tuple)
|
||||
@@ -37,6 +62,10 @@ class Crosshair(QObject):
|
||||
# Signal for 2D plot
|
||||
coordinatesChanged2D = Signal(tuple)
|
||||
coordinatesClicked2D = Signal(tuple)
|
||||
# Pinned marker signals (a persistent marker placed on click)
|
||||
coordinatesPinned1D = Signal(tuple)
|
||||
coordinatesPinned2D = Signal(tuple)
|
||||
pinCleared = Signal()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -101,6 +130,14 @@ class Crosshair(QObject):
|
||||
self.marker_clicked_1d = {}
|
||||
self.marker_2d_row = None
|
||||
self.marker_2d_col = None
|
||||
# Pinned marker (a single persistent marker placed on click). The live
|
||||
# crosshair keeps tracking the cursor; the pin stays until removed.
|
||||
self.pinned_point = None
|
||||
self.pinned_label = None
|
||||
self.pinned_v_line = None
|
||||
self.pinned_h_line = None
|
||||
self.pinned_pos = None
|
||||
self._pin_hit_radius_px = 12 # how close (scene px) a click counts as "on the pin"
|
||||
self.update_markers()
|
||||
self.check_log()
|
||||
self.check_derivatives()
|
||||
@@ -489,13 +526,16 @@ class Crosshair(QObject):
|
||||
event: The mouse clicked event
|
||||
"""
|
||||
|
||||
# we only accept left mouse clicks
|
||||
# we only accept left mouse clicks; right-clicks on the pin are handled
|
||||
# by the PinScatterItem itself (item events precede the ViewBox menu).
|
||||
if event.button() != Qt.MouseButton.LeftButton:
|
||||
return
|
||||
self.update_markers()
|
||||
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()
|
||||
# Keep the raw click position; ``x``/``y`` are reused/snapped below.
|
||||
pin_x, pin_y = mouse_point.x(), mouse_point.y()
|
||||
scaled_x, scaled_y = self.scale_emitted_coordinates(mouse_point.x(), mouse_point.y())
|
||||
self.crosshairClicked.emit((scaled_x, scaled_y))
|
||||
self.positionClicked.emit((x, y))
|
||||
@@ -545,6 +585,147 @@ class Crosshair(QObject):
|
||||
else:
|
||||
continue
|
||||
|
||||
# Pinned marker: double-click clears it, clicking the existing pin
|
||||
# removes it, otherwise (re)place a single pin at the clicked point.
|
||||
if event.double():
|
||||
self.clear_pin()
|
||||
elif self._pin_hit(event._scenePos):
|
||||
self.clear_pin()
|
||||
else:
|
||||
self.set_pin(pin_x, pin_y, x_snap_values, y_snap_values)
|
||||
|
||||
def set_pin(self, x: float, y: float, x_snap=None, y_snap=None):
|
||||
"""Place (or move) the single pinned marker at the data point nearest to (x, y).
|
||||
|
||||
The pin is independent of the live crosshair: it stays in place until removed.
|
||||
Emits :attr:`coordinatesPinned1D` / :attr:`coordinatesPinned2D` with the snapped
|
||||
coordinates so consumers (e.g. the image ROI panels) can freeze a reference.
|
||||
|
||||
Args:
|
||||
x (float): The x-coordinate of the click, in plot coordinates.
|
||||
y (float): The y-coordinate of the click, in plot coordinates.
|
||||
x_snap, y_snap: Optionally pre-computed snap dictionaries (from
|
||||
:meth:`snap_to_data`) to avoid recomputing them.
|
||||
"""
|
||||
# Make sure the tracked items are current (e.g. when called programmatically).
|
||||
self.update_markers()
|
||||
if x_snap is None or y_snap is None:
|
||||
x_snap, y_snap = self.snap_to_data(x, y)
|
||||
if x_snap is None or y_snap is None:
|
||||
return
|
||||
|
||||
precision = self._current_precision()
|
||||
draw_pt = None
|
||||
pinned_1d = None
|
||||
pinned_2d = None
|
||||
for item in self.items:
|
||||
if isinstance(item, pg.PlotDataItem):
|
||||
name = item.name() or str(id(item))
|
||||
sx, sy = x_snap.get(name), y_snap.get(name)
|
||||
if sx is None or sy is None:
|
||||
continue
|
||||
draw_pt = (sx, sy)
|
||||
sx_s, sy_s = self.scale_emitted_coordinates(sx, sy)
|
||||
pinned_1d = (name, round(sx_s, precision), round(sy_s, precision))
|
||||
break
|
||||
elif isinstance(item, pg.ImageItem):
|
||||
name = item.objectName() or str(id(item))
|
||||
px, py = x_snap.get(name), y_snap.get(name)
|
||||
if px is None or py is None:
|
||||
continue
|
||||
# Pin the marker at the pixel centre, mapped back into plot coordinates.
|
||||
if isinstance(item, ImageItem) and item.image_transform is not None:
|
||||
pt = item.image_transform.map(QPointF(px + 0.5, py + 0.5))
|
||||
draw_pt = (pt.x(), pt.y())
|
||||
else:
|
||||
draw_pt = (px + 0.5, py + 0.5)
|
||||
pinned_2d = (name, px, py)
|
||||
break
|
||||
|
||||
if draw_pt is None:
|
||||
return
|
||||
|
||||
self._draw_pin(draw_pt[0], draw_pt[1])
|
||||
self.pinned_pos = draw_pt
|
||||
if pinned_2d is not None:
|
||||
self.coordinatesPinned2D.emit(pinned_2d)
|
||||
if pinned_1d is not None:
|
||||
self.coordinatesPinned1D.emit(pinned_1d)
|
||||
|
||||
def _draw_pin(self, x: float, y: float):
|
||||
"""Create/move the pinned marker at plot coordinates (x, y).
|
||||
|
||||
The pin is drawn as a frozen crosshair: dashed infinite lines plus a small dot
|
||||
and a coordinate label, so it is visually distinct from the live crosshair.
|
||||
"""
|
||||
pin_pen = pg.mkPen("#f2c037", width=1, style=Qt.PenStyle.DashLine)
|
||||
if self.pinned_v_line is None:
|
||||
self.pinned_v_line = pg.InfiniteLine(angle=90, movable=False, pen=pin_pen)
|
||||
self.pinned_v_line.skip_auto_range = True
|
||||
self.pinned_v_line.is_crosshair = True
|
||||
self.pinned_v_line.setZValue(999)
|
||||
self.plot_item.addItem(self.pinned_v_line, ignoreBounds=True)
|
||||
if self.pinned_h_line is None:
|
||||
self.pinned_h_line = pg.InfiniteLine(angle=0, movable=False, pen=pin_pen)
|
||||
self.pinned_h_line.skip_auto_range = True
|
||||
self.pinned_h_line.is_crosshair = True
|
||||
self.pinned_h_line.setZValue(999)
|
||||
self.plot_item.addItem(self.pinned_h_line, ignoreBounds=True)
|
||||
self.pinned_v_line.setPos(x)
|
||||
self.pinned_h_line.setPos(y)
|
||||
|
||||
if self.pinned_point is None:
|
||||
self.pinned_point = PinScatterItem(
|
||||
size=9,
|
||||
symbol="o",
|
||||
pen=pg.mkPen("#3a2d00", width=1.5),
|
||||
brush=pg.mkBrush("#f2c037"),
|
||||
on_remove=self.clear_pin,
|
||||
)
|
||||
self.pinned_point.skip_auto_range = True
|
||||
self.pinned_point.is_crosshair = True
|
||||
self.pinned_point.setZValue(1000)
|
||||
self.plot_item.addItem(self.pinned_point)
|
||||
self.pinned_point.setData([x], [y])
|
||||
|
||||
if self.pinned_label is None:
|
||||
self.pinned_label = pg.TextItem("", anchor=(0, 1), color="#f2c037", fill=(0, 0, 0, 150))
|
||||
self.pinned_label.skip_auto_range = True
|
||||
self.pinned_label.setZValue(1000)
|
||||
self.plot_item.addItem(self.pinned_label)
|
||||
x_scaled, y_scaled = self.scale_emitted_coordinates(x, y)
|
||||
precision = self._current_precision()
|
||||
self.pinned_label.setText(f"pin ({x_scaled:.{precision}f}, {y_scaled:.{precision}f})")
|
||||
self.pinned_label.setPos(x, y)
|
||||
self.pinned_label.setVisible(True)
|
||||
|
||||
def clear_pin(self):
|
||||
"""Remove the pinned marker (if any) and notify consumers via :attr:`pinCleared`."""
|
||||
had_pin = self.pinned_pos is not None
|
||||
if self.pinned_point is not None:
|
||||
self.plot_item.removeItem(self.pinned_point)
|
||||
self.pinned_point = None
|
||||
if self.pinned_v_line is not None:
|
||||
self.plot_item.removeItem(self.pinned_v_line)
|
||||
self.pinned_v_line = None
|
||||
if self.pinned_h_line is not None:
|
||||
self.plot_item.removeItem(self.pinned_h_line)
|
||||
self.pinned_h_line = None
|
||||
if self.pinned_label is not None:
|
||||
self.plot_item.removeItem(self.pinned_label)
|
||||
self.pinned_label = None
|
||||
self.pinned_pos = None
|
||||
if had_pin:
|
||||
self.pinCleared.emit()
|
||||
|
||||
def _pin_hit(self, scene_pos) -> bool:
|
||||
"""Return True if a click at ``scene_pos`` (scene pixels) lands on the pinned marker."""
|
||||
if self.pinned_pos is None:
|
||||
return False
|
||||
pin_scene = self.plot_item.vb.mapViewToScene(QPointF(*self.pinned_pos))
|
||||
delta = pin_scene - scene_pos
|
||||
return (delta.x() ** 2 + delta.y() ** 2) ** 0.5 <= self._pin_hit_radius_px
|
||||
|
||||
def _get_transformed_position(
|
||||
self, x: float, y: float, transform: QTransform
|
||||
) -> tuple[QPointF, QPointF]:
|
||||
@@ -626,11 +807,16 @@ class Crosshair(QObject):
|
||||
self.is_log_x = self.plot_item.axes["bottom"]["item"].logMode
|
||||
self.is_log_y = self.plot_item.axes["left"]["item"].logMode
|
||||
self.clear_markers()
|
||||
# The pin is stored in view coordinates, which change meaning when the
|
||||
# axis scale changes; a stale pin would be drawn at the wrong data point.
|
||||
self.clear_pin()
|
||||
|
||||
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()
|
||||
# Same as check_log: the plotted data changes meaning, so drop the pin.
|
||||
self.clear_pin()
|
||||
|
||||
@SafeSlot()
|
||||
def reset(self):
|
||||
@@ -641,6 +827,7 @@ class Crosshair(QObject):
|
||||
if self.marker_2d_col is not None:
|
||||
self.plot_item.removeItem(self.marker_2d_col)
|
||||
self.marker_2d_col = None
|
||||
self.clear_pin()
|
||||
self.clear_markers()
|
||||
|
||||
def cleanup(self):
|
||||
|
||||
@@ -607,6 +607,13 @@ class ImageBase(PlotBase):
|
||||
self.x_roi.plot_item.setXLink(self.plot_item)
|
||||
self.y_roi = ImageROIPlot(parent=self)
|
||||
self.y_roi.plot_item.setYLink(self.plot_item)
|
||||
|
||||
# Persistent live profile curves (updated in place so a pinned reference
|
||||
# curve is not wiped on every crosshair move) and the frozen pinned curves.
|
||||
self.x_roi_curve = None
|
||||
self.y_roi_curve = None
|
||||
self.x_roi_pinned = None
|
||||
self.y_roi_pinned = None
|
||||
self.x_roi.apply_theme("dark")
|
||||
self.y_roi.apply_theme("dark")
|
||||
|
||||
@@ -647,10 +654,15 @@ class ImageBase(PlotBase):
|
||||
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)
|
||||
# Clicking pins a static copy of the current X/Y profiles for comparison.
|
||||
self.crosshair.coordinatesPinned2D.connect(self.pin_image_slices)
|
||||
self.crosshair.pinCleared.connect(self.clear_pinned_slices)
|
||||
self.image_updated.connect(self.update_image_slices)
|
||||
else:
|
||||
self.unhook_crosshair()
|
||||
# Hide the ROI panels
|
||||
# Hide the ROI panels (the crosshair is destroyed, so its pin signals
|
||||
# disconnect automatically; just drop any frozen reference curves).
|
||||
self.clear_pinned_slices()
|
||||
self.side_panel_x.hide_panel()
|
||||
self.side_panel_y.hide_panel()
|
||||
self.image_updated.disconnect(self.update_image_slices)
|
||||
@@ -678,14 +690,46 @@ class ImageBase(PlotBase):
|
||||
x = coordinates[1]
|
||||
y = coordinates[2]
|
||||
image_item = self.layer_manager["main"].image
|
||||
slices = self._compute_image_slices(image_item, x, y)
|
||||
if slices is None:
|
||||
return
|
||||
h_world_x, h_slice, v_world_y, v_slice = slices
|
||||
|
||||
# Update the live curves in place. They are persistent (rather than
|
||||
# clear()+plot() each time) so a pinned reference curve survives.
|
||||
if self.x_roi_curve is None:
|
||||
self.x_roi_curve = self.x_roi.plot_item.plot(
|
||||
h_world_x, h_slice, pen=pg.mkPen(self.x_roi.curve_color, width=3)
|
||||
)
|
||||
else:
|
||||
self.x_roi_curve.setData(h_world_x, h_slice)
|
||||
|
||||
if self.y_roi_curve is None:
|
||||
self.y_roi_curve = self.y_roi.plot_item.plot(
|
||||
v_slice, v_world_y, pen=pg.mkPen(self.y_roi.curve_color, width=3)
|
||||
)
|
||||
else:
|
||||
self.y_roi_curve.setData(v_slice, v_world_y)
|
||||
|
||||
def _compute_image_slices(self, image_item, row: int, col: int):
|
||||
"""Compute the horizontal/vertical profile slices through the image at (row, col).
|
||||
|
||||
Args:
|
||||
image_item: The image item to slice.
|
||||
row (int): Pixel index along the image's first axis.
|
||||
col (int): Pixel index along the image's second axis.
|
||||
|
||||
Returns:
|
||||
tuple | None: ``(h_world_x, h_slice, v_world_y, v_slice)`` in world coordinates,
|
||||
or ``None`` if there is no image or the pixel is out of bounds.
|
||||
"""
|
||||
image = image_item.image
|
||||
if image is None:
|
||||
return
|
||||
return None
|
||||
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
|
||||
return None
|
||||
# Horizontal slice (varies along the first axis at the fixed column)
|
||||
h_slice = image[:, col]
|
||||
x_pixel_indices = np.arange(h_slice.shape[0])
|
||||
if image_item.image_transform is None:
|
||||
@@ -694,10 +738,7 @@ class ImageBase(PlotBase):
|
||||
h_world_x = [
|
||||
image_item.image_transform.map(xi + 0.5, col + 0.5)[0] for xi in x_pixel_indices
|
||||
]
|
||||
self.x_roi.plot_item.clear()
|
||||
self.x_roi.plot_item.plot(h_world_x, h_slice, pen=pg.mkPen(self.x_roi.curve_color, width=3))
|
||||
|
||||
# Vertical slice
|
||||
# Vertical slice (varies along the second axis at the fixed row)
|
||||
v_slice = image[row, :]
|
||||
y_pixel_indices = np.arange(v_slice.shape[0])
|
||||
if image_item.image_transform is None:
|
||||
@@ -706,8 +747,43 @@ class ImageBase(PlotBase):
|
||||
v_world_y = [
|
||||
image_item.image_transform.map(row + 0.5, yi + 0.5)[1] for yi in y_pixel_indices
|
||||
]
|
||||
self.y_roi.plot_item.clear()
|
||||
self.y_roi.plot_item.plot(v_slice, v_world_y, pen=pg.mkPen(self.y_roi.curve_color, width=3))
|
||||
return h_world_x, h_slice, v_world_y, v_slice
|
||||
|
||||
@SafeSlot(tuple)
|
||||
def pin_image_slices(self, coordinates: tuple):
|
||||
"""Freeze the X/Y profiles at the pinned pixel as static reference curves.
|
||||
|
||||
Args:
|
||||
coordinates (tuple): ``(name, row, col)`` pixel coordinates of the pin, as
|
||||
emitted by the crosshair's ``coordinatesPinned2D`` signal.
|
||||
"""
|
||||
image_item = self.layer_manager["main"].image
|
||||
slices = self._compute_image_slices(image_item, coordinates[1], coordinates[2])
|
||||
if slices is None:
|
||||
return
|
||||
h_world_x, h_slice, v_world_y, v_slice = slices
|
||||
# Single pin: drop any previously frozen curves first.
|
||||
self.clear_pinned_slices()
|
||||
# Solid pen on purpose: Qt's dash stroker on a many-segment profile curve is
|
||||
# 10-20x more expensive to paint than a solid stroke, which stalls the GUI
|
||||
# thread on every image update while a pin is active. The straight pin
|
||||
# InfiniteLines can stay dashed cheaply; these data curves cannot.
|
||||
pen = pg.mkPen("#f2c037", width=2)
|
||||
self.x_roi_pinned = self.x_roi.plot_item.plot(h_world_x, h_slice, pen=pen)
|
||||
self.y_roi_pinned = self.y_roi.plot_item.plot(v_slice, v_world_y, pen=pen)
|
||||
# Keep the frozen reference styling when the ROI plots re-pen on theme change.
|
||||
self.x_roi_pinned.is_pinned_reference = True
|
||||
self.y_roi_pinned.is_pinned_reference = True
|
||||
|
||||
@SafeSlot()
|
||||
def clear_pinned_slices(self):
|
||||
"""Remove the frozen pinned profile curves, if present."""
|
||||
if self.x_roi_pinned is not None:
|
||||
self.x_roi.plot_item.removeItem(self.x_roi_pinned)
|
||||
self.x_roi_pinned = None
|
||||
if self.y_roi_pinned is not None:
|
||||
self.y_roi.plot_item.removeItem(self.y_roi_pinned)
|
||||
self.y_roi_pinned = None
|
||||
|
||||
################################################################################
|
||||
# Widget Specific Properties
|
||||
|
||||
@@ -26,6 +26,9 @@ class ImageROIPlot(RoundedFrame):
|
||||
else:
|
||||
self.curve_color = "k"
|
||||
for curve in self.plot_item.curves:
|
||||
if getattr(curve, "is_pinned_reference", False):
|
||||
# Frozen reference profiles keep their own (dashed) styling.
|
||||
continue
|
||||
curve.setPen(pg.mkPen(self.curve_color, width=3))
|
||||
super().apply_theme(theme)
|
||||
|
||||
|
||||
@@ -116,6 +116,8 @@ class PlotBase(BECWidget, QWidget):
|
||||
crosshair_position_clicked = Signal(tuple)
|
||||
crosshair_coordinates_changed = Signal(tuple)
|
||||
crosshair_coordinates_clicked = Signal(tuple)
|
||||
crosshair_coordinates_pinned = Signal(tuple)
|
||||
crosshair_pin_cleared = Signal()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -1081,6 +1083,9 @@ class PlotBase(BECWidget, QWidget):
|
||||
self.crosshair.coordinatesClicked1D.connect(self.crosshair_coordinates_clicked)
|
||||
self.crosshair.coordinatesChanged2D.connect(self.crosshair_coordinates_changed)
|
||||
self.crosshair.coordinatesClicked2D.connect(self.crosshair_coordinates_clicked)
|
||||
self.crosshair.coordinatesPinned1D.connect(self.crosshair_coordinates_pinned)
|
||||
self.crosshair.coordinatesPinned2D.connect(self.crosshair_coordinates_pinned)
|
||||
self.crosshair.pinCleared.connect(self.crosshair_pin_cleared)
|
||||
|
||||
def unhook_crosshair(self) -> None:
|
||||
"""Unhook the crosshair from all plots."""
|
||||
@@ -1091,6 +1096,9 @@ class PlotBase(BECWidget, QWidget):
|
||||
self.crosshair.coordinatesClicked1D.disconnect(self.crosshair_coordinates_clicked)
|
||||
self.crosshair.coordinatesChanged2D.disconnect(self.crosshair_coordinates_changed)
|
||||
self.crosshair.coordinatesClicked2D.disconnect(self.crosshair_coordinates_clicked)
|
||||
self.crosshair.coordinatesPinned1D.disconnect(self.crosshair_coordinates_pinned)
|
||||
self.crosshair.coordinatesPinned2D.disconnect(self.crosshair_coordinates_pinned)
|
||||
self.crosshair.pinCleared.disconnect(self.crosshair_pin_cleared)
|
||||
self.crosshair.cleanup()
|
||||
self.crosshair.deleteLater()
|
||||
self.crosshair = None
|
||||
|
||||
@@ -376,3 +376,186 @@ def test_ignore_invisible_curves_on_move(qtbot, mocked_client):
|
||||
wf.crosshair.mouse_moved(event_mock)
|
||||
qtbot.wait(200)
|
||||
assert set(wf.crosshair.marker_moved_1d.keys()) == {"Curve_0"}
|
||||
|
||||
|
||||
###############################################
|
||||
# Pinned marker (click to pin, remove gestures)
|
||||
###############################################
|
||||
|
||||
|
||||
class _FakeClickEvent:
|
||||
"""Minimal stand-in for a pyqtgraph MouseClickEvent for routing tests."""
|
||||
|
||||
def __init__(self, scene_pos, button=Qt.LeftButton, double=False):
|
||||
self._scenePos = scene_pos
|
||||
self._button = button
|
||||
self._double = double
|
||||
self.accepted = False
|
||||
|
||||
def button(self):
|
||||
return self._button
|
||||
|
||||
def double(self):
|
||||
return self._double
|
||||
|
||||
def accept(self):
|
||||
self.accepted = True
|
||||
|
||||
|
||||
def test_set_pin_1d_creates_marker_and_emits(plot_widget_with_crosshair):
|
||||
crosshair, _ = plot_widget_with_crosshair
|
||||
pinned = []
|
||||
crosshair.coordinatesPinned1D.connect(pinned.append)
|
||||
|
||||
assert crosshair.pinned_point is None
|
||||
crosshair.set_pin(2, 5)
|
||||
|
||||
assert crosshair.pinned_point is not None
|
||||
assert crosshair.pinned_pos is not None
|
||||
assert len(pinned) == 1
|
||||
_, px, py = pinned[0]
|
||||
assert np.isclose(px, 2) and np.isclose(py, 5)
|
||||
|
||||
|
||||
def test_pin_draws_dashed_crosshair_lines(plot_widget_with_crosshair):
|
||||
crosshair, plot_item = plot_widget_with_crosshair
|
||||
crosshair.set_pin(2, 5)
|
||||
|
||||
# A frozen, dashed crosshair (two infinite lines) is drawn at the pin.
|
||||
assert crosshair.pinned_v_line is not None
|
||||
assert crosshair.pinned_h_line is not None
|
||||
assert np.isclose(crosshair.pinned_v_line.value(), 2)
|
||||
assert np.isclose(crosshair.pinned_h_line.value(), 5)
|
||||
assert crosshair.pinned_v_line.pen.style() == Qt.PenStyle.DashLine
|
||||
assert crosshair.pinned_v_line in plot_item.items
|
||||
assert crosshair.pinned_h_line in plot_item.items
|
||||
|
||||
crosshair.clear_pin()
|
||||
assert crosshair.pinned_v_line is None
|
||||
assert crosshair.pinned_h_line is None
|
||||
|
||||
|
||||
def test_clear_pin_emits_only_when_pinned(plot_widget_with_crosshair):
|
||||
crosshair, _ = plot_widget_with_crosshair
|
||||
cleared = []
|
||||
crosshair.pinCleared.connect(lambda: cleared.append(True))
|
||||
|
||||
crosshair.set_pin(2, 5)
|
||||
crosshair.clear_pin()
|
||||
assert crosshair.pinned_point is None
|
||||
assert crosshair.pinned_v_line is None
|
||||
assert crosshair.pinned_h_line is None
|
||||
assert crosshair.pinned_pos is None
|
||||
assert cleared == [True]
|
||||
|
||||
# Clearing again is a no-op and must not re-emit.
|
||||
crosshair.clear_pin()
|
||||
assert cleared == [True]
|
||||
|
||||
|
||||
def test_single_click_pins(qtbot, plot_widget_with_crosshair):
|
||||
crosshair, plot_item = plot_widget_with_crosshair
|
||||
graphics_view = plot_item.vb.scene().views()[0]
|
||||
qtbot.waitExposed(graphics_view)
|
||||
pos = graphics_view.mapFromScene(plot_item.vb.mapViewToScene(QPointF(2, 5)))
|
||||
|
||||
qtbot.mouseClick(graphics_view.viewport(), Qt.LeftButton, pos=pos)
|
||||
assert crosshair.pinned_point is not None
|
||||
assert crosshair.pinned_pos is not None
|
||||
|
||||
|
||||
def test_reclick_on_pin_removes_it(plot_widget_with_crosshair):
|
||||
crosshair, plot_item = plot_widget_with_crosshair
|
||||
crosshair.set_pin(2, 5)
|
||||
assert crosshair.pinned_point is not None
|
||||
|
||||
# A left click that lands on the existing pin toggles it off.
|
||||
scene_pos = plot_item.vb.mapViewToScene(QPointF(*crosshair.pinned_pos))
|
||||
crosshair.mouse_clicked(_FakeClickEvent(scene_pos, button=Qt.LeftButton))
|
||||
assert crosshair.pinned_point is None
|
||||
|
||||
|
||||
def test_double_click_clears_pin(plot_widget_with_crosshair):
|
||||
crosshair, plot_item = plot_widget_with_crosshair
|
||||
crosshair.set_pin(2, 5)
|
||||
assert crosshair.pinned_point is not None
|
||||
|
||||
scene_pos = plot_item.vb.mapViewToScene(QPointF(2, 5))
|
||||
crosshair.mouse_clicked(_FakeClickEvent(scene_pos, double=True))
|
||||
assert crosshair.pinned_point is None
|
||||
|
||||
|
||||
def test_right_click_on_pin_removes_via_menu(monkeypatch, plot_widget_with_crosshair):
|
||||
"""Right-clicks are handled by the pin item itself (accepting the event there
|
||||
is what keeps pyqtgraph's plot context menu from opening on top)."""
|
||||
from qtpy.QtWidgets import QMenu
|
||||
|
||||
crosshair, _ = plot_widget_with_crosshair
|
||||
crosshair.set_pin(2, 5)
|
||||
assert crosshair.pinned_point is not None
|
||||
|
||||
# Choose the (only) "Remove pinned marker" action without showing a real menu.
|
||||
monkeypatch.setattr(QMenu, "exec_", lambda self, *a, **k: self.actions()[0])
|
||||
event = _FakeClickEvent(None, button=Qt.RightButton)
|
||||
crosshair.pinned_point.mouseClickEvent(event)
|
||||
|
||||
assert crosshair.pinned_point is None
|
||||
assert event.accepted is True
|
||||
|
||||
|
||||
def test_right_click_on_pin_menu_cancelled_keeps_pin(monkeypatch, plot_widget_with_crosshair):
|
||||
from qtpy.QtWidgets import QMenu
|
||||
|
||||
crosshair, _ = plot_widget_with_crosshair
|
||||
crosshair.set_pin(2, 5)
|
||||
|
||||
monkeypatch.setattr(QMenu, "exec_", lambda self, *a, **k: None) # menu dismissed
|
||||
event = _FakeClickEvent(None, button=Qt.RightButton)
|
||||
crosshair.pinned_point.mouseClickEvent(event)
|
||||
|
||||
assert crosshair.pinned_point is not None
|
||||
assert event.accepted is True
|
||||
|
||||
|
||||
def test_right_click_without_pin_is_ignored(plot_widget_with_crosshair):
|
||||
crosshair, plot_item = plot_widget_with_crosshair
|
||||
scene_pos = plot_item.vb.mapViewToScene(QPointF(2, 5))
|
||||
event = _FakeClickEvent(scene_pos, button=Qt.RightButton)
|
||||
crosshair.mouse_clicked(event)
|
||||
# No pin -> nothing handled at scene level, the event is left for
|
||||
# pyqtgraph's own context menu.
|
||||
assert event.accepted is False
|
||||
|
||||
|
||||
def test_log_mode_change_clears_pin(plot_widget_with_crosshair):
|
||||
"""The pin lives in view coordinates; toggling log mode must drop it instead
|
||||
of leaving it at a now-meaningless position."""
|
||||
crosshair, plot_item = plot_widget_with_crosshair
|
||||
crosshair.set_pin(2, 5)
|
||||
assert crosshair.pinned_point is not None
|
||||
|
||||
plot_item.ctrl.logYCheck.setChecked(True)
|
||||
|
||||
assert crosshair.pinned_point is None
|
||||
assert crosshair.pinned_pos is None
|
||||
|
||||
|
||||
def test_set_pin_2d_emits_pixel_coordinates(image_widget_with_crosshair):
|
||||
crosshair, _ = image_widget_with_crosshair
|
||||
pinned = []
|
||||
crosshair.coordinatesPinned2D.connect(pinned.append)
|
||||
|
||||
crosshair.set_pin(40, 60)
|
||||
assert crosshair.pinned_point is not None
|
||||
assert len(pinned) == 1
|
||||
_, px, py = pinned[0]
|
||||
assert (px, py) == (40, 60)
|
||||
|
||||
|
||||
def test_reset_clears_pin(image_widget_with_crosshair):
|
||||
crosshair, _ = image_widget_with_crosshair
|
||||
crosshair.set_pin(40, 60)
|
||||
assert crosshair.pinned_point is not None
|
||||
crosshair.reset()
|
||||
assert crosshair.pinned_point is None
|
||||
assert crosshair.pinned_pos is None
|
||||
|
||||
@@ -996,6 +996,66 @@ def test_roi_plot_data_from_image(qtbot, mocked_client):
|
||||
np.testing.assert_array_equal(h_slice, test_data[2])
|
||||
|
||||
|
||||
def test_pinned_roi_profiles_freeze_and_clear(qtbot, mocked_client):
|
||||
"""Clicking pins a frozen copy of the X/Y profiles that survives live updates."""
|
||||
import numpy as np
|
||||
|
||||
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
|
||||
test_data = np.arange(25).reshape(5, 5)
|
||||
bec_image_view.on_image_update_2d({"data": test_data}, {})
|
||||
|
||||
switch = bec_image_view.toolbar.components.get_action("image_switch_crosshair")
|
||||
switch.actions["crosshair_roi"].action.trigger()
|
||||
qtbot.wait(50)
|
||||
|
||||
assert bec_image_view.x_roi_pinned is None
|
||||
assert bec_image_view.y_roi_pinned is None
|
||||
|
||||
# Pin at pixel (row=2, col=3) -> freezes the corresponding profiles.
|
||||
bec_image_view.crosshair.set_pin(2.0, 3.0)
|
||||
assert bec_image_view.x_roi_pinned is not None
|
||||
assert bec_image_view.y_roi_pinned is not None
|
||||
_, x_pinned = bec_image_view.x_roi_pinned.getData()
|
||||
np.testing.assert_array_equal(x_pinned, test_data[:, 3])
|
||||
y_pinned, _ = bec_image_view.y_roi_pinned.getData()
|
||||
np.testing.assert_array_equal(y_pinned, test_data[2])
|
||||
|
||||
# A live update at another location must NOT wipe the frozen pinned curves.
|
||||
pinned_item = bec_image_view.x_roi_pinned
|
||||
bec_image_view.update_image_slices((0, 1, 1))
|
||||
assert bec_image_view.x_roi_pinned is pinned_item
|
||||
assert pinned_item in bec_image_view.x_roi.plot_item.listDataItems()
|
||||
|
||||
# Clearing the pin removes the frozen curves.
|
||||
bec_image_view.crosshair.clear_pin()
|
||||
assert bec_image_view.x_roi_pinned is None
|
||||
assert bec_image_view.y_roi_pinned is None
|
||||
assert pinned_item not in bec_image_view.x_roi.plot_item.listDataItems()
|
||||
|
||||
|
||||
def test_pinned_roi_profiles_keep_style_on_theme_change(qtbot, mocked_client):
|
||||
"""Theme changes re-pen the live profile curves but must not restyle the
|
||||
frozen (amber) pinned reference curves."""
|
||||
import numpy as np
|
||||
|
||||
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
|
||||
bec_image_view.on_image_update_2d({"data": np.arange(25).reshape(5, 5)}, {})
|
||||
switch = bec_image_view.toolbar.components.get_action("image_switch_crosshair")
|
||||
switch.actions["crosshair_roi"].action.trigger()
|
||||
qtbot.wait(50)
|
||||
|
||||
bec_image_view.crosshair.set_pin(2.0, 3.0)
|
||||
bec_image_view.update_image_slices((0, 2, 3)) # ensure the live curve exists
|
||||
pinned = bec_image_view.x_roi_pinned
|
||||
assert pinned.opts["pen"].color().name() == "#f2c037"
|
||||
|
||||
bec_image_view.x_roi.apply_theme("light")
|
||||
|
||||
# The frozen reference keeps its amber styling, the live curve is re-penned.
|
||||
assert pinned.opts["pen"].color().name() == "#f2c037"
|
||||
assert bec_image_view.x_roi_curve.opts["pen"].color().name() == "#000000"
|
||||
|
||||
|
||||
##############################################
|
||||
# Device selection toolbar sync
|
||||
##############################################
|
||||
|
||||
Reference in New Issue
Block a user