From 42915bfe062cda06af338c657dda8e02b37ce3f0 Mon Sep 17 00:00:00 2001 From: Duy Anh Philippe PHAM Date: Fri, 17 Apr 2026 16:00:32 +0200 Subject: [PATCH 01/18] Fix: Sort Y values and use abs(delta) in YRangeCursorTool LABELFUNCS * [FIX] Sort ymin/ymax in range label to ensure correct inequality * [FIX] Use abs() for delta-y to prevent negative range width --- plotpy/tools/curve.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plotpy/tools/curve.py b/plotpy/tools/curve.py index 3285dcb..c92ea4a 100644 --- a/plotpy/tools/curve.py +++ b/plotpy/tools/curve.py @@ -212,8 +212,8 @@ class YRangeCursorTool(BaseRangeCursorTool): TITLE = _("Y-range") ICON = "yrange.png" LABELFUNCS: tuple[tuple[str, Callable[..., Any]], ...] = ( - ("%g < y < %g", lambda ymin, ymax: (ymin, ymax)), - ("∆y=%g", lambda ymin, ymax: ymax - ymin), + ("%g < y < %g", lambda ymin, ymax: (min(ymin, ymax), max(ymin, ymax))), + ("∆y=%g", lambda ymin, ymax: abs(ymax - ymin)), ) SHAPECLASS = YRangeSelection From 2d9124e025a5a14d576221dc797e77fe36ed6977 Mon Sep 17 00:00:00 2001 From: Pierre Raybaut Date: Wed, 22 Apr 2026 09:49:58 +0200 Subject: [PATCH 02/18] 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 03/18] 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 04/18] 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 05/18] 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 06/18] 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 07/18] 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 08/18] 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") ] From a1f985c6dd943851dc6f70130ad0585fa16742f2 Mon Sep 17 00:00:00 2001 From: Pierre Raybaut Date: Tue, 12 May 2026 10:57:21 +0200 Subject: [PATCH 09/18] feat(plot): add per-axis autoscale strategy (auto / fixed / none) Closes #61 --- plotpy/locale/fr/LC_MESSAGES/plotpy.po | 20 +++- plotpy/plot/base.py | 56 +++++++++ plotpy/styles/axes.py | 24 ++++ plotpy/tests/unit/test_autoscale_strategy.py | 116 +++++++++++++++++++ 4 files changed, 215 insertions(+), 1 deletion(-) create mode 100644 plotpy/tests/unit/test_autoscale_strategy.py diff --git a/plotpy/locale/fr/LC_MESSAGES/plotpy.po b/plotpy/locale/fr/LC_MESSAGES/plotpy.po index 97dd6c4..9d52bb3 100644 --- a/plotpy/locale/fr/LC_MESSAGES/plotpy.po +++ b/plotpy/locale/fr/LC_MESSAGES/plotpy.po @@ -364,6 +364,21 @@ msgstr "date/heure" msgid "logarithmic" msgstr "logarithmique" +msgid "Autoscale strategy" +msgstr "Stratégie d'autoscale" + +msgid "Auto" +msgstr "Automatique" + +msgid "Fixed range" +msgstr "Plage fixe" + +msgid "Disabled" +msgstr "Désactivée" + +msgid "Strategy used by the AutoScale action for this axis: 'Auto' computes bounds from items, 'Fixed range' applies the Min/Max values defined above, 'Disabled' leaves the axis untouched." +msgstr "Stratégie utilisée par l'action AutoScale pour cet axe : « Automatique » calcule les bornes à partir des éléments, « Plage fixe » applique les valeurs Min/Max définies ci-dessus, « Désactivée » laisse l'axe inchangé." + msgid "Lower axis limit" msgstr "Borne inférieure de l'axe" @@ -544,6 +559,10 @@ msgstr "Taille" msgid "Border" msgstr "Bordure" +#, fuzzy +msgid "Border width" +msgstr "Bordure" + msgid "Background color" msgstr "Couleur du fond" @@ -1686,4 +1705,3 @@ msgstr "Rotation et rognage" msgid "Show cropping rectangle" msgstr "Afficher le rectangle de rognage" - diff --git a/plotpy/plot/base.py b/plotpy/plot/base.py index de34684..7e978fa 100644 --- a/plotpy/plot/base.py +++ b/plotpy/plot/base.py @@ -325,6 +325,9 @@ def __init__( self.__autoscale_excluded_items: list[itf.IBasePlotItem] = [] self.autoscale_margin_percent = options.autoscale_margin_percent + self._axis_autoscale_strategy: dict[ + int, tuple[str, float | None, float | None] + ] = {axis_id: ("auto", None, None) for axis_id in self.AXIS_IDS} self.lock_aspect_ratio = options.lock_aspect_ratio self.__autoLockAspectRatio = False if self.lock_aspect_ratio is None: @@ -2177,9 +2180,54 @@ def get_auto_scale_excludes(self) -> list[itf.IBasePlotItem]: ] return [item_ref() for item_ref in self.__autoscale_excluded_items] + def get_axis_autoscale_strategy( + self, axis_id: int + ) -> tuple[str, float | None, float | None]: + """Return the autoscale strategy configured for a given axis. + + Args: + axis_id: the axis ID + + Returns: + A 3-tuple ``(strategy, vmin, vmax)`` where ``strategy`` is one of + ``"auto"``, ``"fixed"`` or ``"none"``. ``vmin``/``vmax`` are the + user-defined bounds applied when ``strategy == "fixed"`` + (``None`` otherwise). + """ + return self._axis_autoscale_strategy.get(axis_id, ("auto", None, None)) + + def set_axis_autoscale_strategy( + self, + axis_id: int, + strategy: str, + vmin: float | None = None, + vmax: float | None = None, + ) -> None: + """Set the autoscale strategy for a given axis. + + Args: + axis_id: the axis ID + strategy: one of ``"auto"`` (compute bounds from items, current + behavior), ``"fixed"`` (apply ``vmin``/``vmax``) or ``"none"`` + (leave the axis untouched on autoscale) + vmin: lower bound applied when ``strategy == "fixed"`` + vmax: upper bound applied when ``strategy == "fixed"`` + """ + if strategy not in ("auto", "fixed", "none"): + raise ValueError( + f"Invalid autoscale strategy {strategy!r}: " + "expected one of 'auto', 'fixed', 'none'" + ) + self._axis_autoscale_strategy[axis_id] = (strategy, vmin, vmax) + def do_autoscale(self, replot: bool = True, axis_id: int | None = None) -> None: """Do autoscale on all axes + The behavior of each axis depends on its autoscale strategy + (see :py:meth:`set_axis_autoscale_strategy`): ``"auto"`` computes + bounds from items (default), ``"fixed"`` applies the configured + ``vmin``/``vmax`` and ``"none"`` leaves the axis untouched. + Args: replot (bool): replot the widget (optional, default=True) axis_id (int | None): the axis ID (optional, default=None) @@ -2191,6 +2239,14 @@ def do_autoscale(self, replot: bool = True, axis_id: int | None = None) -> None: vmin, vmax = None, None if not self.axisEnabled(axis_id): continue + strategy, fixed_vmin, fixed_vmax = self.get_axis_autoscale_strategy(axis_id) + if strategy == "none": + continue + if strategy == "fixed": + if fixed_vmin is None or fixed_vmax is None: + continue + self.set_axis_limits(axis_id, fixed_vmin, fixed_vmax) + continue for item in self.get_items(): if ( isinstance(item, self.AUTOSCALE_TYPES) diff --git a/plotpy/styles/axes.py b/plotpy/styles/axes.py index a61289c..c474964 100644 --- a/plotpy/styles/axes.py +++ b/plotpy/styles/axes.py @@ -47,6 +47,20 @@ class AxisParam(DataSet): [("lin", _("linear")), ("log", _("logarithmic")), ("datetime", _("date/time"))], default="lin", ) + autoscale = ChoiceItem( + _("Autoscale strategy"), + [ + ("auto", _("Auto")), + ("fixed", _("Fixed range")), + ("none", _("Disabled")), + ], + default="auto", + help=_( + "Strategy used by the AutoScale action for this axis: " + "'Auto' computes bounds from items, 'Fixed range' applies the " + "Min/Max values defined above, 'Disabled' leaves the axis untouched." + ), + ) vmin = FloatItem("Min", help=_("Lower axis limit"), default=0.0) vmax = FloatItem("Max", help=_("Upper axis limit"), default=1.0) @@ -62,6 +76,13 @@ def update_param(self, plot: BasePlot, axis_id: int) -> None: axis: QwtScaleDiv = plot.axisScaleDiv(axis_id) self.vmin = axis.lowerBound() self.vmax = axis.upperBound() + strategy, fixed_vmin, fixed_vmax = plot.get_axis_autoscale_strategy(axis_id) + self.autoscale = strategy + if strategy == "fixed": + if fixed_vmin is not None: + self.vmin = fixed_vmin + if fixed_vmax is not None: + self.vmax = fixed_vmax def update_axis(self, plot: BasePlot, axis_id: int) -> None: """ @@ -74,6 +95,9 @@ def update_axis(self, plot: BasePlot, axis_id: int) -> None: plot.enableAxis(axis_id, True) plot.set_axis_scale(axis_id, self.scale, autoscale=False) plot.setAxisScale(axis_id, self.vmin, self.vmax) + plot.set_axis_autoscale_strategy( + axis_id, self.autoscale, vmin=self.vmin, vmax=self.vmax + ) plot.disable_unused_axes() plot.SIG_AXIS_PARAMETERS_CHANGED.emit(axis_id) diff --git a/plotpy/tests/unit/test_autoscale_strategy.py b/plotpy/tests/unit/test_autoscale_strategy.py new file mode 100644 index 0000000..4162d19 --- /dev/null +++ b/plotpy/tests/unit/test_autoscale_strategy.py @@ -0,0 +1,116 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the terms of the BSD 3-Clause +# (see plotpy/LICENSE for details) + +"""Testing per-axis autoscale strategy.""" + +# guitest: skip + +from __future__ import annotations + +import numpy as np +import pytest +from guidata.qthelpers import qt_app_context + +from plotpy.builder import make +from plotpy.constants import AXIS_IDS, X_BOTTOM, Y_LEFT +from plotpy.tests import vistools as ptv + + +def _make_plot(): + """Create a plot widget with a single curve item.""" + x = np.linspace(0.0, 10.0, 11) + y = np.linspace(-5.0, 5.0, 11) + items = [make.curve(x, y, color="b")] + win = ptv.show_items(items, wintitle="autoscale-strategy-test", auto_tools=False) + return win, win.get_plot() + + +def test_default_strategy_is_auto(): + """All axes default to the 'auto' strategy.""" + with qt_app_context(exec_loop=False): + _win, plot = _make_plot() + for axis_id in AXIS_IDS: + assert plot.get_axis_autoscale_strategy(axis_id) == ("auto", None, None) + + +def test_set_get_strategy_round_trip(): + """`set_axis_autoscale_strategy` round-trips through the getter.""" + with qt_app_context(exec_loop=False): + _win, plot = _make_plot() + plot.set_axis_autoscale_strategy(X_BOTTOM, "fixed", vmin=1.5, vmax=8.5) + assert plot.get_axis_autoscale_strategy(X_BOTTOM) == ("fixed", 1.5, 8.5) + plot.set_axis_autoscale_strategy(Y_LEFT, "none") + assert plot.get_axis_autoscale_strategy(Y_LEFT) == ("none", None, None) + + +def test_invalid_strategy_raises(): + """Unknown strategies are rejected.""" + with qt_app_context(exec_loop=False): + _win, plot = _make_plot() + with pytest.raises(ValueError): + plot.set_axis_autoscale_strategy(X_BOTTOM, "bogus") + + +def test_strategy_none_keeps_limits(): + """An axis with strategy 'none' is left untouched by `do_autoscale`.""" + with qt_app_context(exec_loop=False): + _win, plot = _make_plot() + plot.set_axis_limits(X_BOTTOM, -42.0, 42.0) + plot.set_axis_autoscale_strategy(X_BOTTOM, "none") + plot.do_autoscale(replot=False) + vmin, vmax = plot.get_axis_limits(X_BOTTOM) + assert vmin == -42.0 + assert vmax == 42.0 + + +def test_strategy_fixed_applies_bounds(): + """An axis with strategy 'fixed' is set to the configured vmin/vmax.""" + with qt_app_context(exec_loop=False): + _win, plot = _make_plot() + plot.set_axis_autoscale_strategy(Y_LEFT, "fixed", vmin=-100.0, vmax=100.0) + plot.do_autoscale(replot=False) + vmin, vmax = plot.get_axis_limits(Y_LEFT) + assert vmin == -100.0 + assert vmax == 100.0 + + +def test_strategy_auto_uses_item_bounds(): + """An axis with strategy 'auto' covers the items' bounding rect.""" + with qt_app_context(exec_loop=False): + _win, plot = _make_plot() + plot.do_autoscale(replot=False) + vmin, vmax = plot.get_axis_limits(X_BOTTOM) + # Curve x-range is [0, 10]; auto strategy adds a margin so bounds are wider. + assert vmin <= 0.0 + assert vmax >= 10.0 + + +def test_explicit_axis_id_honors_none(): + """`do_autoscale(axis_id=...)` honors the 'none' strategy.""" + with qt_app_context(exec_loop=False): + _win, plot = _make_plot() + plot.set_axis_limits(X_BOTTOM, -7.0, 7.0) + plot.set_axis_autoscale_strategy(X_BOTTOM, "none") + plot.do_autoscale(replot=False, axis_id=X_BOTTOM) + vmin, vmax = plot.get_axis_limits(X_BOTTOM) + assert vmin == -7.0 + assert vmax == 7.0 + + +def test_disabled_axis_is_inert(): + """A disabled axis is ignored even when its strategy is 'fixed'.""" + with qt_app_context(exec_loop=False): + _win, plot = _make_plot() + from plotpy.constants import X_TOP + + assert not plot.axisEnabled(X_TOP) + plot.set_axis_autoscale_strategy(X_TOP, "fixed", vmin=-1.0, vmax=1.0) + # Should not raise nor mutate the disabled axis state. + plot.do_autoscale(replot=False) + plot.do_autoscale(replot=False, axis_id=X_TOP) + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) From e1779ac184c04dc63921c7505db73229c6c8372e Mon Sep 17 00:00:00 2001 From: ovallcorba Date: Thu, 14 May 2026 13:12:09 +0200 Subject: [PATCH 10/18] fix: update log data when z-axis log scale is enabled --- plotpy/items/image/base.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/plotpy/items/image/base.py b/plotpy/items/image/base.py index b228d58..c2c7f9c 100644 --- a/plotpy/items/image/base.py +++ b/plotpy/items/image/base.py @@ -360,6 +360,12 @@ def set_data( _min, _max = get_nan_range(data) self.set_lut_range((_min, _max)) + if self.get_zaxis_log_state(): + self._log_data = np.array( + np.log10(self.data.clip(1)), dtype=np.float64 + ) + self.set_lut_range(get_nan_range(self._log_data)) + def get_data( self, x0: float, y0: float, x1: float | None = None, y1: float | None = None ) -> float | tuple[np.ndarray, np.ndarray, np.ndarray]: From 7013dadd1936c13a16b1e3de246376fa636e4128 Mon Sep 17 00:00:00 2001 From: Pierre Raybaut Date: Thu, 14 May 2026 18:26:44 +0200 Subject: [PATCH 11/18] refactor(image): factor out _recompute_log_data and honor keep_lut_range in log mode Assisted-by: Claude Opus 4.7 --- plotpy/items/image/base.py | 23 +++++-- plotpy/tests/unit/test_image_log_set_data.py | 70 ++++++++++++++++++++ 2 files changed, 86 insertions(+), 7 deletions(-) create mode 100644 plotpy/tests/unit/test_image_log_set_data.py diff --git a/plotpy/items/image/base.py b/plotpy/items/image/base.py index c2c7f9c..0a251d8 100644 --- a/plotpy/items/image/base.py +++ b/plotpy/items/image/base.py @@ -340,6 +340,15 @@ def get_r_values(self, i0, i1, j0, j1, flag_circle=False): """ return self.get_x_values(i0, i1) + def _recompute_log_data(self) -> None: + """Refresh the cached log10 data from the current ``self.data``. + + Used both when toggling the Z-axis log scale on and when the underlying + data is replaced (e.g. via :meth:`set_data`) while the log scale is + already active. + """ + self._log_data = np.array(np.log10(self.data.clip(1)), dtype=np.float64) + def set_data( self, data: np.ndarray, lut_range: tuple[float, float] | None = None ) -> None: @@ -353,19 +362,19 @@ def set_data( self.histogram_cache = None self.update_bounds() self.update_border() + # Refresh the cached log10 data when log scale is active, otherwise the + # display would keep using the previous (now stale) log data. + if self.get_zaxis_log_state(): + self._recompute_log_data() if not self.param.keep_lut_range: if lut_range is not None: _min, _max = lut_range + elif self.get_zaxis_log_state(): + _min, _max = get_nan_range(self._log_data) else: _min, _max = get_nan_range(data) self.set_lut_range((_min, _max)) - if self.get_zaxis_log_state(): - self._log_data = np.array( - np.log10(self.data.clip(1)), dtype=np.float64 - ) - self.set_lut_range(get_nan_range(self._log_data)) - def get_data( self, x0: float, y0: float, x1: float | None = None, y1: float | None = None ) -> float | tuple[np.ndarray, np.ndarray, np.ndarray]: @@ -580,7 +589,7 @@ def set_zaxis_log_state(self, state: bool) -> None: 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._recompute_log_data() self.set_lut_range(get_nan_range(self._log_data)) dtype = self._log_data.dtype else: diff --git a/plotpy/tests/unit/test_image_log_set_data.py b/plotpy/tests/unit/test_image_log_set_data.py new file mode 100644 index 0000000..cc26cb1 --- /dev/null +++ b/plotpy/tests/unit/test_image_log_set_data.py @@ -0,0 +1,70 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the terms of the BSD 3-Clause +# (see plotpy/LICENSE for details) + +"""Regression tests for cached log10 data refresh in ``ImageItem.set_data``. + +When the Z-axis is in logarithmic scale, ``ImageItem`` keeps a cached +``_log_data`` array. Prior to the fix, calling ``set_data`` did not refresh +that cache, so the displayed image kept reflecting the previous values until +the user toggled the log scale off and on again. +""" + +from __future__ import annotations + +import numpy as np +from guidata.qthelpers import qt_app_context + +from plotpy.builder import make + + +def _make_item(data: np.ndarray): + """Return an ``ImageItem`` ready for log-scale tests.""" + return make.image(data, interpolation="nearest") + + +def test_set_data_refreshes_log_data_when_log_scale_enabled() -> None: + """``set_data`` must recompute ``_log_data`` when log scale is active.""" + with qt_app_context(exec_loop=False): + first = np.array([[1.0, 10.0], [100.0, 1000.0]]) + item = _make_item(first) + item.set_zaxis_log_state(True) + np.testing.assert_array_almost_equal(item._log_data, np.log10(first.clip(1))) + + second = np.array([[10.0, 100.0], [1000.0, 10000.0]]) + item.set_data(second) + + # The cache must reflect the new data, not the previous one. + np.testing.assert_array_almost_equal(item._log_data, np.log10(second.clip(1))) + # And the LUT range must be derived from the refreshed log data. + lut_min, lut_max = item.get_lut_range() + assert lut_min == np.log10(second.clip(1)).min() + assert lut_max == np.log10(second.clip(1)).max() + + +def test_set_data_keeps_lut_range_in_log_mode() -> None: + """``keep_lut_range`` must be honored even when log scale is active.""" + with qt_app_context(exec_loop=False): + first = np.array([[1.0, 10.0], [100.0, 1000.0]]) + item = _make_item(first) + item.set_zaxis_log_state(True) + item.set_lut_range((0.5, 2.5)) + item.param.keep_lut_range = True + + second = np.array([[10.0, 100.0], [1000.0, 10000.0]]) + item.set_data(second) + + # Cache must still be refreshed (display correctness)… + np.testing.assert_array_almost_equal(item._log_data, np.log10(second.clip(1))) + # …but the LUT range must remain frozen as requested by the user. + assert item.get_lut_range() == (0.5, 2.5) + + +def test_set_data_does_not_create_log_data_when_log_scale_disabled() -> None: + """When log scale is off, ``set_data`` must not create ``_log_data``.""" + with qt_app_context(exec_loop=False): + item = _make_item(np.array([[1.0, 2.0], [3.0, 4.0]])) + assert item._log_data is None + item.set_data(np.array([[5.0, 6.0], [7.0, 8.0]])) + assert item._log_data is None From 38d0e27346a74f581064787556e8c80e9a5810b0 Mon Sep 17 00:00:00 2001 From: Duy Anh Philippe PHAM Date: Mon, 18 May 2026 13:51:10 +0200 Subject: [PATCH 12/18] Fix plot nan values --- plotpy/tools/curve.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/plotpy/tools/curve.py b/plotpy/tools/curve.py index c92ea4a..9ea68f8 100644 --- a/plotpy/tools/curve.py +++ b/plotpy/tools/curve.py @@ -142,13 +142,13 @@ class CurveStatsTool(BaseRangeCursorTool): TITLE = _("Signal statistics") ICON = "xrange.png" LABELFUNCS: tuple[tuple[str, Callable[..., Any]], ...] = ( - ("%g < x < %g", lambda *args: (args[0].min(), args[0].max())), - ("%g < y < %g", lambda *args: (args[1].min(), args[1].max())), - ("∆x=%g", lambda *args: args[0].max() - args[0].min()), - ("∆y=%g", lambda *args: args[1].max() - args[1].min()), - ("<y>=%g", lambda *args: args[1].mean()), - ("σ(y)=%g", lambda *args: args[1].std()), - ("∑(y)=%g", lambda *args: np.sum(args[1])), + ("%g < x < %g", lambda *args: (np.nanmin(args[0]), np.nanmax(args[0]))), + ("%g < y < %g", lambda *args: (np.nanmin(args[1]), np.nanmax(args[1]))), + ("∆x=%g", lambda *args: np.nanmax(args[0]) - np.nanmin(args[0])), + ("∆y=%g", lambda *args: np.nanmax(args[1]) - np.nanmin(args[1])), + ("<y>=%g", lambda *args: np.nanmean(args[1])), + ("σ(y)=%g", lambda *args: np.nanstd(args[1])), + ("∑(y)=%g", lambda *args: np.nansum(args[1])), ("∫ydx=%g", lambda *args: spt.trapezoid(args[1], args[0])), ) SHAPECLASS = XRangeSelection From 6fb84f439390cecb38581996a6d04894c77850d8 Mon Sep 17 00:00:00 2001 From: Thomas MALLET Date: Fri, 22 May 2026 15:01:49 +0200 Subject: [PATCH 13/18] fix linter warnings --- scripts/reinstall_dev.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/reinstall_dev.py b/scripts/reinstall_dev.py index 532477d..1ea0e6c 100644 --- a/scripts/reinstall_dev.py +++ b/scripts/reinstall_dev.py @@ -3,7 +3,8 @@ Reinstall multiple local libraries in editable mode for development. Workflow: - 1) Try to uninstall all target libraries in one command (ignore errors if some are not installed). + 1) Try to uninstall all target libraries in one command (ignore errors if some are + not installed). 2) Reinstall each library in editable mode from a sibling folder: ../. This script uses the same Python interpreter that runs it (sys.executable), From 51bca46b8d5c41c0bfa51b0983d2b7aff130afc3 Mon Sep 17 00:00:00 2001 From: Thomas MALLET Date: Fri, 22 May 2026 15:06:31 +0200 Subject: [PATCH 14/18] update translation --- plotpy/locale/fr/LC_MESSAGES/plotpy.po | 41 +++++++++++++++++++------- 1 file changed, 31 insertions(+), 10 deletions(-) diff --git a/plotpy/locale/fr/LC_MESSAGES/plotpy.po b/plotpy/locale/fr/LC_MESSAGES/plotpy.po index 97dd6c4..01b23a0 100644 --- a/plotpy/locale/fr/LC_MESSAGES/plotpy.po +++ b/plotpy/locale/fr/LC_MESSAGES/plotpy.po @@ -355,15 +355,15 @@ msgstr "Police des valeurs" msgid "Scale" msgstr "Échelle" +msgid "logarithmic" +msgstr "logarithmique" + msgid "linear" msgstr "linéaire" msgid "date/time" msgstr "date/heure" -msgid "logarithmic" -msgstr "logarithmique" - msgid "Lower axis limit" msgstr "Borne inférieure de l'axe" @@ -630,12 +630,12 @@ msgstr "Classes" msgid "Number of bins" msgstr "Nombre de classes" -msgid "Minimum value" -msgstr "Valeur minimale" - msgid "Min" msgstr "Min" +msgid "Minimum value" +msgstr "Valeur minimale" + msgid "Maximum value" msgstr "Valeur maximale" @@ -1422,12 +1422,11 @@ msgid "" "Keyboard/mouse shortcuts:

