Skip to content

Commit c5c1213

Browse files
Merge pull request #67 from PlotPyStack/develop
Develop v2.10.0
2 parents 4671cf2 + 0ec5de3 commit c5c1213

22 files changed

Lines changed: 748 additions & 82 deletions

doc/release_notes/release_2.10.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# Version 2.10 #
2+
3+
## PlotPy Version 2.10.0 ##
4+
5+
✨ New features:
6+
7+
* **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)
8+
* **Symbol border width**: Added an `edgewidth` parameter to `SymbolParam` for customizable marker border thickness — previously the border was always 1 pixel wide
9+
10+
🛠️ Bug fixes:
11+
12+
* **Rectangular snapshot tool** — Fixed the "Original size" computation (closes [Issue #57](https://github.com/PlotPyStack/PlotPy/issues/57)):
13+
* The preview no longer displays negative dimensions when the X or Y axis is
14+
reversed
15+
* The "Original size" is now computed from pixel coordinates instead of axis
16+
units, so it is correct for `XYImageItem` (and any item with non-uniform
17+
axis scaling) regardless of axis orientation
18+
* The `ValueError` raised by the resize dialog when the selection produced
19+
negative dimensions on a reversed axis is gone
20+
* Selecting a region larger than the plotted image now reports the same
21+
native pixel resolution for both `ImageItem` and `XYImageItem`
22+
(previously `XYImageItem` reported ``shape - 1`` while `ImageItem`
23+
reported the full oversized resolution): exporting at "Original size"
24+
now consistently preserves the source pixel density and avoids
25+
upsampling, regardless of the item type
26+
* **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))
27+
* **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))
28+
* **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
29+
* **`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))
30+
* **`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
31+
32+
⚙️ Dependencies:
33+
34+
* 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)

doc/requirements.rst

Lines changed: 22 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ The `PlotPy` package requires the following Python modules:
1414
- >= 3.14.1
1515
- Automatic GUI generation for easy dataset editing and display
1616
* - PythonQwt
17-
- >= 0.15
17+
- >= 0.16
1818
- Qt plotting widgets for Python
1919
* - numpy
2020
- >= 1.22
@@ -26,10 +26,10 @@ The `PlotPy` package requires the following Python modules:
2626
- >= 0.19
2727
- Image processing in Python
2828
* - Pillow
29-
-
29+
-
3030
- Python Imaging Library (fork)
3131
* - tifffile
32-
-
32+
-
3333
- Read and write TIFF files
3434

3535
Optional modules for GUI support (Qt):
@@ -55,26 +55,32 @@ Optional modules for development:
5555
- Version
5656
- Summary
5757
* - build
58-
-
58+
-
5959
- A simple, correct Python build frontend
6060
* - babel
61-
-
61+
-
6262
- Internationalization utilities
6363
* - Coverage
64-
-
64+
-
6565
- Code coverage measurement for Python
6666
* - Cython
6767
- >=3.0
6868
- The Cython compiler for writing C extensions in the Python language.
6969
* - pylint
70-
-
70+
-
7171
- python code static checker
7272
* - ruff
73-
-
73+
-
7474
- An extremely fast Python linter and code formatter, written in Rust.
7575
* - pre-commit
76-
-
76+
-
7777
- A framework for managing and maintaining multi-language pre-commit hooks.
78+
* - setuptools
79+
-
80+
- Most extensible Python build backend with support for C/C++ extension modules
81+
* - wheel
82+
-
83+
- Command line tool for manipulating wheel files
7884

7985
Optional modules for building the documentation:
8086

