diff --git a/doc/release_notes/release_2.12.md b/doc/release_notes/release_2.12.md index 544aac0..2a34d2e 100644 --- a/doc/release_notes/release_2.12.md +++ b/doc/release_notes/release_2.12.md @@ -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: diff --git a/plotpy/config.py b/plotpy/config.py index 3efc805..4f11b57 100644 --- a/plotpy/config.py +++ b/plotpy/config.py @@ -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", diff --git a/plotpy/items/curve/base.py b/plotpy/items/curve/base.py index 04960ee..769ac73 100644 --- a/plotpy/items/curve/base.py +++ b/plotpy/items/curve/base.py @@ -2,6 +2,7 @@ from __future__ import annotations +import math import sys from typing import TYPE_CHECKING @@ -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 @@ -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) @@ -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 diff --git a/plotpy/tests/unit/test_curve_thick_strokes.py b/plotpy/tests/unit/test_curve_thick_strokes.py new file mode 100644 index 0000000..2132e25 --- /dev/null +++ b/plotpy/tests/unit/test_curve_thick_strokes.py @@ -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"))