feat(crosshair): pin crosshair has Intensity label for 2D

This commit is contained in:
2026-07-10 14:48:01 +02:00
committed by Jan Wyzula
parent 4a6c351be3
commit e3bb7f8368
6 changed files with 234 additions and 24 deletions
+71 -22
View File
@@ -130,6 +130,7 @@ class Crosshair(QObject):
self.marker_clicked_1d = {}
self.marker_2d_row = None
self.marker_2d_col = None
self._image_marker_geometry = 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
@@ -331,27 +332,72 @@ class Crosshair(QObject):
Update markers when the image changes, e.g. when the
image shape or transformation changes.
"""
self.update_image_marker_geometry()
self.update_pinned_label()
# Explicit callers may need the live crosshair refreshed from the
# cursor. Image update notifications intentionally do not take this
# path: synthesizing mouse moves for each incoming frame is expensive.
views = self.plot_item.vb.scene().views()
if not views:
return
view = views[0]
global_pos = QCursor.pos()
view_pos = view.mapFromGlobal(global_pos)
scene_pos = view.mapToScene(view_pos)
if self.plot_item.vb.sceneBoundingRect().contains(scene_pos):
plot_pt = self.plot_item.vb.mapSceneToView(scene_pos)
self.mouse_moved(manual_pos=(plot_pt.x(), plot_pt.y()))
def update_image_marker_geometry(self) -> None:
"""Update 2D marker geometry only when the image geometry changes."""
geometry = {}
for item in self.items:
if not isinstance(item, pg.ImageItem):
if not isinstance(item, pg.ImageItem) or item.image is None:
continue
transform = item.image_transform or QTransform()
geometry[id(item)] = (
item.image.shape[0],
item.image.shape[1],
self._transform_signature(transform),
)
if geometry == self._image_marker_geometry:
return
self._image_marker_geometry = geometry
for item in self.items:
if not isinstance(item, pg.ImageItem) or item.image is None:
continue
transform = item.image_transform or QTransform()
if self.marker_2d_row is not None:
self.marker_2d_row.setSize([item.image.shape[0], 1])
self.marker_2d_row.setTransform(item.image_transform)
self.marker_2d_row.setTransform(transform)
if self.marker_2d_col is not None:
self.marker_2d_col.setSize([1, item.image.shape[1]])
self.marker_2d_col.setTransform(item.image_transform)
# Get the current mouse position
views = self.plot_item.vb.scene().views()
if not views:
return
view = views[0]
global_pos = QCursor.pos()
view_pos = view.mapFromGlobal(global_pos)
scene_pos = view.mapToScene(view_pos)
self.marker_2d_col.setTransform(transform)
if self.plot_item.vb.sceneBoundingRect().contains(scene_pos):
plot_pt = self.plot_item.vb.mapSceneToView(scene_pos)
self.mouse_moved(manual_pos=(plot_pt.x(), plot_pt.y()))
@staticmethod
def _transform_signature(transform: QTransform) -> tuple[float, ...]:
"""Return a value-based transform signature for geometry change detection."""
return (
transform.m11(),
transform.m12(),
transform.m13(),
transform.m21(),
transform.m22(),
transform.m23(),
transform.m31(),
transform.m32(),
transform.m33(),
)
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:
return
self.pinned_label.setText(self._coord_label_text(*self.pinned_pos, prefix="pin "))
def snap_to_data(
self, x: float, y: float
@@ -698,9 +744,7 @@ class Crosshair(QObject):
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.setText(self._coord_label_text(x, y, prefix="pin "))
self.pinned_label.setPos(x, y)
self.pinned_label.setVisible(True)
@@ -811,6 +855,7 @@ class Crosshair(QObject):
def clear_markers(self):
"""Clears the markers from the plot."""
self._image_marker_geometry = None
for marker in self.marker_moved_1d.values():
self.plot_item.removeItem(marker)
for markers in self.marker_clicked_1d.values():
@@ -842,9 +887,16 @@ class Crosshair(QObject):
pos (tuple): The (x, y) position of the crosshair.
"""
x, y = pos
# Update coordinate label
self.coord_label.setText(self._coord_label_text(x, y))
self.coord_label.setPos(x, y)
self.coord_label.setVisible(True)
def _coord_label_text(self, x: float, y: float, *, prefix: str = "") -> str:
"""Return coordinate label text, including image intensity for 2D plots."""
x_scaled, y_scaled = self.scale_emitted_coordinates(x, y)
precision = self._current_precision()
text = f"({x_scaled:.{precision}f}, {y_scaled:.{precision}f})"
text = f"{prefix}({x_scaled:.{precision}f}, {y_scaled:.{precision}f})"
for item in self.items:
if isinstance(item, pg.ImageItem):
image = item.image
@@ -865,10 +917,7 @@ class Crosshair(QObject):
intensity = image[ix, iy]
text += f"\nIntensity: {intensity:.{precision}f}"
break
# Update coordinate label
self.coord_label.setText(text)
self.coord_label.setPos(x, y)
self.coord_label.setVisible(True)
return text
def check_log(self):
"""Checks if the x or y axis is in log scale and updates the internal state accordingly."""
@@ -741,8 +741,6 @@ class Heatmap(ImageBase):
if self._color_bar is not None:
self._color_bar.blockSignals(False)
self.image_updated.emit()
if self.crosshair is not None:
self.crosshair.update_markers_on_image_change()
def _request_step_scan_interpolation(
self,
@@ -276,6 +276,9 @@ 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
)
self.autorange = True
self.autorange_mode = "mean"
@@ -306,6 +309,11 @@ 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()
def add_layer(self, name: str | None = None, **kwargs) -> ImageLayer:
"""
Add a new image layer to the widget.
@@ -1125,6 +1133,9 @@ 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
+59
View File
@@ -1136,6 +1136,65 @@ 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."""
if self._detached_pin is None:
return
pos = self._detached_pin.get("pos")
label = self._detached_pin.get("label")
if pos is None or label is None:
return
x, y = pos
precision = self._current_crosshair_precision()
x_scaled, y_scaled = self._scale_crosshair_coordinates(x, y)
text = f"pin ({x_scaled:.{precision}f}, {y_scaled:.{precision}f})"
for item in self.plot_item.items:
if not isinstance(item, pg.ImageItem) or item.image is None:
continue
if item.image_transform is not None:
inv_transform, _ = item.image_transform.inverted()
pt = inv_transform.map(QPointF(x, y))
px, py = pt.x(), pt.y()
else:
px, py = x, y
ix = int(np.clip(px, 0, item.image.shape[0] - 1))
iy = int(np.clip(py, 0, item.image.shape[1] - 1))
text += f"\nIntensity: {item.image[ix, iy]:.{precision}f}"
break
label.setText(text)
def _current_crosshair_precision(self) -> int:
"""Return crosshair precision using the same dynamic rule as Crosshair."""
view_range = self.plot_item.vb.viewRange()
x_span = abs(view_range[0][1] - view_range[0][0])
y_span = abs(view_range[1][1] - view_range[1][0])
spans = [span for span in (x_span, y_span) if span > 0]
span = min(spans) if spans else 1.0
exponent = np.floor(np.log10(span))
decimals = max(0, int(-exponent) + 1)
return max(self._minimal_crosshair_precision, decimals)
def _scale_crosshair_coordinates(self, x: float, y: float) -> tuple[float, float]:
"""Scale coordinates for log axes using Crosshair's display convention."""
if self.plot_item.axes["bottom"]["item"].logMode:
x = 10**x
if self.plot_item.axes["left"]["item"].logMode:
y = 10**y
return x, y
def toggle_crosshair(self, enabled: bool | None = None) -> None:
"""Toggle the crosshair on all plots.
+26
View File
@@ -260,6 +260,19 @@ def test_update_coord_label_2D(image_widget_with_crosshair):
assert crosshair.coord_label.isVisible()
def test_update_markers_on_image_change_accepts_image_without_transform(
image_widget_with_crosshair,
):
crosshair, plot_item = image_widget_with_crosshair
image_item = next(item for item in plot_item.items if isinstance(item, pg.ImageItem))
image_item.image_transform = None
crosshair.update_markers_on_image_change()
assert crosshair.marker_2d_row.transform() == QTransform()
assert crosshair.marker_2d_col.transform() == QTransform()
def test_crosshair_precision_properties(plot_widget_with_crosshair):
"""
Ensure Crosshair.precision and Crosshair.min_precision behave correctly
@@ -573,6 +586,19 @@ def test_set_pin_2d_emits_pixel_coordinates(image_widget_with_crosshair):
assert (px, py) == (40, 60)
def test_set_pin_2d_label_includes_intensity(image_widget_with_crosshair):
crosshair, plot_item = image_widget_with_crosshair
image = np.arange(10_000, dtype=float).reshape(100, 100)
for item in plot_item.items:
if isinstance(item, pg.ImageItem):
item.setImage(image)
crosshair.set_pin(40, 60)
assert crosshair.pinned_label is not None
assert crosshair.pinned_label.toPlainText() == "pin (40.500, 60.500)\nIntensity: 4060.000"
def test_reset_preserves_pin_cleanup_removes_it(image_widget_with_crosshair):
crosshair, _ = image_widget_with_crosshair
crosshair.set_pin(40, 60)
@@ -1158,6 +1158,73 @@ def test_detached_pin_can_be_removed_with_right_click(qtbot, mocked_client, monk
assert cleared == [True]
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)
bec_image_view.on_image_update_2d({"data": np.arange(25).reshape(5, 5)}, {})
bec_image_view.hook_crosshair()
bec_image_view.crosshair.set_pin(2.0, 3.0)
assert bec_image_view.crosshair.pinned_label.toPlainText() == (
"pin (2.500, 3.500)\nIntensity: 13.000"
)
bec_image_view.on_image_update_2d({"data": np.arange(25).reshape(5, 5) + 100}, {})
qtbot.waitUntil(
lambda: bec_image_view.crosshair.pinned_label.toPlainText()
== "pin (2.500, 3.500)\nIntensity: 113.000",
timeout=500,
)
def test_image_update_does_not_replay_active_crosshair_mouse_handling(
qtbot, mocked_client, monkeypatch
):
"""Image updates refresh a pin without re-snapping or emitting live crosshair updates."""
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
bec_image_view.on_image_update_2d({"data": np.arange(25).reshape(5, 5)}, {})
bec_image_view.hook_crosshair()
bec_image_view.crosshair.set_pin(2.0, 3.0)
mouse_moves = []
monkeypatch.setattr(
bec_image_view.crosshair,
"mouse_moved",
lambda *args, **kwargs: mouse_moves.append((args, kwargs)),
)
bec_image_view.on_image_update_2d({"data": np.arange(25).reshape(5, 5) + 100}, {})
qtbot.waitUntil(
lambda: bec_image_view.crosshair.pinned_label.toPlainText()
== "pin (2.500, 3.500)\nIntensity: 113.000",
timeout=500,
)
assert mouse_moves == []
def test_detached_pin_label_intensity_updates_on_image_update(qtbot, mocked_client):
"""Detached pin labels keep following image updates while crosshair is disabled."""
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
bec_image_view.on_image_update_2d({"data": np.arange(25).reshape(5, 5)}, {})
bec_image_view.hook_crosshair()
bec_image_view.crosshair.set_pin(2.0, 3.0)
bec_image_view.unhook_crosshair()
assert bec_image_view.crosshair is None
assert bec_image_view._detached_pin is not None
assert bec_image_view._detached_pin["label"].toPlainText() == (
"pin (2.500, 3.500)\nIntensity: 13.000"
)
bec_image_view.on_image_update_2d({"data": np.arange(25).reshape(5, 5) + 200}, {})
qtbot.waitUntil(
lambda: bec_image_view._detached_pin["label"].toPlainText()
== "pin (2.500, 3.500)\nIntensity: 213.000",
timeout=500,
)
##############################################