|
4 | 4 |
|
5 | 5 | import math |
6 | 6 | import sys |
| 7 | +from collections.abc import Generator |
| 8 | +from contextlib import contextmanager |
7 | 9 | from typing import TYPE_CHECKING |
8 | 10 |
|
9 | 11 | import numpy as np |
|
39 | 41 | SELECTED_SYMBOL = SELECTED_SYMBOL_PARAM.build_symbol() |
40 | 42 |
|
41 | 43 |
|
| 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 | + |
42 | 78 | def get_screen_pen(painter: QG.QPainter, pen: QG.QPen) -> QG.QPen: |
43 | 79 | """Return the pen to draw a line on screen |
44 | 80 |
|
@@ -222,6 +258,9 @@ def __init__(self, curveparam: CurveParam | None = None) -> None: |
222 | 258 | self.immutable = True # set to false to allow moving points around |
223 | 259 | self._x = None |
224 | 260 | self._y = None |
| 261 | + self._dsamp_indices: np.ndarray | None = None |
| 262 | + self._dsamp_size = 0 |
| 263 | + self._dsamp_frozen = False |
225 | 264 | self.update_params() |
226 | 265 |
|
227 | 266 | def _get_visible_axis_min(self, axis_id: int, axis_data: np.ndarray) -> float: |
@@ -547,21 +586,69 @@ def update_data(self) -> None: |
547 | 586 | if isinstance(self._x, np.ndarray) and isinstance(self._y, np.ndarray): |
548 | 587 | self._setData(self._x, self._y) |
549 | 588 |
|
550 | | - def dsamp(self, data: np.ndarray) -> np.ndarray: |
| 589 | + def dsamp(self, data: np.ndarray | None) -> np.ndarray | None: |
551 | 590 | """Downsample data |
552 | 591 |
|
553 | 592 | Args: |
554 | | - data: Data to downsample |
| 593 | + data: Data to downsample (X, Y or error data of the curve) |
555 | 594 |
|
556 | 595 | Returns: |
557 | 596 | Downsampled data |
558 | 597 | """ |
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) |
562 | 647 |
|
563 | 648 | def _setData(self, x: np.ndarray, y: np.ndarray) -> None: |
564 | 649 | """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)) |
565 | 652 | return super().setData(self.dsamp(x), self.dsamp(y)) |
566 | 653 |
|
567 | 654 | 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]: |
639 | 726 | p1x = plot.transform(ax, self._x[i + 1]) |
640 | 727 | p1y = plot.transform(ay, self._y[i + 1]) |
641 | 728 | 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) |
643 | 735 | return distance, final_index, False, None |
644 | 736 |
|
645 | 737 | def get_closest_coordinates(self, x: float, y: float) -> tuple[float, float]: |
@@ -714,12 +806,19 @@ def move_local_point_to( |
714 | 806 | """ |
715 | 807 | if self.immutable: |
716 | 808 | 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]) |
717 | 815 | if handle < 0 or handle > self._x.shape[0]: |
718 | 816 | return |
719 | 817 | x, y = canvas_to_axes(self, pos) |
720 | 818 | self._x[handle] = x |
721 | 819 | self._y[handle] = y |
722 | | - self._setData(self._x, self._y) |
| 820 | + with self.frozen_downsampling(): |
| 821 | + self._setData(self._x, self._y) |
723 | 822 | self.plot().replot() |
724 | 823 |
|
725 | 824 | def move_local_shape(self, old_pos: QC.QPointF, new_pos: QC.QPointF) -> None: |
|
0 commit comments