[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):
return self._nodes.get(nodeName)
@Slot(str, result=Attribute)
def attribute(self, fullName):
# 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.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
}

View file

@ -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 {

View file

@ -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)