Skip to content

Commit 0f42aa7

Browse files
committed
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
1 parent 3730e9c commit 0f42aa7

4 files changed

Lines changed: 276 additions & 0 deletions

File tree

‎doc/release_notes/release_2.12.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
* **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
1414
* **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
1515
* **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))
16+
* **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))
1617

1718
⚙️ Dependencies:
1819

‎plotpy/config.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ def get_plotpy_defaults() -> dict[str, int | float | str | bool]:
5050
"antialiasing": False,
5151
# Draw thin curves (width <= 1) one device pixel wide on screen
5252
"curve/cosmetic_hairline": True,
53+
# Draw thick curves as several one-pixel strokes on screen
54+
"curve/fast_thick_lines": True,
5355
"title/font/size": 12,
5456
"title/font/bold": False,
5557
"selected_curve_symbol/marker": "Rect",

‎plotpy/items/curve/base.py‎

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
import math
56
import sys
67
from typing import TYPE_CHECKING
78

@@ -11,6 +12,7 @@
1112
from qtpy import QtCore as QC
1213
from qtpy import QtGui as QG
1314
from qwt import QwtPlotCurve
15+
from qwt.plot_curve import series_to_polyline
1416

1517
from plotpy.config import CONF, _
1618
from plotpy.coords import canvas_to_axes
@@ -70,6 +72,68 @@ def get_screen_pen(painter: QG.QPainter, pen: QG.QPen) -> QG.QPen:
7072
return pen
7173

7274

