diff --git a/examples/Flowchart.py b/examples/Flowchart.py index 8de6016a..ade647fc 100644 --- a/examples/Flowchart.py +++ b/examples/Flowchart.py @@ -21,21 +21,24 @@ import pyqtgraph.metaarray as metaarray app = QtGui.QApplication([]) - +## Create main window with grid layout win = QtGui.QMainWindow() cw = QtGui.QWidget() win.setCentralWidget(cw) layout = QtGui.QGridLayout() cw.setLayout(layout) +## Create flowchart, define input/output terminals fc = Flowchart(terminals={ 'dataIn': {'io': 'in'}, 'dataOut': {'io': 'out'} }) w = fc.widget() +## Add flowchart control panel to the main window layout.addWidget(fc.widget(), 0, 0, 2, 1) +## Add two plot widgets pw1 = pg.PlotWidget() pw2 = pg.PlotWidget() layout.addWidget(pw1, 0, 1) @@ -43,14 +46,17 @@ layout.addWidget(pw2, 1, 1) win.show() - +## generate signal data to pass through the flowchart data = np.random.normal(size=1000) data[200:300] += 1 data += np.sin(np.linspace(0, 100, 1000)) data = metaarray.MetaArray(data, info=[{'name': 'Time', 'values': np.linspace(0, 1.0, len(data))}, {}]) +## Feed data into the input terminal of the flowchart fc.setInput(dataIn=data) +## populate the flowchart with a basic set of processing nodes. +## (usually we let the user do this) pw1Node = fc.createNode('PlotWidget', pos=(0, -150)) pw1Node.setPlot(pw1) @@ -59,42 +65,12 @@ pw2Node.setPlot(pw2) fNode = fc.createNode('GaussianFilter', pos=(0, 0)) fNode.ctrls['sigma'].setValue(5) -fc.connectTerminals(fc.dataIn, fNode.In) -fc.connectTerminals(fc.dataIn, pw1Node.In) -fc.connectTerminals(fNode.Out, pw2Node.In) -fc.connectTerminals(fNode.Out, fc.dataOut) +fc.connectTerminals(fc['dataIn'], fNode['In']) +fc.connectTerminals(fc['dataIn'], pw1Node['In']) +fc.connectTerminals(fNode['Out'], pw2Node['In']) +fc.connectTerminals(fNode['Out'], fc['dataOut']) -#n1 = fc.createNode('Add', pos=(0,-80)) -#n2 = fc.createNode('Subtract', pos=(140,-10)) -#n3 = fc.createNode('Abs', pos=(0, 80)) -#n4 = fc.createNode('Add', pos=(140,100)) - -#fc.connectTerminals(fc.dataIn, n1.A) -#fc.connectTerminals(fc.dataIn, n1.B) -#fc.connectTerminals(fc.dataIn, n2.A) -#fc.connectTerminals(n1.Out, n4.A) -#fc.connectTerminals(n1.Out, n2.B) -#fc.connectTerminals(n2.Out, n3.In) -#fc.connectTerminals(n3.Out, n4.B) -#fc.connectTerminals(n4.Out, fc.dataOut) - - -#def process(**kargs): - #return fc.process(**kargs) - - -#print process(dataIn=7) - -#fc.setInput(dataIn=3) - -#s = fc.saveState() -#fc.clear() - -#fc.restoreState(s) - -#fc.setInput(dataIn=3) - ## Start Qt event loop unless running in interactive mode or using pyside. if __name__ == '__main__': diff --git a/examples/FlowchartCustomNode.py b/examples/FlowchartCustomNode.py new file mode 100644 index 00000000..9ed3d6da --- /dev/null +++ b/examples/FlowchartCustomNode.py @@ -0,0 +1,144 @@ +# -*- coding: utf-8 -*- +""" +This example demonstrates writing a custom Node subclass for use with flowcharts. + +We implement a couple of simple image processing nodes. +""" +import initExample ## Add path to library (just for examples; you do not need this) + +from pyqtgraph.flowchart import Flowchart, Node +import pyqtgraph.flowchart.library as fclib +from pyqtgraph.flowchart.library.common import CtrlNode +from pyqtgraph.Qt import QtGui, QtCore +import pyqtgraph as pg +import numpy as np +import scipy.ndimage + +app = QtGui.QApplication([]) + +## Create main window with a grid layout inside +win = QtGui.QMainWindow() +cw = QtGui.QWidget() +win.setCentralWidget(cw) +layout = QtGui.QGridLayout() +cw.setLayout(layout) + +## Create an empty flowchart with a single input and output +fc = Flowchart(terminals={ + 'dataIn': {'io': 'in'}, + 'dataOut': {'io': 'out'} +}) +w = fc.widget() + +layout.addWidget(fc.widget(), 0, 0, 2, 1) + +## Create two ImageView widgets to display the raw and processed data with contrast +## and color control. +v1 = pg.ImageView() +v2 = pg.ImageView() +layout.addWidget(v1, 0, 1) +layout.addWidget(v2, 1, 1) + +win.show() + +## generate random input data +data = np.random.normal(size=(100,100)) +data = 25 * scipy.ndimage.gaussian_filter(data, (5,5)) +data += np.random.normal(size=(100,100)) +data[40:60, 40:60] += 15.0 +data[30:50, 30:50] += 15.0 +#data += np.sin(np.linspace(0, 100, 1000)) +#data = metaarray.MetaArray(data, info=[{'name': 'Time', 'values': np.linspace(0, 1.0, len(data))}, {}]) + +## Set the raw data as the input value to the flowchart +fc.setInput(dataIn=data) + + +## At this point, we need some custom Node classes since those provided in the library +## are not sufficient. Each node will define a set of input/output terminals, a +## processing function, and optionally a control widget (to be displayed in the +## flowchart control panel) + +class ImageViewNode(Node): + """Node that displays image data in an ImageView widget""" + nodeName = 'ImageView' + + def __init__(self, name): + self.view = None + ## Initialize node with only a single input terminal + Node.__init__(self, name, terminals={'data': {'io':'in'}}) + + def setView(self, view): ## setView must be called by the program + self.view = view + + def process(self, data, display=True): + ## if process is called with display=False, then the flowchart is being operated + ## in batch processing mode, so we should skip displaying to improve performance. + + if display and self.view is not None: + ## the 'data' argument is the value given to the 'data' terminal + if data is None: + self.view.setImage(np.zeros((1,1))) # give a blank array to clear the view + else: + self.view.setImage(data) + +## register the class so it will appear in the menu of node types. +## It will appear in the 'display' sub-menu. +fclib.registerNodeType(ImageViewNode, [('Display',)]) + +## We will define an unsharp masking filter node as a subclass of CtrlNode. +## CtrlNode is just a convenience class that automatically creates its +## control widget based on a simple data structure. +class UnsharpMaskNode(CtrlNode): + """Return the input data passed through scipy.ndimage.gaussian_filter.""" + nodeName = "UnsharpMask" + uiTemplate = [ + ('sigma', 'spin', {'value': 1.0, 'step': 1.0, 'range': [0.0, None]}), + ('strength', 'spin', {'value': 1.0, 'dec': True, 'step': 0.5, 'minStep': 0.01, 'range': [0.0, None]}), + ] + def __init__(self, name): + ## Define the input / output terminals available on this node + terminals = { + 'dataIn': dict(io='in'), # each terminal needs at least a name and + 'dataOut': dict(io='out'), # to specify whether it is input or output + } # other more advanced options are available + # as well.. + + CtrlNode.__init__(self, name, terminals=terminals) + + def process(self, dataIn, display=True): + # CtrlNode has created self.ctrls, which is a dict containing {ctrlName: widget} + sigma = self.ctrls['sigma'].value() + strength = self.ctrls['strength'].value() + output = dataIn - (strength * scipy.ndimage.gaussian_filter(dataIn, (sigma,sigma))) + return {'dataOut': output} + +## register the class so it will appear in the menu of node types. +## It will appear in a new 'image' sub-menu. +fclib.registerNodeType(UnsharpMaskNode, [('Image',)]) + + + +## Now we will programmatically add nodes to define the function of the flowchart. +## Normally, the user will do this manually or by loading a pre-generated +## flowchart file. + +v1Node = fc.createNode('ImageView', pos=(0, -150)) +v1Node.setView(v1) + +v2Node = fc.createNode('ImageView', pos=(150, -150)) +v2Node.setView(v2) + +fNode = fc.createNode('UnsharpMask', pos=(0, 0)) +fc.connectTerminals(fc['dataIn'], fNode['dataIn']) +fc.connectTerminals(fc['dataIn'], v1Node['data']) +fc.connectTerminals(fNode['dataOut'], v2Node['data']) +fc.connectTerminals(fNode['dataOut'], fc['dataOut']) + + + +## Start Qt event loop unless running in interactive mode or using pyside. +if __name__ == '__main__': + import sys + if (sys.flags.interactive != 1) or not hasattr(QtCore, 'PYQT_VERSION'): + QtGui.QApplication.instance().exec_() diff --git a/pyqtgraph/flowchart/Node.py b/pyqtgraph/flowchart/Node.py index ed5c9714..e6e98d1a 100644 --- a/pyqtgraph/flowchart/Node.py +++ b/pyqtgraph/flowchart/Node.py @@ -13,6 +13,18 @@ def strDict(d): return dict([(str(k), v) for k, v in d.items()]) class Node(QtCore.QObject): + """ + Node represents the basic processing unit of a flowchart. + A Node subclass implements at least: + + 1) A list of input / ouptut terminals and their properties + 2) a process() function which takes the names of input terminals as keyword arguments and returns a dict with the names of output terminals as keys. + + A flowchart thus consists of multiple instances of Node subclasses, each of which is connected + to other by wires between their terminals. A flowchart is, itself, also a special subclass of Node. + This allows Nodes within the flowchart to connect to the input/output nodes of the flowchart itself. + + Optionally, a node class can implement the ctrlWidget() method, which must return a QWidget (usually containing other widgets) that will be displayed in the flowchart control panel. Some nodes implement fairly complex control widgets, but most nodes follow a simple form-like pattern: a list of parameter names and a single value (represented as spin box, check box, etc..) for each parameter. To make this easier, the CtrlNode subclass allows you to instead define a simple data structure that CtrlNode will use to automatically generate the control widget. """ sigOutputChanged = QtCore.Signal(object) # self sigClosed = QtCore.Signal(object) @@ -23,6 +35,31 @@ class Node(QtCore.QObject): def __init__(self, name, terminals=None, allowAddInput=False, allowAddOutput=False, allowRemove=True): + """ + ============== ============================================================ + Arguments + name The name of this specific node instance. It can be any + string, but must be unique within a flowchart. Usually, + we simply let the flowchart decide on a name when calling + Flowchart.addNode(...) + terminals Dict-of-dicts specifying the terminals present on this Node. + Terminal specifications look like:: + + 'inputTerminalName': {'io': 'in'} + 'outputTerminalName': {'io': 'out'} + + There are a number of optional parameters for terminals: + multi, pos, renamable, removable, multiable, bypass. See + the Terminal class for more information. + allowAddInput bool; whether the user is allowed to add inputs by the + context menu. + allowAddOutput bool; whether the user is allowed to add outputs by the + context menu. + allowRemove bool; whether the user is allowed to remove this node by the + context menu. + ============== ============================================================ + + """ QtCore.QObject.__init__(self) self._name = name self._bypass = False @@ -52,15 +89,25 @@ class Node(QtCore.QObject): return name2 def addInput(self, name="Input", **args): + """Add a new input terminal to this Node with the given name. Extra + keyword arguments are passed to Terminal.__init__. + + This is a convenience function that just calls addTerminal(io='in', ...)""" #print "Node.addInput called." return self.addTerminal(name, io='in', **args) def addOutput(self, name="Output", **args): + """Add a new output terminal to this Node with the given name. Extra + keyword arguments are passed to Terminal.__init__. + + This is a convenience function that just calls addTerminal(io='out', ...)""" return self.addTerminal(name, io='out', **args) def removeTerminal(self, term): - ## term may be a terminal or its name + """Remove the specified terminal from this Node. May specify either the + terminal's name or the terminal itself. + Causes sigTerminalRemoved to be emitted.""" if isinstance(term, Terminal): name = term.name() else: @@ -80,7 +127,9 @@ class Node(QtCore.QObject): def terminalRenamed(self, term, oldName): - """Called after a terminal has been renamed""" + """Called after a terminal has been renamed + + Causes sigTerminalRenamed to be emitted.""" newName = term.name() for d in [self.terminals, self._inputs, self._outputs]: if oldName not in d: @@ -92,6 +141,10 @@ class Node(QtCore.QObject): self.sigTerminalRenamed.emit(term, oldName) def addTerminal(self, name, **opts): + """Add a new terminal to this Node with the given name. Extra + keyword arguments are passed to Terminal.__init__. + + Causes sigTerminalAdded to be emitted.""" name = self.nextTerminalName(name) term = Terminal(self, name, **opts) self.terminals[name] = term @@ -105,38 +158,60 @@ class Node(QtCore.QObject): def inputs(self): + """Return dict of all input terminals. + Warning: do not modify.""" return self._inputs def outputs(self): + """Return dict of all output terminals. + Warning: do not modify.""" return self._outputs def process(self, **kargs): - """Process data through this node. Each named argument supplies data to the corresponding terminal.""" + """Process data through this node. This method is called any time the flowchart + wants the node to process data. It will be called with one keyword argument + corresponding to each input terminal, and must return a dict mapping the name + of each output terminal to its new value. + + This method is also called with a 'display' keyword argument, which indicates + whether the node should update its display (if it implements any) while processing + this data. This is primarily used to disable expensive display operations + during batch processing. + """ return {} def graphicsItem(self): - """Return a (the?) graphicsitem for this node""" - #print "Node.graphicsItem called." + """Return the GraphicsItem for this node. Subclasses may re-implement + this method to customize their appearance in the flowchart.""" if self._graphicsItem is None: - #print "Creating NodeGraphicsItem..." self._graphicsItem = NodeGraphicsItem(self) - #print "Node.graphicsItem is returning ", self._graphicsItem return self._graphicsItem + ## this is just bad planning. Causes too many bugs. def __getattr__(self, attr): """Return the terminal with the given name""" if attr not in self.terminals: raise AttributeError(attr) else: + import traceback + traceback.print_stack() + print("Warning: use of node.terminalName is deprecated; use node['terminalName'] instead.") return self.terminals[attr] def __getitem__(self, item): - return getattr(self, item) + #return getattr(self, item) + """Return the terminal with the given name""" + if item not in self.terminals: + raise KeyError(item) + else: + return self.terminals[item] def name(self): + """Return the name of this node.""" return self._name def rename(self, name): + """Rename this node. This will cause sigRenamed to be emitted.""" oldName = self._name self._name = name #self.emit(QtCore.SIGNAL('renamed'), self, oldName) @@ -154,15 +229,25 @@ class Node(QtCore.QObject): return "" % (self.name(), id(self)) def ctrlWidget(self): + """Return this Node's control widget.""" return None def bypass(self, byp): + """Set whether this node should be bypassed. + + When bypassed, a Node's process() method is never called. In some cases, + data is automatically copied directly from specific input nodes to + output nodes instead (see the bypass argument to Terminal.__init__). + This is usually called when the user disables a node from the flowchart + control panel. + """ self._bypass = byp if self.bypassButton is not None: self.bypassButton.setChecked(byp) self.update() def isBypassed(self): + """Return True if this Node is currently bypassed.""" return self._bypass def setInput(self, **args): @@ -179,12 +264,14 @@ class Node(QtCore.QObject): self.update() def inputValues(self): + """Return a dict of all input values currently assigned to this node.""" vals = {} for n, t in self.inputs().items(): vals[n] = t.value() return vals def outputValues(self): + """Return a dict of all output values currently generated by this node.""" vals = {} for n, t in self.outputs().items(): vals[n] = t.value() @@ -195,11 +282,15 @@ class Node(QtCore.QObject): pass def disconnected(self, localTerm, remoteTerm): - """Called whenever one of this node's terminals is connected elsewhere.""" + """Called whenever one of this node's terminals is disconnected from another.""" pass def update(self, signal=True): - """Collect all input values, attempt to process new output values, and propagate downstream.""" + """Collect all input values, attempt to process new output values, and propagate downstream. + Subclasses should call update() whenever thir internal state has changed + (such as when the user interacts with the Node's control widget). Update + is automatically called when the inputs to the node are changed. + """ vals = self.inputValues() #print " inputs:", vals try: @@ -227,6 +318,9 @@ class Node(QtCore.QObject): self.sigOutputChanged.emit(self) ## triggers flowchart to propagate new data def processBypassed(self, args): + """Called when the flowchart would normally call Node.process, but this node is currently bypassed. + The default implementation looks for output terminals with a bypass connection and returns the + corresponding values. Most Node subclasses will _not_ need to reimplement this method.""" result = {} for term in list(self.outputs().values()): byp = term.bypassValue() @@ -266,6 +360,13 @@ class Node(QtCore.QObject): self.graphicsItem().setPen(QtGui.QPen(QtGui.QColor(150, 0, 0), 3)) def saveState(self): + """Return a dictionary representing the current state of this node + (excluding input / output values). This is used for saving/reloading + flowcharts. The default implementation returns this Node's position, + bypass state, and information about each of its terminals. + + Subclasses may want to extend this method, adding extra keys to the returned + dict.""" pos = self.graphicsItem().pos() state = {'pos': (pos.x(), pos.y()), 'bypass': self.isBypassed()} termsEditable = self._allowAddInput | self._allowAddOutput @@ -276,6 +377,8 @@ class Node(QtCore.QObject): return state def restoreState(self, state): + """Restore the state of this node from a structure previously generated + by saveState(). """ pos = state.get('pos', (0,0)) self.graphicsItem().setPos(*pos) self.bypass(state.get('bypass', False)) diff --git a/pyqtgraph/flowchart/Terminal.py b/pyqtgraph/flowchart/Terminal.py index 18ff12c1..623d1a28 100644 --- a/pyqtgraph/flowchart/Terminal.py +++ b/pyqtgraph/flowchart/Terminal.py @@ -24,6 +24,8 @@ class Terminal(object): renamable (bool) Whether the terminal can be renamed by the user removable (bool) Whether the terminal can be removed by the user multiable (bool) Whether the user may toggle the *multi* option for this terminal + bypass (str) Name of the terminal from which this terminal's value is derived + when the Node is in bypass mode. ============== ================================================================================= """ self._io = io