-
-
Notifications
You must be signed in to change notification settings - Fork 1.2k
Expand file tree
/
Copy pathgraph.py
More file actions
1857 lines (1555 loc) · 70.7 KB
/
graph.py
File metadata and controls
1857 lines (1555 loc) · 70.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import json
import logging
import os
import re
from typing import Any, Optional
from collections.abc import Iterable
from collections import defaultdict, OrderedDict
import weakref
from contextlib import contextmanager
from pathlib import Path
from enum import Enum
import meshroom
import meshroom.core
from meshroom.common import BaseObject, DictModel, Slot, Signal, Property
from meshroom.core import Version
from meshroom.core import submitters
from meshroom.core.attribute import Attribute, ListAttribute, GroupAttribute
from meshroom.core.exception import GraphCompatibilityError, InvalidEdgeError, StopGraphVisit, StopBranchVisit, CyclicDependencyError
from meshroom.core.graphIO import GraphIO, GraphSerializer, TemplateGraphSerializer, PartialGraphSerializer
from meshroom.core.node import BaseNode, Status, Node, CompatibilityNode
from meshroom.core.nodeFactory import nodeFactory, getNodeConstructor
from meshroom.core.mtyping import PathLike
from meshroom.core.submitter import BaseSubmittedJob, jobManager
# Replace default encoder to support Enums
DefaultJSONEncoder = json.JSONEncoder # store the original one
class MyJSONEncoder(DefaultJSONEncoder): # declare a new one with Enum support
def default(self, obj):
if isinstance(obj, Enum):
return obj.name
return DefaultJSONEncoder.default(self, obj) # use the default one for all other types
json.JSONEncoder = MyJSONEncoder # replace the default implementation with our new one
@contextmanager
def GraphModification(graph):
"""
A Context Manager that can be used to trigger only one Graph update
for a group of several modifications.
GraphModifications can be nested.
"""
if not isinstance(graph, Graph):
raise ValueError("GraphModification expects a Graph instance")
# Store update policy for nested usage
enabled = graph.updateEnabled
# Disable graph update for nested block
# (does nothing if already disabled)
graph.updateEnabled = False
try:
yield # Execute nested block
except Exception:
raise
finally:
# Restore update policy
graph.updateEnabled = enabled
class Edge(BaseObject):
def __init__(self, src, dst, parent=None):
super().__init__(parent)
self._src = weakref.ref(src)
self._dst = weakref.ref(dst)
self._repr = f"<Edge> {self._src()} -> {self._dst()}"
@property
def src(self):
return self._src()
@property
def dst(self):
return self._dst()
src = Property(Attribute, src.fget, constant=True)
dst = Property(Attribute, dst.fget, constant=True)
WHITE = 0
GRAY = 1
BLACK = 2
class Visitor:
"""
Base class for Graph Visitors that does nothing.
Sub-classes can override any method to implement specific algorithms.
"""
def __init__(self, reverse, dependenciesOnly):
super().__init__()
self.reverse = reverse
self.dependenciesOnly = dependenciesOnly
# def initializeVertex(self, s, g):
# '''is invoked on every vertex of the graph before the start of the graph search.'''
# pass
# def startVertex(self, s, g):
# '''is invoked on the source vertex once before the start of the search.'''
# pass
def discoverVertex(self, u, g):
""" Is invoked when a vertex is encountered for the first time. """
pass
def examineEdge(self, e, g):
""" Is invoked on every out-edge of each vertex after it is discovered."""
pass
def treeEdge(self, e, g):
"""
Is invoked on each edge as it becomes a member of the edges that form the search tree.
If you wish to record predecessors, do so at this event point.
"""
pass
def backEdge(self, e, g):
""" Is invoked on the back edges in the graph. """
pass
def forwardOrCrossEdge(self, e, g):
"""
Is invoked on forward or cross edges in the graph.
In an undirected graph this method is never called.
"""
pass
def finishEdge(self, e, g):
"""
Is invoked on the non-tree edges in the graph
as well as on each tree edge after its target vertex is finished.
"""
pass
def finishVertex(self, u, g):
"""
Is invoked on a vertex after all of its out edges have been added to the search tree and
all of the adjacent vertices have been discovered (but before their out-edges have been
examined).
"""
pass
def changeTopology(func):
"""
Graph methods modifying the graph topology (add/remove edges or nodes)
must be decorated with 'changeTopology' for update mechanism to work as intended.
"""
def decorator(self, *args, **kwargs):
assert isinstance(self, Graph)
# call method
result = func(self, *args, **kwargs)
# mark graph dirty
self.dirtyTopology = True
# request graph update
self.update()
return result
return decorator
def blockNodeCallbacks(func):
"""
Graph methods loading serialized graph content must be decorated with 'blockNodeCallbacks',
to avoid attribute changed callbacks defined on node descriptions to be triggered during
this process.
"""
def inner(self, *args, **kwargs):
self._loading = True
try:
return func(self, *args, **kwargs)
finally:
self._loading = False
return inner
def generateTempProjectFilepath(tmpFolder=None):
"""
Generate a temporary project filepath.
This method is used to generate a temporary project file for the current graph.
"""
from datetime import datetime
if tmpFolder is None:
from meshroom.env import EnvVar
tmpFolder = EnvVar.get(EnvVar.MESHROOM_TEMP_PATH)
timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M")
return os.path.join(tmpFolder, f"meshroom_{timestamp}.mg")
class Graph(BaseObject):
"""
_________________ _________________ _________________
| | | | | |
| Node A | | Node B | | Node C |
| | edge | | edge | |
|input output|>---->|input output|>---->|input output|
|_______________| |_______________| |_______________|
Data structures:
nodes = {'A': <nodeA>, 'B': <nodeB>, 'C': <nodeC>}
edges = {B.input: A.output, C.input: B.output,}
"""
def __init__(self, name: str = "", parent: BaseObject = None):
super().__init__(parent)
self.name: str = name
self._loading: bool = False
self._saving: bool = False
self._updateEnabled: bool = True
self._updateRequested: bool = False
self.dirtyTopology: bool = False
self._nodesMinMaxDepths = {}
self._computationBlocked = {}
self._canComputeLeaves: bool = True
self._nodes = DictModel(keyAttrName='name', parent=self)
# Edges: use dst attribute as unique key since it can only have one input connection
self._edges = DictModel(keyAttrName='dst', parent=self)
self._compatibilityNodes = DictModel(keyAttrName='name', parent=self)
self._cacheDir: str = ''
self._filepath: str = ''
self._fileDateVersion = 0
self.header = {}
def clear(self):
self._clearGraphContent()
self.header.clear()
self._unsetFilepath()
def _clearGraphContent(self):
self._edges.clear()
# Tell QML nodes are going to be deleted
for node in self._nodes:
node.alive = False
self._nodes.clear()
self._compatibilityNodes.clear()
@property
def fileFeatures(self):
""" Get loaded file supported features based on its version. """
return GraphIO.getFeaturesForVersion(self.header.get(GraphIO.Keys.FileVersion, "0.0"))
@property
def isLoading(self):
""" Return True if the graph is currently being loaded. """
return self._loading
@property
def isSaving(self):
""" Return True if the graph is currently being saved. """
return self._saving
@Slot(str)
def load(self, filepath: PathLike):
"""
Load a Meshroom Graph ".mg" file in place.
Args:
filepath: The path to the Meshroom Graph file to load.
"""
self._setFilepath(filepath)
self._deserialize(Graph._loadGraphData(filepath))
self._fileDateVersion = os.path.getmtime(filepath)
def initFromTemplate(self, filepath: PathLike, copyOutputs: bool = False):
"""
Deserialize a template Meshroom Graph ".mg" file in place.
When initializing from a template, the internal filepath of the graph instance is not set.
Saving the file on disk will require to specify a filepath.
Args:
filepath: The path to the Meshroom Graph file to load.
copyOutputs: (optional) Whether to keep 'CopyFiles' nodes.
"""
self._deserialize(Graph._loadGraphData(filepath))
# Creating nodes from a template is conceptually similar to explicit node creation,
# therefore the nodes descriptors' "onNodeCreated" callback is triggered for each
# node instance created by this process.
self._triggerNodeCreatedCallback(self.nodes)
if not copyOutputs:
with GraphModification(self):
for node in [node for node in self.nodes if node.nodeType == "CopyFiles"]:
self.removeNode(node.name)
@staticmethod
def _loadGraphData(filepath: PathLike) -> dict:
"""Deserialize the content of the Meshroom Graph file at `filepath` to a dictionnary."""
with open(filepath) as file:
graphData = json.load(file)
return graphData
@blockNodeCallbacks
def _deserialize(self, graphData: dict):
"""Deserialize `graphData` in the current Graph instance.
Args:
graphData: The serialized Graph.
"""
self._clearGraphContent()
self.header.clear()
self.header = graphData.get(GraphIO.Keys.Header, {})
fileVersion = Version(self.header.get(GraphIO.Keys.FileVersion, "0.0"))
graphContent = self._normalizeGraphContent(graphData, fileVersion)
isTemplate = self.header.get(GraphIO.Keys.Template, False)
with GraphModification(self):
# iterate over nodes sorted by suffix index in their names
for nodeName, nodeData in sorted(
graphContent.items(), key=lambda x: self.getNodeIndexFromName(x[0])
):
self._deserializeNode(nodeData, nodeName, self)
# Create graph edges by resolving attributes expressions
self._applyExpr()
# Templates are specific: they contain only the minimal amount of
# serialized data to describe the graph structure.
# They are not meant to be computed: therefore, we can early return here,
# as uid conflict evaluation is only meaningful for nodes with computed data.
if isTemplate:
return
# By this point, the graph has been fully loaded and an updateInternals has been triggered, so all the
# nodes' links have been resolved and their UID computations are all complete.
# It is now possible to check whether the UIDs stored in the graph file for each node correspond to the ones
# that were computed.
self._evaluateUidConflicts(graphContent)
def _normalizeGraphContent(self, graphData: dict, fileVersion: Version) -> dict:
graphContent = graphData.get(GraphIO.Keys.Graph, graphData)
if fileVersion < Version("2.0"):
# For internal folders, all "{uid0}" keys should be replaced with "{uid}"
updatedFileData = json.dumps(graphContent).replace("{uid0}", "{uid}")
# For fileVersion < 2.0, the nodes' UID is stored as:
# "uids": {"0": "hashvalue"}
# These should be identified and replaced with:
# "uid": "hashvalue"
uidPattern = re.compile(r'"uids": \{"0":.*?\}')
uidOccurrences = uidPattern.findall(updatedFileData)
for occ in uidOccurrences:
uid = occ.split("\"")[-2] # UID is second to last element
newUidStr = fr'"uid": "{uid}"'
updatedFileData = updatedFileData.replace(occ, newUidStr)
graphContent = json.loads(updatedFileData)
return graphContent
def _deserializeNode(self, nodeData: dict, nodeName: str, fromGraph: "Graph"):
# Retrieve version info from:
# 1. nodeData: node saved from a CompatibilityNode
# 2. nodesVersion in file header: node saved from a Node
# If unvailable, the "version" field will not be set in `nodeData`.
if "version" not in nodeData:
if version := fromGraph._getNodeTypeVersionFromHeader(nodeData["nodeType"]):
nodeData["version"] = version
inTemplate = fromGraph.header.get(GraphIO.Keys.Template, False)
node = nodeFactory(nodeData, nodeName, inTemplate=inTemplate)
self._addNode(node, nodeName)
return node
def _getNodeTypeVersionFromHeader(self, nodeType: str, default: Optional[str] = None) -> Optional[str]:
nodeVersions = self.header.get(GraphIO.Keys.NodesVersions, {})
return nodeVersions.get(nodeType, default)
def _evaluateUidConflicts(self, graphContent: dict):
"""
Compare the computed UIDs of all the nodes in the graph with the UIDs serialized in `graphContent`. If there
are mismatches, the nodes with the unexpected UID are replaced with "UidConflict" compatibility nodes.
Args:
graphContent: The serialized Graph content.
"""
def _serializedNodeUidMatchesComputedUid(nodeData: dict, node: BaseNode) -> bool:
"""
Returns whether the serialized UID matches the one computed in the `node` instance.
"""
if isinstance(node, CompatibilityNode):
return True
serializedUid = nodeData.get("uid", None)
computedUid = node._uid
return serializedUid is None or computedUid is None or serializedUid == computedUid
uidConflictingNodes = [
node
for node in self.nodes
if not _serializedNodeUidMatchesComputedUid(graphContent[node.name], node)
]
if not uidConflictingNodes:
return
logging.warning("UID Compatibility issues found: recreating conflicting nodes as CompatibilityNodes.")
# A uid conflict is contagious: if a node has a uid conflict, all of its downstream nodes may be
# impacted as well, as the uid flows through connections.
# Therefore, we deal with conflicting uid nodes by depth: replacing a node with a CompatibilityNode restores
# the serialized uid, which might solve "false-positives" downstream conflicts as well.
nodesSortedByDepth = sorted(uidConflictingNodes, key=lambda node: node.minDepth)
for node in nodesSortedByDepth:
nodeData = graphContent[node.name]
# Evaluate if the node uid is still conflicting at this point, or if it has been resolved by an
# upstream node replacement.
if _serializedNodeUidMatchesComputedUid(nodeData, node):
continue
expectedUid = node._uid
compatibilityNode = nodeFactory(graphContent[node.name], node.name, expectedUid=expectedUid)
# This operation will trigger a graph update that will recompute the uids of all nodes,
# allowing the iterative resolution of uid conflicts.
self.replaceNode(node.name, compatibilityNode)
def importGraphContentFromFile(self, filepath: PathLike) -> list[Node]:
"""Import the content (nodes and edges) of another Graph file into this Graph instance.
Args:
filepath: The path to the Graph file to import.
Returns:
The list of newly created Nodes.
"""
graph = loadGraph(filepath)
return self.importGraphContent(graph)
@blockNodeCallbacks
def importGraphContent(self, graph: "Graph") -> list[Node]:
"""
Import the content (node and edges) of another `graph` into this Graph instance.
Nodes are imported with their original names if possible, otherwise a new unique name is generated
from their node type.
Args:
graph: The graph to import.
Returns:
The list of newly created Nodes.
"""
def _renameClashingNodes():
if not self.nodes:
return
unavailableNames = set(self.nodes.keys())
for node in graph.nodes:
if node._name in unavailableNames:
node._name = self._createUniqueNodeName(node.nodeType, unavailableNames)
unavailableNames.add(node._name)
def _importNodesAndEdges() -> list[Node]:
importedNodes = []
# If we import the content of the graph within itself,
# iterate over a copy of the nodes as the graph is modified during the iteration.
nodes = graph.nodes if graph is not self else list(graph.nodes)
with GraphModification(self):
for srcNode in nodes:
node = self._deserializeNode(srcNode.toDict(), srcNode.name, graph)
importedNodes.append(node)
self._applyExpr()
return importedNodes
_renameClashingNodes()
importedNodes = _importNodesAndEdges()
return importedNodes
@property
def updateEnabled(self):
return self._updateEnabled
@updateEnabled.setter
def updateEnabled(self, enabled):
self._updateEnabled = enabled
if enabled and self._updateRequested:
# Trigger an update if requested while disabled
self.update()
self._updateRequested = False
@changeTopology
def _addNode(self, node, uniqueName):
"""
Internal method to add the given node to this Graph, with the given name (must be unique).
Attribute expressions are not resolved.
"""
if node.graph is not None and node.graph != self:
raise RuntimeError(
'Node "{}" cannot be part of the Graph "{}", as it is already part of the other graph "{}".'.format(
node.nodeType, self.name, node.graph.name))
assert uniqueName not in self._nodes.keys()
node._name = uniqueName
node.graph = self
self._nodes.add(node)
node.chunksChanged.connect(self.updated)
def addNode(self, node, uniqueName=None):
"""
Add the given node to this Graph with an optional unique name,
and resolve attributes expressions.
"""
self._addNode(node, uniqueName if uniqueName else self._createUniqueNodeName(node.nodeType))
# Resolve attribute expressions
with GraphModification(self):
node._applyExpr()
return node
def renameNode(self, node: Node, newName: str):
""" Rename a node in the Node Graph.
If the proposed name is already assigned to a node then it will create a unique name
Args:
node (Node): Node to rename.
newName (str): New name of the node.
"""
# Handle empty string
if not newName:
return
if node.getLocked():
logging.warning(f"Cannot rename node {node} because of the locked status")
return
usedNames = {n._name for n in self._nodes if n != node}
# Make sure we rename to an available name
if newName in usedNames:
newName = self._createUniqueNodeName(newName, usedNames)
# Rename in the dict model
self._nodes.rename(node._name, newName)
# Finally rename the node name property and notify Qt
node._name = newName
node.nodeNameChanged.emit()
def copyNode(self, srcNode: Node, withEdges: bool=False):
"""
Get a copy instance of a node outside the graph.
Args:
srcNode: the node to copy
withEdges: whether to copy edges
Returns:
The created node instance and the mapping of skipped edges per attribute
(always empty if `withEdges` is True)
"""
def _removeLinkExpressions(attribute: Attribute, removed: dict[Attribute, str]):
""" Recursively remove link expressions from the given root `attribute`. """
# Link expressions are only stored on input attributes
if attribute.isOutput:
return
if attribute._linkExpression:
removed[attribute] = attribute._linkExpression
attribute._linkExpression = None
elif isinstance(attribute, (ListAttribute, GroupAttribute)):
for child in attribute.value:
_removeLinkExpressions(child, removed)
with GraphModification(self):
node = nodeFactory(srcNode.toDict(), name=srcNode.nodeType)
skippedEdges = {}
if not withEdges:
for _, attr in node.attributes.items():
_removeLinkExpressions(attr, skippedEdges)
return node, skippedEdges
def duplicateNodes(self, srcNodes):
""" Duplicate nodes in the graph with their connections.
Args:
srcNodes: the nodes to duplicate
Returns:
OrderedDict[Node, Node]: the source->duplicate map
"""
# use OrderedDict to keep duplicated nodes creation order
duplicates = OrderedDict()
with GraphModification(self):
duplicateEdges = {}
# first, duplicate all nodes without edges and keep a 'source=>duplicate' map
# keeps tracks of non-created edges for later remap
for srcNode in srcNodes:
node, edges = self.copyNode(srcNode, withEdges=False)
duplicate = self.addNode(node)
duplicateEdges.update(edges)
duplicates.setdefault(srcNode, []).append(duplicate)
# re-create edges taking into account what has been duplicated
for attr, linkExpression in duplicateEdges.items():
# logging.warning("attr={} linkExpression={}".format(attr.rootName, linkExpression))
link = linkExpression[1:-1] # remove starting '{' and trailing '}'
# get source node and attribute name
edgeSrcNodeName, edgeSrcAttrName = link.split(".", 1)
edgeSrcNode = self.node(edgeSrcNodeName)
# if the edge's source node has been duplicated (the key exists in the dictionary),
# use the duplicate; otherwise use the original node
if edgeSrcNode in duplicates:
edgeSrcNode = duplicates.get(edgeSrcNode)[0]
self.addEdge(edgeSrcNode.attribute(edgeSrcAttrName), attr)
return duplicates
def outEdges(self, attribute):
""" Return the list of edges starting from the given attribute """
# type: (Attribute,) -> [Edge]
return [edge for edge in self.edges if edge.src == attribute]
def nodeInEdges(self, node):
# type: (Node) -> [Edge]
""" Return the list of edges arriving to this node """
return [edge for edge in self.edges if edge.dst.node == node]
def nodeOutEdges(self, node):
# type: (Node) -> [Edge]
""" Return the list of edges starting from this node """
return [edge for edge in self.edges if edge.src.node == node]
@changeTopology
def removeNode(self, nodeName):
"""
Remove the node identified by 'nodeName' from the graph.
Returns:
- a dictionary containing the incoming edges removed by this operation:
{dstAttr.fullName, srcAttr.fullName}
- a dictionary containing the outgoing edges removed by this operation:
{dstAttr.fullName, srcAttr.fullName}
- a dictionary containing the values, indices and keys of attributes that were connected to a ListAttribute
prior to the removal of all edges:
{dstAttr.fullName, (dstAttr.root.fullName, dstAttr.index, dstAttr.value)}
"""
node = self.node(nodeName)
inEdges = {}
outEdges = {}
outListAttributes = {}
# Remove all edges arriving to and starting from this node
with GraphModification(self):
# Two iterations over the outgoing edges are necessary:
# - the first one is used to collect all the information about the edges while they are all there
# (overall context)
# - once we have collected all the information, the edges (and perhaps the entries in ListAttributes) can
# actually be removed
for edge in self.nodeOutEdges(node):
outEdges[edge.dst.fullName] = edge.src.fullName
if isinstance(edge.dst.root, ListAttribute):
index = edge.dst.root.index(edge.dst)
outListAttributes[edge.dst.fullName] = (edge.dst.root.fullName,
index, edge.dst.value
if edge.dst.value else None)
for edge in self.nodeOutEdges(node):
self.removeEdge(edge.dst)
# Remove the corresponding attributes from the ListAttributes instead of just emptying their values
if isinstance(edge.dst.root, ListAttribute):
index = edge.dst.root.index(edge.dst)
edge.dst.root.remove(index)
for edge in self.nodeInEdges(node):
self.removeEdge(edge.dst)
inEdges[edge.dst.fullName] = edge.src.fullName
node.alive = False
self._nodes.remove(node)
self.update()
return inEdges, outEdges, outListAttributes
def addNewNode(
self, nodeType: str, name: Optional[str] = None, position: Optional[str] = None, **kwargs
) -> Node:
"""
Create and add a new node to the graph.
Args:
nodeType: the node type name.
name: if specified, the desired name for this node. If not unique, will be prefixed (_N).
position: the position of the node.
**kwargs: keyword arguments to initialize the created node's attributes.
Returns:
The newly created node.
"""
if name and name in self._nodes.keys():
name = self._createUniqueNodeName(name)
node = self.addNode(getNodeConstructor(nodeType, position=position, **kwargs), uniqueName=name)
node.updateInternals()
self._triggerNodeCreatedCallback([node])
return node
def _triggerNodeCreatedCallback(self, nodes: Iterable[Node]):
"""
Trigger the `onNodeCreated` node descriptor callback for each node instance in `nodes`.
"""
with GraphModification(self):
for node in nodes:
if node.nodeDesc:
node.nodeDesc.onNodeCreated(node)
def _createUniqueNodeName(self, inputName: str, existingNames: Optional[set[str]] = None):
"""
Create a unique node name based on the input name.
Args:
inputName: The desired node name.
existingNames: (optional) If specified, consider this set for uniqueness check, instead of the list of nodes.
"""
existingNodeNames = existingNames or set(self._nodes.objects.keys())
idx = 1
while idx:
newName = f"{inputName}_{idx}"
if newName not in existingNodeNames:
return newName
idx += 1
def node(self, nodeName) -> Optional[Node]:
return self._nodes.get(nodeName)
def upgradeNode(self, nodeName) -> Node:
"""
Upgrade the CompatibilityNode identified as 'nodeName'
Args:
nodeName (str): the name of the CompatibilityNode to upgrade
Returns:
- the upgraded (newly created) node
- a dictionary containing the incoming edges removed by this operation:
{dstAttr.fullName, srcAttr.fullName}
- a dictionary containing the outgoing edges removed by this operation:
{dstAttr.fullName, srcAttr.fullName}
- a dictionary containing the values, indices and keys of attributes that were connected to a ListAttribute
prior to the removal of all edges:
{dstAttr.fullName, (dstAttr.root.fullName, dstAttr.index, dstAttr.value)}
"""
node = self.node(nodeName)
if not isinstance(node, CompatibilityNode):
raise ValueError("Upgrade is only available on CompatibilityNode instances.")
upgradedNode = node.upgrade()
self.replaceNode(nodeName, upgradedNode)
return upgradedNode
@changeTopology
def replaceNode(self, nodeName: str, newNode: BaseNode):
"""
Replace the node idenfitied by `nodeName` with `newNode`, while restoring compatible edges.
Args:
nodeName: The name of the Node to replace.
newNode: The Node instance to replace it with.
"""
with GraphModification(self):
_, outEdges, outListAttributes = self.removeNode(nodeName)
self.addNode(newNode, nodeName)
self._restoreOutEdges(outEdges, outListAttributes)
def _restoreOutEdges(self, outEdges: dict[str, str], outListAttributes):
"""
Restore output edges that were removed during a call to "removeNode".
Args:
outEdges: a dictionary containing the outgoing edges removed by a call to "removeNode".
{dstAttr.fullName, srcAttr.fullName}
outListAttributes: a dictionary containing the values, indices and keys of attributes that were connected
to a ListAttribute prior to the removal of all edges.
{dstAttr.fullName, (dstAttr.root.fullName, dstAttr.index, dstAttr.value)}
"""
def _recreateTargetListAttributeChildren(listAttrName: str, index: int, value: Any):
listAttr = self.attribute(listAttrName)
if not isinstance(listAttr, ListAttribute):
return
if isinstance(value, list):
listAttr[index:index] = value
else:
listAttr.insert(index, value)
for dstName, srcName in outEdges.items():
# Re-create the entries in ListAttributes that were completely removed during the call to "removeNode"
if dstName in outListAttributes:
_recreateTargetListAttributeChildren(*outListAttributes[dstName])
try:
srcAttr = self.attribute(srcName)
dstAttr = self.attribute(dstName)
if srcAttr is None or dstAttr is None:
logging.warning(f"Failed to restore edge {srcName}{' (missing)' if srcAttr is None else ''} -> {dstName}{' (missing)' if dstAttr is None else ''}")
continue
self.addEdge(srcAttr, dstAttr)
except (KeyError, ValueError) as err:
logging.warning(f"Failed to restore edge {srcName} -> {dstName}: {err}")
def upgradeAllNodes(self):
""" Upgrade all upgradable CompatibilityNode instances in the graph. """
nodeNames = [name for name, n in self._compatibilityNodes.items() if n.canUpgrade]
with GraphModification(self):
for nodeName in nodeNames:
self.upgradeNode(nodeName)
def reloadNodePlugins(self, nodeTypes: list[str]):
"""
Replace all the node instances of "nodeTypes" in the current graph with new node instances of the
same type. If the description of the nodes has changed, the reloaded nodes will reflect theses
changes. If "nodeTypes" is empty, then the function returns immediately.
Args:
nodeTypes: the list of node types that will be reloaded.
"""
if not nodeTypes:
# No updated node to replace in the graph, nothing to do
return
newNodes: dict[str, BaseNode] = {}
for node in self._nodes.values():
if node.nodeType in nodeTypes:
newNode = nodeFactory(node.toDict(), node.nodeType, expectedUid=node._uid)
newNodes[node.name] = newNode
# Replace in a different loop to ensure all the nodes have been looped over: when looping
# over self._nodes and replacing nodes at the same time, some nodes might not be reached
for name, node in newNodes.items():
self.replaceNode(name, node)
@Slot(str, result=Attribute)
def attribute(self, fullName):
# type: (str) -> Attribute
"""
Return the attribute identified by the unique name 'fullName'.
If it does not exist, return None.
"""
node, attribute = fullName.split('.', 1)
if self.node(node).hasAttribute(attribute):
return self.node(node).attribute(attribute)
return None
@Slot(str, result=Attribute)
def internalAttribute(self, fullName):
# type: (str) -> Attribute
"""
Return the internal attribute identified by the unique name 'fullName'.
If it does not exist, return None.
"""
node, attribute = fullName.split('.', 1)
if self.node(node).hasInternalAttribute(attribute):
return self.node(node).internalAttribute(attribute)
return None
@staticmethod
def getNodeIndexFromName(name):
""" Nodes are created with a suffix index; returns this index by parsing node name.
Args:
name (str): the node name
Returns:
int: the index retrieved from node name (-1 if not found)
"""
try:
return int(name.split('_')[-1])
except Exception:
return -1
@staticmethod
def sortNodesByIndex(nodes):
"""
Sort the given list of Nodes using the suffix index in their names.
[NodeName_1, NodeName_0] => [NodeName_0, NodeName_1]
Args:
nodes (list[Node]): the list of Nodes to sort
Returns:
list[Node]: the sorted list of Nodes based on their index
"""
return sorted(nodes, key=lambda x: Graph.getNodeIndexFromName(x.name))
def nodesOfType(self, nodeType, sortedByIndex=True):
"""
Returns all Nodes of the given nodeType.
Args:
nodeType (str): the node type name to consider.
sortedByIndex (bool): whether to sort the nodes by their index (see Graph.sortNodesByIndex)
Returns:
list[Node]: the list of nodes matching the given nodeType.
"""
nodes = [n for n in self._nodes.values() if n.nodeType == nodeType]
return self.sortNodesByIndex(nodes) if sortedByIndex else nodes
def findInitNodes(self):
"""
Returns:
list[Node]: the list of Init nodes (nodes inheriting from InitNode)
"""
nodes = [n for n in self._nodes.values() if isinstance(n.nodeDesc, meshroom.core.desc.InitNode)]
return nodes
def findNodeCandidates(self, nodeNameExpr: str) -> list[Node]:
pattern = re.compile(nodeNameExpr)
return [v for k, v in self._nodes.objects.items() if pattern.match(k)]
def findNode(self, nodeExpr: str) -> Node:
candidates = self.findNodeCandidates('^' + nodeExpr)
if not candidates:
raise KeyError(f'No node candidate for "{nodeExpr}"')
if len(candidates) > 1:
for c in candidates:
if c.name == nodeExpr:
return c
raise KeyError(f'Multiple node candidates for "{nodeExpr}": {str([c.name for c in candidates])}')
return candidates[0]
def findNodes(self, nodesExpr):
if isinstance(nodesExpr, list):
return [self.findNode(nodeName) for nodeName in nodesExpr]
return [self.findNode(nodesExpr)]
def edge(self, dstAttributeName):
return self._edges.get(dstAttributeName)
def getLeafNodes(self, dependenciesOnly):
nodesWithOutputLink = {edge.src.node for edge in self.getEdges(dependenciesOnly)}
return set(self._nodes) - nodesWithOutputLink
def getRootNodes(self, dependenciesOnly):
nodesWithInputLink = {edge.dst.node for edge in self.getEdges(dependenciesOnly)}
return set(self._nodes) - nodesWithInputLink
@changeTopology
def addEdge(self, srcAttr: Attribute, dstAttr: Attribute) -> tuple[list[Attribute], list[Attribute]]:
if not srcAttr.node.graph == dstAttr.node.graph == self:
raise InvalidEdgeError(srcAttr.fullName, dstAttr.fullName,
"Attributes do not belong to this graph.")
if not dstAttr.validateIncomingConnection(srcAttr):
raise InvalidEdgeError(srcAttr.fullName, dstAttr.fullName,
f"Attributes are not compatible (src base type: {srcAttr.baseType}; dst base type: {dstAttr.baseType}).")
deletedEdge = []
if dstAttr in self.edges.keys():
deletedEdge = self.removeEdge(dstAttr)
edge = Edge(srcAttr, dstAttr)
self.edges.add(edge)
self.markNodesDirty(dstAttr.node)
dstAttr.valueChanged.emit()
dstAttr.inputLinksChanged.emit()
srcAttr.outputLinksChanged.emit()
return [edge.src, edge.dst], deletedEdge
@changeTopology
def removeEdge(self, dstAttr: Attribute):
if not self.edges.get(dstAttr):
return None
edge = self.edges.pop(dstAttr)
self.markNodesDirty(dstAttr.node)
dstAttr.valueChanged.emit()
dstAttr.inputLinksChanged.emit()
edge.src.outputLinksChanged.emit()
return [edge.src, dstAttr]
def getDepth(self, node, minimal=False):
""" Return node's depth in this Graph.
By default, returns the maximal depth of the node unless minimal is set to True.
Args:
node (Node): the node to consider.
minimal (bool): whether to return the minimal depth instead of the maximal one (default).
Returns:
int: the node's depth in this Graph.
"""
assert node.graph == self
assert not self.dirtyTopology
minDepth, maxDepth = self._nodesMinMaxDepths[node]
return minDepth if minimal else maxDepth
def getInputEdges(self, node, dependenciesOnly):
return {edge for edge in self.getEdges(dependenciesOnly=dependenciesOnly) if edge.dst.node is node}
def _getInputEdgesPerNode(self, dependenciesOnly):
nodeEdges = defaultdict(set)
for edge in self.getEdges(dependenciesOnly=dependenciesOnly):
nodeEdges[edge.dst.node].add(edge.src.node)
return nodeEdges
def _getOutputEdgesPerNode(self, dependenciesOnly):
nodeEdges = defaultdict(set)
for edge in self.getEdges(dependenciesOnly=dependenciesOnly):
nodeEdges[edge.src.node].add(edge.dst.node)
return nodeEdges