Skip to content

Commit 47ed7c6

Browse files
committed
Merge branch 'develop' into feature/83-minmax-downsampling
2 parents d6dcb67 + 8042fd5 commit 47ed7c6

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
@@ -18,6 +18,7 @@
1818
* **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
1919
* **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
2020
* **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))
21+
* **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))
2122

2223
⚙️ Dependencies:
2324

‎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 collections.abc import Generator
78
from contextlib import contextmanager
@@ -13,6 +14,7 @@
1314
from qtpy import QtCore as QC
1415
from qtpy import QtGui as QG
1516
from qwt import QwtPlotCurve
17+
from qwt.plot_curve import series_to_polyline
1618

1719
from plotpy.config import CONF, _
1820
from plotpy.coords import canvas_to_axes
@@ -106,6 +108,68 @@ def get_screen_pen(painter: QG.QPainter, pen: QG.QPen) -> QG.QPen:
106108
return pen
107109

108110

111+
#: Maximum width, in device pixels, of the lines drawn as several one-pixel strokes
112+
#: (see :py:func:`get_thick_line_strokes`): wider lines are drawn with their pen
113+
THICK_LINE_MAX_STROKES_WIDTH = 8.0
114+
115+
116+
def get_thick_line_strokes(
117+
painter: QG.QPainter, pen: QG.QPen
118+
) -> tuple[QG.QPen, list[tuple[float, float]]] | None:
119+
"""Return the strokes drawing a thick line quickly on screen
120+
121+
Qt's raster engine draws pens wider than one device pixel much more slowly
122+
than one-pixel pens (up to a hundred times slower for noisy curves, with
123+
antialiasing). When painting on screen (raster engine, without scaling
124+
transform), an opaque solid line wider than one device pixel may instead be
125+
drawn as several one-device-pixel strokes, offset horizontally and vertically
126+
by at most one device pixel from each other so as to cover the line width,
127+
whatever the line direction. Dash patterns are scaled by the line width, as
128+
for the original pen.
129+
130+
The line is drawn with its pen when printing or exporting (other paint engines
131+
or scaling transform), for translucent colors (whose overlapping strokes would
132+
accumulate), for lines wider than :py:data:`THICK_LINE_MAX_STROKES_WIDTH`
133+
device pixels, or when the ``curve/fast_thick_lines`` option of the ``plot``
134+
configuration section is disabled.
135+
136+
Args:
137+
painter: Painter
138+
pen: Pen to draw with
139+
140+
Returns:
141+
A tuple (stroke pen, stroke offsets in logical coordinates), or None if
142+
the line must be drawn with the pen itself
143+
"""
144+
if (
145+
pen.style() == QC.Qt.NoPen
146+
or pen.isCosmetic()
147+
or pen.brush().style() != QC.Qt.SolidPattern
148+
or pen.color().alpha() < 255
149+
or painter.paintEngine().type() != QG.QPaintEngine.Raster
150+
or painter.worldTransform().isScaling()
151+
):
152+
return None
153+
ratio = painter.device().devicePixelRatioF()
154+
width = pen.widthF() * ratio
155+
if (
156+
width <= 1.0
157+
or width > THICK_LINE_MAX_STROKES_WIDTH
158+
or not CONF.get("plot", "curve/fast_thick_lines", True)
159+
):
160+
return None
161+
stroke_pen = QG.QPen(pen)
162+
stroke_pen.setWidthF(1.0)
163+
stroke_pen.setCosmetic(True)
164+
if pen.style() != QC.Qt.SolidLine:
165+
stroke_pen.setDashPattern([length * width for length in pen.dashPattern()])
166+
extra = width - 1.0
167+
shifts = np.linspace(-extra / 2, extra / 2, math.ceil(extra) + 1) / ratio
168+
offsets = [(float(shift), 0.0) for shift in shifts]
169+
offsets += [(0.0, float(shift)) for shift in shifts if shift != 0.0]
170+
return stroke_pen, offsets
171+
172+
109173
def seg_dist(P: QC.QPointF, P0: QC.QPointF, P1: QC.QPointF) -> float:
110174
"""Compute distance between point P and segment (P0, P1)
111175
@@ -267,6 +331,47 @@ def drawCurve(
267331
painter.setPen(screen_pen)
268332
super().drawCurve(painter, style, xMap, yMap, canvasRect, from_, to)
269333

334+
def drawLines(
335+
self,
336+
painter: QG.QPainter,
337+
xMap: QwtScaleMap,
338+
yMap: QwtScaleMap,
339+
canvasRect: QC.QRectF,
340+
from_: int,
341+
to: int,
342+
) -> None:
343+
"""Draw lines
344+
345+
Reimplemented to draw thick lines quickly on screen, as several one-pixel
346+
strokes (see :py:func:`get_thick_line_strokes`).
347+
348+
Args:
349+
painter: Painter
350+
xMap: X axis scale map
351+
yMap: Y axis scale map
352+
canvasRect: Contents rectangle of the canvas
353+
from_: Index of the first point to be painted
354+
to: Index of the last point to be painted
355+
"""
356+
strokes = get_thick_line_strokes(painter, painter.pen())
357+
if strokes is None:
358+
super().drawLines(painter, xMap, yMap, canvasRect, from_, to)
359+
return
360+
if from_ > to:
361+
return
362+
stroke_pen, offsets = strokes
363+
polyline = series_to_polyline(xMap, yMap, self.data(), from_, to)
364+
painter.save()
365+
painter.setPen(stroke_pen)
366+
for dx, dy in offsets:
367+
painter.translate(dx, dy)
368+
painter.drawPolyline(polyline)
369+
painter.translate(-dx, -dy)
370+
painter.restore()
371+
brush = self.brush()
372+
if brush.style() != QC.Qt.NoBrush and brush.color().alpha() > 0:
373+
self.fillCurve(painter, xMap, yMap, canvasRect, polyline)
374+
270375
def types(self) -> tuple[type[IItemType], ...]:
271376
"""Returns a group or category for this item.
272377
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)