From fe280e2c8eebc4f13bbf936e947d1a8d2903b901 Mon Sep 17 00:00:00 2001 From: Mei Date: Fri, 26 Jan 2024 17:07:10 +0800 Subject: [PATCH] [Version] Bump version to v0.6.0 (#15) Co-authored-by: YANG Zhitao Co-authored-by: wangfanzhou Co-authored-by: yangzhitao Co-authored-by: yangzhitao --- .github/CODE_OF_CONDUCT.md | 3 - .gitignore | 1 + README.md | 34 +- docs/en/_templates/layout.html | 113 +++++ docs/en/apis/sequence.rst | 14 +- docs/en/apis/utils.rst | 10 + docs/en/conf.py | 5 +- docs/en/faq.rst | 91 +++- pyproject.toml | 11 +- samples/README.md | 1 + samples/anim/utils.py | 58 --- samples/blender/01_add_shapes.py | 6 +- samples/blender/02_add_cameras.py | 6 +- samples/blender/03_basic_render.py | 6 +- samples/blender/04_staticmesh_render.py | 7 +- samples/blender/05_skeletalmesh_render.py | 6 +- samples/blender/06_custom_usage.py | 6 +- samples/blender/07_amass.py | 105 +++-- samples/run_all.py | 1 + samples/setup.py | 4 +- samples/unreal/01_add_shapes.py | 8 +- samples/unreal/02_add_cameras.py | 8 +- samples/unreal/03_basic_render.py | 8 +- samples/unreal/04_staticmesh_render.py | 9 +- samples/unreal/05_skeletalmesh_render.py | 8 +- samples/unreal/06_custom_usage.py | 8 +- samples/unreal/07_amass.py | 105 +++++ samples/utils.py | 15 +- src/XRFeitoriaBpy/__init__.py | 3 +- src/XRFeitoriaBpy/constants.py | 3 +- src/XRFeitoriaBpy/core/factory.py | 120 +++++- src/XRFeitoriaBpy/core/renderer.py | 6 +- src/XRFeitoriaBpy/properties.py | 6 +- src/XRFeitoriaBpy/utils_logger.py | 10 +- src/XRFeitoriaUnreal/Config/FilterPlugin.ini | 8 + .../Blueprints/SequenceAssetData.uasset | Bin 9740 -> 0 bytes .../Content/Python/constants.py | 2 + .../Content/Python/custom_movie_pipeline.py | 12 +- .../Content/Python/sequence.py | 271 ++++++++++-- src/XRFeitoriaUnreal/Content/Python/utils.py | 85 ++-- .../Content/Python/utils_actor.py | 2 +- .../XRFeitoriaUnreal/Private/Annotator.cpp | 3 +- .../CustomMoviePipelineDeferredPass.cpp | 3 +- .../Private/CustomMoviePipelineOutput.cpp | 8 +- .../Private/MoviePipelineMeshOperator.cpp | 154 ++++--- .../Private/XF_BlueprintFunctionLibrary.cpp | 3 +- .../Private/XRFeitoriaUnreal.cpp | 6 +- .../XRFeitoriaUnreal/Public/Annotator.h | 2 +- .../Public/CustomMoviePipelineDeferredPass.h | 2 +- .../Public/CustomMoviePipelineOutput.h | 11 +- .../Public/MoviePipelineMeshOperator.h | 9 +- .../Public/XF_BlueprintFunctionLibrary.h | 2 +- .../Public/XRFeitoriaUnreal.h | 2 +- .../XRFeitoriaUnreal.Build.cs | 3 +- src/XRFeitoriaUnreal/XRFeitoriaUnreal.uplugin | 19 +- tests/blender/actor.py | 7 +- tests/blender/camera.py | 7 +- tests/blender/init.py | 7 +- tests/blender/level.py | 9 +- tests/blender/main.py | 11 +- tests/blender/sequence.py | 7 +- tests/unreal/actor.py | 5 +- tests/unreal/camera.py | 5 +- tests/unreal/init.py | 7 +- tests/unreal/main.py | 14 +- tests/unreal/sequence.py | 7 +- xrfeitoria/__init__.py | 16 +- xrfeitoria/actor/actor_base.py | 8 +- xrfeitoria/actor/actor_blender.py | 34 +- xrfeitoria/camera/camera_base.py | 5 +- xrfeitoria/camera/camera_blender.py | 2 +- xrfeitoria/camera/camera_parameter.py | 5 +- xrfeitoria/cmd/blender/__init__.py | 0 xrfeitoria/cmd/blender/install_plugin.py | 80 ++++ xrfeitoria/cmd/{ => blender}/render.py | 4 +- xrfeitoria/cmd/blender/vis_smplx.py | 193 +++++++++ xrfeitoria/data_structure/constants.py | 13 +- xrfeitoria/data_structure/models.py | 9 +- xrfeitoria/factory.py | 61 ++- xrfeitoria/material/__init__.py | 0 xrfeitoria/material/material_base.py | 113 +++++ xrfeitoria/material/material_blender.py | 131 ++++++ xrfeitoria/material/material_unreal.py | 0 xrfeitoria/object/object_base.py | 3 + xrfeitoria/object/object_utils.py | 2 +- xrfeitoria/renderer/renderer_base.py | 2 +- xrfeitoria/renderer/renderer_blender.py | 11 +- xrfeitoria/renderer/renderer_unreal.py | 60 ++- xrfeitoria/rpc/client.py | 6 +- xrfeitoria/rpc/factory.py | 37 +- xrfeitoria/sequence/sequence_base.py | 47 +- xrfeitoria/sequence/sequence_base.pyi | 27 +- xrfeitoria/sequence/sequence_blender.py | 58 ++- xrfeitoria/sequence/sequence_blender.pyi | 5 - xrfeitoria/sequence/sequence_unreal.py | 128 +++++- xrfeitoria/sequence/sequence_unreal.pyi | 66 +-- xrfeitoria/sequence/sequence_wrapper.py | 150 ++++--- xrfeitoria/sequence/sequence_wrapper.pyi | 56 +-- xrfeitoria/utils/__init__.py | 3 +- xrfeitoria/utils/anim/__init__.py | 1 + .../utils}/anim/constants.py | 0 {samples => xrfeitoria/utils}/anim/motion.py | 402 +++++++++++++++--- .../utils}/anim/transform3d.py | 52 ++- xrfeitoria/utils/anim/utils.py | 108 +++++ .../utils/functions/blender_functions.py | 70 ++- .../utils/functions/unreal_functions.py | 43 +- xrfeitoria/utils/plugin_infos.json | 5 + xrfeitoria/utils/projector.py | 2 +- xrfeitoria/utils/publish_plugins.py | 201 ++++++--- xrfeitoria/utils/runner.py | 321 +++++++++++--- xrfeitoria/utils/tools.py | 100 ++++- xrfeitoria/utils/viewer.py | 13 +- 112 files changed, 3282 insertions(+), 869 deletions(-) create mode 100644 docs/en/_templates/layout.html delete mode 100644 samples/anim/utils.py create mode 100644 samples/unreal/07_amass.py create mode 100644 src/XRFeitoriaUnreal/Config/FilterPlugin.ini delete mode 100644 src/XRFeitoriaUnreal/Content/Blueprints/SequenceAssetData.uasset create mode 100644 xrfeitoria/cmd/blender/__init__.py create mode 100644 xrfeitoria/cmd/blender/install_plugin.py rename xrfeitoria/cmd/{ => blender}/render.py (98%) create mode 100644 xrfeitoria/cmd/blender/vis_smplx.py create mode 100644 xrfeitoria/material/__init__.py create mode 100644 xrfeitoria/material/material_base.py create mode 100644 xrfeitoria/material/material_blender.py create mode 100644 xrfeitoria/material/material_unreal.py create mode 100644 xrfeitoria/utils/anim/__init__.py rename {samples => xrfeitoria/utils}/anim/constants.py (100%) rename {samples => xrfeitoria/utils}/anim/motion.py (58%) rename {samples => xrfeitoria/utils}/anim/transform3d.py (84%) create mode 100644 xrfeitoria/utils/anim/utils.py diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md index 2056f7aa..923f25f7 100644 --- a/.github/CODE_OF_CONDUCT.md +++ b/.github/CODE_OF_CONDUCT.md @@ -74,6 +74,3 @@ For answers to common questions about this code of conduct, see https://www.contributor-covenant.org/faq [homepage]: https://www.contributor-covenant.org - -For answers to common questions about this code of conduct, see -https://www.contributor-covenant.org/faq diff --git a/.gitignore b/.gitignore index 2d1d49de..709bb750 100644 --- a/.gitignore +++ b/.gitignore @@ -515,6 +515,7 @@ src/XRFeitoriaUnreal/Binaries/ **/output/ # Python +scripts/ xrfeitoria/version.py # Tutorials diff --git a/README.md b/README.md index b598bee0..2c3a52de 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@
[![Documentation](https://readthedocs.org/projects/xrfeitoria/badge/?version=latest)](https://xrfeitoria.readthedocs.io/en/latest/?badge=latest) -[![actions](https://github.com/openxrlab/xrfeitoria/workflows/lint/badge.svg)](https://github.com/openxrlab/xrfeitoria/actions) +[![actions](https://github.com/openxrlab/xrfeitoria/actions/workflows/lint.yml/badge.svg)](https://github.com/openxrlab/xrfeitoria/actions) [![PyPI](https://img.shields.io/pypi/v/xrfeitoria)](https://pypi.org/project/xrfeitoria/) [![license](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://www.apache.org/licenses/LICENSE-2.0) @@ -75,23 +75,45 @@ The reference documentation is available on [readthedocs](https://xrfeitoria.rea There are several [tutorials](/tutorials/). You can read them [here](https://xrfeitoria.readthedocs.io/en/latest/src/Tutorials.html). - ### Sample codes There are several [samples](/samples/). Please follow the instructions [here](/samples/README.md). +### Use plugins under development + +Details can be found [here](https://xrfeitoria.readthedocs.io/en/latest/faq.html#how-to-use-the-plugin-of-blender-unreal-under-development). + +If you want to publish plugins of your own, you can use the following command: + +```powershell +# install xrfeitoria first +cd xrfeitoria +pip install . + +# for instance, build plugins for Blender, UE 5.1, UE 5.2, and UE 5.3 on Windows. +# using powershell where backtick(`) is the line continuation character. +python -m xrfeitoria.utils.publish_plugins ` + -u "C:/Program Files/Epic Games/UE_5.1/Engine/Binaries/Win64/UnrealEditor-Cmd.exe" ` + -u "C:/Program Files/Epic Games/UE_5.2/Engine/Binaries/Win64/UnrealEditor-Cmd.exe" ` + -u "C:/Program Files/Epic Games/UE_5.3/Engine/Binaries/Win64/UnrealEditor-Cmd.exe" +``` + +### Frequently Asked Questions + +Please refer to [FAQ](https://xrfeitoria.readthedocs.io/en/latest/faq.html). ## :rocket: Amazing Projects Using XRFeitoria | Project | Teaser | Engine | | :---: | :---: | :---: | -| [Synbody: Synthetic Dataset with Layered Human Models for 3D Human Perception and Modeling](https://synbody.github.io/) | | Unreal Engine / Blender | -| [Zolly: Zoom Focal Length Correctly for Perspective-Distorted Human Mesh Reconstruction](https://wenjiawang0312.github.io/projects/zolly/) | | Blender | -| [SHERF: Generalizable Human NeRF from a Single Image](https://skhu101.github.io/SHERF/) | | Blender | -| [MatrixCity: A Large-scale City Dataset for City-scale Neural Rendering and Beyond](https://city-super.github.io/matrixcity/) | | Unreal Engine | +| [SynBody: Synthetic Dataset with Layered Human Models for 3D Human Perception and Modeling](https://synbody.github.io/) | | Unreal Engine / Blender | +| [Zolly: Zoom Focal Length Correctly for Perspective-Distorted Human Mesh Reconstruction](https://wenjiawang0312.github.io/projects/zolly/) | | Blender | +| [SHERF: Generalizable Human NeRF from a Single Image](https://skhu101.github.io/SHERF/) | | Blender | +| [MatrixCity: A Large-scale City Dataset for City-scale Neural Rendering and Beyond](https://city-super.github.io/matrixcity/) | | Unreal Engine | | [HumanLiff: Layer-wise 3D Human Generation with Diffusion Model](https://skhu101.github.io/HumanLiff/) | | Blender | +| [PrimDiffusion: Volumetric Primitives Diffusion for 3D Human Generation](https://frozenburning.github.io/projects/primdiffusion/) | | Blender | ## License diff --git a/docs/en/_templates/layout.html b/docs/en/_templates/layout.html new file mode 100644 index 00000000..0697e716 --- /dev/null +++ b/docs/en/_templates/layout.html @@ -0,0 +1,113 @@ + +{% extends '!layout.html' %} +{% block document %} +{{super()}} + +
+ + + + + + + + + CLICK + + + + + + + + + + + + + + +
+ + +{% endblock %} diff --git a/docs/en/apis/sequence.rst b/docs/en/apis/sequence.rst index 5b575dea..7834dcc9 100644 --- a/docs/en/apis/sequence.rst +++ b/docs/en/apis/sequence.rst @@ -5,9 +5,6 @@ xrfeitoria.sequence xrfeitoria.sequence.sequence_base.SequenceBase xrfeitoria.sequence.sequence_blender.SequenceBlender xrfeitoria.sequence.sequence_unreal.SequenceUnreal - xrfeitoria.sequence.sequence_wrapper.SequenceWrapperBase - xrfeitoria.sequence.sequence_wrapper.SequenceWrapperBlender - xrfeitoria.sequence.sequence_wrapper.SequenceWrapperUnreal :parts: 1 :align: center :skip-classes: abc.ABC @@ -22,6 +19,11 @@ xrfeitoria.sequence xrfeitoria.sequence.sequence_base.SequenceBase xrfeitoria.sequence.sequence_blender.SequenceBlender xrfeitoria.sequence.sequence_unreal.SequenceUnreal - xrfeitoria.sequence.sequence_wrapper.SequenceWrapperBase - xrfeitoria.sequence.sequence_wrapper.SequenceWrapperBlender - xrfeitoria.sequence.sequence_wrapper.SequenceWrapperUnreal + +---- + +.. autosummary:: + :toctree: generated/ + :template: custom-module.rst + + xrfeitoria.sequence.sequence_wrapper diff --git a/docs/en/apis/utils.rst b/docs/en/apis/utils.rst index 952b5d70..8b8c4300 100644 --- a/docs/en/apis/utils.rst +++ b/docs/en/apis/utils.rst @@ -11,6 +11,16 @@ Remote Functions xrfeitoria.utils.functions.blender_functions xrfeitoria.utils.functions.unreal_functions +Animation utils +--------------- + +.. autosummary:: + :toctree: generated/ + :template: custom-module.rst + + xrfeitoria.utils.anim.motion + xrfeitoria.utils.anim.utils + RPC runner ---------- diff --git a/docs/en/conf.py b/docs/en/conf.py index fe3e3643..eacbd19f 100644 --- a/docs/en/conf.py +++ b/docs/en/conf.py @@ -131,9 +131,12 @@ # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # +html_context = { + 'github_user': 'openxrlab', + 'github_repo': 'xrfeitoria', +} html_theme = 'sphinx_rtd_theme' html_static_path = ['_static'] -html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] html_css_files = ['override.css'] # override py property html_theme_options = { 'navigation_depth': 3, diff --git a/docs/en/faq.rst b/docs/en/faq.rst index 927043e6..eb7a79ab 100644 --- a/docs/en/faq.rst +++ b/docs/en/faq.rst @@ -7,9 +7,97 @@ We list some common troubles faced by many users and their corresponding solutio Feel free to enrich the list if you find any frequent issues and have ways to help others to solve them. If the contents here do not cover your issue, do not hesitate to create an issue! +----------- + API ---- +.. _FAQ-Plugin: + +How to use the plugin of Blender/Unreal under development +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +First you should clone the repo of XRFeitoria, and maybe modify the code of the plugin under ``src/XRFeitoriaBlender`` or ``src/XRFeitoriaUnreal``. +Then you can use the plugin under development by setting ``dev_plugin=True`` in :class:`init_blender ` or :class:`init_unreal `. + +You can install the plugin by: + +.. tabs:: + .. tab:: Blender + .. code-block:: bash + :linenos: + + git clone https://github.com/openxrlab/xrfeitoria.git + cd xrfeitoria + pip install -e . + python -c "import xrfeitoria as xf; xf.init_blender(replace_plugin=True, dev_plugin=True)" + + # or through the code in tests + python -m tests.blender.init --dev [-b] + + .. tab:: Unreal + .. code-block:: bash + :linenos: + + git clone https://github.com/openxrlab/xrfeitoria.git + cd xrfeitoria + pip install -e . + python -c "import xrfeitoria as xf; xf.init_unreal(replace_plugin=True, dev_plugin=True)" + + # or through the code in tests + python -m tests.unreal.init --dev [-b] + + +Build plugins +^^^^^^^^^^^^^^ + +If you want to publish plugins of your own, you can use the following command: + +.. code-block:: powershell + :linenos: + + # install xrfeitoria first + cd xrfeitoria + pip install . + + # for instance, build plugins for Blender, UE 5.1, UE 5.2, and UE 5.3 on Windows. + # using powershell where backtick(`) is the line continuation character. + python -m xrfeitoria.utils.publish_plugins ` + -u "C:/Program Files/Epic Games/UE_5.1/Engine/Binaries/Win64/UnrealEditor-Cmd.exe" ` + -u "C:/Program Files/Epic Games/UE_5.2/Engine/Binaries/Win64/UnrealEditor-Cmd.exe" ` + -u "C:/Program Files/Epic Games/UE_5.3/Engine/Binaries/Win64/UnrealEditor-Cmd.exe" + +Please check the path ``./src/dist`` for the generated plugins. +``XRFeitoriaBlender`` will be archived by default, and ``XRFeitoriaUnreal`` will only be built when you specify the Unreal editor path. +Make sure you have installed the corresponding Unreal Engine and Visual Studio before building the unreal plugin. + +Find out the plugin version in ``./xrfeitoria/version.py``. Or by: + +.. code-block:: bash + + >>> python -c "import xrfeitoria; print(xrfeitoria.__version__)" + 0.6.1.dev10+gd12997e.d20240122 + +You can set the environment variable ``XRFEITORIA__DIST_ROOT`` and ``XRFEITORIA__VERSION`` to change the plugins used by XRFeitoria. +Run your code ``xxx.py`` like: + +.. tabs:: + .. tab:: UNIX + + .. code-block:: bash + + XRFEITORIA__VERSION=$(python -c "import xrfeitoria; print(xrfeitoria.__version__)") \ + XRFEITORIA__DIST_ROOT=src/dist \ + python xxx.py + + .. tab:: Windows + + .. code-block:: powershell + + $env:XRFEITORIA__VERSION=$(python -c "import xrfeitoria; print(xrfeitoria.__version__)") + $env:XRFEITORIA__DIST_ROOT="src/dist"; ` + python xxx.py + .. _FAQ-stencil-value: What is ``stencil_value`` @@ -59,10 +147,11 @@ you can set the environment variable ``BLENDER_PORT`` or ``UNREAL_PORT`` to chan .. tab:: Windows - .. code-block:: bash + .. code-block:: powershell $env:BLENDER_PORT=50051; python xxx.py +----------- Known Issues ------------- diff --git a/pyproject.toml b/pyproject.toml index 29aac5ff..950e1682 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,7 +45,10 @@ dependencies = [ dynamic = ['version'] [project.optional-dependencies] -all = ["xrfeitoria[doc,vis]"] +all = ["xrfeitoria[anim,doc,vis]"] +anim = [ + "scipy>1,<2" +] doc = [ "autodoc_pydantic==2.0.1", "docutils", @@ -60,7 +63,7 @@ doc = [ "sphinx-tabs==3.4.1", "enum-tools[sphinx]", ] -vis=[ +vis = [ "matplotlib>=3.4,<4", "opencv-python>=4,<5", "flow_vis==0.1", @@ -72,7 +75,9 @@ vis=[ "Documentation" = "https://xrfeitoria.readthedocs.io/en/latest/" [project.scripts] -xf-render = "xrfeitoria.cmd.render:app" +xf-render = "xrfeitoria.cmd.blender.render:app" +xf-install-plugin = "xrfeitoria.cmd.blender.install_plugin:app" +xf-smplx = "xrfeitoria.cmd.blender.vis_smplx:app" [tool.black] line-length = 120 diff --git a/samples/README.md b/samples/README.md index 3fb285fd..b0a15a5a 100644 --- a/samples/README.md +++ b/samples/README.md @@ -26,6 +26,7 @@ python -m samples.blender.03_basic_render [-b] [--debug] python -m samples.blender.04_staticmesh_render [-b] [--debug] python -m samples.blender.05_skeletalmesh_render [-b] [--debug] python -m samples.blender.06_custom_usage [-b] [--debug] +python -m samples.blender.07_amass ``` ## Unreal diff --git a/samples/anim/utils.py b/samples/anim/utils.py deleted file mode 100644 index 597682bb..00000000 --- a/samples/anim/utils.py +++ /dev/null @@ -1,58 +0,0 @@ -from pathlib import Path -from typing import Union - -import numpy as np - -from .motion import Motion, SMPLMotion, SMPLXMotion - - -def load_amass_motion(input_amass_smplx_path: Union[Path, str]) -> Motion: - """Load AMASS SMPLX motion data. - - Args: - input_amass_smplx_path (Union[Path, str]): Path to AMASS SMPLX motion data. - - Returns: - Motion: Motion data, which consists of data read from AMASS file. - """ - input_amass_smplx_path = Path(input_amass_smplx_path).resolve() - if not input_amass_smplx_path.exists(): - raise ValueError(f'Not exist: {input_amass_smplx_path}') - # Use AMASS motion - # src_actor_name = "SMPLX" - amass_smplx_data = np.load(input_amass_smplx_path, allow_pickle=True) - src_motion = SMPLXMotion.from_amass_data(amass_smplx_data, insert_rest_pose=True) - - return src_motion - - -def load_humandata_motion(input_humandata_path: Union[Path, str]) -> Motion: - """Load humandata SMPL / SMPLX motion data. - HumanData is a structure of smpl/smplx data defined in https://github.com/open-mmlab/mmhuman3d/blob/main/docs/human_data.md - - Args: - input_humandata_path (Union[Path, str]): Path to humandata SMPL / SMPLX motion data. - - Returns: - Motion: Motion data, which consists of data read from humandata file. - """ - input_humandata_path = Path(input_humandata_path).resolve() - if not input_humandata_path.exists(): - raise ValueError(f'Not exist: {input_humandata_path}') - # Use humandata SMPL / SMPLX - humandata = np.load(input_humandata_path, allow_pickle=True) - if 'smpl' in humandata: - # src_actor_name = "SMPL" - smpl_data = humandata['smpl'].item() - src_motion = SMPLMotion.from_smpl_data(smpl_data=smpl_data, insert_rest_pose=False) - else: - # src_actor_name = "SMPLX" - smplx_data = humandata['smplx'].item() - src_motion = SMPLXMotion.from_smplx_data(smplx_data=smplx_data, insert_rest_pose=False) - - return src_motion - - -if __name__ == '__main__': - motion = load_amass_motion('amass-smplx_n/ACCAD/s001/EricCamper04_stageii.npz') - motion_data = motion.get_motion_data() diff --git a/samples/blender/01_add_shapes.py b/samples/blender/01_add_shapes.py index efa6e26b..4179b058 100644 --- a/samples/blender/01_add_shapes.py +++ b/samples/blender/01_add_shapes.py @@ -6,11 +6,11 @@ from pathlib import Path import xrfeitoria as xf +from xrfeitoria.utils import setup_logger from ..config import blender_exec -from ..utils import setup_logger -root = Path(__file__).parents[2].resolve() +root = Path(__file__).resolve().parents[2] # output_path = '~/xrfeitoria/output/samples/blender/{file_name}' output_path = root / 'output' / Path(__file__).relative_to(root).with_suffix('') log_path = output_path / 'blender.log' @@ -18,7 +18,7 @@ def main(debug=False, background=False): - logger = setup_logger(debug=debug, log_path=log_path) + logger = setup_logger(level='DEBUG' if debug else 'INFO', log_path=log_path) # The blender will start in a separate process in RPC Server mode automatically, # and close when calling `xf_runner.close()`. diff --git a/samples/blender/02_add_cameras.py b/samples/blender/02_add_cameras.py index e7c86f1f..41d1aa33 100644 --- a/samples/blender/02_add_cameras.py +++ b/samples/blender/02_add_cameras.py @@ -7,11 +7,11 @@ from pathlib import Path import xrfeitoria as xf +from xrfeitoria.utils import setup_logger from ..config import blender_exec -from ..utils import setup_logger -root = Path(__file__).parents[2].resolve() +root = Path(__file__).resolve().parents[2] # output_path = '~/xrfeitoria/output/samples/blender/{file_name}' output_path = root / 'output' / Path(__file__).relative_to(root).with_suffix('') log_path = output_path / 'blender.log' @@ -19,7 +19,7 @@ def main(debug=False, background=False): - logger = setup_logger(debug=debug, log_path=log_path) + logger = setup_logger(level='DEBUG' if debug else 'INFO', log_path=log_path) # Open blender xf_runner = xf.init_blender(exec_path=blender_exec, background=background, new_process=True) diff --git a/samples/blender/03_basic_render.py b/samples/blender/03_basic_render.py index 792dfd1e..0f284102 100644 --- a/samples/blender/03_basic_render.py +++ b/samples/blender/03_basic_render.py @@ -7,11 +7,11 @@ import xrfeitoria as xf from xrfeitoria.data_structure.models import RenderPass +from xrfeitoria.utils import setup_logger from ..config import assets_path, blender_exec -from ..utils import setup_logger -root = Path(__file__).parents[2].resolve() +root = Path(__file__).resolve().parents[2] # output_path = '~/xrfeitoria/output/samples/blender/{file_name}' output_path = root / 'output' / Path(__file__).relative_to(root).with_suffix('') log_path = output_path / 'blender.log' @@ -21,7 +21,7 @@ def main(debug=False, background=False): - logger = setup_logger(debug=debug, log_path=log_path) + logger = setup_logger(level='DEBUG' if debug else 'INFO', log_path=log_path) ################################################################################################################################ # In XRFeitoria, a `level` is an editable space that can be used to place objects(3D models, lights, cameras, etc.), diff --git a/samples/blender/04_staticmesh_render.py b/samples/blender/04_staticmesh_render.py index a52bb7c5..5bc937c2 100644 --- a/samples/blender/04_staticmesh_render.py +++ b/samples/blender/04_staticmesh_render.py @@ -9,11 +9,12 @@ import xrfeitoria as xf from xrfeitoria.data_structure.models import RenderPass from xrfeitoria.data_structure.models import SequenceTransformKey as SeqTransKey +from xrfeitoria.utils import setup_logger from ..config import assets_path, blender_exec -from ..utils import setup_logger, visualize_vertices +from ..utils import visualize_vertices -root = Path(__file__).parents[2].resolve() +root = Path(__file__).resolve().parents[2] # output_path = '~/xrfeitoria/output/samples/blender/{file_name}' output_path = root / 'output' / Path(__file__).relative_to(root).with_suffix('') log_path = output_path / 'blender.log' @@ -24,7 +25,7 @@ def main(debug=False, background=False): - logger = setup_logger(debug=debug, log_path=log_path) + logger = setup_logger(level='DEBUG' if debug else 'INFO', log_path=log_path) ############################# #### use default level ###### diff --git a/samples/blender/05_skeletalmesh_render.py b/samples/blender/05_skeletalmesh_render.py index 92de7c9b..a16a337d 100644 --- a/samples/blender/05_skeletalmesh_render.py +++ b/samples/blender/05_skeletalmesh_render.py @@ -8,11 +8,11 @@ import xrfeitoria as xf from xrfeitoria.data_structure.models import RenderPass from xrfeitoria.data_structure.models import SequenceTransformKey as SeqTransKey +from xrfeitoria.utils import setup_logger from ..config import assets_path, blender_exec -from ..utils import setup_logger -root = Path(__file__).parents[2].resolve() +root = Path(__file__).resolve().parents[2] # output_path = '~/xrfeitoria/output/samples/blender/{file_name}' output_path = root / 'output' / Path(__file__).relative_to(root).with_suffix('') log_path = output_path / 'blender.log' @@ -22,7 +22,7 @@ def main(debug=False, background=False): - logger = setup_logger(debug=debug, log_path=log_path) + logger = setup_logger(level='DEBUG' if debug else 'INFO', log_path=log_path) ################################# #### Define your own level ###### diff --git a/samples/blender/06_custom_usage.py b/samples/blender/06_custom_usage.py index 94a120e9..bf1020f0 100644 --- a/samples/blender/06_custom_usage.py +++ b/samples/blender/06_custom_usage.py @@ -7,11 +7,11 @@ import xrfeitoria as xf from xrfeitoria.rpc import remote_blender +from xrfeitoria.utils import setup_logger from ..config import blender_exec -from ..utils import setup_logger -root = Path(__file__).parents[2].resolve() +root = Path(__file__).resolve().parents[2] # output_path = '~/xrfeitoria/output/samples/blender/{file_name}' output_path = root / 'output' / Path(__file__).relative_to(root).with_suffix('') log_path = output_path / 'blender.log' @@ -35,7 +35,7 @@ def add_cubes_in_blender(): def main(debug=False, background=False): - logger = setup_logger(debug=debug, log_path=log_path) + logger = setup_logger(level='DEBUG' if debug else 'INFO', log_path=log_path) xf_runner = xf.init_blender(exec_path=blender_exec, background=background, new_process=True) # The function `add_cubes_in_blender` decorated with `@remote_blender` will be executed in blender. diff --git a/samples/blender/07_amass.py b/samples/blender/07_amass.py index f228a016..1f1534ef 100644 --- a/samples/blender/07_amass.py +++ b/samples/blender/07_amass.py @@ -10,11 +10,36 @@ ** It is recommended to run this script with Blender >= 3.6 ** """ +from pathlib import Path + import xrfeitoria as xf +from xrfeitoria.data_structure.models import RenderPass from xrfeitoria.rpc import remote_blender -from xrfeitoria.utils.tools import Logger +from xrfeitoria.utils import setup_logger +from xrfeitoria.utils.anim import dump_humandata, load_amass_motion + +# prepare the assets +#################### +root = Path('.cache/sample-amass').resolve() # modify this to your own path + +# 1. Download Amass from https://amass.is.tue.mpg.de/download.php +# For example, download ACCAD (SMPL-X N) from https://download.is.tue.mpg.de/download.php?domain=amass&sfile=amass_per_dataset/smplx/neutral/mosh_results/ACCAD.tar.bz2 +# and use `ACCAD/s001/EricCamper04_stageii.npz` from the uncompressed folder +amass_file = root / 'EricCamper04_stageii.npz' + +# 2.1 Download SMPL-XL model from https://openxrlab-share.oss-cn-hongkong.aliyuncs.com/xrfeitoria/assets/SMPL-XL-001.fbx +# or from https://openxlab.org.cn/datasets/OpenXDLab/SynBody/tree/main/Assets +# With downloading this, you are agreeing to CC BY-NC-SA 4.0 License (https://creativecommons.org/licenses/by-nc-sa/4.0/). +smpl_xl_file = root / 'SMPL-XL-001.fbx' +# 2.2 Download the meta information from https://openxrlab-share.oss-cn-hongkong.aliyuncs.com/xrfeitoria/assets/SMPL-XL-001.npz +smpl_xl_meta_file = root / 'SMPL-XL-001.npz' -from ..anim.utils import load_amass_motion +# 3. Define the output file path +seq_name = 'seq_amass' +output_path = Path(__file__).resolve().parents[2] / 'output/samples/blender' / Path(__file__).stem +output_path.mkdir(parents=True, exist_ok=True) +saved_humandata_file = output_path / 'output.npz' +saved_blend_file = output_path / 'output.blend' @remote_blender() @@ -26,37 +51,69 @@ def apply_scale(actor_name: str): bpy.ops.object.transform_apply(scale=True) -def main(): - logger = Logger.setup_logging() +def main(background: bool = False): + logger = setup_logger() - # Download Amass from https://amass.is.tue.mpg.de/download.php - # For example, download ACCAD (SMPL-X N), and use any motion file from the uncompressed folder - motion = load_amass_motion('ACCAD/s001/EricCamper04_stageii.npz') # modify this to motion file in absolute path + motion = load_amass_motion(amass_file) + motion.convert_fps(30) # convert the motion from 120fps (amass) to 30fps motion_data = motion.get_motion_data() + # modify this to your blender executable path xf_runner = xf.init_blender( - exec_path='C:/Program Files/Blender Foundation/Blender 3.3/blender.exe' - ) # modify this to your blender executable path - - # SMPL-XL model - # 1. Download SMPL-XL model from https://openxrlab-share.oss-cn-hongkong.aliyuncs.com/xrfeitoria/assets/SMPL-XL-001.fbx - # or from https://openxlab.org.cn/datasets/OpenXDLab/SynBody/tree/main/Assets - # With downloading this, you are agreeing to CC BY-NC-SA 4.0 License (https://creativecommons.org/licenses/by-nc-sa/4.0/) - # 2. Import SMPL-XL model - actor = xf_runner.Actor.import_from_file('SMPL-XL-001.fbx') # modify this to SMPL-XL model file in absolute path - apply_scale(actor.name) # SMPL-XL model is imported with scale, we need to apply scale to it - xf_runner.utils.apply_motion_data_to_actor(motion_data=motion_data, actor_name=actor.name) - - # Modify the frame range to the length of the motion - frame_start, frame_end = xf_runner.utils.get_keys_range() - xf_runner.utils.set_frame_range(frame_start, frame_end) + exec_path='C:/Program Files/Blender Foundation/Blender 3.6/blender.exe', background=background + ) + + with xf_runner.Sequence.new(seq_name=seq_name, seq_length=motion.n_frames) as seq: + # Import SMPL-XL model + actor = xf_runner.Actor.import_from_file(smpl_xl_file) + apply_scale(actor.name) # SMPL-XL model is imported with scale, we need to apply scale to it + + # Apply motion data to the actor + logger.info('Applying motion data') + xf_runner.utils.apply_motion_data_to_actor(motion_data=motion_data, actor_name=actor.name) + # Save the motion data as annotation in humandata format defined in https://github.com/open-mmlab/mmhuman3d/blob/main/docs/human_data.md + dump_humandata(motion, save_filepath=saved_humandata_file, meta_filepath=smpl_xl_meta_file) + + # Modify the frame range to the length of the motion + frame_start, frame_end = xf_runner.utils.get_keys_range() + xf_runner.utils.set_frame_range(frame_start, frame_end) + # env + xf_runner.utils.set_env_color(color=(1, 1, 1, 1)) + + # + camera = xf_runner.Camera.spawn(location=(0, -2.5, 0.6), rotation=(90, 0, 0)) + + seq.add_to_renderer( + output_path=output_path, + resolution=(1920, 1080), + render_passes=[RenderPass('img', 'png')], + render_engine='eevee', + ) + + # Save the blend file + xf_runner.utils.save_blend(saved_blend_file, pack=True) + + # render + xf_runner.render() logger.info('🎉 [bold green]Success!') - input('Press Any Key to Exit...') + output_img = output_path / seq_name / 'img' / camera.name / '0000.png' + if output_img.exists(): + logger.info(f'Check the output in "{output_img.as_posix()}"') + if not background: + input('You can check the result in the blender window. Press Any Key to Exit...') # Close the blender process xf_runner.close() + logger.info(f'You can use Blender to check the result in "{saved_blend_file.as_posix()}"') + if __name__ == '__main__': - main() + from argparse import ArgumentParser + + parser = ArgumentParser() + parser.add_argument('--background', '-b', action='store_true') + args = parser.parse_args() + + main(background=args.background) diff --git a/samples/run_all.py b/samples/run_all.py index a60b590d..fd3819ec 100644 --- a/samples/run_all.py +++ b/samples/run_all.py @@ -16,6 +16,7 @@ def main(engine: Literal['unreal', 'blender'], debug: bool = False, background: '04_staticmesh_render', '05_skeletalmesh_render', '06_custom_usage', + '07_amass', ] for script in scripts: subprocess.check_call(['python', '-m', f'samples.{engine}.{script}'] + args) diff --git a/samples/setup.py b/samples/setup.py index 01c774f3..428ea05a 100644 --- a/samples/setup.py +++ b/samples/setup.py @@ -8,9 +8,9 @@ from rich.prompt import Prompt from xrfeitoria.data_structure.constants import tmp_dir +from xrfeitoria.utils import setup_logger from xrfeitoria.utils.downloader import download from xrfeitoria.utils.setup import Config, get_exec_path, guess_exec_path -from xrfeitoria.utils.tools import Logger # XXX: Hard-coded assets url assets_url = dict( @@ -94,7 +94,7 @@ def main(): except ImportError: blender_exec = unreal_exec = unreal_project = None - Logger.setup_logging() + setup_logger() engine = Prompt.ask('Which engine do you want to use?', choices=['blender', 'unreal'], default='blender') if engine == 'blender': blender_exec = get_exec('blender', exec_from_config=blender_exec) diff --git a/samples/unreal/01_add_shapes.py b/samples/unreal/01_add_shapes.py index 4ec7c30d..a0906c92 100644 --- a/samples/unreal/01_add_shapes.py +++ b/samples/unreal/01_add_shapes.py @@ -6,21 +6,19 @@ from pathlib import Path -from loguru import logger - import xrfeitoria as xf +from xrfeitoria.utils import setup_logger from ..config import unreal_exec, unreal_project -from ..utils import setup_logger -root = Path(__file__).parents[2].resolve() +root = Path(__file__).resolve().parents[2] # output_path = '~/xrfeitoria/output/samples/unreal/{file_name}' output_path = root / 'output' / Path(__file__).relative_to(root).with_suffix('') default_level = '/Game/Levels/Default' def main(debug=False, background=False): - setup_logger(debug=debug) + logger = setup_logger(level='DEBUG' if debug else 'INFO') # unreal will start in a separate process in RPC Server mode automatically, # and close using `xf_runner.close()` diff --git a/samples/unreal/02_add_cameras.py b/samples/unreal/02_add_cameras.py index cae2104c..53cd8c74 100644 --- a/samples/unreal/02_add_cameras.py +++ b/samples/unreal/02_add_cameras.py @@ -7,19 +7,19 @@ from pathlib import Path import xrfeitoria as xf +from xrfeitoria.utils import setup_logger from ..config import unreal_exec, unreal_project -from ..utils import setup_logger -root = Path(__file__).parents[2].resolve() +root = Path(__file__).resolve().parents[2] # output_path = '~/xrfeitoria/output/samples/unreal/{file_name}' output_path = root / 'output' / Path(__file__).relative_to(root).with_suffix('') -log_path = output_path / 'blender.log' +log_path = output_path / 'unreal.log' default_level = '/Game/Levels/Default' def main(debug=False, background=False): - logger = setup_logger(debug=debug, log_path=log_path) + logger = setup_logger(level='DEBUG' if debug else 'INFO', log_path=log_path) xf_runner = xf.init_unreal(exec_path=unreal_exec, project_path=unreal_project, background=background) # open a level, this can be omitted if you don't need to open a level diff --git a/samples/unreal/03_basic_render.py b/samples/unreal/03_basic_render.py index 6ffa7c6e..9a72741c 100644 --- a/samples/unreal/03_basic_render.py +++ b/samples/unreal/03_basic_render.py @@ -7,20 +7,20 @@ import xrfeitoria as xf from xrfeitoria.data_structure.models import RenderPass +from xrfeitoria.utils import setup_logger from ..config import unreal_exec, unreal_project -from ..utils import setup_logger -root = Path(__file__).parents[2].resolve() +root = Path(__file__).resolve().parents[2] # output_path = '~/xrfeitoria/output/samples/unreal/{file_name}' output_path = root / 'output' / Path(__file__).relative_to(root).with_suffix('') -log_path = output_path / 'blender.log' +log_path = output_path / 'unreal.log' seq_name = 'seq_preset' def main(debug=False, background=False): - logger = setup_logger(debug=debug, log_path=log_path) + logger = setup_logger(level='DEBUG' if debug else 'INFO', log_path=log_path) xf_runner = xf.init_unreal(exec_path=unreal_exec, project_path=unreal_project, background=background) # There are many assets already made by others, including levels, sequences, cameras, meshes, etc. diff --git a/samples/unreal/04_staticmesh_render.py b/samples/unreal/04_staticmesh_render.py index d3fb785a..6406fca8 100644 --- a/samples/unreal/04_staticmesh_render.py +++ b/samples/unreal/04_staticmesh_render.py @@ -9,14 +9,15 @@ import xrfeitoria as xf from xrfeitoria.data_structure.models import RenderPass from xrfeitoria.data_structure.models import SequenceTransformKey as SeqTransKey +from xrfeitoria.utils import setup_logger from ..config import assets_path, unreal_exec, unreal_project -from ..utils import setup_logger, visualize_vertices +from ..utils import visualize_vertices -root = Path(__file__).parents[2].resolve() +root = Path(__file__).resolve().parents[2] # output_path = '~/xrfeitoria/output/samples/unreal/{file_name}' output_path = root / 'output' / Path(__file__).relative_to(root).with_suffix('') -log_path = output_path / 'blender.log' +log_path = output_path / 'unreal.log' # pre-defined level default_level_path = '/Game/Levels/Default' @@ -26,7 +27,7 @@ def main(debug=False, background=False): - logger = setup_logger(debug=debug, log_path=log_path) + logger = setup_logger(level='DEBUG' if debug else 'INFO', log_path=log_path) xf_runner = xf.init_unreal(exec_path=unreal_exec, project_path=unreal_project, background=background) # duplicate the level to a new level diff --git a/samples/unreal/05_skeletalmesh_render.py b/samples/unreal/05_skeletalmesh_render.py index 43f3f1d1..45c28160 100644 --- a/samples/unreal/05_skeletalmesh_render.py +++ b/samples/unreal/05_skeletalmesh_render.py @@ -7,21 +7,21 @@ import xrfeitoria as xf from xrfeitoria.data_structure.models import RenderPass +from xrfeitoria.utils import setup_logger from ..config import assets_path, unreal_exec, unreal_project -from ..utils import setup_logger -root = Path(__file__).parents[2].resolve() +root = Path(__file__).resolve().parents[2] # output_path = '~/xrfeitoria/output/samples/unreal/{file_name}' output_path = root / 'output' / Path(__file__).relative_to(root).with_suffix('') -log_path = output_path / 'blender.log' +log_path = output_path / 'unreal.log' level_path = '/Game/Levels/Playground' # pre-defined level seq_name = 'seq_skeletalmesh' def main(debug=False, background=False): - logger = setup_logger(debug=debug, log_path=log_path) + logger = setup_logger(level='DEBUG' if debug else 'INFO', log_path=log_path) xf_runner = xf.init_unreal(exec_path=unreal_exec, project_path=unreal_project, background=background) # import actor from file and its animation diff --git a/samples/unreal/06_custom_usage.py b/samples/unreal/06_custom_usage.py index 0b4bb689..782db5e1 100644 --- a/samples/unreal/06_custom_usage.py +++ b/samples/unreal/06_custom_usage.py @@ -7,14 +7,14 @@ import xrfeitoria as xf from xrfeitoria.rpc import remote_unreal +from xrfeitoria.utils import setup_logger from ..config import unreal_exec, unreal_project -from ..utils import setup_logger -root = Path(__file__).parents[2].resolve() +root = Path(__file__).resolve().parents[2] # output_path = '~/xrfeitoria/output/samples/unreal/{file_name}' output_path = root / 'output' / Path(__file__).relative_to(root).with_suffix('') -log_path = output_path / 'blender.log' +log_path = output_path / 'unreal.log' @remote_unreal() @@ -41,7 +41,7 @@ def add_cubes_in_unreal(): def main(debug=False, background=False): - logger = setup_logger(debug=debug, log_path=log_path) + logger = setup_logger(level='DEBUG' if debug else 'INFO', log_path=log_path) xf_runner = xf.init_unreal(exec_path=unreal_exec, project_path=unreal_project, background=background) # The function `add_cubes_in_unreal` decorated with `@remote_unreal` will be executed in blender. diff --git a/samples/unreal/07_amass.py b/samples/unreal/07_amass.py new file mode 100644 index 00000000..e0e8ae22 --- /dev/null +++ b/samples/unreal/07_amass.py @@ -0,0 +1,105 @@ +""" +>>> python -m samples.unreal.07_amass + +This is a script to demonstrate importing Amass motion and applying it to SMPL-XL model. +Before running this script, please download `SMPL-XL model` and `Amass dataset` first, +you can find the download links in the comments in main function. + +SMPL-XL: a parametric human model based on SMPL-X in a layered representation, introduced in https://synbody.github.io/ +Amass: a large database of human motion, introduced in https://amass.is.tue.mpg.de/ +""" +from pathlib import Path + +import xrfeitoria as xf +from xrfeitoria.data_structure.models import RenderPass +from xrfeitoria.utils import setup_logger +from xrfeitoria.utils.anim import dump_humandata, load_amass_motion + +from ..config import unreal_exec, unreal_project + +# prepare the assets +#################### +root = Path('.cache/sample-amass').resolve() # modify this to your own path + +# 1. Download Amass from https://amass.is.tue.mpg.de/download.php +# For example, download ACCAD (SMPL-X N) from https://download.is.tue.mpg.de/download.php?domain=amass&sfile=amass_per_dataset/smplx/neutral/mosh_results/ACCAD.tar.bz2 +# and use `ACCAD/s001/EricCamper04_stageii.npz` from the uncompressed folder +amass_file = root / 'EricCamper04_stageii.npz' + +# 2.1 Download SMPL-XL model from https://openxrlab-share.oss-cn-hongkong.aliyuncs.com/xrfeitoria/assets/SMPL-XL-001.fbx +# or from https://openxlab.org.cn/datasets/OpenXDLab/SynBody/tree/main/Assets +# With downloading this, you are agreeing to CC BY-NC-SA 4.0 License (https://creativecommons.org/licenses/by-nc-sa/4.0/). +smpl_xl_file = root / 'SMPL-XL-001.fbx' +# 2.2 Download the meta information from https://openxrlab-share.oss-cn-hongkong.aliyuncs.com/xrfeitoria/assets/SMPL-XL-001.npz +smpl_xl_meta_file = root / 'SMPL-XL-001.npz' + +# 3. Define the output file path +seq_name = 'seq_amass' +output_path = Path(__file__).resolve().parents[2] / 'output/samples/unreal' / Path(__file__).stem +output_path.mkdir(parents=True, exist_ok=True) +saved_humandata_file = output_path / 'output.npz' + + +def main(background: bool = False): + logger = setup_logger() + + motion = load_amass_motion(amass_file) + motion.convert_fps(30) # convert the motion from 120fps (amass) to 30fps + motion_data = motion.get_motion_data() + + xf_runner = xf.init_unreal(exec_path=unreal_exec, project_path=unreal_project, background=background) + + # Import SMPL-XL model + actor_path = xf_runner.utils.import_asset(smpl_xl_file) + + with xf_runner.Sequence.new(seq_name=seq_name, level='/Game/Levels/Playground', seq_length=motion.n_frames) as seq: + seq.show() + + # Spawn the actor, and add motion data as FK animation + actor = seq.spawn_actor( + actor_asset_path=actor_path, + location=(0, 0, 0), + rotation=(0, 0, 0), + stencil_value=1, + motion_data=motion_data, + ) + + camera = seq.spawn_camera( + location=(0, 2.5, 0.6), + rotation=(0, 0, -90), + ) + + # Add render job to renderer + seq.add_to_renderer( + output_path=output_path, + resolution=(1920, 1080), + render_passes=[RenderPass('img', 'png')], + ) + + # Save the motion data as annotation in humandata format defined in https://github.com/open-mmlab/mmhuman3d/blob/main/docs/human_data.md + dump_humandata(motion, save_filepath=saved_humandata_file, meta_filepath=smpl_xl_meta_file) + + # render + xf_runner.render() + + logger.info('🎉 [bold green]Success!') + output_img = output_path / seq_name / 'img' / camera.name / '0000.png' + if output_img.exists(): + logger.info(f'Check the output in "{output_img.as_posix()}"') + if not background: + input('You can check the result in the unreal window. Press Any Key to Exit...') + + # Close the unreal process + xf_runner.close() + + logger.info(f'You can use Unreal to check the result in "{Path(unreal_project).as_posix()}"') + + +if __name__ == '__main__': + from argparse import ArgumentParser + + parser = ArgumentParser() + parser.add_argument('--background', '-b', action='store_true') + args = parser.parse_args() + + main(background=args.background) diff --git a/samples/utils.py b/samples/utils.py index 16b67ac2..aa5e66d0 100644 --- a/samples/utils.py +++ b/samples/utils.py @@ -11,7 +11,7 @@ import xrfeitoria as xf from xrfeitoria.camera.camera_parameter import CameraParameter from xrfeitoria.utils import projector -from xrfeitoria.utils.tools import Logger +from xrfeitoria.utils import setup_logger as _setup_logger try: from .config import blender_exec, unreal_exec, unreal_project @@ -64,15 +64,6 @@ def visualize_vertices(camera_name, actor_names: List[str], seq_output_path: Pat logger.info(f'Overlap image saved to: "{save_path.as_posix()}"') -def setup_logger(debug: bool = False, log_path: str = None, reload: bool = True): - if reload: - os.environ['RPC_RELOAD'] = '1' # reload modules on every call - logger = Logger.setup_logging(level='DEBUG' if debug else 'INFO', log_path=log_path) - if debug: - os.environ['RPC_DEBUG'] = '1' - return logger - - @contextmanager def __timer__(step_name: str): t1 = time.time() @@ -96,7 +87,7 @@ def parse_args(): xf.init_blender, exec_path=blender_exec, new_process=False, - replace_plugin=True, + replace_plugin=False, dev_plugin=False, ) _init_unreal = partial( @@ -104,6 +95,6 @@ def parse_args(): exec_path=unreal_exec, project_path=unreal_project, new_process=False, - replace_plugin=True, + replace_plugin=False, dev_plugin=False, ) diff --git a/src/XRFeitoriaBpy/__init__.py b/src/XRFeitoriaBpy/__init__.py index 5f7745a9..98f25074 100644 --- a/src/XRFeitoriaBpy/__init__.py +++ b/src/XRFeitoriaBpy/__init__.py @@ -4,10 +4,11 @@ bl_info = { 'name': 'XRFeitoriaBpy', 'author': 'OpenXRLab', - 'version': (0, 5, 1), + 'version': (0, 6, 0), 'blender': (3, 3, 0), 'category': 'Tools', } +__version__ = version = '0.6.0' def register(): diff --git a/src/XRFeitoriaBpy/constants.py b/src/XRFeitoriaBpy/constants.py index e672e6b7..0ed10206 100644 --- a/src/XRFeitoriaBpy/constants.py +++ b/src/XRFeitoriaBpy/constants.py @@ -1,6 +1,6 @@ from enum import Enum from pathlib import Path -from typing import List, Optional, Tuple, Type, TypeVar, Union +from typing import Dict, List, Optional, Tuple, Type, TypeVar, Union import bpy @@ -8,6 +8,7 @@ Tuple3 = Tuple[float, float, float] PathLike = Union[str, Path] +MotionFrame = Dict[str, Dict[str, Union[float, List[float]]]] class EnumBase(str, Enum): diff --git a/src/XRFeitoriaBpy/core/factory.py b/src/XRFeitoriaBpy/core/factory.py index 052edfea..31d537a0 100644 --- a/src/XRFeitoriaBpy/core/factory.py +++ b/src/XRFeitoriaBpy/core/factory.py @@ -2,13 +2,23 @@ import math from contextlib import contextmanager from pathlib import Path -from typing import Any, Dict, List, Literal, Optional, Tuple, Union +from typing import Any, Dict, List, Literal, NamedTuple, Optional, Tuple, Union import bpy import numpy as np from .. import logger -from ..constants import Tuple3 +from ..constants import MotionFrame, Tuple3 + + +class SequenceProperties(NamedTuple): + level: bpy.types.Scene + fps: int + frame_start: int + frame_end: int + frame_current: int + resolution_x: int + resolution_y: int class XRFeitoriaBlenderFactory: @@ -123,6 +133,8 @@ def set_sequence_properties( frame_start: int, frame_end: int, frame_current: int, + resolution_x: int, + resolution_y: int, ) -> None: """Set the sequence properties. @@ -133,29 +145,35 @@ def set_sequence_properties( frame_start (int): Start frame of the sequence. frame_end (int): End frame of the sequence. frame_current (int): Current frame of the sequence. + resoltion_x (int): Resolution_x of the sequence. + resoltion_y (int): Resolution_y of the sequence. """ collection.sequence_properties.level = level collection.sequence_properties.fps = fps collection.sequence_properties.frame_start = frame_start collection.sequence_properties.frame_end = frame_end collection.sequence_properties.frame_current = frame_current + collection.sequence_properties.resolution_x = resolution_x + collection.sequence_properties.resolution_y = resolution_y - def get_sequence_properties(collection: 'bpy.types.Collection') -> 'Tuple[bpy.types.Scene, int, int, int, int]': + def get_sequence_properties(collection: 'bpy.types.Collection') -> SequenceProperties: """Get the sequence properties. Args: collection (bpy.types.Collection): Collection of the sequence. Returns: - Tuple[bpy.types.Scene, int, int, int, int]: - The level(scene), FPS of the sequence, Start frame of the sequence, End frame of the sequence, Current frame of the sequence. + SequenceProperties: Sequence properties. """ level = collection.sequence_properties.level fps = collection.sequence_properties.fps frame_start = collection.sequence_properties.frame_start frame_end = collection.sequence_properties.frame_end frame_current = collection.sequence_properties.frame_current - return level, fps, frame_start, frame_end, frame_current + resolution_x = collection.sequence_properties.resolution_x + resolution_y = collection.sequence_properties.resolution_y + + return SequenceProperties(level, fps, frame_start, frame_end, frame_current, resolution_x, resolution_y) def open_sequence(seq_name: str) -> 'bpy.types.Scene': """Open the given sequence. @@ -166,9 +184,15 @@ def open_sequence(seq_name: str) -> 'bpy.types.Scene': # get sequence collection seq_collection = XRFeitoriaBlenderFactory.get_collection(seq_name) # get sequence properties - level_scene, fps, frame_start, frame_end, frame_current = XRFeitoriaBlenderFactory.get_sequence_properties( - collection=seq_collection - ) + ( + level_scene, + fps, + frame_start, + frame_end, + frame_current, + resolution_x, + resolution_y, + ) = XRFeitoriaBlenderFactory.get_sequence_properties(collection=seq_collection) # deactivate all cameras in this level for obj in level_scene.objects: if obj.type == 'CAMERA': @@ -187,6 +211,8 @@ def open_sequence(seq_name: str) -> 'bpy.types.Scene': level_scene.frame_end = frame_end level_scene.frame_current = frame_current level_scene.render.fps = fps + level_scene.render.resolution_x = resolution_x + level_scene.render.resolution_y = resolution_y # set cameras in this sequence to active for obj in seq_collection.objects: @@ -205,6 +231,8 @@ def open_sequence(seq_name: str) -> 'bpy.types.Scene': for actor_data in seq_collection.sequence_properties.level_actors: actor = actor_data.actor actor.pass_index = actor_data.sequence_stencil_value + for child in actor.children_recursive: + child.pass_index = actor_data.sequence_stencil_value if actor_data.sequence_animation: XRFeitoriaBlenderFactory.apply_action_to_actor(action=actor_data.sequence_animation, actor=actor) @@ -227,12 +255,25 @@ def close_sequence() -> None: # clear all sequences in this level for collection in level_scene.collection.children: if XRFeitoriaBlenderFactory.is_sequence_collecion(collection): + # save sequence properties + XRFeitoriaBlenderFactory.set_sequence_properties( + collection=collection, + level=level_scene, + fps=level_scene.render.fps, + frame_start=level_scene.frame_start, + frame_end=level_scene.frame_end, + frame_current=level_scene.frame_current, + resolution_x=level_scene.render.resolution_x, + resolution_y=level_scene.render.resolution_y, + ) # unlink the sequence from the level XRFeitoriaBlenderFactory.unlink_collection_from_scene(collection=collection, scene=level_scene) # restore level actors' properties for actor_data in collection.sequence_properties.level_actors: actor = actor_data.actor actor.pass_index = actor_data.level_stencil_value + for child in actor.children_recursive: + child.pass_index = actor_data.level_stencil_value if actor_data.level_animation: XRFeitoriaBlenderFactory.apply_action_to_actor(action=actor_data.level_animation, actor=actor) else: @@ -414,8 +455,11 @@ def set_collection_active(collection: 'bpy.types.Collection') -> None: Args: collection (bpy.types.Collection): The collection to be set as the active collection. """ - layer_collection = bpy.context.view_layer.layer_collection.children[collection.name] - bpy.context.view_layer.active_layer_collection = layer_collection + if collection.name in bpy.context.view_layer.layer_collection.children.keys(): + layer_collection = bpy.context.view_layer.layer_collection.children[collection.name] + bpy.context.view_layer.active_layer_collection = layer_collection + elif collection.name == bpy.context.view_layer.layer_collection.name: + bpy.context.view_layer.active_layer_collection = bpy.context.view_layer.layer_collection def set_frame_range(scene: 'bpy.types.Scene', start: int, end: int) -> None: """Set the frame range of the given scene. @@ -934,6 +978,19 @@ def get_active_cameras(scene: 'bpy.types.Scene') -> List[str]: ##################################### ############### Import ############## ##################################### + def import_texture(texture_file: str) -> bpy.types.Image: + """Import an image as a texture. + + Args: + texture_file (str): File path of the image. + Returns: + bpy.types.Image: The imported texture. + """ + try: + texture = bpy.data.images.load(filepath=str(texture_file)) + except Exception: + raise Exception(f'Failed to import texture: {texture_file}') + return texture def import_fbx(fbx_file: str) -> None: """Import an fbx file. Only support binary fbx. @@ -999,7 +1056,8 @@ def import_glb(glb_file: str) -> None: raise Exception(f'Failed to import glb: {glb_file}\n{e}') def import_mo_json(mo_json_file: Path, actor_name: str) -> None: - """Import an animation from json, and apply the animation to the given actor. + """Import an animation from json, and apply the animation to the given actor. In + form of quaternion. Args: mo_json_file (Path): json file path. @@ -1052,14 +1110,15 @@ def apply_action_to_actor(action: 'bpy.types.Action', actor: 'bpy.types.Object') actor.animation_data.action = action def apply_motion_data_to_action( - motion_data: 'List[Dict[str, Dict]]', + motion_data: 'List[MotionFrame]', action: 'bpy.types.Action', scale: float = 1.0, ) -> None: """Apply motion data in dict to object. Args: - motion_data (List[Dict[str, Dict]]): Motion data in the form of dict, normally imported from json. + motion_data (List[Dict[str, Dict]]): Motion data in the form of dict, + containing rotation (quaternion) and location. action (bpy.types.Action): Action. scale (float, optional): Scale of movement in location of animation. Defaults to 1.0. """ @@ -1105,17 +1164,31 @@ def _get_fcurve(data_path: str, index: int): # fcurve.keyframe_points[f].co = (f, val) fcurve.keyframe_points.insert(frame=f, value=val, options={'FAST'}) - def apply_motion_data_to_actor(motion_data: 'List[Dict[str, Dict[str, List[float]]]]', actor_name: str) -> None: + def apply_motion_data_to_actor(motion_data: 'List[MotionFrame]', actor_name: str) -> None: """Applies motion data to a given actor. Args: - motion_data: A list of dictionaries containing motion data for the actor. + motion_data: A list of dictionaries containing motion data (quaternion) for the actor. actor_name: The name of the actor to apply the motion data to. """ action = bpy.data.actions.new('Action') XRFeitoriaBlenderFactory.apply_motion_data_to_action(motion_data=motion_data, action=action) XRFeitoriaBlenderFactory.apply_action_to_actor(action, actor=bpy.data.objects[actor_name]) + def apply_shape_keys_to_mesh(shape_keys: 'List[Dict[str, float]]', mesh_name: str) -> None: + """Apply shape keys to the given mesh. + + Args: + shape_keys (List[Dict[str, float]]): A list of dictionaries representing the shape keys and their values. + mesh_name (str): Name of the mesh. + """ + actor = bpy.data.objects[mesh_name] + for f in range(len(shape_keys)): + for key, value in shape_keys[f].items(): + actor.data.shape_keys.key_blocks[key].value = value + # set keyframe + actor.data.shape_keys.key_blocks[key].keyframe_insert(data_path='value', frame=f) + ##################################### ############# validate ############## ##################################### @@ -1257,3 +1330,18 @@ def get_bound_box_in_world_space( return bbox_min, bbox_max else: raise ValueError(f'Invalid object type: {obj.type}') + + ##################################### + ############# Material ############## + ##################################### + def get_material(mat_name: str) -> 'bpy.types.Material': + if mat_name not in bpy.data.materials.keys(): + raise ValueError(f"Material '{mat_name}' does not exists in this blend file.") + return bpy.data.materials[mat_name] + + def new_mat_node(mat: 'bpy.types.Material', type: str, name: Optional[str] = None) -> bpy.types.Node: + _nodes = mat.node_tree.nodes + node = _nodes.new(type=type) + if name: + node.name = name + return node diff --git a/src/XRFeitoriaBpy/core/renderer.py b/src/XRFeitoriaBpy/core/renderer.py index 1105bd1a..17e70a10 100644 --- a/src/XRFeitoriaBpy/core/renderer.py +++ b/src/XRFeitoriaBpy/core/renderer.py @@ -86,18 +86,18 @@ def set_file_format(context: bpy.types.Context, file_format: ImageFileFormatEnum @staticmethod def set_slot_to_jpg(node_slot: bpy.types.NodeOutputFileSlotFile): node_slot.use_node_format = False - node_slot.format.file_format = ImageFileFormatEnum.jpeg + node_slot.format.file_format = ImageFileFormatEnum.jpeg.value @staticmethod def set_slot_to_png(node_slot: bpy.types.NodeOutputFileSlotFile): node_slot.use_node_format = False - node_slot.format.file_format = ImageFileFormatEnum.png + node_slot.format.file_format = ImageFileFormatEnum.png.value @staticmethod def set_slot_to_exr(node_slot: bpy.types.NodeOutputFileSlotFile): """Set depth to save as float (EXR)""" node_slot.use_node_format = False - node_slot.format.file_format = ImageFileFormatEnum.exr + node_slot.format.file_format = ImageFileFormatEnum.exr.value node_slot.format.color_depth = '32' @staticmethod diff --git a/src/XRFeitoriaBpy/properties.py b/src/XRFeitoriaBpy/properties.py index 50ee2c10..719b0493 100644 --- a/src/XRFeitoriaBpy/properties.py +++ b/src/XRFeitoriaBpy/properties.py @@ -35,6 +35,8 @@ class SequenceProperties(bpy.types.PropertyGroup): frame_start: bpy.props.IntProperty() frame_end: bpy.props.IntProperty() frame_current: bpy.props.IntProperty() + resolution_x: bpy.props.IntProperty() + resolution_y: bpy.props.IntProperty() ## level properties for scene @@ -44,7 +46,9 @@ def active_sequence_update(self, context): if self.active_sequence is None: XRFeitoriaBlenderFactory.close_sequence() elif XRFeitoriaBlenderFactory.is_sequence_collecion(self.active_sequence): - level_scene, _, _, _, _ = XRFeitoriaBlenderFactory.get_sequence_properties(collection=self.active_sequence) + level_scene, _, _, _, _, _, _ = XRFeitoriaBlenderFactory.get_sequence_properties( + collection=self.active_sequence + ) if self.active_sequence.name not in level_scene.collection.children: XRFeitoriaBlenderFactory.open_sequence(self.active_sequence.name) else: diff --git a/src/XRFeitoriaBpy/utils_logger.py b/src/XRFeitoriaBpy/utils_logger.py index 96084334..98668748 100644 --- a/src/XRFeitoriaBpy/utils_logger.py +++ b/src/XRFeitoriaBpy/utils_logger.py @@ -18,7 +18,7 @@ def filter(self, record): return False -def setup_logging(level: str = 'INFO') -> 'logging.Logger': +def setup_logger(level: str = 'INFO') -> 'logging.Logger': """Setup logging to file and console. Args: @@ -30,7 +30,8 @@ def setup_logging(level: str = 'INFO') -> 'logging.Logger': logger = logging.getLogger(__name__) logger.handlers.clear() logger.setLevel(level) - logger_format = '{asctime} | ' + '{levelname:^8} | ' + '[blender] {message}' + # logger_format = '{asctime} | ' + '{levelname:^8} | ' + '[blender] {message}' + logger_format = '{message}' formatter = logging.Formatter(logger_format, style='{', datefmt='%Y-%m-%d %H:%M:%S') console_handler = logging.StreamHandler() console_handler.setFormatter(formatter) @@ -39,9 +40,4 @@ def setup_logging(level: str = 'INFO') -> 'logging.Logger': return logger -def setup_logger(level: str = 'INFO'): - logger = setup_logging(level=level) - return logger - - logger = setup_logger() diff --git a/src/XRFeitoriaUnreal/Config/FilterPlugin.ini b/src/XRFeitoriaUnreal/Config/FilterPlugin.ini new file mode 100644 index 00000000..ccebca2f --- /dev/null +++ b/src/XRFeitoriaUnreal/Config/FilterPlugin.ini @@ -0,0 +1,8 @@ +[FilterPlugin] +; This section lists additional files which will be packaged along with your plugin. Paths should be listed relative to the root plugin directory, and +; may include "...", "*", and "?" wildcards to match directories, files, and individual characters respectively. +; +; Examples: +; /README.txt +; /Extras/... +; /Binaries/ThirdParty/*.dll diff --git a/src/XRFeitoriaUnreal/Content/Blueprints/SequenceAssetData.uasset b/src/XRFeitoriaUnreal/Content/Blueprints/SequenceAssetData.uasset deleted file mode 100644 index 91e2e03c7bc4191e190632bc132a78952e7746e1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9740 zcmdrS3y@UBu~#_$$4^kyfPe@bpW{9p+<^h_c5jdSAKWMS0ep8md#vp3+uQe+a}n@B zLhzF^3NcAxw4_9dVilz%q(nt53}~vv#Ar;;NL55C7_Cx^_yfFj&zpYxc5m;aP*y4f z@6AkiPfvGGPfyR=JACJihrjIX?7XEPz>rG-KE;~KAbhSnymJ28B_{`bvITy-v317O z{s`MTZu~7y|p3mRAKe0kCv7_IsJDBUR;f^$#2y?^kVhY zJ>G$ncD%89zcveDCv)D}mtK9>)dw~n-@B{q{JbFuTWp>?-tO7wJ~8J0?Z(9MmtTvp zqLn+}@m9VtWzXKxTVj{{PGlhLN*MQoa}kt z`R)RDer~SYUF^+u=jXV+^NVx6-u%3|-u(GAM3yvsrws)dhV`|B0Y1Qn3)`d80j|I& z51%D>y?gHV=0TqO9{cAb_q<$C_5SR&AAivPOvT;1_da|gZ~sxECq5)d8j9h9>tDXt zH5z*}XVw?z&0HpeLNmndyv!Vl2YCAP&E4Q}kSxDB`fd|7eueYDLxyw|T;fi$*AEf) zt^@ZS?AI|ht6(^?DU3w4SWQF^i-;M6(tYm-Mj*7YFc{R^JSe^y&_mGq>F|H!w9r@~ znzg1_l@Pu2UqLoAgJ~%$`%KFQl(At<`ik1cuF^KOb zbMVZBL3OdPfMKH^IDyJTk*)`A$wv0yclTuktZC5DMH+*`BaEgWhs#UK!~C)ans45Nd`oqb9sg zZtkwG@~o=TLjv}Hk&{82i)JVnF-SiYy@@EO-=Lx2Lh--WKO!lFUyBA!*wm>nA_+WJ z-y$0Mhcmh4f1ZF54r;L~twq4PpPwqCB`EflM6~egnm|Yzv>mgr28tnQ0zGDMvOsF}4 z`W$e1PSHYmgSuwg20Hu1d*?}JZzLc>zMv&vzgJ!xO7fM6kcgn=eD0uT7_fEF@;b7B z6k4wRuFZd3OPfP>+%;dXCQVC2arV34oL4pmVHKKT7NcBowG0?G;gUDVe3e>Qs?z-C z1~-kTK09mPj!m0LXkKdR%CO8o`mZENC0adDLCWoEUfe-yka_LFn?D;=CV`@j9m;^m z9{==N5?QYCnSQUNlcLI?JZoXcvmcH}aE;cqMr#%odXp9mtkcMFh-$664gq+IszuOUW7Ss0d>Ua?@p`;=?Uz zA7$(e&mMpyET1vkZoB_V?5U`+YE?}n(4s|R6ldgF2uDso;UjmnDR`OeqjHrS?x`MA zNt&ye0{!*sgNpnzM@rEAKk`g7hzGbo3nZlJc=zwvnw)hKrKb@k zOO~pWgyyr6Yoc1DNQ+cW36oc~d!qXS>6Y}7uCxzypCiBP`$$*VN4nxZ(pB_8SEg`O zQG!7wm0g^4Y}4N87F+BR_%V!SBD=U#(D59V3PgUPJTHL$ame z!-e7hNz#+?$u-d@#^%)L-*^m8Jw@#9!c+#1drJ8m&6S$_6^KbZG&19d1to73OJyuC ziee5XDDor3y0=teX0TRRN5w*fF(b#xlqs>(7-OAFnaM$T8c;%EPb`l|38|^Op{5vf z-bjlU1QN%li6=Vx4H@Bt1He(X}6LY5LcJS9_|*$hQTf_h7p}PPIzlR?tB}8NS`n0&Bq! ztr*M*8iguo#JUkyBhp5~jBCAVp=O#s=Eh zhqVj+){^*Q=}-erum)F-xO>pLjp&c$k*q^^c%GlFoLYw|O4=-_!x?CAwBkHjI1|~* z4-MetKL@G8(2NvLXxyWQTqNCD_A=D82<0$PYg)JN;6pFcylhjNw-&W;#)qt6?VY&I zyTfw~>DlJPm9)ijhDP)p+A}{w9jzD@pQmR&@z@YX2`O4I=bp#$Dp3S%O^65_HQ3JI zb^ozs4d}a8bnOZm*=5aG`U>B}uY@^j#oZq_G7fooS54gRQs%HXvjJW=-_!*V?)S^_*EIfAYM*{Fsu;O5GO?3i+9K~m@(O`anaf`E z{JMusH{l}F%^iwN)QLA4zFAKvL9tA=Dooi_p-&Bby`>L)RBR+A^88a4bE2G^3UiWE z&;^X|^i0^H*yKrad=WP$V&LP7h`BPYT`y3rYgBC$(tV#wfmNZ?TZJyNRDNJpUEewL zZ`d=iBCT>qX6=9x>tGRi4;5xmY<1;bQ#BK-xvI+_8O|hs31CFxu*4o+$f@L7m2@K6 zq}AnJG*S8(xdN4WRt1@=mNy$b1OUndMvE}jCw8)s`b;Y+xSgSf<*VAZB&E?*W+_A$ zQ?ouZ6#;$Dpu!yJ-YpBsLS>Rw+1|&d^mc9TC*z*0``_>vHnlVt{#^C{;wtTGnQX5h-(-JcV7Vgd)>UW| zQR(8!lPx%2Ypue*WOGVGIQ}*u>~F|@@;VB)RIH6;eKn&#&o2=inI;eh3lqO#b?pEo z5lqA??^Hx|1g9)x6_f|??#M|=t$?$W2C<0O^)v`0(MuM7%u5`L=yMPio)w$zi+NAd zPfJwz$gqj%MCd0`Adi04R27r4Ml4*nk-cBW@L^MOcb6P&eCFDX>jvG)^Ao=ris?jE za}?wi;z=Z$XiCA!+hz!gVk(+l37S1^)BLNkA8}u@%MXlb0`onn?gnrwk!v0Ndr*(+;90FqL z;u1K%lT1TJ{hDHR None: + export_setting: unreal.MoviePipelineWaveOutput = movie_preset.find_or_add_setting_by_class( + unreal.MoviePipelineWaveOutput + ) + @staticmethod def add_render_passes(movie_preset: unreal.MoviePipelineMasterConfig, render_passes: List[RenderPass]) -> None: """Add render passes to a movie preset. @@ -224,7 +230,7 @@ def add_anti_alias( anti_alias_config.override_anti_aliasing = True if anti_alias.warmup_frames: anti_alias_config.use_camera_cut_for_warm_up = True - anti_alias_config.render_warm_up_count = anti_alias['warmup_frames'] + anti_alias_config.render_warm_up_count = anti_alias.warmup_frames if anti_alias.render_warmup_frame: anti_alias_config.render_warm_up_frames = True @@ -273,6 +279,7 @@ def create_movie_preset( console_variables: Dict[str, float] = {'r.MotionBlurQuality': 0.0}, export_vertices: bool = False, export_skeleton: bool = False, + export_audio: bool = False, ) -> unreal.MoviePipelineMasterConfig: """ Create a movie preset from args. @@ -303,6 +310,8 @@ def create_movie_preset( cls.set_render_all_cameras(movie_preset, enable=True) cls.set_export_vertices(movie_preset, enable=export_vertices) cls.set_export_skeleton(movie_preset, enable=export_skeleton) + if export_audio: + cls.set_export_audio(movie_preset) return movie_preset @@ -389,6 +398,7 @@ def add_job_to_queue(cls, job: RenderJobUnreal) -> bool: console_variables=job.console_variables, export_vertices=job.export_vertices, export_skeleton=job.export_skeleton, + export_audio=job.export_audio, ) new_job.set_configuration(movie_preset) unreal.log(f'Added new job ({new_job.job_name}) to queue') diff --git a/src/XRFeitoriaUnreal/Content/Python/sequence.py b/src/XRFeitoriaUnreal/Content/Python/sequence.py index 2b09e277..6a667efa 100644 --- a/src/XRFeitoriaUnreal/Content/Python/sequence.py +++ b/src/XRFeitoriaUnreal/Content/Python/sequence.py @@ -6,6 +6,8 @@ DEFAULT_SEQUENCE_DATA_ASSET, DEFAULT_SEQUENCE_PATH, ENGINE_MAJOR_VERSION, + ENGINE_MINOR_VERSION, + MotionFrame, SequenceTransformKey, SubSystem, TransformKeys, @@ -17,7 +19,6 @@ EditorLevelSequenceSub = SubSystem.EditorLevelSequenceSub EditorAssetSub = SubSystem.EditorAssetSub EditorLevelSub = SubSystem.EditorLevelSub -START_FRAME = -1 ################################################################################ # misc @@ -31,6 +32,20 @@ def duplicate_binding(binding: unreal.SequencerBindingProxy) -> None: # TODO: the event track would be lost after pasting, need to fix it +def get_binding_id(binding: unreal.SequencerBindingProxy) -> unreal.MovieSceneObjectBindingID: + """Get the MovieSceneObjectBindingID from a SequencerBindingProxy. + + Args: + binding (unreal.SequencerBindingProxy): The SequencerBindingProxy object. + + Returns: + unreal.MovieSceneObjectBindingID: The MovieSceneObjectBindingID extracted from the binding. + """ + binding_id = unreal.MovieSceneObjectBindingID() + binding_id.set_editor_property('Guid', binding.binding_id) + return binding_id + + def convert_frame_rate_to_fps(frame_rate: unreal.FrameRate) -> float: return frame_rate.numerator / frame_rate.denominator @@ -46,9 +61,10 @@ def get_animation_length(animation_asset: unreal.AnimSequence, seq_fps: float = # TODO: check if this is true anim_frame_rate = animation_asset.get_editor_property('target_frame_rate') anim_frame_rate = convert_frame_rate_to_fps(anim_frame_rate) - assert ( - anim_frame_rate == seq_fps - ), f'anim fps {anim_frame_rate} != seq fps {seq_fps}, this would cause animation interpolation.' + if anim_frame_rate == seq_fps: + unreal.log_warning( + f'anim fps {anim_frame_rate} != seq fps {seq_fps}, this would cause animation interpolation.' + ) anim_len = animation_asset.get_editor_property('number_of_sampled_frames') @@ -246,7 +262,7 @@ def add_property_bool_track_to_binding( bool_section.set_end_frame_bounded(0) # set key - for channel in bool_section.find_channels_by_type(unreal.MovieSceneScriptingBoolChannel): + for channel in bool_section.get_channels_by_type(unreal.MovieSceneScriptingBoolChannel): channel.set_default(property_value) return bool_track, bool_section @@ -267,7 +283,7 @@ def add_property_int_track_to_binding( int_section.set_end_frame_bounded(0) # set key - for channel in int_section.find_channels_by_type(unreal.MovieSceneScriptingIntegerChannel): + for channel in int_section.get_channels_by_type(unreal.MovieSceneScriptingIntegerChannel): channel.set_default(property_value) return int_track, int_section @@ -288,7 +304,7 @@ def add_property_string_track_to_binding( string_section.set_end_frame_bounded(0) # set key - for channel in string_section.find_channels_by_type(unreal.MovieSceneScriptingStringChannel): + for channel in string_section.get_channels_by_type(unreal.MovieSceneScriptingStringChannel): channel: unreal.MovieSceneScriptingStringChannel if isinstance(property_value, str): channel.set_default(property_value) @@ -377,6 +393,69 @@ def add_animation_to_binding( set_animation_by_section(animation_section, animation_asset, animation_length, seq_fps) +def add_fk_motion_to_binding(binding: unreal.SequencerBindingProxy, motion_data: List[MotionFrame]) -> None: + """Add FK motion to the given actor binding. + + Args: + binding (unreal.SequencerBindingProxy): The binding of actor in sequence to add FK motion to. + motion_data (List[MotionFrame]): The FK motion data. + """ + rig_track: unreal.MovieSceneControlRigParameterTrack = ( + unreal.ControlRigSequencerLibrary.find_or_create_control_rig_track( + world=get_world(), + level_sequence=binding.sequence, + control_rig_class=unreal.FKControlRig, + binding=binding, + ) + ) + rig_section: unreal.MovieSceneControlRigParameterSection = rig_track.get_section_to_key() + param_names = list(rig_section.get_parameter_names()) + for bone_name, bone_data in motion_data[0].items(): + if 'curve' in bone_data.keys(): + bone_name = f'{bone_name}_CURVE_CONTROL' + else: + bone_name = f'{bone_name}_CONTROL' + assert bone_name in param_names, RuntimeError(f'bone name: {bone_name} not in param names: {param_names}') + + if ENGINE_MAJOR_VERSION == 5 and ENGINE_MINOR_VERSION < 2: + msg = 'FKRigExecuteMode is not supported in < UE5.2, may cause unexpected result using FK motion.' + unreal.log_warning(msg) + else: + rig_proxies = unreal.ControlRigSequencerLibrary.get_control_rigs(binding.sequence) + for rig_proxy in rig_proxies: + ### TODO: judge if the track belongs to this actor + unreal.ControlRigSequencerLibrary.set_control_rig_apply_mode( + rig_proxy.control_rig, unreal.ControlRigFKRigExecuteMode.ADDITIVE + ) + + def get_transform_from_bone_data(bone_data: Dict[str, List[float]]): + quat: Tuple[float, float, float, float] = bone_data.get('rotation') + location: Tuple[float, float, float] = bone_data.get('location', (0, 0, 0)) # default location is (0, 0, 0) + + # HACK: convert space + location = [location[0] * 100, -location[1] * 100, location[2] * 100] # cm -> m, y -> -y + quat = (-quat[1], quat[2], -quat[3], quat[0]) # (w, x, y, z) -> (-x, y, -z, w) + + transform = unreal.Transform(location=location, rotation=unreal.Quat(*quat).rotator()) + return transform + + for frame, motion_frame in enumerate(motion_data): + for bone_name, bone_data in motion_frame.items(): + # TODO: set key type to STATIC + if 'curve' in bone_data.keys(): + rig_section.add_scalar_parameter_key( + parameter_name=f'{bone_name}_CURVE_CONTROL', + time=get_time(binding.sequence, frame), + value=bone_data['curve'], + ) + else: + rig_section.add_transform_parameter_key( + parameter_name=f'{bone_name}_CONTROL', + time=get_time(binding.sequence, frame), + value=get_transform_from_bone_data(bone_data), + ) + + def get_spawnable_actor_from_binding( sequence: unreal.MovieSceneSequence, binding: unreal.SequencerBindingProxy, @@ -410,13 +489,13 @@ def add_level_visibility_to_sequence( # add level visibility section level_visible_section: unreal.MovieSceneLevelVisibilitySection = level_visibility_track.add_section() level_visible_section.set_visibility(unreal.LevelVisibility.VISIBLE) - level_visible_section.set_start_frame(START_FRAME) + level_visible_section.set_start_frame(Sequence.START_FRAME) level_visible_section.set_end_frame(seq_length) level_hidden_section: unreal.MovieSceneLevelVisibilitySection = level_visibility_track.add_section() level_hidden_section.set_row_index(1) level_hidden_section.set_visibility(unreal.LevelVisibility.HIDDEN) - level_hidden_section.set_start_frame(START_FRAME) + level_hidden_section.set_start_frame(Sequence.START_FRAME) level_hidden_section.set_end_frame(seq_length) return level_visible_section, level_hidden_section @@ -480,7 +559,7 @@ def add_camera_to_sequence( camera_binding = sequence.add_possessable(camera) camera_track: unreal.MovieScene3DTransformTrack = camera_binding.add_track(unreal.MovieScene3DTransformTrack) # type: ignore camera_section: unreal.MovieScene3DTransformSection = camera_track.add_section() # type: ignore - camera_section.set_start_frame(START_FRAME) + camera_section.set_start_frame(Sequence.START_FRAME) camera_section.set_end_frame(seq_length) camera_component_binding = sequence.add_possessable(camera.camera_component) camera_component_binding.set_parent(camera_binding) @@ -492,13 +571,14 @@ def add_camera_to_sequence( camera_cut_track: unreal.MovieSceneCameraCutTrack = sequence.add_master_track(unreal.MovieSceneCameraCutTrack) # type: ignore # add a camera cut track for this camera - # make sure the camera cut is stretched to the START_FRAME mark + # make sure the camera cut is stretched to the Sequence.START_FRAME mark camera_cut_section: unreal.MovieSceneCameraCutSection = camera_cut_track.add_section() # type: ignore - camera_cut_section.set_start_frame(START_FRAME) + camera_cut_section.set_start_frame(Sequence.START_FRAME) camera_cut_section.set_end_frame(seq_length) # set the camera cut to use this camera - camera_cut_section.set_camera_binding_id(camera_binding.get_binding_id()) + # camera_cut_section.set_camera_binding_id(camera_binding.get_binding_id()) + camera_cut_section.set_camera_binding_id(get_binding_id(camera_binding)) # ------- add transform track ------- # transform_track, transform_section = add_or_find_transform_track_to_binding(camera_binding) @@ -562,9 +642,9 @@ def add_spawnable_camera_to_sequence( # camera_cut_track = sequence.add_track(unreal.MovieSceneCameraCutTrack) camera_cut_track: unreal.MovieSceneCameraCutTrack = sequence.add_master_track(unreal.MovieSceneCameraCutTrack) - # add a camera cut track for this camera, make sure the camera cut is stretched to the START_FRAME mark + # add a camera cut track for this camera, make sure the camera cut is stretched to the Sequence.START_FRAME mark camera_cut_section: unreal.MovieSceneCameraCutSection = camera_cut_track.add_section() - camera_cut_section.set_start_frame(START_FRAME) + camera_cut_section.set_start_frame(Sequence.START_FRAME) camera_cut_section.set_end_frame(seq_length) # set the camera cut to use this camera @@ -576,7 +656,8 @@ def add_spawnable_camera_to_sequence( # camera_binding_id = sequence.make_binding_id(camera_binding, unreal.MovieSceneObjectBindingSpace.LOCAL) # camera_cut_section.set_camera_binding_id(camera_binding_id) - camera_cut_section.set_camera_binding_id(camera_binding.get_binding_id()) + # camera_cut_section.set_camera_binding_id(camera_binding.get_binding_id()) + camera_cut_section.set_camera_binding_id(get_binding_id(camera_binding)) # ------- add transform track ------- # transform_track, transform_section = add_or_find_transform_track_to_binding(camera_binding) @@ -604,6 +685,7 @@ def add_actor_to_sequence( actor_transform_keys: Optional[Union[SequenceTransformKey, List[SequenceTransformKey]]] = None, actor_stencil_value: int = 1, animation_asset: Optional[unreal.AnimSequence] = None, + motion_data: Optional[List[MotionFrame]] = None, seq_fps: Optional[float] = None, seq_length: Optional[int] = None, ) -> Dict[str, Any]: @@ -613,6 +695,8 @@ def add_actor_to_sequence( if seq_length is None: if animation_asset: seq_length = get_animation_length(animation_asset, seq_fps) + if motion_data: + seq_length = len(motion_data) else: seq_length = sequence.get_playback_end() @@ -635,6 +719,10 @@ def add_actor_to_sequence( if animation_asset: add_animation_to_binding(actor_binding, animation_asset, seq_length, seq_fps) + # add motion data (FK / ControlRig) + if motion_data: + add_fk_motion_to_binding(actor_binding, motion_data) + # ------- add transform track ------- # transform_track, transform_section = add_or_find_transform_track_to_binding(actor_binding) if actor_transform_keys: @@ -658,6 +746,7 @@ def add_spawnable_actor_to_sequence( actor_name: str, actor_asset: Union[unreal.SkeletalMesh, unreal.StaticMesh], animation_asset: Optional[unreal.AnimSequence] = None, + motion_data: Optional[List[MotionFrame]] = None, actor_transform_keys: Optional[Union[SequenceTransformKey, List[SequenceTransformKey]]] = None, actor_stencil_value: int = 1, seq_fps: Optional[float] = None, @@ -669,6 +758,8 @@ def add_spawnable_actor_to_sequence( if seq_length is None: if animation_asset: seq_length = get_animation_length(animation_asset, seq_fps) + if motion_data: + seq_length = len(motion_data) else: seq_length = sequence.get_playback_end() @@ -694,6 +785,10 @@ def add_spawnable_actor_to_sequence( if animation_asset: add_animation_to_binding(actor_binding, animation_asset, seq_length, seq_fps) + # add motion data (FK / ControlRig) + if motion_data: + add_fk_motion_to_binding(actor_binding, motion_data) + # ------- add transform track ------- # transform_track, transform_section = add_or_find_transform_track_to_binding(actor_binding) if actor_transform_keys: @@ -712,6 +807,35 @@ def add_spawnable_actor_to_sequence( } +def add_audio_to_sequence( + sequence: unreal.LevelSequence, + audio_asset: unreal.SoundWave, + start_frame: Optional[int] = None, + end_frame: Optional[int] = None, +) -> Dict[str, Any]: + fps = get_sequence_fps(sequence) + # ------- add audio track ------- # + audio_track: unreal.MovieSceneAudioTrack = sequence.add_track(unreal.MovieSceneAudioTrack) + audio_section: unreal.MovieSceneAudioSection = audio_track.add_section() + audio_track.set_display_name(audio_asset.get_name()) + + # ------- set start frame ------- # + if start_frame is None: + start_frame = 0 + + # ------- set end frame ------- # + if end_frame is None: + duration = audio_asset.get_editor_property('duration') + end_frame = start_frame + int(duration * fps) + audio_section.set_end_frame(end_frame=end_frame) + audio_section.set_start_frame(start_frame=start_frame) + + # ------- set audio ------- # + audio_section.set_sound(audio_asset) + + return {'audio_track': {'track': audio_track, 'section': audio_section}} + + def generate_sequence( sequence_dir: str, sequence_name: str, @@ -739,8 +863,12 @@ class Sequence: sequence_path = None sequence_data_asset: unreal.DataAsset = None # contains sequence_path and map_path sequence: unreal.LevelSequence = None + # TODO: make this work + # Currently if there's value in bindings and exited accidentally, the value will be kept and cause error bindings: Dict[str, Dict[str, Any]] = {} + START_FRAME = -1 + def __init__(self) -> NoReturn: raise Exception('Sequence (XRFeitoriaUnreal/Python) should not be instantiated') @@ -812,13 +940,16 @@ def new( if seq_dir is None: seq_dir = DEFAULT_SEQUENCE_PATH - data_asset_path = f'{seq_dir}/{seq_name}{data_asset_suffix}' - if unreal.EditorAssetLibrary.does_asset_exist(f'{seq_dir}/{seq_name}'): + seq_path = f'{seq_dir}/{seq_name}' + data_asset_path = f'{seq_path}{data_asset_suffix}' + if unreal.EditorAssetLibrary.does_asset_exist(seq_path) or unreal.EditorAssetLibrary.does_asset_exist( + data_asset_path + ): if replace: - unreal.EditorAssetLibrary.delete_asset(f'{seq_dir}/{seq_name}') + unreal.EditorAssetLibrary.delete_asset(seq_path) unreal.EditorAssetLibrary.delete_asset(data_asset_path) else: - raise Exception(f'Sequence `{seq_dir}/{seq_name}` already exists, use `replace=True` to replace it') + raise Exception(f'Sequence `{seq_path}` already exists, use `replace=True` to replace it') unreal.EditorLoadingAndSavingUtils.load_map(map_path) cls.map_path = map_path @@ -828,7 +959,7 @@ def new( seq_fps=seq_fps, seq_length=seq_length, ) - cls.sequence_path = f'{seq_dir}/{seq_name}' + cls.sequence_path = seq_path cls.sequence_data_asset = cls.new_data_asset( asset_path=data_asset_path, @@ -890,6 +1021,53 @@ def get_data_asset_info(data_asset_path: str) -> Tuple[str, str]: map_path = seq_data_asset.get_editor_property('MapPath').export_text() return seq_path.split('.')[0], map_path.split('.')[0] + @classmethod + def set_camera_cut_playback(cls, start_frame: Optional[int] = None, end_frame: Optional[int] = None) -> None: + """Set the camera cut playback. + + Args: + start_frame (Optional[int], optional): start frame of the camera cut playback. Defaults to None. + end_frame (Optional[int], optional): end frame of the camera cut playback. Defaults to None. + + Raises: + AssertionError: If the sequence is not initialized. + """ + assert cls.sequence is not None, 'Sequence not initialized' + camera_tracks = cls.sequence.find_master_tracks_by_type(unreal.MovieSceneCameraCutTrack) + for camera_track in camera_tracks: + for section in camera_track.get_sections(): + if start_frame: + section.set_start_frame(start_frame) + if end_frame: + section.set_end_frame(end_frame) + + @classmethod + def set_playback(cls, start_frame: Optional[int] = None, end_frame: Optional[int] = None) -> None: + """Set the playback range for the sequence. + + Args: + start_frame (Optional[int]): The start frame of the playback range. Defaults to None. + end_frame (Optional[int]): The end frame of the playback range. Defaults to None. + + Raises: + AssertionError: If the sequence is not initialized. + """ + assert cls.sequence is not None, 'Sequence not initialized' + master_tracks = cls.sequence.get_tracks() + + if start_frame: + cls.START_FRAME = start_frame + cls.sequence.set_playback_start(start_frame=start_frame) + for master_track in master_tracks: + for section in master_track.get_sections(): + section.set_start_frame(start_frame) + + if end_frame: + cls.sequence.set_playback_end(end_frame=end_frame) + for master_track in master_tracks: + for section in master_track.get_sections(): + section.set_end_frame(end_frame) + # ------ add actor and camera -------- # @classmethod @@ -915,7 +1093,7 @@ def add_camera( camera_transform_keys=transform_keys, camera_fov=fov, ) - cls.bindings[camera_name] = bindings + # cls.bindings[camera_name] = bindings else: camera = utils_actor.get_actor_by_name(camera_name) bindings = add_camera_to_sequence( @@ -924,7 +1102,7 @@ def add_camera( camera_transform_keys=transform_keys, camera_fov=fov, ) - cls.bindings[camera_name] = bindings + # cls.bindings[camera_name] = bindings @classmethod def add_actor( @@ -934,15 +1112,21 @@ def add_actor( transform_keys: 'Optional[TransformKeys]' = None, stencil_value: int = 1, animation_asset: 'Optional[Union[str, unreal.AnimSequence]]' = None, + motion_data: 'Optional[List[MotionFrame]]' = None, ) -> None: """Spawn an actor in sequence. Args: - actor (Union[str, unreal.Actor]): actor path (e.g. '/Game/Cube') / loaded asset (via `unreal.load_asset('/Game/Cube')`) - animation_asset (Union[str, unreal.AnimSequence]): animation path (e.g. '/Game/Anim') / loaded asset (via `unreal.load_asset('/Game/Anim')`). Can be None which means no animation. - actor_name (str, optional): Name of actor to set in sequence. Defaults to "Actor". - transform_keys (TransformKeys, optional): List of transform keys. Defaults to None. - actor_stencil_value (int, optional): Stencil value of actor, used for specifying the mask color for this actor (mask id). Defaults to 1. + actor_name (str): The name of the actor. + actor (Optional[Union[str, unreal.Actor]]): actor path (e.g. '/Game/Cube') / loaded asset (via `unreal.load_asset('/Game/Cube')`) + transform_keys (Optional[TransformKeys]): List of transform keys. Defaults to None. + stencil_value (int): Stencil value of actor, used for specifying the mask color for this actor (mask id). Defaults to 1. + animation_asset (Optional[Union[str, unreal.AnimSequence]]): animation path (e.g. '/Game/Anim') / loaded asset (via `unreal.load_asset('/Game/Anim')`). Can be None which means no animation. + motion_data (Optional[List[MotionFrame]]): The motion data used for FK animation. + + Raises: + AssertionError: If `cls.sequence` is not initialized. + AssertionError: If `animation_asset` and `motion_data` are both provided. Only one can be provided. """ assert cls.sequence is not None, 'Sequence not initialized' if animation_asset and isinstance(animation_asset, str): @@ -956,10 +1140,11 @@ def add_actor( actor_name=actor_name, actor_asset=actor, animation_asset=animation_asset, + motion_data=motion_data, actor_transform_keys=transform_keys, actor_stencil_value=stencil_value, ) - cls.bindings[actor_name] = bindings + # cls.bindings[actor_name] = bindings else: actor = utils_actor.get_actor_by_name(actor_name) @@ -969,8 +1154,34 @@ def add_actor( actor_transform_keys=transform_keys, actor_stencil_value=stencil_value, animation_asset=animation_asset, + motion_data=motion_data, ) - cls.bindings[actor_name] = bindings + # cls.bindings[actor_name] = bindings + + @classmethod + def add_audio( + cls, + audio_asset: Union[str, unreal.SoundWave], + start_frame: Optional[int] = None, + end_frame: Optional[int] = None, + ): + """Spawn an audio in sequence. + + Args: + audio_asset (Union[str, unreal.SoundWave]): audio path (e.g. '/Game/audio_sample') / loaded asset (via `unreal.load_asset('/Game/audio_sample')`) + start_frame (Optional[int], optional): start frame of the audio. Defaults to None. + end_frame (Optional[int], optional): end frame of the audio. Defaults to None. + Raises: + AssertionError: If `cls.sequence` is not initialized. + """ + assert cls.sequence is not None, 'Sequence not initialized' + if isinstance(audio_asset, str): + audio_asset = unreal.load_asset(audio_asset) + + bindings = add_audio_to_sequence( + sequence=cls.sequence, audio_asset=audio_asset, start_frame=start_frame, end_frame=end_frame + ) + # cls.bindings[audio_asset.get_name()] = bindings if __name__ == '__main__': diff --git a/src/XRFeitoriaUnreal/Content/Python/utils.py b/src/XRFeitoriaUnreal/Content/Python/utils.py index 87afb604..45b7aef1 100644 --- a/src/XRFeitoriaUnreal/Content/Python/utils.py +++ b/src/XRFeitoriaUnreal/Content/Python/utils.py @@ -68,13 +68,16 @@ def wrap_func(*args, **kwargs): # assets -def import_asset(path: Union[str, List[str]], dst_dir_in_engine: Optional[str] = None) -> List[str]: +def import_asset( + path: Union[str, List[str]], dst_dir_in_engine: Optional[str] = None, replace: bool = True +) -> List[str]: """Import assets to the default asset path. Args: path (Union[str, List[str]]): a file path or a list of file paths to import, e.g. "D:/assets/SMPL_XL.fbx" dst_dir_in_engine (str, optional): destination directory in the engine. Defaults to None falls back to DEFAULT_ASSET_PATH. + replace (bool, optional): whether to replace the existing asset. Defaults to True. Returns: List[str]: a list of paths to the imported assets, e.g. ["/Game/XRFeitoriaUnreal/Assets/SMPL_XL"] @@ -88,66 +91,80 @@ def import_asset(path: Union[str, List[str]], dst_dir_in_engine: Optional[str] = asset_paths = [] for path in paths: + assert Path(path).exists(), f'File does not exist: {path}' name = Path(path).stem - dst_dir = unreal.Paths.combine([dst_dir_in_engine, Path(path).stem]) - # check if asset exists - dst_path = unreal.Paths.combine([dst_dir, name]) - if unreal.EditorAssetLibrary.does_asset_exist(dst_path): + dst_dir = unreal.Paths.combine([dst_dir_in_engine, name]) + dst_path = unreal.Paths.combine([dst_dir, name]) # check if asset exists + if unreal.EditorAssetLibrary.does_asset_exist(dst_path) and not replace: asset_paths.append(dst_path) continue unreal.log(f'Importing asset: {path}') - # assetsTools = unreal.AssetToolsHelpers.get_asset_tools() - # assetImportData = unreal.AutomatedAssetImportData() - # assetImportData.destination_path = dst_dir - # assetImportData.filenames = [p] - # assets: List[unreal.Object] = assetsTools.import_assets_automated(assetImportData) - # asset_paths.extend([asset.get_path_name().split('.')[0] for asset in assets]) - - asset_tools = unreal.AssetToolsHelpers.get_asset_tools() - import_options = unreal.FbxImportUI() - import_options.set_editor_property('import_animations', True) - - import_task = unreal.AssetImportTask() - import_task.set_editor_property('automated', True) - import_task.set_editor_property('destination_name', '') - import_task.set_editor_property('destination_path', dst_dir) - import_task.set_editor_property('filename', path) - import_task.set_editor_property('replace_existing', True) - import_task.set_editor_property('options', import_options) - - import_tasks = [import_task] - asset_tools.import_asset_tasks(import_tasks) - asset_paths.extend([path.split('.')[0] for path in import_task.get_editor_property('imported_object_paths')]) - + if path.lower().endswith('.fbx'): + asset_tools = unreal.AssetToolsHelpers.get_asset_tools() + import_options = unreal.FbxImportUI() + import_options.set_editor_property('import_animations', True) + + import_task = unreal.AssetImportTask() + import_task.set_editor_property('automated', True) + import_task.set_editor_property('destination_name', '') + import_task.set_editor_property('destination_path', dst_dir) + import_task.set_editor_property('filename', path) + import_task.set_editor_property('replace_existing', replace) + import_task.set_editor_property('options', import_options) + + import_tasks = [import_task] + asset_tools.import_asset_tasks(import_tasks) + asset_paths.extend( + [path.partition('.')[0] for path in import_task.get_editor_property('imported_object_paths')] + ) + else: + assetsTools = unreal.AssetToolsHelpers.get_asset_tools() + assetImportData = unreal.AutomatedAssetImportData() + assetImportData.destination_path = dst_dir + assetImportData.filenames = [path] + assetImportData.replace_existing = replace + assets: List[unreal.Object] = assetsTools.import_assets_automated(assetImportData) + asset_paths.extend([asset.get_path_name().partition('.')[0] for asset in assets]) unreal.EditorAssetLibrary.save_directory(dst_dir, False, True) # save assets unreal.log(f'Imported asset: {path}') return asset_paths -def import_anim(path: str, skeleton_path: str) -> List[str]: +def import_anim(path: str, skeleton_path: str, dst_dir: Optional[str] = None, replace: bool = True) -> List[str]: """Import animation to the default asset path. Args: path (str): a file path to import, e.g. "D:/assets/SMPL_XL.fbx" skeleton_path (str): a path to the skeleton, e.g. "/Game/XRFeitoriaUnreal/Assets/SMPL_XL" + dst_dir (str, optional): destination directory in the engine. Defaults to None falls back to {skeleton_path.parent}/Animation. + replace (bool, optional): whether to replace the existing asset. Defaults to True. Returns: str: a path to the imported animation, e.g. "/Game/XRFeitoriaUnreal/Assets/SMPL_XL" """ + assert Path(path).exists(), f'File does not exist: {path}' # init task import_task = unreal.AssetImportTask() import_task.set_editor_property('filename', path) + # set destination path to {skeleton_path}/Animation - dst_path = unreal.Paths.combine([unreal.Paths.get_path(skeleton_path), 'Animation']) - import_task.set_editor_property('destination_path', dst_path) - import_task.set_editor_property('replace_existing', True) - import_task.set_editor_property('replace_existing_settings', True) + if dst_dir is None: + dst_dir = unreal.Paths.combine([unreal.Paths.get_path(skeleton_path), 'Animation']) + dst_path = unreal.Paths.combine([dst_dir, Path(path).stem]) + # check if asset exists + if unreal.EditorAssetLibrary.does_asset_exist(dst_path) and not replace: + return [dst_path] + + import_task.set_editor_property('destination_path', dst_dir) + import_task.set_editor_property('replace_existing', replace) + import_task.set_editor_property('replace_existing_settings', replace) import_task.set_editor_property('automated', True) # options for importing animation options = unreal.FbxImportUI() options.mesh_type_to_import = unreal.FBXImportType.FBXIT_ANIMATION options.skeleton = unreal.load_asset(skeleton_path) + options.import_animations = True import_data = unreal.FbxAnimSequenceImportData() import_data.set_editor_properties( { @@ -161,7 +178,7 @@ def import_anim(path: str, skeleton_path: str) -> List[str]: unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([import_task]) # save assets - unreal.EditorAssetLibrary.save_directory(dst_path, False, True) + unreal.EditorAssetLibrary.save_directory(dst_dir, False, True) # return paths return [path.split('.')[0] for path in import_task.get_editor_property('imported_object_paths')] diff --git a/src/XRFeitoriaUnreal/Content/Python/utils_actor.py b/src/XRFeitoriaUnreal/Content/Python/utils_actor.py index 1b7289d7..3c3bf56b 100644 --- a/src/XRFeitoriaUnreal/Content/Python/utils_actor.py +++ b/src/XRFeitoriaUnreal/Content/Python/utils_actor.py @@ -15,7 +15,7 @@ 'rgb': Tuple[int, int, int], }, ) -mask_colors: List[color_type] = json.load((root / 'data' / 'mask_colors.json').open()) +mask_colors: List[color_type] = json.loads((root / 'data' / 'mask_colors.json').read_text()) def get_stencil_value(actor: unreal.Actor) -> int: diff --git a/src/XRFeitoriaUnreal/Source/XRFeitoriaUnreal/Private/Annotator.cpp b/src/XRFeitoriaUnreal/Source/XRFeitoriaUnreal/Private/Annotator.cpp index 5ce05dc6..c239aab9 100644 --- a/src/XRFeitoriaUnreal/Source/XRFeitoriaUnreal/Private/Annotator.cpp +++ b/src/XRFeitoriaUnreal/Source/XRFeitoriaUnreal/Private/Annotator.cpp @@ -1,5 +1,4 @@ -// Fill out your copyright notice in the Description page of Project Settings. - +// Copyright OpenXRLab 2023-2024. All Rights Reserved. #include "Annotator.h" diff --git a/src/XRFeitoriaUnreal/Source/XRFeitoriaUnreal/Private/CustomMoviePipelineDeferredPass.cpp b/src/XRFeitoriaUnreal/Source/XRFeitoriaUnreal/Private/CustomMoviePipelineDeferredPass.cpp index 577e2b42..4722f5f5 100644 --- a/src/XRFeitoriaUnreal/Source/XRFeitoriaUnreal/Private/CustomMoviePipelineDeferredPass.cpp +++ b/src/XRFeitoriaUnreal/Source/XRFeitoriaUnreal/Private/CustomMoviePipelineDeferredPass.cpp @@ -1,5 +1,4 @@ -// Fill out your copyright notice in the Description page of Project Settings. - +// Copyright OpenXRLab 2023-2024. All Rights Reserved. #include "CustomMoviePipelineDeferredPass.h" #include "CustomMoviePipelineOutput.h" diff --git a/src/XRFeitoriaUnreal/Source/XRFeitoriaUnreal/Private/CustomMoviePipelineOutput.cpp b/src/XRFeitoriaUnreal/Source/XRFeitoriaUnreal/Private/CustomMoviePipelineOutput.cpp index 883607c1..8220f5a9 100644 --- a/src/XRFeitoriaUnreal/Source/XRFeitoriaUnreal/Private/CustomMoviePipelineOutput.cpp +++ b/src/XRFeitoriaUnreal/Source/XRFeitoriaUnreal/Private/CustomMoviePipelineOutput.cpp @@ -1,5 +1,4 @@ -// Fill out your copyright notice in the Description page of Project Settings. - +// Copyright OpenXRLab 2023-2024. All Rights Reserved. #include "CustomMoviePipelineOutput.h" @@ -13,10 +12,11 @@ #include "Misc/StringFormatArg.h" #include "Misc/FileHelper.h" #include "Misc/FrameRate.h" -#include "HAL/PlatformFilemanager.h" -#include "HAL/PlatformTime.h" #include "Misc/Paths.h" +// #include "HAL/PlatformFilemanager.h" +// #include "HAL/PlatformTime.h" + #include "Camera/CameraActor.h" #include "Camera/CameraComponent.h" #include "Engine/StaticMeshActor.h" diff --git a/src/XRFeitoriaUnreal/Source/XRFeitoriaUnreal/Private/MoviePipelineMeshOperator.cpp b/src/XRFeitoriaUnreal/Source/XRFeitoriaUnreal/Private/MoviePipelineMeshOperator.cpp index c96de843..b491c499 100644 --- a/src/XRFeitoriaUnreal/Source/XRFeitoriaUnreal/Private/MoviePipelineMeshOperator.cpp +++ b/src/XRFeitoriaUnreal/Source/XRFeitoriaUnreal/Private/MoviePipelineMeshOperator.cpp @@ -1,10 +1,11 @@ -// Fill out your copyright notice in the Description page of Project Settings. - +// Copyright OpenXRLab 2023-2024. All Rights Reserved. #include "MoviePipelineMeshOperator.h" #include "XF_BlueprintFunctionLibrary.h" #include "Engine/StaticMeshActor.h" #include "Animation/SkeletalMeshActor.h" +#include "LevelSequenceEditorBlueprintLibrary.h" +#include "MovieSceneObjectBindingID.h" #include "Camera/CameraActor.h" #include "Camera/CameraComponent.h" #include "Misc/FileHelper.h" @@ -21,86 +22,103 @@ void UMoviePipelineMeshOperator::SetupForPipelineImpl(UMoviePipeline* InPipeline ULevelSequence* LevelSequence = GetPipeline()->GetTargetSequence(); UMovieSceneSequence* MovieSceneSequence = GetPipeline()->GetTargetSequence(); UMovieScene* MovieScene = LevelSequence->GetMovieScene(); - TArray bindings = MovieScene->GetBindings(); - TArray bindingProxies; - for (FMovieSceneBinding binding : bindings) + TMap bindingMap; + for (int idx = 0; idx < MovieScene->GetSpawnableCount(); idx++) { - FGuid guid = binding.GetObjectGuid(); - bindingProxies.Add(FSequencerBindingProxy(guid, MovieSceneSequence)); + FMovieSceneSpawnable spawnable = MovieScene->GetSpawnable(idx); + FGuid guid = spawnable.GetGuid(); + FString name = spawnable.GetName(); + + bindingMap.Add(name, guid); } - boundObjects = USequencerToolsFunctionLibrary::GetBoundObjects( - GetPipeline()->GetWorld(), - LevelSequence, - bindingProxies, - FSequencerScriptingRange::FromNative( - MovieScene->GetPlaybackRange(), - MovieScene->GetDisplayRate() - ) - ); + for (int idx = 0; idx < MovieScene->GetPossessableCount(); idx++) + { + FMovieScenePossessable possessable = MovieScene->GetPossessable(idx); + FGuid guid = possessable.GetGuid(); + FString name = possessable.GetName(); + + bindingMap.Add(name, guid); + } - for (FSequencerBoundObjects boundObject : boundObjects) + for (TPair pair : bindingMap) { - // loop over bound objects - UObject* BoundObject = boundObject.BoundObjects[0]; // only have one item + FString name = pair.Key; + FGuid guid = pair.Value; + + TArray _boundObjects_ = USequencerToolsFunctionLibrary::GetBoundObjects( + GetPipeline()->GetWorld(), + LevelSequence, + TArray({ FSequencerBindingProxy(guid, MovieSceneSequence) }), + FSequencerScriptingRange::FromNative( + MovieScene->GetPlaybackRange(), + MovieScene->GetDisplayRate() + ) + ); + + UObject* BoundObject = _boundObjects_[0].BoundObjects[0]; // only have one item if (BoundObject->IsA(ASkeletalMeshActor::StaticClass())) { ASkeletalMeshActor* SkeletalMeshActor = Cast(BoundObject); - SkeletalMeshComponents.Add(SkeletalMeshActor->GetSkeletalMeshComponent()); + SkeletalMeshComponents.Add(name, SkeletalMeshActor->GetSkeletalMeshComponent()); } else if (BoundObject->IsA(AStaticMeshActor::StaticClass())) { AStaticMeshActor* StaticMeshActor = Cast(BoundObject); - StaticMeshComponents.Add(StaticMeshActor->GetStaticMeshComponent()); - } - else if (BoundObject->IsA(USkeletalMeshComponent::StaticClass())) - { - USkeletalMeshComponent* SkeletalMeshComponent = Cast(BoundObject); - // check if it's already in the list - bool bFound = false; - for (USkeletalMeshComponent* SkeletalMeshComponentInList : SkeletalMeshComponents) - { - if (SkeletalMeshComponentInList == SkeletalMeshComponent) - { - bFound = true; - break; - } - } - if (!bFound) SkeletalMeshComponents.Add(SkeletalMeshComponent); - } - else if (BoundObject->IsA(UStaticMeshComponent::StaticClass())) - { - UStaticMeshComponent* StaticMeshComponent = Cast(BoundObject); - // check if it's already in the list - bool bFound = false; - for (UStaticMeshComponent* StaticMeshComponentInList : StaticMeshComponents) - { - if (StaticMeshComponentInList == StaticMeshComponent) - { - bFound = true; - break; - } - } - if (!bFound) - StaticMeshComponents.Add(StaticMeshComponent); + StaticMeshComponents.Add(name, StaticMeshActor->GetStaticMeshComponent()); } + //else if (BoundObject->IsA(USkeletalMeshComponent::StaticClass())) + //{ + // USkeletalMeshComponent* SkeletalMeshComponent = Cast(BoundObject); + // // check if it's already in the list + // bool bFound = false; + // for (TPair SKMPair : SkeletalMeshComponents) + // { + // USkeletalMeshComponent* SkeletalMeshComponentInList = SKMPair.Value; + // if (SkeletalMeshComponentInList == SkeletalMeshComponent) + // { + // bFound = true; + // break; + // } + // } + // if (!bFound) SkeletalMeshComponents.Add(name, SkeletalMeshComponent); + //} + //else if (BoundObject->IsA(UStaticMeshComponent::StaticClass())) + //{ + // UStaticMeshComponent* StaticMeshComponent = Cast(BoundObject); + // // check if it's already in the list + // bool bFound = false; + // for (TPair SKMPair : StaticMeshComponents) + // { + // UStaticMeshComponent* StaticMeshComponentInList = SKMPair.Value; + // if (StaticMeshComponentInList == StaticMeshComponent) + // { + // bFound = true; + // break; + // } + // } + // if (!bFound) StaticMeshComponents.Add(name, StaticMeshComponent); + //} } } void UMoviePipelineMeshOperator::OnReceiveImageDataImpl(FMoviePipelineMergerOutputFrame* InMergedOutputFrame) { - for (USkeletalMeshComponent* SkeletalMeshComponent : SkeletalMeshComponents) + for (TPair SKMPair : SkeletalMeshComponents) { // loop over Skeletal mesh components if (!SkeletalMeshOperatorOption.bEnabled) continue; - // Actor in level - FString MeshNameFromLabel = SkeletalMeshComponent->GetOwner()->GetActorNameOrLabel(); - // Actor spawned from sequence - FString MeshNameFromName = SkeletalMeshComponent->GetOwner()->GetFName().GetPlainNameString(); - // Judge which name is correct - FString MeshName = MeshNameFromName.StartsWith("SkeletalMesh") ? MeshNameFromLabel : MeshNameFromName; + FString MeshName = SKMPair.Key; + USkeletalMeshComponent* SkeletalMeshComponent = SKMPair.Value; + + //// Actor in level + //FString MeshNameFromLabel = SkeletalMeshComponent->GetOwner()->GetActorNameOrLabel(); + //// Actor spawned from sequence + //FString MeshNameFromName = SkeletalMeshComponent->GetOwner()->GetFName().GetPlainNameString(); + //// Judge which name is correct + //FString MeshName = MeshNameFromName.StartsWith("SkeletalMesh") ? MeshNameFromLabel : MeshNameFromName; if (SkeletalMeshOperatorOption.bSaveVerticesPosition) { @@ -198,17 +216,20 @@ void UMoviePipelineMeshOperator::OnReceiveImageDataImpl(FMoviePipelineMergerOutp // // TODO: export to npz //} } - for (UStaticMeshComponent* StaticMeshComponent : StaticMeshComponents) + for (TPair SKMPair : StaticMeshComponents) { // loop over static mesh components if (!StaticMeshOperatorOption.bEnabled) continue; - // Actor in level - FString MeshNameFromLabel = StaticMeshComponent->GetOwner()->GetActorNameOrLabel(); - // Actor spawned from sequence - FString MeshNameFromName = StaticMeshComponent->GetOwner()->GetFName().GetPlainNameString(); - // Judge which name is correct - FString MeshName = MeshNameFromName.StartsWith("StaticMesh") ? MeshNameFromLabel : MeshNameFromName; + UStaticMeshComponent* StaticMeshComponent = SKMPair.Value; + FString MeshName = SKMPair.Key; + + //// Actor in level + //FString MeshNameFromLabel = StaticMeshComponent->GetOwner()->GetActorNameOrLabel(); + //// Actor spawned from sequence + //FString MeshNameFromName = StaticMeshComponent->GetOwner()->GetFName().GetPlainNameString(); + //// Judge which name is correct + //FString MeshName = MeshNameFromName.StartsWith("StaticMesh") ? MeshNameFromLabel : MeshNameFromName; if (StaticMeshOperatorOption.bSaveVerticesPosition) { @@ -236,6 +257,7 @@ void UMoviePipelineMeshOperator::OnReceiveImageDataImpl(FMoviePipelineMergerOutp StaticMeshOperatorOption.DirectoryVertices / MeshName, "dat", &InMergedOutputFrame->FrameOutputState)); } } + if (bIsFirstFrame) bIsFirstFrame = false; } diff --git a/src/XRFeitoriaUnreal/Source/XRFeitoriaUnreal/Private/XF_BlueprintFunctionLibrary.cpp b/src/XRFeitoriaUnreal/Source/XRFeitoriaUnreal/Private/XF_BlueprintFunctionLibrary.cpp index 020040e3..1cd89d7e 100644 --- a/src/XRFeitoriaUnreal/Source/XRFeitoriaUnreal/Private/XF_BlueprintFunctionLibrary.cpp +++ b/src/XRFeitoriaUnreal/Source/XRFeitoriaUnreal/Private/XF_BlueprintFunctionLibrary.cpp @@ -1,5 +1,4 @@ -// Fill out your copyright notice in the Description page of Project Settings. - +// Copyright OpenXRLab 2023-2024. All Rights Reserved. #include "XF_BlueprintFunctionLibrary.h" diff --git a/src/XRFeitoriaUnreal/Source/XRFeitoriaUnreal/Private/XRFeitoriaUnreal.cpp b/src/XRFeitoriaUnreal/Source/XRFeitoriaUnreal/Private/XRFeitoriaUnreal.cpp index 765153bc..3327cead 100644 --- a/src/XRFeitoriaUnreal/Source/XRFeitoriaUnreal/Private/XRFeitoriaUnreal.cpp +++ b/src/XRFeitoriaUnreal/Source/XRFeitoriaUnreal/Private/XRFeitoriaUnreal.cpp @@ -1,4 +1,4 @@ -// Copyright Epic Games, Inc. All Rights Reserved. +// Copyright OpenXRLab 2023-2024. All Rights Reserved. #include "XRFeitoriaUnreal.h" #include "Engine/RendererSettings.h" @@ -8,12 +8,12 @@ #include "CustomMoviePipelineOutput.h" #include "CustomMoviePipelineDeferredPass.h" -#define LOCTEXT_NAMESPACE "FXRFeitoriaGearModule" +#define LOCTEXT_NAMESPACE "FXRFeitoriaUnrealModule" void FXRFeitoriaUnrealModule::StartupModule() { // This code will execute after your module is loaded into memory; the exact timing is specified in the .uplugin file per-module - UE_LOG(LogTemp, Log, TEXT( "XRFeitoriaGear Loaded. Doing initialization." )); + UE_LOG(LogTemp, Log, TEXT( "XRFeitoriaUnreal Loaded. Doing initialization." )); URendererSettings* Settings = GetMutableDefault(); #if ENGINE_MAJOR_VERSION == 5 diff --git a/src/XRFeitoriaUnreal/Source/XRFeitoriaUnreal/Public/Annotator.h b/src/XRFeitoriaUnreal/Source/XRFeitoriaUnreal/Public/Annotator.h index 8e26d20f..e94377c4 100644 --- a/src/XRFeitoriaUnreal/Source/XRFeitoriaUnreal/Public/Annotator.h +++ b/src/XRFeitoriaUnreal/Source/XRFeitoriaUnreal/Public/Annotator.h @@ -1,4 +1,4 @@ -// Fill out your copyright notice in the Description page of Project Settings. +// Copyright OpenXRLab 2023-2024. All Rights Reserved. #pragma once diff --git a/src/XRFeitoriaUnreal/Source/XRFeitoriaUnreal/Public/CustomMoviePipelineDeferredPass.h b/src/XRFeitoriaUnreal/Source/XRFeitoriaUnreal/Public/CustomMoviePipelineDeferredPass.h index e6ffaa8f..1963517b 100644 --- a/src/XRFeitoriaUnreal/Source/XRFeitoriaUnreal/Public/CustomMoviePipelineDeferredPass.h +++ b/src/XRFeitoriaUnreal/Source/XRFeitoriaUnreal/Public/CustomMoviePipelineDeferredPass.h @@ -1,4 +1,4 @@ -// Fill out your copyright notice in the Description page of Project Settings. +// Copyright OpenXRLab 2023-2024. All Rights Reserved. #pragma once diff --git a/src/XRFeitoriaUnreal/Source/XRFeitoriaUnreal/Public/CustomMoviePipelineOutput.h b/src/XRFeitoriaUnreal/Source/XRFeitoriaUnreal/Public/CustomMoviePipelineOutput.h index c0a91a49..136e76c1 100644 --- a/src/XRFeitoriaUnreal/Source/XRFeitoriaUnreal/Public/CustomMoviePipelineOutput.h +++ b/src/XRFeitoriaUnreal/Source/XRFeitoriaUnreal/Public/CustomMoviePipelineOutput.h @@ -1,4 +1,4 @@ -// Fill out your copyright notice in the Description page of Project Settings. +// Copyright OpenXRLab 2023-2024. All Rights Reserved. #pragma once @@ -42,12 +42,13 @@ THIRD_PARTY_INCLUDES_END #include "ImageWriteTask.h" #include "ImagePixelData.h" -#include "HAL/PlatformFilemanager.h" -#include "HAL/FileManager.h" +// #include "HAL/PlatformFilemanager.h" +// #include "HAL/FileManager.h" +// #include "HAL/PlatformTime.h" + #include "Misc/FileHelper.h" #include "Async/Async.h" #include "Misc/Paths.h" -#include "HAL/PlatformTime.h" #include "Math/Float16.h" #include "MovieRenderPipelineCoreModule.h" #include "MoviePipelineOutputSetting.h" @@ -105,7 +106,7 @@ struct XRFEITORIAUNREAL_API FCustomMoviePipelineRenderPass // UMaterialInterface* Material; UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Render Pass") - ECustomImageFormat Extension; + ECustomImageFormat Extension = ECustomImageFormat::PNG; FString SPassName; diff --git a/src/XRFeitoriaUnreal/Source/XRFeitoriaUnreal/Public/MoviePipelineMeshOperator.h b/src/XRFeitoriaUnreal/Source/XRFeitoriaUnreal/Public/MoviePipelineMeshOperator.h index bcc5e398..af17eb01 100644 --- a/src/XRFeitoriaUnreal/Source/XRFeitoriaUnreal/Public/MoviePipelineMeshOperator.h +++ b/src/XRFeitoriaUnreal/Source/XRFeitoriaUnreal/Public/MoviePipelineMeshOperator.h @@ -1,4 +1,4 @@ -// Fill out your copyright notice in the Description page of Project Settings. +// Copyright OpenXRLab 2023-2024. All Rights Reserved. #pragma once @@ -6,7 +6,7 @@ #include "MoviePipelineOutputSetting.h" #include "MoviePipelineMasterConfig.h" #include "Misc/FileHelper.h" -#include "HAL/PlatformFilemanager.h" +// #include "HAL/PlatformFilemanager.h" #if WITH_EDITOR #include "MovieSceneExportMetadata.h" @@ -101,8 +101,7 @@ class XRFEITORIAUNREAL_API UMoviePipelineMeshOperator : public UMoviePipelineOut FSkeletalMeshOperatorOption SkeletalMeshOperatorOption = FSkeletalMeshOperatorOption(); private: - TArray boundObjects; - TArray StaticMeshComponents; - TArray SkeletalMeshComponents; + TMap StaticMeshComponents; + TMap SkeletalMeshComponents; bool bIsFirstFrame = true; }; diff --git a/src/XRFeitoriaUnreal/Source/XRFeitoriaUnreal/Public/XF_BlueprintFunctionLibrary.h b/src/XRFeitoriaUnreal/Source/XRFeitoriaUnreal/Public/XF_BlueprintFunctionLibrary.h index 2c8cf495..41910f62 100644 --- a/src/XRFeitoriaUnreal/Source/XRFeitoriaUnreal/Public/XF_BlueprintFunctionLibrary.h +++ b/src/XRFeitoriaUnreal/Source/XRFeitoriaUnreal/Public/XF_BlueprintFunctionLibrary.h @@ -1,4 +1,4 @@ -// Fill out your copyright notice in the Description page of Project Settings. +// Copyright OpenXRLab 2023-2024. All Rights Reserved. #pragma once #include "Serialization/Archive.h" diff --git a/src/XRFeitoriaUnreal/Source/XRFeitoriaUnreal/Public/XRFeitoriaUnreal.h b/src/XRFeitoriaUnreal/Source/XRFeitoriaUnreal/Public/XRFeitoriaUnreal.h index b21a8ba4..ad6d3d3a 100644 --- a/src/XRFeitoriaUnreal/Source/XRFeitoriaUnreal/Public/XRFeitoriaUnreal.h +++ b/src/XRFeitoriaUnreal/Source/XRFeitoriaUnreal/Public/XRFeitoriaUnreal.h @@ -1,4 +1,4 @@ -// Copyright Epic Games, Inc. All Rights Reserved. +// Copyright OpenXRLab 2023-2024. All Rights Reserved. #pragma once diff --git a/src/XRFeitoriaUnreal/Source/XRFeitoriaUnreal/XRFeitoriaUnreal.Build.cs b/src/XRFeitoriaUnreal/Source/XRFeitoriaUnreal/XRFeitoriaUnreal.Build.cs index ffad6ba7..a0ea0481 100644 --- a/src/XRFeitoriaUnreal/Source/XRFeitoriaUnreal/XRFeitoriaUnreal.Build.cs +++ b/src/XRFeitoriaUnreal/Source/XRFeitoriaUnreal/XRFeitoriaUnreal.Build.cs @@ -1,4 +1,4 @@ -// Copyright Epic Games, Inc. All Rights Reserved. +// Copyright OpenXRLab 2023-2024. All Rights Reserved. using UnrealBuildTool; @@ -45,6 +45,7 @@ public XRFeitoriaUnreal(ReadOnlyTargetRules Target) : base(Target) "MovieSceneTools", "MovieSceneTracks", "LevelSequence", + "LevelSequenceEditor", "SequencerScripting", "SequencerScriptingEditor", } diff --git a/src/XRFeitoriaUnreal/XRFeitoriaUnreal.uplugin b/src/XRFeitoriaUnreal/XRFeitoriaUnreal.uplugin index 77b63de2..ba5df286 100644 --- a/src/XRFeitoriaUnreal/XRFeitoriaUnreal.uplugin +++ b/src/XRFeitoriaUnreal/XRFeitoriaUnreal.uplugin @@ -1,15 +1,15 @@ { "FileVersion": 3, "Version": 1, - "VersionName": "0.5.1", + "VersionName": "0.6.0", "FriendlyName": "XRFeitoriaUnreal", "Description": "OpenXRLab Synthetic Data Rendering Toolbox", "Category": "Scripting", "CreatedBy": "OpenXRLab", - "CreatedByURL": "", + "CreatedByURL": "https://openxrlab.org.cn", "DocsURL": "https://xrfeitoria.readthedocs.io/en/latest/", - "MarketplaceURL": "", - "SupportURL": "", + "SupportURL": "https://xrfeitoria.readthedocs.io/en/latest/", + "MarketplaceURL": "com.epicgames.launcher://ue/marketplace/product/ace631ac6e664dd281ab66bcae56ad55", "CanContainContent": true, "IsBetaVersion": false, "IsExperimentalVersion": false, @@ -17,8 +17,9 @@ "Modules": [ { "Name": "XRFeitoriaUnreal", - "Type": "Runtime", - "LoadingPhase": "Default" + "Type": "Editor", + "LoadingPhase": "Default", + "WhitelistPlatforms":["Win64", "Mac", "Linux"] } ], "Plugins": [ @@ -35,8 +36,12 @@ "Enabled": true }, { - "Name": "SequencerScripting", + "Name": "PythonFoundationPackages", "Enabled": true }, + { + "Name": "SequencerScripting", + "Enabled": true + } ] } diff --git a/tests/blender/actor.py b/tests/blender/actor.py index a644a191..1ae7fd44 100644 --- a/tests/blender/actor.py +++ b/tests/blender/actor.py @@ -7,18 +7,19 @@ from xrfeitoria.data_structure.constants import xf_obj_name from xrfeitoria.data_structure.models import SequenceTransformKey as SeqTransKey +from xrfeitoria.utils import setup_logger from ..config import assets_path -from ..utils import __timer__, _init_blender, setup_logger +from ..utils import __timer__, _init_blender -root = Path(__file__).parents[2].resolve() +root = Path(__file__).resolve().parents[2] # output_path = '~/xrfeitoria/output/tests/blender/{file_name}' output_path = root / 'output' / Path(__file__).relative_to(root).with_suffix('') bunny_obj = assets_path['bunny'] def actor_test(debug: bool = False, background: bool = False): - logger = setup_logger(debug=debug) + logger = setup_logger(level='DEBUG' if debug else 'INFO') with _init_blender(background=background) as xf_runner: with __timer__('import_actor'): actor = xf_runner.Actor.import_from_file( diff --git a/tests/blender/camera.py b/tests/blender/camera.py index e93d1679..0a033d99 100644 --- a/tests/blender/camera.py +++ b/tests/blender/camera.py @@ -6,16 +6,17 @@ import numpy as np from xrfeitoria.data_structure.constants import xf_obj_name +from xrfeitoria.utils import setup_logger -from ..utils import __timer__, _init_blender, setup_logger +from ..utils import __timer__, _init_blender -root = Path(__file__).parents[2].resolve() +root = Path(__file__).resolve().parents[2] # output_path = '~/xrfeitoria/output/tests/blender/{file_name}' output_path = root / 'output' / Path(__file__).relative_to(root).with_suffix('') def camera_test(debug: bool = False, background: bool = False): - logger = setup_logger(debug=debug) + logger = setup_logger(level='DEBUG' if debug else 'INFO') with _init_blender(background=background) as xf_runner: with __timer__('spawn camera'): ## test spawn camera diff --git a/tests/blender/init.py b/tests/blender/init.py index aa096f04..ae905dc9 100644 --- a/tests/blender/init.py +++ b/tests/blender/init.py @@ -2,11 +2,10 @@ >>> python -m tests.blender.init """ -from loguru import logger - from xrfeitoria.rpc import remote_blender +from xrfeitoria.utils import setup_logger -from ..utils import __timer__, _init_blender, setup_logger +from ..utils import __timer__, _init_blender @remote_blender() @@ -16,7 +15,7 @@ def test_blender(): def init_test(debug: bool = False, dev: bool = False, background: bool = False): - setup_logger(debug=debug) + logger = setup_logger(level='DEBUG' if debug else 'INFO') with __timer__('init_blender'): with _init_blender(dev_plugin=dev, background=background) as xf_runner: test_blender() diff --git a/tests/blender/level.py b/tests/blender/level.py index 02e55cbc..edf165c3 100644 --- a/tests/blender/level.py +++ b/tests/blender/level.py @@ -3,16 +3,15 @@ """ from pathlib import Path -from loguru import logger - import xrfeitoria as xf from xrfeitoria.data_structure.models import RenderPass from xrfeitoria.factory import XRFeitoriaBlender +from xrfeitoria.utils import setup_logger from ..config import assets_path -from ..utils import __timer__, _init_blender, setup_logger +from ..utils import __timer__, _init_blender -root = Path(__file__).parents[2].resolve() +root = Path(__file__).resolve().parents[2] # output_path = '~/xrfeitoria/output/tests/blender/{file_name}' output_path = root / 'output' / Path(__file__).relative_to(root).with_suffix('') @@ -43,7 +42,7 @@ def seq_simple(xf_runner: XRFeitoriaBlender, seq_name: str = 'seq_simple'): def level_test(debug=False, background=False): - setup_logger(debug=debug) + logger = setup_logger(level='DEBUG' if debug else 'INFO') with _init_blender(background=background, new_process=True, cleanup=False, project_path=blend_sample) as xf_runner: with __timer__('create and sequence'): seq_simple(xf_runner) diff --git a/tests/blender/main.py b/tests/blender/main.py index 75d56ba8..98f8e566 100644 --- a/tests/blender/main.py +++ b/tests/blender/main.py @@ -3,22 +3,25 @@ """ from pathlib import Path -from ..utils import _init_blender, setup_logger +from xrfeitoria.utils import setup_logger + +from ..utils import _init_blender from .actor import actor_test from .camera import camera_test from .init import init_test from .level import level_test from .sequence import sequence_test -root = Path(__file__).parents[2].resolve() +root = Path(__file__).resolve().parents[2] # output_path = '~/xrfeitoria/output/tests/blender' output_path = root / 'output' / Path(__file__).parent.relative_to(root) def main(debug: bool = False, background: bool = False): - logger = setup_logger(debug=debug, log_path=output_path / 'blender.log') + logger = setup_logger(level='DEBUG' if debug else 'INFO', log_path=output_path / 'blender.log') + + init_test(debug=debug, background=background, dev=True) with _init_blender(background=background) as xf_runner: - init_test(debug=debug) actor_test(debug=debug) camera_test(debug=debug) sequence_test(debug=debug) diff --git a/tests/blender/sequence.py b/tests/blender/sequence.py index 08862f95..17839e6c 100644 --- a/tests/blender/sequence.py +++ b/tests/blender/sequence.py @@ -9,11 +9,12 @@ from xrfeitoria.data_structure.models import RenderPass from xrfeitoria.data_structure.models import SequenceTransformKey as SeqTransKey from xrfeitoria.factory import XRFeitoriaBlender +from xrfeitoria.utils import setup_logger from ..config import assets_path -from ..utils import __timer__, _init_blender, setup_logger +from ..utils import __timer__, _init_blender -root = Path(__file__).parents[2].resolve() +root = Path(__file__).resolve().parents[2] # output_path = '~/xrfeitoria/output/tests/blender/{file_name}' output_path = root / 'output' / Path(__file__).relative_to(root).with_suffix('') @@ -130,7 +131,7 @@ def seq_shape(xf_runner: XRFeitoriaBlender, seq_name='seq_shape'): def sequence_test(debug=False, background=False): - logger = setup_logger(debug=debug) + setup_logger(level='DEBUG' if debug else 'INFO') with _init_blender(background=background) as xf_runner: with __timer__('seq_actor'): seq_actor(xf_runner, seq_name='seq_actor') diff --git a/tests/unreal/actor.py b/tests/unreal/actor.py index bdc47120..144fe2c4 100644 --- a/tests/unreal/actor.py +++ b/tests/unreal/actor.py @@ -5,9 +5,10 @@ from loguru import logger from xrfeitoria.data_structure.constants import xf_obj_name +from xrfeitoria.utils import setup_logger from ..config import assets_path -from ..utils import __timer__, _init_unreal, setup_logger +from ..utils import __timer__, _init_unreal bunny_obj = assets_path['bunny'] kc_fbx = assets_path['koupen_chan'] @@ -15,7 +16,7 @@ def actor_test(debug: bool = False, background: bool = False): - setup_logger(debug=debug) + setup_logger(level='DEBUG' if debug else 'INFO') with _init_unreal(background=background) as xf_runner: with __timer__('import actor'): kc_path = xf_runner.utils.import_asset(path=kc_fbx) diff --git a/tests/unreal/camera.py b/tests/unreal/camera.py index 696be38f..1d96eb34 100644 --- a/tests/unreal/camera.py +++ b/tests/unreal/camera.py @@ -5,12 +5,13 @@ import numpy as np from xrfeitoria.data_structure.constants import xf_obj_name +from xrfeitoria.utils import setup_logger -from ..utils import __timer__, _init_unreal, setup_logger +from ..utils import __timer__, _init_unreal def camera_test(debug: bool = False, background: bool = False): - logger = setup_logger(debug=debug) + logger = setup_logger(level='DEBUG' if debug else 'INFO') with _init_unreal(background=background) as xf_runner: with __timer__('spawn camera'): camera0 = xf_runner.Camera.spawn() diff --git a/tests/unreal/init.py b/tests/unreal/init.py index fd25eb8d..513be0b5 100644 --- a/tests/unreal/init.py +++ b/tests/unreal/init.py @@ -2,11 +2,10 @@ >>> python -m tests.unreal.init_test """ -from loguru import logger - from xrfeitoria.rpc import remote_unreal +from xrfeitoria.utils import setup_logger -from ..utils import __timer__, _init_unreal, setup_logger +from ..utils import __timer__, _init_unreal @remote_unreal() @@ -16,7 +15,7 @@ def test_unreal(): def init_test(debug: bool = False, dev: bool = False, background: bool = False): - setup_logger(debug=debug) + logger = setup_logger(level='DEBUG' if debug else 'INFO') with __timer__('init unreal'): with _init_unreal(dev_plugin=dev, background=background) as xf_runner: test_unreal() diff --git a/tests/unreal/main.py b/tests/unreal/main.py index 001046b1..408a9da8 100644 --- a/tests/unreal/main.py +++ b/tests/unreal/main.py @@ -3,7 +3,9 @@ """ from pathlib import Path -from ..utils import _init_unreal, setup_logger +from xrfeitoria.utils import setup_logger + +from ..utils import _init_unreal from .actor import actor_test from .camera import camera_test from .init import init_test @@ -12,9 +14,10 @@ root = Path(__file__).parent -def main(debug: bool = False, background: bool = False): - logger = setup_logger(debug=debug, log_path=root / 'unreal.log') - with _init_unreal(background=background) as xf_runner: +def main(debug: bool = False, dev: bool = False, background: bool = False): + logger = setup_logger(level='DEBUG' if debug else 'INFO', log_path=root / 'unreal.log') + + with _init_unreal(background=background, dev_plugin=dev) as xf_runner: init_test(debug=debug, background=background) actor_test(debug=debug, background=background) camera_test(debug=debug, background=background) @@ -28,6 +31,7 @@ def main(debug: bool = False, background: bool = False): args = argparse.ArgumentParser() args.add_argument('--debug', action='store_true') + args.add_argument('--dev', action='store_true') args = args.parse_args() - main(debug=args.debug) + main(debug=args.debug, dev=args.dev) diff --git a/tests/unreal/sequence.py b/tests/unreal/sequence.py index f70a9f97..32118ed5 100644 --- a/tests/unreal/sequence.py +++ b/tests/unreal/sequence.py @@ -7,11 +7,12 @@ from xrfeitoria.data_structure.models import RenderPass from xrfeitoria.data_structure.models import SequenceTransformKey as SeqTransKey from xrfeitoria.factory import XRFeitoriaUnreal +from xrfeitoria.utils import setup_logger from ..config import assets_path -from ..utils import __timer__, _init_unreal, setup_logger, visualize_vertices +from ..utils import __timer__, _init_unreal, visualize_vertices -root = Path(__file__).parents[2].resolve() +root = Path(__file__).resolve().parents[2] # output_path = '~/xrfeitoria/output/tests/unreal/{file_name}' output_path = root / 'output' / Path(__file__).relative_to(root).with_suffix('') seq_name = 'seq_test' @@ -86,7 +87,7 @@ def new_seq(xf_runner: XRFeitoriaUnreal, level_path: str, seq_name: str): def sequence_test(debug: bool = False, background: bool = False): - logger = setup_logger(debug=debug) + logger = setup_logger(level='DEBUG' if debug else 'INFO') with _init_unreal(background=background) as xf_runner: xf_runner.Renderer.clear() diff --git a/xrfeitoria/__init__.py b/xrfeitoria/__init__.py index 883e5dc5..72b021b7 100644 --- a/xrfeitoria/__init__.py +++ b/xrfeitoria/__init__.py @@ -1,11 +1,20 @@ import threading -_tls = threading.local() -_tls.cache = {'platform': None, 'engine_process': None, 'unreal_project_path': None} - __all__ = ['__version__', 'init_blender', 'init_unreal'] +class CacheThread: + def __init__(self): + self.global_vars = {} + + @property + def cache(self): + key = threading.current_thread().ident + if key not in self.global_vars: + self.global_vars[key] = {'platform': None, 'engine_process': None, 'unreal_project_path': None} + return self.global_vars[key] + + def _get_version() -> str: from importlib.metadata import PackageNotFoundError, version @@ -17,5 +26,6 @@ def _get_version() -> str: return __version__ +_tls = CacheThread() __version__ = _get_version() from .factory import init_blender, init_unreal diff --git a/xrfeitoria/actor/actor_base.py b/xrfeitoria/actor/actor_base.py index 09999a48..5628e48a 100644 --- a/xrfeitoria/actor/actor_base.py +++ b/xrfeitoria/actor/actor_base.py @@ -3,7 +3,6 @@ from typing import Optional, Tuple from loguru import logger -from typing_extensions import Self from ..data_structure.constants import PathLike, Vector from ..object.object_base import ObjectBase @@ -57,7 +56,7 @@ def import_from_file( rotation: 'Vector' = None, scale: 'Vector' = None, stencil_value: int = 1, - ) -> Self: + ) -> 'ActorBase': """Imports an actor from a file and returns its corresponding actor. For Blender, support files in types: fbx, obj, abc, ply, stl. @@ -77,7 +76,7 @@ def import_from_file( Ref to :ref:`FAQ-stencil-value` for details. Returns: - Self: the actor object. + ActorBase: the actor object. """ if actor_name is None: actor_name = cls._object_utils.generate_obj_name(obj_type='actor') @@ -130,6 +129,9 @@ def setup_animation(self, animation_path: 'PathLike', action_name: 'Optional[str f'from "{animation_path}" and setup for actor "{self.name}"' ) + def __repr__(self) -> str: + return f'' + ##################################### ###### RPC METHODS (Private) ######## ##################################### diff --git a/xrfeitoria/actor/actor_blender.py b/xrfeitoria/actor/actor_blender.py index cb294f89..03fd37f2 100644 --- a/xrfeitoria/actor/actor_blender.py +++ b/xrfeitoria/actor/actor_blender.py @@ -4,6 +4,7 @@ from loguru import logger from ..data_structure.constants import Vector, default_level_blender +from ..material.material_blender import MaterialBlender from ..object.object_utils import ObjectUtilsBlender from ..rpc import remote_blender from ..utils import Validator @@ -18,7 +19,7 @@ try: from ..data_structure.models import TransformKeys # isort:skip -except ModuleNotFoundError: +except (ImportError, ModuleNotFoundError): pass @@ -43,6 +44,9 @@ def set_transform_keys(self, transform_keys: 'TransformKeys') -> None: transform_keys = [i.model_dump() for i in transform_keys] self._object_utils.set_transform_keys(name=self.name, transform_keys=transform_keys) + def set_material(self, mat: MaterialBlender) -> None: + self._set_material_in_engine(actor_name=self.name, mat_name=mat._name) + ##################################### ###### RPC METHODS (Private) ######## ##################################### @@ -82,7 +86,7 @@ def _set_stencil_value_in_engine(actor_name: str, value: int) -> int: """ object = bpy.data.objects[actor_name] object.pass_index = value - for child in object.children: + for child in object.children_recursive: child.pass_index = value @staticmethod @@ -128,6 +132,32 @@ def _import_animation_from_file_in_engine(animation_path: str, actor_name: str, else: raise TypeError(f"Invalid anim file, expected 'json', 'blend', or 'fbx' (got {anim_file_ext[1:]} instead).") + @staticmethod + def _set_material_in_engine(actor_name: str, mat_name: str) -> None: + """Set material to an actor. If the actor has multiple meshes, set material to + the 1st mesh. + + Args: + actor_name (str): Name of the actor. + mat_name (str): Name of the material. + """ + actor = bpy.data.objects[actor_name] + material = XRFeitoriaBlenderFactory.get_material(mat_name=mat_name) + + if actor.type == 'ARMATURE': + if len(actor.children) == 0: + raise TypeError(f'Actor {actor_name} has no meshes, thus cannot set material to it.') + mesh = actor.children[0] + elif actor.type == 'MESH': + mesh = actor + + if mesh.data.materials: + # assign to 1-st material slot + mesh.data.materials[0] = material + else: + # no existing slot + mesh.data.materials.append(material) + @remote_blender(dec_class=True, suffix='_in_engine') class ShapeBlenderWrapper: diff --git a/xrfeitoria/camera/camera_base.py b/xrfeitoria/camera/camera_base.py index 1a01e075..89cc5a6c 100644 --- a/xrfeitoria/camera/camera_base.py +++ b/xrfeitoria/camera/camera_base.py @@ -85,7 +85,7 @@ def dump_params(self, output_path: PathLike) -> None: # dump K, R, T = self.get_KRT() - camera_param = CameraParameter(K=K, R=R, T=T) + camera_param = CameraParameter(K=K, R=R, T=T, world2cam=True) camera_param.dump(output_path.as_posix()) logger.debug(f'Camera parameters dumped to "{output_path.as_posix()}"') @@ -100,6 +100,9 @@ def look_at(self, target: Vector) -> None: # self.rotation = self._object_utils.direction_to_euler(direction) self._look_at_in_engine(self._name, target) + def __repr__(self) -> str: + return f'' + ################################# #### RPC METHODS (Private) #### ################################# diff --git a/xrfeitoria/camera/camera_blender.py b/xrfeitoria/camera/camera_blender.py index 33fdf19b..4a22d5b9 100644 --- a/xrfeitoria/camera/camera_blender.py +++ b/xrfeitoria/camera/camera_blender.py @@ -15,7 +15,7 @@ try: from ..data_structure.models import TransformKeys # isort:skip -except ModuleNotFoundError: +except (ImportError, ModuleNotFoundError): pass diff --git a/xrfeitoria/camera/camera_parameter.py b/xrfeitoria/camera/camera_parameter.py index 0ffb4a18..a63aab7d 100644 --- a/xrfeitoria/camera/camera_parameter.py +++ b/xrfeitoria/camera/camera_parameter.py @@ -122,7 +122,6 @@ def fromfile(cls, file: PathLike) -> 'CameraParameter': """ file = str(file) ret_cam = PinholeCameraParameter.fromfile(file) - ret_cam.load(file) return cls._from_pinhole(ret_cam) @classmethod @@ -158,11 +157,11 @@ def from_bin(cls, file: PathLike) -> 'CameraParameter': # extrinsic matrix RT x, y, z = -rotation[1], -rotation[2], -rotation[0] R = rotation_matrix([x, y, z], order='xyz', degrees=True) - T = np.array([location[1], -location[2], location[0]]) / 100.0 # unit: meter + _T = np.array([location[1], -location[2], location[0]]) / 100.0 # unit: meter + T = -R @ _T # construct camera parameter cam_param = cls(K=K, R=R, T=T, world2cam=True) - cam_param.inverse_extrinsic() return cam_param @classmethod diff --git a/xrfeitoria/cmd/blender/__init__.py b/xrfeitoria/cmd/blender/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/xrfeitoria/cmd/blender/install_plugin.py b/xrfeitoria/cmd/blender/install_plugin.py new file mode 100644 index 00000000..9bc33d83 --- /dev/null +++ b/xrfeitoria/cmd/blender/install_plugin.py @@ -0,0 +1,80 @@ +"""Install a blender plugin with a command line interface. + +>>> xf-install-plugin --help +>>> xf-render {} [-o {output_path}] + +# TODO: install XRFeitoriaBpy +""" + +from pathlib import Path +from textwrap import dedent + +from typer import Argument, Option, Typer +from typing_extensions import Annotated + +import xrfeitoria as xf +from xrfeitoria.utils import setup_logger + +app = Typer(pretty_exceptions_show_locals=False) + + +@app.command() +def main( + # path config + plugin_path: Annotated[ + Path, + Argument( + ..., + exists=True, + file_okay=True, + dir_okay=False, + resolve_path=True, + help='filepath of the plugin (.zip) to be installed', + ), + ], + plugin_name_blender: Annotated[ + str, + Option( + '--plugin-name-blender', + '-n', + help='name of the plugin in blender', + ), + ] = None, + # engine config + blender_exec: Annotated[ + Path, + Option('--blender-exec', help='path to blender executable, e.g. /usr/bin/blender'), + ] = None, + # misc + debug: Annotated[ + bool, + Option('--debug/--no-debug', help='log in debug mode'), + ] = False, +): + """Install a blender plugin with a command line interface.""" + logger = setup_logger(level='DEBUG' if debug else 'INFO') + logger.info( + dedent( + f"""\ + :rocket: Starting: + Executing install plugin with the following parameters: + --------------------------------------------------------- + [yellow]# path config[/yellow] + - plugin_path: {plugin_path} + - plugin_name_blender: {plugin_name_blender} + [yellow]# engine config[/yellow] + - blender_exec: {blender_exec} + [yellow]# misc[/yellow] + - debug: {debug} + ---------------------------------------------------------""" + ) + ) + + with xf.init_blender(exec_path=blender_exec, background=True) as xf_runner: + xf_runner.utils.install_plugin(plugin_path, plugin_name_blender) + + logger.info(':tada: [green]Installation of plugin completed![/green]') + + +if __name__ == '__main__': + app() diff --git a/xrfeitoria/cmd/render.py b/xrfeitoria/cmd/blender/render.py similarity index 98% rename from xrfeitoria/cmd/render.py rename to xrfeitoria/cmd/blender/render.py index 1ddbcbaa..c0516710 100644 --- a/xrfeitoria/cmd/render.py +++ b/xrfeitoria/cmd/blender/render.py @@ -20,7 +20,7 @@ import xrfeitoria as xf from xrfeitoria.data_structure.constants import ImageFileFormatEnum, RenderEngineEnumBlender, RenderOutputEnumBlender from xrfeitoria.data_structure.models import RenderPass -from xrfeitoria.utils.tools import Logger +from xrfeitoria.utils import setup_logger RENDER_SAMPLES = {'low': 64, 'medium': 256, 'high': 1024} app = Typer(pretty_exceptions_show_locals=False) @@ -117,7 +117,7 @@ def main( ] = False, ): """Render a mesh with blender to output_path.""" - logger = Logger.setup_logging(level='DEBUG' if debug else 'INFO') + logger = setup_logger(level='DEBUG' if debug else 'INFO') if render_engine == 'eevee': render_quality = 'low' diff --git a/xrfeitoria/cmd/blender/vis_smplx.py b/xrfeitoria/cmd/blender/vis_smplx.py new file mode 100644 index 00000000..58a94e65 --- /dev/null +++ b/xrfeitoria/cmd/blender/vis_smplx.py @@ -0,0 +1,193 @@ +"""Install a blender plugin with a command line interface. + +>>> xf-install-plugin --help +>>> xf-render {} [-o {output_path}] +""" + +from pathlib import Path +from textwrap import dedent +from typing import Tuple + +import xrfeitoria as xf +from xrfeitoria.rpc import remote_blender +from xrfeitoria.utils import setup_logger + +try: + import numpy as np + from typer import Argument, Option, Typer + from typing_extensions import Annotated + + from xrfeitoria.utils.anim.motion import Motion, SMPLMotion, SMPLXMotion +except (ImportError, NameError): + pass + +app = Typer(pretty_exceptions_show_locals=False) + + +@remote_blender() +def add_smplx(betas: 'Tuple[float, ...]' = (0.0,) * 10, gender: str = 'neutral') -> str: + """Add smplx mesh to scene and return the name of the armature and the mesh. + + Args: + betas (Tuple[float, ...]): betas of smplx model + gender (str): gender of smplx model + + Returns: + str: armature name + """ + import bpy + + assert hasattr(bpy.ops.scene, 'smplx_add_gender'), 'Please install smplx addon first' + + bpy.data.window_managers['WinMan'].smplx_tool.smplx_gender = gender + bpy.data.window_managers['WinMan'].smplx_tool.smplx_handpose = 'flat' + bpy.ops.scene.smplx_add_gender() + + bpy.data.window_managers['WinMan'].smplx_tool.smplx_texture = 'smplx_texture_f_alb.png' + bpy.ops.object.smplx_set_texture() + + smplx_mesh = bpy.context.selected_objects[0] + for index, beta in enumerate(betas): + smplx_mesh.data.shape_keys.key_blocks[f'Shape{index:03d}'].value = beta + bpy.ops.object.smplx_update_joint_locations() + bpy.ops.object.smplx_set_handpose() + + return smplx_mesh.parent.name + + +@remote_blender() +def export_fbx( + tgt_rig_name: str, + save_path: 'Path', + with_mesh: bool = False, + use_better_fbx: bool = True, + **options, +) -> bool: + import bpy + + if use_better_fbx and not hasattr(bpy.ops, 'better_export'): + raise RuntimeError('Unable to found better_fbx addon!') + + target_rig = bpy.data.objects[tgt_rig_name] + + save_path = Path(save_path).resolve() + bpy.ops.object.mode_set(mode='OBJECT') + # re-select the armature + # bpy.ops.object.select_all(action='DESELECT') + for obj in bpy.data.objects: + obj.select_set(False) + bpy.context.view_layer.objects.active = target_rig + if with_mesh: + # select the mesh for export + bpy.ops.object.select_grouped(type='CHILDREN_RECURSIVE') + target_rig.select_set(True) + + if save_path.exists(): + ctime = save_path.stat().st_ctime + else: + ctime = 0 + + save_path.parent.mkdir(exist_ok=True, parents=True) + save_path_str = str(save_path) + if use_better_fbx: + bpy.ops.better_export.fbx(filepath=save_path_str, use_selection=True, **options) + else: + bpy.ops.export_scene.fbx(filepath=save_path_str, use_selection=True, **options) + + if save_path.exists() and save_path.stat().st_ctime > ctime: + success = True + else: + success = False + return success + + +def read_smpl_x(path: Path) -> 'Motion': + """Reads a SMPL/SMPLX motion from a .npz file. + + Args: + path (Path): Path to the .npz file. + + Returns: + SMPLXMotion: The motion. + """ + smpl_x_data = np.load(path, allow_pickle=True) + if 'smplx' in smpl_x_data: + smpl_x_data = smpl_x_data['smplx'].item() + motion = SMPLXMotion.from_smplx_data(smpl_x_data) + if 'smpl' in smpl_x_data: + smpl_x_data = smpl_x_data['smpl'].item() + motion = SMPLMotion.from_smpl_data(smpl_x_data) + if 'pose_body' in smpl_x_data: + motion = SMPLXMotion.from_amass_data(smpl_x_data, insert_rest_pose=False) + else: + try: + motion = SMPLXMotion.from_smplx_data(smpl_x_data) + except Exception: + raise ValueError(f'Unknown data format of {path}, got {smpl_x_data.keys()}, but expected "smpl" or "smplx"') + motion.insert_rest_pose() + return motion + + +@app.command() +def main( + # path config + smplx_path: Annotated[ + Path, + Argument( + ..., + exists=True, + file_okay=True, + dir_okay=False, + resolve_path=True, + help='filepath of the smplx motion (.npz) to be retargeted', + ), + ], + # engine config + blender_exec: Annotated[ + Path, + Option('--blender-exec', help='path to blender executable, e.g. /usr/bin/blender'), + ] = None, + # misc + debug: Annotated[ + bool, + Option('--debug/--no-debug', help='log in debug mode'), + ] = False, +): + """Visualize a SMPL-X motion with a command line interface.""" + logger = setup_logger(level='DEBUG' if debug else 'INFO') + logger.info( + dedent( + f"""\ + :rocket: Starting: + Executing install plugin with the following parameters: + --------------------------------------------------------- + [yellow]# path config[/yellow] + - smplx_path: {smplx_path} + [yellow]# engine config[/yellow] + - blender_exec: {blender_exec} + [yellow]# misc[/yellow] + - debug: {debug} + ---------------------------------------------------------""" + ) + ) + + with xf.init_blender(exec_path=blender_exec, background=True) as xf_runner: + logger.info(f'Loading motion data: {smplx_path} ...') + motion = read_smpl_x(smplx_path) + + logger.info('Adding smplx actor using smplx_addon ...') + smplx_rig_name = add_smplx() + + logger.info('Applying motion data to actor ...') + xf_runner.utils.apply_motion_data_to_actor(motion_data=motion.get_motion_data(), actor_name=smplx_rig_name) + xf_runner.utils.set_frame_range(start=0, end=motion.n_frames) + + blend_file = smplx_path.with_name(f'{smplx_path.stem}.blend') + logger.info(f'Saving it to blend file "{blend_file}" ...') + xf_runner.utils.save_blend(blend_file) + + logger.info(':tada: [green]Visualization finished[/green]!') + + +if __name__ == '__main__': + app() diff --git a/xrfeitoria/data_structure/constants.py b/xrfeitoria/data_structure/constants.py index d02c3856..0266d34d 100644 --- a/xrfeitoria/data_structure/constants.py +++ b/xrfeitoria/data_structure/constants.py @@ -1,6 +1,6 @@ from enum import Enum from pathlib import Path -from typing import Optional, Tuple, TypedDict, Union +from typing import Dict, List, Optional, Tuple, TypedDict, Union ##### Typing Constants ##### @@ -8,6 +8,7 @@ Matrix = Tuple[Vector, Vector, Vector] Transform = Tuple[Vector, Vector, Vector] PathLike = Union[str, Path] +MotionFrame = Dict[str, Dict[str, Union[float, List[float]]]] actor_info_type = TypedDict('actor_info', {'actor_name': str, 'mask_color': Tuple[int, int, int]}) ##### Package Constants ##### @@ -15,6 +16,7 @@ package_name = 'XRFeitoria' plugin_name_blender = 'XRFeitoriaBpy' plugin_name_unreal = 'XRFeitoriaUnreal' +plugin_name_pattern = '{plugin_name}-{plugin_version}-{engine_version}-{platform}' xf_obj_name = '[XF]{obj_type}-{obj_idx:03d}' ##### Path Constants ##### @@ -129,6 +131,7 @@ class RenderOutputEnumUnreal(EnumBase): skeleton = 'skeleton' actor_infos = 'actor_infos' camera_params = cam_param_dir + audio = 'Audio' class InterpolationEnumUnreal(EnumBase): @@ -175,3 +178,11 @@ class ShapeTypeEnumUnreal(EnumBase): cylinder = 'cylinder' plane = 'plane' sphere = 'sphere' + + +class BSDFNodeLinkEnumBlender(EnumBase): + """Shader node link enum of Blender.""" + + diffuse = 'Base Color' + normal = 'Normal' + roughness = 'Roughness' diff --git a/xrfeitoria/data_structure/models.py b/xrfeitoria/data_structure/models.py index cfc49da0..4156f11c 100644 --- a/xrfeitoria/data_structure/models.py +++ b/xrfeitoria/data_structure/models.py @@ -242,7 +242,7 @@ class AntiAliasSetting(BaseModel): description='File name format of the render job.', ) console_variables: Dict[str, float] = Field( - default={}, + default={'r.MotionBlurQuality': 0}, description='Additional console variables of the render job. Ref to :ref:`FAQ-console-variables` for details.', ) anti_aliasing: AntiAliasSetting = Field( @@ -250,6 +250,7 @@ class AntiAliasSetting(BaseModel): ) export_vertices: bool = Field(default=False, description='Whether to export vertices of the render job.') export_skeleton: bool = Field(default=False, description='Whether to export skeleton of the render job.') + export_audio: bool = Field(default=False, description='Whether to export audio of the render job.') class Config: use_enum_values = True @@ -300,9 +301,9 @@ class SequenceTransformKey(BaseModel): def __init__( self, frame: int, - location: Vector = None, - rotation: Vector = None, - scale: Vector = None, + location: Optional[Vector] = None, + rotation: Optional[Vector] = None, + scale: Optional[Vector] = None, interpolation: Literal['CONSTANT', 'AUTO', 'LINEAR'] = 'AUTO', ) -> None: """Transform key for Unreal or Blender, which contains frame, location, diff --git a/xrfeitoria/factory.py b/xrfeitoria/factory.py index 4d52a815..b778ace5 100644 --- a/xrfeitoria/factory.py +++ b/xrfeitoria/factory.py @@ -2,6 +2,7 @@ from . import _tls from .data_structure.constants import EngineEnum, PathLike, default_level_blender +from .utils import setup_logger __all__ = ['init_blender', 'init_unreal'] @@ -15,7 +16,7 @@ class XRFeitoriaBlender: :class:`Actor `: Actor class.\n :class:`Shape `: Shape wrapper class.\n :class:`Renderer `: Renderer class.\n - :class:`Sequence `: Sequence wrapper class.\n + :class:`sequence `: Sequence wrapper function.\n :class:`utils `: Utilities functions executed in Blender.\n :meth:`render `: Render jobs.\n """ @@ -25,6 +26,7 @@ def __init__( engine_exec: Optional[PathLike] = None, project_path: Optional[PathLike] = None, background: bool = False, + reload_rpc_code: bool = False, replace_plugin: bool = False, dev_plugin: bool = False, new_process: bool = False, @@ -36,6 +38,9 @@ def __init__( exec_path (Optional[PathLike], optional): Path to Blender executable. Defaults to None. project_path (Optional[PathLike], optional): Path to Blender project. Defaults to None. background (bool, optional): Whether to start Blender in background. Defaults to False. + reload_rpc_code (bool, optional): whether to reload the registered rpc functions and classes. + If you are developing the package or writing a custom remote function, set this to True to reload the code. + This will only be in effect when `new_process=False` if the engine process is reused. Defaults to False. replace_plugin (bool, optional): Whether to replace the plugin. Defaults to False. dev_plugin (bool, optional): Whether to use the plugin under local directory. If False, would use the plugin downloaded from a remote server. Defaults to False. @@ -44,24 +49,27 @@ def __init__( from .object.object_utils import ObjectUtilsBlender # isort:skip from .camera.camera_blender import CameraBlender # isort:skip from .actor.actor_blender import ActorBlender, ShapeBlenderWrapper # isort:skip + from .material.material_blender import MaterialBlender # isort:skip from .renderer.renderer_blender import RendererBlender # isort:skip - from .sequence.sequence_wrapper import SequenceWrapperBlender # isort:skip + from .sequence.sequence_wrapper import SequenceWrapperBlender, sequence_wrapper_blender # isort:skip from .utils.runner import BlenderRPCRunner # isort:skip from .utils.functions import blender_functions # isort:skip - from .utils.tools import Logger # isort:skip - self.logger = Logger.setup_logging() # default level is INFO + self.logger = setup_logger() # default level is INFO self.ObjectUtils = ObjectUtilsBlender self.Camera = CameraBlender self.Actor = ActorBlender + self.Material = MaterialBlender self.Shape = ShapeBlenderWrapper self.Renderer = RendererBlender self.render = self.Renderer.render_jobs + self.sequence = sequence_wrapper_blender self.Sequence = SequenceWrapperBlender self.utils = blender_functions self._rpc_runner = BlenderRPCRunner( engine_exec=engine_exec, project_path=project_path, + reload_rpc_code=reload_rpc_code, replace_plugin=replace_plugin, dev_plugin=dev_plugin, background=background, @@ -78,7 +86,7 @@ class XRFeitoriaUnreal: :class:`Actor `: Actor class.\n :class:`Shape `: Shape wrapper class.\n :class:`Renderer `: Renderer class.\n - :class:`Sequence `: Sequence wrapper class.\n + :class:`sequence `: Sequence wrapper function.\n :class:`utils `: Utilities functions executed in Unreal.\n :meth:`render `: Render jobs.\n """ @@ -88,6 +96,7 @@ def __init__( engine_exec: Optional[PathLike] = None, project_path: Optional[PathLike] = None, background: bool = False, + reload_rpc_code: bool = False, replace_plugin: bool = False, dev_plugin: bool = False, new_process: bool = False, @@ -98,6 +107,9 @@ def __init__( exec_path (Optional[PathLike], optional): Path to Unreal executable. Defaults to None. project_path (Optional[PathLike], optional): Path to Unreal project. Defaults to None. background (bool, optional): Whether to start Unreal in background. Defaults to False. + reload_rpc_code (bool, optional): whether to reload the registered rpc functions and classes. + If you are developing the package or writing a custom remote function, set this to True to reload the code. + This will only be in effect when `new_process=False` if the engine process is reused. Defaults to False. replace_plugin (bool, optional): Whether to replace the plugin. Defaults to False. dev_plugin (bool, optional): Whether to use the plugin under local directory. If False, would use the plugin downloaded from a remote server. Defaults to False. @@ -107,23 +119,24 @@ def __init__( from .camera.camera_unreal import CameraUnreal # isort:skip from .actor.actor_unreal import ActorUnreal, ShapeUnrealWrapper # isort:skip from .renderer.renderer_unreal import RendererUnreal # isort:skip - from .sequence.sequence_wrapper import SequenceWrapperUnreal # isort:skip + from .sequence.sequence_wrapper import SequenceWrapperUnreal, sequence_wrapper_unreal # isort:skip from .utils.runner import UnrealRPCRunner # isort:skip from .utils.functions import unreal_functions # isort:skip - from .utils.tools import Logger # isort:skip - self.logger = Logger.setup_logging() # default level is INFO + self.logger = setup_logger() # default level is INFO self.ObjectUtils = ObjectUtilsUnreal self.Camera = CameraUnreal self.Actor = ActorUnreal self.Shape = ShapeUnrealWrapper self.Renderer = RendererUnreal self.render = self.Renderer.render_jobs + self.sequence = sequence_wrapper_unreal self.Sequence = SequenceWrapperUnreal self.utils = unreal_functions self._rpc_runner = UnrealRPCRunner( engine_exec=engine_exec, project_path=project_path, + reload_rpc_code=reload_rpc_code, replace_plugin=replace_plugin, dev_plugin=dev_plugin, background=background, @@ -159,6 +172,7 @@ def __init__( exec_path: Optional[PathLike] = None, project_path: Optional[PathLike] = None, background: bool = False, + reload_rpc_code: bool = False, replace_plugin: bool = False, dev_plugin: bool = False, cleanup: bool = True, @@ -170,6 +184,9 @@ def __init__( exec_path (Optional[PathLike], optional): Path to Blender executable. Defaults to None. project_path (Optional[PathLike], optional): Path to Blender project. Defaults to None. background (bool, optional): Whether to start Blender in background. Defaults to False. + reload_rpc_code (bool, optional): whether to reload the registered rpc functions and classes. + If you are developing the package or writing a custom remote function, set this to True to reload the code. + This will only be in effect when `new_process=False` if the engine process is reused. Defaults to False. replace_plugin (bool, optional): Whether to replace the plugin. Defaults to False. dev_plugin (bool, optional): Whether to use the plugin under local directory. If False, would use the plugin downloaded from a remote server. Defaults to False. @@ -179,6 +196,7 @@ def __init__( If ``dev_plugin=True``, the plugin under local directory would be used, which is under ``src/XRFeitoriaBlender``. You should git clone first, and then use this option if you want to develop the plugin. + Please ref to :ref:`FAQ-Plugin`. .. code-block:: bash :linenos: @@ -193,14 +211,20 @@ def __init__( super().__init__( engine_exec=exec_path, project_path=project_path, + reload_rpc_code=reload_rpc_code, replace_plugin=replace_plugin, dev_plugin=dev_plugin, background=background, new_process=new_process, ) - self._rpc_runner.start() - self.utils.init_scene_and_collection(default_level_blender, self._cleanup) - self.utils.set_env_color(color=(1.0, 1.0, 1.0, 1.0)) + try: + self._rpc_runner.start() + self.utils.init_scene_and_collection(default_level_blender, self._cleanup) + self.utils.set_env_color(color=(1.0, 1.0, 1.0, 1.0)) + except Exception as e: + self.logger.error(e) + self._rpc_runner.stop() + raise e def __enter__(self) -> 'init_blender': return self @@ -240,6 +264,7 @@ def __init__( exec_path: Optional[PathLike] = None, project_path: Optional[PathLike] = None, background: bool = False, + reload_rpc_code: bool = False, replace_plugin: bool = False, dev_plugin: bool = False, new_process: bool = False, @@ -250,6 +275,9 @@ def __init__( exec_path (Optional[PathLike], optional): Path to Unreal executable. Defaults to None. project_path (Optional[PathLike], optional): Path to Unreal project. Defaults to None. background (bool, optional): Whether to start Unreal in background. Defaults to False. + reload_rpc_code (bool, optional): whether to reload the registered rpc functions and classes. + If you are developing the package or writing a custom remote function, set this to True to reload the code. + This will only be in effect when `new_process=False` if the engine process is reused. Defaults to False. replace_plugin (bool, optional): Whether to replace the plugin. Defaults to False. dev_plugin (bool, optional): Whether to use the plugin under local directory. If False, would use the plugin downloaded from a remote server. Defaults to False. @@ -259,6 +287,7 @@ def __init__( If ``dev_plugin=True``, the plugin under local directory would be used, which is under ``src/XRFeitoriaUnreal``. You should git clone first, and then use this option if you want to develop the plugin. + Please ref to :ref:`FAQ-Plugin`. .. code-block:: bash :linenos: @@ -273,13 +302,19 @@ def __init__( super().__init__( engine_exec=exec_path, project_path=project_path, + reload_rpc_code=reload_rpc_code, replace_plugin=replace_plugin, dev_plugin=dev_plugin, background=background, new_process=new_process, ) - # xf_runner.Renderer.clear() - self._rpc_runner.start() + try: + self._rpc_runner.start() + # xf_runner.Renderer.clear() + except Exception as e: + self.logger.error(e) + self._rpc_runner.stop() + raise e def __enter__(self) -> 'init_unreal': return self diff --git a/xrfeitoria/material/__init__.py b/xrfeitoria/material/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/xrfeitoria/material/material_base.py b/xrfeitoria/material/material_base.py new file mode 100644 index 00000000..b66ad624 --- /dev/null +++ b/xrfeitoria/material/material_base.py @@ -0,0 +1,113 @@ +from abc import ABC, abstractmethod +from pathlib import Path +from typing import Optional + +from ..data_structure.constants import PathLike +from ..object.object_utils import ObjectUtilsBase + + +class MaterialBase(ABC): + """Base material class.""" + + _object_utils = ObjectUtilsBase + + def __init__(self, name: str) -> None: + """ + Args: + name (str): name of the object + """ + self._name = name + + @classmethod + def new(cls, mat_name: str) -> 'MaterialBase': + """Add a new material. + + Args: + mat_name (str): Name of the material. + + Returns: + MaterialBase: Material object. + """ + cls._new_material_in_engine(mat_name) + return cls(mat_name) + + def add_diffuse_texture( + self, + texture_file: PathLike, + texture_name: Optional[str] = None, + ) -> None: + """Add a diffuse texture to the material. + + Args: + texture_file (PathLike): File path of the texture. + texture_name (Optional[str]): Name of the texture. Defaults to None. + """ + if texture_name is None: + texture_name = Path(texture_file).stem + self._add_diffuse_texture_in_engine(mat_name=self._name, texture_file=texture_file, texture_name=texture_name) + + def add_normal_texture( + self, + texture_file: PathLike, + texture_name: Optional[str] = None, + ) -> None: + """Add a normal texture to the material. + + Args: + texture_file (PathLike): File path of the texture. + texture_name (Optional[str]): Name of the texture. Defaults to None. + """ + if texture_name is None: + texture_name = Path(texture_file).stem + self._add_normal_texture_in_engine(mat_name=self._name, texture_file=texture_file, texture_name=texture_name) + + def add_roughness_texture( + self, + texture_file: PathLike, + texture_name: Optional[str] = None, + ) -> None: + """Add a roughness texture to the material. + + Args: + texture_file (PathLike): File path of the texture. + texture_name (Optional[str]): Name of the texture. Defaults to None. + """ + if texture_name is None: + texture_name = Path(texture_file).stem + self._add_roughness_texture_in_engine(mat_name=self._name, texture_file=texture_file, texture_name=texture_name) + + ################################# + #### RPC METHODS (Private) #### + ################################# + + @staticmethod + @abstractmethod + def _new_material_in_engine(mat_name: str) -> None: + pass + + @staticmethod + @abstractmethod + def _add_diffuse_texture_in_engine( + mat_name: str, + texture_file: str, + texture_name: str, + ) -> None: + pass + + @staticmethod + @abstractmethod + def _add_normal_texture_in_engine( + mat_name: str, + texture_file: str, + texture_name: str, + ) -> None: + pass + + @staticmethod + @abstractmethod + def _add_roughness_texture_in_engine( + mat_name: str, + texture_file: str, + texture_name: str, + ) -> None: + pass diff --git a/xrfeitoria/material/material_blender.py b/xrfeitoria/material/material_blender.py new file mode 100644 index 00000000..9df23cab --- /dev/null +++ b/xrfeitoria/material/material_blender.py @@ -0,0 +1,131 @@ +import math +from typing import Dict, List, Optional, Tuple, Union + +from ..data_structure.constants import BSDFNodeLinkEnumBlender, Vector +from ..object.object_utils import ObjectUtilsBlender +from ..rpc import remote_blender +from ..utils import Validator +from .material_base import MaterialBase + +try: + import bpy # isort:skip + from XRFeitoriaBpy.core.factory import XRFeitoriaBlenderFactory # defined in src/XRFeitoriaBpy/core/factory.py +except ModuleNotFoundError: + pass + + +@remote_blender(dec_class=True, suffix='_in_engine') +class MaterialBlender(MaterialBase): + """Material class for Blender.""" + + _object_utils = ObjectUtilsBlender + + ##################################### + ###### RPC METHODS (Private) ######## + ##################################### + + ###### Getter ###### + @staticmethod + def _new_material_in_engine(mat_name: str) -> None: + """Add a new material. + + Args: + mat_name (str): Name of the material in Blender. + """ + mat = bpy.data.materials.new(name=mat_name) + mat.use_nodes = True + + @staticmethod + def _add_diffuse_texture_in_engine( + mat_name: str, + texture_file: str, + texture_name: str, + ) -> None: + """Add a diffuse texture to the material. + + Args: + mat_name (str): Name of the material in Blender. + texture_file (PathLike): File path of the texture. + texture_name (Optional[str]): Name of the texture. Defaults to None. + """ + mat = XRFeitoriaBlenderFactory.get_material(mat_name=mat_name) + tex_node = XRFeitoriaBlenderFactory.new_mat_node(mat=mat, type='ShaderNodeTexImage', name=texture_name) + tex_node.image = XRFeitoriaBlenderFactory.import_texture(texture_file=texture_file) + + # HACK: move nodes to the left, for better visibility + tex_node.location.x -= 400 + tex_node.location.y += 200 + + bsdf_node = mat.node_tree.nodes['Principled BSDF'] + mat.node_tree.links.new(tex_node.outputs['Color'], bsdf_node.inputs[BSDFNodeLinkEnumBlender.diffuse]) + + @staticmethod + def _add_normal_texture_in_engine( + mat_name: str, + texture_file: str, + texture_name: str, + ) -> None: + """Add a normal texture to the material. + + Args: + mat_name (str): Name of the material in Blender. + texture_file (PathLike): File path of the texture. + texture_name (Optional[str]): Name of the texture. Defaults to None. + """ + mat = XRFeitoriaBlenderFactory.get_material(mat_name=mat_name) + tex_node = XRFeitoriaBlenderFactory.new_mat_node(mat=mat, type='ShaderNodeTexImage', name=texture_name) + tex_node.image = XRFeitoriaBlenderFactory.import_texture(texture_file=texture_file) + + normal_node = XRFeitoriaBlenderFactory.new_mat_node(mat=mat, type='ShaderNodeNormalMap') + gamma_node = XRFeitoriaBlenderFactory.new_mat_node(mat=mat, type='ShaderNodeGamma') + gamma_node.inputs['Gamma'].default_value = 0.454 # 2.2 gamma + + # HACK: move nodes to the left, for better visibility + tex_node.location.x -= 800 + gamma_node.location.x -= 400 + normal_node.location.x -= 200 + tex_node.location.y -= 600 + gamma_node.location.y -= 600 + normal_node.location.y -= 600 + + bsdf_node = mat.node_tree.nodes['Principled BSDF'] + mat.node_tree.links.new(tex_node.outputs['Color'], gamma_node.inputs['Color']) + mat.node_tree.links.new(gamma_node.outputs['Color'], normal_node.inputs['Color']) + mat.node_tree.links.new(normal_node.outputs['Normal'], bsdf_node.inputs[BSDFNodeLinkEnumBlender.normal]) + + @staticmethod + def _add_roughness_texture_in_engine( + mat_name: str, + texture_file: str, + texture_name: str, + ) -> None: + """Add a roughness texture to the material. + + Args: + mat_name (str): Name of the material in Blender. + texture_file (PathLike): File path of the texture. + texture_name (Optional[str]): Name of the texture. Defaults to None. + """ + mat = XRFeitoriaBlenderFactory.get_material(mat_name=mat_name) + tex_node = XRFeitoriaBlenderFactory.new_mat_node(mat=mat, type='ShaderNodeTexImage', name=texture_name) + tex_node.image = XRFeitoriaBlenderFactory.import_texture(texture_file=texture_file) + + math_node = XRFeitoriaBlenderFactory.new_mat_node(mat=mat, type='ShaderNodeMath') + math_node.operation = 'SUBTRACT' + math_node.inputs[0].default_value = 1.0 + + map_node = XRFeitoriaBlenderFactory.new_mat_node(mat=mat, type='ShaderNodeMapRange') + map_node.inputs[3].default_value = 0.5 + + # HACK: move nodes to the left, for better visibility + tex_node.location.x -= 800 + math_node.location.x -= 400 + map_node.location.x -= 200 + tex_node.location.y -= 200 + math_node.location.y -= 200 + map_node.location.y -= 200 + + bsdf_node = mat.node_tree.nodes['Principled BSDF'] + mat.node_tree.links.new(tex_node.outputs['Color'], math_node.inputs[1]) + mat.node_tree.links.new(math_node.outputs['Value'], map_node.inputs[0]) + mat.node_tree.links.new(map_node.outputs['Result'], bsdf_node.inputs[BSDFNodeLinkEnumBlender.roughness]) diff --git a/xrfeitoria/material/material_unreal.py b/xrfeitoria/material/material_unreal.py new file mode 100644 index 00000000..e69de29b diff --git a/xrfeitoria/object/object_base.py b/xrfeitoria/object/object_base.py index 6e923ac3..cf14828d 100644 --- a/xrfeitoria/object/object_base.py +++ b/xrfeitoria/object/object_base.py @@ -98,3 +98,6 @@ def delete(self): self._object_utils.delete_obj(self._name) logger.info(f'[red]Deleted[/red] object "{self.name}"') del self + + def __repr__(self): + return f'' diff --git a/xrfeitoria/object/object_utils.py b/xrfeitoria/object/object_utils.py index 5d201e37..7ee5eae5 100644 --- a/xrfeitoria/object/object_utils.py +++ b/xrfeitoria/object/object_utils.py @@ -8,7 +8,7 @@ from ..utils.functions import blender_functions try: - # only for linting, not imported in runtime + # linting and for engine import bpy import unreal from unreal_factory import XRFeitoriaUnrealFactory # defined in src/XRFeitoriaUnreal/Content/Python diff --git a/xrfeitoria/renderer/renderer_base.py b/xrfeitoria/renderer/renderer_base.py index d86a2535..9c7d3eca 100644 --- a/xrfeitoria/renderer/renderer_base.py +++ b/xrfeitoria/renderer/renderer_base.py @@ -52,7 +52,7 @@ def render_jobs(cls): @classmethod def clear(cls): """Clear all rendering jobs in the renderer queue.""" - logger.warning('[red] Clearing Renderer jobs[/red]') + logger.warning('[red]Clearing Renderer jobs[/red]') cls._clear_queue_in_engine() cls.render_queue.clear() diff --git a/xrfeitoria/renderer/renderer_blender.py b/xrfeitoria/renderer/renderer_blender.py index 2844c00b..fdf570c5 100644 --- a/xrfeitoria/renderer/renderer_blender.py +++ b/xrfeitoria/renderer/renderer_blender.py @@ -24,14 +24,15 @@ from .renderer_base import RendererBase, render_status try: - from ..data_structure.models import RenderJobBlender, RenderPass # isort:skip + # linting and for engine + from XRFeitoriaBpy.core.factory import XRFeitoriaBlenderFactory # defined in src/XRFeitoriaBpy/core/factory.py except ModuleNotFoundError: pass + try: - # only for linting, not imported in runtime - from XRFeitoriaBpy.core.factory import XRFeitoriaBlenderFactory # defined in src/XRFeitoriaBpy/core/factory.py -except ModuleNotFoundError: + from ..data_structure.models import RenderJobBlender, RenderPass # isort:skip +except (ImportError, ModuleNotFoundError): pass @@ -93,6 +94,7 @@ def receive_stdout( break text = f'[bold green]:rocket: Rendering Job{job_info}: frame {frame_count}/{frame_length}[/bold green]' spinner.update(text=text) + logger.debug(f'(XF-Rendering) Job{job_info}: frame {frame_count}/{frame_length}') # reset first_trigger = second_trigger = False else: @@ -114,6 +116,7 @@ def receive_stdout( break text = f'[bold green]:rocket: Rendering Job{job_info}: frame {frame_count}/{frame_length}[/bold green]' spinner.update(text=text) + logger.debug(f'(XF-Rendering) Job{job_info}: frame {frame_count}/{frame_length}') # reset first_trigger = second_trigger = False diff --git a/xrfeitoria/renderer/renderer_unreal.py b/xrfeitoria/renderer/renderer_unreal.py index 2b3de96b..872702c5 100644 --- a/xrfeitoria/renderer/renderer_unreal.py +++ b/xrfeitoria/renderer/renderer_unreal.py @@ -20,7 +20,7 @@ try: from ..data_structure.models import RenderJobUnreal as RenderJob from ..data_structure.models import RenderPass -except ModuleNotFoundError: +except (ImportError, ModuleNotFoundError): pass @@ -39,10 +39,11 @@ def add_job( resolution: Tuple[int, int], render_passes: 'List[RenderPass]', file_name_format: str = '{sequence_name}/{render_pass}/{camera_name}/{frame_number}', - console_variables: Dict[str, float] = {}, + console_variables: Dict[str, float] = {'r.MotionBlurQuality': 0}, anti_aliasing: 'Optional[RenderJob.AntiAliasSetting]' = None, export_vertices: bool = False, export_skeleton: bool = False, + export_audio: bool = False, ) -> None: """Add a rendering job to the renderer queue. @@ -53,11 +54,12 @@ def add_job( resolution (Tuple[int, int]): Resolution of the output image. render_passes (List[RenderPass]): Render passes to render. file_name_format (str, optional): File name format of the output image. Defaults to ``{sequence_name}/{render_pass}/{camera_name}/{frame_number}``. - console_variables (Dict[str, float], optional): Console variables to set. Defaults to {}. + console_variables (Dict[str, float], optional): Console variables to set. Defaults to ``{'r.MotionBlurQuality': 0}``. Ref to :ref:`FAQ-console-variables` for details. - anti_aliasing (Optional[RenderJob.AntiAliasSetting], optional): Anti aliasing setting. Defaults to None. + anti_aliasing (Optional[RenderJobUnreal.AntiAliasSetting], optional): Anti aliasing setting. Defaults to None. export_vertices (bool, optional): Whether to export vertices. Defaults to False. export_skeleton (bool, optional): Whether to export skeleton. Defaults to False. + export_audio (bool, optional): Whether to export audio. Defaults to False. Note: The motion blur is turned off by default. If you want to turn it on, please set ``r.MotionBlurQuality`` to a non-zero value in ``console_variables``. @@ -67,7 +69,11 @@ def add_job( # turn off motion blur by default if 'r.MotionBlurQuality' not in console_variables.keys(): - console_variables['r.MotionBlurQuality'] = 0 + logger.warning( + 'Seems you gave a console variable dict in ``add_to_renderer(console_variables=...)``, ' + 'and it replaces the default ``r.MotionBlurQuality`` setting, which would open the motion blur in rendering. ' + "If you want to turn off the motion blur the same as default, set ``console_variables={..., 'r.MotionBlurQuality': 0}``." + ) job = RenderJob( map_path=map_path, @@ -80,6 +86,7 @@ def add_job( anti_aliasing=anti_aliasing, export_vertices=export_vertices, export_skeleton=export_skeleton, + export_audio=export_audio, ) cls._add_job_in_engine(job.model_dump(mode='json')) cls.render_queue.append(job) @@ -141,7 +148,10 @@ def render_jobs(cls) -> None: break if 'Render completed. Success: True' in data: break - logger.info(f'\[unreal] {data}') + if 'Render completed. Success: False' in data: + logger.error('[red]Render Failed[/red]') + break + logger.info(f'(engine) {data}') except BlockingIOError: pass except ConnectionResetError: @@ -156,11 +166,11 @@ def render_jobs(cls) -> None: error_txt += f' Check unreal log: "{log_path.as_posix()}"' logger.error(error_txt) + break # cls.clear() server.close() - # post process, including: convert cam params. cls._post_process() # clear render queue @@ -168,7 +178,17 @@ def render_jobs(cls) -> None: @classmethod def _post_process(cls) -> None: + """Post-processes the rendered output by: + - converting camera parameters: from `.dat` to `.json` + - convert actor infos: from `.dat` to `.json` + - convert vertices: from `.dat` to `.npz` + - convert skeleton: from `.dat` to `.npz` + + This method is called after rendering is complete. + """ import numpy as np # isort:skip + from rich import get_console # isort:skip + from rich.spinner import Spinner # isort:skip from ..camera.camera_parameter import CameraParameter # isort:skip def convert_camera(camera_file: Path) -> None: @@ -182,8 +202,8 @@ def convert_camera(camera_file: Path) -> None: camera_file.unlink() def convert_vertices(folder: Path) -> None: - """Convert vertices from `.bin` to `.npz`. Merge all vertices files into one - `.npz` file. + """Convert vertices from `.dat` to `.npz`. Merge all vertices files into one + `.npz` file with structure of: {'verts': np.ndarray, 'faces': None} Args: folder (Path): Path to the folder containing vertices files. @@ -234,10 +254,21 @@ def convert_actor_infos(folder: Path) -> None: # Remove the folder shutil.rmtree(folder) - for job in cls.render_queue: + console = get_console() + try: + spinner: Spinner = console._live.renderable + except AttributeError: + status = console.status('[bold green]:rocket: Rendering...[/bold green]') + status.start() + spinner: Spinner = status.renderable + + for idx, job in enumerate(cls.render_queue): seq_name = job.sequence_path.split('/')[-1] seq_path = Path(job.output_path).resolve() / seq_name + text = f'job {idx + 1}/{len(cls.render_queue)}: seq_name="{seq_name}", post-processing...' + spinner.update(text=text) + # 1. convert camera parameters from `.bat` to `.json` with xrprimer # glob camera files in {seq_path}/{cam_param_dir}/* camera_files = sorted(seq_path.glob(f'{RenderOutputEnumUnreal.camera_params.value}/*.dat')) @@ -247,13 +278,20 @@ def convert_actor_infos(folder: Path) -> None: # 2. convert actor infos from `.dat` to `.json` convert_actor_infos(folder=seq_path / RenderOutputEnumUnreal.actor_infos.value) - # 3. convert vertices from `.bin` to `.npz` + # 3. convert vertices from `.dat` to `.npz` if job.export_vertices: # glob actors in {seq_path}/vertices/* actor_folders = sorted(seq_path.glob(f'{RenderOutputEnumUnreal.vertices.value}/*')) for actor_folder in actor_folders: convert_vertices(actor_folder) + # 4. convert skeleton from `.dat` to `.json` + if job.export_skeleton: + # glob actors in {seq_path}/skeleton/* + actor_folders = sorted(seq_path.glob(f'{RenderOutputEnumUnreal.skeleton.value}/*')) + for actor_folder in actor_folders: + convert_vertices(actor_folder) + @staticmethod def _add_job_in_engine(job: 'Dict[str, Any]') -> None: _job = XRFeitoriaUnrealFactory.constants.RenderJobUnreal(**job) diff --git a/xrfeitoria/rpc/client.py b/xrfeitoria/rpc/client.py index 398a0a73..d7cc61bd 100644 --- a/xrfeitoria/rpc/client.py +++ b/xrfeitoria/rpc/client.py @@ -4,7 +4,7 @@ import inspect import os import re -from xmlrpc.client import ExpatParser, ResponseError, ServerProxy, Transport, Unmarshaller +from xmlrpc.client import ExpatParser, Fault, ResponseError, ServerProxy, Transport, Unmarshaller class RPCUnmarshaller(Unmarshaller): @@ -52,9 +52,9 @@ def close(self): logger.error( f"RPC Fault :\n{marshallables.get('faultString')}" ) - # raise Fault(**marshallables) + raise Fault(**marshallables) # raise RuntimeError('RPC Fault') - exit(1) + # exit(1) return tuple(self._stack) diff --git a/xrfeitoria/rpc/factory.py b/xrfeitoria/rpc/factory.py index 76e184be..becde246 100644 --- a/xrfeitoria/rpc/factory.py +++ b/xrfeitoria/rpc/factory.py @@ -26,11 +26,20 @@ class RPCFactory: rpc_client: RPCClient = None + reload_rpc_code: bool = False file_path = None remap_pairs = [] default_imports = [] registered_function_names = [] + @classmethod + def clear(cls): + cls.rpc_client = None + cls.file_path = None + cls.remap_pairs = [] + cls.default_imports = [] + cls.registered_function_names.clear() + @classmethod def setup(cls, port: int, remap_pairs: List[str] = None, default_imports: List[str] = None): """Sets up the RPC factory. @@ -44,9 +53,6 @@ def setup(cls, port: int, remap_pairs: List[str] = None, default_imports: List[s cls.rpc_client = RPCClient(port) cls.remap_pairs = remap_pairs cls.default_imports = default_imports or [] - if os.environ.get('RPC_RELOAD'): - # clear the registered functions, so they can be re-registered - cls.registered_function_names.clear() @staticmethod def _get_docstring(code: List[str], function_name: str) -> str: @@ -103,14 +109,17 @@ def _get_callstack_references(cls, code, function): # this re.split is used to split the line by the following characters: . ( ) [ ] = # e.g. ret = bpy.data.objects['Cube'] -> ["bpy", "data", "objects", "'Cube'""] if key in re.split('\.|\(|\)|\[|\]|\=|\ = | ', line.strip()): - relative_path = function.__module__.replace('.', os.path.sep) - import_dir = cls.file_path.strip('.py').replace(relative_path, '').strip(os.sep) + __module__ = function.__module__ + if __module__ == '__main__': + __module__ = os.path.basename(cls.file_path).replace('.py', '') + relative_path = __module__.replace('.', os.sep) + import_dir = cls.file_path.replace('.py', '').replace(relative_path, '').rstrip(os.sep) # add the source file to the import code source_import_code = f'sys.path.append(r"{import_dir}")' if source_import_code not in import_code: import_code.append(source_import_code) # relatively import the module from the source file - relative_import_code = f'from {function.__module__} import {key}' + relative_import_code = f'from {__module__} import {key}' if relative_import_code not in import_code: import_code.append(relative_import_code) @@ -161,8 +170,8 @@ def _register(cls, function: Callable) -> List[str]: from loguru import logger # if function registered, skip it - if function.__name__ in cls.registered_function_names: - logger.debug(f'Function "{function.__name__}" has already been registered with the server!') + if function.__name__ in cls.registered_function_names and not cls.reload_rpc_code: + logger.log('RPC', f'Function "{function.__name__}" has already been registered with the server!') return [] code = cls._get_code(function) @@ -177,14 +186,14 @@ def _register(cls, function: Callable) -> List[str]: response = cls.rpc_client.proxy.add_new_callable(function.__name__, '\n'.join(code), additional_paths) cls.registered_function_names.append(function.__name__) - if os.environ.get('RPC_DEBUG'): - _code = '\n'.join(code) - logger.debug(f'code:\n{_code}') - logger.debug(f'response: {response}') + _code = '\n'.join(code) + logger.log('RPC', f'code:\n{_code}') + logger.log('RPC', f'response: {response}') except ConnectionRefusedError: - server_name = os.environ.get(f'RPC_SERVER_{cls.rpc_client.port}', cls.rpc_client.port) - raise ConnectionRefusedError(f'No connection could be made with "{server_name}"') + if cls.rpc_client: + server_name = os.environ.get(f'RPC_SERVER_{cls.rpc_client.port}', cls.rpc_client.port) + raise ConnectionRefusedError(f'No connection could be made with "{server_name}"') return code diff --git a/xrfeitoria/sequence/sequence_base.py b/xrfeitoria/sequence/sequence_base.py index 24e265ad..0e6b523e 100644 --- a/xrfeitoria/sequence/sequence_base.py +++ b/xrfeitoria/sequence/sequence_base.py @@ -14,7 +14,7 @@ try: from ..data_structure.models import SequenceTransformKey, TransformKeys, RenderPass # isort:skip -except ModuleNotFoundError: +except (ImportError, ModuleNotFoundError): SequenceTransformKey = TransformKeys = RenderPass = None @@ -29,6 +29,12 @@ class SequenceBase(ABC): _renderer = RendererBase __platform__: EngineEnum = _tls.cache.get('platform', None) + def __enter__(self) -> 'SequenceBase': + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.close() + @classmethod def _new( cls, @@ -43,7 +49,7 @@ def _new( Args: seq_name (str): Name of the sequence. - level (str): Level to link to the sequence. + level (Optional[str], optional): Name of the level. Defaults to None. seq_fps (int, optional): Frame per second of the new sequence. Defaults to 60. seq_length (int, optional): Frame length of the new sequence. Defaults to 60. replace (bool, optional): Replace the exist same-name sequence. Defaults to False. @@ -75,17 +81,6 @@ def close(cls) -> None: logger.info(f'<<<< [red]Closed[/red] sequence "{cls.name}" <<<<') cls.name = None - @classmethod - def save(cls) -> None: - """Save the sequence.""" - cls._save_seq_in_engine() - logger.info(f'++++ [cyan]Saved[/cyan] sequence "{cls.name}" ++++') - - @classmethod - def show(cls) -> None: - """Show the sequence in the engine.""" - cls._show_seq_in_engine() - # ------ import actor ------ # @classmethod def import_actor( @@ -97,6 +92,19 @@ def import_actor( scale: 'Vector' = None, stencil_value: int = 1, ) -> ActorBase: + """Imports an actor from a file and adds it to the sequence. + + Args: + file_path (PathLike): The path to the file containing the actor to import. + actor_name (Optional[str], optional): The name to give the imported actor. If not provided, a name will be generated automatically. Defaults to None. + location (Vector, optional): The initial location of the actor. Defaults to None. + rotation (Vector, optional): The initial rotation of the actor. Defaults to None. + scale (Vector, optional): The initial scale of the actor. Defaults to None. + stencil_value (int, optional): The stencil value to use for the actor. Defaults to 1. + + Returns: + ActorBase: The imported actor. + """ if actor_name is None: actor_name = cls._object_utils.generate_obj_name(obj_type='actor') # judge file path @@ -423,6 +431,9 @@ def add_to_renderer( ) # return render_job + def __repr__(self) -> str: + return f'' + ##################################### ###### RPC METHODS (Private) ######## ##################################### @@ -481,13 +492,3 @@ def _open_seq_in_engine(*args, **kwargs) -> None: @abstractmethod def _close_seq_in_engine() -> None: pass - - @staticmethod - @abstractmethod - def _save_seq_in_engine() -> None: - pass - - @staticmethod - @abstractmethod - def _show_seq_in_engine() -> None: - pass diff --git a/xrfeitoria/sequence/sequence_base.pyi b/xrfeitoria/sequence/sequence_base.pyi index ac8dfec3..4e6198e0 100644 --- a/xrfeitoria/sequence/sequence_base.pyi +++ b/xrfeitoria/sequence/sequence_base.pyi @@ -36,7 +36,13 @@ class SequenceBase(ABC): stencil_value: int = ..., ) -> ...: ... @classmethod - def spawn_camera(cls, location: Vector, rotation: Vector, fov: float = ..., camera_name: str = ...) -> ...: ... + def spawn_camera( + cls, + location: Vector, + rotation: Vector, + fov: float = ..., + camera_name: str = ..., + ) -> ...: ... @classmethod def spawn_camera_with_keys( cls, @@ -45,10 +51,19 @@ class SequenceBase(ABC): camera_name: str = ..., ) -> ...: ... def use_camera( - cls, camera, location: Optional[Vector] = ..., rotation: Optional[Vector] = ..., fov: float = ... + cls, + camera, + location: Optional[Vector] = ..., + rotation: Optional[Vector] = ..., + fov: float = ..., ) -> None: ... @classmethod - def use_camera_with_keys(cls, camera, transform_keys: TransformKeys, fov: float = ...) -> None: ... + def use_camera_with_keys( + cls, + camera, + transform_keys: TransformKeys, + fov: float = ..., + ) -> None: ... @classmethod def use_actor( cls, @@ -89,5 +104,9 @@ class SequenceBase(ABC): ) -> ...: ... @classmethod def add_to_renderer( - cls, output_path: PathLike, resolution: Tuple[int, int], render_passes: List[RenderPass], **kwargs + cls, + output_path: PathLike, + resolution: Tuple[int, int], + render_passes: List[RenderPass], + **kwargs, ): ... diff --git a/xrfeitoria/sequence/sequence_blender.py b/xrfeitoria/sequence/sequence_blender.py index 756305a7..10d3853f 100644 --- a/xrfeitoria/sequence/sequence_blender.py +++ b/xrfeitoria/sequence/sequence_blender.py @@ -1,5 +1,7 @@ from typing import Dict, List, Optional, Tuple, Union +from loguru import logger + from ..actor.actor_blender import ActorBlender, ShapeBlenderWrapper from ..camera.camera_blender import CameraBlender from ..data_structure.constants import PathLike, Vector @@ -10,11 +12,15 @@ try: import bpy # isort:skip - from ..data_structure.models import TransformKeys # isort:skip from XRFeitoriaBpy.core.factory import XRFeitoriaBlenderFactory # defined in src/XRFeitoriaBpy/core/factory.py except ModuleNotFoundError: pass +try: + from ..data_structure.models import RenderPass, TransformKeys # isort:skip +except (ImportError, ModuleNotFoundError): + pass + @remote_blender(dec_class=True, suffix='_in_engine') class SequenceBlender(SequenceBase): @@ -56,6 +62,30 @@ def import_actor_with_keys( cls._object_utils.set_transform_keys(name=actor.name, transform_keys=transform_keys) return actor + @classmethod + def add_to_renderer( + cls, + output_path: PathLike, + resolution: Tuple[int, int], + render_passes: 'List[RenderPass]', + **kwargs, + ): + cls._renderer.add_job( + sequence_name=cls.name, + output_path=output_path, + resolution=resolution, + render_passes=render_passes, + **kwargs, + ) + # set renderer in engine (for storing the render settings like resolution, render_passes, etc.) + cls._renderer._set_renderer_in_engine( + job=cls._renderer.render_queue[-1].model_dump(mode='json'), tmp_render_path='/tmp' + ) + logger.info( + f'[cyan]Added[/cyan] sequence "{cls.name}" to [bold]`Renderer`[/bold] ' + f'(jobs to render: {len(cls._renderer.render_queue)})' + ) + ##################################### ###### RPC METHODS (Private) ######## ##################################### @@ -96,6 +126,8 @@ def _new_seq_in_engine( frame_start=0, frame_end=seq_length - 1, frame_current=0, + resolution_x=level_scene.render.resolution_x, + resolution_y=level_scene.render.resolution_y, ) level_scene.frame_start = 0 level_scene.frame_end = seq_length - 1 @@ -131,10 +163,6 @@ def _close_seq_in_engine() -> None: level_scene = XRFeitoriaBlenderFactory.get_active_scene() XRFeitoriaBlenderFactory.set_level_properties(scene=level_scene, active_seq=None) - @staticmethod - def _show_seq_in_engine() -> None: - raise NotImplementedError - # -------- spawn methods -------- # @staticmethod def _import_actor_in_engine( @@ -146,10 +174,10 @@ def _import_actor_in_engine( """Import. Args: - file_path (PathLike): _description_ - transform_keys (Union[List[Dict], Dict]): _description_ - actor_name (str, optional): _description_. Defaults to 'Actor'. - stencil_value (int, optional): _description_. Defaults to 1. + file_path (PathLike): Path of the imported file. + transform_keys (Union[List[Dict], Dict]): Transform keys of the imported actor. + actor_name (str, optional): Name of the actor. Defaults to 'Actor'. + stencil_value (int, optional): Stencil value of the actor. Defaults to 1. """ if not isinstance(transform_keys, list): transform_keys = [transform_keys] @@ -157,7 +185,10 @@ def _import_actor_in_engine( ActorBlender._import_actor_from_file_in_engine(file_path=file_path, actor_name=actor_name) ObjectUtilsBlender._set_transform_keys_in_engine(obj_name=actor_name, transform_keys=transform_keys) # XXX: set stencil value. may use actor property - bpy.data.objects[actor_name].pass_index = stencil_value + actor = bpy.data.objects[actor_name] + actor.pass_index = stencil_value + for child in actor.children_recursive: + child.pass_index = stencil_value @staticmethod def _spawn_camera_in_engine( @@ -229,7 +260,10 @@ def _spawn_shape_in_engine( ) ObjectUtilsBlender._set_transform_keys_in_engine(obj_name=shape_name, transform_keys=transform_keys) # XXX: set stencil value. may use actor property - bpy.data.objects[shape_name].pass_index = stencil_value + actor = bpy.data.objects[shape_name] + actor.pass_index = stencil_value + for child in actor.children_recursive: + child.pass_index = stencil_value # -------- use methods -------- # @staticmethod @@ -307,6 +341,8 @@ def _use_actor_in_engine( # set level actor's properties actor.pass_index = stencil_value + for child in actor.children_recursive: + child.pass_index = stencil_value if action: XRFeitoriaBlenderFactory.apply_action_to_actor(action=action, actor=actor) ObjectUtilsBlender._set_transform_keys_in_engine(obj_name=actor_name, transform_keys=transform_keys) diff --git a/xrfeitoria/sequence/sequence_blender.pyi b/xrfeitoria/sequence/sequence_blender.pyi index 7fc9843b..b4377a8a 100644 --- a/xrfeitoria/sequence/sequence_blender.pyi +++ b/xrfeitoria/sequence/sequence_blender.pyi @@ -1,4 +1,3 @@ -from pathlib import Path as Path from typing import List, Literal, Optional, Tuple, Union from ..actor.actor_blender import ActorBlender @@ -71,10 +70,6 @@ class SequenceBlender(SequenceBase): arrange_file_structure: bool = True, ): ... @classmethod - def use_camera( - cls, camera: CameraBlender, location: Optional[Vector] = ..., rotation: Optional[Vector] = ..., fov: float = ... - ) -> None: ... - @classmethod def use_camera_with_keys( cls, camera: CameraBlender, transform_keys: TransformKeys, fov: float = ... ) -> CameraBlender: ... diff --git a/xrfeitoria/sequence/sequence_unreal.py b/xrfeitoria/sequence/sequence_unreal.py index e705cee8..b5d059e9 100644 --- a/xrfeitoria/sequence/sequence_unreal.py +++ b/xrfeitoria/sequence/sequence_unreal.py @@ -4,7 +4,7 @@ from ..actor.actor_unreal import ActorUnreal from ..camera.camera_unreal import CameraUnreal -from ..data_structure.constants import PathLike, Vector +from ..data_structure.constants import MotionFrame, PathLike, Vector from ..object.object_utils import ObjectUtilsUnreal from ..renderer.renderer_unreal import RendererUnreal from ..rpc import remote_unreal @@ -21,7 +21,7 @@ from ..data_structure.models import RenderJobUnreal, RenderPass from ..data_structure.models import SequenceTransformKey as SeqTransKey from ..data_structure.models import TransformKeys -except ModuleNotFoundError: +except (ImportError, ModuleNotFoundError): pass @@ -34,6 +34,21 @@ class SequenceUnreal(SequenceBase): _object_utils = ObjectUtilsUnreal _renderer = RendererUnreal + def __exit__(self, exc_type, exc_val, exc_tb): + self.save() + self.close() + + @classmethod + def save(cls) -> None: + """Save the sequence.""" + cls._save_seq_in_engine() + logger.info(f'++++ [cyan]Saved[/cyan] sequence "{cls.name}" ++++') + + @classmethod + def show(cls) -> None: + """Show the sequence in the engine.""" + cls._show_seq_in_engine() + @classmethod def add_to_renderer( cls, @@ -41,15 +56,16 @@ def add_to_renderer( resolution: Tuple[int, int], render_passes: 'List[RenderPass]', file_name_format: str = '{sequence_name}/{render_pass}/{camera_name}/{frame_number}', - console_variables: Dict[str, float] = {}, + console_variables: Dict[str, float] = {'r.MotionBlurQuality': 0}, anti_aliasing: 'Optional[RenderJobUnreal.AntiAliasSetting]' = None, export_vertices: bool = False, export_skeleton: bool = False, + export_audio: bool = False, ) -> None: """Add the sequence to the renderer's job queue. Can only be called after the sequence is instantiated using :meth:`~xrfeitoria.sequence.sequence_wrapper.SequenceWrapperUnreal.new` or - :meth:`~xrfeitoria.sequence.sequence_wrapper.SequenceWrapperUnreal.o pen`. + :meth:`~xrfeitoria.sequence.sequence_wrapper.SequenceWrapperUnreal.open`. Args: output_path (PathLike): The path where the rendered output will be saved. @@ -57,12 +73,13 @@ def add_to_renderer( render_passes (List[RenderPass]): The list of render passes to be rendered. file_name_format (str, optional): The format of the output file name. Defaults to ``{sequence_name}/{render_pass}/{camera_name}/{frame_number}``. - console_variables (Dict[str, float], optional): The console variables to be set before rendering. Defaults to {}. + console_variables (Dict[str, float], optional): The console variables to be set before rendering. Defaults to {'r.MotionBlurQuality': 0}. Ref to :ref:`FAQ-stencil-value` for details. anti_aliasing (Optional[RenderJobUnreal.AntiAliasSetting], optional): The anti-aliasing settings for the render job. Defaults to None. export_vertices (bool, optional): Whether to export vertices. Defaults to False. export_skeleton (bool, optional): Whether to export the skeleton. Defaults to False. + export_audio (bool, optional): Whether to export audio. Defaults to False. Examples: >>> import xrfeitoria as xf @@ -91,6 +108,7 @@ def add_to_renderer( anti_aliasing=anti_aliasing, export_vertices=export_vertices, export_skeleton=export_skeleton, + export_audio=export_audio, ) logger.info( f'[cyan]Added[/cyan] sequence "{cls.name}" to [bold]`Renderer`[/bold] ' @@ -101,12 +119,13 @@ def add_to_renderer( def spawn_actor( cls, actor_asset_path: str, - location: 'Vector', - rotation: 'Vector', + location: 'Optional[Vector]' = None, + rotation: 'Optional[Vector]' = None, scale: 'Optional[Vector]' = None, actor_name: Optional[str] = None, stencil_value: int = 1, anim_asset_path: 'Optional[str]' = None, + motion_data: 'Optional[List[MotionFrame]]' = None, ) -> ActorUnreal: """Spawns an actor in the Unreal Engine at the specified location, rotation, and scale. @@ -114,13 +133,14 @@ def spawn_actor( Args: cls: The class object. actor_asset_path (str): The actor asset path in engine to spawn. - location (Vector): The location to spawn the actor at. unit: meter. - rotation (Vector): The rotation to spawn the actor with. unit: degree. + location (Optional[Vector, optional]): The location to spawn the actor at. unit: meter. + rotation (Optional[Vector, optional]): The rotation to spawn the actor with. unit: degree. scale (Optional[Vector], optional): The scale to spawn the actor with. Defaults to None. actor_name (Optional[str], optional): The name to give the spawned actor. Defaults to None. stencil_value (int in [0, 255], optional): The stencil value to use for the spawned actor. Defaults to 1. Ref to :ref:`FAQ-stencil-value` for details. anim_asset_path (Optional[str], optional): The engine path to the animation asset of the actor. Defaults to None. + motion_data (Optional[List[MotionFrame]]): The motion data used for FK animation. Returns: ActorUnreal: The spawned actor object. @@ -134,6 +154,7 @@ def spawn_actor( actor_asset_path=actor_asset_path, transform_keys=transform_keys.model_dump(), anim_asset_path=anim_asset_path, + motion_data=motion_data, actor_name=actor_name, stencil_value=stencil_value, ) @@ -148,6 +169,7 @@ def spawn_actor_with_keys( actor_name: Optional[str] = None, stencil_value: int = 1, anim_asset_path: 'Optional[str]' = None, + motion_data: 'Optional[List[MotionFrame]]' = None, ) -> ActorUnreal: """Spawns an actor in the Unreal Engine with the given asset path, transform keys, actor name, stencil value, and animation asset path. @@ -159,6 +181,7 @@ def spawn_actor_with_keys( stencil_value (int in [0, 255], optional): The stencil value to use for the spawned actor. Defaults to 1. Ref to :ref:`FAQ-stencil-value` for details. anim_asset_path (Optional[str], optional): The engine path to the animation asset of the actor. Defaults to None. + motion_data (Optional[List[MotionFrame]]): The motion data used for FK animation. Returns: ActorUnreal: The spawned actor. @@ -174,6 +197,7 @@ def spawn_actor_with_keys( actor_asset_path=actor_asset_path, transform_keys=transform_keys, anim_asset_path=anim_asset_path, + motion_data=motion_data, actor_name=actor_name, stencil_value=stencil_value, ) @@ -182,6 +206,22 @@ def spawn_actor_with_keys( ) return ActorUnreal(actor_name) + @classmethod + def add_audio( + cls, + audio_asset_path: str, + start_frame: Optional[int] = None, + end_frame: Optional[int] = None, + ) -> None: + """Add an audio track to the sequence. + + Args: + audio_asset_path (str): The path to the audio asset in the engine. + start_frame (Optional[int], optional): The start frame of the audio track. Defaults to None. + end_frame (Optional[int], optional): The end frame of the audio track. Defaults to None. + """ + cls._add_audio_in_engine(audio_asset_path=audio_asset_path, start_frame=start_frame, end_frame=end_frame) + @classmethod def get_map_path(cls) -> str: """Returns the path to the map corresponding to the sequence in the Unreal @@ -201,10 +241,53 @@ def get_seq_path(cls) -> str: """ return cls._get_seq_path_in_engine() + @classmethod + def set_playback(cls, start_frame: Optional[int] = None, end_frame: Optional[int] = None) -> None: + """Set the playback range for the sequence. + + Args: + start_frame (Optional[int]): The start frame of the playback range. If not provided, the default start frame will be used. + end_frame (Optional[int]): The end frame of the playback range. If not provided, the default end frame will be used. + + Returns: + None + """ + cls._set_playback_in_engine(start_frame=start_frame, end_frame=end_frame) + + @classmethod + def set_camera_cut_playback(cls, start_frame: Optional[int] = None, end_frame: Optional[int] = None) -> None: + """Set the playback range for the sequence. + + Args: + start_frame (Optional[int]): The start frame of the playback range. If not provided, the default start frame will be used. + end_frame (Optional[int]): The end frame of the playback range. If not provided, the default end frame will be used. + + Returns: + None + """ + cls._set_camera_cut_player_in_engine(start_frame=start_frame, end_frame=end_frame) + + @classmethod + def _open(cls, seq_name: str, seq_dir: 'Optional[str]' = None) -> None: + """Open an exist sequence. + + Args: + seq_name (str): Name of the sequence. + seq_dir (Optional[str], optional): Path of the sequence. + Defaults to None and fallback to the default path '/Game/XRFeitoriaUnreal/Sequences'. + """ + cls._open_seq_in_engine(seq_name=seq_name, seq_dir=seq_dir) + cls.name = seq_name + logger.info(f'>>>> [cyan]Opened[/cyan] sequence "{cls.name}" >>>>') + ##################################### ###### RPC METHODS (Private) ######## ##################################### + @staticmethod + def _get_default_seq_path_in_engine() -> str: + return XRFeitoriaUnrealFactory.constants.DEFAULT_SEQUENCE_PATH + @staticmethod def _get_seq_info_in_engine( seq_name: str, @@ -288,6 +371,16 @@ def _close_seq_in_engine() -> None: def _show_seq_in_engine() -> None: XRFeitoriaUnrealFactory.Sequence.show() + @staticmethod + def _set_playback_in_engine(start_frame: 'Optional[int]' = None, end_frame: 'Optional[int]' = None) -> None: + XRFeitoriaUnrealFactory.Sequence.set_playback(start_frame=start_frame, end_frame=end_frame) + + @staticmethod + def _set_camera_cut_player_in_engine( + start_frame: 'Optional[int]' = None, end_frame: 'Optional[int]' = None + ) -> None: + XRFeitoriaUnrealFactory.Sequence.set_camera_cut_playback(start_frame=start_frame, end_frame=end_frame) + # ------ add actor and camera -------- # @staticmethod @@ -375,6 +468,7 @@ def _spawn_actor_in_engine( actor_asset_path: str, transform_keys: 'Union[List[Dict], Dict]', anim_asset_path: 'Optional[str]' = None, + motion_data: 'Optional[List[MotionFrame]]' = None, actor_name: str = 'Actor', stencil_value: int = 1, ) -> None: @@ -391,6 +485,7 @@ def _spawn_actor_in_engine( XRFeitoriaUnrealFactory.Sequence.add_actor( actor=actor_asset_path, animation_asset=anim_asset_path, + motion_data=motion_data, actor_name=actor_name, transform_keys=transform_keys, stencil_value=stencil_value, @@ -416,3 +511,18 @@ def _spawn_shape_in_engine( transform_keys=transform_keys, stencil_value=stencil_value, ) + + # ------ add audio -------- # + @staticmethod + def _add_audio_in_engine( + audio_asset_path: str, + start_frame: 'Optional[int]' = None, + end_frame: 'Optional[int]' = None, + ): + # check asset + unreal_functions.check_asset_in_engine(audio_asset_path, raise_error=True) + XRFeitoriaUnrealFactory.Sequence.add_audio( + audio_asset=audio_asset_path, + start_frame=start_frame, + end_frame=end_frame, + ) diff --git a/xrfeitoria/sequence/sequence_unreal.pyi b/xrfeitoria/sequence/sequence_unreal.pyi index 2c2ad6c2..2038e6e4 100644 --- a/xrfeitoria/sequence/sequence_unreal.pyi +++ b/xrfeitoria/sequence/sequence_unreal.pyi @@ -1,17 +1,13 @@ from typing import Dict, List, Optional, Tuple -from ..actor.actor_unreal import ActorUnreal as ActorUnreal -from ..camera.camera_unreal import CameraUnreal as CameraUnreal -from ..data_structure.constants import PathLike as PathLike -from ..data_structure.constants import Vector as Vector -from ..data_structure.models import RenderJobUnreal as RenderJobUnreal -from ..data_structure.models import RenderPass as RenderPass -from ..data_structure.models import TransformKeys as TransformKeys -from ..object.object_utils import ObjectUtilsUnreal as ObjectUtilsUnreal -from ..renderer.renderer_unreal import RendererUnreal as RendererUnreal -from ..rpc import remote_unreal as remote_unreal -from ..utils.functions import unreal_functions as unreal_functions -from .sequence_base import SequenceBase as SequenceBase +from ..actor.actor_unreal import ActorUnreal +from ..camera.camera_unreal import CameraUnreal +from ..data_structure.constants import MotionFrame, PathLike, Vector +from ..data_structure.models import RenderJobUnreal, RenderPass, TransformKeys +from ..object.object_utils import ObjectUtilsUnreal +from ..renderer.renderer_unreal import RendererUnreal +from ..utils.functions import unreal_functions +from .sequence_base import SequenceBase class SequenceUnreal(SequenceBase): @classmethod @@ -30,11 +26,12 @@ class SequenceUnreal(SequenceBase): output_path: PathLike, resolution: Tuple[int, int], render_passes: List[RenderPass], - file_name_format: str = ..., - console_variables: Dict[str, float] = ..., - anti_aliasing: 'Optional[RenderJobUnreal.AntiAliasSetting]' = ..., - export_vertices: bool = ..., - export_skeleton: bool = ..., + file_name_format: str = '{sequence_name}/{render_pass}/{camera_name}/{frame_number}', + console_variables: Dict[str, float] = {'r.MotionBlurQuality': 0}, + anti_aliasing: 'Optional[RenderJobUnreal.AntiAliasSetting]' = None, + export_vertices: bool = False, + export_skeleton: bool = False, + export_audio: bool = False, ) -> None: ... @classmethod def spawn_camera( @@ -44,21 +41,23 @@ class SequenceUnreal(SequenceBase): def spawn_actor( cls, actor_asset_path: str, - location: Vector, - rotation: Vector, - scale: Optional[Vector] = ..., - actor_name: Optional[str] = ..., - stencil_value: int = ..., - anim_asset_path: Optional[str] = ..., + location: Optional[Vector] = None, + rotation: Optional[Vector] = None, + scale: Optional[Vector] = None, + actor_name: Optional[str] = None, + stencil_value: int = 1, + anim_asset_path: Optional[str] = None, + motion_data: Optional[List[MotionFrame]] = None, ) -> ActorUnreal: ... @classmethod def spawn_actor_with_keys( cls, actor_asset_path: str, transform_keys: TransformKeys, - actor_name: Optional[str] = ..., - stencil_value: int = ..., - anim_asset_path: Optional[str] = ..., + actor_name: Optional[str] = None, + stencil_value: int = 1, + anim_asset_path: Optional[str] = None, + motion_data: Optional[List[MotionFrame]] = None, ) -> ActorUnreal: ... @classmethod def use_camera( @@ -87,6 +86,21 @@ class SequenceUnreal(SequenceBase): anim_asset_path: Optional[str] = ..., ) -> None: ... @classmethod + def add_audio( + cls, + audio_asset_path: str, + start_frame: Optional[int] = None, + end_frame: Optional[int] = None, + ) -> None: ... + @classmethod def get_map_path(cls) -> str: ... @classmethod def get_seq_path(cls) -> str: ... + @classmethod + def set_playback(cls, start_frame: Optional[int] = None, end_frame: Optional[int] = None) -> None: ... + @classmethod + def set_camera_cut_playback(cls, start_frame: Optional[int] = None, end_frame: Optional[int] = None) -> None: ... + @classmethod + def _open(cls, seq_name: str, seq_dir: 'Optional[str]' = ...) -> None: ... + @staticmethod + def _get_default_seq_path_in_engine() -> str: ... diff --git a/xrfeitoria/sequence/sequence_wrapper.py b/xrfeitoria/sequence/sequence_wrapper.py index 83a3141c..4dd0d09b 100644 --- a/xrfeitoria/sequence/sequence_wrapper.py +++ b/xrfeitoria/sequence/sequence_wrapper.py @@ -1,27 +1,38 @@ +"""Sequence wrapper functions.""" + +import warnings from contextlib import contextmanager from typing import ContextManager, List, Optional, Tuple, Union from ..data_structure.constants import default_level_blender +from ..utils.functions import blender_functions, unreal_functions from .sequence_base import SequenceBase from .sequence_blender import SequenceBlender from .sequence_unreal import SequenceUnreal +__all__ = ['sequence_wrapper_blender', 'sequence_wrapper_unreal'] + -class SequenceWrapperBase: +class SequenceWrapperBlender: """Sequence utils class.""" - _seq = SequenceBase + _seq = SequenceBlender + _warn_msg = ( + '\n`Sequence` class will be deprecated in the future. Please use `sequence` function instead:\n' + '>>> xf_runner = xf.init_blender()\n' + '>>> with xf_runner.sequence(...) as seq: ...' + ) @classmethod @contextmanager def new( cls, seq_name: str, - level: Optional[str] = None, + level: str = default_level_blender, seq_fps: int = 30, seq_length: int = 1, replace: bool = False, - ) -> ContextManager[SequenceUnreal]: + ) -> ContextManager[SequenceBase]: """Create a new sequence and close the sequence after exiting the it. Args: @@ -33,6 +44,7 @@ def new( Yields: SequenceBase: Sequence object. """ + warnings.showwarning(cls._warn_msg, DeprecationWarning, __file__, 0) cls._seq._new( seq_name=seq_name, level=level, @@ -41,7 +53,6 @@ def new( replace=replace, ) yield cls._seq - cls._seq.save() cls._seq.close() @classmethod @@ -55,73 +66,40 @@ def open(cls, seq_name: str) -> ContextManager[SequenceBase]: Yields: SequenceBase: Sequence object. """ + warnings.showwarning(cls._warn_msg, DeprecationWarning, __file__, 0) cls._seq._open(seq_name=seq_name) yield cls._seq - cls._seq.save() cls._seq.close() -class SequenceWrapperBlender(SequenceWrapperBase): - """Sequence utils class for Blender.""" - - _seq = SequenceBlender - - @classmethod - @contextmanager - def new( - cls, - seq_name: str, - level: Optional[str] = None, - seq_fps: int = 30, - seq_length: int = 1, - replace: bool = False, - ) -> ContextManager[SequenceBlender]: - """Create a new sequence and close the sequence after exiting the it. +class SequenceWrapperUnreal: + """Sequence utils class for Unreal.""" - Args: - seq_name (str): Name of the sequence. - level (Optional[str], optional): Name of the level. Defaults to None. If None, use the default level named 'XRFeitoria'. - seq_fps (int, optional): Frame per second of the new sequence. Defaults to 30. - seq_length (int, optional): Frame length of the new sequence. Defaults to 1. - replace (bool, optional): Replace the exist same-name sequence. Defaults to False. - Yields: - SequenceBase: Sequence object. - """ - if level is None: - level = default_level_blender - cls._seq._new( - seq_name=seq_name, - level=level, - seq_fps=seq_fps, - seq_length=seq_length, - replace=replace, - ) - yield cls._seq - cls._seq.save() - cls._seq.close() + _seq = SequenceUnreal + _warn_msg = ( + '\n`Sequence` class will be deprecated in the future. Please use `sequence` function instead:\n' + '>>> xf_runner = xf.init_unreal()\n' + '>>> with xf_runner.sequence(...) as seq: ...' + ) @classmethod @contextmanager - def open(cls, seq_name: str) -> ContextManager[SequenceBase]: + def open(cls, seq_name: str, seq_dir: 'Optional[str]' = None) -> ContextManager[SequenceUnreal]: """Open a sequence and close the sequence after existing it. Args: seq_name (str): Name of the sequence. + seq_dir (Optional[str], optional): Path of the sequence. + Defaults to None and fallback to the default path '/Game/XRFeitoriaUnreal/Sequences'. Yields: - SequenceBase: Sequence object. + SequenceUnreal: Sequence object. """ - cls._seq._open(seq_name=seq_name) + cls._seq._open(seq_name=seq_name, seq_dir=seq_dir) yield cls._seq cls._seq.save() cls._seq.close() - -class SequenceWrapperUnreal(SequenceWrapperBase): - """Sequence utils class for Unreal.""" - - _seq = SequenceUnreal - @classmethod @contextmanager def new( @@ -158,3 +136,71 @@ def new( yield cls._seq cls._seq.save() cls._seq.close() + + +def sequence_wrapper_blender( + seq_name: str, + level: str = default_level_blender, + seq_fps: int = 30, + seq_length: int = 1, + replace: bool = False, +) -> Union[SequenceBlender, ContextManager[SequenceBlender]]: + """Create a new sequence and close the sequence after exiting it. + + Args: + seq_name (str): The name of the sequence. + level (str, optional): The level to associate the sequence with. Defaults to 'XRFeitoria'. + seq_fps (int, optional): The frames per second of the sequence. Defaults to 30. + seq_length (int, optional): The length of the sequence. Defaults to 1. + replace (bool, optional): Whether to replace an existing sequence with the same name. Defaults to False. + + Returns: + SequenceBlender: The created SequenceBlender object. + """ + if blender_functions.check_sequence(seq_name=seq_name) and not replace: + SequenceBlender._open(seq_name=seq_name) + else: + SequenceBlender._new(seq_name=seq_name, level=level, seq_fps=seq_fps, seq_length=seq_length, replace=replace) + return SequenceBlender() + + +def sequence_wrapper_unreal( + seq_name: str, + seq_dir: Optional[str] = None, + level: Optional[str] = None, + seq_fps: int = 30, + seq_length: int = 1, + replace: bool = False, +) -> Union[SequenceUnreal, ContextManager[SequenceUnreal]]: + """Create a new sequence and close the sequence after exiting it. + + Args: + seq_name (str): The name of the sequence. + seq_dir (Optional[str], optional): The directory where the sequence is located. Defaults to None. + level (Optional[str], optional): The level to associate the sequence with. Defaults to None. + seq_fps (int, optional): The frames per second of the sequence. Defaults to 30. + seq_length (int, optional): The length of the sequence in seconds. Defaults to 1. + replace (bool, optional): Whether to replace an existing sequence with the same name. Defaults to False. + + Returns: + SequenceUnreal: The created SequenceUnreal object. + """ + + default_sequence_path = SequenceUnreal._get_default_seq_path_in_engine() + seq_dir = seq_dir or default_sequence_path + if ( + unreal_functions.check_asset_in_engine(f'{seq_dir}/{seq_name}') + and unreal_functions.check_asset_in_engine(f'{seq_dir}/{seq_name}_data') + and not replace + ): + SequenceUnreal._open(seq_name=seq_name, seq_dir=seq_dir) + else: + SequenceUnreal._new( + seq_name=seq_name, + seq_dir=seq_dir, + level=level, + seq_fps=seq_fps, + seq_length=seq_length, + replace=replace, + ) + return SequenceUnreal() diff --git a/xrfeitoria/sequence/sequence_wrapper.pyi b/xrfeitoria/sequence/sequence_wrapper.pyi index c3b1c1a9..d4a6075d 100644 --- a/xrfeitoria/sequence/sequence_wrapper.pyi +++ b/xrfeitoria/sequence/sequence_wrapper.pyi @@ -1,46 +1,18 @@ from typing import ContextManager, List, Optional, Union +from ..data_structure.constants import default_level_blender from .sequence_blender import SequenceBlender as SequenceBlender from .sequence_unreal import SequenceUnreal as SequenceUnreal -class SequenceWrapperBase: +class SequenceWrapperBlender: @classmethod def new( - cls, - seq_name: str, - level: Union[str, List[str]] = ..., - seq_fps: int = ..., - seq_length: int = ..., - replace: bool = ..., - ) -> ...: ... - @classmethod - def open(cls, seq_name: str) -> ...: ... - -class SequenceWrapperBlender(SequenceWrapperBase): - @classmethod - def new( - cls, - seq_name: str, - level: str = ..., - seq_fps: int = ..., - seq_length: int = ..., - replace: bool = ..., - ) -> ContextManager[SequenceBlender]: - """Create a new sequence and close the sequence after exiting the it. - - Args: - seq_name (str): Name of the sequence. - level (Optional[str], optional): Name of the level. Defaults to None. When None, use the default level named 'XRFeitoria'. - seq_fps (int, optional): Frame per second of the new sequence. Defaults to 60. - seq_length (int, optional): Frame length of the new sequence. Defaults to 1. - replace (bool, optional): Replace the exist same-name sequence. Defaults to False. - Yields: - SequenceBlender: Sequence object. - """ + cls, seq_name: str, level: str = ..., seq_fps: int = ..., seq_length: int = ..., replace: bool = ... + ) -> ContextManager[SequenceBlender]: ... @classmethod def open(cls, seq_name: str) -> ContextManager[SequenceBlender]: ... -class SequenceWrapperUnreal(SequenceWrapperBase): +class SequenceWrapperUnreal: @classmethod def new( cls, @@ -52,4 +24,20 @@ class SequenceWrapperUnreal(SequenceWrapperBase): seq_dir: Optional[str] = ..., ) -> ContextManager[SequenceUnreal]: ... @classmethod - def open(cls, seq_name: str) -> ContextManager[SequenceUnreal]: ... + def open(cls, seq_name: str, seq_dir: Optional[str] = ...) -> ContextManager[SequenceUnreal]: ... + +def sequence_wrapper_blender( + seq_name: str, + level: str = default_level_blender, + seq_fps: int = 30, + seq_length: int = 1, + replace: bool = False, +) -> Union[SequenceBlender, ContextManager[SequenceBlender]]: ... +def sequence_wrapper_unreal( + seq_name: str, + seq_dir: Optional[str] = None, + level: Optional[str] = None, + seq_fps: int = 30, + seq_length: int = 1, + replace: bool = False, +) -> Union[SequenceUnreal, ContextManager[SequenceUnreal]]: ... diff --git a/xrfeitoria/utils/__init__.py b/xrfeitoria/utils/__init__.py index 087d3959..27b04b37 100644 --- a/xrfeitoria/utils/__init__.py +++ b/xrfeitoria/utils/__init__.py @@ -1,3 +1,4 @@ +from .tools import setup_logger from .validations import Validator -__all__ = ['Validator'] +__all__ = ['Validator', 'setup_logger'] diff --git a/xrfeitoria/utils/anim/__init__.py b/xrfeitoria/utils/anim/__init__.py new file mode 100644 index 00000000..9e9e61a3 --- /dev/null +++ b/xrfeitoria/utils/anim/__init__.py @@ -0,0 +1 @@ +from .utils import dump_humandata, load_amass_motion, load_humandata_motion diff --git a/samples/anim/constants.py b/xrfeitoria/utils/anim/constants.py similarity index 100% rename from samples/anim/constants.py rename to xrfeitoria/utils/anim/constants.py diff --git a/samples/anim/motion.py b/xrfeitoria/utils/anim/motion.py similarity index 58% rename from samples/anim/motion.py rename to xrfeitoria/utils/anim/motion.py index 5e527adc..7f7d5f1e 100644 --- a/samples/anim/motion.py +++ b/xrfeitoria/utils/anim/motion.py @@ -1,11 +1,13 @@ +"""Motion data structure and related functions.""" from collections import OrderedDict from functools import partial -from typing import Callable, Dict, List, Optional +from pathlib import Path +from typing import Any, Callable, Dict, List, Literal, Optional, Tuple import numpy as np from scipy.spatial.transform import Rotation as spRotation -from typing_extensions import Self +from ...data_structure.constants import MotionFrame, PathLike from .constants import ( NUM_SMPLX_BODYJOINTS, SMPL_IDX_TO_JOINTS, @@ -19,6 +21,8 @@ ConverterType = Callable[[np.ndarray], np.ndarray] +__all__ = ['Motion', 'SMPLMotion', 'SMPLXMotion', 'get_humandata'] + class Converter: @classmethod @@ -108,67 +112,66 @@ def __init__( def _bone2idx(self, bone_name) -> Optional[int]: return self.BONE_NAME_TO_IDX.get(bone_name) - def get_transl(self, frame=0) -> np.ndarray: + def _get_transl(self, frame=0) -> np.ndarray: return self.transl[frame, :3] - def get_global_orient(self, frame=0) -> np.ndarray: + def _get_global_orient(self, frame=0) -> np.ndarray: return self.global_orient[frame, :3] - def get_bone_rotvec(self, bone_name, frame=0) -> np.ndarray: + def _get_bone_rotvec(self, bone_name, frame=0) -> np.ndarray: idx = self._bone2idx(bone_name) if idx == 0: - return self.get_global_orient(frame) + return self._get_global_orient(frame) elif idx: return self.body_poses[frame, idx, :3] else: return np.zeros([3], dtype=np.float32) - def get_bone_rotation(self, bone_name: str, frame=0) -> spRotation: - rotvec = self.get_bone_rotvec(bone_name, frame) + def _get_bone_rotation(self, bone_name: str, frame=0) -> spRotation: + rotvec = self._get_bone_rotvec(bone_name, frame) return spRotation.from_rotvec(rotvec) # type: ignore def get_bone_matrix_basis(self, bone_name: str, frame=0) -> np.ndarray: """pose2rest: relative to the bone space at rest pose. - Result: - np.ndarray: transform matrix like - [ - [R, T], - [0, 1] - ] + Args: + bone_name (str): bone name + frame (int, optional): frame index. Defaults to 0. + + Returns: + np.ndarray: transform matrix like [ [R, T], [0, 1] ] """ idx = self._bone2idx(bone_name) if idx == 0: - transl = self.get_transl(frame) + transl = self._get_transl(frame) else: transl = np.zeros(3) - rot = self.get_bone_rotation(bone_name, frame) + rot = self._get_bone_rotation(bone_name, frame) matrix_basis = rot.as_matrix() matrix_basis = np.pad(matrix_basis, (0, 1)) matrix_basis[:3, 3] = transl matrix_basis[3, 3] = 1 return matrix_basis - def get_parent_bone_name(self, bone_name: str) -> Optional[str]: + def _get_parent_bone_name(self, bone_name: str) -> Optional[str]: ... - def convert_fps_smplx_data(self, smplx_data: Dict[str, np.ndarray], scaling: int) -> Dict[str, np.ndarray]: + def _convert_fps_smplx_data(self, smplx_data: Dict[str, np.ndarray], scaling: int) -> Dict[str, np.ndarray]: for key, value in smplx_data.items(): if key in ['betas']: continue smplx_data[key] = value[::scaling, :] return smplx_data - def convert_fps(self, fps): + def convert_fps(self, fps: float): """Converts the frames per second (fps) of the animation to the specified value. Args: - fps (int): The desired frames per second. + fps (float): The desired frames per second. Raises: - NotImplementedError: - - If the desired fps is greater than the current fps, motion interpolation is not supported. - - If the desired fps is less than the current fps, motion interpolation is not supported. + NotImplementedError: If the fps is greater than the current fps. + NotImplementedError: If the fps is less than the current fps when undividable. """ if fps == self.fps: return @@ -181,9 +184,9 @@ def convert_fps(self, fps): self.global_orient: np.ndarray = self.global_orient[::scaling, :] self.n_frames = self.body_poses.shape[0] if hasattr(self, 'smpl_data'): - self.smpl_data = self.convert_fps_smplx_data(self.smpl_data, scaling) + self.smpl_data = self._convert_fps_smplx_data(self.smpl_data, scaling) if hasattr(self, 'smplx_data'): - self.smplx_data = self.convert_fps_smplx_data(self.smplx_data, scaling) + self.smplx_data = self._convert_fps_smplx_data(self.smplx_data, scaling) self.fps = fps elif fps > self.fps: # TODO: motion interpolation @@ -197,6 +200,9 @@ def slice_motion(self, frame_interval: int): Args: frame_interval (int): The frame interval to use for slicing the motion sequence. + + Raises: + TypeError: If the frame interval is not an integer. """ assert isinstance(frame_interval, int), TypeError(f'scaling={frame_interval} should be int') @@ -205,15 +211,19 @@ def slice_motion(self, frame_interval: int): self.global_orient: np.ndarray = self.global_orient[::frame_interval, :] self.n_frames = self.body_poses.shape[0] if hasattr(self, 'smpl_data'): - self.smpl_data = self.convert_fps_smplx_data(self.smpl_data, frame_interval) + self.smpl_data = self._convert_fps_smplx_data(self.smpl_data, frame_interval) if hasattr(self, 'smplx_data'): - self.smplx_data = self.convert_fps_smplx_data(self.smplx_data, frame_interval) + self.smplx_data = self._convert_fps_smplx_data(self.smplx_data, frame_interval) def sample_motion(self, n_frames: int): - """Randomly sample motion to n_frames. + """Randomly sample motions, picking n_frames from the original motion sequence. + The indices are totally random using `np.random.choice`. Args: n_frames (int): The number of frames to sample. Randomly sampled from the original motion sequence. + + Raises: + AssertionError: If the number of frames to sample is less than or equal to 0. """ assert n_frames > 0, f'n_frames={n_frames}' if n_frames == self.n_frames: @@ -235,7 +245,10 @@ def sample_motion(self, n_frames: int): self.insert_rest_pose() def cut_transl(self): - """Cut the transl to zero.""" + """Cut the transl to zero. + + This will make the animation stay in place, like root motion. + """ self.transl = np.zeros_like(self.transl) if hasattr(self, 'smpl_data'): self.smpl_data['transl'] = np.zeros_like(self.smpl_data['transl']) @@ -252,24 +265,24 @@ def insert_rest_pose(self): for key, arr in self.smpl_data.items(): if key == 'betas': continue - self.smplx_dat[key] = np.insert(arr, 0, 0, axis=0) + self.smpl_data[key] = np.insert(arr, 0, 0, axis=0) if hasattr(self, 'smplx_data'): for key, arr in self.smplx_data.items(): if key == 'betas': continue self.smplx_data[key] = np.insert(arr, 0, 0, axis=0) - def get_motion_data(self) -> List[Dict[str, Dict[str, List[float]]]]: - """Returns a list of dictionaries containing motion data for each frame of the - animation. + def get_motion_data(self) -> List[MotionFrame]: + """Returns a list of dictionaries containing `rotation` and `location` for each + bone of each frame in the animation. Each dictionary contains bone names as keys and a nested dictionary as values. The nested dictionary contains 'rotation' and 'location' keys, which correspond to the rotation and location of the bone in that frame. Returns: - List[Dict[str, Dict[str, List[float]]]]: A list of dictionaries containing motion data for each frame of the animation. + List[MotionFrame]: A list of dictionaries containing motion data for each frame of the animation. """ - motion_data: List[Dict[str, Dict[str, List[float]]]] = [] + motion_data: List[MotionFrame] = [] for frame in range(self.n_frames): frame_motion_data = {} for bone_name in self.BONE_NAMES: @@ -283,13 +296,25 @@ def get_motion_data(self) -> List[Dict[str, Dict[str, List[float]]]]: motion_data.append(frame_motion_data) return motion_data + def copy(self) -> 'Motion': + """Return a copy of the motion instance.""" + return self.__class__( + transl=self.transl.copy(), + body_poses=self.body_poses.copy(), + n_frames=self.n_frames, + fps=self.fps, + ) + + def __repr__(self) -> str: + return f'Motion(n_frames={self.n_frames}, fps={self.fps})' + class SMPLMotion(Motion): SMPL_IDX_TO_NAME: Dict[int, str] = OrderedDict(SMPL_IDX_TO_JOINTS) NAME_TO_SMPL_IDX = OrderedDict([(v, k) for k, v in SMPL_IDX_TO_NAME.items() if v]) NAMES = [x for x in SMPL_IDX_TO_NAME.values() if x] PARENTS = list(SMPL_PARENT_IDX) - BONE_NAMES = SMPLX_JOINT_NAMES[:NUM_SMPLX_BODYJOINTS] + BONE_NAMES = SMPLX_JOINT_NAMES[1 : NUM_SMPLX_BODYJOINTS + 1] BONE_NAME_TO_IDX: Dict[str, int] = {bone_name: idx for idx, bone_name in enumerate(BONE_NAMES)} # In order to make the smpl head up to +z @@ -313,14 +338,12 @@ def from_smpl_data( insert_rest_pose: bool = False, global_orient_adj: Optional[spRotation] = GLOBAL_ORIENT_ADJUSTMENT, vector_convertor: Optional[ConverterType] = Converter.vec_humandata2smplx, - ) -> Self: + ) -> 'SMPLMotion': """Create SMPLMotion instance from smpl_data. `smpl_data` should be a dict like object, - with required keys: - ["body_pose", "global_orient"] - and optional key: - ["transl"]. + with required keys: ['betas', 'body_pose', 'global_orient'] + and optional key: ['transl'] Args: smpl_data: dict with require keys ["body_pose", "global_orient"] @@ -332,10 +355,10 @@ def from_smpl_data( SMPLMotion: An instance of SMPLMotion containing the smpl_data. """ smpl_data = dict(smpl_data) - _get_smpl = partial(_get_from_smpl_x, smpl_x_data=smpl_data, dtype=np.float32) + _get_smpl = partial(_get_from_smpl_x_, smpl_x_data=smpl_data, dtype=np.float32) n_frames = smpl_data['body_pose'].shape[0] - betas = _get_smpl('betas', shape=[10]) + betas = _get_smpl('betas', shape=[1, 10]) transl = _get_smpl('transl', shape=[n_frames, 3], required=False) global_orient = _get_smpl('global_orient', shape=[n_frames, 3]) body_pose = _get_smpl('body_pose', shape=[n_frames, -1]) @@ -378,16 +401,16 @@ def from_smpl_data( instance.smpl_data = smpl_data return instance - def get_bone_rotvec(self, bone_name, frame=0) -> np.ndarray: + def _get_bone_rotvec(self, bone_name, frame=0) -> np.ndarray: idx = self._bone2idx(bone_name) if idx == 0: - return self.get_global_orient(frame) + return self._get_global_orient(frame) elif idx: return self.body_poses[frame, idx, :3] else: return np.zeros([3], dtype=np.float32) - def get_parent_bone_name(self, bone_name) -> Optional[str]: + def _get_parent_bone_name(self, bone_name) -> Optional[str]: idx = self._bone2idx(bone_name) if idx is None: raise ValueError(f'bone.name="{bone_name}" not in smpl skeleton.') @@ -398,6 +421,73 @@ def get_parent_bone_name(self, bone_name) -> Optional[str]: else: return self.BONE_NAMES[parent_idx] + def dump_humandata( + self, + filepath: PathLike, + betas: np.ndarray, + meta: Optional[Dict[str, Any]] = None, + global_orient_offset: np.ndarray = np.zeros(3), + transl_offset: np.ndarray = np.zeros(3), + root_location_t0: Optional[np.ndarray] = None, + pelvis_location_t0: Optional[np.ndarray] = None, + ) -> None: + """Dump the motion data to a humandata file at the given `filepath`. + + Args: + filepath (PathLike): The filepath to dump the motion data to. + betas (np.ndarray): The betas array. + meta (Optional[Dict[str, Any]]): Additional metadata. Defaults to None. + global_orient_offset (np.ndarray): The global orientation offset. Defaults to np.zeros(3). + transl_offset (np.ndarray): The translation offset. Defaults to np.zeros(3). + root_location_t0 (Optional[np.ndarray]): The root location at time 0. Defaults to None. + pelvis_location_t0 (Optional[np.ndarray]): The pelvis location at time 0. Defaults to None. + + Note: + HumanData is a structure of smpl/smplx data defined in https://github.com/open-mmlab/mmhuman3d/blob/main/docs/human_data.md + + The humandata file is a npz file containing the following keys: + + .. code-block:: python + + motion_data = { + '__data_len__': n_frames, + 'smpl': { + 'betas': betas, # (1, 10) + 'transl': transl, # (n_frames, 3) + 'global_orient': global_orient, # (n_frames, 3) + 'body_pose': body_pose, # (n_frames, 69) + }, + 'meta': {'gender': 'neutral'}, # optional + } + """ + humandata = get_humandata( + smpl_x_data=self.smplx_data, + smpl_x_type='smpl', + betas=betas, + meta=meta, + global_orient_offset=global_orient_offset, + transl_offset=transl_offset, + root_location_t0=root_location_t0, + pelvis_location_t0=pelvis_location_t0, + ) + filepath = Path(filepath).resolve() + filepath.parent.mkdir(parents=True, exist_ok=True) + np.savez(filepath, **humandata) + + def copy(self) -> 'SMPLMotion': + """Return a copy of the motion instance.""" + instance = self.__class__( + transl=self.transl.copy(), + body_poses=self.body_poses.copy(), + n_frames=self.n_frames, + fps=self.fps, + ) + instance.smpl_data = {k: v.copy() for k, v in self.smpl_data.items()} + return instance + + def __repr__(self) -> str: + return f'SMPLMotion(n_frames={self.n_frames}, fps={self.fps})' + class SMPLXMotion(Motion): SMPLX_IDX_TO_NAME: Dict[int, str] = OrderedDict(SMPLX_IDX_TO_JOINTS) @@ -429,14 +519,12 @@ def from_smplx_data( flat_hand_mean: bool = False, global_orient_adj: Optional[spRotation] = GLOBAL_ORIENT_ADJUSTMENT, vector_convertor: Optional[Callable[[np.ndarray], np.ndarray]] = Converter.vec_humandata2smplx, - ) -> Self: + ) -> 'SMPLXMotion': """Create SMPLXMotion instance from smplx_data. `smplx_data` should be a dict like object, - with required keys: - ["body_pose", "global_orient"] - and optional key: - ["transl"] + with required keys: ['betas', "body_pose", "global_orient"] + and optional key: ['transl', 'jaw_pose', 'leye_pose', 'reye_pose', 'left_hand_pose', 'right_hand_pose', 'expression'] Args: smplx_data: require keys ["body_pose", "global_orient"] @@ -455,7 +543,7 @@ def from_smplx_data( SMPLXMotion: An instance of SMPLXMotion containing the smplx_data. """ smplx_data = dict(smplx_data) - _get_smplx = partial(_get_from_smpl_x, smpl_x_data=smplx_data, dtype=np.float32) + _get_smplx = partial(_get_from_smpl_x_, smpl_x_data=smplx_data, dtype=np.float32) n_frames = smplx_data['body_pose'].shape[0] betas = _get_smplx('betas', shape=[1, 10]) transl = _get_smplx('transl', shape=[n_frames, 3], required=False) @@ -525,12 +613,13 @@ def from_smplx_data( return instance @classmethod - def from_amass_data(cls, amass_data, insert_rest_pose: bool) -> Self: - """Create a Motion instance from AMASS data. + def from_amass_data(cls, amass_data, insert_rest_pose: bool, flat_hand_mean: bool = True) -> 'SMPLXMotion': + """Create a Motion instance from AMASS data (SMPLX) Args: amass_data (dict): A dictionary containing the AMASS data. insert_rest_pose (bool): Whether to insert a rest pose at the beginning of the motion. + flat_hand_mean (bool): Whether to use the flat hand mean pose. Returns: SMPLXMotion: A SMPLXMotion instance containing the AMASS data. @@ -588,14 +677,9 @@ def from_amass_data(cls, amass_data, insert_rest_pose: bool) -> Self: # arr[0, 1] = pelvis_height smplx_data[key] = arr - return cls.from_smplx_data( - smplx_data, - insert_rest_pose=False, - fps=fps, - flat_hand_mean=True, - ) + return cls.from_smplx_data(smplx_data, insert_rest_pose=False, fps=fps, flat_hand_mean=flat_hand_mean) - def get_parent_bone_name(self, bone_name) -> Optional[str]: + def _get_parent_bone_name(self, bone_name) -> Optional[str]: idx = self._bone2idx(bone_name) if idx is None: raise ValueError(f'bone.name="{bone_name}" not in smplx skeleton.') @@ -606,8 +690,82 @@ def get_parent_bone_name(self, bone_name) -> Optional[str]: else: return self.BONE_NAMES[parent_idx] + def dump_humandata( + self, + filepath: PathLike, + betas: np.ndarray, + meta: Optional[Dict[str, Any]] = None, + global_orient_offset: np.ndarray = np.zeros(3), + transl_offset: np.ndarray = np.zeros(3), + root_location_t0: Optional[np.ndarray] = None, + pelvis_location_t0: Optional[np.ndarray] = None, + ) -> None: + """Dump the motion data to a humandata file at the given `filepath`. -def _get_from_smpl_x(key, shape, *, smpl_x_data, dtype=np.float32, required=True) -> np.ndarray: + Args: + filepath (PathLike): The filepath to dump the motion data to. + betas (np.ndarray): The betas array. + meta (Optional[Dict[str, Any]]): Additional metadata. Defaults to None. + global_orient_offset (np.ndarray): The global orientation offset. Defaults to np.zeros(3). + transl_offset (np.ndarray): The translation offset. Defaults to np.zeros(3). + root_location_t0 (Optional[np.ndarray]): The root location at time 0. Defaults to None. + pelvis_location_t0 (Optional[np.ndarray]): The pelvis location at time 0. Defaults to None. + + Note: + + HumanData is a structure of smpl/smplx data defined in https://github.com/open-mmlab/mmhuman3d/blob/main/docs/human_data.md + + The humandata file is a npz file containing the following keys: + + .. code-block:: python + + humandata = { + '__data_len__': n_frames, + 'smplx': { + 'betas': betas, # (1, 10) + 'transl': transl, # (n_frames, 3) + 'global_orient': global_orient, # (n_frames, 3) + 'body_pose': body_pose, # (n_frames, 63) + 'jaw_pose': jaw_pose, # (n_frames, 3) + 'leye_pose': leye_pose, # (n_frames, 3) + 'reye_pose': reye_pose, # (n_frames, 3) + 'left_hand_pose': left_hand_pose, # (n_frames, 45) + 'right_hand_pose': right_hand_pose, # (n_frames, 45) + 'expression': expression, # (n_frames, 10) + }, + 'meta': {'gender': 'neutral'}, # optional + } + """ + humandata = get_humandata( + smpl_x_data=self.smplx_data, + smpl_x_type='smplx', + betas=betas, + meta=meta, + global_orient_offset=global_orient_offset, + transl_offset=transl_offset, + root_location_t0=root_location_t0, + pelvis_location_t0=pelvis_location_t0, + ) + filepath = Path(filepath).resolve() + filepath.parent.mkdir(parents=True, exist_ok=True) + np.savez(filepath, **humandata) + + def copy(self) -> 'SMPLXMotion': + """Return a copy of the motion instance.""" + instance = self.__class__( + transl=self.transl.copy(), + body_poses=self.body_poses.copy(), + n_frames=self.n_frames, + fps=self.fps, + ) + instance.smplx_data = {k: v.copy() for k, v in self.smplx_data.items()} + return instance + + def __repr__(self) -> str: + return f'SMPLXMotion(n_frames={self.n_frames}, fps={self.fps})' + + +def _get_from_smpl_x_(key, shape, *, smpl_x_data, dtype=np.float32, required=True) -> np.ndarray: """Get data from smpl-x data dict. Args: @@ -624,7 +782,123 @@ def _get_from_smpl_x(key, shape, *, smpl_x_data, dtype=np.float32, required=True _data = smpl_x_data[key].astype(dtype) n_frames, n_dims = shape _data = _data.reshape([n_frames, -1]) - _data = _data[:, :n_dims] # XXX: handle the case that n_dims > data.shape[1] + if not n_dims < 0: + _data = _data[:, :n_dims] # XXX: handle the case that n_dims > data.shape[1] return _data return np.zeros(shape, dtype=dtype) - return np.zeros(shape, dtype=dtype) + + +def _transform_transl_global_orient_( + global_orient: np.ndarray, + transl: np.ndarray, + global_orient_offset: np.ndarray, + transl_offset: np.ndarray, + root_location_t0: Optional[np.ndarray] = None, + pelvis_location_t0: Optional[np.ndarray] = None, +) -> Tuple[np.ndarray, np.ndarray]: + """Transform the global orientation and translation based on the given offsets. + + Args: + global_orient (np.ndarray): Global orientation array. + transl (np.ndarray): Translation array. + global_orient_offset (np.ndarray): Global orientation offset array. + transl_offset (np.ndarray): Translation offset array. + root_location_t0 (Optional[np.ndarray]): Root location at time 0. Defaults to None. + pelvis_location_t0 (Optional[np.ndarray]): Pelvis location at time 0. Defaults to None. + + Returns: + Tuple[np.ndarray, np.ndarray]: Transformed global orientation and translation arrays. + """ + R_offset = spRotation.from_rotvec(global_orient_offset) * spRotation.from_rotvec(global_orient[0, :]).inv() + global_orient_ = (R_offset * spRotation.from_rotvec(global_orient)).as_rotvec() + + loc0 = transl[0, :] + + if pelvis_location_t0 is not None and root_location_t0 is not None: + transl_offset_t0 = pelvis_location_t0 - root_location_t0 + rot_pivot_offset = transl_offset_t0 + transl_offset - loc0 + transl_ = R_offset.apply(transl + rot_pivot_offset) - pelvis_location_t0 + else: + transl_ = transl + transl_offset - loc0 + + return global_orient_, transl_ + + +def get_humandata( + smpl_x_data: Dict[str, np.ndarray], + smpl_x_type: Literal['smpl', 'smplx'], + betas: np.ndarray, + meta: Optional[Dict[str, Any]] = None, + global_orient_offset: np.ndarray = np.zeros(3), + transl_offset: np.ndarray = np.zeros(3), + root_location_t0: Optional[np.ndarray] = None, + pelvis_location_t0: Optional[np.ndarray] = None, +) -> Dict[str, Any]: + """Get human data for a given set of parameters. + + Args: + smpl_x_data (Dict[str, np.ndarray]): Dictionary containing the SMPL-X data. + smpl_x_type (Literal['smpl', 'smplx']): Type of SMPL-X model. + betas (np.ndarray): Array of shape (n, 10) representing the shape parameters. + meta (Optional[Dict[str, Any]], optional): Additional metadata. Defaults to None. + global_orient_offset (np.ndarray): Array of shape (n, 3) representing the global orientation offset. + transl_offset (np.ndarray): Array of shape (3,) representing the translation offset. + root_location_t0 (Optional[np.ndarray], optional): Array of shape (3,) representing the root location at time t=0. Defaults to None. + pelvis_location_t0 (Optional[np.ndarray], optional): Array of shape (3,) representing the pelvis location at time t=0. Defaults to None. + + Returns: + dict: Dictionary containing the human data. + """ + global_orient = smpl_x_data['global_orient'].reshape(-1, 3) + n = global_orient.shape[0] + transl = smpl_x_data['transl'].reshape(n, 3) + body_pose = smpl_x_data['body_pose'].reshape(n, -1) + bone_len = body_pose.shape[1] + assert n > 0, f'Got n_frames={n}, should be > 0.' + assert bone_len in (63, 69), f'Got body_pose in [{n}, {bone_len}], should be in shape of [n, 63] or [n, 69].' + + # transform + global_orient_, transl_ = _transform_transl_global_orient_( + global_orient=global_orient, + transl=transl, + global_orient_offset=global_orient_offset, + transl_offset=transl_offset, + root_location_t0=root_location_t0, + pelvis_location_t0=pelvis_location_t0, + ) + + if smpl_x_type == 'smpl': + if bone_len == 69: + body_pose_ = body_pose + elif bone_len == 63: + body_pose_ = np.concatenate([body_pose, np.zeros([n, 6])], axis=1, dtype=np.float32) + else: + body_pose_ = body_pose[:, :63] + + smpl_x_data_ = { + 'betas': betas.astype(np.float32), + 'global_orient': global_orient_.astype(np.float32), + 'transl': transl_.astype(np.float32), + 'body_pose': body_pose_.astype(np.float32), + } + + if smpl_x_type == 'smplx': + extra = { + 'left_hand_pose': np.zeros([n, 45], dtype=np.float32), + 'right_hand_pose': np.zeros([n, 45], dtype=np.float32), + 'jaw_pose': np.zeros([n, 3], dtype=np.float32), + 'leye_pose': np.zeros([n, 3], dtype=np.float32), + 'reye_pose': np.zeros([n, 3], dtype=np.float32), + 'expression': np.zeros([n, 10], dtype=np.float32), + } + for k, v in extra.items(): + if k in smpl_x_data: + extra[k] = smpl_x_data[k].reshape(v.shape).astype(np.float32) + smpl_x_data_.update(extra) + + humandata = { + '__data_len__': global_orient_.shape[0], + smpl_x_type: smpl_x_data_, + 'meta': meta, + } + return humandata diff --git a/samples/anim/transform3d.py b/xrfeitoria/utils/anim/transform3d.py similarity index 84% rename from samples/anim/transform3d.py rename to xrfeitoria/utils/anim/transform3d.py index b3f54a19..d716f822 100644 --- a/samples/anim/transform3d.py +++ b/xrfeitoria/utils/anim/transform3d.py @@ -3,7 +3,6 @@ import numpy as np import numpy.typing as npt from scipy.spatial.transform import Rotation as spRotation -from typing_extensions import Self class Matrix: @@ -23,25 +22,25 @@ def __init__(self, mat: Optional[npt.ArrayLike] = None): ) self.data = mat - def to_4x4(self) -> Self: + def to_4x4(self) -> 'Matrix': mat = np.eye(4) old = self.data[:4, :4] mat[: old.shape[0], : old.shape[1]] = old return Matrix(mat) - def to_3x3(self) -> Self: + def to_3x3(self) -> 'Matrix': mat = np.eye(3) old = self.data[:3, :3] mat[: old.shape[0], : old.shape[1]] = old return Matrix(mat) - def to_2x2(self) -> Self: + def to_2x2(self) -> 'Matrix': mat = np.eye(2) old = self.data[:2, :2] mat[: old.shape[0], : old.shape[1]] = old return Matrix(mat) - def inverted(self) -> Self: + def inverted(self) -> 'Matrix': return Matrix(np.linalg.inv(self.data)) def decompose(self) -> Tuple[np.ndarray, np.ndarray, np.ndarray]: @@ -56,14 +55,14 @@ def _to_matmul_type(value) -> np.ndarray: except Exception as e: raise TypeError(f'Unsupported operand type for @, value: {value}') - def __matmul__(self, other) -> Self: + def __matmul__(self, other) -> 'Matrix': right = self._to_matmul_type(other) left = self.data if self.data.shape[1] != other.data.shape[0]: raise ValueError('Matrix multiplication: shape mismatch: ' f'{left.shape} @ {right.shape}') return Matrix(left @ right) - def __rmatmul__(self, other) -> Self: + def __rmatmul__(self, other) -> 'Matrix': left = self._to_matmul_type(other) right = self.data if self.data.shape[1] != other.data.shape[0]: @@ -139,30 +138,27 @@ def __repr__(self) -> str: return self.__str__() -def decompose_trs( - mat: npt.ArrayLike, -) -> Tuple[np.ndarray, np.ndarray, np.ndarray]: +def decompose_trs(mat: npt.ArrayLike) -> Tuple[np.ndarray, np.ndarray, np.ndarray]: """Decompose a 4x4 transformation matrix into 3 parts: translation, rotation, scale. - Parameters - ---------- - mat : npt.ArrayLike - A transformation matrix of shape (N, 4, 4) or (4, 4) - e.g. - [ - [1, 0, 0, x], - [0, 1, 0, y], - [0, 0, 1, z], - [0, 0, 0, 1], - ] - - Returns - ------- - Tuple[np.ndarray, np.ndarray, np.ndarray] - - translation(x, y, z): np.ndarray of shape (N, 3) or (3,) - - quaternion(w, x, y, z): np.ndarray of shape (N, 4) - - scaling(x, y, z): np.ndarray of shape (N, 3) or (3,) + Args: + mat: npt.ArrayLike + A transformation matrix of shape (N, 4, 4) or (4, 4) + e.g. + [ + [1, 0, 0, x], + [0, 1, 0, y], + [0, 0, 1, z], + [0, 0, 0, 1], + ] + + Returns: + Tuple[np.ndarray, np.ndarray, np.ndarray]: + + - translation(x, y, z): np.ndarray of shape (N, 3) or (3,) + - quaternion(w, x, y, z): np.ndarray of shape (N, 4) + - scaling(x, y, z): np.ndarray of shape (N, 3) or (3,) """ mat = np.array(mat) ndim = mat.ndim diff --git a/xrfeitoria/utils/anim/utils.py b/xrfeitoria/utils/anim/utils.py new file mode 100644 index 00000000..84ac0b3e --- /dev/null +++ b/xrfeitoria/utils/anim/utils.py @@ -0,0 +1,108 @@ +"""Utilities for animation data loading and dumping.""" +from pathlib import Path +from typing import Union + +import numpy as np + +from ...data_structure.constants import PathLike +from .motion import Motion, SMPLMotion, SMPLXMotion + + +def load_amass_motion(input_amass_smplx_path: PathLike) -> SMPLXMotion: + """Load AMASS SMPLX motion data. Only for SMPLX motion for now. + + Args: + input_amass_smplx_path (PathLike): Path to AMASS SMPLX motion data. + + Returns: + Motion: Motion data, which consists of data read from AMASS file. + """ + input_amass_smplx_path = Path(input_amass_smplx_path).resolve() + if not input_amass_smplx_path.exists(): + raise ValueError(f'Not exist: {input_amass_smplx_path}') + # Use AMASS motion + # src_actor_name = "SMPLX" + amass_smplx_data = np.load(input_amass_smplx_path, allow_pickle=True) + src_motion = SMPLXMotion.from_amass_data(amass_smplx_data, insert_rest_pose=True) + return src_motion + + +def load_humandata_motion(input_humandata_path: PathLike) -> Union[SMPLMotion, SMPLXMotion]: + """Load humandata SMPL / SMPLX motion data. + + HumanData is a structure of smpl/smplx data defined in https://github.com/open-mmlab/mmhuman3d/blob/main/docs/human_data.md + + Args: + input_humandata_path (PathLike): Path to humandata SMPL / SMPLX motion data. + + Returns: + Union[SMPLMotion, SMPLXMotion]: Motion data, which consists of data read from humandata file. + """ + input_humandata_path = Path(input_humandata_path).resolve() + if not input_humandata_path.exists(): + raise ValueError(f'Not exist: {input_humandata_path}') + # Use humandata SMPL / SMPLX + humandata = np.load(input_humandata_path, allow_pickle=True) + if 'smpl' in humandata: + # src_actor_name = "SMPL" + smpl_data = humandata['smpl'].item() + src_motion = SMPLMotion.from_smpl_data(smpl_data=smpl_data, insert_rest_pose=False) + else: + # src_actor_name = "SMPLX" + smplx_data = humandata['smplx'].item() + src_motion = SMPLXMotion.from_smplx_data(smplx_data=smplx_data, insert_rest_pose=False) + return src_motion + + +def dump_humandata(motion: SMPLXMotion, save_filepath: PathLike, meta_filepath: PathLike) -> None: + """Dump human data to a file. This function must be associate with a meta file + provided by SMPL-XL. + + Args: + motion (SMPLXMotion): Motion data to dump. + save_filepath (PathLike): The file path to save the dumped data. + meta_filepath (PathLike): The file path to the meta information, storing the parameters of the SMPL-XL model. + + Note: + HumanData is a structure of smpl/smplx data defined in https://github.com/open-mmlab/mmhuman3d/blob/main/docs/human_data.md + + The humandata file is a npz file containing the following keys: + + .. code-block:: python + + humandata = { + '__data_len__': n_frames, + 'smplx': { + 'betas': betas, # (1, 10) + 'transl': transl, # (n_frames, 3) + 'global_orient': global_orient, # (n_frames, 3) + 'body_pose': body_pose, # (n_frames, 63) + 'jaw_pose': jaw_pose, # (n_frames, 3) + 'leye_pose': leye_pose, # (n_frames, 3) + 'reye_pose': reye_pose, # (n_frames, 3) + 'left_hand_pose': left_hand_pose, # (n_frames, 45) + 'right_hand_pose': right_hand_pose, # (n_frames, 45) + 'expression': expression, # (n_frames, 10) + }, + 'meta': {'gender': 'neutral'}, # optional + } + """ + meta_info = np.load(meta_filepath, allow_pickle=True) + smplx = meta_info['smplx'].item() + motion.dump_humandata( + filepath=save_filepath, + betas=smplx['betas'], + meta=meta_info['meta'].item(), + global_orient_offset=smplx['global_orient'], + transl_offset=smplx['transl'], + root_location_t0=smplx['root_location_t0'], + pelvis_location_t0=smplx['pelvis_location_t0'], + ) + + +if __name__ == '__main__': + """Python -m xrfeitoria.utils.anim.utils.""" + motion = load_amass_motion('.cache/ACCAD/s001/EricCamper04_stageii.npz') + motion_data = motion.get_motion_data() + dump_humandata(motion, '.cache/SMPL-XL_test.npz', '.cache/SMPL-XL-001.npz') + print('Done') diff --git a/xrfeitoria/utils/functions/blender_functions.py b/xrfeitoria/utils/functions/blender_functions.py index 9b1cfaee..321352e4 100644 --- a/xrfeitoria/utils/functions/blender_functions.py +++ b/xrfeitoria/utils/functions/blender_functions.py @@ -1,13 +1,13 @@ """Remote functions for blender.""" from pathlib import Path -from typing import Dict, List, Literal, Tuple +from typing import Dict, List, Literal, Optional, Tuple -from ...data_structure.constants import ImportFileFormatEnum, PathLike, Vector +from ...data_structure.constants import ImportFileFormatEnum, MotionFrame, PathLike, Vector from ...rpc import remote_blender try: - # only for linting, not imported in runtime + # linting and for engine import bpy from XRFeitoriaBpy import logger # defined in src/XRFeitoriaBpy/__init__.py from XRFeitoriaBpy.core.factory import XRFeitoriaBlenderFactory # defined in src/XRFeitoriaBpy/core/factory.py @@ -67,16 +67,27 @@ def import_file(file_path: 'PathLike') -> None: @remote_blender() -def apply_motion_data_to_actor(motion_data: 'List[Dict[str, Dict[str, List[float]]]]', actor_name: str) -> None: +def apply_motion_data_to_actor(motion_data: 'List[MotionFrame]', actor_name: str) -> None: """Applies motion data to a given actor in Blender. Args: - motion_data (List[Dict[str, Dict[str, List[float]]]]): A list of dictionaries containing motion data for the actor. + motion_data (List[MotionFrame]): A list of dictionaries containing motion data for the actor. actor_name (str): The name of the actor to apply the motion data to. """ XRFeitoriaBlenderFactory.apply_motion_data_to_actor(motion_data=motion_data, actor_name=actor_name) +@remote_blender() +def apply_shape_keys_to_mesh(shape_keys: 'List[Dict[str, float]]', mesh_name: str) -> None: + """Apply shape keys to the given mesh. + + Args: + shape_keys (List[Dict[str, float]]): A list of dictionaries representing the shape keys and their values. + mesh_name (str): Name of the mesh. + """ + XRFeitoriaBlenderFactory.apply_shape_keys_to_mesh(shape_keys=shape_keys, mesh_name=mesh_name) + + @remote_blender() def is_background_mode(warning: bool = False) -> bool: """Check whether Blender is running in background mode. @@ -100,7 +111,8 @@ def cleanup_unused(): @remote_blender() def save_blend(save_path: 'PathLike' = None, pack: bool = False): - """Save the current blend file to the given path. + """Save the current blend file to the given path. If no path is given, save to the + current blend file path. Args: save_path (PathLike, optional): Path to save the blend file. Defaults to None. @@ -155,6 +167,22 @@ def set_hdr_map(hdr_map_path: 'PathLike') -> None: XRFeitoriaBlenderFactory.set_hdr_map(scene=scene, hdr_map_path=hdr_map_path) +@remote_blender() +def set_active_level(level_name: str): + """Sets the active level in XRFeitoria Blender Factory. + + Args: + level_name (str): The name of the level to set as active. (e.g. 'Scene') + + Example: + >>> import xrfeitoria as xf + >>> xf_runner = xf.init_blender() + >>> xf_runner.utils.set_active_level('Scene') # Return to default level defined by blender + """ + level = XRFeitoriaBlenderFactory.get_scene(level_name) + XRFeitoriaBlenderFactory.set_scene_active(level) + + @remote_blender() def get_frame_range() -> 'Tuple[int, int]': """Get the frame range of the active scene. @@ -272,6 +300,19 @@ def get_rotation_to_look_at(location: 'Vector', target: 'Vector') -> 'Vector': return tuple(math.degrees(r) for r in rotation) +@remote_blender() +def check_sequence(seq_name: str) -> bool: + """Check whether the sequence exists. + + Args: + seq_name (str): Name of the sequence. + + Returns: + bool: True if the sequence exists. + """ + return seq_name in bpy.data.collections.keys() + + @remote_blender() def init_scene_and_collection(name: str, cleanup: bool = False) -> None: """Init the default scene and default collection. @@ -314,3 +355,20 @@ def enable_gpu(gpu_num: int = 1): gpu_num (int, optional): Number of GPUs to use. Defaults to 1. """ XRFeitoriaBlenderFactory.enable_gpu(gpu_num=gpu_num) + + +@remote_blender() +def install_plugin(plugin_path: 'PathLike', plugin_name_blender: 'Optional[str]' = None): + """Install plugin in blender. + + Args: + path (PathLike): Path to the plugin. + """ + bpy.ops.preferences.addon_install(filepath=Path(plugin_path).resolve().as_posix()) + if plugin_name_blender is None: + plugin_name_blender = Path(plugin_path).stem + logger.warning(f'Plugin name not specified, use {plugin_name_blender} as default.') + bpy.ops.preferences.addon_enable(module=plugin_name_blender) + bpy.ops.wm.save_userpref() + + logger.info(f'Plugin {plugin_name_blender} installed successfully.') diff --git a/xrfeitoria/utils/functions/unreal_functions.py b/xrfeitoria/utils/functions/unreal_functions.py index 1af434bd..1e226fb3 100644 --- a/xrfeitoria/utils/functions/unreal_functions.py +++ b/xrfeitoria/utils/functions/unreal_functions.py @@ -69,35 +69,40 @@ def save_current_level(asset_path: 'Optional[str]' = None) -> None: @remote_unreal() -def import_asset(path: 'Union[str, List[str]]', dst_dir_in_engine: 'Optional[str]' = None) -> 'Union[str, List[str]]': +def import_asset( + path: 'Union[str, List[str]]', dst_dir_in_engine: 'Optional[str]' = None, replace: bool = True +) -> 'Union[str, List[str]]': """Import assets to the default asset path. Args: path (Union[str, List[str]]): a file path or a list of file paths to import, e.g. "D:/assets/SMPL_XL.fbx" dst_dir_in_engine (Optional[str], optional): destination directory in the engine. Defaults to None falls back to '/Game/XRFeitoriaUnreal/Assets' + replace (bool, optional): whether to replace the existing asset. Defaults to True. Returns: Union[str, List[str]]: a path or a list of paths to the imported assets, e.g. "/Game/XRFeitoriaUnreal/Assets/SMPL_XL" """ - paths = XRFeitoriaUnrealFactory.utils.import_asset(path, dst_dir_in_engine) + paths = XRFeitoriaUnrealFactory.utils.import_asset(path, dst_dir_in_engine, replace=replace) if len(paths) == 1: return paths[0] return paths @remote_unreal() -def import_anim(path: str, skeleton_path: str) -> 'List[str]': +def import_anim(path: str, skeleton_path: str, dest_path: 'Optional[str]' = None, replace: bool = True) -> 'List[str]': """Import animation to the default asset path. Args: - path (str): a file path to import, e.g. "D:/assets/SMPL_XL-Animation.fbx" - skeleton_path (str): a path to the skeleton, e.g. "/Game/XRFeitoriaUnreal/Assets/SMPL_XL_Skeleton" + path (str): The file path to import, e.g. "D:/assets/SMPL_XL-Animation.fbx". + skeleton_path (str): The path to the skeleton, e.g. "/Game/XRFeitoriaUnreal/Assets/SMPL_XL_Skeleton". + dest_path (str, optional): The destination directory in the engine. Defaults to None, falls back to {skeleton_path.parent}/Animation. + replace (bool, optional): whether to replace the existing asset. Defaults to True. Returns: - str: a path to the imported animation, e.g. "/Game/XRFeitoriaUnreal/Assets/SMPL_XL-Animation" + List[str]: A list of paths to the imported animations, e.g. ["/Game/XRFeitoriaUnreal/Assets/SMPL_XL-Animation"]. """ - return XRFeitoriaUnrealFactory.utils.import_anim(path, skeleton_path) + return XRFeitoriaUnrealFactory.utils.import_anim(path, skeleton_path, dest_path, replace=replace) @remote_unreal() @@ -119,6 +124,30 @@ def duplicate_asset(src_path: str, dst_path: str, replace: bool = False) -> None unreal.EditorAssetLibrary.save_asset(dst_path) +@remote_unreal() +def new_seq_data(asset_path: str, sequence_path: str, map_path: str) -> None: + """Create a new data asset of sequence data. + + Args: + asset_path (str): path of the data asset. + sequence_path (str): path of the sequence asset. + map_path (str): path of the map asset. + + Returns: + unreal.DataAsset: the created data asset. + + Notes: + SequenceData Properties: + - "SequencePath": str + - "MapPath": str + """ + XRFeitoriaUnrealFactory.Sequence.new_data_asset( + asset_path=asset_path, + sequence_path=sequence_path, + map_path=map_path, + ) + + @remote_unreal() def delete_asset(asset_path: str) -> None: """Delete asset. diff --git a/xrfeitoria/utils/plugin_infos.json b/xrfeitoria/utils/plugin_infos.json index 563bf95d..329bc044 100644 --- a/xrfeitoria/utils/plugin_infos.json +++ b/xrfeitoria/utils/plugin_infos.json @@ -1,4 +1,9 @@ { + "0.6.0": { + "XRFeitoria": "0.6.0", + "XRFeitoriaBpy": "0.6.0", + "XRFeitoriaUnreal": "0.6.0" + }, "0.5.1": { "XRFeitoria": "0.5.1", "XRFeitoriaBpy": "0.5.1", diff --git a/xrfeitoria/utils/projector.py b/xrfeitoria/utils/projector.py index fa9f7227..d580a92e 100644 --- a/xrfeitoria/utils/projector.py +++ b/xrfeitoria/utils/projector.py @@ -24,7 +24,7 @@ def project_points3d(points3d: np.ndarray, camera_param: CameraParameter) -> np. # convert to opencv convention, and cam2world _camera_param = camera_param.clone() - if _camera_param.world2cam: + if not _camera_param.world2cam: _camera_param.inverse_extrinsic() if _camera_param.convention != 'opencv': _camera_param.convert_convention(dst='opencv') diff --git a/xrfeitoria/utils/publish_plugins.py b/xrfeitoria/utils/publish_plugins.py index d3e8e5b1..99a77c7a 100644 --- a/xrfeitoria/utils/publish_plugins.py +++ b/xrfeitoria/utils/publish_plugins.py @@ -1,98 +1,191 @@ """Publish plugins to zip files. ->>> python -m xrfeitoria.utils.publish_plugins --unreal -s 0.5.0-Unreal5.2-Windows ->>> python -m xrfeitoria.utils.publish_plugins --blender -s 0.5.0-None-None +>>> python -m xrfeitoria.utils.publish_plugins --help """ +import os +import platform +import re +import subprocess +from contextlib import contextmanager from pathlib import Path -from typing import Literal, Optional +from typing import List, Optional, Tuple from loguru import logger -from .. import __version__ -from ..data_structure.constants import PathLike, plugin_name_blender, plugin_name_unreal +from ..data_structure.constants import plugin_name_blender, plugin_name_pattern, plugin_name_unreal +from ..utils import setup_logger +from ..version import __version__, __version_tuple__ +from .runner import UnrealRPCRunner root = Path(__file__).parent.resolve() project_root = root.parents[1] +src_root = project_root / 'src' +dist_root = src_root / 'dist' +dist_root.mkdir(exist_ok=True, parents=True) + + +@contextmanager +def working_directory(path): + """Changes working directory and returns to previous on exit.""" + prev_cwd = Path.cwd() + os.chdir(path) + try: + yield + finally: + os.chdir(prev_cwd) def _make_archive( - plugin_folder: PathLike, - zip_name: Optional[str] = None, - folder_name: Optional[str] = None, + src_folder: Path, + dst_path: Optional[Path] = None, + folder_name_inside_zip: Optional[str] = None, + filter_names: Tuple[str, ...] = ('.git', '.vscode', '.gitignore', '.DS_Store', '__pycache__', 'Intermediate'), ) -> Path: """Make archive of plugin folder. + Zip Plugin folder to ``{plugin_folder.parent}/{zip_name}.zip``. + Args: - plugin_folder (PathLike): path to plugin folder. - zip_name (Optional[str], optional): name of the archive file. - E.g. dst_name='plugin', the archive file would be ``plugin.zip``. + plugin_folder (Path): path to plugin folder. + zip_name (Optional[str], optional): name of the archive file. E.g. dst_name='plugin', the archive file would be ``plugin.zip``. Defaults to None, fallback to {plugin_folder.name}. + folder_name (Optional[str], optional): name of the root folder in the archive. + filter_names (Tuple[str, ...], optional): names of folders to be ignored. + Defaults to ('.git', '.vscode', '.gitignore', '.DS_Store', '__pycache__', 'Intermediate'). """ import zipfile - if zip_name is None: - zip_name = plugin_folder.name - if folder_name is None: - folder_name = zip_name + if dst_path is None: + dst_path = src_folder.parent / f'{src_folder.name}.zip' + if folder_name_inside_zip is None: + folder_name_inside_zip = dst_path.stem - plugin_folder = Path(plugin_folder).resolve() - plugin_zip = plugin_folder.parent / f'{zip_name}.zip' - if plugin_zip.exists(): - plugin_zip.unlink() + if dst_path.exists(): + dst_path.unlink() - filter_names = ['.git', '.idea', '.vscode', '.gitignore', '.DS_Store', '__pycache__', 'Intermediate'] - with zipfile.ZipFile(plugin_zip, 'w', compression=zipfile.ZIP_DEFLATED) as zipf: - for file in plugin_folder.rglob('*'): + with zipfile.ZipFile(dst_path, 'w', compression=zipfile.ZIP_DEFLATED) as zipf: + for file in src_folder.rglob('*'): # filter if any([folder in file.parts for folder in filter_names]): continue # in zip, the folder name is the root folder - # {folder_name}/a/b/c - arcname = folder_name + '/' + file.relative_to(plugin_folder).as_posix() + # {folder_name_inside_zip}/a/b/c + arcname = folder_name_inside_zip + '/' + file.relative_to(src_folder).as_posix() zipf.write(file, arcname=arcname) - # plugin_folder = shutil.make_archive(plugin_zip.with_suffix(''), 'zip', plugin_folder.parent, plugin_folder.name) - logger.debug(f'Compressed {plugin_folder} => {plugin_zip}') - return plugin_zip + logger.info(f'Compressed {src_folder} => {dst_path}') + return dst_path -def main(engine: Literal['unreal', 'blender'], suffix: Optional[str] = None): - if engine == 'blender': - plugin_name = plugin_name_blender - folder_name = plugin_name # in zip, {XRFeitoriaBpy} is the root folder - elif engine == 'unreal': - # TODO: auto detect binaries - plugin_name = plugin_name_unreal - folder_name = None # in zip, {XRFeitoriaUnreal-x.x.x-UE5.x-Windows} is the root folder +def update_bpy_version(bpy_init_file: Path): + """Update version in ``src/XRFeitoriaBpy/__init__.py``. - dir_plugin = project_root / 'src' / plugin_name + Args: + bpy_init_file (Path): path to ``__init__.py`` file. + """ + content = bpy_init_file.read_text() + # update version + content = re.sub(pattern=r"'version': \(.*\)", repl=f"'version': {__version_tuple__}", string=content) + content = re.sub( + pattern=r'__version__ = version = .*', repl=f"__version__ = version = '{__version__}'", string=content + ) + bpy_init_file.write_text(content) + logger.info(f'Updated "{bpy_init_file}" with version {__version__}') - name = plugin_name - if suffix is not None: - name += f'-{suffix}' - else: - name += f'-{__version__}' - plugin_zip = _make_archive(dir_plugin, zip_name=name, folder_name=folder_name) - logger.info(f'Plugin for {engine}: {plugin_zip}') + +def update_uplugin_version(uplugin_path: Path): + """Update version in ``src/XRFeitoriaUnreal/XRFeitoria.uplugin``. + + Args: + uplugin_file (Path): path to ``XRFeitoria.uplugin`` file. + """ + content = uplugin_path.read_text() + # update version + content = re.sub(pattern=r'"VersionName": ".*"', repl=f'"VersionName": "{__version__}"', string=content) + uplugin_path.write_text(content) + logger.info(f'Updated "{uplugin_path}" with version {__version__}') + + +def build_blender(): + plugin_name = plugin_name_pattern.format( + plugin_name=plugin_name_blender, + plugin_version=__version__, + engine_version='None', + platform='None', + ) # e.g. XRFeitoriaBlender-0.5.0-None-None + dir_plugin = src_root / plugin_name_blender + update_bpy_version(dir_plugin / '__init__.py') + + plugin_zip = _make_archive( + src_folder=dir_plugin, + dst_path=dist_root / f'{plugin_name}.zip', + folder_name_inside_zip=plugin_name_blender, + ) + dst_plugin_zip = dist_root / plugin_zip.name + logger.info(f'Plugin for blender: "{dst_plugin_zip}"') + + +def build_unreal(unreal_exec_list: List[Path]): + dir_plugin = src_root / plugin_name_unreal + uplugin_path = dir_plugin / f'{plugin_name_unreal}.uplugin' + update_uplugin_version(uplugin_path) + logger.info('Compiling plugin for Unreal Engine...') + for unreal_exec in unreal_exec_list: + uat_path = unreal_exec.parents[2] / 'Build/BatchFiles/RunUAT.bat' + unreal_infos = UnrealRPCRunner._get_engine_info(unreal_exec) + engine_version = ''.join(unreal_infos) # e.g. Unreal5.1 + plugin_name = plugin_name_pattern.format( + plugin_name=plugin_name_unreal, + plugin_version=__version__, + engine_version=engine_version, + platform=platform.system(), + ) # e.g. XRFeitoriaUnreal-0.6.0-Unreal5.3-Windows + plugin_src_name = plugin_name_pattern.format( + plugin_name=plugin_name_unreal, + plugin_version=__version__, + engine_version=engine_version, + platform='Source', + ) # e.g. XRFeitoriaUnreal-0.6.0-Unreal5.3-Source + dist_path = dist_root / plugin_name + subprocess.call([uat_path, 'BuildPlugin', f'-Plugin={uplugin_path}', f'-Package={dist_path}']) + _make_archive(src_folder=dist_path) + _make_archive( + src_folder=dist_path, + dst_path=dist_root / f'{plugin_src_name}.zip', + folder_name_inside_zip=plugin_name_unreal, + filter_names=('.DS_Store', '__pycache__', 'Intermediate', 'Binaries'), + ) + logger.info(f'Plugin for {engine_version}: "{dist_path}.zip"') if __name__ == '__main__': from typer import Option, run def wrapper( - unreal: bool = Option(False, '--unreal', '-u', help='Build plugin for Unreal'), - blender: bool = Option(False, '--blender', '-b', help='Build plugin for Blender'), - suffix: str = Option( + unreal_exec: List[Path] = Option( None, - '--suffix', - '-s', - help='Suffix of the compressed file, e.g. "XRFeitoriaUnreal-{suffix}.zip". if None, use version number', - ), + '-u', + resolve_path=True, + file_okay=True, + dir_okay=False, + exists=True, + help='Path to Unreal Engine executable. e.g. "C:/Program Files/Epic Games/UE_5.1/Engine/Binaries/Win64/UnrealEditor-Cmd.exe"', + ) ): - if unreal: - main(engine='unreal', suffix=suffix) - if blender: - main(engine='blender', suffix=suffix) + """Publish plugins to zip files. + + Examples: + + >>> python -m xrfeitoria.utils.publish_plugins + -u "C:/Program Files/Epic Games/UE_5.1/Engine/Binaries/Win64/UnrealEditor-Cmd.exe" + -u "C:/Program Files/Epic Games/UE_5.2/Engine/Binaries/Win64/UnrealEditor-Cmd.exe" + -u "C:/Program Files/Epic Games/UE_5.3/Engine/Binaries/Win64/UnrealEditor-Cmd.exe" + """ + setup_logger(level='INFO') + build_blender() + if len(unreal_exec) > 0: + build_unreal(unreal_exec_list=unreal_exec) + logger.info(f'Check "{dist_root}" for the plugin zip files.') run(wrapper) diff --git a/xrfeitoria/utils/runner.py b/xrfeitoria/utils/runner.py index 8e92c7d6..a66a07ac 100644 --- a/xrfeitoria/utils/runner.py +++ b/xrfeitoria/utils/runner.py @@ -13,24 +13,42 @@ from http.client import RemoteDisconnected from pathlib import Path from textwrap import dedent -from typing import Dict, Optional, Tuple +from typing import Dict, List, Literal, Optional, Tuple, TypedDict from urllib.error import HTTPError, URLError from xmlrpc.client import ProtocolError +import psutil from loguru import logger from packaging.version import parse from rich import get_console from rich.prompt import Confirm from .. import __version__, _tls -from ..data_structure.constants import EngineEnum, PathLike, plugin_name_blender, plugin_name_unreal, tmp_dir -from ..rpc import BLENDER_PORT, UNREAL_PORT, remote_blender, remote_unreal +from ..data_structure.constants import ( + EngineEnum, + PathLike, + package_name, + plugin_name_blender, + plugin_name_pattern, + plugin_name_unreal, + tmp_dir, +) +from ..rpc import BLENDER_PORT, UNREAL_PORT, factory, remote_blender, remote_unreal from .downloader import download from .setup import get_exec_path # XXX: hardcode download url -oss_root = 'https://openxrlab-share.oss-cn-hongkong.aliyuncs.com/xrfeitoria' +dist_root = os.environ.get('XRFEITORIA__DIST_ROOT') or 'https://openxrlab-share.oss-cn-hongkong.aliyuncs.com/xrfeitoria' plugin_infos_json = Path(__file__).parent.resolve() / 'plugin_infos.json' +plugin_info_type = TypedDict( + 'PluginInfo', + { + 'plugin_name': str, + 'plugin_version': str, + 'engine_version': str, + 'platform': Literal['Windows', 'Linux', 'Darwin'], + }, +) def _rmtree(path: Path) -> None: @@ -70,6 +88,7 @@ def __init__( new_process: bool = False, engine_exec: Optional[PathLike] = None, project_path: PathLike = '', + reload_rpc_code: bool = False, replace_plugin: bool = False, dev_plugin: bool = False, background: bool = True, @@ -80,6 +99,9 @@ def __init__( new_process (bool, optional): whether to start a new process. Defaults to False. engine_exec (Optional[PathLike], optional): path to engine executable. Defaults to None. project_path (PathLike, optional): path to project. Defaults to ''. + reload_rpc_code (bool, optional): whether to reload the registered rpc functions and classes. + If you are developing the package or writing a custom remote function, set this to True to reload the code. + This will only be in effect when `new_process=False` if the engine process is reused. Defaults to False. replace_plugin (bool, optional): whether to replace the plugin installed for the engine. Defaults to False. dev_plugin (bool, optional): Whether to use the plugin under local directory. If False, would use the plugin downloaded from a remote server. Defaults to False. @@ -87,13 +109,24 @@ def __init__( """ self.console = get_console() self.engine_type: EngineEnum = _tls.cache.get('platform', None) + self.engine_pid: Optional[int] = None self.engine_process: Optional[subprocess.Popen] = None self.engine_running: bool = False + self.engine_outputs: List[str] = [] self.new_process = new_process self.replace_plugin = replace_plugin self.dev_plugin = dev_plugin self.background = background - self.debug = os.environ.get('RPC_DEBUG', '0') == '1' # logger.level('DEBUG') + self.debug = logger._core.min_level <= 10 # DEBUG level + + # child threads + self.thread_engine_alive: Optional[threading.Thread] = None + self.thread_receive_stdout: Optional[threading.Thread] = None + + if reload_rpc_code: + # clear registered functions and classes for reloading + factory.RPCFactory.registered_function_names.clear() + factory.RPCFactory.reload_rpc_code = True if self.dev_plugin: self.replace_plugin = True @@ -165,18 +198,31 @@ def __exit__(self, exc_type, exc_value, traceback) -> None: def stop(self) -> None: """Stop rpc server.""" - import psutil + # clear rpc server + factory.RPCFactory.clear() + + # stop threads + self.engine_running = False + if self.thread_receive_stdout: + self.thread_receive_stdout.join() + if self.thread_engine_alive: + self.thread_engine_alive.join() + # stop engine process process = self.engine_process if process is not None: logger.info(':bell: [bold red]Exiting RPC Server[/bold red], killing engine process') - self.engine_running = False - for child in psutil.Process(process.pid).children(recursive=True): - if child.is_running(): - logger.debug(f'Killing child process {child.pid}') - child.kill() - process.kill() + if psutil.pid_exists(self.engine_pid): + for child in psutil.Process(self.engine_pid).children(recursive=True): + if child.is_running(): + logger.debug(f'Killing child process {child.pid}') + child.kill() + process.kill() self.engine_process = None + self.engine_pid = None + # prevent to be called from another thread + _tls.cache['engine_process'] = None + _tls.cache['engine_pid'] = None else: logger.info(':bell: [bold red]Exiting runner[/bold red], reused engine process remains') @@ -192,17 +238,19 @@ def reuse(self) -> bool: try: with self.console.status('[bold green]Try to reuse existing engine process...[/bold green]'): self.test_connection(debug=self.debug) + self.engine_pid = self.get_pid() logger.info(':direct_hit: [bold cyan]Reuse[/bold cyan] existing engine process') # raise an error if new_process is True if self.new_process: raise RuntimeError( f'RPC server in `RPC_PORT={self.port}` already started! ' 'This is raised when calling `init_blender` or `init_unreal` with `new_progress=True`' - 'when an existing server (blender or unreal) is already running. \n' + 'if an existing server (blender or unreal) is already running. \n' '3 ways to get around this: \n' - ' - set `new_process=False` for using the existing server. \n' - ' - stop the server (engine process) and try again; \n' - " - change the rpc port via system env 'RPC_PORT' and try again." + ' - Set `new_process=False` for using the existing server. \n' + ' - Stop the server (engine process) and try again; \n' + " - Change the rpc port via system env 'RPC_PORT' and try again.\n" + 'For multi-processing, please refer to https://xrfeitoria.readthedocs.io/en/latest/faq.html#rpc-port' ) except ConnectionRefusedError: return False @@ -223,9 +271,74 @@ def _popen(cmd: str) -> subprocess.Popen: process = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) return process - def start(self): + def _receive_stdout(self) -> None: + """Receive output from the subprocess, and log it in `trace` level. + + This function should be called in a separate thread. + """ + # start receiving + while True: + try: + data = self.engine_process.stdout.readline().decode() + except AttributeError: + break + if not data: + break + # log in debug level + logger.trace(f'(engine) {data.strip()}') + self.engine_outputs.append(data) + + def check_engine_alive(self) -> None: + """Check if the engine process is alive. This function should be called in a + separate thread. + + Raises: + RuntimeError: if engine process is not alive. + """ + logger.debug('(Thread) Start checking engine process') + while self.engine_running: + if self.engine_process.poll() is not None: + logger.error(self.get_process_output(self.engine_process)) + logger.error('[red]RPC server stopped unexpectedly, check the engine output above[/red]') + self.engine_running = False # for multi-processing + factory.RPCFactory.clear() + raise RuntimeError('RPC server stopped unexpectedly') + time.sleep(5) + + def check_engine_alive_psutil(self) -> None: + """Check if the engine process is alive using psutil. This function should be + called in a separate thread. + + Raises: + RuntimeError: if engine process is not alive. + """ + logger.debug('(Thread) Start checking engine process, using psutil') + p = psutil.Process(self.engine_pid) + while self.engine_running: + if not p.is_running(): + logger.error('[red]RPC server stopped unexpectedly[/red]') + self.engine_running = False # for multi-processing + factory.RPCFactory.clear() + raise RuntimeError('RPC server stopped unexpectedly') + time.sleep(5) + + def get_process_output(self, process: subprocess.Popen) -> str: + """Get process output when process is exited with non-zero code.""" + # engine_out = process.stdout.read().decode("utf-8") + engine_out = ''.join(self.engine_outputs) + out = ( + f'Engine process exited with code {process.poll()}\n\n' + '[gray]>>>> Engine output >>>>\n\n[/gray]' + f'{engine_out}\n' + '[gray]<<<< Engine output <<<<\n[/gray]' + ) + return out + + def start(self) -> None: """Start rpc server.""" if not self.new_process: + self.engine_running = True + self.thread_engine_alive = threading.Thread(target=self.check_engine_alive_psutil, daemon=True).start() return with self.console.status('Initializing RPC server...') as status: @@ -234,31 +347,14 @@ def start(self): status.update(status=f'[green bold]Starting {" ".join(self.engine_info)} as RPC server...') self.engine_process = self._start_rpc(background=self.background, project_path=self.project_path) self.engine_running = True + self.engine_pid = self.engine_process.pid _tls.cache['engine_process'] = self.engine_process + _tls.cache['engine_pid'] = self.engine_pid logger.info(f'RPC server started at port {self.port}') # check if engine process is alive in a separate thread - threading.Thread(target=self.check_engine_alive, daemon=True).start() - - def check_engine_alive(self) -> bool: - """Check if the engine process is alive.""" - while self.engine_running: - if self.engine_process.poll() is not None: - logger.error(self.get_process_output(self.engine_process)) - logger.error('[red]RPC server stopped unexpectedly, check the engine output above[/red]') - os._exit(1) # exit main thread - time.sleep(1) - - @staticmethod - def get_process_output(process: subprocess.Popen) -> str: - """Get process output when process is exited with non-zero code.""" - out = ( - f'Engine process exited with code {process.poll()}\n\n' - '>>>> Engine output >>>>\n\n' - f'{process.stdout.read().decode("utf-8")}' - '\n\n<<<< Engine output <<<<\n' - ) - return out + self.thread_receive_stdout = threading.Thread(target=self._receive_stdout, daemon=True).start() + self.thread_engine_alive = threading.Thread(target=self.check_engine_alive, daemon=True).start() def wait_for_start(self, process: subprocess.Popen) -> None: """Wait 3 minutes for RPC server to start. @@ -275,13 +371,20 @@ def wait_for_start(self, process: subprocess.Popen) -> None: _num = 0 while True: if process.poll() is not None: - logger.error(self.get_process_output(process)) + error_msg = self.get_process_output(process) + logger.error(error_msg) + + if 'Unable to open a display' in error_msg: + raise RuntimeError( + 'Failed to start RPC server. Please run blender in background mode. ' + 'Set `background=True` in `init_blender`. ' + ) raise RuntimeError('RPC server failed to start. Check the engine output above.') try: self.test_connection(debug=self.debug) break except (RemoteDisconnected, ConnectionRefusedError, ProtocolError): - logger.debug(f'Waiting for RPC server to start [tryout {_num}/{tryout_num}]') + logger.debug(f'Waiting for RPC server to start (tryout {_num}/{tryout_num})') _num += 1 time.sleep(tryout_sec) # wait for 5 seconds if _num >= tryout_num: # 3 minutes @@ -312,7 +415,7 @@ def get_src_plugin_path(self) -> Path: @property @lru_cache def dst_plugin_dir(self) -> Path: - """Get plugin directory.""" + """Get plugin directory to install.""" if self.engine_type == EngineEnum.blender: dst_plugin_dir = _get_user_addon_path(version=self.engine_info[1]) / plugin_name_blender elif self.engine_type == EngineEnum.unreal: @@ -322,6 +425,29 @@ def dst_plugin_dir(self) -> Path: dst_plugin_dir.parent.mkdir(exist_ok=True, parents=True) return dst_plugin_dir + @property + @lru_cache + def installed_plugin_version(self) -> str: + """Get plugin version installed.""" + assert ( + self.dst_plugin_dir.exists() + ), f'Plugin not installed in "{self.dst_plugin_dir.as_posix()}", should not call this function' + + if self.engine_type == EngineEnum.blender: + init_file = self.dst_plugin_dir / '__init__.py' + _content = init_file.read_text() + _match = re.search(r"__version__ = version = '(.*?)'", _content) + if _match: + dst_plugin_version = _match.groups()[0] + else: + dst_plugin_version = '0.0.0' # cannot find version + elif self.engine_type == EngineEnum.unreal: + uplugin_file = self.dst_plugin_dir / f'{plugin_name_unreal}.uplugin' + dst_plugin_version = json.loads(uplugin_file.read_text())['VersionName'] + else: + raise NotImplementedError + return dst_plugin_version + def _download(self, url: str, dst_dir: Path) -> Path: """Check if the url is valid and download the plugin to the given directory.""" try: @@ -332,9 +458,9 @@ def _download(self, url: str, dst_dir: Path) -> Path: code=e.code, msg=( 'Failed to download plugin.\n' - f'Sorry, pre-built plugin for {"".join(self.engine_info)} in {platform.system()} is not supported.\n' - 'Set `dev_plugin=True` in init_blender/init_unreal to build the plugin from source.\n' - 'Clone the source code from https://github.com/openxrlab/xrfeitoria.git' + f'Sorry, pre-built plugin for {plugin_name_pattern.format(**self.plugin_info)} is not provided. ' + 'You can try to build the plugin from source.\n' + 'Follow the instructions here: https://xrfeitoria.readthedocs.io/en/latest/faq.html#how-to-use-the-plugin-of-blender-unreal-under-development' ), hdrs=e.hdrs, fp=e.fp, @@ -369,10 +495,12 @@ def _get_cmd(self) -> str: pass @abstractmethod - def _start_rpc(self, background: bool = True, project_path: Optional[Path] = '') -> None: + def _start_rpc(self, background: bool = True, project_path: Optional[Path] = '') -> subprocess.Popen: pass - def _get_plugin_url(self) -> Optional[str]: + @property + @lru_cache + def plugin_info(self) -> plugin_info_type: # plugin_infos = { "0.5.0": { "XRFeitoria": "0.5.0", "XRFeitoriaBpy": "0.5.0", "XRFeitoriaUnreal": "0.5.0" }, ... } plugin_infos: Dict[str, Dict[str, str]] = json.loads(plugin_infos_json.read_text()) plugin_versions = sorted((map(parse, plugin_infos.keys()))) @@ -384,23 +512,58 @@ def _get_plugin_url(self) -> Optional[str]: else: _idx = bisect_left(plugin_versions, parse(__version__)) - 1 compatible_version = plugin_versions[_idx] + + # read from env (highest priority) + if os.environ.get('XRFEITORIA__VERSION'): + compatible_version = parse(os.environ['XRFEITORIA__VERSION']) + logger.debug(f'Compatible plugin version: {compatible_version}') # get link if self.engine_type == EngineEnum.unreal: - _plugin_name = plugin_name_unreal - _platform = f'{"".join(self.engine_info)}-{platform.system()}' # e.g. Unreal5.1-Windows + plugin_name = plugin_name_unreal + engine_version = ''.join(self.engine_info) # e.g. Unreal5.1 + _platform = platform.system() # Literal["Windows", "Linux", "Darwin"] elif self.engine_type == EngineEnum.blender: - _plugin_name = plugin_name_blender - _platform = 'None-None' - _plugin_version = plugin_infos[str(compatible_version)][_plugin_name] - # e.g. https://openxrlab-share.oss-cn-hongkong.aliyuncs.com/xrfeitoria/plugins/XRFeitoriaBpy-0.5.0-None-None.zip - # e.g. https://openxrlab-share.oss-cn-hongkong.aliyuncs.com/xrfeitoria/plugins/XRFeitoriaUnreal-0.5.0-Unreal5.1-Windows.zip - return f'{oss_root}/plugins/{_plugin_name}-{_plugin_version}-{_platform}.zip' + plugin_name = plugin_name_blender + engine_version = 'None' # support all blender versions + _platform = 'None' # support all platforms + plugin_version = os.environ.get('XRFEITORIA__VERSION') or plugin_infos[str(compatible_version)][plugin_name] + # e.g. XRFeitoriaBpy-0.5.0-None-None + # e.g. XRFeitoriaUnreal-0.5.0-Unreal5.1-Windows + return dict( + plugin_name=plugin_name, + plugin_version=plugin_version, + engine_version=engine_version, + platform=_platform, + ) + + @property + @lru_cache + def plugin_url(self) -> Optional[str]: + if dist_root.startswith('http'): + # e.g. https://openxrlab-share.oss-cn-hongkong.aliyuncs.com/xrfeitoria/plugins/XRFeitoriaBpy-0.5.0-None-None.zip + # e.g. https://openxrlab-share.oss-cn-hongkong.aliyuncs.com/xrfeitoria/plugins/XRFeitoriaUnreal-0.5.0-Unreal5.1-Windows.zip + return f'{dist_root}/plugins/{plugin_name_pattern.format(**self.plugin_info)}.zip' + else: + # e.g. /path/to/dist/XRFeitoriaBpy-0.5.0-None-None.zip + # e.g. /path/to/dist/XRFeitoriaUnreal-0.5.0-Unreal5.1-Windows.zip + return f'{dist_root}/{plugin_name_pattern.format(**self.plugin_info)}.zip' def _install_plugin(self) -> None: """Install plugin.""" - if self.dst_plugin_dir.exists(): + if self.dst_plugin_dir.exists() and not self.replace_plugin: + if parse(self.installed_plugin_version) < parse(self.plugin_info['plugin_version']): + self.replace_plugin = True + if ( + parse(self.installed_plugin_version) > parse(self.plugin_info['plugin_version']) + and not self.replace_plugin + ): + logger.warning( + f'Plugin installed in "{self.dst_plugin_dir.as_posix()}" is in version {self.installed_plugin_version}, ' + f'newer than version {self.plugin_info["plugin_version"]} which is required by {package_name}-{__version__}. ' + 'May cause unexpected errors.' + ) if not self.replace_plugin: logger.debug(f'Plugin "{self.dst_plugin_dir.as_posix()}" already exists') return @@ -446,6 +609,11 @@ def _install_plugin(self) -> None: def test_connection(debug: bool = False) -> None: pass + @staticmethod + @abstractmethod + def get_pid() -> int: + pass + class BlenderRPCRunner(RPCRunner): def get_src_plugin_path(self) -> Path: @@ -456,14 +624,16 @@ def get_src_plugin_path(self) -> Path: src_plugin_dir = Path(__file__).resolve().parents[2] / 'src' / plugin_name_blender src_plugin_path = _make_archive(src_plugin_dir) else: - url = self._get_plugin_url() src_plugin_root = tmp_dir / 'plugins' - src_plugin_path = src_plugin_root / Path(url).name # with suffix (.zip) + src_plugin_path = src_plugin_root / Path(self.plugin_url).name # with suffix (.zip) if src_plugin_path.exists(): logger.debug(f'Downloaded Plugin "{src_plugin_path.as_posix()}" exists') return src_plugin_path - plugin_path = self._download(url=url, dst_dir=src_plugin_root) + if self.plugin_url.startswith('http'): + plugin_path = self._download(url=self.plugin_url, dst_dir=src_plugin_root) + else: + plugin_path = Path(self.plugin_url) if plugin_path != src_plugin_path: shutil.move(plugin_path, src_plugin_path) return src_plugin_path @@ -547,6 +717,14 @@ def test_connection(debug: bool = False) -> bool: except Exception: pass + @staticmethod + @remote_blender(default_imports=[]) + def get_pid() -> int: + """Get blender process id.""" + import os + + return os.getpid() + class UnrealRPCRunner(RPCRunner): """UnrealRPCRunner.""" @@ -558,14 +736,13 @@ def get_src_plugin_path(self) -> Path: if not src_plugin_path.exists(): raise FileNotFoundError( f'Plugin source code not found in {src_plugin_path}, ' - 'please set `dev_plugin=False` to download the pre-built plugin. ' + 'please set `dev_plugin=False` to download the pre-built plugin. \n' 'Or clone the source code and build the plugin from source. ' 'https://github.com/openxrlab/xrfeitoria.git' ) - else: - url = self._get_plugin_url() + elif self.plugin_url.startswith('http'): src_plugin_root = tmp_dir / 'plugins' - src_plugin_compress = src_plugin_root / Path(url).name # with suffix (.zip) + src_plugin_compress = src_plugin_root / Path(self.plugin_url).name # with suffix (.zip) src_plugin_path = src_plugin_compress.with_suffix('') # without suffix (.zip) if src_plugin_path.exists(): logger.debug(f'Downloaded Plugin "{src_plugin_path.as_posix()}" exists') @@ -576,17 +753,19 @@ def get_src_plugin_path(self) -> Path: assert src_plugin_path.exists(), f'Failed to unzip {src_plugin_compress} to {src_plugin_path}' return src_plugin_path - plugin_compress = self._download(url=url, dst_dir=src_plugin_root) + plugin_compress = self._download(url=self.plugin_url, dst_dir=src_plugin_root) shutil.unpack_archive(plugin_compress, src_plugin_root) assert src_plugin_path.exists(), f'Failed to download plugin to {src_plugin_path}' + else: + # custom dist path + plugin_compress = Path(self.plugin_url) + src_plugin_path = plugin_compress.with_suffix('') # without suffix (.zip) return src_plugin_path @staticmethod def _get_engine_info(engine_exec: Path) -> Tuple[str, str]: - try: - _version = re.findall(r'UE_(\d+\.\d+)', engine_exec.as_posix())[0] - except IndexError: - raise FileNotFoundError(f'Cannot find unreal executable in {engine_exec}') + build_info = json.loads((engine_exec.parents[2] / 'Build' / 'Build.version').read_text()) + _version = f'{build_info["MajorVersion"]}.{build_info["MinorVersion"]}' return 'Unreal', _version def _get_cmd( @@ -654,3 +833,11 @@ def test_connection(debug: bool = False) -> None: import unreal unreal.log('Connection test passed') + + @staticmethod + @remote_unreal(default_imports=[]) + def get_pid() -> int: + """Get unreal process id.""" + import os + + return os.getpid() diff --git a/xrfeitoria/utils/tools.py b/xrfeitoria/utils/tools.py index 9ef265a1..1ec42253 100644 --- a/xrfeitoria/utils/tools.py +++ b/xrfeitoria/utils/tools.py @@ -1,12 +1,13 @@ """Utils tools for logging and progress bar.""" - +import os +import sys from pathlib import Path -from typing import Iterable, Optional, Sequence, Tuple, Union +from typing import Iterable, Literal, Optional, Sequence, Tuple, Union import loguru from loguru import logger -from rich import print as rprint +from rich.console import Console from rich.progress import ( BarColumn, MofNCompleteColumn, @@ -23,10 +24,10 @@ from ..data_structure.constants import PathLike -__all__ = ['Logger'] +__all__ = ['setup_logger'] -class Logger: +class LoggerWrapper: """A wrapper for logger tools.""" is_setup = False @@ -68,33 +69,110 @@ def filter_unique(cls, record: 'loguru.Record', level_name: str = 'WARNING') -> @classmethod def setup_logging( - cls, level: str = 'INFO', log_path: 'Optional[PathLike]' = None, replace: bool = True + cls, + level: Literal['RPC', 'TRACE', 'DEBUG', 'INFO', 'SUCCESS', 'WARNING', 'ERROR', 'CRITICAL'] = 'INFO', + log_path: 'Optional[PathLike]' = None, + replace: bool = True, ) -> 'loguru.Logger': """Setup logging to file and console. Args: - level (str, optional): logging level. Defaults to "INFO", can be "DEBUG", "INFO", "WARNING", - "ERROR", "CRITICAL". + level (Literal['RPC', 'TRACE', 'DEBUG', 'INFO', 'SUCCESS', 'WARNING', 'ERROR', 'CRITICAL'], optional): + logging level. Defaults to "INFO", find more in https://loguru.readthedocs.io/en/stable/api/logger.html. log_path (Path, optional): path to save the log file. Defaults to None. replace (bool, optional): replace the log file if exists. Defaults to True. """ if cls.is_setup: return logger + cls.setup_encoding() + + # add custom level called RPC, which is the minimum level + logger.level('RPC', no=1, color='', icon='📢') + logger.remove() # remove default logger - logger.add(sink=lambda msg: rprint(msg, end=''), level=level, format=cls.logger_format) - # logger.add(RichHandler(level=level, rich_tracebacks=True, markup=True), level=level, format='{message}') + # logger.add(sink=lambda msg: rprint(msg, end=''), level=level, format=cls.logger_format) + c = Console( + width=sys.maxsize, # disable wrapping + log_time=False, + log_path=False, + log_time_format='', + ) + logger.add(sink=lambda msg: c.print(msg, end=''), level=level, format=cls.logger_format) if log_path: # add file logger log_path = Path(log_path).resolve() log_path.parent.mkdir(parents=True, exist_ok=True) if replace and log_path.exists(): log_path.unlink(missing_ok=True) - logger.add(log_path, level='DEBUG', filter=cls.filter_unique, format=cls.logger_format, encoding='utf-8') + _level = 'RPC' if level == 'RPC' else 'TRACE' + logger.add(log_path, level=_level, filter=cls.filter_unique, format=cls.logger_format, encoding='utf-8') logger.info(f'Python Logging to "{log_path.as_posix()}"') cls.is_setup = True return logger + @staticmethod + def setup_encoding( + encoding: Optional[str] = None, + errorhandler: Literal['ignore', 'replace', 'backslashreplace', 'xmlcharrefreplace'] = 'backslashreplace', + ): + """Modify `PYTHONIOENCODING` to prevent suppress UnicodeEncodeError caused by logging of emojis. + It will affect the default behavior of `sys.stdin`, `sys.stdout` and `sys.stderr`. + Ref: https://docs.python.org/3/using/cmdline.html#envvar-PYTHONIOENCODING + + Args: + errorhandler (Literal['ignore', 'replace', 'backslashreplace', 'xmlcharrefreplace'], optional): + specify which error handler to handle unsupported characters. 'strict' is forbidden. Defaults to 'replace'. + Ref to https://docs.python.org/3/library/stdtypes.html#str.encode + """ + encodingname, _, handler = os.environ.get('PYTHONIOENCODING', '').lower().partition(':') + encoding = encodingname if encodingname else encoding + # Set an errorhandler except for "strict" + if handler in ('ignore', 'replace', 'backslashreplace', 'xmlcharrefreplace'): + errorhandler = handler + # elif handler in ('', 'strict'): + # print( + # f'PYTHONIOENCODING is going to use "strict" errorhandler, which could raise errors during logging. Reset to "{errorhandler}"', + # file=sys.stderr, + # ) + # else: + # print( + # f'PYTHONIOENCODING is set with invalid errorhandler "{handler}". Reset to "{errorhandler}"', + # file=sys.stderr, + # ) + # if not encoding.lower().startswith("utf"): + os.environ['PYTHONIOENCODING'] = f"{encoding or ''}:{errorhandler}" + + +def setup_logger( + level: Literal['RPC', 'TRACE', 'DEBUG', 'INFO', 'SUCCESS', 'WARNING', 'ERROR', 'CRITICAL'] = 'INFO', + log_path: 'Optional[PathLike]' = None, + replace: bool = True, +) -> 'loguru.Logger': + """Setup logging to file and console. + + Args: + level (Literal['TRACE', 'DEBUG', 'INFO', 'SUCCESS', 'WARNING', 'ERROR', 'CRITICAL'], optional): logging level. + Defaults to 'INFO', find more in https://loguru.readthedocs.io/en/stable/api/logger.html. + The order of the levels is: + + - 'RPC' (custom level): logging RPC messages which are sent by RPC protocols. + - 'TRACE': logging engine output like console output of blender. + - 'DEBUG': logging debug messages. + - 'INFO': logging info messages. + - ... + log_path (Path, optional): path to save the log file. Defaults to None. + replace (bool, optional): replace the log file if exists. Defaults to True. + """ + try: + return LoggerWrapper.setup_logging(level, log_path, replace) + except Exception as e: + import traceback + + print(repr(e)) + print(traceback.format_exc()) + raise e + #### (rich) progress bar #### class SpeedColumn(ProgressColumn): diff --git a/xrfeitoria/utils/viewer.py b/xrfeitoria/utils/viewer.py index e1f76115..31fc8363 100644 --- a/xrfeitoria/utils/viewer.py +++ b/xrfeitoria/utils/viewer.py @@ -6,6 +6,7 @@ import numpy as np from ..data_structure.constants import PathLike +from ..data_structure.models import RenderOutputEnumBlender os.environ['OPENCV_IO_ENABLE_OPENEXR'] = '1' @@ -104,12 +105,12 @@ class Viewer: """ # folder names of each data modal - IMG = 'img' - MASK = 'mask' - DEPTH = 'depth' - FLOW = 'flow' - NORMAL = 'normal' - DIFFUSE = 'diffuse' + IMG = RenderOutputEnumBlender.img.name + MASK = RenderOutputEnumBlender.mask.name + DEPTH = RenderOutputEnumBlender.depth.name + FLOW = RenderOutputEnumBlender.flow.name + NORMAL = RenderOutputEnumBlender.normal.name + DIFFUSE = RenderOutputEnumBlender.diffuse.name def __init__(self, sequence_dir: PathLike) -> None: """Initialize with the sequence directory.