\n" " - single left-click: item (curve, image, ...) selection
\n" " - single right-click: context-menu relative to selected item
\n" -" - shift: on-active-curve (or image) cursor (+ control to maintain\n" -"cursor visible)
\n" -" - shift + control: on-active-curve cursor (+ control to maintain\n" -"cursor visible)
\n" +" - shift: on-active-curve (or image) cursor
\n" +" - shift + control: on-active-curve cursor (maintained visible)
\n" " - alt: free cursor
\n" " - left-click + mouse move: move item (when available)
\n" +" - control + left-click + mouse move: move label on markers and range selections
\n" " - middle-click + mouse move: pan
\n" " - right-click + mouse move: zoom" msgstr "" @@ -1687,3 +1686,25 @@ msgstr "Rotation et rognage" msgid "Show cropping rectangle" msgstr "Afficher le rectangle de rognage" +msgid "" +"Keyboard/mouse shortcuts:

\n" +" - single left-click: item (curve, image, ...) selection
\n" +" - single right-click: context-menu relative to selected item
\n" +" - shift: on-active-curve (or image) cursor (+ control to maintain\n" +"cursor visible)
\n" +" - shift + control: on-active-curve cursor (+ control to maintain\n" +"cursor visible)
\n" +" - alt: free cursor
\n" +" - left-click + mouse move: move item (when available)
\n" +" - middle-click + mouse move: pan
\n" +" - right-click + mouse move: zoom" +msgstr "" +"Raccourcis clavier et souris :

