Skip to content

Commit d6dcb67

Browse files
committed
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
1 parent 3730e9c commit d6dcb67

7 files changed

Lines changed: 319 additions & 28 deletions

File tree

‎doc/release_notes/release_2.12.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,13 @@
22

33
## PlotPy Version 2.12.0 ##
44

5+
✨ New features:
6+
7+
* **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))
8+
59
🛠️ Bug fixes:
610

11+
* **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
712
* **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))
813
* **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
914

‎plotpy/items/curve/base.py‎

Lines changed: 106 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
from __future__ import annotations
44

55
import sys
6+
from collections.abc import Generator
7+
from contextlib import contextmanager
68
from typing import TYPE_CHECKING
79

810
import numpy as np
@@ -37,6 +39,40 @@
3739
SELECTED_SYMBOL = SELECTED_SYMBOL_PARAM.build_symbol()
3840

3941

42+
def minmax_downsampling_indices(data: np.ndarray, factor: int) -> np.ndarray:
43+
"""Return the indices of the points kept by min/max downsampling
44+
45+
The data is split into groups of ``2 * factor`` points, and the indices of the
46+
minimum and of the maximum of each group are kept, in increasing order, so that
47+
peaks are preserved with about as many points as when keeping one point out of
48+
``factor``. The first and the last points are always kept. NaN values are
49+
ignored, except in groups containing only NaN values, which keep one of them so
50+
that the curve is still interrupted there.
51+
52+
Args:
53+
data: Data to downsample (e.g. Y values of a curve)
54+
factor: Downsampling factor
55+
56+
Returns:
57+
Sorted indices of the points to keep
58+
"""
59+
data = np.asarray(data, dtype=float)
60+
size = data.size
61+
if factor <= 1 or size <= 2:
62+
return np.arange(size)
63+
bucket = 2 * factor
64+
nbuckets = -(-size // bucket) # Last group may be incomplete
65+
padded = np.full(nbuckets * bucket, np.nan)
66+
padded[:size] = data
67+
groups = padded.reshape(nbuckets, bucket)
68+
isnan = np.isnan(groups)
69+
imin = np.argmin(np.where(isnan, np.inf, groups), axis=1)
70+
imax = np.argmax(np.where(isnan, -np.inf, groups), axis=1)
71+
offsets = np.arange(nbuckets) * bucket
72+
indices = np.concatenate(([0, size - 1], offsets + imin, offsets + imax))
73+
return np.unique(indices[indices < size])
74+
75+
4076
def get_screen_pen(painter: QG.QPainter, pen: QG.QPen) -> QG.QPen:
4177
"""Return the pen to draw a line on screen
4278
@@ -158,6 +194,9 @@ def __init__(self, curveparam: CurveParam | None = None) -> None:
158194
self.immutable = True # set to false to allow moving points around
159195
self._x = None
160196
self._y = None
197+
self._dsamp_indices: np.ndarray | None = None
198+
self._dsamp_size = 0
199+
self._dsamp_frozen = False
161200
self.update_params()
162201

163202
def _get_visible_axis_min(self, axis_id: int, axis_data: np.ndarray) -> float:
@@ -442,21 +481,69 @@ def update_data(self) -> None:
442481
if isinstance(self._x, np.ndarray) and isinstance(self._y, np.ndarray):
443482
self._setData(self._x, self._y)
444483

445-
def dsamp(self, data: np.ndarray) -> np.ndarray:
484+
def dsamp(self, data: np.ndarray | None) -> np.ndarray | None:
446485
"""Downsample data
447486
448487
Args:
449-
data: Data to downsample
488+
data: Data to downsample (X, Y or error data of the curve)
450489
451490
Returns:
452491
Downsampled data
453492
"""
454-
if self.param.use_dsamp and self.param.dsamp_factor > 1:
455-
return data[:: self.param.dsamp_factor]
456-
return data
493+
if data is None or self._dsamp_indices is None:
494+
return data
495+
return data[self._dsamp_indices]
496+
497+
def get_dsamp_indices(self) -> np.ndarray | None:
498+
"""Return the indices of the points kept by downsampling
499+
500+
Returns:
501+
Sorted indices of the points of the curve data that are drawn, or None if
502+
the curve is not downsampled
503+
"""
504+
return self._dsamp_indices
505+
506+
@contextmanager
507+
def frozen_downsampling(self) -> Generator[None, None, None]:
508+
"""Keep the points kept by downsampling while the curve data is updated
509+
510+
This is useful when editing curve points: with min/max downsampling, a
511+
point which is moved could otherwise be replaced by another point of its
512+
group. The points kept by downsampling are computed again when the number
513+
of points changes, or when the data is updated after this context.
514+
"""
515+
self._dsamp_frozen = True
516+
try:
517+
yield
518+
finally:
519+
self._dsamp_frozen = False
520+
521+
def __update_dsamp_indices(self, values: np.ndarray) -> None:
522+
"""Update the indices of the points kept by downsampling
523+
524+
Args:
525+
values: Curve values (Y data, or X data for vertical curves)
526+
"""
527+
indices = self._dsamp_indices
528+
if (
529+
self._dsamp_frozen
530+
and indices is not None
531+
and self._dsamp_size == values.size
532+
):
533+
return
534+
self._dsamp_size = values.size
535+
factor = self.param.dsamp_factor
536+
if not self.param.use_dsamp or factor <= 1:
537+
self._dsamp_indices = None
538+
elif self.param.dsamp_method == "stride":
539+
self._dsamp_indices = np.arange(0, values.size, factor)
540+
else:
541+
self._dsamp_indices = minmax_downsampling_indices(values, factor)
457542

458543
def _setData(self, x: np.ndarray, y: np.ndarray) -> None:
459544
"""Wrapper around QwtPlotCurve.setData() to handle downsampling"""
545+
values = x if self.orientation() == QC.Qt.Vertical else y
546+
self.__update_dsamp_indices(np.asarray(values))
460547
return super().setData(self.dsamp(x), self.dsamp(y))
461548

462549
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]:
534621
p1x = plot.transform(ax, self._x[i + 1])
535622
p1y = plot.transform(ay, self._y[i + 1])
536623
distance = seg_dist(QC.QPointF(pos), QC.QPointF(p0x, p0y), QC.QPointF(p1x, p1y))
537-
final_index = i // (int(not self.param.use_dsamp) or self.param.dsamp_factor)
624+
indices = self._dsamp_indices
625+
if indices is None:
626+
final_index = int(i)
627+
else:
628+
# Index of the last point kept by downsampling at or before point `i`
629+
final_index = max(int(np.searchsorted(indices, i, side="right")) - 1, 0)
538630
return distance, final_index, False, None
539631

540632
def get_closest_coordinates(self, x: float, y: float) -> tuple[float, float]:
@@ -609,12 +701,19 @@ def move_local_point_to(
609701
"""
610702
if self.immutable:
611703
return
704+
indices = self._dsamp_indices
705+
if indices is not None:
706+
# The handle is the index of a point kept by downsampling
707+
if handle < 0 or handle >= indices.size:
708+
return
709+
handle = int(indices[handle])
612710
if handle < 0 or handle > self._x.shape[0]:
613711
return
614712
x, y = canvas_to_axes(self, pos)
615713
self._x[handle] = x
616714
self._y[handle] = y
617-
self._setData(self._x, self._y)
715+
with self.frozen_downsampling():
716+
self._setData(self._x, self._y)
618717
self.plot().replot()
619718

620719
def move_local_shape(self, old_pos: QC.QPointF, new_pos: QC.QPointF) -> None:

‎plotpy/locale/fr/LC_MESSAGES/plotpy.po‎

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -355,15 +355,15 @@ msgstr "Police des valeurs"
355355
msgid "Scale"
356356
msgstr "Échelle"
357357

358-
msgid "logarithmic"
359-
msgstr "logarithmique"
360-
361358
msgid "linear"
362359
msgstr "linéaire"
363360

364361
msgid "date/time"
365362
msgstr "date/heure"
366363

364+
msgid "logarithmic"
365+
msgstr "logarithmique"
366+
367367
msgid "Autoscale strategy"
368368
msgstr "Stratégie d'autoscale"
369369

@@ -616,6 +616,22 @@ msgstr "Utiliser le sous-échantillonnage"
616616
msgid "Downsampling factor"
617617
msgstr "Facteur de sous-échantillonnage"
618618

619+
msgid "Downsampling method"
620+
msgstr "Méthode de sous-échantillonnage"
621+
622+
msgid "Min/max (peaks preserved)"
623+
msgstr "Min/max (pics préservés)"
624+
625+
msgid "Stride (one point out of N)"
626+
msgstr "Pas (un point sur N)"
627+
628+
msgid ""
629+
"Min/max: the minimum and the maximum of each group of 2 × N points are kept, so that peaks are preserved.\n"
630+
"Stride: one point out of N is kept."
631+
msgstr ""
632+
"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"
633+
"Pas : un point sur N est conservé."
634+
619635
msgid "Display"
620636
msgstr "Affichage"
621637

@@ -648,12 +664,12 @@ msgstr "Classes"
648664
msgid "Number of bins"
649665
msgstr "Nombre de classes"
650666

651-
msgid "Min"
652-
msgstr "Min"
653-
654667
msgid "Minimum value"
655668
msgstr "Valeur minimale"
656669

670+
msgid "Min"
671+
msgstr "Min"
672+
657673
msgid "Maximum value"
658674
msgstr "Valeur maximale"
659675

@@ -1707,3 +1723,4 @@ msgstr "Rotation et rognage"
17071723

17081724
msgid "Show cropping rectangle"
17091725
msgstr "Afficher le rectangle de rognage"
1726+

‎plotpy/styles/curve.py‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
from guidata.dataset import (
88
BoolItem,
9+
ChoiceItem,
910
DataSet,
1011
FloatItem,
1112
GetAttrProp,
@@ -49,6 +50,19 @@ class CurveParam(DataSet):
4950
dsamp_factor = IntItem(_("Downsampling factor"), default=10, min=1).set_prop(
5051
"display", active=_use_dsamp_prop
5152
)
53+
dsamp_method = ChoiceItem(
54+
_("Downsampling method"),
55+
(
56+
("minmax", _("Min/max (peaks preserved)")),
57+
("stride", _("Stride (one point out of N)")),
58+
),
59+
default="minmax",
60+
help=_(
61+
"Min/max: the minimum and the maximum of each group of "
62+
"2 × N points are kept, so that peaks are preserved.\n"
63+
"Stride: one point out of N is kept."
64+
),
65+
).set_prop("display", active=_use_dsamp_prop)
5266

5367
def update_param(self, curve: CurveItem) -> None:
5468
"""Updates the parameters using values from a given CurveItem

‎plotpy/tests/unit/test_builder_curve.py‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,8 +63,15 @@ def _make_curve_dsamp(dsamp_factor, use_dsamp):
6363
x = np.linspace(-10, 10, 1000)
6464
y = np.sin(x)
6565
curve = make.curve(x, y, dsamp_factor=dsamp_factor, use_dsamp=use_dsamp)
66-
if use_dsamp:
66+
if use_dsamp and dsamp_factor > 1:
67+
# Min/max downsampling (default): about one point out of `dsamp_factor`
68+
assert curve.dataSize() == curve.get_dsamp_indices().size
69+
assert curve.dataSize() <= x[::dsamp_factor].size + 2
70+
curve.param.dsamp_method = "stride"
71+
curve.update_params()
6772
assert curve.dataSize() == x[::dsamp_factor].size
73+
else:
74+
assert curve.dataSize() == x.size
6875
return curve
6976

7077

0 commit comments

Comments
 (0)