Commit 3a279970 authored by Luke Campagnola's avatar Luke Campagnola

Plotting performance improvements:

  - AxisItem shows fewer tick levels in some cases.
  - Lots of boundingRect and dataBounds caching
    (improves ViewBox auto-range performance, especially with multiple plots)
  - GraphicsScene avoids testing for hover intersections with non-hoverable items
    (much less slowdown when moving mouse over plots)

Improved performance for remote plotting:
  - reduced cost of transferring arrays between processes (pickle is too slow)
  - avoid unnecessary synchronous calls

Added RemoteSpeedTest example
parents 79c0ab8a 6903886b
#!/usr/bin/python
# -*- coding: utf-8 -*-
## Add path to library (just for examples; you do not need this)
import initExample
from pyqtgraph.Qt import QtGui, QtCore
import numpy as np
import pyqtgraph as pg
from pyqtgraph.ptime import time
#QtGui.QApplication.setGraphicsSystem('raster')
app = QtGui.QApplication([])
#mw = QtGui.QMainWindow()
#mw.resize(800,800)
p = pg.plot()
#p.setRange(QtCore.QRectF(0, -10, 5000, 20))
p.setLabel('bottom', 'Index', units='B')
nPlots = 10
#curves = [p.plot(pen=(i,nPlots*1.3)) for i in range(nPlots)]
curves = [pg.PlotCurveItem(pen=(i,nPlots*1.3)) for i in range(nPlots)]
for c in curves:
p.addItem(c)
rgn = pg.LinearRegionItem([1,100])
p.addItem(rgn)
data = np.random.normal(size=(53,5000/nPlots))
ptr = 0
lastTime = time()
fps = None
count = 0
def update():
global curve, data, ptr, p, lastTime, fps, nPlots, count
count += 1
#print "---------", count
for i in range(nPlots):
curves[i].setData(i+data[(ptr+i)%data.shape[0]])
#print " setData done."
ptr += nPlots
now = time()
dt = now - lastTime
lastTime = now
if fps is None:
fps = 1.0/dt
else:
s = np.clip(dt*3., 0, 1)
fps = fps * (1-s) + (1.0/dt) * s
p.setTitle('%0.2f fps' % fps)
#app.processEvents() ## force complete redraw for every plot
timer = QtCore.QTimer()
timer.timeout.connect(update)
timer.start(0)
## Start Qt event loop unless running in interactive mode.
if __name__ == '__main__':
import sys
if (sys.flags.interactive != 1) or not hasattr(QtCore, 'PYQT_VERSION'):
QtGui.QApplication.instance().exec_()
......@@ -45,6 +45,9 @@ p5 = win.addPlot(title="Scatter plot, axis labels, log scale")
x = np.random.normal(size=1000) * 1e-5
y = x*1000 + 0.005 * np.random.normal(size=1000)
y -= y.min()-1.0
mask = x > 1e-15
x = x[mask]
y = y[mask]
p5.plot(x, y, pen=None, symbol='t', symbolPen=None, symbolSize=10, symbolBrush=(100, 100, 255, 50))
p5.setLabel('left', "Y Axis", units='A')
p5.setLabel('bottom', "Y Axis", units='s')
......
# -*- coding: utf-8 -*-
"""
This example demonstrates the use of RemoteGraphicsView to improve performance in
applications with heavy load. It works by starting a second process to handle
all graphics rendering, thus freeing up the main process to do its work.
In this example, the update() function is very expensive and is called frequently.
After update() generates a new set of data, it can either plot directly to a local
plot (bottom) or remotely via a RemoteGraphicsView (top), allowing speed comparison
between the two cases. IF you have a multi-core CPU, it should be obvious that the
remote case is much faster.
"""
import initExample ## Add path to library (just for examples; you do not need this)
from pyqtgraph.Qt import QtGui, QtCore
import pyqtgraph as pg
import pyqtgraph.widgets.RemoteGraphicsView
import numpy as np
app = pg.mkQApp()
view = pg.widgets.RemoteGraphicsView.RemoteGraphicsView()
pg.setConfigOptions(antialias=True) ## this will be expensive for the local plot
view.pg.setConfigOptions(antialias=True) ## prettier plots at no cost to the main process!
label = QtGui.QLabel()
rcheck = QtGui.QCheckBox('plot remote')
rcheck.setChecked(True)
lcheck = QtGui.QCheckBox('plot local')
lplt = pg.PlotWidget()
layout = pg.LayoutWidget()
layout.addWidget(rcheck)
layout.addWidget(lcheck)
layout.addWidget(label)
layout.addWidget(view, row=1, col=0, colspan=3)
layout.addWidget(lplt, row=2, col=0, colspan=3)
layout.resize(800,800)
layout.show()
## Create a PlotItem in the remote process that will be displayed locally
rplt = view.pg.PlotItem()
rplt._setProxyOptions(deferGetattr=True) ## speeds up access to rplt.plot
view.setCentralItem(rplt)
lastUpdate = pg.ptime.time()
avgFps = 0.0
def update():
global check, label, plt, lastUpdate, avgFps, rpltfunc
data = np.random.normal(size=(10000,50)).sum(axis=1)
data += 5 * np.sin(np.linspace(0, 10, data.shape[0]))
if rcheck.isChecked():
rplt.plot(data, clear=True, _callSync='off') ## We do not expect a return value.
## By turning off callSync, we tell
## the proxy that it does not need to
## wait for a reply from the remote
## process.
if lcheck.isChecked():
lplt.plot(data, clear=True)
now = pg.ptime.time()
fps = 1.0 / (now - lastUpdate)
lastUpdate = now
avgFps = avgFps * 0.8 + fps * 0.2
label.setText("Generating %0.2f fps" % avgFps)
timer = QtCore.QTimer()
timer.timeout.connect(update)
timer.start(0)
## Start Qt event loop unless running in interactive mode or using pyside.
if __name__ == '__main__':
import sys
if (sys.flags.interactive != 1) or not hasattr(QtCore, 'PYQT_VERSION'):
QtGui.QApplication.instance().exec_()
# -*- coding: utf-8 -*-
import sys, os
## Add path to library (just for examples; you do not need this)
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..'))
import initExample
from pyqtgraph.Qt import QtGui, QtCore
import pyqtgraph as pg
......
......@@ -22,6 +22,7 @@ examples = OrderedDict([
('Dock widgets', 'dockarea.py'),
('Console', 'ConsoleWidget.py'),
('Histograms', 'histogram.py'),
('Remote Plotting', 'RemoteSpeedTest.py'),
('GraphicsItems', OrderedDict([
('Scatter Plot', 'ScatterPlot.py'),
#('PlotItem', 'PlotItem.py'),
......@@ -182,6 +183,7 @@ def testFile(name, f, exe, lib, graphicsSystem=None):
code = """
try:
%s
import initExample
import pyqtgraph as pg
%s
import %s
......
......@@ -16,7 +16,6 @@ if not hasattr(sys, 'frozen'):
sys.path.remove(p)
sys.path.insert(0, p)
## should force example to use PySide instead of PyQt
if 'pyside' in sys.argv:
from PySide import QtGui
......
......@@ -43,6 +43,9 @@ from pyqtgraph.Qt import QtCore, QtGui
app = pg.QtGui.QApplication([])
print "\n=================\nStart QtProcess"
import sys
if (sys.flags.interactive != 1):
print " (not interactive; remote process will exit immediately.)"
proc = mp.QtProcess()
d1 = proc.transfer(np.random.normal(size=1000))
d2 = proc.transfer(np.random.normal(size=1000))
......
......@@ -75,6 +75,8 @@ class GraphicsScene(QtGui.QGraphicsScene):
sigMouseMoved = QtCore.Signal(object) ## emits position of mouse on every move
sigMouseClicked = QtCore.Signal(object) ## emitted when mouse is clicked. Check for event.isAccepted() to see whether the event has already been acted on.
sigPrepareForPaint = QtCore.Signal() ## emitted immediately before the scene is about to be rendered
_addressCache = weakref.WeakValueDictionary()
ExportDirectory = None
......@@ -98,6 +100,7 @@ class GraphicsScene(QtGui.QGraphicsScene):
self.clickEvents = []
self.dragButtons = []
self.prepItems = weakref.WeakKeyDictionary() ## set of items with prepareForPaintMethods
self.mouseGrabber = None
self.dragItem = None
self.lastDrag = None
......@@ -112,6 +115,17 @@ class GraphicsScene(QtGui.QGraphicsScene):
self.exportDialog = None
def render(self, *args):
self.prepareForPaint()
return QGraphicsScene.render(self, *args)
def prepareForPaint(self):
"""Called before every render. This method will inform items that the scene is about to
be rendered by emitting sigPrepareForPaint.
This allows items to delay expensive processing until they know a paint will be required."""
self.sigPrepareForPaint.emit()
def setClickRadius(self, r):
"""
......@@ -224,7 +238,7 @@ class GraphicsScene(QtGui.QGraphicsScene):
else:
acceptable = int(ev.buttons()) == 0 ## if we are in mid-drag, do not allow items to accept the hover event.
event = HoverEvent(ev, acceptable)
items = self.itemsNearEvent(event)
items = self.itemsNearEvent(event, hoverable=True)
self.sigMouseHover.emit(items)
prevItems = list(self.hoverItems.keys())
......@@ -402,7 +416,7 @@ class GraphicsScene(QtGui.QGraphicsScene):
#return item
return self.translateGraphicsItem(item)
def itemsNearEvent(self, event, selMode=QtCore.Qt.IntersectsItemShape, sortOrder=QtCore.Qt.DescendingOrder):
def itemsNearEvent(self, event, selMode=QtCore.Qt.IntersectsItemShape, sortOrder=QtCore.Qt.DescendingOrder, hoverable=False):
"""
Return an iterator that iterates first through the items that directly intersect point (in Z order)
followed by any other items that are within the scene's click radius.
......@@ -429,6 +443,8 @@ class GraphicsScene(QtGui.QGraphicsScene):
## remove items whose shape does not contain point (scene.items() apparently sucks at this)
items2 = []
for item in items:
if hoverable and not hasattr(item, 'hoverEvent'):
continue
shape = item.shape()
if shape is None:
continue
......
......@@ -356,8 +356,14 @@ class GarbageWatcher(object):
return self.objs[item]
class Profiler(object):
class Profiler:
"""Simple profiler allowing measurement of multiple time intervals.
Arguments:
msg: message to print at start and finish of profiling
disabled: If true, profiler does nothing (so you can leave it in place)
delayed: If true, all messages are printed after call to finish()
(this can result in more accurate time step measurements)
globalDelay: if True, all nested profilers delay printing until the top level finishes
Example:
prof = Profiler('Function')
......@@ -368,34 +374,65 @@ class Profiler(object):
prof.finish()
"""
depth = 0
msgs = []
def __init__(self, msg="Profiler", disabled=False):
self.depth = Profiler.depth
Profiler.depth += 1
def __init__(self, msg="Profiler", disabled=False, delayed=True, globalDelay=True):
self.disabled = disabled
if disabled:
return
self.markCount = 0
self.finished = False
self.depth = Profiler.depth
Profiler.depth += 1
if not globalDelay:
self.msgs = []
self.delayed = delayed
self.msg = " "*self.depth + msg
msg2 = self.msg + " >>> Started"
if self.delayed:
self.msgs.append(msg2)
else:
print msg2
self.t0 = ptime.time()
self.t1 = self.t0
self.msg = " "*self.depth + msg
print(self.msg, ">>> Started")
def mark(self, msg=''):
def mark(self, msg=None):
if self.disabled:
return
if msg is None:
msg = str(self.markCount)
self.markCount += 1
t1 = ptime.time()
print(" "+self.msg, msg, "%gms" % ((t1-self.t1)*1000))
self.t1 = t1
msg2 = " "+self.msg+" "+msg+" "+"%gms" % ((t1-self.t1)*1000)
if self.delayed:
self.msgs.append(msg2)
else:
print msg2
self.t1 = ptime.time() ## don't measure time it took to print
def finish(self):
if self.disabled:
def finish(self, msg=None):
if self.disabled or self.finished:
return
if msg is not None:
self.mark(msg)
t1 = ptime.time()
print(self.msg, '<<< Finished, total time:', "%gms" % ((t1-self.t0)*1000))
msg = self.msg + ' <<< Finished, total time: %gms' % ((t1-self.t0)*1000)
if self.delayed:
self.msgs.append(msg)
if self.depth == 0:
for line in self.msgs:
print line
Profiler.msgs = []
else:
print msg
Profiler.depth = self.depth
self.finished = True
def __del__(self):
Profiler.depth -= 1
def profile(code, name='profile_run', sort='cumulative', num=30):
......
......@@ -350,9 +350,7 @@ class AxisItem(GraphicsWidget):
## decide optimal minor tick spacing in pixels (this is just aesthetics)
pixelSpacing = np.log(size+10) * 5
optimalTickCount = size / pixelSpacing
if optimalTickCount < 1:
optimalTickCount = 1
optimalTickCount = max(2., size / pixelSpacing)
## optimal minor tick spacing
optimalSpacing = dif / optimalTickCount
......@@ -366,12 +364,21 @@ class AxisItem(GraphicsWidget):
while intervals[minorIndex+1] <= optimalSpacing:
minorIndex += 1
return [
levels = [
(intervals[minorIndex+2], 0),
(intervals[minorIndex+1], 0),
(intervals[minorIndex], 0)
#(intervals[minorIndex], 0) ## Pretty, but eats up CPU
]
## decide whether to include the last level of ticks
minSpacing = min(size / 20., 30.)
maxTickCount = size / minSpacing
if dif / intervals[minorIndex] <= maxTickCount:
levels.append((intervals[minorIndex], 0))
return levels
##### This does not work -- switching between 2/5 confuses the automatic text-level-selection
### Determine major/minor tick spacings which flank the optimal spacing.
#intervals = np.array([1., 2., 5., 10., 20., 50., 100.]) * p10unit
......@@ -587,7 +594,7 @@ class AxisItem(GraphicsWidget):
ticks = tickLevels[i][1]
## length of tick
tickLength = self.tickLength / ((i*1.0)+1.0)
tickLength = self.tickLength / ((i*0.5)+1.0)
lineAlpha = 255 / (i+1)
if self.grid is not False:
......
......@@ -3,8 +3,30 @@ from pyqtgraph.GraphicsScene import GraphicsScene
from pyqtgraph.Point import Point
import pyqtgraph.functions as fn
import weakref
from pyqtgraph.pgcollections import OrderedDict
import operator
class FiniteCache(OrderedDict):
"""Caches a finite number of objects, removing
least-frequently used items."""
def __init__(self, length):
self._length = length
OrderedDict.__init__(self)
def __setitem__(self, item, val):
self.pop(item, None) # make sure item is added to end
OrderedDict.__setitem__(self, item, val)
while len(self) > self._length:
del self[self.keys()[0]]
def __getitem__(self, item):
val = dict.__getitem__(self, item)
del self[item]
self[item] = val ## promote this key
return val
class GraphicsItem(object):
"""
**Bases:** :class:`object`
......@@ -16,6 +38,8 @@ class GraphicsItem(object):
The GraphicsView system places a lot of emphasis on the notion that the graphics within the scene should be device independent--you should be able to take the same graphics and display them on screens of different resolutions, printers, export to SVG, etc. This is nice in principle, but causes me a lot of headache in practice. It means that I have to circumvent all the device-independent expectations any time I want to operate in pixel coordinates rather than arbitrary scene coordinates. A lot of the code in GraphicsItem is devoted to this task--keeping track of view widgets and device transforms, computing the size and shape of a pixel in local item coordinates, etc. Note that in item coordinates, a pixel does not have to be square or even rectangular, so just asking how to increase a bounding rect by 2px can be a rather complex task.
"""
_pixelVectorGlobalCache = FiniteCache(100)
def __init__(self, register=True):
if not hasattr(self, '_qtBaseClass'):
for b in self.__class__.__bases__:
......@@ -25,6 +49,7 @@ class GraphicsItem(object):
if not hasattr(self, '_qtBaseClass'):
raise Exception('Could not determine Qt base class for GraphicsItem: %s' % str(self))
self._pixelVectorCache = [None, None]
self._viewWidget = None
self._viewBox = None
self._connectedView = None
......@@ -155,7 +180,6 @@ class GraphicsItem(object):
def pixelVectors(self, direction=None):
"""Return vectors in local coordinates representing the width and height of a view pixel.
If direction is specified, then return vectors parallel and orthogonal to it.
......@@ -163,13 +187,28 @@ class GraphicsItem(object):
Return (None, None) if pixel size is not yet defined (usually because the item has not yet been displayed)
or if pixel size is below floating-point precision limit.
"""
## This is an expensive function that gets called very frequently.
## We have two levels of cache to try speeding things up.
dt = self.deviceTransform()
if dt is None:
return None, None
## check local cache
if direction is None and dt == self._pixelVectorCache[0]:
return self._pixelVectorCache[1]
## check global cache
key = (dt.m11(), dt.m21(), dt.m31(), dt.m12(), dt.m22(), dt.m32(), dt.m31(), dt.m32())
pv = self._pixelVectorGlobalCache.get(key, None)
if pv is not None:
self._pixelVectorCache = [dt, pv]
return pv
if direction is None:
direction = Point(1, 0)
direction = QtCore.QPointF(1, 0)
if direction.manhattanLength() == 0:
raise Exception("Cannot compute pixel length for 0-length vector.")
......@@ -184,28 +223,33 @@ class GraphicsItem(object):
r = ((abs(dt.m32())/(abs(dt.m12()) + abs(dt.m22()))) * (abs(dt.m31())/(abs(dt.m11()) + abs(dt.m21()))))**0.5
directionr = direction * r
viewDir = Point(dt.map(directionr) - dt.map(Point(0,0)))
if viewDir.manhattanLength() == 0:
## map direction vector onto device
#viewDir = Point(dt.map(directionr) - dt.map(Point(0,0)))
#mdirection = dt.map(directionr)
dirLine = QtCore.QLineF(QtCore.QPointF(0,0), directionr)
viewDir = dt.map(dirLine)
if viewDir.length() == 0:
return None, None ## pixel size cannot be represented on this scale
orthoDir = Point(viewDir[1], -viewDir[0]) ## orthogonal to line in pixel-space
## get unit vector and orthogonal vector (length of pixel)
#orthoDir = Point(viewDir[1], -viewDir[0]) ## orthogonal to line in pixel-space
try:
normView = viewDir.norm() ## direction of one pixel orthogonal to line
normOrtho = orthoDir.norm()
normView = viewDir.unitVector()
#normView = viewDir.norm() ## direction of one pixel orthogonal to line
normOrtho = normView.normalVector()
#normOrtho = orthoDir.norm()
except:
raise Exception("Invalid direction %s" %directionr)
## map back to item
dti = fn.invertQTransform(dt)
return Point(dti.map(normView)-dti.map(Point(0,0))), Point(dti.map(normOrtho)-dti.map(Point(0,0)))
#pv = Point(dti.map(normView)-dti.map(Point(0,0))), Point(dti.map(normOrtho)-dti.map(Point(0,0)))
pv = Point(dti.map(normView).p2()), Point(dti.map(normOrtho).p2())
self._pixelVectorCache[1] = pv
self._pixelVectorCache[0] = dt
self._pixelVectorGlobalCache[key] = pv
return self._pixelVectorCache[1]
#vt = self.deviceTransform()
#if vt is None:
#return None
#vt = vt.inverted()[0]
#orig = vt.map(QtCore.QPointF(0, 0))
#return vt.map(QtCore.QPointF(1, 0))-orig, vt.map(QtCore.QPointF(0, 1))-orig
def pixelLength(self, direction, ortho=False):
"""Return the length of one pixel in the direction indicated (in local coordinates)
......@@ -220,7 +264,6 @@ class GraphicsItem(object):
return orthoV.length()
return normV.length()
def pixelSize(self):
## deprecated
......@@ -235,7 +278,7 @@ class GraphicsItem(object):
if vt is None:
return 0
vt = fn.invertQTransform(vt)
return Point(vt.map(QtCore.QPointF(1, 0))-vt.map(QtCore.QPointF(0, 0))).length()
return vt.map(QtCore.QLineF(0, 0, 1, 0)).length()
def pixelHeight(self):
## deprecated
......@@ -243,7 +286,8 @@ class GraphicsItem(object):
if vt is None:
return 0
vt = fn.invertQTransform(vt)
return Point(vt.map(QtCore.QPointF(0, 1))-vt.map(QtCore.QPointF(0, 0))).length()
return vt.map(QtCore.QLineF(0, 0, 0, 1)).length()
#return Point(vt.map(QtCore.QPointF(0, 1))-vt.map(QtCore.QPointF(0, 0))).length()
def mapToDevice(self, obj):
......@@ -357,10 +401,11 @@ class GraphicsItem(object):
tr = self.itemTransform(relativeItem)
if isinstance(tr, tuple): ## difference between pyside and pyqt
tr = tr[0]
vec = tr.map(Point(1,0)) - tr.map(Point(0,0))
return Point(vec).angle(Point(1,0))
tr = tr[0]
#vec = tr.map(Point(1,0)) - tr.map(Point(0,0))
vec = tr.map(QtCore.QLineF(0,0,1,0))
#return Point(vec).angle(Point(1,0))
return vec.angleTo(QtCore.QLineF(vec.p1(), vec.p1()+QtCore.QPointF(1,0)))
#def itemChange(self, change, value):
#ret = self._qtBaseClass.itemChange(self, change, value)
......@@ -500,3 +545,6 @@ class GraphicsItem(object):
else:
self._exportOpts = False
#def update(self):
#self._qtBaseClass.update(self)
#print "Update:", self
\ No newline at end of file
......@@ -52,7 +52,7 @@ class PlotCurveItem(GraphicsObject):
self.clear()
self.path = None
self.fillPath = None
self._boundsCache = [None, None]
## this is disastrous for performance.
#self.setCacheMode(QtGui.QGraphicsItem.DeviceCoordinateCache)
......@@ -85,6 +85,12 @@ class PlotCurveItem(GraphicsObject):
return self.xData, self.yData
def dataBounds(self, ax, frac=1.0, orthoRange=None):
## Need this to run as fast as possible.
## check cache first:
cache = self._boundsCache[ax]
if cache is not None and cache[0] == (frac, orthoRange):
return cache[1]