diff --git a/dockarea/Dock.py b/dockarea/Dock.py index 63fafca8..35781535 100644 --- a/dockarea/Dock.py +++ b/dockarea/Dock.py @@ -77,6 +77,11 @@ class Dock(QtGui.QWidget, DockDrop): return name == 'dock' def setStretch(self, x=None, y=None): + """ + Set the 'target' size for this Dock. + The actual size will be determined by comparing this Dock's + stretch value to the rest of the docks it shares space with. + """ #print "setStretch", self, x, y #self._stretch = (x, y) if x is None: @@ -100,6 +105,10 @@ class Dock(QtGui.QWidget, DockDrop): #return self._stretch def hideTitleBar(self): + """ + Hide the title bar for this Dock. + This will prevent the Dock being moved by the user. + """ self.label.hide() self.labelHidden = True if 'center' in self.allowedAreas: @@ -107,12 +116,21 @@ class Dock(QtGui.QWidget, DockDrop): self.updateStyle() def showTitleBar(self): + """ + Show the title bar for this Dock. + """ self.label.show() self.labelHidden = False self.allowedAreas.add('center') self.updateStyle() def setOrientation(self, o='auto', force=False): + """ + Sets the orientation of the title bar for this Dock. + Must be one of 'auto', 'horizontal', or 'vertical'. + By default ('auto'), the orientation is determined + based on the aspect ratio of the Dock. + """ #print self.name(), "setOrientation", o, force if o == 'auto' and self.autoOrient: if self.container().type() == 'tab': @@ -127,6 +145,7 @@ class Dock(QtGui.QWidget, DockDrop): self.updateStyle() def updateStyle(self): + ## updates orientation and appearance of title bar #print self.name(), "update style:", self.orientation, self.moveLabel, self.label.isVisible() if self.labelHidden: self.widgetArea.setStyleSheet(self.nStyle) @@ -154,6 +173,10 @@ class Dock(QtGui.QWidget, DockDrop): return self._container def addWidget(self, widget, row=None, col=0, rowspan=1, colspan=1): + """ + Add a new widget to the interior of this Dock. + Each Dock uses a QGridLayout to arrange widgets within. + """ if row is None: row = self.currentRow self.currentRow = max(row+1, self.currentRow) @@ -188,7 +211,8 @@ class Dock(QtGui.QWidget, DockDrop): def __repr__(self): return "" % (self.name(), self.stretch()) - + + class DockLabel(VerticalLabel): sigClicked = QtCore.Signal(object, object) @@ -287,76 +311,3 @@ class DockLabel(VerticalLabel): -#class DockLabel(QtGui.QWidget): - #def __init__(self, text, dock): - #QtGui.QWidget.__init__(self) - #self._text = text - #self.dock = dock - #self.orientation = None - #self.setOrientation('horizontal') - - #def text(self): - #return self._text - - #def mousePressEvent(self, ev): - #if ev.button() == QtCore.Qt.LeftButton: - #self.pressPos = ev.pos() - #self.startedDrag = False - #ev.accept() - - #def mouseMoveEvent(self, ev): - #if not self.startedDrag and (ev.pos() - self.pressPos).manhattanLength() > QtGui.QApplication.startDragDistance(): - #self.dock.startDrag() - #ev.accept() - ##print ev.pos() - - #def mouseReleaseEvent(self, ev): - #ev.accept() - - #def mouseDoubleClickEvent(self, ev): - #if ev.button() == QtCore.Qt.LeftButton: - #self.dock.float() - - #def setOrientation(self, o): - #if self.orientation == o: - #return - #self.orientation = o - #self.update() - #self.updateGeometry() - - #def paintEvent(self, ev): - #p = QtGui.QPainter(self) - #p.setBrush(QtGui.QBrush(QtGui.QColor(100, 100, 200))) - #p.setPen(QtGui.QPen(QtGui.QColor(50, 50, 100))) - #p.drawRect(self.rect().adjusted(0, 0, -1, -1)) - - #p.setPen(QtGui.QPen(QtGui.QColor(255, 255, 255))) - - #if self.orientation == 'vertical': - #p.rotate(-90) - #rgn = QtCore.QRect(-self.height(), 0, self.height(), self.width()) - #else: - #rgn = self.rect() - #align = QtCore.Qt.AlignTop|QtCore.Qt.AlignHCenter - - #self.hint = p.drawText(rgn, align, self.text()) - #p.end() - - #if self.orientation == 'vertical': - #self.setMaximumWidth(self.hint.height()) - #self.setMaximumHeight(16777215) - #else: - #self.setMaximumHeight(self.hint.height()) - #self.setMaximumWidth(16777215) - - #def sizeHint(self): - #if self.orientation == 'vertical': - #if hasattr(self, 'hint'): - #return QtCore.QSize(self.hint.height(), self.hint.width()) - #else: - #return QtCore.QSize(19, 50) - #else: - #if hasattr(self, 'hint'): - #return QtCore.QSize(self.hint.width(), self.hint.height()) - #else: - #return QtCore.QSize(50, 19) diff --git a/dockarea/DockArea.py b/dockarea/DockArea.py index 49dd95ff..d49f02ad 100644 --- a/dockarea/DockArea.py +++ b/dockarea/DockArea.py @@ -35,8 +35,18 @@ class DockArea(Container, QtGui.QWidget, DockDrop): def addDock(self, dock, position='bottom', relativeTo=None): """Adds a dock to this area. - position may be: bottom, top, left, right, over, under - If relativeTo specifies an existing dock, the new dock is added adjacent to it""" + + =========== ================================================================= + Arguments: + dock The new Dock object to add. + position 'bottom', 'top', 'left', 'right', 'over', or 'under' + 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'). + =========== ================================================================= + + """ ## Determine the container to insert this dock into. ## If there is no neighbor, then the container is the top. @@ -90,6 +100,17 @@ class DockArea(Container, QtGui.QWidget, DockDrop): dock.area = self self.docks[dock.name()] = dock + def moveDock(self, dock, position, neighbor): + """ + Move an existing Dock to a new location. + """ + old = dock.container() + ## Moving to the edge of a tabbed dock causes a drop outside the tab box + if position in ['left', 'right', 'top', 'bottom'] and neighbor is not None and neighbor.container() is not None and neighbor.container().type() == 'tab': + neighbor = neighbor.container() + self.addDock(dock, position, neighbor) + old.apoptose() + def getContainer(self, obj): if obj is None: return self @@ -131,13 +152,6 @@ class DockArea(Container, QtGui.QWidget, DockDrop): return 0 return 1 - def moveDock(self, dock, position, neighbor): - old = dock.container() - ## Moving to the edge of a tabbed dock causes a drop outside the tab box - if position in ['left', 'right', 'top', 'bottom'] and neighbor is not None and neighbor.container() is not None and neighbor.container().type() == 'tab': - neighbor = neighbor.container() - self.addDock(dock, position, neighbor) - old.apoptose() #def paintEvent(self, ev): #self.drawDockOverlay() @@ -159,6 +173,7 @@ class DockArea(Container, QtGui.QWidget, DockDrop): return area def floatDock(self, dock): + """Removes *dock* from this DockArea and places it in a new window.""" area = self.addTempArea() area.win.resize(dock.size()) area.moveDock(dock, 'top', None) @@ -170,6 +185,9 @@ class DockArea(Container, QtGui.QWidget, DockDrop): area.window().close() def saveState(self): + """ + Return a serialized (storable) representation of the state of + all Docks in this DockArea.""" state = {'main': self.childState(self.topContainer), 'float': []} for a in self.tempAreas: geo = a.win.geometry() @@ -188,6 +206,10 @@ class DockArea(Container, QtGui.QWidget, DockDrop): def restoreState(self, state): + """ + Restore Dock configuration as generated by saveState. + """ + ## 1) make dict of all docks and list of existing containers containers, docks = self.findAll() oldTemps = self.tempAreas[:] diff --git a/documentation/source/3dgraphics/glimageitem.rst b/documentation/source/3dgraphics/glimageitem.rst new file mode 100644 index 00000000..ca40ff41 --- /dev/null +++ b/documentation/source/3dgraphics/glimageitem.rst @@ -0,0 +1,8 @@ +GLImageItem +=========== + +.. autoclass:: pyqtgraph.opengl.GLImageItem + :members: + + .. automethod:: pyqtgraph.opengl.GLImageItem.__init__ + diff --git a/documentation/source/3dgraphics/glscatterplotitem.rst b/documentation/source/3dgraphics/glscatterplotitem.rst new file mode 100644 index 00000000..4fa337c6 --- /dev/null +++ b/documentation/source/3dgraphics/glscatterplotitem.rst @@ -0,0 +1,8 @@ +GLScatterPlotItem +================= + +.. autoclass:: pyqtgraph.opengl.GLScatterPlotItem + :members: + + .. automethod:: pyqtgraph.opengl.GLScatterPlotItem.__init__ + diff --git a/documentation/source/3dgraphics/index.rst b/documentation/source/3dgraphics/index.rst index b92070a4..d569db09 100644 --- a/documentation/source/3dgraphics/index.rst +++ b/documentation/source/3dgraphics/index.rst @@ -18,7 +18,9 @@ Contents: glgriditem glmeshitem glvolumeitem + glimageitem glaxisitem glgraphicsitem + glscatterplotitem meshdata diff --git a/documentation/source/graphicsItems/index.rst b/documentation/source/graphicsItems/index.rst index c554db8c..d618fb83 100644 --- a/documentation/source/graphicsItems/index.rst +++ b/documentation/source/graphicsItems/index.rst @@ -29,6 +29,7 @@ Contents: scalebar labelitem vtickgroup + legenditem gradienteditoritem histogramlutitem gradientlegend diff --git a/documentation/source/graphicsItems/legenditem.rst b/documentation/source/graphicsItems/legenditem.rst new file mode 100644 index 00000000..e94b0995 --- /dev/null +++ b/documentation/source/graphicsItems/legenditem.rst @@ -0,0 +1,8 @@ +LegendItem +========== + +.. autoclass:: pyqtgraph.LegendItem + :members: + + .. automethod:: pyqtgraph.LegendItem.__init__ + diff --git a/examples/GLImageItem.py b/examples/GLImageItem.py new file mode 100644 index 00000000..6e8c96bd --- /dev/null +++ b/examples/GLImageItem.py @@ -0,0 +1,47 @@ +# -*- coding: utf-8 -*- +## Add path to library (just for examples; you do not need this) +import sys, os +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..')) + +from pyqtgraph.Qt import QtCore, QtGui +import pyqtgraph.opengl as gl +import pyqtgraph as pg +import numpy as np +import scipy.ndimage as ndi + +app = QtGui.QApplication([]) +w = gl.GLViewWidget() +w.opts['distance'] = 200 +w.show() + +## create volume data set to slice three images from +shape = (100,100,70) +data = ndi.gaussian_filter(np.random.normal(size=shape), (4,4,4)) +data += ndi.gaussian_filter(np.random.normal(size=shape), (15,15,15))*15 + +## slice out three planes, convert to ARGB for OpenGL texture +levels = (-0.08, 0.08) +tex1 = pg.makeRGBA(data[shape[0]/2], levels=levels)[0] # yz plane +tex2 = pg.makeRGBA(data[:,shape[1]/2], levels=levels)[0] # xz plane +tex3 = pg.makeRGBA(data[:,:,shape[2]/2], levels=levels)[0] # xy plane + +## Create three image items from textures, add to view +v1 = gl.GLImageItem(tex1) +v1.translate(-shape[1]/2, -shape[2]/2, 0) +v1.rotate(90, 0,0,1) +v1.rotate(-90, 0,1,0) +w.addItem(v1) +v2 = gl.GLImageItem(tex2) +v2.translate(-shape[0]/2, -shape[2]/2, 0) +v2.rotate(-90, 1,0,0) +w.addItem(v2) +v3 = gl.GLImageItem(tex3) +v3.translate(-shape[0]/2, -shape[1]/2, 0) +w.addItem(v3) + +ax = gl.GLAxisItem() +w.addItem(ax) + +## Start Qt event loop unless running in interactive mode. +if sys.flags.interactive != 1: + app.exec_() diff --git a/examples/GLScatterPlotItem.py b/examples/GLScatterPlotItem.py index c1c6e8f8..16033520 100644 --- a/examples/GLScatterPlotItem.py +++ b/examples/GLScatterPlotItem.py @@ -5,6 +5,7 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..')) from pyqtgraph.Qt import QtCore, QtGui import pyqtgraph.opengl as gl +import numpy as np app = QtGui.QApplication([]) w = gl.GLViewWidget() @@ -14,18 +15,47 @@ w.show() g = gl.GLGridItem() w.addItem(g) -pts = [ - {'pos': (1,0,0), 'size':0.5, 'color':(1.0, 0.0, 0.0, 0.5)}, - {'pos': (0,1,0), 'size':0.2, 'color':(0.0, 0.0, 1.0, 0.5)}, - {'pos': (0,0,1), 'size':2./3., 'color':(0.0, 1.0, 0.0, 0.5)}, -] -z = 0.5 -d = 6.0 -for i in range(50): - pts.append({'pos': (0,0,z), 'size':2./d, 'color':(0.0, 1.0, 0.0, 0.5)}) - z *= 0.5 - d *= 2.0 -sp = gl.GLScatterPlotItem(pts) +#pos = np.empty((53, 3)) +#size = np.empty((53)) +#color = np.empty((53, 4)) +#pos[0] = (1,0,0); size[0] = 0.5; color[0] = (1.0, 0.0, 0.0, 0.5) +#pos[1] = (0,1,0); size[1] = 0.2; color[1] = (0.0, 0.0, 1.0, 0.5) +#pos[2] = (0,0,1); size[2] = 2./3.; color[2] = (0.0, 1.0, 0.0, 0.5) + +#z = 0.5 +#d = 6.0 +#for i in range(3,53): + #pos[i] = (0,0,z) + #size[i] = 2./d + #color[i] = (0.0, 1.0, 0.0, 0.5) + #z *= 0.5 + #d *= 2.0 + +#sp = gl.GLScatterPlotItem(pos=pos, sizes=size, colors=color, pxMode=False) + + +pos = (np.random.random(size=(100000,3)) * 10) - 5 +color = np.ones((pos.shape[0], 4)) +d = (pos**2).sum(axis=1)**0.5 +color[:,3] = np.clip(-np.cos(d*2) * 0.2, 0, 1) +sp = gl.GLScatterPlotItem(pos=pos, color=color, size=5) +phase = 0. + +def update(): + global phase, color, sp, d + s = -np.cos(d*2+phase) + color[:,3] = np.clip(s * 0.2, 0, 1) + color[:,0] = np.clip(s * 3.0, 0, 1) + color[:,1] = np.clip(s * 1.0, 0, 1) + color[:,2] = np.clip(s ** 3, 0, 1) + + sp.setData(color=color) + phase -= 0.1 + +t = QtCore.QTimer() +t.timeout.connect(update) +t.start(50) + w.addItem(sp) ## Start Qt event loop unless running in interactive mode. diff --git a/examples/Legend.py b/examples/Legend.py new file mode 100644 index 00000000..f615a6eb --- /dev/null +++ b/examples/Legend.py @@ -0,0 +1,21 @@ +# -*- 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 + +plt = pg.plot() + +l = pg.LegendItem((100,60), (60,10)) # args are (size, position) +l.setParentItem(plt.graphicsItem()) # Note we do NOT call plt.addItem in this case + +c1 = plt.plot([1,3,2,4], pen='r') +c2 = plt.plot([2,1,4,3], pen='g') +l.addItem(c1, 'red plot') +l.addItem(c2, 'green plot') + + +## Start Qt event loop unless running in interactive mode or using pyside. +import sys +if (sys.flags.interactive != 1) or not hasattr(QtCore, 'PYQT_VERSION'): + QtGui.QApplication.instance().exec_() diff --git a/examples/ScatterPlotSpeedTest.py b/examples/ScatterPlotSpeedTest.py index 386522d1..a44e58e3 100644 --- a/examples/ScatterPlotSpeedTest.py +++ b/examples/ScatterPlotSpeedTest.py @@ -26,7 +26,7 @@ win.show() p = ui.plot data = np.random.normal(size=(50,500), scale=100) -sizeArray = np.random.random(500) * 20. +sizeArray = (np.random.random(500) * 20.).astype(int) ptr = 0 lastTime = time() fps = None @@ -49,7 +49,8 @@ def update(): s = np.clip(dt*3., 0, 1) fps = fps * (1-s) + (1.0/dt) * s p.setTitle('%0.2f fps' % fps) - app.processEvents() ## force complete redraw for every plot + p.repaint() + #app.processEvents() ## force complete redraw for every plot timer = QtCore.QTimer() timer.timeout.connect(update) timer.start(0) diff --git a/examples/__main__.py b/examples/__main__.py index 19514ae1..3035ddcf 100644 --- a/examples/__main__.py +++ b/examples/__main__.py @@ -17,12 +17,11 @@ examples = OrderedDict([ ('ImageView', 'ImageView.py'), ('ParameterTree', 'parametertree.py'), ('Crosshair / Mouse interaction', 'crosshair.py'), - ('Video speed test', 'VideoSpeedTest.py'), - ('Plot speed test', 'PlotSpeedTest.py'), ('Data Slicing', 'DataSlicing.py'), ('Plot Customization', 'customPlot.py'), ('Dock widgets', 'dockarea.py'), ('Console', 'ConsoleWidget.py'), + ('Histograms', 'histogram.py'), ('GraphicsItems', OrderedDict([ ('Scatter Plot', 'ScatterPlot.py'), #('PlotItem', 'PlotItem.py'), @@ -31,14 +30,22 @@ examples = OrderedDict([ ('ImageItem - draw', 'Draw.py'), ('Region-of-Interest', 'ROIExamples.py'), ('GraphicsLayout', 'GraphicsLayout.py'), + ('LegendItem', 'Legend.py'), ('Text Item', 'text.py'), ('Linked Views', 'linkedViews.py'), ('Arrow', 'Arrow.py'), ('ViewBox', 'ViewBox.py'), ])), + ('Benchmarks', OrderedDict([ + ('Video speed test', 'VideoSpeedTest.py'), + ('Line Plot update', 'PlotSpeedTest.py'), + ('Scatter Plot update', 'ScatterPlotSpeedTest.py'), + ])), ('3D Graphics', OrderedDict([ ('Volumetric', 'GLVolumeItem.py'), ('Isosurface', 'GLMeshItem.py'), + ('Image', 'GLImageItem.py'), + ('Scatter Plot', 'GLScatterPlotItem.py'), ])), ('Widgets', OrderedDict([ ('PlotWidget', 'PlotWidget.py'), @@ -80,7 +87,17 @@ class ExampleLoader(QtGui.QMainWindow): self.ui.loadBtn.clicked.connect(self.loadFile) self.ui.exampleTree.currentItemChanged.connect(self.showFile) self.ui.exampleTree.itemDoubleClicked.connect(self.loadFile) + self.ui.pyqtCheck.toggled.connect(self.pyqtToggled) + self.ui.pysideCheck.toggled.connect(self.pysideToggled) + def pyqtToggled(self, b): + if b: + self.ui.pysideCheck.setChecked(False) + + def pysideToggled(self, b): + if b: + self.ui.pyqtCheck.setChecked(False) + def populateTree(self, root, examples): for key, val in examples.items(): @@ -101,12 +118,19 @@ class ExampleLoader(QtGui.QMainWindow): def loadFile(self): fn = self.currentFile() + extra = [] + if self.ui.pyqtCheck.isChecked(): + extra.append('pyqt') + elif self.ui.pysideCheck.isChecked(): + extra.append('pyside') + if fn is None: return if sys.platform.startswith('win'): - os.spawnl(os.P_NOWAIT, sys.executable, sys.executable, '"' + fn + '"') + os.spawnl(os.P_NOWAIT, sys.executable, sys.executable, '"' + fn + '"', *extra) else: - os.spawnl(os.P_NOWAIT, sys.executable, sys.executable, fn) + + os.spawnl(os.P_NOWAIT, sys.executable, sys.executable, fn, *extra) def showFile(self): diff --git a/examples/exampleLoaderTemplate.ui b/examples/exampleLoaderTemplate.ui index b1941447..1453240c 100644 --- a/examples/exampleLoaderTemplate.ui +++ b/examples/exampleLoaderTemplate.ui @@ -39,6 +39,24 @@ + + + + + + Force PyQt + + + + + + + Force PySide + + + + + diff --git a/examples/exampleLoaderTemplate_pyqt.py b/examples/exampleLoaderTemplate_pyqt.py index 80e95295..26e55a44 100644 --- a/examples/exampleLoaderTemplate_pyqt.py +++ b/examples/exampleLoaderTemplate_pyqt.py @@ -2,7 +2,7 @@ # Form implementation generated from reading ui file './examples/exampleLoaderTemplate.ui' # -# Created: Sun Sep 9 14:41:31 2012 +# Created: Fri Oct 26 07:53:55 2012 # by: PyQt4 UI code generator 4.9.1 # # WARNING! All changes made in this file will be lost! @@ -35,6 +35,15 @@ class Ui_Form(object): self.exampleTree.headerItem().setText(0, _fromUtf8("1")) self.exampleTree.header().setVisible(False) self.verticalLayout.addWidget(self.exampleTree) + self.horizontalLayout = QtGui.QHBoxLayout() + self.horizontalLayout.setObjectName(_fromUtf8("horizontalLayout")) + self.pyqtCheck = QtGui.QCheckBox(self.layoutWidget) + self.pyqtCheck.setObjectName(_fromUtf8("pyqtCheck")) + self.horizontalLayout.addWidget(self.pyqtCheck) + self.pysideCheck = QtGui.QCheckBox(self.layoutWidget) + self.pysideCheck.setObjectName(_fromUtf8("pysideCheck")) + self.horizontalLayout.addWidget(self.pysideCheck) + self.verticalLayout.addLayout(self.horizontalLayout) self.loadBtn = QtGui.QPushButton(self.layoutWidget) self.loadBtn.setObjectName(_fromUtf8("loadBtn")) self.verticalLayout.addWidget(self.loadBtn) @@ -51,5 +60,7 @@ class Ui_Form(object): def retranslateUi(self, Form): Form.setWindowTitle(QtGui.QApplication.translate("Form", "Form", None, QtGui.QApplication.UnicodeUTF8)) + self.pyqtCheck.setText(QtGui.QApplication.translate("Form", "Force PyQt", None, QtGui.QApplication.UnicodeUTF8)) + self.pysideCheck.setText(QtGui.QApplication.translate("Form", "Force PySide", None, QtGui.QApplication.UnicodeUTF8)) self.loadBtn.setText(QtGui.QApplication.translate("Form", "Load Example", None, QtGui.QApplication.UnicodeUTF8)) diff --git a/examples/exampleLoaderTemplate_pyside.py b/examples/exampleLoaderTemplate_pyside.py index fa0ccfd1..a81f7299 100644 --- a/examples/exampleLoaderTemplate_pyside.py +++ b/examples/exampleLoaderTemplate_pyside.py @@ -2,7 +2,7 @@ # Form implementation generated from reading ui file './examples/exampleLoaderTemplate.ui' # -# Created: Sun Sep 9 14:41:31 2012 +# Created: Fri Oct 26 07:53:57 2012 # by: pyside-uic 0.2.13 running on PySide 1.1.0 # # WARNING! All changes made in this file will be lost! @@ -30,6 +30,15 @@ class Ui_Form(object): self.exampleTree.headerItem().setText(0, "1") self.exampleTree.header().setVisible(False) self.verticalLayout.addWidget(self.exampleTree) + self.horizontalLayout = QtGui.QHBoxLayout() + self.horizontalLayout.setObjectName("horizontalLayout") + self.pyqtCheck = QtGui.QCheckBox(self.layoutWidget) + self.pyqtCheck.setObjectName("pyqtCheck") + self.horizontalLayout.addWidget(self.pyqtCheck) + self.pysideCheck = QtGui.QCheckBox(self.layoutWidget) + self.pysideCheck.setObjectName("pysideCheck") + self.horizontalLayout.addWidget(self.pysideCheck) + self.verticalLayout.addLayout(self.horizontalLayout) self.loadBtn = QtGui.QPushButton(self.layoutWidget) self.loadBtn.setObjectName("loadBtn") self.verticalLayout.addWidget(self.loadBtn) @@ -46,5 +55,7 @@ class Ui_Form(object): def retranslateUi(self, Form): Form.setWindowTitle(QtGui.QApplication.translate("Form", "Form", None, QtGui.QApplication.UnicodeUTF8)) + self.pyqtCheck.setText(QtGui.QApplication.translate("Form", "Force PyQt", None, QtGui.QApplication.UnicodeUTF8)) + self.pysideCheck.setText(QtGui.QApplication.translate("Form", "Force PySide", None, QtGui.QApplication.UnicodeUTF8)) self.loadBtn.setText(QtGui.QApplication.translate("Form", "Load Example", None, QtGui.QApplication.UnicodeUTF8)) diff --git a/examples/histogram.py b/examples/histogram.py new file mode 100644 index 00000000..1241f42c --- /dev/null +++ b/examples/histogram.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- +""" +In this example we draw two different kinds of histogram. +""" + + +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.GraphicsWindow() +win.resize(800,350) +plt1 = win.addPlot() +plt2 = win.addPlot() + +## make interesting distribution of values +vals = np.hstack([np.random.normal(size=500), np.random.normal(size=260, loc=4)]) + +## draw standard histogram +y,x = np.histogram(vals, bins=np.linspace(-3, 8, 40)) + +## notice that len(x) == len(y)+1 +## We are required to use stepMode=True so that PlotCurveItem will interpret this data correctly. +curve = pg.PlotCurveItem(x, y, stepMode=True, fillLevel=0, brush=(0, 0, 255, 80)) +plt1.addItem(curve) + + +## Now draw all points as a nicely-spaced scatter plot +y = pg.pseudoScatter(vals, spacing=0.15) +#plt2.plot(vals, y, pen=None, symbol='o', symbolSize=5) +plt2.plot(vals, y, pen=None, symbol='o', symbolSize=5, symbolPen=(255,255,255,200), symbolBrush=(0,0,255,150)) + +## Start Qt event loop unless running in interactive mode or using pyside. +import sys +if (sys.flags.interactive != 1) or not hasattr(QtCore, 'PYQT_VERSION'): + QtGui.QApplication.instance().exec_() diff --git a/examples/initExample.py b/examples/initExample.py index 040dbeea..1b38363b 100644 --- a/examples/initExample.py +++ b/examples/initExample.py @@ -1,3 +1,8 @@ ## make this version of pyqtgraph importable before any others import sys, os sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))) + +if 'pyside' in sys.argv: ## should force example to use PySide instead of PyQt + import PySide +elif 'pyqt' in sys.argv: + import PyQt4 diff --git a/exporters/CSVExporter.py b/exporters/CSVExporter.py index 5e949be6..629b2789 100644 --- a/exporters/CSVExporter.py +++ b/exporters/CSVExporter.py @@ -22,7 +22,7 @@ class CSVExporter(Exporter): def export(self, fileName=None): if not isinstance(self.item, pg.PlotItem): - raise Exception("Matplotlib export currently only works with plot items") + raise Exception("Must have a PlotItem selected for CSV export.") if fileName is None: self.fileSaveDialog(filter=["*.csv", "*.tsv"]) diff --git a/exporters/Exporter.py b/exporters/Exporter.py index 709926d4..0ade8467 100644 --- a/exporters/Exporter.py +++ b/exporters/Exporter.py @@ -54,8 +54,13 @@ class Exporter(object): fileName = str(fileName) global LastExportDirectory LastExportDirectory = os.path.split(fileName)[0] - self.export(fileName=fileName, **self.fileDialog.opts) + ext = os.path.splitext(fileName)[1].lower() + selectedExt = str(self.fileDialog.selectedNameFilter()).lstrip('*').lower() + if ext != selectedExt: + fileName = fileName + selectedExt + + self.export(fileName=fileName, **self.fileDialog.opts) def getScene(self): if isinstance(self.item, pg.GraphicsScene): diff --git a/exporters/ImageExporter.py b/exporters/ImageExporter.py index e27d48ff..bf2a66f8 100644 --- a/exporters/ImageExporter.py +++ b/exporters/ImageExporter.py @@ -63,7 +63,7 @@ class ImageExporter(Exporter): self.getScene().render(painter, QtCore.QRectF(targetRect), sourceRect) finally: self.setExportMode(False) - self.png.save(fileName) painter.end() + self.png.save(fileName) \ No newline at end of file diff --git a/functions.py b/functions.py index e70e72fc..1342b527 100644 --- a/functions.py +++ b/functions.py @@ -22,9 +22,10 @@ SI_PREFIXES_ASCII = 'yzafpnum kMGTPEZY' -from .Qt import QtGui, QtCore +from .Qt import QtGui, QtCore, USE_PYSIDE import numpy as np import decimal, re +import ctypes try: import scipy.ndimage @@ -223,13 +224,15 @@ def mkColor(*args): return QtGui.QColor(*args) -def mkBrush(*args): +def mkBrush(*args, **kwds): """ | Convenience function for constructing Brush. | This function always constructs a solid brush and accepts the same arguments as :func:`mkColor() ` | Calling mkBrush(None) returns an invisible brush. """ - if len(args) == 1: + if 'color' in kwds: + color = kwds['color'] + elif len(args) == 1: arg = args[0] if arg is None: return QtGui.QBrush(QtCore.Qt.NoBrush) @@ -237,7 +240,7 @@ def mkBrush(*args): return QtGui.QBrush(arg) else: color = arg - if len(args) > 1: + elif len(args) > 1: color = args return QtGui.QBrush(mkColor(color)) @@ -579,7 +582,10 @@ def solveBilinearTransform(points1, points2): - +def makeRGBA(*args, **kwds): + """Equivalent to makeARGB(..., useRGBA=True)""" + kwds['useRGBA'] = True + return makeARGB(*args, **kwds) def makeARGB(data, lut=None, levels=None, useRGBA=False): """ @@ -605,7 +611,7 @@ def makeARGB(data, lut=None, levels=None, useRGBA=False): Lookup tables can be built using GradientWidget. levels - List [min, max]; optionally rescale data before converting through the lookup table. rescaled = (data-min) * len(lut) / (max-min) - useRGBA - If True, the data is returned in RGBA order. The default is + useRGBA - If True, the data is returned in RGBA order (useful for building OpenGL textures). The default is False, which returns in BGRA order for use with QImage. """ @@ -779,30 +785,117 @@ def makeARGB(data, lut=None, levels=None, useRGBA=False): return imgData, alpha -def makeQImage(imgData, alpha): - """Turn an ARGB array into QImage""" +def makeQImage(imgData, alpha=None, copy=True, transpose=True): + """ + Turn an ARGB array into QImage. + By default, the data is copied; changes to the array will not + be reflected in the image. The image will be given a 'data' attribute + pointing to the array which shares its data to prevent python + freeing that memory while the image is in use. + + =========== =================================================================== + Arguments: + imgData Array of data to convert. Must have shape (width, height, 3 or 4) + and dtype=ubyte. The order of values in the 3rd axis must be + (b, g, r, a). + alpha If True, the QImage returned will have format ARGB32. If False, + the format will be RGB32. By default, _alpha_ is True if + 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. + 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 + opposite. + =========== =================================================================== + """ ## create QImage from buffer prof = debug.Profiler('functions.makeQImage', disabled=True) + ## If we didn't explicitly specify alpha, check the array shape. + if alpha is None: + alpha = (imgData.shape[2] == 4) + + copied = False + if imgData.shape[2] == 3: ## need to make alpha channel (even if alpha==False; QImage requires 32 bpp) + if copy is True: + d2 = np.empty(imgData.shape[:2] + (4,), dtype=imgData.dtype) + d2[:,:,:3] = imgData + d2[:,:,3] = 255 + imgData = d2 + copied = True + else: + raise Exception('Array has only 3 channels; cannot make QImage without copying.') + if alpha: imgFormat = QtGui.QImage.Format_ARGB32 else: imgFormat = QtGui.QImage.Format_RGB32 - imgData = imgData.transpose((1, 0, 2)) ## QImage expects the row/column order to be opposite - try: - buf = imgData.data - except AttributeError: ## happens when image data is non-contiguous + if transpose: + imgData = imgData.transpose((1, 0, 2)) ## QImage expects the row/column order to be opposite + + if not imgData.flags['C_CONTIGUOUS']: + if copy is False: + extra = ' (try setting transpose=False)' if transpose else '' + raise Exception('Array is not contiguous; cannot make QImage without copying.'+extra) imgData = np.ascontiguousarray(imgData) - buf = imgData.data + copied = True - prof.mark('1') - qimage = QtGui.QImage(buf, imgData.shape[1], imgData.shape[0], imgFormat) - prof.mark('2') - qimage.data = imgData - prof.finish() - return qimage + if copy is True and copied is False: + imgData = imgData.copy() + + if USE_PYSIDE: + ch = ctypes.c_char.from_buffer(imgData, 0) + img = QtGui.QImage(ch, imgData.shape[1], imgData.shape[0], imgFormat) + else: + addr = ctypes.addressof(ctypes.c_char.from_buffer(imgData, 0)) + img = QtGui.QImage(addr, imgData.shape[1], imgData.shape[0], imgFormat) + img.data = imgData + return img + #try: + #buf = imgData.data + #except AttributeError: ## happens when image data is non-contiguous + #buf = imgData.data + + #prof.mark('1') + #qimage = QtGui.QImage(buf, imgData.shape[1], imgData.shape[0], imgFormat) + #prof.mark('2') + #qimage.data = imgData + #prof.finish() + #return qimage +def imageToArray(img, copy=False, transpose=True): + """ + Convert a QImage into numpy array. The image must have format RGB32, ARGB32, or ARGB32_Premultiplied. + By default, the image is not copied; changes made to the array will appear in the QImage as well (beware: if + the QImage is collected before the array, there may be trouble). + The array will have shape (width, height, (b,g,r,a)). + """ + fmt = img.format() + ptr = img.bits() + if USE_PYSIDE: + arr = np.frombuffer(ptr, dtype=np.ubyte) + else: + ptr.setsize(img.byteCount()) + arr = np.asarray(ptr) + + if fmt == img.Format_RGB32: + arr = arr.reshape(img.height(), img.width(), 3) + elif fmt == img.Format_ARGB32 or fmt == img.Format_ARGB32_Premultiplied: + arr = arr.reshape(img.height(), img.width(), 4) + + if copy: + arr = arr.copy() + + if transpose: + return arr.transpose((1,0,2)) + else: + return arr + + + def rescaleData(data, scale, offset): newData = np.empty((data.size,), dtype=np.int) @@ -1386,3 +1479,52 @@ 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): + """ + Used for examining the distribution of values in a set. + + 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). + """ + inds = np.arange(len(data)) + if shuffle: + np.random.shuffle(inds) + + data = data[inds] + + if spacing is None: + spacing = 2.*np.std(data)/len(data)**0.5 + s2 = spacing**2 + + yvals = np.empty(len(data)) + yvals[0] = 0 + for i in range(1,len(data)): + x = data[i] # current x value to be placed + x0 = data[:i] # all x values already placed + y0 = yvals[:i] # all y values already placed + y = 0 + + dx = (x0-x)**2 # x-distance to each previous point + 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() + + yvals[i] = y + + return yvals[np.argsort(inds)] ## un-shuffle values before returning \ No newline at end of file diff --git a/graphicsItems/AxisItem.py b/graphicsItems/AxisItem.py index 416d0b5a..82cbcfae 100644 --- a/graphicsItems/AxisItem.py +++ b/graphicsItems/AxisItem.py @@ -172,6 +172,8 @@ class AxisItem(GraphicsWidget): return asUnicode("%s") % (style, s) 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.label.isVisible(): @@ -182,6 +184,8 @@ class AxisItem(GraphicsWidget): def setWidth(self, w=None): + """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.label.isVisible(): diff --git a/graphicsItems/GraphicsItem.py b/graphicsItems/GraphicsItem.py index bd181f8d..347d3886 100644 --- a/graphicsItems/GraphicsItem.py +++ b/graphicsItems/GraphicsItem.py @@ -394,14 +394,17 @@ class GraphicsItem(object): if oldView is not None: #print "disconnect:", self, oldView oldView.sigRangeChanged.disconnect(self.viewRangeChanged) + oldView.sigTransformChanged.disconnect(self.viewTransformChanged) self._connectedView = None ## connect to new view if view is not None: #print "connect:", self, view view.sigRangeChanged.connect(self.viewRangeChanged) + view.sigTransformChanged.connect(self.viewTransformChanged) self._connectedView = weakref.ref(view) self.viewRangeChanged() + self.viewTransformChanged() ## inform children that their view might have changed self._replaceView(oldView) @@ -425,3 +428,9 @@ class GraphicsItem(object): Called whenever the view coordinates of the ViewBox containing this item have changed. """ pass + + def viewTransformChanged(self): + """ + Called whenever the transformation matrix of the view has changed. + """ + pass \ No newline at end of file diff --git a/graphicsItems/LegendItem.py b/graphicsItems/LegendItem.py new file mode 100644 index 00000000..a41201e1 --- /dev/null +++ b/graphicsItems/LegendItem.py @@ -0,0 +1,68 @@ +from .GraphicsWidget import GraphicsWidget +from .LabelItem import LabelItem +from ..Qt import QtGui, QtCore +from .. import functions as fn + +__all__ = ['LegendItem'] + +class LegendItem(GraphicsWidget): + """ + Displays a legend used for describing the contents of a plot. + + Note that this item should not be added directly to a PlotItem. Instead, + Make it a direct descendant of the PlotItem:: + + legend.setParentItem(plotItem) + + """ + def __init__(self, size, offset): + GraphicsWidget.__init__(self) + self.setFlag(self.ItemIgnoresTransformations) + self.layout = QtGui.QGraphicsGridLayout() + self.setLayout(self.layout) + self.items = [] + self.size = size + self.offset = offset + self.setGeometry(QtCore.QRectF(self.offset[0], self.offset[1], self.size[0], self.size[1])) + + def addItem(self, item, title): + """ + Add a new entry to the legend. + + =========== ======================================================== + Arguments + item A PlotDataItem from which the line and point style + of the item will be determined + title The title to display for this item. Simple HTML allowed. + =========== ======================================================== + """ + label = LabelItem(title) + sample = ItemSample(item) + row = len(self.items) + self.items.append((sample, label)) + self.layout.addItem(sample, row, 0) + self.layout.addItem(label, row, 1) + + def boundingRect(self): + return QtCore.QRectF(0, 0, self.size[0], self.size[1]) + + 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()) + + +class ItemSample(GraphicsWidget): + def __init__(self, item): + GraphicsWidget.__init__(self) + self.item = item + + def boundingRect(self): + return QtCore.QRectF(0, 0, 20, 20) + + def paint(self, p, *args): + p.setPen(fn.mkPen(self.item.opts['pen'])) + p.drawLine(2, 18, 18, 2) + + + diff --git a/graphicsItems/PlotCurveItem.py b/graphicsItems/PlotCurveItem.py index dab205fd..17ee4566 100644 --- a/graphicsItems/PlotCurveItem.py +++ b/graphicsItems/PlotCurveItem.py @@ -64,6 +64,7 @@ class PlotCurveItem(GraphicsObject): 'shadowPen': None, 'fillLevel': None, 'brush': None, + 'stepMode': False, } self.setClickable(kargs.get('clickable', False)) self.setData(*args, **kargs) @@ -223,8 +224,15 @@ class PlotCurveItem(GraphicsObject): prof.mark('copy') - if self.xData.shape != self.yData.shape: - raise Exception("X and Y arrays must be the same shape--got %s and %s." % (str(x.shape), str(y.shape))) + if 'stepMode' in kargs: + self.opts['stepMode'] = kargs['stepMode'] + + 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))) + 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))) self.path = None self.fillPath = None @@ -267,6 +275,29 @@ class PlotCurveItem(GraphicsObject): ## 0(i4) ## ## All values are big endian--pack using struct.pack('>d') or struct.pack('>i') + + if self.opts['stepMode']: + ## each value in the x/y arrays generates 2 points. + x2 = np.empty((len(x),2), dtype=x.dtype) + x2[:] = x[:,np.newaxis] + if self.opts['fillLevel'] is None: + x = x2.reshape(x2.size)[1:-1] + y2 = np.empty((len(y),2), dtype=y.dtype) + y2[:] = y[:,np.newaxis] + y = y2.reshape(y2.size) + else: + ## If we have a fill level, add two extra points at either end + x = x2.reshape(x2.size) + y2 = np.empty((len(y)+2,2), dtype=y.dtype) + y2[1:-1] = y[:,np.newaxis] + y = y2.reshape(y2.size)[1:-1] + y[0] = self.opts['fillLevel'] + y[-1] = self.opts['fillLevel'] + + + + + 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 @@ -324,12 +355,21 @@ class PlotCurveItem(GraphicsObject): pixels = self.pixelVectors() if pixels == (None, None): pixels = [Point(0,0), Point(0,0)] - xmin = x.min() - pixels[0].x() * lineWidth - xmax = x.max() + pixels[0].x() * lineWidth - ymin = y.min() - abs(pixels[1].y()) * lineWidth - ymax = y.max() + abs(pixels[1].y()) * lineWidth - + xmin = x.min() + xmax = x.max() + ymin = y.min() + ymax = y.max() + + if self.opts['fillLevel'] is not None: + ymin = min(ymin, self.opts['fillLevel']) + ymax = max(ymax, self.opts['fillLevel']) + + xmin -= pixels[0].x() * lineWidth + xmax += pixels[0].x() * lineWidth + ymin -= abs(pixels[1].y()) * lineWidth + ymax += abs(pixels[1].y()) * lineWidth + return QtCore.QRectF(xmin, ymin, xmax-xmin, ymax-ymin) def paint(self, p, opt, widget): diff --git a/graphicsItems/PlotDataItem.py b/graphicsItems/PlotDataItem.py index d30d737b..34af641a 100644 --- a/graphicsItems/PlotDataItem.py +++ b/graphicsItems/PlotDataItem.py @@ -130,6 +130,8 @@ class PlotDataItem(GraphicsObject): 'symbolBrush': (50, 50, 150), 'pxMode': True, + 'pointMode': None, + 'data': None, } self.setData(*args, **kargs) @@ -144,22 +146,30 @@ class PlotDataItem(GraphicsObject): return QtCore.QRectF() ## let child items handle this def setAlpha(self, alpha, auto): + if self.opts['alphaHint'] == alpha and self.opts['alphaMode'] == auto: + return self.opts['alphaHint'] = alpha self.opts['alphaMode'] = auto self.setOpacity(alpha) #self.update() def setFftMode(self, mode): + if self.opts['fftMode'] == mode: + return self.opts['fftMode'] = mode self.xDisp = self.yDisp = None self.updateItems() def setLogMode(self, xMode, yMode): - self.opts['logMode'] = (xMode, yMode) + if self.opts['logMode'] == [xMode, yMode]: + return + self.opts['logMode'] = [xMode, yMode] self.xDisp = self.yDisp = None self.updateItems() def setPointMode(self, mode): + if self.opts['pointMode'] == mode: + return self.opts['pointMode'] = mode self.update() @@ -193,6 +203,8 @@ class PlotDataItem(GraphicsObject): def setFillBrush(self, *args, **kargs): brush = fn.mkBrush(*args, **kargs) + if self.opts['fillBrush'] == brush: + return self.opts['fillBrush'] = brush self.updateItems() @@ -200,16 +212,22 @@ class PlotDataItem(GraphicsObject): return self.setFillBrush(*args, **kargs) def setFillLevel(self, level): + if self.opts['fillLevel'] == level: + return self.opts['fillLevel'] = level self.updateItems() def setSymbol(self, symbol): + if self.opts['symbol'] == symbol: + return self.opts['symbol'] = symbol #self.scatter.setSymbol(symbol) self.updateItems() def setSymbolPen(self, *args, **kargs): pen = fn.mkPen(*args, **kargs) + if self.opts['symbolPen'] == pen: + return self.opts['symbolPen'] = pen #self.scatter.setSymbolPen(pen) self.updateItems() @@ -218,21 +236,26 @@ class PlotDataItem(GraphicsObject): def setSymbolBrush(self, *args, **kargs): brush = fn.mkBrush(*args, **kargs) + if self.opts['symbolBrush'] == brush: + return self.opts['symbolBrush'] = brush #self.scatter.setSymbolBrush(brush) self.updateItems() def setSymbolSize(self, size): + if self.opts['symbolSize'] == size: + return self.opts['symbolSize'] = size #self.scatter.setSymbolSize(symbolSize) self.updateItems() def setDownsampling(self, ds): - if self.opts['downsample'] != ds: - self.opts['downsample'] = ds - self.xDisp = self.yDisp = None - self.updateItems() + if self.opts['downsample'] == ds: + return + self.opts['downsample'] = ds + self.xDisp = self.yDisp = None + self.updateItems() def setData(self, *args, **kargs): """ @@ -436,9 +459,12 @@ class PlotDataItem(GraphicsObject): and max) =============== ============================================================= """ + 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 (0, 0) + return None if ax == 0: d = x @@ -450,14 +476,15 @@ class PlotDataItem(GraphicsObject): if orthoRange is not None: mask = (d2 >= orthoRange[0]) * (d2 <= orthoRange[1]) d = d[mask] - d2 = d2[mask] + #d2 = d2[mask] - if frac >= 1.0: - return (np.min(d), np.max(d)) - elif frac <= 0.0: - raise Exception("Value for parameter 'frac' must be > 0. (got %s)" % str(frac)) + 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 (scipy.stats.scoreatpercentile(d, 50 - (frac * 50)), scipy.stats.scoreatpercentile(d, 50 + (frac * 50))) + return None def clear(self): diff --git a/graphicsItems/ScatterPlotItem.py b/graphicsItems/ScatterPlotItem.py index 625eb0b6..6e442a1d 100644 --- a/graphicsItems/ScatterPlotItem.py +++ b/graphicsItems/ScatterPlotItem.py @@ -1,4 +1,4 @@ -from pyqtgraph.Qt import QtGui, QtCore +from pyqtgraph.Qt import QtGui, QtCore, USE_PYSIDE from pyqtgraph.Point import Point import pyqtgraph.functions as fn from .GraphicsItem import GraphicsItem @@ -32,26 +32,171 @@ for k, c in coords.items(): Symbols[k].lineTo(x, y) Symbols[k].closeSubpath() + +def drawSymbol(painter, symbol, size, pen, brush): + painter.scale(size, size) + painter.setPen(pen) + painter.setBrush(brush) + if isinstance(symbol, basestring): + symbol = Symbols[symbol] + if np.isscalar(symbol): + symbol = Symbols.values()[symbol % len(Symbols)] + painter.drawPath(symbol) -def makeSymbolPixmap(size, pen, brush, symbol): + +def renderSymbol(symbol, size, pen, brush, device=None): + """ + Render a symbol specification to QImage. + Symbol may be either a QPainterPath or one of the keys in the Symbols dict. + If *device* is None, a new QPixmap will be returned. Otherwise, + 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.width()), 1) - image = QtGui.QImage(int(size+penPxWidth), int(size+penPxWidth), QtGui.QImage.Format_ARGB32_Premultiplied) + image = QtGui.QImage(int(size+penPxWidth), int(size+penPxWidth), QtGui.QImage.Format_ARGB32) image.fill(0) p = QtGui.QPainter(image) p.setRenderHint(p.Antialiasing) p.translate(image.width()*0.5, image.height()*0.5) - p.scale(size, size) - p.setPen(pen) - p.setBrush(brush) - if isinstance(symbol, basestring): - symbol = Symbols[symbol] - p.drawPath(symbol) + drawSymbol(p, symbol, size, pen, brush) p.end() - return QtGui.QPixmap(image) + return image + #pixmap = QtGui.QPixmap(image) + #SymbolPixmapCache[key] = pixmap + #return pixmap +def makeSymbolPixmap(size, pen, brush, symbol): + ## deprecated + img = renderSymbol(symbol, size, pen, brush) + return QtGui.QPixmap(img) + +class SymbolAtlas: + """ + Used to efficiently construct a single QPixmap containing all rendered symbols + for a ScatterPlotItem. This is required for fragment rendering. + + Use example: + atlas = SymbolAtlas() + sc1 = atlas.getSymbolCoords('o', 5, QPen(..), QBrush(..)) + sc2 = atlas.getSymbolCoords('t', 10, QPen(..), QBrush(..)) + pm = atlas.getAtlas() + + """ + class SymbolCoords(list): ## needed because lists are not allowed in weak references. + pass + + def __init__(self): + # symbol key : [x, y, w, h] atlas coordinates + # note that the coordinate list will always be the same list object as + # long as the symbol is in the atlas, but the coordinates may + # change if the atlas is rebuilt. + # weak value; if all external refs to this list disappear, + # the symbol will be forgotten. + self.symbolMap = weakref.WeakValueDictionary() + + self.atlasData = None # numpy array of atlas image + self.atlas = None # atlas as QPixmap + self.atlasValid = False + + def getSymbolCoords(self, opts): + """ + Given a list of spot records, return an object representing the coordinates of that symbol within the atlas + """ + coords = np.empty(len(opts), dtype=object) + for i, rec in enumerate(opts): + symbol, size, pen, brush = rec['symbol'], rec['size'], rec['pen'], rec['brush'] + pen = fn.mkPen(pen) if not isinstance(pen, QtGui.QPen) else pen + brush = fn.mkBrush(brush) if not isinstance(pen, QtGui.QBrush) else brush + key = (symbol, size, fn.colorTuple(pen.color()), pen.width(), pen.style(), fn.colorTuple(brush.color())) + if key not in self.symbolMap: + newCoords = SymbolAtlas.SymbolCoords() + self.symbolMap[key] = newCoords + self.atlasValid = False + #try: + #self.addToAtlas(key) ## squeeze this into the atlas if there is room + #except: + #self.buildAtlas() ## otherwise, we need to rebuild + + coords[i] = self.symbolMap[key] + return coords + + def buildAtlas(self): + # get rendered array for all symbols, keep track of avg/max width + rendered = {} + avgWidth = 0.0 + maxWidth = 0 + images = [] + for key, coords in self.symbolMap.items(): + if len(coords) == 0: + pen = fn.mkPen(color=key[2], width=key[3], style=key[4]) + brush = fn.mkBrush(color=key[5]) + img = renderSymbol(key[0], key[1], pen, brush) + images.append(img) ## we only need this to prevent the images being garbage collected immediately + arr = fn.imageToArray(img, copy=False, transpose=False) + else: + (x,y,w,h) = self.symbolMap[key] + arr = self.atlasData[x:x+w, y:y+w] + rendered[key] = arr + w = arr.shape[0] + avgWidth += w + maxWidth = max(maxWidth, w) + + nSymbols = len(rendered) + if nSymbols > 0: + avgWidth /= nSymbols + width = max(maxWidth, avgWidth * (nSymbols**0.5)) + else: + avgWidth = 0 + width = 0 + + # sort symbols by height + symbols = sorted(rendered.keys(), key=lambda x: rendered[x].shape[1], reverse=True) + + self.atlasRows = [] + x = width + y = 0 + rowheight = 0 + for key in symbols: + arr = rendered[key] + w,h = arr.shape[:2] + if x+w > width: + y += rowheight + x = 0 + rowheight = h + self.atlasRows.append([y, rowheight, 0]) + self.symbolMap[key][:] = x, y, w, h + x += w + self.atlasRows[-1][2] = x + height = y + rowheight + self.atlasData = np.zeros((width, height, 4), dtype=np.ubyte) + for key in symbols: + x, y, w, h = self.symbolMap[key] + self.atlasData[x:x+w, y:y+h] = rendered[key] + self.atlas = None + self.atlasValid = True + + def getAtlas(self): + if not self.atlasValid: + self.buildAtlas() + if self.atlas is None: + if len(self.atlasData) == 0: + return QtGui.QPixmap(0,0) + img = fn.makeQImage(self.atlasData, copy=False, transpose=False) + self.atlas = QtGui.QPixmap(img) + return self.atlas + + + + class ScatterPlotItem(GraphicsObject): """ Displays a set of x/y points. Instances of this class are created @@ -79,13 +224,16 @@ class ScatterPlotItem(GraphicsObject): """ prof = debug.Profiler('ScatterPlotItem.__init__', disabled=True) GraphicsObject.__init__(self) - self.setFlag(self.ItemHasNoContents, True) - self.data = np.empty(0, dtype=[('x', float), ('y', float), ('size', float), ('symbol', object), ('pen', object), ('brush', object), ('item', object), ('data', object)]) + + self.picture = None # QPicture used for rendering when pxmode==False + self.fragments = None # fragment specification for pxmode; updated every time the view changes. + self.fragmentAtlas = SymbolAtlas() + + self.data = np.empty(0, dtype=[('x', float), ('y', float), ('size', float), ('symbol', object), ('pen', object), ('brush', object), ('data', object), ('fragCoords', object), ('item', object)]) self.bounds = [None, None] ## caches data bounds self._maxSpotWidth = 0 ## maximum size of the scale-variant portion of all spots self._maxSpotPxWidth = 0 ## maximum size of the scale-invariant portion of all spots - self._spotPixmap = None - self.opts = {'pxMode': True} + self.opts = {'pxMode': True, 'useCache': True} ## If useCache is False, symbols are re-drawn on every paint. self.setPen(200,200,200, update=False) self.setBrush(100,100,150, update=False) @@ -96,6 +244,8 @@ class ScatterPlotItem(GraphicsObject): prof.mark('setData') prof.finish() + #self.setCacheMode(self.DeviceCoordinateCache) + def setData(self, *args, **kargs): """ **Ordered Arguments:** @@ -130,6 +280,7 @@ class ScatterPlotItem(GraphicsObject): *identical* *Deprecated*. This functionality is handled automatically now. ====================== =============================================================================================== """ + oldData = self.data ## this causes cached pixmaps to be preserved while new data is registered. self.clear() ## clear out all old data self.addPoints(*args, **kargs) @@ -183,8 +334,8 @@ class ScatterPlotItem(GraphicsObject): ## note that np.empty initializes object fields to None and string fields to '' self.data[:len(oldData)] = oldData - for i in range(len(oldData)): - oldData[i]['item']._data = self.data[i] ## Make sure items have proper reference to new array + #for i in range(len(oldData)): + #oldData[i]['item']._data = self.data[i] ## Make sure items have proper reference to new array newData = self.data[len(oldData):] newData['size'] = -1 ## indicates to use default size @@ -217,7 +368,7 @@ class ScatterPlotItem(GraphicsObject): newData['y'] = kargs['y'] if 'pxMode' in kargs: - self.setPxMode(kargs['pxMode'], update=False) + self.setPxMode(kargs['pxMode']) ## Set any extra parameters provided in keyword arguments for k in ['pen', 'brush', 'symbol', 'size']: @@ -228,12 +379,18 @@ class ScatterPlotItem(GraphicsObject): if 'data' in kargs: self.setPointData(kargs['data'], dataSet=newData) - #self.updateSpots() self.prepareGeometryChange() self.bounds = [None, None] - self.generateSpotItems() + self.invalidate() + self.updateSpots(newData) self.sigPlotChanged.emit(self) + def invalidate(self): + ## clear any cached drawing state + self.picture = None + self.fragments = None + self.update() + def getData(self): return self.data['x'], self.data['y'] @@ -263,8 +420,8 @@ class ScatterPlotItem(GraphicsObject): dataSet['pen'] = pens else: self.opts['pen'] = fn.mkPen(*args, **kargs) - self._spotPixmap = None + dataSet['fragCoords'] = None if update: self.updateSpots(dataSet) @@ -285,8 +442,9 @@ class ScatterPlotItem(GraphicsObject): dataSet['brush'] = brushes else: self.opts['brush'] = fn.mkBrush(*args, **kargs) - self._spotPixmap = None + #self._spotPixmap = None + dataSet['fragCoords'] = None if update: self.updateSpots(dataSet) @@ -307,6 +465,7 @@ class ScatterPlotItem(GraphicsObject): self.opts['symbol'] = symbol self._spotPixmap = None + dataSet['fragCoords'] = None if update: self.updateSpots(dataSet) @@ -327,6 +486,7 @@ class ScatterPlotItem(GraphicsObject): self.opts['size'] = size self._spotPixmap = None + dataSet['fragCoords'] = None if update: self.updateSpots(dataSet) @@ -346,34 +506,71 @@ class ScatterPlotItem(GraphicsObject): else: dataSet['data'] = data - def setPxMode(self, mode, update=True): + def setPxMode(self, mode): if self.opts['pxMode'] == mode: return self.opts['pxMode'] = mode - self.clearItems() - if update: - self.generateSpotItems() + self.invalidate() def updateSpots(self, dataSet=None): if dataSet is None: dataSet = self.data self._maxSpotWidth = 0 self._maxSpotPxWidth = 0 - for spot in dataSet['item']: - spot.updateItem() + invalidate = False self.measureSpotSizes(dataSet) + if self.opts['pxMode']: + mask = np.equal(dataSet['fragCoords'], None) + if np.any(mask): + invalidate = True + opts = self.getSpotOpts(dataSet[mask]) + coords = self.fragmentAtlas.getSymbolCoords(opts) + dataSet['fragCoords'][mask] = coords + + #for rec in dataSet: + #if rec['fragCoords'] is None: + #invalidate = True + #rec['fragCoords'] = self.fragmentAtlas.getSymbolCoords(*self.getSpotOpts(rec)) + if invalidate: + self.invalidate() + def getSpotOpts(self, recs): + if recs.ndim == 0: + rec = recs + symbol = rec['symbol'] + if symbol is None: + symbol = self.opts['symbol'] + size = rec['size'] + if size < 0: + size = self.opts['size'] + pen = rec['pen'] + if pen is None: + pen = self.opts['pen'] + brush = rec['brush'] + if brush is None: + brush = self.opts['brush'] + return (symbol, size, fn.mkPen(pen), fn.mkBrush(brush)) + else: + recs = recs.copy() + recs['symbol'][np.equal(recs['symbol'], None)] = self.opts['symbol'] + recs['size'][np.equal(recs['size'], -1)] = self.opts['size'] + recs['pen'][np.equal(recs['pen'], None)] = fn.mkPen(self.opts['pen']) + recs['brush'][np.equal(recs['brush'], None)] = fn.mkBrush(self.opts['brush']) + return recs + + + def measureSpotSizes(self, dataSet): - for spot in dataSet['item']: + for rec in dataSet: ## keep track of the maximum spot size and pixel size + symbol, size, pen, brush = self.getSpotOpts(rec) width = 0 pxWidth = 0 - pen = spot.pen() if self.opts['pxMode']: - pxWidth = spot.size() + pen.width() + pxWidth = size + pen.width() else: - width = spot.size() + width = size if pen.isCosmetic(): pxWidth += pen.width() else: @@ -385,20 +582,11 @@ class ScatterPlotItem(GraphicsObject): def clear(self): """Remove all spots from the scatter plot""" - self.clearItems() + #self.clearItems() self.data = np.empty(0, dtype=self.data.dtype) self.bounds = [None, None] + self.invalidate() - def clearItems(self): - for i in self.data['item']: - if i is None: - continue - i.setParentItem(None) - s = i.scene() - if s is not None: - s.removeItem(i) - self.data['item'] = None - def dataBounds(self, ax, frac=1.0, orthoRange=None): if frac >= 1.0 and self.bounds[ax] is not None: return self.bounds[ax] @@ -436,28 +624,12 @@ class ScatterPlotItem(GraphicsObject): else: return (scipy.stats.scoreatpercentile(d, 50 - (frac * 50)), scipy.stats.scoreatpercentile(d, 50 + (frac * 50))) - - - - - def generateSpotItems(self): - if self.opts['pxMode']: - for rec in self.data: - if rec['item'] is None: - rec['item'] = PixmapSpotItem(rec, self) - else: - for rec in self.data: - if rec['item'] is None: - rec['item'] = PathSpotItem(rec, self) - self.measureSpotSizes(self.data) - self.sigPlotChanged.emit(self) - - def defaultSpotPixmap(self): - ## Return the default spot pixmap - if self._spotPixmap is None: - self._spotPixmap = makeSymbolPixmap(size=self.opts['size'], brush=self.opts['brush'], pen=self.opts['pen'], symbol=self.opts['symbol']) - return self._spotPixmap + #def defaultSpotPixmap(self): + ### Return the default spot pixmap + #if self._spotPixmap is None: + #self._spotPixmap = makeSymbolPixmap(size=self.opts['size'], brush=self.opts['brush'], pen=self.opts['pen'], symbol=self.opts['symbol']) + #return self._spotPixmap def boundingRect(self): (xmn, xmx) = self.dataBounds(ax=0) @@ -470,19 +642,72 @@ class ScatterPlotItem(GraphicsObject): ymx = 0 return QtCore.QRectF(xmn, ymn, xmx-xmn, ymx-ymn) - def viewRangeChanged(self): + def viewTransformChanged(self): self.prepareGeometryChange() - GraphicsObject.viewRangeChanged(self) + GraphicsObject.viewTransformChanged(self) self.bounds = [None, None] + self.fragments = None + def generateFragments(self): + tr = self.deviceTransform() + if tr is None: + return + pts = np.empty((2,len(self.data['x']))) + pts[0] = self.data['x'] + pts[1] = self.data['y'] + pts = fn.transformCoordinates(tr, pts) + self.fragments = [] + for i in xrange(len(self.data)): + rec = self.data[i] + pos = QtCore.QPointF(pts[0,i], pts[1,i]) + x,y,w,h = rec['fragCoords'] + rect = QtCore.QRectF(y, x, h, w) + self.fragments.append(QtGui.QPainter.PixmapFragment.create(pos, rect)) + def paint(self, p, *args): - ## NOTE: self.paint is disabled by this line in __init__: - ## self.setFlag(self.ItemHasNoContents, True) - p.setPen(fn.mkPen('r')) - p.drawRect(self.boundingRect()) + #p.setPen(fn.mkPen('r')) + #p.drawRect(self.boundingRect()) + if self.opts['pxMode']: + atlas = self.fragmentAtlas.getAtlas() + #arr = fn.imageToArray(atlas.toImage(), copy=True) + #if hasattr(self, 'lastAtlas'): + #if np.any(self.lastAtlas != arr): + #print "Atlas changed:", arr + #self.lastAtlas = arr + + if self.fragments is None: + self.updateSpots() + self.generateFragments() + + p.resetTransform() + + if not USE_PYSIDE and self.opts['useCache']: + p.drawPixmapFragments(self.fragments, atlas) + else: + for i in range(len(self.data)): + rec = self.data[i] + frag = self.fragments[i] + p.resetTransform() + p.translate(frag.x, frag.y) + drawSymbol(p, *self.getSpotOpts(rec)) + else: + if self.picture is None: + self.picture = QtGui.QPicture() + p2 = QtGui.QPainter(self.picture) + for rec in self.data: + + p2.resetTransform() + p2.translate(rec['x'], rec['y']) + drawSymbol(p2, *self.getSpotOpts(rec)) + p2.end() + + self.picture.play(p) def points(self): + for rec in self.data: + if rec['item'] is None: + rec['item'] = SpotItem(rec, self) return self.data['item'] def pointsAt(self, pos): @@ -506,8 +731,8 @@ class ScatterPlotItem(GraphicsObject): #else: #print "No hit:", (x, y), (sx, sy) #print " ", (sx-s2x, sy-s2y), (sx+s2x, sy+s2y) - pts.sort(lambda a,b: cmp(b.zValue(), a.zValue())) - return pts + #pts.sort(lambda a,b: cmp(b.zValue(), a.zValue())) + return pts[::-1] def mouseClickEvent(self, ev): @@ -524,7 +749,7 @@ class ScatterPlotItem(GraphicsObject): ev.ignore() -class SpotItem(GraphicsItem): +class SpotItem(object): """ Class referring to individual spots in a scatter plot. These can be retrieved by calling ScatterPlotItem.points() or @@ -532,14 +757,12 @@ class SpotItem(GraphicsItem): """ def __init__(self, data, plot): - GraphicsItem.__init__(self, register=False) + #GraphicsItem.__init__(self, register=False) self._data = data self._plot = plot - #self._viewBox = None - #self._viewWidget = None - self.setParentItem(plot) - self.setPos(QtCore.QPointF(data['x'], data['y'])) - self.updateItem() + #self.setParentItem(plot) + #self.setPos(QtCore.QPointF(data['x'], data['y'])) + #self.updateItem() def data(self): """Return the user data associated with this spot.""" @@ -553,6 +776,12 @@ class SpotItem(GraphicsItem): else: return self._data['size'] + def pos(self): + return Point(self._data['x'], self._data['y']) + + def viewPos(self): + return self._plot.mapToView(self.pos()) + def setSize(self, size): """Set the size of this spot. If the size is set to -1, then the ScatterPlotItem's default size @@ -618,37 +847,41 @@ class SpotItem(GraphicsItem): """Set the user-data associated with this spot""" self._data['data'] = data - -class PixmapSpotItem(SpotItem, QtGui.QGraphicsPixmapItem): - def __init__(self, data, plot): - QtGui.QGraphicsPixmapItem.__init__(self) - self.setFlags(self.flags() | self.ItemIgnoresTransformations) - SpotItem.__init__(self, data, plot) - - def setPixmap(self, pixmap): - QtGui.QGraphicsPixmapItem.setPixmap(self, pixmap) - self.setOffset(-pixmap.width()/2.+0.5, -pixmap.height()/2.) - def updateItem(self): - symbolOpts = (self._data['pen'], self._data['brush'], self._data['size'], self._data['symbol']) + self._data['fragCoords'] = None + self._plot.updateSpots([self._data]) + self._plot.invalidate() + +#class PixmapSpotItem(SpotItem, QtGui.QGraphicsPixmapItem): + #def __init__(self, data, plot): + #QtGui.QGraphicsPixmapItem.__init__(self) + #self.setFlags(self.flags() | self.ItemIgnoresTransformations) + #SpotItem.__init__(self, data, plot) + + #def setPixmap(self, pixmap): + #QtGui.QGraphicsPixmapItem.setPixmap(self, pixmap) + #self.setOffset(-pixmap.width()/2.+0.5, -pixmap.height()/2.) + + #def updateItem(self): + #symbolOpts = (self._data['pen'], self._data['brush'], self._data['size'], self._data['symbol']) - ## If all symbol options are default, use default pixmap - if symbolOpts == (None, None, -1, ''): - pixmap = self._plot.defaultSpotPixmap() - else: - pixmap = makeSymbolPixmap(size=self.size(), pen=self.pen(), brush=self.brush(), symbol=self.symbol()) - self.setPixmap(pixmap) + ### If all symbol options are default, use default pixmap + #if symbolOpts == (None, None, -1, ''): + #pixmap = self._plot.defaultSpotPixmap() + #else: + #pixmap = makeSymbolPixmap(size=self.size(), pen=self.pen(), brush=self.brush(), symbol=self.symbol()) + #self.setPixmap(pixmap) -class PathSpotItem(SpotItem, QtGui.QGraphicsPathItem): - def __init__(self, data, plot): - QtGui.QGraphicsPathItem.__init__(self) - SpotItem.__init__(self, data, plot) +#class PathSpotItem(SpotItem, QtGui.QGraphicsPathItem): + #def __init__(self, data, plot): + #QtGui.QGraphicsPathItem.__init__(self) + #SpotItem.__init__(self, data, plot) - def updateItem(self): - QtGui.QGraphicsPathItem.setPath(self, Symbols[self.symbol()]) - QtGui.QGraphicsPathItem.setPen(self, self.pen()) - QtGui.QGraphicsPathItem.setBrush(self, self.brush()) - size = self.size() - self.resetTransform() - self.scale(size, size) + #def updateItem(self): + #QtGui.QGraphicsPathItem.setPath(self, Symbols[self.symbol()]) + #QtGui.QGraphicsPathItem.setPen(self, self.pen()) + #QtGui.QGraphicsPathItem.setBrush(self, self.brush()) + #size = self.size() + #self.resetTransform() + #self.scale(size, size) diff --git a/graphicsItems/ViewBox/ViewBox.py b/graphicsItems/ViewBox/ViewBox.py index 6c4ce4f2..d403291b 100644 --- a/graphicsItems/ViewBox/ViewBox.py +++ b/graphicsItems/ViewBox/ViewBox.py @@ -48,6 +48,7 @@ class ViewBox(GraphicsWidget): sigRangeChanged = QtCore.Signal(object, object) #sigActionPositionChanged = QtCore.Signal(object) sigStateChanged = QtCore.Signal(object) + sigTransformChanged = QtCore.Signal(object) ## mouse modes PanMode = 3 @@ -307,10 +308,6 @@ class ViewBox(GraphicsWidget): print("make qrectf failed:", self.state['viewRange']) raise - #def viewportTransform(self): - ##return self.itemTransform(self.childGroup)[0] - #return self.childGroup.itemTransform(self)[0] - def targetRange(self): return [x[:] for x in self.state['targetRange']] ## return copy @@ -554,10 +551,10 @@ class ViewBox(GraphicsWidget): ## Make corrections to range xr = childRange[ax] if xr is not None: - if self.state['autoPan'][0]: + if self.state['autoPan'][ax]: x = sum(xr) * 0.5 #x = childRect.center().x() - w2 = (targetRect[0][1]-targetRect[0][0]) / 2. + w2 = (targetRect[ax][1]-targetRect[ax][0]) / 2. #childRect.setLeft(x-w2) #childRect.setRight(x+w2) childRange[ax] = [x-w2, x+w2] @@ -1127,29 +1124,15 @@ class ViewBox(GraphicsWidget): m = QtGui.QTransform() ## First center the viewport at 0 - #self.childGroup.resetTransform() - #self.resetTransform() - #center = self.transform().inverted()[0].map(bounds.center()) center = bounds.center() - #print " transform to center:", center - #if self.state['yInverted']: - #m.translate(center.x(), -center.y()) - #print " inverted; translate", center.x(), center.y() - #else: m.translate(center.x(), center.y()) - #print " not inverted; translate", center.x(), -center.y() ## Now scale and translate properly m.scale(scale[0], scale[1]) st = Point(vr.center()) - #st = translate m.translate(-st[0], -st[1]) self.childGroup.setTransform(m) - #self.setTransform(m) - #self.prepareGeometryChange() - - #self.currentScale = scale if changed[0]: self.sigXRangeChanged.emit(self, tuple(self.state['viewRange'][0])) @@ -1157,6 +1140,8 @@ class ViewBox(GraphicsWidget): self.sigYRangeChanged.emit(self, tuple(self.state['viewRange'][1])) if any(changed): self.sigRangeChanged.emit(self, self.state['viewRange']) + + self.sigTransformChanged.emit(self) def paint(self, p, opt, widget): if self.border is not None: @@ -1165,20 +1150,6 @@ class ViewBox(GraphicsWidget): #p.fillRect(bounds, QtGui.QColor(0, 0, 0)) p.drawPath(bounds) - #def saveSvg(self): - #pass - - #def saveImage(self): - #pass - - #def savePrint(self): - #printer = QtGui.QPrinter() - #if QtGui.QPrintDialog(printer).exec_() == QtGui.QDialog.Accepted: - #p = QtGui.QPainter(printer) - #p.setRenderHint(p.Antialiasing) - #self.scene().render(p) - #p.end() - def updateBackground(self): bg = self.state['background'] if bg is None: diff --git a/graphicsWindows.py b/graphicsWindows.py index 9e5090e1..6e7d6305 100644 --- a/graphicsWindows.py +++ b/graphicsWindows.py @@ -21,13 +21,13 @@ def mkQApp(): class GraphicsWindow(GraphicsLayoutWidget): def __init__(self, title=None, size=(800,600), **kargs): mkQApp() - self.win = QtGui.QMainWindow() + #self.win = QtGui.QMainWindow() GraphicsLayoutWidget.__init__(self, **kargs) - self.win.setCentralWidget(self) - self.win.resize(*size) + #self.win.setCentralWidget(self) + self.resize(*size) if title is not None: - self.win.setWindowTitle(title) - self.win.show() + self.setWindowTitle(title) + self.show() class TabWindow(QtGui.QMainWindow): diff --git a/opengl/items/GLImageItem.py b/opengl/items/GLImageItem.py new file mode 100644 index 00000000..b292a7b7 --- /dev/null +++ b/opengl/items/GLImageItem.py @@ -0,0 +1,87 @@ +from OpenGL.GL import * +from .. GLGraphicsItem import GLGraphicsItem +from pyqtgraph.Qt import QtGui +import numpy as np + +__all__ = ['GLImageItem'] + +class GLImageItem(GLGraphicsItem): + """ + **Bases:** :class:`GLGraphicsItem ` + + Displays image data as a textured quad. + """ + + + def __init__(self, data, smooth=False): + """ + + ============== ======================================================================================= + **Arguments:** + data Volume data to be rendered. *Must* be 3D numpy array (x, y, RGBA) with dtype=ubyte. + (See functions.makeRGBA) + smooth (bool) If True, the volume slices are rendered with linear interpolation + ============== ======================================================================================= + """ + + self.smooth = smooth + self.data = data + GLGraphicsItem.__init__(self) + + def initializeGL(self): + glEnable(GL_TEXTURE_2D) + self.texture = glGenTextures(1) + 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.data.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.data.transpose((1,0,2))) + glDisable(GL_TEXTURE_2D) + + #self.lists = {} + #for ax in [0,1,2]: + #for d in [-1, 1]: + #l = glGenLists(1) + #self.lists[(ax,d)] = l + #glNewList(l, GL_COMPILE) + #self.drawVolume(ax, d) + #glEndList() + + + def paint(self): + + 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 ) + glColor4f(1,1,1,1) + + glBegin(GL_QUADS) + glTexCoord2f(0,0) + glVertex3f(0,0,0) + glTexCoord2f(1,0) + glVertex3f(self.data.shape[0], 0, 0) + glTexCoord2f(1,1) + glVertex3f(self.data.shape[0], self.data.shape[1], 0) + glTexCoord2f(0,1) + glVertex3f(0, self.data.shape[1], 0) + glEnd() + glDisable(GL_TEXTURE_3D) + diff --git a/opengl/items/GLScatterPlotItem.py b/opengl/items/GLScatterPlotItem.py index 56c81fe9..3ef3f11b 100644 --- a/opengl/items/GLScatterPlotItem.py +++ b/opengl/items/GLScatterPlotItem.py @@ -8,31 +8,47 @@ __all__ = ['GLScatterPlotItem'] class GLScatterPlotItem(GLGraphicsItem): """Draws points at a list of 3D positions.""" - def __init__(self, data=None): + def __init__(self, **kwds): GLGraphicsItem.__init__(self) - self.data = [] - if data is not None: - self.setData(data) + self.pos = [] + self.size = 10 + self.color = [1.0,1.0,1.0,0.5] + self.pxMode = True + self.setData(**kwds) - def setData(self, data): + def setData(self, **kwds): """ - Data may be either a list of dicts (one dict per point) or a numpy record array. + Update the data displayed by this item. All arguments are optional; + for example it is allowed to update spot positions while leaving + colors unchanged, etc. ==================== ================================================== - Allowed fields are: + Arguments: ------------------------------------------------------------------------ - pos (x,y,z) tuple of coordinate values or QVector3D - color (r,g,b,a) tuple of floats (0.0-1.0) or QColor - size (float) diameter of spot in pixels + pos (N,3) array of floats specifying point locations. + color (N,4) array of floats (0.0-1.0) specifying + spot colors OR a tuple of floats specifying + a single color for all spots. + size (N,) array of floats specifying spot sizes or + a single value to apply to all spots. + pxMode If True, spot sizes are expressed in pixels. + Otherwise, they are expressed in item coordinates. ==================== ================================================== """ - - - self.data = data + args = ['pos', 'color', 'size', 'pxMode'] + for k in kwds.keys(): + if k not in args: + raise Exception('Invalid keyword argument: %s (allowed arguments are %s)' % (k, str(args))) + self.pos = kwds.get('pos', self.pos) + self.color = kwds.get('color', self.color) + self.size = kwds.get('size', self.size) + self.pxMode = kwds.get('pxMode', self.pxMode) self.update() def initializeGL(self): + + ## Generate texture for rendering points w = 64 def fn(x,y): r = ((x-w/2.)**2 + (y-w/2.)**2) ** 0.5 @@ -73,28 +89,49 @@ class GLScatterPlotItem(GLGraphicsItem): glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE) glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE) - for pt in self.data: - pos = pt['pos'] - try: - color = pt['color'] - except KeyError: - color = (1,1,1,1) - try: - size = pt['size'] - except KeyError: - size = 10 - - if isinstance(color, QtGui.QColor): - color = fn.glColor(color) - - pxSize = self.view().pixelSize(QtGui.QVector3D(*pos)) + if self.pxMode: + glVertexPointerf(self.pos) + if isinstance(self.color, np.ndarray): + glColorPointerf(self.color) + else: + if isinstance(self.color, QtGui.QColor): + glColor4f(*fn.glColor(self.color)) + else: + glColor4f(*self.color) - glPointSize(size / pxSize) - glBegin( GL_POINTS ) - glColor4f(*color) # x is blue - #glNormal3f(size, 0, 0) - glVertex3f(*pos) - glEnd() + if isinstance(self.size, np.ndarray): + raise Exception('Array size not yet supported in pxMode (hopefully soon)') + + glPointSize(self.size) + glEnableClientState(GL_VERTEX_ARRAY) + glEnableClientState(GL_COLOR_ARRAY) + glDrawArrays(GL_POINTS, 0, len(self.pos)) + else: + + + for i in range(len(self.pos)): + pos = self.pos[i] + + if isinstance(self.color, np.ndarray): + color = self.color[i] + else: + color = self.color + if isinstance(self.color, QtGui.QColor): + color = fn.glColor(self.color) + + if isinstance(self.size, np.ndarray): + size = self.size[i] + else: + size = self.size + + pxSize = self.view().pixelSize(QtGui.QVector3D(*pos)) + + glPointSize(size / pxSize) + glBegin( GL_POINTS ) + glColor4f(*color) # x is blue + #glNormal3f(size, 0, 0) + glVertex3f(*pos) + glEnd() diff --git a/widgets/GraphicsView.py b/widgets/GraphicsView.py index f773d929..403aed9d 100644 --- a/widgets/GraphicsView.py +++ b/widgets/GraphicsView.py @@ -42,6 +42,7 @@ class GraphicsView(QtGui.QGraphicsView): enabled via enableMouse() (but ordinarily, we use ViewBox for this functionality).""" sigRangeChanged = QtCore.Signal(object, object) + sigTransformChanged = QtCore.Signal(object) sigMouseReleased = QtCore.Signal(object) sigSceneMouseMoved = QtCore.Signal(object) #sigRegionChanged = QtCore.Signal(object) @@ -212,6 +213,7 @@ class GraphicsView(QtGui.QGraphicsView): self.fitInView(self.range, QtCore.Qt.IgnoreAspectRatio) self.sigRangeChanged.emit(self, self.range) + self.sigTransformChanged.emit(self) if propagate: for v in self.lockedViewports: