Skip to content

Commit 7b38c01

Browse files
committed
fix: rectangular snapshot "Original size" on reversed axes and XYImageItem (#57)
Compute the snapshot "Original size" from pixel coordinates instead of axis units, via the new helper compute_image_items_original_size(). This fixes: - negative dimensions when X or Y axis is reversed - wrong size for XYImageItem (and any non-uniformly scaled item) - ValueError raised by the resize dialog on negative selections Also harden compute_trimageitems_original_size() against negative source sizes, and add unit tests covering all reported cases.
1 parent 2d9124e commit 7b38c01

6 files changed

Lines changed: 212 additions & 9 deletions

File tree

doc/release_notes/release_2.09.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,19 @@
11
# Version 2.9 #
22

3+
## PlotPy Version 2.9.1 ##
4+
5+
🛠️ Bug fixes:
6+
7+
* Fixed the rectangular snapshot tool's "Original size" computation. This closes
8+
[Issue #57](https://github.com/PlotPyStack/PlotPy/issues/57):
9+
* The preview no longer displays negative dimensions when the X or Y axis is
10+
reversed
11+
* The "Original size" is now computed from pixel coordinates instead of axis
12+
units, so it is correct for `XYImageItem` (and any item with non-uniform
13+
axis scaling) regardless of axis orientation
14+
* The `ValueError` raised by the resize dialog when the selection produced
15+
negative dimensions on a reversed axis is gone
16+
317
## PlotPy Version 2.9.0 ##
418

519
💥 New features:

plotpy/items/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
XYImageFilterItem,
3434
XYImageItem,
3535
assemble_imageitems,
36+
compute_image_items_original_size,
3637
compute_trimageitems_original_size,
3738
get_image_from_plot,
3839
get_image_from_qrect,

plotpy/items/image/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
Histogram2DItem,
1212
QuadGridItem,
1313
assemble_imageitems,
14+
compute_image_items_original_size,
1415
compute_trimageitems_original_size,
1516
get_image_from_plot,
1617
get_image_from_qrect,

plotpy/items/image/misc.py

Lines changed: 62 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -577,19 +577,27 @@ def get_items_in_rectangle(
577577

578578
def compute_trimageitems_original_size(
579579
items: list[TrImageItem],
580-
src_w: list[float, float, float, float],
581-
src_h: list[float, float, float, float],
580+
src_w: float,
581+
src_h: float,
582582
) -> tuple[float, float]:
583583
"""Compute `TrImageItem` original size from max dx and dy
584584
585585
Args:
586586
items: List of image items
587-
src_w: Source width
588-
src_h: Source height
587+
src_w: Source width (in plot axis units)
588+
src_h: Source height (in plot axis units)
589589
590590
Returns:
591591
Tuple of original size
592+
593+
.. note::
594+
595+
The returned size is always positive: when the source rectangle is
596+
defined on a reversed axis, ``src_w`` and/or ``src_h`` may be
597+
negative. The original (pixel) size is intrinsically positive,
598+
independent of axis orientation.
592599
"""
600+
src_w, src_h = abs(src_w), abs(src_h)
593601
trparams = [item.get_transform() for item in items if isinstance(item, TrImageItem)]
594602
if trparams:
595603
dx_max = max([dx for _x, _y, _angle, dx, _dy, _hf, _vf in trparams])
@@ -598,6 +606,54 @@ def compute_trimageitems_original_size(
598606
return src_w, src_h
599607

600608

609+
def compute_image_items_original_size(
610+
items: list[BaseImageItem],
611+
plot: qwt.plot.QwtPlot,
612+
p0: QPointF,
613+
p1: QPointF,
614+
) -> tuple[float, float]:
615+
"""Compute the original (pixel) size of a rectangular selection across the
616+
given image items.
617+
618+
The size is computed in **pixel coordinates** (independent of axis
619+
orientation or scaling), by projecting the canvas points ``p0`` and ``p1``
620+
on each item's pixel grid via :meth:`BaseImageItem.get_pixel_coordinates`.
621+
622+
Args:
623+
plot: Plot
624+
items: List of image items in the selection
625+
p0: First canvas point (top-left, in canvas coordinates)
626+
p1: Second canvas point (bottom-right, in canvas coordinates)
627+
628+
Returns:
629+
Tuple ``(width, height)`` in pixels (always positive). When no
630+
compatible item is found, falls back to the absolute axis-units
631+
size of the selection.
632+
"""
633+
p0x = plot.invTransform(X_BOTTOM, p0.x())
634+
p0y = plot.invTransform(Y_LEFT, p0.y())
635+
p1x = plot.invTransform(X_BOTTOM, p1.x() + 1)
636+
p1y = plot.invTransform(Y_LEFT, p1.y() + 1)
637+
widths: list[float] = []
638+
heights: list[float] = []
639+
for item in items:
640+
get_pix = getattr(item, "get_pixel_coordinates", None)
641+
if get_pix is None:
642+
continue
643+
try:
644+
x0p, y0p = get_pix(p0x, p0y)
645+
x1p, y1p = get_pix(p1x, p1y)
646+
except (ValueError, TypeError, IndexError):
647+
continue
648+
widths.append(abs(x1p - x0p))
649+
heights.append(abs(y1p - y0p))
650+
if widths:
651+
return max(widths), max(heights)
652+
# Fallback: axis-units size (always positive)
653+
_src_x, _src_y, src_w, src_h = get_plot_qrect(plot, p0, p1).getRect()
654+
return abs(src_w), abs(src_h)
655+
656+
601657
def get_image_from_qrect(
602658
plot: BasePlot,
603659
p0: QPointF,
@@ -636,12 +692,12 @@ def get_image_from_qrect(
636692
if not items:
637693
raise TypeError(_("There is no supported image item in current plot."))
638694
if src_size is None:
639-
_src_x, _src_y, src_w, src_h = get_plot_qrect(plot, p0, p1).getRect()
695+
destw, desth = compute_image_items_original_size(items, plot, p0, p1)
640696
else:
641697
# The only benefit to pass the src_size list is to avoid any
642698
# rounding error in the transformation computed in `get_plot_qrect`
643699
src_w, src_h = src_size
644-
destw, desth = compute_trimageitems_original_size(items, src_w, src_h)
700+
destw, desth = compute_trimageitems_original_size(items, src_w, src_h)
645701
data = get_image_from_plot(
646702
plot,
647703
p0,
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
# -*- coding: utf-8 -*-
2+
#
3+
# Licensed under the terms of the BSD 3-Clause
4+
# (see plotpy/LICENSE for details)
5+
6+
"""Unit tests for the rectangular snapshot tool's "Original size" computation.
7+
8+
This test reproduces the issue reported in PlotPyStack/PlotPy#57:
9+
10+
The "Original size" option in the rectangular snapshot tool does not behave
11+
correctly under certain conditions:
12+
13+
* When the Y axis is not reversed (image-style plot)
14+
* When the X axis is reversed
15+
* When using an XYImageItem
16+
17+
In these cases, the "Original size" preview may display negative values or
18+
incorrect dimensions. The computation appears to rely on axis scaling
19+
rather than pixel coordinates, particularly for ``XYImageItem``.
20+
21+
Additionally, a ``ValueError`` is raised when either axis leads to negative
22+
values.
23+
"""
24+
25+
from __future__ import annotations
26+
27+
import numpy as np
28+
import pytest
29+
from guidata.qthelpers import qt_app_context
30+
from qtpy import QtCore as QC
31+
32+
from plotpy.builder import make
33+
from plotpy.items import (
34+
compute_image_items_original_size,
35+
compute_trimageitems_original_size,
36+
)
37+
from plotpy.tests import vistools as ptv
38+
39+
# Image used by the tests (rows = Y, cols = X)
40+
NB_ROWS, NB_COLS = 100, 200
41+
42+
43+
def _canvas_points(plot, x0_plot, y0_plot, x1_plot, y1_plot):
44+
"""Convert plot-coordinate corners into canvas QPointF, the way the
45+
snapshot tool builds them from a rubber-band rectangle."""
46+
from plotpy.constants import X_BOTTOM, Y_LEFT
47+
48+
x0c = plot.transform(X_BOTTOM, x0_plot)
49+
x1c = plot.transform(X_BOTTOM, x1_plot)
50+
y0c = plot.transform(Y_LEFT, y0_plot)
51+
y1c = plot.transform(Y_LEFT, y1_plot)
52+
# Mimic the tool: p0 is top-left, p1 is bottom-right (in canvas pixels)
53+
p0 = QC.QPointF(min(x0c, x1c), min(y0c, y1c))
54+
p1 = QC.QPointF(max(x0c, x1c), max(y0c, y1c))
55+
return p0, p1
56+
57+
58+
def test_compute_trimageitems_original_size_handles_reversed_axes():
59+
"""Regression: ``compute_trimageitems_original_size`` must return positive
60+
dimensions even when the source rectangle is given with negative width or
61+
height (which happens on reversed axes)."""
62+
# No items: legacy fallback path
63+
w, h = compute_trimageitems_original_size([], -50.0, -25.0)
64+
assert w == 50.0 and h == 25.0
65+
66+
67+
def _expected_pixel_size(x0, y0, x1, y1):
68+
"""Original (pixel) size for a selection on a non-transformed image
69+
spanning [0, NB_COLS] x [0, NB_ROWS] in plot units."""
70+
return abs(x1 - x0), abs(y1 - y0)
71+
72+
73+
@pytest.mark.parametrize(
74+
"xreverse,yreverse",
75+
[(False, False), (False, True), (True, False), (True, True)],
76+
)
77+
def test_snapshot_original_size_with_image_item(xreverse, yreverse):
78+
"""Original size must be positive and equal to the pixel selection size,
79+
regardless of axis orientation, for a regular ``ImageItem``."""
80+
data = np.arange(NB_ROWS * NB_COLS, dtype=np.float64).reshape(NB_ROWS, NB_COLS)
81+
with qt_app_context(exec_loop=False):
82+
image = make.image(data)
83+
win = ptv.show_items([image], plot_type="image", auto_tools=False)
84+
plot = win.manager.get_plot()
85+
plot.set_axis_direction("bottom", xreverse)
86+
plot.set_axis_direction("left", yreverse)
87+
plot.replot()
88+
89+
# Selection in plot coordinates: a 40x30 pixel rectangle
90+
x0, y0, x1, y1 = 30.0, 20.0, 70.0, 50.0
91+
p0, p1 = _canvas_points(plot, x0, y0, x1, y1)
92+
93+
width, height = compute_image_items_original_size([image], plot, p0, p1)
94+
95+
exp_w, exp_h = _expected_pixel_size(x0, y0, x1, y1)
96+
# Allow 1 pixel tolerance for canvas rounding
97+
assert width > 0 and height > 0
98+
assert abs(width - exp_w) <= 1.5
99+
assert abs(height - exp_h) <= 1.5
100+
win.close()
101+
102+
103+
def test_snapshot_original_size_with_xy_image_item():
104+
"""For an ``XYImageItem``, the original size must be expressed in **pixel**
105+
coordinates (independent of axis scaling), not in axis units."""
106+
data = np.arange(NB_ROWS * NB_COLS, dtype=np.float64).reshape(NB_ROWS, NB_COLS)
107+
# Non-trivial axis scaling: 1 pixel == 5 axis units (X), 2 axis units (Y)
108+
x = np.linspace(0.0, NB_COLS * 5.0, NB_COLS + 1)
109+
y = np.linspace(0.0, NB_ROWS * 2.0, NB_ROWS + 1)
110+
with qt_app_context(exec_loop=False):
111+
image = make.xyimage(x, y, data)
112+
win = ptv.show_items([image], plot_type="image", auto_tools=False)
113+
plot = win.manager.get_plot()
114+
plot.replot()
115+
116+
# Selection spanning ~40 columns and ~30 rows in pixel space:
117+
x0, x1 = 30.0 * 5.0, 70.0 * 5.0 # 40 columns
118+
y0, y1 = 20.0 * 2.0, 50.0 * 2.0 # 30 rows
119+
p0, p1 = _canvas_points(plot, x0, y0, x1, y1)
120+
121+
width, height = compute_image_items_original_size([image], plot, p0, p1)
122+
123+
# Must be in pixel units, not axis units (axis units would give
124+
# ~200 x ~60 instead of ~40 x ~30)
125+
assert width > 0 and height > 0
126+
assert abs(width - 40) <= 5
127+
assert abs(height - 30) <= 5
128+
win.close()

plotpy/tools/misc.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
from plotpy.config import _
1616
from plotpy.interfaces import IImageItemType
1717
from plotpy.items import (
18-
compute_trimageitems_original_size,
18+
compute_image_items_original_size,
1919
get_image_from_plot,
2020
get_items_in_rectangle,
2121
get_plot_qrect,
@@ -86,10 +86,13 @@ def save_snapshot(plot, p0, p1, new_size=None):
8686
)
8787
return
8888
src_x, src_y, src_w, src_h = get_plot_qrect(plot, p0, p1).getRect()
89-
original_size = compute_trimageitems_original_size(items, src_w, src_h)
89+
original_size = compute_image_items_original_size(items, plot, p0, p1)
9090

9191
if new_size is None:
92-
new_size = (int(p1.x() - p0.x() + 1), int(p1.y() - p0.y() + 1)) # Screen size
92+
new_size = (
93+
int(abs(p1.x() - p0.x()) + 1),
94+
int(abs(p1.y() - p0.y()) + 1),
95+
) # Screen size
9396

9497
dlg = ResizeDialog(
9598
plot, new_size=new_size, old_size=original_size, text=_("Destination size:")

0 commit comments

Comments
 (0)