feat(crosshair): update labels and ROI profiles on image changes

This commit is contained in:
2026-07-10 14:48:01 +02:00
committed by Jan Wyzula
parent e3bb7f8368
commit e013078976
6 changed files with 214 additions and 70 deletions
+16 -1
View File
@@ -393,6 +393,20 @@ class Crosshair(QObject):
transform.m33(),
)
@SafeSlot()
def update_on_image_change(self) -> None:
"""Refresh image-dependent crosshair state after the image data changed.
The live and the pinned coordinate use the same mechanism: each label is
rebuilt from its coordinate against the current image (the live label
otherwise only refreshes on mouse moves, which would leave a stale
intensity under a streaming image).
"""
self.update_image_marker_geometry()
if self.coord_label.isVisible():
self.update_coord_label((self.v_line.value(), self.h_line.value()))
self.update_pinned_label()
def update_pinned_label(self) -> None:
"""Refresh the pinned marker label against the current plotted data."""
if self.pinned_pos is None or self.pinned_label is None:
@@ -744,9 +758,10 @@ class Crosshair(QObject):
self.pinned_label.skip_auto_range = True
self.pinned_label.setZValue(1000)
self.plot_item.addItem(self.pinned_label)
self.pinned_label.setText(self._coord_label_text(x, y, prefix="pin "))
self.pinned_pos = (x, y)
self.pinned_label.setPos(x, y)
self.pinned_label.setVisible(True)
self.update_pinned_label()
def clear_pin(self):
"""Remove the pinned marker (if any) and notify consumers via :attr:`pinCleared`."""
@@ -740,6 +740,8 @@ class Heatmap(ImageBase):
self.main_image.set_data(img, transform=transform)
if self._color_bar is not None:
self._color_bar.blockSignals(False)
# Crosshair labels and pinned profiles refresh via image_updated
# (ImageBase._on_image_updated_refresh).
self.image_updated.emit()
def _request_step_scan_interpolation(
+99 -58
View File
@@ -276,9 +276,14 @@ class ImageBase(PlotBase):
)
self.layer_manager.add("main")
self._init_image_base_toolbar()
self._crosshair_image_update_proxy = pg.SignalProxy(
self.image_updated, rateLimit=10, slot=self._on_crosshair_image_update
)
# The pinned array coordinates are tracked at widget level so the pinned
# profiles can refresh regardless of the crosshair lifecycle.
self.crosshair_coordinates_pinned.connect(self._on_pin_coordinates)
self.crosshair_pin_cleared.connect(self._on_pin_cleared)
# Live and pinned crosshair coordinates share one refresh mechanism; the
# live label otherwise only refreshes on mouse moves, which would leave a
# stale intensity under a streaming image.
self.image_updated.connect(self._on_image_updated_refresh)
self.autorange = True
self.autorange_mode = "mean"
@@ -309,10 +314,32 @@ class ImageBase(PlotBase):
self.x_roi.apply_theme(theme)
self.y_roi.apply_theme(theme)
@SafeSlot(object)
def _on_crosshair_image_update(self, _event=None) -> None:
"""Rate-limited crosshair refresh after image data changes."""
self.update_crosshair_on_image_change()
@SafeSlot(tuple)
def _on_pin_coordinates(self, coordinates: tuple) -> None:
"""Track the pinned array coordinates.
Args:
coordinates (tuple): ``(name, row, col)`` as emitted by the crosshair.
"""
self._pinned_slice_coords = (coordinates[1], coordinates[2])
@SafeSlot()
def _on_pin_cleared(self) -> None:
"""Forget the pinned coordinates once the pin is removed."""
self._pinned_slice_coords = None
@SafeSlot()
def _on_image_updated_refresh(self) -> None:
"""Refresh crosshair labels and pinned profiles after the image changed.
The live and the pinned coordinate use the same mechanism: each label and
each profile is rebuilt from its coordinate against the current image.
"""
if self.crosshair is not None:
self.crosshair.update_on_image_change()
else:
self._update_detached_pin_label()
self.update_image_slices(pinned=True)
def add_layer(self, name: str | None = None, **kwargs) -> ImageLayer:
"""
@@ -617,11 +644,14 @@ class ImageBase(PlotBase):
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.
# curve is not wiped on every crosshair move) and the pinned curves that
# follow the image data at a fixed pixel.
self.x_roi_curve = None
self.y_roi_curve = None
self.x_roi_pinned = None
self.y_roi_pinned = None
# Pinned pixel (array indices) for the pinned profile refreshes.
self._pinned_slice_coords: tuple[int, int] | None = None
self.x_roi.apply_theme("dark")
self.y_roi.apply_theme("dark")
@@ -662,65 +692,91 @@ 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.
# Clicking pins the X/Y profiles at a fixed pixel 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)
# If a pin survived a previous toggle, rebuild its frozen profiles now that
self.update_image_slices()
# If a pin survived a previous toggle, rebuild its profiles now that
# the consumer slots are connected again.
if self.crosshair.pinned_pos is not None:
self.crosshair.reemit_pin()
else:
self.unhook_crosshair()
# Hide the ROI panels (the crosshair is destroyed, so its pin signals
# disconnect automatically; just drop any frozen reference curves).
# disconnect automatically; just drop the pinned 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)
@SafeSlot()
def update_image_slices(self, coordinates: tuple[int, int, int] = None):
def update_image_slices(
self, coordinates: tuple[int, int, int] | None = None, *, pinned: bool = False
):
"""
Update the image slices based on the crosshair position.
Update the X/Y profile curves at a pixel position.
The live crosshair and the pinned marker share this logic; ``pinned``
selects which pair of curves is updated and how it is styled. The live
pixel comes from ``coordinates`` (or the crosshair lines), the pinned
pixel is the stored pin position.
Args:
coordinates(tuple): The coordinates of the crosshair.
coordinates(tuple): ``(name, row, col)`` pixel coordinates for the
live curves; ignored for the pinned curves.
pinned(bool): Update the pinned reference curves instead of the live ones.
"""
if coordinates is None:
# Try to get coordinates from crosshair position (like in crosshair mouse_moved)
if (
hasattr(self, "crosshair")
and hasattr(self.crosshair, "v_line")
and hasattr(self.crosshair, "h_line")
):
x = int(round(self.crosshair.v_line.value()))
y = int(round(self.crosshair.h_line.value()))
else:
if pinned:
if self._pinned_slice_coords is None:
return
row, col = self._pinned_slice_coords
elif coordinates is not None:
row, col = coordinates[1], coordinates[2]
elif (
hasattr(self, "crosshair")
and hasattr(self.crosshair, "v_line")
and hasattr(self.crosshair, "h_line")
):
# Fall back to the crosshair line positions (like crosshair mouse_moved).
row = int(round(self.crosshair.v_line.value()))
col = int(round(self.crosshair.h_line.value()))
else:
x = coordinates[1]
y = coordinates[2]
return
image_item = self.layer_manager["main"].image
slices = self._compute_image_slices(image_item, x, y)
slices = self._compute_image_slices(image_item, row, col)
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.
# Curves are persistent and updated in place (rather than clear()+plot())
# so the other pair survives every refresh.
if pinned:
if self.x_roi_pinned is None:
# Solid pen on purpose: Qt's dash stroker on a many-segment profile
# curve is 10-20x more expensive to paint and dominates the whole
# panel repaint.
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 pinned 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
else:
self.x_roi_pinned.setData(h_world_x, h_slice)
self.y_roi_pinned.setData(v_slice, v_world_y)
return
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.x_roi_curve.setData(h_world_x, h_slice)
self.y_roi_curve.setData(v_slice, v_world_y)
def _compute_image_slices(self, image_item, row: int, col: int):
@@ -762,34 +818,22 @@ class ImageBase(PlotBase):
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.
def pin_image_slices(self, _coordinates: tuple):
"""Create or update the pinned profile curves at the pinned pixel.
Args:
coordinates (tuple): ``(name, row, col)`` pixel coordinates of the pin, as
emitted by the crosshair's ``coordinatesPinned2D`` signal.
_coordinates (tuple): ``(name, row, col)`` from ``coordinatesPinned2D``;
the pixel itself is tracked by ``_on_pin_coordinates``.
"""
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
self.update_image_slices(pinned=True)
@SafeSlot()
def clear_pinned_slices(self):
"""Remove the frozen pinned profile curves, if present."""
"""Remove the pinned profile curves, if present.
The pinned coordinates are kept: the pin itself may still exist (e.g. the
ROI panels were toggled off) and its label keeps following the image.
"""
if self.x_roi_pinned is not None:
self.x_roi.plot_item.removeItem(self.x_roi_pinned)
self.x_roi_pinned = None
@@ -1133,9 +1177,6 @@ class ImageBase(PlotBase):
Cleanup the widget.
"""
self.toolbar.cleanup()
if self._crosshair_image_update_proxy is not None:
self._crosshair_image_update_proxy.disconnect()
self._crosshair_image_update_proxy = None
# Remove all ROIs
rois = self.rois
+5 -9
View File
@@ -1136,16 +1136,12 @@ class PlotBase(BECWidget, QWidget):
if had_pin:
self.crosshair_pin_cleared.emit()
def update_crosshair_on_image_change(self) -> None:
"""Refresh image-dependent crosshair state without replaying mouse movement."""
if self.crosshair is not None:
self.crosshair.update_image_marker_geometry()
self.crosshair.update_pinned_label()
return
self._update_detached_pin_label()
def _update_detached_pin_label(self) -> None:
"""Refresh a detached pin label against the current image data."""
"""Refresh a detached pin label against the current image data.
Uses the same pull mechanism as the crosshair labels: the label is rebuilt
from the pinned coordinate against the currently plotted image.
"""
if self._detached_pin is None:
return
pos = self._detached_pin.get("pos")
+8
View File
@@ -587,6 +587,8 @@ def test_set_pin_2d_emits_pixel_coordinates(image_widget_with_crosshair):
def test_set_pin_2d_label_includes_intensity(image_widget_with_crosshair):
"""The pin label includes the image intensity at the pinned pixel and follows
image changes through update_on_image_change (same mechanism as the live label)."""
crosshair, plot_item = image_widget_with_crosshair
image = np.arange(10_000, dtype=float).reshape(100, 100)
for item in plot_item.items:
@@ -598,6 +600,12 @@ def test_set_pin_2d_label_includes_intensity(image_widget_with_crosshair):
assert crosshair.pinned_label is not None
assert crosshair.pinned_label.toPlainText() == "pin (40.500, 60.500)\nIntensity: 4060.000"
for item in plot_item.items:
if isinstance(item, pg.ImageItem):
item.setImage(image + 100.0)
crosshair.update_on_image_change()
assert crosshair.pinned_label.toPlainText() == "pin (40.500, 60.500)\nIntensity: 4160.000"
def test_reset_preserves_pin_cleanup_removes_it(image_widget_with_crosshair):
crosshair, _ = image_widget_with_crosshair
+84 -2
View File
@@ -1049,7 +1049,7 @@ def test_pinned_roi_profiles_freeze_and_clear(qtbot, mocked_client):
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."""
frozen (dashed) pinned reference curves."""
import numpy as np
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
@@ -1065,7 +1065,7 @@ def test_pinned_roi_profiles_keep_style_on_theme_change(qtbot, mocked_client):
bec_image_view.x_roi.apply_theme("light")
# The frozen reference keeps its amber styling, the live curve is re-penned.
# The pinned 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"
@@ -1158,6 +1158,88 @@ def test_detached_pin_can_be_removed_with_right_click(qtbot, mocked_client, monk
assert cleared == [True]
def test_pinned_profiles_follow_image_updates(qtbot, mocked_client):
"""Pinned profile curves keep their position but follow incoming image data,
exactly like the pin's intensity label."""
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
test_data = np.arange(25, dtype=float).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)
bec_image_view.crosshair.set_pin(2.0, 3.0)
_, x_pinned = bec_image_view.x_roi_pinned.getData()
np.testing.assert_array_equal(x_pinned, test_data[:, 3])
updated = test_data + 100.0
bec_image_view.on_image_update_2d({"data": updated}, {})
qtbot.waitUntil(
lambda: np.array_equal(bec_image_view.x_roi_pinned.getData()[1], updated[:, 3]), timeout=500
)
y_pinned, _ = bec_image_view.y_roi_pinned.getData()
np.testing.assert_array_equal(y_pinned, updated[2])
def test_detached_pin_profiles_follow_image_updates(qtbot, mocked_client):
"""Pinned profiles keep following image data while the crosshair is toggled off."""
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
test_data = np.arange(25, dtype=float).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)
bec_image_view.crosshair.set_pin(2.0, 3.0)
bec_image_view.toggle_crosshair(False) # pin becomes detached, panels stay active
assert bec_image_view.crosshair is None
assert bec_image_view.x_roi_pinned is not None
updated = test_data + 100.0
bec_image_view.on_image_update_2d({"data": updated}, {})
qtbot.waitUntil(
lambda: np.array_equal(bec_image_view.x_roi_pinned.getData()[1], updated[:, 3]), timeout=500
)
def test_crosshair_moves_update_profiles_immediately(qtbot, mocked_client):
"""Crosshair moves update the live profiles synchronously; new frames refresh
them at the current crosshair position."""
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
test_data = np.arange(25, dtype=float).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)
# Position the crosshair lines and emit the snapped pixel (as mouse_moved does).
bec_image_view.crosshair.mouse_moved(manual_pos=(1.2, 2.2))
np.testing.assert_array_equal(bec_image_view.x_roi_curve.getData()[1], test_data[:, 2])
# New frames refresh the live profile at the crosshair position.
updated = test_data + 100.0
bec_image_view.on_image_update_2d({"data": updated}, {})
np.testing.assert_array_equal(bec_image_view.x_roi_curve.getData()[1], updated[:, 2])
def test_live_label_intensity_updates_on_image_update(qtbot, mocked_client):
"""The live crosshair label refreshes its intensity when the image changes,
not only when the mouse moves."""
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
test_data = np.arange(25, dtype=float).reshape(5, 5)
bec_image_view.on_image_update_2d({"data": test_data}, {})
bec_image_view.hook_crosshair()
bec_image_view.crosshair.mouse_moved(manual_pos=(2.5, 3.5))
assert "Intensity: 13.000" in bec_image_view.crosshair.coord_label.toPlainText()
bec_image_view.on_image_update_2d({"data": test_data + 100.0}, {})
assert "Intensity: 113.000" in bec_image_view.crosshair.coord_label.toPlainText()
def test_active_pin_label_intensity_updates_on_image_update(qtbot, mocked_client):
"""Pinned crosshair label intensity follows image updates at the pinned position."""
bec_image_view = create_widget(qtbot, Image, client=mocked_client)