0
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2025-07-12 18:51:50 +02:00

fix(crosshair): fix crosshair support for transformations

This commit is contained in:
2025-07-04 13:32:22 +02:00
committed by Klaus Wakonig
parent a6fc7993a3
commit 3ba0fc4b44
4 changed files with 153 additions and 25 deletions

View File

@ -5,9 +5,12 @@ from typing import Any
import numpy as np
import pyqtgraph as pg
from qtpy.QtCore import QObject, Qt, Signal, Slot
from qtpy.QtCore import QObject, QPointF, Qt, Signal, Slot
from qtpy.QtGui import QTransform
from qtpy.QtWidgets import QApplication
from bec_widgets.widgets.plots.image.image_item import ImageItem
class CrosshairScatterItem(pg.ScatterPlotItem):
def setDownsampling(self, ds=None, auto=None, method=None):
@ -265,12 +268,16 @@ class Crosshair(QObject):
[0, 0], size=[item.image.shape[0], 1], pen=pg.mkPen("r", width=2), movable=False
)
self.marker_2d_row.skip_auto_range = True
if item.image_transform is not None:
self.marker_2d_row.setTransform(item.image_transform)
self.plot_item.addItem(self.marker_2d_row)
# Create vertical ROI for column highlighting
self.marker_2d_col = pg.ROI(
[0, 0], size=[1, item.image.shape[1]], pen=pg.mkPen("r", width=2), movable=False
)
if item.image_transform is not None:
self.marker_2d_col.setTransform(item.image_transform)
self.marker_2d_col.skip_auto_range = True
self.plot_item.addItem(self.marker_2d_col)
@ -316,9 +323,25 @@ class Crosshair(QObject):
image_2d = item.image
if image_2d is None:
continue
# Clip the x and y values to the image dimensions to avoid out of bounds errors
y_values[name] = int(np.clip(y, 0, image_2d.shape[1] - 1))
x_values[name] = int(np.clip(x, 0, image_2d.shape[0] - 1))
# Map scene coordinates (plot units) back to image pixel coordinates
if item.image_transform is not None:
inv_transform, _ = item.image_transform.inverted()
xy_trans = inv_transform.map(QPointF(x, y))
else:
xy_trans = QPointF(x, y)
# Define valid pixel coordinate bounds
min_x_px, min_y_px = 0, 0
max_x_px = image_2d.shape[0] - 1 # columns
max_y_px = image_2d.shape[1] - 1 # rows
# Clip the mapped coordinates to the image bounds
px = int(np.clip(xy_trans.x(), min_x_px, max_x_px))
py = int(np.clip(xy_trans.y(), min_y_px, max_y_px))
# Store snapped pixel positions
x_values[name] = px
y_values[name] = py
if x_values and y_values:
if all(v is None for v in x_values.values()) or all(
@ -404,10 +427,17 @@ class Crosshair(QObject):
x, y = x_snap_values[name], y_snap_values[name]
if x is None or y is None:
continue
# Set position of horizontal ROI (row)
self.marker_2d_row.setPos([0, y])
# Set position of vertical ROI (column)
self.marker_2d_col.setPos([x, 0])
# Compute offsets that respect the image's transform so the ROIs
if isinstance(item, ImageItem) and item.image_transform is not None:
row, col = self._get_transformed_position(x, y, item.image_transform)
self.marker_2d_row.setPos(row)
self.marker_2d_col.setPos(col)
else:
self.marker_2d_row.setPos([0, y])
self.marker_2d_col.setPos([x, 0])
coordinate_to_emit = (name, x, y)
self.coordinatesChanged2D.emit(coordinate_to_emit)
else:
@ -462,15 +492,35 @@ class Crosshair(QObject):
x, y = x_snap_values[name], y_snap_values[name]
if x is None or y is None:
continue
# Set position of horizontal ROI (row)
self.marker_2d_row.setPos([0, y])
# Set position of vertical ROI (column)
self.marker_2d_col.setPos([x, 0])
if isinstance(item, ImageItem) and item.image_transform is not None:
row, col = self._get_transformed_position(x, y, item.image_transform)
self.marker_2d_row.setPos(row)
self.marker_2d_col.setPos(col)
else:
self.marker_2d_row.setPos([0, y])
self.marker_2d_col.setPos([x, 0])
coordinate_to_emit = (name, x, y)
self.coordinatesClicked2D.emit(coordinate_to_emit)
else:
continue
def _get_transformed_position(
self, x: float, y: float, transform: QTransform
) -> tuple[QPointF, QPointF]:
"""
Maps the given x and y coordinates to the transformed position using the provided transform.
Args:
x (float): The x-coordinate to transform.
y (float): The y-coordinate to transform.
transform (QTransform): The transformation to apply.
"""
origin = transform.map(QPointF(0, 0))
row = transform.map(QPointF(0, y)) - origin
col = transform.map(QPointF(x, 0)) - origin
return row, col
def clear_markers(self):
"""Clears the markers from the plot."""
for marker in self.marker_moved_1d.values():
@ -512,8 +562,18 @@ class Crosshair(QObject):
image = item.image
if image is None:
continue
ix = int(np.clip(x, 0, image.shape[0] - 1))
iy = int(np.clip(y, 0, image.shape[1] - 1))
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
# Clip to valid pixel indices
ix = int(np.clip(px, 0, image.shape[0] - 1)) # column
iy = int(np.clip(py, 0, image.shape[1] - 1)) # row
intensity = image[ix, iy]
text += f"\nIntensity: {intensity:.{precision}f}"
break

View File

@ -567,7 +567,9 @@ class ImageBase(PlotBase):
"""
# Create ROI plot widgets
self.x_roi = ImageROIPlot(parent=self)
self.x_roi.plot_item.setXLink(self.plot_item)
self.y_roi = ImageROIPlot(parent=self)
self.y_roi.plot_item.setYLink(self.plot_item)
self.x_roi.apply_theme("dark")
self.y_roi.apply_theme("dark")
@ -638,7 +640,8 @@ class ImageBase(PlotBase):
else:
x = coordinates[1]
y = coordinates[2]
image = self.layer_manager["main"].image.image
image_item = self.layer_manager["main"].image
image = image_item.image
if image is None:
return
max_row, max_col = image.shape[0] - 1, image.shape[1] - 1
@ -647,14 +650,27 @@ class ImageBase(PlotBase):
return
# Horizontal slice
h_slice = image[:, col]
x_axis = np.arange(h_slice.shape[0])
x_pixel_indices = np.arange(h_slice.shape[0])
if image_item.image_transform is None:
h_world_x = np.arange(h_slice.shape[0])
else:
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(x_axis, h_slice, pen=pg.mkPen(self.x_roi.curve_color, width=3))
self.x_roi.plot_item.plot(h_world_x, h_slice, pen=pg.mkPen(self.x_roi.curve_color, width=3))
# Vertical slice
v_slice = image[row, :]
y_axis = np.arange(v_slice.shape[0])
y_pixel_indices = np.arange(v_slice.shape[0])
if image_item.image_transform is None:
v_world_y = np.arange(v_slice.shape[0])
else:
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, y_axis, pen=pg.mkPen(self.y_roi.curve_color, width=3))
self.y_roi.plot_item.plot(v_slice, v_world_y, pen=pg.mkPen(self.y_roi.curve_color, width=3))
################################################################################
# Widget Specific Properties

View File

@ -86,10 +86,10 @@ class ImageItem(BECConnector, pg.ImageItem):
self.set_parent(parent_image)
else:
self.parent_image = None
self.image_transform = None
super().__init__(config=config, gui_id=gui_id, **kwargs)
self.raw_data = None
self.transform = None
self.buffer = []
self.max_len = 0
@ -104,7 +104,7 @@ class ImageItem(BECConnector, pg.ImageItem):
def set_data(self, data: np.ndarray, transform: QTransform | None = None):
self.raw_data = data
self.transform = transform
self.image_transform = transform
self._process_image()
################################################################################
@ -218,8 +218,8 @@ class ImageItem(BECConnector, pg.ImageItem):
self._image_processor.set_config(self.config.processing)
processed_data = self._image_processor.process_image(self.raw_data)
self.setImage(processed_data, autoLevels=False)
if self.transform is not None:
self.setTransform(self.transform)
if self.image_transform is not None:
self.setTransform(self.image_transform)
self.autorange = autorange
@property

View File

@ -2,8 +2,10 @@ import numpy as np
import pyqtgraph as pg
import pytest
from qtpy.QtCore import QPointF, Qt
from qtpy.QtGui import QTransform
from bec_widgets.utils import Crosshair
from bec_widgets.widgets.plots.image.image_item import ImageItem
# pylint: disable = redefined-outer-name
@ -27,7 +29,7 @@ def image_widget_with_crosshair(qtbot):
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
image_item = pg.ImageItem()
image_item = ImageItem()
image_item.setImage(np.random.rand(100, 100))
widget.addItem(image_item)
@ -113,7 +115,7 @@ def test_mouse_moved_signals_2D(image_widget_with_crosshair):
crosshair.mouse_moved(event_mock)
assert emitted_values_2D == [(str(id(image_item)), 21, 55)]
assert emitted_values_2D == [("ImageItem", 21, 55)]
def test_mouse_moved_signals_2D_outside(image_widget_with_crosshair):
@ -311,3 +313,53 @@ def test_crosshair_precision_properties_image(image_widget_with_crosshair):
crosshair.precision = 2
assert crosshair._current_precision() == 2
def test_get_transformed_position(plot_widget_with_crosshair):
"""Test that _get_transformed_position correctly transforms coordinates."""
crosshair, _ = plot_widget_with_crosshair
# Create a simple transform
transform = QTransform()
transform.translate(10, 20) # Origin is now at (10, 20)
# Test coordinates
x, y = 5, 8
# Get the transformed position
row, col = crosshair._get_transformed_position(x, y, transform)
# Calculate expected values:
# row should be the y-offset from origin after transform
# col should be the x-offset from origin after transform
expected_row = QPointF(0, 8) # y direction offset
expected_col = QPointF(5, 0) # x direction offset
# Check that the results match expectations
assert row == expected_row
assert col == expected_col
def test_get_transformed_position_with_scale(plot_widget_with_crosshair):
"""Test that _get_transformed_position correctly handles scaling transformations."""
crosshair, _ = plot_widget_with_crosshair
# Create a transform with scaling
transform = QTransform()
transform.translate(10, 20) # Origin is now at (10, 20)
transform.scale(2, 3) # Scale x by 2 and y by 3
# Test coordinates
x, y = 5, 8
# Get the transformed position
row, col = crosshair._get_transformed_position(x, y, transform)
# Calculate expected values with scaling applied:
# For a scale transform, the offsets should be multiplied by the scale factors
expected_row = QPointF(0, 8 * 3) # y direction offset with scale factor 3
expected_col = QPointF(5 * 2, 0) # x direction offset with scale factor 2
# Check that the results match expectations
assert row == expected_row
assert col == expected_col