From 21fdcfeb5c1d8f40cfe2801552c1ca86704ef6b3 Mon Sep 17 00:00:00 2001 From: Alice Sonolet Date: Wed, 1 Jul 2026 10:17:49 +0200 Subject: [PATCH 1/4] core: Add external API to query scene infos --- meshroom/api/__init__.py | 22 +++ meshroom/api/core.py | 79 ++++++++++ meshroom/api/scene.py | 89 +++++++++++ tests/resources/templateGraphWithBackdrops.mg | 75 ++++++++++ tests/test_api.py | 141 ++++++++++++++++++ 5 files changed, 406 insertions(+) create mode 100644 meshroom/api/__init__.py create mode 100644 meshroom/api/core.py create mode 100644 meshroom/api/scene.py create mode 100644 tests/resources/templateGraphWithBackdrops.mg create mode 100644 tests/test_api.py diff --git a/meshroom/api/__init__.py b/meshroom/api/__init__.py new file mode 100644 index 0000000000..0c8b4d99c5 --- /dev/null +++ b/meshroom/api/__init__.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- + + +from meshroom.api.core import ( + setLoglevel, + initialize, + listPlugins, + unregisterPlugin, + registerPlugin, + listNodes, + unregisterNode, + registerNode, +) + +from meshroom.api.scene import ( + loadGraph, + getNodes, + getBackdropNodes, + getNode, + getNodesInsideBackdrop, + getNodeAttributes, +) diff --git a/meshroom/api/core.py b/meshroom/api/core.py new file mode 100644 index 0000000000..d330f2724d --- /dev/null +++ b/meshroom/api/core.py @@ -0,0 +1,79 @@ +# -*- coding: utf-8 -*- + +""" +Functions that are designed to provide a simple interface +to Meshroom plugins. +""" + +import logging +from typing import Union +import meshroom +from meshroom.core import pluginManager + + +LOGGER = logging.getLogger("MeshroomApi") + + +def setLoglevel(level: Union[int, str]): + if isinstance(level, str): + level = logging._nameToLevel.get(level.upper(), None) + if not isinstance(level, int): + LOGGER.warning(f"Cannot set level {level} : not an integer.") + logging.getLogger().setLevel(level) + levelName = logging.getLevelName(int(level)) + LOGGER.info(f"Meshroom log level has been set to {levelName}.") + + +def initialize(plugins=False, rezPlugins=False, nodes=False, submitters=False, pipelines=False): + if plugins: + meshroom.core.initPlugins() + if rezPlugins: + meshroom.core.initRezPlugins() + if nodes: + meshroom.core.initNodes() + nodes = pluginManager.getRegisteredNodePlugins() + LOGGER.info(f"{len(nodes)} Registered NodePlugins") + for n in nodes.values(): + LOGGER.info(f"Registered NodePlugin {n.nodeDescriptor.__module__}") + if submitters: + meshroom.core.initSubmitters() + if pipelines: + meshroom.core.initPipelines() + + +def listPlugins(): + plugins = pluginManager.getPlugins() + return plugins + + +def registerPlugin(plugin): + pluginManager.addPlugin(plugin, registerNodePlugins=True) + LOGGER.info(f"Register Plugin {plugin._name}") + + +def unregisterPlugin(name): + plugin = pluginManager.getPlugin(name) + if not plugin: + LOGGER.warning(f"No Plugin named {name}") + return + LOGGER.info(f"Unregister Plugin {plugin.name}") + pluginManager.removePlugin(plugin) + + +def listNodes(): + nodes = pluginManager.getRegisteredNodePlugins() + return nodes + + +def registerNode(nodePlugin): + pluginManager.registerNode(nodePlugin) + LOGGER.info(f"Register NodePlugin {nodePlugin.nodeDescriptor.__module__}") + + +def unregisterNode(name): + node = pluginManager.getRegisteredNodePlugin(name) + if not node: + LOGGER.warning(f"No NodePlugin named {name}") + return + LOGGER.info(f"Unregister NodePlugin {node.nodeDescriptor.__module__}") + pluginManager.unregisterNode(node) diff --git a/meshroom/api/scene.py b/meshroom/api/scene.py new file mode 100644 index 0000000000..d7805cd4a0 --- /dev/null +++ b/meshroom/api/scene.py @@ -0,0 +1,89 @@ +# -*- coding: utf-8 -*- + +""" +Functions that are designed to provide a simple interface +to Meshroom scenes. This can be used to parse a scene and +get infos about the scene, about specific nodes, etc. +""" + +import logging +from typing import Optional + +from meshroom.core import graph as meshroomGraph +from meshroom.core.graph import Graph +from meshroom.core.node import BaseNode, BackdropNode +from meshroom.core.attribute import Attribute + + +LOGGER = logging.getLogger("MeshroomApi") + + +def loadGraph(filePath, strictCompatibility=False) -> Graph: + g = meshroomGraph.loadGraph(filePath, strictCompatibility=strictCompatibility) + compatibilityNodesNames = [n.name for n in g._compatibilityNodes] + if compatibilityNodesNames: + LOGGER.warning(f"Scene ({filePath}) loaded with compatibility nodes : {compatibilityNodesNames}") + return g + + +def getNodes(graph: Graph, filterTypes: Optional[list[str]]=None) -> list[BaseNode]: + nodes: list[BaseNode] = [n for n in graph.nodes] + if filterTypes: + nodes = [n for n in nodes if n.nodeType in filterTypes] + return nodes + + +def getBackdropNodes(graph: Graph) -> list[BackdropNode]: + return getNodes(graph, filterTypes="Backdrop") + + +def getNode(graph: Graph, instanceName: str) -> BaseNode: + nodes = getNodes(graph) + for node in nodes: + if node.name == instanceName: + return node + return None + + +def getNodesInsideBackdrop(graph: Graph, backdropNode: BackdropNode): + """ List nodes inside a backdrop node + + HACK: Except for Backdrop nodes we don't know nodes height and width. + - As of now the width is fixed to 160 so we will use this + - For the height the node header has an height of approximately 20 and it will + likely not change. A node without any exposed param will be at least the double so + we will take a height of 40. + + This might not work well, but this will work well enough for controlled cases. + """ + + class Rect: + def __init__(self, node): + self.x1 = node.x + self.y1 = node.y + w = node.getNodeWidth() or 160 + self.x2 = w + self.x1 + h = node.getNodeHeight() or 40 + self.y2 = h + self.y1 + + backdropRect = Rect(backdropNode) + + def isNodeInsideBackdrop(node: BaseNode): + nodeRect = Rect(node) + isinside = \ + backdropRect.x1 < nodeRect.x1 < nodeRect.x2 < backdropRect.x2 and \ + backdropRect.y1 < nodeRect.y1 < nodeRect.y2 < backdropRect.y2 + return isinside + + nodes = [n for n in getNodes(graph) if n.name != backdropNode.name] + nodesInside = [n for n in nodes if isNodeInsideBackdrop(n)] + return nodesInside + + +def getNodeAttributes(node: BaseNode, internalAttributes=False, allAttributes=False) -> list[Attribute]: + attributes = [] + if not internalAttributes or allAttributes: + attributes.extend([v for v in node.getAttributes().values()]) + if internalAttributes or allAttributes: + attributes.extend([v for v in node.getInternalAttributes().values()]) + return attributes diff --git a/tests/resources/templateGraphWithBackdrops.mg b/tests/resources/templateGraphWithBackdrops.mg new file mode 100644 index 0000000000..831d91efcb --- /dev/null +++ b/tests/resources/templateGraphWithBackdrops.mg @@ -0,0 +1,75 @@ +{ + "header": { + "releaseVersion": "2026.1.0+develop", + "fileVersion": "2.1", + "nodesVersions": { + "InputFile": "1.0", + "InputInt": "1.0", + "InputString": "1.0" + }, + "template": true + }, + "graph": { + "A_1": { + "nodeType": "InputString", + "position": [ + -109, + 39.0 + ], + "inputs": { + "string": "string_2" + } + }, + "Backdrop_1": { + "nodeType": "Backdrop", + "position": [ + -130, + -131 + ], + "internalInputs": { + "nodeWidth": 200, + "nodeHeight": 311 + } + }, + "InputString_1": { + "nodeType": "InputString", + "position": [ + -109, + -28 + ], + "inputs": { + "string": "string_1" + } + }, + "InsideBackdrop_1": { + "nodeType": "InputFile", + "position": [ + -109, + 108 + ], + "inputs": { + "inputFile": "/path1" + } + }, + "Int_1": { + "nodeType": "InputInt", + "position": [ + -109, + -91 + ], + "inputs": { + "integer": 3 + } + }, + "OutsideBackdrop_1": { + "nodeType": "InputFile", + "position": [ + -109, + 219.0 + ], + "inputs": { + "inputFile": "/path2" + } + } + } +} \ No newline at end of file diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000000..5bc2283fd2 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,141 @@ +import os +import tempfile +import pytest +from collections import defaultdict + +from meshroom.core.graph import Graph +from meshroom.core import desc, cacheFolderName +from meshroom.core.graph import Graph, loadGraph +from meshroom.core.node import CompatibilityIssue, CompatibilityNode +from meshroom.core.exception import GraphCompatibilityError, NodeUpgradeError +from .utils import registerNodeDesc, registeredNodeTypes, overrideNodeTypeVersion + +from meshroom import api as meshroomApi + + +@pytest.fixture +def sceneFilepath(): + """ Scene with : + - 1 Backdrop node (Backdrop_1) containing : + - 2 InputString nodes (A_1, InputString_1) + - 1 InputInt node (Int_1) + - 1 InputFile node (InsideBackdrop_1) + - 1 InputFile node (OutsideBackdrop_1) + """ + folder = os.path.join(os.path.dirname(__file__), "resources") + scene = "templateGraphWithBackdrops.mg" + path = os.path.join(folder, scene) + return path + + +def loadGraph(path, failedOnCompatbility=False): + g = meshroomApi.loadGraph(path, strictCompatibility=failedOnCompatbility) + return g + + +def getGeneralPlugin(): + plugins = meshroomApi.listPlugins() + assert "general" in plugins.keys() + p = plugins["general"] + return p + + +def getInputFileNodePlugin(): + nodes = meshroomApi.listNodes() + assert "InputFile" in nodes.keys() + n = nodes["InputFile"] + return n + + +class TestMeshroomApi: + @classmethod + def setup_class(cls): + # meshroomApi.setLoglevel("info") + meshroomApi.initialize(nodes=True) + + @classmethod + def teardown_class(cls): + meshroomApi.setLoglevel("warning") + + def test_api_registerPlugin(self): + """ Test unregisterPlugin, unregisterPlugin, listPlugins """ + plugin = getGeneralPlugin() + meshroomApi.unregisterPlugin("general") + meshroomApi.registerPlugin(plugin) + plugins = meshroomApi.listPlugins() + assert "general" in plugins.keys() + + def test_api_registerNode(self): + """ Test unregisterNode, registerNode, listNodes """ + node = getInputFileNodePlugin() + meshroomApi.unregisterNode("InputFile") + meshroomApi.registerNode(node) + nodes = meshroomApi.listNodes() + assert "InputFile" in nodes + + def test_api_loadGraph(self, sceneFilepath): + g = loadGraph(sceneFilepath, failedOnCompatbility=True) + assert g.filepath == sceneFilepath + assert os.path.dirname(g.cacheDir) == os.path.dirname(sceneFilepath) + + def test_api_loadGraphRaiseOnCompatibility(self, sceneFilepath): + node = getInputFileNodePlugin() + try: + meshroomApi.unregisterNode("InputFile") + _ = loadGraph(sceneFilepath, failedOnCompatbility=True) + except (NodeUpgradeError, GraphCompatibilityError): + pass + else: + raise RuntimeError("Test was expected to fail because of missing nodes.") + finally: + # Restore InputFile for other tests + meshroomApi.registerNode(node) + + def test_api_getNodes(self, sceneFilepath): + g = loadGraph(sceneFilepath) + nodes = meshroomApi.getNodes(g) + nodeByType = defaultdict(list) + for node in nodes: + nodeByType[node.nodeType].append(node) + nodeTypes = nodeByType.keys() + assert set(nodeTypes) == {"InputString", "InputFile", "InputInt", "Backdrop"} + assert len(nodeByType["InputString"]) == 2 + assert len(nodeByType["InputFile"]) == 2 + assert len(nodeByType["InputInt"]) == 1 + assert len(nodeByType["Backdrop"]) == 1 + + def test_api_getBackdropNodes(self, sceneFilepath): + g = loadGraph(sceneFilepath) + backdropNodes = meshroomApi.getBackdropNodes(g) + assert len(backdropNodes) == 1 + backdrop = backdropNodes[0] + assert backdrop.name == "Backdrop_1" + + def test_api_getNode(self, sceneFilepath): + g = loadGraph(sceneFilepath) + def checkNode(name, checkType): + node = meshroomApi.getNode(g, instanceName=name) + assert node is not None + assert node.nodeType == checkType + checkNode("Backdrop_1", "Backdrop") + checkNode("A_1", "InputString") + checkNode("InputString_1", "InputString") + checkNode("Int_1", "InputInt") + checkNode("InsideBackdrop_1", "InputFile") + checkNode("OutsideBackdrop_1", "InputFile") + + def test_api_getNodesInsideBackdrop(self, sceneFilepath): + g = loadGraph(sceneFilepath) + backdropNode = meshroomApi.getBackdropNodes(g)[0] + nodesInside = meshroomApi.getNodesInsideBackdrop(g, backdropNode) + nodeNames = set([n.name for n in nodesInside]) + for n in ["A_1", "InputString_1", "Int_1", "InsideBackdrop_1"]: + assert n in nodeNames + assert "OutsideBackdrop_1" not in nodeNames + + def test_api_getNodeAttributes(self, sceneFilepath): + g = loadGraph(sceneFilepath) + nodeInt_1 = meshroomApi.getNode(g, instanceName="Int_1") + attrs = meshroomApi.getNodeAttributes(nodeInt_1) + assert len(attrs) == 1 + assert attrs[0].name == "integer" From b2bede2126387f1128920bb4d5382fb0ef05b84c Mon Sep 17 00:00:00 2001 From: Alice Sonolet Date: Wed, 1 Jul 2026 10:17:49 +0200 Subject: [PATCH 2/4] test_api: fix windows path --- tests/test_api.py | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/tests/test_api.py b/tests/test_api.py index 5bc2283fd2..e59f739fb9 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,15 +1,9 @@ import os -import tempfile import pytest from collections import defaultdict -from meshroom.core.graph import Graph -from meshroom.core import desc, cacheFolderName -from meshroom.core.graph import Graph, loadGraph -from meshroom.core.node import CompatibilityIssue, CompatibilityNode +from meshroom.core.graph import loadGraph from meshroom.core.exception import GraphCompatibilityError, NodeUpgradeError -from .utils import registerNodeDesc, registeredNodeTypes, overrideNodeTypeVersion - from meshroom import api as meshroomApi @@ -25,7 +19,7 @@ def sceneFilepath(): folder = os.path.join(os.path.dirname(__file__), "resources") scene = "templateGraphWithBackdrops.mg" path = os.path.join(folder, scene) - return path + return os.path.normpath(path) def loadGraph(path, failedOnCompatbility=False): From ec5061a70c0f55d2c499755f6526668a118b52d4 Mon Sep 17 00:00:00 2001 From: Alice Sonolet Date: Wed, 1 Jul 2026 10:17:50 +0200 Subject: [PATCH 3/4] api: Fix mistakes after review --- meshroom/api/core.py | 3 ++- meshroom/api/scene.py | 18 +++++++----------- 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/meshroom/api/core.py b/meshroom/api/core.py index d330f2724d..4d19190ee3 100644 --- a/meshroom/api/core.py +++ b/meshroom/api/core.py @@ -19,6 +19,7 @@ def setLoglevel(level: Union[int, str]): level = logging._nameToLevel.get(level.upper(), None) if not isinstance(level, int): LOGGER.warning(f"Cannot set level {level} : not an integer.") + return logging.getLogger().setLevel(level) levelName = logging.getLevelName(int(level)) LOGGER.info(f"Meshroom log level has been set to {levelName}.") @@ -48,7 +49,7 @@ def listPlugins(): def registerPlugin(plugin): pluginManager.addPlugin(plugin, registerNodePlugins=True) - LOGGER.info(f"Register Plugin {plugin._name}") + LOGGER.info(f"Register Plugin {plugin.name}") def unregisterPlugin(name): diff --git a/meshroom/api/scene.py b/meshroom/api/scene.py index d7805cd4a0..81feaed6de 100644 --- a/meshroom/api/scene.py +++ b/meshroom/api/scene.py @@ -20,7 +20,7 @@ def loadGraph(filePath, strictCompatibility=False) -> Graph: g = meshroomGraph.loadGraph(filePath, strictCompatibility=strictCompatibility) - compatibilityNodesNames = [n.name for n in g._compatibilityNodes] + compatibilityNodesNames = [n.name for n in g.compatibilityNodes] if compatibilityNodesNames: LOGGER.warning(f"Scene ({filePath}) loaded with compatibility nodes : {compatibilityNodesNames}") return g @@ -34,15 +34,11 @@ def getNodes(graph: Graph, filterTypes: Optional[list[str]]=None) -> list[BaseNo def getBackdropNodes(graph: Graph) -> list[BackdropNode]: - return getNodes(graph, filterTypes="Backdrop") + return getNodes(graph, filterTypes=["Backdrop"]) def getNode(graph: Graph, instanceName: str) -> BaseNode: - nodes = getNodes(graph) - for node in nodes: - if node.name == instanceName: - return node - return None + return graph.node(instanceName) def getNodesInsideBackdrop(graph: Graph, backdropNode: BackdropNode): @@ -82,8 +78,8 @@ def isNodeInsideBackdrop(node: BaseNode): def getNodeAttributes(node: BaseNode, internalAttributes=False, allAttributes=False) -> list[Attribute]: attributes = [] - if not internalAttributes or allAttributes: - attributes.extend([v for v in node.getAttributes().values()]) - if internalAttributes or allAttributes: - attributes.extend([v for v in node.getInternalAttributes().values()]) + if allAttributes or not internalAttributes: + attributes.extend(node.getAttributes().values()) + if allAttributes or internalAttributes: + attributes.extend(node.getInternalAttributes().values()) return attributes From f31ec7e6588f3c42ef67b74536e4afb2594decea Mon Sep 17 00:00:00 2001 From: Alice Sonolet Date: Wed, 1 Jul 2026 10:17:50 +0200 Subject: [PATCH 4/4] test_api : fix check paths on windows --- tests/test_api.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/test_api.py b/tests/test_api.py index e59f739fb9..00493dbf2a 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -68,9 +68,11 @@ def test_api_registerNode(self): assert "InputFile" in nodes def test_api_loadGraph(self, sceneFilepath): + def assertPathsAreEqual(p1, p2): + assert os.path.normpath(p1) == os.path.normpath(p2) g = loadGraph(sceneFilepath, failedOnCompatbility=True) - assert g.filepath == sceneFilepath - assert os.path.dirname(g.cacheDir) == os.path.dirname(sceneFilepath) + assertPathsAreEqual(g.filepath, sceneFilepath) + assertPathsAreEqual(os.path.dirname(g.cacheDir), os.path.dirname(sceneFilepath)) def test_api_loadGraphRaiseOnCompatibility(self, sceneFilepath): node = getInputFileNodePlugin()