mirror of
https://github.com/alicevision/Meshroom.git
synced 2025-07-26 04:57:18 +02:00
Merge pull request #539 from alicevision/dev/featuresViewer
Features Viewer
This commit is contained in:
commit
059ca3685f
9 changed files with 318 additions and 28 deletions
66
meshroom/ui/qml/Controls/ColorChart.qml
Normal file
66
meshroom/ui/qml/Controls/ColorChart.qml
Normal file
|
@ -0,0 +1,66 @@
|
||||||
|
import QtQuick 2.10
|
||||||
|
import QtQuick.Controls 2.10
|
||||||
|
|
||||||
|
import Utils 1.0
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ColorChart is a color picker based on a set of predefined colors.
|
||||||
|
* It takes the form of a ToolButton that pops-up its palette when pressed.
|
||||||
|
*/
|
||||||
|
ToolButton {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property var colors: ["red", "green", "blue"]
|
||||||
|
property int currentIndex: 0
|
||||||
|
|
||||||
|
signal colorPicked(var colorIndex)
|
||||||
|
|
||||||
|
background: Rectangle {
|
||||||
|
color: root.colors[root.currentIndex]
|
||||||
|
border.width: hovered ? 1 : 0
|
||||||
|
border.color: Colors.sysPalette.midlight
|
||||||
|
}
|
||||||
|
|
||||||
|
onPressed: palettePopup.open()
|
||||||
|
|
||||||
|
// Popup for the color palette
|
||||||
|
Popup {
|
||||||
|
id: palettePopup
|
||||||
|
|
||||||
|
padding: 4
|
||||||
|
// content width is missing side padding (hence the + padding*2)
|
||||||
|
implicitWidth: colorChart.contentItem.width + padding*2
|
||||||
|
|
||||||
|
// center the current color
|
||||||
|
y: - (root.height - padding) / 2
|
||||||
|
x: - colorChart.currentItem.x - padding
|
||||||
|
|
||||||
|
// Colors palette
|
||||||
|
ListView {
|
||||||
|
id: colorChart
|
||||||
|
implicitHeight: contentItem.childrenRect.height
|
||||||
|
implicitWidth: contentWidth
|
||||||
|
orientation: ListView.Horizontal
|
||||||
|
spacing: 2
|
||||||
|
currentIndex: root.currentIndex
|
||||||
|
model: root.colors
|
||||||
|
// display each color as a ToolButton with a custom background
|
||||||
|
delegate: ToolButton {
|
||||||
|
padding: 0
|
||||||
|
width: root.width
|
||||||
|
height: root.height
|
||||||
|
background: Rectangle {
|
||||||
|
color: modelData
|
||||||
|
// display border of current/selected item
|
||||||
|
border.width: hovered || index === colorChart.currentIndex ? 1 : 0
|
||||||
|
border.color: Colors.sysPalette.midlight
|
||||||
|
}
|
||||||
|
|
||||||
|
onClicked: {
|
||||||
|
colorPicked(index);
|
||||||
|
palettePopup.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,5 +1,6 @@
|
||||||
module Controls
|
module Controls
|
||||||
|
|
||||||
|
ColorChart 1.0 ColorChart.qml
|
||||||
FloatingPane 1.0 FloatingPane.qml
|
FloatingPane 1.0 FloatingPane.qml
|
||||||
Group 1.0 Group.qml
|
Group 1.0 Group.qml
|
||||||
MessageDialog 1.0 MessageDialog.qml
|
MessageDialog 1.0 MessageDialog.qml
|
||||||
|
|
|
@ -14,4 +14,7 @@ QtObject {
|
||||||
readonly property color yellow: "#FFEB3B"
|
readonly property color yellow: "#FFEB3B"
|
||||||
readonly property color red: "#F44336"
|
readonly property color red: "#F44336"
|
||||||
readonly property color blue: "#03A9F4"
|
readonly property color blue: "#03A9F4"
|
||||||
|
readonly property color cyan: "#00BCD4"
|
||||||
|
readonly property color pink: "#E91E63"
|
||||||
|
readonly property color lime: "#CDDC39"
|
||||||
}
|
}
|
||||||
|
|
111
meshroom/ui/qml/Viewer/FeaturesInfoOverlay.qml
Normal file
111
meshroom/ui/qml/Viewer/FeaturesInfoOverlay.qml
Normal file
|
@ -0,0 +1,111 @@
|
||||||
|
import QtQuick 2.9
|
||||||
|
import QtQuick.Controls 2.3
|
||||||
|
import QtQuick.Layouts 1.3
|
||||||
|
import MaterialIcons 2.2
|
||||||
|
|
||||||
|
import Utils 1.0
|
||||||
|
import Controls 1.0
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FeaturesInfoOverlay is an overlay that displays info and
|
||||||
|
* provides controls over a FeaturesViewer component.
|
||||||
|
*/
|
||||||
|
FloatingPane {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property int pluginStatus: Loader.Null
|
||||||
|
property Item featuresViewer: null
|
||||||
|
property var featureExtractionNode: null
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
|
||||||
|
// Header
|
||||||
|
RowLayout {
|
||||||
|
// FeatureExtraction node name
|
||||||
|
Label {
|
||||||
|
text: featureExtractionNode.label
|
||||||
|
Layout.fillWidth: true
|
||||||
|
}
|
||||||
|
// Settings menu
|
||||||
|
Loader {
|
||||||
|
active: root.pluginStatus === Loader.Ready
|
||||||
|
sourceComponent: MaterialToolButton {
|
||||||
|
text: MaterialIcons.settings
|
||||||
|
font.pointSize: 10
|
||||||
|
onClicked: settingsMenu.popup(width, 0)
|
||||||
|
Menu {
|
||||||
|
id: settingsMenu
|
||||||
|
padding: 4
|
||||||
|
implicitWidth: 210
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
Label {
|
||||||
|
text: "Display Mode:"
|
||||||
|
}
|
||||||
|
ComboBox {
|
||||||
|
id: displayModeCB
|
||||||
|
flat: true
|
||||||
|
Layout.fillWidth: true
|
||||||
|
model: featuresViewer.displayModes
|
||||||
|
onActivated: featuresViewer.displayMode = currentIndex
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error message if AliceVision plugin is unavailable
|
||||||
|
Label {
|
||||||
|
visible: root.pluginStatus === Loader.Error
|
||||||
|
text: "AliceVision plugin is required to display Features"
|
||||||
|
color: Colors.red
|
||||||
|
}
|
||||||
|
|
||||||
|
// Feature types
|
||||||
|
ListView {
|
||||||
|
implicitHeight: contentHeight
|
||||||
|
implicitWidth: contentItem.childrenRect.width
|
||||||
|
|
||||||
|
model: featuresViewer !== null ? featuresViewer.model : 0
|
||||||
|
|
||||||
|
delegate: RowLayout {
|
||||||
|
id: featureType
|
||||||
|
|
||||||
|
property var viewer: featuresViewer.itemAt(index)
|
||||||
|
spacing: 4
|
||||||
|
|
||||||
|
// Visibility toogle
|
||||||
|
MaterialToolButton {
|
||||||
|
text: featureType.viewer.visible ? MaterialIcons.visibility : MaterialIcons.visibility_off
|
||||||
|
onClicked: featureType.viewer.visible = !featureType.viewer.visible
|
||||||
|
font.pointSize: 10
|
||||||
|
opacity: featureType.viewer.visible ? 1.0 : 0.6
|
||||||
|
}
|
||||||
|
// ColorChart picker
|
||||||
|
ColorChart {
|
||||||
|
implicitWidth: 12
|
||||||
|
implicitHeight: implicitWidth
|
||||||
|
colors: featuresViewer.colors
|
||||||
|
currentIndex: featureType.viewer.colorIndex
|
||||||
|
// offset FeaturesViewer color set when changing the color of one feature type
|
||||||
|
onColorPicked: featuresViewer.colorOffset = colorIndex - index
|
||||||
|
}
|
||||||
|
// Feature type name
|
||||||
|
Label {
|
||||||
|
text: featureType.viewer.describerType + (featureType.viewer.loading ? "" : ": " + featureType.viewer.features.length)
|
||||||
|
}
|
||||||
|
// Feature loading status
|
||||||
|
Loader {
|
||||||
|
active: featureType.viewer.loading
|
||||||
|
sourceComponent: BusyIndicator {
|
||||||
|
padding: 0
|
||||||
|
implicitWidth: 12
|
||||||
|
implicitHeight: 12
|
||||||
|
running: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
40
meshroom/ui/qml/Viewer/FeaturesViewer.qml
Normal file
40
meshroom/ui/qml/Viewer/FeaturesViewer.qml
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
import QtQuick 2.11
|
||||||
|
import AliceVision 1.0 as AliceVision
|
||||||
|
|
||||||
|
import Utils 1.0
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FeaturesViewer displays the extracted feature points of a View.
|
||||||
|
* Requires QtAliceVision plugin.
|
||||||
|
*/
|
||||||
|
Repeater {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
/// ViewID to display the features of
|
||||||
|
property int viewId
|
||||||
|
/// Folder containing the features files
|
||||||
|
property string folder
|
||||||
|
/// The list of describer types to load
|
||||||
|
property alias describerTypes: root.model
|
||||||
|
/// List of available display modes
|
||||||
|
readonly property var displayModes: ['Points', 'Squares', 'Oriented Squares']
|
||||||
|
/// Current display mode index
|
||||||
|
property int displayMode: 0
|
||||||
|
/// The list of colors used for displaying several describers
|
||||||
|
property var colors: [Colors.blue, Colors.red, Colors.yellow, Colors.green, Colors.orange, Colors.cyan, Colors.pink, Colors.lime]
|
||||||
|
/// Offset the color list
|
||||||
|
property int colorOffset: 0
|
||||||
|
|
||||||
|
model: root.describerTypes
|
||||||
|
|
||||||
|
// instantiate one FeaturesViewer by describer type
|
||||||
|
delegate: AliceVision.FeaturesViewer {
|
||||||
|
readonly property int colorIndex: (index+root.colorOffset)%root.colors.length
|
||||||
|
describerType: modelData
|
||||||
|
folder: root.folder
|
||||||
|
viewId: root.viewId
|
||||||
|
color: root.colors[colorIndex]
|
||||||
|
displayMode: root.displayMode
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -77,7 +77,38 @@ FocusScope {
|
||||||
|
|
||||||
visible: image.status === Image.Loading
|
visible: image.status === Image.Loading
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FeatureViewer: display view extracted feature points
|
||||||
|
// note: requires QtAliceVision plugin - use a Loader to evaluate plugin avaibility at runtime
|
||||||
|
Loader {
|
||||||
|
id: featuresViewerLoader
|
||||||
|
|
||||||
|
active: displayFeatures.checked
|
||||||
|
|
||||||
|
// handle rotation/position based on available metadata
|
||||||
|
rotation: {
|
||||||
|
var orientation = metadata ? metadata["Orientation"] : 0
|
||||||
|
switch(orientation) {
|
||||||
|
case "6": return 90;
|
||||||
|
case "8": return -90;
|
||||||
|
default: return 0;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
x: rotation === 90 ? image.paintedWidth : 0
|
||||||
|
y: rotation === -90 ? image.paintedHeight : 0
|
||||||
|
|
||||||
|
Component.onCompleted: {
|
||||||
|
// instantiate and initialize a FeaturesViewer component dynamically using Loader.setSource
|
||||||
|
setSource("FeaturesViewer.qml", {
|
||||||
|
'active': Qt.binding(function() { return displayFeatures.checked; }),
|
||||||
|
'viewId': Qt.binding(function() { return _reconstruction.selectedViewId; }),
|
||||||
|
'model': Qt.binding(function() { return _reconstruction.featureExtraction.attribute("describerTypes").value; }),
|
||||||
|
'folder': Qt.binding(function() { return Filepath.stringToUrl(_reconstruction.featureExtraction.attribute("output").value); }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// Busy indicator
|
// Busy indicator
|
||||||
BusyIndicator {
|
BusyIndicator {
|
||||||
|
@ -147,6 +178,21 @@ FocusScope {
|
||||||
metadata: visible ? root.metadata : {}
|
metadata: visible ? root.metadata : {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
Loader {
|
||||||
|
id: featuresOverlay
|
||||||
|
anchors.bottom: bottomToolbar.top
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.margins: 2
|
||||||
|
active: displayFeatures.checked
|
||||||
|
|
||||||
|
sourceComponent: FeaturesInfoOverlay {
|
||||||
|
featureExtractionNode: _reconstruction.featureExtraction
|
||||||
|
pluginStatus: featuresViewerLoader.status
|
||||||
|
featuresViewer: featuresViewerLoader.item
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
FloatingPane {
|
FloatingPane {
|
||||||
id: bottomToolbar
|
id: bottomToolbar
|
||||||
anchors.bottom: parent.bottom
|
anchors.bottom: parent.bottom
|
||||||
|
@ -163,6 +209,13 @@ FocusScope {
|
||||||
text: (image.status == Image.Ready ? image.scale.toFixed(2) : "1.00") + "x"
|
text: (image.status == Image.Ready ? image.scale.toFixed(2) : "1.00") + "x"
|
||||||
state: "xsmall"
|
state: "xsmall"
|
||||||
}
|
}
|
||||||
|
MaterialToolButton {
|
||||||
|
id: displayFeatures
|
||||||
|
font.pointSize: 11
|
||||||
|
ToolTip.text: "Display Features"
|
||||||
|
checkable: true
|
||||||
|
text: MaterialIcons.scatter_plot
|
||||||
|
}
|
||||||
|
|
||||||
Item {
|
Item {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
|
|
|
@ -133,8 +133,8 @@ Item {
|
||||||
|
|
||||||
// Load reconstructed model
|
// Load reconstructed model
|
||||||
Button {
|
Button {
|
||||||
readonly property var outputAttribute: _reconstruction.endNode ? _reconstruction.endNode.attribute("outputMesh") : null
|
readonly property var outputAttribute: _reconstruction.texturing ? _reconstruction.texturing.attribute("outputMesh") : null
|
||||||
readonly property bool outputReady: outputAttribute && _reconstruction.endNode.globalStatus === "SUCCESS"
|
readonly property bool outputReady: outputAttribute && _reconstruction.texturing.globalStatus === "SUCCESS"
|
||||||
readonly property int outputMediaIndex: viewer3D.library.find(outputAttribute)
|
readonly property int outputMediaIndex: viewer3D.library.find(outputAttribute)
|
||||||
|
|
||||||
text: "Load Model"
|
text: "Load Model"
|
||||||
|
@ -142,7 +142,7 @@ Item {
|
||||||
anchors.bottomMargin: 10
|
anchors.bottomMargin: 10
|
||||||
anchors.horizontalCenter: parent.horizontalCenter
|
anchors.horizontalCenter: parent.horizontalCenter
|
||||||
visible: outputReady && outputMediaIndex == -1
|
visible: outputReady && outputMediaIndex == -1
|
||||||
onClicked: viewer3D.view(_reconstruction.endNode.attribute("outputMesh"))
|
onClicked: viewer3D.view(_reconstruction.texturing.attribute("outputMesh"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -595,6 +595,14 @@ ApplicationWindow {
|
||||||
{
|
{
|
||||||
_reconstruction.sfm = node;
|
_reconstruction.sfm = node;
|
||||||
}
|
}
|
||||||
|
else if(node.nodeType === "FeatureExtraction")
|
||||||
|
{
|
||||||
|
_reconstruction.featureExtraction = node;
|
||||||
|
}
|
||||||
|
else if(node.nodeType === "CameraInit")
|
||||||
|
{
|
||||||
|
_reconstruction.cameraInit = node;
|
||||||
|
}
|
||||||
for(var i=0; i < node.attributes.count; ++i)
|
for(var i=0; i < node.attributes.count; ++i)
|
||||||
{
|
{
|
||||||
var attr = node.attributes.at(i)
|
var attr = node.attributes.at(i)
|
||||||
|
|
|
@ -161,19 +161,31 @@ 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._buildingIntrinsics = False
|
|
||||||
self._cameraInit = None
|
|
||||||
self._cameraInits = QObjectListModel(parent=self)
|
|
||||||
self._endNode = None
|
|
||||||
self.intrinsicsBuilt.connect(self.onIntrinsicsAvailable)
|
|
||||||
self.graphChanged.connect(self.onGraphChanged)
|
|
||||||
self._liveSfmManager = LiveSfmManager(self)
|
|
||||||
|
|
||||||
# SfM result
|
# 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._sfm = None
|
||||||
self._views = None
|
self._views = None
|
||||||
self._poses = None
|
self._poses = None
|
||||||
self._selectedViewId = 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:
|
if graphFilepath:
|
||||||
self.onGraphChanged()
|
self.onGraphChanged()
|
||||||
|
@ -211,8 +223,9 @@ class Reconstruction(UIGraph):
|
||||||
def onGraphChanged(self):
|
def onGraphChanged(self):
|
||||||
""" React to the change of the internal graph. """
|
""" React to the change of the internal graph. """
|
||||||
self._liveSfmManager.reset()
|
self._liveSfmManager.reset()
|
||||||
|
self.featureExtraction = None
|
||||||
self.sfm = None
|
self.sfm = None
|
||||||
self.endNode = None
|
self.texturing = None
|
||||||
self.updateCameraInits()
|
self.updateCameraInits()
|
||||||
if not self._graph:
|
if not self._graph:
|
||||||
return
|
return
|
||||||
|
@ -249,6 +262,10 @@ class Reconstruction(UIGraph):
|
||||||
camInit = self._cameraInits[idx] if self._cameraInits else None
|
camInit = self._cameraInits[idx] if self._cameraInits else None
|
||||||
self.cameraInit = camInit
|
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):
|
def lastSfmNode(self):
|
||||||
""" Retrieve the last SfM node from the initial CameraInit node. """
|
""" Retrieve the last SfM node from the initial CameraInit node. """
|
||||||
return self.lastNodeOfType("StructureFromMotion", self._cameraInit, Status.SUCCESS)
|
return self.lastNodeOfType("StructureFromMotion", self._cameraInit, Status.SUCCESS)
|
||||||
|
@ -493,21 +510,8 @@ class Reconstruction(UIGraph):
|
||||||
self._sfm.chunks[0].statusChanged.disconnect(self.updateViewsAndPoses)
|
self._sfm.chunks[0].statusChanged.disconnect(self.updateViewsAndPoses)
|
||||||
self._sfm.destroyed.disconnect(self._unsetSfm)
|
self._sfm.destroyed.disconnect(self._unsetSfm)
|
||||||
self._setSfm(node)
|
self._setSfm(node)
|
||||||
self.setEndNode(self.lastNodeOfType("Texturing", self._sfm, Status.SUCCESS))
|
|
||||||
|
|
||||||
def setEndNode(self, node=None):
|
self.texturing = self.lastNodeOfType("Texturing", self._sfm, Status.SUCCESS)
|
||||||
if self._endNode == node:
|
|
||||||
return
|
|
||||||
if self._endNode:
|
|
||||||
try:
|
|
||||||
self._endNode.destroyed.disconnect(self.setEndNode)
|
|
||||||
except RuntimeError:
|
|
||||||
# self._endNode might have been destroyed at this point, causing PySide2 to throw a RuntimeError
|
|
||||||
pass
|
|
||||||
self._endNode = node
|
|
||||||
if self._endNode:
|
|
||||||
self._endNode.destroyed.connect(self.setEndNode)
|
|
||||||
self.endNodeChanged.emit()
|
|
||||||
|
|
||||||
@Slot(QObject, result=bool)
|
@Slot(QObject, result=bool)
|
||||||
def isInViews(self, viewpoint):
|
def isInViews(self, viewpoint):
|
||||||
|
@ -566,12 +570,16 @@ class Reconstruction(UIGraph):
|
||||||
|
|
||||||
sfmChanged = Signal()
|
sfmChanged = Signal()
|
||||||
sfm = Property(QObject, getSfm, setSfm, notify=sfmChanged)
|
sfm = Property(QObject, getSfm, setSfm, notify=sfmChanged)
|
||||||
|
|
||||||
|
featureExtractionChanged = Signal()
|
||||||
|
featureExtraction = makeProperty(QObject, "_featureExtraction", featureExtractionChanged, resetOnDestroy=True)
|
||||||
|
|
||||||
sfmReportChanged = Signal()
|
sfmReportChanged = Signal()
|
||||||
# convenient property for QML binding re-evaluation when sfm report changes
|
# convenient property for QML binding re-evaluation when sfm report changes
|
||||||
sfmReport = Property(bool, lambda self: len(self._poses) > 0, notify=sfmReportChanged)
|
sfmReport = Property(bool, lambda self: len(self._poses) > 0, notify=sfmReportChanged)
|
||||||
sfmAugmented = Signal(Node, Node)
|
sfmAugmented = Signal(Node, Node)
|
||||||
endNodeChanged = Signal()
|
texturingChanged = Signal()
|
||||||
endNode = Property(QObject, lambda self: self._endNode, setEndNode, notify=endNodeChanged)
|
texturing = makeProperty(QObject, "_texturing", notify=texturingChanged)
|
||||||
|
|
||||||
nbCameras = Property(int, reconstructedCamerasCount, notify=sfmReportChanged)
|
nbCameras = Property(int, reconstructedCamerasCount, notify=sfmReportChanged)
|
||||||
|
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue