mirror of
https://github.com/alicevision/Meshroom.git
synced 2025-04-29 10:17:27 +02:00
Save up to 3 folder locations from which images have been imported. These folder locations will be saved across Meshroom sessions, in a similar fashion as the "Recent Files" entries. If no folder location has been saved (or if all saved folder locations are invalid), we fall back to the default behaviour: if a project with images has been opened before the "Import Images" action is called, then the base folder will be the folder containing the opened project's images.
443 lines
18 KiB
Python
443 lines
18 KiB
Python
import logging
|
|
import os
|
|
import re
|
|
import argparse
|
|
|
|
from PySide2.QtCore import Qt, QUrl, Slot, QJsonValue, Property, Signal, qInstallMessageHandler, QtMsgType, QSettings
|
|
from PySide2.QtGui import QIcon
|
|
from PySide2.QtWidgets import QApplication
|
|
|
|
import meshroom
|
|
from meshroom.core import nodesDesc
|
|
from meshroom.core.taskManager import TaskManager
|
|
|
|
from meshroom.ui import components
|
|
from meshroom.ui.components.clipboard import ClipboardHelper
|
|
from meshroom.ui.components.filepath import FilepathHelper
|
|
from meshroom.ui.components.scene3D import Scene3DHelper, Transformations3DHelper
|
|
from meshroom.ui.palette import PaletteManager
|
|
from meshroom.ui.reconstruction import Reconstruction
|
|
from meshroom.ui.utils import QmlInstantEngine
|
|
from meshroom.ui import commands
|
|
|
|
|
|
class MessageHandler(object):
|
|
"""
|
|
MessageHandler that translates Qt logs to Python logging system.
|
|
Also contains and filters a list of blacklisted QML warnings that end up in the
|
|
standard error even when setOutputWarningsToStandardError is set to false on the engine.
|
|
"""
|
|
|
|
outputQmlWarnings = bool(os.environ.get("MESHROOM_OUTPUT_QML_WARNINGS", False))
|
|
|
|
logFunctions = {
|
|
QtMsgType.QtDebugMsg: logging.debug,
|
|
QtMsgType.QtWarningMsg: logging.warning,
|
|
QtMsgType.QtInfoMsg: logging.info,
|
|
QtMsgType.QtFatalMsg: logging.fatal,
|
|
QtMsgType.QtCriticalMsg: logging.critical,
|
|
QtMsgType.QtSystemMsg: logging.critical
|
|
}
|
|
|
|
# Warnings known to be inoffensive and related to QML but not silenced
|
|
# even when 'MESHROOM_OUTPUT_QML_WARNINGS' is set to False
|
|
qmlWarningsBlacklist = (
|
|
'Failed to download scene at QUrl("")',
|
|
'QVariant(Invalid) Please check your QParameters',
|
|
'Texture will be invalid for this frame',
|
|
)
|
|
|
|
@classmethod
|
|
def handler(cls, messageType, context, message):
|
|
""" Message handler remapping Qt logs to Python logging system. """
|
|
|
|
if not cls.outputQmlWarnings:
|
|
# If MESHROOM_OUTPUT_QML_WARNINGS is not set and an error in qml files happen we're
|
|
# left without any output except "QQmlApplicationEngine failed to load component".
|
|
# This is extremely hard to debug to someone who does not know about
|
|
# MESHROOM_OUTPUT_QML_WARNINGS beforehand because by default Qml will output errors to
|
|
# stdout.
|
|
if "QQmlApplicationEngine failed to load component" in message:
|
|
logging.warning("Set MESHROOM_OUTPUT_QML_WARNINGS=1 to get a detailed error message.")
|
|
|
|
# discard blacklisted Qt messages related to QML when 'output qml warnings' is not enabled
|
|
elif any(w in message for w in cls.qmlWarningsBlacklist):
|
|
return
|
|
MessageHandler.logFunctions[messageType](message)
|
|
|
|
|
|
class MeshroomApp(QApplication):
|
|
""" Meshroom UI Application. """
|
|
def __init__(self, args):
|
|
QtArgs = [args[0], '-style', 'fusion'] + args[1:] # force Fusion style by default
|
|
|
|
parser = argparse.ArgumentParser(prog=args[0], description='Launch Meshroom UI.', add_help=True)
|
|
|
|
parser.add_argument('project', metavar='PROJECT', type=str, nargs='?',
|
|
help='Meshroom project file (e.g. myProject.mg) or folder with images to reconstruct.')
|
|
parser.add_argument('-i', '--import', metavar='IMAGES/FOLDERS', type=str, nargs='*',
|
|
help='Import images or folder with images to reconstruct.')
|
|
parser.add_argument('-I', '--importRecursive', metavar='FOLDERS', type=str, nargs='*',
|
|
help='Import images to reconstruct from specified folder and sub-folders.')
|
|
parser.add_argument('-s', '--save', metavar='PROJECT.mg', type=str, default='',
|
|
help='Save the created scene.')
|
|
parser.add_argument('-p', '--pipeline', metavar="FILE.mg/" + "/".join(meshroom.core.pipelineTemplates), type=str,
|
|
default=os.environ.get("MESHROOM_DEFAULT_PIPELINE", "photogrammetry"),
|
|
help='Override the default Meshroom pipeline with this external or template graph.')
|
|
parser.add_argument("--submitLabel", metavar='SUBMITLABEL', type=str, help="Label of a node in the submitter", default='{projectName} [Meshroom]')
|
|
parser.add_argument("--verbose", help="Verbosity level", default='warning',
|
|
choices=['fatal', 'error', 'warning', 'info', 'debug', 'trace'],)
|
|
|
|
args = parser.parse_args(args[1:])
|
|
|
|
logStringToPython = {
|
|
'fatal': logging.FATAL,
|
|
'error': logging.ERROR,
|
|
'warning': logging.WARNING,
|
|
'info': logging.INFO,
|
|
'debug': logging.DEBUG,
|
|
'trace': logging.DEBUG,
|
|
}
|
|
logging.getLogger().setLevel(logStringToPython[args.verbose])
|
|
|
|
QApplication.setAttribute(Qt.AA_EnableHighDpiScaling)
|
|
|
|
super(MeshroomApp, self).__init__(QtArgs)
|
|
|
|
self.setOrganizationName('AliceVision')
|
|
self.setApplicationName('Meshroom')
|
|
self.setApplicationVersion(meshroom.__version_name__)
|
|
|
|
font = self.font()
|
|
font.setPointSize(9)
|
|
self.setFont(font)
|
|
|
|
pwd = os.path.dirname(__file__)
|
|
self.setWindowIcon(QIcon(os.path.join(pwd, "img/meshroom.svg")))
|
|
|
|
# QML engine setup
|
|
qmlDir = os.path.join(pwd, "qml")
|
|
url = os.path.join(qmlDir, "main.qml")
|
|
self.engine = QmlInstantEngine()
|
|
self.engine.addFilesFromDirectory(qmlDir, recursive=True)
|
|
self.engine.setWatching(os.environ.get("MESHROOM_INSTANT_CODING", False))
|
|
# whether to output qml warnings to stderr (disable by default)
|
|
self.engine.setOutputWarningsToStandardError(MessageHandler.outputQmlWarnings)
|
|
qInstallMessageHandler(MessageHandler.handler)
|
|
|
|
self.engine.addImportPath(qmlDir)
|
|
components.registerTypes()
|
|
|
|
# expose available node types that can be instantiated
|
|
self.engine.rootContext().setContextProperty("_nodeTypes", {n: {"category": nodesDesc[n].category} for n in sorted(nodesDesc.keys())})
|
|
|
|
# instantiate Reconstruction object
|
|
self._undoStack = commands.UndoStack(self)
|
|
self._taskManager = TaskManager(self)
|
|
r = Reconstruction(undoStack=self._undoStack, taskManager=self._taskManager, defaultPipeline=args.pipeline, parent=self)
|
|
r.setSubmitLabel(args.submitLabel)
|
|
self.engine.rootContext().setContextProperty("_reconstruction", r)
|
|
|
|
# those helpers should be available from QML Utils module as singletons, but:
|
|
# - qmlRegisterUncreatableType is not yet available in PySide2
|
|
# - declaring them as singleton in qmldir file causes random crash at exit
|
|
# => expose them as context properties instead
|
|
self.engine.rootContext().setContextProperty("Filepath", FilepathHelper(parent=self))
|
|
self.engine.rootContext().setContextProperty("Scene3DHelper", Scene3DHelper(parent=self))
|
|
self.engine.rootContext().setContextProperty("Transformations3DHelper", Transformations3DHelper(parent=self))
|
|
self.engine.rootContext().setContextProperty("Clipboard", ClipboardHelper(parent=self))
|
|
|
|
# additional context properties
|
|
self.engine.rootContext().setContextProperty("_PaletteManager", PaletteManager(self.engine, parent=self))
|
|
self.engine.rootContext().setContextProperty("MeshroomApp", self)
|
|
|
|
# request any potential computation to stop on exit
|
|
self.aboutToQuit.connect(r.stopChildThreads)
|
|
|
|
if args.project and not os.path.isfile(args.project):
|
|
raise RuntimeError(
|
|
"Meshroom Command Line Error: 'PROJECT' argument should be a Meshroom project file (.mg).\n"
|
|
"Invalid value: '{}'".format(args.project))
|
|
|
|
if args.project:
|
|
r.load(args.project)
|
|
self.addRecentProjectFile(args.project)
|
|
else:
|
|
r.new()
|
|
|
|
# import is a python keyword, so we have to access the attribute by a string
|
|
if getattr(args, "import", None):
|
|
r.importImagesFromFolder(getattr(args, "import"), recursive=False)
|
|
|
|
if args.importRecursive:
|
|
r.importImagesFromFolder(args.importRecursive, recursive=True)
|
|
|
|
if args.save:
|
|
if os.path.isfile(args.save):
|
|
raise RuntimeError(
|
|
"Meshroom Command Line Error: Cannot save the new Meshroom project as the file (.mg) already exists.\n"
|
|
"Invalid value: '{}'".format(args.save))
|
|
projectFolder = os.path.dirname(args.save)
|
|
if not os.path.isdir(projectFolder):
|
|
if not os.path.isdir(os.path.dirname(projectFolder)):
|
|
raise RuntimeError(
|
|
"Meshroom Command Line Error: Cannot save the new Meshroom project file (.mg) as the parent of the folder does not exists.\n"
|
|
"Invalid value: '{}'".format(args.save))
|
|
os.mkdir(projectFolder)
|
|
r.saveAs(args.save)
|
|
self.addRecentProjectFile(args.save)
|
|
|
|
self.engine.load(os.path.normpath(url))
|
|
|
|
def _pipelineTemplateFiles(self):
|
|
templates = []
|
|
for key in sorted(meshroom.core.pipelineTemplates.keys()):
|
|
# Use uppercase letters in the names as separators to format the templates' name nicely
|
|
# e.g: the template "panoramaHdr" will be shown as "Panorama Hdr" in the menu
|
|
name = " ".join(re.findall('[A-Z][^A-Z]*', key[0].upper() + key[1:]))
|
|
variant = {"name": name, "key": key, "path": meshroom.core.pipelineTemplates[key]}
|
|
templates.append(variant)
|
|
return templates
|
|
|
|
def _pipelineTemplateNames(self):
|
|
return [p["name"] for p in self.pipelineTemplateFiles]
|
|
|
|
@Slot()
|
|
def reloadTemplateList(self):
|
|
for f in meshroom.core.pipelineTemplatesFolders:
|
|
meshroom.core.loadPipelineTemplates(f)
|
|
self.pipelineTemplateFilesChanged.emit()
|
|
|
|
def _recentProjectFiles(self):
|
|
projects = []
|
|
settings = QSettings()
|
|
settings.beginGroup("RecentFiles")
|
|
size = settings.beginReadArray("Projects")
|
|
for i in range(size):
|
|
settings.setArrayIndex(i)
|
|
p = settings.value("filepath")
|
|
if p:
|
|
projects.append(p)
|
|
settings.endArray()
|
|
return projects
|
|
|
|
@Slot(str)
|
|
@Slot(QUrl)
|
|
def addRecentProjectFile(self, projectFile):
|
|
if not isinstance(projectFile, (QUrl, str)):
|
|
raise TypeError("Unexpected data type: {}".format(projectFile.__class__))
|
|
if isinstance(projectFile, QUrl):
|
|
projectFileNorm = projectFile.toLocalFile()
|
|
if not projectFileNorm:
|
|
projectFileNorm = projectFile.toString()
|
|
else:
|
|
projectFileNorm = QUrl(projectFile).toLocalFile()
|
|
if not projectFileNorm:
|
|
projectFileNorm = QUrl.fromLocalFile(projectFile).toLocalFile()
|
|
|
|
projects = self._recentProjectFiles()
|
|
|
|
# remove duplicates while preserving order
|
|
from collections import OrderedDict
|
|
uniqueProjects = OrderedDict.fromkeys(projects)
|
|
projects = list(uniqueProjects)
|
|
# remove previous usage of the value
|
|
if projectFileNorm in uniqueProjects:
|
|
projects.remove(projectFileNorm)
|
|
# add the new value in the first place
|
|
projects.insert(0, projectFileNorm)
|
|
|
|
# keep only the 20 first elements
|
|
projects = projects[0:20]
|
|
|
|
settings = QSettings()
|
|
settings.beginGroup("RecentFiles")
|
|
size = settings.beginWriteArray("Projects")
|
|
for i, p in enumerate(projects):
|
|
settings.setArrayIndex(i)
|
|
settings.setValue("filepath", p)
|
|
settings.endArray()
|
|
settings.sync()
|
|
|
|
self.recentProjectFilesChanged.emit()
|
|
|
|
@Slot(str)
|
|
@Slot(QUrl)
|
|
def removeRecentProjectFile(self, projectFile):
|
|
if not isinstance(projectFile, (QUrl, str)):
|
|
raise TypeError("Unexpected data type: {}".format(projectFile.__class__))
|
|
if isinstance(projectFile, QUrl):
|
|
projectFileNorm = projectFile.toLocalFile()
|
|
if not projectFileNorm:
|
|
projectFileNorm = projectFile.toString()
|
|
else:
|
|
projectFileNorm = QUrl(projectFile).toLocalFile()
|
|
if not projectFileNorm:
|
|
projectFileNorm = QUrl.fromLocalFile(projectFile).toLocalFile()
|
|
|
|
projects = self._recentProjectFiles()
|
|
|
|
# remove duplicates while preserving order
|
|
from collections import OrderedDict
|
|
uniqueProjects = OrderedDict.fromkeys(projects)
|
|
projects = list(uniqueProjects)
|
|
# remove previous usage of the value
|
|
if projectFileNorm not in uniqueProjects:
|
|
return
|
|
|
|
projects.remove(projectFileNorm)
|
|
|
|
settings = QSettings()
|
|
settings.beginGroup("RecentFiles")
|
|
size = settings.beginWriteArray("Projects")
|
|
for i, p in enumerate(projects):
|
|
settings.setArrayIndex(i)
|
|
settings.setValue("filepath", p)
|
|
settings.endArray()
|
|
settings.sync()
|
|
|
|
self.recentProjectFilesChanged.emit()
|
|
|
|
def _recentImportedImagesFolders(self):
|
|
folders = []
|
|
settings = QSettings()
|
|
settings.beginGroup("RecentFiles")
|
|
size = settings.beginReadArray("ImagesFolders")
|
|
for i in range(size):
|
|
settings.setArrayIndex(i)
|
|
f = settings.value("path")
|
|
if f:
|
|
folders.append(f)
|
|
settings.endArray()
|
|
return folders
|
|
|
|
@Slot(QUrl)
|
|
def addRecentImportedImagesFolder(self, imagesFolder):
|
|
if isinstance(imagesFolder, QUrl):
|
|
folderPath = imagesFolder.toLocalFile()
|
|
if not folderPath:
|
|
folderPath = imagesFolder.toString()
|
|
else:
|
|
raise TypeError("Unexpected data type: {}".format(imagesFolder.__class__))
|
|
|
|
folders = self._recentImportedImagesFolders()
|
|
|
|
# remove duplicates while preserving order
|
|
from collections import OrderedDict
|
|
uniqueFolders = OrderedDict.fromkeys(folders)
|
|
folders = list(uniqueFolders)
|
|
# remove previous usage of the value
|
|
if folderPath in uniqueFolders:
|
|
folders.remove(folderPath)
|
|
# add the new value in the first place
|
|
folders.insert(0, folderPath)
|
|
|
|
# keep only the first three elements to have a backup if one of the folders goes missing
|
|
folders = folders[0:3]
|
|
|
|
settings = QSettings()
|
|
settings.beginGroup("RecentFiles")
|
|
size = settings.beginWriteArray("ImagesFolders")
|
|
for i, p in enumerate(folders):
|
|
settings.setArrayIndex(i)
|
|
settings.setValue("path", p)
|
|
settings.endArray()
|
|
settings.sync()
|
|
|
|
self.recentImportedImagesFoldersChanged.emit()
|
|
|
|
@Slot(QUrl)
|
|
def removeRecentImportedImagesFolder(self, imagesFolder):
|
|
if isinstance(imagesFolder, QUrl):
|
|
folderPath = imagesFolder.toLocalFile()
|
|
if not folderPath:
|
|
folderPath = imagesFolder.toString()
|
|
else:
|
|
raise TypeError("Unexpected data type: {}".format(imagesFolder.__class__))
|
|
|
|
folders = self._recentImportedImagesFolders()
|
|
|
|
# remove duplicates while preserving order
|
|
from collections import OrderedDict
|
|
uniqueFolders = OrderedDict.fromkeys(folders)
|
|
folders = list(uniqueFolders)
|
|
# remove previous usage of the value
|
|
if folderPath not in uniqueFolders:
|
|
return
|
|
|
|
folders.remove(folderPath)
|
|
|
|
settings = QSettings()
|
|
settings.beginGroup("RecentFiles")
|
|
size = settings.beginWriteArray("ImagesFolders")
|
|
for i, f in enumerate(folders):
|
|
settings.setArrayIndex(i)
|
|
settings.setValue("path", f)
|
|
settings.endArray()
|
|
settings.sync()
|
|
|
|
self.recentImportedImagesFoldersChanged.emit()
|
|
|
|
@Slot(str, result=str)
|
|
def markdownToHtml(self, md):
|
|
"""
|
|
Convert markdown to HTML.
|
|
|
|
Args:
|
|
md (str): the markdown text to convert
|
|
|
|
Returns:
|
|
str: the resulting HTML string
|
|
"""
|
|
try:
|
|
from markdown import markdown
|
|
except ImportError:
|
|
logging.warning("Can't import markdown module, returning source markdown text.")
|
|
return md
|
|
return markdown(md)
|
|
|
|
def _systemInfo(self):
|
|
import platform
|
|
import sys
|
|
return {
|
|
'platform': '{} {}'.format(platform.system(), platform.release()),
|
|
'python': 'Python {}'.format(sys.version.split(" ")[0])
|
|
}
|
|
|
|
systemInfo = Property(QJsonValue, _systemInfo, constant=True)
|
|
|
|
def _licensesModel(self):
|
|
"""
|
|
Get info about open-source licenses for the application.
|
|
Model provides:
|
|
title: the name of the project
|
|
localUrl: the local path to COPYING.md
|
|
onlineUrl: the remote path to COPYING.md
|
|
"""
|
|
rootDir = os.environ.get("MESHROOM_INSTALL_DIR", os.getcwd())
|
|
return [
|
|
{
|
|
"title": "Meshroom",
|
|
"localUrl": os.path.join(rootDir, "COPYING.md"),
|
|
"onlineUrl": "https://raw.githubusercontent.com/alicevision/meshroom/develop/COPYING.md"
|
|
},
|
|
{
|
|
"title": "AliceVision",
|
|
"localUrl": os.path.join(rootDir, "aliceVision", "share", "aliceVision", "COPYING.md"),
|
|
"onlineUrl": "https://raw.githubusercontent.com/alicevision/AliceVision/develop/COPYING.md"
|
|
}
|
|
]
|
|
|
|
def _default8bitViewerEnabled(self):
|
|
return bool(os.environ.get("MESHROOM_USE_8BIT_VIEWER", False))
|
|
|
|
|
|
licensesModel = Property("QVariantList", _licensesModel, constant=True)
|
|
pipelineTemplateFilesChanged = Signal()
|
|
recentProjectFilesChanged = Signal()
|
|
recentImportedImagesFoldersChanged = Signal()
|
|
pipelineTemplateFiles = Property("QVariantList", _pipelineTemplateFiles, notify=pipelineTemplateFilesChanged)
|
|
pipelineTemplateNames = Property("QVariantList", _pipelineTemplateNames, notify=pipelineTemplateFilesChanged)
|
|
recentProjectFiles = Property("QVariantList", _recentProjectFiles, notify=recentProjectFilesChanged)
|
|
recentImportedImagesFolders = Property("QVariantList", _recentImportedImagesFolders, notify=recentImportedImagesFoldersChanged)
|
|
default8bitViewerEnabled = Property(bool, _default8bitViewerEnabled, constant=True)
|