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 @@ -12,6 +12,7 @@
* **Plot refresh** — Refreshing a plot no longer recomputes the plot layout when the title and axis styles did not change
* **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))

⚙️ Dependencies:

Expand Down
2 changes: 2 additions & 0 deletions plotpy/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ def get_plotpy_defaults() -> dict[str, int | float | str | bool]:
"plot": {
"selection/distance": 6,
"antialiasing": False,
# Draw thin curves (width <= 1) one device pixel wide on screen
"curve/cosmetic_hairline": True,
"title/font/size": 12,
"title/font/bold": False,
"selected_curve_symbol/marker": "Rect",
Expand Down
65 changes: 65 additions & 0 deletions plotpy/items/curve/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from guidata.dataset import update_dataset
from guidata.utils.misc import assert_interfaces_valid
from qtpy import QtCore as QC
from qtpy import QtGui as QG
from qwt import QwtPlotCurve

from plotpy.config import CONF, _
Expand All @@ -24,6 +25,7 @@

if TYPE_CHECKING:
import guidata.io
from qwt import QwtScaleMap

from plotpy.interfaces import IItemType
from plotpy.plot import BasePlot
Expand All @@ -35,6 +37,39 @@
SELECTED_SYMBOL = SELECTED_SYMBOL_PARAM.build_symbol()


def get_screen_pen(painter: QG.QPainter, pen: QG.QPen) -> QG.QPen:
"""Return the pen to draw a line on screen

On high-DPI screens (e.g. with a 150 % scaling factor), a pen of width 1 is
drawn wider than one device pixel, which makes Qt's raster engine take its
much slower path for wide pens. When painting on screen (raster engine,
without scaling transform), pens of width 1 or less are therefore made
cosmetic, i.e. one device pixel wide. Printing and exporting, which use other
paint engines or a scaling transform, are not affected.

This behavior may be disabled with the ``curve/cosmetic_hairline`` option of
the ``plot`` configuration section.

Args:
painter: Painter
pen: Pen to draw with

Returns:
A cosmetic copy of the pen if applicable, the pen itself otherwise
"""
if (
pen.isCosmetic()
or pen.widthF() > 1.0
or painter.paintEngine().type() != QG.QPaintEngine.Raster
or painter.worldTransform().isScaling()
or not CONF.get("plot", "curve/cosmetic_hairline", True)
):
return pen
pen = QG.QPen(pen)
pen.setCosmetic(True)
return pen


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 @@ -163,6 +198,36 @@ def boundingRect(self) -> QC.QRectF:
else:
return QwtPlotCurve.boundingRect(self)

def drawCurve(
self,
painter: QG.QPainter,
style: int,
xMap: QwtScaleMap,
yMap: QwtScaleMap,
canvasRect: QC.QRectF,
from_: int,
to: int,
) -> None:
"""Draw the line part (without symbols) of a curve interval

Reimplemented to draw thin curves with a cosmetic pen on screen
(see :py:func:`get_screen_pen`).

Args:
painter: Painter
style: Curve style, see ``QwtPlotCurve.CurveStyle``
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
"""
pen = painter.pen()
screen_pen = get_screen_pen(painter, pen)
if screen_pen is not pen:
painter.setPen(screen_pen)
super().drawCurve(painter, style, xMap, yMap, canvasRect, from_, to)

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
4 changes: 2 additions & 2 deletions plotpy/items/curve/errorbar.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from qwt import QwtPlotCurve, QwtScaleMap

from plotpy.config import _
from plotpy.items.curve.base import CurveItem
from plotpy.items.curve.base import CurveItem, get_screen_pen
from plotpy.styles.curve import CurveParam
from plotpy.styles.errorbar import ErrorBarParam

Expand Down Expand Up @@ -286,7 +286,7 @@ def draw(
QwtPlotCurve.draw(self, painter, xMap, yMap, canvasRect)

painter.save()
painter.setPen(self.errorPen)
painter.setPen(get_screen_pen(painter, self.errorPen))
cap = self.errorCap / 2.0

if self._dx is not None and self.errorbarparam.mode == 0:
Expand Down
161 changes: 161 additions & 0 deletions plotpy/tests/unit/test_curve_screen_pen.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
# -*- coding: utf-8 -*-
#
# Licensed under the terms of the BSD 3-Clause
# (see plotpy/LICENSE for details)

"""Test the cosmetic pen used to draw thin curves on screen"""

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 get_screen_pen

WIDTH, HEIGHT = 100, 60


def _scale_maps() -> tuple[QwtScaleMap, QwtScaleMap]:
"""Return X and Y scale maps from [0, 1] to the image area"""
xmap, ymap = QwtScaleMap(), QwtScaleMap()
xmap.setPaintInterval(0, WIDTH)
xmap.setScaleInterval(0.0, 1.0)
ymap.setPaintInterval(HEIGHT, 0)
ymap.setScaleInterval(0.0, 1.0)
return xmap, ymap


def _hidpi_image(ratio: float = 2.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 _painted_rows(image: QG.QImage, column: int) -> int:
"""Return the number of non-white pixels in an image column"""
white = QG.QColor(QC.Qt.white).rgb()
return sum(image.pixel(column, row) != white for row in range(image.height()))


def _painted_columns(image: QG.QImage, row: int) -> int:
"""Return the number of non-white pixels in an image row"""
white = QG.QColor(QC.Qt.white).rgb()
return sum(image.pixel(col, row) != white for col in range(image.width()))


def _draw_horizontal_curve(image: QG.QImage, width: float) -> None:
"""Draw a horizontal black curve in the middle of the image"""
x = np.linspace(0.0, 1.0, 50)
y = np.full_like(x, 0.5)
curve = make.curve(x, y, color="#000000", linewidth=width)
xmap, ymap = _scale_maps()
painter = QG.QPainter(image)
curve.drawSeries(painter, xmap, ymap, QC.QRectF(0, 0, WIDTH, HEIGHT), 0, -1)
painter.end()


def _disable_option(monkeypatch: pytest.MonkeyPatch) -> None:
"""Disable the ``curve/cosmetic_hairline`` option"""
original_get = CONF.get

def get(section, option, *args, **kwargs):
if (section, option) == ("plot", "curve/cosmetic_hairline"):
return False
return original_get(section, option, *args, **kwargs)

monkeypatch.setattr(curve_base.CONF, "get", get)


def test_screen_pen_raster() -> None:
"""Thin pens become cosmetic on screen, wide pens are left unchanged"""
with qt_app_context(exec_loop=False):
image = _hidpi_image()
painter = QG.QPainter(image)
for width in (0.5, 1.0):
pen = QG.QPen(QG.QColor("red"), width)
screen_pen = get_screen_pen(painter, pen)
assert screen_pen.isCosmetic()
assert screen_pen.widthF() == width
assert not pen.isCosmetic() # The original pen is not modified
pen = QG.QPen(QG.QColor("red"), 2.0)
assert get_screen_pen(painter, pen) is pen
painter.end()


def test_screen_pen_print_and_export() -> None:
"""Pens are left unchanged for other paint engines or scaled painters"""
with qt_app_context(exec_loop=False):
pen = QG.QPen(QG.QColor("red"), 1.0)
picture = QG.QPicture()
painter = QG.QPainter(picture)
assert get_screen_pen(painter, pen) is pen
painter.end()
image = _hidpi_image()
painter = QG.QPainter(image)
painter.scale(3.0, 3.0) # As done by QwtPlotRenderer for printing
assert get_screen_pen(painter, pen) is pen
painter.end()


def test_screen_pen_option(monkeypatch: pytest.MonkeyPatch) -> None:
"""The ``curve/cosmetic_hairline`` option disables cosmetic pens"""
_disable_option(monkeypatch)
with qt_app_context(exec_loop=False):
image = _hidpi_image()
painter = QG.QPainter(image)
pen = QG.QPen(QG.QColor("red"), 1.0)
assert get_screen_pen(painter, pen) is pen
painter.end()


def test_curve_hidpi_rendering(monkeypatch: pytest.MonkeyPatch) -> None:
"""Curves of width 1 are drawn one device pixel wide on HiDPI screens"""
with qt_app_context(exec_loop=False):
image = _hidpi_image()
_draw_horizontal_curve(image, 1.0)
assert _painted_rows(image, image.width() // 2) == 1
image = _hidpi_image()
_draw_horizontal_curve(image, 2.0)
assert _painted_rows(image, image.width() // 2) == 4
_disable_option(monkeypatch)
image = _hidpi_image()
_draw_horizontal_curve(image, 1.0)
assert _painted_rows(image, image.width() // 2) == 2


def test_curve_pen_parameters() -> None:
"""The stored curve pen and its parameters are not modified"""
with qt_app_context(exec_loop=False):
curve = make.curve([0.0, 1.0], [0.0, 1.0], color="#000000", linewidth=1.0)
image = _hidpi_image()
painter = QG.QPainter(image)
xmap, ymap = _scale_maps()
curve.drawSeries(painter, xmap, ymap, QC.QRectF(0, 0, WIDTH, HEIGHT), 0, -1)
painter.end()
assert not curve.pen().isCosmetic()
curve.param.update_param(curve)
assert curve.param.line.width == 1.0


def test_errorbar_hidpi_rendering() -> None:
"""Error bars of width 1 are drawn one device pixel wide on HiDPI screens"""
with qt_app_context(exec_loop=False):
x = np.array([0.5])
y = np.array([0.5])
dy = np.array([0.3])
item = make.error(x, y, None, dy, curvestyle="NoCurve", errorbarcap=0)
image = _hidpi_image()
painter = QG.QPainter(image)
xmap, ymap = _scale_maps()
item.draw(painter, xmap, ymap, QC.QRectF(0, 0, WIDTH, HEIGHT))
painter.end()
assert _painted_columns(image, image.height() // 2) == 1
Loading