From d6dcb67487781714bf9e8a6871a8a77103dd56c9 Mon Sep 17 00:00:00 2001 From: Pierre Raybaut <1311787+PierreRaybaut@users.noreply.github.com> Date: Thu, 29 Oct 2026 06:53:45 +0100 Subject: [PATCH] feat(curves): min/max curve downsampling preserving peaks Curve downsampling kept one point out of N (`data[::N]`), which discards extrema: narrow peaks could disappear and the envelope of noisy signals looked thinner than it is. - New `CurveParam.dsamp_method` parameter: "minmax" (new default) keeps the minimum and the maximum of each group of 2 x N points, in their original order, with the first and last points; "stride" restores the former method. NaN values are ignored, except in groups containing only NaN values, which keep one so that the curve is still interrupted - The indices of the points kept are stored (`get_dsamp_indices`), so that X, Y and error data are downsampled consistently (`minmax_downsampling_indices` computes them) - `hit_test` maps original indices to indices of points kept, and `move_local_point_to` moves the corresponding original point (it used the handle as an index into the full data) - `EditPointTool` edits original points through these indices, and keeps the points drawn while a point is dragged (`frozen_downsampling`) - French translations Closes #83 Assisted-by: Claude Opus 5.5 --- doc/release_notes/release_2.12.md | 5 + plotpy/items/curve/base.py | 113 +++++++++++++- plotpy/locale/fr/LC_MESSAGES/plotpy.po | 29 +++- plotpy/styles/curve.py | 14 ++ plotpy/tests/unit/test_builder_curve.py | 9 +- plotpy/tests/unit/test_curve_downsampling.py | 148 +++++++++++++++++++ plotpy/tools/curve.py | 29 ++-- 7 files changed, 319 insertions(+), 28 deletions(-) create mode 100644 plotpy/tests/unit/test_curve_downsampling.py diff --git a/doc/release_notes/release_2.12.md b/doc/release_notes/release_2.12.md index 544aac0b..36aa2748 100644 --- a/doc/release_notes/release_2.12.md +++ b/doc/release_notes/release_2.12.md @@ -2,8 +2,13 @@ ## PlotPy Version 2.12.0 ## +✨ New features: + +* **Min/max curve downsampling** — Curve downsampling now keeps, by default, the minimum and the maximum of each group of 2 × N points (N being the downsampling factor), instead of one point out of N: narrow peaks and the envelope of noisy signals are preserved, with about as many points as before. The new **Downsampling method** curve parameter (`CurveParam.dsamp_method`) restores the former method ("stride"). `CurveItem.get_dsamp_indices` returns the indices of the points kept, the new `minmax_downsampling_indices` function computes them, and the `CurveItem.frozen_downsampling` context keeps them while points are edited (closes [Issue #83](https://github.com/PlotPyStack/PlotPy/issues/83)) + 🛠️ Bug fixes: +* **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 diff --git a/plotpy/items/curve/base.py b/plotpy/items/curve/base.py index 04960ee5..4cd2638b 100644 --- a/plotpy/items/curve/base.py +++ b/plotpy/items/curve/base.py @@ -3,6 +3,8 @@ from __future__ import annotations import sys +from collections.abc import Generator +from contextlib import contextmanager from typing import TYPE_CHECKING import numpy as np @@ -37,6 +39,40 @@ SELECTED_SYMBOL = SELECTED_SYMBOL_PARAM.build_symbol() +def minmax_downsampling_indices(data: np.ndarray, factor: int) -> np.ndarray: + """Return the indices of the points kept by min/max downsampling + + The data is split into groups of ``2 * factor`` points, and the indices of the + minimum and of the maximum of each group are kept, in increasing order, so that + peaks are preserved with about as many points as when keeping one point out of + ``factor``. The first and the last points are always kept. NaN values are + ignored, except in groups containing only NaN values, which keep one of them so + that the curve is still interrupted there. + + Args: + data: Data to downsample (e.g. Y values of a curve) + factor: Downsampling factor + + Returns: + Sorted indices of the points to keep + """ + data = np.asarray(data, dtype=float) + size = data.size + if factor <= 1 or size <= 2: + return np.arange(size) + bucket = 2 * factor + nbuckets = -(-size // bucket) # Last group may be incomplete + padded = np.full(nbuckets * bucket, np.nan) + padded[:size] = data + groups = padded.reshape(nbuckets, bucket) + isnan = np.isnan(groups) + imin = np.argmin(np.where(isnan, np.inf, groups), axis=1) + imax = np.argmax(np.where(isnan, -np.inf, groups), axis=1) + offsets = np.arange(nbuckets) * bucket + indices = np.concatenate(([0, size - 1], offsets + imin, offsets + imax)) + return np.unique(indices[indices < size]) + + def get_screen_pen(painter: QG.QPainter, pen: QG.QPen) -> QG.QPen: """Return the pen to draw a line on screen @@ -158,6 +194,9 @@ def __init__(self, curveparam: CurveParam | None = None) -> None: self.immutable = True # set to false to allow moving points around self._x = None self._y = None + self._dsamp_indices: np.ndarray | None = None + self._dsamp_size = 0 + self._dsamp_frozen = False self.update_params() def _get_visible_axis_min(self, axis_id: int, axis_data: np.ndarray) -> float: @@ -442,21 +481,69 @@ def update_data(self) -> None: if isinstance(self._x, np.ndarray) and isinstance(self._y, np.ndarray): self._setData(self._x, self._y) - def dsamp(self, data: np.ndarray) -> np.ndarray: + def dsamp(self, data: np.ndarray | None) -> np.ndarray | None: """Downsample data Args: - data: Data to downsample + data: Data to downsample (X, Y or error data of the curve) Returns: Downsampled data """ - if self.param.use_dsamp and self.param.dsamp_factor > 1: - return data[:: self.param.dsamp_factor] - return data + if data is None or self._dsamp_indices is None: + return data + return data[self._dsamp_indices] + + def get_dsamp_indices(self) -> np.ndarray | None: + """Return the indices of the points kept by downsampling + + Returns: + Sorted indices of the points of the curve data that are drawn, or None if + the curve is not downsampled + """ + return self._dsamp_indices + + @contextmanager + def frozen_downsampling(self) -> Generator[None, None, None]: + """Keep the points kept by downsampling while the curve data is updated + + This is useful when editing curve points: with min/max downsampling, a + point which is moved could otherwise be replaced by another point of its + group. The points kept by downsampling are computed again when the number + of points changes, or when the data is updated after this context. + """ + self._dsamp_frozen = True + try: + yield + finally: + self._dsamp_frozen = False + + def __update_dsamp_indices(self, values: np.ndarray) -> None: + """Update the indices of the points kept by downsampling + + Args: + values: Curve values (Y data, or X data for vertical curves) + """ + indices = self._dsamp_indices + if ( + self._dsamp_frozen + and indices is not None + and self._dsamp_size == values.size + ): + return + self._dsamp_size = values.size + factor = self.param.dsamp_factor + if not self.param.use_dsamp or factor <= 1: + self._dsamp_indices = None + elif self.param.dsamp_method == "stride": + self._dsamp_indices = np.arange(0, values.size, factor) + else: + self._dsamp_indices = minmax_downsampling_indices(values, factor) def _setData(self, x: np.ndarray, y: np.ndarray) -> None: """Wrapper around QwtPlotCurve.setData() to handle downsampling""" + values = x if self.orientation() == QC.Qt.Vertical else y + self.__update_dsamp_indices(np.asarray(values)) return super().setData(self.dsamp(x), self.dsamp(y)) def set_data(self, x: np.ndarray, y: np.ndarray) -> None: @@ -534,7 +621,12 @@ def hit_test(self, pos: QC.QPointF) -> tuple[float, float, bool, None]: p1x = plot.transform(ax, self._x[i + 1]) p1y = plot.transform(ay, self._y[i + 1]) distance = seg_dist(QC.QPointF(pos), QC.QPointF(p0x, p0y), QC.QPointF(p1x, p1y)) - final_index = i // (int(not self.param.use_dsamp) or self.param.dsamp_factor) + indices = self._dsamp_indices + if indices is None: + final_index = int(i) + else: + # Index of the last point kept by downsampling at or before point `i` + final_index = max(int(np.searchsorted(indices, i, side="right")) - 1, 0) return distance, final_index, False, None def get_closest_coordinates(self, x: float, y: float) -> tuple[float, float]: @@ -609,12 +701,19 @@ def move_local_point_to( """ if self.immutable: return + indices = self._dsamp_indices + if indices is not None: + # The handle is the index of a point kept by downsampling + if handle < 0 or handle >= indices.size: + return + handle = int(indices[handle]) if handle < 0 or handle > self._x.shape[0]: return x, y = canvas_to_axes(self, pos) self._x[handle] = x self._y[handle] = y - self._setData(self._x, self._y) + with self.frozen_downsampling(): + self._setData(self._x, self._y) self.plot().replot() def move_local_shape(self, old_pos: QC.QPointF, new_pos: QC.QPointF) -> None: diff --git a/plotpy/locale/fr/LC_MESSAGES/plotpy.po b/plotpy/locale/fr/LC_MESSAGES/plotpy.po index cbebc66a..24c21584 100644 --- a/plotpy/locale/fr/LC_MESSAGES/plotpy.po +++ b/plotpy/locale/fr/LC_MESSAGES/plotpy.po @@ -355,15 +355,15 @@ msgstr "Police des valeurs" msgid "Scale" msgstr "Échelle" -msgid "logarithmic" -msgstr "logarithmique" - msgid "linear" msgstr "linéaire" msgid "date/time" msgstr "date/heure" +msgid "logarithmic" +msgstr "logarithmique" + msgid "Autoscale strategy" msgstr "Stratégie d'autoscale" @@ -616,6 +616,22 @@ msgstr "Utiliser le sous-échantillonnage" msgid "Downsampling factor" msgstr "Facteur de sous-échantillonnage" +msgid "Downsampling method" +msgstr "Méthode de sous-échantillonnage" + +msgid "Min/max (peaks preserved)" +msgstr "Min/max (pics préservés)" + +msgid "Stride (one point out of N)" +msgstr "Pas (un point sur N)" + +msgid "" +"Min/max: the minimum and the maximum of each group of 2 × N points are kept, so that peaks are preserved.\n" +"Stride: one point out of N is kept." +msgstr "" +"Min/max : le minimum et le maximum de chaque groupe de 2 × N points sont conservés, de sorte que les pics sont préservés.\n" +"Pas : un point sur N est conservé." + msgid "Display" msgstr "Affichage" @@ -648,12 +664,12 @@ msgstr "Classes" msgid "Number of bins" msgstr "Nombre de classes" -msgid "Min" -msgstr "Min" - msgid "Minimum value" msgstr "Valeur minimale" +msgid "Min" +msgstr "Min" + msgid "Maximum value" msgstr "Valeur maximale" @@ -1707,3 +1723,4 @@ msgstr "Rotation et rognage" msgid "Show cropping rectangle" msgstr "Afficher le rectangle de rognage" + diff --git a/plotpy/styles/curve.py b/plotpy/styles/curve.py index 16f51e7a..f593dff7 100644 --- a/plotpy/styles/curve.py +++ b/plotpy/styles/curve.py @@ -6,6 +6,7 @@ from guidata.dataset import ( BoolItem, + ChoiceItem, DataSet, FloatItem, GetAttrProp, @@ -49,6 +50,19 @@ class CurveParam(DataSet): dsamp_factor = IntItem(_("Downsampling factor"), default=10, min=1).set_prop( "display", active=_use_dsamp_prop ) + dsamp_method = ChoiceItem( + _("Downsampling method"), + ( + ("minmax", _("Min/max (peaks preserved)")), + ("stride", _("Stride (one point out of N)")), + ), + default="minmax", + help=_( + "Min/max: the minimum and the maximum of each group of " + "2 × N points are kept, so that peaks are preserved.\n" + "Stride: one point out of N is kept." + ), + ).set_prop("display", active=_use_dsamp_prop) def update_param(self, curve: CurveItem) -> None: """Updates the parameters using values from a given CurveItem diff --git a/plotpy/tests/unit/test_builder_curve.py b/plotpy/tests/unit/test_builder_curve.py index 76e3ba65..f5843852 100644 --- a/plotpy/tests/unit/test_builder_curve.py +++ b/plotpy/tests/unit/test_builder_curve.py @@ -63,8 +63,15 @@ def _make_curve_dsamp(dsamp_factor, use_dsamp): x = np.linspace(-10, 10, 1000) y = np.sin(x) curve = make.curve(x, y, dsamp_factor=dsamp_factor, use_dsamp=use_dsamp) - if use_dsamp: + if use_dsamp and dsamp_factor > 1: + # Min/max downsampling (default): about one point out of `dsamp_factor` + assert curve.dataSize() == curve.get_dsamp_indices().size + assert curve.dataSize() <= x[::dsamp_factor].size + 2 + curve.param.dsamp_method = "stride" + curve.update_params() assert curve.dataSize() == x[::dsamp_factor].size + else: + assert curve.dataSize() == x.size return curve diff --git a/plotpy/tests/unit/test_curve_downsampling.py b/plotpy/tests/unit/test_curve_downsampling.py new file mode 100644 index 00000000..a4a5a2c4 --- /dev/null +++ b/plotpy/tests/unit/test_curve_downsampling.py @@ -0,0 +1,148 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the terms of the BSD 3-Clause +# (see plotpy/LICENSE for details) + +"""Test curve downsampling methods (min/max and stride)""" + +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 QtWidgets as QW + +from plotpy.builder import make +from plotpy.items.curve.base import minmax_downsampling_indices + + +def _spiky_signal(size: int = 10_000, seed: int = 0) -> tuple[np.ndarray, np.ndarray]: + """Return a noisy signal with a few narrow peaks""" + rng = np.random.default_rng(seed) + x = np.linspace(0.0, 1.0, size) + y = 0.1 * rng.standard_normal(size) + for index, height in ((1234, 5.0), (5001, -4.0), (8888, 3.0)): + if index < size: + y[index] = height + return x, y + + +@pytest.mark.parametrize("factor", [2, 5, 10, 50, 333]) +def test_minmax_indices(factor: int) -> None: + """Min/max downsampling keeps extrema, first and last points""" + _x, y = _spiky_signal() + indices = minmax_downsampling_indices(y, factor) + assert np.all(np.diff(indices) > 0) + assert indices[0] == 0 and indices[-1] == y.size - 1 + assert indices.size <= 2 * -(-y.size // (2 * factor)) + 2 + for peak in (1234, 5001, 8888): + assert peak in indices + # Each group of 2 * factor points keeps its minimum and its maximum: + for start in range(0, y.size, 2 * factor): + group = y[start : start + 2 * factor] + kept = y[indices[(indices >= start) & (indices < start + 2 * factor)]] + assert kept.min() == group.min() and kept.max() == group.max() + + +def test_minmax_indices_special_cases() -> None: + """Min/max downsampling of short, constant and NaN data""" + assert np.array_equal(minmax_downsampling_indices(np.arange(5.0), 1), np.arange(5)) + assert np.array_equal(minmax_downsampling_indices(np.arange(2.0), 10), [0, 1]) + assert minmax_downsampling_indices(np.array([]), 10).size == 0 + constant = minmax_downsampling_indices(np.ones(100), 10) + assert constant[0] == 0 and constant[-1] == 99 + y = np.arange(100.0) + y[20:40] = np.nan # A whole group of NaN values + y[45] = np.nan # A NaN value in a group of finite values + indices = minmax_downsampling_indices(y, 10) + assert np.count_nonzero(np.isnan(y[indices])) == 1 # The curve is interrupted + assert 45 not in indices + assert {40, 59} <= set(indices) + + +def test_curve_downsampling_methods() -> None: + """Curve downsampling with the min/max (default) and stride methods""" + with qt_app_context(exec_loop=False): + x, y = _spiky_signal() + curve = make.curve(x, y, dsamp_factor=50, use_dsamp=True) + assert curve.param.dsamp_method == "minmax" + indices = curve.get_dsamp_indices() + assert np.array_equal(indices, minmax_downsampling_indices(y, 50)) + assert curve.dataSize() == indices.size + assert curve.sample(int(np.searchsorted(indices, 1234))).y() == 5.0 + + curve.param.dsamp_method = "stride" + curve.update_params() + assert np.array_equal(curve.get_dsamp_indices(), np.arange(0, y.size, 50)) + assert curve.dataSize() == y[::50].size + + curve.param.use_dsamp = False + curve.update_params() + assert curve.get_dsamp_indices() is None + assert curve.dataSize() == y.size + assert np.array_equal(curve.dsamp(y), y) + + +def test_errorbar_downsampling() -> None: + """Error bars are downsampled like the curve data""" + with qt_app_context(exec_loop=False): + x, y = _spiky_signal(2000) + dy = np.linspace(0.1, 0.2, x.size) + item = make.error(x, y, None, dy, dsamp_factor=10, use_dsamp=True) + indices = item.get_dsamp_indices() + assert indices is not None and indices.size == item.dataSize() + assert np.array_equal(item.dsamp(dy), dy[indices]) + assert item.dsamp(None) is None + _xmin, _xmax, ymin, ymax = item.get_minmax_arrays() + assert np.allclose(ymin, y[indices] - dy[indices]) + assert np.allclose(ymax, y[indices] + dy[indices]) + + +def test_frozen_downsampling() -> None: + """Points kept by downsampling may be frozen while the data is updated""" + with qt_app_context(exec_loop=False): + x, y = _spiky_signal(2000) + curve = make.curve(x, y, dsamp_factor=10, use_dsamp=True) + indices = curve.get_dsamp_indices().copy() + y2 = y.copy() + y2[1234] = 0.0 # The peak is no longer a maximum of its group + with curve.frozen_downsampling(): + curve.set_data(x, y2) + assert np.array_equal(curve.get_dsamp_indices(), indices) + # The number of points changes: the kept points are computed again + curve.set_data(x[:-1], y2[:-1]) + assert curve.get_dsamp_indices()[-1] == x.size - 2 + curve.set_data(x, y2) + assert 1234 not in curve.get_dsamp_indices() + + +def test_hit_test_and_move_point() -> None: + """Handles of downsampled curves are indices of the points kept""" + with qt_app_context(exec_loop=False): + x, y = _spiky_signal(2000) + curve = make.curve(x, y, dsamp_factor=10, use_dsamp=True) + curve.immutable = False + win = make.dialog(type="curve") + plot = win.manager.get_plot() + plot.add_item(curve) + win.resize(800, 600) + win.show() + QW.QApplication.processEvents() + plot.replot() + indices = curve.get_dsamp_indices() + handle = int(np.searchsorted(indices, 1234)) + ax, ay = curve.xAxis(), curve.yAxis() + # Slightly beside the peak (`hit_test` ignores positions exactly on points) + pos = QC.QPointF( + plot.transform(ax, x[1234]) + 0.3, plot.transform(ay, y[1234]) + 0.3 + ) + _distance, attach, _inside, _other = curve.hit_test(pos) + assert attach in (handle - 1, handle) + # Moving the handle moves the corresponding point of the curve data: + new_pos = QC.QPointF(pos.x(), plot.transform(ay, 1.0)) + curve.move_local_point_to(handle, new_pos) + _x, new_y = curve.get_data() + assert new_y[1234] == pytest.approx(1.0, abs=0.05) + assert curve.sample(handle).y() == new_y[1234] # Still drawn + win.close() diff --git a/plotpy/tools/curve.py b/plotpy/tools/curve.py index 9ea68f8f..e39f6076 100644 --- a/plotpy/tools/curve.py +++ b/plotpy/tools/curve.py @@ -784,7 +784,8 @@ def __init__( self.__indexed_changes: dict[CurveItem, dict[int, tuple[float, float]]] = {} self.__selection_threshold: float = 0.0 self.end_callback = end_callback - self.__dsampling = 1 # synchronized with the active CurveItem + # Indices of the points kept by downsampling the active CurveItem, if any + self.__dsamp_indices: np.ndarray | None = None self.__curve_item_array_backup: dict[ CurveItem, tuple[np.ndarray, np.ndarray] ] = {} @@ -1001,16 +1002,16 @@ def __get_x_bounds(self, x_array: np.ndarray, index: int) -> tuple[float, float] def downsampled_x(self) -> np.ndarray: """Downsampled x array""" assert self.__x is not None - if self.__dsampling > 1: - return self.__x[:: self.__dsampling] + if self.__dsamp_indices is not None: + return self.__x[self.__dsamp_indices] return self.__x @property def downsampled_y(self) -> np.ndarray: """Downsampled y array""" assert self.__y is not None - if self.__dsampling > 1: - return self.__y[:: self.__dsampling] + if self.__dsamp_indices is not None: + return self.__y[self.__dsamp_indices] return self.__y def start(self, filter: StatefulEventFilter, event: QG.QMouseEvent) -> None: @@ -1031,9 +1032,7 @@ def start(self, filter: StatefulEventFilter, event: QG.QMouseEvent) -> None: if self.__x is not curve_x or self.__x is None or self.__y is not curve_y: self.__x, self.__y = curve_x.copy(), curve_y.copy() - self.__dsampling: int = ( - 1 if not curve_item.param.use_dsamp else curve_item.param.dsamp_factor - ) # type: ignore + self.__dsamp_indices = curve_item.get_dsamp_indices() self.__current_location_marker = self.__get_current_marker(filter) self.__current_location_marker.move_local_point_to(0, event.pos()) x_value = self.__current_location_marker.xValue() @@ -1041,7 +1040,9 @@ def start(self, filter: StatefulEventFilter, event: QG.QMouseEvent) -> None: int(np.searchsorted(self.downsampled_x, x_value)), len(self.downsampled_x) - 1, ) - self.__idx = self.__dsampled_idx * self.__dsampling # type: ignore + self.__idx = self.__dsampled_idx + if self.__dsamp_indices is not None: + self.__idx = int(self.__dsamp_indices[self.__dsampled_idx]) self.__lower_upper_x_bounds = self.__get_x_bounds(curve_x, self.__idx) self.__selection_threshold = self.__get_selection_threshold(filter) filter.plot.replot() @@ -1083,11 +1084,11 @@ def move_point(self, filter: StatefulEventFilter, event: QG.QMouseEvent) -> None ) assert self.__x is not None and self.__y is not None - ( - self.downsampled_x[self.__dsampled_idx], - self.downsampled_y[self.__dsampled_idx], - ) = (new_x, new_y) - curve_item.set_data(self.__x, self.__y) + self.__x[self.__idx], self.__y[self.__idx] = new_x, new_y + # The moved point must stay drawn, even if it is no longer the minimum + # or the maximum of its group of points (min/max downsampling) + with curve_item.frozen_downsampling(): + curve_item.set_data(self.__x, self.__y) self.__current_location_marker.constraint_cb = ( self.__current_location_marker.center_handle