Skip to content

Commit eb2a4d6

Browse files
Merge pull request #85 from PlotPyStack/feature/83-minmax-downsampling
Min/max curve downsampling preserving peaks
2 parents 8042fd5 + 47ed7c6 commit eb2a4d6

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
@@ -4,6 +4,8 @@
44

55
import math
66
import sys
7+
from collections.abc import Generator
8+
from contextlib import contextmanager
79
from typing import TYPE_CHECKING
810

911
import numpy as np
@@ -39,6 +41,40 @@
3941
SELECTED_SYMBOL = SELECTED_SYMBOL_PARAM.build_symbol()
4042

4143

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

227266
def _get_visible_axis_min(self, axis_id: int, axis_data: np.ndarray) -> float:
@@ -547,21 +586,69 @@ def update_data(self) -> None:
547586
if isinstance(self._x, np.ndarray) and isinstance(self._y, np.ndarray):
548587
self._setData(self._x, self._y)
549588

550-
def dsamp(self, data: np.ndarray) -> np.ndarray:
589+
def dsamp(self, data: np.ndarray | None) -> np.ndarray | None:
551590
"""Downsample data
552591
553592
Args:
554-
data: Data to downsample
593+
data: Data to downsample (X, Y or error data of the curve)
555594
556595
Returns:
557596
Downsampled data
558597
"""
559-
if self.param.use_dsamp and self.param.dsamp_factor > 1:
560-
return data[:: self.param.dsamp_factor]
561-
return data
598+
if data is None or self._dsamp_indices is None:
599+
return data
600+
return data[self._dsamp_indices]
601+
602+
def get_dsamp_indices(self) -> np.ndarray | None:
603+
"""Return the indices of the points kept by downsampling
604+
605+
Returns:
606+
Sorted indices of the points of the curve data that are drawn, or None if
607+
the curve is not downsampled
608+
"""
609+
return self._dsamp_indices
610+
611+
@contextmanager
612+
def frozen_downsampling(self) -> Generator[None, None, None]:
613+
"""Keep the points kept by downsampling while the curve data is updated
614+
615+
This is useful when editing curve points: with min/max downsampling, a
616+
point which is moved could otherwise be replaced by another point of its
617+
group. The points kept by downsampling are computed again when the number
618+
of points changes, or when the data is updated after this context.
619+
"""
620+
self._dsamp_frozen = True
621+
try:
622+
yield
623+
finally:
624+
self._dsamp_frozen = False
625+
626+
def __update_dsamp_indices(self, values: np.ndarray) -> None:
627+
"""Update the indices of the points kept by downsampling
628+
629+
Args:
630+
values: Curve values (Y data, or X data for vertical curves)
631+
"""
632+
indices = self._dsamp_indices
633+
if (
634+
self._dsamp_frozen
635+
and indices is not None
636+
and self._dsamp_size == values.size
637+
):
638+
return
639+
self._dsamp_size = values.size
640+
factor = self.param.dsamp_factor
641+
if not self.param.use_dsamp or factor <= 1:
642+
self._dsamp_indices = None
643+
elif self.param.dsamp_method == "stride":
644+
self._dsamp_indices = np.arange(0, values.size, factor)
645+
else:
646+
self._dsamp_indices = minmax_downsampling_indices(values, factor)
562647

563648
def _setData(self, x: np.ndarray, y: np.ndarray) -> None:
564649
"""Wrapper around QwtPlotCurve.setData() to handle downsampling"""
650+
values = x if self.orientation() == QC.Qt.Vertical else y
651+
self.__update_dsamp_indices(np.asarray(values))
565652
return super().setData(self.dsamp(x), self.dsamp(y))
566653

567654
def set_data(self, x: np.ndarray, y: np.ndarray) -> None:
@@ -639,7 +726,12 @@ def hit_test(self, pos: QC.QPointF) -> tuple[float, float, bool, None]:
639726
p1x = plot.transform(ax, self._x[i + 1])
640727
p1y = plot.transform(ay, self._y[i + 1])
641728
distance = seg_dist(QC.QPointF(pos), QC.QPointF(p0x, p0y), QC.QPointF(p1x, p1y))
642-
final_index = i // (int(not self.param.use_dsamp) or self.param.dsamp_factor)
729+
indices = self._dsamp_indices
730+
if indices is None:
731+
final_index = int(i)
732+
else:
733+
# Index of the last point kept by downsampling at or before point `i`
734+
final_index = max(int(np.searchsorted(indices, i, side="right")) - 1, 0)
643735
return distance, final_index, False, None
644736

645737
def get_closest_coordinates(self, x: float, y: float) -> tuple[float, float]:
@@ -714,12 +806,19 @@ def move_local_point_to(
714806
"""
715807
if self.immutable:
716808
return
809+
indices = self._dsamp_indices
810+
if indices is not None:
811+
# The handle is the index of a point kept by downsampling
812+
if handle < 0 or handle >= indices.size:
813+
return
814+
handle = int(indices[handle])
717815
if handle < 0 or handle > self._x.shape[0]:
718816
return
719817
x, y = canvas_to_axes(self, pos)
720818
self._x[handle] = x
721819
self._y[handle] = y
722-
self._setData(self._x, self._y)
820+
with self.frozen_downsampling():
821+
self._setData(self._x, self._y)
723822
self.plot().replot()
724823

725824
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)