From dd309c1c0eb51d5c398b041082b53f81e32d93ba Mon Sep 17 00:00:00 2001 From: Pierre Raybaut <1311787+PierreRaybaut@users.noreply.github.com> Date: Thu, 29 Oct 2026 06:03:46 +0100 Subject: [PATCH] perf(curves): draw thin curves with a cosmetic pen on screen With a display scaling factor such as 150 %, curves and error bars of width 1 were drawn wider than one device pixel, which made Qt's raster engine take its slow path for wide pens (about 800 ms instead of 3 ms for a noisy 2000-point curve). When painting on screen (raster engine without scaling transform), pens of width 1 or less are now made cosmetic at drawing time. The stored pen and curve parameters are unchanged, and printing or exporting (other paint engines or scaled painters) is not affected. The new `curve/cosmetic_hairline` option of the `plot` section restores the previous behavior. Closes #80 Assisted-by: Claude Opus 5.5 --- doc/release_notes/release_2.12.md | 1 + plotpy/config.py | 2 + plotpy/items/curve/base.py | 65 +++++++++ plotpy/items/curve/errorbar.py | 4 +- plotpy/tests/unit/test_curve_screen_pen.py | 161 +++++++++++++++++++++ 5 files changed, 231 insertions(+), 2 deletions(-) create mode 100644 plotpy/tests/unit/test_curve_screen_pen.py diff --git a/doc/release_notes/release_2.12.md b/doc/release_notes/release_2.12.md index 390993e0..544aac0b 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 d5fbec0e..3efc8055 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 211d3b8b..04960ee5 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 b067c77c..16fe84b8 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 00000000..285d37f2 --- /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