From ca3fbe2ff9d07162e4cc4488f4280f0b189f86e8 Mon Sep 17 00:00:00 2001 From: Luke Campagnola Date: Thu, 7 Aug 2014 08:41:30 -0400 Subject: [PATCH] Merged numerous updates from acq4: * Added HDF5 exporter * CSV exporter gets (x,y,y,y) export mode * Updates to SVG, Matplotlib exporter * Console can filter exceptions by string * Added tick context menu to GradientEditorItem * Added export feature to imageview * Parameter trees: - Option to save only user-editable values - Option to set visible title of parameters separately from name - Added experimental ParameterSystem for handling large systems of interdependent parameters - Auto-select editable portion of spinbox when editing * Added Vector.__abs__ * Added replacement garbage collector for avoiding crashes on multithreaded Qt * Fixed "illegal instruction" caused by closing file handle 7 on OSX * configfile now reloads QtCore objects, Point, ColorMap, numpy arrays * Avoid triggering recursion issues in exception handler * Various bugfies and performance enhancements --- Vector.py | 2 + __init__.py | 9 +- canvas/Canvas.py | 18 +- canvas/CanvasTemplate.ui | 28 +- canvas/CanvasTemplate_pyqt.py | 53 ++-- colormap.py | 5 +- configfile.py | 17 +- console/Console.py | 11 + console/template.ui | 42 +-- console/template_pyqt.py | 35 ++- debug.py | 127 +++++++-- exceptionHandling.py | 56 ++-- exporters/CSVExporter.py | 32 ++- exporters/HDF5Exporter.py | 58 ++++ exporters/Matplotlib.py | 61 ++++- exporters/SVGExporter.py | 33 ++- exporters/__init__.py | 2 +- flowchart/Flowchart.py | 57 +--- flowchart/library/Data.py | 4 +- flowchart/library/Filters.py | 70 ++++- flowchart/library/common.py | 36 +++ flowchart/library/functions.py | 2 +- functions.py | 92 +++++++ graphicsItems/AxisItem.py | 16 +- graphicsItems/GradientEditorItem.py | 127 +++++---- graphicsItems/ImageItem.py | 11 +- graphicsItems/PlotDataItem.py | 18 +- graphicsItems/PlotItem/PlotItem.py | 14 +- graphicsItems/ROI.py | 112 +++++++- graphicsItems/ScaleBar.py | 61 +---- graphicsItems/ScatterPlotItem.py | 10 +- graphicsItems/ViewBox/ViewBox.py | 33 ++- imageview/ImageView.py | 51 +++- imageview/ImageViewTemplate.ui | 7 +- imageview/ImageViewTemplate_pyqt.py | 19 +- imageview/ImageViewTemplate_pyside.py | 19 +- metaarray/MetaArray.py | 72 +++-- multiprocess/remoteproxy.py | 223 +++++++++------ parametertree/Parameter.py | 104 +++++-- parametertree/ParameterItem.py | 24 +- parametertree/ParameterSystem.py | 127 +++++++++ parametertree/SystemSolver.py | 381 ++++++++++++++++++++++++++ parametertree/__init__.py | 2 +- parametertree/parameterTypes.py | 13 +- tests/test_functions.py | 13 + util/garbage_collector.py | 50 ++++ widgets/ColorMapWidget.py | 47 +++- widgets/ComboBox.py | 5 + widgets/DataTreeWidget.py | 2 +- widgets/SpinBox.py | 30 +- widgets/TableWidget.py | 13 +- 51 files changed, 1913 insertions(+), 541 deletions(-) create mode 100644 exporters/HDF5Exporter.py create mode 100644 parametertree/ParameterSystem.py create mode 100644 parametertree/SystemSolver.py create mode 100644 util/garbage_collector.py diff --git a/Vector.py b/Vector.py index b18b3091..f2898e80 100644 --- a/Vector.py +++ b/Vector.py @@ -81,5 +81,7 @@ class Vector(QtGui.QVector3D): # ang *= -1. return ang * 180. / np.pi + def __abs__(self): + return Vector(abs(self.x()), abs(self.y()), abs(self.z())) \ No newline at end of file diff --git a/__init__.py b/__init__.py index 01e84c49..f8983455 100644 --- a/__init__.py +++ b/__init__.py @@ -325,8 +325,13 @@ def exit(): atexit._run_exitfuncs() ## close file handles - os.closerange(3, 4096) ## just guessing on the maximum descriptor count.. - + if sys.platform == 'darwin': + for fd in xrange(3, 4096): + if fd not in [7]: # trying to close 7 produces an illegal instruction on the Mac. + os.close(fd) + else: + os.closerange(3, 4096) ## just guessing on the maximum descriptor count.. + os._exit(0) diff --git a/canvas/Canvas.py b/canvas/Canvas.py index d07b3428..4de891f7 100644 --- a/canvas/Canvas.py +++ b/canvas/Canvas.py @@ -67,8 +67,8 @@ class Canvas(QtGui.QWidget): self.ui.itemList.sigItemMoved.connect(self.treeItemMoved) self.ui.itemList.itemSelectionChanged.connect(self.treeItemSelected) self.ui.autoRangeBtn.clicked.connect(self.autoRange) - self.ui.storeSvgBtn.clicked.connect(self.storeSvg) - self.ui.storePngBtn.clicked.connect(self.storePng) + #self.ui.storeSvgBtn.clicked.connect(self.storeSvg) + #self.ui.storePngBtn.clicked.connect(self.storePng) self.ui.redirectCheck.toggled.connect(self.updateRedirect) self.ui.redirectCombo.currentIndexChanged.connect(self.updateRedirect) self.multiSelectBox.sigRegionChanged.connect(self.multiSelectBoxChanged) @@ -94,11 +94,13 @@ class Canvas(QtGui.QWidget): self.ui.itemList.contextMenuEvent = self.itemListContextMenuEvent - def storeSvg(self): - self.ui.view.writeSvg() + #def storeSvg(self): + #from pyqtgraph.GraphicsScene.exportDialog import ExportDialog + #ex = ExportDialog(self.ui.view) + #ex.show() - def storePng(self): - self.ui.view.writeImage() + #def storePng(self): + #self.ui.view.writeImage() def splitterMoved(self): self.resizeEvent() @@ -571,7 +573,9 @@ class Canvas(QtGui.QWidget): self.menu.popup(ev.globalPos()) def removeClicked(self): - self.removeItem(self.menuItem) + #self.removeItem(self.menuItem) + for item in self.selectedItems(): + self.removeItem(item) self.menuItem = None import gc gc.collect() diff --git a/canvas/CanvasTemplate.ui b/canvas/CanvasTemplate.ui index 218cf48d..9bea8f89 100644 --- a/canvas/CanvasTemplate.ui +++ b/canvas/CanvasTemplate.ui @@ -28,21 +28,7 @@ - - - - Store SVG - - - - - - - Store PNG - - - - + @@ -55,7 +41,7 @@ - + 0 @@ -75,7 +61,7 @@ - + @@ -93,28 +79,28 @@ - + 0 - + Reset Transforms - + Mirror Selection - + MirrorXY diff --git a/canvas/CanvasTemplate_pyqt.py b/canvas/CanvasTemplate_pyqt.py index c809cb1d..557354e0 100644 --- a/canvas/CanvasTemplate_pyqt.py +++ b/canvas/CanvasTemplate_pyqt.py @@ -1,9 +1,9 @@ # -*- coding: utf-8 -*- -# Form implementation generated from reading ui file './pyqtgraph/canvas/CanvasTemplate.ui' +# Form implementation generated from reading ui file 'acq4/pyqtgraph/canvas/CanvasTemplate.ui' # -# Created: Mon Dec 23 10:10:52 2013 -# by: PyQt4 UI code generator 4.10 +# Created: Thu Jan 2 11:13:07 2014 +# by: PyQt4 UI code generator 4.9 # # WARNING! All changes made in this file will be lost! @@ -12,16 +12,7 @@ from PyQt4 import QtCore, QtGui try: _fromUtf8 = QtCore.QString.fromUtf8 except AttributeError: - def _fromUtf8(s): - return s - -try: - _encoding = QtGui.QApplication.UnicodeUTF8 - def _translate(context, text, disambig): - return QtGui.QApplication.translate(context, text, disambig, _encoding) -except AttributeError: - def _translate(context, text, disambig): - return QtGui.QApplication.translate(context, text, disambig) + _fromUtf8 = lambda s: s class Ui_Form(object): def setupUi(self, Form): @@ -41,12 +32,6 @@ class Ui_Form(object): self.gridLayout_2 = QtGui.QGridLayout(self.layoutWidget) self.gridLayout_2.setMargin(0) self.gridLayout_2.setObjectName(_fromUtf8("gridLayout_2")) - self.storeSvgBtn = QtGui.QPushButton(self.layoutWidget) - self.storeSvgBtn.setObjectName(_fromUtf8("storeSvgBtn")) - self.gridLayout_2.addWidget(self.storeSvgBtn, 1, 0, 1, 1) - self.storePngBtn = QtGui.QPushButton(self.layoutWidget) - self.storePngBtn.setObjectName(_fromUtf8("storePngBtn")) - self.gridLayout_2.addWidget(self.storePngBtn, 1, 1, 1, 1) self.autoRangeBtn = QtGui.QPushButton(self.layoutWidget) sizePolicy = QtGui.QSizePolicy(QtGui.QSizePolicy.Minimum, QtGui.QSizePolicy.Fixed) sizePolicy.setHorizontalStretch(0) @@ -54,7 +39,7 @@ class Ui_Form(object): sizePolicy.setHeightForWidth(self.autoRangeBtn.sizePolicy().hasHeightForWidth()) self.autoRangeBtn.setSizePolicy(sizePolicy) self.autoRangeBtn.setObjectName(_fromUtf8("autoRangeBtn")) - self.gridLayout_2.addWidget(self.autoRangeBtn, 3, 0, 1, 2) + self.gridLayout_2.addWidget(self.autoRangeBtn, 2, 0, 1, 2) self.horizontalLayout = QtGui.QHBoxLayout() self.horizontalLayout.setSpacing(0) self.horizontalLayout.setObjectName(_fromUtf8("horizontalLayout")) @@ -64,7 +49,7 @@ class Ui_Form(object): self.redirectCombo = CanvasCombo(self.layoutWidget) self.redirectCombo.setObjectName(_fromUtf8("redirectCombo")) self.horizontalLayout.addWidget(self.redirectCombo) - self.gridLayout_2.addLayout(self.horizontalLayout, 6, 0, 1, 2) + self.gridLayout_2.addLayout(self.horizontalLayout, 5, 0, 1, 2) self.itemList = TreeWidget(self.layoutWidget) sizePolicy = QtGui.QSizePolicy(QtGui.QSizePolicy.Expanding, QtGui.QSizePolicy.Expanding) sizePolicy.setHorizontalStretch(0) @@ -74,35 +59,33 @@ class Ui_Form(object): self.itemList.setHeaderHidden(True) self.itemList.setObjectName(_fromUtf8("itemList")) self.itemList.headerItem().setText(0, _fromUtf8("1")) - self.gridLayout_2.addWidget(self.itemList, 7, 0, 1, 2) + self.gridLayout_2.addWidget(self.itemList, 6, 0, 1, 2) self.ctrlLayout = QtGui.QGridLayout() self.ctrlLayout.setSpacing(0) self.ctrlLayout.setObjectName(_fromUtf8("ctrlLayout")) - self.gridLayout_2.addLayout(self.ctrlLayout, 11, 0, 1, 2) + self.gridLayout_2.addLayout(self.ctrlLayout, 10, 0, 1, 2) self.resetTransformsBtn = QtGui.QPushButton(self.layoutWidget) self.resetTransformsBtn.setObjectName(_fromUtf8("resetTransformsBtn")) - self.gridLayout_2.addWidget(self.resetTransformsBtn, 8, 0, 1, 1) + self.gridLayout_2.addWidget(self.resetTransformsBtn, 7, 0, 1, 1) self.mirrorSelectionBtn = QtGui.QPushButton(self.layoutWidget) self.mirrorSelectionBtn.setObjectName(_fromUtf8("mirrorSelectionBtn")) - self.gridLayout_2.addWidget(self.mirrorSelectionBtn, 4, 0, 1, 1) + self.gridLayout_2.addWidget(self.mirrorSelectionBtn, 3, 0, 1, 1) self.reflectSelectionBtn = QtGui.QPushButton(self.layoutWidget) self.reflectSelectionBtn.setObjectName(_fromUtf8("reflectSelectionBtn")) - self.gridLayout_2.addWidget(self.reflectSelectionBtn, 4, 1, 1, 1) + self.gridLayout_2.addWidget(self.reflectSelectionBtn, 3, 1, 1, 1) self.gridLayout.addWidget(self.splitter, 0, 0, 1, 1) self.retranslateUi(Form) QtCore.QMetaObject.connectSlotsByName(Form) def retranslateUi(self, Form): - Form.setWindowTitle(_translate("Form", "Form", None)) - self.storeSvgBtn.setText(_translate("Form", "Store SVG", None)) - self.storePngBtn.setText(_translate("Form", "Store PNG", None)) - self.autoRangeBtn.setText(_translate("Form", "Auto Range", None)) - self.redirectCheck.setToolTip(_translate("Form", "Check to display all local items in a remote canvas.", None)) - self.redirectCheck.setText(_translate("Form", "Redirect", None)) - self.resetTransformsBtn.setText(_translate("Form", "Reset Transforms", None)) - self.mirrorSelectionBtn.setText(_translate("Form", "Mirror Selection", None)) - self.reflectSelectionBtn.setText(_translate("Form", "MirrorXY", None)) + Form.setWindowTitle(QtGui.QApplication.translate("Form", "Form", None, QtGui.QApplication.UnicodeUTF8)) + self.autoRangeBtn.setText(QtGui.QApplication.translate("Form", "Auto Range", None, QtGui.QApplication.UnicodeUTF8)) + self.redirectCheck.setToolTip(QtGui.QApplication.translate("Form", "Check to display all local items in a remote canvas.", None, QtGui.QApplication.UnicodeUTF8)) + self.redirectCheck.setText(QtGui.QApplication.translate("Form", "Redirect", None, QtGui.QApplication.UnicodeUTF8)) + self.resetTransformsBtn.setText(QtGui.QApplication.translate("Form", "Reset Transforms", None, QtGui.QApplication.UnicodeUTF8)) + self.mirrorSelectionBtn.setText(QtGui.QApplication.translate("Form", "Mirror Selection", None, QtGui.QApplication.UnicodeUTF8)) + self.reflectSelectionBtn.setText(QtGui.QApplication.translate("Form", "MirrorXY", None, QtGui.QApplication.UnicodeUTF8)) from ..widgets.TreeWidget import TreeWidget from CanvasManager import CanvasCombo diff --git a/colormap.py b/colormap.py index 446044e1..c0033708 100644 --- a/colormap.py +++ b/colormap.py @@ -244,4 +244,7 @@ class ColorMap(object): else: return np.all(self.color == np.array([[0,0,0,255], [255,255,255,255]])) - + def __repr__(self): + pos = repr(self.pos).replace('\n', '') + color = repr(self.color).replace('\n', '') + return "ColorMap(%s, %s)" % (pos, color) diff --git a/configfile.py b/configfile.py index f709c786..c095bba3 100644 --- a/configfile.py +++ b/configfile.py @@ -14,6 +14,10 @@ from .pgcollections import OrderedDict GLOBAL_PATH = None # so not thread safe. from . import units from .python2_3 import asUnicode +from .Qt import QtCore +from .Point import Point +from .colormap import ColorMap +import numpy class ParseError(Exception): def __init__(self, message, lineNum, line, fileName=None): @@ -46,7 +50,7 @@ def readConfigFile(fname): fname2 = os.path.join(GLOBAL_PATH, fname) if os.path.exists(fname2): fname = fname2 - + GLOBAL_PATH = os.path.dirname(os.path.abspath(fname)) try: @@ -135,6 +139,17 @@ def parseString(lines, start=0): local = units.allUnits.copy() local['OrderedDict'] = OrderedDict local['readConfigFile'] = readConfigFile + local['Point'] = Point + local['QtCore'] = QtCore + local['ColorMap'] = ColorMap + # Needed for reconstructing numpy arrays + local['array'] = numpy.array + for dtype in ['int8', 'uint8', + 'int16', 'uint16', 'float16', + 'int32', 'uint32', 'float32', + 'int64', 'uint64', 'float64']: + local[dtype] = getattr(numpy, dtype) + if len(k) < 1: raise ParseError('Missing name preceding colon', ln+1, l) if k[0] == '(' and k[-1] == ')': ## If the key looks like a tuple, try evaluating it. diff --git a/console/Console.py b/console/Console.py index 6d77c4cf..896de924 100644 --- a/console/Console.py +++ b/console/Console.py @@ -341,6 +341,17 @@ class ConsoleWidget(QtGui.QWidget): filename = tb.tb_frame.f_code.co_filename function = tb.tb_frame.f_code.co_name + filterStr = str(self.ui.filterText.text()) + if filterStr != '': + if isinstance(exc, Exception): + msg = exc.message + elif isinstance(exc, basestring): + msg = exc + else: + msg = repr(exc) + match = re.search(filterStr, "%s:%s:%s" % (filename, function, msg)) + return match is not None + ## Go through a list of common exception points we like to ignore: if excType is GeneratorExit or excType is StopIteration: return False diff --git a/console/template.ui b/console/template.ui index 6e5c5be3..1a672c5e 100644 --- a/console/template.ui +++ b/console/template.ui @@ -6,7 +6,7 @@ 0 0 - 710 + 694 497 @@ -89,6 +89,16 @@ 0 + + + + false + + + Clear Exception + + + @@ -109,7 +119,7 @@ - + Only Uncaught Exceptions @@ -119,14 +129,14 @@ - + true - + Run commands in selected stack frame @@ -136,24 +146,14 @@ - + Exception Info - - - - false - - - Clear Exception - - - - + Qt::Horizontal @@ -166,6 +166,16 @@ + + + + Filter (regex): + + + + + + diff --git a/console/template_pyqt.py b/console/template_pyqt.py index e0852c93..354fb1d6 100644 --- a/console/template_pyqt.py +++ b/console/template_pyqt.py @@ -1,9 +1,9 @@ # -*- coding: utf-8 -*- -# Form implementation generated from reading ui file './pyqtgraph/console/template.ui' +# Form implementation generated from reading ui file 'template.ui' # -# Created: Mon Dec 23 10:10:53 2013 -# by: PyQt4 UI code generator 4.10 +# Created: Fri May 02 18:55:28 2014 +# by: PyQt4 UI code generator 4.10.4 # # WARNING! All changes made in this file will be lost! @@ -26,7 +26,7 @@ except AttributeError: class Ui_Form(object): def setupUi(self, Form): Form.setObjectName(_fromUtf8("Form")) - Form.resize(710, 497) + Form.resize(694, 497) self.gridLayout = QtGui.QGridLayout(Form) self.gridLayout.setMargin(0) self.gridLayout.setSpacing(0) @@ -71,6 +71,10 @@ class Ui_Form(object): self.gridLayout_2.setSpacing(0) self.gridLayout_2.setContentsMargins(-1, 0, -1, 0) self.gridLayout_2.setObjectName(_fromUtf8("gridLayout_2")) + self.clearExceptionBtn = QtGui.QPushButton(self.exceptionGroup) + self.clearExceptionBtn.setEnabled(False) + self.clearExceptionBtn.setObjectName(_fromUtf8("clearExceptionBtn")) + self.gridLayout_2.addWidget(self.clearExceptionBtn, 0, 6, 1, 1) self.catchAllExceptionsBtn = QtGui.QPushButton(self.exceptionGroup) self.catchAllExceptionsBtn.setCheckable(True) self.catchAllExceptionsBtn.setObjectName(_fromUtf8("catchAllExceptionsBtn")) @@ -82,24 +86,26 @@ class Ui_Form(object): self.onlyUncaughtCheck = QtGui.QCheckBox(self.exceptionGroup) self.onlyUncaughtCheck.setChecked(True) self.onlyUncaughtCheck.setObjectName(_fromUtf8("onlyUncaughtCheck")) - self.gridLayout_2.addWidget(self.onlyUncaughtCheck, 0, 2, 1, 1) + self.gridLayout_2.addWidget(self.onlyUncaughtCheck, 0, 4, 1, 1) self.exceptionStackList = QtGui.QListWidget(self.exceptionGroup) self.exceptionStackList.setAlternatingRowColors(True) self.exceptionStackList.setObjectName(_fromUtf8("exceptionStackList")) - self.gridLayout_2.addWidget(self.exceptionStackList, 2, 0, 1, 5) + self.gridLayout_2.addWidget(self.exceptionStackList, 2, 0, 1, 7) self.runSelectedFrameCheck = QtGui.QCheckBox(self.exceptionGroup) self.runSelectedFrameCheck.setChecked(True) self.runSelectedFrameCheck.setObjectName(_fromUtf8("runSelectedFrameCheck")) - self.gridLayout_2.addWidget(self.runSelectedFrameCheck, 3, 0, 1, 5) + self.gridLayout_2.addWidget(self.runSelectedFrameCheck, 3, 0, 1, 7) self.exceptionInfoLabel = QtGui.QLabel(self.exceptionGroup) self.exceptionInfoLabel.setObjectName(_fromUtf8("exceptionInfoLabel")) - self.gridLayout_2.addWidget(self.exceptionInfoLabel, 1, 0, 1, 5) - self.clearExceptionBtn = QtGui.QPushButton(self.exceptionGroup) - self.clearExceptionBtn.setEnabled(False) - self.clearExceptionBtn.setObjectName(_fromUtf8("clearExceptionBtn")) - self.gridLayout_2.addWidget(self.clearExceptionBtn, 0, 4, 1, 1) + self.gridLayout_2.addWidget(self.exceptionInfoLabel, 1, 0, 1, 7) spacerItem = QtGui.QSpacerItem(40, 20, QtGui.QSizePolicy.Expanding, QtGui.QSizePolicy.Minimum) - self.gridLayout_2.addItem(spacerItem, 0, 3, 1, 1) + self.gridLayout_2.addItem(spacerItem, 0, 5, 1, 1) + self.label = QtGui.QLabel(self.exceptionGroup) + self.label.setObjectName(_fromUtf8("label")) + self.gridLayout_2.addWidget(self.label, 0, 2, 1, 1) + self.filterText = QtGui.QLineEdit(self.exceptionGroup) + self.filterText.setObjectName(_fromUtf8("filterText")) + self.gridLayout_2.addWidget(self.filterText, 0, 3, 1, 1) self.gridLayout.addWidget(self.splitter, 0, 0, 1, 1) self.retranslateUi(Form) @@ -110,11 +116,12 @@ class Ui_Form(object): self.historyBtn.setText(_translate("Form", "History..", None)) self.exceptionBtn.setText(_translate("Form", "Exceptions..", None)) self.exceptionGroup.setTitle(_translate("Form", "Exception Handling", None)) + self.clearExceptionBtn.setText(_translate("Form", "Clear Exception", None)) self.catchAllExceptionsBtn.setText(_translate("Form", "Show All Exceptions", None)) self.catchNextExceptionBtn.setText(_translate("Form", "Show Next Exception", None)) self.onlyUncaughtCheck.setText(_translate("Form", "Only Uncaught Exceptions", None)) self.runSelectedFrameCheck.setText(_translate("Form", "Run commands in selected stack frame", None)) self.exceptionInfoLabel.setText(_translate("Form", "Exception Info", None)) - self.clearExceptionBtn.setText(_translate("Form", "Clear Exception", None)) + self.label.setText(_translate("Form", "Filter (regex):", None)) from .CmdInput import CmdInput diff --git a/debug.py b/debug.py index 0deae0e0..57c71bc8 100644 --- a/debug.py +++ b/debug.py @@ -32,6 +32,57 @@ def ftrace(func): return rv return w + +class Tracer(object): + """ + Prints every function enter/exit. Useful for debugging crashes / lockups. + """ + def __init__(self): + self.count = 0 + self.stack = [] + + def trace(self, frame, event, arg): + self.count += 1 + # If it has been a long time since we saw the top of the stack, + # print a reminder + if self.count % 1000 == 0: + print("----- current stack: -----") + for line in self.stack: + print(line) + if event == 'call': + line = " " * len(self.stack) + ">> " + self.frameInfo(frame) + print(line) + self.stack.append(line) + elif event == 'return': + self.stack.pop() + line = " " * len(self.stack) + "<< " + self.frameInfo(frame) + print(line) + if len(self.stack) == 0: + self.count = 0 + + return self.trace + + def stop(self): + sys.settrace(None) + + def start(self): + sys.settrace(self.trace) + + def frameInfo(self, fr): + filename = fr.f_code.co_filename + funcname = fr.f_code.co_name + lineno = fr.f_lineno + callfr = sys._getframe(3) + callline = "%s %d" % (callfr.f_code.co_name, callfr.f_lineno) + args, _, _, value_dict = inspect.getargvalues(fr) + if len(args) and args[0] == 'self': + instance = value_dict.get('self', None) + if instance is not None: + cls = getattr(instance, '__class__', None) + if cls is not None: + funcname = cls.__name__ + "." + funcname + return "%s: %s %s: %s" % (callline, filename, lineno, funcname) + def warnOnException(func): """Decorator which catches/ignores exceptions and prints a stack trace.""" def w(*args, **kwds): @@ -41,17 +92,22 @@ def warnOnException(func): printExc('Ignored exception:') return w -def getExc(indent=4, prefix='| '): - tb = traceback.format_exc() - lines = [] - for l in tb.split('\n'): - lines.append(" "*indent + prefix + l) - return '\n'.join(lines) +def getExc(indent=4, prefix='| ', skip=1): + lines = (traceback.format_stack()[:-skip] + + [" ---- exception caught ---->\n"] + + traceback.format_tb(sys.exc_info()[2]) + + traceback.format_exception_only(*sys.exc_info()[:2])) + lines2 = [] + for l in lines: + lines2.extend(l.strip('\n').split('\n')) + lines3 = [" "*indent + prefix + l for l in lines2] + return '\n'.join(lines3) + def printExc(msg='', indent=4, prefix='|'): """Print an error message followed by an indented exception backtrace (This function is intended to be called within except: blocks)""" - exc = getExc(indent, prefix + ' ') + exc = getExc(indent, prefix + ' ', skip=2) print("[%s] %s\n" % (time.strftime("%H:%M:%S"), msg)) print(" "*indent + prefix + '='*30 + '>>') print(exc) @@ -407,6 +463,7 @@ class Profiler(object): _depth = 0 _msgs = [] + disable = False # set this flag to disable all or individual profilers at runtime class DisabledProfiler(object): def __init__(self, *args, **kwds): @@ -418,12 +475,11 @@ class Profiler(object): def mark(self, msg=None): pass _disabledProfiler = DisabledProfiler() - - + def __new__(cls, msg=None, disabled='env', delayed=True): """Optionally create a new profiler based on caller's qualname. """ - if disabled is True or (disabled=='env' and len(cls._profilers) == 0): + if disabled is True or (disabled == 'env' and len(cls._profilers) == 0): return cls._disabledProfiler # determine the qualified name of the caller function @@ -431,11 +487,11 @@ class Profiler(object): try: caller_object_type = type(caller_frame.f_locals["self"]) except KeyError: # we are in a regular function - qualifier = caller_frame.f_globals["__name__"].split(".", 1)[-1] + qualifier = caller_frame.f_globals["__name__"].split(".", 1)[1] else: # we are in a method qualifier = caller_object_type.__name__ func_qualname = qualifier + "." + caller_frame.f_code.co_name - if disabled=='env' and func_qualname not in cls._profilers: # don't do anything + if disabled == 'env' and func_qualname not in cls._profilers: # don't do anything return cls._disabledProfiler # create an actual profiling object cls._depth += 1 @@ -447,13 +503,12 @@ class Profiler(object): obj._firstTime = obj._lastTime = ptime.time() obj._newMsg("> Entering " + obj._name) return obj - #else: - #def __new__(cls, delayed=True): - #return lambda msg=None: None def __call__(self, msg=None): """Register or print a new message with timing information. """ + if self.disable: + return if msg is None: msg = str(self._markCount) self._markCount += 1 @@ -479,7 +534,7 @@ class Profiler(object): def finish(self, msg=None): """Add a final message; flush the message list if no parent profiler. """ - if self._finished: + if self._finished or self.disable: return self._finished = True if msg is not None: @@ -984,6 +1039,7 @@ def qObjectReport(verbose=False): class PrintDetector(object): + """Find code locations that print to stdout.""" def __init__(self): self.stdout = sys.stdout sys.stdout = self @@ -1002,6 +1058,45 @@ class PrintDetector(object): self.stdout.flush() +def listQThreads(): + """Prints Thread IDs (Qt's, not OS's) for all QThreads.""" + thr = findObj('[Tt]hread') + thr = [t for t in thr if isinstance(t, QtCore.QThread)] + import sip + for t in thr: + print("--> ", t) + print(" Qt ID: 0x%x" % sip.unwrapinstance(t)) + + +def pretty(data, indent=''): + """Format nested dict/list/tuple structures into a more human-readable string + This function is a bit better than pprint for displaying OrderedDicts. + """ + ret = "" + ind2 = indent + " " + if isinstance(data, dict): + ret = indent+"{\n" + for k, v in data.iteritems(): + ret += ind2 + repr(k) + ": " + pretty(v, ind2).strip() + "\n" + ret += indent+"}\n" + elif isinstance(data, list) or isinstance(data, tuple): + s = repr(data) + if len(s) < 40: + ret += indent + s + else: + if isinstance(data, list): + d = '[]' + else: + d = '()' + ret = indent+d[0]+"\n" + for i, v in enumerate(data): + ret += ind2 + str(i) + ": " + pretty(v, ind2).strip() + "\n" + ret += indent+d[1]+"\n" + else: + ret += indent + repr(data) + return ret + + class PeriodicTrace(object): """ Used to debug freezing by starting a new thread that reports on the diff --git a/exceptionHandling.py b/exceptionHandling.py index daa821b7..3182b7eb 100644 --- a/exceptionHandling.py +++ b/exceptionHandling.py @@ -49,29 +49,45 @@ def setTracebackClearing(clear=True): class ExceptionHandler(object): def __call__(self, *args): - ## call original exception handler first (prints exception) - global original_excepthook, callbacks, clear_tracebacks - print("===== %s =====" % str(time.strftime("%Y.%m.%d %H:%m:%S", time.localtime(time.time())))) - ret = original_excepthook(*args) + ## Start by extending recursion depth just a bit. + ## If the error we are catching is due to recursion, we don't want to generate another one here. + recursionLimit = sys.getrecursionlimit() + try: + sys.setrecursionlimit(recursionLimit+100) - for cb in callbacks: + + ## call original exception handler first (prints exception) + global original_excepthook, callbacks, clear_tracebacks try: - cb(*args) - except: - print(" --------------------------------------------------------------") - print(" Error occurred during exception callback %s" % str(cb)) - print(" --------------------------------------------------------------") - traceback.print_exception(*sys.exc_info()) - - - ## Clear long-term storage of last traceback to prevent memory-hogging. - ## (If an exception occurs while a lot of data is present on the stack, - ## such as when loading large files, the data would ordinarily be kept - ## until the next exception occurs. We would rather release this memory - ## as soon as possible.) - if clear_tracebacks is True: - sys.last_traceback = None + print("===== %s =====" % str(time.strftime("%Y.%m.%d %H:%m:%S", time.localtime(time.time())))) + except Exception: + sys.stderr.write("Warning: stdout is broken! Falling back to stderr.\n") + sys.stdout = sys.stderr + ret = original_excepthook(*args) + + for cb in callbacks: + try: + cb(*args) + except Exception: + print(" --------------------------------------------------------------") + print(" Error occurred during exception callback %s" % str(cb)) + print(" --------------------------------------------------------------") + traceback.print_exception(*sys.exc_info()) + + + ## Clear long-term storage of last traceback to prevent memory-hogging. + ## (If an exception occurs while a lot of data is present on the stack, + ## such as when loading large files, the data would ordinarily be kept + ## until the next exception occurs. We would rather release this memory + ## as soon as possible.) + if clear_tracebacks is True: + sys.last_traceback = None + + finally: + sys.setrecursionlimit(recursionLimit) + + def implements(self, interface=None): ## this just makes it easy for us to detect whether an ExceptionHook is already installed. if interface is None: diff --git a/exporters/CSVExporter.py b/exporters/CSVExporter.py index 6ed4cf07..b87f0182 100644 --- a/exporters/CSVExporter.py +++ b/exporters/CSVExporter.py @@ -14,6 +14,7 @@ class CSVExporter(Exporter): self.params = Parameter(name='params', type='group', children=[ {'name': 'separator', 'type': 'list', 'value': 'comma', 'values': ['comma', 'tab']}, {'name': 'precision', 'type': 'int', 'value': 10, 'limits': [0, None]}, + {'name': 'columnMode', 'type': 'list', 'values': ['(x,y) per plot', '(x,y,y,y) for all plots']} ]) def parameters(self): @@ -31,15 +32,24 @@ class CSVExporter(Exporter): fd = open(fileName, 'w') data = [] header = [] - for c in self.item.curves: + + appendAllX = self.params['columnMode'] == '(x,y) per plot' + + for i, c in enumerate(self.item.curves): cd = c.getData() if cd[0] is None: continue data.append(cd) - name = '' if hasattr(c, 'implements') and c.implements('plotData') and c.name() is not None: name = c.name().replace('"', '""') + '_' - header.extend(['"'+name+'x"', '"'+name+'y"']) + xName, yName = '"'+name+'x"', '"'+name+'y"' + else: + xName = 'x%04d' % i + yName = 'y%04d' % i + if appendAllX or i == 0: + header.extend([xName, yName]) + else: + header.extend([yName]) if self.params['separator'] == 'comma': sep = ',' @@ -51,12 +61,20 @@ class CSVExporter(Exporter): numFormat = '%%0.%dg' % self.params['precision'] numRows = max([len(d[0]) for d in data]) for i in range(numRows): - for d in data: - for j in [0, 1]: - if i < len(d[j]): - fd.write(numFormat % d[j][i] + sep) + for j, d in enumerate(data): + # write x value if this is the first column, or if we want x + # for all rows + if appendAllX or j == 0: + if d is not None and i < len(d[0]): + fd.write(numFormat % d[0][i] + sep) else: fd.write(' %s' % sep) + + # write y value + if d is not None and i < len(d[1]): + fd.write(numFormat % d[1][i] + sep) + else: + fd.write(' %s' % sep) fd.write('\n') fd.close() diff --git a/exporters/HDF5Exporter.py b/exporters/HDF5Exporter.py new file mode 100644 index 00000000..cc8b5733 --- /dev/null +++ b/exporters/HDF5Exporter.py @@ -0,0 +1,58 @@ +from ..Qt import QtGui, QtCore +from .Exporter import Exporter +from ..parametertree import Parameter +from .. import PlotItem + +import numpy +try: + import h5py + HAVE_HDF5 = True +except ImportError: + HAVE_HDF5 = False + +__all__ = ['HDF5Exporter'] + + +class HDF5Exporter(Exporter): + Name = "HDF5 Export: plot (x,y)" + windows = [] + allowCopy = False + + def __init__(self, item): + Exporter.__init__(self, item) + self.params = Parameter(name='params', type='group', children=[ + {'name': 'Name', 'type': 'str', 'value': 'Export',}, + {'name': 'columnMode', 'type': 'list', 'values': ['(x,y) per plot', '(x,y,y,y) for all plots']}, + ]) + + def parameters(self): + return self.params + + def export(self, fileName=None): + if not HAVE_HDF5: + raise RuntimeError("This exporter requires the h5py package, " + "but it was not importable.") + + if not isinstance(self.item, PlotItem): + raise Exception("Must have a PlotItem selected for HDF5 export.") + + if fileName is None: + self.fileSaveDialog(filter=["*.h5", "*.hdf", "*.hd5"]) + return + dsname = self.params['Name'] + fd = h5py.File(fileName, 'a') # forces append to file... 'w' doesn't seem to "delete/overwrite" + data = [] + + appendAllX = self.params['columnMode'] == '(x,y) per plot' + for i,c in enumerate(self.item.curves): + d = c.getData() + if appendAllX or i == 0: + data.append(d[0]) + data.append(d[1]) + + fdata = numpy.array(data).astype('double') + dset = fd.create_dataset(dsname, data=fdata) + fd.close() + +if HAVE_HDF5: + HDF5Exporter.register() diff --git a/exporters/Matplotlib.py b/exporters/Matplotlib.py index 57c4cfdb..8cec1cef 100644 --- a/exporters/Matplotlib.py +++ b/exporters/Matplotlib.py @@ -4,7 +4,29 @@ from .. import PlotItem from .. import functions as fn __all__ = ['MatplotlibExporter'] - + +""" +It is helpful when using the matplotlib Exporter if your +.matplotlib/matplotlibrc file is configured appropriately. +The following are suggested for getting usable PDF output that +can be edited in Illustrator, etc. + +backend : Qt4Agg +text.usetex : True # Assumes you have a findable LaTeX installation +interactive : False +font.family : sans-serif +font.sans-serif : 'Arial' # (make first in list) +mathtext.default : sf +figure.facecolor : white # personal preference +# next setting allows pdf font to be readable in Adobe Illustrator +pdf.fonttype : 42 # set fonts to TrueType (otherwise it will be 3 + # and the text will be vectorized. +text.dvipnghack : True # primarily to clean up font appearance on Mac + +The advantage is that there is less to do to get an exported file cleaned and ready for +publication. Fonts are not vectorized (outlined), and window colors are white. + +""" class MatplotlibExporter(Exporter): Name = "Matplotlib Window" @@ -14,18 +36,43 @@ class MatplotlibExporter(Exporter): def parameters(self): return None + + def cleanAxes(self, axl): + if type(axl) is not list: + axl = [axl] + for ax in axl: + if ax is None: + continue + for loc, spine in ax.spines.iteritems(): + if loc in ['left', 'bottom']: + pass + elif loc in ['right', 'top']: + spine.set_color('none') + # do not draw the spine + else: + raise ValueError('Unknown spine location: %s' % loc) + # turn off ticks when there is no spine + ax.xaxis.set_ticks_position('bottom') def export(self, fileName=None): if isinstance(self.item, PlotItem): mpw = MatplotlibWindow() MatplotlibExporter.windows.append(mpw) + + stdFont = 'Arial' + fig = mpw.getFigure() - ax = fig.add_subplot(111) + # get labels from the graphic item + xlabel = self.item.axes['bottom']['item'].label.toPlainText() + ylabel = self.item.axes['left']['item'].label.toPlainText() + title = self.item.titleLabel.text + + ax = fig.add_subplot(111, title=title) ax.clear() + self.cleanAxes(ax) #ax.grid(True) - for item in self.item.curves: x, y = item.getData() opts = item.opts @@ -42,17 +89,21 @@ class MatplotlibExporter(Exporter): symbolBrush = fn.mkBrush(opts['symbolBrush']) markeredgecolor = tuple([c/255. for c in fn.colorTuple(symbolPen.color())]) markerfacecolor = tuple([c/255. for c in fn.colorTuple(symbolBrush.color())]) + markersize = opts['symbolSize'] if opts['fillLevel'] is not None and opts['fillBrush'] is not None: fillBrush = fn.mkBrush(opts['fillBrush']) fillcolor = tuple([c/255. for c in fn.colorTuple(fillBrush.color())]) ax.fill_between(x=x, y1=y, y2=opts['fillLevel'], facecolor=fillcolor) - ax.plot(x, y, marker=symbol, color=color, linewidth=pen.width(), linestyle=linestyle, markeredgecolor=markeredgecolor, markerfacecolor=markerfacecolor) - + pl = ax.plot(x, y, marker=symbol, color=color, linewidth=pen.width(), + linestyle=linestyle, markeredgecolor=markeredgecolor, markerfacecolor=markerfacecolor, + markersize=markersize) xr, yr = self.item.viewRange() ax.set_xbound(*xr) ax.set_ybound(*yr) + ax.set_xlabel(xlabel) # place the labels. + ax.set_ylabel(ylabel) mpw.draw() else: raise Exception("Matplotlib export currently only works with plot items") diff --git a/exporters/SVGExporter.py b/exporters/SVGExporter.py index e46c9981..a91466c8 100644 --- a/exporters/SVGExporter.py +++ b/exporters/SVGExporter.py @@ -102,14 +102,12 @@ xmlHeader = """\ pyqtgraph SVG export Generated with Qt and pyqtgraph - - """ def generateSvg(item): global xmlHeader try: - node = _generateItemSvg(item) + node, defs = _generateItemSvg(item) finally: ## reset export mode for all items in the tree if isinstance(item, QtGui.QGraphicsScene): @@ -124,7 +122,11 @@ def generateSvg(item): cleanXml(node) - return xmlHeader + node.toprettyxml(indent=' ') + "\n\n" + defsXml = "\n" + for d in defs: + defsXml += d.toprettyxml(indent=' ') + defsXml += "\n" + return xmlHeader + defsXml + node.toprettyxml(indent=' ') + "\n\n" def _generateItemSvg(item, nodes=None, root=None): @@ -230,6 +232,10 @@ def _generateItemSvg(item, nodes=None, root=None): g1 = doc.getElementsByTagName('g')[0] ## get list of sub-groups g2 = [n for n in g1.childNodes if isinstance(n, xml.Element) and n.tagName == 'g'] + + defs = doc.getElementsByTagName('defs') + if len(defs) > 0: + defs = [n for n in defs[0].childNodes if isinstance(n, xml.Element)] except: print(doc.toxml()) raise @@ -238,7 +244,7 @@ def _generateItemSvg(item, nodes=None, root=None): ## Get rid of group transformation matrices by applying ## transformation to inner coordinates - correctCoordinates(g1, item) + correctCoordinates(g1, defs, item) profiler('correct') ## make sure g1 has the transformation matrix #m = (tr.m11(), tr.m12(), tr.m21(), tr.m22(), tr.m31(), tr.m32()) @@ -275,7 +281,9 @@ def _generateItemSvg(item, nodes=None, root=None): path = QtGui.QGraphicsPathItem(item.mapToScene(item.shape())) item.scene().addItem(path) try: - pathNode = _generateItemSvg(path, root=root).getElementsByTagName('path')[0] + #pathNode = _generateItemSvg(path, root=root).getElementsByTagName('path')[0] + pathNode = _generateItemSvg(path, root=root)[0].getElementsByTagName('path')[0] + # assume for this path is empty.. possibly problematic. finally: item.scene().removeItem(path) @@ -294,14 +302,19 @@ def _generateItemSvg(item, nodes=None, root=None): ## Add all child items as sub-elements. childs.sort(key=lambda c: c.zValue()) for ch in childs: - cg = _generateItemSvg(ch, nodes, root) - if cg is None: + csvg = _generateItemSvg(ch, nodes, root) + if csvg is None: continue + cg, cdefs = csvg childGroup.appendChild(cg) ### this isn't quite right--some items draw below their parent (good enough for now) + defs.extend(cdefs) + profiler('children') - return g1 + return g1, defs -def correctCoordinates(node, item): +def correctCoordinates(node, defs, item): + # TODO: correct gradient coordinates inside defs + ## Remove transformation matrices from tags by applying matrix to coordinates inside. ## Each item is represented by a single top-level group with one or more groups inside. ## Each inner group contains one or more drawing primitives, possibly of different types. diff --git a/exporters/__init__.py b/exporters/__init__.py index 8be1c3b6..62ab1331 100644 --- a/exporters/__init__.py +++ b/exporters/__init__.py @@ -4,7 +4,7 @@ from .SVGExporter import * from .Matplotlib import * from .CSVExporter import * from .PrintExporter import * - +from .HDF5Exporter import * def listExporters(): return Exporter.Exporters[:] diff --git a/flowchart/Flowchart.py b/flowchart/Flowchart.py index 48357b30..878f86ae 100644 --- a/flowchart/Flowchart.py +++ b/flowchart/Flowchart.py @@ -20,41 +20,12 @@ from ..debug import printExc from .. import configfile as configfile from .. import dockarea as dockarea from . import FlowchartGraphicsView +from .. import functions as fn def strDict(d): return dict([(str(k), v) for k, v in d.items()]) -def toposort(deps, nodes=None, seen=None, stack=None, depth=0): - """Topological sort. Arguments are: - deps dictionary describing dependencies where a:[b,c] means "a depends on b and c" - nodes optional, specifies list of starting nodes (these should be the nodes - which are not depended on by any other nodes) - """ - - if nodes is None: - ## run through deps to find nodes that are not depended upon - rem = set() - for dep in deps.values(): - rem |= set(dep) - nodes = set(deps.keys()) - rem - if seen is None: - seen = set() - stack = [] - sorted = [] - #print " "*depth, "Starting from", nodes - for n in nodes: - if n in stack: - raise Exception("Cyclic dependency detected", stack + [n]) - if n in seen: - continue - seen.add(n) - #print " "*depth, " descending into", n, deps[n] - sorted.extend( toposort(deps, deps[n], seen, stack+[n], depth=depth+1)) - #print " "*depth, " Added", n - sorted.append(n) - #print " "*depth, " ", sorted - return sorted class Flowchart(Node): @@ -278,9 +249,10 @@ class Flowchart(Node): ## Record inputs given to process() for n, t in self.inputNode.outputs().items(): - if n not in args: - raise Exception("Parameter %s required to process this chart." % n) - data[t] = args[n] + # if n not in args: + # raise Exception("Parameter %s required to process this chart." % n) + if n in args: + data[t] = args[n] ret = {} @@ -305,7 +277,7 @@ class Flowchart(Node): if len(inputs) == 0: continue if inp.isMultiValue(): ## multi-input terminals require a dict of all inputs - args[inp.name()] = dict([(i, data[i]) for i in inputs]) + args[inp.name()] = dict([(i, data[i]) for i in inputs if i in data]) else: ## single-inputs terminals only need the single input value available args[inp.name()] = data[inputs[0]] @@ -325,9 +297,8 @@ class Flowchart(Node): #print out.name() try: data[out] = result[out.name()] - except: - print(out, out.name()) - raise + except KeyError: + pass elif c == 'd': ## delete a terminal result (no longer needed; may be holding a lot of memory) #print "===> delete", arg if arg in data: @@ -352,7 +323,7 @@ class Flowchart(Node): #print "DEPS:", deps ## determine correct node-processing order #deps[self] = [] - order = toposort(deps) + order = fn.toposort(deps) #print "ORDER1:", order ## construct list of operations @@ -401,7 +372,7 @@ class Flowchart(Node): deps[node].extend(t.dependentNodes()) ## determine order of updates - order = toposort(deps, nodes=[startNode]) + order = fn.toposort(deps, nodes=[startNode]) order.reverse() ## keep track of terminals that have been updated @@ -542,7 +513,7 @@ class Flowchart(Node): return ## NOTE: was previously using a real widget for the file dialog's parent, but this caused weird mouse event bugs.. #fileName = QtGui.QFileDialog.getOpenFileName(None, "Load Flowchart..", startDir, "Flowchart (*.fc)") - fileName = str(fileName) + fileName = unicode(fileName) state = configfile.readConfigFile(fileName) self.restoreState(state, clear=True) self.viewBox.autoRange() @@ -563,7 +534,7 @@ class Flowchart(Node): self.fileDialog.fileSelected.connect(self.saveFile) return #fileName = QtGui.QFileDialog.getSaveFileName(None, "Save Flowchart..", startDir, "Flowchart (*.fc)") - fileName = str(fileName) + fileName = unicode(fileName) configfile.writeConfigFile(self.saveState(), fileName) self.sigFileSaved.emit(fileName) @@ -685,7 +656,7 @@ class FlowchartCtrlWidget(QtGui.QWidget): #self.setCurrentFile(newFile) def fileSaved(self, fileName): - self.setCurrentFile(str(fileName)) + self.setCurrentFile(unicode(fileName)) self.ui.saveBtn.success("Saved.") def saveClicked(self): @@ -714,7 +685,7 @@ class FlowchartCtrlWidget(QtGui.QWidget): #self.setCurrentFile(newFile) def setCurrentFile(self, fileName): - self.currentFileName = str(fileName) + self.currentFileName = unicode(fileName) if fileName is None: self.ui.fileNameLabel.setText("[ new ]") else: diff --git a/flowchart/library/Data.py b/flowchart/library/Data.py index 532f6c5b..5236de8d 100644 --- a/flowchart/library/Data.py +++ b/flowchart/library/Data.py @@ -182,8 +182,8 @@ class EvalNode(Node): def __init__(self, name): Node.__init__(self, name, terminals = { - 'input': {'io': 'in', 'renamable': True}, - 'output': {'io': 'out', 'renamable': True}, + 'input': {'io': 'in', 'renamable': True, 'multiable': True}, + 'output': {'io': 'out', 'renamable': True, 'multiable': True}, }, allowAddInput=True, allowAddOutput=True) diff --git a/flowchart/library/Filters.py b/flowchart/library/Filters.py index b72fbca5..88a2f6c5 100644 --- a/flowchart/library/Filters.py +++ b/flowchart/library/Filters.py @@ -6,6 +6,8 @@ from ... import functions as pgfn from .common import * import numpy as np +from ... import PolyLineROI +from ... import Point from ... import metaarray as metaarray @@ -201,6 +203,72 @@ class Detrend(CtrlNode): raise Exception("DetrendFilter node requires the package scipy.signal.") return detrend(data) +class RemoveBaseline(PlottingCtrlNode): + """Remove an arbitrary, graphically defined baseline from the data.""" + nodeName = 'RemoveBaseline' + + def __init__(self, name): + ## define inputs and outputs (one output needs to be a plot) + PlottingCtrlNode.__init__(self, name) + self.line = PolyLineROI([[0,0],[1,0]]) + self.line.sigRegionChanged.connect(self.changed) + + ## create a PolyLineROI, add it to a plot -- actually, I think we want to do this after the node is connected to a plot (look at EventDetection.ThresholdEvents node for ideas), and possible after there is data. We will need to update the end positions of the line each time the input data changes + #self.line = None ## will become a PolyLineROI + + def connectToPlot(self, node): + """Define what happens when the node is connected to a plot""" + + if node.plot is None: + return + node.getPlot().addItem(self.line) + + def disconnectFromPlot(self, plot): + """Define what happens when the node is disconnected from a plot""" + plot.removeItem(self.line) + + def processData(self, data): + ## get array of baseline (from PolyLineROI) + h0 = self.line.getHandles()[0] + h1 = self.line.getHandles()[-1] + + timeVals = data.xvals(0) + h0.setPos(timeVals[0], h0.pos()[1]) + h1.setPos(timeVals[-1], h1.pos()[1]) + + pts = self.line.listPoints() ## lists line handles in same coordinates as data + pts, indices = self.adjustXPositions(pts, timeVals) ## maxe sure x positions match x positions of data points + + ## construct an array that represents the baseline + arr = np.zeros(len(data), dtype=float) + n = 1 + arr[0] = pts[0].y() + for i in range(len(pts)-1): + x1 = pts[i].x() + x2 = pts[i+1].x() + y1 = pts[i].y() + y2 = pts[i+1].y() + m = (y2-y1)/(x2-x1) + b = y1 + + times = timeVals[(timeVals > x1)*(timeVals <= x2)] + arr[n:n+len(times)] = (m*(times-times[0]))+b + n += len(times) + + return data - arr ## subract baseline from data + + def adjustXPositions(self, pts, data): + """Return a list of Point() where the x position is set to the nearest x value in *data* for each point in *pts*.""" + points = [] + timeIndices = [] + for p in pts: + x = np.argwhere(abs(data - p.x()) == abs(data - p.x()).min()) + points.append(Point(data[x], p.y())) + timeIndices.append(x) + + return points, timeIndices + + class AdaptiveDetrend(CtrlNode): """Removes baseline from data, ignoring anomalous events""" @@ -275,4 +343,4 @@ class RemovePeriodic(CtrlNode): return ma - \ No newline at end of file + diff --git a/flowchart/library/common.py b/flowchart/library/common.py index 548dc440..425fe86c 100644 --- a/flowchart/library/common.py +++ b/flowchart/library/common.py @@ -131,6 +131,42 @@ class CtrlNode(Node): l.show() +class PlottingCtrlNode(CtrlNode): + """Abstract class for CtrlNodes that can connect to plots.""" + + def __init__(self, name, ui=None, terminals=None): + #print "PlottingCtrlNode.__init__ called." + CtrlNode.__init__(self, name, ui=ui, terminals=terminals) + self.plotTerminal = self.addOutput('plot', optional=True) + + def connected(self, term, remote): + CtrlNode.connected(self, term, remote) + if term is not self.plotTerminal: + return + node = remote.node() + node.sigPlotChanged.connect(self.connectToPlot) + self.connectToPlot(node) + + def disconnected(self, term, remote): + CtrlNode.disconnected(self, term, remote) + if term is not self.plotTerminal: + return + remote.node().sigPlotChanged.disconnect(self.connectToPlot) + self.disconnectFromPlot(remote.node().getPlot()) + + def connectToPlot(self, node): + """Define what happens when the node is connected to a plot""" + raise Exception("Must be re-implemented in subclass") + + def disconnectFromPlot(self, plot): + """Define what happens when the node is disconnected from a plot""" + raise Exception("Must be re-implemented in subclass") + + def process(self, In, display=True): + out = CtrlNode.process(self, In, display) + out['plot'] = None + return out + def metaArrayWrapper(fn): def newFn(self, data, *args, **kargs): diff --git a/flowchart/library/functions.py b/flowchart/library/functions.py index 027e1386..338d25c4 100644 --- a/flowchart/library/functions.py +++ b/flowchart/library/functions.py @@ -206,7 +206,7 @@ def adaptiveDetrend(data, x=None, threshold=3.0): #d3 = where(mask, 0, d2) #d4 = d2 - lowPass(d3, cutoffs[1], dt=dt) - lr = stats.linregress(x[mask], d[mask]) + lr = scipy.stats.linregress(x[mask], d[mask]) base = lr[1] + lr[0]*x d4 = d - base diff --git a/functions.py b/functions.py index 77643c99..6ae2f65b 100644 --- a/functions.py +++ b/functions.py @@ -591,6 +591,50 @@ def interpolateArray(data, x, default=0.0): return result +def subArray(data, offset, shape, stride): + """ + Unpack a sub-array from *data* using the specified offset, shape, and stride. + + Note that *stride* is specified in array elements, not bytes. + For example, we have a 2x3 array packed in a 1D array as follows:: + + data = [_, _, 00, 01, 02, _, 10, 11, 12, _] + + Then we can unpack the sub-array with this call:: + + subArray(data, offset=2, shape=(2, 3), stride=(4, 1)) + + ..which returns:: + + [[00, 01, 02], + [10, 11, 12]] + + This function operates only on the first axis of *data*. So changing + the input in the example above to have shape (10, 7) would cause the + output to have shape (2, 3, 7). + """ + #data = data.flatten() + data = data[offset:] + shape = tuple(shape) + stride = tuple(stride) + extraShape = data.shape[1:] + #print data.shape, offset, shape, stride + for i in range(len(shape)): + mask = (slice(None),) * i + (slice(None, shape[i] * stride[i]),) + newShape = shape[:i+1] + if i < len(shape)-1: + newShape += (stride[i],) + newShape += extraShape + #print i, mask, newShape + #print "start:\n", data.shape, data + data = data[mask] + #print "mask:\n", data.shape, data + data = data.reshape(newShape) + #print "reshape:\n", data.shape, data + + return data + + def transformToArray(tr): """ Given a QTransform, return a 3x3 numpy array. @@ -2156,3 +2200,51 @@ def pseudoScatter(data, spacing=None, shuffle=True, bidir=False): yvals[i] = y return yvals[np.argsort(inds)] ## un-shuffle values before returning + + + +def toposort(deps, nodes=None, seen=None, stack=None, depth=0): + """Topological sort. Arguments are: + deps dictionary describing dependencies where a:[b,c] means "a depends on b and c" + nodes optional, specifies list of starting nodes (these should be the nodes + which are not depended on by any other nodes). Other candidate starting + nodes will be ignored. + + Example:: + + # Sort the following graph: + # + # B ──┬─────> C <── D + # │ │ + # E <─┴─> A <─┘ + # + deps = {'a': ['b', 'c'], 'c': ['b', 'd'], 'e': ['b']} + toposort(deps) + => ['b', 'd', 'c', 'a', 'e'] + """ + # fill in empty dep lists + deps = deps.copy() + for k,v in list(deps.items()): + for k in v: + if k not in deps: + deps[k] = [] + + if nodes is None: + ## run through deps to find nodes that are not depended upon + rem = set() + for dep in deps.values(): + rem |= set(dep) + nodes = set(deps.keys()) - rem + if seen is None: + seen = set() + stack = [] + sorted = [] + for n in nodes: + if n in stack: + raise Exception("Cyclic dependency detected", stack + [n]) + if n in seen: + continue + seen.add(n) + sorted.extend( toposort(deps, deps[n], seen, stack+[n], depth=depth+1)) + sorted.append(n) + return sorted diff --git a/graphicsItems/AxisItem.py b/graphicsItems/AxisItem.py index ededed56..e5b9e3f5 100644 --- a/graphicsItems/AxisItem.py +++ b/graphicsItems/AxisItem.py @@ -918,13 +918,17 @@ class AxisItem(GraphicsWidget): rects.append(br) textRects.append(rects[-1]) - ## measure all text, make sure there's enough room - if axis == 0: - textSize = np.sum([r.height() for r in textRects]) - textSize2 = np.max([r.width() for r in textRects]) if textRects else 0 + if len(textRects) > 0: + ## measure all text, make sure there's enough room + if axis == 0: + textSize = np.sum([r.height() for r in textRects]) + textSize2 = np.max([r.width() for r in textRects]) + else: + textSize = np.sum([r.width() for r in textRects]) + textSize2 = np.max([r.height() for r in textRects]) else: - textSize = np.sum([r.width() for r in textRects]) - textSize2 = np.max([r.height() for r in textRects]) if textRects else 0 + textSize = 0 + textSize2 = 0 if i > 0: ## always draw top level ## If the strings are too crowded, stop drawing text now. diff --git a/graphicsItems/GradientEditorItem.py b/graphicsItems/GradientEditorItem.py index e16370f5..a151798a 100644 --- a/graphicsItems/GradientEditorItem.py +++ b/graphicsItems/GradientEditorItem.py @@ -3,6 +3,7 @@ from ..python2_3 import sortList from .. import functions as fn from .GraphicsObject import GraphicsObject from .GraphicsWidget import GraphicsWidget +from ..widgets.SpinBox import SpinBox import weakref from ..pgcollections import OrderedDict from ..colormap import ColorMap @@ -300,6 +301,7 @@ class TickSliderItem(GraphicsWidget): pos.setX(x) tick.setPos(pos) self.ticks[tick] = val + self.updateGradient() def tickValue(self, tick): ## public @@ -537,23 +539,22 @@ class GradientEditorItem(TickSliderItem): def tickClicked(self, tick, ev): #private if ev.button() == QtCore.Qt.LeftButton: - if not tick.colorChangeAllowed: - return - self.currentTick = tick - self.currentTickColor = tick.color - self.colorDialog.setCurrentColor(tick.color) - self.colorDialog.open() - #color = QtGui.QColorDialog.getColor(tick.color, self, "Select Color", QtGui.QColorDialog.ShowAlphaChannel) - #if color.isValid(): - #self.setTickColor(tick, color) - #self.updateGradient() + self.raiseColorDialog(tick) elif ev.button() == QtCore.Qt.RightButton: - if not tick.removeAllowed: - return - if len(self.ticks) > 2: - self.removeTick(tick) - self.updateGradient() - + self.raiseTickContextMenu(tick, ev) + + def raiseColorDialog(self, tick): + if not tick.colorChangeAllowed: + return + self.currentTick = tick + self.currentTickColor = tick.color + self.colorDialog.setCurrentColor(tick.color) + self.colorDialog.open() + + def raiseTickContextMenu(self, tick, ev): + self.tickMenu = TickMenu(tick, self) + self.tickMenu.popup(ev.screenPos().toQPoint()) + def tickMoved(self, tick, pos): #private TickSliderItem.tickMoved(self, tick, pos) @@ -726,6 +727,7 @@ class GradientEditorItem(TickSliderItem): def removeTick(self, tick, finish=True): TickSliderItem.removeTick(self, tick) if finish: + self.updateGradient() self.sigGradientChangeFinished.emit(self) @@ -867,44 +869,59 @@ class Tick(QtGui.QGraphicsObject): ## NOTE: Making this a subclass of GraphicsO self.currentPen = self.pen self.update() - #def mouseMoveEvent(self, ev): - ##print self, "move", ev.scenePos() - #if not self.movable: - #return - #if not ev.buttons() & QtCore.Qt.LeftButton: - #return - - - #newPos = ev.scenePos() + self.mouseOffset - #newPos.setY(self.pos().y()) - ##newPos.setX(min(max(newPos.x(), 0), 100)) - #self.setPos(newPos) - #self.view().tickMoved(self, newPos) - #self.movedSincePress = True - ##self.emit(QtCore.SIGNAL('tickChanged'), self) - #ev.accept() - #def mousePressEvent(self, ev): - #self.movedSincePress = False - #if ev.button() == QtCore.Qt.LeftButton: - #ev.accept() - #self.mouseOffset = self.pos() - ev.scenePos() - #self.pressPos = ev.scenePos() - #elif ev.button() == QtCore.Qt.RightButton: - #ev.accept() - ##if self.endTick: - ##return - ##self.view.tickChanged(self, delete=True) - - #def mouseReleaseEvent(self, ev): - ##print self, "release", ev.scenePos() - #if not self.movedSincePress: - #self.view().tickClicked(self, ev) +class TickMenu(QtGui.QMenu): + + def __init__(self, tick, sliderItem): + QtGui.QMenu.__init__(self) - ##if ev.button() == QtCore.Qt.LeftButton and ev.scenePos() == self.pressPos: - ##color = QtGui.QColorDialog.getColor(self.color, None, "Select Color", QtGui.QColorDialog.ShowAlphaChannel) - ##if color.isValid(): - ##self.color = color - ##self.setBrush(QtGui.QBrush(QtGui.QColor(self.color))) - ###self.emit(QtCore.SIGNAL('tickChanged'), self) - ##self.view.tickChanged(self) + self.tick = weakref.ref(tick) + self.sliderItem = weakref.ref(sliderItem) + + self.removeAct = self.addAction("Remove Tick", lambda: self.sliderItem().removeTick(tick)) + if (not self.tick().removeAllowed) or len(self.sliderItem().ticks) < 3: + self.removeAct.setEnabled(False) + + positionMenu = self.addMenu("Set Position") + w = QtGui.QWidget() + l = QtGui.QGridLayout() + w.setLayout(l) + + value = sliderItem.tickValue(tick) + self.fracPosSpin = SpinBox() + self.fracPosSpin.setOpts(value=value, bounds=(0.0, 1.0), step=0.01, decimals=2) + #self.dataPosSpin = SpinBox(value=dataVal) + #self.dataPosSpin.setOpts(decimals=3, siPrefix=True) + + l.addWidget(QtGui.QLabel("Position:"), 0,0) + l.addWidget(self.fracPosSpin, 0, 1) + #l.addWidget(QtGui.QLabel("Position (data units):"), 1, 0) + #l.addWidget(self.dataPosSpin, 1,1) + + #if self.sliderItem().dataParent is None: + # self.dataPosSpin.setEnabled(False) + + a = QtGui.QWidgetAction(self) + a.setDefaultWidget(w) + positionMenu.addAction(a) + + self.fracPosSpin.sigValueChanging.connect(self.fractionalValueChanged) + #self.dataPosSpin.valueChanged.connect(self.dataValueChanged) + + colorAct = self.addAction("Set Color", lambda: self.sliderItem().raiseColorDialog(self.tick())) + if not self.tick().colorChangeAllowed: + colorAct.setEnabled(False) + + def fractionalValueChanged(self, x): + self.sliderItem().setTickValue(self.tick(), self.fracPosSpin.value()) + #if self.sliderItem().dataParent is not None: + # self.dataPosSpin.blockSignals(True) + # self.dataPosSpin.setValue(self.sliderItem().tickDataValue(self.tick())) + # self.dataPosSpin.blockSignals(False) + + #def dataValueChanged(self, val): + # self.sliderItem().setTickValue(self.tick(), val, dataUnits=True) + # self.fracPosSpin.blockSignals(True) + # self.fracPosSpin.setValue(self.sliderItem().tickValue(self.tick())) + # self.fracPosSpin.blockSignals(False) + diff --git a/graphicsItems/ImageItem.py b/graphicsItems/ImageItem.py index f5c2d248..5c39627c 100644 --- a/graphicsItems/ImageItem.py +++ b/graphicsItems/ImageItem.py @@ -177,6 +177,12 @@ class ImageItem(GraphicsObject): self.translate(rect.left(), rect.top()) self.scale(rect.width() / self.width(), rect.height() / self.height()) + def clear(self): + self.image = None + self.prepareGeometryChange() + self.informViewBoundsChanged() + self.update() + def setImage(self, image=None, autoLevels=None, **kargs): """ Update the image displayed by this item. For more information on how the image @@ -512,6 +518,9 @@ class ImageItem(GraphicsObject): def removeClicked(self): ## Send remove event only after we have exited the menu event handler self.removeTimer = QtCore.QTimer() - self.removeTimer.timeout.connect(lambda: self.sigRemoveRequested.emit(self)) + self.removeTimer.timeout.connect(self.emitRemoveRequested) self.removeTimer.start(0) + def emitRemoveRequested(self): + self.removeTimer.timeout.disconnect(self.emitRemoveRequested) + self.sigRemoveRequested.emit(self) diff --git a/graphicsItems/PlotDataItem.py b/graphicsItems/PlotDataItem.py index befc5783..6148989d 100644 --- a/graphicsItems/PlotDataItem.py +++ b/graphicsItems/PlotDataItem.py @@ -168,6 +168,7 @@ class PlotDataItem(GraphicsObject): 'downsample': 1, 'autoDownsample': False, 'downsampleMethod': 'peak', + 'autoDownsampleFactor': 5., # draw ~5 samples per pixel 'clipToView': False, 'data': None, @@ -380,14 +381,23 @@ class PlotDataItem(GraphicsObject): elif len(args) == 2: seq = ('listOfValues', 'MetaArray', 'empty') - if dataType(args[0]) not in seq or dataType(args[1]) not in seq: + dtyp = dataType(args[0]), dataType(args[1]) + if dtyp[0] not in seq or dtyp[1] not in seq: raise Exception('When passing two unnamed arguments, both must be a list or array of values. (got %s, %s)' % (str(type(args[0])), str(type(args[1])))) if not isinstance(args[0], np.ndarray): - x = np.array(args[0]) + #x = np.array(args[0]) + if dtyp[0] == 'MetaArray': + x = args[0].asarray() + else: + x = np.array(args[0]) else: x = args[0].view(np.ndarray) if not isinstance(args[1], np.ndarray): - y = np.array(args[1]) + #y = np.array(args[1]) + if dtyp[1] == 'MetaArray': + y = args[1].asarray() + else: + y = np.array(args[1]) else: y = args[1].view(np.ndarray) @@ -538,7 +548,7 @@ class PlotDataItem(GraphicsObject): x1 = (range.right()-x[0]) / dx width = self.getViewBox().width() if width != 0.0: - ds = int(max(1, int(0.2 * (x1-x0) / width))) + ds = int(max(1, int((x1-x0) / (width*self.opts['autoDownsampleFactor'])))) ## downsampling is expensive; delay until after clipping. if self.opts['clipToView']: diff --git a/graphicsItems/PlotItem/PlotItem.py b/graphicsItems/PlotItem/PlotItem.py index 8292875c..f8959e22 100644 --- a/graphicsItems/PlotItem/PlotItem.py +++ b/graphicsItems/PlotItem/PlotItem.py @@ -469,7 +469,8 @@ class PlotItem(GraphicsWidget): ### Average data together (x, y) = curve.getData() - if plot.yData is not None: + if plot.yData is not None and y.shape == plot.yData.shape: + # note that if shapes do not match, then the average resets. newData = plot.yData * (n-1) / float(n) + y * 1.0 / float(n) plot.setData(plot.xData, newData) else: @@ -1207,10 +1208,13 @@ class PlotItem(GraphicsWidget): self.updateButtons() def updateButtons(self): - if self._exportOpts is False and self.mouseHovering and not self.buttonsHidden and not all(self.vb.autoRangeEnabled()): - self.autoBtn.show() - else: - self.autoBtn.hide() + try: + if self._exportOpts is False and self.mouseHovering and not self.buttonsHidden and not all(self.vb.autoRangeEnabled()): + self.autoBtn.show() + else: + self.autoBtn.hide() + except RuntimeError: + pass # this can happen if the plot has been deleted. def _plotArray(self, arr, x=None, **kargs): if arr.ndim != 1: diff --git a/graphicsItems/ROI.py b/graphicsItems/ROI.py index f3ebd992..7707466a 100644 --- a/graphicsItems/ROI.py +++ b/graphicsItems/ROI.py @@ -25,7 +25,7 @@ from .UIGraphicsItem import UIGraphicsItem __all__ = [ 'ROI', 'TestROI', 'RectROI', 'EllipseROI', 'CircleROI', 'PolygonROI', - 'LineROI', 'MultiLineROI', 'MultiRectROI', 'LineSegmentROI', 'PolyLineROI', 'SpiralROI', + 'LineROI', 'MultiLineROI', 'MultiRectROI', 'LineSegmentROI', 'PolyLineROI', 'SpiralROI', 'CrosshairROI', ] @@ -862,8 +862,10 @@ class ROI(GraphicsObject): elif h['type'] == 'sr': if h['center'][0] == h['pos'][0]: scaleAxis = 1 + nonScaleAxis=0 else: scaleAxis = 0 + nonScaleAxis=1 try: if lp1.length() == 0 or lp0.length() == 0: @@ -885,6 +887,8 @@ class ROI(GraphicsObject): newState['size'][scaleAxis] = round(newState['size'][scaleAxis] / self.snapSize) * self.snapSize if newState['size'][scaleAxis] == 0: newState['size'][scaleAxis] = 1 + if self.aspectLocked: + newState['size'][nonScaleAxis] = newState['size'][scaleAxis] c1 = c * newState['size'] tr = QtGui.QTransform() @@ -972,14 +976,16 @@ class ROI(GraphicsObject): return QtCore.QRectF(0, 0, self.state['size'][0], self.state['size'][1]).normalized() def paint(self, p, opt, widget): - p.save() - r = self.boundingRect() + # p.save() + # Note: don't use self.boundingRect here, because subclasses may need to redefine it. + r = QtCore.QRectF(0, 0, self.state['size'][0], self.state['size'][1]).normalized() + p.setRenderHint(QtGui.QPainter.Antialiasing) p.setPen(self.currentPen) p.translate(r.left(), r.top()) p.scale(r.width(), r.height()) p.drawRect(0, 0, 1, 1) - p.restore() + # p.restore() def getArraySlice(self, data, img, axes=(0,1), returnSlice=True): """Return a tuple of slice objects that can be used to slice the region from data covered by this ROI. @@ -2139,6 +2145,102 @@ class SpiralROI(ROI): p.drawRect(self.boundingRect()) +class CrosshairROI(ROI): + """A crosshair ROI whose position is at the center of the crosshairs. By default, it is scalable, rotatable and translatable.""" + + def __init__(self, pos=None, size=None, **kargs): + if size == None: + #size = [100e-6,100e-6] + size=[1,1] + if pos == None: + pos = [0,0] + self._shape = None + ROI.__init__(self, pos, size, **kargs) + + self.sigRegionChanged.connect(self.invalidate) + self.addScaleRotateHandle(Point(1, 0), Point(0, 0)) + self.aspectLocked = True + def invalidate(self): + self._shape = None + self.prepareGeometryChange() + + def boundingRect(self): + #size = self.size() + #return QtCore.QRectF(-size[0]/2., -size[1]/2., size[0], size[1]).normalized() + return self.shape().boundingRect() + + #def getRect(self): + ### same as boundingRect -- for internal use so that boundingRect can be re-implemented in subclasses + #size = self.size() + #return QtCore.QRectF(-size[0]/2., -size[1]/2., size[0], size[1]).normalized() + + + def shape(self): + if self._shape is None: + radius = self.getState()['size'][1] + p = QtGui.QPainterPath() + p.moveTo(Point(0, -radius)) + p.lineTo(Point(0, radius)) + p.moveTo(Point(-radius, 0)) + p.lineTo(Point(radius, 0)) + p = self.mapToDevice(p) + stroker = QtGui.QPainterPathStroker() + stroker.setWidth(10) + outline = stroker.createStroke(p) + self._shape = self.mapFromDevice(outline) - + + ##h1 = self.handles[0]['item'].pos() + ##h2 = self.handles[1]['item'].pos() + #w1 = Point(-0.5, 0)*self.size() + #w2 = Point(0.5, 0)*self.size() + #h1 = Point(0, -0.5)*self.size() + #h2 = Point(0, 0.5)*self.size() + + #dh = h2-h1 + #dw = w2-w1 + #if dh.length() == 0 or dw.length() == 0: + #return p + #pxv = self.pixelVectors(dh)[1] + #if pxv is None: + #return p + + #pxv *= 4 + + #p.moveTo(h1+pxv) + #p.lineTo(h2+pxv) + #p.lineTo(h2-pxv) + #p.lineTo(h1-pxv) + #p.lineTo(h1+pxv) + + #pxv = self.pixelVectors(dw)[1] + #if pxv is None: + #return p + + #pxv *= 4 + + #p.moveTo(w1+pxv) + #p.lineTo(w2+pxv) + #p.lineTo(w2-pxv) + #p.lineTo(w1-pxv) + #p.lineTo(w1+pxv) + + return self._shape + + def paint(self, p, *args): + #p.save() + #r = self.getRect() + radius = self.getState()['size'][1] + p.setRenderHint(QtGui.QPainter.Antialiasing) + p.setPen(self.currentPen) + #p.translate(r.left(), r.top()) + #p.scale(r.width()/10., r.height()/10.) ## need to scale up a little because drawLine has trouble dealing with 0.5 + #p.drawLine(0,5, 10,5) + #p.drawLine(5,0, 5,10) + #p.restore() + + p.drawLine(Point(0, -radius), Point(0, radius)) + p.drawLine(Point(-radius, 0), Point(radius, 0)) + + diff --git a/graphicsItems/ScaleBar.py b/graphicsItems/ScaleBar.py index 66258678..8ba546f7 100644 --- a/graphicsItems/ScaleBar.py +++ b/graphicsItems/ScaleBar.py @@ -5,6 +5,7 @@ from .TextItem import TextItem import numpy as np from .. import functions as fn from .. import getConfigOption +from ..Point import Point __all__ = ['ScaleBar'] @@ -12,7 +13,7 @@ class ScaleBar(GraphicsObject, GraphicsWidgetAnchor): """ Displays a rectangular bar to indicate the relative scale of objects on the view. """ - def __init__(self, size, width=5, brush=None, pen=None, suffix='m'): + def __init__(self, size, width=5, brush=None, pen=None, suffix='m', offset=None): GraphicsObject.__init__(self) GraphicsWidgetAnchor.__init__(self) self.setFlag(self.ItemHasNoContents) @@ -24,6 +25,9 @@ class ScaleBar(GraphicsObject, GraphicsWidgetAnchor): self.pen = fn.mkPen(pen) self._width = width self.size = size + if offset == None: + offset = (0,0) + self.offset = offset self.bar = QtGui.QGraphicsRectItem() self.bar.setPen(self.pen) @@ -54,51 +58,14 @@ class ScaleBar(GraphicsObject, GraphicsWidgetAnchor): def boundingRect(self): return QtCore.QRectF() + def setParentItem(self, p): + ret = GraphicsObject.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 - - -#class ScaleBar(UIGraphicsItem): - #""" - #Displays a rectangular bar with 10 divisions to indicate the relative scale of objects on the view. - #""" - #def __init__(self, size, width=5, color=(100, 100, 255)): - #UIGraphicsItem.__init__(self) - #self.setAcceptedMouseButtons(QtCore.Qt.NoButton) - - #self.brush = fn.mkBrush(color) - #self.pen = fn.mkPen((0,0,0)) - #self._width = width - #self.size = size - - #def paint(self, p, opt, widget): - #UIGraphicsItem.paint(self, p, opt, widget) - - #rect = self.boundingRect() - #unit = self.pixelSize() - #y = rect.top() + (rect.bottom()-rect.top()) * 0.02 - #y1 = y + unit[1]*self._width - #x = rect.right() + (rect.left()-rect.right()) * 0.02 - #x1 = x - self.size - - #p.setPen(self.pen) - #p.setBrush(self.brush) - #rect = QtCore.QRectF( - #QtCore.QPointF(x1, y1), - #QtCore.QPointF(x, y) - #) - #p.translate(x1, y1) - #p.scale(rect.width(), rect.height()) - #p.drawRect(0, 0, 1, 1) - - #alpha = np.clip(((self.size/unit[0]) - 40.) * 255. / 80., 0, 255) - #p.setPen(QtGui.QPen(QtGui.QColor(0, 0, 0, alpha))) - #for i in range(1, 10): - ##x2 = x + (x1-x) * 0.1 * i - #x2 = 0.1 * i - #p.drawLine(QtCore.QPointF(x2, 0), QtCore.QPointF(x2, 1)) - - - #def setSize(self, s): - #self.size = s - diff --git a/graphicsItems/ScatterPlotItem.py b/graphicsItems/ScatterPlotItem.py index bdf89c45..e39b535a 100644 --- a/graphicsItems/ScatterPlotItem.py +++ b/graphicsItems/ScatterPlotItem.py @@ -68,10 +68,12 @@ def renderSymbol(symbol, size, pen, brush, device=None): device = QtGui.QImage(int(size+penPxWidth), int(size+penPxWidth), QtGui.QImage.Format_ARGB32) device.fill(0) p = QtGui.QPainter(device) - p.setRenderHint(p.Antialiasing) - p.translate(device.width()*0.5, device.height()*0.5) - drawSymbol(p, symbol, size, pen, brush) - p.end() + try: + p.setRenderHint(p.Antialiasing) + p.translate(device.width()*0.5, device.height()*0.5) + drawSymbol(p, symbol, size, pen, brush) + finally: + p.end() return device def makeSymbolPixmap(size, pen, brush, symbol): diff --git a/graphicsItems/ViewBox/ViewBox.py b/graphicsItems/ViewBox/ViewBox.py index d66f32ad..542bbc1a 100644 --- a/graphicsItems/ViewBox/ViewBox.py +++ b/graphicsItems/ViewBox/ViewBox.py @@ -760,7 +760,8 @@ class ViewBox(GraphicsWidget): x = vr.left()+x, vr.right()+x if y is not None: y = vr.top()+y, vr.bottom()+y - self.setRange(xRange=x, yRange=y, padding=0) + if x is not None or y is not None: + self.setRange(xRange=x, yRange=y, padding=0) @@ -902,6 +903,14 @@ class ViewBox(GraphicsWidget): return args['padding'] = 0 args['disableAutoRange'] = False + + # check for and ignore bad ranges + for k in ['xRange', 'yRange']: + if k in args: + if not np.all(np.isfinite(args[k])): + r = args.pop(k) + print "Warning: %s is invalid: %s" % (k, str(r)) + self.setRange(**args) finally: self._autoRangeNeedsUpdate = False @@ -1066,7 +1075,7 @@ class ViewBox(GraphicsWidget): return self.state['yInverted'] = b - #self.updateMatrix(changed=(False, True)) + self._matrixNeedsUpdate = True # updateViewRange won't detect this for us self.updateViewRange() self.sigStateChanged.emit(self) self.sigYRangeChanged.emit(self, tuple(self.state['viewRange'][1])) @@ -1485,7 +1494,7 @@ class ViewBox(GraphicsWidget): aspect = self.state['aspectLocked'] # size ratio / view ratio tr = self.targetRect() bounds = self.rect() - if aspect is not False and aspect != 0 and tr.height() != 0 and bounds.height() != 0: + if aspect is not False and 0 not in [aspect, tr.height(), bounds.height(), bounds.width()]: ## This is the view range aspect ratio we have requested targetRatio = tr.width() / tr.height() if tr.height() != 0 else 1 @@ -1581,18 +1590,16 @@ class ViewBox(GraphicsWidget): if any(changed): self.sigRangeChanged.emit(self, self.state['viewRange']) self.update() + self._matrixNeedsUpdate = True - # Inform linked views that the range has changed - for ax in [0, 1]: - if not changed[ax]: - continue - link = self.linkedView(ax) - if link is not None: - link.linkedViewChanged(self, ax) + # Inform linked views that the range has changed + for ax in [0, 1]: + if not changed[ax]: + continue + link = self.linkedView(ax) + if link is not None: + link.linkedViewChanged(self, ax) - self.update() - self._matrixNeedsUpdate = True - def updateMatrix(self, changed=None): ## Make the childGroup's transform match the requested viewRange. bounds = self.rect() diff --git a/imageview/ImageView.py b/imageview/ImageView.py index c9f421b4..65252cfe 100644 --- a/imageview/ImageView.py +++ b/imageview/ImageView.py @@ -12,7 +12,7 @@ Widget used for displaying 2D or 3D data. Features: - ROI plotting - Image normalization through a variety of methods """ -import sys +import os, sys import numpy as np from ..Qt import QtCore, QtGui, USE_PYSIDE @@ -136,6 +136,8 @@ class ImageView(QtGui.QWidget): self.ui.histogram.setImageItem(self.imageItem) + self.menu = None + self.ui.normGroup.hide() self.roi = PlotROI(10) @@ -176,7 +178,8 @@ class ImageView(QtGui.QWidget): self.timeLine.sigPositionChanged.connect(self.timeLineChanged) self.ui.roiBtn.clicked.connect(self.roiClicked) self.roi.sigRegionChanged.connect(self.roiChanged) - self.ui.normBtn.toggled.connect(self.normToggled) + #self.ui.normBtn.toggled.connect(self.normToggled) + self.ui.menuBtn.clicked.connect(self.menuClicked) self.ui.normDivideRadio.clicked.connect(self.normRadioChanged) self.ui.normSubtractRadio.clicked.connect(self.normRadioChanged) self.ui.normOffRadio.clicked.connect(self.normRadioChanged) @@ -321,6 +324,10 @@ class ImageView(QtGui.QWidget): profiler() + def clear(self): + self.image = None + self.imageItem.clear() + def play(self, rate): """Begin automatically stepping frames forward at the given rate (in fps). This can also be accessed by pressing the spacebar.""" @@ -671,3 +678,43 @@ class ImageView(QtGui.QWidget): def getHistogramWidget(self): """Return the HistogramLUTWidget for this ImageView""" return self.ui.histogram + + def export(self, fileName): + """ + Export data from the ImageView to a file, or to a stack of files if + the data is 3D. Saving an image stack will result in index numbers + being added to the file name. Images are saved as they would appear + onscreen, with levels and lookup table applied. + """ + img = self.getProcessedImage() + if self.hasTimeAxis(): + base, ext = os.path.splitext(fileName) + fmt = "%%s%%0%dd%%s" % int(np.log10(img.shape[0])+1) + for i in range(img.shape[0]): + self.imageItem.setImage(img[i], autoLevels=False) + self.imageItem.save(fmt % (base, i, ext)) + self.updateImage() + else: + self.imageItem.save(fileName) + + def exportClicked(self): + fileName = QtGui.QFileDialog.getSaveFileName() + if fileName == '': + return + self.export(fileName) + + def buildMenu(self): + self.menu = QtGui.QMenu() + self.normAction = QtGui.QAction("Normalization", self.menu) + self.normAction.setCheckable(True) + self.normAction.toggled.connect(self.normToggled) + self.menu.addAction(self.normAction) + self.exportAction = QtGui.QAction("Export", self.menu) + self.exportAction.triggered.connect(self.exportClicked) + self.menu.addAction(self.exportAction) + + def menuClicked(self): + if self.menu is None: + self.buildMenu() + self.menu.popup(QtGui.QCursor.pos()) + diff --git a/imageview/ImageViewTemplate.ui b/imageview/ImageViewTemplate.ui index 9a3dab03..927bda30 100644 --- a/imageview/ImageViewTemplate.ui +++ b/imageview/ImageViewTemplate.ui @@ -53,7 +53,7 @@ - + 0 @@ -61,10 +61,7 @@ - Norm - - - true + Menu diff --git a/imageview/ImageViewTemplate_pyqt.py b/imageview/ImageViewTemplate_pyqt.py index 78156317..e728b265 100644 --- a/imageview/ImageViewTemplate_pyqt.py +++ b/imageview/ImageViewTemplate_pyqt.py @@ -1,9 +1,9 @@ # -*- coding: utf-8 -*- -# Form implementation generated from reading ui file './pyqtgraph/imageview/ImageViewTemplate.ui' +# Form implementation generated from reading ui file 'ImageViewTemplate.ui' # -# Created: Mon Dec 23 10:10:52 2013 -# by: PyQt4 UI code generator 4.10 +# Created: Thu May 1 15:20:40 2014 +# by: PyQt4 UI code generator 4.10.4 # # WARNING! All changes made in this file will be lost! @@ -55,15 +55,14 @@ class Ui_Form(object): self.roiBtn.setCheckable(True) self.roiBtn.setObjectName(_fromUtf8("roiBtn")) self.gridLayout.addWidget(self.roiBtn, 1, 1, 1, 1) - self.normBtn = QtGui.QPushButton(self.layoutWidget) + self.menuBtn = QtGui.QPushButton(self.layoutWidget) sizePolicy = QtGui.QSizePolicy(QtGui.QSizePolicy.Minimum, QtGui.QSizePolicy.Fixed) sizePolicy.setHorizontalStretch(0) sizePolicy.setVerticalStretch(1) - sizePolicy.setHeightForWidth(self.normBtn.sizePolicy().hasHeightForWidth()) - self.normBtn.setSizePolicy(sizePolicy) - self.normBtn.setCheckable(True) - self.normBtn.setObjectName(_fromUtf8("normBtn")) - self.gridLayout.addWidget(self.normBtn, 1, 2, 1, 1) + sizePolicy.setHeightForWidth(self.menuBtn.sizePolicy().hasHeightForWidth()) + self.menuBtn.setSizePolicy(sizePolicy) + self.menuBtn.setObjectName(_fromUtf8("menuBtn")) + self.gridLayout.addWidget(self.menuBtn, 1, 2, 1, 1) self.roiPlot = PlotWidget(self.splitter) sizePolicy = QtGui.QSizePolicy(QtGui.QSizePolicy.Preferred, QtGui.QSizePolicy.Preferred) sizePolicy.setHorizontalStretch(0) @@ -149,7 +148,7 @@ class Ui_Form(object): def retranslateUi(self, Form): Form.setWindowTitle(_translate("Form", "Form", None)) self.roiBtn.setText(_translate("Form", "ROI", None)) - self.normBtn.setText(_translate("Form", "Norm", None)) + self.menuBtn.setText(_translate("Form", "Menu", None)) self.normGroup.setTitle(_translate("Form", "Normalization", None)) self.normSubtractRadio.setText(_translate("Form", "Subtract", None)) self.normDivideRadio.setText(_translate("Form", "Divide", None)) diff --git a/imageview/ImageViewTemplate_pyside.py b/imageview/ImageViewTemplate_pyside.py index 2f8b570b..6d6c9632 100644 --- a/imageview/ImageViewTemplate_pyside.py +++ b/imageview/ImageViewTemplate_pyside.py @@ -1,9 +1,9 @@ # -*- coding: utf-8 -*- -# Form implementation generated from reading ui file './pyqtgraph/imageview/ImageViewTemplate.ui' +# Form implementation generated from reading ui file 'ImageViewTemplate.ui' # -# Created: Mon Dec 23 10:10:52 2013 -# by: pyside-uic 0.2.14 running on PySide 1.1.2 +# Created: Thu May 1 15:20:42 2014 +# by: pyside-uic 0.2.15 running on PySide 1.2.1 # # WARNING! All changes made in this file will be lost! @@ -41,15 +41,14 @@ class Ui_Form(object): self.roiBtn.setCheckable(True) self.roiBtn.setObjectName("roiBtn") self.gridLayout.addWidget(self.roiBtn, 1, 1, 1, 1) - self.normBtn = QtGui.QPushButton(self.layoutWidget) + self.menuBtn = QtGui.QPushButton(self.layoutWidget) sizePolicy = QtGui.QSizePolicy(QtGui.QSizePolicy.Minimum, QtGui.QSizePolicy.Fixed) sizePolicy.setHorizontalStretch(0) sizePolicy.setVerticalStretch(1) - sizePolicy.setHeightForWidth(self.normBtn.sizePolicy().hasHeightForWidth()) - self.normBtn.setSizePolicy(sizePolicy) - self.normBtn.setCheckable(True) - self.normBtn.setObjectName("normBtn") - self.gridLayout.addWidget(self.normBtn, 1, 2, 1, 1) + sizePolicy.setHeightForWidth(self.menuBtn.sizePolicy().hasHeightForWidth()) + self.menuBtn.setSizePolicy(sizePolicy) + self.menuBtn.setObjectName("menuBtn") + self.gridLayout.addWidget(self.menuBtn, 1, 2, 1, 1) self.roiPlot = PlotWidget(self.splitter) sizePolicy = QtGui.QSizePolicy(QtGui.QSizePolicy.Preferred, QtGui.QSizePolicy.Preferred) sizePolicy.setHorizontalStretch(0) @@ -135,7 +134,7 @@ class Ui_Form(object): def retranslateUi(self, Form): Form.setWindowTitle(QtGui.QApplication.translate("Form", "Form", None, QtGui.QApplication.UnicodeUTF8)) self.roiBtn.setText(QtGui.QApplication.translate("Form", "ROI", None, QtGui.QApplication.UnicodeUTF8)) - self.normBtn.setText(QtGui.QApplication.translate("Form", "Norm", None, QtGui.QApplication.UnicodeUTF8)) + self.menuBtn.setText(QtGui.QApplication.translate("Form", "Menu", None, QtGui.QApplication.UnicodeUTF8)) self.normGroup.setTitle(QtGui.QApplication.translate("Form", "Normalization", None, QtGui.QApplication.UnicodeUTF8)) self.normSubtractRadio.setText(QtGui.QApplication.translate("Form", "Subtract", None, QtGui.QApplication.UnicodeUTF8)) self.normDivideRadio.setText(QtGui.QApplication.translate("Form", "Divide", None, QtGui.QApplication.UnicodeUTF8)) diff --git a/metaarray/MetaArray.py b/metaarray/MetaArray.py index d24a7d05..9c3f5b8a 100644 --- a/metaarray/MetaArray.py +++ b/metaarray/MetaArray.py @@ -103,6 +103,14 @@ class MetaArray(object): """ version = '2' + + # Default hdf5 compression to use when writing + # 'gzip' is widely available and somewhat slow + # 'lzf' is faster, but generally not available outside h5py + # 'szip' is also faster, but lacks write support on windows + # (so by default, we use no compression) + # May also be a tuple (filter, opts), such as ('gzip', 3) + defaultCompression = None ## Types allowed as axis or column names nameTypes = [basestring, tuple] @@ -122,7 +130,7 @@ class MetaArray(object): if file is not None: self._data = None self.readFile(file, **kwargs) - if self._data is None: + if kwargs.get("readAllData", True) and self._data is None: raise Exception("File read failed: %s" % file) else: self._info = info @@ -720,25 +728,28 @@ class MetaArray(object): """ ## decide which read function to use - fd = open(filename, 'rb') - magic = fd.read(8) - if magic == '\x89HDF\r\n\x1a\n': - fd.close() - self._readHDF5(filename, **kwargs) - self._isHDF = True - else: - fd.seek(0) - meta = MetaArray._readMeta(fd) - if 'version' in meta: - ver = meta['version'] + with open(filename, 'rb') as fd: + magic = fd.read(8) + if magic == '\x89HDF\r\n\x1a\n': + fd.close() + self._readHDF5(filename, **kwargs) + self._isHDF = True else: - ver = 1 - rFuncName = '_readData%s' % str(ver) - if not hasattr(MetaArray, rFuncName): - raise Exception("This MetaArray library does not support array version '%s'" % ver) - rFunc = getattr(self, rFuncName) - rFunc(fd, meta, **kwargs) - self._isHDF = False + fd.seek(0) + meta = MetaArray._readMeta(fd) + + if not kwargs.get("readAllData", True): + self._data = np.empty(meta['shape'], dtype=meta['type']) + if 'version' in meta: + ver = meta['version'] + else: + ver = 1 + rFuncName = '_readData%s' % str(ver) + if not hasattr(MetaArray, rFuncName): + raise Exception("This MetaArray library does not support array version '%s'" % ver) + rFunc = getattr(self, rFuncName) + rFunc(fd, meta, **kwargs) + self._isHDF = False @staticmethod def _readMeta(fd): @@ -756,7 +767,7 @@ class MetaArray(object): #print ret return ret - def _readData1(self, fd, meta, mmap=False): + def _readData1(self, fd, meta, mmap=False, **kwds): ## Read array data from the file descriptor for MetaArray v1 files ## read in axis values for any axis that specifies a length frameSize = 1 @@ -766,16 +777,18 @@ class MetaArray(object): frameSize *= ax['values_len'] del ax['values_len'] del ax['values_type'] + self._info = meta['info'] + if not kwds.get("readAllData", True): + return ## the remaining data is the actual array if mmap: subarr = np.memmap(fd, dtype=meta['type'], mode='r', shape=meta['shape']) else: subarr = np.fromstring(fd.read(), dtype=meta['type']) subarr.shape = meta['shape'] - self._info = meta['info'] self._data = subarr - def _readData2(self, fd, meta, mmap=False, subset=None): + def _readData2(self, fd, meta, mmap=False, subset=None, **kwds): ## read in axis values dynAxis = None frameSize = 1 @@ -792,7 +805,10 @@ class MetaArray(object): frameSize *= ax['values_len'] del ax['values_len'] del ax['values_type'] - + self._info = meta['info'] + if not kwds.get("readAllData", True): + return + ## No axes are dynamic, just read the entire array in at once if dynAxis is None: #if rewriteDynamic is not None: @@ -1027,10 +1043,18 @@ class MetaArray(object): def writeHDF5(self, fileName, **opts): ## default options for writing datasets + comp = self.defaultCompression + if isinstance(comp, tuple): + comp, copts = comp + else: + copts = None + dsOpts = { - 'compression': 'lzf', + 'compression': comp, 'chunks': True, } + if copts is not None: + dsOpts['compression_opts'] = copts ## if there is an appendable axis, then we can guess the desired chunk shape (optimized for appending) appAxis = opts.get('appendAxis', None) diff --git a/multiprocess/remoteproxy.py b/multiprocess/remoteproxy.py index 4e7b7a1c..4f484b74 100644 --- a/multiprocess/remoteproxy.py +++ b/multiprocess/remoteproxy.py @@ -1,5 +1,6 @@ import os, time, sys, traceback, weakref import numpy as np +import threading try: import __builtin__ as builtins import cPickle as pickle @@ -53,8 +54,10 @@ class RemoteEventHandler(object): ## status is either 'result' or 'error' ## if 'error', then result will be (exception, formatted exceprion) ## where exception may be None if it could not be passed through the Connection. + self.resultLock = threading.RLock() self.proxies = {} ## maps {weakref(proxy): proxyId}; used to inform the remote process when a proxy has been deleted. + self.proxyLock = threading.RLock() ## attributes that affect the behavior of the proxy. ## See ObjectProxy._setProxyOptions for description @@ -66,10 +69,15 @@ class RemoteEventHandler(object): 'deferGetattr': False, ## True, False 'noProxyTypes': [ type(None), str, int, float, tuple, list, dict, LocalObjectProxy, ObjectProxy ], } + self.optsLock = threading.RLock() self.nextRequestId = 0 self.exited = False + # Mutexes to help prevent issues when multiple threads access the same RemoteEventHandler + self.processLock = threading.RLock() + self.sendLock = threading.RLock() + RemoteEventHandler.handlers[pid] = self ## register this handler as the one communicating with pid @classmethod @@ -86,46 +94,59 @@ class RemoteEventHandler(object): cprint.cout(self.debug, "[%d] %s\n" % (os.getpid(), str(msg)), -1) def getProxyOption(self, opt): - return self.proxyOptions[opt] + with self.optsLock: + return self.proxyOptions[opt] def setProxyOptions(self, **kwds): """ Set the default behavior options for object proxies. See ObjectProxy._setProxyOptions for more info. """ - self.proxyOptions.update(kwds) + with self.optsLock: + self.proxyOptions.update(kwds) def processRequests(self): """Process all pending requests from the pipe, return after no more events are immediately available. (non-blocking) Returns the number of events processed. """ - if self.exited: - self.debugMsg(' processRequests: exited already; raise ClosedError.') - raise ClosedError() - - numProcessed = 0 - while self.conn.poll(): - try: - self.handleRequest() - numProcessed += 1 - except ClosedError: - self.debugMsg('processRequests: got ClosedError from handleRequest; setting exited=True.') - self.exited = True - raise - #except IOError as err: ## let handleRequest take care of this. - #self.debugMsg(' got IOError from handleRequest; try again.') - #if err.errno == 4: ## interrupted system call; try again - #continue - #else: - #raise - except: - print("Error in process %s" % self.name) - sys.excepthook(*sys.exc_info()) - - if numProcessed > 0: - self.debugMsg('processRequests: finished %d requests' % numProcessed) - return numProcessed + with self.processLock: + + if self.exited: + self.debugMsg(' processRequests: exited already; raise ClosedError.') + raise ClosedError() + + numProcessed = 0 + + while self.conn.poll(): + #try: + #poll = self.conn.poll() + #if not poll: + #break + #except IOError: # this can happen if the remote process dies. + ## might it also happen in other circumstances? + #raise ClosedError() + + try: + self.handleRequest() + numProcessed += 1 + except ClosedError: + self.debugMsg('processRequests: got ClosedError from handleRequest; setting exited=True.') + self.exited = True + raise + #except IOError as err: ## let handleRequest take care of this. + #self.debugMsg(' got IOError from handleRequest; try again.') + #if err.errno == 4: ## interrupted system call; try again + #continue + #else: + #raise + except: + print("Error in process %s" % self.name) + sys.excepthook(*sys.exc_info()) + + if numProcessed > 0: + self.debugMsg('processRequests: finished %d requests' % numProcessed) + return numProcessed def handleRequest(self): """Handle a single request from the remote process. @@ -183,9 +204,11 @@ class RemoteEventHandler(object): returnType = opts.get('returnType', 'auto') if cmd == 'result': - self.results[resultId] = ('result', opts['result']) + with self.resultLock: + self.results[resultId] = ('result', opts['result']) elif cmd == 'error': - self.results[resultId] = ('error', (opts['exception'], opts['excString'])) + with self.resultLock: + self.results[resultId] = ('error', (opts['exception'], opts['excString'])) elif cmd == 'getObjAttr': result = getattr(opts['obj'], opts['attr']) elif cmd == 'callObj': @@ -259,7 +282,9 @@ class RemoteEventHandler(object): self.debugMsg(" handleRequest: sending return value for %d: %s" % (reqId, str(result))) #print "returnValue:", returnValue, result if returnType == 'auto': - result = self.autoProxy(result, self.proxyOptions['noProxyTypes']) + with self.optsLock: + noProxyTypes = self.proxyOptions['noProxyTypes'] + result = self.autoProxy(result, noProxyTypes) elif returnType == 'proxy': result = LocalObjectProxy(result) @@ -378,54 +403,59 @@ class RemoteEventHandler(object): traceback ============= ===================================================================== """ - #if len(kwds) > 0: - #print "Warning: send() ignored args:", kwds + if self.exited: + self.debugMsg(' send: exited already; raise ClosedError.') + raise ClosedError() + + with self.sendLock: + #if len(kwds) > 0: + #print "Warning: send() ignored args:", kwds + + if opts is None: + opts = {} - if opts is None: - opts = {} - - assert callSync in ['off', 'sync', 'async'], 'callSync must be one of "off", "sync", or "async"' - if reqId is None: - if callSync != 'off': ## requested return value; use the next available request ID - reqId = self.nextRequestId - self.nextRequestId += 1 - else: - ## If requestId is provided, this _must_ be a response to a previously received request. - assert request in ['result', 'error'] - - if returnType is not None: - opts['returnType'] = returnType + assert callSync in ['off', 'sync', 'async'], 'callSync must be one of "off", "sync", or "async"' + if reqId is None: + if callSync != 'off': ## requested return value; use the next available request ID + reqId = self.nextRequestId + self.nextRequestId += 1 + else: + ## If requestId is provided, this _must_ be a response to a previously received request. + assert request in ['result', 'error'] - #print os.getpid(), "send request:", request, reqId, opts - - ## double-pickle args to ensure that at least status and request ID get through - try: - optStr = pickle.dumps(opts) - except: - print("==== Error pickling this object: ====") - print(opts) - print("=======================================") - raise - - nByteMsgs = 0 - if byteData is not None: - nByteMsgs = len(byteData) + if returnType is not None: + opts['returnType'] = returnType + + #print os.getpid(), "send request:", request, reqId, opts + + ## double-pickle args to ensure that at least status and request ID get through + try: + optStr = pickle.dumps(opts) + except: + print("==== Error pickling this object: ====") + print(opts) + print("=======================================") + raise + + nByteMsgs = 0 + if byteData is not None: + nByteMsgs = len(byteData) + + ## Send primary request + request = (request, reqId, nByteMsgs, optStr) + self.debugMsg('send request: cmd=%s nByteMsgs=%d id=%s opts=%s' % (str(request[0]), nByteMsgs, str(reqId), str(opts))) + self.conn.send(request) + + ## follow up by sending byte messages + if byteData is not None: + for obj in byteData: ## Remote process _must_ be prepared to read the same number of byte messages! + self.conn.send_bytes(obj) + self.debugMsg(' sent %d byte messages' % len(byteData)) + + self.debugMsg(' call sync: %s' % callSync) + if callSync == 'off': + return - ## Send primary request - request = (request, reqId, nByteMsgs, optStr) - self.debugMsg('send request: cmd=%s nByteMsgs=%d id=%s opts=%s' % (str(request[0]), nByteMsgs, str(reqId), str(opts))) - self.conn.send(request) - - ## follow up by sending byte messages - if byteData is not None: - for obj in byteData: ## Remote process _must_ be prepared to read the same number of byte messages! - self.conn.send_bytes(obj) - self.debugMsg(' sent %d byte messages' % len(byteData)) - - self.debugMsg(' call sync: %s' % callSync) - if callSync == 'off': - return - req = Request(self, reqId, description=str(request), timeout=timeout) if callSync == 'async': return req @@ -437,20 +467,30 @@ class RemoteEventHandler(object): return req def close(self, callSync='off', noCleanup=False, **kwds): - self.send(request='close', opts=dict(noCleanup=noCleanup), callSync=callSync, **kwds) + try: + self.send(request='close', opts=dict(noCleanup=noCleanup), callSync=callSync, **kwds) + self.exited = True + except ClosedError: + pass def getResult(self, reqId): ## raises NoResultError if the result is not available yet #print self.results.keys(), os.getpid() - if reqId not in self.results: + with self.resultLock: + haveResult = reqId in self.results + + if not haveResult: try: self.processRequests() except ClosedError: ## even if remote connection has closed, we may have ## received new data during this call to processRequests() pass - if reqId not in self.results: - raise NoResultError() - status, result = self.results.pop(reqId) + + with self.resultLock: + if reqId not in self.results: + raise NoResultError() + status, result = self.results.pop(reqId) + if status == 'result': return result elif status == 'error': @@ -494,11 +534,13 @@ class RemoteEventHandler(object): args = list(args) ## Decide whether to send arguments by value or by proxy - noProxyTypes = opts.pop('noProxyTypes', None) - if noProxyTypes is None: - noProxyTypes = self.proxyOptions['noProxyTypes'] - - autoProxy = opts.pop('autoProxy', self.proxyOptions['autoProxy']) + with self.optsLock: + noProxyTypes = opts.pop('noProxyTypes', None) + if noProxyTypes is None: + noProxyTypes = self.proxyOptions['noProxyTypes'] + + autoProxy = opts.pop('autoProxy', self.proxyOptions['autoProxy']) + if autoProxy is True: args = [self.autoProxy(v, noProxyTypes) for v in args] for k, v in kwds.iteritems(): @@ -520,11 +562,14 @@ class RemoteEventHandler(object): return self.send(request='callObj', opts=dict(obj=obj, args=args, kwds=kwds), byteData=byteMsgs, **opts) def registerProxy(self, proxy): - ref = weakref.ref(proxy, self.deleteProxy) - self.proxies[ref] = proxy._proxyId + with self.proxyLock: + ref = weakref.ref(proxy, self.deleteProxy) + self.proxies[ref] = proxy._proxyId def deleteProxy(self, ref): - proxyId = self.proxies.pop(ref) + with self.proxyLock: + proxyId = self.proxies.pop(ref) + try: self.send(request='del', opts=dict(proxyId=proxyId), callSync='off') except IOError: ## if remote process has closed down, there is no need to send delete requests anymore diff --git a/parametertree/Parameter.py b/parametertree/Parameter.py index 1c75c333..5f37ccdc 100644 --- a/parametertree/Parameter.py +++ b/parametertree/Parameter.py @@ -1,6 +1,7 @@ from ..Qt import QtGui, QtCore import os, weakref, re from ..pgcollections import OrderedDict +from ..python2_3 import asUnicode from .ParameterItem import ParameterItem PARAM_TYPES = {} @@ -13,7 +14,9 @@ def registerParameterType(name, cls, override=False): PARAM_TYPES[name] = cls PARAM_NAMES[cls] = name - +def __reload__(old): + PARAM_TYPES.update(old.get('PARAM_TYPES', {})) + PARAM_NAMES.update(old.get('PARAM_NAMES', {})) class Parameter(QtCore.QObject): """ @@ -46,6 +49,7 @@ class Parameter(QtCore.QObject): including during editing. sigChildAdded(self, child, index) Emitted when a child is added sigChildRemoved(self, child) Emitted when a child is removed + sigRemoved(self) Emitted when this parameter is removed sigParentChanged(self, parent) Emitted when this parameter's parent has changed sigLimitsChanged(self, limits) Emitted when this parameter's limits have changed sigDefaultChanged(self, default) Emitted when this parameter's default value has changed @@ -61,6 +65,7 @@ class Parameter(QtCore.QObject): sigChildAdded = QtCore.Signal(object, object, object) ## self, child, index sigChildRemoved = QtCore.Signal(object, object) ## self, child + sigRemoved = QtCore.Signal(object) ## self sigParentChanged = QtCore.Signal(object, object) ## self, parent sigLimitsChanged = QtCore.Signal(object, object) ## self, limits sigDefaultChanged = QtCore.Signal(object, object) ## self, default @@ -133,6 +138,12 @@ class Parameter(QtCore.QObject): expanded If True, the Parameter will appear expanded when displayed in a ParameterTree (its children will be visible). (default=True) + title (str or None) If specified, then the parameter will be + displayed to the user using this string as its name. + However, the parameter will still be referred to + internally using the *name* specified above. Note that + this option is not compatible with renamable=True. + (default=None; added in version 0.9.9) ======================= ========================================================= """ @@ -148,6 +159,7 @@ class Parameter(QtCore.QObject): 'removable': False, 'strictNaming': False, # forces name to be usable as a python variable 'expanded': True, + 'title': None, #'limits': None, ## This is a bad plan--each parameter type may have a different data type for limits. } self.opts.update(opts) @@ -266,16 +278,27 @@ class Parameter(QtCore.QObject): vals[ch.name()] = (ch.value(), ch.getValues()) return vals - def saveState(self): + def saveState(self, filter=None): """ Return a structure representing the entire state of the parameter tree. - The tree state may be restored from this structure using restoreState() + The tree state may be restored from this structure using restoreState(). + + If *filter* is set to 'user', then only user-settable data will be included in the + returned state. """ - state = self.opts.copy() - state['children'] = OrderedDict([(ch.name(), ch.saveState()) for ch in self]) - if state['type'] is None: - global PARAM_NAMES - state['type'] = PARAM_NAMES.get(type(self), None) + if filter is None: + state = self.opts.copy() + if state['type'] is None: + global PARAM_NAMES + state['type'] = PARAM_NAMES.get(type(self), None) + elif filter == 'user': + state = {'value': self.value()} + else: + raise ValueError("Unrecognized filter argument: '%s'" % filter) + + ch = OrderedDict([(ch.name(), ch.saveState(filter=filter)) for ch in self]) + if len(ch) > 0: + state['children'] = ch return state def restoreState(self, state, recursive=True, addChildren=True, removeChildren=True, blockSignals=True): @@ -293,8 +316,11 @@ class Parameter(QtCore.QObject): ## list of children may be stored either as list or dict. if isinstance(childState, dict): - childState = childState.values() - + cs = [] + for k,v in childState.items(): + cs.append(v.copy()) + cs[-1].setdefault('name', k) + childState = cs if blockSignals: self.blockTreeChangeSignal() @@ -311,14 +337,14 @@ class Parameter(QtCore.QObject): for ch in childState: name = ch['name'] - typ = ch['type'] + #typ = ch.get('type', None) #print('child: %s, %s' % (self.name()+'.'+name, typ)) - ## First, see if there is already a child with this name and type + ## First, see if there is already a child with this name gotChild = False for i, ch2 in enumerate(self.childs[ptr:]): #print " ", ch2.name(), ch2.type() - if ch2.name() != name or not ch2.isType(typ): + if ch2.name() != name: # or not ch2.isType(typ): continue gotChild = True #print " found it" @@ -393,15 +419,22 @@ class Parameter(QtCore.QObject): Note that the value of the parameter can *always* be changed by calling setValue(). """ - return not self.opts.get('readonly', False) + return not self.readonly() def setWritable(self, writable=True): """Set whether this Parameter should be editable by the user. (This is exactly the opposite of setReadonly).""" self.setOpts(readonly=not writable) + def readonly(self): + """ + Return True if this parameter is read-only. (this is the opposite of writable()) + """ + return self.opts.get('readonly', False) + def setReadonly(self, readonly=True): - """Set whether this Parameter's value may be edited by the user.""" + """Set whether this Parameter's value may be edited by the user + (this is the opposite of setWritable()).""" self.setOpts(readonly=readonly) def setOpts(self, **opts): @@ -453,11 +486,20 @@ class Parameter(QtCore.QObject): return ParameterItem(self, depth=depth) - def addChild(self, child): - """Add another parameter to the end of this parameter's child list.""" - return self.insertChild(len(self.childs), child) + def addChild(self, child, autoIncrementName=None): + """ + Add another parameter to the end of this parameter's child list. + + See insertChild() for a description of the *autoIncrementName* + argument. + """ + return self.insertChild(len(self.childs), child, autoIncrementName=autoIncrementName) def addChildren(self, children): + """ + Add a list or dict of children to this parameter. This method calls + addChild once for each value in *children*. + """ ## If children was specified as dict, then assume keys are the names. if isinstance(children, dict): ch2 = [] @@ -473,19 +515,24 @@ class Parameter(QtCore.QObject): self.addChild(chOpts) - def insertChild(self, pos, child): + def insertChild(self, pos, child, autoIncrementName=None): """ Insert a new child at pos. If pos is a Parameter, then insert at the position of that Parameter. If child is a dict, then a parameter is constructed using :func:`Parameter.create `. + + By default, the child's 'autoIncrementName' option determines whether + the name will be adjusted to avoid prior name collisions. This + behavior may be overridden by specifying the *autoIncrementName* + argument. This argument was added in version 0.9.9. """ if isinstance(child, dict): child = Parameter.create(**child) name = child.name() if name in self.names and child is not self.names[name]: - if child.opts.get('autoIncrementName', False): + if autoIncrementName is True or (autoIncrementName is None and child.opts.get('autoIncrementName', False)): name = self.incrementName(name) child.setName(name) else: @@ -550,6 +597,7 @@ class Parameter(QtCore.QObject): if parent is None: raise Exception("Cannot remove; no parent.") parent.removeChild(self) + self.sigRemoved.emit(self) def incrementName(self, name): ## return an unused name by adding a number to the name given @@ -590,9 +638,12 @@ class Parameter(QtCore.QObject): names = (names,) return self.param(*names).setValue(value) - def param(self, *names): + def child(self, *names): """Return a child parameter. - Accepts the name of the child or a tuple (path, to, child)""" + Accepts the name of the child or a tuple (path, to, child) + + Added in version 0.9.9. Ealier versions used the 'param' method, which is still + implemented for backward compatibility.""" try: param = self.names[names[0]] except KeyError: @@ -603,8 +654,12 @@ class Parameter(QtCore.QObject): else: return param + def param(self, *names): + # for backward compatibility. + return self.child(*names) + def __repr__(self): - return "<%s '%s' at 0x%x>" % (self.__class__.__name__, self.name(), id(self)) + return asUnicode("<%s '%s' at 0x%x>") % (self.__class__.__name__, self.name(), id(self)) def __getattr__(self, attr): ## Leaving this undocumented because I might like to remove it in the future.. @@ -692,7 +747,8 @@ class Parameter(QtCore.QObject): if self.blockTreeChangeEmit == 0: changes = self.treeStateChanges self.treeStateChanges = [] - self.sigTreeStateChanged.emit(self, changes) + if len(changes) > 0: + self.sigTreeStateChanged.emit(self, changes) class SignalBlocker(object): diff --git a/parametertree/ParameterItem.py b/parametertree/ParameterItem.py index 5a90becf..c149c411 100644 --- a/parametertree/ParameterItem.py +++ b/parametertree/ParameterItem.py @@ -1,4 +1,5 @@ from ..Qt import QtGui, QtCore +from ..python2_3 import asUnicode import os, weakref, re class ParameterItem(QtGui.QTreeWidgetItem): @@ -15,8 +16,11 @@ class ParameterItem(QtGui.QTreeWidgetItem): """ def __init__(self, param, depth=0): - QtGui.QTreeWidgetItem.__init__(self, [param.name(), '']) - + title = param.opts.get('title', None) + if title is None: + title = param.name() + QtGui.QTreeWidgetItem.__init__(self, [title, '']) + self.param = param self.param.registerItem(self) ## let parameter know this item is connected to it (for debugging) self.depth = depth @@ -30,7 +34,6 @@ class ParameterItem(QtGui.QTreeWidgetItem): param.sigOptionsChanged.connect(self.optsChanged) param.sigParentChanged.connect(self.parentChanged) - opts = param.opts ## Generate context menu for renaming/removing parameter @@ -38,6 +41,8 @@ class ParameterItem(QtGui.QTreeWidgetItem): self.contextMenu.addSeparator() flags = QtCore.Qt.ItemIsSelectable | QtCore.Qt.ItemIsEnabled if opts.get('renamable', False): + if param.opts.get('title', None) is not None: + raise Exception("Cannot make parameter with both title != None and renamable == True.") flags |= QtCore.Qt.ItemIsEditable self.contextMenu.addAction('Rename').triggered.connect(self.editName) if opts.get('removable', False): @@ -107,15 +112,15 @@ class ParameterItem(QtGui.QTreeWidgetItem): self.contextMenu.popup(ev.globalPos()) def columnChangedEvent(self, col): - """Called when the text in a column has been edited. + """Called when the text in a column has been edited (or otherwise changed). By default, we only use changes to column 0 to rename the parameter. """ - if col == 0: + if col == 0 and (self.param.opts.get('title', None) is None): if self.ignoreNameColumnChange: return try: - newName = self.param.setName(str(self.text(col))) - except: + newName = self.param.setName(asUnicode(self.text(col))) + except Exception: self.setText(0, self.param.name()) raise @@ -127,8 +132,9 @@ class ParameterItem(QtGui.QTreeWidgetItem): def nameChanged(self, param, name): ## called when the parameter's name has changed. - self.setText(0, name) - + if self.param.opts.get('title', None) is None: + self.setText(0, name) + def limitsChanged(self, param, limits): """Called when the parameter's limits have changed""" pass diff --git a/parametertree/ParameterSystem.py b/parametertree/ParameterSystem.py new file mode 100644 index 00000000..33bb2de8 --- /dev/null +++ b/parametertree/ParameterSystem.py @@ -0,0 +1,127 @@ +from .parameterTypes import GroupParameter +from .. import functions as fn +from .SystemSolver import SystemSolver + + +class ParameterSystem(GroupParameter): + """ + ParameterSystem is a subclass of GroupParameter that manages a tree of + sub-parameters with a set of interdependencies--changing any one parameter + may affect other parameters in the system. + + See parametertree/SystemSolver for more information. + + NOTE: This API is experimental and may change substantially across minor + version numbers. + """ + def __init__(self, *args, **kwds): + GroupParameter.__init__(self, *args, **kwds) + self._system = None + self._fixParams = [] # all auto-generated 'fixed' params + sys = kwds.pop('system', None) + if sys is not None: + self.setSystem(sys) + self._ignoreChange = [] # params whose changes should be ignored temporarily + self.sigTreeStateChanged.connect(self.updateSystem) + + def setSystem(self, sys): + self._system = sys + + # auto-generate defaults to match child parameters + defaults = {} + vals = {} + for param in self: + name = param.name() + constraints = '' + if hasattr(sys, '_' + name): + constraints += 'n' + + if not param.readonly(): + constraints += 'f' + if 'n' in constraints: + ch = param.addChild(dict(name='fixed', type='bool', value=False)) + self._fixParams.append(ch) + param.setReadonly(True) + param.setOpts(expanded=False) + else: + vals[name] = param.value() + ch = param.addChild(dict(name='fixed', type='bool', value=True, readonly=True)) + #self._fixParams.append(ch) + + defaults[name] = [None, param.type(), None, constraints] + + sys.defaultState.update(defaults) + sys.reset() + for name, value in vals.items(): + setattr(sys, name, value) + + self.updateAllParams() + + def updateSystem(self, param, changes): + changes = [ch for ch in changes if ch[0] not in self._ignoreChange] + + #resets = [ch[0] for ch in changes if ch[1] == 'setToDefault'] + sets = [ch[0] for ch in changes if ch[1] == 'value'] + #for param in resets: + #setattr(self._system, param.name(), None) + + for param in sets: + #if param in resets: + #continue + + #if param in self._fixParams: + #param.parent().setWritable(param.value()) + #else: + if param in self._fixParams: + parent = param.parent() + if param.value(): + setattr(self._system, parent.name(), parent.value()) + else: + setattr(self._system, parent.name(), None) + else: + setattr(self._system, param.name(), param.value()) + + self.updateAllParams() + + def updateAllParams(self): + try: + self.sigTreeStateChanged.disconnect(self.updateSystem) + for name, state in self._system._vars.items(): + param = self.child(name) + try: + v = getattr(self._system, name) + if self._system._vars[name][2] is None: + self.updateParamState(self.child(name), 'autoSet') + param.setValue(v) + else: + self.updateParamState(self.child(name), 'fixed') + except RuntimeError: + self.updateParamState(param, 'autoUnset') + finally: + self.sigTreeStateChanged.connect(self.updateSystem) + + def updateParamState(self, param, state): + if state == 'autoSet': + bg = fn.mkBrush((200, 255, 200, 255)) + bold = False + readonly = True + elif state == 'autoUnset': + bg = fn.mkBrush(None) + bold = False + readonly = False + elif state == 'fixed': + bg = fn.mkBrush('y') + bold = True + readonly = False + + param.setReadonly(readonly) + + #for item in param.items: + #item.setBackground(0, bg) + #f = item.font(0) + #f.setWeight(f.Bold if bold else f.Normal) + #item.setFont(0, f) + + + + diff --git a/parametertree/SystemSolver.py b/parametertree/SystemSolver.py new file mode 100644 index 00000000..367210f2 --- /dev/null +++ b/parametertree/SystemSolver.py @@ -0,0 +1,381 @@ +from collections import OrderedDict +import numpy as np + +class SystemSolver(object): + """ + This abstract class is used to formalize and manage user interaction with a + complex system of equations (related to "constraint satisfaction problems"). + It is often the case that devices must be controlled + through a large number of free variables, and interactions between these + variables make the system difficult to manage and conceptualize as a user + interface. This class does _not_ attempt to numerically solve the system + of equations. Rather, it provides a framework for subdividing the system + into manageable pieces and specifying closed-form solutions to these small + pieces. + + For an example, see the simple Camera class below. + + Theory of operation: Conceptualize the system as 1) a set of variables + whose values may be either user-specified or automatically generated, and + 2) a set of functions that define *how* each variable should be generated. + When a variable is accessed (as an instance attribute), the solver first + checks to see if it already has a value (either user-supplied, or cached + from a previous calculation). If it does not, then the solver calls a + method on itself (the method must be named `_variableName`) that will + either return the calculated value (which usually involves acccessing + other variables in the system), or raise RuntimeError if it is unable to + calculate the value (usually because the user has not provided sufficient + input to fully constrain the system). + + Each method that calculates a variable value may include multiple + try/except blocks, so that if one method generates a RuntimeError, it may + fall back on others. + In this way, the system may be solved by recursively searching the tree of + possible relationships between variables. This allows the user flexibility + in deciding which variables are the most important to specify, while + avoiding the apparent combinatorial explosion of calculation pathways + that must be considered by the developer. + + Solved values are cached for efficiency, and automatically cleared when + a state change invalidates the cache. The rules for this are simple: any + time a value is set, it invalidates the cache *unless* the previous value + was None (which indicates that no other variable has yet requested that + value). More complex cache management may be defined in subclasses. + + + Subclasses must define: + + 1) The *defaultState* class attribute: This is a dict containing a + description of the variables in the system--their default values, + data types, and the ways they can be constrained. The format is:: + + { name: [value, type, constraint, allowed_constraints], ...} + + * *value* is the default value. May be None if it has not been specified + yet. + * *type* may be float, int, bool, np.ndarray, ... + * *constraint* may be None, single value, or (min, max) + * None indicates that the value is not constrained--it may be + automatically generated if the value is requested. + * *allowed_constraints* is a string composed of (n)one, (f)ixed, and (r)ange. + + Note: do not put mutable objects inside defaultState! + + 2) For each variable that may be automatically determined, a method must + be defined with the name `_variableName`. This method may either return + the + """ + + defaultState = OrderedDict() + + def __init__(self): + self.__dict__['_vars'] = OrderedDict() + self.__dict__['_currentGets'] = set() + self.reset() + + def reset(self): + """ + Reset all variables in the solver to their default state. + """ + self._currentGets.clear() + for k in self.defaultState: + self._vars[k] = self.defaultState[k][:] + + def __getattr__(self, name): + if name in self._vars: + return self.get(name) + raise AttributeError(name) + + def __setattr__(self, name, value): + """ + Set the value of a state variable. + If None is given for the value, then the constraint will also be set to None. + If a tuple is given for a scalar variable, then the tuple is used as a range constraint instead of a value. + Otherwise, the constraint is set to 'fixed'. + + """ + # First check this is a valid attribute + if name in self._vars: + if value is None: + self.set(name, value, None) + elif isinstance(value, tuple) and self._vars[name][1] is not np.ndarray: + self.set(name, None, value) + else: + self.set(name, value, 'fixed') + else: + # also allow setting any other pre-existing attribute + if hasattr(self, name): + object.__setattr__(self, name, value) + else: + raise AttributeError(name) + + def get(self, name): + """ + Return the value for parameter *name*. + + If the value has not been specified, then attempt to compute it from + other interacting parameters. + + If no value can be determined, then raise RuntimeError. + """ + if name in self._currentGets: + raise RuntimeError("Cyclic dependency while calculating '%s'." % name) + self._currentGets.add(name) + try: + v = self._vars[name][0] + if v is None: + cfunc = getattr(self, '_' + name, None) + if cfunc is None: + v = None + else: + v = cfunc() + if v is None: + raise RuntimeError("Parameter '%s' is not specified." % name) + v = self.set(name, v) + finally: + self._currentGets.remove(name) + + return v + + def set(self, name, value=None, constraint=True): + """ + Set a variable *name* to *value*. The actual set value is returned (in + some cases, the value may be cast into another type). + + If *value* is None, then the value is left to be determined in the + future. At any time, the value may be re-assigned arbitrarily unless + a constraint is given. + + If *constraint* is True (the default), then supplying a value that + violates a previously specified constraint will raise an exception. + + If *constraint* is 'fixed', then the value is set (if provided) and + the variable will not be updated automatically in the future. + + If *constraint* is a tuple, then the value is constrained to be within the + given (min, max). Either constraint may be None to disable + it. In some cases, a constraint cannot be satisfied automatically, + and the user will be forced to resolve the constraint manually. + + If *constraint* is None, then any constraints are removed for the variable. + """ + var = self._vars[name] + if constraint is None: + if 'n' not in var[3]: + raise TypeError("Empty constraints not allowed for '%s'" % name) + var[2] = constraint + elif constraint == 'fixed': + if 'f' not in var[3]: + raise TypeError("Fixed constraints not allowed for '%s'" % name) + var[2] = constraint + elif isinstance(constraint, tuple): + if 'r' not in var[3]: + raise TypeError("Range constraints not allowed for '%s'" % name) + assert len(constraint) == 2 + var[2] = constraint + elif constraint is not True: + raise TypeError("constraint must be None, True, 'fixed', or tuple. (got %s)" % constraint) + + # type checking / massaging + if var[1] is np.ndarray: + value = np.array(value, dtype=float) + elif var[1] in (int, float, tuple) and value is not None: + value = var[1](value) + + # constraint checks + if constraint is True and not self.check_constraint(name, value): + raise ValueError("Setting %s = %s violates constraint %s" % (name, value, var[2])) + + # invalidate other dependent values + if var[0] is not None: + # todo: we can make this more clever..(and might need to) + # we just know that a value of None cannot have dependencies + # (because if anyone else had asked for this value, it wouldn't be + # None anymore) + self.resetUnfixed() + + var[0] = value + return value + + def check_constraint(self, name, value): + c = self._vars[name][2] + if c is None or value is None: + return True + if isinstance(c, tuple): + return ((c[0] is None or c[0] <= value) and + (c[1] is None or c[1] >= value)) + else: + return value == c + + def saveState(self): + """ + Return a serializable description of the solver's current state. + """ + state = OrderedDict() + for name, var in self._vars.items(): + state[name] = (var[0], var[2]) + return state + + def restoreState(self, state): + """ + Restore the state of all values and constraints in the solver. + """ + self.reset() + for name, var in state.items(): + self.set(name, var[0], var[1]) + + def resetUnfixed(self): + """ + For any variable that does not have a fixed value, reset + its value to None. + """ + for var in self._vars.values(): + if var[2] != 'fixed': + var[0] = None + + def solve(self): + for k in self._vars: + getattr(self, k) + + def __repr__(self): + state = OrderedDict() + for name, var in self._vars.items(): + if var[2] == 'fixed': + state[name] = var[0] + state = ', '.join(["%s=%s" % (n, v) for n,v in state.items()]) + return "<%s %s>" % (self.__class__.__name__, state) + + + + + +if __name__ == '__main__': + + class Camera(SystemSolver): + """ + Consider a simple SLR camera. The variables we will consider that + affect the camera's behavior while acquiring a photo are aperture, shutter speed, + ISO, and flash (of course there are many more, but let's keep the example simple). + + In rare cases, the user wants to manually specify each of these variables and + no more work needs to be done to take the photo. More often, the user wants to + specify more interesting constraints like depth of field, overall exposure, + or maximum allowed ISO value. + + If we add a simple light meter measurement into this system and an 'exposure' + variable that indicates the desired exposure (0 is "perfect", -1 is one stop + darker, etc), then the system of equations governing the camera behavior would + have the following variables: + + aperture, shutter, iso, flash, exposure, light meter + + The first four variables are the "outputs" of the system (they directly drive + the camera), the last is a constant (the camera itself cannot affect the + reading on the light meter), and 'exposure' specifies a desired relationship + between other variables in the system. + + So the question is: how can I formalize a system like this as a user interface? + Typical cameras have a fairly limited approach: provide the user with a list + of modes, each of which defines a particular set of constraints. For example: + + manual: user provides aperture, shutter, iso, and flash + aperture priority: user provides aperture and exposure, camera selects + iso, shutter, and flash automatically + shutter priority: user provides shutter and exposure, camera selects + iso, aperture, and flash + program: user specifies exposure, camera selects all other variables + automatically + action: camera selects all variables while attempting to maximize + shutter speed + portrait: camera selects all variables while attempting to minimize + aperture + + A more general approach might allow the user to provide more explicit + constraints on each variable (for example: I want a shutter speed of 1/30 or + slower, an ISO no greater than 400, an exposure between -1 and 1, and the + smallest aperture possible given all other constraints) and have the camera + solve the system of equations, with a warning if no solution is found. This + is exactly what we will implement in this example class. + """ + + defaultState = OrderedDict([ + # Field stop aperture + ('aperture', [None, float, None, 'nf']), + # Duration that shutter is held open. + ('shutter', [None, float, None, 'nf']), + # ISO (sensitivity) value. 100, 200, 400, 800, 1600.. + ('iso', [None, int, None, 'nf']), + + # Flash is a value indicating the brightness of the flash. A table + # is used to decide on "balanced" settings for each flash level: + # 0: no flash + # 1: s=1/60, a=2.0, iso=100 + # 2: s=1/60, a=4.0, iso=100 ..and so on.. + ('flash', [None, float, None, 'nf']), + + # exposure is a value indicating how many stops brighter (+1) or + # darker (-1) the photographer would like the photo to appear from + # the 'balanced' settings indicated by the light meter (see below). + ('exposure', [None, float, None, 'f']), + + # Let's define this as an external light meter (not affected by + # aperture) with logarithmic output. We arbitrarily choose the + # following settings as "well balanced" for each light meter value: + # -1: s=1/60, a=2.0, iso=100 + # 0: s=1/60, a=4.0, iso=100 + # 1: s=1/120, a=4.0, iso=100 ..and so on.. + # Note that the only allowed constraint mode is (f)ixed, since the + # camera never _computes_ the light meter value, it only reads it. + ('lightMeter', [None, float, None, 'f']), + + # Indicates the camera's final decision on how it thinks the photo will + # look, given the chosen settings. This value is _only_ determined + # automatically. + ('balance', [None, float, None, 'n']), + ]) + + def _aperture(self): + """ + Determine aperture automatically under a variety of conditions. + """ + iso = self.iso + exp = self.exposure + light = self.lightMeter + + try: + # shutter-priority mode + sh = self.shutter # this raises RuntimeError if shutter has not + # been specified + ap = 4.0 * (sh / (1./60.)) * (iso / 100.) * (2 ** exp) * (2 ** light) + ap = np.clip(ap, 2.0, 16.0) + except RuntimeError: + # program mode; we can select a suitable shutter + # value at the same time. + sh = (1./60.) + raise + + + + return ap + + def _balance(self): + iso = self.iso + light = self.lightMeter + sh = self.shutter + ap = self.aperture + fl = self.flash + + bal = (4.0 / ap) * (sh / (1./60.)) * (iso / 100.) * (2 ** light) + return np.log2(bal) + + camera = Camera() + + camera.iso = 100 + camera.exposure = 0 + camera.lightMeter = 2 + camera.shutter = 1./60. + camera.flash = 0 + + camera.solve() + print camera.saveState() + \ No newline at end of file diff --git a/parametertree/__init__.py b/parametertree/__init__.py index acdb7a37..722410d5 100644 --- a/parametertree/__init__.py +++ b/parametertree/__init__.py @@ -1,5 +1,5 @@ from .Parameter import Parameter, registerParameterType from .ParameterTree import ParameterTree from .ParameterItem import ParameterItem - +from .ParameterSystem import ParameterSystem, SystemSolver from . import parameterTypes as types \ No newline at end of file diff --git a/parametertree/parameterTypes.py b/parametertree/parameterTypes.py index 8aba4bca..7b1c5ee6 100644 --- a/parametertree/parameterTypes.py +++ b/parametertree/parameterTypes.py @@ -78,6 +78,7 @@ class WidgetParameterItem(ParameterItem): ## no starting value was given; use whatever the widget has self.widgetValueChanged() + self.updateDefaultBtn() def makeWidget(self): """ @@ -191,6 +192,9 @@ class WidgetParameterItem(ParameterItem): def updateDefaultBtn(self): ## enable/disable default btn self.defaultBtn.setEnabled(not self.param.valueIsDefault() and self.param.writable()) + + # hide / show + self.defaultBtn.setVisible(not self.param.readonly()) def updateDisplayLabel(self, value=None): """Update the display label to reflect the value of the parameter.""" @@ -234,6 +238,8 @@ class WidgetParameterItem(ParameterItem): self.widget.show() self.displayLabel.hide() self.widget.setFocus(QtCore.Qt.OtherFocusReason) + if isinstance(self.widget, SpinBox): + self.widget.selectNumber() # select the numerical portion of the text for quick editing def hideEditor(self): self.widget.hide() @@ -277,7 +283,7 @@ class WidgetParameterItem(ParameterItem): if 'readonly' in opts: self.updateDefaultBtn() if isinstance(self.widget, (QtGui.QCheckBox,ColorButton)): - w.setEnabled(not opts['readonly']) + self.widget.setEnabled(not opts['readonly']) ## If widget is a SpinBox, pass options straight through if isinstance(self.widget, SpinBox): @@ -315,8 +321,8 @@ class SimpleParameter(Parameter): def colorValue(self): return fn.mkColor(Parameter.value(self)) - def saveColorState(self): - state = Parameter.saveState(self) + def saveColorState(self, *args, **kwds): + state = Parameter.saveState(self, *args, **kwds) state['value'] = fn.colorTuple(self.value()) return state @@ -539,7 +545,6 @@ class ListParameter(Parameter): self.forward, self.reverse = self.mapping(limits) Parameter.setLimits(self, limits) - #print self.name(), self.value(), limits, self.reverse if len(self.reverse[0]) > 0 and self.value() not in self.reverse[0]: self.setValue(self.reverse[0][0]) diff --git a/tests/test_functions.py b/tests/test_functions.py index 47fa266d..f622dd87 100644 --- a/tests/test_functions.py +++ b/tests/test_functions.py @@ -61,6 +61,19 @@ def test_interpolateArray(): assert_array_almost_equal(r1, r2) +def test_subArray(): + a = np.array([0, 0, 111, 112, 113, 0, 121, 122, 123, 0, 0, 0, 211, 212, 213, 0, 221, 222, 223, 0, 0, 0, 0]) + b = pg.subArray(a, offset=2, shape=(2,2,3), stride=(10,4,1)) + c = np.array([[[111,112,113], [121,122,123]], [[211,212,213], [221,222,223]]]) + assert np.all(b == c) + + # operate over first axis; broadcast over the rest + aa = np.vstack([a, a/100.]).T + cc = np.empty(c.shape + (2,)) + cc[..., 0] = c + cc[..., 1] = c / 100. + bb = pg.subArray(aa, offset=2, shape=(2,2,3), stride=(10,4,1)) + assert np.all(bb == cc) diff --git a/util/garbage_collector.py b/util/garbage_collector.py new file mode 100644 index 00000000..979e66c5 --- /dev/null +++ b/util/garbage_collector.py @@ -0,0 +1,50 @@ +import gc + +from ..Qt import QtCore + +class GarbageCollector(object): + ''' + Disable automatic garbage collection and instead collect manually + on a timer. + + This is done to ensure that garbage collection only happens in the GUI + thread, as otherwise Qt can crash. + + Credit: Erik Janssens + Source: http://pydev.blogspot.com/2014/03/should-python-garbage-collector-be.html + ''' + + def __init__(self, interval=1.0, debug=False): + self.debug = debug + if debug: + gc.set_debug(gc.DEBUG_LEAK) + + self.timer = QtCore.QTimer() + self.timer.timeout.connect(self.check) + + self.threshold = gc.get_threshold() + gc.disable() + self.timer.start(interval * 1000) + + def check(self): + #return self.debug_cycles() # uncomment to just debug cycles + l0, l1, l2 = gc.get_count() + if self.debug: + print('gc_check called:', l0, l1, l2) + if l0 > self.threshold[0]: + num = gc.collect(0) + if self.debug: + print('collecting gen 0, found: %d unreachable' % num) + if l1 > self.threshold[1]: + num = gc.collect(1) + if self.debug: + print('collecting gen 1, found: %d unreachable' % num) + if l2 > self.threshold[2]: + num = gc.collect(2) + if self.debug: + print('collecting gen 2, found: %d unreachable' % num) + + def debug_cycles(self): + gc.collect() + for obj in gc.garbage: + print (obj, repr(obj), type(obj)) diff --git a/widgets/ColorMapWidget.py b/widgets/ColorMapWidget.py index 8cd72e15..f6e28960 100644 --- a/widgets/ColorMapWidget.py +++ b/widgets/ColorMapWidget.py @@ -19,8 +19,8 @@ class ColorMapWidget(ptree.ParameterTree): """ sigColorMapChanged = QtCore.Signal(object) - def __init__(self): - ptree.ParameterTree.__init__(self, showHeader=False) + def __init__(self, parent=None): + ptree.ParameterTree.__init__(self, parent=parent, showHeader=False) self.params = ColorMapParameter() self.setParameters(self.params) @@ -32,6 +32,15 @@ class ColorMapWidget(ptree.ParameterTree): def mapChanged(self): self.sigColorMapChanged.emit(self) + + def widgetGroupInterface(self): + return (self.sigColorMapChanged, self.saveState, self.restoreState) + + def saveState(self): + return self.params.saveState() + + def restoreState(self, state): + self.params.restoreState(state) class ColorMapParameter(ptree.types.GroupParameter): @@ -48,9 +57,11 @@ class ColorMapParameter(ptree.types.GroupParameter): def addNew(self, name): mode = self.fields[name].get('mode', 'range') if mode == 'range': - self.addChild(RangeColorMapItem(name, self.fields[name])) + item = RangeColorMapItem(name, self.fields[name]) elif mode == 'enum': - self.addChild(EnumColorMapItem(name, self.fields[name])) + item = EnumColorMapItem(name, self.fields[name]) + self.addChild(item) + return item def fieldNames(self): return self.fields.keys() @@ -95,6 +106,9 @@ class ColorMapParameter(ptree.types.GroupParameter): returned as 0.0-1.0 float values. ============== ================================================================= """ + if isinstance(data, dict): + data = np.array([tuple(data.values())], dtype=[(k, float) for k in data.keys()]) + colors = np.zeros((len(data),4)) for item in self.children(): if not item['Enabled']: @@ -126,8 +140,26 @@ class ColorMapParameter(ptree.types.GroupParameter): return colors + def saveState(self): + items = OrderedDict() + for item in self: + itemState = item.saveState(filter='user') + itemState['field'] = item.fieldName + items[item.name()] = itemState + state = {'fields': self.fields, 'items': items} + return state + + def restoreState(self, state): + if 'fields' in state: + self.setFields(state['fields']) + for itemState in state['items']: + item = self.addNew(itemState['field']) + item.restoreState(itemState) + class RangeColorMapItem(ptree.types.SimpleParameter): + mapType = 'range' + def __init__(self, name, opts): self.fieldName = name units = opts.get('units', '') @@ -151,8 +183,6 @@ class RangeColorMapItem(ptree.types.SimpleParameter): def map(self, data): data = data[self.fieldName] - - scaled = np.clip((data-self['Min']) / (self['Max']-self['Min']), 0, 1) cmap = self.value() colors = cmap.map(scaled, mode='float') @@ -162,10 +192,11 @@ class RangeColorMapItem(ptree.types.SimpleParameter): nanColor = (nanColor.red()/255., nanColor.green()/255., nanColor.blue()/255., nanColor.alpha()/255.) colors[mask] = nanColor - return colors - + return colors class EnumColorMapItem(ptree.types.GroupParameter): + mapType = 'enum' + def __init__(self, name, opts): self.fieldName = name vals = opts.get('values', []) diff --git a/widgets/ComboBox.py b/widgets/ComboBox.py index f9983c97..5cf6f918 100644 --- a/widgets/ComboBox.py +++ b/widgets/ComboBox.py @@ -1,5 +1,6 @@ from ..Qt import QtGui, QtCore from ..SignalProxy import SignalProxy +import sys from ..pgcollections import OrderedDict from ..python2_3 import asUnicode @@ -20,6 +21,10 @@ class ComboBox(QtGui.QComboBox): self.currentIndexChanged.connect(self.indexChanged) self._ignoreIndexChange = False + #self.value = default + if 'darwin' in sys.platform: ## because MacOSX can show names that are wider than the comboBox + self.setSizeAdjustPolicy(QtGui.QComboBox.AdjustToMinimumContentsLength) + #self.setMinimumContentsLength(10) self._chosenText = None self._items = OrderedDict() diff --git a/widgets/DataTreeWidget.py b/widgets/DataTreeWidget.py index b99121bf..29e60319 100644 --- a/widgets/DataTreeWidget.py +++ b/widgets/DataTreeWidget.py @@ -57,7 +57,7 @@ class DataTreeWidget(QtGui.QTreeWidget): } if isinstance(data, dict): - for k in data: + for k in data.keys(): self.buildTree(data[k], node, str(k)) elif isinstance(data, list) or isinstance(data, tuple): for i in range(len(data)): diff --git a/widgets/SpinBox.py b/widgets/SpinBox.py index 422522de..23516827 100644 --- a/widgets/SpinBox.py +++ b/widgets/SpinBox.py @@ -47,29 +47,29 @@ class SpinBox(QtGui.QAbstractSpinBox): """ ============== ======================================================================== **Arguments:** - parent Sets the parent widget for this SpinBox (optional) - value (float/int) initial value + parent Sets the parent widget for this SpinBox (optional). Default is None. + value (float/int) initial value. Default is 0.0. bounds (min,max) Minimum and maximum values allowed in the SpinBox. - Either may be None to leave the value unbounded. - suffix (str) suffix (units) to display after the numerical value + Either may be None to leave the value unbounded. By default, values are unbounded. + suffix (str) suffix (units) to display after the numerical value. By default, suffix is an empty str. siPrefix (bool) If True, then an SI prefix is automatically prepended to the units and the value is scaled accordingly. For example, if value=0.003 and suffix='V', then the SpinBox will display - "300 mV" (but a call to SpinBox.value will still return 0.003). + "300 mV" (but a call to SpinBox.value will still return 0.003). Default is False. step (float) The size of a single step. This is used when clicking the up/ down arrows, when rolling the mouse wheel, or when pressing keyboard arrows while the widget has keyboard focus. Note that the interpretation of this value is different when specifying - the 'dec' argument. + the 'dec' argument. Default is 0.01. dec (bool) If True, then the step value will be adjusted to match the current size of the variable (for example, a value of 15 might step in increments of 1 whereas a value of 1500 would step in increments of 100). In this case, the 'step' argument is interpreted *relative* to the current value. The most common - 'step' values when dec=True are 0.1, 0.2, 0.5, and 1.0. + 'step' values when dec=True are 0.1, 0.2, 0.5, and 1.0. Default is False. minStep (float) When dec=True, this specifies the minimum allowable step size. - int (bool) if True, the value is forced to integer type - decimals (int) Number of decimal values to display + int (bool) if True, the value is forced to integer type. Default is False + decimals (int) Number of decimal values to display. Default is 2. ============== ======================================================================== """ QtGui.QAbstractSpinBox.__init__(self, parent) @@ -233,6 +233,18 @@ class SpinBox(QtGui.QAbstractSpinBox): def setDecimals(self, decimals): self.setOpts(decimals=decimals) + + def selectNumber(self): + """ + Select the numerical portion of the text to allow quick editing by the user. + """ + le = self.lineEdit() + text = le.text() + try: + index = text.index(' ') + except ValueError: + return + le.setSelection(0, index) def value(self): """ diff --git a/widgets/TableWidget.py b/widgets/TableWidget.py index 14060546..69085a20 100644 --- a/widgets/TableWidget.py +++ b/widgets/TableWidget.py @@ -365,7 +365,7 @@ class TableWidget(QtGui.QTableWidget): ev.ignore() def handleItemChanged(self, item): - item.textChanged() + item.itemChanged() class TableWidgetItem(QtGui.QTableWidgetItem): @@ -425,7 +425,8 @@ class TableWidgetItem(QtGui.QTableWidgetItem): def _updateText(self): self._blockValueChange = True try: - self.setText(self.format()) + self._text = self.format() + self.setText(self._text) finally: self._blockValueChange = False @@ -433,14 +434,22 @@ class TableWidgetItem(QtGui.QTableWidgetItem): self.value = value self._updateText() + def itemChanged(self): + """Called when the data of this item has changed.""" + if self.text() != self._text: + self.textChanged() + def textChanged(self): """Called when this item's text has changed for any reason.""" + self._text = self.text() + if self._blockValueChange: # text change was result of value or format change; do not # propagate. return try: + self.value = type(self.value)(self.text()) except ValueError: self.value = str(self.text())