From 17409bc9a6b48f9ca8a1764f4a4e4b2013c09d69 Mon Sep 17 00:00:00 2001
From: Luke Campagnola <>
Date: Sun, 10 Feb 2013 14:10:30 -0500
Subject: [PATCH] Merge new fixes and features from acq4
---
GraphicsScene/exportDialog.py | 6 +
GraphicsScene/exportDialogTemplate.ui | 7 +
GraphicsScene/exportDialogTemplate_pyqt.py | 8 +-
GraphicsScene/exportDialogTemplate_pyside.py | 8 +-
PlotData.py | 55 ++++
colormap.py | 262 +++++++++++++++++++
debug.py | 18 ++
exporters/Exporter.py | 8 +-
exporters/ImageExporter.py | 14 +-
exporters/SVGExporter.py | 29 +-
graphicsItems/AxisItem.py | 5 +-
graphicsItems/GradientEditorItem.py | 31 ++-
graphicsItems/GraphicsItem.py | 41 ++-
graphicsItems/PlotDataItem.py | 17 +-
graphicsItems/PlotItem/PlotItem.py | 26 ++
graphicsItems/ScatterPlotItem.py | 18 +-
graphicsItems/ViewBox/ViewBox.py | 9 +-
parametertree/Parameter.py | 11 +-
parametertree/parameterTypes.py | 16 +-
rebuildUi.py | 4 +-
widgets/ColorButton.py | 10 +-
widgets/ColorMapWidget.py | 173 ++++++++++++
widgets/DataFilterWidget.py | 115 ++++++++
widgets/ScatterPlotWidget.py | 183 +++++++++++++
24 files changed, 1023 insertions(+), 51 deletions(-)
create mode 100644 PlotData.py
create mode 100644 colormap.py
create mode 100644 widgets/ColorMapWidget.py
create mode 100644 widgets/DataFilterWidget.py
create mode 100644 widgets/ScatterPlotWidget.py
diff --git a/GraphicsScene/exportDialog.py b/GraphicsScene/exportDialog.py
index dafcd501..73a8c83f 100644
--- a/GraphicsScene/exportDialog.py
+++ b/GraphicsScene/exportDialog.py
@@ -27,6 +27,7 @@ class ExportDialog(QtGui.QWidget):
self.ui.closeBtn.clicked.connect(self.close)
self.ui.exportBtn.clicked.connect(self.exportClicked)
+ self.ui.copyBtn.clicked.connect(self.copyClicked)
self.ui.itemTree.currentItemChanged.connect(self.exportItemChanged)
self.ui.formatList.currentItemChanged.connect(self.exportFormatChanged)
@@ -116,11 +117,16 @@ class ExportDialog(QtGui.QWidget):
else:
self.ui.paramTree.setParameters(params)
self.currentExporter = exp
+ self.ui.copyBtn.setEnabled(exp.allowCopy)
def exportClicked(self):
self.selectBox.hide()
self.currentExporter.export()
+ def copyClicked(self):
+ self.selectBox.hide()
+ self.currentExporter.export(copy=True)
+
def close(self):
self.selectBox.setVisible(False)
self.setVisible(False)
diff --git a/GraphicsScene/exportDialogTemplate.ui b/GraphicsScene/exportDialogTemplate.ui
index c81c8831..c91fbc3f 100644
--- a/GraphicsScene/exportDialogTemplate.ui
+++ b/GraphicsScene/exportDialogTemplate.ui
@@ -79,6 +79,13 @@
+ -
+
+
+ Copy
+
+
+
diff --git a/GraphicsScene/exportDialogTemplate_pyqt.py b/GraphicsScene/exportDialogTemplate_pyqt.py
index 20609b51..c3056d1c 100644
--- a/GraphicsScene/exportDialogTemplate_pyqt.py
+++ b/GraphicsScene/exportDialogTemplate_pyqt.py
@@ -2,8 +2,8 @@
# Form implementation generated from reading ui file './GraphicsScene/exportDialogTemplate.ui'
#
-# Created: Sun Sep 9 14:41:31 2012
-# by: PyQt4 UI code generator 4.9.1
+# Created: Wed Jan 30 21:02:28 2013
+# by: PyQt4 UI code generator 4.9.3
#
# WARNING! All changes made in this file will be lost!
@@ -49,6 +49,9 @@ class Ui_Form(object):
self.label_3 = QtGui.QLabel(Form)
self.label_3.setObjectName(_fromUtf8("label_3"))
self.gridLayout.addWidget(self.label_3, 4, 0, 1, 3)
+ self.copyBtn = QtGui.QPushButton(Form)
+ self.copyBtn.setObjectName(_fromUtf8("copyBtn"))
+ self.gridLayout.addWidget(self.copyBtn, 6, 0, 1, 1)
self.retranslateUi(Form)
QtCore.QMetaObject.connectSlotsByName(Form)
@@ -60,5 +63,6 @@ class Ui_Form(object):
self.exportBtn.setText(QtGui.QApplication.translate("Form", "Export", None, QtGui.QApplication.UnicodeUTF8))
self.closeBtn.setText(QtGui.QApplication.translate("Form", "Close", None, QtGui.QApplication.UnicodeUTF8))
self.label_3.setText(QtGui.QApplication.translate("Form", "Export options", None, QtGui.QApplication.UnicodeUTF8))
+ self.copyBtn.setText(QtGui.QApplication.translate("Form", "Copy", None, QtGui.QApplication.UnicodeUTF8))
from pyqtgraph.parametertree import ParameterTree
diff --git a/GraphicsScene/exportDialogTemplate_pyside.py b/GraphicsScene/exportDialogTemplate_pyside.py
index 4ffc0b9a..cf27f60a 100644
--- a/GraphicsScene/exportDialogTemplate_pyside.py
+++ b/GraphicsScene/exportDialogTemplate_pyside.py
@@ -2,8 +2,8 @@
# Form implementation generated from reading ui file './GraphicsScene/exportDialogTemplate.ui'
#
-# Created: Sun Sep 9 14:41:31 2012
-# by: pyside-uic 0.2.13 running on PySide 1.1.0
+# Created: Wed Jan 30 21:02:28 2013
+# by: pyside-uic 0.2.13 running on PySide 1.1.1
#
# WARNING! All changes made in this file will be lost!
@@ -44,6 +44,9 @@ class Ui_Form(object):
self.label_3 = QtGui.QLabel(Form)
self.label_3.setObjectName("label_3")
self.gridLayout.addWidget(self.label_3, 4, 0, 1, 3)
+ self.copyBtn = QtGui.QPushButton(Form)
+ self.copyBtn.setObjectName("copyBtn")
+ self.gridLayout.addWidget(self.copyBtn, 6, 0, 1, 1)
self.retranslateUi(Form)
QtCore.QMetaObject.connectSlotsByName(Form)
@@ -55,5 +58,6 @@ class Ui_Form(object):
self.exportBtn.setText(QtGui.QApplication.translate("Form", "Export", None, QtGui.QApplication.UnicodeUTF8))
self.closeBtn.setText(QtGui.QApplication.translate("Form", "Close", None, QtGui.QApplication.UnicodeUTF8))
self.label_3.setText(QtGui.QApplication.translate("Form", "Export options", None, QtGui.QApplication.UnicodeUTF8))
+ self.copyBtn.setText(QtGui.QApplication.translate("Form", "Copy", None, QtGui.QApplication.UnicodeUTF8))
from pyqtgraph.parametertree import ParameterTree
diff --git a/PlotData.py b/PlotData.py
new file mode 100644
index 00000000..18531c14
--- /dev/null
+++ b/PlotData.py
@@ -0,0 +1,55 @@
+
+
+class PlotData(object):
+ """
+ Class used for managing plot data
+ - allows data sharing between multiple graphics items (curve, scatter, graph..)
+ - each item may define the columns it needs
+ - column groupings ('pos' or x, y, z)
+ - efficiently appendable
+ - log, fft transformations
+ - color mode conversion (float/byte/qcolor)
+ - pen/brush conversion
+ - per-field cached masking
+ - allows multiple masking fields (different graphics need to mask on different criteria)
+ - removal of nan/inf values
+ - option for single value shared by entire column
+ - cached downsampling
+ """
+ def __init__(self):
+ self.fields = {}
+
+ self.maxVals = {} ## cache for max/min
+ self.minVals = {}
+
+ def addFields(self, fields):
+ for f in fields:
+ if f not in self.fields:
+ self.fields[f] = None
+
+ def hasField(self, f):
+ return f in self.fields
+
+ def __getitem__(self, field):
+ return self.fields[field]
+
+ def __setitem__(self, field, val):
+ self.fields[field] = val
+
+ def max(self, field):
+ mx = self.maxVals.get(field, None)
+ if mx is None:
+ mx = np.max(self[field])
+ self.maxVals[field] = mx
+ return mx
+
+ def min(self, field):
+ mn = self.minVals.get(field, None)
+ if mn is None:
+ mn = np.min(self[field])
+ self.minVals[field] = mn
+ return mn
+
+
+
+
\ No newline at end of file
diff --git a/colormap.py b/colormap.py
new file mode 100644
index 00000000..c7e683fb
--- /dev/null
+++ b/colormap.py
@@ -0,0 +1,262 @@
+import numpy as np
+import scipy.interpolate
+from pyqtgraph.Qt import QtGui, QtCore
+
+class ColorMap(object):
+
+ ## color interpolation modes
+ RGB = 1
+ HSV_POS = 2
+ HSV_NEG = 3
+
+ ## boundary modes
+ CLIP = 1
+ REPEAT = 2
+ MIRROR = 3
+
+ ## return types
+ BYTE = 1
+ FLOAT = 2
+ QCOLOR = 3
+
+ enumMap = {
+ 'rgb': RGB,
+ 'hsv+': HSV_POS,
+ 'hsv-': HSV_NEG,
+ 'clip': CLIP,
+ 'repeat': REPEAT,
+ 'mirror': MIRROR,
+ 'byte': BYTE,
+ 'float': FLOAT,
+ 'qcolor': QCOLOR,
+ }
+
+ def __init__(self, pos, color, mode=None):
+ """
+ ========= ==============================================================
+ Arguments
+ pos Array of positions where each color is defined
+ color Array of RGBA colors.
+ Integer data types are interpreted as 0-255; float data types
+ are interpreted as 0.0-1.0
+ mode Array of color modes (ColorMap.RGB, HSV_POS, or HSV_NEG)
+ indicating the color space that should be used when
+ interpolating between stops. Note that the last mode value is
+ ignored. By default, the mode is entirely RGB.
+ ========= ==============================================================
+ """
+ self.pos = pos
+ self.color = color
+ if mode is None:
+ mode = np.ones(len(pos))
+ self.mode = mode
+ self.stopsCache = {}
+
+ def map(self, data, mode='byte'):
+ """
+ Data must be either a scalar position or an array (any shape) of positions.
+ """
+ if isinstance(mode, basestring):
+ mode = self.enumMap[mode.lower()]
+
+ if mode == self.QCOLOR:
+ pos, color = self.getStops(self.BYTE)
+ else:
+ pos, color = self.getStops(mode)
+
+ data = np.clip(data, pos.min(), pos.max())
+
+ if not isinstance(data, np.ndarray):
+ interp = scipy.interpolate.griddata(pos, color, np.array([data]))[0]
+ else:
+ interp = scipy.interpolate.griddata(pos, color, data)
+
+ if mode == self.QCOLOR:
+ if not isinstance(data, np.ndarray):
+ return QtGui.QColor(*interp)
+ else:
+ return [QtGui.QColor(*x) for x in interp]
+ else:
+ return interp
+
+ def mapToQColor(self, data):
+ return self.map(data, mode=self.QCOLOR)
+
+ def mapToByte(self, data):
+ return self.map(data, mode=self.BYTE)
+
+ def mapToFloat(self, data):
+ return self.map(data, mode=self.FLOAT)
+
+ def getGradient(self, p1=None, p2=None):
+ """Return a QLinearGradient object."""
+ if p1 == None:
+ p1 = QtCore.QPointF(0,0)
+ if p2 == None:
+ p2 = QtCore.QPointF(self.pos.max()-self.pos.min(),0)
+ g = QtGui.QLinearGradient(p1, p2)
+
+ pos, color = self.getStops(mode=self.BYTE)
+ color = [QtGui.QColor(*x) for x in color]
+ g.setStops(zip(pos, color))
+
+ #if self.colorMode == 'rgb':
+ #ticks = self.listTicks()
+ #g.setStops([(x, QtGui.QColor(t.color)) for t,x in ticks])
+ #elif self.colorMode == 'hsv': ## HSV mode is approximated for display by interpolating 10 points between each stop
+ #ticks = self.listTicks()
+ #stops = []
+ #stops.append((ticks[0][1], ticks[0][0].color))
+ #for i in range(1,len(ticks)):
+ #x1 = ticks[i-1][1]
+ #x2 = ticks[i][1]
+ #dx = (x2-x1) / 10.
+ #for j in range(1,10):
+ #x = x1 + dx*j
+ #stops.append((x, self.getColor(x)))
+ #stops.append((x2, self.getColor(x2)))
+ #g.setStops(stops)
+ return g
+
+ def getColors(self, mode=None):
+ """Return list of all colors converted to the specified mode.
+ If mode is None, then no conversion is done."""
+ if isinstance(mode, basestring):
+ mode = self.enumMap[mode.lower()]
+
+ color = self.color
+ if mode in [self.BYTE, self.QCOLOR] and color.dtype.kind == 'f':
+ color = (color * 255).astype(np.ubyte)
+ elif mode == self.FLOAT and color.dtype.kind != 'f':
+ color = color.astype(float) / 255.
+
+ if mode == self.QCOLOR:
+ color = [QtGui.QColor(*x) for x in color]
+
+ return color
+
+ def getStops(self, mode):
+ ## Get fully-expanded set of RGBA stops in either float or byte mode.
+ if mode not in self.stopsCache:
+ color = self.color
+ if mode == self.BYTE and color.dtype.kind == 'f':
+ color = (color * 255).astype(np.ubyte)
+ elif mode == self.FLOAT and color.dtype.kind != 'f':
+ color = color.astype(float) / 255.
+
+ ## to support HSV mode, we need to do a little more work..
+ #stops = []
+ #for i in range(len(self.pos)):
+ #pos = self.pos[i]
+ #color = color[i]
+
+ #imode = self.mode[i]
+ #if imode == self.RGB:
+ #stops.append((x,color))
+ #else:
+ #ns =
+ self.stopsCache[mode] = (self.pos, color)
+ return self.stopsCache[mode]
+
+ #def getColor(self, x, toQColor=True):
+ #"""
+ #Return a color for a given value.
+
+ #============= ==================================================================
+ #**Arguments**
+ #x Value (position on gradient) of requested color.
+ #toQColor If true, returns a QColor object, else returns a (r,g,b,a) tuple.
+ #============= ==================================================================
+ #"""
+ #ticks = self.listTicks()
+ #if x <= ticks[0][1]:
+ #c = ticks[0][0].color
+ #if toQColor:
+ #return QtGui.QColor(c) # always copy colors before handing them out
+ #else:
+ #return (c.red(), c.green(), c.blue(), c.alpha())
+ #if x >= ticks[-1][1]:
+ #c = ticks[-1][0].color
+ #if toQColor:
+ #return QtGui.QColor(c) # always copy colors before handing them out
+ #else:
+ #return (c.red(), c.green(), c.blue(), c.alpha())
+
+ #x2 = ticks[0][1]
+ #for i in range(1,len(ticks)):
+ #x1 = x2
+ #x2 = ticks[i][1]
+ #if x1 <= x and x2 >= x:
+ #break
+
+ #dx = (x2-x1)
+ #if dx == 0:
+ #f = 0.
+ #else:
+ #f = (x-x1) / dx
+ #c1 = ticks[i-1][0].color
+ #c2 = ticks[i][0].color
+ #if self.colorMode == 'rgb':
+ #r = c1.red() * (1.-f) + c2.red() * f
+ #g = c1.green() * (1.-f) + c2.green() * f
+ #b = c1.blue() * (1.-f) + c2.blue() * f
+ #a = c1.alpha() * (1.-f) + c2.alpha() * f
+ #if toQColor:
+ #return QtGui.QColor(int(r), int(g), int(b), int(a))
+ #else:
+ #return (r,g,b,a)
+ #elif self.colorMode == 'hsv':
+ #h1,s1,v1,_ = c1.getHsv()
+ #h2,s2,v2,_ = c2.getHsv()
+ #h = h1 * (1.-f) + h2 * f
+ #s = s1 * (1.-f) + s2 * f
+ #v = v1 * (1.-f) + v2 * f
+ #c = QtGui.QColor()
+ #c.setHsv(h,s,v)
+ #if toQColor:
+ #return c
+ #else:
+ #return (c.red(), c.green(), c.blue(), c.alpha())
+
+ def getLookupTable(self, start=0.0, stop=1.0, nPts=512, alpha=None, mode='byte'):
+ """
+ Return an RGB(A) lookup table (ndarray).
+
+ ============= ============================================================================
+ **Arguments**
+ nPts The number of points in the returned lookup table.
+ alpha True, False, or None - Specifies whether or not alpha values are included
+ in the table. If alpha is None, it will be automatically determined.
+ ============= ============================================================================
+ """
+ if isinstance(mode, basestring):
+ mode = self.enumMap[mode.lower()]
+
+ if alpha is None:
+ alpha = self.usesAlpha()
+
+ x = np.linspace(start, stop, nPts)
+ table = self.map(x, mode)
+
+ if not alpha:
+ return table[:,:3]
+ else:
+ return table
+
+ def usesAlpha(self):
+ """Return True if any stops have an alpha < 255"""
+ max = 1.0 if self.color.dtype.kind == 'f' else 255
+ return np.any(self.color[:,3] != max)
+
+ def isMapTrivial(self):
+ """Return True if the gradient has exactly two stops in it: black at 0.0 and white at 1.0"""
+ if len(self.pos) != 2:
+ return False
+ if self.pos[0] != 0.0 or self.pos[1] != 1.0:
+ return False
+ if self.color.dtype.kind == 'f':
+ return np.all(self.color == np.array([[0.,0.,0.,1.], [1.,1.,1.,1.]]))
+ else:
+ return np.all(self.color == np.array([[0,0,0,255], [255,255,255,255]]))
+
+
diff --git a/debug.py b/debug.py
index 7fa169a4..ae2b21ac 100644
--- a/debug.py
+++ b/debug.py
@@ -917,3 +917,21 @@ def qObjectReport(verbose=False):
for t in typs:
print(count[t], "\t", t)
+
+class PrintDetector(object):
+ def __init__(self):
+ self.stdout = sys.stdout
+ sys.stdout = self
+
+ def remove(self):
+ sys.stdout = self.stdout
+
+ def __del__(self):
+ self.remove()
+
+ def write(self, x):
+ self.stdout.write(x)
+ traceback.print_stack()
+
+ def flush(self):
+ self.stdout.flush()
\ No newline at end of file
diff --git a/exporters/Exporter.py b/exporters/Exporter.py
index b1a663bc..81930670 100644
--- a/exporters/Exporter.py
+++ b/exporters/Exporter.py
@@ -9,7 +9,8 @@ class Exporter(object):
"""
Abstract class used for exporting graphics to file / printer / whatever.
"""
-
+ allowCopy = False # subclasses set this to True if they can use the copy buffer
+
def __init__(self, item):
"""
Initialize with the item to be exported.
@@ -25,10 +26,11 @@ class Exporter(object):
"""Return the parameters used to configure this exporter."""
raise Exception("Abstract method must be overridden in subclass.")
- def export(self, fileName=None, toBytes=False):
+ def export(self, fileName=None, toBytes=False, copy=False):
"""
If *fileName* is None, pop-up a file dialog.
- If *toString* is True, return a bytes object rather than writing to file.
+ If *toBytes* is True, return a bytes object rather than writing to file.
+ If *copy* is True, export to the copy buffer rather than writing to file.
"""
raise Exception("Abstract method must be overridden in subclass.")
diff --git a/exporters/ImageExporter.py b/exporters/ImageExporter.py
index cb6cf396..bdb8b9be 100644
--- a/exporters/ImageExporter.py
+++ b/exporters/ImageExporter.py
@@ -8,6 +8,8 @@ __all__ = ['ImageExporter']
class ImageExporter(Exporter):
Name = "Image File (PNG, TIF, JPG, ...)"
+ allowCopy = True
+
def __init__(self, item):
Exporter.__init__(self, item)
tr = self.getTargetRect()
@@ -38,8 +40,8 @@ class ImageExporter(Exporter):
def parameters(self):
return self.params
- def export(self, fileName=None):
- if fileName is None:
+ def export(self, fileName=None, toBytes=False, copy=False):
+ if fileName is None and not toBytes and not copy:
filter = ["*."+str(f) for f in QtGui.QImageWriter.supportedImageFormats()]
preferred = ['*.png', '*.tif', '*.jpg']
for p in preferred[::-1]:
@@ -78,6 +80,12 @@ class ImageExporter(Exporter):
finally:
self.setExportMode(False)
painter.end()
- self.png.save(fileName)
+
+ if copy:
+ QtGui.QApplication.clipboard().setImage(self.png)
+ elif toBytes:
+ return self.png
+ else:
+ self.png.save(fileName)
\ No newline at end of file
diff --git a/exporters/SVGExporter.py b/exporters/SVGExporter.py
index 587282e0..b284db89 100644
--- a/exporters/SVGExporter.py
+++ b/exporters/SVGExporter.py
@@ -11,6 +11,8 @@ __all__ = ['SVGExporter']
class SVGExporter(Exporter):
Name = "Scalable Vector Graphics (SVG)"
+ allowCopy=True
+
def __init__(self, item):
Exporter.__init__(self, item)
#tr = self.getTargetRect()
@@ -37,8 +39,8 @@ class SVGExporter(Exporter):
def parameters(self):
return self.params
- def export(self, fileName=None, toBytes=False):
- if toBytes is False and fileName is None:
+ def export(self, fileName=None, toBytes=False, copy=False):
+ if toBytes is False and copy is False and fileName is None:
self.fileSaveDialog(filter="Scalable Vector Graphics (*.svg)")
return
#self.svg = QtSvg.QSvgGenerator()
@@ -83,11 +85,16 @@ class SVGExporter(Exporter):
xml = generateSvg(self.item)
if toBytes:
- return bytes(xml)
+ return xml.encode('UTF-8')
+ elif copy:
+ md = QtCore.QMimeData()
+ md.setData('image/svg+xml', QtCore.QByteArray(xml.encode('UTF-8')))
+ QtGui.QApplication.clipboard().setMimeData(md)
else:
with open(fileName, 'w') as fh:
fh.write(xml.encode('UTF-8'))
+
xmlHeader = """\