diff --git a/doc/release_notes/release_2.12.md b/doc/release_notes/release_2.12.md index d6bfb8b..72111ab 100644 --- a/doc/release_notes/release_2.12.md +++ b/doc/release_notes/release_2.12.md @@ -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: diff --git a/plotpy/items/image/base.py b/plotpy/items/image/base.py index 6b25ac8..838d720 100644 --- a/plotpy/items/image/base.py +++ b/plotpy/items/image/base.py @@ -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, @@ -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 @@ -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) @@ -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 ----------------------------------------------------- diff --git a/plotpy/items/image/standard.py b/plotpy/items/image/standard.py index 7c65b4e..9b67d9a 100644 --- a/plotpy/items/image/standard.py +++ b/plotpy/items/image/standard.py @@ -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, @@ -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) diff --git a/plotpy/items/image/transform.py b/plotpy/items/image/transform.py index c76dc2d..0ae84d8 100644 --- a/plotpy/items/image/transform.py +++ b/plotpy/items/image/transform.py @@ -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) diff --git a/plotpy/panels/csection/csplot.py b/plotpy/panels/csection/csplot.py index 5f84fb1..c60c772 100644 --- a/plotpy/panels/csection/csplot.py +++ b/plotpy/panels/csection/csplot.py @@ -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 diff --git a/plotpy/tests/benchmarks/test_benchmarks.py b/plotpy/tests/benchmarks/test_benchmarks.py index 392b9e4..e0714ec 100644 --- a/plotpy/tests/benchmarks/test_benchmarks.py +++ b/plotpy/tests/benchmarks/test_benchmarks.py @@ -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: @@ -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" diff --git a/plotpy/tests/unit/test_about.py b/plotpy/tests/unit/test_about.py new file mode 100644 index 0000000..ae0b816 --- /dev/null +++ b/plotpy/tests/unit/test_about.py @@ -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() diff --git a/plotpy/tests/unit/test_cross_section_visible_items.py b/plotpy/tests/unit/test_cross_section_visible_items.py index 1f6dab9..142cc95 100644 --- a/plotpy/tests/unit/test_cross_section_visible_items.py +++ b/plotpy/tests/unit/test_cross_section_visible_items.py @@ -3,12 +3,14 @@ # 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 @@ -16,11 +18,40 @@ 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): @@ -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): @@ -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() diff --git a/plotpy/tests/unit/test_image_aa_kernel.py b/plotpy/tests/unit/test_image_aa_kernel.py new file mode 100644 index 0000000..3f55c5b --- /dev/null +++ b/plotpy/tests/unit/test_image_aa_kernel.py @@ -0,0 +1,128 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the terms of the BSD 3-Clause +# (see plotpy/LICENSE for details) + +"""Anti-aliasing interpolation kernel of image items (Issue #89). + +The scaling functions require the anti-aliasing kernel to have the data type of +the data they scale: exporting and drawing an image must work whatever the Z-axis +scale and the data type of the image data. +""" + +from __future__ import annotations + +import numpy as np +import pytest +from guidata.qthelpers import qt_app_context +from qtpy import QtCore as QC +from qtpy import QtGui as QG +from qtpy import QtWidgets as QW +from qwt import QwtScaleMap + +from plotpy.builder import make +from plotpy.items import assemble_imageitems + +KINDS = ("image", "xyimage", "trimage", "maskedimage", "maskedxyimage") +DTYPES = (np.uint8, np.uint16, np.int32, np.float32) +NY, NX = 6, 8 + + +def _data(dtype: type) -> np.ndarray: + """Return image data of the given data type""" + return np.arange(1, NY * NX + 1).reshape(NY, NX).astype(dtype) + + +def _make_item(kind: str, data: np.ndarray): + """Return an image item of the given kind, with the anti-aliasing + interpolation""" + x, y = np.arange(NX, dtype=float), np.arange(NY, dtype=float) + mask = np.zeros(data.shape, dtype=bool) + interpolation = "antialiasing" + builders = { + "image": lambda: make.image(data, interpolation=interpolation), + "xyimage": lambda: make.xyimage(x, y, data, interpolation=interpolation), + "trimage": lambda: make.trimage(data, interpolation=interpolation), + "maskedimage": lambda: make.maskedimage( + data, mask, interpolation=interpolation + ), + "maskedxyimage": lambda: make.maskedxyimage( + x, y, data, mask, interpolation=interpolation + ), + } + return builders[kind]() + + +def _export(item, apply_lut: bool) -> np.ndarray: + """Export the image with its interpolation""" + rect = QC.QRectF(0, 0, NX, NY) + return assemble_imageitems( + [item], rect, NX, NY, apply_lut=apply_lut, apply_interpolation=True + ) + + +def _draw(item) -> None: + """Draw the image item on an image, raising the exceptions which would be + raised in the plot's paint event""" + width, height = 4 * NX, 4 * NY + xmap, ymap = QwtScaleMap(), QwtScaleMap() + xmap.setPaintInterval(0, width) + xmap.setScaleInterval(0, NX) + ymap.setPaintInterval(0, height) + ymap.setScaleInterval(0, NY) + image = QG.QImage(width, height, QG.QImage.Format_ARGB32) + painter = QG.QPainter(image) + try: + item.draw(painter, xmap, ymap, QC.QRectF(0, 0, width, height)) + finally: + painter.end() + + +def _plot_with_item(item): + """Return a plot window showing the image item""" + # 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") + win.manager.get_plot().add_item(item) + return win + + +@pytest.mark.parametrize("dtype", DTYPES) +@pytest.mark.parametrize("kind", KINDS) +def test_export_with_anti_aliasing(kind: str, dtype: type) -> None: + """Exporting an image area with the anti-aliasing interpolation works with or + without the LUT, with or without the Z-axis log scale""" + with qt_app_context(exec_loop=False): + item = _make_item(kind, _data(dtype)) + for log_state in (False, True): + item.set_zaxis_log_state(log_state) + for apply_lut in (False, True): + assert _export(item, apply_lut).shape == (NY, NX) + + +@pytest.mark.parametrize("kind", KINDS) +def test_set_data_of_another_data_type(kind: str) -> None: + """After replacing the data by data of another type, the kernel has the data + type of the data drawn, and the image can be drawn and exported""" + with qt_app_context(exec_loop=False): + item = _make_item(kind, _data(np.uint16)) + win = _plot_with_item(item) + for log_state in (False, True): + item.set_zaxis_log_state(log_state) + for dtype in (np.float32, np.uint8): + item.set_data(_data(dtype)) + # With the log scale, the data drawn is the float64 logarithm + expected_dtype = np.float64 if log_state else dtype + assert item.interpolate[1].dtype == expected_dtype + _draw(item) + for apply_lut in (False, True): + assert _export(item, apply_lut).shape == (NY, NX) + win.close() + + +if __name__ == "__main__": + for _kind in KINDS: + for _dtype in DTYPES: + test_export_with_anti_aliasing(_kind, _dtype) + test_set_data_of_another_data_type(_kind) diff --git a/plotpy/widgets/about.py b/plotpy/widgets/about.py index 8c49377..da00e55 100644 --- a/plotpy/widgets/about.py +++ b/plotpy/widgets/about.py @@ -30,7 +30,7 @@ def get_python_libs_info(addinfo: str = "") -> str: if addinfo: addinfo = ", " + addinfo addinfo = f"PlotPy {plotpy.__version__}{addinfo}" - return guidata_about.get_python_libs_info(addinfo) + return guidata_about.get_python_libs_infos(addinfo) def about(html: bool = True, copyright_only: bool = False) -> str: