Scatter Plot Improvements (#1420)

* Added hovering demo to ScatterPlot example

* Use Qt's serialization for SymbolAtlas.symbolMap keys

Yields significant performance improvements when updating the scatter plot's options. See e.g. the plot hover example.

* Further optimized scatter plot picking

* Fix ScatterPlot example tool tip

* Clean up while I'm here

* Compatibility

* Some simple optimizations for ScatterPlotItem

Speedups for ScatterPlotSpeedTest.py:
~50% without pxMode
~ 0% pxMode with useCache
~30% pxMode without useCache

* ~3x speedup in scatter plot speed test with pxMode

* More optimization low-hanging fruit for the scatter plot

* Removed hover example to lazily pass tests

* Avoid segfault

* Re-add hover example to ScatterPlot.py

* Switch to id-based keying for scatter plot symbol atlas

- Use cases exist where serialization-based keying is a significant bottleneck, e.g. updating without atlas invalidation when a large variety pens or brushes are present.
- To avoid a performance hit, the onus is on the user to carefully reuse pen and brush objects.

* Optimized caching in scatter plot hovering example

* Fixed and optimized scatter plot hovering example

* Minor scatter plot optimization

* Cleanup

* Store hovered points in a set for the hovering example

* Keep a limited number symbol atlas entries around for future reuse

* Added a docstring note to remind the user to reuse QPen and QBrush objects for better performance

* Tidied up hovering example

* Typo

* Avoid unnecessary atlas rebuilds

* Refactored SymbolAtlas

* Efficient appending to SymbolAtlas

* SymbolAtlas rewrite

* Cleanup and profiling

* Add randomized brushes to speed test

* Add loc indexer to ScatterPlotItem

* Profile ScatterPlotItem.paint to identify bottlenecks

* Reuse targetRect to improve paint performance

* Readability improvements (opinionated)

* Only need to set x and y of targetRect

- w and h can stay set to 0 (not entirely sure why)
- this is a bit faster than setting all of x, y, w, h

* Minor renaming

* Strip off API changes and leave to another PR

* Renaming

* Compatibility

* Use drawPixmap(x, y, pm, sx, sy, sw, sh) signature to avoid needing to update QRectFs

* Use different drawing approaches for each Qt binding for performance reasons

* Fix a bug introduced two commits ago

Incidentally, I think there is a similar bug in the main branch currently.

* Minor performance and readability improvements

* Strip out source and target QRectF stuff

* Bring source and target QRectF stuff back in a less coupled way

* Leave deprecating getSpotOpts for another PR

* Compatibility fix

* Added docstrings and use SymbolAtlas__len__ where possible

* Fix export issue

* Add missing import

* Add deprecation warnings

* Avoid using deprecated methods

* Fix and cleanup max spot size measurements

* Make creation of style opts entries explicit

* Add hovering API to ScatterPlotItem

* Compatibility

* Marshal pen and brush lists in setPen and setBrush

* Fixed platform dependent bug
This commit is contained in:
lidstrom83 2020-12-16 11:07:39 -08:00 committed by GitHub
parent 1847bfcf97
commit 3af23725ca
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 582 additions and 244 deletions

View File

@ -12,6 +12,7 @@ from pyqtgraph.Qt import QtGui, QtCore
import pyqtgraph as pg import pyqtgraph as pg
import numpy as np import numpy as np
from collections import namedtuple from collections import namedtuple
from itertools import chain
app = QtGui.QApplication([]) app = QtGui.QApplication([])
mw = QtGui.QMainWindow() mw = QtGui.QMainWindow()
@ -30,9 +31,21 @@ w3 = view.addPlot()
w4 = view.addPlot() w4 = view.addPlot()
print("Generating data, this takes a few seconds...") print("Generating data, this takes a few seconds...")
## There are a few different ways we can draw scatter plots; each is optimized for different types of data: ## Make all plots clickable
clickedPen = pg.mkPen('b', width=2)
lastClicked = []
def clicked(plot, points):
global lastClicked
for p in lastClicked:
p.resetPen()
print("clicked points", points)
for p in points:
p.setPen(clickedPen)
lastClicked = points
## There are a few different ways we can draw scatter plots; each is optimized for different types of data:
## 1) All spots identical and transform-invariant (top-left plot). ## 1) All spots identical and transform-invariant (top-left plot).
## In this case we can get a huge performance boost by pre-rendering the spot ## In this case we can get a huge performance boost by pre-rendering the spot
## image and just drawing that image repeatedly. ## image and just drawing that image repeatedly.
@ -43,21 +56,9 @@ pos = np.random.normal(size=(2,n), scale=1e-5)
spots = [{'pos': pos[:,i], 'data': 1} for i in range(n)] + [{'pos': [0,0], 'data': 1}] spots = [{'pos': pos[:,i], 'data': 1} for i in range(n)] + [{'pos': [0,0], 'data': 1}]
s1.addPoints(spots) s1.addPoints(spots)
w1.addItem(s1) w1.addItem(s1)
## Make all plots clickable
lastClicked = []
def clicked(plot, points):
global lastClicked
for p in lastClicked:
p.resetPen()
print("clicked points", points)
for p in points:
p.setPen('b', width=2)
lastClicked = points
s1.sigClicked.connect(clicked) s1.sigClicked.connect(clicked)
## 2) Spots are transform-invariant, but not identical (top-right plot). ## 2) Spots are transform-invariant, but not identical (top-right plot).
## In this case, drawing is almsot as fast as 1), but there is more startup ## In this case, drawing is almsot as fast as 1), but there is more startup
## overhead and memory usage since each spot generates its own pre-rendered ## overhead and memory usage since each spot generates its own pre-rendered
@ -95,7 +96,12 @@ s2.sigClicked.connect(clicked)
## This is the slowest case, since all spots must be completely re-drawn ## This is the slowest case, since all spots must be completely re-drawn
## every time because their apparent transformation may have changed. ## every time because their apparent transformation may have changed.
s3 = pg.ScatterPlotItem(pxMode=False) ## Set pxMode=False to allow spots to transform with the view s3 = pg.ScatterPlotItem(
pxMode=False, # Set pxMode=False to allow spots to transform with the view
hoverable=True,
hoverPen=pg.mkPen('g'),
hoverSize=1e-6
)
spots3 = [] spots3 = []
for i in range(10): for i in range(10):
for j in range(10): for j in range(10):
@ -104,17 +110,31 @@ s3.addPoints(spots3)
w3.addItem(s3) w3.addItem(s3)
s3.sigClicked.connect(clicked) s3.sigClicked.connect(clicked)
## Test performance of large scatterplots ## Test performance of large scatterplots
s4 = pg.ScatterPlotItem(size=10, pen=pg.mkPen(None), brush=pg.mkBrush(255, 255, 255, 20)) s4 = pg.ScatterPlotItem(
pos = np.random.normal(size=(2,10000), scale=1e-9) size=10,
s4.addPoints(x=pos[0], y=pos[1]) pen=pg.mkPen(None),
brush=pg.mkBrush(255, 255, 255, 20),
hoverable=True,
hoverSymbol='s',
hoverSize=15,
hoverPen=pg.mkPen('r', width=2),
hoverBrush=pg.mkBrush('g'),
)
n = 10000
pos = np.random.normal(size=(2, n), scale=1e-9)
s4.addPoints(
x=pos[0],
y=pos[1],
# size=(np.random.random(n) * 20.).astype(int),
# brush=[pg.mkBrush(x) for x in np.random.randint(0, 256, (n, 3))],
data=np.arange(n)
)
w4.addItem(s4) w4.addItem(s4)
s4.sigClicked.connect(clicked) s4.sigClicked.connect(clicked)
## Start Qt event loop unless running in interactive mode. ## Start Qt event loop unless running in interactive mode.
if __name__ == '__main__': if __name__ == '__main__':
import sys import sys

