Skip to content

Commit 615a4f8

Browse files
Fix empty cross sections, anti-aliasing kernel TypeError and About AttributeError (#90)
Fix empty cross sections, anti-aliasing kernel TypeError and About AttributeError
2 parents 9984b49 + 12a62cc commit 615a4f8

10 files changed

Lines changed: 345 additions & 20 deletions

File tree

‎doc/release_notes/release_2.12.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,10 @@
1212
* **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
1313
* **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))
1414
* **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
15+
* **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))
1516
* **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))
17+
* **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))
18+
* **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))
1619

1720
⚡ Performance:
1821

‎plotpy/items/image/base.py‎

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -516,6 +516,8 @@ def set_data(
516516
# display would keep using the previous (now stale) log data.
517517
if self._is_zaxis_log:
518518
self._recompute_log_data()
519+
# The new data may have another data type than the previous one
520+
self._sync_interpolation_dtype()
519521
if not self.param.keep_lut_range:
520522
if self._is_zaxis_log:
521523
# 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]:
653655
"""Get interpolation mode"""
654656
return self.interpolate
655657

658+
def _get_interpolation_for(
659+
self, data: np.ndarray
660+
) -> tuple[int] | tuple[int, np.ndarray]:
661+
"""Return the interpolation mode to be used to scale the given data
662+
663+
The scaling functions require the anti-aliasing kernel to have the data
664+
type of the data they scale, which is not always the data drawn (e.g. the
665+
raw data, exported without the LUT, instead of its logarithm drawn with
666+
the Z-axis logarithmic scale).
667+
668+
Args:
669+
data: Data to be scaled
670+
671+
Returns:
672+
Interpolation mode, with an anti-aliasing kernel of the data type of
673+
the given data
674+
"""
675+
interp = self.interpolate
676+
if interp[0] == INTERP_AA and interp[1].dtype != data.dtype:
677+
interp = (INTERP_AA, interp[1].astype(data.dtype))
678+
return interp
679+
680+
def _sync_interpolation_dtype(self) -> None:
681+
"""Give the anti-aliasing kernel the data type of the data drawn"""
682+
if self.data is not None:
683+
self.interpolate = self._get_interpolation_for(self._get_display_data())
684+
656685
def set_lut_range(self, lut_range: tuple[float, float]) -> None:
657686
"""
658687
Set the current active lut range
@@ -759,9 +788,7 @@ def set_zaxis_log_state(self, state: bool) -> None:
759788
self._is_zaxis_log = False
760789
self._log_data = None
761790
self.set_lut_range(self._lin_lut_range)
762-
if self.data is not None and self.interpolate[0] == INTERP_AA:
763-
dtype = self._get_display_data().dtype
764-
self.interpolate = (INTERP_AA, self.interpolate[1].astype(dtype))
791+
self._sync_interpolation_dtype()
765792
plot = self.plot()
766793
if plot is not None:
767794
plot.update_colormap_axis(self)
@@ -892,7 +919,10 @@ def export_roi(
892919
else:
893920
a, b = 1.0, 0.0
894921
data = self.data
895-
interp = self.interpolate if apply_interpolation else (INTERP_NEAREST,)
922+
if apply_interpolation:
923+
interp = self._get_interpolation_for(data)
924+
else:
925+
interp = (INTERP_NEAREST,)
896926
_scale_rect(data, src_rect, dst_image, dst_rect, (a, b, None), interp)
897927

898928
# ---- QwtPlotItem API -----------------------------------------------------

‎plotpy/items/image/standard.py‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -404,7 +404,10 @@ def export_roi(
404404
else:
405405
a, b = 1.0, 0.0
406406
data = self.data
407-
interp = self.interpolate if apply_interpolation else (INTERP_NEAREST,)
407+
if apply_interpolation:
408+
interp = self._get_interpolation_for(data)
409+
else:
410+
interp = (INTERP_NEAREST,)
408411
rescaled_src = self._rescale_src_rect(src_rect)
409412
_scale_rect(
410413
data,
@@ -697,7 +700,10 @@ def export_roi(
697700
a, b = 1.0, 0.0
698701
lut = (a, b, None, np.zeros((1,), np.uint32))
699702
data = self.data
700-
interp = self.interpolate if apply_interpolation else (INTERP_NEAREST,)
703+
if apply_interpolation:
704+
interp = self._get_interpolation_for(data)
705+
else:
706+
interp = (INTERP_NEAREST,)
701707
xytr = self.x, self.y, src_rect
702708
_scale_xy(data, xytr, dst_image, dst_rect, lut, interp)
703709

‎plotpy/items/image/transform.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -360,7 +360,7 @@ def export_roi(
360360
aa = np.ones((force_interp_size, force_interp_size), data.dtype)
361361
interp = (force_interp_mode, aa)
362362
else: # don't force interpolation, keep current image interpolation
363-
interp = self.interpolate
363+
interp = self._get_interpolation_for(data)
364364
else: # don't apply interpolation --> INTERP_NEAREST
365365
interp = (INTERP_NEAREST,)
366366
_scale_tr(data, mat, dst_image, dst_rect, (a, b, None), interp)

‎plotpy/panels/csection/csplot.py‎

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -173,34 +173,50 @@ def items_changed(self, plot: BasePlot) -> None:
173173
make every change cost more as images accumulate in the plot (e.g. an
174174
application showing one image at a time among many hidden ones).
175175
176+
The curves of image items which are added or shown again are computed
177+
right away from the last marker or shape used for the cross section,
178+
unless the auto-refresh mode is disabled.
179+
176180
Args:
177181
plot: Plot containing the image items to be used for cross section
178182
"""
179183
new_sources = plot.get_items(item_type=ICSImageItemType)
184+
visible_sources = [source for source in new_sources if source.isVisible()]
180185
# Del cross section items of removed or hidden image items
181186
for source in self.known_items.copy():
182-
if source not in new_sources or not source.isVisible():
187+
if source not in visible_sources:
183188
curve = self.known_items.pop(source)
184189
curve.clear_data() # useful to emit SIG_CS_CURVE_CHANGED
185190
# (eventually notify other panels that the
186191
# cross section curve is now empty)
187192
self.del_item(curve)
188193

194+
# Add cross section items of added or shown again image items
195+
# (the shade is applied to the curves when they are created)
196+
if visible_sources:
197+
self.param.shade = self.SHADE / len(visible_sources)
198+
new_curves: list[CrossSectionItem] = []
199+
for source in visible_sources:
200+
if source not in self.known_items:
201+
if not self.single_source or not self.known_items:
202+
self.add_cross_section_item(source=source)
203+
new_curves.append(self.known_items[source])
204+
189205
# Update plot only to show/hide cross section curves according to
190206
# the associated image item visibility state (hence `refresh=False`)
191207
self.update_plot(refresh=False)
192208

209+
# Compute the new curves only: the other curves are already up to date
210+
obj = self.get_last_obj()
211+
if new_curves and self.autorefresh_mode and obj is not None:
212+
if obj.plot() is not None:
213+
for curve in new_curves:
214+
curve.update_item(obj)
215+
193216
self.plot_axis_changed(plot)
194217

195218
if not new_sources:
196219
self.replot()
197-
return
198-
199-
self.param.shade = self.SHADE / len(new_sources)
200-
for source in new_sources:
201-
if source not in self.known_items and source.isVisible():
202-
if not self.single_source or not self.known_items:
203-
self.add_cross_section_item(source=source)
204220

205221
def active_item_changed(self, plot: BasePlot) -> None:
206222
"""Active item has just changed

‎plotpy/tests/benchmarks/test_benchmarks.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,11 @@
3535
import pytest
3636
from guidata.env import execenv
3737
from guidata.qthelpers import qt_app_context
38-
from guidata.widgets import about
3938
from qtpy import QtWidgets as QW
4039

4140
from plotpy.builder import make
4241
from plotpy.plot import PlotOptions, PlotWindow
42+
from plotpy.widgets.about import get_python_libs_info
4343

4444

4545
class BaseBM:
@@ -51,7 +51,7 @@ class BaseBM:
5151
@classmethod
5252
def print_header(cls):
5353
"""Print header for benchmark results"""
54-
execenv.print(f"PlotPy plot benchmark [{about.get_python_libs_info()}]")
54+
execenv.print(f"PlotPy plot benchmark [{get_python_libs_info()}]")
5555
execenv.print()
5656
table_header = (
5757
"N".rjust(10) + " | " + "∆t (ms)".rjust(7) + " | " + "Description"

‎plotpy/tests/unit/test_about.py‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# -*- coding: utf-8 -*-
2+
#
3+
# Licensed under the terms of the BSD 3-Clause
4+
# (see plotpy/LICENSE for details)
5+
6+
"""Python and libraries information shown by PlotPy (Issue #89)."""
7+
8+
from __future__ import annotations
9+
10+
import plotpy
11+
from plotpy.tests.benchmarks.test_benchmarks import BaseBM
12+
from plotpy.widgets.about import about, get_python_libs_info
13+
14+
15+
def test_python_libs_info() -> None:
16+
"""The information gives the versions of Python and PlotPy"""
17+
info = get_python_libs_info()
18+
assert info.startswith("Python ")
19+
assert f"PlotPy {plotpy.__version__}" in info
20+
assert f"PlotPy {plotpy.__version__}, extra" in get_python_libs_info("extra")
21+
22+
23+
def test_about_text() -> None:
24+
"""The about text gives the version of PlotPy"""
25+
assert plotpy.__version__ in about(html=False)
26+
27+
28+
def test_benchmark_header() -> None:
29+
"""The benchmark header (`plotpy-benchmarks`) can be printed"""
30+
BaseBM.print_header()
31+
32+
33+
if __name__ == "__main__":
34+
test_python_libs_info()
35+
test_about_text()
36+
test_benchmark_header()

‎plotpy/tests/unit/test_cross_section_visible_items.py‎

Lines changed: 108 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,24 +3,55 @@
33
# Licensed under the terms of the BSD 3-Clause
44
# (see plotpy/LICENSE for details)
55

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

910
from __future__ import annotations
1011

1112
import numpy as np
13+
import pytest
1214
from guidata.qthelpers import qt_app_context
1315
from qtpy import QtWidgets as QW
1416

1517
from plotpy.builder import make
1618

1719

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

2328

29+
def _cs_plots(win):
30+
"""Return the X and Y cross section plots of the window"""
31+
return win.manager.get_xcs_panel().cs_plot, win.manager.get_ycs_panel().cs_plot
32+
33+
34+
def _add_marker(win, x: float, y: float):
35+
"""Add a marker to the plot and register it for the cross sections"""
36+
plot = win.manager.get_plot()
37+
marker = make.marker(position=(x, y))
38+
plot.add_item(marker)
39+
for cs_plot in _cs_plots(win):
40+
cs_plot.register_shape(plot, marker)
41+
return marker
42+
43+
44+
def _assert_sections(win, image, x: float, y: float) -> None:
45+
"""Assert that the cross section curves of the image are the sections at
46+
(x, y)"""
47+
xcs, ycs = _cs_plots(win)
48+
xcurve, ycurve = xcs.known_items[image], ycs.known_items[image]
49+
assert xcurve.dataSize() > 0 and ycurve.dataSize() > 0
50+
# The Y cross section curve is vertical: section values are its X data
51+
assert np.array_equal(xcurve.get_data()[1], image.get_xsection(y)[1])
52+
assert np.array_equal(ycurve.get_data()[0], image.get_ysection(x)[1])
53+
54+
2455
def test_cross_section_curves_follow_visible_images():
2556
"""Hidden images have no cross section curve, until they are shown again."""
2657
with qt_app_context(exec_loop=False):
@@ -49,6 +80,77 @@ def test_cross_section_curves_follow_visible_images():
4980
win.close()
5081

5182

83+
def test_cross_section_computed_when_image_shown_again():
84+
"""The cross sections of an image shown again are those at the marker
85+
position, even if the marker was moved while the image was hidden."""
86+
with qt_app_context(exec_loop=False):
87+
win = _window()
88+
plot = win.manager.get_plot()
89+
image1 = make.image(np.arange(16 * 16, dtype=float).reshape(16, 16))
90+
image2 = make.image(np.full((16, 16), 42.0))
91+
plot.add_item(image1)
92+
plot.add_item(image2)
93+
marker = _add_marker(win, 5, 5)
94+
_assert_sections(win, image1, 5, 5)
95+
plot.set_item_visible(image1, False)
96+
marker.set_pos(10, 12)
97+
plot.set_item_visible(image1, True)
98+
_assert_sections(win, image1, 10, 12)
99+
_assert_sections(win, image2, 10, 12)
100+
win.close()
101+
102+
103+
def test_cross_section_computed_for_added_image():
104+
"""The cross sections of an image added to the plot are computed at once."""
105+
with qt_app_context(exec_loop=False):
106+
win = _window()
107+
plot = win.manager.get_plot()
108+
plot.add_item(make.image(np.full((16, 16), 42.0)))
109+
_add_marker(win, 5, 7)
110+
image = make.image(np.arange(16 * 16, dtype=float).reshape(16, 16))
111+
plot.add_item(image)
112+
_assert_sections(win, image, 5, 7)
113+
win.close()
114+
115+
116+
def test_cross_section_of_shown_image_follows_autorefresh_mode():
117+
"""With the auto-refresh mode disabled, the cross section curves of an image
118+
shown again are computed on the next refresh only."""
119+
with qt_app_context(exec_loop=False):
120+
win = _window()
121+
plot = win.manager.get_plot()
122+
xcs, _ycs = _cs_plots(win)
123+
image = make.image(np.arange(16 * 16, dtype=float).reshape(16, 16))
124+
plot.add_item(image)
125+
_add_marker(win, 5, 5)
126+
xcs.toggle_autorefresh(False)
127+
plot.set_item_visible(image, False)
128+
plot.set_item_visible(image, True)
129+
assert xcs.known_items[image].dataSize() == 0
130+
xcs.update_plot()
131+
assert xcs.known_items[image].dataSize() == 16
132+
win.close()
133+
134+
135+
def test_cross_section_shade_counts_visible_images_only():
136+
"""The shade of the cross section curves ignores hidden images."""
137+
with qt_app_context(exec_loop=False):
138+
win = _window()
139+
plot = win.manager.get_plot()
140+
xcs, _ycs = _cs_plots(win)
141+
images = []
142+
for _index in range(4):
143+
if images:
144+
plot.set_item_visible(images[-1], False)
145+
image = make.image(np.ones((16, 16)))
146+
plot.add_item(image)
147+
images.append(image)
148+
assert xcs.param.shade == pytest.approx(xcs.SHADE)
149+
brush_color = xcs.known_items[images[-1]].brush().color()
150+
assert brush_color.alphaF() == pytest.approx(xcs.SHADE, abs=1e-2)
151+
win.close()
152+
153+
52154
def test_unchanged_axis_style_does_not_relayout():
53155
"""Setting the same titles again neither lays out the plot nor notifies."""
54156
with qt_app_context(exec_loop=False):
@@ -70,4 +172,8 @@ def test_unchanged_axis_style_does_not_relayout():
70172

71173
if __name__ == "__main__":
72174
test_cross_section_curves_follow_visible_images()
175+
test_cross_section_computed_when_image_shown_again()
176+
test_cross_section_computed_for_added_image()
177+
test_cross_section_of_shown_image_follows_autorefresh_mode()
178+
test_cross_section_shade_counts_visible_images_only()
73179
test_unchanged_axis_style_does_not_relayout()

0 commit comments

Comments
 (0)