|
3 | 3 | from __future__ import annotations |
4 | 4 |
|
5 | 5 | import sys |
| 6 | +from collections.abc import Generator |
| 7 | +from contextlib import contextmanager |
6 | 8 | from typing import TYPE_CHECKING |
7 | 9 |
|
8 | 10 | import numpy as np |
|
37 | 39 | SELECTED_SYMBOL = SELECTED_SYMBOL_PARAM.build_symbol() |
38 | 40 |
|
39 | 41 |
|
| 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 | + |
40 | 76 | def get_screen_pen(painter: QG.QPainter, pen: QG.QPen) -> QG.QPen: |
41 | 77 | """Return the pen to draw a line on screen |
42 | 78 |
|
@@ -158,6 +194,9 @@ def __init__(self, curveparam: CurveParam | None = None) -> None: |
158 | 194 | self.immutable = True # set to false to allow moving points around |
159 | 195 | self._x = None |
160 | 196 | self._y = None |
| 197 | + self._dsamp_indices: np.ndarray | None = None |
| 198 | + self._dsamp_size = 0 |
| 199 | + self._dsamp_frozen = False |
161 | 200 | self.update_params() |
162 | 201 |
|
163 | 202 | def _get_visible_axis_min(self, axis_id: int, axis_data: np.ndarray) -> float: |
@@ -442,21 +481,69 @@ def update_data(self) -> None: |
442 | 481 | if isinstance(self._x, np.ndarray) and isinstance(self._y, np.ndarray): |
443 | 482 | self._setData(self._x, self._y) |
444 | 483 |
|
445 | | - def dsamp(self, data: np.ndarray) -> np.ndarray: |
| 484 | + def dsamp(self, data: np.ndarray | None) -> np.ndarray | None: |
446 | 485 | """Downsample data |
447 | 486 |
|
448 | 487 | Args: |
449 | | - data: Data to downsample |
| 488 | + data: Data to downsample (X, Y or error data of the curve) |
450 | 489 |
|
451 | 490 | Returns: |
452 | 491 | Downsampled data |
453 | 492 | """ |
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) |
457 | 542 |
|
458 | 543 | def _setData(self, x: np.ndarray, y: np.ndarray) -> None: |
459 | 544 | """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)) |
460 | 547 | return super().setData(self.dsamp(x), self.dsamp(y)) |
461 | 548 |
|
462 | 549 | 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]: |
534 | 621 | p1x = plot.transform(ax, self._x[i + 1]) |
535 | 622 | p1y = plot.transform(ay, self._y[i + 1]) |
536 | 623 | 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) |
538 | 630 | return distance, final_index, False, None |
539 | 631 |
|
540 | 632 | def get_closest_coordinates(self, x: float, y: float) -> tuple[float, float]: |
@@ -609,12 +701,19 @@ def move_local_point_to( |
609 | 701 | """ |
610 | 702 | if self.immutable: |
611 | 703 | 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]) |
612 | 710 | if handle < 0 or handle > self._x.shape[0]: |
613 | 711 | return |
614 | 712 | x, y = canvas_to_axes(self, pos) |
615 | 713 | self._x[handle] = x |
616 | 714 | self._y[handle] = y |
617 | | - self._setData(self._x, self._y) |
| 715 | + with self.frozen_downsampling(): |
| 716 | + self._setData(self._x, self._y) |
618 | 717 | self.plot().replot() |
619 | 718 |
|
620 | 719 | def move_local_shape(self, old_pos: QC.QPointF, new_pos: QC.QPointF) -> None: |
|
0 commit comments