diff --git a/PlotData.py b/PlotData.py index 0bf13ca8..e5faadda 100644 --- a/PlotData.py +++ b/PlotData.py @@ -15,6 +15,7 @@ class PlotData(object): - removal of nan/inf values - option for single value shared by entire column - cached downsampling + - cached min / max / hasnan / isuniform """ def __init__(self): self.fields = {} diff --git a/Point.py b/Point.py index ea35d119..4d04f01c 100644 --- a/Point.py +++ b/Point.py @@ -80,6 +80,12 @@ class Point(QtCore.QPointF): def __div__(self, a): return self._math_('__div__', a) + def __truediv__(self, a): + return self._math_('__truediv__', a) + + def __rtruediv__(self, a): + return self._math_('__rtruediv__', a) + def __rpow__(self, a): return self._math_('__rpow__', a) @@ -146,4 +152,4 @@ class Point(QtCore.QPointF): return Point(self) def toQPoint(self): - return QtCore.QPoint(*self) \ No newline at end of file + return QtCore.QPoint(*self) diff --git a/SRTTransform.py b/SRTTransform.py index a861f940..efb24f60 100644 --- a/SRTTransform.py +++ b/SRTTransform.py @@ -130,11 +130,14 @@ class SRTTransform(QtGui.QTransform): self._state['angle'] = angle self.update() - def __div__(self, t): + def __truediv__(self, t): """A / B == B^-1 * A""" dt = t.inverted()[0] * self return SRTTransform(dt) + def __div__(self, t): + return self.__truediv__(t) + def __mul__(self, t): return SRTTransform(QtGui.QTransform.__mul__(self, t)) diff --git a/SRTTransform3D.py b/SRTTransform3D.py index 77583b5a..7d87dcb8 100644 --- a/SRTTransform3D.py +++ b/SRTTransform3D.py @@ -123,7 +123,6 @@ class SRTTransform3D(pg.Transform3D): m = self.matrix().reshape(4,4) ## translation is 4th column self._state['pos'] = m[:3,3] - ## scale is vector-length of first three columns scale = (m[:3,:3]**2).sum(axis=0)**0.5 ## see whether there is an inversion @@ -141,18 +140,30 @@ class SRTTransform3D(pg.Transform3D): print("Scale: %s" % str(scale)) print("Original matrix: %s" % str(m)) raise - eigIndex = np.argwhere(np.abs(evals-1) < 1e-7) + eigIndex = np.argwhere(np.abs(evals-1) < 1e-6) if len(eigIndex) < 1: print("eigenvalues: %s" % str(evals)) print("eigenvectors: %s" % str(evecs)) print("index: %s, %s" % (str(eigIndex), str(evals-1))) raise Exception("Could not determine rotation axis.") - axis = evecs[eigIndex[0,0]].real + axis = evecs[:,eigIndex[0,0]].real axis /= ((axis**2).sum())**0.5 self._state['axis'] = axis ## trace(r) == 2 cos(angle) + 1, so: - self._state['angle'] = np.arccos((r.trace()-1)*0.5) * 180 / np.pi + cos = (r.trace()-1)*0.5 ## this only gets us abs(angle) + + ## The off-diagonal values can be used to correct the angle ambiguity, + ## but we need to figure out which element to use: + axisInd = np.argmax(np.abs(axis)) + rInd,sign = [((1,2), -1), ((0,2), 1), ((0,1), -1)][axisInd] + + ## Then we have r-r.T = sin(angle) * 2 * sign * axis[axisInd]; + ## solve for sin(angle) + sin = (r-r.T)[rInd] / (2. * sign * axis[axisInd]) + + ## finally, we get the complete angle from arctan(sin/cos) + self._state['angle'] = np.arctan2(sin, cos) * 180 / np.pi if self._state['angle'] == 0: self._state['axis'] = (0,0,1) diff --git a/Vector.py b/Vector.py index e9c109d8..4b4fb02f 100644 --- a/Vector.py +++ b/Vector.py @@ -5,7 +5,7 @@ Copyright 2010 Luke Campagnola Distributed under MIT/X11 license. See license.txt for more infomation. """ -from .Qt import QtGui, QtCore +from .Qt import QtGui, QtCore, USE_PYSIDE import numpy as np class Vector(QtGui.QVector3D): @@ -33,7 +33,13 @@ class Vector(QtGui.QVector3D): def __len__(self): return 3 - + + def __add__(self, b): + # workaround for pyside bug. see https://bugs.launchpad.net/pyqtgraph/+bug/1223173 + if USE_PYSIDE and isinstance(b, QtGui.QVector3D): + b = Vector(b) + return QtGui.QVector3D.__add__(self, b) + #def __reduce__(self): #return (Point, (self.x(), self.y())) diff --git a/__init__.py b/__init__.py index d3aefa83..11e281a4 100644 --- a/__init__.py +++ b/__init__.py @@ -54,6 +54,8 @@ CONFIG_OPTIONS = { 'editorCommand': None, ## command used to invoke code editor from ConsoleWidgets 'useWeave': True, ## Use weave to speed up some operations, if it is available 'weaveDebug': False, ## Print full error message if weave compile fails + 'exitCleanup': True, ## Attempt to work around some exit crash bugs in PyQt and PySide + 'enableExperimental': False, ## Enable experimental features (the curious can search for this key in the code) } @@ -137,7 +139,7 @@ def importModules(path, globals, locals, excludes=()): d = os.path.join(os.path.split(globals['__file__'])[0], path) files = set() for f in frozenSupport.listdir(d): - if frozenSupport.isdir(os.path.join(d, f)) and f != '__pycache__': + if frozenSupport.isdir(os.path.join(d, f)) and f not in ['__pycache__', 'tests']: files.add(f) elif f[-3:] == '.py' and f != '__init__.py': files.add(f[:-3]) @@ -152,7 +154,8 @@ def importModules(path, globals, locals, excludes=()): try: if len(path) > 0: modName = path + '.' + modName - mod = __import__(modName, globals, locals, fromlist=['*']) + #mod = __import__(modName, globals, locals, fromlist=['*']) + mod = __import__(modName, globals, locals, ['*'], 1) mods[modName] = mod except: import traceback @@ -175,7 +178,8 @@ def importAll(path, globals, locals, excludes=()): globals[k] = getattr(mod, k) importAll('graphicsItems', globals(), locals()) -importAll('widgets', globals(), locals(), excludes=['MatplotlibWidget', 'RemoteGraphicsView']) +importAll('widgets', globals(), locals(), + excludes=['MatplotlibWidget', 'RawImageWidget', 'RemoteGraphicsView']) from .imageview import * from .WidgetGroup import * @@ -190,9 +194,20 @@ from .SignalProxy import * from .colormap import * from .ptime import time +############################################################## +## PyQt and PySide both are prone to crashing on exit. +## There are two general approaches to dealing with this: +## 1. Install atexit handlers that assist in tearing down to avoid crashes. +## This helps, but is never perfect. +## 2. Terminate the process before python starts tearing down +## This is potentially dangerous +## Attempts to work around exit crashes: import atexit def cleanup(): + if not getConfigOption('exitCleanup'): + return + ViewBox.quit() ## tell ViewBox that it doesn't need to deregister views anymore. ## Workaround for Qt exit crash: @@ -212,6 +227,38 @@ def cleanup(): atexit.register(cleanup) +## Optional function for exiting immediately (with some manual teardown) +def exit(): + """ + Causes python to exit without garbage-collecting any objects, and thus avoids + calling object destructor methods. This is a sledgehammer workaround for + a variety of bugs in PyQt and Pyside that cause crashes on exit. + + This function does the following in an attempt to 'safely' terminate + the process: + + * Invoke atexit callbacks + * Close all open file handles + * os._exit() + + Note: there is some potential for causing damage with this function if you + are using objects that _require_ their destructors to be called (for example, + to properly terminate log files, disconnect from devices, etc). Situations + like this are probably quite rare, but use at your own risk. + """ + + ## first disable our own cleanup function; won't be needing it. + setConfigOptions(exitCleanup=False) + + ## invoke atexit callbacks + atexit._run_exitfuncs() + + ## close file handles + os.closerange(3, 4096) ## just guessing on the maximum descriptor count.. + + os._exit(0) + + ## Convenience functions for command-line use @@ -235,7 +282,7 @@ def plot(*args, **kargs): #if len(args)+len(kargs) > 0: #w.plot(*args, **kargs) - pwArgList = ['title', 'labels', 'name', 'left', 'right', 'top', 'bottom'] + pwArgList = ['title', 'labels', 'name', 'left', 'right', 'top', 'bottom', 'background'] pwArgs = {} dataArgs = {} for k in kargs: @@ -265,13 +312,15 @@ def image(*args, **kargs): return w show = image ## for backward compatibility -def dbg(): +def dbg(*args, **kwds): """ Create a console window and begin watching for exceptions. + + All arguments are passed to :func:`ConsoleWidget.__init__() `. """ mkQApp() - import console - c = console.ConsoleWidget() + from . import console + c = console.ConsoleWidget(*args, **kwds) c.catchAllExceptions() c.show() global consoles diff --git a/console/Console.py b/console/Console.py index 6fbe44a7..982c2424 100644 --- a/console/Console.py +++ b/console/Console.py @@ -169,7 +169,7 @@ class ConsoleWidget(QtGui.QWidget): def execMulti(self, nextLine): - self.stdout.write(nextLine+"\n") + #self.stdout.write(nextLine+"\n") if nextLine.strip() != '': self.multiline += "\n" + nextLine return @@ -372,4 +372,4 @@ class ConsoleWidget(QtGui.QWidget): return False return True - \ No newline at end of file + diff --git a/debug.py b/debug.py index ae2b21ac..a175be9c 100644 --- a/debug.py +++ b/debug.py @@ -28,6 +28,15 @@ def ftrace(func): return rv return w +def warnOnException(func): + """Decorator which catches/ignores exceptions and prints a stack trace.""" + def w(*args, **kwds): + try: + func(*args, **kwds) + except: + printExc('Ignored exception:') + return w + def getExc(indent=4, prefix='| '): tb = traceback.format_exc() lines = [] diff --git a/dockarea/Dock.py b/dockarea/Dock.py index 19ebc76e..414980ac 100644 --- a/dockarea/Dock.py +++ b/dockarea/Dock.py @@ -209,6 +209,13 @@ class Dock(QtGui.QWidget, DockDrop): self.setOrientation(force=True) + def close(self): + """Remove this dock from the DockArea it lives inside.""" + self.setParent(None) + self.label.setParent(None) + self._container.apoptose() + self._container = None + def __repr__(self): return "" % (self.name(), self.stretch()) diff --git a/dockarea/DockArea.py b/dockarea/DockArea.py index 752cf3b6..882b29a3 100644 --- a/dockarea/DockArea.py +++ b/dockarea/DockArea.py @@ -40,11 +40,11 @@ class DockArea(Container, QtGui.QWidget, DockDrop): Arguments: dock The new Dock object to add. If None, then a new Dock will be created. - position 'bottom', 'top', 'left', 'right', 'over', or 'under' + position 'bottom', 'top', 'left', 'right', 'above', or 'below' relativeTo If relativeTo is None, then the new Dock is added to fill an entire edge of the window. If relativeTo is another Dock, then the new Dock is placed adjacent to it (or in a tabbed - configuration for 'over' and 'under'). + configuration for 'above' and 'below'). =========== ================================================================= All extra keyword arguments are passed to Dock.__init__() if *dock* is @@ -316,4 +316,4 @@ class DockArea(Container, QtGui.QWidget, DockDrop): DockDrop.dropEvent(self, *args) - \ No newline at end of file + diff --git a/exporters/Exporter.py b/exporters/Exporter.py index f5a93088..6371a3b9 100644 --- a/exporters/Exporter.py +++ b/exporters/Exporter.py @@ -1,6 +1,7 @@ from pyqtgraph.widgets.FileDialog import FileDialog import pyqtgraph as pg from pyqtgraph.Qt import QtGui, QtCore, QtSvg +from pyqtgraph.python2_3 import asUnicode import os, re LastExportDirectory = None @@ -56,13 +57,13 @@ class Exporter(object): return def fileSaveFinished(self, fileName): - fileName = str(fileName) + fileName = asUnicode(fileName) global LastExportDirectory LastExportDirectory = os.path.split(fileName)[0] ## If file name does not match selected extension, append it now ext = os.path.splitext(fileName)[1].lower().lstrip('.') - selectedExt = re.search(r'\*\.(\w+)\b', str(self.fileDialog.selectedNameFilter())) + selectedExt = re.search(r'\*\.(\w+)\b', asUnicode(self.fileDialog.selectedNameFilter())) if selectedExt is not None: selectedExt = selectedExt.groups()[0].lower() if ext != selectedExt: @@ -118,7 +119,7 @@ class Exporter(object): else: childs = root.childItems() rootItem = [root] - childs.sort(lambda a,b: cmp(a.zValue(), b.zValue())) + childs.sort(key=lambda a: a.zValue()) while len(childs) > 0: ch = childs.pop(0) tree = self.getPaintItems(ch) diff --git a/exporters/ImageExporter.py b/exporters/ImageExporter.py index bdb8b9be..9fb77e2a 100644 --- a/exporters/ImageExporter.py +++ b/exporters/ImageExporter.py @@ -1,6 +1,6 @@ from .Exporter import Exporter from pyqtgraph.parametertree import Parameter -from pyqtgraph.Qt import QtGui, QtCore, QtSvg +from pyqtgraph.Qt import QtGui, QtCore, QtSvg, USE_PYSIDE import pyqtgraph as pg import numpy as np @@ -17,7 +17,11 @@ class ImageExporter(Exporter): scene = item.scene() else: scene = item - bg = scene.views()[0].backgroundBrush().color() + bgbrush = scene.views()[0].backgroundBrush() + bg = bgbrush.color() + if bgbrush.style() == QtCore.Qt.NoBrush: + bg.setAlpha(0) + self.params = Parameter(name='params', type='group', children=[ {'name': 'width', 'type': 'int', 'value': tr.width(), 'limits': (0, None)}, {'name': 'height', 'type': 'int', 'value': tr.height(), 'limits': (0, None)}, @@ -42,7 +46,10 @@ class ImageExporter(Exporter): def export(self, fileName=None, toBytes=False, copy=False): if fileName is None and not toBytes and not copy: - filter = ["*."+str(f) for f in QtGui.QImageWriter.supportedImageFormats()] + if USE_PYSIDE: + filter = ["*."+str(f) for f in QtGui.QImageWriter.supportedImageFormats()] + else: + filter = ["*."+bytes(f).decode('utf-8') for f in QtGui.QImageWriter.supportedImageFormats()] preferred = ['*.png', '*.tif', '*.jpg'] for p in preferred[::-1]: if p in filter: @@ -57,6 +64,9 @@ class ImageExporter(Exporter): #self.png = QtGui.QImage(targetRect.size(), QtGui.QImage.Format_ARGB32) #self.png.fill(pyqtgraph.mkColor(self.params['background'])) + w, h = self.params['width'], self.params['height'] + if w == 0 or h == 0: + raise Exception("Cannot export image with size=0 (requested export size is %dx%d)" % (w,h)) bg = np.empty((self.params['width'], self.params['height'], 4), dtype=np.ubyte) color = self.params['background'] bg[:,:,0] = color.blue() diff --git a/exporters/SVGExporter.py b/exporters/SVGExporter.py index b284db89..62b49d30 100644 --- a/exporters/SVGExporter.py +++ b/exporters/SVGExporter.py @@ -1,4 +1,5 @@ from .Exporter import Exporter +from pyqtgraph.python2_3 import asUnicode from pyqtgraph.parametertree import Parameter from pyqtgraph.Qt import QtGui, QtCore, QtSvg import pyqtgraph as pg @@ -91,8 +92,8 @@ class SVGExporter(Exporter): md.setData('image/svg+xml', QtCore.QByteArray(xml.encode('UTF-8'))) QtGui.QApplication.clipboard().setMimeData(md) else: - with open(fileName, 'w') as fh: - fh.write(xml.encode('UTF-8')) + with open(fileName, 'wb') as fh: + fh.write(asUnicode(xml).encode('utf-8')) xmlHeader = """\ @@ -221,8 +222,8 @@ def _generateItemSvg(item, nodes=None, root=None): ## this is taken care of in generateSvg instead. #if hasattr(item, 'setExportMode'): #item.setExportMode(False) - - xmlStr = str(arr) + + xmlStr = bytes(arr).decode('utf-8') doc = xml.parseString(xmlStr) try: @@ -304,14 +305,43 @@ def _generateItemSvg(item, nodes=None, root=None): def correctCoordinates(node, item): ## Remove transformation matrices from tags by applying matrix to coordinates inside. + ## Each item is represented by a single top-level group with one or more groups inside. + ## Each inner group contains one or more drawing primitives, possibly of different types. groups = node.getElementsByTagName('g') + + ## Since we leave text unchanged, groups which combine text and non-text primitives must be split apart. + ## (if at some point we start correcting text transforms as well, then it should be safe to remove this) + groups2 = [] + for grp in groups: + subGroups = [grp.cloneNode(deep=False)] + textGroup = None + for ch in grp.childNodes[:]: + if isinstance(ch, xml.Element): + if textGroup is None: + textGroup = ch.tagName == 'text' + if ch.tagName == 'text': + if textGroup is False: + subGroups.append(grp.cloneNode(deep=False)) + textGroup = True + else: + if textGroup is True: + subGroups.append(grp.cloneNode(deep=False)) + textGroup = False + subGroups[-1].appendChild(ch) + groups2.extend(subGroups) + for sg in subGroups: + node.insertBefore(sg, grp) + node.removeChild(grp) + groups = groups2 + + for grp in groups: matrix = grp.getAttribute('transform') match = re.match(r'matrix\((.*)\)', matrix) if match is None: vals = [1,0,0,1,0,0] else: - vals = map(float, match.groups()[0].split(',')) + vals = [float(a) for a in match.groups()[0].split(',')] tr = np.array([[vals[0], vals[2], vals[4]], [vals[1], vals[3], vals[5]]]) removeTransform = False @@ -320,9 +350,9 @@ def correctCoordinates(node, item): continue if ch.tagName == 'polyline': removeTransform = True - coords = np.array([map(float, c.split(',')) for c in ch.getAttribute('points').strip().split(' ')]) + coords = np.array([[float(a) for a in c.split(',')] for c in ch.getAttribute('points').strip().split(' ')]) coords = pg.transformCoordinates(tr, coords, transpose=True) - ch.setAttribute('points', ' '.join([','.join(map(str, c)) for c in coords])) + ch.setAttribute('points', ' '.join([','.join([str(a) for a in c]) for c in coords])) elif ch.tagName == 'path': removeTransform = True newCoords = '' @@ -374,7 +404,6 @@ def correctCoordinates(node, item): if removeTransform: grp.removeAttribute('transform') - def itemTransform(item, root): ## Return the transformation mapping item to root diff --git a/flowchart/Flowchart.py b/flowchart/Flowchart.py index a68cf542..81f9e163 100644 --- a/flowchart/Flowchart.py +++ b/flowchart/Flowchart.py @@ -376,10 +376,10 @@ class Flowchart(Node): #tdeps[t] = lastNode if lastInd is not None: dels.append((lastInd+1, t)) - dels.sort(lambda a,b: cmp(b[0], a[0])) + #dels.sort(lambda a,b: cmp(b[0], a[0])) + dels.sort(key=lambda a: a[0], reverse=True) for i, t in dels: ops.insert(i, ('d', t)) - return ops @@ -491,7 +491,8 @@ class Flowchart(Node): self.clear() Node.restoreState(self, state) nodes = state['nodes'] - nodes.sort(lambda a, b: cmp(a['pos'][0], b['pos'][0])) + #nodes.sort(lambda a, b: cmp(a['pos'][0], b['pos'][0])) + nodes.sort(key=lambda a: a['pos'][0]) for n in nodes: if n['name'] in self._nodes: #self._nodes[n['name']].graphicsItem().moveBy(*n['pos']) @@ -560,6 +561,7 @@ class Flowchart(Node): self.fileDialog.fileSelected.connect(self.saveFile) return #fileName = QtGui.QFileDialog.getSaveFileName(None, "Save Flowchart..", startDir, "Flowchart (*.fc)") + fileName = str(fileName) configfile.writeConfigFile(self.saveState(), fileName) self.sigFileSaved.emit(fileName) @@ -681,7 +683,7 @@ class FlowchartCtrlWidget(QtGui.QWidget): #self.setCurrentFile(newFile) def fileSaved(self, fileName): - self.setCurrentFile(fileName) + self.setCurrentFile(str(fileName)) self.ui.saveBtn.success("Saved.") def saveClicked(self): @@ -710,7 +712,7 @@ class FlowchartCtrlWidget(QtGui.QWidget): #self.setCurrentFile(newFile) def setCurrentFile(self, fileName): - self.currentFileName = fileName + self.currentFileName = str(fileName) if fileName is None: self.ui.fileNameLabel.setText("[ new ]") else: diff --git a/flowchart/Terminal.py b/flowchart/Terminal.py index 623d1a28..45805cd8 100644 --- a/flowchart/Terminal.py +++ b/flowchart/Terminal.py @@ -521,6 +521,17 @@ class ConnectionItem(GraphicsObject): self.target = target self.length = 0 self.hovered = False + self.path = None + self.shapePath = None + self.style = { + 'shape': 'line', + 'color': (100, 100, 250), + 'width': 1.0, + 'hoverColor': (150, 150, 250), + 'hoverWidth': 1.0, + 'selectedColor': (200, 200, 0), + 'selectedWidth': 3.0, + } #self.line = QtGui.QGraphicsLineItem(self) self.source.getViewBox().addItem(self) self.updateLine() @@ -535,6 +546,13 @@ class ConnectionItem(GraphicsObject): self.target = target self.updateLine() + def setStyle(self, **kwds): + self.style.update(kwds) + if 'shape' in kwds: + self.updateLine() + else: + self.update() + def updateLine(self): start = Point(self.source.connectPoint()) if isinstance(self.target, TerminalGraphicsItem): @@ -544,15 +562,21 @@ class ConnectionItem(GraphicsObject): else: return self.prepareGeometryChange() - self.resetTransform() - ang = (stop-start).angle(Point(0, 1)) - if ang is None: - ang = 0 - self.rotate(ang) - self.setPos(start) - self.length = (start-stop).length() + + self.path = self.generatePath(start, stop) + self.shapePath = None self.update() - #self.line.setLine(start.x(), start.y(), stop.x(), stop.y()) + + def generatePath(self, start, stop): + path = QtGui.QPainterPath() + path.moveTo(start) + if self.style['shape'] == 'line': + path.lineTo(stop) + elif self.style['shape'] == 'cubic': + path.cubicTo(Point(stop.x(), start.y()), Point(start.x(), stop.y()), Point(stop.x(), stop.y())) + else: + raise Exception('Invalid shape "%s"; options are "line" or "cubic"' % self.style['shape']) + return path def keyPressEvent(self, ev): if ev.key() == QtCore.Qt.Key_Delete or ev.key() == QtCore.Qt.Key_Backspace: @@ -582,20 +606,33 @@ class ConnectionItem(GraphicsObject): def boundingRect(self): - #return self.line.boundingRect() - px = self.pixelWidth() - return QtCore.QRectF(-5*px, 0, 10*px, self.length) + 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() - #def shape(self): - #return self.line.shape() + def shape(self): + if self.shapePath is None: + if self.path is None: + return QtGui.QPainterPath() + stroker = QtGui.QPainterPathStroker() + px = self.pixelWidth() + stroker.setWidth(px*8) + self.shapePath = stroker.createStroke(self.path) + return self.shapePath def paint(self, p, *args): if self.isSelected(): - p.setPen(fn.mkPen(200, 200, 0, width=3)) + p.setPen(fn.mkPen(self.style['selectedColor'], width=self.style['selectedWidth'])) else: if self.hovered: - p.setPen(fn.mkPen(150, 150, 250, width=1)) + p.setPen(fn.mkPen(self.style['hoverColor'], width=self.style['hoverWidth'])) else: - p.setPen(fn.mkPen(100, 100, 250, width=1)) + p.setPen(fn.mkPen(self.style['color'], width=self.style['width'])) - p.drawLine(0, 0, 0, self.length) + #p.drawLine(0, 0, 0, self.length) + + p.drawPath(self.path) diff --git a/flowchart/library/Operators.py b/flowchart/library/Operators.py index 412af573..579d2cd2 100644 --- a/flowchart/library/Operators.py +++ b/flowchart/library/Operators.py @@ -24,7 +24,15 @@ class BinOpNode(Node): }) def process(self, **args): - fn = getattr(args['A'], self.fn) + if isinstance(self.fn, tuple): + for name in self.fn: + try: + fn = getattr(args['A'], name) + break + except AttributeError: + pass + else: + fn = getattr(args['A'], self.fn) out = fn(args['B']) if out is NotImplemented: raise Exception("Operation %s not implemented between %s and %s" % (fn, str(type(args['A'])), str(type(args['B'])))) @@ -60,5 +68,7 @@ class DivideNode(BinOpNode): """Returns A / B. Does not check input types.""" nodeName = 'Divide' def __init__(self, name): - BinOpNode.__init__(self, name, '__div__') + # try truediv first, followed by div + BinOpNode.__init__(self, name, ('__truediv__', '__div__')) + diff --git a/functions.py b/functions.py index 62f69cb1..337dfb67 100644 --- a/functions.py +++ b/functions.py @@ -5,6 +5,7 @@ Copyright 2010 Luke Campagnola Distributed under MIT/X11 license. See license.txt for more infomation. """ +from __future__ import division from .python2_3 import asUnicode Colors = { 'b': (0,0,255,255), @@ -23,7 +24,7 @@ SI_PREFIXES_ASCII = 'yzafpnum kMGTPEZY' from .Qt import QtGui, QtCore, USE_PYSIDE -from pyqtgraph import getConfigOption +import pyqtgraph as pg import numpy as np import decimal, re import ctypes @@ -32,12 +33,11 @@ import sys, struct try: import scipy.ndimage HAVE_SCIPY = True - WEAVE_DEBUG = getConfigOption('weaveDebug') - try: - import scipy.weave - USE_WEAVE = getConfigOption('useWeave') - except: - USE_WEAVE = False + if pg.getConfigOption('useWeave'): + try: + import scipy.weave + except ImportError: + pg.setConfigOptions(useWeave=False) except ImportError: HAVE_SCIPY = False @@ -264,6 +264,7 @@ def mkPen(*args, **kargs): color = kargs.get('color', None) width = kargs.get('width', 1) style = kargs.get('style', None) + dash = kargs.get('dash', None) cosmetic = kargs.get('cosmetic', True) hsv = kargs.get('hsv', None) @@ -291,6 +292,8 @@ def mkPen(*args, **kargs): pen.setCosmetic(cosmetic) if style is not None: pen.setStyle(style) + if dash is not None: + pen.setDashPattern(dash) return pen def hsvColor(hue, sat=1.0, val=1.0, alpha=1.0): @@ -611,15 +614,24 @@ def rescaleData(data, scale, offset, dtype=None): Uses scipy.weave (if available) to improve performance. """ - global USE_WEAVE if dtype is None: dtype = data.dtype + else: + dtype = np.dtype(dtype) try: - if not USE_WEAVE: + if not pg.getConfigOption('useWeave'): raise Exception('Weave is disabled; falling back to slower version.') - newData = np.empty((data.size,), dtype=dtype) + ## require native dtype when using weave + if not data.dtype.isnative: + data = data.astype(data.dtype.newbyteorder('=')) + if not dtype.isnative: + weaveDtype = dtype.newbyteorder('=') + else: + weaveDtype = dtype + + newData = np.empty((data.size,), dtype=weaveDtype) flat = np.ascontiguousarray(data).reshape(data.size) size = data.size @@ -631,12 +643,14 @@ def rescaleData(data, scale, offset, dtype=None): } """ scipy.weave.inline(code, ['flat', 'newData', 'size', 'offset', 'scale'], compiler='gcc') + if dtype != weaveDtype: + newData = newData.astype(dtype) data = newData.reshape(data.shape) except: - if USE_WEAVE: - if WEAVE_DEBUG: + if pg.getConfigOption('useWeave'): + if pg.getConfigOption('weaveDebug'): debug.printExc("Error; disabling weave.") - USE_WEAVE = False + pg.setConfigOption('useWeave', False) #p = np.poly1d([scale, -offset*scale]) #data = p(data).astype(dtype) @@ -653,8 +667,6 @@ def applyLookupTable(data, lut): Uses scipy.weave to improve performance if it is available. Note: color gradient lookup tables can be generated using GradientWidget. """ - global USE_WEAVE - if data.dtype.kind not in ('i', 'u'): data = data.astype(int) @@ -839,7 +851,6 @@ def makeARGB(data, lut=None, levels=None, scale=None, useRGBA=False): if minVal == maxVal: maxVal += 1e-16 data = rescaleData(data, scale/(maxVal-minVal), minVal, dtype=int) - prof.mark('2') @@ -849,7 +860,6 @@ def makeARGB(data, lut=None, levels=None, scale=None, useRGBA=False): else: if data.dtype is not np.ubyte: data = np.clip(data, 0, 255).astype(np.ubyte) - prof.mark('3') @@ -904,7 +914,8 @@ def makeQImage(imgData, alpha=None, copy=True, transpose=True): array.shape[2] == 4. copy If True, the data is copied before converting to QImage. If False, the new QImage points directly to the data in the array. - Note that the array must be contiguous for this to work. + Note that the array must be contiguous for this to work + (see numpy.ascontiguousarray). transpose If True (the default), the array x/y axes are transposed before creating the image. Note that Qt expects the axes to be in (height, width) order whereas pyqtgraph usually prefers the @@ -954,12 +965,22 @@ def makeQImage(imgData, alpha=None, copy=True, transpose=True): #addr = ctypes.addressof(ctypes.c_char.from_buffer(imgData, 0)) ## PyQt API for QImage changed between 4.9.3 and 4.9.6 (I don't know exactly which version it was) ## So we first attempt the 4.9.6 API, then fall back to 4.9.3 - addr = ctypes.c_char.from_buffer(imgData, 0) + #addr = ctypes.c_char.from_buffer(imgData, 0) + #try: + #img = QtGui.QImage(addr, imgData.shape[1], imgData.shape[0], imgFormat) + #except TypeError: + #addr = ctypes.addressof(addr) + #img = QtGui.QImage(addr, imgData.shape[1], imgData.shape[0], imgFormat) try: - img = QtGui.QImage(addr, imgData.shape[1], imgData.shape[0], imgFormat) - except TypeError: - addr = ctypes.addressof(addr) - img = QtGui.QImage(addr, imgData.shape[1], imgData.shape[0], imgFormat) + img = QtGui.QImage(imgData.ctypes.data, imgData.shape[1], imgData.shape[0], imgFormat) + except: + if copy: + # does not leak memory, is not mutable + img = QtGui.QImage(buffer(imgData), imgData.shape[1], imgData.shape[0], imgFormat) + else: + # mutable, but leaks memory + img = QtGui.QImage(memoryview(imgData), imgData.shape[1], imgData.shape[0], imgFormat) + img.data = imgData return img #try: @@ -1056,14 +1077,29 @@ def arrayToQPath(x, y, connect='all'): should be connected, or an array of int32 values (0 or 1) indicating connections. """ - - ## Create all vertices in path. The method used below creates a binary format so that all - ## vertices can be read in at once. This binary format may change in future versions of Qt, + + ## Create all vertices in path. The method used below creates a binary format so that all + ## vertices can be read in at once. This binary format may change in future versions of Qt, ## so the original (slower) method is left here for emergencies: - #path.moveTo(x[0], y[0]) - #for i in range(1, y.shape[0]): - # path.lineTo(x[i], y[i]) - + #path.moveTo(x[0], y[0]) + #if connect == 'all': + #for i in range(1, y.shape[0]): + #path.lineTo(x[i], y[i]) + #elif connect == 'pairs': + #for i in range(1, y.shape[0]): + #if i%2 == 0: + #path.lineTo(x[i], y[i]) + #else: + #path.moveTo(x[i], y[i]) + #elif isinstance(connect, np.ndarray): + #for i in range(1, y.shape[0]): + #if connect[i] == 1: + #path.lineTo(x[i], y[i]) + #else: + #path.moveTo(x[i], y[i]) + #else: + #raise Exception('connect argument must be "all", "pairs", or array') + ## Speed this up using >> operator ## Format is: ## numVerts(i4) 0(i4) @@ -1073,71 +1109,62 @@ def arrayToQPath(x, y, connect='all'): ## 0(i4) ## ## All values are big endian--pack using struct.pack('>d') or struct.pack('>i') - + path = QtGui.QPainterPath() - + #prof = debug.Profiler('PlotCurveItem.generatePath', disabled=True) - if sys.version_info[0] == 2: ## So this is disabled for python 3... why?? - n = x.shape[0] - # create empty array, pad with extra space on either end - arr = np.empty(n+2, dtype=[('x', '>f8'), ('y', '>f8'), ('c', '>i4')]) - # write first two integers - #prof.mark('allocate empty') - arr.data[12:20] = struct.pack('>ii', n, 0) - #prof.mark('pack header') - # Fill array with vertex values - arr[1:-1]['x'] = x - arr[1:-1]['y'] = y - - # decide which points are connected by lines - if connect == 'pairs': - connect = np.empty((n/2,2), dtype=np.int32) - connect[:,0] = 1 - connect[:,1] = 0 - connect = connect.flatten() - - if connect == 'all': - arr[1:-1]['c'] = 1 - elif isinstance(connect, np.ndarray): - arr[1:-1]['c'] = connect - else: - raise Exception('connect argument must be "all", "pairs", or array') - - #prof.mark('fill array') - # write last 0 - lastInd = 20*(n+1) - arr.data[lastInd:lastInd+4] = struct.pack('>i', 0) - #prof.mark('footer') - # create datastream object and stream into path - buf = QtCore.QByteArray(arr.data[12:lastInd+4]) # I think one unnecessary copy happens here - #prof.mark('create buffer') - ds = QtCore.QDataStream(buf) - #prof.mark('create datastream') - ds >> path - #prof.mark('load') - - #prof.finish() + n = x.shape[0] + # create empty array, pad with extra space on either end + arr = np.empty(n+2, dtype=[('x', '>f8'), ('y', '>f8'), ('c', '>i4')]) + # write first two integers + #prof.mark('allocate empty') + byteview = arr.view(dtype=np.ubyte) + byteview[:12] = 0 + byteview.data[12:20] = struct.pack('>ii', n, 0) + #prof.mark('pack header') + # Fill array with vertex values + arr[1:-1]['x'] = x + arr[1:-1]['y'] = y + + # decide which points are connected by lines + if connect == 'pairs': + connect = np.empty((n/2,2), dtype=np.int32) + connect[:,0] = 1 + connect[:,1] = 0 + connect = connect.flatten() + if connect == 'finite': + connect = np.isfinite(x) & np.isfinite(y) + arr[1:-1]['c'] = connect + if connect == 'all': + arr[1:-1]['c'] = 1 + elif isinstance(connect, np.ndarray): + arr[1:-1]['c'] = connect else: - ## This does exactly the same as above, but less efficiently (and more simply). - path.moveTo(x[0], y[0]) - if connect == 'all': - for i in range(1, y.shape[0]): - path.lineTo(x[i], y[i]) - elif connect == 'pairs': - for i in range(1, y.shape[0]): - if i%2 == 0: - path.lineTo(x[i], y[i]) - else: - path.moveTo(x[i], y[i]) - elif isinstance(connect, np.ndarray): - for i in range(1, y.shape[0]): - if connect[i] == 1: - path.lineTo(x[i], y[i]) - else: - path.moveTo(x[i], y[i]) - else: - raise Exception('connect argument must be "all", "pairs", or array') - + raise Exception('connect argument must be "all", "pairs", or array') + + #prof.mark('fill array') + # write last 0 + lastInd = 20*(n+1) + byteview.data[lastInd:lastInd+4] = struct.pack('>i', 0) + #prof.mark('footer') + # create datastream object and stream into path + + ## Avoiding this method because QByteArray(str) leaks memory in PySide + #buf = QtCore.QByteArray(arr.data[12:lastInd+4]) # I think one unnecessary copy happens here + + path.strn = byteview.data[12:lastInd+4] # make sure data doesn't run away + try: + buf = QtCore.QByteArray.fromRawData(path.strn) + except TypeError: + buf = QtCore.QByteArray(bytes(path.strn)) + #prof.mark('create buffer') + ds = QtCore.QDataStream(buf) + + ds >> path + #prof.mark('load') + + #prof.finish() + return path #def isosurface(data, level): @@ -1838,9 +1865,9 @@ def isosurface(data, level): for i in [0,1,2]: vim = vertexInds[:,3] == i vi = vertexInds[vim, :3] - viFlat = (vi * (np.array(data.strides[:3]) / data.itemsize)[np.newaxis,:]).sum(axis=1) + viFlat = (vi * (np.array(data.strides[:3]) // data.itemsize)[np.newaxis,:]).sum(axis=1) v1 = dataFlat[viFlat] - v2 = dataFlat[viFlat + data.strides[i]/data.itemsize] + v2 = dataFlat[viFlat + data.strides[i]//data.itemsize] vertexes[vim,i] += (level-v1) / (v2-v1) ### compute the set of vertex indexes for each face. @@ -1866,7 +1893,7 @@ def isosurface(data, level): #p = debug.Profiler('isosurface', disabled=False) ## this helps speed up an indexing operation later on - cs = np.array(cutEdges.strides)/cutEdges.itemsize + cs = np.array(cutEdges.strides)//cutEdges.itemsize cutEdges = cutEdges.flatten() ## this, strangely, does not seem to help. @@ -1925,9 +1952,9 @@ def invertQTransform(tr): return QtGui.QTransform(inv[0,0], inv[0,1], inv[0,2], inv[1,0], inv[1,1], inv[1,2], inv[2,0], inv[2,1]) -def pseudoScatter(data, spacing=None, shuffle=True): +def pseudoScatter(data, spacing=None, shuffle=True, bidir=False): """ - Used for examining the distribution of values in a set. + Used for examining the distribution of values in a set. Produces scattering as in beeswarm or column scatter plots. Given a list of x-values, construct a set of y-values such that an x,y scatter-plot will not have overlapping points (it will look similar to a histogram). @@ -1943,6 +1970,8 @@ def pseudoScatter(data, spacing=None, shuffle=True): s2 = spacing**2 yvals = np.empty(len(data)) + if len(data) == 0: + return yvals yvals[0] = 0 for i in range(1,len(data)): x = data[i] # current x value to be placed @@ -1954,23 +1983,41 @@ def pseudoScatter(data, spacing=None, shuffle=True): xmask = dx < s2 # exclude anything too far away if xmask.sum() > 0: - dx = dx[xmask] - dy = (s2 - dx)**0.5 - limits = np.empty((2,len(dy))) # ranges of y-values to exclude - limits[0] = y0[xmask] - dy - limits[1] = y0[xmask] + dy - - while True: - # ignore anything below this y-value - mask = limits[1] >= y - limits = limits[:,mask] - - # are we inside an excluded region? - mask = (limits[0] < y) & (limits[1] > y) - if mask.sum() == 0: - break - y = limits[:,mask].max() - + if bidir: + dirs = [-1, 1] + else: + dirs = [1] + yopts = [] + for direction in dirs: + y = 0 + dx2 = dx[xmask] + dy = (s2 - dx2)**0.5 + limits = np.empty((2,len(dy))) # ranges of y-values to exclude + limits[0] = y0[xmask] - dy + limits[1] = y0[xmask] + dy + while True: + # ignore anything below this y-value + if direction > 0: + mask = limits[1] >= y + else: + mask = limits[0] <= y + + limits2 = limits[:,mask] + + # are we inside an excluded region? + mask = (limits2[0] < y) & (limits2[1] > y) + if mask.sum() == 0: + break + + if direction > 0: + y = limits2[:,mask].max() + else: + y = limits2[:,mask].min() + yopts.append(y) + if bidir: + y = yopts[0] if -yopts[0] < yopts[1] else yopts[1] + else: + y = yopts[0] yvals[i] = y return yvals[np.argsort(inds)] ## un-shuffle values before returning diff --git a/graphicsItems/AxisItem.py b/graphicsItems/AxisItem.py index 9ef64763..429ff49c 100644 --- a/graphicsItems/AxisItem.py +++ b/graphicsItems/AxisItem.py @@ -39,19 +39,25 @@ class AxisItem(GraphicsWidget): if orientation not in ['left', 'right', 'top', 'bottom']: raise Exception("Orientation argument must be one of 'left', 'right', 'top', or 'bottom'.") if orientation in ['left', 'right']: - #self.setMinimumWidth(25) - #self.setSizePolicy(QtGui.QSizePolicy( - #QtGui.QSizePolicy.Minimum, - #QtGui.QSizePolicy.Expanding - #)) self.label.rotate(-90) - #else: - #self.setMinimumHeight(50) - #self.setSizePolicy(QtGui.QSizePolicy( - #QtGui.QSizePolicy.Expanding, - #QtGui.QSizePolicy.Minimum - #)) - #self.drawLabel = False + + self.style = { + 'tickTextOffset': (5, 2), ## (horizontal, vertical) spacing between text and axis + 'tickTextWidth': 30, ## space reserved for tick text + 'tickTextHeight': 18, + 'autoExpandTextSpace': True, ## automatically expand text space if needed + 'tickFont': None, + 'stopAxisAtTick': (False, False), ## whether axis is drawn to edge of box or to last tick + 'textFillLimits': [ ## how much of the axis to fill up with tick text, maximally. + (0, 0.8), ## never fill more than 80% of the axis + (2, 0.6), ## If we already have 2 ticks with text, fill no more than 60% of the axis + (4, 0.4), ## If we already have 4 ticks with text, fill no more than 40% of the axis + (6, 0.2), ## If we already have 6 ticks with text, fill no more than 20% of the axis + ] + } + + self.textWidth = 30 ## Keeps track of maximum width / height of tick text + self.textHeight = 18 self.labelText = '' self.labelUnits = '' @@ -60,11 +66,11 @@ class AxisItem(GraphicsWidget): self.logMode = False self.tickFont = None - self.textHeight = 18 self.tickLength = maxTickLength self._tickLevels = None ## used to override the automatic ticking system with explicit ticks self.scale = 1.0 - self.autoScale = True + self.autoSIPrefix = True + self.autoSIPrefixScale = 1.0 self.setRange(0, 1) @@ -144,8 +150,8 @@ class AxisItem(GraphicsWidget): self.setWidth() else: self.setHeight() - if self.autoScale: - self.setScale() + if self.autoSIPrefix: + self.updateAutoSIPrefix() def setLabel(self, text=None, units=None, unitPrefix=None, **args): """Set the text displayed adjacent to the axis. @@ -184,33 +190,62 @@ class AxisItem(GraphicsWidget): if len(args) > 0: self.labelStyle = args self.label.setHtml(self.labelString()) - self.resizeEvent() + self._adjustSize() self.picture = None self.update() def labelString(self): if self.labelUnits == '': - if self.scale == 1.0: + if not self.autoSIPrefix or self.autoSIPrefixScale == 1.0: units = '' else: - units = asUnicode('(x%g)') % (1.0/self.scale) + units = asUnicode('(x%g)') % (1.0/self.autoSIPrefixScale) else: #print repr(self.labelUnitPrefix), repr(self.labelUnits) - units = asUnicode('(%s%s)') % (self.labelUnitPrefix, self.labelUnits) + units = asUnicode('(%s%s)') % (asUnicode(self.labelUnitPrefix), asUnicode(self.labelUnits)) - s = asUnicode('%s %s') % (self.labelText, units) + s = asUnicode('%s %s') % (asUnicode(self.labelText), asUnicode(units)) style = ';'.join(['%s: %s' % (k, self.labelStyle[k]) for k in self.labelStyle]) - return asUnicode("%s") % (style, s) + return asUnicode("%s") % (style, asUnicode(s)) + + def _updateMaxTextSize(self, x): + ## Informs that the maximum tick size orthogonal to the axis has + ## changed; we use this to decide whether the item needs to be resized + ## to accomodate. + if self.orientation in ['left', 'right']: + mx = max(self.textWidth, x) + if mx > self.textWidth or mx < self.textWidth-10: + self.textWidth = mx + if self.style['autoExpandTextSpace'] is True: + self.setWidth() + #return True ## size has changed + else: + mx = max(self.textHeight, x) + if mx > self.textHeight or mx < self.textHeight-10: + self.textHeight = mx + if self.style['autoExpandTextSpace'] is True: + self.setHeight() + #return True ## size has changed + def _adjustSize(self): + if self.orientation in ['left', 'right']: + self.setWidth() + else: + self.setHeight() + def setHeight(self, h=None): """Set the height of this axis reserved for ticks and tick labels. The height of the axis label is automatically added.""" if h is None: - h = self.textHeight + max(0, self.tickLength) + if self.style['autoExpandTextSpace'] is True: + h = self.textHeight + else: + h = self.style['tickTextHeight'] + h += max(0, self.tickLength) + self.style['tickTextOffset'][1] if self.label.isVisible(): - h += self.textHeight + h += self.label.boundingRect().height() * 0.8 self.setMaximumHeight(h) self.setMinimumHeight(h) self.picture = None @@ -220,11 +255,16 @@ class AxisItem(GraphicsWidget): """Set the width of this axis reserved for ticks and tick labels. The width of the axis label is automatically added.""" if w is None: - w = max(0, self.tickLength) + 40 + if self.style['autoExpandTextSpace'] is True: + w = self.textWidth + else: + w = self.style['tickTextWidth'] + w += max(0, self.tickLength) + self.style['tickTextOffset'][0] if self.label.isVisible(): - w += self.textHeight + w += self.label.boundingRect().height() * 0.8 ## bounding rect is usually an overestimate self.setMaximumWidth(w) self.setMinimumWidth(w) + self.picture = None def pen(self): if self._pen is None: @@ -247,30 +287,16 @@ class AxisItem(GraphicsWidget): def setScale(self, scale=None): """ - Set the value scaling for this axis. Values on the axis are multiplied - by this scale factor before being displayed as text. By default, - this scaling value is automatically determined based on the visible range - and the axis units are updated to reflect the chosen scale factor. + Set the value scaling for this axis. - For example: If the axis spans values from -0.1 to 0.1 and has units set - to 'V' then a scale of 1000 would cause the axis to display values -100 to 100 - and the units would appear as 'mV' + Setting this value causes the axis to draw ticks and tick labels as if + the view coordinate system were scaled. By default, the axis scaling is + 1.0. """ - if scale is None: - #if self.drawLabel: ## If there is a label, then we are free to rescale the values - if self.label.isVisible(): - #d = self.range[1] - self.range[0] - #(scale, prefix) = fn.siScale(d / 2.) - (scale, prefix) = fn.siScale(max(abs(self.range[0]), abs(self.range[1]))) - if self.labelUnits == '' and prefix in ['k', 'm']: ## If we are not showing units, wait until 1e6 before scaling. - scale = 1.0 - prefix = '' - self.setLabel(unitPrefix=prefix) - else: - scale = 1.0 - else: - self.setLabel(unitPrefix='') - self.autoScale = False + # Deprecated usage, kept for backward compatibility + if scale is None: + scale = 1.0 + self.enableAutoSIPrefix(True) if scale != self.scale: self.scale = scale @@ -278,14 +304,47 @@ class AxisItem(GraphicsWidget): self.picture = None self.update() + def enableAutoSIPrefix(self, enable=True): + """ + Enable (or disable) automatic SI prefix scaling on this axis. + + When enabled, this feature automatically determines the best SI prefix + to prepend to the label units, while ensuring that axis values are scaled + accordingly. + + For example, if the axis spans values from -0.1 to 0.1 and has units set + to 'V' then the axis would display values -100 to 100 + and the units would appear as 'mV' + + This feature is enabled by default, and is only available when a suffix + (unit string) is provided to display on the label. + """ + self.autoSIPrefix = enable + self.updateAutoSIPrefix() + + def updateAutoSIPrefix(self): + if self.label.isVisible(): + (scale, prefix) = fn.siScale(max(abs(self.range[0]*self.scale), abs(self.range[1]*self.scale))) + if self.labelUnits == '' and prefix in ['k', 'm']: ## If we are not showing units, wait until 1e6 before scaling. + scale = 1.0 + prefix = '' + self.setLabel(unitPrefix=prefix) + else: + scale = 1.0 + + self.autoSIPrefixScale = scale + self.picture = None + self.update() + + def setRange(self, mn, mx): """Set the range of values displayed by the axis. Usually this is handled automatically by linking the axis to a ViewBox with :func:`linkToView `""" if any(np.isinf((mn, mx))) or any(np.isnan((mn, mx))): raise Exception("Not setting range to [%s, %s]" % (str(mn), str(mx))) self.range = [mn, mx] - if self.autoScale: - self.setScale() + if self.autoSIPrefix: + self.updateAutoSIPrefix() self.picture = None self.update() @@ -309,10 +368,21 @@ class AxisItem(GraphicsWidget): oldView.sigXRangeChanged.disconnect(self.linkedViewChanged) view.sigXRangeChanged.connect(self.linkedViewChanged) - def linkedViewChanged(self, view, newRange): - if self.orientation in ['right', 'left'] and view.yInverted(): - self.setRange(*newRange[::-1]) + if oldView is not None: + oldView.sigResized.disconnect(self.linkedViewChanged) + view.sigResized.connect(self.linkedViewChanged) + + def linkedViewChanged(self, view, newRange=None): + if self.orientation in ['right', 'left']: + if newRange is None: + newRange = view.viewRange()[1] + if view.yInverted(): + self.setRange(*newRange[::-1]) + else: + self.setRange(*newRange) else: + if newRange is None: + newRange = view.viewRange()[0] self.setRange(*newRange) def boundingRect(self): @@ -322,34 +392,36 @@ class AxisItem(GraphicsWidget): ## extend rect if ticks go in negative direction ## also extend to account for text that flows past the edges if self.orientation == 'left': - #rect.setRight(rect.right() - min(0,self.tickLength)) - #rect.setTop(rect.top() - 15) - #rect.setBottom(rect.bottom() + 15) rect = rect.adjusted(0, -15, -min(0,self.tickLength), 15) elif self.orientation == 'right': - #rect.setLeft(rect.left() + min(0,self.tickLength)) rect = rect.adjusted(min(0,self.tickLength), -15, 0, 15) elif self.orientation == 'top': - #rect.setBottom(rect.bottom() - min(0,self.tickLength)) rect = rect.adjusted(-15, 0, 15, -min(0,self.tickLength)) elif self.orientation == 'bottom': - #rect.setTop(rect.top() + min(0,self.tickLength)) rect = rect.adjusted(-15, min(0,self.tickLength), 15, 0) return rect else: return self.mapRectFromParent(self.geometry()) | linkedView.mapRectToItem(self, linkedView.boundingRect()) def paint(self, p, opt, widget): + prof = debug.Profiler('AxisItem.paint', disabled=True) if self.picture is None: - self.picture = QtGui.QPicture() - painter = QtGui.QPainter(self.picture) try: - self.drawPicture(painter) + picture = QtGui.QPicture() + painter = QtGui.QPainter(picture) + specs = self.generateDrawSpecs(painter) + prof.mark('generate specs') + if specs is not None: + self.drawPicture(painter, *specs) + prof.mark('draw picture') finally: painter.end() + self.picture = picture #p.setRenderHint(p.Antialiasing, False) ## Sometimes we get a segfault here ??? #p.setRenderHint(p.TextAntialiasing, True) self.picture.play(p) + prof.finish() + def setTicks(self, ticks): @@ -375,7 +447,7 @@ class AxisItem(GraphicsWidget): This method is called whenever the axis needs to be redrawn and is a good method to override in subclasses that require control over tick locations. - The return value must be a list of three tuples:: + The return value must be a list of tuples, one for each set of ticks:: [ (major tick spacing, offset), @@ -389,7 +461,7 @@ class AxisItem(GraphicsWidget): return [] ## decide optimal minor tick spacing in pixels (this is just aesthetics) - pixelSpacing = np.log(size+10) * 5 + pixelSpacing = size / np.log(size) optimalTickCount = max(2., size / pixelSpacing) ## optimal minor tick spacing @@ -458,6 +530,10 @@ class AxisItem(GraphicsWidget): """ minVal, maxVal = sorted((minVal, maxVal)) + + minVal *= self.scale + maxVal *= self.scale + #size *= self.scale ticks = [] tickLevels = self.tickSpacing(minVal, maxVal, size) @@ -470,16 +546,25 @@ class AxisItem(GraphicsWidget): ## determine number of ticks num = int((maxVal-start) / spacing) + 1 - values = np.arange(num) * spacing + start + values = (np.arange(num) * spacing + start) / self.scale ## remove any ticks that were present in higher levels ## we assume here that if the difference between a tick value and a previously seen tick value ## is less than spacing/100, then they are 'equal' and we can ignore the new tick. values = list(filter(lambda x: all(np.abs(allValues-x) > spacing*0.01), values) ) allValues = np.concatenate([allValues, values]) - ticks.append((spacing, values)) + ticks.append((spacing/self.scale, values)) if self.logMode: return self.logTickValues(minVal, maxVal, size, ticks) + + + #nticks = [] + #for t in ticks: + #nvals = [] + #for v in t[1]: + #nvals.append(v/self.scale) + #nticks.append((t[0]/self.scale,nvals)) + #ticks = nticks return ticks @@ -535,12 +620,13 @@ class AxisItem(GraphicsWidget): def logTickStrings(self, values, scale, spacing): return ["%0.1g"%x for x in 10 ** np.array(values).astype(float)] - def drawPicture(self, p): - - p.setRenderHint(p.Antialiasing, False) - p.setRenderHint(p.TextAntialiasing, True) - - prof = debug.Profiler("AxisItem.paint", disabled=True) + def generateDrawSpecs(self, p): + """ + Calls tickValues() and tickStrings to determine where and how ticks should + be drawn, then generates from this a set of drawing commands to be + interpreted by drawPicture(). + """ + prof = debug.Profiler("AxisItem.generateDrawSpecs", disabled=True) #bounds = self.boundingRect() bounds = self.mapRectFromParent(self.geometry()) @@ -577,11 +663,6 @@ class AxisItem(GraphicsWidget): axis = 1 #print tickStart, tickStop, span - ## draw long line along axis - p.setPen(self.pen()) - p.drawLine(*span) - p.translate(0.5,0) ## resolves some damn pixel ambiguity - ## determine size of this item in pixels points = list(map(self.mapToDevice, span)) if None in points: @@ -610,12 +691,16 @@ class AxisItem(GraphicsWidget): ## determine mapping between tick values and local coordinates dif = self.range[1] - self.range[0] - if axis == 0: - xScale = -bounds.height() / dif - offset = self.range[0] * xScale - bounds.height() + if dif == 0: + xscale = 1 + offset = 0 else: - xScale = bounds.width() / dif - offset = self.range[0] * xScale + if axis == 0: + xScale = -bounds.height() / dif + offset = self.range[0] * xScale - bounds.height() + else: + xScale = bounds.width() / dif + offset = self.range[0] * xScale xRange = [x * xScale - offset for x in self.range] xMin = min(xRange) @@ -628,7 +713,7 @@ class AxisItem(GraphicsWidget): ## draw ticks ## (to improve performance, we do not interleave line and text drawing, since this causes unnecessary pipeline switching) ## draw three different intervals, long ticks first - + tickSpecs = [] for i in range(len(tickLevels)): tickPositions.append([]) ticks = tickLevels[i][1] @@ -658,20 +743,44 @@ class AxisItem(GraphicsWidget): color = tickPen.color() color.setAlpha(lineAlpha) tickPen.setColor(color) - p.setPen(tickPen) - p.drawLine(Point(p1), Point(p2)) - prof.mark('draw ticks') + tickSpecs.append((tickPen, Point(p1), Point(p2))) + prof.mark('compute ticks') - ## Draw text until there is no more room (or no more text) - if self.tickFont is not None: - p.setFont(self.tickFont) + ## This is where the long axis line should be drawn + if self.style['stopAxisAtTick'][0] is True: + stop = max(span[0].y(), min(map(min, tickPositions))) + if axis == 0: + span[0].setY(stop) + else: + span[0].setX(stop) + if self.style['stopAxisAtTick'][1] is True: + stop = min(span[1].y(), max(map(max, tickPositions))) + if axis == 0: + span[1].setY(stop) + else: + span[1].setX(stop) + axisSpec = (self.pen(), span[0], span[1]) + + + + textOffset = self.style['tickTextOffset'][axis] ## spacing between axis and text + #if self.style['autoExpandTextSpace'] is True: + #textWidth = self.textWidth + #textHeight = self.textHeight + #else: + #textWidth = self.style['tickTextWidth'] ## space allocated for horizontal text + #textHeight = self.style['tickTextHeight'] ## space allocated for horizontal text + + textSize2 = 0 textRects = [] + textSpecs = [] ## list of draw + textSize2 = 0 for i in range(len(tickLevels)): ## Get the list of strings to display for this level if tickStrings is None: spacing, values = tickLevels[i] - strings = self.tickStrings(values, self.scale, spacing) + strings = self.tickStrings(values, self.autoSIPrefixScale * self.scale, spacing) else: strings = tickStrings[i] @@ -683,18 +792,41 @@ class AxisItem(GraphicsWidget): if tickPositions[i][j] is None: strings[j] = None - textRects.extend([p.boundingRect(QtCore.QRectF(0, 0, 100, 100), QtCore.Qt.AlignCenter, str(s)) for s in strings if s is not None]) + ## Measure density of text; decide whether to draw this level + rects = [] + for s in strings: + if s is None: + rects.append(None) + else: + br = p.boundingRect(QtCore.QRectF(0, 0, 100, 100), QtCore.Qt.AlignCenter, str(s)) + ## boundingRect is usually just a bit too large + ## (but this probably depends on per-font metrics?) + br.setHeight(br.height() * 0.8) + + rects.append(br) + textRects.append(rects[-1]) + if i > 0: ## always draw top level ## measure all text, make sure there's enough room if axis == 0: textSize = np.sum([r.height() for r in textRects]) + textSize2 = np.max([r.width() for r in textRects]) else: textSize = np.sum([r.width() for r in textRects]) + textSize2 = np.max([r.height() for r in textRects]) - ## If the strings are too crowded, stop drawing text now + ## If the strings are too crowded, stop drawing text now. + ## We use three different crowding limits based on the number + ## of texts drawn so far. textFillRatio = float(textSize) / lengthInPixels - if textFillRatio > 0.7: + finished = False + for nTexts, limit in self.style['textFillLimits']: + if len(textSpecs) >= nTexts and textFillRatio >= limit: + finished = True + break + if finished: break + #spacing, values = tickLevels[best] #strings = self.tickStrings(values, self.scale, spacing) for j in range(len(strings)): @@ -703,24 +835,61 @@ class AxisItem(GraphicsWidget): continue vstr = str(vstr) x = tickPositions[i][j] - textRect = p.boundingRect(QtCore.QRectF(0, 0, 100, 100), QtCore.Qt.AlignCenter, vstr) + #textRect = p.boundingRect(QtCore.QRectF(0, 0, 100, 100), QtCore.Qt.AlignCenter, vstr) + textRect = rects[j] height = textRect.height() - self.textHeight = height + width = textRect.width() + #self.textHeight = height + offset = max(0,self.tickLength) + textOffset if self.orientation == 'left': - textFlags = QtCore.Qt.AlignRight|QtCore.Qt.AlignVCenter - rect = QtCore.QRectF(tickStop-100, x-(height/2), 99-max(0,self.tickLength), height) + textFlags = QtCore.Qt.TextDontClip|QtCore.Qt.AlignRight|QtCore.Qt.AlignVCenter + rect = QtCore.QRectF(tickStop-offset-width, x-(height/2), width, height) elif self.orientation == 'right': - textFlags = QtCore.Qt.AlignLeft|QtCore.Qt.AlignVCenter - rect = QtCore.QRectF(tickStop+max(0,self.tickLength)+1, x-(height/2), 100-max(0,self.tickLength), height) + textFlags = QtCore.Qt.TextDontClip|QtCore.Qt.AlignLeft|QtCore.Qt.AlignVCenter + rect = QtCore.QRectF(tickStop+offset, x-(height/2), width, height) elif self.orientation == 'top': - textFlags = QtCore.Qt.AlignCenter|QtCore.Qt.AlignBottom - rect = QtCore.QRectF(x-100, tickStop-max(0,self.tickLength)-height, 200, height) + textFlags = QtCore.Qt.TextDontClip|QtCore.Qt.AlignCenter|QtCore.Qt.AlignBottom + rect = QtCore.QRectF(x-width/2., tickStop-offset-height, width, height) elif self.orientation == 'bottom': - textFlags = QtCore.Qt.AlignCenter|QtCore.Qt.AlignTop - rect = QtCore.QRectF(x-100, tickStop+max(0,self.tickLength), 200, height) + textFlags = QtCore.Qt.TextDontClip|QtCore.Qt.AlignCenter|QtCore.Qt.AlignTop + rect = QtCore.QRectF(x-width/2., tickStop+offset, width, height) - p.setPen(self.pen()) - p.drawText(rect, textFlags, vstr) + #p.setPen(self.pen()) + #p.drawText(rect, textFlags, vstr) + textSpecs.append((rect, textFlags, vstr)) + prof.mark('compute text') + + ## update max text size if needed. + self._updateMaxTextSize(textSize2) + + return (axisSpec, tickSpecs, textSpecs) + + def drawPicture(self, p, axisSpec, tickSpecs, textSpecs): + prof = debug.Profiler("AxisItem.drawPicture", disabled=True) + + p.setRenderHint(p.Antialiasing, False) + p.setRenderHint(p.TextAntialiasing, True) + + ## draw long line along axis + pen, p1, p2 = axisSpec + p.setPen(pen) + p.drawLine(p1, p2) + p.translate(0.5,0) ## resolves some damn pixel ambiguity + + ## draw ticks + for pen, p1, p2 in tickSpecs: + p.setPen(pen) + p.drawLine(p1, p2) + prof.mark('draw ticks') + + ## Draw all text + if self.tickFont is not None: + p.setFont(self.tickFont) + p.setPen(self.pen()) + for rect, flags, text in textSpecs: + p.drawText(rect, flags, text) + #p.drawRect(rect) + prof.mark('draw text') prof.finish() diff --git a/graphicsItems/BarGraphItem.py b/graphicsItems/BarGraphItem.py new file mode 100644 index 00000000..0527e9f1 --- /dev/null +++ b/graphicsItems/BarGraphItem.py @@ -0,0 +1,149 @@ +import pyqtgraph as pg +from pyqtgraph.Qt import QtGui, QtCore +from .GraphicsObject import GraphicsObject +import numpy as np + +__all__ = ['BarGraphItem'] + +class BarGraphItem(GraphicsObject): + def __init__(self, **opts): + """ + Valid keyword options are: + x, x0, x1, y, y0, y1, width, height, pen, brush + + x specifies the x-position of the center of the bar. + x0, x1 specify left and right edges of the bar, respectively. + width specifies distance from x0 to x1. + You may specify any combination: + + x, width + x0, width + x1, width + x0, x1 + + Likewise y, y0, y1, and height. + If only height is specified, then y0 will be set to 0 + + Example uses: + + BarGraphItem(x=range(5), height=[1,5,2,4,3], width=0.5) + + + """ + GraphicsObject.__init__(self) + self.opts = dict( + x=None, + y=None, + x0=None, + y0=None, + x1=None, + y1=None, + height=None, + width=None, + pen=None, + brush=None, + pens=None, + brushes=None, + ) + self.setOpts(**opts) + + def setOpts(self, **opts): + self.opts.update(opts) + self.picture = None + self.update() + self.informViewBoundsChanged() + + def drawPicture(self): + self.picture = QtGui.QPicture() + p = QtGui.QPainter(self.picture) + + pen = self.opts['pen'] + pens = self.opts['pens'] + + if pen is None and pens is None: + pen = pg.getConfigOption('foreground') + + brush = self.opts['brush'] + brushes = self.opts['brushes'] + if brush is None and brushes is None: + brush = (128, 128, 128) + + def asarray(x): + if x is None or np.isscalar(x) or isinstance(x, np.ndarray): + return x + return np.array(x) + + + x = asarray(self.opts.get('x')) + x0 = asarray(self.opts.get('x0')) + x1 = asarray(self.opts.get('x1')) + width = asarray(self.opts.get('width')) + + if x0 is None: + if width is None: + raise Exception('must specify either x0 or width') + if x1 is not None: + x0 = x1 - width + elif x is not None: + x0 = x - width/2. + else: + raise Exception('must specify at least one of x, x0, or x1') + if width is None: + if x1 is None: + raise Exception('must specify either x1 or width') + width = x1 - x0 + + y = asarray(self.opts.get('y')) + y0 = asarray(self.opts.get('y0')) + y1 = asarray(self.opts.get('y1')) + height = asarray(self.opts.get('height')) + + if y0 is None: + if height is None: + y0 = 0 + elif y1 is not None: + y0 = y1 - height + elif y is not None: + y0 = y - height/2. + else: + y0 = 0 + if height is None: + if y1 is None: + raise Exception('must specify either y1 or height') + height = y1 - y0 + + p.setPen(pg.mkPen(pen)) + p.setBrush(pg.mkBrush(brush)) + for i in range(len(x0)): + if pens is not None: + p.setPen(pg.mkPen(pens[i])) + if brushes is not None: + p.setBrush(pg.mkBrush(brushes[i])) + + if np.isscalar(y0): + y = y0 + else: + y = y0[i] + if np.isscalar(width): + w = width + else: + w = width[i] + + p.drawRect(QtCore.QRectF(x0[i], y, w, height[i])) + + + p.end() + self.prepareGeometryChange() + + + def paint(self, p, *args): + if self.picture is None: + self.drawPicture() + self.picture.play(p) + + def boundingRect(self): + if self.picture is None: + self.drawPicture() + return QtCore.QRectF(self.picture.boundingRect()) + + \ No newline at end of file diff --git a/graphicsItems/GraphItem.py b/graphicsItems/GraphItem.py index be6138ce..b1f34baa 100644 --- a/graphicsItems/GraphItem.py +++ b/graphicsItems/GraphItem.py @@ -103,11 +103,18 @@ class GraphItem(GraphicsObject): def paint(self, p, *args): if self.picture == None: self.generatePicture() + if pg.getConfigOption('antialias') is True: + p.setRenderHint(p.Antialiasing) self.picture.play(p) def boundingRect(self): return self.scatter.boundingRect() + def dataBounds(self, *args, **kwds): + return self.scatter.dataBounds(*args, **kwds) + + def pixelPadding(self): + return self.scatter.pixelPadding() diff --git a/graphicsItems/GraphicsItem.py b/graphicsItems/GraphicsItem.py index 3a63afa7..a129436e 100644 --- a/graphicsItems/GraphicsItem.py +++ b/graphicsItems/GraphicsItem.py @@ -446,6 +446,14 @@ class GraphicsItem(object): #print " --> ", ch2.scene() #self.setChildScene(ch2) + def parentChanged(self): + """Called when the item's parent has changed. + This method handles connecting / disconnecting from ViewBox signals + to make sure viewRangeChanged works properly. It should generally be + extended, not overridden.""" + self._updateView() + + def _updateView(self): ## called to see whether this item has a new view to connect to ## NOTE: This is called from GraphicsObject.itemChange or GraphicsWidget.itemChange. @@ -496,6 +504,12 @@ class GraphicsItem(object): ## inform children that their view might have changed self._replaceView(oldView) + self.viewChanged(view, oldView) + + def viewChanged(self, view, oldView): + """Called when this item's view has changed + (ie, the item has been added to or removed from a ViewBox)""" + pass def _replaceView(self, oldView, item=None): if item is None: @@ -519,6 +533,7 @@ class GraphicsItem(object): def viewTransformChanged(self): """ Called whenever the transformation matrix of the view has changed. + (eg, the view range has changed or the view was resized) """ pass diff --git a/graphicsItems/GraphicsObject.py b/graphicsItems/GraphicsObject.py index 121a67ea..d8f55d27 100644 --- a/graphicsItems/GraphicsObject.py +++ b/graphicsItems/GraphicsObject.py @@ -12,6 +12,7 @@ class GraphicsObject(GraphicsItem, QtGui.QGraphicsObject): """ _qtBaseClass = QtGui.QGraphicsObject def __init__(self, *args): + self.__inform_view_on_changes = True QtGui.QGraphicsObject.__init__(self, *args) self.setFlag(self.ItemSendsGeometryChanges) GraphicsItem.__init__(self) @@ -19,8 +20,8 @@ class GraphicsObject(GraphicsItem, QtGui.QGraphicsObject): def itemChange(self, change, value): ret = QtGui.QGraphicsObject.itemChange(self, change, value) if change in [self.ItemParentHasChanged, self.ItemSceneHasChanged]: - self._updateView() - if change in [self.ItemPositionHasChanged, self.ItemTransformHasChanged]: + self.parentChanged() + if self.__inform_view_on_changes and change in [self.ItemPositionHasChanged, self.ItemTransformHasChanged]: self.informViewBoundsChanged() ## workaround for pyqt bug: diff --git a/graphicsItems/GraphicsWidget.py b/graphicsItems/GraphicsWidget.py index 8f28d208..7650b125 100644 --- a/graphicsItems/GraphicsWidget.py +++ b/graphicsItems/GraphicsWidget.py @@ -20,16 +20,17 @@ class GraphicsWidget(GraphicsItem, QtGui.QGraphicsWidget): ## done by GraphicsItem init #GraphicsScene.registerObject(self) ## workaround for pyqt bug in graphicsscene.items() -## Removed because this causes segmentation faults. Don't know why. -# def itemChange(self, change, value): -# ret = QtGui.QGraphicsWidget.itemChange(self, change, value) ## segv occurs here -# if change in [self.ItemParentHasChanged, self.ItemSceneHasChanged]: -# self._updateView() -# return ret + # Removed due to https://bugreports.qt-project.org/browse/PYSIDE-86 + #def itemChange(self, change, value): + ## BEWARE: Calling QGraphicsWidget.itemChange can lead to crashing! + ##ret = QtGui.QGraphicsWidget.itemChange(self, change, value) ## segv occurs here + ## The default behavior is just to return the value argument, so we'll do that + ## without calling the original method. + #ret = value + #if change in [self.ItemParentHasChanged, self.ItemSceneHasChanged]: + #self._updateView() + #return ret - #def getMenu(self): - #pass - def setFixedHeight(self, h): self.setMaximumHeight(h) self.setMinimumHeight(h) diff --git a/graphicsItems/GraphicsWidgetAnchor.py b/graphicsItems/GraphicsWidgetAnchor.py index 9770b661..251bc0c8 100644 --- a/graphicsItems/GraphicsWidgetAnchor.py +++ b/graphicsItems/GraphicsWidgetAnchor.py @@ -5,7 +5,9 @@ from ..Point import Point class GraphicsWidgetAnchor(object): """ Class used to allow GraphicsWidgets to anchor to a specific position on their - parent. + parent. The item will be automatically repositioned if the parent is resized. + This is used, for example, to anchor a LegendItem to a corner of its parent + PlotItem. """ @@ -45,7 +47,52 @@ class GraphicsWidgetAnchor(object): self.__parentAnchor = parentPos self.__offset = offset self.__geometryChanged() + + + def autoAnchor(self, pos, relative=True): + """ + Set the position of this item relative to its parent by automatically + choosing appropriate anchor settings. + If relative is True, one corner of the item will be anchored to + the appropriate location on the parent with no offset. The anchored + corner will be whichever is closest to the parent's boundary. + + If relative is False, one corner of the item will be anchored to the same + corner of the parent, with an absolute offset to achieve the correct + position. + """ + pos = Point(pos) + br = self.mapRectToParent(self.boundingRect()).translated(pos - self.pos()) + pbr = self.parentItem().boundingRect() + anchorPos = [0,0] + parentPos = Point() + itemPos = Point() + if abs(br.left() - pbr.left()) < abs(br.right() - pbr.right()): + anchorPos[0] = 0 + parentPos[0] = pbr.left() + itemPos[0] = br.left() + else: + anchorPos[0] = 1 + parentPos[0] = pbr.right() + itemPos[0] = br.right() + + if abs(br.top() - pbr.top()) < abs(br.bottom() - pbr.bottom()): + anchorPos[1] = 0 + parentPos[1] = pbr.top() + itemPos[1] = br.top() + else: + anchorPos[1] = 1 + parentPos[1] = pbr.bottom() + itemPos[1] = br.bottom() + + if relative: + relPos = [(itemPos[0]-pbr.left()) / pbr.width(), (itemPos[1]-pbr.top()) / pbr.height()] + self.anchor(anchorPos, relPos) + else: + offset = itemPos - parentPos + self.anchor(anchorPos, anchorPos, offset) + def __geometryChanged(self): if self.__parent is None: return diff --git a/graphicsItems/ImageItem.py b/graphicsItems/ImageItem.py index 123612b8..530db7fb 100644 --- a/graphicsItems/ImageItem.py +++ b/graphicsItems/ImageItem.py @@ -196,10 +196,12 @@ class ImageItem(GraphicsObject): return else: gotNewData = True - if self.image is None or image.shape != self.image.shape: - self.prepareGeometryChange() + shapeChanged = (self.image is None or image.shape != self.image.shape) self.image = image.view(np.ndarray) - + if shapeChanged: + self.prepareGeometryChange() + self.informViewBoundsChanged() + prof.mark('1') if autoLevels is None: @@ -249,7 +251,7 @@ class ImageItem(GraphicsObject): def render(self): prof = debug.Profiler('ImageItem.render', disabled=True) - if self.image is None: + if self.image is None or self.image.size == 0: return if isinstance(self.lut, collections.Callable): lut = self.lut(self.image) @@ -269,6 +271,8 @@ class ImageItem(GraphicsObject): return if self.qimage is None: self.render() + if self.qimage is None: + return prof.mark('render QImage') if self.paintMode is not None: p.setCompositionMode(self.paintMode) diff --git a/graphicsItems/LabelItem.py b/graphicsItems/LabelItem.py index 17301fb3..6101c4bc 100644 --- a/graphicsItems/LabelItem.py +++ b/graphicsItems/LabelItem.py @@ -2,11 +2,12 @@ from pyqtgraph.Qt import QtGui, QtCore import pyqtgraph.functions as fn import pyqtgraph as pg from .GraphicsWidget import GraphicsWidget +from .GraphicsWidgetAnchor import GraphicsWidgetAnchor __all__ = ['LabelItem'] -class LabelItem(GraphicsWidget): +class LabelItem(GraphicsWidget, GraphicsWidgetAnchor): """ GraphicsWidget displaying text. Used mainly as axis labels, titles, etc. @@ -17,6 +18,7 @@ class LabelItem(GraphicsWidget): def __init__(self, text=' ', parent=None, angle=0, **args): GraphicsWidget.__init__(self, parent) + GraphicsWidgetAnchor.__init__(self) self.item = QtGui.QGraphicsTextItem(self) self.opts = { 'color': None, diff --git a/graphicsItems/LegendItem.py b/graphicsItems/LegendItem.py index c41feb95..69ddffea 100644 --- a/graphicsItems/LegendItem.py +++ b/graphicsItems/LegendItem.py @@ -4,6 +4,7 @@ from ..Qt import QtGui, QtCore from .. import functions as fn from ..Point import Point from .GraphicsWidgetAnchor import GraphicsWidgetAnchor +import pyqtgraph as pg __all__ = ['LegendItem'] class LegendItem(GraphicsWidget, GraphicsWidgetAnchor): @@ -62,18 +63,43 @@ class LegendItem(GraphicsWidget, GraphicsWidgetAnchor): =========== ======================================================== Arguments item A PlotDataItem from which the line and point style - of the item will be determined + of the item will be determined or an instance of + ItemSample (or a subclass), allowing the item display + to be customized. title The title to display for this item. Simple HTML allowed. =========== ======================================================== """ label = LabelItem(name) - sample = ItemSample(item) + if isinstance(item, ItemSample): + sample = item + else: + sample = ItemSample(item) row = len(self.items) self.items.append((sample, label)) self.layout.addItem(sample, row, 0) self.layout.addItem(label, row, 1) self.updateSize() - + + def removeItem(self, name): + """ + Removes one item from the legend. + + =========== ======================================================== + Arguments + title The title displayed for this item. + =========== ======================================================== + """ + # Thanks, Ulrich! + # cycle for a match + for sample, label in self.items: + if label.text == name: # hit + self.items.remove( (sample, label) ) # remove from itemlist + self.layout.removeItem(sample) # remove from layout + sample.close() # remove from drawing + self.layout.removeItem(label) + label.close() + self.updateSize() # redraq box + def updateSize(self): if self.size is not None: return @@ -87,17 +113,29 @@ class LegendItem(GraphicsWidget, GraphicsWidgetAnchor): #print(width, height) #print width, height self.setGeometry(0, 0, width+25, height) - + def boundingRect(self): return QtCore.QRectF(0, 0, self.width(), self.height()) - + def paint(self, p, *args): p.setPen(fn.mkPen(255,255,255,100)) p.setBrush(fn.mkBrush(100,100,100,50)) p.drawRect(self.boundingRect()) + + def hoverEvent(self, ev): + ev.acceptDrags(QtCore.Qt.LeftButton) + def mouseDragEvent(self, ev): + if ev.button() == QtCore.Qt.LeftButton: + dpos = ev.pos() - ev.lastPos() + self.autoAnchor(self.pos() + dpos) class ItemSample(GraphicsWidget): + """ Class responsible for drawing a single item in a LegendItem (sans label). + + This may be subclassed to draw custom graphics in a Legend. + """ + ## Todo: make this more generic; let each item decide how it should be represented. def __init__(self, item): GraphicsWidget.__init__(self) self.item = item @@ -106,6 +144,7 @@ class ItemSample(GraphicsWidget): return QtCore.QRectF(0, 0, 20, 20) def paint(self, p, *args): + #p.setRenderHint(p.Antialiasing) # only if the data is antialiased. opts = self.item.opts if opts.get('fillLevel',None) is not None and opts.get('fillBrush',None) is not None: @@ -113,8 +152,21 @@ class ItemSample(GraphicsWidget): p.setPen(fn.mkPen(None)) p.drawPolygon(QtGui.QPolygonF([QtCore.QPointF(2,18), QtCore.QPointF(18,2), QtCore.QPointF(18,18)])) - p.setPen(fn.mkPen(opts['pen'])) - p.drawLine(2, 18, 18, 2) + if not isinstance(self.item, pg.ScatterPlotItem): + p.setPen(fn.mkPen(opts['pen'])) + p.drawLine(2, 18, 18, 2) + + symbol = opts.get('symbol', None) + if symbol is not None: + if isinstance(self.item, pg.PlotDataItem): + opts = self.item.scatter.opts + + pen = pg.mkPen(opts['pen']) + brush = pg.mkBrush(opts['brush']) + size = opts['size'] + + p.translate(10,10) + path = pg.graphicsItems.ScatterPlotItem.drawSymbol(p, symbol, size, pen, brush) diff --git a/graphicsItems/PlotCurveItem.py b/graphicsItems/PlotCurveItem.py index 35a38ae7..28214552 100644 --- a/graphicsItems/PlotCurveItem.py +++ b/graphicsItems/PlotCurveItem.py @@ -1,7 +1,11 @@ from pyqtgraph.Qt import QtGui, QtCore -from scipy.fftpack import fft +try: + from pyqtgraph.Qt import QtOpenGL + HAVE_OPENGL = True +except: + HAVE_OPENGL = False + import numpy as np -import scipy.stats from .GraphicsObject import GraphicsObject import pyqtgraph.functions as fn from pyqtgraph import debug @@ -21,7 +25,6 @@ class PlotCurveItem(GraphicsObject): Features: - Fast data update - - FFT display mode (accessed via PlotItem context menu) - Fill under curve - Mouse interaction @@ -65,7 +68,8 @@ class PlotCurveItem(GraphicsObject): 'brush': None, 'stepMode': False, 'name': None, - 'antialias': pg.getConfigOption('antialias'), + 'antialias': pg.getConfigOption('antialias'),\ + 'connect': 'all', } self.setClickable(kargs.get('clickable', False)) self.setData(*args, **kargs) @@ -106,16 +110,21 @@ class PlotCurveItem(GraphicsObject): if orthoRange is not None: mask = (d2 >= orthoRange[0]) * (d2 <= orthoRange[1]) d = d[mask] - d2 = d2[mask] + #d2 = d2[mask] + + if len(d) == 0: + return (None, None) ## Get min/max (or percentiles) of the requested data range if frac >= 1.0: - b = (d.min(), d.max()) + b = (np.nanmin(d), np.nanmax(d)) elif frac <= 0.0: raise Exception("Value for parameter 'frac' must be > 0. (got %s)" % str(frac)) else: - b = (scipy.stats.scoreatpercentile(d, 50 - (frac * 50)), scipy.stats.scoreatpercentile(d, 50 + (frac * 50))) - + mask = np.isfinite(d) + d = d[mask] + b = np.percentile(d, [50 * (1 - frac), 50 * (1 + frac)]) + ## adjust for fill level if ax == 1 and self.opts['fillLevel'] is not None: b = (min(b[0], self.opts['fillLevel']), max(b[1], self.opts['fillLevel'])) @@ -252,6 +261,15 @@ class PlotCurveItem(GraphicsObject): by :func:`mkBrush ` is allowed. antialias (bool) Whether to use antialiasing when drawing. This is disabled by default because it decreases performance. + stepMode If True, two orthogonal lines are drawn for each sample + as steps. This is commonly used when drawing histograms. + Note that in this case, len(x) == len(y) + 1 + connect Argument specifying how vertexes should be connected + by line segments. Default is "all", indicating full + connection. "pairs" causes only even-numbered segments + to be drawn. "finite" causes segments to be omitted if + they are attached to nan or inf values. For any other + connectivity, specify an array of boolean values. ============== ======================================================== If non-keyword arguments are used, they will be interpreted as @@ -303,10 +321,10 @@ class PlotCurveItem(GraphicsObject): if self.opts['stepMode'] is True: if len(self.xData) != len(self.yData)+1: ## allow difference of 1 for step mode plots - raise Exception("len(X) must be len(Y)+1 since stepMode=True (got %s and %s)" % (str(x.shape), str(y.shape))) + raise Exception("len(X) must be len(Y)+1 since stepMode=True (got %s and %s)" % (self.xData.shape, self.yData.shape)) else: if self.xData.shape != self.yData.shape: ## allow difference of 1 for step mode plots - raise Exception("X and Y arrays must be the same shape--got %s and %s." % (str(x.shape), str(y.shape))) + raise Exception("X and Y arrays must be the same shape--got %s and %s." % (self.xData.shape, self.yData.shape)) self.path = None self.fillPath = None @@ -314,7 +332,8 @@ class PlotCurveItem(GraphicsObject): if 'name' in kargs: self.opts['name'] = kargs['name'] - + if 'connect' in kargs: + self.opts['connect'] = kargs['connect'] if 'pen' in kargs: self.setPen(kargs['pen']) if 'shadowPen' in kargs: @@ -353,7 +372,7 @@ class PlotCurveItem(GraphicsObject): y[0] = self.opts['fillLevel'] y[-1] = self.opts['fillLevel'] - path = fn.arrayToQPath(x, y, connect='all') + path = fn.arrayToQPath(x, y, connect=self.opts['connect']) return path @@ -366,16 +385,16 @@ class PlotCurveItem(GraphicsObject): return QtGui.QPainterPath() return self.path + @pg.debug.warnOnException ## raising an exception here causes crash def paint(self, p, opt, widget): prof = debug.Profiler('PlotCurveItem.paint '+str(id(self)), disabled=True) if self.xData is None: return - #if self.opts['spectrumMode']: - #if self.specPath is None: - - #self.specPath = self.generatePath(*self.getData()) - #path = self.specPath - #else: + + if HAVE_OPENGL and pg.getConfigOption('enableExperimental') and isinstance(widget, QtOpenGL.QGLWidget): + self.paintGL(p, opt, widget) + return + x = None y = None if self.path is None: @@ -385,7 +404,6 @@ class PlotCurveItem(GraphicsObject): self.path = self.generatePath(x,y) self.fillPath = None - path = self.path prof.mark('generate path') @@ -440,6 +458,65 @@ class PlotCurveItem(GraphicsObject): #p.setPen(QtGui.QPen(QtGui.QColor(255,0,0))) #p.drawRect(self.boundingRect()) + def paintGL(self, p, opt, widget): + p.beginNativePainting() + import OpenGL.GL as gl + + ## set clipping viewport + view = self.getViewBox() + if view is not None: + rect = view.mapRectToItem(self, view.boundingRect()) + #gl.glViewport(int(rect.x()), int(rect.y()), int(rect.width()), int(rect.height())) + + #gl.glTranslate(-rect.x(), -rect.y(), 0) + + gl.glEnable(gl.GL_STENCIL_TEST) + gl.glColorMask(gl.GL_FALSE, gl.GL_FALSE, gl.GL_FALSE, gl.GL_FALSE) # disable drawing to frame buffer + gl.glDepthMask(gl.GL_FALSE) # disable drawing to depth buffer + gl.glStencilFunc(gl.GL_NEVER, 1, 0xFF) + gl.glStencilOp(gl.GL_REPLACE, gl.GL_KEEP, gl.GL_KEEP) + + ## draw stencil pattern + gl.glStencilMask(0xFF); + gl.glClear(gl.GL_STENCIL_BUFFER_BIT) + gl.glBegin(gl.GL_TRIANGLES) + gl.glVertex2f(rect.x(), rect.y()) + gl.glVertex2f(rect.x()+rect.width(), rect.y()) + gl.glVertex2f(rect.x(), rect.y()+rect.height()) + gl.glVertex2f(rect.x()+rect.width(), rect.y()+rect.height()) + gl.glVertex2f(rect.x()+rect.width(), rect.y()) + gl.glVertex2f(rect.x(), rect.y()+rect.height()) + gl.glEnd() + + gl.glColorMask(gl.GL_TRUE, gl.GL_TRUE, gl.GL_TRUE, gl.GL_TRUE) + gl.glDepthMask(gl.GL_TRUE) + gl.glStencilMask(0x00) + gl.glStencilFunc(gl.GL_EQUAL, 1, 0xFF) + + try: + x, y = self.getData() + pos = np.empty((len(x), 2)) + pos[:,0] = x + pos[:,1] = y + gl.glEnableClientState(gl.GL_VERTEX_ARRAY) + try: + gl.glVertexPointerf(pos) + pen = fn.mkPen(self.opts['pen']) + color = pen.color() + gl.glColor4f(color.red()/255., color.green()/255., color.blue()/255., color.alpha()/255.) + width = pen.width() + if pen.isCosmetic() and width < 1: + width = 1 + gl.glPointSize(width) + gl.glEnable(gl.GL_LINE_SMOOTH) + gl.glEnable(gl.GL_BLEND) + gl.glBlendFunc(gl.GL_SRC_ALPHA, gl.GL_ONE_MINUS_SRC_ALPHA) + gl.glHint(gl.GL_LINE_SMOOTH_HINT, gl.GL_NICEST); + gl.glDrawArrays(gl.GL_LINE_STRIP, 0, pos.size / pos.shape[-1]) + finally: + gl.glDisableClientState(gl.GL_VERTEX_ARRAY) + finally: + p.endNativePainting() def clear(self): self.xData = None ## raw values diff --git a/graphicsItems/PlotDataItem.py b/graphicsItems/PlotDataItem.py index c0d5f2f3..87b47227 100644 --- a/graphicsItems/PlotDataItem.py +++ b/graphicsItems/PlotDataItem.py @@ -4,7 +4,6 @@ from .GraphicsObject import GraphicsObject from .PlotCurveItem import PlotCurveItem from .ScatterPlotItem import ScatterPlotItem import numpy as np -import scipy import pyqtgraph.functions as fn import pyqtgraph.debug as debug import pyqtgraph as pg @@ -58,6 +57,8 @@ class PlotDataItem(GraphicsObject): **Line style keyword arguments:** ========== ================================================ + connect Specifies how / whether vertexes should be connected. + See :func:`arrayToQPath() ` pen Pen to use for drawing line between points. Default is solid grey, 1px width. Use None to disable line drawing. May be any single argument accepted by :func:`mkPen() ` @@ -84,13 +85,28 @@ class PlotDataItem(GraphicsObject): **Optimization keyword arguments:** - ========== ===================================================================== - antialias (bool) By default, antialiasing is disabled to improve performance. - Note that in some cases (in particluar, when pxMode=True), points - will be rendered antialiased even if this is set to False. - identical *deprecated* - decimate (int) sub-sample data by selecting every nth sample before plotting - ========== ===================================================================== + ================ ===================================================================== + antialias (bool) By default, antialiasing is disabled to improve performance. + Note that in some cases (in particluar, when pxMode=True), points + will be rendered antialiased even if this is set to False. + decimate deprecated. + downsample (int) Reduce the number of samples displayed by this value + downsampleMethod 'subsample': Downsample by taking the first of N samples. + This method is fastest and least accurate. + 'mean': Downsample by taking the mean of N samples. + 'peak': Downsample by drawing a saw wave that follows the min + and max of the original data. This method produces the best + visual representation of the data but is slower. + autoDownsample (bool) If True, resample the data before plotting to avoid plotting + multiple line segments per pixel. This can improve performance when + viewing very high-density data, but increases the initial overhead + and memory usage. + clipToView (bool) If True, only plot data that is visible within the X range of + the containing ViewBox. This can improve performance when plotting + very large data sets where only a fraction of the data is visible + at any time. + identical *deprecated* + ================ ===================================================================== **Meta-info keyword arguments:** @@ -104,7 +120,7 @@ class PlotDataItem(GraphicsObject): self.yData = None self.xDisp = None self.yDisp = None - self.dataMask = None + #self.dataMask = None #self.curves = [] #self.scatters = [] self.curve = PlotCurveItem() @@ -118,9 +134,10 @@ class PlotDataItem(GraphicsObject): #self.clear() self.opts = { + 'connect': 'all', + 'fftMode': False, 'logMode': [False, False], - 'downsample': False, 'alphaHint': 1.0, 'alphaMode': False, @@ -138,6 +155,11 @@ class PlotDataItem(GraphicsObject): 'antialias': pg.getConfigOption('antialias'), 'pointMode': None, + 'downsample': 1, + 'autoDownsample': False, + 'downsampleMethod': 'peak', + 'clipToView': False, + 'data': None, } self.setData(*args, **kargs) @@ -164,6 +186,7 @@ class PlotDataItem(GraphicsObject): return self.opts['fftMode'] = mode self.xDisp = self.yDisp = None + self.xClean = self.yClean = None self.updateItems() self.informViewBoundsChanged() @@ -172,6 +195,7 @@ class PlotDataItem(GraphicsObject): return self.opts['logMode'] = [xMode, yMode] self.xDisp = self.yDisp = None + self.xClean = self.yClean = None self.updateItems() self.informViewBoundsChanged() @@ -258,13 +282,51 @@ class PlotDataItem(GraphicsObject): #self.scatter.setSymbolSize(symbolSize) self.updateItems() - def setDownsampling(self, ds): - if self.opts['downsample'] == ds: + def setDownsampling(self, ds=None, auto=None, method=None): + """ + Set the downsampling mode of this item. Downsampling reduces the number + of samples drawn to increase performance. + + =========== ================================================================= + Arguments + ds (int) Reduce visible plot samples by this factor. To disable, + set ds=1. + auto (bool) If True, automatically pick *ds* based on visible range + mode 'subsample': Downsample by taking the first of N samples. + This method is fastest and least accurate. + 'mean': Downsample by taking the mean of N samples. + 'peak': Downsample by drawing a saw wave that follows the min + and max of the original data. This method produces the best + visual representation of the data but is slower. + =========== ================================================================= + """ + changed = False + if ds is not None: + if self.opts['downsample'] != ds: + changed = True + self.opts['downsample'] = ds + + if auto is not None and self.opts['autoDownsample'] != auto: + self.opts['autoDownsample'] = auto + changed = True + + if method is not None: + if self.opts['downsampleMethod'] != method: + changed = True + self.opts['downsampleMethod'] = method + + if changed: + self.xDisp = self.yDisp = None + self.updateItems() + + def setClipToView(self, clip): + if self.opts['clipToView'] == clip: return - self.opts['downsample'] = ds + self.opts['clipToView'] = clip self.xDisp = self.yDisp = None self.updateItems() + def setData(self, *args, **kargs): """ Clear any data displayed by this item and display new data. @@ -304,7 +366,7 @@ class PlotDataItem(GraphicsObject): raise Exception('Invalid data type %s' % type(data)) elif len(args) == 2: - seq = ('listOfValues', 'MetaArray') + seq = ('listOfValues', 'MetaArray', 'empty') if dataType(args[0]) not in seq or dataType(args[1]) not in seq: raise Exception('When passing two unnamed arguments, both must be a list or array of values. (got %s, %s)' % (str(type(args[0])), str(type(args[1])))) if not isinstance(args[0], np.ndarray): @@ -327,6 +389,8 @@ class PlotDataItem(GraphicsObject): if 'name' in kargs: self.opts['name'] = kargs['name'] + if 'connect' in kargs: + self.opts['connect'] = kargs['connect'] ## if symbol pen/brush are given with no symbol, then assume symbol is 'o' @@ -365,6 +429,7 @@ class PlotDataItem(GraphicsObject): self.xData = x.view(np.ndarray) ## one last check to make sure there are no MetaArrays getting by self.yData = y.view(np.ndarray) + self.xClean = self.yClean = None self.xDisp = None self.yDisp = None prof.mark('set data') @@ -385,7 +450,7 @@ class PlotDataItem(GraphicsObject): def updateItems(self): curveArgs = {} - for k,v in [('pen','pen'), ('shadowPen','shadowPen'), ('fillLevel','fillLevel'), ('fillBrush', 'brush'), ('antialias', 'antialias')]: + for k,v in [('pen','pen'), ('shadowPen','shadowPen'), ('fillLevel','fillLevel'), ('fillBrush', 'brush'), ('antialias', 'antialias'), ('connect', 'connect')]: curveArgs[v] = self.opts[k] scatterArgs = {} @@ -394,7 +459,7 @@ class PlotDataItem(GraphicsObject): scatterArgs[v] = self.opts[k] x,y = self.getData() - scatterArgs['mask'] = self.dataMask + #scatterArgs['mask'] = self.dataMask if curveArgs['pen'] is not None or (curveArgs['brush'] is not None and curveArgs['fillLevel'] is not None): self.curve.setData(x=x, y=y, **curveArgs) @@ -412,40 +477,89 @@ class PlotDataItem(GraphicsObject): def getData(self): if self.xData is None: return (None, None) - if self.xDisp is None: - nanMask = np.isnan(self.xData) | np.isnan(self.yData) | np.isinf(self.xData) | np.isinf(self.yData) - if any(nanMask): - self.dataMask = ~nanMask - x = self.xData[self.dataMask] - y = self.yData[self.dataMask] - else: - self.dataMask = None - x = self.xData - y = self.yData - + + #if self.xClean is None: + #nanMask = np.isnan(self.xData) | np.isnan(self.yData) | np.isinf(self.xData) | np.isinf(self.yData) + #if nanMask.any(): + #self.dataMask = ~nanMask + #self.xClean = self.xData[self.dataMask] + #self.yClean = self.yData[self.dataMask] + #else: + #self.dataMask = None + #self.xClean = self.xData + #self.yClean = self.yData - ds = self.opts['downsample'] - if ds > 1: - x = x[::ds] - #y = resample(y[:len(x)*ds], len(x)) ## scipy.signal.resample causes nasty ringing - y = y[::ds] + if self.xDisp is None: + x = self.xData + y = self.yData + + + #ds = self.opts['downsample'] + #if isinstance(ds, int) and ds > 1: + #x = x[::ds] + ##y = resample(y[:len(x)*ds], len(x)) ## scipy.signal.resample causes nasty ringing + #y = y[::ds] if self.opts['fftMode']: - f = np.fft.fft(y) / len(y) - y = abs(f[1:len(f)/2]) - dt = x[-1] - x[0] - x = np.linspace(0, 0.5*len(x)/dt, len(y)) + x,y = self._fourierTransform(x, y) if self.opts['logMode'][0]: x = np.log10(x) if self.opts['logMode'][1]: y = np.log10(y) - if any(self.opts['logMode']): ## re-check for NANs after log - nanMask = np.isinf(x) | np.isinf(y) | np.isnan(x) | np.isnan(y) - if any(nanMask): - self.dataMask = ~nanMask - x = x[self.dataMask] - y = y[self.dataMask] - else: - self.dataMask = None + #if any(self.opts['logMode']): ## re-check for NANs after log + #nanMask = np.isinf(x) | np.isinf(y) | np.isnan(x) | np.isnan(y) + #if any(nanMask): + #self.dataMask = ~nanMask + #x = x[self.dataMask] + #y = y[self.dataMask] + #else: + #self.dataMask = None + + ds = self.opts['downsample'] + if not isinstance(ds, int): + ds = 1 + + if self.opts['autoDownsample']: + # 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) + x0 = (range.left()-x[0]) / dx + x1 = (range.right()-x[0]) / dx + width = self.getViewBox().width() + ds = int(max(1, int(0.2 * (x1-x0) / width))) + ## 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] + + if ds > 1: + if self.opts['downsampleMethod'] == 'subsample': + x = x[::ds] + y = y[::ds] + elif self.opts['downsampleMethod'] == 'mean': + n = len(x) / ds + x = x[:n*ds:ds] + y = y[:n*ds].reshape(n,ds).mean(axis=1) + elif self.opts['downsampleMethod'] == 'peak': + n = len(x) / ds + x1 = np.empty((n,2)) + x1[:] = x[:n*ds:ds,np.newaxis] + x = x1.reshape(n*2) + y1 = np.empty((n,2)) + y2 = y[:n*ds].reshape((n, ds)) + y1[:,0] = y2.max(axis=1) + y1[:,1] = y2.min(axis=1) + y = y1.reshape(n*2) + + self.xDisp = x self.yDisp = y #print self.yDisp.shape, self.yDisp.min(), self.yDisp.max() @@ -482,33 +596,6 @@ class PlotDataItem(GraphicsObject): r2[1] if range[1] is None else (range[1] if r2[1] is None else min(r2[1], range[1])) ] return range - - #if frac <= 0.0: - #raise Exception("Value for parameter 'frac' must be > 0. (got %s)" % str(frac)) - - #(x, y) = self.getData() - #if x is None or len(x) == 0: - #return None - - #if ax == 0: - #d = x - #d2 = y - #elif ax == 1: - #d = y - #d2 = x - - #if orthoRange is not None: - #mask = (d2 >= orthoRange[0]) * (d2 <= orthoRange[1]) - #d = d[mask] - ##d2 = d2[mask] - - #if len(d) > 0: - #if frac >= 1.0: - #return (np.min(d), np.max(d)) - #else: - #return (scipy.stats.scoreatpercentile(d, 50 - (frac * 50)), scipy.stats.scoreatpercentile(d, 50 + (frac * 50))) - #else: - #return None def pixelPadding(self): """ @@ -531,6 +618,8 @@ class PlotDataItem(GraphicsObject): #self.scatters = [] self.xData = None self.yData = None + #self.xClean = None + #self.yClean = None self.xDisp = None self.yDisp = None self.curve.setData([]) @@ -546,6 +635,27 @@ class PlotDataItem(GraphicsObject): self.sigClicked.emit(self) self.sigPointsClicked.emit(self, points) + def viewRangeChanged(self): + # view range has changed; re-plot if needed + if self.opts['clipToView'] or self.opts['autoDownsample']: + self.xDisp = self.yDisp = None + self.updateItems() + + def _fourierTransform(self, x, y): + ## Perform fourier transform. If x values are not sampled uniformly, + ## then use interpolate.griddata to resample before taking fft. + dx = np.diff(x) + uniform = not np.any(np.abs(dx-dx[0]) > (abs(dx[0]) / 1000.)) + if not uniform: + import scipy.interpolate as interp + x2 = np.linspace(x[0], x[-1], len(x)) + y = interp.griddata(x, y, x2, method='linear') + x = x2 + f = np.fft.fft(y) / len(y) + y = abs(f[1:len(f)/2]) + dt = x[-1] - x[0] + x = np.linspace(0, 0.5*len(x)/dt, len(y)) + return x, y def dataType(obj): if hasattr(obj, '__len__') and len(obj) == 0: diff --git a/graphicsItems/PlotItem/PlotItem.py b/graphicsItems/PlotItem/PlotItem.py index 3100087a..ec0960ba 100644 --- a/graphicsItems/PlotItem/PlotItem.py +++ b/graphicsItems/PlotItem/PlotItem.py @@ -256,6 +256,11 @@ class PlotItem(GraphicsWidget): c.logYCheck.toggled.connect(self.updateLogMode) c.downsampleSpin.valueChanged.connect(self.updateDownsampling) + c.downsampleCheck.toggled.connect(self.updateDownsampling) + c.autoDownsampleCheck.toggled.connect(self.updateDownsampling) + c.subsampleRadio.toggled.connect(self.updateDownsampling) + c.meanRadio.toggled.connect(self.updateDownsampling) + c.clipToViewCheck.toggled.connect(self.updateDownsampling) self.ctrl.avgParamList.itemClicked.connect(self.avgParamListClicked) self.ctrl.averageGroup.toggled.connect(self.avgToggled) @@ -295,19 +300,21 @@ class PlotItem(GraphicsWidget): - def setLogMode(self, x, y): + def setLogMode(self, x=None, y=None): """ - Set log scaling for x and y axes. + Set log scaling for x and/or y axes. This informs PlotDataItems to transform logarithmically and switches the axes to use log ticking. Note that *no other items* in the scene will be affected by - this; there is no generic way to redisplay a GraphicsItem + this; there is (currently) no generic way to redisplay a GraphicsItem with log coordinates. """ - self.ctrl.logXCheck.setChecked(x) - self.ctrl.logYCheck.setChecked(y) + if x is not None: + self.ctrl.logXCheck.setChecked(x) + if y is not None: + self.ctrl.logYCheck.setChecked(y) def showGrid(self, x=None, y=None, alpha=None): """ @@ -526,7 +533,8 @@ class PlotItem(GraphicsWidget): (alpha, auto) = self.alphaState() item.setAlpha(alpha, auto) item.setFftMode(self.ctrl.fftCheck.isChecked()) - item.setDownsampling(self.downsampleMode()) + item.setDownsampling(*self.downsampleMode()) + item.setClipToView(self.clipToViewMode()) item.setPointMode(self.pointMode()) ## Hide older plots if needed @@ -568,8 +576,8 @@ class PlotItem(GraphicsWidget): :func:`InfiniteLine.__init__() `. Returns the item created. """ - angle = 0 if x is None else 90 - pos = x if x is not None else y + pos = kwds.get('pos', x if x is not None else y) + angle = kwds.get('angle', 0 if x is None else 90) line = InfiniteLine(pos, angle, **kwds) self.addItem(line) if z is not None: @@ -941,23 +949,81 @@ class PlotItem(GraphicsWidget): self.enableAutoRange() self.recomputeAverages() + def setDownsampling(self, ds=None, auto=None, mode=None): + """Change the default downsampling mode for all PlotDataItems managed by this plot. + =========== ================================================================= + Arguments + ds (int) Reduce visible plot samples by this factor, or + (bool) To enable/disable downsampling without changing the value. + auto (bool) If True, automatically pick *ds* based on visible range + mode 'subsample': Downsample by taking the first of N samples. + This method is fastest and least accurate. + 'mean': Downsample by taking the mean of N samples. + 'peak': Downsample by drawing a saw wave that follows the min + and max of the original data. This method produces the best + visual representation of the data but is slower. + =========== ================================================================= + """ + if ds is not None: + if ds is False: + self.ctrl.downsampleCheck.setChecked(False) + elif ds is True: + self.ctrl.downsampleCheck.setChecked(True) + else: + self.ctrl.downsampleCheck.setChecked(True) + self.ctrl.downsampleSpin.setValue(ds) + + if auto is not None: + if auto and ds is not False: + self.ctrl.downsampleCheck.setChecked(True) + self.ctrl.autoDownsampleCheck.setChecked(auto) + + if mode is not None: + if mode == 'subsample': + self.ctrl.subsampleRadio.setChecked(True) + elif mode == 'mean': + self.ctrl.meanRadio.setChecked(True) + elif mode == 'peak': + self.ctrl.peakRadio.setChecked(True) + else: + raise ValueError("mode argument must be 'subsample', 'mean', or 'peak'.") + def updateDownsampling(self): - ds = self.downsampleMode() + ds, auto, method = self.downsampleMode() + clip = self.ctrl.clipToViewCheck.isChecked() for c in self.curves: - c.setDownsampling(ds) + c.setDownsampling(ds, auto, method) + c.setClipToView(clip) self.recomputeAverages() - def downsampleMode(self): - if self.ctrl.decimateGroup.isChecked(): - if self.ctrl.manualDecimateRadio.isChecked(): - ds = self.ctrl.downsampleSpin.value() - else: - ds = True + if self.ctrl.downsampleCheck.isChecked(): + ds = self.ctrl.downsampleSpin.value() else: - ds = False - return ds + ds = 1 + + auto = self.ctrl.downsampleCheck.isChecked() and self.ctrl.autoDownsampleCheck.isChecked() + + if self.ctrl.subsampleRadio.isChecked(): + method = 'subsample' + elif self.ctrl.meanRadio.isChecked(): + method = 'mean' + elif self.ctrl.peakRadio.isChecked(): + method = 'peak' + + return ds, auto, method + + def setClipToView(self, clip): + """Set the default clip-to-view mode for all PlotDataItems managed by this plot. + If *clip* is True, then PlotDataItems will attempt to draw only points within the visible + range of the ViewBox.""" + self.ctrl.clipToViewCheck.setChecked(clip) + + def clipToViewMode(self): + return self.ctrl.clipToViewCheck.isChecked() + + def updateDecimation(self): if self.ctrl.maxTracesCheck.isChecked(): @@ -1079,6 +1145,7 @@ class PlotItem(GraphicsWidget): ============= ================================================================= """ self.getAxis(axis).setLabel(text=text, units=units, **args) + self.showAxis(axis) def setLabels(self, **kwds): """ diff --git a/graphicsItems/PlotItem/plotConfigTemplate.ui b/graphicsItems/PlotItem/plotConfigTemplate.ui index 516ec721..dffc62d0 100644 --- a/graphicsItems/PlotItem/plotConfigTemplate.ui +++ b/graphicsItems/PlotItem/plotConfigTemplate.ui @@ -6,8 +6,8 @@ 0 0 - 258 - 605 + 481 + 840 @@ -16,8 +16,8 @@ - 10 - 200 + 0 + 640 242 182 @@ -46,21 +46,15 @@ - + - 0 - 70 - 242 - 160 + 10 + 140 + 191 + 171 - - Downsample - - - true - 0 @@ -68,40 +62,17 @@ 0 - - + + + + Plot only the portion of each curve that is visible. This assumes X values are uniformly spaced. + - Manual - - - true + Clip to View - - - - 1 - - - 100000 - - - 1 - - - - - - - Auto - - - false - - - - + If multiple curves are displayed in this plot, check this box to limit the number of traces that are displayed. @@ -111,14 +82,34 @@ - + + + + Downsample + + + + + + + Downsample by drawing a saw wave that follows the min and max of the original data. This method produces the best visual representation of the data but is slower. + + + Peak + + + true + + + + If multiple curves are displayed in this plot, check "Max Traces" and set this value to limit the number of traces that are displayed. - + If MaxTraces is checked, remove curves from memory after they are hidden (saves memory, but traces can not be un-hidden). @@ -128,6 +119,74 @@ + + + + Downsample by taking the mean of N samples. + + + Mean + + + + + + + Downsample by taking the first of N samples. This method is fastest and least accurate. + + + Subsample + + + + + + + Automatically downsample data based on the visible range. This assumes X values are uniformly spaced. + + + Auto + + + true + + + + + + + Qt::Horizontal + + + QSizePolicy::Maximum + + + + 30 + 20 + + + + + + + + Downsample data before plotting. (plot every Nth sample) + + + x + + + 1 + + + 100000 + + + 1 + + + diff --git a/graphicsItems/PlotItem/plotConfigTemplate_pyqt.py b/graphicsItems/PlotItem/plotConfigTemplate_pyqt.py index d34cd297..5335ee76 100644 --- a/graphicsItems/PlotItem/plotConfigTemplate_pyqt.py +++ b/graphicsItems/PlotItem/plotConfigTemplate_pyqt.py @@ -1,9 +1,9 @@ # -*- coding: utf-8 -*- -# Form implementation generated from reading ui file './graphicsItems/PlotItem/plotConfigTemplate.ui' +# Form implementation generated from reading ui file './pyqtgraph/graphicsItems/PlotItem/plotConfigTemplate.ui' # -# Created: Sun Sep 9 14:41:32 2012 -# by: PyQt4 UI code generator 4.9.1 +# Created: Mon Jul 1 23:21:08 2013 +# by: PyQt4 UI code generator 4.9.3 # # WARNING! All changes made in this file will be lost! @@ -17,9 +17,9 @@ except AttributeError: class Ui_Form(object): def setupUi(self, Form): Form.setObjectName(_fromUtf8("Form")) - Form.resize(258, 605) + Form.resize(481, 840) self.averageGroup = QtGui.QGroupBox(Form) - self.averageGroup.setGeometry(QtCore.QRect(10, 200, 242, 182)) + self.averageGroup.setGeometry(QtCore.QRect(0, 640, 242, 182)) self.averageGroup.setCheckable(True) self.averageGroup.setChecked(False) self.averageGroup.setObjectName(_fromUtf8("averageGroup")) @@ -30,37 +30,50 @@ class Ui_Form(object): self.avgParamList = QtGui.QListWidget(self.averageGroup) self.avgParamList.setObjectName(_fromUtf8("avgParamList")) self.gridLayout_5.addWidget(self.avgParamList, 0, 0, 1, 1) - self.decimateGroup = QtGui.QGroupBox(Form) - self.decimateGroup.setGeometry(QtCore.QRect(0, 70, 242, 160)) - self.decimateGroup.setCheckable(True) + self.decimateGroup = QtGui.QFrame(Form) + self.decimateGroup.setGeometry(QtCore.QRect(10, 140, 191, 171)) self.decimateGroup.setObjectName(_fromUtf8("decimateGroup")) self.gridLayout_4 = QtGui.QGridLayout(self.decimateGroup) self.gridLayout_4.setMargin(0) self.gridLayout_4.setSpacing(0) self.gridLayout_4.setObjectName(_fromUtf8("gridLayout_4")) - self.manualDecimateRadio = QtGui.QRadioButton(self.decimateGroup) - self.manualDecimateRadio.setChecked(True) - self.manualDecimateRadio.setObjectName(_fromUtf8("manualDecimateRadio")) - self.gridLayout_4.addWidget(self.manualDecimateRadio, 0, 0, 1, 1) + self.clipToViewCheck = QtGui.QCheckBox(self.decimateGroup) + self.clipToViewCheck.setObjectName(_fromUtf8("clipToViewCheck")) + self.gridLayout_4.addWidget(self.clipToViewCheck, 7, 0, 1, 3) + self.maxTracesCheck = QtGui.QCheckBox(self.decimateGroup) + self.maxTracesCheck.setObjectName(_fromUtf8("maxTracesCheck")) + self.gridLayout_4.addWidget(self.maxTracesCheck, 8, 0, 1, 2) + self.downsampleCheck = QtGui.QCheckBox(self.decimateGroup) + self.downsampleCheck.setObjectName(_fromUtf8("downsampleCheck")) + self.gridLayout_4.addWidget(self.downsampleCheck, 0, 0, 1, 3) + self.peakRadio = QtGui.QRadioButton(self.decimateGroup) + self.peakRadio.setChecked(True) + self.peakRadio.setObjectName(_fromUtf8("peakRadio")) + self.gridLayout_4.addWidget(self.peakRadio, 6, 1, 1, 2) + self.maxTracesSpin = QtGui.QSpinBox(self.decimateGroup) + self.maxTracesSpin.setObjectName(_fromUtf8("maxTracesSpin")) + self.gridLayout_4.addWidget(self.maxTracesSpin, 8, 2, 1, 1) + self.forgetTracesCheck = QtGui.QCheckBox(self.decimateGroup) + self.forgetTracesCheck.setObjectName(_fromUtf8("forgetTracesCheck")) + self.gridLayout_4.addWidget(self.forgetTracesCheck, 9, 0, 1, 3) + self.meanRadio = QtGui.QRadioButton(self.decimateGroup) + self.meanRadio.setObjectName(_fromUtf8("meanRadio")) + self.gridLayout_4.addWidget(self.meanRadio, 3, 1, 1, 2) + self.subsampleRadio = QtGui.QRadioButton(self.decimateGroup) + self.subsampleRadio.setObjectName(_fromUtf8("subsampleRadio")) + self.gridLayout_4.addWidget(self.subsampleRadio, 2, 1, 1, 2) + self.autoDownsampleCheck = QtGui.QCheckBox(self.decimateGroup) + self.autoDownsampleCheck.setChecked(True) + self.autoDownsampleCheck.setObjectName(_fromUtf8("autoDownsampleCheck")) + self.gridLayout_4.addWidget(self.autoDownsampleCheck, 1, 2, 1, 1) + spacerItem = QtGui.QSpacerItem(30, 20, QtGui.QSizePolicy.Maximum, QtGui.QSizePolicy.Minimum) + self.gridLayout_4.addItem(spacerItem, 2, 0, 1, 1) self.downsampleSpin = QtGui.QSpinBox(self.decimateGroup) self.downsampleSpin.setMinimum(1) self.downsampleSpin.setMaximum(100000) self.downsampleSpin.setProperty("value", 1) self.downsampleSpin.setObjectName(_fromUtf8("downsampleSpin")) - self.gridLayout_4.addWidget(self.downsampleSpin, 0, 1, 1, 1) - self.autoDecimateRadio = QtGui.QRadioButton(self.decimateGroup) - self.autoDecimateRadio.setChecked(False) - self.autoDecimateRadio.setObjectName(_fromUtf8("autoDecimateRadio")) - self.gridLayout_4.addWidget(self.autoDecimateRadio, 1, 0, 1, 1) - self.maxTracesCheck = QtGui.QCheckBox(self.decimateGroup) - self.maxTracesCheck.setObjectName(_fromUtf8("maxTracesCheck")) - self.gridLayout_4.addWidget(self.maxTracesCheck, 2, 0, 1, 1) - self.maxTracesSpin = QtGui.QSpinBox(self.decimateGroup) - self.maxTracesSpin.setObjectName(_fromUtf8("maxTracesSpin")) - self.gridLayout_4.addWidget(self.maxTracesSpin, 2, 1, 1, 1) - self.forgetTracesCheck = QtGui.QCheckBox(self.decimateGroup) - self.forgetTracesCheck.setObjectName(_fromUtf8("forgetTracesCheck")) - self.gridLayout_4.addWidget(self.forgetTracesCheck, 3, 0, 1, 2) + self.gridLayout_4.addWidget(self.downsampleSpin, 1, 1, 1, 1) self.transformGroup = QtGui.QFrame(Form) self.transformGroup.setGeometry(QtCore.QRect(0, 0, 154, 79)) self.transformGroup.setObjectName(_fromUtf8("transformGroup")) @@ -129,14 +142,24 @@ class Ui_Form(object): Form.setWindowTitle(QtGui.QApplication.translate("Form", "Form", None, QtGui.QApplication.UnicodeUTF8)) self.averageGroup.setToolTip(QtGui.QApplication.translate("Form", "Display averages of the curves displayed in this plot. The parameter list allows you to choose parameters to average over (if any are available).", None, QtGui.QApplication.UnicodeUTF8)) self.averageGroup.setTitle(QtGui.QApplication.translate("Form", "Average", None, QtGui.QApplication.UnicodeUTF8)) - self.decimateGroup.setTitle(QtGui.QApplication.translate("Form", "Downsample", None, QtGui.QApplication.UnicodeUTF8)) - self.manualDecimateRadio.setText(QtGui.QApplication.translate("Form", "Manual", None, QtGui.QApplication.UnicodeUTF8)) - self.autoDecimateRadio.setText(QtGui.QApplication.translate("Form", "Auto", None, QtGui.QApplication.UnicodeUTF8)) + self.clipToViewCheck.setToolTip(QtGui.QApplication.translate("Form", "Plot only the portion of each curve that is visible. This assumes X values are uniformly spaced.", None, QtGui.QApplication.UnicodeUTF8)) + self.clipToViewCheck.setText(QtGui.QApplication.translate("Form", "Clip to View", None, QtGui.QApplication.UnicodeUTF8)) self.maxTracesCheck.setToolTip(QtGui.QApplication.translate("Form", "If multiple curves are displayed in this plot, check this box to limit the number of traces that are displayed.", None, QtGui.QApplication.UnicodeUTF8)) self.maxTracesCheck.setText(QtGui.QApplication.translate("Form", "Max Traces:", None, QtGui.QApplication.UnicodeUTF8)) + self.downsampleCheck.setText(QtGui.QApplication.translate("Form", "Downsample", None, QtGui.QApplication.UnicodeUTF8)) + self.peakRadio.setToolTip(QtGui.QApplication.translate("Form", "Downsample by drawing a saw wave that follows the min and max of the original data. This method produces the best visual representation of the data but is slower.", None, QtGui.QApplication.UnicodeUTF8)) + self.peakRadio.setText(QtGui.QApplication.translate("Form", "Peak", None, QtGui.QApplication.UnicodeUTF8)) self.maxTracesSpin.setToolTip(QtGui.QApplication.translate("Form", "If multiple curves are displayed in this plot, check \"Max Traces\" and set this value to limit the number of traces that are displayed.", None, QtGui.QApplication.UnicodeUTF8)) self.forgetTracesCheck.setToolTip(QtGui.QApplication.translate("Form", "If MaxTraces is checked, remove curves from memory after they are hidden (saves memory, but traces can not be un-hidden).", None, QtGui.QApplication.UnicodeUTF8)) self.forgetTracesCheck.setText(QtGui.QApplication.translate("Form", "Forget hidden traces", None, QtGui.QApplication.UnicodeUTF8)) + self.meanRadio.setToolTip(QtGui.QApplication.translate("Form", "Downsample by taking the mean of N samples.", None, QtGui.QApplication.UnicodeUTF8)) + self.meanRadio.setText(QtGui.QApplication.translate("Form", "Mean", None, QtGui.QApplication.UnicodeUTF8)) + self.subsampleRadio.setToolTip(QtGui.QApplication.translate("Form", "Downsample by taking the first of N samples. This method is fastest and least accurate.", None, QtGui.QApplication.UnicodeUTF8)) + self.subsampleRadio.setText(QtGui.QApplication.translate("Form", "Subsample", None, QtGui.QApplication.UnicodeUTF8)) + self.autoDownsampleCheck.setToolTip(QtGui.QApplication.translate("Form", "Automatically downsample data based on the visible range. This assumes X values are uniformly spaced.", None, QtGui.QApplication.UnicodeUTF8)) + self.autoDownsampleCheck.setText(QtGui.QApplication.translate("Form", "Auto", None, QtGui.QApplication.UnicodeUTF8)) + self.downsampleSpin.setToolTip(QtGui.QApplication.translate("Form", "Downsample data before plotting. (plot every Nth sample)", None, QtGui.QApplication.UnicodeUTF8)) + self.downsampleSpin.setSuffix(QtGui.QApplication.translate("Form", "x", None, QtGui.QApplication.UnicodeUTF8)) self.fftCheck.setText(QtGui.QApplication.translate("Form", "Power Spectrum (FFT)", None, QtGui.QApplication.UnicodeUTF8)) self.logXCheck.setText(QtGui.QApplication.translate("Form", "Log X", None, QtGui.QApplication.UnicodeUTF8)) self.logYCheck.setText(QtGui.QApplication.translate("Form", "Log Y", None, QtGui.QApplication.UnicodeUTF8)) diff --git a/graphicsItems/PlotItem/plotConfigTemplate_pyside.py b/graphicsItems/PlotItem/plotConfigTemplate_pyside.py index 85b563a7..b8e0b19e 100644 --- a/graphicsItems/PlotItem/plotConfigTemplate_pyside.py +++ b/graphicsItems/PlotItem/plotConfigTemplate_pyside.py @@ -1,9 +1,9 @@ # -*- coding: utf-8 -*- -# Form implementation generated from reading ui file './graphicsItems/PlotItem/plotConfigTemplate.ui' +# Form implementation generated from reading ui file './pyqtgraph/graphicsItems/PlotItem/plotConfigTemplate.ui' # -# Created: Sun Sep 9 14:41:32 2012 -# by: pyside-uic 0.2.13 running on PySide 1.1.0 +# Created: Mon Jul 1 23:21:08 2013 +# by: pyside-uic 0.2.13 running on PySide 1.1.2 # # WARNING! All changes made in this file will be lost! @@ -12,9 +12,9 @@ from PySide import QtCore, QtGui class Ui_Form(object): def setupUi(self, Form): Form.setObjectName("Form") - Form.resize(258, 605) + Form.resize(481, 840) self.averageGroup = QtGui.QGroupBox(Form) - self.averageGroup.setGeometry(QtCore.QRect(10, 200, 242, 182)) + self.averageGroup.setGeometry(QtCore.QRect(0, 640, 242, 182)) self.averageGroup.setCheckable(True) self.averageGroup.setChecked(False) self.averageGroup.setObjectName("averageGroup") @@ -25,37 +25,50 @@ class Ui_Form(object): self.avgParamList = QtGui.QListWidget(self.averageGroup) self.avgParamList.setObjectName("avgParamList") self.gridLayout_5.addWidget(self.avgParamList, 0, 0, 1, 1) - self.decimateGroup = QtGui.QGroupBox(Form) - self.decimateGroup.setGeometry(QtCore.QRect(0, 70, 242, 160)) - self.decimateGroup.setCheckable(True) + self.decimateGroup = QtGui.QFrame(Form) + self.decimateGroup.setGeometry(QtCore.QRect(10, 140, 191, 171)) self.decimateGroup.setObjectName("decimateGroup") self.gridLayout_4 = QtGui.QGridLayout(self.decimateGroup) self.gridLayout_4.setContentsMargins(0, 0, 0, 0) self.gridLayout_4.setSpacing(0) self.gridLayout_4.setObjectName("gridLayout_4") - self.manualDecimateRadio = QtGui.QRadioButton(self.decimateGroup) - self.manualDecimateRadio.setChecked(True) - self.manualDecimateRadio.setObjectName("manualDecimateRadio") - self.gridLayout_4.addWidget(self.manualDecimateRadio, 0, 0, 1, 1) + self.clipToViewCheck = QtGui.QCheckBox(self.decimateGroup) + self.clipToViewCheck.setObjectName("clipToViewCheck") + self.gridLayout_4.addWidget(self.clipToViewCheck, 7, 0, 1, 3) + self.maxTracesCheck = QtGui.QCheckBox(self.decimateGroup) + self.maxTracesCheck.setObjectName("maxTracesCheck") + self.gridLayout_4.addWidget(self.maxTracesCheck, 8, 0, 1, 2) + self.downsampleCheck = QtGui.QCheckBox(self.decimateGroup) + self.downsampleCheck.setObjectName("downsampleCheck") + self.gridLayout_4.addWidget(self.downsampleCheck, 0, 0, 1, 3) + self.peakRadio = QtGui.QRadioButton(self.decimateGroup) + self.peakRadio.setChecked(True) + self.peakRadio.setObjectName("peakRadio") + self.gridLayout_4.addWidget(self.peakRadio, 6, 1, 1, 2) + self.maxTracesSpin = QtGui.QSpinBox(self.decimateGroup) + self.maxTracesSpin.setObjectName("maxTracesSpin") + self.gridLayout_4.addWidget(self.maxTracesSpin, 8, 2, 1, 1) + self.forgetTracesCheck = QtGui.QCheckBox(self.decimateGroup) + self.forgetTracesCheck.setObjectName("forgetTracesCheck") + self.gridLayout_4.addWidget(self.forgetTracesCheck, 9, 0, 1, 3) + self.meanRadio = QtGui.QRadioButton(self.decimateGroup) + self.meanRadio.setObjectName("meanRadio") + self.gridLayout_4.addWidget(self.meanRadio, 3, 1, 1, 2) + self.subsampleRadio = QtGui.QRadioButton(self.decimateGroup) + self.subsampleRadio.setObjectName("subsampleRadio") + self.gridLayout_4.addWidget(self.subsampleRadio, 2, 1, 1, 2) + self.autoDownsampleCheck = QtGui.QCheckBox(self.decimateGroup) + self.autoDownsampleCheck.setChecked(True) + self.autoDownsampleCheck.setObjectName("autoDownsampleCheck") + self.gridLayout_4.addWidget(self.autoDownsampleCheck, 1, 2, 1, 1) + spacerItem = QtGui.QSpacerItem(30, 20, QtGui.QSizePolicy.Maximum, QtGui.QSizePolicy.Minimum) + self.gridLayout_4.addItem(spacerItem, 2, 0, 1, 1) self.downsampleSpin = QtGui.QSpinBox(self.decimateGroup) self.downsampleSpin.setMinimum(1) self.downsampleSpin.setMaximum(100000) self.downsampleSpin.setProperty("value", 1) self.downsampleSpin.setObjectName("downsampleSpin") - self.gridLayout_4.addWidget(self.downsampleSpin, 0, 1, 1, 1) - self.autoDecimateRadio = QtGui.QRadioButton(self.decimateGroup) - self.autoDecimateRadio.setChecked(False) - self.autoDecimateRadio.setObjectName("autoDecimateRadio") - self.gridLayout_4.addWidget(self.autoDecimateRadio, 1, 0, 1, 1) - self.maxTracesCheck = QtGui.QCheckBox(self.decimateGroup) - self.maxTracesCheck.setObjectName("maxTracesCheck") - self.gridLayout_4.addWidget(self.maxTracesCheck, 2, 0, 1, 1) - self.maxTracesSpin = QtGui.QSpinBox(self.decimateGroup) - self.maxTracesSpin.setObjectName("maxTracesSpin") - self.gridLayout_4.addWidget(self.maxTracesSpin, 2, 1, 1, 1) - self.forgetTracesCheck = QtGui.QCheckBox(self.decimateGroup) - self.forgetTracesCheck.setObjectName("forgetTracesCheck") - self.gridLayout_4.addWidget(self.forgetTracesCheck, 3, 0, 1, 2) + self.gridLayout_4.addWidget(self.downsampleSpin, 1, 1, 1, 1) self.transformGroup = QtGui.QFrame(Form) self.transformGroup.setGeometry(QtCore.QRect(0, 0, 154, 79)) self.transformGroup.setObjectName("transformGroup") @@ -124,14 +137,24 @@ class Ui_Form(object): Form.setWindowTitle(QtGui.QApplication.translate("Form", "Form", None, QtGui.QApplication.UnicodeUTF8)) self.averageGroup.setToolTip(QtGui.QApplication.translate("Form", "Display averages of the curves displayed in this plot. The parameter list allows you to choose parameters to average over (if any are available).", None, QtGui.QApplication.UnicodeUTF8)) self.averageGroup.setTitle(QtGui.QApplication.translate("Form", "Average", None, QtGui.QApplication.UnicodeUTF8)) - self.decimateGroup.setTitle(QtGui.QApplication.translate("Form", "Downsample", None, QtGui.QApplication.UnicodeUTF8)) - self.manualDecimateRadio.setText(QtGui.QApplication.translate("Form", "Manual", None, QtGui.QApplication.UnicodeUTF8)) - self.autoDecimateRadio.setText(QtGui.QApplication.translate("Form", "Auto", None, QtGui.QApplication.UnicodeUTF8)) + self.clipToViewCheck.setToolTip(QtGui.QApplication.translate("Form", "Plot only the portion of each curve that is visible. This assumes X values are uniformly spaced.", None, QtGui.QApplication.UnicodeUTF8)) + self.clipToViewCheck.setText(QtGui.QApplication.translate("Form", "Clip to View", None, QtGui.QApplication.UnicodeUTF8)) self.maxTracesCheck.setToolTip(QtGui.QApplication.translate("Form", "If multiple curves are displayed in this plot, check this box to limit the number of traces that are displayed.", None, QtGui.QApplication.UnicodeUTF8)) self.maxTracesCheck.setText(QtGui.QApplication.translate("Form", "Max Traces:", None, QtGui.QApplication.UnicodeUTF8)) + self.downsampleCheck.setText(QtGui.QApplication.translate("Form", "Downsample", None, QtGui.QApplication.UnicodeUTF8)) + self.peakRadio.setToolTip(QtGui.QApplication.translate("Form", "Downsample by drawing a saw wave that follows the min and max of the original data. This method produces the best visual representation of the data but is slower.", None, QtGui.QApplication.UnicodeUTF8)) + self.peakRadio.setText(QtGui.QApplication.translate("Form", "Peak", None, QtGui.QApplication.UnicodeUTF8)) self.maxTracesSpin.setToolTip(QtGui.QApplication.translate("Form", "If multiple curves are displayed in this plot, check \"Max Traces\" and set this value to limit the number of traces that are displayed.", None, QtGui.QApplication.UnicodeUTF8)) self.forgetTracesCheck.setToolTip(QtGui.QApplication.translate("Form", "If MaxTraces is checked, remove curves from memory after they are hidden (saves memory, but traces can not be un-hidden).", None, QtGui.QApplication.UnicodeUTF8)) self.forgetTracesCheck.setText(QtGui.QApplication.translate("Form", "Forget hidden traces", None, QtGui.QApplication.UnicodeUTF8)) + self.meanRadio.setToolTip(QtGui.QApplication.translate("Form", "Downsample by taking the mean of N samples.", None, QtGui.QApplication.UnicodeUTF8)) + self.meanRadio.setText(QtGui.QApplication.translate("Form", "Mean", None, QtGui.QApplication.UnicodeUTF8)) + self.subsampleRadio.setToolTip(QtGui.QApplication.translate("Form", "Downsample by taking the first of N samples. This method is fastest and least accurate.", None, QtGui.QApplication.UnicodeUTF8)) + self.subsampleRadio.setText(QtGui.QApplication.translate("Form", "Subsample", None, QtGui.QApplication.UnicodeUTF8)) + self.autoDownsampleCheck.setToolTip(QtGui.QApplication.translate("Form", "Automatically downsample data based on the visible range. This assumes X values are uniformly spaced.", None, QtGui.QApplication.UnicodeUTF8)) + self.autoDownsampleCheck.setText(QtGui.QApplication.translate("Form", "Auto", None, QtGui.QApplication.UnicodeUTF8)) + self.downsampleSpin.setToolTip(QtGui.QApplication.translate("Form", "Downsample data before plotting. (plot every Nth sample)", None, QtGui.QApplication.UnicodeUTF8)) + self.downsampleSpin.setSuffix(QtGui.QApplication.translate("Form", "x", None, QtGui.QApplication.UnicodeUTF8)) self.fftCheck.setText(QtGui.QApplication.translate("Form", "Power Spectrum (FFT)", None, QtGui.QApplication.UnicodeUTF8)) self.logXCheck.setText(QtGui.QApplication.translate("Form", "Log X", None, QtGui.QApplication.UnicodeUTF8)) self.logYCheck.setText(QtGui.QApplication.translate("Form", "Log Y", None, QtGui.QApplication.UnicodeUTF8)) diff --git a/graphicsItems/ROI.py b/graphicsItems/ROI.py index 4da8fa4a..f6ce4680 100644 --- a/graphicsItems/ROI.py +++ b/graphicsItems/ROI.py @@ -38,6 +38,27 @@ def rectStr(r): class ROI(GraphicsObject): """Generic region-of-interest widget. Can be used for implementing many types of selection box with rotate/translate/scale handles. + + Signals + ----------------------- ---------------------------------------------------- + sigRegionChangeFinished Emitted when the user stops dragging the ROI (or + one of its handles) or if the ROI is changed + programatically. + sigRegionChangeStarted Emitted when the user starts dragging the ROI (or + one of its handles). + sigRegionChanged Emitted any time the position of the ROI changes, + including while it is being dragged by the user. + sigHoverEvent Emitted when the mouse hovers over the ROI. + sigClicked Emitted when the user clicks on the ROI. + Note that clicking is disabled by default to prevent + stealing clicks from objects behind the ROI. To + enable clicking, call + roi.setAcceptedMouseButtons(QtCore.Qt.LeftButton). + See QtGui.QGraphicsItem documentation for more + details. + sigRemoveRequested Emitted when the user selects 'remove' from the + ROI's context menu (if available). + ----------------------- ---------------------------------------------------- """ sigRegionChangeFinished = QtCore.Signal(object) @@ -802,7 +823,11 @@ class ROI(GraphicsObject): Also returns the transform which maps the ROI into data coordinates. If returnSlice is set to False, the function returns a pair of tuples with the values that would have - been used to generate the slice objects. ((ax0Start, ax0Stop), (ax1Start, ax1Stop))""" + been used to generate the slice objects. ((ax0Start, ax0Stop), (ax1Start, ax1Stop)) + + If the slice can not be computed (usually because the scene/transforms are not properly + constructed yet), then the method returns None. + """ #print "getArraySlice" ## Determine shape of array along ROI axes @@ -810,8 +835,11 @@ class ROI(GraphicsObject): #print " dshape", dShape ## Determine transform that maps ROI bounding box to image coordinates - tr = self.sceneTransform() * fn.invertQTransform(img.sceneTransform()) - + try: + tr = self.sceneTransform() * fn.invertQTransform(img.sceneTransform()) + except np.linalg.linalg.LinAlgError: + return None + ## Modify transform to scale from image coords to data coords #m = QtGui.QTransform() tr.scale(float(dShape[0]) / img.width(), float(dShape[1]) / img.height()) @@ -1292,7 +1320,6 @@ class Handle(UIGraphicsItem): ## determine rotation of transform #m = self.sceneTransform() ## Qt bug: do not access sceneTransform() until we know this object has a scene. #mi = m.inverted()[0] - dt = self.deviceTransform() if dt is None: @@ -1311,10 +1338,10 @@ class Handle(UIGraphicsItem): return dti.map(tr.map(self.path)) - def viewRangeChanged(self): - GraphicsObject.viewRangeChanged(self) + def viewTransformChanged(self): + GraphicsObject.viewTransformChanged(self) self._shape = None ## invalidate shape, recompute later if requested. - #self.updateShape() + self.update() #def itemChange(self, change, value): #if change == self.ItemScenePositionHasChanged: @@ -1599,7 +1626,7 @@ class PolyLineROI(ROI): if pos is None: pos = [0,0] - #pen=args.get('pen', fn.mkPen((100,100,255))) + ROI.__init__(self, pos, size=[1,1], **args) self.closed = closed self.segments = [] @@ -1610,33 +1637,6 @@ class PolyLineROI(ROI): start = -1 if self.closed else 0 for i in range(start, len(self.handles)-1): self.addSegment(self.handles[i]['item'], self.handles[i+1]['item']) - #for i in range(len(positions)-1): - #h2 = self.addFreeHandle(positions[i+1]) - #segment = LineSegmentROI(handles=(h, h2), pen=pen, parent=self, movable=False) - #self.segments.append(segment) - #h = h2 - - - #for i, s in enumerate(self.segments): - #h = s.handles[0] - #self.addFreeHandle(h['pos'], item=h['item']) - #s.setZValue(self.zValue() +1) - - #h = self.segments[-1].handles[1] - #self.addFreeHandle(h['pos'], item=h['item']) - - #if closed: - #h1 = self.handles[-1]['item'] - #h2 = self.handles[0]['item'] - #self.segments.append(LineSegmentROI([positions[-1], positions[0]], pos=pos, handles=(h1, h2), pen=pen, parent=self, movable=False)) - #h2.setParentItem(self.segments[-1]) - - - #for s in self.segments: - #self.setSegmentSettings(s) - - #def movePoint(self, *args, **kargs): - #pass def addSegment(self, h1, h2, index=None): seg = LineSegmentROI(handles=(h1, h2), pen=self.pen, parent=self, movable=False) @@ -1653,9 +1653,6 @@ class PolyLineROI(ROI): def setMouseHover(self, hover): ## Inform all the ROI's segments that the mouse is(not) hovering over it - #if self.mouseHovering == hover: - #return - #self.mouseHovering = hover ROI.setMouseHover(self, hover) for s in self.segments: s.setMouseHover(hover) @@ -1680,15 +1677,6 @@ class PolyLineROI(ROI): self.addSegment(h3, h2, index=i+1) segment.replaceHandle(h2, h3) - - #def report(self): - #for s in self.segments: - #print s - #for h in s.handles: - #print " ", h - #for h in self.handles: - #print h - def removeHandle(self, handle, updateSegments=True): ROI.removeHandle(self, handle) handle.sigRemoveRequested.disconnect(self.removeHandle) @@ -1737,11 +1725,34 @@ class PolyLineROI(ROI): def shape(self): p = QtGui.QPainterPath() + if len(self.handles) == 0: + return p p.moveTo(self.handles[0]['item'].pos()) for i in range(len(self.handles)): p.lineTo(self.handles[i]['item'].pos()) p.lineTo(self.handles[0]['item'].pos()) - return p + return p + + def getArrayRegion(self, data, img, axes=(0,1), returnMappedCoords=False, **kwds): + sl = self.getArraySlice(data, img, axes=(0,1)) + if sl is None: + return None + sliced = data[sl[0]] + im = QtGui.QImage(sliced.shape[axes[0]], sliced.shape[axes[1]], QtGui.QImage.Format_ARGB32) + im.fill(0x0) + p = QtGui.QPainter(im) + p.setPen(fn.mkPen(None)) + p.setBrush(fn.mkBrush('w')) + p.setTransform(self.itemTransform(img)[0]) + bounds = self.mapRectToItem(img, self.boundingRect()) + p.translate(-bounds.left(), -bounds.top()) + p.drawPath(self.shape()) + p.end() + mask = fn.imageToArray(im)[:,:,0].astype(float) / 255. + shape = [1] * data.ndim + shape[axes[0]] = sliced.shape[axes[0]] + shape[axes[1]] = sliced.shape[axes[1]] + return sliced * mask.reshape(shape) class LineSegmentROI(ROI): @@ -1845,8 +1856,8 @@ class SpiralROI(ROI): #for h in self.handles: #h['pos'] = h['item'].pos()/self.state['size'][0] - def stateChanged(self): - ROI.stateChanged(self) + def stateChanged(self, finish=True): + ROI.stateChanged(self, finish=finish) if len(self.handles) > 1: self.path = QtGui.QPainterPath() h0 = Point(self.handles[0]['item'].pos()).length() diff --git a/graphicsItems/ScaleBar.py b/graphicsItems/ScaleBar.py index 961f07d7..768f6978 100644 --- a/graphicsItems/ScaleBar.py +++ b/graphicsItems/ScaleBar.py @@ -1,50 +1,104 @@ from pyqtgraph.Qt import QtGui, QtCore -from .UIGraphicsItem import * +from .GraphicsObject import * +from .GraphicsWidgetAnchor import * +from .TextItem import TextItem import numpy as np import pyqtgraph.functions as fn +import pyqtgraph as pg __all__ = ['ScaleBar'] -class ScaleBar(UIGraphicsItem): + +class ScaleBar(GraphicsObject, GraphicsWidgetAnchor): """ - Displays a rectangular bar with 10 divisions to indicate the relative scale of objects on the view. + Displays a rectangular bar to indicate the relative scale of objects on the view. """ - def __init__(self, size, width=5, color=(100, 100, 255)): - UIGraphicsItem.__init__(self) + def __init__(self, size, width=5, brush=None, pen=None, suffix='m'): + GraphicsObject.__init__(self) + GraphicsWidgetAnchor.__init__(self) + self.setFlag(self.ItemHasNoContents) self.setAcceptedMouseButtons(QtCore.Qt.NoButton) - self.brush = fn.mkBrush(color) - self.pen = fn.mkPen((0,0,0)) + if brush is None: + brush = pg.getConfigOption('foreground') + self.brush = fn.mkBrush(brush) + self.pen = fn.mkPen(pen) self._width = width self.size = size - def paint(self, p, opt, widget): - UIGraphicsItem.paint(self, p, opt, widget) + self.bar = QtGui.QGraphicsRectItem() + self.bar.setPen(self.pen) + self.bar.setBrush(self.brush) + self.bar.setParentItem(self) - rect = self.boundingRect() - unit = self.pixelSize() - y = rect.top() + (rect.bottom()-rect.top()) * 0.02 - y1 = y + unit[1]*self._width - x = rect.right() + (rect.left()-rect.right()) * 0.02 - x1 = x - self.size + self.text = TextItem(text=fn.siFormat(size, suffix=suffix), anchor=(0.5,1)) + self.text.setParentItem(self) + + def parentChanged(self): + view = self.parentItem() + if view is None: + return + view.sigRangeChanged.connect(self.updateBar) + self.updateBar() - p.setPen(self.pen) - p.setBrush(self.brush) - rect = QtCore.QRectF( - QtCore.QPointF(x1, y1), - QtCore.QPointF(x, y) - ) - p.translate(x1, y1) - p.scale(rect.width(), rect.height()) - p.drawRect(0, 0, 1, 1) - alpha = np.clip(((self.size/unit[0]) - 40.) * 255. / 80., 0, 255) - p.setPen(QtGui.QPen(QtGui.QColor(0, 0, 0, alpha))) - for i in range(1, 10): - #x2 = x + (x1-x) * 0.1 * i - x2 = 0.1 * i - p.drawLine(QtCore.QPointF(x2, 0), QtCore.QPointF(x2, 1)) + def updateBar(self): + view = self.parentItem() + if view is None: + return + p1 = view.mapFromViewToItem(self, QtCore.QPointF(0,0)) + p2 = view.mapFromViewToItem(self, QtCore.QPointF(self.size,0)) + w = (p2-p1).x() + self.bar.setRect(QtCore.QRectF(-w, 0, w, self._width)) + self.text.setPos(-w/2., 0) + + def boundingRect(self): + return QtCore.QRectF() + + + + + +#class ScaleBar(UIGraphicsItem): + #""" + #Displays a rectangular bar with 10 divisions to indicate the relative scale of objects on the view. + #""" + #def __init__(self, size, width=5, color=(100, 100, 255)): + #UIGraphicsItem.__init__(self) + #self.setAcceptedMouseButtons(QtCore.Qt.NoButton) + + #self.brush = fn.mkBrush(color) + #self.pen = fn.mkPen((0,0,0)) + #self._width = width + #self.size = size + + #def paint(self, p, opt, widget): + #UIGraphicsItem.paint(self, p, opt, widget) + + #rect = self.boundingRect() + #unit = self.pixelSize() + #y = rect.top() + (rect.bottom()-rect.top()) * 0.02 + #y1 = y + unit[1]*self._width + #x = rect.right() + (rect.left()-rect.right()) * 0.02 + #x1 = x - self.size + + #p.setPen(self.pen) + #p.setBrush(self.brush) + #rect = QtCore.QRectF( + #QtCore.QPointF(x1, y1), + #QtCore.QPointF(x, y) + #) + #p.translate(x1, y1) + #p.scale(rect.width(), rect.height()) + #p.drawRect(0, 0, 1, 1) + + #alpha = np.clip(((self.size/unit[0]) - 40.) * 255. / 80., 0, 255) + #p.setPen(QtGui.QPen(QtGui.QColor(0, 0, 0, alpha))) + #for i in range(1, 10): + ##x2 = x + (x1-x) * 0.1 * i + #x2 = 0.1 * i + #p.drawLine(QtCore.QPointF(x2, 0), QtCore.QPointF(x2, 1)) - def setSize(self, s): - self.size = s + #def setSize(self, s): + #self.size = s diff --git a/graphicsItems/ScatterPlotItem.py b/graphicsItems/ScatterPlotItem.py index a69131ef..f1a5201d 100644 --- a/graphicsItems/ScatterPlotItem.py +++ b/graphicsItems/ScatterPlotItem.py @@ -4,7 +4,6 @@ import pyqtgraph.functions as fn from .GraphicsItem import GraphicsItem from .GraphicsObject import GraphicsObject import numpy as np -import scipy.stats import weakref import pyqtgraph.debug as debug from pyqtgraph.pgcollections import OrderedDict @@ -15,7 +14,7 @@ __all__ = ['ScatterPlotItem', 'SpotItem'] ## Build all symbol paths -Symbols = OrderedDict([(name, QtGui.QPainterPath()) for name in ['o', 's', 't', 'd', '+']]) +Symbols = OrderedDict([(name, QtGui.QPainterPath()) for name in ['o', 's', 't', 'd', '+', 'x']]) Symbols['o'].addEllipse(QtCore.QRectF(-0.5, -0.5, 1, 1)) Symbols['s'].addRect(QtCore.QRectF(-0.5, -0.5, 1, 1)) coords = { @@ -32,9 +31,14 @@ for k, c in coords.items(): for x,y in c[1:]: Symbols[k].lineTo(x, y) Symbols[k].closeSubpath() +tr = QtGui.QTransform() +tr.rotate(45) +Symbols['x'] = tr.map(Symbols['+']) def drawSymbol(painter, symbol, size, pen, brush): + if symbol is None: + return painter.scale(size, size) painter.setPen(pen) painter.setBrush(brush) @@ -53,25 +57,17 @@ def renderSymbol(symbol, size, pen, brush, device=None): the symbol will be rendered into the device specified (See QPainter documentation for more information). """ - ## see if this pixmap is already cached - #global SymbolPixmapCache - #key = (symbol, size, fn.colorTuple(pen.color()), pen.width(), pen.style(), fn.colorTuple(brush.color())) - #if key in SymbolPixmapCache: - #return SymbolPixmapCache[key] - ## Render a spot with the given parameters to a pixmap penPxWidth = max(np.ceil(pen.widthF()), 1) - image = QtGui.QImage(int(size+penPxWidth), int(size+penPxWidth), QtGui.QImage.Format_ARGB32) - image.fill(0) - p = QtGui.QPainter(image) + if device is None: + device = QtGui.QImage(int(size+penPxWidth), int(size+penPxWidth), QtGui.QImage.Format_ARGB32) + device.fill(0) + p = QtGui.QPainter(device) p.setRenderHint(p.Antialiasing) - p.translate(image.width()*0.5, image.height()*0.5) + p.translate(device.width()*0.5, device.height()*0.5) drawSymbol(p, symbol, size, pen, brush) p.end() - return image - #pixmap = QtGui.QPixmap(image) - #SymbolPixmapCache[key] = pixmap - #return pixmap + return device def makeSymbolPixmap(size, pen, brush, symbol): ## deprecated @@ -520,7 +516,7 @@ class ScatterPlotItem(GraphicsObject): ## Bug: If data is a numpy record array, then items from that array must be copied to dataSet one at a time. ## (otherwise they are converted to tuples and thus lose their field names. - if isinstance(data, np.ndarray) and len(data.dtype.fields) > 1: + if isinstance(data, np.ndarray) and (data.dtype.fields is not None)and len(data.dtype.fields) > 1: for i, rec in enumerate(data): dataSet['data'][i] = rec else: @@ -629,13 +625,15 @@ class ScatterPlotItem(GraphicsObject): d2 = d2[mask] if frac >= 1.0: - self.bounds[ax] = (d.min() - self._maxSpotWidth*0.7072, d.max() + self._maxSpotWidth*0.7072) + self.bounds[ax] = (np.nanmin(d) - self._maxSpotWidth*0.7072, np.nanmax(d) + self._maxSpotWidth*0.7072) return self.bounds[ax] elif frac <= 0.0: raise Exception("Value for parameter 'frac' must be > 0. (got %s)" % str(frac)) else: - return (scipy.stats.scoreatpercentile(d, 50 - (frac * 50)), scipy.stats.scoreatpercentile(d, 50 + (frac * 50))) - + mask = np.isfinite(d) + d = d[mask] + return np.percentile(d, [50 * (1 - frac), 50 * (1 + frac)]) + def pixelPadding(self): return self._maxSpotPxWidth*0.7072 @@ -677,7 +675,7 @@ class ScatterPlotItem(GraphicsObject): pts[1] = self.data['y'] pts = fn.transformCoordinates(tr, pts) self.fragments = [] - pts = np.clip(pts, -2**31, 2**31) ## prevent Qt segmentation fault. + 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] @@ -689,7 +687,8 @@ class ScatterPlotItem(GraphicsObject): def setExportMode(self, *args, **kwds): GraphicsObject.setExportMode(self, *args, **kwds) self.invalidate() - + + @pg.debug.warnOnException ## raising an exception here causes crash def paint(self, p, *args): #p.setPen(fn.mkPen('r')) @@ -740,6 +739,7 @@ class ScatterPlotItem(GraphicsObject): drawSymbol(p2, *self.getSpotOpts(rec, scale)) p2.end() + p.setRenderHint(p.Antialiasing, aa) self.picture.play(p) def points(self): diff --git a/graphicsItems/ViewBox/ViewBox.py b/graphicsItems/ViewBox/ViewBox.py index 87b687bd..3cbb1ea2 100644 --- a/graphicsItems/ViewBox/ViewBox.py +++ b/graphicsItems/ViewBox/ViewBox.py @@ -17,6 +17,10 @@ __all__ = ['ViewBox'] class ChildGroup(ItemGroup): sigItemsChanged = QtCore.Signal() + def __init__(self, parent): + ItemGroup.__init__(self, parent) + # excempt from telling view when transform changes + self._GraphicsObject__inform_view_on_change = False def itemChange(self, change, value): ret = ItemGroup.itemChange(self, change, value) @@ -50,6 +54,7 @@ class ViewBox(GraphicsWidget): #sigActionPositionChanged = QtCore.Signal(object) sigStateChanged = QtCore.Signal(object) sigTransformChanged = QtCore.Signal(object) + sigResized = QtCore.Signal(object) ## mouse modes PanMode = 3 @@ -86,6 +91,10 @@ class ViewBox(GraphicsWidget): self.addedItems = [] #self.gView = view #self.showGrid = showGrid + self._matrixNeedsUpdate = True ## indicates that range has changed, but matrix update was deferred + self._autoRangeNeedsUpdate = True ## indicates auto-range needs to be recomputed. + + self._lastScene = None ## stores reference to the last known scene this view was a part of. self.state = { @@ -137,9 +146,16 @@ class ViewBox(GraphicsWidget): self.rbScaleBox = QtGui.QGraphicsRectItem(0, 0, 1, 1) self.rbScaleBox.setPen(fn.mkPen((255,255,100), width=1)) self.rbScaleBox.setBrush(fn.mkBrush(255,255,0,100)) + self.rbScaleBox.setZValue(1e9) self.rbScaleBox.hide() self.addItem(self.rbScaleBox, ignoreBounds=True) + ## show target rect for debugging + self.target = QtGui.QGraphicsRectItem(0, 0, 1, 1) + self.target.setPen(fn.mkPen('r')) + self.target.setParentItem(self) + self.target.hide() + self.axHistory = [] # maintain a history of zoom locations self.axHistoryPointer = -1 # pointer into the history. Allows forward/backward movement, not just "undo" @@ -174,7 +190,7 @@ class ViewBox(GraphicsWidget): def unregister(self): """ - Remove this ViewBox forom the list of linkable views. (see :func:`register() `) + Remove this ViewBox from the list of linkable views. (see :func:`register() `) """ del ViewBox.AllViews[self] if self.name is not None: @@ -186,6 +202,48 @@ class ViewBox(GraphicsWidget): def implements(self, interface): return interface == 'ViewBox' + # removed due to https://bugreports.qt-project.org/browse/PYSIDE-86 + #def itemChange(self, change, value): + ## Note: Calling QWidget.itemChange causes segv in python 3 + PyQt + ##ret = QtGui.QGraphicsItem.itemChange(self, change, value) + #ret = GraphicsWidget.itemChange(self, change, value) + #if change == self.ItemSceneChange: + #scene = self.scene() + #if scene is not None and hasattr(scene, 'sigPrepareForPaint'): + #scene.sigPrepareForPaint.disconnect(self.prepareForPaint) + #elif change == self.ItemSceneHasChanged: + #scene = self.scene() + #if scene is not None and hasattr(scene, 'sigPrepareForPaint'): + #scene.sigPrepareForPaint.connect(self.prepareForPaint) + #return ret + + def checkSceneChange(self): + # ViewBox needs to receive sigPrepareForPaint from its scene before + # being painted. However, we have no way of being informed when the + # scene has changed in order to make this connection. The usual way + # to do this is via itemChange(), but bugs prevent this approach + # (see above). Instead, we simply check at every paint to see whether + # (the scene has changed. + scene = self.scene() + if scene == self._lastScene: + return + if self._lastScene is not None and hasattr(self.lastScene, 'sigPrepareForPaint'): + self._lastScene.sigPrepareForPaint.disconnect(self.prepareForPaint) + if scene is not None and hasattr(scene, 'sigPrepareForPaint'): + scene.sigPrepareForPaint.connect(self.prepareForPaint) + self.prepareForPaint() + self._lastScene = scene + + + + + def prepareForPaint(self): + #autoRangeEnabled = (self.state['autoRange'][0] is not False) or (self.state['autoRange'][1] is not False) + # don't check whether auto range is enabled here--only check when setting dirty flag. + if self._autoRangeNeedsUpdate: # and autoRangeEnabled: + self.updateAutoRange() + if self._matrixNeedsUpdate: + self.updateMatrix() def getState(self, copy=True): """Return the current state of the ViewBox. @@ -214,7 +272,8 @@ class ViewBox(GraphicsWidget): del state['linkedViews'] self.state.update(state) - self.updateMatrix() + #self.updateMatrix() + self.updateViewRange() self.sigStateChanged.emit(self) @@ -274,6 +333,9 @@ class ViewBox(GraphicsWidget): """ if item.zValue() < self.zValue(): item.setZValue(self.zValue()+1) + scene = self.scene() + if scene is not None and scene is not item.scene(): + scene.addItem(item) ## Necessary due to Qt bug: https://bugreports.qt-project.org/browse/QTBUG-18616 item.setParentItem(self.childGroup) if not ignoreBounds: self.addedItems.append(item) @@ -293,17 +355,17 @@ class ViewBox(GraphicsWidget): for i in self.addedItems[:]: self.removeItem(i) for ch in self.childGroup.childItems(): - ch.setParent(None) + ch.setParentItem(None) def resizeEvent(self, ev): - #self.setRange(self.range, padding=0) + self.linkedXChanged() + self.linkedYChanged() self.updateAutoRange() - self.updateMatrix() + self.updateViewRange() self.sigStateChanged.emit(self) self.background.setRect(self.rect()) - #self._itemBoundsCache.clear() - #self.linkedXChanged() - #self.linkedYChanged() + self.sigResized.emit(self) + def viewRange(self): """Return a the view's visible range as a list: [[xmin, xmax], [ymin, ymax]]""" @@ -339,83 +401,128 @@ class ViewBox(GraphicsWidget): def setRange(self, rect=None, xRange=None, yRange=None, padding=None, update=True, disableAutoRange=True): """ Set the visible range of the ViewBox. - Must specify at least one of *range*, *xRange*, or *yRange*. + Must specify at least one of *rect*, *xRange*, or *yRange*. - ============= ===================================================================== + ================== ===================================================================== **Arguments** - *rect* (QRectF) The full range that should be visible in the view box. - *xRange* (min,max) The range that should be visible along the x-axis. - *yRange* (min,max) The range that should be visible along the y-axis. - *padding* (float) Expand the view by a fraction of the requested range. - By default, this value is set between 0.02 and 0.1 depending on - the size of the ViewBox. - ============= ===================================================================== + *rect* (QRectF) The full range that should be visible in the view box. + *xRange* (min,max) The range that should be visible along the x-axis. + *yRange* (min,max) The range that should be visible along the y-axis. + *padding* (float) Expand the view by a fraction of the requested range. + By default, this value is set between 0.02 and 0.1 depending on + the size of the ViewBox. + *update* (bool) If True, update the range of the ViewBox immediately. + Otherwise, the update is deferred until before the next render. + *disableAutoRange* (bool) If True, auto-ranging is diabled. Otherwise, it is left + unchanged. + ================== ===================================================================== """ + #print self.name, "ViewBox.setRange", rect, xRange, yRange, padding + #import traceback + #traceback.print_stack() - changes = {} + changes = {} # axes + setRequested = [False, False] if rect is not None: changes = {0: [rect.left(), rect.right()], 1: [rect.top(), rect.bottom()]} + setRequested = [True, True] if xRange is not None: changes[0] = xRange + setRequested[0] = True if yRange is not None: changes[1] = yRange + setRequested[1] = True if len(changes) == 0: print(rect) raise Exception("Must specify at least one of rect, xRange, or yRange. (gave rect=%s)" % str(type(rect))) + # Update axes one at a time changed = [False, False] for ax, range in changes.items(): - if padding is None: - xpad = self.suggestPadding(ax) - else: - xpad = padding mn = min(range) mx = max(range) - if mn == mx: ## If we requested 0 range, try to preserve previous scale. Otherwise just pick an arbitrary scale. + + # If we requested 0 range, try to preserve previous scale. + # Otherwise just pick an arbitrary scale. + if mn == mx: dy = self.state['viewRange'][ax][1] - self.state['viewRange'][ax][0] if dy == 0: dy = 1 mn -= dy*0.5 mx += dy*0.5 xpad = 0.0 - if any(np.isnan([mn, mx])) or any(np.isinf([mn, mx])): - raise Exception("Not setting range [%s, %s]" % (str(mn), str(mx))) + # Make sure no nan/inf get through + if not all(np.isfinite([mn, mx])): + raise Exception("Cannot set range [%s, %s]" % (str(mn), str(mx))) + + # Apply padding + if padding is None: + xpad = self.suggestPadding(ax) + else: + xpad = padding p = (mx-mn) * xpad mn -= p mx += p + # Set target range if self.state['targetRange'][ax] != [mn, mx]: self.state['targetRange'][ax] = [mn, mx] changed[ax] = True - - if any(changed) and disableAutoRange: - if all(changed): - ax = ViewBox.XYAxes - elif changed[0]: - ax = ViewBox.XAxis - elif changed[1]: - ax = ViewBox.YAxis - self.enableAutoRange(ax, False) - - self.sigStateChanged.emit(self) - - if update: - self.updateMatrix(changed) + # Update viewRange to match targetRange as closely as possible while + # accounting for aspect ratio constraint + lockX, lockY = setRequested + if lockX and lockY: + lockX = False + lockY = False + self.updateViewRange(lockX, lockY) - for ax, range in changes.items(): - link = self.linkedView(ax) - if link is not None: - link.linkedViewChanged(self, ax) + # Disable auto-range for each axis that was requested to be set + if disableAutoRange: + xOff = False if setRequested[0] else None + yOff = False if setRequested[1] else None + self.enableAutoRange(x=xOff, y=yOff) + changed.append(True) + + # If nothing has changed, we are done. + if any(changed): + #if update and self.matrixNeedsUpdate: + #self.updateMatrix(changed) + #return + + self.sigStateChanged.emit(self) + + # Update target rect for debugging + if self.target.isVisible(): + self.target.setRect(self.mapRectFromItem(self.childGroup, self.targetRect())) + + # If ortho axes have auto-visible-only, update them now + # Note that aspect ratio constraints and auto-visible probably do not work together.. + if changed[0] and self.state['autoVisibleOnly'][1] and (self.state['autoRange'][0] is not False): + self._autoRangeNeedsUpdate = True + #self.updateAutoRange() ## Maybe just indicate that auto range needs to be updated? + elif changed[1] and self.state['autoVisibleOnly'][0] and (self.state['autoRange'][1] is not False): + self._autoRangeNeedsUpdate = True + #self.updateAutoRange() + + ## Update view matrix only if requested + #if update: + #self.updateMatrix(changed) + ## Otherwise, indicate that the matrix needs to be updated + #else: + #self.matrixNeedsUpdate = True + + ## Inform linked views that the range has changed <> + #for ax, range in changes.items(): + #link = self.linkedView(ax) + #if link is not None: + #link.linkedViewChanged(self, ax) + - if changed[0] and self.state['autoVisibleOnly'][1]: - self.updateAutoRange() - elif changed[1] and self.state['autoVisibleOnly'][0]: - self.updateAutoRange() def setYRange(self, min, max, padding=None, update=True): """ @@ -465,37 +572,73 @@ class ViewBox(GraphicsWidget): padding = 0.02 return padding - def scaleBy(self, s, center=None): + def scaleBy(self, s=None, center=None, x=None, y=None): """ Scale by *s* around given center point (or center of view). - *s* may be a Point or tuple (x, y) + *s* may be a Point or tuple (x, y). + + Optionally, x or y may be specified individually. This allows the other + axis to be left unaffected (note that using a scale factor of 1.0 may + cause slight changes due to floating-point error). """ - scale = Point(s) + if s is not None: + scale = Point(s) + else: + scale = [x, y] + + affect = [True, True] + if scale[0] is None and scale[1] is None: + return + elif scale[0] is None: + affect[0] = False + scale[0] = 1.0 + elif scale[1] is None: + affect[1] = False + scale[1] = 1.0 + + scale = Point(scale) + if self.state['aspectLocked'] is not False: - scale[0] = self.state['aspectLocked'] * scale[1] + scale[0] = scale[1] vr = self.targetRect() if center is None: center = Point(vr.center()) else: center = Point(center) + tl = center + (vr.topLeft()-center) * scale br = center + (vr.bottomRight()-center) * scale - self.setRange(QtCore.QRectF(tl, br), padding=0) - def translateBy(self, t): + if not affect[0]: + self.setYRange(tl.y(), br.y(), padding=0) + elif not affect[1]: + self.setXRange(tl.x(), br.x(), padding=0) + else: + self.setRange(QtCore.QRectF(tl, br), padding=0) + + def translateBy(self, t=None, x=None, y=None): """ Translate the view by *t*, which may be a Point or tuple (x, y). + + Alternately, x or y may be specified independently, leaving the other + axis unchanged (note that using a translation of 0 may still cause + small changes due to floating-point error). """ - t = Point(t) - #if viewCoords: ## scale from pixels - #o = self.mapToView(Point(0,0)) - #t = self.mapToView(t) - o - vr = self.targetRect() - self.setRange(vr.translated(t), padding=0) + if t is not None: + t = Point(t) + self.setRange(vr.translated(t), padding=0) + else: + if x is not None: + x = vr.left()+x, vr.right()+x + if y is not None: + y = vr.top()+y, vr.bottom()+y + self.setRange(xRange=x, yRange=y, padding=0) + - def enableAutoRange(self, axis=None, enable=True): + + def enableAutoRange(self, axis=None, enable=True, x=None, y=None): """ Enable (or disable) auto-range for *axis*, which may be ViewBox.XAxis, ViewBox.YAxis, or ViewBox.XYAxes for both (if *axis* is omitted, both axes will be changed). @@ -507,25 +650,47 @@ class ViewBox(GraphicsWidget): #if not enable: #import traceback #traceback.print_stack() - + + # support simpler interface: + if x is not None or y is not None: + if x is not None: + self.enableAutoRange(ViewBox.XAxis, x) + if y is not None: + self.enableAutoRange(ViewBox.YAxis, y) + return + if enable is True: enable = 1.0 if axis is None: axis = ViewBox.XYAxes + needAutoRangeUpdate = False + if axis == ViewBox.XYAxes or axis == 'xy': - self.state['autoRange'][0] = enable - self.state['autoRange'][1] = enable + axes = [0, 1] elif axis == ViewBox.XAxis or axis == 'x': - self.state['autoRange'][0] = enable + axes = [0] elif axis == ViewBox.YAxis or axis == 'y': - self.state['autoRange'][1] = enable + axes = [1] else: raise Exception('axis argument must be ViewBox.XAxis, ViewBox.YAxis, or ViewBox.XYAxes.') - if enable: - self.updateAutoRange() + for ax in axes: + if self.state['autoRange'][ax] != enable: + # If we are disabling, do one last auto-range to make sure that + # previously scheduled auto-range changes are enacted + if enable is False and self._autoRangeNeedsUpdate: + self.updateAutoRange() + + self.state['autoRange'][ax] = enable + self._autoRangeNeedsUpdate |= (enable is not False) + self.update() + + + #if needAutoRangeUpdate: + # self.updateAutoRange() + self.sigStateChanged.emit(self) def disableAutoRange(self, axis=None): @@ -613,6 +778,7 @@ class ViewBox(GraphicsWidget): args['disableAutoRange'] = False self.setRange(**args) finally: + self._autoRangeNeedsUpdate = False self._updatingRange = False def setXLink(self, view): @@ -651,6 +817,7 @@ class ViewBox(GraphicsWidget): if oldLink is not None: try: getattr(oldLink, signal).disconnect(slot) + oldLink.sigResized.disconnect(slot) except TypeError: ## This can occur if the view has been deleted already pass @@ -661,12 +828,14 @@ class ViewBox(GraphicsWidget): else: self.state['linkedViews'][axis] = weakref.ref(view) getattr(view, signal).connect(slot) + view.sigResized.connect(slot) if view.autoRangeEnabled()[axis] is not False: self.enableAutoRange(axis, False) slot() else: if self.autoRangeEnabled()[axis] is False: slot() + self.sigStateChanged.emit(self) @@ -696,6 +865,7 @@ class ViewBox(GraphicsWidget): if self.linksBlocked or view is None: return + #print self.name, "ViewBox.linkedViewChanged", axis, view.viewRange()[axis] vr = view.viewRect() vg = view.screenGeometry() sg = self.screenGeometry() @@ -719,12 +889,15 @@ class ViewBox(GraphicsWidget): else: overlap = min(sg.bottom(), vg.bottom()) - max(sg.top(), vg.top()) if overlap < min(vg.height()/3, sg.height()/3): ## if less than 1/3 of views overlap, - ## then just replicate the view + ## then just replicate the view y1 = vr.top() y2 = vr.bottom() else: ## views overlap; line them up upp = float(vr.height()) / vg.height() - y2 = vr.bottom() - (sg.y()-vg.y()) * upp + if self.yInverted(): + y2 = vr.bottom() + (sg.bottom()-vg.bottom()) * upp + else: + y2 = vr.bottom() + (sg.top()-vg.top()) * upp y1 = y2 - sg.height() * upp self.enableAutoRange(ViewBox.YAxis, False) self.setYRange(y1, y2, padding=0) @@ -751,14 +924,21 @@ class ViewBox(GraphicsWidget): def itemBoundsChanged(self, item): self._itemBoundsCache.pop(item, None) - self.updateAutoRange() + if (self.state['autoRange'][0] is not False) or (self.state['autoRange'][1] is not False): + self._autoRangeNeedsUpdate = True + self.update() + #self.updateAutoRange() def invertY(self, b=True): """ By default, the positive y-axis points upward on the screen. Use invertY(True) to reverse the y-axis. """ + if self.state['yInverted'] == b: + return + self.state['yInverted'] = b - self.updateMatrix(changed=(False, True)) + #self.updateMatrix(changed=(False, True)) + self.updateViewRange() self.sigStateChanged.emit(self) def yInverted(self): @@ -768,19 +948,31 @@ class ViewBox(GraphicsWidget): """ If the aspect ratio is locked, view scaling must always preserve the aspect ratio. By default, the ratio is set to 1; x and y both have the same scaling. - This ratio can be overridden (width/height), or use None to lock in the current ratio. + This ratio can be overridden (xScale/yScale), or use None to lock in the current ratio. """ + if not lock: + if self.state['aspectLocked'] == False: + return self.state['aspectLocked'] = False else: + rect = self.rect() vr = self.viewRect() - currentRatio = vr.width() / vr.height() + if rect.height() == 0 or vr.width() == 0 or vr.height() == 0: + currentRatio = 1.0 + else: + currentRatio = (rect.width()/float(rect.height())) / (vr.width()/vr.height()) if ratio is None: ratio = currentRatio + if self.state['aspectLocked'] == ratio: # nothing to change + return self.state['aspectLocked'] = ratio if ratio != currentRatio: ## If this would change the current range, do that now #self.setRange(0, self.state['viewRange'][0][0], self.state['viewRange'][0][1]) - self.updateMatrix() + self.updateViewRange() + + self.updateAutoRange() + self.updateViewRange() self.sigStateChanged.emit(self) def childTransform(self): @@ -911,7 +1103,8 @@ class ViewBox(GraphicsWidget): dif = dif * -1 ## Ignore axes if mouse is disabled - mask = np.array(self.state['mouseEnabled'], dtype=np.float) + mouseEnabled = np.array(self.state['mouseEnabled'], dtype=np.float) + mask = mouseEnabled.copy() if axis is not None: mask[1-axis] = 0.0 @@ -933,7 +1126,10 @@ class ViewBox(GraphicsWidget): else: tr = dif*mask tr = self.mapToView(tr) - self.mapToView(Point(0,0)) - self.translateBy(tr) + x = tr.x() if mask[0] == 1 else None + y = tr.y() if mask[1] == 1 else None + + self.translateBy(x=x, y=y) self.sigRangeChangedManually.emit(self.state['mouseEnabled']) elif ev.button() & QtCore.Qt.RightButton: #print "vb.rightDrag" @@ -948,8 +1144,11 @@ class ViewBox(GraphicsWidget): tr = self.childGroup.transform() tr = fn.invertQTransform(tr) + x = s[0] if mouseEnabled[0] == 1 else None + y = s[1] if mouseEnabled[1] == 1 else None + center = Point(tr.map(ev.buttonDownPos(QtCore.Qt.RightButton))) - self.scaleBy(s, center) + self.scaleBy(x=x, y=y, center=center) self.sigRangeChangedManually.emit(self.state['mouseEnabled']) def keyPressEvent(self, ev): @@ -1046,10 +1245,10 @@ class ViewBox(GraphicsWidget): xr = item.dataBounds(0, frac=frac[0], orthoRange=orthoRange[0]) yr = item.dataBounds(1, frac=frac[1], orthoRange=orthoRange[1]) pxPad = 0 if not hasattr(item, 'pixelPadding') else item.pixelPadding() - if xr is None or xr == (None, None): + if xr is None or (xr[0] is None and xr[1] is None) or np.isnan(xr).any() or np.isinf(xr).any(): useX = False xr = (0,0) - if yr is None or yr == (None, None): + if yr is None or (yr[0] is None and yr[1] is None) or np.isnan(yr).any() or np.isinf(yr).any(): useY = False yr = (0,0) @@ -1140,40 +1339,79 @@ class ViewBox(GraphicsWidget): bounds = QtCore.QRectF(range[0][0], range[1][0], range[0][1]-range[0][0], range[1][1]-range[1][0]) return bounds + def updateViewRange(self, forceX=False, forceY=False): + ## Update viewRange to match targetRange as closely as possible, given + ## aspect ratio constraints. The *force* arguments are used to indicate + ## which axis (if any) should be unchanged when applying constraints. + viewRange = [self.state['targetRange'][0][:], self.state['targetRange'][1][:]] + changed = [False, False] - - def updateMatrix(self, changed=None): - ## Make the childGroup's transform match the requested range. + # Make correction for aspect ratio constraint - if changed is None: - changed = [False, False] - changed = list(changed) - #print "udpateMatrix:" - #print " range:", self.range + ## aspect is (widget w/h) / (view range w/h) + aspect = self.state['aspectLocked'] # size ratio / view ratio tr = self.targetRect() - bounds = self.rect() #boundingRect() - #print bounds - - ## set viewRect, given targetRect and possibly aspect ratio constraint - if self.state['aspectLocked'] is False or bounds.height() == 0: - self.state['viewRange'] = [self.state['targetRange'][0][:], self.state['targetRange'][1][:]] - else: - viewRatio = bounds.width() / bounds.height() - targetRatio = self.state['aspectLocked'] * tr.width() / tr.height() - if targetRatio > viewRatio: - ## target is wider than view - dy = 0.5 * (tr.width() / (self.state['aspectLocked'] * viewRatio) - tr.height()) + bounds = self.rect() + if aspect is not False and aspect != 0 and tr.height() != 0 and bounds.height() != 0: + + ## This is the view range aspect ratio we have requested + targetRatio = tr.width() / tr.height() + ## This is the view range aspect ratio we need to obey aspect constraint + viewRatio = (bounds.width() / bounds.height()) / aspect + + # Decide which range to keep unchanged + #print self.name, "aspect:", aspect, "changed:", changed, "auto:", self.state['autoRange'] + if forceX: + ax = 0 + elif forceY: + ax = 1 + else: + # if we are not required to keep a particular axis unchanged, + # then make the entire target range visible + ax = 0 if targetRatio > viewRatio else 1 + + #### these should affect viewRange, not targetRange! + if ax == 0: + ## view range needs to be taller than target + dy = 0.5 * (tr.width() / viewRatio - tr.height()) if dy != 0: changed[1] = True - self.state['viewRange'] = [self.state['targetRange'][0][:], [self.state['targetRange'][1][0] - dy, self.state['targetRange'][1][1] + dy]] + viewRange[1] = [self.state['targetRange'][1][0] - dy, self.state['targetRange'][1][1] + dy] else: - dx = 0.5 * (tr.height() * viewRatio * self.state['aspectLocked'] - tr.width()) + ## view range needs to be wider than target + dx = 0.5 * (tr.height() * viewRatio - tr.width()) if dx != 0: changed[0] = True - self.state['viewRange'] = [[self.state['targetRange'][0][0] - dx, self.state['targetRange'][0][1] + dx], self.state['targetRange'][1][:]] + viewRange[0] = [self.state['targetRange'][0][0] - dx, self.state['targetRange'][0][1] + dx] + + changed = [(viewRange[i][0] != self.state['viewRange'][i][0]) and (viewRange[i][1] != self.state['viewRange'][i][1]) for i in (0,1)] + self.state['viewRange'] = viewRange + + # emit range change signals + if changed[0]: + self.sigXRangeChanged.emit(self, tuple(self.state['viewRange'][0])) + if changed[1]: + self.sigYRangeChanged.emit(self, tuple(self.state['viewRange'][1])) + + if any(changed): + self.sigRangeChanged.emit(self, self.state['viewRange']) + self.update() + + # Inform linked views that the range has changed + for ax in [0, 1]: + if not changed[ax]: + continue + link = self.linkedView(ax) + if link is not None: + link.linkedViewChanged(self, ax) + + self._matrixNeedsUpdate = True + + def updateMatrix(self, changed=None): + ## Make the childGroup's transform match the requested viewRange. + bounds = self.rect() vr = self.viewRect() - #print " bounds:", bounds if vr.height() == 0 or vr.width() == 0: return scale = Point(bounds.width()/vr.width(), bounds.height()/vr.height()) @@ -1192,21 +1430,23 @@ class ViewBox(GraphicsWidget): self.childGroup.setTransform(m) - if changed[0]: - self.sigXRangeChanged.emit(self, tuple(self.state['viewRange'][0])) - if changed[1]: - self.sigYRangeChanged.emit(self, tuple(self.state['viewRange'][1])) - if any(changed): - self.sigRangeChanged.emit(self, self.state['viewRange']) - self.sigTransformChanged.emit(self) ## segfaults here: 1 + self._matrixNeedsUpdate = False def paint(self, p, opt, widget): + self.checkSceneChange() + if self.border is not None: bounds = self.shape() p.setPen(self.border) #p.fillRect(bounds, QtGui.QColor(0, 0, 0)) p.drawPath(bounds) + + #p.setPen(fn.mkPen('r')) + #path = QtGui.QPainterPath() + #path.addRect(self.targetRect()) + #tr = self.mapFromView(path) + #p.drawPath(tr) def updateBackground(self): bg = self.state['background'] @@ -1276,6 +1516,8 @@ class ViewBox(GraphicsWidget): k.destroyed.disconnect() except RuntimeError: ## signal is already disconnected. pass + except TypeError: ## view has already been deleted (?) + pass def locate(self, item, timeout=3.0, children=False): """ diff --git a/graphicsItems/ViewBox/ViewBoxMenu.py b/graphicsItems/ViewBox/ViewBoxMenu.py index bbb40efc..5242ecdd 100644 --- a/graphicsItems/ViewBox/ViewBoxMenu.py +++ b/graphicsItems/ViewBox/ViewBoxMenu.py @@ -65,8 +65,18 @@ class ViewBoxMenu(QtGui.QMenu): self.leftMenu = QtGui.QMenu("Mouse Mode") group = QtGui.QActionGroup(self) - pan = self.leftMenu.addAction("3 button", self.set3ButtonMode) - zoom = self.leftMenu.addAction("1 button", self.set1ButtonMode) + + # This does not work! QAction _must_ be initialized with a permanent + # object as the parent or else it may be collected prematurely. + #pan = self.leftMenu.addAction("3 button", self.set3ButtonMode) + #zoom = self.leftMenu.addAction("1 button", self.set1ButtonMode) + pan = QtGui.QAction("3 button", self.leftMenu) + zoom = QtGui.QAction("1 button", self.leftMenu) + self.leftMenu.addAction(pan) + self.leftMenu.addAction(zoom) + pan.triggered.connect(self.set3ButtonMode) + zoom.triggered.connect(self.set1ButtonMode) + pan.setCheckable(True) zoom.setCheckable(True) pan.setActionGroup(group) diff --git a/graphicsItems/tests/ViewBox.py b/graphicsItems/tests/ViewBox.py new file mode 100644 index 00000000..91d9b617 --- /dev/null +++ b/graphicsItems/tests/ViewBox.py @@ -0,0 +1,95 @@ +""" +ViewBox test cases: + +* call setRange then resize; requested range must be fully visible +* lockAspect works correctly for arbitrary aspect ratio +* autoRange works correctly with aspect locked +* call setRange with aspect locked, then resize +* AutoRange with all the bells and whistles + * item moves / changes transformation / changes bounds + * pan only + * fractional range + + +""" + +import pyqtgraph as pg +app = pg.mkQApp() + +imgData = pg.np.zeros((10, 10)) +imgData[0] = 3 +imgData[-1] = 3 +imgData[:,0] = 3 +imgData[:,-1] = 3 + +def testLinkWithAspectLock(): + global win, vb + win = pg.GraphicsWindow() + vb = win.addViewBox(name="image view") + vb.setAspectLocked() + vb.enableAutoRange(x=False, y=False) + p1 = win.addPlot(name="plot 1") + p2 = win.addPlot(name="plot 2", row=1, col=0) + win.ci.layout.setRowFixedHeight(1, 150) + win.ci.layout.setColumnFixedWidth(1, 150) + + def viewsMatch(): + r0 = pg.np.array(vb.viewRange()) + r1 = pg.np.array(p1.vb.viewRange()[1]) + r2 = pg.np.array(p2.vb.viewRange()[1]) + match = (abs(r0[1]-r1) <= (abs(r1) * 0.001)).all() and (abs(r0[0]-r2) <= (abs(r2) * 0.001)).all() + return match + + p1.setYLink(vb) + p2.setXLink(vb) + print "link views match:", viewsMatch() + win.show() + print "show views match:", viewsMatch() + img = pg.ImageItem(imgData) + vb.addItem(img) + vb.autoRange() + p1.plot(x=imgData.sum(axis=0), y=range(10)) + p2.plot(x=range(10), y=imgData.sum(axis=1)) + print "add items views match:", viewsMatch() + #p1.setAspectLocked() + #grid = pg.GridItem() + #vb.addItem(grid) + pg.QtGui.QApplication.processEvents() + pg.QtGui.QApplication.processEvents() + #win.resize(801, 600) + +def testAspectLock(): + global win, vb + win = pg.GraphicsWindow() + vb = win.addViewBox(name="image view") + vb.setAspectLocked() + img = pg.ImageItem(imgData) + vb.addItem(img) + + +#app.processEvents() +#print "init views match:", viewsMatch() +#p2.setYRange(-300, 300) +#print "setRange views match:", viewsMatch() +#app.processEvents() +#print "setRange views match (after update):", viewsMatch() + +#print "--lock aspect--" +#p1.setAspectLocked(True) +#print "lockAspect views match:", viewsMatch() +#p2.setYRange(-200, 200) +#print "setRange views match:", viewsMatch() +#app.processEvents() +#print "setRange views match (after update):", viewsMatch() + +#win.resize(100, 600) +#app.processEvents() +#vb.setRange(xRange=[-10, 10], padding=0) +#app.processEvents() +#win.resize(600, 100) +#app.processEvents() +#print vb.viewRange() + + +if __name__ == '__main__': + testLinkWithAspectLock() diff --git a/imageview/ImageView.py b/imageview/ImageView.py index f0c13a60..77f34419 100644 --- a/imageview/ImageView.py +++ b/imageview/ImageView.py @@ -90,14 +90,6 @@ class ImageView(QtGui.QWidget): self.ignoreTimeLine = False - #if 'linux' in sys.platform.lower(): ## Stupid GL bug in linux. - # self.ui.graphicsView.setViewport(QtGui.QWidget()) - - #self.ui.graphicsView.enableMouse(True) - #self.ui.graphicsView.autoPixelRange = False - #self.ui.graphicsView.setAspectLocked(True) - #self.ui.graphicsView.invertY() - #self.ui.graphicsView.enableMouse() if view is None: self.view = ViewBox() else: @@ -106,13 +98,6 @@ class ImageView(QtGui.QWidget): self.view.setAspectLocked(True) self.view.invertY() - #self.ticks = [t[0] for t in self.ui.gradientWidget.listTicks()] - #self.ticks[0].colorChangeAllowed = False - #self.ticks[1].colorChangeAllowed = False - #self.ui.gradientWidget.allowAdd = False - #self.ui.gradientWidget.setTickColor(self.ticks[1], QtGui.QColor(255,255,255)) - #self.ui.gradientWidget.setOrientation('right') - if imageItem is None: self.imageItem = ImageItem() else: @@ -133,7 +118,6 @@ class ImageView(QtGui.QWidget): self.normRoi.setZValue(20) self.view.addItem(self.normRoi) self.normRoi.hide() - #self.ui.roiPlot.hide() self.roiCurve = self.ui.roiPlot.plot() self.timeLine = InfiniteLine(0, movable=True) self.timeLine.setPen(QtGui.QPen(QtGui.QColor(255, 255, 0, 200))) @@ -147,13 +131,6 @@ class ImageView(QtGui.QWidget): self.playRate = 0 self.lastPlayTime = 0 - #self.normLines = [] - #for i in [0,1]: - #l = InfiniteLine(self.ui.roiPlot, 0) - #l.setPen(QtGui.QPen(QtGui.QColor(0, 100, 200, 200))) - #self.ui.roiPlot.addItem(l) - #self.normLines.append(l) - #l.hide() self.normRgn = LinearRegionItem() self.normRgn.setZValue(0) self.ui.roiPlot.addItem(self.normRgn) @@ -168,7 +145,6 @@ class ImageView(QtGui.QWidget): setattr(self, fn, getattr(self.ui.histogram, fn)) self.timeLine.sigPositionChanged.connect(self.timeLineChanged) - #self.ui.gradientWidget.sigGradientChanged.connect(self.updateImage) self.ui.roiBtn.clicked.connect(self.roiClicked) self.roi.sigRegionChanged.connect(self.roiChanged) self.ui.normBtn.toggled.connect(self.normToggled) @@ -187,31 +163,32 @@ class ImageView(QtGui.QWidget): self.noRepeatKeys = [QtCore.Qt.Key_Right, QtCore.Qt.Key_Left, QtCore.Qt.Key_Up, QtCore.Qt.Key_Down, QtCore.Qt.Key_PageUp, QtCore.Qt.Key_PageDown] - self.roiClicked() ## initialize roi plot to correct shape / visibility - def setImage(self, img, autoRange=True, autoLevels=True, levels=None, axes=None, xvals=None, pos=None, scale=None, transform=None): + def setImage(self, img, autoRange=True, autoLevels=True, levels=None, axes=None, xvals=None, pos=None, scale=None, transform=None, autoHistogramRange=True): """ Set the image to be displayed in the widget. - ============== ======================================================================= + ================== ======================================================================= **Arguments:** - *img* (numpy array) the image to be displayed. - *xvals* (numpy array) 1D array of z-axis values corresponding to the third axis - in a 3D image. For video, this array should contain the time of each frame. - *autoRange* (bool) whether to scale/pan the view to fit the image. - *autoLevels* (bool) whether to update the white/black levels to fit the image. - *levels* (min, max); the white and black level values to use. - *axes* Dictionary indicating the interpretation for each axis. - This is only needed to override the default guess. Format is:: + img (numpy array) the image to be displayed. + xvals (numpy array) 1D array of z-axis values corresponding to the third axis + in a 3D image. For video, this array should contain the time of each frame. + autoRange (bool) whether to scale/pan the view to fit the image. + autoLevels (bool) whether to update the white/black levels to fit the image. + levels (min, max); the white and black level values to use. + axes Dictionary indicating the interpretation for each axis. + This is only needed to override the default guess. Format is:: - {'t':0, 'x':1, 'y':2, 'c':3}; + {'t':0, 'x':1, 'y':2, 'c':3}; - *pos* Change the position of the displayed image - *scale* Change the scale of the displayed image - *transform* Set the transform of the dispalyed image. This option overrides *pos* - and *scale*. - ============== ======================================================================= + pos Change the position of the displayed image + scale Change the scale of the displayed image + transform Set the transform of the displayed image. This option overrides *pos* + and *scale*. + autoHistogramRange If True, the histogram y-range is automatically scaled to fit the + image data. + ================== ======================================================================= """ prof = debug.Profiler('ImageView.setImage', disabled=True) @@ -231,9 +208,7 @@ class ImageView(QtGui.QWidget): self.tVals = np.arange(img.shape[0]) else: self.tVals = np.arange(img.shape[0]) - #self.ui.timeSlider.setValue(0) - #self.ui.normStartSlider.setValue(0) - #self.ui.timeSlider.setMaximum(img.shape[0]-1) + prof.mark('1') if axes is None: @@ -265,14 +240,13 @@ class ImageView(QtGui.QWidget): prof.mark('3') - + self.currentIndex = 0 - self.updateImage() + self.updateImage(autoHistogramRange=autoHistogramRange) if levels is None and autoLevels: self.autoLevels() if levels is not None: ## this does nothing since getProcessedImage sets these values again. - self.levelMax = levels[1] - self.levelMin = levels[0] + self.setLevels(*levels) if self.ui.roiBtn.isChecked(): self.roiChanged() @@ -328,15 +302,9 @@ class ImageView(QtGui.QWidget): if not self.playTimer.isActive(): self.playTimer.start(16) - - def autoLevels(self): - """Set the min/max levels automatically to match the image data.""" - #image = self.getProcessedImage() + """Set the min/max intensity levels automatically to match the image data.""" self.setLevels(self.levelMin, self.levelMax) - - #self.ui.histogram.imageChanged(autoLevel=True) - def setLevels(self, min, max): """Set the min/max (bright and dark) levels.""" @@ -345,17 +313,16 @@ class ImageView(QtGui.QWidget): def autoRange(self): """Auto scale and pan the view around the image.""" image = self.getProcessedImage() - - #self.ui.graphicsView.setRange(QtCore.QRectF(0, 0, image.shape[self.axes['x']], image.shape[self.axes['y']]), padding=0., lockAspect=True) - self.view.autoRange() ##setRange(self.imageItem.viewBoundingRect(), padding=0.) + self.view.autoRange() def getProcessedImage(self): - """Returns the image data after it has been processed by any normalization options in use.""" + """Returns the image data after it has been processed by any normalization options in use. + This method also sets the attributes self.levelMin and self.levelMax + to indicate the range of data in the image.""" if self.imageDisp is None: image = self.normalize(self.image) self.imageDisp = image self.levelMin, self.levelMax = list(map(float, ImageView.quickMinMax(self.imageDisp))) - self.ui.histogram.setHistogramRange(self.levelMin, self.levelMax) return self.imageDisp @@ -364,7 +331,6 @@ class ImageView(QtGui.QWidget): """Closes the widget nicely, making sure to clear the graphics scene and release memory.""" self.ui.roiPlot.close() self.ui.graphicsView.close() - #self.ui.gradientWidget.sigGradientChanged.disconnect(self.updateImage) self.scene.clear() del self.image del self.imageDisp @@ -467,20 +433,12 @@ class ImageView(QtGui.QWidget): def normRadioChanged(self): self.imageDisp = None self.updateImage() + self.autoLevels() self.roiChanged() self.sigProcessingChanged.emit(self) def updateNorm(self): - #for l, sl in zip(self.normLines, [self.ui.normStartSlider, self.ui.normStopSlider]): - #if self.ui.normTimeRangeCheck.isChecked(): - #l.show() - #else: - #l.hide() - - #i, t = self.timeIndex(sl) - #l.setPos(t) - if self.ui.normTimeRangeCheck.isChecked(): #print "show!" self.normRgn.show() @@ -496,6 +454,7 @@ class ImageView(QtGui.QWidget): if not self.ui.normOffRadio.isChecked(): self.imageDisp = None self.updateImage() + self.autoLevels() self.roiChanged() self.sigProcessingChanged.emit(self) @@ -633,22 +592,19 @@ class ImageView(QtGui.QWidget): #self.emit(QtCore.SIGNAL('timeChanged'), ind, time) self.sigTimeChanged.emit(ind, time) - def updateImage(self): + def updateImage(self, autoHistogramRange=True): ## Redraw image on screen if self.image is None: return image = self.getProcessedImage() - #print "update:", image.ndim, image.max(), image.min(), self.blackLevel(), self.whiteLevel() + + if autoHistogramRange: + self.ui.histogram.setHistogramRange(self.levelMin, self.levelMax) if self.axes['t'] is None: - #self.ui.timeSlider.hide() self.imageItem.updateImage(image) - #self.ui.roiPlot.hide() - #self.ui.roiBtn.hide() else: - #self.ui.roiBtn.show() self.ui.roiPlot.show() - #self.ui.timeSlider.show() self.imageItem.updateImage(image[self.currentIndex]) @@ -656,38 +612,22 @@ class ImageView(QtGui.QWidget): ## Return the time and frame index indicated by a slider if self.image is None: return (0,0) - #v = slider.value() - #vmax = slider.maximum() - #f = float(v) / vmax t = slider.value() - #t = 0.0 - #xv = self.image.xvals('Time') xv = self.tVals if xv is None: ind = int(t) - #ind = int(f * self.image.shape[0]) else: if len(xv) < 2: return (0,0) totTime = xv[-1] + (xv[-1]-xv[-2]) - #t = f * totTime inds = np.argwhere(xv < t) if len(inds) < 1: return (0,t) ind = inds[-1,0] - #print ind return ind, t - #def whiteLevel(self): - #return self.levelMin + (self.levelMax-self.levelMin) * self.ui.gradientWidget.tickValue(self.ticks[1]) - ##return self.levelMin + (self.levelMax-self.levelMin) * self.ui.whiteSlider.value() / self.ui.whiteSlider.maximum() - - #def blackLevel(self): - #return self.levelMin + (self.levelMax-self.levelMin) * self.ui.gradientWidget.tickValue(self.ticks[0]) - ##return self.levelMin + ((self.levelMax-self.levelMin) / self.ui.blackSlider.maximum()) * self.ui.blackSlider.value() - def getView(self): """Return the ViewBox (or other compatible object) which displays the ImageItem""" return self.view diff --git a/metaarray/MetaArray.py b/metaarray/MetaArray.py index 0797c75e..f55c60dc 100644 --- a/metaarray/MetaArray.py +++ b/metaarray/MetaArray.py @@ -328,6 +328,9 @@ class MetaArray(object): def __div__(self, b): return self._binop('__div__', b) + def __truediv__(self, b): + return self._binop('__truediv__', b) + def _binop(self, op, b): if isinstance(b, MetaArray): b = b.asarray() diff --git a/multiprocess/bootstrap.py b/multiprocess/bootstrap.py index 4ecfb7da..bb71a703 100644 --- a/multiprocess/bootstrap.py +++ b/multiprocess/bootstrap.py @@ -20,10 +20,9 @@ if __name__ == '__main__': if opts.pop('pyside', False): import PySide - #import pyqtgraph - #import pyqtgraph.multiprocess.processes + + targetStr = opts.pop('targetStr') target = pickle.loads(targetStr) ## unpickling the target should import everything we need - #target(name, port, authkey, ppid) target(**opts) ## Send all other options to the target function sys.exit(0) diff --git a/multiprocess/parallelizer.py b/multiprocess/parallelizer.py index 9925a573..e96692e2 100644 --- a/multiprocess/parallelizer.py +++ b/multiprocess/parallelizer.py @@ -129,7 +129,7 @@ class Parallelize(object): self.childs.append(proc) ## Keep track of the progress of each worker independently. - self.progress = {ch.childPid: [] for ch in self.childs} + self.progress = dict([(ch.childPid, []) for ch in self.childs]) ## for each child process, self.progress[pid] is a list ## of task indexes. The last index is the task currently being ## processed; all others are finished. diff --git a/multiprocess/processes.py b/multiprocess/processes.py index 93a109ed..4d32c999 100644 --- a/multiprocess/processes.py +++ b/multiprocess/processes.py @@ -1,7 +1,7 @@ from .remoteproxy import RemoteEventHandler, ClosedError, NoResultError, LocalObjectProxy, ObjectProxy import subprocess, atexit, os, sys, time, random, socket, signal import multiprocessing.connection -from pyqtgraph.Qt import USE_PYSIDE +import pyqtgraph as pg try: import cPickle as pickle except ImportError: @@ -35,7 +35,7 @@ class Process(RemoteEventHandler): ProxyObject for more information. """ - def __init__(self, name=None, target=None, executable=None, copySysPath=True, debug=False): + def __init__(self, name=None, target=None, executable=None, copySysPath=True, debug=False, timeout=20, wrapStdout=None): """ ============ ============================================================= Arguments: @@ -49,8 +49,13 @@ class Process(RemoteEventHandler): copySysPath If True, copy the contents of sys.path to the remote process debug If True, print detailed information about communication with the child process. + wrapStdout If True (default on windows) then stdout and stderr from the + child process will be caught by the parent process and + forwarded to its stdout/stderr. This provides a workaround + for a python bug: http://bugs.python.org/issue3905 + but has the side effect that child output is significantly + delayed relative to the parent output. ============ ============================================================= - """ if target is None: target = startEventLoop @@ -62,28 +67,48 @@ class Process(RemoteEventHandler): ## random authentication key authkey = os.urandom(20) + + ## Windows seems to have a hard time with hmac + if sys.platform.startswith('win'): + authkey = None + #print "key:", ' '.join([str(ord(x)) for x in authkey]) ## Listen for connection from remote process (and find free port number) port = 10000 while True: try: - ## hmac authentication appears to be broken on windows (says AuthenticationError: digest received was wrong) l = multiprocessing.connection.Listener(('localhost', int(port)), authkey=authkey) break except socket.error as ex: - if ex.errno != 98: + if ex.errno != 98 and ex.errno != 10048: # unix=98, win=10048 raise port += 1 - + + ## start remote process, instruct it to run target function sysPath = sys.path if copySysPath else None bootstrap = os.path.abspath(os.path.join(os.path.dirname(__file__), 'bootstrap.py')) self.debugMsg('Starting child process (%s %s)' % (executable, bootstrap)) - self.proc = subprocess.Popen((executable, bootstrap), stdin=subprocess.PIPE) + + if wrapStdout is None: + wrapStdout = sys.platform.startswith('win') + + if wrapStdout: + ## note: we need all three streams to have their own PIPE due to this bug: + ## http://bugs.python.org/issue3905 + stdout = subprocess.PIPE + stderr = subprocess.PIPE + self.proc = subprocess.Popen((executable, bootstrap), stdin=subprocess.PIPE, stdout=stdout, stderr=stderr) + ## to circumvent the bug and still make the output visible, we use + ## background threads to pass data from pipes to stdout/stderr + self._stdoutForwarder = FileForwarder(self.proc.stdout, "stdout") + self._stderrForwarder = FileForwarder(self.proc.stderr, "stderr") + else: + self.proc = subprocess.Popen((executable, bootstrap), stdin=subprocess.PIPE) + targetStr = pickle.dumps(target) ## double-pickle target so that child has a chance to ## set its sys.path properly before unpickling the target pid = os.getpid() # we must send pid to child because windows does not have getppid - pyside = USE_PYSIDE ## Send everything the remote process needs to start correctly data = dict( @@ -93,14 +118,14 @@ class Process(RemoteEventHandler): ppid=pid, targetStr=targetStr, path=sysPath, - pyside=pyside, + pyside=pg.Qt.USE_PYSIDE, debug=debug ) pickle.dump(data, self.proc.stdin) self.proc.stdin.close() ## open connection for remote process - self.debugMsg('Listening for child process..') + self.debugMsg('Listening for child process on port %d, authkey=%s..' % (port, repr(authkey))) while True: try: conn = l.accept() @@ -115,6 +140,7 @@ class Process(RemoteEventHandler): self.debugMsg('Connected to child process.') atexit.register(self.join) + def join(self, timeout=10): self.debugMsg('Joining child process..') @@ -126,10 +152,24 @@ class Process(RemoteEventHandler): raise Exception('Timed out waiting for remote process to end.') time.sleep(0.05) self.debugMsg('Child process exited. (%d)' % self.proc.returncode) - + + def debugMsg(self, msg): + if hasattr(self, '_stdoutForwarder'): + ## Lock output from subprocess to make sure we do not get line collisions + with self._stdoutForwarder.lock: + with self._stderrForwarder.lock: + RemoteEventHandler.debugMsg(self, msg) + else: + RemoteEventHandler.debugMsg(self, msg) + def startEventLoop(name, port, authkey, ppid, debug=False): + if debug: + import os + print('[%d] connecting to server at port localhost:%d, authkey=%s..' % (os.getpid(), port, repr(authkey))) conn = multiprocessing.connection.Client(('localhost', int(port)), authkey=authkey) + if debug: + print('[%d] connected; starting remote proxy.' % os.getpid()) global HANDLER #ppid = 0 if not hasattr(os, 'getppid') else os.getppid() HANDLER = RemoteEventHandler(conn, name, ppid, debug=debug) @@ -321,7 +361,8 @@ class QtProcess(Process): GUI. - A QTimer is also started on the parent process which polls for requests from the child process. This allows Qt signals emitted within the child - process to invoke slots on the parent process and vice-versa. + process to invoke slots on the parent process and vice-versa. This can + be disabled using processRequests=False in the constructor. Example:: @@ -338,18 +379,29 @@ class QtProcess(Process): def __init__(self, **kwds): if 'target' not in kwds: kwds['target'] = startQtEventLoop + self._processRequests = kwds.pop('processRequests', True) Process.__init__(self, **kwds) self.startEventTimer() def startEventTimer(self): from pyqtgraph.Qt import QtGui, QtCore ## avoid module-level import to keep bootstrap snappy. self.timer = QtCore.QTimer() - app = QtGui.QApplication.instance() - if app is None: - raise Exception("Must create QApplication before starting QtProcess") + if self._processRequests: + app = QtGui.QApplication.instance() + if app is None: + raise Exception("Must create QApplication before starting QtProcess, or use QtProcess(processRequests=False)") + self.startRequestProcessing() + + def startRequestProcessing(self, interval=0.01): + """Start listening for requests coming from the child process. + This allows signals to be connected from the child process to the parent. + """ self.timer.timeout.connect(self.processRequests) - self.timer.start(10) + self.timer.start(interval*1000) + def stopRequestProcessing(self): + self.timer.stop() + def processRequests(self): try: Process.processRequests(self) @@ -357,7 +409,12 @@ class QtProcess(Process): self.timer.stop() def startQtEventLoop(name, port, authkey, ppid, debug=False): + if debug: + import os + print('[%d] connecting to server at port localhost:%d, authkey=%s..' % (os.getpid(), port, repr(authkey))) conn = multiprocessing.connection.Client(('localhost', int(port)), authkey=authkey) + if debug: + print('[%d] connected; starting remote proxy.' % os.getpid()) from pyqtgraph.Qt import QtGui, QtCore #from PyQt4 import QtGui, QtCore app = QtGui.QApplication.instance() @@ -373,4 +430,43 @@ def startQtEventLoop(name, port, authkey, ppid, debug=False): HANDLER.startEventTimer() app.exec_() +import threading +class FileForwarder(threading.Thread): + """ + Background thread that forwards data from one pipe to another. + This is used to catch data from stdout/stderr of the child process + and print it back out to stdout/stderr. We need this because this + bug: http://bugs.python.org/issue3905 _requires_ us to catch + stdout/stderr. + + *output* may be a file or 'stdout' or 'stderr'. In the latter cases, + sys.stdout/stderr are retrieved once for every line that is output, + which ensures that the correct behavior is achieved even if + sys.stdout/stderr are replaced at runtime. + """ + def __init__(self, input, output): + threading.Thread.__init__(self) + self.input = input + self.output = output + self.lock = threading.Lock() + self.start() + + def run(self): + if self.output == 'stdout': + while True: + line = self.input.readline() + with self.lock: + sys.stdout.write(line) + elif self.output == 'stderr': + while True: + line = self.input.readline() + with self.lock: + sys.stderr.write(line) + else: + while True: + line = self.input.readline() + with self.lock: + self.output.write(line) + + diff --git a/multiprocess/remoteproxy.py b/multiprocess/remoteproxy.py index 6cd65f6e..eba42ef3 100644 --- a/multiprocess/remoteproxy.py +++ b/multiprocess/remoteproxy.py @@ -97,7 +97,6 @@ class RemoteEventHandler(object): after no more events are immediately available. (non-blocking) Returns the number of events processed. """ - self.debugMsg('processRequests:') if self.exited: self.debugMsg(' processRequests: exited already; raise ClosedError.') raise ClosedError() @@ -108,7 +107,7 @@ class RemoteEventHandler(object): self.handleRequest() numProcessed += 1 except ClosedError: - self.debugMsg(' processRequests: got ClosedError from handleRequest; setting exited=True.') + self.debugMsg('processRequests: got ClosedError from handleRequest; setting exited=True.') self.exited = True raise #except IOError as err: ## let handleRequest take care of this. @@ -121,7 +120,8 @@ class RemoteEventHandler(object): print("Error in process %s" % self.name) sys.excepthook(*sys.exc_info()) - self.debugMsg(' processRequests: finished %d requests' % numProcessed) + if numProcessed > 0: + self.debugMsg('processRequests: finished %d requests' % numProcessed) return numProcessed def handleRequest(self): @@ -205,7 +205,11 @@ class RemoteEventHandler(object): fnkwds[k] = np.fromstring(byteData[ind], dtype=dtype).reshape(shape) if len(fnkwds) == 0: ## need to do this because some functions do not allow keyword arguments. - result = obj(*fnargs) + try: + result = obj(*fnargs) + except: + print("Failed to call object %s: %d, %s" % (obj, len(fnargs), fnargs[1:])) + raise else: result = obj(*fnargs, **fnkwds) @@ -803,7 +807,7 @@ class ObjectProxy(object): return val def _getProxyOptions(self): - return {k: self._getProxyOption(k) for k in self._proxyOptions} + return dict([(k, self._getProxyOption(k)) for k in self._proxyOptions]) def __reduce__(self): return (unpickleObjectProxy, (self._processId, self._proxyId, self._typeStr, self._attributes)) @@ -887,6 +891,12 @@ class ObjectProxy(object): def __div__(self, *args): return self._getSpecialAttr('__div__')(*args) + def __truediv__(self, *args): + return self._getSpecialAttr('__truediv__')(*args) + + def __floordiv__(self, *args): + return self._getSpecialAttr('__floordiv__')(*args) + def __mul__(self, *args): return self._getSpecialAttr('__mul__')(*args) @@ -902,6 +912,12 @@ class ObjectProxy(object): def __idiv__(self, *args): return self._getSpecialAttr('__idiv__')(*args, _callSync='off') + def __itruediv__(self, *args): + return self._getSpecialAttr('__itruediv__')(*args, _callSync='off') + + def __ifloordiv__(self, *args): + return self._getSpecialAttr('__ifloordiv__')(*args, _callSync='off') + def __imul__(self, *args): return self._getSpecialAttr('__imul__')(*args, _callSync='off') @@ -914,17 +930,11 @@ class ObjectProxy(object): def __lshift__(self, *args): return self._getSpecialAttr('__lshift__')(*args) - def __floordiv__(self, *args): - return self._getSpecialAttr('__pow__')(*args) - def __irshift__(self, *args): - return self._getSpecialAttr('__rshift__')(*args, _callSync='off') + return self._getSpecialAttr('__irshift__')(*args, _callSync='off') def __ilshift__(self, *args): - return self._getSpecialAttr('__lshift__')(*args, _callSync='off') - - def __ifloordiv__(self, *args): - return self._getSpecialAttr('__pow__')(*args, _callSync='off') + return self._getSpecialAttr('__ilshift__')(*args, _callSync='off') def __eq__(self, *args): return self._getSpecialAttr('__eq__')(*args) @@ -974,6 +984,12 @@ class ObjectProxy(object): def __rdiv__(self, *args): return self._getSpecialAttr('__rdiv__')(*args) + def __rfloordiv__(self, *args): + return self._getSpecialAttr('__rfloordiv__')(*args) + + def __rtruediv__(self, *args): + return self._getSpecialAttr('__rtruediv__')(*args) + def __rmul__(self, *args): return self._getSpecialAttr('__rmul__')(*args) @@ -986,9 +1002,6 @@ class ObjectProxy(object): def __rlshift__(self, *args): return self._getSpecialAttr('__rlshift__')(*args) - def __rfloordiv__(self, *args): - return self._getSpecialAttr('__rpow__')(*args) - def __rand__(self, *args): return self._getSpecialAttr('__rand__')(*args) @@ -1001,6 +1014,10 @@ class ObjectProxy(object): def __rmod__(self, *args): return self._getSpecialAttr('__rmod__')(*args) + def __hash__(self): + ## Required for python3 since __eq__ is defined. + return id(self) + class DeferredObjectProxy(ObjectProxy): """ This class represents an attribute (or sub-attribute) of a proxied object. diff --git a/opengl/GLGraphicsItem.py b/opengl/GLGraphicsItem.py index 9babec3a..9680fba7 100644 --- a/opengl/GLGraphicsItem.py +++ b/opengl/GLGraphicsItem.py @@ -40,6 +40,7 @@ class GLGraphicsItem(QtCore.QObject): self.__glOpts = {} def setParentItem(self, item): + """Set this item's parent in the scenegraph hierarchy.""" if self.__parent is not None: self.__parent.__children.remove(self) if item is not None: @@ -98,9 +99,11 @@ class GLGraphicsItem(QtCore.QObject): def parentItem(self): + """Return a this item's parent in the scenegraph hierarchy.""" return self.__parent def childItems(self): + """Return a list of this item's children in the scenegraph hierarchy.""" return list(self.__children) def _setView(self, v): @@ -116,27 +119,35 @@ class GLGraphicsItem(QtCore.QObject): Items with negative depth values are drawn before their parent. (This is analogous to QGraphicsItem.zValue) The depthValue does NOT affect the position of the item or the values it imparts to the GL depth buffer. - '""" + """ self.__depthValue = value def depthValue(self): - """Return the depth value of this item. See setDepthValue for mode information.""" + """Return the depth value of this item. See setDepthValue for more information.""" return self.__depthValue def setTransform(self, tr): + """Set the local transform for this object. + Must be a :class:`Transform3D ` instance. This transform + determines how the local coordinate system of the item is mapped to the coordinate + system of its parent.""" self.__transform = Transform3D(tr) self.update() def resetTransform(self): + """Reset this item's transform to an identity transformation.""" self.__transform.setToIdentity() self.update() def applyTransform(self, tr, local): """ Multiply this object's transform by *tr*. - If local is True, then *tr* is multiplied on the right of the current transform: + If local is True, then *tr* is multiplied on the right of the current transform:: + newTransform = transform * tr - If local is False, then *tr* is instead multiplied on the left: + + If local is False, then *tr* is instead multiplied on the left:: + newTransform = tr * transform """ if local: @@ -145,9 +156,12 @@ class GLGraphicsItem(QtCore.QObject): self.setTransform(tr * self.transform()) def transform(self): + """Return this item's transform object.""" return self.__transform def viewTransform(self): + """Return the transform mapping this item's local coordinate system to the + view coordinate system.""" tr = self.__transform p = self while True: @@ -187,16 +201,24 @@ class GLGraphicsItem(QtCore.QObject): def hide(self): + """Hide this item. + This is equivalent to setVisible(False).""" self.setVisible(False) def show(self): + """Make this item visible if it was previously hidden. + This is equivalent to setVisible(True).""" self.setVisible(True) def setVisible(self, vis): + """Set the visibility of this item.""" self.__visible = vis self.update() def visible(self): + """Return True if the item is currently set to be visible. + Note that this does not guarantee that the item actually appears in the + view, as it may be obscured or outside of the current view area.""" return self.__visible @@ -234,10 +256,14 @@ class GLGraphicsItem(QtCore.QObject): self.setupGLState() def update(self): + """ + Indicates that this item needs to be redrawn, and schedules an update + with the view it is displayed in. + """ v = self.view() if v is None: return - v.updateGL() + v.update() def mapToParent(self, point): tr = self.transform() diff --git a/opengl/GLViewWidget.py b/opengl/GLViewWidget.py index d1c1d090..89fef92e 100644 --- a/opengl/GLViewWidget.py +++ b/opengl/GLViewWidget.py @@ -1,7 +1,10 @@ from pyqtgraph.Qt import QtCore, QtGui, QtOpenGL from OpenGL.GL import * +import OpenGL.GL.framebufferobjects as glfbo import numpy as np from pyqtgraph import Vector +import pyqtgraph.functions as fn + ##Vector = QtGui.QVector3D class GLViewWidget(QtOpenGL.QGLWidget): @@ -31,6 +34,7 @@ class GLViewWidget(QtOpenGL.QGLWidget): 'elevation': 30, ## camera's angle of elevation in degrees 'azimuth': 45, ## camera's azimuthal angle in degrees ## (rotation around z-axis 0 points along x-axis) + 'viewport': None, ## glViewport params; None == whole widget } self.items = [] self.noRepeatKeys = [QtCore.Qt.Key_Right, QtCore.Qt.Key_Left, QtCore.Qt.Key_Up, QtCore.Qt.Key_Down, QtCore.Qt.Key_PageUp, QtCore.Qt.Key_PageDown] @@ -63,55 +67,116 @@ class GLViewWidget(QtOpenGL.QGLWidget): glClearColor(0.0, 0.0, 0.0, 0.0) self.resizeGL(self.width(), self.height()) + def getViewport(self): + vp = self.opts['viewport'] + if vp is None: + return (0, 0, self.width(), self.height()) + else: + return vp + def resizeGL(self, w, h): - glViewport(0, 0, w, h) + pass + #glViewport(*self.getViewport()) #self.update() - def setProjection(self): - ## Create the projection matrix + def setProjection(self, region=None): + m = self.projectionMatrix(region) glMatrixMode(GL_PROJECTION) glLoadIdentity() - w = self.width() - h = self.height() + a = np.array(m.copyDataTo()).reshape((4,4)) + glMultMatrixf(a.transpose()) + + def projectionMatrix(self, region=None): + # Xw = (Xnd + 1) * width/2 + X + if region is None: + region = (0, 0, self.width(), self.height()) + + x0, y0, w, h = self.getViewport() dist = self.opts['distance'] fov = self.opts['fov'] - nearClip = dist * 0.001 farClip = dist * 1000. - + r = nearClip * np.tan(fov * 0.5 * np.pi / 180.) t = r * h / w - glFrustum( -r, r, -t, t, nearClip, farClip) + + # convert screen coordinates (region) to normalized device coordinates + # Xnd = (Xw - X0) * 2/width - 1 + ## Note that X0 and width in these equations must be the values used in viewport + left = r * ((region[0]-x0) * (2.0/w) - 1) + right = r * ((region[0]+region[2]-x0) * (2.0/w) - 1) + bottom = t * ((region[1]-y0) * (2.0/h) - 1) + top = t * ((region[1]+region[3]-y0) * (2.0/h) - 1) + + tr = QtGui.QMatrix4x4() + tr.frustum(left, right, bottom, top, nearClip, farClip) + return tr def setModelview(self): glMatrixMode(GL_MODELVIEW) glLoadIdentity() - glTranslatef( 0.0, 0.0, -self.opts['distance']) - glRotatef(self.opts['elevation']-90, 1, 0, 0) - glRotatef(self.opts['azimuth']+90, 0, 0, -1) + m = self.viewMatrix() + a = np.array(m.copyDataTo()).reshape((4,4)) + glMultMatrixf(a.transpose()) + + def viewMatrix(self): + tr = QtGui.QMatrix4x4() + tr.translate( 0.0, 0.0, -self.opts['distance']) + tr.rotate(self.opts['elevation']-90, 1, 0, 0) + tr.rotate(self.opts['azimuth']+90, 0, 0, -1) center = self.opts['center'] - glTranslatef(-center.x(), -center.y(), -center.z()) + tr.translate(-center.x(), -center.y(), -center.z()) + return tr + + def itemsAt(self, region=None): + #buf = np.zeros(100000, dtype=np.uint) + buf = glSelectBuffer(100000) + try: + glRenderMode(GL_SELECT) + glInitNames() + glPushName(0) + self._itemNames = {} + self.paintGL(region=region, useItemNames=True) + + finally: + hits = glRenderMode(GL_RENDER) + + items = [(h.near, h.names[0]) for h in hits] + items.sort(key=lambda i: i[0]) + return [self._itemNames[i[1]] for i in items] - def paintGL(self): - self.setProjection() + def paintGL(self, region=None, viewport=None, useItemNames=False): + """ + viewport specifies the arguments to glViewport. If None, then we use self.opts['viewport'] + region specifies the sub-region of self.opts['viewport'] that should be rendered. + Note that we may use viewport != self.opts['viewport'] when exporting. + """ + if viewport is None: + glViewport(*self.getViewport()) + else: + glViewport(*viewport) + self.setProjection(region=region) self.setModelview() glClear( GL_DEPTH_BUFFER_BIT | GL_COLOR_BUFFER_BIT ) - self.drawItemTree() + self.drawItemTree(useItemNames=useItemNames) - def drawItemTree(self, item=None): + def drawItemTree(self, item=None, useItemNames=False): if item is None: items = [x for x in self.items if x.parentItem() is None] else: items = item.childItems() items.append(item) - items.sort(lambda a,b: cmp(a.depthValue(), b.depthValue())) + items.sort(key=lambda a: a.depthValue()) for i in items: if not i.visible(): continue if i is item: try: glPushAttrib(GL_ALL_ATTRIB_BITS) + if useItemNames: + glLoadName(id(i)) + self._itemNames[id(i)] = i i.paint() except: import pyqtgraph.debug @@ -120,7 +185,7 @@ class GLViewWidget(QtOpenGL.QGLWidget): ver = glGetString(GL_VERSION) if ver is not None: ver = ver.split()[0] - if int(ver.split('.')[0]) < 2: + if int(ver.split(b'.')[0]) < 2: print(msg + " The original exception is printed above; however, pyqtgraph requires OpenGL version 2.0 or greater for many of its 3D features and your OpenGL version is %s. Installing updated display drivers may resolve this issue." % ver) else: print(msg) @@ -134,7 +199,7 @@ class GLViewWidget(QtOpenGL.QGLWidget): tr = i.transform() a = np.array(tr.copyDataTo()).reshape((4,4)) glMultMatrixf(a.transpose()) - self.drawItemTree(i) + self.drawItemTree(i, useItemNames=useItemNames) finally: glMatrixMode(GL_MODELVIEW) glPopMatrix() @@ -168,6 +233,7 @@ class GLViewWidget(QtOpenGL.QGLWidget): def orbit(self, azim, elev): """Orbits the camera around the center position. *azim* and *elev* are given in degrees.""" self.opts['azimuth'] += azim + #self.opts['elevation'] += elev self.opts['elevation'] = np.clip(self.opts['elevation'] + elev, -90, 90) self.update() @@ -286,4 +352,85 @@ class GLViewWidget(QtOpenGL.QGLWidget): raise - \ No newline at end of file + + def readQImage(self): + """ + Read the current buffer pixels out as a QImage. + """ + w = self.width() + h = self.height() + self.repaint() + pixels = np.empty((h, w, 4), dtype=np.ubyte) + pixels[:] = 128 + pixels[...,0] = 50 + pixels[...,3] = 255 + + glReadPixels(0, 0, w, h, GL_RGBA, GL_UNSIGNED_BYTE, pixels) + + # swap B,R channels for Qt + tmp = pixels[...,0].copy() + pixels[...,0] = pixels[...,2] + pixels[...,2] = tmp + pixels = pixels[::-1] # flip vertical + + img = fn.makeQImage(pixels, transpose=False) + return img + + + def renderToArray(self, size, format=GL_BGRA, type=GL_UNSIGNED_BYTE, textureSize=1024, padding=256): + w,h = map(int, size) + + self.makeCurrent() + tex = None + fb = None + try: + output = np.empty((w, h, 4), dtype=np.ubyte) + fb = glfbo.glGenFramebuffers(1) + glfbo.glBindFramebuffer(glfbo.GL_FRAMEBUFFER, fb ) + + glEnable(GL_TEXTURE_2D) + tex = glGenTextures(1) + glBindTexture(GL_TEXTURE_2D, tex) + texwidth = textureSize + data = np.zeros((texwidth,texwidth,4), dtype=np.ubyte) + + ## Test texture dimensions first + glTexImage2D(GL_PROXY_TEXTURE_2D, 0, GL_RGBA, texwidth, texwidth, 0, GL_RGBA, GL_UNSIGNED_BYTE, None) + if glGetTexLevelParameteriv(GL_PROXY_TEXTURE_2D, 0, GL_TEXTURE_WIDTH) == 0: + raise Exception("OpenGL failed to create 2D texture (%dx%d); too large for this hardware." % shape[:2]) + ## create teture + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, texwidth, texwidth, 0, GL_RGBA, GL_UNSIGNED_BYTE, data.transpose((1,0,2))) + + self.opts['viewport'] = (0, 0, w, h) # viewport is the complete image; this ensures that paintGL(region=...) + # is interpreted correctly. + p2 = 2 * padding + for x in range(-padding, w-padding, texwidth-p2): + for y in range(-padding, h-padding, texwidth-p2): + x2 = min(x+texwidth, w+padding) + y2 = min(y+texwidth, h+padding) + w2 = x2-x + h2 = y2-y + + ## render to texture + glfbo.glFramebufferTexture2D(glfbo.GL_FRAMEBUFFER, glfbo.GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, tex, 0) + + self.paintGL(region=(x, h-y-h2, w2, h2), viewport=(0, 0, w2, h2)) # only render sub-region + + ## read texture back to array + data = glGetTexImage(GL_TEXTURE_2D, 0, format, type) + data = np.fromstring(data, dtype=np.ubyte).reshape(texwidth,texwidth,4).transpose(1,0,2)[:, ::-1] + output[x+padding:x2-padding, y+padding:y2-padding] = data[padding:w2-padding, -(h2-padding):-padding] + + finally: + self.opts['viewport'] = None + glfbo.glBindFramebuffer(glfbo.GL_FRAMEBUFFER, 0) + glBindTexture(GL_TEXTURE_2D, 0) + if tex is not None: + glDeleteTextures([tex]) + if fb is not None: + glfbo.glDeleteFramebuffers([fb]) + + return output + + + diff --git a/opengl/MeshData.py b/opengl/MeshData.py index 170074b9..71e566c9 100644 --- a/opengl/MeshData.py +++ b/opengl/MeshData.py @@ -44,7 +44,7 @@ class MeshData(object): ## mappings between vertexes, faces, and edges self._faces = None # Nx3 array of indexes into self._vertexes specifying three vertexes for each face - self._edges = None + self._edges = None # Nx2 array of indexes into self._vertexes specifying two vertexes per edge self._vertexFaces = None ## maps vertex ID to a list of face IDs (inverse mapping of _faces) self._vertexEdges = None ## maps vertex ID to a list of edge IDs (inverse mapping of _edges) @@ -143,12 +143,19 @@ class MeshData(object): def faces(self): """Return an array (Nf, 3) of vertex indexes, three per triangular face in the mesh.""" return self._faces + + def edges(self): + """Return an array (Nf, 3) of vertex indexes, two per edge in the mesh.""" + if self._edges is None: + self._computeEdges() + return self._edges def setFaces(self, faces): """Set the (Nf, 3) array of faces. Each rown in the array contains three indexes into the vertex array, specifying the three corners of a triangular face.""" self._faces = faces + self._edges = None self._vertexFaces = None self._vertexesIndexedByFaces = None self.resetNormals() @@ -240,9 +247,9 @@ class MeshData(object): return self._faceNormals elif indexed == 'faces': if self._faceNormalsIndexedByFaces is None: - norms = np.empty((self._faceNormals.shape[0], 3, 3)) - norms[:] = self._faceNormals[:,np.newaxis,:] - self._faceNormalsIndexedByFaces = norms + norms = np.empty((self._faceNormals.shape[0], 3, 3)) + norms[:] = self._faceNormals[:,np.newaxis,:] + self._faceNormalsIndexedByFaces = norms return self._faceNormalsIndexedByFaces else: raise Exception("Invalid indexing mode. Accepts: None, 'faces'") @@ -418,6 +425,25 @@ class MeshData(object): #""" #pass + def _computeEdges(self): + ## generate self._edges from self._faces + #print self._faces + nf = len(self._faces) + edges = np.empty(nf*3, dtype=[('i', np.uint, 2)]) + edges['i'][0:nf] = self._faces[:,:2] + edges['i'][nf:2*nf] = self._faces[:,1:3] + edges['i'][-nf:,0] = self._faces[:,2] + edges['i'][-nf:,1] = self._faces[:,0] + + # sort per-edge + mask = edges['i'][:,0] > edges['i'][:,1] + edges['i'][mask] = edges['i'][mask][:,::-1] + + # remove duplicate entries + self._edges = np.unique(edges)['i'] + #print self._edges + + def save(self): """Serialize this mesh to a string appropriate for disk storage""" import pickle diff --git a/opengl/__init__.py b/opengl/__init__.py index 199c372c..5345e187 100644 --- a/opengl/__init__.py +++ b/opengl/__init__.py @@ -23,8 +23,8 @@ from pyqtgraph import importAll importAll('items', globals(), locals()) \ -from MeshData import MeshData +from .MeshData import MeshData ## for backward compatibility: #MeshData.MeshData = MeshData ## breaks autodoc. -import shaders +from . import shaders diff --git a/opengl/items/GLAxisItem.py b/opengl/items/GLAxisItem.py index 1586d70a..860ac497 100644 --- a/opengl/items/GLAxisItem.py +++ b/opengl/items/GLAxisItem.py @@ -12,11 +12,13 @@ class GLAxisItem(GLGraphicsItem): """ - def __init__(self, size=None): + def __init__(self, size=None, antialias=True, glOptions='translucent'): GLGraphicsItem.__init__(self) if size is None: size = QtGui.QVector3D(1,1,1) + self.antialias = antialias self.setSize(size=size) + self.setGLOptions(glOptions) def setSize(self, x=None, y=None, z=None, size=None): """ @@ -36,11 +38,15 @@ class GLAxisItem(GLGraphicsItem): def paint(self): - glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) - glEnable( GL_BLEND ) - glEnable( GL_ALPHA_TEST ) - glEnable( GL_POINT_SMOOTH ) - #glDisable( GL_DEPTH_TEST ) + #glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) + #glEnable( GL_BLEND ) + #glEnable( GL_ALPHA_TEST ) + self.setupGLState() + + if self.antialias: + glEnable(GL_LINE_SMOOTH) + glHint(GL_LINE_SMOOTH_HINT, GL_NICEST); + glBegin( GL_LINES ) x,y,z = self.size() diff --git a/opengl/items/GLBarGraphItem.py b/opengl/items/GLBarGraphItem.py new file mode 100644 index 00000000..b3060dc9 --- /dev/null +++ b/opengl/items/GLBarGraphItem.py @@ -0,0 +1,29 @@ +from .GLMeshItem import GLMeshItem +from ..MeshData import MeshData +import numpy as np + +class GLBarGraphItem(GLMeshItem): + def __init__(self, pos, size): + """ + pos is (...,3) array of the bar positions (the corner of each bar) + size is (...,3) array of the sizes of each bar + """ + nCubes = reduce(lambda a,b: a*b, pos.shape[:-1]) + cubeVerts = np.mgrid[0:2,0:2,0:2].reshape(3,8).transpose().reshape(1,8,3) + cubeFaces = np.array([ + [0,1,2], [3,2,1], + [4,5,6], [7,6,5], + [0,1,4], [5,4,1], + [2,3,6], [7,6,3], + [0,2,4], [6,4,2], + [1,3,5], [7,5,3]]).reshape(1,12,3) + size = size.reshape((nCubes, 1, 3)) + pos = pos.reshape((nCubes, 1, 3)) + verts = cubeVerts * size + pos + faces = cubeFaces + (np.arange(nCubes) * 8).reshape(nCubes,1,1) + md = MeshData(verts.reshape(nCubes*8,3), faces.reshape(nCubes*12,3)) + + GLMeshItem.__init__(self, meshdata=md, shader='shaded', smooth=False) + + + \ No newline at end of file diff --git a/opengl/items/GLBoxItem.py b/opengl/items/GLBoxItem.py index af888e91..bc25afd1 100644 --- a/opengl/items/GLBoxItem.py +++ b/opengl/items/GLBoxItem.py @@ -11,7 +11,7 @@ class GLBoxItem(GLGraphicsItem): Displays a wire-frame box. """ - def __init__(self, size=None, color=None): + def __init__(self, size=None, color=None, glOptions='translucent'): GLGraphicsItem.__init__(self) if size is None: size = QtGui.QVector3D(1,1,1) @@ -19,6 +19,7 @@ class GLBoxItem(GLGraphicsItem): if color is None: color = (255,255,255,80) self.setColor(color) + self.setGLOptions(glOptions) def setSize(self, x=None, y=None, z=None, size=None): """ @@ -43,12 +44,14 @@ class GLBoxItem(GLGraphicsItem): return self.__color def paint(self): - glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) - glEnable( GL_BLEND ) - glEnable( GL_ALPHA_TEST ) - #glAlphaFunc( GL_ALWAYS,0.5 ) - glEnable( GL_POINT_SMOOTH ) - glDisable( GL_DEPTH_TEST ) + #glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) + #glEnable( GL_BLEND ) + #glEnable( GL_ALPHA_TEST ) + ##glAlphaFunc( GL_ALWAYS,0.5 ) + #glEnable( GL_POINT_SMOOTH ) + #glDisable( GL_DEPTH_TEST ) + self.setupGLState() + glBegin( GL_LINES ) glColor4f(*self.color().glColor()) diff --git a/opengl/items/GLGridItem.py b/opengl/items/GLGridItem.py index 630b2aba..01a2b178 100644 --- a/opengl/items/GLGridItem.py +++ b/opengl/items/GLGridItem.py @@ -11,9 +11,10 @@ class GLGridItem(GLGraphicsItem): Displays a wire-grame grid. """ - def __init__(self, size=None, color=None, glOptions='translucent'): + def __init__(self, size=None, color=None, antialias=True, glOptions='translucent'): GLGraphicsItem.__init__(self) self.setGLOptions(glOptions) + self.antialias = antialias if size is None: size = QtGui.QVector3D(1,1,1) self.setSize(size=size) @@ -36,11 +37,13 @@ class GLGridItem(GLGraphicsItem): def paint(self): self.setupGLState() - #glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) - #glEnable( GL_BLEND ) - #glEnable( GL_ALPHA_TEST ) - glEnable( GL_POINT_SMOOTH ) - #glDisable( GL_DEPTH_TEST ) + + if self.antialias: + glEnable(GL_LINE_SMOOTH) + glEnable(GL_BLEND) + glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) + glHint(GL_LINE_SMOOTH_HINT, GL_NICEST); + glBegin( GL_LINES ) x,y,z = self.size() diff --git a/opengl/items/GLImageItem.py b/opengl/items/GLImageItem.py index b292a7b7..aca68f3d 100644 --- a/opengl/items/GLImageItem.py +++ b/opengl/items/GLImageItem.py @@ -13,7 +13,7 @@ class GLImageItem(GLGraphicsItem): """ - def __init__(self, data, smooth=False): + def __init__(self, data, smooth=False, glOptions='translucent'): """ ============== ======================================================================================= @@ -27,6 +27,7 @@ class GLImageItem(GLGraphicsItem): self.smooth = smooth self.data = data GLGraphicsItem.__init__(self) + self.setGLOptions(glOptions) def initializeGL(self): glEnable(GL_TEXTURE_2D) @@ -66,11 +67,13 @@ class GLImageItem(GLGraphicsItem): glEnable(GL_TEXTURE_2D) glBindTexture(GL_TEXTURE_2D, self.texture) - glEnable(GL_DEPTH_TEST) - #glDisable(GL_CULL_FACE) - glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) - glEnable( GL_BLEND ) - glEnable( GL_ALPHA_TEST ) + self.setupGLState() + + #glEnable(GL_DEPTH_TEST) + ##glDisable(GL_CULL_FACE) + #glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) + #glEnable( GL_BLEND ) + #glEnable( GL_ALPHA_TEST ) glColor4f(1,1,1,1) glBegin(GL_QUADS) diff --git a/opengl/items/GLLinePlotItem.py b/opengl/items/GLLinePlotItem.py index ef747d17..23d227c9 100644 --- a/opengl/items/GLLinePlotItem.py +++ b/opengl/items/GLLinePlotItem.py @@ -11,6 +11,7 @@ class GLLinePlotItem(GLGraphicsItem): """Draws line plots in 3D.""" def __init__(self, **kwds): + """All keyword arguments are passed to setData()""" GLGraphicsItem.__init__(self) glopts = kwds.pop('glOptions', 'additive') self.setGLOptions(glopts) @@ -22,23 +23,25 @@ class GLLinePlotItem(GLGraphicsItem): def setData(self, **kwds): """ Update the data displayed by this item. All arguments are optional; - for example it is allowed to update spot positions while leaving + for example it is allowed to update vertex positions while leaving colors unchanged, etc. ==================== ================================================== Arguments: ------------------------------------------------------------------------ pos (N,3) array of floats specifying point locations. - color tuple of floats (0.0-1.0) specifying - a color for the entire item. + color (N,4) array of floats (0.0-1.0) or + tuple of floats specifying + a single color for the entire item. width float specifying line width + antialias enables smooth line drawing ==================== ================================================== """ - args = ['pos', 'color', 'width', 'connected'] + args = ['pos', 'color', 'width', 'connected', 'antialias'] for k in kwds.keys(): if k not in args: raise Exception('Invalid keyword argument: %s (allowed arguments are %s)' % (k, str(args))) - + self.antialias = False for arg in args: if arg in kwds: setattr(self, arg, kwds[arg]) @@ -69,13 +72,30 @@ class GLLinePlotItem(GLGraphicsItem): self.setupGLState() glEnableClientState(GL_VERTEX_ARRAY) + try: glVertexPointerf(self.pos) - glColor4f(*self.color) - glPointSize(self.width) - glDrawArrays(GL_LINE_STRIP, 0, self.pos.size / self.pos.shape[-1]) + if isinstance(self.color, np.ndarray): + glEnableClientState(GL_COLOR_ARRAY) + glColorPointerf(self.color) + else: + if isinstance(self.color, QtGui.QColor): + glColor4f(*fn.glColor(self.color)) + else: + glColor4f(*self.color) + glLineWidth(self.width) + #glPointSize(self.width) + + if self.antialias: + glEnable(GL_LINE_SMOOTH) + glEnable(GL_BLEND) + glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) + glHint(GL_LINE_SMOOTH_HINT, GL_NICEST); + + glDrawArrays(GL_LINE_STRIP, 0, int(self.pos.size / self.pos.shape[-1])) finally: + glDisableClientState(GL_COLOR_ARRAY) glDisableClientState(GL_VERTEX_ARRAY) diff --git a/opengl/items/GLMeshItem.py b/opengl/items/GLMeshItem.py index 4222c96b..5b245e64 100644 --- a/opengl/items/GLMeshItem.py +++ b/opengl/items/GLMeshItem.py @@ -22,9 +22,15 @@ class GLMeshItem(GLGraphicsItem): Arguments meshdata MeshData object from which to determine geometry for this item. - color Default color used if no vertex or face colors are - specified. - shader Name of shader program to use (None for no shader) + color Default face color used if no vertex or face colors + are specified. + edgeColor Default edge color to use if no edge colors are + specified in the mesh data. + drawEdges If True, a wireframe mesh will be drawn. + (default=False) + drawFaces If True, mesh faces are drawn. (default=True) + shader Name of shader program to use when drawing faces. + (None for no shader) smooth If True, normal vectors are computed for each vertex and interpolated within each face. computeNormals If False, then computation of normal vectors is @@ -35,6 +41,9 @@ class GLMeshItem(GLGraphicsItem): self.opts = { 'meshdata': None, 'color': (1., 1., 1., 1.), + 'drawEdges': False, + 'drawFaces': True, + 'edgeColor': (0.5, 0.5, 0.5, 1.0), 'shader': None, 'smooth': True, 'computeNormals': True, @@ -60,7 +69,11 @@ class GLMeshItem(GLGraphicsItem): self.update() def shader(self): - return shaders.getShaderProgram(self.opts['shader']) + shader = self.opts['shader'] + if isinstance(shader, shaders.ShaderProgram): + return shader + else: + return shaders.getShaderProgram(shader) def setColor(self, c): """Set the default color to use when no vertex or face colors are specified.""" @@ -100,6 +113,8 @@ class GLMeshItem(GLGraphicsItem): self.faces = None self.normals = None self.colors = None + self.edges = None + self.edgeColors = None self.update() def parseMeshData(self): @@ -137,6 +152,9 @@ class GLMeshItem(GLGraphicsItem): elif md.hasFaceColor(): self.colors = md.faceColors(indexed='faces') + if self.opts['drawEdges']: + self.edges = md.edges() + self.edgeVerts = md.vertexes() return def paint(self): @@ -144,19 +162,52 @@ class GLMeshItem(GLGraphicsItem): self.parseMeshData() - with self.shader(): - verts = self.vertexes - norms = self.normals - color = self.colors - faces = self.faces - if verts is None: - return + if self.opts['drawFaces']: + with self.shader(): + verts = self.vertexes + norms = self.normals + color = self.colors + faces = self.faces + if verts is None: + return + glEnableClientState(GL_VERTEX_ARRAY) + try: + glVertexPointerf(verts) + + if self.colors is None: + color = self.opts['color'] + if isinstance(color, QtGui.QColor): + glColor4f(*pg.glColor(color)) + else: + glColor4f(*color) + else: + glEnableClientState(GL_COLOR_ARRAY) + glColorPointerf(color) + + + if norms is not None: + glEnableClientState(GL_NORMAL_ARRAY) + glNormalPointerf(norms) + + if faces is None: + glDrawArrays(GL_TRIANGLES, 0, np.product(verts.shape[:-1])) + else: + faces = faces.astype(np.uint).flatten() + glDrawElements(GL_TRIANGLES, faces.shape[0], GL_UNSIGNED_INT, faces) + finally: + glDisableClientState(GL_NORMAL_ARRAY) + glDisableClientState(GL_VERTEX_ARRAY) + glDisableClientState(GL_COLOR_ARRAY) + + if self.opts['drawEdges']: + verts = self.edgeVerts + edges = self.edges glEnableClientState(GL_VERTEX_ARRAY) try: glVertexPointerf(verts) - if self.colors is None: - color = self.opts['color'] + if self.edgeColors is None: + color = self.opts['edgeColor'] if isinstance(color, QtGui.QColor): glColor4f(*pg.glColor(color)) else: @@ -164,19 +215,9 @@ class GLMeshItem(GLGraphicsItem): else: glEnableClientState(GL_COLOR_ARRAY) glColorPointerf(color) - - - if norms is not None: - glEnableClientState(GL_NORMAL_ARRAY) - glNormalPointerf(norms) - - if faces is None: - glDrawArrays(GL_TRIANGLES, 0, np.product(verts.shape[:-1])) - else: - faces = faces.astype(np.uint).flatten() - glDrawElements(GL_TRIANGLES, faces.shape[0], GL_UNSIGNED_INT, faces) + edges = edges.flatten() + glDrawElements(GL_LINES, edges.shape[0], GL_UNSIGNED_INT, edges) finally: - glDisableClientState(GL_NORMAL_ARRAY) glDisableClientState(GL_VERTEX_ARRAY) glDisableClientState(GL_COLOR_ARRAY) diff --git a/opengl/items/GLScatterPlotItem.py b/opengl/items/GLScatterPlotItem.py index e9bbde64..b02a9dda 100644 --- a/opengl/items/GLScatterPlotItem.py +++ b/opengl/items/GLScatterPlotItem.py @@ -146,7 +146,7 @@ class GLScatterPlotItem(GLGraphicsItem): else: glNormal3f(self.size, 0, 0) ## vertex shader uses norm.x to determine point size #glPointSize(self.size) - glDrawArrays(GL_POINTS, 0, pos.size / pos.shape[-1]) + glDrawArrays(GL_POINTS, 0, int(pos.size / pos.shape[-1])) finally: glDisableClientState(GL_NORMAL_ARRAY) glDisableClientState(GL_VERTEX_ARRAY) diff --git a/opengl/items/GLSurfacePlotItem.py b/opengl/items/GLSurfacePlotItem.py index 46c54fc2..88d50fac 100644 --- a/opengl/items/GLSurfacePlotItem.py +++ b/opengl/items/GLSurfacePlotItem.py @@ -1,5 +1,5 @@ from OpenGL.GL import * -from GLMeshItem import GLMeshItem +from .GLMeshItem import GLMeshItem from .. MeshData import MeshData from pyqtgraph.Qt import QtGui import pyqtgraph as pg @@ -136,4 +136,4 @@ class GLSurfacePlotItem(GLMeshItem): start = row * cols * 2 faces[start:start+cols] = rowtemplate1 + row * (cols+1) faces[start+cols:start+(cols*2)] = rowtemplate2 + row * (cols+1) - self._faces = faces \ No newline at end of file + self._faces = faces diff --git a/opengl/shaders.py b/opengl/shaders.py index b1652850..8922cd21 100644 --- a/opengl/shaders.py +++ b/opengl/shaders.py @@ -1,3 +1,7 @@ +try: + from OpenGL import NullFunctionError +except ImportError: + from OpenGL.error import NullFunctionError from OpenGL.GL import * from OpenGL.GL import shaders import re @@ -218,15 +222,20 @@ class Shader(object): if self.compiled is None: try: self.compiled = shaders.compileShader(self.code, self.shaderType) + except NullFunctionError: + raise Exception("This OpenGL implementation does not support shader programs; many OpenGL features in pyqtgraph will not work.") except RuntimeError as exc: ## Format compile errors a bit more nicely if len(exc.args) == 3: err, code, typ = exc.args if not err.startswith('Shader compile failure'): raise - code = code[0].split('\n') + code = code[0].decode('utf_8').split('\n') err, c, msgs = err.partition(':') err = err + '\n' + msgs = re.sub('b\'','',msgs) + msgs = re.sub('\'$','',msgs) + msgs = re.sub('\\\\n','\n',msgs) msgs = msgs.split('\n') errNums = [()] * len(code) for i, msg in enumerate(msgs): @@ -354,7 +363,7 @@ class ShaderProgram(object): def uniform(self, name): """Return the location integer for a uniform variable in this program""" - return glGetUniformLocation(self.program(), name) + return glGetUniformLocation(self.program(), name.encode('utf_8')) #def uniformBlockInfo(self, blockName): #blockIndex = glGetUniformBlockIndex(self.program(), blockName) @@ -390,4 +399,4 @@ class HeightColorShader(ShaderProgram): ## bind buffer to the same binding point glBindBufferBase(GL_UNIFORM_BUFFER, bindPoint, buf) -initShaders() \ No newline at end of file +initShaders() diff --git a/ordereddict.py b/ordereddict.py new file mode 100644 index 00000000..5b0303f5 --- /dev/null +++ b/ordereddict.py @@ -0,0 +1,127 @@ +# Copyright (c) 2009 Raymond Hettinger +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation files +# (the "Software"), to deal in the Software without restriction, +# including without limitation the rights to use, copy, modify, merge, +# publish, distribute, sublicense, and/or sell copies of the Software, +# and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +# OTHER DEALINGS IN THE SOFTWARE. + +from UserDict import DictMixin + +class OrderedDict(dict, DictMixin): + + def __init__(self, *args, **kwds): + if len(args) > 1: + raise TypeError('expected at most 1 arguments, got %d' % len(args)) + try: + self.__end + except AttributeError: + self.clear() + self.update(*args, **kwds) + + def clear(self): + self.__end = end = [] + end += [None, end, end] # sentinel node for doubly linked list + self.__map = {} # key --> [key, prev, next] + dict.clear(self) + + def __setitem__(self, key, value): + if key not in self: + end = self.__end + curr = end[1] + curr[2] = end[1] = self.__map[key] = [key, curr, end] + dict.__setitem__(self, key, value) + + def __delitem__(self, key): + dict.__delitem__(self, key) + key, prev, next = self.__map.pop(key) + prev[2] = next + next[1] = prev + + def __iter__(self): + end = self.__end + curr = end[2] + while curr is not end: + yield curr[0] + curr = curr[2] + + def __reversed__(self): + end = self.__end + curr = end[1] + while curr is not end: + yield curr[0] + curr = curr[1] + + def popitem(self, last=True): + if not self: + raise KeyError('dictionary is empty') + if last: + key = reversed(self).next() + else: + key = iter(self).next() + value = self.pop(key) + return key, value + + def __reduce__(self): + items = [[k, self[k]] for k in self] + tmp = self.__map, self.__end + del self.__map, self.__end + inst_dict = vars(self).copy() + self.__map, self.__end = tmp + if inst_dict: + return (self.__class__, (items,), inst_dict) + return self.__class__, (items,) + + def keys(self): + return list(self) + + setdefault = DictMixin.setdefault + update = DictMixin.update + pop = DictMixin.pop + values = DictMixin.values + items = DictMixin.items + iterkeys = DictMixin.iterkeys + itervalues = DictMixin.itervalues + iteritems = DictMixin.iteritems + + def __repr__(self): + if not self: + return '%s()' % (self.__class__.__name__,) + return '%s(%r)' % (self.__class__.__name__, self.items()) + + def copy(self): + return self.__class__(self) + + @classmethod + def fromkeys(cls, iterable, value=None): + d = cls() + for key in iterable: + d[key] = value + return d + + def __eq__(self, other): + if isinstance(other, OrderedDict): + if len(self) != len(other): + return False + for p, q in zip(self.items(), other.items()): + if p != q: + return False + return True + return dict.__eq__(self, other) + + def __ne__(self, other): + return not self == other diff --git a/parametertree/ParameterItem.py b/parametertree/ParameterItem.py index 376e900d..46499fd3 100644 --- a/parametertree/ParameterItem.py +++ b/parametertree/ParameterItem.py @@ -157,3 +157,9 @@ class ParameterItem(QtGui.QTreeWidgetItem): ## since destroying the menu in mid-action will cause a crash. QtCore.QTimer.singleShot(0, self.param.remove) + ## for python 3 support, we need to redefine hash and eq methods. + def __hash__(self): + return id(self) + + def __eq__(self, x): + return x is self diff --git a/parametertree/ParameterTree.py b/parametertree/ParameterTree.py index e57430ea..866875e5 100644 --- a/parametertree/ParameterTree.py +++ b/parametertree/ParameterTree.py @@ -1,6 +1,7 @@ from pyqtgraph.Qt import QtCore, QtGui from pyqtgraph.widgets.TreeWidget import TreeWidget import os, weakref, re +from .ParameterItem import ParameterItem #import functions as fn @@ -103,7 +104,7 @@ class ParameterTree(TreeWidget): sel = self.selectedItems() if len(sel) != 1: sel = None - if self.lastSel is not None: + if self.lastSel is not None and isinstance(self.lastSel, ParameterItem): self.lastSel.selected(False) if sel is None: self.lastSel = None diff --git a/parametertree/parameterTypes.py b/parametertree/parameterTypes.py index 28e1e618..3300171f 100644 --- a/parametertree/parameterTypes.py +++ b/parametertree/parameterTypes.py @@ -476,32 +476,16 @@ class ListParameterItem(WidgetParameterItem): return w def value(self): - #vals = self.param.opts['limits'] key = asUnicode(self.widget.currentText()) - #if isinstance(vals, dict): - #return vals[key] - #else: - #return key - #print key, self.forward return self.forward.get(key, None) def setValue(self, val): - #vals = self.param.opts['limits'] - #if isinstance(vals, dict): - #key = None - #for k,v in vals.iteritems(): - #if v == val: - #key = k - #if key is None: - #raise Exception("Value '%s' not allowed." % val) - #else: - #key = unicode(val) self.targetValue = val - if val not in self.reverse: + if val not in self.reverse[0]: self.widget.setCurrentIndex(0) else: - key = self.reverse[val] + key = self.reverse[1][self.reverse[0].index(val)] ind = self.widget.findText(key) self.widget.setCurrentIndex(ind) @@ -531,8 +515,8 @@ class ListParameter(Parameter): itemClass = ListParameterItem def __init__(self, **opts): - self.forward = OrderedDict() ## name: value - self.reverse = OrderedDict() ## value: name + self.forward = OrderedDict() ## {name: value, ...} + self.reverse = ([], []) ## ([value, ...], [name, ...]) ## Parameter uses 'limits' option to define the set of allowed values if 'values' in opts: @@ -547,23 +531,40 @@ class ListParameter(Parameter): Parameter.setLimits(self, limits) #print self.name(), self.value(), limits - if self.value() not in self.reverse and len(self.reverse) > 0: - self.setValue(list(self.reverse.keys())[0]) + if len(self.reverse) > 0 and self.value() not in self.reverse[0]: + self.setValue(self.reverse[0][0]) + + #def addItem(self, name, value=None): + #if name in self.forward: + #raise Exception("Name '%s' is already in use for this parameter" % name) + #limits = self.opts['limits'] + #if isinstance(limits, dict): + #limits = limits.copy() + #limits[name] = value + #self.setLimits(limits) + #else: + #if value is not None: + #raise Exception ## raise exception or convert to dict? + #limits = limits[:] + #limits.append(name) + ## what if limits == None? @staticmethod def mapping(limits): - ## Return forward and reverse mapping dictionaries given a limit specification - forward = OrderedDict() ## name: value - reverse = OrderedDict() ## value: name + ## Return forward and reverse mapping objects given a limit specification + forward = OrderedDict() ## {name: value, ...} + reverse = ([], []) ## ([value, ...], [name, ...]) if isinstance(limits, dict): for k, v in limits.items(): forward[k] = v - reverse[v] = k + reverse[0].append(v) + reverse[1].append(k) else: for v in limits: n = asUnicode(v) forward[n] = v - reverse[v] = n + reverse[0].append(v) + reverse[1].append(n) return forward, reverse registerParameterType('list', ListParameter, override=True) @@ -615,13 +616,20 @@ registerParameterType('action', ActionParameter, override=True) class TextParameterItem(WidgetParameterItem): def __init__(self, param, depth): WidgetParameterItem.__init__(self, param, depth) + self.hideWidget = False self.subItem = QtGui.QTreeWidgetItem() self.addChild(self.subItem) def treeWidgetChanged(self): + ## TODO: fix so that superclass method can be called + ## (WidgetParameter should just natively support this style) + #WidgetParameterItem.treeWidgetChanged(self) self.treeWidget().setFirstItemColumnSpanned(self.subItem, True) self.treeWidget().setItemWidget(self.subItem, 0, self.textBox) - self.setExpanded(True) + + # for now, these are copied from ParameterItem.treeWidgetChanged + self.setHidden(not self.param.opts.get('visible', True)) + self.setExpanded(self.param.opts.get('expanded', True)) def makeWidget(self): self.textBox = QtGui.QTextEdit() diff --git a/pgcollections.py b/pgcollections.py index b0198526..76850622 100644 --- a/pgcollections.py +++ b/pgcollections.py @@ -15,75 +15,9 @@ import threading, sys, copy, collections try: from collections import OrderedDict -except: - # Deprecated; this class is now present in Python 2.7 as collections.OrderedDict - # Only keeping this around for python2.6 support. - class OrderedDict(dict): - """extends dict so that elements are iterated in the order that they were added. - Since this class can not be instantiated with regular dict notation, it instead uses - a list of tuples: - od = OrderedDict([(key1, value1), (key2, value2), ...]) - items set using __setattr__ are added to the end of the key list. - """ - - def __init__(self, data=None): - self.order = [] - if data is not None: - for i in data: - self[i[0]] = i[1] - - def __setitem__(self, k, v): - if not self.has_key(k): - self.order.append(k) - dict.__setitem__(self, k, v) - - def __delitem__(self, k): - self.order.remove(k) - dict.__delitem__(self, k) - - def keys(self): - return self.order[:] - - def items(self): - it = [] - for k in self.keys(): - it.append((k, self[k])) - return it - - def values(self): - return [self[k] for k in self.order] - - def remove(self, key): - del self[key] - #self.order.remove(key) - - def __iter__(self): - for k in self.order: - yield k - - def update(self, data): - """Works like dict.update, but accepts list-of-tuples as well as dict.""" - if isinstance(data, dict): - for k, v in data.iteritems(): - self[k] = v - else: - for k,v in data: - self[k] = v - - def copy(self): - return OrderedDict(self.items()) - - def itervalues(self): - for k in self.order: - yield self[k] - - def iteritems(self): - for k in self.order: - yield (k, self[k]) - - def __deepcopy__(self, memo): - return OrderedDict([(k, copy.deepcopy(v, memo)) for k, v in self.iteritems()]) - +except ImportError: + # fallback: try to use the ordereddict backport when using python 2.6 + from ordereddict import OrderedDict class ReverseDict(dict): @@ -540,4 +474,4 @@ if __name__ == '__main__': lp = protect(l) t = (1, 'x', ['a', 'b'], ('c', 'd'), {'x': 1, 'y': 2}) - tp = protect(t) \ No newline at end of file + tp = protect(t) diff --git a/widgets/ColorMapWidget.py b/widgets/ColorMapWidget.py index 619d639a..26539d7e 100644 --- a/widgets/ColorMapWidget.py +++ b/widgets/ColorMapWidget.py @@ -72,7 +72,8 @@ class ColorMapParameter(ptree.types.GroupParameter): (see *values* option). units String indicating the units of the data for this field. values List of unique values for which the user may assign a - color when mode=='enum'. + color when mode=='enum'. Optionally may specify a dict + instead {value: name}. ============== ============================================================ """ self.fields = OrderedDict(fields) @@ -168,7 +169,16 @@ class EnumColorMapItem(ptree.types.GroupParameter): def __init__(self, name, opts): self.fieldName = name vals = opts.get('values', []) + if isinstance(vals, list): + vals = OrderedDict([(v,str(v)) for v in vals]) childs = [{'name': v, 'type': 'color'} for v in vals] + + childs = [] + for val,vname in vals.items(): + ch = ptree.Parameter.create(name=vname, type='color') + ch.maskValue = val + childs.append(ch) + ptree.types.GroupParameter.__init__(self, name=name, autoIncrementName=True, removable=True, renamable=True, children=[ @@ -191,8 +201,7 @@ class EnumColorMapItem(ptree.types.GroupParameter): colors[:] = default for v in self.param('Values'): - n = v.name() - mask = data == n + mask = data == v.maskValue c = np.array(fn.colorTuple(v.value())) / 255. colors[mask] = c #scaled = np.clip((data-self['Min']) / (self['Max']-self['Min']), 0, 1) diff --git a/widgets/DataFilterWidget.py b/widgets/DataFilterWidget.py index a2e1a7b8..c94f6c68 100644 --- a/widgets/DataFilterWidget.py +++ b/widgets/DataFilterWidget.py @@ -2,6 +2,7 @@ from pyqtgraph.Qt import QtGui, QtCore import pyqtgraph.parametertree as ptree import numpy as np from pyqtgraph.pgcollections import OrderedDict +import pyqtgraph as pg __all__ = ['DataFilterWidget'] @@ -22,6 +23,7 @@ class DataFilterWidget(ptree.ParameterTree): self.setFields = self.params.setFields self.filterData = self.params.filterData + self.describe = self.params.describe def filterChanged(self): self.sigFilterChanged.emit(self) @@ -70,18 +72,28 @@ class DataFilterParameter(ptree.types.GroupParameter): for fp in self: if fp.value() is False: continue - mask &= fp.generateMask(data) + mask &= fp.generateMask(data, mask.copy()) #key, mn, mx = fp.fieldName, fp['Min'], fp['Max'] #vals = data[key] #mask &= (vals >= mn) #mask &= (vals < mx) ## Use inclusive minimum and non-inclusive maximum. This makes it easier to create non-overlapping selections return mask + + def describe(self): + """Return a list of strings describing the currently enabled filters.""" + desc = [] + for fp in self: + if fp.value() is False: + continue + desc.append(fp.describe()) + return desc class RangeFilterItem(ptree.types.SimpleParameter): def __init__(self, name, opts): self.fieldName = name units = opts.get('units', '') + self.units = units ptree.types.SimpleParameter.__init__(self, name=name, autoIncrementName=True, type='bool', value=True, removable=True, renamable=True, children=[ @@ -90,26 +102,49 @@ class RangeFilterItem(ptree.types.SimpleParameter): dict(name='Max', type='float', value=1.0, suffix=units, siPrefix=True), ]) - def generateMask(self, data): - vals = data[self.fieldName] - return (vals >= mn) & (vals < mx) ## Use inclusive minimum and non-inclusive maximum. This makes it easier to create non-overlapping selections + def generateMask(self, data, mask): + vals = data[self.fieldName][mask] + mask[mask] = (vals >= self['Min']) & (vals < self['Max']) ## Use inclusive minimum and non-inclusive maximum. This makes it easier to create non-overlapping selections + return mask + def describe(self): + return "%s < %s < %s" % (pg.siFormat(self['Min'], suffix=self.units), self.fieldName, pg.siFormat(self['Max'], suffix=self.units)) class EnumFilterItem(ptree.types.SimpleParameter): def __init__(self, name, opts): self.fieldName = name vals = opts.get('values', []) - childs = [{'name': v, 'type': 'bool', 'value': True} for v in vals] + childs = [] + if isinstance(vals, list): + vals = OrderedDict([(v,str(v)) for v in vals]) + for val,vname in vals.items(): + ch = ptree.Parameter.create(name=vname, type='bool', value=True) + ch.maskValue = val + childs.append(ch) + ch = ptree.Parameter.create(name='(other)', type='bool', value=True) + ch.maskValue = '__other__' + childs.append(ch) + ptree.types.SimpleParameter.__init__(self, name=name, autoIncrementName=True, type='bool', value=True, removable=True, renamable=True, children=childs) - def generateMask(self, data): - vals = data[self.fieldName] - mask = np.ones(len(data), dtype=bool) + def generateMask(self, data, startMask): + vals = data[self.fieldName][startMask] + mask = np.ones(len(vals), dtype=bool) + otherMask = np.ones(len(vals), dtype=bool) for c in self: - if c.value() is True: - continue - key = c.name() - mask &= vals != key - return mask + key = c.maskValue + if key == '__other__': + m = ~otherMask + else: + m = vals != key + otherMask &= m + if c.value() is False: + mask &= m + startMask[startMask] = mask + return startMask + + def describe(self): + vals = [ch.name() for ch in self if ch.value() is True] + return "%s: %s" % (self.fieldName, ', '.join(vals)) \ No newline at end of file diff --git a/widgets/GraphicsView.py b/widgets/GraphicsView.py index dd49ab7d..fb535929 100644 --- a/widgets/GraphicsView.py +++ b/widgets/GraphicsView.py @@ -82,6 +82,7 @@ class GraphicsView(QtGui.QGraphicsView): ## This might help, but it's probably dangerous in the general case.. #self.setOptimizationFlag(self.DontSavePainterState, True) + self.setBackgroundRole(QtGui.QPalette.NoRole) self.setBackground(background) self.setFocusPolicy(QtCore.Qt.StrongFocus) @@ -138,17 +139,19 @@ class GraphicsView(QtGui.QGraphicsView): self._background = background if background == 'default': background = pyqtgraph.getConfigOption('background') - if background is None: - self.setBackgroundRole(QtGui.QPalette.NoRole) - else: - brush = fn.mkBrush(background) - self.setBackgroundBrush(brush) - + brush = fn.mkBrush(background) + self.setBackgroundBrush(brush) + def paintEvent(self, ev): self.scene().prepareForPaint() #print "GV: paint", ev.rect() return QtGui.QGraphicsView.paintEvent(self, ev) + def render(self, *args, **kwds): + self.scene().prepareForPaint() + return QtGui.QGraphicsView.render(self, *args, **kwds) + + def close(self): self.centralWidget = None self.scene().clear() @@ -181,8 +184,9 @@ class GraphicsView(QtGui.QGraphicsView): if self.centralWidget is not None: self.scene().removeItem(self.centralWidget) self.centralWidget = item - self.sceneObj.addItem(item) - self.resizeEvent(None) + if item is not None: + self.sceneObj.addItem(item) + self.resizeEvent(None) def addItem(self, *args): return self.scene().addItem(*args) @@ -272,7 +276,8 @@ class GraphicsView(QtGui.QGraphicsView): scaleChanged = True self.range = newRect #print "New Range:", self.range - self.centralWidget.setGeometry(self.range) + if self.centralWidget is not None: + self.centralWidget.setGeometry(self.range) self.updateMatrix(propagate) if scaleChanged: self.sigScaleChanged.emit(self) diff --git a/widgets/HistogramLUTWidget.py b/widgets/HistogramLUTWidget.py index bc041595..cbe8eb61 100644 --- a/widgets/HistogramLUTWidget.py +++ b/widgets/HistogramLUTWidget.py @@ -13,7 +13,7 @@ __all__ = ['HistogramLUTWidget'] class HistogramLUTWidget(GraphicsView): def __init__(self, parent=None, *args, **kargs): - background = kargs.get('background', 'k') + background = kargs.get('background', 'default') GraphicsView.__init__(self, parent, useOpenGL=False, background=background) self.item = HistogramLUTItem(*args, **kargs) self.setCentralItem(self.item) diff --git a/widgets/MatplotlibWidget.py b/widgets/MatplotlibWidget.py index 25e058f9..6a22c973 100644 --- a/widgets/MatplotlibWidget.py +++ b/widgets/MatplotlibWidget.py @@ -1,5 +1,9 @@ -from pyqtgraph.Qt import QtGui, QtCore +from pyqtgraph.Qt import QtGui, QtCore, USE_PYSIDE import matplotlib + +if USE_PYSIDE: + matplotlib.rcParams['backend.qt4']='PySide' + from matplotlib.backends.backend_qt4agg import FigureCanvasQTAgg as FigureCanvas from matplotlib.backends.backend_qt4agg import NavigationToolbar2QTAgg as NavigationToolbar from matplotlib.figure import Figure diff --git a/widgets/PlotWidget.py b/widgets/PlotWidget.py index 1fa07f2a..7b3c685c 100644 --- a/widgets/PlotWidget.py +++ b/widgets/PlotWidget.py @@ -40,10 +40,12 @@ class PlotWidget(GraphicsView): For all other methods, use :func:`getPlotItem `. """ - def __init__(self, parent=None, **kargs): - """When initializing PlotWidget, all keyword arguments except *parent* are passed + def __init__(self, parent=None, background='default', **kargs): + """When initializing PlotWidget, *parent* and *background* are passed to + :func:`GraphicsWidget.__init__() ` + and all others are passed to :func:`PlotItem.__init__() `.""" - GraphicsView.__init__(self, parent) + GraphicsView.__init__(self, parent, background=background) self.setSizePolicy(QtGui.QSizePolicy.Expanding, QtGui.QSizePolicy.Expanding) self.enableMouse(False) self.plotItem = PlotItem(**kargs) diff --git a/widgets/RawImageWidget.py b/widgets/RawImageWidget.py index ea5c98a0..517f4f99 100644 --- a/widgets/RawImageWidget.py +++ b/widgets/RawImageWidget.py @@ -1,6 +1,7 @@ from pyqtgraph.Qt import QtCore, QtGui try: from pyqtgraph.Qt import QtOpenGL + from OpenGL.GL import * HAVE_OPENGL = True except ImportError: HAVE_OPENGL = False @@ -11,8 +12,8 @@ import numpy as np class RawImageWidget(QtGui.QWidget): """ Widget optimized for very fast video display. - Generally using an ImageItem inside GraphicsView is fast enough, - but if you need even more performance, this widget is about as fast as it gets (but only in unscaled mode). + Generally using an ImageItem inside GraphicsView is fast enough. + On some systems this may provide faster video. See the VideoSpeedTest example for benchmarking. """ def __init__(self, parent=None, scaled=False): """ @@ -62,23 +63,78 @@ if HAVE_OPENGL: class RawImageGLWidget(QtOpenGL.QGLWidget): """ Similar to RawImageWidget, but uses a GL widget to do all drawing. - Generally this will be about as fast as using GraphicsView + ImageItem, - but performance may vary on some platforms. + Perfomance varies between platforms; see examples/VideoSpeedTest for benchmarking. """ def __init__(self, parent=None, scaled=False): QtOpenGL.QGLWidget.__init__(self, parent=None) self.scaled = scaled self.image = None + self.uploaded = False + self.smooth = False + self.opts = None - def setImage(self, img): - self.image = fn.makeQImage(img) + def setImage(self, img, *args, **kargs): + """ + img must be ndarray of shape (x,y), (x,y,3), or (x,y,4). + Extra arguments are sent to functions.makeARGB + """ + self.opts = (img, args, kargs) + self.image = None + self.uploaded = False self.update() - def paintEvent(self, ev): + def initializeGL(self): + self.texture = glGenTextures(1) + + def uploadTexture(self): + glEnable(GL_TEXTURE_2D) + glBindTexture(GL_TEXTURE_2D, self.texture) + if self.smooth: + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR) + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR) + else: + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST) + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST) + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_BORDER) + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_BORDER) + #glTexParameteri(GL_TEXTURE_3D, GL_TEXTURE_WRAP_R, GL_CLAMP_TO_BORDER) + shape = self.image.shape + + ### Test texture dimensions first + #glTexImage2D(GL_PROXY_TEXTURE_2D, 0, GL_RGBA, shape[0], shape[1], 0, GL_RGBA, GL_UNSIGNED_BYTE, None) + #if glGetTexLevelParameteriv(GL_PROXY_TEXTURE_2D, 0, GL_TEXTURE_WIDTH) == 0: + #raise Exception("OpenGL failed to create 2D texture (%dx%d); too large for this hardware." % shape[:2]) + + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, shape[0], shape[1], 0, GL_RGBA, GL_UNSIGNED_BYTE, self.image.transpose((1,0,2))) + glDisable(GL_TEXTURE_2D) + + def paintGL(self): if self.image is None: - return - p = QtGui.QPainter(self) - p.drawImage(self.rect(), self.image) - p.end() + if self.opts is None: + return + img, args, kwds = self.opts + kwds['useRGBA'] = True + self.image, alpha = fn.makeARGB(img, *args, **kwds) + + if not self.uploaded: + self.uploadTexture() + + glViewport(0, 0, self.width(), self.height()) + glEnable(GL_TEXTURE_2D) + glBindTexture(GL_TEXTURE_2D, self.texture) + glColor4f(1,1,1,1) + + glBegin(GL_QUADS) + glTexCoord2f(0,0) + glVertex3f(-1,-1,0) + glTexCoord2f(1,0) + glVertex3f(1, -1, 0) + glTexCoord2f(1,1) + glVertex3f(1, 1, 0) + glTexCoord2f(0,1) + glVertex3f(-1, 1, 0) + glEnd() + glDisable(GL_TEXTURE_3D) + diff --git a/widgets/RemoteGraphicsView.py b/widgets/RemoteGraphicsView.py index cb36ba62..d44fd1c3 100644 --- a/widgets/RemoteGraphicsView.py +++ b/widgets/RemoteGraphicsView.py @@ -1,4 +1,6 @@ from pyqtgraph.Qt import QtGui, QtCore, USE_PYSIDE +if not USE_PYSIDE: + import sip import pyqtgraph.multiprocess as mp import pyqtgraph as pg from .GraphicsView import GraphicsView @@ -16,16 +18,27 @@ class RemoteGraphicsView(QtGui.QWidget): """ def __init__(self, parent=None, *args, **kwds): + """ + The keyword arguments 'useOpenGL' and 'backgound', if specified, are passed to the remote + GraphicsView.__init__(). All other keyword arguments are passed to multiprocess.QtProcess.__init__(). + """ self._img = None self._imgReq = None self._sizeHint = (640,480) ## no clue why this is needed, but it seems to be the default sizeHint for GraphicsView. ## without it, the widget will not compete for space against another GraphicsView. QtGui.QWidget.__init__(self) - self._proc = mp.QtProcess(debug=False) + + # separate local keyword arguments from remote. + remoteKwds = {} + for kwd in ['useOpenGL', 'background']: + if kwd in kwds: + remoteKwds[kwd] = kwds.pop(kwd) + + self._proc = mp.QtProcess(**kwds) self.pg = self._proc._import('pyqtgraph') self.pg.setConfigOptions(**self.pg.CONFIG_OPTIONS) rpgRemote = self._proc._import('pyqtgraph.widgets.RemoteGraphicsView') - self._view = rpgRemote.Renderer(*args, **kwds) + self._view = rpgRemote.Renderer(*args, **remoteKwds) self._view._setProxyOptions(deferGetattr=True) self.setFocusPolicy(QtCore.Qt.StrongFocus) @@ -67,7 +80,9 @@ class RemoteGraphicsView(QtGui.QWidget): else: self.shm = mmap.mmap(self.shmFile.fileno(), size, mmap.MAP_SHARED, mmap.PROT_READ) self.shm.seek(0) - self._img = QtGui.QImage(self.shm.read(w*h*4), w, h, QtGui.QImage.Format_ARGB32) + data = self.shm.read(w*h*4) + self._img = QtGui.QImage(data, w, h, QtGui.QImage.Format_ARGB32) + self._img.data = data # data must be kept alive or PySide 1.2.1 (and probably earlier) will crash. self.update() def paintEvent(self, ev): @@ -78,17 +93,17 @@ class RemoteGraphicsView(QtGui.QWidget): p.end() def mousePressEvent(self, ev): - self._view.mousePressEvent(ev.type(), ev.pos(), ev.globalPos(), ev.button(), int(ev.buttons()), int(ev.modifiers()), _callSync='off') + self._view.mousePressEvent(int(ev.type()), ev.pos(), ev.globalPos(), int(ev.button()), int(ev.buttons()), int(ev.modifiers()), _callSync='off') ev.accept() return QtGui.QWidget.mousePressEvent(self, ev) def mouseReleaseEvent(self, ev): - self._view.mouseReleaseEvent(ev.type(), ev.pos(), ev.globalPos(), ev.button(), int(ev.buttons()), int(ev.modifiers()), _callSync='off') + self._view.mouseReleaseEvent(int(ev.type()), ev.pos(), ev.globalPos(), int(ev.button()), int(ev.buttons()), int(ev.modifiers()), _callSync='off') ev.accept() return QtGui.QWidget.mouseReleaseEvent(self, ev) def mouseMoveEvent(self, ev): - self._view.mouseMoveEvent(ev.type(), ev.pos(), ev.globalPos(), ev.button(), int(ev.buttons()), int(ev.modifiers()), _callSync='off') + self._view.mouseMoveEvent(int(ev.type()), ev.pos(), ev.globalPos(), int(ev.button()), int(ev.buttons()), int(ev.modifiers()), _callSync='off') ev.accept() return QtGui.QWidget.mouseMoveEvent(self, ev) @@ -98,22 +113,27 @@ class RemoteGraphicsView(QtGui.QWidget): return QtGui.QWidget.wheelEvent(self, ev) def keyEvent(self, ev): - if self._view.keyEvent(ev.type(), int(ev.modifiers()), text, autorep, count): + if self._view.keyEvent(int(ev.type()), int(ev.modifiers()), text, autorep, count): ev.accept() return QtGui.QWidget.keyEvent(self, ev) def enterEvent(self, ev): - self._view.enterEvent(ev.type(), _callSync='off') + self._view.enterEvent(int(ev.type()), _callSync='off') return QtGui.QWidget.enterEvent(self, ev) def leaveEvent(self, ev): - self._view.leaveEvent(ev.type(), _callSync='off') + self._view.leaveEvent(int(ev.type()), _callSync='off') return QtGui.QWidget.leaveEvent(self, ev) def remoteProcess(self): """Return the remote process handle. (see multiprocess.remoteproxy.RemoteEventHandler)""" return self._proc - + + def close(self): + """Close the remote process. After this call, the widget will no longer be updated.""" + self._proc.close() + + class Renderer(GraphicsView): ## Created by the remote process to handle render requests @@ -121,12 +141,13 @@ class Renderer(GraphicsView): def __init__(self, *args, **kwds): ## Create shared memory for rendered image + #pg.dbg(namespace={'r': self}) if sys.platform.startswith('win'): self.shmtag = "pyqtgraph_shmem_" + ''.join([chr((random.getrandbits(20)%25) + 97) for i in range(20)]) self.shm = mmap.mmap(-1, mmap.PAGESIZE, self.shmtag) # use anonymous mmap on windows else: self.shmFile = tempfile.NamedTemporaryFile(prefix='pyqtgraph_shmem_') - self.shmFile.write('\x00' * mmap.PAGESIZE) + self.shmFile.write(b'\x00' * (mmap.PAGESIZE+1)) fd = self.shmFile.fileno() self.shm = mmap.mmap(fd, mmap.PAGESIZE, mmap.MAP_SHARED, mmap.PROT_WRITE) atexit.register(self.close) @@ -140,9 +161,9 @@ class Renderer(GraphicsView): def close(self): self.shm.close() - if sys.platform.startswith('win'): + if not sys.platform.startswith('win'): self.shmFile.close() - + def shmFileName(self): if sys.platform.startswith('win'): return self.shmtag @@ -174,7 +195,6 @@ class Renderer(GraphicsView): self.shm = mmap.mmap(-1, size, self.shmtag) else: self.shm.resize(size) - address = ctypes.addressof(ctypes.c_char.from_buffer(self.shm, 0)) ## render the scene directly to shared memory if USE_PYSIDE: @@ -182,7 +202,17 @@ class Renderer(GraphicsView): #ch = ctypes.c_char_p(address) self.img = QtGui.QImage(ch, self.width(), self.height(), QtGui.QImage.Format_ARGB32) else: - self.img = QtGui.QImage(address, self.width(), self.height(), QtGui.QImage.Format_ARGB32) + address = ctypes.addressof(ctypes.c_char.from_buffer(self.shm, 0)) + + # different versions of pyqt have different requirements here.. + try: + self.img = QtGui.QImage(sip.voidptr(address), self.width(), self.height(), QtGui.QImage.Format_ARGB32) + except TypeError: + try: + self.img = QtGui.QImage(memoryview(buffer(self.shm)), self.width(), self.height(), QtGui.QImage.Format_ARGB32) + except TypeError: + # Works on PyQt 4.9.6 + self.img = QtGui.QImage(address, self.width(), self.height(), QtGui.QImage.Format_ARGB32) self.img.fill(0xffffffff) p = QtGui.QPainter(self.img) self.render(p, self.viewRect(), self.rect()) @@ -191,18 +221,21 @@ class Renderer(GraphicsView): def mousePressEvent(self, typ, pos, gpos, btn, btns, mods): typ = QtCore.QEvent.Type(typ) + btn = QtCore.Qt.MouseButton(btn) btns = QtCore.Qt.MouseButtons(btns) mods = QtCore.Qt.KeyboardModifiers(mods) return GraphicsView.mousePressEvent(self, QtGui.QMouseEvent(typ, pos, gpos, btn, btns, mods)) def mouseMoveEvent(self, typ, pos, gpos, btn, btns, mods): typ = QtCore.QEvent.Type(typ) + btn = QtCore.Qt.MouseButton(btn) btns = QtCore.Qt.MouseButtons(btns) mods = QtCore.Qt.KeyboardModifiers(mods) return GraphicsView.mouseMoveEvent(self, QtGui.QMouseEvent(typ, pos, gpos, btn, btns, mods)) def mouseReleaseEvent(self, typ, pos, gpos, btn, btns, mods): typ = QtCore.QEvent.Type(typ) + btn = QtCore.Qt.MouseButton(btn) btns = QtCore.Qt.MouseButtons(btns) mods = QtCore.Qt.KeyboardModifiers(mods) return GraphicsView.mouseReleaseEvent(self, QtGui.QMouseEvent(typ, pos, gpos, btn, btns, mods)) @@ -226,6 +259,3 @@ class Renderer(GraphicsView): ev = QtCore.QEvent(QtCore.QEvent.Type(typ)) return GraphicsView.leaveEvent(self, ev) - - - diff --git a/widgets/ScatterPlotWidget.py b/widgets/ScatterPlotWidget.py index 2e1c1918..e9e24dd7 100644 --- a/widgets/ScatterPlotWidget.py +++ b/widgets/ScatterPlotWidget.py @@ -6,6 +6,7 @@ import pyqtgraph.parametertree as ptree import pyqtgraph.functions as fn import numpy as np from pyqtgraph.pgcollections import OrderedDict +import pyqtgraph as pg __all__ = ['ScatterPlotWidget'] @@ -47,14 +48,22 @@ class ScatterPlotWidget(QtGui.QSplitter): self.ctrlPanel.addWidget(self.ptree) self.addWidget(self.plot) + bg = pg.mkColor(pg.getConfigOption('background')) + bg.setAlpha(150) + self.filterText = pg.TextItem(border=pg.getConfigOption('foreground'), color=bg) + self.filterText.setPos(60,20) + self.filterText.setParentItem(self.plot.plotItem) + self.data = None + self.mouseOverField = None + self.scatterPlot = None self.style = dict(pen=None, symbol='o') self.fieldList.itemSelectionChanged.connect(self.fieldSelectionChanged) self.filter.sigFilterChanged.connect(self.filterChanged) self.colorMap.sigColorMapChanged.connect(self.updatePlot) - def setFields(self, fields): + def setFields(self, fields, mouseOverField=None): """ Set the list of field names/units to be processed. @@ -62,6 +71,7 @@ class ScatterPlotWidget(QtGui.QSplitter): :func:`ColorMapWidget.setFields ` """ self.fields = OrderedDict(fields) + self.mouseOverField = mouseOverField self.fieldList.clear() for f,opts in fields: item = QtGui.QListWidgetItem(f) @@ -94,6 +104,13 @@ class ScatterPlotWidget(QtGui.QSplitter): def filterChanged(self, f): self.filtered = None self.updatePlot() + desc = self.filter.describe() + if len(desc) == 0: + self.filterText.setVisible(False) + else: + self.filterText.setText('\n'.join(desc)) + self.filterText.setVisible(True) + def updatePlot(self): self.plot.clear() @@ -122,64 +139,78 @@ class ScatterPlotWidget(QtGui.QSplitter): self.plot.setLabels(left=('N', ''), bottom=(sel[0], units[0]), title='') if len(data) == 0: return - x = data[sel[0]] - #if x.dtype.kind == 'f': - #mask = ~np.isnan(x) - #else: - #mask = np.ones(len(x), dtype=bool) - #x = x[mask] - #style['symbolBrush'] = colors[mask] - y = None + #x = data[sel[0]] + #y = None + xy = [data[sel[0]], None] elif len(sel) == 2: self.plot.setLabels(left=(sel[1],units[1]), bottom=(sel[0],units[0])) if len(data) == 0: return - xydata = [] - for ax in [0,1]: - d = data[sel[ax]] - ## scatter catecorical values just a bit so they show up better in the scatter plot. - #if sel[ax] in ['MorphologyBSMean', 'MorphologyTDMean', 'FIType']: - #d += np.random.normal(size=len(cells), scale=0.1) - xydata.append(d) - x,y = xydata - #mask = np.ones(len(x), dtype=bool) - #if x.dtype.kind == 'f': - #mask |= ~np.isnan(x) - #if y.dtype.kind == 'f': - #mask |= ~np.isnan(y) - #x = x[mask] - #y = y[mask] - #style['symbolBrush'] = colors[mask] + xy = [data[sel[0]], data[sel[1]]] + #xydata = [] + #for ax in [0,1]: + #d = data[sel[ax]] + ### scatter catecorical values just a bit so they show up better in the scatter plot. + ##if sel[ax] in ['MorphologyBSMean', 'MorphologyTDMean', 'FIType']: + ##d += np.random.normal(size=len(cells), scale=0.1) + + #xydata.append(d) + #x,y = xydata ## convert enum-type fields to float, set axis labels - xy = [x,y] + enum = [False, False] for i in [0,1]: axis = self.plot.getAxis(['bottom', 'left'][i]) - if xy[i] is not None and xy[i].dtype.kind in ('S', 'O'): + if xy[i] is not None and (self.fields[sel[i]].get('mode', None) == 'enum' or xy[i].dtype.kind in ('S', 'O')): vals = self.fields[sel[i]].get('values', list(set(xy[i]))) - xy[i] = np.array([vals.index(x) if x in vals else None for x in xy[i]], dtype=float) + xy[i] = np.array([vals.index(x) if x in vals else len(vals) for x in xy[i]], dtype=float) axis.setTicks([list(enumerate(vals))]) + enum[i] = True else: axis.setTicks(None) # reset to automatic ticking - x,y = xy ## mask out any nan values - mask = np.ones(len(x), dtype=bool) - if x.dtype.kind == 'f': - mask &= ~np.isnan(x) - if y is not None and y.dtype.kind == 'f': - mask &= ~np.isnan(y) - x = x[mask] + mask = np.ones(len(xy[0]), dtype=bool) + if xy[0].dtype.kind == 'f': + mask &= ~np.isnan(xy[0]) + if xy[1] is not None and xy[1].dtype.kind == 'f': + mask &= ~np.isnan(xy[1]) + + xy[0] = xy[0][mask] style['symbolBrush'] = colors[mask] ## Scatter y-values for a histogram-like appearance - if y is None: - y = fn.pseudoScatter(x) + if xy[1] is None: + ## column scatter plot + xy[1] = fn.pseudoScatter(xy[0]) else: - y = y[mask] - - - self.plot.plot(x, y, **style) + ## beeswarm plots + xy[1] = xy[1][mask] + for ax in [0,1]: + if not enum[ax]: + continue + imax = int(xy[ax].max()) if len(xy[ax]) > 0 else 0 + for i in range(imax+1): + keymask = xy[ax] == i + scatter = pg.pseudoScatter(xy[1-ax][keymask], bidir=True) + if len(scatter) == 0: + continue + smax = np.abs(scatter).max() + if smax != 0: + scatter *= 0.2 / smax + xy[ax][keymask] += scatter + + if self.scatterPlot is not None: + try: + self.scatterPlot.sigPointsClicked.disconnect(self.plotClicked) + except: + pass + self.scatterPlot = self.plot.plot(xy[0], xy[1], data=data[mask], **style) + self.scatterPlot.sigPointsClicked.connect(self.plotClicked) + def plotClicked(self, plot, points): + pass + + diff --git a/widgets/SpinBox.py b/widgets/SpinBox.py index 71695f4a..57e4f1ed 100644 --- a/widgets/SpinBox.py +++ b/widgets/SpinBox.py @@ -313,7 +313,7 @@ class SpinBox(QtGui.QAbstractSpinBox): s = [D(-1), D(1)][n >= 0] ## determine sign of step val = self.val - for i in range(abs(n)): + for i in range(int(abs(n))): if self.opts['log']: raise Exception("Log mode no longer supported.") diff --git a/widgets/TableWidget.py b/widgets/TableWidget.py index dc4f875b..8ffe7291 100644 --- a/widgets/TableWidget.py +++ b/widgets/TableWidget.py @@ -6,27 +6,26 @@ import numpy as np try: import metaarray HAVE_METAARRAY = True -except: +except ImportError: HAVE_METAARRAY = False __all__ = ['TableWidget'] class TableWidget(QtGui.QTableWidget): """Extends QTableWidget with some useful functions for automatic data handling - and copy / export context menu. Can automatically format and display: - - - numpy arrays - - numpy record arrays - - metaarrays - - list-of-lists [[1,2,3], [4,5,6]] - - dict-of-lists {'x': [1,2,3], 'y': [4,5,6]} - - list-of-dicts [{'x': 1, 'y': 4}, {'x': 2, 'y': 5}, ...] + and copy / export context menu. Can automatically format and display a variety + of data types (see :func:`setData() ` for more + information. """ - def __init__(self, *args): + def __init__(self, *args, **kwds): QtGui.QTableWidget.__init__(self, *args) self.setVerticalScrollMode(self.ScrollPerPixel) self.setSelectionMode(QtGui.QAbstractItemView.ContiguousSelection) + self.setSizePolicy(QtGui.QSizePolicy.Preferred, QtGui.QSizePolicy.Preferred) + self.setSortingEnabled(True) self.clear() + editable = kwds.get('editable', False) + self.setEditable(editable) self.contextMenu = QtGui.QMenu() self.contextMenu.addAction('Copy Selection').triggered.connect(self.copySel) self.contextMenu.addAction('Copy All').triggered.connect(self.copyAll) @@ -34,6 +33,7 @@ class TableWidget(QtGui.QTableWidget): self.contextMenu.addAction('Save All').triggered.connect(self.saveAll) def clear(self): + """Clear all contents from the table.""" QtGui.QTableWidget.clear(self) self.verticalHeadersSet = False self.horizontalHeadersSet = False @@ -42,8 +42,19 @@ class TableWidget(QtGui.QTableWidget): self.setColumnCount(0) def setData(self, data): + """Set the data displayed in the table. + Allowed formats are: + + * numpy arrays + * numpy record arrays + * metaarrays + * list-of-lists [[1,2,3], [4,5,6]] + * dict-of-lists {'x': [1,2,3], 'y': [4,5,6]} + * list-of-dicts [{'x': 1, 'y': 4}, {'x': 2, 'y': 5}, ...] + """ self.clear() self.appendData(data) + self.resizeColumnsToContents() def appendData(self, data): """Types allowed: @@ -60,26 +71,19 @@ class TableWidget(QtGui.QTableWidget): first = next(it0) except StopIteration: return - #if type(first) == type(np.float64(1)): - # return fn1, header1 = self.iteratorFn(first) if fn1 is None: self.clear() return - #print fn0, header0 - #print fn1, header1 firstVals = [x for x in fn1(first)] self.setColumnCount(len(firstVals)) - #print header0, header1 if not self.verticalHeadersSet and header0 is not None: - #print "set header 0:", header0 self.setRowCount(len(header0)) self.setVerticalHeaderLabels(header0) self.verticalHeadersSet = True if not self.horizontalHeadersSet and header1 is not None: - #print "set header 1:", header1 self.setHorizontalHeaderLabels(header1) self.horizontalHeadersSet = True @@ -88,10 +92,15 @@ class TableWidget(QtGui.QTableWidget): for row in it0: self.setRow(i, [x for x in fn1(row)]) i += 1 + + def setEditable(self, editable=True): + self.editable = editable + for item in self.items: + item.setEditable(editable) def iteratorFn(self, data): - """Return 1) a function that will provide an iterator for data and 2) a list of header strings""" - if isinstance(data, list): + ## Return 1) a function that will provide an iterator for data and 2) a list of header strings + if isinstance(data, list) or isinstance(data, tuple): return lambda d: d.__iter__(), None elif isinstance(data, dict): return lambda d: iter(d.values()), list(map(str, data.keys())) @@ -110,13 +119,16 @@ class TableWidget(QtGui.QTableWidget): elif data is None: return (None,None) else: - raise Exception("Don't know how to iterate over data type: %s" % str(type(data))) + msg = "Don't know how to iterate over data type: {!s}".format(type(data)) + raise TypeError(msg) def iterFirstAxis(self, data): for i in range(data.shape[0]): yield data[i] - def iterate(self, data): ## for numpy.void, which can be iterated but mysteriously has no __iter__ (??) + def iterate(self, data): + # for numpy.void, which can be iterated but mysteriously + # has no __iter__ (??) for x in data: yield x @@ -124,32 +136,39 @@ class TableWidget(QtGui.QTableWidget): self.appendData([data]) def addRow(self, vals): - #print "add row:", vals row = self.rowCount() - self.setRowCount(row+1) + self.setRowCount(row + 1) self.setRow(row, vals) def setRow(self, row, vals): - if row > self.rowCount()-1: - self.setRowCount(row+1) - for col in range(self.columnCount()): + if row > self.rowCount() - 1: + self.setRowCount(row + 1) + for col in range(len(vals)): val = vals[col] - if isinstance(val, float) or isinstance(val, np.floating): - s = "%0.3g" % val - else: - s = str(val) - item = QtGui.QTableWidgetItem(s) - item.value = val - #print "add item to row %d:"%row, item, item.value + item = TableWidgetItem(val) + item.setEditable(self.editable) self.items.append(item) self.setItem(row, col, item) - + + def sizeHint(self): + # based on http://stackoverflow.com/a/7195443/54056 + width = sum(self.columnWidth(i) for i in range(self.columnCount())) + width += self.verticalHeader().sizeHint().width() + width += self.verticalScrollBar().sizeHint().width() + width += self.frameWidth() * 2 + height = sum(self.rowHeight(i) for i in range(self.rowCount())) + height += self.verticalHeader().sizeHint().height() + height += self.horizontalScrollBar().sizeHint().height() + return QtCore.QSize(width, height) + def serialize(self, useSelection=False): """Convert entire table (or just selected area) into tab-separated text values""" if useSelection: selection = self.selectedRanges()[0] - rows = list(range(selection.topRow(), selection.bottomRow()+1)) - columns = list(range(selection.leftColumn(), selection.rightColumn()+1)) + rows = list(range(selection.topRow(), + selection.bottomRow() + 1)) + columns = list(range(selection.leftColumn(), + selection.rightColumn() + 1)) else: rows = list(range(self.rowCount())) columns = list(range(self.columnCount())) @@ -215,6 +234,28 @@ class TableWidget(QtGui.QTableWidget): else: ev.ignore() +class TableWidgetItem(QtGui.QTableWidgetItem): + def __init__(self, val): + if isinstance(val, float) or isinstance(val, np.floating): + s = "%0.3g" % val + else: + s = str(val) + QtGui.QTableWidgetItem.__init__(self, s) + self.value = val + flags = QtCore.Qt.ItemIsSelectable | QtCore.Qt.ItemIsEnabled + self.setFlags(flags) + + def setEditable(self, editable): + if editable: + self.setFlags(self.flags() | QtCore.Qt.ItemIsEditable) + else: + self.setFlags(self.flags() & ~QtCore.Qt.ItemIsEditable) + + def __lt__(self, other): + if hasattr(other, 'value'): + return self.value < other.value + else: + return self.text() < other.text() if __name__ == '__main__':