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)