mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-07-25 04:34:07 +02:00
feat(crosshair): pin crosshair has Intensity label for 2D
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
##############################################
|
||||
|
||||
Reference in New Issue
Block a user