diff --git a/doc/source/functions.rst b/doc/source/functions.rst index 966fd926..556c5be0 100644 --- a/doc/source/functions.rst +++ b/doc/source/functions.rst @@ -97,6 +97,6 @@ Miscellaneous Functions .. autofunction:: pyqtgraph.systemInfo - +.. autofunction:: pyqtgraph.exit diff --git a/examples/GraphItem.py b/examples/GraphItem.py index effa6b0b..c6362295 100644 --- a/examples/GraphItem.py +++ b/examples/GraphItem.py @@ -10,6 +10,9 @@ import pyqtgraph as pg from pyqtgraph.Qt import QtCore, QtGui import numpy as np +# Enable antialiasing for prettier plots +pg.setConfigOptions(antialias=True) + w = pg.GraphicsWindow() w.setWindowTitle('pyqtgraph example: GraphItem') v = w.addViewBox() diff --git a/examples/Plotting.py b/examples/Plotting.py index 6a3a1d11..6578fb2b 100644 --- a/examples/Plotting.py +++ b/examples/Plotting.py @@ -21,7 +21,8 @@ win = pg.GraphicsWindow(title="Basic plotting examples") win.resize(1000,600) win.setWindowTitle('pyqtgraph example: Plotting') - +# Enable antialiasing for prettier plots +pg.setConfigOptions(antialias=True) p1 = win.addPlot(title="Basic array plotting", y=np.random.normal(size=100)) diff --git a/examples/ScaleBar.py b/examples/ScaleBar.py new file mode 100644 index 00000000..5f9675e4 --- /dev/null +++ b/examples/ScaleBar.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- +""" +Demonstrates ScaleBar +""" +import initExample ## Add path to library (just for examples; you do not need this) + +import pyqtgraph as pg +from pyqtgraph.Qt import QtCore, QtGui +import numpy as np + +pg.mkQApp() +win = pg.GraphicsWindow() +win.setWindowTitle('pyqtgraph example: ScaleBar') + +vb = win.addViewBox() +vb.setAspectLocked() + +img = pg.ImageItem() +img.setImage(np.random.normal(size=(100,100))) +img.scale(0.01, 0.01) +vb.addItem(img) + +scale = pg.ScaleBar(size=0.1) +scale.setParentItem(vb) +scale.anchor((1, 1), (1, 1), offset=(-20, -20)) + +## Start Qt event loop unless running in interactive mode or using pyside. +if __name__ == '__main__': + import sys + if (sys.flags.interactive != 1) or not hasattr(QtCore, 'PYQT_VERSION'): + QtGui.QApplication.instance().exec_() diff --git a/examples/ScatterPlotWidget.py b/examples/ScatterPlotWidget.py new file mode 100644 index 00000000..563667bd --- /dev/null +++ b/examples/ScatterPlotWidget.py @@ -0,0 +1,52 @@ +# -*- coding: utf-8 -*- +import initExample ## Add path to library (just for examples; you do not need this) + +import pyqtgraph as pg +from pyqtgraph.Qt import QtCore, QtGui +import numpy as np + +pg.mkQApp() + +spw = pg.ScatterPlotWidget() +spw.show() + +data = np.array([ + (1, 1, 3, 4, 'x'), + (2, 3, 3, 7, 'y'), + (3, 2, 5, 2, 'z'), + (4, 4, 6, 9, 'z'), + (5, 3, 6, 7, 'x'), + (6, 5, 4, 6, 'x'), + (7, 5, 8, 2, 'z'), + (8, 1, 2, 4, 'x'), + (9, 2, 3, 7, 'z'), + (0, 6, 0, 2, 'z'), + (1, 3, 1, 2, 'z'), + (2, 5, 4, 6, 'y'), + (3, 4, 8, 1, 'y'), + (4, 7, 6, 8, 'z'), + (5, 8, 7, 4, 'y'), + (6, 1, 2, 3, 'y'), + (7, 5, 3, 9, 'z'), + (8, 9, 3, 1, 'x'), + (9, 2, 6, 2, 'z'), + (0, 3, 4, 6, 'x'), + (1, 5, 9, 3, 'y'), + ], dtype=[('col1', float), ('col2', float), ('col3', int), ('col4', int), ('col5', 'S10')]) + +spw.setFields([ + ('col1', {'units': 'm'}), + ('col2', {'units': 'm'}), + ('col3', {}), + ('col4', {}), + ('col5', {'mode': 'enum', 'values': ['x', 'y', 'z']}), + ]) + +spw.setData(data) + + +## Start Qt event loop unless running in interactive mode or using pyside. +if __name__ == '__main__': + import sys + if (sys.flags.interactive != 1) or not hasattr(QtCore, 'PYQT_VERSION'): + QtGui.QApplication.instance().exec_() diff --git a/examples/SimplePlot.py b/examples/SimplePlot.py index ec40cf16..f572743a 100644 --- a/examples/SimplePlot.py +++ b/examples/SimplePlot.py @@ -3,9 +3,12 @@ import initExample ## Add path to library (just for examples; you do not need th from pyqtgraph.Qt import QtGui, QtCore import pyqtgraph as pg import numpy as np -pg.plot(np.random.normal(size=100000), title="Simplest possible plotting example") - +plt = pg.plot(np.random.normal(size=100), title="Simplest possible plotting example") +plt.getAxis('bottom').setTicks([[(x*20, str(x*20)) for x in range(6)]]) ## Start Qt event loop unless running in interactive mode or using pyside. +ex = pg.exporters.SVGExporter.SVGExporter(plt.plotItem.scene()) +ex.export('/home/luke/tmp/test.svg') + if __name__ == '__main__': import sys if sys.flags.interactive != 1 or not hasattr(QtCore, 'PYQT_VERSION'): diff --git a/examples/beeswarm.py b/examples/beeswarm.py new file mode 100644 index 00000000..48ee4236 --- /dev/null +++ b/examples/beeswarm.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- +""" +Example beeswarm / bar chart +""" +import initExample ## Add path to library (just for examples; you do not need this) + +import pyqtgraph as pg +from pyqtgraph.Qt import QtCore, QtGui +import numpy as np + +win = pg.plot() +win.setWindowTitle('pyqtgraph example: beeswarm') + +data = np.random.normal(size=(4,20)) +data[0] += 5 +data[1] += 7 +data[2] += 5 +data[3] = 10 + data[3] * 2 + +## Make bar graph +#bar = pg.BarGraphItem(x=range(4), height=data.mean(axis=1), width=0.5, brush=0.4) +#win.addItem(bar) + +## add scatter plots on top +for i in range(4): + xvals = pg.pseudoScatter(data[i], spacing=0.4, bidir=True) * 0.2 + win.plot(x=xvals+i, y=data[i], pen=None, symbol='o', symbolBrush=pg.intColor(i,6,maxValue=128)) + +## Make error bars +err = pg.ErrorBarItem(x=np.arange(4), y=data.mean(axis=1), height=data.std(axis=1), beam=0.5, pen={'color':'w', 'width':2}) +win.addItem(err) + + +## Start Qt event loop unless running in interactive mode or using pyside. +if __name__ == '__main__': + import sys + if (sys.flags.interactive != 1) or not hasattr(QtCore, 'PYQT_VERSION'): + QtGui.QApplication.instance().exec_() diff --git a/pyqtgraph/__init__.py b/pyqtgraph/__init__.py index ed9e3357..67eb712e 100644 --- a/pyqtgraph/__init__.py +++ b/pyqtgraph/__init__.py @@ -54,6 +54,7 @@ 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) } @@ -191,9 +192,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: @@ -213,6 +225,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(os.EX_OK) + + ## Convenience functions for command-line use diff --git a/pyqtgraph/console/Console.py b/pyqtgraph/console/Console.py index 6fbe44a7..982c2424 100644 --- a/pyqtgraph/console/Console.py +++ b/pyqtgraph/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/pyqtgraph/exporters/SVGExporter.py b/pyqtgraph/exporters/SVGExporter.py index b284db89..672897ab 100644 --- a/pyqtgraph/exporters/SVGExporter.py +++ b/pyqtgraph/exporters/SVGExporter.py @@ -304,7 +304,36 @@ 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) @@ -374,7 +403,6 @@ def correctCoordinates(node, item): if removeTransform: grp.removeAttribute('transform') - def itemTransform(item, root): ## Return the transformation mapping item to root diff --git a/pyqtgraph/flowchart/Terminal.py b/pyqtgraph/flowchart/Terminal.py index 3066223d..45805cd8 100644 --- a/pyqtgraph/flowchart/Terminal.py +++ b/pyqtgraph/flowchart/Terminal.py @@ -523,6 +523,15 @@ class ConnectionItem(GraphicsObject): 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() @@ -537,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): @@ -547,19 +563,20 @@ class ConnectionItem(GraphicsObject): return self.prepareGeometryChange() - self.path = QtGui.QPainterPath() - self.path.moveTo(start) - self.path.cubicTo(Point(stop.x(), start.y()), Point(start.x(), stop.y()), Point(stop.x(), stop.y())) + self.path = self.generatePath(start, stop) self.shapePath = None - #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.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: @@ -609,12 +626,12 @@ class ConnectionItem(GraphicsObject): 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) diff --git a/pyqtgraph/functions.py b/pyqtgraph/functions.py index 84a5c573..5f820a9a 100644 --- a/pyqtgraph/functions.py +++ b/pyqtgraph/functions.py @@ -1930,9 +1930,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). @@ -1959,23 +1959,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/pyqtgraph/graphicsItems/BarGraphItem.py b/pyqtgraph/graphicsItems/BarGraphItem.py new file mode 100644 index 00000000..0527e9f1 --- /dev/null +++ b/pyqtgraph/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/pyqtgraph/graphicsItems/GraphItem.py b/pyqtgraph/graphicsItems/GraphItem.py index be6138ce..79f8804a 100644 --- a/pyqtgraph/graphicsItems/GraphItem.py +++ b/pyqtgraph/graphicsItems/GraphItem.py @@ -103,6 +103,8 @@ 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): diff --git a/pyqtgraph/graphicsItems/GraphicsItem.py b/pyqtgraph/graphicsItems/GraphicsItem.py index 3a63afa7..40ff6bc5 100644 --- a/pyqtgraph/graphicsItems/GraphicsItem.py +++ b/pyqtgraph/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: diff --git a/pyqtgraph/graphicsItems/GraphicsObject.py b/pyqtgraph/graphicsItems/GraphicsObject.py index 121a67ea..e4c5cd81 100644 --- a/pyqtgraph/graphicsItems/GraphicsObject.py +++ b/pyqtgraph/graphicsItems/GraphicsObject.py @@ -19,7 +19,7 @@ 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() + self.parentChanged() if change in [self.ItemPositionHasChanged, self.ItemTransformHasChanged]: self.informViewBoundsChanged() diff --git a/pyqtgraph/graphicsItems/GraphicsWidgetAnchor.py b/pyqtgraph/graphicsItems/GraphicsWidgetAnchor.py index 9770b661..3174e6e0 100644 --- a/pyqtgraph/graphicsItems/GraphicsWidgetAnchor.py +++ b/pyqtgraph/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. """ diff --git a/pyqtgraph/graphicsItems/LabelItem.py b/pyqtgraph/graphicsItems/LabelItem.py index 17301fb3..6101c4bc 100644 --- a/pyqtgraph/graphicsItems/LabelItem.py +++ b/pyqtgraph/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/pyqtgraph/graphicsItems/PlotDataItem.py b/pyqtgraph/graphicsItems/PlotDataItem.py index c0d5f2f3..76b74359 100644 --- a/pyqtgraph/graphicsItems/PlotDataItem.py +++ b/pyqtgraph/graphicsItems/PlotDataItem.py @@ -84,13 +84,24 @@ 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. + decimate (int) Sub-sample data by selecting every nth sample before plotting + onlyVisible (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. + autoResample (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. + sampleRate (float) The sample rate of the data along the X axis (for data with + a fixed sample rate). Providing this value improves performance of + the *onlyVisible* and *autoResample* options. identical *deprecated* - decimate (int) sub-sample data by selecting every nth sample before plotting - ========== ===================================================================== + ============ ===================================================================== **Meta-info keyword arguments:** diff --git a/pyqtgraph/graphicsItems/ScaleBar.py b/pyqtgraph/graphicsItems/ScaleBar.py index 961f07d7..768f6978 100644 --- a/pyqtgraph/graphicsItems/ScaleBar.py +++ b/pyqtgraph/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/pyqtgraph/graphicsItems/ScatterPlotItem.py b/pyqtgraph/graphicsItems/ScatterPlotItem.py index 84c05478..29bfeaac 100644 --- a/pyqtgraph/graphicsItems/ScatterPlotItem.py +++ b/pyqtgraph/graphicsItems/ScatterPlotItem.py @@ -677,15 +677,12 @@ 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] pos = QtCore.QPointF(pts[0,i], pts[1,i]) x,y,w,h = rec['fragCoords'] - if abs(w) > 10000 or abs(h) > 10000: - print self.data - raise Exception("fragment corrupt") rect = QtCore.QRectF(y, x, h, w) self.fragments.append(QtGui.QPainter.PixmapFragment.create(pos, rect)) @@ -743,6 +740,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/pyqtgraph/widgets/ColorMapWidget.py b/pyqtgraph/widgets/ColorMapWidget.py index 619d639a..26539d7e 100644 --- a/pyqtgraph/widgets/ColorMapWidget.py +++ b/pyqtgraph/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/pyqtgraph/widgets/DataFilterWidget.py b/pyqtgraph/widgets/DataFilterWidget.py index a2e1a7b8..c94f6c68 100644 --- a/pyqtgraph/widgets/DataFilterWidget.py +++ b/pyqtgraph/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/pyqtgraph/widgets/ScatterPlotWidget.py b/pyqtgraph/widgets/ScatterPlotWidget.py index 2e1c1918..fe785e04 100644 --- a/pyqtgraph/widgets/ScatterPlotWidget.py +++ b/pyqtgraph/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,73 @@ 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 + for i in range(int(xy[ax].max())+1): + keymask = xy[ax] == i + scatter = pg.pseudoScatter(xy[1-ax][keymask], bidir=True) + scatter *= 0.2 / np.abs(scatter).max() + 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 + +