Skip to content

Commit 4510f67

Browse files
committed
Fix coordinate handling in XYImageItem: resolve multiple bugs related to bin edges vs pixel centers, update documentation, and improve method accuracy
1 parent 8ff10d2 commit 4510f67

File tree

3 files changed

+70
-24
lines changed

3 files changed

+70
-24
lines changed

CHANGELOG.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,16 @@
6363

6464
🛠️ Bug fixes:
6565

66-
* [Issue #49](https://github.com/PlotPyStack/PlotPy/issues/49) - Using cross-section tools on `XYImageItem` images alters the X/Y coordinate arrays
66+
* [Issue #49](https://github.com/PlotPyStack/PlotPy/issues/49) - Fixed multiple coordinate handling bugs in `XYImageItem`:
67+
* **Root cause**: `XYImageItem` internally stores bin edges (length n+1) but several methods were incorrectly treating them as pixel centers (length n)
68+
* Fixed `get_x_values()` and `get_y_values()` to correctly compute and return pixel centers from stored bin edges: `(edge[i] + edge[i+1]) / 2`
69+
* Fixed `get_pixel_coordinates()` to correctly convert plot coordinates to pixel indices using `searchsorted()` with proper edge-to-index adjustment
70+
* Fixed `get_plot_coordinates()` to return pixel center coordinates instead of bin edge coordinates
71+
* Fixed `get_closest_coordinates()` to return pixel center coordinates instead of bin edge coordinates
72+
* Added comprehensive docstring documentation explaining that `XYImageItem.x` and `XYImageItem.y` store bin edges, not pixel centers
73+
* Removed redundant pixel centering code in `CrossSectionItem.update_curve_data()` that was working around these bugs
74+
* This fixes the reported issue where using cross-section tools progressively translated image data to the bottom-right corner
75+
* All coordinate-related methods now properly handle the bin edge vs pixel center distinction throughout the `XYImageItem` API
6776
* Fixed index bounds calculation for image slicing compatibility:
6877
* Corrected the calculation of maximum indices in `get_plot_coordinates` to ensure proper bounds when using NumPy array slicing
6978
* Previously, the maximum indices were off by one, which could cause issues when extracting image data using the returned coordinates

plotpy/items/image/standard.py

Lines changed: 57 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,6 @@
3434

3535
if TYPE_CHECKING:
3636
import guidata.io
37-
import qwt.color_map
3837
import qwt.scale_map
3938
from qtpy.QtCore import QPointF, QRectF
4039
from qtpy.QtGui import QColor, QPainter
@@ -510,10 +509,17 @@ class XYImageItem(RawImageItem):
510509
"""XY image item (non-linear axes)
511510
512511
Args:
513-
x: 1D NumPy array, must be increasing
514-
y: 1D NumPy array, must be increasing
512+
x: 1D NumPy array of pixel center coordinates, must be increasing
513+
y: 1D NumPy array of pixel center coordinates, must be increasing
515514
data: 2D NumPy array
516515
param: image parameters
516+
517+
Note:
518+
Internally, `self.x` and `self.y` store **bin edges** (boundaries between
519+
pixels), not pixel centers. If input arrays have length nj and ni (matching
520+
data dimensions), they are converted to bin edges of length nj+1 and ni+1
521+
using `to_bins()`. Methods that need pixel center coordinates must compute
522+
them from the stored bin edges.
517523
"""
518524

519525
__implements__ = (IBasePlotItem, IBaseImageItem, ISerializableType)
@@ -604,12 +610,19 @@ def set_xy(self, x: np.ndarray | list[float], y: np.ndarray | list[float]) -> No
604610
"""Set X and Y data
605611
606612
Args:
607-
x: 1D NumPy array, must be increasing
608-
y: 1D NumPy array, must be increasing
613+
x: 1D NumPy array of pixel center coordinates, must be increasing
614+
y: 1D NumPy array of pixel center coordinates, must be increasing
609615
610616
Raises:
611617
ValueError: If X or Y are not increasing
612618
IndexError: If X or Y are not of the right length
619+
620+
Note:
621+
Input arrays represent pixel **center** coordinates. Internally, they are
622+
stored as bin **edges** in `self.x` and `self.y`:
623+
- If input has length nj (or ni), it's converted to edges via `to_bins()`
624+
- If input already has length nj+1 (or ni+1), it's used directly as edges
625+
- The stored edges have length nj+1 and ni+1 (one more than data dimensions)
613626
"""
614627
ni, nj = self.data.shape
615628
x = np.array(x, float)
@@ -677,9 +690,18 @@ def get_pixel_coordinates(self, xplot: float, yplot: float) -> tuple[float, floa
677690
yplot: Y plot coordinate
678691
679692
Returns:
680-
Pixel coordinates
681-
"""
682-
return self.x.searchsorted(xplot), self.y.searchsorted(yplot)
693+
Pixel coordinates (integer pixel indices)
694+
"""
695+
# self.x and self.y are bin edges. searchsorted finds the right edge index.
696+
# To get the pixel index, we need the left edge index, which is right_edge - 1
697+
# But clamp to valid range [0, n-1] where n is number of pixels
698+
i = self.x.searchsorted(xplot)
699+
j = self.y.searchsorted(yplot)
700+
# searchsorted returns the insertion point (right edge index)
701+
# Pixel index is insertion_point - 1, but clamped to [0, data.shape-1]
702+
i = max(0, min(i - 1, self.data.shape[1] - 1))
703+
j = max(0, min(j - 1, self.data.shape[0] - 1))
704+
return i, j
683705

684706
def get_plot_coordinates(self, xpixel: float, ypixel: float) -> tuple[float, float]:
685707
"""Get plot coordinates from pixel coordinates
@@ -689,9 +711,17 @@ def get_plot_coordinates(self, xpixel: float, ypixel: float) -> tuple[float, flo
689711
ypixel: Y pixel coordinate
690712
691713
Returns:
692-
Plot coordinates
693-
"""
694-
return self.x[int(pixelround(xpixel))], self.y[int(pixelround(ypixel))]
714+
Plot coordinates (pixel center coordinates)
715+
"""
716+
# self.x and self.y store bin edges, compute centers
717+
i = int(pixelround(xpixel))
718+
j = int(pixelround(ypixel))
719+
# Protect against out-of-bounds access
720+
i = max(0, min(i, len(self.x) - 2))
721+
j = max(0, min(j, len(self.y) - 2))
722+
x_center = (self.x[i] + self.x[i + 1]) / 2.0
723+
y_center = (self.y[j] + self.y[j + 1]) / 2.0
724+
return x_center, y_center
695725

696726
def get_x_values(self, i0: int, i1: int) -> np.ndarray:
697727
"""Get X values from pixel indexes
@@ -701,12 +731,13 @@ def get_x_values(self, i0: int, i1: int) -> np.ndarray:
701731
i1: Second index
702732
703733
Returns:
704-
X values corresponding to the given pixel indexes
734+
X values corresponding to the given pixel indexes (pixel centers)
705735
"""
706-
# Returning a copy to prevent modification of internal data by caller
736+
# self.x stores bin edges (length nj+1), but we need to return pixel centers
737+
# Compute centers from edges: center[i] = (edge[i] + edge[i+1]) / 2
707738
# (Fixes issue #49 - Using cross-section tools on `XYImageItem` images alters
708739
# the X/Y coordinate arrays)
709-
return self.x[i0:i1].copy()
740+
return (self.x[i0:i1] + self.x[i0 + 1 : i1 + 1]) / 2.0
710741

711742
def get_y_values(self, j0: int, j1: int) -> np.ndarray:
712743
"""Get Y values from pixel indexes
@@ -716,9 +747,11 @@ def get_y_values(self, j0: int, j1: int) -> np.ndarray:
716747
j1: Second index
717748
718749
Returns:
719-
Y values corresponding to the given pixel indexes
750+
Y values corresponding to the given pixel indexes (pixel centers)
720751
"""
721-
return self.y[j0:j1].copy() # (same remark as in `get_x_values`)
752+
# self.y stores bin edges (length ni+1), but we need to return pixel centers
753+
# Compute centers from edges: center[j] = (edge[j] + edge[j+1]) / 2
754+
return (self.y[j0:j1] + self.y[j0 + 1 : j1 + 1]) / 2.0
722755

723756
def get_closest_coordinates(self, x: float, y: float) -> tuple[float, float]:
724757
"""
@@ -729,10 +762,16 @@ def get_closest_coordinates(self, x: float, y: float) -> tuple[float, float]:
729762
y: Y coordinate
730763
731764
Returns:
732-
tuple[float, float]: Closest coordinates
765+
tuple[float, float]: Closest pixel center coordinates
733766
"""
734767
i, j = self.get_closest_indexes(x, y)
735-
return self.x[i], self.y[j]
768+
# self.x and self.y store bin edges, compute centers
769+
# Protect against out-of-bounds access
770+
i = max(0, min(i, len(self.x) - 2))
771+
j = max(0, min(j, len(self.y) - 2))
772+
x_center = (self.x[i] + self.x[i + 1]) / 2.0
773+
y_center = (self.y[j] + self.y[j + 1]) / 2.0
774+
return x_center, y_center
736775

737776
# ---- IBasePlotItem API ---------------------------------------------------
738777
def types(self) -> tuple[type[IItemType], ...]:

plotpy/panels/csection/csitem.py

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -319,16 +319,14 @@ def setStyle(self, style):
319319
self.update_curve_data(obj)
320320

321321
def update_curve_data(self, obj):
322-
"""
322+
"""Update curve data from cross section
323323
324-
:param obj:
324+
Args:
325+
obj: The object defining the cross section (marker, shape, etc.)
325326
"""
326327
sectx, secty = self.get_cross_section(obj)
327328
if secty.size == 0 or np.all(np.isnan(secty)):
328329
sectx, secty = np.array([]), np.array([])
329-
elif self.param.curvestyle != "Steps" and sectx.size > 1:
330-
# Center the symbols at the middle of pixels:
331-
sectx[:-1] += np.mean(np.diff(sectx) / 2)
332330
if self.orientation() == QC.Qt.Orientation.Vertical:
333331
self.process_curve_data(secty, sectx)
334332
else:

0 commit comments

Comments
 (0)