def __init__(self, defaultPipeline='', parent=None): super(Reconstruction, self).__init__(parent) # initialize member variables for key steps of the 3D reconstruction pipeline self._activeNodes = meshroom.common.DictModel(keyAttrName="nodeType") self.initActiveNodes() # - CameraInit self._cameraInit = None # current CameraInit node self._cameraInits = QObjectListModel( parent=self) # all CameraInit nodes self._buildingIntrinsics = False self.intrinsicsBuilt.connect(self.onIntrinsicsAvailable) self.cameraInitChanged.connect(self.onCameraInitChanged) self._tempCameraInit = None self.importImagesFailed.connect(self.onImportImagesFailed) # - SfM self._sfm = None self._views = None self._poses = None self._solvedIntrinsics = None self._selectedViewId = None self._selectedViewpoint = None self._liveSfmManager = LiveSfmManager(self) # react to internal graph changes to update those variables self.graphChanged.connect(self.onGraphChanged) self.setDefaultPipeline(defaultPipeline)
def __init__(self, parent=None): """Initialize the object without any parameter.""" super(CsvData, self).__init__(parent=parent) self._filepath = "" self._data = QObjectListModel(parent=self) # List of CsvColumn self._ready = False self.filepathChanged.connect(self.updateData)
def __init__(self, graphFilepath='', parent=None): super(Reconstruction, self).__init__(graphFilepath, parent) # initialize member variables for key steps of the 3D reconstruction pipeline # - CameraInit self._cameraInit = None # current CameraInit node self._cameraInits = QObjectListModel( parent=self) # all CameraInit nodes self._buildingIntrinsics = False self.intrinsicsBuilt.connect(self.onIntrinsicsAvailable) # - Feature Extraction self._featureExtraction = None self.cameraInitChanged.connect(self.updateFeatureExtraction) # - SfM self._sfm = None self._views = None self._poses = None self._selectedViewId = None self._liveSfmManager = LiveSfmManager(self) # - Texturing self._texturing = None # react to internal graph changes to update those variables self.graphChanged.connect(self.onGraphChanged) if graphFilepath: self.onGraphChanged() else: self.new()
def __init__(self, parent=None): super(UIGraph, self).__init__(parent) self._undoStack = commands.UndoStack(self) self._graph = Graph('', self) self._modificationCount = 0 self._chunksMonitor = ChunksMonitor(parent=self) self._computeThread = Thread() self._running = self._submitted = False self._sortedDFSChunks = QObjectListModel(parent=self) self._layout = GraphLayout(self) self._selectedNode = None self._hoveredNode = None
def __init__(self, filepath='', parent=None): super(UIGraph, self).__init__(parent) self._undoStack = commands.UndoStack(self) self._graph = Graph('', self) self._modificationCount = 0 self._chunksMonitor = ChunksMonitor(parent=self) self._chunksMonitor.chunkStatusChanged.connect( self.onChunkStatusChanged) self._computeThread = Thread() self._running = self._submitted = False self._sortedDFSChunks = QObjectListModel(parent=self) self._layout = GraphLayout(self) if filepath: self.load(filepath)
def __init__(self, undoStack, taskManager, parent=None): super(UIGraph, self).__init__(parent) self._undoStack = undoStack self._taskManager = taskManager self._graph = Graph('', self) self._modificationCount = 0 self._chunksMonitor = ChunksMonitor(parent=self) self._computeThread = Thread() self._running = self._submitted = False self._sortedDFSChunks = QObjectListModel(parent=self) self._layout = GraphLayout(self) self._selectedNode = None self._hoveredNode = None self.computeStatusChanged.connect(self.updateLockedUndoStack)
def test_listModel_add_remove(): m = QObjectListModel(keyAttrName='name') node = DummyNode("DummyNode_1") m.add(node) assert len(m) == 1 assert m.get("DummyNode_1") == node m.remove(node) assert len(m) == 0
def __init__(self, graphFilepath='', parent=None): super(Reconstruction, self).__init__(graphFilepath, parent) self._buildingIntrinsics = False self._cameraInit = None self._cameraInits = QObjectListModel(parent=self) self._endNode = None self.intrinsicsBuilt.connect(self.onIntrinsicsAvailable) self.graphChanged.connect(self.onGraphChanged) self._liveSfmManager = LiveSfmManager(self) # SfM result self._sfm = None self._views = None self._poses = None self._selectedViewId = None if graphFilepath: self.onGraphChanged() else: self.new()
def __init__(self, defaultPipeline='', parent=None): super(Reconstruction, self).__init__(parent) # initialize member variables for key steps of the 3D reconstruction pipeline # - CameraInit self._cameraInit = None # current CameraInit node self._cameraInits = QObjectListModel(parent=self) # all CameraInit nodes self._buildingIntrinsics = False self.intrinsicsBuilt.connect(self.onIntrinsicsAvailable) self.importImagesFailed.connect(self.onImportImagesFailed) # - Feature Extraction self._featureExtraction = None self.cameraInitChanged.connect(self.updateFeatureExtraction) # - SfM self._sfm = None self._views = None self._poses = None self._solvedIntrinsics = None self._selectedViewId = None self._selectedViewpoint = None self._liveSfmManager = LiveSfmManager(self) # - Prepare Dense Scene (undistorted images) self._prepareDenseScene = None # - Depth Map self._depthMap = None self.cameraInitChanged.connect(self.updateDepthMapNode) # - Texturing self._texturing = None # react to internal graph changes to update those variables self.graphChanged.connect(self.onGraphChanged) self.setDefaultPipeline(defaultPipeline)
def getViewpoints(self): """ Return the Viewpoints model. """ # TODO: handle multiple Viewpoints models return self._cameraInit.viewpoints.value if self._cameraInit else QObjectListModel( parent=self)
class Reconstruction(UIGraph): """ Specialization of a UIGraph designed to manage a 3D reconstruction. """ activeNodeCategories = { "sfm": [ "StructureFromMotion", "GlobalSfM", "PanoramaEstimation", "SfMTransfer", "SfMTransform", "SfMAlignment" ], "undistort": ["PrepareDenseScene", "PanoramaWarping"], "allDepthMap": ["DepthMap", "DepthMapFilter"], } def __init__(self, defaultPipeline='', parent=None): super(Reconstruction, self).__init__(parent) # initialize member variables for key steps of the 3D reconstruction pipeline self._activeNodes = meshroom.common.DictModel(keyAttrName="nodeType") self.initActiveNodes() # - CameraInit self._cameraInit = None # current CameraInit node self._cameraInits = QObjectListModel( parent=self) # all CameraInit nodes self._buildingIntrinsics = False self.intrinsicsBuilt.connect(self.onIntrinsicsAvailable) self.cameraInitChanged.connect(self.onCameraInitChanged) self._tempCameraInit = None self.importImagesFailed.connect(self.onImportImagesFailed) # - SfM self._sfm = None self._views = None self._poses = None self._solvedIntrinsics = None self._selectedViewId = None self._selectedViewpoint = None self._liveSfmManager = LiveSfmManager(self) # react to internal graph changes to update those variables self.graphChanged.connect(self.onGraphChanged) self.setDefaultPipeline(defaultPipeline) def clear(self): self.clearActiveNodes() super(Reconstruction, self).clear() def setDefaultPipeline(self, defaultPipeline): self._defaultPipeline = defaultPipeline def initActiveNodes(self): # Create all possible entries for category, _ in self.activeNodeCategories.items(): self._activeNodes.add(ActiveNode(category, self)) for nodeType, _ in meshroom.core.nodesDesc.items(): self._activeNodes.add(ActiveNode(nodeType, self)) def clearActiveNodes(self): for key in self._activeNodes.keys(): self._activeNodes.get(key).node = None def onCameraInitChanged(self): # Update active nodes when CameraInit changes nodes = self._graph.nodesFromNode(self._cameraInit)[0] self.setActiveNodes(nodes) @Slot() @Slot(str) def new(self, pipeline=None): p = pipeline if pipeline != None else self._defaultPipeline """ Create a new photogrammetry pipeline. """ if p.lower() == "photogrammetry": # default photogrammetry pipeline self.setGraph(multiview.photogrammetry()) elif p.lower() == "panoramahdr": # default panorama hdr pipeline self.setGraph(multiview.panoramaHdr()) elif p.lower() == "panoramafisheyehdr": # default panorama fisheye hdr pipeline self.setGraph(multiview.panoramaFisheyeHdr()) else: # use the user-provided default photogrammetry project file self.load(p, setupProjectFile=False) @Slot(str, result=bool) def load(self, filepath, setupProjectFile=True): try: status = super(Reconstruction, self).loadGraph(filepath, setupProjectFile) # warn about pre-release projects being automatically upgraded if Version(self._graph.fileReleaseVersion).major == "0": self.warning.emit( Message( "Automatic project upgrade", "This project was created with an older version of Meshroom and has been automatically upgraded.\n" "Data might have been lost in the process.", "Open it with the corresponding version of Meshroom to recover your data." )) return status except FileNotFoundError as e: self.error.emit( Message( "No Such File", "Error While Loading '{}': No Such File.".format( os.path.basename(filepath)), "")) logging.error("Error while loading '{}': No Such File.".format( os.path.basename(filepath))) return False except Exception as e: import traceback trace = traceback.format_exc() self.error.emit( Message( "Error While Loading Project File", "An unexpected error has occurred while loading file: '{}'" .format(os.path.basename(filepath)), trace)) logging.error(trace) return False @Slot(QUrl, result=bool) def loadUrl(self, url): if isinstance(url, (QUrl)): # depending how the QUrl has been initialized, # toLocalFile() may return the local path or an empty string localFile = url.toLocalFile() if not localFile: localFile = url.toString() else: localFile = url return self.load(localFile) def onGraphChanged(self): """ React to the change of the internal graph. """ self._liveSfmManager.reset() self.selectedViewId = "-1" self.sfm = None self.tempCameraInit = None self.updateCameraInits() if not self._graph: return self.setSfm(self.lastSfmNode()) # TODO: listen specifically for cameraInit creation/deletion self._graph.nodes.countChanged.connect(self.updateCameraInits) @staticmethod def runAsync(func, args=(), kwargs=None): thread = Thread(target=func, args=args, kwargs=kwargs) thread.start() return thread @Slot(QObject) def getViewpoints(self): """ Return the Viewpoints model. """ # TODO: handle multiple Viewpoints models return self._cameraInit.viewpoints.value if self._cameraInit else QObjectListModel( parent=self) def updateCameraInits(self): cameraInits = self._graph.nodesByType("CameraInit", sortedByIndex=True) if set(self._cameraInits.objectList()) == set(cameraInits): return self._cameraInits.setObjectList(cameraInits) self.cameraInit = cameraInits[0] if cameraInits else None def getCameraInitIndex(self): if not self._cameraInit: # No CameraInit node return -1 if not self._cameraInit.graph: # The CameraInit node is a temporary one not attached to a graph return -1 return self._cameraInits.indexOf(self._cameraInit) def setCameraInitIndex(self, idx): camInit = self._cameraInits[idx] if self._cameraInits else None self.cameraInit = camInit @Slot() def clearTempCameraInit(self): self.tempCameraInit = None @Slot(QObject, str) def setupTempCameraInit(self, node, attrName): if not node or not attrName: self.tempCameraInit = None return sfmFile = node.attribute(attrName).value if not sfmFile or not os.path.isfile(sfmFile): self.tempCameraInit = None return nodeDesc = meshroom.core.nodesDesc["CameraInit"]() views, intrinsics = nodeDesc.readSfMData(sfmFile) tmpCameraInit = Node("CameraInit", viewpoints=views, intrinsics=intrinsics) self.tempCameraInit = tmpCameraInit @Slot(QObject, result=QVector3D) def getAutoFisheyeCircle(self, panoramaInit): if not panoramaInit or not panoramaInit.isComputed: return QVector3D(0.0, 0.0, 0.0) if not panoramaInit.attribute("estimateFisheyeCircle").value: return QVector3D(0.0, 0.0, 0.0) sfmFile = panoramaInit.attribute('outSfMData').value if not os.path.exists(sfmFile): return QVector3D(0.0, 0.0, 0.0) import io # use io.open for Python2/3 compatibility (allow to specify encoding + errors handling) # skip decoding errors to avoid potential exceptions due to non utf-8 characters in images metadata with io.open(sfmFile, 'r', encoding='utf-8', errors='ignore') as f: data = json.load(f) intrinsics = data.get('intrinsics', []) if len(intrinsics) == 0: return QVector3D(0.0, 0.0, 0.0) intrinsic = intrinsics[0] res = QVector3D( float(intrinsic.get("fisheyeCircleCenterX", 0.0)) - float(intrinsic.get("width", 0.0)) * 0.5, float(intrinsic.get("fisheyeCircleCenterY", 0.0)) - float(intrinsic.get("height", 0.0)) * 0.5, float(intrinsic.get("fisheyeCircleRadius", 0.0))) return res def lastSfmNode(self): """ Retrieve the last SfM node from the initial CameraInit node. """ return self.lastNodeOfType(self.activeNodeCategories['sfm'], self._cameraInit, Status.SUCCESS) def lastNodeOfType(self, nodeTypes, startNode, preferredStatus=None): """ Returns the last node of the given type starting from 'startNode'. If 'preferredStatus' is specified, the last node with this status will be considered in priority. Args: nodeTypes (str list): the node types startNode (Node): the node to start from preferredStatus (Status): (optional) the node status to prioritize Returns: Node: the node matching the input parameters or None """ if not startNode: return None nodes = self._graph.nodesFromNode(startNode, nodeTypes)[0] if not nodes: return None node = nodes[-1] if preferredStatus: node = next((n for n in reversed(nodes) if n.getGlobalStatus() == preferredStatus), node) return node def addSfmAugmentation(self, withMVS=False): """ Create a new augmentation step connected to the last SfM node of this Reconstruction and return the created CameraInit and SfM nodes. If the Reconstruction is not initialized (empty initial CameraInit), this method won't create anything and return initial CameraInit and SfM nodes. Args: withMVS (bool): whether to create the MVS pipeline after the augmentation Returns: Node, Node: CameraInit, StructureFromMotion """ sfm = self.lastSfmNode() if not sfm: return None, None if len(self._cameraInits) == 1: assert self._cameraInit == self._cameraInits[0] # Initial CameraInit is empty, use this one if len(self._cameraInits[0].viewpoints) == 0: return self._cameraInit, sfm # enable updates between duplication and layout to get correct depths during layout with self.groupedGraphModification("SfM Augmentation", disableUpdates=False): # disable graph updates when adding augmentation branch with self.groupedGraphModification("Augmentation", disableUpdates=True): sfm, mvs = multiview.sfmAugmentation(self, self.lastSfmNode(), withMVS=withMVS) first, last = sfm[0], mvs[-1] if mvs else sfm[-1] # use graph current bounding box height to spawn the augmentation branch bb = self.layout.boundingBox() self.layout.autoLayout(first, last, bb[0], bb[3] + self._layout.gridSpacing) self.sfmAugmented.emit(first, last) return sfm[0], sfm[-1] def allImagePaths(self): """ Get all image paths in the reconstruction. """ return [ vp.path.value for node in self._cameraInits for vp in node.viewpoints.value ] def allViewIds(self): """ Get all view Ids involved in the reconstruction. """ return [ vp.viewId.value for node in self._cameraInits for vp in node.viewpoints.value ] @Slot(QObject, Node) def handleFilesDrop(self, drop, cameraInit): """ Handle drop events aiming to add images to the Reconstruction. Fetching urls from dropEvent is generally expensive in QML/JS (bug ?). This method allows to reduce process time by doing it on Python side. """ filesByType = self.getFilesByTypeFromDrop(drop) if filesByType.images: self.importImagesAsync(filesByType.images, cameraInit) if filesByType.videos: boundingBox = self.layout.boundingBox() keyframeNode = self.addNewNode( "KeyframeSelection", position=Position(boundingBox[0], boundingBox[1] + boundingBox[3])) keyframeNode.mediaPaths.value = filesByType.videos if len(filesByType.videos) == 1: newVideoNodeMessage = "New node '{}' added for the input video.".format( keyframeNode.getLabel()) else: newVideoNodeMessage = "New node '{}' added for a rig of {} synchronized cameras.".format( keyframeNode.getLabel(), len(filesByType.videos)) self.info.emit( Message( "Video Input", newVideoNodeMessage, "Warning: You need to manually compute the KeyframeSelection node \n" "and then reimport the created images into Meshroom for the reconstruction.\n\n" "If you know the Camera Make/Model, it is highly recommended to declare them in the Node." )) if filesByType.panoramaInfo: if len(filesByType.panoramaInfo) > 1: self.error.emit( Message( "Multiple XML files in input", "Ignore the xml Panorama files:\n\n'{}'.".format( ',\n'.join(filesByType.panoramaInfo)), "", )) else: panoramaInitNodes = self.graph.nodesByType('PanoramaInit') for panoramaInfoFile in filesByType.panoramaInfo: for panoramaInitNode in panoramaInitNodes: panoramaInitNode.attribute( 'config').value = panoramaInfoFile if panoramaInitNodes: self.info.emit( Message( "Panorama XML", "XML file declared on PanoramaInit node", "XML file '{}' set on node '{}'".format( ','.join(filesByType.panoramaInfo), ','.join([ n.getLabel() for n in panoramaInitNodes ])), )) else: self.error.emit( Message( "No PanoramaInit Node", "No PanoramaInit 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))) @staticmethod def getFilesByTypeFromDrop(drop): """ Args: drop: Returns: <images, otherFiles> List of recognized images and list of other files """ urls = drop.property("urls") # Build the list of images paths filesByType = multiview.FilesByType() for url in urls: localFile = url.toLocalFile() if os.path.isdir(localFile): # get folder content filesByType.extend( multiview.findFilesByTypeInFolder(localFile)) else: filesByType.addFile(localFile) return filesByType 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. """ logging.debug("importImagesFromFolder: " + str(path)) filesByType = multiview.findFilesByTypeInFolder(path, recursive) if filesByType.images: self.buildIntrinsics(self.cameraInit, filesByType.images) @Slot("QVariant") def importImagesUrls(self, imagePaths, recursive=False): paths = [] for imagePath in imagePaths: if isinstance(imagePath, (QUrl)): p = imagePath.toLocalFile() if not p: p = imagePath.toString() else: p = imagePath paths.append(p) self.importImagesFromFolder(paths) def importImagesAsync(self, images, cameraInit): """ Add the given list of images to the Reconstruction. """ # Start the process of updating views and intrinsics logging.debug("Import images: " + str(images)) self.runAsync(self.importImagesSync, args=( images, cameraInit, )) def importImagesSync(self, images, cameraInit): try: self.buildIntrinsics(cameraInit, images) except Exception as e: self.importImagesFailed.emit(str(e)) @Slot() def onImportImagesFailed(self, msg): self.error.emit( Message( "Failed to Import Images", "You probably have a corrupted image within the images that you are trying to import.", "" # msg )) def buildIntrinsics(self, cameraInit, additionalViews, rebuild=False): """ Build up-to-date intrinsics and views based on already loaded + additional images. Does not modify the graph, can be called outside the main thread. Emits intrinsicBuilt(views, intrinsics) when done. Args: cameraInit (Node): CameraInit node to build the intrinsics for additionalViews: list of additional views to add to the CameraInit viewpoints rebuild (bool): whether to rebuild already created intrinsics """ views = [] intrinsics = [] # Duplicate 'cameraInit' outside the graph. # => allows to compute intrinsics without modifying the node or the graph # If cameraInit is None (i.e: SfM augmentation): # * create an uninitialized node # * wait for the result before actually creating new nodes in the graph (see onIntrinsicsAvailable) inputs = cameraInit.toDict()["inputs"] if cameraInit else {} cameraInitCopy = Node("CameraInit", **inputs) if rebuild: # if rebuilding all intrinsics, for each Viewpoint: for vp in cameraInitCopy.viewpoints.value: vp.intrinsicId.resetValue() # reset intrinsic assignation vp.metadata.resetValue( ) # and metadata (to clear any previous 'SensorWidth' entries) # reset existing intrinsics list cameraInitCopy.intrinsics.resetValue() try: self.setBuildingIntrinsics(True) # Retrieve the list of updated viewpoints and intrinsics views, intrinsics = cameraInitCopy.nodeDesc.buildIntrinsics( cameraInitCopy, additionalViews) except Exception as e: logging.error("Error while building intrinsics: {}".format(str(e))) raise finally: # Delete the duplicate cameraInitCopy.deleteLater() self.setBuildingIntrinsics(False) # always emit intrinsicsBuilt signal to inform listeners # in other threads that computation is over self.intrinsicsBuilt.emit(cameraInit, views, intrinsics, rebuild) @Slot(Node) def rebuildIntrinsics(self, cameraInit): """ Rebuild intrinsics of 'cameraInit' from scratch. Args: cameraInit (Node): the CameraInit node """ self.runAsync(self.buildIntrinsics, args=(cameraInit, (), True)) def onIntrinsicsAvailable(self, cameraInit, views, intrinsics, rebuild=False): """ Update CameraInit with given views and intrinsics. """ augmentSfM = cameraInit is None commandTitle = "Add {} Images" # SfM augmentation if augmentSfM: # filter out views already involved in the reconstruction allViewIds = self.allViewIds() views = [ view for view in views if int(view["viewId"]) not in allViewIds ] commandTitle = "Augment Reconstruction ({} Images)" if rebuild: commandTitle = "Rebuild '{}' Intrinsics".format(cameraInit.label) # No additional views: early return if not views: return commandTitle = commandTitle.format(len(views)) # allow updates between commands so that node depths # are updated after "addSfmAugmentation" (useful for auto layout) with self.groupedGraphModification(commandTitle, disableUpdates=False): if augmentSfM: cameraInit, self.sfm = self.addSfmAugmentation(withMVS=True) with self.groupedGraphModification("Set Views and Intrinsics"): self.setAttribute(cameraInit.viewpoints, views) self.setAttribute(cameraInit.intrinsics, intrinsics) self.cameraInit = cameraInit def setBuildingIntrinsics(self, value): if self._buildingIntrinsics == value: return self._buildingIntrinsics = value self.buildingIntrinsicsChanged.emit() activeNodes = makeProperty(QObject, "_activeNodes", resetOnDestroy=True) cameraInitChanged = Signal() cameraInit = makeProperty(QObject, "_cameraInit", cameraInitChanged, resetOnDestroy=True) tempCameraInitChanged = Signal() tempCameraInit = makeProperty(QObject, "_tempCameraInit", tempCameraInitChanged, resetOnDestroy=True) cameraInitIndex = Property(int, getCameraInitIndex, setCameraInitIndex, notify=cameraInitChanged) viewpoints = Property(QObject, getViewpoints, notify=cameraInitChanged) cameraInits = Property(QObject, lambda self: self._cameraInits, constant=True) importImagesFailed = Signal(str) intrinsicsBuilt = Signal(QObject, list, list, bool) buildingIntrinsicsChanged = Signal() buildingIntrinsics = Property(bool, lambda self: self._buildingIntrinsics, notify=buildingIntrinsicsChanged) liveSfmManager = Property(QObject, lambda self: self._liveSfmManager, constant=True) @Slot(QObject) def setActiveNode(self, node): """ Set node as the active node of its type. """ for category, nodeTypes in self.activeNodeCategories.items(): if node.nodeType in nodeTypes: self.activeNodes.get(category).node = node if category == 'sfm': self.setSfm(node) self.activeNodes.get(node.nodeType).node = node @Slot(QObject) def setActiveNodes(self, nodes): """ Set node as the active node of its type. """ # Setup the active node per category only once, on the last one nodesByCategory = {} for node in nodes: if node is None: continue for category, nodeTypes in self.activeNodeCategories.items(): if node.nodeType in nodeTypes: nodesByCategory[category] = node for category, node in nodesByCategory.items(): self.activeNodes.get(category).node = node if category == 'sfm': self.setSfm(node) for node in nodes: if node is None: continue if not isinstance(node, CompatibilityNode): self.activeNodes.get(node.nodeType).node = node def updateSfMResults(self): """ Update internal views, poses and solved intrinsics based on the current SfM node. """ if not self._sfm or ('outputViewsAndPoses' not in self._sfm.getAttributes().keys()): self._views = dict() self._poses = dict() self._solvedIntrinsics = dict() else: self._views, self._poses, self._solvedIntrinsics = parseSfMJsonFile( self._sfm.outputViewsAndPoses.value) self.sfmReportChanged.emit() def getSfm(self): """ Returns the current SfM node. """ return self._sfm def _unsetSfm(self): """ Unset current SfM node. This is shortcut equivalent to _setSfm(None). """ self._setSfm(None) def _setSfm(self, node): """ Set current SfM node to 'node' and update views and poses. Notes: this should not be called directly, use setSfm instead. See Also: setSfm """ self._sfm = node # Update sfm results and do so each time # the status of the SfM node's only chunk changes self.updateSfMResults() if self._sfm: # when destroyed, directly use '_setSfm' to bypass # disconnection step in 'setSfm' (at this point, 'self._sfm' underlying object # has been destroyed and can't be evaluated anymore) self._sfm.destroyed.connect(self._unsetSfm) self._sfm.chunks[0].statusChanged.connect(self.updateSfMResults) self.sfmChanged.emit() def setSfm(self, node): """ Set the current SfM node. This node will be used to retrieve sparse reconstruction result like camera poses. """ # disconnect from previous SfM node if any if self._sfm: self._sfm.chunks[0].statusChanged.disconnect(self.updateSfMResults) self._sfm.destroyed.disconnect(self._unsetSfm) self._setSfm(node) @Slot(QObject, result=bool) def isInViews(self, viewpoint): if not viewpoint: return False # keys are strings (faster lookup) return str(viewpoint.viewId.value) in self._views @Slot(QObject, result=bool) def isReconstructed(self, viewpoint): if not viewpoint: return False # fetch up-to-date poseId from sfm result (in case of rigs, poseId might have changed) view = self._views.get(str(viewpoint.poseId.value), None) # keys are strings (faster lookup) return view.get('poseId', -1) in self._poses if view else False @Slot(QObject, result=bool) def hasValidIntrinsic(self, viewpoint): # keys are strings (faster lookup) allIntrinsicIds = [ i.intrinsicId.value for i in self._cameraInit.intrinsics.value ] return viewpoint.intrinsicId.value in allIntrinsicIds @Slot(QObject, result=QObject) def getIntrinsic(self, viewpoint): """ Get the intrinsic attribute associated to 'viewpoint' based on its intrinsicId. Args: viewpoint (Attribute): the Viewpoint to consider. Returns: Attribute: the Viewpoint's corresponding intrinsic or None if not found. """ if not viewpoint: return None return next((i for i in self._cameraInit.intrinsics.value if i.intrinsicId.value == viewpoint.intrinsicId.value), None) @Slot(QObject, result=bool) def hasMetadata(self, viewpoint): # Should be greater than 2 to avoid the particular case of "" return len(viewpoint.metadata.value) > 2 def setSelectedViewId(self, viewId): if viewId == self._selectedViewId: return self._selectedViewId = viewId vp = None if self.viewpoints: vp = next((v for v in self.viewpoints if str(v.viewId.value) == self._selectedViewId), None) self.setSelectedViewpoint(vp) self.selectedViewIdChanged.emit() def setSelectedViewpoint(self, viewpointAttribute): if self._selectedViewpoint: # Reconstruction has ownership of Viewpoint object - destroy it when not needed anymore self._selectedViewpoint.deleteLater() self._selectedViewpoint = ViewpointWrapper( viewpointAttribute, self) if viewpointAttribute else None def reconstructedCamerasCount(self): """ Get the number of reconstructed cameras in the current context. """ viewpoints = self.getViewpoints() # Check that the object is iterable to avoid error with undefined Qt Property if not isinstance(viewpoints, Iterable): return 0 return len([v for v in viewpoints if self.isReconstructed(v)]) @Slot(QObject, result="QVariant") def getSolvedIntrinsics(self, viewpoint): """ Return viewpoint's solved intrinsics if it has been reconstructed, None otherwise. Args: viewpoint: the viewpoint object to instrinsics for. """ if not viewpoint: return None return self._solvedIntrinsics.get(str(viewpoint.intrinsicId.value), None) def getPoseRT(self, viewpoint): """ Get the camera pose as rotation and translation of the given viewpoint. Args: viewpoint: the viewpoint attribute to consider. Returns: R, T: the rotation and translation as lists of floats """ if not viewpoint: return None, None view = self._views.get(str(viewpoint.viewId.value), None) if not view: return None, None pose = self._poses.get(view.get('poseId', -1), None) if not pose: return None, None pose = pose["transform"] R = [float(i) for i in pose["rotation"]] T = [float(i) for i in pose["center"]] return R, T selectedViewIdChanged = Signal() selectedViewId = Property(str, lambda self: self._selectedViewId, setSelectedViewId, notify=selectedViewIdChanged) selectedViewpoint = Property(ViewpointWrapper, lambda self: self._selectedViewpoint, notify=selectedViewIdChanged) sfmChanged = Signal() sfm = Property(QObject, getSfm, setSfm, notify=sfmChanged) sfmReportChanged = Signal() # convenient property for QML binding re-evaluation when sfm report changes sfmReport = Property(bool, lambda self: len(self._poses) > 0, notify=sfmReportChanged) sfmAugmented = Signal(Node, Node) nbCameras = Property(int, reconstructedCamerasCount, notify=sfmReportChanged) # Signals to propagate high-level messages error = Signal(Message) warning = Signal(Message) info = Signal(Message)
class UIGraph(QObject): """ High level wrapper over core.Graph, with additional features dedicated to UI integration. 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, parent=None): super(UIGraph, self).__init__(parent) self._undoStack = commands.UndoStack(self) self._graph = Graph('', self) self._modificationCount = 0 self._chunksMonitor = ChunksMonitor(parent=self) self._computeThread = Thread() self._running = self._submitted = False self._sortedDFSChunks = QObjectListModel(parent=self) self._layout = GraphLayout(self) self._selectedNode = None self._hoveredNode = None def setGraph(self, g): """ Set the internal graph. """ if self._graph: self.stopExecution() self.clear() oldGraph = self._graph self._graph = g if oldGraph: oldGraph.deleteLater() self._graph.updated.connect(self.onGraphUpdated) self._graph.update() # perform auto-layout if graph does not provide nodes positions if Graph.IO.Features.NodesPositions not in self._graph.fileFeatures: self._layout.reset() # clear undo-stack after layout self._undoStack.clear() else: bbox = self._layout.positionBoundingBox() if bbox[2] == 0 and bbox[3] == 0: self._layout.reset() # clear undo-stack after layout self._undoStack.clear() self.graphChanged.emit() def onGraphUpdated(self): """ Callback to any kind of attribute modification. """ # TODO: handle this with a better granularity self.updateChunks() def updateChunks(self): dfsNodes = self._graph.dfsOnFinish(None)[0] chunks = self._graph.getChunks(dfsNodes) # Nothing has changed, return if self._sortedDFSChunks.objectList() == chunks: return for chunk in self._sortedDFSChunks: chunk.statusChanged.disconnect(self.updateGraphComputingStatus) self._sortedDFSChunks.setObjectList(chunks) for chunk in self._sortedDFSChunks: chunk.statusChanged.connect(self.updateGraphComputingStatus) # provide ChunkMonitor with the update list of chunks self.updateChunkMonitor(self._sortedDFSChunks) # update graph computing status based on the new list of NodeChunks self.updateGraphComputingStatus() def updateChunkMonitor(self, chunks): """ Update the list of chunks for status files monitoring. """ self._chunksMonitor.setChunks(chunks) def clear(self): if self._graph: self.clearNodeHover() self.clearNodeSelection() self._graph.clear() self._sortedDFSChunks.clear() self._undoStack.clear() def stopChildThreads(self): """ Stop all child threads. """ self.stopExecution() self._chunksMonitor.stop() @Slot(str, result=bool) def loadGraph(self, filepath, setupProjectFile=True): g = Graph('') status = g.load(filepath, setupProjectFile) if not os.path.exists(g.cacheDir): os.mkdir(g.cacheDir) self.setGraph(g) return status @Slot(QUrl) def saveAs(self, url): 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" self._graph.save(localFile) self._undoStack.setClean() # saving file on disk impacts cache folder location # => force re-evaluation of monitored status files paths self.updateChunkMonitor(self._sortedDFSChunks) @Slot() def save(self): self._graph.save() self._undoStack.setClean() @Slot(Node) def execute(self, node=None): if self.computing: return nodes = [node] if node else None self._computeThread = Thread(target=self._execute, args=(nodes, )) self._computeThread.start() def _execute(self, nodes): self.computeStatusChanged.emit() try: executeGraph(self._graph, nodes) except Exception as e: logging.error("Error during Graph execution {}".format(e)) finally: self.computeStatusChanged.emit() @Slot() def stopExecution(self): if not self.isComputingLocally(): return self._graph.stopExecution() self._computeThread.join() self.computeStatusChanged.emit() @Slot(Node) 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 Notes: Default submitter is specified using the MESHROOM_DEFAULT_SUBMITTER environment variable. """ self.save() # graph must be saved before being submitted node = [node] if node else None submitGraph(self._graph, os.environ.get('MESHROOM_DEFAULT_SUBMITTER', ''), node) def updateGraphComputingStatus(self): # update graph computing status running = any([ ch.status.status == Status.RUNNING for ch in self._sortedDFSChunks ]) submitted = any([ ch.status.status == Status.SUBMITTED for ch in self._sortedDFSChunks ]) if self._running != running or self._submitted != submitted: self._running = running self._submitted = submitted self.computeStatusChanged.emit() def isComputing(self): """ Whether is graph is being computed, either locally or externally. """ return self.isComputingLocally() or self.isComputingExternally() def isComputingExternally(self): """ Whether this graph is being computed externally. """ return (self._running or self._submitted) and not self.isComputingLocally() def isComputingLocally(self): """ Whether this graph is being computed locally (i.e computation can be stopped). """ return self._computeThread.is_alive() def push(self, command): """ Try and push the given command to the undo stack. Args: command (commands.UndoCommand): the command to push """ return self._undoStack.tryAndPush(command) def groupedGraphModification(self, title, disableUpdates=True): """ Get a GroupedGraphModification for this Graph. Args: title (str): the title of the macro command disableUpdates (bool): whether to disable graph updates Returns: GroupedGraphModification: the instantiated context manager """ return commands.GroupedGraphModification(self._graph, self._undoStack, title, disableUpdates) def beginModification(self, name): """ Begin a Graph modification. Calls to beginModification and endModification may be nested, but every call to beginModification must have a matching call to endModification. """ self._modificationCount += 1 self._undoStack.beginMacro(name) def endModification(self): """ Ends a Graph modification. Must match a call to beginModification. """ assert self._modificationCount > 0 self._modificationCount -= 1 self._undoStack.endMacro() @Slot(str, QPoint, result=QObject) def addNewNode(self, nodeType, position=None, **kwargs): """ [Undoable] Create a new Node of type 'nodeType' and returns it. Args: nodeType (str): the type of the Node to create. position (QPoint): (optional) the initial position of the node **kwargs: optional node attributes values Returns: Node: the created node """ if isinstance(position, QPoint): position = Position(position.x(), position.y()) return self.push( commands.AddNodeCommand(self._graph, nodeType, position=position, **kwargs)) @Slot(Node, QPoint) def moveNode(self, node, position): """ Move 'node' to the given 'position'. Args: node (Node): the node to move position (QPoint): the target position """ if isinstance(position, QPoint): position = Position(position.x(), position.y()) self.push(commands.MoveNodeCommand(self._graph, node, position)) @Slot(Node) def removeNode(self, node): self.push(commands.RemoveNodeCommand(self._graph, node)) @Slot(Node) def removeNodesFrom(self, startNode): """ Remove all nodes starting from 'startNode' to graph leaves. Args: startNode (Node): the node to start from. """ with self.groupedGraphModification("Remove Nodes from {}".format( startNode.name)): # Perform nodes removal from leaves to start node so that edges # can be re-created in correct order on redo. [ self.removeNode(node) for node in reversed(self._graph.nodesFromNode(startNode)[0]) ] @Slot(Attribute, Attribute) def addEdge(self, src, dst): if isinstance(dst, ListAttribute) and not isinstance(src, ListAttribute): with self.groupedGraphModification( "Insert and Add Edge on {}".format(dst.getFullName())): self.appendAttribute(dst) self.push(commands.AddEdgeCommand(self._graph, src, dst.at(-1))) else: self.push(commands.AddEdgeCommand(self._graph, src, dst)) @Slot(Edge) def removeEdge(self, edge): if isinstance(edge.dst.root, ListAttribute): with self.groupedGraphModification( "Remove Edge and Delete {}".format( edge.dst.getFullName())): self.push(commands.RemoveEdgeCommand(self._graph, edge)) self.removeAttribute(edge.dst) else: self.push(commands.RemoveEdgeCommand(self._graph, edge)) @Slot(Attribute, "QVariant") def setAttribute(self, attribute, value): self.push(commands.SetAttributeCommand(self._graph, attribute, value)) @Slot(Attribute) def resetAttribute(self, attribute): """ Reset 'attribute' to its default value """ self.push( commands.SetAttributeCommand(self._graph, attribute, attribute.defaultValue())) @Slot(Node, bool, result="QVariantList") def duplicateNode(self, srcNode, duplicateFollowingNodes=False): """ Duplicate a node an optionally all the following nodes to graph leaves. Args: srcNode (Node): node to start the duplication from duplicateFollowingNodes (bool): whether to duplicate all the following nodes to graph leaves Returns: [Nodes]: the list of duplicated nodes """ title = "Duplicate Nodes from {}" if duplicateFollowingNodes else "Duplicate {}" # enable updates between duplication and layout to get correct depths during layout with self.groupedGraphModification(title.format(srcNode.name), disableUpdates=False): # disable graph updates during duplication with self.groupedGraphModification("Node duplication", disableUpdates=True): duplicates = self.push( commands.DuplicateNodeCommand(self._graph, srcNode, duplicateFollowingNodes)) # move nodes below the bounding box formed by the duplicated node(s) bbox = self._layout.boundingBox(duplicates) for n in duplicates: self.moveNode( n, Position(n.x, bbox[3] + self.layout.gridSpacing + n.y)) return duplicates @Slot(CompatibilityNode, result=Node) def upgradeNode(self, node): """ Upgrade a CompatibilityNode. """ return self.push(commands.UpgradeNodeCommand(self._graph, node)) @Slot() def upgradeAllNodes(self): """ Upgrade all upgradable CompatibilityNode instances in the graph. """ with self.groupedGraphModification("Upgrade all Nodes"): nodes = [ n for n in self._graph._compatibilityNodes.values() if n.canUpgrade ] for node in nodes: self.upgradeNode(node) @Slot() def forceNodesStatusUpdate(self): """ Force re-evaluation of graph's nodes status. """ self._graph.updateStatusFromCache(force=True) @Slot(Attribute, QJsonValue) def appendAttribute(self, attribute, value=QJsonValue()): if isinstance(value, QJsonValue): if value.isArray(): pyValue = value.toArray().toVariantList() else: pyValue = None if value.isNull() else value.toObject() else: pyValue = value self.push( commands.ListAttributeAppendCommand(self._graph, attribute, pyValue)) @Slot(Attribute) def removeAttribute(self, attribute): self.push(commands.ListAttributeRemoveCommand(self._graph, attribute)) def clearNodeSelection(self): """ Clear node selection. """ self.selectedNode = None def clearNodeHover(self): """ Reset currently hovered node to None. """ self.hoveredNode = None undoStack = Property(QObject, lambda self: self._undoStack, constant=True) graphChanged = Signal() graph = Property(Graph, lambda self: self._graph, notify=graphChanged) nodes = Property(QObject, lambda self: self._graph.nodes, notify=graphChanged) layout = Property(GraphLayout, lambda self: self._layout, constant=True) computeStatusChanged = Signal() computing = Property(bool, isComputing, notify=computeStatusChanged) computingExternally = Property(bool, isComputingExternally, notify=computeStatusChanged) computingLocally = Property(bool, isComputingLocally, notify=computeStatusChanged) canSubmit = Property(bool, lambda self: len(submitters), constant=True) sortedDFSChunks = Property(QObject, lambda self: self._sortedDFSChunks, constant=True) lockedChanged = Signal() selectedNodeChanged = Signal() # Currently selected node selectedNode = makeProperty(QObject, "_selectedNode", selectedNodeChanged, resetOnDestroy=True) hoveredNodeChanged = Signal() # Currently hovered node hoveredNode = makeProperty(QObject, "_hoveredNode", hoveredNodeChanged, resetOnDestroy=True)
class Reconstruction(UIGraph): """ Specialization of a UIGraph designed to manage a 3D reconstruction. """ def __init__(self, graphFilepath='', parent=None): super(Reconstruction, self).__init__(graphFilepath, parent) # initialize member variables for key steps of the 3D reconstruction pipeline # - CameraInit self._cameraInit = None # current CameraInit node self._cameraInits = QObjectListModel( parent=self) # all CameraInit nodes self._buildingIntrinsics = False self.intrinsicsBuilt.connect(self.onIntrinsicsAvailable) # - Feature Extraction self._featureExtraction = None self.cameraInitChanged.connect(self.updateFeatureExtraction) # - SfM self._sfm = None self._views = None self._poses = None self._selectedViewId = None self._liveSfmManager = LiveSfmManager(self) # - Texturing self._texturing = None # react to internal graph changes to update those variables self.graphChanged.connect(self.onGraphChanged) if graphFilepath: self.onGraphChanged() else: self.new() @Slot() def new(self): """ Create a new photogrammetry pipeline. """ self.setGraph(multiview.photogrammetry()) def load(self, filepath): try: super(Reconstruction, self).load(filepath) # warn about pre-release projects being automatically upgraded if Version(self._graph.fileReleaseVersion).major == "0": self.warning.emit( Message( "Automatic project upgrade", "This project was created with an older version of Meshroom and has been automatically upgraded.\n" "Data might have been lost in the process.", "Open it with the corresponding version of Meshroom to recover your data." )) except Exception as e: import traceback trace = traceback.format_exc() self.error.emit( Message( "Error while loading {}".format( os.path.basename(filepath)), "An unexpected error has occurred", trace)) logging.error(trace) def onGraphChanged(self): """ React to the change of the internal graph. """ self._liveSfmManager.reset() self.featureExtraction = None self.sfm = None self.texturing = None self.updateCameraInits() if not self._graph: return self.setSfm(self.lastSfmNode()) # TODO: listen specifically for cameraInit creation/deletion self._graph.nodes.countChanged.connect(self.updateCameraInits) @staticmethod def runAsync(func, args=(), kwargs=None): thread = Thread(target=func, args=args, kwargs=kwargs) thread.start() return thread def getViewpoints(self): """ Return the Viewpoints model. """ # TODO: handle multiple Viewpoints models return self._cameraInit.viewpoints.value if self._cameraInit else None def updateCameraInits(self): cameraInits = self._graph.nodesByType("CameraInit", sortedByIndex=True) if set(self._cameraInits.objectList()) == set(cameraInits): return self._cameraInits.setObjectList(cameraInits) self.cameraInit = cameraInits[0] if cameraInits else None def getCameraInitIndex(self): if not self._cameraInit: return -1 return self._cameraInits.indexOf(self._cameraInit) def setCameraInitIndex(self, idx): camInit = self._cameraInits[idx] if self._cameraInits else None self.cameraInit = camInit def updateFeatureExtraction(self): """ Set the current FeatureExtraction node based on the current CameraInit node. """ self.featureExtraction = self.lastNodeOfType( 'FeatureExtraction', self.cameraInit) if self.cameraInit else None def lastSfmNode(self): """ Retrieve the last SfM node from the initial CameraInit node. """ return self.lastNodeOfType("StructureFromMotion", self._cameraInit, Status.SUCCESS) def lastNodeOfType(self, nodeType, startNode, preferredStatus=None): """ Returns the last node of the given type starting from 'startNode'. If 'preferredStatus' is specified, the last node with this status will be considered in priority. Args: nodeType (str): the node type startNode (Node): the node to start from preferredStatus (Status): (optional) the node status to prioritize Returns: Node: the node matching the input parameters or None """ if not startNode: return None nodes = self._graph.nodesFromNode(startNode, nodeType)[0] if not nodes: return None node = nodes[-1] if preferredStatus: node = next((n for n in reversed(nodes) if n.getGlobalStatus() == preferredStatus), node) return node def addSfmAugmentation(self, withMVS=False): """ Create a new augmentation step connected to the last SfM node of this Reconstruction and return the created CameraInit and SfM nodes. If the Reconstruction is not initialized (empty initial CameraInit), this method won't create anything and return initial CameraInit and SfM nodes. Args: withMVS (bool): whether to create the MVS pipeline after the augmentation Returns: Node, Node: CameraInit, StructureFromMotion """ sfm = self.lastSfmNode() if not sfm: return None, None if len(self._cameraInits) == 1: assert self._cameraInit == self._cameraInits[0] # Initial CameraInit is empty, use this one if len(self._cameraInits[0].viewpoints) == 0: return self._cameraInit, sfm # enable updates between duplication and layout to get correct depths during layout with self.groupedGraphModification("SfM Augmentation", disableUpdates=False): # disable graph updates when adding augmentation branch with self.groupedGraphModification("Augmentation", disableUpdates=True): sfm, mvs = multiview.sfmAugmentation(self, self.lastSfmNode(), withMVS=withMVS) first, last = sfm[0], mvs[-1] if mvs else sfm[-1] # use graph current bounding box height to spawn the augmentation branch bb = self.layout.boundingBox() self.layout.autoLayout(first, last, bb[0], bb[3] + self._layout.gridSpacing) self.sfmAugmented.emit(first, last) return sfm[0], sfm[-1] def allImagePaths(self): """ Get all image paths in the reconstruction. """ return [ vp.path.value for node in self._cameraInits for vp in node.viewpoints.value ] def allViewIds(self): """ Get all view Ids involved in the reconstruction. """ return [ vp.viewId.value for node in self._cameraInits for vp in node.viewpoints.value ] @Slot(QObject, Node) def handleFilesDrop(self, drop, cameraInit): """ Handle drop events aiming to add images to the Reconstruction. 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) @staticmethod def getImageFilesFromDrop(drop): urls = drop.property("urls") # Build the list of images paths images = [] 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) return images def importImages(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, )) def buildIntrinsics(self, cameraInit, additionalViews, rebuild=False): """ Build up-to-date intrinsics and views based on already loaded + additional images. Does not modify the graph, can be called outside the main thread. Emits intrinsicBuilt(views, intrinsics) when done. Args: cameraInit (Node): CameraInit node to build the intrinsics for additionalViews: list of additional views to add to the CameraInit viewpoints rebuild (bool): whether to rebuild already created intrinsics """ views = [] intrinsics = [] # Duplicate 'cameraInit' outside the graph. # => allows to compute intrinsics without modifying the node or the graph # If cameraInit is None (i.e: SfM augmentation): # * create an uninitialized node # * wait for the result before actually creating new nodes in the graph (see onIntrinsicsAvailable) inputs = cameraInit.toDict()["inputs"] if cameraInit else {} cameraInitCopy = Node("CameraInit", **inputs) if rebuild: # if rebuilding all intrinsics, for each Viewpoint: for vp in cameraInitCopy.viewpoints.value: vp.intrinsicId.resetValue() # reset intrinsic assignation vp.metadata.resetValue( ) # and metadata (to clear any previous 'SensorWidth' entries) # reset existing intrinsics list cameraInitCopy.intrinsics.resetValue() try: self.setBuildingIntrinsics(True) # Retrieve the list of updated viewpoints and intrinsics views, intrinsics = cameraInitCopy.nodeDesc.buildIntrinsics( cameraInitCopy, additionalViews) except Exception: import traceback logging.error("Error while building intrinsics : {}".format( traceback.format_exc())) # Delete the duplicate cameraInitCopy.deleteLater() self.setBuildingIntrinsics(False) # always emit intrinsicsBuilt signal to inform listeners # in other threads that computation is over self.intrinsicsBuilt.emit(cameraInit, views, intrinsics, rebuild) @Slot(Node) def rebuildIntrinsics(self, cameraInit): """ Rebuild intrinsics of 'cameraInit' from scratch. Args: cameraInit (Node): the CameraInit node """ self.runAsync(self.buildIntrinsics, args=(cameraInit, (), True)) def onIntrinsicsAvailable(self, cameraInit, views, intrinsics, rebuild=False): """ Update CameraInit with given views and intrinsics. """ augmentSfM = cameraInit is None commandTitle = "Add {} Images" # SfM augmentation if augmentSfM: # filter out views already involved in the reconstruction allViewIds = self.allViewIds() views = [ view for view in views if int(view["viewId"]) not in allViewIds ] commandTitle = "Augment Reconstruction ({} Images)" if rebuild: commandTitle = "Rebuild '{}' Intrinsics".format(cameraInit.label) # No additional views: early return if not views: return commandTitle = commandTitle.format(len(views)) # allow updates between commands so that node depths # are updated after "addSfmAugmentation" (useful for auto layout) with self.groupedGraphModification(commandTitle, disableUpdates=False): if augmentSfM: cameraInit, self.sfm = self.addSfmAugmentation(withMVS=True) with self.groupedGraphModification("Set Views and Intrinsics"): self.setAttribute(cameraInit.viewpoints, views) self.setAttribute(cameraInit.intrinsics, intrinsics) self.cameraInit = cameraInit def setBuildingIntrinsics(self, value): if self._buildingIntrinsics == value: return self._buildingIntrinsics = value self.buildingIntrinsicsChanged.emit() cameraInitChanged = Signal() cameraInit = makeProperty(QObject, "_cameraInit", cameraInitChanged, resetOnDestroy=True) cameraInitIndex = Property(int, getCameraInitIndex, setCameraInitIndex, notify=cameraInitChanged) viewpoints = Property(QObject, getViewpoints, notify=cameraInitChanged) cameraInits = Property(QObject, lambda self: self._cameraInits, constant=True) intrinsicsBuilt = Signal(QObject, list, list, bool) buildingIntrinsicsChanged = Signal() buildingIntrinsics = Property(bool, lambda self: self._buildingIntrinsics, notify=buildingIntrinsicsChanged) liveSfmManager = Property(QObject, lambda self: self._liveSfmManager, constant=True) def updateViewsAndPoses(self): """ Update internal views and poses based on the current SfM node. """ if not self._sfm: self._views = dict() self._poses = dict() else: self._views, self._poses = self._sfm.nodeDesc.getViewsAndPoses( self._sfm) self.sfmReportChanged.emit() def getSfm(self): """ Returns the current SfM node. """ return self._sfm def _unsetSfm(self): """ Unset current SfM node. This is shortcut equivalent to _setSfm(None). """ self._setSfm(None) def _setSfm(self, node): """ Set current SfM node to 'node' and update views and poses. Notes: this should not be called directly, use setSfm instead. See Also: setSfm """ self._sfm = node # Update views and poses and do so each time # the status of the SfM node's only chunk changes self.updateViewsAndPoses() if self._sfm: # when destroyed, directly use '_setSfm' to bypass # disconnection step in 'setSfm' (at this point, 'self._sfm' underlying object # has been destroyed and can't be evaluated anymore) self._sfm.destroyed.connect(self._unsetSfm) self._sfm.chunks[0].statusChanged.connect(self.updateViewsAndPoses) self.sfmChanged.emit() def setSfm(self, node): """ Set the current SfM node. This node will be used to retrieve sparse reconstruction result like camera poses. """ # disconnect from previous SfM node if any if self._sfm: self._sfm.chunks[0].statusChanged.disconnect( self.updateViewsAndPoses) self._sfm.destroyed.disconnect(self._unsetSfm) self._setSfm(node) self.texturing = self.lastNodeOfType("Texturing", self._sfm, Status.SUCCESS) @Slot(QObject, result=bool) def isInViews(self, viewpoint): if not viewpoint: return False # keys are strings (faster lookup) return str(viewpoint.viewId.value) in self._views @Slot(QObject, result=bool) def isReconstructed(self, viewpoint): if not viewpoint: return False # fetch up-to-date poseId from sfm result (in case of rigs, poseId might have changed) view = self._views.get(str(viewpoint.poseId.value), None) # keys are strings (faster lookup) return view.get('poseId', -1) in self._poses if view else False @Slot(QObject, result=bool) def hasValidIntrinsic(self, viewpoint): # keys are strings (faster lookup) allIntrinsicIds = [ i.intrinsicId.value for i in self._cameraInit.intrinsics.value ] return viewpoint.intrinsicId.value in allIntrinsicIds @Slot(QObject, result=QObject) def getIntrinsic(self, viewpoint): """ Get the intrinsic attribute associated to 'viewpoint' based on its intrinsicId. Args: viewpoint (Attribute): the Viewpoint to consider. Returns: Attribute: the Viewpoint's corresponding intrinsic or None if not found. """ if not viewpoint: return None return next((i for i in self._cameraInit.intrinsics.value if i.intrinsicId.value == viewpoint.intrinsicId.value), None) @Slot(QObject, result=bool) def hasMetadata(self, viewpoint): # Should be greater than 2 to avoid the particular case of "" return len(viewpoint.metadata.value) > 2 def setSelectedViewId(self, viewId): if viewId == self._selectedViewId: return self._selectedViewId = viewId self.selectedViewIdChanged.emit() def reconstructedCamerasCount(self): """ Get the number of reconstructed cameras in the current context. """ return len( [v for v in self.getViewpoints() if self.isReconstructed(v)]) selectedViewIdChanged = Signal() selectedViewId = Property(str, lambda self: self._selectedViewId, setSelectedViewId, notify=selectedViewIdChanged) sfmChanged = Signal() sfm = Property(QObject, getSfm, setSfm, notify=sfmChanged) featureExtractionChanged = Signal() featureExtraction = makeProperty(QObject, "_featureExtraction", featureExtractionChanged, resetOnDestroy=True) sfmReportChanged = Signal() # convenient property for QML binding re-evaluation when sfm report changes sfmReport = Property(bool, lambda self: len(self._poses) > 0, notify=sfmReportChanged) sfmAugmented = Signal(Node, Node) texturingChanged = Signal() texturing = makeProperty(QObject, "_texturing", notify=texturingChanged) nbCameras = Property(int, reconstructedCamerasCount, notify=sfmReportChanged) # Signals to propagate high-level messages error = Signal(Message) warning = Signal(Message) info = Signal(Message)
class CsvData(QObject): """Store data from a CSV file.""" def __init__(self, parent=None): """Initialize the object without any parameter.""" super(CsvData, self).__init__(parent=parent) self._filepath = "" self._data = QObjectListModel(parent=self) # List of CsvColumn self._ready = False self.filepathChanged.connect(self.updateData) @Slot(int, result=QObject) def getColumn(self, index): return self._data.at(index) @Slot(result=str) def getFilepath(self): return self._filepath @Slot(result=int) def getNbColumns(self): if self._ready: return len(self._data) else: return 0 @Slot(str) def setFilepath(self, filepath): if self._filepath == filepath: return self.setReady(False) self._filepath = filepath self.filepathChanged.emit() def setReady(self, ready): if self._ready == ready: return self._ready = ready self.readyChanged.emit() @Slot() def updateData(self): self.setReady(False) self._data.clear() newColumns = self.read() if newColumns: self._data.setObjectList(newColumns) self.setReady(True) def read(self): """Read the CSV file and return a list containing CsvColumn objects.""" if not self._filepath or not self._filepath.lower().endswith( ".csv") or not os.path.isfile(self._filepath): return [] dataList = [] try: csvRows = [] with open(self._filepath, "r") as fp: reader = csv.reader(fp) for row in reader: csvRows.append(row) # Create the objects in dataList # with the first line elements as objects' title for elt in csvRows[0]: dataList.append(CsvColumn(elt)) # , parent=self._data # Populate the content attribute for elt in csvRows[1:]: for idx, value in enumerate(elt): dataList[idx].appendValue(value) except Exception as e: logging.error("CsvData: Failed to load file: {}\n{}".format( self._filepath, str(e))) return dataList filepathChanged = Signal() filepath = Property(str, getFilepath, setFilepath, notify=filepathChanged) readyChanged = Signal() ready = Property(bool, lambda self: self._ready, notify=readyChanged) data = Property(QObject, lambda self: self._data, notify=readyChanged) nbColumns = Property(int, getNbColumns, notify=readyChanged)
class Reconstruction(UIGraph): """ Specialization of a UIGraph designed to manage a 3D reconstruction. """ imageExtensions = ('.jpg', '.jpeg', '.tif', '.tiff', '.png', '.exr', '.rw2', '.cr2', '.nef') def __init__(self, graphFilepath='', parent=None): super(Reconstruction, self).__init__(graphFilepath, parent) self._buildingIntrinsics = False self._cameraInit = None self._cameraInits = QObjectListModel(parent=self) self._endChunk = None self._meshFile = '' self.intrinsicsBuilt.connect(self.onIntrinsicsAvailable) self.graphChanged.connect(self.onGraphChanged) self._liveSfmManager = LiveSfmManager(self) # SfM result self._sfm = None self._views = None self._poses = None self._selectedViewId = None if graphFilepath: self.onGraphChanged() else: self.new() @Slot() def new(self): """ Create a new photogrammetry pipeline. """ self.setGraph(multiview.photogrammetry()) def load(self, filepath): try: super(Reconstruction, self).load(filepath) # warn about pre-release projects being automatically upgraded if Version(self._graph.fileReleaseVersion).major == "0": self.warning.emit( Message( "Automatic project upgrade", "This project was created with an older version of Meshroom and has been automatically upgraded.\n" "Data might have been lost in the process.", "Open it with the corresponding version of Meshroom to recover your data." )) except Exception as e: import traceback trace = traceback.format_exc() self.error.emit( Message( "Error while loading {}".format( os.path.basename(filepath)), "An unexpected error has occurred", trace)) logging.error(trace) def onGraphChanged(self): """ React to the change of the internal graph. """ self._liveSfmManager.reset() self.sfm = None self._endChunk = None self.setMeshFile('') self.updateCameraInits() if not self._graph: return self.setSfm(self.lastSfmNode()) try: endNode = self._graph.findNode("Texturing") self._endChunk = endNode.getChunks()[0] # type: graph.NodeChunk endNode.outputMesh.valueChanged.connect(self.updateMeshFile) self._endChunk.statusChanged.connect(self.updateMeshFile) self.updateMeshFile() except KeyError: self._endChunk = None # TODO: listen specifically for cameraInit creation/deletion self._graph.nodes.countChanged.connect(self.updateCameraInits) @staticmethod def runAsync(func, args=(), kwargs=None): thread = Thread(target=func, args=args, kwargs=kwargs) thread.start() return thread def getViewpoints(self): """ Return the Viewpoints model. """ # TODO: handle multiple Viewpoints models return self._cameraInit.viewpoints.value if self._cameraInit else None def updateCameraInits(self): cameraInits = self._graph.nodesByType("CameraInit", sortedByIndex=True) if set(self._cameraInits.objectList()) == set(cameraInits): return self._cameraInits.setObjectList(cameraInits) self.setCameraInit(cameraInits[0] if cameraInits else None) def setCameraInit(self, cameraInit): """ Set the internal CameraInit node. """ # TODO: handle multiple CameraInit nodes if self._cameraInit == cameraInit: return self._cameraInit = cameraInit self.cameraInitChanged.emit() def getCameraInitIndex(self): if not self._cameraInit: return -1 return self._cameraInits.indexOf(self._cameraInit) def setCameraInitIndex(self, idx): self.setCameraInit(self._cameraInits[idx]) def updateMeshFile(self): if self._endChunk and self._endChunk.status.status == Status.SUCCESS: self.setMeshFile(self._endChunk.node.outputMesh.value) else: self.setMeshFile('') def setMeshFile(self, mf): if self._meshFile == mf: return self._meshFile = mf self.meshFileChanged.emit() def lastSfmNode(self): """ Retrieve the last SfM node from the initial CameraInit node. """ sfmNodes = self._graph.nodesFromNode(self._cameraInits[0], 'StructureFromMotion')[0] return sfmNodes[-1] if sfmNodes else None def addSfmAugmentation(self, withMVS=False): """ Create a new augmentation step connected to the last SfM node of this Reconstruction and return the created CameraInit and SfM nodes. If the Reconstruction is not initialized (empty initial CameraInit), this method won't create anything and return initial CameraInit and SfM nodes. Args: withMVS (bool): whether to create the MVS pipeline after the augmentation Returns: Node, Node: CameraInit, StructureFromMotion """ sfm = self.lastSfmNode() if not sfm: return None, None if len(self._cameraInits) == 1: assert self._cameraInit == self._cameraInits[0] # Initial CameraInit is empty, use this one if len(self._cameraInits[0].viewpoints) == 0: return self._cameraInit, sfm # enable updates between duplication and layout to get correct depths during layout with self.groupedGraphModification("SfM Augmentation", disableUpdates=False): # disable graph updates when adding augmentation branch with self.groupedGraphModification("Augmentation", disableUpdates=True): sfm, mvs = multiview.sfmAugmentation(self, self.lastSfmNode(), withMVS=withMVS) first, last = sfm[0], mvs[-1] if mvs else sfm[-1] # use graph current bounding box height to spawn the augmentation branch bb = self.layout.boundingBox() self.layout.autoLayout(first, last, bb[0], bb[3] + self._layout.gridSpacing) self.sfmAugmented.emit(first, last) return sfm[0], sfm[-1] def allImagePaths(self): """ Get all image paths in the reconstruction. """ return [ vp.path.value for node in self._cameraInits for vp in node.viewpoints.value ] def allViewIds(self): """ Get all view Ids involved in the reconstruction. """ return [ vp.viewId.value for node in self._cameraInits for vp in node.viewpoints.value ] @Slot(QObject, Node) def handleFilesDrop(self, drop, cameraInit): """ Handle drop events aiming to add images to the Reconstruction. 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) @staticmethod def isImageFile(filepath): """ Return whether filepath is a path to an image file supported by Meshroom. """ return os.path.splitext( filepath)[1].lower() in Reconstruction.imageExtensions @staticmethod def getImageFilesFromDrop(drop): urls = drop.property("urls") # Build the list of images paths images = [] for url in urls: localFile = url.toLocalFile() if os.path.isdir(localFile): # get folder content files = [ os.path.join(localFile, f) for f in os.listdir(localFile) ] else: files = [localFile] images.extend([f for f in files if Reconstruction.isImageFile(f)]) return images def importImages(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, )) def buildIntrinsics(self, cameraInit, additionalViews): """ Build up-to-date intrinsics and views based on already loaded + additional images. Does not modify the graph, can be called outside the main thread. Emits intrinsicBuilt(views, intrinsics) when done. """ views = [] intrinsics = [] # Duplicate 'cameraInit' outside the graph. # => allows to compute intrinsics without modifying the node or the graph # If cameraInit is None (i.e: SfM augmentation): # * create an uninitialized node # * wait for the result before actually creating new nodes in the graph (see onIntrinsicsAvailable) inputs = cameraInit.toDict()["inputs"] if cameraInit else {} cameraInitCopy = Node("CameraInit", **inputs) try: self.setBuildingIntrinsics(True) # Retrieve the list of updated viewpoints and intrinsics views, intrinsics = cameraInitCopy.nodeDesc.buildIntrinsics( cameraInitCopy, additionalViews) except Exception: import traceback logging.error("Error while building intrinsics : {}".format( traceback.format_exc())) # Delete the duplicate cameraInitCopy.deleteLater() self.setBuildingIntrinsics(False) # always emit intrinsicsBuilt signal to inform listeners # in other threads that computation is over self.intrinsicsBuilt.emit(cameraInit, views, intrinsics) def onIntrinsicsAvailable(self, cameraInit, views, intrinsics): """ Update CameraInit with given views and intrinsics. """ augmentSfM = cameraInit is None commandTitle = "Add {} Images" # SfM augmentation if augmentSfM: # filter out views already involved in the reconstruction allViewIds = self.allViewIds() views = [ view for view in views if int(view["viewId"]) not in allViewIds ] commandTitle = "Augment Reconstruction ({} Images)" # No additional views: early return if not views: return commandTitle = commandTitle.format(len(views)) # allow updates between commands so that node depths # are updated after "addSfmAugmentation" (useful for auto layout) with self.groupedGraphModification(commandTitle, disableUpdates=False): if augmentSfM: cameraInit, self.sfm = self.addSfmAugmentation(withMVS=True) with self.groupedGraphModification("Set Views and Intrinsics"): self.setAttribute(cameraInit.viewpoints, views) self.setAttribute(cameraInit.intrinsics, intrinsics) self.setCameraInit(cameraInit) def setBuildingIntrinsics(self, value): if self._buildingIntrinsics == value: return self._buildingIntrinsics = value self.buildingIntrinsicsChanged.emit() cameraInitChanged = Signal() cameraInit = Property(QObject, lambda self: self._cameraInit, notify=cameraInitChanged) cameraInitIndex = Property(int, getCameraInitIndex, setCameraInitIndex, notify=cameraInitChanged) viewpoints = Property(QObject, getViewpoints, notify=cameraInitChanged) cameraInits = Property(QObject, lambda self: self._cameraInits, constant=True) intrinsicsBuilt = Signal(QObject, list, list) buildingIntrinsicsChanged = Signal() buildingIntrinsics = Property(bool, lambda self: self._buildingIntrinsics, notify=buildingIntrinsicsChanged) meshFileChanged = Signal() meshFile = Property(str, lambda self: self._meshFile, notify=meshFileChanged) liveSfmManager = Property(QObject, lambda self: self._liveSfmManager, constant=True) def updateViewsAndPoses(self): """ Update internal views and poses based on the current SfM node. """ if not self._sfm: self._views = [] self._poses = [] else: self._views, self._poses = self._sfm.nodeDesc.getViewsAndPoses( self._sfm) self.sfmReportChanged.emit() def getSfm(self): """ Returns the current SfM node. """ return self._sfm def _unsetSfm(self): """ Unset current SfM node. This is shortcut equivalent to _setSfm(None). """ self._setSfm(None) def _setSfm(self, node): """ Set current SfM node to 'node' and update views and poses. Notes: this should not be called directly, use setSfm instead. See Also: setSfm """ self._sfm = node # Update views and poses and do so each time # the status of the SfM node's only chunk changes self.updateViewsAndPoses() if self._sfm: # when destroyed, directly use '_setSfm' to bypass # disconnection step in 'setSfm' (at this point, 'self._sfm' underlying object # has been destroyed and can't be evaluated anymore) self._sfm.destroyed.connect(self._unsetSfm) self._sfm.chunks[0].statusChanged.connect(self.updateViewsAndPoses) self.sfmChanged.emit() def setSfm(self, node): """ Set the current SfM node. This node will be used to retrieve sparse reconstruction result like camera poses. """ # disconnect from previous SfM node if any if self._sfm: self._sfm.chunks[0].statusChanged.disconnect( self.updateViewsAndPoses) self._sfm.destroyed.disconnect(self._unsetSfm) self._setSfm(node) @Slot(QObject, result=bool) def isInViews(self, viewpoint): # keys are strings (faster lookup) return str(viewpoint.viewId.value) in self._views @Slot(QObject, result=bool) def isReconstructed(self, viewpoint): # keys are strings (faster lookup) return str(viewpoint.poseId.value) in self._poses @Slot(QObject, result=bool) def hasValidIntrinsic(self, viewpoint): # keys are strings (faster lookup) allIntrinsicIds = [ i.intrinsicId.value for i in self._cameraInit.intrinsics.value ] return viewpoint.intrinsicId.value in allIntrinsicIds @Slot(QObject, result=bool) def hasMetadata(self, viewpoint): # Should be greater than 2 to avoid the particular case of "" return len(viewpoint.metadata.value) > 2 def setSelectedViewId(self, viewId): if viewId == self._selectedViewId: return self._selectedViewId = viewId self.selectedViewIdChanged.emit() selectedViewIdChanged = Signal() selectedViewId = Property(str, lambda self: self._selectedViewId, setSelectedViewId, notify=selectedViewIdChanged) sfmChanged = Signal() sfm = Property(QObject, getSfm, setSfm, notify=sfmChanged) sfmReportChanged = Signal() # convenient property for QML binding re-evaluation when sfm report changes sfmReport = Property(bool, lambda self: len(self._poses) > 0, notify=sfmReportChanged) sfmAugmented = Signal(Node, Node) nbCameras = Property(int, lambda self: len(self._poses), notify=sfmReportChanged) # Signals to propagate high-level messages error = Signal(Message) warning = Signal(Message) info = Signal(Message)