Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions doc/release_notes/release_2.12.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
113 changes: 106 additions & 7 deletions plotpy/items/curve/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

import math
import sys
from collections.abc import Generator
from contextlib import contextmanager
from typing import TYPE_CHECKING

import numpy as np
Expand Down Expand Up @@ -39,6 +41,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

Expand Down Expand Up @@ -222,6 +258,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:
Expand Down Expand Up @@ -547,21 +586,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:
Expand Down Expand Up @@ -639,7 +726,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]:
Expand Down Expand Up @@ -714,12 +806,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:
Expand Down
29 changes: 23 additions & 6 deletions plotpy/locale/fr/LC_MESSAGES/plotpy.po
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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"

Expand Down Expand Up @@ -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"

Expand Down Expand Up @@ -1707,3 +1723,4 @@ msgstr "Rotation et rognage"

msgid "Show cropping rectangle"
msgstr "Afficher le rectangle de rognage"

14 changes: 14 additions & 0 deletions plotpy/styles/curve.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

from guidata.dataset import (
BoolItem,
ChoiceItem,
DataSet,
FloatItem,
GetAttrProp,
Expand Down Expand Up @@ -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
Expand Down
9 changes: 8 additions & 1 deletion plotpy/tests/unit/test_builder_curve.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
Loading
Loading