From b92a6f9781cbffaf649f40f22851b4cfdd1ebc58 Mon Sep 17 00:00:00 2001 From: Kevin Zakka Date: Mon, 12 Feb 2024 14:06:42 -0800 Subject: [PATCH] VHACD -> CoACD. --- README.md | 13 +-- install_vhacd.sh | 19 ----- obj2mjcf/cli.py | 168 +++++++-------------------------------- obj2mjcf/mjcf_builder.py | 21 +++-- setup.py | 1 + tests/test_cli.py | 9 +-- 6 files changed, 47 insertions(+), 184 deletions(-) delete mode 100644 install_vhacd.sh diff --git a/README.md b/README.md index e1ff534..3a79928 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ * Splits an OBJ file into sub-meshes that are grouped by the materials referenced in the OBJ's MTL file * Generates an MJCF XML file that is pre-filled with materials, meshes and geom elements referencing these OBJ files -* Optionally generates a collision mesh by performing a convex decomposition of the OBJ using [V-HACD] +* Optionally generates a collision mesh by performing a convex decomposition of the OBJ using [CoACD] `obj2mjcf` was used to process model meshes for [MuJoCo Menagerie]: @@ -34,15 +34,6 @@ The recommended way to install this package is via [PyPI](https://pypi.org/proje pip install --upgrade obj2mjcf ``` -### Extra: V-HACD 4.0 - -We recommend installing [V-HACD v4.0](https://github.com/kmammou/v-hacd). If available, `obj2mjcf` will leverage it to create better collision geometry for your OBJ file. - -```bash -# For macOS and Linux. -bash install_vhacd.sh -``` - ## Usage Type the following at the command line for a detailed description of available options: @@ -53,5 +44,5 @@ obj2mjcf --help [OBJ]: https://en.wikipedia.org/wiki/Wavefront_.obj_file [MuJoCo]: https://github.com/deepmind/mujoco -[V-HACD]: https://github.com/kmammou/v-hacd +[CoACD]: https://github.com/SarahWeiii/CoACD [MuJoCo Menagerie]: https://github.com/deepmind/mujoco_menagerie diff --git a/install_vhacd.sh b/install_vhacd.sh deleted file mode 100644 index bbdfd07..0000000 --- a/install_vhacd.sh +++ /dev/null @@ -1,19 +0,0 @@ -#!/bin/bash -# -# Install V-HACD v4.0.0. - -# Check that cmake is installed. -t=`which cmake` -if [ -z "$t" ]; then - echo "You need cmake to install V-HACD." 1>&2 - exit 1 -fi - -# Clone and build executable. -git clone https://github.com/kmammou/v-hacd.git --branch v4.0.0 -cd v-hacd/app -cmake CMakeLists.txt -cmake --build . - -# Add executable to /usr/local/bin. -sudo ln -s "$PWD/TestVHACD" /usr/local/bin/TestVHACD diff --git a/obj2mjcf/cli.py b/obj2mjcf/cli.py index 94b189c..a78670a 100644 --- a/obj2mjcf/cli.py +++ b/obj2mjcf/cli.py @@ -1,12 +1,9 @@ """A CLI for processing composite Wavefront OBJ files for use in MuJoCo.""" -import enum import logging import os import re import shutil -import subprocess -import tempfile from dataclasses import dataclass, field from pathlib import Path from typing import List, Optional @@ -20,46 +17,13 @@ from obj2mjcf.material import Material from obj2mjcf.mjcf_builder import MJCFBuilder -# Find the V-HACD v4.0 executable in the system path. -# Note trimesh has not updated their code to work with v4.0 which is why we do not use -# their `convex_decomposition` function. -# TODO(kevin): Is there a way to assert that the V-HACD version is 4.0? -_VHACD_EXECUTABLE = shutil.which("TestVHACD") - -# Names of the V-HACD output files. -_VHACD_OUTPUTS = ["decomp.obj", "decomp.stl"] - - -class FillMode(enum.Enum): - FLOOD = enum.auto() - SURFACE = enum.auto() - RAYCAST = enum.auto() - @dataclass(frozen=True) -class VhacdArgs: +class CoacdArgs: enable: bool = False """enable convex decomposition using V-HACD""" - max_output_convex_hulls: int = 32 - """maximum number of output convex hulls""" - voxel_resolution: int = 100_000 - """total number of voxels to use""" - volume_error_percent: float = 1.0 - """volume error allowed as a percentage""" - max_recursion_depth: int = 14 - """maximum recursion depth""" - disable_shrink_wrap: bool = False - """do not shrink wrap output to source mesh""" - fill_mode: FillMode = FillMode.FLOOD - """fill mode""" - max_hull_vert_count: int = 64 - """maximum number of vertices in the output convex hull""" - disable_async: bool = False - """do not run asynchronously""" - min_edge_length: int = 2 - """minimum size of a voxel edge""" - split_hull: bool = False - """try to find optimal split plane location""" + seed: int = 0 + """seed for reproducibility""" @dataclass(frozen=True) @@ -69,16 +33,14 @@ class Args: converted""" obj_filter: Optional[str] = None """only convert obj files matching this regex""" - save_mtl: bool = False - """save the mtl files""" save_mjcf: bool = False """save an example XML (MJCF) file""" compile_model: bool = False """compile the MJCF file to check for errors""" verbose: bool = False """print verbose output""" - vhacd_args: VhacdArgs = field(default_factory=VhacdArgs) - """arguments to pass to V-HACD""" + coacd_args: CoacdArgs = field(default_factory=CoacdArgs) + """arguments to pass to CoACD""" texture_resize_percent: float = 1.0 """resize the texture to this percentage of the original size""" overwrite: bool = False @@ -99,78 +61,33 @@ def resize_texture(filename: Path, resize_percent) -> None: image.save(filename) -def decompose_convex(filename: Path, work_dir: Path, vhacd_args: VhacdArgs) -> bool: - if not vhacd_args.enable: +def decompose_convex(filename: Path, work_dir: Path, coacd_args: CoacdArgs) -> bool: + if not coacd_args.enable: return False - if _VHACD_EXECUTABLE is None: - logging.info( - "V-HACD was enabled but not found in the system path. Either install it " - "manually or run `bash install_vhacd.sh`. Skipping decomposition" - ) - return False + cprint(f"Decomposing {filename}", "yellow") + + import coacd # noqa: F401 obj_file = filename.resolve() logging.info(f"Decomposing {obj_file}") - with tempfile.TemporaryDirectory() as tmpdirname: - prev_dir = os.getcwd() - os.chdir(tmpdirname) - - # Copy the obj file to the temporary directory. - shutil.copy(obj_file, tmpdirname) - - # Call V-HACD, suppressing output. - ret = subprocess.run( - [ - f"{_VHACD_EXECUTABLE}", - obj_file.name, - "-o", - "obj", - "-h", - f"{vhacd_args.max_output_convex_hulls}", - "-r", - f"{vhacd_args.voxel_resolution}", - "-e", - f"{vhacd_args.volume_error_percent}", - "-d", - f"{vhacd_args.max_recursion_depth}", - "-s", - f"{int(not vhacd_args.disable_shrink_wrap)}", - "-f", - f"{vhacd_args.fill_mode.name.lower()}", - "-v", - f"{vhacd_args.max_hull_vert_count}", - "-a", - f"{int(not vhacd_args.disable_async)}", - "-l", - f"{vhacd_args.min_edge_length}", - "-p", - f"{int(vhacd_args.split_hull)}", - ], - stdout=subprocess.DEVNULL, - stderr=subprocess.STDOUT, - check=True, - ) - if ret.returncode != 0: - logging.error(f"V-HACD failed on {filename}") - return False - - # Remove the original obj file and the V-HACD output files. - for name in _VHACD_OUTPUTS + [obj_file.name]: - file_to_delete = Path(tmpdirname) / name - if file_to_delete.exists(): - file_to_delete.unlink() - - os.chdir(prev_dir) - - # Get list of sorted collisions. - collisions = list(Path(tmpdirname).glob("*.obj")) - collisions.sort(key=lambda x: x.stem) - - for i, filename in enumerate(collisions): - savename = str(work_dir / f"{obj_file.stem}_collision_{i}.obj") - shutil.move(str(filename), savename) + mesh = trimesh.load(obj_file, force="mesh") + mesh = coacd.Mesh(mesh.vertices, mesh.faces) + + parts = coacd.run_coacd( + mesh=mesh, + seed=coacd_args.seed, + ) + + mesh_parts = [] + for vs, fs in parts: + mesh_parts.append(trimesh.Trimesh(vs, fs)) + + # Save the decomposed parts as separate OBJ files. + for i, p in enumerate(mesh_parts): + submesh_name = work_dir / f"{obj_file.stem}_collision_{i}.obj" + p.export(submesh_name.as_posix()) return True @@ -191,8 +108,8 @@ def process_obj(filename: Path, args: Args) -> None: work_dir.mkdir(exist_ok=True) logging.info(f"Saving processed meshes to {work_dir}") - # Decompose the mesh into convex pieces if V-HACD is available. - decomp_success = decompose_convex(filename, work_dir, args.vhacd_args) + # Decompose the mesh into convex pieces if desired. + decomp_success = decompose_convex(filename, work_dir, args.coacd_args) # Check if the OBJ files references an MTL file. # TODO(kevin): Should we support multiple MTL files? @@ -308,35 +225,6 @@ def process_obj(filename: Path, args: Args) -> None: ]: file.unlink() - # Save an MTL file for each submesh if desired. - if args.save_mtl: - for i, smtl in enumerate(sub_mtls): - mtl_name = smtl[0].split(" ")[1].strip() - for line in smtl: - if "newmtl" in line: - material_name = line.split(" ")[1].strip() - break - # Save the MTL file. - with open(work_dir / f"{mtl_name}.mtl", "w") as f: - f.write("\n".join(smtl)) - # Edit the mtllib line to point to the new MTL file. - if len(sub_mtls) > 1: - savename = str(work_dir / f"{filename.stem}_{i}.obj") - else: - savename = str(work_dir / f"{filename.stem}.obj") - with open(savename, "r") as f: - lines = f.readlines() - for i, line in enumerate(lines): - if line.startswith("mtllib"): - lines[i] = f"mtllib {mtl_name}.mtl\n" - break - for i, line in enumerate(lines): - if line.startswith("usemtl"): - lines[i] = f"usemtl {material_name}\n" - break - with open(savename, "w") as f: - f.write("".join(lines)) - # Build an MJCF. builder = MJCFBuilder(filename, mesh, mtls, decomp_success=decomp_success) builder.build() diff --git a/obj2mjcf/mjcf_builder.py b/obj2mjcf/mjcf_builder.py index 4b3942c..8694adc 100644 --- a/obj2mjcf/mjcf_builder.py +++ b/obj2mjcf/mjcf_builder.py @@ -3,6 +3,7 @@ from typing import Any, List, Union import mujoco +import numpy as np import trimesh from lxml import etree from termcolor import cprint @@ -109,14 +110,14 @@ def add_visual_geometries( if isinstance(mesh, trimesh.base.Trimesh): meshname = Path(f"{filename.stem}.obj") # Add the mesh to assets. - etree.SubElement(asset_elem, "mesh", file=str(meshname)) + etree.SubElement(asset_elem, "mesh", file=meshname.as_posix()) # Add the geom to the worldbody. if process_mtl: e_ = etree.SubElement( obj_body, "geom", material=materials[0].name, - mesh=str(meshname.stem), + mesh=meshname.stem, ) e_.attrib["class"] = "visual" else: @@ -126,7 +127,7 @@ def add_visual_geometries( for i, (name, geom) in enumerate(mesh.geometry.items()): meshname = Path(f"{filename.stem}_{i}.obj") # Add the mesh to assets. - etree.SubElement(asset_elem, "mesh", file=str(meshname)) + etree.SubElement(asset_elem, "mesh", file=meshname.as_posix()) # Add the geom to the worldbody. if process_mtl: e_ = etree.SubElement( @@ -160,7 +161,13 @@ def add_collision_geometries( for collision in collisions: etree.SubElement(asset_elem, "mesh", file=collision.name) - e_ = etree.SubElement(obj_body, "geom", mesh=collision.stem) + rgb = np.random.rand(3) # Generate random color for collision meshes. + e_ = etree.SubElement( + obj_body, + "geom", + mesh=collision.stem, + rgba=f"{rgb[0]} {rgb[1]} {rgb[2]} 1", + ) e_.attrib["class"] = "collision" else: # If no decomposed convex hulls were created, use the original mesh as the @@ -221,7 +228,7 @@ def compile_model(self): try: tmp_path = work_dir / "tmp.xml" tree.write(tmp_path, encoding="utf-8") - model = mujoco.MjModel.from_xml_path(str(tmp_path)) + model = mujoco.MjModel.from_xml_path(tmp_path.as_posix()) data = mujoco.MjData(model) mujoco.mj_step(model, data) cprint(f"{filename} compiled successfully!", "green") @@ -244,6 +251,6 @@ def save_mjcf( raise ValueError("Tree has not been defined yet.") # Save the MJCF file. - xml_path = str(work_dir / f"{filename.stem}.xml") - tree.write(xml_path, encoding="utf-8") + xml_path = work_dir / f"{filename.stem}.xml" + tree.write(xml_path.as_posix(), encoding="utf-8") logging.info(f"Saved MJCF to {xml_path}") diff --git a/setup.py b/setup.py index ba53736..c5e08c3 100644 --- a/setup.py +++ b/setup.py @@ -27,6 +27,7 @@ "numpy", "termcolor>=2.0.1", "lxml>=4.9.1", + "coacd>=1.0.0", ] testing_requirements = [ diff --git a/tests/test_cli.py b/tests/test_cli.py index 7134cb7..deb9bba 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,15 +1,10 @@ import pathlib -import subprocess import shutil +import subprocess # Path to the directory containing this file. _THIS_DIR = pathlib.Path(__file__).parent.absolute() -# Remove groups dir if it exists. -_OUT_DIR = _THIS_DIR / "groups" -if _OUT_DIR.exists(): - shutil.rmtree(_OUT_DIR) - def test_runs_without_error() -> None: retcode = subprocess.call( @@ -17,7 +12,7 @@ def test_runs_without_error() -> None: "obj2mjcf", "--obj-dir", f"{str(_THIS_DIR)}", - "--save-mtl", + "--overwrite", "--save-mjcf", "--compile-model", "--verbose",