75+
#: Maximum width, in device pixels, of the lines drawn as several one-pixel strokes
76+
#: (see :py:func:`get_thick_line_strokes`): wider lines are drawn with their pen
77+
THICK_LINE_MAX_STROKES_WIDTH = 8.0
78+
79+
80+
def get_thick_line_strokes(
81+
painter: QG.QPainter, pen: QG.QPen
82+
) -> tuple[QG.QPen, list[tuple[float, float]]] | None:
83+
"""Return the strokes drawing a thick line quickly on screen
84+
85+
Qt's raster engine draws pens wider than one device pixel much more slowly
86+
than one-pixel pens (up to a hundred times slower for noisy curves, with
87+
antialiasing). When painting on screen (raster engine, without scaling
88+
transform), an opaque solid line wider than one device pixel may instead be
89+
drawn as several one-device-pixel strokes, offset horizontally and vertically
90+
by at most one device pixel from each other so as to cover the line width,
91+
whatever the line direction. Dash patterns are scaled by the line width, as
92+
for the original pen.
93+
94+
The line is drawn with its pen when printing or exporting (other paint engines
95+
or scaling transform), for translucent colors (whose overlapping strokes would
96+
accumulate), for lines wider than :py:data:`THICK_LINE_MAX_STROKES_WIDTH`
97+
device pixels, or when the ``curve/fast_thick_lines`` option of the ``plot``
98+
configuration section is disabled.
99+
100+
Args:
101+
painter: Painter
102+
pen: Pen to draw with
103+
104+
Returns:
105+
A tuple (stroke pen, stroke offsets in logical coordinates), or None if
106+
the line must be drawn with the pen itself
107+
"""
108+
if (
109+
pen.style() == QC.Qt.NoPen
110+
or pen.isCosmetic()
111+
or pen.brush().style() != QC.Qt.SolidPattern
112+
or pen.color().alpha() < 255
113+
or painter.paintEngine().type() != QG.QPaintEngine.Raster
114+
or painter.worldTransform().isScaling()
115+
):
116+
return None
117+
ratio = painter.device().devicePixelRatioF()
118+
width = pen.widthF() * ratio
119+
if (
120+
width <= 1.0
121+
or width > THICK_LINE_MAX_STROKES_WIDTH
122+
or not CONF.get("plot", "curve/fast_thick_lines", True)
123+
):
124+
return None
125+
stroke_pen = QG.QPen(pen)
126+
stroke_pen.setWidthF(1.0)
127+
stroke_pen.setCosmetic(True)
128+
if pen.style() != QC.Qt.SolidLine:
129+
stroke_pen.setDashPattern([length * width for length in pen.dashPattern()])
130+
extra = width - 1.0
131+
shifts = np.linspace(-extra / 2, extra / 2, math.ceil(extra) + 1) / ratio
132+
offsets = [(float(shift), 0.0) for shift in shifts]
133+
offsets += [(0.0, float(shift)) for shift in shifts if shift != 0.0]
134+
return stroke_pen, offsets
135+
136+
73137
def seg_dist(P: QC.QPointF, P0: QC.QPointF, P1: QC.QPointF) -> float:
74138
"""Compute distance between point P and segment (P0, P1)
75139
@@ -228,6 +292,47 @@ def drawCurve(
228292
painter.setPen(screen_pen)
229293
super().drawCurve(painter, style, xMap, yMap, canvasRect, from_, to)
230294

295+
def drawLines(
296+
self,
297+
painter: QG.QPainter,
298+
xMap: QwtScaleMap,
299+
yMap: QwtScaleMap,
300+
canvasRect: QC.QRectF,
301+
from_: int,
302+
to: int,
303+
) -> None:
304+
"""Draw lines
305+
306+
Reimplemented to draw thick lines quickly on screen, as several one-pixel
307+
strokes (see :py:func:`get_thick_line_strokes`).
308+
309+
Args:
310+
painter: Painter
311+
xMap: X axis scale map
312+
yMap: Y axis scale map
313+
canvasRect: Contents rectangle of the canvas
314+
from_: Index of the first point to be painted
315+
to: Index of the last point to be painted
316+
"""
317+
strokes = get_thick_line_strokes(painter, painter.pen())
318+
if strokes is None:
319+
super().drawLines(painter, xMap, yMap, canvasRect, from_, to)
320+
return
321+
if from_ > to:
322+
return
323+
stroke_pen, offsets = strokes
324+
polyline = series_to_polyline(xMap, yMap, self.data(), from_, to)
325+
painter.save()
326+
painter.setPen(stroke_pen)
327+
for dx, dy in offsets:
328+
painter.translate(dx, dy)
329+
painter.drawPolyline(polyline)
330+
painter.translate(-dx, -dy)
331+
painter.restore()
332+
brush = self.brush()
333+
if brush.style() != QC.Qt.NoBrush and brush.color().alpha() > 0:
334+
self.fillCurve(painter, xMap, yMap, canvasRect, polyline)
335+
231336
def types(self) -> tuple[type[IItemType], ...]:
232337
"""Returns a group or category for this item.
233338
This should be a tuple of class objects inheriting from IItemType
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
# -*- coding: utf-8 -*-
2+
#
3+
# Licensed under the terms of the BSD 3-Clause
4+
# (see plotpy/LICENSE for details)
5+
6+
"""Test the fast drawing of thick curves as several one-pixel strokes"""
7+
8+
from __future__ import annotations
9+
10+
import numpy as np
11+
import pytest
12+
from guidata.qthelpers import qt_app_context
13+
from qtpy import QtCore as QC
14+
from qtpy import QtGui as QG
15+
from qwt import QwtScaleMap
16+
17+
from plotpy.builder import make
18+
from plotpy.config import CONF
19+
from plotpy.items.curve import base as curve_base
20+
from plotpy.items.curve.base import (
21+
THICK_LINE_MAX_STROKES_WIDTH,
22+
get_thick_line_strokes,
23+
)
24+
25+
WIDTH, HEIGHT = 200, 120
26+
27+
28+
def _image(ratio: float = 1.0) -> QG.QImage:
29+
"""Return a white image with the given device pixel ratio"""
30+
image = QG.QImage(int(WIDTH * ratio), int(HEIGHT * ratio), QG.QImage.Format_ARGB32)
31+
image.setDevicePixelRatio(ratio)
32+
image.fill(QC.Qt.white)
33+
return image
34+
35+
36+
def _set_option(monkeypatch: pytest.MonkeyPatch, enabled: bool) -> None:
37+
"""Set the ``curve/fast_thick_lines`` option"""
38+
original_get = CONF.get
39+
40+
def get(section, option, *args, **kwargs):
41+
if (section, option) == ("plot", "curve/fast_thick_lines"):
42+
return enabled
43+
return original_get(section, option, *args, **kwargs)
44+
45+
monkeypatch.setattr(curve_base.CONF, "get", get)
46+
47+
48+
def _strokes(image: QG.QImage, pen: QG.QPen, scale: float = 1.0):
49+
"""Return the strokes of a pen for a painter on an image"""
50+
painter = QG.QPainter(image)
51+
if scale != 1.0:
52+
painter.scale(scale, scale)
53+
strokes = get_thick_line_strokes(painter, pen)
54+
painter.end()
55+
return strokes
56+
57+
58+
def _render(
59+
width: float, color: str = "#000000", style: str = "-", ratio: float = 1.0
60+
) -> np.ndarray:
61+
"""Render a noisy sine curve and return the image darkness (0 to 1)"""
62+
rng = np.random.default_rng(0)
63+
x = np.linspace(0.0, 1.0, 400)
64+
y = 0.5 + 0.3 * np.sin(12 * x) + 0.03 * rng.standard_normal(x.size)
65+
curve = make.curve(x, y, color=color, linewidth=width, linestyle=style)
66+
xmap, ymap = QwtScaleMap(), QwtScaleMap()
67+
xmap.setPaintInterval(0, WIDTH)
68+
xmap.setScaleInterval(0.0, 1.0)
69+
ymap.setPaintInterval(HEIGHT, 0)
70+
ymap.setScaleInterval(0.0, 1.0)
71+
image = _image(ratio)
72+
painter = QG.QPainter(image)
73+
painter.setRenderHint(QG.QPainter.Antialiasing, True)
74+
curve.drawSeries(painter, xmap, ymap, QC.QRectF(0, 0, WIDTH, HEIGHT), 0, -1)
75+
painter.end()
76+
gray = image.convertToFormat(QG.QImage.Format_Grayscale8)
77+
data = np.array(
78+
[
79+
[gray.pixelColor(col, row).value() for col in range(gray.width())]
80+
for row in range(gray.height())
81+
],
82+
dtype=float,
83+
)
84+
return 1.0 - data / 255.0
85+
86+
87+
def test_thick_line_strokes_offsets() -> None:
88+
"""Strokes cover the line width, horizontally and vertically"""
89+
with qt_app_context(exec_loop=False):
90+
pen = QG.QPen(QG.QColor("#000000"), 2.0)
91+
stroke_pen, offsets = _strokes(_image(), pen)
92+
assert stroke_pen.isCosmetic() and stroke_pen.widthF() == 1.0
93+
assert sorted(offsets) == [(-0.5, 0.0), (0.0, -0.5), (0.0, 0.5), (0.5, 0.0)]
94+
# Width of 3 device pixels with a device pixel ratio of 1.5:
95+
_stroke_pen, offsets = _strokes(_image(1.5), pen)
96+
assert len(offsets) == 5
97+
shifts = sorted(dx * 1.5 for dx, _dy in offsets if _dy == 0.0)
98+
assert shifts == pytest.approx([-1.0, 0.0, 1.0])
99+
assert not pen.isCosmetic() # The original pen is not modified
100+
101+
102+
def test_thick_line_strokes_dash_pattern() -> None:
103+
"""Dash patterns of strokes are scaled by the line width"""
104+
with qt_app_context(exec_loop=False):
105+
pen = QG.QPen(QG.QColor("#000000"), 2.0, QC.Qt.DashLine)
106+
stroke_pen, _offsets = _strokes(_image(), pen)
107+
assert list(stroke_pen.dashPattern()) == [
108+
2.0 * length for length in pen.dashPattern()
109+
]
110+
111+
112+
@pytest.mark.parametrize(
113+
"pen_args, scale",
114+
[
115+
(("#000000", 1.0), 1.0), # Thin line
116+
(("#80000000", 2.0), 1.0), # Translucent color
117+
(("#000000", THICK_LINE_MAX_STROKES_WIDTH + 1.0), 1.0), # Very wide line
118+
(("#000000", 2.0), 3.0), # Scaled painter (printing, exporting)
119+
],
120+
)
121+
def test_thick_line_pen_kept(pen_args: tuple[str, float], scale: float) -> None:
122+
"""Lines are drawn with their pen when strokes do not apply"""
123+
with qt_app_context(exec_loop=False):
124+
color, width = pen_args
125+
pen = QG.QPen(QG.QColor(color), width)
126+
assert _strokes(_image(), pen, scale) is None
127+
128+
129+
def test_thick_line_other_engine() -> None:
130+
"""Lines are drawn with their pen on other paint devices"""
131+
with qt_app_context(exec_loop=False):
132+
picture = QG.QPicture()
133+
painter = QG.QPainter(picture)
134+
assert get_thick_line_strokes(painter, QG.QPen(QG.QColor("red"), 2.0)) is None
135+
painter.end()
136+
137+
138+
def test_thick_line_option(monkeypatch: pytest.MonkeyPatch) -> None:
139+
"""The ``curve/fast_thick_lines`` option disables strokes"""
140+
_set_option(monkeypatch, False)
141+
with qt_app_context(exec_loop=False):
142+
assert _strokes(_image(), QG.QPen(QG.QColor("red"), 2.0)) is None
143+
144+
145+
@pytest.mark.parametrize("ratio", [1.0, 1.5])
146+
@pytest.mark.parametrize("width", [1.5, 2.0, 3.0])
147+
def test_thick_line_rendering(
148+
monkeypatch: pytest.MonkeyPatch, width: float, ratio: float
149+
) -> None:
150+
"""Strokes look like the line drawn with its pen"""
151+
with qt_app_context(exec_loop=False):
152+
_set_option(monkeypatch, True)
153+
strokes = _render(width, ratio=ratio)
154+
_set_option(monkeypatch, False)
155+
reference = _render(width, ratio=ratio)
156+
# Same amount of ink, at the same place:
157+
assert strokes.sum() == pytest.approx(reference.sum(), rel=0.1)
158+
assert np.abs(strokes - reference).sum() < 0.2 * reference.sum()
159+
assert not np.array_equal(strokes, reference) # Strokes were used
160+
161+
162+
def test_translucent_thick_line_rendering(monkeypatch: pytest.MonkeyPatch) -> None:
163+
"""Translucent thick lines are drawn with their pen"""
164+
with qt_app_context(exec_loop=False):
165+
_set_option(monkeypatch, True)
166+
image = _render(2.0, color="#80ff0000")
167+
_set_option(monkeypatch, False)
168+
np.testing.assert_array_equal(image, _render(2.0, color="#80ff0000"))

0 commit comments

Comments
 (0)