Example #1
0
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 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)
Example #3
0
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)
Example #4
0
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)