Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions doc/release_notes/release_2.12.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
2 changes: 2 additions & 0 deletions plotpy/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
105 changes: 105 additions & 0 deletions plotpy/items/curve/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import math
import sys
from typing import TYPE_CHECKING

Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
168 changes: 168 additions & 0 deletions plotpy/tests/unit/test_curve_thick_strokes.py
Original file line number Diff line number Diff line change
@@ -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"))
Loading