\n" +" - clique gauche : sélection d'un objet (courbe, image, ...)
\n" +" - clique droit : menu contextuel relatif à l'objet sélectionné
\n" +" - shift : curseur sur la courbe (ou l'image) active (+ control pour maintenir le curseur visible)
\n" +" - shift + control : curseur sur la courbe (ou l'image) active (+ control pour maintenir le curseur visible)
\n" +" - alt : curseur libre
\n" +" - clique gauche + déplacement souris : déplacement de l'objet actif (si possible)
\n" +" - clique du milieu + déplacement souris : translation dans le plan ('pan')
\n" +" - clique droit + déplacement souris : agrandissement ('zoom')" From cd6b28f82d106a3346857207b651b6066826100f Mon Sep 17 00:00:00 2001 From: Thomas MALLET Date: Thu, 7 May 2026 11:03:16 +0200 Subject: [PATCH 15/18] use new PythonQwt 0.16 version to get the performance fix apply --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 45d3f15..cc5bb73 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,7 @@ classifiers = [ requires-python = ">=3.9, <4" dependencies = [ "guidata >= 3.14.1", - "PythonQwt >= 0.15", + "PythonQwt >= 0.16", "numpy >= 1.22", "SciPy >= 1.7.3", "scikit-image >= 0.19", From d819f8c49dc6c756cf47ca3c058ead1a654eeb33 Mon Sep 17 00:00:00 2001 From: Thomas MALLET Date: Thu, 7 May 2026 11:03:16 +0200 Subject: [PATCH 16/18] use new PythonQwt 0.16 version to get the performance fix apply --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 45d3f15..cc5bb73 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,7 @@ classifiers = [ requires-python = ">=3.9, <4" dependencies = [ "guidata >= 3.14.1", - "PythonQwt >= 0.15", + "PythonQwt >= 0.16", "numpy >= 1.22", "SciPy >= 1.7.3", "scikit-image >= 0.19", From 0a641c265ec384cbda61786558c3df1c0463fb89 Mon Sep 17 00:00:00 2001 From: Thomas MALLET Date: Tue, 2 Jun 2026 14:35:48 +0200 Subject: [PATCH 17/18] update changelog for version 2.10.0 --- doc/release_notes/release_2.09.md | 20 ------------------ doc/release_notes/release_2.10.md | 34 +++++++++++++++++++++++++++++++ plotpy/__init__.py | 2 +- 3 files changed, 35 insertions(+), 21 deletions(-) create mode 100644 doc/release_notes/release_2.10.md diff --git a/doc/release_notes/release_2.09.md b/doc/release_notes/release_2.09.md index 2c38734..866a9ff 100644 --- a/doc/release_notes/release_2.09.md +++ b/doc/release_notes/release_2.09.md @@ -1,25 +1,5 @@ # 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 - * 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 ## 💥 New features: diff --git a/doc/release_notes/release_2.10.md b/doc/release_notes/release_2.10.md new file mode 100644 index 0000000..c351019 --- /dev/null +++ b/doc/release_notes/release_2.10.md @@ -0,0 +1,34 @@ +# Version 2.10 # + +## PlotPy Version 2.10.0 ## + +✨ New features: + +* **Per-axis autoscale strategy**: Added configurable autoscale behavior for each axis via the axis parameters dialog. Three strategies are available: *Auto* (default — compute bounds from items), *Fixed range* (apply user-defined Min/Max values) and *Disabled* (leave the axis untouched on autoscale). New API: `BasePlot.set_axis_autoscale_strategy()` / `BasePlot.get_axis_autoscale_strategy()` (closes [Issue #63](https://github.com/PlotPyStack/PlotPy/issues/63), partial) +* **Symbol border width**: Added an `edgewidth` parameter to `SymbolParam` for customizable marker border thickness — previously the border was always 1 pixel wide + +🛠️ Bug fixes: + +* **Rectangular snapshot tool** — Fixed the "Original size" computation (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 + * 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 +* **Snapshot tool cursor** — Fixed the mouse cursor remaining stuck as a cross (`+`) outside the plot canvas (axes, toolbar) after using the snapshot tool. The modal dialogs are now opened after Qt has released the implicit pointer grab, so the cursor is correctly restored (closes [Issue #58](https://github.com/PlotPyStack/PlotPy/issues/58)) +* **Z-axis log tool** — Fixed the `ZAxisLogTool` being always disabled for non-`ImageItem` image types (`XYImageItem`, `MaskedImageItem`, `MaskedXYImageItem`, `TrImageItem`, `RGBImageItem`). The Z-axis log API (`get_zaxis_log_state` / `set_zaxis_log_state`) was moved from `ImageItem` up to `BaseImageItem` so all image item types support it. This notably fixes the tool being permanently greyed out in DataLab's image panel (closes [Issue #59](https://github.com/PlotPyStack/PlotPy/issues/59)) +* **Z-axis log data update** — Fixed image data not being recomputed when calling `set_data()` while Z-axis log scale is active — the log-transformed data is now refreshed and the LUT range preserved in log mode +* **`YRangeCursorTool`** — Fixed incorrect inequality display and negative ∆y when the Y-range cursors are inverted (dragging the top cursor below the bottom one). Values are now sorted and ∆y is always positive (closes [Issue #55](https://github.com/PlotPyStack/PlotPy/issues/55)) +* **`CurveStatsTool`** — Replaced `min`/`max`/`mean`/`std`/`sum` with their NaN-safe equivalents (`nanmin`, `nanmax`, `nanmean`, `nanstd`, `nansum`) so that signal statistics are computed correctly when the data contains NaN values + +⚙️ Dependencies: + +* Bumped minimum PythonQwt version from 0.15 to **0.16** to benefit from the Qt6 performance optimizations (closes [Issue #22](https://github.com/PlotPyStack/PlotPy/issues/22) — see [PythonQwt#93](https://github.com/PlotPyStack/PythonQwt/issues/93) for the full optimization log) \ No newline at end of file diff --git a/plotpy/__init__.py b/plotpy/__init__.py index 9d8905d..50d749b 100644 --- a/plotpy/__init__.py +++ b/plotpy/__init__.py @@ -20,7 +20,7 @@ .. _GitHub: https://github.com/PierreRaybaut/plotpy """ -__version__ = "2.9.1" +__version__ = "2.10.0" __VERSION__ = tuple([int(number) for number in __version__.split(".")]) # --- Important note: DATAPATH and LOCALEPATH are used by guidata.configtools From 0ec5de33bb538745703911f54a579d40c3f43efa Mon Sep 17 00:00:00 2001 From: Thomas MALLET Date: Tue, 2 Jun 2026 16:05:22 +0200 Subject: [PATCH 18/18] update translation and generated requirements --- doc/requirements.rst | 38 ++++++++++++++---------- plotpy/locale/fr/LC_MESSAGES/plotpy.po | 41 ++++++++++++-------------- requirements.txt | 5 ++-- 3 files changed, 44 insertions(+), 40 deletions(-) diff --git a/doc/requirements.rst b/doc/requirements.rst index 79c61bb..4026339 100644 --- a/doc/requirements.rst +++ b/doc/requirements.rst @@ -14,7 +14,7 @@ The `PlotPy` package requires the following Python modules: - >= 3.14.1 - Automatic GUI generation for easy dataset editing and display * - PythonQwt - - >= 0.15 + - >= 0.16 - Qt plotting widgets for Python * - numpy - >= 1.22 @@ -26,10 +26,10 @@ The `PlotPy` package requires the following Python modules: - >= 0.19 - Image processing in Python * - Pillow - - + - - Python Imaging Library (fork) * - tifffile - - + - - Read and write TIFF files Optional modules for GUI support (Qt): @@ -55,26 +55,32 @@ Optional modules for development: - Version - Summary * - build - - + - - A simple, correct Python build frontend * - babel - - + - - Internationalization utilities * - Coverage - - + - - Code coverage measurement for Python * - Cython - >=3.0 - The Cython compiler for writing C extensions in the Python language. * - pylint - - + - - python code static checker * - ruff - - + - - An extremely fast Python linter and code formatter, written in Rust. * - pre-commit - - + - - A framework for managing and maintaining multi-language pre-commit hooks. + * - setuptools + - + - Most extensible Python build backend with support for C/C++ extension modules + * - wheel + - + - Command line tool for manipulating wheel files Optional modules for building the documentation: @@ -86,19 +92,19 @@ Optional modules for building the documentation: - Version - Summary * - sphinx - - + - - Python documentation generator * - myst_parser - - + - - An extended [CommonMark](https://spec.commonmark.org/) compliant parser, * - sphinx-copybutton - - + - - Add a copy button to each of your code cells. * - sphinx_qt_documentation - - + - - Plugin for proper resolve intersphinx references for Qt elements * - python-docs-theme - - + - - The Sphinx theme for the CPython docs and related projects Optional modules for running test suite: @@ -111,8 +117,8 @@ Optional modules for running test suite: - Version - Summary * - pytest - - + - - pytest: simple powerful testing with Python * - pytest-xvfb - - + - - A pytest plugin to run Xvfb (or Xephyr/Xvnc) for tests. \ No newline at end of file diff --git a/plotpy/locale/fr/LC_MESSAGES/plotpy.po b/plotpy/locale/fr/LC_MESSAGES/plotpy.po index 70a699b..773f0a7 100644 --- a/plotpy/locale/fr/LC_MESSAGES/plotpy.po +++ b/plotpy/locale/fr/LC_MESSAGES/plotpy.po @@ -364,24 +364,6 @@ msgstr "linéaire" msgid "date/time" msgstr "date/heure" -msgid "logarithmic" -msgstr "logarithmique" - -msgid "Autoscale strategy" -msgstr "Stratégie d'autoscale" - -msgid "Auto" -msgstr "Automatique" - -msgid "Fixed range" -msgstr "Plage fixe" - -msgid "Disabled" -msgstr "Désactivée" - -msgid "Strategy used by the AutoScale action for this axis: 'Auto' computes bounds from items, 'Fixed range' applies the Min/Max values defined above, 'Disabled' leaves the axis untouched." -msgstr "Stratégie utilisée par l'action AutoScale pour cet axe : « Automatique » calcule les bornes à partir des éléments, « Plage fixe » applique les valeurs Min/Max définies ci-dessus, « Désactivée » laisse l'axe inchangé." - msgid "Lower axis limit" msgstr "Borne inférieure de l'axe" @@ -562,10 +544,6 @@ msgstr "Taille" msgid "Border" msgstr "Bordure" -#, fuzzy -msgid "Border width" -msgstr "Bordure" - msgid "Background color" msgstr "Couleur du fond" @@ -1708,6 +1686,10 @@ msgstr "Rotation et rognage" msgid "Show cropping rectangle" msgstr "Afficher le rectangle de rognage" +#, fuzzy +msgid "Border width" +msgstr "Bordure" + msgid "" "Keyboard/mouse shortcuts:

\n" " - single left-click: item (curve, image, ...) selection
\n" @@ -1730,3 +1712,18 @@ msgstr "" " - clique gauche + déplacement souris : déplacement de l'objet actif (si possible)
\n" " - clique du milieu + déplacement souris : translation dans le plan ('pan')
\n" " - clique droit + déplacement souris : agrandissement ('zoom')" + +msgid "Autoscale strategy" +msgstr "Stratégie d'autoscale" + +msgid "Auto" +msgstr "Automatique" + +msgid "Fixed range" +msgstr "Plage fixe" + +msgid "Disabled" +msgstr "Désactivée" + +msgid "Strategy used by the AutoScale action for this axis: 'Auto' computes bounds from items, 'Fixed range' applies the Min/Max values defined above, 'Disabled' leaves the axis untouched." +msgstr "Stratégie utilisée par l'action AutoScale pour cet axe : « Automatique » calcule les bornes à partir des éléments, « Plage fixe » applique les valeurs Min/Max définies ci-dessus, « Désactivée » laisse l'axe inchangé." diff --git a/requirements.txt b/requirements.txt index 2437ecc..9b23aa0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,11 +2,10 @@ Coverage Cython>=3.0 Pillow PyQt5>5.15.5 -PythonQwt >= 0.15 +PythonQwt >= 0.16 SciPy >= 1.7.3 babel build -setuptools guidata >= 3.14.1 myst_parser numpy >= 1.22 @@ -17,7 +16,9 @@ pytest-xvfb python-docs-theme ruff scikit-image >= 0.19 +setuptools sphinx sphinx-copybutton sphinx_qt_documentation tifffile +wheel