From 0f42aa7b1029bce2783d2871c311e479fdb2b87c Mon Sep 17 00:00:00 2001 From: Pierre Raybaut <1311787+PierreRaybaut@users.noreply.github.com> Date: Thu, 29 Oct 2026 06:42:10 +0100 Subject: [PATCH] perf(curves): draw thick curves as several one-pixel strokes on screen Qt's raster engine draws pens wider than one device pixel much more slowly than one-pixel pens. On screen (raster engine without scaling transform), opaque solid curves wider than one device pixel are now drawn as several cosmetic one-pixel strokes, offset horizontally and vertically by at most one device pixel from each other, with dash patterns scaled by the line width. The polyline is computed once and the curve is filled once. A noisy 2000-point curve of width 2 now takes about 10 ms instead of 370 ms to draw with antialiasing, with almost the same appearance. Printing, exporting, translucent colors and lines wider than 8 device pixels still use the original pen. The new `curve/fast_thick_lines` option of the `plot` section restores the previous behavior. Closes #82 Assisted-by: Claude Opus 5.5 --- doc/release_notes/release_2.12.md | 1 + plotpy/config.py | 2 + plotpy/items/curve/base.py | 105 +++++++++++ plotpy/tests/unit/test_curve_thick_strokes.py | 168 ++++++++++++++++++ 4 files changed, 276 insertions(+) create mode 100644 plotpy/tests/unit/test_curve_thick_strokes.py diff --git a/doc/release_notes/release_2.12.md b/doc/release_notes/release_2.12.md index 544aac0..2a34d2e 100644 --- a/doc/release_notes/release_2.12.md +++ b/doc/release_notes/release_2.12.md @@ -13,6 +13,7 @@ * **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)) +* **Thick curves** — Curves wider than one screen pixel are now drawn on screen as several one-pixel strokes, slightly offset from each other, which looks almost the same as before but is 30 to 50 times faster on noisy curves: a 2000-point curve of width 2 takes about 10 ms instead of 370 ms to draw with antialiasing. Dash patterns are scaled as before. Printing and exporting, curves with a translucent color and curves wider than 8 screen pixels still use the original pen. The new `curve/fast_thick_lines` option of the `plot` configuration section restores the previous behavior (closes [Issue #82](https://github.com/PlotPyStack/PlotPy/issues/82)) ⚙️ Dependencies: diff --git a/plotpy/config.py b/plotpy/config.py index 3efc805..4f11b57 100644 --- a/plotpy/config.py +++ b/plotpy/config.py @@ -50,6 +50,8 @@ def get_plotpy_defaults() -> dict[str, int | float | str | bool]: "antialiasing": False, # Draw thin curves (width <= 1) one device pixel wide on screen "curve/cosmetic_hairline": True, + # Draw thick curves as several one-pixel strokes on screen + "curve/fast_thick_lines": 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 04960ee..769ac73 100644 --- a/plotpy/items/curve/base.py +++ b/plotpy/items/curve/base.py @@ -2,6 +2,7 @@ from __future__ import annotations +import math import sys from typing import TYPE_CHECKING @@ -11,6 +12,7 @@ from qtpy import QtCore as QC from qtpy import QtGui as QG from qwt import QwtPlotCurve +from qwt.plot_curve import series_to_polyline from plotpy.config import CONF, _ from plotpy.coords import canvas_to_axes @@ -70,6 +72,68 @@ def get_screen_pen(painter: QG.QPainter, pen: QG.QPen) -> QG.QPen: return pen +#: Maximum width, in device pixels, of the lines drawn as several one-pixel strokes +#: (see :py:func:`get_thick_line_strokes`): wider lines are drawn with their pen +THICK_LINE_MAX_STROKES_WIDTH = 8.0 + + +def get_thick_line_strokes( + painter: QG.QPainter, pen: QG.QPen +) -> tuple[QG.QPen, list[tuple[float, float]]] | None: + """Return the strokes drawing a thick line quickly on screen + + Qt's raster engine draws pens wider than one device pixel much more slowly + than one-pixel pens (up to a hundred times slower for noisy curves, with + antialiasing). When painting on screen (raster engine, without scaling + transform), an opaque solid line wider than one device pixel may instead be + drawn as several one-device-pixel strokes, offset horizontally and vertically + by at most one device pixel from each other so as to cover the line width, + whatever the line direction. Dash patterns are scaled by the line width, as + for the original pen. + + The line is drawn with its pen when printing or exporting (other paint engines + or scaling transform), for translucent colors (whose overlapping strokes would + accumulate), for lines wider than :py:data:`THICK_LINE_MAX_STROKES_WIDTH` + device pixels, or when the ``curve/fast_thick_lines`` option of the ``plot`` + configuration section is disabled. + + Args: + painter: Painter + pen: Pen to draw with + + Returns: + A tuple (stroke pen, stroke offsets in logical coordinates), or None if + the line must be drawn with the pen itself + """ + if ( + pen.style() == QC.Qt.NoPen + or pen.isCosmetic() + or pen.brush().style() != QC.Qt.SolidPattern + or pen.color().alpha() < 255 + or painter.paintEngine().type() != QG.QPaintEngine.Raster + or painter.worldTransform().isScaling() + ): + return None + ratio = painter.device().devicePixelRatioF() + width = pen.widthF() * ratio + if ( + width <= 1.0 + or width > THICK_LINE_MAX_STROKES_WIDTH + or not CONF.get("plot", "curve/fast_thick_lines", True) + ): + return None + stroke_pen = QG.QPen(pen) + stroke_pen.setWidthF(1.0) + stroke_pen.setCosmetic(True) + if pen.style() != QC.Qt.SolidLine: + stroke_pen.setDashPattern([length * width for length in pen.dashPattern()]) + extra = width - 1.0 + shifts = np.linspace(-extra / 2, extra / 2, math.ceil(extra) + 1) / ratio + offsets = [(float(shift), 0.0) for shift in shifts] + offsets += [(0.0, float(shift)) for shift in shifts if shift != 0.0] + return stroke_pen, offsets + + def seg_dist(P: QC.QPointF, P0: QC.QPointF, P1: QC.QPointF) -> float: """Compute distance between point P and segment (P0, P1) @@ -228,6 +292,47 @@ def drawCurve( painter.setPen(screen_pen) super().drawCurve(painter, style, xMap, yMap, canvasRect, from_, to) + def drawLines( + self, + painter: QG.QPainter, + xMap: QwtScaleMap, + yMap: QwtScaleMap, + canvasRect: QC.QRectF, + from_: int, + to: int, + ) -> None: + """Draw lines + + Reimplemented to draw thick lines quickly on screen, as several one-pixel + strokes (see :py:func:`get_thick_line_strokes`). + + Args: + painter: Painter + 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 + """ + strokes = get_thick_line_strokes(painter, painter.pen()) + if strokes is None: + super().drawLines(painter, xMap, yMap, canvasRect, from_, to) + return + if from_ > to: + return + stroke_pen, offsets = strokes + polyline = series_to_polyline(xMap, yMap, self.data(), from_, to) + painter.save() + painter.setPen(stroke_pen) + for dx, dy in offsets: + painter.translate(dx, dy) + painter.drawPolyline(polyline) + painter.translate(-dx, -dy) + painter.restore() + brush = self.brush() + if brush.style() != QC.Qt.NoBrush and brush.color().alpha() > 0: + self.fillCurve(painter, xMap, yMap, canvasRect, polyline) + 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/tests/unit/test_curve_thick_strokes.py b/plotpy/tests/unit/test_curve_thick_strokes.py new file mode 100644 index 0000000..2132e25 --- /dev/null +++ b/plotpy/tests/unit/test_curve_thick_strokes.py @@ -0,0 +1,168 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the terms of the BSD 3-Clause +# (see plotpy/LICENSE for details) + +"""Test the fast drawing of thick curves as several one-pixel strokes""" + +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 ( + THICK_LINE_MAX_STROKES_WIDTH, + get_thick_line_strokes, +) + +WIDTH, HEIGHT = 200, 120 + + +def _image(ratio: float = 1.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 _set_option(monkeypatch: pytest.MonkeyPatch, enabled: bool) -> None: + """Set the ``curve/fast_thick_lines`` option""" + original_get = CONF.get + + def get(section, option, *args, **kwargs): + if (section, option) == ("plot", "curve/fast_thick_lines"): + return enabled + return original_get(section, option, *args, **kwargs) + + monkeypatch.setattr(curve_base.CONF, "get", get) + + +def _strokes(image: QG.QImage, pen: QG.QPen, scale: float = 1.0): + """Return the strokes of a pen for a painter on an image""" + painter = QG.QPainter(image) + if scale != 1.0: + painter.scale(scale, scale) + strokes = get_thick_line_strokes(painter, pen) + painter.end() + return strokes + + +def _render( + width: float, color: str = "#000000", style: str = "-", ratio: float = 1.0 +) -> np.ndarray: + """Render a noisy sine curve and return the image darkness (0 to 1)""" + rng = np.random.default_rng(0) + x = np.linspace(0.0, 1.0, 400) + y = 0.5 + 0.3 * np.sin(12 * x) + 0.03 * rng.standard_normal(x.size) + curve = make.curve(x, y, color=color, linewidth=width, linestyle=style) + xmap, ymap = QwtScaleMap(), QwtScaleMap() + xmap.setPaintInterval(0, WIDTH) + xmap.setScaleInterval(0.0, 1.0) + ymap.setPaintInterval(HEIGHT, 0) + ymap.setScaleInterval(0.0, 1.0) + image = _image(ratio) + painter = QG.QPainter(image) + painter.setRenderHint(QG.QPainter.Antialiasing, True) + curve.drawSeries(painter, xmap, ymap, QC.QRectF(0, 0, WIDTH, HEIGHT), 0, -1) + painter.end() + gray = image.convertToFormat(QG.QImage.Format_Grayscale8) + data = np.array( + [ + [gray.pixelColor(col, row).value() for col in range(gray.width())] + for row in range(gray.height()) + ], + dtype=float, + ) + return 1.0 - data / 255.0 + + +def test_thick_line_strokes_offsets() -> None: + """Strokes cover the line width, horizontally and vertically""" + with qt_app_context(exec_loop=False): + pen = QG.QPen(QG.QColor("#000000"), 2.0) + stroke_pen, offsets = _strokes(_image(), pen) + assert stroke_pen.isCosmetic() and stroke_pen.widthF() == 1.0 + assert sorted(offsets) == [(-0.5, 0.0), (0.0, -0.5), (0.0, 0.5), (0.5, 0.0)] + # Width of 3 device pixels with a device pixel ratio of 1.5: + _stroke_pen, offsets = _strokes(_image(1.5), pen) + assert len(offsets) == 5 + shifts = sorted(dx * 1.5 for dx, _dy in offsets if _dy == 0.0) + assert shifts == pytest.approx([-1.0, 0.0, 1.0]) + assert not pen.isCosmetic() # The original pen is not modified + + +def test_thick_line_strokes_dash_pattern() -> None: + """Dash patterns of strokes are scaled by the line width""" + with qt_app_context(exec_loop=False): + pen = QG.QPen(QG.QColor("#000000"), 2.0, QC.Qt.DashLine) + stroke_pen, _offsets = _strokes(_image(), pen) + assert list(stroke_pen.dashPattern()) == [ + 2.0 * length for length in pen.dashPattern() + ] + + +@pytest.mark.parametrize( + "pen_args, scale", + [ + (("#000000", 1.0), 1.0), # Thin line + (("#80000000", 2.0), 1.0), # Translucent color + (("#000000", THICK_LINE_MAX_STROKES_WIDTH + 1.0), 1.0), # Very wide line + (("#000000", 2.0), 3.0), # Scaled painter (printing, exporting) + ], +) +def test_thick_line_pen_kept(pen_args: tuple[str, float], scale: float) -> None: + """Lines are drawn with their pen when strokes do not apply""" + with qt_app_context(exec_loop=False): + color, width = pen_args + pen = QG.QPen(QG.QColor(color), width) + assert _strokes(_image(), pen, scale) is None + + +def test_thick_line_other_engine() -> None: + """Lines are drawn with their pen on other paint devices""" + with qt_app_context(exec_loop=False): + picture = QG.QPicture() + painter = QG.QPainter(picture) + assert get_thick_line_strokes(painter, QG.QPen(QG.QColor("red"), 2.0)) is None + painter.end() + + +def test_thick_line_option(monkeypatch: pytest.MonkeyPatch) -> None: + """The ``curve/fast_thick_lines`` option disables strokes""" + _set_option(monkeypatch, False) + with qt_app_context(exec_loop=False): + assert _strokes(_image(), QG.QPen(QG.QColor("red"), 2.0)) is None + + +@pytest.mark.parametrize("ratio", [1.0, 1.5]) +@pytest.mark.parametrize("width", [1.5, 2.0, 3.0]) +def test_thick_line_rendering( + monkeypatch: pytest.MonkeyPatch, width: float, ratio: float +) -> None: + """Strokes look like the line drawn with its pen""" + with qt_app_context(exec_loop=False): + _set_option(monkeypatch, True) + strokes = _render(width, ratio=ratio) + _set_option(monkeypatch, False) + reference = _render(width, ratio=ratio) + # Same amount of ink, at the same place: + assert strokes.sum() == pytest.approx(reference.sum(), rel=0.1) + assert np.abs(strokes - reference).sum() < 0.2 * reference.sum() + assert not np.array_equal(strokes, reference) # Strokes were used + + +def test_translucent_thick_line_rendering(monkeypatch: pytest.MonkeyPatch) -> None: + """Translucent thick lines are drawn with their pen""" + with qt_app_context(exec_loop=False): + _set_option(monkeypatch, True) + image = _render(2.0, color="#80ff0000") + _set_option(monkeypatch, False) + np.testing.assert_array_equal(image, _render(2.0, color="#80ff0000"))