Skip to content

Commit 0b90437

Browse files
Merge pull request #79 from PlotPyStack/develop
PlotPy 2.12.0
2 parents 300efd3 + 5b337b0 commit 0b90437

23 files changed

Lines changed: 690 additions & 81 deletions

‎.github/copilot-instructions.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ PythonQwt and guidata.
1212

1313
### Technology Stack
1414

15-
- **Python**: 3.9+ (`from __future__ import annotations`)
15+
- **Python**: 3.10+ (`from __future__ import annotations`)
1616
- **Core**: NumPy, SciPy, scikit-image
1717
- **GUI**: Qt via QtPy, guidata, PythonQwt
1818
- **Build**: Some features rely on Cython extensions

‎.github/workflows/build.yml‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,12 @@ jobs:
1313
os: [ubuntu-latest, windows-latest, macos-13, macos-14]
1414

1515
steps:
16-
- uses: actions/checkout@v4
16+
- uses: actions/checkout@v7
1717

1818
- name: Build wheels
1919
run: pipx run cibuildwheel==2.21.2
2020

21-
- uses: actions/upload-artifact@v4
21+
- uses: actions/upload-artifact@v6
2222
with:
2323
name: cibw-wheels-${{ matrix.os }}-${{ strategy.job-index }}
2424
path: ./wheelhouse/*.whl

‎.github/workflows/build_deploy.yml‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ jobs:
1717
os: [ubuntu-latest, windows-latest, macos-15]
1818

1919
steps:
20-
- uses: actions/checkout@v4
20+
- uses: actions/checkout@v7
2121

2222
- name: Install build dependencies (for prebuild)
2323
run: |
@@ -38,7 +38,7 @@ jobs:
3838
name: Build source distribution
3939
runs-on: ubuntu-latest
4040
steps:
41-
- uses: actions/checkout@v4
41+
- uses: actions/checkout@v7
4242

4343
- name: Install build dependencies
4444
run: |

‎.github/workflows/test_pyqt5.yml‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,12 @@ jobs:
2222
strategy:
2323
fail-fast: false
2424
matrix:
25-
python-version: ["3.9", "3.13", "3.14"]
25+
python-version: ["3.10", "3.13", "3.14"]
2626

2727
steps:
28-
- uses: actions/checkout@v4
28+
- uses: actions/checkout@v7
2929
- name: Set up Python ${{ matrix.python-version }}
30-
uses: actions/setup-python@v5
30+
uses: actions/setup-python@v7
3131
with:
3232
python-version: ${{ matrix.python-version }}
3333
- name: Install dependencies

‎.readthedocs.yaml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ build:
1010
post_create_environment:
1111
- pip install PyQt5 setuptools numpy Cython
1212
- python setup.py build_ext --inplace
13+
pre_install:
14+
# Development builds may require dependency versions not yet released on PyPI
15+
- python scripts/rtd_install_dev_deps.py
1316
sphinx:
1417
configuration: doc/conf.py
1518
formats:

‎doc/dev/platforms.rst‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ The following sections describe the reference platforms for the project.
77

88
.. note::
99

10-
The officially supported Python versions are 3.9, 3.10, 3.11, 3.12 and 3.13.
10+
The officially supported Python versions are 3.10, 3.11, 3.12, 3.13 and 3.14.
1111

1212
The officially supported Qt binding is PyQt5. However, efforts have been
1313
made and will continue to be made to support PyQt6 and PySide6 as well.

‎doc/release_notes/release_2.12.md‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Version 2.12 #
2+
3+
## PlotPy Version 2.12.0 ##
4+
5+
🛠️ Bug fixes:
6+
7+
* **Curve fitting dialog** — Running the automatic fit no longer raises `OverflowError: argument 1 overflowed` errors when a fitted value ends up far outside the range of its slider (e.g. with the Powell method on a fit range restricted to the tail of a peak). These errors left the slider of the parameter unresponsive, and the values of the following parameters were not displayed. Such values are now shown with a warning icon, whose tooltip gives the slider range, while the slider stays at its nearest end. Typing `nan` or `inf` in a parameter field now restores the previous value instead of raising errors, and the number of slider steps must be at least 2 in the parameter properties dialog (closes [Issue #76](https://github.com/PlotPyStack/PlotPy/issues/76))
8+
* **Cross-section panels with many hidden images** — X/Y cross-section panels kept one curve for every image ever displayed, even after it was hidden, so plots containing hundreds of hidden images became progressively slower to refresh. Curves are now only kept for visible images, and are recreated when a hidden image is shown again
9+
10+
⚡ Performance:
11+
12+
* **Plot refresh** — Refreshing a plot no longer recomputes the plot layout when the title and axis styles did not change
13+
* **Item tools and item list** — Updating the status of item-related tools and of the item list panel is no longer quadratic in the number of plot items
14+
* **Image creation** — Creating an image item no longer recomputes the colormap lookup table when the same colormap, inversion and transparency settings were already used: the table (more than 1000 colormap evaluations) is computed once and reused, which saves a few milliseconds per image
15+
16+
⚙️ Dependencies:
17+
18+
* Python 3.10 or later is now required, and wheels are no longer built for Python 3.9. Python 3.9 users must keep an earlier compatible release (PlotPy 2.11.x)

‎doc/requirements.rst‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ The `PlotPy` package requires the following Python modules:
88
- Version
99
- Summary
1010
* - Python
11-
- >=3.9, <4
11+
- >=3.10, <4
1212
- Python programming language
1313
* - guidata
1414
- >= 3.14.1

‎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.11.1"
23+
__version__ = "2.12.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/image/base.py‎

Lines changed: 111 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
import os
99
import os.path as osp
10+
from collections import OrderedDict
1011
from typing import TYPE_CHECKING, Any
1112

1213
import numpy as np
@@ -46,6 +47,7 @@
4647
from plotpy.mathutils.arrayfuncs import get_nan_range
4748
from plotpy.mathutils.colormap import FULLRANGE, get_cmap
4849
from plotpy.styles.image import RawImageParam
50+
from plotpy.widgets.colormap.widget import EditableColormap
4951

5052
if TYPE_CHECKING:
5153
import guidata.io
@@ -57,7 +59,110 @@
5759
from plotpy.interfaces import IItemType
5860
from plotpy.plot import BasePlot
5961
from plotpy.styles.base import ItemParameters
60-
from plotpy.widgets.colormap.widget import EditableColormap
62+
63+
#: Lookup tables already computed by :func:`_get_colormap_lut`, most recent last
64+
_LUT_CACHE: OrderedDict[tuple, tuple[tuple[int, ...], np.ndarray]] = OrderedDict()
65+
_LUT_CACHE_SIZE = 32
66+
67+
68+
def _lut_cache_key(
69+
table: EditableColormap, alpha: float, alpha_function: int
70+
) -> tuple | None:
71+
"""Return the key identifying the lookup table of a colormap, if cacheable.
72+
73+
The key is built from the colormap content (not its identity), so that an
74+
edited colormap gets a new lookup table.
75+
76+
Args:
77+
table: Colormap
78+
alpha: Global alpha value
79+
alpha_function: Alpha function (see :class:`plotpy.constants.LUTAlpha`)
80+
81+
Returns:
82+
Cache key, or None if the lookup table must be computed: colormap
83+
subclasses may compute colors differently, and the colors of translucent
84+
stops depend on the order in which the stops were inserted.
85+
"""
86+
if type(table) is not EditableColormap:
87+
return None
88+
stops = tuple((stop.pos, stop.rgb) for stop in table.colorStops())
89+
if any(stop.a != 255 for stop in table.colorStops()):
90+
return None
91+
return (stops, table.mode(), table.invert, alpha, alpha_function)
92+
93+
94+
def _compute_colormap_lut(
95+
table: EditableColormap, alpha: float, alpha_function: int
96+
) -> tuple[tuple[int, ...], np.ndarray]:
97+
"""Compute the color table and the lookup table of a colormap.
98+
99+
Args:
100+
table: Colormap
101+
alpha: Global alpha value
102+
alpha_function: Alpha function (see :class:`plotpy.constants.LUTAlpha`)
103+
104+
Returns:
105+
Color table (256 colors) and lookup table (``LUT_SIZE`` ARGB values)
106+
"""
107+
colors = tuple(table.colorTable(FULLRANGE))
108+
lut = np.zeros((LUT_SIZE,), np.uint32)
109+
for i in range(LUT_SIZE):
110+
if alpha_function == LUTAlpha.NONE.value:
111+
pix_alpha = 1.0
112+
elif alpha_function == LUTAlpha.CONSTANT.value:
113+
pix_alpha = alpha
114+
else:
115+
x = i / float(LUT_SIZE - 1)
116+
if alpha_function == LUTAlpha.LINEAR.value:
117+
# Linear alpha function
118+
pix_alpha = alpha * x
119+
elif alpha_function == LUTAlpha.SIGMOID.value:
120+
# Sigmoid alpha function
121+
pix_alpha = alpha / (1 + np.exp(-10 * x))
122+
elif alpha_function == LUTAlpha.TANH.value:
123+
# Hyperbolic tangent alpha function
124+
pix_alpha = alpha * np.tanh(5 * x)
125+
elif alpha_function == LUTAlpha.STEP.value:
126+
# Fully transparent lowest value and `alpha` transparent elsewhere
127+
pix_alpha = alpha if x > 0 else 0
128+
else:
129+
raise ValueError(f"Invalid alpha function {alpha_function}")
130+
# pylint: disable=too-many-function-args
131+
alpha_channel = max(min(np.uint32(255 * pix_alpha + 0.5), 255), 0) << 24
132+
lut[i] = (
133+
np.uint32((table.rgb(FULLRANGE, i / LUT_MAX)) & 0xFFFFFF) | alpha_channel
134+
)
135+
return colors, lut
136+
137+
138+
def _get_colormap_lut(
139+
table: EditableColormap, alpha: float, alpha_function: int
140+
) -> tuple[tuple[int, ...], np.ndarray]:
141+
"""Return the color table and the lookup table of a colormap.
142+
143+
Computing them takes more than 1000 calls to the colormap: they are cached,
144+
since every new image item usually shows the same colormap.
145+
146+
Args:
147+
table: Colormap
148+
alpha: Global alpha value
149+
alpha_function: Alpha function (see :class:`plotpy.constants.LUTAlpha`)
150+
151+
Returns:
152+
Color table (256 colors) and lookup table (``LUT_SIZE`` ARGB values),
153+
the latter being read-only
154+
"""
155+
key = _lut_cache_key(table, alpha, alpha_function)
156+
if key is not None and key in _LUT_CACHE:
157+
_LUT_CACHE.move_to_end(key)
158+
return _LUT_CACHE[key]
159+
colors, lut = _compute_colormap_lut(table, alpha, alpha_function)
160+
lut.flags.writeable = False
161+
if key is not None:
162+
_LUT_CACHE[key] = (colors, lut)
163+
if len(_LUT_CACHE) > _LUT_CACHE_SIZE:
164+
_LUT_CACHE.popitem(last=False)
165+
return colors, lut
61166

62167

63168
class BaseImageItem(QwtPlotItem):
@@ -466,37 +571,11 @@ def set_color_map(
466571
if invert is not None:
467572
table.invert = invert
468573
self.cmap_table = table
469-
self.cmap = table.colorTable(FULLRANGE)
470-
cmap_a = self.lut[3]
471-
alpha = self.param.alpha
472-
alpha_function = self.param.alpha_function
473-
for i in range(LUT_SIZE):
474-
if alpha_function == LUTAlpha.NONE.value:
475-
pix_alpha = 1.0
476-
elif alpha_function == LUTAlpha.CONSTANT.value:
477-
pix_alpha = alpha
478-
else:
479-
x = i / float(LUT_SIZE - 1)
480-
if alpha_function == LUTAlpha.LINEAR.value:
481-
# Linear alpha function
482-
pix_alpha = alpha * x
483-
elif alpha_function == LUTAlpha.SIGMOID.value:
484-
# Sigmoid alpha function
485-
pix_alpha = alpha / (1 + np.exp(-10 * x))
486-
elif alpha_function == LUTAlpha.TANH.value:
487-
# Hyperbolic tangent alpha function
488-
pix_alpha = alpha * np.tanh(5 * x)
489-
elif alpha_function == LUTAlpha.STEP.value:
490-
# Fully transparent lowest value and `alpha` transparent elsewhere
491-
pix_alpha = alpha if x > 0 else 0
492-
else:
493-
raise ValueError(f"Invalid alpha function {alpha_function}")
494-
# pylint: disable=too-many-function-args
495-
alpha_channel = max(min(np.uint32(255 * pix_alpha + 0.5), 255), 0) << 24
496-
cmap_a[i] = (
497-
np.uint32((table.rgb(FULLRANGE, i / LUT_MAX)) & 0xFFFFFF)
498-
| alpha_channel
499-
)
574+
colors, lut = _get_colormap_lut(
575+
table, self.param.alpha, self.param.alpha_function
576+
)
577+
self.cmap = list(colors)
578+
self.lut[3][:] = lut
500579
plot = self.plot()
501580
if plot:
502581
plot.update_colormap_axis(self)

0 commit comments

Comments
 (0)