From e5f383fbb5e3953d44b9767d3a73b32d2a32e480 Mon Sep 17 00:00:00 2001 From: Luke Campagnola <> Date: Fri, 23 Nov 2012 16:01:25 -0500 Subject: [PATCH 1/3] Bugfixes and updates to functions.py: - generalized makeARGB API: can now process arrays of arbitrary shape. - affineSlice automatically converts vector arguments to array - new function applyLookupTable taken from makeARGB - isosurface function returns array Updated VideoSpeedTest example to follow new makeARGB API LayoutWidget: row argument now accepts 'next' as value ParameterTree bugfix: avoid infinite recursion when accessing non-existent attributes ViewBox: avoid exit error caused when cleanup callback is invoked while python is shutting down --- examples/VideoSpeedTest.py | 26 ++- examples/VideoTemplate.ui | 10 +- examples/VideoTemplate_pyqt.py | 14 +- examples/VideoTemplate_pyside.py | 14 +- functions.py | 367 ++++++++++++++++++------------- graphicsItems/GraphicsItem.py | 8 +- graphicsItems/ViewBox/ViewBox.py | 4 +- parametertree/Parameter.py | 7 +- widgets/LayoutWidget.py | 7 +- 9 files changed, 273 insertions(+), 184 deletions(-) diff --git a/examples/VideoSpeedTest.py b/examples/VideoSpeedTest.py index d51798bd..0a20e9cf 100644 --- a/examples/VideoSpeedTest.py +++ b/examples/VideoSpeedTest.py @@ -61,37 +61,41 @@ ui.alphaCheck.toggled.connect(updateLUT) def updateScale(): global ui spins = [ui.minSpin1, ui.maxSpin1, ui.minSpin2, ui.maxSpin2, ui.minSpin3, ui.maxSpin3] - if ui.rgbCheck.isChecked(): + if ui.rgbLevelsCheck.isChecked(): for s in spins[2:]: s.setEnabled(True) else: for s in spins[2:]: s.setEnabled(False) -ui.rgbCheck.toggled.connect(updateScale) +ui.rgbLevelsCheck.toggled.connect(updateScale) cache = {} def mkData(): global data, cache, ui - dtype = ui.dtypeCombo.currentText() + dtype = (ui.dtypeCombo.currentText(), ui.rgbCheck.isChecked()) if dtype not in cache: - if dtype == 'uint8': + if dtype[0] == 'uint8': dt = np.uint8 loc = 128 scale = 64 mx = 255 - elif dtype == 'uint16': + elif dtype[0] == 'uint16': dt = np.uint16 loc = 4096 scale = 1024 mx = 2**16 - elif dtype == 'float': + elif dtype[0] == 'float': dt = np.float loc = 1.0 scale = 0.1 - data = np.random.normal(size=(20,512,512), loc=loc, scale=scale) - data = ndi.gaussian_filter(data, (0, 3, 3)) - if dtype != 'float': + if ui.rgbCheck.isChecked(): + data = np.random.normal(size=(20,512,512,3), loc=loc, scale=scale) + data = ndi.gaussian_filter(data, (0, 6, 6, 0)) + else: + data = np.random.normal(size=(20,512,512), loc=loc, scale=scale) + data = ndi.gaussian_filter(data, (0, 6, 6)) + if dtype[0] != 'float': data = np.clip(data, 0, mx) data = data.astype(dt) cache[dtype] = data @@ -100,7 +104,7 @@ def mkData(): updateLUT() mkData() ui.dtypeCombo.currentIndexChanged.connect(mkData) - +ui.rgbCheck.toggled.connect(mkData) ptr = 0 lastTime = ptime.time() @@ -113,7 +117,7 @@ def update(): useLut = None if ui.scaleCheck.isChecked(): - if ui.rgbCheck.isChecked(): + if ui.rgbLevelsCheck.isChecked(): useScale = [ [ui.minSpin1.value(), ui.maxSpin1.value()], [ui.minSpin2.value(), ui.maxSpin2.value()], diff --git a/examples/VideoTemplate.ui b/examples/VideoTemplate.ui index 078e7ccf..3dddb928 100644 --- a/examples/VideoTemplate.ui +++ b/examples/VideoTemplate.ui @@ -25,7 +25,6 @@ 0 - fpsLabel @@ -84,7 +83,7 @@ - + RGB @@ -218,6 +217,13 @@ + + + + RGB + + + diff --git a/examples/VideoTemplate_pyqt.py b/examples/VideoTemplate_pyqt.py index 21e66635..c3430e2d 100644 --- a/examples/VideoTemplate_pyqt.py +++ b/examples/VideoTemplate_pyqt.py @@ -2,7 +2,7 @@ # Form implementation generated from reading ui file './examples/VideoTemplate.ui' # -# Created: Sun Sep 9 14:41:31 2012 +# Created: Sun Nov 4 18:24:20 2012 # by: PyQt4 UI code generator 4.9.1 # # WARNING! All changes made in this file will be lost! @@ -55,9 +55,9 @@ class Ui_MainWindow(object): self.scaleCheck = QtGui.QCheckBox(self.centralwidget) self.scaleCheck.setObjectName(_fromUtf8("scaleCheck")) self.gridLayout_2.addWidget(self.scaleCheck, 3, 0, 1, 1) - self.rgbCheck = QtGui.QCheckBox(self.centralwidget) - self.rgbCheck.setObjectName(_fromUtf8("rgbCheck")) - self.gridLayout_2.addWidget(self.rgbCheck, 3, 1, 1, 1) + self.rgbLevelsCheck = QtGui.QCheckBox(self.centralwidget) + self.rgbLevelsCheck.setObjectName(_fromUtf8("rgbLevelsCheck")) + self.gridLayout_2.addWidget(self.rgbLevelsCheck, 3, 1, 1, 1) self.horizontalLayout = QtGui.QHBoxLayout() self.horizontalLayout.setObjectName(_fromUtf8("horizontalLayout")) self.minSpin1 = SpinBox(self.centralwidget) @@ -124,6 +124,9 @@ class Ui_MainWindow(object): self.fpsLabel.setAlignment(QtCore.Qt.AlignCenter) self.fpsLabel.setObjectName(_fromUtf8("fpsLabel")) self.gridLayout_2.addWidget(self.fpsLabel, 0, 0, 1, 4) + self.rgbCheck = QtGui.QCheckBox(self.centralwidget) + self.rgbCheck.setObjectName(_fromUtf8("rgbCheck")) + self.gridLayout_2.addWidget(self.rgbCheck, 2, 1, 1, 1) MainWindow.setCentralWidget(self.centralwidget) self.retranslateUi(MainWindow) @@ -138,12 +141,13 @@ class Ui_MainWindow(object): self.dtypeCombo.setItemText(1, QtGui.QApplication.translate("MainWindow", "uint16", None, QtGui.QApplication.UnicodeUTF8)) self.dtypeCombo.setItemText(2, QtGui.QApplication.translate("MainWindow", "float", None, QtGui.QApplication.UnicodeUTF8)) self.scaleCheck.setText(QtGui.QApplication.translate("MainWindow", "Scale Data", None, QtGui.QApplication.UnicodeUTF8)) - self.rgbCheck.setText(QtGui.QApplication.translate("MainWindow", "RGB", None, QtGui.QApplication.UnicodeUTF8)) + self.rgbLevelsCheck.setText(QtGui.QApplication.translate("MainWindow", "RGB", None, QtGui.QApplication.UnicodeUTF8)) self.label_2.setText(QtGui.QApplication.translate("MainWindow", "<--->", None, QtGui.QApplication.UnicodeUTF8)) self.label_3.setText(QtGui.QApplication.translate("MainWindow", "<--->", None, QtGui.QApplication.UnicodeUTF8)) self.label_4.setText(QtGui.QApplication.translate("MainWindow", "<--->", None, QtGui.QApplication.UnicodeUTF8)) self.lutCheck.setText(QtGui.QApplication.translate("MainWindow", "Use Lookup Table", None, QtGui.QApplication.UnicodeUTF8)) self.alphaCheck.setText(QtGui.QApplication.translate("MainWindow", "alpha", None, QtGui.QApplication.UnicodeUTF8)) self.fpsLabel.setText(QtGui.QApplication.translate("MainWindow", "FPS", None, QtGui.QApplication.UnicodeUTF8)) + self.rgbCheck.setText(QtGui.QApplication.translate("MainWindow", "RGB", None, QtGui.QApplication.UnicodeUTF8)) from pyqtgraph import SpinBox, GradientWidget, GraphicsView, RawImageWidget diff --git a/examples/VideoTemplate_pyside.py b/examples/VideoTemplate_pyside.py index 5cbce05c..d19e0f23 100644 --- a/examples/VideoTemplate_pyside.py +++ b/examples/VideoTemplate_pyside.py @@ -2,7 +2,7 @@ # Form implementation generated from reading ui file './examples/VideoTemplate.ui' # -# Created: Sun Sep 9 14:41:31 2012 +# Created: Sun Nov 4 18:24:21 2012 # by: pyside-uic 0.2.13 running on PySide 1.1.0 # # WARNING! All changes made in this file will be lost! @@ -50,9 +50,9 @@ class Ui_MainWindow(object): self.scaleCheck = QtGui.QCheckBox(self.centralwidget) self.scaleCheck.setObjectName("scaleCheck") self.gridLayout_2.addWidget(self.scaleCheck, 3, 0, 1, 1) - self.rgbCheck = QtGui.QCheckBox(self.centralwidget) - self.rgbCheck.setObjectName("rgbCheck") - self.gridLayout_2.addWidget(self.rgbCheck, 3, 1, 1, 1) + self.rgbLevelsCheck = QtGui.QCheckBox(self.centralwidget) + self.rgbLevelsCheck.setObjectName("rgbLevelsCheck") + self.gridLayout_2.addWidget(self.rgbLevelsCheck, 3, 1, 1, 1) self.horizontalLayout = QtGui.QHBoxLayout() self.horizontalLayout.setObjectName("horizontalLayout") self.minSpin1 = SpinBox(self.centralwidget) @@ -119,6 +119,9 @@ class Ui_MainWindow(object): self.fpsLabel.setAlignment(QtCore.Qt.AlignCenter) self.fpsLabel.setObjectName("fpsLabel") self.gridLayout_2.addWidget(self.fpsLabel, 0, 0, 1, 4) + self.rgbCheck = QtGui.QCheckBox(self.centralwidget) + self.rgbCheck.setObjectName("rgbCheck") + self.gridLayout_2.addWidget(self.rgbCheck, 2, 1, 1, 1) MainWindow.setCentralWidget(self.centralwidget) self.retranslateUi(MainWindow) @@ -133,12 +136,13 @@ class Ui_MainWindow(object): self.dtypeCombo.setItemText(1, QtGui.QApplication.translate("MainWindow", "uint16", None, QtGui.QApplication.UnicodeUTF8)) self.dtypeCombo.setItemText(2, QtGui.QApplication.translate("MainWindow", "float", None, QtGui.QApplication.UnicodeUTF8)) self.scaleCheck.setText(QtGui.QApplication.translate("MainWindow", "Scale Data", None, QtGui.QApplication.UnicodeUTF8)) - self.rgbCheck.setText(QtGui.QApplication.translate("MainWindow", "RGB", None, QtGui.QApplication.UnicodeUTF8)) + self.rgbLevelsCheck.setText(QtGui.QApplication.translate("MainWindow", "RGB", None, QtGui.QApplication.UnicodeUTF8)) self.label_2.setText(QtGui.QApplication.translate("MainWindow", "<--->", None, QtGui.QApplication.UnicodeUTF8)) self.label_3.setText(QtGui.QApplication.translate("MainWindow", "<--->", None, QtGui.QApplication.UnicodeUTF8)) self.label_4.setText(QtGui.QApplication.translate("MainWindow", "<--->", None, QtGui.QApplication.UnicodeUTF8)) self.lutCheck.setText(QtGui.QApplication.translate("MainWindow", "Use Lookup Table", None, QtGui.QApplication.UnicodeUTF8)) self.alphaCheck.setText(QtGui.QApplication.translate("MainWindow", "alpha", None, QtGui.QApplication.UnicodeUTF8)) self.fpsLabel.setText(QtGui.QApplication.translate("MainWindow", "FPS", None, QtGui.QApplication.UnicodeUTF8)) + self.rgbCheck.setText(QtGui.QApplication.translate("MainWindow", "RGB", None, QtGui.QApplication.UnicodeUTF8)) from pyqtgraph import SpinBox, GradientWidget, GraphicsView, RawImageWidget diff --git a/functions.py b/functions.py index 1342b527..4eafbbf3 100644 --- a/functions.py +++ b/functions.py @@ -434,8 +434,10 @@ def affineSlice(data, shape, origin, vectors, axes, order=1, returnCoords=False, ## make sure vectors are arrays - vectors = np.array(vectors) - origin = np.array(origin) + if not isinstance(vectors, np.ndarray): + vectors = np.array(vectors) + if not isinstance(origin, np.ndarray): + origin = np.array(origin) origin.shape = (len(axes),) + (1,)*len(shape) ## Build array of sample locations. @@ -580,171 +582,247 @@ def solveBilinearTransform(points1, points2): return matrix +def rescaleData(data, scale, offset, dtype=None): + """Return data rescaled and optionally cast to a new dtype:: + data => (data-offset) * scale + + Uses scipy.weave (if available) to improve performance. + """ + global USE_WEAVE + if dtype is None: + dtype = data.dtype + try: + if not USE_WEAVE: + raise Exception('Weave is disabled; falling back to slower version.') + + newData = np.empty((data.size,), dtype=dtype) + flat = np.ascontiguousarray(data).reshape(data.size) + size = data.size + + code = """ + double sc = (double)scale; + double off = (double)offset; + for( int i=0; i0 and max->*scale*:: - For 3D arrays (x, y, rgba): - * The third axis must have length 3 or 4 and will be interpreted as RGBA. - * The 'lut' argument is not allowed. + rescaled = (clip(data, min, max) - min) * (*scale* / (max - min)) - lut - Lookup table for 2D data. May be 1D or 2D (N,rgba) and must have dtype=ubyte. - Values in data will be converted to color by indexing directly from lut. - 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 (useful for building OpenGL textures). The default is - False, which returns in BGRA order for use with QImage. - + It is also possible to use a 2D (N,2) array of values for levels. In this case, + it is assumed that each pair of min,max values in the levels array should be + applied to a different subset of the input data (for example, the input data may + already have RGB values and the levels are used to independently scale each + channel). The use of this feature requires that levels.shape[0] == data.shape[-1]. + scale The maximum value to which data will be rescaled before being passed through the + lookup table (or returned if there is no lookup table). By default this will + be set to the length of the lookup table, or 256 is no lookup table is provided. + For OpenGL color specifications (as in GLColor4f) use scale=1.0 + lut Optional lookup table (array with dtype=ubyte). + Values in data will be converted to color by indexing directly from lut. + The output data shape will be input.shape + lut.shape[1:]. + + Note: the output of makeARGB will have the same dtype as the lookup table, so + for conversion to QImage, the dtype must be ubyte. + + Lookup tables can be built using GradientWidget. + useRGBA If True, the data is returned in RGBA order (useful for building OpenGL textures). + The default is False, which returns in ARGB order for use with QImage + (Note that 'ARGB' is a term used by the Qt documentation; the _actual_ order + is BGRA). + ============ ================================================================================== """ prof = debug.Profiler('functions.makeARGB', disabled=True) + if lut is not None and not isinstance(lut, np.ndarray): + lut = np.array(lut) + if levels is not None and not isinstance(levels, np.ndarray): + levels = np.array(levels) + ## sanity checks - if data.ndim == 3: - if data.shape[2] not in (3,4): - raise Exception("data.shape[2] must be 3 or 4") - #if lut is not None: - #raise Exception("can not use lookup table with 3D data") - elif data.ndim != 2: - raise Exception("data must be 2D or 3D") + #if data.ndim == 3: + #if data.shape[2] not in (3,4): + #raise Exception("data.shape[2] must be 3 or 4") + ##if lut is not None: + ##raise Exception("can not use lookup table with 3D data") + #elif data.ndim != 2: + #raise Exception("data must be 2D or 3D") - if lut is not None: - if lut.ndim == 2: - if lut.shape[1] not in (3,4): - raise Exception("lut.shape[1] must be 3 or 4") - elif lut.ndim != 1: - raise Exception("lut must be 1D or 2D") - if lut.dtype != np.ubyte: - raise Exception('lookup table must have dtype=ubyte (got %s instead)' % str(lut.dtype)) + #if lut is not None: + ##if lut.ndim == 2: + ##if lut.shape[1] : + ##raise Exception("lut.shape[1] must be 3 or 4") + ##elif lut.ndim != 1: + ##raise Exception("lut must be 1D or 2D") + #if lut.dtype != np.ubyte: + #raise Exception('lookup table must have dtype=ubyte (got %s instead)' % str(lut.dtype)) + if levels is not None: - levels = np.array(levels) - if levels.shape == (2,): - pass - elif levels.shape in [(3,2), (4,2)]: - if data.ndim == 3: - raise Exception("Can not use 2D levels with 3D data.") - if lut is not None: - raise Exception('Can not use 2D levels and lookup table together.') + if levels.ndim == 1: + if len(levels) != 2: + raise Exception('levels argument must have length 2') + elif levels.ndim == 2: + if lut is not None and lut.ndim > 1: + raise Exception('Cannot make ARGB data when bot levels and lut have ndim > 2') + if levels.shape != (data.shape[-1], 2): + raise Exception('levels must have shape (data.shape[-1], 2)') else: - raise Exception("Levels must have shape (2,) or (3,2) or (4,2)") + print levels + raise Exception("levels argument must be 1D or 2D.") + #levels = np.array(levels) + #if levels.shape == (2,): + #pass + #elif levels.shape in [(3,2), (4,2)]: + #if data.ndim == 3: + #raise Exception("Can not use 2D levels with 3D data.") + #if lut is not None: + #raise Exception('Can not use 2D levels and lookup table together.') + #else: + #raise Exception("Levels must have shape (2,) or (3,2) or (4,2)") prof.mark('1') - if lut is not None: - lutLength = lut.shape[0] - else: - lutLength = 256 - - ## weave requires contiguous arrays - global USE_WEAVE - if (levels is not None or lut is not None) and USE_WEAVE: - data = np.ascontiguousarray(data) + if scale is None: + if lut is not None: + scale = lut.shape[0] + else: + scale = 255. ## Apply levels if given if levels is not None: - try: ## use weave to speed up scaling - if not USE_WEAVE: - raise Exception('Weave is disabled; falling back to slower version.') - if levels.ndim == 1: - scale = float(lutLength) / (levels[1]-levels[0]) - offset = float(levels[0]) - data = rescaleData(data, scale, offset) - else: - if data.ndim == 2: - newData = np.empty(data.shape+(levels.shape[0],), dtype=np.uint32) - for i in range(levels.shape[0]): - scale = float(lutLength / (levels[i,1]-levels[i,0])) - offset = float(levels[i,0]) - newData[...,i] = rescaleData(data, scale, offset) - elif data.ndim == 3: - newData = np.empty(data.shape, dtype=np.uint32) - for i in range(data.shape[2]): - scale = float(lutLength / (levels[i,1]-levels[i,0])) - offset = float(levels[i,0]) - #print scale, offset, data.shape, newData.shape, levels.shape - newData[...,i] = rescaleData(data[...,i], scale, offset) - data = newData - except: - if USE_WEAVE: - debug.printExc("Error; disabling weave.") - USE_WEAVE = False - - if levels.ndim == 1: - if data.ndim == 2: - levels = levels[np.newaxis, np.newaxis, :] - else: - levels = levels[np.newaxis, np.newaxis, np.newaxis, :] - else: - levels = levels[np.newaxis, np.newaxis, ...] - if data.ndim == 2: - data = data[..., np.newaxis] - data = ((data-levels[...,0]) * lutLength) / (levels[...,1]-levels[...,0]) + if isinstance(levels, np.ndarray) and levels.ndim == 2: + ## we are going to rescale each channel independently + if levels.shape[0] != data.shape[-1]: + raise Exception("When rescaling multi-channel data, there must be the same number of levels as channels (data.shape[-1] == levels.shape[0])") + newData = np.empty(data.shape, dtype=int) + for i in range(data.shape[-1]): + minVal, maxVal = levels[i] + if minVal == maxVal: + maxVal += 1e-16 + newData[...,i] = rescaleData(data[...,i], scale/(maxVal-minVal), minVal, dtype=int) + data = newData + else: + minVal, maxVal = levels + if minVal == maxVal: + maxVal += 1e-16 + data = rescaleData(data, scale/(maxVal-minVal), minVal, dtype=int) prof.mark('2') ## apply LUT if given - if lut is not None and data.ndim == 2: - - if data.dtype.kind not in ('i', 'u'): - data = data.astype(int) - - data = np.clip(data, 0, lutLength-1) - try: - if not USE_WEAVE: - raise Exception('Weave is disabled; falling back to slower version.') - - newData = np.empty((data.size,) + lut.shape[1:], dtype=np.uint8) - flat = data.reshape(data.size) - size = data.size - ncol = lut.shape[1] - newStride = newData.strides[0] - newColStride = newData.strides[1] - lutStride = lut.strides[0] - lutColStride = lut.strides[1] - flatStride = flat.strides[0] / flat.dtype.itemsize - - #print "newData:", newData.shape, newData.dtype - #print "flat:", flat.shape, flat.dtype, flat.min(), flat.max() - #print "lut:", lut.shape, lut.dtype - #print "size:", size, "ncols:", ncol - #print "strides:", newStride, newColStride, lutStride, lutColStride, flatStride - - code = """ - - for( int i=0; i Date: Fri, 23 Nov 2012 16:05:14 -0500 Subject: [PATCH 2/3] New features for LegendItem: - Can be anchored to parent item at any location - Support for filled plot styles - Automatically resizes to fit contents - PlotItem can auto-generate legend --- examples/Legend.py | 13 ++--- graphicsItems/GraphicsWidgetAnchor.py | 63 ++++++++++++++++++++++++ graphicsItems/LegendItem.py | 69 +++++++++++++++++++++++---- graphicsItems/PlotCurveItem.py | 4 ++ graphicsItems/PlotDataItem.py | 2 + graphicsItems/PlotItem/PlotItem.py | 14 ++++++ 6 files changed, 151 insertions(+), 14 deletions(-) create mode 100644 graphicsItems/GraphicsWidgetAnchor.py diff --git a/examples/Legend.py b/examples/Legend.py index f615a6eb..af6fce8c 100644 --- a/examples/Legend.py +++ b/examples/Legend.py @@ -6,13 +6,14 @@ 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 +plt.addLegend() +#l = pg.LegendItem((100,60), offset=(70,30)) # args are (size, offset) +#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') +c1 = plt.plot([1,3,2,4], pen='r', name='red plot') +c2 = plt.plot([2,1,4,3], pen='g', fillLevel=0, fillBrush=(255,255,255,30), name='green plot') +#l.addItem(c1, 'red plot') +#l.addItem(c2, 'green plot') ## Start Qt event loop unless running in interactive mode or using pyside. diff --git a/graphicsItems/GraphicsWidgetAnchor.py b/graphicsItems/GraphicsWidgetAnchor.py new file mode 100644 index 00000000..b71e781a --- /dev/null +++ b/graphicsItems/GraphicsWidgetAnchor.py @@ -0,0 +1,63 @@ +from ..Qt import QtGui, QtCore +from ..Point import Point + + +class GraphicsWidgetAnchor: + """ + Class used to allow GraphicsWidgets to anchor to a specific position on their + parent. + + """ + + def __init__(self): + self.__parent = None + self.__parentAnchor = None + self.__itemAnchor = None + self.__offset = (0,0) + if hasattr(self, 'geometryChanged'): + self.geometryChanged.connect(self.__geometryChanged) + + def anchor(self, itemPos, parentPos, offset=(0,0)): + """ + Anchors the item at its local itemPos to the item's parent at parentPos. + Both positions are expressed in values relative to the size of the item or parent; + a value of 0 indicates left or top edge, while 1 indicates right or bottom edge. + + Optionally, offset may be specified to introduce an absolute offset. + + Example: anchor a box such that its upper-right corner is fixed 10px left + and 10px down from its parent's upper-right corner:: + + box.anchor(itemPos=(1,0), parentPos=(1,0), offset=(-10,10)) + """ + parent = self.parentItem() + if parent is None: + raise Exception("Cannot anchor; parent is not set.") + + if self.__parent is not parent: + if self.__parent is not None: + self.__parent.geometryChanged.disconnect(self.__geometryChanged) + + self.__parent = parent + parent.geometryChanged.connect(self.__geometryChanged) + + self.__itemAnchor = itemPos + self.__parentAnchor = parentPos + self.__offset = offset + self.__geometryChanged() + + def __geometryChanged(self): + if self.__parent is None: + return + if self.__itemAnchor is None: + return + + o = self.mapToParent(Point(0,0)) + a = self.boundingRect().bottomRight() * Point(self.__itemAnchor) + a = self.mapToParent(a) + p = self.__parent.boundingRect().bottomRight() * Point(self.__parentAnchor) + off = Point(self.__offset) + pos = p + (o-a) + off + self.setPos(pos) + + \ No newline at end of file diff --git a/graphicsItems/LegendItem.py b/graphicsItems/LegendItem.py index a41201e1..14c690a5 100644 --- a/graphicsItems/LegendItem.py +++ b/graphicsItems/LegendItem.py @@ -2,12 +2,14 @@ from .GraphicsWidget import GraphicsWidget from .LabelItem import LabelItem from ..Qt import QtGui, QtCore from .. import functions as fn - +from ..Point import Point +from .GraphicsWidgetAnchor import GraphicsWidgetAnchor __all__ = ['LegendItem'] -class LegendItem(GraphicsWidget): +class LegendItem(GraphicsWidget, GraphicsWidgetAnchor): """ Displays a legend used for describing the contents of a plot. + LegendItems are most commonly created by calling PlotItem.addLegend(). Note that this item should not be added directly to a PlotItem. Instead, Make it a direct descendant of the PlotItem:: @@ -15,17 +17,45 @@ class LegendItem(GraphicsWidget): legend.setParentItem(plotItem) """ - def __init__(self, size, offset): + def __init__(self, size=None, offset=None): + """ + ========== =============================================================== + Arguments + size Specifies the fixed size (width, height) of the legend. If + this argument is omitted, the legend will autimatically resize + to fit its contents. + offset Specifies the offset position relative to the legend's parent. + Positive values offset from the left or top; negative values + offset from the right or bottom. If offset is None, the + legend must be anchored manually by calling anchor() or + positioned by calling setPos(). + ========== =============================================================== + + """ + + GraphicsWidget.__init__(self) + GraphicsWidgetAnchor.__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])) + if size is not None: + self.setGeometry(QtCore.QRectF(0, 0, self.size[0], self.size[1])) - def addItem(self, item, title): + def setParentItem(self, p): + ret = GraphicsWidget.setParentItem(self, p) + if self.offset is not None: + offset = Point(self.offset) + anchorx = 1 if offset[0] <= 0 else 0 + anchory = 1 if offset[1] <= 0 else 0 + anchor = (anchorx, anchory) + self.anchor(itemPos=anchor, parentPos=anchor, offset=offset) + return ret + + def addItem(self, item, name): """ Add a new entry to the legend. @@ -36,15 +66,30 @@ class LegendItem(GraphicsWidget): title The title to display for this item. Simple HTML allowed. =========== ======================================================== """ - label = LabelItem(title) + label = LabelItem(name) sample = ItemSample(item) row = len(self.items) self.items.append((sample, label)) self.layout.addItem(sample, row, 0) self.layout.addItem(label, row, 1) + self.updateSize() + + def updateSize(self): + if self.size is not None: + return + + height = 0 + width = 0 + print "-------" + for sample, label in self.items: + height += max(sample.height(), label.height()) + 3 + width = max(width, sample.width()+label.width()) + print width, height + print width, height + self.setGeometry(0, 0, width+25, height) def boundingRect(self): - return QtCore.QRectF(0, 0, self.size[0], self.size[1]) + return QtCore.QRectF(0, 0, self.width(), self.height()) def paint(self, p, *args): p.setPen(fn.mkPen(255,255,255,100)) @@ -61,8 +106,16 @@ class ItemSample(GraphicsWidget): return QtCore.QRectF(0, 0, 20, 20) def paint(self, p, *args): - p.setPen(fn.mkPen(self.item.opts['pen'])) + opts = self.item.opts + + if opts.get('fillLevel',None) is not None and opts.get('fillBrush',None) is not None: + p.setBrush(fn.mkBrush(opts['fillBrush'])) + p.setPen(fn.mkPen(None)) + p.drawPolygon(QtGui.QPolygonF([QtCore.QPointF(2,18), QtCore.QPointF(18,2), QtCore.QPointF(18,18)])) + + p.setPen(fn.mkPen(opts['pen'])) p.drawLine(2, 18, 18, 2) + diff --git a/graphicsItems/PlotCurveItem.py b/graphicsItems/PlotCurveItem.py index 17ee4566..d267f58e 100644 --- a/graphicsItems/PlotCurveItem.py +++ b/graphicsItems/PlotCurveItem.py @@ -65,6 +65,7 @@ class PlotCurveItem(GraphicsObject): 'fillLevel': None, 'brush': None, 'stepMode': False, + 'name': None } self.setClickable(kargs.get('clickable', False)) self.setData(*args, **kargs) @@ -238,6 +239,9 @@ class PlotCurveItem(GraphicsObject): self.fillPath = None #self.xDisp = self.yDisp = None + if 'name' in kargs: + self.opts['name'] = kargs['name'] + if 'pen' in kargs: self.setPen(kargs['pen']) if 'shadowPen' in kargs: diff --git a/graphicsItems/PlotDataItem.py b/graphicsItems/PlotDataItem.py index ce5b11aa..58158905 100644 --- a/graphicsItems/PlotDataItem.py +++ b/graphicsItems/PlotDataItem.py @@ -317,6 +317,8 @@ class PlotDataItem(GraphicsObject): ## pull in all style arguments. ## Use self.opts to fill in anything not present in kargs. + if 'name' in kargs: + self.opts['name'] = kargs['name'] ## if symbol pen/brush are given with no symbol, then assume symbol is 'o' diff --git a/graphicsItems/PlotItem/PlotItem.py b/graphicsItems/PlotItem/PlotItem.py index 3177a176..8281f031 100644 --- a/graphicsItems/PlotItem/PlotItem.py +++ b/graphicsItems/PlotItem/PlotItem.py @@ -33,6 +33,7 @@ from .. PlotDataItem import PlotDataItem from .. ViewBox import ViewBox from .. AxisItem import AxisItem from .. LabelItem import LabelItem +from .. LegendItem import LegendItem from .. GraphicsWidget import GraphicsWidget from .. ButtonItem import ButtonItem from pyqtgraph.WidgetGroup import WidgetGroup @@ -528,6 +529,9 @@ class PlotItem(GraphicsWidget): #c.connect(c, QtCore.SIGNAL('plotChanged'), self.plotChanged) #item.sigPlotChanged.connect(self.plotChanged) #self.plotChanged() + name = kargs.get('name', getattr(item, 'opts', {}).get('name', None)) + if name is not None and self.legend is not None: + self.legend.addItem(item, name=name) def addDataItem(self, item, *args): @@ -596,6 +600,16 @@ class PlotItem(GraphicsWidget): return item + def addLegend(self, size=None, offset=(30, 30)): + """ + Create a new LegendItem and anchor it over the internal ViewBox. + Plots will be automatically displayed in the legend if they + are created with the 'name' argument. + """ + self.legend = LegendItem(size, offset) + self.legend.setParentItem(self.vb) + return self.legend + def scatterPlot(self, *args, **kargs): if 'pen' in kargs: kargs['symbolPen'] = kargs['pen'] From aca9c8310fa3a64c6836772f7691c500081a43e2 Mon Sep 17 00:00:00 2001 From: Luke Campagnola <> Date: Fri, 23 Nov 2012 17:34:22 -0500 Subject: [PATCH 3/3] Major overhaul for GLMeshItem, MeshData classes [ Note: These APIs have changed significantly. ] - MeshData and GLMeshItem now operate on numpy arrays instead of lists. - MeshData can handle per-vertex and per-triangle color information Added GLSurfacePlotItem class based on new GLMeshItem GLGraphicsItem now has per-item support for customizing GL state (setGLOptions method) Added several new shader programs Added new examples: GLIsosurface GLSurfacePlot GLshaders --- examples/GLIsosurface.py | 72 ++++ examples/GLMeshItem.py | 134 ++++++-- examples/GLSurfacePlot.py | 98 ++++++ examples/GLshaders.py | 108 ++++++ examples/__main__.py | 10 +- opengl/GLGraphicsItem.py | 91 ++++- opengl/GLViewWidget.py | 20 +- opengl/MeshData.py | 530 +++++++++++++++++++++++------- opengl/items/GLGridItem.py | 11 +- opengl/items/GLMeshItem.py | 202 +++++++++--- opengl/items/GLScatterPlotItem.py | 30 +- opengl/items/GLSurfacePlotItem.py | 139 ++++++++ opengl/items/GLVolumeItem.py | 12 +- opengl/shaders.py | 323 ++++++++++++++++-- 14 files changed, 1532 insertions(+), 248 deletions(-) create mode 100644 examples/GLIsosurface.py create mode 100644 examples/GLSurfacePlot.py create mode 100644 examples/GLshaders.py create mode 100644 opengl/items/GLSurfacePlotItem.py diff --git a/examples/GLIsosurface.py b/examples/GLIsosurface.py new file mode 100644 index 00000000..c4f06adb --- /dev/null +++ b/examples/GLIsosurface.py @@ -0,0 +1,72 @@ +# -*- coding: utf-8 -*- + +## This example uses the isosurface function to convert a scalar field +## (a hydrogen orbital) into a mesh for 3D display. + +## 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 as pg +import pyqtgraph.opengl as gl + +app = QtGui.QApplication([]) +w = gl.GLViewWidget() +w.show() + +w.setCameraPosition(distance=40) + +g = gl.GLGridItem() +g.scale(2,2,1) +w.addItem(g) + +import numpy as np + +## Define a scalar field from which we will generate an isosurface +def psi(i, j, k, offset=(25, 25, 50)): + x = i-offset[0] + y = j-offset[1] + z = k-offset[2] + th = np.arctan2(z, (x**2+y**2)**0.5) + phi = np.arctan2(y, x) + r = (x**2 + y**2 + z **2)**0.5 + a0 = 1 + #ps = (1./81.) * (2./np.pi)**0.5 * (1./a0)**(3/2) * (6 - r/a0) * (r/a0) * np.exp(-r/(3*a0)) * np.cos(th) + ps = (1./81.) * 1./(6.*np.pi)**0.5 * (1./a0)**(3/2) * (r/a0)**2 * np.exp(-r/(3*a0)) * (3 * np.cos(th)**2 - 1) + + return ps + + #return ((1./81.) * (1./np.pi)**0.5 * (1./a0)**(3/2) * (r/a0)**2 * (r/a0) * np.exp(-r/(3*a0)) * np.sin(th) * np.cos(th) * np.exp(2 * 1j * phi))**2 + + +print("Generating scalar field..") +data = np.abs(np.fromfunction(psi, (50,50,100))) + + +print("Generating isosurface..") +verts = pg.isosurface(data, data.max()/4.) + +md = gl.MeshData.MeshData(vertexes=verts) + +colors = np.ones((md.faceCount(), 4), dtype=float) +colors[:,3] = 0.2 +colors[:,2] = np.linspace(0, 1, colors.shape[0]) +md.setFaceColors(colors) +m1 = gl.GLMeshItem(meshdata=md, smooth=False, shader='balloon') +m1.setGLOptions('additive') + +#w.addItem(m1) +m1.translate(-25, -25, -20) + +m2 = gl.GLMeshItem(meshdata=md, smooth=True, shader='balloon') +m2.setGLOptions('additive') + +w.addItem(m2) +m2.translate(-25, -25, -50) + + + +## Start Qt event loop unless running in interactive mode. +if sys.flags.interactive != 1: + app.exec_() diff --git a/examples/GLMeshItem.py b/examples/GLMeshItem.py index 15d6bc33..049ad53d 100644 --- a/examples/GLMeshItem.py +++ b/examples/GLMeshItem.py @@ -1,7 +1,8 @@ # -*- coding: utf-8 -*- +""" +Simple examples demonstrating the use of GLMeshItem. -## This example uses the isosurface function to convert a scalar field -## (a hydrogen orbital) into a mesh for 3D display. +""" ## Add path to library (just for examples; you do not need this) import sys, os @@ -15,52 +16,117 @@ app = QtGui.QApplication([]) w = gl.GLViewWidget() w.show() +w.setCameraPosition(distance=40) + g = gl.GLGridItem() g.scale(2,2,1) w.addItem(g) import numpy as np -def psi(i, j, k, offset=(25, 25, 50)): - x = i-offset[0] - y = j-offset[1] - z = k-offset[2] - th = np.arctan2(z, (x**2+y**2)**0.5) - phi = np.arctan2(y, x) - r = (x**2 + y**2 + z **2)**0.5 - a0 = 1 - #ps = (1./81.) * (2./np.pi)**0.5 * (1./a0)**(3/2) * (6 - r/a0) * (r/a0) * np.exp(-r/(3*a0)) * np.cos(th) - ps = (1./81.) * 1./(6.*np.pi)**0.5 * (1./a0)**(3/2) * (r/a0)**2 * np.exp(-r/(3*a0)) * (3 * np.cos(th)**2 - 1) + +## Example 1: +## Array of vertex positions and array of vertex indexes defining faces +## Colors are specified per-face + +verts = np.array([ + [0, 0, 0], + [2, 0, 0], + [1, 2, 0], + [1, 1, 1], +]) +faces = np.array([ + [0, 1, 2], + [0, 1, 3], + [0, 2, 3], + [1, 2, 3] +]) +colors = np.array([ + [1, 0, 0, 0.3], + [0, 1, 0, 0.3], + [0, 0, 1, 0.3], + [1, 1, 0, 0.3] +]) + +## Mesh item will automatically compute face normals. +m1 = gl.GLMeshItem(vertexes=verts, faces=faces, faceColors=colors, smooth=False) +m1.translate(5, 5, 0) +m1.setGLOptions('additive') +w.addItem(m1) + +## Example 2: +## Array of vertex positions, three per face +## Colors are specified per-vertex + +verts = verts[faces] ## Same mesh geometry as example 2, but now we are passing in 12 vertexes +colors = np.random.random(size=(verts.shape[0], 3, 4)) +#colors[...,3] = 1.0 + +m2 = gl.GLMeshItem(vertexes=verts, vertexColors=colors, smooth=False, shader='balloon') +m2.translate(-5, 5, 0) +w.addItem(m2) + + +## Example 3: +## icosahedron + +md = gl.MeshData.sphere(rows=10, cols=20) +#colors = np.random.random(size=(md.faceCount(), 4)) +#colors[:,3] = 0.3 +#colors[100:] = 0.0 +colors = np.ones((md.faceCount(), 4), dtype=float) +colors[::2,0] = 0 +colors[:,1] = np.linspace(0, 1, colors.shape[0]) +md.setFaceColors(colors) +m3 = gl.GLMeshItem(meshdata=md, smooth=False)#, shader='balloon') + +#m3.translate(-5, -5, 0) +w.addItem(m3) + + + + + +#def psi(i, j, k, offset=(25, 25, 50)): + #x = i-offset[0] + #y = j-offset[1] + #z = k-offset[2] + #th = np.arctan2(z, (x**2+y**2)**0.5) + #phi = np.arctan2(y, x) + #r = (x**2 + y**2 + z **2)**0.5 + #a0 = 1 + ##ps = (1./81.) * (2./np.pi)**0.5 * (1./a0)**(3/2) * (6 - r/a0) * (r/a0) * np.exp(-r/(3*a0)) * np.cos(th) + #ps = (1./81.) * 1./(6.*np.pi)**0.5 * (1./a0)**(3/2) * (r/a0)**2 * np.exp(-r/(3*a0)) * (3 * np.cos(th)**2 - 1) - return ps + #return ps - #return ((1./81.) * (1./np.pi)**0.5 * (1./a0)**(3/2) * (r/a0)**2 * (r/a0) * np.exp(-r/(3*a0)) * np.sin(th) * np.cos(th) * np.exp(2 * 1j * phi))**2 + ##return ((1./81.) * (1./np.pi)**0.5 * (1./a0)**(3/2) * (r/a0)**2 * (r/a0) * np.exp(-r/(3*a0)) * np.sin(th) * np.cos(th) * np.exp(2 * 1j * phi))**2 -print("Generating scalar field..") -data = np.abs(np.fromfunction(psi, (50,50,100))) +#print("Generating scalar field..") +#data = np.abs(np.fromfunction(psi, (50,50,100))) -#data = np.fromfunction(lambda i,j,k: np.sin(0.2*((i-25)**2+(j-15)**2+k**2)**0.5), (50,50,50)); -print("Generating isosurface..") -faces = pg.isosurface(data, data.max()/4.) -m = gl.GLMeshItem(faces) -w.addItem(m) -m.translate(-25, -25, -50) +##data = np.fromfunction(lambda i,j,k: np.sin(0.2*((i-25)**2+(j-15)**2+k**2)**0.5), (50,50,50)); +#print("Generating isosurface..") +#verts = pg.isosurface(data, data.max()/4.) + +#md = gl.MeshData.MeshData(vertexes=verts) + +#colors = np.ones((md.vertexes(indexed='faces').shape[0], 4), dtype=float) +#colors[:,3] = 0.3 +#colors[:,2] = np.linspace(0, 1, colors.shape[0]) +#m1 = gl.GLMeshItem(meshdata=md, color=colors, smooth=False) + +#w.addItem(m1) +#m1.translate(-25, -25, -20) + +#m2 = gl.GLMeshItem(vertexes=verts, color=colors, smooth=True) + +#w.addItem(m2) +#m2.translate(-25, -25, -50) - -#data = np.zeros((5,5,5)) -#data[2,2,1:4] = 1 -#data[2,1:4,2] = 1 -#data[1:4,2,2] = 1 -#tr.translate(-2.5, -2.5, 0) -#data = np.ones((2,2,2)) -#data[0, 1, 0] = 0 -#faces = pg.isosurface(data, 0.5) -#m = gl.GLMeshItem(faces) -#w.addItem(m) -#m.setTransform(tr) ## Start Qt event loop unless running in interactive mode. if sys.flags.interactive != 1: diff --git a/examples/GLSurfacePlot.py b/examples/GLSurfacePlot.py new file mode 100644 index 00000000..d930fff7 --- /dev/null +++ b/examples/GLSurfacePlot.py @@ -0,0 +1,98 @@ +# -*- coding: utf-8 -*- +""" +This example demonstrates the use of GLSurfacePlotItem. +""" + + +## 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 as pg +import pyqtgraph.opengl as gl +import scipy.ndimage as ndi +import numpy as np + +## Create a GL View widget to display data +app = QtGui.QApplication([]) +w = gl.GLViewWidget() +w.show() +w.setCameraPosition(distance=50) + +## Add a grid to the view +g = gl.GLGridItem() +g.scale(2,2,1) +g.setDepthValue(10) # draw grid after surfaces since they may be translucent +w.addItem(g) + + +## Simple surface plot example +## x, y values are not specified, so assumed to be 0:50 +z = ndi.gaussian_filter(np.random.normal(size=(50,50)), (1,1)) +p1 = gl.GLSurfacePlotItem(z=z, shader='shaded', color=(0.5, 0.5, 1, 1)) +p1.scale(16./49., 16./49., 1.0) +p1.translate(-18, 2, 0) +w.addItem(p1) + + +## Saddle example with x and y specified +x = np.linspace(-8, 8, 50) +y = np.linspace(-8, 8, 50) +z = 0.1 * ((x.reshape(50,1) ** 2) - (y.reshape(1,50) ** 2)) +p2 = gl.GLSurfacePlotItem(x=x, y=y, z=z, shader='normalColor') +p2.translate(-10,-10,0) +w.addItem(p2) + + +## Manually specified colors +z = ndi.gaussian_filter(np.random.normal(size=(50,50)), (1,1)) +x = np.linspace(-12, 12, 50) +y = np.linspace(-12, 12, 50) +colors = np.ones((50,50,4), dtype=float) +colors[...,0] = np.clip(np.cos(((x.reshape(50,1) ** 2) + (y.reshape(1,50) ** 2)) ** 0.5), 0, 1) +colors[...,1] = colors[...,0] + +p3 = gl.GLSurfacePlotItem(z=z, colors=colors.reshape(50*50,4), shader='shaded', smooth=False) +p3.scale(16./49., 16./49., 1.0) +p3.translate(2, -18, 0) +w.addItem(p3) + + + + +## Animated example +## compute surface vertex data +cols = 100 +rows = 100 +x = np.linspace(-8, 8, cols+1).reshape(cols+1,1) +y = np.linspace(-8, 8, rows+1).reshape(1,rows+1) +d = (x**2 + y**2) * 0.1 +d2 = d ** 0.5 + 0.1 + +## precompute height values for all frames +phi = np.arange(0, np.pi*2, np.pi/20.) +z = np.sin(d[np.newaxis,...] + phi.reshape(phi.shape[0], 1, 1)) / d2[np.newaxis,...] + + +## create a surface plot, tell it to use the 'heightColor' shader +## since this does not require normal vectors to render (thus we +## can set computeNormals=False to save time when the mesh updates) +p4 = gl.GLSurfacePlotItem(x=x[:,0], y = y[0,:], shader='heightColor', computeNormals=False, smooth=False) +p4.shader()['colorMap'] = np.array([0.2, 2, 0.5, 0.2, 1, 1, 0.2, 0, 2]) +p4.translate(10, 10, 0) +w.addItem(p4) + +index = 0 +def update(): + global p4, z, index + index -= 1 + p4.setData(z=z[index%z.shape[0]]) + +timer = QtCore.QTimer() +timer.timeout.connect(update) +timer.start(30) + +## Start Qt event loop unless running in interactive mode. +if sys.flags.interactive != 1: + app.exec_() diff --git a/examples/GLshaders.py b/examples/GLshaders.py new file mode 100644 index 00000000..616b8b4c --- /dev/null +++ b/examples/GLshaders.py @@ -0,0 +1,108 @@ +# -*- coding: utf-8 -*- +""" +Demonstration of some of the shader programs included with pyqtgraph. +""" + + + +## 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 as pg +import pyqtgraph.opengl as gl + +app = QtGui.QApplication([]) +w = gl.GLViewWidget() +w.show() + +w.setCameraPosition(distance=15, azimuth=-90) + +g = gl.GLGridItem() +g.scale(2,2,1) +w.addItem(g) + +import numpy as np + + +md = gl.MeshData.sphere(rows=10, cols=20) +x = np.linspace(-8, 8, 6) + +m1 = gl.GLMeshItem(meshdata=md, smooth=True, color=(1, 0, 0, 0.2), shader='balloon', glOptions='additive') +m1.translate(x[0], 0, 0) +m1.scale(1, 1, 2) +w.addItem(m1) + +m2 = gl.GLMeshItem(meshdata=md, smooth=True, shader='normalColor', glOptions='opaque') +m2.translate(x[1], 0, 0) +m2.scale(1, 1, 2) +w.addItem(m2) + +m3 = gl.GLMeshItem(meshdata=md, smooth=True, shader='viewNormalColor', glOptions='opaque') +m3.translate(x[2], 0, 0) +m3.scale(1, 1, 2) +w.addItem(m3) + +m4 = gl.GLMeshItem(meshdata=md, smooth=True, shader='shaded', glOptions='opaque') +m4.translate(x[3], 0, 0) +m4.scale(1, 1, 2) +w.addItem(m4) + +m5 = gl.GLMeshItem(meshdata=md, smooth=True, color=(1, 0, 0, 1), shader='edgeHilight', glOptions='opaque') +m5.translate(x[4], 0, 0) +m5.scale(1, 1, 2) +w.addItem(m5) + +m6 = gl.GLMeshItem(meshdata=md, smooth=True, color=(1, 0, 0, 1), shader='heightColor', glOptions='opaque') +m6.translate(x[5], 0, 0) +m6.scale(1, 1, 2) +w.addItem(m6) + + + + +#def psi(i, j, k, offset=(25, 25, 50)): + #x = i-offset[0] + #y = j-offset[1] + #z = k-offset[2] + #th = np.arctan2(z, (x**2+y**2)**0.5) + #phi = np.arctan2(y, x) + #r = (x**2 + y**2 + z **2)**0.5 + #a0 = 1 + ##ps = (1./81.) * (2./np.pi)**0.5 * (1./a0)**(3/2) * (6 - r/a0) * (r/a0) * np.exp(-r/(3*a0)) * np.cos(th) + #ps = (1./81.) * 1./(6.*np.pi)**0.5 * (1./a0)**(3/2) * (r/a0)**2 * np.exp(-r/(3*a0)) * (3 * np.cos(th)**2 - 1) + + #return ps + + ##return ((1./81.) * (1./np.pi)**0.5 * (1./a0)**(3/2) * (r/a0)**2 * (r/a0) * np.exp(-r/(3*a0)) * np.sin(th) * np.cos(th) * np.exp(2 * 1j * phi))**2 + + +#print("Generating scalar field..") +#data = np.abs(np.fromfunction(psi, (50,50,100))) + + +##data = np.fromfunction(lambda i,j,k: np.sin(0.2*((i-25)**2+(j-15)**2+k**2)**0.5), (50,50,50)); +#print("Generating isosurface..") +#verts = pg.isosurface(data, data.max()/4.) + +#md = gl.MeshData.MeshData(vertexes=verts) + +#colors = np.ones((md.vertexes(indexed='faces').shape[0], 4), dtype=float) +#colors[:,3] = 0.3 +#colors[:,2] = np.linspace(0, 1, colors.shape[0]) +#m1 = gl.GLMeshItem(meshdata=md, color=colors, smooth=False) + +#w.addItem(m1) +#m1.translate(-25, -25, -20) + +#m2 = gl.GLMeshItem(vertexes=verts, color=colors, smooth=True) + +#w.addItem(m2) +#m2.translate(-25, -25, -50) + + + +## Start Qt event loop unless running in interactive mode. +if sys.flags.interactive != 1: + app.exec_() diff --git a/examples/__main__.py b/examples/__main__.py index 3035ddcf..9bd96d8a 100644 --- a/examples/__main__.py +++ b/examples/__main__.py @@ -43,9 +43,12 @@ examples = OrderedDict([ ])), ('3D Graphics', OrderedDict([ ('Volumetric', 'GLVolumeItem.py'), - ('Isosurface', 'GLMeshItem.py'), - ('Image', 'GLImageItem.py'), + ('Isosurface', 'GLIsosurface.py'), + ('Surface Plot', 'GLSurfacePlot.py'), ('Scatter Plot', 'GLScatterPlotItem.py'), + ('Shaders', 'GLshaders.py'), + ('Mesh', 'GLMeshItem.py'), + ('Image', 'GLImageItem.py'), ])), ('Widgets', OrderedDict([ ('PlotWidget', 'PlotWidget.py'), @@ -127,9 +130,8 @@ class ExampleLoader(QtGui.QMainWindow): if fn is None: return if sys.platform.startswith('win'): - os.spawnl(os.P_NOWAIT, sys.executable, sys.executable, '"' + fn + '"', *extra) + os.spawnl(os.P_NOWAIT, sys.executable, '"'+sys.executable+'"', '"' + fn + '"', *extra) else: - os.spawnl(os.P_NOWAIT, sys.executable, sys.executable, fn, *extra) diff --git a/opengl/GLGraphicsItem.py b/opengl/GLGraphicsItem.py index 7d1cf70b..1ed757b0 100644 --- a/opengl/GLGraphicsItem.py +++ b/opengl/GLGraphicsItem.py @@ -1,5 +1,31 @@ from pyqtgraph.Qt import QtGui, QtCore from pyqtgraph import Transform3D +from OpenGL.GL import * +from OpenGL import GL + +GLOptions = { + 'opaque': { + GL_DEPTH_TEST: True, + GL_BLEND: False, + GL_ALPHA_TEST: False, + GL_CULL_FACE: False, + }, + 'translucent': { + GL_DEPTH_TEST: True, + GL_BLEND: True, + GL_ALPHA_TEST: False, + GL_CULL_FACE: False, + 'glBlendFunc': (GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA), + }, + 'additive': { + GL_DEPTH_TEST: False, + GL_BLEND: True, + GL_ALPHA_TEST: False, + GL_CULL_FACE: False, + 'glBlendFunc': (GL_SRC_ALPHA, GL_ONE), + }, +} + class GLGraphicsItem(QtCore.QObject): def __init__(self, parentItem=None): @@ -11,6 +37,7 @@ class GLGraphicsItem(QtCore.QObject): self.__visible = True self.setParentItem(parentItem) self.setDepthValue(0) + self.__glOpts = {} def setParentItem(self, item): if self.__parent is not None: @@ -23,7 +50,52 @@ class GLGraphicsItem(QtCore.QObject): if self.view() is not None: self.view().removeItem(self) self.__parent.view().addItem(self) + + def setGLOptions(self, opts): + """ + Set the OpenGL state options to use immediately before drawing this item. + (Note that subclasses must call setupGLState before painting for this to work) + The simplest way to invoke this method is to pass in the name of + a predefined set of options (see the GLOptions variable): + + ============= ====================================================== + opaque Enables depth testing and disables blending + translucent Enables depth testing and blending + Elements must be drawn sorted back-to-front for + translucency to work correctly. + additive Disables depth testing, enables blending. + Colors are added together, so sorting is not required. + ============= ====================================================== + + It is also possible to specify any arbitrary settings as a dictionary. + This may consist of {'functionName': (args...)} pairs where functionName must + be a callable attribute of OpenGL.GL, or {GL_STATE_VAR: bool} pairs + which will be interpreted as calls to glEnable or glDisable(GL_STATE_VAR). + + For example:: + + { + GL_ALPHA_TEST: True, + GL_CULL_FACE: False, + 'glBlendFunc': (GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA), + } + + + """ + if isinstance(opts, basestring): + opts = GLOptions[opts] + self.__glOpts = opts.copy() + + def updateGLOptions(self, opts): + """ + Modify the OpenGL state options to use immediately before drawing this item. + *opts* must be a dictionary as specified by setGLOptions. + Values may also be None, in which case the key will be ignored. + """ + self.__glOpts.update(opts) + + def parentItem(self): return self.__parent @@ -135,13 +207,30 @@ class GLGraphicsItem(QtCore.QObject): """ pass + def setupGLState(self): + """ + This method is responsible for preparing the GL state options needed to render + this item (blending, depth testing, etc). The method is called immediately before painting the item. + """ + for k,v in self.__glOpts.items(): + if v is None: + continue + if isinstance(k, basestring): + func = getattr(GL, k) + func(*v) + else: + if v is True: + glEnable(k) + else: + glDisable(k) + def paint(self): """ Called by the GLViewWidget to draw this item. It is the responsibility of the item to set up its own modelview matrix, but the caller will take care of pushing/popping. """ - pass + self.setupGLState() def update(self): v = self.view() diff --git a/opengl/GLViewWidget.py b/opengl/GLViewWidget.py index 6911d849..37d42072 100644 --- a/opengl/GLViewWidget.py +++ b/opengl/GLViewWidget.py @@ -12,8 +12,16 @@ class GLViewWidget(QtOpenGL.QGLWidget): - Export options """ + + ShareWidget = None + def __init__(self, parent=None): - QtOpenGL.QGLWidget.__init__(self, parent) + if GLViewWidget.ShareWidget is None: + ## create a dummy widget to allow sharing objects (textures, shaders, etc) between views + GLViewWidget.ShareWidget = QtOpenGL.QGLWidget() + + QtOpenGL.QGLWidget.__init__(self, parent, GLViewWidget.ShareWidget) + self.setFocusPolicy(QtCore.Qt.ClickFocus) self.opts = { @@ -131,6 +139,16 @@ class GLViewWidget(QtOpenGL.QGLWidget): glMatrixMode(GL_MODELVIEW) glPopMatrix() + def setCameraPosition(self, pos=None, distance=None, elevation=None, azimuth=None): + if distance is not None: + self.opts['distance'] = distance + if elevation is not None: + self.opts['elevation'] = elevation + if azimuth is not None: + self.opts['azimuth'] = azimuth + self.update() + + def cameraPosition(self): """Return current position of camera based on center, dist, elevation, and azimuth""" diff --git a/opengl/MeshData.py b/opengl/MeshData.py index f5024f90..ad77f3d6 100644 --- a/opengl/MeshData.py +++ b/opengl/MeshData.py @@ -1,5 +1,6 @@ from pyqtgraph.Qt import QtGui import pyqtgraph.functions as fn +import numpy as np class MeshData(object): """ @@ -10,148 +11,400 @@ class MeshData(object): - list of triangles - colors per vertex, edge, or tri - normals per vertex or tri + + This class handles conversion between the standard [list of vertexes, list of faces] + format (suitable for use with glDrawElements) and 'indexed' [list of vertexes] format + (suitable for use with glDrawArrays). It will automatically compute face normal + vectors as well as averaged vertex normal vectors. + + The class attempts to be as efficient as possible in caching conversion results and + avoiding unnecessary conversions. """ - def __init__(self): - self._vertexes = [] + def __init__(self, vertexes=None, faces=None, edges=None, vertexColors=None, faceColors=None): + """ + ============= ===================================================== + Arguments + vertexes (Nv, 3) array of vertex coordinates. + If faces is not specified, then this will instead be + interpreted as (Nf, 3, 3) array of coordinates. + faces (Nf, 3) array of indexes into the vertex array. + edges [not available yet] + vertexColors (Nv, 4) array of vertex colors. + If faces is not specified, then this will instead be + interpreted as (Nf, 3, 4) array of colors. + faceColors (Nf, 4) array of face colors. + ============= ===================================================== + + All arguments are optional. + """ + self._vertexes = None # (Nv,3) array of vertex coordinates + self._vertexesIndexedByFaces = None # (Nf, 3, 3) array of vertex coordinates + self._vertexesIndexedByEdges = None # (Ne, 2, 3) array of vertex coordinates + + ## mappings between vertexes, faces, and edges + self._faces = None # Nx3 array of indexes into self._vertexes specifying three vertexes for each face self._edges = None - self._faces = [] - self._vertexFaces = None ## maps vertex ID to a list of face IDs - self._vertexNormals = None - self._faceNormals = None - self._vertexColors = None - self._edgeColors = None - self._faceColors = None - self._meshColor = (1, 1, 1, 0.1) # default color to use if no face/edge/vertex colors are given + self._vertexFaces = None ## maps vertex ID to a list of face IDs (inverse mapping of _faces) + self._vertexEdges = None ## maps vertex ID to a list of edge IDs (inverse mapping of _edges) - def setFaces(self, faces, vertexes=None): - """ - Set the faces in this data set. - Data may be provided either as an Nx3x3 list of floats (9 float coordinate values per face):: + ## Per-vertex data + self._vertexNormals = None # (Nv, 3) array of normals, one per vertex + self._vertexNormalsIndexedByFaces = None # (Nf, 3, 3) array of normals + self._vertexColors = None # (Nv, 3) array of colors + self._vertexColorsIndexedByFaces = None # (Nf, 3, 4) array of colors + self._vertexColorsIndexedByEdges = None # (Nf, 2, 4) array of colors - faces = [ [(x, y, z), (x, y, z), (x, y, z)], ... ] + ## Per-face data + self._faceNormals = None # (Nf, 3) array of face normals + self._faceNormalsIndexedByFaces = None # (Nf, 3, 3) array of face normals + self._faceColors = None # (Nf, 4) array of face colors + self._faceColorsIndexedByFaces = None # (Nf, 3, 4) array of face colors + self._faceColorsIndexedByEdges = None # (Ne, 2, 4) array of face colors + + ## Per-edge data + self._edgeColors = None # (Ne, 4) array of edge colors + self._edgeColorsIndexedByEdges = None # (Ne, 2, 4) array of edge colors + #self._meshColor = (1, 1, 1, 0.1) # default color to use if no face/edge/vertex colors are given + + + + if vertexes is not None: + if faces is None: + self.setVertexes(vertexes, indexed='faces') + if vertexColors is not None: + self.setVertexColors(vertexColors, indexed='faces') + if faceColors is not None: + self.setFaceColors(faceColors, indexed='faces') + else: + self.setVertexes(vertexes) + self.setFaces(faces) + if vertexColors is not None: + self.setVertexColors(vertexColors) + if faceColors is not None: + self.setFaceColors(faceColors) - or as an Nx3 list of ints (vertex integers) AND an Mx3 list of floats (3 float coordinate values per vertex):: - - faces = [ (p1, p2, p3), ... ] - vertexes = [ (x, y, z), ... ] + #self.setFaces(vertexes=vertexes, faces=faces, vertexColors=vertexColors, faceColors=faceColors) - """ - if vertexes is None: - self._setUnindexedFaces(faces) + #def setFaces(self, vertexes=None, faces=None, vertexColors=None, faceColors=None): + #""" + #Set the faces in this data set. + #Data may be provided either as an Nx3x3 array of floats (9 float coordinate values per face):: + + #faces = [ [(x, y, z), (x, y, z), (x, y, z)], ... ] + + #or as an Nx3 array of ints (vertex integers) AND an Mx3 array of floats (3 float coordinate values per vertex):: + + #faces = [ (p1, p2, p3), ... ] + #vertexes = [ (x, y, z), ... ] + + #""" + #if not isinstance(vertexes, np.ndarray): + #vertexes = np.array(vertexes) + #if vertexes.dtype != np.float: + #vertexes = vertexes.astype(float) + #if faces is None: + #self._setIndexedFaces(vertexes, vertexColors, faceColors) + #else: + #self._setUnindexedFaces(faces, vertexes, vertexColors, faceColors) + ##print self.vertexes().shape + ##print self.faces().shape + + + #def setMeshColor(self, color): + #"""Set the color of the entire mesh. This removes any per-face or per-vertex colors.""" + #color = fn.Color(color) + #self._meshColor = color.glColor() + #self._vertexColors = None + #self._faceColors = None + + + #def __iter__(self): + #"""Iterate over all faces, yielding a list of three tuples [(position, normal, color), ...] for each face.""" + #vnorms = self.vertexNormals() + #vcolors = self.vertexColors() + #for i in range(self._faces.shape[0]): + #face = [] + #for j in [0,1,2]: + #vind = self._faces[i,j] + #pos = self._vertexes[vind] + #norm = vnorms[vind] + #if vcolors is None: + #color = self._meshColor + #else: + #color = vcolors[vind] + #face.append((pos, norm, color)) + #yield face + + #def __len__(self): + #return len(self._faces) + + def faces(self): + """Return an array (Nf, 3) of vertex indexes, three per triangular face in the mesh.""" + return self._faces + + def setFaces(self, faces): + """Set the (Nf, 3) array of faces. Each rown in the array contains + three indexes into the vertex array, specifying the three corners + of a triangular face.""" + self._faces = faces + self._vertexFaces = None + self._vertexesIndexedByFaces = None + self.resetNormals() + self._vertexColorsIndexedByFaces = None + self._faceColorsIndexedByFaces = None + + + + def vertexes(self, indexed=None): + """Return an array (N,3) of the positions of vertexes in the mesh. + By default, each unique vertex appears only once in the array. + If indexed is 'faces', then the array will instead contain three vertexes + per face in the mesh (and a single vertex may appear more than once in the array).""" + if indexed is None: + if self._vertexes is None and self._vertexesIndexedByFaces is not None: + self._computeUnindexedVertexes() + return self._vertexes + elif indexed == 'faces': + if self._vertexesIndexedByFaces is None and self._vertexes is not None: + self._vertexesIndexedByFaces = self._vertexes[self.faces()] + return self._vertexesIndexedByFaces else: - self._setIndexedFaces(faces, vertexes) + raise Exception("Invalid indexing mode. Accepts: None, 'faces'") + + def setVertexes(self, verts=None, indexed=None, resetNormals=True): + """ + Set the array (Nv, 3) of vertex coordinates. + If indexed=='faces', then the data must have shape (Nf, 3, 3) and is + assumed to be already indexed as a list of faces. + This will cause any pre-existing normal vectors to be cleared + unless resetNormals=False. + """ + if indexed is None: + if verts is not None: + self._vertexes = verts + self._vertexesIndexedByFaces = None + elif indexed=='faces': + self._vertexes = None + if verts is not None: + self._vertexesIndexedByFaces = verts + else: + raise Exception("Invalid indexing mode. Accepts: None, 'faces'") + + if resetNormals: + self.resetNormals() - def setMeshColor(self, color): - """Set the color of the entire mesh. This removes any per-face or per-vertex colors.""" - color = fn.Color(color) - self._meshColor = color.glColor() - self._vertexColors = None - self._faceColors = None + def resetNormals(self): + self._vertexNormals = None + self._vertexNormalsIndexedByFaces = None + self._faceNormals = None + self._faceNormalsIndexedByFaces = None + + + def hasFaceIndexedData(self): + """Return True if this object already has vertex positions indexed by face""" + return self._vertexesIndexedByFaces is not None - def _setUnindexedFaces(self, faces): - verts = {} - self._faces = [] + def hasEdgeIndexedData(self): + return self._vertexesIndexedByEdges is not None + + def hasVertexColor(self): + """Return True if this data set has vertex color information""" + for v in (self._vertexColors, self._vertexColorsIndexedByFaces, self._vertexColorsIndexedByEdges): + if v is not None: + return True + return False + + def hasFaceColor(self): + """Return True if this data set has face color information""" + for v in (self._faceColors, self._faceColorsIndexedByFaces, self._faceColorsIndexedByEdges): + if v is not None: + return True + return False + + + def faceNormals(self, indexed=None): + """ + Return an array (Nf, 3) of normal vectors for each face. + If indexed='faces', then instead return an indexed array + (Nf, 3, 3) (this is just the same array with each vector + copied three times). + """ + if self._faceNormals is None: + v = self.vertexes(indexed='faces') + self._faceNormals = np.cross(v[:,1]-v[:,0], v[:,2]-v[:,0]) + + + if indexed is None: + return self._faceNormals + elif indexed == 'faces': + if self._faceNormalsIndexedByFaces is None: + norms = np.empty((self._faceNormals.shape[0], 3, 3)) + norms[:] = self._faceNormals[:,np.newaxis,:] + self._faceNormalsIndexedByFaces = norms + return self._faceNormalsIndexedByFaces + else: + raise Exception("Invalid indexing mode. Accepts: None, 'faces'") + + def vertexNormals(self, indexed=None): + """ + Return an array of normal vectors. + By default, the array will be (N, 3) with one entry per unique vertex in the mesh. + If indexed is 'faces', then the array will contain three normal vectors per face + (and some vertexes may be repeated). + """ + if self._vertexNormals is None: + faceNorms = self.faceNormals() + vertFaces = self.vertexFaces() + self._vertexNormals = np.empty(self._vertexes.shape, dtype=float) + for vindex in xrange(self._vertexes.shape[0]): + norms = faceNorms[vertFaces[vindex]] ## get all face normals + norm = norms.sum(axis=0) ## sum normals + norm /= (norm**2).sum()**0.5 ## and re-normalize + self._vertexNormals[vindex] = norm + + if indexed is None: + return self._vertexNormals + elif indexed == 'faces': + return self._vertexNormals[self.faces()] + else: + raise Exception("Invalid indexing mode. Accepts: None, 'faces'") + + def vertexColors(self, indexed=None): + """ + Return an array (Nv, 4) of vertex colors. + If indexed=='faces', then instead return an indexed array + (Nf, 3, 4). + """ + if indexed is None: + return self._vertexColors + elif indexed == 'faces': + if self._vertexColorsIndexedByFaces is None: + self._vertexColorsIndexedByFaces = self._vertexColors[self.faces()] + return self._vertexColorsIndexedByFaces + else: + raise Exception("Invalid indexing mode. Accepts: None, 'faces'") + + def setVertexColors(self, colors, indexed=None): + """ + Set the vertex color array (Nv, 4). + If indexed=='faces', then the array will be interpreted + as indexed and should have shape (Nf, 3, 4) + """ + if indexed is None: + self._vertexColors = colors + self._vertexColorsIndexedByFaces = None + elif indexed == 'faces': + self._vertexColors = None + self._vertexColorsIndexedByFaces = colors + else: + raise Exception("Invalid indexing mode. Accepts: None, 'faces'") + + def faceColors(self, indexed=None): + """ + Return an array (Nf, 4) of face colors. + If indexed=='faces', then instead return an indexed array + (Nf, 3, 4) (note this is just the same array with each color + repeated three times). + """ + if indexed is None: + return self._faceColors + elif indexed == 'faces': + if self._faceColorsIndexedByFaces is None and self._faceColors is not None: + Nf = self._faceColors.shape[0] + self._faceColorsIndexedByFaces = np.empty((Nf, 3, 4), dtype=self._faceColors.dtype) + self._faceColorsIndexedByFaces[:] = self._faceColors.reshape(Nf, 1, 4) + return self._faceColorsIndexedByFaces + else: + raise Exception("Invalid indexing mode. Accepts: None, 'faces'") + + def setFaceColors(self, colors, indexed=None): + """ + Set the face color array (Nf, 4). + If indexed=='faces', then the array will be interpreted + as indexed and should have shape (Nf, 3, 4) + """ + if indexed is None: + self._faceColors = colors + self._faceColorsIndexedByFaces = None + elif indexed == 'faces': + self._faceColors = None + self._faceColorsIndexedByFaces = colors + else: + raise Exception("Invalid indexing mode. Accepts: None, 'faces'") + + def faceCount(self): + """ + Return the number of faces in the mesh. + """ + if self._faces is not None: + return self._faces.shape[0] + elif self._vertexesIndexedByFaces is not None: + return self._vertexesIndexedByFaces.shape[0] + + def edgeColors(self): + return self._edgeColors + + #def _setIndexedFaces(self, faces, vertexColors=None, faceColors=None): + #self._vertexesIndexedByFaces = faces + #self._vertexColorsIndexedByFaces = vertexColors + #self._faceColorsIndexedByFaces = faceColors + + def _computeUnindexedVertexes(self): + ## Given (Nv, 3, 3) array of vertexes-indexed-by-face, convert backward to unindexed vertexes + ## This is done by collapsing into a list of 'unique' vertexes (difference < 1e-14) + + ## I think generally this should be discouraged.. + + faces = self._vertexesIndexedByFaces + verts = {} ## used to remember the index of each vertex position + self._faces = np.empty(faces.shape[:2], dtype=np.uint) self._vertexes = [] self._vertexFaces = [] self._faceNormals = None self._vertexNormals = None - for face in faces: + for i in xrange(faces.shape[0]): + face = faces[i] inds = [] - for pt in face: + for j in range(face.shape[0]): + pt = face[j] pt2 = tuple([round(x*1e14) for x in pt]) ## quantize to be sure that nearly-identical points will be merged index = verts.get(pt2, None) if index is None: - self._vertexes.append(QtGui.QVector3D(*pt)) + #self._vertexes.append(QtGui.QVector3D(*pt)) + self._vertexes.append(pt) self._vertexFaces.append([]) index = len(self._vertexes)-1 verts[pt2] = index - self._vertexFaces[index].append(len(self._faces)) - inds.append(index) - self._faces.append(tuple(inds)) + self._vertexFaces[index].append(i) # keep track of which vertexes belong to which faces + self._faces[i,j] = index + self._vertexes = np.array(self._vertexes, dtype=float) - def _setIndexedFaces(self, faces, vertexes): - self._vertexes = [QtGui.QVector3D(*v) for v in vertexes] - self._faces = faces - self._edges = None - self._vertexFaces = None - self._faceNormals = None - self._vertexNormals = None + #def _setUnindexedFaces(self, faces, vertexes, vertexColors=None, faceColors=None): + #self._vertexes = vertexes #[QtGui.QVector3D(*v) for v in vertexes] + #self._faces = faces.astype(np.uint) + #self._edges = None + #self._vertexFaces = None + #self._faceNormals = None + #self._vertexNormals = None + #self._vertexColors = vertexColors + #self._faceColors = faceColors def vertexFaces(self): """ Return list mapping each vertex index to a list of face indexes that use the vertex. """ if self._vertexFaces is None: - self._vertexFaces = [[]] * len(self._vertexes) - for i, face in enumerate(self._faces): + self._vertexFaces = [None] * len(self.vertexes()) + for i in xrange(self._faces.shape[0]): + face = self._faces[i] for ind in face: - if len(self._vertexFaces[ind]) == 0: + if self._vertexFaces[ind] is None: self._vertexFaces[ind] = [] ## need a unique/empty list to fill self._vertexFaces[ind].append(i) return self._vertexFaces - def __iter__(self): - """Iterate over all faces, yielding a list of three tuples [(position, normal, color), ...] for each face.""" - vnorms = self.vertexNormals() - vcolors = self.vertexColors() - for i in range(len(self._faces)): - face = [] - for j in [0,1,2]: - vind = self._faces[i][j] - pos = self._vertexes[vind] - norm = vnorms[vind] - if vcolors is None: - color = self._meshColor - else: - color = vcolors[vind] - face.append((pos, norm, color)) - yield face - - - def faceNormals(self): - """ - Computes and stores normal of each face. - """ - if self._faceNormals is None: - self._faceNormals = [] - for i, face in enumerate(self._faces): - ## compute face normal - pts = [self._vertexes[vind] for vind in face] - norm = QtGui.QVector3D.crossProduct(pts[1]-pts[0], pts[2]-pts[0]) - norm = norm / norm.length() ## don't use .normalized(); doesn't work for small values. - self._faceNormals.append(norm) - return self._faceNormals - - def vertexNormals(self): - """ - Assigns each vertex the average of its connected face normals. - If face normals have not been computed yet, then generateFaceNormals will be called. - """ - if self._vertexNormals is None: - faceNorms = self.faceNormals() - vertFaces = self.vertexFaces() - self._vertexNormals = [] - for vindex in range(len(self._vertexes)): - #print vertFaces[vindex] - norms = [faceNorms[findex] for findex in vertFaces[vindex]] - norm = QtGui.QVector3D() - for fn in norms: - norm += fn - norm = norm / norm.length() ## don't use .normalize(); doesn't work for small values. - self._vertexNormals.append(norm) - return self._vertexNormals - - def vertexColors(self): - return self._vertexColors - - def faceColors(self): - return self._faceColors - - def edgeColors(self): - return self._edgeColors - #def reverseNormals(self): #""" #Reverses the direction of all normal vectors. @@ -168,7 +421,21 @@ class MeshData(object): def save(self): """Serialize this mesh to a string appropriate for disk storage""" import pickle - names = ['_vertexes', '_edges', '_faces', '_vertexFaces', '_vertexNormals', '_faceNormals', '_vertexColors', '_edgeColors', '_faceColors', '_meshColor'] + if self._faces is not None: + names = ['_vertexes', '_faces'] + else: + names = ['_vertexesIndexedByFaces'] + + if self._vertexColors is not None: + names.append('_vertexColors') + elif self._vertexColorsIndexedByFaces is not None: + names.append('_vertexColorsIndexedByFaces') + + if self._faceColors is not None: + names.append('_faceColors') + elif self._faceColorsIndexedByFaces is not None: + names.append('_faceColorsIndexedByFaces') + state = {n:getattr(self, n) for n in names} return pickle.dumps(state) @@ -178,6 +445,45 @@ class MeshData(object): state = pickle.loads(state) for k in state: setattr(self, k, state[k]) - - - \ No newline at end of file + + + + +def sphere(rows, cols, radius=1.0, offset=True): + """ + Return a MeshData instance with vertexes and faces computed + for a spherical surface. + """ + verts = np.empty((rows+1, cols, 3), dtype=float) + + ## compute vertexes + phi = (np.arange(rows+1) * np.pi / rows).reshape(rows+1, 1) + s = radius * np.sin(phi) + verts[...,2] = radius * np.cos(phi) + th = ((np.arange(cols) * 2 * np.pi / cols).reshape(1, cols)) + if offset: + th = th + ((np.pi / cols) * np.arange(rows+1).reshape(rows+1,1)) ## rotate each row by 1/2 column + verts[...,0] = s * np.cos(th) + verts[...,1] = s * np.sin(th) + verts = verts.reshape((rows+1)*cols, 3)[cols-1:-(cols-1)] ## remove redundant vertexes from top and bottom + + ## compute faces + faces = np.empty((rows*cols*2, 3), dtype=np.uint) + rowtemplate1 = ((np.arange(cols).reshape(cols, 1) + np.array([[0, 1, 0]])) % cols) + np.array([[0, 0, cols]]) + rowtemplate2 = ((np.arange(cols).reshape(cols, 1) + np.array([[0, 1, 1]])) % cols) + np.array([[cols, 0, cols]]) + for row in range(rows): + start = row * cols * 2 + faces[start:start+cols] = rowtemplate1 + row * cols + faces[start+cols:start+(cols*2)] = rowtemplate2 + row * cols + faces = faces[cols:-cols] ## cut off zero-area triangles at top and bottom + + ## adjust for redundant vertexes that were removed from top and bottom + vmin = cols-1 + faces[facesvmax] = vmax + + return MeshData(vertexes=verts, faces=faces) + + \ No newline at end of file diff --git a/opengl/items/GLGridItem.py b/opengl/items/GLGridItem.py index 35a6f766..630b2aba 100644 --- a/opengl/items/GLGridItem.py +++ b/opengl/items/GLGridItem.py @@ -11,8 +11,9 @@ class GLGridItem(GLGraphicsItem): Displays a wire-grame grid. """ - def __init__(self, size=None, color=None): + def __init__(self, size=None, color=None, glOptions='translucent'): GLGraphicsItem.__init__(self) + self.setGLOptions(glOptions) if size is None: size = QtGui.QVector3D(1,1,1) self.setSize(size=size) @@ -34,10 +35,10 @@ class GLGridItem(GLGraphicsItem): def paint(self): - - glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) - glEnable( GL_BLEND ) - glEnable( GL_ALPHA_TEST ) + self.setupGLState() + #glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) + #glEnable( GL_BLEND ) + #glEnable( GL_ALPHA_TEST ) glEnable( GL_POINT_SMOOTH ) #glDisable( GL_DEPTH_TEST ) glBegin( GL_LINES ) diff --git a/opengl/items/GLMeshItem.py b/opengl/items/GLMeshItem.py index 266b84c0..c29f29e0 100644 --- a/opengl/items/GLMeshItem.py +++ b/opengl/items/GLMeshItem.py @@ -16,65 +16,161 @@ class GLMeshItem(GLGraphicsItem): Displays a 3D triangle mesh. """ - def __init__(self, faces, vertexes=None): + def __init__(self, **kwds): """ - See :class:`MeshData ` for initialization arguments. + ============== ===================================================== + Arguments + meshdata MeshData object from which to determine geometry for + this item. + color Default color used if no vertex or face colors are + specified. + shader Name of shader program to use (None for no shader) + smooth If True, normal vectors are computed for each vertex + and interpolated within each face. + computeNormals If False, then computation of normal vectors is + disabled. This can provide a performance boost for + meshes that do not make use of normals. + ============== ===================================================== """ - if isinstance(faces, MeshData): - self.data = faces - else: - self.data = MeshData() - self.data.setFaces(faces, vertexes) + self.opts = { + 'meshdata': None, + 'color': (1., 1., 1., 1.), + 'shader': None, + 'smooth': True, + 'computeNormals': True, + } + GLGraphicsItem.__init__(self) + glopts = kwds.pop('glOptions', 'opaque') + self.setGLOptions(glopts) + shader = kwds.pop('shader', None) + self.setShader(shader) - def initializeGL(self): - self.shader = shaders.getShaderProgram('balloon') + self.setMeshData(**kwds) - l = glGenLists(1) - self.triList = l - glNewList(l, GL_COMPILE) + ## storage for data compiled from MeshData object + self.vertexes = None + self.normals = None + self.colors = None + self.faces = None - glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) - glEnable( GL_BLEND ) - glEnable( GL_ALPHA_TEST ) - #glAlphaFunc( GL_ALWAYS,0.5 ) - glEnable( GL_POINT_SMOOTH ) - glDisable( GL_DEPTH_TEST ) - glColor4f(1, 1, 1, .1) - glBegin( GL_TRIANGLES ) - for face in self.data: - for (pos, norm, color) in face: - glColor4f(*color) - glNormal3f(norm.x(), norm.y(), norm.z()) - glVertex3f(pos.x(), pos.y(), pos.z()) - glEnd() - glEndList() + def setShader(self, shader): + self.opts['shader'] = shader + self.update() + def shader(self): + return shaders.getShaderProgram(self.opts['shader']) - #l = glGenLists(1) - #self.meshList = l - #glNewList(l, GL_COMPILE) - #glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) - #glEnable( GL_BLEND ) - #glEnable( GL_ALPHA_TEST ) - ##glAlphaFunc( GL_ALWAYS,0.5 ) - #glEnable( GL_POINT_SMOOTH ) - #glEnable( GL_DEPTH_TEST ) - #glColor4f(1, 1, 1, .3) - #glBegin( GL_LINES ) - #for f in self.faces: - #for i in [0,1,2]: - #j = (i+1) % 3 - #glVertex3f(*f[i]) - #glVertex3f(*f[j]) - #glEnd() - #glEndList() - - + def setMeshData(self, **kwds): + """ + Set mesh data for this item. This can be invoked two ways: + + 1. Specify *meshdata* argument with a new MeshData object + 2. Specify keyword arguments to be passed to MeshData(..) to create a new instance. + """ + md = kwds.get('meshdata', None) + if md is None: + opts = {} + for k in ['vertexes', 'faces', 'edges', 'vertexColors', 'faceColors']: + try: + opts[k] = kwds.pop(k) + except KeyError: + pass + md = MeshData(**opts) + + self.opts['meshdata'] = md + self.opts.update(kwds) + self.meshDataChanged() + self.update() + + + def meshDataChanged(self): + """ + This method must be called to inform the item that the MeshData object + has been altered. + """ + + self.vertexes = None + self.faces = None + self.normals = None + self.colors = None + self.update() + + def parseMeshData(self): + ## interpret vertex / normal data before drawing + ## This can: + ## - automatically generate normals if they were not specified + ## - pull vertexes/noormals/faces from MeshData if that was specified + + if self.vertexes is not None and self.normals is not None: + return + #if self.opts['normals'] is None: + #if self.opts['meshdata'] is None: + #self.opts['meshdata'] = MeshData(vertexes=self.opts['vertexes'], faces=self.opts['faces']) + if self.opts['meshdata'] is not None: + md = self.opts['meshdata'] + if self.opts['smooth'] and not md.hasFaceIndexedData(): + self.vertexes = md.vertexes() + if self.opts['computeNormals']: + self.normals = md.vertexNormals() + self.faces = md.faces() + if md.hasVertexColor(): + self.colors = md.vertexColors() + if md.hasFaceColor(): + self.colors = md.faceColors() + else: + self.vertexes = md.vertexes(indexed='faces') + if self.opts['computeNormals']: + if self.opts['smooth']: + self.normals = md.vertexNormals(indexed='faces') + else: + self.normals = md.faceNormals(indexed='faces') + self.faces = None + if md.hasVertexColor(): + self.colors = md.vertexColors(indexed='faces') + elif md.hasFaceColor(): + self.colors = md.faceColors(indexed='faces') + + return + def paint(self): - with self.shader: - glCallList(self.triList) - #shaders.glUseProgram(self.shader) - #glCallList(self.triList) - #shaders.glUseProgram(0) - #glCallList(self.meshList) + self.setupGLState() + + self.parseMeshData() + + with self.shader(): + verts = self.vertexes + norms = self.normals + color = self.colors + faces = self.faces + if verts is None: + return + glEnableClientState(GL_VERTEX_ARRAY) + try: + glVertexPointerf(verts) + + if self.colors is None: + color = self.opts['color'] + if isinstance(color, QtGui.QColor): + glColor4f(*fn.glColor(color)) + else: + glColor4f(*color) + else: + glEnableClientState(GL_COLOR_ARRAY) + glColorPointerf(color) + + + if norms is not None: + glEnableClientState(GL_NORMAL_ARRAY) + glNormalPointerf(norms) + + if faces is None: + glDrawArrays(GL_TRIANGLES, 0, np.product(verts.shape[:-1])) + else: + faces = faces.astype(np.uint).flatten() + glDrawElements(GL_TRIANGLES, faces.shape[0], GL_UNSIGNED_INT, faces) + finally: + glDisableClientState(GL_NORMAL_ARRAY) + glDisableClientState(GL_VERTEX_ARRAY) + glDisableClientState(GL_COLOR_ARRAY) + diff --git a/opengl/items/GLScatterPlotItem.py b/opengl/items/GLScatterPlotItem.py index 9ff22c37..d04a2ac7 100644 --- a/opengl/items/GLScatterPlotItem.py +++ b/opengl/items/GLScatterPlotItem.py @@ -12,6 +12,8 @@ class GLScatterPlotItem(GLGraphicsItem): def __init__(self, **kwds): GLGraphicsItem.__init__(self) + glopts = kwds.pop('glOptions', 'additive') + self.setGLOptions(glopts) self.pos = [] self.size = 10 self.color = [1.0,1.0,1.0,0.5] @@ -71,27 +73,27 @@ class GLScatterPlotItem(GLGraphicsItem): glBindTexture(GL_TEXTURE_2D, self.pointTexture) glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, pData.shape[0], pData.shape[1], 0, GL_RGBA, GL_UNSIGNED_BYTE, pData) - self.shader = shaders.getShaderProgram('point_sprite') + self.shader = shaders.getShaderProgram('pointSprite') #def getVBO(self, name): #if name not in self.vbo: #self.vbo[name] = vbo.VBO(getattr(self, name).astype('f')) #return self.vbo[name] - def setupGLState(self): - """Prepare OpenGL state for drawing. This function is called immediately before painting.""" - #glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) ## requires z-sorting to render properly. - glBlendFunc(GL_SRC_ALPHA, GL_ONE) - glEnable( GL_BLEND ) - glEnable( GL_ALPHA_TEST ) - glDisable( GL_DEPTH_TEST ) + #def setupGLState(self): + #"""Prepare OpenGL state for drawing. This function is called immediately before painting.""" + ##glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) ## requires z-sorting to render properly. + #glBlendFunc(GL_SRC_ALPHA, GL_ONE) + #glEnable( GL_BLEND ) + #glEnable( GL_ALPHA_TEST ) + #glDisable( GL_DEPTH_TEST ) - #glEnable( GL_POINT_SMOOTH ) + ##glEnable( GL_POINT_SMOOTH ) - #glHint(GL_POINT_SMOOTH_HINT, GL_NICEST) - #glPointParameterfv(GL_POINT_DISTANCE_ATTENUATION, (0, 0, -1e-3)) - #glPointParameterfv(GL_POINT_SIZE_MAX, (65500,)) - #glPointParameterfv(GL_POINT_SIZE_MIN, (0,)) + ##glHint(GL_POINT_SMOOTH_HINT, GL_NICEST) + ##glPointParameterfv(GL_POINT_DISTANCE_ATTENUATION, (0, 0, -1e-3)) + ##glPointParameterfv(GL_POINT_SIZE_MAX, (65500,)) + ##glPointParameterfv(GL_POINT_SIZE_MIN, (0,)) def paint(self): self.setupGLState() @@ -139,7 +141,7 @@ class GLScatterPlotItem(GLGraphicsItem): glNormalPointerf(norm) else: - glNormal3f(self.size,0,0) + glNormal3f(self.size, 0, 0) ## vertex shader uses norm.x to determine point size #glPointSize(self.size) glDrawArrays(GL_POINTS, 0, len(self.pos)) finally: diff --git a/opengl/items/GLSurfacePlotItem.py b/opengl/items/GLSurfacePlotItem.py new file mode 100644 index 00000000..69080fad --- /dev/null +++ b/opengl/items/GLSurfacePlotItem.py @@ -0,0 +1,139 @@ +from OpenGL.GL import * +from GLMeshItem import GLMeshItem +from .. MeshData import MeshData +from pyqtgraph.Qt import QtGui +import pyqtgraph as pg +import numpy as np + + + +__all__ = ['GLSurfacePlotItem'] + +class GLSurfacePlotItem(GLMeshItem): + """ + **Bases:** :class:`GLMeshItem ` + + Displays a surface plot on a regular x,y grid + """ + def __init__(self, x=None, y=None, z=None, colors=None, **kwds): + """ + The x, y, z, and colors arguments are passed to setData(). + All other keyword arguments are passed to GLMeshItem.__init__(). + """ + + self._x = None + self._y = None + self._z = None + self._color = None + self._vertexes = None + self._meshdata = MeshData() + GLMeshItem.__init__(self, meshdata=self._meshdata, **kwds) + + self.setData(x, y, z, colors) + + + + def setData(self, x=None, y=None, z=None, colors=None): + """ + Update the data in this surface plot. + + ========== ===================================================================== + Arguments + x,y 1D arrays of values specifying the x,y positions of vertexes in the + grid. If these are omitted, then the values will be assumed to be + integers. + z 2D array of height values for each grid vertex. + colors (width, height, 4) array of vertex colors. + ========== ===================================================================== + + All arguments are optional. + + Note that if vertex positions are updated, the normal vectors for each triangle must + be recomputed. This is somewhat expensive if the surface was initialized with smooth=False + and very expensive if smooth=True. For faster performance, initialize with + computeNormals=False and use per-vertex colors or a normal-independent shader program. + """ + if x is not None: + if self._x is None or len(x) != len(self._x): + self._vertexes = None + self._x = x + + if y is not None: + if self._y is None or len(y) != len(self._y): + self._vertexes = None + self._y = y + + if z is not None: + #if self._x is None: + #self._x = np.arange(z.shape[0]) + #self._vertexes = None + #if self._y is None: + #self._y = np.arange(z.shape[1]) + #self._vertexes = None + + if self._x is not None and z.shape[0] != len(self._x): + raise Exception('Z values must have shape (len(x), len(y))') + if self._y is not None and z.shape[1] != len(self._y): + raise Exception('Z values must have shape (len(x), len(y))') + self._z = z + if self._vertexes is not None and self._z.shape != self._vertexes.shape[:2]: + self._vertexes = None + + if colors is not None: + self._colors = colors + self._meshdata.setVertexColors(colors) + + if self._z is None: + return + + updateMesh = False + newVertexes = False + + ## Generate vertex and face array + if self._vertexes is None: + newVertexes = True + self._vertexes = np.empty((self._z.shape[0], self._z.shape[1], 3), dtype=float) + self.generateFaces() + self._meshdata.setFaces(self._faces) + updateMesh = True + + ## Copy x, y, z data into vertex array + if newVertexes or x is not None: + if x is None: + if self._x is None: + x = np.arange(self._z.shape[0]) + else: + x = self._x + self._vertexes[:, :, 0] = x.reshape(len(x), 1) + updateMesh = True + + if newVertexes or y is not None: + if y is None: + if self._y is None: + y = np.arange(self._z.shape[1]) + else: + y = self._y + self._vertexes[:, :, 1] = y.reshape(1, len(y)) + updateMesh = True + + if newVertexes or z is not None: + self._vertexes[...,2] = self._z + updateMesh = True + + ## Update MeshData + if updateMesh: + self._meshdata.setVertexes(self._vertexes.reshape(self._vertexes.shape[0]*self._vertexes.shape[1], 3)) + self.meshDataChanged() + + + def generateFaces(self): + cols = self._z.shape[0]-1 + rows = self._z.shape[1]-1 + faces = np.empty((cols*rows*2, 3), dtype=np.uint) + rowtemplate1 = np.arange(cols).reshape(cols, 1) + np.array([[0, 1, cols+1]]) + rowtemplate2 = np.arange(cols).reshape(cols, 1) + np.array([[cols+1, 1, cols+2]]) + for row in range(rows): + start = row * cols * 2 + faces[start:start+cols] = rowtemplate1 + row * (cols+1) + faces[start+cols:start+(cols*2)] = rowtemplate2 + row * (cols+1) + self._faces = faces \ No newline at end of file diff --git a/opengl/items/GLVolumeItem.py b/opengl/items/GLVolumeItem.py index 9981f4ba..4980239d 100644 --- a/opengl/items/GLVolumeItem.py +++ b/opengl/items/GLVolumeItem.py @@ -13,7 +13,7 @@ class GLVolumeItem(GLGraphicsItem): """ - def __init__(self, data, sliceDensity=1, smooth=True): + def __init__(self, data, sliceDensity=1, smooth=True, glOptions='translucent'): """ ============== ======================================================================================= **Arguments:** @@ -27,6 +27,7 @@ class GLVolumeItem(GLGraphicsItem): self.smooth = smooth self.data = data GLGraphicsItem.__init__(self) + self.setGLOptions(glOptions) def initializeGL(self): glEnable(GL_TEXTURE_3D) @@ -62,15 +63,16 @@ class GLVolumeItem(GLGraphicsItem): def paint(self): + self.setupGLState() glEnable(GL_TEXTURE_3D) glBindTexture(GL_TEXTURE_3D, self.texture) - glEnable(GL_DEPTH_TEST) + #glEnable(GL_DEPTH_TEST) #glDisable(GL_CULL_FACE) - glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) - glEnable( GL_BLEND ) - glEnable( GL_ALPHA_TEST ) + #glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) + #glEnable( GL_BLEND ) + #glEnable( GL_ALPHA_TEST ) glColor4f(1,1,1,1) view = self.view() diff --git a/opengl/shaders.py b/opengl/shaders.py index 7f4fa665..e9a4af9d 100644 --- a/opengl/shaders.py +++ b/opengl/shaders.py @@ -1,18 +1,22 @@ from OpenGL.GL import * from OpenGL.GL import shaders +import re ## For centralizing and managing vertex/fragment shader programs. def initShaders(): global Shaders Shaders = [ - ShaderProgram('balloon', [ ## increases fragment alpha as the normal turns orthogonal to the view + ShaderProgram(None, []), + + ## increases fragment alpha as the normal turns orthogonal to the view + ## this is useful for viewing shells that enclose a volume (such as isosurfaces) + ShaderProgram('balloon', [ VertexShader(""" varying vec3 normal; void main() { + // compute here for use in fragment shader normal = normalize(gl_NormalMatrix * gl_Normal); - //vec4 color = normal; - //normal.w = min(color.w + 2.0 * color.w * pow(normal.x*normal.x + normal.y*normal.y, 2.0), 1.0); gl_FrontColor = gl_Color; gl_BackColor = gl_Color; gl_Position = ftransform(); @@ -27,7 +31,154 @@ def initShaders(): } """) ]), - ShaderProgram('point_sprite', [ ## allows specifying point size using normal.x + + ## colors fragments based on face normals relative to view + ## This means that the colors will change depending on how the view is rotated + ShaderProgram('viewNormalColor', [ + VertexShader(""" + varying vec3 normal; + void main() { + // compute here for use in fragment shader + normal = normalize(gl_NormalMatrix * gl_Normal); + gl_FrontColor = gl_Color; + gl_BackColor = gl_Color; + gl_Position = ftransform(); + } + """), + FragmentShader(""" + varying vec3 normal; + void main() { + vec4 color = gl_Color; + color.x = (normal.x + 1) * 0.5; + color.y = (normal.y + 1) * 0.5; + color.z = (normal.z + 1) * 0.5; + gl_FragColor = color; + } + """) + ]), + + ## colors fragments based on absolute face normals. + ShaderProgram('normalColor', [ + VertexShader(""" + varying vec3 normal; + void main() { + // compute here for use in fragment shader + normal = normalize(gl_Normal); + gl_FrontColor = gl_Color; + gl_BackColor = gl_Color; + gl_Position = ftransform(); + } + """), + FragmentShader(""" + varying vec3 normal; + void main() { + vec4 color = gl_Color; + color.x = (normal.x + 1) * 0.5; + color.y = (normal.y + 1) * 0.5; + color.z = (normal.z + 1) * 0.5; + gl_FragColor = color; + } + """) + ]), + + ## very simple simulation of lighting. + ## The light source position is always relative to the camera. + ShaderProgram('shaded', [ + VertexShader(""" + varying vec3 normal; + void main() { + // compute here for use in fragment shader + normal = normalize(gl_NormalMatrix * gl_Normal); + gl_FrontColor = gl_Color; + gl_BackColor = gl_Color; + gl_Position = ftransform(); + } + """), + FragmentShader(""" + varying vec3 normal; + void main() { + float p = dot(normal, normalize(vec3(1, -1, -1))); + p = p < 0. ? 0. : p * 0.8; + vec4 color = gl_Color; + color.x = color.x * (0.2 + p); + color.y = color.y * (0.2 + p); + color.z = color.z * (0.2 + p); + gl_FragColor = color; + } + """) + ]), + + ## colors get brighter near edges of object + ShaderProgram('edgeHilight', [ + VertexShader(""" + varying vec3 normal; + void main() { + // compute here for use in fragment shader + normal = normalize(gl_NormalMatrix * gl_Normal); + gl_FrontColor = gl_Color; + gl_BackColor = gl_Color; + gl_Position = ftransform(); + } + """), + FragmentShader(""" + varying vec3 normal; + void main() { + vec4 color = gl_Color; + float s = pow(normal.x*normal.x + normal.y*normal.y, 2.0); + color.x = color.x + s * (1.0-color.x); + color.y = color.y + s * (1.0-color.y); + color.z = color.z + s * (1.0-color.z); + gl_FragColor = color; + } + """) + ]), + + ## colors fragments by z-value. + ## This is useful for coloring surface plots by height. + ## This shader uses a uniform called "colorMap" to determine how to map the colors: + ## red = pow(z * colorMap[0] + colorMap[1], colorMap[2]) + ## green = pow(z * colorMap[3] + colorMap[4], colorMap[5]) + ## blue = pow(z * colorMap[6] + colorMap[7], colorMap[8]) + ## (set the values like this: shader['uniformMap'] = array([...]) + ShaderProgram('heightColor', [ + VertexShader(""" + varying vec4 pos; + void main() { + gl_FrontColor = gl_Color; + gl_BackColor = gl_Color; + pos = gl_Vertex; + gl_Position = ftransform(); + } + """), + FragmentShader(""" + #version 140 // required for uniform blocks + uniform float colorMap[9]; + varying vec4 pos; + out vec4 gl_FragColor; + in vec4 gl_Color; + void main() { + vec4 color = gl_Color; + color.x = colorMap[0] * (pos.z + colorMap[1]); + if (colorMap[2] != 1.0) + color.x = pow(color.x, colorMap[2]); + color.x = color.x < 0 ? 0 : (color.x > 1 ? 1 : color.x); + + color.y = colorMap[3] * (pos.z + colorMap[4]); + if (colorMap[5] != 1.0) + color.y = pow(color.y, colorMap[5]); + color.y = color.y < 0 ? 0 : (color.y > 1 ? 1 : color.y); + + color.z = colorMap[6] * (pos.z + colorMap[7]); + if (colorMap[8] != 1.0) + color.z = pow(color.z, colorMap[8]); + color.z = color.z < 0 ? 0 : (color.z > 1 ? 1 : color.z); + + color.w = 1.0; + gl_FragColor = color; + } + """), + ], uniforms={'colorMap': [1, 1, 1, 1, 0.5, 1, 1, 0, 1]}), + ShaderProgram('pointSprite', [ ## allows specifying point size using normal.x ## See: ## ## http://stackoverflow.com/questions/9609423/applying-part-of-a-texture-sprite-sheet-texture-map-to-a-point-sprite-in-ios @@ -58,52 +209,186 @@ CompiledShaderPrograms = {} def getShaderProgram(name): return ShaderProgram.names[name] -class VertexShader: - def __init__(self, code): +class Shader: + def __init__(self, shaderType, code): + self.shaderType = shaderType self.code = code self.compiled = None def shader(self): if self.compiled is None: - self.compiled = shaders.compileShader(self.code, GL_VERTEX_SHADER) + try: + self.compiled = shaders.compileShader(self.code, self.shaderType) + except RuntimeError as exc: + ## Format compile errors a bit more nicely + if len(exc.args) == 3: + err, code, typ = exc.args + if not err.startswith('Shader compile failure'): + raise + code = code[0].split('\n') + err, c, msgs = err.partition(':') + err = err + '\n' + msgs = msgs.split('\n') + errNums = [()] * len(code) + for i, msg in enumerate(msgs): + msg = msg.strip() + if msg == '': + continue + m = re.match(r'\d+\((\d+)\)', msg) + if m is not None: + line = int(m.groups()[0]) + errNums[line-1] = errNums[line-1] + (str(i+1),) + #code[line-1] = '%d\t%s' % (i+1, code[line-1]) + err = err + "%d %s\n" % (i+1, msg) + errNums = [','.join(n) for n in errNums] + maxlen = max(map(len, errNums)) + code = [errNums[i] + " "*(maxlen-len(errNums[i])) + line for i, line in enumerate(code)] + err = err + '\n'.join(code) + raise Exception(err) + else: + raise return self.compiled -class FragmentShader: +class VertexShader(Shader): def __init__(self, code): - self.code = code - self.compiled = None + Shader.__init__(self, GL_VERTEX_SHADER, code) + +class FragmentShader(Shader): + def __init__(self, code): + Shader.__init__(self, GL_FRAGMENT_SHADER, code) - def shader(self): - if self.compiled is None: - self.compiled = shaders.compileShader(self.code, GL_FRAGMENT_SHADER) - return self.compiled class ShaderProgram: names = {} - def __init__(self, name, shaders): + def __init__(self, name, shaders, uniforms=None): self.name = name ShaderProgram.names[name] = self self.shaders = shaders self.prog = None + self.blockData = {} + self.uniformData = {} + + ## parse extra options from the shader definition + if uniforms is not None: + for k,v in uniforms.items(): + self[k] = v + + def setBlockData(self, blockName, data): + if data is None: + del self.blockData[blockName] + else: + self.blockData[blockName] = data + + def setUniformData(self, uniformName, data): + if data is None: + del self.uniformData[uniformName] + else: + self.uniformData[uniformName] = data + + def __setitem__(self, item, val): + self.setUniformData(item, val) + + def __delitem__(self, item): + self.setUniformData(item, None) def program(self): if self.prog is None: - compiled = [s.shader() for s in self.shaders] ## compile all shaders - self.prog = shaders.compileProgram(*compiled) ## compile program + try: + compiled = [s.shader() for s in self.shaders] ## compile all shaders + self.prog = shaders.compileProgram(*compiled) ## compile program + except: + self.prog = -1 + raise return self.prog def __enter__(self): - glUseProgram(self.program()) + if len(self.shaders) > 0 and self.program() != -1: + glUseProgram(self.program()) + + try: + ## load uniform values into program + for uniformName, data in self.uniformData.items(): + loc = self.uniform(uniformName) + if loc == -1: + raise Exception('Could not find uniform variable "%s"' % uniformName) + glUniform1fv(loc, len(data), data) + + ### bind buffer data to program blocks + #if len(self.blockData) > 0: + #bindPoint = 1 + #for blockName, data in self.blockData.items(): + ### Program should have a uniform block declared: + ### + ### layout (std140) uniform blockName { + ### vec4 diffuse; + ### }; + + ### pick any-old binding point. (there are a limited number of these per-program + #bindPoint = 1 + + ### get the block index for a uniform variable in the shader + #blockIndex = glGetUniformBlockIndex(self.program(), blockName) + + ### give the shader block a binding point + #glUniformBlockBinding(self.program(), blockIndex, bindPoint) + + ### create a buffer + #buf = glGenBuffers(1) + #glBindBuffer(GL_UNIFORM_BUFFER, buf) + #glBufferData(GL_UNIFORM_BUFFER, size, data, GL_DYNAMIC_DRAW) + ### also possible to use glBufferSubData to fill parts of the buffer + + ### bind buffer to the same binding point + #glBindBufferBase(GL_UNIFORM_BUFFER, bindPoint, buf) + except: + glUseProgram(0) + raise + + def __exit__(self, *args): - glUseProgram(0) + if len(self.shaders) > 0: + glUseProgram(0) def uniform(self, name): """Return the location integer for a uniform variable in this program""" return glGetUniformLocation(self.program(), name) + #def uniformBlockInfo(self, blockName): + #blockIndex = glGetUniformBlockIndex(self.program(), blockName) + #count = glGetActiveUniformBlockiv(self.program(), blockIndex, GL_UNIFORM_BLOCK_ACTIVE_UNIFORMS) + #indices = [] + #for i in range(count): + #indices.append(glGetActiveUniformBlockiv(self.program(), blockIndex, GL_UNIFORM_BLOCK_ACTIVE_UNIFORM_INDICES)) + +class HeightColorShader(ShaderProgram): + def __enter__(self): + ## Program should have a uniform block declared: + ## + ## layout (std140) uniform blockName { + ## vec4 diffuse; + ## vec4 ambient; + ## }; + + ## pick any-old binding point. (there are a limited number of these per-program + bindPoint = 1 + + ## get the block index for a uniform variable in the shader + blockIndex = glGetUniformBlockIndex(self.program(), "blockName") + + ## give the shader block a binding point + glUniformBlockBinding(self.program(), blockIndex, bindPoint) + + ## create a buffer + buf = glGenBuffers(1) + glBindBuffer(GL_UNIFORM_BUFFER, buf) + glBufferData(GL_UNIFORM_BUFFER, size, data, GL_DYNAMIC_DRAW) + ## also possible to use glBufferSubData to fill parts of the buffer + + ## bind buffer to the same binding point + glBindBufferBase(GL_UNIFORM_BUFFER, bindPoint, buf) initShaders() \ No newline at end of file