View File

@ -38,20 +38,26 @@ win.show()
p = ui.plot p = ui.plot
p.setRange(xRange=[-500, 500], yRange=[-500, 500]) p.setRange(xRange=[-500, 500], yRange=[-500, 500])
data = np.random.normal(size=(50,500), scale=100) count = 500
sizeArray = (np.random.random(500) * 20.).astype(int) data = np.random.normal(size=(50,count), scale=100)
sizeArray = (np.random.random(count) * 20.).astype(int)
brushArray = [pg.mkBrush(x) for x in np.random.randint(0, 256, (count, 3))]
ptr = 0 ptr = 0
lastTime = time() lastTime = time()
fps = None fps = None
def update(): def update():
global curve, data, ptr, p, lastTime, fps global curve, data, ptr, p, lastTime, fps
p.clear() p.clear()
if ui.randCheck.isChecked(): if ui.randCheck.isChecked():
size = sizeArray size = sizeArray
brush = brushArray
else: else:
size = ui.sizeSpin.value() size = ui.sizeSpin.value()
curve = pg.ScatterPlotItem(x=data[ptr%50], y=data[(ptr+1)%50], brush = 'b'
pen='w', brush='b', size=size, curve = pg.ScatterPlotItem(x=data[ptr % 50], y=data[(ptr+1) % 50],
pen='w', brush=brush, size=size,
pxMode=ui.pixelModeCheck.isChecked()) pxMode=ui.pixelModeCheck.isChecked())
p.addItem(curve) p.addItem(curve)
ptr += 1 ptr += 1

View File

