From 0e08291f8a58d9a414ce8c23e9a94fd94e500082 Mon Sep 17 00:00:00 2001 From: Yann Lanthony Date: Fri, 24 Nov 2017 16:44:26 +0100 Subject: [PATCH] [ui] introduce UIGraph + ChunksMonitor * extract UIGraph from Reconstruction: base class that wraps a core.Graph, without knowledge of photogrammetry pipeline * ChunksMonitor: watch NodeChunks status files for external changes to keep UI updated even when the graph is being computed externally * Reconstruction inherits UIGraph with photogrammetry specific features --- meshroom/ui/graph.py | 273 ++++++++++++++++++++++++++++++++++ meshroom/ui/qml/main.qml | 6 +- meshroom/ui/reconstruction.py | 198 ++++++------------------ 3 files changed, 325 insertions(+), 152 deletions(-) create mode 100644 meshroom/ui/graph.py diff --git a/meshroom/ui/graph.py b/meshroom/ui/graph.py new file mode 100644 index 00000000..58d0e7d8 --- /dev/null +++ b/meshroom/ui/graph.py @@ -0,0 +1,273 @@ +#!/usr/bin/env python +# coding:utf-8 +import logging +from threading import Thread + +import os +from PySide2.QtCore import Slot, QJsonValue, QObject, QUrl, Property, Signal + +from meshroom.common.qt import QObjectListModel +from meshroom.core import graph +from meshroom.ui import commands + + +class ChunksMonitor(QObject): + """ + ChunksMonitor regularly check NodeChunks' status files for modification and trigger their update on change. + + When working locally, status changes are reflected through the emission of 'statusChanged' signals. + But when a graph is being computed externally - either via a Submitter or on another machine, + NodeChunks status files are modified by another instance, potentially outside this machine file system scope. + Same goes when status files are deleted/modified manually. + Thus, for genericity, monitoring is based on regular polling and not file system watching. + """ + def __init__(self, chunks=(), parent=None): + super(ChunksMonitor, self).__init__(parent) + self.chunkByStatusFile = dict() + self.lastModificationRecords = dict() + self.setChunks(chunks) + # Check status files every x seconds + # TODO: adapt frequency according to graph compute status + self.startTimer(5000) + + def setChunks(self, chunks): + """ Set the list of chunks to monitor. """ + self.clear() + for chunk in chunks: + f = chunk.statusFile() + self.chunkByStatusFile[f] = chunk + # For local use, handle statusChanged emitted directly from the node chunk + chunk.statusChanged.connect(lambda ch=chunk: self.onChunkStatusChanged(chunk)) + self.lastModificationRecords[f] = os.path.getmtime(f) if os.path.exists(f) else -1 + + self.chunkStatusChanged.emit(None, -1) + + def clear(self): + """ Clear the list of monitored chunks""" + self.chunkByStatusFile.clear() + self.lastModificationRecords.clear() + + def timerEvent(self, evt): + self.checkFileTimes() + + def onChunkStatusChanged(self, chunk): + """ React to change of status coming from the NodeChunk itself. + + Args: + chunk (graph.NodeChunk): the chunk that emitted statusChanged signal + """ + f = chunk.statusFile() + assert f in self.lastModificationRecords + # Update record entry for this file so that it's up-to-date on next timerEvent + self.lastModificationRecords[f] = self.getFileLastModTime(f) + self.chunkStatusChanged.emit(chunk, chunk.status.status) + + @staticmethod + def getFileLastModTime(f): + """ Return 'mtime' of the file if it exists, -1 otherwise. """ + return os.path.getmtime(f) if os.path.exists(f) else -1 + + def checkFileTimes(self): + """ Check status files last modification time and compare with stored value """ + for f, t in self.lastModificationRecords.items(): + lastMod = self.getFileLastModTime(f) + if lastMod != t: + self.lastModificationRecords[f] = lastMod + self.chunkByStatusFile[f].updateStatusFromCache() + logging.debug("Status for node {} changed: {}".format(self.chunkByStatusFile[f].node, + self.chunkByStatusFile[f].status.status)) + + def isLocked(self): + return any([ch.status.status in (graph.Status.RUNNING, graph.Status.SUBMITTED) for ch in self.chunkByStatusFile.values()]) + + chunkStatusChanged = Signal(graph.NodeChunk, int) + + +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, filepath='', parent=None): + super(UIGraph, self).__init__(parent) + self._undoStack = commands.UndoStack(self) + self._graph = graph.Graph('', self) + self._chunksMonitor = ChunksMonitor(parent=self) + self._chunksMonitor.chunkStatusChanged.connect(self.onChunkStatusChanged) + self._computeThread = Thread() + self._orderedChunks = QObjectListModel(parent=self) + self._running = self._submitted = False + if filepath: + self.load(filepath) + + def setGraph(self, g): + """ Set the internal graph. """ + if self._graph: + self.clear() + self._graph = g + self._graph.updated.connect(self.onGraphUpdated) + self._graph.update() + self.graphChanged.emit() + + def onGraphUpdated(self): + """ Callback to any kind of graph modification. """ + # TODO: handle this with a better granularity + # TODO: make sure the list of chunks has changed before resetting it + chunks = self._graph.getOrderedChunks() + self._orderedChunks.setObjectList(chunks) + self._chunksMonitor.setChunks(chunks) + + def clear(self): + if self._graph: + self._graph.deleteLater() + self._graph = None + self._undoStack.clear() + + def load(self, filepath): + g = graph.Graph('') + g.load(filepath) + if not os.path.exists(g.cacheDir): + os.mkdir(g.cacheDir) + self.setGraph(g) + + @Slot(QUrl) + def loadUrl(self, url): + self.load(url.toLocalFile()) + + @Slot(QUrl) + def saveAs(self, url): + self._graph.save(url.toLocalFile()) + self._undoStack.setClean() + + @Slot() + def save(self): + self._graph.save() + self._undoStack.setClean() + + @Slot(graph.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: + graph.execute(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() + def submit(self): + """ Submit the whole graph to the default Submitter. """ + self.save() # graph must be saved before being submitted + graph.submitGraph(self._graph, os.environ.get('MESHROOM_DEFAULT_SUBMITTER', '')) + + def onChunkStatusChanged(self, chunk, status): + # update graph computing status + running = any([ch.status.status == graph.Status.RUNNING for ch in self._orderedChunks]) + submitted = any([ch.status.status == graph.Status.SUBMITTED for ch in self._orderedChunks]) + 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 + """ + self._undoStack.tryAndPush(command) + + def groupedGraphModification(self, title): + """ Get a GroupedGraphModification for this Reconstruction. + + Args: + title (str): the title of the macro command + + Returns: + GroupedGraphModification: the instantiated context manager + """ + return commands.GroupedGraphModification(self._graph, self._undoStack, title) + + @Slot(str) + def addNode(self, nodeType): + self.push(commands.AddNodeCommand(self._graph, nodeType)) + + @Slot(graph.Node) + def removeNode(self, node): + self.push(commands.RemoveNodeCommand(self._graph, node)) + + @Slot(graph.Attribute, graph.Attribute) + def addEdge(self, src, dst): + if isinstance(dst, graph.ListAttribute): + with self.groupedGraphModification("Insert and Add Edge on {}".format(dst.fullName())): + self.appendAttribute(dst) + self.push(commands.AddEdgeCommand(self._graph, src, dst[-1])) + else: + self.push(commands.AddEdgeCommand(self._graph, src, dst)) + + @Slot(graph.Edge) + def removeEdge(self, edge): + if isinstance(edge.dst.root, graph.ListAttribute): + with self.groupedGraphModification("Remove Edge and Delete {}".format(edge.dst.fullName())): + self.push(commands.RemoveEdgeCommand(self._graph, edge)) + self.removeAttribute(edge.dst) + else: + self.push(commands.RemoveEdgeCommand(self._graph, edge)) + + @Slot(graph.Attribute, "QVariant") + def setAttribute(self, attribute, value): + self.push(commands.SetAttributeCommand(self._graph, attribute, value)) + + @Slot(graph.Attribute, QJsonValue) + def appendAttribute(self, attribute, value=QJsonValue()): + if value.isArray(): + pyValue = value.toArray().toVariantList() + else: + pyValue = None if value.isNull() else value.toObject() + self.push(commands.ListAttributeAppendCommand(self._graph, attribute, pyValue)) + + @Slot(graph.Attribute) + def removeAttribute(self, attribute): + self.push(commands.ListAttributeRemoveCommand(self._graph, attribute)) + + undoStack = Property(QObject, lambda self: self._undoStack, constant=True) + graphChanged = Signal() + graph = Property(graph.Graph, lambda self: self._graph, notify=graphChanged) + + computeStatusChanged = Signal() + computing = Property(bool, isComputing, notify=computeStatusChanged) + computingExternally = Property(bool, isComputingExternally, notify=computeStatusChanged) + computingLocally = Property(bool, isComputingLocally, notify=computeStatusChanged) + + chunksMonitor = Property(ChunksMonitor, lambda self: self._chunksMonitor, constant=True) + # The list of NodeChunks in this graph sorted by processing order + orderedChunks = Property(QObject, lambda self: self._orderedChunks, constant=True) + lockedChanged = Signal() diff --git a/meshroom/ui/qml/main.qml b/meshroom/ui/qml/main.qml index d4a45a55..4f303049 100755 --- a/meshroom/ui/qml/main.qml +++ b/meshroom/ui/qml/main.qml @@ -12,7 +12,7 @@ ApplicationWindow { width: 1280 height: 720 visible: true - title: (_reconstruction.filepath ? _reconstruction.filepath : "Untitled") + (_reconstruction.undoStack.clean ? "" : "*") + " - Meshroom" + title: (_reconstruction.graph.filepath ? _reconstruction.graph.filepath : "Untitled") + (_reconstruction.undoStack.clean ? "" : "*") + " - Meshroom" font.pointSize: 10 property variant node: null @@ -190,7 +190,7 @@ ApplicationWindow { id: saveAction text: "Save" shortcut: "Ctrl+S" - enabled: _reconstruction.filepath != "" && !_reconstruction.undoStack.clean + enabled: _reconstruction.graph.filepath != "" && !_reconstruction.undoStack.clean onTriggered: _reconstruction.save() } Action { @@ -249,7 +249,7 @@ ApplicationWindow { } Button { text: "Stop" - enabled: _reconstruction.computing + enabled: _reconstruction.computingLocally onClicked: _reconstruction.stopExecution() } } diff --git a/meshroom/ui/reconstruction.py b/meshroom/ui/reconstruction.py index f3a5af63..555e7523 100755 --- a/meshroom/ui/reconstruction.py +++ b/meshroom/ui/reconstruction.py @@ -2,159 +2,50 @@ import logging import os from threading import Thread -from PySide2.QtCore import QObject, Slot, Property, Signal, QJsonValue, QUrl +from PySide2.QtCore import QObject, Slot, Property, Signal from meshroom import multiview -from meshroom.core import graph, defaultCacheFolder, cacheFolderName -from meshroom.ui import commands +from meshroom.core import graph +from meshroom.ui.graph import UIGraph -class Reconstruction(QObject): - - def __init__(self, graphFilepath="", parent=None): - super(Reconstruction, self).__init__(parent) - self._graph = None - self._undoStack = commands.UndoStack(self) - self._computeThread = Thread() - self._filepath = graphFilepath - if self._filepath: - self.load(self._filepath) +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) + self._buildIntrinsicsThread = None + self._endChunk = None + self._meshFile = '' + self.intrinsicsBuilt.connect(self.onIntrinsicsAvailable) + self.graphChanged.connect(self.onGraphChanged) + if graphFilepath: + self.onGraphChanged() else: self.new() - self._buildIntrinsicsThread = None - self.intrinsicsBuilt.connect(self.onIntrinsicsAvailable) - @Slot() def new(self): - self.clear() - self._graph = multiview.photogrammetryPipeline() - self._graph.cacheDir = defaultCacheFolder - self._graph.update() - self.graphChanged.emit() + """ Create a new photogrammetry pipeline. """ + self.setGraph(multiview.photogrammetryPipeline()) - def clear(self): - if self._graph: - self._graph.clear() - self._graph.deleteLater() - self._graph = None - self.setFilepath("") - self._undoStack.clear() + def onGraphChanged(self): + """ React to the change of the internal graph. """ + self._endChunk = None + self.setMeshFile('') - def setFilepath(self, path): - if self._filepath == path: + if not self._graph: return - self._filepath = path - self.filepathChanged.emit() - def push(self, command): - """ Try and push the given command to the undo stack. - - Args: - command (commands.UndoCommand): the command to push - """ - self._undoStack.tryAndPush(command) - - def groupedGraphModification(self, title): - """ Get a GroupedGraphModification for this Reconstruction. - - Args: - title (str): the title of the macro command - - Returns: - GroupedGraphModification: the instantiated context manager - """ - return commands.GroupedGraphModification(self._graph, self._undoStack, title) - - @Slot(str) - def addNode(self, nodeType): - self.push(commands.AddNodeCommand(self._graph, nodeType)) - - @Slot(graph.Node) - def removeNode(self, node): - self.push(commands.RemoveNodeCommand(self._graph, node)) - - @Slot(graph.Attribute, graph.Attribute) - def addEdge(self, src, dst): - if isinstance(dst, graph.ListAttribute): - with self.groupedGraphModification("Insert and Add Edge on {}".format(dst.fullName())): - self.appendAttribute(dst) - self.push(commands.AddEdgeCommand(self._graph, src, dst[-1])) - else: - self.push(commands.AddEdgeCommand(self._graph, src, dst)) - - @Slot(graph.Edge) - def removeEdge(self, edge): - if isinstance(edge.dst.root, graph.ListAttribute): - with self.groupedGraphModification("Remove Edge and Delete {}".format(edge.dst.fullName())): - self.push(commands.RemoveEdgeCommand(self._graph, edge)) - self.removeAttribute(edge.dst) - else: - self.push(commands.RemoveEdgeCommand(self._graph, edge)) - - @Slot(graph.Attribute, "QVariant") - def setAttribute(self, attribute, value): - self.push(commands.SetAttributeCommand(self._graph, attribute, value)) - - @Slot(graph.Attribute, QJsonValue) - def appendAttribute(self, attribute, value=QJsonValue()): - if value.isArray(): - pyValue = value.toArray().toVariantList() - else: - pyValue = None if value.isNull() else value.toObject() - self.push(commands.ListAttributeAppendCommand(self._graph, attribute, pyValue)) - - @Slot(graph.Attribute) - def removeAttribute(self, attribute): - self.push(commands.ListAttributeRemoveCommand(self._graph, attribute)) - - def load(self, filepath): - self.clear() - self._graph = graph.Graph("") - self._graph.load(filepath) - self.setFilepath(filepath) - self.graphChanged.emit() - - @Slot(QUrl) - def loadUrl(self, url): - self.load(url.toLocalFile()) - - @Slot(QUrl) - def saveAs(self, url): - self.setFilepath(url.toLocalFile()) - self.save() - - @Slot() - def save(self): - self._graph.save(self._filepath) - self._graph.cacheDir = os.path.join(os.path.dirname(self._filepath), cacheFolderName) - self._undoStack.setClean() - - @Slot(graph.Node) - def execute(self, node=None): - if self.computing: - return - nodes = [node] if node else self._graph.getLeaves() - self._computeThread = Thread(target=self._execute, args=(nodes,)) - self._computeThread.start() - - def _execute(self, nodes): - self.computingChanged.emit() try: - graph.execute(self._graph, nodes) - except Exception as e: - import traceback - logging.error("Error during Graph execution: {}".format(traceback.format_exc())) - finally: - self.computingChanged.emit() - - @Slot() - def stopExecution(self): - if not self.computing: - return - self._graph.stopExecution() - self._computeThread.join() - self.computingChanged.emit() + 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 @staticmethod def runAsync(func, args=(), kwargs=None): @@ -162,13 +53,17 @@ class Reconstruction(QObject): thread.start() return thread - undoStack = Property(QObject, lambda self: self._undoStack, constant=True) - graphChanged = Signal() - graph = Property(graph.Graph, lambda self: self._graph, notify=graphChanged) - computingChanged = Signal() - computing = Property(bool, lambda self: self._computeThread.is_alive(), notify=computingChanged) - filepathChanged = Signal() - filepath = Property(str, lambda self: self._filepath, notify=filepathChanged) + def updateMeshFile(self): + if self._endChunk and self._endChunk.status.status == graph.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() @Slot(QObject) def handleFilesDrop(self, drop): @@ -217,8 +112,13 @@ class Reconstruction(QObject): self.setAttribute(cameraInit.viewpoints, views) self.setAttribute(cameraInit.intrinsics, intrinsics) - intrinsicsBuilt = Signal(list, list) + def isBuildingIntrinsics(self): + """ Whether intrinsics are being built """ + return self._buildIntrinsicsThread and self._buildIntrinsicsThread.isAlive() + intrinsicsBuilt = Signal(list, list) buildingIntrinsicsChanged = Signal() - buildingIntrinsics = Property(bool, lambda self: self._buildIntrinsicsThread and self._buildIntrinsicsThread.isAlive(), - notify=buildingIntrinsicsChanged) + buildingIntrinsics = Property(bool, isBuildingIntrinsics, notify=buildingIntrinsicsChanged) + meshFileChanged = Signal() + meshFile = Property(str, lambda self: self._meshFile, notify=meshFileChanged) +