diff --git a/meshroom/core/graph.py b/meshroom/core/graph.py index 67b6de3b..fc91ef62 100644 --- a/meshroom/core/graph.py +++ b/meshroom/core/graph.py @@ -1248,6 +1248,7 @@ class Graph(BaseObject): def node(self, nodeName): return self._nodes.get(nodeName) + @Slot(str, result=Attribute) def attribute(self, fullName): # type: (str) -> Attribute """ diff --git a/meshroom/ui/qml/ImageDelegate.qml b/meshroom/ui/qml/ImageDelegate.qml new file mode 100644 index 00000000..adfbdab4 --- /dev/null +++ b/meshroom/ui/qml/ImageDelegate.qml @@ -0,0 +1,91 @@ +import QtQuick 2.9 +import QtQuick.Controls 2.3 +import QtQuick.Layouts 1.3 +import "filepath.js" as Filepath + + +/** + * ImageDelegate for a Viewpoint object. + */ +Item { + id: imageDelegate + + property variant viewpoint + property bool isCurrentItem: false + property alias source: _viewpoint.source + property alias metadata: _viewpoint.metadata + + signal pressed(var mouse) + + // retrieve viewpoints inner data + QtObject { + id: _viewpoint + property string source: viewpoint ? viewpoint.get("path").value : '' + property string metadataStr: viewpoint ? viewpoint.get("metadata").value : '' + property var metadata: metadataStr ? JSON.parse(viewpoint.get("metadata").value) : null + } + + MouseArea { + id: imageMA + anchors.fill: parent + anchors.margins: 6 + hoverEnabled: true + acceptedButtons: Qt.LeftButton | Qt.RightButton + onPressed: { + if (mouse.button == Qt.RightButton) + imageMenu.popup() + imageDelegate.pressed(mouse) + } + + Menu { + id: imageMenu + MenuItem { + text: "Show Containing Folder" + onClicked: { + Qt.openUrlExternally(Filepath.dirname(imageDelegate.source)) + } + } + MenuItem { + text: "Remove" + enabled: !root.readOnly + onClicked: removeImageRequest(viewpoint) + } + } + + ColumnLayout { + anchors.fill: parent + spacing: 0 + + // Image thumbnail and background + Rectangle { + id: imageBackground + color: Qt.darker(palette.base, 1.15) + Layout.fillHeight: true + Layout.fillWidth: true + border.color: isCurrentItem ? palette.highlight : Qt.darker(palette.highlight) + border.width: imageMA.containsMouse || imageDelegate.isCurrentItem ? 2 : 0 + Image { + anchors.fill: parent + anchors.margins: 4 + source: imageDelegate.source + sourceSize: Qt.size(100, 100) + asynchronous: true + autoTransform: true + fillMode: Image.PreserveAspectFit + } + } + // Image basename + Label { + Layout.fillWidth: true + padding: 2 + font.pointSize: 8 + elide: Text.ElideMiddle + horizontalAlignment: Text.AlignHCenter + text: Filepath.basename(imageDelegate.source) + background: Rectangle { + color: imageDelegate.isCurrentItem ? palette.highlight : "transparent" + } + } + } + } +} diff --git a/meshroom/ui/qml/ImageGallery.qml b/meshroom/ui/qml/ImageGallery.qml index 995ae088..f8dea21b 100644 --- a/meshroom/ui/qml/ImageGallery.qml +++ b/meshroom/ui/qml/ImageGallery.qml @@ -2,17 +2,18 @@ import QtQuick 2.7 import QtQuick.Controls 2.3 import QtQuick.Layouts 1.3 import MaterialIcons 2.2 -import "filepath.js" as Filepath import QtQml.Models 2.2 /** * ImageGallery displays as a grid of Images a model containing Viewpoints objects. + * It manages a model of multiple CameraInit nodes as individual groups. */ Panel { id: root - property alias model: grid.model + property variant cameraInits + property variant cameraInit readonly property string currentItemSource: grid.currentItem ? grid.currentItem.source : "" readonly property var currentItemMetadata: grid.currentItem ? grid.currentItem.metadata : undefined signal removeImageRequest(var attribute) @@ -21,9 +22,13 @@ Panel { implicitWidth: 100 implicitHeight: 300 title: "Images" + property int currentIndex: 0 + readonly property variant viewpoints: cameraInit.attribute('viewpoints').value + signal filesDropped(var drop) ColumnLayout { anchors.fill: parent + spacing: 4 GridView { id: grid @@ -31,102 +36,46 @@ Panel { Layout.fillWidth: true Layout.fillHeight: true - cellWidth: thumbnailSizeSlider.value - cellHeight: cellWidth ScrollBar.vertical: ScrollBar {} - keyNavigationEnabled: true - highlightFollowsCurrentItem: true + focus: true clip: true + cellWidth: thumbnailSizeSlider.value + cellHeight: cellWidth + highlightFollowsCurrentItem: true + keyNavigationEnabled: true + model: root.viewpoints - - delegate: Item { - id: imageDelegate - - readonly property bool isCurrentItem: grid.currentIndex == index - readonly property alias source: _viewpoint.source - readonly property alias metadata: _viewpoint.metadata - - // retrieve viewpoints inner data - QtObject { - id: _viewpoint - readonly property string source: object.value.get("path").value - readonly property var metadata: JSON.parse(object.value.get("metadata").value) + // Keyboard shortcut to change current image group + Keys.priority: Keys.BeforeItem + Keys.onPressed: { + if(event.modifiers & Qt.AltModifier) + { + event.accepted = true + if(event.key == Qt.Key_Right) + root.currentIndex = Math.min(root.cameraInits.count - 1, root.currentIndex + 1) + else if(event.key == Qt.Key_Left) + root.currentIndex = Math.max(0, root.currentIndex - 1) } + } + delegate: ImageDelegate { + viewpoint: object.value width: grid.cellWidth height: grid.cellHeight - MouseArea { - id: imageMA - anchors.fill: parent - anchors.margins: 6 - hoverEnabled: true - acceptedButtons: Qt.LeftButton | Qt.RightButton - onPressed: { - grid.currentIndex = index - if(mouse.button == Qt.RightButton) - imageMenu.popup() - else - grid.forceActiveFocus() - } - Menu { - id: imageMenu - MenuItem { - text: "Show Containing Folder" - onClicked: { - Qt.openUrlExternally(Filepath.dirname(imageDelegate.source)) - } - } - MenuItem { - text: "Remove" - enabled: !root.readOnly - onClicked: removeImageRequest(object) - } - } - - ColumnLayout { - anchors.fill: parent - spacing: 0 - - // Image thumbnail and background - Rectangle { - id: imageBackground - color: Qt.darker(palette.base, 1.15) - Layout.fillHeight: true - Layout.fillWidth: true - border.color: grid.currentIndex == index ? palette.highlight : Qt.darker(palette.highlight) - border.width: imageMA.containsMouse || imageDelegate.isCurrentItem ? 2 : 0 - Image { - anchors.fill: parent - anchors.margins: 4 - source: imageDelegate.source - sourceSize: Qt.size(100, 100) - asynchronous: true - autoTransform: true - fillMode: Image.PreserveAspectFit - } - } - // Image basename - Label { - Layout.fillWidth: true - padding: 2 - font.pointSize: 8 - elide: Text.ElideMiddle - horizontalAlignment: Text.AlignHCenter - text: Filepath.basename(imageDelegate.source) - background: Rectangle { - color: imageDelegate.isCurrentItem ? palette.highlight : "transparent" - } - } - } + isCurrentItem: grid.currentIndex == index + onPressed: { + grid.currentIndex = index + if(mouse.button == Qt.LeftButton) + grid.forceActiveFocus() } } // Explanatory placeholder when no image has been added yet Column { anchors.centerIn: parent - visible: model.count == 0 + visible: grid.model.count == 0 spacing: 4 Label { anchors.horizontalCenter: parent.horizontalCenter @@ -145,7 +94,7 @@ Panel { enabled: !root.readOnly // TODO: onEntered: call specific method to filter files based on extension onDropped: { - _reconstruction.handleFilesDrop(drop) + root.filesDropped(drop) } // DropArea overlay Rectangle { @@ -156,6 +105,40 @@ Panel { } } } + + RowLayout { + Layout.fillHeight: false + visible: root.cameraInits.count > 1 + anchors.horizontalCenter: parent.horizontalCenter + spacing: 2 + + ToolButton { + text: MaterialIcons.navigate_before + font.family: MaterialIcons.fontFamily + ToolTip.text: "Previous Group (Alt+Left)" + ToolTip.visible: hovered + enabled: nodesCB.currentIndex > 0 + onClicked: nodesCB.decrementCurrentIndex() + } + Label { text: "Group " } + ComboBox { + id: nodesCB + model: root.cameraInits.count + implicitWidth: 40 + currentIndex: root.currentIndex + onActivated: root.currentIndex = currentIndex + + } + Label { text: "/ " + (root.cameraInits.count - 1) } + ToolButton { + text: MaterialIcons.navigate_next + font.family: MaterialIcons.fontFamily + ToolTip.text: "Next Group (Alt+Right)" + ToolTip.visible: hovered + enabled: root.currentIndex < root.cameraInits.count - 1 + onClicked: nodesCB.incrementCurrentIndex() + } + } } footerContent: RowLayout { @@ -164,7 +147,7 @@ Panel { // Image count Label { Layout.fillWidth: true - text: model.count + " image" + (model.count > 1 ? "s" : "") + text: grid.model.count + " image" + (grid.model.count > 1 ? "s" : "") elide: Text.ElideRight } diff --git a/meshroom/ui/qml/WorkspaceView.qml b/meshroom/ui/qml/WorkspaceView.qml index 63fc8067..cbc95474 100644 --- a/meshroom/ui/qml/WorkspaceView.qml +++ b/meshroom/ui/qml/WorkspaceView.qml @@ -17,7 +17,7 @@ Item { id: root property variant reconstruction: _reconstruction - readonly property variant viewpoints: _reconstruction.viewpoints + readonly property variant cameraInits: _reconstruction.cameraInits readonly property string meshFile: _reconstruction.meshFile property bool readOnly: false @@ -45,8 +45,12 @@ Item { Layout.fillHeight: true Layout.fillWidth: true Layout.minimumWidth: defaultCellSize - model: viewpoints + cameraInits: root.cameraInits + cameraInit: _reconstruction.cameraInit + currentIndex: reconstruction.cameraInitIndex + onCurrentIndexChanged: reconstruction.cameraInitIndex = currentIndex onRemoveImageRequest: reconstruction.removeAttribute(attribute) + onFilesDropped: reconstruction.handleFilesDrop(drop, cameraInit) } Panel { diff --git a/meshroom/ui/reconstruction.py b/meshroom/ui/reconstruction.py index 6ff1df4f..78552a25 100755 --- a/meshroom/ui/reconstruction.py +++ b/meshroom/ui/reconstruction.py @@ -5,6 +5,7 @@ from threading import Thread from PySide2.QtCore import QObject, Slot, Property, Signal from meshroom import multiview +from meshroom.common.qt import QObjectListModel from meshroom.core import graph from meshroom.ui.graph import UIGraph @@ -19,7 +20,9 @@ class Reconstruction(UIGraph): def __init__(self, graphFilepath='', parent=None): super(Reconstruction, self).__init__(graphFilepath, parent) self._buildIntrinsicsThread = None + self._buildingIntrinsics = False self._cameraInit = None + self._cameraInits = QObjectListModel(parent=self) self._endChunk = None self._meshFile = '' self.intrinsicsBuilt.connect(self.onIntrinsicsAvailable) @@ -38,7 +41,7 @@ class Reconstruction(UIGraph): """ React to the change of the internal graph. """ self._endChunk = None self.setMeshFile('') - self.updateCameraInit() + self.updateCameraInits() if not self._graph: return @@ -51,7 +54,7 @@ class Reconstruction(UIGraph): except KeyError: self._endChunk = None # TODO: listen specifically for cameraInit creation/deletion - self._graph.nodes.countChanged.connect(self.updateCameraInit) + self._graph.nodes.countChanged.connect(self.updateCameraInits) @staticmethod def runAsync(func, args=(), kwargs=None): @@ -64,12 +67,11 @@ class Reconstruction(UIGraph): # TODO: handle multiple Viewpoints models return self._cameraInit.viewpoints.value if self._cameraInit else None - def updateCameraInit(self): - """ Update internal CameraInit node (Viewpoints model owner) based on graph content. """ - # TODO: handle multiple CameraInit nodes - if self._cameraInit in self._graph.nodes: + def updateCameraInits(self): + cameraInits = self._graph.nodesByType("CameraInit", sortedByIndex=True) + if set(self._cameraInits.objectList()) == set(cameraInits): return - cameraInits = self._graph.findNodeCandidates("CameraInit") + self._cameraInits.setObjectList(cameraInits) self.setCameraInit(cameraInits[0] if cameraInits else None) def setCameraInit(self, cameraInit): @@ -78,7 +80,15 @@ class Reconstruction(UIGraph): if self._cameraInit == cameraInit: return self._cameraInit = cameraInit - self.viewpointsChanged.emit() + 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 == graph.Status.SUCCESS: @@ -92,22 +102,22 @@ class Reconstruction(UIGraph): self._meshFile = mf self.meshFileChanged.emit() - @Slot(QObject) - def handleFilesDrop(self, drop): + @Slot(QObject, graph.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.importImagesFromUrls(drop.property("urls")) + 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 - @Slot(QObject) - def importImagesFromUrls(self, urls): - """ Add the given list of images (as QUrl) to the Reconstruction. """ + @staticmethod + def getImageFilesFromDrop(drop): + urls = drop.property("urls") # Build the list of images paths images = [] for url in urls: @@ -117,43 +127,52 @@ class Reconstruction(UIGraph): else: files = [localFile] images.extend([f for f in files if Reconstruction.isImageFile(f)]) - if not images: - return - # Start the process of updating views and intrinsics - self._buildIntrinsicsThread = self.runAsync(self.buildIntrinsics, args=(images,)) + return images - def buildIntrinsics(self, additionalViews): + def importImages(self, images, cameraInit): + """ Add the given list of images to the Reconstruction. """ + # Start the process of updating views and intrinsics + self._buildIntrinsicsThread = 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. """ try: - self.buildingIntrinsicsChanged.emit() + self.setBuildingIntrinsics(True) # Retrieve the list of updated viewpoints and intrinsics - views, intrinsics = self._cameraInit.nodeDesc.buildIntrinsics(self._cameraInit, additionalViews) - self.intrinsicsBuilt.emit(views, intrinsics) + views, intrinsics = cameraInit.nodeDesc.buildIntrinsics(cameraInit, additionalViews) + self.intrinsicsBuilt.emit(cameraInit, views, intrinsics) return views, intrinsics - except Exception as e: - logging.error("Error while building intrinsics : {}".format(e)) + except Exception: + import traceback + logging.error("Error while building intrinsics : {}".format(traceback.format_exc())) finally: - self.buildingIntrinsicsChanged.emit() + self.setBuildingIntrinsics(False) - def onIntrinsicsAvailable(self, views, intrinsics): + def onIntrinsicsAvailable(self, cameraInit, views, intrinsics): """ Update CameraInit with given views and intrinsics. """ with self.groupedGraphModification("Add Images"): - self.setAttribute(self._cameraInit.viewpoints, views) - self.setAttribute(self._cameraInit.intrinsics, intrinsics) + self.setAttribute(cameraInit.viewpoints, views) + self.setAttribute(cameraInit.intrinsics, intrinsics) + self.setCameraInit(cameraInit) - def isBuildingIntrinsics(self): - """ Whether intrinsics are being built """ - return self._buildIntrinsicsThread and self._buildIntrinsicsThread.isAlive() + def setBuildingIntrinsics(self, value): + if self._buildingIntrinsics == value: + return + self._buildingIntrinsics = value + self.buildingIntrinsicsChanged.emit() - viewpointsChanged = Signal() - viewpoints = Property(QObject, getViewpoints, notify=viewpointsChanged) - intrinsicsBuilt = Signal(list, list) + 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, isBuildingIntrinsics, notify=buildingIntrinsicsChanged) + buildingIntrinsics = Property(bool, lambda self: self._buildingIntrinsics, notify=buildingIntrinsicsChanged) meshFileChanged = Signal() meshFile = Property(str, lambda self: self._meshFile, notify=meshFileChanged)