From 70482432b809d5d271760bcfe0dbc0daf628b08e Mon Sep 17 00:00:00 2001 From: Luke Campagnola Date: Sat, 30 Jan 2016 00:10:25 -0800 Subject: [PATCH] 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. --- doc/source/functions.rst | 2 + pyqtgraph/flowchart/FlowchartGraphicsView.py | 70 +--------------- pyqtgraph/flowchart/Node.py | 3 +- pyqtgraph/flowchart/Terminal.py | 85 +++----------------- pyqtgraph/flowchart/eq.py | 36 --------- pyqtgraph/functions.py | 41 +++++++++- pyqtgraph/graphicsItems/ImageItem.py | 74 ++++++++++++----- 7 files changed, 105 insertions(+), 206 deletions(-) delete mode 100644 pyqtgraph/flowchart/eq.py diff --git a/doc/source/functions.rst b/doc/source/functions.rst index 5d328ad9..8ea67a69 100644 --- a/doc/source/functions.rst +++ b/doc/source/functions.rst @@ -91,6 +91,8 @@ Mesh Generation Functions Miscellaneous Functions ----------------------- +.. autofunction:: pyqtgraph.eq + .. autofunction:: pyqtgraph.arrayToQPath .. autofunction:: pyqtgraph.pseudoScatter diff --git a/pyqtgraph/flowchart/FlowchartGraphicsView.py b/pyqtgraph/flowchart/FlowchartGraphicsView.py index ab4b2914..93011218 100644 --- a/pyqtgraph/flowchart/FlowchartGraphicsView.py +++ b/pyqtgraph/flowchart/FlowchartGraphicsView.py @@ -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) \ No newline at end of file diff --git a/pyqtgraph/flowchart/Node.py b/pyqtgraph/flowchart/Node.py index fc7b04d3..c450a9f3 100644 --- a/pyqtgraph/flowchart/Node.py +++ b/pyqtgraph/flowchart/Node.py @@ -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: diff --git a/pyqtgraph/flowchart/Terminal.py b/pyqtgraph/flowchart/Terminal.py index 6a6db62e..016e2d30 100644 --- a/pyqtgraph/flowchart/Terminal.py +++ b/pyqtgraph/flowchart/Terminal.py @@ -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 "" % (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) diff --git a/pyqtgraph/flowchart/eq.py b/pyqtgraph/flowchart/eq.py deleted file mode 100644 index 554989b2..00000000 --- a/pyqtgraph/flowchart/eq.py +++ /dev/null @@ -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))) diff --git a/pyqtgraph/functions.py b/pyqtgraph/functions.py index 002da469..3e9e3c77 100644 --- a/pyqtgraph/functions.py +++ b/pyqtgraph/functions.py @@ -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() diff --git a/pyqtgraph/graphicsItems/ImageItem.py b/pyqtgraph/graphicsItems/ImageItem.py index 2c9b2278..f42e78a6 100644 --- a/pyqtgraph/graphicsItems/ImageItem.py +++ b/pyqtgraph/graphicsItems/ImageItem.py @@ -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 ` 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 ` or :class:`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)