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:
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
Reference in New Issue
Block a user