@ -1,3 +1,4 @@
import warnings
from functools import reduce from functools import reduce
from ..Qt import QtGui, QtCore, isQObjectAlive from ..Qt import QtGui, QtCore, isQObjectAlive
from ..GraphicsScene import GraphicsScene from ..GraphicsScene import GraphicsScene
@ -103,10 +104,6 @@ class GraphicsItem(object):
Return the transform that converts local item coordinates to device coordinates (usually pixels). Return the transform that converts local item coordinates to device coordinates (usually pixels).
Extends deviceTransform to automatically determine the viewportTransform. Extends deviceTransform to automatically determine the viewportTransform.
""" """
if self._exportOpts is not False and 'painter' in self._exportOpts: ## currently exporting; device transform may be different.
scaler = self._exportOpts.get('resolutionScale', 1.0)
return self.sceneTransform() * QtGui.QTransform(scaler, 0, 0, scaler, 1, 1)
if viewportTransform is None: if viewportTransform is None:
view = self.getViewWidget() view = self.getViewWidget()
if view is None: if view is None:

View File

@ -28,12 +28,15 @@ class PlotDataItem(GraphicsObject):
sigClicked(self, ev) Emitted when the item is clicked. sigClicked(self, ev) Emitted when the item is clicked.
sigPointsClicked(self, points, ev) Emitted when a plot point is clicked sigPointsClicked(self, points, ev) Emitted when a plot point is clicked
Sends the list of points under the mouse. Sends the list of points under the mouse.
sigPointsHovered(self, points, ev) Emitted when a plot point is hovered over.
Sends the list of points under the mouse.
================================== ============================================== ================================== ==============================================
""" """
sigPlotChanged = QtCore.Signal(object) sigPlotChanged = QtCore.Signal(object)
sigClicked = QtCore.Signal(object, object) sigClicked = QtCore.Signal(object, object)
sigPointsClicked = QtCore.Signal(object, object, object) sigPointsClicked = QtCore.Signal(object, object, object)
sigPointsHovered = QtCore.Signal(object, object, object)
def __init__(self, *args, **kargs): def __init__(self, *args, **kargs):
""" """
@ -161,6 +164,7 @@ class PlotDataItem(GraphicsObject):
self.curve.sigClicked.connect(self.curveClicked) self.curve.sigClicked.connect(self.curveClicked)
self.scatter.sigClicked.connect(self.scatterClicked) self.scatter.sigClicked.connect(self.scatterClicked)
self.scatter.sigHovered.connect(self.scatterHovered)
self._dataRect = None self._dataRect = None
#self.clear() #self.clear()
@ -768,6 +772,9 @@ class PlotDataItem(GraphicsObject):
self.sigClicked.emit(self, ev) self.sigClicked.emit(self, ev)
self.sigPointsClicked.emit(self, points, ev) self.sigPointsClicked.emit(self, points, ev)
def scatterHovered(self, plt, points, ev):
self.sigPointsHovered.emit(self, points, ev)
def viewRangeChanged(self): def viewRangeChanged(self):
# view range has changed; re-plot if needed # view range has changed; re-plot if needed
if( self.opts['clipToView'] if( self.opts['clipToView']

File diff suppressed because it is too large Load Diff

View File

@ -34,7 +34,8 @@ class ScatterPlotWidget(QtGui.QSplitter):
4) A PlotWidget for displaying the data. 4) A PlotWidget for displaying the data.
""" """
sigScatterPlotClicked = QtCore.Signal(object, object, object) sigScatterPlotClicked = QtCore.Signal(object, object, object)
sigScatterPlotHovered = QtCore.Signal(object, object, object)
def __init__(self, parent=None): def __init__(self, parent=None):
QtGui.QSplitter.__init__(self, QtCore.Qt.Horizontal) QtGui.QSplitter.__init__(self, QtCore.Qt.Horizontal)
self.ctrlPanel = QtGui.QSplitter(QtCore.Qt.Vertical) self.ctrlPanel = QtGui.QSplitter(QtCore.Qt.Vertical)
@ -257,6 +258,7 @@ class ScatterPlotWidget(QtGui.QSplitter):
self._indexMap = None self._indexMap = None
self.scatterPlot = self.plot.plot(xy[0], xy[1], data=data, **style) self.scatterPlot = self.plot.plot(xy[0], xy[1], data=data, **style)
self.scatterPlot.sigPointsClicked.connect(self.plotClicked) self.scatterPlot.sigPointsClicked.connect(self.plotClicked)
self.scatterPlot.sigPointsHovered.connect(self.plotHovered)
self.updateSelected() self.updateSelected()
def updateSelected(self): def updateSelected(self):
@ -284,3 +286,6 @@ class ScatterPlotWidget(QtGui.QSplitter):
for pt in points: for pt in points:
pt.originalIndex = self._visibleIndices[pt.index()] pt.originalIndex = self._visibleIndices[pt.index()]
self.sigScatterPlotClicked.emit(self, points, ev) self.sigScatterPlotClicked.emit(self, points, ev)
def plotHovered(self, plot, points, ev):
self.sigScatterPlotHovered.emit(self, points, ev)