Improve ImageItem performance by scaling LUT instead of image when possible.

Moved eq function from flowcharts to main function library to support this.
Bonus: fixed a flowchart bug (backspace deletes wrong connector) while I was in there.
This commit is contained in:
Luke Campagnola 2016-01-30 00:10:25 -08:00
parent 4be2869773
commit 70482432b8
7 changed files with 105 additions and 206 deletions

View File

@ -91,6 +91,8 @@ Mesh Generation Functions
Miscellaneous Functions
-----------------------
.. autofunction:: pyqtgraph.eq
.. autofunction:: pyqtgraph.arrayToQPath
.. autofunction:: pyqtgraph.pseudoScatter

View File

@ -4,72 +4,27 @@ from ..widgets.GraphicsView import GraphicsView
from ..GraphicsScene import GraphicsScene
from ..graphicsItems.ViewBox import ViewBox
#class FlowchartGraphicsView(QtGui.QGraphicsView):
class FlowchartGraphicsView(GraphicsView):
sigHoverOver = QtCore.Signal(object)
sigClicked = QtCore.Signal(object)
def __init__(self, widget, *args):
#QtGui.QGraphicsView.__init__(self, *args)
GraphicsView.__init__(self, *args, useOpenGL=False)
#self.setBackgroundBrush(QtGui.QBrush(QtGui.QColor(255,255,255)))
self._vb = FlowchartViewBox(widget, lockAspect=True, invertY=True)
self.setCentralItem(self._vb)
#self.scene().addItem(self.vb)
#self.setMouseTracking(True)
#self.lastPos = None
#self.setTransformationAnchor(self.AnchorViewCenter)
#self.setRenderHints(QtGui.QPainter.Antialiasing)
self.setRenderHint(QtGui.QPainter.Antialiasing, True)
#self.setDragMode(QtGui.QGraphicsView.RubberBandDrag)
#self.setRubberBandSelectionMode(QtCore.Qt.ContainsItemBoundingRect)
def viewBox(self):
return self._vb
#def mousePressEvent(self, ev):
#self.moved = False
#self.lastPos = ev.pos()
#return QtGui.QGraphicsView.mousePressEvent(self, ev)
#def mouseMoveEvent(self, ev):
#self.moved = True
#callSuper = False
#if ev.buttons() & QtCore.Qt.RightButton:
#if self.lastPos is not None:
#dif = ev.pos() - self.lastPos
#self.scale(1.01**-dif.y(), 1.01**-dif.y())
#elif ev.buttons() & QtCore.Qt.MidButton:
#if self.lastPos is not None:
#dif = ev.pos() - self.lastPos
#self.translate(dif.x(), -dif.y())
#else:
##self.emit(QtCore.SIGNAL('hoverOver'), self.items(ev.pos()))
#self.sigHoverOver.emit(self.items(ev.pos()))
#callSuper = True
#self.lastPos = ev.pos()
#if callSuper:
#QtGui.QGraphicsView.mouseMoveEvent(self, ev)
#def mouseReleaseEvent(self, ev):
#if not self.moved:
##self.emit(QtCore.SIGNAL('clicked'), ev)
#self.sigClicked.emit(ev)
#return QtGui.QGraphicsView.mouseReleaseEvent(self, ev)
class FlowchartViewBox(ViewBox):
def __init__(self, widget, *args, **kwargs):
ViewBox.__init__(self, *args, **kwargs)
self.widget = widget
#self.menu = None
#self._subMenus = None ## need a place to store the menus otherwise they dissappear (even though they've been added to other menus) ((yes, it doesn't make sense))
def getMenu(self, ev):
## called by ViewBox to create a new context menu
@ -84,26 +39,3 @@ class FlowchartViewBox(ViewBox):
menu = self.widget.buildMenu(ev.scenePos())
menu.setTitle("Add node")
return [menu, ViewBox.getMenu(self, ev)]
##class FlowchartGraphicsScene(QtGui.QGraphicsScene):
#class FlowchartGraphicsScene(GraphicsScene):
#sigContextMenuEvent = QtCore.Signal(object)
#def __init__(self, *args):
##QtGui.QGraphicsScene.__init__(self, *args)
#GraphicsScene.__init__(self, *args)
#def mouseClickEvent(self, ev):
##QtGui.QGraphicsScene.contextMenuEvent(self, ev)
#if not ev.button() in [QtCore.Qt.RightButton]:
#self.sigContextMenuEvent.emit(ev)

