From 2d9124e025a5a14d576221dc797e77fe36ed6977 Mon Sep 17 00:00:00 2001
From: Pierre Raybaut
Date: Wed, 22 Apr 2026 09:49:58 +0200
Subject: [PATCH 1/7] bump version to 2.9.1
---
plotpy/__init__.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/plotpy/__init__.py b/plotpy/__init__.py
index 089908a..9d8905d 100644
--- a/plotpy/__init__.py
+++ b/plotpy/__init__.py
@@ -20,7 +20,7 @@
.. _GitHub: https://github.com/PierreRaybaut/plotpy
"""
-__version__ = "2.9.0"
+__version__ = "2.9.1"
__VERSION__ = tuple([int(number) for number in __version__.split(".")])
# --- Important note: DATAPATH and LOCALEPATH are used by guidata.configtools
From 7b38c013d2806b2e73b3cbde00a7867e3f831390 Mon Sep 17 00:00:00 2001
From: Pierre Raybaut
Date: Wed, 22 Apr 2026 09:57:01 +0200
Subject: [PATCH 2/7] 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.
---
doc/release_notes/release_2.09.md | 14 ++
plotpy/items/__init__.py | 1 +
plotpy/items/image/__init__.py | 1 +
plotpy/items/image/misc.py | 68 +++++++++-
.../tests/unit/test_snapshot_original_size.py | 128 ++++++++++++++++++
plotpy/tools/misc.py | 9 +-
6 files changed, 212 insertions(+), 9 deletions(-)
create mode 100644 plotpy/tests/unit/test_snapshot_original_size.py
diff --git a/doc/release_notes/release_2.09.md b/doc/release_notes/release_2.09.md
index 866a9ff..7241faa 100644
--- a/doc/release_notes/release_2.09.md
+++ b/doc/release_notes/release_2.09.md
@@ -1,5 +1,19 @@
# Version 2.9 #
+## PlotPy Version 2.9.1 ##
+
+🛠️ Bug fixes:
+
+* Fixed the rectangular snapshot tool's "Original size" computation. This closes
+ [Issue #57](https://github.com/PlotPyStack/PlotPy/issues/57):
+ * The preview no longer displays negative dimensions when the X or Y axis is
+ reversed
+ * The "Original size" is now computed from pixel coordinates instead of axis
+ units, so it is correct for `XYImageItem` (and any item with non-uniform
+ axis scaling) regardless of axis orientation
+ * The `ValueError` raised by the resize dialog when the selection produced
+ negative dimensions on a reversed axis is gone
+
## PlotPy Version 2.9.0 ##
💥 New features:
diff --git a/plotpy/items/__init__.py b/plotpy/items/__init__.py
index 26683fa..31bda93 100644
--- a/plotpy/items/__init__.py
+++ b/plotpy/items/__init__.py
@@ -33,6 +33,7 @@
XYImageFilterItem,
XYImageItem,
assemble_imageitems,
+ compute_image_items_original_size,
compute_trimageitems_original_size,
get_image_from_plot,
get_image_from_qrect,
diff --git a/plotpy/items/image/__init__.py b/plotpy/items/image/__init__.py
index a1cb212..94f39e8 100644
--- a/plotpy/items/image/__init__.py
+++ b/plotpy/items/image/__init__.py
@@ -11,6 +11,7 @@
Histogram2DItem,
QuadGridItem,
assemble_imageitems,
+ compute_image_items_original_size,
compute_trimageitems_original_size,
get_image_from_plot,
get_image_from_qrect,
diff --git a/plotpy/items/image/misc.py b/plotpy/items/image/misc.py
index 803adbd..15367b8 100644
--- a/plotpy/items/image/misc.py
+++ b/plotpy/items/image/misc.py
@@ -577,19 +577,27 @@ def get_items_in_rectangle(
def compute_trimageitems_original_size(
items: list[TrImageItem],
- src_w: list[float, float, float, float],
- src_h: list[float, float, float, float],
+ src_w: float,
+ src_h: float,
) -> tuple[float, float]:
"""Compute `TrImageItem` original size from max dx and dy
Args:
items: List of image items
- src_w: Source width
- src_h: Source height
+ src_w: Source width (in plot axis units)
+ src_h: Source height (in plot axis units)
Returns:
Tuple of original size
+
+ .. note::
+
+ The returned size is always positive: when the source rectangle is
+ defined on a reversed axis, ``src_w`` and/or ``src_h`` may be
+ negative. The original (pixel) size is intrinsically positive,
+ independent of axis orientation.
"""
+ src_w, src_h = abs(src_w), abs(src_h)
trparams = [item.get_transform() for item in items if isinstance(item, TrImageItem)]
if trparams:
dx_max = max([dx for _x, _y, _angle, dx, _dy, _hf, _vf in trparams])
@@ -598,6 +606,54 @@ def compute_trimageitems_original_size(
return src_w, src_h
+def compute_image_items_original_size(
+ items: list[BaseImageItem],
+ plot: qwt.plot.QwtPlot,
+ p0: QPointF,
+ p1: QPointF,
+) -> tuple[float, float]:
+ """Compute the original (pixel) size of a rectangular selection across the
+ given image items.
+
+ The size is computed in **pixel coordinates** (independent of axis
+ orientation or scaling), by projecting the canvas points ``p0`` and ``p1``
+ on each item's pixel grid via :meth:`BaseImageItem.get_pixel_coordinates`.
+
+ Args:
+ plot: Plot
+ items: List of image items in the selection
+ p0: First canvas point (top-left, in canvas coordinates)
+ p1: Second canvas point (bottom-right, in canvas coordinates)
+
+ Returns:
+ Tuple ``(width, height)`` in pixels (always positive). When no
+ compatible item is found, falls back to the absolute axis-units
+ size of the selection.
+ """
+ p0x = plot.invTransform(X_BOTTOM, p0.x())
+ p0y = plot.invTransform(Y_LEFT, p0.y())
+ p1x = plot.invTransform(X_BOTTOM, p1.x() + 1)
+ p1y = plot.invTransform(Y_LEFT, p1.y() + 1)
+ widths: list[float] = []
+ heights: list[float] = []
+ for item in items:
+ get_pix = getattr(item, "get_pixel_coordinates", None)
+ if get_pix is None:
+ continue
+ try:
+ x0p, y0p = get_pix(p0x, p0y)
+ x1p, y1p = get_pix(p1x, p1y)
+ except (ValueError, TypeError, IndexError):
+ continue
+ widths.append(abs(x1p - x0p))
+ heights.append(abs(y1p - y0p))
+ if widths:
+ return max(widths), max(heights)
+ # Fallback: axis-units size (always positive)
+ _src_x, _src_y, src_w, src_h = get_plot_qrect(plot, p0, p1).getRect()
+ return abs(src_w), abs(src_h)
+
+
def get_image_from_qrect(
plot: BasePlot,
p0: QPointF,
@@ -636,12 +692,12 @@ def get_image_from_qrect(
if not items:
raise TypeError(_("There is no supported image item in current plot."))
if src_size is None:
- _src_x, _src_y, src_w, src_h = get_plot_qrect(plot, p0, p1).getRect()
+ destw, desth = compute_image_items_original_size(items, plot, p0, p1)
else:
# The only benefit to pass the src_size list is to avoid any
# rounding error in the transformation computed in `get_plot_qrect`
src_w, src_h = src_size
- destw, desth = compute_trimageitems_original_size(items, src_w, src_h)
+ destw, desth = compute_trimageitems_original_size(items, src_w, src_h)
data = get_image_from_plot(
plot,
p0,
diff --git a/plotpy/tests/unit/test_snapshot_original_size.py b/plotpy/tests/unit/test_snapshot_original_size.py
new file mode 100644
index 0000000..af330c6
--- /dev/null
+++ b/plotpy/tests/unit/test_snapshot_original_size.py
@@ -0,0 +1,128 @@
+# -*- coding: utf-8 -*-
+#
+# Licensed under the terms of the BSD 3-Clause
+# (see plotpy/LICENSE for details)
+
+"""Unit tests for the rectangular snapshot tool's "Original size" computation.
+
+This test reproduces the issue reported in PlotPyStack/PlotPy#57:
+
+ The "Original size" option in the rectangular snapshot tool does not behave
+ correctly under certain conditions:
+
+ * When the Y axis is not reversed (image-style plot)
+ * When the X axis is reversed
+ * When using an XYImageItem
+
+ In these cases, the "Original size" preview may display negative values or
+ incorrect dimensions. The computation appears to rely on axis scaling
+ rather than pixel coordinates, particularly for ``XYImageItem``.
+
+ Additionally, a ``ValueError`` is raised when either axis leads to negative
+ values.
+"""
+
+from __future__ import annotations
+
+import numpy as np
+import pytest
+from guidata.qthelpers import qt_app_context
+from qtpy import QtCore as QC
+
+from plotpy.builder import make
+from plotpy.items import (
+ compute_image_items_original_size,
+ compute_trimageitems_original_size,
+)
+from plotpy.tests import vistools as ptv
+
+# Image used by the tests (rows = Y, cols = X)
+NB_ROWS, NB_COLS = 100, 200
+
+
+def _canvas_points(plot, x0_plot, y0_plot, x1_plot, y1_plot):
+ """Convert plot-coordinate corners into canvas QPointF, the way the
+ snapshot tool builds them from a rubber-band rectangle."""
+ from plotpy.constants import X_BOTTOM, Y_LEFT
+
+ x0c = plot.transform(X_BOTTOM, x0_plot)
+ x1c = plot.transform(X_BOTTOM, x1_plot)
+ y0c = plot.transform(Y_LEFT, y0_plot)
+ y1c = plot.transform(Y_LEFT, y1_plot)
+ # Mimic the tool: p0 is top-left, p1 is bottom-right (in canvas pixels)
+ p0 = QC.QPointF(min(x0c, x1c), min(y0c, y1c))
+ p1 = QC.QPointF(max(x0c, x1c), max(y0c, y1c))
+ return p0, p1
+
+
+def test_compute_trimageitems_original_size_handles_reversed_axes():
+ """Regression: ``compute_trimageitems_original_size`` must return positive
+ dimensions even when the source rectangle is given with negative width or
+ height (which happens on reversed axes)."""
+ # No items: legacy fallback path
+ w, h = compute_trimageitems_original_size([], -50.0, -25.0)
+ assert w == 50.0 and h == 25.0
+
+
+def _expected_pixel_size(x0, y0, x1, y1):
+ """Original (pixel) size for a selection on a non-transformed image
+ spanning [0, NB_COLS] x [0, NB_ROWS] in plot units."""
+ return abs(x1 - x0), abs(y1 - y0)
+
+
+@pytest.mark.parametrize(
+ "xreverse,yreverse",
+ [(False, False), (False, True), (True, False), (True, True)],
+)
+def test_snapshot_original_size_with_image_item(xreverse, yreverse):
+ """Original size must be positive and equal to the pixel selection size,
+ regardless of axis orientation, for a regular ``ImageItem``."""
+ data = np.arange(NB_ROWS * NB_COLS, dtype=np.float64).reshape(NB_ROWS, NB_COLS)
+ with qt_app_context(exec_loop=False):
+ image = make.image(data)
+ win = ptv.show_items([image], plot_type="image", auto_tools=False)
+ plot = win.manager.get_plot()
+ plot.set_axis_direction("bottom", xreverse)
+ plot.set_axis_direction("left", yreverse)
+ plot.replot()
+
+ # Selection in plot coordinates: a 40x30 pixel rectangle
+ x0, y0, x1, y1 = 30.0, 20.0, 70.0, 50.0
+ p0, p1 = _canvas_points(plot, x0, y0, x1, y1)
+
+ width, height = compute_image_items_original_size([image], plot, p0, p1)
+
+ exp_w, exp_h = _expected_pixel_size(x0, y0, x1, y1)
+ # Allow 1 pixel tolerance for canvas rounding
+ assert width > 0 and height > 0
+ assert abs(width - exp_w) <= 1.5
+ assert abs(height - exp_h) <= 1.5
+ win.close()
+
+
+def test_snapshot_original_size_with_xy_image_item():
+ """For an ``XYImageItem``, the original size must be expressed in **pixel**
+ coordinates (independent of axis scaling), not in axis units."""
+ data = np.arange(NB_ROWS * NB_COLS, dtype=np.float64).reshape(NB_ROWS, NB_COLS)
+ # Non-trivial axis scaling: 1 pixel == 5 axis units (X), 2 axis units (Y)
+ x = np.linspace(0.0, NB_COLS * 5.0, NB_COLS + 1)
+ y = np.linspace(0.0, NB_ROWS * 2.0, NB_ROWS + 1)
+ with qt_app_context(exec_loop=False):
+ image = make.xyimage(x, y, data)
+ win = ptv.show_items([image], plot_type="image", auto_tools=False)
+ plot = win.manager.get_plot()
+ plot.replot()
+
+ # Selection spanning ~40 columns and ~30 rows in pixel space:
+ x0, x1 = 30.0 * 5.0, 70.0 * 5.0 # 40 columns
+ y0, y1 = 20.0 * 2.0, 50.0 * 2.0 # 30 rows
+ p0, p1 = _canvas_points(plot, x0, y0, x1, y1)
+
+ width, height = compute_image_items_original_size([image], plot, p0, p1)
+
+ # Must be in pixel units, not axis units (axis units would give
+ # ~200 x ~60 instead of ~40 x ~30)
+ assert width > 0 and height > 0
+ assert abs(width - 40) <= 5
+ assert abs(height - 30) <= 5
+ win.close()
diff --git a/plotpy/tools/misc.py b/plotpy/tools/misc.py
index 6c1c39f..47c1ff5 100644
--- a/plotpy/tools/misc.py
+++ b/plotpy/tools/misc.py
@@ -15,7 +15,7 @@
from plotpy.config import _
from plotpy.interfaces import IImageItemType
from plotpy.items import (
- compute_trimageitems_original_size,
+ compute_image_items_original_size,
get_image_from_plot,
get_items_in_rectangle,
get_plot_qrect,
@@ -86,10 +86,13 @@ def save_snapshot(plot, p0, p1, new_size=None):
)
return
src_x, src_y, src_w, src_h = get_plot_qrect(plot, p0, p1).getRect()
- original_size = compute_trimageitems_original_size(items, src_w, src_h)
+ original_size = compute_image_items_original_size(items, plot, p0, p1)
if new_size is None:
- new_size = (int(p1.x() - p0.x() + 1), int(p1.y() - p0.y() + 1)) # Screen size
+ new_size = (
+ int(abs(p1.x() - p0.x()) + 1),
+ int(abs(p1.y() - p0.y()) + 1),
+ ) # Screen size
dlg = ResizeDialog(
plot, new_size=new_size, old_size=original_size, text=_("Destination size:")
From dc4c23eb4d67a15e0c1d95b5620b0cf1d3ba325c Mon Sep 17 00:00:00 2001
From: Pierre Raybaut
Date: Wed, 22 Apr 2026 14:45:15 +0200
Subject: [PATCH 3/7] feat: add edge width parameter to SymbolParam for
customizable border thickness
---
plotpy/styles/base.py | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/plotpy/styles/base.py b/plotpy/styles/base.py
index 1df2f7f..4796335 100644
--- a/plotpy/styles/base.py
+++ b/plotpy/styles/base.py
@@ -368,6 +368,7 @@ class SymbolParam(DataSet):
marker = ImageChoiceItem(_("Style"), MARKER_CHOICES, default="NoSymbol")
size = IntItem(_("Size"), default=9)
edgecolor = ColorItem(_("Border"), default="gray")
+ edgewidth = FloatItem(_("Border width"), default=1.0, min=0.0)
facecolor = ColorItem(_("Background color"), default="yellow")
alpha = FloatItem(_("Background alpha"), default=1.0, min=0, max=1)
@@ -386,6 +387,7 @@ def update_param(self, symb):
self.marker = MARKER_NAME[symb.style()]
self.size = int(symb.size().width())
self.edgecolor = str(symb.pen().color().name())
+ self.edgewidth = float(symb.pen().widthF())
self.facecolor = str(symb.brush().color().name())
def build_symbol(self):
@@ -396,10 +398,12 @@ def build_symbol(self):
marker_type = getattr(QwtSymbol, self.marker)
color = QG.QColor(self.facecolor)
color.setAlphaF(self.alpha)
+ pen = QG.QPen(QG.QColor(self.edgecolor))
+ pen.setWidthF(self.edgewidth)
marker = QwtSymbol(
marker_type,
QG.QBrush(color),
- QG.QPen(QG.QColor(self.edgecolor)),
+ pen,
QC.QSizeF(self.size, self.size),
)
return marker
From 1a49db06bd8e40ce583c66447017cdbdde7bf534 Mon Sep 17 00:00:00 2001
From: Pierre Raybaut
Date: Thu, 23 Apr 2026 17:45:43 +0200
Subject: [PATCH 4/7] fix: ensure consistent original size reporting for
selections larger than plotted images in ImageItem and XYImageItem
Ref. #57
---
doc/release_notes/release_2.09.md | 5 ++
plotpy/items/image/misc.py | 53 +++++++++++++++----
.../tests/unit/test_snapshot_original_size.py | 35 ++++++++++++
3 files changed, 82 insertions(+), 11 deletions(-)
diff --git a/doc/release_notes/release_2.09.md b/doc/release_notes/release_2.09.md
index 7241faa..c9d27f7 100644
--- a/doc/release_notes/release_2.09.md
+++ b/doc/release_notes/release_2.09.md
@@ -13,6 +13,11 @@
axis scaling) regardless of axis orientation
* The `ValueError` raised by the resize dialog when the selection produced
negative dimensions on a reversed axis is gone
+ * Selecting a region larger than the plotted image now consistently reports
+ the full image size for both `ImageItem` and `XYImageItem` (previously
+ `XYImageItem` reported ``shape - 1`` and `ImageItem` reported an
+ oversized value): the selection is now clipped to the image's bounding
+ rectangle before computing the original size
## PlotPy Version 2.9.0 ##
diff --git a/plotpy/items/image/misc.py b/plotpy/items/image/misc.py
index 15367b8..fd737ae 100644
--- a/plotpy/items/image/misc.py
+++ b/plotpy/items/image/misc.py
@@ -616,8 +616,11 @@ def compute_image_items_original_size(
given image items.
The size is computed in **pixel coordinates** (independent of axis
- orientation or scaling), by projecting the canvas points ``p0`` and ``p1``
- on each item's pixel grid via :meth:`BaseImageItem.get_pixel_coordinates`.
+ orientation or scaling). The selection is first clipped to each item's
+ bounding rectangle (in plot coordinates), so that a selection larger
+ than the plotted image is treated as a selection of the image itself,
+ and all item types (``ImageItem``, ``XYImageItem``, ``TrImageItem``)
+ give consistent results.
Args:
plot: Plot
@@ -634,19 +637,47 @@ def compute_image_items_original_size(
p0y = plot.invTransform(Y_LEFT, p0.y())
p1x = plot.invTransform(X_BOTTOM, p1.x() + 1)
p1y = plot.invTransform(Y_LEFT, p1.y() + 1)
+ sel_x0, sel_x1 = sorted([p0x, p1x])
+ sel_y0, sel_y1 = sorted([p0y, p1y])
widths: list[float] = []
heights: list[float] = []
for item in items:
- get_pix = getattr(item, "get_pixel_coordinates", None)
- if get_pix is None:
+ data = getattr(item, "data", None)
+ if data is None:
continue
- try:
- x0p, y0p = get_pix(p0x, p0y)
- x1p, y1p = get_pix(p1x, p1y)
- except (ValueError, TypeError, IndexError):
- continue
- widths.append(abs(x1p - x0p))
- heights.append(abs(y1p - y0p))
+ # Clip selection to the item's bounding rect (in plot coordinates)
+ # so that a selection larger than the plotted image yields the full
+ # image size, consistently across item types.
+ brect = item.boundingRect()
+ x_min, x_max = sorted([brect.left(), brect.right()])
+ y_min, y_max = sorted([brect.top(), brect.bottom()])
+ cx0 = max(sel_x0, x_min)
+ cx1 = min(sel_x1, x_max)
+ cy0 = max(sel_y0, y_min)
+ cy1 = min(sel_y1, y_max)
+ if cx1 <= cx0 or cy1 <= cy0:
+ continue # no overlap
+ if isinstance(item, TrImageItem):
+ # Use the item's affine transform (no clamping, handles rotation)
+ get_pix = item.get_pixel_coordinates
+ try:
+ x0p, y0p = get_pix(cx0, cy0)
+ x1p, y1p = get_pix(cx1, cy1)
+ except (ValueError, TypeError, IndexError):
+ continue
+ widths.append(abs(x1p - x0p))
+ heights.append(abs(y1p - y0p))
+ else:
+ # For ImageItem / XYImageItem, use the fraction of the bounding
+ # rect covered by the clipped selection. This gives a consistent
+ # result even when pixel coordinate helpers clamp to integer
+ # indices (as XYImageItem does).
+ bw = x_max - x_min
+ bh = y_max - y_min
+ if bw <= 0 or bh <= 0:
+ continue
+ widths.append((cx1 - cx0) / bw * data.shape[1])
+ heights.append((cy1 - cy0) / bh * data.shape[0])
if widths:
return max(widths), max(heights)
# Fallback: axis-units size (always positive)
diff --git a/plotpy/tests/unit/test_snapshot_original_size.py b/plotpy/tests/unit/test_snapshot_original_size.py
index af330c6..d8efd1f 100644
--- a/plotpy/tests/unit/test_snapshot_original_size.py
+++ b/plotpy/tests/unit/test_snapshot_original_size.py
@@ -126,3 +126,38 @@ def test_snapshot_original_size_with_xy_image_item():
assert abs(width - 40) <= 5
assert abs(height - 30) <= 5
win.close()
+
+
+@pytest.mark.parametrize("make_factory", ["image", "xyimage"])
+def test_snapshot_original_size_selection_larger_than_image(make_factory):
+ """When the selection is larger than the plotted image, the "Original size"
+ must correspond to the clipped intersection with the image (i.e. the full
+ image when the selection fully covers it), consistently for both
+ ``ImageItem`` and ``XYImageItem``.
+
+ Regression for the off-by-one inconsistency reported in issue #57
+ (99x99 for XYImageItem vs. oversized for ImageItem on a 100x100 image).
+ """
+ data = np.arange(NB_ROWS * NB_COLS, dtype=np.float64).reshape(NB_ROWS, NB_COLS)
+ with qt_app_context(exec_loop=False):
+ if make_factory == "image":
+ image = make.image(data)
+ else:
+ x = np.linspace(0.0, float(NB_COLS), NB_COLS + 1)
+ y = np.linspace(0.0, float(NB_ROWS), NB_ROWS + 1)
+ image = make.xyimage(x, y, data)
+ win = ptv.show_items([image], plot_type="image", auto_tools=False)
+ plot = win.manager.get_plot()
+ plot.replot()
+
+ # Selection much larger than the image (negative lower bound, upper
+ # bound well beyond the image):
+ x0, y0, x1, y1 = -50.0, -50.0, NB_COLS + 50.0, NB_ROWS + 50.0
+ p0, p1 = _canvas_points(plot, x0, y0, x1, y1)
+
+ width, height = compute_image_items_original_size([image], plot, p0, p1)
+
+ # Must match the full image size exactly (not shape - 1, not oversize)
+ assert abs(width - NB_COLS) <= 1
+ assert abs(height - NB_ROWS) <= 1
+ win.close()
From b25614cefa0a05b0651cf36488824a165a39c061 Mon Sep 17 00:00:00 2001
From: Pierre Raybaut
Date: Fri, 24 Apr 2026 10:20:48 +0200
Subject: [PATCH 5/7] Fix snapshot tool's "Original size" to preserve native
pixel resolution
Follow-up to the initial #57 fix
Co-authored-by: Copilot
---
doc/release_notes/release_2.09.md | 11 ++--
plotpy/items/image/misc.py | 60 +++++++++----------
.../tests/unit/test_snapshot_original_size.py | 24 +++++---
3 files changed, 49 insertions(+), 46 deletions(-)
diff --git a/doc/release_notes/release_2.09.md b/doc/release_notes/release_2.09.md
index c9d27f7..2c38734 100644
--- a/doc/release_notes/release_2.09.md
+++ b/doc/release_notes/release_2.09.md
@@ -13,11 +13,12 @@
axis scaling) regardless of axis orientation
* The `ValueError` raised by the resize dialog when the selection produced
negative dimensions on a reversed axis is gone
- * Selecting a region larger than the plotted image now consistently reports
- the full image size for both `ImageItem` and `XYImageItem` (previously
- `XYImageItem` reported ``shape - 1`` and `ImageItem` reported an
- oversized value): the selection is now clipped to the image's bounding
- rectangle before computing the original size
+ * Selecting a region larger than the plotted image now reports the same
+ native pixel resolution for both `ImageItem` and `XYImageItem`
+ (previously `XYImageItem` reported ``shape - 1`` while `ImageItem`
+ reported the full oversized resolution): exporting at "Original size"
+ now consistently preserves the source pixel density and avoids
+ upsampling, regardless of the item type
## PlotPy Version 2.9.0 ##
diff --git a/plotpy/items/image/misc.py b/plotpy/items/image/misc.py
index fd737ae..1853671 100644
--- a/plotpy/items/image/misc.py
+++ b/plotpy/items/image/misc.py
@@ -612,15 +612,18 @@ def compute_image_items_original_size(
p0: QPointF,
p1: QPointF,
) -> tuple[float, float]:
- """Compute the original (pixel) size of a rectangular selection across the
- given image items.
-
- The size is computed in **pixel coordinates** (independent of axis
- orientation or scaling). The selection is first clipped to each item's
- bounding rectangle (in plot coordinates), so that a selection larger
- than the plotted image is treated as a selection of the image itself,
- and all item types (``ImageItem``, ``XYImageItem``, ``TrImageItem``)
- give consistent results.
+ """Compute the **native pixel resolution** of a rectangular selection
+ across the given image items.
+
+ The "Original size" semantics is *original resolution*: the returned
+ size is the number of source pixels that span the selection at the
+ item's native resolution, *independent of axis orientation or scaling*.
+ When the selection is larger than the plotted image, the returned size
+ is consequently larger than the image (the missing area will be padded
+ by the export step). When the selection is smaller, it is smaller in
+ pixels — there is **no** clipping to the image bounding rectangle, so
+ that exporting at "Original size" always preserves the source pixel
+ density.
Args:
plot: Plot
@@ -639,45 +642,38 @@ def compute_image_items_original_size(
p1y = plot.invTransform(Y_LEFT, p1.y() + 1)
sel_x0, sel_x1 = sorted([p0x, p1x])
sel_y0, sel_y1 = sorted([p0y, p1y])
+ sel_w = sel_x1 - sel_x0
+ sel_h = sel_y1 - sel_y0
widths: list[float] = []
heights: list[float] = []
for item in items:
data = getattr(item, "data", None)
if data is None:
continue
- # Clip selection to the item's bounding rect (in plot coordinates)
- # so that a selection larger than the plotted image yields the full
- # image size, consistently across item types.
- brect = item.boundingRect()
- x_min, x_max = sorted([brect.left(), brect.right()])
- y_min, y_max = sorted([brect.top(), brect.bottom()])
- cx0 = max(sel_x0, x_min)
- cx1 = min(sel_x1, x_max)
- cy0 = max(sel_y0, y_min)
- cy1 = min(sel_y1, y_max)
- if cx1 <= cx0 or cy1 <= cy0:
- continue # no overlap
if isinstance(item, TrImageItem):
- # Use the item's affine transform (no clamping, handles rotation)
+ # Use the item's affine transform (handles rotation and shear)
get_pix = item.get_pixel_coordinates
try:
- x0p, y0p = get_pix(cx0, cy0)
- x1p, y1p = get_pix(cx1, cy1)
+ x0p, y0p = get_pix(sel_x0, sel_y0)
+ x1p, y1p = get_pix(sel_x1, sel_y1)
except (ValueError, TypeError, IndexError):
continue
widths.append(abs(x1p - x0p))
heights.append(abs(y1p - y0p))
else:
- # For ImageItem / XYImageItem, use the fraction of the bounding
- # rect covered by the clipped selection. This gives a consistent
- # result even when pixel coordinate helpers clamp to integer
- # indices (as XYImageItem does).
- bw = x_max - x_min
- bh = y_max - y_min
+ # For ImageItem / XYImageItem: convert the (possibly oversized)
+ # selection to pixels via the item's own pixel density. This
+ # avoids ``XYImageItem.get_pixel_coordinates`` clamping to
+ # integer indices and yields oversized values when the
+ # selection extends beyond the image — consistently with the
+ # historical behavior of ``ImageItem``.
+ brect = item.boundingRect()
+ bw = abs(brect.width())
+ bh = abs(brect.height())
if bw <= 0 or bh <= 0:
continue
- widths.append((cx1 - cx0) / bw * data.shape[1])
- heights.append((cy1 - cy0) / bh * data.shape[0])
+ widths.append(sel_w / bw * data.shape[1])
+ heights.append(sel_h / bh * data.shape[0])
if widths:
return max(widths), max(heights)
# Fallback: axis-units size (always positive)
diff --git a/plotpy/tests/unit/test_snapshot_original_size.py b/plotpy/tests/unit/test_snapshot_original_size.py
index d8efd1f..72066e4 100644
--- a/plotpy/tests/unit/test_snapshot_original_size.py
+++ b/plotpy/tests/unit/test_snapshot_original_size.py
@@ -131,12 +131,15 @@ def test_snapshot_original_size_with_xy_image_item():
@pytest.mark.parametrize("make_factory", ["image", "xyimage"])
def test_snapshot_original_size_selection_larger_than_image(make_factory):
"""When the selection is larger than the plotted image, the "Original size"
- must correspond to the clipped intersection with the image (i.e. the full
- image when the selection fully covers it), consistently for both
- ``ImageItem`` and ``XYImageItem``.
-
- Regression for the off-by-one inconsistency reported in issue #57
- (99x99 for XYImageItem vs. oversized for ImageItem on a 100x100 image).
+ must reflect the **native pixel resolution** of the selection (i.e. the
+ number of source pixels the selection would cover at the item's pixel
+ density), not the clipped image size — so that exporting at "Original
+ size" preserves the source pixel density and the image is not upsampled.
+
+ Both ``ImageItem`` and ``XYImageItem`` must agree on this: this is the
+ consistency fix for the off-by-one / inconsistency reported in #57
+ (99x99 for XYImageItem vs. oversized for ImageItem on a 100x100 image —
+ they must now both give the same oversized value).
"""
data = np.arange(NB_ROWS * NB_COLS, dtype=np.float64).reshape(NB_ROWS, NB_COLS)
with qt_app_context(exec_loop=False):
@@ -157,7 +160,10 @@ def test_snapshot_original_size_selection_larger_than_image(make_factory):
width, height = compute_image_items_original_size([image], plot, p0, p1)
- # Must match the full image size exactly (not shape - 1, not oversize)
- assert abs(width - NB_COLS) <= 1
- assert abs(height - NB_ROWS) <= 1
+ # Native pixel resolution of the (oversized) selection: 200 px wide
+ # selection on a 100 axis-unit / 100 px image -> 200 px
+ exp_w = (x1 - x0) * NB_COLS / float(NB_COLS) # = 200
+ exp_h = (y1 - y0) * NB_ROWS / float(NB_ROWS) # = 200
+ assert abs(width - exp_w) <= 2
+ assert abs(height - exp_h) <= 2
win.close()
From 4a3eeb3dd6d459cc5d38b6797735824d79e5abcd Mon Sep 17 00:00:00 2001
From: Pierre Raybaut
Date: Fri, 24 Apr 2026 14:03:00 +0200
Subject: [PATCH 6/7] Fix snapshot tool's stuck cross cursor outside the plot
canvas
Closes #58
Co-authored-by: Copilot
---
plotpy/tools/misc.py | 28 ++++++++++++++++++++++++++++
1 file changed, 28 insertions(+)
diff --git a/plotpy/tools/misc.py b/plotpy/tools/misc.py
index 47c1ff5..e6d322d 100644
--- a/plotpy/tools/misc.py
+++ b/plotpy/tools/misc.py
@@ -221,6 +221,34 @@ def __init__(self, manager, toolbar_id=DefaultToolbarID):
manager, save_snapshot, toolbar_id=toolbar_id, fix_orientation=True
)
+ def end_rect(self, filter, p0, p1):
+ """End rect: emit ``SIG_TOOL_JOB_FINISHED`` *synchronously* so the
+ ``switch_to_default_tool`` listener restores the canvas cursor while
+ we are still inside the mouse-release event handler chain — Qt then
+ gets the chance to refresh the cursor on neighbouring widgets
+ (axes, toolbar) before any nested event loop is started by the
+ snapshot dialogs. The action function itself is deferred via a
+ zero-delay timer so the modal ``ResizeDialog`` (and following
+ dialogs) is not opened from inside the rubber-band ``mouseRelease``
+ handler chain — otherwise Qt's implicit grab is left in an unclean
+ state on Windows and the cross cursor used by the canvas during
+ the drag remains "stuck" on neighbouring widgets until the mouse
+ moves over them.
+ """
+ plot = filter.plot
+ if self.fix_orientation:
+ left, right = min(p0.x(), p1.x()), max(p0.x(), p1.x())
+ top, bottom = min(p0.y(), p1.y()), max(p0.y(), p1.y())
+ p0, p1 = QC.QPointF(left, top), QC.QPointF(right, bottom)
+ # Synchronous: cursor is restored on the canvas now, while we are
+ # still in the mouse-release handler chain.
+ self.SIG_TOOL_JOB_FINISHED.emit()
+ if self.switch_to_default_tool:
+ shape = self.get_last_final_shape()
+ plot.set_active_item(shape)
+ # Deferred: open the dialogs after Qt has cleanly released the grab.
+ QC.QTimer.singleShot(0, lambda: self.action_func(plot, p0, p1))
+
class HelpTool(CommandTool):
""" """
From 53af760831cf0b67dc55b16b95a4e60391572df7 Mon Sep 17 00:00:00 2001
From: Pierre Raybaut
Date: Fri, 24 Apr 2026 18:16:58 +0200
Subject: [PATCH 7/7] Fix Z-axis log tool being always disabled for
non-ImageItem image types
Closes #59
Move the Z-axis logarithmic scale API (`get_zaxis_log_state` /
`set_zaxis_log_state`, along with `_log_data`, `_lin_lut_range` and
`_is_zaxis_log` attributes) from `ImageItem` up to `BaseImageItem`,
so that all image item types (`XYImageItem`, `MaskedImageItem`,
`MaskedXYImageItem`, `TrImageItem`, `RGBImageItem`) inherit it.
Update `XYImageItem.draw_image` and `TrImageItem.draw_image` to use
`_log_data` when log mode is active, like `ImageItem` already did.
Loosen the `ZAxisLogTool` filter from `isinstance(item, ImageItem)`
to `isinstance(item, BaseImageItem)`. The existing `hasattr` check
keeps the filter safe for any custom item.
Previously, the tool was always disabled when the plot only contained
`XYImageItem`-derived items (e.g. `MaskedXYImageItem` used by DataLab).
Co-authored-by: Copilot
---
plotpy/items/image/base.py | 34 +++++++++++++++++++++++++++++++++
plotpy/items/image/standard.py | 33 +++++---------------------------
plotpy/items/image/transform.py | 6 +++++-
plotpy/tools/image.py | 5 ++---
4 files changed, 46 insertions(+), 32 deletions(-)
diff --git a/plotpy/items/image/base.py b/plotpy/items/image/base.py
index a33de74..b228d58 100644
--- a/plotpy/items/image/base.py
+++ b/plotpy/items/image/base.py
@@ -132,6 +132,12 @@ def __init__(
self._filename = None # The file this image comes from
self.histogram_cache = None
+
+ # Z-axis logarithmic scale support
+ self._log_data: np.ndarray | None = None
+ self._lin_lut_range: tuple[float, float] | None = None
+ self._is_zaxis_log = False
+
if data is not None:
self.set_data(data)
self.param.update_item(self)
@@ -552,6 +558,34 @@ def get_lut_range_full(self) -> tuple[float, float]:
"""
return get_nan_range(self.data)
+ # ---- Z-axis logarithmic scale --------------------------------------------
+ def get_zaxis_log_state(self) -> bool:
+ """Return True if Z-axis is in logarithmic scale"""
+ return self._is_zaxis_log
+
+ def set_zaxis_log_state(self, state: bool) -> None:
+ """Set Z-axis logarithmic scale state
+
+ Args:
+ state: True to enable logarithmic scale, False otherwise
+ """
+ self._is_zaxis_log = state
+ plot = self.plot()
+ if state:
+ self._lin_lut_range = self.get_lut_range()
+ if self._log_data is None:
+ self._log_data = np.array(np.log10(self.data.clip(1)), dtype=np.float64)
+ self.set_lut_range(get_nan_range(self._log_data))
+ dtype = self._log_data.dtype
+ else:
+ self._log_data = None
+ self.set_lut_range(self._lin_lut_range)
+ dtype = self.data.dtype
+ if self.interpolate[0] == INTERP_AA:
+ self.interpolate = (INTERP_AA, self.interpolate[1].astype(dtype))
+ if plot is not None:
+ plot.update_colormap_axis(self)
+
def get_lut_range_max(self) -> tuple[float, float]:
"""Get maximum range for this dataset
diff --git a/plotpy/items/image/standard.py b/plotpy/items/image/standard.py
index 6883cc3..c900e1a 100644
--- a/plotpy/items/image/standard.py
+++ b/plotpy/items/image/standard.py
@@ -11,7 +11,6 @@
from qtpy import QtCore as QC
from plotpy import io
-from plotpy._scaler import INTERP_AA
from plotpy.config import _
from plotpy.constants import LUTAlpha
from plotpy.coords import canvas_to_axes, pixelround
@@ -29,7 +28,6 @@
)
from plotpy.items.image.base import RawImageItem
from plotpy.items.image.filter import XYImageFilterItem, to_bins
-from plotpy.mathutils.arrayfuncs import get_nan_range
from plotpy.styles.image import ImageParam, RGBImageParam, XYImageParam
if TYPE_CHECKING:
@@ -84,9 +82,6 @@ def __init__(
self.xmax = None
self.ymin = None
self.ymax = None
- self._log_data = None
- self._lin_lut_range = None
- self._is_zaxis_log = False
super().__init__(data=data, param=param)
# ---- BaseImageItem API ---------------------------------------------------
@@ -228,28 +223,6 @@ def update_bounds(self) -> None:
(xmin, xmax), (ymin, ymax) = self.get_xdata(), self.get_ydata()
self.bounds = QC.QRectF(QC.QPointF(xmin, ymin), QC.QPointF(xmax, ymax))
- def get_zaxis_log_state(self):
- """Reimplement image.ImageItem method"""
- return self._is_zaxis_log
-
- def set_zaxis_log_state(self, state):
- """Reimplement image.ImageItem method"""
- self._is_zaxis_log = state
- plot = self.plot()
- if state:
- self._lin_lut_range = self.get_lut_range()
- if self._log_data is None:
- self._log_data = np.array(np.log10(self.data.clip(1)), dtype=np.float64)
- self.set_lut_range(get_nan_range(self._log_data))
- dtype = self._log_data.dtype
- else:
- self._log_data = None
- self.set_lut_range(self._lin_lut_range)
- dtype = self.data.dtype
- if self.interpolate[0] == INTERP_AA:
- self.interpolate = (INTERP_AA, self.interpolate[1].astype(dtype))
- plot.update_colormap_axis(self)
-
# ---- BaseImageItem API ---------------------------------------------------
def get_pixel_coordinates(self, xplot: float, yplot: float) -> tuple[float, float]:
"""Get pixel coordinates from plot coordinates
@@ -684,8 +657,12 @@ def draw_image(
return
xytr = self.x, self.y, src_rect
dst_rect = tuple([int(i) for i in dst_rect])
+ if self.get_zaxis_log_state():
+ data = self._log_data
+ else:
+ data = self.data
dest = _scale_xy(
- self.data, xytr, self._offscreen, dst_rect, self.lut, self.interpolate
+ data, xytr, self._offscreen, dst_rect, self.lut, self.interpolate
)
qrect = QC.QRectF(QC.QPointF(dest[0], dest[1]), QC.QPointF(dest[2], dest[3]))
painter.drawImage(qrect, self._image, qrect)
diff --git a/plotpy/items/image/transform.py b/plotpy/items/image/transform.py
index 7aa3787..4309fe1 100644
--- a/plotpy/items/image/transform.py
+++ b/plotpy/items/image/transform.py
@@ -274,8 +274,12 @@ def draw_image(
mat = self.tr @ tr
dst_rect = tuple([int(i) for i in dst_rect])
+ if self.get_zaxis_log_state():
+ data = self._log_data
+ else:
+ data = self.data
dest = _scale_tr(
- self.data, mat, self._offscreen, dst_rect, self.lut, self.interpolate
+ data, mat, self._offscreen, dst_rect, self.lut, self.interpolate
)
qrect = QC.QRectF(QC.QPointF(dest[0], dest[1]), QC.QPointF(dest[2], dest[3]))
painter.drawImage(qrect, self._image, qrect)
diff --git a/plotpy/tools/image.py b/plotpy/tools/image.py
index 916d9b4..bb96a2f 100644
--- a/plotpy/tools/image.py
+++ b/plotpy/tools/image.py
@@ -25,13 +25,13 @@
from plotpy.items import (
AnnotatedRectangle,
EllipseShape,
- ImageItem,
MaskedImageItem,
MaskedXYImageItem,
RectangleShape,
TrImageItem,
get_items_in_rectangle,
)
+from plotpy.items.image.base import BaseImageItem
from plotpy.mathutils.colormap import ALL_COLORMAPS, build_icon_from_cmap_name, get_cmap
from plotpy.tools.base import (
CommandTool,
@@ -54,7 +54,6 @@
from plotpy.events import StatefulEventFilter
from plotpy.interfaces.items import IBasePlotItem
- from plotpy.items.image.base import BaseImageItem
from plotpy.items.shape.base import AbstractShape
from plotpy.items.shape.polygon import PolygonShape
from plotpy.plot import BasePlot
@@ -412,7 +411,7 @@ def get_supported_items(self, plot: BasePlot) -> list[BaseImageItem]:
items = [
item
for item in plot.get_items()
- if isinstance(item, ImageItem)
+ if isinstance(item, BaseImageItem)
and not item.is_empty()
and hasattr(item, "get_zaxis_log_state")
]