@@ -86,19 +92,19 @@ Optional modules for building the documentation:
8692
- Version
8793
- Summary
8894
* - sphinx
89-
-
95+
-
9096
- Python documentation generator
9197
* - myst_parser
92-
-
98+
-
9399
- An extended [CommonMark](https://spec.commonmark.org/) compliant parser,
94100
* - sphinx-copybutton
95-
-
101+
-
96102
- Add a copy button to each of your code cells.
97103
* - sphinx_qt_documentation
98-
-
104+
-
99105
- Plugin for proper resolve intersphinx references for Qt elements
100106
* - python-docs-theme
101-
-
107+
-
102108
- The Sphinx theme for the CPython docs and related projects
103109

104110
Optional modules for running test suite:
@@ -111,8 +117,8 @@ Optional modules for running test suite:
111117
- Version
112118
- Summary
113119
* - pytest
114-
-
120+
-
115121
- pytest: simple powerful testing with Python
116122
* - pytest-xvfb
117-
-
123+
-
118124
- A pytest plugin to run Xvfb (or Xephyr/Xvnc) for tests.

plotpy/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
.. _GitHub: https://github.com/PierreRaybaut/plotpy
2121
"""
2222

23-
__version__ = "2.9.0"
23+
__version__ = "2.10.0"
2424
__VERSION__ = tuple([int(number) for number in __version__.split(".")])
2525

2626
# --- Important note: DATAPATH and LOCALEPATH are used by guidata.configtools

plotpy/items/__init__.py

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

plotpy/items/image/__init__.py

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

plotpy/items/image/base.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,12 @@ def __init__(
132132
self._filename = None # The file this image comes from
133133

134134
self.histogram_cache = None
135+
136+
# Z-axis logarithmic scale support
137+
self._log_data: np.ndarray | None = None
138+
self._lin_lut_range: tuple[float, float] | None = None
139+
self._is_zaxis_log = False
140+
135141
if data is not None:
136142
self.set_data(data)
137143
self.param.update_item(self)
@@ -334,6 +340,15 @@ def get_r_values(self, i0, i1, j0, j1, flag_circle=False):
334340
"""
335341
return self.get_x_values(i0, i1)
336342

343+
def _recompute_log_data(self) -> None:
344+
"""Refresh the cached log10 data from the current ``self.data``.
345+
346+
Used both when toggling the Z-axis log scale on and when the underlying
347+
data is replaced (e.g. via :meth:`set_data`) while the log scale is
348+
already active.
349+
"""
350+
self._log_data = np.array(np.log10(self.data.clip(1)), dtype=np.float64)
351+
337352
def set_data(
338353
self, data: np.ndarray, lut_range: tuple[float, float] | None = None
339354
) -> None:
@@ -347,9 +362,15 @@ def set_data(
347362
self.histogram_cache = None
348363
self.update_bounds()
349364
self.update_border()
365+
# Refresh the cached log10 data when log scale is active, otherwise the
366+
# display would keep using the previous (now stale) log data.
367+
if self.get_zaxis_log_state():
368+
self._recompute_log_data()
350369
if not self.param.keep_lut_range:
351370
if lut_range is not None:
352371
_min, _max = lut_range
372+
elif self.get_zaxis_log_state():
373+
_min, _max = get_nan_range(self._log_data)
353374
else:
354375
_min, _max = get_nan_range(data)
355376
self.set_lut_range((_min, _max))
@@ -552,6 +573,34 @@ def get_lut_range_full(self) -> tuple[float, float]:
552573
"""
553574
return get_nan_range(self.data)
554575

576+
# ---- Z-axis logarithmic scale --------------------------------------------
577+
def get_zaxis_log_state(self) -> bool:
578+
"""Return True if Z-axis is in logarithmic scale"""
579+
return self._is_zaxis_log
580+
581+
def set_zaxis_log_state(self, state: bool) -> None:
582+
"""Set Z-axis logarithmic scale state
583+
584+
Args:
585+
state: True to enable logarithmic scale, False otherwise
586+
"""
587+
self._is_zaxis_log = state
588+
plot = self.plot()
589+
if state:
590+
self._lin_lut_range = self.get_lut_range()
591+
if self._log_data is None:
592+
self._recompute_log_data()
593+
self.set_lut_range(get_nan_range(self._log_data))
594+
dtype = self._log_data.dtype
595+
else:
596+
self._log_data = None
597+
self.set_lut_range(self._lin_lut_range)
598+
dtype = self.data.dtype
599+
if self.interpolate[0] == INTERP_AA:
600+
self.interpolate = (INTERP_AA, self.interpolate[1].astype(dtype))
601+
if plot is not None:
602+
plot.update_colormap_axis(self)
603+
555604
def get_lut_range_max(self) -> tuple[float, float]:
556605
"""Get maximum range for this dataset
557606

plotpy/items/image/misc.py

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

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

600608

609+
def compute_image_items_original_size(
610+
items: list[BaseImageItem],
611+
plot: qwt.plot.QwtPlot,
612+
p0: QPointF,
613+
p1: QPointF,
614+
) -> tuple[float, float]:
615+
"""Compute the **native pixel resolution** of a rectangular selection
616+
across the given image items.
617+
618+
The "Original size" semantics is *original resolution*: the returned
619+
size is the number of source pixels that span the selection at the
620+
item's native resolution, *independent of axis orientation or scaling*.
621+
When the selection is larger than the plotted image, the returned size
622+
is consequently larger than the image (the missing area will be padded
623+
by the export step). When the selection is smaller, it is smaller in
624+
pixels — there is **no** clipping to the image bounding rectangle, so
625+
that exporting at "Original size" always preserves the source pixel
626+
density.
627+
628+
Args:
629+
plot: Plot
630+
items: List of image items in the selection
631+
p0: First canvas point (top-left, in canvas coordinates)
632+
p1: Second canvas point (bottom-right, in canvas coordinates)
633+
634+
Returns:
635+
Tuple ``(width, height)`` in pixels (always positive). When no
636+
compatible item is found, falls back to the absolute axis-units
637+
size of the selection.
638+
"""
639+
p0x = plot.invTransform(X_BOTTOM, p0.x())
640+
p0y = plot.invTransform(Y_LEFT, p0.y())
641+
p1x = plot.invTransform(X_BOTTOM, p1.x() + 1)
642+
p1y = plot.invTransform(Y_LEFT, p1.y() + 1)
643+
sel_x0, sel_x1 = sorted([p0x, p1x])
644+
sel_y0, sel_y1 = sorted([p0y, p1y])
645+
sel_w = sel_x1 - sel_x0
646+
sel_h = sel_y1 - sel_y0
647+
widths: list[float] = []
648+
heights: list[float] = []
649+
for item in items:
650+
data = getattr(item, "data", None)
651+
if data is None:
652+
continue
653+
if isinstance(item, TrImageItem):
654+
# Use the item's affine transform (handles rotation and shear)
655+
get_pix = item.get_pixel_coordinates
656+
try:
657+
x0p, y0p = get_pix(sel_x0, sel_y0)
658+
x1p, y1p = get_pix(sel_x1, sel_y1)
659+
except (ValueError, TypeError, IndexError):
660+
continue
661+
widths.append(abs(x1p - x0p))
662+
heights.append(abs(y1p - y0p))
663+
else:
664+
# For ImageItem / XYImageItem: convert the (possibly oversized)
665+
# selection to pixels via the item's own pixel density. This
666+
# avoids ``XYImageItem.get_pixel_coordinates`` clamping to
667+
# integer indices and yields oversized values when the
668+
# selection extends beyond the image — consistently with the
669+
# historical behavior of ``ImageItem``.
670+
brect = item.boundingRect()
671+
bw = abs(brect.width())
672+
bh = abs(brect.height())
673+
if bw <= 0 or bh <= 0:
674+
continue
675+
widths.append(sel_w / bw * data.shape[1])
676+
heights.append(sel_h / bh * data.shape[0])
677+
if widths:
678+
return max(widths), max(heights)
679+
# Fallback: axis-units size (always positive)
680+
_src_x, _src_y, src_w, src_h = get_plot_qrect(plot, p0, p1).getRect()
681+
return abs(src_w), abs(src_h)
682+
683+
601684
def get_image_from_qrect(
602685
plot: BasePlot,
603686
p0: QPointF,
@@ -636,12 +719,12 @@ def get_image_from_qrect(
636719
if not items:
637720
raise TypeError(_("There is no supported image item in current plot."))
638721
if src_size is None:
639-
_src_x, _src_y, src_w, src_h = get_plot_qrect(plot, p0, p1).getRect()
722+
destw, desth = compute_image_items_original_size(items, plot, p0, p1)
640723
else:
641724
# The only benefit to pass the src_size list is to avoid any
642725
# rounding error in the transformation computed in `get_plot_qrect`
643726
src_w, src_h = src_size
644-
destw, desth = compute_trimageitems_original_size(items, src_w, src_h)
727+
destw, desth = compute_trimageitems_original_size(items, src_w, src_h)
645728
data = get_image_from_plot(
646729
plot,
647730
p0,

0 commit comments

Comments
 (0)