mirror of
https://github.com/alicevision/Meshroom.git
synced 2025-04-28 17:57:16 +02:00
478 lines
20 KiB
Python
478 lines
20 KiB
Python
import logging
|
|
import os
|
|
import re
|
|
import argparse
|
|
|
|
from PySide2 import QtCore
|
|
from PySide2.QtCore import Qt, QUrl, QJsonValue, 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.common import Property, Variant, Signal, Slot
|
|
|
|
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.components.scriptEditor import ScriptEditorManager
|
|
from meshroom.ui.components.thumbnail import ThumbnailCache
|
|
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='[Meshroom] {projectName}')
|
|
parser.add_argument("--verbose", help="Verbosity level", default=os.environ.get('MESHROOM_VERBOSE', '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_label__)
|
|
|
|
font = self.font()
|
|
font.setPointSize(9)
|
|
self.setFont(font)
|
|
|
|
pwd = os.path.dirname(__file__)
|
|
self.setWindowIcon(QIcon(os.path.join(pwd, "img/meshroom.svg")))
|
|
|
|
# Initialize thumbnail cache:
|
|
# - read related environment variables
|
|
# - clean cache directory and make sure it exists on disk
|
|
ThumbnailCache.initialize()
|
|
|
|
meshroom.core.initPlugins()
|
|
|
|
# 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)
|
|
if QtCore.__version_info__ < (5, 14, 2):
|
|
# After 5.14.1, it gets stuck during logging
|
|
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)
|
|
self._activeProject = Reconstruction(undoStack=self._undoStack, taskManager=self._taskManager, defaultPipeline=args.pipeline, parent=self)
|
|
self._activeProject.setSubmitLabel(args.submitLabel)
|
|
self.engine.rootContext().setContextProperty("_reconstruction", self._activeProject)
|
|
|
|
# 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))
|
|
self.engine.rootContext().setContextProperty("ThumbnailCache", ThumbnailCache(parent=self))
|
|
|
|
# additional context properties
|
|
self.engine.rootContext().setContextProperty("_PaletteManager", PaletteManager(self.engine, parent=self))
|
|
self.engine.rootContext().setContextProperty("ScriptEditorManager", ScriptEditorManager(parent=self))
|
|
self.engine.rootContext().setContextProperty("MeshroomApp", self)
|
|
|
|
# request any potential computation to stop on exit
|
|
self.aboutToQuit.connect(self._activeProject.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:
|
|
args.project = os.path.abspath(args.project)
|
|
self._activeProject.load(args.project)
|
|
self.addRecentProjectFile(args.project)
|
|
else:
|
|
self._activeProject.new()
|
|
|
|
# import is a python keyword, so we have to access the attribute by a string
|
|
if getattr(args, "import", None):
|
|
self._activeProject.importImagesFromFolder(getattr(args, "import"), recursive=False)
|
|
|
|
if args.importRecursive:
|
|
self._activeProject.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)
|
|
self._activeProject.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 40 first elements
|
|
projects = projects[0:40]
|
|
|
|
settings = QSettings()
|
|
settings.beginGroup("RecentFiles")
|
|
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")
|
|
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")
|
|
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")
|
|
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 _changelogModel(self):
|
|
"""
|
|
Get the complete changelog for the application.
|
|
Model provides:
|
|
title: the name of the changelog
|
|
localUrl: the local path to CHANGES.md
|
|
onlineUrl: the remote path to CHANGES.md
|
|
"""
|
|
rootDir = os.environ.get("MESHROOM_INSTALL_DIR", os.getcwd())
|
|
return [
|
|
{
|
|
"title": "Changelog",
|
|
"localUrl": os.path.join(rootDir, "CHANGES.md"),
|
|
"onlineUrl": "https://raw.githubusercontent.com/alicevision/meshroom/develop/CHANGES.md"
|
|
}
|
|
]
|
|
|
|
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))
|
|
|
|
activeProjectChanged = Signal()
|
|
activeProject = Property(Variant, lambda self: self._activeProject, notify=activeProjectChanged)
|
|
changelogModel = Property("QVariantList", _changelogModel, constant=True)
|
|
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)
|