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

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") ...@@ -45,6 +45,9 @@ p5 = win.addPlot(title="Scatter plot, axis labels, log scale")
x = np.random.normal(size=1000) * 1e-5 x = np.random.normal(size=1000) * 1e-5
y = x*1000 + 0.005 * np.random.normal(size=1000) y = x*1000 + 0.005 * np.random.normal(size=1000)
y -= y.min()-1.0 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.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('left', "Y Axis", units='A')
p5.setLabel('bottom', "Y Axis", units='s') 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 -*- # -*- coding: utf-8 -*-
import sys, os
## Add path to library (just for examples; you do not need this) ## 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 from pyqtgraph.Qt import QtGui, QtCore
import pyqtgraph as pg import pyqtgraph as pg
......
...@@ -22,6 +22,7 @@ examples = OrderedDict([ ...@@ -22,6 +22,7 @@ examples = OrderedDict([
('Dock widgets', 'dockarea.py'), ('Dock widgets', 'dockarea.py'),
('Console', 'ConsoleWidget.py'), ('Console', 'ConsoleWidget.py'),
('Histograms', 'histogram.py'), ('Histograms', 'histogram.py'),
('Remote Plotting', 'RemoteSpeedTest.py'),
('GraphicsItems', OrderedDict([ ('GraphicsItems', OrderedDict([
('Scatter Plot', 'ScatterPlot.py'), ('Scatter Plot', 'ScatterPlot.py'),
#('PlotItem', 'PlotItem.py'), #('PlotItem', 'PlotItem.py'),
...@@ -182,6 +183,7 @@ def testFile(name, f, exe, lib, graphicsSystem=None): ...@@ -182,6 +183,7 @@ def testFile(name, f, exe, lib, graphicsSystem=None):
code = """ code = """
try: try:
%s %s
import initExample
import pyqtgraph as pg import pyqtgraph as pg
%s %s
import %s import %s
......
...@@ -16,7 +16,6 @@ if not hasattr(sys, 'frozen'): ...@@ -16,7 +16,6 @@ if not hasattr(sys, 'frozen'):
sys.path.remove(p) sys.path.remove(p)
sys.path.insert(0, p) sys.path.insert(0, p)
## should force example to use PySide instead of PyQt ## should force example to use PySide instead of PyQt
if 'pyside' in sys.argv: if 'pyside' in sys.argv:
from PySide import QtGui from PySide import QtGui
......
...@@ -43,6 +43,9 @@ from pyqtgraph.Qt import QtCore, QtGui ...@@ -43,6 +43,9 @@ from pyqtgraph.Qt import QtCore, QtGui
app = pg.QtGui.QApplication([]) app = pg.QtGui.QApplication([])
print "\n=================\nStart QtProcess" print "\n=================\nStart QtProcess"
import sys
if (sys.flags.interactive != 1):
print " (not interactive; remote process will exit immediately.)"
proc = mp.QtProcess() proc = mp.QtProcess()
d1 = proc.transfer(np.random.normal(size=1000)) d1 = proc.transfer(np.random.normal(size=1000))
d2 = proc.transfer(np.random.normal(size=1000)) d2 = proc.transfer(np.random.normal(size=1000))
......
...@@ -75,6 +75,8 @@ class GraphicsScene(QtGui.QGraphicsScene): ...@@ -75,6 +75,8 @@ class GraphicsScene(QtGui.QGraphicsScene):
sigMouseMoved = QtCore.Signal(object) ## emits position of mouse on every move 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. 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() _addressCache = weakref.WeakValueDictionary()
ExportDirectory = None ExportDirectory = None
...@@ -98,6 +100,7 @@ class GraphicsScene(QtGui.QGraphicsScene): ...@@ -98,6 +100,7 @@ class GraphicsScene(QtGui.QGraphicsScene):
self.clickEvents = [] self.clickEvents = []
self.dragButtons = [] self.dragButtons = []
self.prepItems = weakref.WeakKeyDictionary() ## set of items with prepareForPaintMethods
self.mouseGrabber = None self.mouseGrabber = None
self.dragItem = None self.dragItem = None
self.lastDrag = None self.lastDrag = None
...@@ -112,6 +115,17 @@ class GraphicsScene(QtGui.QGraphicsScene): ...@@ -112,6 +115,17 @@ class GraphicsScene(QtGui.QGraphicsScene):
self.exportDialog = None 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): def setClickRadius(self, r):
""" """
...@@ -224,7 +238,7 @@ class GraphicsScene(QtGui.QGraphicsScene): ...@@ -224,7 +238,7 @@ class GraphicsScene(QtGui.QGraphicsScene):
else: else:
acceptable = int(ev.buttons()) == 0 ## if we are in mid-drag, do not allow items to accept the hover event. acceptable = int(ev.buttons()) == 0 ## if we are in mid-drag, do not allow items to accept the hover event.
event = HoverEvent(ev, acceptable) event = HoverEvent(ev, acceptable)
items = self.itemsNearEvent(event) items = self.itemsNearEvent(event, hoverable=True)
self.sigMouseHover.emit(items) self.sigMouseHover.emit(items)
prevItems = list(self.hoverItems.keys()) prevItems = list(self.hoverItems.keys())
...@@ -402,7 +416,7 @@ class GraphicsScene(QtGui.QGraphicsScene): ...@@ -402,7 +416,7 @@ class GraphicsScene(QtGui.QGraphicsScene):
#return item #return item
return self.translateGraphicsItem(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) 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. followed by any other items that are within the scene's click radius.
...@@ -429,6 +443,8 @@ class GraphicsScene(QtGui.QGraphicsScene): ...@@ -429,6 +443,8 @@ class GraphicsScene(QtGui.QGraphicsScene):
## remove items whose shape does not contain point (scene.items() apparently sucks at this) ## remove items whose shape does not contain point (scene.items() apparently sucks at this)
items2 = [] items2 = []
for item in items: for item in items:
if hoverable and not hasattr(item, 'hoverEvent'):
continue
shape = item.shape() shape = item.shape()
if shape is None: if shape is None:
continue continue
......
...@@ -356,8 +356,14 @@ class GarbageWatcher(object): ...@@ -356,8 +356,14 @@ class GarbageWatcher(object):
return self.objs[item] return self.objs[item]
class Profiler(object): class Profiler:
"""Simple profiler allowing measurement of multiple time intervals. """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: Example:
prof = Profiler('Function') prof = Profiler('Function')
...@@ -368,34 +374,65 @@ class Profiler(object): ...@@ -368,34 +374,65 @@ class Profiler(object):
prof.finish() prof.finish()
""" """
depth = 0 depth = 0
msgs = []
def __init__(self, msg="Profiler", disabled=False): def __init__(self, msg="Profiler", disabled=False, delayed=True, globalDelay=True):
self.depth = Profiler.depth
Profiler.depth += 1
self.disabled = disabled self.disabled = disabled
if disabled: if disabled:
return 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.t0 = ptime.time()
self.t1 = self.t0 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: if self.disabled:
return return
if msg is None:
msg = str(self.markCount)
self.markCount += 1
t1 = ptime.time() t1 = ptime.time()
print(" "+self.msg, msg, "%gms" % ((t1-self.t1)*1000)) msg2 = " "+self.msg+" "+msg+" "+"%gms" % ((t1-self.t1)*1000)
self.t1 = t1 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): def finish(self, msg=None):
if self.disabled: if self.disabled or self.finished:
return return
if msg is not None:
self.mark(msg)
t1 = ptime.time() 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): def profile(code, name='profile_run', sort='cumulative', num=30):
......
...@@ -350,9 +350,7 @@ class AxisItem(GraphicsWidget): ...@@ -350,9 +350,7 @@ class AxisItem(GraphicsWidget):
## decide optimal minor tick spacing in pixels (this is just aesthetics) ## decide optimal minor tick spacing in pixels (this is just aesthetics)
pixelSpacing = np.log(size+10) * 5 pixelSpacing = np.log(size+10) * 5
optimalTickCount = size / pixelSpacing optimalTickCount = max(2., size / pixelSpacing)
if optimalTickCount < 1:
optimalTickCount = 1
## optimal minor tick spacing ## optimal minor tick spacing
optimalSpacing = dif / optimalTickCount optimalSpacing = dif / optimalTickCount
...@@ -366,12 +364,21 @@ class AxisItem(GraphicsWidget): ...@@ -366,12 +364,21 @@ class AxisItem(GraphicsWidget):
while intervals[minorIndex+1] <= optimalSpacing: while intervals[minorIndex+1] <= optimalSpacing:
minorIndex += 1 minorIndex += 1
return [ levels = [
(intervals[minorIndex+2], 0), (intervals[minorIndex+2], 0),
(intervals[minorIndex+1], 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 ##### This does not work -- switching between 2/5 confuses the automatic text-level-selection
### Determine major/minor tick spacings which flank the optimal spacing. ### Determine major/minor tick spacings which flank the optimal spacing.
#intervals = np.array([1., 2., 5., 10., 20., 50., 100.]) * p10unit #intervals = np.array([1., 2., 5., 10., 20., 50., 100.]) * p10unit
...@@ -587,7 +594,7 @@ class AxisItem(GraphicsWidget): ...@@ -587,7 +594,7 @@ class AxisItem(GraphicsWidget):
ticks = tickLevels[i][1] ticks = tickLevels[i][1]
## length of tick ## length of tick
tickLength = self.tickLength / ((i*1.0)+1.0) tickLength = self.tickLength / ((i*0.5)+1.0)
lineAlpha = 255 / (i+1) lineAlpha = 255 / (i+1)
if self.grid is not False: if self.grid is not False:
......
...@@ -3,8 +3,30 @@ from pyqtgraph.GraphicsScene import GraphicsScene ...@@ -3,8 +3,30 @@ from pyqtgraph.GraphicsScene import GraphicsScene
from pyqtgraph.Point import Point from pyqtgraph.Point import Point
import pyqtgraph.functions as fn import pyqtgraph.functions as fn
import weakref import weakref
from pyqtgraph.pgcollections import OrderedDict
import operator 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): class GraphicsItem(object):
""" """
**Bases:** :class:`object` **Bases:** :class:`object`
...@@ -16,6 +38,8 @@ class GraphicsItem(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. 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): def __init__(self, register=True):
if not hasattr(self, '_qtBaseClass'): if not hasattr(self, '_qtBaseClass'):
for b in self.__class__.__bases__: for b in self.__class__.__bases__:
...@@ -25,6 +49,7 @@ class GraphicsItem(object): ...@@ -25,6 +49,7 @@ class GraphicsItem(object):
if not hasattr(self, '_qtBaseClass'): if not hasattr(self, '_qtBaseClass'):
raise Exception('Could not determine Qt base class for GraphicsItem: %s' % str(self)) raise Exception('Could not determine Qt base class for GraphicsItem: %s' % str(self))
self._pixelVectorCache = [None, None]
self._viewWidget = None self._viewWidget = None
self._viewBox = None self._viewBox = None
self._connectedView = None self._connectedView = None
...@@ -155,7 +180,6 @@ class GraphicsItem(object): ...@@ -155,7 +180,6 @@ class GraphicsItem(object):
def pixelVectors(self, direction=None): def pixelVectors(self, direction=None):
"""Return vectors in local coordinates representing the width and height of a view pixel. """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. If direction is specified, then return vectors parallel and orthogonal to it.
...@@ -163,13 +187,28 @@ class GraphicsItem(object): ...@@ -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) 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. 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() dt = self.deviceTransform()
if dt is None: if dt is None:
return None, 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: if