View File

@ -6,7 +6,6 @@ from .Terminal import *
from ..pgcollections import OrderedDict
from ..debug import *
import numpy as np
from .eq import *
def strDict(d):
@ -261,7 +260,7 @@ class Node(QtCore.QObject):
for k, v in args.items():
term = self._inputs[k]
oldVal = term.value()
if not eq(oldVal, v):
if not fn.eq(oldVal, v):
changed = True
term.setValue(v, process=False)
if changed and '_updatesHandled_' not in args:

View File

@ -4,8 +4,7 @@ import weakref
from ..graphicsItems.GraphicsObject import GraphicsObject
from .. import functions as fn
from ..Point import Point
#from PySide import QtCore, QtGui
from .eq import *
class Terminal(object):
def __init__(self, node, name, io, optional=False, multi=False, pos=None, renamable=False, removable=False, multiable=False, bypass=None):
@ -29,9 +28,6 @@ class Terminal(object):
============== =================================================================================
"""
self._io = io
#self._isOutput = opts[0] in ['out', 'io']
#self._isInput = opts[0]] in ['in', 'io']
#self._isIO = opts[0]=='io'
self._optional = optional
self._multi = multi
self._node = weakref.ref(node)
@ -68,7 +64,7 @@ class Terminal(object):
"""If this is a single-value terminal, val should be a single value.
If this is a multi-value terminal, val should be a dict of terminal:value pairs"""
if not self.isMultiValue():
if eq(val, self._value):
if fn.eq(val, self._value):
return
self._value = val
else:
@ -81,11 +77,6 @@ class Terminal(object):
if self.isInput() and process:
self.node().update()
## Let the flowchart handle this.
#if self.isOutput():
#for c in self.connections():
#if c.isInput():
#c.inputChanged(self)
self.recolor()
def setOpts(self, **opts):
@ -94,7 +85,6 @@ class Terminal(object):
self._multiable = opts.get('multiable', self._multiable)
if 'multi' in opts:
self.setMultiValue(opts['multi'])
def connected(self, term):
"""Called whenever this terminal has been connected to another. (note--this function is called on both terminals)"""
@ -109,12 +99,10 @@ class Terminal(object):
if self.isMultiValue() and term in self._value:
del self._value[term]
self.node().update()
#self.recolor()
else:
if self.isInput():
self.setValue(None)
self.node().disconnected(self, term)
#self.node().update()
def inputChanged(self, term, process=True):
"""Called whenever there is a change to the input value to this terminal.
@ -178,7 +166,6 @@ class Terminal(object):
return term in self.connections()
def hasInput(self):
#conn = self.extendedConnections()
for t in self.connections():
if t.isOutput():
return True
@ -186,17 +173,10 @@ class Terminal(object):
def inputTerminals(self):
"""Return the terminal(s) that give input to this one."""
#terms = self.extendedConnections()
#for t in terms:
#if t.isOutput():
#return t
return [t for t in self.connections() if t.isOutput()]
def dependentNodes(self):
"""Return the list of nodes which receive input from this terminal."""
#conn = self.extendedConnections()
#del conn[self]
return set([t.node() for t in self.connections() if t.isInput()])
def connectTo(self, term, connectionItem=None):
@ -210,12 +190,6 @@ class Terminal(object):
for t in [self, term]:
if t.isInput() and not t._multi and len(t.connections()) > 0:
raise Exception("Cannot connect %s <-> %s: Terminal %s is already connected to %s (and does not allow multiple connections)" % (self, term, t, list(t.connections().keys())))
#if self.hasInput() and term.hasInput():
#raise Exception('Target terminal already has input')
#if term in self.node().terminals.values():
#if self.isOutput() or term.isOutput():
#raise Exception('Can not connect an output back to the same node.')
except:
if connectionItem is not None:
connectionItem.close()
@ -223,18 +197,12 @@ class Terminal(object):
if connectionItem is None:
connectionItem = ConnectionItem(self.graphicsItem(), term.graphicsItem())
#self.graphicsItem().scene().addItem(connectionItem)
self.graphicsItem().getViewBox().addItem(connectionItem)
#connectionItem.setParentItem(self.graphicsItem().parent().parent())
self._connections[term] = connectionItem
term._connections[self] = connectionItem
self.recolor()
#if self.isOutput() and term.isInput():
#term.inputChanged(self)
#if term.isInput() and term.isOutput():
#self.inputChanged(term)
self.connected(term)
term.connected(self)
@ -244,8 +212,6 @@ class Terminal(object):
if not self.connectedTo(term):
return
item = self._connections[term]
#print "removing connection", item
#item.scene().removeItem(item)
item.close()
del self._connections[term]
del term._connections[self]
@ -254,10 +220,6 @@ class Terminal(object):
self.disconnected(term)
term.disconnected(self)
#if self.isOutput() and term.isInput():
#term.inputChanged(self)
#if term.isInput() and term.isOutput():
#self.inputChanged(term)
def disconnectAll(self):
@ -270,7 +232,7 @@ class Terminal(object):
color = QtGui.QColor(0,0,0)
elif self.isInput() and not self.hasInput(): ## input terminal with no connected output terminals
color = QtGui.QColor(200,200,0)
elif self._value is None or eq(self._value, {}): ## terminal is connected but has no data (possibly due to processing error)
elif self._value is None or fn.eq(self._value, {}): ## terminal is connected but has no data (possibly due to processing error)
color = QtGui.QColor(255,255,255)
elif self.valueIsAcceptable() is None: ## terminal has data, but it is unknown if the data is ok
color = QtGui.QColor(200, 200, 0)
@ -283,7 +245,6 @@ class Terminal(object):
if recurse:
for t in self.connections():
t.recolor(color, recurse=False)
def rename(self, name):
oldName = self._name
@ -294,17 +255,6 @@ class Terminal(object):
def __repr__(self):
return "<Terminal %s.%s>" % (str(self.node().name()), str(self.name()))
#def extendedConnections(self, terms=None):
#"""Return list of terminals (including this one) that are directly or indirectly wired to this."""
#if terms is None:
#terms = {}
#terms[self] = None
#for t in self._connections:
#if t in terms:
#continue
#terms.update(t.extendedConnections(terms))
#return terms
def __hash__(self):
return id(self)
@ -318,18 +268,15 @@ class Terminal(object):
return {'io': self._io, 'multi': self._multi, 'optional': self._optional, 'renamable': self._renamable, 'removable': self._removable, 'multiable': self._multiable}
#class TerminalGraphicsItem(QtGui.QGraphicsItem):
class TerminalGraphicsItem(GraphicsObject):
def __init__(self, term, parent=None):
self.term = term
#QtGui.QGraphicsItem.__init__(self, parent)
GraphicsObject.__init__(self, parent)
self.brush = fn.mkBrush(0,0,0)
self.box = QtGui.QGraphicsRectItem(0, 0, 10, 10, self)
self.label = QtGui.QGraphicsTextItem(self.term.name(), self)
self.label.scale(0.7, 0.7)
#self.setAcceptHoverEvents(True)
self.newConnection = None
self.setFiltersChildEvents(True) ## to pick up mouse events on the rectitem
if self.term.isRenamable():
@ -338,7 +285,6 @@ class TerminalGraphicsItem(GraphicsObject):
self.label.keyPressEvent = self.labelKeyPress
self.setZValue(1)
self.menu = None
def labelFocusOut(self, ev):
QtGui.QGraphicsTextItem.focusOutEvent(self.label, ev)
@ -471,8 +417,6 @@ class TerminalGraphicsItem(GraphicsObject):
break
if not gotTarget:
#print "remove unused connection"
#self.scene().removeItem(self.newConnection)
self.newConnection.close()
self.newConnection = None
else:
@ -488,12 +432,6 @@ class TerminalGraphicsItem(GraphicsObject):
self.box.setBrush(self.brush)
self.update()
#def hoverEnterEvent(self, ev):
#self.hover = True
#def hoverLeaveEvent(self, ev):
#self.hover = False
def connectPoint(self):
## return the connect position of this terminal in view coords
return self.mapToView(self.mapFromItem(self.box, self.box.boundingRect().center()))
@ -503,11 +441,9 @@ class TerminalGraphicsItem(GraphicsObject):
item.updateLine()
#class ConnectionItem(QtGui.QGraphicsItem):
class ConnectionItem(GraphicsObject):
def __init__(self, source, target=None):
#QtGui.QGraphicsItem.__init__(self)
GraphicsObject.__init__(self)
self.setFlags(
self.ItemIsSelectable |
@ -528,14 +464,12 @@ class ConnectionItem(GraphicsObject):
'selectedColor': (200, 200, 0),
'selectedWidth': 3.0,
}
#self.line = QtGui.QGraphicsLineItem(self)
self.source.getViewBox().addItem(self)
self.updateLine()
self.setZValue(0)
def close(self):
if self.scene() is not None:
#self.scene().removeItem(self.line)
self.scene().removeItem(self)
def setTarget(self, target):
@ -575,8 +509,11 @@ class ConnectionItem(GraphicsObject):
return path
def keyPressEvent(self, ev):
if not self.isSelected():
ev.ignore()
return
if ev.key() == QtCore.Qt.Key_Delete or ev.key() == QtCore.Qt.Key_Backspace:
#if isinstance(self.target, TerminalGraphicsItem):
self.source.disconnect(self.target)
ev.accept()
else:
@ -590,6 +527,7 @@ class ConnectionItem(GraphicsObject):
ev.accept()
sel = self.isSelected()
self.setSelected(True)
self.setFocus()
if not sel and self.isSelected():
self.update()
@ -600,12 +538,9 @@ class ConnectionItem(GraphicsObject):
self.hovered = False
self.update()
def boundingRect(self):
return self.shape().boundingRect()
##return self.line.boundingRect()
#px = self.pixelWidth()
#return QtCore.QRectF(-5*px, 0, 10*px, self.length)
def viewRangeChanged(self):
self.shapePath = None
self.prepareGeometryChange()
@ -628,7 +563,5 @@ class ConnectionItem(GraphicsObject):
p.setPen(fn.mkPen(self.style['hoverColor'], width=self.style['hoverWidth']))
else:
p.setPen(fn.mkPen(self.style['color'], width=self.style['width']))
#p.drawLine(0, 0, 0, self.length)
p.drawPath(self.path)

