diff --git a/bin/meshroom_photogrammetry b/bin/meshroom_photogrammetry index c5ac365e..b7d8fecc 100755 --- a/bin/meshroom_photogrammetry +++ b/bin/meshroom_photogrammetry @@ -10,7 +10,7 @@ meshroom.setupEnvironment() import meshroom.core.graph from meshroom import multiview -parser = argparse.ArgumentParser(description='Launch the full photogrammetry pipeline.') +parser = argparse.ArgumentParser(description='Launch the full photogrammetry or HDRI pipeline.') 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) ' @@ -19,9 +19,8 @@ parser.add_argument('-I', '--inputRecursive', metavar='FOLDERS/IMAGES', type=str default=[], help='Input folders containing all images recursively.') -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. ' +parser.add_argument('-p', '--pipeline', metavar='photogrammetry/hdri/MG_FILE', type=str, default='photogrammetry', + help='"photogrammetry" pipeline, "hdri" pipeline or a Meshroom file containing a custom pipeline to run on input images. ' 'Requirements: the graph must contain one CameraInit node, ' 'and one Publish node if --output is set.') @@ -60,6 +59,13 @@ parser.add_argument('--forceStatus', help='Force computation if status is RUNNIN parser.add_argument('--forceCompute', help='Compute in all cases even if already computed.', action='store_true') +parser.add_argument('--submit', help='Submit on renderfarm instead of local computation.', + action='store_true') +parser.add_argument('--submitter', + type=str, + default='SimpleFarm', + help='Execute job with a specific submitter.') + args = parser.parse_args() @@ -78,7 +84,7 @@ if not args.input and not args.inputRecursive: views, intrinsics = [], [] # Build image files list from inputImages arguments -images = [] +filesByType = multiview.FilesByType() hasSearchedForImages = False @@ -88,21 +94,32 @@ if args.input: from meshroom.nodes.aliceVision.CameraInit import readSfMData views, intrinsics = readSfMData(args.input[0]) else: - images += multiview.findImageFiles(args.input, recursive=False) + filesByType.extend(multiview.findFilesByTypeInFolder(args.input, recursive=False)) hasSearchedForImages = True if args.inputRecursive: - images += multiview.findImageFiles(args.inputRecursive, recursive=True) + filesByType.extend(multiview.findFilesByTypeInFolder(args.inputRecursive, recursive=True)) hasSearchedForImages = True -if hasSearchedForImages and not images: +if hasSearchedForImages and not filesByType.images: print("No image found") exit(-1) -# initialize photogrammetry pipeline -if args.pipeline: - # custom pipeline - graph = meshroom.core.graph.loadGraph(args.pipeline) +graph = multiview.Graph(name=args.pipeline) + +with multiview.GraphModification(graph): + # initialize photogrammetry pipeline + if args.pipeline.lower() == "photogrammetry": + # default photogrammetry pipeline + multiview.photogrammetry(inputViewpoints=views, inputIntrinsics=intrinsics, output=args.output, graph=graph) + elif args.pipeline.lower() == "hdri": + # default hdri pipeline + graph = multiview.hdri(inputViewpoints=views, inputIntrinsics=intrinsics, output=args.output, graph=graph) + else: + # custom pipeline + graph.load(args.pipeline) + # graph.update() + cameraInit = getOnlyNodeOfType(graph, 'CameraInit') # reset graph inputs cameraInit.viewpoints.resetValue() @@ -117,59 +134,55 @@ if args.pipeline: if args.output: publish = getOnlyNodeOfType(graph, 'Publish') publish.output.value = args.output -else: - # default pipeline - graph = multiview.photogrammetry(inputViewpoints=views, inputIntrinsics=intrinsics, output=args.output) - cameraInit = getOnlyNodeOfType(graph, 'CameraInit') -if images: - views, intrinsics = cameraInit.nodeDesc.buildIntrinsics(cameraInit, images) - cameraInit.viewpoints.value = views - cameraInit.intrinsics.value = intrinsics + if filesByType.images: + views, intrinsics = cameraInit.nodeDesc.buildIntrinsics(cameraInit, filesByType.images) + cameraInit.viewpoints.value = views + cameraInit.intrinsics.value = intrinsics -if args.overrides: - import io - import json - with io.open(args.overrides, 'r', encoding='utf-8', errors='ignore') as f: - data = json.load(f) - for nodeName, overrides in data.items(): - for attrName, value in overrides.items(): - graph.findNode(nodeName).attribute(attrName).value = value + if args.overrides: + import io + import json + with io.open(args.overrides, 'r', encoding='utf-8', errors='ignore') as f: + data = json.load(f) + for nodeName, overrides in data.items(): + 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: + 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)) - 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") + 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 + # setup DepthMap downscaling + if args.scale > 0: + for node in graph.nodesByType('DepthMap'): + node.downscale.value = args.scale -# setup cache directory -graph.cacheDir = args.cache if args.cache else meshroom.core.defaultCacheFolder + # setup cache directory + graph.cacheDir = args.cache if args.cache else meshroom.core.defaultCacheFolder -if args.save: - graph.save(args.save, setupProjectFile=not bool(args.cache)) - print('File successfully saved: "{}"'.format(args.save)) + if args.save: + graph.save(args.save, setupProjectFile=not bool(args.cache)) + print('File successfully saved: "{}"'.format(args.save)) if not args.output: print('No output set, results will be available in the cache folder: "{}"'.format(graph.cacheDir)) @@ -177,6 +190,11 @@ if not args.output: # find end nodes (None will compute all graph) toNodes = graph.findNodes(args.toNode) if args.toNode else None -if args.compute: +if args.submit: + if not args.save: + raise ValueError('Need to save the project to file to submit on renderfarm.') + # submit on renderfarm + meshroom.core.graph.submit(args.save, args.submitter, toNode=toNodes) +elif 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 19511986..c1856e51 100644 --- a/meshroom/core/__init__.py +++ b/meshroom/core/__init__.py @@ -260,10 +260,7 @@ meshroomFolder = os.path.dirname(os.path.dirname(__file__)) # - Nodes loadAllNodes(folder=os.path.join(meshroomFolder, 'nodes')) # - Submitters -subs = loadSubmitters(meshroomFolder, 'submitters') -# - additional 3rd party submitters -if "MESHROOM_SUBMITTERS_PATH" in os.environ: - subs += loadSubmitters(os.environ["MESHROOM_SUBMITTERS_PATH"], 'submitters') +subs = loadSubmitters(os.environ.get("MESHROOM_SUBMITTERS_PATH", meshroomFolder), 'submitters') for sub in subs: registerSubmitter(sub()) diff --git a/meshroom/core/desc.py b/meshroom/core/desc.py index a9a96a10..be6f52e5 100755 --- a/meshroom/core/desc.py +++ b/meshroom/core/desc.py @@ -318,13 +318,14 @@ class DynamicNodeSize(object): def computeSize(self, node): param = node.attribute(self._param) - assert param.isInput # Link: use linked node's size if param.isLink: return param.getLinkParam().node.size # ListAttribute: use list size if isinstance(param.desc, ListAttribute): return len(param) + if isinstance(param.desc, IntParam): + return param.value return 1 @@ -383,7 +384,26 @@ class Node(object): def __init__(self): pass - def updateInternals(self, node): + @classmethod + def update(cls, node): + """ Method call before node's internal update on invalidation. + + Args: + node: the BaseNode instance being updated + See Also: + BaseNode.updateInternals + """ + pass + + @classmethod + def postUpdate(cls, node): + """ Method call after node's internal update on invalidation. + + Args: + node: the BaseNode instance being updated + See Also: + NodeBase.updateInternals + """ pass def stopProcess(self, chunk): diff --git a/meshroom/core/node.py b/meshroom/core/node.py index ca1874f1..6fc1ce92 100644 --- a/meshroom/core/node.py +++ b/meshroom/core/node.py @@ -502,6 +502,11 @@ class BaseNode(BaseObject): for name, attr in self._attributes.objects.items(): if attr.isInput: continue # skip inputs + + # Only consider File attributes for command output parameters + if not isinstance(attr.attributeDesc, desc.File): + continue + attr.value = attr.attributeDesc.value.format(**self._cmdVars) attr._invalidationValue = attr.attributeDesc.value.format(**cmdVarsNoCache) v = attr.getValueStr() @@ -599,6 +604,8 @@ class BaseNode(BaseObject): Args: cacheDir (str): (optional) override graph's cache directory with custom path """ + if self.nodeDesc: + self.nodeDesc.update(self) # Update chunks splitting self._updateChunks() # Retrieve current internal folder (if possible) @@ -613,6 +620,8 @@ class BaseNode(BaseObject): } self._computeUids() self._buildCmdVars() + if self.nodeDesc: + self.nodeDesc.postUpdate(self) # Notify internal folder change if needed if self.internalFolder != folder: self.internalFolderChanged.emit() diff --git a/meshroom/multiview.py b/meshroom/multiview.py index b72011b3..99983a66 100644 --- a/meshroom/multiview.py +++ b/meshroom/multiview.py @@ -6,15 +6,53 @@ import os from meshroom.core.graph import Graph, GraphModification # Supported image extensions -imageExtensions = ('.jpg', '.jpeg', '.tif', '.tiff', '.png', '.exr', '.rw2', '.cr2', '.nef', '.arw', '.dng') +imageExtensions = ('.jpg', '.jpeg', '.tif', '.tiff', '.png', '.exr', '.rw2', '.cr2', '.nef', '.arw') +videoExtensions = ('.avi', '.mov', '.qt', + '.mkv', '.webm', + '.mp4', '.mpg', '.mpeg', '.m2v', '.m4v', + '.wmv', + '.ogv', '.ogg', + '.mxf') +panoramaInfoExtensions = ('.xml') -def isImageFile(filepath): - """ Return whether filepath is a path to an image file supported by Meshroom. """ - return os.path.splitext(filepath)[1].lower() in imageExtensions +def hasExtension(filepath, extensions): + """ Return whether filepath is one of the following extensions. """ + return os.path.splitext(filepath)[1].lower() in extensions -def findImageFiles(folder, recursive=False): +class FilesByType: + def __init__(self): + self.images = [] + self.videos = [] + self.panoramaInfo = [] + self.other = [] + + def __bool__(self): + return self.images or self.videos or self.panoramaInfo + + def extend(self, other): + self.images.extend(other.images) + self.videos.extend(other.videos) + self.panoramaInfo.extend(other.panoramaInfo) + self.other.extend(other.other) + + def addFile(self, file): + if hasExtension(file, imageExtensions): + self.images.append(file) + elif hasExtension(file, videoExtensions): + self.videos.append(file) + elif hasExtension(file, panoramaInfoExtensions): + self.panoramaInfo.append(file) + else: + self.other.append(file) + + def addFiles(self, files): + for file in files: + self.addFile(file) + + +def findFilesByTypeInFolder(folder, recursive=False): """ Return all files that are images in 'folder' based on their extensions. @@ -30,23 +68,111 @@ def findImageFiles(folder, recursive=False): else: inputFolders.append(folder) - output = [] + output = FilesByType() for currentFolder in inputFolders: if os.path.isfile(currentFolder): - if isImageFile(currentFolder): - output.append(currentFolder) + output.addFile(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)) + elif os.path.isdir(currentFolder): + if recursive: + for root, directories, files in os.walk(currentFolder): + for filename in files: + output.addFile(os.path.join(root, filename)) + else: + output.addFiles([os.path.join(currentFolder, filename) for filename in os.listdir(currentFolder)]) else: - output.extend([os.path.join(currentFolder, filename) for filename in os.listdir(currentFolder) if isImageFile(filename)]) + # if not a diretory or a file, it may be an expression + import glob + paths = glob.glob(currentFolder) + filesByType = findFilesByTypeInFolder(paths, recursive=recursive) + output.extend(filesByType) + return output -def photogrammetry(inputImages=list(), inputViewpoints=list(), inputIntrinsics=list(), output=''): +def hdri(inputImages=list(), inputViewpoints=list(), inputIntrinsics=list(), output='', graph=None): + """ + Create a new Graph with a complete HDRI pipeline. + + Args: + inputImages (list of str, optional): list of image file paths + inputViewpoints (list of Viewpoint, optional): list of Viewpoints + output (str, optional): the path to export reconstructed model to + + Returns: + Graph: the created graph + """ + if not graph: + graph = Graph('HDRI') + with GraphModification(graph): + nodes = hdriPipeline(graph) + cameraInit = nodes[0] + cameraInit.viewpoints.extend([{'path': image} for image in inputImages]) + cameraInit.viewpoints.extend(inputViewpoints) + cameraInit.intrinsics.extend(inputIntrinsics) + + if output: + stitching = nodes[-1] + graph.addNewNode('Publish', output=output, inputFiles=[stitching.output]) + + return graph + + +def hdriPipeline(graph): + """ + Instantiate an HDRI pipeline inside 'graph'. + Args: + graph (Graph/UIGraph): the graph in which nodes should be instantiated + + Returns: + list of Node: the created nodes + """ + cameraInit = graph.addNewNode('CameraInit') + + ldr2hdr = graph.addNewNode('LDRToHDR', + input=cameraInit.output) + + featureExtraction = graph.addNewNode('FeatureExtraction', + input=ldr2hdr.outSfMDataFilename) + featureExtraction.describerPreset.value = 'ultra' + imageMatching = graph.addNewNode('ImageMatching', + input=featureExtraction.input, + featuresFolders=[featureExtraction.output]) + featureMatching = graph.addNewNode('FeatureMatching', + input=imageMatching.input, + featuresFolders=imageMatching.featuresFolders, + imagePairsList=imageMatching.output) + + panoramaExternalInfo = graph.addNewNode('PanoramaExternalInfo', + input=ldr2hdr.outSfMDataFilename, + matchesFolders=[featureMatching.output] # Workaround for tractor submission with a fake dependency + ) + + panoramaEstimation = graph.addNewNode('PanoramaEstimation', + input=panoramaExternalInfo.outSfMDataFilename, + featuresFolders=featureMatching.featuresFolders, + matchesFolders=[featureMatching.output]) + + panoramaWarping = graph.addNewNode('PanoramaWarping', + input=panoramaEstimation.outSfMDataFilename) + + panoramaCompositing = graph.addNewNode('PanoramaCompositing', + input=panoramaWarping.output) + + return [ + cameraInit, + featureExtraction, + imageMatching, + featureMatching, + panoramaExternalInfo, + panoramaEstimation, + panoramaWarping, + panoramaCompositing, + ] + + + +def photogrammetry(inputImages=list(), inputViewpoints=list(), inputIntrinsics=list(), output='', graph=None): """ Create a new Graph with a complete photogrammetry pipeline. @@ -58,7 +184,8 @@ def photogrammetry(inputImages=list(), inputViewpoints=list(), inputIntrinsics=l Returns: Graph: the created graph """ - graph = Graph('Photogrammetry') + if not graph: + graph = Graph('Photogrammetry') with GraphModification(graph): sfmNodes, mvsNodes = photogrammetryPipeline(graph) cameraInit = sfmNodes[0] diff --git a/meshroom/nodes/aliceVision/CameraDownscale.py b/meshroom/nodes/aliceVision/CameraDownscale.py new file mode 100644 index 00000000..894c3cc3 --- /dev/null +++ b/meshroom/nodes/aliceVision/CameraDownscale.py @@ -0,0 +1,49 @@ +__version__ = "1.0" + +import json +import os + +from meshroom.core import desc + + +class CameraDownscale(desc.CommandLineNode): + commandLine = 'aliceVision_cameraDownscale {allParams}' + size = desc.DynamicNodeSize('input') + + inputs = [ + desc.File( + name='input', + label='Input', + description="SfM Data File", + value='', + uid=[0], + ), + desc.FloatParam( + name='rescalefactor', + label='RescaleFactor', + description='Newsize = rescalefactor * oldsize', + value=0.5, + range=(0.0, 1.0, 0.1), + uid=[0], + advanced=True, + ), + desc.ChoiceParam( + name='verboseLevel', + label='Verbose Level', + description='Verbosity level (fatal, error, warning, info, debug, trace).', + value='info', + values=['fatal', 'error', 'warning', 'info', 'debug', 'trace'], + exclusive=True, + uid=[], + ), + ] + + outputs = [ + desc.File( + name='outSfMDataFilename', + label='Output SfMData File', + description='Path to the output sfmdata file', + value=desc.Node.internalFolder + 'sfmData.abc', + uid=[], + ) + ] diff --git a/meshroom/nodes/aliceVision/CameraInit.py b/meshroom/nodes/aliceVision/CameraInit.py index b8196638..3dbe003b 100644 --- a/meshroom/nodes/aliceVision/CameraInit.py +++ b/meshroom/nodes/aliceVision/CameraInit.py @@ -186,7 +186,7 @@ class CameraInit(desc.CommandLineNode): # logging.debug(' - commandLine:', cmd) proc = psutil.Popen(cmd, stdout=None, stderr=None, shell=True) stdout, stderr = proc.communicate() - proc.wait() + # proc.wait() if proc.returncode != 0: raise RuntimeError('CameraInit failed with error code {}.\nCommand was: "{}".\n'.format( proc.returncode, cmd) diff --git a/meshroom/nodes/aliceVision/ExportMatches.py b/meshroom/nodes/aliceVision/ExportMatches.py new file mode 100644 index 00000000..8df3b391 --- /dev/null +++ b/meshroom/nodes/aliceVision/ExportMatches.py @@ -0,0 +1,71 @@ +__version__ = "1.1" + +from meshroom.core import desc + + +class ExportMatches(desc.CommandLineNode): + commandLine = 'aliceVision_exportMatches {allParams}' + size = desc.DynamicNodeSize('input') + + inputs = [ + desc.File( + name='input', + label='Input', + description='SfMData file.', + value='', + uid=[0], + ), + desc.ChoiceParam( + name='describerTypes', + label='Describer Types', + description='Describer types used to describe an image.', + value=['sift'], + values=['sift', 'sift_float', 'sift_upright', 'akaze', 'akaze_liop', 'akaze_mldb', 'cctag3', 'cctag4', 'sift_ocv', 'akaze_ocv'], + exclusive=False, + uid=[0], + joinChar=',', + ), + desc.ListAttribute( + elementDesc=desc.File( + name="featuresFolder", + label="Features Folder", + description="", + value="", + uid=[0], + ), + name="featuresFolders", + label="Features Folders", + description="Folder(s) containing the extracted features and descriptors." + ), + desc.ListAttribute( + elementDesc=desc.File( + name="matchesFolder", + label="Matches Folder", + description="", + value="", + uid=[0], + ), + name="matchesFolders", + label="Matches Folders", + description="Folder(s) in which computed matches are stored." + ), + desc.ChoiceParam( + name='verboseLevel', + label='Verbose Level', + description='verbosity level (fatal, error, warning, info, debug, trace).', + value='info', + values=['fatal', 'error', 'warning', 'info', 'debug', 'trace'], + exclusive=True, + uid=[], + ) + ] + + outputs = [ + desc.File( + name='output', + label='Output Folder', + description='Output path for the features and descriptors files (*.feat, *.desc).', + value=desc.Node.internalFolder, + uid=[], + ), + ] diff --git a/meshroom/nodes/aliceVision/GlobalSfM.py b/meshroom/nodes/aliceVision/GlobalSfM.py new file mode 100644 index 00000000..a60b7a78 --- /dev/null +++ b/meshroom/nodes/aliceVision/GlobalSfM.py @@ -0,0 +1,114 @@ +__version__ = "1.0" + +import json +import os + +from meshroom.core import desc + + +class GlobalSfM(desc.CommandLineNode): + commandLine = 'aliceVision_globalSfM {allParams}' + size = desc.DynamicNodeSize('input') + + inputs = [ + desc.File( + name='input', + label='Input', + description="SfM Data File", + value='', + uid=[0], + ), + desc.ListAttribute( + elementDesc=desc.File( + name='featuresFolder', + label='Features Folder', + description="", + value='', + uid=[0], + ), + name='featuresFolders', + label='Features Folders', + description="Folder(s) containing the extracted features." + ), + desc.ListAttribute( + elementDesc=desc.File( + name='matchesFolder', + label='Matches Folder', + description="", + value='', + uid=[0], + ), + name='matchesFolders', + label='Matches Folders', + description="Folder(s) in which computed matches are stored." + ), + desc.ChoiceParam( + name='describerTypes', + label='Describer Types', + description='Describer types used to describe an image.', + value=['sift'], + values=['sift', 'sift_float', 'sift_upright', 'akaze', 'akaze_liop', 'akaze_mldb', 'cctag3', 'cctag4', + 'sift_ocv', 'akaze_ocv'], + exclusive=False, + uid=[0], + joinChar=',', + ), + desc.ChoiceParam( + name='rotationAveraging', + label='Rotation Averaging Method', + description="Method for rotation averaging :\n" + " * L1 minimization\n" + " * L2 minimization\n", + values=['L1_minimization', 'L2_minimization'], + value='L2_minimization', + exclusive=True, + uid=[0], + ), + desc.ChoiceParam( + name='translationAveraging', + label='Translation Averaging Method', + description="Method for translation averaging :\n" + " * L1 minimization\n" + " * L2 minimization of sum of squared Chordal distances\n" + " * L1 soft minimization", + values=['L1_minimization', 'L2_minimization', 'L1_soft_minimization'], + value='L1_soft_minimization', + exclusive=True, + uid=[0], + ), + desc.BoolParam( + name='lockAllIntrinsics', + label='Force Lock of All Intrinsic Camera Parameters.', + description='Force to keep constant all the intrinsics parameters of the cameras (focal length, \n' + 'principal point, distortion if any) during the reconstruction.\n' + 'This may be helpful if the input cameras are already fully calibrated.', + value=False, + uid=[0], + ), + desc.ChoiceParam( + name='verboseLevel', + label='Verbose Level', + description='Verbosity level (fatal, error, warning, info, debug, trace).', + value='info', + values=['fatal', 'error', 'warning', 'info', 'debug', 'trace'], + exclusive=True, + uid=[], + ) + ] + + outputs = [ + desc.File( + name='output', + label='Output Folder', + description='', + value=desc.Node.internalFolder, + uid=[], + ), + desc.File( + name='outSfMDataFilename', + label='Output SfMData File', + description='Path to the output sfmdata file', + value=desc.Node.internalFolder + 'SfmData.abc', + uid=[], + ), + ] diff --git a/meshroom/nodes/aliceVision/HDRIstitching.py b/meshroom/nodes/aliceVision/HDRIstitching.py new file mode 100644 index 00000000..af81410e --- /dev/null +++ b/meshroom/nodes/aliceVision/HDRIstitching.py @@ -0,0 +1,89 @@ +__version__ = "1.0" + +from meshroom.core import desc + + +class HDRIstitching(desc.CommandLineNode): + commandLine = 'aliceVision_utils_fisheyeProjection {allParams}' + + inputs = [ + desc.ListAttribute( + elementDesc=desc.File( + name='inputFile', + label='Input File/Folder', + description="", + value='', + uid=[0], + ), + name='input', + label='Input Folder', + description="List of fisheye images or folder containing them." + ), + desc.FloatParam( + name='blurWidth', + label='Blur Width', + description="Blur width of alpha channel for all fisheye (between 0 and 1). \n" + "Determine the transitions sharpness.", + value=0.2, + range=(0, 1, 0.1), + uid=[0], + ), + desc.ListAttribute( + elementDesc=desc.FloatParam( + name='imageXRotation', + label='Image X Rotation', + description="", + value=0, + range=(-20, 20, 1), + uid=[0], + ), + name='xRotation', + label='X Rotations', + description="Rotations in degree on axis X (horizontal axis) for each image.", + ), + desc.ListAttribute( + elementDesc=desc.FloatParam( + name='imageYRotation', + label='Image Y Rotation', + description="", + value=0, + range=(-30, 30, 5), + uid=[0], + ), + name='yRotation', + label='Y Rotations', + description="Rotations in degree on axis Y (vertical axis) for each image.", + ), + desc.ListAttribute( + elementDesc=desc.FloatParam( + name='imageZRotation', + label='Image Z Rotation', + description="", + value=0, + range=(-10, 10, 1), + uid=[0], + ), + name='zRotation', + label='Z Rotations', + description="Rotations in degree on axis Z (depth axis) for each image.", + ), + desc.ChoiceParam( + name='verboseLevel', + label='Verbose Level', + description="Verbosity level (fatal, error, warning, info, debug, trace).", + value='info', + values=['fatal', 'error', 'warning', 'info', 'debug', 'trace'], + exclusive=True, + uid=[], + ), + ] + + outputs = [ + desc.File( + name='output', + label='Output Panorama', + description="Output folder for panorama", + value=desc.Node.internalFolder, + uid=[], + ), + ] \ No newline at end of file diff --git a/meshroom/nodes/aliceVision/LDRToHDR.py b/meshroom/nodes/aliceVision/LDRToHDR.py index ad36c17d..9da8f57f 100644 --- a/meshroom/nodes/aliceVision/LDRToHDR.py +++ b/meshroom/nodes/aliceVision/LDRToHDR.py @@ -1,23 +1,93 @@ -__version__ = "1.0" +__version__ = "2.0" + +import json +import os from meshroom.core import desc +class DividedInputNodeSize(desc.DynamicNodeSize): + """ + The LDR2HDR will reduce the amount of views in the SfMData. + This class converts the number of LDR input views into the number of HDR output views. + """ + def __init__(self, param, divParam): + super(DividedInputNodeSize, self).__init__(param) + self._divParam = divParam + def computeSize(self, node): + s = super(DividedInputNodeSize, self).computeSize(node) + divParam = node.attribute(self._divParam) + if divParam.value == 0: + return s + return s / divParam.value + + class LDRToHDR(desc.CommandLineNode): commandLine = 'aliceVision_convertLDRToHDR {allParams}' + size = DividedInputNodeSize('input', 'nbBrackets') + + cpu = desc.Level.INTENSIVE + ram = desc.Level.NORMAL inputs = [ - desc.ListAttribute( - elementDesc=desc.File( - name='inputFolder', - label='Input File/Folder', - description="Folder containing LDR images", - value='', - uid=[0], - ), - name="input", - label="Input Files or Folders", - description='Folders containing LDR images.', + desc.File( + name='input', + label='Input', + description="SfM Data File", + value='', + uid=[0], + ), + desc.IntParam( + name='userNbBrackets', + label='Number of Brackets', + description='Number of exposure brackets per HDR image (0 for automatic).', + value=0, + range=(0, 15, 1), + uid=[0], + group='user', # not used directly on the command line + ), + desc.IntParam( + name='nbBrackets', + label='Automatic Nb Brackets', + description='Number of exposure brackets used per HDR image. It is detected automatically from input Viewpoints metadata if "userNbBrackets" is 0, else it is equal to "userNbBrackets".', + value=0, + range=(0, 10, 1), + uid=[], + advanced=True, + ), + desc.FloatParam( + name='highlightCorrectionFactor', + label='Highlights Correction', + description='Pixels saturated in all input images have a partial information about their real luminance.\n' + 'We only know that the value should be >= to the standard hdr fusion.\n' + 'This parameter allows to perform a post-processing step to put saturated pixels to a constant ' + 'value defined by the `highlightsMaxLuminance` parameter.\n' + 'This parameter is float to enable to weight this correction.', + value=1.0, + range=(0.0, 1.0, 0.01), + uid=[0], + ), + desc.FloatParam( + name='highlightTargetLux', + label='Highlight Target Luminance (Lux)', + description='This is an arbitrary target value (in Lux) used to replace the unknown luminance value of the saturated pixels.\n' + '\n' + 'Some Outdoor Reference Light Levels:\n' + ' * 120,000 lux : Brightest sunlight\n' + ' * 110,000 lux : Bright sunlight\n' + ' * 20,000 lux : Shade illuminated by entire clear blue sky, midday\n' + ' * 1,000 lux : Typical overcast day, midday\n' + ' * 400 lux : Sunrise or sunset on a clear day\n' + ' * 40 lux : Fully overcast, sunset/sunrise\n' + '\n' + 'Some Indoor Reference Light Levels:\n' + ' * 20000 lux : Max Usually Used Indoor\n' + ' * 750 lux : Supermarkets\n' + ' * 500 lux : Office Work\n' + ' * 150 lux : Home\n', + value=120000.0, + range=(1000.0, 150000.0, 1.0), + uid=[0], ), desc.BoolParam( name='fisheyeLens', @@ -25,7 +95,21 @@ class LDRToHDR(desc.CommandLineNode): description="Enable if a fisheye lens has been used.\n " "This will improve the estimation of the Camera's Response Function by considering only the pixels in the center of the image\n" "and thus ignore undefined/noisy pixels outside the circle defined by the fisheye lens.", - value=True, + value=False, + uid=[0], + ), + desc.BoolParam( + name='calibrationRefineExposures', + label='Refine Exposures', + description="Refine exposures provided by metadata (shutter speed, f-number, iso). Only available for 'laguerre' calibration method.", + value=False, + uid=[0], + ), + desc.BoolParam( + name='byPass', + label='bypass convert', + description="Bypass HDR creation and use the medium bracket as the source for the next steps", + value=False, uid=[0], ), desc.ChoiceParam( @@ -35,26 +119,13 @@ class LDRToHDR(desc.CommandLineNode): " * linear \n" " * robertson \n" " * debevec \n" - " * grossberg", - values=['linear', 'robertson', 'debevec', 'grossberg'], - value='linear', + " * grossberg \n" + " * laguerre", + values=['linear', 'robertson', 'debevec', 'grossberg', 'laguerre'], + value='debevec', exclusive=True, uid=[0], ), - desc.File( - name='inputResponse', - label='Input Response', - description="external camera response file path to fuse all LDR images together.", - value='', - uid=[0], - ), - desc.StringParam( - name='targetExposureImage', - label='Target Exposure Image', - description="LDR image(s) name(s) at the target exposure for the output HDR image(s) to be centered.", - value='', - uid=[0], - ), desc.ChoiceParam( name='calibrationWeight', label='Calibration Weight', @@ -73,56 +144,116 @@ class LDRToHDR(desc.CommandLineNode): label='Fusion Weight', description="Weight function used to fuse all LDR images together \n" " * gaussian \n" - " * triangle \n" + " * triangle \n" " * plateau", value='gaussian', values=['gaussian', 'triangle', 'plateau'], exclusive=True, uid=[0], ), - desc.FloatParam( - name='expandDynamicRange', - label='Expand Dynamic Range', - description="Correction of clamped high values in dynamic range: \n" - " - use 0 for no correction \n" - " - use 0.5 for interior lighting \n" - " - use 1 for outdoor lighting", - value=1, - range=(0, 1, 0.1), + desc.IntParam( + name='calibrationNbPoints', + label='Calibration Nb Points', + description='Internal number of points used for calibration.', + value=0, + range=(0, 10000000, 1000), uid=[0], + advanced=True, + ), + desc.IntParam( + name='calibrationDownscale', + label='Calibration Downscale', + description='Scaling factor applied to images before calibration of the response function to reduce the impact of misalignment.', + value=4, + range=(1, 16, 1), + uid=[0], + advanced=True, + ), + desc.IntParam( + name='channelQuantizationPower', + label='Channel Quantization Power', + description='Quantization level like 8 bits or 10 bits.', + value=10, + range=(8, 14, 1), + uid=[0], + advanced=True, ), desc.ChoiceParam( name='verboseLevel', label='Verbose Level', - description="Verbosity level (fatal, error, warning, info, debug, trace).", + description='Verbosity level (fatal, error, warning, info, debug, trace).', value='info', values=['fatal', 'error', 'warning', 'info', 'debug', 'trace'], exclusive=True, uid=[], ), - desc.File( - name='recoverPath', - label='Output Recovered Files', - description="(debug) Folder for recovered LDR images at target exposures.", - advanced=True, - value='', - uid=[], - ), ] outputs = [ desc.File( - name='output', - label='Output Folder', - description="Output folder for HDR images", - value=desc.Node.internalFolder, + name='outSfMDataFilename', + label='Output SfMData File', + description='Path to the output sfmdata file', + value=desc.Node.internalFolder + 'sfmData.abc', uid=[], - ), - desc.File( - name='outputResponse', - label='Output Response', - description="Output response function path.", - value=desc.Node.internalFolder + 'response.csv', - uid=[], - ), + ) ] + + @classmethod + def update(cls, node): + if not isinstance(node.nodeDesc, cls): + raise ValueError("Node {} is not an instance of type {}".format(node, cls)) + # TODO: use Node version for this test + if 'userNbBrackets' not in node.getAttributes().keys(): + # Old version of the node + return + if node.userNbBrackets.value != 0: + node.nbBrackets.value = node.userNbBrackets.value + return + # logging.info("[LDRToHDR] Update start: version:" + str(node.packageVersion)) + cameraInitOutput = node.input.getLinkParam() + if not cameraInitOutput: + node.nbBrackets.value = 0 + return + viewpoints = cameraInitOutput.node.viewpoints.value + + # logging.info("[LDRToHDR] Update start: nb viewpoints:" + str(len(viewpoints))) + inputs = [] + for viewpoint in viewpoints: + jsonMetadata = viewpoint.metadata.value + if not jsonMetadata: + # no metadata, we cannot found the number of brackets + node.nbBrackets.value = 0 + return + d = json.loads(jsonMetadata) + fnumber = d.get("FNumber", d.get("Exif:ApertureValue", "")) + shutterSpeed = d.get("Exif:ShutterSpeedValue", "") # also "ExposureTime"? + iso = d.get("Exif:ISOSpeedRatings", "") + if not fnumber and not shutterSpeed: + # if one image without shutter or fnumber, we cannot found the number of brackets + node.nbBrackets.value = 0 + return + inputs.append((viewpoint.path.value, (fnumber, shutterSpeed, iso))) + inputs.sort() + + exposureGroups = [] + exposures = [] + for path, exp in inputs: + if exposures and exp != exposures[-1] and exp == exposures[0]: + exposureGroups.append(exposures) + exposures = [exp] + else: + exposures.append(exp) + exposureGroups.append(exposures) + exposures = None + bracketSizes = set() + for expGroup in exposureGroups: + bracketSizes.add(len(expGroup)) + if len(bracketSizes) == 1: + node.nbBrackets.value = bracketSizes.pop() + # logging.info("[LDRToHDR] nb bracket size:" + str(node.nbBrackets.value)) + else: + node.nbBrackets.value = 0 + # logging.info("[LDRToHDR] Update end") + + diff --git a/meshroom/nodes/aliceVision/PanoramaCompositing.py b/meshroom/nodes/aliceVision/PanoramaCompositing.py new file mode 100644 index 00000000..34af53ad --- /dev/null +++ b/meshroom/nodes/aliceVision/PanoramaCompositing.py @@ -0,0 +1,59 @@ +__version__ = "1.0" + +import json +import os + +from meshroom.core import desc + + +class PanoramaCompositing(desc.CommandLineNode): + commandLine = 'aliceVision_panoramaCompositing {allParams}' + size = desc.DynamicNodeSize('input') + + inputs = [ + desc.File( + name='input', + label='Input', + description="Panorama Warping result", + value='', + uid=[0], + ), + desc.ChoiceParam( + name='outputFileType', + label='Output File Type', + description='Output file type for the undistorted images.', + value='exr', + values=['jpg', 'png', 'tif', 'exr'], + exclusive=True, + uid=[0], + group='', # not part of allParams, as this is not a parameter for the command line + ), + desc.ChoiceParam( + name='compositerType', + label='Compositer Type', + description='Which compositer should be used to blend images', + value='multiband', + values=['replace', 'alpha', 'multiband'], + exclusive=True, + uid=[0] + ), + desc.ChoiceParam( + name='verboseLevel', + label='Verbose Level', + description='Verbosity level (fatal, error, warning, info, debug, trace).', + value='info', + values=['fatal', 'error', 'warning', 'info', 'debug', 'trace'], + exclusive=True, + uid=[], + ), + ] + + outputs = [ + desc.File( + name='output', + label='Output Panorama', + description='', + value=desc.Node.internalFolder + 'panorama.{outputFileTypeValue}', + uid=[], + ), + ] diff --git a/meshroom/nodes/aliceVision/PanoramaEstimation.py b/meshroom/nodes/aliceVision/PanoramaEstimation.py new file mode 100644 index 00000000..6aaff58f --- /dev/null +++ b/meshroom/nodes/aliceVision/PanoramaEstimation.py @@ -0,0 +1,149 @@ +__version__ = "1.0" + +import json +import os + +from meshroom.core import desc + + +class PanoramaEstimation(desc.CommandLineNode): + commandLine = 'aliceVision_panoramaEstimation {allParams}' + size = desc.DynamicNodeSize('input') + + inputs = [ + desc.File( + name='input', + label='Input', + description="SfM Data File", + value='', + uid=[0], + ), + desc.ListAttribute( + elementDesc=desc.File( + name='featuresFolder', + label='Features Folder', + description="", + value='', + uid=[0], + ), + name='featuresFolders', + label='Features Folders', + description="Folder(s) containing the extracted features." + ), + desc.ListAttribute( + elementDesc=desc.File( + name='matchesFolder', + label='Matches Folder', + description="", + value='', + uid=[0], + ), + name='matchesFolders', + label='Matches Folders', + description="Folder(s) in which computed matches are stored." + ), + desc.ChoiceParam( + name='describerTypes', + label='Describer Types', + description='Describer types used to describe an image.', + value=['sift'], + values=['sift', 'sift_float', 'sift_upright', 'akaze', 'akaze_liop', 'akaze_mldb', 'cctag3', 'cctag4', + 'sift_ocv', 'akaze_ocv'], + exclusive=False, + uid=[0], + joinChar=',', + ), + desc.IntParam( + name='orientation', + label='Orientation', + description='Orientation', + value=0, + range=(0, 6, 1), + uid=[0], + advanced=True, + ), + desc.FloatParam( + name='offsetLongitude', + label='Longitude offset (deg.)', + description='''Offset to the panorama longitude''', + value=0.0, + range=(-180.0, 180.0, 1.0), + uid=[0], + advanced=True, + ), + desc.FloatParam( + name='offsetLatitude', + label='Latitude offset (deg.)', + description='''Offset to the panorama latitude''', + value=0.0, + range=(-90.0, 90.0, 1.0), + uid=[0], + advanced=True, + ), + desc.ChoiceParam( + name='rotationAveraging', + label='Rotation Averaging Method', + description="Method for rotation averaging :\n" + " * L1 minimization\n" + " * L2 minimization\n", + values=['L1_minimization', 'L2_minimization'], + value='L2_minimization', + exclusive=True, + uid=[0], + advanced=True, + ), + desc.ChoiceParam( + name='relativeRotation', + label='Relative Rotation Method', + description="Method for relative rotation :\n" + " * from essential matrix\n" + " * from homography matrix", + values=['essential_matrix', 'homography_matrix'], + value='homography_matrix', + exclusive=True, + uid=[0], + advanced=True, + ), + desc.BoolParam( + name='refine', + label='Refine', + description='Refine camera relative poses, points and optionally internal camera parameter', + value=True, + uid=[0], + ), + desc.BoolParam( + name='lockAllIntrinsics', + label='Force Lock of All Intrinsic Camera Parameters.', + description='Force to keep constant all the intrinsics parameters of the cameras (focal length, \n' + 'principal point, distortion if any) during the reconstruction.\n' + 'This may be helpful if the input cameras are already fully calibrated.', + value=False, + uid=[0], + ), + desc.ChoiceParam( + name='verboseLevel', + label='Verbose Level', + description='Verbosity level (fatal, error, warning, info, debug, trace).', + value='info', + values=['fatal', 'error', 'warning', 'info', 'debug', 'trace'], + exclusive=True, + uid=[], + ), + ] + + outputs = [ + desc.File( + name='output', + label='Output Folder', + description='', + value=desc.Node.internalFolder, + uid=[], + ), + desc.File( + name='outSfMDataFilename', + label='Output SfMData File', + description='Path to the output sfmdata file', + value=desc.Node.internalFolder + 'sfmData.abc', + uid=[], + ), + ] diff --git a/meshroom/nodes/aliceVision/PanoramaExternalInfo.py b/meshroom/nodes/aliceVision/PanoramaExternalInfo.py new file mode 100644 index 00000000..4fca9880 --- /dev/null +++ b/meshroom/nodes/aliceVision/PanoramaExternalInfo.py @@ -0,0 +1,60 @@ +__version__ = "1.0" + +import json +import os + +from meshroom.core import desc + + +class PanoramaExternalInfo(desc.CommandLineNode): + commandLine = 'aliceVision_panoramaExternalInfo {allParams}' + size = desc.DynamicNodeSize('input') + + inputs = [ + desc.File( + name='input', + label='Input', + description="SfM Data File", + value='', + uid=[0], + ), + desc.File( + name='config', + label='Xml Config', + description="XML Data File", + value='', + uid=[0], + ), + desc.ListAttribute( + elementDesc=desc.File( + name='matchesFolder', + label='Matches Folder', + description="", + value='', + uid=[0], + ), + name='matchesFolders', + label='Matches Folders', + description="Folder(s) in which computed matches are stored. (WORKAROUND for valid Tractor graph submission)", + group='forDependencyOnly', + ), + desc.ChoiceParam( + name='verboseLevel', + label='Verbose Level', + description='Verbosity level (fatal, error, warning, info, debug, trace).', + value='info', + values=['fatal', 'error', 'warning', 'info', 'debug', 'trace'], + exclusive=True, + uid=[], + ), + ] + + outputs = [ + desc.File( + name='outSfMDataFilename', + label='Output SfMData File', + description='Path to the output sfmdata file', + value=desc.Node.internalFolder + 'sfmData.abc', + uid=[], + ) + ] diff --git a/meshroom/nodes/aliceVision/PanoramaWarping.py b/meshroom/nodes/aliceVision/PanoramaWarping.py new file mode 100644 index 00000000..a127fe35 --- /dev/null +++ b/meshroom/nodes/aliceVision/PanoramaWarping.py @@ -0,0 +1,48 @@ +__version__ = "1.0" + +import json +import os + +from meshroom.core import desc + + +class PanoramaWarping(desc.CommandLineNode): + commandLine = 'aliceVision_panoramaWarping {allParams}' + size = desc.DynamicNodeSize('input') + + inputs = [ + desc.File( + name='input', + label='Input', + description="SfM Data File", + value='', + uid=[0], + ), + desc.IntParam( + name='panoramaWidth', + label='Panorama Width', + description='Panorama width (pixels). 0 For automatic size', + value=10000, + range=(0, 50000, 1000), + uid=[0] + ), + desc.ChoiceParam( + name='verboseLevel', + label='Verbose Level', + description='Verbosity level (fatal, error, warning, info, debug, trace).', + value='info', + values=['fatal', 'error', 'warning', 'info', 'debug', 'trace'], + exclusive=True, + uid=[], + ), + ] + + outputs = [ + desc.File( + name='output', + label='Output directory', + description='', + value=desc.Node.internalFolder, + uid=[], + ), + ] diff --git a/meshroom/nodes/aliceVision/Texturing.py b/meshroom/nodes/aliceVision/Texturing.py index 8fdf4b33..e812bb78 100644 --- a/meshroom/nodes/aliceVision/Texturing.py +++ b/meshroom/nodes/aliceVision/Texturing.py @@ -91,32 +91,6 @@ class Texturing(desc.CommandLineNode): uid=[0], advanced=True, ), - desc.BoolParam( - name='correctEV', - label='Correct Exposure', - description='Uniformize images exposure values.', - value=False, - uid=[0], - advanced=True, - ), - desc.BoolParam( - name='useScore', - label='Use Score', - description='Use triangles scores for multiband blending.', - value=True, - uid=[0], - advanced=True, - ), - desc.ChoiceParam( - name='processColorspace', - label='Process Colorspace', - description="Colorspace for the texturing internal computation (does not impact the output file colorspace).", - value='sRGB', - values=('sRGB', 'LAB', 'XYZ'), - exclusive=True, - uid=[0], - advanced=True, - ), desc.IntParam( name='multiBandDownscale', label='Multi Band Downscale', @@ -138,6 +112,14 @@ class Texturing(desc.CommandLineNode): description='''Number of contributions per frequency band for multiband blending (each frequency band also contributes to lower bands)''', advanced=True, ), + desc.BoolParam( + name='useScore', + label='Use Score', + description='Use triangles scores (ie. reprojection area) for multiband blending.', + value=True, + uid=[0], + advanced=True, + ), desc.FloatParam( name='bestScoreThreshold', label='Best Score Threshold', @@ -156,6 +138,23 @@ class Texturing(desc.CommandLineNode): uid=[0], advanced=True, ), + desc.ChoiceParam( + name='processColorspace', + label='Process Colorspace', + description="Colorspace for the texturing internal computation (does not impact the output file colorspace).", + value='sRGB', + values=('sRGB', 'LAB', 'XYZ'), + exclusive=True, + uid=[0], + advanced=True, + ), + desc.BoolParam( + name='correctEV', + label='Correct Exposure', + description='Uniformize images exposure values.', + value=False, + uid=[0], + ), desc.BoolParam( name='forceVisibleByAllVertices', label='Force Visible By All Vertices', @@ -182,6 +181,15 @@ class Texturing(desc.CommandLineNode): uid=[0], advanced=True, ), + desc.FloatParam( + name='subdivisionTargetRatio', + label='Subdivision Target Ratio', + description='''Percentage of the density of the reconstruction as the target for the subdivision (0: disable subdivision, 0.5: half density of the reconstruction, 1: full density of the reconstruction).''', + value=0.8, + range=(0.0, 1.0, 0.001), + uid=[0], + advanced=True, + ), desc.ChoiceParam( name='verboseLevel', label='Verbose Level', diff --git a/meshroom/ui/app.py b/meshroom/ui/app.py index e8e22fc9..b8c124d6 100644 --- a/meshroom/ui/app.py +++ b/meshroom/ui/app.py @@ -65,16 +65,31 @@ class MeshroomApp(QApplication): 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, + parser.add_argument('-s', '--save', metavar='PROJECT.mg', type=str, default='', + help='Save the created scene.') + parser.add_argument('-p', '--pipeline', metavar='MESHROOM_FILE/photogrammetry/hdri', type=str, default=os.environ.get("MESHROOM_DEFAULT_PIPELINE", "photogrammetry"), help='Override the default Meshroom pipeline with this external graph.') + parser.add_argument("--verbose", help="Verbosity level", default='warning', + choices=['fatal', 'error', 'warning', 'info', 'debug', 'trace'],) args = parser.parse_args(args[1:]) + logStringToPython = { + 'fatal': logging.FATAL, + 'error': logging.ERROR, + 'warning': logging.WARNING, + 'info': logging.INFO, + 'debug': logging.DEBUG, + 'trace': logging.DEBUG, + } + logging.getLogger().setLevel(logStringToPython[args.verbose]) + + QApplication.setAttribute(Qt.AA_EnableHighDpiScaling) + super(MeshroomApp, self).__init__(QtArgs) self.setOrganizationName('AliceVision') self.setApplicationName('Meshroom') - self.setAttribute(Qt.AA_EnableHighDpiScaling) self.setApplicationVersion(meshroom.__version_name__) font = self.font() @@ -101,7 +116,7 @@ class MeshroomApp(QApplication): self.engine.rootContext().setContextProperty("_nodeTypes", sorted(nodesDesc.keys())) # instantiate Reconstruction object - r = Reconstruction(parent=self) + r = Reconstruction(defaultPipeline=args.pipeline, parent=self) self.engine.rootContext().setContextProperty("_reconstruction", r) # those helpers should be available from QML Utils module as singletons, but: @@ -119,15 +134,6 @@ class MeshroomApp(QApplication): # request any potential computation to stop on exit self.aboutToQuit.connect(r.stopChildThreads) - 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" @@ -135,6 +141,8 @@ class MeshroomApp(QApplication): if args.project: r.load(args.project) + else: + r.new() # import is a python keyword, so we have to access the attribute by a string if getattr(args, "import", None): @@ -143,6 +151,20 @@ class MeshroomApp(QApplication): if args.importRecursive: r.importImagesFromFolder(args.importRecursive, recursive=True) + if args.save: + if os.path.isfile(args.save): + raise RuntimeError( + "Meshroom Command Line Error: Cannot save the new Meshroom project as the file (.mg) already exists.\n" + "Invalid value: '{}'".format(args.save)) + projectFolder = os.path.dirname(args.save) + if not os.path.isdir(projectFolder): + if not os.path.isdir(os.path.dirname(projectFolder)): + raise RuntimeError( + "Meshroom Command Line Error: Cannot save the new Meshroom project file (.mg) as the parent of the folder does not exists.\n" + "Invalid value: '{}'".format(args.save)) + os.mkdir(projectFolder) + r.saveAs(args.save) + self.engine.load(os.path.normpath(url)) @Slot(str, result=str) diff --git a/meshroom/ui/components/filepath.py b/meshroom/ui/components/filepath.py index 9e9a0475..f15d9700 100644 --- a/meshroom/ui/components/filepath.py +++ b/meshroom/ui/components/filepath.py @@ -78,3 +78,14 @@ class FilepathHelper(QObject): def normpath(self, path): """ Returns native normalized path """ return os.path.normpath(self.asStr(path)) + + @Slot(str, result=str) + @Slot(QUrl, result=str) + def globFirst(self, path): + """ Returns the first from a list of paths matching a pathname pattern. """ + import glob + fileList = glob.glob(self.asStr(path)) + fileList.sort() + if fileList: + return fileList[0] + return "" diff --git a/meshroom/ui/graph.py b/meshroom/ui/graph.py index f1fa8c46..b38c8b13 100644 --- a/meshroom/ui/graph.py +++ b/meshroom/ui/graph.py @@ -9,6 +9,7 @@ from multiprocessing.pool import ThreadPool from PySide2.QtCore import Slot, QJsonValue, QObject, QUrl, Property, Signal, QPoint +from meshroom import multiview from meshroom.common.qt import QObjectListModel from meshroom.core.attribute import Attribute, ListAttribute from meshroom.core.graph import Graph, Edge, submitGraph @@ -245,7 +246,7 @@ class UIGraph(QObject): UIGraph exposes undoable methods on its graph and computation in a separate thread. It also provides a monitoring of all its computation units (NodeChunks). """ - def __init__(self, filepath='', parent=None): + def __init__(self, parent=None): super(UIGraph, self).__init__(parent) self._undoStack = commands.UndoStack(self) self._graph = Graph('', self) @@ -260,9 +261,6 @@ class UIGraph(QObject): self._layout = GraphLayout(self) self._selectedNode = None self._hoveredNode = None - self._defaultPipelineFilepath = None - if filepath: - self.load(filepath) def setGraph(self, g): """ Set the internal graph. """ @@ -319,10 +317,6 @@ class UIGraph(QObject): self.stopExecution() self._chunksMonitor.stop() - def setDefaultPipeline(self, pipelineFilepath): - self._defaultPipelineFilepath = pipelineFilepath - self._graph.load(pipelineFilepath, setupProjectFile=False) - def load(self, filepath, setupProjectFile=True): g = Graph('') g.load(filepath, setupProjectFile) @@ -336,7 +330,10 @@ class UIGraph(QObject): @Slot(QUrl) def saveAs(self, url): - localFile = url.toLocalFile() + if isinstance(url, (str)): + localFile = url + else: + localFile = url.toLocalFile() # ensure file is saved with ".mg" extension if os.path.splitext(localFile)[-1] != ".mg": localFile += ".mg" diff --git a/meshroom/ui/qml/GraphEditor/Node.qml b/meshroom/ui/qml/GraphEditor/Node.qml index d0fd4553..248d8ef8 100755 --- a/meshroom/ui/qml/GraphEditor/Node.qml +++ b/meshroom/ui/qml/GraphEditor/Node.qml @@ -59,232 +59,238 @@ Item { } } + // Whether an attribute can be displayed as an attribute pin on the node + function isDisplayableAsPin(attribute) { + // ATM, only File attributes are meant to be connected + // TODO: review this if we want to connect something else + return attribute.type == "File" + || (attribute.type == "ListAttribute" && attribute.desc.elementDesc.type == "File") + } + // Main Layout - MouseArea { + MouseArea { + width: parent.width + height: body.height + drag.target: root + // small drag threshold to avoid moving the node by mistake + drag.threshold: 2 + hoverEnabled: true + acceptedButtons: Qt.LeftButton | Qt.RightButton + onPressed: root.pressed(mouse) + onDoubleClicked: root.doubleClicked(mouse) + onEntered: root.entered() + onExited: root.exited() + drag.onActiveChanged: { + if(!drag.active) + { + root.moved(Qt.point(root.x, root.y)); + } + } + + cursorShape: drag.active ? Qt.ClosedHandCursor : Qt.ArrowCursor + + // Selection border + Rectangle { + anchors.fill: parent + anchors.margins: -border.width + visible: root.selected || root.hovered + border.width: 2.5 + border.color: root.selected ? activePalette.highlight : Qt.darker(activePalette.highlight, 1.5) + opacity: 0.9 + radius: background.radius + color: "transparent" + } + + // Background + Rectangle { + id: background + anchors.fill: parent + color: Qt.lighter(activePalette.base, 1.4) + layer.enabled: true + layer.effect: DropShadow { radius: 3; color: shadowColor } + radius: 3 + opacity: 0.7 + } + + // Data Layout + Column { + id: body width: parent.width - height: body.height - drag.target: root - // small drag threshold to avoid moving the node by mistake - drag.threshold: 2 - hoverEnabled: true - acceptedButtons: Qt.LeftButton | Qt.RightButton - onPressed: root.pressed(mouse) - onDoubleClicked: root.doubleClicked(mouse) - onEntered: root.entered() - onExited: root.exited() - drag.onActiveChanged: { - if(!drag.active) - { - root.moved(Qt.point(root.x, root.y)); - } - } - cursorShape: drag.active ? Qt.ClosedHandCursor : Qt.ArrowCursor - - // Selection border + // Header Rectangle { - anchors.fill: parent - anchors.margins: -border.width - visible: root.selected || root.hovered - border.width: 2.5 - border.color: root.selected ? activePalette.highlight : Qt.darker(activePalette.highlight, 1.5) - opacity: 0.9 - radius: background.radius - color: "transparent" - } - - // Background - Rectangle { - id: background - anchors.fill: parent - color: Qt.lighter(activePalette.base, 1.4) - layer.enabled: true - layer.effect: DropShadow { radius: 3; color: shadowColor } - radius: 3 - opacity: 0.7 - } - - // Data Layout - Column { - id: body + id: header width: parent.width + height: headerLayout.height + color: root.selected ? activePalette.highlight : root.baseColor + radius: background.radius - // Header + // Fill header's bottom radius Rectangle { - id: header width: parent.width - height: headerLayout.height - color: root.selected ? activePalette.highlight : root.baseColor - radius: background.radius + height: parent.radius + anchors.bottom: parent.bottom + color: parent.color + z: -1 + } - // Fill header's bottom radius - Rectangle { - width: parent.width - height: parent.radius - anchors.bottom: parent.bottom - color: parent.color - z: -1 + // Header Layout + RowLayout { + id: headerLayout + width: parent.width + spacing: 0 + + // Node Name + Label { + Layout.fillWidth: true + text: node.label + padding: 4 + color: root.selected ? "white" : activePalette.text + elide: Text.ElideMiddle + font.pointSize: 8 } - // Header Layout + // Node State icons RowLayout { - id: headerLayout - width: parent.width - spacing: 0 + Layout.fillWidth: true + Layout.alignment: Qt.AlignRight + Layout.rightMargin: 2 + spacing: 2 - // Node Name - Label { - Layout.fillWidth: true - text: node.label - padding: 4 - color: root.selected ? "white" : activePalette.text - elide: Text.ElideMiddle - font.pointSize: 8 + // Data sharing indicator + MaterialToolButton { + visible: node.chunks.count > 0 && node.globalStatus !== "NONE" && node.chunks.at(0).statusNodeName !== node.name + text: MaterialIcons.layers + font.pointSize: 7 + padding: 2 + palette.text: Colors.sysPalette.text + ToolTip.text: visible ? "Data has been computed by " + node.nameToLabel(node.chunks.at(0).statusNodeName) + "" : "" } - // Node State icons - RowLayout { - Layout.fillWidth: true - Layout.alignment: Qt.AlignRight - Layout.rightMargin: 2 - spacing: 2 + // Submitted externally indicator + MaterialLabel { + visible: ["SUBMITTED", "RUNNING"].includes(node.globalStatus) && node.chunks.count > 0 && node.chunks.at(0).execModeName === "EXTERN" + text: MaterialIcons.cloud + padding: 2 + font.pointSize: 7 + palette.text: Colors.sysPalette.text + ToolTip.text: "Computed Externally" + } - // Data sharing indicator - MaterialToolButton { - visible: node.chunks.count > 0 && node.globalStatus !== "NONE" && node.chunks.at(0).statusNodeName !== node.name - text: MaterialIcons.layers - font.pointSize: 7 - padding: 2 - palette.text: Colors.sysPalette.text - ToolTip.text: visible ? "Data has been computed by " + node.nameToLabel(node.chunks.at(0).statusNodeName) + "" : "" - } - - // Submitted externally indicator - MaterialLabel { - visible: ["SUBMITTED", "RUNNING"].includes(node.globalStatus) && node.chunks.count > 0 && node.chunks.at(0).execModeName === "EXTERN" - text: MaterialIcons.cloud - padding: 2 - font.pointSize: 7 - palette.text: Colors.sysPalette.text - ToolTip.text: "Computed Externally" - } - - // Lock indicator - MaterialLabel { - visible: root.readOnly - text: MaterialIcons.lock - padding: 2 - font.pointSize: 7 - palette.text: "red" - ToolTip.text: "Locked" - } + // Lock indicator + MaterialLabel { + visible: root.readOnly + text: MaterialIcons.lock + padding: 2 + font.pointSize: 7 + palette.text: "red" + ToolTip.text: "Locked" } } } + } - // Node Chunks - NodeChunks { - defaultColor: Colors.sysPalette.mid - implicitHeight: 3 + // Node Chunks + NodeChunks { + defaultColor: Colors.sysPalette.mid + implicitHeight: 3 + width: parent.width + model: node.chunks + + Rectangle { + anchors.fill: parent + color: Colors.sysPalette.mid + z: -1 + } + } + + // Vertical Spacer + Item { width: parent.width; height: 2 } + + // Input/Output Attributes + Item { + id: nodeAttributes + width: parent.width - 2 + height: childrenRect.height + anchors.horizontalCenter: parent.horizontalCenter + + enabled: !root.readOnly && !root.isCompatibilityNode + + Column { width: parent.width - model: node.chunks - - Rectangle { - anchors.fill: parent - color: Colors.sysPalette.mid - z: -1 - } - } - - // Vertical Spacer - Item { width: parent.width; height: 2 } - - // Input/Output Attributes - Item { - id: nodeAttributes - width: parent.width - 2 - height: childrenRect.height - anchors.horizontalCenter: parent.horizontalCenter - - enabled: !root.readOnly && !root.isCompatibilityNode + spacing: 5 + bottomPadding: 2 Column { + id: outputs width: parent.width - spacing: 5 - bottomPadding: 2 + spacing: 3 + Repeater { + model: node.attributes - Column { - id: outputs - width: parent.width - spacing: 3 - Repeater { - model: node.attributes + delegate: Loader { + id: outputLoader + active: object.isOutput && isDisplayableAsPin(object) + anchors.right: parent.right + width: outputs.width - delegate: Loader { - id: outputLoader - active: object.isOutput - anchors.right: parent.right - width: outputs.width + sourceComponent: AttributePin { + id: outPin + nodeItem: root + attribute: object - sourceComponent: AttributePin { - id: outPin - nodeItem: root - attribute: object - - readOnly: root.readOnly - onPressed: root.pressed(mouse) - Component.onCompleted: attributePinCreated(object, outPin) - Component.onDestruction: attributePinDeleted(attribute, outPin) - } - } - } - } - - Column { - id: inputs - width: parent.width - spacing: 3 - Repeater { - model: node.attributes - delegate: Loader { - active: !object.isOutput && object.type == "File" - || (object.type == "ListAttribute" && object.desc.elementDesc.type == "File") // TODO: review this - width: inputs.width - - - sourceComponent: AttributePin { - id: inPin - nodeItem: root - attribute: object - readOnly: root.readOnly - Component.onCompleted: attributePinCreated(attribute, inPin) - Component.onDestruction: attributePinDeleted(attribute, inPin) - onPressed: root.pressed(mouse) - onChildPinCreated: attributePinCreated(childAttribute, inPin) - onChildPinDeleted: attributePinDeleted(childAttribute, inPin) - } + readOnly: root.readOnly + onPressed: root.pressed(mouse) + Component.onCompleted: attributePinCreated(object, outPin) + Component.onDestruction: attributePinDeleted(attribute, outPin) } } } } - } - } - // CompatibilityBadge icon for CompatibilityNodes - Loader { - active: root.isCompatibilityNode - anchors { - right: parent.right - top: parent.top - margins: -4 - } - sourceComponent: CompatibilityBadge { - sourceComponent: iconDelegate - canUpgrade: root.node.canUpgrade - issueDetails: root.node.issueDetails + Column { + id: inputs + width: parent.width + spacing: 3 + Repeater { + model: node.attributes + delegate: Loader { + active: !object.isOutput && isDisplayableAsPin(object) + width: inputs.width + + sourceComponent: AttributePin { + id: inPin + nodeItem: root + attribute: object + readOnly: root.readOnly + Component.onCompleted: attributePinCreated(attribute, inPin) + Component.onDestruction: attributePinDeleted(attribute, inPin) + onPressed: root.pressed(mouse) + onChildPinCreated: attributePinCreated(childAttribute, inPin) + onChildPinDeleted: attributePinDeleted(childAttribute, inPin) + } + } + } + } } } } + // CompatibilityBadge icon for CompatibilityNodes + Loader { + active: root.isCompatibilityNode + anchors { + right: parent.right + top: parent.top + margins: -4 + } + sourceComponent: CompatibilityBadge { + sourceComponent: iconDelegate + canUpgrade: root.node.canUpgrade + issueDetails: root.node.issueDetails + } + } + } } + diff --git a/meshroom/ui/qml/Viewer3D/DepthMapLoader.qml b/meshroom/ui/qml/Viewer3D/DepthMapLoader.qml index 32f7ae1a..0f07ff91 100644 --- a/meshroom/ui/qml/Viewer3D/DepthMapLoader.qml +++ b/meshroom/ui/qml/Viewer3D/DepthMapLoader.qml @@ -1,4 +1,4 @@ -import DepthMapEntity 2.0 +import DepthMapEntity 2.1 /** * Support for Depth Map files (EXR) in Qt3d. diff --git a/meshroom/ui/qml/Viewer3D/EnvironmentMapEntity.qml b/meshroom/ui/qml/Viewer3D/EnvironmentMapEntity.qml new file mode 100644 index 00000000..5d3402a3 --- /dev/null +++ b/meshroom/ui/qml/Viewer3D/EnvironmentMapEntity.qml @@ -0,0 +1,51 @@ +import QtQuick 2.9 +import Qt3D.Core 2.1 +import Qt3D.Render 2.1 +import Qt3D.Extras 2.10 + + +/** + * EnvironmentMap maps an equirectangular image on a Sphere. + * The 'position' property can be used to virually attach it to a camera + * and get the impression of an environment at an infinite distance. + */ +Entity { + id: root + + /// Source of the equirectangular image + property url source + /// Radius of the sphere + property alias radius: sphereMesh.radius + /// Number of slices of the sphere + property alias slices: sphereMesh.slices + /// Number of rings of the sphere + property alias rings: sphereMesh.rings + /// Position of the sphere + property alias position: transform.translation + /// Texture loading status + property alias status: textureLoader.status + + components: [ + SphereMesh { + id: sphereMesh + radius: 1000 + slices: 50 + rings: 50 + }, + Transform { + id: transform + translation: root.position + }, + DiffuseMapMaterial { + ambient: "#FFF" + shininess: 0 + specular: "#000" + diffuse: TextureLoader { + id: textureLoader + magnificationFilter: Texture.Linear + mirrored: true + source: root.source + } + } + ] +} diff --git a/meshroom/ui/qml/Viewer3D/MediaLibrary.qml b/meshroom/ui/qml/Viewer3D/MediaLibrary.qml index 56af1866..6542e2db 100644 --- a/meshroom/ui/qml/Viewer3D/MediaLibrary.qml +++ b/meshroom/ui/qml/Viewer3D/MediaLibrary.qml @@ -16,6 +16,9 @@ Entity { property bool pickingEnabled: false readonly property alias count: instantiator.count // number of instantiated media delegates + /// Camera to consider for positionning + property Camera camera: null + /// True while at least one media is being loaded readonly property bool loading: { for(var i=0; i 1: + self.error.emit( + Message( + "Multiple XML files in input", + "Ignore the xml Panorama files:\n\n'{}'.".format(',\n'.join(filesByType.panoramaInfo)), + "", + )) + else: + panoramaExternalInfoNodes = self.graph.nodesByType('PanoramaExternalInfo') + for panoramaInfoFile in filesByType.panoramaInfo: + for panoramaInfoNode in panoramaExternalInfoNodes: + panoramaInfoNode.attribute('config').value = panoramaInfoFile + if panoramaExternalInfoNodes: + self.info.emit( + Message( + "Panorama XML", + "XML file declared on PanoramaExternalInfo node", + "XML file '{}' set on node '{}'".format(','.join(filesByType.panoramaInfo), ','.join([n.getLabel() for n in panoramaExternalInfoNodes])), + )) + else: + self.error.emit( + Message( + "No PanoramaExternalInfo Node", + "No PanoramaExternalInfo Node to set the Panorama file:\n'{}'.".format(','.join(filesByType.panoramaInfo)), + "", + )) + + if not filesByType.images and not filesByType.videos and not filesByType.panoramaInfo: + if filesByType.other: + extensions = set([os.path.splitext(url)[1] for url in filesByType.other]) + self.error.emit( + Message( + "No Recognized Input File", + "No recognized input file in the {} dropped files".format(len(filesByType.other)), + "Unknown file extensions: " + ', '.join(extensions) + ) ) - ) - return - self.importImagesAsync(images, cameraInit) @staticmethod - def getImageFilesFromDrop(drop): + def getFilesByTypeFromDrop(drop): """ Args: @@ -582,17 +631,14 @@ class Reconstruction(UIGraph): """ urls = drop.property("urls") # Build the list of images paths - images = [] - otherFiles = [] + filesByType = multiview.FilesByType() for url in urls: localFile = url.toLocalFile() if os.path.isdir(localFile): # get folder content - images.extend(multiview.findImageFiles(localFile)) - elif multiview.isImageFile(localFile): - images.append(localFile) + filesByType.extend(multiview.findFilesByTypeInFolder(localFile)) else: - otherFiles.append(localFile) - return images, otherFiles + filesByType.addFile(localFile) + return filesByType def importImagesFromFolder(self, path, recursive=False): """ @@ -602,19 +648,9 @@ class Reconstruction(UIGraph): 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) + filesByType = multiview.findFilesByTypeInFolder(path, recursive) + if filesByType.images: + self.buildIntrinsics(self.cameraInit, filesByType.images) def importImagesAsync(self, images, cameraInit): """ Add the given list of images to the Reconstruction. """