diff --git a/doc/release_notes/release_2.12.md b/doc/release_notes/release_2.12.md index 390993e..544aac0 100644 --- a/doc/release_notes/release_2.12.md +++ b/doc/release_notes/release_2.12.md @@ -12,6 +12,7 @@ * **Plot refresh** — Refreshing a plot no longer recomputes the plot layout when the title and axis styles did not change * **Item tools and item list** — Updating the status of item-related tools and of the item list panel is no longer quadratic in the number of plot items * **Image creation** — Creating an image item no longer recomputes the colormap lookup table when the same colormap, inversion and transparency settings were already used: the table (more than 1000 colormap evaluations) is computed once and reused, which saves a few milliseconds per image +* **Curves on high-DPI screens** — With a display scaling factor such as 125 % or 150 %, curves and error bars of width 1 were drawn wider than one screen pixel, which made their rendering up to several hundred times slower on noisy signals (about 800 ms instead of 3 ms for a 2000-point curve at 150 %). Curves and error bars of width 1 or less are now drawn one screen pixel wide on screen; printing and exporting are not affected. The new `curve/cosmetic_hairline` option of the `plot` configuration section restores the previous behavior (closes [Issue #80](https://github.com/PlotPyStack/PlotPy/issues/80)) ⚙️ Dependencies: diff --git a/plotpy/config.py b/plotpy/config.py index d5fbec0..3efc805 100644 --- a/plotpy/config.py +++ b/plotpy/config.py @@ -48,6 +48,8 @@ def get_plotpy_defaults() -> dict[str, int | float | str | bool]: "plot": { "selection/distance": 6, "antialiasing": False, + # Draw thin curves (width <= 1) one device pixel wide on screen + "curve/cosmetic_hairline": True, "title/font/size": 12, "title/font/bold": False, "selected_curve_symbol/marker": "Rect", diff --git a/plotpy/items/curve/base.py b/plotpy/items/curve/base.py index 211d3b8..04960ee 100644 --- a/plotpy/items/curve/base.py +++ b/plotpy/items/curve/base.py @@ -9,6 +9,7 @@ from guidata.dataset import update_dataset from guidata.utils.misc import assert_interfaces_valid from qtpy import QtCore as QC +from qtpy import QtGui as QG from qwt import QwtPlotCurve from plotpy.config import CONF, _ @@ -24,6 +25,7 @@ if TYPE_CHECKING: import guidata.io + from qwt import QwtScaleMap from plotpy.interfaces import IItemType from plotpy.plot import BasePlot @@ -35,6 +37,39 @@ SELECTED_SYMBOL = SELECTED_SYMBOL_PARAM.build_symbol() +def get_screen_pen(painter: QG.QPainter, pen: QG.QPen) -> QG.QPen: + """Return the pen to draw a line on screen + + On high-DPI screens (e.g. with a 150 % scaling factor), a pen of width 1 is + drawn wider than one device pixel, which makes Qt's raster engine take its + much slower path for wide pens. When painting on screen (raster engine, + without scaling transform), pens of width 1 or less are therefore made + cosmetic, i.e. one device pixel wide. Printing and exporting, which use other + paint engines or a scaling transform, are not affected. + + This behavior may be disabled with the ``curve/cosmetic_hairline`` option of + the ``plot`` configuration section. + + Args: + painter: Painter + pen: Pen to draw with + + Returns: + A cosmetic copy of the pen if applicable, the pen itself otherwise + """ + if ( + pen.isCosmetic() + or pen.widthF() > 1.0 + or painter.paintEngine().type() != QG.QPaintEngine.Raster + or painter.worldTransform().isScaling() + or not CONF.get("plot", "curve/cosmetic_hairline", True) + ): + return pen + pen = QG.QPen(pen) + pen.setCosmetic(True) + return pen + + def seg_dist(P: QC.QPointF, P0: QC.QPointF, P1: QC.QPointF) -> float: """Compute distance between point P and segment (P0, P1) @@ -163,6 +198,36 @@ def boundingRect(self) -> QC.QRectF: else: return QwtPlotCurve.boundingRect(self) + def drawCurve( + self, + painter: QG.QPainter, + style: int, + xMap: QwtScaleMap, + yMap: QwtScaleMap, + canvasRect: QC.QRectF, + from_: int, + to: int, + ) -> None: + """Draw the line part (without symbols) of a curve interval + + Reimplemented to draw thin curves with a cosmetic pen on screen + (see :py:func:`get_screen_pen`). + + Args: + painter: Painter + style: Curve style, see ``QwtPlotCurve.CurveStyle`` + xMap: X axis scale map + yMap: Y axis scale map + canvasRect: Contents rectangle of the canvas + from_: Index of the first point to be painted + to: Index of the last point to be painted + """ + pen = painter.pen() + screen_pen = get_screen_pen(painter, pen) + if screen_pen is not pen: + painter.setPen(screen_pen) + super().drawCurve(painter, style, xMap, yMap, canvasRect, from_, to) + def types(self) -> tuple[type[IItemType], ...]: """Returns a group or category for this item. This should be a tuple of class objects inheriting from IItemType diff --git a/plotpy/items/curve/errorbar.py b/plotpy/items/curve/errorbar.py index b067c77..16fe84b 100644 --- a/plotpy/items/curve/errorbar.py +++ b/plotpy/items/curve/errorbar.py @@ -13,7 +13,7 @@ from qwt import QwtPlotCurve, QwtScaleMap from plotpy.config import _ -from plotpy.items.curve.base import CurveItem +from plotpy.items.curve.base import CurveItem, get_screen_pen from plotpy.styles.curve import CurveParam from plotpy.styles.errorbar import ErrorBarParam @@ -286,7 +286,7 @@ def draw( QwtPlotCurve.draw(self, painter, xMap, yMap, canvasRect) painter.save() - painter.setPen(self.errorPen) + painter.setPen(get_screen_pen(painter, self.errorPen)) cap = self.errorCap / 2.0 if self._dx is not None and self.errorbarparam.mode == 0: diff --git a/plotpy/tests/unit/test_curve_screen_pen.py b/plotpy/tests/unit/test_curve_screen_pen.py new file mode 100644 index 0000000..285d37f --- /dev/null +++ b/plotpy/tests/unit/test_curve_screen_pen.py @@ -0,0 +1,161 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the terms of the BSD 3-Clause +# (see plotpy/LICENSE for details) + +"""Test the cosmetic pen used to draw thin curves on screen""" + +from __future__ import annotations + +import numpy as np +import pytest +from guidata.qthelpers import qt_app_context +from qtpy import QtCore as QC +from qtpy import QtGui as QG +from qwt import QwtScaleMap + +from plotpy.builder import make +from plotpy.config import CONF +from plotpy.items.curve import base as curve_base +from plotpy.items.curve.base import get_screen_pen + +WIDTH, HEIGHT = 100, 60 + + +def _scale_maps() -> tuple[QwtScaleMap, QwtScaleMap]: + """Return X and Y scale maps from [0, 1] to the image area""" + xmap, ymap = QwtScaleMap(), QwtScaleMap() + xmap.setPaintInterval(0, WIDTH) + xmap.setScaleInterval(0.0, 1.0) + ymap.setPaintInterval(HEIGHT, 0) + ymap.setScaleInterval(0.0, 1.0) + return xmap, ymap + + +def _hidpi_image(ratio: float = 2.0) -> QG.QImage: + """Return a white image with the given device pixel ratio""" + image = QG.QImage(int(WIDTH * ratio), int(HEIGHT * ratio), QG.QImage.Format_ARGB32) + image.setDevicePixelRatio(ratio) + image.fill(QC.Qt.white) + return image + + +def _painted_rows(image: QG.QImage, column: int) -> int: + """Return the number of non-white pixels in an image column""" + white = QG.QColor(QC.Qt.white).rgb() + return sum(image.pixel(column, row) != white for row in range(image.height())) + + +def _painted_columns(image: QG.QImage, row: int) -> int: + """Return the number of non-white pixels in an image row""" + white = QG.QColor(QC.Qt.white).rgb() + return sum(image.pixel(col, row) != white for col in range(image.width())) + + +def _draw_horizontal_curve(image: QG.QImage, width: float) -> None: + """Draw a horizontal black curve in the middle of the image""" + x = np.linspace(0.0, 1.0, 50) + y = np.full_like(x, 0.5) + curve = make.curve(x, y, color="#000000", linewidth=width) + xmap, ymap = _scale_maps() + painter = QG.QPainter(image) + curve.drawSeries(painter, xmap, ymap, QC.QRectF(0, 0, WIDTH, HEIGHT), 0, -1) + painter.end() + + +def _disable_option(monkeypatch: pytest.MonkeyPatch) -> None: + """Disable the ``curve/cosmetic_hairline`` option""" + original_get = CONF.get + + def get(section, option, *args, **kwargs): + if (section, option) == ("plot", "curve/cosmetic_hairline"): + return False + return original_get(section, option, *args, **kwargs) + + monkeypatch.setattr(curve_base.CONF, "get", get) + + +def test_screen_pen_raster() -> None: + """Thin pens become cosmetic on screen, wide pens are left unchanged""" + with qt_app_context(exec_loop=False): + image = _hidpi_image() + painter = QG.QPainter(image) + for width in (0.5, 1.0): + pen = QG.QPen(QG.QColor("red"), width) + screen_pen = get_screen_pen(painter, pen) + assert screen_pen.isCosmetic() + assert screen_pen.widthF() == width + assert not pen.isCosmetic() # The original pen is not modified + pen = QG.QPen(QG.QColor("red"), 2.0) + assert get_screen_pen(painter, pen) is pen + painter.end() + + +def test_screen_pen_print_and_export() -> None: + """Pens are left unchanged for other paint engines or scaled painters""" + with qt_app_context(exec_loop=False): + pen = QG.QPen(QG.QColor("red"), 1.0) + picture = QG.QPicture() + painter = QG.QPainter(picture) + assert get_screen_pen(painter, pen) is pen + painter.end() + image = _hidpi_image() + painter = QG.QPainter(image) + painter.scale(3.0, 3.0) # As done by QwtPlotRenderer for printing + assert get_screen_pen(painter, pen) is pen + painter.end() + + +def test_screen_pen_option(monkeypatch: pytest.MonkeyPatch) -> None: + """The ``curve/cosmetic_hairline`` option disables cosmetic pens""" + _disable_option(monkeypatch) + with qt_app_context(exec_loop=False): + image = _hidpi_image() + painter = QG.QPainter(image) + pen = QG.QPen(QG.QColor("red"), 1.0) + assert get_screen_pen(painter, pen) is pen + painter.end() + + +def test_curve_hidpi_rendering(monkeypatch: pytest.MonkeyPatch) -> None: + """Curves of width 1 are drawn one device pixel wide on HiDPI screens""" + with qt_app_context(exec_loop=False): + image = _hidpi_image() + _draw_horizontal_curve(image, 1.0) + assert _painted_rows(image, image.width() // 2) == 1 + image = _hidpi_image() + _draw_horizontal_curve(image, 2.0) + assert _painted_rows(image, image.width() // 2) == 4 + _disable_option(monkeypatch) + image = _hidpi_image() + _draw_horizontal_curve(image, 1.0) + assert _painted_rows(image, image.width() // 2) == 2 + + +def test_curve_pen_parameters() -> None: + """The stored curve pen and its parameters are not modified""" + with qt_app_context(exec_loop=False): + curve = make.curve([0.0, 1.0], [0.0, 1.0], color="#000000", linewidth=1.0) + image = _hidpi_image() + painter = QG.QPainter(image) + xmap, ymap = _scale_maps() + curve.drawSeries(painter, xmap, ymap, QC.QRectF(0, 0, WIDTH, HEIGHT), 0, -1) + painter.end() + assert not curve.pen().isCosmetic() + curve.param.update_param(curve) + assert curve.param.line.width == 1.0 + + +def test_errorbar_hidpi_rendering() -> None: + """Error bars of width 1 are drawn one device pixel wide on HiDPI screens""" + with qt_app_context(exec_loop=False): + x = np.array([0.5]) + y = np.array([0.5]) + dy = np.array([0.3]) + item = make.error(x, y, None, dy, curvestyle="NoCurve", errorbarcap=0) + image = _hidpi_image() + painter = QG.QPainter(image) + xmap, ymap = _scale_maps() + item.draw(painter, xmap, ymap, QC.QRectF(0, 0, WIDTH, HEIGHT)) + painter.end() + assert _painted_columns(image, image.height() // 2) == 1