View File

@ -1,36 +0,0 @@
# -*- coding: utf-8 -*-
from numpy import ndarray, bool_
from ..metaarray import MetaArray
def eq(a, b):
"""The great missing equivalence function: Guaranteed evaluation to a single bool value."""
if a is b:
return True
try:
e = a==b
except ValueError:
return False
except AttributeError:
return False
except:
print("a:", str(type(a)), str(a))
print("b:", str(type(b)), str(b))
raise
t = type(e)
if t is bool:
return e
elif t is bool_:
return bool(e)
elif isinstance(e, ndarray) or (hasattr(e, 'implements') and e.implements('MetaArray')):
try: ## disaster: if a is an empty array and b is not, then e.all() is True
if a.shape != b.shape:
return False
except:
return False
if (hasattr(e, 'implements') and e.implements('MetaArray')):
return e.asarray().all()
else:
return e.all()
else:
raise Exception("== operator returned type %s" % str(type(e)))

View File

@ -243,6 +243,7 @@ def mkBrush(*args, **kwds):
color = args
return QtGui.QBrush(mkColor(color))
def mkPen(*args, **kargs):
"""
Convenience function for constructing QPen.
@ -292,6 +293,7 @@ def mkPen(*args, **kargs):
pen.setDashPattern(dash)
return pen
def hsvColor(hue, sat=1.0, val=1.0, alpha=1.0):
"""Generate a QColor from HSVa values. (all arguments are float 0.0-1.0)"""
c = QtGui.QColor()
@ -303,10 +305,12 @@ def colorTuple(c):
"""Return a tuple (R,G,B,A) from a QColor"""
return (c.red(), c.green(), c.blue(), c.alpha())
def colorStr(c):
"""Generate a hex string code from a QColor"""
return ('%02x'*4) % colorTuple(c)
def intColor(index, hues=9, values=1, maxValue=255, minValue=150, maxHue=360, minHue=0, sat=255, alpha=255, **kargs):
"""
Creates a QColor from a single index. Useful for stepping through a predefined list of colors.
@ -331,6 +335,7 @@ def intColor(index, hues=9, values=1, maxValue=255, minValue=150, maxHue=360, mi
c.setAlpha(alpha)
return c
def glColor(*args, **kargs):
"""
Convert a color to OpenGL color format (r,g,b,a) floats 0.0-1.0
@ -367,6 +372,40 @@ def makeArrowPath(headLen=20, tipAngle=20, tailLen=20, tailWidth=3, baseAngle=0)
return path
def eq(a, b):
"""The great missing equivalence function: Guaranteed evaluation to a single bool value."""
if a is b:
return True
try:
e = a==b
except ValueError:
return False
except AttributeError:
return False
except:
print('failed to evaluate equivalence for:')
print(" a:", str(type(a)), str(a))
print(" b:", str(type(b)), str(b))
raise
t = type(e)
if t is bool:
return e
elif t is np.bool_:
return bool(e)
elif isinstance(e, np.ndarray) or (hasattr(e, 'implements') and e.implements('MetaArray')):
try: ## disaster: if a is an empty array and b is not, then e.all() is True
if a.shape != b.shape:
return False
except:
return False
if (hasattr(e, 'implements') and e.implements('MetaArray')):
return e.asarray().all()
else:
return e.all()
else:
raise Exception("== operator returned type %s" % str(type(e)))
def affineSlice(data, shape, origin, vectors, axes, order=1, returnCoords=False, **kargs):
"""
@ -930,7 +969,7 @@ def makeARGB(data, lut=None, levels=None, scale=None, useRGBA=False):
if levels.shape != (data.shape[-1], 2):
raise Exception('levels must have shape (data.shape[-1], 2)')
else:
raise Exception("levels argument must be 1D or 2D.")
raise Exception("levels argument must be 1D or 2D (got shape=%s)." % repr(levels.shape))
profile()

View File

@ -47,6 +47,10 @@ class ImageItem(GraphicsObject):
self.lut = None
self.autoDownsample = False
# In some cases, we use a modified lookup table to handle both rescaling
# and LUT more efficiently
self._effectiveLut = None
self.drawKernel = None
self.border = None
self.removable = False
@ -74,11 +78,6 @@ class ImageItem(GraphicsObject):
"""
self.paintMode = mode
self.update()
## use setOpacity instead.
#def setAlpha(self, alpha):
#self.setOpacity(alpha)
#self.updateImage()
def setBorder(self, b):
self.border = fn.mkPen(b)
@ -99,16 +98,6 @@ class ImageItem(GraphicsObject):
return QtCore.QRectF(0., 0., 0., 0.)
return QtCore.QRectF(0., 0., float(self.width()), float(self.height()))
#def setClipLevel(self, level=None):
#self.clipLevel = level
#self.updateImage()
#def paint(self, p, opt, widget):
#pass
#if self.pixmap is not None:
#p.drawPixmap(0, 0, self.pixmap)
#print "paint"
def setLevels(self, levels, update=True):
"""
Set image scaling levels. Can be one of:
@ -119,9 +108,13 @@ class ImageItem(GraphicsObject):
Only the first format is compatible with lookup tables. See :func:`makeARGB <pyqtgraph.makeARGB>`
for more details on how levels are applied.
"""
self.levels = levels
if update:
self.updateImage()
if levels is not None:
levels = np.asarray(levels)
if not fn.eq(levels, self.levels):
self.levels = levels
self._effectiveLut = None
if update:
self.updateImage()
def getLevels(self):
return self.levels
@ -137,9 +130,11 @@ class ImageItem(GraphicsObject):
Ordinarily, this table is supplied by a :class:`HistogramLUTItem <pyqtgraph.HistogramLUTItem>`
or :class:`GradientEditorItem <pyqtgraph.GradientEditorItem>`.
"""
self.lut = lut
if update:
self.updateImage()
if lut is not self.lut:
self.lut = lut
self._effectiveLut = None
if update:
self.updateImage()
def setAutoDownsample(self, ads):
"""
@ -222,7 +217,10 @@ class ImageItem(GraphicsObject):
else:
gotNewData = True
shapeChanged = (self.image is None or image.shape != self.image.shape)
self.image = image.view(np.ndarray)
image = image.view(np.ndarray)
if self.image is None or image.dtype != self.image.dtype:
self._effectiveLut = None
self.image = image
if self.image.shape[0] > 2**15-1 or self.image.shape[1] > 2**15-1:
if 'autoDownsample' not in kargs:
kargs['autoDownsample'] = True
@ -261,6 +259,17 @@ class ImageItem(GraphicsObject):
if gotNewData:
self.sigImageChanged.emit()
def quickMinMax(self, targetSize=1e6):
"""
Estimate the min/max values of the image data by subsampling.
"""
data = self.image
while data.size > targetSize:
ax = np.argmax(data.shape)
sl = [slice(None)] * data.ndim
sl[ax] = slice(None, None, 2)
data = data[sl]
return nanmin(data), nanmax(data)
def updateImage(self, *args, **kargs):
## used for re-rendering qimage from self.image.
@ -297,6 +306,27 @@ class ImageItem(GraphicsObject):
image = fn.downsample(image, yds, axis=1)
else:
image = self.image
# if the image data is a small int, then we can combine levels + lut
# into a single lut for better performance
if self.levels is not None and self.levels.ndim == 1 and image.dtype in (np.ubyte, np.uint16):
if self._effectiveLut is None:
eflsize = 2**(image.itemsize*8)
ind = np.arange(eflsize)
minlev, maxlev = self.levels
if lut is None:
efflut = fn.rescaleData(ind, scale=255./(maxlev-minlev),
offset=minlev, dtype=np.ubyte)
else:
lutdtype = np.min_scalar_type(lut.shape[0]-1)
efflut = fn.rescaleData(ind, scale=(lut.shape[0]-1)/(maxlev-minlev),
offset=minlev, dtype=lutdtype, clip=(0, lut.shape[0]-1))
efflut = lut[efflut]
self._effectiveLut = efflut
lut = self._effectiveLut
levels = None
argb, alpha = fn.makeARGB(image.transpose((1, 0, 2)[:image.ndim]), lut=lut, levels=self.levels)
self.qimage = fn.makeQImage(argb, alpha, transpose=False)