diff --git a/examples/MultiPlotSpeedTest.py b/examples/MultiPlotSpeedTest.py index 3ee684e6..dd751279 100644 --- a/examples/MultiPlotSpeedTest.py +++ b/examples/MultiPlotSpeedTest.py @@ -45,7 +45,7 @@ def update(): count += 1 for i in range(nPlots): - curves[i].setData(data[(ptr+i)%data.shape[0]]) + curves[i].setData(data[(ptr+i)%data.shape[0]], skipFiniteCheck=True) ptr += nPlots now = time() diff --git a/pyqtgraph/functions.py b/pyqtgraph/functions.py index a07303ed..455856fa 100644 --- a/pyqtgraph/functions.py +++ b/pyqtgraph/functions.py @@ -1690,69 +1690,92 @@ def downsample(data, n, axis=0, xvals='subsample'): return MetaArray(d2, info=info) -def arrayToQPath(x, y, connect='all'): - """Convert an array of x,y coordinats to QPainterPath as efficiently as possible. - The *connect* argument may be 'all', indicating that each point should be - connected to the next; 'pairs', indicating that each pair of points - should be connected, or an array of int32 values (0 or 1) indicating +def arrayToQPath(x, y, connect='all', finiteCheck=True): + """ + Convert an array of x,y coordinates to QPainterPath as efficiently as + possible. The *connect* argument may be 'all', indicating that each point + should be connected to the next; 'pairs', indicating that each pair of + points should be connected, or an array of int32 values (0 or 1) indicating connections. + + Parameters + ---------- + x : (N,) ndarray + x-values to be plotted + y : (N,) ndarray + y-values to be plotted, must be same length as `x` + connect : {'all', 'pairs', 'finite', (N,) ndarray}, optional + Argument detailing how to connect the points in the path. `all` will + have sequential points being connected. `pairs` generates lines + between every other point. `finite` only connects points that are + finite. If an ndarray is passed, containing int32 values of 0 or 1, + only values with 1 will connect to the previous point. Def + finiteCheck : bool, default Ture + When false, the check for finite values will be skipped, which can + improve performance. If finite values are present in `x` or `y`, + an empty QPainterPath will be generated. + + Returns + ------- + QPainterPath + QPainterPath object to be drawn + + Raises + ------ + ValueError + Raised when the connect argument has an invalid value placed within. + + Notes + ----- + A QPainterPath is generated through one of two ways. When the connect + parameter is 'all', a QPolygonF object is created, and + ``QPainterPath.addPolygon()`` is called. For other connect parameters + a ``QDataStream`` object is created and the QDataStream >> QPainterPath + operator is used to pass the data. The memory format is as follows + + numVerts(i4) + 0(i4) x(f8) y(f8) <-- 0 means this vertex does not connect + 1(i4) x(f8) y(f8) <-- 1 means this vertex connects to the previous vertex + ... + cStart(i4) fillRule(i4) + + see: https://github.com/qt/qtbase/blob/dev/src/gui/painting/qpainterpath.cpp + + All values are big endian--pack using struct.pack('>d') or struct.pack('>i') + This binary format may change in future versions of Qt """ - ## Create all vertices in path. The method used below creates a binary format so that all - ## vertices can be read in at once. This binary format may change in future versions of Qt, - ## so the original (slower) method is left here for emergencies: - #path.moveTo(x[0], y[0]) - #if connect == 'all': - #for i in range(1, y.shape[0]): - #path.lineTo(x[i], y[i]) - #elif connect == 'pairs': - #for i in range(1, y.shape[0]): - #if i%2 == 0: - #path.lineTo(x[i], y[i]) - #else: - #path.moveTo(x[i], y[i]) - #elif isinstance(connect, np.ndarray): - #for i in range(1, y.shape[0]): - #if connect[i] == 1: - #path.lineTo(x[i], y[i]) - #else: - #path.moveTo(x[i], y[i]) - #else: - #raise Exception('connect argument must be "all", "pairs", or array') - - ## Speed this up using >> operator - ## Format is: - ## numVerts(i4) - ## 0(i4) x(f8) y(f8) <-- 0 means this vertex does not connect - ## 1(i4) x(f8) y(f8) <-- 1 means this vertex connects to the previous vertex - ## ... - ## cStart(i4) fillRule(i4) - ## - ## see: https://github.com/qt/qtbase/blob/dev/src/gui/painting/qpainterpath.cpp - - ## All values are big endian--pack using struct.pack('>d') or struct.pack('>i') - path = QtGui.QPainterPath() - n = x.shape[0] - # create empty array, pad with extra space on either end - arr = np.empty(n+2, dtype=[('c', '>i4'), ('x', '>f8'), ('y', '>f8')]) + connect_array = None + if isinstance(connect, np.ndarray): + # make connect argument contain only str type + connect_array, connect = connect, 'array' - # write first two integers - byteview = arr.view(dtype=np.ubyte) - byteview[:16] = 0 - byteview.data[16:20] = struct.pack('>i', n) + use_qpolygonf = connect == 'all' + + if use_qpolygonf: + backstore = create_qpolygonf(n) + arr = np.frombuffer(ndarray_from_qpolygonf(backstore), dtype=[('x', 'f8'), ('y', 'f8')]) + else: + backstore = bytearray(4 + n*20 + 8) + arr = np.frombuffer(backstore, dtype=[('c', '>i4'), ('x', '>f8'), ('y', '>f8')], + count=n, offset=4) + struct.pack_into('>i', backstore, 0, n) + # cStart, fillRule (Qt.OddEvenFill) + struct.pack_into('>ii', backstore, 4+n*20, 0, 0) # Fill array with vertex values - arr[1:-1]['x'] = x - arr[1:-1]['y'] = y + arr['x'] = x + arr['y'] = y - # inf/nans completely prevent the plot from being displayed starting on - # Qt version 5.12.3; these must now be manually cleaned out. + # the presence of inf/nans result in an empty QPainterPath being generated + # this behavior started in Qt 5.12.3 and was introduced in this commit + # https://github.com/qt/qtbase/commit/c04bd30de072793faee5166cff866a4c4e0a9dd7 + # We therefore replace non-finite values isfinite = None - qtver = [int(x) for x in QtVersion.split('.')] - if qtver >= [5, 12, 3]: + if finiteCheck: isfinite = np.isfinite(x) & np.isfinite(y) if not np.all(isfinite): # credit: Divakar https://stackoverflow.com/a/41191127/643629 @@ -1764,51 +1787,103 @@ def arrayToQPath(x, y, connect='all'): if first < len(x): # Replace all non-finite entries from beginning of arr with the first finite one idx[:first] = first - arr[1:-1] = arr[1:-1][idx] + arr[:] = arr[:][idx] # decide which points are connected by lines - if eq(connect, 'all'): - arr[1:-1]['c'] = 1 - elif eq(connect, 'pairs'): - arr[1:-1]['c'][::2] = 0 - arr[1:-1]['c'][1::2] = 1 # connect every 2nd point to every 1st one - elif eq(connect, 'finite'): + if connect == 'all': + path.addPolygon(backstore) + return path + elif connect == 'pairs': + arr['c'][::2] = 0 + arr['c'][1::2] = 1 # connect every 2nd point to every 1st one + elif connect == 'finite': # Let's call a point with either x or y being nan is an invalid point. # A point will anyway not connect to an invalid point regardless of the # 'c' value of the invalid point. Therefore, we should set 'c' to 0 for # the next point of an invalid point. if isfinite is None: isfinite = np.isfinite(x) & np.isfinite(y) - arr[2:]['c'] = isfinite - elif isinstance(connect, np.ndarray): - arr[2:-1]['c'] = connect[:-1] + arr[1:]['c'] = isfinite[:-1] + elif connect == 'array': + arr[1:]['c'] = connect_array[:-1] else: - raise Exception('connect argument must be "all", "pairs", "finite", or array') + raise ValueError('connect argument must be "all", "pairs", "finite", or array') - arr[1]['c'] = 0 # the first vertex has no previous vertex to connect - - byteview.data[-20:-16] = struct.pack('>i', 0) # cStart - byteview.data[-16:-12] = struct.pack('>i', 0) # fillRule (Qt.OddEvenFill) - - # create datastream object and stream into path - - ## Avoiding this method because QByteArray(str) leaks memory in PySide - #buf = QtCore.QByteArray(arr.data[12:lastInd+4]) # I think one unnecessary copy happens here - - path.strn = byteview.data[16:-12] # make sure data doesn't run away - try: - buf = QtCore.QByteArray.fromRawData(path.strn) - except TypeError: - buf = QtCore.QByteArray(bytes(path.strn)) - except AttributeError: - # PyQt6 raises AttributeError - buf = QtCore.QByteArray(path.strn, path.strn.nbytes) + arr[0]['c'] = 0 # the first vertex has no previous vertex to connect + # create QDataStream object and stream into QPainterPath + path.strn = backstore + if QT_LIB == "PyQt6" and QtCore.PYQT_VERSION < 0x60101: + # due to issue detailed here: + # https://www.riverbankcomputing.com/pipermail/pyqt/2021-May/043942.html + buf = QtCore.QByteArray(path.strn, len(path.strn)) + else: + buf = QtCore.QByteArray(path.strn) ds = QtCore.QDataStream(buf) ds >> path - return path +def ndarray_from_qpolygonf(polyline): + nbytes = 2 * len(polyline) * 8 + if QT_LIB == "PySide2": + buffer = Qt.shiboken2.VoidPtr(polyline.data(), nbytes, True) + elif QT_LIB == "PySide6": + buffer = Qt.shiboken6.VoidPtr(polyline.data(), nbytes, True) + else: + buffer = polyline.data() + buffer.setsize(nbytes) + memory = np.frombuffer(buffer, np.double).reshape((-1, 2)) + return memory + +def create_qpolygonf(size): + if QtVersion.startswith("5"): + polyline = QtGui.QPolygonF(size) + else: + polyline = QtGui.QPolygonF() + if QT_LIB == "PySide6": + polyline.resize(size) + else: + polyline.fill(QtCore.QPointF(), size) + return polyline + +def arrayToQPolygonF(x, y): + """ + Utility function to convert two 1D-NumPy arrays representing curve data + (X-axis, Y-axis data) into a single open polygon (QtGui.PolygonF) object. + + Thanks to PythonQwt for making this code available + + License/copyright: MIT License © Pierre Raybaut 2020. + + Parameters + ---------- + x : np.array + x-axis coordinates for data to be plotted, must have have ndim of 1 + y : np.array + y-axis coordinates for data to be plotted, must have ndim of 1 and + be the same length as x + + Returns + ------- + QPolygonF + Open QPolygonF object that represents the path looking to be plotted + + Raises + ------ + ValueError + When xdata or ydata does not meet the required criteria + """ + if not ( + x.size == y.size == x.shape[0] == y.shape[0] + ): + raise ValueError("Arguments must be 1D and the same size") + size = x.size + polyline = create_qpolygonf(size) + memory = ndarray_from_qpolygonf(polyline) + memory[:, 0] = x + memory[:, 1] = y + return polyline + #def isosurface(data, level): #""" #Generate isosurface from volumetric data using marching tetrahedra algorithm. diff --git a/pyqtgraph/graphicsItems/PlotCurveItem.py b/pyqtgraph/graphicsItems/PlotCurveItem.py index 06f9bdee..150b755a 100644 --- a/pyqtgraph/graphicsItems/PlotCurveItem.py +++ b/pyqtgraph/graphicsItems/PlotCurveItem.py @@ -65,6 +65,7 @@ class PlotCurveItem(GraphicsObject): 'connect': 'all', 'mouseWidth': 8, # width of shape responding to mouse click 'compositionMode': None, + 'skipFiniteCheck': True } if 'pen' not in kargs: self.opts['pen'] = fn.mkPen('w') @@ -336,6 +337,11 @@ class PlotCurveItem(GraphicsObject): connectivity, specify an array of boolean values. compositionMode See :func:`setCompositionMode `. + skipFiniteCheck Optimization parameter that can speed up plot time by + telling the painter to not check and compensate for NaN + values. If set to True, and NaN values exist, the data + may not be displayed or your plot will take a + significant performance hit. Defaults to False. =============== ======================================================== If non-keyword arguments are used, they will be interpreted as @@ -373,6 +379,7 @@ class PlotCurveItem(GraphicsObject): if data.dtype.kind == 'c': raise Exception("Can not plot complex data types.") + profiler("data checks") #self.setCacheMode(QtGui.QGraphicsItem.NoCache) ## Disabling and re-enabling the cache works around a bug in Qt 4.6 causing the cached results to display incorrectly @@ -421,6 +428,8 @@ class PlotCurveItem(GraphicsObject): if 'antialias' in kargs: self.opts['antialias'] = kargs['antialias'] + self.opts['skipFiniteCheck'] = kargs.get('skipFiniteCheck', False) + profiler('set') self.update() profiler('update') @@ -458,10 +467,12 @@ class PlotCurveItem(GraphicsObject): y[0] = self.opts['fillLevel'] y[-1] = self.opts['fillLevel'] - path = fn.arrayToQPath(x, y, connect=self.opts['connect']) - - return path - + return fn.arrayToQPath( + x, + y, + connect=self.opts['connect'], + finiteCheck=not self.opts['skipFiniteCheck'] + ) def getPath(self): if self.path is None: diff --git a/pyqtgraph/graphicsItems/PlotDataItem.py b/pyqtgraph/graphicsItems/PlotDataItem.py index c36f4ed1..eb883c22 100644 --- a/pyqtgraph/graphicsItems/PlotDataItem.py +++ b/pyqtgraph/graphicsItems/PlotDataItem.py @@ -140,6 +140,11 @@ class PlotDataItem(GraphicsObject): at any time. dynamicRangeLimit (float or None) Limit off-screen positions of data points at large magnification to avoids display errors. Disabled if None. + skipFiniteCheck (bool) Optimization parameter that can speed up plot time by + telling the painter to not check and compensate for NaN + values. If set to True, and NaN values exist, the data + may not be displayed or your plot will take a + significant performance hit. Defaults to False. identical *deprecated* ================= ===================================================================== @@ -210,7 +215,7 @@ class PlotDataItem(GraphicsObject): 'clipToView': False, 'dynamicRangeLimit': 1e6, 'dynamicRangeHyst': 3.0, - + 'skipFiniteCheck': False, 'data': None, } self.setCurveClickable(kargs.get('clickable', False)) @@ -605,11 +610,29 @@ class PlotDataItem(GraphicsObject): scatterArgs = {} if styleUpdate: # repeat style arguments only when changed - for k,v in [('pen','pen'), ('shadowPen','shadowPen'), ('fillLevel','fillLevel'), ('fillOutline', 'fillOutline'), ('fillBrush', 'brush'), ('antialias', 'antialias'), ('connect', 'connect'), ('stepMode', 'stepMode')]: + for k, v in [ + ('pen','pen'), + ('shadowPen','shadowPen'), + ('fillLevel','fillLevel'), + ('fillOutline', 'fillOutline'), + ('fillBrush', 'brush'), + ('antialias', 'antialias'), + ('connect', 'connect'), + ('stepMode', 'stepMode'), + ('skipFiniteCheck', 'skipFiniteCheck') + ]: if k in self.opts: curveArgs[v] = self.opts[k] - for k,v in [('symbolPen','pen'), ('symbolBrush','brush'), ('symbol','symbol'), ('symbolSize', 'size'), ('data', 'data'), ('pxMode', 'pxMode'), ('antialias', 'antialias')]: + for k, v in [ + ('symbolPen','pen'), + ('symbolBrush','brush'), + ('symbol','symbol'), + ('symbolSize', 'size'), + ('data', 'data'), + ('pxMode', 'pxMode'), + ('antialias', 'antialias') + ]: if k in self.opts: scatterArgs[v] = self.opts[k] diff --git a/tests/test_functions.py b/tests/test_functions.py index 6f90567c..2f4d4e43 100644 --- a/tests/test_functions.py +++ b/tests/test_functions.py @@ -1,6 +1,8 @@ # -*- coding: utf-8 -*- from collections import OrderedDict from copy import deepcopy +from contextlib import suppress +from pyqtgraph.functions import arrayToQPath, eq import numpy as np import pytest @@ -245,3 +247,78 @@ def test_siParse(s, suffix, expected): else: with pytest.raises(expected): pg.siParse(s, suffix=suffix) + + +MoveToElement = pg.QtGui.QPainterPath.ElementType.MoveToElement +LineToElement = pg.QtGui.QPainterPath.ElementType.LineToElement +@pytest.mark.parametrize( + "xs, ys, connect, expected", [ + ( + np.arange(6), np.arange(0, -6, step=-1), 'all', ( + (MoveToElement, 0.0, 0.0), + (LineToElement, 1.0, -1.0), + (LineToElement, 2.0, -2.0), + (LineToElement, 3.0, -3.0), + (LineToElement, 4.0, -4.0), + (LineToElement, 5.0, -5.0), + ) + ), + ( + np.arange(6), np.arange(0, -6, step=-1), 'pairs', ( + (MoveToElement, 0.0, 0.0), + (LineToElement, 1.0, -1.0), + (MoveToElement, 2.0, -2.0), + (LineToElement, 3.0, -3.0), + (MoveToElement, 4.0, -4.0), + (LineToElement, 5.0, -5.0), + ) + ), + ( + np.arange(5), np.arange(0, -5, step=-1), 'pairs', ( + (MoveToElement, 0.0, 0.0), + (LineToElement, 1.0, -1.0), + (MoveToElement, 2.0, -2.0), + (LineToElement, 3.0, -3.0), + (MoveToElement, 4.0, -4.0) + ) + ), + ( + np.arange(5), np.array([0, -1, np.NaN, -3, -4]), 'finite', ( + (MoveToElement, 0.0, 0.0), + (LineToElement, 1.0, -1.0), + (LineToElement, 1.0, -1.0), + (MoveToElement, 3.0, -3.0), + (LineToElement, 4.0, -4.0) + ) + ), + ( + np.array([0, 1, np.NaN, 3, 4]), np.arange(0, -5, step=-1), 'finite', ( + (MoveToElement, 0.0, 0.0), + (LineToElement, 1.0, -1.0), + (LineToElement, 1.0, -1.0), + (MoveToElement, 3.0, -3.0), + (LineToElement, 4.0, -4.0) + ) + ), + ( + np.arange(5), np.arange(0, -5, step=-1), np.array([0, 1, 0, 1, 0]), ( + (MoveToElement, 0.0, 0.0), + (MoveToElement, 1.0, -1.0), + (LineToElement, 2.0, -2.0), + (MoveToElement, 3.0, -3.0), + (LineToElement, 4.0, -4.0) + ) + ) + ] +) +def test_arrayToQPath(xs, ys, connect, expected): + path = arrayToQPath(xs, ys, connect=connect) + for i in range(path.elementCount()): + with suppress(NameError): + # nan elements add two line-segments, for simplicity of test config + # we can ignore the second segment + if (eq(element.x, np.nan) or eq(element.y, np.nan)): + continue + element = path.elementAt(i) + assert eq(expected[i], (element.type, element.x, element.y)) +