[ui] handle multiple CameraInit nodes in a Reconstruction

* [ui] ImageGallery: add control to navigate image groups
* [graph] expose Node.attribute as a Slot
This commit is contained in:
Yann Lanthony 2018-01-15 14:32:25 +01:00
parent 1f0ed1f2c9
commit 273cfd9f0e
5 changed files with 220 additions and 122 deletions

View file

@ -1248,6 +1248,7 @@ class Graph(BaseObject):
def node(self, nodeName): def node(self, nodeName):
return self._nodes.get(nodeName) return self._nodes.get(nodeName)
@Slot(str, result=Attribute)
def attribute(self, fullName): def attribute(self, fullName):
# type: (str) -> Attribute # type: (str) -> Attribute
""" """

View file

@ -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"
}
}
}
}
}

View file

@ -2,17 +2,18 @@ import QtQuick 2.7
import QtQuick.Controls 2.3 import QtQuick.Controls 2.3
import QtQuick.Layouts 1.3 import QtQuick.Layouts 1.3
import MaterialIcons 2.2 import MaterialIcons 2.2
import "filepath.js" as Filepath
import QtQml.Models 2.2 import QtQml.Models 2.2
/** /**
* ImageGallery displays as a grid of Images a model containing Viewpoints objects. * ImageGallery displays as a grid of Images a model containing Viewpoints objects.
* It manages a model of multiple CameraInit nodes as individual groups.
*/ */
Panel { Panel {
id: root id: root
property alias model: grid.model property variant cameraInits
property variant cameraInit
readonly property string currentItemSource: grid.currentItem ? grid.currentItem.source : "" readonly property string currentItemSource: grid.currentItem ? grid.currentItem.source : ""
readonly property var currentItemMetadata: grid.currentItem ? grid.currentItem.metadata : undefined readonly property var currentItemMetadata: grid.currentItem ? grid.currentItem.metadata : undefined
signal removeImageRequest(var attribute) signal removeImageRequest(var attribute)
@ -21,9 +22,13 @@ Panel {
implicitWidth: 100 implicitWidth: 100
implicitHeight: 300 implicitHeight: 300
title: "Images" title: "Images"
property int currentIndex: 0
readonly property variant viewpoints: cameraInit.attribute('viewpoints').value
signal filesDropped(var drop)
ColumnLayout { ColumnLayout {
anchors.fill: parent anchors.fill: parent
spacing: 4
GridView { GridView {
id: grid id: grid
@ -31,102 +36,46 @@ Panel {
Layout.fillWidth: true Layout.fillWidth: true
Layout.fillHeight: true Layout.fillHeight: true
cellWidth: thumbnailSizeSlider.value
cellHeight: cellWidth
ScrollBar.vertical: ScrollBar {} ScrollBar.vertical: ScrollBar {}
keyNavigationEnabled: true
highlightFollowsCurrentItem: true
focus: true focus: true
clip: true clip: true
cellWidth: thumbnailSizeSlider.value
cellHeight: cellWidth
highlightFollowsCurrentItem: true
keyNavigationEnabled: true
model: root.viewpoints
// Keyboard shortcut to change current image group
delegate: Item { Keys.priority: Keys.BeforeItem
id: imageDelegate Keys.onPressed: {
if(event.modifiers & Qt.AltModifier)
readonly property bool isCurrentItem: grid.currentIndex == index {
readonly property alias source: _viewpoint.source event.accepted = true
readonly property alias metadata: _viewpoint.metadata if(event.key == Qt.Key_Right)
root.currentIndex = Math.min(root.cameraInits.count - 1, root.currentIndex + 1)
// retrieve viewpoints inner data else if(event.key == Qt.Key_Left)
QtObject { root.currentIndex = Math.max(0, root.currentIndex - 1)
id: _viewpoint
readonly property string source: object.value.get("path").value
readonly property var metadata: JSON.parse(object.value.get("metadata").value)
} }
}
delegate: ImageDelegate {
viewpoint: object.value
width: grid.cellWidth width: grid.cellWidth
height: grid.cellHeight height: grid.cellHeight
MouseArea { isCurrentItem: grid.currentIndex == index
id: imageMA onPressed: {
anchors.fill: parent grid.currentIndex = index
anchors.margins: 6 if(mouse.button == Qt.LeftButton)
hoverEnabled: true grid.forceActiveFocus()
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"
}
}
}
} }
} }
// Explanatory placeholder when no image has been added yet // Explanatory placeholder when no image has been added yet
Column { Column {
anchors.centerIn: parent anchors.centerIn: parent
visible: model.count == 0 visible: grid.model.count == 0
spacing: 4 spacing: 4
Label { Label {
anchors.horizontalCenter: parent.horizontalCenter anchors.horizontalCenter: parent.horizontalCenter
@ -145,7 +94,7 @@ Panel {
enabled: !root.readOnly enabled: !root.readOnly
// TODO: onEntered: call specific method to filter files based on extension // TODO: onEntered: call specific method to filter files based on extension
onDropped: { onDropped: {
_reconstruction.handleFilesDrop(drop) root.filesDropped(drop)
} }
// DropArea overlay // DropArea overlay
Rectangle { 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 { footerContent: RowLayout {
@ -164,7 +147,7 @@ Panel {
// Image count // Image count
Label { Label {
Layout.fillWidth: true Layout.fillWidth: true
text: model.count + " image" + (model.count > 1 ? "s" : "") text: grid.model.count + " image" + (grid.model.count > 1 ? "s" : "")
elide: Text.ElideRight elide: Text.ElideRight
} }

View file

@ -17,7 +17,7 @@ Item {
id: root id: root
property variant reconstruction: _reconstruction property variant reconstruction: _reconstruction
readonly property variant viewpoints: _reconstruction.viewpoints readonly property variant cameraInits: _reconstruction.cameraInits
readonly property string meshFile: _reconstruction.meshFile readonly property string meshFile: _reconstruction.meshFile
property bool readOnly: false property bool readOnly: false
@ -45,8 +45,12 @@ Item {
Layout.fillHeight: true Layout.fillHeight: true
Layout.fillWidth: true Layout.fillWidth: true
Layout.minimumWidth: defaultCellSize Layout.minimumWidth: defaultCellSize
model: viewpoints cameraInits: root.cameraInits
cameraInit: _reconstruction.cameraInit
currentIndex: reconstruction.cameraInitIndex
onCurrentIndexChanged: reconstruction.cameraInitIndex = currentIndex
onRemoveImageRequest: reconstruction.removeAttribute(attribute) onRemoveImageRequest: reconstruction.removeAttribute(attribute)
onFilesDropped: reconstruction.handleFilesDrop(drop, cameraInit)
} }
Panel { Panel {

View file

@ -5,6 +5,7 @@ from threading import Thread
from PySide2.QtCore import QObject, Slot, Property, Signal from PySide2.QtCore import QObject, Slot, Property, Signal
from meshroom import multiview from meshroom import multiview
from meshroom.common.qt import QObjectListModel
from meshroom.core import graph from meshroom.core import graph
from meshroom.ui.graph import UIGraph from meshroom.ui.graph import UIGraph
@ -19,7 +20,9 @@ class Reconstruction(UIGraph):
def __init__(self, graphFilepath='', parent=None): def __init__(self, graphFilepath='', parent=None):
super(Reconstruction, self).__init__(graphFilepath, parent) super(Reconstruction, self).__init__(graphFilepath, parent)
self._buildIntrinsicsThread = None self._buildIntrinsicsThread = None
self._buildingIntrinsics = False
self._cameraInit = None self._cameraInit = None
self._cameraInits = QObjectListModel(parent=self)
self._endChunk = None self._endChunk = None
self._meshFile = '' self._meshFile = ''
self.intrinsicsBuilt.connect(self.onIntrinsicsAvailable) self.intrinsicsBuilt.connect(self.onIntrinsicsAvailable)
@ -38,7 +41,7 @@ class Reconstruction(UIGraph):
""" React to the change of the internal graph. """ """ React to the change of the internal graph. """
self._endChunk = None self._endChunk = None
self.setMeshFile('') self.setMeshFile('')
self.updateCameraInit() self.updateCameraInits()
if not self._graph: if not self._graph:
return return
@ -51,7 +54,7 @@ class Reconstruction(UIGraph):
except KeyError: except KeyError:
self._endChunk = None self._endChunk = None
# TODO: listen specifically for cameraInit creation/deletion # TODO: listen specifically for cameraInit creation/deletion
self._graph.nodes.countChanged.connect(self.updateCameraInit) self._graph.nodes.countChanged.connect(self.updateCameraInits)
@staticmethod @staticmethod
def runAsync(func, args=(), kwargs=None): def runAsync(func, args=(), kwargs=None):
@ -64,12 +67,11 @@ class Reconstruction(UIGraph):
# TODO: handle multiple Viewpoints models # TODO: handle multiple Viewpoints models
return self._cameraInit.viewpoints.value if self._cameraInit else None return self._cameraInit.viewpoints.value if self._cameraInit else None
def updateCameraInit(self): def updateCameraInits(self):
""" Update internal CameraInit node (Viewpoints model owner) based on graph content. """ cameraInits = self._graph.nodesByType("CameraInit", sortedByIndex=True)
# TODO: handle multiple CameraInit nodes if set(self._cameraInits.objectList()) == set(cameraInits):
if self._cameraInit in self._graph.nodes:
return return
cameraInits = self._graph.findNodeCandidates("CameraInit") self._cameraInits.setObjectList(cameraInits)
self.setCameraInit(cameraInits[0] if cameraInits else None) self.setCameraInit(cameraInits[0] if cameraInits else None)
def setCameraInit(self, cameraInit): def setCameraInit(self, cameraInit):
@ -78,7 +80,15 @@ class Reconstruction(UIGraph):
if self._cameraInit == cameraInit: if self._cameraInit == cameraInit:
return return
self._cameraInit = cameraInit 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): def updateMeshFile(self):
if self._endChunk and self._endChunk.status.status == graph.Status.SUCCESS: if self._endChunk and self._endChunk.status.status == graph.Status.SUCCESS:
@ -92,22 +102,22 @@ class Reconstruction(UIGraph):
self._meshFile = mf self._meshFile = mf
self.meshFileChanged.emit() self.meshFileChanged.emit()
@Slot(QObject) @Slot(QObject, graph.Node)
def handleFilesDrop(self, drop): def handleFilesDrop(self, drop, cameraInit):
""" Handle drop events aiming to add images to the Reconstruction. """ Handle drop events aiming to add images to the Reconstruction.
Fetching urls from dropEvent is generally expensive in QML/JS (bug ?). Fetching urls from dropEvent is generally expensive in QML/JS (bug ?).
This method allows to reduce process time by doing it on Python side. 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 @staticmethod
def isImageFile(filepath): def isImageFile(filepath):
""" Return whether filepath is a path to an image file supported by Meshroom. """ """ Return whether filepath is a path to an image file supported by Meshroom. """
return os.path.splitext(filepath)[1].lower() in Reconstruction.imageExtensions return os.path.splitext(filepath)[1].lower() in Reconstruction.imageExtensions
@Slot(QObject) @staticmethod
def importImagesFromUrls(self, urls): def getImageFilesFromDrop(drop):
""" Add the given list of images (as QUrl) to the Reconstruction. """ urls = drop.property("urls")
# Build the list of images paths # Build the list of images paths
images = [] images = []
for url in urls: for url in urls:
@ -117,43 +127,52 @@ class Reconstruction(UIGraph):
else: else:
files = [localFile] files = [localFile]
images.extend([f for f in files if Reconstruction.isImageFile(f)]) images.extend([f for f in files if Reconstruction.isImageFile(f)])
if not images: return images
return
# Start the process of updating views and intrinsics
self._buildIntrinsicsThread = self.runAsync(self.buildIntrinsics, args=(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. 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. Does not modify the graph, can be called outside the main thread.
Emits intrinsicBuilt(views, intrinsics) when done. Emits intrinsicBuilt(views, intrinsics) when done.
""" """
try: try:
self.buildingIntrinsicsChanged.emit() self.setBuildingIntrinsics(True)
# Retrieve the list of updated viewpoints and intrinsics # Retrieve the list of updated viewpoints and intrinsics
views, intrinsics = self._cameraInit.nodeDesc.buildIntrinsics(self._cameraInit, additionalViews) views, intrinsics = cameraInit.nodeDesc.buildIntrinsics(cameraInit, additionalViews)
self.intrinsicsBuilt.emit(views, intrinsics) self.intrinsicsBuilt.emit(cameraInit, views, intrinsics)
return views, intrinsics return views, intrinsics
except Exception as e: except Exception:
logging.error("Error while building intrinsics : {}".format(e)) import traceback
logging.error("Error while building intrinsics : {}".format(traceback.format_exc()))
finally: 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. """ """ Update CameraInit with given views and intrinsics. """
with self.groupedGraphModification("Add Images"): with self.groupedGraphModification("Add Images"):
self.setAttribute(self._cameraInit.viewpoints, views) self.setAttribute(cameraInit.viewpoints, views)
self.setAttribute(self._cameraInit.intrinsics, intrinsics) self.setAttribute(cameraInit.intrinsics, intrinsics)
self.setCameraInit(cameraInit)
def isBuildingIntrinsics(self): def setBuildingIntrinsics(self, value):
""" Whether intrinsics are being built """ if self._buildingIntrinsics == value:
return self._buildIntrinsicsThread and self._buildIntrinsicsThread.isAlive() return
self._buildingIntrinsics = value
self.buildingIntrinsicsChanged.emit()
viewpointsChanged = Signal() cameraInitChanged = Signal()
viewpoints = Property(QObject, getViewpoints, notify=viewpointsChanged) cameraInit = Property(QObject, lambda self: self._cameraInit, notify=cameraInitChanged)
intrinsicsBuilt = Signal(list, list) 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() buildingIntrinsicsChanged = Signal()
buildingIntrinsics = Property(bool, isBuildingIntrinsics, notify=buildingIntrinsicsChanged) buildingIntrinsics = Property(bool, lambda self: self._buildingIntrinsics, notify=buildingIntrinsicsChanged)
meshFileChanged = Signal() meshFileChanged = Signal()
meshFile = Property(str, lambda self: self._meshFile, notify=meshFileChanged) meshFile = Property(str, lambda self: self._meshFile, notify=meshFileChanged)