Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 3 additions & 0 deletions doc/release_notes/release_2.12.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@
* **Editing points of downsampled curves** — With downsampling enabled, moving a curve point modified another point of the curve data than the one shown, since the handle of a drawn point was used as an index into the full data
* **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
* **Cross sections of images added or shown again** — When an image was added to the plot, or shown again after having been hidden, while a cross-section marker or shape was active, its X/Y cross-section curves remained empty (image added) or showed the cross section at a former position of the marker (image shown again) until the marker was moved. These curves are now computed as soon as the image is shown, unless the auto-refresh of the cross-section panel is disabled. The shade under the cross-section curves no longer fades as hidden images accumulate in the plot (closes [Issue #89](https://github.com/PlotPyStack/PlotPy/issues/89))
* **Custom colormaps location** — Custom colormaps were saved in a `.none` directory of the user's configuration directory (`~/.config/.none` on Linux, `%USERPROFILE%\.none` on Windows), shared by all applications whatever their own configuration directory, and this directory was created as soon as colormaps were loaded. Custom colormaps are now saved in PlotPy's configuration directory (`.plotpy` by default) or, if the application sets the name of PlotPy's configuration with `plotpy.config.CONF.set_application` (e.g. `"MyApp/plotpy"`), in the configuration directory of this application, from which they are reloaded even if the name is set after PlotPy was imported. The first time, custom colormaps saved in the former `.none` and `.plotpy` directories are copied to the new location, without modifying the former files. Looking up the location of PlotPy's configuration files no longer creates any directory (closes [Issue #70](https://github.com/PlotPyStack/PlotPy/issues/70))
* **Anti-aliasing interpolation** — With the anti-aliasing interpolation, replacing the data of an image by data of another type (e.g. 16-bit integers by floating-point numbers) made the next drawing of the image fail, which aborted the application with PyQt5. Exporting an image area with its interpolation but without its contrast settings (e.g. with the rectangle snapshot tool) failed with `TypeError: AA Mask data type must be ...` errors when the Z-axis logarithmic scale was enabled, or after such a data type change. Images are now drawn and exported whatever their data type and their Z-axis scale (closes [Issue #89](https://github.com/PlotPyStack/PlotPy/issues/89))
* **Python and libraries information** — `plotpy.widgets.about.get_python_libs_info` and the `plotpy-benchmarks` script failed with `AttributeError: module 'guidata.widgets.about' has no attribute 'get_python_libs_info'` (closes [Issue #89](https://github.com/PlotPyStack/PlotPy/issues/89))

⚡ Performance:

Expand Down
38 changes: 34 additions & 4 deletions plotpy/items/image/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -516,6 +516,8 @@ def set_data(
# display would keep using the previous (now stale) log data.
if self._is_zaxis_log:
self._recompute_log_data()
# The new data may have another data type than the previous one
self._sync_interpolation_dtype()
if not self.param.keep_lut_range:
if self._is_zaxis_log:
# The linear LUT range, restored when the log scale is disabled,
Expand Down Expand Up @@ -653,6 +655,33 @@ def get_interpolation(self) -> tuple[int] | tuple[int, np.ndarray]:
"""Get interpolation mode"""
return self.interpolate

def _get_interpolation_for(
self, data: np.ndarray
) -> tuple[int] | tuple[int, np.ndarray]:
"""Return the interpolation mode to be used to scale the given data

The scaling functions require the anti-aliasing kernel to have the data
type of the data they scale, which is not always the data drawn (e.g. the
raw data, exported without the LUT, instead of its logarithm drawn with
the Z-axis logarithmic scale).

Args:
data: Data to be scaled

Returns:
Interpolation mode, with an anti-aliasing kernel of the data type of
the given data
"""
interp = self.interpolate
if interp[0] == INTERP_AA and interp[1].dtype != data.dtype:
interp = (INTERP_AA, interp[1].astype(data.dtype))
return interp

def _sync_interpolation_dtype(self) -> None:
"""Give the anti-aliasing kernel the data type of the data drawn"""
if self.data is not None:
self.interpolate = self._get_interpolation_for(self._get_display_data())

def set_lut_range(self, lut_range: tuple[float, float]) -> None:
"""
Set the current active lut range
Expand Down Expand Up @@ -759,9 +788,7 @@ def set_zaxis_log_state(self, state: bool) -> None:
self._is_zaxis_log = False
self._log_data = None
self.set_lut_range(self._lin_lut_range)
if self.data is not None and self.interpolate[0] == INTERP_AA:
dtype = self._get_display_data().dtype
self.interpolate = (INTERP_AA, self.interpolate[1].astype(dtype))
self._sync_interpolation_dtype()
plot = self.plot()
if plot is not None:
plot.update_colormap_axis(self)
Expand Down Expand Up @@ -892,7 +919,10 @@ def export_roi(
else:
a, b = 1.0, 0.0
data = self.data
interp = self.interpolate if apply_interpolation else (INTERP_NEAREST,)
if apply_interpolation:
interp = self._get_interpolation_for(data)
else:
interp = (INTERP_NEAREST,)
_scale_rect(data, src_rect, dst_image, dst_rect, (a, b, None), interp)

# ---- QwtPlotItem API -----------------------------------------------------
Expand Down
10 changes: 8 additions & 2 deletions plotpy/items/image/standard.py
Original file line number Diff line number Diff line change
Expand Up @@ -404,7 +404,10 @@ def export_roi(
else:
a, b = 1.0, 0.0
data = self.data
interp = self.interpolate if apply_interpolation else (INTERP_NEAREST,)
if apply_interpolation:
interp = self._get_interpolation_for(data)
else:
interp = (INTERP_NEAREST,)
rescaled_src = self._rescale_src_rect(src_rect)
_scale_rect(
data,
Expand Down Expand Up @@ -697,7 +700,10 @@ def export_roi(
a, b = 1.0, 0.0
lut = (a, b, None, np.zeros((1,), np.uint32))
data = self.data
interp = self.interpolate if apply_interpolation else (INTERP_NEAREST,)
if apply_interpolation:
interp = self._get_interpolation_for(data)
else:
interp = (INTERP_NEAREST,)
xytr = self.x, self.y, src_rect
_scale_xy(data, xytr, dst_image, dst_rect, lut, interp)

Expand Down
2 changes: 1 addition & 1 deletion plotpy/items/image/transform.py
Original file line number Diff line number Diff line change
Expand Up @@ -360,7 +360,7 @@ def export_roi(
aa = np.ones((force_interp_size, force_interp_size), data.dtype)
interp = (force_interp_mode, aa)
else: # don't force interpolation, keep current image interpolation
interp = self.interpolate
interp = self._get_interpolation_for(data)
else: # don't apply interpolation --> INTERP_NEAREST
interp = (INTERP_NEAREST,)
_scale_tr(data, mat, dst_image, dst_rect, (a, b, None), interp)
Expand Down
32 changes: 24 additions & 8 deletions plotpy/panels/csection/csplot.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,34 +173,50 @@ def items_changed(self, plot: BasePlot) -> None:
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).

The curves of image items which are added or shown again are computed
right away from the last marker or shape used for the cross section,
unless the auto-refresh mode is disabled.

Args:
plot: Plot containing the image items to be used for cross section
"""
new_sources = plot.get_items(item_type=ICSImageItemType)
visible_sources = [source for source in new_sources if source.isVisible()]
# Del cross section items of removed or hidden image items
for source in self.known_items.copy():
if source not in new_sources or not source.isVisible():
if source not in visible_sources:
curve = self.known_items.pop(source)
curve.clear_data() # useful to emit SIG_CS_CURVE_CHANGED
# (eventually notify other panels that the
# cross section curve is now empty)
self.del_item(curve)

# Add cross section items of added or shown again image items
# (the shade is applied to the curves when they are created)
if visible_sources:
self.param.shade = self.SHADE / len(visible_sources)
new_curves: list[CrossSectionItem] = []
for source in visible_sources:
if source not in self.known_items:
if not self.single_source or not self.known_items:
self.add_cross_section_item(source=source)
new_curves.append(self.known_items[source])

# Update plot only to show/hide cross section curves according to
# the associated image item visibility state (hence `refresh=False`)
self.update_plot(refresh=False)

# Compute the new curves only: the other curves are already up to date
obj = self.get_last_obj()
if new_curves and self.autorefresh_mode and obj is not None:
if obj.plot() is not None:
for curve in new_curves:
curve.update_item(obj)

self.plot_axis_changed(plot)

if not new_sources:
self.replot()
return

self.param.shade = self.SHADE / len(new_sources)
for source in new_sources:
if source not in self.known_items and source.isVisible():
if not self.single_source or not self.known_items:
self.add_cross_section_item(source=source)

def active_item_changed(self, plot: BasePlot) -> None:
"""Active item has just changed
Expand Down
4 changes: 2 additions & 2 deletions plotpy/tests/benchmarks/test_benchmarks.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,11 @@
import pytest
from guidata.env import execenv
from guidata.qthelpers import qt_app_context
from guidata.widgets import about
from qtpy import QtWidgets as QW

from plotpy.builder import make
from plotpy.plot import PlotOptions, PlotWindow
from plotpy.widgets.about import get_python_libs_info


class BaseBM:
Expand All @@ -51,7 +51,7 @@ class BaseBM:
@classmethod
def print_header(cls):
"""Print header for benchmark results"""
execenv.print(f"PlotPy plot benchmark [{about.get_python_libs_info()}]")
execenv.print(f"PlotPy plot benchmark [{get_python_libs_info()}]")
execenv.print()
table_header = (
"N".rjust(10) + " | " + "∆t (ms)".rjust(7) + " | " + "Description"
Expand Down
36 changes: 36 additions & 0 deletions plotpy/tests/unit/test_about.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# -*- coding: utf-8 -*-
#
# Licensed under the terms of the BSD 3-Clause
# (see plotpy/LICENSE for details)

"""Python and libraries information shown by PlotPy (Issue #89)."""

from __future__ import annotations

import plotpy
from plotpy.tests.benchmarks.test_benchmarks import BaseBM
from plotpy.widgets.about import about, get_python_libs_info


def test_python_libs_info() -> None:
"""The information gives the versions of Python and PlotPy"""
info = get_python_libs_info()
assert info.startswith("Python ")
assert f"PlotPy {plotpy.__version__}" in info
assert f"PlotPy {plotpy.__version__}, extra" in get_python_libs_info("extra")


def test_about_text() -> None:
"""The about text gives the version of PlotPy"""
assert plotpy.__version__ in about(html=False)


def test_benchmark_header() -> None:
"""The benchmark header (`plotpy-benchmarks`) can be printed"""
BaseBM.print_header()


if __name__ == "__main__":
test_python_libs_info()
test_about_text()
test_benchmark_header()
110 changes: 108 additions & 2 deletions plotpy/tests/unit/test_cross_section_visible_items.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,55 @@
# Licensed under the terms of the BSD 3-Clause
# (see plotpy/LICENSE for details)

"""Cross section panels keep curves for visible images only; unchanged axis
styles do not lay out the plot again."""
"""Cross section panels keep curves for visible images only, and compute the
curves of images added or shown again; unchanged axis styles do not lay out the
plot again."""

from __future__ import annotations

import numpy as np
import pytest
from guidata.qthelpers import qt_app_context
from qtpy import QtWidgets as QW

from plotpy.builder import make


def _window():
# In unattended mode, leaving a `qt_app_context` schedules the closing of all
# top-level widgets: process it now, so that it does not close this window
QW.QApplication.processEvents()
win = make.dialog(type="image", show_xsection=True, show_ysection=True)
win.show()
return win


def _cs_plots(win):
"""Return the X and Y cross section plots of the window"""
return win.manager.get_xcs_panel().cs_plot, win.manager.get_ycs_panel().cs_plot


def _add_marker(win, x: float, y: float):
"""Add a marker to the plot and register it for the cross sections"""
plot = win.manager.get_plot()
marker = make.marker(position=(x, y))
plot.add_item(marker)
for cs_plot in _cs_plots(win):
cs_plot.register_shape(plot, marker)
return marker


def _assert_sections(win, image, x: float, y: float) -> None:
"""Assert that the cross section curves of the image are the sections at
(x, y)"""
xcs, ycs = _cs_plots(win)
xcurve, ycurve = xcs.known_items[image], ycs.known_items[image]
assert xcurve.dataSize() > 0 and ycurve.dataSize() > 0
# The Y cross section curve is vertical: section values are its X data
assert np.array_equal(xcurve.get_data()[1], image.get_xsection(y)[1])
assert np.array_equal(ycurve.get_data()[0], image.get_ysection(x)[1])


def test_cross_section_curves_follow_visible_images():
"""Hidden images have no cross section curve, until they are shown again."""
with qt_app_context(exec_loop=False):
Expand Down Expand Up @@ -49,6 +80,77 @@ def test_cross_section_curves_follow_visible_images():
win.close()


def test_cross_section_computed_when_image_shown_again():
"""The cross sections of an image shown again are those at the marker
position, even if the marker was moved while the image was hidden."""
with qt_app_context(exec_loop=False):
win = _window()
plot = win.manager.get_plot()
image1 = make.image(np.arange(16 * 16, dtype=float).reshape(16, 16))
image2 = make.image(np.full((16, 16), 42.0))
plot.add_item(image1)
plot.add_item(image2)
marker = _add_marker(win, 5, 5)
_assert_sections(win, image1, 5, 5)
plot.set_item_visible(image1, False)
marker.set_pos(10, 12)
plot.set_item_visible(image1, True)
_assert_sections(win, image1, 10, 12)
_assert_sections(win, image2, 10, 12)
win.close()


def test_cross_section_computed_for_added_image():
"""The cross sections of an image added to the plot are computed at once."""
with qt_app_context(exec_loop=False):
win = _window()
plot = win.manager.get_plot()
plot.add_item(make.image(np.full((16, 16), 42.0)))
_add_marker(win, 5, 7)
image = make.image(np.arange(16 * 16, dtype=float).reshape(16, 16))
plot.add_item(image)
_assert_sections(win, image, 5, 7)
win.close()


def test_cross_section_of_shown_image_follows_autorefresh_mode():
"""With the auto-refresh mode disabled, the cross section curves of an image
shown again are computed on the next refresh only."""
with qt_app_context(exec_loop=False):
win = _window()
plot = win.manager.get_plot()
xcs, _ycs = _cs_plots(win)
image = make.image(np.arange(16 * 16, dtype=float).reshape(16, 16))
plot.add_item(image)
_add_marker(win, 5, 5)
xcs.toggle_autorefresh(False)
plot.set_item_visible(image, False)
plot.set_item_visible(image, True)
assert xcs.known_items[image].dataSize() == 0
xcs.update_plot()
assert xcs.known_items[image].dataSize() == 16
win.close()


def test_cross_section_shade_counts_visible_images_only():
"""The shade of the cross section curves ignores hidden images."""
with qt_app_context(exec_loop=False):
win = _window()
plot = win.manager.get_plot()
xcs, _ycs = _cs_plots(win)
images = []
for _index in range(4):
if images:
plot.set_item_visible(images[-1], False)
image = make.image(np.ones((16, 16)))
plot.add_item(image)
images.append(image)
assert xcs.param.shade == pytest.approx(xcs.SHADE)
brush_color = xcs.known_items[images[-1]].brush().color()
assert brush_color.alphaF() == pytest.approx(xcs.SHADE, abs=1e-2)
win.close()


def test_unchanged_axis_style_does_not_relayout():
"""Setting the same titles again neither lays out the plot nor notifies."""
with qt_app_context(exec_loop=False):
Expand All @@ -70,4 +172,8 @@ def test_unchanged_axis_style_does_not_relayout():

if __name__ == "__main__":
test_cross_section_curves_follow_visible_images()
test_cross_section_computed_when_image_shown_again()
test_cross_section_computed_for_added_image()
test_cross_section_of_shown_image_follows_autorefresh_mode()
test_cross_section_shade_counts_visible_images_only()
test_unchanged_axis_style_does_not_relayout()
Loading
Loading