Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ PythonQwt and guidata.

### Technology Stack

- **Python**: 3.9+ (`from __future__ import annotations`)
- **Python**: 3.10+ (`from __future__ import annotations`)
- **Core**: NumPy, SciPy, scikit-image
- **GUI**: Qt via QtPy, guidata, PythonQwt
- **Build**: Some features rely on Cython extensions
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,12 @@ jobs:
os: [ubuntu-latest, windows-latest, macos-13, macos-14]

steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7

- name: Build wheels
run: pipx run cibuildwheel==2.21.2

- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v6
with:
name: cibw-wheels-${{ matrix.os }}-${{ strategy.job-index }}
path: ./wheelhouse/*.whl
4 changes: 2 additions & 2 deletions .github/workflows/build_deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ jobs:
os: [ubuntu-latest, windows-latest, macos-15]

steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7

- name: Install build dependencies (for prebuild)
run: |
Expand All @@ -38,7 +38,7 @@ jobs:
name: Build source distribution
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7

- name: Install build dependencies
run: |
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/test_pyqt5.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,12 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: ["3.9", "3.13", "3.14"]
python-version: ["3.10", "3.13", "3.14"]

steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
uses: actions/setup-python@v7
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
Expand Down
3 changes: 3 additions & 0 deletions .readthedocs.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ build:
post_create_environment:
- pip install PyQt5 setuptools numpy Cython
- python setup.py build_ext --inplace
pre_install:
# Development builds may require dependency versions not yet released on PyPI
- python scripts/rtd_install_dev_deps.py
sphinx:
configuration: doc/conf.py
formats:
Expand Down
2 changes: 1 addition & 1 deletion doc/dev/platforms.rst
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ The following sections describe the reference platforms for the project.

.. note::

The officially supported Python versions are 3.9, 3.10, 3.11, 3.12 and 3.13.
The officially supported Python versions are 3.10, 3.11, 3.12, 3.13 and 3.14.

The officially supported Qt binding is PyQt5. However, efforts have been
made and will continue to be made to support PyQt6 and PySide6 as well.
Expand Down
18 changes: 18 additions & 0 deletions doc/release_notes/release_2.12.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Version 2.12 #

## PlotPy Version 2.12.0 ##

🛠️ Bug fixes:

* **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))
* **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

⚡ Performance:

* **Plot refresh** — Refreshing a plot no longer recomputes the plot layout when the title and axis styles did not change
* **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
* **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

⚙️ Dependencies:

* 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)
2 changes: 1 addition & 1 deletion doc/requirements.rst
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ The `PlotPy` package requires the following Python modules:
- Version
- Summary
* - Python
- >=3.9, <4
- >=3.10, <4
- Python programming language
* - guidata
- >= 3.14.1
Expand Down
2 changes: 1 addition & 1 deletion plotpy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
.. _GitHub: https://github.com/PierreRaybaut/plotpy
"""

__version__ = "2.11.1"
__version__ = "2.12.0"
__VERSION__ = tuple([int(number) for number in __version__.split(".")])

# --- Important note: DATAPATH and LOCALEPATH are used by guidata.configtools
Expand Down
143 changes: 111 additions & 32 deletions plotpy/items/image/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import os
import os.path as osp
from collections import OrderedDict
from typing import TYPE_CHECKING, Any

import numpy as np
Expand Down Expand Up @@ -46,6 +47,7 @@
from plotpy.mathutils.arrayfuncs import get_nan_range
from plotpy.mathutils.colormap import FULLRANGE, get_cmap
from plotpy.styles.image import RawImageParam
from plotpy.widgets.colormap.widget import EditableColormap

if TYPE_CHECKING:
import guidata.io
Expand All @@ -57,7 +59,110 @@
from plotpy.interfaces import IItemType
from plotpy.plot import BasePlot
from plotpy.styles.base import ItemParameters
from plotpy.widgets.colormap.widget import EditableColormap

#: Lookup tables already computed by :func:`_get_colormap_lut`, most recent last
_LUT_CACHE: OrderedDict[tuple, tuple[tuple[int, ...], np.ndarray]] = OrderedDict()
_LUT_CACHE_SIZE = 32


def _lut_cache_key(
table: EditableColormap, alpha: float, alpha_function: int
) -> tuple | None:
"""Return the key identifying the lookup table of a colormap, if cacheable.
The key is built from the colormap content (not its identity), so that an
edited colormap gets a new lookup table.
Args:
table: Colormap
alpha: Global alpha value
alpha_function: Alpha function (see :class:`plotpy.constants.LUTAlpha`)
Returns:
Cache key, or None if the lookup table must be computed: colormap
subclasses may compute colors differently, and the colors of translucent
stops depend on the order in which the stops were inserted.
"""
if type(table) is not EditableColormap:
return None
stops = tuple((stop.pos, stop.rgb) for stop in table.colorStops())
if any(stop.a != 255 for stop in table.colorStops()):
return None
return (stops, table.mode(), table.invert, alpha, alpha_function)


def _compute_colormap_lut(
table: EditableColormap, alpha: float, alpha_function: int
) -> tuple[tuple[int, ...], np.ndarray]:
"""Compute the color table and the lookup table of a colormap.
Args:
table: Colormap
alpha: Global alpha value
alpha_function: Alpha function (see :class:`plotpy.constants.LUTAlpha`)
Returns:
Color table (256 colors) and lookup table (``LUT_SIZE`` ARGB values)
"""
colors = tuple(table.colorTable(FULLRANGE))
lut = np.zeros((LUT_SIZE,), np.uint32)
for i in range(LUT_SIZE):
if alpha_function == LUTAlpha.NONE.value:
pix_alpha = 1.0
elif alpha_function == LUTAlpha.CONSTANT.value:
pix_alpha = alpha
else:
x = i / float(LUT_SIZE - 1)
if alpha_function == LUTAlpha.LINEAR.value:
# Linear alpha function
pix_alpha = alpha * x
elif alpha_function == LUTAlpha.SIGMOID.value:
# Sigmoid alpha function
pix_alpha = alpha / (1 + np.exp(-10 * x))
elif alpha_function == LUTAlpha.TANH.value:
# Hyperbolic tangent alpha function
pix_alpha = alpha * np.tanh(5 * x)
elif alpha_function == LUTAlpha.STEP.value:
# Fully transparent lowest value and `alpha` transparent elsewhere
pix_alpha = alpha if x > 0 else 0
else:
raise ValueError(f"Invalid alpha function {alpha_function}")
# pylint: disable=too-many-function-args
alpha_channel = max(min(np.uint32(255 * pix_alpha + 0.5), 255), 0) << 24
lut[i] = (
np.uint32((table.rgb(FULLRANGE, i / LUT_MAX)) & 0xFFFFFF) | alpha_channel
)
return colors, lut


def _get_colormap_lut(
table: EditableColormap, alpha: float, alpha_function: int
) -> tuple[tuple[int, ...], np.ndarray]:
"""Return the color table and the lookup table of a colormap.
Computing them takes more than 1000 calls to the colormap: they are cached,
since every new image item usually shows the same colormap.
Args:
table: Colormap
alpha: Global alpha value
alpha_function: Alpha function (see :class:`plotpy.constants.LUTAlpha`)
Returns:
Color table (256 colors) and lookup table (``LUT_SIZE`` ARGB values),
the latter being read-only
"""
key = _lut_cache_key(table, alpha, alpha_function)
if key is not None and key in _LUT_CACHE:
_LUT_CACHE.move_to_end(key)
return _LUT_CACHE[key]
colors, lut = _compute_colormap_lut(table, alpha, alpha_function)
lut.flags.writeable = False
if key is not None:
_LUT_CACHE[key] = (colors, lut)
if len(_LUT_CACHE) > _LUT_CACHE_SIZE:
_LUT_CACHE.popitem(last=False)
return colors, lut


class BaseImageItem(QwtPlotItem):
Expand Down Expand Up @@ -466,37 +571,11 @@ def set_color_map(
if invert is not None:
table.invert = invert
self.cmap_table = table
self.cmap = table.colorTable(FULLRANGE)
cmap_a = self.lut[3]
alpha = self.param.alpha
alpha_function = self.param.alpha_function
for i in range(LUT_SIZE):
if alpha_function == LUTAlpha.NONE.value:
pix_alpha = 1.0
elif alpha_function == LUTAlpha.CONSTANT.value:
pix_alpha = alpha
else:
x = i / float(LUT_SIZE - 1)
if alpha_function == LUTAlpha.LINEAR.value:
# Linear alpha function
pix_alpha = alpha * x
elif alpha_function == LUTAlpha.SIGMOID.value:
# Sigmoid alpha function
pix_alpha = alpha / (1 + np.exp(-10 * x))
elif alpha_function == LUTAlpha.TANH.value:
# Hyperbolic tangent alpha function
pix_alpha = alpha * np.tanh(5 * x)
elif alpha_function == LUTAlpha.STEP.value:
# Fully transparent lowest value and `alpha` transparent elsewhere
pix_alpha = alpha if x > 0 else 0
else:
raise ValueError(f"Invalid alpha function {alpha_function}")
# pylint: disable=too-many-function-args
alpha_channel = max(min(np.uint32(255 * pix_alpha + 0.5), 255), 0) << 24
cmap_a[i] = (
np.uint32((table.rgb(FULLRANGE, i / LUT_MAX)) & 0xFFFFFF)
| alpha_channel
)
colors, lut = _get_colormap_lut(
table, self.param.alpha, self.param.alpha_function
)
self.cmap = list(colors)
self.lut[3][:] = lut
plot = self.plot()
if plot:
plot.update_colormap_axis(self)
Expand Down
5 changes: 4 additions & 1 deletion plotpy/locale/fr/LC_MESSAGES/plotpy.po
Original file line number Diff line number Diff line change
Expand Up @@ -1489,7 +1489,6 @@ msgstr "Sélection"
msgid "Polyline"
msgstr "Ligne brisée"

#, fuzzy
msgid "Polygon"
msgstr "Polygone"

Expand Down Expand Up @@ -1628,6 +1627,10 @@ msgstr "Paramètre d'ajustement"
msgid "Edit '{name}' fit parameter properties"
msgstr "Modifier les propriétés du paramètre d'ajustement '{name}'"

#, python-brace-format
msgid "This value is outside the slider range ({vmin} to {vmax})"
msgstr "Cette valeur est en dehors de la plage du curseur ({vmin} à {vmax})"

msgid "Fit parameters"
msgstr "Paramètres d'ajustement"

Expand Down
10 changes: 8 additions & 2 deletions plotpy/panels/csection/csplot.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,13 +167,19 @@ def get_cross_section_curves(self) -> list[CrossSectionItem]:
def items_changed(self, plot: BasePlot) -> None:
"""Items have changed in the plot

Cross section curves are kept for visible image items only: the curve
of an image item that is hidden or removed is deleted, and created
again when the item is shown. Keeping the curves of hidden items would
make every change cost more as images accumulate in the plot (e.g. an
application showing one image at a time among many hidden ones).

Args:
plot: Plot containing the image items to be used for cross section
"""
# Del obsolete cross section items
new_sources = plot.get_items(item_type=ICSImageItemType)
# Del cross section items of removed or hidden image items
for source in self.known_items.copy():
if source not in new_sources:
if source not in new_sources or not source.isVisible():
curve = self.known_items.pop(source)
curve.clear_data() # useful to emit SIG_CS_CURVE_CHANGED
# (eventually notify other panels that the
Expand Down
4 changes: 2 additions & 2 deletions plotpy/panels/itemlist.py
Original file line number Diff line number Diff line change
Expand Up @@ -233,9 +233,9 @@ def active_item_changed(self, plot: BasePlot) -> None:
self.plot = plot
_block = self.blockSignals(True)
active = plot.get_active_item()
for item in self.items:
for row, item in enumerate(self.items):
title = item.title().text()
lw_item = self.item(self.items.index(item))
lw_item = self.item(row)
lw_item.setText(title)
lw_item.setSelected(item.selected)
font = lw_item.font()
Expand Down
20 changes: 17 additions & 3 deletions plotpy/plot/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -886,6 +886,10 @@ def set_title(self, title: str) -> None:
Args:
title (str): the title
"""
current = self.title()
if current.text() == title and current.font().key() == self.font_title.key():
# Nothing changed: setting the title would lay out the plot again
return
text = qwt.QwtText(title)
text.setFont(self.font_title)
self.setTitle(text)
Expand Down Expand Up @@ -1044,18 +1048,28 @@ def update_axis_style(self, axis_id: int) -> None:

title_font = style.title_font.build_font()
ticks_font = style.ticks_font.build_font()
self.setAxisFont(axis_id, ticks_font)

if style.title and style.unit:
title = f"{style.title} ({style.unit})"
elif style.title:
title = style.title
else:
title = style.unit
color = QG.QColor(style.color)
axis_text = self.axisTitle(axis_id)
# Fonts are compared by key: the widget font is resolved against its
# parent font, so it never compares equal to the built font
if (
self.axisFont(axis_id).key() == ticks_font.key()
and axis_text.text() == title
and axis_text.font().key() == title_font.key()
and axis_text.color() == color
):
# Nothing changed: setting the axis title would lay out the plot again
return
self.setAxisFont(axis_id, ticks_font)
axis_text.setFont(title_font)
axis_text.setText(title)
axis_text.setColor(QG.QColor(style.color))
axis_text.setColor(color)
self.setAxisTitle(axis_id, axis_text)
self.SIG_PLOT_LABELS_CHANGED.emit(self)

Expand Down
Loading
Loading