Skip to content

Commit dd309c1

Browse files
committed
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
1 parent cb83e6a commit dd309c1

5 files changed

Lines changed: 231 additions & 2 deletions

File tree

‎doc/release_notes/release_2.12.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
* **Plot refresh** — Refreshing a plot no longer recomputes the plot layout when the title and axis styles did not change
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
15+
* **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))
1516

1617
⚙️ Dependencies:
1718

‎plotpy/config.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ def get_plotpy_defaults() -> dict[str, int | float | str | bool]:
4848
"plot": {
4949
"selection/distance": 6,
5050
"antialiasing": False,
51+
# Draw thin curves (width <= 1) one device pixel wide on screen
52+
"curve/cosmetic_hairline": True,
5153
"title/font/size": 12,
5254
"title/font/bold": False,
5355
"selected_curve_symbol/marker": "Rect",

‎plotpy/items/curve/base.py‎

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from guidata.dataset import update_dataset
1010
from guidata.utils.misc import assert_interfaces_valid
1111
from qtpy import QtCore as QC
12+
from qtpy import QtGui as QG
1213
from qwt import QwtPlotCurve
1314

1415
from plotpy.config import CONF, _
@@ -24,6 +25,7 @@
2425

2526
if TYPE_CHECKING:
2627
import guidata.io
28+
from qwt import QwtScaleMap
2729

2830
from plotpy.interfaces import IItemType
2931
from plotpy.plot import BasePlot
@@ -35,6 +37,39 @@
3537
SELECTED_SYMBOL = SELECTED_SYMBOL_PARAM.build_symbol()
3638

3739

40+
def get_screen_pen(painter: QG.QPainter, pen: QG.QPen) -> QG.QPen:
41+
"""Return the pen to draw a line on screen
42+
43+
On high-DPI screens (e.g. with a 150 % scaling factor), a pen of width 1 is
44+
drawn wider than one device pixel, which makes Qt's raster engine take its
45+
much slower path for wide pens. When painting on screen (raster engine,
46+
without scaling transform), pens of width 1 or less are therefore made
47+
cosmetic, i.e. one device pixel wide. Printing and exporting, which use other
48+
paint engines or a scaling transform, are not affected.
49+
50+
This behavior may be disabled with the ``curve/cosmetic_hairline`` option of
51+
the ``plot`` configuration section.
52+
53+
Args:
54+
painter: Painter
55+
pen: Pen to draw with
56+
57+
Returns:
58+
A cosmetic copy of the pen if applicable, the pen itself otherwise
59+
"""
60+
if (
61+
pen.isCosmetic()
62+
or pen.widthF() > 1.0
63+
or painter.paintEngine().type() != QG.QPaintEngine.Raster
64+
or painter.worldTransform().isScaling()
65+
or not CONF.get("plot", "curve/cosmetic_hairline", True)
66+
):
67+
return pen
68+
pen = QG.QPen(pen)
69+
pen.setCosmetic(True)
70+
return pen
71+
72+
3873
def seg_dist(P: QC.QPointF, P0: QC.QPointF, P1: QC.QPointF) -> float:
3974
"""Compute distance between point P and segment (P0, P1)
4075
@@ -163,6 +198,36 @@ def boundingRect(self) -> QC.QRectF:
163198
else:
164199
return QwtPlotCurve.boundingRect(self)
165200

201+
def drawCurve(
202+
self,
203+
painter: QG.QPainter,
204+
style: int,
205+
xMap: QwtScaleMap,
206+
yMap: QwtScaleMap,
207+
canvasRect: QC.QRectF,
208+
from_: int,
209+
to: int,
210+
) -> None:
211+
"""Draw the line part (without symbols) of a curve interval
212+
213+
Reimplemented to draw thin curves with a cosmetic pen on screen
214+
(see :py:func:`get_screen_pen`).
215+
216+
Args:
217+
painter: Painter
218+
style: Curve style, see ``QwtPlotCurve.CurveStyle``
219+
xMap: X axis scale map
220+
yMap: Y axis scale map
221+
canvasRect: Contents rectangle of the canvas
222+
from_: Index of the first point to be painted
223+
to: Index of the last point to be painted
224+
"""
225+
pen = painter.pen()
226+
screen_pen = get_screen_pen(painter, pen)
227+
if screen_pen is not pen:
228+
painter.setPen(screen_pen)
229+
super().drawCurve(painter, style, xMap, yMap, canvasRect, from_, to)
230+
166231
def types(self) -> tuple[type[IItemType], ...]:
167232
"""Returns a group or category for this item.
168233
This should be a tuple of class objects inheriting from IItemType

‎plotpy/items/curve/errorbar.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from qwt import QwtPlotCurve, QwtScaleMap
1414

1515
from plotpy.config import _
16-
from plotpy.items.curve.base import CurveItem
16+
from plotpy.items.curve.base import CurveItem, get_screen_pen
1717
from plotpy.styles.curve import CurveParam
1818
from plotpy.styles.errorbar import ErrorBarParam
1919

@@ -286,7 +286,7 @@ def draw(
286286
QwtPlotCurve.draw(self, painter, xMap, yMap, canvasRect)
287287

288288
painter.save()
289-
painter.setPen(self.errorPen)
289+
painter.setPen(get_screen_pen(painter, self.errorPen))
290290
cap = self.errorCap / 2.0
291291

292292
if self._dx is not None and self.errorbarparam.mode == 0:
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
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 cosmetic pen used to draw thin curves on screen"""
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 get_screen_pen
21+
22+
WIDTH, HEIGHT = 100, 60
23+
24+
25+
def _scale_maps() -> tuple[QwtScaleMap, QwtScaleMap]:
26+
"""Return X and Y scale maps from [0, 1] to the image area"""
27+
xmap, ymap = QwtScaleMap(), QwtScaleMap()
28+
xmap.setPaintInterval(0, WIDTH)
29+
xmap.setScaleInterval(0.0, 1.0)
30+
ymap.setPaintInterval(HEIGHT, 0)
31+
ymap.setScaleInterval(0.0, 1.0)
32+
return xmap, ymap
33+
34+
35+
def _hidpi_image(ratio: float = 2.0) -> QG.QImage:
36+
"""Return a white image with the given device pixel ratio"""
37+
image = QG.QImage(int(WIDTH * ratio), int(HEIGHT * ratio), QG.QImage.Format_ARGB32)
38+
image.setDevicePixelRatio(ratio)
39+
image.fill(QC.Qt.white)
40+
return image
41+
42+
43+
def _painted_rows(image: QG.QImage, column: int) -> int:
44+
"""Return the number of non-white pixels in an image column"""
45+
white = QG.QColor(QC.Qt.white).rgb()
46+
return sum(image.pixel(column, row) != white for row in range(image.height()))
47+
48+
49+
def _painted_columns(image: QG.QImage, row: int) -> int:
50+
"""Return the number of non-white pixels in an image row"""
51+
white = QG.QColor(QC.Qt.white).rgb()
52+
return sum(image.pixel(col, row) != white for col in range(image.width()))
53+
54+
55+
def _draw_horizontal_curve(image: QG.QImage, width: float) -> None:
56+
"""Draw a horizontal black curve in the middle of the image"""
57+
x = np.linspace(0.0, 1.0, 50)
58+
y = np.full_like(x, 0.5)
59+
curve = make.curve(x, y, color="#000000", linewidth=width)
60+
xmap, ymap = _scale_maps()
61+
painter = QG.QPainter(image)
62+
curve.drawSeries(painter, xmap, ymap, QC.QRectF(0, 0, WIDTH, HEIGHT), 0, -1)
63+
painter.end()
64+
65+
66+
def _disable_option(monkeypatch: pytest.MonkeyPatch) -> None:
67+
"""Disable the ``curve/cosmetic_hairline`` option"""
68+
original_get = CONF.get
69+
70+
def get(section, option, *args, **kwargs):
71+
if (section, option) == ("plot", "curve/cosmetic_hairline"):
72+
return False
73+
return original_get(section, option, *args, **kwargs)
74+
75+
monkeypatch.setattr(curve_base.CONF, "get", get)
76+
77+
78+
def test_screen_pen_raster() -> None:
79+
"""Thin pens become cosmetic on screen, wide pens are left unchanged"""
80+
with qt_app_context(exec_loop=False):
81+
image = _hidpi_image()
82+
painter = QG.QPainter(image)
83+
for width in (0.5, 1.0):
84+
pen = QG.QPen(QG.QColor("red"), width)
85+
screen_pen = get_screen_pen(painter, pen)
86+
assert screen_pen.isCosmetic()
87+
assert screen_pen.widthF() == width
88+
assert not pen.isCosmetic() # The original pen is not modified
89+
pen = QG.QPen(QG.QColor("red"), 2.0)
90+
assert get_screen_pen(painter, pen) is pen
91+
painter.end()
92+
93+
94+
def test_screen_pen_print_and_export() -> None:
95+
"""Pens are left unchanged for other paint engines or scaled painters"""
96+
with qt_app_context(exec_loop=False):
97+
pen = QG.QPen(QG.QColor("red"), 1.0)
98+
picture = QG.QPicture()
99+
painter = QG.QPainter(picture)
100+
assert get_screen_pen(painter, pen) is pen
101+
painter.end()
102+
image = _hidpi_image()
103+
painter = QG.QPainter(image)
104+
painter.scale(3.0, 3.0) # As done by QwtPlotRenderer for printing
105+
assert get_screen_pen(painter, pen) is pen
106+
painter.end()
107+
108+
109+
def test_screen_pen_option(monkeypatch: pytest.MonkeyPatch) -> None:
110+
"""The ``curve/cosmetic_hairline`` option disables cosmetic pens"""
111+
_disable_option(monkeypatch)
112+
with qt_app_context(exec_loop=False):
113+
image = _hidpi_image()
114+
painter = QG.QPainter(image)
115+
pen = QG.QPen(QG.QColor("red"), 1.0)
116+
assert get_screen_pen(painter, pen) is pen
117+
painter.end()
118+
119+
120+
def test_curve_hidpi_rendering(monkeypatch: pytest.MonkeyPatch) -> None:
121+
"""Curves of width 1 are drawn one device pixel wide on HiDPI screens"""
122+
with qt_app_context(exec_loop=False):
123+
image = _hidpi_image()
124+
_draw_horizontal_curve(image, 1.0)
125+
assert _painted_rows(image, image.width() // 2) == 1
126+
image = _hidpi_image()
127+
_draw_horizontal_curve(image, 2.0)
128+
assert _painted_rows(image, image.width() // 2) == 4
129+
_disable_option(monkeypatch)
130+
image = _hidpi_image()
131+
_draw_horizontal_curve(image, 1.0)
132+
assert _painted_rows(image, image.width() // 2) == 2
133+
134+
135+
def test_curve_pen_parameters() -> None:
136+
"""The stored curve pen and its parameters are not modified"""
137+
with qt_app_context(exec_loop=False):
138+
curve = make.curve([0.0, 1.0], [0.0, 1.0], color="#000000", linewidth=1.0)
139+
image = _hidpi_image()
140+
painter = QG.QPainter(image)
141+
xmap, ymap = _scale_maps()
142+
curve.drawSeries(painter, xmap, ymap, QC.QRectF(0, 0, WIDTH, HEIGHT), 0, -1)
143+
painter.end()
144+
assert not curve.pen().isCosmetic()
145+
curve.param.update_param(curve)
146+
assert curve.param.line.width == 1.0
147+
148+
149+
def test_errorbar_hidpi_rendering() -> None:
150+
"""Error bars of width 1 are drawn one device pixel wide on HiDPI screens"""
151+
with qt_app_context(exec_loop=False):
152+
x = np.array([0.5])
153+
y = np.array([0.5])
154+
dy = np.array([0.3])
155+
item = make.error(x, y, None, dy, curvestyle="NoCurve", errorbarcap=0)
156+
image = _hidpi_image()
157+
painter = QG.QPainter(image)
158+
xmap, ymap = _scale_maps()
159+
item.draw(painter, xmap, ymap, QC.QRectF(0, 0, WIDTH, HEIGHT))
160+
painter.end()
161+
assert _painted_columns(image, image.height() // 2) == 1

0 commit comments

Comments
 (0)