feat(crosshair): add click-to-pin marker with frozen ROI profiles

This commit is contained in:
2026-07-10 14:48:01 +02:00
committed by Jan Wyzula
parent 141a42ad51
commit d6445ce462
6 changed files with 530 additions and 13 deletions
+189 -2
View File
@@ -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):
+87 -11
View File
@@ -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)
+8
View File
@@ -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
+183
View File
@@ -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
##############################################