diff --git a/doc/locale/fr/LC_MESSAGES/release_notes/release_1.01.po b/doc/locale/fr/LC_MESSAGES/release_notes/release_1.01.po index 8b721a1..3e10cd1 100644 --- a/doc/locale/fr/LC_MESSAGES/release_notes/release_1.01.po +++ b/doc/locale/fr/LC_MESSAGES/release_notes/release_1.01.po @@ -39,6 +39,9 @@ msgstr "Les identifiants d'annotation canoniques, les métadonnées, les extensi msgid "The tooltip of a rectangular ROI defined in pixel indices now shows the pixels selected by the ROI, i.e. those whose centers lie inside the rectangle: the corners of the rectangle were rounded to the nearest pixel indices, which could show a selection shifted by one pixel once the rectangle had been moved." msgstr "L'info-bulle d'une ROI rectangulaire définie en indices de pixels affiche désormais les pixels sélectionnés par la ROI, c'est-à-dire ceux dont le centre est situé dans le rectangle : les coins du rectangle étaient arrondis aux indices de pixels les plus proches, ce qui pouvait afficher une sélection décalée d'un pixel une fois le rectangle déplacé." +msgid "Auto downsampling of signals now starts as soon as a signal has more points than the maximum number of points set by the `sig_autodownsampling_maxpoints` option, and draws at most this number of points (closes [Issue #30](https://github.com/DataLab-Platform/SigimaX/issues/30)). It only started above 5 times this number (500 000 points with the default value of 100 000), so that signals of a few hundred thousand points were drawn in full and slowed down the display, and it could draw up to 20 % more points than the maximum. A change of this option is now also applied to curves which were already downsampled." +msgstr "Le sous-échantillonnage automatique des signaux commence désormais dès qu'un signal compte plus de points que le nombre maximal de points défini par l'option `sig_autodownsampling_maxpoints`, et trace au plus ce nombre de points (corrige [Issue #30](https://github.com/DataLab-Platform/SigimaX/issues/30)). Il ne commençait qu'au-delà de 5 fois ce nombre (500 000 points avec la valeur par défaut de 100 000), si bien que les signaux de quelques centaines de milliers de points étaient tracés en entier et ralentissaient l'affichage, et il pouvait tracer jusqu'à 20 % de points de plus que le maximum. Une modification de cette option s'applique désormais aussi aux courbes déjà sous-échantillonnées." + msgid "Main window" msgstr "Fenêtre principale" diff --git a/doc/release_notes/release_1.01.md b/doc/release_notes/release_1.01.md index 7b2168b..a2a0353 100644 --- a/doc/release_notes/release_1.01.md +++ b/doc/release_notes/release_1.01.md @@ -19,6 +19,7 @@ state are preserved across PlotPy editing round trips. Application-specific opaque annotation entries continue to coexist with graphical annotations. * The tooltip of a rectangular ROI defined in pixel indices now shows the pixels selected by the ROI, i.e. those whose centers lie inside the rectangle: the corners of the rectangle were rounded to the nearest pixel indices, which could show a selection shifted by one pixel once the rectangle had been moved. +* Auto downsampling of signals now starts as soon as a signal has more points than the maximum number of points set by the `sig_autodownsampling_maxpoints` option, and draws at most this number of points (closes [Issue #30](https://github.com/DataLab-Platform/SigimaX/issues/30)). It only started above 5 times this number (500 000 points with the default value of 100 000), so that signals of a few hundred thousand points were drawn in full and slowed down the display, and it could draw up to 20 % more points than the maximum. A change of this option is now also applied to curves which were already downsampled. ### Curve styles ### diff --git a/sigimax/adapters_plotpy/objects/signal.py b/sigimax/adapters_plotpy/objects/signal.py index 40b078e..63e98a1 100644 --- a/sigimax/adapters_plotpy/objects/signal.py +++ b/sigimax/adapters_plotpy/objects/signal.py @@ -260,22 +260,29 @@ def apply_line_width(item: CurveItem) -> None: def apply_downsampling(item: CurveItem, do_not_update: bool = False) -> None: - """Apply downsampling to curve item + """Apply automatic downsampling to curve item + + When the ``sig_autodownsampling`` option is enabled, a curve with more points + than the ``sig_autodownsampling_maxpoints`` option is downsampled for display, + with the smallest factor drawing at most this number of points. The data of + the curve are not modified. Args: item: curve item do_not_update: if True, do not update the item even if the downsampling parameters have changed """ - old_use_dsamp = item.param.use_dsamp - item.param.use_dsamp = False + param = item.param + old_state = (param.use_dsamp, param.dsamp_factor) + param.use_dsamp = False if get_conf().sig_autodownsampling.get(): nbpoints = item.get_data()[0].size maxpoints = get_conf().sig_autodownsampling_maxpoints.get() - if nbpoints > 5 * maxpoints: - item.param.use_dsamp = True - item.param.dsamp_factor = nbpoints // maxpoints - if not do_not_update and old_use_dsamp != item.param.use_dsamp: + if nbpoints > maxpoints: + param.use_dsamp = True + param.dsamp_factor = -(-nbpoints // maxpoints) # Rounded up + new_state = (param.use_dsamp, param.dsamp_factor) + if not do_not_update and old_state != new_state: item.update_data() diff --git a/sigimax/config.py b/sigimax/config.py index c4ca4d8..9f920d6 100644 --- a/sigimax/config.py +++ b/sigimax/config.py @@ -1484,7 +1484,10 @@ def __init__(self) -> None: category="view", default=100000, expected_type=int, - description="Maximum number of points before auto-downsampling kicks in.", + description=( + "Maximum number of points displayed for a signal: signals with more " + "points are downsampled for display (data are not modified)." + ), ) self.sig_autoscale_margin_percent = TypedOptionField( self, diff --git a/sigimax/tests/adapters_plotpy/test_curve_downsampling.py b/sigimax/tests/adapters_plotpy/test_curve_downsampling.py new file mode 100644 index 0000000..47d0c11 --- /dev/null +++ b/sigimax/tests/adapters_plotpy/test_curve_downsampling.py @@ -0,0 +1,131 @@ +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. + +""" +Curve auto downsampling tests (offscreen Qt) +-------------------------------------------- + +Tests of the automatic downsampling of curves with many points, driven by the +``sig_autodownsampling`` and ``sig_autodownsampling_maxpoints`` options. +""" + +from __future__ import annotations + +import math +from collections.abc import Generator + +import numpy as np +import pytest +from guidata.qthelpers import qt_app_context +from plotpy.items import CurveItem +from sigima.objects import create_signal + +from sigimax.adapters_plotpy.converters import create_adapter_from_object +from sigimax.adapters_plotpy.objects.signal import ( + SignalObjPlotPyAdapter, + apply_downsampling, +) +from sigimax.config import get_conf + +pytestmark = pytest.mark.gui + +#: Maximum number of points used by the tests (even, see `_check_downsampling`) +MAXPOINTS = 1000 + + +@pytest.fixture(name="autodownsampling") +def fixture_autodownsampling() -> Generator[None, None, None]: + """Enable auto downsampling with a small maximum number of points, then restore + the options""" + conf = get_conf() + options = (conf.sig_autodownsampling, conf.sig_autodownsampling_maxpoints) + old_values = [option.get() for option in options] + conf.sig_autodownsampling.set(True) + conf.sig_autodownsampling_maxpoints.set(MAXPOINTS) + try: + with qt_app_context(exec_loop=False): + yield + finally: + for option, value in zip(options, old_values): + option.set(value) + + +def _make_adapter(size: int) -> SignalObjPlotPyAdapter: + """Return the adapter of a new signal with a given number of points""" + x = np.linspace(0.0, 1.0, size) + obj = create_signal(f"Signal ({size} points)", x, np.sin(2 * np.pi * 50 * x)) + return create_adapter_from_object(obj) + + +def _check_downsampling(item: CurveItem, size: int, maxpoints: int) -> None: + """Check the downsampling of a curve item + + Args: + item: curve item + size: number of points of the curve + maxpoints: maximum number of points to draw (even number) + """ + if size > maxpoints: + assert item.param.use_dsamp + assert item.param.dsamp_factor == math.ceil(size / maxpoints) + # Keeping one point out of N draws at most `maxpoints` points, and min/max + # downsampling (PlotPy >= 2.12) at most 2 more points (first and last points) + assert item.dataSize() <= maxpoints + 2 + else: + assert not item.param.use_dsamp + assert item.dataSize() == size + + +@pytest.mark.usefixtures("autodownsampling") +@pytest.mark.parametrize( + "size", + [ + MAXPOINTS // 2, + MAXPOINTS, + MAXPOINTS + 1, + 5 * MAXPOINTS, + 5 * MAXPOINTS + 1, + 6 * MAXPOINTS - 1, + ], +) +def test_downsampling_threshold(size: int) -> None: + """Curves with more points than the maximum number of points are downsampled, + and are drawn with at most this number of points""" + item = _make_adapter(size).make_item() + _check_downsampling(item, size, MAXPOINTS) + + +@pytest.mark.usefixtures("autodownsampling") +def test_downsampling_disabled() -> None: + """Curves are not downsampled when auto downsampling is disabled""" + get_conf().sig_autodownsampling.set(False) + size = 10 * MAXPOINTS + item = _make_adapter(size).make_item() + assert not item.param.use_dsamp + assert item.dataSize() == size + + +@pytest.mark.usefixtures("autodownsampling") +def test_downsampling_factor_change() -> None: + """The drawn data follow a change of the downsampling factor alone""" + size = 20 * MAXPOINTS + item = _make_adapter(size).make_item() + _check_downsampling(item, size, MAXPOINTS) + get_conf().sig_autodownsampling_maxpoints.set(2 * MAXPOINTS) + apply_downsampling(item) + _check_downsampling(item, size, 2 * MAXPOINTS) + assert item.dataSize() > MAXPOINTS + 2 + + +@pytest.mark.usefixtures("autodownsampling") +def test_update_item_follows_options() -> None: + """Updating a plot item applies the current downsampling options""" + size = 20 * MAXPOINTS + adapter = _make_adapter(size) + item = adapter.make_item() + get_conf().sig_autodownsampling_maxpoints.set(4 * MAXPOINTS) + adapter.update_item(item, data_changed=False) + _check_downsampling(item, size, 4 * MAXPOINTS) + get_conf().sig_autodownsampling.set(False) + adapter.update_item(item, data_changed=False) + assert not item.param.use_dsamp + assert item.dataSize() == size