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 3936e926..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): """ @@ -774,12 +813,11 @@ def solveBilinearTransform(points1, points2): return matrix -def rescaleData(data, scale, offset, dtype=None): +def rescaleData(data, scale, offset, dtype=None, clip=None): """Return data rescaled and optionally cast to a new dtype:: data => (data-offset) * scale - Uses scipy.weave (if available) to improve performance. """ if dtype is None: dtype = data.dtype @@ -824,9 +862,21 @@ def rescaleData(data, scale, offset, dtype=None): setConfigOptions(useWeave=False) #p = np.poly1d([scale, -offset*scale]) - #data = p(data).astype(dtype) - d2 = data-offset - np.multiply(d2, scale, out=d2, casting="unsafe") + #d2 = p(data) + d2 = data - float(offset) + d2 *= scale + + # Clip before converting dtype to avoid overflow + if dtype.kind in 'ui': + lim = np.iinfo(dtype) + if clip is None: + # don't let rescale cause integer overflow + d2 = np.clip(d2, lim.min, lim.max) + else: + d2 = np.clip(d2, max(clip[0], lim.min), min(clip[1], lim.max)) + else: + if clip is not None: + d2 = np.clip(d2, *clip) data = d2.astype(dtype) return data @@ -848,15 +898,18 @@ def makeRGBA(*args, **kwds): kwds['useRGBA'] = True return makeARGB(*args, **kwds) + def makeARGB(data, lut=None, levels=None, scale=None, useRGBA=False): """ - Convert an array of values into an ARGB array suitable for building QImages, OpenGL textures, etc. + Convert an array of values into an ARGB array suitable for building QImages, + OpenGL textures, etc. - Returns the ARGB array (values 0-255) and a boolean indicating whether there is alpha channel data. - This is a two stage process: + Returns the ARGB array (unsigned byte) and a boolean indicating whether + there is alpha channel data. This is a two stage process: 1) Rescale the data based on the values in the *levels* argument (min, max). - 2) Determine the final output by passing the rescaled values through a lookup table. + 2) Determine the final output by passing the rescaled values through a + lookup table. Both stages are optional. @@ -875,55 +928,68 @@ def makeARGB(data, lut=None, levels=None, scale=None, useRGBA=False): channel). The use of this feature requires that levels.shape[0] == data.shape[-1]. scale The maximum value to which data will be rescaled before being passed through the lookup table (or returned if there is no lookup table). By default this will - be set to the length of the lookup table, or 256 is no lookup table is provided. - For OpenGL color specifications (as in GLColor4f) use scale=1.0 + be set to the length of the lookup table, or 255 if no lookup table is provided. lut Optional lookup table (array with dtype=ubyte). Values in data will be converted to color by indexing directly from lut. The output data shape will be input.shape + lut.shape[1:]. - - Note: the output of makeARGB will have the same dtype as the lookup table, so - for conversion to QImage, the dtype must be ubyte. - - Lookup tables can be built using GradientWidget. + Lookup tables can be built using ColorMap or GradientWidget. useRGBA If True, the data is returned in RGBA order (useful for building OpenGL textures). The default is False, which returns in ARGB order for use with QImage - (Note that 'ARGB' is a term used by the Qt documentation; the _actual_ order + (Note that 'ARGB' is a term used by the Qt documentation; the *actual* order is BGRA). ============== ================================================================================== """ profile = debug.Profiler() + + if data.ndim not in (2, 3): + raise TypeError("data must be 2D or 3D") + if data.ndim == 3 and data.shape[2] > 4: + raise TypeError("data.shape[2] must be <= 4") if lut is not None and not isinstance(lut, np.ndarray): lut = np.array(lut) - if levels is not None and not isinstance(levels, np.ndarray): - levels = np.array(levels) - if levels is not None: - if levels.ndim == 1: - if len(levels) != 2: - raise Exception('levels argument must have length 2') - elif levels.ndim == 2: - if lut is not None and lut.ndim > 1: - raise Exception('Cannot make ARGB data when bot levels and lut have ndim > 2') - if levels.shape != (data.shape[-1], 2): - raise Exception('levels must have shape (data.shape[-1], 2)') + if levels is None: + # automatically decide levels based on data dtype + if data.dtype.kind == 'u': + levels = np.array([0, 2**(data.itemsize*8)-1]) + elif data.dtype.kind == 'i': + s = 2**(data.itemsize*8 - 1) + levels = np.array([-s, s-1]) else: - print(levels) - raise Exception("levels argument must be 1D or 2D.") + raise Exception('levels argument is required for float input types') + if not isinstance(levels, np.ndarray): + levels = np.array(levels) + if levels.ndim == 1: + if levels.shape[0] != 2: + raise Exception('levels argument must have length 2') + elif levels.ndim == 2: + if lut is not None and lut.ndim > 1: + raise Exception('Cannot make ARGB data when both levels and lut have ndim > 2') + 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 (got shape=%s)." % repr(levels.shape)) profile() + # Decide on maximum scaled value if scale is None: if lut is not None: - scale = lut.shape[0] + scale = lut.shape[0] - 1 else: scale = 255. - ## Apply levels if given + # Decide on the dtype we want after scaling + if lut is None: + dtype = np.ubyte + else: + dtype = np.min_scalar_type(lut.shape[0]-1) + + # Apply levels if given if levels is not None: - if isinstance(levels, np.ndarray) and levels.ndim == 2: - ## we are going to rescale each channel independently + # we are going to rescale each channel independently if levels.shape[0] != data.shape[-1]: raise Exception("When rescaling multi-channel data, there must be the same number of levels as channels (data.shape[-1] == levels.shape[0])") newData = np.empty(data.shape, dtype=int) @@ -931,20 +997,20 @@ def makeARGB(data, lut=None, levels=None, scale=None, useRGBA=False): minVal, maxVal = levels[i] if minVal == maxVal: maxVal += 1e-16 - newData[...,i] = rescaleData(data[...,i], scale/(maxVal-minVal), minVal, dtype=int) + newData[...,i] = rescaleData(data[...,i], scale/(maxVal-minVal), minVal, dtype=dtype) data = newData else: + # Apply level scaling unless it would have no effect on the data minVal, maxVal = levels - if minVal == maxVal: - maxVal += 1e-16 - if maxVal == minVal: - data = rescaleData(data, 1, minVal, dtype=int) - else: - data = rescaleData(data, scale/(maxVal-minVal), minVal, dtype=int) + if minVal != 0 or maxVal != scale: + if minVal == maxVal: + maxVal += 1e-16 + data = rescaleData(data, scale/(maxVal-minVal), minVal, dtype=dtype) + profile() - ## apply LUT if given + # apply LUT if given if lut is not None: data = applyLookupTable(data, lut) else: @@ -953,16 +1019,18 @@ def makeARGB(data, lut=None, levels=None, scale=None, useRGBA=False): profile() - ## copy data into ARGB ordered array + # this will be the final image array imgData = np.empty(data.shape[:2]+(4,), dtype=np.ubyte) profile() + # decide channel order if useRGBA: - order = [0,1,2,3] ## array comes out RGBA + order = [0,1,2,3] # array comes out RGBA else: - order = [2,1,0,3] ## for some reason, the colors line up as BGR in the final image. + order = [2,1,0,3] # for some reason, the colors line up as BGR in the final image. + # copy data into image array if data.ndim == 2: # This is tempting: # imgData[..., :3] = data[..., np.newaxis] @@ -977,7 +1045,8 @@ def makeARGB(data, lut=None, levels=None, scale=None, useRGBA=False): imgData[..., i] = data[..., order[i]] profile() - + + # add opaque alpha channel if needed if data.ndim == 2 or data.shape[2] == 3: alpha = False imgData[..., 3] = 255 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) diff --git a/pyqtgraph/tests/test_functions.py b/pyqtgraph/tests/test_functions.py index 4ef2daf0..6852bb2a 100644 --- a/pyqtgraph/tests/test_functions.py +++ b/pyqtgraph/tests/test_functions.py @@ -111,6 +111,183 @@ def test_subArray(): assert np.all(bb == cc) +def test_rescaleData(): + dtypes = map(np.dtype, ('ubyte', 'uint16', 'byte', 'int16', 'int', 'float')) + for dtype1 in dtypes: + for dtype2 in dtypes: + data = (np.random.random(size=10) * 2**32 - 2**31).astype(dtype1) + for scale, offset in [(10, 0), (10., 0.), (1, -50), (0.2, 0.5), (0.001, 0)]: + if dtype2.kind in 'iu': + lim = np.iinfo(dtype2) + lim = lim.min, lim.max + else: + lim = (-np.inf, np.inf) + s1 = np.clip(float(scale) * (data-float(offset)), *lim).astype(dtype2) + s2 = pg.rescaleData(data, scale, offset, dtype2) + assert s1.dtype == s2.dtype + if dtype2.kind in 'iu': + assert np.all(s1 == s2) + else: + assert np.allclose(s1, s2) + + +def test_makeARGB(): + # Many parameters to test here: + # * data dtype (ubyte, uint16, float, others) + # * data ndim (2 or 3) + # * levels (None, 1D, or 2D) + # * lut dtype + # * lut size + # * lut ndim (1 or 2) + # * useRGBA argument + # Need to check that all input values map to the correct output values, especially + # at and beyond the edges of the level range. + + def checkArrays(a, b): + # because py.test output is difficult to read for arrays + if not np.all(a == b): + comp = [] + for i in range(a.shape[0]): + if a.shape[1] > 1: + comp.append('[') + for j in range(a.shape[1]): + m = a[i,j] == b[i,j] + comp.append('%d,%d %s %s %s%s' % + (i, j, str(a[i,j]).ljust(15), str(b[i,j]).ljust(15), + m, ' ********' if not np.all(m) else '')) + if a.shape[1] > 1: + comp.append(']') + raise Exception("arrays do not match:\n%s" % '\n'.join(comp)) + + def checkImage(img, check, alpha, alphaCheck): + assert img.dtype == np.ubyte + assert alpha is alphaCheck + if alpha is False: + checkArrays(img[..., 3], 255) + + if np.isscalar(check) or check.ndim == 3: + checkArrays(img[..., :3], check) + elif check.ndim == 2: + checkArrays(img[..., :3], check[..., np.newaxis]) + elif check.ndim == 1: + checkArrays(img[..., :3], check[..., np.newaxis, np.newaxis]) + else: + raise Exception('invalid check array ndim') + + # uint8 data tests + + im1 = np.arange(256).astype('ubyte').reshape(256, 1) + im2, alpha = pg.makeARGB(im1, levels=(0, 255)) + checkImage(im2, im1, alpha, False) + + im3, alpha = pg.makeARGB(im1, levels=(0.0, 255.0)) + checkImage(im3, im1, alpha, False) + + im4, alpha = pg.makeARGB(im1, levels=(255, 0)) + checkImage(im4, 255-im1, alpha, False) + + im5, alpha = pg.makeARGB(np.concatenate([im1]*3, axis=1), levels=[(0, 255), (0.0, 255.0), (255, 0)]) + checkImage(im5, np.concatenate([im1, im1, 255-im1], axis=1), alpha, False) + + + im2, alpha = pg.makeARGB(im1, levels=(128,383)) + checkImage(im2[:128], 0, alpha, False) + checkImage(im2[128:], im1[:128], alpha, False) + + + # uint8 data + uint8 LUT + lut = np.arange(256)[::-1].astype(np.uint8) + im2, alpha = pg.makeARGB(im1, lut=lut) + checkImage(im2, lut, alpha, False) + + # lut larger than maxint + lut = np.arange(511).astype(np.uint8) + im2, alpha = pg.makeARGB(im1, lut=lut) + checkImage(im2, lut[::2], alpha, False) + + # lut smaller than maxint + lut = np.arange(128).astype(np.uint8) + im2, alpha = pg.makeARGB(im1, lut=lut) + checkImage(im2, np.linspace(0, 127, 256).astype('ubyte'), alpha, False) + + # lut + levels + lut = np.arange(256)[::-1].astype(np.uint8) + im2, alpha = pg.makeARGB(im1, lut=lut, levels=[-128, 384]) + checkImage(im2, np.linspace(192, 65.5, 256).astype('ubyte'), alpha, False) + + im2, alpha = pg.makeARGB(im1, lut=lut, levels=[64, 192]) + checkImage(im2, np.clip(np.linspace(385.5, -126.5, 256), 0, 255).astype('ubyte'), alpha, False) + + # uint8 data + uint16 LUT + lut = np.arange(4096)[::-1].astype(np.uint16) // 16 + im2, alpha = pg.makeARGB(im1, lut=lut) + checkImage(im2, np.arange(256)[::-1].astype('ubyte'), alpha, False) + + # uint8 data + float LUT + lut = np.linspace(10., 137., 256) + im2, alpha = pg.makeARGB(im1, lut=lut) + checkImage(im2, lut.astype('ubyte'), alpha, False) + + # uint8 data + 2D LUT + lut = np.zeros((256, 3), dtype='ubyte') + lut[:,0] = np.arange(256) + lut[:,1] = np.arange(256)[::-1] + lut[:,2] = 7 + im2, alpha = pg.makeARGB(im1, lut=lut) + checkImage(im2, lut[:,None,::-1], alpha, False) + + # check useRGBA + im2, alpha = pg.makeARGB(im1, lut=lut, useRGBA=True) + checkImage(im2, lut[:,None,:], alpha, False) + + + # uint16 data tests + im1 = np.arange(0, 2**16, 256).astype('uint16')[:, None] + im2, alpha = pg.makeARGB(im1, levels=(512, 2**16)) + checkImage(im2, np.clip(np.linspace(-2, 253, 256), 0, 255).astype('ubyte'), alpha, False) + + lut = (np.arange(512, 2**16)[::-1] // 256).astype('ubyte') + im2, alpha = pg.makeARGB(im1, lut=lut, levels=(512, 2**16-256)) + checkImage(im2, np.clip(np.linspace(257, 2, 256), 0, 255).astype('ubyte'), alpha, False) + + + # float data tests + im1 = np.linspace(1.0, 17.0, 256)[:, None] + im2, alpha = pg.makeARGB(im1, levels=(5.0, 13.0)) + checkImage(im2, np.clip(np.linspace(-128, 383, 256), 0, 255).astype('ubyte'), alpha, False) + + lut = (np.arange(1280)[::-1] // 10).astype('ubyte') + im2, alpha = pg.makeARGB(im1, lut=lut, levels=(1, 17)) + checkImage(im2, np.linspace(127.5, 0, 256).astype('ubyte'), alpha, False) + + + # test sanity checks + class AssertExc(object): + def __init__(self, exc=Exception): + self.exc = exc + def __enter__(self): + return self + def __exit__(self, *args): + assert args[0] is self.exc, "Should have raised %s (got %s)" % (self.exc, args[0]) + return True + + with AssertExc(TypeError): # invalid image shape + pg.makeARGB(np.zeros((2,), dtype='float')) + with AssertExc(TypeError): # invalid image shape + pg.makeARGB(np.zeros((2,2,7), dtype='float')) + with AssertExc(): # float images require levels arg + pg.makeARGB(np.zeros((2,2), dtype='float')) + with AssertExc(): # bad levels arg + pg.makeARGB(np.zeros((2,2), dtype='float'), levels=[1]) + with AssertExc(): # bad levels arg + pg.makeARGB(np.zeros((2,2), dtype='float'), levels=[1,2,3]) + with AssertExc(): # can't mix 3-channel levels and LUT + pg.makeARGB(np.zeros((2,2)), lut=np.zeros((10,3), dtype='ubyte'), levels=[(0,1)]*3) + with AssertExc(): # multichannel levels must have same number of channels as image + pg.makeARGB(np.zeros((2,2,3), dtype='float'), levels=[(1,2)]*4) + with AssertExc(): # 3d levels not allowed + pg.makeARGB(np.zeros((2,2,3), dtype='float'), levels=np.zeros([3, 2, 2])) + if __name__ == '__main__': test_interpolateArray() \ No newline at end of file