diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 00000000..1ab4321e --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,2 @@ +github: [alicevision] +custom: ['https://alicevision.org/association/#donate'] diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml new file mode 100644 index 00000000..888cd202 --- /dev/null +++ b/.github/workflows/continuous-integration.yml @@ -0,0 +1,45 @@ +name: Continuous Integration + +on: + push: + branches: + - master + - develop + # Skip jobs when only documentation files are changed + paths-ignore: + - '**.md' + - '**.rst' + - 'docs/**' + pull_request: + paths-ignore: + - '**.md' + - '**.rst' + - 'docs/**' + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: [2.7, 3.8] + + steps: + - uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install flake8 pytest + pip install -r requirements.txt -r dev_requirements.txt --timeout 45 + - name: Lint with flake8 + run: | + # stop the build if there are Python syntax errors or undefined names + flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics + # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide + flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + - name: Test with pytest + run: | + pytest tests/ diff --git a/.gitignore b/.gitignore index 8b8a6e96..737b6f29 100644 --- a/.gitignore +++ b/.gitignore @@ -25,7 +25,7 @@ __pycache__ /scripts /build /dist -/*.sh +/dl # tests /.tests diff --git a/.travis.yml b/.travis.yml index 3ba8cefc..745669ea 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,12 +1,11 @@ language: python - -dist: xenial # required for Python >= 3.7 +dist: bionic python: - - "2.7" - "3.6" - "3.7" - + - "3.8" + install: - "pip install -r requirements.txt -r dev_requirements.txt --timeout 45" - pip install flake8 diff --git a/CHANGES.md b/CHANGES.md index c8773c2b..33ea7697 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -3,6 +3,88 @@ For algorithmic changes related to the photogrammetric pipeline, please refer to [AliceVision changelog](https://github.com/alicevision/AliceVision/blob/develop/CHANGES.md). +## Release 2020.1.1 (2020.10.14) + +Based on [AliceVision 2.3.1](https://github.com/alicevision/AliceVision/tree/v2.3.1). + + - [core] Fix crashes on process statistics (windows-only) [PR](https://github.com/alicevision/meshroom/pull/1096) + + +## Release 2020.1.0 (2020.10.09) + +Based on [AliceVision 2.3.0](https://github.com/alicevision/AliceVision/tree/v2.3.0). + +### Release Notes Summary + + - [nodes] New Panorama Stitching nodes with support for fisheye lenses [PR](https://github.com/alicevision/meshroom/pull/639) [PR](https://github.com/alicevision/meshroom/pull/808) + - [nodes] HDR: Largely improved HDR calibration, including new LdrToHdrSampling for optimal sample selection [PR](https://github.com/alicevision/meshroom/pull/808) [PR](https://github.com/alicevision/meshroom/pull/1016) [PR](https://github.com/alicevision/meshroom/pull/990) + - [ui] Viewer3D: Input bounding box (Meshing) & manual transformation (SfMTransform) thanks to a new 3D Gizmo [PR](https://github.com/alicevision/meshroom/pull/978) + - [ui] Sync 3D camera with image selection [PR](https://github.com/alicevision/meshroom/pull/633) + - [ui] New HDR (floating point) Image Viewer [PR](https://github.com/alicevision/meshroom/pull/795) + - [ui] Ability to load depth maps into 2D and 3D Viewers [PR](https://github.com/alicevision/meshroom/pull/769) [PR](https://github.com/alicevision/meshroom/pull/657) + - [ui] New features overlay in Viewer2D allows to display tracks and landmarks [PR](https://github.com/alicevision/meshroom/pull/873) [PR](https://github.com/alicevision/meshroom/pull/1001) + - [ui] Add SfM statistics [PR](https://github.com/alicevision/meshroom/pull/873) + - [ui] Visual interface for node resources usage [PR](https://github.com/alicevision/meshroom/pull/564) + - [nodes] Coordinate system alignment to specific markers or between scenes [PR](https://github.com/alicevision/meshroom/pull/652) + - [nodes] New Sketchfab upload node [PR](https://github.com/alicevision/meshroom/pull/712) + - [ui] Dynamic Parameters: add a new 'enabled' property to node's attributes [PR](https://github.com/alicevision/meshroom/pull/1007) [PR](https://github.com/alicevision/meshroom/pull/1027) + - [ui] Viewer: add Camera Response Function display [PR](https://github.com/alicevision/meshroom/pull/1020) [PR](https://github.com/alicevision/meshroom/pull/1041) + - [ui] UI improvements in the Viewer2D and ImageGallery [PR](https://github.com/alicevision/meshroom/pull/823) + - [bin] Improve Meshroom command line [PR](https://github.com/alicevision/meshroom/pull/759) [PR](https://github.com/alicevision/meshroom/pull/632) + - [nodes] New ImageProcessing node [PR](https://github.com/alicevision/meshroom/pull/839) [PR](https://github.com/alicevision/meshroom/pull/970) [PR](https://github.com/alicevision/meshroom/pull/941) + - [nodes] `FeatureMatching` Add `fundamental_with_distortion` option [PR](https://github.com/alicevision/meshroom/pull/931) + - [multiview] Declare more recognized image file extensions [PR](https://github.com/alicevision/meshroom/pull/965) + - [multiview] More generic metadata support [PR](https://github.com/alicevision/meshroom/pull/957) + +### Other Improvements and Bug Fixes + + - [nodes] CameraInit: New viewId generation and selection of allowed intrinsics [PR](https://github.com/alicevision/meshroom/pull/973) + - [core] Avoid error during project load on border cases [PR](https://github.com/alicevision/meshroom/pull/991) + - [core] Compatibility : Improve list of groups update [PR](https://github.com/alicevision/meshroom/pull/791) + - [core] Invalidation hooks [PR](https://github.com/alicevision/meshroom/pull/732) + - [core] Log manager for Python based nodes [PR](https://github.com/alicevision/meshroom/pull/631) + - [core] new Node Update Hooks mechanism [PR](https://github.com/alicevision/meshroom/pull/733) + - [core] Option to make chunks optional [PR](https://github.com/alicevision/meshroom/pull/778) + - [nodes] Add methods in ImageMatching and features in StructureFromMotion and FeatureMatching [PR](https://github.com/alicevision/meshroom/pull/768) + - [nodes] FeatureExtraction: add maxThreads argument [PR](https://github.com/alicevision/meshroom/pull/647) + - [nodes] Fix python nodes being blocked by log [PR](https://github.com/alicevision/meshroom/pull/783) + - [nodes] ImageProcessing: add new option to fix non finite pixels [PR](https://github.com/alicevision/meshroom/pull/1057) + - [nodes] Meshing: simplify input depth map folders [PR](https://github.com/alicevision/meshroom/pull/951) + - [nodes] PanoramaCompositing: add a new graphcut option to improve seams [PR](https://github.com/alicevision/meshroom/pull/1026) + - [nodes] PanoramaCompositing: option to select the percentage of upscaled pixels [PR](https://github.com/alicevision/meshroom/pull/1049) + - [nodes] PanoramaInit: add debug circle detection option [PR](https://github.com/alicevision/meshroom/pull/1069) + - [nodes] PanoramaInit: New parameter to set an extra image rotation to each camera declared the input xml [PR](https://github.com/alicevision/meshroom/pull/1046) + - [nodes] SfmTransfer: New option to transfer intrinsics parameters [PR](https://github.com/alicevision/meshroom/pull/1053) + - [nodes] StructureFromMotion: Add features’s scale as an option [PR](https://github.com/alicevision/meshroom/pull/822) [PR](https://github.com/alicevision/meshroom/pull/817) + - [nodes] Texturing: add options for retopoMesh & reorganise options [PR](https://github.com/alicevision/meshroom/pull/571) + - [nodes] Texturing: put downscale to 2 by default [PR](https://github.com/alicevision/meshroom/pull/1048) + - [sfm] Add option to include 'unknown' feature types in ConvertSfMFormat, needed to be used on dense point cloud from the Meshing node [PR](https://github.com/alicevision/meshroom/pull/584) + - [ui] Automatically update layout when needed [PR](https://github.com/alicevision/meshroom/pull/989) + - [ui] Avoid crash in 3D with large panoramas [PR](https://github.com/alicevision/meshroom/pull/1061) + - [ui] Fix graph axes naming for ram statistics [PR](https://github.com/alicevision/meshroom/pull/1033) + - [ui] NodeEditor: minor improvements with single tab group and status table [PR](https://github.com/alicevision/meshroom/pull/637) + - [ui] Viewer3D: Display equirectangular images as environment maps [PR](https://github.com/alicevision/meshroom/pull/731) + - [windows] Fix open recent broken on windows and remove unnecessary warnings [PR](https://github.com/alicevision/meshroom/pull/940) + +### Build, CI, Documentation + + - [build] Fix cxFreeze version for Python 2.7 compatibility [PR](https://github.com/alicevision/meshroom/pull/634) + - [ci] Add github Actions [PR](https://github.com/alicevision/meshroom/pull/1051) + - [ci] AppVeyor: Update build environment and save artifacts [PR](https://github.com/alicevision/meshroom/pull/875) + - [ci] Travis: Update environment, remove Python 2.7 & add 3.8 [PR](https://github.com/alicevision/meshroom/pull/874) + - [docker] Clean Dockerfiles [PR](https://github.com/alicevision/meshroom/pull/1054) + - [docker] Move to PySide2 / Qt 5.14.1 + - [docker] Fix some packaging issues of the release 2019.2.0 [PR](https://github.com/alicevision/meshroom/pull/627) + - [github] Add exemptLabels [PR](https://github.com/alicevision/meshroom/pull/801) + - [github] Add issue templates [PR](https://github.com/alicevision/meshroom/pull/579) + - [github] Add template for questions / help only [PR](https://github.com/alicevision/meshroom/pull/629) + - [github] Added automatic stale detection and closing for issues [PR](https://github.com/alicevision/meshroom/pull/598) + - [python] Import ABC from collections.abc [PR](https://github.com/alicevision/meshroom/pull/983) + +For more details see all PR merged: https://github.com/alicevision/meshroom/milestone/10 + +See [AliceVision 2.3.0 Release Notes](https://github.com/alicevision/AliceVision/blob/v2.3.0/CHANGES.md) for more details about algorithmic changes. + ## Release 2019.2.0 (2019.08.08) diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 4de93473..00000000 --- a/Dockerfile +++ /dev/null @@ -1,74 +0,0 @@ -ARG CUDA_TAG=7.0 -ARG OS_TAG=7 -FROM alicevision/alicevision:2.2.0-centos${OS_TAG}-cuda${CUDA_TAG} -LABEL maintainer="AliceVision Team alicevision-team@googlegroups.com" - -# Execute with nvidia docker (https://github.com/nvidia/nvidia-docker/wiki/Installation-(version-2.0)) -# docker run -it --runtime=nvidia meshroom - -ENV MESHROOM_DEV=/opt/Meshroom \ - MESHROOM_BUILD=/tmp/Meshroom_build \ - MESHROOM_BUNDLE=/opt/Meshroom_bundle \ - QT_DIR=/opt/qt/5.13.0/gcc_64 \ - PATH="${PATH}:${MESHROOM_BUNDLE}" - -# Workaround for qmlAlembic/qtAliceVision builds: fuse lib/lib64 folders -RUN cp -rf ${AV_INSTALL}/lib/* ${AV_INSTALL}/lib64 && rm -rf ${AV_INSTALL}/lib && ln -s ${AV_INSTALL}/lib64 ${AV_INSTALL}/lib - -# Install libs needed by Qt -RUN yum install -y \ - flex \ - fontconfig \ - freetype \ - glib2 \ - libICE \ - libX11 \ - libxcb \ - libXext \ - libXi \ - libXrender \ - libSM \ - libXt-devel \ - libGLU-devel \ - mesa-libOSMesa-devel \ - mesa-libGL-devel \ - mesa-libGLU-devel \ - xcb-util-keysyms \ - xcb-util-image - -# Install Python3 -RUN yum install -y centos-release-scl && yum install -y rh-python36 - -COPY . "${MESHROOM_DEV}" - -WORKDIR "${MESHROOM_DEV}" - -# Install Meshroom requirements and freeze bundle -RUN source scl_source enable rh-python36 && pip install -r dev_requirements.txt -r requirements.txt && python setup.py install_exe -d "${MESHROOM_BUNDLE}" && \ - find ${MESHROOM_BUNDLE} -name "*Qt5Web*" -delete && \ - find ${MESHROOM_BUNDLE} -name "*Qt5Designer*" -delete && \ - rm -rf ${MESHROOM_BUNDLE}/lib/PySide2/typesystems/ ${MESHROOM_BUNDLE}/lib/PySide2/examples/ ${MESHROOM_BUNDLE}/lib/PySide2/include/ ${MESHROOM_BUNDLE}/lib/PySide2/Qt/translations/ ${MESHROOM_BUNDLE}/lib/PySide2/Qt/resources/ && \ - rm ${MESHROOM_BUNDLE}/lib/PySide2/QtWeb* && \ - rm ${MESHROOM_BUNDLE}/lib/PySide2/pyside2-lupdate ${MESHROOM_BUNDLE}/lib/PySide2/pyside2-rcc - -# Install Qt (to build plugins) -WORKDIR /tmp/qt -# Qt version in specified in docker/qt-installer-noninteractive.qs -RUN curl -LO http://download.qt.io/official_releases/online_installers/qt-unified-linux-x64-online.run && \ - chmod u+x qt-unified-linux-x64-online.run && \ - ./qt-unified-linux-x64-online.run --verbose --platform minimal --script "${MESHROOM_DEV}/docker/qt-installer-noninteractive.qs" && \ - rm ./qt-unified-linux-x64-online.run - -WORKDIR ${MESHROOM_BUILD} - -# Build Meshroom plugins -RUN cmake "${MESHROOM_DEV}" -DALICEVISION_ROOT="${AV_INSTALL}" -DQT_DIR="${QT_DIR}" -DCMAKE_INSTALL_PREFIX="${MESHROOM_BUNDLE}/qtPlugins" -# RUN make -j8 qtOIIO -# RUN make -j8 qmlAlembic -# RUN make -j8 qtAliceVision -RUN make -j8 && cd /tmp && rm -rf ${MESHROOM_BUILD} - -RUN mv "${AV_BUNDLE}" "${MESHROOM_BUNDLE}/aliceVision" -RUN rm -rf ${MESHROOM_BUNDLE}/aliceVision/share/doc ${MESHROOM_BUNDLE}/aliceVision/share/eigen3 ${MESHROOM_BUNDLE}/aliceVision/share/fonts ${MESHROOM_BUNDLE}/aliceVision/share/lemon ${MESHROOM_BUNDLE}/aliceVision/share/libraw ${MESHROOM_BUNDLE}/aliceVision/share/man/ aliceVision/share/pkgconfig - - diff --git a/Dockerfile_py2 b/Dockerfile_py2 deleted file mode 100644 index 4e277848..00000000 --- a/Dockerfile_py2 +++ /dev/null @@ -1,74 +0,0 @@ -ARG CUDA_TAG=7.0 -ARG OS_TAG=7 -FROM alicevision/alicevision:2.2.0-centos${OS_TAG}-cuda${CUDA_TAG} -LABEL maintainer="AliceVision Team alicevision-team@googlegroups.com" - -# Execute with nvidia docker (https://github.com/nvidia/nvidia-docker/wiki/Installation-(version-2.0)) -# docker run -it --runtime=nvidia meshroom - -ENV MESHROOM_DEV=/opt/Meshroom \ - MESHROOM_BUILD=/tmp/Meshroom_build \ - MESHROOM_BUNDLE=/opt/Meshroom_bundle \ - QT_DIR=/opt/qt/5.13.0/gcc_64 \ - PATH="${PATH}:${MESHROOM_BUNDLE}" - -# Workaround for qmlAlembic/qtAliceVision builds: fuse lib/lib64 folders -RUN cp -rf ${AV_INSTALL}/lib/* ${AV_INSTALL}/lib64 && rm -rf ${AV_INSTALL}/lib && ln -s ${AV_INSTALL}/lib64 ${AV_INSTALL}/lib - -# Install libs needed by Qt -RUN yum install -y \ - flex \ - fontconfig \ - freetype \ - glib2 \ - libICE \ - libX11 \ - libxcb \ - libXext \ - libXi \ - libXrender \ - libSM \ - libXt-devel \ - libGLU-devel \ - mesa-libOSMesa-devel \ - mesa-libGL-devel \ - mesa-libGLU-devel \ - xcb-util-keysyms \ - xcb-util-image - -# Install Python2 -RUN yum install -y python-devel && curl https://bootstrap.pypa.io/get-pip.py -o /tmp/get-pip.py && python /tmp/get-pip.py && pip install --upgrade pip - -COPY . "${MESHROOM_DEV}" - -WORKDIR "${MESHROOM_DEV}" - -# Install Meshroom requirements and freeze bundle -RUN pip install -r dev_requirements.txt -r requirements.txt && python setup.py install_exe -d "${MESHROOM_BUNDLE}" && \ - find ${MESHROOM_BUNDLE} -name "*Qt5Web*" -delete && \ - find ${MESHROOM_BUNDLE} -name "*Qt5Designer*" -delete && \ - rm -rf ${MESHROOM_BUNDLE}/lib/PySide2/typesystems/ ${MESHROOM_BUNDLE}/lib/PySide2/examples/ ${MESHROOM_BUNDLE}/lib/PySide2/include/ ${MESHROOM_BUNDLE}/lib/PySide2/Qt/translations/ ${MESHROOM_BUNDLE}/lib/PySide2/Qt/resources/ && \ - rm ${MESHROOM_BUNDLE}/lib/PySide2/QtWeb* && \ - rm ${MESHROOM_BUNDLE}/lib/PySide2/pyside2-lupdate ${MESHROOM_BUNDLE}/lib/PySide2/pyside2-rcc - -# Install Qt (to build plugins) -WORKDIR /tmp/qt -# Qt version in specified in docker/qt-installer-noninteractive.qs -RUN curl -LO http://download.qt.io/official_releases/online_installers/qt-unified-linux-x64-online.run && \ - chmod u+x qt-unified-linux-x64-online.run && \ - ./qt-unified-linux-x64-online.run --verbose --platform minimal --script "${MESHROOM_DEV}/docker/qt-installer-noninteractive.qs" && \ - rm ./qt-unified-linux-x64-online.run - -WORKDIR ${MESHROOM_BUILD} - -# Build Meshroom plugins -RUN cmake "${MESHROOM_DEV}" -DALICEVISION_ROOT="${AV_INSTALL}" -DQT_DIR="${QT_DIR}" -DCMAKE_INSTALL_PREFIX="${MESHROOM_BUNDLE}/qtPlugins" -# RUN make -j8 qtOIIO -# RUN make -j8 qmlAlembic -# RUN make -j8 qtAliceVision -RUN make -j8 && cd /tmp && rm -rf ${MESHROOM_BUILD} - -RUN mv "${AV_BUNDLE}" "${MESHROOM_BUNDLE}/aliceVision" -RUN rm -rf ${MESHROOM_BUNDLE}/aliceVision/share/doc ${MESHROOM_BUNDLE}/aliceVision/share/eigen3 ${MESHROOM_BUNDLE}/aliceVision/share/fonts ${MESHROOM_BUNDLE}/aliceVision/share/lemon ${MESHROOM_BUNDLE}/aliceVision/share/libraw ${MESHROOM_BUNDLE}/aliceVision/share/man/ aliceVision/share/pkgconfig - - diff --git a/appveyor.yml b/appveyor.yml index 2b7b695a..387267a8 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -1,7 +1,6 @@ - +image: Visual Studio 2019 environment: - matrix: - - PYTHON: "C:\\Python36-x64" + PYTHON: "C:\\Python38-x64" install: - "set PATH=%PYTHON%;%PYTHON%\\Scripts;%PATH%" @@ -14,3 +13,7 @@ test_script: after_test: - "python setup.py build" + - 7z a meshroomWin64snapshot%APPVEYOR_PULL_REQUEST_HEAD_COMMIT%.zip ./build/* + +artifacts: + - path: meshroomWin64*.zip diff --git a/bin/meshroom_compute b/bin/meshroom_compute index 13231f03..42c22b47 100755 --- a/bin/meshroom_compute +++ b/bin/meshroom_compute @@ -6,6 +6,7 @@ import meshroom meshroom.setupEnvironment() import meshroom.core.graph +import meshroom.core.taskManager from meshroom.core.node import Status @@ -39,7 +40,11 @@ graph.update() if args.node: # Execute the node node = graph.findNode(args.node) - submittedStatuses = [Status.SUBMITTED, Status.RUNNING] + submittedStatuses = [Status.RUNNING] + if not args.extern: + # If running as "extern", the task is supposed to have the status SUBMITTED. + # If not running as "extern", the SUBMITTED status should generate a warning. + submittedStatuses.append(Status.SUBMITTED) if not args.forceStatus and not args.forceCompute: if args.iteration != -1: chunks = [node.chunks[args.iteration]] @@ -47,7 +52,7 @@ if args.node: chunks = node.chunks for chunk in chunks: if chunk.status.status in submittedStatuses: - print('Error: Node is already submitted with status "{}". See file: "{}"'.format(chunk.status.status.name, chunk.statusFile)) + print('Warning: Node is already submitted with status "{}". See file: "{}"'.format(chunk.status.status.name, chunk.statusFile)) # sys.exit(-1) if args.iteration != -1: chunk = node.chunks[args.iteration] @@ -61,5 +66,7 @@ else: toNodes = None if args.toNode: toNodes = graph.findNodes([args.toNode]) - meshroom.core.graph.executeGraph(graph, toNodes=toNodes, forceCompute=args.forceCompute, forceStatus=args.forceStatus) + + taskManager = meshroom.core.taskManager.TaskManager() + taskManager.compute(graph, toNodes=toNodes, forceCompute=args.forceCompute, forceStatus=args.forceStatus) diff --git a/bin/meshroom_photogrammetry b/bin/meshroom_photogrammetry index b7d8fecc..556be74f 100755 --- a/bin/meshroom_photogrammetry +++ b/bin/meshroom_photogrammetry @@ -8,9 +8,10 @@ import meshroom meshroom.setupEnvironment() import meshroom.core.graph +import meshroom.core.taskManager from meshroom import multiview -parser = argparse.ArgumentParser(description='Launch the full photogrammetry or HDRI pipeline.') +parser = argparse.ArgumentParser(description='Launch the full photogrammetry or Panorama HDR pipeline.') parser.add_argument('-i', '--input', metavar='SFM/FOLDERS/IMAGES', type=str, nargs='*', default=[], help='Input folder containing images or folders of images or file (.sfm or .json) ' @@ -19,8 +20,8 @@ parser.add_argument('-I', '--inputRecursive', metavar='FOLDERS/IMAGES', type=str default=[], help='Input folders containing all images recursively.') -parser.add_argument('-p', '--pipeline', metavar='photogrammetry/hdri/MG_FILE', type=str, default='photogrammetry', - help='"photogrammetry" pipeline, "hdri" pipeline or a Meshroom file containing a custom pipeline to run on input images. ' +parser.add_argument('-p', '--pipeline', metavar='photogrammetry/panoramaHdr/panoramaFisheyeHdr/MG_FILE', type=str, default='photogrammetry', + help='"photogrammetry" pipeline, "panotamaHdr" pipeline, "panotamaFisheyeHdr" pipeline or a Meshroom file containing a custom pipeline to run on input images. ' 'Requirements: the graph must contain one CameraInit node, ' 'and one Publish node if --output is set.') @@ -112,9 +113,12 @@ with multiview.GraphModification(graph): if args.pipeline.lower() == "photogrammetry": # default photogrammetry pipeline multiview.photogrammetry(inputViewpoints=views, inputIntrinsics=intrinsics, output=args.output, graph=graph) - elif args.pipeline.lower() == "hdri": - # default hdri pipeline - graph = multiview.hdri(inputViewpoints=views, inputIntrinsics=intrinsics, output=args.output, graph=graph) + elif args.pipeline.lower() == "panoramahdr": + # default panorama Hdr pipeline + multiview.panoramaHdr(inputViewpoints=views, inputIntrinsics=intrinsics, output=args.output, graph=graph) + elif args.pipeline.lower() == "panoramafisheyehdr": + # default panorama Fisheye Hdr pipeline + multiview.panoramaFisheyeHdr(inputViewpoints=views, inputIntrinsics=intrinsics, output=args.output, graph=graph) else: # custom pipeline graph.load(args.pipeline) @@ -194,7 +198,9 @@ if args.submit: if not args.save: raise ValueError('Need to save the project to file to submit on renderfarm.') # submit on renderfarm - meshroom.core.graph.submit(args.save, args.submitter, toNode=toNodes) + taskManager = meshroom.core.taskManager.TaskManager() + taskManager.submitFromFile(args.save, args.submitter, toNode=toNodes) elif args.compute: # start computation - meshroom.core.graph.executeGraph(graph, toNodes=toNodes, forceCompute=args.forceCompute, forceStatus=args.forceStatus) + taskManager = meshroom.core.taskManager.TaskManager() + taskManager.compute(graph, toNodes=toNodes, forceCompute=args.forceCompute, forceStatus=args.forceStatus) diff --git a/bin/meshroom_submit b/bin/meshroom_submit index f8ab606f..8ee83524 100755 --- a/bin/meshroom_submit +++ b/bin/meshroom_submit @@ -5,6 +5,7 @@ import meshroom meshroom.setupEnvironment() import meshroom.core.graph +import meshroom.core.taskManager parser = argparse.ArgumentParser(description='Submit a Graph of processes on renderfarm.') parser.add_argument('meshroomFile', metavar='MESHROOMFILE.mg', type=str, @@ -18,4 +19,5 @@ parser.add_argument('--submitter', help='Execute job with a specific submitter.') args = parser.parse_args() -meshroom.core.graph.submit(args.meshroomFile, args.submitter, toNode=args.toNode) +taskManager = meshroom.core.taskManager.TaskManager() +taskManager.submitFromFile(args.meshroomFile, args.submitter, toNode=args.toNode) diff --git a/dev_requirements.txt b/dev_requirements.txt index 8a9d354b..0a1c0791 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -1,5 +1,8 @@ # packaging -cx_Freeze==5.1.1 + +cx_Freeze==5.1.1;python_version<"3.5" +# Problem with cx_freeze-6.2, see https://github.com/marcelotduarte/cx_Freeze/issues/652 +cx_Freeze==6.1;python_version>="3.5" # testing -pytest \ No newline at end of file +pytest diff --git a/docker/Dockerfile_centos b/docker/Dockerfile_centos new file mode 100644 index 00000000..3d802072 --- /dev/null +++ b/docker/Dockerfile_centos @@ -0,0 +1,72 @@ +ARG MESHROOM_VERSION +ARG AV_VERSION +ARG CUDA_VERSION +ARG CENTOS_VERSION +FROM alicevision/meshroom-deps:${MESHROOM_VERSION}-av${AV_VERSION}-centos${CENTOS_VERSION}-cuda${CUDA_VERSION} +LABEL maintainer="AliceVision Team alicevision-team@googlegroups.com" + +# Execute with nvidia docker (https://github.com/nvidia/nvidia-docker/wiki/Installation-(version-2.0)) +# docker run -it --runtime nvidia -p 2222:22 --name meshroom -v:/data alicevision/meshroom:develop-av2.2.8.develop-ubuntu20.04-cuda11.0 +# ssh -p 2222 -X root@ /opt/Meshroom_bundle/Meshroom # Password is 'meshroom' + +ENV MESHROOM_DEV=/opt/Meshroom \ + MESHROOM_BUILD=/tmp/Meshroom_build \ + MESHROOM_BUNDLE=/opt/Meshroom_bundle \ + AV_INSTALL=/opt/AliceVision_install \ + QT_DIR=/opt/Qt5.14.1/5.14.1/gcc_64 \ + PATH="${PATH}:${MESHROOM_BUNDLE}" \ + OPENIMAGEIO_LIBRARY=/opt/AliceVision_install/lib + +COPY *.txt *.md *.py ${MESHROOM_DEV}/ +COPY ./docs ${MESHROOM_DEV}/docs +COPY ./meshroom ${MESHROOM_DEV}/meshroom +COPY ./tests ${MESHROOM_DEV}/tests +COPY ./bin ${MESHROOM_DEV}/bin + +WORKDIR ${MESHROOM_DEV} + +RUN source scl_source enable rh-python36 && python setup.py install_exe -d "${MESHROOM_BUNDLE}" && \ + find ${MESHROOM_BUNDLE} -name "*Qt5Web*" -delete && \ + find ${MESHROOM_BUNDLE} -name "*Qt5Designer*" -delete && \ + rm -rf ${MESHROOM_BUNDLE}/lib/PySide2/typesystems/ \ + ${MESHROOM_BUNDLE}/lib/PySide2/examples/ \ + ${MESHROOM_BUNDLE}/lib/PySide2/include/ \ + ${MESHROOM_BUNDLE}/lib/PySide2/Qt/translations/ \ + ${MESHROOM_BUNDLE}/lib/PySide2/Qt/resources/ \ + ${MESHROOM_BUNDLE}/lib/PySide2/QtWeb* \ + ${MESHROOM_BUNDLE}/lib/PySide2/pyside2-lupdate \ + ${MESHROOM_BUNDLE}/lib/PySide2/rcc \ + ${MESHROOM_BUNDLE}/lib/PySide2/designer + +WORKDIR ${MESHROOM_BUILD} + +# Build Meshroom plugins +RUN cmake "${MESHROOM_DEV}" -DALICEVISION_ROOT="${AV_INSTALL}" -DCMAKE_INSTALL_PREFIX="${MESHROOM_BUNDLE}/qtPlugins" +RUN make "-j$(nproc)" qtOIIO + +RUN make "-j$(nproc)" qmlAlembic +RUN make "-j$(nproc)" qtAliceVision +RUN make "-j$(nproc)" && \ + rm -rf "${MESHROOM_BUILD}" "${MESHROOM_DEV}" \ + ${MESHROOM_BUNDLE}/aliceVision/share/doc \ + ${MESHROOM_BUNDLE}/aliceVision/share/eigen3 \ + ${MESHROOM_BUNDLE}/aliceVision/share/fonts \ + ${MESHROOM_BUNDLE}/aliceVision/share/lemon \ + ${MESHROOM_BUNDLE}/aliceVision/share/libraw \ + ${MESHROOM_BUNDLE}/aliceVision/share/man/ \ + aliceVision/share/pkgconfig + +# Enable SSH X11 forwarding, needed when the Docker image +# is run on a remote machine +RUN yum -y install openssh-server xauth mesa-dri-drivers && \ + systemctl enable sshd && \ + mkdir -p /run/sshd + +RUN sed -i "s/^.*X11Forwarding.*$/X11Forwarding yes/; s/^.*X11UseLocalhost.*$/X11UseLocalhost no/; s/^.*PermitRootLogin prohibit-password/PermitRootLogin yes/; s/^.*X11UseLocalhost.*/X11UseLocalhost no/;" /etc/ssh/sshd_config +RUN echo "root:meshroom" | chpasswd + +WORKDIR /root + +EXPOSE 22 +CMD bash -c "test -s /etc/machine-id || systemd-machine-id-setup; sshd-keygen; /usr/sbin/sshd -D" + diff --git a/docker/Dockerfile_centos_deps b/docker/Dockerfile_centos_deps new file mode 100644 index 00000000..be0ec7a8 --- /dev/null +++ b/docker/Dockerfile_centos_deps @@ -0,0 +1,65 @@ +ARG AV_VERSION +ARG CUDA_VERSION +ARG CENTOS_VERSION=7 +FROM alicevision/alicevision:${AV_VERSION}-centos${CENTOS_VERSION}-cuda${CUDA_VERSION} +LABEL maintainer="AliceVision Team alicevision-team@googlegroups.com" + +# Execute with nvidia docker (https://github.com/nvidia/nvidia-docker/wiki/Installation-(version-2.0)) +# docker run -it --runtime=nvidia meshroom + +ENV MESHROOM_DEV=/opt/Meshroom \ + MESHROOM_BUILD=/tmp/Meshroom_build \ + MESHROOM_BUNDLE=/opt/Meshroom_bundle \ + QT_DIR=/opt/Qt5.14.1/5.14.1/gcc_64 \ + QT_CI_LOGIN=alicevisionjunk@gmail.com \ + QT_CI_PASSWORD=azerty1. + +WORKDIR ${MESHROOM_BUNDLE} +RUN mv "${AV_BUNDLE}" "${MESHROOM_BUNDLE}/aliceVision" && \ + rm -rf ${MESHROOM_BUNDLE}/aliceVision/share/doc \ + ${MESHROOM_BUNDLE}/aliceVision/share/eigen3 \ + ${MESHROOM_BUNDLE}/aliceVision/share/fonts \ + ${MESHROOM_BUNDLE}/aliceVision/share/lemon \ + ${MESHROOM_BUNDLE}/aliceVision/share/libraw \ + ${MESHROOM_BUNDLE}/aliceVision/share/man \ + ${MESHROOM_BUNDLE}/aliceVision/share/pkgconfig + +# Install libs needed by Qt +RUN yum install -y \ + flex \ + fontconfig \ + freetype \ + glib2 \ + libICE \ + libX11 \ + libxcb \ + libXext \ + libXi \ + libXrender \ + libSM \ + libXt-devel \ + libGLU-devel \ + mesa-libOSMesa-devel \ + mesa-libGL-devel \ + mesa-libGLU-devel \ + xcb-util-keysyms \ + xcb-util-image \ + libxkbcommon-x11 + +# Install Python3 +RUN yum install -y centos-release-scl && yum install -y rh-python36 && source scl_source enable rh-python36 && pip install --upgrade pip + +COPY ./*requirements.txt ${MESHROOM_DEV}/ + +# Install Meshroom requirements and freeze bundle +WORKDIR "${MESHROOM_DEV}" +RUN source scl_source enable rh-python36 && pip install -r dev_requirements.txt -r requirements.txt + +# Install Qt (to build plugins) +WORKDIR /tmp/qt +COPY dl/qt.run /tmp/qt +COPY ./docker/qt-installer-noninteractive.qs ${MESHROOM_DEV}/docker/ +RUN chmod +x qt.run && \ + ./qt.run --verbose --platform minimal --script "${MESHROOM_DEV}/docker/qt-installer-noninteractive.qs" && \ + rm qt.run + diff --git a/docker/Dockerfile_centos_deps_py2 b/docker/Dockerfile_centos_deps_py2 new file mode 100644 index 00000000..fcbd539a --- /dev/null +++ b/docker/Dockerfile_centos_deps_py2 @@ -0,0 +1,68 @@ +ARG AV_VERSION +ARG CUDA_VERSION +ARG CENTOS_VERSION=7 +FROM alicevision/alicevision:${AV_VERSION}-centos${CENTOS_VERSION}-cuda${CUDA_VERSION} +LABEL maintainer="AliceVision Team alicevision-team@googlegroups.com" + +# Execute with nvidia docker (https://github.com/nvidia/nvidia-docker/wiki/Installation-(version-2.0)) +# docker run -it --runtime=nvidia meshroom + +ENV MESHROOM_DEV=/opt/Meshroom \ + MESHROOM_BUILD=/tmp/Meshroom_build \ + MESHROOM_BUNDLE=/opt/Meshroom_bundle \ + QT_DIR=/opt/Qt5.14.1/5.14.1/gcc_64 \ + QT_CI_LOGIN=alicevisionjunk@gmail.com \ + QT_CI_PASSWORD=azerty1. + +WORKDIR ${MESHROOM_BUNDLE} +RUN mv "${AV_BUNDLE}" "${MESHROOM_BUNDLE}/aliceVision" && \ + rm -rf ${MESHROOM_BUNDLE}/aliceVision/share/doc \ + ${MESHROOM_BUNDLE}/aliceVision/share/eigen3 \ + ${MESHROOM_BUNDLE}/aliceVision/share/fonts \ + ${MESHROOM_BUNDLE}/aliceVision/share/lemon \ + ${MESHROOM_BUNDLE}/aliceVision/share/libraw \ + ${MESHROOM_BUNDLE}/aliceVision/share/man \ + ${MESHROOM_BUNDLE}/aliceVision/share/pkgconfig + +# Install libs needed by Qt +RUN yum install -y \ + flex \ + fontconfig \ + freetype \ + glib2 \ + libICE \ + libX11 \ + libxcb \ + libXext \ + libXi \ + libXrender \ + libSM \ + libXt-devel \ + libGLU-devel \ + mesa-libOSMesa-devel \ + mesa-libGL-devel \ + mesa-libGLU-devel \ + xcb-util-keysyms \ + xcb-util-image \ + libxkbcommon-x11 + +# Install Python2 +RUN yum install -y python-devel && \ + curl https://bootstrap.pypa.io/get-pip.py -o /tmp/get-pip.py && \ + python /tmp/get-pip.py && \ + pip install --upgrade pip + +COPY ./*requirements.txt ${MESHROOM_DEV}/ + +# Install Meshroom requirements and freeze bundle +WORKDIR "${MESHROOM_DEV}" +RUN pip install -r dev_requirements.txt -r requirements.txt + +# Install Qt (to build plugins) +WORKDIR /tmp/qt +COPY dl/qt.run /tmp/qt +COPY ./docker/qt-installer-noninteractive.qs ${MESHROOM_DEV}/docker/ +RUN chmod +x qt.run && \ + ./qt.run --verbose --platform minimal --script "${MESHROOM_DEV}/docker/qt-installer-noninteractive.qs" && \ + rm qt.run + diff --git a/docker/Dockerfile_centos_py2 b/docker/Dockerfile_centos_py2 new file mode 100644 index 00000000..7b41cbac --- /dev/null +++ b/docker/Dockerfile_centos_py2 @@ -0,0 +1,72 @@ +ARG MESHROOM_VERSION +ARG AV_VERSION +ARG CUDA_VERSION +ARG CENTOS_VERSION +FROM alicevision/meshroom-deps:${MESHROOM_VERSION}-av${AV_VERSION}-centos${CENTOS_VERSION}-cuda${CUDA_VERSION}-py2 +LABEL maintainer="AliceVision Team alicevision-team@googlegroups.com" + +# Execute with nvidia docker (https://github.com/nvidia/nvidia-docker/wiki/Installation-(version-2.0)) +# docker run -it --runtime nvidia -p 2222:22 --name meshroom -v:/data alicevision/meshroom:develop-av2.2.8.develop-ubuntu20.04-cuda11.0 +# ssh -p 2222 -X root@ /opt/Meshroom_bundle/Meshroom # Password is 'meshroom' + +ENV MESHROOM_DEV=/opt/Meshroom \ + MESHROOM_BUILD=/tmp/Meshroom_build \ + MESHROOM_BUNDLE=/opt/Meshroom_bundle \ + AV_INSTALL=/opt/AliceVision_install \ + QT_DIR=/opt/Qt5.14.1/5.14.1/gcc_64 \ + PATH="${PATH}:${MESHROOM_BUNDLE}" \ + OPENIMAGEIO_LIBRARY=/opt/AliceVision_install/lib + +COPY *.txt *.md *.py ${MESHROOM_DEV}/ +COPY ./docs ${MESHROOM_DEV}/docs +COPY ./meshroom ${MESHROOM_DEV}/meshroom +COPY ./tests ${MESHROOM_DEV}/tests +COPY ./bin ${MESHROOM_DEV}/bin + +WORKDIR ${MESHROOM_DEV} + +RUN python setup.py install_exe -d "${MESHROOM_BUNDLE}" && \ + find ${MESHROOM_BUNDLE} -name "*Qt5Web*" -delete && \ + find ${MESHROOM_BUNDLE} -name "*Qt5Designer*" -delete && \ + rm -rf ${MESHROOM_BUNDLE}/lib/PySide2/typesystems/ \ + ${MESHROOM_BUNDLE}/lib/PySide2/examples/ \ + ${MESHROOM_BUNDLE}/lib/PySide2/include/ \ + ${MESHROOM_BUNDLE}/lib/PySide2/Qt/translations/ \ + ${MESHROOM_BUNDLE}/lib/PySide2/Qt/resources/ \ + ${MESHROOM_BUNDLE}/lib/PySide2/QtWeb* \ + ${MESHROOM_BUNDLE}/lib/PySide2/pyside2-lupdate \ + ${MESHROOM_BUNDLE}/lib/PySide2/rcc \ + ${MESHROOM_BUNDLE}/lib/PySide2/designer + +WORKDIR ${MESHROOM_BUILD} + +# Build Meshroom plugins +RUN cmake "${MESHROOM_DEV}" -DALICEVISION_ROOT="${AV_INSTALL}" -DCMAKE_INSTALL_PREFIX="${MESHROOM_BUNDLE}/qtPlugins" +RUN make "-j$(nproc)" qtOIIO + +RUN make "-j$(nproc)" qmlAlembic +RUN make "-j$(nproc)" qtAliceVision +RUN make "-j$(nproc)" && \ + rm -rf "${MESHROOM_BUILD}" "${MESHROOM_DEV}" \ + ${MESHROOM_BUNDLE}/aliceVision/share/doc \ + ${MESHROOM_BUNDLE}/aliceVision/share/eigen3 \ + ${MESHROOM_BUNDLE}/aliceVision/share/fonts \ + ${MESHROOM_BUNDLE}/aliceVision/share/lemon \ + ${MESHROOM_BUNDLE}/aliceVision/share/libraw \ + ${MESHROOM_BUNDLE}/aliceVision/share/man \ + ${MESHROOM_BUNDLE}/aliceVision/share/pkgconfig + +# Enable SSH X11 forwarding, needed when the Docker image +# is run on a remote machine +RUN yum -y install openssh-server xauth mesa-dri-drivers && \ + systemctl enable sshd && \ + mkdir -p /run/sshd + +RUN sed -i "s/^.*X11Forwarding.*$/X11Forwarding yes/; s/^.*X11UseLocalhost.*$/X11UseLocalhost no/; s/^.*PermitRootLogin prohibit-password/PermitRootLogin yes/; s/^.*X11UseLocalhost.*/X11UseLocalhost no/;" /etc/ssh/sshd_config +RUN echo "root:meshroom" | chpasswd + +WORKDIR /root + +EXPOSE 22 +CMD bash -c "test -s /etc/machine-id || systemd-machine-id-setup; sshd-keygen; /usr/sbin/sshd -D" + diff --git a/docker/Dockerfile_ubuntu b/docker/Dockerfile_ubuntu new file mode 100644 index 00000000..3312c5df --- /dev/null +++ b/docker/Dockerfile_ubuntu @@ -0,0 +1,71 @@ +ARG MESHROOM_VERSION +ARG AV_VERSION +ARG CUDA_VERSION +ARG UBUNTU_VERSION +FROM alicevision/meshroom-deps:${MESHROOM_VERSION}-av${AV_VERSION}-ubuntu${UBUNTU_VERSION}-cuda${CUDA_VERSION} +LABEL maintainer="AliceVision Team alicevision-team@googlegroups.com" + +# Execute with nvidia docker (https://github.com/nvidia/nvidia-docker/wiki/Installation-(version-2.0)) +# docker run -it --runtime nvidia -p 2222:22 --name meshroom -v:/data alicevision/meshroom:develop-av2.2.8.develop-ubuntu20.04-cuda11.0 +# ssh -p 2222 -X root@ /opt/Meshroom_bundle/Meshroom # Password is 'meshroom' + +ENV MESHROOM_DEV=/opt/Meshroom \ + MESHROOM_BUILD=/tmp/Meshroom_build \ + MESHROOM_BUNDLE=/opt/Meshroom_bundle \ + AV_INSTALL=/opt/AliceVision_install \ + QT_DIR=/opt/Qt5.14.1/5.14.1/gcc_64 \ + PATH="${PATH}:${MESHROOM_BUNDLE}" \ + OPENIMAGEIO_LIBRARY=/opt/AliceVision_install/lib + +COPY *.txt *.md *.py ${MESHROOM_DEV}/ +COPY ./docs ${MESHROOM_DEV}/docs +COPY ./meshroom ${MESHROOM_DEV}/meshroom +COPY ./tests ${MESHROOM_DEV}/tests +COPY ./bin ${MESHROOM_DEV}/bin + +WORKDIR ${MESHROOM_DEV} + +RUN python3 setup.py install_exe -d "${MESHROOM_BUNDLE}" && \ + find ${MESHROOM_BUNDLE} -name "*Qt5Web*" -delete && \ + find ${MESHROOM_BUNDLE} -name "*Qt5Designer*" -delete && \ + rm -rf ${MESHROOM_BUNDLE}/lib/PySide2/typesystems/ \ + ${MESHROOM_BUNDLE}/lib/PySide2/examples/ \ + ${MESHROOM_BUNDLE}/lib/PySide2/include/ \ + ${MESHROOM_BUNDLE}/lib/PySide2/Qt/translations/ \ + ${MESHROOM_BUNDLE}/lib/PySide2/Qt/resources/ \ + ${MESHROOM_BUNDLE}/lib/PySide2/QtWeb* \ + ${MESHROOM_BUNDLE}/lib/PySide2/pyside2-lupdate \ + ${MESHROOM_BUNDLE}/lib/PySide2/rcc \ + ${MESHROOM_BUNDLE}/lib/PySide2/designer + +WORKDIR ${MESHROOM_BUILD} + +# Build Meshroom plugins +RUN cmake "${MESHROOM_DEV}" -DALICEVISION_ROOT="${AV_INSTALL}" -DCMAKE_INSTALL_PREFIX="${MESHROOM_BUNDLE}/qtPlugins" +RUN make "-j$(nproc)" qtOIIO +RUN make "-j$(nproc)" qmlAlembic +RUN make "-j$(nproc)" qtAliceVision +RUN make "-j$(nproc)" && \ + rm -rf "${MESHROOM_BUILD}" "${MESHROOM_DEV}" \ + ${MESHROOM_BUNDLE}/aliceVision/share/doc \ + ${MESHROOM_BUNDLE}/aliceVision/share/eigen3 \ + ${MESHROOM_BUNDLE}/aliceVision/share/fonts \ + ${MESHROOM_BUNDLE}/aliceVision/share/lemon \ + ${MESHROOM_BUNDLE}/aliceVision/share/libraw \ + ${MESHROOM_BUNDLE}/aliceVision/share/man/ \ + aliceVision/share/pkgconfig + +# Enable SSH X11 forwarding, needed when the Docker image +# is run on a remote machine +RUN apt install ssh xauth && \ + systemctl enable ssh && \ + mkdir -p /run/sshd + +RUN sed -i "s/^.*X11Forwarding.*$/X11Forwarding yes/; s/^.*X11UseLocalhost.*$/X11UseLocalhost no/; s/^.*PermitRootLogin prohibit-password/PermitRootLogin yes/; s/^.*X11UseLocalhost.*/X11UseLocalhost no/;" /etc/ssh/sshd_config +RUN echo "root:meshroom" | chpasswd + +WORKDIR /root + +EXPOSE 22 +CMD ["/usr/sbin/sshd", "-D"] + diff --git a/docker/Dockerfile_ubuntu_deps b/docker/Dockerfile_ubuntu_deps new file mode 100644 index 00000000..7c775f7c --- /dev/null +++ b/docker/Dockerfile_ubuntu_deps @@ -0,0 +1,75 @@ +ARG AV_VERSION +ARG CUDA_VERSION +ARG UBUNTU_VERSION +FROM alicevision/alicevision:${AV_VERSION}-ubuntu${UBUNTU_VERSION}-cuda${CUDA_VERSION} +LABEL maintainer="AliceVision Team alicevision-team@googlegroups.com" + +# Execute with nvidia docker (https://github.com/nvidia/nvidia-docker/wiki/Installation-(version-2.0)) +# docker run -it --runtime=nvidia meshroom + +ENV MESHROOM_DEV=/opt/Meshroom \ + MESHROOM_BUILD=/tmp/Meshroom_build \ + QT_DIR=/opt/Qt5.14.1/5.14.1/gcc_64 \ + QT_CI_LOGIN=alicevisionjunk@gmail.com \ + QT_CI_PASSWORD=azerty1. + +# Workaround for qmlAlembic/qtAliceVision builds: fuse lib/lib64 folders +#RUN ln -s ${AV_INSTALL}/lib ${AV_INSTALL}/lib64 + +# Install libs needed by Qt +RUN apt-get update && \ + DEBIAN_FRONTEND=noninteractive apt-get install -yq --no-install-recommends \ + flex \ + fontconfig \ + libfreetype6 \ + libglib2.0-0 \ + libice6 \ + libx11-6 \ + libxcb1 \ + libxext6 \ + libxi6 \ + libxrender1 \ + libsm6 \ + libxt-dev \ + libglu-dev \ + libosmesa-dev \ + libgl-dev \ + libglu-dev \ + libqt5charts5-dev \ + libxcb-keysyms1 \ + libxcb-image0 \ + libxkbcommon-x11-0 \ + libz-dev \ + systemd \ + ssh + +# Disabled as QTOIIO requires ah least 5.13 (5.12 available in Ubuntu 20.04) +# qtdeclarative5-dev \ +# qt3d-assimpsceneimport-plugin \ +# qt3d-defaultgeometryloader-plugin \ +# qt3d-gltfsceneio-plugin \ +# qt3d-scene2d-plugin \ +# qt53dextras5 \ +# qt3d5-dev + + +RUN apt-get install -y --no-install-recommends \ + software-properties-common + +# Install Python3 +RUN apt install python3-pip -y && pip3 install --upgrade pip + +# Install Qt (to build plugins) +WORKDIR /tmp/qt +COPY dl/qt.run /tmp/qt +COPY ./docker/qt-installer-noninteractive.qs ${MESHROOM_DEV}/docker/ +RUN chmod +x qt.run && \ + ./qt.run --verbose --platform minimal --script "${MESHROOM_DEV}/docker/qt-installer-noninteractive.qs" && \ + rm qt.run + +COPY ./*requirements.txt ./setup.py ${MESHROOM_DEV}/ + +# Install Meshroom requirements and freeze bundle +WORKDIR "${MESHROOM_DEV}" +RUN pip install -r dev_requirements.txt -r requirements.txt + diff --git a/docker/build-all.sh b/docker/build-all.sh new file mode 100755 index 00000000..006a6146 --- /dev/null +++ b/docker/build-all.sh @@ -0,0 +1,16 @@ +#!/bin/sh + +set -e + +test -d docker || ( + echo This script must be run from the top level Meshroom directory + exit 1 +) + +CUDA_VERSION=11.0 UBUNTU_VERSION=20.04 docker/build-ubuntu.sh +CUDA_VERSION=11.0 UBUNTU_VERSION=18.04 docker/build-ubuntu.sh +CUDA_VERSION=10.2 UBUNTU_VERSION=18.04 docker/build-ubuntu.sh +CUDA_VERSION=9.2 UBUNTU_VERSION=18.04 docker/build-ubuntu.sh + +CUDA_VERSION=10.2 CENTOS_VERSION=7 docker/build-centos.sh +CUDA_VERSION=9.2 CENTOS_VERSION=7 docker/build-centos.sh diff --git a/docker/build-centos.sh b/docker/build-centos.sh new file mode 100755 index 00000000..bf61f201 --- /dev/null +++ b/docker/build-centos.sh @@ -0,0 +1,42 @@ +#!/bin/bash +set -ex + + +test -z "$MESHROOM_VERSION" && MESHROOM_VERSION="$(git rev-parse --abbrev-ref HEAD)-$(git rev-parse --short HEAD)" +test -z "$AV_VERSION" && echo "AliceVision version not specified, set AV_VERSION in the environment" && exit 1 +test -z "$CUDA_VERSION" && CUDA_VERSION="10.2" +test -z "$CENTOS_VERSION" && CENTOS_VERSION="7" +test -z "$MESHROOM_PYTHON2" || echo "========== Build for Python 2 ==========" +test -z "$MESHROOM_PYTHON2" || export PYTHON2_DOCKER_EXT="-py2" +test -z "$MESHROOM_PYTHON2" || export PYTHON2_DOCKERFILE_EXT="_py2" +test -z "$MESHROOM_PYTHON2" && echo "========== Build for Python 3 ==========" + +test -d docker || ( + echo This script must be run from the top level Meshroom directory + exit 1 +) + +test -d dl || \ + mkdir dl +test -f dl/qt.run || \ + wget "https://download.qt.io/archive/qt/5.14/5.14.1/qt-opensource-linux-x64-5.14.1.run" -O "dl/qt.run" + +# DEPENDENCIES +docker build \ + --rm \ + --build-arg "CUDA_VERSION=${CUDA_VERSION}" \ + --build-arg "CENTOS_VERSION=${CENTOS_VERSION}" \ + --build-arg "AV_VERSION=${AV_VERSION}" \ + --tag "alicevision/meshroom-deps:${MESHROOM_VERSION}-av${AV_VERSION}-centos${CENTOS_VERSION}-cuda${CUDA_VERSION}${PYTHON2_DOCKER_EXT}" \ + -f docker/Dockerfile_centos_deps${PYTHON2_DOCKERFILE_EXT} . + +# Meshroom +docker build \ + --rm \ + --build-arg "MESHROOM_VERSION=${MESHROOM_VERSION}" \ + --build-arg "CUDA_VERSION=${CUDA_VERSION}" \ + --build-arg "CENTOS_VERSION=${CENTOS_VERSION}" \ + --build-arg "AV_VERSION=${AV_VERSION}" \ + --tag "alicevision/meshroom:${MESHROOM_VERSION}-av${AV_VERSION}-centos${CENTOS_VERSION}-cuda${CUDA_VERSION}${PYTHON2_DOCKER_EXT}" \ + -f docker/Dockerfile_centos${PYTHON2_DOCKERFILE_EXT} . + diff --git a/docker/build-ubuntu.sh b/docker/build-ubuntu.sh new file mode 100755 index 00000000..909b3ec0 --- /dev/null +++ b/docker/build-ubuntu.sh @@ -0,0 +1,37 @@ +#!/bin/bash +set -e + +test -z "$MESHROOM_VERSION" && MESHROOM_VERSION="$(git rev-parse --abbrev-ref HEAD)-$(git rev-parse --short HEAD)" +test -z "$AV_VERSION" && echo "AliceVision version not specified, set AV_VERSION in the environment" && exit 1 +test -z "$CUDA_VERSION" && CUDA_VERSION=11.0 +test -z "$UBUNTU_VERSION" && UBUNTU_VERSION=20.04 + +test -d docker || ( + echo This script must be run from the top level Meshroom directory + exit 1 +) + +test -d dl || \ + mkdir dl +test -f dl/qt.run || \ + "wget https://download.qt.io/archive/qt/5.14/5.14.1/qt-opensource-linux-x64-5.14.1.run" -O "dl/qt.run" + +# DEPENDENCIES +docker build \ + --rm \ + --build-arg "CUDA_VERSION=${CUDA_VERSION}" \ + --build-arg "UBUNTU_VERSION=${UBUNTU_VERSION}" \ + --build-arg "AV_VERSION=${AV_VERSION}" \ + --tag "alicevision/meshroom-deps:${MESHROOM_VERSION}-av${AV_VERSION}-ubuntu${UBUNTU_VERSION}-cuda${CUDA_VERSION}" \ + -f docker/Dockerfile_ubuntu_deps . + +# Meshroom +docker build \ + --rm \ + --build-arg "MESHROOM_VERSION=${MESHROOM_VERSION}" \ + --build-arg "CUDA_VERSION=${CUDA_VERSION}" \ + --build-arg "UBUNTU_VERSION=${UBUNTU_VERSION}" \ + --build-arg "AV_VERSION=${AV_VERSION}" \ + --tag "alicevision/meshroom:${MESHROOM_VERSION}-av${AV_VERSION}-ubuntu${UBUNTU_VERSION}-cuda${CUDA_VERSION}" \ + -f docker/Dockerfile_ubuntu . + diff --git a/docker/extract.sh b/docker/extract.sh new file mode 100755 index 00000000..055e0ff4 --- /dev/null +++ b/docker/extract.sh @@ -0,0 +1,28 @@ +#!/bin/bash +set -ex + +AV_VERSION="2.2.10.hdri" +MESHROOM_VERSION="2020.0.1.hdri" + +test -z "$MESHROOM_VERSION" && MESHROOM_VERSION="$(git rev-parse --abbrev-ref HEAD)-$(git rev-parse --short HEAD)" +test -z "$AV_VERSION" && echo "AliceVision version not specified, set AV_VERSION in the environment" && exit 1 +test -z "$CUDA_VERSION" && CUDA_VERSION="10.2" +test -z "$CENTOS_VERSION" && CENTOS_VERSION="7" +test -z "$MESHROOM_PYTHON2" || echo "========== Build for Python 2 ==========" +test -z "$MESHROOM_PYTHON2" || export PYTHON2_DOCKER_EXT="-py2" +test -z "$MESHROOM_PYTHON2" || export PYTHON2_DOCKERFILE_EXT="_py2" +test -z "$MESHROOM_PYTHON2" && echo "========== Build for Python 3 ==========" + +test -d docker || ( + echo This script must be run from the top level Meshroom directory + exit 1 +) + +VERSION_NAME=${MESHROOM_VERSION}-av${AV_VERSION}-centos${CENTOS_VERSION}-cuda${CUDA_VERSION}${PYTHON2_DOCKER_EXT} + +# Retrieve the Meshroom bundle folder +rm -rf ./Meshroom-${VERSION_NAME} +CID=$(docker create alicevision/meshroom:${VERSION_NAME}) +docker cp ${CID}:/opt/Meshroom_bundle ./Meshroom-${VERSION_NAME} +docker rm ${CID} + diff --git a/docker/qt-installer-noninteractive.qs b/docker/qt-installer-noninteractive.qs index 32d65cb1..504aaad9 100644 --- a/docker/qt-installer-noninteractive.qs +++ b/docker/qt-installer-noninteractive.qs @@ -1,83 +1,74 @@ -// Emacs mode hint: -*- mode: JavaScript -*- - function Controller() { - installer.autoRejectMessageBoxes(); installer.installationFinished.connect(function() { gui.clickButton(buttons.NextButton); - }) - - // Copied from https://bugreports.qt.io/browse/QTIFW-1072?jql=project%20%3D%20QTIFW - // there are some changes between Qt Online installer 3.0.1 and 3.0.2. Welcome page does some network - // queries that is why the next button is called too early. - var page = gui.pageWidgetByObjectName("WelcomePage") - page.completeChanged.connect(welcomepageFinished) + }); + installer.setMessageBoxAutomaticAnswer("OverwriteTargetDirectory", QMessageBox.Yes); + installer.setMessageBoxAutomaticAnswer("installationErrorWithRetry", QMessageBox.Ignore); + installer.setMessageBoxAutomaticAnswer("cancelInstallation", QMessageBox.Yes); } - Controller.prototype.WelcomePageCallback = function() { - gui.clickButton(buttons.NextButton); + console.log("Welcome Page"); + gui.clickButton(buttons.NextButton, 3000); } - -welcomepageFinished = function() -{ - //completeChange() -function is called also when other pages visible - //Make sure that next button is clicked only when in welcome page - if(gui.currentPageWidget().objectName == "WelcomePage") { - gui.clickButton( buttons.NextButton); - } -} - Controller.prototype.CredentialsPageCallback = function() { - gui.clickButton(buttons.NextButton); -} - -Controller.prototype.IntroductionPageCallback = function() { - gui.clickButton(buttons.NextButton); -} - -Controller.prototype.TargetDirectoryPageCallback = function() -{ - gui.currentPageWidget().TargetDirectoryLineEdit.setText("/opt/qt"); - gui.clickButton(buttons.NextButton); -} - -Controller.prototype.ComponentSelectionPageCallback = function() { + console.log("Credentials Page"); + var login = installer.environmentVariable("QT_CI_LOGIN"); + var password = installer.environmentVariable("QT_CI_PASSWORD"); + if( login === "" || password === "" ) { + console.log("No credentials provided - could stuck here forever"); + gui.clickButton(buttons.CommitButton); + } + var widget = gui.currentPageWidget(); + widget.loginWidget.EmailLineEdit.setText(login); + widget.loginWidget.PasswordLineEdit.setText(password); + gui.clickButton(buttons.CommitButton); +} +Controller.prototype.ComponentSelectionPageCallback = function() { + console.log("Select components"); var widget = gui.currentPageWidget(); - widget.deselectAll(); - - // widget.selectComponent("qt"); - // widget.selectComponent("qt.qt5.5130"); - widget.selectComponent("qt.qt5.5130.gcc_64"); - // widget.selectComponent("qt.qt5.5130.qtscript"); - // widget.selectComponent("qt.qt5.5130.qtscript.gcc_64"); - // widget.selectComponent("qt.qt5.5130.qtwebengine"); - // widget.selectComponent("qt.qt5.5130.qtwebengine.gcc_64"); - // widget.selectComponent("qt.qt5.5130.qtwebglplugin"); - // widget.selectComponent("qt.qt5.5130.qtwebglplugin.gcc_64"); - // widget.selectComponent("qt.tools"); - + widget.selectComponent("qt.qt5.5141.gcc_64"); + widget.selectComponent("qt.qt5.5141.qtcharts"); + widget.selectComponent("qt.qt5.5141.qtcharts.gcc_64"); + gui.clickButton(buttons.NextButton); +} +Controller.prototype.IntroductionPageCallback = function() { + console.log("Introduction Page"); + console.log("Retrieving meta information from remote repository"); + gui.clickButton(buttons.NextButton); +} +Controller.prototype.TargetDirectoryPageCallback = function() { gui.clickButton(buttons.NextButton); } - Controller.prototype.LicenseAgreementPageCallback = function() { - gui.currentPageWidget().AcceptLicenseRadioButton.setChecked(true); + console.log("Accept license agreement"); + var widget = gui.currentPageWidget(); + if (widget != null) { + widget.AcceptLicenseRadioButton.setChecked(true); + } gui.clickButton(buttons.NextButton); } - -Controller.prototype.StartMenuDirectoryPageCallback = function() { +Controller.prototype.ObligationsPageCallback = function() { + console.log("Accept obligation agreement"); + var page = gui.pageWidgetByObjectName("ObligationsPage"); + page.obligationsAgreement.setChecked(true); + page.completeChanged(); gui.clickButton(buttons.NextButton); } - -Controller.prototype.ReadyForInstallationPageCallback = function() -{ - gui.clickButton(buttons.NextButton); +Controller.prototype.ReadyForInstallationPageCallback = function() { + console.log("Ready to install"); + gui.clickButton(buttons.CommitButton); } - Controller.prototype.FinishedPageCallback = function() { - var checkBoxForm = gui.currentPageWidget().LaunchQtCreatorCheckBoxForm - if (checkBoxForm && checkBoxForm.launchQtCreatorCheckBox) { - checkBoxForm.launchQtCreatorCheckBox.checked = false; + var widget = gui.currentPageWidget(); + if (widget.LaunchQtCreatorCheckBoxForm) { + // No this form for minimal platform + widget.LaunchQtCreatorCheckBoxForm.launchQtCreatorCheckBox.setChecked(false); } gui.clickButton(buttons.FinishButton); } - +Controller.prototype.DynamicTelemetryPluginFormCallback = function() { + var page = gui.pageWidgetByObjectName("DynamicTelemetryPluginForm"); + page.statisticGroupBox.disableStatisticRadioButton.setChecked(true); + gui.clickButton(buttons.NextButton); +} diff --git a/meshroom/__init__.py b/meshroom/__init__.py index e2847226..5c9549d1 100644 --- a/meshroom/__init__.py +++ b/meshroom/__init__.py @@ -1,4 +1,4 @@ -__version__ = "2019.2.0" +__version__ = "2020.1.1" __version_name__ = __version__ from distutils import util @@ -78,7 +78,12 @@ def setupEnvironment(): if not isinstance(val, (list, tuple)): val = [val] - paths[index:index] = val + if index == -1: + paths.extend(val) + elif index == 0: + paths = val + paths + else: + raise ValueError("addToEnvPath: index must be -1 or 0.") os.environ[var] = os.pathsep.join(paths) # setup root directory (override possible by setting "MESHROOM_INSTALL_DIR" environment variable) diff --git a/meshroom/common/PySignal.py b/meshroom/common/PySignal.py new file mode 100644 index 00000000..1dde99ab --- /dev/null +++ b/meshroom/common/PySignal.py @@ -0,0 +1,313 @@ +# https://github.com/dgovil/PySignal + +__author__ = "Dhruv Govil" +__copyright__ = "Copyright 2016, Dhruv Govil" +__credits__ = ["Dhruv Govil", "John Hood", "Jason Viloria", "Adric Worley", "Alex Widener"] +__license__ = "MIT" +__version__ = "1.1.3" +__maintainer__ = "Dhruv Govil" +__email__ = "dhruvagovil@gmail.com" +__status__ = "Beta" + +import inspect +import sys +import weakref +from functools import partial + + +# weakref.WeakMethod backport +try: + from weakref import WeakMethod + +except ImportError: + import types + + class WeakMethod(object): + """Light WeakMethod backport compiled from various sources. Tested in 2.7""" + + def __init__(self, func): + if inspect.ismethod(func): + self._obj = weakref.ref(func.__self__) + self._func = weakref.ref(func.__func__) + + else: + self._obj = None + + try: + self._func = weakref.ref(func.__func__) + + # Rather than attempting to handle this, raise the same exception + # you get from WeakMethod. + except AttributeError: + raise TypeError("argument should be a bound method, not %s" % type(func)) + + def __call__(self): + if self._obj is not None: + obj = self._obj() + func = self._func() + if func is None or obj is None: + return None + + else: + return types.MethodType(func, obj, obj.__class__) + + elif self._func is not None: + return self._func() + + else: + return None + + def __eq__(self, other): + try: + return type(self) is type(other) and self() == other() + + except Exception: + return False + + def __ne__(self, other): + return not self.__eq__(other) + + +class Signal(object): + """ + The Signal is the core object that handles connection and emission . + """ + + def __init__(self): + super(Signal, self).__init__() + self._block = False + self._sender = None + self._slots = [] + + def __call__(self, *args, **kwargs): + self.emit(*args, **kwargs) + + def emit(self, *args, **kwargs): + """ + Calls all the connected slots with the provided args and kwargs unless block is activated + """ + if self._block: + return + + def _get_sender(): + """Try to get the bound, class or module method calling the emit.""" + prev_frame = sys._getframe(2) + func_name = prev_frame.f_code.co_name + + # Faster to try/catch than checking for 'self' + try: + return getattr(prev_frame.f_locals['self'], func_name) + + except KeyError: + return getattr(inspect.getmodule(prev_frame), func_name) + + # Get the sender + try: + self._sender = WeakMethod(_get_sender()) + + # Account for when func_name is at '' + except AttributeError: + self._sender = None + + # Handle unsupported module level methods for WeakMethod. + # TODO: Support module level methods. + except TypeError: + self._sender = None + + for slot in self._slots: + if not slot: + continue + elif isinstance(slot, partial): + slot() + elif isinstance(slot, weakref.WeakKeyDictionary): + # For class methods, get the class object and call the method accordingly. + for obj, method in slot.items(): + method(obj, *args, **kwargs) + elif isinstance(slot, weakref.ref): + # If it's a weakref, call the ref to get the instance and then call the func + # Don't wrap in try/except so we don't risk masking exceptions from the actual func call + tested_slot = slot() + if tested_slot is not None: + tested_slot(*args, **kwargs) + else: + # Else call it in a standard way. Should be just lambdas at this point + slot(*args, **kwargs) + + def connect(self, slot): + """ + Connects the signal to any callable object + """ + if not callable(slot): + raise ValueError("Connection to non-callable '%s' object failed" % slot.__class__.__name__) + + if isinstance(slot, (partial, Signal)) or '<' in slot.__name__: + # If it's a partial, a Signal or a lambda. The '<' check is the only py2 and py3 compatible way I could find + if slot not in self._slots: + self._slots.append(slot) + elif inspect.ismethod(slot): + # Check if it's an instance method and store it with the instance as the key + slotSelf = slot.__self__ + slotDict = weakref.WeakKeyDictionary() + slotDict[slotSelf] = slot.__func__ + if slotDict not in self._slots: + self._slots.append(slotDict) + else: + # If it's just a function then just store it as a weakref. + newSlotRef = weakref.ref(slot) + if newSlotRef not in self._slots: + self._slots.append(newSlotRef) + + def disconnect(self, slot): + """ + Disconnects the slot from the signal + """ + if not callable(slot): + return + + if inspect.ismethod(slot): + # If it's a method, then find it by its instance + slotSelf = slot.__self__ + for s in self._slots: + if (isinstance(s, weakref.WeakKeyDictionary) and + (slotSelf in s) and + (s[slotSelf] is slot.__func__)): + self._slots.remove(s) + break + elif isinstance(slot, (partial, Signal)) or '<' in slot.__name__: + # If it's a partial, a Signal or lambda, try to remove directly + try: + self._slots.remove(slot) + except ValueError: + pass + else: + # It's probably a function, so try to remove by weakref + try: + self._slots.remove(weakref.ref(slot)) + except ValueError: + pass + + def clear(self): + """Clears the signal of all connected slots""" + self._slots = [] + + def block(self, isBlocked): + """Sets blocking of the signal""" + self._block = bool(isBlocked) + + def sender(self): + """Return the callable responsible for emitting the signal, if found.""" + try: + return self._sender() + + except TypeError: + return None + + +class ClassSignal(object): + """ + The class signal allows a signal to be set on a class rather than an instance. + This emulates the behavior of a PyQt signal + """ + _map = {} + + def __get__(self, instance, owner): + if instance is None: + # When we access ClassSignal element on the class object without any instance, + # we return the ClassSignal itself + return self + tmp = self._map.setdefault(self, weakref.WeakKeyDictionary()) + return tmp.setdefault(instance, Signal()) + + def __set__(self, instance, value): + raise RuntimeError("Cannot assign to a Signal object") + + +class SignalFactory(dict): + """ + The Signal Factory object lets you handle signals by a string based name instead of by objects. + """ + + def register(self, name, *slots): + """ + Registers a given signal + :param name: the signal to register + """ + # setdefault initializes the object even if it exists. This is more efficient + if name not in self: + self[name] = Signal() + + for slot in slots: + self[name].connect(slot) + + def deregister(self, name): + """ + Removes a given signal + :param name: the signal to deregister + """ + self.pop(name, None) + + def emit(self, signalName, *args, **kwargs): + """ + Emits a signal by name if it exists. Any additional args or kwargs are passed to the signal + :param signalName: the signal name to emit + """ + assert signalName in self, "%s is not a registered signal" % signalName + self[signalName].emit(*args, **kwargs) + + def connect(self, signalName, slot): + """ + Connects a given signal to a given slot + :param signalName: the signal name to connect to + :param slot: the callable slot to register + """ + assert signalName in self, "%s is not a registered signal" % signalName + self[signalName].connect(slot) + + def block(self, signals=None, isBlocked=True): + """ + Sets the block on any provided signals, or to all signals + + :param signals: defaults to all signals. Accepts either a single string or a list of strings + :param isBlocked: the state to set the signal to + """ + if signals: + try: + if isinstance(signals, basestring): + signals = [signals] + except NameError: + if isinstance(signals, str): + signals = [signals] + + signals = signals or self.keys() + + for signal in signals: + if signal not in self: + raise RuntimeError("Could not find signal matching %s" % signal) + self[signal].block(isBlocked) + + +class ClassSignalFactory(object): + """ + The class signal allows a signal factory to be set on a class rather than an instance. + """ + _map = {} + _names = set() + + def __get__(self, instance, owner): + tmp = self._map.setdefault(self, weakref.WeakKeyDictionary()) + + signal = tmp.setdefault(instance, SignalFactory()) + for name in self._names: + signal.register(name) + + return signal + + def __set__(self, instance, value): + raise RuntimeError("Cannot assign to a Signal object") + + def register(self, name): + """ + Registers a new signal with the given name + :param name: The signal to register + """ + self._names.add(name) diff --git a/meshroom/common/__init__.py b/meshroom/common/__init__.py index 8607447b..3331e9c6 100644 --- a/meshroom/common/__init__.py +++ b/meshroom/common/__init__.py @@ -8,13 +8,14 @@ Property = None BaseObject = None Variant = None VariantList = None +JSValue = None if meshroom.backend == meshroom.Backend.PYSIDE: # PySide types - from .qt import DictModel, ListModel, Slot, Signal, Property, BaseObject, Variant, VariantList + from .qt import DictModel, ListModel, Slot, Signal, Property, BaseObject, Variant, VariantList, JSValue elif meshroom.backend == meshroom.Backend.STANDALONE: # Core types - from .core import DictModel, ListModel, Slot, Signal, Property, BaseObject, Variant, VariantList + from .core import DictModel, ListModel, Slot, Signal, Property, BaseObject, Variant, VariantList, JSValue class _BaseModel: diff --git a/meshroom/common/core.py b/meshroom/common/core.py index 54ba9cbe..766480e6 100644 --- a/meshroom/common/core.py +++ b/meshroom/common/core.py @@ -1,3 +1,4 @@ +from . import PySignal class CoreDictModel: @@ -73,6 +74,9 @@ class CoreListModel: def __getitem__(self, idx): return self._objects[idx] + def values(self): + return self._objects + def setObjectList(self, iterable): self.clear() self._objects = iterable @@ -102,20 +106,6 @@ class CoreListModel: self._objects[index:index] = iterable -class CoreSignal: - """ Simple signal/callback implementation """ - def __init__(self): - self._callbacks = set() - - def emit(self, *args): - # TODO: check if we really need this in non-UI mode - # [cb(*args) for cb in self._callbacks] - pass - - def connect(self, func): - self._callbacks.add(func) - - def CoreSlot(*args, **kwargs): def slot_decorator(func): def func_wrapper(*f_args, **f_kwargs): @@ -130,9 +120,15 @@ class CoreProperty(property): class CoreObject(object): + def __init__(self, parent=None, *args, **kwargs): super(CoreObject, self).__init__() self._parent = parent + # Note: we do not use ClassSignal, as it can not be used in __del__. + self.destroyed = PySignal.Signal() + + def __del__(self): + self.destroyed.emit() def parent(self): return self._parent @@ -141,8 +137,9 @@ class CoreObject(object): DictModel = CoreDictModel ListModel = CoreListModel Slot = CoreSlot -Signal = CoreSignal +Signal = PySignal.ClassSignal Property = CoreProperty BaseObject = CoreObject Variant = object VariantList = object +JSValue = None diff --git a/meshroom/common/qt.py b/meshroom/common/qt.py index cb1087d5..0c4c7b5d 100644 --- a/meshroom/common/qt.py +++ b/meshroom/common/qt.py @@ -1,5 +1,5 @@ -from PySide2 import QtCore - +from PySide2 import QtCore, QtQml +import shiboken2 class QObjectListModel(QtCore.QAbstractListModel): """ @@ -216,6 +216,7 @@ class QObjectListModel(QtCore.QAbstractListModel): def reset(self, objects): self.setObjectList(objects) + @QtCore.Slot(QtCore.QObject, result=bool) def contains(self, obj): """ Returns true if the list contains an occurrence of object; otherwise returns false. @@ -271,7 +272,7 @@ class QObjectListModel(QtCore.QAbstractListModel): def _dereferenceItem(self, item): # Ask for object deletion if parented to the model - if item.parent() == self: + if shiboken2.isValid(item) and item.parent() == self: # delay deletion until the next event loop # This avoids warnings when the QML engine tries to evaluate (but should not) # an object that has already been deleted @@ -374,3 +375,4 @@ Property = QtCore.Property BaseObject = QtCore.QObject Variant = "QVariant" VariantList = "QVariantList" +JSValue = QtQml.QJSValue diff --git a/meshroom/core/attribute.py b/meshroom/core/attribute.py index 9a5b77e8..c0651de2 100644 --- a/meshroom/core/attribute.py +++ b/meshroom/core/attribute.py @@ -1,8 +1,10 @@ #!/usr/bin/env python # coding:utf-8 -import collections +import copy import re import weakref +import types +import logging from meshroom.common import BaseObject, Property, Variant, Signal, ListModel, DictModel, Slot from meshroom.core import desc, pyCompatibility, hashValue @@ -54,8 +56,9 @@ class Attribute(BaseObject): self._node = weakref.ref(node) self.attributeDesc = attributeDesc self._isOutput = isOutput - self._value = attributeDesc.value + self._value = copy.copy(attributeDesc.value) self._label = attributeDesc.label + self._enabled = True # invalidation value for output attributes self._invalidationValue = "" @@ -90,9 +93,27 @@ class Attribute(BaseObject): def getType(self): return self.attributeDesc.__class__.__name__ + def getBaseType(self): + return self.getType() + def getLabel(self): return self._label + def getEnabled(self): + if isinstance(self.desc.enabled, types.FunctionType): + try: + return self.desc.enabled(self.node) + except: + # Node implementation may fail due to version mismatch + return True + return self.attributeDesc.enabled + + def setEnabled(self, v): + if self._enabled == v: + return + self._enabled = v + self.enabledChanged.emit() + def _get_value(self): return self.getLinkParam().value if self.isLink else self._value @@ -119,11 +140,12 @@ class Attribute(BaseObject): self.valueChanged.emit() def resetValue(self): - self._value = "" + self._value = self.attributeDesc.value def requestGraphUpdate(self): if self.node.graph: self.node.graph.markNodesDirty(self.node) + self.node.graph.update() @property def isOutput(self): @@ -152,7 +174,7 @@ class Attribute(BaseObject): def isLink(self): """ Whether the attribute is a link to another attribute. """ # note: directly use self.node.graph._edges to avoid using the property that may become invalid at some point - return self.node.graph and self.isInput and self in self.node.graph._edges.keys() + return self.node.graph and self.isInput and self.node.graph._edges and self in self.node.graph._edges.keys() @staticmethod def isLinkExpression(value): @@ -162,8 +184,23 @@ class Attribute(BaseObject): """ return isinstance(value, pyCompatibility.basestring) and Attribute.stringIsLinkRe.match(value) - def getLinkParam(self): - return self.node.graph.edge(self).src if self.isLink else None + def getLinkParam(self, recursive=False): + if not self.isLink: + return None + linkParam = self.node.graph.edge(self).src + if not recursive: + return linkParam + if linkParam.isLink: + return linkParam.getLinkParam(recursive) + return linkParam + + @property + def hasOutputConnections(self): + """ Whether the attribute has output connections, i.e is the source of at least one edge. """ + # safety check to avoid evaluation errors + if not self.node.graph or not self.node.graph.edges: + return False + return next((edge for edge in self.node.graph.edges.values() if edge.src == self), None) is not None def _applyExpr(self): """ @@ -182,21 +219,32 @@ class Attribute(BaseObject): # value is a link to another attribute link = v[1:-1] linkNode, linkAttr = link.split('.') - g.addEdge(g.node(linkNode).attribute(linkAttr), self) + try: + g.addEdge(g.node(linkNode).attribute(linkAttr), self) + except KeyError as err: + logging.warning('Connect Attribute from Expression failed.\nExpression: "{exp}"\nError: "{err}".'.format(exp=v, err=err)) self.resetValue() def getExportValue(self): if self.isLink: return self.getLinkParam().asLinkExpr() if self.isOutput: - return self.desc.value + return self.defaultValue() return self._value - def format(self): - return self.desc.format(self.value) + def getValueStr(self): + if isinstance(self.attributeDesc, desc.ChoiceParam) and not self.attributeDesc.exclusive: + assert(isinstance(self.value, pyCompatibility.Sequence) and not isinstance(self.value, pyCompatibility.basestring)) + return self.attributeDesc.joinChar.join(self.value) + if isinstance(self.attributeDesc, (desc.StringParam, desc.File)): + return '"{}"'.format(self.value) + return str(self.value) def defaultValue(self): - return self.desc.value + if isinstance(self.desc.value, types.FunctionType): + return self.desc.value(self) + # Need to force a copy, for the case where the value is a list (avoid reference to the desc value) + return copy.copy(self.desc.value) def _isDefault(self): return self._value == self.defaultValue() @@ -204,19 +252,30 @@ class Attribute(BaseObject): def getPrimitiveValue(self, exportDefault=True): return self._value + def updateInternals(self): + # Emit if the enable status has changed + self.setEnabled(self.getEnabled()) + + name = Property(str, getName, constant=True) fullName = Property(str, getFullName, constant=True) label = Property(str, getLabel, constant=True) type = Property(str, getType, constant=True) + baseType = Property(str, getType, constant=True) desc = Property(desc.Attribute, lambda self: self.attributeDesc, constant=True) valueChanged = Signal() value = Property(Variant, _get_value, _set_value, notify=valueChanged) isOutput = Property(bool, isOutput.fget, constant=True) isLinkChanged = Signal() isLink = Property(bool, isLink.fget, notify=isLinkChanged) + hasOutputConnectionsChanged = Signal() + hasOutputConnections = Property(bool, hasOutputConnections.fget, notify=hasOutputConnectionsChanged) isDefault = Property(bool, _isDefault, notify=valueChanged) linkParam = Property(BaseObject, getLinkParam, notify=isLinkChanged) + rootLinkParam = Property(BaseObject, lambda self: self.getLinkParam(recursive=True), notify=isLinkChanged) node = Property(BaseObject, node.fget, constant=True) + enabledChanged = Signal() + enabled = Property(bool, getEnabled, setEnabled, notify=enabledChanged) def raiseIfLink(func): @@ -237,6 +296,9 @@ class ListAttribute(Attribute): def __len__(self): return len(self._value) + def getBaseType(self): + return self.attributeDesc.elementDesc.__class__.__name__ + def at(self, idx): """ Returns child attribute at index 'idx' """ # implement 'at' rather than '__getitem__' @@ -257,8 +319,8 @@ class ListAttribute(Attribute): self._value = value # New value else: - self.desc.validateValue(value) - self.extend(value) + newValue = self.desc.validateValue(value) + self.extend(newValue) self.requestGraphUpdate() @raiseIfLink @@ -328,9 +390,20 @@ class ListAttribute(Attribute): else: return [attr.getPrimitiveValue(exportDefault=exportDefault) for attr in self._value if not attr.isDefault] + def getValueStr(self): + if isinstance(self.value, ListModel): + return self.attributeDesc.joinChar.join([v.getValueStr() for v in self.value]) + return super(ListAttribute, self).getValueStr() + + def updateInternals(self): + super(ListAttribute, self).updateInternals() + for attr in self._value: + attr.updateInternals() + # Override value property setter value = Property(Variant, Attribute._get_value, _set_value, notify=Attribute.valueChanged) isDefault = Property(bool, _isDefault, notify=Attribute.valueChanged) + baseType = Property(str, getBaseType, constant=True) class GroupAttribute(Attribute): @@ -357,10 +430,16 @@ class GroupAttribute(Attribute): raise AttributeError(key) def _set_value(self, exportedValue): - self.desc.validateValue(exportedValue) - # set individual child attribute values - for key, value in exportedValue.items(): - self._value.get(key).value = value + value = self.desc.validateValue(exportedValue) + if isinstance(value, dict): + # set individual child attribute values + for key, v in value.items(): + self._value.get(key).value = v + elif isinstance(value, (list, tuple)): + for attrDesc, v in zip(self.desc._groupDesc, value): + self._value.get(attrDesc.name).value = v + else: + raise AttributeError("Failed to set on GroupAttribute: {}".format(str(value))) @Slot(str, result=Attribute) def childAttribute(self, key): @@ -381,7 +460,7 @@ class GroupAttribute(Attribute): def uid(self, uidIndex): uids = [] for k, v in self._value.items(): - if uidIndex in v.desc.uid: + if v.enabled and uidIndex in v.desc.uid: uids.append(v.uid(uidIndex)) return hashValue(uids) @@ -404,6 +483,16 @@ class GroupAttribute(Attribute): else: return {name: attr.getPrimitiveValue(exportDefault=exportDefault) for name, attr in self._value.items() if not attr.isDefault} + def getValueStr(self): + # sort values based on child attributes group description order + sortedSubValues = [self._value.get(attr.name).getValueStr() for attr in self.attributeDesc.groupDesc] + return self.attributeDesc.joinChar.join(sortedSubValues) + + def updateInternals(self): + super(GroupAttribute, self).updateInternals() + for attr in self._value: + attr.updateInternals() + # Override value property value = Property(Variant, Attribute._get_value, _set_value, notify=Attribute.valueChanged) isDefault = Property(bool, _isDefault, notify=Attribute.valueChanged) diff --git a/meshroom/core/desc.py b/meshroom/core/desc.py index c5ba12ac..0956191a 100755 --- a/meshroom/core/desc.py +++ b/meshroom/core/desc.py @@ -1,17 +1,16 @@ -from meshroom.common import BaseObject, Property, Variant, VariantList, ListModel +from meshroom.common import BaseObject, Property, Variant, VariantList, JSValue, ListModel from meshroom.core import pyCompatibility from enum import Enum # available by default in python3. For python2: "pip install enum34" -import collections import math import os import psutil - +import ast class Attribute(BaseObject): """ """ - def __init__(self, name, label, description, value, advanced, semantic, uid, group, formatter): + def __init__(self, name, label, description, value, advanced, semantic, uid, group, enabled, formatter): super(Attribute, self).__init__() self._name = name self._label = label @@ -20,6 +19,7 @@ class Attribute(BaseObject): self._uid = uid self._group = group self._advanced = advanced + self._enabled = enabled self._semantic = semantic self._formatter = formatter or self._defaultFormatter @@ -30,16 +30,17 @@ class Attribute(BaseObject): uid = Property(Variant, lambda self: self._uid, constant=True) group = Property(str, lambda self: self._group, constant=True) advanced = Property(bool, lambda self: self._advanced, constant=True) + enabled = Property(Variant, lambda self: self._enabled, constant=True) semantic = Property(str, lambda self: self._semantic, constant=True) type = Property(str, lambda self: self.__class__.__name__, constant=True) def validateValue(self, value): - """ Return validated/conformed 'value'. + """ Return validated/conformed 'value'. Need to be implemented in derived classes. Raises: ValueError: if value does not have the proper type """ - return value + raise NotImplementedError("Attribute.validateValue is an abstract function that should be implemented in the derived class.") def matchDescription(self, value, conform=False): """ Returns whether the value perfectly match attribute's description. @@ -73,19 +74,27 @@ class Attribute(BaseObject): class ListAttribute(Attribute): """ A list of Attributes """ - def __init__(self, elementDesc, name, label, description, group='allParams', advanced=False, semantic='', joinChar=' ', formatter=None): + def __init__(self, elementDesc, name, label, description, group='allParams', advanced=False, semantic='', enabled=True, joinChar=' ', formatter=None): """ :param elementDesc: the Attribute description of elements to store in that list """ self._elementDesc = elementDesc self._joinChar = joinChar - super(ListAttribute, self).__init__(name=name, label=label, description=description, value=[], uid=(), group=group, advanced=advanced, semantic=semantic, formatter=formatter) + super(ListAttribute, self).__init__(name=name, label=label, description=description, value=[], uid=(), group=group, advanced=advanced, semantic=semantic, enabled=enabled, formatter=formatter) elementDesc = Property(Attribute, lambda self: self._elementDesc, constant=True) uid = Property(Variant, lambda self: self.elementDesc.uid, constant=True) joinChar = Property(str, lambda self: self._joinChar, constant=True) def validateValue(self, value): + if JSValue is not None and isinstance(value, JSValue): + # Note: we could use isArray(), property("length").toInt() to retrieve all values + raise ValueError("ListAttribute.validateValue: cannot recognize QJSValue. Please, use JSON.stringify(value) in QML.") + if isinstance(value, pyCompatibility.basestring): + # Alternative solution to set values from QML is to convert values to JSON string + # In this case, it works with all data types + value = ast.literal_eval(value) + if not isinstance(value, (list, tuple)): raise ValueError('ListAttribute only supports list/tuple input values (param:{}, value:{}, type:{})'.format(self.name, value, type(value))) return value @@ -109,23 +118,36 @@ class ListAttribute(Attribute): class GroupAttribute(Attribute): """ A macro Attribute composed of several Attributes """ - def __init__(self, groupDesc, name, label, description, group='allParams', advanced=False, semantic='', joinChar=' ', formatter=None): + def __init__(self, groupDesc, name, label, description, group='allParams', advanced=False, enabled=True, joinChar=' ', semantic='', formatter=None): """ :param groupDesc: the description of the Attributes composing this group """ self._groupDesc = groupDesc self._joinChar = joinChar - super(GroupAttribute, self).__init__(name=name, label=label, description=description, value={}, uid=(), group=group, advanced=advanced, semantic=semantic, formatter=formatter) + super(GroupAttribute, self).__init__(name=name, label=label, description=description, value={}, uid=(), group=group, advanced=advanced, enabled=enabled, semantic=semantic, formatter=formatter) groupDesc = Property(Variant, lambda self: self._groupDesc, constant=True) def validateValue(self, value): - """ Ensure value is a dictionary with keys compatible with the group description. """ - if not isinstance(value, dict): - raise ValueError('GroupAttribute only supports dict input values (param:{}, value:{}, type:{})'.format(self.name, value, type(value))) - invalidKeys = set(value.keys()).difference([attr.name for attr in self._groupDesc]) - if invalidKeys: - raise ValueError('Value contains key that does not match group description : {}'.format(invalidKeys)) + """ Ensure value is compatible with the group description and convert value if needed. """ + if JSValue is not None and isinstance(value, JSValue): + # Note: we could use isArray(), property("length").toInt() to retrieve all values + raise ValueError("GroupAttribute.validateValue: cannot recognize QJSValue. Please, use JSON.stringify(value) in QML.") + if isinstance(value, pyCompatibility.basestring): + # Alternative solution to set values from QML is to convert values to JSON string + # In this case, it works with all data types + value = ast.literal_eval(value) + + if isinstance(value, dict): + invalidKeys = set(value.keys()).difference([attr.name for attr in self._groupDesc]) + if invalidKeys: + raise ValueError('Value contains key that does not match group description : {}'.format(invalidKeys)) + elif isinstance(value, (list, tuple)): + if len(value) != len(self._groupDesc): + raise ValueError('Value contains incoherent number of values: desc size: {}, value size: {}'.format(len(self._groupDesc), len(value))) + else: + raise ValueError('GroupAttribute only supports dict/list/tuple input values (param:{}, value:{}, type:{})'.format(self.name, value, type(value))) + return value def matchDescription(self, value, conform=False): @@ -185,15 +207,15 @@ class GroupAttribute(Attribute): class Param(Attribute): """ """ - def __init__(self, name, label, description, value, uid, group, advanced, semantic='', formatter=None): - super(Param, self).__init__(name=name, label=label, description=description, value=value, uid=uid, group=group, advanced=advanced, semantic=semantic, formatter=formatter) + def __init__(self, name, label, description, value, uid, group, advanced, enabled, semantic='', formatter=None): + super(Param, self).__init__(name=name, label=label, description=description, value=value, uid=uid, group=group, advanced=advanced, enabled=enabled, semantic=semantic, formatter=formatter) class File(Attribute): """ """ - def __init__(self, name, label, description, value, uid, group='allParams', advanced=False, semantic='', formatter=None): - super(File, self).__init__(name=name, label=label, description=description, value=value, uid=uid, group=group, advanced=advanced, semantic=semantic, formatter=formatter) + def __init__(self, name, label, description, value, uid, group='allParams', advanced=False, enabled=True, semantic='', formatter=None): + super(File, self).__init__(name=name, label=label, description=description, value=value, uid=uid, group=group, advanced=advanced, enabled=True, semantic=semantic, formatter=formatter) def validateValue(self, value): if not isinstance(value, pyCompatibility.basestring): @@ -204,12 +226,12 @@ class File(Attribute): class BoolParam(Param): """ """ - def __init__(self, name, label, description, value, uid, group='allParams', advanced=False, semantic='', formatter=None): - super(BoolParam, self).__init__(name=name, label=label, description=description, value=value, uid=uid, group=group, advanced=advanced, semantic=semantic, formatter=formatter) + def __init__(self, name, label, description, value, uid, group='allParams', advanced=False, enabled=True, semantic='', formatter=None): + super(BoolParam, self).__init__(name=name, label=label, description=description, value=value, uid=uid, group=group, advanced=advanced, enabled=True, semantic=semantic, formatter=formatter) def validateValue(self, value): try: - return bool(int(value)) # int cast is useful to handle string values ('0', '1') + return bool(int(value)) # int cast is useful to handle string values ('0', '1') except: raise ValueError('BoolParam only supports bool value (param:{}, value:{}, type:{})'.format(self.name, value, type(value))) @@ -217,9 +239,9 @@ class BoolParam(Param): class IntParam(Param): """ """ - def __init__(self, name, label, description, value, range, uid, group='allParams', advanced=False, semantic='', formatter=None): + def __init__(self, name, label, description, value, range, uid, group='allParams', advanced=False, enabled=True, semantic='', formatter=None): self._range = range - super(IntParam, self).__init__(name=name, label=label, description=description, value=value, uid=uid, group=group, advanced=advanced, semantic=semantic, formatter=formatter) + super(IntParam, self).__init__(name=name, label=label, description=description, value=value, uid=uid, group=group, advanced=advanced, enabled=enabled, semantic=semantic, formatter=formatter) def validateValue(self, value): # handle unsigned int values that are translated to int by shiboken and may overflow @@ -236,9 +258,9 @@ class IntParam(Param): class FloatParam(Param): """ """ - def __init__(self, name, label, description, value, range, uid, group='allParams', advanced=False, semantic='', formatter=None): + def __init__(self, name, label, description, value, range, uid, group='allParams', advanced=False, enabled=True, semantic='', formatter=None): self._range = range - super(FloatParam, self).__init__(name=name, label=label, description=description, value=value, uid=uid, group=group, advanced=advanced, semantic=semantic, formatter=formatter) + super(FloatParam, self).__init__(name=name, label=label, description=description, value=value, uid=uid, group=group, advanced=advanced, enabled=enabled, semantic=semantic, formatter=formatter) def validateValue(self, value): try: @@ -252,13 +274,13 @@ class FloatParam(Param): class ChoiceParam(Param): """ """ - def __init__(self, name, label, description, value, values, exclusive, uid, group='allParams', joinChar=' ', advanced=False, semantic='', formatter=None): + def __init__(self, name, label, description, value, values, exclusive, uid, group='allParams', joinChar=' ', advanced=False, enabled=True, semantic='', formatter=None): assert values self._values = values self._exclusive = exclusive self._joinChar = joinChar self._valueType = type(self._values[0]) # cast to value type - super(ChoiceParam, self).__init__(name=name, label=label, description=description, value=value, uid=uid, group=group, advanced=advanced, semantic=semantic, formatter=formatter) + super(ChoiceParam, self).__init__(name=name, label=label, description=description, value=value, uid=uid, group=group, advanced=advanced, enabled=enabled, semantic=semantic, formatter=formatter) def conformValue(self, val): """ Conform 'val' to the correct type and check for its validity """ @@ -271,7 +293,7 @@ class ChoiceParam(Param): if self.exclusive: return self.conformValue(value) - if not isinstance(value, collections.Iterable): + if not isinstance(value, pyCompatibility.Iterable): raise ValueError('Non exclusive ChoiceParam value should be iterable (param:{}, value:{}, type:{})'.format(self.name, value, type(value))) return [self.conformValue(v) for v in value] @@ -283,8 +305,8 @@ class ChoiceParam(Param): class StringParam(Param): """ """ - def __init__(self, name, label, description, value, uid, group='allParams', advanced=False, semantic='', formatter=None): - super(StringParam, self).__init__(name=name, label=label, description=description,value=value, uid=uid, group=group, advanced=advanced, semantic=semantic, formatter=formatter) + def __init__(self, name, label, description, value, uid, group='allParams', advanced=False, enabled=True, semantic='', formatter=None): + super(StringParam, self).__init__(name=name, label=label, description=description,value=value, uid=uid, group=group, advanced=advanced, enabled=True, semantic=semantic, formatter=formatter) def validateValue(self, value): if not isinstance(value, pyCompatibility.basestring): @@ -438,6 +460,7 @@ class Node(object): outputs = [] size = StaticNodeSize(1) parallelization = None + documentation = '' def __init__(self): pass diff --git a/meshroom/core/graph.py b/meshroom/core/graph.py index 1b176e8a..27d2e56e 100644 --- a/meshroom/core/graph.py +++ b/meshroom/core/graph.py @@ -85,6 +85,10 @@ class Visitor(object): Base class for Graph Visitors that does nothing. Sub-classes can override any method to implement specific algorithms. """ + def __init__(self, reverse, dependenciesOnly): + super(Visitor, self).__init__() + self.reverse = reverse + self.dependenciesOnly = dependenciesOnly # def initializeVertex(self, s, g): # '''is invoked on every vertex of the graph before the start of the graph search.''' @@ -223,8 +227,11 @@ class Graph(BaseObject): def clear(self): self.header.clear() self._compatibilityNodes.clear() - self._nodes.clear() self._edges.clear() + # Tell QML nodes are going to be deleted + for node in self._nodes: + node.alive = False + self._nodes.clear() @property def fileFeatures(self): @@ -273,12 +280,15 @@ class Graph(BaseObject): # Add node to the graph with raw attributes values self._addNode(n, nodeName) - if setupProjectFile: - # Update filepath related members - self._setFilepath(filepath) + # Create graph edges by resolving attributes expressions + self._applyExpr() - # Create graph edges by resolving attributes expressions - self._applyExpr() + if setupProjectFile: + # Update filepath related members + # Note: needs to be done at the end as it will trigger an updateInternals. + self._setFilepath(filepath) + + return True @property def updateEnabled(self): @@ -374,7 +384,7 @@ class Graph(BaseObject): Returns: OrderedDict[Node, Node]: the source->duplicate map """ - srcNodes, srcEdges = self.nodesFromNode(fromNode) + srcNodes, srcEdges = self.dfsOnDiscover(startNodes=[fromNode], reverse=True, dependenciesOnly=True) # use OrderedDict to keep duplicated nodes creation order duplicates = OrderedDict() @@ -434,6 +444,7 @@ class Graph(BaseObject): self.removeEdge(edge.dst) inEdges[edge.dst.getFullName()] = edge.src.getFullName() + node.alive = False self._nodes.remove(node) self.update() @@ -558,7 +569,10 @@ class Graph(BaseObject): candidates = self.findNodeCandidates('^' + nodeExpr) if not candidates: raise KeyError('No node candidate for "{}"'.format(nodeExpr)) - elif len(candidates) > 1: + if len(candidates) > 1: + for c in candidates: + if c.name == nodeExpr: + return c raise KeyError('Multiple node candidates for "{}": {}'.format(nodeExpr, str([c.name for c in candidates]))) return candidates[0] @@ -568,9 +582,13 @@ class Graph(BaseObject): def edge(self, dstAttributeName): return self._edges.get(dstAttributeName) - def getLeaves(self): - nodesWithOutput = set([edge.src.node for edge in self.edges]) - return set(self._nodes) - nodesWithOutput + def getLeafNodes(self, dependenciesOnly): + nodesWithOutputLink = set([edge.src.node for edge in self.getEdges(dependenciesOnly)]) + return set(self._nodes) - nodesWithOutputLink + + def getRootNodes(self, dependenciesOnly): + nodesWithInputLink = set([edge.dst.node for edge in self.getEdges(dependenciesOnly)]) + return set(self._nodes) - nodesWithInputLink @changeTopology def addEdge(self, srcAttr, dstAttr): @@ -585,6 +603,7 @@ class Graph(BaseObject): self.markNodesDirty(dstAttr.node) dstAttr.valueChanged.emit() dstAttr.isLinkChanged.emit() + srcAttr.hasOutputConnectionsChanged.emit() return edge def addEdges(self, *edges): @@ -596,10 +615,11 @@ class Graph(BaseObject): def removeEdge(self, dstAttr): if dstAttr not in self.edges.keys(): raise RuntimeError('Attribute "{}" is not connected'.format(dstAttr.getFullName())) - self.edges.pop(dstAttr) + edge = self.edges.pop(dstAttr) self.markNodesDirty(dstAttr.node) dstAttr.valueChanged.emit() dstAttr.isLinkChanged.emit() + edge.src.hasOutputConnectionsChanged.emit() def getDepth(self, node, minimal=False): """ Return node's depth in this Graph. @@ -616,35 +636,40 @@ class Graph(BaseObject): minDepth, maxDepth = self._nodesMinMaxDepths[node] return minDepth if minimal else maxDepth - def getInputEdges(self, node): - return set([edge for edge in self.edges if edge.dst.node is node]) + def getInputEdges(self, node, dependenciesOnly): + return set([edge for edge in self.getEdges(dependenciesOnly=dependenciesOnly) if edge.dst.node is node]) - def _getInputEdgesPerNode(self): + def _getInputEdgesPerNode(self, dependenciesOnly): nodeEdges = defaultdict(set) - for edge in self.edges: + for edge in self.getEdges(dependenciesOnly=dependenciesOnly): nodeEdges[edge.dst.node].add(edge.src.node) return nodeEdges - def _getOutputEdgesPerNode(self): + def _getOutputEdgesPerNode(self, dependenciesOnly): nodeEdges = defaultdict(set) - for edge in self.edges: + for edge in self.getEdges(dependenciesOnly=dependenciesOnly): nodeEdges[edge.src.node].add(edge.dst.node) return nodeEdges - def dfs(self, visitor, startNodes=None, longestPathFirst=False, reverse=False): - # Default direction: from node to root - # Reverse direction: from node to leaves - nodeChildren = self._getOutputEdgesPerNode() if reverse else self._getInputEdgesPerNode() + def dfs(self, visitor, startNodes=None, longestPathFirst=False): + # Default direction (visitor.reverse=False): from node to root + # Reverse direction (visitor.reverse=True): from node to leaves + nodeChildren = self._getOutputEdgesPerNode(visitor.dependenciesOnly) if visitor.reverse else self._getInputEdgesPerNode(visitor.dependenciesOnly) # Initialize color map colors = {} for u in self._nodes: colors[u] = WHITE - nodes = startNodes or self.getLeaves() + if longestPathFirst and visitor.reverse: + # Because we have no knowledge of the node's count between a node and its leaves, + # it is not possible to handle this case at the moment + raise NotImplementedError("Graph.dfs(): longestPathFirst=True and visitor.reverse=True are not compatible yet.") + + nodes = startNodes or (self.getRootNodes(visitor.dependenciesOnly) if visitor.reverse else self.getLeafNodes(visitor.dependenciesOnly)) if longestPathFirst: # Graph topology must be known and node depths up-to-date @@ -678,26 +703,66 @@ class Graph(BaseObject): # (u,v) is a tree edge self.dfsVisit(v, visitor, colors, nodeChildren, longestPathFirst) # TODO: avoid recursion elif colors[v] == GRAY: + # (u,v) is a back edge visitor.backEdge((u, v), self) - pass # (u,v) is a back edge elif colors[v] == BLACK: + # (u,v) is a cross or forward edge visitor.forwardOrCrossEdge((u, v), self) - pass # (u,v) is a cross or forward edge visitor.finishEdge((u, v), self) colors[u] = BLACK visitor.finishVertex(u, self) - def dfsOnFinish(self, startNodes=None): + def dfsOnFinish(self, startNodes=None, longestPathFirst=False, reverse=False, dependenciesOnly=False): """ - :param startNodes: list of starting nodes. Use all leaves if empty. - :return: visited nodes and edges. The order is defined by the visit and finishVertex event. + Return the node chain from startNodes to the graph roots/leaves. + Order is defined by the visit and finishVertex event. + + Args: + startNodes (Node list): the nodes to start the visit from. + longestPathFirst (bool): (optional) if multiple paths, nodes belonging to + the longest one will be visited first. + reverse (bool): (optional) direction of visit. + True is for getting nodes depending on the startNodes (to leaves). + False is for getting nodes required for the startNodes (to roots). + Returns: + The list of nodes and edges, from startNodes to the graph roots/leaves following edges. """ nodes = [] edges = [] - visitor = Visitor() + visitor = Visitor(reverse=reverse, dependenciesOnly=dependenciesOnly) visitor.finishVertex = lambda vertex, graph: nodes.append(vertex) visitor.finishEdge = lambda edge, graph: edges.append(edge) - self.dfs(visitor=visitor, startNodes=startNodes) + self.dfs(visitor=visitor, startNodes=startNodes, longestPathFirst=longestPathFirst) + return nodes, edges + + def dfsOnDiscover(self, startNodes=None, filterTypes=None, longestPathFirst=False, reverse=False, dependenciesOnly=False): + """ + Return the node chain from startNodes to the graph roots/leaves. + Order is defined by the visit and discoverVertex event. + + Args: + startNodes (Node list): the nodes to start the visit from. + filterTypes (str list): (optional) only return the nodes of the given types + (does not stop the visit, this is a post-process only) + longestPathFirst (bool): (optional) if multiple paths, nodes belonging to + the longest one will be visited first. + reverse (bool): (optional) direction of visit. + True is for getting nodes depending on the startNodes (to leaves). + False is for getting nodes required for the startNodes (to roots). + Returns: + The list of nodes and edges, from startNodes to the graph roots/leaves following edges. + """ + nodes = [] + edges = [] + visitor = Visitor(reverse=reverse, dependenciesOnly=dependenciesOnly) + + def discoverVertex(vertex, graph): + if not filterTypes or vertex.nodeType in filterTypes: + nodes.append(vertex) + + visitor.discoverVertex = discoverVertex + visitor.examineEdge = lambda edge, graph: edges.append(edge) + self.dfs(visitor=visitor, startNodes=startNodes, longestPathFirst=longestPathFirst) return nodes, edges def dfsToProcess(self, startNodes=None): @@ -713,22 +778,16 @@ class Graph(BaseObject): """ nodes = [] edges = [] - visitor = Visitor() + visitor = Visitor(reverse=False, dependenciesOnly=True) def discoverVertex(vertex, graph): if vertex.hasStatus(Status.SUCCESS): # stop branch visit if discovering a node already computed raise StopBranchVisit() - if self._computationBlocked[vertex]: - raise RuntimeError("Can't compute node '{}'".format(vertex.name)) def finishVertex(vertex, graph): chunksToProcess = [] for chunk in vertex.chunks: - if chunk.status.status is Status.SUBMITTED: - logging.warning('Node "{}" is already submitted.'.format(chunk.name)) - if chunk.status.status is Status.RUNNING: - logging.warning('Node "{}" is already running.'.format(chunk.name)) if chunk.status.status is not Status.SUCCESS: chunksToProcess.append(chunk) if chunksToProcess: @@ -737,8 +796,7 @@ class Graph(BaseObject): def finishEdge(edge, graph): if edge[0].hasStatus(Status.SUCCESS) or edge[1].hasStatus(Status.SUCCESS): return - else: - edges.append(edge) + edges.append(edge) visitor.finishVertex = finishVertex visitor.finishEdge = finishEdge @@ -775,7 +833,7 @@ class Graph(BaseObject): self._computationBlocked.clear() compatNodes = [] - visitor = Visitor() + visitor = Visitor(reverse=False, dependenciesOnly=True) def discoverVertex(vertex, graph): # initialize depths @@ -809,7 +867,7 @@ class Graph(BaseObject): # propagate inputVertex computability self._computationBlocked[currentVertex] |= self._computationBlocked[inputVertex] - leaves = self.getLeaves() + leaves = self.getLeafNodes(visitor.dependenciesOnly) visitor.finishEdge = finishEdge visitor.discoverVertex = discoverVertex self.dfs(visitor=visitor, startNodes=leaves) @@ -833,7 +891,7 @@ class Graph(BaseObject): """ nodesStack = [] edgesScore = defaultdict(lambda: 0) - visitor = Visitor() + visitor = Visitor(reverse=False, dependenciesOnly=False) def finishEdge(edge, graph): u, v = edge @@ -869,29 +927,69 @@ class Graph(BaseObject): flowEdges.append(link) return flowEdges - def nodesFromNode(self, startNode, filterType=None): - """ - Return the node chain from startNode to the graph leaves. + def getEdges(self, dependenciesOnly=False): + if not dependenciesOnly: + return self.edges + + outEdges = [] + for e in self.edges: + attr = e.src + if dependenciesOnly: + if attr.isLink: + attr = attr.getLinkParam(recursive=True) + if not attr.isOutput: + continue + newE = Edge(attr, e.dst) + outEdges.append(newE) + return outEdges + + def getInputNodes(self, node, recursive, dependenciesOnly): + """ Return either the first level input nodes of a node or the whole chain. """ + if not recursive: + return set([edge.src.node for edge in self.getEdges(dependenciesOnly) if edge.dst.node is node]) + + inputNodes, edges = self.dfsOnDiscover(startNodes=[node], filterTypes=None, reverse=False) + return inputNodes[1:] # exclude current node + + def getOutputNodes(self, node, recursive, dependenciesOnly): + """ Return either the first level output nodes of a node or the whole chain. """ + if not recursive: + return set([edge.dst.node for edge in self.getEdges(dependenciesOnly) if edge.src.node is node]) + + outputNodes, edges = self.dfsOnDiscover(startNodes=[node], filterTypes=None, reverse=True) + return outputNodes[1:] # exclude current node + + @Slot(Node, result=int) + def canSubmitOrCompute(self, startNode): + """ + Check if a node can be submitted/computed. - Args: - startNode (Node): the node to start the visit from. - filterType (str): (optional) only return the nodes of the given type - (does not stop the visit, this is a post-process only) Returns: - The list of nodes from startNode to the graph leaves following edges. + int: 0 = cannot be submitted or computed / + 1 = can be computed / + 2 = can be submitted / + 3 = can be submitted and computed """ - nodes = [] - edges = [] - visitor = Visitor() + if startNode.isAlreadySubmittedOrFinished(): + return 0 - def discoverVertex(vertex, graph): - if not filterType or vertex.nodeType == filterType: - nodes.append(vertex) + class SCVisitor(Visitor): + def __init__(self, reverse, dependenciesOnly): + super(SCVisitor, self).__init__(reverse, dependenciesOnly) + + canCompute = True + canSubmit = True + + def discoverVertex(self, vertex, graph): + if vertex.isAlreadySubmitted(): + self.canSubmit = False + if vertex.isExtern(): + self.canCompute = False + + visitor = SCVisitor(reverse=False, dependenciesOnly=True) + self.dfs(visitor=visitor, startNodes=[startNode]) + return visitor.canCompute + (2 * visitor.canSubmit) - visitor.discoverVertex = discoverVertex - visitor.examineEdge = lambda edge, graph: edges.append(edge) - self.dfs(visitor=visitor, startNodes=[startNode], reverse=True) - return nodes, edges def _applyExpr(self): with GraphModification(self): @@ -974,6 +1072,24 @@ class Graph(BaseObject): for node in self._nodes: node.updateStatisticsFromCache() + def updateNodesPerUid(self): + """ Update the duplicate nodes (sharing same uid) list of each node. """ + # First step is to construct a map uid/nodes + nodesPerUid = {} + for node in self.nodes: + uid = node._uids.get(0) + + # We try to add the node to the list corresponding to this uid + try: + nodesPerUid.get(uid).append(node) + # If it fails because the uid is not in the map, we add it + except AttributeError: + nodesPerUid.update({uid: [node]}) + + # Now, update each individual node + for node in self.nodes: + node.updateDuplicates(nodesPerUid) + def update(self): if not self._updateEnabled: # To do the update once for multiple changes @@ -986,6 +1102,8 @@ class Graph(BaseObject): for node in self.nodes: node.dirty = False + self.updateNodesPerUid() + # Graph topology has changed if self.dirtyTopology: # update nodes topological data cache @@ -996,7 +1114,7 @@ class Graph(BaseObject): def markNodesDirty(self, fromNode): """ - Mark all nodes following 'fromNode' as dirty, and request a graph update. + Mark all nodes following 'fromNode' as dirty. All nodes marked as dirty will get their outputs to be re-evaluated during the next graph update. @@ -1006,15 +1124,21 @@ class Graph(BaseObject): See Also: Graph.update, Graph.updateInternals, Graph.updateStatusFromCache """ - nodes, edges = self.nodesFromNode(fromNode) + nodes, edges = self.dfsOnDiscover(startNodes=[fromNode], reverse=True) for node in nodes: node.dirty = True - self.update() def stopExecution(self): """ Request graph execution to be stopped by terminating running chunks""" for chunk in self.iterChunksByStatus(Status.RUNNING): - chunk.stopProcess() + if not chunk.isExtern(): + chunk.stopProcess() + + @Slot() + def forceUnlockNodes(self): + """ Force to unlock all the nodes. """ + for node in self.nodes: + node.setLocked(False) @Slot() def clearSubmittedNodes(self): @@ -1024,7 +1148,7 @@ class Graph(BaseObject): @Slot(Node) def clearDataFrom(self, startNode): - for node in self.nodesFromNode(startNode)[0]: + for node in self.dfsOnDiscover(startNodes=[startNode], reverse=True, dependenciesOnly=True)[0]: node.clearData() def iterChunksByStatus(self, status): @@ -1098,99 +1222,3 @@ def loadGraph(filepath): graph.update() return graph - -def getAlreadySubmittedChunks(nodes): - out = [] - for node in nodes: - for chunk in node.chunks: - if chunk.isAlreadySubmitted(): - out.append(chunk) - return out - - -def executeGraph(graph, toNodes=None, forceCompute=False, forceStatus=False): - """ - """ - if forceCompute: - nodes, edges = graph.dfsOnFinish(startNodes=toNodes) - else: - nodes, edges = graph.dfsToProcess(startNodes=toNodes) - chunksInConflict = getAlreadySubmittedChunks(nodes) - - if chunksInConflict: - chunksStatus = set([chunk.status.status.name for chunk in chunksInConflict]) - chunksName = [node.name for node in chunksInConflict] - msg = 'WARNING: Some nodes are already submitted with status: {}\nNodes: {}'.format( - ', '.join(chunksStatus), - ', '.join(chunksName) - ) - if forceStatus: - print(msg) - else: - raise RuntimeError(msg) - - print('Nodes to execute: ', str([n.name for n in nodes])) - - for node in nodes: - node.beginSequence(forceCompute) - - for n, node in enumerate(nodes): - try: - multiChunks = len(node.chunks) > 1 - for c, chunk in enumerate(node.chunks): - if multiChunks: - print('\n[{node}/{nbNodes}]({chunk}/{nbChunks}) {nodeName}'.format( - node=n+1, nbNodes=len(nodes), - chunk=c+1, nbChunks=len(node.chunks), nodeName=node.nodeType)) - else: - print('\n[{node}/{nbNodes}] {nodeName}'.format( - node=n + 1, nbNodes=len(nodes), nodeName=node.nodeType)) - chunk.process(forceCompute) - except Exception as e: - logging.error("Error on node computation: {}".format(e)) - graph.clearSubmittedNodes() - raise - - for node in nodes: - node.endSequence() - - -def submitGraph(graph, submitter, toNodes=None): - nodesToProcess, edgesToProcess = graph.dfsToProcess(startNodes=toNodes) - flowEdges = graph.flowEdges(startNodes=toNodes) - edgesToProcess = set(edgesToProcess).intersection(flowEdges) - - if not nodesToProcess: - logging.warning('Nothing to compute') - return - - logging.info("Nodes to process: {}".format(edgesToProcess)) - logging.info("Edges to process: {}".format(edgesToProcess)) - - sub = None - if submitter: - sub = meshroom.core.submitters.get(submitter, None) - elif len(meshroom.core.submitters) == 1: - # if only one submitter available use it - sub = meshroom.core.submitters.values()[0] - if sub is None: - raise RuntimeError("Unknown Submitter: '{submitter}'. Available submitters are: '{allSubmitters}'.".format( - submitter=submitter, allSubmitters=str(meshroom.core.submitters.keys()))) - - try: - res = sub.submit(nodesToProcess, edgesToProcess, graph.filepath) - if res: - for node in nodesToProcess: - node.submit() # update node status - except Exception as e: - logging.error("Error on submit : {}".format(e)) - - -def submit(graphFile, submitter, toNode=None): - """ - Submit the given graph via the given submitter. - """ - graph = loadGraph(graphFile) - toNodes = graph.findNodes([toNode]) if toNode else None - submitGraph(graph, submitter, toNodes) - diff --git a/meshroom/core/node.py b/meshroom/core/node.py index 2d520586..00646e6c 100644 --- a/meshroom/core/node.py +++ b/meshroom/core/node.py @@ -10,6 +10,7 @@ import platform import re import shutil import time +import types import uuid from collections import defaultdict, namedtuple from enum import Enum @@ -205,12 +206,18 @@ class LogManager: self.progressBar = False def textToLevel(self, text): - if text == 'critical': return logging.CRITICAL - elif text == 'error': return logging.ERROR - elif text == 'warning': return logging.WARNING - elif text == 'info': return logging.INFO - elif text == 'debug': return logging.DEBUG - else: return logging.NOTSET + if text == 'critical': + return logging.CRITICAL + elif text == 'error': + return logging.ERROR + elif text == 'warning': + return logging.WARNING + elif text == 'info': + return logging.INFO + elif text == 'debug': + return logging.DEBUG + else: + return logging.NOTSET runningProcesses = {} @@ -236,6 +243,8 @@ class NodeChunk(BaseObject): # notify update in filepaths when node's internal folder changes self.node.internalFolderChanged.connect(self.nodeFolderChanged) + self.execModeNameChanged.connect(self.node.globalExecModeChanged) + @property def index(self): return self.range.iteration @@ -316,6 +325,9 @@ class NodeChunk(BaseObject): if newStatus.value <= self.status.status.value: print('WARNING: downgrade status on node "{}" from {} to {}'.format(self.name, self.status.status, newStatus)) + + if newStatus == Status.SUBMITTED: + self.status = StatusData(self.node.name, self.node.nodeType, self.node.packageName, self.node.packageVersion) if execMode is not None: self.status.execMode = execMode self.execModeNameChanged.emit() @@ -350,6 +362,15 @@ class NodeChunk(BaseObject): def isAlreadySubmitted(self): return self.status.status in (Status.SUBMITTED, Status.RUNNING) + def isAlreadySubmittedOrFinished(self): + return self.status.status in (Status.SUBMITTED, Status.RUNNING, Status.SUCCESS) + + def isFinishedOrRunning(self): + return self.status.status in (Status.SUCCESS, Status.RUNNING) + + def isStopped(self): + return self.status.status == Status.STOPPED + def process(self, forceCompute=False): if not forceCompute and self.status.status == Status.SUCCESS: print("Node chunk already computed:", self.name) @@ -364,7 +385,8 @@ class NodeChunk(BaseObject): try: self.node.nodeDesc.processChunk(self) except Exception as e: - self.upgradeStatusTo(Status.ERROR) + if self.status.status != Status.STOPPED: + self.upgradeStatusTo(Status.ERROR) raise except (KeyboardInterrupt, SystemError, GeneratorExit) as e: self.upgradeStatusTo(Status.STOPPED) @@ -382,8 +404,12 @@ class NodeChunk(BaseObject): self.upgradeStatusTo(Status.SUCCESS) def stopProcess(self): + self.upgradeStatusTo(Status.STOPPED) self.node.nodeDesc.stopProcess(self) + def isExtern(self): + return self.status.execMode == ExecMode.EXTERN + statusChanged = Signal() statusName = Property(str, statusName.fget, notify=statusChanged) execModeNameChanged = Signal() @@ -395,6 +421,9 @@ class NodeChunk(BaseObject): logFile = Property(str, logFile.fget, notify=nodeFolderChanged) statisticsFile = Property(str, statisticsFile.fget, notify=nodeFolderChanged) + nodeName = Property(str, lambda self: self.node.name, constant=True) + statusNodeName = Property(str, lambda self: self.status.nodeName, constant=True) + # simple structure for storing node position Position = namedtuple("Position", ["x", "y"]) @@ -442,6 +471,12 @@ class BaseNode(BaseObject): self._position = position or Position() self._attributes = DictModel(keyAttrName='name', parent=self) self.attributesPerUid = defaultdict(set) + self._alive = True # for QML side to know if the node can be used or is going to be deleted + self._locked = False + self._duplicates = ListModel(parent=self) # list of nodes with the same uid + self._hasDuplicates = False + + self.globalStatusChanged.connect(self.updateDuplicatesStatusAndLocked) def __getattr__(self, k): try: @@ -462,9 +497,20 @@ class BaseNode(BaseObject): Returns: str: the high-level label of this node """ - t, idx = self._name.split("_") + return self.nameToLabel(self._name) + + @Slot(str, result=str) + def nameToLabel(self, name): + """ + Returns: + str: the high-level label from the technical node name + """ + t, idx = name.split("_") return "{}{}".format(t, idx if int(idx) > 1 else "") + def getDocumentation(self): + return self.nodeDesc.documentation + @property def packageFullName(self): return '-'.join([self.packageName, self.packageVersion]) @@ -495,6 +541,10 @@ class BaseNode(BaseObject): def getAttributes(self): return self._attributes + @Slot(str, result=bool) + def hasAttribute(self, name): + return name in self._attributes.keys() + def _applyExpr(self): for attr in self._attributes: attr._applyExpr() @@ -520,6 +570,17 @@ class BaseNode(BaseObject): self._position = value self.positionChanged.emit() + @property + def alive(self): + return self._alive + + @alive.setter + def alive(self, value): + if self._alive == value: + return + self._alive = value + self.aliveChanged.emit() + @property def depth(self): return self.graph.getDepth(self) @@ -528,6 +589,12 @@ class BaseNode(BaseObject): def minDepth(self): return self.graph.getDepth(self, minimal=True) + def getInputNodes(self, recursive, dependenciesOnly): + return self.graph.getInputNodes(self, recursive=recursive, dependenciesOnly=dependenciesOnly) + + def getOutputNodes(self, recursive, dependenciesOnly): + return self.graph.getOutputNodes(self, recursive=recursive, dependenciesOnly=dependenciesOnly) + def toDict(self): pass @@ -535,28 +602,38 @@ class BaseNode(BaseObject): """ Compute node uids by combining associated attributes' uids. """ for uidIndex, associatedAttributes in self.attributesPerUid.items(): # uid is computed by hashing the sorted list of tuple (name, value) of all attributes impacting this uid - uidAttributes = [(a.getName(), a.uid(uidIndex)) for a in associatedAttributes] + uidAttributes = [(a.getName(), a.uid(uidIndex)) for a in associatedAttributes if a.enabled] uidAttributes.sort() self._uids[uidIndex] = hashValue(uidAttributes) def _buildCmdVars(self): + def _buildAttributeCmdVars(cmdVars, name, attr): + if attr.enabled: + group = attr.attributeDesc.group(attr.node) if isinstance(attr.attributeDesc.group, types.FunctionType) else attr.attributeDesc.group + if group is not None: + # if there is a valid command line "group" + v = attr.getValueStr() + cmdVars[name] = '--{name} {value}'.format(name=name, value=v) + cmdVars[name + 'Value'] = str(v) + + if v: + cmdVars[group] = cmdVars.get(group, '') + ' ' + cmdVars[name] + elif isinstance(attr, GroupAttribute): + assert isinstance(attr.value, DictModel) + # if the GroupAttribute is not set in a single command line argument, + # the sub-attributes may need to be exposed individually + for v in attr._value: + _buildAttributeCmdVars(cmdVars, v.name, v) + """ Generate command variables using input attributes and resolved output attributes names and values. """ for uidIndex, value in self._uids.items(): self._cmdVars['uid{}'.format(uidIndex)] = value - def populate(cmdVars, group, name, value): - cmdVars[name] = '--{name} {value}'.format(name=name, value=value) - cmdVars[name + 'Value'] = str(v) - if v: - cmdVars[group] = cmdVars.get(group, '') + ' ' + cmdVars[name] - # Evaluate input params - for _, attr in self._attributes.objects.items(): + for name, attr in self._attributes.objects.items(): if attr.isOutput: continue # skip outputs - group_name_values = attr.format() - for group, name, v in group_name_values: - populate(self._cmdVars, group, name, v) + _buildAttributeCmdVars(self._cmdVars, name, attr) # For updating output attributes invalidation values cmdVarsNoCache = self._cmdVars.copy() @@ -571,11 +648,22 @@ class BaseNode(BaseObject): if not isinstance(attr.attributeDesc, desc.File): continue - attr.value = attr.attributeDesc.value.format(**self._cmdVars) - attr._invalidationValue = attr.attributeDesc.value.format(**cmdVarsNoCache) - group_name_values = attr.format() - for group, name, v in group_name_values: - populate(self._cmdVars, group, name, v) + defaultValue = attr.defaultValue() + try: + attr.value = defaultValue.format(**self._cmdVars) + attr._invalidationValue = defaultValue.format(**cmdVarsNoCache) + except KeyError as e: + logging.warning('Invalid expression with missing key on "{nodeName}.{attrName}" with value "{defaultValue}".\nError: {err}'.format(nodeName=self.name, attrName=attr.name, defaultValue=defaultValue, err=str(e))) + except ValueError as e: + logging.warning('Invalid expression value on "{nodeName}.{attrName}" with value "{defaultValue}".\nError: {err}'.format(nodeName=self.name, attrName=attr.name, defaultValue=defaultValue, err=str(e))) + v = attr.getValueStr() + + self._cmdVars[name] = '--{name} {value}'.format(name=name, value=v) + self._cmdVars[name + 'Value'] = str(v) + + if v: + self._cmdVars[attr.attributeDesc.group] = self._cmdVars.get(attr.attributeDesc.group, '') + \ + ' ' + self._cmdVars[name] @property def isParallelized(self): @@ -593,8 +681,7 @@ class BaseNode(BaseObject): return False return True - @Slot(result=bool) - def isComputed(self): + def _isComputed(self): return self.hasStatus(Status.SUCCESS) @Slot() @@ -612,9 +699,22 @@ class BaseNode(BaseObject): return True return False + def isAlreadySubmittedOrFinished(self): + for chunk in self._chunks: + if not chunk.isAlreadySubmittedOrFinished(): + return False + return True + + def isFinishedOrRunning(self): + """ Return True if all chunks of this Node is either finished or running, False otherwise. """ + return all(chunk.isFinishedOrRunning() for chunk in self._chunks) + def alreadySubmittedChunks(self): return [ch for ch in self._chunks if ch.isAlreadySubmitted()] + def isExtern(self): + return self._chunks.at(0).isExtern() + @Slot() def clearSubmittedChunks(self): """ Reset all submitted chunks to Status.NONE. This method should be used to clear inconsistent status @@ -625,7 +725,8 @@ class BaseNode(BaseObject): if the graph is still being computed. """ for chunk in self.alreadySubmittedChunks(): - chunk.upgradeStatusTo(Status.NONE, ExecMode.NONE) + if not chunk.isExtern(): + chunk.upgradeStatusTo(Status.NONE, ExecMode.NONE) def upgradeStatusTo(self, newStatus): """ @@ -653,6 +754,10 @@ class BaseNode(BaseObject): """ if self.nodeDesc: self.nodeDesc.update(self) + + for attr in self._attributes: + attr.updateInternals() + # Update chunks splitting self._updateChunks() # Retrieve current internal folder (if possible) @@ -691,7 +796,7 @@ class BaseNode(BaseObject): def beginSequence(self, forceCompute=False): for chunk in self._chunks: - if forceCompute or chunk.status.status != Status.SUCCESS: + if forceCompute or (chunk.status.status not in (Status.RUNNING, Status.SUCCESS)): chunk.upgradeStatusTo(Status.SUBMITTED, ExecMode.LOCAL) def processIteration(self, iteration): @@ -704,6 +809,12 @@ class BaseNode(BaseObject): def endSequence(self): pass + def stopComputation(self): + """ Stop the computation of this node. """ + for chunk in self._chunks.values(): + if not chunk.isExtern(): + chunk.stopProcess() + def getGlobalStatus(self): """ Get node global status based on the status of its chunks. @@ -726,6 +837,10 @@ class BaseNode(BaseObject): return Status.NONE + @property + def globalExecMode(self): + return self._chunks.at(0).execModeName + def getChunks(self): return self._chunks @@ -741,9 +856,134 @@ class BaseNode(BaseObject): def __repr__(self): return self.name + def getLocked(self): + return self._locked + + def setLocked(self, lock): + if self._locked == lock: + return + self._locked = lock + self.lockedChanged.emit() + + @Slot() + def updateDuplicatesStatusAndLocked(self): + """ Update status of duplicate nodes without any latency and update locked. """ + if self.name == self._chunks.at(0).statusNodeName: + for node in self._duplicates: + node.updateStatusFromCache() + + self.updateLocked() + + def updateLocked(self): + currentStatus = self.getGlobalStatus() + + lockedStatus = (Status.RUNNING, Status.SUBMITTED) + + # Unlock required nodes if the current node changes to Error, Stopped or None + # Warning: we must handle some specific cases for global start/stop + if self._locked and currentStatus in (Status.ERROR, Status.STOPPED, Status.NONE): + self.setLocked(False) + inputNodes = self.getInputNodes(recursive=True, dependenciesOnly=True) + + for node in inputNodes: + if node.getGlobalStatus() == Status.RUNNING: + # Return without unlocking if at least one input node is running + # Example: using Cancel Computation on a submitted node + return + for node in inputNodes: + node.setLocked(False) + return + + # Avoid useless travel through nodes + # For instance: when loading a scene with successful nodes + if not self._locked and currentStatus == Status.SUCCESS: + return + + if currentStatus == Status.SUCCESS: + # At this moment, the node is necessarily locked because of previous if statement + inputNodes = self.getInputNodes(recursive=True, dependenciesOnly=True) + outputNodes = self.getOutputNodes(recursive=True, dependenciesOnly=True) + stayLocked = None + + # Check if at least one dependentNode is submitted or currently running + for node in outputNodes: + if node.getGlobalStatus() in lockedStatus and node._chunks.at(0).statusNodeName == node.name: + stayLocked = True + break + if not stayLocked: + self.setLocked(False) + # Unlock every input node + for node in inputNodes: + node.setLocked(False) + return + elif currentStatus in lockedStatus and self._chunks.at(0).statusNodeName == self.name: + self.setLocked(True) + inputNodes = self.getInputNodes(recursive=True, dependenciesOnly=True) + for node in inputNodes: + node.setLocked(True) + return + + self.setLocked(False) + + def updateDuplicates(self, nodesPerUid): + """ Update the list of duplicate nodes (sharing the same uid). """ + uid = self._uids.get(0) + if not nodesPerUid or not uid: + if len(self._duplicates) > 0: + self._duplicates.clear() + self._hasDuplicates = False + self.hasDuplicatesChanged.emit() + return + + newList = [node for node in nodesPerUid.get(uid) if node != self] + + # If number of elements in both lists are identical, + # we must check if their content is the same + if len(newList) == len(self._duplicates): + newListName = set([node.name for node in newList]) + oldListName = set([node.name for node in self._duplicates.values()]) + + # If strict equality between both sets, + # there is no need to set the new list + if newListName == oldListName: + return + + # Set the newList + self._duplicates.setObjectList(newList) + # Emit a specific signal 'hasDuplicates' to avoid extra binding + # re-evaluation when the number of duplicates has changed + if bool(len(newList)) != self._hasDuplicates: + self._hasDuplicates = bool(len(newList)) + self.hasDuplicatesChanged.emit() + + + def statusInThisSession(self): + if not self._chunks: + return False + for chunk in self._chunks: + if chunk.status.sessionUid != meshroom.core.sessionUid: + return False + return True + + @Slot(result=bool) + def canBeStopped(self): + # Only locked nodes running in local with the same + # sessionUid as the Meshroom instance can be stopped + return (self.locked and self.getGlobalStatus() == Status.RUNNING and + self.globalExecMode == "LOCAL" and self.statusInThisSession()) + + @Slot(result=bool) + def canBeCanceled(self): + # Only locked nodes submitted in local with the same + # sessionUid as the Meshroom instance can be canceled + return (self.locked and self.getGlobalStatus() == Status.SUBMITTED and + self.globalExecMode == "LOCAL" and self.statusInThisSession()) + + name = Property(str, getName, constant=True) label = Property(str, getLabel, constant=True) nodeType = Property(str, nodeType.fget, constant=True) + documentation = Property(str, getDocumentation, constant=True) positionChanged = Signal() position = Property(Variant, position.fget, position.fset, notify=positionChanged) x = Property(float, lambda self: self._position.x, notify=positionChanged) @@ -760,6 +1000,16 @@ class BaseNode(BaseObject): size = Property(int, getSize, notify=sizeChanged) globalStatusChanged = Signal() globalStatus = Property(str, lambda self: self.getGlobalStatus().name, notify=globalStatusChanged) + globalExecModeChanged = Signal() + globalExecMode = Property(str, globalExecMode.fget, notify=globalExecModeChanged) + isComputed = Property(bool, _isComputed, notify=globalStatusChanged) + aliveChanged = Signal() + alive = Property(bool, alive.fget, alive.fset, notify=aliveChanged) + lockedChanged = Signal() + locked = Property(bool, getLocked, setLocked, notify=lockedChanged) + duplicates = Property(Variant, lambda self: self._duplicates, constant=True) + hasDuplicatesChanged = Signal() + hasDuplicates = Property(bool, lambda self: self._hasDuplicates, notify=hasDuplicatesChanged) class Node(BaseNode): diff --git a/meshroom/core/pyCompatibility.py b/meshroom/core/pyCompatibility.py index b2483ee7..50e90271 100644 --- a/meshroom/core/pyCompatibility.py +++ b/meshroom/core/pyCompatibility.py @@ -13,3 +13,10 @@ else: unicode = unicode bytes = str basestring = basestring + +try: + # Import ABC from collections.abc in Python 3.4+ + from collections.abc import Sequence, Iterable +except ImportError: + # Import ABC from collections in Python 2 support + from collections import Sequence, Iterable diff --git a/meshroom/core/stats.py b/meshroom/core/stats.py index ec07dbef..784319cd 100644 --- a/meshroom/core/stats.py +++ b/meshroom/core/stats.py @@ -44,7 +44,7 @@ class ComputerStatistics: self.gpuMemoryTotal = 0 self.gpuName = '' self.curves = defaultdict(list) - + self.nvidia_smi = None self._isInit = False def initOnFirstTime(self): @@ -53,40 +53,21 @@ class ComputerStatistics: self._isInit = True self.cpuFreq = psutil.cpu_freq().max - self.ramTotal = psutil.virtual_memory().total / 1024/1024/1024 + self.ramTotal = psutil.virtual_memory().total / (1024*1024*1024) if platform.system() == "Windows": from distutils import spawn # If the platform is Windows and nvidia-smi - # could not be found from the environment path, - # try to find it from system drive with default installation path self.nvidia_smi = spawn.find_executable('nvidia-smi') if self.nvidia_smi is None: - self.nvidia_smi = "%s\\Program Files\\NVIDIA Corporation\\NVSMI\\nvidia-smi.exe" % os.environ['systemdrive'] + # could not be found from the environment path, + # try to find it from system drive with default installation path + default_nvidia_smi = "%s\\Program Files\\NVIDIA Corporation\\NVSMI\\nvidia-smi.exe" % os.environ['systemdrive'] + if os.path.isfile(default_nvidia_smi): + self.nvidia_smi = default_nvidia_smi else: self.nvidia_smi = "nvidia-smi" - try: - p = subprocess.Popen([self.nvidia_smi, "-q", "-x"], stdout=subprocess.PIPE) - xmlGpu, stdError = p.communicate() - - smiTree = ET.fromstring(xmlGpu) - gpuTree = smiTree.find('gpu') - - try: - self.gpuMemoryTotal = gpuTree.find('fb_memory_usage').find('total').text.split(" ")[0] - except Exception as e: - logging.debug('Failed to get gpuMemoryTotal: "{}".'.format(str(e))) - pass - try: - self.gpuName = gpuTree.find('product_name').text - except Exception as e: - logging.debug('Failed to get gpuName: "{}".'.format(str(e))) - pass - - except Exception as e: - logging.debug('Failed to get information from nvidia_smi at init: "{}".'.format(str(e))) - def _addKV(self, k, v): if isinstance(v, tuple): for ki, vi in v._asdict().items(): @@ -98,18 +79,23 @@ class ComputerStatistics: self.curves[k].append(v) def update(self): - self.initOnFirstTime() - self._addKV('cpuUsage', psutil.cpu_percent(percpu=True)) # interval=None => non-blocking (percentage since last call) - self._addKV('ramUsage', psutil.virtual_memory().percent) - self._addKV('swapUsage', psutil.swap_memory().percent) - self._addKV('vramUsage', 0) - self._addKV('ioCounters', psutil.disk_io_counters()) - self.updateGpu() + try: + self.initOnFirstTime() + self._addKV('cpuUsage', psutil.cpu_percent(percpu=True)) # interval=None => non-blocking (percentage since last call) + self._addKV('ramUsage', psutil.virtual_memory().percent) + self._addKV('swapUsage', psutil.swap_memory().percent) + self._addKV('vramUsage', 0) + self._addKV('ioCounters', psutil.disk_io_counters()) + self.updateGpu() + except Exception as e: + logging.debug('Failed to get statistics: "{}".'.format(str(e))) def updateGpu(self): + if not self.nvidia_smi: + return try: - p = subprocess.Popen([self.nvidia_smi, "-q", "-x"], stdout=subprocess.PIPE) - xmlGpu, stdError = p.communicate() + p = subprocess.Popen([self.nvidia_smi, "-q", "-x"], stdout=subprocess.PIPE, stderr=subprocess.PIPE) + xmlGpu, stdError = p.communicate(timeout=10) # 10 seconds smiTree = ET.fromstring(xmlGpu) gpuTree = smiTree.find('gpu') @@ -129,7 +115,11 @@ class ComputerStatistics: except Exception as e: logging.debug('Failed to get gpuTemperature: "{}".'.format(str(e))) pass - + except subprocess.TimeoutExpired as e: + logging.debug('Timeout when retrieving information from nvidia_smi: "{}".'.format(str(e))) + p.kill() + outs, errs = p.communicate() + return except Exception as e: logging.debug('Failed to get information from nvidia_smi: "{}".'.format(str(e))) return @@ -201,15 +191,19 @@ class ProcStatistics: data = proc.as_dict(self.dynamicKeys) for k, v in data.items(): self._addKV(k, v) - - files = [f.path for f in proc.open_files()] - if self.lastIterIndexWithFiles != -1: - if set(files) != set(self.openFiles[self.lastIterIndexWithFiles]): - self.openFiles[self.iterIndex] = files - self.lastIterIndexWithFiles = self.iterIndex - elif files: - self.openFiles[self.iterIndex] = files - self.lastIterIndexWithFiles = self.iterIndex + + ## Note: Do not collect stats about open files for now, + # as there is bug in psutil-5.7.2 on Windows which crashes the application. + # https://github.com/giampaolo/psutil/issues/1763 + # + # files = [f.path for f in proc.open_files()] + # if self.lastIterIndexWithFiles != -1: + # if set(files) != set(self.openFiles[self.lastIterIndexWithFiles]): + # self.openFiles[self.iterIndex] = files + # self.lastIterIndexWithFiles = self.iterIndex + # elif files: + # self.openFiles[self.iterIndex] = files + # self.lastIterIndexWithFiles = self.iterIndex self.iterIndex += 1 def toDict(self): @@ -234,7 +228,7 @@ class Statistics: self.computer = ComputerStatistics() self.process = ProcStatistics() self.times = [] - self.interval = 5 + self.interval = 10 # refresh interval in seconds def update(self, proc): ''' @@ -303,7 +297,7 @@ class StatisticsThread(threading.Thread): if self.proc.is_running(): self.updateStats() return - except (KeyboardInterrupt, SystemError, GeneratorExit): + except (KeyboardInterrupt, SystemError, GeneratorExit, psutil.NoSuchProcess): pass def stopRequest(self): diff --git a/meshroom/core/taskManager.py b/meshroom/core/taskManager.py new file mode 100644 index 00000000..bac031c5 --- /dev/null +++ b/meshroom/core/taskManager.py @@ -0,0 +1,465 @@ +import logging +from threading import Thread +from enum import Enum + +import meshroom +from meshroom.common import BaseObject, DictModel, Property, Signal, Slot +from meshroom.core.node import Status +import meshroom.core.graph + + +class State(Enum): + """ + State of the Thread that is computing nodes + """ + IDLE = 0 + RUNNING = 1 + STOPPED = 2 + DEAD = 3 + ERROR = 4 + + +class TaskThread(Thread): + """ + A thread with a pile of nodes to compute + """ + def __init__(self, manager): + Thread.__init__(self, target=self.run) + self._state = State.IDLE + self._manager = manager + self.forceCompute = False + + def isRunning(self): + return self._state == State.RUNNING + + def run(self): + """ Consume compute tasks. """ + self._state = State.RUNNING + + stopAndRestart = False + + for nId, node in enumerate(self._manager._nodesToProcess): + + # skip already finished/running nodes + if node.isFinishedOrRunning(): + continue + + # if a node does not exist anymore, node.chunks becomes a PySide property + try: + multiChunks = len(node.chunks) > 1 + except TypeError: + continue + + for cId, chunk in enumerate(node.chunks): + if chunk.isFinishedOrRunning() or not self.isRunning(): + continue + + if multiChunks: + logging.info('[{node}/{nbNodes}]({chunk}/{nbChunks}) {nodeName}'.format( + node=nId+1, nbNodes=len(self._manager._nodesToProcess), + chunk=cId+1, nbChunks=len(node.chunks), nodeName=node.nodeType)) + else: + logging.info('[{node}/{nbNodes}] {nodeName}'.format( + node=nId+1, nbNodes=len(self._manager._nodesToProcess), nodeName=node.nodeType)) + try: + chunk.process(self.forceCompute) + except Exception as e: + if chunk.isStopped(): + stopAndRestart = True + break + else: + logging.error("Error on node computation: {}".format(e)) + nodesToRemove, _ = self._manager._graph.dfsOnDiscover(startNodes=[node], reverse=True) + # remove following nodes from the task queue + for n in nodesToRemove[1:]: # exclude current node + try: + self._manager._nodesToProcess.remove(n) + except ValueError: + # Node already removed (for instance a global clear of _nodesToProcess) + pass + n.clearSubmittedChunks() + + if stopAndRestart: + break + + if stopAndRestart: + self._state = State.STOPPED + self._manager.restartRequested.emit() + else: + self._manager._nodesToProcess = [] + self._state = State.DEAD + + +class TaskManager(BaseObject): + """ + Manage graph - local and external - computation tasks. + """ + def __init__(self, parent=None): + super(TaskManager, self).__init__(parent) + self._graph = None + self._nodes = DictModel(keyAttrName='_name', parent=self) + self._nodesToProcess = [] + self._nodesExtern = [] + # internal thread in which local tasks are executed + self._thread = TaskThread(self) + + self._blockRestart = False + self.restartRequested.connect(self.restart) + + def requestBlockRestart(self): + """ + Block computing. + Note: should only be used to completely stop computing. + """ + self._blockRestart = True + + def blockRestart(self): + """ Avoid the automatic restart of computing. """ + for node in self._nodesToProcess: + if node.getGlobalStatus() in (Status.SUBMITTED, Status.ERROR): + node.upgradeStatusTo(Status.NONE) + self.removeNode(node, displayList=True) + + self._blockRestart = False + self._nodesToProcess = [] + self._thread._state = State.DEAD + + @Slot() + def restart(self): + """ + Restart computing when thread has been stopped. + Note: this is done like this to avoid app freezing. + """ + # Make sure to wait the end of the current thread + self._thread.join() + + # Avoid restart if thread was globally stopped + if self._blockRestart: + self.blockRestart() + return + + if self._thread._state != State.STOPPED: + return + + for node in self._nodesToProcess: + if node.getGlobalStatus() == Status.STOPPED: + # Remove node from the computing list + self.removeNode(node, displayList=False, processList=True) + + # Remove output nodes from display and computing lists + outputNodes = node.getOutputNodes(recursive=True, dependenciesOnly=True) + for n in outputNodes: + if n.getGlobalStatus() in (Status.ERROR, Status.SUBMITTED): + n.upgradeStatusTo(Status.NONE) + self.removeNode(n, displayList=True, processList=True) + + # Start a new thread with the remaining nodes to compute + self._thread = TaskThread(self) + self._thread.start() + + def compute(self, graph=None, toNodes=None, forceCompute=False, forceStatus=False): + """ + Start graph computation, from root nodes to leaves - or nodes in 'toNodes' if specified. + Computation tasks (NodeChunk) happen in a separate thread (see TaskThread). + + :param graph: the graph to consider. + :param toNodes: specific leaves, all graph leaves if None. + :param forceCompute: force the computation despite nodes status. + :param forceStatus: force the computation even if some nodes are submitted externally. + """ + self._graph = graph + + if self._thread._state != State.RUNNING: + self._nodes.clear() + externEmpty = any(node.isAlreadySubmitted() for node in self._nodesExtern) + if not externEmpty: + self._nodes.update(self._nodesExtern) + else: + self._nodesExtern = [] + + if forceCompute: + nodes, edges = graph.dfsOnFinish(startNodes=toNodes) + self.checkCompatibilityNodes(graph, nodes, "COMPUTATION") # name of the context is important for QML + self.checkDuplicates(nodes, "COMPUTATION") # name of the context is important for QML + else: + # Check dependencies of toNodes + if not toNodes: + toNodes = graph.getLeafNodes(dependenciesOnly=True) + toNodes = list(toNodes) + allReady = self.checkNodesDependencies(graph, toNodes, "COMPUTATION") + + # At this point, toNodes is a list + # If it is empty, we raise an error to avoid passing through dfsToProcess + if not toNodes: + self.raiseImpossibleProcess("COMPUTATION") + + nodes, edges = graph.dfsToProcess(startNodes=toNodes) + if not nodes: + logging.warning('Nothing to compute') + return + self.checkCompatibilityNodes(graph, nodes, "COMPUTATION") # name of the context is important for QML + self.checkDuplicates(nodes, "COMPUTATION") # name of the context is important for QML + + nodes = [node for node in nodes if not self.contains(node)] # be sure to avoid non-real conflicts + chunksInConflict = self.getAlreadySubmittedChunks(nodes) + + if chunksInConflict: + chunksStatus = set([chunk.status.status.name for chunk in chunksInConflict]) + chunksName = [node.name for node in chunksInConflict] + # Warning: Syntax and terms are parsed on QML side to recognize the error + # Syntax : [Context] ErrorType: ErrorMessage + msg = '[COMPUTATION] Already Submitted:\n' \ + 'WARNING - Some nodes are already submitted with status: {}\nNodes: {}'.format( + ', '.join(chunksStatus), + ', '.join(chunksName) + ) + + if forceStatus: + logging.warning(msg) + else: + raise RuntimeError(msg) + + for node in nodes: + node.destroyed.connect(lambda obj=None, name=node.name: self.onNodeDestroyed(obj, name)) + node.beginSequence(forceCompute) + + self._nodes.update(nodes) + self._nodesToProcess.extend(nodes) + + if self._thread._state == State.IDLE: + self._thread.start() + elif self._thread._state in (State.DEAD, State.ERROR): + self._thread = TaskThread(self) + self._thread.start() + + # At the end because it raises a WarningError but should not stop processing + if not allReady: + self.raiseDependenciesMessage("COMPUTATION") + + def onNodeDestroyed(self, obj, name): + """ + Remove node from the taskmanager when it's destroyed in the graph + :param obj: + :param name: + :return: + """ + if name in self._nodes.keys(): + self._nodes.pop(name) + + def contains(self, node): + return node in self._nodes.values() + + def containsNodeName(self, name): + """ Check if a node with the argument name belongs to the display list. """ + if name in self._nodes.keys(): + return True + return False + + def removeNode(self, node, displayList=True, processList=False, externList=False): + """ Remove node from the Task Manager. + + Args: + node (Node): node to remove. + displayList (bool): remove from the display list. + processList (bool): remove from the nodesToProcess list. + externList (bool): remove from the nodesExtern list. + """ + if displayList and self._nodes.contains(node): + self._nodes.pop(node.name) + if processList and node in self._nodesToProcess: + self._nodesToProcess.remove(node) + if externList and node in self._nodesExtern: + self._nodesExtern.remove(node) + + def clear(self): + """ + Remove all the nodes from the taskmanager + :return: + """ + self._nodes.clear() + self._nodesExtern = [] + self._nodesToProcess = [] + + def update(self, graph): + """ + Add all the nodes that are being rendered in a renderfarm to the taskmanager when new graph is loaded + :param graph: + :return: + """ + for node in graph._nodes: + if node.isAlreadySubmitted() and node._chunks.size() > 0 and node.isExtern(): + self._nodes.add(node) + self._nodesExtern.append(node) + + def checkCompatibilityNodes(self, graph, nodes, context): + compatNodes = [] + for node in nodes: + if node in graph._compatibilityNodes.values(): + compatNodes.append(node.nameToLabel(node.name)) + if compatNodes: + # Warning: Syntax and terms are parsed on QML side to recognize the error + # Syntax : [Context] ErrorType: ErrorMessage + raise RuntimeError("[{}] Compatibility Issue:\n" + "Cannot compute because of these incompatible nodes:\n" + "{}".format(context, sorted(compatNodes))) + + def checkDuplicates(self, nodesToProcess, context): + for node in nodesToProcess: + for duplicate in node.duplicates: + if duplicate in nodesToProcess: + # Warning: Syntax and terms are parsed on QML side to recognize the error + # Syntax : [Context] ErrorType: ErrorMessage + raise RuntimeError("[{}] Duplicates Issue:\n" + "Cannot compute because there are some duplicate nodes to process:\n\n" + "First match: '{}' and '{}'\n\n" + "There can be other duplicate nodes in the list. Please, check the graph and try again.".format( + context, node.nameToLabel(node.name), node.nameToLabel(duplicate.name))) + + def checkNodesDependencies(self, graph, toNodes, context): + """ + Check dependencies of nodes to process. + Update toNodes with computable/submittable nodes only. + + Returns: + bool: True if all the nodes can be processed. False otherwise. + """ + ready = [] + computed = [] + for node in toNodes: + if context == "COMPUTATION": + if graph.canCompute(node) and graph.canSubmitOrCompute(node) % 2 == 1: + ready.append(node) + elif node.isComputed: + computed.append(node) + elif context == "SUBMITTING": + if graph.canCompute(node) and graph.canSubmitOrCompute(node) > 1: + ready.append(node) + elif node.isComputed: + computed.append(node) + else: + raise ValueError("Argument 'context' must be: 'COMPUTATION' or 'SUBMITTING'") + + if len(ready) + len(computed) != len(toNodes): + toNodes.clear() + toNodes.extend(ready) + return False + + return True + + def raiseDependenciesMessage(self, context): + # Warning: Syntax and terms are parsed on QML side to recognize the error + # Syntax : [Context] ErrorType: ErrorMessage + raise RuntimeWarning("[{}] Unresolved dependencies:\n" + "Some nodes cannot be computed in LOCAL/submitted in EXTERN because of unresolved dependencies.\n\n" + "Nodes which are ready will be processed.".format(context)) + + def raiseImpossibleProcess(self, context): + # Warning: Syntax and terms are parsed on QML side to recognize the error + # Syntax : [Context] ErrorType: ErrorMessage + raise RuntimeError("[{}] Impossible Process:\n" + "There is no node able to be processed.".format(context)) + + def submit(self, graph=None, submitter=None, toNodes=None): + """ + Nodes are send to the renderfarm + :param graph: + :param submitter: + :param toNodes: + :return: + """ + + # Ensure submitter is properly set + sub = None + if submitter: + sub = meshroom.core.submitters.get(submitter, None) + elif len(meshroom.core.submitters) == 1: + # if only one submitter available use it + allSubmitters = meshroom.core.submitters.values() + sub = next(iter(allSubmitters)) # retrieve the first element + if sub is None: + # Warning: Syntax and terms are parsed on QML side to recognize the error + # Syntax : [Context] ErrorType: ErrorMessage + raise RuntimeError("[SUBMITTING] Unknown Submitter:\n" + "Unknown Submitter called '{submitter}'. Available submitters are: '{allSubmitters}'.".format( + submitter=submitter, + allSubmitters=str(meshroom.core.submitters.keys()) + )) + + # Update task manager's lists + if self._thread._state != State.RUNNING: + self._nodes.clear() + + externEmpty = True + for node in self._nodesExtern: + if node.isAlreadySubmitted(): + externEmpty = False + break + + if not externEmpty: + self._nodes.update(self._nodesExtern) + else: + self._nodesExtern = [] + + # Check dependencies of toNodes + if not toNodes: + toNodes = graph.getLeafNodes(dependenciesOnly=True) + toNodes = list(toNodes) + allReady = self.checkNodesDependencies(graph, toNodes, "SUBMITTING") + + # At this point, toNodes is a list + # If it is empty, we raise an error to avoid passing through dfsToProcess + if not toNodes: + self.raiseImpossibleProcess("SUBMITTING") + + nodesToProcess, edgesToProcess = graph.dfsToProcess(startNodes=toNodes) + if not nodesToProcess: + logging.warning('Nothing to compute') + return + self.checkCompatibilityNodes(graph, nodesToProcess, "SUBMITTING") # name of the context is important for QML + self.checkDuplicates(nodesToProcess, "SUBMITTING") # name of the context is important for QML + + flowEdges = graph.flowEdges(startNodes=toNodes) + edgesToProcess = set(edgesToProcess).intersection(flowEdges) + + logging.info("Nodes to process: {}".format(nodesToProcess)) + logging.info("Edges to process: {}".format(edgesToProcess)) + + try: + res = sub.submit(nodesToProcess, edgesToProcess, graph.filepath) + if res: + for node in nodesToProcess: + node.destroyed.connect(lambda obj=None, name=node.name: self.onNodeDestroyed(obj, name)) + node.submit() # update node status + self._nodes.update(nodesToProcess) + self._nodesExtern.extend(nodesToProcess) + + # At the end because it raises a WarningError but should not stop processing + if not allReady: + self.raiseDependenciesMessage("SUBMITTING") + except Exception as e: + logging.error("Error on submit : {}".format(e)) + + def submitFromFile(self, graphFile, submitter, toNode=None): + """ + Submit the given graph via the given submitter. + """ + graph = meshroom.core.graph.loadGraph(graphFile) + toNodes = graph.findNodes([toNode]) if toNode else None + self.submit(graph, submitter, toNodes) + + def getAlreadySubmittedChunks(self, nodes): + """ + Check if nodes have already been submitted in another Meshroom instance. + :param nodes: + :return: + """ + out = [] + for node in nodes: + for chunk in node.chunks: + # Already submitted/running chunks in another task manager + if chunk.isAlreadySubmitted() and not self.containsNodeName(chunk.statusNodeName): + out.append(chunk) + return out + + nodes = Property(BaseObject, lambda self: self._nodes, constant=True) + restartRequested = Signal() \ No newline at end of file diff --git a/meshroom/multiview.py b/meshroom/multiview.py index 99983a66..76ccce6c 100644 --- a/meshroom/multiview.py +++ b/meshroom/multiview.py @@ -6,13 +6,66 @@ import os from meshroom.core.graph import Graph, GraphModification # Supported image extensions -imageExtensions = ('.jpg', '.jpeg', '.tif', '.tiff', '.png', '.exr', '.rw2', '.cr2', '.nef', '.arw') -videoExtensions = ('.avi', '.mov', '.qt', - '.mkv', '.webm', - '.mp4', '.mpg', '.mpeg', '.m2v', '.m4v', - '.wmv', - '.ogv', '.ogg', - '.mxf') +imageExtensions = ( + # bmp: + '.bmp', + # cineon: + '.cin', + # dds + 'dds' + # dpx: + '.dpx', + # gif: + '.gif', + # hdr: + '.hdr', '.rgbe', + # ico: + '.ico', + # iff: + '.iff', '.z', + # jpeg: + '.jpg', '.jpe', '.jpeg', '.jif', '.jfif', '.jfi', + # jpeg2000: + '.jp2', '.j2k', '.j2c', + # openexr: + '.exr', '.sxr', '.mxr', + # png: + '.png', + # pnm: + '.ppm', '.pgm', '.pbm', '.pnm', '.pfm', + # psd: + '.psd', '.pdd', '.psb', + # ptex: + '.ptex', '.ptx', + # raw: + '.bay', '.bmq', '.cr2', '.cr3', '.crw', '.cs1', '.dc2', '.dcr', '.dng', '.erf', '.fff', '.k25', '.kdc', '.mdc', '.mos', '.mrw', '.nef', '.orf', '.pef', '.pxn', '.raf', '.raw', '.rdc', '.sr2', '.srf', '.x3f', '.arw', '.3fr', '.cine', '.ia', '.kc2', '.mef', '.nrw', '.qtk', '.rw2', '.sti', '.rwl', '.srw', '.drf', '.dsc', '.cap', '.iiq', '.rwz', + # rla: + '.rla', + # sgi: + '.sgi', '.rgb', '.rgba', '.bw', '.int', '.inta', + # socket: + '.socket', + # softimage: + '.pic', + # tiff: + '.tiff', '.tif', '.tx', '.env', '.sm', '.vsm', + # targa: + '.tga', '.tpic', + # webp: + 'webp', + # zfile: + '.zfile', + # osl: + '.osl', '.oso', '.oslgroup', '.oslbody', + ) +videoExtensions = ( + '.avi', '.mov', '.qt', + '.mkv', '.webm', + '.mp4', '.mpg', '.mpeg', '.m2v', '.m4v', + '.wmv', + '.ogv', '.ogg', + '.mxf', + ) panoramaInfoExtensions = ('.xml') @@ -90,9 +143,9 @@ def findFilesByTypeInFolder(folder, recursive=False): return output -def hdri(inputImages=list(), inputViewpoints=list(), inputIntrinsics=list(), output='', graph=None): +def panoramaHdr(inputImages=None, inputViewpoints=None, inputIntrinsics=None, output='', graph=None): """ - Create a new Graph with a complete HDRI pipeline. + Create a new Graph with a Panorama HDR pipeline. Args: inputImages (list of str, optional): list of image file paths @@ -103,24 +156,39 @@ def hdri(inputImages=list(), inputViewpoints=list(), inputIntrinsics=list(), out Graph: the created graph """ if not graph: - graph = Graph('HDRI') + graph = Graph('PanoramaHDR') with GraphModification(graph): - nodes = hdriPipeline(graph) + nodes = panoramaHdrPipeline(graph) cameraInit = nodes[0] - cameraInit.viewpoints.extend([{'path': image} for image in inputImages]) - cameraInit.viewpoints.extend(inputViewpoints) - cameraInit.intrinsics.extend(inputIntrinsics) + if inputImages: + cameraInit.viewpoints.extend([{'path': image} for image in inputImages]) + if inputViewpoints: + cameraInit.viewpoints.extend(inputViewpoints) + if inputIntrinsics: + cameraInit.intrinsics.extend(inputIntrinsics) if output: - stitching = nodes[-1] - graph.addNewNode('Publish', output=output, inputFiles=[stitching.output]) + imageProcessing = nodes[-1] + graph.addNewNode('Publish', output=output, inputFiles=[imageProcessing.outputImages]) return graph +def panoramaFisheyeHdr(inputImages=None, inputViewpoints=None, inputIntrinsics=None, output='', graph=None): + if not graph: + graph = Graph('PanoramaFisheyeHDR') + with GraphModification(graph): + panoramaHdr(inputImages, inputViewpoints, inputIntrinsics, output, graph) + for panoramaInit in graph.nodesByType("PanoramaInit"): + panoramaInit.attribute("useFisheye").value = True + # when using fisheye images, the overlap between images can be small + # and thus requires many features to get enough correspondances for cameras estimation + for featureExtraction in graph.nodesByType("FeatureExtraction"): + featureExtraction.attribute("describerPreset").value = 'high' + return graph -def hdriPipeline(graph): +def panoramaHdrPipeline(graph): """ - Instantiate an HDRI pipeline inside 'graph'. + Instantiate an PanoramaHDR pipeline inside 'graph'. Args: graph (Graph/UIGraph): the graph in which nodes should be instantiated @@ -128,46 +196,86 @@ def hdriPipeline(graph): list of Node: the created nodes """ cameraInit = graph.addNewNode('CameraInit') + try: + # fisheye4 does not work well in the ParoramaEstimation, so here we avoid to use it. + cameraInit.attribute('allowedCameraModels').value.remove("fisheye4") + except ValueError: + pass - ldr2hdr = graph.addNewNode('LDRToHDR', + panoramaPrepareImages = graph.addNewNode('PanoramaPrepareImages', input=cameraInit.output) + ldr2hdrSampling = graph.addNewNode('LdrToHdrSampling', + input=panoramaPrepareImages.output) + + ldr2hdrCalibration = graph.addNewNode('LdrToHdrCalibration', + input=ldr2hdrSampling.input, + userNbBrackets=ldr2hdrSampling.userNbBrackets, + byPass=ldr2hdrSampling.byPass, + channelQuantizationPower=ldr2hdrSampling.channelQuantizationPower, + samples=ldr2hdrSampling.output) + + ldr2hdrMerge = graph.addNewNode('LdrToHdrMerge', + input=ldr2hdrCalibration.input, + userNbBrackets=ldr2hdrCalibration.userNbBrackets, + byPass=ldr2hdrCalibration.byPass, + channelQuantizationPower=ldr2hdrCalibration.channelQuantizationPower, + response=ldr2hdrCalibration.response) + featureExtraction = graph.addNewNode('FeatureExtraction', - input=ldr2hdr.outSfMDataFilename) - featureExtraction.describerPreset.value = 'ultra' - imageMatching = graph.addNewNode('ImageMatching', + input=ldr2hdrMerge.outSfMData, + describerQuality='high') + + panoramaInit = graph.addNewNode('PanoramaInit', input=featureExtraction.input, - featuresFolders=[featureExtraction.output]) + dependency=[featureExtraction.output] # Workaround for tractor submission with a fake dependency + ) + + imageMatching = graph.addNewNode('ImageMatching', + input=panoramaInit.outSfMData, + featuresFolders=[featureExtraction.output], + method='FrustumOrVocabularyTree') + featureMatching = graph.addNewNode('FeatureMatching', input=imageMatching.input, featuresFolders=imageMatching.featuresFolders, - imagePairsList=imageMatching.output) - - panoramaExternalInfo = graph.addNewNode('PanoramaExternalInfo', - input=ldr2hdr.outSfMDataFilename, - matchesFolders=[featureMatching.output] # Workaround for tractor submission with a fake dependency - ) + imagePairsList=imageMatching.output, + describerTypes=featureExtraction.describerTypes) panoramaEstimation = graph.addNewNode('PanoramaEstimation', - input=panoramaExternalInfo.outSfMDataFilename, - featuresFolders=featureMatching.featuresFolders, - matchesFolders=[featureMatching.output]) + input=featureMatching.input, + featuresFolders=featureMatching.featuresFolders, + matchesFolders=[featureMatching.output], + describerTypes=featureMatching.describerTypes) + + panoramaOrientation = graph.addNewNode('SfMTransform', + input=panoramaEstimation.output, + method='from_single_camera') panoramaWarping = graph.addNewNode('PanoramaWarping', - input=panoramaEstimation.outSfMDataFilename) + input=panoramaOrientation.output) panoramaCompositing = graph.addNewNode('PanoramaCompositing', - input=panoramaWarping.output) + input=panoramaWarping.input, + warpingFolder=panoramaWarping.output) + + imageProcessing = graph.addNewNode('ImageProcessing', + input=panoramaCompositing.output, + fixNonFinite=True, + fillHoles=True, + extension='exr') return [ cameraInit, featureExtraction, + panoramaInit, imageMatching, featureMatching, - panoramaExternalInfo, panoramaEstimation, + panoramaOrientation, panoramaWarping, panoramaCompositing, + imageProcessing, ] @@ -240,11 +348,13 @@ def sfmPipeline(graph): featureMatching = graph.addNewNode('FeatureMatching', input=imageMatching.input, featuresFolders=imageMatching.featuresFolders, - imagePairsList=imageMatching.output) + imagePairsList=imageMatching.output, + describerTypes=featureExtraction.describerTypes) structureFromMotion = graph.addNewNode('StructureFromMotion', input=featureMatching.input, featuresFolders=featureMatching.featuresFolders, - matchesFolders=[featureMatching.output]) + matchesFolders=[featureMatching.output], + describerTypes=featureMatching.describerTypes) return [ cameraInit, featureExtraction, @@ -278,8 +388,7 @@ def mvsPipeline(graph, sfm=None): depthMapsFolder=depthMap.output) meshing = graph.addNewNode('Meshing', input=depthMapFilter.input, - depthMapsFolder=depthMapFilter.depthMapsFolder, - depthMapsFilterFolder=depthMapFilter.output) + depthMapsFolder=depthMapFilter.output) meshFiltering = graph.addNewNode('MeshFiltering', inputMesh=meshing.outputMesh) texturing = graph.addNewNode('Texturing', @@ -320,16 +429,18 @@ def sfmAugmentation(graph, sourceSfm, withMVS=False): featureMatching = graph.addNewNode('FeatureMatching', input=imageMatchingMulti.outputCombinedSfM, featuresFolders=imageMatchingMulti.featuresFolders, - imagePairsList=imageMatchingMulti.output) + imagePairsList=imageMatchingMulti.output, + describerTypes=featureExtraction.describerTypes) structureFromMotion = graph.addNewNode('StructureFromMotion', input=featureMatching.input, featuresFolders=featureMatching.featuresFolders, - matchesFolders=[featureMatching.output]) + matchesFolders=[featureMatching.output], + describerTypes=featureMatching.describerTypes) graph.addEdge(sourceSfm.output, imageMatchingMulti.inputB) sfmNodes = [ cameraInit, - featureMatching, + featureExtraction, imageMatchingMulti, featureMatching, structureFromMotion diff --git a/meshroom/nodes/aliceVision/CameraDownscale.py b/meshroom/nodes/aliceVision/CameraDownscale.py deleted file mode 100644 index 894c3cc3..00000000 --- a/meshroom/nodes/aliceVision/CameraDownscale.py +++ /dev/null @@ -1,49 +0,0 @@ -__version__ = "1.0" - -import json -import os - -from meshroom.core import desc - - -class CameraDownscale(desc.CommandLineNode): - commandLine = 'aliceVision_cameraDownscale {allParams}' - size = desc.DynamicNodeSize('input') - - inputs = [ - desc.File( - name='input', - label='Input', - description="SfM Data File", - value='', - uid=[0], - ), - desc.FloatParam( - name='rescalefactor', - label='RescaleFactor', - description='Newsize = rescalefactor * oldsize', - value=0.5, - range=(0.0, 1.0, 0.1), - uid=[0], - advanced=True, - ), - desc.ChoiceParam( - name='verboseLevel', - label='Verbose Level', - description='Verbosity level (fatal, error, warning, info, debug, trace).', - value='info', - values=['fatal', 'error', 'warning', 'info', 'debug', 'trace'], - exclusive=True, - uid=[], - ), - ] - - outputs = [ - desc.File( - name='outSfMDataFilename', - label='Output SfMData File', - description='Path to the output sfmdata file', - value=desc.Node.internalFolder + 'sfmData.abc', - uid=[], - ) - ] diff --git a/meshroom/nodes/aliceVision/CameraInit.py b/meshroom/nodes/aliceVision/CameraInit.py index eff321e1..8156fc7b 100644 --- a/meshroom/nodes/aliceVision/CameraInit.py +++ b/meshroom/nodes/aliceVision/CameraInit.py @@ -1,10 +1,11 @@ -__version__ = "2.0" +__version__ = "3.0" import os import json import psutil import shutil import tempfile +import logging from meshroom.core import desc @@ -16,18 +17,39 @@ Viewpoint = [ desc.IntParam(name="intrinsicId", label="Intrinsic", description="Internal Camera Parameters", value=-1, uid=[0], range=None), desc.IntParam(name="rigId", label="Rig", description="Rig Parameters", value=-1, uid=[0], range=None), desc.IntParam(name="subPoseId", label="Rig Sub-Pose", description="Rig Sub-Pose Parameters", value=-1, uid=[0], range=None), - desc.StringParam(name="metadata", label="Image Metadata", description="", value="", uid=[], advanced=True), + desc.StringParam(name="metadata", label="Image Metadata", + description="The configuration of the Viewpoints is based on the images metadata.\n" + "The important ones are:\n" + " * Focal Length: the focal length in mm.\n" + " * Make and Model: this information allows to convert the focal in mm into a focal length in pixel using an embedded sensor database.\n" + " * Serial Number: allows to uniquely identify a device so multiple devices with the same Make, Model can be differentiated and their internal parameters are optimized separately.", + value="", uid=[], advanced=True), ] Intrinsic = [ desc.IntParam(name="intrinsicId", label="Id", description="Intrinsic UID", value=-1, uid=[0], range=None), - desc.FloatParam(name="pxInitialFocalLength", label="Initial Focal Length", description="Initial Guess on the Focal Length", value=-1.0, uid=[0], range=None), - desc.FloatParam(name="pxFocalLength", label="Focal Length", description="Known/Calibrated Focal Length", value=-1.0, uid=[0], range=None), - desc.ChoiceParam(name="type", label="Camera Type", description="Camera Type", value="", values=['', 'pinhole', 'radial1', 'radial3', 'brown', 'fisheye4'], exclusive=True, uid=[0]), + desc.FloatParam(name="pxInitialFocalLength", label="Initial Focal Length", + description="Initial Guess on the Focal Length (in pixels). \n" + "When we have an initial value from EXIF, this value is not accurate but cannot be wrong. \n" + "So this value is used to limit the range of possible values in the optimization. \n" + "If you put -1, this value will not be used and the focal length will not be bounded.", + value=-1.0, uid=[0], range=None), + desc.FloatParam(name="pxFocalLength", label="Focal Length", description="Known/Calibrated Focal Length (in pixels)", value=-1.0, uid=[0], range=None), + desc.ChoiceParam(name="type", label="Camera Type", + description="Mathematical Model used to represent a camera:\n" + " * pinhole: Simplest projective camera model without optical distortion (focal and optical center).\n" + " * radial1: Pinhole camera with one radial distortion parameter\n" + " * radial3: Pinhole camera with 3 radial distortion parameters\n" + " * brown: Pinhole camera with 3 radial and 2 tangential distortion parameters\n" + " * fisheye4: Pinhole camera with 4 distortion parameters suited for fisheye optics (like 120deg FoV)\n" + " * equidistant_r3: Non-projective camera model suited for full-fisheye optics (like 180deg FoV)\n", + value="", values=['', 'pinhole', 'radial1', 'radial3', 'brown', 'fisheye4', 'equidistant_r3'], exclusive=True, uid=[0]), desc.IntParam(name="width", label="Width", description="Image Width", value=0, uid=[], range=(0, 10000, 1)), desc.IntParam(name="height", label="Height", description="Image Height", value=0, uid=[], range=(0, 10000, 1)), - desc.StringParam(name="serialNumber", label="Serial Number", description="Device Serial Number (camera and lens combined)", value="", uid=[]), - desc.GroupAttribute(name="principalPoint", label="Principal Point", description="", groupDesc=[ + desc.FloatParam(name="sensorWidth", label="Sensor Width", description="Sensor Width (mm)", value=36, uid=[], range=(0, 1000, 1)), + desc.FloatParam(name="sensorHeight", label="Sensor Height", description="Sensor Height (mm)", value=24, uid=[], range=(0, 1000, 1)), + desc.StringParam(name="serialNumber", label="Serial Number", description="Device Serial Number (Camera UID and Lens UID combined)", value="", uid=[]), + desc.GroupAttribute(name="principalPoint", label="Principal Point", description="Position of the Optical Center in the Image (i.e. the sensor surface).", groupDesc=[ desc.FloatParam(name="x", label="x", description="", value=0, uid=[], range=(0, 10000, 1)), desc.FloatParam(name="y", label="y", description="", value=0, uid=[], range=(0, 10000, 1)), ]), @@ -94,6 +116,21 @@ class CameraInit(desc.CommandLineNode): size = desc.DynamicNodeSize('viewpoints') + documentation = ''' +This node describes your dataset. It lists the Viewpoints candidates, the guess about the type of optic, the initial focal length +and which images are sharing the same internal camera parameters, as well as potential cameras rigs. + +When you import new images into Meshroom, this node is automatically configured from the analysis of the image metadata. +The software can support images without any metadata but it is recommended to have them for robustness. + +### Metadata +Metadata allows images to be grouped together and provides an initialization of the focal length (in pixel unit). +The metadata needed are: + * **Focal Length**: the focal length in mm. + * **Make** & **Model**: this information allows to convert the focal in mm into a focal length in pixel using an embedded sensor database. + * **Serial Number**: allows to uniquely identify a device so multiple devices with the same Make, Model can be differentiated and their internal parameters are optimized separately (in the photogrammetry case). +''' + inputs = [ desc.ListAttribute( name="viewpoints", @@ -122,7 +159,8 @@ class CameraInit(desc.CommandLineNode): description='Empirical value for the field of view in degree.', value=45.0, range=(0, 180.0, 1), - uid=[0], + uid=[], + advanced=True, ), desc.ChoiceParam( name='groupCameraFallback', @@ -136,8 +174,51 @@ class CameraInit(desc.CommandLineNode): values=['global', 'folder', 'image'], value='folder', exclusive=True, + uid=[], + advanced=True, + ), + desc.ChoiceParam( + name='allowedCameraModels', + label='Allowed Camera Models', + description='the Camera Models that can be attributed.', + value=['pinhole', 'radial1', 'radial3', 'brown', 'fisheye4', 'fisheye1'], + values=['pinhole', 'radial1', 'radial3', 'brown', 'fisheye4', 'fisheye1'], + exclusive=False, + uid=[], + joinChar=',', + advanced=True, + ), + desc.BoolParam( + name='useInternalWhiteBalance', + label='Apply internal white balance', + description='Apply image white balance (Only for raw images)', + value=True, uid=[0], - advanced=True + ), + desc.ChoiceParam( + name='viewIdMethod', + label='ViewId Method', + description="Allows to choose the way the viewID is generated:\n" + " * metadata : Generate viewId from image metadata.\n" + " * filename : Generate viewId from file names using regex.", + value='metadata', + values=['metadata', 'filename'], + exclusive=True, + uid=[], + advanced=True, + ), + desc.StringParam( + name='viewIdRegex', + label='ViewId Regex', + description='Regex used to catch number used as viewId in filename.' + 'You should capture specific parts of the filename with parenthesis to define matching elements. (only number will works)\n' + 'Some examples of patterns:\n' + ' - Match the longest number at the end of filename (default value): ".*?(\d+)"\n' + ' - Match the first number found in filename : "(\d+).*"\n', + value='.*?(\d+)', + uid=[], + advanced=True, + enabled=lambda node: node.viewIdMethod.value == 'filename', ), desc.ChoiceParam( name='verboseLevel', @@ -153,7 +234,7 @@ class CameraInit(desc.CommandLineNode): outputs = [ desc.File( name='output', - label='Output SfMData File', + label='SfMData', description='''Output SfMData.''', value=desc.Node.internalFolder + 'cameraInit.sfm', uid=[], @@ -186,7 +267,7 @@ class CameraInit(desc.CommandLineNode): os.makedirs(os.path.join(tmpCache, node.internalFolder)) self.createViewpointsFile(node, additionalViews) cmd = self.buildCommandLine(node.chunks[0]) - # logging.debug(' - commandLine:', cmd) + logging.debug(' - commandLine: {}'.format(cmd)) proc = psutil.Popen(cmd, stdout=None, stderr=None, shell=True) stdout, stderr = proc.communicate() # proc.wait() @@ -199,10 +280,13 @@ class CameraInit(desc.CommandLineNode): cameraInitSfM = node.output.value return readSfMData(cameraInitSfM) - except Exception: + except Exception as e: + logging.debug("[CameraInit] Error while building intrinsics: {}".format(str(e))) raise finally: - shutil.rmtree(tmpCache) + if os.path.exists(tmpCache): + logging.debug("[CameraInit] Remove temp files in: {}".format(tmpCache)) + shutil.rmtree(tmpCache) def createViewpointsFile(self, node, additionalViews=()): node.viewpointsFile = "" @@ -227,7 +311,7 @@ class CameraInit(desc.CommandLineNode): "featureFolder": "", "matchingFolder": "", } - node.viewpointsFile = (node.nodeDesc.internalFolder + '/viewpoints.sfm').format(**node._cmdVars) + node.viewpointsFile = os.path.join(node.nodeDesc.internalFolder + 'viewpoints.sfm').format(**node._cmdVars) with open(node.viewpointsFile, 'w') as f: json.dump(sfmData, f, indent=4) diff --git a/meshroom/nodes/aliceVision/CameraLocalization.py b/meshroom/nodes/aliceVision/CameraLocalization.py index 09002827..08a14e41 100644 --- a/meshroom/nodes/aliceVision/CameraLocalization.py +++ b/meshroom/nodes/aliceVision/CameraLocalization.py @@ -41,7 +41,7 @@ class CameraLocalization(desc.CommandLineNode): label='Match Desc Types', description='''Describer types to use for the matching.''', value=['sift'], - values=['sift', 'sift_float', 'sift_upright', 'akaze', 'akaze_liop', 'akaze_mldb', 'cctag3', 'cctag4', 'sift_ocv', 'akaze_ocv'], + values=['sift', 'sift_float', 'sift_upright', 'dspsift', 'akaze', 'akaze_liop', 'akaze_mldb', 'cctag3', 'cctag4', 'sift_ocv', 'akaze_ocv'], exclusive=False, uid=[0], joinChar=',', diff --git a/meshroom/nodes/aliceVision/CameraRigCalibration.py b/meshroom/nodes/aliceVision/CameraRigCalibration.py index 67ea8730..fe189d08 100644 --- a/meshroom/nodes/aliceVision/CameraRigCalibration.py +++ b/meshroom/nodes/aliceVision/CameraRigCalibration.py @@ -48,7 +48,7 @@ class CameraRigCalibration(desc.CommandLineNode): label='Match Describer Types', description='''The describer types to use for the matching''', value=['sift'], - values=['sift', 'sift_float', 'sift_upright', 'akaze', 'akaze_liop', 'akaze_mldb', 'cctag3', 'cctag4', 'sift_ocv', 'akaze_ocv'], + values=['sift', 'sift_float', 'sift_upright', 'dspsift', 'akaze', 'akaze_liop', 'akaze_mldb', 'cctag3', 'cctag4', 'sift_ocv', 'akaze_ocv'], exclusive=False, uid=[0], joinChar=',', diff --git a/meshroom/nodes/aliceVision/ConvertSfMFormat.py b/meshroom/nodes/aliceVision/ConvertSfMFormat.py index 5260a59b..687ad5c4 100644 --- a/meshroom/nodes/aliceVision/ConvertSfMFormat.py +++ b/meshroom/nodes/aliceVision/ConvertSfMFormat.py @@ -6,7 +6,12 @@ from meshroom.core import desc class ConvertSfMFormat(desc.CommandLineNode): commandLine = 'aliceVision_convertSfMFormat {allParams}' size = desc.DynamicNodeSize('input') - + + documentation = ''' +Convert an SfM scene from one file format to another. +It can also be used to remove specific parts of from an SfM scene (like filter all 3D landmarks or filter 2D observations). +''' + inputs = [ desc.File( name='input', @@ -30,7 +35,7 @@ class ConvertSfMFormat(desc.CommandLineNode): label='Describer Types', description='Describer types to keep.', value=['sift'], - values=['sift', 'sift_float', 'sift_upright', 'akaze', 'akaze_liop', 'akaze_mldb', 'cctag3', 'cctag4', 'sift_ocv', 'akaze_ocv', 'unknown'], + values=['sift', 'sift_float', 'sift_upright', 'dspsift', 'akaze', 'akaze_liop', 'akaze_mldb', 'cctag3', 'cctag4', 'sift_ocv', 'akaze_ocv', 'unknown'], exclusive=False, uid=[0], joinChar=',', diff --git a/meshroom/nodes/aliceVision/DepthMap.py b/meshroom/nodes/aliceVision/DepthMap.py index 2da0cfcf..b29deeca 100644 --- a/meshroom/nodes/aliceVision/DepthMap.py +++ b/meshroom/nodes/aliceVision/DepthMap.py @@ -10,10 +10,20 @@ class DepthMap(desc.CommandLineNode): parallelization = desc.Parallelization(blockSize=3) commandLineRange = '--rangeStart {rangeStart} --rangeSize {rangeBlockSize}' + documentation = ''' +For each camera that have been estimated by the Structure-From-Motion, it estimates the depth value per pixel. + +Adjust the downscale factor to compute depth maps at a higher/lower resolution. +Use a downscale factor of one (full-resolution) only if the quality of the input images is really high (camera on a tripod with high-quality optics). + +## Online +[https://alicevision.org/#photogrammetry/depth_maps_estimation](https://alicevision.org/#photogrammetry/depth_maps_estimation) +''' + inputs = [ desc.File( name='input', - label='Input', + label='SfMData', description='SfMData file.', value='', uid=[0], diff --git a/meshroom/nodes/aliceVision/DepthMapFilter.py b/meshroom/nodes/aliceVision/DepthMapFilter.py index af78f805..9d5e34cb 100644 --- a/meshroom/nodes/aliceVision/DepthMapFilter.py +++ b/meshroom/nodes/aliceVision/DepthMapFilter.py @@ -10,17 +10,22 @@ class DepthMapFilter(desc.CommandLineNode): parallelization = desc.Parallelization(blockSize=10) commandLineRange = '--rangeStart {rangeStart} --rangeSize {rangeBlockSize}' + documentation = ''' +Filter depth map values that are not coherent in multiple depth maps. +This allows to filter unstable points before starting the fusion of all depth maps in the Meshing node. +''' + inputs = [ desc.File( name='input', - label='Input', + label='SfMData', description='SfMData file.', value='', uid=[0], ), desc.File( name="depthMapsFolder", - label="Depth Maps Folder", + label="DepthMaps Folder", description="Input depth maps folder", value="", uid=[0], @@ -117,7 +122,7 @@ class DepthMapFilter(desc.CommandLineNode): outputs = [ desc.File( name='output', - label='Output', + label='Filtered DepthMaps Folder', description='Output folder for generated depth maps.', value=desc.Node.internalFolder, uid=[], diff --git a/meshroom/nodes/aliceVision/ExportAnimatedCamera.py b/meshroom/nodes/aliceVision/ExportAnimatedCamera.py index b8d97557..eefeb489 100644 --- a/meshroom/nodes/aliceVision/ExportAnimatedCamera.py +++ b/meshroom/nodes/aliceVision/ExportAnimatedCamera.py @@ -6,6 +6,11 @@ from meshroom.core import desc class ExportAnimatedCamera(desc.CommandLineNode): commandLine = 'aliceVision_exportAnimatedCamera {allParams}' + documentation = ''' +Convert cameras from an SfM scene into an animated cameras in Alembic file format. +Based on the input image filenames, it will recognize the input video sequence to create an animated camera. +''' + inputs = [ desc.File( name='input', diff --git a/meshroom/nodes/aliceVision/ExportMatches.py b/meshroom/nodes/aliceVision/ExportMatches.py index 8df3b391..53525165 100644 --- a/meshroom/nodes/aliceVision/ExportMatches.py +++ b/meshroom/nodes/aliceVision/ExportMatches.py @@ -20,7 +20,7 @@ class ExportMatches(desc.CommandLineNode): label='Describer Types', description='Describer types used to describe an image.', value=['sift'], - values=['sift', 'sift_float', 'sift_upright', 'akaze', 'akaze_liop', 'akaze_mldb', 'cctag3', 'cctag4', 'sift_ocv', 'akaze_ocv'], + values=['sift', 'sift_float', 'sift_upright', 'dspsift', 'akaze', 'akaze_liop', 'akaze_mldb', 'cctag3', 'cctag4', 'sift_ocv', 'akaze_ocv'], exclusive=False, uid=[0], joinChar=',', diff --git a/meshroom/nodes/aliceVision/ExportMaya.py b/meshroom/nodes/aliceVision/ExportMaya.py index 41568f5d..93288526 100644 --- a/meshroom/nodes/aliceVision/ExportMaya.py +++ b/meshroom/nodes/aliceVision/ExportMaya.py @@ -6,6 +6,13 @@ from meshroom.core import desc class ExportMaya(desc.CommandLineNode): commandLine = 'aliceVision_exportMeshroomMaya {allParams}' + documentation = ''' +Export a scene for Autodesk Maya, with an Alembic file describing the SfM: cameras and 3D points. +It will export half-size undistorted images to use as image planes for cameras and also export thumbnails. +Use the MeshroomMaya plugin, to load the ABC file. It will recognize the file structure and will setup the scene. +MeshroomMaya contains a user interface to browse all cameras. +''' + inputs = [ desc.File( name='input', diff --git a/meshroom/nodes/aliceVision/FeatureExtraction.py b/meshroom/nodes/aliceVision/FeatureExtraction.py index 03bc07a3..3c1f19b1 100644 --- a/meshroom/nodes/aliceVision/FeatureExtraction.py +++ b/meshroom/nodes/aliceVision/FeatureExtraction.py @@ -9,10 +9,30 @@ class FeatureExtraction(desc.CommandLineNode): parallelization = desc.Parallelization(blockSize=40) commandLineRange = '--rangeStart {rangeStart} --rangeSize {rangeBlockSize}' + documentation = ''' +This node extracts distinctive groups of pixels that are, to some extent, invariant to changing camera viewpoints during image acquisition. +Hence, a feature in the scene should have similar feature descriptions in all images. + +This node implements multiple methods: + * **SIFT** +The most standard method. This is the default and recommended value for all use cases. + * **AKAZE** +AKAZE can be interesting solution to extract features in challenging condition. It could be able to match wider angle than SIFT but has drawbacks. +It may extract to many features, the repartition is not always good. +It is known to be good on challenging surfaces such as skin. + * **CCTAG** +CCTag is a marker type with 3 or 4 crowns. You can put markers in the scene during the shooting session to automatically re-orient and re-scale the scene to a known size. +It is robust to motion-blur, depth-of-field, occlusion. Be careful to have enough white margin around your CCTags. + + +## Online +[https://alicevision.org/#photogrammetry/natural_feature_extraction](https://alicevision.org/#photogrammetry/natural_feature_extraction) +''' + inputs = [ desc.File( name='input', - label='Input', + label='SfMData', description='SfMData file.', value='', uid=[0], @@ -29,20 +49,76 @@ class FeatureExtraction(desc.CommandLineNode): label='Describer Types', description='Describer types used to describe an image.', value=['sift'], - values=['sift', 'sift_float', 'sift_upright', 'akaze', 'akaze_liop', 'akaze_mldb', 'cctag3', 'cctag4', 'sift_ocv', 'akaze_ocv'], + values=['sift', 'sift_float', 'sift_upright', 'dspsift', 'akaze', 'akaze_liop', 'akaze_mldb', 'cctag3', 'cctag4', 'sift_ocv', 'akaze_ocv'], exclusive=False, uid=[0], joinChar=',', ), desc.ChoiceParam( name='describerPreset', - label='Describer Preset', - description='Control the ImageDescriber configuration (low, medium, normal, high, ultra). Configuration "ultra" can take long time !', + label='Describer Density', + description='Control the ImageDescriber density (low, medium, normal, high, ultra).\n' + 'Warning: Use ULTRA only on small datasets.', + value='normal', + values=['low', 'medium', 'normal', 'high', 'ultra', 'custom'], + exclusive=True, + uid=[0], + group=lambda node: 'allParams' if node.describerPreset.value != 'custom' else None, + ), + desc.IntParam( + name='maxNbFeatures', + label='Max Nb Features', + description='Max number of features extracted (0 means default value based on Describer Density).', + value=0, + range=(0, 100000, 1000), + uid=[0], + advanced=True, + enabled=lambda node: (node.describerPreset.value == 'custom'), + ), + desc.ChoiceParam( + name='describerQuality', + label='Describer Quality', + description='Control the ImageDescriber quality (low, medium, normal, high, ultra).', value='normal', values=['low', 'medium', 'normal', 'high', 'ultra'], exclusive=True, uid=[0], ), + desc.ChoiceParam( + name='contrastFiltering', + label='Contrast Filtering', + description="Contrast filtering method to ignore features with too low contrast that can be considered as noise:\n" + "* Static: Fixed threshold.\n" + "* AdaptiveToMedianVariance: Based on image content analysis.\n" + "* NoFiltering: Disable contrast filtering.\n" + "* GridSortOctaves: Grid Sort but per octaves (and only per scale at the end).\n" + "* GridSort: Grid sort per octaves and at the end (scale * peakValue).\n" + "* GridSortScaleSteps: Grid sort per octaves and at the end (scale and then peakValue).\n" + "* NonExtremaFiltering: Filter non-extrema peakValues.\n", + value='GridSort', + values=['Static', 'AdaptiveToMedianVariance', 'NoFiltering', 'GridSortOctaves', 'GridSort', 'GridSortScaleSteps', 'GridSortOctaveSteps', 'NonExtremaFiltering'], + exclusive=True, + advanced=True, + uid=[0], + ), + desc.FloatParam( + name='relativePeakThreshold', + label='Relative Peak Threshold', + description='Peak Threshold relative to median of gradiants.', + value=0.01, + range=(0.01, 1.0, 0.001), + advanced=True, + uid=[0], + enabled=lambda node: (node.contrastFiltering.value == 'AdaptiveToMedianVariance'), + ), + desc.BoolParam( + name='gridFiltering', + label='Grid Filtering', + description='Enable grid filtering. Highly recommended to ensure usable number of features.', + value=True, + advanced=True, + uid=[0], + ), desc.BoolParam( name='forceCpuExtraction', label='Force CPU Extraction', @@ -74,7 +150,7 @@ class FeatureExtraction(desc.CommandLineNode): outputs = [ desc.File( name='output', - label='Output Folder', + label='Features Folder', description='Output path for the features and descriptors files (*.feat, *.desc).', value=desc.Node.internalFolder, uid=[], diff --git a/meshroom/nodes/aliceVision/FeatureMatching.py b/meshroom/nodes/aliceVision/FeatureMatching.py index 0b4c00d7..d5bf61f9 100644 --- a/meshroom/nodes/aliceVision/FeatureMatching.py +++ b/meshroom/nodes/aliceVision/FeatureMatching.py @@ -9,10 +9,32 @@ class FeatureMatching(desc.CommandLineNode): parallelization = desc.Parallelization(blockSize=20) commandLineRange = '--rangeStart {rangeStart} --rangeSize {rangeBlockSize}' + documentation = ''' +This node performs the matching of all features between the candidate image pairs. + +It is performed in 2 steps: + + 1/ **Photometric Matches** + +It performs the photometric matches between the set of features descriptors from the 2 input images. +For each feature descriptor on the first image, it looks for the 2 closest descriptors in the second image and uses a relative threshold between them. +This assumption kill features on repetitive structure but has proved to be a robust criterion. + + 2/ **Geometric Filtering** + +It performs a geometric filtering of the photometric match candidates. +It uses the features positions in the images to make a geometric filtering by using epipolar geometry in an outlier detection framework +called RANSAC (RANdom SAmple Consensus). It randomly selects a small set of feature correspondences and compute the fundamental (or essential) matrix, +then it checks the number of features that validates this model and iterate through the RANSAC framework. + +## Online +[https://alicevision.org/#photogrammetry/feature_matching](https://alicevision.org/#photogrammetry/feature_matching) +''' + inputs = [ desc.File( name='input', - label='Input', + label='SfMData', description='SfMData file.', value='', uid=[0], @@ -31,7 +53,7 @@ class FeatureMatching(desc.CommandLineNode): ), desc.File( name='imagePairsList', - label='Image Pairs List', + label='Image Pairs', description='Path to a file which contains the list of image pairs to match.', value='', uid=[0], @@ -41,7 +63,7 @@ class FeatureMatching(desc.CommandLineNode): label='Describer Types', description='Describer types used to describe an image.', value=['sift'], - values=['sift', 'sift_float', 'sift_upright', 'akaze', 'akaze_liop', 'akaze_mldb', 'cctag3', 'cctag4', 'sift_ocv', 'akaze_ocv'], + values=['sift', 'sift_float', 'sift_upright', 'dspsift', 'akaze', 'akaze_liop', 'akaze_mldb', 'cctag3', 'cctag4', 'sift_ocv', 'akaze_ocv'], exclusive=False, uid=[0], joinChar=',', @@ -77,12 +99,13 @@ class FeatureMatching(desc.CommandLineNode): label='Geometric Filter Type', description='Geometric validation method to filter features matches: \n' ' * fundamental_matrix\n' + ' * fundamental_with_distortion\n' ' * essential_matrix\n' ' * homography_matrix\n' ' * homography_growing\n' ' * no_filtering', value='fundamental_matrix', - values=['fundamental_matrix', 'essential_matrix', 'homography_matrix', 'homography_growing', 'no_filtering'], + values=['fundamental_matrix', 'fundamental_with_distortion', 'essential_matrix', 'homography_matrix', 'homography_growing', 'no_filtering'], exclusive=True, uid=[0], advanced=True, @@ -179,7 +202,7 @@ class FeatureMatching(desc.CommandLineNode): outputs = [ desc.File( name='output', - label='Output Folder', + label='Matches Folder', description='Path to a folder in which computed matches will be stored.', value=desc.Node.internalFolder, uid=[], diff --git a/meshroom/nodes/aliceVision/FeatureRepeatability.py b/meshroom/nodes/aliceVision/FeatureRepeatability.py new file mode 100644 index 00000000..746ba551 --- /dev/null +++ b/meshroom/nodes/aliceVision/FeatureRepeatability.py @@ -0,0 +1,131 @@ +__version__ = "1.1" + +from meshroom.core import desc + + +class FeatureRepeatability(desc.CommandLineNode): + commandLine = 'aliceVision_samples_repeatabilityDataset {allParams}' + size = desc.DynamicNodeSize('input') + # parallelization = desc.Parallelization(blockSize=40) + # commandLineRange = '--rangeStart {rangeStart} --rangeSize {rangeBlockSize}' + + documentation = ''' +''' + + inputs = [ + desc.File( + name='input', + label='Input Folder', + description='Input Folder with evaluation datasets.', + value='', + uid=[0], + ), + desc.ChoiceParam( + name='describerTypes', + label='Describer Types', + description='Describer types used to describe an image.', + value=['sift'], + values=['sift', 'sift_float', 'sift_upright', 'dspsift', 'akaze', 'akaze_liop', 'akaze_mldb', 'cctag3', 'cctag4', 'sift_ocv', 'akaze_ocv'], + exclusive=False, + uid=[0], + joinChar=',', + ), + desc.ChoiceParam( + name='describerPreset', + label='Describer Density', + description='Control the ImageDescriber density (low, medium, normal, high, ultra).\n' + 'Warning: Use ULTRA only on small datasets.', + value='normal', + values=['low', 'medium', 'normal', 'high', 'ultra'], + exclusive=True, + uid=[0], + ), + desc.ChoiceParam( + name='describerQuality', + label='Describer Quality', + description='Control the ImageDescriber quality (low, medium, normal, high, ultra).', + value='normal', + values=['low', 'medium', 'normal', 'high', 'ultra'], + exclusive=True, + uid=[0], + ), + desc.ChoiceParam( + name='contrastFiltering', + label='Contrast Filtering', + description="Contrast filtering method to ignore features with too low contrast that can be considered as noise:\n" + "* Static: Fixed threshold.\n" + "* AdaptiveToMedianVariance: Based on image content analysis.\n" + "* NoFiltering: Disable contrast filtering.\n" + "* GridSortOctaves: Grid Sort but per octaves (and only per scale at the end).\n" + "* GridSort: Grid sort per octaves and at the end (scale * peakValue).\n" + "* GridSortScaleSteps: Grid sort per octaves and at the end (scale and then peakValue).\n" + "* NonExtremaFiltering: Filter non-extrema peakValues.\n", + value='Static', + values=['Static', 'AdaptiveToMedianVariance', 'NoFiltering', 'GridSortOctaves', 'GridSort', 'GridSortScaleSteps', 'GridSortOctaveSteps', 'NonExtremaFiltering'], + exclusive=True, + advanced=True, + uid=[0], + ), + desc.FloatParam( + name='relativePeakThreshold', + label='Relative Peak Threshold', + description='Peak Threshold relative to median of gradiants.', + value=0.01, + range=(0.01, 1.0, 0.001), + advanced=True, + uid=[0], + enabled=lambda node: (node.contrastFiltering.value == 'AdaptiveToMedianVariance'), + ), + desc.BoolParam( + name='gridFiltering', + label='Grid Filtering', + description='Enable grid filtering. Highly recommended to ensure usable number of features.', + value=True, + advanced=True, + uid=[0], + ), + desc.BoolParam( + name='forceCpuExtraction', + label='Force CPU Extraction', + description='Use only CPU feature extraction.', + value=True, + uid=[], + advanced=True, + ), + desc.IntParam( + name='invalidate', + label='Invalidate', + description='Invalidate.', + value=0, + range=(0, 10000, 1), + group="", + uid=[0], + ), + desc.StringParam( + name="comments", + label="Comments", + description="Comments", + value="", + group="", + uid=[], + ), + desc.ChoiceParam( + name='verboseLevel', + label='Verbose Level', + description='verbosity level (fatal, error, warning, info, debug, trace).', + value='info', + values=['fatal', 'error', 'warning', 'info', 'debug', 'trace'], + exclusive=True, + uid=[], + ) + ] + + outputs = [ + desc.File( + name='output', + label='Output Folder', + description='Output path for the features and descriptors files (*.feat, *.desc).', + value=desc.Node.internalFolder, + uid=[], + ), + ] diff --git a/meshroom/nodes/aliceVision/GlobalSfM.py b/meshroom/nodes/aliceVision/GlobalSfM.py index a60b7a78..49410fea 100644 --- a/meshroom/nodes/aliceVision/GlobalSfM.py +++ b/meshroom/nodes/aliceVision/GlobalSfM.py @@ -10,6 +10,11 @@ class GlobalSfM(desc.CommandLineNode): commandLine = 'aliceVision_globalSfM {allParams}' size = desc.DynamicNodeSize('input') + documentation = ''' +Performs the Structure-From-Motion with a global approach. +It is known to be faster but less robust to challenging datasets than the Incremental approach. +''' + inputs = [ desc.File( name='input', @@ -47,7 +52,7 @@ class GlobalSfM(desc.CommandLineNode): label='Describer Types', description='Describer types used to describe an image.', value=['sift'], - values=['sift', 'sift_float', 'sift_upright', 'akaze', 'akaze_liop', 'akaze_mldb', 'cctag3', 'cctag4', + values=['sift', 'sift_float', 'sift_upright', 'dspsift', 'akaze', 'akaze_liop', 'akaze_mldb', 'cctag3', 'cctag4', 'sift_ocv', 'akaze_ocv'], exclusive=False, uid=[0], @@ -99,16 +104,23 @@ class GlobalSfM(desc.CommandLineNode): outputs = [ desc.File( name='output', - label='Output Folder', - description='', - value=desc.Node.internalFolder, + label='Output SfMData File', + description='Path to the output sfmdata file', + value=desc.Node.internalFolder + 'sfm.abc', uid=[], ), desc.File( - name='outSfMDataFilename', - label='Output SfMData File', - description='Path to the output sfmdata file', - value=desc.Node.internalFolder + 'SfmData.abc', + name='outputViewsAndPoses', + label='Output Poses', + description='''Path to the output sfmdata file with cameras (views and poses).''', + value=desc.Node.internalFolder + 'cameras.sfm', + uid=[], + ), + desc.File( + name='extraInfoFolder', + label='Output Folder', + description='Folder for intermediate reconstruction files and additional reconstruction information files.', + value=desc.Node.internalFolder, uid=[], ), ] diff --git a/meshroom/nodes/aliceVision/HDRIstitching.py b/meshroom/nodes/aliceVision/HDRIstitching.py deleted file mode 100644 index af81410e..00000000 --- a/meshroom/nodes/aliceVision/HDRIstitching.py +++ /dev/null @@ -1,89 +0,0 @@ -__version__ = "1.0" - -from meshroom.core import desc - - -class HDRIstitching(desc.CommandLineNode): - commandLine = 'aliceVision_utils_fisheyeProjection {allParams}' - - inputs = [ - desc.ListAttribute( - elementDesc=desc.File( - name='inputFile', - label='Input File/Folder', - description="", - value='', - uid=[0], - ), - name='input', - label='Input Folder', - description="List of fisheye images or folder containing them." - ), - desc.FloatParam( - name='blurWidth', - label='Blur Width', - description="Blur width of alpha channel for all fisheye (between 0 and 1). \n" - "Determine the transitions sharpness.", - value=0.2, - range=(0, 1, 0.1), - uid=[0], - ), - desc.ListAttribute( - elementDesc=desc.FloatParam( - name='imageXRotation', - label='Image X Rotation', - description="", - value=0, - range=(-20, 20, 1), - uid=[0], - ), - name='xRotation', - label='X Rotations', - description="Rotations in degree on axis X (horizontal axis) for each image.", - ), - desc.ListAttribute( - elementDesc=desc.FloatParam( - name='imageYRotation', - label='Image Y Rotation', - description="", - value=0, - range=(-30, 30, 5), - uid=[0], - ), - name='yRotation', - label='Y Rotations', - description="Rotations in degree on axis Y (vertical axis) for each image.", - ), - desc.ListAttribute( - elementDesc=desc.FloatParam( - name='imageZRotation', - label='Image Z Rotation', - description="", - value=0, - range=(-10, 10, 1), - uid=[0], - ), - name='zRotation', - label='Z Rotations', - description="Rotations in degree on axis Z (depth axis) for each image.", - ), - desc.ChoiceParam( - name='verboseLevel', - label='Verbose Level', - description="Verbosity level (fatal, error, warning, info, debug, trace).", - value='info', - values=['fatal', 'error', 'warning', 'info', 'debug', 'trace'], - exclusive=True, - uid=[], - ), - ] - - outputs = [ - desc.File( - name='output', - label='Output Panorama', - description="Output folder for panorama", - value=desc.Node.internalFolder, - uid=[], - ), - ] \ No newline at end of file diff --git a/meshroom/nodes/aliceVision/ImageMatching.py b/meshroom/nodes/aliceVision/ImageMatching.py index 3e1ffb40..2a1e60f8 100644 --- a/meshroom/nodes/aliceVision/ImageMatching.py +++ b/meshroom/nodes/aliceVision/ImageMatching.py @@ -1,4 +1,4 @@ -__version__ = "1.1" +__version__ = "2.0" import os from meshroom.core import desc @@ -8,10 +8,34 @@ class ImageMatching(desc.CommandLineNode): commandLine = 'aliceVision_imageMatching {allParams}' size = desc.DynamicNodeSize('input') + documentation = ''' +The goal of this node is to select the image pairs to match. The ambition is to find the images that are looking to the same areas of the scene. +Thanks to this node, the FeatureMatching node will only compute the matches between the selected image pairs. + +It provides multiple methods: + * **VocabularyTree** +It uses image retrieval techniques to find images that share some content without the cost of resolving all feature matches in details. +Each image is represented in a compact image descriptor which allows to compute the distance between all images descriptors very efficiently. +If your scene contains less than "Voc Tree: Minimal Number of Images", all image pairs will be selected. + * **Sequential** +If your input is a video sequence, you can use this option to link images between them over time. + * **SequentialAndVocabularyTree** +Combines sequential approach with Voc Tree to enable connections between keyframes at different times. + * **Exhaustive** +Export all image pairs. + * **Frustum** +If images have known poses, computes the intersection between cameras frustums to create the list of image pairs. + * **FrustumOrVocabularyTree** +If images have known poses, use frustum intersection else use VocabularuTree. + +## Online +[https://alicevision.org/#photogrammetry/image_matching](https://alicevision.org/#photogrammetry/image_matching) +''' + inputs = [ desc.File( name='input', - label='Input', + label='SfmData', description='SfMData file .', value='', uid=[0], @@ -31,9 +55,17 @@ class ImageMatching(desc.CommandLineNode): desc.ChoiceParam( name='method', label='Method', - description='Method used to select the image pairs to match.', + description='Method used to select the image pairs to match:\n' + ' * VocabularyTree: It uses image retrieval techniques to find images that share some content without the cost of resolving all \n' + 'feature matches in details. Each image is represented in a compact image descriptor which allows to compute the distance between all \n' + 'images descriptors very efficiently. If your scene contains less than "Voc Tree: Minimal Number of Images", all image pairs will be selected.\n' + ' * Sequential: If your input is a video sequence, you can use this option to link images between them over time.\n' + ' * SequentialAndVocabularyTree: Combines sequential approach with VocTree to enable connections between keyframes at different times.\n' + ' * Exhaustive: Export all image pairs.\n' + ' * Frustum: If images have known poses, computes the intersection between cameras frustums to create the list of image pairs.\n' + ' * FrustumOrVocabularyTree: If images have known poses, use frustum intersection else use VocabularyTree.\n', value='VocabularyTree', - values=['VocabularyTree', 'Sequential', 'SequentialAndVocabularyTree','Exhaustive','Frustum'], + values=['VocabularyTree', 'Sequential', 'SequentialAndVocabularyTree', 'Exhaustive', 'Frustum', 'FrustumOrVocabularyTree'], exclusive=True, uid=[0], ), @@ -43,6 +75,7 @@ class ImageMatching(desc.CommandLineNode): description='Input name for the vocabulary tree file.', value=os.environ.get('ALICEVISION_VOCTREE', ''), uid=[], + enabled=lambda node: 'VocabularyTree' in node.method.value, ), desc.File( name='weights', @@ -51,6 +84,7 @@ class ImageMatching(desc.CommandLineNode): value='', uid=[0], advanced=True, + enabled=lambda node: 'VocabularyTree' in node.method.value, ), desc.IntParam( name='minNbImages', @@ -60,6 +94,7 @@ class ImageMatching(desc.CommandLineNode): range=(0, 500, 1), uid=[0], advanced=True, + enabled=lambda node: 'VocabularyTree' in node.method.value, ), desc.IntParam( name='maxDescriptors', @@ -69,6 +104,7 @@ class ImageMatching(desc.CommandLineNode): range=(0, 100000, 1), uid=[0], advanced=True, + enabled=lambda node: 'VocabularyTree' in node.method.value, ), desc.IntParam( name='nbMatches', @@ -78,6 +114,7 @@ class ImageMatching(desc.CommandLineNode): range=(0, 1000, 1), uid=[0], advanced=True, + enabled=lambda node: 'VocabularyTree' in node.method.value, ), desc.IntParam( name='nbNeighbors', @@ -87,6 +124,7 @@ class ImageMatching(desc.CommandLineNode): range=(0, 1000, 1), uid=[0], advanced=True, + enabled=lambda node: 'Sequential' in node.method.value, ), desc.ChoiceParam( name='verboseLevel', @@ -102,7 +140,7 @@ class ImageMatching(desc.CommandLineNode): outputs = [ desc.File( name='output', - label='Output List File', + label='Image Pairs', description='Filepath to the output file with the list of selected image pairs.', value=desc.Node.internalFolder + 'imageMatches.txt', uid=[], diff --git a/meshroom/nodes/aliceVision/ImageMatchingMultiSfM.py b/meshroom/nodes/aliceVision/ImageMatchingMultiSfM.py index b5601c84..2d506227 100644 --- a/meshroom/nodes/aliceVision/ImageMatchingMultiSfM.py +++ b/meshroom/nodes/aliceVision/ImageMatchingMultiSfM.py @@ -9,6 +9,14 @@ class ImageMatchingMultiSfM(desc.CommandLineNode): # use both SfM inputs to define Node's size size = desc.MultiDynamicNodeSize(['input', 'inputB']) + documentation = ''' +The goal of this node is to select the image pairs to match in the context of an SfM augmentation. +The ambition is to find the images that are looking to the same areas of the scene. +Thanks to this node, the FeatureMatching node will only compute the matches between the selected image pairs. + +## Online +[https://alicevision.org/#photogrammetry/image_matching](https://alicevision.org/#photogrammetry/image_matching) +''' inputs = [ desc.File( name='input', diff --git a/meshroom/nodes/aliceVision/ImageProcessing.py b/meshroom/nodes/aliceVision/ImageProcessing.py new file mode 100644 index 00000000..653a1600 --- /dev/null +++ b/meshroom/nodes/aliceVision/ImageProcessing.py @@ -0,0 +1,339 @@ +__version__ = "3.0" + +from meshroom.core import desc + +import os.path + +def outputImagesValueFunct(attr): + basename = os.path.basename(attr.node.input.value) + fileStem = os.path.splitext(basename)[0] + inputExt = os.path.splitext(basename)[1] + outputExt = ('.' + attr.node.extension.value) if attr.node.extension.value else None + + if inputExt in ['.abc', '.sfm']: + # If we have an SfM in input + return desc.Node.internalFolder + '*' + (outputExt or '.*') + + if inputExt: + # if we have one or multiple files in input + return desc.Node.internalFolder + fileStem + (outputExt or inputExt) + + if '*' in fileStem: + # The fileStem of the input param is a regular expression, + # so even if there is no file extension, + # we consider that the expression represents files. + return desc.Node.internalFolder + fileStem + (outputExt or '.*') + + # No extension and no expression means that the input param is a folder path + return desc.Node.internalFolder + '*' + (outputExt or '.*') + + +class ImageProcessing(desc.CommandLineNode): + commandLine = 'aliceVision_utils_imageProcessing {allParams}' + size = desc.DynamicNodeSize('input') + # parallelization = desc.Parallelization(blockSize=40) + # commandLineRange = '--rangeStart {rangeStart} --rangeSize {rangeBlockSize}' + + documentation = ''' +Convert or apply filtering to the input images. +''' + + inputs = [ + desc.File( + name='input', + label='Input', + description='SfMData file input, image filenames or regex(es) on the image file path.\nsupported regex: \'#\' matches a single digit, \'@\' one or more digits, \'?\' one character and \'*\' zero or more.', + value='', + uid=[0], + ), + desc.ListAttribute( + elementDesc=desc.File( + name="inputFolder", + label="input Folder", + description="", + value="", + uid=[0], + ), + name="inputFolders", + label="Images input Folders", + description='Use images from specific folder(s).', + ), + desc.ListAttribute( + elementDesc=desc.StringParam( + name="metadataFolder", + label="Metadata Folder", + description="", + value="", + uid=[0], + ), + name="metadataFolders", + label="Metadata input Folders", + description='Use images metadata from specific folder(s).', + ), + desc.ChoiceParam( + name='extension', + label='Output File Extension', + description='Output Image File Extension.', + value='', + values=['', 'exr', 'jpg', 'tiff', 'png'], + exclusive=True, + uid=[0], + ), + desc.BoolParam( + name='reconstructedViewsOnly', + label='Only Reconstructed Views', + description='Process Only Reconstructed Views', + value=False, + uid=[0], + ), + desc.BoolParam( + name='fixNonFinite', + label='Fix Non-Finite', + description='Fix non-finite pixels based on neighboring pixels average.', + value=False, + uid=[0], + ), + desc.BoolParam( + name='exposureCompensation', + label='Exposure Compensation', + description='Exposure Compensation', + value=False, + uid=[0], + ), + desc.FloatParam( + name='scaleFactor', + label='ScaleFactor', + description='Scale Factor.', + value=1.0, + range=(0.0, 1.0, 0.01), + uid=[0], + ), + desc.FloatParam( + name='contrast', + label='Contrast', + description='Contrast.', + value=1.0, + range=(0.0, 100.0, 0.1), + uid=[0], + ), + desc.IntParam( + name='medianFilter', + label='Median Filter', + description='Median Filter.', + value=0, + range=(0, 10, 1), + uid=[0], + ), + desc.BoolParam( + name='fillHoles', + label='Fill Holes', + description='Fill holes based on the alpha channel.\n' + 'Note: It will enable fixNonFinite, as it is required for the image pyramid construction used to fill holes.', + value=False, + uid=[0], + ), + desc.GroupAttribute(name="sharpenFilter", label="Sharpen Filter", description="Sharpen Filtering Parameters.", joinChar=":", groupDesc=[ + desc.BoolParam( + name='sharpenFilterEnabled', + label='Enable', + description='Use sharpen.', + value=False, + uid=[0], + ), + desc.IntParam( + name='width', + label='Width', + description='Sharpen Width.', + value=3, + range=(1, 9, 2), + uid=[0], + enabled=lambda node: node.sharpenFilter.sharpenFilterEnabled.value, + ), + desc.FloatParam( + name='contrast', + label='Contrast', + description='Sharpen Contrast.', + value=1.0, + range=(0.0, 100.0, 0.1), + uid=[0], + enabled=lambda node: node.sharpenFilter.sharpenFilterEnabled.value, + ), + desc.FloatParam( + name='threshold', + label='Threshold', + description='Sharpen Threshold.', + value=0.0, + range=(0.0, 1.0, 0.01), + uid=[0], + enabled=lambda node: node.sharpenFilter.sharpenFilterEnabled.value, + ), + ]), + desc.GroupAttribute(name="bilateralFilter", label="Bilateral Filter", description="Bilateral Filtering Parameters.", joinChar=":", groupDesc=[ + desc.BoolParam( + name='bilateralFilterEnabled', + label='Enable', + description='Bilateral Filter.', + value=False, + uid=[0], + ), + desc.IntParam( + name='bilateralFilterDistance', + label='Distance', + description='Diameter of each pixel neighborhood that is used during bilateral filtering.\nCould be very slow for large filters, so it is recommended to use 5.', + value=0, + range=(0, 9, 1), + uid=[0], + enabled=lambda node: node.bilateralFilter.bilateralFilterEnabled.value, + ), + desc.FloatParam( + name='bilateralFilterSigmaSpace', + label='Sigma Coordinate Space', + description='Bilateral Filter sigma in the coordinate space.', + value=0.0, + range=(0.0, 150.0, 0.01), + uid=[0], + enabled=lambda node: node.bilateralFilter.bilateralFilterEnabled.value, + ), + desc.FloatParam( + name='bilateralFilterSigmaColor', + label='Sigma Color Space', + description='Bilateral Filter sigma in the color space.', + value=0.0, + range=(0.0, 150.0, 0.01), + uid=[0], + enabled=lambda node: node.bilateralFilter.bilateralFilterEnabled.value, + ), + ]), + desc.GroupAttribute(name="claheFilter", label="Clahe Filter", description="Clahe Filtering Parameters.", joinChar=":", groupDesc=[ + desc.BoolParam( + name='claheEnabled', + label='Enable', + description='Use Contrast Limited Adaptive Histogram Equalization (CLAHE) Filter.', + value=False, + uid=[0], + ), + desc.FloatParam( + name='claheClipLimit', + label='Clip Limit', + description='Sets Threshold For Contrast Limiting.', + value=4.0, + range=(0.0, 8.0, 1.0), + uid=[0], + enabled=lambda node: node.claheFilter.claheEnabled.value, + ), + desc.IntParam( + name='claheTileGridSize', + label='Tile Grid Size', + description='Sets Size Of Grid For Histogram Equalization. Input Image Will Be Divided Into Equally Sized Rectangular Tiles.', + value=8, + range=(4, 64, 4), + uid=[0], + enabled=lambda node: node.claheFilter.claheEnabled.value, + ), + ]), + desc.GroupAttribute(name="noiseFilter", label="Noise Filter", description="Noise Filtering Parameters.", joinChar=":", groupDesc=[ + desc.BoolParam( + name='noiseEnabled', + label='Enable', + description='Add Noise.', + value=False, + uid=[0], + ), + desc.ChoiceParam( + name='noiseMethod', + label='Method', + description=" * method: There are several noise types to choose from:\n" + " * uniform: adds noise values uninformly distributed on range [A,B).\n" + " * gaussian: adds Gaussian (normal distribution) noise values with mean value A and standard deviation B.\n" + " * salt: changes to value A a portion of pixels given by B.\n", + value='uniform', + values=['uniform', 'gaussian', 'salt'], + exclusive=True, + uid=[0], + enabled=lambda node: node.noiseFilter.noiseEnabled.value, + ), + desc.FloatParam( + name='noiseA', + label='A', + description='Parameter that have a different interpretation depending on the method chosen.', + value=0.0, + range=(0.0, 1.0, 0.0001), + uid=[0], + enabled=lambda node: node.noiseFilter.noiseEnabled.value, + ), + desc.FloatParam( + name='noiseB', + label='B', + description='Parameter that have a different interpretation depending on the method chosen.', + value=1.0, + range=(0.0, 1.0, 0.0001), + uid=[0], + enabled=lambda node: node.noiseFilter.noiseEnabled.value, + ), + desc.BoolParam( + name='noiseMono', + label='Mono', + description='If is Checked, a single noise value will be applied to all channels otherwise a separate noise value will be computed for each channel.', + value=True, + uid=[0], + enabled=lambda node: node.noiseFilter.noiseEnabled.value, + ), + ]), + desc.ChoiceParam( + name='outputFormat', + label='Output Image Format', + description='Allows you to choose the format of the output image.', + value='rgba', + values=['rgba', 'rgb', 'grayscale'], + exclusive=True, + uid=[0], + ), + desc.ChoiceParam( + name='storageDataType', + label='Storage Data Type for EXR output', + description='Storage image data type:\n' + ' * float: Use full floating point (32 bits per channel)\n' + ' * half: Use half float (16 bits per channel)\n' + ' * halfFinite: Use half float, but clamp values to avoid non-finite values\n' + ' * auto: Use half float if all values can fit, else use full float\n', + value='float', + values=['float', 'half', 'halfFinite', 'auto'], + exclusive=True, + uid=[0], + ), + desc.ChoiceParam( + name='verboseLevel', + label='Verbose Level', + description='verbosity level (fatal, error, warning, info, debug, trace).', + value='info', + values=['fatal', 'error', 'warning', 'info', 'debug', 'trace'], + exclusive=True, + uid=[], + ) + ] + + outputs = [ + desc.File( + name='outSfMData', + label='Output sfmData', + description='Output sfmData.', + value=lambda attr: (desc.Node.internalFolder + os.path.basename(attr.node.input.value)) if (os.path.splitext(attr.node.input.value)[1] in ['.abc', '.sfm']) else '', + uid=[], + group='', # do not export on the command line + ), + desc.File( + name='output', + label='Output Folder', + description='Output Images Folder.', + value=desc.Node.internalFolder, + uid=[], + ), + desc.File( + name='outputImages', + label='Output Images', + description='Output Image Files.', + value= outputImagesValueFunct, + group='', # do not export on the command line + uid=[], + ), + ] diff --git a/meshroom/nodes/aliceVision/KeyframeSelection.py b/meshroom/nodes/aliceVision/KeyframeSelection.py index 8d85dff8..987af423 100644 --- a/meshroom/nodes/aliceVision/KeyframeSelection.py +++ b/meshroom/nodes/aliceVision/KeyframeSelection.py @@ -7,6 +7,13 @@ from meshroom.core import desc class KeyframeSelection(desc.CommandLineNode): commandLine = 'aliceVision_utils_keyframeSelection {allParams}' + documentation = ''' +Allows to extract keyframes from a video and insert metadata. +It can extract frames from a synchronized multi-cameras rig. + +You can extract frames at regular interval by configuring only the min/maxFrameStep. +''' + inputs = [ desc.ListAttribute( elementDesc=desc.File( diff --git a/meshroom/nodes/aliceVision/LdrToHdrCalibration.py b/meshroom/nodes/aliceVision/LdrToHdrCalibration.py new file mode 100644 index 00000000..2c16734e --- /dev/null +++ b/meshroom/nodes/aliceVision/LdrToHdrCalibration.py @@ -0,0 +1,227 @@ +__version__ = "3.0" + +import json + +from meshroom.core import desc + +def findMetadata(d, keys, defaultValue): + v = None + for key in keys: + v = d.get(key, None) + k = key.lower() + if v is not None: + return v + for dk, dv in d.items(): + dkm = dk.lower().replace(" ", "") + if dkm == key.lower(): + return dv + dkm = dkm.split(":")[-1] + dkm = dkm.split("/")[-1] + if dkm == k: + return dv + return defaultValue + + + +class LdrToHdrCalibration(desc.CommandLineNode): + commandLine = 'aliceVision_LdrToHdrCalibration {allParams}' + size = desc.DynamicNodeSize('input') + + cpu = desc.Level.INTENSIVE + ram = desc.Level.NORMAL + + documentation = ''' + Calibrate LDR to HDR response curve from samples +''' + + inputs = [ + desc.File( + name='input', + label='Input', + description='SfMData file.', + value='', + uid=[0], + ), + desc.File( + name='samples', + label='Samples folder', + description='Samples folder', + value=desc.Node.internalFolder, + uid=[0], + ), + desc.IntParam( + name='userNbBrackets', + label='Number of Brackets', + description='Number of exposure brackets per HDR image (0 for automatic detection).', + value=0, + range=(0, 15, 1), + uid=[], + group='user', # not used directly on the command line + ), + desc.IntParam( + name='nbBrackets', + label='Automatic Nb Brackets', + description='Number of exposure brackets used per HDR image. It is detected automatically from input Viewpoints metadata if "userNbBrackets" is 0, else it is equal to "userNbBrackets".', + value=0, + range=(0, 10, 1), + uid=[0], + ), + desc.BoolParam( + name='byPass', + label='Bypass', + description="Bypass HDR creation and use the medium bracket as the source for the next steps", + value=False, + uid=[0], + group='internal', + enabled= lambda node: node.nbBrackets.value != 1, + ), + desc.ChoiceParam( + name='calibrationMethod', + label='Calibration Method', + description="Method used for camera calibration \n" + " * Linear: Disable the calibration and assumes a linear Camera Response Function. If images are encoded in a known colorspace (like sRGB for JPEG), the images will be automatically converted to linear. \n" + " * Debevec: This is the standard method for HDR calibration. \n" + " * Grossberg: Based on learned database of cameras, it allows to reduce the CRF to few parameters while keeping all the precision. \n" + " * Laguerre: Simple but robust method estimating the minimal number of parameters. \n" + " * Robertson: First method for HDR calibration in the literature. \n", + values=['linear', 'debevec', 'grossberg', 'laguerre'], + value='debevec', + exclusive=True, + uid=[0], + enabled= lambda node: node.byPass.enabled and not node.byPass.value, + ), + desc.ChoiceParam( + name='calibrationWeight', + label='Calibration Weight', + description="Weight function used to calibrate camera response \n" + " * default (automatically selected according to the calibrationMethod) \n" + " * gaussian \n" + " * triangle \n" + " * plateau", + value='default', + values=['default', 'gaussian', 'triangle', 'plateau'], + exclusive=True, + uid=[0], + enabled= lambda node: node.byPass.enabled and not node.byPass.value, + ), + desc.IntParam( + name='channelQuantizationPower', + label='Channel Quantization Power', + description='Quantization level like 8 bits or 10 bits.', + value=10, + range=(8, 14, 1), + uid=[0], + advanced=True, + enabled= lambda node: node.byPass.enabled and not node.byPass.value, + ), + desc.IntParam( + name='maxTotalPoints', + label='Max Number of Points', + description='Max number of points used from the sampling. This ensures that the number of pixels values extracted by the sampling\n' + 'can be managed by the calibration step (in term of computation time and memory usage).', + value=1000000, + range=(8, 10000000, 1000), + uid=[0], + advanced=True, + enabled= lambda node: node.byPass.enabled and not node.byPass.value, + ), + desc.ChoiceParam( + name='verboseLevel', + label='Verbose Level', + description='verbosity level (fatal, error, warning, info, debug, trace).', + value='info', + values=['fatal', 'error', 'warning', 'info', 'debug', 'trace'], + exclusive=True, + uid=[], + ) + ] + + outputs = [ + desc.File( + name='response', + label='Output response File', + description='Path to the output response file', + value=desc.Node.internalFolder + 'response.csv', + uid=[], + ) + ] + + def processChunk(self, chunk): + if chunk.node.nbBrackets.value == 1 or chunk.node.byPass.value: + return + super(LdrToHdrCalibration, self).processChunk(chunk) + + @classmethod + def update(cls, node): + if not isinstance(node.nodeDesc, cls): + raise ValueError("Node {} is not an instance of type {}".format(node, cls)) + # TODO: use Node version for this test + if 'userNbBrackets' not in node.getAttributes().keys(): + # Old version of the node + return + if node.userNbBrackets.value != 0: + node.nbBrackets.value = node.userNbBrackets.value + return + # logging.info("[LDRToHDR] Update start: version:" + str(node.packageVersion)) + cameraInitOutput = node.input.getLinkParam(recursive=True) + if not cameraInitOutput: + node.nbBrackets.value = 0 + return + if not cameraInitOutput.node.hasAttribute('viewpoints'): + if cameraInitOutput.node.hasAttribute('input'): + cameraInitOutput = cameraInitOutput.node.input.getLinkParam(recursive=True) + if cameraInitOutput and cameraInitOutput.node and cameraInitOutput.node.hasAttribute('viewpoints'): + viewpoints = cameraInitOutput.node.viewpoints.value + else: + # No connected CameraInit + node.nbBrackets.value = 0 + return + + # logging.info("[LDRToHDR] Update start: nb viewpoints:" + str(len(viewpoints))) + inputs = [] + for viewpoint in viewpoints: + jsonMetadata = viewpoint.metadata.value + if not jsonMetadata: + # no metadata, we cannot found the number of brackets + node.nbBrackets.value = 0 + return + d = json.loads(jsonMetadata) + fnumber = findMetadata(d, ["FNumber", "Exif:ApertureValue", "ApertureValue", "Aperture"], "") + shutterSpeed = findMetadata(d, ["Exif:ShutterSpeedValue", "ShutterSpeedValue", "ShutterSpeed"], "") + iso = findMetadata(d, ["Exif:ISOSpeedRatings", "ISOSpeedRatings", "ISO"], "") + if not fnumber and not shutterSpeed: + # If one image without shutter or fnumber, we cannot found the number of brackets. + # We assume that there is no multi-bracketing, so nothing to do. + node.nbBrackets.value = 1 + return + inputs.append((viewpoint.path.value, (fnumber, shutterSpeed, iso))) + inputs.sort() + + exposureGroups = [] + exposures = [] + for path, exp in inputs: + if exposures and exp != exposures[-1] and exp == exposures[0]: + exposureGroups.append(exposures) + exposures = [exp] + else: + exposures.append(exp) + exposureGroups.append(exposures) + exposures = None + bracketSizes = set() + if len(exposureGroups) == 1: + if len(set(exposureGroups[0])) == 1: + # Single exposure and multiple views + node.nbBrackets.value = 1 + else: + # Single view and multiple exposures + node.nbBrackets.value = len(exposureGroups[0]) + else: + for expGroup in exposureGroups: + bracketSizes.add(len(expGroup)) + if len(bracketSizes) == 1: + node.nbBrackets.value = bracketSizes.pop() + # logging.info("[LDRToHDR] nb bracket size:" + str(node.nbBrackets.value)) + else: + node.nbBrackets.value = 0 + # logging.info("[LDRToHDR] Update end") + diff --git a/meshroom/nodes/aliceVision/LDRToHDR.py b/meshroom/nodes/aliceVision/LdrToHdrMerge.py similarity index 52% rename from meshroom/nodes/aliceVision/LDRToHDR.py rename to meshroom/nodes/aliceVision/LdrToHdrMerge.py index 56e2f247..af6f0435 100644 --- a/meshroom/nodes/aliceVision/LDRToHDR.py +++ b/meshroom/nodes/aliceVision/LdrToHdrMerge.py @@ -1,49 +1,59 @@ -__version__ = "2.0" +__version__ = "4.0" import json -import os from meshroom.core import desc - -class DividedInputNodeSize(desc.DynamicNodeSize): - """ - The LDR2HDR will reduce the amount of views in the SfMData. - This class converts the number of LDR input views into the number of HDR output views. - """ - def __init__(self, param, divParam): - super(DividedInputNodeSize, self).__init__(param) - self._divParam = divParam - def computeSize(self, node): - s = super(DividedInputNodeSize, self).computeSize(node) - divParam = node.attribute(self._divParam) - if divParam.value == 0: - return s - return s / divParam.value +def findMetadata(d, keys, defaultValue): + v = None + for key in keys: + v = d.get(key, None) + k = key.lower() + if v is not None: + return v + for dk, dv in d.items(): + dkm = dk.lower().replace(" ", "") + if dkm == key.lower(): + return dv + dkm = dkm.split(":")[-1] + dkm = dkm.split("/")[-1] + if dkm == k: + return dv + return defaultValue -class LDRToHDR(desc.CommandLineNode): - commandLine = 'aliceVision_convertLDRToHDR {allParams}' - size = DividedInputNodeSize('input', 'nbBrackets') +class LdrToHdrMerge(desc.CommandLineNode): + commandLine = 'aliceVision_LdrToHdrMerge {allParams}' + size = desc.DynamicNodeSize('input') + parallelization = desc.Parallelization(blockSize=2) + commandLineRange = '--rangeStart {rangeStart} --rangeSize {rangeBlockSize}' - cpu = desc.Level.INTENSIVE - ram = desc.Level.NORMAL + documentation = ''' + Calibrate LDR to HDR response curve from samples +''' inputs = [ desc.File( name='input', label='Input', - description="SfM Data File", + description='SfMData file.', + value='', + uid=[0], + ), + desc.File( + name='response', + label='Response file', + description='Response file', value='', uid=[0], ), desc.IntParam( name='userNbBrackets', label='Number of Brackets', - description='Number of exposure brackets per HDR image (0 for automatic).', + description='Number of exposure brackets per HDR image (0 for automatic detection).', value=0, range=(0, 15, 1), - uid=[0], + uid=[], group='user', # not used directly on the command line ), desc.IntParam( @@ -52,97 +62,29 @@ class LDRToHDR(desc.CommandLineNode): description='Number of exposure brackets used per HDR image. It is detected automatically from input Viewpoints metadata if "userNbBrackets" is 0, else it is equal to "userNbBrackets".', value=0, range=(0, 10, 1), - uid=[], - advanced=True, - ), - desc.FloatParam( - name='highlightCorrectionFactor', - label='Highlights Correction', - description='Pixels saturated in all input images have a partial information about their real luminance.\n' - 'We only know that the value should be >= to the standard hdr fusion.\n' - 'This parameter allows to perform a post-processing step to put saturated pixels to a constant ' - 'value defined by the `highlightsMaxLuminance` parameter.\n' - 'This parameter is float to enable to weight this correction.', - value=1.0, - range=(0.0, 1.0, 0.01), uid=[0], ), - desc.FloatParam( - name='highlightTargetLux', - label='Highlight Target Luminance (Lux)', - description='This is an arbitrary target value (in Lux) used to replace the unknown luminance value of the saturated pixels.\n' - '\n' - 'Some Outdoor Reference Light Levels:\n' - ' * 120,000 lux : Brightest sunlight\n' - ' * 110,000 lux : Bright sunlight\n' - ' * 20,000 lux : Shade illuminated by entire clear blue sky, midday\n' - ' * 1,000 lux : Typical overcast day, midday\n' - ' * 400 lux : Sunrise or sunset on a clear day\n' - ' * 40 lux : Fully overcast, sunset/sunrise\n' - '\n' - 'Some Indoor Reference Light Levels:\n' - ' * 20000 lux : Max Usually Used Indoor\n' - ' * 750 lux : Supermarkets\n' - ' * 500 lux : Office Work\n' - ' * 150 lux : Home\n', - value=120000.0, - range=(1000.0, 150000.0, 1.0), - uid=[0], - ), - desc.BoolParam( - name='fisheyeLens', - label='Fisheye Lens', - description="Enable if a fisheye lens has been used.\n " - "This will improve the estimation of the Camera's Response Function by considering only the pixels in the center of the image\n" - "and thus ignore undefined/noisy pixels outside the circle defined by the fisheye lens.", - value=False, - uid=[0], - ), - desc.BoolParam( - name='calibrationRefineExposures', - label='Refine Exposures', - description="Refine exposures provided by metadata (shutter speed, f-number, iso). Only available for 'laguerre' calibration method.", - value=False, + desc.IntParam( + name='offsetRefBracketIndex', + label='Offset Ref Bracket Index', + description='Zero to use the center bracket. +N to use a more exposed bracket or -N to use a less exposed backet.', + value=1, + range=(-4, 4, 1), uid=[0], + enabled= lambda node: node.nbBrackets.value != 1, ), desc.BoolParam( name='byPass', - label='bypass convert', - description="Bypass HDR creation and use the medium bracket as the source for the next steps", + label='Bypass', + description="Bypass HDR creation and use the medium bracket as the source for the next steps.", value=False, uid=[0], - ), - desc.ChoiceParam( - name='calibrationMethod', - label='Calibration Method', - description="Method used for camera calibration \n" - " * linear \n" - " * robertson \n" - " * debevec \n" - " * grossberg \n" - " * laguerre", - values=['linear', 'robertson', 'debevec', 'grossberg', 'laguerre'], - value='debevec', - exclusive=True, - uid=[0], - ), - desc.ChoiceParam( - name='calibrationWeight', - label='Calibration Weight', - description="Weight function used to calibrate camera response \n" - " * default (automatically selected according to the calibrationMethod) \n" - " * gaussian \n" - " * triangle \n" - " * plateau", - value='default', - values=['default', 'gaussian', 'triangle', 'plateau'], - exclusive=True, - uid=[0], + enabled= lambda node: node.nbBrackets.value != 1, ), desc.ChoiceParam( name='fusionWeight', label='Fusion Weight', - description="Weight function used to fuse all LDR images together \n" + description="Weight function used to fuse all LDR images together:\n" " * gaussian \n" " * triangle \n" " * plateau", @@ -150,24 +92,7 @@ class LDRToHDR(desc.CommandLineNode): values=['gaussian', 'triangle', 'plateau'], exclusive=True, uid=[0], - ), - desc.IntParam( - name='calibrationNbPoints', - label='Calibration Nb Points', - description='Internal number of points used for calibration.', - value=0, - range=(0, 10000000, 1000), - uid=[0], - advanced=True, - ), - desc.IntParam( - name='calibrationDownscale', - label='Calibration Downscale', - description='Scaling factor applied to images before calibration of the response function to reduce the impact of misalignment.', - value=4, - range=(1, 16, 1), - uid=[0], - advanced=True, + enabled= lambda node: node.byPass.enabled and not node.byPass.value, ), desc.IntParam( name='channelQuantizationPower', @@ -177,21 +102,71 @@ class LDRToHDR(desc.CommandLineNode): range=(8, 14, 1), uid=[0], advanced=True, + enabled= lambda node: node.byPass.enabled and not node.byPass.value, + ), + desc.FloatParam( + name='highlightCorrectionFactor', + label='Highlights Correction', + description='Pixels saturated in all input images have a partial information about their real luminance.\n' + 'We only know that the value should be >= to the standard hdr fusion.\n' + 'This parameter allows to perform a post-processing step to put saturated pixels to a constant\n' + 'value defined by the `highlightsMaxLuminance` parameter.\n' + 'This parameter is float to enable to weight this correction.', + value=1.0, + range=(0.0, 1.0, 0.01), + uid=[0], + enabled= lambda node: node.byPass.enabled and not node.byPass.value, + ), + desc.FloatParam( + name='highlightTargetLux', + label='Highlight Target Luminance (Lux)', + description='This is an arbitrary target value (in Lux) used to replace the unknown luminance value of the saturated pixels.\n' + '\n' + 'Some Outdoor Reference Light Levels:\n' + ' * 120,000 lux: Brightest sunlight\n' + ' * 110,000 lux: Bright sunlight\n' + ' * 20,000 lux: Shade illuminated by entire clear blue sky, midday\n' + ' * 1,000 lux: Typical overcast day, midday\n' + ' * 400 lux: Sunrise or sunset on a clear day\n' + ' * 40 lux: Fully overcast, sunset/sunrise\n' + '\n' + 'Some Indoor Reference Light Levels:\n' + ' * 20000 lux: Max Usually Used Indoor\n' + ' * 750 lux: Supermarkets\n' + ' * 500 lux: Office Work\n' + ' * 150 lux: Home\n', + value=120000.0, + range=(1000.0, 150000.0, 1.0), + uid=[0], + enabled= lambda node: node.byPass.enabled and not node.byPass.value and node.highlightCorrectionFactor.value != 0, + ), + desc.ChoiceParam( + name='storageDataType', + label='Storage Data Type', + description='Storage image data type:\n' + ' * float: Use full floating point (32 bits per channel)\n' + ' * half: Use half float (16 bits per channel)\n' + ' * halfFinite: Use half float, but clamp values to avoid non-finite values\n' + ' * auto: Use half float if all values can fit, else use full float\n', + value='float', + values=['float', 'half', 'halfFinite', 'auto'], + exclusive=True, + uid=[0], ), desc.ChoiceParam( name='verboseLevel', label='Verbose Level', - description='Verbosity level (fatal, error, warning, info, debug, trace).', + description='verbosity level (fatal, error, warning, info, debug, trace).', value='info', values=['fatal', 'error', 'warning', 'info', 'debug', 'trace'], exclusive=True, uid=[], - ), + ) ] outputs = [ desc.File( - name='outSfMDataFilename', + name='outSfMData', label='Output SfMData File', description='Path to the output sfmdata file', value=desc.Node.internalFolder + 'sfmData.sfm', @@ -211,11 +186,19 @@ class LDRToHDR(desc.CommandLineNode): node.nbBrackets.value = node.userNbBrackets.value return # logging.info("[LDRToHDR] Update start: version:" + str(node.packageVersion)) - cameraInitOutput = node.input.getLinkParam() + cameraInitOutput = node.input.getLinkParam(recursive=True) if not cameraInitOutput: node.nbBrackets.value = 0 return - viewpoints = cameraInitOutput.node.viewpoints.value + if not cameraInitOutput.node.hasAttribute('viewpoints'): + if cameraInitOutput.node.hasAttribute('input'): + cameraInitOutput = cameraInitOutput.node.input.getLinkParam(recursive=True) + if cameraInitOutput and cameraInitOutput.node and cameraInitOutput.node.hasAttribute('viewpoints'): + viewpoints = cameraInitOutput.node.viewpoints.value + else: + # No connected CameraInit + node.nbBrackets.value = 0 + return # logging.info("[LDRToHDR] Update start: nb viewpoints:" + str(len(viewpoints))) inputs = [] @@ -226,12 +209,13 @@ class LDRToHDR(desc.CommandLineNode): node.nbBrackets.value = 0 return d = json.loads(jsonMetadata) - fnumber = d.get("FNumber", d.get("Exif:ApertureValue", "")) - shutterSpeed = d.get("Exif:ShutterSpeedValue", "") # also "ExposureTime"? - iso = d.get("Exif:ISOSpeedRatings", "") + fnumber = findMetadata(d, ["FNumber", "Exif:ApertureValue", "ApertureValue", "Aperture"], "") + shutterSpeed = findMetadata(d, ["Exif:ShutterSpeedValue", "ShutterSpeedValue", "ShutterSpeed"], "") + iso = findMetadata(d, ["Exif:ISOSpeedRatings", "ISOSpeedRatings", "ISO"], "") if not fnumber and not shutterSpeed: - # if one image without shutter or fnumber, we cannot found the number of brackets - node.nbBrackets.value = 0 + # If one image without shutter or fnumber, we cannot found the number of brackets. + # We assume that there is no multi-bracketing, so nothing to do. + node.nbBrackets.value = 1 return inputs.append((viewpoint.path.value, (fnumber, shutterSpeed, iso))) inputs.sort() @@ -247,13 +231,20 @@ class LDRToHDR(desc.CommandLineNode): exposureGroups.append(exposures) exposures = None bracketSizes = set() - for expGroup in exposureGroups: - bracketSizes.add(len(expGroup)) - if len(bracketSizes) == 1: - node.nbBrackets.value = bracketSizes.pop() - # logging.info("[LDRToHDR] nb bracket size:" + str(node.nbBrackets.value)) + if len(exposureGroups) == 1: + if len(set(exposureGroups[0])) == 1: + # Single exposure and multiple views + node.nbBrackets.value = 1 + else: + # Single view and multiple exposures + node.nbBrackets.value = len(exposureGroups[0]) else: - node.nbBrackets.value = 0 + for expGroup in exposureGroups: + bracketSizes.add(len(expGroup)) + if len(bracketSizes) == 1: + node.nbBrackets.value = bracketSizes.pop() + # logging.info("[LDRToHDR] nb bracket size:" + str(node.nbBrackets.value)) + else: + node.nbBrackets.value = 0 # logging.info("[LDRToHDR] Update end") - diff --git a/meshroom/nodes/aliceVision/LdrToHdrSampling.py b/meshroom/nodes/aliceVision/LdrToHdrSampling.py new file mode 100644 index 00000000..8c862466 --- /dev/null +++ b/meshroom/nodes/aliceVision/LdrToHdrSampling.py @@ -0,0 +1,233 @@ +__version__ = "4.0" + +import json + +from meshroom.core import desc + + +def findMetadata(d, keys, defaultValue): + v = None + for key in keys: + v = d.get(key, None) + k = key.lower() + if v is not None: + return v + for dk, dv in d.items(): + dkm = dk.lower().replace(" ", "") + if dkm == key.lower(): + return dv + dkm = dkm.split(":")[-1] + dkm = dkm.split("/")[-1] + if dkm == k: + return dv + return defaultValue + + +class DividedInputNodeSize(desc.DynamicNodeSize): + ''' + The LDR2HDR will reduce the amount of views in the SfMData. + This class converts the number of LDR input views into the number of HDR output views. + ''' + def __init__(self, param, divParam): + super(DividedInputNodeSize, self).__init__(param) + self._divParam = divParam + def computeSize(self, node): + s = super(DividedInputNodeSize, self).computeSize(node) + divParam = node.attribute(self._divParam) + if divParam.value == 0: + return s + return s / divParam.value + + +class LdrToHdrSampling(desc.CommandLineNode): + commandLine = 'aliceVision_LdrToHdrSampling {allParams}' + size = DividedInputNodeSize('input', 'nbBrackets') + parallelization = desc.Parallelization(blockSize=2) + commandLineRange = '--rangeStart {rangeStart} --rangeSize {rangeBlockSize}' + + documentation = ''' + Sample pixels from Low range images for HDR creation +''' + + inputs = [ + desc.File( + name='input', + label='Input', + description='SfMData file.', + value='', + uid=[0], + ), + desc.IntParam( + name='userNbBrackets', + label='Number of Brackets', + description='Number of exposure brackets per HDR image (0 for automatic detection).', + value=0, + range=(0, 15, 1), + uid=[], + group='user', # not used directly on the command line + ), + desc.IntParam( + name='nbBrackets', + label='Automatic Nb Brackets', + description='Number of exposure brackets used per HDR image. It is detected automatically from input Viewpoints metadata if "userNbBrackets" is 0, else it is equal to "userNbBrackets".', + value=0, + range=(0, 10, 1), + uid=[0], + ), + desc.BoolParam( + name='byPass', + label='Bypass', + description="Bypass HDR creation and use the medium bracket as the source for the next steps", + value=False, + uid=[0], + group='internal', + enabled= lambda node: node.nbBrackets.value != 1, + ), + desc.IntParam( + name='channelQuantizationPower', + label='Channel Quantization Power', + description='Quantization level like 8 bits or 10 bits.', + value=10, + range=(8, 14, 1), + uid=[0], + advanced=True, + enabled= lambda node: node.byPass.enabled and not node.byPass.value, + ), + desc.IntParam( + name='blockSize', + label='Block Size', + description='Size of the image tile to extract a sample.', + value=256, + range=(8, 1024, 1), + uid=[0], + advanced=True, + enabled= lambda node: node.byPass.enabled and not node.byPass.value, + ), + desc.IntParam( + name='radius', + label='Patch Radius', + description='Radius of the patch used to analyze the sample statistics.', + value=5, + range=(0, 10, 1), + uid=[0], + advanced=True, + enabled= lambda node: node.byPass.enabled and not node.byPass.value, + ), + desc.IntParam( + name='maxCountSample', + label='Max Number of Samples', + description='Max number of samples per image group.', + value=200, + range=(10, 1000, 10), + uid=[0], + advanced=True, + enabled= lambda node: node.byPass.enabled and not node.byPass.value, + ), + desc.BoolParam( + name='debug', + label='Export Debug Files', + description="Export debug files to analyze the sampling strategy.", + value=False, + uid=[], + enabled= lambda node: node.byPass.enabled and not node.byPass.value, + ), + desc.ChoiceParam( + name='verboseLevel', + label='Verbose Level', + description='verbosity level (fatal, error, warning, info, debug, trace).', + value='info', + values=['fatal', 'error', 'warning', 'info', 'debug', 'trace'], + exclusive=True, + uid=[], + ) + ] + + outputs = [ + desc.File( + name='output', + label='Output Folder', + description='Output path for the samples.', + value=desc.Node.internalFolder, + uid=[], + ), + ] + + def processChunk(self, chunk): + if chunk.node.nbBrackets.value == 1 or chunk.node.byPass.value: + return + super(LdrToHdrSampling, self).processChunk(chunk) + + @classmethod + def update(cls, node): + if not isinstance(node.nodeDesc, cls): + raise ValueError("Node {} is not an instance of type {}".format(node, cls)) + # TODO: use Node version for this test + if 'userNbBrackets' not in node.getAttributes().keys(): + # Old version of the node + return + if node.userNbBrackets.value != 0: + node.nbBrackets.value = node.userNbBrackets.value + return + # logging.info("[LDRToHDR] Update start: version:" + str(node.packageVersion)) + cameraInitOutput = node.input.getLinkParam(recursive=True) + if not cameraInitOutput: + node.nbBrackets.value = 0 + return + if not cameraInitOutput.node.hasAttribute('viewpoints'): + if cameraInitOutput.node.hasAttribute('input'): + cameraInitOutput = cameraInitOutput.node.input.getLinkParam(recursive=True) + if cameraInitOutput and cameraInitOutput.node and cameraInitOutput.node.hasAttribute('viewpoints'): + viewpoints = cameraInitOutput.node.viewpoints.value + else: + # No connected CameraInit + node.nbBrackets.value = 0 + return + + # logging.info("[LDRToHDR] Update start: nb viewpoints:" + str(len(viewpoints))) + inputs = [] + for viewpoint in viewpoints: + jsonMetadata = viewpoint.metadata.value + if not jsonMetadata: + # no metadata, we cannot found the number of brackets + node.nbBrackets.value = 0 + return + d = json.loads(jsonMetadata) + fnumber = findMetadata(d, ["FNumber", "Exif:ApertureValue", "ApertureValue", "Aperture"], "") + shutterSpeed = findMetadata(d, ["Exif:ShutterSpeedValue", "ShutterSpeedValue", "ShutterSpeed"], "") + iso = findMetadata(d, ["Exif:ISOSpeedRatings", "ISOSpeedRatings", "ISO"], "") + if not fnumber and not shutterSpeed: + # If one image without shutter or fnumber, we cannot found the number of brackets. + # We assume that there is no multi-bracketing, so nothing to do. + node.nbBrackets.value = 1 + return + inputs.append((viewpoint.path.value, (fnumber, shutterSpeed, iso))) + inputs.sort() + + exposureGroups = [] + exposures = [] + for path, exp in inputs: + if exposures and exp != exposures[-1] and exp == exposures[0]: + exposureGroups.append(exposures) + exposures = [exp] + else: + exposures.append(exp) + exposureGroups.append(exposures) + exposures = None + bracketSizes = set() + if len(exposureGroups) == 1: + if len(set(exposureGroups[0])) == 1: + # Single exposure and multiple views + node.nbBrackets.value = 1 + else: + # Single view and multiple exposures + node.nbBrackets.value = len(exposureGroups[0]) + else: + for expGroup in exposureGroups: + bracketSizes.add(len(expGroup)) + if len(bracketSizes) == 1: + node.nbBrackets.value = bracketSizes.pop() + # logging.info("[LDRToHDR] nb bracket size:" + str(node.nbBrackets.value)) + else: + node.nbBrackets.value = 0 + # logging.info("[LDRToHDR] Update end") + diff --git a/meshroom/nodes/aliceVision/LightingEstimation.py b/meshroom/nodes/aliceVision/LightingEstimation.py new file mode 100644 index 00000000..dc5b188f --- /dev/null +++ b/meshroom/nodes/aliceVision/LightingEstimation.py @@ -0,0 +1,89 @@ +__version__ = "1.0" + +from meshroom.core import desc + + +class LightingEstimation(desc.CommandLineNode): + commandLine = 'aliceVision_utils_lightingEstimation {allParams}' + + inputs = [ + desc.File( + name='input', + label='Input', + description='SfMData file.', + value='', + uid=[0], + ), + desc.File( + name="depthMapsFilterFolder", + label='Filtered Depth Maps Folder', + description='Input filtered depth maps folder', + value='', + uid=[0], + ), + desc.File( + name='imagesFolder', + label='Images Folder', + description='Use images from a specific folder instead of those specify in the SfMData file.\nFilename should be the image uid.', + value='', + uid=[0], + ), + desc.ChoiceParam( + name='lightingEstimationMode', + label='Lighting Estimation Mode', + description='Lighting Estimation Mode.', + value='global', + values=['global', 'per_image'], + exclusive=True, + uid=[0], + advanced=True, + ), + desc.ChoiceParam( + name='lightingColor', + label='Lighting Color Mode', + description='Lighting Color Mode.', + value='RGB', + values=['RGB', 'Luminance'], + exclusive=True, + uid=[0], + advanced=True, + ), + desc.ChoiceParam( + name='albedoEstimationName', + label='Albedo Estimation Name', + description='Albedo estimation method used for light estimation.', + value='constant', + values=['constant', 'picture', 'median_filter', 'blur_filter'], + exclusive=True, + uid=[0], + advanced=True, + ), + desc.IntParam( + name='albedoEstimationFilterSize', + label='Albedo Estimation Filter Size', + description='Albedo filter size for estimation method using filter.', + value=3, + range=(0, 100, 1), + uid=[0], + advanced=True, + ), + desc.ChoiceParam( + name='verboseLevel', + label='Verbose Level', + description='Verbosity level (fatal, error, warning, info, debug, trace).', + value='info', + values=['fatal', 'error', 'warning', 'info', 'debug', 'trace'], + exclusive=True, + uid=[], + ), + ] + + outputs = [ + desc.File( + name='output', + label='Output Folder', + description='Folder for output lighting vector files.', + value=desc.Node.internalFolder, + uid=[], + ), + ] diff --git a/meshroom/nodes/aliceVision/MeshDecimate.py b/meshroom/nodes/aliceVision/MeshDecimate.py index 280e9319..8b928350 100644 --- a/meshroom/nodes/aliceVision/MeshDecimate.py +++ b/meshroom/nodes/aliceVision/MeshDecimate.py @@ -9,6 +9,10 @@ class MeshDecimate(desc.CommandLineNode): cpu = desc.Level.NORMAL ram = desc.Level.NORMAL + documentation = ''' +This node allows to reduce the density of the Mesh. +''' + inputs = [ desc.File( name="input", diff --git a/meshroom/nodes/aliceVision/MeshDenoising.py b/meshroom/nodes/aliceVision/MeshDenoising.py index 807dc7df..1b6863ed 100644 --- a/meshroom/nodes/aliceVision/MeshDenoising.py +++ b/meshroom/nodes/aliceVision/MeshDenoising.py @@ -6,6 +6,11 @@ from meshroom.core import desc class MeshDenoising(desc.CommandLineNode): commandLine = 'aliceVision_meshDenoising {allParams}' + documentation = ''' +This experimental node allows to reduce noise from a Mesh. +for now, the parameters are difficult to control and vary a lot from one dataset to another. +''' + inputs = [ desc.File( name='input', diff --git a/meshroom/nodes/aliceVision/MeshFiltering.py b/meshroom/nodes/aliceVision/MeshFiltering.py index 4a1ae7ea..94f1db50 100644 --- a/meshroom/nodes/aliceVision/MeshFiltering.py +++ b/meshroom/nodes/aliceVision/MeshFiltering.py @@ -6,10 +6,15 @@ from meshroom.core import desc class MeshFiltering(desc.CommandLineNode): commandLine = 'aliceVision_meshFiltering {allParams}' + documentation = ''' +This node applies a Laplacian filtering to remove local defects from the raw Meshing cut. + +''' + inputs = [ desc.File( name='inputMesh', - label='Input Mesh', + label='Mesh', description='''Input Mesh (OBJ file format).''', value='', uid=[0], @@ -60,7 +65,7 @@ class MeshFiltering(desc.CommandLineNode): outputs = [ desc.File( name='outputMesh', - label='Output Mesh', + label='Mesh', description='''Output mesh (OBJ file format).''', value=desc.Node.internalFolder + 'mesh.obj', uid=[], diff --git a/meshroom/nodes/aliceVision/MeshResampling.py b/meshroom/nodes/aliceVision/MeshResampling.py index e6966366..64c4cab7 100644 --- a/meshroom/nodes/aliceVision/MeshResampling.py +++ b/meshroom/nodes/aliceVision/MeshResampling.py @@ -9,6 +9,10 @@ class MeshResampling(desc.CommandLineNode): cpu = desc.Level.NORMAL ram = desc.Level.NORMAL + documentation = ''' +This node allows to recompute the mesh surface with a new topology and uniform density. +''' + inputs = [ desc.File( name="input", diff --git a/meshroom/nodes/aliceVision/Meshing.py b/meshroom/nodes/aliceVision/Meshing.py index 449ef008..29cb664f 100644 --- a/meshroom/nodes/aliceVision/Meshing.py +++ b/meshroom/nodes/aliceVision/Meshing.py @@ -1,4 +1,4 @@ -__version__ = "3.0" +__version__ = "6.0" from meshroom.core import desc @@ -9,10 +9,21 @@ class Meshing(desc.CommandLineNode): cpu = desc.Level.INTENSIVE ram = desc.Level.INTENSIVE + documentation = ''' +This node creates a dense geometric surface representation of the scene. + +First, it fuses all the depth maps into a global dense point cloud with an adaptive resolution. +It then performs a 3D Delaunay tetrahedralization and a voting procedure is done to compute weights on cells and weights on facets connecting the cells. +A Graph Cut Max-Flow is applied to optimally cut the volume. This cut represents the extracted mesh surface. + +## Online +[https://alicevision.org/#photogrammetry/meshing](https://alicevision.org/#photogrammetry/meshing) +''' + inputs = [ desc.File( name='input', - label='Input', + label='SfmData', description='SfMData file.', value='', uid=[0], @@ -20,16 +31,104 @@ class Meshing(desc.CommandLineNode): desc.File( name="depthMapsFolder", label='Depth Maps Folder', - description='Input depth maps folder', + description='Input depth maps folder.', value='', uid=[0], ), - desc.File( - name="depthMapsFilterFolder", - label='Filtered Depth Maps Folder', - description='Input filtered depth maps folder', - value='', + desc.BoolParam( + name='useBoundingBox', + label='Custom Bounding Box', + description='Edit the meshing bounding box. If enabled, it takes priority over the Estimate From SfM option. Parameters can be adjusted in advanced settings.', + value=False, uid=[0], + group='' + ), + desc.GroupAttribute( + name="boundingBox", + label="Bounding Box Settings", + description="Translation, rotation and scale of the bounding box.", + groupDesc=[ + desc.GroupAttribute( + name="bboxTranslation", + label="Translation", + description="Position in space.", + groupDesc=[ + desc.FloatParam( + name="x", label="x", description="X Offset", + value=0.0, + uid=[0], + range=(-20.0, 20.0, 0.01) + ), + desc.FloatParam( + name="y", label="y", description="Y Offset", + value=0.0, + uid=[0], + range=(-20.0, 20.0, 0.01) + ), + desc.FloatParam( + name="z", label="z", description="Z Offset", + value=0.0, + uid=[0], + range=(-20.0, 20.0, 0.01) + ) + ], + joinChar="," + ), + desc.GroupAttribute( + name="bboxRotation", + label="Euler Rotation", + description="Rotation in Euler degrees.", + groupDesc=[ + desc.FloatParam( + name="x", label="x", description="Euler X Rotation", + value=0.0, + uid=[0], + range=(-90.0, 90.0, 1) + ), + desc.FloatParam( + name="y", label="y", description="Euler Y Rotation", + value=0.0, + uid=[0], + range=(-180.0, 180.0, 1) + ), + desc.FloatParam( + name="z", label="z", description="Euler Z Rotation", + value=0.0, + uid=[0], + range=(-180.0, 180.0, 1) + ) + ], + joinChar="," + ), + desc.GroupAttribute( + name="bboxScale", + label="Scale", + description="Scale of the bounding box.", + groupDesc=[ + desc.FloatParam( + name="x", label="x", description="X Scale", + value=1.0, + uid=[0], + range=(0.0, 20.0, 0.01) + ), + desc.FloatParam( + name="y", label="y", description="Y Scale", + value=1.0, + uid=[0], + range=(0.0, 20.0, 0.01) + ), + desc.FloatParam( + name="z", label="z", description="Z Scale", + value=1.0, + uid=[0], + range=(0.0, 20.0, 0.01) + ) + ], + joinChar="," + ) + ], + joinChar=",", + enabled=lambda node: node.useBoundingBox.value, ), desc.BoolParam( name='estimateSpaceFromSfM', @@ -47,6 +146,7 @@ class Meshing(desc.CommandLineNode): range=(0, 100, 1), uid=[0], advanced=True, + enabled=lambda node: node.estimateSpaceFromSfM.value, ), desc.FloatParam( name='estimateSpaceMinObservationAngle', @@ -55,6 +155,7 @@ class Meshing(desc.CommandLineNode): value=10, range=(0, 120, 1), uid=[0], + enabled=lambda node: node.estimateSpaceFromSfM.value, ), desc.IntParam( name='maxInputPoints', @@ -238,14 +339,14 @@ class Meshing(desc.CommandLineNode): outputs = [ desc.File( name="outputMesh", - label="Output Mesh", + label="Mesh", description="Output mesh (OBJ file format).", value="{cache}/{nodeType}/{uid0}/mesh.obj", uid=[], ), desc.File( name="output", - label="Output Dense Point Cloud", + label="Dense SfMData", description="Output dense point cloud with visibilities (SfMData file format).", value="{cache}/{nodeType}/{uid0}/densePointCloud.abc", uid=[], diff --git a/meshroom/nodes/aliceVision/PanoramaCompositing.py b/meshroom/nodes/aliceVision/PanoramaCompositing.py index 34af53ad..7a754fe1 100644 --- a/meshroom/nodes/aliceVision/PanoramaCompositing.py +++ b/meshroom/nodes/aliceVision/PanoramaCompositing.py @@ -10,11 +10,28 @@ class PanoramaCompositing(desc.CommandLineNode): commandLine = 'aliceVision_panoramaCompositing {allParams}' size = desc.DynamicNodeSize('input') + cpu = desc.Level.INTENSIVE + ram = desc.Level.INTENSIVE + + documentation = ''' +Once the images have been transformed geometrically (in PanoramaWarping), +they have to be fused together in a single panorama image which looks like a single photography. +The Multi-band Blending method provides the best quality. It averages the pixel values using multiple bands in the frequency domain. +Multiple cameras are contributing to the low frequencies and only the best one contributes to the high frequencies. +''' + inputs = [ desc.File( name='input', - label='Input', - description="Panorama Warping result", + label='Input SfMData', + description="Input SfMData.", + value='', + uid=[0], + ), + desc.File( + name='warpingFolder', + label='Warping Folder', + description="Panorama Warping results", value='', uid=[0], ), @@ -31,12 +48,49 @@ class PanoramaCompositing(desc.CommandLineNode): desc.ChoiceParam( name='compositerType', label='Compositer Type', - description='Which compositer should be used to blend images', + description='Which compositer should be used to blend images:\n' + ' * multiband: high quality transition by fusing images by frequency bands\n' + ' * replace: debug option with straight transitions\n' + ' * alpha: debug option with linear transitions\n', value='multiband', values=['replace', 'alpha', 'multiband'], exclusive=True, uid=[0] ), + desc.BoolParam( + name='useGraphCut', + label='Use Smart Seams', + description='Use a graphcut algorithm to optmize seams for better transitions between images.', + value=True, + uid=[0], + ), + desc.ChoiceParam( + name='storageDataType', + label='Storage Data Type', + description='Storage image data type:\n' + ' * float: Use full floating point (32 bits per channel)\n' + ' * half: Use half float (16 bits per channel)\n' + ' * halfFinite: Use half float, but clamp values to avoid non-finite values\n' + ' * auto: Use half float if all values can fit, else use full float\n', + value='float', + values=['float', 'half', 'halfFinite', 'auto'], + exclusive=True, + uid=[0], + ), + desc.ChoiceParam( + name='overlayType', + label='Overlay Type', + description='Overlay on top of panorama to analyze transitions:\n' + ' * none: no overlay\n' + ' * borders: display image borders\n' + ' * seams: display transitions between images\n' + ' * all: display borders and seams\n', + value='none', + values=['none', 'borders', 'seams', 'all'], + exclusive=True, + advanced=True, + uid=[0] + ), desc.ChoiceParam( name='verboseLevel', label='Verbose Level', diff --git a/meshroom/nodes/aliceVision/PanoramaEstimation.py b/meshroom/nodes/aliceVision/PanoramaEstimation.py index 6aaff58f..b89ae370 100644 --- a/meshroom/nodes/aliceVision/PanoramaEstimation.py +++ b/meshroom/nodes/aliceVision/PanoramaEstimation.py @@ -10,6 +10,10 @@ class PanoramaEstimation(desc.CommandLineNode): commandLine = 'aliceVision_panoramaEstimation {allParams}' size = desc.DynamicNodeSize('input') + documentation = ''' +Estimate relative camera rotations between input images. +''' + inputs = [ desc.File( name='input', @@ -47,21 +51,12 @@ class PanoramaEstimation(desc.CommandLineNode): label='Describer Types', description='Describer types used to describe an image.', value=['sift'], - values=['sift', 'sift_float', 'sift_upright', 'akaze', 'akaze_liop', 'akaze_mldb', 'cctag3', 'cctag4', + values=['sift', 'sift_float', 'sift_upright', 'dspsift', 'akaze', 'akaze_liop', 'akaze_mldb', 'cctag3', 'cctag4', 'sift_ocv', 'akaze_ocv'], exclusive=False, uid=[0], joinChar=',', ), - desc.IntParam( - name='orientation', - label='Orientation', - description='Orientation', - value=0, - range=(0, 6, 1), - uid=[0], - advanced=True, - ), desc.FloatParam( name='offsetLongitude', label='Longitude offset (deg.)', @@ -69,7 +64,6 @@ class PanoramaEstimation(desc.CommandLineNode): value=0.0, range=(-180.0, 180.0, 1.0), uid=[0], - advanced=True, ), desc.FloatParam( name='offsetLatitude', @@ -78,7 +72,6 @@ class PanoramaEstimation(desc.CommandLineNode): value=0.0, range=(-90.0, 90.0, 1.0), uid=[0], - advanced=True, ), desc.ChoiceParam( name='rotationAveraging', @@ -97,9 +90,10 @@ class PanoramaEstimation(desc.CommandLineNode): label='Relative Rotation Method', description="Method for relative rotation :\n" " * from essential matrix\n" - " * from homography matrix", - values=['essential_matrix', 'homography_matrix'], - value='homography_matrix', + " * from homography matrix\n" + " * from rotation matrix", + values=['essential_matrix', 'homography_matrix', 'rotation_matrix'], + value='rotation_matrix', exclusive=True, uid=[0], advanced=True, @@ -113,13 +107,47 @@ class PanoramaEstimation(desc.CommandLineNode): ), desc.BoolParam( name='lockAllIntrinsics', - label='Force Lock of All Intrinsic Camera Parameters.', + label='Force Lock of All Intrinsics', description='Force to keep constant all the intrinsics parameters of the cameras (focal length, \n' 'principal point, distortion if any) during the reconstruction.\n' 'This may be helpful if the input cameras are already fully calibrated.', value=False, uid=[0], ), + desc.FloatParam( + name='maxAngleToPrior', + label='Max Angle To Priors (deg.)', + description='''Maximal angle allowed regarding the input prior (in degrees).''', + value=20.0, + range=(0.0, 360.0, 1.0), + uid=[0], + advanced=True, + ), + desc.FloatParam( + name='maxAngularError', + label='Max Angular Error (deg.)', + description='''Maximal angular error in global rotation averging (in degrees).''', + value=100.0, + range=(0.0, 360.0, 1.0), + uid=[0], + advanced=True, + ), + desc.BoolParam( + name='intermediateRefineWithFocal', + label='Intermediate Refine: Focal', + description='Intermediate refine with rotation and focal length only.', + value=False, + uid=[0], + advanced=True, + ), + desc.BoolParam( + name='intermediateRefineWithFocalDist', + label='Intermediate Refine: Focal And Distortion', + description='Intermediate refine with rotation, focal length and distortion.', + value=False, + uid=[0], + advanced=True, + ), desc.ChoiceParam( name='verboseLevel', label='Verbose Level', @@ -134,16 +162,16 @@ class PanoramaEstimation(desc.CommandLineNode): outputs = [ desc.File( name='output', - label='Output Folder', - description='', - value=desc.Node.internalFolder, + label='Output SfMData File', + description='Path to the output sfmdata file', + value=desc.Node.internalFolder + 'panorama.abc', uid=[], ), desc.File( - name='outSfMDataFilename', - label='Output SfMData File', - description='Path to the output sfmdata file', - value=desc.Node.internalFolder + 'sfmData.abc', + name='outputViewsAndPoses', + label='Output Poses', + description='''Path to the output sfmdata file with cameras (views and poses).''', + value=desc.Node.internalFolder + 'cameras.sfm', uid=[], ), ] diff --git a/meshroom/nodes/aliceVision/PanoramaExternalInfo.py b/meshroom/nodes/aliceVision/PanoramaExternalInfo.py deleted file mode 100644 index 4fca9880..00000000 --- a/meshroom/nodes/aliceVision/PanoramaExternalInfo.py +++ /dev/null @@ -1,60 +0,0 @@ -__version__ = "1.0" - -import json -import os - -from meshroom.core import desc - - -class PanoramaExternalInfo(desc.CommandLineNode): - commandLine = 'aliceVision_panoramaExternalInfo {allParams}' - size = desc.DynamicNodeSize('input') - - inputs = [ - desc.File( - name='input', - label='Input', - description="SfM Data File", - value='', - uid=[0], - ), - desc.File( - name='config', - label='Xml Config', - description="XML Data File", - value='', - uid=[0], - ), - desc.ListAttribute( - elementDesc=desc.File( - name='matchesFolder', - label='Matches Folder', - description="", - value='', - uid=[0], - ), - name='matchesFolders', - label='Matches Folders', - description="Folder(s) in which computed matches are stored. (WORKAROUND for valid Tractor graph submission)", - group='forDependencyOnly', - ), - desc.ChoiceParam( - name='verboseLevel', - label='Verbose Level', - description='Verbosity level (fatal, error, warning, info, debug, trace).', - value='info', - values=['fatal', 'error', 'warning', 'info', 'debug', 'trace'], - exclusive=True, - uid=[], - ), - ] - - outputs = [ - desc.File( - name='outSfMDataFilename', - label='Output SfMData File', - description='Path to the output sfmdata file', - value=desc.Node.internalFolder + 'sfmData.abc', - uid=[], - ) - ] diff --git a/meshroom/nodes/aliceVision/PanoramaInit.py b/meshroom/nodes/aliceVision/PanoramaInit.py new file mode 100644 index 00000000..740f791a --- /dev/null +++ b/meshroom/nodes/aliceVision/PanoramaInit.py @@ -0,0 +1,130 @@ +__version__ = "2.0" + +from meshroom.core import desc + + +class PanoramaInit(desc.CommandLineNode): + commandLine = 'aliceVision_panoramaInit {allParams}' + size = desc.DynamicNodeSize('input') + + documentation = ''' +This node allows to setup the Panorama: + +1/ Enables the initialization the cameras from known position in an XML file (provided by +["Roundshot VR Drive"](https://www.roundshot.com/xml_1/internet/fr/application/d394/d395/f396.cfm) ). + +2/ Enables to setup Full Fisheye Optics (to use an Equirectangular camera model). + +3/ To automatically detects the Fisheye Circle (radius + center) in input images or manually adjust it. + +''' + + inputs = [ + desc.File( + name='input', + label='Input', + description="SfM Data File", + value='', + uid=[0], + ), + desc.File( + name='config', + label='Xml Config', + description="XML Data File", + value='', + uid=[0], + ), + desc.ListAttribute( + elementDesc=desc.File( + name='dependency', + label='', + description="", + value='', + uid=[], + ), + name='dependency', + label='Dependency', + description="Folder(s) in which computed features are stored. (WORKAROUND for valid Tractor graph submission)", + group='forDependencyOnly', # not a command line argument + ), + desc.BoolParam( + name='useFisheye', + label='Full Fisheye', + description='To declare a full fisheye panorama setup', + value=False, + uid=[0], + ), + desc.BoolParam( + name='estimateFisheyeCircle', + label='Estimate Fisheye Circle', + description='Automatically estimate the Fisheye Circle center and radius instead of using user values.', + value=True, + uid=[0], + enabled=lambda node: node.useFisheye.value, + ), + desc.GroupAttribute( + name="fisheyeCenterOffset", + label="Fisheye Center", + description="Center of the Fisheye circle (XY offset to the center in pixels).", + groupDesc=[ + desc.FloatParam( + name="fisheyeCenterOffset_x", label="x", description="X Offset in pixels", + value=0.0, + uid=[0], + range=(-1000.0, 10000.0, 1.0)), + desc.FloatParam( + name="fisheyeCenterOffset_y", label="y", description="Y Offset in pixels", + value=0.0, + uid=[0], + range=(-1000.0, 10000.0, 1.0)), + ], + group=None, # skip group from command line + enabled=lambda node: node.useFisheye.value and not node.estimateFisheyeCircle.value, + ), + desc.FloatParam( + name='fisheyeRadius', + label='Radius', + description='Fisheye visibillity circle radius (% of image shortest side).', + value=96.0, + range=(0.0, 150.0, 0.01), + uid=[0], + enabled=lambda node: node.useFisheye.value and not node.estimateFisheyeCircle.value, + ), + desc.ChoiceParam( + name='inputAngle', + label='input Angle offset', + description='Add a rotation to the input XML given poses (CCW).', + value='None', + values=['None', 'rotate90', 'rotate180', 'rotate270'], + exclusive=True, + uid=[0] + ), + desc.BoolParam( + name='debugFisheyeCircleEstimation', + label='Debug Fisheye Circle Detection', + description='Debug fisheye circle detection.', + value=False, + uid=[0], + enabled=lambda node: node.useFisheye.value, + advanced=True, + ), + desc.ChoiceParam( + name='verboseLevel', + label='Verbose Level', + description='Verbosity level (fatal, error, warning, info, debug, trace).', + value='info', + values=['fatal', 'error', 'warning', 'info', 'debug', 'trace'], + exclusive=True, + uid=[], + ), + ] + + outputs = [ + desc.File( + name='outSfMData', + label='Output SfMData File', + description='Path to the output sfmdata file', + value=desc.Node.internalFolder + 'sfmData.sfm', + uid=[], + ) + ] diff --git a/meshroom/nodes/aliceVision/PanoramaPrepareImages.py b/meshroom/nodes/aliceVision/PanoramaPrepareImages.py new file mode 100644 index 00000000..67a6357b --- /dev/null +++ b/meshroom/nodes/aliceVision/PanoramaPrepareImages.py @@ -0,0 +1,43 @@ +__version__ = "1.1" + +from meshroom.core import desc + +import os.path + + +class PanoramaPrepareImages(desc.CommandLineNode): + commandLine = 'aliceVision_panoramaPrepareImages {allParams}' + size = desc.DynamicNodeSize('input') + + documentation = ''' +Prepare images for Panorama pipeline: ensures that images orientations are coherent. +''' + + inputs = [ + desc.File( + name='input', + label='Input', + description='SfMData file.', + value='', + uid=[0], + ), + desc.ChoiceParam( + name='verboseLevel', + label='Verbose Level', + description='verbosity level (fatal, error, warning, info, debug, trace).', + value='info', + values=['fatal', 'error', 'warning', 'info', 'debug', 'trace'], + exclusive=True, + uid=[], + ) + ] + + outputs = [ + desc.File( + name='output', + label='Output sfmData', + description='Output sfmData.', + value=lambda attr: desc.Node.internalFolder + os.path.basename(attr.node.input.value), + uid=[], + ), + ] diff --git a/meshroom/nodes/aliceVision/PanoramaWarping.py b/meshroom/nodes/aliceVision/PanoramaWarping.py index a127fe35..37642eac 100644 --- a/meshroom/nodes/aliceVision/PanoramaWarping.py +++ b/meshroom/nodes/aliceVision/PanoramaWarping.py @@ -10,6 +10,13 @@ class PanoramaWarping(desc.CommandLineNode): commandLine = 'aliceVision_panoramaWarping {allParams}' size = desc.DynamicNodeSize('input') + parallelization = desc.Parallelization(blockSize=5) + commandLineRange = '--rangeStart {rangeStart} --rangeSize {rangeBlockSize}' + + documentation = ''' +Compute the image warping for each input image in the panorama coordinate system. +''' + inputs = [ desc.File( name='input', @@ -18,14 +25,59 @@ class PanoramaWarping(desc.CommandLineNode): value='', uid=[0], ), + desc.BoolParam( + name='estimateResolution', + label='Estimate Resolution', + description='Estimate output panorama resolution automatically based on the input images resolution.', + value=True, + uid=[0], + group=None, # skip group from command line + ), desc.IntParam( name='panoramaWidth', label='Panorama Width', - description='Panorama width (pixels). 0 For automatic size', + description='Choose the output panorama width (in pixels).', value=10000, range=(0, 50000, 1000), + uid=[0], + enabled=lambda node: (not node.estimateResolution.value), + ), + desc.IntParam( + name='percentUpscale', + label='Upscale Ratio', + description='Percentage of upscaled pixels.\n' + '\n' + 'How many percent of the pixels will be upscaled (compared to its original resolution):\n' + ' * 0: all pixels will be downscaled\n' + ' * 50: on average the input resolution is kept (optimal to reduce over/under-sampling)\n' + ' * 100: all pixels will be upscaled\n', + value=50, + range=(0, 100, 1), + enabled=lambda node: (node.estimateResolution.value), uid=[0] ), + desc.IntParam( + name='maxPanoramaWidth', + label='Max Panorama Width', + description='Choose the maximal output panorama width (in pixels). Zero means no limit.', + value=35000, + range=(0, 100000, 1000), + uid=[0], + enabled=lambda node: (node.estimateResolution.value), + ), + desc.ChoiceParam( + name='storageDataType', + label='Storage Data Type', + description='Storage image data type:\n' + ' * float: Use full floating point (32 bits per channel)\n' + ' * half: Use half float (16 bits per channel)\n' + ' * halfFinite: Use half float, but clamp values to avoid non-finite values\n' + ' * auto: Use half float if all values can fit, else use full float\n', + value='float', + values=['float', 'half', 'halfFinite', 'auto'], + exclusive=True, + uid=[0], + ), desc.ChoiceParam( name='verboseLevel', label='Verbose Level', diff --git a/meshroom/nodes/aliceVision/PrepareDenseScene.py b/meshroom/nodes/aliceVision/PrepareDenseScene.py index 816305d6..65cc7ff4 100644 --- a/meshroom/nodes/aliceVision/PrepareDenseScene.py +++ b/meshroom/nodes/aliceVision/PrepareDenseScene.py @@ -9,10 +9,14 @@ class PrepareDenseScene(desc.CommandLineNode): parallelization = desc.Parallelization(blockSize=40) commandLineRange = '--rangeStart {rangeStart} --rangeSize {rangeBlockSize}' + documentation = ''' +This node export undistorted images so the depth map and texturing can be computed on Pinhole images without distortion. +''' + inputs = [ desc.File( name='input', - label='Input', + label='SfMData', description='''SfMData file.''', value='', uid=[0], @@ -89,14 +93,14 @@ class PrepareDenseScene(desc.CommandLineNode): outputs = [ desc.File( name='output', - label='Output', + label='Images Folder', description='''Output folder.''', value=desc.Node.internalFolder, uid=[], ), desc.File( name='outputUndistorted', - label='Undistorted images', + label='Undistorted Images', description='List of undistorted images.', value=desc.Node.internalFolder + '*.{outputFileTypeValue}', uid=[], diff --git a/meshroom/nodes/aliceVision/Publish.py b/meshroom/nodes/aliceVision/Publish.py index 447bd65d..556499f9 100644 --- a/meshroom/nodes/aliceVision/Publish.py +++ b/meshroom/nodes/aliceVision/Publish.py @@ -10,6 +10,11 @@ import os class Publish(desc.Node): size = desc.DynamicNodeSize('inputFiles') + + documentation = ''' +This node allows to copy files into a specific folder. +''' + inputs = [ desc.ListAttribute( elementDesc=desc.File( diff --git a/meshroom/nodes/aliceVision/SfMAlignment.py b/meshroom/nodes/aliceVision/SfMAlignment.py index 798ce1c5..0b210051 100644 --- a/meshroom/nodes/aliceVision/SfMAlignment.py +++ b/meshroom/nodes/aliceVision/SfMAlignment.py @@ -1,12 +1,26 @@ -__version__ = "1.0" +__version__ = "2.0" from meshroom.core import desc +import os.path + class SfMAlignment(desc.CommandLineNode): commandLine = 'aliceVision_utils_sfmAlignment {allParams}' size = desc.DynamicNodeSize('input') + documentation = ''' +This node allows to change the coordinate system of one SfM scene to align it on another one. + +The alignment can be based on: + * from_cameras_viewid: Align cameras in both SfM on the specified viewId + * from_cameras_poseid: Align cameras in both SfM on the specified poseId + * from_cameras_filepath: Align cameras with a filepath matching, using 'fileMatchingPattern' + * from_cameras_metadata: Align cameras with matching metadata, using 'metadataMatchingList' + * from_markers: Align from markers with the same Id + +''' + inputs = [ desc.File( name='input', @@ -95,9 +109,16 @@ class SfMAlignment(desc.CommandLineNode): outputs = [ desc.File( name='output', - label='Output', - description='''Aligned SfMData file .''', - value=desc.Node.internalFolder + 'alignedSfM.abc', + label='Output SfMData File', + description='SfMData file.', + value=lambda attr: desc.Node.internalFolder + (os.path.splitext(os.path.basename(attr.node.input.value))[0] or 'sfmData') + '.abc', + uid=[], + ), + desc.File( + name='outputViewsAndPoses', + label='Output Poses', + description='''Path to the output sfmdata file with cameras (views and poses).''', + value=desc.Node.internalFolder + 'cameras.sfm', uid=[], ), ] diff --git a/meshroom/nodes/aliceVision/SfMTransfer.py b/meshroom/nodes/aliceVision/SfMTransfer.py index a30695ca..caf0c30a 100644 --- a/meshroom/nodes/aliceVision/SfMTransfer.py +++ b/meshroom/nodes/aliceVision/SfMTransfer.py @@ -1,12 +1,18 @@ -__version__ = "1.0" +__version__ = "2.0" from meshroom.core import desc +import os.path + class SfMTransfer(desc.CommandLineNode): commandLine = 'aliceVision_utils_sfmTransfer {allParams}' size = desc.DynamicNodeSize('input') + documentation = ''' +This node allows to transfer poses and/or intrinsics form one SfM scene onto another one. +''' + inputs = [ desc.File( name='input', @@ -28,9 +34,10 @@ class SfMTransfer(desc.CommandLineNode): description="Matching Method:\n" " * from_viewid: Align cameras with same view Id\n" " * from_filepath: Align cameras with a filepath matching, using 'fileMatchingPattern'\n" - " * from_metadata: Align cameras with matching metadata, using 'metadataMatchingList'\n", + " * from_metadata: Align cameras with matching metadata, using 'metadataMatchingList'\n" + " * from_intrinsicid: Copy intrinsics parameters\n", value='from_viewid', - values=['from_viewid', 'from_filepath', 'from_metadata'], + values=['from_viewid', 'from_filepath', 'from_metadata', 'from_intrinsicid'], exclusive=True, uid=[0], ), @@ -86,9 +93,16 @@ class SfMTransfer(desc.CommandLineNode): outputs = [ desc.File( name='output', - label='Output', + label='Output SfMData File', description='SfMData file.', - value=desc.Node.internalFolder + 'sfmData.abc', + value=lambda attr: desc.Node.internalFolder + (os.path.splitext(os.path.basename(attr.node.input.value))[0] or 'sfmData') + '.abc', + uid=[], + ), + desc.File( + name='outputViewsAndPoses', + label='Output Poses', + description='''Path to the output sfmdata file with cameras (views and poses).''', + value=desc.Node.internalFolder + 'cameras.sfm', uid=[], ), ] diff --git a/meshroom/nodes/aliceVision/SfMTransform.py b/meshroom/nodes/aliceVision/SfMTransform.py index 96592aa0..cfaa2097 100644 --- a/meshroom/nodes/aliceVision/SfMTransform.py +++ b/meshroom/nodes/aliceVision/SfMTransform.py @@ -1,12 +1,26 @@ -__version__ = "1.1" +__version__ = "3.0" from meshroom.core import desc +import os.path + class SfMTransform(desc.CommandLineNode): commandLine = 'aliceVision_utils_sfmTransform {allParams}' size = desc.DynamicNodeSize('input') + documentation = ''' +This node allows to change the coordinate system of one SfM scene. + +The transformation can be based on: + * transformation: Apply a given transformation + * auto_from_cameras: Fit all cameras into a box [-1,1] + * auto_from_landmarks: Fit all landmarks into a box [-1,1] + * from_single_camera: Use a specific camera as the origin of the coordinate system + * from_markers: Align specific markers to custom coordinates + +''' + inputs = [ desc.File( name='input', @@ -20,12 +34,13 @@ class SfMTransform(desc.CommandLineNode): label='Transformation Method', description="Transformation method:\n" " * transformation: Apply a given transformation\n" + " * manual: Apply the gizmo transformation (show the transformed input)\n" " * auto_from_cameras: Use cameras\n" " * auto_from_landmarks: Use landmarks\n" " * from_single_camera: Use a specific camera as the origin of the coordinate system\n" " * from_markers: Align specific markers to custom coordinates", value='auto_from_landmarks', - values=['transformation', 'auto_from_cameras', 'auto_from_landmarks', 'from_single_camera', 'from_markers'], + values=['transformation', 'manual', 'auto_from_cameras', 'auto_from_landmarks', 'from_single_camera', 'from_markers'], exclusive=True, uid=[0], ), @@ -37,13 +52,83 @@ class SfMTransform(desc.CommandLineNode): " * from_single_camera: Camera UID or simplified regular expression to match image filepath (like '*camera2*.jpg')", value='', uid=[0], + enabled=lambda node: node.method.value == "transformation" or node.method.value == "from_single_camera", + ), + desc.GroupAttribute( + name="manualTransform", + label="Manual Transform (Gizmo)", + description="Translation, rotation (Euler ZXY) and uniform scale.", + groupDesc=[ + desc.GroupAttribute( + name="manualTranslation", + label="Translation", + description="Translation in space.", + groupDesc=[ + desc.FloatParam( + name="x", label="x", description="X Offset", + value=0.0, + uid=[0], + range=(-20.0, 20.0, 0.01) + ), + desc.FloatParam( + name="y", label="y", description="Y Offset", + value=0.0, + uid=[0], + range=(-20.0, 20.0, 0.01) + ), + desc.FloatParam( + name="z", label="z", description="Z Offset", + value=0.0, + uid=[0], + range=(-20.0, 20.0, 0.01) + ) + ], + joinChar="," + ), + desc.GroupAttribute( + name="manualRotation", + label="Euler Rotation", + description="Rotation in Euler degrees.", + groupDesc=[ + desc.FloatParam( + name="x", label="x", description="Euler X Rotation", + value=0.0, + uid=[0], + range=(-90.0, 90.0, 1) + ), + desc.FloatParam( + name="y", label="y", description="Euler Y Rotation", + value=0.0, + uid=[0], + range=(-180.0, 180.0, 1) + ), + desc.FloatParam( + name="z", label="z", description="Euler Z Rotation", + value=0.0, + uid=[0], + range=(-180.0, 180.0, 1) + ) + ], + joinChar="," + ), + desc.FloatParam( + name="manualScale", + label="Scale", + description="Uniform Scale.", + value=1.0, + uid=[0], + range=(0.0, 20.0, 0.01) + ) + ], + joinChar=",", + enabled=lambda node: node.method.value == "manual", ), desc.ChoiceParam( name='landmarksDescriberTypes', label='Landmarks Describer Types', description='Image describer types used to compute the mean of the point cloud. (only for "landmarks" method).', - value=['sift', 'akaze'], - values=['sift', 'sift_float', 'sift_upright', 'akaze', 'akaze_liop', 'akaze_mldb', 'cctag3', 'cctag4', 'sift_ocv', 'akaze_ocv'], + value=['sift', 'dspsift', 'akaze'], + values=['sift', 'sift_float', 'sift_upright', 'dspsift', 'akaze', 'akaze_liop', 'akaze_mldb', 'cctag3', 'cctag4', 'sift_ocv', 'akaze_ocv', 'unknown'], exclusive=False, uid=[0], joinChar=',', @@ -74,21 +159,24 @@ class SfMTransform(desc.CommandLineNode): label='Scale', description='Apply scale transformation.', value=True, - uid=[0] + uid=[0], + enabled=lambda node: node.method.value != "manual", ), desc.BoolParam( name='applyRotation', label='Rotation', description='Apply rotation transformation.', value=True, - uid=[0] + uid=[0], + enabled=lambda node: node.method.value != "manual", ), desc.BoolParam( name='applyTranslation', label='Translation', description='Apply translation transformation.', value=True, - uid=[0] + uid=[0], + enabled=lambda node: node.method.value != "manual", ), desc.ChoiceParam( name='verboseLevel', @@ -104,9 +192,16 @@ class SfMTransform(desc.CommandLineNode): outputs = [ desc.File( name='output', - label='Output', + label='Output SfMData File', description='''Aligned SfMData file .''', - value=desc.Node.internalFolder + 'transformedSfM.abc', + value=lambda attr: desc.Node.internalFolder + (os.path.splitext(os.path.basename(attr.node.input.value))[0] or 'sfmData') + '.abc', + uid=[], + ), + desc.File( + name='outputViewsAndPoses', + label='Output Poses', + description='''Path to the output sfmdata file with cameras (views and poses).''', + value=desc.Node.internalFolder + 'cameras.sfm', uid=[], ), ] diff --git a/meshroom/nodes/aliceVision/SketchfabUpload.py b/meshroom/nodes/aliceVision/SketchfabUpload.py index 27bea2fb..06571b0f 100644 --- a/meshroom/nodes/aliceVision/SketchfabUpload.py +++ b/meshroom/nodes/aliceVision/SketchfabUpload.py @@ -51,6 +51,11 @@ def progressUpdate(size=None, progress=None, logManager=None): class SketchfabUpload(desc.Node): size = desc.DynamicNodeSize('inputFiles') + + documentation = ''' +Upload a textured mesh on Sketchfab. +''' + inputs = [ desc.ListAttribute( elementDesc=desc.File( diff --git a/meshroom/nodes/aliceVision/StructureFromMotion.py b/meshroom/nodes/aliceVision/StructureFromMotion.py index 3467fb02..fdc79dc2 100644 --- a/meshroom/nodes/aliceVision/StructureFromMotion.py +++ b/meshroom/nodes/aliceVision/StructureFromMotion.py @@ -1,8 +1,5 @@ __version__ = "2.0" -import json -import os - from meshroom.core import desc @@ -10,10 +7,63 @@ class StructureFromMotion(desc.CommandLineNode): commandLine = 'aliceVision_incrementalSfM {allParams}' size = desc.DynamicNodeSize('input') + documentation = ''' +This node will analyze feature matches to understand the geometric relationship behind all the 2D observations, +and infer the rigid scene structure (3D points) with the pose (position and orientation) and internal calibration of all cameras. +The pipeline is a growing reconstruction process (called incremental SfM): it first computes an initial two-view reconstruction that is iteratively extended by adding new views. + +1/ Fuse 2-View Matches into Tracks + +It fuses all feature matches between image pairs into tracks. Each track represents a candidate point in space, visible from multiple cameras. +However, at this step of the pipeline, it still contains many outliers. + +2/ Initial Image Pair + +It chooses the best initial image pair. This choice is critical for the quality of the final reconstruction. +It should indeed provide robust matches and contain reliable geometric information. +So, this image pair should maximize the number of matches and the repartition of the corresponding features in each image. +But at the same time, the angle between the cameras should also be large enough to provide reliable geometric information. + +3/ Initial 2-View Geometry + +It computes the fundamental matrix between the 2 images selected and consider that the first one is the origin of the coordinate system. + +4/ Triangulate + +Now with the pose of the 2 first cameras, it triangulates the corresponding 2D features into 3D points. + +5/ Next Best View Selection + +After that, it selects all the images that have enough associations with the features that are already reconstructed in 3D. + +6/ Estimate New Cameras + +Based on these 2D-3D associations it performs the resectioning of each of these new cameras. +The resectioning is a Perspective-n-Point algorithm (PnP) in a RANSAC framework to find the pose of the camera that validates most of the features associations. +On each camera, a non-linear minimization is performed to refine the pose. + +7/ Triangulate + +From these new cameras poses, some tracks become visible by 2 or more resected cameras and it triangulates them. + +8/ Optimize + +It performs a Bundle Adjustment to refine everything: extrinsics and intrinsics parameters of all cameras as well as the position of all 3D points. +It filters the results of the Bundle Adjustment by removing all observations that have high reprojection error or insufficient angles between observations. + +9/ Loop from 5 to 9 + +As we have triangulated new points, we get more image candidates for next best views selection and we can iterate from 5 to 9. +It iterates like that, adding cameras and triangulating new 2D features into 3D points and removing 3D points that became invalidated, until we cannot localize new views. + +## Online +[https://alicevision.org/#photogrammetry/sfm](https://alicevision.org/#photogrammetry/sfm) +''' + inputs = [ desc.File( name='input', - label='Input', + label='SfMData', description='SfMData file.', value='', uid=[0], @@ -47,7 +97,7 @@ class StructureFromMotion(desc.CommandLineNode): label='Describer Types', description='Describer types used to describe an image.', value=['sift'], - values=['sift', 'sift_float', 'sift_upright', 'akaze', 'akaze_liop', 'akaze_mldb', 'cctag3', 'cctag4', 'sift_ocv', 'akaze_ocv'], + values=['sift', 'sift_float', 'sift_upright', 'dspsift', 'akaze', 'akaze_liop', 'akaze_mldb', 'cctag3', 'cctag4', 'sift_ocv', 'akaze_ocv'], exclusive=False, uid=[0], joinChar=',', @@ -62,6 +112,18 @@ class StructureFromMotion(desc.CommandLineNode): uid=[0], advanced=True, ), + desc.ChoiceParam( + name='observationConstraint', + label='Observation Constraint', + description='Observation contraint mode used in the optimization:\n' + ' * Basic: Use standard reprojection error in pixel coordinates\n' + ' * Scale: Use reprojection error in pixel coordinates but relative to the feature scale', + value='Basic', + values=['Basic', 'Scale'], + exclusive=True, + uid=[0], + advanced=True, + ), desc.IntParam( name='localizerEstimatorMaxIterations', label='Localizer Max Ransac Iterations', @@ -242,7 +304,7 @@ class StructureFromMotion(desc.CommandLineNode): label='Filter Track Forks', description='Enable/Disable the track forks removal. A track contains a fork when incoherent matches \n' 'lead to multiple features in the same image for a single track. \n', - value=True, + value=False, uid=[0], ), desc.File( @@ -283,14 +345,14 @@ class StructureFromMotion(desc.CommandLineNode): outputs = [ desc.File( name='output', - label='Output SfMData File', + label='SfMData', description='Path to the output sfmdata file', value=desc.Node.internalFolder + 'sfm.abc', uid=[], ), desc.File( name='outputViewsAndPoses', - label='Output SfMData File', + label='Views and Poses', description='''Path to the output sfmdata file with cameras (views and poses).''', value=desc.Node.internalFolder + 'cameras.sfm', uid=[], @@ -303,30 +365,3 @@ class StructureFromMotion(desc.CommandLineNode): uid=[], ), ] - - @staticmethod - def getResults(node): - """ - Parse SfM result and return views, poses and intrinsics as three dicts with viewId, poseId and intrinsicId as keys. - """ - reportFile = node.outputViewsAndPoses.value - if not os.path.exists(reportFile): - return {}, {}, {} - - with open(reportFile) as jsonFile: - report = json.load(jsonFile) - - views = dict() - poses = dict() - intrinsics = dict() - - for view in report['views']: - views[view['viewId']] = view - - for pose in report['poses']: - poses[pose['poseId']] = pose['pose'] - - for intrinsic in report['intrinsics']: - intrinsics[intrinsic['intrinsicId']] = intrinsic - - return views, poses, intrinsics diff --git a/meshroom/nodes/aliceVision/Texturing.py b/meshroom/nodes/aliceVision/Texturing.py index 117201a8..f388e44f 100644 --- a/meshroom/nodes/aliceVision/Texturing.py +++ b/meshroom/nodes/aliceVision/Texturing.py @@ -7,10 +7,24 @@ class Texturing(desc.CommandLineNode): commandLine = 'aliceVision_texturing {allParams}' cpu = desc.Level.INTENSIVE ram = desc.Level.INTENSIVE + + documentation = ''' +This node computes the texturing on the mesh. + +If the mesh has no associated UV, it automatically computes UV maps. + +For each triangle, it uses the visibility information associated to each vertex to retrieve the texture candidates. +It select the best cameras based on the resolution covering the triangle. Finally it averages the pixel values using multiple bands in the frequency domain. +Many cameras are contributing to the low frequencies and only the best ones contributes to the high frequencies. + +## Online +[https://alicevision.org/#photogrammetry/texturing](https://alicevision.org/#photogrammetry/texturing) +''' + inputs = [ desc.File( name='input', - label='Input', + label='Dense SfMData', description='SfMData file.', value='', uid=[0], @@ -24,7 +38,7 @@ class Texturing(desc.CommandLineNode): ), desc.File( name='inputMesh', - label='Other Input Mesh', + label='Mesh', description='Optional input mesh to texture. By default, it will texture the result of the reconstruction.', value='', uid=[0], @@ -42,7 +56,7 @@ class Texturing(desc.CommandLineNode): name='downscale', label='Texture Downscale', description='''Texture downscale factor''', - value=1, + value=2, values=(1, 2, 4, 8), exclusive=True, uid=[0], @@ -205,31 +219,31 @@ class Texturing(desc.CommandLineNode): outputs = [ desc.File( name='output', - label='Output Folder', + label='Folder', description='Folder for output mesh: OBJ, material and texture files.', value=desc.Node.internalFolder, uid=[], ), desc.File( name='outputMesh', - label='Output Mesh', - description='Folder for output mesh: OBJ, material and texture files.', + label='Mesh', + description='Output Mesh file.', value=desc.Node.internalFolder + 'texturedMesh.obj', uid=[], group='', ), desc.File( name='outputMaterial', - label='Output Material', - description='Folder for output mesh: OBJ, material and texture files.', + label='Material', + description='Output Material file.', value=desc.Node.internalFolder + 'texturedMesh.mtl', uid=[], group='', ), desc.File( name='outputTextures', - label='Output Textures', - description='Folder for output mesh: OBJ, material and texture files.', + label='Textures', + description='Output Texture files.', value=desc.Node.internalFolder + 'texture_*.{outputTextureFileTypeValue}', uid=[], group='', diff --git a/meshroom/submitters/simpleFarmConfig.json b/meshroom/submitters/simpleFarmConfig.json index d3f11369..67fd4036 100644 --- a/meshroom/submitters/simpleFarmConfig.json +++ b/meshroom/submitters/simpleFarmConfig.json @@ -2,13 +2,13 @@ "BASE": ["mikrosRender"], "CPU": { "NONE": [], - "NORMAL": [], + "NORMAL": ["@.nCPUs>8"], "INTENSIVE": ["@.nCPUs>30"] }, "RAM": { "NONE": [], "NORMAL": ["@.mem>8"], - "INTENSIVE": ["@.mem>30"] + "INTENSIVE": ["@.mem>80"] }, "GPU": { "NONE": [], diff --git a/meshroom/ui/app.py b/meshroom/ui/app.py index fba3425a..52d50497 100644 --- a/meshroom/ui/app.py +++ b/meshroom/ui/app.py @@ -8,13 +8,17 @@ from PySide2.QtWidgets import QApplication import meshroom from meshroom.core import nodesDesc +from meshroom.core import pyCompatibility +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 +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): @@ -67,7 +71,7 @@ class MeshroomApp(QApplication): 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='MESHROOM_FILE/photogrammetry/hdri', type=str, default=os.environ.get("MESHROOM_DEFAULT_PIPELINE", "photogrammetry"), + parser.add_argument('-p', '--pipeline', metavar='MESHROOM_FILE/photogrammetry/panoramaHdr/panoramaFisheyeHdr', type=str, default=os.environ.get("MESHROOM_DEFAULT_PIPELINE", "photogrammetry"), help='Override the default Meshroom pipeline with this external graph.') parser.add_argument("--verbose", help="Verbosity level", default='warning', choices=['fatal', 'error', 'warning', 'info', 'debug', 'trace'],) @@ -116,7 +120,9 @@ class MeshroomApp(QApplication): self.engine.rootContext().setContextProperty("_nodeTypes", sorted(nodesDesc.keys())) # instantiate Reconstruction object - r = Reconstruction(defaultPipeline=args.pipeline, parent=self) + self._undoStack = commands.UndoStack(self) + self._taskManager = TaskManager(self) + r = Reconstruction(undoStack=self._undoStack, taskManager=self._taskManager, defaultPipeline=args.pipeline, parent=self) self.engine.rootContext().setContextProperty("_reconstruction", r) # those helpers should be available from QML Utils module as singletons, but: @@ -125,6 +131,7 @@ class MeshroomApp(QApplication): # => 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 @@ -183,8 +190,19 @@ class MeshroomApp(QApplication): return projects @Slot(str) + @Slot(QUrl) def addRecentProjectFile(self, projectFile): - projectFile = QUrl(projectFile).path() + if not isinstance(projectFile, (QUrl, pyCompatibility.basestring)): + 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 @@ -192,10 +210,10 @@ class MeshroomApp(QApplication): uniqueProjects = OrderedDict.fromkeys(projects) projects = list(uniqueProjects) # remove previous usage of the value - if projectFile in uniqueProjects: - projects.remove(projectFile) + if projectFileNorm in uniqueProjects: + projects.remove(projectFileNorm) # add the new value in the first place - projects.insert(0, projectFile) + projects.insert(0, projectFileNorm) # keep only the 10 first elements projects = projects[0:20] @@ -211,6 +229,43 @@ class MeshroomApp(QApplication): self.recentProjectFilesChanged.emit() + @Slot(str) + @Slot(QUrl) + def removeRecentProjectFile(self, projectFile): + if not isinstance(projectFile, (QUrl, pyCompatibility.basestring)): + 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() + @Slot(str, result=str) def markdownToHtml(self, md): """ diff --git a/meshroom/ui/commands.py b/meshroom/ui/commands.py index c327b293..f5429c0e 100755 --- a/meshroom/ui/commands.py +++ b/meshroom/ui/commands.py @@ -52,6 +52,10 @@ class UndoStack(QUndoStack): self.canRedoChanged.connect(self._canRedoChanged) self.undoTextChanged.connect(self._undoTextChanged) self.redoTextChanged.connect(self._redoTextChanged) + self.indexChanged.connect(self._indexChanged) + + self._undoableIndex = 0 # used to block the undo stack while computing + self._lockedRedo = False # used to avoid unwanted behaviors while computing def tryAndPush(self, command): # type: (UndoCommand) -> bool @@ -63,21 +67,54 @@ class UndoStack(QUndoStack): if res is not False: command.setEnabled(False) self.push(command) # takes ownership + self.setLockedRedo(False) # make sure to unlock the redo action command.setEnabled(True) return res + def setUndoableIndex(self, value): + if self._undoableIndex == value: + return + self._undoableIndex = value + self.isUndoableIndexChanged.emit() + + def setLockedRedo(self, value): + if self._lockedRedo == value: + return + self._lockedRedo = value + self.lockedRedoChanged.emit() + + def lockAtThisIndex(self): + """ + Lock the undo stack at the current index and lock the redo action. + Note: should be used while starting a new compute to avoid problems. + """ + self.setUndoableIndex(self.index) + self.setLockedRedo(True) + + def unlock(self): + """ Unlock both undo stack and redo action. """ + self.setUndoableIndex(0) + self.setLockedRedo(False) + # Redeclare QUndoStack signal since original ones can not be used for properties notifying _cleanChanged = Signal() _canUndoChanged = Signal() _canRedoChanged = Signal() _undoTextChanged = Signal() _redoTextChanged = Signal() + _indexChanged = Signal() clean = Property(bool, QUndoStack.isClean, notify=_cleanChanged) canUndo = Property(bool, QUndoStack.canUndo, notify=_canRedoChanged) canRedo = Property(bool, QUndoStack.canRedo, notify=_canUndoChanged) undoText = Property(str, QUndoStack.undoText, notify=_undoTextChanged) redoText = Property(str, QUndoStack.redoText, notify=_redoTextChanged) + index = Property(int, QUndoStack.index, notify=_indexChanged) + + isUndoableIndexChanged = Signal() + isUndoableIndex = Property(bool, lambda self: self.index > self._undoableIndex, notify=isUndoableIndexChanged) + lockedRedoChanged = Signal() + lockedRedo = Property(bool, lambda self: self._lockedRedo, setLockedRedo, notify=lockedRedoChanged) class GraphCommand(UndoCommand): @@ -190,6 +227,9 @@ class AddEdgeCommand(GraphCommand): self.dstAttr = dst.getFullName() self.setText("Connect '{}'->'{}'".format(self.srcAttr, self.dstAttr)) + if src.baseType != dst.baseType: + raise ValueError("Attribute types are not compatible and cannot be connected: '{}'({})->'{}'({})".format(self.srcAttr, src.baseType, self.dstAttr, dst.baseType)) + def redoImpl(self): self.graph.addEdge(self.graph.attribute(self.srcAttr), self.graph.attribute(self.dstAttr)) return True diff --git a/meshroom/ui/components/__init__.py b/meshroom/ui/components/__init__.py index 6c94a10d..260acb04 100755 --- a/meshroom/ui/components/__init__.py +++ b/meshroom/ui/components/__init__.py @@ -4,10 +4,13 @@ def registerTypes(): from meshroom.ui.components.clipboard import ClipboardHelper from meshroom.ui.components.edge import EdgeMouseArea from meshroom.ui.components.filepath import FilepathHelper - from meshroom.ui.components.scene3D import Scene3DHelper, TrackballController + from meshroom.ui.components.scene3D import Scene3DHelper, TrackballController, Transformations3DHelper + from meshroom.ui.components.csvData import CsvData qmlRegisterType(EdgeMouseArea, "GraphEditor", 1, 0, "EdgeMouseArea") qmlRegisterType(ClipboardHelper, "Meshroom.Helpers", 1, 0, "ClipboardHelper") # TODO: uncreatable qmlRegisterType(FilepathHelper, "Meshroom.Helpers", 1, 0, "FilepathHelper") # TODO: uncreatable qmlRegisterType(Scene3DHelper, "Meshroom.Helpers", 1, 0, "Scene3DHelper") # TODO: uncreatable + qmlRegisterType(Transformations3DHelper, "Meshroom.Helpers", 1, 0, "Transformations3DHelper") # TODO: uncreatable qmlRegisterType(TrackballController, "Meshroom.Helpers", 1, 0, "TrackballController") + qmlRegisterType(CsvData, "DataObjects", 1, 0, "CsvData") diff --git a/meshroom/ui/components/csvData.py b/meshroom/ui/components/csvData.py new file mode 100644 index 00000000..c9ecef2f --- /dev/null +++ b/meshroom/ui/components/csvData.py @@ -0,0 +1,125 @@ +from meshroom.common.qt import QObjectListModel + +from PySide2.QtCore import QObject, Slot, Signal, Property +from PySide2.QtCharts import QtCharts + +import csv +import os +import logging + + +class CsvData(QObject): + """Store data from a CSV file.""" + def __init__(self, parent=None): + """Initialize the object without any parameter.""" + super(CsvData, self).__init__(parent=parent) + self._filepath = "" + self._data = QObjectListModel(parent=self) # List of CsvColumn + self._ready = False + self.filepathChanged.connect(self.updateData) + + @Slot(int, result=QObject) + def getColumn(self, index): + return self._data.at(index) + + @Slot(result=str) + def getFilepath(self): + return self._filepath + + @Slot(result=int) + def getNbColumns(self): + if self._ready: + return len(self._data) + else: + return 0 + + @Slot(str) + def setFilepath(self, filepath): + if self._filepath == filepath: + return + self.setReady(False) + self._filepath = filepath + self.filepathChanged.emit() + + def setReady(self, ready): + if self._ready == ready: + return + self._ready = ready + self.readyChanged.emit() + + @Slot() + def updateData(self): + self.setReady(False) + self._data.clear() + newColumns = self.read() + if newColumns: + self._data.setObjectList(newColumns) + self.setReady(True) + + def read(self): + """Read the CSV file and return a list containing CsvColumn objects.""" + if not self._filepath or not self._filepath.lower().endswith(".csv") or not os.path.isfile(self._filepath): + return [] + + dataList = [] + try: + csvRows = [] + with open(self._filepath, "r") as fp: + reader = csv.reader(fp) + for row in reader: + csvRows.append(row) + # Create the objects in dataList + # with the first line elements as objects' title + for elt in csvRows[0]: + dataList.append(CsvColumn(elt)) # , parent=self._data + # Populate the content attribute + for elt in csvRows[1:]: + for idx, value in enumerate(elt): + dataList[idx].appendValue(value) + except Exception as e: + logging.error("CsvData: Failed to load file: {}\n{}".format(self._filepath, str(e))) + + return dataList + + filepathChanged = Signal() + filepath = Property(str, getFilepath, setFilepath, notify=filepathChanged) + readyChanged = Signal() + ready = Property(bool, lambda self: self._ready, notify=readyChanged) + data = Property(QObject, lambda self: self._data, notify=readyChanged) + nbColumns = Property(int, getNbColumns, notify=readyChanged) + + +class CsvColumn(QObject): + """Store content of a CSV column.""" + def __init__(self, title="", parent=None): + """Initialize the object with optional column title parameter.""" + super(CsvColumn, self).__init__(parent=parent) + self._title = title + self._content = [] + + def appendValue(self, value): + self._content.append(value) + + @Slot(result=str) + def getFirst(self): + if not self._content: + return "" + return self._content[0] + + @Slot(result=str) + def getLast(self): + if not self._content: + return "" + return self._content[-1] + + @Slot(QtCharts.QXYSeries) + def fillChartSerie(self, serie): + """Fill XYSerie used for displaying QML Chart.""" + if not serie: + return + serie.clear() + for index, value in enumerate(self._content): + serie.append(float(index), float(value)) + + title = Property(str, lambda self: self._title, constant=True) + content = Property("QStringList", lambda self: self._content, constant=True) diff --git a/meshroom/ui/components/filepath.py b/meshroom/ui/components/filepath.py index f15d9700..6c19e87f 100644 --- a/meshroom/ui/components/filepath.py +++ b/meshroom/ui/components/filepath.py @@ -2,7 +2,7 @@ # coding:utf-8 from meshroom.core import pyCompatibility -from PySide2.QtCore import QUrl +from PySide2.QtCore import QUrl, QFileInfo from PySide2.QtCore import QObject, Slot import os @@ -89,3 +89,8 @@ class FilepathHelper(QObject): if fileList: return fileList[0] return "" + + @Slot(QUrl, result=int) + def fileSizeMB(self, path): + """ Returns the file size in MB. """ + return QFileInfo(self.asStr(path)).size() / (1024*1024) diff --git a/meshroom/ui/components/scene3D.py b/meshroom/ui/components/scene3D.py index bf5b44c5..b4569c19 100644 --- a/meshroom/ui/components/scene3D.py +++ b/meshroom/ui/components/scene3D.py @@ -3,7 +3,7 @@ from math import acos, pi, sqrt from PySide2.QtCore import QObject, Slot, QSize, Signal, QPointF from PySide2.Qt3DCore import Qt3DCore from PySide2.Qt3DRender import Qt3DRender -from PySide2.QtGui import QVector3D, QQuaternion, QVector2D +from PySide2.QtGui import QVector3D, QQuaternion, QVector2D, QVector4D, QMatrix4x4 from meshroom.ui.utils import makeProperty @@ -103,3 +103,206 @@ class TrackballController(QObject): trackballSize = makeProperty(float, '_trackballSize', trackballSizeChanged) rotationSpeedChanged = Signal() rotationSpeed = makeProperty(float, '_rotationSpeed', rotationSpeedChanged) + + +class Transformations3DHelper(QObject): + + # ---------- Exposed to QML ---------- # + + @Slot(QVector4D, Qt3DRender.QCamera, QSize, result=QVector2D) + def pointFromWorldToScreen(self, point, camera, windowSize): + """ Compute the Screen point corresponding to a World Point. + Args: + point (QVector4D): point in world coordinates + camera (QCamera): camera viewing the scene + windowSize (QSize): size of the Scene3D window + Returns: + QVector2D: point in screen coordinates + """ + # Transform the point from World Coord to Normalized Device Coord + viewMatrix = camera.transform().matrix().inverted() + projectedPoint = (camera.projectionMatrix() * viewMatrix[0]).map(point) + projectedPoint2D = QVector2D( + projectedPoint.x()/projectedPoint.w(), + projectedPoint.y()/projectedPoint.w() + ) + + # Transform the point from Normalized Device Coord to Screen Coord + screenPoint2D = QVector2D( + int((projectedPoint2D.x() + 1) * windowSize.width() / 2), + int((projectedPoint2D.y() - 1) * windowSize.height() / -2) + ) + + return screenPoint2D + + @Slot(Qt3DCore.QTransform, QMatrix4x4, QMatrix4x4, QMatrix4x4, QVector3D) + def relativeLocalTranslate(self, transformQtInstance, initialPosMat, initialRotMat, initialScaleMat, translateVec): + """ Translate the QTransform in its local space relatively to an initial state. + Args: + transformQtInstance (QTransform): reference to the Transform to modify + initialPosMat (QMatrix4x4): initial position matrix + initialRotMat (QMatrix4x4): initial rotation matrix + initialScaleMat (QMatrix4x4): initial scale matrix + translateVec (QVector3D): vector used for the local translation + """ + # Compute the translation transformation matrix + translationMat = QMatrix4x4() + translationMat.translate(translateVec) + + # Compute the new model matrix (POSITION * ROTATION * TRANSLATE * SCALE) and set it to the Transform + mat = initialPosMat * initialRotMat * translationMat * initialScaleMat + transformQtInstance.setMatrix(mat) + + @Slot(Qt3DCore.QTransform, QMatrix4x4, QQuaternion, QMatrix4x4, QVector3D, int) + def relativeLocalRotate(self, transformQtInstance, initialPosMat, initialRotQuat, initialScaleMat, axis, degree): + """ Rotate the QTransform in its local space relatively to an initial state. + Args: + transformQtInstance (QTransform): reference to the Transform to modify + initialPosMat (QMatrix4x4): initial position matrix + initialRotQuat (QQuaternion): initial rotation quaternion + initialScaleMat (QMatrix4x4): initial scale matrix + axis (QVector3D): axis to rotate around + degree (int): angle of rotation in degree + """ + # Compute the transformation quaternion from axis and angle in degrees + transformQuat = QQuaternion.fromAxisAndAngle(axis, degree) + + # Compute the new rotation quaternion and then calculate the matrix + newRotQuat = initialRotQuat * transformQuat # Order is important + newRotationMat = self.quaternionToRotationMatrix(newRotQuat) + + # Compute the new model matrix (POSITION * NEW_COMPUTED_ROTATION * SCALE) and set it to the Transform + mat = initialPosMat * newRotationMat * initialScaleMat + transformQtInstance.setMatrix(mat) + + @Slot(Qt3DCore.QTransform, QMatrix4x4, QMatrix4x4, QMatrix4x4, QVector3D) + def relativeLocalScale(self, transformQtInstance, initialPosMat, initialRotMat, initialScaleMat, scaleVec): + """ Scale the QTransform in its local space relatively to an initial state. + Args: + transformQtInstance (QTransform): reference to the Transform to modify + initialPosMat (QMatrix4x4): initial position matrix + initialRotMat (QMatrix4x4): initial rotation matrix + initialScaleMat (QMatrix4x4): initial scale matrix + scaleVec (QVector3D): vector used for the relative scale + """ + # Make a copy of the scale matrix (otherwise, it is a reference and it does not work as expected) + scaleMat = self.copyMatrix4x4(initialScaleMat) + + # Update the scale matrix copy (X then Y then Z) with the scaleVec values + scaleVecTuple = scaleVec.toTuple() + for i in range(3): + currentRow = list(scaleMat.row(i).toTuple()) # QVector3D does not implement [] operator or easy way to access value by index so this little hack is required + value = currentRow[i] + scaleVecTuple[i] + value = value if value >= 0 else -value # Make sure to have only positive scale (because negative scale can make issues with matrix decomposition) + currentRow[i] = value + + scaleMat.setRow(i, QVector3D(currentRow[0], currentRow[1], currentRow[2])) # Apply the new row to the scale matrix + + # Compute the new model matrix (POSITION * ROTATION * SCALE) and set it to the Transform + mat = initialPosMat * initialRotMat * scaleMat + transformQtInstance.setMatrix(mat) + + @Slot(QMatrix4x4, result="QVariant") + def modelMatrixToMatrices(self, modelMat): + """ Decompose a model matrix into individual matrices. + Args: + modelMat (QMatrix4x4): model matrix to decompose + Returns: + QVariant: object containing position, rotation and scale matrices + rotation quaternion + """ + decomposition = self.decomposeModelMatrix(modelMat) + + posMat = QMatrix4x4() + posMat.translate(decomposition.get("translation")) + + rotMat = self.quaternionToRotationMatrix(decomposition.get("quaternion")) + + scaleMat = QMatrix4x4() + scaleMat.scale(decomposition.get("scale")) + + return {"position": posMat, "rotation": rotMat, "scale": scaleMat, "quaternion": decomposition.get("quaternion")} + + @Slot(QVector3D, QVector3D, QVector3D, result=QMatrix4x4) + def computeModelMatrixWithEuler(self, translation, rotation, scale): + """ Compute a model matrix from three Vector3D. + Args: + translation (QVector3D): position in space (x, y, z) + rotation (QVector3D): Euler angles in degrees (x, y, z) + scale (QVector3D): scale of the object (x, y, z) + Returns: + QMatrix4x4: corresponding model matrix + """ + posMat = QMatrix4x4() + posMat.translate(translation) + + quaternion = QQuaternion.fromEulerAngles(rotation) + rotMat = self.quaternionToRotationMatrix(quaternion) + + scaleMat = QMatrix4x4() + scaleMat.scale(scale) + + modelMat = posMat * rotMat * scaleMat + + return modelMat + + @Slot(QVector3D, QMatrix4x4, Qt3DRender.QCamera, QSize, result=float) + def computeScaleUnitFromModelMatrix(self, axis, modelMat, camera, windowSize): + """ Compute the length of the screen projected vector axis unit transformed by the model matrix. + Args: + axis (QVector3D): chosen axis ((1,0,0) or (0,1,0) or (0,0,1)) + modelMat (QMatrix4x4): model matrix used for the transformation + camera (QCamera): camera viewing the scene + windowSize (QSize): size of the window in pixels + Returns: + float: length (in pixels) + """ + decomposition = self.decomposeModelMatrix(modelMat) + + posMat = QMatrix4x4() + posMat.translate(decomposition.get("translation")) + + rotMat = self.quaternionToRotationMatrix(decomposition.get("quaternion")) + + unitScaleModelMat = posMat * rotMat * QMatrix4x4() + + worldCenterPoint = unitScaleModelMat.map(QVector4D(0,0,0,1)) + worldAxisUnitPoint = unitScaleModelMat.map(QVector4D(axis.x(),axis.y(),axis.z(),1)) + screenCenter2D = self.pointFromWorldToScreen(worldCenterPoint, camera, windowSize) + screenAxisUnitPoint2D = self.pointFromWorldToScreen(worldAxisUnitPoint, camera, windowSize) + + screenVector = QVector2D(screenAxisUnitPoint2D.x() - screenCenter2D.x(), -(screenAxisUnitPoint2D.y() - screenCenter2D.y())) + + value = screenVector.length() + return value if (value and value > 10) else 10 # Threshold to avoid problems in extreme case + + # ---------- "Private" Methods ---------- # + + def copyMatrix4x4(self, mat): + """ Make a deep copy of a QMatrix4x4. """ + newMat = QMatrix4x4() + for i in range(4): + newMat.setRow(i, mat.row(i)) + return newMat + + def decomposeModelMatrix(self, modelMat): + """ Decompose a model matrix into individual component. + Args: + modelMat (QMatrix4x4): model matrix to decompose + Returns: + QVariant: object containing translation and scale vectors + rotation quaternion + """ + translation = modelMat.column(3).toVector3D() + quaternion = QQuaternion.fromDirection(modelMat.column(2).toVector3D(), modelMat.column(1).toVector3D()) + scale = QVector3D(modelMat.column(0).length(), modelMat.column(1).length(), modelMat.column(2).length()) + + return {"translation": translation, "quaternion": quaternion, "scale": scale} + + def quaternionToRotationMatrix(self, q): + """ Return a rotation matrix from a quaternion. """ + rotMat3x3 = q.toRotationMatrix() + return QMatrix4x4( + rotMat3x3(0, 0), rotMat3x3(0, 1), rotMat3x3(0, 2), 0, + rotMat3x3(1, 0), rotMat3x3(1, 1), rotMat3x3(1, 2), 0, + rotMat3x3(2, 0), rotMat3x3(2, 1), rotMat3x3(2, 2), 0, + 0, 0, 0, 1 + ) diff --git a/meshroom/ui/graph.py b/meshroom/ui/graph.py index 566c122c..73aed58c 100644 --- a/meshroom/ui/graph.py +++ b/meshroom/ui/graph.py @@ -12,7 +12,10 @@ from PySide2.QtCore import Slot, QJsonValue, QObject, QUrl, Property, Signal, QP from meshroom import multiview from meshroom.common.qt import QObjectListModel from meshroom.core.attribute import Attribute, ListAttribute -from meshroom.core.graph import Graph, Edge, submitGraph, executeGraph +from meshroom.core.graph import Graph, Edge + +from meshroom.core.taskManager import TaskManager + from meshroom.core.node import NodeChunk, Node, Status, CompatibilityNode, Position from meshroom.core import submitters from meshroom.ui import commands @@ -152,9 +155,9 @@ class GraphLayout(QObject): super(GraphLayout, self).__init__(graph) self.graph = graph self._depthMode = GraphLayout.DepthMode.MaxDepth - self._nodeWidth = 140 # implicit node width - self._nodeHeight = 80 # implicit node height - self._gridSpacing = 15 # column/line spacing between nodes + self._nodeWidth = 160 # implicit node width + self._nodeHeight = 120 # implicit node height + self._gridSpacing = 40 # column/line spacing between nodes @Slot(Node, Node, int, int) def autoLayout(self, fromNode=None, toNode=None, startX=0, startY=0): @@ -194,6 +197,30 @@ class GraphLayout(QObject): """ Perform auto-layout on the whole graph. """ self.autoLayout() + def positionBoundingBox(self, nodes=None): + """ + Return bounding box for a set of nodes as (x, y, width, height). + + Args: + nodes (list of Node): the list of nodes or the whole graph if None + + Returns: + list of int: the resulting bounding box (x, y, width, height) + """ + if nodes is None: + nodes = self.graph.nodes.values() + first = nodes[0] + bbox = [first.x, first.y, first.x, first.y] + for n in nodes: + bbox[0] = min(bbox[0], n.x) + bbox[1] = min(bbox[1], n.y) + bbox[2] = max(bbox[2], n.x) + bbox[3] = max(bbox[3], n.y) + + bbox[2] -= bbox[0] + bbox[3] -= bbox[1] + return bbox + def boundingBox(self, nodes=None): """ Return bounding box for a set of nodes as (x, y, width, height). @@ -202,22 +229,12 @@ class GraphLayout(QObject): nodes (list of Node): the list of nodes or the whole graph if None Returns: - tuple of int: the resulting bounding box (x, y, width, height) + list of int: the resulting bounding box (x, y, width, height) """ - if nodes is None: - nodes = self.graph.nodes.values() - first = nodes[0] - bbox = [first.x, first.y, first.x + self._nodeWidth, first.y + self._nodeHeight] - for n in nodes: - bbox[0] = min(bbox[0], n.x) - bbox[1] = min(bbox[1], n.y) - bbox[2] = max(bbox[2], n.x + self._nodeWidth) - bbox[3] = max(bbox[3], n.y + self._nodeHeight) - - bbox[2] -= bbox[0] - bbox[3] -= bbox[1] - - return tuple(bbox) + bbox = self.positionBoundingBox(nodes) + bbox[2] += self._nodeWidth + bbox[3] += self._nodeHeight + return bbox def setDepthMode(self, mode): """ Set node depth mode to use. """ @@ -243,10 +260,12 @@ class UIGraph(QObject): UIGraph exposes undoable methods on its graph and computation in a separate thread. It also provides a monitoring of all its computation units (NodeChunks). """ - def __init__(self, parent=None): + def __init__(self, undoStack, taskManager, parent=None): super(UIGraph, self).__init__(parent) - self._undoStack = commands.UndoStack(self) + self._undoStack = undoStack + self._taskManager = taskManager self._graph = Graph('', self) + self._modificationCount = 0 self._chunksMonitor = ChunksMonitor(parent=self) self._computeThread = Thread() @@ -256,18 +275,32 @@ class UIGraph(QObject): self._selectedNode = None self._hoveredNode = None + self.computeStatusChanged.connect(self.updateLockedUndoStack) + def setGraph(self, g): """ Set the internal graph. """ if self._graph: self.stopExecution() self.clear() + oldGraph = self._graph self._graph = g + if oldGraph: + oldGraph.deleteLater() + self._graph.updated.connect(self.onGraphUpdated) self._graph.update() + self._taskManager.update(self._graph) # perform auto-layout if graph does not provide nodes positions if Graph.IO.Features.NodesPositions not in self._graph.fileFeatures: self._layout.reset() - self._undoStack.clear() # clear undo-stack after layout + # clear undo-stack after layout + self._undoStack.clear() + else: + bbox = self._layout.positionBoundingBox() + if bbox[2] == 0 and bbox[3] == 0: + self._layout.reset() + # clear undo-stack after layout + self._undoStack.clear() self.graphChanged.emit() def onGraphUpdated(self): @@ -299,8 +332,8 @@ class UIGraph(QObject): if self._graph: self.clearNodeHover() self.clearNodeSelection() - self._graph.deleteLater() - self._graph = None + self._taskManager.clear() + self._graph.clear() self._sortedDFSChunks.clear() self._undoStack.clear() @@ -309,16 +342,14 @@ class UIGraph(QObject): self.stopExecution() self._chunksMonitor.stop() - def load(self, filepath, setupProjectFile=True): + @Slot(str, result=bool) + def loadGraph(self, filepath, setupProjectFile=True): g = Graph('') - g.load(filepath, setupProjectFile) + status = g.load(filepath, setupProjectFile) if not os.path.exists(g.cacheDir): os.mkdir(g.cacheDir) self.setGraph(g) - - @Slot(QUrl) - def loadUrl(self, url): - self.load(url.toLocalFile()) + return status @Slot(QUrl) def saveAs(self, url): @@ -340,30 +371,49 @@ class UIGraph(QObject): self._graph.save() self._undoStack.setClean() + @Slot() + def updateLockedUndoStack(self): + if self.isComputingLocally(): + self._undoStack.lockAtThisIndex() + else: + self._undoStack.unlock() + @Slot(Node) def execute(self, node=None): - if self.computing: - return nodes = [node] if node else None - self._computeThread = Thread(target=self._execute, args=(nodes,)) - self._computeThread.start() - - def _execute(self, nodes): - self.computeStatusChanged.emit() - try: - executeGraph(self._graph, nodes) - except Exception as e: - logging.error("Error during Graph execution {}".format(e)) - finally: - self.computeStatusChanged.emit() + self._taskManager.compute(self._graph, nodes) + self.updateLockedUndoStack() # explicitly call the update while it is already computing @Slot() def stopExecution(self): if not self.isComputingLocally(): return + self._taskManager.requestBlockRestart() self._graph.stopExecution() - self._computeThread.join() - self.computeStatusChanged.emit() + self._taskManager._thread.join() + + @Slot(Node) + def stopNodeComputation(self, node): + """ Stop the computation of the node and update all the nodes depending on it. """ + if not self.isComputingLocally(): + return + + # Stop the node and wait Task Manager + node.stopComputation() + self._taskManager._thread.join() + + @Slot(Node) + def cancelNodeComputation(self, node): + """ Cancel the computation of the node and all the nodes depending on it. """ + if node.getGlobalStatus() == Status.SUBMITTED: + # Status from SUBMITTED to NONE + # Make sure to remove the nodes from the Task Manager list + node.clearSubmittedChunks() + self._taskManager.removeNode(node, displayList=True, processList=True) + + for n in node.getOutputNodes(recursive=True, dependenciesOnly=True): + n.clearSubmittedChunks() + self._taskManager.removeNode(n, displayList=True, processList=True) @Slot(Node) def submit(self, node=None): @@ -375,8 +425,9 @@ class UIGraph(QObject): Default submitter is specified using the MESHROOM_DEFAULT_SUBMITTER environment variable. """ self.save() # graph must be saved before being submitted + self._undoStack.clear() # the undo stack must be cleared node = [node] if node else None - submitGraph(self._graph, os.environ.get('MESHROOM_DEFAULT_SUBMITTER', ''), node) + self._taskManager.submit(self._graph, os.environ.get('MESHROOM_DEFAULT_SUBMITTER', ''), node) def updateGraphComputingStatus(self): # update graph computing status @@ -393,11 +444,11 @@ class UIGraph(QObject): def isComputingExternally(self): """ Whether this graph is being computed externally. """ - return (self._running or self._submitted) and not self.isComputingLocally() + return self._submitted def isComputingLocally(self): """ Whether this graph is being computed locally (i.e computation can be stopped). """ - return self._computeThread.is_alive() + return self._taskManager._thread.isRunning() def push(self, command): """ Try and push the given command to the undo stack. @@ -473,9 +524,11 @@ class UIGraph(QObject): startNode (Node): the node to start from. """ with self.groupedGraphModification("Remove Nodes from {}".format(startNode.name)): + nodes, _ = self._graph.dfsOnDiscover(startNodes=[startNode], reverse=True, dependenciesOnly=True) # Perform nodes removal from leaves to start node so that edges # can be re-created in correct order on redo. - [self.removeNode(node) for node in reversed(self._graph.nodesFromNode(startNode)[0])] + for node in reversed(nodes): + self.removeNode(node) @Slot(Attribute, Attribute) def addEdge(self, src, dst): @@ -573,6 +626,7 @@ class UIGraph(QObject): undoStack = Property(QObject, lambda self: self._undoStack, constant=True) graphChanged = Signal() graph = Property(Graph, lambda self: self._graph, notify=graphChanged) + taskManager = Property(TaskManager, lambda self: self._taskManager, constant=True) nodes = Property(QObject, lambda self: self._graph.nodes, notify=graphChanged) layout = Property(GraphLayout, lambda self: self._layout, constant=True) diff --git a/meshroom/ui/qml/AboutDialog.qml b/meshroom/ui/qml/AboutDialog.qml index 8af761e4..423d69b9 100644 --- a/meshroom/ui/qml/AboutDialog.qml +++ b/meshroom/ui/qml/AboutDialog.qml @@ -67,7 +67,14 @@ Dialog { font.pointSize: 21 palette.buttonText: root.palette.link ToolTip.text: "AliceVision Website" - onClicked: Qt.openUrlExternally("https://alicevision.github.io") + onClicked: Qt.openUrlExternally("https://alicevision.org") + } + MaterialToolButton { + text: MaterialIcons.favorite + font.pointSize: 21 + palette.buttonText: root.palette.link + ToolTip.text: "Donate to get a better software" + onClicked: Qt.openUrlExternally("https://alicevision.org/association/#donate") } ToolButton { icon.source: "../img/GitHub-Mark-Light-32px.png" diff --git a/meshroom/ui/qml/Charts/InteractiveChartView.qml b/meshroom/ui/qml/Charts/InteractiveChartView.qml new file mode 100644 index 00000000..eef47765 --- /dev/null +++ b/meshroom/ui/qml/Charts/InteractiveChartView.qml @@ -0,0 +1,57 @@ +import QtQuick 2.9 +import QtQuick.Controls 2.3 +import QtQuick.Layouts 1.3 +import MaterialIcons 2.2 +import QtPositioning 5.8 +import QtLocation 5.9 + +import QtCharts 2.13 + +import Controls 1.0 +import Utils 1.0 + + +ChartView { + id: root + antialiasing: true + + Rectangle { + id: plotZone + x: root.plotArea.x + y: root.plotArea.y + width: root.plotArea.width + height: root.plotArea.height + color: "transparent" + + MouseArea { + anchors.fill: parent + + property double degreeToScale: 1.0 / 120.0 // default mouse scroll is 15 degree + acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton + // onWheel: { + // console.warn("root.plotArea before: " + root.plotArea) + // var zoomFactor = wheel.angleDelta.y > 0 ? 1.0 / (1.0 + wheel.angleDelta.y * degreeToScale) : (1.0 + Math.abs(wheel.angleDelta.y) * degreeToScale) + + // // var mouse_screen = Qt.point(wheel.x, wheel.y) + // var mouse_screen = mapToItem(root, wheel.x, wheel.y) + // var mouse_normalized = Qt.point(mouse_screen.x / plotZone.width, mouse_screen.y / plotZone.height) + // var mouse_plot = Qt.point(mouse_normalized.x * plotZone.width, mouse_normalized.y * plotZone.height) + + // // var p = mapToValue(mouse_screen, root.series(0)) + // // var pMin = mapToValue(mouse_screen, Qt.point(root.axisX().min, root.axisY().min)) + // // var pMax = mapToValue(mouse_screen, Qt.point(root.axisX().max, root.axisY().max)) + // // console.warn("p: " + p) + + // // Qt.rect() + // var r = Qt.rect(mouse_plot.x, mouse_plot.y, plotZone.width * zoomFactor, plotZone.height * zoomFactor) + // //var r = Qt.rect(pMin.x, pMin.y, (pMax.x-pMin.x) / 2, (pMax.y-pMin.y) / 2) + // root.zoomIn(r) + // } + onClicked: { + root.zoomReset(); + } + } + } + + +} diff --git a/meshroom/ui/qml/Charts/qmldir b/meshroom/ui/qml/Charts/qmldir index 32ea2d32..0b50d1ed 100644 --- a/meshroom/ui/qml/Charts/qmldir +++ b/meshroom/ui/qml/Charts/qmldir @@ -2,3 +2,4 @@ module Charts ChartViewLegend 1.0 ChartViewLegend.qml ChartViewCheckBox 1.0 ChartViewCheckBox.qml +InteractiveChartView 1.0 InteractiveChartView.qml diff --git a/meshroom/ui/qml/Controls/Panel.qml b/meshroom/ui/qml/Controls/Panel.qml index a340bdf3..6cadad93 100644 --- a/meshroom/ui/qml/Controls/Panel.qml +++ b/meshroom/ui/qml/Controls/Panel.qml @@ -18,6 +18,8 @@ Page { property alias headerBar: headerLayout.data property alias footerContent: footerLayout.data property alias icon: iconPlaceHolder.data + property alias loading: loadingIndicator.running + property alias loadingText: loadingLabal.text clip: true @@ -46,18 +48,37 @@ Page { width: childrenRect.width height: childrenRect.height Layout.alignment: Qt.AlignVCenter - visible: icon != "" + visible: icon !== "" } // Title Label { text: root.title - Layout.fillWidth: true elide: Text.ElideRight topPadding: m.vPadding bottomPadding: m.vPadding } - // + Item { + width: 10 + } + // Feature loading status + BusyIndicator { + id: loadingIndicator + padding: 0 + implicitWidth: 12 + implicitHeight: 12 + running: false + } + Label { + id: loadingLabal + text: "" + font.italic: true + } + Item { + Layout.fillWidth: true + } + + // Header menu Row { id: headerLayout } } } diff --git a/meshroom/ui/qml/Controls/TabPanel.qml b/meshroom/ui/qml/Controls/TabPanel.qml new file mode 100644 index 00000000..fac384c1 --- /dev/null +++ b/meshroom/ui/qml/Controls/TabPanel.qml @@ -0,0 +1,93 @@ +import QtQuick 2.9 +import QtQuick.Controls 2.3 +import QtQuick.Layouts 1.3 + +Page { + id: root + + property alias headerBar: headerLayout.data + property alias footerContent: footerLayout.data + + property var tabs: [] + property int currentTab: 0 + + clip: true + + QtObject { + id: m + readonly property color paneBackgroundColor: Qt.darker(root.palette.window, 1.15) + } + padding: 0 + + header: Pane { + id: headerPane + padding: 0 + background: Rectangle { color: m.paneBackgroundColor } + + RowLayout { + width: parent.width + spacing: 0 + + TabBar { + id: mainTabBar + padding: 4 + Layout.fillWidth: true + onCurrentIndexChanged: root.currentTab = currentIndex + + Repeater { + model: root.tabs + + TabButton { + text: modelData + y: mainTabBar.padding + padding: 4 + width: 150 + background: Rectangle { + color: index === mainTabBar.currentIndex ? root.palette.window : Qt.darker(root.palette.window, 1.30) + } + + Rectangle { + property bool commonBorder : false + + property int lBorderwidth : index === mainTabBar.currentIndex ? 2 : 1 + property int rBorderwidth : index === mainTabBar.currentIndex ? 2 : 1 + property int tBorderwidth : index === mainTabBar.currentIndex ? 2 : 1 + property int bBorderwidth : 0 + + property int commonBorderWidth : 1 + + z : -1 + + color: Qt.darker(root.palette.window, 1.50) + + anchors { + left: parent.left + right: parent.right + top: parent.top + bottom: parent.bottom + + topMargin : commonBorder ? -commonBorderWidth : -tBorderwidth + bottomMargin : commonBorder ? -commonBorderWidth : -bBorderwidth + leftMargin : commonBorder ? -commonBorderWidth : -lBorderwidth + rightMargin : commonBorder ? -commonBorderWidth : -rBorderwidth + } + } + } + } + } + + Row { id: headerLayout } + } + } + + footer: Pane { + id: footerPane + visible: footerLayout.children.length > 0 + background: Rectangle { color: m.paneBackgroundColor } + + RowLayout { + id: footerLayout + width: parent.width + } + } +} diff --git a/meshroom/ui/qml/Controls/qmldir b/meshroom/ui/qml/Controls/qmldir index 00e89655..a1fc1f6a 100644 --- a/meshroom/ui/qml/Controls/qmldir +++ b/meshroom/ui/qml/Controls/qmldir @@ -6,4 +6,5 @@ Group 1.0 Group.qml MessageDialog 1.0 MessageDialog.qml Panel 1.0 Panel.qml SearchBar 1.0 SearchBar.qml +TabPanel 1.0 TabPanel.qml TextFileViewer 1.0 TextFileViewer.qml diff --git a/meshroom/ui/qml/GraphEditor/AttributeEditor.qml b/meshroom/ui/qml/GraphEditor/AttributeEditor.qml index 62a6b2f2..6baeca39 100644 --- a/meshroom/ui/qml/GraphEditor/AttributeEditor.qml +++ b/meshroom/ui/qml/GraphEditor/AttributeEditor.qml @@ -10,7 +10,6 @@ import Utils 1.0 ListView { id: root - property variant attributes: null property bool readOnly: false property int labelWidth: 180 @@ -23,10 +22,8 @@ ListView { clip: true ScrollBar.vertical: ScrollBar { id: scrollBar } - model: attributes - delegate: Loader { - active: !object.desc.advanced || GraphEditorSettings.showAdvancedAttributes + active: object.enabled && (!object.desc.advanced || GraphEditorSettings.showAdvancedAttributes) visible: active height: item ? item.implicitHeight : -spacing // compensate for spacing if item is hidden diff --git a/meshroom/ui/qml/GraphEditor/AttributeItemDelegate.qml b/meshroom/ui/qml/GraphEditor/AttributeItemDelegate.qml index 4a1b6572..83ab8ca3 100644 --- a/meshroom/ui/qml/GraphEditor/AttributeItemDelegate.qml +++ b/meshroom/ui/qml/GraphEditor/AttributeItemDelegate.qml @@ -238,6 +238,10 @@ RowLayout { property string displayValue: String(slider.active && slider.item.pressed ? slider.item.formattedValue : attribute.value) text: displayValue selectByMouse: true + // Note: Use autoScroll as a workaround for alignment + // When the value change keep the text align to the left to be able to read the most important part + // of the number. When we are editing (item is in focus), the content should follow the editing. + autoScroll: activeFocus validator: attribute.type == "FloatParam" ? doubleValidator : intValidator onEditingFinished: setTextFieldAttribute(text) onAccepted: { @@ -369,7 +373,7 @@ RowLayout { Component.onCompleted: { var cpt = Qt.createComponent("AttributeEditor.qml"); var obj = cpt.createObject(groupItem, - {'attributes': Qt.binding(function() { return attribute.value }), + {'model': Qt.binding(function() { return attribute.value }), 'readOnly': Qt.binding(function() { return root.readOnly }), 'labelWidth': 100, // reduce label width for children (space gain) }) diff --git a/meshroom/ui/qml/GraphEditor/AttributePin.qml b/meshroom/ui/qml/GraphEditor/AttributePin.qml index 27f78f0e..d7db4cfb 100755 --- a/meshroom/ui/qml/GraphEditor/AttributePin.qml +++ b/meshroom/ui/qml/GraphEditor/AttributePin.qml @@ -1,6 +1,8 @@ import QtQuick 2.9 import QtQuick.Controls 2.3 import QtQuick.Layouts 1.3 +import MaterialIcons 2.2 +import Utils 1.0 /** The representation of an Attribute on a Node. @@ -11,21 +13,26 @@ RowLayout { property var nodeItem property var attribute property bool readOnly: false + /// Whether to display an output pin for input attribute + property bool displayOutputPinForInput: true // position of the anchor for attaching and edge to this attribute pin - readonly property point edgeAnchorPos: Qt.point(edgeAnchor.x + edgeAnchor.width/2, - edgeAnchor.y + edgeAnchor.height/2) + readonly property point inputAnchorPos: Qt.point(inputAnchor.x + inputAnchor.width/2, + inputAnchor.y + inputAnchor.height/2) - readonly property bool isList: attribute.type == "ListAttribute" + readonly property point outputAnchorPos: Qt.point(outputAnchor.x + outputAnchor.width/2, + outputAnchor.y + outputAnchor.height/2) + + readonly property bool isList: attribute && attribute.type === "ListAttribute" signal childPinCreated(var childAttribute, var pin) signal childPinDeleted(var childAttribute, var pin) signal pressed(var mouse) - objectName: attribute.name + "." - layoutDirection: attribute.isOutput ? Qt.RightToLeft : Qt.LeftToRight - spacing: 2 + objectName: attribute ? attribute.name + "." : "" + layoutDirection: Qt.LeftToRight + spacing: 3 // Instantiate empty Items for each child attribute Repeater { @@ -39,23 +46,31 @@ RowLayout { } Rectangle { - id: edgeAnchor + visible: !attribute.isOutput + id: inputAnchor - width: 7 + width: 8 height: width radius: isList ? 0 : width/2 Layout.alignment: Qt.AlignVCenter - border.color: "#3e3e3e" - color: { - if(connectMA.containsMouse || connectMA.drag.active || (dropArea.containsDrag && dropArea.acceptableDrop)) - return nameLabel.palette.highlight - else if(attribute.isLink) - return "#3e3e3e" - return "white" + + border.color: Colors.sysPalette.mid + color: Colors.sysPalette.base + + Rectangle { + visible: inputConnectMA.containsMouse || childrenRepeater.count > 0 || (attribute && attribute.isLink) + radius: isList ? 0 : 2 + anchors.fill: parent + anchors.margins: 2 + color: { + if(inputConnectMA.containsMouse || inputConnectMA.drag.active || (inputDropArea.containsDrag && inputDropArea.acceptableDrop)) + return Colors.sysPalette.highlight + return Colors.sysPalette.text + } } DropArea { - id: dropArea + id: inputDropArea property bool acceptableDrop: false @@ -63,127 +78,289 @@ RowLayout { anchors.fill: parent anchors.margins: -2 // add horizontal negative margins according to the current layout - anchors.leftMargin: root.layoutDirection == Qt.RightToLeft ? -root.width * 0.3 : 0 - anchors.rightMargin: root.layoutDirection == Qt.LeftToRight ? -root.width * 0.3 : 0 + anchors.rightMargin: -root.width * 0.3 - keys: [dragTarget.objectName] + keys: [inputDragTarget.objectName] onEntered: { - // Filter drops: - if( drag.source.objectName != dragTarget.objectName // not an edge connector - || drag.source.nodeItem == dragTarget.nodeItem // connection between attributes of the same node - || dragTarget.isOutput // connection on an output - || dragTarget.attribute.isLink // already connected attribute - || (drag.source.isList && !dragTarget.isList) // connection between a list and a simple attribute - || (drag.source.isList && childrenRepeater.count) // source/target are lists but target already has children - ) + // Check if attributes are compatible to create a valid connection + if( root.readOnly // cannot connect on a read-only attribute + || drag.source.objectName != inputDragTarget.objectName // not an edge connector + || drag.source.baseType != inputDragTarget.baseType // not the same base type + || drag.source.nodeItem == inputDragTarget.nodeItem // connection between attributes of the same node + || inputDragTarget.attribute.isLink // already connected attribute + || (drag.source.isList && !inputDragTarget.isList) // connection between a list and a simple attribute + || (drag.source.isList && childrenRepeater.count) // source/target are lists but target already has children + || drag.source.connectorType == "input" // refuse to connect an "input pin" on another one (input attr can be connected to input attr, but not the graphical pin) + ) { + // Refuse attributes connection drag.accepted = false } - dropArea.acceptableDrop = drag.accepted + inputDropArea.acceptableDrop = drag.accepted + } + onExited: { + acceptableDrop = false + drag.source.dropAccepted = false } - onExited: acceptableDrop = false onDropped: { - _reconstruction.addEdge(drag.source.attribute, dragTarget.attribute) + _reconstruction.addEdge(drag.source.attribute, inputDragTarget.attribute) } } Item { - id: dragTarget + id: inputDragTarget objectName: "edgeConnector" + readonly property string connectorType: "input" readonly property alias attribute: root.attribute readonly property alias nodeItem: root.nodeItem readonly property bool isOutput: attribute.isOutput + readonly property string baseType: attribute.baseType readonly property alias isList: root.isList - anchors.centerIn: root.state == "Dragging" ? undefined : parent + property bool dragAccepted: false + anchors.verticalCenter: parent.verticalCenter + anchors.horizontalCenter: parent.horizontalCenter width: 4 height: 4 - Drag.keys: [dragTarget.objectName] - Drag.active: connectMA.drag.active + Drag.keys: [inputDragTarget.objectName] + Drag.active: inputConnectMA.drag.active Drag.hotSpot.x: width*0.5 Drag.hotSpot.y: height*0.5 - anchors.onCenterInChanged: { - // snap dragTarget to current mouse position in connectMA - if(anchors.centerIn == undefined) { - var pos = mapFromItem(connectMA, connectMA.mouseX, connectMA.mouseY) - x = pos.x - y = pos.y - } - } } MouseArea { - id: connectMA - drag.target: dragTarget + id: inputConnectMA + // If an input attribute is connected (isLink), we disable drag&drop + drag.target: attribute.isLink ? undefined : inputDragTarget drag.threshold: 0 enabled: !root.readOnly anchors.fill: parent // use the same negative margins as DropArea to ease pin selection - anchors.margins: dropArea.anchors.margins - anchors.leftMargin: dropArea.anchors.leftMargin - anchors.rightMargin: dropArea.anchors.rightMargin - onPressed: root.pressed(mouse) - onReleased: dragTarget.Drag.drop() + anchors.margins: inputDropArea.anchors.margins + anchors.leftMargin: inputDropArea.anchors.leftMargin + anchors.rightMargin: inputDropArea.anchors.rightMargin + onPressed: { + root.pressed(mouse) + } + onReleased: { + inputDragTarget.Drag.drop() + } hoverEnabled: true } Edge { - id: connectEdge + id: inputConnectEdge visible: false - point1x: parent.width / 2 - point1y: parent.width / 2 - point2x: dragTarget.x + dragTarget.width/2 - point2y: dragTarget.y + dragTarget.height/2 - color: nameLabel.color + point1x: inputDragTarget.x + inputDragTarget.width/2 + point1y: inputDragTarget.y + inputDragTarget.height/2 + point2x: parent.width / 2 + point2y: parent.width / 2 + color: palette.highlight + thickness: outputDragTarget.dropAccepted ? 2 : 1 } } + + // Attribute name Item { id: nameContainer Layout.fillWidth: true implicitHeight: childrenRect.height - TextMetrics { - id: metrics - property bool truncated: width > nameContainer.width - text: nameLabel.text - font: nameLabel.font - } - Label { id: nameLabel - property bool hovered: (connectMA.containsMouse || connectMA.drag.active || dropArea.containsDrag) - text: attribute.name + enabled: !root.readOnly + property bool hovered: (inputConnectMA.containsMouse || inputConnectMA.drag.active || inputDropArea.containsDrag || outputConnectMA.containsMouse || outputConnectMA.drag.active || outputDropArea.containsDrag) + text: attribute ? attribute.label : "" elide: hovered ? Text.ElideNone : Text.ElideMiddle width: hovered ? contentWidth : parent.width - font.pointSize: 5 - horizontalAlignment: attribute.isOutput ? Text.AlignRight : Text.AlignLeft - anchors.right: attribute.isOutput ? parent.right : undefined - - background: Rectangle { - visible: parent.hovered && metrics.truncated - anchors { fill: parent; leftMargin: -1; rightMargin: -1 } - color: parent.palette.window - } + font.pointSize: 7 + horizontalAlignment: attribute && attribute.isOutput ? Text.AlignRight : Text.AlignLeft + anchors.right: attribute && attribute.isOutput ? parent.right : undefined + rightPadding: 0 + color: hovered ? palette.highlight : palette.text } } - state: connectMA.pressed ? "Dragging" : "" + + Rectangle { + id: outputAnchor + + visible: displayOutputPinForInput || attribute.isOutput + width: 8 + height: width + radius: isList ? 0 : width / 2 + + Layout.alignment: Qt.AlignVCenter + + border.color: Colors.sysPalette.mid + color: Colors.sysPalette.base + + Rectangle { + visible: attribute.hasOutputConnections + radius: isList ? 0 : 2 + anchors.fill: parent + anchors.margins: 2 + color: { + if(outputConnectMA.containsMouse || outputConnectMA.drag.active || (outputDropArea.containsDrag && outputDropArea.acceptableDrop)) + return Colors.sysPalette.highlight + return Colors.sysPalette.text + } + } + + DropArea { + id: outputDropArea + + property bool acceptableDrop: false + + // add negative margins for DropArea to make the connection zone easier to reach + anchors.fill: parent + anchors.margins: -2 + // add horizontal negative margins according to the current layout + anchors.leftMargin: -root.width * 0.2 + + keys: [outputDragTarget.objectName] + onEntered: { + // Check if attributes are compatible to create a valid connection + if( drag.source.objectName != outputDragTarget.objectName // not an edge connector + || drag.source.baseType != outputDragTarget.baseType // not the same base type + || drag.source.nodeItem == outputDragTarget.nodeItem // connection between attributes of the same node + || drag.source.attribute.isLink // already connected attribute + || (!drag.source.isList && outputDragTarget.isList) // connection between a list and a simple attribute + || (drag.source.isList && childrenRepeater.count) // source/target are lists but target already has children + || drag.source.connectorType == "output" // refuse to connect an output pin on another one + ) + { + // Refuse attributes connection + drag.accepted = false + } + outputDropArea.acceptableDrop = drag.accepted + } + onExited: { + acceptableDrop = false + } + + onDropped: { + _reconstruction.addEdge(outputDragTarget.attribute, drag.source.attribute) + } + } + + Item { + id: outputDragTarget + objectName: "edgeConnector" + readonly property string connectorType: "output" + readonly property alias attribute: root.attribute + readonly property alias nodeItem: root.nodeItem + readonly property bool isOutput: attribute.isOutput + readonly property alias isList: root.isList + readonly property string baseType: attribute.baseType + property bool dropAccepted: false + anchors.horizontalCenter: parent.horizontalCenter + anchors.verticalCenter: parent.verticalCenter + width: 4 + height: 4 + Drag.keys: [outputDragTarget.objectName] + Drag.active: outputConnectMA.drag.active + Drag.hotSpot.x: width*0.5 + Drag.hotSpot.y: height*0.5 + } + + MouseArea { + id: outputConnectMA + drag.target: outputDragTarget + drag.threshold: 0 + anchors.fill: parent + // use the same negative margins as DropArea to ease pin selection + anchors.margins: outputDropArea.anchors.margins + anchors.leftMargin: outputDropArea.anchors.leftMargin + anchors.rightMargin: outputDropArea.anchors.rightMargin + + onPressed: root.pressed(mouse) + onReleased: outputDragTarget.Drag.drop() + + hoverEnabled: true + } + + Edge { + id: outputConnectEdge + visible: false + point1x: parent.width / 2 + point1y: parent.width / 2 + point2x: outputDragTarget.x + outputDragTarget.width/2 + point2y: outputDragTarget.y + outputDragTarget.height/2 + color: palette.highlight + thickness: outputDragTarget.dropAccepted ? 2 : 1 + } + } + + state: (inputConnectMA.pressed && !attribute.isLink) ? "DraggingInput" : outputConnectMA.pressed ? "DraggingOutput" : "" states: [ State { name: "" + AnchorChanges { + target: outputDragTarget + anchors.horizontalCenter: outputAnchor.horizontalCenter + anchors.verticalCenter: outputAnchor.verticalCenter + } + AnchorChanges { + target: inputDragTarget + anchors.horizontalCenter: inputAnchor.horizontalCenter + anchors.verticalCenter: inputAnchor.verticalCenter + } + PropertyChanges { + target: inputDragTarget + x: 0 + y: 0 + } + PropertyChanges { + target: outputDragTarget + x: 0 + y: 0 + } }, State { - name: "Dragging" + name: "DraggingInput" + AnchorChanges { + target: inputDragTarget + anchors.horizontalCenter: undefined + anchors.verticalCenter: undefined + } PropertyChanges { - target: connectEdge + target: inputConnectEdge z: 100 visible: true } + StateChangeScript { + script: { + var pos = inputDragTarget.mapFromItem(inputConnectMA, inputConnectMA.mouseX, inputConnectMA.mouseY); + inputDragTarget.x = pos.x - inputDragTarget.width/2; + inputDragTarget.y = pos.y - inputDragTarget.height/2; + } + } + }, + State { + name: "DraggingOutput" + AnchorChanges { + target: outputDragTarget + anchors.horizontalCenter: undefined + anchors.verticalCenter: undefined + } + PropertyChanges { + target: outputConnectEdge + z: 100 + visible: true + } + StateChangeScript { + script: { + var pos = outputDragTarget.mapFromItem(outputConnectMA, outputConnectMA.mouseX, outputConnectMA.mouseY); + outputDragTarget.x = pos.x - outputDragTarget.width/2; + outputDragTarget.y = pos.y - outputDragTarget.height/2; + } + } } ] diff --git a/meshroom/ui/qml/GraphEditor/CompatibilityManager.qml b/meshroom/ui/qml/GraphEditor/CompatibilityManager.qml index c9cf40a1..d9cad1ee 100644 --- a/meshroom/ui/qml/GraphEditor/CompatibilityManager.qml +++ b/meshroom/ui/qml/GraphEditor/CompatibilityManager.qml @@ -91,15 +91,15 @@ MessageDialog { Label { Layout.preferredWidth: 130 - text: compatibilityNodeDelegate.node.nodeType + text: compatibilityNodeDelegate.node ? compatibilityNodeDelegate.node.nodeType : "" } Label { Layout.fillWidth: true - text: compatibilityNodeDelegate.node.issueDetails + text: compatibilityNodeDelegate.node ? compatibilityNodeDelegate.node.issueDetails : "" } Label { - text: compatibilityNodeDelegate.node.canUpgrade ? MaterialIcons.check : MaterialIcons.clear - color: compatibilityNodeDelegate.node.canUpgrade ? "#4CAF50" : "#F44336" + text: compatibilityNodeDelegate.node && compatibilityNodeDelegate.node.canUpgrade ? MaterialIcons.check : MaterialIcons.clear + color: compatibilityNodeDelegate.node && compatibilityNodeDelegate.node.canUpgrade ? "#4CAF50" : "#F44336" font.family: MaterialIcons.fontFamily font.pointSize: 14 font.bold: true diff --git a/meshroom/ui/qml/GraphEditor/Edge.qml b/meshroom/ui/qml/GraphEditor/Edge.qml index 2af53fbb..e28a3f18 100644 --- a/meshroom/ui/qml/GraphEditor/Edge.qml +++ b/meshroom/ui/qml/GraphEditor/Edge.qml @@ -41,13 +41,16 @@ Shape { startY: root.startY fillColor: "transparent" strokeColor: "#3E3E3E" - capStyle: ShapePath.RoundCap + strokeStyle: edge != undefined && ((edge.src != undefined && edge.src.isOutput) || edge.dst == undefined) ? ShapePath.SolidLine : ShapePath.DashLine strokeWidth: 1 + // final visual width of this path (never below 1) + readonly property real visualWidth: Math.max(strokeWidth, 1) + dashPattern: [6/visualWidth, 4/visualWidth] + capStyle: ShapePath.RoundCap PathCubic { id: cubic - property real curveScale: 0.7 - property real ctrlPtDist: Math.abs(root.width * curveScale) + property real ctrlPtDist: 30 x: root.endX y: root.endY relativeControl1X: ctrlPtDist; relativeControl1Y: 0 @@ -58,7 +61,7 @@ Shape { EdgeMouseArea { id: edgeArea anchors.fill: parent - curveScale: cubic.curveScale + curveScale: cubic.ctrlPtDist / root.width // normalize by width acceptedButtons: Qt.LeftButton | Qt.RightButton thickness: root.thickness + 4 onPressed: root.pressed(arguments[0]) // can't get named args, use arguments array diff --git a/meshroom/ui/qml/GraphEditor/GraphEditor.qml b/meshroom/ui/qml/GraphEditor/GraphEditor.qml index b87fc085..9b6c2e11 100755 --- a/meshroom/ui/qml/GraphEditor/GraphEditor.qml +++ b/meshroom/ui/qml/GraphEditor/GraphEditor.qml @@ -14,7 +14,6 @@ Item { property variant uigraph: null /// Meshroom ui graph (UIGraph) readonly property variant graph: uigraph ? uigraph.graph : null /// core graph contained in ui graph property variant nodeTypesModel: null /// the list of node types that can be instantiated - property bool readOnly: false property var _attributeToDelegate: ({}) @@ -60,8 +59,6 @@ Item { /// Duplicate a node and optionnally all the following ones function duplicateNode(node, duplicateFollowingNodes) { - if(root.readOnly) - return; var nodes = uigraph.duplicateNode(node, duplicateFollowingNodes) selectNode(nodes[0]) } @@ -120,13 +117,9 @@ Item { onClicked: { if(mouse.button == Qt.RightButton) { - if(readOnly) - lockedMenu.popup(); - else { - // store mouse click position in 'draggable' coordinates as new node spawn position - newNodeMenu.spawnPosition = mouseArea.mapToItem(draggable, mouse.x, mouse.y); - newNodeMenu.popup(); - } + // store mouse click position in 'draggable' coordinates as new node spawn position + newNodeMenu.spawnPosition = mouseArea.mapToItem(draggable, mouse.x, mouse.y); + newNodeMenu.popup(); } } @@ -229,8 +222,8 @@ Item { id: edgeMenu property var currentEdge: null MenuItem { + enabled: edgeMenu.currentEdge && !edgeMenu.currentEdge.dst.node.locked text: "Remove" - enabled: !root.readOnly onTriggered: uigraph.removeEdge(edgeMenu.currentEdge) } } @@ -240,13 +233,13 @@ Item { id: edgesRepeater // delay edges loading after nodes (edges needs attribute pins to be created) - model: nodeRepeater.loaded ? root.graph.edges : undefined + model: nodeRepeater.loaded && root.graph ? root.graph.edges : undefined delegate: Edge { property var src: root._attributeToDelegate[edge.src] property var dst: root._attributeToDelegate[edge.dst] - property var srcAnchor: src.nodeItem.mapFromItem(src, src.edgeAnchorPos.x, src.edgeAnchorPos.y) - property var dstAnchor: dst.nodeItem.mapFromItem(dst, dst.edgeAnchorPos.x, dst.edgeAnchorPos.y) + property bool isValidEdge: src != undefined && dst != undefined + visible: isValidEdge property bool inFocus: containsMouse || (edgeMenu.opened && edgeMenu.currentEdge == edge) @@ -254,14 +247,16 @@ Item { color: inFocus ? activePalette.highlight : activePalette.text thickness: inFocus ? 2 : 1 opacity: 0.7 - point1x: src.nodeItem.x + srcAnchor.x - point1y: src.nodeItem.y + srcAnchor.y - point2x: dst.nodeItem.x + dstAnchor.x - point2y: dst.nodeItem.y + dstAnchor.y + point1x: isValidEdge ? src.globalX + src.outputAnchorPos.x : 0 + point1y: isValidEdge ? src.globalY + src.outputAnchorPos.y : 0 + point2x: isValidEdge ? dst.globalX + dst.inputAnchorPos.x : 0 + point2y: isValidEdge ? dst.globalY + dst.inputAnchorPos.y : 0 onPressed: { + const canEdit = !edge.dst.node.locked + if(event.button == Qt.RightButton) { - if(!root.readOnly && event.modifiers & Qt.AltModifier) { + if(canEdit && (event.modifiers & Qt.AltModifier)) { uigraph.removeEdge(edge) } else { @@ -277,20 +272,38 @@ Item { id: nodeMenu property var currentNode: null property bool canComputeNode: currentNode != null && uigraph.graph.canCompute(currentNode) + //canSubmitOrCompute: return int n : 0 >= n <= 3 | n=0 cannot submit or compute | n=1 can compute | n=2 can submit | n=3 can compute & submit + property int canSubmitOrCompute: currentNode != null && uigraph.graph.canSubmitOrCompute(currentNode) onClosed: currentNode = null MenuItem { text: "Compute" - enabled: !uigraph.computing && !root.readOnly && nodeMenu.canComputeNode - onTriggered: computeRequest(nodeMenu.currentNode) + enabled: nodeMenu.canComputeNode && (nodeMenu.canSubmitOrCompute%2 == 1) //canSubmit if canSubmitOrCompute == 1(can compute) or 3(can compute & submit) + onTriggered: { + computeRequest(nodeMenu.currentNode) + } } MenuItem { text: "Submit" - enabled: !uigraph.computing && !root.readOnly && nodeMenu.canComputeNode + enabled: nodeMenu.canComputeNode && nodeMenu.canSubmitOrCompute > 1 visible: uigraph.canSubmit height: visible ? implicitHeight : 0 onTriggered: submitRequest(nodeMenu.currentNode) } + MenuItem { + text: "Stop Computation" + enabled: nodeMenu.currentNode ? nodeMenu.currentNode.canBeStopped() : false + visible: enabled + height: visible ? implicitHeight : 0 + onTriggered: uigraph.stopNodeComputation(nodeMenu.currentNode) + } + MenuItem { + text: "Cancel Computation" + enabled: nodeMenu.currentNode ? nodeMenu.currentNode.canBeCanceled() : false + visible: enabled + height: visible ? implicitHeight : 0 + onTriggered: uigraph.cancelNodeComputation(nodeMenu.currentNode) + } MenuItem { text: "Open Folder" onTriggered: Qt.openUrlExternally(Filepath.stringToUrl(nodeMenu.currentNode.internalFolder)) @@ -298,7 +311,7 @@ Item { MenuSeparator {} MenuItem { text: "Duplicate Node" + (duplicateFollowingButton.hovered ? "s From Here" : "") - enabled: !root.readOnly + enabled: true onTriggered: duplicateNode(nodeMenu.currentNode, false) MaterialToolButton { id: duplicateFollowingButton @@ -313,7 +326,7 @@ Item { } MenuItem { text: "Remove Node" + (removeFollowingButton.hovered ? "s From Here" : "") - enabled: !root.readOnly + enabled: nodeMenu.currentNode ? !nodeMenu.currentNode.locked : false onTriggered: uigraph.removeNode(nodeMenu.currentNode) MaterialToolButton { id: removeFollowingButton @@ -329,7 +342,19 @@ Item { MenuSeparator {} MenuItem { text: "Delete Data" + (deleteFollowingButton.hovered ? " From Here" : "" ) + "..." - enabled: !root.readOnly + enabled: { + if(!nodeMenu.currentNode) + return false + // Check if the current node is locked (needed because it does not belong to its own duplicates list) + if(nodeMenu.currentNode.locked) + return false + // Check if at least one of the duplicate nodes is locked + for(let i = 0; i < nodeMenu.currentNode.duplicates.count; ++i) { + if(nodeMenu.currentNode.duplicates.at(i).locked) + return false + } + return true + } function showConfirmationDialog(deleteFollowing) { var obj = deleteDataDialog.createObject(root, @@ -362,7 +387,7 @@ Item { modal: false header.visible: false - text: "Delete Data computed by '" + node.label + (deleteFollowing ? "' and following Nodes?" : "'?") + text: "Delete Data of '" + node.label + (deleteFollowing ? "' and following Nodes?" : "'?") helperText: "Warning: This operation can not be undone." standardButtons: Dialog.Yes | Dialog.Cancel @@ -382,8 +407,8 @@ Item { Repeater { id: nodeRepeater - model: root.graph.nodes - property bool loaded: count === model.count + model: root.graph ? root.graph.nodes : undefined + property bool loaded: model ? count === model.count : false delegate: Node { id: nodeDelegate @@ -392,7 +417,7 @@ Item { node: object width: uigraph.layout.nodeWidth - readOnly: root.readOnly + selected: uigraph.selectedNode === node hovered: uigraph.hoveredNode === node onSelectedChanged: if(selected) forceActiveFocus() @@ -422,8 +447,8 @@ Item { onExited: uigraph.hoveredNode = null Keys.onDeletePressed: { - if(root.readOnly) - return; + if(node.locked) + return if(event.modifiers == Qt.AltModifier) uigraph.removeNodesFrom(node) else diff --git a/meshroom/ui/qml/GraphEditor/Node.qml b/meshroom/ui/qml/GraphEditor/Node.qml index b2c8e249..2825dbbc 100755 --- a/meshroom/ui/qml/GraphEditor/Node.qml +++ b/meshroom/ui/qml/GraphEditor/Node.qml @@ -2,35 +2,58 @@ import QtQuick 2.9 import QtQuick.Controls 2.3 import QtQuick.Layouts 1.3 import QtGraphicalEffects 1.0 -import Utils 1.0 +import Utils 1.0 +import MaterialIcons 2.2 + + +/** + * Visual representation of a Graph Node. + */ Item { id: root + + /// The underlying Node object property variant node - property bool readOnly: false - property color baseColor: defaultColor - property color shadowColor: "black" - readonly property bool isCompatibilityNode: node.hasOwnProperty("compatibilityIssue") - readonly property color defaultColor: isCompatibilityNode ? "#444" : "#607D8B" + /// Whether the node can be modified + property bool readOnly: node.locked + /// Whether the node is in compatibility mode + readonly property bool isCompatibilityNode: node ? node.hasOwnProperty("compatibilityIssue") : false + /// Mouse related states property bool selected: false property bool hovered: false + /// Styling + property color shadowColor: "#cc000000" + readonly property color defaultColor: isCompatibilityNode ? "#444" : activePalette.base + property color baseColor: defaultColor + Item { + id: m + property bool displayParams: false + } + + // Mouse interaction related signals signal pressed(var mouse) signal doubleClicked(var mouse) signal moved(var position) signal entered() signal exited() + + /// Emitted when child attribute pins are created signal attributePinCreated(var attribute, var pin) + /// Emitted when child attribute pins are deleted signal attributePinDeleted(var attribute, var pin) - implicitHeight: childrenRect.height - objectName: node.name - - SystemPalette { id: activePalette } + // use node name as object name to simplify debugging + objectName: node ? node.name : "" // initialize position with node coordinates - x: root.node.x - y: root.node.y + x: root.node ? root.node.x : undefined + y: root.node ? root.node.y : undefined + + implicitHeight: childrenRect.height + + SystemPalette { id: activePalette } Connections { target: root.node @@ -42,17 +65,37 @@ Item { } // Whether an attribute can be displayed as an attribute pin on the node - function isDisplayableAsPin(attribute) { + function isFileAttributeBaseType(attribute) { // ATM, only File attributes are meant to be connected // TODO: review this if we want to connect something else return attribute.type == "File" || (attribute.type == "ListAttribute" && attribute.desc.elementDesc.type == "File") } + // Used to generate list of node's label sharing the same uid + function generateDuplicateList() { + let str = "Shares internal folder (data) with:" + for(let i = 0; i < node.duplicates.count; ++i) { + if(i % 5 === 0) + str += "
" + + const currentNode = node.duplicates.at(i) + + if(i === node.duplicates.count - 1) { + str += currentNode.nameToLabel(currentNode.name) + return str + } + + str += (currentNode.nameToLabel(currentNode.name) + ", ") + } + return str + } + + // Main Layout MouseArea { width: parent.width height: body.height - drag.target: parent + drag.target: root // small drag threshold to avoid moving the node by mistake drag.threshold: 2 hoverEnabled: true @@ -63,125 +106,316 @@ Item { onExited: root.exited() drag.onActiveChanged: { if(!drag.active) - root.moved(Qt.point(root.x, root.y)) + { + root.moved(Qt.point(root.x, root.y)); + } } + cursorShape: drag.active ? Qt.ClosedHandCursor : Qt.ArrowCursor + // Selection border Rectangle { - anchors.fill: parent + anchors.fill: nodeContent anchors.margins: -border.width visible: root.selected || root.hovered border.width: 2.5 border.color: root.selected ? activePalette.highlight : Qt.darker(activePalette.highlight, 1.5) opacity: 0.9 + radius: background.radius color: "transparent" } Rectangle { id: background - anchors.fill: parent - color: activePalette.base + anchors.fill: nodeContent + color: Qt.lighter(activePalette.base, 1.4) layer.enabled: true - layer.effect: DropShadow { radius: 2; color: shadowColor } + layer.effect: DropShadow { radius: 3; color: shadowColor } + radius: 3 + opacity: 0.7 } - Column { - id: body + Rectangle { + id: nodeContent width: parent.width + height: childrenRect.height + color: "transparent" - Label { + // Data Layout + Column { + id: body width: parent.width - horizontalAlignment: Text.AlignHCenter - padding: 4 - text: node.label - color: "#EEE" - font.pointSize: 8 - background: Rectangle { - color: root.baseColor - } - } - // Node Chunks - NodeChunks { - defaultColor: Qt.darker(baseColor, 1.3) - implicitHeight: 3 - width: parent.width - model: node.chunks - } + // Header + Rectangle { + id: header + width: parent.width + height: headerLayout.height + color: root.selected ? activePalette.highlight : root.baseColor + radius: background.radius - Item { width: 1; height: 2} + // Fill header's bottom radius + Rectangle { + width: parent.width + height: parent.radius + anchors.bottom: parent.bottom + color: parent.color + z: -1 + } - Item { - width: parent.width + 6 - height: childrenRect.height - anchors.horizontalCenter: parent.horizontalCenter + // Header Layout + RowLayout { + id: headerLayout + width: parent.width + spacing: 0 - Column { - id: inputs - width: parent.width / 2 - spacing: 1 - Repeater { - model: node.attributes - delegate: Loader { - active: !object.isOutput && isDisplayableAsPin(object) - width: inputs.width + // Node Name + Label { + Layout.fillWidth: true + text: node ? node.label : "" + padding: 4 + color: root.selected ? "white" : activePalette.text + elide: Text.ElideMiddle + font.pointSize: 8 + } - sourceComponent: AttributePin { - id: inPin - nodeItem: root - attribute: object - readOnly: root.readOnly - Component.onCompleted: attributePinCreated(attribute, inPin) - Component.onDestruction: attributePinDeleted(attribute, inPin) - onPressed: root.pressed(mouse) - onChildPinCreated: attributePinCreated(childAttribute, inPin) - onChildPinDeleted: attributePinDeleted(childAttribute, inPin) + // Node State icons + RowLayout { + Layout.fillWidth: true + Layout.alignment: Qt.AlignRight + Layout.rightMargin: 2 + spacing: 2 + + // CompatibilityBadge icon for CompatibilityNodes + Loader { + active: root.isCompatibilityNode + sourceComponent: CompatibilityBadge { + sourceComponent: iconDelegate + canUpgrade: root.node.canUpgrade + issueDetails: root.node.issueDetails + } + } + + // Data sharing indicator + // Note: for an unknown reason, there are some performance issues with the UI refresh. + // Example: a node duplicated 40 times will be slow while creating another identical node + // (sharing the same uid) will not be as slow. If save, quit and reload, it will become slow. + MaterialToolButton { + property string baseText: "Shares internal folder (data) with other node(s). Hold click for details." + property string toolTipText: visible ? baseText : "" + visible: node.hasDuplicates + text: MaterialIcons.layers + font.pointSize: 7 + padding: 2 + palette.text: Colors.sysPalette.text + ToolTip.text: toolTipText + + onPressed: { offsetReleased.running = false; toolTipText = visible ? generateDuplicateList() : "" } + onReleased: { toolTipText = "" ; offsetReleased.running = true } + onCanceled: released() + + // Used for a better user experience with the button + // Avoid to change the text too quickly + Timer { + id: offsetReleased + interval: 750; running: false; repeat: false + onTriggered: parent.toolTipText = visible ? parent.baseText : "" + } + } + + // Submitted externally indicator + MaterialLabel { + visible: ["SUBMITTED", "RUNNING"].includes(node.globalStatus) && node.chunks.count > 0 && node.globalExecMode === "EXTERN" + text: MaterialIcons.cloud + padding: 2 + font.pointSize: 7 + palette.text: Colors.sysPalette.text + ToolTip.text: "Computed Externally" + } + + // Lock indicator + MaterialLabel { + visible: root.readOnly + text: MaterialIcons.lock + padding: 2 + font.pointSize: 7 + palette.text: "red" + ToolTip.text: "Locked" } } } } - Column { - id: outputs - width: parent.width / 2 - anchors.right: parent.right - spacing: 1 - Repeater { - model: node.attributes - delegate: Loader { - active: object.isOutput && isDisplayableAsPin(object) - anchors.right: parent.right - width: outputs.width + // Node Chunks + NodeChunks { + defaultColor: Colors.sysPalette.mid + implicitHeight: 3 + width: parent.width + model: node ? node.chunks : undefined - sourceComponent: AttributePin { - id: outPin - nodeItem: root - attribute: object - readOnly: root.readOnly - onPressed: root.pressed(mouse) - Component.onCompleted: attributePinCreated(object, outPin) - Component.onDestruction: attributePinDeleted(attribute, outPin) + Rectangle { + anchors.fill: parent + color: Colors.sysPalette.mid + z: -1 + } + } + + // Vertical Spacer + Item { width: parent.width; height: 2 } + + // Input/Output Attributes + Item { + id: nodeAttributes + width: parent.width - 2 + height: childrenRect.height + anchors.horizontalCenter: parent.horizontalCenter + + enabled: !root.isCompatibilityNode + + Column { + id: attributesColumn + width: parent.width + spacing: 5 + bottomPadding: 2 + + Column { + id: outputs + width: parent.width + spacing: 3 + Repeater { + model: node ? node.attributes : undefined + + delegate: Loader { + id: outputLoader + active: object.isOutput && isFileAttributeBaseType(object) + anchors.right: parent.right + width: outputs.width + + sourceComponent: AttributePin { + id: outPin + nodeItem: root + attribute: object + + property real globalX: root.x + nodeAttributes.x + outputs.x + outputLoader.x + outPin.x + property real globalY: root.y + nodeAttributes.y + outputs.y + outputLoader.y + outPin.y + + onPressed: root.pressed(mouse) + Component.onCompleted: attributePinCreated(object, outPin) + Component.onDestruction: attributePinDeleted(attribute, outPin) + } + } + } + } + + Column { + id: inputs + width: parent.width + spacing: 3 + Repeater { + model: node ? node.attributes : undefined + delegate: Loader { + id: inputLoader + active: !object.isOutput && isFileAttributeBaseType(object) + width: inputs.width + + sourceComponent: AttributePin { + id: inPin + nodeItem: root + attribute: object + + property real globalX: root.x + nodeAttributes.x + inputs.x + inputLoader.x + inPin.x + property real globalY: root.y + nodeAttributes.y + inputs.y + inputLoader.y + inPin.y + + readOnly: root.readOnly + Component.onCompleted: attributePinCreated(attribute, inPin) + Component.onDestruction: attributePinDeleted(attribute, inPin) + onPressed: root.pressed(mouse) + onChildPinCreated: attributePinCreated(childAttribute, inPin) + onChildPinDeleted: attributePinDeleted(childAttribute, inPin) + } + } + } + } + + // Vertical Spacer + Rectangle { + height: inputParams.height > 0 ? 3 : 0 + visible: (height == 3) + Behavior on height { PropertyAnimation {easing.type: Easing.Linear} } + width: parent.width + color: Colors.sysPalette.mid + MaterialToolButton { + text: " " + width: parent.width + height: parent.height + padding: 0 + spacing: 0 + anchors.margins: 0 + font.pointSize: 6 + onClicked: { + m.displayParams = ! m.displayParams + } + } + } + + Rectangle { + id: inputParamsRect + width: parent.width + height: childrenRect.height + color: "transparent" + + Column { + id: inputParams + width: parent.width + spacing: 3 + Repeater { + id: inputParamsRepeater + model: node ? node.attributes : undefined + delegate: Loader { + id: paramLoader + active: !object.isOutput && !isFileAttributeBaseType(object) + property bool isFullyActive: (m.displayParams || object.isLink || object.hasOutputConnections) + width: parent.width + + sourceComponent: AttributePin { + id: inPin + nodeItem: root + property real globalX: root.x + nodeAttributes.x + inputParamsRect.x + paramLoader.x + inPin.x + property real globalY: root.y + nodeAttributes.y + inputParamsRect.y + paramLoader.y + inPin.y + + height: isFullyActive ? childrenRect.height : 0 + Behavior on height { PropertyAnimation {easing.type: Easing.Linear} } + visible: (height == childrenRect.height) + attribute: object + readOnly: root.readOnly + Component.onCompleted: attributePinCreated(attribute, inPin) + Component.onDestruction: attributePinDeleted(attribute, inPin) + onPressed: root.pressed(mouse) + onChildPinCreated: attributePinCreated(childAttribute, inPin) + onChildPinDeleted: attributePinDeleted(childAttribute, inPin) + } + } + } + } + } + + MaterialToolButton { + text: root.hovered ? (m.displayParams ? MaterialIcons.arrow_drop_up : MaterialIcons.arrow_drop_down) : " " + Layout.alignment: Qt.AlignBottom + width: parent.width + height: 5 + padding: 0 + spacing: 0 + anchors.margins: 0 + font.pointSize: 10 + onClicked: { + m.displayParams = ! m.displayParams } } } } } - Item { width: 1; height: 2} - } - - // CompatibilityBadge icon for CompatibilityNodes - Loader { - active: root.isCompatibilityNode - anchors { - right: parent.right - top: parent.top - margins: -4 - } - sourceComponent: CompatibilityBadge { - sourceComponent: iconDelegate - canUpgrade: root.node.canUpgrade - issueDetails: root.node.issueDetails - } } } } + diff --git a/meshroom/ui/qml/GraphEditor/NodeChunks.qml b/meshroom/ui/qml/GraphEditor/NodeChunks.qml index b6842092..ac426db4 100644 --- a/meshroom/ui/qml/GraphEditor/NodeChunks.qml +++ b/meshroom/ui/qml/GraphEditor/NodeChunks.qml @@ -1,6 +1,7 @@ import QtQuick 2.7 +import Utils 1.0 -import "common.js" as Common +//import "common.js" as Common ListView { id: root @@ -19,7 +20,7 @@ ListView { id: chunkDelegate height: root.chunkHeight width: root.chunkWidth - color: Common.getChunkColor(object, {"NONE": root.defaultColor}) + color: Colors.getChunkColor(object, {"NONE": root.defaultColor}) } } diff --git a/meshroom/ui/qml/GraphEditor/NodeDocumentation.qml b/meshroom/ui/qml/GraphEditor/NodeDocumentation.qml new file mode 100644 index 00000000..337f3e37 --- /dev/null +++ b/meshroom/ui/qml/GraphEditor/NodeDocumentation.qml @@ -0,0 +1,38 @@ +import QtQuick 2.11 +import QtQuick.Controls 2.3 +import QtQuick.Layouts 1.3 +import Controls 1.0 + +import "common.js" as Common + +/** + * Displays Node documentation + */ +FocusScope { + id: root + + property variant node + + SystemPalette { id: activePalette } + + ScrollView { + width: parent.width + height: parent.height + ScrollBar.vertical.policy: ScrollBar.AlwaysOn + ScrollBar.horizontal.policy: ScrollBar.AlwaysOff + clip: true + + TextEdit { + width: parent.parent.width + height: parent.height + + padding: 8 + textFormat: TextEdit.MarkdownText + selectByMouse: true + selectionColor: activePalette.highlight + color: activePalette.text + text: node.documentation + wrapMode: TextEdit.Wrap + } + } +} diff --git a/meshroom/ui/qml/GraphEditor/NodeEditor.qml b/meshroom/ui/qml/GraphEditor/NodeEditor.qml index b0d1041d..ba3e33bf 100644 --- a/meshroom/ui/qml/GraphEditor/NodeEditor.qml +++ b/meshroom/ui/qml/GraphEditor/NodeEditor.qml @@ -122,7 +122,7 @@ Panel { currentIndex: tabBar.currentIndex AttributeEditor { - attributes: root.node.attributes + model: root.node.attributes readOnly: root.readOnly || root.isCompatibilityNode onAttributeDoubleClicked: root.attributeDoubleClicked(mouse, attribute) onUpgradeRequest: root.upgradeRequest() @@ -148,6 +148,12 @@ Panel { chunkCurrentIndex: m.chunkCurrentIndex onChangeCurrentChunk: { m.chunkCurrentIndex = chunkIndex } } + + NodeDocumentation { + id: nodeDocumentation + Layout.fillWidth: true + node: root.node + } } } } @@ -185,6 +191,12 @@ Panel { leftPadding: 8 rightPadding: leftPadding } + TabButton { + text: "Documentation" + width: implicitWidth + leftPadding: 8 + rightPadding: leftPadding + } } } } diff --git a/meshroom/ui/qml/GraphEditor/StatViewer.qml b/meshroom/ui/qml/GraphEditor/StatViewer.qml index 6ebf29c9..3923ef40 100644 --- a/meshroom/ui/qml/GraphEditor/StatViewer.qml +++ b/meshroom/ui/qml/GraphEditor/StatViewer.qml @@ -6,6 +6,7 @@ import Utils 1.0 import Charts 1.0 import MaterialIcons 2.2 + Item { id: root @@ -163,7 +164,7 @@ Item { root.nbReads = categories[0].length-1 for(var j = 0; j < nbCores; j++) { - var lineSerie = cpuChart.createSeries(ChartView.SeriesTypeLine, "CPU" + j, valueAxisX, valueAxisY) + var lineSerie = cpuChart.createSeries(ChartView.SeriesTypeLine, "CPU" + j, valueCpuX, valueCpuY) if(categories[j].length === 1) { lineSerie.append(0, categories[j][0]) @@ -176,7 +177,7 @@ Item { lineSerie.color = colors[j % colors.length] } - var averageLine = cpuChart.createSeries(ChartView.SeriesTypeLine, "AVERAGE", valueAxisX, valueAxisY) + var averageLine = cpuChart.createSeries(ChartView.SeriesTypeLine, "AVERAGE", valueCpuX, valueCpuY) var average = [] for(var l = 0; l < categories[0].length; l++) { @@ -226,7 +227,7 @@ Item { root.ramLabel = "RAM Max Peak: " } - var ramSerie = ramChart.createSeries(ChartView.SeriesTypeLine, root.ramLabel + root.ramTotal + "GB", valueAxisX2, valueAxisRam) + var ramSerie = ramChart.createSeries(ChartView.SeriesTypeLine, root.ramLabel + root.ramTotal + "GB", valueRamX, valueRamY) if(ram.length === 1) { // Create 2 entries if we have only one input value to create a segment that can be display @@ -252,9 +253,9 @@ Item { var gpuUsed = getPropertyWithDefault(jsonObject.computer.curves, 'gpuUsed', 0) var gpuTemperature = getPropertyWithDefault(jsonObject.computer.curves, 'gpuTemperature', 0) - var gpuUsedSerie = gpuChart.createSeries(ChartView.SeriesTypeLine, "GPU", valueAxisX3, valueAxisY3) - var gpuUsedMemorySerie = gpuChart.createSeries(ChartView.SeriesTypeLine, "Memory", valueAxisX3, valueAxisY3) - var gpuTemperatureSerie = gpuChart.createSeries(ChartView.SeriesTypeLine, "Temperature", valueAxisX3, valueAxisY3) + var gpuUsedSerie = gpuChart.createSeries(ChartView.SeriesTypeLine, "GPU", valueGpuX, valueGpuY) + var gpuUsedMemorySerie = gpuChart.createSeries(ChartView.SeriesTypeLine, "Memory", valueGpuX, valueGpuY) + var gpuTemperatureSerie = gpuChart.createSeries(ChartView.SeriesTypeLine, "Temperature", valueGpuX, valueGpuY) if(gpuUsedMemory.length === 1) { gpuUsedSerie.append(0, gpuUsed[0]) @@ -364,7 +365,7 @@ Item { } } - ChartView { + InteractiveChartView { id: cpuChart Layout.fillWidth: true @@ -383,7 +384,7 @@ Item { title: "CPU: " + root.nbCores + " cores, " + root.cpuFrequency + "Hz" ValueAxis { - id: valueAxisY + id: valueCpuY min: 0 max: 100 titleText: "%" @@ -396,7 +397,7 @@ Item { } ValueAxis { - id: valueAxisX + id: valueCpuX min: 0 max: root.deltaTime * Math.max(1, root.nbReads) titleText: "Minutes" @@ -419,7 +420,7 @@ Item { ColumnLayout { - ChartView { + InteractiveChartView { id: ramChart margins.top: 0 margins.bottom: 0 @@ -438,7 +439,7 @@ Item { title: root.ramLabel + root.ramTotal + "GB" ValueAxis { - id: valueAxisY2 + id: valueRamY min: 0 max: 100 titleText: "%" @@ -451,20 +452,7 @@ Item { } ValueAxis { - id: valueAxisRam - min: 0 - max: root.ramTotal - titleText: "GB" - color: textColor - gridLineColor: textColor - minorGridLineColor: textColor - shadesColor: textColor - shadesBorderColor: textColor - labelsColor: textColor - } - - ValueAxis { - id: valueAxisX2 + id: valueRamX min: 0 max: root.deltaTime * Math.max(1, root.nbReads) titleText: "Minutes" @@ -487,7 +475,7 @@ Item { ColumnLayout { - ChartView { + InteractiveChartView { id: gpuChart Layout.fillWidth: true @@ -506,7 +494,7 @@ Item { title: (root.gpuName || root.gpuTotalMemory) ? ("GPU: " + root.gpuName + ", " + root.gpuTotalMemory + "MB") : "No GPU" ValueAxis { - id: valueAxisY3 + id: valueGpuY min: 0 max: root.gpuMaxAxis titleText: "%, °C" @@ -519,7 +507,7 @@ Item { } ValueAxis { - id: valueAxisX3 + id: valueGpuX min: 0 max: root.deltaTime * Math.max(1, root.nbReads) titleText: "Minutes" diff --git a/meshroom/ui/qml/GraphEditor/TaskManager.qml b/meshroom/ui/qml/GraphEditor/TaskManager.qml new file mode 100644 index 00000000..52fa209d --- /dev/null +++ b/meshroom/ui/qml/GraphEditor/TaskManager.qml @@ -0,0 +1,289 @@ +import QtQuick 2.7 +import QtQuick.Controls 2.3 +import QtQuick.Layouts 1.3 +import Controls 1.0 +import Utils 1.0 +import MaterialIcons 2.2 + +Item { + id: root + + implicitWidth: 500 + implicitHeight: 500 + + property var uigraph + property var taskManager + + SystemPalette { id: activePalette } + + property color textColor: Colors.sysPalette.text + property color bgColor: Qt.darker(Colors.sysPalette.window, 1.15) + property color headBgColor: Qt.darker(Colors.sysPalette.window, 1.30) + property color tableBorder: Colors.sysPalette.window + property int borderWidth: 3 + + function selectNode(node) { + uigraph.selectedNode = node + } + + ScrollBar { + id: hbar + hoverEnabled: true + active: true + orientation: Qt.Horizontal + size: root.width / taskList.width + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + } + + TextMetrics { + id: nbMetrics + text: root.taskManager.nodes.count + } + + TextMetrics { + id: statusMetrics + text: "SUBMITTED" + } + + TextMetrics { + id: chunksMetrics + text: "Chunks Done" + } + + TextMetrics { + id: execMetrics + text: "Exec Mode" + } + + TextMetrics { + id: progressMetrics + text: "Progress" + } + + ListView { + id: taskList + anchors.fill: parent + ScrollBar.vertical: ScrollBar {} + + model: parent.taskManager.nodes + spacing: 3 + + headerPositioning: ListView.OverlayHeader + + header: RowLayout { + height: 30 + spacing: 3 + + width: parent.width + + z: 2 + + Label { + text: qsTr("Nb") + Layout.preferredWidth: nbMetrics.width + 20 + Layout.preferredHeight: parent.height + horizontalAlignment: Label.AlignHCenter + verticalAlignment: Label.AlignVCenter + background: Rectangle { + color: headBgColor + } + } + Label { + text: qsTr("Node") + Layout.preferredWidth: 250 + Layout.preferredHeight: parent.height + horizontalAlignment: Label.AlignHCenter + verticalAlignment: Label.AlignVCenter + background: Rectangle { + color: headBgColor + } + } + Label { + text: qsTr("State") + Layout.preferredWidth: statusMetrics.width + 20 + Layout.preferredHeight: parent.height + horizontalAlignment: Label.AlignHCenter + verticalAlignment: Label.AlignVCenter + background: Rectangle { + color: headBgColor + } + } + Label { + text: qsTr("Chunks Done") + Layout.preferredWidth: chunksMetrics.width + 20 + Layout.preferredHeight: parent.height + horizontalAlignment: Label.AlignHCenter + verticalAlignment: Label.AlignVCenter + background: Rectangle { + color: headBgColor + } + } + Label { + text: qsTr("Exec Mode") + Layout.preferredWidth: execMetrics.width + 20 + Layout.preferredHeight: parent.height + horizontalAlignment: Label.AlignHCenter + verticalAlignment: Label.AlignVCenter + background: Rectangle { + color: headBgColor + } + } + Label { + text: qsTr("Progress") + Layout.fillWidth: true + Layout.minimumWidth: progressMetrics.width + 20 + Layout.preferredHeight: parent.height + horizontalAlignment: Label.AlignHCenter + verticalAlignment: Label.AlignVCenter + background: Rectangle { + color: headBgColor + } + } + } + + delegate: RowLayout { + width: parent.width + height: 18 + spacing: 3 + + function getNbFinishedChunks(chunks) { + var nbSuccess = 0 + for(var i = 0; i < chunks.count; i++) { + if(chunks.at(i).statusName === "SUCCESS") { + nbSuccess += 1 + } + } + return nbSuccess + } + + Label { + text: index+1 + Layout.preferredWidth: nbMetrics.width + 20 + Layout.preferredHeight: parent.height + horizontalAlignment: Label.AlignHCenter + verticalAlignment: Label.AlignVCenter + color: object == uigraph.selectedNode ? Colors.sysPalette.window : Colors.sysPalette.text + background: Rectangle { + color: object == uigraph.selectedNode ? Colors.sysPalette.text : bgColor + } + + MouseArea { + anchors.fill: parent + onPressed: { + selectNode(object) + } + } + } + Label { + text: object.label + Layout.preferredWidth: 250 + Layout.preferredHeight: parent.height + horizontalAlignment: Label.AlignHCenter + verticalAlignment: Label.AlignVCenter + color: object == uigraph.selectedNode ? Colors.sysPalette.window : Colors.sysPalette.text + background: Rectangle { + color: object == uigraph.selectedNode ? Colors.sysPalette.text : bgColor + } + + MouseArea { + anchors.fill: parent + onPressed: { + selectNode(object) + } + } + } + Label { + text: object.globalStatus + Layout.preferredWidth: statusMetrics.width + 20 + Layout.preferredHeight: parent.height + horizontalAlignment: Label.AlignHCenter + verticalAlignment: Label.AlignVCenter + color: object == uigraph.selectedNode ? Colors.sysPalette.window : Colors.sysPalette.text + background: Rectangle { + color: object == uigraph.selectedNode ? Colors.sysPalette.text : bgColor + } + + MouseArea { + anchors.fill: parent + onPressed: { + selectNode(object) + } + } + } + Label { + text: getNbFinishedChunks(object.chunks) + "/" + object.chunks.count + Layout.preferredWidth: chunksMetrics.width + 20 + Layout.preferredHeight: parent.height + horizontalAlignment: Label.AlignHCenter + verticalAlignment: Label.AlignVCenter + color: object == uigraph.selectedNode ? Colors.sysPalette.window : Colors.sysPalette.text + background: Rectangle { + color: object == uigraph.selectedNode ? Colors.sysPalette.text : bgColor + } + + MouseArea { + anchors.fill: parent + onPressed: { + selectNode(object) + } + } + } + Label { + text: object.globalExecMode + Layout.preferredWidth: execMetrics.width + 20 + Layout.preferredHeight: parent.height + horizontalAlignment: Label.AlignHCenter + verticalAlignment: Label.AlignVCenter + color: object == uigraph.selectedNode ? Colors.sysPalette.window : Colors.sysPalette.text + background: Rectangle { + color: object == uigraph.selectedNode ? Colors.sysPalette.text : bgColor + } + + MouseArea { + anchors.fill: parent + onPressed: { + selectNode(object) + } + } + } + Item { + Layout.fillWidth: true + Layout.minimumWidth: progressMetrics.width + 20 + Layout.preferredHeight: parent.height + + ListView { + id: chunkList + width: parent.width + height: parent.height + orientation: ListView.Horizontal + model: object.chunks + property var node: object + + spacing: 3 + + delegate: Label { + width: (ListView.view.width / ListView.view.model.count) -3 + height: ListView.view.height + anchors.verticalCenter: parent.verticalCenter + background: Rectangle { + color: Colors.getChunkColor(object, {"NONE": bgColor}) + radius: 3 + border.width: 2 + border.color: chunkList.node == uigraph.selectedNode ? Colors.sysPalette.text : Colors.getChunkColor(object, {"NONE": bgColor}) + + } + + MouseArea { + anchors.fill: parent + onPressed: { + selectNode(chunkList.node) + } + } + } + } + } + } + } +} diff --git a/meshroom/ui/qml/GraphEditor/common.js b/meshroom/ui/qml/GraphEditor/common.js index dd635269..52c24976 100644 --- a/meshroom/ui/qml/GraphEditor/common.js +++ b/meshroom/ui/qml/GraphEditor/common.js @@ -4,7 +4,8 @@ var statusColors = { "SUBMITTED": "#009688", "RUNNING": "#FF9800", "ERROR": "#F44336", - "SUCCESS": "#4CAF50" + "SUCCESS": "#4CAF50", + "STOPPED": "#E91E63" } var statusColorsExternOverrides = { diff --git a/meshroom/ui/qml/GraphEditor/qmldir b/meshroom/ui/qml/GraphEditor/qmldir index 2d1e6574..ae565ef6 100644 --- a/meshroom/ui/qml/GraphEditor/qmldir +++ b/meshroom/ui/qml/GraphEditor/qmldir @@ -11,3 +11,4 @@ AttributeItemDelegate 1.0 AttributeItemDelegate.qml CompatibilityBadge 1.0 CompatibilityBadge.qml CompatibilityManager 1.0 CompatibilityManager.qml singleton GraphEditorSettings 1.0 GraphEditorSettings.qml +TaskManager 1.0 TaskManager.qml diff --git a/meshroom/ui/qml/ImageGallery/ImageDelegate.qml b/meshroom/ui/qml/ImageGallery/ImageDelegate.qml index 9100216f..bc5beb11 100644 --- a/meshroom/ui/qml/ImageGallery/ImageDelegate.qml +++ b/meshroom/ui/qml/ImageGallery/ImageDelegate.qml @@ -56,6 +56,12 @@ Item { enabled: !root.readOnly onClicked: removeRequest() } + MenuItem { + text: "Define As Center Image" + property var activeNode: _reconstruction.activeNodes.get("SfMTransform").node + enabled: !root.readOnly && _viewpoint.viewId != -1 && _reconstruction && activeNode + onClicked: activeNode.attribute("transformation").value = _viewpoint.viewId.toString() + } } ColumnLayout { diff --git a/meshroom/ui/qml/ImageGallery/ImageGallery.qml b/meshroom/ui/qml/ImageGallery/ImageGallery.qml index d4dcd759..1658db1f 100644 --- a/meshroom/ui/qml/ImageGallery/ImageGallery.qml +++ b/meshroom/ui/qml/ImageGallery/ImageGallery.qml @@ -16,10 +16,12 @@ Panel { property variant cameraInits property variant cameraInit - property variant hdrCameraInit + property variant tempCameraInit readonly property alias currentItem: grid.currentItem readonly property string currentItemSource: grid.currentItem ? grid.currentItem.source : "" readonly property var currentItemMetadata: grid.currentItem ? grid.currentItem.metadata : undefined + readonly property int centerViewId: (_reconstruction && _reconstruction.sfmTransform) ? parseInt(_reconstruction.sfmTransform.attribute("transformation").value) : 0 + property int defaultCellSize: 160 property int currentIndex: 0 property bool readOnly: false @@ -36,7 +38,7 @@ Panel { QtObject { id: m - property variant currentCameraInit: displayHDR.checked ? _reconstruction.hdrCameraInit : root.cameraInit + property variant currentCameraInit: _reconstruction.tempCameraInit ? _reconstruction.tempCameraInit : root.cameraInit property variant viewpoints: currentCameraInit ? currentCameraInit.attribute('viewpoints').value : undefined property bool readOnly: root.readOnly || displayHDR.checked } @@ -74,7 +76,7 @@ Panel { SensorDBDialog { id: sensorDBDialog - sensorDatabase: Filepath.stringToUrl(cameraInit.attribute("sensorDatabase").value) + sensorDatabase: cameraInit ? Filepath.stringToUrl(cameraInit.attribute("sensorDatabase").value) : "" readOnly: _reconstruction.computing onUpdateIntrinsicsRequest: _reconstruction.rebuildIntrinsics(cameraInit) } @@ -189,6 +191,16 @@ Panel { } } + // Center of SfMTransform + Loader { + id: sfmTransformIndicator + active: viewpoint && (viewpoint.get("viewId").value == centerViewId) + sourceComponent: ImageBadge { + text: MaterialIcons.gamepad + ToolTip.text: "Camera used to define the center of the scene." + } + } + Item { Layout.fillWidth: true } // Reconstruction status indicator @@ -331,47 +343,121 @@ Panel { } footerContent: RowLayout { - - // Image count - RowLayout { - Layout.fillWidth: true - spacing: 8 - RowLayout { - MaterialLabel { text: MaterialIcons.image } - Label { text: grid.model.count } - } - RowLayout { - visible: _reconstruction.cameraInit && _reconstruction.nbCameras - MaterialLabel { text: MaterialIcons.videocam } - Label { text: _reconstruction.cameraInit ? _reconstruction.nbCameras : 0 } - } + // Images count + MaterialToolLabel { + Layout.minimumWidth: childrenRect.width + ToolTip.text: grid.model.count + " Input Images" + iconText: MaterialIcons.image + label: grid.model.count.toString() + // enabled: grid.model.count > 0 + // margin: 4 } - - MaterialToolButton { - id: displayHDR - font.pointSize: 20 - padding: 0 - anchors.margins: 0 - implicitHeight: 14 - ToolTip.text: "Visualize HDR images" - text: MaterialIcons.hdr_on - visible: _reconstruction.ldr2hdr - enabled: visible && _reconstruction.ldr2hdr.isComputed() - onEnabledChanged: { - // Reset the toggle to avoid getting stuck - // with the HDR node checked but disabled. - checked = false; - } - checkable: true - checked: false - onClicked: { _reconstruction.setupLDRToHDRCameraInit(); } + // cameras count + MaterialToolLabel { + Layout.minimumWidth: childrenRect.width + ToolTip.text: label + " Estimated Cameras" + iconText: MaterialIcons.videocam + label: _reconstruction ? _reconstruction.nbCameras.toString() : "0" + // margin: 4 + // enabled: _reconstruction.cameraInit && _reconstruction.nbCameras } Item { Layout.fillHeight: true; Layout.fillWidth: true } + MaterialToolLabelButton { + id: displayHDR + Layout.minimumWidth: childrenRect.width + property var activeNode: _reconstruction.activeNodes.get("LdrToHdrMerge").node + ToolTip.text: "Visualize HDR images: " + (activeNode ? activeNode.label : "No Node") + iconText: MaterialIcons.filter + label: activeNode ? activeNode.attribute("nbBrackets").value : "" + visible: activeNode + enabled: activeNode && activeNode.isComputed + property string nodeID: activeNode ? (activeNode.label + activeNode.isComputed) : "" + onNodeIDChanged: { + if(checked) { + open(); + } + } + onEnabledChanged: { + // Reset the toggle to avoid getting stuck + // with the HDR node checked but disabled. + if(checked) { + checked = false; + close(); + } + } + checkable: true + checked: false + onClicked: { + if(checked) { + open(); + } else { + close(); + } + } + function open() { + if(imageProcessing.checked) + imageProcessing.checked = false; + _reconstruction.setupTempCameraInit(activeNode, "outSfMData"); + } + function close() { + _reconstruction.clearTempCameraInit(); + } + } + + MaterialToolButton { + id: imageProcessing + Layout.minimumWidth: childrenRect.width + + property var activeNode: _reconstruction.activeNodes.get("ImageProcessing").node + font.pointSize: 15 + padding: 0 + ToolTip.text: "Preprocessed Images: " + (activeNode ? activeNode.label : "No Node") + text: MaterialIcons.wallpaper + visible: activeNode && activeNode.attribute("outSfMData").value + enabled: activeNode && activeNode.isComputed + property string nodeID: activeNode ? (activeNode.label + activeNode.isComputed) : "" + onNodeIDChanged: { + if(checked) { + open(); + } + } + onEnabledChanged: { + // Reset the toggle to avoid getting stuck + // with the HDR node checked but disabled. + if(checked) { + checked = false; + close(); + } + } + checkable: true + checked: false + onClicked: { + if(checked) { + open(); + } else { + close(); + } + } + function open() { + if(displayHDR.checked) + displayHDR.checked = false; + _reconstruction.setupTempCameraInit(activeNode, "outSfMData"); + } + function close() { + _reconstruction.clearTempCameraInit(); + } + } + + Item { Layout.fillHeight: true; width: 1 } + // Thumbnail size icon and slider MaterialToolButton { + Layout.minimumWidth: childrenRect.width + text: MaterialIcons.photo_size_select_large + ToolTip.text: "Thumbnails Scale" padding: 0 anchors.margins: 0 font.pointSize: 11 @@ -385,5 +471,4 @@ Panel { implicitWidth: 70 } } - } diff --git a/meshroom/ui/qml/ImageGallery/IntrinsicsIndicator.qml b/meshroom/ui/qml/ImageGallery/IntrinsicsIndicator.qml index faee1061..cd7f472a 100644 --- a/meshroom/ui/qml/ImageGallery/IntrinsicsIndicator.qml +++ b/meshroom/ui/qml/ImageGallery/IntrinsicsIndicator.qml @@ -18,13 +18,22 @@ ImageBadge { readonly property string distortionModel: intrinsic ? childAttributeValue(intrinsic, "type", "") : "" property var metadata: ({}) + function findMetadata(key) { + var keyLower = key.toLowerCase() + for(var mKey in metadata) + { + if(mKey.toLowerCase().endsWith(keyLower)) + return metadata[mKey] + } + return "" + } // access useful metadata - readonly property var make: metadata["Make"] - readonly property var model: metadata["Model"] - readonly property var focalLength: metadata["Exif:FocalLength"] - readonly property var focalLength35: metadata["Exif:FocalLengthIn35mmFilm"] - readonly property var bodySerialNumber: metadata["Exif:BodySerialNumber"] - readonly property var lensSerialNumber: metadata["Exif:LensSerialNumber"] + readonly property var make: findMetadata("Make") + readonly property var model: findMetadata("Model") + readonly property var focalLength: findMetadata("FocalLength") + readonly property var focalLength35: findMetadata("FocalLengthIn35mmFilm") + readonly property var bodySerialNumber: findMetadata("BodySerialNumber") + readonly property var lensSerialNumber: findMetadata("LensSerialNumber") readonly property var sensorWidth: metadata["AliceVision:SensorWidth"] readonly property var sensorWidthEstimation: metadata["AliceVision:SensorWidthEstimation"] diff --git a/meshroom/ui/qml/MaterialIcons/MLabel.qml b/meshroom/ui/qml/MaterialIcons/MLabel.qml new file mode 100644 index 00000000..2251a6b6 --- /dev/null +++ b/meshroom/ui/qml/MaterialIcons/MLabel.qml @@ -0,0 +1,23 @@ +import QtQuick 2.9 +import QtQuick.Controls 2.4 + + +/** + * MLabel is a standard Label. + * If ToolTip.text is set, it shows up a tooltip when hovered. + */ +Label { + padding: 4 + MouseArea { + id: mouseArea + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.NoButton + } + ToolTip.visible: mouseArea.containsMouse + ToolTip.delay: 500 + background: Rectangle { + anchors.fill: parent + color: mouseArea.containsMouse ? Qt.darker(parent.palette.base, 0.6) : "transparent" + } +} diff --git a/meshroom/ui/qml/MaterialIcons/MaterialToolButton.qml b/meshroom/ui/qml/MaterialIcons/MaterialToolButton.qml index 2eea5411..b24e2ad4 100644 --- a/meshroom/ui/qml/MaterialIcons/MaterialToolButton.qml +++ b/meshroom/ui/qml/MaterialIcons/MaterialToolButton.qml @@ -1,5 +1,6 @@ import QtQuick 2.9 import QtQuick.Controls 2.3 +import QtQuick.Layouts 1.3 /** @@ -7,6 +8,7 @@ import QtQuick.Controls 2.3 * It also shows up its tooltip when hovered. */ ToolButton { + id: control font.family: MaterialIcons.fontFamily padding: 4 font.pointSize: 13 diff --git a/meshroom/ui/qml/MaterialIcons/MaterialToolLabel.qml b/meshroom/ui/qml/MaterialIcons/MaterialToolLabel.qml new file mode 100644 index 00000000..b32df53d --- /dev/null +++ b/meshroom/ui/qml/MaterialIcons/MaterialToolLabel.qml @@ -0,0 +1,45 @@ +import QtQuick 2.9 +import QtQuick.Controls 2.3 +import QtQuick.Layouts 1.3 + + +/** + * MaterialToolLabel is a Label with an icon (using MaterialIcons). + * It shows up its tooltip when hovered. + */ +Item { + id: control + property alias iconText: icon.text + property alias iconSize: icon.font.pointSize + property alias label: labelItem.text + width: childrenRect.width + height: childrenRect.height + + RowLayout { + Label { + id: icon + font.family: MaterialIcons.fontFamily + font.pointSize: 13 + padding: 0 + text: "" + color: palette.text + } + Label { + id: labelItem + text: "" + color: palette.text + } + Item { + width: 5 + } + } + + MouseArea { + id: mouseArea + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.NoButton + } + ToolTip.visible: mouseArea.containsMouse + ToolTip.delay: 500 +} diff --git a/meshroom/ui/qml/MaterialIcons/MaterialToolLabelButton.qml b/meshroom/ui/qml/MaterialIcons/MaterialToolLabelButton.qml new file mode 100644 index 00000000..6613dd51 --- /dev/null +++ b/meshroom/ui/qml/MaterialIcons/MaterialToolLabelButton.qml @@ -0,0 +1,51 @@ +import QtQuick 2.9 +import QtQuick.Controls 2.3 +import QtQuick.Layouts 1.3 + + +/** + * MaterialToolButton is a standard ToolButton using MaterialIcons font. + * It also shows up its tooltip when hovered. + */ +ToolButton { + id: control + property alias iconText: icon.text + property alias iconSize: icon.font.pointSize + property alias label: labelItem.text + padding: 0 + ToolTip.visible: ToolTip.text && hovered + ToolTip.delay: 100 + width: childrenRect.width + height: childrenRect.height + contentItem: RowLayout { + Layout.margins: 0 + Label { + id: icon + font.family: MaterialIcons.fontFamily + font.pointSize: 13 + padding: 0 + text: "" + color: (checked ? palette.highlight : palette.text) + } + Label { + id: labelItem + text: "" + padding: 0 + color: (checked ? palette.highlight : palette.text) + } + } + background: Rectangle { + color: { + if(pressed || checked || hovered) + { + if(pressed || checked) + return Qt.darker(parent.palette.base, 1.3) + if(hovered) + return Qt.darker(parent.palette.base, 0.6) + } + return "transparent"; + } + + border.color: checked ? Qt.darker(parent.palette.base, 1.4) : "transparent" + } +} diff --git a/meshroom/ui/qml/MaterialIcons/qmldir b/meshroom/ui/qml/MaterialIcons/qmldir index c3d64e4b..41606081 100644 --- a/meshroom/ui/qml/MaterialIcons/qmldir +++ b/meshroom/ui/qml/MaterialIcons/qmldir @@ -1,4 +1,7 @@ module MaterialIcons singleton MaterialIcons 2.2 MaterialIcons.qml MaterialToolButton 2.2 MaterialToolButton.qml +MaterialToolLabelButton 2.2 MaterialToolLabelButton.qml +MaterialToolLabel 2.2 MaterialToolLabel.qml MaterialLabel 2.2 MaterialLabel.qml +MLabel 2.2 MLabel.qml diff --git a/meshroom/ui/qml/Utils/Colors.qml b/meshroom/ui/qml/Utils/Colors.qml index 54f6fd66..ca1783ad 100644 --- a/meshroom/ui/qml/Utils/Colors.qml +++ b/meshroom/ui/qml/Utils/Colors.qml @@ -17,4 +17,45 @@ QtObject { readonly property color cyan: "#00BCD4" readonly property color pink: "#E91E63" readonly property color lime: "#CDDC39" + + readonly property var statusColors: { + "NONE": "transparent", + "SUBMITTED": cyan, + "RUNNING": orange, + "ERROR": red, + "SUCCESS": green, + "STOPPED": pink + } + + readonly property var ghostColors: { + "SUBMITTED": Qt.darker(cyan, 1.5), + "RUNNING": Qt.darker(orange, 1.5), + "STOPPED": Qt.darker(pink, 1.5) + } + + readonly property var statusColorsExternOverrides: { + "SUBMITTED": "#2196F3" + } + + function getChunkColor(chunk, overrides) + { + if(overrides && chunk.statusName in overrides) + { + return overrides[chunk.statusName] + } + else if(chunk.execModeName === "EXTERN" + && chunk.statusName in statusColorsExternOverrides) + { + return statusColorsExternOverrides[chunk.statusName] + } + else if(chunk.nodeName !== chunk.statusNodeName && chunk.statusName in ghostColors) { + return ghostColors[chunk.statusName] + } + else if(chunk.statusName in statusColors) + { + return statusColors[chunk.statusName] + } + console.warn("Unknown status : " + chunk.status) + return "magenta" + } } diff --git a/meshroom/ui/qml/Utils/Transformations3DHelper.qml b/meshroom/ui/qml/Utils/Transformations3DHelper.qml new file mode 100644 index 00000000..16e90ea6 --- /dev/null +++ b/meshroom/ui/qml/Utils/Transformations3DHelper.qml @@ -0,0 +1,6 @@ +pragma Singleton +import Meshroom.Helpers 1.0 + +Transformations3DHelper { + +} \ No newline at end of file diff --git a/meshroom/ui/qml/Utils/errorHandler.js b/meshroom/ui/qml/Utils/errorHandler.js new file mode 100644 index 00000000..2b4cec25 --- /dev/null +++ b/meshroom/ui/qml/Utils/errorHandler.js @@ -0,0 +1,32 @@ +.pragma library + +/** + * Analyse raised errors. + * Works only if errors are written with this specific syntax: + * [Context] ErrorType: ErrorMessage + * + * Maybe it would be better to handle errors on Python side but it should be harder to handle Dialog customization + */ +function analyseError(error) { + const msg = error.toString() + + // The use of [^] is like . but it takes in count every character including \n (works as a double negation) + // Group 1: Context + // Group 2: ErrorType + // Group 3: ErrorMessage + const regex = /\[(.*)\]\s(.*):([^]*)/ + if(!regex.test(msg)) + return { + context: "", + type: "", + msg: "" + } + + const data = regex.exec(msg) + + return { + context: data[1], + type: data[2], + msg: data[3].startsWith("\n") ? data[3].slice(1) : data[3] + } +} \ No newline at end of file diff --git a/meshroom/ui/qml/Utils/qmldir b/meshroom/ui/qml/Utils/qmldir index c8767f30..c28f49d2 100644 --- a/meshroom/ui/qml/Utils/qmldir +++ b/meshroom/ui/qml/Utils/qmldir @@ -4,7 +4,9 @@ singleton Colors 1.0 Colors.qml SortFilterDelegateModel 1.0 SortFilterDelegateModel.qml Request 1.0 request.js Format 1.0 format.js +ErrorHandler 1.0 errorHandler.js # using singleton here causes random crash at application exit # singleton Clipboard 1.0 Clipboard.qml # singleton Filepath 1.0 Filepath.qml # singleton Scene3DHelper 1.0 Scene3DHelper.qml +# singleton Transformations3DHelper 1.0 Transformations3DHelper.qml diff --git a/meshroom/ui/qml/Viewer/CameraResponseGraph.qml b/meshroom/ui/qml/Viewer/CameraResponseGraph.qml new file mode 100644 index 00000000..54ace025 --- /dev/null +++ b/meshroom/ui/qml/Viewer/CameraResponseGraph.qml @@ -0,0 +1,141 @@ +import QtQuick 2.9 +import QtQuick.Controls 2.3 +import QtQuick.Layouts 1.3 +import MaterialIcons 2.2 +import QtPositioning 5.8 +import QtLocation 5.9 +import QtCharts 2.13 +import Charts 1.0 + +import Controls 1.0 +import Utils 1.0 +import DataObjects 1.0 + +FloatingPane { + id: root + + property var ldrHdrCalibrationNode: null + property color textColor: Colors.sysPalette.text + + clip: true + padding: 4 + + CsvData { + id: csvData + property bool hasAttr: (ldrHdrCalibrationNode && ldrHdrCalibrationNode.hasAttribute("response")) + filepath: hasAttr ? ldrHdrCalibrationNode.attribute("response").value : "" + } + + // To avoid interaction with components in background + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton + onPressed: {} + onReleased: {} + onWheel: {} + } + + // note: We need to use csvData.getNbColumns() slot instead of the csvData.nbColumns property to avoid a crash on linux. + property bool crfReady: csvData && csvData.ready && (csvData.getNbColumns() >= 4) + onCrfReadyChanged: { + if(crfReady) + { + redCurve.clear() + greenCurve.clear() + blueCurve.clear() + csvData.getColumn(1).fillChartSerie(redCurve) + csvData.getColumn(2).fillChartSerie(greenCurve) + csvData.getColumn(3).fillChartSerie(blueCurve) + } + else + { + redCurve.clear() + greenCurve.clear() + blueCurve.clear() + } + } + Item { + anchors.horizontalCenter: parent.horizontalCenter + anchors.verticalCenter: parent.verticalCenter + anchors.horizontalCenterOffset: -responseChart.width/2 + anchors.verticalCenterOffset: -responseChart.height/2 + + InteractiveChartView { + id: responseChart + width: root.width > 400 ? 400 : (root.width < 350 ? 350 : root.width) + height: width * 0.75 + + title: "Camera Response Function (CRF)" + legend.visible: false + antialiasing: true + + ValueAxis { + id: valueAxisX + labelFormat: "%i" + titleText: "Camera Brightness" + min: crfReady ? csvData.getColumn(0).getFirst() : 0 + max: crfReady ? csvData.getColumn(0).getLast() : 1 + } + ValueAxis { + id: valueAxisY + titleText: "Normalized Radiance" + min: 0.0 + max: 1.0 + } + + // We cannot use a Repeater with these Components so we need to instantiate them one by one + // Red curve + LineSeries { + id: redCurve + axisX: valueAxisX + axisY: valueAxisY + name: crfReady ? csvData.getColumn(1).title : "" + color: name.toLowerCase() + } + // Green curve + LineSeries { + id: greenCurve + axisX: valueAxisX + axisY: valueAxisY + name: crfReady ? csvData.getColumn(2).title : "" + color: name.toLowerCase() + } + // Blue curve + LineSeries { + id: blueCurve + axisX: valueAxisX + axisY: valueAxisY + name: crfReady ? csvData.getColumn(3).title : "" + color: name.toLowerCase() + } + } + + Item { + id: btnContainer + + anchors.bottom: responseChart.bottom + anchors.bottomMargin: 35 + anchors.left: responseChart.left + anchors.leftMargin: responseChart.width * 0.15 + + RowLayout { + ChartViewCheckBox { + text: "ALL" + color: textColor + checkState: legend.buttonGroup.checkState + onClicked: { + const _checked = checked + for(let i = 0; i < responseChart.count; ++i) { + responseChart.series(i).visible = _checked + } + } + } + + ChartViewLegend { + id: legend + chartView: responseChart + } + } + } + } +} diff --git a/meshroom/ui/qml/Viewer/CircleGizmo.qml b/meshroom/ui/qml/Viewer/CircleGizmo.qml new file mode 100644 index 00000000..0b7d9e98 --- /dev/null +++ b/meshroom/ui/qml/Viewer/CircleGizmo.qml @@ -0,0 +1,100 @@ +import QtQuick 2.11 + +Rectangle { + id: root + + property bool readOnly: false + + signal moved() + signal incrementRadius(real radiusOffset) + + width: radius * 2 + height: width + color: "transparent" + border.width: 5 + border.color: readOnly ? "green" : "yellow" + + /* + // visualize top-left corner for debugging purpose + Rectangle { + color: "red" + width: 500 + height: 50 + } + Rectangle { + color: "red" + width: 50 + height: 500 + } + */ + // Cross to visualize the circle center + Rectangle { + color: parent.border.color + anchors.centerIn: parent + width: parent.width * 0.2 + height: parent.border.width * 0.5 + } + Rectangle { + color: parent.border.color + anchors.centerIn: parent + width: parent.border.width * 0.5 + height: parent.height * 0.2 + } + + Behavior on x { + NumberAnimation { + duration: 100 + } + } + + Behavior on y { + NumberAnimation { + duration: 100 + } + } + + Behavior on radius { + NumberAnimation { + duration: 100 + } + } + + Loader { + anchors.fill: parent + active: !root.readOnly + + sourceComponent: MouseArea { + id: mArea + anchors.fill: parent + cursorShape: root.readOnly ? Qt.ArrowCursor : (controlModifierEnabled ? Qt.SizeBDiagCursor : (pressed ? Qt.ClosedHandCursor : Qt.OpenHandCursor)) + propagateComposedEvents: true + + property bool controlModifierEnabled: false + onPositionChanged: { + mArea.controlModifierEnabled = (mouse.modifiers & Qt.ControlModifier) + mouse.accepted = false; + } + acceptedButtons: Qt.LeftButton + hoverEnabled: true + drag.target: root + + drag.onActiveChanged: { + if(!drag.active) { + moved(); + } + } + onPressed: { + forceActiveFocus(); + } + onWheel: { + mArea.controlModifierEnabled = (wheel.modifiers & Qt.ControlModifier) + if (wheel.modifiers & Qt.ControlModifier) { + incrementRadius(wheel.angleDelta.y / 120.0); + wheel.accepted = true; + } else { + wheel.accepted = false; + } + } + } + } +} diff --git a/meshroom/ui/qml/Viewer/FeaturesInfoOverlay.qml b/meshroom/ui/qml/Viewer/FeaturesInfoOverlay.qml index 380dd21e..bb689f91 100644 --- a/meshroom/ui/qml/Viewer/FeaturesInfoOverlay.qml +++ b/meshroom/ui/qml/Viewer/FeaturesInfoOverlay.qml @@ -18,12 +18,11 @@ FloatingPane { property var featureExtractionNode: null ColumnLayout { - // Header RowLayout { // FeatureExtraction node name Label { - text: featureExtractionNode.label + text: featureExtractionNode ? featureExtractionNode.label : "" Layout.fillWidth: true } // Settings menu @@ -46,7 +45,8 @@ FloatingPane { id: displayModeCB flat: true Layout.fillWidth: true - model: root.featuresViewer.displayModes + model: root.featuresViewer ? root.featuresViewer.displayModes : null + currentIndex: root.featuresViewer ? root.featuresViewer.displayMode : 0 onActivated: root.featuresViewer.displayMode = currentIndex } } @@ -73,15 +73,46 @@ FloatingPane { id: featureType property var viewer: root.featuresViewer.itemAt(index) + spacing: 4 - // Visibility toogle + // Features visibility toggle MaterialToolButton { - text: featureType.viewer.visible ? MaterialIcons.visibility : MaterialIcons.visibility_off - onClicked: featureType.viewer.visible = !featureType.viewer.visible + id: featuresVisibilityButton + checkable: true + checked: true + text: MaterialIcons.center_focus_strong + onClicked: { + featureType.viewer.displayfeatures = featuresVisibilityButton.checked; + } font.pointSize: 10 opacity: featureType.viewer.visible ? 1.0 : 0.6 } + + // Tracks visibility toogle + MaterialToolButton { + id: tracksVisibilityButton + checkable: true + checked: true + text: MaterialIcons.timeline + onClicked: { + featureType.viewer.displayTracks = tracksVisibilityButton.checked; + } + font.pointSize: 10 + } + + // Landmarks visibility toogle + MaterialToolButton { + id: landmarksVisibilityButton + checkable: true + checked: true + text: MaterialIcons.fiber_manual_record + onClicked: { + featureType.viewer.displayLandmarks = landmarksVisibilityButton.checked; + } + font.pointSize: 10 + } + // ColorChart picker ColorChart { implicitWidth: 12 @@ -89,15 +120,22 @@ FloatingPane { colors: root.featuresViewer.colors currentIndex: featureType.viewer.colorIndex // offset featuresViewer color set when changing the color of one feature type - onColorPicked: root.featuresViewer.colorOffset = colorIndex - index + onColorPicked: featureType.viewer.colorOffset = colorIndex - index } // Feature type name Label { - text: featureType.viewer.describerType + (featureType.viewer.loading ? "" : ": " + featureType.viewer.features.length) + text: { + if(featureType.viewer.loadingFeatures) + return featureType.viewer.describerType; + return featureType.viewer.describerType + ": " + + ((featureExtractionNode && featureExtractionNode.isComputed) ? featureType.viewer.features.length : " - ") + " / " + + (featureType.viewer.haveValidTracks ? featureType.viewer.nbTracks : " - ") + " / " + + (featureType.viewer.haveValidLandmarks ? featureType.viewer.nbLandmarks : " - "); + } } // Feature loading status Loader { - active: featureType.viewer.loading + active: featureType.viewer.loadingFeatures sourceComponent: BusyIndicator { padding: 0 implicitWidth: 12 @@ -105,6 +143,7 @@ FloatingPane { running: true } } + } } } diff --git a/meshroom/ui/qml/Viewer/FeaturesViewer.qml b/meshroom/ui/qml/Viewer/FeaturesViewer.qml index 6e21de14..37307473 100644 --- a/meshroom/ui/qml/Viewer/FeaturesViewer.qml +++ b/meshroom/ui/qml/Viewer/FeaturesViewer.qml @@ -10,31 +10,37 @@ import Utils 1.0 Repeater { id: root - /// ViewID to display the features of + /// ViewID to display the features of a specific view property int viewId + /// SfMData to display the data of SfM + property var sfmData /// Folder containing the features files - property string folder + property string featureFolder + /// Tracks object loading all the matches files + property var tracks /// 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 + property int displayMode: 2 /// 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 + property var colors: [Colors.blue, Colors.green, Colors.yellow, Colors.orange, Colors.cyan, Colors.pink, Colors.lime] //, Colors.red model: root.describerTypes // instantiate one FeaturesViewer by describer type delegate: AliceVision.FeaturesViewer { - readonly property int colorIndex: (index+root.colorOffset)%root.colors.length + readonly property int colorIndex: (index + colorOffset) % root.colors.length + property int colorOffset: 0 describerType: modelData - folder: root.folder + featureFolder: root.featureFolder + mtracks: root.tracks viewId: root.viewId color: root.colors[colorIndex] + landmarkColor: Colors.red displayMode: root.displayMode + msfmData: root.sfmData } } diff --git a/meshroom/ui/qml/Viewer/FloatImage.qml b/meshroom/ui/qml/Viewer/FloatImage.qml index 36bc77cb..24809c4d 100644 --- a/meshroom/ui/qml/Viewer/FloatImage.qml +++ b/meshroom/ui/qml/Viewer/FloatImage.qml @@ -50,5 +50,7 @@ AliceVision.FloatImageViewer { id: mouseArea anchors.fill: parent hoverEnabled: true + // Do not intercept mouse events, only get the mouse over information + acceptedButtons: Qt.NoButton } } diff --git a/meshroom/ui/qml/Viewer/HdrImageToolbar.qml b/meshroom/ui/qml/Viewer/HdrImageToolbar.qml index 88f11317..e720f42f 100644 --- a/meshroom/ui/qml/Viewer/HdrImageToolbar.qml +++ b/meshroom/ui/qml/Viewer/HdrImageToolbar.qml @@ -10,10 +10,11 @@ FloatingPane { padding: 5 radius: 0 + property real gainDefaultValue: 1 property real gammaDefaultValue: 1 - property real offsetDefaultValue: 0 - property real gammaValue: gammaCtrl.value - property real offsetValue: offsetCtrl.value + property real slidersPowerValue: 4 + property real gainValue: Math.pow(gainCtrl.value, slidersPowerValue) + property real gammaValue: Math.pow(gammaCtrl.value, slidersPowerValue) property string channelModeValue: channelsCtrl.value property variant colorRGBA: null @@ -44,7 +45,7 @@ FloatingPane { model: channels } - // offset slider + // gain slider RowLayout { spacing: 5 @@ -56,30 +57,30 @@ FloatingPane { ToolTip.text: "Reset Gain" onClicked: { - offsetCtrl.value = offsetDefaultValue; + gainCtrl.value = gainDefaultValue; } } TextField { - id: offsetLabel + id: gainLabel ToolTip.visible: ToolTip.text && hovered ToolTip.delay: 100 ToolTip.text: "Color Gain (in linear colorspace)" - text: offsetValue.toFixed(2) - Layout.preferredWidth: textMetrics_offsetValue.width + text: gainValue.toFixed(2) + Layout.preferredWidth: textMetrics_gainValue.width selectByMouse: true validator: doubleValidator onAccepted: { - offsetCtrl.value = Number(offsetLabel.text) + gainCtrl.value = Math.pow(Number(gainLabel.text), 1.0/slidersPowerValue) } } Slider { - id: offsetCtrl + id: gainCtrl Layout.fillWidth: true - from: -1 - to: 1 - value: 0 + from: 0.01 + to: 2 + value: gainDefaultValue stepSize: 0.01 } } @@ -107,19 +108,19 @@ FloatingPane { ToolTip.text: "Apply Gamma (after Gain and in linear colorspace)" text: gammaValue.toFixed(2) - Layout.preferredWidth: textMetrics_offsetValue.width + Layout.preferredWidth: textMetrics_gainValue.width selectByMouse: true validator: doubleValidator onAccepted: { - gammaCtrl.value = Number(offsetLabel.text) + gammaCtrl.value = Math.pow(Number(gammaLabel.text), 1.0/slidersPowerValue) } } Slider { id: gammaCtrl Layout.fillWidth: true from: 0.01 - to: 4 - value: 1 + to: 2 + value: gammaDefaultValue stepSize: 0.01 } } @@ -131,7 +132,7 @@ FloatingPane { color: root.colorRGBA ? Qt.rgba(red.value_gamma, green.value_gamma, blue.value_gamma, 1.0) : "black" } - // gamma slider + // RGBA colors RowLayout { spacing: 1 TextField { @@ -230,8 +231,8 @@ FloatingPane { text: "1.2345" // use one more than expected to get the correct value (probably needed due to TextField margin) } TextMetrics { - id: textMetrics_offsetValue - font: offsetLabel.font - text: "-10.01" + id: textMetrics_gainValue + font: gainLabel.font + text: "1.2345" } } diff --git a/meshroom/ui/qml/Viewer/ImageMetadataView.qml b/meshroom/ui/qml/Viewer/ImageMetadataView.qml index 412bf3d9..7c86767f 100644 --- a/meshroom/ui/qml/Viewer/ImageMetadataView.qml +++ b/meshroom/ui/qml/Viewer/ImageMetadataView.qml @@ -19,6 +19,7 @@ FloatingPane { clip: true padding: 4 + anchors.rightMargin: 0 /** * Convert GPS metadata to degree coordinates. @@ -76,13 +77,16 @@ FloatingPane { for(var key in metadata) { var entry = {} - entry["raw"] = key // split on ":" to get group and key - var sKey = key.split(":", 2) - if(sKey.length === 2) + var i = key.lastIndexOf(":") + if(i == -1) { - entry["group"] = sKey[0] - entry["key"] = sKey[1] + i = key.lastIndexOf("/") + } + if(i != -1) + { + entry["group"] = key.substr(0, i) + entry["key"] = key.substr(i+1) } else { @@ -125,7 +129,52 @@ FloatingPane { id: searchBar Layout.fillWidth: true } - + RowLayout { + Layout.alignment: Qt.AlignHCenter + Label { + font.family: MaterialIcons.fontFamily + text: MaterialIcons.shutter_speed + } + Label { + id: exposureLabel + text: { + if(metadata["ExposureTime"] === undefined) + return ""; + var expStr = metadata["ExposureTime"]; + var exp = parseFloat(expStr); + if(exp < 1.0) + { + var invExp = 1.0 / exp; + return "1/" + invExp.toFixed(0); + } + return expStr; + } + elide: Text.ElideRight + horizontalAlignment: Text.AlignHLeft + } + Item { width: 4 } + Label { + font.family: MaterialIcons.fontFamily + text: MaterialIcons.camera + } + Label { + id: fnumberLabel + text: (metadata["FNumber"] !== undefined) ? ("f/" + metadata["FNumber"]) : "" + elide: Text.ElideRight + horizontalAlignment: Text.AlignHLeft + } + Item { width: 4 } + Label { + font.family: MaterialIcons.fontFamily + text: MaterialIcons.iso + } + Label { + id: isoLabel + text: metadata["Exif:ISOSpeedRatings"] || "" + elide: Text.ElideRight + horizontalAlignment: Text.AlignHLeft + } + } // Metadata ListView ListView { id: metadataView diff --git a/meshroom/ui/qml/Viewer/MSfMData.qml b/meshroom/ui/qml/Viewer/MSfMData.qml new file mode 100644 index 00000000..eb880a31 --- /dev/null +++ b/meshroom/ui/qml/Viewer/MSfMData.qml @@ -0,0 +1,7 @@ +import QtQuick 2.11 +import AliceVision 1.0 as AliceVision + +// Data from the SfM +AliceVision.MSfMData { + id: root +} diff --git a/meshroom/ui/qml/Viewer/MTracks.qml b/meshroom/ui/qml/Viewer/MTracks.qml new file mode 100644 index 00000000..9b5afd08 --- /dev/null +++ b/meshroom/ui/qml/Viewer/MTracks.qml @@ -0,0 +1,6 @@ +import QtQuick 2.11 +import AliceVision 1.0 as AliceVision + +AliceVision.MTracks { + id: root +} diff --git a/meshroom/ui/qml/Viewer/SfmGlobalStats.qml b/meshroom/ui/qml/Viewer/SfmGlobalStats.qml new file mode 100644 index 00000000..e97d8c96 --- /dev/null +++ b/meshroom/ui/qml/Viewer/SfmGlobalStats.qml @@ -0,0 +1,327 @@ +import QtQuick 2.9 +import QtQuick.Controls 2.3 +import QtQuick.Layouts 1.3 +import MaterialIcons 2.2 +import QtPositioning 5.8 +import QtLocation 5.9 +import QtCharts 2.13 +import Charts 1.0 + +import Controls 1.0 +import Utils 1.0 + +import AliceVision 1.0 as AliceVision + + +FloatingPane { + id: root + + property var msfmData + property var mTracks + property color textColor: Colors.sysPalette.text + + visible: (_reconstruction.sfm && _reconstruction.sfm.isComputed) ? root.visible : false + clip: true + padding: 4 + + // To avoid interaction with components in background + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton + onPressed: {} + onReleased: {} + onWheel: {} + } + + + InteractiveChartView { + id: residualsPerViewChart + width: parent.width * 0.5 + height: parent.height * 0.5 + + title: "Residuals Per View" + legend.visible: false + antialiasing: true + + ValueAxis { + id: residualsPerViewValueAxisX + labelFormat: "%i" + titleText: "Ordered Views" + min: 0 + max: sfmDataStat.residualsPerViewMaxAxisX + } + ValueAxis { + id: residualsPerViewValueAxisY + titleText: "Reprojection Error (pix)" + min: 0 + max: sfmDataStat.residualsPerViewMaxAxisY + tickAnchor: 0 + tickInterval: 0.50 + tickCount: sfmDataStat.residualsPerViewMaxAxisY * 2 + } + LineSeries { + id: residualsMinPerViewLineSerie + axisX: residualsPerViewValueAxisX + axisY: residualsPerViewValueAxisY + name: "Min" + } + LineSeries { + id: residualsMaxPerViewLineSerie + axisX: residualsPerViewValueAxisX + axisY: residualsPerViewValueAxisY + name: "Max" + } + LineSeries { + id: residualsMeanPerViewLineSerie + axisX: residualsPerViewValueAxisX + axisY: residualsPerViewValueAxisY + name: "Mean" + } + LineSeries { + id: residualsMedianPerViewLineSerie + axisX: residualsPerViewValueAxisX + axisY: residualsPerViewValueAxisY + name: "Median" + } + LineSeries { + id: residualsFirstQuartilePerViewLineSerie + axisX: residualsPerViewValueAxisX + axisY: residualsPerViewValueAxisY + name: "Q1" + } + LineSeries { + id: residualsThirdQuartilePerViewLineSerie + axisX: residualsPerViewValueAxisX + axisY: residualsPerViewValueAxisY + name: "Q3" + } + } + + Item { + id: residualsPerViewBtnContainer + + Layout.fillWidth: true + anchors.bottom: residualsPerViewChart.bottom + anchors.bottomMargin: 35 + anchors.left: residualsPerViewChart.left + anchors.leftMargin: residualsPerViewChart.width * 0.25 + + RowLayout { + + ChartViewCheckBox { + id: allObservations + text: "ALL" + color: textColor + checkState: residualsPerViewLegend.buttonGroup.checkState + onClicked: { + var _checked = checked; + for(var i = 0; i < residualsPerViewChart.count; ++i) + { + residualsPerViewChart.series(i).visible = _checked; + } + } + } + + ChartViewLegend { + id: residualsPerViewLegend + chartView: residualsPerViewChart + } + + } + } + + InteractiveChartView { + id: observationsLengthsPerViewChart + width: parent.width * 0.5 + height: parent.height * 0.5 + anchors.top: parent.top + anchors.topMargin: (parent.height) * 0.5 + + title: "Observations Lengths Per View" + legend.visible: false + antialiasing: true + + ValueAxis { + id: observationsLengthsPerViewValueAxisX + labelFormat: "%i" + titleText: "Ordered Views" + min: 0 + max: sfmDataStat.observationsLengthsPerViewMaxAxisX + } + ValueAxis { + id: observationsLengthsPerViewValueAxisY + titleText: "Observations Lengths" + min: 0 + max: sfmDataStat.observationsLengthsPerViewMaxAxisY + tickAnchor: 0 + tickInterval: 0.50 + tickCount: sfmDataStat.observationsLengthsPerViewMaxAxisY * 2 + } + + LineSeries { + id: observationsLengthsMinPerViewLineSerie + axisX: observationsLengthsPerViewValueAxisX + axisY: observationsLengthsPerViewValueAxisY + name: "Min" + } + LineSeries { + id: observationsLengthsMaxPerViewLineSerie + axisX: observationsLengthsPerViewValueAxisX + axisY: observationsLengthsPerViewValueAxisY + name: "Max" + } + LineSeries { + id: observationsLengthsMeanPerViewLineSerie + axisX: observationsLengthsPerViewValueAxisX + axisY: observationsLengthsPerViewValueAxisY + name: "Mean" + } + LineSeries { + id: observationsLengthsMedianPerViewLineSerie + axisX: observationsLengthsPerViewValueAxisX + axisY: observationsLengthsPerViewValueAxisY + name: "Median" + } + LineSeries { + id: observationsLengthsFirstQuartilePerViewLineSerie + axisX: observationsLengthsPerViewValueAxisX + axisY: observationsLengthsPerViewValueAxisY + name: "Q1" + } + LineSeries { + id: observationsLengthsThirdQuartilePerViewLineSerie + axisX: observationsLengthsPerViewValueAxisX + axisY: observationsLengthsPerViewValueAxisY + name: "Q3" + } + } + + Item { + id: observationsLengthsPerViewBtnContainer + + Layout.fillWidth: true + anchors.bottom: observationsLengthsPerViewChart.bottom + anchors.bottomMargin: 35 + anchors.left: observationsLengthsPerViewChart.left + anchors.leftMargin: observationsLengthsPerViewChart.width * 0.25 + + RowLayout { + + ChartViewCheckBox { + id: allModes + text: "ALL" + color: textColor + checkState: observationsLengthsPerViewLegend.buttonGroup.checkState + onClicked: { + var _checked = checked; + for(var i = 0; i < observationsLengthsPerViewChart.count; ++i) + { + observationsLengthsPerViewChart.series(i).visible = _checked; + } + } + } + + ChartViewLegend { + id: observationsLengthsPerViewLegend + chartView: observationsLengthsPerViewChart + } + + } + } + + InteractiveChartView { + id: landmarksPerViewChart + width: parent.width * 0.5 + height: parent.height * 0.5 + anchors.left: parent.left + anchors.leftMargin: (parent.width) * 0.5 + anchors.top: parent.top + + title: "Landmarks Per View" + legend.visible: false + antialiasing: true + + ValueAxis { + id: landmarksPerViewValueAxisX + titleText: "Ordered Views" + min: 0.0 + max: sfmDataStat.landmarksPerViewMaxAxisX + } + ValueAxis { + id: landmarksPerViewValueAxisY + labelFormat: "%i" + titleText: "Number of Landmarks" + min: 0 + max: sfmDataStat.landmarksPerViewMaxAxisY + } + LineSeries { + id: landmarksPerViewLineSerie + axisX: landmarksPerViewValueAxisX + axisY: landmarksPerViewValueAxisY + name: "Landmarks" + } + LineSeries { + id: tracksPerViewLineSerie + axisX: landmarksPerViewValueAxisX + axisY: landmarksPerViewValueAxisY + name: "Tracks" + } + } + + Item { + id: landmarksFeatTracksPerViewBtnContainer + + Layout.fillWidth: true + anchors.bottom: landmarksPerViewChart.bottom + anchors.bottomMargin: 35 + anchors.left: landmarksPerViewChart.left + anchors.leftMargin: landmarksPerViewChart.width * 0.25 + + RowLayout { + + ChartViewCheckBox { + id: allFeatures + text: "ALL" + color: textColor + checkState: landmarksFeatTracksPerViewLegend.buttonGroup.checkState + onClicked: { + var _checked = checked; + for(var i = 0; i < landmarksPerViewChart.count; ++i) + { + landmarksPerViewChart.series(i).visible = _checked; + } + } + } + + ChartViewLegend { + id: landmarksFeatTracksPerViewLegend + chartView: landmarksPerViewChart + } + + } + } + + // Stats from the sfmData + AliceVision.MSfMDataStats { + id: sfmDataStat + msfmData: root.msfmData + mTracks: root.mTracks + + onAxisChanged: { + fillLandmarksPerViewSerie(landmarksPerViewLineSerie); + fillTracksPerViewSerie(tracksPerViewLineSerie); + fillResidualsMinPerViewSerie(residualsMinPerViewLineSerie); + fillResidualsMaxPerViewSerie(residualsMaxPerViewLineSerie); + fillResidualsMeanPerViewSerie(residualsMeanPerViewLineSerie); + fillResidualsMedianPerViewSerie(residualsMedianPerViewLineSerie); + fillResidualsFirstQuartilePerViewSerie(residualsFirstQuartilePerViewLineSerie); + fillResidualsThirdQuartilePerViewSerie(residualsThirdQuartilePerViewLineSerie); + fillObservationsLengthsMinPerViewSerie(observationsLengthsMinPerViewLineSerie); + fillObservationsLengthsMaxPerViewSerie(observationsLengthsMaxPerViewLineSerie); + fillObservationsLengthsMeanPerViewSerie(observationsLengthsMeanPerViewLineSerie); + fillObservationsLengthsMedianPerViewSerie(observationsLengthsMedianPerViewLineSerie); + fillObservationsLengthsFirstQuartilePerViewSerie(observationsLengthsFirstQuartilePerViewLineSerie); + fillObservationsLengthsThirdQuartilePerViewSerie(observationsLengthsThirdQuartilePerViewLineSerie); + } + } +} diff --git a/meshroom/ui/qml/Viewer/SfmStatsView.qml b/meshroom/ui/qml/Viewer/SfmStatsView.qml new file mode 100644 index 00000000..d4ba8b2f --- /dev/null +++ b/meshroom/ui/qml/Viewer/SfmStatsView.qml @@ -0,0 +1,263 @@ +import QtQuick 2.9 +import QtQuick.Controls 2.3 +import QtQuick.Layouts 1.3 +import MaterialIcons 2.2 +import QtPositioning 5.8 +import QtLocation 5.9 +import QtCharts 2.13 +import Charts 1.0 + +import Controls 1.0 +import Utils 1.0 + +import AliceVision 1.0 as AliceVision + + + +FloatingPane { + id: root + + property var msfmData: null + property int viewId + property color textColor: Colors.sysPalette.text + + visible: (_reconstruction.sfm && _reconstruction.sfm.isComputed) ? root.visible : false + clip: true + padding: 4 + + // To avoid interaction with components in background + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton + onPressed: {} + onReleased: {} + onWheel: {} + } + + InteractiveChartView { + id: residualChart + width: parent.width * 0.5 + height: parent.height * 0.5 + + title: "Reprojection Errors" + legend.visible: false + antialiasing: true + + ValueAxis { + id: residualValueAxisX + titleText: "Reprojection Error" + min: 0.0 + max: viewStat.residualMaxAxisX + } + ValueAxis { + id: residualValueAxisY + labelFormat: "%i" + titleText: "Number of Points" + min: 0 + max: viewStat.residualMaxAxisY + } + LineSeries { + id: residualFullLineSerie + axisX: residualValueAxisX + axisY: residualValueAxisY + name: "Average on All Cameras" + } + LineSeries { + id: residualViewLineSerie + axisX: residualValueAxisX + axisY: residualValueAxisY + name: "Current" + } + } + + Item { + id: residualBtnContainer + + Layout.fillWidth: true + anchors.bottom: residualChart.bottom + anchors.bottomMargin: 35 + anchors.left: residualChart.left + anchors.leftMargin: residualChart.width * 0.15 + + RowLayout { + + ChartViewCheckBox { + id: allResiduals + text: "ALL" + color: textColor + checkState: residualLegend.buttonGroup.checkState + onClicked: { + var _checked = checked; + for(var i = 0; i < residualChart.count; ++i) + { + residualChart.series(i).visible = _checked; + } + } + } + + ChartViewLegend { + id: residualLegend + chartView: residualChart + } + } + } + + InteractiveChartView { + id: observationsLengthsChart + width: parent.width * 0.5 + height: parent.height * 0.5 + anchors.top: parent.top + anchors.topMargin: (parent.height) * 0.5 + + legend.visible: false + title: "Observations Lengths" + + ValueAxis { + id: observationsLengthsvalueAxisX + labelFormat: "%i" + titleText: "Observations Length" + min: 2 + max: viewStat.observationsLengthsMaxAxisX + tickAnchor: 2 + tickInterval: 1 + tickCount: 5 + } + ValueAxis { + id: observationsLengthsvalueAxisY + labelFormat: "%i" + titleText: "Number of Points" + min: 0 + max: viewStat.observationsLengthsMaxAxisY + } + LineSeries { + id: observationsLengthsFullLineSerie + axisX: observationsLengthsvalueAxisX + axisY: observationsLengthsvalueAxisY + name: "All Cameras" + } + LineSeries { + id: observationsLengthsViewLineSerie + axisX: observationsLengthsvalueAxisX + axisY: observationsLengthsvalueAxisY + name: "Current" + } + + } + + Item { + id: observationsLengthsBtnContainer + + Layout.fillWidth: true + anchors.bottom: observationsLengthsChart.bottom + anchors.bottomMargin: 35 + anchors.left: observationsLengthsChart.left + anchors.leftMargin: observationsLengthsChart.width * 0.25 + + RowLayout { + + ChartViewCheckBox { + id: allObservations + text: "ALL" + color: textColor + checkState: observationsLengthsLegend.buttonGroup.checkState + onClicked: { + var _checked = checked; + for(var i = 0; i < observationsLengthsChart.count; ++i) + { + observationsLengthsChart.series(i).visible = _checked; + } + } + } + + ChartViewLegend { + id: observationsLengthsLegend + chartView: observationsLengthsChart + } + + } + } + + InteractiveChartView { + id: observationsScaleChart + width: parent.width * 0.5 + height: parent.height * 0.5 + anchors.left: parent.left + anchors.leftMargin: (parent.width) * 0.5 + anchors.top: parent.top + + legend.visible: false + title: "Observations Scale" + + ValueAxis { + id: observationsScaleValueAxisX + titleText: "Scale" + min: 0 + max: viewStat.observationsScaleMaxAxisX + } + ValueAxis { + id: observationsScaleValueAxisY + titleText: "Number of Points" + min: 0 + max: viewStat.observationsScaleMaxAxisY + } + LineSeries { + id: observationsScaleFullLineSerie + axisX: observationsScaleValueAxisX + axisY: observationsScaleValueAxisY + name: " Average on All Cameras" + } + LineSeries { + id: observationsScaleViewLineSerie + axisX: observationsScaleValueAxisX + axisY: observationsScaleValueAxisY + name: "Current" + } + } + + Item { + id: observationsScaleBtnContainer + + Layout.fillWidth: true + anchors.bottom: observationsScaleChart.bottom + anchors.bottomMargin: 35 + anchors.left: observationsScaleChart.left + anchors.leftMargin: observationsScaleChart.width * 0.15 + + RowLayout { + + ChartViewCheckBox { + id: allObservationsScales + text: "ALL" + color: textColor + checkState: observationsScaleLegend.buttonGroup.checkState + onClicked: { + var _checked = checked; + for(var i = 0; i < observationsScaleChart.count; ++i) + { + observationsScaleChart.series(i).visible = _checked; + } + } + } + + ChartViewLegend { + id: observationsScaleLegend + chartView: observationsScaleChart + } + } + } + + // Stats from a view the sfmData + AliceVision.MViewStats { + id: viewStat + msfmData: (root.visible && root.msfmData && root.msfmData.status === AliceVision.MSfMData.Ready) ? root.msfmData : null + viewId: root.viewId + onViewStatsChanged: { + fillResidualFullSerie(residualFullLineSerie); + fillResidualViewSerie(residualViewLineSerie); + fillObservationsLengthsFullSerie(observationsLengthsFullLineSerie); + fillObservationsLengthsViewSerie(observationsLengthsViewLineSerie); + fillObservationsScaleFullSerie(observationsScaleFullLineSerie); + fillObservationsScaleViewSerie(observationsScaleViewLineSerie); + } + } +} diff --git a/meshroom/ui/qml/Viewer/TestAliceVisionPlugin.qml b/meshroom/ui/qml/Viewer/TestAliceVisionPlugin.qml new file mode 100644 index 00000000..4b40ee5e --- /dev/null +++ b/meshroom/ui/qml/Viewer/TestAliceVisionPlugin.qml @@ -0,0 +1,9 @@ +import AliceVision 1.0 +import QtQuick 2.7 + +/** + * To evaluate if the QtAliceVision plugin is available. + */ +Item { + id: root +} diff --git a/meshroom/ui/qml/Viewer/TestOIIOPlugin.qml b/meshroom/ui/qml/Viewer/TestOIIOPlugin.qml new file mode 100644 index 00000000..7fde5b45 --- /dev/null +++ b/meshroom/ui/qml/Viewer/TestOIIOPlugin.qml @@ -0,0 +1,10 @@ +import DepthMapEntity 2.1 +import QtQuick 2.7 + +/** + * To evaluate if the QtOIIO plugin is available. + * DepthMapEntity is in the same plugin than the imageformats plugin, that we cannot check from qml. + */ +Item { + id: root +} diff --git a/meshroom/ui/qml/Viewer/Viewer2D.qml b/meshroom/ui/qml/Viewer/Viewer2D.qml index 1140a07f..480deaf0 100644 --- a/meshroom/ui/qml/Viewer/Viewer2D.qml +++ b/meshroom/ui/qml/Viewer/Viewer2D.qml @@ -14,9 +14,59 @@ FocusScope { property var viewIn3D property Component floatViewerComp: Qt.createComponent("FloatImage.qml") - readonly property bool floatViewerAvailable: floatViewerComp.status === Component.Ready property alias useFloatImageViewer: displayHDR.checked + Loader { + id: aliceVisionPluginLoader + active: true + source: "TestAliceVisionPlugin.qml" + } + Loader { + id: oiioPluginLoader + active: true + source: "TestOIIOPlugin.qml" + } + readonly property bool aliceVisionPluginAvailable: aliceVisionPluginLoader.status === Component.Ready + readonly property bool oiioPluginAvailable: oiioPluginLoader.status === Component.Ready + + Component.onCompleted: { + if(!aliceVisionPluginAvailable) + console.warn("Missing plugin qtAliceVision.") + if(!oiioPluginAvailable) + console.warn("Missing plugin qtOIIO.") + } + + property string loadingModules: { + if(!imgContainer.image) + return ""; + var res = ""; + if(imgContainer.image.status === Image.Loading) + res += " Image"; + if(featuresViewerLoader.status === Loader.Ready && featuresViewerLoader.item) + { + for (var i = 0; i < featuresViewerLoader.item.count; ++i) { + if(featuresViewerLoader.item.itemAt(i).loadingFeatures) + { + res += " Features"; + break; + } + } + } + if(mtracksLoader.status === Loader.Ready) + { + if(mtracksLoader.item.status === MTracks.Loading) + res += " Tracks"; + } + if(msfmDataLoader.status === Loader.Ready) + { + if(msfmDataLoader.item.status === MSfMData.Loading) + { + res += " SfMData"; + } + } + return res; + } + function clear() { source = '' @@ -73,10 +123,13 @@ FocusScope { } function getImageFile(type) { + if(!_reconstruction.activeNodes) + return ""; + var depthMapNode = _reconstruction.activeNodes.get('allDepthMap').node; if (type == "image") { return root.source; - } else if (_reconstruction.depthMap != undefined && _reconstruction.selectedViewId >= 0) { - return Filepath.stringToUrl(_reconstruction.depthMap.internalFolder+_reconstruction.selectedViewId+"_"+type+"Map.exr"); + } else if (depthMapNode != undefined && _reconstruction.selectedViewId >= 0) { + return Filepath.stringToUrl(depthMapNode.internalFolder+_reconstruction.selectedViewId+"_"+type+"Map.exr"); } return ""; } @@ -147,28 +200,32 @@ FocusScope { // qtAliceVision Image Viewer Loader { id: floatImageViewerLoader - active: root.useFloatImageViewer + active: root.aliceVisionPluginAvailable && root.useFloatImageViewer visible: (floatImageViewerLoader.status === Loader.Ready) anchors.centerIn: parent - Component.onCompleted: { - // instantiate and initialize a FeaturesViewer component dynamically using Loader.setSource - // Note: It does not work to use previously created component, - // so we re-create it with setSource. - // floatViewerComp.createObject(floatImageViewerLoader, { - setSource("FloatImage.qml", { - 'source': Qt.binding(function() { return getImageFile(imageType.type); }), - 'gamma': Qt.binding(function() { return hdrImageToolbar.gammaValue; }), - 'offset': Qt.binding(function() { return hdrImageToolbar.offsetValue; }), - 'channelModeString': Qt.binding(function() { return hdrImageToolbar.channelModeValue; }), - }) + onActiveChanged: { + if(active) { + // instantiate and initialize a FeaturesViewer component dynamically using Loader.setSource + // Note: It does not work to use previously created component, so we re-create it with setSource. + // floatViewerComp.createObject(floatImageViewerLoader, { + setSource("FloatImage.qml", { + 'source': Qt.binding(function() { return getImageFile(imageType.type); }), + 'gamma': Qt.binding(function() { return hdrImageToolbar.gammaValue; }), + 'gain': Qt.binding(function() { return hdrImageToolbar.gainValue; }), + 'channelModeString': Qt.binding(function() { return hdrImageToolbar.channelModeValue; }), + }) + } else { + // Force the unload (instead of using Component.onCompleted to load it once and for all) is necessary since Qt 5.14 + setSource("", {}) + } } } // Simple QML Image Viewer (using Qt or qtOIIO to load images) Loader { id: qtImageViewerLoader - active: (!root.useFloatImageViewer) || (floatImageViewerLoader.status === Loader.Error) + active: !floatImageViewerLoader.active anchors.centerIn: parent sourceComponent: Image { id: qtImageViewer @@ -185,8 +242,7 @@ FocusScope { } // Image cache of the last loaded image - // Only visible when the main one is loading, to keep an image - // displayed at all time and smoothen transitions + // Only visible when the main one is loading, to maintain a displayed image for smoother transitions Image { id: qtImageViewerCache @@ -207,11 +263,11 @@ FocusScope { scale: 1.0 // FeatureViewer: display view extracted feature points - // note: requires QtAliceVision plugin - use a Loader to evaluate plugin avaibility at runtime + // note: requires QtAliceVision plugin - use a Loader to evaluate plugin availability at runtime Loader { id: featuresViewerLoader - active: displayFeatures.checked + property var activeNode: _reconstruction.activeNodes.get("FeatureExtraction").node // handle rotation/position based on available metadata rotation: { @@ -225,14 +281,65 @@ FocusScope { x: (imgContainer.image && rotation === 90) ? imgContainer.image.paintedWidth : 0 y: (imgContainer.image && rotation === -90) ? imgContainer.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); }), - }) + onActiveChanged: { + if(active) { + // instantiate and initialize a FeaturesViewer component dynamically using Loader.setSource + setSource("FeaturesViewer.qml", { + 'viewId': Qt.binding(function() { return _reconstruction.selectedViewId; }), + 'model': Qt.binding(function() { return activeNode ? activeNode.attribute("describerTypes").value : ""; }), + 'featureFolder': Qt.binding(function() { return activeNode ? Filepath.stringToUrl(activeNode.attribute("output").value) : ""; }), + 'tracks': Qt.binding(function() { return mtracksLoader.status === Loader.Ready ? mtracksLoader.item : null; }), + 'sfmData': Qt.binding(function() { return msfmDataLoader.status === Loader.Ready ? msfmDataLoader.item : null; }), + }) + } else { + // Force the unload (instead of using Component.onCompleted to load it once and for all) is necessary since Qt 5.14 + setSource("", {}) + } + } + } + + // FisheyeCircleViewer: display fisheye circle + // note: use a Loader to evaluate if a PanoramaInit node exist and displayFisheyeCircle checked at runtime + Loader { + anchors.centerIn: parent + property var activeNode: _reconstruction.activeNodes.get("PanoramaInit").node + active: (displayFisheyeCircleLoader.checked && activeNode) + + // 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; + } + } + + sourceComponent: CircleGizmo { + property bool useAuto: activeNode.attribute("estimateFisheyeCircle").value + readOnly: useAuto + visible: (!useAuto) || activeNode.isComputed + property real userFisheyeRadius: activeNode.attribute("fisheyeRadius").value + property variant fisheyeAutoParams: _reconstruction.getAutoFisheyeCircle(activeNode) + + x: useAuto ? fisheyeAutoParams.x : activeNode.attribute("fisheyeCenterOffset.fisheyeCenterOffset_x").value + y: useAuto ? fisheyeAutoParams.y : activeNode.attribute("fisheyeCenterOffset.fisheyeCenterOffset_y").value + radius: useAuto ? fisheyeAutoParams.z : ((imgContainer.image ? Math.min(imgContainer.image.width, imgContainer.image.height) : 1.0) * 0.5 * (userFisheyeRadius * 0.01)) + + border.width: Math.max(1, (3.0 / imgContainer.scale)) + onMoved: { + if(!useAuto) + { + _reconstruction.setAttribute(activeNode.attribute("fisheyeCenterOffset.fisheyeCenterOffset_x"), x) + _reconstruction.setAttribute(activeNode.attribute("fisheyeCenterOffset.fisheyeCenterOffset_y"), y) + } + } + onIncrementRadius: { + if(!useAuto) + { + _reconstruction.setAttribute(activeNode.attribute("fisheyeRadius"), activeNode.attribute("fisheyeRadius").value + radiusOffset) + } + } } } } @@ -267,8 +374,9 @@ FocusScope { // show which depthmap node is active Label { id: depthMapNodeName - visible: (_reconstruction.depthMap != undefined) && (imageType.type != "image") - text: (_reconstruction.depthMap != undefined ? _reconstruction.depthMap.label : "") + property var activeNode: root.oiioPluginAvailable ? _reconstruction.activeNodes.get("allDepthMap").node : null + visible: (imageType.type != "image") && activeNode + text: activeNode ? activeNode.label : "" font.pointSize: 8 horizontalAlignment: TextInput.AlignLeft @@ -297,6 +405,139 @@ FocusScope { metadata: visible ? root.metadata : {} } + Loader { + id: msfmDataLoader + + property bool isUsed: displayFeatures.checked || displaySfmStatsView.checked || displaySfmDataGlobalStats.checked + property var activeNode: root.aliceVisionPluginAvailable ? _reconstruction.activeNodes.get('sfm').node : null + property bool isComputed: activeNode && activeNode.isComputed + property string filepath: Filepath.stringToUrl(isComputed ? activeNode.attribute("output").value : "") + + active: false + // It takes time to load tracks, so keep them looaded, if we may use it again. + // If we load another node, we can trash them (to eventually load the new node data). + onIsUsedChanged: { + if(!active && isUsed && isComputed) + { + active = true; + } + } + onIsComputedChanged: { + if(!isComputed) + { + active = false; + } + else if(!active && isUsed) + { + active = true; + } + } + onActiveNodeChanged: { + if(!isUsed) + { + active = false; + } + else if(!isComputed) + { + active = false; + } + else + { + active = true; + } + } + + onActiveChanged: { + if(active) { + // instantiate and initialize a SfmStatsView component dynamically using Loader.setSource + // so it can fail safely if the c++ plugin is not available + setSource("MSfMData.qml", { + 'sfmDataPath': Qt.binding(function() { return filepath; }), + }) + } else { + // Force the unload (instead of using Component.onCompleted to load it once and for all) is necessary since Qt 5.14 + setSource("", {}) + } + } + } + Loader { + id: mtracksLoader + + property bool isUsed: displayFeatures.checked || displaySfmStatsView.checked || displaySfmDataGlobalStats.checked + property var activeNode: root.aliceVisionPluginAvailable ? _reconstruction.activeNodes.get('FeatureMatching').node : null + property bool isComputed: activeNode && activeNode.isComputed + + active: false + // It takes time to load tracks, so keep them looaded, if we may use it again. + // If we load another node, we can trash them (to eventually load the new node data). + onIsUsedChanged: { + if(!active && isUsed && isComputed) { + active = true; + } + } + onIsComputedChanged: { + if(!isComputed) { + active = false; + } + else if(!active && isUsed) { + active = true; + } + } + onActiveNodeChanged: { + if(!isUsed) { + active = false; + } + else if(!isComputed) { + active = false; + } + else { + active = true; + } + } + + onActiveChanged: { + if(active) { + // instantiate and initialize a SfmStatsView component dynamically using Loader.setSource + // so it can fail safely if the c++ plugin is not available + setSource("MTracks.qml", { + 'matchingFolder': Qt.binding(function() { return Filepath.stringToUrl(isComputed ? activeNode.attribute("output").value : ""); }), + }) + } else { + // Force the unload (instead of using Component.onCompleted to load it once and for all) is necessary since Qt 5.14 + setSource("", {}) + } + } + } + Loader { + id: sfmStatsView + anchors.fill: parent + active: msfmDataLoader.status === Loader.Ready && displaySfmStatsView.checked + + Component.onCompleted: { + // instantiate and initialize a SfmStatsView component dynamically using Loader.setSource + // so it can fail safely if the c++ plugin is not available + setSource("SfmStatsView.qml", { + 'msfmData': Qt.binding(function() { return msfmDataLoader.item; }), + 'viewId': Qt.binding(function() { return _reconstruction.selectedViewId; }), + }) + } + } + Loader { + id: sfmGlobalStats + anchors.fill: parent + active: msfmDataLoader.status === Loader.Ready && displaySfmDataGlobalStats.checked + + Component.onCompleted: { + // instantiate and initialize a SfmStatsView component dynamically using Loader.setSource + // so it can fail safely if the c++ plugin is not available + setSource("SfmGlobalStats.qml", { + 'msfmData': Qt.binding(function() { return msfmDataLoader.item; }), + 'mTracks': Qt.binding(function() { return mtracksLoader.item; }), + + }) + } + } + Loader { id: featuresOverlay anchors { @@ -304,14 +545,31 @@ FocusScope { left: parent.left margins: 2 } - active: displayFeatures.checked + active: root.aliceVisionPluginAvailable && displayFeatures.checked && featuresViewerLoader.status === Loader.Ready sourceComponent: FeaturesInfoOverlay { - featureExtractionNode: _reconstruction.featureExtraction + featureExtractionNode: _reconstruction.activeNodes.get('FeatureExtraction').node pluginStatus: featuresViewerLoader.status featuresViewer: featuresViewerLoader.item } } + + Loader { + id: ldrHdrCalibrationGraph + anchors.fill: parent + + property var activeNode: _reconstruction.activeNodes.get('LdrToHdrCalibration').node + property var isEnabled: displayLdrHdrCalibrationGraph.checked && activeNode && activeNode.isComputed + // active: isEnabled + // Setting "active" from true to false creates a crash on linux with Qt 5.14.2. + // As a workaround, we clear the CameraResponseGraph with an empty node + // and hide the loader content. + visible: isEnabled + + sourceComponent: CameraResponseGraph { + ldrHdrCalibrationNode: isEnabled ? activeNode : null + } + } } FloatingPane { id: bottomToolbar @@ -323,9 +581,8 @@ FocusScope { anchors.fill: parent // zoom label - Label { - text: ((imgContainer.image && (imgContainer.image.status == Image.Ready)) ? imgContainer.scale.toFixed(2) : "1.00") + "x" - state: "xsmall" + MLabel { + text: ((imgContainer.image && (imgContainer.image.status === Image.Ready)) ? imgContainer.scale.toFixed(2) : "1.00") + "x" MouseArea { anchors.fill: parent acceptedButtons: Qt.LeftButton | Qt.RightButton @@ -342,6 +599,7 @@ FocusScope { } } } + ToolTip.text: "Zoom" } MaterialToolButton { id: displayAlphaBackground @@ -362,7 +620,7 @@ FocusScope { Layout.minimumWidth: 0 checkable: true checked: false - enabled: root.floatViewerAvailable + enabled: root.aliceVisionPluginAvailable } MaterialToolButton { id: displayFeatures @@ -372,23 +630,53 @@ FocusScope { Layout.minimumWidth: 0 checkable: true checked: false + enabled: root.aliceVisionPluginAvailable + } + MaterialToolButton { + id: displayFisheyeCircleLoader + property var activeNode: _reconstruction.activeNodes.get('PanoramaInit').node + ToolTip.text: "Display Fisheye Circle: " + (activeNode ? activeNode.label : "No Node") + text: MaterialIcons.vignette + // text: MaterialIcons.panorama_fish_eye + font.pointSize: 11 + Layout.minimumWidth: 0 + checkable: true + checked: false + enabled: activeNode && activeNode.attribute("useFisheye").value + visible: activeNode + } + + MaterialToolButton { + id: displayLdrHdrCalibrationGraph + property var activeNode: _reconstruction.activeNodes.get("LdrToHdrCalibration").node + property bool isComputed: activeNode && activeNode.isComputed + ToolTip.text: "Display Camera Response Function: " + (activeNode ? activeNode.label : "No Node") + text: MaterialIcons.timeline + font.pointSize: 11 + Layout.minimumWidth: 0 + checkable: true + checked: false + enabled: activeNode && activeNode.isComputed + visible: activeNode + + onIsComputedChanged: { + if(!isComputed) + checked = false + } } Label { id: resolutionLabel Layout.fillWidth: true - text: imgContainer.image ? (imgContainer.image.sourceSize.width + "x" + imgContainer.image.sourceSize.height) : "" + text: (imgContainer.image && imgContainer.image.sourceSize.width > 0) ? (imgContainer.image.sourceSize.width + "x" + imgContainer.image.sourceSize.height) : "" elide: Text.ElideRight horizontalAlignment: Text.AlignHCenter - /*Rectangle { - anchors.fill: parent - color: "blue" - }*/ } ComboBox { id: imageType + property var activeNode: root.oiioPluginAvailable ? _reconstruction.activeNodes.get('allDepthMap').node : null // set min size to 5 characters + one margin for the combobox clip: true Layout.minimumWidth: 0 @@ -399,12 +687,13 @@ FocusScope { property string type: enabled ? types[currentIndex] : types[0] model: types - enabled: _reconstruction.depthMap != undefined + enabled: activeNode } MaterialToolButton { - enabled: _reconstruction.depthMap != undefined - ToolTip.text: "View Depth Map in 3D (" + (_reconstruction.depthMap != undefined ? _reconstruction.depthMap.label : "No DepthMap Node Selected") + ")" + property var activeNode: root.oiioPluginAvailable ? _reconstruction.activeNodes.get('allDepthMap').node : null + enabled: activeNode + ToolTip.text: "View Depth Map in 3D (" + (activeNode ? activeNode.label : "No DepthMap Node Selected") + ")" text: MaterialIcons.input font.pointSize: 11 Layout.minimumWidth: 0 @@ -414,6 +703,53 @@ FocusScope { } } + MaterialToolButton { + id: displaySfmStatsView + property var activeNode: root.aliceVisionPluginAvailable ? _reconstruction.activeNodes.get('sfm').node : null + + font.family: MaterialIcons.fontFamily + text: MaterialIcons.assessment + + ToolTip.text: "StructureFromMotion Statistics" + ToolTip.visible: hovered + + font.pointSize: 14 + padding: 2 + smooth: false + flat: true + checkable: enabled + enabled: activeNode && activeNode.isComputed && _reconstruction.selectedViewId >= 0 + onCheckedChanged: { + if(checked == true) { + displaySfmDataGlobalStats.checked = false + metadataCB.checked = false + } + } + } + + MaterialToolButton { + id: displaySfmDataGlobalStats + property var activeNode: root.aliceVisionPluginAvailable ? _reconstruction.activeNodes.get('sfm').node : null + + font.family: MaterialIcons.fontFamily + text: MaterialIcons.language + + ToolTip.text: "StructureFromMotion Global Statistics" + ToolTip.visible: hovered + + font.pointSize: 14 + padding: 2 + smooth: false + flat: true + checkable: enabled + enabled: activeNode && activeNode.isComputed + onCheckedChanged: { + if(checked == true) { + displaySfmStatsView.checked = false + metadataCB.checked = false + } + } + } MaterialToolButton { id: metadataCB @@ -429,7 +765,15 @@ FocusScope { flat: true checkable: enabled enabled: _reconstruction.selectedViewId >= 0 + onCheckedChanged: { + if(checked == true) + { + displaySfmDataGlobalStats.checked = false + displaySfmStatsView.checked = false + } + } } + } } } diff --git a/meshroom/ui/qml/Viewer3D/BoundingBox.qml b/meshroom/ui/qml/Viewer3D/BoundingBox.qml new file mode 100644 index 00000000..a33cb2ba --- /dev/null +++ b/meshroom/ui/qml/Viewer3D/BoundingBox.qml @@ -0,0 +1,92 @@ +import Qt3D.Core 2.0 +import Qt3D.Render 2.9 +import Qt3D.Input 2.0 +import Qt3D.Extras 2.10 +import QtQuick 2.9 + +Entity { + id: root + property Transform transform: Transform {} + + components: [transform] + + Entity { + components: [cube, greyMaterial] + + CuboidMesh { + id: cube + property real edge : 1.995 // Almost 2: important to have all the cube's vertices with a unit of 1 + xExtent: edge + yExtent: edge + zExtent: edge + } + PhongAlphaMaterial { + id: greyMaterial + property color base: "#fff" + ambient: base + alpha: 0.15 + + // Pretty convincing combination + blendFunctionArg: BlendEquation.Add + sourceRgbArg: BlendEquationArguments.SourceAlpha + sourceAlphaArg: BlendEquationArguments.OneMinusSourceAlpha + destinationRgbArg: BlendEquationArguments.DestinationColor + destinationAlphaArg: BlendEquationArguments.OneMinusSourceAlpha + } + } + + Entity { + components: [edges, orangeMaterial] + + PhongMaterial { + id: orangeMaterial + property color base: "#f49b2b" + ambient: base + } + + GeometryRenderer { + id: edges + primitiveType: GeometryRenderer.Lines + geometry: Geometry { + Attribute { + id: boundingBoxPosition + attributeType: Attribute.VertexAttribute + vertexBaseType: Attribute.Float + vertexSize: 3 + count: 24 + name: defaultPositionAttributeName + buffer: Buffer { + type: Buffer.VertexBuffer + data: new Float32Array([ + 1.0, 1.0, 1.0, + 1.0, -1.0, 1.0, + 1.0, 1.0, 1.0, + 1.0, 1.0, -1.0, + 1.0, 1.0, 1.0, + -1.0, 1.0, 1.0, + -1.0, -1.0, -1.0, + -1.0, 1.0, -1.0, + -1.0, -1.0, -1.0, + 1.0, -1.0, -1.0, + -1.0, -1.0, -1.0, + -1.0, -1.0, 1.0, + 1.0, -1.0, 1.0, + 1.0, -1.0, -1.0, + 1.0, 1.0, -1.0, + 1.0, -1.0, -1.0, + -1.0, 1.0, 1.0, + -1.0, 1.0, -1.0, + 1.0, -1.0, 1.0, + -1.0, -1.0, 1.0, + -1.0, 1.0, 1.0, + -1.0, -1.0, 1.0, + -1.0, 1.0, -1.0, + 1.0, 1.0, -1.0 + ]) + } + } + boundingVolumePositionAttribute: boundingBoxPosition + } + } + } +} \ No newline at end of file diff --git a/meshroom/ui/qml/Viewer3D/DefaultCameraController.qml b/meshroom/ui/qml/Viewer3D/DefaultCameraController.qml index 1992ca28..b922a1cd 100644 --- a/meshroom/ui/qml/Viewer3D/DefaultCameraController.qml +++ b/meshroom/ui/qml/Viewer3D/DefaultCameraController.qml @@ -23,6 +23,8 @@ Entity { property alias windowSize: trackball.windowSize property alias trackballSize: trackball.trackballSize + property bool loseMouseFocus: false // Must be changed by other entities when they want to take mouse focus + readonly property alias pressed: mouseHandler._pressed signal mousePressed(var mouse) signal mouseReleased(var mouse, var moved) @@ -44,7 +46,7 @@ Entity { property point lastPosition property point currentPosition property bool hasMoved - sourceDevice: mouseSourceDevice + sourceDevice: loseMouseFocus ? null : mouseSourceDevice onPressed: { _pressed = true; currentPosition.x = lastPosition.x = mouse.x; @@ -60,6 +62,30 @@ Entity { onPositionChanged: { currentPosition.x = mouse.x; currentPosition.y = mouse.y; + + const dt = 0.02 + + if(panning) { // translate + var d = (root.camera.viewCenter.minus(root.camera.position)).length() * 0.03; + var tx = axisMX.value * root.translateSpeed * d; + var ty = axisMY.value * root.translateSpeed * d; + mouseHandler.hasMoved = true; + root.camera.translate(Qt.vector3d(-tx, -ty, 0).times(dt)); + return; + } + if(moving){ // trackball rotation + trackball.rotate(mouseHandler.lastPosition, mouseHandler.currentPosition, dt); + mouseHandler.lastPosition = mouseHandler.currentPosition; + mouseHandler.hasMoved = true; + return; + } + if(zooming) { // zoom with alt + RMD + var d = (root.camera.viewCenter.minus(root.camera.position)).length() * 0.1; + var tz = axisMX.value * root.translateSpeed * d; + mouseHandler.hasMoved = true; + root.camera.translate(Qt.vector3d(0, 0, tz).times(dt), Camera.DontTranslateViewCenter) + return; + } } onDoubleClicked: mouseDoubleClicked(mouse) onWheel: { @@ -162,32 +188,4 @@ Entity { } ] } - - components: [ - FrameAction { - onTriggered: { - if(panning) { // translate - var d = (root.camera.viewCenter.minus(root.camera.position)).length() * 0.03; - var tx = axisMX.value * root.translateSpeed * d; - var ty = axisMY.value * root.translateSpeed * d; - mouseHandler.hasMoved = true; - root.camera.translate(Qt.vector3d(-tx, -ty, 0).times(dt)); - return; - } - if(moving){ // trackball rotation - trackball.rotate(mouseHandler.lastPosition, mouseHandler.currentPosition, dt); - mouseHandler.lastPosition = mouseHandler.currentPosition; - mouseHandler.hasMoved = true; - return; - } - if(zooming) { // zoom with alt + RMD - var d = (root.camera.viewCenter.minus(root.camera.position)).length() * 0.1; - var tz = axisMX.value * root.translateSpeed * d; - mouseHandler.hasMoved = true; - root.camera.translate(Qt.vector3d(0, 0, tz).times(dt), Camera.DontTranslateViewCenter) - return; - } - } - } - ] } diff --git a/meshroom/ui/qml/Viewer3D/EntityWithGizmo.qml b/meshroom/ui/qml/Viewer3D/EntityWithGizmo.qml new file mode 100644 index 00000000..b2cf95e5 --- /dev/null +++ b/meshroom/ui/qml/Viewer3D/EntityWithGizmo.qml @@ -0,0 +1,36 @@ +import Qt3D.Core 2.0 +import Qt3D.Render 2.9 +import Qt3D.Input 2.0 +import Qt3D.Extras 2.10 +import QtQuick 2.9 +import Qt3D.Logic 2.0 + +/** + * Wrapper for TransformGizmo. + * Must be instantiated to control an other entity. + * The goal is to instantiate the other entity inside this wrapper to gather the object and the gizmo. + * objectTranform is the component the other entity should use as a Transform. + */ + +Entity { + id: root + property DefaultCameraController sceneCameraController + property Layer frontLayerComponent + property var window + property alias uniformScale: transformGizmo.uniformScale // By default, if not set, the value is: false + property TransformGizmo transformGizmo: TransformGizmo { + id: transformGizmo + camera: root.camera + windowSize: root.windowSize + frontLayerComponent: root.frontLayerComponent + window: root.window + + onPickedChanged: { + sceneCameraController.loseMouseFocus = pressed // Notify the camera if the transform takes/releases the focus + } + } + + readonly property Camera camera : sceneCameraController.camera + readonly property var windowSize: sceneCameraController.windowSize + readonly property alias objectTransform : transformGizmo.objectTransform // The Transform the object should use +} \ No newline at end of file diff --git a/meshroom/ui/qml/Viewer3D/ImageOverlay.qml b/meshroom/ui/qml/Viewer3D/ImageOverlay.qml index a6ddf90a..5f79d4df 100644 --- a/meshroom/ui/qml/Viewer3D/ImageOverlay.qml +++ b/meshroom/ui/qml/Viewer3D/ImageOverlay.qml @@ -4,7 +4,7 @@ import QtQuick.Layouts 1.12 /** * ImageOverlay enables to display a Viewpoint image on top of a 3D View. * It takes the principal point correction into account and handle image ratio to - * correclty fit or crop according to original image ratio and parent Item ratio. + * correctly fit or crop according to original image ratio and parent Item ratio. */ Item { id: root diff --git a/meshroom/ui/qml/Viewer3D/Inspector3D.qml b/meshroom/ui/qml/Viewer3D/Inspector3D.qml index 5c37c73d..48198fbb 100644 --- a/meshroom/ui/qml/Viewer3D/Inspector3D.qml +++ b/meshroom/ui/qml/Viewer3D/Inspector3D.qml @@ -284,6 +284,38 @@ FloatingPane { } } + // BoundingBox visibility (if meshing node) + MaterialToolButton { + visible: model.hasBoundingBox + enabled: model.visible + Layout.alignment: Qt.AlignTop + Layout.fillHeight: true + text: MaterialIcons.transform + font.pointSize: 10 + ToolTip.text: model.displayBoundingBox ? "Hide BBox" : "Show BBox" + flat: true + opacity: model.visible ? (model.displayBoundingBox ? 1.0 : 0.6) : 0.6 + onClicked: { + model.displayBoundingBox = !model.displayBoundingBox + } + } + + // Transform visibility (if SfMTransform node) + MaterialToolButton { + visible: model.hasTransform + enabled: model.visible + Layout.alignment: Qt.AlignTop + Layout.fillHeight: true + text: MaterialIcons._3d_rotation + font.pointSize: 10 + ToolTip.text: model.displayTransform ? "Hide Gizmo" : "Show Gizmo" + flat: true + opacity: model.visible ? (model.displayTransform ? 1.0 : 0.6) : 0.6 + onClicked: { + model.displayTransform = !model.displayTransform + } + } + // Media label and info Item { implicitHeight: childrenRect.height diff --git a/meshroom/ui/qml/Viewer3D/MaterialSwitcher.qml b/meshroom/ui/qml/Viewer3D/MaterialSwitcher.qml index 75081e18..06b09a3f 100644 --- a/meshroom/ui/qml/Viewer3D/MaterialSwitcher.qml +++ b/meshroom/ui/qml/Viewer3D/MaterialSwitcher.qml @@ -3,6 +3,7 @@ import Qt3D.Render 2.9 import Qt3D.Input 2.0 import Qt3D.Extras 2.10 import QtQuick 2.0 +import Utils 1.0 import "Materials" /** @@ -67,6 +68,10 @@ Entity { // "textured" material resolution order: diffuse map > vertex color data > no color info material: diffuseMap ? textured : (Scene3DHelper.vertexColorCount(root.parent) ? colored : solid) } + }, + State { + name: "Spherical Harmonics" + PropertyChanges { target: m; material: shMaterial } } ] } @@ -112,4 +117,11 @@ Entity { specular: root.specular } + SphericalHarmonicsMaterial { + id: shMaterial + objectName: "SphericalHarmonicsMaterial" + effect: SphericalHarmonicsEffect {} + shlSource: Filepath.stringToUrl(Viewer3DSettings.shlFile) + displayNormals: Viewer3DSettings.displayNormals + } } diff --git a/meshroom/ui/qml/Viewer3D/Materials/SphericalHarmonicsEffect.qml b/meshroom/ui/qml/Viewer3D/Materials/SphericalHarmonicsEffect.qml new file mode 100644 index 00000000..065992c9 --- /dev/null +++ b/meshroom/ui/qml/Viewer3D/Materials/SphericalHarmonicsEffect.qml @@ -0,0 +1,36 @@ +import Qt3D.Core 2.0 +import Qt3D.Render 2.0 + +Effect { + id: root + + + parameters: [ + Parameter { name: "shCoeffs[0]"; value: [] }, + Parameter { name: "displayNormals"; value: false } + ] + + techniques: [ + Technique { + graphicsApiFilter { + api: GraphicsApiFilter.OpenGL + profile: GraphicsApiFilter.CoreProfile + majorVersion: 3 + minorVersion: 1 + } + + + filterKeys: [ FilterKey { name: "renderingStyle"; value: "forward" } ] + + renderPasses: [ + RenderPass { + shaderProgram: ShaderProgram { + vertexShaderCode: loadSource(Qt.resolvedUrl("shaders/SphericalHarmonics.vert")) + fragmentShaderCode: loadSource(Qt.resolvedUrl("shaders/SphericalHarmonics.frag")) + + } + } + ] + } + ] +} diff --git a/meshroom/ui/qml/Viewer3D/Materials/SphericalHarmonicsMaterial.qml b/meshroom/ui/qml/Viewer3D/Materials/SphericalHarmonicsMaterial.qml new file mode 100644 index 00000000..8ecc07bb --- /dev/null +++ b/meshroom/ui/qml/Viewer3D/Materials/SphericalHarmonicsMaterial.qml @@ -0,0 +1,63 @@ +import Qt3D.Core 2.0 +import Qt3D.Render 2.0 +import Utils 1.0 + +Material { + id: root + + /// Source file containing coefficients + property url shlSource + /// Spherical Harmonics coefficients (array of 9 vector3d) + property var coefficients: noCoeffs + /// Whether to display normals instead of SH + property bool displayNormals: false + + // default coefficients (uniform magenta) + readonly property var noCoeffs: [ + Qt.vector3d(0.0, 0.0, 0.0), + Qt.vector3d(0.0, 0.0, 0.0), + Qt.vector3d(0.0, 0.0, 0.0), + Qt.vector3d(1.0, 0.0, 1.0), + Qt.vector3d(0.0, 0.0, 0.0), + Qt.vector3d(0.0, 0.0, 0.0), + Qt.vector3d(0.0, 0.0, 0.0), + Qt.vector3d(0.0, 0.0, 0.0), + Qt.vector3d(0.0, 0.0, 0.0) + ] + + effect: SphericalHarmonicsEffect {} + + onShlSourceChanged: { + if(!shlSource) { + coefficients = noCoeffs; + return; + } + Request.get(Filepath.urlToString(shlSource), function(xhr) { + if(xhr.readyState === XMLHttpRequest.DONE) { + var coeffs = []; + var lines = xhr.responseText.split("\n"); + lines.forEach(function(l){ + var lineCoeffs = []; + l.split(" ").forEach(function(v){ + if(v) { lineCoeffs.push(v); } + }) + if(lineCoeffs.length == 3) + coeffs.push(Qt.vector3d(lineCoeffs[0], lineCoeffs[1], lineCoeffs[2])); + }); + + if(coeffs.length == 9) { + coefficients = coeffs; + } + else { + console.warn("Invalid SHL file: " + shlSource + " with " + coeffs.length + " coefficients."); + coefficients = noCoeffs; + } + } + }) + } + + parameters: [ + Parameter { name: "shCoeffs[0]"; value: coefficients }, + Parameter { name: "displayNormals"; value: displayNormals } + ] +} diff --git a/meshroom/ui/qml/Viewer3D/Materials/shaders/SphericalHarmonics.frag b/meshroom/ui/qml/Viewer3D/Materials/shaders/SphericalHarmonics.frag new file mode 100644 index 00000000..ecc5d730 --- /dev/null +++ b/meshroom/ui/qml/Viewer3D/Materials/shaders/SphericalHarmonics.frag @@ -0,0 +1,34 @@ +#version 330 core + +in vec3 normal; +out vec4 fragColor; + +uniform vec3 shCoeffs[9]; +uniform bool displayNormals = false; + +vec3 resolveSH_Opt(vec3 premulCoefficients[9], vec3 dir) +{ + vec3 result = premulCoefficients[0] * dir.x; + result += premulCoefficients[1] * dir.y; + result += premulCoefficients[2] * dir.z; + result += premulCoefficients[3]; + vec3 dirSq = dir * dir; + result += premulCoefficients[4] * (dir.x * dir.y); + result += premulCoefficients[5] * (dir.x * dir.z); + result += premulCoefficients[6] * (dir.y * dir.z); + result += premulCoefficients[7] * (dirSq.x - dirSq.y); + result += premulCoefficients[8] * (3 * dirSq.z - 1); + return result; +} + +void main() +{ + if(displayNormals) { + // Display normals mode + fragColor = vec4(normal, 1.0); + } + else { + // Calculate the color from spherical harmonics coeffs + fragColor = vec4(resolveSH_Opt(shCoeffs, normal), 1.0); + } +} diff --git a/meshroom/ui/qml/Viewer3D/Materials/shaders/SphericalHarmonics.vert b/meshroom/ui/qml/Viewer3D/Materials/shaders/SphericalHarmonics.vert new file mode 100644 index 00000000..79d44323 --- /dev/null +++ b/meshroom/ui/qml/Viewer3D/Materials/shaders/SphericalHarmonics.vert @@ -0,0 +1,16 @@ +#version 330 core + +in vec3 vertexPosition; +in vec3 vertexNormal; + +out vec3 normal; + +uniform mat4 modelView; +uniform mat3 modelViewNormal; +uniform mat4 mvp; + +void main() +{ + normal = vertexNormal; + gl_Position = mvp * vec4( vertexPosition, 1.0 ); +} diff --git a/meshroom/ui/qml/Viewer3D/MediaLibrary.qml b/meshroom/ui/qml/Viewer3D/MediaLibrary.qml index 6542e2db..624a579a 100644 --- a/meshroom/ui/qml/Viewer3D/MediaLibrary.qml +++ b/meshroom/ui/qml/Viewer3D/MediaLibrary.qml @@ -16,6 +16,11 @@ Entity { property bool pickingEnabled: false readonly property alias count: instantiator.count // number of instantiated media delegates + // For TransformGizmo in BoundingBox + property DefaultCameraController sceneCameraController + property Layer frontLayerComponent + property var window + /// Camera to consider for positionning property Camera camera: null @@ -41,6 +46,10 @@ Entity { "valid": true, "label": "", "visible": true, + "hasBoundingBox": false, // for Meshing node only + "displayBoundingBox": true, // for Meshing node only + "hasTransform": false, // for SfMTransform node only + "displayTransform": true, // for SfMTransform node only "section": "", "attribute": null, "entity": null, @@ -146,144 +155,232 @@ Entity { id: instantiator model: m.mediaModel - delegate: MediaLoader { - id: mediaLoader + delegate: Entity { + id: instantiatedEntity + property alias fullyInstantiated: mediaLoader.fullyInstantiated + readonly property alias modelSource: mediaLoader.modelSource - // whether MediaLoader has been fully instantiated by the NodeInstantiator - property bool fullyInstantiated: false + // Get the node + property var currentNode: model.attribute ? model.attribute.node : null + property string nodeType: currentNode ? currentNode.nodeType: null - // explicitely store some attached model properties for outside use and ease binding - readonly property var attribute: model.attribute - readonly property int idx: index - readonly property var modelSource: attribute || model.source - readonly property bool visible: model.visible - - // multi-step binding to ensure MediaLoader source is properly - // updated when needed, whether raw source is valid or not - - // raw source path - readonly property string rawSource: attribute ? attribute.value : model.source - // whether dependencies are statified (applies for output/connected input attributes only) - readonly property bool dependencyReady: { - if(attribute && attribute.isOutput) - return attribute.node.globalStatus === "SUCCESS"; - if(attribute && attribute.isLink) - return attribute.linkParam.node.globalStatus === "SUCCESS"; - return true; + // Specific properties to the MESHING node (declared and initialized for every Entity anyway) + property bool hasBoundingBox: { + if(nodeType === "Meshing" && currentNode.attribute("useBoundingBox")) // Can have a BoundingBox + return currentNode.attribute("useBoundingBox").value + return false } - // source based on raw source + dependency status - readonly property string currentSource: dependencyReady ? rawSource : "" - // source based on currentSource + "requested" property - readonly property string finalSource: model.requested ? currentSource : "" + onHasBoundingBoxChanged: model.hasBoundingBox = hasBoundingBox + property bool displayBoundingBox: model.displayBoundingBox - camera: root.camera - renderMode: root.renderMode - enabled: visible - - // QObject.destroyed signal is not accessible - // Use the object as NodeInstantiator model to be notified of its deletion - NodeInstantiator { - model: attribute - delegate: Entity { objectName: "DestructionWatcher [" + attribute.toString() + "]" } - onObjectRemoved: remove(idx) + // Specific properties to the SFMTRANSFORM node (declared and initialized for every Entity anyway) + property bool hasTransform: { + if(nodeType === "SfMTransform" && currentNode.attribute("method")) // Can have a Transform + return currentNode.attribute("method").value === "manual" + return false } + onHasTransformChanged: model.hasTransform = hasTransform + property bool displayTransform: model.displayTransform - // 'visible' property drives media loading request - onVisibleChanged: { - // always request media loading if visible - if(model.visible) - model.requested = true; - // only cancel loading request if media is not valid - // (a media won't be unloaded if already loaded, only hidden) - else if(!model.valid) - model.requested = false; - } - function updateCacheAndModel(forceRequest) { - // don't cache explicitely unloaded media - if(model.requested && object && dependencyReady) { - // cache current object - if(cache.add(Filepath.urlToString(mediaLoader.source), object)); - object = null; + // Create the media + MediaLoader { + id: mediaLoader + + // whether MediaLoader has been fully instantiated by the NodeInstantiator + property bool fullyInstantiated: false + + // explicitely store some attached model properties for outside use and ease binding + readonly property var attribute: model.attribute + readonly property int idx: index + readonly property var modelSource: attribute || model.source + readonly property bool visible: model.visible + + // multi-step binding to ensure MediaLoader source is properly + // updated when needed, whether raw source is valid or not + + // raw source path + property string rawSource: attribute ? attribute.value : model.source + // whether dependencies are statified (applies for output/connected input attributes only) + readonly property bool dependencyReady: { + if(attribute) { + const rootAttribute = attribute.isLink ? attribute.rootLinkParam : attribute + if(rootAttribute.isOutput) + return rootAttribute.node.globalStatus === "SUCCESS" + } + return true // is an input param without link (so no dependency) or an external file } - updateModel(forceRequest); - } + // source based on raw source + dependency status + property string currentSource: dependencyReady ? rawSource : "" + // source based on currentSource + "requested" property + property string finalSource: model.requested ? currentSource : "" - function updateModel(forceRequest) { - // update model's source path if input is an attribute - if(attribute) { - model.source = rawSource; + // To use only if we want to draw the input source and not the current node output (Warning: to use with caution) + // There is maybe a better way to do this to avoid overwritting bindings which should be readonly properties + function drawInputSource() { + rawSource = Qt.binding(() => instantiatedEntity.currentNode ? instantiatedEntity.currentNode.attribute("input").value: "") + currentSource = Qt.binding(() => rawSource) + finalSource = Qt.binding(() => rawSource) } - // auto-restore entity if raw source is in cache - model.requested = forceRequest || (!model.valid && model.requested) || cache.contains(rawSource); - model.valid = Filepath.exists(rawSource) && dependencyReady; - } - Component.onCompleted: { - // keep 'source' -> 'entity' reference - m.sourceToEntity[modelSource] = mediaLoader; - // always request media loading when delegate has been created - updateModel(true); - // if external media failed to open, remove element from model - if(!attribute && !object) - remove(index) - } + camera: root.camera + renderMode: root.renderMode + enabled: visible - onCurrentSourceChanged: { - updateCacheAndModel(false) - } + // QObject.destroyed signal is not accessible + // Use the object as NodeInstantiator model to be notified of its deletion + NodeInstantiator { + model: attribute + delegate: Entity { objectName: "DestructionWatcher [" + model.toString() + "]" } + onObjectRemoved: remove(index) + } - onFinalSourceChanged: { - // update media visibility - // (useful if media was explicitly unloaded or hidden but loaded back from cache) - model.visible = model.requested; + property bool alive: attribute ? attribute.node.alive : false + onAliveChanged: { + if(!alive && index >= 0) + remove(index) + } - var cachedObject = cache.pop(rawSource); - cached = cachedObject !== undefined; - if(cached) { - object = cachedObject; - // only change cached object parent if mediaLoader has been fully instantiated - // by the NodeInstantiator; otherwise re-parenting will fail silently and the object will disappear... - // see "onFullyInstantiatedChanged" and parent NodeInstantiator's "onObjectAdded" - if(fullyInstantiated) { - object.parent = mediaLoader; + // 'visible' property drives media loading request + onVisibleChanged: { + // always request media loading if visible + if(model.visible) + model.requested = true; + // only cancel loading request if media is not valid + // (a media won't be unloaded if already loaded, only hidden) + else if(!model.valid) + model.requested = false; + } + + function updateCacheAndModel(forceRequest) { + // don't cache explicitly unloaded media + if(model.requested && object && dependencyReady) { + // cache current object + if(cache.add(Filepath.urlToString(mediaLoader.source), object)); + object = null; + } + updateModel(forceRequest); + } + + function updateModel(forceRequest) { + // update model's source path if input is an attribute + if(attribute) { + model.source = rawSource; + } + // auto-restore entity if raw source is in cache + model.requested = forceRequest || (!model.valid && model.requested) || cache.contains(rawSource); + model.valid = Filepath.exists(rawSource) && dependencyReady; + } + + Component.onCompleted: { + // keep 'source' -> 'entity' reference + m.sourceToEntity[modelSource] = instantiatedEntity; + // always request media loading when delegate has been created + updateModel(true); + // if external media failed to open, remove element from model + if(!attribute && !object) + remove(index) + } + + onCurrentSourceChanged: { + updateCacheAndModel(false) + + // Avoid the bounding box to disappear when we move it after a mesh already computed + if(instantiatedEntity.hasBoundingBox && !currentSource) + model.visible = true + } + + onFinalSourceChanged: { + // update media visibility + // (useful if media was explicitly unloaded or hidden but loaded back from cache) + model.visible = model.requested; + + var cachedObject = cache.pop(rawSource); + cached = cachedObject !== undefined; + if(cached) { + object = cachedObject; + // only change cached object parent if mediaLoader has been fully instantiated + // by the NodeInstantiator; otherwise re-parenting will fail silently and the object will disappear... + // see "onFullyInstantiatedChanged" and parent NodeInstantiator's "onObjectAdded" + if(fullyInstantiated) { + object.parent = mediaLoader; + } + } + mediaLoader.source = Filepath.stringToUrl(finalSource); + if(object) { + // bind media info to corresponding model roles + // (test for object validity to avoid error messages right after object has been deleted) + var boundProperties = ["vertexCount", "faceCount", "cameraCount", "textureCount"]; + boundProperties.forEach( function(prop){ + model[prop] = Qt.binding(function() { return object ? object[prop] : 0; }); + }) + } + else if(finalSource && status === Component.Ready) { + // source was valid but no loader was created, remove element + // check if component is ready to avoid removing element from the model before adding instance to the node + remove(index) } } - mediaLoader.source = Filepath.stringToUrl(finalSource); - if(object) { - // bind media info to corresponding model roles - // (test for object validity to avoid error messages right after object has been deleted) - var boundProperties = ["vertexCount", "faceCount", "cameraCount", "textureCount"]; - boundProperties.forEach( function(prop){ - model[prop] = Qt.binding(function() { return object ? object[prop] : 0; }); - }) + + onFullyInstantiatedChanged: { + // delayed reparenting of object coming from the cache + if(object) + object.parent = mediaLoader; } - else if(finalSource) { - // source was valid but no loader was created, remove element - remove(index); + + onStatusChanged: { + model.status = status + // remove model entry for external media that failed to load + if(status === SceneLoader.Error && !model.attribute) + remove(index); + } + + components: [ + ObjectPicker { + enabled: mediaLoader.enabled && pickingEnabled + hoverEnabled: false + onPressed: root.pressed(pick) + } + ] + } + + // Transform: display a TransformGizmo for SfMTransform node only + // note: use a NodeInstantiator to evaluate if the current node is a SfMTransform node and if the transform mode is set to Manual + NodeInstantiator { + id: sfmTransformGizmoInstantiator + active: instantiatedEntity.hasTransform + model: 1 + + SfMTransformGizmo { + id: sfmTransformGizmoEntity + sceneCameraController: root.sceneCameraController + frontLayerComponent: root.frontLayerComponent + window: root.window + currentSfMTransformNode: instantiatedEntity.currentNode + enabled: mediaLoader.visible && instantiatedEntity.displayTransform + + Component.onCompleted: { + mediaLoader.drawInputSource() // Because we are sure we want to show the input in MANUAL mode only + Scene3DHelper.addComponent(mediaLoader, sfmTransformGizmoEntity.objectTransform) // Add the transform to the media to see real-time transformations + } } } - onFullyInstantiatedChanged: { - // delayed reparenting of object coming from the cache - if(object) - object.parent = mediaLoader; - } + // BoundingBox: display bounding box for MESHING computation + // note: use a NodeInstantiator to evaluate if the current node is a MESHING node and if the checkbox is active + NodeInstantiator { + id: boundingBoxInstantiator + active: instantiatedEntity.hasBoundingBox + model: 1 - onStatusChanged: { - model.status = status - // remove model entry for external media that failed to load - if(status === SceneLoader.Error && !model.attribute) - remove(index); - } - - components: [ - ObjectPicker { - enabled: mediaLoader.enabled && pickingEnabled - hoverEnabled: false - onPressed: root.pressed(pick) + MeshingBoundingBox { + sceneCameraController: root.sceneCameraController + frontLayerComponent: root.frontLayerComponent + window: root.window + currentMeshingNode: instantiatedEntity.currentNode + enabled: mediaLoader.visible && instantiatedEntity.displayBoundingBox } - ] + } } onObjectAdded: { @@ -292,7 +389,8 @@ Entity { } onObjectRemoved: { - delete m.sourceToEntity[object.modelSource]; + if(m.sourceToEntity[object.modelSource]) + delete m.sourceToEntity[object.modelSource] } } } diff --git a/meshroom/ui/qml/Viewer3D/MediaLoader.qml b/meshroom/ui/qml/Viewer3D/MediaLoader.qml index fd48f6c8..96bfc516 100644 --- a/meshroom/ui/qml/Viewer3D/MediaLoader.qml +++ b/meshroom/ui/qml/Viewer3D/MediaLoader.qml @@ -31,7 +31,7 @@ import Utils 1.0 return; } - // clear previously created objet if any + // clear previously created object if any if(object) { object.destroy(); object = null; @@ -110,6 +110,14 @@ import Utils 1.0 MediaLoaderEntity { id: exrLoaderEntity Component.onCompleted: { + var fSize = Filepath.fileSizeMB(source) + if(fSize > 500) + { + // Do not load images that are larger than 500MB + console.warn("Viewer3D: Do not load the EXR in 3D as the file size is too large: " + fSize + "MB") + root.status = SceneLoader.Error; + return; + } // EXR loading strategy: // - [1] as a depth map var obj = Viewer3DSettings.depthMapLoaderComp.createObject( diff --git a/meshroom/ui/qml/Viewer3D/MeshingBoundingBox.qml b/meshroom/ui/qml/Viewer3D/MeshingBoundingBox.qml new file mode 100644 index 00000000..4baf4121 --- /dev/null +++ b/meshroom/ui/qml/Viewer3D/MeshingBoundingBox.qml @@ -0,0 +1,91 @@ +import Qt3D.Core 2.0 +import Qt3D.Render 2.9 +import Qt3D.Input 2.0 +import Qt3D.Extras 2.10 +import QtQuick 2.9 + +/** + * BoundingBox entity for Meshing node. Used to define the area to reconstruct. + * Simple box controlled by a gizmo to give easy and visual representation. + */ +Entity { + id: root + property DefaultCameraController sceneCameraController + property Layer frontLayerComponent + property var window + property var currentMeshingNode: null + enabled: true + + EntityWithGizmo { + id: boundingBoxEntity + sceneCameraController: root.sceneCameraController + frontLayerComponent: root.frontLayerComponent + window: root.window + + // Update node meshing slider values when the gizmo has changed: translation, rotation, scale, type + transformGizmo.onGizmoChanged: { + switch(type) { + case TransformGizmo.Type.TRANSLATION: { + _reconstruction.setAttribute( + root.currentMeshingNode.attribute("boundingBox.bboxTranslation"), + JSON.stringify([translation.x, translation.y, translation.z]) + ) + break + } + case TransformGizmo.Type.ROTATION: { + _reconstruction.setAttribute( + root.currentMeshingNode.attribute("boundingBox.bboxRotation"), + JSON.stringify([rotation.x, rotation.y, rotation.z]) + ) + break + } + case TransformGizmo.Type.SCALE: { + _reconstruction.setAttribute( + root.currentMeshingNode.attribute("boundingBox.bboxScale"), + JSON.stringify([scale.x, scale.y, scale.z]) + ) + break + } + case TransformGizmo.Type.ALL: { + _reconstruction.setAttribute( + root.currentMeshingNode.attribute("boundingBox"), + JSON.stringify([ + [translation.x, translation.y, translation.z], + [rotation.x, rotation.y, rotation.z], + [scale.x, scale.y, scale.z] + ]) + ) + break + } + } + } + + // Translation values from node (vector3d because this is the type of QTransform.translation) + property var nodeTranslation : Qt.vector3d( + root.currentMeshingNode ? root.currentMeshingNode.attribute("boundingBox.bboxTranslation.x").value : 0, + root.currentMeshingNode ? root.currentMeshingNode.attribute("boundingBox.bboxTranslation.y").value : 0, + root.currentMeshingNode ? root.currentMeshingNode.attribute("boundingBox.bboxTranslation.z").value : 0 + ) + // Rotation values from node (3 separated values because QTransform stores Euler angles like this) + property var nodeRotationX: root.currentMeshingNode ? root.currentMeshingNode.attribute("boundingBox.bboxRotation.x").value : 0 + property var nodeRotationY: root.currentMeshingNode ? root.currentMeshingNode.attribute("boundingBox.bboxRotation.y").value : 0 + property var nodeRotationZ: root.currentMeshingNode ? root.currentMeshingNode.attribute("boundingBox.bboxRotation.z").value : 0 + // Scale values from node (vector3d because this is the type of QTransform.scale3D) + property var nodeScale: Qt.vector3d( + root.currentMeshingNode ? root.currentMeshingNode.attribute("boundingBox.bboxScale.x").value : 1, + root.currentMeshingNode ? root.currentMeshingNode.attribute("boundingBox.bboxScale.y").value : 1, + root.currentMeshingNode ? root.currentMeshingNode.attribute("boundingBox.bboxScale.z").value : 1 + ) + + // Automatically evaluate the Transform: value is taken from the node OR from the actual modification if the gizmo is moved by mouse. + // When the gizmo has changed (with mouse), the new values are set to the node, the priority is given back to the node and the Transform is re-evaluated once with those values. + transformGizmo.gizmoDisplayTransform.translation: transformGizmo.focusGizmoPriority ? transformGizmo.gizmoDisplayTransform.translation : nodeTranslation + transformGizmo.gizmoDisplayTransform.rotationX: transformGizmo.focusGizmoPriority ? transformGizmo.gizmoDisplayTransform.rotationX : nodeRotationX + transformGizmo.gizmoDisplayTransform.rotationY: transformGizmo.focusGizmoPriority ? transformGizmo.gizmoDisplayTransform.rotationY : nodeRotationY + transformGizmo.gizmoDisplayTransform.rotationZ: transformGizmo.focusGizmoPriority ? transformGizmo.gizmoDisplayTransform.rotationZ : nodeRotationZ + transformGizmo.objectTransform.scale3D: transformGizmo.focusGizmoPriority ? transformGizmo.objectTransform.scale3D : nodeScale + + // The entity + BoundingBox { transform: boundingBoxEntity.objectTransform } + } +} diff --git a/meshroom/ui/qml/Viewer3D/SfMTransformGizmo.qml b/meshroom/ui/qml/Viewer3D/SfMTransformGizmo.qml new file mode 100644 index 00000000..e9998264 --- /dev/null +++ b/meshroom/ui/qml/Viewer3D/SfMTransformGizmo.qml @@ -0,0 +1,88 @@ +import Qt3D.Core 2.0 +import Qt3D.Render 2.9 +import Qt3D.Input 2.0 +import Qt3D.Extras 2.10 +import QtQuick 2.9 + +/** + * Gizmo for SfMTransform node. + * Uses EntityWithGizmo wrapper because we should not instantiate TransformGizmo alone. + */ +Entity { + id: root + property DefaultCameraController sceneCameraController + property Layer frontLayerComponent + property var window + property var currentSfMTransformNode: null + enabled: true + + readonly property alias objectTransform: sfmTranformGizmoEntity.objectTransform // The Transform the object should use + + EntityWithGizmo { + id: sfmTranformGizmoEntity + sceneCameraController: root.sceneCameraController + frontLayerComponent: root.frontLayerComponent + window: root.window + uniformScale: true // We want to make uniform scale transformations + + // Update node SfMTransform slider values when the gizmo has changed: translation, rotation, scale, type + transformGizmo.onGizmoChanged: { + switch(type) { + case TransformGizmo.Type.TRANSLATION: { + _reconstruction.setAttribute( + root.currentSfMTransformNode.attribute("manualTransform.manualTranslation"), + JSON.stringify([translation.x, translation.y, translation.z]) + ) + break + } + case TransformGizmo.Type.ROTATION: { + _reconstruction.setAttribute( + root.currentSfMTransformNode.attribute("manualTransform.manualRotation"), + JSON.stringify([rotation.x, rotation.y, rotation.z]) + ) + break + } + case TransformGizmo.Type.SCALE: { + // Only one scale is needed since the scale is uniform + _reconstruction.setAttribute( + root.currentSfMTransformNode.attribute("manualTransform.manualScale"), + scale.x + ) + break + } + case TransformGizmo.Type.ALL: { + _reconstruction.setAttribute( + root.currentSfMTransformNode.attribute("manualTransform"), + JSON.stringify([ + [translation.x, translation.y, translation.z], + [rotation.x, rotation.y, rotation.z], + scale.x + ]) + ) + break + } + } + } + + // Translation values from node (vector3d because this is the type of QTransform.translation) + property var nodeTranslation : Qt.vector3d( + root.currentSfMTransformNode ? root.currentSfMTransformNode.attribute("manualTransform.manualTranslation.x").value : 0, + root.currentSfMTransformNode ? root.currentSfMTransformNode.attribute("manualTransform.manualTranslation.y").value : 0, + root.currentSfMTransformNode ? root.currentSfMTransformNode.attribute("manualTransform.manualTranslation.z").value : 0 + ) + // Rotation values from node (3 separated values because QTransform stores Euler angles like this) + property var nodeRotationX: root.currentSfMTransformNode ? root.currentSfMTransformNode.attribute("manualTransform.manualRotation.x").value : 0 + property var nodeRotationY: root.currentSfMTransformNode ? root.currentSfMTransformNode.attribute("manualTransform.manualRotation.y").value : 0 + property var nodeRotationZ: root.currentSfMTransformNode ? root.currentSfMTransformNode.attribute("manualTransform.manualRotation.z").value : 0 + // Scale value from node (simple number because we use uniform scale) + property var nodeScale: root.currentSfMTransformNode ? root.currentSfMTransformNode.attribute("manualTransform.manualScale").value : 1 + + // Automatically evaluate the Transform: value is taken from the node OR from the actual modification if the gizmo is moved by mouse. + // When the gizmo has changed (with mouse), the new values are set to the node, the priority is given back to the node and the Transform is re-evaluated once with those values. + transformGizmo.gizmoDisplayTransform.translation: transformGizmo.focusGizmoPriority ? transformGizmo.gizmoDisplayTransform.translation : nodeTranslation + transformGizmo.gizmoDisplayTransform.rotationX: transformGizmo.focusGizmoPriority ? transformGizmo.gizmoDisplayTransform.rotationX : nodeRotationX + transformGizmo.gizmoDisplayTransform.rotationY: transformGizmo.focusGizmoPriority ? transformGizmo.gizmoDisplayTransform.rotationY : nodeRotationY + transformGizmo.gizmoDisplayTransform.rotationZ: transformGizmo.focusGizmoPriority ? transformGizmo.gizmoDisplayTransform.rotationZ : nodeRotationZ + transformGizmo.objectTransform.scale3D: transformGizmo.focusGizmoPriority ? transformGizmo.objectTransform.scale3D : Qt.vector3d(nodeScale, nodeScale, nodeScale) + } +} diff --git a/meshroom/ui/qml/Viewer3D/TransformGizmo.qml b/meshroom/ui/qml/Viewer3D/TransformGizmo.qml new file mode 100644 index 00000000..05ada079 --- /dev/null +++ b/meshroom/ui/qml/Viewer3D/TransformGizmo.qml @@ -0,0 +1,594 @@ +import Qt3D.Core 2.0 +import Qt3D.Render 2.9 +import Qt3D.Input 2.0 +import Qt3D.Extras 2.10 +import QtQuick 2.9 +import Qt3D.Logic 2.0 +import QtQuick.Controls 2.3 +import Utils 1.0 + + +/** + * Simple transformation gizmo entirely made with Qt3D entities. + * Uses Python Transformations3DHelper to compute matrices. + * This TransformGizmo entity should only be instantiated in EntityWithGizmo entity which is its wrapper. + * It means, to use it for a specified application, make sure to instantiate EntityWithGizmo. + */ +Entity { + id: root + property Camera camera + property var windowSize + property Layer frontLayerComponent // Used to draw gizmo on top of everything + property var window + readonly property alias gizmoScale: gizmoScaleLookSlider.value + property bool uniformScale: false // By default, the scale is not uniform + property bool focusGizmoPriority: false // If true, it is used to give the priority to the current transformation (and not to a upper-level binding) + property Transform gizmoDisplayTransform: Transform { + id: gizmoDisplayTransform + scale: root.gizmoScale * (camera.position.minus(gizmoDisplayTransform.translation)).length() // The gizmo needs a constant apparent size + } + // Component the object controlled by the gizmo must use + property Transform objectTransform : Transform { + translation: gizmoDisplayTransform.translation + rotation: gizmoDisplayTransform.rotation + scale3D: Qt.vector3d(1,1,1) + } + + signal pickedChanged(bool pressed) + signal gizmoChanged(var translation, var rotation, var scale, int type) + + function emitGizmoChanged(type) { + const translation = gizmoDisplayTransform.translation // Position in space + const rotation = Qt.vector3d(gizmoDisplayTransform.rotationX, gizmoDisplayTransform.rotationY, gizmoDisplayTransform.rotationZ) // Euler angles + const scale = objectTransform.scale3D // Scale of the object + + gizmoChanged(translation, rotation, scale, type) + root.focusGizmoPriority = false + } + + components: [gizmoDisplayTransform, mouseHandler, frontLayerComponent] + + + /***** ENUMS *****/ + + enum Axis { + X, + Y, + Z + } + + enum Type { + TRANSLATION, + ROTATION, + SCALE, + ALL + } + + function convertAxisEnum(axis) { + switch(axis) { + case TransformGizmo.Axis.X: return Qt.vector3d(1,0,0) + case TransformGizmo.Axis.Y: return Qt.vector3d(0,1,0) + case TransformGizmo.Axis.Z: return Qt.vector3d(0,0,1) + } + } + + function convertTypeEnum(type) { + switch(type) { + case TransformGizmo.Type.TRANSLATION: return "TRANSLATION" + case TransformGizmo.Type.ROTATION: return "ROTATION" + case TransformGizmo.Type.SCALE: return "SCALE" + case TransformGizmo.Type.ALL: return "ALL" + } + } + + /***** TRANSFORMATIONS (using local vars) *****/ + + /** + * @brief Translate locally the gizmo and the object. + * + * @remarks + * To make local translation, we need to recompute a new matrix. + * Update gizmoDisplayTransform's matrix and all its properties while avoiding the override of translation property. + * Update objectTransform in the same time thanks to binding on translation property. + * + * @param initialModelMatrix object containing position, rotation and scale matrices + rotation quaternion + * @param translateVec vector3d used to make the local translation + */ + function doRelativeTranslation(initialModelMatrix, translateVec) { + Transformations3DHelper.relativeLocalTranslate( + gizmoDisplayTransform, + initialModelMatrix.position, + initialModelMatrix.rotation, + initialModelMatrix.scale, + translateVec + ) + } + + /** + * @brief Rotate the gizmo and the object around a specific axis. + * + * @remarks + * To make local rotation around an axis, we need to recompute a new matrix from a quaternion. + * Update gizmoDisplayTransform's matrix and all its properties while avoiding the override of rotation, rotationX, rotationY and rotationZ properties. + * Update objectTransform in the same time thanks to binding on rotation property. + * + * @param initialModelMatrix object containing position, rotation and scale matrices + rotation quaternion + * @param axis vector3d describing the axis to rotate around + * @param degree angle of rotation in degrees + */ + function doRelativeRotation(initialModelMatrix, axis, degree) { + Transformations3DHelper.relativeLocalRotate( + gizmoDisplayTransform, + initialModelMatrix.position, + initialModelMatrix.quaternion, + initialModelMatrix.scale, + axis, + degree + ) + } + + /** + * @brief Scale the object relatively to its current scale. + * + * @remarks + * To change scale of the object, we need to recompute a new matrix to avoid overriding bindings. + * Update objectTransform properties only (gizmoDisplayTransform is not affected). + * + * @param initialModelMatrix object containing position, rotation and scale matrices + rotation quaternion + * @param scaleVec vector3d used to make the relative scale + */ + function doRelativeScale(initialModelMatrix, scaleVec) { + Transformations3DHelper.relativeLocalScale( + objectTransform, + initialModelMatrix.position, + initialModelMatrix.rotation, + initialModelMatrix.scale, + scaleVec + ) + } + + /** + * @brief Reset the translation of the gizmo and the object. + * + * @remarks + * Update gizmoDisplayTransform's matrix and all its properties while avoiding the override of translation property. + * Update objectTransform in the same time thanks to binding on translation property. + */ + function resetTranslation() { + const mat = gizmoDisplayTransform.matrix + const newMat = Qt.matrix4x4( + mat.m11, mat.m12, mat.m13, 0, + mat.m21, mat.m22, mat.m23, 0, + mat.m31, mat.m32, mat.m33, 0, + mat.m41, mat.m42, mat.m43, 1 + ) + gizmoDisplayTransform.setMatrix(newMat) + } + + /** + * @brief Reset the rotation of the gizmo and the object. + * + * @remarks + * Update gizmoDisplayTransform's quaternion while avoiding the override of rotationX, rotationY and rotationZ properties. + * Update objectTransform in the same time thanks to binding on rotation property. + * Here, we can change the rotation property (but not rotationX, rotationY and rotationZ because they can be used in upper-level bindings). + * + * @note + * We could implement a way of changing the matrix instead of overriding rotation (quaternion) property. + */ + function resetRotation() { + gizmoDisplayTransform.rotation = Qt.quaternion(1,0,0,0) + } + + /** + * @brief Reset the scale of the object. + * + * @remarks + * To reset the scale, we make the difference of the current one to 1 and recompute the matrix. + * Like this, we kind of apply an inverse scale transformation. + * It prevents overriding scale3D property (because it can be used in upper-level binding). + */ + function resetScale() { + const modelMat = Transformations3DHelper.modelMatrixToMatrices(objectTransform.matrix) + const scaleDiff = Qt.vector3d( + -(objectTransform.scale3D.x - 1), + -(objectTransform.scale3D.y - 1), + -(objectTransform.scale3D.z - 1) + ) + doRelativeScale(modelMat, scaleDiff) + } + + /***** DEVICES *****/ + + MouseDevice { id: mouseSourceDevice } + + MouseHandler { + id: mouseHandler + sourceDevice: enabled ? mouseSourceDevice : null + property var objectPicker: null + property bool enabled: false + + onPositionChanged: { + if (objectPicker && objectPicker.button === Qt.LeftButton) { + root.focusGizmoPriority = true + + // Get the selected axis + const pickedAxis = convertAxisEnum(objectPicker.gizmoAxis) + + // TRANSLATION or SCALE transformation + if(objectPicker.gizmoType === TransformGizmo.Type.TRANSLATION || objectPicker.gizmoType === TransformGizmo.Type.SCALE) { + // Compute the vector PickedPosition -> CurrentMousePoint + const pickedPosition = objectPicker.screenPoint + const mouseVector = Qt.vector2d(mouse.x - pickedPosition.x, -(mouse.y - pickedPosition.y)) + + // Transform the positive picked axis vector from World Coord to Screen Coord + const gizmoLocalPointOnAxis = gizmoDisplayTransform.matrix.times(Qt.vector4d(pickedAxis.x, pickedAxis.y, pickedAxis.z, 1)) + const gizmoCenterPoint = gizmoDisplayTransform.matrix.times(Qt.vector4d(0, 0, 0, 1)) + const screenPoint2D = Transformations3DHelper.pointFromWorldToScreen(gizmoLocalPointOnAxis, camera, windowSize) + const screenCenter2D = Transformations3DHelper.pointFromWorldToScreen(gizmoCenterPoint, camera, windowSize) + const screenAxisVector = Qt.vector2d(screenPoint2D.x - screenCenter2D.x, -(screenPoint2D.y - screenCenter2D.y)) + + // Get the cosinus of the angle from the screenAxisVector to the mouseVector + // It will be used as a intensity factor + const cosAngle = screenAxisVector.dotProduct(mouseVector) / (screenAxisVector.length() * mouseVector.length()) + const offset = cosAngle * mouseVector.length() / objectPicker.scaleUnit + + // Do the transformation + if(objectPicker.gizmoType === TransformGizmo.Type.TRANSLATION && offset !== 0) { + doRelativeTranslation(objectPicker.modelMatrix, pickedAxis.times(offset)) // Do a translation from the initial Object Model Matrix when we picked the gizmo + } + else if(objectPicker.gizmoType === TransformGizmo.Type.SCALE && offset !== 0) { + if(root.uniformScale) + doRelativeScale(objectPicker.modelMatrix, Qt.vector3d(1,1,1).times(offset)) // Do a uniform scale from the initial Object Model Matrix when we picked the gizmo + else + doRelativeScale(objectPicker.modelMatrix, pickedAxis.times(offset)) // Do a scale on one axis from the initial Object Model Matrix when we picked the gizmo + } + + return + } + // ROTATION transformation + else if(objectPicker.gizmoType === TransformGizmo.Type.ROTATION) { + // Get Screen Coordinates of the gizmo center + const gizmoCenterPoint = gizmoDisplayTransform.matrix.times(Qt.vector4d(0, 0, 0, 1)) + const screenCenter2D = Transformations3DHelper.pointFromWorldToScreen(gizmoCenterPoint, camera, root.windowSize) + + // Get the vector screenCenter2D -> PickedPosition + const originalVector = Qt.vector2d(objectPicker.screenPoint.x - screenCenter2D.x, -(objectPicker.screenPoint.y - screenCenter2D.y)) + + // Compute the vector screenCenter2D -> CurrentMousePoint + const mouseVector = Qt.vector2d(mouse.x - screenCenter2D.x, -(mouse.y - screenCenter2D.y)) + + // Get the angle from the originalVector to the mouseVector + const angle = Math.atan2(-originalVector.y*mouseVector.x + originalVector.x*mouseVector.y, originalVector.x*mouseVector.x + originalVector.y*mouseVector.y) * 180 / Math.PI + + // Get the orientation of the gizmo in function of the camera + const gizmoLocalAxisVector = gizmoDisplayTransform.matrix.times(Qt.vector4d(pickedAxis.x, pickedAxis.y, pickedAxis.z, 0)) + const gizmoToCameraVector = camera.position.toVector4d().minus(gizmoCenterPoint) + const orientation = gizmoLocalAxisVector.dotProduct(gizmoToCameraVector) > 0 ? 1 : -1 + + if (angle !== 0) doRelativeRotation(objectPicker.modelMatrix, pickedAxis, angle*orientation) // Do a rotation from the initial Object Model Matrix when we picked the gizmo + + return + } + } + + if(objectPicker && objectPicker.button === Qt.RightButton) { + resetMenu.popup(window) + } + } + onReleased: { + if(objectPicker && mouse.button === Qt.LeftButton) { + const type = objectPicker.gizmoType + objectPicker = null // To prevent going again in the onPositionChanged + emitGizmoChanged(type) + } + } + } + + Menu { + id: resetMenu + + MenuItem { + text: "Reset Translation" + onTriggered: { + resetTranslation() + emitGizmoChanged(TransformGizmo.Type.TRANSLATION) + } + } + MenuItem { + text: "Reset Rotation" + onTriggered: { + resetRotation() + emitGizmoChanged(TransformGizmo.Type.ROTATION) + } + } + MenuItem { + text: "Reset Scale" + onTriggered: { + resetScale() + emitGizmoChanged(TransformGizmo.Type.SCALE) + } + } + MenuItem { + text: "Reset All" + onTriggered: { + resetTranslation() + resetRotation() + resetScale() + emitGizmoChanged(TransformGizmo.Type.ALL) + } + } + MenuItem { + text: "Gizmo Scale Look" + Slider { + id: gizmoScaleLookSlider + anchors.right: parent.right + anchors.rightMargin: 10 + height: parent.height + width: parent.width * 0.40 + + from: 0.06 + to: 0.30 + stepSize: 0.01 + value: 0.15 + } + } + } + + /***** GIZMO'S BASIC COMPONENTS *****/ + + Entity { + id: centerSphereEntity + components: [centerSphereMesh, centerSphereMaterial, frontLayerComponent] + + SphereMesh { + id: centerSphereMesh + radius: 0.04 + rings: 8 + slices: 8 + } + PhongMaterial { + id: centerSphereMaterial + property color base: "white" + ambient: base + shininess: 0.2 + } + } + + // AXIS GIZMO INSTANTIATOR => X, Y and Z + NodeInstantiator { + model: 3 + + Entity { + id: axisContainer + property int axis : { + switch(index) { + case 0: return TransformGizmo.Axis.X + case 1: return TransformGizmo.Axis.Y + case 2: return TransformGizmo.Axis.Z + } + } + property color baseColor: { + switch(axis) { + case TransformGizmo.Axis.X: return "#e63b55" // Red + case TransformGizmo.Axis.Y: return "#83c414" // Green + case TransformGizmo.Axis.Z: return "#3387e2" // Blue + } + } + property real lineRadius: 0.011 + + // SCALE ENTITY + Entity { + id: scaleEntity + + Entity { + id: axisCylinder + components: [cylinderMesh, cylinderTransform, scaleMaterial, frontLayerComponent] + + CylinderMesh { + id: cylinderMesh + length: 0.5 + radius: axisContainer.lineRadius + rings: 2 + slices: 16 + } + Transform { + id: cylinderTransform + matrix: { + const offset = cylinderMesh.length/2 + centerSphereMesh.radius + const m = Qt.matrix4x4() + switch(axis) { + case TransformGizmo.Axis.X: { + m.translate(Qt.vector3d(offset, 0, 0)) + m.rotate(90, Qt.vector3d(0,0,1)) + break + } + case TransformGizmo.Axis.Y: { + m.translate(Qt.vector3d(0, offset, 0)) + break + } + case TransformGizmo.Axis.Z: { + m.translate(Qt.vector3d(0, 0, offset)) + m.rotate(90, Qt.vector3d(1,0,0)) + break + } + } + return m + } + } + } + + Entity { + id: axisScaleBox + components: [cubeScaleMesh, cubeScaleTransform, scaleMaterial, scalePicker, frontLayerComponent] + + CuboidMesh { + id: cubeScaleMesh + property real edge: 0.06 + xExtent: edge + yExtent: edge + zExtent: edge + } + Transform { + id: cubeScaleTransform + matrix: { + const offset = cylinderMesh.length + centerSphereMesh.radius + const m = Qt.matrix4x4() + switch(axis) { + case TransformGizmo.Axis.X: { + m.translate(Qt.vector3d(offset, 0, 0)) + m.rotate(90, Qt.vector3d(0,0,1)) + break + } + case TransformGizmo.Axis.Y: { + m.translate(Qt.vector3d(0, offset, 0)) + break + } + case TransformGizmo.Axis.Z: { + m.translate(Qt.vector3d(0, 0, offset)) + m.rotate(90, Qt.vector3d(1,0,0)) + break + } + } + return m + } + } + } + + PhongMaterial { + id: scaleMaterial + ambient: baseColor + } + + TransformGizmoPicker { + id: scalePicker + mouseController: mouseHandler + gizmoMaterial: scaleMaterial + gizmoBaseColor: baseColor + gizmoAxis: axis + gizmoType: TransformGizmo.Type.SCALE + + onPickedChanged: { + // Save the current transformations of the OBJECT + this.modelMatrix = Transformations3DHelper.modelMatrixToMatrices(objectTransform.matrix) + // Compute a scale unit at picking time + this.scaleUnit = Transformations3DHelper.computeScaleUnitFromModelMatrix(convertAxisEnum(gizmoAxis), gizmoDisplayTransform.matrix, camera, root.windowSize) + // Prevent camera transformations + root.pickedChanged(picker.isPressed) + } + } + } + + // TRANSLATION ENTITY + Entity { + id: positionEntity + components: [coneMesh, coneTransform, positionMaterial, positionPicker, frontLayerComponent] + + ConeMesh { + id: coneMesh + bottomRadius : 0.035 + topRadius : 0.001 + hasBottomEndcap : true + hasTopEndcap : true + length : 0.13 + rings : 2 + slices : 8 + } + Transform { + id: coneTransform + matrix: { + const offset = cylinderMesh.length + centerSphereMesh.radius + 0.4 + const m = Qt.matrix4x4() + switch(axis) { + case TransformGizmo.Axis.X: { + m.translate(Qt.vector3d(offset, 0, 0)) + m.rotate(-90, Qt.vector3d(0,0,1)) + break + } + case TransformGizmo.Axis.Y: { + m.translate(Qt.vector3d(0, offset, 0)) + break + } + case TransformGizmo.Axis.Z: { + m.translate(Qt.vector3d(0, 0, offset)) + m.rotate(90, Qt.vector3d(1,0,0)) + break + } + } + return m + } + } + PhongMaterial { + id: positionMaterial + ambient: baseColor + } + + TransformGizmoPicker { + id: positionPicker + mouseController: mouseHandler + gizmoMaterial: positionMaterial + gizmoBaseColor: baseColor + gizmoAxis: axis + gizmoType: TransformGizmo.Type.TRANSLATION + + onPickedChanged: { + // Save the current transformations of the OBJECT + this.modelMatrix = Transformations3DHelper.modelMatrixToMatrices(objectTransform.matrix) + // Compute a scale unit at picking time + this.scaleUnit = Transformations3DHelper.computeScaleUnitFromModelMatrix(convertAxisEnum(gizmoAxis), gizmoDisplayTransform.matrix, camera, root.windowSize) + // Prevent camera transformations + root.pickedChanged(picker.isPressed) + } + } + } + + // ROTATION ENTITY + Entity { + id: rotationEntity + components: [torusMesh, torusTransform, rotationMaterial, rotationPicker, frontLayerComponent] + + TorusMesh { + id: torusMesh + radius: cylinderMesh.length + 0.25 + minorRadius: axisContainer.lineRadius + slices: 8 + rings: 32 + } + Transform { + id: torusTransform + matrix: { + const scaleDiff = 2*torusMesh.minorRadius + 0.01 // Just to make sure there is no face overlapping + const m = Qt.matrix4x4() + switch(axis) { + case TransformGizmo.Axis.X: m.rotate(90, Qt.vector3d(0,1,0)); break + case TransformGizmo.Axis.Y: m.rotate(90, Qt.vector3d(1,0,0)); m.scale(Qt.vector3d(1-scaleDiff, 1-scaleDiff, 1-scaleDiff)); break + case TransformGizmo.Axis.Z: m.scale(Qt.vector3d(1-2*scaleDiff, 1-2*scaleDiff, 1-2*scaleDiff)); break + } + return m + } + } + PhongMaterial { + id: rotationMaterial + ambient: baseColor + } + + TransformGizmoPicker { + id: rotationPicker + mouseController: mouseHandler + gizmoMaterial: rotationMaterial + gizmoBaseColor: baseColor + gizmoAxis: axis + gizmoType: TransformGizmo.Type.ROTATION + + onPickedChanged: { + // Save the current transformations of the OBJECT + this.modelMatrix = Transformations3DHelper.modelMatrixToMatrices(objectTransform.matrix) + // No need to compute a scale unit for rotation + // Prevent camera transformations + root.pickedChanged(picker.isPressed) + } + } + } + } + } +} diff --git a/meshroom/ui/qml/Viewer3D/TransformGizmoPicker.qml b/meshroom/ui/qml/Viewer3D/TransformGizmoPicker.qml new file mode 100644 index 00000000..7eb649be --- /dev/null +++ b/meshroom/ui/qml/Viewer3D/TransformGizmoPicker.qml @@ -0,0 +1,46 @@ +import Qt3D.Core 2.0 +import Qt3D.Render 2.9 +import Qt3D.Input 2.0 +import Qt3D.Extras 2.10 +import QtQuick 2.9 +import Qt3D.Logic 2.0 + +ObjectPicker { + id: root + property bool isPressed : false + property MouseHandler mouseController + property var gizmoMaterial + property color gizmoBaseColor + property int gizmoAxis + property int gizmoType + property point screenPoint + property var modelMatrix + property real scaleUnit + property int button + + signal pickedChanged(var picker) + + hoverEnabled: true + + onPressed: { + mouseController.enabled = true + mouseController.objectPicker = this + root.isPressed = true + screenPoint = pick.position + button = pick.button + pickedChanged(this) + } + onEntered: { + gizmoMaterial.ambient = "white" + } + onExited: { + if(!isPressed) gizmoMaterial.ambient = gizmoBaseColor + } + onReleased: { + gizmoMaterial.ambient = gizmoBaseColor + root.isPressed = false + mouseController.objectPicker = null + mouseController.enabled = false + pickedChanged(this) + } +} \ No newline at end of file diff --git a/meshroom/ui/qml/Viewer3D/Viewer3D.qml b/meshroom/ui/qml/Viewer3D/Viewer3D.qml index 8451fda7..5e0e390f 100644 --- a/meshroom/ui/qml/Viewer3D/Viewer3D.qml +++ b/meshroom/ui/qml/Viewer3D/Viewer3D.qml @@ -77,7 +77,7 @@ FocusScope { if (event.key == Qt.Key_F) { resetCameraPosition(); } - else if(Qt.Key_1 <= event.key && event.key <= Qt.Key_3) + else if(Qt.Key_1 <= event.key && event.key < Qt.Key_1 + Viewer3DSettings.renderModes.length) { Viewer3DSettings.renderMode = event.key - Qt.Key_1; } @@ -207,6 +207,17 @@ FocusScope { ] } } + LayerFilter { + filterMode: LayerFilter.DiscardAnyMatchingLayers + layers: Layer {id: drawOnFront} + } + LayerFilter { + filterMode: LayerFilter.AcceptAnyMatchingLayers + layers: [drawOnFront] + RenderStateSet { + renderStates: DepthTest { depthFunction: DepthTest.GreaterOrEqual } + } + } } } } @@ -223,6 +234,11 @@ FocusScope { pickingEnabled: cameraController.pickingActive || doubleClickTimer.running camera: cameraSelector.camera + // Used for TransformGizmo in BoundingBox + sceneCameraController: cameraController + frontLayerComponent: drawOnFront + window: root + components: [ Transform { id: transform @@ -274,8 +290,34 @@ FocusScope { } } + FloatingPane { + visible: Viewer3DSettings.renderMode == 3 + anchors.bottom: renderModesPanel.top + GridLayout { + columns: 2 + rowSpacing: 0 + + RadioButton { text: "SHL File"; autoExclusive: true; checked: true } + TextField { + text: Viewer3DSettings.shlFile + selectByMouse: true + Layout.minimumWidth: 300 + onEditingFinished: Viewer3DSettings.shlFile = text + } + + RadioButton { + Layout.columnSpan: 2 + autoExclusive: true + text: "Normals" + onCheckedChanged: Viewer3DSettings.displayNormals = checked + } + + } + } + // Rendering modes FloatingPane { + id: renderModesPanel anchors.bottom: parent.bottom padding: 4 Row { diff --git a/meshroom/ui/qml/Viewer3D/Viewer3DSettings.qml b/meshroom/ui/qml/Viewer3D/Viewer3DSettings.qml index c53cb5f7..aded135a 100644 --- a/meshroom/ui/qml/Viewer3D/Viewer3DSettings.qml +++ b/meshroom/ui/qml/Viewer3D/Viewer3DSettings.qml @@ -26,10 +26,16 @@ Item { {"name": "Solid", "icon": MaterialIcons.crop_din }, {"name": "Wireframe", "icon": MaterialIcons.details }, {"name": "Textured", "icon": MaterialIcons.texture }, + {"name": "Spherical Harmonics", "icon": MaterialIcons.brightness_7} ] // Current render mode property int renderMode: 2 + // Spherical Harmonics file + property string shlFile: "" + // Whether to display normals + property bool displayNormals: false + // Rasterized point size property real pointSize: 1.5 // Whether point size is fixed or view dependent diff --git a/meshroom/ui/qml/WorkspaceView.qml b/meshroom/ui/qml/WorkspaceView.qml index fe88f546..cdd5642a 100644 --- a/meshroom/ui/qml/WorkspaceView.qml +++ b/meshroom/ui/qml/WorkspaceView.qml @@ -44,9 +44,10 @@ Item { // Load reconstruction's current SfM file function viewSfM() { - if(!reconstruction.sfm) + var activeNode = _reconstruction.activeNodes.get('sfm').node; + if(!activeNode) return; - viewer3D.view(reconstruction.sfm.attribute('output')); + viewer3D.view(activeNode.attribute('output')); } SystemPalette { id: activePalette } @@ -65,7 +66,7 @@ Item { readOnly: root.readOnly cameraInits: root.cameraInits cameraInit: reconstruction.cameraInit - hdrCameraInit: reconstruction.hdrCameraInit + tempCameraInit: reconstruction.tempCameraInit currentIndex: reconstruction.cameraInitIndex onRemoveImageRequest: reconstruction.removeAttribute(attribute) onFilesDropped: reconstruction.handleFilesDrop(drop, augmentSfm ? null : cameraInit) @@ -82,6 +83,8 @@ Item { Layout.fillHeight: true Layout.fillWidth: true Layout.minimumWidth: 50 + loading: viewer2D.loadingModules.length > 0 + loadingText: loading ? "Loading " + viewer2D.loadingModules : "" headerBar: RowLayout { MaterialToolButton { @@ -189,7 +192,7 @@ Item { mediaLibrary: viewer3D.library camera: viewer3D.mainCamera uigraph: reconstruction - onNodeActivated: _reconstruction.setActiveNodeOfType(node) + onNodeActivated: _reconstruction.setActiveNode(node) } } } diff --git a/meshroom/ui/qml/main.qml b/meshroom/ui/qml/main.qml index 75b8cbeb..7f18215b 100755 --- a/meshroom/ui/qml/main.qml +++ b/meshroom/ui/qml/main.qml @@ -4,7 +4,10 @@ import QtQuick.Controls 1.4 as Controls1 // For SplitView import QtQuick.Layouts 1.1 import QtQuick.Window 2.3 import QtQml.Models 2.2 + import Qt.labs.platform 1.0 as Platform +import QtQuick.Dialogs 1.3 + import Qt.labs.settings 1.0 import GraphEditor 1.0 import MaterialIcons 2.2 @@ -20,9 +23,6 @@ ApplicationWindow { minimumHeight: 500 visible: true - /// Whether graph is currently locked and therefore read-only - readonly property bool graphLocked: _reconstruction.computing && GraphEditorSettings.lockOnCompute - title: { var t = (_reconstruction.graph && _reconstruction.graph.filepath) ? _reconstruction.graph.filepath : "Untitled" if(!_reconstruction.undoStack.clean) @@ -161,14 +161,90 @@ ApplicationWindow { unsavedComputeDialog.currentNode = node; unsavedComputeDialog.open(); } - else - _reconstruction.execute(node); + else { + try { + _reconstruction.execute(node) + } + catch (error) { + const data = ErrorHandler.analyseError(error) + if(data.context === "COMPUTATION") + computeSubmitErrorDialog.openError(data.type, data.msg, node) + } + } } function submit(node) { - _reconstruction.submit(node); + try { + _reconstruction.submit(node) + } + catch (error) { + const data = ErrorHandler.analyseError(error) + if(data.context === "SUBMITTING") + computeSubmitErrorDialog.openError(data.type, data.msg, node) + } } + MessageDialog { + id: computeSubmitErrorDialog + + property string errorType // Used to specify signals' behavior + property var currentNode: null + + function openError(type, msg, node) { + errorType = type + switch(type) { + case "Already Submitted": this.setupPendingStatusError(msg, node); break + case "Compatibility Issue": this.setupCompatibilityIssue(msg); break + default: this.onlyDisplayError(msg) + } + + this.open() + } + + function onlyDisplayError(msg) { + text = msg + + standardButtons = Dialog.Ok + } + + function setupPendingStatusError(msg, node) { + currentNode = node + text = msg + "\n\nDo you want to Clear Pending Status and Start Computing?" + + standardButtons = (Dialog.Ok | Dialog.Cancel) + } + + function setupCompatibilityIssue(msg) { + text = msg + "\n\nDo you want to open the Compatibility Manager?" + + standardButtons = (Dialog.Ok | Dialog.Cancel) + } + + canCopy: false + icon.text: MaterialIcons.warning + parent: Overlay.overlay + preset: "Warning" + title: "Computation/Submitting" + text: "" + + onAccepted: { + switch(errorType) { + case "Already Submitted": { + close() + _reconstruction.graph.clearSubmittedNodes() + _reconstruction.execute(currentNode) + break + } + case "Compatibility Issue": { + close() + compatibilityManager.open() + } + default: close() + } + } + + onRejected: close() + } MessageDialog { id: unsavedComputeDialog @@ -202,13 +278,27 @@ ApplicationWindow { } } - Platform.FileDialog { + FileDialog { id: openFileDialog title: "Open File" nameFilters: ["Meshroom Graphs (*.mg)"] onAccepted: { - _reconstruction.loadUrl(file.toString()) - MeshroomApp.addRecentProjectFile(file.toString()) + if(_reconstruction.loadUrl(fileUrl)) + { + MeshroomApp.addRecentProjectFile(fileUrl.toString()) + } + } + } + + FileDialog { + id: importFilesDialog + title: "Import Images" + selectExisting: true + selectMultiple: true + nameFilters: [] + onAccepted: { + console.warn("importFilesDialog fileUrls: " + importFilesDialog.fileUrls) + _reconstruction.importImagesUrls(importFilesDialog.fileUrls) } } @@ -289,7 +379,7 @@ ApplicationWindow { property string tooltip: 'Undo "' +_reconstruction.undoStack.undoText +'"' text: "Undo" shortcut: "Ctrl+Z" - enabled: _reconstruction.undoStack.canUndo && !graphLocked + enabled: _reconstruction.undoStack.canUndo && _reconstruction.undoStack.isUndoableIndex onTriggered: _reconstruction.undoStack.undo() } Action { @@ -298,7 +388,7 @@ ApplicationWindow { property string tooltip: 'Redo "' +_reconstruction.undoStack.redoText +'"' text: "Redo" shortcut: "Ctrl+Shift+Z" - enabled: _reconstruction.undoStack.canRedo && !graphLocked + enabled: _reconstruction.undoStack.canRedo && !_reconstruction.undoStack.lockedRedo onTriggered: _reconstruction.undoStack.redo() } @@ -323,15 +413,24 @@ ApplicationWindow { onTriggered: ensureSaved(function() { _reconstruction.new("photogrammetry") }) } Action { - text: "HDRI" - onTriggered: ensureSaved(function() { _reconstruction.new("hdri") }) + text: "Panorama HDR" + onTriggered: ensureSaved(function() { _reconstruction.new("panoramahdr") }) + } + Action { + text: "Panorama Fisheye HDR" + onTriggered: ensureSaved(function() { _reconstruction.new("panoramafisheyehdr") }) } } Action { id: openActionItem text: "Open" shortcut: "Ctrl+O" - onTriggered: ensureSaved(function() { openFileDialog.open() }) + onTriggered: ensureSaved(function() { + if(_reconstruction.graph && _reconstruction.graph.filepath) { + openFileDialog.folder = Filepath.stringToUrl(Filepath.dirname(_reconstruction.graph.filepath)) + } + openFileDialog.open() + }) } Menu { id: openRecentMenu @@ -353,8 +452,14 @@ ApplicationWindow { MenuItem { onTriggered: ensureSaved(function() { openRecentMenu.dismiss(); - _reconstruction.load(modelData); - MeshroomApp.addRecentProjectFile(modelData); + if(_reconstruction.loadUrl(modelData)) + { + MeshroomApp.addRecentProjectFile(modelData); + } + else + { + MeshroomApp.removeRecentProjectFile(modelData); + } }) text: fileTextMetrics.elidedText @@ -367,23 +472,42 @@ ApplicationWindow { } } } + Action { + id: importActionItem + text: "Import Images" + shortcut: "Ctrl+I" + onTriggered: importFilesDialog.open() + } Action { id: saveAction text: "Save" shortcut: "Ctrl+S" enabled: (_reconstruction.graph && !_reconstruction.graph.filepath) || !_reconstruction.undoStack.clean - onTriggered: _reconstruction.graph.filepath ? _reconstruction.save() : saveFileDialog.open() + onTriggered: { + if(_reconstruction.graph.filepath) { + _reconstruction.save() + } + else + { + saveFileDialog.open() + } + } } Action { id: saveAsAction text: "Save As..." shortcut: "Ctrl+Shift+S" - onTriggered: saveFileDialog.open() + onTriggered: { + if(_reconstruction.graph && _reconstruction.graph.filepath) { + saveFileDialog.folder = Filepath.stringToUrl(Filepath.dirname(_reconstruction.graph.filepath)) + } + saveFileDialog.open() + } } MenuSeparator { } Action { text: "Quit" - onTriggered: Qt.quit() + onTriggered: _window.close() } } Menu { @@ -509,7 +633,6 @@ ApplicationWindow { Row { // disable controls if graph is executed externally - enabled: !_reconstruction.computingExternally Layout.alignment: Qt.AlignHCenter Button { @@ -518,7 +641,6 @@ ApplicationWindow { palette.button: enabled ? buttonColor : disabledPalette.button palette.window: enabled ? buttonColor : disabledPalette.window palette.buttonText: enabled ? "white" : disabledPalette.buttonText - enabled: computeManager.canStartComputation onClicked: computeManager.compute(null) } Button { @@ -529,7 +651,6 @@ ApplicationWindow { Item { width: 20; height: 1 } Button { visible: _reconstruction.canSubmit - enabled: computeManager.canSubmit text: "Submit" onClicked: computeManager.submit(null) } @@ -549,13 +670,6 @@ ApplicationWindow { } } - Label { - text: "Graph is being computed externally" - font.italic: true - Layout.alignment: Qt.AlignHCenter - visible: _reconstruction.computingExternally - } - // "ProgressBar" reflecting status of all the chunks in the graph, in their process order NodeChunks { id: chunksListView @@ -618,11 +732,11 @@ ApplicationWindow { height: Math.round(parent.height * 0.3) visible: settings_UILayout.showGraphEditor - Panel { + TabPanel { id: graphEditorPanel Layout.fillWidth: true padding: 4 - title: "Graph Editor" + tabs: ["Graph Editor", "Task Manager"] headerBar: RowLayout { MaterialToolButton { @@ -655,54 +769,60 @@ ApplicationWindow { enabled: !_reconstruction.computingLocally onTriggered: _reconstruction.graph.clearSubmittedNodes() } - Menu { - title: "Advanced" - MenuItem { - text: "Lock on Compute" - ToolTip.text: "Lock Graph when computing. This should only be disabled for advanced usage." - ToolTip.visible: hovered - checkable: true - checked: GraphEditorSettings.lockOnCompute - onClicked: GraphEditorSettings.lockOnCompute = !GraphEditorSettings.lockOnCompute - } + MenuItem { + text: "Force Unlock Nodes" + onTriggered: _reconstruction.graph.forceUnlockNodes() } } } } - GraphEditor { id: graphEditor + visible: graphEditorPanel.currentTab === 0 + anchors.fill: parent uigraph: _reconstruction nodeTypesModel: _nodeTypes - readOnly: graphLocked onNodeDoubleClicked: { - _reconstruction.setActiveNodeOfType(node); + _reconstruction.setActiveNode(node); let viewable = false; for(var i=0; i < node.attributes.count; ++i) { var attr = node.attributes.at(i) - if(attr.isOutput && workspaceView.viewAttribute(attr)) + if(attr.isOutput && workspaceView.viewAttribute(attr, mouse)) break; } } onComputeRequest: computeManager.compute(node) onSubmitRequest: computeManager.submit(node) } + + TaskManager { + id: taskManager + + visible: graphEditorPanel.currentTab === 1 + + uigraph: _reconstruction + taskManager: _reconstruction.taskManager + + anchors.fill: parent + } + } NodeEditor { + id: nodeEditor width: Math.round(parent.width * 0.3) node: _reconstruction.selectedNode + property bool computing: _reconstruction.computing // Make NodeEditor readOnly when computing - readOnly: graphLocked - onAttributeDoubleClicked: { - workspaceView.viewAttribute(attribute); - } + readOnly: node ? node.locked : false + + onAttributeDoubleClicked: workspaceView.viewAttribute(attribute, mouse) onUpgradeRequest: { var n = _reconstruction.upgradeNode(node); _reconstruction.selectedNode = n; diff --git a/meshroom/ui/reconstruction.py b/meshroom/ui/reconstruction.py index cb78edc7..54e99198 100755 --- a/meshroom/ui/reconstruction.py +++ b/meshroom/ui/reconstruction.py @@ -3,18 +3,26 @@ import logging import math import os from threading import Thread +from collections import Iterable from PySide2.QtCore import QObject, Slot, Property, Signal, QUrl, QSizeF from PySide2.QtGui import QMatrix4x4, QMatrix3x3, QQuaternion, QVector3D, QVector2D import meshroom.core +import meshroom.common from meshroom import multiview from meshroom.common.qt import QObjectListModel from meshroom.core import Version -from meshroom.core.node import Node, Status, Position +from meshroom.core.node import Node, CompatibilityNode, Status, Position from meshroom.ui.graph import UIGraph from meshroom.ui.utils import makeProperty +# Python2 compatibility +try: + FileNotFoundError +except NameError: + FileNotFoundError = IOError + class Message(QObject): """ Simple structure wrapping a high-level message. """ @@ -103,7 +111,7 @@ class LiveSfmManager(QObject): to include those images to the reconstruction. """ # Get all new images in the watched folder - imagesInFolder = multiview.findFilesByTypeInFolder(self._folder) + imagesInFolder = multiview.findFilesByTypeInFolder(self._folder).images newImages = set(imagesInFolder).difference(self.allImages) for imagePath in newImages: # print('[LiveSfmManager] New image file : {}'.format(imagePath)) @@ -190,6 +198,7 @@ class ViewpointWrapper(QObject): self._reconstructed = False # PrepareDenseScene self._undistortedImagePath = '' + self._activeNode_PrepareDenseScene = self._reconstruction.activeNodes.get("PrepareDenseScene") # update internally cached variables self._updateInitialParams() @@ -199,16 +208,20 @@ class ViewpointWrapper(QObject): # trigger internal members updates when reconstruction members changes self._reconstruction.cameraInitChanged.connect(self._updateInitialParams) self._reconstruction.sfmReportChanged.connect(self._updateSfMParams) - self._reconstruction.prepareDenseSceneChanged.connect(self._updateDenseSceneParams) + self._activeNode_PrepareDenseScene.nodeChanged.connect(self._updateDenseSceneParams) def _updateInitialParams(self): """ Update internal members depending on CameraInit. """ if not self._reconstruction.cameraInit: - self.initialIntrinsics = None + self._initialIntrinsics = None self._metadata = {} else: self._initialIntrinsics = self._reconstruction.getIntrinsic(self._viewpoint) - self._metadata = json.loads(self._viewpoint.metadata.value) if self._viewpoint.metadata.value else None + try: + self._metadata = json.loads(self._viewpoint.metadata.value) if self._viewpoint.metadata.value else None + except Exception as e: + logging.warning("Failed to parse Viewpoint metadata: '{}', '{}'".format(str(e), str(self._viewpoint.metadata.value))) + self._metadata = {} if not self._metadata: self._metadata = {} self.initialParamsChanged.emit() @@ -229,11 +242,11 @@ class ViewpointWrapper(QObject): def _updateDenseSceneParams(self): """ Update internal members depending on PrepareDenseScene. """ # undistorted image path - if not self._reconstruction.prepareDenseScene: + if not self._activeNode_PrepareDenseScene.node: self._undistortedImagePath = '' else: - filename = "{}.{}".format(self._viewpoint.viewId.value, self._reconstruction.prepareDenseScene.outputFileType.value) - self._undistortedImagePath = os.path.join(self._reconstruction.prepareDenseScene.output.value, filename) + filename = "{}.{}".format(self._viewpoint.viewId.value, self._activeNode_PrepareDenseScene.node.outputFileType.value) + self._undistortedImagePath = os.path.join(self._activeNode_PrepareDenseScene.node.output.value, filename) self.denseSceneParamsChanged.emit() @Property(type=QObject, constant=True) @@ -356,30 +369,76 @@ class ViewpointWrapper(QObject): return QUrl.fromLocalFile(self._undistortedImagePath) +def parseSfMJsonFile(sfmJsonFile): + """ + Parse the SfM Json file and return views, poses and intrinsics as three dicts with viewId, poseId and intrinsicId as keys. + """ + if not os.path.exists(sfmJsonFile): + return {}, {}, {} + + with open(sfmJsonFile) as jsonFile: + report = json.load(jsonFile) + + views = dict() + poses = dict() + intrinsics = dict() + + for view in report['views']: + views[view['viewId']] = view + + for pose in report['poses']: + poses[pose['poseId']] = pose['pose'] + + for intrinsic in report['intrinsics']: + intrinsics[intrinsic['intrinsicId']] = intrinsic + + return views, poses, intrinsics + + +class ActiveNode(QObject): + """ + Hold one active node for a given NodeType. + """ + def __init__(self, nodeType, parent=None): + super(ActiveNode, self).__init__(parent) + self.nodeType = nodeType + self._node = None + + nodeChanged = Signal() + node = makeProperty(QObject, "_node", nodeChanged, resetOnDestroy=True) + + class Reconstruction(UIGraph): """ Specialization of a UIGraph designed to manage a 3D reconstruction. """ + activeNodeCategories = { + "sfm": ["StructureFromMotion", "GlobalSfM", "PanoramaEstimation", "SfMTransfer", "SfMTransform", + "SfMAlignment"], + "undistort": ["PrepareDenseScene", "PanoramaWarping"], + "allDepthMap": ["DepthMap", "DepthMapFilter"], + } - def __init__(self, defaultPipeline='', parent=None): - super(Reconstruction, self).__init__(parent) + def __init__(self, undoStack, taskManager, defaultPipeline='', parent=None): + super(Reconstruction, self).__init__(undoStack, taskManager, parent) # initialize member variables for key steps of the 3D reconstruction pipeline + self._activeNodes = meshroom.common.DictModel(keyAttrName="nodeType") + self.initActiveNodes() + # - CameraInit self._cameraInit = None # current CameraInit node self._cameraInits = QObjectListModel(parent=self) # all CameraInit nodes self._buildingIntrinsics = False self.intrinsicsBuilt.connect(self.onIntrinsicsAvailable) - self._hdrCameraInit = None + self.cameraInitChanged.connect(self.onCameraInitChanged) + + self._tempCameraInit = None self.importImagesFailed.connect(self.onImportImagesFailed) - # - Feature Extraction - self._featureExtraction = None - self.cameraInitChanged.connect(self.updateFeatureExtraction) - # - SfM self._sfm = None self._views = None @@ -389,28 +448,34 @@ class Reconstruction(UIGraph): self._selectedViewpoint = None self._liveSfmManager = LiveSfmManager(self) - # - Prepare Dense Scene (undistorted images) - self._prepareDenseScene = None - - # - Depth Map - self._depthMap = None - self.cameraInitChanged.connect(self.updateDepthMapNode) - - # - Texturing - self._texturing = None - - # - LDR2HDR - self._ldr2hdr = None - self.cameraInitChanged.connect(self.updateLdr2hdrNode) - # react to internal graph changes to update those variables self.graphChanged.connect(self.onGraphChanged) self.setDefaultPipeline(defaultPipeline) + def clear(self): + self.clearActiveNodes() + super(Reconstruction, self).clear() + def setDefaultPipeline(self, defaultPipeline): self._defaultPipeline = defaultPipeline + def initActiveNodes(self): + # Create all possible entries + for category, _ in self.activeNodeCategories.items(): + self._activeNodes.add(ActiveNode(category, self)) + for nodeType, _ in meshroom.core.nodesDesc.items(): + self._activeNodes.add(ActiveNode(nodeType, self)) + + def clearActiveNodes(self): + for key in self._activeNodes.keys(): + self._activeNodes.get(key).node = None + + def onCameraInitChanged(self): + # Update active nodes when CameraInit changes + nodes = self._graph.dfsOnDiscover(startNodes=[self._cameraInit], reverse=True)[0] + self.setActiveNodes(nodes) + @Slot() @Slot(str) def new(self, pipeline=None): @@ -419,17 +484,20 @@ class Reconstruction(UIGraph): if p.lower() == "photogrammetry": # default photogrammetry pipeline self.setGraph(multiview.photogrammetry()) - elif p.lower() == "hdri": - # default hdri pipeline - self.setGraph(multiview.hdri()) + elif p.lower() == "panoramahdr": + # default panorama hdr pipeline + self.setGraph(multiview.panoramaHdr()) + elif p.lower() == "panoramafisheyehdr": + # default panorama fisheye hdr pipeline + self.setGraph(multiview.panoramaFisheyeHdr()) else: # use the user-provided default photogrammetry project file self.load(p, setupProjectFile=False) - @Slot(str) + @Slot(str, result=bool) def load(self, filepath, setupProjectFile=True): try: - super(Reconstruction, self).load(filepath, setupProjectFile) + status = super(Reconstruction, self).loadGraph(filepath, setupProjectFile) # warn about pre-release projects being automatically upgraded if Version(self._graph.fileReleaseVersion).major == "0": self.warning.emit(Message( @@ -438,29 +506,48 @@ class Reconstruction(UIGraph): "Data might have been lost in the process.", "Open it with the corresponding version of Meshroom to recover your data." )) + return status + except FileNotFoundError as e: + self.error.emit( + Message( + "No Such File", + "Error While Loading '{}': No Such File.".format(os.path.basename(filepath)), + "" + ) + ) + logging.error("Error while loading '{}': No Such File.".format(os.path.basename(filepath))) + return False except Exception as e: import traceback trace = traceback.format_exc() self.error.emit( Message( - "Error while loading {}".format(os.path.basename(filepath)), - "An unexpected error has occurred", + "Error While Loading Project File", + "An unexpected error has occurred while loading file: '{}'".format(os.path.basename(filepath)), trace ) ) logging.error(trace) + return False + + @Slot(QUrl, result=bool) + def loadUrl(self, url): + if isinstance(url, (QUrl)): + # depending how the QUrl has been initialized, + # toLocalFile() may return the local path or an empty string + localFile = url.toLocalFile() + if not localFile: + localFile = url.toString() + else: + localFile = url + return self.load(localFile) def onGraphChanged(self): """ React to the change of the internal graph. """ self._liveSfmManager.reset() self.selectedViewId = "-1" - self.featureExtraction = None self.sfm = None - self.prepareDenseScene = None - self.depthMap = None - self.texturing = None - self.ldr2hdr = None - self.hdrCameraInit = None + self.tempCameraInit = None self.updateCameraInits() if not self._graph: return @@ -476,10 +563,11 @@ class Reconstruction(UIGraph): thread.start() return thread + @Slot(QObject) def getViewpoints(self): """ Return the Viewpoints model. """ # TODO: handle multiple Viewpoints models - return self._cameraInit.viewpoints.value if self._cameraInit else None + return self._cameraInit.viewpoints.value if self._cameraInit else QObjectListModel(parent=self) def updateCameraInits(self): cameraInits = self._graph.nodesByType("CameraInit", sortedByIndex=True) @@ -501,43 +589,60 @@ class Reconstruction(UIGraph): camInit = self._cameraInits[idx] if self._cameraInits else None 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 updateDepthMapNode(self): - """ Set the current FeatureExtraction node based on the current CameraInit node. """ - self.depthMap = self.lastNodeOfType('DepthMapFilter', self.cameraInit) if self.cameraInit else None - - def updateLdr2hdrNode(self): - """ Set the current LDR2HDR node based on the current CameraInit node. """ - self.ldr2hdr = self.lastNodeOfType('LDRToHDR', self.cameraInit) if self.cameraInit else None - @Slot() - def setupLDRToHDRCameraInit(self): - if not self.ldr2hdr: - self.hdrCameraInit = Node("CameraInit") + def clearTempCameraInit(self): + self.tempCameraInit = None + + @Slot(QObject, str) + def setupTempCameraInit(self, node, attrName): + if not node or not attrName: + self.tempCameraInit = None return - sfmFile = self.ldr2hdr.attribute("outSfMDataFilename").value + sfmFile = node.attribute(attrName).value if not sfmFile or not os.path.isfile(sfmFile): - self.hdrCameraInit = Node("CameraInit") + self.tempCameraInit = None return nodeDesc = meshroom.core.nodesDesc["CameraInit"]() views, intrinsics = nodeDesc.readSfMData(sfmFile) tmpCameraInit = Node("CameraInit", viewpoints=views, intrinsics=intrinsics) - self.hdrCameraInit = tmpCameraInit + self.tempCameraInit = tmpCameraInit + + @Slot(QObject, result=QVector3D) + def getAutoFisheyeCircle(self, panoramaInit): + if not panoramaInit or not panoramaInit.isComputed: + return QVector3D(0.0, 0.0, 0.0) + if not panoramaInit.attribute("estimateFisheyeCircle").value: + return QVector3D(0.0, 0.0, 0.0) + + sfmFile = panoramaInit.attribute('outSfMData').value + if not os.path.exists(sfmFile): + return QVector3D(0.0, 0.0, 0.0) + import io # use io.open for Python2/3 compatibility (allow to specify encoding + errors handling) + # skip decoding errors to avoid potential exceptions due to non utf-8 characters in images metadata + with io.open(sfmFile, 'r', encoding='utf-8', errors='ignore') as f: + data = json.load(f) + + intrinsics = data.get('intrinsics', []) + if len(intrinsics) == 0: + return QVector3D(0.0, 0.0, 0.0) + intrinsic = intrinsics[0] + + res = QVector3D(float(intrinsic.get("fisheyeCircleCenterX", 0.0)) - float(intrinsic.get("width", 0.0)) * 0.5, + float(intrinsic.get("fisheyeCircleCenterY", 0.0)) - float(intrinsic.get("height", 0.0)) * 0.5, + float(intrinsic.get("fisheyeCircleRadius", 0.0))) + return res def lastSfmNode(self): """ Retrieve the last SfM node from the initial CameraInit node. """ - return self.lastNodeOfType("StructureFromMotion", self._cameraInit, Status.SUCCESS) + return self.lastNodeOfType(self.activeNodeCategories['sfm'], self._cameraInit, Status.SUCCESS) - def lastNodeOfType(self, nodeType, startNode, preferredStatus=None): + def lastNodeOfType(self, nodeTypes, startNode, preferredStatus=None): """ Returns the last node of the given type starting from 'startNode'. If 'preferredStatus' is specified, the last node with this status will be considered in priority. Args: - nodeType (str): the node type + nodeTypes (str list): the node types startNode (Node): the node to start from preferredStatus (Status): (optional) the node status to prioritize @@ -546,7 +651,7 @@ class Reconstruction(UIGraph): """ if not startNode: return None - nodes = self._graph.nodesFromNode(startNode, nodeType)[0] + nodes = self._graph.dfsOnDiscover(startNodes=[startNode], filterTypes=nodeTypes, reverse=True)[0] if not nodes: return None node = nodes[-1] @@ -634,22 +739,22 @@ class Reconstruction(UIGraph): "", )) else: - panoramaExternalInfoNodes = self.graph.nodesByType('PanoramaExternalInfo') + panoramaInitNodes = self.graph.nodesByType('PanoramaInit') for panoramaInfoFile in filesByType.panoramaInfo: - for panoramaInfoNode in panoramaExternalInfoNodes: - panoramaInfoNode.attribute('config').value = panoramaInfoFile - if panoramaExternalInfoNodes: + for panoramaInitNode in panoramaInitNodes: + panoramaInitNode.attribute('config').value = panoramaInfoFile + if panoramaInitNodes: self.info.emit( Message( "Panorama XML", - "XML file declared on PanoramaExternalInfo node", - "XML file '{}' set on node '{}'".format(','.join(filesByType.panoramaInfo), ','.join([n.getLabel() for n in panoramaExternalInfoNodes])), + "XML file declared on PanoramaInit node", + "XML file '{}' set on node '{}'".format(','.join(filesByType.panoramaInfo), ','.join([n.getLabel() for n in panoramaInitNodes])), )) else: self.error.emit( Message( - "No PanoramaExternalInfo Node", - "No PanoramaExternalInfo Node to set the Panorama file:\n'{}'.".format(','.join(filesByType.panoramaInfo)), + "No PanoramaInit Node", + "No PanoramaInit Node to set the Panorama file:\n'{}'.".format(','.join(filesByType.panoramaInfo)), "", )) @@ -693,10 +798,24 @@ class Reconstruction(UIGraph): recursive: List files in folders recursively. """ + logging.debug("importImagesFromFolder: " + str(path)) filesByType = multiview.findFilesByTypeInFolder(path, recursive) if filesByType.images: self.buildIntrinsics(self.cameraInit, filesByType.images) + @Slot("QVariant") + def importImagesUrls(self, imagePaths, recursive=False): + paths = [] + for imagePath in imagePaths: + if isinstance(imagePath, (QUrl)): + p = imagePath.toLocalFile() + if not p: + p = imagePath.toString() + else: + p = imagePath + paths.append(p) + self.importImagesFromFolder(paths) + def importImagesAsync(self, images, cameraInit): """ Add the given list of images to the Reconstruction. """ # Start the process of updating views and intrinsics @@ -810,10 +929,11 @@ class Reconstruction(UIGraph): self._buildingIntrinsics = value self.buildingIntrinsicsChanged.emit() + activeNodes = makeProperty(QObject, "_activeNodes", resetOnDestroy=True) cameraInitChanged = Signal() cameraInit = makeProperty(QObject, "_cameraInit", cameraInitChanged, resetOnDestroy=True) - hdrCameraInitChanged = Signal() - hdrCameraInit = makeProperty(QObject, "_hdrCameraInit", hdrCameraInitChanged, resetOnDestroy=True) + tempCameraInitChanged = Signal() + tempCameraInit = makeProperty(QObject, "_tempCameraInit", tempCameraInitChanged, resetOnDestroy=True) cameraInitIndex = Property(int, getCameraInitIndex, setCameraInitIndex, notify=cameraInitChanged) viewpoints = Property(QObject, getViewpoints, notify=cameraInitChanged) cameraInits = Property(QObject, lambda self: self._cameraInits, constant=True) @@ -824,29 +944,46 @@ class Reconstruction(UIGraph): liveSfmManager = Property(QObject, lambda self: self._liveSfmManager, constant=True) @Slot(QObject) - def setActiveNodeOfType(self, node): + def setActiveNode(self, node): """ Set node as the active node of its type. """ - if node.nodeType == "StructureFromMotion": - self.sfm = node - elif node.nodeType == "FeatureExtraction": - self.featureExtraction = node - elif node.nodeType == "CameraInit": - self.cameraInit = node - elif node.nodeType == "PrepareDenseScene": - self.prepareDenseScene = node - elif node.nodeType in ("DepthMap", "DepthMapFilter"): - self.depthMap = node + for category, nodeTypes in self.activeNodeCategories.items(): + if node.nodeType in nodeTypes: + self.activeNodes.get(category).node = node + if category == 'sfm': + self.setSfm(node) + self.activeNodes.get(node.nodeType).node = node + + @Slot(QObject) + def setActiveNodes(self, nodes): + """ Set node as the active node of its type. """ + # Setup the active node per category only once, on the last one + nodesByCategory = {} + for node in nodes: + if node is None: + continue + for category, nodeTypes in self.activeNodeCategories.items(): + if node.nodeType in nodeTypes: + nodesByCategory[category] = node + for category, node in nodesByCategory.items(): + self.activeNodes.get(category).node = node + if category == 'sfm': + self.setSfm(node) + for node in nodes: + if node is None: + continue + if not isinstance(node, CompatibilityNode): + self.activeNodes.get(node.nodeType).node = node def updateSfMResults(self): """ Update internal views, poses and solved intrinsics based on the current SfM node. """ - if not self._sfm: + if not self._sfm or ('outputViewsAndPoses' not in self._sfm.getAttributes().keys()): self._views = dict() self._poses = dict() self._solvedIntrinsics = dict() else: - self._views, self._poses, self._solvedIntrinsics = self._sfm.nodeDesc.getResults(self._sfm) + self._views, self._poses, self._solvedIntrinsics = parseSfMJsonFile(self._sfm.outputViewsAndPoses.value) self.sfmReportChanged.emit() def getSfm(self): @@ -884,9 +1021,6 @@ class Reconstruction(UIGraph): self._sfm.destroyed.disconnect(self._unsetSfm) self._setSfm(node) - self.texturing = self.lastNodeOfType("Texturing", self._sfm, Status.SUCCESS) - self.prepareDenseScene = self.lastNodeOfType("PrepareDenseScene", self._sfm, Status.SUCCESS) - @Slot(QObject, result=bool) def isInViews(self, viewpoint): if not viewpoint: @@ -946,7 +1080,11 @@ class Reconstruction(UIGraph): def reconstructedCamerasCount(self): """ Get the number of reconstructed cameras in the current context. """ - return len([v for v in self.getViewpoints() if self.isReconstructed(v)]) + viewpoints = self.getViewpoints() + # Check that the object is iterable to avoid error with undefined Qt Property + if not isinstance(viewpoints, Iterable): + return 0 + return len([v for v in viewpoints if self.isReconstructed(v)]) @Slot(QObject, result="QVariant") def getSolvedIntrinsics(self, viewpoint): @@ -989,26 +1127,11 @@ class Reconstruction(UIGraph): sfmChanged = Signal() sfm = Property(QObject, getSfm, setSfm, notify=sfmChanged) - featureExtractionChanged = Signal() - featureExtraction = makeProperty(QObject, "_featureExtraction", featureExtractionChanged, resetOnDestroy=True) - sfmReportChanged = Signal() # convenient property for QML binding re-evaluation when sfm report changes sfmReport = Property(bool, lambda self: len(self._poses) > 0, notify=sfmReportChanged) sfmAugmented = Signal(Node, Node) - prepareDenseSceneChanged = Signal() - prepareDenseScene = makeProperty(QObject, "_prepareDenseScene", notify=prepareDenseSceneChanged, resetOnDestroy=True) - - depthMapChanged = Signal() - depthMap = makeProperty(QObject, "_depthMap", depthMapChanged, resetOnDestroy=True) - - texturingChanged = Signal() - texturing = makeProperty(QObject, "_texturing", notify=texturingChanged) - - ldr2hdrChanged = Signal() - ldr2hdr = makeProperty(QObject, "_ldr2hdr", notify=ldr2hdrChanged, resetOnDestroy=True) - nbCameras = Property(int, reconstructedCamerasCount, notify=sfmReportChanged) # Signals to propagate high-level messages diff --git a/requirements.txt b/requirements.txt index 9caca220..d43e1923 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ # runtime psutil>=5.6.3 enum34;python_version<"3.4" -PySide2==5.13.0 +PySide2==5.14.1 markdown==2.6.11 requests==2.22.0 diff --git a/setup.py b/setup.py index c14ed6b2..1071456d 100644 --- a/setup.py +++ b/setup.py @@ -6,6 +6,8 @@ from cx_Freeze import setup, Executable import meshroom +currentDir = os.path.dirname(os.path.abspath(__file__)) + class PlatformExecutable(Executable): """ Extend cx_Freeze.Executable to handle platform variations. @@ -32,7 +34,6 @@ class PlatformExecutable(Executable): # get icon for platform if defined icon = icons.get(platform.system(), None) if icons else None if platform.system() in (self.Linux, self.Darwin): - currentDir = os.path.dirname(os.path.abspath(__file__)) initScript = os.path.join(currentDir, "setupInitScriptUnix.py") super(PlatformExecutable, self).__init__(script, initScript, base, targetName, icon, shortcutName, shortcutDir, copyright, trademarks) @@ -46,6 +47,11 @@ build_exe_options = { ], "include_files": ["CHANGES.md", "COPYING.md", "LICENSE-MPL2.md", "README.md"] } +if os.path.isdir(os.path.join(currentDir, "tractor")): + build_exe_options["packages"].append("tractor") +if os.path.isdir(os.path.join(currentDir, "simpleFarm")): + build_exe_options["packages"].append("simpleFarm") + if platform.system() == PlatformExecutable.Linux: # include required system libs diff --git a/start.sh b/start.sh new file mode 100644 index 00000000..4e635b2a --- /dev/null +++ b/start.sh @@ -0,0 +1,3 @@ +#!/bin/sh +export PYTHONPATH="$(dirname "$(readlink -f "${BASH_SOURCE[0]}" )" )" +python meshroom/ui diff --git a/tests/test_graph.py b/tests/test_graph.py index c7fa6e4d..ed924475 100644 --- a/tests/test_graph.py +++ b/tests/test_graph.py @@ -158,8 +158,8 @@ def test_transitive_reduction(): assert node.depth == maxDepth -def test_graph_reverse_dfs(): - graph = Graph('Test reverse DFS') +def test_graph_reverse_dfsOnDiscover(): + graph = Graph('Test dfsOnDiscover(reverse=True)') # ------------\ # / ~ C - E - F @@ -174,17 +174,60 @@ def test_graph_reverse_dfs(): F = graph.addNewNode('AppendText', input=A.output, inputText=E.output) # Get all nodes from A (use set, order not guaranteed) - nodes = graph.nodesFromNode(A)[0] + nodes = graph.dfsOnDiscover(startNodes=[A], reverse=True)[0] assert set(nodes) == {A, B, D, C, E, F} # Get all nodes from B - nodes = graph.nodesFromNode(B)[0] + nodes = graph.dfsOnDiscover(startNodes=[B], reverse=True)[0] assert set(nodes) == {B, D, C, E, F} # Get all nodes of type AppendText from B - nodes = graph.nodesFromNode(B, filterType='AppendText')[0] + nodes = graph.dfsOnDiscover(startNodes=[B], filterTypes=['AppendText'], reverse=True)[0] assert set(nodes) == {B, D, C, F} # Get all nodes from C (order guaranteed) - nodes = graph.nodesFromNode(C)[0] + nodes = graph.dfsOnDiscover(startNodes=[C], reverse=True)[0] assert nodes == [C, E, F] + # Get all nodes + nodes = graph.dfsOnDiscover(reverse=True)[0] + assert set(nodes) == {A, B, C, D, E, F} + + +def test_graph_dfsOnDiscover(): + graph = Graph('Test dfsOnDiscover(reverse=False)') + + # ------------\ + # / ~ C - E - F + # A - B + # ~ D + # G + + G = graph.addNewNode('Ls', input='/tmp') + A = graph.addNewNode('Ls', input='/tmp') + B = graph.addNewNode('AppendText', inputText=A.output) + C = graph.addNewNode('AppendText', inputText=B.output) + D = graph.addNewNode('AppendText', input=G.output, inputText=B.output) + E = graph.addNewNode('Ls', input=C.output) + F = graph.addNewNode('AppendText', input=A.output, inputText=E.output) + + # Get all nodes from A (use set, order not guaranteed) + nodes = graph.dfsOnDiscover(startNodes=[A], reverse=False)[0] + assert set(nodes) == {A} + # Get all nodes from D + nodes = graph.dfsOnDiscover(startNodes=[D], reverse=False)[0] + assert set(nodes) == {A, B, D, G} + # Get all nodes from E + nodes = graph.dfsOnDiscover(startNodes=[E], reverse=False)[0] + assert set(nodes) == {A, B, C, E} + # Get all nodes from F + nodes = graph.dfsOnDiscover(startNodes=[F], reverse=False)[0] + assert set(nodes) == {A, B, C, E, F} + # Get all nodes of type AppendText from C + nodes = graph.dfsOnDiscover(startNodes=[C], filterTypes=['AppendText'], reverse=False)[0] + assert set(nodes) == {B, C} + # Get all nodes from D (order guaranteed) + nodes = graph.dfsOnDiscover(startNodes=[D], longestPathFirst=True, reverse=False)[0] + assert nodes == [D, B, A, G] + # Get all nodes + nodes = graph.dfsOnDiscover(reverse=False)[0] + assert set(nodes) == {A, B, C, D, E, F, G} def test_graph_nodes_sorting():