diff --git a/__init__.py b/__init__.py index 77b7c590..588de0cd 100644 --- a/__init__.py +++ b/__init__.py @@ -48,8 +48,8 @@ else: CONFIG_OPTIONS = { 'useOpenGL': useOpenGL, ## by default, this is platform-dependent (see widgets/GraphicsView). Set to True or False to explicitly enable/disable opengl. 'leftButtonPan': True, ## if false, left button drags a rubber band for zooming in viewbox - 'foreground': (150, 150, 150), ## default foreground color for axes, labels, etc. - 'background': (0, 0, 0), ## default background for GraphicsWidget + 'foreground': 'd', ## default foreground color for axes, labels, etc. + 'background': 'k', ## default background for GraphicsWidget 'antialias': False, 'editorCommand': None, ## command used to invoke code editor from ConsoleWidgets 'useWeave': True, ## Use weave to speed up some operations, if it is available diff --git a/flowchart/Flowchart.py b/flowchart/Flowchart.py index 8d1ea4ce..27586040 100644 --- a/flowchart/Flowchart.py +++ b/flowchart/Flowchart.py @@ -58,14 +58,15 @@ def toposort(deps, nodes=None, seen=None, stack=None, depth=0): class Flowchart(Node): - sigFileLoaded = QtCore.Signal(object) sigFileSaved = QtCore.Signal(object) #sigOutputChanged = QtCore.Signal() ## inherited from Node sigChartLoaded = QtCore.Signal() - sigStateChanged = QtCore.Signal() + sigStateChanged = QtCore.Signal() # called when output is expected to have changed + sigChartChanged = QtCore.Signal(object, object, object) # called when nodes are added, removed, or renamed. + # (self, action, node) def __init__(self, terminals=None, name=None, filePath=None, library=None): self.library = library or LIBRARY @@ -218,6 +219,7 @@ class Flowchart(Node): node.sigClosed.connect(self.nodeClosed) node.sigRenamed.connect(self.nodeRenamed) node.sigOutputChanged.connect(self.nodeOutputChanged) + self.sigChartChanged.emit(self, 'add', node) def removeNode(self, node): node.close() @@ -237,11 +239,13 @@ class Flowchart(Node): node.sigOutputChanged.disconnect(self.nodeOutputChanged) except TypeError: pass + self.sigChartChanged.emit(self, 'remove', node) def nodeRenamed(self, node, oldName): del self._nodes[oldName] self._nodes[node.name()] = node self.widget().nodeRenamed(node, oldName) + self.sigChartChanged.emit(self, 'rename', node) def arrangeNodes(self): pass diff --git a/flowchart/library/Display.py b/flowchart/library/Display.py index 2c352fb2..642e6491 100644 --- a/flowchart/library/Display.py +++ b/flowchart/library/Display.py @@ -4,7 +4,7 @@ import weakref from ...Qt import QtCore, QtGui from ...graphicsItems.ScatterPlotItem import ScatterPlotItem from ...graphicsItems.PlotCurveItem import PlotCurveItem -from ... import PlotDataItem +from ... import PlotDataItem, ComboBox from .common import * import numpy as np @@ -16,7 +16,9 @@ class PlotWidgetNode(Node): def __init__(self, name): Node.__init__(self, name, terminals={'In': {'io': 'in', 'multi': True}}) - self.plot = None + self.plot = None # currently selected plot + self.plots = {} # list of available plots user may select from + self.ui = None self.items = {} def disconnected(self, localTerm, remoteTerm): @@ -26,16 +28,27 @@ class PlotWidgetNode(Node): def setPlot(self, plot): #print "======set plot" + if plot == self.plot: + return + + # clear data from previous plot + if self.plot is not None: + for vid in list(self.items.keys()): + self.plot.removeItem(self.items[vid]) + del self.items[vid] + self.plot = plot + self.updateUi() + self.update() self.sigPlotChanged.emit(self) def getPlot(self): return self.plot def process(self, In, display=True): - if display: - #self.plot.clearPlots() + if display and self.plot is not None: items = set() + # Add all new input items to selected plot for name, vals in In.items(): if vals is None: continue @@ -45,14 +58,13 @@ class PlotWidgetNode(Node): for val in vals: vid = id(val) if vid in self.items and self.items[vid].scene() is self.plot.scene(): + # Item is already added to the correct scene + # possible bug: what if two plots occupy the same scene? (should + # rarely be a problem because items are removed from a plot before + # switching). items.add(vid) else: - #if isinstance(val, PlotCurveItem): - #self.plot.addItem(val) - #item = val - #if isinstance(val, ScatterPlotItem): - #self.plot.addItem(val) - #item = val + # Add the item to the plot, or generate a new item if needed. if isinstance(val, QtGui.QGraphicsItem): self.plot.addItem(val) item = val @@ -60,22 +72,48 @@ class PlotWidgetNode(Node): item = self.plot.plot(val) self.items[vid] = item items.add(vid) + + # Any left-over items that did not appear in the input must be removed for vid in list(self.items.keys()): if vid not in items: - #print "remove", self.items[vid] self.plot.removeItem(self.items[vid]) del self.items[vid] def processBypassed(self, args): + if self.plot is None: + return for item in list(self.items.values()): self.plot.removeItem(item) self.items = {} - #def setInput(self, **args): - #for k in args: - #self.plot.plot(args[k]) + def ctrlWidget(self): + if self.ui is None: + self.ui = ComboBox() + self.ui.currentIndexChanged.connect(self.plotSelected) + self.updateUi() + return self.ui - + def plotSelected(self, index): + self.setPlot(self.ui.value()) + + def setPlotList(self, plots): + """ + Specify the set of plots (PlotWidget or PlotItem) that the user may + select from. + + *plots* must be a dictionary of {name: plot} pairs. + """ + self.plots = plots + self.updateUi() + + def updateUi(self): + # sets list and automatically preserves previous selection + self.ui.setItems(self.plots) + try: + self.ui.setValue(self.plot) + except ValueError: + pass + class CanvasNode(Node): """Connection to a Canvas widget.""" diff --git a/functions.py b/functions.py index 12588cf1..db01b6b9 100644 --- a/functions.py +++ b/functions.py @@ -7,15 +7,19 @@ Distributed under MIT/X11 license. See license.txt for more infomation. from __future__ import division from .python2_3 import asUnicode +from .Qt import QtGui, QtCore, USE_PYSIDE Colors = { - 'b': (0,0,255,255), - 'g': (0,255,0,255), - 'r': (255,0,0,255), - 'c': (0,255,255,255), - 'm': (255,0,255,255), - 'y': (255,255,0,255), - 'k': (0,0,0,255), - 'w': (255,255,255,255), + 'b': QtGui.QColor(0,0,255,255), + 'g': QtGui.QColor(0,255,0,255), + 'r': QtGui.QColor(255,0,0,255), + 'c': QtGui.QColor(0,255,255,255), + 'm': QtGui.QColor(255,0,255,255), + 'y': QtGui.QColor(255,255,0,255), + 'k': QtGui.QColor(0,0,0,255), + 'w': QtGui.QColor(255,255,255,255), + 'd': QtGui.QColor(150,150,150,255), + 'l': QtGui.QColor(200,200,200,255), + 's': QtGui.QColor(100,100,150,255), } SI_PREFIXES = asUnicode('yzafpnµm kMGTPEZY') @@ -168,17 +172,15 @@ def mkColor(*args): """ err = 'Not sure how to make a color from "%s"' % str(args) if len(args) == 1: - if isinstance(args[0], QtGui.QColor): - return QtGui.QColor(args[0]) - elif isinstance(args[0], float): - r = g = b = int(args[0] * 255) - a = 255 - elif isinstance(args[0], basestring): + if isinstance(args[0], basestring): c = args[0] if c[0] == '#': c = c[1:] if len(c) == 1: - (r, g, b, a) = Colors[c] + try: + return Colors[c] + except KeyError: + raise Exception('No color named "%s"' % c) if len(c) == 3: r = int(c[0]*2, 16) g = int(c[1]*2, 16) @@ -199,6 +201,11 @@ def mkColor(*args): g = int(c[2:4], 16) b = int(c[4:6], 16) a = int(c[6:8], 16) + elif isinstance(args[0], QtGui.QColor): + return QtGui.QColor(args[0]) + elif isinstance(args[0], float): + r = g = b = int(args[0] * 255) + a = 255 elif hasattr(args[0], '__len__'): if len(args[0]) == 3: (r, g, b) = args[0] @@ -282,7 +289,7 @@ def mkPen(*args, **kargs): color = args if color is None: - color = mkColor(200, 200, 200) + color = mkColor('l') if hsv is not None: color = hsvColor(*hsv) else: @@ -821,7 +828,10 @@ def makeARGB(data, lut=None, levels=None, scale=None, useRGBA=False): minVal, maxVal = levels if minVal == maxVal: maxVal += 1e-16 - data = rescaleData(data, scale/(maxVal-minVal), minVal, dtype=int) + if maxVal == minVal: + data = rescaleData(data, 1, minVal, dtype=int) + else: + data = rescaleData(data, scale/(maxVal-minVal), minVal, dtype=int) profile() diff --git a/graphicsItems/AxisItem.py b/graphicsItems/AxisItem.py index 1d0b36b6..b483f0e4 100644 --- a/graphicsItems/AxisItem.py +++ b/graphicsItems/AxisItem.py @@ -277,11 +277,11 @@ class AxisItem(GraphicsWidget): if pen == None, the default will be used (see :func:`setConfigOption `) """ - self._pen = pen self.picture = None if pen is None: pen = getConfigOption('foreground') - self.labelStyle['color'] = '#' + fn.colorStr(fn.mkPen(pen).color())[:6] + self._pen = fn.mkPen(pen) + self.labelStyle['color'] = '#' + fn.colorStr(self._pen.color())[:6] self.setLabel() self.update() @@ -458,8 +458,7 @@ class AxisItem(GraphicsWidget): return [] ## decide optimal minor tick spacing in pixels (this is just aesthetics) - pixelSpacing = size / np.log(size) - optimalTickCount = max(2., size / pixelSpacing) + optimalTickCount = max(2., np.log(size)) ## optimal minor tick spacing optimalSpacing = dif / optimalTickCount @@ -795,7 +794,7 @@ class AxisItem(GraphicsWidget): if s is None: rects.append(None) else: - br = p.boundingRect(QtCore.QRectF(0, 0, 100, 100), QtCore.Qt.AlignCenter, str(s)) + br = p.boundingRect(QtCore.QRectF(0, 0, 100, 100), QtCore.Qt.AlignCenter, asUnicode(s)) ## boundingRect is usually just a bit too large ## (but this probably depends on per-font metrics?) br.setHeight(br.height() * 0.8) @@ -830,7 +829,7 @@ class AxisItem(GraphicsWidget): vstr = strings[j] if vstr is None: ## this tick was ignored because it is out of bounds continue - vstr = str(vstr) + vstr = asUnicode(vstr) x = tickPositions[i][j] #textRect = p.boundingRect(QtCore.QRectF(0, 0, 100, 100), QtCore.Qt.AlignCenter, vstr) textRect = rects[j] diff --git a/graphicsItems/FillBetweenItem.py b/graphicsItems/FillBetweenItem.py index 13e5fa6b..3cf33acd 100644 --- a/graphicsItems/FillBetweenItem.py +++ b/graphicsItems/FillBetweenItem.py @@ -1,24 +1,70 @@ from ..Qt import QtGui from .. import functions as fn +from .PlotDataItem import PlotDataItem +from .PlotCurveItem import PlotCurveItem class FillBetweenItem(QtGui.QGraphicsPathItem): """ GraphicsItem filling the space between two PlotDataItems. """ - def __init__(self, p1, p2, brush=None): + def __init__(self, curve1=None, curve2=None, brush=None): QtGui.QGraphicsPathItem.__init__(self) - self.p1 = p1 - self.p2 = p2 - p1.sigPlotChanged.connect(self.updatePath) - p2.sigPlotChanged.connect(self.updatePath) + self.curves = None + if curve1 is not None and curve2 is not None: + self.setCurves(curve1, curve2) + elif curve1 is not None or curve2 is not None: + raise Exception("Must specify two curves to fill between.") + if brush is not None: self.setBrush(fn.mkBrush(brush)) - self.setZValue(min(p1.zValue(), p2.zValue())-1) + self.updatePath() + + def setCurves(self, curve1, curve2): + """Set the curves to fill between. + + Arguments must be instances of PlotDataItem or PlotCurveItem.""" + + if self.curves is not None: + for c in self.curves: + try: + c.sigPlotChanged.disconnect(self.curveChanged) + except TypeError: + pass + + curves = [curve1, curve2] + for c in curves: + if not isinstance(c, PlotDataItem) and not isinstance(c, PlotCurveItem): + raise TypeError("Curves must be PlotDataItem or PlotCurveItem.") + self.curves = curves + curve1.sigPlotChanged.connect(self.curveChanged) + curve2.sigPlotChanged.connect(self.curveChanged) + self.setZValue(min(curve1.zValue(), curve2.zValue())-1) + self.curveChanged() + + def setBrush(self, *args, **kwds): + """Change the fill brush. Acceps the same arguments as pg.mkBrush()""" + QtGui.QGraphicsPathItem.setBrush(self, fn.mkBrush(*args, **kwds)) + + def curveChanged(self): self.updatePath() def updatePath(self): - p1 = self.p1.curve.path - p2 = self.p2.curve.path + if self.curves is None: + self.setPath(QtGui.QPainterPath()) + return + paths = [] + for c in self.curves: + if isinstance(c, PlotDataItem): + paths.append(c.curve.getPath()) + elif isinstance(c, PlotCurveItem): + paths.append(c.getPath()) + path = QtGui.QPainterPath() - path.addPolygon(p1.toSubpathPolygons()[0] + p2.toReversed().toSubpathPolygons()[0]) + p1 = paths[0].toSubpathPolygons() + p2 = paths[1].toReversed().toSubpathPolygons() + if len(p1) == 0 or len(p2) == 0: + self.setPath(QtGui.QPainterPath()) + return + + path.addPolygon(p1[0] + p2[0]) self.setPath(path) diff --git a/graphicsItems/ImageItem.py b/graphicsItems/ImageItem.py index 120312ad..7c80859d 100644 --- a/graphicsItems/ImageItem.py +++ b/graphicsItems/ImageItem.py @@ -1,3 +1,5 @@ +from __future__ import division + from ..Qt import QtGui, QtCore import numpy as np import collections @@ -287,15 +289,45 @@ class ImageItem(GraphicsObject): self.render() self.qimage.save(fileName, *args) - def getHistogram(self, bins=500, step=3): + def getHistogram(self, bins='auto', step='auto', targetImageSize=200, targetHistogramSize=500, **kwds): """Returns x and y arrays containing the histogram values for the current image. - The step argument causes pixels to be skipped when computing the histogram to save time. + For an explanation of the return format, see numpy.histogram(). + + The *step* argument causes pixels to be skipped when computing the histogram to save time. + If *step* is 'auto', then a step is chosen such that the analyzed data has + dimensions roughly *targetImageSize* for each axis. + + The *bins* argument and any extra keyword arguments are passed to + np.histogram(). If *bins* is 'auto', then a bin number is automatically + chosen based on the image characteristics: + + * Integer images will have approximately *targetHistogramSize* bins, + with each bin having an integer width. + * All other types will have *targetHistogramSize* bins. + This method is also used when automatically computing levels. """ if self.image is None: return None,None - stepData = self.image[::step, ::step] - hist = np.histogram(stepData, bins=bins) + if step == 'auto': + step = (np.ceil(self.image.shape[0] / targetImageSize), + np.ceil(self.image.shape[1] / targetImageSize)) + if np.isscalar(step): + step = (step, step) + stepData = self.image[::step[0], ::step[1]] + + if bins == 'auto': + if stepData.dtype.kind in "ui": + mn = stepData.min() + mx = stepData.max() + step = np.ceil((mx-mn) / 500.) + bins = np.arange(mn, mx+1.01*step, step, dtype=np.int) + else: + bins = 500 + + kwds['bins'] = bins + hist = np.histogram(stepData, **kwds) + return hist[1][:-1], hist[0] def setPxMode(self, b): diff --git a/graphicsItems/LegendItem.py b/graphicsItems/LegendItem.py index a1228789..ba6a6897 100644 --- a/graphicsItems/LegendItem.py +++ b/graphicsItems/LegendItem.py @@ -3,7 +3,7 @@ from .LabelItem import LabelItem from ..Qt import QtGui, QtCore from .. import functions as fn from ..Point import Point -from .ScatterPlotItem import ScatterPlotItem +from .ScatterPlotItem import ScatterPlotItem, drawSymbol from .PlotDataItem import PlotDataItem from .GraphicsWidgetAnchor import GraphicsWidgetAnchor __all__ = ['LegendItem'] @@ -167,7 +167,7 @@ class ItemSample(GraphicsWidget): size = opts['size'] p.translate(10,10) - path = ScatterPlotItem.drawSymbol(p, symbol, size, pen, brush) + path = drawSymbol(p, symbol, size, pen, brush) diff --git a/graphicsItems/PlotCurveItem.py b/graphicsItems/PlotCurveItem.py index b2beaa99..ea337100 100644 --- a/graphicsItems/PlotCurveItem.py +++ b/graphicsItems/PlotCurveItem.py @@ -393,16 +393,18 @@ class PlotCurveItem(GraphicsObject): if self.path is None: x,y = self.getData() if x is None or len(x) == 0 or y is None or len(y) == 0: - return QtGui.QPainterPath() - self.path = self.generatePath(*self.getData()) + self.path = QtGui.QPainterPath() + else: + self.path = self.generatePath(*self.getData()) self.fillPath = None self._mouseShape = None + return self.path @debug.warnOnException ## raising an exception here causes crash def paint(self, p, opt, widget): profiler = debug.Profiler() - if self.xData is None: + if self.xData is None or len(self.xData) == 0: return if HAVE_OPENGL and getConfigOption('enableExperimental') and isinstance(widget, QtOpenGL.QGLWidget): diff --git a/graphicsItems/PlotDataItem.py b/graphicsItems/PlotDataItem.py index e8c4145c..8baab719 100644 --- a/graphicsItems/PlotDataItem.py +++ b/graphicsItems/PlotDataItem.py @@ -15,7 +15,7 @@ class PlotDataItem(GraphicsObject): GraphicsItem for displaying plot curves, scatter plots, or both. While it is possible to use :class:`PlotCurveItem ` or :class:`ScatterPlotItem ` individually, this class - provides a unified interface to both. Inspances of :class:`PlotDataItem` are + provides a unified interface to both. Instances of :class:`PlotDataItem` are usually created by plot() methods such as :func:`pyqtgraph.plot` and :func:`PlotItem.plot() `. @@ -531,15 +531,17 @@ class PlotDataItem(GraphicsObject): ## downsampling is expensive; delay until after clipping. if self.opts['clipToView']: - # this option presumes that x-values have uniform spacing - range = self.viewRect() - if range is not None: - dx = float(x[-1]-x[0]) / (len(x)-1) - # clip to visible region extended by downsampling value - x0 = np.clip(int((range.left()-x[0])/dx)-1*ds , 0, len(x)-1) - x1 = np.clip(int((range.right()-x[0])/dx)+2*ds , 0, len(x)-1) - x = x[x0:x1] - y = y[x0:x1] + view = self.getViewBox() + if view is None or not view.autoRangeEnabled()[0]: + # this option presumes that x-values have uniform spacing + range = self.viewRect() + if range is not None: + dx = float(x[-1]-x[0]) / (len(x)-1) + # clip to visible region extended by downsampling value + x0 = np.clip(int((range.left()-x[0])/dx)-1*ds , 0, len(x)-1) + x1 = np.clip(int((range.right()-x[0])/dx)+2*ds , 0, len(x)-1) + x = x[x0:x1] + y = y[x0:x1] if ds > 1: if self.opts['downsampleMethod'] == 'subsample': diff --git a/graphicsItems/PlotItem/PlotItem.py b/graphicsItems/PlotItem/PlotItem.py index baff1aa9..575a1599 100644 --- a/graphicsItems/PlotItem/PlotItem.py +++ b/graphicsItems/PlotItem/PlotItem.py @@ -95,7 +95,6 @@ class PlotItem(GraphicsWidget): lastFileDir = None - managers = {} def __init__(self, parent=None, name=None, labels=None, title=None, viewBox=None, axisItems=None, enableMenu=True, **kargs): """ @@ -369,28 +368,6 @@ class PlotItem(GraphicsWidget): self.scene().removeItem(self.vb) self.vb = None - ## causes invalid index errors: - #for i in range(self.layout.count()): - #self.layout.removeAt(i) - - #for p in self.proxies: - #try: - #p.setWidget(None) - #except RuntimeError: - #break - #self.scene().removeItem(p) - #self.proxies = [] - - #self.menuAction.releaseWidget(self.menuAction.defaultWidget()) - #self.menuAction.setParent(None) - #self.menuAction = None - - #if self.manager is not None: - #self.manager.sigWidgetListChanged.disconnect(self.updatePlotList) - #self.manager.removeWidget(self.name) - #else: - #print "no manager" - def registerPlot(self, name): ## for backward compatibility self.vb.register(name) diff --git a/graphicsItems/ROI.py b/graphicsItems/ROI.py index 0dee2fd4..b99465b5 100644 --- a/graphicsItems/ROI.py +++ b/graphicsItems/ROI.py @@ -1623,9 +1623,9 @@ class PolyLineROI(ROI): if pos is None: pos = [0,0] - ROI.__init__(self, pos, size=[1,1], **args) self.closed = closed self.segments = [] + ROI.__init__(self, pos, size=[1,1], **args) for p in positions: self.addFreeHandle(p) @@ -1750,6 +1750,10 @@ class PolyLineROI(ROI): shape[axes[1]] = sliced.shape[axes[1]] return sliced * mask.reshape(shape) + def setPen(self, *args, **kwds): + ROI.setPen(self, *args, **kwds) + for seg in self.segments: + seg.setPen(*args, **kwds) class LineSegmentROI(ROI): """ diff --git a/graphicsItems/ScatterPlotItem.py b/graphicsItems/ScatterPlotItem.py index 926c9045..1c11fcf9 100644 --- a/graphicsItems/ScatterPlotItem.py +++ b/graphicsItems/ScatterPlotItem.py @@ -3,6 +3,11 @@ from ..Point import Point from .. import functions as fn from .GraphicsItem import GraphicsItem from .GraphicsObject import GraphicsObject +from itertools import starmap, repeat +try: + from itertools import imap +except ImportError: + imap = map import numpy as np import weakref from .. import getConfigOption @@ -86,11 +91,8 @@ class SymbolAtlas(object): pm = atlas.getAtlas() """ - class SymbolCoords(list): ## needed because lists are not allowed in weak references. - pass - def __init__(self): - # symbol key : [x, y, w, h] atlas coordinates + # symbol key : QRect(...) coordinates where symbol can be found in atlas. # note that the coordinate list will always be the same list object as # long as the symbol is in the atlas, but the coordinates may # change if the atlas is rebuilt. @@ -101,28 +103,32 @@ class SymbolAtlas(object): self.atlasData = None # numpy array of atlas image self.atlas = None # atlas as QPixmap self.atlasValid = False + self.max_width=0 def getSymbolCoords(self, opts): """ Given a list of spot records, return an object representing the coordinates of that symbol within the atlas """ - coords = np.empty(len(opts), dtype=object) + sourceRect = np.empty(len(opts), dtype=object) + keyi = None + sourceRecti = None for i, rec in enumerate(opts): - symbol, size, pen, brush = rec['symbol'], rec['size'], rec['pen'], rec['brush'] - pen = fn.mkPen(pen) if not isinstance(pen, QtGui.QPen) else pen - brush = fn.mkBrush(brush) if not isinstance(pen, QtGui.QBrush) else brush - key = (symbol, size, fn.colorTuple(pen.color()), pen.widthF(), pen.style(), fn.colorTuple(brush.color())) - if key not in self.symbolMap: - newCoords = SymbolAtlas.SymbolCoords() - self.symbolMap[key] = newCoords - self.atlasValid = False - #try: - #self.addToAtlas(key) ## squeeze this into the atlas if there is room - #except: - #self.buildAtlas() ## otherwise, we need to rebuild - - coords[i] = self.symbolMap[key] - return coords + key = (rec[3], rec[2], id(rec[4]), id(rec[5])) # TODO: use string indexes? + if key == keyi: + sourceRect[i] = sourceRecti + else: + try: + sourceRect[i] = self.symbolMap[key] + except KeyError: + newRectSrc = QtCore.QRectF() + newRectSrc.pen = rec['pen'] + newRectSrc.brush = rec['brush'] + self.symbolMap[key] = newRectSrc + self.atlasValid = False + sourceRect[i] = newRectSrc + keyi = key + sourceRecti = newRectSrc + return sourceRect def buildAtlas(self): # get rendered array for all symbols, keep track of avg/max width @@ -130,15 +136,13 @@ class SymbolAtlas(object): avgWidth = 0.0 maxWidth = 0 images = [] - for key, coords in self.symbolMap.items(): - if len(coords) == 0: - pen = fn.mkPen(color=key[2], width=key[3], style=key[4]) - brush = fn.mkBrush(color=key[5]) - img = renderSymbol(key[0], key[1], pen, brush) + for key, sourceRect in self.symbolMap.items(): + if sourceRect.width() == 0: + img = renderSymbol(key[0], key[1], sourceRect.pen, sourceRect.brush) images.append(img) ## we only need this to prevent the images being garbage collected immediately arr = fn.imageToArray(img, copy=False, transpose=False) else: - (x,y,w,h) = self.symbolMap[key] + (y,x,h,w) = sourceRect.getRect() arr = self.atlasData[x:x+w, y:y+w] rendered[key] = arr w = arr.shape[0] @@ -169,17 +173,18 @@ class SymbolAtlas(object): x = 0 rowheight = h self.atlasRows.append([y, rowheight, 0]) - self.symbolMap[key][:] = x, y, w, h + self.symbolMap[key].setRect(y, x, h, w) x += w self.atlasRows[-1][2] = x height = y + rowheight self.atlasData = np.zeros((width, height, 4), dtype=np.ubyte) for key in symbols: - x, y, w, h = self.symbolMap[key] + y, x, h, w = self.symbolMap[key].getRect() self.atlasData[x:x+w, y:y+h] = rendered[key] self.atlas = None self.atlasValid = True + self.max_width = maxWidth def getAtlas(self): if not self.atlasValid: @@ -223,10 +228,9 @@ class ScatterPlotItem(GraphicsObject): GraphicsObject.__init__(self) self.picture = None # QPicture used for rendering when pxmode==False - self.fragments = None # fragment specification for pxmode; updated every time the view changes. self.fragmentAtlas = SymbolAtlas() - self.data = np.empty(0, dtype=[('x', float), ('y', float), ('size', float), ('symbol', object), ('pen', object), ('brush', object), ('data', object), ('fragCoords', object), ('item', object)]) + self.data = np.empty(0, dtype=[('x', float), ('y', float), ('size', float), ('symbol', object), ('pen', object), ('brush', object), ('data', object), ('item', object), ('sourceRect', object), ('targetRect', object), ('width', float)]) self.bounds = [None, None] ## caches data bounds self._maxSpotWidth = 0 ## maximum size of the scale-variant portion of all spots self._maxSpotPxWidth = 0 ## maximum size of the scale-invariant portion of all spots @@ -237,8 +241,8 @@ class ScatterPlotItem(GraphicsObject): 'name': None, } - self.setPen(200,200,200, update=False) - self.setBrush(100,100,150, update=False) + self.setPen(fn.mkPen(getConfigOption('foreground')), update=False) + self.setBrush(fn.mkBrush(100,100,150), update=False) self.setSymbol('o', update=False) self.setSize(7, update=False) profiler() @@ -388,6 +392,7 @@ class ScatterPlotItem(GraphicsObject): self.setPointData(kargs['data'], dataSet=newData) self.prepareGeometryChange() + self.informViewBoundsChanged() self.bounds = [None, None] self.invalidate() self.updateSpots(newData) @@ -396,12 +401,10 @@ class ScatterPlotItem(GraphicsObject): def invalidate(self): ## clear any cached drawing state self.picture = None - self.fragments = None self.update() def getData(self): - return self.data['x'], self.data['y'] - + return self.data['x'], self.data['y'] def setPoints(self, *args, **kargs): ##Deprecated; use setData @@ -434,7 +437,7 @@ class ScatterPlotItem(GraphicsObject): else: self.opts['pen'] = fn.mkPen(*args, **kargs) - dataSet['fragCoords'] = None + dataSet['sourceRect'] = None if update: self.updateSpots(dataSet) @@ -459,7 +462,7 @@ class ScatterPlotItem(GraphicsObject): self.opts['brush'] = fn.mkBrush(*args, **kargs) #self._spotPixmap = None - dataSet['fragCoords'] = None + dataSet['sourceRect'] = None if update: self.updateSpots(dataSet) @@ -482,7 +485,7 @@ class ScatterPlotItem(GraphicsObject): self.opts['symbol'] = symbol self._spotPixmap = None - dataSet['fragCoords'] = None + dataSet['sourceRect'] = None if update: self.updateSpots(dataSet) @@ -505,7 +508,7 @@ class ScatterPlotItem(GraphicsObject): self.opts['size'] = size self._spotPixmap = None - dataSet['fragCoords'] = None + dataSet['sourceRect'] = None if update: self.updateSpots(dataSet) @@ -537,22 +540,26 @@ class ScatterPlotItem(GraphicsObject): def updateSpots(self, dataSet=None): if dataSet is None: dataSet = self.data - self._maxSpotWidth = 0 - self._maxSpotPxWidth = 0 + invalidate = False - self.measureSpotSizes(dataSet) if self.opts['pxMode']: - mask = np.equal(dataSet['fragCoords'], None) + mask = np.equal(dataSet['sourceRect'], None) if np.any(mask): invalidate = True opts = self.getSpotOpts(dataSet[mask]) - coords = self.fragmentAtlas.getSymbolCoords(opts) - dataSet['fragCoords'][mask] = coords + sourceRect = self.fragmentAtlas.getSymbolCoords(opts) + dataSet['sourceRect'][mask] = sourceRect - #for rec in dataSet: - #if rec['fragCoords'] is None: - #invalidate = True - #rec['fragCoords'] = self.fragmentAtlas.getSymbolCoords(*self.getSpotOpts(rec)) + self.fragmentAtlas.getAtlas() # generate atlas so source widths are available. + + dataSet['width'] = np.array(list(imap(QtCore.QRectF.width, dataSet['sourceRect'])))/2 + dataSet['targetRect'] = None + self._maxSpotPxWidth = self.fragmentAtlas.max_width + else: + self._maxSpotWidth = 0 + self._maxSpotPxWidth = 0 + self.measureSpotSizes(dataSet) + if invalidate: self.invalidate() @@ -669,29 +676,42 @@ class ScatterPlotItem(GraphicsObject): self.prepareGeometryChange() GraphicsObject.viewTransformChanged(self) self.bounds = [None, None] - self.fragments = None - - def generateFragments(self): - tr = self.deviceTransform() - if tr is None: - return - pts = np.empty((2,len(self.data['x']))) - pts[0] = self.data['x'] - pts[1] = self.data['y'] - pts = fn.transformCoordinates(tr, pts) - self.fragments = [] - pts = np.clip(pts, -2**30, 2**30) ## prevent Qt segmentation fault. - ## Still won't be able to render correctly, though. - for i in xrange(len(self.data)): - rec = self.data[i] - pos = QtCore.QPointF(pts[0,i], pts[1,i]) - x,y,w,h = rec['fragCoords'] - rect = QtCore.QRectF(y, x, h, w) - self.fragments.append(QtGui.QPainter.PixmapFragment.create(pos, rect)) - + self.data['targetRect'] = None + def setExportMode(self, *args, **kwds): GraphicsObject.setExportMode(self, *args, **kwds) self.invalidate() + + + def mapPointsToDevice(self, pts): + # Map point locations to device + tr = self.deviceTransform() + if tr is None: + return None + + #pts = np.empty((2,len(self.data['x']))) + #pts[0] = self.data['x'] + #pts[1] = self.data['y'] + pts = fn.transformCoordinates(tr, pts) + pts -= self.data['width'] + pts = np.clip(pts, -2**30, 2**30) ## prevent Qt segmentation fault. + + return pts + + def getViewMask(self, pts): + # Return bool mask indicating all points that are within viewbox + # pts is expressed in *device coordiantes* + vb = self.getViewBox() + if vb is None: + return None + viewBounds = vb.mapRectToDevice(vb.boundingRect()) + w = self.data['width'] + mask = ((pts[0] + w > viewBounds.left()) & + (pts[0] - w < viewBounds.right()) & + (pts[1] + w > viewBounds.top()) & + (pts[1] - w < viewBounds.bottom())) ## remove out of view points + return mask + @debug.warnOnException ## raising an exception here causes crash def paint(self, p, *args): @@ -707,29 +727,44 @@ class ScatterPlotItem(GraphicsObject): scale = 1.0 if self.opts['pxMode'] is True: - atlas = self.fragmentAtlas.getAtlas() - #arr = fn.imageToArray(atlas.toImage(), copy=True) - #if hasattr(self, 'lastAtlas'): - #if np.any(self.lastAtlas != arr): - #print "Atlas changed:", arr - #self.lastAtlas = arr - - if self.fragments is None: - self.updateSpots() - self.generateFragments() - p.resetTransform() - if not USE_PYSIDE and self.opts['useCache'] and self._exportOpts is False: - p.drawPixmapFragments(self.fragments, atlas) - else: - p.setRenderHint(p.Antialiasing, aa) + # Map point coordinates to device + pts = np.vstack([self.data['x'], self.data['y']]) + pts = self.mapPointsToDevice(pts) + if pts is None: + return + + # Cull points that are outside view + viewMask = self.getViewMask(pts) + #pts = pts[:,mask] + #data = self.data[mask] + + if self.opts['useCache'] and self._exportOpts is False: + # Draw symbols from pre-rendered atlas + atlas = self.fragmentAtlas.getAtlas() - for i in range(len(self.data)): - rec = self.data[i] - frag = self.fragments[i] + # Update targetRects if necessary + updateMask = viewMask & np.equal(self.data['targetRect'], None) + if np.any(updateMask): + updatePts = pts[:,updateMask] + width = self.data[updateMask]['width']*2 + self.data['targetRect'][updateMask] = list(imap(QtCore.QRectF, updatePts[0,:], updatePts[1,:], width, width)) + + data = self.data[viewMask] + if USE_PYSIDE: + list(imap(p.drawPixmap, data['targetRect'], repeat(atlas), data['sourceRect'])) + else: + p.drawPixmapFragments(data['targetRect'].tolist(), data['sourceRect'].tolist(), atlas) + else: + # render each symbol individually + p.setRenderHint(p.Antialiasing, aa) + + data = self.data[viewMask] + pts = pts[:,viewMask] + for i, rec in enumerate(data): p.resetTransform() - p.translate(frag.x, frag.y) + p.translate(pts[0,i] + rec['width'], pts[1,i] + rec['width']) drawSymbol(p, *self.getSpotOpts(rec, scale)) else: if self.picture is None: @@ -891,7 +926,7 @@ class SpotItem(object): self._data['data'] = data def updateItem(self): - self._data['fragCoords'] = None + self._data['sourceRect'] = None self._plot.updateSpots(self._data.reshape(1)) self._plot.invalidate() diff --git a/graphicsItems/tests/ScatterPlotItem.py b/graphicsItems/tests/ScatterPlotItem.py new file mode 100644 index 00000000..ef8271bf --- /dev/null +++ b/graphicsItems/tests/ScatterPlotItem.py @@ -0,0 +1,23 @@ +import pyqtgraph as pg +import numpy as np +app = pg.mkQApp() +plot = pg.plot() +app.processEvents() + +# set view range equal to its bounding rect. +# This causes plots to look the same regardless of pxMode. +plot.setRange(rect=plot.boundingRect()) + + +def test_modes(): + for i, pxMode in enumerate([True, False]): + for j, useCache in enumerate([True, False]): + s = pg.ScatterPlotItem() + s.opts['useCache'] = useCache + plot.addItem(s) + s.setData(x=np.array([10,40,20,30])+i*100, y=np.array([40,60,10,30])+j*100, pxMode=pxMode) + s.addPoints(x=np.array([60, 70])+i*100, y=np.array([60, 70])+j*100, size=[20, 30]) + + +if __name__ == '__main__': + test_modes() diff --git a/imageview/ImageView.py b/imageview/ImageView.py index d4458a0e..c50a54c0 100644 --- a/imageview/ImageView.py +++ b/imageview/ImageView.py @@ -198,6 +198,7 @@ class ImageView(QtGui.QWidget): if not isinstance(img, np.ndarray): raise Exception("Image must be specified as ndarray.") self.image = img + self.imageDisp = None if xvals is not None: self.tVals = xvals diff --git a/widgets/ComboBox.py b/widgets/ComboBox.py index 72ac384f..f9983c97 100644 --- a/widgets/ComboBox.py +++ b/widgets/ComboBox.py @@ -1,41 +1,212 @@ from ..Qt import QtGui, QtCore from ..SignalProxy import SignalProxy - +from ..pgcollections import OrderedDict +from ..python2_3 import asUnicode class ComboBox(QtGui.QComboBox): """Extends QComboBox to add extra functionality. - - updateList() - updates the items in the comboBox while blocking signals, remembers and resets to the previous values if it's still in the list + + * Handles dict mappings -- user selects a text key, and the ComboBox indicates + the selected value. + * Requires item strings to be unique + * Remembers selected value if list is cleared and subsequently repopulated + * setItems() replaces the items in the ComboBox and blocks signals if the + value ultimately does not change. """ def __init__(self, parent=None, items=None, default=None): QtGui.QComboBox.__init__(self, parent) + self.currentIndexChanged.connect(self.indexChanged) + self._ignoreIndexChange = False - #self.value = default + self._chosenText = None + self._items = OrderedDict() if items is not None: - self.addItems(items) + self.setItems(items) if default is not None: self.setValue(default) def setValue(self, value): - ind = self.findText(value) + """Set the selected item to the first one having the given value.""" + text = None + for k,v in self._items.items(): + if v == value: + text = k + break + if text is None: + raise ValueError(value) + + self.setText(text) + + def setText(self, text): + """Set the selected item to the first one having the given text.""" + ind = self.findText(text) if ind == -1: - return + raise ValueError(text) #self.value = value - self.setCurrentIndex(ind) - - def updateList(self, items): - prevVal = str(self.currentText()) - try: + self.setCurrentIndex(ind) + + def value(self): + """ + If items were given as a list of strings, then return the currently + selected text. If items were given as a dict, then return the value + corresponding to the currently selected key. If the combo list is empty, + return None. + """ + if self.count() == 0: + return None + text = asUnicode(self.currentText()) + return self._items[text] + + def ignoreIndexChange(func): + # Decorator that prevents updates to self._chosenText + def fn(self, *args, **kwds): + prev = self._ignoreIndexChange + self._ignoreIndexChange = True + try: + ret = func(self, *args, **kwds) + finally: + self._ignoreIndexChange = prev + return ret + return fn + + def blockIfUnchanged(func): + # decorator that blocks signal emission during complex operations + # and emits currentIndexChanged only if the value has actually + # changed at the end. + def fn(self, *args, **kwds): + prevVal = self.value() + blocked = self.signalsBlocked() self.blockSignals(True) + try: + ret = func(self, *args, **kwds) + finally: + self.blockSignals(blocked) + + # only emit if the value has changed + if self.value() != prevVal: + self.currentIndexChanged.emit(self.currentIndex()) + + return ret + return fn + + @ignoreIndexChange + @blockIfUnchanged + def setItems(self, items): + """ + *items* may be a list or a dict. + If a dict is given, then the keys are used to populate the combo box + and the values will be used for both value() and setValue(). + """ + prevVal = self.value() + + self.blockSignals(True) + try: self.clear() self.addItems(items) - self.setValue(prevVal) - finally: self.blockSignals(False) - if str(self.currentText()) != prevVal: + # only emit if we were not able to re-set the original value + if self.value() != prevVal: self.currentIndexChanged.emit(self.currentIndex()) - \ No newline at end of file + + def items(self): + return self.items.copy() + + def updateList(self, items): + # for backward compatibility + return self.setItems(items) + + def indexChanged(self, index): + # current index has changed; need to remember new 'chosen text' + if self._ignoreIndexChange: + return + self._chosenText = asUnicode(self.currentText()) + + def setCurrentIndex(self, index): + QtGui.QComboBox.setCurrentIndex(self, index) + + def itemsChanged(self): + # try to set the value to the last one selected, if it is available. + if self._chosenText is not None: + try: + self.setText(self._chosenText) + except ValueError: + pass + + @ignoreIndexChange + def insertItem(self, *args): + raise NotImplementedError() + #QtGui.QComboBox.insertItem(self, *args) + #self.itemsChanged() + + @ignoreIndexChange + def insertItems(self, *args): + raise NotImplementedError() + #QtGui.QComboBox.insertItems(self, *args) + #self.itemsChanged() + + @ignoreIndexChange + def addItem(self, *args, **kwds): + # Need to handle two different function signatures for QComboBox.addItem + try: + if isinstance(args[0], basestring): + text = args[0] + if len(args) == 2: + value = args[1] + else: + value = kwds.get('value', text) + else: + text = args[1] + if len(args) == 3: + value = args[2] + else: + value = kwds.get('value', text) + + except IndexError: + raise TypeError("First or second argument of addItem must be a string.") + + if text in self._items: + raise Exception('ComboBox already has item named "%s".' % text) + + self._items[text] = value + QtGui.QComboBox.addItem(self, *args) + self.itemsChanged() + + def setItemValue(self, name, value): + if name not in self._items: + self.addItem(name, value) + else: + self._items[name] = value + + @ignoreIndexChange + @blockIfUnchanged + def addItems(self, items): + if isinstance(items, list): + texts = items + items = dict([(x, x) for x in items]) + elif isinstance(items, dict): + texts = list(items.keys()) + else: + raise TypeError("items argument must be list or dict (got %s)." % type(items)) + + for t in texts: + if t in self._items: + raise Exception('ComboBox already has item named "%s".' % t) + + + for k,v in items.items(): + self._items[k] = v + QtGui.QComboBox.addItems(self, list(texts)) + + self.itemsChanged() + + @ignoreIndexChange + def clear(self): + self._items = OrderedDict() + QtGui.QComboBox.clear(self) + self.itemsChanged() + diff --git a/widgets/tests/test_combobox.py b/widgets/tests/test_combobox.py new file mode 100644 index 00000000..f511331c --- /dev/null +++ b/widgets/tests/test_combobox.py @@ -0,0 +1,44 @@ +import pyqtgraph as pg +pg.mkQApp() + +def test_combobox(): + cb = pg.ComboBox() + items = {'a': 1, 'b': 2, 'c': 3} + cb.setItems(items) + cb.setValue(2) + assert str(cb.currentText()) == 'b' + assert cb.value() == 2 + + # Clear item list; value should be None + cb.clear() + assert cb.value() == None + + # Reset item list; value should be set automatically + cb.setItems(items) + assert cb.value() == 2 + + # Clear item list; repopulate with same names and new values + items = {'a': 4, 'b': 5, 'c': 6} + cb.clear() + cb.setItems(items) + assert cb.value() == 5 + + # Set list instead of dict + cb.setItems(list(items.keys())) + assert str(cb.currentText()) == 'b' + + cb.setValue('c') + assert cb.value() == str(cb.currentText()) + assert cb.value() == 'c' + + cb.setItemValue('c', 7) + assert cb.value() == 7 + + +if __name__ == '__main__': + cb = pg.ComboBox() + cb.show() + cb.setItems({'': None, 'a': 1, 'b': 2, 'c': 3}) + def fn(ind): + print("New value: %s" % cb.value()) + cb.currentIndexChanged.connect(fn) \ No newline at end of file