Skip to content

Commit 760a240

Browse files
authored
Merge pull request #3001 from alicevision/dev/outputListAttributes
[core] Enable output list attributes in the graph
2 parents 38a131c + 5fc51a3 commit 760a240

5 files changed

Lines changed: 232 additions & 18 deletions

File tree

meshroom/core/attribute.py

Lines changed: 108 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import copy
55
import os
66
import re
7+
import threading
78
import weakref
89
import logging
910
import inspect
@@ -185,8 +186,17 @@ def _getValue(self):
185186
raise RuntimeError(f"Cannot get value of {self._getFullName()}, the attribute is keyable.")
186187
if self.isLink:
187188
return self._getInputLink().value
189+
self._resolveValue()
188190
return self._value
189191

192+
def _resolveValue(self):
193+
"""
194+
Hook for subclasses to resolve pending values before returning _value.
195+
Called by _getValue before returning self._value.
196+
Default implementation is a no-op.
197+
"""
198+
pass
199+
190200
def _setValue(self, value):
191201
"""
192202
Set the attribute value from a given value, a given function or a given attribute.
@@ -820,6 +830,9 @@ def getSerializedValue(self):
820830

821831
class ListAttribute(Attribute):
822832

833+
# Sentinel to distinguish 'no pending dynamic value' from 'pending reset to empty'
834+
_NO_PENDING_VALUE = object()
835+
823836
def __init__(self, node, attributeDesc: desc.ListAttribute, isOutput: bool,
824837
root=None, parent=None):
825838
super().__init__(node, attributeDesc, isOutput, root, parent)
@@ -861,13 +874,14 @@ def insert(self, index, value):
861874
self._value.insert(index, attrs)
862875
self.valueChanged.emit()
863876
self._applyExpr()
864-
self.requestGraphUpdate()
877+
if self.isInput:
878+
self.requestGraphUpdate()
865879

866880
@raiseIfLink
867881
def remove(self, index, count=1):
868882
if self._value is None:
869883
return
870-
if self.node.graph:
884+
if self.node.graph and self.isInput:
871885
from meshroom.core.graph import GraphModification
872886
with GraphModification(self.node.graph):
873887
# remove potential links
@@ -877,15 +891,41 @@ def remove(self, index, count=1):
877891
# delete edge if the attribute is linked
878892
self.node.graph.removeEdge(attr)
879893
self._value.removeAt(index, count)
880-
self.requestGraphUpdate()
894+
if self.isInput:
895+
self.requestGraphUpdate()
881896
self.valueChanged.emit()
882897

883898
# Override
884899
def _initValue(self):
900+
self._dynamicValueLock = threading.Lock()
901+
self._dynamicValue = ListAttribute._NO_PENDING_VALUE
885902
self.resetToDefaultValue()
886903

887904
# Override
888905
def _setValue(self, value):
906+
if self.isOutput:
907+
# For output attributes (set during processChunk in a worker thread,
908+
# or during loadOutputAttr in the TaskThread), store raw values without
909+
# creating QObject children to avoid cross-thread parenting issues.
910+
# The raw values are:
911+
# - serialized by saveOutputAttr via getPrimitiveValue
912+
# - materialized into QObjects lazily by _getValue on the main thread
913+
with self._dynamicValueLock:
914+
if value is None:
915+
self._dynamicValue = None # pending reset
916+
else:
917+
self._dynamicValue = self._desc.validateValue(value)
918+
return
919+
920+
# Input attribute path: handle None
921+
if value is None:
922+
if self.node.graph and self._value is not None and len(self._value) > 0:
923+
self.remove(0, len(self))
924+
if self._value is None:
925+
self._value = ListModel(parent=self)
926+
self.valueChanged.emit()
927+
return
928+
889929
if self.node.graph:
890930
self.remove(0, len(self))
891931
if self._handleLinkValue(value):
@@ -897,7 +937,8 @@ def _setValue(self, value):
897937
self._value = ListModel(parent=self)
898938
newValue = self._desc.validateValue(value)
899939
self.extend(newValue)
900-
self.requestGraphUpdate()
940+
if self.isInput:
941+
self.requestGraphUpdate()
901942

902943
# Override
903944
def _applyExpr(self):
@@ -907,6 +948,20 @@ def _applyExpr(self):
907948
for value in self._value:
908949
value._applyExpr()
909950

951+
def _populateFromDynamicValue(self, value):
952+
"""Store raw dynamic values for lazy materialization.
953+
954+
Does NOT create QObject children — safe to call from any thread.
955+
The actual ListModel population happens lazily in _getValue()
956+
when the main thread (e.g. QML) reads the value.
957+
"""
958+
with self._dynamicValueLock:
959+
if value is None:
960+
self._dynamicValue = None # pending reset
961+
else:
962+
self._dynamicValue = self._desc.validateValue(value)
963+
self.valueChanged.emit()
964+
910965
# Override
911966
def resetToDefaultValue(self):
912967
self._value = ListModel(parent=self)
@@ -922,8 +977,57 @@ def getSerializedValue(self):
922977
return self._getInputLink().asLinkExpr()
923978
return [attr.getSerializedValue() for attr in self._value]
924979

980+
value = Property(Variant, Attribute._getValue, _setValue, notify=Attribute.valueChanged)
981+
982+
# Override
983+
def _resolveValue(self):
984+
"""
985+
Lazily materialize QObject children from pending raw dynamic values.
986+
Called by Attribute._getValue (base) before returning self._value.
987+
This hook dispatches via normal Python MRO, bypassing PySide Property
988+
getter dispatch limitations.
989+
Must only create QObjects on the main thread to avoid cross-thread issues.
990+
"""
991+
if self._dynamicValue is not ListAttribute._NO_PENDING_VALUE:
992+
if threading.current_thread() is threading.main_thread():
993+
self._materializeDynamicValue()
994+
995+
def _materializeDynamicValue(self):
996+
"""
997+
Create QObject children in the ListModel from pending raw dynamic values.
998+
Must only be called on the main thread.
999+
"""
1000+
1001+
# Thread proof reading of dynamic value
1002+
with self._dynamicValueLock:
1003+
pendingValue = self._dynamicValue
1004+
self._dynamicValue = ListAttribute._NO_PENDING_VALUE
1005+
1006+
# Create an empty list if the value is None
1007+
if self._value is None:
1008+
self._value = ListModel(parent=self)
1009+
elif len(self._value) > 0:
1010+
# Erase all items before reassigning
1011+
self._value.removeAt(0, len(self._value))
1012+
1013+
# Effectively create the objects from the raw data
1014+
if pendingValue:
1015+
attrs = [attributeFactory(self._desc.elementDesc, v, self.isOutput, self.node, self)
1016+
for v in pendingValue]
1017+
self._value.insert(0, attrs)
1018+
1019+
self.valueChanged.emit()
1020+
9251021
# Override
9261022
def getPrimitiveValue(self, exportDefault=True):
1023+
# If there is a pending dynamic value (set or reset), return it directly
1024+
# without touching the ListModel (which may be on a different thread).
1025+
with self._dynamicValueLock:
1026+
if self._dynamicValue is not ListAttribute._NO_PENDING_VALUE:
1027+
if self._dynamicValue is None:
1028+
return []
1029+
return list(self._dynamicValue)
1030+
9271031
if exportDefault:
9281032
return [attr.getPrimitiveValue(exportDefault=exportDefault) for attr in self._value]
9291033
return [attr.getPrimitiveValue(exportDefault=exportDefault) for attr in self._value

meshroom/core/desc/attribute.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -181,15 +181,17 @@ class ListAttribute(Attribute):
181181
""" A list of Attributes """
182182
@deprecated.depreciateParam("group", "Param 'group' on {name} should not be used anymore. Please use 'commandLineGroup' instead")
183183
def __init__(self, elementDesc, name, label=None, description=None, group="allParams", commandLineGroup=_setParamSentinel,
184-
advanced=False, semantic="", enabled=True, joinChar=" ", visible=True, exposed=False):
184+
advanced=False, semantic="", enabled=True, joinChar=" ", visible=True, exposed=False, value=None):
185185
"""
186186
:param elementDesc: the Attribute description of elements to store in that list
187+
:param value: default value. Use None to declare a dynamic output ListAttribute
188+
whose content is set during processChunk.
187189
"""
188190
self._elementDesc = elementDesc
189191
self._joinChar = joinChar
190192
commandLineGroup = commandLineGroup if commandLineGroup is not _setParamSentinel else group
191193

192-
super(ListAttribute, self).__init__(name=name, label=label, description=description, value=[],
194+
super(ListAttribute, self).__init__(name=name, label=label, description=description, value=value,
193195
invalidate=False, commandLineGroup=commandLineGroup, advanced=advanced, semantic=semantic,
194196
enabled=enabled, visible=visible, exposed=exposed)
195197

meshroom/core/node.py

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1788,22 +1788,35 @@ def resetOutputAttr(self):
17881788
def loadOutputAttr(self):
17891789
""" Load output attributes with dynamic values from a values.json file.
17901790
"""
1791+
1792+
# This does not apply to non dynamic output
17911793
if not self.nodeDesc.hasDynamicOutputAttribute:
17921794
return
1795+
1796+
# Check existence of values.json file
17931797
valuesFile = self.valuesFile
17941798
if not os.path.exists(valuesFile):
17951799
logging.warning(f"No output attr file: {valuesFile}")
17961800
return
17971801

1798-
# logging.warning("load output attr: {}, value: {}".format(self.name, valuesFile))
1802+
# Open json file and parse
17991803
with open(valuesFile) as jsonFile:
18001804
data = json.load(jsonFile)
18011805

1802-
# logging.warning(data)
1806+
# loop over all output attributes in the node description
18031807
for output in self.nodeDesc.outputs:
1808+
# Only consider dynamic values
18041809
if output.isDynamicValue:
18051810
if self.hasAttribute(output.name) and output.name in data:
1806-
self.attribute(output.name).value = data[output.name]
1811+
attr = self.attribute(output.name)
1812+
1813+
# Use _populateFromDynamicValue for compatible classes
1814+
# (E.g. for ListAttributes) to properly
1815+
# create QObject children on the main thread
1816+
if hasattr(attr, '_populateFromDynamicValue'):
1817+
attr._populateFromDynamicValue(data[output.name])
1818+
else:
1819+
attr.value = data[output.name]
18071820
else:
18081821
if not self.hasAttribute(output.name):
18091822
logging.warning(f"loadOutputAttr: Missing dynamic output attribute. Node={self.name}, "
@@ -1821,7 +1834,8 @@ def saveOutputAttr(self):
18211834
for output in self.nodeDesc.outputs:
18221835
if output.isDynamicValue:
18231836
if self.hasAttribute(output.name):
1824-
data[output.name] = self.attribute(output.name).value
1837+
# Store the primitive value and not the value itself
1838+
data[output.name] = self.attribute(output.name).getPrimitiveValue()
18251839
else:
18261840
logging.warning(f"saveOutputAttr: Missing dynamic output attribute: {self.name}.{output.name}")
18271841

tests/test_attributeDescDefaults.py

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -95,14 +95,6 @@ def test_param_no_value_is_dynamic(attrDesc):
9595
assert attrDesc.isDynamicValue is True
9696

9797

98-
def test_list_and_group_attributes_not_dynamic():
99-
"""ListAttribute and GroupAttribute always have a non-None default value."""
100-
la = desc.ListAttribute(desc.StringParam(name="elem"), name="items")
101-
ga = desc.GroupAttribute([], name="group")
102-
assert la.isDynamicValue is False
103-
assert ga.isDynamicValue is False
104-
105-
10698
def test_label_auto_generated_from_camel_case():
10799
"""Label should be auto-generated from camelCase attribute names."""
108100
assert desc.File(name="outputFile").label == "Output File"

tests/test_nodeDynamicOutputs.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,34 @@ def process(self, node):
8585
node.floatOutput.value = node.floatInput.value * 2.0
8686

8787

88+
class NodeWithDynamicListOutput(desc.Node):
89+
"""Node with a dynamic output ListAttribute set during processChunk."""
90+
inputs = [
91+
desc.ListAttribute(
92+
name="listInput",
93+
label="List Input",
94+
description="A list of strings as input.",
95+
elementDesc=desc.StringParam(name="value", label="Value", description="", value=""),
96+
),
97+
]
98+
99+
outputs = [
100+
desc.ListAttribute(
101+
name="listOutput",
102+
label="List Output",
103+
description="A dynamic list output set during processing.",
104+
elementDesc=desc.StringParam(name="value", label="Value", description="", value=""),
105+
value=None,
106+
),
107+
]
108+
109+
def processChunk(self, chunk):
110+
# Read input list and produce an output list with uppercased values
111+
inputValues = [attr.value for attr in chunk.node.listInput.value]
112+
outputValues = [v.upper() for v in inputValues]
113+
chunk.node.listOutput.value = outputValues
114+
115+
88116
class InputNodeWithDynamicOutputs(desc.InputNode):
89117
inputs = [
90118
desc.File(
@@ -220,3 +248,77 @@ def test_registerInputNodeWithDynamicOutputsV2(self):
220248
# InputDynamicOutputs has the same description as InputNodeWithDynamicOutputs: had it been valid, it would
221249
# have been loaded and registered by the plugin manager at the upper level of the test suite.
222250
graph.addNewNode("InputDynamicOutputs")
251+
252+
253+
class TestDynamicListOutputs:
254+
"""Tests for dynamic output ListAttribute support."""
255+
256+
@classmethod
257+
def setup_class(cls):
258+
registerNodeDesc(NodeWithDynamicListOutput)
259+
260+
@classmethod
261+
def teardown_class(cls):
262+
unregisterNodeDesc(NodeWithDynamicListOutput)
263+
264+
def test_dynamicListOutputDescriptor(self):
265+
"""Check that a ListAttribute with value=None is correctly flagged as dynamic."""
266+
nodeDesc = NodeWithDynamicListOutput()
267+
assert nodeDesc.hasDynamicOutputAttribute
268+
listOutputDesc = nodeDesc.outputs[0]
269+
assert listOutputDesc.isDynamicValue
270+
271+
def test_processWithDynamicListOutput(self, graphSavedOnDisk):
272+
"""Process a node that sets a dynamic output ListAttribute during processChunk."""
273+
graph: Graph = graphSavedOnDisk
274+
node = graph.addNewNode(NodeWithDynamicListOutput.__name__)
275+
276+
node.listInput.value = ["hello", "world"]
277+
278+
# Execute the node
279+
node.process(inCurrentEnv=True)
280+
281+
# After processChunk, raw values are stored for serialization (thread-safe).
282+
# getPrimitiveValue returns the raw values without requiring QObject children.
283+
assert node.listOutput.getPrimitiveValue() == ["HELLO", "WORLD"]
284+
285+
# loadOutputAttr materializes QObject children (normally on the main thread)
286+
node.loadOutputAttr()
287+
outputValues = [attr.value for attr in node.listOutput.value]
288+
assert outputValues == ["HELLO", "WORLD"]
289+
290+
def test_loadGraphWithComputedDynamicListOutput(self, graphSavedOnDisk):
291+
"""Check that dynamic list output values are persisted and reloaded correctly."""
292+
graph: Graph = graphSavedOnDisk
293+
node = graph.addNewNode(NodeWithDynamicListOutput.__name__)
294+
name = node.name
295+
296+
node.listInput.value = ["foo", "bar", "baz"]
297+
graph.save()
298+
299+
# Execute the node
300+
node.process(inCurrentEnv=True)
301+
302+
# Verify raw output was stored
303+
assert node.listOutput.getPrimitiveValue() == ["FOO", "BAR", "BAZ"]
304+
305+
# Reload the graph — loadOutputAttr populates the ListModel on the main thread
306+
loadedGraph = loadGraph(graph.filepath)
307+
loadedNode = loadedGraph.node(name)
308+
309+
assert loadedNode
310+
loadedOutputValues = [attr.value for attr in loadedNode.listOutput.value]
311+
assert loadedOutputValues == ["FOO", "BAR", "BAZ"]
312+
313+
def test_loadGraphWithUncomputedDynamicListOutput(self, graphSavedOnDisk):
314+
"""Check that an uncomputed dynamic list output is empty after loading."""
315+
graph: Graph = graphSavedOnDisk
316+
node = graph.addNewNode(NodeWithDynamicListOutput.__name__)
317+
graph.save()
318+
319+
loadedGraph = loadGraph(graph.filepath)
320+
loadedNode = loadedGraph.node(node.name)
321+
322+
assert loadedNode
323+
# Uncomputed dynamic list output should be empty
324+
assert len(loadedNode.listOutput) == 0

0 commit comments

Comments
 (0)