Skip to content

Commit 3730e9c

Browse files
Merge pull request #81 from PlotPyStack/feature/80-cosmetic-curve-pen
Draw thin curves with a cosmetic pen on screen (fast rendering on HiDPI screens). Assisted-by: Claude Opus 5.5
2 parents cb83e6a + dd309c1 commit 3730e9c

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)