Merge pull request #1796 from j9ac9k/test-polyline
If arrayToQPath uses connect=all, use a different construction for QPainterPath
This commit is contained in:
commit
e206ea5ae9
@ -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()
|
||||
|
@ -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.
|
||||
|
@ -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
|
||||
<pyqtgraph.PlotCurveItem.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:
|
||||
|
@ -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]
|
||||
|
||||
|
@ -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))
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user