diff --git a/bin/meshroom_photogrammetry b/bin/meshroom_photogrammetry index dae5183b07..af4c592b51 100755 --- a/bin/meshroom_photogrammetry +++ b/bin/meshroom_photogrammetry @@ -2,6 +2,7 @@ import argparse import os import sys +import distutils.util import meshroom meshroom.setupEnvironment() @@ -10,15 +11,15 @@ import meshroom.core.graph from meshroom import multiview parser = argparse.ArgumentParser(description='Launch the full photogrammetry pipeline.') -parser.add_argument('--input', metavar='FOLDER_OR_SFM', type=str, - default='', - help='Input folder containing images or file (.sfm or .json) ' +parser.add_argument('-i', '--input', metavar='SFM/FOLDERS/IMAGES', type=str, nargs='*', + default=[], + help='Input folder containing images or folders of images or file (.sfm or .json) ' 'with images paths and optionally predefined camera intrinsics.') -parser.add_argument('--inputImages', metavar='IMAGES', type=str, nargs='*', +parser.add_argument('-I', '--inputRecursive', metavar='FOLDERS/IMAGES', type=str, nargs='*', default=[], - help='Input images.') + help='Input folders containing all images recursively.') -parser.add_argument('--pipeline', metavar='MESHROOM_FILE', type=str, required=False, +parser.add_argument('-p', '--pipeline', metavar='MESHROOM_FILE', type=str, required=False, help='Meshroom file containing a pre-configured photogrammetry pipeline to run on input images. ' 'If not set, the default photogrammetry pipeline will be used. ' 'Requirements: the graph must contain one CameraInit node, ' @@ -27,7 +28,10 @@ parser.add_argument('--pipeline', metavar='MESHROOM_FILE', type=str, required=Fa parser.add_argument('--overrides', metavar='SETTINGS', type=str, default=None, help='A JSON file containing the graph parameters override.') -parser.add_argument('--output', metavar='FOLDER', type=str, required=False, +parser.add_argument('--paramOverrides', metavar='NODETYPE:param=value NODEINSTANCE.param=value', type=str, default=None, nargs='*', + help='Override specific parameters directly from the command line (by node type or by node names).') + +parser.add_argument('-o', '--output', metavar='FOLDER', type=str, required=False, help='Output folder where results should be copied to. ' 'If not set, results will have to be retrieved directly from the cache folder.') @@ -37,7 +41,10 @@ parser.add_argument('--cache', metavar='FOLDER', type=str, 'If not set, the default cache folder will be used: ' + meshroom.core.defaultCacheFolder) parser.add_argument('--save', metavar='FILE', type=str, required=False, - help='Save the configured Meshroom project to a file (instead of running it).') + help='Save the configured Meshroom graph to a project file. It will setup the cache folder accordingly if not explicitly changed by --cache.') + +parser.add_argument('--compute', metavar='', type=lambda x: bool(distutils.util.strtobool(x)), default=True, required=False, + help='You can set it to to disable the computation.') parser.add_argument('--scale', type=int, default=-1, choices=[-1, 1, 2, 4, 8, 16], @@ -65,24 +72,32 @@ def getOnlyNodeOfType(g, nodeType): return nodes[0] -if not args.input and not args.inputImages: - print('Nothing to compute. You need to set --input or --inputImages.') +if not args.input and not args.inputRecursive: + print('Nothing to compute. You need to set --input or --inputRecursive.') sys.exit(1) views, intrinsics = [], [] # Build image files list from inputImages arguments -images = [f for f in args.inputImages if multiview.isImageFile(f)] +images = [] + +hasSearchedForImages = False if args.input: - if os.path.isdir(args.input): - # args.input is a folder: extend images list with images in that folder - images += multiview.findImageFiles(args.input) - elif os.path.isfile(args.input) and os.path.splitext(args.input)[-1] in ('.json', '.sfm'): + if len(args.input) == 1 and os.path.isfile(args.input[0]) and os.path.splitext(args.input[0])[-1] in ('.json', '.sfm'): # args.input is a sfmData file: setup pre-calibrated views and intrinsics from meshroom.nodes.aliceVision.CameraInit import readSfMData - views, intrinsics = readSfMData(args.input) + views, intrinsics = readSfMData(args.input[0]) else: - raise RuntimeError(args.input + ': format not supported.') + images += multiview.findImageFiles(args.input, recursive=False) + hasSearchedForImages = True + +if args.inputRecursive: + images += multiview.findImageFiles(args.inputRecursive, recursive=True) + hasSearchedForImages = True + +if hasSearchedForImages and not images: + print("No image found") + exit(-1) # initialize photogrammetry pipeline if args.pipeline: @@ -121,24 +136,47 @@ if args.overrides: for attrName, value in overrides.items(): graph.findNode(nodeName).attribute(attrName).value = value +if args.paramOverrides: + print("\n") + import re + reExtract = re.compile('(\w+)([:.])(\w+)=(.*)') + for p in args.paramOverrides: + result = reExtract.match(p) + if not result: + raise ValueError('Invalid param override: ' + str(p)) + node, t, param, value = result.groups() + if t == ':': + nodesByType = graph.nodesByType(node) + if not nodesByType: + raise ValueError('No node with the type "{}" in the scene.'.format(node)) + for n in nodesByType: + print('Overrides {node}.{param}={value}'.format(node=node, param=param, value=value)) + n.attribute(param).value = value + elif t == '.': + print('Overrides {node}.{param}={value}'.format(node=node, param=param, value=value)) + graph.findNode(node).attribute(param).value = value + else: + raise ValueError('Invalid param override: ' + str(p)) + print("\n") + # setup DepthMap downscaling if args.scale > 0: for node in graph.nodesByType('DepthMap'): node.downscale.value = args.scale -if args.save: - graph.save(args.save) - print('File successfully saved:', args.save) - sys.exit(0) - # setup cache directory graph.cacheDir = args.cache if args.cache else meshroom.core.defaultCacheFolder +if args.save: + graph.save(args.save, setupFileRef=not bool(args.cache)) + print('File successfully saved: "{}"'.format(args.save)) + if not args.output: - print('No output set, results will be available in {}'.format(graph.cacheDir)) + print('No output set, results will be available in the cache folder: "{}"'.format(graph.cacheDir)) # find end nodes (None will compute all graph) toNodes = graph.findNodes(args.toNode) if args.toNode else None -# start computation -meshroom.core.graph.executeGraph(graph, toNodes=toNodes, forceCompute=args.forceCompute, forceStatus=args.forceStatus) +if args.compute: + # start computation + meshroom.core.graph.executeGraph(graph, toNodes=toNodes, forceCompute=args.forceCompute, forceStatus=args.forceStatus) diff --git a/meshroom/core/__init__.py b/meshroom/core/__init__.py index 20b5f774c8..1951198623 100644 --- a/meshroom/core/__init__.py +++ b/meshroom/core/__init__.py @@ -240,7 +240,7 @@ def loadAllNodes(folder): nodeTypes = loadNodes(folder, package) for nodeType in nodeTypes: registerNodeType(nodeType) - print('Plugins loaded: ', ', '.join([nodeType.__name__ for nodeType in nodeTypes])) + logging.debug('Plugins loaded: ', ', '.join([nodeType.__name__ for nodeType in nodeTypes])) def registerSubmitter(s): diff --git a/meshroom/core/graph.py b/meshroom/core/graph.py index 0427b33fd5..1b176e8a59 100644 --- a/meshroom/core/graph.py +++ b/meshroom/core/graph.py @@ -234,7 +234,15 @@ def fileFeatures(self): return Graph.IO.getFeaturesForVersion(self.header.get(Graph.IO.Keys.FileVersion, "0.0")) @Slot(str) - def load(self, filepath): + def load(self, filepath, setupProjectFile=True): + """ + Load a meshroom graph ".mg" file. + + Args: + filepath: project filepath to load + setupProjectFile: Store the reference to the project file and setup the cache directory. + If false, it only loads the graph of the project file as a template. + """ self.clear() with open(filepath) as jsonFile: fileData = json.load(jsonFile) @@ -265,8 +273,9 @@ def load(self, filepath): # Add node to the graph with raw attributes values self._addNode(n, nodeName) - # Update filepath related members - self._setFilepath(filepath) + if setupProjectFile: + # Update filepath related members + self._setFilepath(filepath) # Create graph edges by resolving attributes expressions self._applyExpr() @@ -896,7 +905,7 @@ def toDict(self): def asString(self): return str(self.toDict()) - def save(self, filepath=None): + def save(self, filepath=None, setupProjectFile=True): path = filepath or self._filepath if not path: raise ValueError("filepath must be specified for unsaved files.") @@ -920,7 +929,7 @@ def save(self, filepath=None): with open(path, 'w') as jsonFile: json.dump(data, jsonFile, indent=4) - if path != self._filepath: + if path != self._filepath and setupProjectFile: self._setFilepath(path) def _setFilepath(self, filepath): @@ -930,7 +939,9 @@ def _setFilepath(self, filepath): Args: filepath: the graph file path """ - assert os.path.isfile(filepath) + if not os.path.isfile(filepath): + self._unsetFilepath() + return if self._filepath == filepath: return @@ -942,6 +953,12 @@ def _setFilepath(self, filepath): self.cacheDir = os.path.join(os.path.abspath(os.path.dirname(filepath)), meshroom.core.cacheFolderName) self.filepathChanged.emit() + def _unsetFilepath(self): + self._filepath = "" + self.name = "" + self.cacheDir = meshroom.core.defaultCacheFolder + self.filepathChanged.emit() + def updateInternals(self, startNodes=None, force=False): nodes, edges = self.dfsOnFinish(startNodes=startNodes) for node in nodes: diff --git a/meshroom/multiview.py b/meshroom/multiview.py index 1474b222d2..52c105f1a6 100644 --- a/meshroom/multiview.py +++ b/meshroom/multiview.py @@ -14,17 +14,36 @@ def isImageFile(filepath): return os.path.splitext(filepath)[1].lower() in imageExtensions -def findImageFiles(folder): +def findImageFiles(folder, recursive=False): """ Return all files that are images in 'folder' based on their extensions. Args: - folder (str): the folder to look into + folder (str): folder to look into or list of folder/files Returns: - list: the list of image files. + list: the list of image files with a supported extension. """ - return [os.path.join(folder, filename) for filename in os.listdir(folder) if isImageFile(filename)] + inputFolders = [] + if isinstance(folder, (list, tuple)): + inputFolders = folder + else: + inputFolders.append(folder) + + output = [] + for currentFolder in inputFolders: + if os.path.isfile(currentFolder): + if isImageFile(currentFolder): + output.append(currentFolder) + continue + if recursive: + for root, directories, files in os.walk(currentFolder): + for filename in files: + if isImageFile(filename): + output.append(os.path.join(root, filename)) + else: + output.extend([os.path.join(currentFolder, filename) for filename in os.listdir(currentFolder) if isImageFile(filename)]) + return output def photogrammetry(inputImages=list(), inputViewpoints=list(), inputIntrinsics=list(), output=''): @@ -47,11 +66,12 @@ def photogrammetry(inputImages=list(), inputViewpoints=list(), inputIntrinsics=l cameraInit.viewpoints.extend(inputViewpoints) cameraInit.intrinsics.extend(inputIntrinsics) - if output: - texturing = mvsNodes[-1] - graph.addNewNode('Publish', output=output, inputFiles=[texturing.outputMesh, - texturing.outputMaterial, - texturing.outputTextures]) + if output: + texturing = mvsNodes[-1] + graph.addNewNode('Publish', output=output, inputFiles=[texturing.outputMesh, + texturing.outputMaterial, + texturing.outputTextures]) + return graph diff --git a/meshroom/ui/app.py b/meshroom/ui/app.py index 6ad13d3171..e8e22fc90c 100644 --- a/meshroom/ui/app.py +++ b/meshroom/ui/app.py @@ -56,6 +56,20 @@ class MeshroomApp(QApplication): """ Meshroom UI Application. """ def __init__(self, args): QtArgs = [args[0], '-style', 'fusion'] + args[1:] # force Fusion style by default + + parser = argparse.ArgumentParser(prog=args[0], description='Launch Meshroom UI.', add_help=True) + + parser.add_argument('project', metavar='PROJECT', type=str, nargs='?', + help='Meshroom project file (e.g. myProject.mg) or folder with images to reconstruct.') + parser.add_argument('-i', '--import', metavar='IMAGES/FOLDERS', type=str, nargs='*', + help='Import images or folder with images to reconstruct.') + parser.add_argument('-I', '--importRecursive', metavar='FOLDERS', type=str, nargs='*', + help='Import images to reconstruct from specified folder and sub-folders.') + parser.add_argument('-p', '--pipeline', metavar='MESHROOM_FILE', type=str, required=False, + help='Override the default Meshroom pipeline with this external graph.') + + args = parser.parse_args(args[1:]) + super(MeshroomApp, self).__init__(QtArgs) self.setOrganizationName('AliceVision') @@ -105,12 +119,29 @@ def __init__(self, args): # request any potential computation to stop on exit self.aboutToQuit.connect(r.stopChildThreads) - parser = argparse.ArgumentParser(prog=args[0], description='Launch Meshroom UI.') - parser.add_argument('--project', metavar='MESHROOM_FILE', type=str, required=False, - help='Meshroom project file (e.g. myProject.mg).') - args = parser.parse_args(args[1:]) + if args.pipeline: + # the pipeline from the command line has the priority + r.setDefaultPipeline(args.pipeline) + else: + # consider the environment variable + defaultPipeline = os.environ.get("MESHROOM_DEFAULT_PIPELINE", "") + if defaultPipeline: + r.setDefaultPipeline(args.pipeline) + + if args.project and not os.path.isfile(args.project): + raise RuntimeError( + "Meshroom Command Line Error: 'PROJECT' argument should be a Meshroom project file (.mg).\n" + "Invalid value: '{}'".format(args.project)) + if args.project: - r.loadUrl(QUrl.fromLocalFile(args.project)) + r.load(args.project) + + # import is a python keyword, so we have to access the attribute by a string + if getattr(args, "import", None): + r.importImagesFromFolder(getattr(args, "import"), recursive=False) + + if args.importRecursive: + r.importImagesFromFolder(args.importRecursive, recursive=True) self.engine.load(os.path.normpath(url)) diff --git a/meshroom/ui/graph.py b/meshroom/ui/graph.py index 952713fe6c..d0e8bd3dc3 100644 --- a/meshroom/ui/graph.py +++ b/meshroom/ui/graph.py @@ -254,6 +254,7 @@ def __init__(self, filepath='', parent=None): self._layout = GraphLayout(self) self._selectedNode = None self._hoveredNode = None + self._defaultPipelineFilepath = None if filepath: self.load(filepath) @@ -310,9 +311,13 @@ def stopChildThreads(self): self.stopExecution() self._chunksMonitor.stop() - def load(self, filepath): + def setDefaultPipeline(self, pipelineFilepath): + self._defaultPipelineFilepath = pipelineFilepath + self._graph.load(pipelineFilepath, setupProjectFile=False) + + def load(self, filepath, setupProjectFile=True): g = Graph('') - g.load(filepath) + g.load(filepath, setupProjectFile) if not os.path.exists(g.cacheDir): os.mkdir(g.cacheDir) self.setGraph(g) @@ -367,7 +372,7 @@ def stopExecution(self): def submit(self, node=None): """ Submit the graph to the default Submitter. If a node is specified, submit this node and its uncomputed predecessors. - Otherwise, submit the whole + Otherwise, submit the whole Notes: Default submitter is specified using the MESHROOM_DEFAULT_SUBMITTER environment variable. diff --git a/meshroom/ui/reconstruction.py b/meshroom/ui/reconstruction.py index 9ef09dadf4..2f37c381d3 100755 --- a/meshroom/ui/reconstruction.py +++ b/meshroom/ui/reconstruction.py @@ -195,11 +195,16 @@ def __init__(self, graphFilepath='', parent=None): @Slot() def new(self): """ Create a new photogrammetry pipeline. """ - self.setGraph(multiview.photogrammetry()) + if self._defaultPipelineFilepath: + # use the user-provided default photogrammetry project file + self.load(self._defaultPipelineFilepath, setupProjectFile=False) + else: + # default photogrammetry pipeline + self.setGraph(multiview.photogrammetry()) - def load(self, filepath): + def load(self, filepath, setupProjectFile=True): try: - super(Reconstruction, self).load(filepath) + super(Reconstruction, self).load(filepath, setupProjectFile) # warn about pre-release projects being automatically upgraded if Version(self._graph.fileReleaseVersion).major == "0": self.warning.emit(Message( @@ -344,7 +349,7 @@ def handleFilesDrop(self, drop, cameraInit): Fetching urls from dropEvent is generally expensive in QML/JS (bug ?). This method allows to reduce process time by doing it on Python side. """ - self.importImages(self.getImageFilesFromDrop(drop), cameraInit) + self.importImagesAsync(self.getImageFilesFromDrop(drop), cameraInit) @staticmethod def getImageFilesFromDrop(drop): @@ -359,7 +364,29 @@ def getImageFilesFromDrop(drop): images.append(localFile) return images - def importImages(self, images, cameraInit): + def importImagesFromFolder(self, path, recursive=False): + """ + + Args: + path: A path to a folder or file or a list of files/folders + recursive: List files in folders recursively. + + """ + images = [] + paths = [] + if isinstance(path, (list, tuple)): + paths = path + else: + paths.append(path) + for p in paths: + if os.path.isdir(p): # get folder content + images.extend(multiview.findImageFiles(p, recursive)) + elif multiview.isImageFile(p): + images.append(p) + if images: + self.buildIntrinsics(self.cameraInit, images) + + def importImagesAsync(self, images, cameraInit): """ Add the given list of images to the Reconstruction. """ # Start the process of updating views and intrinsics self.runAsync(self.buildIntrinsics, args=(cameraInit, images,))