diff --git a/bec_widgets/utils/crosshair.py b/bec_widgets/utils/crosshair.py index 3e6a948d..a5c64df5 100644 --- a/bec_widgets/utils/crosshair.py +++ b/bec_widgets/utils/crosshair.py @@ -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.""" diff --git a/bec_widgets/widgets/plots/heatmap/heatmap.py b/bec_widgets/widgets/plots/heatmap/heatmap.py index c1b2a67d..2eada7b6 100644 --- a/bec_widgets/widgets/plots/heatmap/heatmap.py +++ b/bec_widgets/widgets/plots/heatmap/heatmap.py @@ -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, diff --git a/bec_widgets/widgets/plots/image/image_base.py b/bec_widgets/widgets/plots/image/image_base.py index cc803bf1..bd7daf43 100644 --- a/bec_widgets/widgets/plots/image/image_base.py +++ b/bec_widgets/widgets/plots/image/image_base.py @@ -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 diff --git a/bec_widgets/widgets/plots/plot_base.py b/bec_widgets/widgets/plots/plot_base.py index 89e93790..b6b34cd7 100644 --- a/bec_widgets/widgets/plots/plot_base.py +++ b/bec_widgets/widgets/plots/plot_base.py @@ -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. diff --git a/tests/unit_tests/test_crosshair.py b/tests/unit_tests/test_crosshair.py index 1c73d7b1..7addfb6a 100644 --- a/tests/unit_tests/test_crosshair.py +++ b/tests/unit_tests/test_crosshair.py @@ -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) diff --git a/tests/unit_tests/test_image_view_next_gen.py b/tests/unit_tests/test_image_view_next_gen.py index 5d6a9e19..a6f5faeb 100644 --- a/tests/unit_tests/test_image_view_next_gen.py +++ b/tests/unit_tests/test_image_view_next_gen.py @@ -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, + ) ##############################################