xinjjj commited on
Commit
a4227aa
·
1 Parent(s): 1602399
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. app.py +2 -2
  2. app_style.py +1 -1
  3. common.py +6 -13
  4. embodied_gen/__init__.py +8 -0
  5. embodied_gen/data/__init__.py +1 -0
  6. embodied_gen/data/asset_converter.py +242 -68
  7. embodied_gen/data/backproject_v3.py +1 -1
  8. embodied_gen/data/differentiable_render.py +35 -9
  9. embodied_gen/data/mesh_operator.py +5 -4
  10. embodied_gen/data/utils.py +73 -8
  11. embodied_gen/envs/__init__.py +1 -0
  12. embodied_gen/envs/pick_embodiedgen.py +1 -1
  13. embodied_gen/models/__init__.py +1 -0
  14. embodied_gen/models/gs_model.py +1 -1
  15. embodied_gen/models/hunyuan3d.py +957 -0
  16. embodied_gen/models/image_comm_model.py +6 -0
  17. embodied_gen/models/sam3d.py +1 -8
  18. embodied_gen/models/segment_model.py +3 -5
  19. embodied_gen/models/text_model.py +19 -7
  20. embodied_gen/models/texture_model.py +11 -5
  21. embodied_gen/scripts/__init__.py +1 -0
  22. embodied_gen/scripts/affordance_annot/affordance_annot.py +313 -0
  23. embodied_gen/scripts/affordance_annot/eval_grasps.py +1303 -0
  24. embodied_gen/scripts/affordance_annot/franka_panda/coll_mesh.obj +464 -0
  25. embodied_gen/scripts/affordance_annot/franka_panda/gripper.urdf +120 -0
  26. embodied_gen/scripts/affordance_annot/franka_panda/meshes/collision/hand.stl +0 -0
  27. embodied_gen/scripts/affordance_annot/franka_panda/meshes/visual/finger.dae +178 -0
  28. embodied_gen/scripts/affordance_annot/franka_panda/meshes/visual/hand.dae +0 -0
  29. embodied_gen/scripts/affordance_annot/gen_grasp.py +842 -0
  30. embodied_gen/scripts/affordance_annot/part_seg.py +697 -0
  31. embodied_gen/scripts/affordance_annot/partsemantics_annot.py +438 -0
  32. embodied_gen/scripts/compose_layout.py +0 -1
  33. embodied_gen/scripts/deformable_sim/genesis_cloth_converter.py +282 -0
  34. embodied_gen/scripts/deformable_sim/render_mesh_displacement_heatmap.py +551 -0
  35. embodied_gen/scripts/deformable_sim/simulate_genesis_cloth.py +608 -0
  36. embodied_gen/scripts/gen_texture.py +1 -1
  37. embodied_gen/scripts/imageto3d.py +102 -19
  38. embodied_gen/scripts/parallel_sim.py +1 -1
  39. embodied_gen/scripts/room_gen/custom_solve.gin +1 -0
  40. embodied_gen/scripts/room_gen/export_scene.py +13 -14
  41. embodied_gen/scripts/room_gen/gen_room.py +21 -0
  42. embodied_gen/scripts/room_gen/gen_trajectory.py +306 -0
  43. embodied_gen/scripts/room_gen/render_usd.py +1017 -22
  44. embodied_gen/scripts/room_gen/route_room.py +5 -0
  45. embodied_gen/scripts/textto3d.py +152 -31
  46. embodied_gen/skills/README.md +14 -14
  47. embodied_gen/skills/asset-process/SKILL.md +101 -0
  48. embodied_gen/skills/asset-process/__init__.py +36 -0
  49. embodied_gen/skills/asset-process/asset_process.py +443 -0
  50. embodied_gen/skills/asset-retrieval/scripts/retrieve_asset.py +16 -1
app.py CHANGED
@@ -57,13 +57,13 @@ with gr.Blocks(delete_cache=(43200, 43200), theme=custom_theme) as demo:
57
  <a href="https://horizonrobotics.github.io/EmbodiedGen">
58
  <img alt="📖 Documentation" src="https://img.shields.io/badge/📖-Documentation-blue">
59
  </a>
60
- <a href="https://arxiv.org/abs/2506.10600">
61
  <img alt="📄 arXiv" src="https://img.shields.io/badge/📄-arXiv-b31b1b">
62
  </a>
63
  <a href="https://github.com/HorizonRobotics/EmbodiedGen">
64
  <img alt="💻 GitHub" src="https://img.shields.io/badge/GitHub-000000?logo=github">
65
  </a>
66
- <a href="https://www.youtube.com/watch?v=rG4odybuJRk">
67
  <img alt="🎥 Video" src="https://img.shields.io/badge/🎥-Video-red">
68
  </a>
69
  </p>
 
57
  <a href="https://horizonrobotics.github.io/EmbodiedGen">
58
  <img alt="📖 Documentation" src="https://img.shields.io/badge/📖-Documentation-blue">
59
  </a>
60
+ <a href="https://arxiv.org/abs/2607.07459">
61
  <img alt="📄 arXiv" src="https://img.shields.io/badge/📄-arXiv-b31b1b">
62
  </a>
63
  <a href="https://github.com/HorizonRobotics/EmbodiedGen">
64
  <img alt="💻 GitHub" src="https://img.shields.io/badge/GitHub-000000?logo=github">
65
  </a>
66
+ <a href="https://youtu.be/MIkJJSVM8L4">
67
  <img alt="🎥 Video" src="https://img.shields.io/badge/🎥-Video-red">
68
  </a>
69
  </p>
app_style.py CHANGED
@@ -15,7 +15,7 @@
15
  # permissions and limitations under the License.
16
 
17
  from gradio.themes import Soft
18
- from gradio.themes.utils.colors import gray, neutral, slate, stone, teal, zinc
19
 
20
  lighting_css = """
21
  <style>
 
15
  # permissions and limitations under the License.
16
 
17
  from gradio.themes import Soft
18
+ from gradio.themes.utils.colors import gray, stone
19
 
20
  lighting_css = """
21
  <style>
common.py CHANGED
@@ -16,15 +16,15 @@
16
 
17
  import spaces # noqa: E402
18
  from embodied_gen.utils.monkey_patch.gradio import (
19
- _disable_xformers_flash3,
20
  _neutralize_warp_in_parent,
21
  _patch_open3d_cuda_device_count_bug,
22
  )
23
  from embodied_gen.utils.monkey_patch.trellis import monkey_path_trellis
 
24
 
25
  _neutralize_warp_in_parent()
26
  _patch_open3d_cuda_device_count_bug()
27
- _disable_xformers_flash3()
28
  monkey_path_trellis()
29
 
30
 
@@ -93,12 +93,6 @@ os.environ.setdefault("OPENAI_API_KEY", "sk-placeholder")
93
  MAX_SEED = 100000
94
 
95
 
96
- def _move_pipeline(pipeline: object, device: str) -> None:
97
- pipeline.to(device)
98
- image_encoder = getattr(pipeline, "image_encoder", None)
99
- if image_encoder is not None:
100
- image_encoder.to(device)
101
-
102
  # Global variables for lazy initialization
103
  _RBG_REMOVER = None
104
  _RBG14_REMOVER = None
@@ -136,6 +130,7 @@ elif os.getenv("GRADIO_APP").startswith("textto3d"):
136
  text_model_dir,
137
  device="cpu",
138
  download_ip_adapter=True,
 
139
  )
140
  _PIPELINE_IMG_IP = None
141
  SEG_CHECKER = ImageSegChecker(GPT_CLIENT)
@@ -624,12 +619,12 @@ def text2image_fn(
624
  "weights/Kolors",
625
  ref_scale=ip_adapt_scale,
626
  device="cpu",
 
627
  )
628
  pipeline = _PIPELINE_IMG_IP
629
  pipeline.set_ip_adapter_scale([ip_adapt_scale])
630
 
631
  try:
632
- _move_pipeline(pipeline, "cuda")
633
  images = text2img_gen(
634
  prompt=prompt,
635
  n_sample=n_sample,
@@ -641,10 +636,8 @@ def text2image_fn(
641
  seed=seed,
642
  )
643
  finally:
644
- try:
645
- _move_pipeline(pipeline, "cpu")
646
- except Exception:
647
- logger.exception("Failed to move text-to-image pipeline to CPU")
648
  gc.collect()
649
  if torch.cuda.is_available():
650
  torch.cuda.empty_cache()
 
16
 
17
  import spaces # noqa: E402
18
  from embodied_gen.utils.monkey_patch.gradio import (
 
19
  _neutralize_warp_in_parent,
20
  _patch_open3d_cuda_device_count_bug,
21
  )
22
  from embodied_gen.utils.monkey_patch.trellis import monkey_path_trellis
23
+ from embodied_gen.utils.monkey_patch.xformers import disable_xformers_flash3
24
 
25
  _neutralize_warp_in_parent()
26
  _patch_open3d_cuda_device_count_bug()
27
+ disable_xformers_flash3()
28
  monkey_path_trellis()
29
 
30
 
 
93
  MAX_SEED = 100000
94
 
95
 
 
 
 
 
 
 
96
  # Global variables for lazy initialization
97
  _RBG_REMOVER = None
98
  _RBG14_REMOVER = None
 
130
  text_model_dir,
131
  device="cpu",
132
  download_ip_adapter=True,
133
+ enable_cpu_offload=True,
134
  )
135
  _PIPELINE_IMG_IP = None
136
  SEG_CHECKER = ImageSegChecker(GPT_CLIENT)
 
619
  "weights/Kolors",
620
  ref_scale=ip_adapt_scale,
621
  device="cpu",
622
+ enable_cpu_offload=True,
623
  )
624
  pipeline = _PIPELINE_IMG_IP
625
  pipeline.set_ip_adapter_scale([ip_adapt_scale])
626
 
627
  try:
 
628
  images = text2img_gen(
629
  prompt=prompt,
630
  n_sample=n_sample,
 
636
  seed=seed,
637
  )
638
  finally:
639
+ if hasattr(pipeline, "maybe_free_model_hooks"):
640
+ pipeline.maybe_free_model_hooks()
 
 
641
  gc.collect()
642
  if torch.cuda.is_available():
643
  torch.cuda.empty_cache()
embodied_gen/__init__.py ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ from importlib.metadata import PackageNotFoundError, version
2
+
3
+ try:
4
+ __version__ = version("embodied_gen")
5
+ except PackageNotFoundError:
6
+ __version__ = "0+unknown"
7
+
8
+ __all__ = ["__version__"]
embodied_gen/data/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Data processing utilities for EmbodiedGen."""
embodied_gen/data/asset_converter.py CHANGED
@@ -24,6 +24,7 @@ from abc import ABC, abstractmethod
24
  from glob import glob
25
  from shutil import copy, copytree, rmtree
26
 
 
27
  import trimesh
28
  from scipy.spatial.transform import Rotation
29
  from embodied_gen.utils.enum import AssetType
@@ -204,6 +205,123 @@ class MeshtoMJCFConverter(AssetConverterBase):
204
  os.makedirs(os.path.dirname(dst), exist_ok=True)
205
  copy(src, dst)
206
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
207
  def add_geometry(
208
  self,
209
  mujoco_element: ET.Element,
@@ -215,6 +333,7 @@ class MeshtoMJCFConverter(AssetConverterBase):
215
  mesh_name: str,
216
  material: ET.Element | None = None,
217
  is_collision: bool = False,
 
218
  ) -> None:
219
  """Adds geometry to MJCF body from URDF link.
220
 
@@ -228,6 +347,8 @@ class MeshtoMJCFConverter(AssetConverterBase):
228
  mesh_name (str): Mesh name.
229
  material (ET.Element, optional): Material element.
230
  is_collision (bool, optional): If True, treat as collision geometry.
 
 
231
  """
232
  element = link.find(tag)
233
  geometry = element.find("geometry")
@@ -238,9 +359,18 @@ class MeshtoMJCFConverter(AssetConverterBase):
238
  output_mesh = f"{output_dir}/{filename}"
239
  self._copy_asset_file(input_mesh, output_mesh)
240
 
241
- mesh_origin = element.find("origin")
242
  if mesh_origin is not None:
243
- self.transform_mesh(input_mesh, output_mesh, mesh_origin)
 
 
 
 
 
 
 
 
 
244
 
245
  if is_collision:
246
  mesh_parts = trimesh.load(
@@ -251,11 +381,13 @@ class MeshtoMJCFConverter(AssetConverterBase):
251
  mesh_parts = [trimesh.load(output_mesh, force="mesh")]
252
  for idx, mesh_part in enumerate(mesh_parts):
253
  if is_collision:
 
254
  idx_mesh_name = f"{mesh_name}_{idx}"
255
  base, ext = os.path.splitext(filename)
256
  idx_filename = f"{base}_{idx}{ext}"
257
- base_outdir = os.path.dirname(output_mesh)
258
- mesh_part.export(os.path.join(base_outdir, '..', idx_filename))
 
259
  geom_attrs = {
260
  "contype": "1",
261
  "conaffinity": "1",
@@ -263,7 +395,11 @@ class MeshtoMJCFConverter(AssetConverterBase):
263
  }
264
  else:
265
  idx_mesh_name, idx_filename = mesh_name, filename
266
- geom_attrs = {"contype": "0", "conaffinity": "0"}
 
 
 
 
267
 
268
  ET.SubElement(
269
  mujoco_element,
@@ -271,6 +407,7 @@ class MeshtoMJCFConverter(AssetConverterBase):
271
  name=idx_mesh_name,
272
  file=idx_filename,
273
  scale=scale,
 
274
  )
275
  geom = ET.SubElement(body, "geom", type="mesh", mesh=idx_mesh_name)
276
  geom.attrib.update(geom_attrs)
@@ -286,7 +423,7 @@ class MeshtoMJCFConverter(AssetConverterBase):
286
  output_dir: str,
287
  name: str,
288
  reflectance: float = 0.2,
289
- ) -> ET.Element:
290
  """Adds materials to MJCF asset from URDF link.
291
 
292
  Args:
@@ -306,37 +443,51 @@ class MeshtoMJCFConverter(AssetConverterBase):
306
  mesh = geometry.find("mesh")
307
  filename = mesh.get("filename")
308
  dirname = os.path.dirname(filename)
309
- material = None
310
- for path in glob(f"{input_dir}/{dirname}/*.png"):
311
- file_name = os.path.basename(path)
312
- if "keep_materials" in self.kwargs:
313
- find_flag = False
314
- for keep_key in self.kwargs["keep_materials"]:
315
- if keep_key in file_name.lower():
316
- find_flag = True
317
- if find_flag is False:
318
- continue
319
-
320
- self._copy_asset_file(
321
- path,
322
- f"{output_dir}/{dirname}/{file_name}",
323
- )
324
- texture_name = f"texture_{name}_{os.path.splitext(file_name)[0]}"
325
- material = ET.SubElement(
326
- mujoco_element,
327
- "material",
328
- name=f"material_{name}",
329
- texture=texture_name,
330
- reflectance=str(reflectance),
331
- )
332
- ET.SubElement(
333
- mujoco_element,
334
- "texture",
335
- name=texture_name,
336
- type="2d",
337
- file=f"{dirname}/{file_name}",
338
- )
339
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
340
  return material
341
 
342
  def convert(self, urdf_path: str, mjcf_path: str):
@@ -369,7 +520,7 @@ class MeshtoMJCFConverter(AssetConverterBase):
369
  output_dir,
370
  name=str(idx),
371
  )
372
- joint = ET.SubElement(body, "joint", attrib={"type": "free"})
373
  self.add_geometry(
374
  mujoco_asset,
375
  link,
@@ -427,6 +578,37 @@ class URDFtoMJCFConverter(MeshtoMJCFConverter):
427
  output_dir = os.path.dirname(mjcf_path)
428
  os.makedirs(output_dir, exist_ok=True)
429
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
430
  body_dict = {}
431
  for idx, link in enumerate(root.findall("link")):
432
  link_name = link.get("name", f"unnamed_link_{idx}")
@@ -450,6 +632,7 @@ class URDFtoMJCFConverter(MeshtoMJCFConverter):
450
  output_dir,
451
  f"visual_mesh_{idx}",
452
  material,
 
453
  )
454
  if link.find("collision") is not None:
455
  self.add_geometry(
@@ -461,18 +644,15 @@ class URDFtoMJCFConverter(MeshtoMJCFConverter):
461
  output_dir,
462
  f"collision_mesh_{idx}",
463
  is_collision=True,
 
464
  )
465
 
466
- # Process joints to set transformations and hierarchy
 
 
467
  for joint in root.findall("joint"):
468
- joint_type = joint.get("type")
469
- if joint_type != "fixed":
470
- logger.warning("Only support fixed joints in conversion now.")
471
- continue
472
-
473
  parent_link = joint.find("parent").get("link")
474
  child_link = joint.find("child").get("link")
475
- origin = joint.find("origin")
476
  if parent_link not in body_dict or child_link not in body_dict:
477
  logger.warning(
478
  f"Parent or child link not found for joint: {joint.get('name')}"
@@ -483,11 +663,6 @@ class URDFtoMJCFConverter(MeshtoMJCFConverter):
483
  mujoco_worldbody.remove(child_body)
484
  parent_body = body_dict[parent_link]
485
  parent_body.append(child_body)
486
- if origin is not None:
487
- xyz = origin.get("xyz", "0 0 0")
488
- rpy = origin.get("rpy", "0 0 0")
489
- child_body.set("pos", xyz)
490
- child_body.set("euler", rpy)
491
 
492
  tree = ET.ElementTree(mujoco_struct)
493
  ET.indent(tree, space=" ", level=0)
@@ -916,13 +1091,6 @@ if __name__ == "__main__":
916
 
917
  urdf_paths = [
918
  'outputs/EmbodiedGenData/demo_assets/banana/result/banana.urdf',
919
- 'outputs/EmbodiedGenData/demo_assets/book/result/book.urdf',
920
- 'outputs/EmbodiedGenData/demo_assets/lamp/result/lamp.urdf',
921
- 'outputs/EmbodiedGenData/demo_assets/mug/result/mug.urdf',
922
- 'outputs/EmbodiedGenData/demo_assets/remote_control/result/remote_control.urdf',
923
- "outputs/EmbodiedGenData/demo_assets/rubik's_cube/result/rubik's_cube.urdf",
924
- 'outputs/EmbodiedGenData/demo_assets/table/result/table.urdf',
925
- 'outputs/EmbodiedGenData/demo_assets/vase/result/vase.urdf',
926
  ]
927
 
928
  if target_asset_type == AssetType.MJCF:
@@ -937,13 +1105,6 @@ if __name__ == "__main__":
937
  elif target_asset_type == AssetType.USD:
938
  output_files = [
939
  'outputs/EmbodiedGenData/demo_assets/banana/usd/banana.usd',
940
- 'outputs/EmbodiedGenData/demo_assets/book/usd/book.usd',
941
- 'outputs/EmbodiedGenData/demo_assets/lamp/usd/lamp.usd',
942
- 'outputs/EmbodiedGenData/demo_assets/mug/usd/mug.usd',
943
- 'outputs/EmbodiedGenData/demo_assets/remote_control/usd/remote_control.usd',
944
- "outputs/EmbodiedGenData/demo_assets/rubik's_cube/usd/rubik's_cube.usd",
945
- 'outputs/EmbodiedGenData/demo_assets/table/usd/table.usd',
946
- 'outputs/EmbodiedGenData/demo_assets/vase/usd/vase.usd',
947
  ]
948
  asset_converter = AssetConverterFactory.create(
949
  target_type=AssetType.USD,
@@ -979,8 +1140,21 @@ if __name__ == "__main__":
979
 
980
  # # Convert infinigen usdc to physics usdc
981
  # converter = PhysicsUSDAdder()
 
 
 
982
  # with converter:
983
- # converter.convert(
984
- # usd_path="/home/users/xinjie.wang/xinjie/infinigen/outputs/usdc/export_scene/export_scene.usdc",
985
- # output_file="/home/users/xinjie.wang/xinjie/infinigen/outputs/usdc_p3/export_scene/export_scene.usdc",
986
- # )
 
 
 
 
 
 
 
 
 
 
 
24
  from glob import glob
25
  from shutil import copy, copytree, rmtree
26
 
27
+ import numpy as np
28
  import trimesh
29
  from scipy.spatial.transform import Rotation
30
  from embodied_gen.utils.enum import AssetType
 
205
  os.makedirs(os.path.dirname(dst), exist_ok=True)
206
  copy(src, dst)
207
 
208
+ @staticmethod
209
+ def _ensure_collision_volume(
210
+ mesh: trimesh.Trimesh,
211
+ min_thickness: float = 1e-3,
212
+ ) -> trimesh.Trimesh:
213
+ """Replace degenerate collision surfaces with thin boxes."""
214
+ extents = mesh.extents
215
+ if min(extents) >= min_thickness:
216
+ return mesh
217
+
218
+ box_extents = [max(float(extent), min_thickness) for extent in extents]
219
+ transform = trimesh.transformations.translation_matrix(
220
+ mesh.bounds.mean(axis=0)
221
+ )
222
+ return trimesh.creation.box(extents=box_extents, transform=transform)
223
+
224
+ @staticmethod
225
+ def _origin_to_matrix(origin: ET.Element | None) -> np.ndarray:
226
+ """Convert a URDF ``<origin>`` element to a 4x4 homogeneous matrix.
227
+
228
+ A missing element is treated as the identity transform. The rotation
229
+ follows the URDF convention (intrinsic XYZ Euler angles in radians),
230
+ matching ``transform_mesh`` which applies ``v' = R @ v + t``.
231
+ """
232
+ matrix = np.eye(4)
233
+ if origin is None:
234
+ return matrix
235
+ xyz = list(map(float, origin.get("xyz", "0 0 0").split()))
236
+ rpy = list(map(float, origin.get("rpy", "0 0 0").split()))
237
+ matrix[:3, :3] = Rotation.from_euler(
238
+ "xyz", rpy, degrees=False
239
+ ).as_matrix()
240
+ matrix[:3, 3] = xyz
241
+ return matrix
242
+
243
+ @staticmethod
244
+ def _bake_obj_vertices(
245
+ input_obj: str, output_obj: str, matrix: np.ndarray
246
+ ) -> None:
247
+ """Bake a rigid transform into an OBJ's vertex/normal lines only.
248
+
249
+ Unlike a full mesh re-export, this rewrites just the ``v`` (rotated and
250
+ translated) and ``vn`` (rotated) lines, leaving ``vt`` UVs, faces,
251
+ ``usemtl``/``mtllib`` and the referenced texture atlas byte-identical.
252
+ That keeps MuJoCo/Isaac Gym texturing identical to the source asset
253
+ while the geometry is placed in world space.
254
+
255
+ Args:
256
+ input_obj (str): Source OBJ path.
257
+ output_obj (str): Destination OBJ path.
258
+ matrix (np.ndarray): 4x4 homogeneous transform (rotation only,
259
+ no scale).
260
+ """
261
+ rotation = matrix[:3, :3]
262
+ translation = matrix[:3, 3]
263
+
264
+ lines: list[str] = []
265
+ with open(input_obj, "r", encoding="utf-8", errors="ignore") as handle:
266
+ for line in handle:
267
+ if line.startswith("v "):
268
+ parts = line.split()
269
+ point = (
270
+ rotation @ np.array(parts[1:4], dtype=float)
271
+ + translation
272
+ )
273
+ extra = " " + " ".join(parts[4:]) if len(parts) > 4 else ""
274
+ lines.append(
275
+ f"v {point[0]:.6f} {point[1]:.6f} {point[2]:.6f}{extra}\n"
276
+ )
277
+ elif line.startswith("vn "):
278
+ parts = line.split()
279
+ normal = rotation @ np.array(parts[1:4], dtype=float)
280
+ lines.append(
281
+ f"vn {normal[0]:.6f} {normal[1]:.6f} {normal[2]:.6f}\n"
282
+ )
283
+ else:
284
+ lines.append(line)
285
+
286
+ os.makedirs(os.path.dirname(output_obj), exist_ok=True)
287
+ with open(output_obj, "w", encoding="utf-8") as handle:
288
+ handle.writelines(lines)
289
+
290
+ @classmethod
291
+ def _compose_origin(
292
+ cls,
293
+ outer: ET.Element | None,
294
+ inner: ET.Element | None,
295
+ ) -> ET.Element | None:
296
+ """Compose two URDF origins into a single ``<origin>`` element.
297
+
298
+ The returned transform applies ``inner`` first and then ``outer``
299
+ (``M = M_outer @ M_inner``), so baking a link's world frame onto its
300
+ local visual/collision origin yields world-space mesh vertices. The
301
+ result always carries both ``xyz`` and ``rpy`` so it can be consumed by
302
+ ``transform_mesh``. Returns ``None`` when both inputs are absent.
303
+
304
+ Args:
305
+ outer (ET.Element | None): Outer (parent/world) origin.
306
+ inner (ET.Element | None): Inner (local) origin.
307
+
308
+ Returns:
309
+ ET.Element | None: Composed origin, or ``None`` if both are ``None``.
310
+ """
311
+ if outer is None and inner is None:
312
+ return None
313
+
314
+ matrix = cls._origin_to_matrix(outer) @ cls._origin_to_matrix(inner)
315
+ xyz = matrix[:3, 3]
316
+ rpy = Rotation.from_matrix(matrix[:3, :3]).as_euler(
317
+ "xyz", degrees=False
318
+ )
319
+
320
+ origin = ET.Element("origin")
321
+ origin.set("xyz", " ".join(f"{value:.8g}" for value in xyz))
322
+ origin.set("rpy", " ".join(f"{value:.8g}" for value in rpy))
323
+ return origin
324
+
325
  def add_geometry(
326
  self,
327
  mujoco_element: ET.Element,
 
333
  mesh_name: str,
334
  material: ET.Element | None = None,
335
  is_collision: bool = False,
336
+ body_origin: ET.Element | None = None,
337
  ) -> None:
338
  """Adds geometry to MJCF body from URDF link.
339
 
 
347
  mesh_name (str): Mesh name.
348
  material (ET.Element, optional): Material element.
349
  is_collision (bool, optional): If True, treat as collision geometry.
350
+ body_origin (ET.Element, optional): Link world origin to bake into
351
+ the mesh vertices; when None only the local origin is applied.
352
  """
353
  element = link.find(tag)
354
  geometry = element.find("geometry")
 
359
  output_mesh = f"{output_dir}/{filename}"
360
  self._copy_asset_file(input_mesh, output_mesh)
361
 
362
+ mesh_origin = self._compose_origin(body_origin, element.find("origin"))
363
  if mesh_origin is not None:
364
+ if not is_collision and input_mesh.lower().endswith(".obj"):
365
+ # Preserve UVs/material atlas for textured visuals; a trimesh
366
+ # re-export would rewrite mtllib and drop the diffuse atlas.
367
+ self._bake_obj_vertices(
368
+ input_mesh,
369
+ output_mesh,
370
+ self._origin_to_matrix(mesh_origin),
371
+ )
372
+ else:
373
+ self.transform_mesh(input_mesh, output_mesh, mesh_origin)
374
 
375
  if is_collision:
376
  mesh_parts = trimesh.load(
 
381
  mesh_parts = [trimesh.load(output_mesh, force="mesh")]
382
  for idx, mesh_part in enumerate(mesh_parts):
383
  if is_collision:
384
+ mesh_part = self._ensure_collision_volume(mesh_part)
385
  idx_mesh_name = f"{mesh_name}_{idx}"
386
  base, ext = os.path.splitext(filename)
387
  idx_filename = f"{base}_{idx}{ext}"
388
+ idx_output_mesh = os.path.join(output_dir, idx_filename)
389
+ os.makedirs(os.path.dirname(idx_output_mesh), exist_ok=True)
390
+ mesh_part.export(idx_output_mesh)
391
  geom_attrs = {
392
  "contype": "1",
393
  "conaffinity": "1",
 
395
  }
396
  else:
397
  idx_mesh_name, idx_filename = mesh_name, filename
398
+ geom_attrs = {
399
+ "contype": "0",
400
+ "conaffinity": "0",
401
+ "density": "0",
402
+ }
403
 
404
  ET.SubElement(
405
  mujoco_element,
 
407
  name=idx_mesh_name,
408
  file=idx_filename,
409
  scale=scale,
410
+ inertia="shell",
411
  )
412
  geom = ET.SubElement(body, "geom", type="mesh", mesh=idx_mesh_name)
413
  geom.attrib.update(geom_attrs)
 
423
  output_dir: str,
424
  name: str,
425
  reflectance: float = 0.2,
426
+ ) -> ET.Element | None:
427
  """Adds materials to MJCF asset from URDF link.
428
 
429
  Args:
 
443
  mesh = geometry.find("mesh")
444
  filename = mesh.get("filename")
445
  dirname = os.path.dirname(filename)
446
+ texture_paths = sorted(glob(f"{input_dir}/{dirname}/*.png"))
447
+ keep_materials = self.kwargs.get("keep_materials")
448
+ if keep_materials:
449
+ texture_paths = [
450
+ path
451
+ for path in texture_paths
452
+ if any(
453
+ key.lower() in os.path.basename(path).lower()
454
+ for key in keep_materials
455
+ )
456
+ ]
457
+ if not texture_paths:
458
+ return None
459
+
460
+ texture_path = next(
461
+ (
462
+ path
463
+ for path in texture_paths
464
+ if "diffuse" in os.path.basename(path).lower()
465
+ ),
466
+ texture_paths[0],
467
+ )
468
+ file_name = os.path.basename(texture_path)
469
+ texture_file = (
470
+ os.path.join(dirname, file_name) if dirname else file_name
471
+ )
472
+ self._copy_asset_file(
473
+ texture_path,
474
+ os.path.join(output_dir, texture_file),
475
+ )
476
+ texture_name = f"texture_{name}_{os.path.splitext(file_name)[0]}"
477
+ material = ET.SubElement(
478
+ mujoco_element,
479
+ "material",
480
+ name=f"material_{name}",
481
+ texture=texture_name,
482
+ reflectance=str(reflectance),
483
+ )
484
+ ET.SubElement(
485
+ mujoco_element,
486
+ "texture",
487
+ name=texture_name,
488
+ type="2d",
489
+ file=texture_file,
490
+ )
491
  return material
492
 
493
  def convert(self, urdf_path: str, mjcf_path: str):
 
520
  output_dir,
521
  name=str(idx),
522
  )
523
+ ET.SubElement(body, "joint", attrib={"type": "free"})
524
  self.add_geometry(
525
  mujoco_asset,
526
  link,
 
578
  output_dir = os.path.dirname(mjcf_path)
579
  os.makedirs(output_dir, exist_ok=True)
580
 
581
+ parent_by_child = {}
582
+ origin_by_child = {}
583
+ for joint in root.findall("joint"):
584
+ joint_type = joint.get("type")
585
+ if joint_type != "fixed":
586
+ raise NotImplementedError(
587
+ f"URDFtoMJCFConverter only supports fixed joints, got "
588
+ f"{joint_type!r} for joint {joint.get('name')!r}."
589
+ )
590
+ parent_by_child[joint.find("child").get("link")] = joint.find(
591
+ "parent"
592
+ ).get("link")
593
+ origin_by_child[joint.find("child").get("link")] = joint.find(
594
+ "origin"
595
+ )
596
+
597
+ link_world_origins = {}
598
+
599
+ def get_link_world_origin(link_name: str) -> ET.Element | None:
600
+ if link_name in link_world_origins:
601
+ return link_world_origins[link_name]
602
+ parent_link = parent_by_child.get(link_name)
603
+ if parent_link is None:
604
+ link_world_origins[link_name] = None
605
+ else:
606
+ link_world_origins[link_name] = self._compose_origin(
607
+ get_link_world_origin(parent_link),
608
+ origin_by_child.get(link_name),
609
+ )
610
+ return link_world_origins[link_name]
611
+
612
  body_dict = {}
613
  for idx, link in enumerate(root.findall("link")):
614
  link_name = link.get("name", f"unnamed_link_{idx}")
 
632
  output_dir,
633
  f"visual_mesh_{idx}",
634
  material,
635
+ body_origin=get_link_world_origin(link_name),
636
  )
637
  if link.find("collision") is not None:
638
  self.add_geometry(
 
644
  output_dir,
645
  f"collision_mesh_{idx}",
646
  is_collision=True,
647
+ body_origin=get_link_world_origin(link_name),
648
  )
649
 
650
+ # Fixed-joint world transforms are baked into the mesh vertices above,
651
+ # so here we only rebuild the body hierarchy (all bodies stay at the
652
+ # identity frame), keeping the output identical across MuJoCo/Isaac Gym.
653
  for joint in root.findall("joint"):
 
 
 
 
 
654
  parent_link = joint.find("parent").get("link")
655
  child_link = joint.find("child").get("link")
 
656
  if parent_link not in body_dict or child_link not in body_dict:
657
  logger.warning(
658
  f"Parent or child link not found for joint: {joint.get('name')}"
 
663
  mujoco_worldbody.remove(child_body)
664
  parent_body = body_dict[parent_link]
665
  parent_body.append(child_body)
 
 
 
 
 
666
 
667
  tree = ET.ElementTree(mujoco_struct)
668
  ET.indent(tree, space=" ", level=0)
 
1091
 
1092
  urdf_paths = [
1093
  'outputs/EmbodiedGenData/demo_assets/banana/result/banana.urdf',
 
 
 
 
 
 
 
1094
  ]
1095
 
1096
  if target_asset_type == AssetType.MJCF:
 
1105
  elif target_asset_type == AssetType.USD:
1106
  output_files = [
1107
  'outputs/EmbodiedGenData/demo_assets/banana/usd/banana.usd',
 
 
 
 
 
 
 
1108
  ]
1109
  asset_converter = AssetConverterFactory.create(
1110
  target_type=AssetType.USD,
 
1140
 
1141
  # # Convert infinigen usdc to physics usdc
1142
  # converter = PhysicsUSDAdder()
1143
+ # usd_paths = [
1144
+ # "outputs/gen_rooms_v2/simple/House_seed0/usd/export_scene/export_scene.usdc",
1145
+ # ]
1146
  # with converter:
1147
+ # for usd_path in usd_paths:
1148
+ # output_path = usd_path.replace("/export_scene/", "/export_scene_physics/")
1149
+ # converter.convert(usd_path=usd_path, output_file=output_path)
1150
+
1151
+ # converter = URDFtoMJCFConverter()
1152
+ # urdf_paths = [
1153
+ # "outputs/gen_rooms_v2/simple/House_seed0/urdf/export_scene/scene.urdf",
1154
+
1155
+ # # "outputs/gen_rooms_v2/detail/House_seed0/urdf/export_scene/scene.urdf",
1156
+ # ]
1157
+ # for urdf_path in urdf_paths:
1158
+ # output_path = urdf_path.replace("/urdf/export_scene/", "/mjcf/export_scene/").replace(".urdf", ".xml")
1159
+ # os.makedirs(os.path.dirname(output_path), exist_ok=True)
1160
+ # converter.convert(urdf_path=urdf_path, mjcf_path=output_path)
embodied_gen/data/backproject_v3.py CHANGED
@@ -419,7 +419,7 @@ def parse_args():
419
  parser.add_argument(
420
  "--save_glb_path", type=str, default=None, help="Save glb path."
421
  )
422
- parser.add_argument("--n_max_faces", type=int, default=30000)
423
  args, unknown = parser.parse_known_args()
424
 
425
  return args
 
419
  parser.add_argument(
420
  "--save_glb_path", type=str, default=None, help="Save glb path."
421
  )
422
+ parser.add_argument("--n_max_faces", type=int, default=50000)
423
  args, unknown = parser.parse_known_args()
424
 
425
  return args
embodied_gen/data/differentiable_render.py CHANGED
@@ -80,7 +80,11 @@ def create_mp4_from_images(
80
  color = (255, 255, 255)
81
  position = (20, 25)
82
 
83
- with imageio.get_writer(output_path, fps=fps) as writer:
 
 
 
 
84
  for image in images:
85
  image = image.clip(min=0, max=1)
86
  image = (255.0 * image).astype(np.uint8)
@@ -147,6 +151,7 @@ class ImageRender(object):
147
  gen_viewnormal_mp4 (bool, optional): Generate MP4 of view-space normals. Defaults to False.
148
  gen_glonormal_mp4 (bool, optional): Generate MP4 of global-space normals. Defaults to False.
149
  no_index_file (bool, optional): Skip saving index file. Defaults to False.
 
150
  light_factor (float, optional): PBR light intensity multiplier. Defaults to 1.0.
151
 
152
  Example:
@@ -185,7 +190,9 @@ class ImageRender(object):
185
  gen_viewnormal_mp4: bool = False,
186
  gen_glonormal_mp4: bool = False,
187
  no_index_file: bool = False,
 
188
  light_factor: float = 1.0,
 
189
  ) -> None:
190
  camera = init_kal_camera(camera_params)
191
  self.camera = camera
@@ -218,7 +225,9 @@ class ImageRender(object):
218
  self.gen_color_mp4 = gen_color_mp4
219
  self.gen_viewnormal_mp4 = gen_viewnormal_mp4
220
  self.gen_glonormal_mp4 = gen_glonormal_mp4
 
221
  self.light_factor = light_factor
 
222
  self.no_index_file = no_index_file
223
 
224
  def render_mesh(
@@ -313,7 +322,7 @@ class ImageRender(object):
313
  create_mp4_from_images(
314
  rendered_normals,
315
  output_path=f"{output_dir}/normal.mp4",
316
- fps=15,
317
  prompt=prompt,
318
  )
319
  else:
@@ -325,9 +334,9 @@ class ImageRender(object):
325
  data_dict[RenderItems.GLOBAL_NORMAL.value] = render_paths
326
 
327
  if RenderItems.VIEW_NORMAL.value in self.render_items:
328
- assert (
329
- RenderItems.GLOBAL_NORMAL in self.render_items
330
- ), f"Must render global normal firstly, got render_items: {self.render_items}." # noqa
331
  rendered_view_normals = self.renderer.transform_normal(
332
  rendered_normals, self.mv, masks, to_view=True
333
  )
@@ -336,7 +345,7 @@ class ImageRender(object):
336
  create_mp4_from_images(
337
  rendered_view_normals,
338
  output_path=f"{output_dir}/view_normal.mp4",
339
- fps=15,
340
  prompt=prompt,
341
  )
342
  else:
@@ -388,7 +397,10 @@ class ImageRender(object):
388
  try:
389
  for idx, cam in enumerate(self.camera):
390
  image, albedo, diffuse, _ = render_pbr(
391
- mesh, cam, light_factor=self.light_factor
 
 
 
392
  )
393
  image = torch.cat([image[0], masks[idx]], axis=-1)
394
  images.append(image.detach().cpu().numpy())
@@ -409,14 +421,14 @@ class ImageRender(object):
409
  create_gif_from_images(
410
  images,
411
  output_path=f"{output_dir}/color.gif",
412
- fps=15,
413
  )
414
 
415
  if self.gen_color_mp4:
416
  create_mp4_from_images(
417
  images,
418
  output_path=f"{output_dir}/color.mp4",
419
- fps=15,
420
  prompt=prompt,
421
  )
422
 
@@ -507,6 +519,12 @@ def parse_args():
507
  default=1.0,
508
  help="Light factor for mesh PBR rendering (default: 1.)",
509
  )
 
 
 
 
 
 
510
  parser.add_argument(
511
  "--with_mtl",
512
  action="store_true",
@@ -544,6 +562,12 @@ def parse_args():
544
  default=None,
545
  help="Text prompts for the rendering.",
546
  )
 
 
 
 
 
 
547
 
548
  args, unknown = parser.parse_known_args()
549
 
@@ -607,7 +631,9 @@ def entrypoint(**kwargs) -> None:
607
  gen_color_mp4=args.gen_color_mp4,
608
  gen_viewnormal_mp4=args.gen_viewnormal_mp4,
609
  gen_glonormal_mp4=args.gen_glonormal_mp4,
 
610
  light_factor=args.pbr_light_factor,
 
611
  no_index_file=gen_video or args.no_index_file,
612
  )
613
  image_render.render_mesh(
 
80
  color = (255, 255, 255)
81
  position = (20, 25)
82
 
83
+ with imageio.get_writer(
84
+ output_path,
85
+ fps=fps,
86
+ macro_block_size=1,
87
+ ) as writer:
88
  for image in images:
89
  image = image.clip(min=0, max=1)
90
  image = (255.0 * image).astype(np.uint8)
 
151
  gen_viewnormal_mp4 (bool, optional): Generate MP4 of view-space normals. Defaults to False.
152
  gen_glonormal_mp4 (bool, optional): Generate MP4 of global-space normals. Defaults to False.
153
  no_index_file (bool, optional): Skip saving index file. Defaults to False.
154
+ video_fps (int, optional): FPS for generated videos. Defaults to 15.
155
  light_factor (float, optional): PBR light intensity multiplier. Defaults to 1.0.
156
 
157
  Example:
 
190
  gen_viewnormal_mp4: bool = False,
191
  gen_glonormal_mp4: bool = False,
192
  no_index_file: bool = False,
193
+ video_fps: int = 15,
194
  light_factor: float = 1.0,
195
+ metallic: bool = False,
196
  ) -> None:
197
  camera = init_kal_camera(camera_params)
198
  self.camera = camera
 
225
  self.gen_color_mp4 = gen_color_mp4
226
  self.gen_viewnormal_mp4 = gen_viewnormal_mp4
227
  self.gen_glonormal_mp4 = gen_glonormal_mp4
228
+ self.video_fps = video_fps
229
  self.light_factor = light_factor
230
+ self.metallic = metallic
231
  self.no_index_file = no_index_file
232
 
233
  def render_mesh(
 
322
  create_mp4_from_images(
323
  rendered_normals,
324
  output_path=f"{output_dir}/normal.mp4",
325
+ fps=self.video_fps,
326
  prompt=prompt,
327
  )
328
  else:
 
334
  data_dict[RenderItems.GLOBAL_NORMAL.value] = render_paths
335
 
336
  if RenderItems.VIEW_NORMAL.value in self.render_items:
337
+ assert RenderItems.GLOBAL_NORMAL in self.render_items, (
338
+ f"Must render global normal firstly, got render_items: {self.render_items}."
339
+ ) # noqa
340
  rendered_view_normals = self.renderer.transform_normal(
341
  rendered_normals, self.mv, masks, to_view=True
342
  )
 
345
  create_mp4_from_images(
346
  rendered_view_normals,
347
  output_path=f"{output_dir}/view_normal.mp4",
348
+ fps=self.video_fps,
349
  prompt=prompt,
350
  )
351
  else:
 
397
  try:
398
  for idx, cam in enumerate(self.camera):
399
  image, albedo, diffuse, _ = render_pbr(
400
+ mesh,
401
+ cam,
402
+ light_factor=self.light_factor,
403
+ metallic=self.metallic,
404
  )
405
  image = torch.cat([image[0], masks[idx]], axis=-1)
406
  images.append(image.detach().cpu().numpy())
 
421
  create_gif_from_images(
422
  images,
423
  output_path=f"{output_dir}/color.gif",
424
+ fps=self.video_fps,
425
  )
426
 
427
  if self.gen_color_mp4:
428
  create_mp4_from_images(
429
  images,
430
  output_path=f"{output_dir}/color.mp4",
431
+ fps=self.video_fps,
432
  prompt=prompt,
433
  )
434
 
 
519
  default=1.0,
520
  help="Light factor for mesh PBR rendering (default: 1.)",
521
  )
522
+ parser.add_argument(
523
+ "--pbr_metallic",
524
+ action=argparse.BooleanOptionalAction,
525
+ default=False,
526
+ help="Whether to keep metallic material properties for mesh PBR rendering (default: True).",
527
+ )
528
  parser.add_argument(
529
  "--with_mtl",
530
  action="store_true",
 
562
  default=None,
563
  help="Text prompts for the rendering.",
564
  )
565
+ parser.add_argument(
566
+ "--video_fps",
567
+ type=int,
568
+ default=15,
569
+ help="FPS for generated video outputs.",
570
+ )
571
 
572
  args, unknown = parser.parse_known_args()
573
 
 
631
  gen_color_mp4=args.gen_color_mp4,
632
  gen_viewnormal_mp4=args.gen_viewnormal_mp4,
633
  gen_glonormal_mp4=args.gen_glonormal_mp4,
634
+ video_fps=args.video_fps,
635
  light_factor=args.pbr_light_factor,
636
+ metallic=args.pbr_metallic,
637
  no_index_file=gen_video or args.no_index_file,
638
  )
639
  image_render.render_mesh(
embodied_gen/data/mesh_operator.py CHANGED
@@ -159,9 +159,10 @@ class MeshFixer(object):
159
  yaws.append(y)
160
  pitchs.append(p)
161
 
162
- yaws, pitchs = torch.tensor(yaws).to(vertices), torch.tensor(
163
- pitchs
164
- ).to(vertices)
 
165
  radius, fov = 2.0, torch.deg2rad(torch.tensor(40)).to(vertices)
166
  projection = utils3d.torch.perspective_from_fov_xy(fov, fov, 1, 3)
167
 
@@ -361,7 +362,7 @@ class MeshFixer(object):
361
 
362
  tqdm.write(f"Removed {(~mask).sum()} faces by mincut")
363
  else:
364
- tqdm.write(f"Removed 0 faces by mincut")
365
 
366
  # Fill small boundaries (holes)
367
  mesh = _meshfix.PyTMesh()
 
159
  yaws.append(y)
160
  pitchs.append(p)
161
 
162
+ yaws, pitchs = (
163
+ torch.tensor(yaws).to(vertices),
164
+ torch.tensor(pitchs).to(vertices),
165
+ )
166
  radius, fov = 2.0, torch.deg2rad(torch.tensor(40)).to(vertices)
167
  projection = utils3d.torch.perspective_from_fov_xy(fov, fov, 1, 3)
168
 
 
362
 
363
  tqdm.write(f"Removed {(~mask).sum()} faces by mincut")
364
  else:
365
+ tqdm.write("Removed 0 faces by mincut")
366
 
367
  # Fill small boundaries (holes)
368
  mesh = _meshfix.PyTMesh()
embodied_gen/data/utils.py CHANGED
@@ -21,6 +21,7 @@ import os
21
  import time
22
  import zipfile
23
  from contextlib import contextmanager
 
24
  from dataclasses import dataclass, field
25
  from shutil import rmtree
26
  from typing import List, Tuple, Union
@@ -441,6 +442,37 @@ def save_images(
441
  return save_paths
442
 
443
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
444
  def _current_lighting(
445
  azimuths: List[float],
446
  elevations: List[float],
@@ -471,29 +503,59 @@ def _current_lighting(
471
  return light_condition
472
 
473
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
474
  def render_pbr(
475
  mesh,
476
  camera,
477
  device="cuda",
478
  cxt=None,
479
- custom_materials=None,
480
  light_factor=1.0,
 
481
  ):
482
  if cxt is None:
483
  cxt = dr.RasterizeCudaContext()
484
 
485
- light_condition = _current_lighting(
486
- azimuths=[0, 90, 180, 270],
487
- elevations=[90, 60, 30, 20],
488
  light_factor=light_factor,
489
  device=device,
490
  )
 
491
  render_res = kal.render.easy_render.render_mesh(
492
  camera,
493
  mesh,
494
  lighting=light_condition,
495
  nvdiffrast_context=cxt,
496
- custom_materials=custom_materials,
497
  )
498
 
499
  image = render_res[kal.render.easy_render.RenderPass.render]
@@ -676,9 +738,9 @@ def import_kaolin_mesh(mesh_path: str, with_mtl: bool = False):
676
  mesh = kal.io.obj.import_mesh(mesh_path, with_materials=with_material)
677
  if with_mtl and mesh.materials and len(mesh.materials) > 0:
678
  material = kal.render.materials.PBRMaterial()
679
- assert (
680
- "map_Kd" in mesh.materials[0]
681
- ), "'map_Kd' not found in materials."
682
  material.diffuse_texture = mesh.materials[0]["map_Kd"] / 255.0
683
  mesh.materials = [material]
684
  elif mesh_path.endswith(".ply"):
@@ -727,6 +789,7 @@ def save_mesh_with_mtl(
727
  output_path: str,
728
  material_base=(250, 250, 250, 255),
729
  mesh_process: bool = True,
 
730
  ) -> trimesh.Trimesh:
731
  if isinstance(texture, np.ndarray):
732
  texture = Image.fromarray(texture)
@@ -742,6 +805,8 @@ def save_mesh_with_mtl(
742
  diffuse=material_base,
743
  ambient=material_base,
744
  specular=material_base,
 
 
745
  )
746
 
747
  dir_name = os.path.dirname(output_path)
 
21
  import time
22
  import zipfile
23
  from contextlib import contextmanager
24
+ from copy import deepcopy
25
  from dataclasses import dataclass, field
26
  from shutil import rmtree
27
  from typing import List, Tuple, Union
 
442
  return save_paths
443
 
444
 
445
+ def _disable_metallic_for_render(materials):
446
+ if materials is None:
447
+ return
448
+
449
+ for material in materials:
450
+ if hasattr(material, "metallic_texture"):
451
+ material.metallic_texture = None
452
+ if (
453
+ hasattr(material, "metallic_value")
454
+ and material.metallic_value is not None
455
+ ):
456
+ if torch.is_tensor(material.metallic_value):
457
+ material.metallic_value = torch.zeros_like(
458
+ material.metallic_value
459
+ )
460
+ else:
461
+ material.metallic_value = 0.0
462
+
463
+
464
+ def _build_render_materials(mesh, metallic: bool = False):
465
+ if metallic:
466
+ return None
467
+
468
+ if mesh.materials is None:
469
+ return None
470
+
471
+ render_materials = deepcopy(mesh.materials)
472
+ _disable_metallic_for_render(render_materials)
473
+ return render_materials
474
+
475
+
476
  def _current_lighting(
477
  azimuths: List[float],
478
  elevations: List[float],
 
503
  return light_condition
504
 
505
 
506
+ def _uniform_lighting(
507
+ light_factor: float = 1.0,
508
+ device: str = "cuda",
509
+ sharpness: float = 0.5,
510
+ num_lights: int = 1024,
511
+ ):
512
+ indices = torch.arange(num_lights, dtype=torch.float32, device=device)
513
+ golden_angle = math.pi * (3.0 - math.sqrt(5.0))
514
+ z = 1.0 - 2.0 * (indices + 0.5) / num_lights
515
+ radius = torch.sqrt(torch.clamp(1.0 - z * z, min=0.0))
516
+ theta = golden_angle * indices
517
+ directions = torch.stack(
518
+ [
519
+ radius * torch.cos(theta),
520
+ radius * torch.sin(theta),
521
+ z,
522
+ ],
523
+ dim=1,
524
+ )
525
+ directions = F.normalize(directions, dim=1)
526
+
527
+ amplitude = torch.ones_like(directions) * (light_factor / len(directions))
528
+ light_condition = kal.render.lighting.SgLightingParameters(
529
+ amplitude=amplitude,
530
+ direction=directions,
531
+ sharpness=sharpness,
532
+ ).to(device)
533
+
534
+ return light_condition
535
+
536
+
537
  def render_pbr(
538
  mesh,
539
  camera,
540
  device="cuda",
541
  cxt=None,
 
542
  light_factor=1.0,
543
+ metallic: bool = False,
544
  ):
545
  if cxt is None:
546
  cxt = dr.RasterizeCudaContext()
547
 
548
+ light_condition = _uniform_lighting(
 
 
549
  light_factor=light_factor,
550
  device=device,
551
  )
552
+ render_materials = _build_render_materials(mesh, metallic)
553
  render_res = kal.render.easy_render.render_mesh(
554
  camera,
555
  mesh,
556
  lighting=light_condition,
557
  nvdiffrast_context=cxt,
558
+ custom_materials=render_materials,
559
  )
560
 
561
  image = render_res[kal.render.easy_render.RenderPass.render]
 
738
  mesh = kal.io.obj.import_mesh(mesh_path, with_materials=with_material)
739
  if with_mtl and mesh.materials and len(mesh.materials) > 0:
740
  material = kal.render.materials.PBRMaterial()
741
+ assert "map_Kd" in mesh.materials[0], (
742
+ "'map_Kd' not found in materials."
743
+ )
744
  material.diffuse_texture = mesh.materials[0]["map_Kd"] / 255.0
745
  mesh.materials = [material]
746
  elif mesh_path.endswith(".ply"):
 
789
  output_path: str,
790
  material_base=(250, 250, 250, 255),
791
  mesh_process: bool = True,
792
+ glossiness: float = 250.0,
793
  ) -> trimesh.Trimesh:
794
  if isinstance(texture, np.ndarray):
795
  texture = Image.fromarray(texture)
 
805
  diffuse=material_base,
806
  ambient=material_base,
807
  specular=material_base,
808
+ # 250 gives a tight visible highlight similar to glossy plastic.
809
+ glossiness=glossiness,
810
  )
811
 
812
  dir_name = os.path.dirname(output_path)
embodied_gen/envs/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Simulation environments for EmbodiedGen."""
embodied_gen/envs/pick_embodiedgen.py CHANGED
@@ -289,7 +289,7 @@ class PickEmbodiedGen(BaseEnv):
289
  options: A dictionary of options for loading the scene.
290
  """
291
  all_objects = []
292
- logger.info(f"Loading EmbodiedGen assets...")
293
  for env_idx in range(self.num_envs):
294
  env_actors = load_assets_from_layout_file(
295
  self.scene,
 
289
  options: A dictionary of options for loading the scene.
290
  """
291
  all_objects = []
292
+ logger.info("Loading EmbodiedGen assets...")
293
  for env_idx in range(self.num_envs):
294
  env_actors = load_assets_from_layout_file(
295
  self.scene,
embodied_gen/models/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Model implementations for EmbodiedGen."""
embodied_gen/models/gs_model.py CHANGED
@@ -488,7 +488,7 @@ class GaussianOperator(GaussianBase):
488
  renders = renders[0]
489
  alphas = alphas[0].squeeze(-1)
490
 
491
- assert renders.shape[-1] == 4, f"Must render rgb, depth and alpha"
492
  rendered_rgb, rendered_depth = torch.split(renders, [3, 1], dim=-1)
493
 
494
  return RenderResult(
 
488
  renders = renders[0]
489
  alphas = alphas[0].squeeze(-1)
490
 
491
+ assert renders.shape[-1] == 4, "Must render rgb, depth and alpha"
492
  rendered_rgb, rendered_depth = torch.split(renders, [3, 1], dim=-1)
493
 
494
  return RenderResult(
embodied_gen/models/hunyuan3d.py ADDED
@@ -0,0 +1,957 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Project EmbodiedGen
2
+ #
3
+ # Copyright (c) 2025 Horizon Robotics. All Rights Reserved.
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
14
+ # implied. See the License for the specific language governing
15
+ # permissions and limitations under the License.
16
+
17
+ from __future__ import annotations
18
+
19
+ import base64
20
+ import hashlib
21
+ import hmac
22
+ import json
23
+ import os
24
+ import re
25
+ import socket
26
+ import time
27
+ import urllib.error
28
+ import urllib.request
29
+ from dataclasses import dataclass
30
+ from datetime import datetime, timezone
31
+ from glob import glob
32
+ from http.client import HTTPSConnection
33
+ from shutil import copy, copytree, rmtree
34
+ from typing import Optional, Tuple
35
+
36
+ import numpy as np
37
+ import trimesh
38
+ from PIL import Image
39
+ from embodied_gen.data.differentiable_render import (
40
+ entrypoint as render_pbr_video,
41
+ )
42
+ from embodied_gen.data.utils import delete_dir
43
+ from embodied_gen.utils.gpt_clients import GPT_CLIENT
44
+ from embodied_gen.utils.log import logger
45
+ from embodied_gen.utils.process_media import combine_images_to_grid
46
+ from embodied_gen.utils.tags import VERSION
47
+ from embodied_gen.validators.quality_checkers import (
48
+ BaseChecker,
49
+ ImageSegChecker,
50
+ )
51
+ from embodied_gen.validators.urdf_convertor import URDFGenerator
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class HunyuanConfig:
56
+ """Tencent Hunyuan3D Pro endpoint + timing.
57
+
58
+ Defaults match the validated probe in ``outputs/hunyuan3d_api_expert/``.
59
+ Only the Pro action set is supported.
60
+ """
61
+
62
+ host: str = "ai3d.tencentcloudapi.com"
63
+ service: str = "ai3d"
64
+ region: str = "ap-guangzhou"
65
+ version: str = "2025-05-13"
66
+ image_action: str = "SubmitHunyuanTo3DProJob"
67
+ query_action: str = "QueryHunyuanTo3DProJob"
68
+ result_format: str = "GLB"
69
+ texture_size: int = 2048
70
+ connect_timeout: float = 10.0
71
+ read_timeout: float = 60.0
72
+ poll_interval: float = 10.0
73
+ max_wait_seconds: float = 900.0
74
+ max_download_bytes: int = 512 * 1024 * 1024
75
+
76
+
77
+ def load_credentials() -> Tuple[str, str]:
78
+ """Read Tencent Cloud SecretId/SecretKey from environment.
79
+
80
+ Prefers ``TENCENT_SECRET_ID/KEY``; falls back to ``TENCENTCLOUD_*``.
81
+ Raises ``RuntimeError`` (credential-free message) when missing.
82
+ """
83
+ sid = os.environ.get("TENCENT_SECRET_ID") or os.environ.get(
84
+ "TENCENTCLOUD_SECRET_ID"
85
+ )
86
+ skey = os.environ.get("TENCENT_SECRET_KEY") or os.environ.get(
87
+ "TENCENTCLOUD_SECRET_KEY"
88
+ )
89
+ if not sid or not skey:
90
+ raise RuntimeError(
91
+ "HUNYUAN3D backend requires Tencent Cloud credentials. Set "
92
+ "TENCENT_SECRET_ID and TENCENT_SECRET_KEY (or TENCENTCLOUD_*) "
93
+ "in the environment, e.g. `source .secrets/hunyuan3d.env`."
94
+ )
95
+ return sid, skey
96
+
97
+
98
+ def _signed_headers(
99
+ payload: str,
100
+ action: str,
101
+ credentials: Tuple[str, str],
102
+ cfg: HunyuanConfig,
103
+ ) -> dict:
104
+ """Build fresh TC3-HMAC-SHA256 auth headers (re-built every request)."""
105
+ sid, skey = credentials
106
+ ts = int(time.time())
107
+ date = datetime.fromtimestamp(ts, tz=timezone.utc).strftime("%Y-%m-%d")
108
+ canon_headers = (
109
+ "content-type:application/json; charset=utf-8\n"
110
+ f"host:{cfg.host}\n"
111
+ f"x-tc-action:{action.lower()}\n"
112
+ )
113
+ signed = "content-type;host;x-tc-action"
114
+ canon_req = (
115
+ f"POST\n/\n\n{canon_headers}\n{signed}\n"
116
+ f"{hashlib.sha256(payload.encode()).hexdigest()}"
117
+ )
118
+ scope = f"{date}/{cfg.service}/tc3_request"
119
+ string_to_sign = (
120
+ f"TC3-HMAC-SHA256\n{ts}\n{scope}\n"
121
+ f"{hashlib.sha256(canon_req.encode()).hexdigest()}"
122
+ )
123
+
124
+ def _sign(key, msg):
125
+ return hmac.new(key, msg.encode(), hashlib.sha256).digest()
126
+
127
+ sd = _sign(("TC3" + skey).encode(), date)
128
+ ss = _sign(sd, cfg.service)
129
+ signing = _sign(ss, "tc3_request")
130
+ sig = hmac.new(
131
+ signing, string_to_sign.encode(), hashlib.sha256
132
+ ).hexdigest()
133
+ return {
134
+ "Authorization": (
135
+ f"TC3-HMAC-SHA256 Credential={sid}/{scope}, "
136
+ f"SignedHeaders={signed}, Signature={sig}"
137
+ ),
138
+ "Content-Type": "application/json; charset=utf-8",
139
+ "Host": cfg.host,
140
+ "X-TC-Action": action,
141
+ "X-TC-Timestamp": str(ts),
142
+ "X-TC-Version": cfg.version,
143
+ "X-TC-Region": cfg.region,
144
+ }
145
+
146
+
147
+ def _post_signed(
148
+ payload_obj: dict,
149
+ action: str,
150
+ credentials: Tuple[str, str],
151
+ cfg: HunyuanConfig,
152
+ ) -> dict:
153
+ """POST a Tencent Cloud TC3-signed JSON request and return ``Response``.
154
+
155
+ Routes through ``HTTPS_PROXY`` via CONNECT (``HTTPSConnection`` does not
156
+ honor the env var on its own). Never logs credentials, signed headers,
157
+ or the request payload (which carries base64 image data).
158
+ """
159
+ payload = json.dumps(
160
+ payload_obj, separators=(",", ":"), ensure_ascii=False
161
+ )
162
+ headers = _signed_headers(payload, action, credentials, cfg)
163
+ # ``http.client.HTTPSConnection`` does NOT auto-honor ``HTTPS_PROXY``
164
+ # (unlike ``urllib.request.urlopen``); read it explicitly and tunnel
165
+ # via CONNECT, otherwise direct connections to Tencent Cloud will be
166
+ # blocked by the corporate egress firewall.
167
+ proxy = os.environ.get("HTTPS_PROXY") or os.environ.get("HTTP_PROXY")
168
+ timeout = cfg.connect_timeout + cfg.read_timeout
169
+ if proxy:
170
+ from urllib.parse import urlparse
171
+
172
+ p = urlparse(proxy)
173
+ conn = HTTPSConnection(p.hostname, p.port or 80, timeout=timeout)
174
+ conn.set_tunnel(cfg.host, 443)
175
+ else:
176
+ conn = HTTPSConnection(cfg.host, timeout=timeout)
177
+ try:
178
+ conn.request("POST", "/", body=payload.encode(), headers=headers)
179
+ resp = conn.getresponse()
180
+ status, body = resp.status, resp.read().decode(errors="replace")
181
+ finally:
182
+ conn.close()
183
+
184
+ if not 200 <= status < 300:
185
+ raise RuntimeError(
186
+ f"Hunyuan3D {action} HTTP {status}; len={len(body)}."
187
+ )
188
+ try:
189
+ data = json.loads(body).get("Response", {})
190
+ except json.JSONDecodeError as exc:
191
+ raise RuntimeError(
192
+ f"Hunyuan3D {action} non-JSON (HTTP {status}): {exc}"
193
+ )
194
+ err = data.get("Error")
195
+ if err:
196
+ raise RuntimeError(
197
+ f"Hunyuan3D {action} Tencent error: "
198
+ f"Code={err.get('Code')} Message={err.get('Message')} "
199
+ f"RequestId={data.get('RequestId')}"
200
+ )
201
+ return data
202
+
203
+
204
+ def submit_pro_job(
205
+ image_path: Optional[str] = None,
206
+ credentials: Tuple[str, str] = None,
207
+ cfg: HunyuanConfig = None,
208
+ prompt: Optional[str] = None,
209
+ ) -> str:
210
+ """Submit a Hunyuan3D Pro job, return its ``JobId``.
211
+
212
+ Provide exactly one of ``image_path`` (image-to-3D, body field
213
+ ``ImageBase64``) or ``prompt`` (text-to-3D, body field ``Prompt``).
214
+ Tencent's ``SubmitHunyuanTo3DProJob`` action is shared between both
215
+ modes; only the body discriminator differs.
216
+ """
217
+ if (image_path is None) == (prompt is None):
218
+ raise ValueError(
219
+ "submit_pro_job requires exactly one of image_path or prompt."
220
+ )
221
+ if credentials is None or cfg is None:
222
+ raise ValueError("credentials and cfg are required.")
223
+
224
+ payload = {"ResultFormat": cfg.result_format, "EnablePBR": True}
225
+ if image_path is not None:
226
+ if not os.path.isfile(image_path):
227
+ raise FileNotFoundError(
228
+ f"Hunyuan3D input image missing: {image_path}"
229
+ )
230
+ with open(image_path, "rb") as fh:
231
+ payload["ImageBase64"] = base64.b64encode(fh.read()).decode()
232
+ mode = "image"
233
+ else:
234
+ payload["Prompt"] = prompt
235
+ mode = "text"
236
+
237
+ resp = _post_signed(payload, cfg.image_action, credentials, cfg)
238
+ job_id = resp.get("JobId")
239
+ if not job_id:
240
+ raise RuntimeError(
241
+ f"Hunyuan3D submit returned no JobId; "
242
+ f"RequestId={resp.get('RequestId')}."
243
+ )
244
+ logger.info(
245
+ "HUNYUAN3D submit OK (%s): JobId=%s RequestId=%s",
246
+ mode,
247
+ job_id,
248
+ resp.get("RequestId"),
249
+ )
250
+ return job_id
251
+
252
+
253
+ def wait_for_pro_job(
254
+ job_id: str,
255
+ credentials: Tuple[str, str],
256
+ cfg: HunyuanConfig,
257
+ ) -> dict:
258
+ """Poll the job until DONE; raise on FAIL/unknown/timeout."""
259
+ deadline = time.time() + cfg.max_wait_seconds
260
+ last_status = None
261
+ while True:
262
+ resp = _post_signed(
263
+ {"JobId": job_id}, cfg.query_action, credentials, cfg
264
+ )
265
+ status = resp.get("Status")
266
+ if status != last_status:
267
+ logger.info(
268
+ "HUNYUAN3D job %s status=%s RequestId=%s",
269
+ job_id,
270
+ status,
271
+ resp.get("RequestId"),
272
+ )
273
+ if last_status is None and status in ("WAIT", "RUN"):
274
+ logger.info(
275
+ "HUNYUAN3D Pro inference typically takes ~3 minutes; "
276
+ "polling every %ss.",
277
+ int(cfg.poll_interval),
278
+ )
279
+ last_status = status
280
+ if status == "DONE":
281
+ return resp
282
+ if status == "FAIL":
283
+ raise RuntimeError(
284
+ f"Hunyuan3D job {job_id} FAIL: "
285
+ f"code={resp.get('ErrorCode')} "
286
+ f"message={resp.get('ErrorMessage')} "
287
+ f"RequestId={resp.get('RequestId')}."
288
+ )
289
+ if status not in ("WAIT", "RUN"):
290
+ raise RuntimeError(
291
+ f"Hunyuan3D job {job_id} unknown status={status!r}; "
292
+ f"RequestId={resp.get('RequestId')}."
293
+ )
294
+ if time.time() >= deadline:
295
+ raise TimeoutError(
296
+ f"Hunyuan3D job {job_id} did not finish within "
297
+ f"{cfg.max_wait_seconds}s (last status={status})."
298
+ )
299
+ time.sleep(cfg.poll_interval)
300
+
301
+
302
+ def _download_url_to_path(url: str, dst: str, cfg: HunyuanConfig) -> int:
303
+ """Stream ``url`` to ``dst`` with size/timeout caps. Returns bytes written.
304
+
305
+ Logs only the host (signed URL paths carry short-lived auth tokens).
306
+ """
307
+ from urllib.parse import urlparse
308
+
309
+ os.makedirs(os.path.dirname(os.path.abspath(dst)), exist_ok=True)
310
+ logger.info(
311
+ "HUNYUAN3D downloading %s from host=%s",
312
+ os.path.basename(dst),
313
+ urlparse(url).hostname or "?",
314
+ )
315
+ total = 0
316
+ timeout = cfg.connect_timeout + cfg.read_timeout
317
+ try:
318
+ with urllib.request.urlopen(url, timeout=timeout) as resp:
319
+ if not 200 <= resp.status < 300:
320
+ raise RuntimeError(
321
+ f"Hunyuan3D download HTTP {resp.status} for "
322
+ f"{os.path.basename(dst)}."
323
+ )
324
+ with open(dst, "wb") as out:
325
+ while True:
326
+ chunk = resp.read(1024 * 1024)
327
+ if not chunk:
328
+ break
329
+ total += len(chunk)
330
+ if total > cfg.max_download_bytes:
331
+ raise RuntimeError(
332
+ f"Hunyuan3D download exceeded "
333
+ f"{cfg.max_download_bytes} bytes for "
334
+ f"{os.path.basename(dst)}."
335
+ )
336
+ out.write(chunk)
337
+ except (urllib.error.URLError, socket.timeout) as exc:
338
+ raise RuntimeError(
339
+ f"Hunyuan3D download failed for {os.path.basename(dst)}: {exc}"
340
+ )
341
+ return total
342
+
343
+
344
+ def acquire_pro_glb(
345
+ image_path: Optional[str] = None,
346
+ output_dir: str = None,
347
+ asset_name: str = None,
348
+ credentials: Tuple[str, str] = None,
349
+ cfg: HunyuanConfig = None,
350
+ prompt: Optional[str] = None,
351
+ ) -> str:
352
+ """End-to-end: submit + poll + download GLB into the output dir.
353
+
354
+ Provide exactly one of ``image_path`` or ``prompt`` (see
355
+ :func:`submit_pro_job` for the body-field difference).
356
+ """
357
+ if (
358
+ output_dir is None
359
+ or asset_name is None
360
+ or credentials is None
361
+ or cfg is None
362
+ ):
363
+ raise ValueError(
364
+ "output_dir, asset_name, credentials and cfg are required."
365
+ )
366
+ os.makedirs(output_dir, exist_ok=True)
367
+ glb_path = os.path.join(output_dir, f"{asset_name}.glb")
368
+
369
+ job_id = submit_pro_job(
370
+ image_path=image_path,
371
+ prompt=prompt,
372
+ credentials=credentials,
373
+ cfg=cfg,
374
+ )
375
+ resp = wait_for_pro_job(job_id, credentials, cfg)
376
+
377
+ files = resp.get("ResultFile3Ds") or []
378
+ glb_url = next(
379
+ (
380
+ f.get("Url")
381
+ for f in files
382
+ if (f.get("Type") or "").upper() == "GLB" and f.get("Url")
383
+ ),
384
+ None,
385
+ )
386
+ if not glb_url:
387
+ raise RuntimeError(
388
+ f"Hunyuan3D job {job_id} returned no GLB; "
389
+ f"RequestId={resp.get('RequestId')}."
390
+ )
391
+ _download_url_to_path(glb_url, glb_path, cfg)
392
+ return glb_path
393
+
394
+
395
+ def _texture_array(tex) -> Optional[np.ndarray]:
396
+ """Return RGB ndarray for a glTF texture, or None if absent/invalid."""
397
+ if tex is None or not hasattr(tex, "convert"):
398
+ return None
399
+ return np.asarray(tex.convert("RGB"))
400
+
401
+
402
+ def _save_rgb(arr: Optional[np.ndarray], dst: str, max_edge: int) -> bool:
403
+ """Save an RGB texture as PNG, capping the longest edge at ``max_edge``."""
404
+ if arr is None:
405
+ return False
406
+ img = Image.fromarray(arr)
407
+ longest = max(img.size)
408
+ if longest > max_edge:
409
+ scale = max_edge / float(longest)
410
+ img = img.resize(
411
+ (
412
+ max(1, int(img.size[0] * scale)),
413
+ max(1, int(img.size[1] * scale)),
414
+ ),
415
+ Image.LANCZOS,
416
+ )
417
+ img.save(dst)
418
+ return True
419
+
420
+
421
+ def _bake_scene_transform(
422
+ scene: trimesh.Scene,
423
+ ) -> Tuple[trimesh.Trimesh, np.ndarray, str]:
424
+ """Apply scene-graph transforms to mesh vertices; return one Trimesh."""
425
+ if len(scene.graph.nodes_geometry) != 1:
426
+ parts = []
427
+ for n in scene.graph.nodes_geometry:
428
+ xform, gname = scene.graph[n]
429
+ m = scene.geometry[gname].copy()
430
+ m.apply_transform(xform)
431
+ parts.append(m)
432
+ return trimesh.util.concatenate(parts), np.eye(4), "concatenated"
433
+ n = next(iter(scene.graph.nodes_geometry))
434
+ xform, gname = scene.graph[n]
435
+ mesh = scene.geometry[gname].copy()
436
+ mesh.apply_transform(xform)
437
+ return mesh, xform, gname
438
+
439
+
440
+ def export_glb_to_obj(
441
+ glb_path: str,
442
+ output_dir: str,
443
+ asset_name: str,
444
+ texture_size: int = 2048,
445
+ pre_align_rotation: Optional[np.ndarray] = None,
446
+ ) -> str:
447
+ """Convert a Hunyuan3D Pro GLB into the full-PBR OBJ + MTL + PBR PNGs.
448
+
449
+ Bakes the GLB scene transform, optionally applies ``pre_align_rotation``
450
+ to the vertex array (used by the text-to-3D path, whose endpoint emits
451
+ a frame rotated 90° around the up axis relative to the image-to-3D
452
+ endpoint), recenters to the bbox origin (matching SAM3D's convention
453
+ of putting the model origin at the geometric center), and writes a
454
+ Blender-compatible OBJ/MTL referencing 4 PBR PNGs (baseColor /
455
+ metallic / roughness / normal) plus a ``_pbr_material.json`` metadata
456
+ sidecar. The source GLB at ``glb_path`` is overwritten with the
457
+ aligned mesh so downstream steps can reuse it. Returns the OBJ path.
458
+ """
459
+ from trimesh.exchange.obj import export_obj
460
+
461
+ os.makedirs(output_dir, exist_ok=True)
462
+ obj_path = os.path.join(output_dir, f"{asset_name}.obj")
463
+ mtl_path = os.path.join(output_dir, f"{asset_name}.mtl")
464
+ json_path = os.path.join(output_dir, f"{asset_name}_pbr_material.json")
465
+
466
+ scene = trimesh.load(glb_path, force="scene", process=False)
467
+ mesh, baked_xform, geom_name = _bake_scene_transform(scene)
468
+ material = getattr(getattr(mesh, "visual", None), "material", None)
469
+
470
+ # Align to SAM3D convention: optional pre-rotation (text-to-3D needs a
471
+ # -90° around the up axis to share the image-to-3D frame) + recenter to
472
+ # the bbox origin. Overwrite the source GLB so downstream steps can
473
+ # reuse the aligned full-PBR mesh without an extra load/export pass.
474
+ V = np.asarray(mesh.vertices, dtype=np.float32)
475
+ if pre_align_rotation is not None:
476
+ V = V @ np.asarray(pre_align_rotation, dtype=np.float32)
477
+ bbox_center = (V.min(axis=0) + V.max(axis=0)) * 0.5
478
+ mesh.vertices = V - bbox_center
479
+ mesh.export(glb_path)
480
+
481
+ raw_name = getattr(material, "name", None) or f"{asset_name}_material"
482
+ material_name = re.sub(r"[^A-Za-z0-9_.-]+", "_", raw_name).strip("._")
483
+ if not material_name:
484
+ material_name = f"{asset_name}_material"
485
+
486
+ # Write OBJ (rewrite usemtl so it points at our material, after mtllib).
487
+ obj_text = export_obj(
488
+ mesh,
489
+ include_normals=True,
490
+ include_color=True,
491
+ include_texture=True,
492
+ return_texture=False,
493
+ write_texture=False,
494
+ mtl_name=os.path.basename(mtl_path),
495
+ header=(
496
+ "Exported from Hunyuan3D Pro GLB; "
497
+ "scene transform baked, recentered to bbox origin"
498
+ ),
499
+ )
500
+ obj_text = re.sub(
501
+ r"^usemtl\s+.+$",
502
+ f"usemtl {material_name}",
503
+ obj_text,
504
+ flags=re.MULTILINE,
505
+ )
506
+ mtllib_line = f"mtllib {os.path.basename(mtl_path)}\n"
507
+ if f"usemtl {material_name}" not in obj_text:
508
+ obj_text = obj_text.replace(
509
+ mtllib_line, f"{mtllib_line}usemtl {material_name}\n", 1
510
+ )
511
+ with open(obj_path, "w", encoding="utf-8") as fh:
512
+ fh.write(obj_text)
513
+
514
+ # PBR textures. metallicRoughnessTexture: G=roughness, B=metallic.
515
+ base_arr = _texture_array(getattr(material, "baseColorTexture", None))
516
+ mr_arr = _texture_array(
517
+ getattr(material, "metallicRoughnessTexture", None)
518
+ )
519
+ normal_arr = _texture_array(getattr(material, "normalTexture", None))
520
+ files = {
521
+ "baseColor": f"{asset_name}_baseColor.png",
522
+ "metallic": f"{asset_name}_metallic.png",
523
+ "roughness": f"{asset_name}_roughness.png",
524
+ "normal": f"{asset_name}_normal.png",
525
+ }
526
+ metallic_arr = (
527
+ np.stack([mr_arr[:, :, 2]] * 3, axis=-1)
528
+ if mr_arr is not None
529
+ else None
530
+ )
531
+ roughness_arr = (
532
+ np.stack([mr_arr[:, :, 1]] * 3, axis=-1)
533
+ if mr_arr is not None
534
+ else None
535
+ )
536
+ saved = {
537
+ "baseColor": _save_rgb(
538
+ base_arr,
539
+ os.path.join(output_dir, files["baseColor"]),
540
+ texture_size,
541
+ ),
542
+ "metallic": _save_rgb(
543
+ metallic_arr,
544
+ os.path.join(output_dir, files["metallic"]),
545
+ texture_size,
546
+ ),
547
+ "roughness": _save_rgb(
548
+ roughness_arr,
549
+ os.path.join(output_dir, files["roughness"]),
550
+ texture_size,
551
+ ),
552
+ "normal": _save_rgb(
553
+ normal_arr,
554
+ os.path.join(output_dir, files["normal"]),
555
+ texture_size,
556
+ ),
557
+ }
558
+
559
+ def _factor(attr: str, default: float = 1.0) -> float:
560
+ v = getattr(material, attr, default)
561
+ return default if v is None else float(v)
562
+
563
+ bc = getattr(material, "baseColorFactor", None)
564
+ if bc is None:
565
+ base_factor = [1.0, 1.0, 1.0, 1.0]
566
+ else:
567
+ arr = np.asarray(bc, dtype=float).reshape(-1)
568
+ if arr.max(initial=1.0) > 1.0:
569
+ arr = arr / 255.0
570
+ base_factor = [float(arr[0]), float(arr[1]), float(arr[2])]
571
+ base_factor.append(float(arr[3]) if len(arr) >= 4 else 1.0)
572
+ metallic_factor = _factor("metallicFactor", 1.0)
573
+ roughness_factor = _factor("roughnessFactor", 1.0)
574
+
575
+ ns = max(1.0, min(1000.0, (1.0 - roughness_factor) * 1000.0))
576
+ lines = [
577
+ "# Exported from Hunyuan3D Pro GLB",
578
+ "# PBR note: glTF metallicRoughnessTexture stores roughness in G "
579
+ "and metallic in B.",
580
+ f"newmtl {material_name}",
581
+ f"Ka {base_factor[0]:.8g} {base_factor[1]:.8g} {base_factor[2]:.8g}",
582
+ f"Kd {base_factor[0]:.8g} {base_factor[1]:.8g} {base_factor[2]:.8g}",
583
+ "Ks 0 0 0",
584
+ f"Ns {ns:.8g}",
585
+ f"d {base_factor[3]:.8g}",
586
+ "illum 2",
587
+ f"Pm {metallic_factor:.8g}",
588
+ f"Pr {roughness_factor:.8g}",
589
+ ]
590
+ if saved["baseColor"]:
591
+ lines.append(f"map_Kd {files['baseColor']}")
592
+ if saved["normal"]:
593
+ lines.append(f"norm {files['normal']}")
594
+ lines.append(f"bump {files['normal']}")
595
+ if saved["metallic"]:
596
+ lines.append(f"map_Pm {files['metallic']}")
597
+ if saved["roughness"]:
598
+ lines.append(f"map_Pr {files['roughness']}")
599
+ with open(mtl_path, "w", encoding="utf-8") as fh:
600
+ fh.write("\n".join(lines) + "\n")
601
+
602
+ metadata = {
603
+ "source": glb_path,
604
+ "obj": os.path.basename(obj_path),
605
+ "mtl": os.path.basename(mtl_path),
606
+ "material": material_name,
607
+ "geometry": geom_name,
608
+ "alignment": "recenter_to_bbox_origin",
609
+ "bakedTransform": np.asarray(baked_xform).tolist(),
610
+ "sourceSceneBounds": np.asarray(scene.bounds).tolist(),
611
+ "exportedObjBounds": np.asarray(mesh.bounds).tolist(),
612
+ "baseColorFactor": base_factor,
613
+ "metallicFactor": metallic_factor,
614
+ "roughnessFactor": roughness_factor,
615
+ "textureMaxEdge": texture_size,
616
+ "textures": {k: (files[k] if saved[k] else None) for k in files},
617
+ }
618
+ with open(json_path, "w", encoding="utf-8") as fh:
619
+ fh.write(json.dumps(metadata, indent=2) + "\n")
620
+
621
+ return obj_path
622
+
623
+
624
+ def _ship_scaled_pbr_artefacts(
625
+ aligned_glb: str,
626
+ urdf_path: str,
627
+ output_root: str,
628
+ final_mesh_dir: str,
629
+ asset_name: str,
630
+ ) -> None:
631
+ """Write scaled OBJ companions + GLB with full PBR into ``final_mesh_dir``.
632
+
633
+ URDFGen's trimesh roundtrip drops Hunyuan's metallic/roughness/normal
634
+ maps; we restore PBR fidelity by:
635
+
636
+ 1. Inferring the scale factor URDFGen applied by comparing the
637
+ scaled OBJ's extent with the aligned source GLB's extent (the
638
+ URDF ``<scale>`` element stores ``real_height`` instead, which is
639
+ a different quantity).
640
+ 2. Loading the aligned full-PBR source GLB, scaling it, and writing
641
+ it next to URDFGen's OBJ so both share the same scale.
642
+ 3. Copying the 4 PBR PNGs from ``output_root`` into the mesh dir.
643
+ 4. Patching URDFGen's ``material.mtl`` so Phong rendering has a
644
+ visible specular highlight and PBR-aware OBJ importers pick up
645
+ ``map_Pm`` / ``map_Pr`` / ``norm`` / ``bump`` references.
646
+ """
647
+ # Derive the actual scale factor from URDFGen's scaled OBJ rather than
648
+ # the URDF ``<scale>`` element (which stores real_height — a midpoint
649
+ # value distinct from the geometric scaling factor URDFGen applied).
650
+ urdfgen_obj = trimesh.load(
651
+ os.path.join(final_mesh_dir, f"{asset_name}.obj"),
652
+ force="mesh",
653
+ process=False,
654
+ )
655
+ target_max = float(urdfgen_obj.extents.max())
656
+
657
+ scene = trimesh.load(aligned_glb, force="scene", process=False)
658
+ mesh, _, _ = _bake_scene_transform(scene)
659
+ V = np.asarray(mesh.vertices, dtype=np.float32)
660
+ src_max = float((V.max(axis=0) - V.min(axis=0)).max())
661
+ scale = target_max / src_max if src_max > 1e-9 else 1.0
662
+ mesh.vertices = V * scale
663
+ mesh.export(os.path.join(final_mesh_dir, f"{asset_name}.glb"))
664
+
665
+ pbr_pngs = {
666
+ "metallic": f"{asset_name}_metallic.png",
667
+ "roughness": f"{asset_name}_roughness.png",
668
+ "normal": f"{asset_name}_normal.png",
669
+ }
670
+ base_color_png = f"{asset_name}_baseColor.png"
671
+ pbr_json = f"{asset_name}_pbr_material.json"
672
+ for fname in (
673
+ base_color_png,
674
+ pbr_pngs["metallic"],
675
+ pbr_pngs["roughness"],
676
+ pbr_pngs["normal"],
677
+ pbr_json,
678
+ ):
679
+ src = os.path.join(output_root, fname)
680
+ if os.path.exists(src):
681
+ copy(src, os.path.join(final_mesh_dir, fname))
682
+
683
+ mtl_path = os.path.join(final_mesh_dir, "material.mtl")
684
+ if os.path.exists(mtl_path):
685
+ with open(mtl_path) as fh:
686
+ mtl_text = fh.read()
687
+ # trimesh's OBJ exporter writes ``Ks 0 0 0`` + ``Ns 1`` which makes
688
+ # Blender's OBJ Phong path render the surface as flat matte. Bump
689
+ # specular and shininess so the OBJ has visible highlights matching
690
+ # the PBR GLB, then append the PBR texture map references that
691
+ # PBR-aware OBJ importers (Blender 3.6+, others) will pick up.
692
+ mtl_text = re.sub(
693
+ r"^Ks\s.+$", "Ks 0.5 0.5 0.5", mtl_text, flags=re.MULTILINE
694
+ )
695
+ mtl_text = re.sub(r"^Ns\s.+$", "Ns 250", mtl_text, flags=re.MULTILINE)
696
+ if not re.search(r"^illum\s", mtl_text, re.MULTILINE):
697
+ mtl_text = mtl_text.rstrip() + "\nillum 2\n"
698
+ extras = []
699
+ if os.path.exists(os.path.join(final_mesh_dir, pbr_pngs["metallic"])):
700
+ extras.append(f"map_Pm {pbr_pngs['metallic']}")
701
+ if os.path.exists(os.path.join(final_mesh_dir, pbr_pngs["roughness"])):
702
+ extras.append(f"map_Pr {pbr_pngs['roughness']}")
703
+ if os.path.exists(os.path.join(final_mesh_dir, pbr_pngs["normal"])):
704
+ extras.append(f"norm {pbr_pngs['normal']}")
705
+ extras.append(f"bump {pbr_pngs['normal']}")
706
+ if extras and not any(
707
+ line in mtl_text for line in ("map_Pm", "map_Pr", "norm ")
708
+ ):
709
+ mtl_text = mtl_text.rstrip() + "\n" + "\n".join(extras) + "\n"
710
+ with open(mtl_path, "w") as fh:
711
+ fh.write(mtl_text)
712
+
713
+
714
+ def _build_asset_attrs(args, idx: int) -> dict:
715
+ """Build the URDF asset_attrs dict from CLI args."""
716
+ attrs = {"version": args.version or VERSION}
717
+ if args.height_range:
718
+ lo, hi = map(float, args.height_range.split("-"))
719
+ attrs["min_height"], attrs["max_height"] = lo, hi
720
+ if args.mass_range:
721
+ lo, hi = map(float, args.mass_range.split("-"))
722
+ attrs["min_mass"], attrs["max_mass"] = lo, hi
723
+ if isinstance(args.asset_type, list) and args.asset_type[idx]:
724
+ attrs["category"] = args.asset_type[idx]
725
+ return attrs
726
+
727
+
728
+ def _render_color_video(
729
+ obj_path: str, work_dir: str, filename: str
730
+ ) -> Optional[str]:
731
+ """Render a turntable color mp4 via the shared kaolin renderer.
732
+
733
+ Returns the produced mp4 path, or ``None`` on failure (caller logs).
734
+ """
735
+ try:
736
+ # differentiable_render hardcodes mp4 fps=15; 90 frames -> 6s,
737
+ # matching SAM3D/TRELLIS gs_mesh.mp4 duration.
738
+ render_pbr_video(
739
+ mesh_path=obj_path,
740
+ output_root=work_dir,
741
+ uuid=[filename],
742
+ num_images=90,
743
+ elevation=[20.0],
744
+ distance=5.0,
745
+ fov=30.0,
746
+ with_mtl=True,
747
+ gen_color_mp4=True,
748
+ no_index_file=True,
749
+ )
750
+ mp4 = os.path.join(work_dir, filename, "color.mp4")
751
+ return mp4 if os.path.exists(mp4) else None
752
+ except Exception as exc: # pragma: no cover - rendering is optional
753
+ logger.warning(f"HUNYUAN3D video render failed: {exc}")
754
+ return None
755
+
756
+
757
+ def _process_glb(
758
+ args,
759
+ idx: int,
760
+ output_root: str,
761
+ filename: str,
762
+ cfg: HunyuanConfig,
763
+ checkers: list,
764
+ log_label: str,
765
+ seg_input_pair: Optional[Tuple[str, str]] = None,
766
+ pre_align_rotation: Optional[np.ndarray] = None,
767
+ ) -> str:
768
+ """GLB-to-result post-processing shared by image and text paths.
769
+
770
+ Expects an aligned full-PBR GLB at ``{output_root}/{filename}.glb``.
771
+ Runs ``export_glb_to_obj`` → video render → URDFGen → PBR fidelity
772
+ fixup → single-arg quality checks (skipped when ``checkers`` is empty)
773
+ → ``result/`` organization. ``seg_input_pair`` lets the image path
774
+ feed raw/cond images to ``ImageSegChecker``; text path passes ``None``.
775
+ ``pre_align_rotation`` (3x3) is folded into the single mesh transform
776
+ inside ``export_glb_to_obj``, avoiding a separate load/export pass.
777
+ Returns the result dir path.
778
+ """
779
+ export_glb_to_obj(
780
+ glb_path=os.path.join(output_root, f"{filename}.glb"),
781
+ output_dir=output_root,
782
+ asset_name=filename,
783
+ texture_size=cfg.texture_size,
784
+ pre_align_rotation=pre_align_rotation,
785
+ )
786
+ mesh_obj_path = os.path.join(output_root, f"{filename}.obj")
787
+
788
+ video_path = _render_color_video(
789
+ mesh_obj_path, os.path.join(output_root, "_video"), filename
790
+ )
791
+
792
+ urdf_convertor = URDFGenerator(
793
+ GPT_CLIENT,
794
+ render_view_num=4,
795
+ decompose_convex=not args.disable_decompose_convex,
796
+ )
797
+ urdf_root = f"{output_root}/URDF_{filename}"
798
+ urdf_path = urdf_convertor(
799
+ mesh_path=mesh_obj_path,
800
+ output_root=urdf_root,
801
+ **_build_asset_attrs(args, idx),
802
+ )
803
+
804
+ # Final mesh dir: keep URDFGen's scaled OBJ + collision, restore full
805
+ # PBR fidelity that URDFGen's simple trimesh roundtrip strips (rescaled
806
+ # source GLB + PBR map refs appended to material.mtl).
807
+ final_mesh_dir = f"{urdf_root}/{urdf_convertor.output_mesh_dir}"
808
+ _ship_scaled_pbr_artefacts(
809
+ aligned_glb=os.path.join(output_root, f"{filename}.glb"),
810
+ urdf_path=urdf_path,
811
+ output_root=output_root,
812
+ final_mesh_dir=final_mesh_dir,
813
+ asset_name=filename,
814
+ )
815
+
816
+ # Quality checks: only the single-arg (BaseChecker.validate) ones go
817
+ # here. Two-arg checkers like TextGenAlignChecker run in the caller.
818
+ if checkers:
819
+ render_image_paths = glob(
820
+ f"{urdf_root}/{urdf_convertor.output_render_dir}/image_color/*.png"
821
+ )
822
+ images_list = []
823
+ for ch in checkers:
824
+ if isinstance(ch, ImageSegChecker) and seg_input_pair is not None:
825
+ images_list.append(list(seg_input_pair))
826
+ else:
827
+ images_list.append(combine_images_to_grid(render_image_paths))
828
+ qa_results = BaseChecker.validate(checkers, images_list)
829
+ urdf_convertor.add_quality_tag(urdf_path, qa_results)
830
+
831
+ # Organize result/ (no gs.ply; video.mp4 included when render OK).
832
+ result_dir = f"{output_root}/result"
833
+ if os.path.exists(result_dir):
834
+ rmtree(result_dir, ignore_errors=True)
835
+ os.makedirs(result_dir, exist_ok=True)
836
+ copy(urdf_path, f"{result_dir}/{os.path.basename(urdf_path)}")
837
+ copytree(
838
+ f"{urdf_root}/{urdf_convertor.output_mesh_dir}",
839
+ f"{result_dir}/{urdf_convertor.output_mesh_dir}",
840
+ )
841
+ if video_path and os.path.exists(video_path):
842
+ copy(video_path, f"{result_dir}/video.mp4")
843
+
844
+ if not args.keep_intermediate:
845
+ delete_dir(output_root, keep_subs=["result"])
846
+
847
+ logger.info(f"Saved results for {log_label} in {result_dir}")
848
+ return result_dir
849
+
850
+
851
+ # Rotation that aligns a Hunyuan3D **text**-to-3D GLB with the **image**-to-3D
852
+ # frame. -90° around the file-coord up axis (Y), i.e. x' = z, z' = -x.
853
+ # Applied as a single multiplication inside ``export_glb_to_obj`` so the
854
+ # text path does not need a separate GLB load/save pass.
855
+ TEXT_TO_IMAGE_FRAME_ROTATION = np.array(
856
+ [[0.0, 0.0, -1.0], [0.0, 1.0, 0.0], [1.0, 0.0, 0.0]],
857
+ dtype=np.float32,
858
+ )
859
+
860
+
861
+ def _acquire_or_reuse_glb(
862
+ output_root: str,
863
+ filename: str,
864
+ cfg: HunyuanConfig,
865
+ hunyuan_credentials: Optional[Tuple[str, str]],
866
+ *,
867
+ image_path: Optional[str] = None,
868
+ prompt: Optional[str] = None,
869
+ ) -> None:
870
+ """Ensure ``{output_root}/{filename}.glb`` exists.
871
+
872
+ Reuses an existing GLB at that path (dev fixture short-circuit) or
873
+ calls :func:`acquire_pro_glb` with ``image_path`` or ``prompt``.
874
+ """
875
+ glb_path = os.path.join(output_root, f"{filename}.glb")
876
+ if os.path.exists(glb_path):
877
+ logger.info(
878
+ "HUNYUAN3D reusing existing GLB at %s; skipping Tencent API call.",
879
+ glb_path,
880
+ )
881
+ return
882
+ creds = hunyuan_credentials or load_credentials()
883
+ acquire_pro_glb(
884
+ image_path=image_path,
885
+ prompt=prompt,
886
+ output_dir=output_root,
887
+ asset_name=filename,
888
+ credentials=creds,
889
+ cfg=cfg,
890
+ )
891
+
892
+
893
+ def process_image(
894
+ args,
895
+ idx: int,
896
+ image_path: str,
897
+ output_root: str,
898
+ filename: str,
899
+ hunyuan_config: Optional[HunyuanConfig],
900
+ hunyuan_credentials: Optional[Tuple[str, str]],
901
+ checkers: list,
902
+ ) -> None:
903
+ """HUNYUAN3D image-to-3D entry: image → GLB → export → URDF → result/."""
904
+ cfg = hunyuan_config or HunyuanConfig()
905
+ _acquire_or_reuse_glb(
906
+ output_root, filename, cfg, hunyuan_credentials, image_path=image_path
907
+ )
908
+ _process_glb(
909
+ args=args,
910
+ idx=idx,
911
+ output_root=output_root,
912
+ filename=filename,
913
+ cfg=cfg,
914
+ checkers=checkers,
915
+ log_label=image_path,
916
+ seg_input_pair=(
917
+ f"{output_root}/{filename}_raw.png",
918
+ f"{output_root}/{filename}_cond.png",
919
+ ),
920
+ )
921
+
922
+
923
+ def process_prompt(
924
+ args,
925
+ idx: int,
926
+ prompt: str,
927
+ output_root: str,
928
+ filename: str,
929
+ hunyuan_config: Optional[HunyuanConfig],
930
+ hunyuan_credentials: Optional[Tuple[str, str]],
931
+ checkers: list,
932
+ ) -> None:
933
+ """HUNYUAN3D text-to-3D entry: prompt → GLB → export → URDF → result/.
934
+
935
+ Text path skips ``text-to-image`` entirely; ``checkers`` should only
936
+ contain single-arg (``BaseChecker.validate``-compatible) checkers.
937
+ Two-arg checkers like ``TextGenAlignChecker`` should be invoked by
938
+ the caller after this returns.
939
+ """
940
+ cfg = hunyuan_config or HunyuanConfig()
941
+ _acquire_or_reuse_glb(
942
+ output_root, filename, cfg, hunyuan_credentials, prompt=prompt
943
+ )
944
+ # Text endpoint sits 90° offset around the up axis vs the image
945
+ # endpoint; fold the alignment rotation into export_glb_to_obj's
946
+ # single mesh-transform pass to avoid a separate GLB roundtrip.
947
+ _process_glb(
948
+ args=args,
949
+ idx=idx,
950
+ output_root=output_root,
951
+ filename=filename,
952
+ cfg=cfg,
953
+ checkers=checkers,
954
+ log_label=f"prompt={prompt!r}",
955
+ seg_input_pair=None,
956
+ pre_align_rotation=TEXT_TO_IMAGE_FRAME_ROTATION,
957
+ )
embodied_gen/models/image_comm_model.py CHANGED
@@ -31,6 +31,9 @@ from diffusers.quantizers import PipelineQuantizationConfig
31
  from huggingface_hub import snapshot_download
32
  from PIL import Image
33
  from transformers import AutoModelForCausalLM, SiglipProcessor
 
 
 
34
 
35
  __all__ = [
36
  "build_hf_image_pipeline",
@@ -99,6 +102,7 @@ class SD35Loader(BasePipelineLoader):
99
  )
100
  pipe = pipe.to(self.device)
101
  pipe.enable_model_cpu_offload()
 
102
  pipe.enable_xformers_memory_efficient_attention()
103
  pipe.enable_attention_slicing()
104
  return pipe
@@ -230,6 +234,7 @@ class KolorsLoader(BasePipelineLoader):
230
  variant="fp16",
231
  ).to(self.device)
232
  pipe.enable_model_cpu_offload()
 
233
  pipe.enable_xformers_memory_efficient_attention()
234
  pipe.scheduler = DPMSolverMultistepScheduler.from_config(
235
  pipe.scheduler.config, use_karras_sigmas=True
@@ -268,6 +273,7 @@ class FluxLoader(BasePipelineLoader):
268
  "black-forest-labs/FLUX.1-schnell", torch_dtype=torch.bfloat16
269
  )
270
  pipe.enable_model_cpu_offload()
 
271
  pipe.enable_xformers_memory_efficient_attention()
272
  pipe.enable_attention_slicing()
273
  return pipe.to(self.device)
 
31
  from huggingface_hub import snapshot_download
32
  from PIL import Image
33
  from transformers import AutoModelForCausalLM, SiglipProcessor
34
+ from embodied_gen.utils.monkey_patch.xformers import (
35
+ disable_xformers_flash3_on_blackwell,
36
+ )
37
 
38
  __all__ = [
39
  "build_hf_image_pipeline",
 
102
  )
103
  pipe = pipe.to(self.device)
104
  pipe.enable_model_cpu_offload()
105
+ disable_xformers_flash3_on_blackwell()
106
  pipe.enable_xformers_memory_efficient_attention()
107
  pipe.enable_attention_slicing()
108
  return pipe
 
234
  variant="fp16",
235
  ).to(self.device)
236
  pipe.enable_model_cpu_offload()
237
+ disable_xformers_flash3_on_blackwell()
238
  pipe.enable_xformers_memory_efficient_attention()
239
  pipe.scheduler = DPMSolverMultistepScheduler.from_config(
240
  pipe.scheduler.config, use_karras_sigmas=True
 
273
  "black-forest-labs/FLUX.1-schnell", torch_dtype=torch.bfloat16
274
  )
275
  pipe.enable_model_cpu_offload()
276
+ disable_xformers_flash3_on_blackwell()
277
  pipe.enable_xformers_memory_efficient_attention()
278
  pipe.enable_attention_slicing()
279
  return pipe.to(self.device)
embodied_gen/models/sam3d.py CHANGED
@@ -53,13 +53,6 @@ class Sam3dInference:
53
  compile (bool): Whether to compile the model for faster inference.
54
  device (str): Device to run the model on (e.g., "cuda" or "cpu").
55
 
56
- Methods:
57
- merge_mask_to_rgba(image, mask):
58
- Merges a binary mask into the alpha channel of an RGB image.
59
-
60
- run(image, mask=None, seed=None, pointmap=None, use_stage1_distillation=False,
61
- use_stage2_distillation=False, stage1_inference_steps=25, stage2_inference_steps=25):
62
- Runs the inference pipeline and returns the output dictionary.
63
  """
64
 
65
  def __init__(
@@ -145,7 +138,7 @@ if __name__ == "__main__":
145
 
146
  start = time()
147
  output = pipeline.run(clean_image, seed=42)
148
- print(f"Running cost: {round(time()-start, 1)}")
149
 
150
  if torch.cuda.is_available():
151
  max_memory = torch.cuda.max_memory_allocated() / (1024**3)
 
53
  compile (bool): Whether to compile the model for faster inference.
54
  device (str): Device to run the model on (e.g., "cuda" or "cpu").
55
 
 
 
 
 
 
 
 
56
  """
57
 
58
  def __init__(
 
138
 
139
  start = time()
140
  output = pipeline.run(clean_image, seed=42)
141
+ print(f"Running cost: {round(time() - start, 1)}")
142
 
143
  if torch.cuda.is_available():
144
  max_memory = torch.cuda.max_memory_allocated() / (1024**3)
embodied_gen/models/segment_model.py CHANGED
@@ -236,7 +236,7 @@ class SAMPredictor(object):
236
  )
237
 
238
  labels = (
239
- torch.Tensor([int(l) for _, l in selected_points])
240
  .to(self.predictor.device)
241
  .unsqueeze(1)
242
  )
@@ -469,13 +469,11 @@ def get_segmented_image_by_agent(
469
  elif _is_valid_seg(image, seg_image_inv):
470
  final_image = seg_image_inv
471
  elif _is_valid_seg(image, seg_image_rbg):
472
- logger.warning(f"Failed to segment by `SAM`, retry with `rembg`.")
473
  final_image = seg_image_rbg
474
  else:
475
  if mode == "strict":
476
- raise RuntimeError(
477
- f"Failed to segment by `SAM` or `rembg`, abort."
478
- )
479
  logger.warning("Failed to segment by SAM or rembg, use raw image.")
480
  final_image = image.convert("RGBA")
481
 
 
236
  )
237
 
238
  labels = (
239
+ torch.Tensor([int(label) for _, label in selected_points])
240
  .to(self.predictor.device)
241
  .unsqueeze(1)
242
  )
 
469
  elif _is_valid_seg(image, seg_image_inv):
470
  final_image = seg_image_inv
471
  elif _is_valid_seg(image, seg_image_rbg):
472
+ logger.warning("Failed to segment by `SAM`, retry with `rembg`.")
473
  final_image = seg_image_rbg
474
  else:
475
  if mode == "strict":
476
+ raise RuntimeError("Failed to segment by `SAM` or `rembg`, abort.")
 
 
477
  logger.warning("Failed to segment by SAM or rembg, use raw image.")
478
  final_image = image.convert("RGBA")
479
 
embodied_gen/models/text_model.py CHANGED
@@ -40,6 +40,9 @@ from kolors.pipelines.pipeline_stable_diffusion_xl_chatglm_256_ipadapter import
40
  )
41
  from PIL import Image
42
  from transformers import CLIPImageProcessor, CLIPVisionModelWithProjection
 
 
 
43
 
44
  logging.basicConfig(level=logging.INFO)
45
  logger = logging.getLogger(__name__)
@@ -110,6 +113,7 @@ def build_text2img_ip_pipeline(
110
  ckpt_dir: str,
111
  ref_scale: float,
112
  device: str = "cuda",
 
113
  ) -> StableDiffusionXLPipelineIP:
114
  """Builds a Stable Diffusion XL pipeline with IP-Adapter for text-to-image generation.
115
 
@@ -117,6 +121,7 @@ def build_text2img_ip_pipeline(
117
  ckpt_dir (str): Directory containing model checkpoints.
118
  ref_scale (float): Reference scale for IP-Adapter.
119
  device (str, optional): Device for inference.
 
120
 
121
  Returns:
122
  StableDiffusionXLPipelineIP: Configured pipeline.
@@ -173,11 +178,12 @@ def build_text2img_ip_pipeline(
173
  )
174
  pipe.set_ip_adapter_scale([ref_scale])
175
 
176
- pipe = pipe.to(device)
177
- pipe.image_encoder = pipe.image_encoder.to(device)
178
- # pipe.enable_model_cpu_offload()
179
- # # pipe.enable_xformers_memory_efficient_attention()
180
- # pipe.enable_vae_slicing()
 
181
 
182
  return pipe
183
 
@@ -186,6 +192,7 @@ def build_text2img_pipeline(
186
  ckpt_dir: str,
187
  device: str = "cuda",
188
  download_ip_adapter: bool = False,
 
189
  ) -> StableDiffusionXLPipeline:
190
  """Builds a Stable Diffusion XL pipeline for text-to-image generation.
191
 
@@ -193,6 +200,7 @@ def build_text2img_pipeline(
193
  ckpt_dir (str): Directory containing model checkpoints.
194
  device (str, optional): Device for inference.
195
  download_ip_adapter: Whether to predownload IP-Adapter weights.
 
196
 
197
  Returns:
198
  StableDiffusionXLPipeline: Configured pipeline.
@@ -233,9 +241,13 @@ def build_text2img_pipeline(
233
  scheduler=scheduler,
234
  force_zeros_for_empty_prompt=False,
235
  )
236
- pipe = pipe.to(device)
237
- # pipe.enable_model_cpu_offload()
 
 
 
238
  # pipe.enable_xformers_memory_efficient_attention()
 
239
 
240
  return pipe
241
 
 
40
  )
41
  from PIL import Image
42
  from transformers import CLIPImageProcessor, CLIPVisionModelWithProjection
43
+ from embodied_gen.utils.monkey_patch.xformers import (
44
+ disable_xformers_flash3_on_blackwell,
45
+ )
46
 
47
  logging.basicConfig(level=logging.INFO)
48
  logger = logging.getLogger(__name__)
 
113
  ckpt_dir: str,
114
  ref_scale: float,
115
  device: str = "cuda",
116
+ enable_cpu_offload: bool = True,
117
  ) -> StableDiffusionXLPipelineIP:
118
  """Builds a Stable Diffusion XL pipeline with IP-Adapter for text-to-image generation.
119
 
 
121
  ckpt_dir (str): Directory containing model checkpoints.
122
  ref_scale (float): Reference scale for IP-Adapter.
123
  device (str, optional): Device for inference.
124
+ enable_cpu_offload: Whether to offload inactive modules to CPU.
125
 
126
  Returns:
127
  StableDiffusionXLPipelineIP: Configured pipeline.
 
178
  )
179
  pipe.set_ip_adapter_scale([ref_scale])
180
 
181
+ if enable_cpu_offload:
182
+ # pipe.enable_model_cpu_offload()
183
+ else:
184
+ pipe = pipe.to(device)
185
+ pipe.image_encoder = pipe.image_encoder.to(device)
186
+ pipe.enable_vae_slicing()
187
 
188
  return pipe
189
 
 
192
  ckpt_dir: str,
193
  device: str = "cuda",
194
  download_ip_adapter: bool = False,
195
+ enable_cpu_offload: bool = True,
196
  ) -> StableDiffusionXLPipeline:
197
  """Builds a Stable Diffusion XL pipeline for text-to-image generation.
198
 
 
200
  ckpt_dir (str): Directory containing model checkpoints.
201
  device (str, optional): Device for inference.
202
  download_ip_adapter: Whether to predownload IP-Adapter weights.
203
+ enable_cpu_offload: Whether to offload inactive modules to CPU.
204
 
205
  Returns:
206
  StableDiffusionXLPipeline: Configured pipeline.
 
241
  scheduler=scheduler,
242
  force_zeros_for_empty_prompt=False,
243
  )
244
+ if enable_cpu_offload:
245
+ # pipe.enable_model_cpu_offload()
246
+ else:
247
+ pipe = pipe.to(device)
248
+ disable_xformers_flash3_on_blackwell()
249
  # pipe.enable_xformers_memory_efficient_attention()
250
+ pipe.enable_vae_slicing()
251
 
252
  return pipe
253
 
embodied_gen/models/texture_model.py CHANGED
@@ -93,7 +93,7 @@ def build_texture_gen_pipe(
93
  """
94
 
95
  download_kolors_weights(f"{base_ckpt_dir}/Kolors")
96
- logger.info(f"Load Kolors weights...")
97
  tokenizer = ChatGLMTokenizer.from_pretrained(
98
  f"{base_ckpt_dir}/Kolors/text_encoder"
99
  )
@@ -101,11 +101,17 @@ def build_texture_gen_pipe(
101
  f"{base_ckpt_dir}/Kolors/text_encoder", torch_dtype=torch.float16
102
  ).half()
103
  vae = AutoencoderKL.from_pretrained(
104
- f"{base_ckpt_dir}/Kolors/vae", revision=None
105
- ).half()
 
 
 
106
  unet = UNet2DConditionModel.from_pretrained(
107
- f"{base_ckpt_dir}/Kolors/unet", revision=None
108
- ).half()
 
 
 
109
  scheduler = EulerDiscreteScheduler.from_pretrained(
110
  f"{base_ckpt_dir}/Kolors/scheduler"
111
  )
 
93
  """
94
 
95
  download_kolors_weights(f"{base_ckpt_dir}/Kolors")
96
+ logger.info("Load Kolors weights...")
97
  tokenizer = ChatGLMTokenizer.from_pretrained(
98
  f"{base_ckpt_dir}/Kolors/text_encoder"
99
  )
 
101
  f"{base_ckpt_dir}/Kolors/text_encoder", torch_dtype=torch.float16
102
  ).half()
103
  vae = AutoencoderKL.from_pretrained(
104
+ f"{base_ckpt_dir}/Kolors/vae",
105
+ torch_dtype=torch.float16,
106
+ variant="fp16",
107
+ use_safetensors=True,
108
+ )
109
  unet = UNet2DConditionModel.from_pretrained(
110
+ f"{base_ckpt_dir}/Kolors/unet",
111
+ torch_dtype=torch.float16,
112
+ variant="fp16",
113
+ use_safetensors=True,
114
+ )
115
  scheduler = EulerDiscreteScheduler.from_pretrained(
116
  f"{base_ckpt_dir}/Kolors/scheduler"
117
  )
embodied_gen/scripts/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Command-line workflows for EmbodiedGen."""
embodied_gen/scripts/affordance_annot/affordance_annot.py ADDED
@@ -0,0 +1,313 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Project EmbodiedGen
2
+ #
3
+ # Copyright (c) 2025 Horizon Robotics. All Rights Reserved.
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
14
+ # implied. See the License for the specific language governing
15
+ # permissions and limitations under the License.
16
+
17
+ import dataclasses
18
+ import os
19
+ import sys
20
+ import time
21
+ from dataclasses import dataclass, field
22
+
23
+ import tyro
24
+
25
+ if __package__ is None or __package__ == "":
26
+ sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../../..")))
27
+
28
+ from embodied_gen.utils.general import filter_warnings
29
+
30
+ filter_warnings()
31
+
32
+ from embodied_gen.scripts.affordance_annot.eval_grasps import (
33
+ EvalGraspsConfig,
34
+ run_eval,
35
+ )
36
+ from embodied_gen.scripts.affordance_annot.gen_grasp import (
37
+ GraspGenerator,
38
+ GraspPoseConfig,
39
+ )
40
+ from embodied_gen.scripts.affordance_annot.part_seg import (
41
+ PartSegConfig,
42
+ PartSegmenter,
43
+ )
44
+ from embodied_gen.scripts.affordance_annot.partsemantics_annot import (
45
+ PartSemanticsAnnotator,
46
+ PartSemanticsAnnotConfig,
47
+ )
48
+ from embodied_gen.utils.log import logger
49
+
50
+ __all__ = [
51
+ "AffordanceAnnotConfig",
52
+ "AffordanceAnnotator",
53
+ "run_affordance_annot",
54
+ "entrypoint",
55
+ ]
56
+
57
+
58
+ @dataclass
59
+ class AffordanceAnnotConfig:
60
+ urdf_paths: list[str] = field(default_factory=list)
61
+ output_dirs: list[str] = field(default_factory=list)
62
+ run_part_seg: bool = True
63
+ run_partsemantics_annot: bool = True
64
+ run_grasp: bool = True
65
+ run_grasp_eval: bool = True
66
+ part_seg: PartSegConfig = field(default_factory=PartSegConfig)
67
+ partsemantics: PartSemanticsAnnotConfig = field(
68
+ default_factory=PartSemanticsAnnotConfig
69
+ )
70
+ grasp: GraspPoseConfig = field(default_factory=GraspPoseConfig)
71
+ grasp_eval: EvalGraspsConfig = field(default_factory=EvalGraspsConfig)
72
+
73
+
74
+ class AffordanceAnnotator:
75
+ def __init__(self, cfg: AffordanceAnnotConfig):
76
+ self.validate_config(cfg)
77
+ self.cfg = cfg
78
+ self.part_segmenter: PartSegmenter | None = None
79
+ self.partsemantics_annotator: PartSemanticsAnnotator | None = None
80
+ self.grasp_generator: GraspGenerator | None = None
81
+ self.asset_timings: list[dict[str, float | str]] = []
82
+
83
+ def validate_config(self, cfg: AffordanceAnnotConfig) -> None:
84
+ if not cfg.urdf_paths:
85
+ raise ValueError("urdf_paths must be provided.")
86
+
87
+ if len(cfg.output_dirs) == 0:
88
+ cfg.output_dirs = [
89
+ os.path.join(os.path.dirname(path), "affordance")
90
+ for path in cfg.urdf_paths
91
+ ]
92
+
93
+ if len(cfg.urdf_paths) != len(cfg.output_dirs):
94
+ raise ValueError(
95
+ "urdf_paths and output_dirs must have the same length, "
96
+ f"got {len(cfg.urdf_paths)} and {len(cfg.output_dirs)}."
97
+ )
98
+
99
+ def sync_batch_stage_config(self, cfg):
100
+ updates = {"urdf_paths": list(self.cfg.urdf_paths)}
101
+ if "output_dirs" in {field.name for field in dataclasses.fields(cfg)}:
102
+ updates["output_dirs"] = list(self.cfg.output_dirs)
103
+ return dataclasses.replace(cfg, **updates)
104
+
105
+ def sync_single_stage_config(self, cfg, urdf_path: str, output_dir: str):
106
+ updates = {"urdf_paths": [urdf_path]}
107
+ if "output_dirs" in {field.name for field in dataclasses.fields(cfg)}:
108
+ updates["output_dirs"] = [output_dir]
109
+ return dataclasses.replace(cfg, **updates)
110
+
111
+ def process_part_seg(self, urdf_path: str, output_dir: str) -> bool:
112
+ if not self.cfg.run_part_seg:
113
+ logger.info("Skipping part segmentation stage.")
114
+ return True
115
+
116
+ return bool(self.get_part_segmenter().process(urdf_path, output_dir))
117
+
118
+ def process_partsemantics_annot(
119
+ self, urdf_path: str, output_dir: str
120
+ ) -> bool:
121
+ if not self.cfg.run_partsemantics_annot:
122
+ logger.info("Skipping part semantics annotation stage.")
123
+ return True
124
+
125
+ return bool(
126
+ self.get_partsemantics_annotator().process(urdf_path, output_dir)
127
+ )
128
+
129
+ def process_grasp(self, urdf_path: str, output_dir: str) -> bool:
130
+ if not self.cfg.run_grasp:
131
+ logger.info("Skipping grasp generation stage.")
132
+ return True
133
+
134
+ return bool(self.get_grasp_generator().process(urdf_path))
135
+
136
+ def process_grasp_eval(self, urdf_path: str, output_dir: str) -> bool:
137
+ if not self.cfg.run_grasp_eval:
138
+ logger.info("Skipping grasp evaluation stage.")
139
+ return True
140
+
141
+ cfg = self.sync_single_stage_config(
142
+ self.cfg.grasp_eval,
143
+ urdf_path,
144
+ output_dir,
145
+ )
146
+ run_eval(cfg)
147
+ return True
148
+
149
+ def get_part_segmenter(self) -> PartSegmenter:
150
+ if self.part_segmenter is None:
151
+ cfg = self.sync_batch_stage_config(self.cfg.part_seg)
152
+ self.part_segmenter = PartSegmenter(cfg)
153
+ return self.part_segmenter
154
+
155
+ def get_partsemantics_annotator(self) -> PartSemanticsAnnotator:
156
+ if self.partsemantics_annotator is None:
157
+ cfg = self.sync_batch_stage_config(self.cfg.partsemantics)
158
+ self.partsemantics_annotator = PartSemanticsAnnotator(cfg)
159
+ return self.partsemantics_annotator
160
+
161
+ def get_grasp_generator(self) -> GraspGenerator:
162
+ if self.grasp_generator is None:
163
+ cfg = self.sync_batch_stage_config(self.cfg.grasp)
164
+ self.grasp_generator = GraspGenerator(cfg)
165
+ return self.grasp_generator
166
+
167
+ def run_stage(
168
+ self,
169
+ stage_name: str,
170
+ stage_fn,
171
+ urdf_path: str,
172
+ ) -> bool:
173
+ try:
174
+ success = bool(stage_fn())
175
+ except Exception as exc:
176
+ logger.warning(
177
+ "{} failed for URDF {}: {}".format(
178
+ stage_name,
179
+ urdf_path,
180
+ exc,
181
+ )
182
+ )
183
+ return False
184
+
185
+ if not success:
186
+ logger.warning(
187
+ "{} returned failure for URDF {}".format(
188
+ stage_name,
189
+ urdf_path,
190
+ )
191
+ )
192
+ return success
193
+
194
+ def process_one_urdf(
195
+ self,
196
+ urdf_path: str,
197
+ output_dir: str,
198
+ index: int,
199
+ ) -> dict[str, bool]:
200
+ start_time = time.perf_counter()
201
+ logger.info(
202
+ "Starting affordance pipeline for URDF {}/{}: {}".format(
203
+ index,
204
+ len(self.cfg.urdf_paths),
205
+ urdf_path,
206
+ )
207
+ )
208
+ stages = [
209
+ (
210
+ "part segmentation",
211
+ lambda: self.process_part_seg(urdf_path, output_dir),
212
+ ),
213
+ (
214
+ "part semantics annotation",
215
+ lambda: self.process_partsemantics_annot(urdf_path, output_dir),
216
+ ),
217
+ (
218
+ "grasp generation",
219
+ lambda: self.process_grasp(urdf_path, output_dir),
220
+ ),
221
+ (
222
+ "grasp evaluation",
223
+ lambda: self.process_grasp_eval(urdf_path, output_dir),
224
+ ),
225
+ ]
226
+
227
+ results = {}
228
+ for stage_name, stage_fn in stages:
229
+ logger.info(f"Starting affordance stage: {stage_name}")
230
+ results[stage_name] = self.run_stage(
231
+ stage_name,
232
+ stage_fn,
233
+ urdf_path,
234
+ )
235
+ logger.info(
236
+ "Finished affordance stage: {} ({})".format(
237
+ stage_name,
238
+ "success" if results[stage_name] else "failed",
239
+ )
240
+ )
241
+
242
+ failed_stages = [
243
+ stage_name for stage_name, success in results.items() if not success
244
+ ]
245
+ if failed_stages:
246
+ logger.warning(
247
+ "Affordance pipeline finished with failed stages for {}: {}".format(
248
+ urdf_path,
249
+ ", ".join(failed_stages),
250
+ )
251
+ )
252
+ else:
253
+ logger.info(f"Affordance pipeline finished successfully: {urdf_path}")
254
+
255
+ elapsed_seconds = time.perf_counter() - start_time
256
+ self.asset_timings.append(
257
+ {
258
+ "urdf_path": urdf_path,
259
+ "output_dir": output_dir,
260
+ "elapsed_seconds": elapsed_seconds,
261
+ }
262
+ )
263
+ logger.info(
264
+ "Affordance pipeline time for {}: {:.2f}s".format(
265
+ urdf_path,
266
+ elapsed_seconds,
267
+ )
268
+ )
269
+ return results
270
+
271
+ def log_timing_summary(self) -> None:
272
+ if not self.asset_timings:
273
+ return
274
+
275
+ total_seconds = sum(
276
+ float(item["elapsed_seconds"]) for item in self.asset_timings
277
+ )
278
+ asset_count = len(self.asset_timings)
279
+ logger.info(
280
+ "Affordance pipeline total time: {:.2f}s for {} asset(s).".format(
281
+ total_seconds,
282
+ asset_count,
283
+ )
284
+ )
285
+ logger.info(
286
+ "Affordance pipeline average time per asset: {:.2f}s".format(
287
+ total_seconds / asset_count,
288
+ )
289
+ )
290
+
291
+ def process(self) -> list[dict[str, bool]]:
292
+ results = []
293
+ for idx, (urdf_path, output_dir) in enumerate(
294
+ zip(self.cfg.urdf_paths, self.cfg.output_dirs),
295
+ start=1,
296
+ ):
297
+ results.append(self.process_one_urdf(urdf_path, output_dir, idx))
298
+ self.log_timing_summary()
299
+ return results
300
+
301
+
302
+ def run_affordance_annot(cfg: AffordanceAnnotConfig) -> None:
303
+ annotator = AffordanceAnnotator(cfg)
304
+ annotator.process()
305
+
306
+
307
+ def entrypoint(*args, **kwargs) -> None:
308
+ cfg = tyro.cli(AffordanceAnnotConfig)
309
+ run_affordance_annot(cfg)
310
+
311
+
312
+ if __name__ == "__main__":
313
+ entrypoint()
embodied_gen/scripts/affordance_annot/eval_grasps.py ADDED
@@ -0,0 +1,1303 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Project EmbodiedGen
2
+ #
3
+ # Copyright (c) 2025 Horizon Robotics. All Rights Reserved.
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
14
+ # implied. See the License for the specific language governing
15
+ # permissions and limitations under the License.
16
+
17
+ import concurrent.futures
18
+ import dataclasses
19
+ import os
20
+ import threading
21
+ import time
22
+ import warnings
23
+ from collections import defaultdict
24
+ from dataclasses import dataclass, field
25
+
26
+ warnings.filterwarnings("ignore", category=UserWarning)
27
+
28
+ from typing import Literal
29
+
30
+ import imageio
31
+ import numpy as np
32
+ import sapien.core as sapien
33
+ import sapien.physx as physx
34
+ import torch
35
+ import tyro
36
+ from PIL import Image, ImageColor
37
+ from scipy.spatial.transform import Rotation as R
38
+ from tqdm import tqdm
39
+ from embodied_gen.data.utils import DiffrastRender
40
+ from embodied_gen.utils.geometry import MeshInfo
41
+ from embodied_gen.utils.io_utils import (
42
+ URDFFile,
43
+ load_json,
44
+ load_mesh_info,
45
+ write_json,
46
+ )
47
+ from embodied_gen.utils.log import logger
48
+
49
+ COLORMAP = list(set(ImageColor.colormap.values()))
50
+ COLOR_PALETTE = np.array(
51
+ [ImageColor.getrgb(c) for c in COLORMAP], dtype=np.uint8
52
+ )
53
+ MAX_NUM_WORKERS = 10
54
+ MAX_GRASPS_PER_WORKER_PROCESS = 8
55
+ SAPIEN_DEFAULT_STATIC_FRICTION = 0.3
56
+ SAPIEN_DEFAULT_DYNAMIC_FRICTION = 0.3
57
+ SAPIEN_DEFAULT_RESTITUTION = 0.1
58
+
59
+ _EVALUATOR_KEEPALIVE: list["EvalGrasps"] = []
60
+ _EVALUATOR_KEEPALIVE_LOCK = threading.Lock()
61
+
62
+ __all__ = [
63
+ "EvalGraspsConfig",
64
+ "GripperControlInfo",
65
+ "SapienSceneManager",
66
+ "EvalGrasps",
67
+ "run_eval",
68
+ "entrypoint",
69
+ ]
70
+
71
+
72
+ @dataclass
73
+ class EvalGraspsConfig:
74
+ urdf_paths: list[str] = field(default_factory=list)
75
+ gripper_name: str = "franka_panda"
76
+ gripper_urdf_path: str = (
77
+ f"embodied_gen/scripts/affordance_annot/{gripper_name}/gripper.urdf"
78
+ )
79
+ output_dirs: list[str] = field(default_factory=list)
80
+ sim_freq: int = 240
81
+ video_fps: int = 30
82
+ render_interval: int = 24
83
+ image_hw: tuple[int, int] = (512, 512)
84
+ num_cameras: int = 1
85
+ fovy_deg: float = 45.0
86
+ sim_backend: str = "cpu"
87
+ ray_tracing: bool = False
88
+ gripper_move_speed: float = 0.1
89
+ gripper_close_speed: float = 0.01
90
+ action_wait_seconds: float = 0.5
91
+ lift_height: float = 0.5
92
+ sweep_distance: float = 0.5
93
+ num_workers: int | None = None
94
+ max_grasps_per_worker_process: int = MAX_GRASPS_PER_WORKER_PROCESS
95
+ gripper_drive_stiffness: float = 2500.0
96
+ gripper_static_friction: float = SAPIEN_DEFAULT_STATIC_FRICTION
97
+ gripper_dynamic_friction: float = SAPIEN_DEFAULT_DYNAMIC_FRICTION
98
+ gripper_restitution: float = SAPIEN_DEFAULT_RESTITUTION
99
+ root_drive_stiffness: float = 1e6
100
+ root_drive_damping: float = 1e4
101
+ object_bottom_clearance: float = 0.1
102
+ hold_object_during_close: bool = True
103
+ slip_translation_threshold: float = 0.05
104
+ slip_rotation_threshold_deg: float = 30.0
105
+ check_close_slip: bool = True
106
+ debug_mode: bool = False
107
+ save_failed_grasps: bool = False
108
+
109
+
110
+ @dataclass
111
+ class GripperControlInfo:
112
+ open_qpos: float
113
+ close_qpos: float
114
+ drive_damping: float
115
+ force_limit: float
116
+
117
+
118
+ class GripperActionAgent:
119
+ def __init__(
120
+ self,
121
+ scene_manager: "SapienSceneManager",
122
+ gripper: physx.PhysxArticulation,
123
+ root_target: sapien.Entity,
124
+ root_drive: physx.PhysxDriveComponent,
125
+ ) -> None:
126
+ self.scene_manager = scene_manager
127
+ self.gripper = gripper
128
+ self.root_target = root_target
129
+ self.root_drive = root_drive
130
+
131
+ def set_action(self, action: np.ndarray | torch.Tensor) -> None:
132
+ if isinstance(action, torch.Tensor):
133
+ values = action.detach().cpu().numpy().reshape(-1)
134
+ else:
135
+ values = np.asarray(action).reshape(-1)
136
+ if values.size != 8:
137
+ raise ValueError(
138
+ f"Gripper action must have 8 values, got {values.size}."
139
+ )
140
+ self.root_target.set_pose(
141
+ sapien.Pose(
142
+ p=values[:3].astype(np.float64),
143
+ q=values[3:7].astype(np.float64),
144
+ )
145
+ )
146
+ self.scene_manager.set_gripper_target(
147
+ self.gripper,
148
+ float(values[7]),
149
+ )
150
+
151
+
152
+ class SapienSceneManager:
153
+ def __init__(
154
+ self, sim_freq: int, ray_tracing: bool, device: str = "cpu"
155
+ ) -> None:
156
+ self.sim_freq = sim_freq
157
+ self.ray_tracing = ray_tracing
158
+ self.device = device
159
+ self.renderer = sapien.SapienRenderer()
160
+ self.scene = self.setup_scene()
161
+ self.cameras: list[sapien.render.RenderCameraComponent] = []
162
+ self.actors: dict[str, sapien.pysapien.Entity] = {}
163
+
164
+ def setup_scene(self) -> sapien.Scene:
165
+ # Ray tracing settings
166
+ if self.ray_tracing:
167
+ sapien.render.set_camera_shader_dir("rt")
168
+ sapien.render.set_ray_tracing_samples_per_pixel(64)
169
+ sapien.render.set_ray_tracing_path_depth(10)
170
+ sapien.render.set_ray_tracing_denoiser("oidn")
171
+
172
+ scene = sapien.Scene()
173
+ scene.set_timestep(1 / self.sim_freq)
174
+
175
+ # Add lighting
176
+ scene.set_ambient_light([0.2, 0.2, 0.2])
177
+ scene.add_directional_light(
178
+ direction=[0, 1, -1],
179
+ color=[1.5, 1.45, 1.4],
180
+ shadow=True,
181
+ shadow_map_size=2048,
182
+ )
183
+ scene.add_directional_light(
184
+ direction=[0, -0.5, 1], color=[0.8, 0.8, 0.85], shadow=False
185
+ )
186
+ scene.add_directional_light(
187
+ direction=[0, -1, 1], color=[1.0, 1.0, 1.0], shadow=False
188
+ )
189
+
190
+ ground_material = self.renderer.create_material()
191
+ ground_material.base_color = [0.5, 0.5, 0.5, 1] # rgba, gray
192
+ ground_material.roughness = 0.7
193
+ ground_material.metallic = 0.0
194
+ scene.add_ground(0, render_material=ground_material)
195
+ return scene
196
+
197
+ def set_ground_color(self, rgba: list[float]) -> None:
198
+ """Update the default ground plane material color for this scene."""
199
+ for actor in self.scene.get_all_actors():
200
+ if actor.name != "ground":
201
+ continue
202
+ for component in actor.components:
203
+ render_shapes = getattr(component, "render_shapes", None)
204
+ if render_shapes is None:
205
+ continue
206
+ for render_shape in render_shapes:
207
+ render_shape.material.set_base_color(rgba)
208
+ return
209
+ raise ValueError("Ground actor not found in the scene.")
210
+
211
+ def create_camera(
212
+ self,
213
+ cam_name: str,
214
+ pose: sapien.Pose,
215
+ image_hw: tuple[int, int],
216
+ fovy_deg: float,
217
+ ) -> sapien.render.RenderCameraComponent:
218
+ cam_actor = self.scene.create_actor_builder().build_kinematic()
219
+ cam_actor.set_pose(pose)
220
+ camera = self.scene.add_mounted_camera(
221
+ name=cam_name,
222
+ mount=cam_actor,
223
+ pose=sapien.Pose(p=[0, 0, 0], q=[1, 0, 0, 0]),
224
+ width=image_hw[1],
225
+ height=image_hw[0],
226
+ fovy=np.deg2rad(fovy_deg),
227
+ near=0.01,
228
+ far=100,
229
+ )
230
+ self.cameras.append(camera)
231
+ return camera
232
+
233
+ def add_ring_cameras(
234
+ self,
235
+ num_cameras: int,
236
+ radius: float,
237
+ height: float,
238
+ target_pt: list[float],
239
+ image_hw: tuple[int, int],
240
+ fovy_deg: float,
241
+ ) -> list[sapien.render.RenderCameraComponent]:
242
+ angle_step = 2 * np.pi / num_cameras
243
+ world_up_vec = np.array([0.0, 0.0, 1.0])
244
+ target_pt = np.array(target_pt)
245
+
246
+ for i in range(num_cameras):
247
+ angle = i * angle_step
248
+ cam_x = target_pt[0] + radius * np.cos(angle)
249
+ cam_y = target_pt[1] + radius * np.sin(angle)
250
+ cam_z = height
251
+ eye_pos = [cam_x, cam_y, cam_z]
252
+
253
+ forward_vec = target_pt - eye_pos
254
+ forward_vec = forward_vec / np.linalg.norm(forward_vec)
255
+ temp_right_vec = np.cross(forward_vec, world_up_vec)
256
+
257
+ if np.linalg.norm(temp_right_vec) < 1e-6:
258
+ temp_right_vec = np.array([1.0, 0.0, 0.0])
259
+ if np.abs(np.dot(temp_right_vec, forward_vec)) > 0.99:
260
+ temp_right_vec = np.array([0.0, 1.0, 0.0])
261
+
262
+ right_vec = temp_right_vec / np.linalg.norm(temp_right_vec)
263
+ up_vec = np.cross(right_vec, forward_vec)
264
+ rotation_matrix = np.array([forward_vec, -right_vec, up_vec]).T
265
+
266
+ rot = R.from_matrix(rotation_matrix)
267
+ scipy_quat = rot.as_quat() # (x, y, z, w)
268
+ quat = [
269
+ scipy_quat[3],
270
+ scipy_quat[0],
271
+ scipy_quat[1],
272
+ scipy_quat[2],
273
+ ] # (w, x, y, z)
274
+
275
+ self.create_camera(
276
+ f"camera_{i}",
277
+ sapien.Pose(p=eye_pos, q=quat),
278
+ image_hw,
279
+ fovy_deg,
280
+ )
281
+
282
+ return self.cameras
283
+
284
+ def add_actor(
285
+ self,
286
+ mesh_info: MeshInfo,
287
+ position: list[float],
288
+ ) -> sapien.Entity:
289
+ builder = self.scene.create_actor_builder()
290
+ material = physx.PhysxMaterial(
291
+ static_friction=float(
292
+ np.clip(mesh_info.static_friction, 0.1, 0.7)
293
+ ),
294
+ dynamic_friction=float(
295
+ np.clip(mesh_info.dynamic_friction, 0.1, 0.6)
296
+ ),
297
+ restitution=0.05,
298
+ )
299
+ builder.set_physx_body_type("dynamic")
300
+ builder.add_multiple_convex_collisions_from_file(
301
+ mesh_info.collision_mesh_path,
302
+ scale=mesh_info.collision_mesh_scale,
303
+ pose=mesh_info.collision_local_pose,
304
+ material=material,
305
+ )
306
+ builder.add_visual_from_file(
307
+ mesh_info.visual_mesh_path,
308
+ scale=mesh_info.visual_mesh_scale,
309
+ pose=mesh_info.visual_local_pose,
310
+ )
311
+
312
+ builder.set_initial_pose(sapien.Pose(p=position))
313
+ actor = builder.build(name=mesh_info.actor_name)
314
+ if mesh_info.mass is not None and hasattr(
315
+ actor.components[1], "set_mass"
316
+ ):
317
+ actor.components[1].set_mass(mesh_info.mass)
318
+ return actor
319
+
320
+ def add_gripper(
321
+ self,
322
+ gripper_urdf_path: str,
323
+ root_pose: sapien.Pose,
324
+ open_qpos: float,
325
+ drive_stiffness: float,
326
+ drive_damping: float,
327
+ force_limit: float,
328
+ static_friction: float,
329
+ dynamic_friction: float,
330
+ restitution: float,
331
+ root_drive_stiffness: float,
332
+ root_drive_damping: float,
333
+ ) -> tuple[
334
+ physx.PhysxArticulation,
335
+ sapien.Entity,
336
+ physx.PhysxDriveComponent,
337
+ ]:
338
+ loader = self.scene.create_urdf_loader()
339
+ loader.fix_root_link = False
340
+ gripper = loader.load(gripper_urdf_path)
341
+ gripper.set_root_pose(root_pose)
342
+ self.set_articulation_contact_material(
343
+ gripper,
344
+ physx.PhysxMaterial(
345
+ static_friction,
346
+ dynamic_friction,
347
+ restitution,
348
+ ),
349
+ )
350
+ for link in gripper.get_links():
351
+ link.set_disable_gravity(True)
352
+
353
+ root_target = self.scene.create_actor_builder().build_kinematic(
354
+ name="gripper_root_target"
355
+ )
356
+ root_target.set_pose(root_pose)
357
+ root_drive = self.scene.create_drive(
358
+ root_target,
359
+ sapien.Pose(),
360
+ gripper.get_root(),
361
+ sapien.Pose(),
362
+ )
363
+ root_drive.set_limit_x(
364
+ 0.0, 0.0, root_drive_stiffness, root_drive_damping
365
+ )
366
+ root_drive.set_limit_y(
367
+ 0.0, 0.0, root_drive_stiffness, root_drive_damping
368
+ )
369
+ root_drive.set_limit_z(
370
+ 0.0, 0.0, root_drive_stiffness, root_drive_damping
371
+ )
372
+ root_drive.set_limit_twist(
373
+ 0.0, 0.0, root_drive_stiffness, root_drive_damping
374
+ )
375
+ root_drive.set_limit_cone(
376
+ 0.0, 0.0, root_drive_stiffness, root_drive_damping
377
+ )
378
+ root_drive.set_drive_property_x(
379
+ root_drive_stiffness, root_drive_damping
380
+ )
381
+ root_drive.set_drive_property_y(
382
+ root_drive_stiffness, root_drive_damping
383
+ )
384
+ root_drive.set_drive_property_z(
385
+ root_drive_stiffness, root_drive_damping
386
+ )
387
+ root_drive.set_drive_property_slerp(
388
+ root_drive_stiffness,
389
+ root_drive_damping,
390
+ )
391
+ root_drive.set_inv_mass_scales(0.0, 1.0)
392
+
393
+ qpos = []
394
+ for joint in gripper.get_active_joints():
395
+ joint.set_drive_properties(
396
+ drive_stiffness,
397
+ drive_damping,
398
+ force_limit,
399
+ )
400
+ joint.set_friction(0.1)
401
+ limit = np.asarray(joint.get_limits()).reshape(-1, 2)[0]
402
+ target = float(np.clip(open_qpos, limit[0], limit[1]))
403
+ joint.set_drive_target(target)
404
+ qpos.append(target)
405
+ if qpos:
406
+ gripper.set_qpos(qpos)
407
+ gripper.set_qvel(np.zeros(len(qpos), dtype=np.float32))
408
+ return gripper, root_target, root_drive
409
+
410
+ @staticmethod
411
+ def set_articulation_contact_material(
412
+ articulation: physx.PhysxArticulation,
413
+ material: physx.PhysxMaterial,
414
+ ) -> None:
415
+ for link in articulation.get_links():
416
+ for shape in link.get_collision_shapes():
417
+ shape.set_physical_material(material)
418
+
419
+ def set_gripper_target(
420
+ self,
421
+ gripper: physx.PhysxArticulation,
422
+ finger_qpos: float,
423
+ ) -> None:
424
+ for joint in gripper.get_active_joints():
425
+ limit = np.asarray(joint.get_limits()).reshape(-1, 2)[0]
426
+ target = float(np.clip(finger_qpos, limit[0], limit[1]))
427
+ joint.set_drive_target(target)
428
+
429
+ def render_images(
430
+ self,
431
+ camera,
432
+ render_keys: list[
433
+ Literal[
434
+ "Color",
435
+ "Segmentation",
436
+ "Normal",
437
+ "Mask",
438
+ "Depth",
439
+ "Foreground",
440
+ ]
441
+ ] = None,
442
+ ) -> dict[str, Image.Image]:
443
+ if render_keys is None:
444
+ render_keys = [
445
+ "Color",
446
+ "Segmentation",
447
+ "Normal",
448
+ "Mask",
449
+ "Depth",
450
+ "Foreground",
451
+ ]
452
+
453
+ results: dict[str, Image.Image] = {}
454
+ if "Color" in render_keys:
455
+ color = camera.get_picture("Color")
456
+ color_rgb = (np.clip(color[..., :3], 0, 1) * 255).astype(np.uint8)
457
+ results["Color"] = Image.fromarray(color_rgb)
458
+
459
+ if "Mask" in render_keys:
460
+ alpha = (np.clip(color[..., 3], 0, 1) * 255).astype(np.uint8)
461
+ results["Mask"] = Image.fromarray(alpha)
462
+
463
+ if "Segmentation" in render_keys:
464
+ seg_labels = camera.get_picture("Segmentation")
465
+ label0 = seg_labels[..., 0].astype(np.uint8)
466
+ seg_color = COLOR_PALETTE[label0]
467
+ results["Segmentation"] = Image.fromarray(seg_color)
468
+
469
+ if "Foreground" in render_keys:
470
+ seg_labels = camera.get_picture("Segmentation")
471
+ label0 = seg_labels[..., 0]
472
+ mask = np.where((label0 > 1), 255, 0).astype(np.uint8)
473
+ color = camera.get_picture("Color")
474
+ color_rgb = (np.clip(color[..., :3], 0, 1) * 255).astype(np.uint8)
475
+ foreground = np.concatenate([color_rgb, mask[..., None]], axis=-1)
476
+ results["Foreground"] = Image.fromarray(foreground)
477
+
478
+ if "Normal" in render_keys:
479
+ normal = camera.get_picture("Normal")[..., :3]
480
+ normal_img = (((normal + 1) / 2) * 255).astype(np.uint8)
481
+ results["Normal"] = Image.fromarray(normal_img)
482
+
483
+ if "Depth" in render_keys:
484
+ position_map = camera.get_picture("Position")
485
+ depth = -position_map[..., 2]
486
+ alpha = torch.tensor(color[..., 3], dtype=torch.float32)
487
+ norm_depth = DiffrastRender.normalize_map_by_mask(
488
+ torch.tensor(depth), alpha
489
+ )
490
+ depth_img = (norm_depth * 255).to(torch.uint8).numpy()
491
+ results["Depth"] = Image.fromarray(depth_img)
492
+
493
+ return results
494
+
495
+ def step_action(
496
+ self,
497
+ agent: GripperActionAgent | None,
498
+ action: np.ndarray | torch.Tensor | None,
499
+ render_keys: list[str],
500
+ sim_steps_per_control: int = 1,
501
+ render: bool = True,
502
+ ) -> dict:
503
+ if action is not None:
504
+ agent.set_action(action)
505
+ frames = defaultdict(defaultdict)
506
+ for _ in range(sim_steps_per_control):
507
+ self.scene.step()
508
+ if not render:
509
+ return frames
510
+ self.scene.update_render()
511
+ for camera in self.cameras:
512
+ camera.take_picture()
513
+ images = self.render_images(camera, render_keys=render_keys)
514
+ frames[camera.name].update(images)
515
+ return frames
516
+
517
+
518
+ class EvalGrasps:
519
+ def __init__(
520
+ self,
521
+ cfg: EvalGraspsConfig,
522
+ mesh_info: MeshInfo,
523
+ gripper_control: GripperControlInfo,
524
+ grasp: dict,
525
+ ) -> None:
526
+ self.cfg = cfg
527
+ self.mesh_info = mesh_info
528
+ self.gripper_control = gripper_control
529
+ self.grasp = grasp
530
+ self.init_scene()
531
+ if self.cfg.debug_mode:
532
+ self.setup_cameras(mesh_info)
533
+ self.object_actor = self.add_object(mesh_info)
534
+
535
+ self.frames = {}
536
+ self.step_index = 0
537
+ self.world_x_offset = np.array(
538
+ [self.cfg.sweep_distance, 0.0, 0.0],
539
+ dtype=np.float64,
540
+ )
541
+ self.world_y_offset = np.array(
542
+ [0.0, self.cfg.sweep_distance, 0.0],
543
+ dtype=np.float64,
544
+ )
545
+
546
+ def should_render(self) -> bool:
547
+ return (
548
+ self.cfg.debug_mode
549
+ and self.step_index % max(1, self.cfg.render_interval) == 0
550
+ )
551
+
552
+ def init_scene(self) -> None:
553
+ self.scene_manager = SapienSceneManager(
554
+ sim_freq=self.cfg.sim_freq,
555
+ ray_tracing=self.cfg.ray_tracing,
556
+ device=self.cfg.sim_backend,
557
+ )
558
+ if self.cfg.debug_mode:
559
+ self.scene_manager.set_ground_color([0.78, 0.90, 0.72, 1.0])
560
+
561
+ def setup_cameras(self, mesh_info: MeshInfo) -> None:
562
+ bounds = mesh_info.transformed_mesh.bounds.copy()
563
+
564
+ mesh_size = bounds[1] - bounds[0]
565
+ mesh_height = max(float(mesh_size[2]), 0.5)
566
+ target_pt = [
567
+ float((bounds[0, 0] + bounds[1, 0]) * 0.5),
568
+ float((bounds[0, 1] + bounds[1, 1]) * 0.5),
569
+ float(bounds[1, 2]),
570
+ ]
571
+
572
+ aspect = self.cfg.image_hw[1] / self.cfg.image_hw[0]
573
+ half_vfov = np.deg2rad(self.cfg.fovy_deg) * 0.5
574
+ half_hfov = np.arctan(np.tan(half_vfov) * aspect)
575
+ half_min_fov = min(half_vfov, half_hfov)
576
+ framing_half_extents = np.array(
577
+ [
578
+ max(float(mesh_size[0]) * 0.5, 1e-6),
579
+ max(float(mesh_size[1]) * 0.5, 1e-6),
580
+ mesh_height,
581
+ ],
582
+ dtype=np.float64,
583
+ )
584
+ camera_radius = max(
585
+ float(
586
+ np.linalg.norm(framing_half_extents)
587
+ / np.sin(half_min_fov)
588
+ * 1.5
589
+ ),
590
+ 2.5,
591
+ )
592
+ camera_height = target_pt[2]
593
+
594
+ self.scene_manager.add_ring_cameras(
595
+ num_cameras=self.cfg.num_cameras,
596
+ radius=camera_radius,
597
+ height=camera_height,
598
+ target_pt=target_pt,
599
+ image_hw=self.cfg.image_hw,
600
+ fovy_deg=self.cfg.fovy_deg,
601
+ )
602
+
603
+ def add_object(
604
+ self,
605
+ mesh_info: MeshInfo,
606
+ ) -> sapien.Entity:
607
+ spawn_z = self.cfg.object_bottom_clearance - float(
608
+ mesh_info.transformed_mesh.bounds[0, 2]
609
+ )
610
+ actor = self.scene_manager.add_actor(mesh_info, [0.0, 0.0, spawn_z])
611
+ self.set_object_gravity(not self.cfg.hold_object_during_close, actor)
612
+ return actor
613
+
614
+ def set_object_gravity(
615
+ self,
616
+ enabled: bool,
617
+ actor: sapien.Entity | None = None,
618
+ ) -> None:
619
+ actor = self.object_actor if actor is None else actor
620
+ for component in actor.components:
621
+ set_disable_gravity = getattr(
622
+ component,
623
+ "set_disable_gravity",
624
+ None,
625
+ )
626
+ if set_disable_gravity is not None:
627
+ set_disable_gravity(not enabled)
628
+ if enabled:
629
+ wake_up = getattr(component, "wake_up", None)
630
+ if wake_up is not None:
631
+ wake_up()
632
+
633
+ def run_actions(
634
+ self,
635
+ agent: GripperActionAgent,
636
+ actions: np.ndarray,
637
+ ) -> None:
638
+ for action in actions:
639
+ this_frames = self.scene_manager.step_action(
640
+ agent=agent,
641
+ action=action,
642
+ render_keys=["Color"],
643
+ sim_steps_per_control=1,
644
+ render=self.should_render(),
645
+ )
646
+ self.update_frames(this_frames)
647
+ self.step_index += 1
648
+
649
+ @staticmethod
650
+ def make_pose(
651
+ position: np.ndarray,
652
+ quat_wxyz: np.ndarray,
653
+ ) -> sapien.Pose:
654
+ return sapien.Pose(
655
+ p=[float(v) for v in position],
656
+ q=[float(v) for v in quat_wxyz],
657
+ )
658
+
659
+ @staticmethod
660
+ def pose_matrix(pose: sapien.Pose) -> np.ndarray:
661
+ quat_wxyz = np.asarray(pose.q, dtype=np.float64)
662
+ quat_norm = np.linalg.norm(quat_wxyz)
663
+ if quat_norm <= 0.0:
664
+ raise ValueError("Pose quaternion has zero norm.")
665
+ quat_wxyz = quat_wxyz / quat_norm
666
+
667
+ matrix = np.eye(4, dtype=np.float64)
668
+ matrix[:3, :3] = R.from_quat(
669
+ [
670
+ quat_wxyz[1],
671
+ quat_wxyz[2],
672
+ quat_wxyz[3],
673
+ quat_wxyz[0],
674
+ ]
675
+ ).as_matrix()
676
+ matrix[:3, 3] = np.asarray(pose.p, dtype=np.float64)
677
+ return matrix
678
+
679
+ @staticmethod
680
+ def pose_delta(
681
+ reference_relative_pose: np.ndarray,
682
+ current_relative_pose: np.ndarray,
683
+ ) -> dict[str, float]:
684
+ delta_pose = (
685
+ np.linalg.inv(reference_relative_pose) @ current_relative_pose
686
+ )
687
+ translation_m = float(np.linalg.norm(delta_pose[:3, 3]))
688
+ rotation_rad = float(R.from_matrix(delta_pose[:3, :3]).magnitude())
689
+ return {
690
+ "translation_m": translation_m,
691
+ "rotation_rad": rotation_rad,
692
+ "rotation_deg": float(np.rad2deg(rotation_rad)),
693
+ }
694
+
695
+ def object_rel_pose(
696
+ self,
697
+ agent: GripperActionAgent,
698
+ ) -> np.ndarray:
699
+ gripper_pose = self.pose_matrix(agent.gripper.get_root_pose())
700
+ object_pose = self.pose_matrix(self.object_actor.get_pose())
701
+ return np.linalg.inv(gripper_pose) @ object_pose
702
+
703
+ def object_bottom_z(self) -> float:
704
+ object_pose = self.pose_matrix(self.object_actor.get_pose())
705
+ vertices = np.asarray(self.mesh_info.transformed_mesh.vertices)
706
+ world_vertices = (object_pose[:3, :3] @ vertices.T).T + object_pose[
707
+ :3, 3
708
+ ]
709
+ return float(np.min(world_vertices[:, 2]))
710
+
711
+ def check_slip(
712
+ self,
713
+ agent: GripperActionAgent,
714
+ reference_relative_pose: np.ndarray,
715
+ ) -> dict[str, float | bool]:
716
+ delta = self.pose_delta(
717
+ reference_relative_pose,
718
+ self.object_rel_pose(agent),
719
+ )
720
+ slipped = (
721
+ delta["translation_m"] > self.cfg.slip_translation_threshold
722
+ or delta["rotation_deg"] > self.cfg.slip_rotation_threshold_deg
723
+ )
724
+ return {
725
+ **delta,
726
+ "slipped": slipped,
727
+ }
728
+
729
+ def checked_action(
730
+ self,
731
+ step_name: str,
732
+ agent: GripperActionAgent,
733
+ action_fn,
734
+ *,
735
+ check_slip: bool = True,
736
+ ) -> dict:
737
+ reference_relative_pose = self.object_rel_pose(agent)
738
+ action_fn()
739
+ self.wait_scene(self.cfg.action_wait_seconds)
740
+ delta = self.check_slip(
741
+ agent,
742
+ reference_relative_pose,
743
+ )
744
+ slipped = bool(delta["slipped"])
745
+ success = not slipped if check_slip else True
746
+ return {
747
+ "step": step_name,
748
+ "success": success,
749
+ "checked": check_slip,
750
+ "slipped": slipped,
751
+ "reason": None if success else "relative_pose_slip",
752
+ "relative_pose_delta": {
753
+ "translation_m": delta["translation_m"],
754
+ "rotation_rad": delta["rotation_rad"],
755
+ "rotation_deg": delta["rotation_deg"],
756
+ },
757
+ }
758
+
759
+ def get_steps(
760
+ self,
761
+ distance: float,
762
+ speed: float,
763
+ ) -> int:
764
+ if distance <= 0.0:
765
+ return 2
766
+ duration = distance / speed
767
+ return max(2, int(np.ceil(duration * self.cfg.sim_freq)))
768
+
769
+ def move_to_pose(
770
+ self,
771
+ agent: GripperActionAgent,
772
+ start_pose: sapien.Pose,
773
+ end_pose: sapien.Pose,
774
+ finger_qpos: float | None = None,
775
+ ) -> None:
776
+ if finger_qpos is None:
777
+ finger_qpos = self.gripper_control.close_qpos
778
+
779
+ start_pos = np.asarray(start_pose.p, dtype=np.float64)
780
+ end_pos = np.asarray(end_pose.p, dtype=np.float64)
781
+ travel_distance = float(np.linalg.norm(end_pos - start_pos))
782
+ num_steps = self.get_steps(
783
+ travel_distance,
784
+ self.cfg.gripper_move_speed,
785
+ )
786
+ alphas = np.linspace(0.0, 1.0, num_steps)
787
+ quat = np.asarray(start_pose.q, dtype=np.float64)
788
+
789
+ actions = []
790
+ for alpha in alphas:
791
+ pose = self.make_pose(
792
+ (1.0 - alpha) * start_pos + alpha * end_pos,
793
+ quat,
794
+ )
795
+ actions.append(self.make_action(pose, finger_qpos))
796
+ self.run_actions(agent, np.stack(actions, axis=0))
797
+
798
+ def move_gripper(
799
+ self,
800
+ agent: GripperActionAgent,
801
+ offset: np.ndarray,
802
+ ) -> None:
803
+ start_pose = agent.gripper.get_root_pose()
804
+ end_pose = self.make_pose(
805
+ np.asarray(start_pose.p, dtype=np.float64)
806
+ + np.asarray(offset, dtype=np.float64),
807
+ np.asarray(start_pose.q, dtype=np.float64),
808
+ )
809
+ self.move_to_pose(agent, start_pose, end_pose)
810
+
811
+ def init_grasp_pose(self) -> sapien.Pose:
812
+ orientation = self.grasp["orientation"]
813
+ self.grasp = sapien.Pose(
814
+ p=[float(v) for v in self.grasp["position"]],
815
+ q=[
816
+ float(orientation["w"]),
817
+ float(orientation["xyz"][0]),
818
+ float(orientation["xyz"][1]),
819
+ float(orientation["xyz"][2]),
820
+ ],
821
+ )
822
+ gripper_pose = self.object_actor.get_pose() * self.grasp
823
+ return gripper_pose
824
+
825
+ def make_action(
826
+ self,
827
+ pose: sapien.Pose,
828
+ finger_qpos: float,
829
+ ) -> np.ndarray:
830
+ return np.array(
831
+ [
832
+ float(pose.p[0]),
833
+ float(pose.p[1]),
834
+ float(pose.p[2]),
835
+ float(pose.q[0]),
836
+ float(pose.q[1]),
837
+ float(pose.q[2]),
838
+ float(pose.q[3]),
839
+ float(finger_qpos),
840
+ ],
841
+ dtype=np.float32,
842
+ )
843
+
844
+ def add_gripper_agent(self) -> GripperActionAgent:
845
+ gripper_pose = self.init_grasp_pose()
846
+ gripper, root_target, root_drive = self.scene_manager.add_gripper(
847
+ self.cfg.gripper_urdf_path,
848
+ gripper_pose,
849
+ self.gripper_control.open_qpos,
850
+ self.cfg.gripper_drive_stiffness,
851
+ self.gripper_control.drive_damping,
852
+ self.gripper_control.force_limit,
853
+ self.cfg.gripper_static_friction,
854
+ self.cfg.gripper_dynamic_friction,
855
+ self.cfg.gripper_restitution,
856
+ self.cfg.root_drive_stiffness,
857
+ self.cfg.root_drive_damping,
858
+ )
859
+ return GripperActionAgent(
860
+ self.scene_manager,
861
+ gripper,
862
+ root_target,
863
+ root_drive,
864
+ )
865
+
866
+ def update_frames(self, this_frames: dict) -> None:
867
+ for cam_name, images_list in this_frames.items():
868
+ if cam_name not in self.frames:
869
+ self.frames[cam_name] = []
870
+ self.frames[cam_name].append(np.array(images_list["Color"]))
871
+
872
+ def wait_scene(self, seconds: float) -> None:
873
+ num_steps = int(self.cfg.sim_freq * seconds)
874
+ for _ in range(num_steps):
875
+ this_frames = self.scene_manager.step_action(
876
+ agent=None,
877
+ action=None,
878
+ render_keys=["Color"],
879
+ sim_steps_per_control=1,
880
+ render=self.should_render(),
881
+ )
882
+ self.update_frames(this_frames)
883
+ self.step_index += 1
884
+
885
+ def close_gripper(self, agent: GripperActionAgent) -> None:
886
+ num_close_steps = self.get_steps(
887
+ abs(
888
+ self.gripper_control.open_qpos
889
+ - self.gripper_control.close_qpos
890
+ ),
891
+ self.cfg.gripper_close_speed,
892
+ )
893
+ root_pose = agent.gripper.get_root_pose()
894
+ actions = [
895
+ self.make_action(
896
+ root_pose,
897
+ (1.0 - alpha) * self.gripper_control.open_qpos
898
+ + alpha * self.gripper_control.close_qpos,
899
+ )
900
+ for alpha in np.linspace(0.0, 1.0, max(1, num_close_steps))
901
+ ]
902
+ self.run_actions(agent, np.stack(actions, axis=0))
903
+
904
+ @staticmethod
905
+ def summarize_steps(step_results: list[dict]) -> dict:
906
+ failure = next(
907
+ (
908
+ step_result
909
+ for step_result in step_results
910
+ if not step_result["success"]
911
+ ),
912
+ None,
913
+ )
914
+ result = {
915
+ "success": failure is None,
916
+ "relative_pose_deltas": {
917
+ step_result["step"]: step_result["relative_pose_delta"]
918
+ for step_result in step_results
919
+ },
920
+ }
921
+ if failure is not None:
922
+ result.update(
923
+ {
924
+ "failure_step": failure["step"],
925
+ "reason": failure["reason"],
926
+ }
927
+ )
928
+ return result
929
+
930
+ def run(self) -> dict:
931
+ step_results = []
932
+ self.wait_scene(2.0)
933
+
934
+ this_agent = self.add_gripper_agent()
935
+ self.wait_scene(self.cfg.action_wait_seconds)
936
+
937
+ # Close the gripper around the object.
938
+ step_result = self.checked_action(
939
+ "close_gripper",
940
+ this_agent,
941
+ lambda: self.close_gripper(this_agent),
942
+ check_slip=self.cfg.check_close_slip,
943
+ )
944
+ step_results.append(step_result)
945
+ if not step_result["success"]:
946
+ return self.summarize_steps(step_results)
947
+
948
+ if self.cfg.hold_object_during_close:
949
+ self.set_object_gravity(True)
950
+
951
+ # Lift the grasped object upward.
952
+ step_result = self.checked_action(
953
+ "lift_gripper",
954
+ this_agent,
955
+ lambda: self.move_gripper(
956
+ this_agent,
957
+ np.array(
958
+ [0.0, 0.0, self.cfg.lift_height],
959
+ dtype=np.float64,
960
+ ),
961
+ ),
962
+ )
963
+ step_results.append(step_result)
964
+ if not step_result["success"]:
965
+ return self.summarize_steps(step_results)
966
+
967
+ shake_start_pose = this_agent.gripper.get_root_pose()
968
+ shake_end_pose = self.make_pose(
969
+ np.asarray(shake_start_pose.p, dtype=np.float64)
970
+ + self.world_x_offset,
971
+ np.asarray(shake_start_pose.q, dtype=np.float64),
972
+ )
973
+
974
+ # Move the gripper outward along the X axis.
975
+ step_result = self.checked_action(
976
+ "move_gripper_out_x",
977
+ this_agent,
978
+ lambda: self.move_to_pose(
979
+ this_agent,
980
+ shake_start_pose,
981
+ shake_end_pose,
982
+ ),
983
+ )
984
+ step_results.append(step_result)
985
+ if not step_result["success"]:
986
+ return self.summarize_steps(step_results)
987
+
988
+ # Move the gripper back from the X-axis offset.
989
+ step_result = self.checked_action(
990
+ "move_gripper_back_x",
991
+ this_agent,
992
+ lambda: self.move_to_pose(
993
+ this_agent,
994
+ this_agent.gripper.get_root_pose(),
995
+ shake_start_pose,
996
+ ),
997
+ )
998
+ step_results.append(step_result)
999
+ if not step_result["success"]:
1000
+ return self.summarize_steps(step_results)
1001
+
1002
+ shake_start_pose = this_agent.gripper.get_root_pose()
1003
+ shake_end_pose = self.make_pose(
1004
+ np.asarray(shake_start_pose.p, dtype=np.float64)
1005
+ + self.world_y_offset,
1006
+ np.asarray(shake_start_pose.q, dtype=np.float64),
1007
+ )
1008
+
1009
+ # Move the gripper outward along the Y axis.
1010
+ step_result = self.checked_action(
1011
+ "move_gripper_out_y",
1012
+ this_agent,
1013
+ lambda: self.move_to_pose(
1014
+ this_agent,
1015
+ shake_start_pose,
1016
+ shake_end_pose,
1017
+ ),
1018
+ )
1019
+ step_results.append(step_result)
1020
+ if not step_result["success"]:
1021
+ return self.summarize_steps(step_results)
1022
+
1023
+ # Move the gripper back from the Y-axis offset.
1024
+ step_result = self.checked_action(
1025
+ "move_gripper_back_y",
1026
+ this_agent,
1027
+ lambda: self.move_to_pose(
1028
+ this_agent,
1029
+ this_agent.gripper.get_root_pose(),
1030
+ shake_start_pose,
1031
+ ),
1032
+ )
1033
+ step_results.append(step_result)
1034
+ if not step_result["success"]:
1035
+ return self.summarize_steps(step_results)
1036
+
1037
+ # Lower the grasped object back down.
1038
+ descend_offset = np.array(
1039
+ [0.0, 0.0, 0.01 - self.object_bottom_z()],
1040
+ dtype=np.float64,
1041
+ )
1042
+ step_result = self.checked_action(
1043
+ "descend_gripper",
1044
+ this_agent,
1045
+ lambda: self.move_gripper(this_agent, descend_offset),
1046
+ )
1047
+ step_results.append(step_result)
1048
+ if not step_result["success"]:
1049
+ return self.summarize_steps(step_results)
1050
+
1051
+ return self.summarize_steps(step_results)
1052
+
1053
+ def export_video(self, output_dir: str) -> None:
1054
+ if not self.cfg.debug_mode:
1055
+ return
1056
+ os.makedirs(output_dir, exist_ok=True)
1057
+ for cam_name, this_frames in self.frames.items():
1058
+ this_output_path = os.path.join(output_dir, f"{cam_name}.mp4")
1059
+ imageio.mimsave(
1060
+ this_output_path, this_frames, fps=self.cfg.video_fps
1061
+ )
1062
+
1063
+
1064
+ def _validate_config(cfg: EvalGraspsConfig) -> None:
1065
+ if not cfg.urdf_paths:
1066
+ raise ValueError("urdf_paths must be provided.")
1067
+
1068
+ if len(cfg.output_dirs) == 0:
1069
+ cfg.output_dirs = [
1070
+ os.path.join(os.path.dirname(path), "affordance")
1071
+ for path in cfg.urdf_paths
1072
+ ]
1073
+
1074
+ if len(cfg.urdf_paths) != len(cfg.output_dirs):
1075
+ raise ValueError(
1076
+ "urdf_paths and output_dirs must have the same length, "
1077
+ f"got {len(cfg.urdf_paths)} and {len(cfg.output_dirs)}."
1078
+ )
1079
+
1080
+
1081
+ def _set_single_config(
1082
+ cfg: EvalGraspsConfig,
1083
+ urdf_path: str,
1084
+ output_dir: str,
1085
+ ) -> EvalGraspsConfig:
1086
+ return dataclasses.replace(
1087
+ cfg,
1088
+ urdf_paths=[urdf_path],
1089
+ output_dirs=[output_dir],
1090
+ )
1091
+
1092
+
1093
+ def _eval_grasp(
1094
+ cfg: EvalGraspsConfig,
1095
+ grasp_id: str,
1096
+ grasp: dict,
1097
+ ) -> dict:
1098
+ output_dir = cfg.output_dirs[0]
1099
+
1100
+ mesh_info = load_mesh_info(cfg.urdf_paths[0])
1101
+ gripper_control = GripperControlInfo(
1102
+ **URDFFile(cfg.gripper_urdf_path).get_prismatic_joint_control_info()
1103
+ )
1104
+ evaluator = EvalGrasps(cfg, mesh_info, gripper_control, grasp)
1105
+ result = evaluator.run()
1106
+ evaluator.export_video(
1107
+ os.path.join(output_dir, "eval_grasp_renders", grasp_id)
1108
+ )
1109
+ if threading.current_thread() is not threading.main_thread():
1110
+ with _EVALUATOR_KEEPALIVE_LOCK:
1111
+ _EVALUATOR_KEEPALIVE.append(evaluator)
1112
+ return {
1113
+ "grasp_id": grasp_id,
1114
+ "output_dir": output_dir,
1115
+ "simulation_result": result,
1116
+ }
1117
+
1118
+
1119
+ def _release_thread_evaluators() -> None:
1120
+ with _EVALUATOR_KEEPALIVE_LOCK:
1121
+ evaluators = list(_EVALUATOR_KEEPALIVE)
1122
+ _EVALUATOR_KEEPALIVE.clear()
1123
+ for evaluator in evaluators:
1124
+ del evaluator
1125
+
1126
+
1127
+ def _eval_grasp_task(task: tuple[EvalGraspsConfig, str, dict]) -> dict:
1128
+ cfg, grasp_id, grasp = task
1129
+ return _eval_grasp(cfg, grasp_id, grasp)
1130
+
1131
+
1132
+ def _slip_thresholds(cfg: EvalGraspsConfig) -> dict[str, float]:
1133
+ return {
1134
+ "translation_m": cfg.slip_translation_threshold,
1135
+ "rotation_deg": cfg.slip_rotation_threshold_deg,
1136
+ }
1137
+
1138
+
1139
+ def _is_successful_grasp_result(result: dict) -> bool:
1140
+ simulation_result = result.get("simulation_result", {})
1141
+ if not isinstance(simulation_result, dict):
1142
+ return False
1143
+ return bool(simulation_result.get("success", False))
1144
+
1145
+
1146
+ def _grasp_success_by_id(results: list[dict]) -> dict[str, bool]:
1147
+ return {
1148
+ result["grasp_id"]: _is_successful_grasp_result(result)
1149
+ for result in results
1150
+ }
1151
+
1152
+
1153
+ def _build_eval_summary(
1154
+ cfg: EvalGraspsConfig,
1155
+ results: list[dict],
1156
+ elapsed_seconds: float,
1157
+ ) -> dict:
1158
+ successful_grasp_ids = [
1159
+ result["grasp_id"]
1160
+ for result in results
1161
+ if _is_successful_grasp_result(result)
1162
+ ]
1163
+ failed_grasp_ids = [
1164
+ result["grasp_id"]
1165
+ for result in results
1166
+ if not _is_successful_grasp_result(result)
1167
+ ]
1168
+ num_grasps = len(results)
1169
+ num_success = len(successful_grasp_ids)
1170
+ return {
1171
+ "num_grasps": num_grasps,
1172
+ "num_success": num_success,
1173
+ "num_failure": len(failed_grasp_ids),
1174
+ "success_rate": num_success / max(1, num_grasps),
1175
+ "successful_grasp_ids": successful_grasp_ids,
1176
+ "failed_grasp_ids": failed_grasp_ids,
1177
+ "slip_thresholds": _slip_thresholds(cfg),
1178
+ "elapsed_seconds": elapsed_seconds,
1179
+ }
1180
+
1181
+
1182
+ def _run_single_eval(cfg: EvalGraspsConfig) -> None:
1183
+ start_time = time.perf_counter()
1184
+ output_dir = cfg.output_dirs[0]
1185
+ os.makedirs(output_dir, exist_ok=True)
1186
+ urdf_path = cfg.urdf_paths[0]
1187
+ urdf = URDFFile(urdf_path)
1188
+
1189
+ affordance_path = urdf.get_affordance_annot_path()
1190
+ affordance_payload = load_json(affordance_path)
1191
+ affordances = affordance_payload.get("affordances", [])
1192
+ grasps = {}
1193
+ for affordance in affordances:
1194
+ grasps.update(affordance.get("grasp_group", {}))
1195
+
1196
+ if not grasps:
1197
+ return
1198
+
1199
+ num_workers = cfg.num_workers
1200
+ if num_workers is None:
1201
+ num_workers = min(len(grasps), os.cpu_count() or 1, MAX_NUM_WORKERS)
1202
+ else:
1203
+ num_workers = min(num_workers, len(grasps), MAX_NUM_WORKERS)
1204
+
1205
+ logger.info(
1206
+ f"Running {len(grasps)} grasp poses with {num_workers} worker(s)"
1207
+ )
1208
+ results = []
1209
+ if num_workers == 1:
1210
+ for grasp_id, grasp in tqdm(grasps.items(), desc="Evaluating grasps"):
1211
+ results.append(_eval_grasp(cfg, grasp_id, grasp))
1212
+ else:
1213
+ worker_cfg = dataclasses.replace(cfg, num_workers=1)
1214
+ tasks = [
1215
+ (worker_cfg, grasp_id, grasp) for grasp_id, grasp in grasps.items()
1216
+ ]
1217
+ try:
1218
+ with concurrent.futures.ThreadPoolExecutor(
1219
+ max_workers=num_workers
1220
+ ) as executor:
1221
+ futures = [
1222
+ executor.submit(_eval_grasp_task, task) for task in tasks
1223
+ ]
1224
+ for future in tqdm(
1225
+ concurrent.futures.as_completed(futures),
1226
+ total=len(futures),
1227
+ desc="Evaluating grasps",
1228
+ ):
1229
+ results.append(future.result())
1230
+ results.sort(
1231
+ key=lambda result: list(grasps.keys()).index(
1232
+ result["grasp_id"]
1233
+ )
1234
+ )
1235
+ finally:
1236
+ _release_thread_evaluators()
1237
+
1238
+ elapsed_seconds = time.perf_counter() - start_time
1239
+ summary = _build_eval_summary(cfg, results, elapsed_seconds)
1240
+ failed_grasp_ids = set(summary["failed_grasp_ids"])
1241
+ grasp_success_by_id = _grasp_success_by_id(results)
1242
+ for affordance in affordance_payload.get("affordances", []):
1243
+ grasp_group = affordance.get("grasp_group")
1244
+ if isinstance(grasp_group, dict):
1245
+ if cfg.debug_mode or cfg.save_failed_grasps:
1246
+ for grasp_id, grasp in grasp_group.items():
1247
+ if (
1248
+ isinstance(grasp, dict)
1249
+ and grasp_id in grasp_success_by_id
1250
+ ):
1251
+ grasp["simulation_success"] = grasp_success_by_id[
1252
+ grasp_id
1253
+ ]
1254
+ continue
1255
+ for failed_grasp_id in failed_grasp_ids:
1256
+ grasp_group.pop(failed_grasp_id, None)
1257
+
1258
+ write_json(affordance_payload, affordance_path)
1259
+
1260
+ logger.info(f"Grasp evaluation simulation took {elapsed_seconds:.2f}s")
1261
+ logger.info(f"Updated grasp simulation results in {affordance_path}")
1262
+ logger.info(
1263
+ f"Grasp success rate: {summary['num_success']}/"
1264
+ f"{summary['num_grasps']} ({summary['success_rate']:.2%})"
1265
+ )
1266
+
1267
+
1268
+ def _run_single_eval_safe(cfg: EvalGraspsConfig) -> bool:
1269
+ urdf_path = cfg.urdf_paths[0]
1270
+ try:
1271
+ _run_single_eval(cfg)
1272
+ except Exception as exc:
1273
+ logger.error(
1274
+ "Grasp evaluation failed for URDF {}: {}".format(
1275
+ urdf_path,
1276
+ exc,
1277
+ )
1278
+ )
1279
+ return False
1280
+ return True
1281
+
1282
+
1283
+ def run_eval(cfg: EvalGraspsConfig) -> None:
1284
+ _validate_config(cfg)
1285
+ for idx, (urdf_path, output_dir) in enumerate(
1286
+ zip(cfg.urdf_paths, cfg.output_dirs),
1287
+ start=1,
1288
+ ):
1289
+ single_cfg = _set_single_config(cfg, urdf_path, output_dir)
1290
+ logger.info(
1291
+ f"Running grasp evaluation for URDF {idx}/{len(cfg.urdf_paths)}: "
1292
+ f"{urdf_path}"
1293
+ )
1294
+ _run_single_eval_safe(single_cfg)
1295
+
1296
+
1297
+ def entrypoint(*args, **kwargs) -> None:
1298
+ cfg = tyro.cli(EvalGraspsConfig)
1299
+ run_eval(cfg)
1300
+
1301
+
1302
+ if __name__ == "__main__":
1303
+ entrypoint()
embodied_gen/scripts/affordance_annot/franka_panda/coll_mesh.obj ADDED
@@ -0,0 +1,464 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # https://github.com/mikedh/trimesh
2
+ v -0.09012898 0.01673458 0.06443186 0.00000000 1.00000000 0.00000000
3
+ v 0.08927116 0.01713458 0.06361439 0.00000000 1.00000000 0.00000000
4
+ v 0.00055840 0.03158979 0.00621423 0.00000000 1.00000000 0.00000000
5
+ v 0.09552484 -0.01705332 0.02212120 0.00000000 1.00000000 0.00000000
6
+ v 0.09790768 -0.01751002 0.05166616 0.00000000 1.00000000 0.00000000
7
+ v 0.00198662 -0.03159750 0.00115293 0.00000000 1.00000000 0.00000000
8
+ v -0.07576962 -0.02598126 -0.00508223 0.00000000 1.00000000 0.00000000
9
+ v 0.00057885 -0.03163592 0.00597307 0.00000000 1.00000000 0.00000000
10
+ v 0.09680219 0.01703352 0.03032204 0.00000000 1.00000000 0.00000000
11
+ v 0.00186074 0.03161579 0.00117971 0.00000000 1.00000000 0.00000000
12
+ v 0.09740699 0.01758546 0.05198465 0.00000000 1.00000000 0.00000000
13
+ v -0.09460201 0.01855551 0.05661729 0.00000000 1.00000000 0.00000000
14
+ v 0.09190618 -0.01680383 0.00629548 0.00000000 1.00000000 0.00000000
15
+ v 0.08753296 -0.01681192 -0.00120645 0.00000000 1.00000000 0.00000000
16
+ v 0.09953746 0.01623047 0.05158409 0.00000000 1.00000000 0.00000000
17
+ v 0.10398972 -0.00004070 0.00569088 0.00000000 1.00000000 0.00000000
18
+ v 0.10374270 -0.00001689 -0.00919930 0.00000000 1.00000000 0.00000000
19
+ v 0.10115783 -0.00361640 -0.01261032 0.00000000 1.00000000 0.00000000
20
+ v 0.10259786 -0.00342918 -0.00896430 0.00000000 1.00000000 0.00000000
21
+ v -0.08206502 -0.02527066 0.00519704 0.00000000 1.00000000 0.00000000
22
+ v -0.04178420 -0.02801102 -0.00639640 0.00000000 1.00000000 0.00000000
23
+ v -0.09280849 -0.00011291 0.06596215 0.00000000 1.00000000 0.00000000
24
+ v -0.09371245 -0.01256706 0.06516847 0.00000000 1.00000000 0.00000000
25
+ v -0.09127641 -0.01442229 0.06568974 0.00000000 1.00000000 0.00000000
26
+ v 0.09070038 -0.01725433 0.06308201 0.00000000 1.00000000 0.00000000
27
+ v 0.09070638 -0.01547137 0.06519010 0.00000000 1.00000000 0.00000000
28
+ v -0.09025214 -0.01676757 0.06433220 0.00000000 1.00000000 0.00000000
29
+ v 0.09160285 0.01695586 0.00556283 0.00000000 1.00000000 0.00000000
30
+ v 0.09384751 0.01760751 0.05844849 0.00000000 1.00000000 0.00000000
31
+ v 0.10018747 0.01250605 0.05689415 0.00000000 1.00000000 0.00000000
32
+ v -0.10019985 0.01555056 0.05467260 0.00000000 1.00000000 0.00000000
33
+ v -0.09849364 0.01926846 0.04494174 0.00000000 1.00000000 0.00000000
34
+ v -0.10028528 0.01621802 0.04586977 0.00000000 1.00000000 0.00000000
35
+ v 0.04551993 0.00191760 -0.02565943 0.00000000 1.00000000 0.00000000
36
+ v -0.04204245 0.02801493 -0.00634960 0.00000000 1.00000000 0.00000000
37
+ v 0.08296617 0.00381064 -0.02444934 0.00000000 1.00000000 0.00000000
38
+ v -0.09369192 0.02067053 0.04330829 0.00000000 1.00000000 0.00000000
39
+ v -0.08118060 0.02544764 0.00350060 0.00000000 1.00000000 0.00000000
40
+ v 0.08358592 -0.00363651 -0.02460626 0.00000000 1.00000000 0.00000000
41
+ v 0.08223634 -0.01653239 -0.00511811 0.00000000 1.00000000 0.00000000
42
+ v 0.10009117 -0.01401675 0.05602695 0.00000000 1.00000000 0.00000000
43
+ v 0.09962869 -0.01601276 0.04930143 0.00000000 1.00000000 0.00000000
44
+ v 0.08776600 -0.00321477 -0.02388439 0.00000000 1.00000000 0.00000000
45
+ v 0.09249580 -0.01298759 0.06571195 0.00000000 1.00000000 0.00000000
46
+ v 0.09294532 -0.00050530 0.06595311 0.00000000 1.00000000 0.00000000
47
+ v -0.09267219 0.01353924 0.06568788 0.00000000 1.00000000 0.00000000
48
+ v 0.09192696 0.01342515 0.06573131 0.00000000 1.00000000 0.00000000
49
+ v -0.09726050 -0.01922958 0.05058843 0.00000000 1.00000000 0.00000000
50
+ v 0.08513211 0.01655853 -0.00387274 0.00000000 1.00000000 0.00000000
51
+ v 0.10271563 0.00349515 -0.00802934 0.00000000 1.00000000 0.00000000
52
+ v -0.09040149 0.02386882 0.00436824 0.00000000 1.00000000 0.00000000
53
+ v -0.09373564 0.02270746 0.01434002 0.00000000 1.00000000 0.00000000
54
+ v -0.07529767 0.02603403 -0.00503085 0.00000000 1.00000000 0.00000000
55
+ v -0.07437764 0.02481648 -0.00715570 0.00000000 1.00000000 0.00000000
56
+ v 0.04586986 -0.00149998 -0.02592480 0.00000000 1.00000000 0.00000000
57
+ v -0.08025997 -0.00083407 -0.00732172 0.00000000 1.00000000 0.00000000
58
+ v -0.07790146 0.02295881 -0.00739904 0.00000000 1.00000000 0.00000000
59
+ v -0.08017428 -0.02276142 -0.00690954 0.00000000 1.00000000 0.00000000
60
+ v 0.09945143 0.00003218 -0.01716121 0.00000000 1.00000000 0.00000000
61
+ v 0.09855405 -0.01663178 0.05473394 0.00000000 1.00000000 0.00000000
62
+ v 0.10119467 0.00360250 -0.01262764 0.00000000 1.00000000 0.00000000
63
+ v -0.09747105 -0.00161709 0.06230734 0.00000000 1.00000000 0.00000000
64
+ v 0.09177952 0.01571872 0.06491254 0.00000000 1.00000000 0.00000000
65
+ v -0.09201546 -0.01844902 0.05839317 0.00000000 1.00000000 0.00000000
66
+ v 0.08797474 -0.00062667 -0.02496160 0.00000000 1.00000000 0.00000000
67
+ v 0.08797288 0.00307632 -0.02397873 0.00000000 1.00000000 0.00000000
68
+ v -0.09701113 0.01980169 0.02091398 0.00000000 1.00000000 0.00000000
69
+ v -0.10003477 0.00994118 0.05834080 0.00000000 1.00000000 0.00000000
70
+ v -0.09906728 0.01712909 0.05430516 0.00000000 1.00000000 0.00000000
71
+ v -0.09461535 -0.00031448 0.00757466 0.00000000 1.00000000 0.00000000
72
+ v -0.09849606 -0.00001631 0.02708179 0.00000000 1.00000000 0.00000000
73
+ v -0.08656162 -0.00033764 -0.00416328 0.00000000 1.00000000 0.00000000
74
+ v -0.08825006 0.02231396 -0.00193320 0.00000000 1.00000000 0.00000000
75
+ v -0.08335528 0.02237667 -0.00552611 0.00000000 1.00000000 0.00000000
76
+ v -0.09092682 -0.02132529 0.00097980 0.00000000 1.00000000 0.00000000
77
+ v -0.08543439 -0.02273309 -0.00412033 0.00000000 1.00000000 0.00000000
78
+ v -0.08589226 -0.02449557 -0.00183273 0.00000000 1.00000000 0.00000000
79
+ v 0.08400230 0.00184310 -0.02552490 0.00000000 1.00000000 0.00000000
80
+ v -0.07666791 -0.02465053 -0.00693213 0.00000000 1.00000000 0.00000000
81
+ v 0.09406284 0.01313908 0.06468318 0.00000000 1.00000000 0.00000000
82
+ v 0.09998268 -0.00932350 0.05848920 0.00000000 1.00000000 0.00000000
83
+ v -0.09769707 -0.01906715 0.02462748 0.00000000 1.00000000 0.00000000
84
+ v -0.10042586 -0.00449476 0.05811909 0.00000000 1.00000000 0.00000000
85
+ v -0.10016816 -0.01462347 0.05573492 0.00000000 1.00000000 0.00000000
86
+ v 0.09941621 0.01580527 0.05497027 0.00000000 1.00000000 0.00000000
87
+ v -0.09433321 0.02134224 0.00894201 0.00000000 1.00000000 0.00000000
88
+ v -0.09099504 -0.00017374 0.00057678 0.00000000 1.00000000 0.00000000
89
+ v -0.09179476 0.02148257 0.00268413 0.00000000 1.00000000 0.00000000
90
+ v -0.08170268 0.02451487 -0.00505142 0.00000000 1.00000000 0.00000000
91
+ v -0.08574020 0.02472907 -0.00123169 0.00000000 1.00000000 0.00000000
92
+ v -0.09466784 -0.02065799 0.00953798 0.00000000 1.00000000 0.00000000
93
+ v 0.10030148 0.00119096 0.05862313 0.00000000 1.00000000 0.00000000
94
+ v 0.09665588 0.00046996 0.06307836 0.00000000 1.00000000 0.00000000
95
+ v -0.09290565 0.01539656 0.06455627 0.00000000 1.00000000 0.00000000
96
+ v 0.08254847 -0.00086753 -0.02587020 0.00000000 1.00000000 0.00000000
97
+ v -0.09116623 -0.02355612 0.00460792 0.00000000 1.00000000 0.00000000
98
+ v 0.09344175 -0.01461668 0.06450005 0.00000000 1.00000000 0.00000000
99
+ v -0.09289678 -0.02302643 0.00987686 0.00000000 1.00000000 0.00000000
100
+ v -0.09544163 -0.02182249 0.02175636 0.00000000 1.00000000 0.00000000
101
+ v -0.09951787 -0.01819300 0.04381417 0.00000000 1.00000000 0.00000000
102
+ v -0.09320920 -0.01604587 0.06361687 0.00000000 1.00000000 0.00000000
103
+ v -0.09936280 -0.01699521 0.05411479 0.00000000 1.00000000 0.00000000
104
+ v 0.06600004 -0.01099976 0.05940000 0.00000000 1.00000000 0.00000000
105
+ v 0.06600004 -0.01099976 0.07940000 0.00000000 1.00000000 0.00000000
106
+ v 0.05100004 -0.01099981 0.05940000 0.00000000 1.00000000 0.00000000
107
+ v 0.05100004 -0.01099981 0.07940000 0.00000000 1.00000000 0.00000000
108
+ v 0.06599996 0.01100024 0.05940000 0.00000000 1.00000000 0.00000000
109
+ v 0.06599996 0.01100024 0.07940000 0.00000000 1.00000000 0.00000000
110
+ v 0.05099996 0.01100019 0.05940000 0.00000000 1.00000000 0.00000000
111
+ v 0.05099996 0.01100019 0.07940000 0.00000000 1.00000000 0.00000000
112
+ v 0.05120004 -0.01099981 0.05870000 0.00000000 1.00000000 0.00000000
113
+ v 0.05120004 -0.01099981 0.06250000 0.00000000 1.00000000 0.00000000
114
+ v 0.04240004 -0.01099984 0.05870000 0.00000000 1.00000000 0.00000000
115
+ v 0.04240004 -0.01099984 0.06250000 0.00000000 1.00000000 0.00000000
116
+ v 0.05119996 0.01100019 0.05870000 0.00000000 1.00000000 0.00000000
117
+ v 0.05119996 0.01100019 0.06250000 0.00000000 1.00000000 0.00000000
118
+ v 0.04239996 0.01100016 0.05870000 0.00000000 1.00000000 0.00000000
119
+ v 0.04239996 0.01100016 0.06250000 0.00000000 1.00000000 0.00000000
120
+ v 0.06480612 -0.00874976 0.07832420 0.00000000 1.00000000 0.00000000
121
+ v 0.05305612 -0.00874981 0.09867580 0.00000000 1.00000000 0.00000000
122
+ v 0.05874394 -0.00874978 0.07482420 0.00000000 1.00000000 0.00000000
123
+ v 0.04699394 -0.00874983 0.09517580 0.00000000 1.00000000 0.00000000
124
+ v 0.06480606 0.00875024 0.07832420 0.00000000 1.00000000 0.00000000
125
+ v 0.05305606 0.00875019 0.09867580 0.00000000 1.00000000 0.00000000
126
+ v 0.05874388 0.00875022 0.07482420 0.00000000 1.00000000 0.00000000
127
+ v 0.04699388 0.00875017 0.09517580 0.00000000 1.00000000 0.00000000
128
+ v 0.05518003 -0.00874980 0.09440000 0.00000000 1.00000000 0.00000000
129
+ v 0.05518003 -0.00874980 0.11290000 0.00000000 1.00000000 0.00000000
130
+ v 0.03998003 -0.00874985 0.09440000 0.00000000 1.00000000 0.00000000
131
+ v 0.03998003 -0.00874985 0.11290000 0.00000000 1.00000000 0.00000000
132
+ v 0.05517997 0.00875020 0.09440000 0.00000000 1.00000000 0.00000000
133
+ v 0.05517997 0.00875020 0.11290000 0.00000000 1.00000000 0.00000000
134
+ v 0.03997997 0.00875015 0.09440000 0.00000000 1.00000000 0.00000000
135
+ v 0.03997997 0.00875015 0.11290000 0.00000000 1.00000000 0.00000000
136
+ v -0.05099996 -0.01100019 0.05940000 0.00000000 1.00000000 0.00000000
137
+ v -0.05099996 -0.01100019 0.07940000 0.00000000 1.00000000 0.00000000
138
+ v -0.06599996 -0.01100024 0.05940000 0.00000000 1.00000000 0.00000000
139
+ v -0.06599996 -0.01100024 0.07940000 0.00000000 1.00000000 0.00000000
140
+ v -0.05100004 0.01099981 0.05940000 0.00000000 1.00000000 0.00000000
141
+ v -0.05100004 0.01099981 0.07940000 0.00000000 1.00000000 0.00000000
142
+ v -0.06600004 0.01099976 0.05940000 0.00000000 1.00000000 0.00000000
143
+ v -0.06600004 0.01099976 0.07940000 0.00000000 1.00000000 0.00000000
144
+ v -0.04239996 -0.01100016 0.05870000 0.00000000 1.00000000 0.00000000
145
+ v -0.04239996 -0.01100016 0.06250000 0.00000000 1.00000000 0.00000000
146
+ v -0.05119996 -0.01100019 0.05870000 0.00000000 1.00000000 0.00000000
147
+ v -0.05119996 -0.01100019 0.06250000 0.00000000 1.00000000 0.00000000
148
+ v -0.04240004 0.01099984 0.05870000 0.00000000 1.00000000 0.00000000
149
+ v -0.04240004 0.01099984 0.06250000 0.00000000 1.00000000 0.00000000
150
+ v -0.05120004 0.01099981 0.05870000 0.00000000 1.00000000 0.00000000
151
+ v -0.05120004 0.01099981 0.06250000 0.00000000 1.00000000 0.00000000
152
+ v -0.05874388 -0.00875022 0.07482420 0.00000000 1.00000000 0.00000000
153
+ v -0.04699388 -0.00875017 0.09517580 0.00000000 1.00000000 0.00000000
154
+ v -0.06480606 -0.00875024 0.07832420 0.00000000 1.00000000 0.00000000
155
+ v -0.05305606 -0.00875019 0.09867580 0.00000000 1.00000000 0.00000000
156
+ v -0.05874394 0.00874978 0.07482420 0.00000000 1.00000000 0.00000000
157
+ v -0.04699394 0.00874983 0.09517580 0.00000000 1.00000000 0.00000000
158
+ v -0.06480612 0.00874976 0.07832420 0.00000000 1.00000000 0.00000000
159
+ v -0.05305612 0.00874981 0.09867580 0.00000000 1.00000000 0.00000000
160
+ v -0.03997997 -0.00875015 0.09440000 0.00000000 1.00000000 0.00000000
161
+ v -0.03997997 -0.00875015 0.11290000 0.00000000 1.00000000 0.00000000
162
+ v -0.05517997 -0.00875020 0.09440000 0.00000000 1.00000000 0.00000000
163
+ v -0.05517997 -0.00875020 0.11290000 0.00000000 1.00000000 0.00000000
164
+ v -0.03998003 0.00874985 0.09440000 0.00000000 1.00000000 0.00000000
165
+ v -0.03998003 0.00874985 0.11290000 0.00000000 1.00000000 0.00000000
166
+ v -0.05518003 0.00874980 0.09440000 0.00000000 1.00000000 0.00000000
167
+ v -0.05518003 0.00874980 0.11290000 0.00000000 1.00000000 0.00000000
168
+ f 1 2 3
169
+ f 4 5 6
170
+ f 7 6 8
171
+ f 9 10 11
172
+ f 10 3 11
173
+ f 12 1 3
174
+ f 13 6 14
175
+ f 8 6 5
176
+ f 4 6 13
177
+ f 15 16 17
178
+ f 18 19 13
179
+ f 20 7 8
180
+ f 21 6 7
181
+ f 22 23 24
182
+ f 25 26 27
183
+ f 28 10 9
184
+ f 2 29 3
185
+ f 29 11 3
186
+ f 30 16 15
187
+ f 31 32 33
188
+ f 34 35 36
189
+ f 37 3 38
190
+ f 6 39 40
191
+ f 25 8 5
192
+ f 41 42 16
193
+ f 18 14 43
194
+ f 22 24 44
195
+ f 44 45 22
196
+ f 46 22 45
197
+ f 45 47 46
198
+ f 20 8 48
199
+ f 9 11 15
200
+ f 36 10 49
201
+ f 50 28 9
202
+ f 10 36 35
203
+ f 12 37 32
204
+ f 37 12 3
205
+ f 37 38 51
206
+ f 37 52 32
207
+ f 52 37 51
208
+ f 10 35 53
209
+ f 10 53 38
210
+ f 10 38 3
211
+ f 54 35 34
212
+ f 55 56 57
213
+ f 56 55 58
214
+ f 57 54 34
215
+ f 39 6 21
216
+ f 59 18 43
217
+ f 40 39 43
218
+ f 14 40 43
219
+ f 14 6 40
220
+ f 60 25 5
221
+ f 59 61 17
222
+ f 42 60 5
223
+ f 16 42 17
224
+ f 62 22 46
225
+ f 22 62 23
226
+ f 1 46 63
227
+ f 47 63 46
228
+ f 25 64 8
229
+ f 64 25 27
230
+ f 64 48 8
231
+ f 65 59 43
232
+ f 66 36 49
233
+ f 63 2 1
234
+ f 33 32 67
235
+ f 68 62 46
236
+ f 32 69 12
237
+ f 70 71 67
238
+ f 53 35 54
239
+ f 72 73 74
240
+ f 75 76 77
241
+ f 78 55 34
242
+ f 55 79 58
243
+ f 4 42 5
244
+ f 50 15 17
245
+ f 59 17 18
246
+ f 7 20 77
247
+ f 24 26 44
248
+ f 27 26 24
249
+ f 45 80 47
250
+ f 41 16 81
251
+ f 82 71 70
252
+ f 62 83 23
253
+ f 33 84 83
254
+ f 49 28 61
255
+ f 61 28 50
256
+ f 61 50 17
257
+ f 63 85 29
258
+ f 85 30 15
259
+ f 80 85 63
260
+ f 85 80 30
261
+ f 9 15 50
262
+ f 85 15 11
263
+ f 11 29 85
264
+ f 83 62 68
265
+ f 34 36 78
266
+ f 70 67 86
267
+ f 71 33 67
268
+ f 58 76 72
269
+ f 73 87 88
270
+ f 72 74 56
271
+ f 56 74 57
272
+ f 34 55 57
273
+ f 21 79 55
274
+ f 21 7 79
275
+ f 89 73 90
276
+ f 39 21 55
277
+ f 91 82 70
278
+ f 84 23 83
279
+ f 28 49 10
280
+ f 92 80 93
281
+ f 31 33 68
282
+ f 68 33 83
283
+ f 51 86 52
284
+ f 69 32 31
285
+ f 46 94 68
286
+ f 94 31 68
287
+ f 94 12 69
288
+ f 76 75 72
289
+ f 90 53 89
290
+ f 86 51 88
291
+ f 75 70 87
292
+ f 87 70 88
293
+ f 95 55 78
294
+ f 39 55 95
295
+ f 14 18 13
296
+ f 42 41 60
297
+ f 19 17 42
298
+ f 19 4 13
299
+ f 45 93 80
300
+ f 92 93 81
301
+ f 16 30 92
302
+ f 92 81 16
303
+ f 96 77 20
304
+ f 66 59 65
305
+ f 65 78 66
306
+ f 59 66 61
307
+ f 49 61 66
308
+ f 2 63 29
309
+ f 80 63 47
310
+ f 80 92 30
311
+ f 78 36 66
312
+ f 94 69 31
313
+ f 94 46 1
314
+ f 12 94 1
315
+ f 70 86 88
316
+ f 67 52 86
317
+ f 52 67 32
318
+ f 57 89 54
319
+ f 53 90 38
320
+ f 90 51 38
321
+ f 75 87 72
322
+ f 72 87 73
323
+ f 56 58 72
324
+ f 42 4 19
325
+ f 19 18 17
326
+ f 97 81 44
327
+ f 81 93 44
328
+ f 41 97 60
329
+ f 26 97 44
330
+ f 98 99 91
331
+ f 100 33 71
332
+ f 84 33 100
333
+ f 96 75 77
334
+ f 95 78 65
335
+ f 73 89 74
336
+ f 53 54 89
337
+ f 73 88 51
338
+ f 90 73 51
339
+ f 41 81 97
340
+ f 44 93 45
341
+ f 25 97 26
342
+ f 101 48 64
343
+ f 64 27 101
344
+ f 102 48 101
345
+ f 96 98 91
346
+ f 48 99 20
347
+ f 99 98 20
348
+ f 98 96 20
349
+ f 96 91 75
350
+ f 84 101 23
351
+ f 27 24 101
352
+ f 89 57 74
353
+ f 77 76 79
354
+ f 79 76 58
355
+ f 79 7 77
356
+ f 102 84 100
357
+ f 91 99 82
358
+ f 48 100 99
359
+ f 100 48 102
360
+ f 75 91 70
361
+ f 84 102 101
362
+ f 24 23 101
363
+ f 43 95 65
364
+ f 25 60 97
365
+ f 82 100 71
366
+ f 43 39 95
367
+ f 100 82 99
368
+ f 104 106 103
369
+ f 107 104 103
370
+ f 103 106 105
371
+ f 105 107 103
372
+ f 104 110 106
373
+ f 108 104 107
374
+ f 108 110 104
375
+ f 106 110 105
376
+ f 109 107 105
377
+ f 105 110 109
378
+ f 109 108 107
379
+ f 110 108 109
380
+ f 112 114 111
381
+ f 115 112 111
382
+ f 111 114 113
383
+ f 113 115 111
384
+ f 112 118 114
385
+ f 116 112 115
386
+ f 116 118 112
387
+ f 114 118 113
388
+ f 117 115 113
389
+ f 113 118 117
390
+ f 117 116 115
391
+ f 118 116 117
392
+ f 120 122 119
393
+ f 123 120 119
394
+ f 119 122 121
395
+ f 121 123 119
396
+ f 120 126 122
397
+ f 124 120 123
398
+ f 124 126 120
399
+ f 122 126 121
400
+ f 125 123 121
401
+ f 121 126 125
402
+ f 125 124 123
403
+ f 126 124 125
404
+ f 128 130 127
405
+ f 131 128 127
406
+ f 127 130 129
407
+ f 129 131 127
408
+ f 128 134 130
409
+ f 132 128 131
410
+ f 132 134 128
411
+ f 130 134 129
412
+ f 133 131 129
413
+ f 129 134 133
414
+ f 133 132 131
415
+ f 134 132 133
416
+ f 136 138 135
417
+ f 139 136 135
418
+ f 135 138 137
419
+ f 137 139 135
420
+ f 136 142 138
421
+ f 140 136 139
422
+ f 140 142 136
423
+ f 138 142 137
424
+ f 141 139 137
425
+ f 137 142 141
426
+ f 141 140 139
427
+ f 142 140 141
428
+ f 144 146 143
429
+ f 147 144 143
430
+ f 143 146 145
431
+ f 145 147 143
432
+ f 144 150 146
433
+ f 148 144 147
434
+ f 148 150 144
435
+ f 146 150 145
436
+ f 149 147 145
437
+ f 145 150 149
438
+ f 149 148 147
439
+ f 150 148 149
440
+ f 152 154 151
441
+ f 155 152 151
442
+ f 151 154 153
443
+ f 153 155 151
444
+ f 152 158 154
445
+ f 156 152 155
446
+ f 156 158 152
447
+ f 154 158 153
448
+ f 157 155 153
449
+ f 153 158 157
450
+ f 157 156 155
451
+ f 158 156 157
452
+ f 160 162 159
453
+ f 163 160 159
454
+ f 159 162 161
455
+ f 161 163 159
456
+ f 160 166 162
457
+ f 164 160 163
458
+ f 164 166 160
459
+ f 162 166 161
460
+ f 165 163 161
461
+ f 161 166 165
462
+ f 165 164 163
463
+ f 166 164 165
464
+
embodied_gen/scripts/affordance_annot/franka_panda/gripper.urdf ADDED
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <!-- =================================================================================== -->
3
+ <!-- | This document was autogenerated by xacro from hand.urdf.xacro | -->
4
+ <!-- | EDITING THIS FILE BY HAND IS NOT RECOMMENDED | -->
5
+ <!-- =================================================================================== -->
6
+ <robot name="hand">
7
+ <link name="world"/>
8
+ <joint name="world_joint" type="fixed">
9
+ <parent link="world"/>
10
+ <child link="panda_hand"/>
11
+ <origin rpy="0 0 1.5708" xyz="0 0 0.0"/>
12
+ </joint>
13
+ <link name="panda_hand">
14
+ <visual>
15
+ <geometry>
16
+ <mesh filename="./meshes/visual/hand.dae"/>
17
+ </geometry>
18
+ </visual>
19
+ <collision>
20
+ <geometry>
21
+ <mesh filename="./meshes/collision/hand.stl"/>
22
+ </geometry>
23
+ </collision>
24
+ </link>
25
+ <!-- Define the hand_tcp frame -->
26
+ <link name="panda_hand_tcp"/>
27
+ <joint name="panda_hand_tcp_joint" type="fixed">
28
+ <origin rpy="0 0 0" xyz="0 0 0.1034"/>
29
+ <parent link="panda_hand"/>
30
+ <child link="panda_hand_tcp"/>
31
+ </joint>
32
+ <link name="panda_leftfinger">
33
+ <visual>
34
+ <geometry>
35
+ <mesh filename="./meshes/visual/finger.dae"/>
36
+ </geometry>
37
+ </visual>
38
+ <!-- screw mount -->
39
+ <collision>
40
+ <origin rpy="0 0 0" xyz="0 18.5e-3 11e-3"/>
41
+ <geometry>
42
+ <box size="22e-3 15e-3 20e-3"/>
43
+ </geometry>
44
+ </collision>
45
+ <!-- cartriage sledge -->
46
+ <collision>
47
+ <origin rpy="0 0 0" xyz="0 6.8e-3 2.2e-3"/>
48
+ <geometry>
49
+ <box size="22e-3 8.8e-3 3.8e-3"/>
50
+ </geometry>
51
+ </collision>
52
+ <!-- diagonal finger -->
53
+ <collision>
54
+ <origin rpy="0.523598775598 0 0" xyz="0 15.9e-3 28.35e-3"/>
55
+ <geometry>
56
+ <box size="17.5e-3 7e-3 23.5e-3"/>
57
+ </geometry>
58
+ </collision>
59
+ <!-- rubber tip with which to grasp -->
60
+ <collision>
61
+ <origin rpy="0 0 0" xyz="0 7.58e-3 45.25e-3"/>
62
+ <geometry>
63
+ <box size="17.5e-3 15.2e-3 18.5e-3"/>
64
+ </geometry>
65
+ </collision>
66
+ </link>
67
+ <link name="panda_rightfinger">
68
+ <visual>
69
+ <origin rpy="0 0 3.14159265359" xyz="0 0 0"/>
70
+ <geometry>
71
+ <mesh filename="./meshes/visual/finger.dae"/>
72
+ </geometry>
73
+ </visual>
74
+ <!-- screw mount -->
75
+ <collision>
76
+ <origin rpy="0 0 0" xyz="0 -18.5e-3 11e-3"/>
77
+ <geometry>
78
+ <box size="22e-3 15e-3 20e-3"/>
79
+ </geometry>
80
+ </collision>
81
+ <!-- cartriage sledge -->
82
+ <collision>
83
+ <origin rpy="0 0 0" xyz="0 -6.8e-3 2.2e-3"/>
84
+ <geometry>
85
+ <box size="22e-3 8.8e-3 3.8e-3"/>
86
+ </geometry>
87
+ </collision>
88
+ <!-- diagonal finger -->
89
+ <collision>
90
+ <origin rpy="-0.523598775598 0 0" xyz="0 -15.9e-3 28.35e-3"/>
91
+ <geometry>
92
+ <box size="17.5e-3 7e-3 23.5e-3"/>
93
+ </geometry>
94
+ </collision>
95
+ <!-- rubber tip with which to grasp -->
96
+ <collision>
97
+ <origin rpy="0 0 0" xyz="0 -7.58e-3 45.25e-3"/>
98
+ <geometry>
99
+ <box size="17.5e-3 15.2e-3 18.5e-3"/>
100
+ </geometry>
101
+ </collision>
102
+ </link>
103
+ <joint name="panda_finger_joint1" type="prismatic">
104
+ <parent link="panda_hand"/>
105
+ <child link="panda_leftfinger"/>
106
+ <origin rpy="0 0 0" xyz="0 0 0.0584"/>
107
+ <axis xyz="0 1 0"/>
108
+ <limit effort="1400" lower="0.0" upper="0.05268743" velocity="0.05"/>
109
+ <dynamics damping="0.3"/>
110
+ </joint>
111
+ <joint name="panda_finger_joint2" type="prismatic">
112
+ <parent link="panda_hand"/>
113
+ <child link="panda_rightfinger"/>
114
+ <origin rpy="0 0 0" xyz="0 0 0.0584"/>
115
+ <axis xyz="0 -1 0"/>
116
+ <limit effort="1400" lower="0.0" upper="0.05268743" velocity="0.05"/>
117
+ <mimic joint="panda_finger_joint1"/>
118
+ <dynamics damping="0.3"/>
119
+ </joint>
120
+ </robot>
embodied_gen/scripts/affordance_annot/franka_panda/meshes/collision/hand.stl ADDED
Binary file (10.1 kB). View file
 
embodied_gen/scripts/affordance_annot/franka_panda/meshes/visual/finger.dae ADDED
@@ -0,0 +1,178 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <COLLADA xmlns="http://www.collada.org/2005/11/COLLADASchema" version="1.4.1">
3
+ <asset>
4
+ <contributor>
5
+ <author>Blender User</author>
6
+ <authoring_tool>Blender 2.79.0</authoring_tool>
7
+ </contributor>
8
+ <created>2018-02-20T15:00:35</created>
9
+ <modified>2018-02-20T15:00:35</modified>
10
+ <unit name="meter" meter="1"/>
11
+ <up_axis>Z_UP</up_axis>
12
+ </asset>
13
+ <library_images/>
14
+ <library_effects>
15
+ <effect id="Part__Feature_007-effect">
16
+ <profile_COMMON>
17
+ <technique sid="common">
18
+ <phong>
19
+ <emission>
20
+ <color sid="emission">0 0 0 1</color>
21
+ </emission>
22
+ <ambient>
23
+ <color sid="ambient">0 0 0 1</color>
24
+ </ambient>
25
+ <diffuse>
26
+ <color sid="diffuse">0.2509804 0.2509804 0.2509804 1</color>
27
+ </diffuse>
28
+ <specular>
29
+ <color sid="specular">0.5 0.5 0.5 1</color>
30
+ </specular>
31
+ <shininess>
32
+ <float sid="shininess">0</float>
33
+ </shininess>
34
+ <transparent opaque="A_ONE">
35
+ <color>1 1 1 1</color>
36
+ </transparent>
37
+ <transparency><float>1</float></transparency>
38
+ <index_of_refraction>
39
+ <float sid="index_of_refraction">1</float>
40
+ </index_of_refraction>
41
+ </phong>
42
+ </technique>
43
+ </profile_COMMON>
44
+ </effect>
45
+ <effect id="Part__Feature001_006-effect">
46
+ <profile_COMMON>
47
+ <technique sid="common">
48
+ <phong>
49
+ <emission>
50
+ <color sid="emission">0 0 0 1</color>
51
+ </emission>
52
+ <ambient>
53
+ <color sid="ambient">0 0 0 1</color>
54
+ </ambient>
55
+ <diffuse>
56
+ <color sid="diffuse">0.9019608 0.9215686 0.9294118 1</color>
57
+ </diffuse>
58
+ <specular>
59
+ <color sid="specular">0.5 0.5 0.5 1</color>
60
+ </specular>
61
+ <shininess>
62
+ <float sid="shininess">0</float>
63
+ </shininess>
64
+ <transparent opaque="A_ONE">
65
+ <color>1 1 1 1</color>
66
+ </transparent>
67
+ <transparency><float>1</float></transparency>
68
+ <index_of_refraction>
69
+ <float sid="index_of_refraction">1</float>
70
+ </index_of_refraction>
71
+ </phong>
72
+ </technique>
73
+ </profile_COMMON>
74
+ </effect>
75
+ </library_effects>
76
+ <library_materials>
77
+ <material id="Part__Feature_007-material" name="Part__Feature_007">
78
+ <instance_effect url="#Part__Feature_007-effect"/>
79
+ </material>
80
+ <material id="Part__Feature001_006-material" name="Part__Feature001_006">
81
+ <instance_effect url="#Part__Feature001_006-effect"/>
82
+ </material>
83
+ </library_materials>
84
+ <library_geometries>
85
+ <geometry id="Part__Feature_007-mesh" name="Part__Feature.007">
86
+ <mesh>
87
+ <source id="Part__Feature_007-mesh-positions">
88
+ <float_array id="Part__Feature_007-mesh-positions-array" count="399">0.002999246 0.01089721 0.04464477 3.59715e-4 0.01089966 0.04796832 0.002338767 0.010885 0.04695528 0.00234574 0.01088672 0.04317915 0.001025557 0.01089477 0.04214996 0.001999616 0.01089692 0.04408597 8.26939e-4 0.01089149 0.04701918 0.002051293 0.01086962 0.0458244 9.84775e-4 0.01088815 0.04306632 -0.001819312 0.009437561 0.04387903 -1.12074e-5 0.00943017 0.04711711 -6.97705e-4 0.0094316 0.04301643 -0.001464784 0.009429216 0.0465517 -0.00208646 0.009431064 0.04526871 -1.55725e-4 0.009388387 0.04323405 0.001297712 0.009390771 0.04378831 1.56458e-4 0.009388387 0.04676586 0.001741111 0.009388566 0.04558634 -0.002886593 0.009435176 0.04399472 -0.001784682 0.009434103 0.0424965 5.2098e-4 0.009434282 0.04803037 -0.001791715 0.009433507 0.04755115 1.48399e-5 0.009437739 0.04194247 -0.002975225 0.009436786 0.04570305 -0.008930683 0.009314417 0.03609037 0.008931338 0.009313642 0.03609037 -8.60488e-4 0.001913011 0.04356497 -0.001740396 0.009388566 0.04441362 -0.00152558 0.001912534 0.04591751 -0.001296997 0.009390771 0.0462116 -9.70155e-4 0.010885 0.04216766 0.004843115 -1.70612e-5 0.05371719 0.008756995 -2.97773e-5 0.04727178 0.008758902 -2.87572e-5 0.04272794 -0.002268254 -3.64761e-5 0.05374616 -0.008709371 -1.03085e-5 0.04993945 -0.00875771 -2.8976e-5 0.0472719 -0.002273738 -3.68374e-5 0.03624957 -0.002243995 -7.46945e-5 0.0427556 -0.008758783 -3.04484e-5 0.04272389 0.002263069 -8.37643e-5 0.04726266 0.00226897 -3.64764e-5 0.05374616 3.65234e-7 0.001558899 0.05380403 3.6521e-7 0.001488626 0.04499995 3.65614e-7 0.001561164 0.03619641 0.002270221 -3.43831e-5 0.0362488 0.002248227 -7.64219e-5 0.04275208 -0.002262294 -8.37643e-5 0.04726266 -0.005691945 1.10341e-5 0.05360394 0.00686866 0.009352147 0.05310285 -0.007860779 0.009355306 0.05219143 0.003445267 0.009386718 0.04705679 0.008785963 0.009348154 0.0505712 0.003923535 0.009389936 0.04573357 -7.30475e-4 0.009391903 0.04108011 0.003038406 0.009433627 0.04465281 7.34002e-4 0.009389936 0.04107677 0.002057075 0.009386718 0.04155498 0.004730045 0.009351372 0.05389988 -7.33049e-4 0.009390354 0.04892301 -0.00205636 0.009386718 0.04844492 0.007075071 -1.88821e-5 0.05276924 0.008674919 -6.99964e-6 0.05031853 0.007492721 0.009395003 0.0442506 0.007331073 0.001441657 0.04408031 0.004115462 0.00143361 0.04404103 9.13617e-4 0.001440465 0.0409246 0.002675354 0.001427888 0.04171341 -9.16098e-4 0.001442193 0.03767275 -0.004073202 0.001442134 0.04408985 -0.003944575 0.009400427 0.04425376 -0.002707123 0.009383678 0.04193902 -0.003045201 0.001419723 0.04195654 -0.007492482 0.00939387 0.03750175 -9.09494e-4 0.001442074 0.04092669 -0.004972159 0.009392738 0.0524379 -0.007140517 0.001431167 0.05044227 -0.007258474 0.009387791 0.05068916 -0.007491827 0.009394824 0.04574936 -0.004114806 0.001433432 0.04595905 -9.13103e-4 0.001440107 0.04907548 -0.002675294 0.001426517 0.04828691 -9.16344e-4 0.001443028 0.05232822 -0.005090415 0.001432299 0.05222845 0.007365286 0.009393692 0.05032676 0.004924833 0.001437306 0.05226349 0.00534445 0.009385824 0.0523557 9.15562e-4 0.001443564 0.05232697 7.49809e-4 0.009395062 0.05249238 9.59386e-4 0.001433491 0.04911512 0.004075884 0.001440107 0.04591345 0.003287315 0.001426517 0.04767566 0.007309973 0.001435518 0.0495814 0.006608247 0.00141263 0.05116909 -0.001967191 0.0108897 0.04407691 -0.002455234 0.0108962 0.04326856 -7.23478e-4 0.01089477 0.04287755 0.002154886 0.009418427 0.04456418 7.21602e-4 0.009433567 0.04298967 0.001448452 0.009429216 0.04656761 8.61216e-4 0.00191313 0.04643499 0.001526296 0.001912474 0.0440824 -0.007818162 -3.97311e-5 0.05191779 -0.008771955 2.75426e-5 0.03626441 -0.008893072 0.009342074 0.04989963 0.008762598 1.08415e-5 0.03626585 -0.005339801 0.009355187 0.05381947 -0.007331967 0.00144273 0.04407978 -0.007496535 0.009394824 0.04424685 -0.007332861 0.001443088 0.03767186 -7.55759e-4 0.009394407 0.03750163 0.00254625 0.009436607 0.04679846 0.002077639 0.009434342 0.04267549 0.007333219 0.001442193 0.03767275 0.007498383 0.00939387 0.03750687 9.16392e-4 0.001442492 0.03767257 0.003923356 0.009390354 0.04426658 7.54132e-4 0.009395062 0.03750258 0.003375649 0.00939083 0.04281467 -0.007332205 0.001443505 0.04591983 -0.003922641 0.009390354 0.04573339 -7.53554e-4 0.009394705 0.05249726 -0.003374874 0.00939083 0.0471853 0.007332324 0.001442849 0.04592007 7.34002e-4 0.009389936 0.04892319 0.007497489 0.009394824 0.04575371 0.002185642 0.00939083 0.0483753 -0.002153158 0.01088654 0.0454204 -0.001070141 0.01088356 0.04698097 -0.003009378 0.01089417 0.04527872 -0.00187093 0.01090669 0.04741126 -0.008804023 0.001558899 0.04499995 0.008809328 0.001564621 0.04499995</float_array>
89
+ <technique_common>
90
+ <accessor source="#Part__Feature_007-mesh-positions-array" count="133" stride="3">
91
+ <param name="X" type="float"/>
92
+ <param name="Y" type="float"/>
93
+ <param name="Z" type="float"/>
94
+ </accessor>
95
+ </technique_common>
96
+ </source>
97
+ <source id="Part__Feature_007-mesh-normals">
98
+ <float_array id="Part__Feature_007-mesh-normals-array" count="786">0.9590276 0.08210206 0.2711559 0.9707201 -0.02025878 -0.2393575 0.356231 0.02584302 -0.9340405 0.2114398 0.06310987 -0.9753515 0.5969651 -0.02077841 0.8019981 0.8966066 0.08637297 -0.4343227 0.8202709 -0.05451494 -0.5693714 0.01862794 0.09478819 0.9953232 0.3569161 0.1740584 -0.917777 0.6076866 0.08563488 0.7895466 0.6939369 -0.0273537 0.7195162 0.9792594 0.07404083 0.1885975 0.9900662 0.03109622 0.1371198 -0.9979203 -0.0384593 -0.05173081 -0.9530383 0.1508886 -0.2625846 -0.2413043 0.07884323 0.9672415 -0.2028678 0.01909875 0.9790199 -0.8043072 -0.05650782 -0.5915207 -0.5880531 0.1500057 -0.7947906 -0.2937553 -0.05141419 -0.954497 -0.009515166 0.1474139 -0.9890291 -0.8421047 0.005896389 0.539282 -0.8765521 0.1154791 0.4672484 0.007623791 -0.9999444 0.00728625 0.003054559 -0.9999539 0.009112834 0.01089298 -0.9999382 0.002262771 0.008302092 -0.9999359 0.007699072 0.007297933 -0.9999547 -0.006123363 0.006982326 -0.9999549 -0.006440281 -0.0141775 -0.9998731 0.007279753 0.008410274 -0.9997746 0.01949483 -0.01479846 -0.999857 -0.008204162 -0.008424222 -0.999939 0.007150232 -0.006763219 -0.9999605 -0.005787611 -0.009926855 -0.9999107 -0.008956611 0.999624 0.005454719 0.02687352 0.9996895 -0.0124461 0.0215857 0.9999408 -0.004187285 0.01004832 0.9997202 -0.02283918 -0.006177306 0.9998357 -0.01812744 4.58244e-4 -0.9998302 -0.003178 0.01815414 -0.9997246 -0.01958203 0.01294118 -0.9997529 -0.02172666 -0.004701018 -0.9998528 -0.01705491 0.001882493 -0.9999096 -0.01316875 0.002750456 -0.5751125 -0.8180485 0.006493091 -0.5750488 -0.8180931 0.006525993 -0.5749478 -0.8181582 -0.007234573 -0.5759127 -0.8174836 -0.006735205 0.575049 -0.8180931 0.006525993 0.5751125 -0.8180485 0.006493151 0.5760833 -0.8173633 -0.006734192 0.5748195 -0.818247 -0.007387042 -0.002469599 -0.01878017 -0.9998206 -0.007602214 -0.02243256 -0.9997196 0.007440984 -0.02224701 -0.9997249 0.002750813 -0.01890742 -0.9998174 -8.40573e-7 -0.01367795 -0.9999065 -0.00798428 -0.007449865 0.9999404 -0.02916109 -0.0219537 0.9993337 -0.04118311 0.02231454 0.9989025 0.01138383 -0.02008819 0.9997335 0.0116266 -0.01935064 0.9997453 -0.03979802 0.9988015 -0.02849191 -0.03750216 0.998896 -0.02829426 0.03617066 0.9993457 1.13911e-6 0.01915013 0.9992586 0.03339964 -0.0110442 0.9997916 0.01717156 2.13537e-4 0.998414 -0.05629932 -0.01905494 0.9995746 0.02207905 0.05574911 0.9984449 -1.19817e-4 0.04555356 0.9989601 -0.00192207 4.14317e-5 0.9983577 -0.05728983 -3.92216e-4 0.9985226 -0.05433702 -0.05497872 0.9984876 -1.74394e-4 -0.04532593 0.9989706 -0.001876235 -0.03215342 0.9994451 0.008700311 0.04420334 0.9982572 0.03909951 0.01356661 0.9997569 0.01738047 -0.02931368 0.9995663 0.002813339 -8.40756e-4 0.9996371 0.02692854 0.02857166 0.9993905 0.02005881 -0.0197553 0.9981452 -0.05758422 0.04960417 0.9985613 0.02036315 -0.01980298 0.9991547 -0.03602528 0.03987985 0.9991744 0.007757186 -1.57562e-4 0.9995709 0.02929502 -0.03734642 0.9993024 9.92346e-5 0.04899358 0.9987992 2.78484e-4 0.001217305 0.9985316 -0.05415779 0.01685023 0.9989423 -0.04278373 0.01720976 0.9990151 -0.04089969 0.0420978 0.9989927 -0.01554173 1.35017e-4 0.9995306 0.0306375 -2.56052e-4 0.9999992 -0.001215219 0.001257061 0.9999992 -2.30649e-5 0.03529703 0.9992358 -0.01679521 -0.03647798 0.9984742 0.04145729 0.03809303 0.9983424 -0.04314595 -8.39925e-5 0.9984248 -0.0561068 -0.001366019 0.9999991 2.84488e-4 -0.03207468 0.9992364 0.02231508 0.03182566 0.9994928 0.00116384 -0.01953393 0.998728 0.04648566 -0.0483641 0.9986693 0.01790457 0.002701222 0.999723 0.02338445 -0.008154749 0.9992551 0.03772181 0.001346409 0.9999982 0.001443564 2.85219e-4 0.999999 -0.001440584 -4.26651e-4 0.9999998 7.07991e-4 -0.001589 0.9999756 0.006819844 -0.04870289 0.9987851 0.007510781 2.78449e-4 0.9987719 0.04954522 -0.001296997 0.9999992 1.20695e-4 -0.03349715 0.9994353 -0.002676308 0.009328544 0.999618 0.02601873 0.01659917 0.9988194 0.04565507 0.7971736 -0.003928363 0.6037375 0.8371688 -0.02469092 0.5463871 0.3907819 -0.02414345 0.9201667 0.349193 -0.01405209 0.9369456 -0.6207736 -0.02563965 0.7835705 -0.542501 0.001066267 0.8400545 -0.9117302 -0.01616036 0.4104716 -0.9116256 -0.01609712 0.4107064 -0.9997932 0.02033382 -3.35866e-4 -0.9997844 0.02074885 -8.37824e-4 0.01214045 0.02862757 -0.9995165 -0.004507839 0.02149677 -0.9997588 0.3398677 0.02567046 -0.940123 0.4086727 0.01358455 -0.91258 0.6901827 0.03923451 -0.7225708 0.8504014 -0.002022802 -0.5261305 0.935144 0.03257489 -0.3527673 0.9997449 0.02257227 8.67363e-4 0.9997735 0.02051991 0.005660176 -2.96537e-5 0.02086174 0.9997825 -6.34519e-4 0.02136081 0.9997717 0.006855368 0.9999764 6.32379e-4 4.84183e-5 0.9999936 0.00359565 -0.002482593 0.9999966 -9.32055e-4 -0.002998709 0.9999955 8.29423e-5 1.54639e-5 0.02138823 0.9997712 -1.39133e-4 0.02151739 0.9997684 -0.9997454 0.02247363 0.002029716 -0.9997687 0.02031034 0.00707817 -0.8811984 0.03637057 -0.4713457 -0.9005144 0.02349865 -0.4341907 -0.3984712 0.02666592 -0.9167931 -0.4344211 0.01645815 -0.9005595 0.001909852 0.02104699 -0.9997767 0.003094434 0.02054262 -0.9997842 0.9997859 0.02069485 -1.41352e-4 0.9997981 0.02008581 5.95311e-4 1.38179e-4 0.9999859 0.005308985 0.005393385 0.9999855 5.00562e-5 -0.01043295 0.9999456 5.47132e-5 2.11252e-4 0.9999458 -0.01041203 0.6565264 0.03311085 -0.7535761 0.6074793 0.01187402 -0.7942469 0.9989751 0.01611906 -0.04229778 0.998706 0.01903963 -0.04715883 0.004506647 0.02152347 0.9997582 -0.01208919 0.02863895 0.9995168 -0.3397133 0.02572649 0.9401772 -0.4084981 0.01364552 0.9126572 -0.6901832 0.03932148 0.7225656 -0.8505302 -0.001964092 0.5259227 -0.9351432 0.03258693 0.3527681 -0.9997715 0.02059018 -0.005760371 -0.9997436 0.0226224 -0.001016139 0.02383154 0.02594166 -0.9993794 0.01405942 0.02096039 -0.9996815 0.003094553 0.9999918 0.002593219 0.001917719 0.9999966 0.001798987 4.78276e-4 0.9999992 -0.001183092 -0.005550026 0.9999728 -0.004876852 -0.002536773 0.9999964 -8.84468e-4 -0.9139914 0.04412901 -0.4033268 -0.5442567 0.0384522 -0.8380371 -0.7084817 -0.007274329 -0.7056916 -0.0296927 0.02016824 -0.9993556 -0.01581388 0.01243138 -0.9997978 0.9997709 0.02093356 -0.00445789 0.999498 0.02863979 0.01354718 0.9401232 0.0257008 0.3398647 0.9126565 0.01364713 0.4084995 0.7225604 0.03932052 0.6901888 0.5259218 -0.001965582 0.8505307 0.3529133 0.03255659 0.9350895 -0.002043247 0.02258056 0.9997431 -0.005658864 0.02102994 0.9997629 -0.9999532 0.00752151 -0.006095588 -0.9993792 0.02015662 -0.02889633 0.001256287 0.9999954 -0.002805233 0.001515209 0.9999939 -0.00313121 -0.004870176 0.9999728 0.005549013 -8.41709e-4 0.9999977 0.001997113 -0.007501363 0.9999103 0.01110303 0.01065987 0.9999243 -0.006161391 0.002291321 0.9999756 0.006608426 0.00793755 0.9999635 0.003200709 -0.002152085 0.9999123 -0.01307111 0.008297443 0.9999594 0.003510475 -0.005011558 0.9999145 -0.01207667 0.003060996 0.9999685 0.007328271 0.01080811 0.9998736 -0.01166355 -0.01513278 0.9998643 0.0065158 0.0114302 0.9999113 -0.006848156 0.02179026 0.9996752 -0.01321882 0.004884779 0.9999445 -0.009344935 -0.003964126 0.9999364 -0.010562 0.01132041 0.9994015 -0.03268706 -0.00926429 0.9998297 0.01596432 -0.0349425 0.9993784 -0.004681348 0.01821058 0.9998341 3.55366e-4 4.26147e-4 0.9999064 -0.01367837 0.974332 0.0273106 0.2234531 0.9557826 0.1073004 0.2737999 0.3330882 -0.09754341 -0.9378367 0.5195872 -0.01804101 0.8542271 0.6077356 0.157705 -0.7783231 0.454423 0.08751672 0.8864765 0.9130505 -0.02745264 -0.4069218 0.8992295 0.02166306 -0.4369408 -0.7316485 0.1390356 0.6673527 0.02041035 -0.07851171 -0.9967043 -0.7062311 0.09077769 0.7021376 -0.3488763 0.1379652 -0.9269579 -0.1099019 -0.03246331 0.9934122 -0.6970786 -0.07612544 -0.7129421 -0.9202278 0.2171145 -0.3256413 -0.9950422 -0.0954045 0.02808898 -0.9590265 0.08210206 -0.2711592 -0.9707195 -0.0202586 0.2393601 -0.3562378 0.02584266 0.9340379 -0.2114403 0.06310951 0.9753514 -0.5969607 -0.02077889 -0.8020014 0.2474833 0.9674915 0.05208033 -0.2217323 0.9520062 0.2109952 0.002188146 0.9818791 0.1894964 0.03618705 0.9948542 -0.094635 -0.03970462 0.9977954 -0.0531792 0.1493319 0.9880552 -0.03803724 0.05737549 0.9980043 -0.02637308 0.03952348 0.9942676 -0.09934759 0.06763625 0.9944091 0.08109295 0.05163925 0.9962646 0.06921261 -0.5420612 0.8172764 -0.1955223 -0.05316966 0.9883101 -0.1428857 -0.04296433 0.9990067 0.01181876 -0.03037488 0.9966946 0.07534843 2.75087e-4 1 -2.00694e-4 -2.71444e-4 1 1.27208e-4 -0.006520807 -0.8174653 0.5759411 -0.008352398 -0.819939 0.5723901 -0.007724702 -0.8196955 -0.5727477 -0.006526708 -0.8180925 -0.5750496 0.007609963 -0.8185901 -0.5743277 0.007052063 -0.8178415 -0.5754005 0.007047355 -0.8173345 0.5761204 0.008122503 -0.8187863 0.574041</float_array>
99
+ <technique_common>
100
+ <accessor source="#Part__Feature_007-mesh-normals-array" count="262" stride="3">
101
+ <param name="X" type="float"/>
102
+ <param name="Y" type="float"/>
103
+ <param name="Z" type="float"/>
104
+ </accessor>
105
+ </technique_common>
106
+ </source>
107
+ <vertices id="Part__Feature_007-mesh-vertices">
108
+ <input semantic="POSITION" source="#Part__Feature_007-mesh-positions"/>
109
+ </vertices>
110
+ <triangles material="Part__Feature_007-material" count="262">
111
+ <input semantic="VERTEX" source="#Part__Feature_007-mesh-vertices" offset="0"/>
112
+ <input semantic="NORMAL" source="#Part__Feature_007-mesh-normals" offset="1"/>
113
+ <p>26 0 27 0 28 0 27 1 29 1 28 1 29 2 16 2 28 2 28 3 16 3 100 3 14 4 27 4 26 4 13 5 127 5 12 5 127 6 128 6 12 6 98 7 96 7 11 7 12 8 128 8 10 8 11 9 96 9 9 9 96 10 94 10 9 10 9 11 94 11 13 11 94 12 127 12 13 12 18 13 23 13 129 13 18 14 129 14 95 14 21 15 1 15 130 15 21 16 20 16 1 16 19 17 18 17 95 17 19 18 95 18 30 18 22 19 19 19 30 19 22 20 30 20 4 20 23 21 21 21 130 21 23 22 130 22 129 22 40 23 31 23 41 23 40 24 61 24 31 24 62 25 61 25 40 25 32 26 62 26 40 26 105 27 33 27 46 27 105 28 46 28 45 28 47 29 34 29 48 29 47 30 48 30 102 30 47 31 102 31 35 31 47 32 35 32 36 32 37 33 38 33 39 33 37 34 39 34 103 34 132 35 62 35 32 35 132 36 52 36 62 36 25 37 52 37 132 37 33 38 25 38 132 38 105 39 25 39 33 39 131 40 36 40 35 40 104 41 131 41 35 41 24 42 39 42 131 42 24 43 103 43 39 43 24 44 131 44 104 44 40 45 41 45 42 45 43 46 40 46 42 46 44 47 45 47 46 47 44 48 46 48 43 48 47 49 43 49 42 49 47 50 42 50 34 50 44 51 43 51 38 51 37 52 44 52 38 52 24 53 37 53 103 53 44 54 37 54 24 54 25 55 45 55 44 55 25 56 105 56 45 56 25 57 44 57 24 57 42 58 58 58 106 58 42 59 106 59 48 59 42 60 48 60 34 60 31 61 42 61 41 61 31 62 58 62 42 62 18 63 71 63 70 63 18 64 19 64 71 64 52 65 63 65 125 65 126 66 111 66 20 66 106 67 77 67 50 67 114 68 25 68 117 68 106 69 75 69 77 69 63 70 25 70 114 70 52 71 25 71 63 71 25 72 24 72 117 72 117 73 24 73 110 73 73 74 24 74 108 74 108 75 24 75 104 75 50 76 77 76 104 76 126 77 51 77 111 77 49 78 84 78 86 78 77 79 78 79 104 79 106 80 121 80 75 80 49 81 52 81 84 81 22 82 71 82 19 82 51 83 53 83 111 83 22 84 54 84 71 84 111 85 53 85 55 85 106 86 88 86 121 86 78 87 108 87 104 87 53 88 116 88 55 88 22 89 56 89 54 89 112 90 57 90 22 90 22 91 57 91 56 91 116 92 118 92 55 92 106 93 58 93 88 93 88 94 59 94 121 94 78 95 120 95 108 95 55 96 118 96 112 96 60 97 21 97 122 97 112 98 118 98 57 98 73 99 110 99 24 99 53 100 125 100 116 100 21 101 23 101 122 101 52 102 125 102 84 102 59 103 21 103 60 103 122 104 23 104 120 104 58 105 86 105 88 105 59 106 20 106 21 106 56 107 117 107 54 107 88 108 124 108 59 108 54 109 117 109 110 109 120 110 70 110 108 110 120 111 23 111 70 111 124 112 20 112 59 112 125 113 63 113 116 113 23 114 18 114 70 114 49 115 86 115 58 115 126 116 20 116 124 116 52 117 49 117 61 117 52 118 61 118 62 118 49 119 31 119 61 119 49 120 58 120 31 120 50 121 102 121 48 121 106 122 50 122 48 122 104 123 35 123 102 123 50 124 104 124 102 124 63 125 113 125 64 125 114 126 113 126 63 126 116 127 64 127 65 127 63 128 64 128 116 128 66 129 56 129 57 129 67 130 66 130 57 130 67 131 57 131 118 131 65 132 67 132 118 132 65 133 118 133 116 133 115 134 56 134 66 134 115 135 117 135 56 135 113 136 114 136 115 136 115 137 114 137 117 137 67 138 115 138 66 138 113 139 115 139 67 139 64 140 67 140 65 140 64 141 113 141 67 141 109 142 110 142 73 142 68 143 110 143 109 143 54 144 68 144 74 144 110 145 68 145 54 145 72 146 70 146 71 146 72 147 69 147 70 147 74 148 71 148 54 148 74 149 72 149 71 149 70 150 107 150 108 150 70 151 69 151 107 151 109 152 108 152 107 152 109 153 73 153 108 153 72 154 68 154 109 154 72 155 109 155 107 155 74 156 68 156 72 156 69 157 72 157 107 157 76 158 77 158 83 158 77 159 75 159 83 159 119 160 77 160 76 160 119 161 78 161 77 161 119 162 120 162 78 162 79 163 120 163 119 163 80 164 59 164 60 164 81 165 80 165 60 165 81 166 60 166 122 166 79 167 81 167 122 167 79 168 122 168 120 168 59 169 82 169 121 169 59 170 80 170 82 170 75 171 82 171 83 171 121 172 82 172 75 172 79 173 119 173 76 173 81 174 79 174 76 174 83 175 81 175 76 175 83 176 80 176 81 176 82 177 80 177 83 177 93 178 84 178 92 178 85 179 86 179 93 179 86 180 84 180 93 180 86 181 87 181 88 181 86 182 85 182 87 182 87 183 124 183 88 183 89 184 124 184 87 184 90 185 53 185 51 185 91 186 90 186 51 186 91 187 51 187 126 187 89 188 91 188 126 188 89 189 126 189 124 189 123 190 53 190 90 190 123 191 125 191 53 191 84 192 123 192 92 192 125 193 123 193 84 193 85 194 91 194 89 194 85 195 89 195 87 195 92 196 90 196 91 196 92 197 123 197 90 197 93 198 92 198 91 198 93 199 91 199 85 199 96 200 95 200 94 200 94 201 95 201 129 201 96 202 30 202 95 202 127 203 94 203 129 203 96 204 4 204 30 204 8 205 4 205 96 205 127 206 129 206 130 206 5 207 4 207 8 207 3 208 4 208 5 208 128 209 127 209 130 209 0 210 3 210 5 210 1 211 6 211 128 211 1 212 128 212 130 212 0 213 5 213 7 213 2 214 0 214 7 214 1 215 7 215 6 215 2 216 7 216 1 216 111 217 55 217 0 217 111 218 0 218 2 218 112 219 22 219 4 219 20 220 111 220 2 220 112 221 4 221 3 221 20 222 2 222 1 222 112 223 3 223 0 223 55 224 112 224 0 224 97 225 5 225 98 225 128 226 6 226 10 226 5 227 8 227 98 227 10 228 6 228 99 228 8 229 96 229 98 229 6 230 7 230 99 230 99 231 7 231 97 231 7 232 5 232 97 232 100 233 17 233 101 233 17 234 15 234 101 234 15 235 14 235 101 235 101 236 14 236 26 236 16 237 17 237 100 237 27 238 9 238 13 238 15 239 97 239 98 239 14 240 98 240 11 240 10 241 29 241 12 241 99 242 17 242 16 242 29 243 27 243 12 243 12 244 27 244 13 244 10 245 16 245 29 245 27 246 11 246 9 246 27 247 14 247 11 247 99 248 97 248 17 248 10 249 99 249 16 249 17 250 97 250 15 250 15 251 98 251 14 251 100 252 101 252 26 252 100 253 26 253 28 253 38 254 43 254 131 254 38 255 131 255 39 255 47 256 36 256 131 256 43 257 47 257 131 257 132 258 32 258 40 258 132 259 40 259 43 259 132 260 43 260 46 260 33 261 132 261 46 261</p>
114
+ </triangles>
115
+ </mesh>
116
+ </geometry>
117
+ <geometry id="Part__Feature001_006-mesh" name="Part__Feature001.006">
118
+ <mesh>
119
+ <source id="Part__Feature001_006-mesh-positions">
120
+ <float_array id="Part__Feature001_006-mesh-positions-array" count="555">-9.00074e-4 0.009423434 0.04836726 -0.003461062 0.009425997 0.04545509 0.003367424 0.009420156 0.04579621 0.002354204 0.009420096 0.04753577 0.001209616 0.009421825 0.04820466 -0.002568781 0.009414196 0.04729557 -0.01040536 0.01838725 0.003964126 0.01030278 0.01967525 0.003999948 -0.01030677 0.0197041 0.003999948 -0.01032835 0.01741087 0.006311535 0.01033771 0.01741051 0.006348371 -0.01034963 0.002563178 0.001799941 -0.01046431 0.008072018 0.00174117 -0.01036626 0.002524316 1.33332e-4 -0.01033806 0.008028566 0.003838419 -0.01049768 0.01066809 0.003808438 -0.01037496 0.01157689 0.01777273 -0.01037907 0.01279222 0.01885205 -0.01040232 0.01741403 0.004769504 -0.01033318 0.01621478 0.01882749 -0.0105009 0.01703566 0.01771438 -0.01046431 0.02075433 0.00174117 -0.01025623 0.02079778 0.003920257 -0.01039052 0.02630412 1.63633e-4 -0.01034963 0.02626317 0.001799941 -0.005033195 0.005965054 0.003874957 0.006783962 0.007870256 0.003922104 0.01033878 0.008028566 0.003838419 -0.006688237 0.007825791 0.003912925 -0.004874587 0.002588212 0.003874957 0.004875361 0.002538204 0.003824949 0.004882395 0.006314396 0.00399816 0.01035034 0.002563178 0.001799941 0.004861891 0.002482414 0.001761496 0.0103783 0.002535223 1.52542e-4 -0.004861116 0.002482414 0.001761496 0.0103892 0.02627986 1.33333e-4 0.004900753 0.02634549 0.001760601 0.01035034 0.02626317 0.0018 -0.004838049 0.0262978 0.003838419 0.004838824 0.02625167 0.003884613 -0.004913091 0.02631509 0.001778841 -0.004740357 0.02308672 0.003991544 0.004736721 0.02311271 0.003989934 0.01039779 0.0114066 0.004751801 -0.01040315 0.01144498 0.004798948 0.01038271 0.01261192 0.0187999 0.0102685 0.02076315 0.00393176 0.01045852 0.02080255 0.001745045 0.01046502 0.008072018 0.00174117 0.0104981 0.01065367 0.00379908 0.01040905 0.01161694 0.01771855 0.01049864 0.01723229 0.004730284 0.01032787 0.01605618 0.01887345 0.0104016 0.01821219 0.003997564 0.01050156 0.01703572 0.01771444 0.007072865 0.02100592 0.001899957 0.005206525 0.0225414 0.001899957 0.005030691 0.02334862 0.003700613 0.005556464 0.02213406 0.003700196 0.00697416 0.02102053 0.003698945 0.00515145 0.006138801 0.001899957 0.006992816 0.007812917 0.001899957 0.00512129 0.005962073 0.003693938 -0.00520581 0.0225414 0.001899957 -0.007104516 0.02099138 0.001899957 -0.006963312 0.02102977 0.003688812 -0.005555689 0.02213406 0.003700196 -0.005031466 0.02334022 0.003702998 -0.005051195 0.005644857 0.001899957 -0.005777955 0.006963193 0.001899957 -0.007155239 0.007843613 0.001899957 0.0102126 0.01028144 0.004017531 -0.01020997 0.01025789 0.004015088 0.01020628 0.01744186 0.005109727 0.01019817 0.01733398 0.0177704 -0.01019358 0.01735031 0.01768088 -0.006566226 0.02081871 0.003997445 0.006602585 0.02079534 0.003997147 -0.010293 0.02316558 0.004009008 -0.01038885 0.02522027 0.01918762 0.01040047 0.02525341 0.01903659 -0.008802294 0.01565176 0.0359255 0.008803725 0.01566779 0.03590935 -0.00874871 0.01441204 0.0360046 0.008746623 0.01441162 0.0360043 -0.005330324 0.01430171 0.05381 -0.007801711 0.01423847 0.05229121 0.006546616 0.01429051 0.05324614 0.004800438 0.01433104 0.05383157 0.008537709 0.01441401 0.05053359 -0.008656799 0.01441329 0.04983228 -0.007084131 0.009504258 0.05282139 -0.008779346 0.009461343 0.05011796 -0.005467712 0.009490966 0.05374723 -0.008745193 0.009413957 0.03600448 -0.003062069 0.009426355 0.04349088 0.005491077 0.009412288 0.05355769 0.007737815 0.009533464 0.05224508 0.008802413 0.00950849 0.05020254 0.008748948 0.009413778 0.03600472 -0.009015202 0.008043587 0.03574204 0.009009778 0.008124589 0.03584498 -0.01019251 0.01739948 0.01925081 0.008962452 0.008020699 0.03557926 0.01018828 0.01739251 0.01925343 -0.01036179 0.02530467 0.006144404 0.01038175 0.02526181 0.006118535 0.01029372 0.02316558 0.004009008 -0.01047909 0.01767802 0.01932358 -0.009055435 0.015468 0.03570801 -0.009049296 0.009711623 0.03573459 -0.009055495 0.01410454 0.03572821 0.008973121 0.0141133 0.04968112 0.009049952 0.009715855 0.03573578 0.00905615 0.015468 0.03570801 0.00905627 0.01410877 0.03572702 0.01047974 0.01767796 0.01932418 -0.008394658 0.009712696 0.0514611 -0.008945405 0.01410996 0.04996031 0.008197128 0.01411491 0.05185294 0.005532205 0.009717047 0.05385756 -0.00195378 0.009417772 0.04215568 0.003167867 0.009425044 0.04377222 -2.00269e-6 0.01280522 0.04499375 1.44479e-4 0.00943315 0.04151052 0.001943349 0.009422361 0.04219353 -0.009823203 0.01750212 0.01110798 -0.008970022 0.01753544 0.00949794 -0.008382201 0.02041316 0.008978068 -0.006987035 0.02041316 0.01564162 -0.007580995 0.01771301 0.01545852 -0.007902503 0.01771277 0.008742332 -0.008936882 0.02041321 0.01448625 -0.006165742 0.01771926 0.008206784 -0.005999624 0.02041316 0.008207798 -0.009010076 0.01771265 0.01427406 -0.009712457 0.01771759 0.01274335 -0.009828627 0.02041321 0.01161444 -0.008163809 0.01741361 0.01548886 -0.002160131 0.01771426 0.01179015 -0.001917064 0.0174154 0.01287972 -0.009805202 0.01741385 0.01357632 -0.002761185 0.01771289 0.01393586 -0.003453075 0.01741337 0.01519817 -0.003756344 0.01771312 0.01503348 -0.005418896 0.01771914 0.01575213 -0.005659103 0.01741391 0.01611226 -0.002533555 0.01741403 0.009761214 -0.007441639 0.01741355 0.008155763 -0.004799664 0.01741427 0.008044362 -0.00424081 0.01770812 0.008646845 -0.002989172 0.01771265 0.009725868 -0.003062367 0.02041316 0.01448625 -0.003617048 0.02041316 0.008978068 -0.00524491 0.02041316 0.01571643 -0.002170562 0.02041321 0.01161444 0.002471983 0.01753765 0.01027405 0.003617763 0.02041316 0.008978068 0.005012929 0.02041316 0.01564162 0.004418909 0.01771301 0.01545852 0.004097461 0.01771277 0.008742332 0.003063082 0.02041316 0.01448625 0.005834221 0.01771926 0.008206784 0.006000339 0.02041316 0.008207798 0.002989888 0.01771265 0.01427406 0.002158105 0.01760274 0.01248186 0.002171337 0.02041321 0.01161444 0.002778768 0.01741474 0.01464384 0.009839832 0.01771426 0.01179015 0.009967148 0.01741391 0.01086372 0.009844422 0.01741361 0.01344197 0.009238719 0.01771289 0.01393586 0.008363068 0.01759368 0.01505017 0.006581008 0.01771914 0.01575213 0.00593394 0.01741504 0.01617604 0.006598711 0.01741325 0.00796616 0.008496105 0.01741367 0.008740007 0.004167318 0.01741486 0.008277237 0.007759153 0.01770812 0.008646845 0.009010791 0.01771265 0.009725868 0.008937597 0.02041316 0.01448625 0.008382916 0.02041316 0.008978068 0.006755053 0.02041316 0.01571643 0.009829342 0.02041321 0.01161444</float_array>
121
+ <technique_common>
122
+ <accessor source="#Part__Feature001_006-mesh-positions-array" count="185" stride="3">
123
+ <param name="X" type="float"/>
124
+ <param name="Y" type="float"/>
125
+ <param name="Z" type="float"/>
126
+ </accessor>
127
+ </technique_common>
128
+ </source>
129
+ <source id="Part__Feature001_006-mesh-normals">
130
+ <float_array id="Part__Feature001_006-mesh-normals-array" count="1080">-0.6173208 -0.6997168 -0.3595991 -0.3576143 -0.707046 -0.6100803 -0.05557465 -0.6977123 -0.7142192 0.3884154 -0.7004421 -0.5987607 0.6412798 -0.6994922 -0.3153902 -3.80453e-5 -0.02722054 0.9996295 -0.001623749 -0.00173664 0.9999972 -0.001385629 0.9999968 0.002131819 -2.94082e-5 0.9996801 0.02529293 -0.9997308 -0.02070355 0.01047992 -0.996492 -0.05960285 0.05874812 -0.9995005 -0.0228253 0.0218603 -0.9997318 -0.02303814 0.002409517 -0.9999711 1.03593e-4 -0.007609546 -0.9871179 0.01438945 0.1593462 -0.9997745 0.01160097 0.01778519 -0.9955217 6.06525e-4 0.09453237 -0.9998695 0 -0.01615536 -0.9992002 0.01117813 0.03839474 -0.9936029 0.06317126 0.09361022 -0.9989078 0 -0.04672628 -0.9983385 -9.44458e-4 -0.05761367 -0.9994639 0.02053815 0.02549898 0.02691906 -0.07772469 0.9966114 -7.57925e-5 -0.03944176 0.9992219 -5.39687e-4 -0.04273873 0.9990862 -0.02465045 -0.07755041 0.9966837 -0.01240146 -5.80474e-4 0.999923 0.004887759 -0.04581922 0.9989379 -8.45258e-4 0.04984575 0.9987566 -0.0113424 -0.03047823 0.9994711 0.01459175 -0.9997453 0.017219 0 -0.999635 0.02701693 -0.004865527 -0.9987398 0.04995161 -0.01454573 -0.9996188 0.02346885 5.54909e-4 -0.9995224 -0.03089886 0 -0.9996694 -0.02571326 0.01502466 0.9998336 0.01034975 0.004553616 0.9990093 0.04427027 -0.003079414 0.9999591 0.008511543 -0.009448766 0.9996371 0.02522802 0.00111252 0.999331 -0.03655552 -0.003088533 0.9999886 0.003658115 -0.004537403 0.04749792 0.998861 8.04919e-5 0.0335403 0.9994374 9.2647e-4 -8.09439e-4 -0.9999993 -0.001456677 0.001272737 -0.9999982 0.001968502 -0.9998666 0.01621508 -0.001822233 -0.9999467 0.01016861 -0.00216782 0.007191956 0.9999718 0.002324879 -0.02130818 0.9997704 0.9947002 0.05414348 0.08740782 0.9991514 5.23699e-4 -0.04118567 0.9986065 -5.02086e-4 -0.05277365 0.9996373 -0.02062958 0.01731967 0.9995319 0.0195676 0.02351826 0.9964926 -0.05959349 0.05874836 0.9998502 -0.01730716 -4.88135e-4 0.9998542 -0.0170713 -5.9048e-4 0.9990535 -0.01702678 0.04002922 0.9873026 0.01232677 0.1583726 0.9928981 -0.01674908 0.1177833 0.9937262 0.01559793 -0.1107475 0.9998602 5.16274e-4 -0.01671421 0.9991797 0.01174086 0.03876018 0.0451585 -0.009151577 0.998938 0.01034229 0.01257103 0.9998675 -0.006676495 0.03607821 0.9993267 0.9141238 0.39574 -0.08813399 0.6350729 0.7719225 0.02860087 0.616694 0.7850795 0.05778169 0.9974451 0.06390476 0.03193026 0.9947919 0.08216506 0.06031489 0.05975353 0.9982017 -0.004800856 0.07612389 0.9967962 0.02454829 -0.006453335 -0.03733032 0.9992822 0.04496628 0.009730398 0.9989412 0.01094931 -0.01204329 0.9998676 0.6699684 -0.7369019 0.09009993 0.7559792 -0.652562 -0.05155879 0.07367104 -0.9971508 -0.0162183 0.04523348 -0.998431 0.03301161 0.9974095 -0.07126808 0.009756922 0.9968822 -0.07878315 -0.004374206 -0.04653424 -0.00969702 0.9988697 0.00358504 0.03181004 0.9994875 -0.01030498 0.01262295 0.9998673 -0.6320471 0.7742158 0.03326278 -0.6164938 0.7852315 0.05785328 -0.9135053 0.397269 -0.08766609 -0.07136505 0.9973254 -0.01578521 -0.06973493 0.9974779 -0.01322406 -0.9973001 0.06351983 0.03685021 -0.9949859 0.07914489 0.06114923 0.006362855 -0.04335838 0.9990394 0 0 1 -0.01212346 -0.0116012 0.9998593 -0.04727256 0.009681224 0.9988352 -0.8725091 -0.4809787 0.08595079 -0.7466658 -0.6630428 -0.05352085 -0.5349152 -0.8368087 0.1166919 -0.9987596 -0.04689872 0.01672834 -0.9982018 -0.0598486 -0.003362953 -0.06805241 -0.9975442 -0.01656448 -0.05555498 -0.9984475 0.004042565 8.8907e-4 -0.5465997 0.8373936 5.33059e-4 -0.5509776 0.83452 0.004123806 0.6375477 0.7703999 -0.01049071 0.8228656 0.5681393 -0.002518236 0.7106881 0.7035028 0.003334283 0.6536505 0.7567892 -0.003888607 -0.6640311 0.7476949 0.003183066 -0.7358403 0.6771478 -0.01673263 0.8151549 0.5790015 -0.373636 0.4680295 0.8008399 -0.7724587 0.6221879 0.1272386 -0.5668749 0.2445532 0.7866681 -0.6982629 0.01288533 0.7157256 -0.9592375 0.06434804 0.2751776 -0.7147101 0.6993146 0.0121932 -0.4388056 0.8982897 0.02291625 -0.5722144 0.6164847 0.5408489 0.7010007 0.001137077 0.7131597 0.5716685 0.2472016 0.7823597 0.7760108 0.6250861 0.08411097 0.3656786 0.464663 0.8064537 0.01790195 0.7870998 0.6165658 -0.8352044 -0.3911832 0.3865352 0.8707776 0.05801951 0.4882419 -0.6089326 -0.02781635 0.7927342 0.8206587 0.3920653 0.4156971 0.5498748 0.8344168 -0.03723388 0.7022116 0.7118432 0.01335102 0.7825589 0.6225079 0.009247243 0.6339415 0.5412512 0.5524178 0.825253 -0.3975813 0.4011068 0.6290233 -0.02652961 0.7769336 0.7685624 -0.03074413 0.6390359 0.3894513 -0.4363483 0.8111275 -0.00314182 -6.49825e-5 -0.9999951 0.02068835 0.03777194 -0.9990723 -3.36067e-4 0.8681395 0.4963201 0.002206206 0.8693922 0.4941177 8.46412e-4 0.07534265 0.9971574 1.67392e-5 0.0636698 0.9979711 -0.08732438 0.9910644 0.1008257 -0.002960801 0.9995301 0.03051143 -0.001022875 0.9997116 0.02399551 2.38456e-5 1 -1.64642e-4 -4.06208e-5 1 -8.76669e-5 0.05079007 0.9952879 0.08259797 -0.002142012 0.005291461 0.9999838 -0.009764373 -0.01276755 0.9998709 -2.90966e-4 -0.9998711 0.01605635 -0.01195007 -0.9999074 0.006530165 -0.001630723 -0.9999364 0.01116681 -0.008608043 -0.9999605 -0.00224334 -0.007068097 -0.9999536 0.006551086 -0.003706216 -0.9999876 0.003347218 0.001899778 -0.9999982 2.12748e-4 -8.44581e-4 -0.9999991 -0.001112341 -0.0017578 -0.9999984 -3.81976e-4 -0.003001391 -0.9999879 0.003933608 0.04073494 -0.9989163 -0.02251619 0.007122337 -0.9998478 0.01593202 0.005625069 -0.9999686 -0.005591452 0.0110715 -0.9999184 0.006386518 -0.00471729 -0.9999871 -0.001937091 1.70855e-4 -0.9999988 0.001574873 0.01087236 -0.9999189 0.006631433 -0.004614412 -0.9999826 -0.003687381 -1.05398e-5 -0.9999939 0.003507912 -1.60083e-5 -0.1229799 0.9924092 -0.004767358 -0.1871572 0.9823184 -0.005576372 -0.8699347 -0.4931353 -2.32577e-4 -0.8672547 -0.4978647 -9.39586e-4 -0.9995101 0.03128528 -3.46689e-4 -0.999222 0.03943848 0.9927443 -0.1197105 0.01132869 -0.9563636 -0.2920154 0.00978285 -0.001544773 0.9999779 0.006467223 0.002068638 0.9999976 6.48183e-4 -0.01753318 0.03230512 -0.9993243 0.003435969 -3.46711e-4 -0.999994 0.9999831 -0.005649566 -0.001454889 0.9998875 0.01003545 -0.01114994 0.9990121 0.002701461 -0.04435819 0.9997629 -0.006217002 -0.02087032 -0.9999886 -0.00428152 -0.00210607 -0.9998611 0.01174783 -0.01182723 -0.9993346 0.004062354 -0.03624898 -0.9998942 -0.004529297 -0.01382708 -0.9959966 0.01350748 0.08836561 -0.9962916 -0.005001425 0.08589476 0.1560158 -0.001218974 -0.9877539 -0.8825191 -0.006937742 -0.4702252 -0.9992199 -0.03507834 0.01814252 -0.9999691 -0.001396 0.007733643 0.9999812 -0.001430571 0.005960941 0.999244 -0.03516072 0.01659327 0.9861967 -0.03598099 -0.1616215 0.9268067 -0.005166351 -0.3755034 0.8828601 -0.002207458 -0.469631 0.99599 0.01377451 0.08839821 0.9963099 -0.01399958 0.08467978 -9.92955e-4 -0.7099062 -0.7042956 0.001225948 -0.7197995 -0.694181 -0.004333555 0.7094319 -0.7047607 -0.002025246 0.6938492 -0.7201175 5.66681e-4 0.7029646 -0.7112247 0.007812917 0.7066024 -0.7075677 -0.5236616 0.003840386 0.8519178 -0.4967814 0.02186524 0.8676003 -0.722224 -0.03209161 0.6909145 -0.897934 0.03721666 0.4385541 -0.9598154 -0.02480667 0.2795337 0.9416905 0.003191292 0.3364652 0.8854467 -0.04922211 0.4621273 0.6450628 7.23494e-4 0.7641294 0.5888193 -0.02258223 0.8079492 0.3185542 0.05585145 0.9462579 0.6454741 0.03606939 0.7629301 0.7021592 -0.638997 -0.3140944 0.7092592 -0.704945 0.00203222 0.40139 -0.669321 0.6252164 0.007280707 -0.701826 0.7123112 -0.4307134 -0.8567411 0.2836915 0.6988441 -0.7152389 0.007094681 -0.6998528 -0.7142868 7.04012e-4 0.9143519 0.003415346 -0.4049062 -0.6693444 -0.01035511 0.7428802 -0.7015902 -0.6401234 -0.3130708 0.7415134 0.5508129 0.3830968 -0.6960386 -0.7179734 0.006675958 0.6727006 0.01042085 0.7398415 0.6373302 0.02924919 0.7700356 -0.8728793 0.004739582 -0.4879134 0.5790942 0.8152195 0.008191704 0.7009998 0.7131506 0.003946483 0.8712383 0.3797509 0.3110197 0.3404745 0.8942072 0.2906386 -0.6368297 0.7211554 0.2727323 -0.7100141 0.7041722 0.00465697 -0.7232721 0.6905426 0.005329906 -0.7358887 0.5554174 0.3872717 -0.6753374 0.01095151 0.7374276 -0.6596332 0.01942574 0.7513366 -0.7151693 -0.6955538 0.06883221 -0.5688336 -0.6933217 0.4424177 0.7013344 -0.6984758 0.1423438 0.5473927 -0.6998478 0.4588838 0.215398 -0.6970678 0.6838861 -0.2572834 -0.6996731 0.6665304 0.002106964 -0.9314457 0.3638744 0.881494 -0.07933795 0.4654824 0.8730121 -0.09179598 0.4789817 0.04290962 -0.01601725 -0.9989507 0.1343826 0.03758537 -0.9902165 0.5740847 0.03054869 0.8182259 0.5090357 -0.05372262 -0.8590674 0.3075678 -0.01931774 0.9513301 0.2946367 -0.03103911 0.9551053 0.6375395 0.04316061 -0.7692078 0.908856 0.008138895 -0.4170307 0.9517405 -0.08275151 -0.2955374 0.9974929 0.013933 -0.06938272 0.3853201 -0.7972646 -0.464648 0.6791377 -0.4461136 -0.5828849 -0.4734979 -0.8706647 -0.1332018 0.5195622 -0.8197226 -0.2410601 -0.4938721 -0.7454801 -0.4476045 -0.7729855 -0.3740035 -0.5124595 0.9405061 -0.3392458 -0.01898998 -0.2958472 -0.6721335 -0.678757 -0.3007575 -0.6191048 -0.7254338 0.08517628 -0.7873173 -0.6106361 0.2099796 -0.49436 -0.8435145 -2.64102e-4 1 1.74548e-4 -1.1962e-5 1 1.26157e-4 2.06489e-4 1 -9.33148e-5 -0.001511156 0.9999989 -1.27516e-4 -0.04181706 0.9984763 0.03600627 -0.005665898 0.9997345 0.02233892 -0.002253532 0.9998073 0.01950085 0.06646537 0.9976769 -0.01493656 -0.4830842 -0.6704126 0.5631843 -0.5432407 -0.436838 0.7169812 -0.5465892 -0.8079434 0.2201545 -0.9770032 -0.09024524 0.193185 0.560274 -0.4717366 0.6808507 0.1733226 -0.8155052 0.5521872 0.03994673 -0.3244244 0.9450678 -0.1251857 -0.8410195 0.526322 -0.9616039 -0.05233085 -0.2694056 -0.7397416 0.05420136 -0.6707046 -0.3073062 -0.04552447 0.9505212 -0.2227703 0.0133779 0.9747792 -0.4903982 -0.05028861 -0.8700463 -0.3967096 0.01345318 -0.9178457 -0.6519777 0.05776602 0.7560345 -0.8745346 -0.07046216 0.479817 -0.9277643 0.02065652 0.3725948 -0.9547636 -0.02300214 -0.2964749 -8.72595e-7 -1 -9.46364e-6 -3.17071e-7 -1 0 -3.17071e-7 -1 8.62569e-7 -4.37507e-7 -1 0 0 -1 0 3.97679e-6 -1 0 0 -1 1.98224e-6 0.8691138 -0.1313883 0.476842 0.04290962 -0.01601725 -0.9989507 0.1343826 0.03758537 -0.9902165 0.6814061 0.0573436 0.7296558 0.5090351 -0.0537225 -0.8590676 0.3075684 -0.01931768 0.9513298 0.2946355 -0.03103935 0.9551055 0.6375406 0.04316031 -0.7692069 0.9068413 0.008536219 -0.4213859 0.9506452 -0.09556525 -0.295197 0.9894707 0.03842526 0.1395403 0.3696854 -0.8152374 -0.445781 -0.9627035 -0.2666468 -0.04584342 -0.5335696 -0.8323478 -0.1500031 0.6228252 -0.7435162 -0.2434592 -0.6888185 -0.4255956 -0.5868538 -0.3595518 -0.4060154 -0.8401631 0.07133811 -0.8588427 -0.5072478 0.4361646 0.05485165 -0.8981936 -3.43774e-4 1 1.10847e-4 3.18553e-4 1 -7.99342e-5 -0.2044163 0.9340437 -0.2928762 0.09703636 0.9950554 0.02118116 6.65985e-4 0.9999999 1.63433e-4 -1.12083e-4 1 -2.28791e-4 -3.81542e-6 1 -2.18706e-4 0.2618119 0.9650791 0.008770644 -0.3162638 -0.5461402 0.7756986 -0.3802565 -0.8110634 0.4445014 -0.7460662 -0.4198443 0.5168328 -0.5037703 -0.839652 0.2029784 0.5497425 -0.6634614 0.5075454 0.1725108 -0.8174732 0.5495251 0.1148756 -0.4207645 0.8998672 -0.1376492 -0.8015738 0.5818353 -0.9616038 -0.05233103 -0.2694066 -0.7876538 0.03749954 -0.6149759 -0.3073074 -0.04552465 0.9505208 -0.4896735 -0.07398062 -0.8687616 -0.3657975 0.01128739 -0.9306262 -0.6519766 0.05776649 0.7560356 -0.8745356 -0.07046252 0.4798154 -0.9277645 0.02065676 0.372594 -0.9547633 -0.02300065 -0.2964761 -3.97679e-6 -1 1.11854e-6 -1.04644e-6 -1 -6.13438e-7 1.79553e-6 -1 5.72717e-7 3.97679e-6 -1 5.59268e-7 -8.85583e-7 -1 0 0 -1 4.29807e-7</float_array>
131
+ <technique_common>
132
+ <accessor source="#Part__Feature001_006-mesh-normals-array" count="360" stride="3">
133
+ <param name="X" type="float"/>
134
+ <param name="Y" type="float"/>
135
+ <param name="Z" type="float"/>
136
+ </accessor>
137
+ </technique_common>
138
+ </source>
139
+ <vertices id="Part__Feature001_006-mesh-vertices">
140
+ <input semantic="POSITION" source="#Part__Feature001_006-mesh-positions"/>
141
+ </vertices>
142
+ <triangles material="Part__Feature001_006-material" count="362">
143
+ <input semantic="VERTEX" source="#Part__Feature001_006-mesh-vertices" offset="0"/>
144
+ <input semantic="NORMAL" source="#Part__Feature001_006-mesh-normals" offset="1"/>
145
+ <p>3 0 124 0 2 0 4 1 124 1 3 1 4 2 0 2 124 2 0 3 5 3 124 3 1 4 124 4 5 4 6 5 7 5 8 5 6 6 54 6 7 6 9 7 74 7 18 7 9 8 10 8 74 8 11 9 12 9 13 9 14 10 15 10 12 10 17 11 20 11 16 11 16 12 20 12 45 12 45 13 20 13 18 13 17 14 19 14 20 14 18 15 6 15 15 15 45 16 18 16 15 16 15 17 21 17 12 17 6 18 21 18 15 18 6 19 22 19 21 19 21 20 23 20 12 20 12 21 23 21 13 21 21 22 24 22 23 22 72 23 26 23 27 23 72 24 73 24 26 24 26 25 73 25 28 25 73 26 14 26 28 26 25 27 29 27 31 27 31 28 29 28 30 28 26 29 28 29 31 29 28 30 25 30 31 30 32 31 33 31 34 31 30 32 35 32 33 32 29 33 35 33 30 33 11 34 13 34 35 34 13 35 34 35 33 35 13 36 33 36 35 36 36 37 37 37 38 37 37 38 39 38 40 38 41 39 39 39 37 39 23 40 24 40 41 40 36 41 23 41 37 41 37 42 23 42 41 42 42 43 40 43 39 43 42 44 43 44 40 44 13 45 36 45 34 45 13 46 23 46 36 46 51 47 16 47 44 47 16 48 45 48 44 48 19 49 17 49 53 49 53 50 17 50 46 50 47 51 54 51 48 51 36 52 48 52 49 52 36 53 49 53 34 53 49 54 32 54 34 54 38 55 48 55 36 55 50 56 27 56 49 56 55 57 44 57 52 57 55 58 51 58 44 58 55 59 46 59 51 59 53 60 46 60 55 60 52 61 44 61 50 61 54 62 52 62 50 62 48 63 50 63 49 63 48 64 54 64 50 64 38 65 56 65 48 65 57 66 56 66 38 66 37 67 57 67 38 67 58 68 59 68 57 68 57 69 60 69 56 69 59 70 60 70 57 70 40 71 58 71 37 71 37 72 58 72 57 72 60 73 48 73 56 73 60 74 47 74 48 74 61 75 33 75 32 75 62 76 32 76 49 76 62 77 61 77 32 77 62 78 26 78 61 78 26 79 63 79 61 79 27 80 62 80 49 80 27 81 26 81 62 81 63 82 30 82 61 82 61 83 30 83 33 83 24 84 21 84 65 84 24 85 64 85 41 85 24 86 65 86 64 86 65 87 66 87 64 87 66 88 67 88 64 88 67 89 68 89 64 89 22 90 66 90 65 90 22 91 65 91 21 91 68 92 39 92 41 92 68 93 41 93 64 93 11 94 35 94 69 94 71 95 69 95 70 95 71 96 11 96 69 96 12 97 11 97 71 97 69 98 25 98 70 98 25 99 28 99 70 99 70 100 28 100 71 100 29 101 25 101 69 101 29 102 69 102 35 102 71 103 14 103 12 103 28 104 14 104 71 104 44 105 45 105 72 105 45 106 73 106 72 106 6 107 18 107 54 107 54 108 18 108 74 108 75 109 76 109 19 109 53 110 75 110 19 110 46 111 17 111 16 111 51 112 46 112 16 112 66 113 22 113 77 113 66 114 77 114 67 114 77 115 42 115 67 115 67 116 42 116 68 116 39 117 68 117 42 117 8 118 22 118 6 118 76 119 9 119 20 119 9 120 18 120 20 120 19 121 76 121 20 121 40 122 43 122 58 122 58 123 43 123 59 123 43 124 78 124 59 124 59 125 78 125 60 125 78 126 47 126 60 126 45 127 15 127 73 127 54 128 47 128 7 128 14 129 73 129 15 129 74 130 52 130 54 130 10 131 52 131 74 131 55 132 10 132 75 132 55 133 52 133 10 133 75 134 53 134 55 134 72 135 50 135 44 135 27 136 50 136 72 136 31 137 30 137 63 137 26 138 31 138 63 138 77 139 79 139 42 139 77 140 22 140 79 140 82 141 83 141 80 141 83 142 81 142 80 142 82 143 85 143 83 143 84 144 85 144 82 144 86 145 91 145 87 145 89 146 91 146 86 146 90 147 91 147 89 147 85 148 84 148 90 148 90 149 84 149 91 149 90 150 89 150 88 150 86 151 121 151 89 151 94 152 121 152 86 152 93 153 5 153 92 153 92 154 5 154 94 154 5 155 0 155 94 155 93 156 1 156 5 156 94 157 0 157 97 157 93 158 95 158 1 158 95 159 96 159 1 159 0 160 4 160 97 160 4 161 3 161 97 161 95 162 122 162 96 162 97 163 3 163 98 163 3 164 99 164 98 164 95 165 125 165 122 165 2 166 99 166 3 166 123 167 100 167 2 167 126 168 100 168 123 168 2 169 100 169 99 169 125 170 100 170 126 170 95 171 100 171 125 171 95 172 102 172 100 172 101 173 102 173 95 173 103 174 104 174 101 174 105 175 104 175 103 175 76 176 75 176 103 176 75 177 105 177 103 177 10 178 105 178 75 178 103 179 9 179 76 179 80 180 81 180 106 180 81 181 107 181 106 181 47 182 78 182 108 182 108 183 78 183 43 183 10 184 107 184 81 184 10 185 81 185 117 185 7 186 108 186 107 186 7 187 107 187 10 187 106 188 9 188 80 188 80 189 9 189 109 189 79 190 8 190 106 190 106 191 8 191 9 191 109 192 110 192 80 192 109 193 101 193 110 193 112 194 101 194 111 194 112 195 110 195 101 195 111 196 93 196 119 196 111 197 119 197 112 197 113 198 114 198 116 198 99 199 114 199 113 199 104 200 114 200 102 200 115 201 116 201 104 201 104 202 116 202 114 202 115 203 117 203 81 203 104 204 117 204 115 204 7 205 9 205 8 205 10 206 9 206 7 206 108 207 43 207 107 207 42 208 106 208 43 208 43 209 106 209 107 209 42 210 79 210 106 210 94 211 86 211 87 211 92 212 94 212 87 212 118 213 92 213 87 213 118 214 87 214 119 214 93 215 118 215 119 215 99 216 113 216 120 216 98 217 99 217 120 217 98 218 120 218 88 218 121 219 98 219 88 219 121 220 88 220 89 220 100 221 102 221 114 221 104 222 105 222 117 222 99 223 100 223 114 223 98 224 121 224 97 224 121 225 94 225 97 225 118 226 93 226 92 226 105 227 10 227 117 227 95 228 93 228 111 228 7 229 47 229 108 229 101 230 95 230 111 230 103 231 101 231 109 231 81 232 83 232 115 232 109 233 9 233 103 233 115 234 85 234 116 234 83 235 85 235 115 235 8 236 79 236 22 236 85 237 90 237 113 237 85 238 113 238 116 238 120 239 113 239 90 239 120 240 90 240 88 240 119 241 87 241 91 241 91 242 84 242 112 242 91 243 112 243 119 243 82 244 80 244 110 244 84 245 110 245 112 245 84 246 82 246 110 246 2 247 124 247 123 247 126 248 123 248 124 248 96 249 124 249 1 249 96 250 122 250 124 250 122 251 125 251 124 251 125 252 126 252 124 252 102 253 101 253 104 253 127 254 128 254 138 254 138 255 128 255 129 255 155 256 146 256 130 256 146 257 131 257 130 257 128 258 132 258 129 258 130 259 131 259 133 259 129 260 134 260 135 260 132 261 134 261 129 261 131 262 136 262 133 262 136 263 137 263 133 263 133 264 137 264 138 264 137 265 127 265 138 265 131 266 139 266 136 266 139 267 142 267 136 267 140 268 141 268 143 268 136 269 142 269 137 269 143 270 144 270 145 270 141 271 144 271 143 271 142 272 127 272 137 272 145 273 144 273 146 273 144 274 147 274 146 274 146 275 147 275 131 275 147 276 139 276 131 276 149 277 144 277 150 277 150 278 144 278 148 278 147 279 144 279 149 279 148 280 144 280 141 280 142 281 139 281 127 281 139 282 147 282 127 282 127 283 147 283 128 283 128 284 147 284 149 284 151 285 148 285 152 285 150 286 148 286 151 286 152 287 148 287 140 287 148 288 141 288 140 288 128 289 149 289 132 289 132 290 149 290 134 290 149 291 150 291 134 291 134 292 150 292 151 292 140 293 143 293 153 293 143 294 145 294 153 294 135 295 151 295 154 295 134 296 151 296 135 296 153 297 145 297 155 297 145 298 146 298 155 298 151 299 152 299 154 299 154 300 152 300 156 300 152 301 140 301 156 301 156 302 140 302 153 302 153 303 155 303 130 303 153 304 130 304 133 304 153 305 133 305 138 305 154 306 153 306 138 306 154 307 138 307 129 307 154 308 156 308 153 308 154 309 129 309 135 309 167 310 157 310 158 310 183 311 174 311 159 311 174 312 160 312 159 312 157 313 161 313 158 313 159 314 160 314 162 314 158 315 163 315 164 315 161 316 163 316 158 316 160 317 165 317 162 317 165 318 166 318 162 318 162 319 166 319 167 319 166 320 157 320 167 320 160 321 168 321 165 321 170 322 171 322 169 322 169 323 171 323 172 323 165 324 168 324 166 324 171 325 173 325 172 325 173 326 175 326 174 326 174 327 175 327 160 327 175 328 168 328 160 328 177 329 171 329 170 329 175 330 171 330 177 330 175 331 173 331 171 331 157 332 168 332 178 332 178 333 168 333 176 333 176 334 175 334 177 334 168 335 175 335 176 335 166 336 168 336 157 336 176 337 177 337 179 337 179 338 177 338 180 338 177 339 170 339 180 339 180 340 170 340 169 340 157 341 178 341 161 341 161 342 178 342 163 342 178 343 176 343 163 343 163 344 176 344 179 344 169 345 172 345 181 345 172 346 173 346 181 346 164 347 179 347 182 347 163 296 179 296 164 296 181 348 173 348 183 348 173 349 174 349 183 349 179 350 180 350 182 350 182 351 180 351 184 351 180 352 169 352 184 352 184 353 169 353 181 353 158 354 162 354 167 354 182 355 183 355 159 355 182 356 181 356 183 356 182 357 184 357 181 357 182 307 159 307 164 307 164 358 159 358 162 358 164 359 162 359 158 359</p>
146
+ </triangles>
147
+ </mesh>
148
+ </geometry>
149
+ </library_geometries>
150
+ <library_controllers/>
151
+ <library_visual_scenes>
152
+ <visual_scene id="Scene" name="Scene">
153
+ <node id="node0" name="node0" type="NODE">
154
+ <matrix sid="transform">1 0 0 0 0 1 0 0 0 0 1 0 0 0 0 1</matrix>
155
+ <instance_geometry url="#Part__Feature_007-mesh" name="node0">
156
+ <bind_material>
157
+ <technique_common>
158
+ <instance_material symbol="Part__Feature_007-material" target="#Part__Feature_007-material"/>
159
+ </technique_common>
160
+ </bind_material>
161
+ </instance_geometry>
162
+ </node>
163
+ <node id="node1" name="node1" type="NODE">
164
+ <matrix sid="transform">1 0 0 0 0 1 0 0 0 0 1 0 0 0 0 1</matrix>
165
+ <instance_geometry url="#Part__Feature001_006-mesh" name="node1">
166
+ <bind_material>
167
+ <technique_common>
168
+ <instance_material symbol="Part__Feature001_006-material" target="#Part__Feature001_006-material"/>
169
+ </technique_common>
170
+ </bind_material>
171
+ </instance_geometry>
172
+ </node>
173
+ </visual_scene>
174
+ </library_visual_scenes>
175
+ <scene>
176
+ <instance_visual_scene url="#Scene"/>
177
+ </scene>
178
+ </COLLADA>
embodied_gen/scripts/affordance_annot/franka_panda/meshes/visual/hand.dae ADDED
The diff for this file is too large to render. See raw diff
 
embodied_gen/scripts/affordance_annot/gen_grasp.py ADDED
@@ -0,0 +1,842 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Project EmbodiedGen
2
+ #
3
+ # Copyright (c) 2025 Horizon Robotics. All Rights Reserved.
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
14
+ # implied. See the License for the specific language governing
15
+ # permissions and limitations under the License.
16
+
17
+ import os
18
+ import sys
19
+ import time
20
+ from dataclasses import dataclass, field
21
+ from typing import Literal
22
+
23
+ from embodied_gen.utils.general import filter_warnings
24
+
25
+ filter_warnings()
26
+
27
+ import numpy as np
28
+ import trimesh
29
+ import trimesh.transformations as tra
30
+ import tyro
31
+ import yaml
32
+
33
+ sys.path.append(".")
34
+ from embodied_gen.utils.geometry import (
35
+ sample_surface_points,
36
+ )
37
+ from embodied_gen.utils.io_utils import (
38
+ URDFFile,
39
+ load_json,
40
+ load_mesh,
41
+ write_json,
42
+ )
43
+ from embodied_gen.utils.log import logger
44
+ from embodied_gen.utils.vis_utils import show_grasps
45
+
46
+ __all__ = [
47
+ "GraspGenHyperConfig",
48
+ "GraspGenXHyperConfig",
49
+ "GraspPoseConfig",
50
+ "GraspGenerator",
51
+ "run_grasp",
52
+ "entrypoint",
53
+ ]
54
+
55
+ FINGER_CENTER_CACHE: dict[str, np.ndarray] = {
56
+ "franka_panda": np.array([0.0, 0.0, 0.1034], dtype=np.float64),
57
+ }
58
+
59
+ GRIPPER_GEOMETRY_CACHE: dict[str, dict[str, float]] = {
60
+ "franka_panda": {
61
+ "width": 0.10537486,
62
+ "depth": 0.10527314,
63
+ },
64
+ }
65
+
66
+
67
+ @dataclass
68
+ class GraspGenHyperConfig:
69
+ gripper_config: str = (
70
+ "~/.cache/huggingface/hub/GraspGenModels/checkpoints/graspgen_{}.yml"
71
+ )
72
+ grasp_threshold: float = -1.0
73
+ num_grasps: int = 4000
74
+ return_topk: bool = True
75
+ topk_num_grasps: int = 4000
76
+ mesh_scale: tuple[float, float, float] = (1.0, 1.0, 1.0)
77
+ num_sample_points: int = 2000
78
+ min_grasps: int = 40
79
+ max_tries: int = 6
80
+ remove_outliers: bool = False
81
+
82
+
83
+ @dataclass
84
+ class GraspGenXHyperConfig:
85
+ checkpoint_root: str = "~/.cache/huggingface/hub/GraspGenXModels/release"
86
+ gen_pth: str | None = None
87
+ dis_pth: str | None = None
88
+ assets_dir: str = (
89
+ "thirdparty/gripper_descriptions/gripper_descriptions/assets"
90
+ )
91
+ grasp_threshold: float = -1.0
92
+ num_grasps: int = 4000
93
+ return_topk: bool = True
94
+ topk_num_grasps: int = 4000
95
+ mesh_scale: tuple[float, float, float] = (1.0, 1.0, 1.0)
96
+ num_sample_points: int = 2000
97
+ min_grasps: int = 40
98
+ max_tries: int = 6
99
+ remove_outliers: bool = False
100
+
101
+
102
+ @dataclass
103
+ class GraspPoseConfig:
104
+ urdf_paths: list[str] = field(default_factory=list)
105
+ model_name: str = "GraspGen"
106
+ gripper_name: str = "franka_panda"
107
+ mesh_type: Literal["collision", "visual"] = "collision"
108
+ update_urdf: bool = True
109
+ overwrite: bool = True
110
+ gripper_mesh_path: str = (
111
+ f"embodied_gen/scripts/affordance_annot/{gripper_name}/coll_mesh.obj"
112
+ )
113
+ grasps_per_part: int = 20
114
+ visualize_grasps: bool = False
115
+ visualizer_port: int = 8080
116
+ graspgen: GraspGenHyperConfig | None = None
117
+
118
+
119
+ class GraspGenerator:
120
+ def __init__(self, cfg: GraspPoseConfig):
121
+ self.cfg = cfg
122
+ self.grasp_visualizer = None
123
+ self.validate_config(cfg)
124
+ self.modules = self.load_model()
125
+ self.pipeline = self.build_sampler()
126
+ self.finger_center = FINGER_CENTER_CACHE.get(
127
+ self.cfg.gripper_name,
128
+ np.array([0.0, 0.0, 0.0], dtype=np.float64),
129
+ )
130
+ (
131
+ self.local_contact_points,
132
+ self.local_closing_directions,
133
+ ) = self.build_local_contact_samples()
134
+
135
+ def validate_config(self, cfg: GraspPoseConfig) -> None:
136
+ cfg.model_name = cfg.model_name.lower()
137
+ if cfg.model_name not in {"graspgen", "graspgenx"}:
138
+ raise ValueError(
139
+ "unsupported model '{}', available models: GraspGen, GraspGenX".format(
140
+ cfg.model_name
141
+ )
142
+ )
143
+
144
+ cfg.graspgen = (
145
+ GraspGenHyperConfig()
146
+ if cfg.model_name == "graspgen"
147
+ else GraspGenXHyperConfig()
148
+ )
149
+ if cfg.model_name == "graspgen":
150
+ cfg.graspgen.gripper_config = cfg.graspgen.gripper_config.format(
151
+ cfg.gripper_name
152
+ )
153
+
154
+ def load_model(self):
155
+ if self.cfg.model_name == "graspgen":
156
+ sys.path.append("thirdparty/GraspGen")
157
+ from grasp_gen.grasp_server import GraspGenSampler, load_grasp_cfg
158
+
159
+ return {
160
+ "GraspGenSampler": GraspGenSampler,
161
+ "model_cfg": load_grasp_cfg,
162
+ }
163
+ if self.cfg.model_name == "graspgenx":
164
+ sys.path.append("thirdparty/GraspGenX")
165
+ from graspgenx.grasp_server import GraspGenXSampler
166
+ from graspgenx.utils.checkpoint_io import load_model_cfg
167
+
168
+ return {
169
+ "GraspGenSampler": GraspGenXSampler,
170
+ "model_cfg": load_model_cfg,
171
+ }
172
+
173
+ @staticmethod
174
+ def _extract_checkpoint_filenames(yml_path: str) -> list[str]:
175
+ """Recursively collect all `checkpoint: <name>.pth` entries.
176
+
177
+ Walks a GraspGen config yaml so we know every weight file it
178
+ requires.
179
+ """
180
+ with open(yml_path) as f:
181
+ cfg = yaml.safe_load(f)
182
+
183
+ names: set[str] = set()
184
+
185
+ def _walk(node) -> None:
186
+ if isinstance(node, dict):
187
+ for key, value in node.items():
188
+ if (
189
+ key == "checkpoint"
190
+ and isinstance(value, str)
191
+ and value.strip()
192
+ ):
193
+ names.add(value.strip())
194
+ else:
195
+ _walk(value)
196
+ elif isinstance(node, list):
197
+ for item in node:
198
+ _walk(item)
199
+
200
+ _walk(cfg)
201
+ return sorted(names)
202
+
203
+ @staticmethod
204
+ def _dir_has_pth(directory: str, filename: str | None) -> bool:
205
+ if filename:
206
+ return os.path.exists(os.path.join(directory, filename))
207
+ return os.path.isdir(directory) and any(
208
+ f.endswith(".pth") for f in os.listdir(directory)
209
+ )
210
+
211
+ def _ensure_checkpoints(
212
+ self,
213
+ is_ready: callable,
214
+ repo_id: str,
215
+ allow_patterns: str,
216
+ download_dir: str,
217
+ desc: str,
218
+ ) -> None:
219
+ """Download `desc` weights from `repo_id` unless already ready.
220
+
221
+ Re-checks afterwards so a stale/partial local cache (e.g. config
222
+ present but a referenced .pth missing) is retried instead of
223
+ silently failing deep inside third-party code.
224
+ """
225
+ if is_ready():
226
+ return
227
+
228
+ logger.info(
229
+ f"Downloading {desc} weights from Hugging Face. "
230
+ "First-time use may take several minutes; please wait patiently."
231
+ )
232
+ from huggingface_hub import snapshot_download
233
+
234
+ snapshot_download(
235
+ repo_id=repo_id,
236
+ allow_patterns=allow_patterns,
237
+ local_dir=os.path.expanduser(download_dir),
238
+ )
239
+
240
+ if not is_ready():
241
+ raise FileNotFoundError(
242
+ f"{desc} checkpoint(s) still missing after downloading from "
243
+ f"Hugging Face repo '{repo_id}'. The local cache may be from "
244
+ "a previous partial download; please remove it and retry, "
245
+ "or download manually."
246
+ )
247
+
248
+ def build_sampler(self):
249
+ if self.cfg.model_name == "graspgen":
250
+ gripper_config = os.path.expanduser(
251
+ self.cfg.graspgen.gripper_config
252
+ )
253
+ checkpoints_dir = os.path.dirname(gripper_config)
254
+
255
+ def _graspgen_ready() -> bool:
256
+ if not os.path.exists(gripper_config):
257
+ return False
258
+ required = self._extract_checkpoint_filenames(gripper_config)
259
+ return all(
260
+ os.path.exists(os.path.join(checkpoints_dir, name))
261
+ for name in required
262
+ )
263
+
264
+ self._ensure_checkpoints(
265
+ _graspgen_ready,
266
+ "adithyamurali/GraspGenModels",
267
+ "checkpoints/*",
268
+ "~/.cache/huggingface/hub/GraspGenModels",
269
+ "GraspGen",
270
+ )
271
+ grasp_cfg = self.modules["model_cfg"](gripper_config)
272
+ return self.modules["GraspGenSampler"](grasp_cfg)
273
+ if self.cfg.model_name == "graspgenx":
274
+ checkpoint_root = os.path.expanduser(
275
+ self.cfg.graspgen.checkpoint_root
276
+ )
277
+ gen_dir = os.path.join(checkpoint_root, "gen")
278
+ dis_dir = os.path.join(checkpoint_root, "dis")
279
+
280
+ def _graspgenx_ready() -> bool:
281
+ return (
282
+ os.path.exists(os.path.join(gen_dir, "config.yaml"))
283
+ and self._dir_has_pth(gen_dir, self.cfg.graspgen.gen_pth)
284
+ and self._dir_has_pth(dis_dir, self.cfg.graspgen.dis_pth)
285
+ )
286
+
287
+ self._ensure_checkpoints(
288
+ _graspgenx_ready,
289
+ "adithyamurali/GraspGenXModel",
290
+ "release/*",
291
+ "~/.cache/huggingface/hub/GraspGenXModels",
292
+ "GraspGenX",
293
+ )
294
+ model_cfg = self.modules["model_cfg"](
295
+ os.path.join(checkpoint_root, "gen"),
296
+ os.path.join(checkpoint_root, "dis"),
297
+ self.cfg.graspgen.gen_pth,
298
+ self.cfg.graspgen.dis_pth,
299
+ )
300
+ return self.modules["GraspGenSampler"](
301
+ model_cfg,
302
+ self.cfg.gripper_name,
303
+ assets_dir=self.cfg.graspgen.assets_dir,
304
+ )
305
+
306
+ def update_urdf(self, urdf_path: str | None, grasp_path: str) -> None:
307
+ if urdf_path is None or not self.cfg.update_urdf:
308
+ return
309
+
310
+ URDFFile(urdf_path).write(
311
+ {
312
+ "custom_data/affordance/affordance_annot": os.path.relpath(
313
+ grasp_path,
314
+ os.path.dirname(urdf_path),
315
+ ),
316
+ }
317
+ )
318
+
319
+ def get_part_seg_mesh_transform(self, urdf: URDFFile) -> dict:
320
+ return {
321
+ "origin_xyz": urdf.read(
322
+ ".//custom_data/affordance/visual_seg/origin",
323
+ attr="xyz",
324
+ default="0 0 0",
325
+ ),
326
+ "origin_rpy": urdf.read(
327
+ ".//custom_data/affordance/visual_seg/origin",
328
+ attr="rpy",
329
+ default="0 0 0",
330
+ ),
331
+ "scale": urdf.read(
332
+ ".//custom_data/affordance/visual_seg/geometry/mesh",
333
+ attr="scale",
334
+ default="1.0 1.0 1.0",
335
+ ),
336
+ }
337
+
338
+ def add_grasps_to_affordance(
339
+ self,
340
+ affordance_payload: dict,
341
+ grasps: np.ndarray,
342
+ confidences: np.ndarray,
343
+ part_grasp_ids: dict[int, list[int]] | None,
344
+ ) -> None:
345
+ for affordance in affordance_payload.get("affordances", []):
346
+ if (
347
+ not isinstance(affordance, dict)
348
+ or affordance.get("id") is None
349
+ ):
350
+ continue
351
+ affordance.pop("grasps", None)
352
+ affordance.pop("confidences", None)
353
+ grasp_ids = part_grasp_ids.get(int(affordance["id"]), [])
354
+ grasp_group = {}
355
+ for grasp_idx in grasp_ids:
356
+ grasp = grasps[grasp_idx]
357
+ quaternion = tra.quaternion_from_matrix(grasp[:3, :3])
358
+ grasp_group[f"grasp_{grasp_idx}"] = {
359
+ "confidence": float(confidences[grasp_idx]),
360
+ "position": grasp[:3, 3].tolist(),
361
+ "orientation": {
362
+ "w": float(quaternion[0]),
363
+ "xyz": quaternion[1:].tolist(),
364
+ },
365
+ }
366
+ affordance["grasp_group"] = grasp_group
367
+
368
+ def infer_grasps(
369
+ self,
370
+ mesh: trimesh.Trimesh,
371
+ mesh_path: str,
372
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray] | None:
373
+ points = sample_surface_points(
374
+ mesh, self.cfg.graspgen.num_sample_points
375
+ )
376
+ center_transform = tra.translation_matrix(-points.mean(axis=0))
377
+ centered_points = tra.transform_points(
378
+ points, center_transform
379
+ ).astype(np.float32)
380
+ topk_limit = (
381
+ self.cfg.graspgen.topk_num_grasps
382
+ if self.cfg.graspgen.return_topk
383
+ else -1
384
+ )
385
+ grasps, confidences = self.modules["GraspGenSampler"].run_inference(
386
+ centered_points,
387
+ self.pipeline,
388
+ grasp_threshold=self.cfg.graspgen.grasp_threshold,
389
+ num_grasps=self.cfg.graspgen.num_grasps,
390
+ topk_num_grasps=topk_limit,
391
+ min_grasps=self.cfg.graspgen.min_grasps,
392
+ max_tries=self.cfg.graspgen.max_tries,
393
+ remove_outliers=self.cfg.graspgen.remove_outliers,
394
+ )
395
+
396
+ confidences = confidences.detach().cpu().numpy()
397
+ grasps = grasps.detach().cpu().numpy()
398
+ if len(grasps) == 0:
399
+ logger.error(f"No grasp poses generated for mesh: {mesh_path}")
400
+ return None
401
+
402
+ grasps = np.asarray(
403
+ [tra.inverse_matrix(center_transform) @ grasp for grasp in grasps]
404
+ )
405
+ return grasps, confidences
406
+
407
+ def get_grasp_centers(
408
+ self,
409
+ grasps: np.ndarray,
410
+ ) -> np.ndarray:
411
+ local_centers = np.repeat(
412
+ np.asarray(self.finger_center, dtype=np.float64)[None, :],
413
+ len(grasps),
414
+ axis=0,
415
+ )
416
+ grasp_center_points = (
417
+ np.einsum(
418
+ "nij,nj->ni",
419
+ grasps[:, :3, :3],
420
+ local_centers,
421
+ )
422
+ + grasps[:, :3, 3]
423
+ )
424
+ return grasp_center_points
425
+
426
+ def build_local_contact_samples(self) -> tuple[np.ndarray, np.ndarray]:
427
+ geometry = GRIPPER_GEOMETRY_CACHE.get(self.cfg.gripper_name)
428
+ if geometry is None:
429
+ return (
430
+ np.asarray(self.finger_center, dtype=np.float64)[None, :],
431
+ np.array([[1.0, 0.0, 0.0]], dtype=np.float64),
432
+ )
433
+
434
+ half_width = float(geometry["width"]) / 2.0
435
+ depth = float(geometry["depth"])
436
+ z_values = np.linspace(depth - 0.015, depth, 6, dtype=np.float64)
437
+ points = []
438
+ directions = []
439
+ for z_value in z_values:
440
+ points.extend(
441
+ [
442
+ [-half_width, 0.0, z_value],
443
+ [half_width, 0.0, z_value],
444
+ ]
445
+ )
446
+ directions.extend(
447
+ [
448
+ [1.0, 0.0, 0.0],
449
+ [-1.0, 0.0, 0.0],
450
+ ]
451
+ )
452
+ return (
453
+ np.asarray(points, dtype=np.float64),
454
+ np.asarray(directions, dtype=np.float64),
455
+ )
456
+
457
+ def get_grasp_contact_rays(
458
+ self,
459
+ grasps: np.ndarray,
460
+ ) -> tuple[np.ndarray, np.ndarray]:
461
+ local_contact_points = np.repeat(
462
+ self.local_contact_points[None, :, :],
463
+ len(grasps),
464
+ axis=0,
465
+ )
466
+ local_closing_directions = np.repeat(
467
+ self.local_closing_directions[None, :, :],
468
+ len(grasps),
469
+ axis=0,
470
+ )
471
+ ray_origins = (
472
+ np.einsum(
473
+ "nij,nkj->nki",
474
+ grasps[:, :3, :3],
475
+ local_contact_points,
476
+ )
477
+ + grasps[:, None, :3, 3]
478
+ )
479
+ ray_directions = np.einsum(
480
+ "nij,nkj->nki",
481
+ grasps[:, :3, :3],
482
+ local_closing_directions,
483
+ )
484
+ ray_norms = np.linalg.norm(ray_directions, axis=-1, keepdims=True)
485
+ ray_directions = np.divide(
486
+ ray_directions,
487
+ ray_norms,
488
+ out=np.zeros_like(ray_directions),
489
+ where=ray_norms > 0.0,
490
+ )
491
+ return ray_origins, ray_directions
492
+
493
+ def bind_contact_rays_to_faces(
494
+ self,
495
+ part_mesh: trimesh.Trimesh,
496
+ ray_origins: np.ndarray,
497
+ ray_directions: np.ndarray,
498
+ ) -> np.ndarray:
499
+ ray_origins = np.asarray(ray_origins, dtype=np.float64)
500
+ ray_directions = np.asarray(ray_directions, dtype=np.float64)
501
+
502
+ if (
503
+ not isinstance(part_mesh, trimesh.Trimesh)
504
+ or len(part_mesh.faces) == 0
505
+ ):
506
+ return np.full(len(ray_origins), -1, dtype=np.int64)
507
+
508
+ try:
509
+ face_ids = part_mesh.ray.intersects_first(
510
+ ray_origins,
511
+ ray_directions,
512
+ )
513
+ return np.asarray(face_ids, dtype=np.int64)
514
+ except Exception as exc:
515
+ logger.warning(
516
+ "Failed to raycast grasp closing directions to part mesh: "
517
+ f"{exc}"
518
+ )
519
+ return np.full(len(ray_origins), -1, dtype=np.int64)
520
+
521
+ def classify_grasps_by_contact(
522
+ self,
523
+ grasps: np.ndarray,
524
+ part_mesh: trimesh.Trimesh,
525
+ face_ids: np.ndarray | None,
526
+ ) -> tuple[np.ndarray | None, np.ndarray | None]:
527
+ if face_ids is None:
528
+ return None, None
529
+
530
+ face_ids = np.asarray(face_ids, dtype=np.int64)
531
+ if len(face_ids) != len(part_mesh.faces):
532
+ raise ValueError(
533
+ "face_ids length must match segmentation mesh faces, got "
534
+ f"{len(face_ids)} and {len(part_mesh.faces)}"
535
+ )
536
+
537
+ ray_origins, ray_directions = self.get_grasp_contact_rays(grasps)
538
+ num_grasps, num_contact_points, _ = ray_origins.shape
539
+ logger.info(
540
+ "Binding {} grasp closing rays to {} part faces.".format(
541
+ num_grasps * num_contact_points,
542
+ len(part_mesh.faces),
543
+ )
544
+ )
545
+ nearest_face_ids = self.bind_contact_rays_to_faces(
546
+ part_mesh,
547
+ ray_origins.reshape(-1, 3),
548
+ ray_directions.reshape(-1, 3),
549
+ ).reshape(num_grasps, num_contact_points)
550
+ contact_part_ids = self.parts_for_faces(
551
+ nearest_face_ids.reshape(-1),
552
+ face_ids,
553
+ ).reshape(num_grasps, num_contact_points)
554
+
555
+ grasp_part_ids = np.full(num_grasps, -1, dtype=np.int64)
556
+ contact_scores = np.zeros(num_grasps, dtype=np.float64)
557
+ for grasp_idx, part_ids in enumerate(contact_part_ids):
558
+ valid_part_ids = part_ids[part_ids >= 0]
559
+ if len(valid_part_ids) == 0:
560
+ continue
561
+ unique_ids, counts = np.unique(
562
+ valid_part_ids,
563
+ return_counts=True,
564
+ )
565
+ best_idx = int(np.argmax(counts))
566
+ grasp_part_ids[grasp_idx] = int(unique_ids[best_idx])
567
+ contact_scores[grasp_idx] = float(counts[best_idx]) / float(
568
+ num_contact_points
569
+ )
570
+
571
+ return grasp_part_ids, contact_scores
572
+
573
+ def parts_for_faces(
574
+ self,
575
+ nearest_face_ids: np.ndarray,
576
+ face_ids: np.ndarray | None,
577
+ ) -> np.ndarray | None:
578
+ if face_ids is None:
579
+ return None
580
+
581
+ nearest_face_ids = np.asarray(nearest_face_ids, dtype=np.int64)
582
+ grasp_part_ids = np.full(len(nearest_face_ids), -1, dtype=np.int64)
583
+ valid_mask = (nearest_face_ids >= 0) & (
584
+ nearest_face_ids < len(face_ids)
585
+ )
586
+ grasp_part_ids[valid_mask] = face_ids[nearest_face_ids[valid_mask]]
587
+ return grasp_part_ids
588
+
589
+ def pick_top_grasps(
590
+ self,
591
+ grasps: np.ndarray,
592
+ confidences: np.ndarray,
593
+ part_ids: np.ndarray | None,
594
+ graspable_part_ids: set[int] | None = None,
595
+ part_scores: np.ndarray | None = None,
596
+ ) -> tuple[
597
+ np.ndarray, np.ndarray, np.ndarray, dict[int, list[int]] | None
598
+ ]:
599
+ if part_ids is None:
600
+ return (
601
+ grasps,
602
+ confidences,
603
+ np.arange(len(grasps), dtype=np.int64),
604
+ None,
605
+ )
606
+
607
+ part_ids = np.asarray(part_ids, dtype=np.int64)
608
+ selected_indices = []
609
+ selected_part_ids = []
610
+ for part_id in sorted(
611
+ int(pid) for pid in np.unique(part_ids[part_ids >= 0])
612
+ ):
613
+ if (
614
+ graspable_part_ids is not None
615
+ and part_id not in graspable_part_ids
616
+ ):
617
+ continue
618
+ part_indices = np.flatnonzero(part_ids == part_id)
619
+ if part_scores is None:
620
+ rank = np.argsort(
621
+ -confidences[part_indices],
622
+ kind="mergesort",
623
+ )
624
+ else:
625
+ rank = np.lexsort(
626
+ (
627
+ -confidences[part_indices],
628
+ -part_scores[part_indices],
629
+ )
630
+ )
631
+ ranked_indices = part_indices[rank]
632
+ if len(ranked_indices) > self.cfg.grasps_per_part:
633
+ selected = ranked_indices[: self.cfg.grasps_per_part]
634
+ else:
635
+ selected = ranked_indices
636
+ selected_indices.extend(int(idx) for idx in selected)
637
+ selected_part_ids.extend([part_id] * len(selected))
638
+
639
+ selected_indices = np.asarray(selected_indices, dtype=np.int64)
640
+ if len(selected_indices) == 0:
641
+ return (
642
+ grasps[:0],
643
+ confidences[:0],
644
+ selected_indices,
645
+ {},
646
+ )
647
+
648
+ part_grasp_ids: dict[int, list[int]] = {}
649
+ for new_idx, part_id in enumerate(selected_part_ids):
650
+ part_grasp_ids.setdefault(part_id, []).append(new_idx)
651
+
652
+ return (
653
+ grasps[selected_indices],
654
+ confidences[selected_indices],
655
+ selected_indices,
656
+ part_grasp_ids,
657
+ )
658
+
659
+ def select_grasps(
660
+ self,
661
+ grasps: np.ndarray,
662
+ confidences: np.ndarray,
663
+ part_mesh: trimesh.Trimesh,
664
+ face_ids: np.ndarray,
665
+ affordance_payload: dict,
666
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray, dict[int, list[int]]]:
667
+ original_count = len(grasps)
668
+
669
+ # Raycast from fingertip samples along the gripper closing direction.
670
+ grasp_center_points = self.get_grasp_centers(grasps)
671
+ grasp_part_ids, contact_scores = self.classify_grasps_by_contact(
672
+ grasps,
673
+ part_mesh,
674
+ face_ids,
675
+ )
676
+
677
+ affordances = affordance_payload.get("affordances", [])
678
+ graspable_part_ids = {
679
+ int(affordance["id"])
680
+ for affordance in affordances
681
+ if isinstance(affordance, dict)
682
+ and affordance.get("id") is not None
683
+ and affordance.get("graspable") is True
684
+ }
685
+
686
+ # Select top grasps per part
687
+ grasps, confidences, selected_indices, part_grasp_ids = (
688
+ self.pick_top_grasps(
689
+ grasps,
690
+ confidences,
691
+ grasp_part_ids,
692
+ graspable_part_ids,
693
+ contact_scores,
694
+ )
695
+ )
696
+ grasp_center_points = grasp_center_points[selected_indices]
697
+ if len(grasps) == 0:
698
+ logger.info(
699
+ "Selected per-part grasps: {} -> 0 grasps across 0 parts".format(
700
+ original_count,
701
+ )
702
+ )
703
+ return grasps, confidences, grasp_center_points, part_grasp_ids
704
+
705
+ logger.info(
706
+ "Selected per-part grasps: {} -> {} grasps across {} parts, confidences range from {:.4f} to {:.4f}".format(
707
+ original_count,
708
+ len(grasps),
709
+ len(part_grasp_ids),
710
+ np.min(confidences),
711
+ np.max(confidences),
712
+ )
713
+ )
714
+ return grasps, confidences, grasp_center_points, part_grasp_ids
715
+
716
+ def process(
717
+ self,
718
+ urdf_path: str,
719
+ ) -> bool:
720
+ try:
721
+ return self._process_impl(urdf_path)
722
+ except Exception as exc:
723
+ logger.error(
724
+ "Grasp generation failed for URDF {}: {}".format(
725
+ urdf_path,
726
+ exc,
727
+ )
728
+ )
729
+ return False
730
+
731
+ def _process_impl(
732
+ self,
733
+ urdf_path: str,
734
+ ) -> bool:
735
+ urdf = URDFFile(urdf_path)
736
+ mesh_path = urdf.get_mesh_path(self.cfg.mesh_type)
737
+ mesh_transform = urdf.get_mesh_transform(self.cfg.mesh_type)
738
+ seg_mesh_path = urdf.get_mesh_part_seg_path()
739
+ seg_mesh_transform = self.get_part_seg_mesh_transform(urdf)
740
+ affordance_path = urdf.get_affordance_annot_path()
741
+ affordance_payload = load_json(affordance_path)
742
+
743
+ if not self.cfg.overwrite:
744
+ has_existing_grasp_group = any(
745
+ isinstance(affordance, dict) and "grasp_group" in affordance
746
+ for affordance in affordance_payload.get("affordances", [])
747
+ )
748
+ if has_existing_grasp_group:
749
+ logger.info(
750
+ f"Skip existing grasp poses in affordance JSON: {affordance_path}"
751
+ )
752
+ return True
753
+
754
+ logger.info(f"Generating grasps for mesh: {mesh_path}")
755
+ mesh = load_mesh(
756
+ mesh_path,
757
+ apply_origin=True,
758
+ apply_scale=True,
759
+ **mesh_transform,
760
+ )
761
+
762
+ seg_mesh, face_ids = load_mesh(
763
+ seg_mesh_path,
764
+ apply_origin=True,
765
+ apply_scale=True,
766
+ **seg_mesh_transform,
767
+ )
768
+
769
+ grasp_result = self.infer_grasps(mesh, mesh_path)
770
+ if grasp_result is None:
771
+ return False
772
+ grasps, confidences = grasp_result
773
+ grasps, confidences, grasp_center_points, part_grasp_ids = (
774
+ self.select_grasps(
775
+ grasps,
776
+ confidences,
777
+ seg_mesh,
778
+ face_ids,
779
+ affordance_payload,
780
+ )
781
+ )
782
+
783
+ self.add_grasps_to_affordance(
784
+ affordance_payload,
785
+ grasps,
786
+ confidences,
787
+ part_grasp_ids,
788
+ )
789
+ write_json(affordance_payload, affordance_path)
790
+ logger.info(f"Saved predicted grasp poses to {affordance_path}")
791
+
792
+ self.update_urdf(urdf_path, affordance_path)
793
+ logger.info(
794
+ "Grasp generation succeeded: {} -> {} ({} grasps)".format(
795
+ mesh_path,
796
+ affordance_path,
797
+ len(grasps),
798
+ )
799
+ )
800
+ if self.cfg.visualize_grasps:
801
+ gripper_mesh = load_mesh(self.cfg.gripper_mesh_path)
802
+ self.grasp_visualizer = show_grasps(
803
+ mesh,
804
+ grasps,
805
+ confidences,
806
+ self.cfg.gripper_name,
807
+ self.cfg.model_name,
808
+ grasp_center_points=grasp_center_points,
809
+ visualizer=self.grasp_visualizer,
810
+ visualizer_port=self.cfg.visualizer_port,
811
+ gripper_info=self.pipeline.get_gripper_info()
812
+ if self.cfg.model_name == "graspgenx"
813
+ else None,
814
+ gripper_mesh=gripper_mesh,
815
+ )
816
+ return True
817
+
818
+
819
+ def run_grasp(cfg: GraspPoseConfig) -> None:
820
+ generator = GraspGenerator(cfg)
821
+ for urdf_path in cfg.urdf_paths:
822
+ generator.process(urdf_path)
823
+
824
+ if cfg.visualize_grasps and generator.grasp_visualizer is not None:
825
+ logger.info(
826
+ "Keeping grasp visualization alive at http://localhost:{} "
827
+ "(press Ctrl+C to exit).".format(cfg.visualizer_port)
828
+ )
829
+ try:
830
+ while True:
831
+ time.sleep(1)
832
+ except KeyboardInterrupt:
833
+ logger.info("Stopping grasp visualization.")
834
+
835
+
836
+ def entrypoint(*args, **kwargs) -> None:
837
+ cfg = tyro.cli(GraspPoseConfig)
838
+ run_grasp(cfg)
839
+
840
+
841
+ if __name__ == "__main__":
842
+ entrypoint()
embodied_gen/scripts/affordance_annot/part_seg.py ADDED
@@ -0,0 +1,697 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Project EmbodiedGen
2
+ #
3
+ # Copyright (c) 2025 Horizon Robotics. All Rights Reserved.
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
14
+ # implied. See the License for the specific language governing
15
+ # permissions and limitations under the License.
16
+
17
+
18
+ import os
19
+ import shutil
20
+ import sys
21
+ from dataclasses import asdict, dataclass, field
22
+ from typing import Literal
23
+
24
+ import json_repair
25
+ from embodied_gen.utils.general import filter_warnings
26
+ from embodied_gen.utils.gpt_clients import GPT_CLIENT
27
+
28
+ filter_warnings()
29
+
30
+ import numpy as np
31
+ import trimesh
32
+ import tyro
33
+ from huggingface_hub import snapshot_download
34
+ from embodied_gen.utils.general import set_seed
35
+ from embodied_gen.utils.geometry import (
36
+ EnclosedFaceLabelFillConfig,
37
+ SmoothFaceLabelMergeConfig,
38
+ fill_small_enclosed_face_labels,
39
+ merge_smooth_face_labels,
40
+ normalize_mesh,
41
+ )
42
+ from embodied_gen.utils.io_utils import URDFFile, load_mesh, save_mesh
43
+ from embodied_gen.utils.log import logger
44
+ from embodied_gen.utils.process_media import render_asset3d
45
+ from embodied_gen.utils.vis_utils import PALETTE, collect_colors, render_grid
46
+ from embodied_gen.validators.quality_checkers import PartSegChecker
47
+
48
+ __all__ = [
49
+ "FaceIdPostProcessConfig",
50
+ "PartSegConfig",
51
+ "PartSegmenter",
52
+ "entrypoint",
53
+ ]
54
+
55
+
56
+ @dataclass
57
+ class P3SAMConfig:
58
+ threshold: float = 0.95
59
+ post_process: bool = True
60
+ save_mid_res: bool = False
61
+ show_info: bool = True
62
+ is_parallel: bool = False
63
+ seed: int = 42
64
+ clean_mesh_flag: bool = False
65
+ prompt_bs: int = 8 # Lower this for reduced GPU memory usage.
66
+
67
+
68
+ @dataclass
69
+ class FaceIdPostProcessConfig:
70
+ enabled: bool = True
71
+ max_smooth_angle_deg: float = 5.0
72
+ vertex_merge_tolerance: float = 0.0
73
+ min_component_area_ratio: float = 0.0
74
+ min_component_faces: int = 1
75
+ preserve_negative: bool = False
76
+ fill_enclosed_components: bool = True
77
+ max_enclosed_component_area_ratio: float = 0.005
78
+ max_enclosed_component_faces: int = 100
79
+ min_enclosure_neighbor_ratio: float = 0.8
80
+
81
+
82
+ @dataclass
83
+ class PartSegConfig:
84
+ urdf_paths: list[str] = field(default_factory=list)
85
+ output_dirs: list[str] = field(default_factory=list)
86
+ model_name: str = "p3sam"
87
+ thirdparty_root: str = ""
88
+ mesh_type: Literal["visual", "collision"] = "visual"
89
+ merge_retries: int = 3
90
+ p3sam: P3SAMConfig = field(default_factory=P3SAMConfig)
91
+ face_id_postprocess: FaceIdPostProcessConfig = field(
92
+ default_factory=FaceIdPostProcessConfig
93
+ )
94
+ grid_num_images: int = 6
95
+ grid_rows: int = 2
96
+ grid_cols: int = 3
97
+ grid_image_size: int = 512
98
+ debug_mode: bool = False
99
+ overwrite: bool = True
100
+
101
+
102
+ class PartSegmenter:
103
+ def __init__(self, cfg: PartSegConfig):
104
+ self.validate_config(cfg)
105
+ self.cfg = cfg
106
+ self.seg_checker = PartSegChecker(GPT_CLIENT)
107
+ self.model = self.import_model()
108
+ self.pipeline = self.build_pipeline(self.model)
109
+
110
+ def validate_config(self, cfg: PartSegConfig) -> None:
111
+ if cfg.model_name != "p3sam":
112
+ raise ValueError(
113
+ "unsupported model '{}', available models: p3sam".format(
114
+ cfg.model_name
115
+ )
116
+ )
117
+
118
+ if cfg.model_name == "p3sam":
119
+ cfg.thirdparty_root = "thirdparty/Hunyuan3D-Part"
120
+
121
+ if not cfg.urdf_paths:
122
+ raise ValueError("urdf_paths must be provided.")
123
+
124
+ if len(cfg.output_dirs) == 0:
125
+ cfg.output_dirs = [
126
+ os.path.join(os.path.dirname(path), "affordance")
127
+ for path in cfg.urdf_paths
128
+ ]
129
+ if len(cfg.urdf_paths) != len(cfg.output_dirs):
130
+ raise ValueError(
131
+ "urdf-derived mesh paths and output_dirs must have the same length, "
132
+ f"got {len(cfg.urdf_paths)} and {len(cfg.output_dirs)}."
133
+ )
134
+
135
+ def import_model(self):
136
+ if self.cfg.model_name == "p3sam":
137
+ p3sam_root = os.path.join(self.cfg.thirdparty_root, "P3-SAM")
138
+ p3sam_demo = os.path.join(p3sam_root, "demo")
139
+ for path in [p3sam_demo, p3sam_root]:
140
+ if path not in sys.path:
141
+ sys.path.append(path)
142
+ from embodied_gen.utils.monkey_patch.p3sam import (
143
+ monkey_patch_p3sam,
144
+ )
145
+
146
+ monkey_patch_p3sam()
147
+ from auto_mask import AutoMask
148
+
149
+ return AutoMask
150
+
151
+ raise ValueError("unsupported model '{}'".format(self.cfg.model_name))
152
+
153
+ def build_pipeline(self, model):
154
+ if self.cfg.model_name == "p3sam":
155
+ ckpt_path = os.path.expanduser(
156
+ "~/.cache/huggingface/hub/P3-SAM/p3sam/p3sam.safetensors"
157
+ )
158
+ if not os.path.exists(ckpt_path):
159
+ download_dir = os.path.expanduser(
160
+ "~/.cache/huggingface/hub/P3-SAM"
161
+ )
162
+ logger.info(
163
+ "Downloading P3-SAM weights from Hugging Face to {}. "
164
+ "First-time use may take several minutes; please wait patiently.".format(
165
+ ckpt_path
166
+ )
167
+ )
168
+ snapshot_download(
169
+ repo_id="tencent/Hunyuan3D-Part",
170
+ allow_patterns="p3sam/*",
171
+ local_dir=download_dir,
172
+ )
173
+ sonata_path = os.path.expanduser(
174
+ "~/.cache/huggingface/hub/sonata/sonata.pth"
175
+ )
176
+ if not os.path.exists(sonata_path):
177
+ logger.info(
178
+ "Downloading Sonata weights from Hugging Face to {}. "
179
+ "First-time use may take several minutes; please wait patiently.".format(
180
+ sonata_path
181
+ )
182
+ )
183
+ return model(
184
+ ckpt_path=ckpt_path,
185
+ threshold=self.cfg.p3sam.threshold,
186
+ post_process=self.cfg.p3sam.post_process,
187
+ )
188
+
189
+ raise ValueError("unsupported model '{}'".format(self.cfg.model_name))
190
+
191
+ def build_texture(
192
+ self,
193
+ mesh: trimesh.Trimesh,
194
+ face_colors: np.ndarray,
195
+ min_cell: int = 16,
196
+ max_tex: int = 8192,
197
+ ) -> trimesh.Trimesh:
198
+ if len(face_colors) != len(mesh.faces):
199
+ raise ValueError(
200
+ f"face_colors length must match mesh faces, got {len(face_colors)} and {len(mesh.faces)}"
201
+ )
202
+
203
+ from PIL import Image, ImageDraw
204
+
205
+ faces = np.asarray(mesh.faces, dtype=np.int64)
206
+ vertices = np.asarray(mesh.vertices, dtype=np.float64)
207
+ n_faces = len(faces)
208
+ grid = int(np.ceil(np.sqrt(n_faces)))
209
+ textured_size = 1 << int(np.ceil(np.log2(max(1, grid * min_cell))))
210
+ textured_size = min(max_tex, max(min_cell, textured_size))
211
+ cell = textured_size / grid
212
+
213
+ image = Image.new(
214
+ "RGBA", (textured_size, textured_size), (255, 255, 255, 255)
215
+ )
216
+ draw = ImageDraw.Draw(image)
217
+ uv = np.zeros((n_faces * 3, 2), dtype=np.float64)
218
+ local_uv = np.asarray(
219
+ [
220
+ [0.18, 0.18],
221
+ [0.82, 0.18],
222
+ [0.18, 0.82],
223
+ ],
224
+ dtype=np.float64,
225
+ )
226
+
227
+ for face_idx, color in enumerate(face_colors):
228
+ row, col = divmod(face_idx, grid)
229
+ rgba = tuple(int(channel) for channel in color)
230
+ x0 = int(np.floor(col * cell))
231
+ y0 = int(np.floor(row * cell))
232
+ x1 = int(np.ceil((col + 1) * cell))
233
+ y1 = int(np.ceil((row + 1) * cell))
234
+ draw.rectangle((x0, y0, x1, y1), fill=rgba)
235
+ uv[face_idx * 3 : face_idx * 3 + 3, 0] = (
236
+ col + local_uv[:, 0]
237
+ ) / grid
238
+ uv[face_idx * 3 : face_idx * 3 + 3, 1] = (
239
+ 1.0 - (row + local_uv[:, 1]) / grid
240
+ )
241
+
242
+ textured_mesh = trimesh.Trimesh(
243
+ vertices=vertices[faces.reshape(-1)],
244
+ faces=np.arange(n_faces * 3, dtype=np.int64).reshape(n_faces, 3),
245
+ process=False,
246
+ )
247
+ material = trimesh.visual.texture.SimpleMaterial(
248
+ image=image,
249
+ diffuse=[255, 255, 255, 255],
250
+ name="part_segmentation",
251
+ )
252
+ textured_mesh.visual = trimesh.visual.texture.TextureVisuals(
253
+ uv=uv, material=material
254
+ )
255
+ return textured_mesh
256
+
257
+ def remap_to_palette(self, face_ids: np.ndarray) -> np.ndarray:
258
+ face_ids = np.asarray(face_ids)
259
+ mapped_ids = np.full(face_ids.shape, -1, dtype=np.int64)
260
+ unique_ids = np.unique(face_ids[face_ids >= 0])
261
+ if len(unique_ids) > len(PALETTE):
262
+ return None
263
+
264
+ for palette_idx, part_id in enumerate(unique_ids):
265
+ palette_id = PALETTE[palette_idx][0]
266
+ mapped_ids[face_ids == part_id] = palette_id
267
+ return mapped_ids
268
+
269
+ def export_seg_mesh(
270
+ self,
271
+ mesh,
272
+ face_ids: np.ndarray,
273
+ output_dir: str,
274
+ mesh_transform: dict | None = None,
275
+ ) -> None:
276
+ output_path = os.path.join(output_dir, "mesh_part_seg.glb")
277
+ palette_colors = {
278
+ palette_id: color for palette_id, color, _ in PALETTE
279
+ }
280
+
281
+ face_colors = np.zeros((len(face_ids), 4), dtype=np.uint8)
282
+ face_colors[:, 3] = 255
283
+ for idx, part_id in enumerate(face_ids):
284
+ if part_id < 0:
285
+ face_colors[idx, :3] = np.array([0, 0, 0], dtype=np.uint8)
286
+ else:
287
+ face_colors[idx, :3] = palette_colors[int(part_id)]
288
+
289
+ color_mesh = self.build_texture(mesh.copy(), face_colors)
290
+ color_mesh.metadata["face_ids"] = face_ids.tolist()
291
+ if mesh_transform is None:
292
+ save_mesh(
293
+ color_mesh, output_path, apply_origin=False, apply_scale=False
294
+ )
295
+ else:
296
+ save_mesh(
297
+ color_mesh,
298
+ output_path,
299
+ apply_origin=True,
300
+ apply_scale=True,
301
+ **mesh_transform,
302
+ )
303
+ return output_path
304
+
305
+ def count_parts(self, face_ids: np.ndarray) -> int:
306
+ valid_ids = np.asarray(face_ids)[np.asarray(face_ids) >= 0]
307
+ return int(len(np.unique(valid_ids)))
308
+
309
+ def postprocess_face_ids(
310
+ self,
311
+ mesh: trimesh.Trimesh,
312
+ face_ids: np.ndarray | None,
313
+ ) -> np.ndarray | None:
314
+ if face_ids is None or not self.cfg.face_id_postprocess.enabled:
315
+ return face_ids
316
+
317
+ merge_cfg = SmoothFaceLabelMergeConfig(
318
+ max_smooth_angle_deg=self.cfg.face_id_postprocess.max_smooth_angle_deg,
319
+ vertex_merge_tolerance=self.cfg.face_id_postprocess.vertex_merge_tolerance,
320
+ min_component_area_ratio=self.cfg.face_id_postprocess.min_component_area_ratio,
321
+ min_component_faces=self.cfg.face_id_postprocess.min_component_faces,
322
+ preserve_negative=self.cfg.face_id_postprocess.preserve_negative,
323
+ )
324
+ face_ids, stats = merge_smooth_face_labels(mesh, face_ids, merge_cfg)
325
+ if stats["faces_changed"] > 0:
326
+ logger.info(
327
+ "Part seg face_id postprocess changed "
328
+ f"{stats['faces_changed']} faces across "
329
+ f"{stats['components_changed']} smooth components."
330
+ )
331
+ if self.cfg.face_id_postprocess.fill_enclosed_components:
332
+ fill_cfg = EnclosedFaceLabelFillConfig(
333
+ max_component_area_ratio=(
334
+ self.cfg.face_id_postprocess.max_enclosed_component_area_ratio
335
+ ),
336
+ max_component_faces=(
337
+ self.cfg.face_id_postprocess.max_enclosed_component_faces
338
+ ),
339
+ min_enclosure_neighbor_ratio=(
340
+ self.cfg.face_id_postprocess.min_enclosure_neighbor_ratio
341
+ ),
342
+ vertex_merge_tolerance=(
343
+ self.cfg.face_id_postprocess.vertex_merge_tolerance
344
+ ),
345
+ )
346
+ face_ids, stats = fill_small_enclosed_face_labels(
347
+ mesh,
348
+ face_ids,
349
+ fill_cfg,
350
+ )
351
+ if stats["faces_changed"] > 0:
352
+ logger.info(
353
+ "Part seg face_id postprocess filled "
354
+ f"{stats['faces_changed']} enclosed faces across "
355
+ f"{stats['components_filled']} components."
356
+ )
357
+ return face_ids
358
+
359
+ def update_urdf(
360
+ self,
361
+ urdf_path: str,
362
+ seg_mesh_path: str,
363
+ n_parts: int,
364
+ check_info: str,
365
+ mesh_transform: dict | None = None,
366
+ ) -> None:
367
+ urdf_dir = os.path.dirname(urdf_path)
368
+ seg_mesh_file = (
369
+ os.path.relpath(
370
+ seg_mesh_path,
371
+ urdf_dir,
372
+ )
373
+ if seg_mesh_path
374
+ else None
375
+ )
376
+ origin_attrs = {"xyz": "0 0 0", "rpy": "0 0 0"}
377
+ mesh_attrs = {"filename": seg_mesh_file, "scale": "1 1 1"}
378
+ if mesh_transform:
379
+ origin_attrs = {
380
+ "xyz": str(mesh_transform["origin_xyz"]),
381
+ "rpy": str(mesh_transform["origin_rpy"]),
382
+ }
383
+ mesh_attrs["scale"] = str(mesh_transform["scale"])
384
+
385
+ updates = {
386
+ "custom_data/affordance/part_count": n_parts,
387
+ "custom_data/affordance/visual_seg": {
388
+ "text": None,
389
+ "clear_attrs": True,
390
+ "clear_children": True,
391
+ },
392
+ "custom_data/affordance/visual_seg/origin": {
393
+ "attrs": origin_attrs,
394
+ },
395
+ "custom_data/affordance/visual_seg/geometry/mesh": {
396
+ "attrs": mesh_attrs,
397
+ },
398
+ "custom_data/quality/PartSegChecker": check_info,
399
+ }
400
+
401
+ URDFFile(urdf_path).write(updates)
402
+
403
+ def render_video(
404
+ self, mesh_path: str, output_dir: str, output_subdir: str = "renders"
405
+ ) -> list[str]:
406
+ return render_asset3d(
407
+ mesh_path,
408
+ output_root=output_dir,
409
+ distance=5.0,
410
+ num_images=128,
411
+ elevation=(45.0,),
412
+ gen_color_mp4=True,
413
+ output_subdir=output_subdir,
414
+ pbr_light_factor=1.5,
415
+ pbr_metallic=False,
416
+ )
417
+
418
+ def render_grid(
419
+ self,
420
+ mesh_path: str,
421
+ output_dir: str,
422
+ output_subdir: str = "renders",
423
+ ) -> tuple[str, list[str]]:
424
+ grid_path, view_paths = render_grid(
425
+ mesh_path,
426
+ output_dir,
427
+ output_subdir=output_subdir,
428
+ num_images=self.cfg.grid_num_images,
429
+ grid_rows=self.cfg.grid_rows,
430
+ grid_cols=self.cfg.grid_cols,
431
+ view_size=self.cfg.grid_image_size,
432
+ )
433
+ return grid_path, view_paths
434
+
435
+ def render_seg_result(
436
+ self,
437
+ mesh: trimesh.Trimesh,
438
+ face_ids: np.ndarray,
439
+ output_dir: str,
440
+ mesh_transform: dict | None = None,
441
+ ) -> tuple[str, str, str]:
442
+ seg_mesh_path = self.export_seg_mesh(
443
+ mesh,
444
+ face_ids,
445
+ output_dir,
446
+ mesh_transform,
447
+ )
448
+ mask_grid_path, _ = self.render_grid(
449
+ seg_mesh_path,
450
+ output_dir,
451
+ output_subdir="part_seg_renders",
452
+ )
453
+ color_names = collect_colors(face_ids)
454
+ logger.info(f"Part seg face colors: {color_names}")
455
+ return seg_mesh_path, mask_grid_path, color_names
456
+
457
+ def apply_merge_hints(
458
+ self,
459
+ face_ids: np.ndarray,
460
+ check_info: str,
461
+ ) -> tuple[np.ndarray, bool]:
462
+ merge_groups = self.parse_merge_groups(check_info)
463
+ if not merge_groups:
464
+ return face_ids, False
465
+
466
+ merged_face_ids = np.asarray(face_ids).copy()
467
+ color_to_id = {
468
+ color_name.lower(): palette_id
469
+ for palette_id, _, color_name in PALETTE
470
+ }
471
+ applied_groups = 0
472
+
473
+ for group in merge_groups:
474
+ color_names = group.get("colors", [])
475
+ palette_ids = []
476
+ for color_name in color_names:
477
+ palette_id = color_to_id.get(str(color_name).strip().lower())
478
+ if palette_id is not None and palette_id not in palette_ids:
479
+ palette_ids.append(palette_id)
480
+
481
+ active_ids = [
482
+ palette_id
483
+ for palette_id in palette_ids
484
+ if np.any(merged_face_ids == palette_id)
485
+ ]
486
+ if len(active_ids) < 2:
487
+ continue
488
+
489
+ target_id = max(
490
+ active_ids,
491
+ key=lambda palette_id: int(
492
+ np.count_nonzero(merged_face_ids == palette_id)
493
+ ),
494
+ )
495
+ changed_faces = 0
496
+ for palette_id in active_ids:
497
+ if palette_id == target_id:
498
+ continue
499
+ source_mask = merged_face_ids == palette_id
500
+ changed_faces += int(np.count_nonzero(source_mask))
501
+ merged_face_ids[source_mask] = target_id
502
+
503
+ applied_groups += 1
504
+ logger.info(
505
+ "Applied PartSegChecker merge group for %s: colors=%s, "
506
+ "target_palette_id=%s, faces_changed=%s",
507
+ group.get("target_part", "unknown part"),
508
+ color_names,
509
+ target_id,
510
+ changed_faces,
511
+ )
512
+
513
+ return merged_face_ids, applied_groups > 0
514
+
515
+ def parse_merge_groups(self, check_info: str) -> list[dict]:
516
+ if not isinstance(check_info, str):
517
+ return []
518
+
519
+ json_start = check_info.find("{")
520
+ json_end = check_info.rfind("}")
521
+ if json_start < 0 or json_end <= json_start:
522
+ return []
523
+
524
+ try:
525
+ parsed = json_repair.loads(check_info[json_start : json_end + 1])
526
+ except Exception as exc:
527
+ logger.warning(
528
+ f"Failed to parse PartSegChecker merge hints: {exc}"
529
+ )
530
+ return []
531
+
532
+ merge_groups = parsed.get("merge_groups", [])
533
+ if not isinstance(merge_groups, list):
534
+ return []
535
+
536
+ valid_groups = []
537
+ for group in merge_groups:
538
+ if not isinstance(group, dict):
539
+ continue
540
+ colors = group.get("colors", [])
541
+ if not isinstance(colors, list) or len(colors) < 2:
542
+ continue
543
+ valid_groups.append(group)
544
+ return valid_groups
545
+
546
+ def infer_seg(self, mesh: trimesh.Trimesh, output_dir: str) -> bool:
547
+ unit_mesh = normalize_mesh(mesh)
548
+ if self.cfg.model_name == "p3sam":
549
+ set_seed(self.cfg.p3sam.seed)
550
+ os.makedirs(output_dir, exist_ok=True)
551
+ _, face_ids, _ = self.pipeline.predict_aabb(
552
+ unit_mesh,
553
+ save_path=output_dir,
554
+ **asdict(self.cfg.p3sam),
555
+ )
556
+ else:
557
+ raise ValueError(
558
+ "unsupported model '{}'".format(self.cfg.model_name)
559
+ )
560
+ face_ids = self.remap_to_palette(face_ids)
561
+ return face_ids
562
+
563
+ def process(self, urdf_path: str, output_dir: str) -> bool:
564
+ try:
565
+ return self._process_impl(urdf_path, output_dir)
566
+ except Exception as exc:
567
+ logger.error(
568
+ "Part segmentation failed for URDF {}: {}".format(
569
+ urdf_path,
570
+ exc,
571
+ )
572
+ )
573
+ return False
574
+
575
+ def _process_impl(self, urdf_path: str, output_dir: str) -> bool:
576
+ seg_mesh_path = os.path.join(output_dir, "mesh_part_seg.glb")
577
+ mask_grid_path = None
578
+ if not self.cfg.overwrite and os.path.exists(seg_mesh_path):
579
+ logger.info(
580
+ f"Skip existing part segmentation result: {seg_mesh_path}"
581
+ )
582
+ return True
583
+
584
+ urdf = URDFFile(urdf_path)
585
+ category = urdf.get_category()
586
+ mesh_path = urdf.get_mesh_path(self.cfg.mesh_type)
587
+ mesh_transform = {}
588
+ if urdf_path is not None:
589
+ mesh_transform = urdf.get_mesh_transform(self.cfg.mesh_type)
590
+ mesh = load_mesh(
591
+ mesh_path,
592
+ apply_origin=True,
593
+ apply_scale=True,
594
+ **mesh_transform,
595
+ )
596
+ rgb_grid_path, _ = self.render_grid(mesh_path, output_dir)
597
+
598
+ face_ids = self.infer_seg(mesh, output_dir)
599
+ face_ids = self.postprocess_face_ids(mesh, face_ids)
600
+
601
+ if face_ids is None:
602
+ logger.error("Part seg failed due to exceeding palette size. ")
603
+ success, check_info = (
604
+ False,
605
+ f"NO: part number exceeds palette size {len(PALETTE)}",
606
+ )
607
+ seg_mesh_path = None
608
+ else:
609
+ seg_mesh_path, mask_grid_path, color_names = (
610
+ self.render_seg_result(
611
+ mesh,
612
+ face_ids,
613
+ output_dir,
614
+ mesh_transform if urdf_path is not None else None,
615
+ )
616
+ )
617
+ success, check_info = self.seg_checker(
618
+ category,
619
+ color_names,
620
+ rgb_grid_path,
621
+ mask_grid_path,
622
+ )
623
+ self.retry_count = 0
624
+ while not success and self.retry_count < self.cfg.merge_retries:
625
+ merged_face_ids, merge_applied = self.apply_merge_hints(
626
+ face_ids,
627
+ check_info,
628
+ )
629
+ if merge_applied:
630
+ face_ids = merged_face_ids
631
+ # 备份mask_grid_path
632
+ mask_grid_path_bak = mask_grid_path.replace(
633
+ "affordance_grid",
634
+ f"affordance_grid_{self.retry_count}",
635
+ )
636
+ if os.path.exists(mask_grid_path):
637
+ shutil.copy2(mask_grid_path, mask_grid_path_bak)
638
+ seg_mesh_path, mask_grid_path, color_names = (
639
+ self.render_seg_result(
640
+ mesh,
641
+ face_ids,
642
+ output_dir,
643
+ mesh_transform if urdf_path is not None else None,
644
+ )
645
+ )
646
+ success, check_info = self.seg_checker(
647
+ category,
648
+ color_names,
649
+ rgb_grid_path,
650
+ mask_grid_path,
651
+ )
652
+ self.retry_count += 1
653
+
654
+ if success:
655
+ logger.info(
656
+ f"Part seg successful for {mesh_path} with {self.count_parts(face_ids)} parts. "
657
+ )
658
+ else:
659
+ logger.error(
660
+ f"Part seg failed for {mesh_path}. Checker info: {check_info}"
661
+ )
662
+ self.update_urdf(
663
+ urdf_path,
664
+ seg_mesh_path,
665
+ self.count_parts(face_ids) if face_ids is not None else 0,
666
+ check_info,
667
+ mesh_transform if urdf_path is not None else None,
668
+ )
669
+ if self.cfg.debug_mode:
670
+ self.render_video(mesh_path, output_dir)
671
+ if seg_mesh_path:
672
+ self.render_video(
673
+ seg_mesh_path, output_dir, output_subdir="part_seg_renders"
674
+ )
675
+ else:
676
+ grid_dirs = {os.path.dirname(rgb_grid_path)}
677
+ if mask_grid_path:
678
+ grid_dirs.add(os.path.dirname(mask_grid_path))
679
+ for grid_dir in grid_dirs:
680
+ if grid_dir:
681
+ shutil.rmtree(grid_dir, ignore_errors=True)
682
+ return success
683
+
684
+
685
+ def run_part_seg(cfg: PartSegConfig) -> None:
686
+ segmenter = PartSegmenter(cfg)
687
+ for urdf_path, output_dir in zip(cfg.urdf_paths, cfg.output_dirs):
688
+ segmenter.process(urdf_path, output_dir)
689
+
690
+
691
+ def entrypoint(*args, **kwargs) -> None:
692
+ cfg = tyro.cli(PartSegConfig)
693
+ run_part_seg(cfg)
694
+
695
+
696
+ if __name__ == "__main__":
697
+ entrypoint()
embodied_gen/scripts/affordance_annot/partsemantics_annot.py ADDED
@@ -0,0 +1,438 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Project EmbodiedGen
2
+ #
3
+ # Copyright (c) 2025 Horizon Robotics. All Rights Reserved.
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
14
+ # implied. See the License for the specific language governing
15
+ # permissions and limitations under the License.
16
+
17
+ import json
18
+ import os
19
+ import shutil
20
+ from dataclasses import dataclass, field
21
+ from typing import Literal
22
+
23
+ import json_repair
24
+ import tyro
25
+ from embodied_gen.utils.general import filter_warnings
26
+
27
+ filter_warnings()
28
+
29
+
30
+ from embodied_gen.utils.gpt_clients import GPT_CLIENT
31
+ from embodied_gen.utils.io_utils import URDFFile, load_mesh
32
+ from embodied_gen.utils.log import logger
33
+ from embodied_gen.utils.vis_utils import (
34
+ PALETTE,
35
+ collect_colors,
36
+ render_grid,
37
+ visualize_partsemantics,
38
+ )
39
+ from embodied_gen.validators.quality_checkers import PartSemanticsChecker
40
+
41
+ __all__ = [
42
+ "PartSemanticsAnnotConfig",
43
+ "PartSemanticsAnnotator",
44
+ "run_partsemantics_annot",
45
+ "entrypoint",
46
+ ]
47
+
48
+
49
+ @dataclass
50
+ class PartSemanticsAnnotConfig:
51
+ urdf_paths: list[str] = field(default_factory=list)
52
+ output_dirs: list[str] = field(default_factory=list)
53
+ mesh_type: Literal["visual", "collision"] = "visual"
54
+ max_tokens: int = 10240
55
+ max_repairs: int = 2
56
+ visualize: bool = False
57
+ vis_fps: int = 12
58
+ vis_frames_per_part: int = 36
59
+ vis_view_size: int = 1080
60
+ grid_num_images: int = 6
61
+ grid_rows: int = 2
62
+ grid_cols: int = 3
63
+ grid_image_size: int = 512
64
+ debug_mode: bool = False
65
+ overwrite: bool = True
66
+
67
+
68
+ class PartSemanticsAnnotator:
69
+ def __init__(self, cfg: PartSemanticsAnnotConfig):
70
+ self.validate_config(cfg)
71
+ self.cfg = cfg
72
+ self.gpt_client = GPT_CLIENT
73
+ self.checker = PartSemanticsChecker(GPT_CLIENT)
74
+ self.system_prompt = (
75
+ "You are an expert annotator for 3D object affordances. You will receive two aligned multi-view grid images of the same object: "
76
+ "the first grid is the RGB render, and the second grid is a colored part mask where each color denotes one segmented part. "
77
+ "You will also receive the object category and the list of colors that appear in the mask.\n"
78
+ "Your task is to infer affordance information for each meaningful colored part.\n"
79
+ "For every annotated part, provide:\n"
80
+ "1. A concise self-contained part name. Prefer the natural standalone part name when it is clear, e.g. \"lampshade\" or \"light bulb\"; add the object category only when the name would otherwise be ambiguous, e.g. \"stem of apple\" rather than \"stem\".\n"
81
+ "2. Whether the part is realistically useful for robotic grasping with a gripper.\n"
82
+ "3. If it is graspable, concrete robotic grasp scenarios with confidence scores.\n"
83
+ "4. Functional labels: concrete short phrases describing what the part can be used for and what functional role it provides.\n"
84
+ "5. Semantic description: a concise natural-language description of the part, including its role, RGB appearance color, material type, surface finish, tactile texture, shape, relative size/position, and any visible markings when inferable.\n"
85
+ "Use the RGB grid to understand physical appearance and the mask grid to map parts to colors. "
86
+ "The mask color is only an ID for the segmented part; do not describe mask colors as physical object colors. "
87
+ "Only use mask colors from the provided color list. Do not invent mask colors. "
88
+ "If a colored region has no clear affordance, still include it when it is a distinct physical part, with conservative functional labels. "
89
+ "Return only valid JSON. Do not wrap the JSON in Markdown or code fences."
90
+ )
91
+ self.user_prompt = (
92
+ "Object category: {category}\n"
93
+ "Mask color list: {color_names}\n"
94
+ "Image inputs:\n"
95
+ "- Image 1: RGB multi-view grid of the object.\n"
96
+ "- Image 2: part mask multi-view grid aligned with Image 1. Different colors indicate different segmented parts.\n\n"
97
+ "Annotate the affordance of the visible colored parts. Match each part to exactly one color from the color list.\n"
98
+ "For each part, include these fields:\n"
99
+ "- \"part_name\": concise semantic name of the part. Use natural standalone names when they are already clear, such as \"lampshade\", \"light bulb\", \"keyboard\", \"wheel\", or \"mug handle\". Use \"part of object\" only when the standalone part name is ambiguous or too generic, such as \"stem of apple\", \"blade of knife\", or \"base of lamp\".\n"
100
+ "- \"mask_color\": one mask color from the provided color list. This is the segmentation color, not the physical RGB color.\n"
101
+ "- \"graspable\": true or false.\n"
102
+ "- \"grasp_scenarios\": list of dicts, each with \"scenario\" and \"confidence\"; each \"confidence\" belongs to that same scenario and is a float from 0.0 to 1.0 estimating how likely a robot gripper would choose this part for that scenario. Use accurate, distinguishable confidences when multiple parts share the same scenario. Do not give high confidence to theoretically possible but uncommon grasps. Use an empty list if graspable is false.\n"
103
+ "- \"functional_labels\": list of 2 to 6 concrete short phrases describing what this part can be used to do and what functional role it provides.\n"
104
+ "- \"semantic_description\": one concise but complete sentence describing the part's RGB appearance color, material type, surface finish, tactile texture, shape, relative size, location, and any visible markings when inferable. Do not mention the mask color unless it is also the real RGB appearance.\n\n"
105
+ "Guidelines:\n"
106
+ "- Use the RGB grid for appearance and the mask grid for color-to-part mapping.\n"
107
+ "- Treat grasping as robotic gripper grasping, not human hand grasping.\n"
108
+ "- A part is graspable only if it is a plausible and useful place for a robot gripper to hold, pick up, stabilize, pull, or manipulate the object.\n"
109
+ "- If a part is only theoretically graspable but normally poor or uncommon for grasping, keep \"graspable\" True only when there is a realistic scenario, and assign low confidence. For example, an apple stem may be graspable in a delicate stem-picking scenario but is low confidence for lifting the apple.\n"
110
+ "- If a part is functional but not graspable, set \"graspable\" to False and still describe its function.\n"
111
+ "- Functional labels should describe what the part can be used for or what function it enables, such as \"support\", \"contain\", \"cut\", \"press\", \"cover\", \"connect\", \"stabilize\", or \"indicate orientation\".\n"
112
+ "- Semantic descriptions should cover visible material and texture cues when inferable: material type such as metal, plastic, ceramic, rubber, fabric, wood, glass, or food skin; surface finish such as glossy, matte, translucent, reflective, or dull; tactile texture such as soft, hard, smooth, rough, fine-grained, pebbled, ribbed, woven, or granular; inherent patterns or special markings such as wood grain, fabric weave, seams, engraved text, logos, printed icons, labels, or scratches.\n"
113
+ "- Keep semantic descriptions concise, usually one sentence under 35 words, and do not invent material or markings that are not visually supported.\n"
114
+ "- Do not assign affordances to background regions.\n"
115
+ "- Do not include colors that are not in the color list.\n\n"
116
+ "Output format: Return exactly one JSON object with this schema:\n"
117
+ "{{\"affordances\": [{{\"part_name\": \"mug handle\", \"mask_color\": \"Red\", \"graspable\": true, \"grasp_scenarios\": [{{\"scenario\": \"grasp the handle to lift the mug\", \"confidence\": 0.92}}, {{\"scenario\": \"hold the handle to stabilize the mug while pouring\", \"confidence\": 0.84}}], \"functional_labels\": [\"provide a side grip\", \"lift the mug without touching the body\", \"stabilize the mug while pouring\"], \"semantic_description\": \"Curved glossy ceramic handle with a hard smooth surface, matching the mug body and sized for side gripping.\"}}, "
118
+ "{{\"part_name\": \"stem of apple\", \"mask_color\": \"Green\", \"graspable\": true, \"grasp_scenarios\": [{{\"scenario\": \"delicately grasp the stem to orient the apple\", \"confidence\": 0.28}}, {{\"scenario\": \"lift the apple by the stem\", \"confidence\": 0.12}}], \"functional_labels\": [\"connect to branch\", \"indicate top orientation\"], \"semantic_description\": \"Small thin brown woody stem with a hard rough fibrous texture protruding from the apple top.\"}}, "
119
+ "{{\"part_name\": \"body of apple\", \"mask_color\": \"Blue\", \"graspable\": true, \"grasp_scenarios\": [{{\"scenario\": \"grasp the apple body to pick it up\", \"confidence\": 0.88}}], \"functional_labels\": [\"contain edible flesh\", \"support biting\", \"main grasp surface\"], \"semantic_description\": \"Large rounded red apple body with glossy smooth food skin, firm texture, and subtle natural color speckles.\"}}]}}\n"
120
+ "If the target object cannot be identified, output exactly: {{\"affordances\": []}}"
121
+ )
122
+
123
+ def validate_config(self, cfg: PartSemanticsAnnotConfig) -> None:
124
+ if not cfg.urdf_paths:
125
+ raise ValueError("urdf_paths must be provided.")
126
+
127
+ if len(cfg.output_dirs) == 0:
128
+ cfg.output_dirs = [
129
+ os.path.join(os.path.dirname(path), "affordance")
130
+ for path in cfg.urdf_paths
131
+ ]
132
+
133
+ if len(cfg.urdf_paths) != len(cfg.output_dirs):
134
+ raise ValueError(
135
+ "urdf_paths and output_dirs must have the same length, "
136
+ f"got {len(cfg.urdf_paths)} and {len(cfg.output_dirs)}."
137
+ )
138
+
139
+ if cfg.grid_num_images != cfg.grid_rows * cfg.grid_cols:
140
+ raise ValueError(
141
+ "grid_num_images must equal grid_rows * grid_cols, "
142
+ f"got {cfg.grid_num_images} and "
143
+ f"{cfg.grid_rows} * {cfg.grid_cols}."
144
+ )
145
+
146
+ def check_requires(self, urdf_path: str) -> tuple[str, str]:
147
+ urdf = URDFFile(urdf_path)
148
+ seg_mesh_path = urdf.get_mesh_part_seg_path()
149
+ if not os.path.exists(seg_mesh_path):
150
+ raise FileNotFoundError(
151
+ f"Part segmentation result not found. Run part segmentation before "
152
+ f"partsemantics annotation. Missing: {seg_mesh_path}"
153
+ )
154
+ return seg_mesh_path
155
+
156
+ def parse_response(self, response: str) -> dict:
157
+ answer = str(response).strip()
158
+ parsed = json.loads(answer)
159
+ return parsed
160
+
161
+ def add_palette_ids(self, payload: dict) -> dict:
162
+ color_to_id = {
163
+ color_name: palette_id for palette_id, _, color_name in PALETTE
164
+ }
165
+ for partsemantics in payload.get("affordances", []):
166
+ if isinstance(partsemantics, dict):
167
+ partsemantics["id"] = color_to_id.get(
168
+ partsemantics.get("mask_color")
169
+ )
170
+ return payload
171
+
172
+ def check_response(
173
+ self, response: str, rgb_grid_path: str, mask_grid_path: str
174
+ ) -> dict:
175
+ passed, message = self.checker(
176
+ response,
177
+ rgb_grid_path,
178
+ mask_grid_path,
179
+ )
180
+ check_info = {
181
+ "success": passed,
182
+ "message": message,
183
+ }
184
+ if not passed:
185
+ feedback = self._parse_checker_message(message)
186
+ modified_response = (
187
+ feedback.get("modified_response")
188
+ if isinstance(feedback, dict)
189
+ else None
190
+ )
191
+ if isinstance(modified_response, str):
192
+ check_info["modified_response"] = modified_response.strip()
193
+ elif modified_response is not None:
194
+ check_info["modified_response"] = json.dumps(
195
+ modified_response,
196
+ ensure_ascii=False,
197
+ )
198
+ return check_info
199
+
200
+ def _parse_checker_message(self, checker_message: str) -> dict | None:
201
+ if not isinstance(checker_message, str):
202
+ return None
203
+
204
+ answer = checker_message.strip()
205
+ try:
206
+ feedback = json_repair.loads(answer)
207
+ except (json.JSONDecodeError, ValueError, TypeError):
208
+ json_start = answer.find("{")
209
+ json_end = answer.rfind("}")
210
+ if json_start < 0 or json_end <= json_start:
211
+ return None
212
+ try:
213
+ feedback = json_repair.loads(answer[json_start : json_end + 1])
214
+ except (json.JSONDecodeError, ValueError, TypeError):
215
+ return None
216
+
217
+ return feedback if isinstance(feedback, dict) else None
218
+
219
+ def _quality_check_for_save(
220
+ self, quality_check: dict | None
221
+ ) -> dict | None:
222
+ if quality_check is None:
223
+ return None
224
+ if not isinstance(quality_check, dict):
225
+ return {"success": None, "message": str(quality_check)}
226
+
227
+ message = quality_check.get("message", "")
228
+ feedback = self._parse_checker_message(message)
229
+ if isinstance(feedback, dict) and feedback.get("reason") is not None:
230
+ message = str(feedback["reason"]).strip()
231
+ elif isinstance(message, str) and message.strip().startswith("NO:"):
232
+ message = message.strip()[3:].strip()
233
+
234
+ return {
235
+ "success": quality_check.get("success"),
236
+ "message": message,
237
+ }
238
+
239
+ def save_annotation(
240
+ self, response: str, output_dir: str, quality_check: dict | None = None
241
+ ) -> str:
242
+ annotation_path = os.path.join(output_dir, "affordance_annot.json")
243
+ if self.cfg.debug_mode:
244
+ raw_output_path = os.path.join(
245
+ output_dir, "affordance_annot_raw.txt"
246
+ )
247
+ with open(raw_output_path, "w", encoding="utf-8") as f:
248
+ f.write(str(response))
249
+
250
+ try:
251
+ payload = self.parse_response(response)
252
+ payload = self.add_palette_ids(payload)
253
+ except (json.JSONDecodeError, ValueError, TypeError) as exc:
254
+ payload = {
255
+ "status": "parse failed",
256
+ "affordances": [],
257
+ "parse_error": str(exc),
258
+ }
259
+
260
+ saved_quality_check = self._quality_check_for_save(quality_check)
261
+ if saved_quality_check is not None:
262
+ payload["quality_check"] = saved_quality_check
263
+
264
+ with open(annotation_path, "w", encoding="utf-8") as f:
265
+ json.dump(payload, f, ensure_ascii=False, indent=2)
266
+ return annotation_path
267
+
268
+ def update_urdf(self, urdf_path: str, annotation_path: str) -> None:
269
+ if urdf_path is None:
270
+ return
271
+
272
+ URDFFile(urdf_path).write(
273
+ {
274
+ "custom_data/affordance/affordance_annot": os.path.relpath(
275
+ annotation_path,
276
+ os.path.dirname(urdf_path),
277
+ ),
278
+ }
279
+ )
280
+
281
+ def render_grid(
282
+ self,
283
+ mesh_path: str,
284
+ output_dir: str,
285
+ output_subdir: str = "renders",
286
+ ) -> tuple[str, list[str]]:
287
+ grid_path, view_paths = render_grid(
288
+ mesh_path,
289
+ output_dir,
290
+ output_subdir=output_subdir,
291
+ num_images=self.cfg.grid_num_images,
292
+ grid_rows=self.cfg.grid_rows,
293
+ grid_cols=self.cfg.grid_cols,
294
+ view_size=self.cfg.grid_image_size,
295
+ )
296
+ return grid_path, view_paths
297
+
298
+ def _process_once(
299
+ self,
300
+ rgb_grid_path: str,
301
+ mask_grid_path: str,
302
+ color_names: str,
303
+ category: str,
304
+ ) -> tuple[bool, dict, str]:
305
+ response = self.gpt_client.query(
306
+ text_prompt=self.user_prompt.format(
307
+ category=category,
308
+ color_names=color_names,
309
+ ),
310
+ image_base64=[rgb_grid_path, mask_grid_path],
311
+ system_role=self.system_prompt,
312
+ params={"max_tokens": self.cfg.max_tokens},
313
+ )
314
+ check_info = {"success": False, "message": "NO: checker was not run"}
315
+ success = False
316
+ max_checks = self.cfg.max_repairs + 1
317
+ for check_idx in range(max_checks):
318
+ logger.info(
319
+ "PartSemantics quality check: attempt "
320
+ f"{check_idx + 1}/{max_checks}"
321
+ )
322
+ check_info = self.check_response(
323
+ response,
324
+ rgb_grid_path,
325
+ mask_grid_path,
326
+ )
327
+ success = check_info.get("success")
328
+ if success:
329
+ return success, check_info, response
330
+
331
+ if check_idx == max_checks - 1:
332
+ break
333
+
334
+ modified_response = check_info.get("modified_response")
335
+ if modified_response is None:
336
+ break
337
+
338
+ response = modified_response
339
+ logger.warning(
340
+ "PartSemantics checker failed; retrying with modified_response. "
341
+ )
342
+
343
+ return success, check_info, response
344
+
345
+ def process(self, urdf_path: str, output_dir: str) -> bool:
346
+ try:
347
+ return self._process_impl(urdf_path, output_dir)
348
+ except Exception as exc:
349
+ logger.error(
350
+ "PartSemantics annotation failed for URDF {}: {}".format(
351
+ urdf_path,
352
+ exc,
353
+ )
354
+ )
355
+ return False
356
+
357
+ def _process_impl(self, urdf_path: str, output_dir: str) -> bool:
358
+ annotation_path = os.path.join(output_dir, "affordance_annot.json")
359
+ if not self.cfg.overwrite and os.path.exists(annotation_path):
360
+ logger.info(
361
+ f"Skip existing PartSemantics annotation: {annotation_path}"
362
+ )
363
+ return True
364
+
365
+ urdf = URDFFile(urdf_path)
366
+ mesh_path = urdf.get_mesh_path(self.cfg.mesh_type)
367
+ seg_mesh_path = self.check_requires(urdf_path)
368
+
369
+ logger.info("Processing PartSemantics annotation...")
370
+ rgb_grid_path, _ = self.render_grid(mesh_path, output_dir)
371
+ mask_grid_path, _ = self.render_grid(
372
+ seg_mesh_path, output_dir, output_subdir="part_seg_renders"
373
+ )
374
+
375
+ _, face_ids = load_mesh(
376
+ seg_mesh_path, apply_origin=False, apply_scale=False
377
+ )
378
+ color_names = collect_colors(face_ids)
379
+ logger.info(f"Mask colors: {color_names}")
380
+
381
+ category = urdf.get_category()
382
+ success, check_info, response = self._process_once(
383
+ rgb_grid_path,
384
+ mask_grid_path,
385
+ color_names,
386
+ category,
387
+ )
388
+ if success:
389
+ logger.info(
390
+ "PartSemantics annotation passed quality check."
391
+ f"{check_info.get('message')}"
392
+ )
393
+ else:
394
+ logger.error(
395
+ "PartSemantics annotation failed quality check after "
396
+ f"{self.cfg.max_repairs} repair attempts;\nsaving last result. "
397
+ )
398
+
399
+ annotation_path = self.save_annotation(
400
+ response, output_dir, check_info
401
+ )
402
+ self.update_urdf(urdf_path, annotation_path)
403
+ if self.cfg.visualize:
404
+ visualize_partsemantics(
405
+ mesh_path,
406
+ seg_mesh_path,
407
+ annotation_path,
408
+ fps=self.cfg.vis_fps,
409
+ frames_per_part=self.cfg.vis_frames_per_part,
410
+ view_size=self.cfg.vis_view_size,
411
+ )
412
+
413
+ if not self.cfg.debug_mode:
414
+ for grid_dir in {
415
+ os.path.dirname(rgb_grid_path),
416
+ os.path.dirname(mask_grid_path),
417
+ }:
418
+ if grid_dir:
419
+ shutil.rmtree(grid_dir, ignore_errors=True)
420
+
421
+ return success
422
+
423
+
424
+ def run_partsemantics_annot(cfg: PartSemanticsAnnotConfig) -> None:
425
+ annotator = PartSemanticsAnnotator(cfg)
426
+ for urdf_path, output_dir in zip(
427
+ annotator.cfg.urdf_paths, annotator.cfg.output_dirs
428
+ ):
429
+ annotator.process(urdf_path, output_dir)
430
+
431
+
432
+ def entrypoint() -> None:
433
+ cfg = tyro.cli(PartSemanticsAnnotConfig)
434
+ run_partsemantics_annot(cfg)
435
+
436
+
437
+ if __name__ == "__main__":
438
+ entrypoint()
embodied_gen/scripts/compose_layout.py CHANGED
@@ -21,7 +21,6 @@ from dataclasses import dataclass
21
 
22
  import tyro
23
  from embodied_gen.scripts.simulate_sapien import entrypoint as sim_cli
24
- from embodied_gen.utils.enum import LayoutInfo
25
  from embodied_gen.utils.geometry import bfs_placement, compose_mesh_scene
26
  from embodied_gen.utils.log import logger
27
 
 
21
 
22
  import tyro
23
  from embodied_gen.scripts.simulate_sapien import entrypoint as sim_cli
 
24
  from embodied_gen.utils.geometry import bfs_placement, compose_mesh_scene
25
  from embodied_gen.utils.log import logger
26
 
embodied_gen/scripts/deformable_sim/genesis_cloth_converter.py ADDED
@@ -0,0 +1,282 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Project EmbodiedGen
2
+ #
3
+ # Copyright (c) 2025 Horizon Robotics. All Rights Reserved.
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
14
+ # implied. See the License for the specific language governing
15
+ # permissions and limitations under the License.
16
+
17
+
18
+ import json
19
+ import xml.etree.ElementTree as ET
20
+ from dataclasses import dataclass
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ import numpy as np
25
+ import trimesh
26
+ import tyro
27
+ from scipy.spatial.transform import Rotation
28
+
29
+
30
+ @dataclass
31
+ class GenesisClothConvertConfig:
32
+ input_path: str
33
+ output_dir: str
34
+ scale: float | None = None
35
+ overwrite: bool = False
36
+
37
+
38
+ DEFAULT_CLOTH_MATERIAL: dict[str, float | bool] = {
39
+ "rho": 0.2,
40
+ "static_friction": 0.5,
41
+ "kinetic_friction": 0.4,
42
+ "stretch_compliance": 1e-9,
43
+ "bending_compliance": 1e-6,
44
+ "stretch_relaxation": 0.5,
45
+ "bending_relaxation": 0.2,
46
+ "air_resistance": 1e-3,
47
+ "particle_size": 0.02,
48
+ "self_collision": True,
49
+ }
50
+
51
+
52
+ def convert_cloth_to_genesis(
53
+ input_path: str,
54
+ output_dir: str,
55
+ scale: float | None = None,
56
+ overwrite: bool = False,
57
+ ) -> str:
58
+ """Convert one URDF visual mesh to a Genesis cloth asset package.
59
+
60
+ Args:
61
+ input_path: URDF path containing a visual mesh reference.
62
+ output_dir: Directory where the ``genesis`` package is written.
63
+ scale: Optional uniform scale recorded in the manifest and used by the
64
+ simulator. The exported mesh itself is not rescaled.
65
+ overwrite: Whether to replace an existing Genesis package.
66
+
67
+ Returns:
68
+ Path to the generated manifest JSON file.
69
+ """
70
+
71
+ urdf_path = Path(input_path)
72
+ if not urdf_path.exists():
73
+ raise FileNotFoundError(f"Input URDF does not exist: {urdf_path}")
74
+ if urdf_path.suffix.lower() != ".urdf":
75
+ raise ValueError(f"input_path must point to a URDF file: {urdf_path}")
76
+
77
+ source_mesh, urdf_transform = _resolve_urdf_visual_mesh(urdf_path)
78
+ package_root = Path(output_dir)
79
+ genesis_dir = package_root / "genesis"
80
+ if genesis_dir.exists() and not overwrite:
81
+ raise FileExistsError(
82
+ f"Genesis cloth package already exists: {genesis_dir}. "
83
+ "Pass overwrite=True to replace it."
84
+ )
85
+ genesis_dir.mkdir(parents=True, exist_ok=True)
86
+
87
+ mesh = _load_mesh(source_mesh)
88
+ mesh, warnings = _clean_mesh(mesh)
89
+ mesh.apply_transform(urdf_transform["matrix"])
90
+ warnings.append("applied URDF visual origin transform to cloth mesh")
91
+
92
+ cloth_mesh_path = genesis_dir / "cloth_mesh.obj"
93
+ material_path = genesis_dir / "cloth_material.json"
94
+ manifest_path = genesis_dir / "manifest.json"
95
+
96
+ mesh.export(cloth_mesh_path)
97
+ _write_json(material_path, DEFAULT_CLOTH_MATERIAL)
98
+
99
+ mesh_stats = _mesh_stats(mesh)
100
+ warnings.extend(_mesh_warnings(mesh_stats))
101
+ manifest = {
102
+ "asset_name": source_mesh.stem,
103
+ "source_urdf": _relative_path(urdf_path, package_root),
104
+ "source_mesh": _relative_path(source_mesh, package_root),
105
+ "cloth_mesh": _relative_path(cloth_mesh_path, package_root),
106
+ "material": _relative_path(material_path, package_root),
107
+ "scale": 1.0 if scale is None else float(scale),
108
+ "up_axis": "z",
109
+ "urdf_visual_origin": urdf_transform["origin"],
110
+ "mesh_stats": mesh_stats,
111
+ "warnings": warnings,
112
+ }
113
+ _write_json(manifest_path, manifest)
114
+ return str(manifest_path)
115
+
116
+
117
+ def entrypoint(**kwargs) -> str:
118
+ if kwargs:
119
+ cfg = GenesisClothConvertConfig(**kwargs)
120
+ else:
121
+ cfg = tyro.cli(GenesisClothConvertConfig)
122
+
123
+ manifest_path = convert_cloth_to_genesis(
124
+ input_path=cfg.input_path,
125
+ output_dir=cfg.output_dir,
126
+ scale=cfg.scale,
127
+ overwrite=cfg.overwrite,
128
+ )
129
+ print(manifest_path)
130
+ return manifest_path
131
+
132
+
133
+ def _resolve_urdf_visual_mesh(urdf_path: Path) -> tuple[Path, dict[str, Any]]:
134
+ root = ET.parse(urdf_path).getroot()
135
+ for visual_tag in root.findall(".//visual"):
136
+ mesh_tag = visual_tag.find("geometry/mesh")
137
+ if mesh_tag is None:
138
+ continue
139
+ filename = mesh_tag.get("filename")
140
+ if not filename:
141
+ raise ValueError(
142
+ f"Visual mesh in URDF has no filename: {urdf_path}"
143
+ )
144
+
145
+ source_mesh = Path(filename)
146
+ if not source_mesh.is_absolute():
147
+ source_mesh = urdf_path.parent / source_mesh
148
+ if not source_mesh.exists():
149
+ raise FileNotFoundError(
150
+ f"Visual mesh does not exist: {source_mesh}"
151
+ )
152
+
153
+ origin_tag = visual_tag.find("origin")
154
+ xyz, rpy = _parse_origin(origin_tag)
155
+ matrix = np.eye(4, dtype=float)
156
+ matrix[:3, :3] = Rotation.from_euler("xyz", rpy).as_matrix()
157
+ matrix[:3, 3] = xyz
158
+ return source_mesh, {
159
+ "matrix": matrix,
160
+ "origin": {
161
+ "urdf": urdf_path.name,
162
+ "xyz": [round(float(value), 6) for value in xyz],
163
+ "rpy": [round(float(value), 6) for value in rpy],
164
+ },
165
+ }
166
+
167
+ raise ValueError(f"No visual mesh found in URDF: {urdf_path}")
168
+
169
+
170
+ def _parse_origin(
171
+ origin_tag: ET.Element | None,
172
+ ) -> tuple[np.ndarray, np.ndarray]:
173
+ if origin_tag is None:
174
+ return np.zeros(3, dtype=float), np.zeros(3, dtype=float)
175
+ xyz = np.fromiter(
176
+ (float(value) for value in origin_tag.get("xyz", "0 0 0").split()),
177
+ dtype=float,
178
+ count=3,
179
+ )
180
+ rpy = np.fromiter(
181
+ (float(value) for value in origin_tag.get("rpy", "0 0 0").split()),
182
+ dtype=float,
183
+ count=3,
184
+ )
185
+ return xyz, rpy
186
+
187
+
188
+ def _load_mesh(source_mesh: Path) -> trimesh.Trimesh:
189
+ loaded = trimesh.load(source_mesh, force="mesh")
190
+ if isinstance(loaded, trimesh.Scene):
191
+ meshes = [
192
+ geometry
193
+ for geometry in loaded.geometry.values()
194
+ if isinstance(geometry, trimesh.Trimesh)
195
+ ]
196
+ if not meshes:
197
+ raise ValueError(f"No mesh geometry found in scene: {source_mesh}")
198
+ loaded = trimesh.util.concatenate(meshes)
199
+ if not isinstance(loaded, trimesh.Trimesh):
200
+ raise TypeError(
201
+ f"Expected a Trimesh from {source_mesh}, got {type(loaded)}"
202
+ )
203
+ if loaded.vertices.size == 0 or loaded.faces.size == 0:
204
+ raise ValueError(f"Source mesh is empty: {source_mesh}")
205
+ return loaded
206
+
207
+
208
+ def _clean_mesh(mesh: trimesh.Trimesh) -> tuple[trimesh.Trimesh, list[str]]:
209
+ mesh = mesh.copy()
210
+ warnings: list[str] = []
211
+ original_vertices = len(mesh.vertices)
212
+ original_faces = len(mesh.faces)
213
+
214
+ if not mesh.is_watertight:
215
+ warnings.append(
216
+ "mesh is not watertight; this is usually acceptable for cloth"
217
+ )
218
+
219
+ mesh.remove_unreferenced_vertices()
220
+ if hasattr(mesh, "nondegenerate_faces"):
221
+ mesh.update_faces(mesh.nondegenerate_faces())
222
+ else:
223
+ mesh.remove_degenerate_faces()
224
+ mesh.merge_vertices()
225
+ mesh.remove_unreferenced_vertices()
226
+
227
+ if (
228
+ len(mesh.vertices) != original_vertices
229
+ or len(mesh.faces) != original_faces
230
+ ):
231
+ warnings.append(
232
+ "mesh cleanup changed topology from "
233
+ f"{original_vertices} vertices/{original_faces} faces to "
234
+ f"{len(mesh.vertices)} vertices/{len(mesh.faces)} faces"
235
+ )
236
+ if len(mesh.vertices) == 0 or len(mesh.faces) == 0:
237
+ raise ValueError("Mesh cleanup produced an empty cloth mesh")
238
+ return mesh, warnings
239
+
240
+
241
+ def _mesh_stats(mesh: trimesh.Trimesh) -> dict[str, Any]:
242
+ bbox_min, bbox_max = mesh.bounds
243
+ extents = np.asarray(mesh.extents, dtype=float)
244
+ return {
245
+ "vertices": int(len(mesh.vertices)),
246
+ "faces": int(len(mesh.faces)),
247
+ "bbox_min": bbox_min.astype(float).round(6).tolist(),
248
+ "bbox_max": bbox_max.astype(float).round(6).tolist(),
249
+ "extents": extents.round(6).tolist(),
250
+ "watertight": bool(mesh.is_watertight),
251
+ }
252
+
253
+
254
+ def _mesh_warnings(mesh_stats: dict[str, Any]) -> list[str]:
255
+ warnings: list[str] = []
256
+ if mesh_stats["faces"] > 20000:
257
+ warnings.append(
258
+ "mesh has more than 20000 faces; Genesis cloth simulation may be slow"
259
+ )
260
+ extents = np.asarray(mesh_stats["extents"], dtype=float)
261
+ if np.any(extents <= 1e-6):
262
+ warnings.append("mesh has a near-zero bounding-box extent")
263
+ if float(extents.max()) > 10.0:
264
+ warnings.append(
265
+ "mesh bounding box is larger than 10 meters; check scale"
266
+ )
267
+ return warnings
268
+
269
+
270
+ def _relative_path(path: Path, root: Path) -> str:
271
+ try:
272
+ return path.relative_to(root).as_posix()
273
+ except ValueError:
274
+ return path.as_posix()
275
+
276
+
277
+ def _write_json(path: Path, data: dict[str, Any]) -> None:
278
+ path.write_text(json.dumps(data, indent=2) + "\n")
279
+
280
+
281
+ if __name__ == "__main__":
282
+ entrypoint()
embodied_gen/scripts/deformable_sim/render_mesh_displacement_heatmap.py ADDED
@@ -0,0 +1,551 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import argparse
2
+ import json
3
+ import math
4
+ from pathlib import Path
5
+
6
+ import cv2
7
+ import numpy as np
8
+
9
+
10
+ def main() -> None:
11
+ parser = argparse.ArgumentParser()
12
+ parser.add_argument("--video-path", required=True)
13
+ parser.add_argument("--mesh-path", required=True)
14
+ parser.add_argument("--particle-positions", required=True)
15
+ parser.add_argument("--sim-config", required=True)
16
+ parser.add_argument("--output-image", required=True)
17
+ parser.add_argument("--output-video", required=True)
18
+ parser.add_argument("--tmp-video", required=True)
19
+ args = parser.parse_args()
20
+
21
+ render_mesh_displacement_heatmap(
22
+ video_path=Path(args.video_path),
23
+ mesh_path=Path(args.mesh_path),
24
+ particle_positions_path=Path(args.particle_positions),
25
+ sim_config_path=Path(args.sim_config),
26
+ output_image_path=Path(args.output_image),
27
+ output_video_path=Path(args.output_video),
28
+ tmp_video_path=Path(args.tmp_video),
29
+ )
30
+
31
+
32
+ def render_mesh_displacement_heatmap(
33
+ video_path: Path,
34
+ mesh_path: Path,
35
+ particle_positions_path: Path,
36
+ sim_config_path: Path,
37
+ output_image_path: Path,
38
+ output_video_path: Path,
39
+ tmp_video_path: Path,
40
+ ) -> None:
41
+ data = np.load(particle_positions_path)
42
+ positions = data["positions"].astype(np.float32)
43
+ rest_positions = data["rest_positions"].astype(np.float32)
44
+ config = json.loads(sim_config_path.read_text())
45
+
46
+ if positions.ndim != 3 or positions.shape[2] != 3:
47
+ raise ValueError(
48
+ f"Invalid particle positions shape: {positions.shape}"
49
+ )
50
+ if rest_positions.shape != positions.shape[1:]:
51
+ raise ValueError(
52
+ "rest_positions must have shape "
53
+ f"{positions.shape[1:]}, got {rest_positions.shape}"
54
+ )
55
+ capture = cv2.VideoCapture(str(video_path))
56
+ if not capture.isOpened():
57
+ raise RuntimeError(f"Could not open video: {video_path}")
58
+
59
+ fps = capture.get(cv2.CAP_PROP_FPS) or float(config.get("fps", 30))
60
+ width = int(capture.get(cv2.CAP_PROP_FRAME_WIDTH))
61
+ height = int(capture.get(cv2.CAP_PROP_FRAME_HEIGHT))
62
+ writer = cv2.VideoWriter(
63
+ str(tmp_video_path),
64
+ cv2.VideoWriter_fourcc(*"mp4v"),
65
+ fps,
66
+ (width, height),
67
+ )
68
+ if not writer.isOpened():
69
+ raise RuntimeError(f"Could not write video: {tmp_video_path}")
70
+
71
+ displacement = np.linalg.norm(
72
+ positions - rest_positions[None, :, :], axis=2
73
+ )
74
+ scale = (
75
+ float(np.percentile(displacement, 95)) if displacement.size else 1.0
76
+ )
77
+ scale = max(scale, 1e-6)
78
+
79
+ camera_pos = np.asarray(config["camera_pos"], dtype=np.float32)
80
+ camera_lookat = np.asarray(config["camera_lookat"], dtype=np.float32)
81
+ camera_fov = float(config["camera_fov"])
82
+ inset_bbox = _compute_global_inset_bbox(
83
+ positions=positions,
84
+ camera_pos=camera_pos,
85
+ camera_lookat=camera_lookat,
86
+ camera_fov=camera_fov,
87
+ image_size=(width, height),
88
+ )
89
+
90
+ snapshot_index = min(
91
+ positions.shape[0] - 1,
92
+ max(0, int(round(fps)) - 1),
93
+ )
94
+
95
+ frame_index = 0
96
+ snapshot_frame = None
97
+ try:
98
+ while frame_index < positions.shape[0]:
99
+ ok, frame_bgr = capture.read()
100
+ if not ok:
101
+ break
102
+
103
+ frame_rgb = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2RGB)
104
+ heatmap = _compose_displacement_frame(
105
+ frame_rgb=frame_rgb,
106
+ positions=positions[frame_index],
107
+ displacement=displacement[frame_index],
108
+ scale=scale,
109
+ camera_pos=camera_pos,
110
+ camera_lookat=camera_lookat,
111
+ camera_fov=camera_fov,
112
+ inset_bbox=inset_bbox,
113
+ )
114
+ if frame_index == snapshot_index:
115
+ snapshot_frame = heatmap
116
+ writer.write(cv2.cvtColor(heatmap, cv2.COLOR_RGB2BGR))
117
+ frame_index += 1
118
+ finally:
119
+ capture.release()
120
+ writer.release()
121
+
122
+ if frame_index == 0 or snapshot_frame is None:
123
+ raise RuntimeError("No frames were written to mesh displacement video")
124
+
125
+ output_image_path.parent.mkdir(parents=True, exist_ok=True)
126
+ output_video_path.parent.mkdir(parents=True, exist_ok=True)
127
+ cv2.imwrite(
128
+ str(output_image_path), cv2.cvtColor(snapshot_frame, cv2.COLOR_RGB2BGR)
129
+ )
130
+
131
+
132
+ def _compose_displacement_frame(
133
+ frame_rgb: np.ndarray,
134
+ positions: np.ndarray,
135
+ displacement: np.ndarray,
136
+ scale: float,
137
+ camera_pos: np.ndarray,
138
+ camera_lookat: np.ndarray,
139
+ camera_fov: float,
140
+ inset_bbox: tuple[int, int, int, int],
141
+ ) -> np.ndarray:
142
+ height, width = frame_rgb.shape[:2]
143
+ pixels, depths = _project_points(
144
+ points=positions,
145
+ camera_pos=camera_pos,
146
+ camera_lookat=camera_lookat,
147
+ camera_fov=camera_fov,
148
+ image_size=(width, height),
149
+ )
150
+ normalized = np.clip(displacement / scale, 0.0, 1.0)
151
+ scalar_layer, weight_layer = _rasterize_particle_scalar_field(
152
+ pixels=pixels,
153
+ depths=depths,
154
+ normalized=normalized,
155
+ image_size=(width, height),
156
+ )
157
+ scalar_blur, weight_blur = _smooth_scalar_field(scalar_layer, weight_layer)
158
+ return _compose_rgb_with_inset(
159
+ frame_rgb=frame_rgb,
160
+ scalar_field=scalar_blur,
161
+ weight_field=weight_blur,
162
+ scale=scale,
163
+ inset_bbox=inset_bbox,
164
+ )
165
+
166
+
167
+ def _compute_global_inset_bbox(
168
+ positions: np.ndarray,
169
+ camera_pos: np.ndarray,
170
+ camera_lookat: np.ndarray,
171
+ camera_fov: float,
172
+ image_size: tuple[int, int],
173
+ ) -> tuple[int, int, int, int]:
174
+ width, height = image_size
175
+ pad = 28
176
+ flat = positions.reshape(-1, 3)
177
+ pixels, depths = _project_points(
178
+ points=flat,
179
+ camera_pos=camera_pos,
180
+ camera_lookat=camera_lookat,
181
+ camera_fov=camera_fov,
182
+ image_size=image_size,
183
+ )
184
+ valid = depths > 1e-5
185
+ if not np.any(valid):
186
+ return (0, 0, width, height)
187
+ px = pixels[valid, 0]
188
+ py = pixels[valid, 1]
189
+ x0 = max(0, int(np.floor(px.min())) - pad)
190
+ x1 = min(width, int(np.ceil(px.max())) + pad)
191
+ y0 = max(0, int(np.floor(py.min())) - pad)
192
+ y1 = min(height, int(np.ceil(py.max())) + pad)
193
+ if x1 <= x0:
194
+ x1 = min(width, x0 + 1)
195
+ if y1 <= y0:
196
+ y1 = min(height, y0 + 1)
197
+ return (x0, y0, x1, y1)
198
+
199
+
200
+ def _rasterize_particle_scalar_field(
201
+ pixels: np.ndarray,
202
+ depths: np.ndarray,
203
+ normalized: np.ndarray,
204
+ image_size: tuple[int, int],
205
+ ) -> tuple[np.ndarray, np.ndarray]:
206
+ width, height = image_size
207
+ scalar_layer = np.zeros((height, width), dtype=np.float32)
208
+ weight_layer = np.zeros((height, width), dtype=np.float32)
209
+ radius = max(5, int(round(min(width, height) / 140.0)))
210
+ for particle_index in np.argsort(depths)[::-1]:
211
+ if depths[particle_index] <= 1e-5:
212
+ continue
213
+
214
+ x_value, y_value = np.rint(pixels[particle_index]).astype(np.int32)
215
+ if (
216
+ x_value < -radius
217
+ or x_value >= width + radius
218
+ or y_value < -radius
219
+ or y_value >= height + radius
220
+ ):
221
+ continue
222
+
223
+ center = (int(x_value), int(y_value))
224
+ cv2.circle(
225
+ scalar_layer, center, radius, float(normalized[particle_index]), -1
226
+ )
227
+ cv2.circle(weight_layer, center, radius, 1.0, -1)
228
+
229
+ return scalar_layer, weight_layer
230
+
231
+
232
+ def _smooth_scalar_field(
233
+ scalar_layer: np.ndarray,
234
+ weight_layer: np.ndarray,
235
+ ) -> tuple[np.ndarray, np.ndarray]:
236
+ height, width = scalar_layer.shape
237
+ radius = max(5, int(round(min(width, height) / 140.0)))
238
+ sigma = max(1.2, float(radius) * 0.55)
239
+ scalar_blur = cv2.GaussianBlur(scalar_layer * weight_layer, (0, 0), sigma)
240
+ weight_blur = cv2.GaussianBlur(weight_layer, (0, 0), sigma)
241
+ scalar_blur = scalar_blur / np.maximum(weight_blur, 1e-6)
242
+ hard_mask = _close_mask(weight_layer > 0.0, radius=max(4, radius * 2))
243
+ scalar_blur[~hard_mask] = 0.0
244
+ weight_blur[~hard_mask] = 0.0
245
+ return scalar_blur, weight_blur
246
+
247
+
248
+ def _close_mask(mask: np.ndarray, radius: int) -> np.ndarray:
249
+ kernel_size = max(3, radius * 2 + 1)
250
+ kernel = cv2.getStructuringElement(
251
+ cv2.MORPH_ELLIPSE,
252
+ (kernel_size, kernel_size),
253
+ )
254
+ closed = cv2.morphologyEx(mask.astype(np.uint8), cv2.MORPH_CLOSE, kernel)
255
+ return closed.astype(bool)
256
+
257
+
258
+ def _compose_rgb_with_inset(
259
+ frame_rgb: np.ndarray,
260
+ scalar_field: np.ndarray,
261
+ weight_field: np.ndarray,
262
+ scale: float,
263
+ inset_bbox: tuple[int, int, int, int],
264
+ ) -> np.ndarray:
265
+ height, width = frame_rgb.shape[:2]
266
+ x0, y0, x1, y1 = inset_bbox
267
+
268
+ heat_bgr = cv2.applyColorMap(
269
+ (np.clip(scalar_field, 0.0, 1.0) * 255).astype(np.uint8),
270
+ cv2.COLORMAP_TURBO,
271
+ )
272
+ heat_layer = cv2.cvtColor(heat_bgr, cv2.COLOR_BGR2RGB)
273
+
274
+ gray = cv2.cvtColor(frame_rgb, cv2.COLOR_RGB2GRAY)
275
+ gray_rgb = cv2.cvtColor(gray, cv2.COLOR_GRAY2RGB)
276
+ hard_mask = weight_field > 0.0
277
+ alpha = np.where(hard_mask, 0.82, 0.0).astype(np.float32)
278
+ inset = (
279
+ gray_rgb.astype(np.float32) * 0.68
280
+ + frame_rgb.astype(np.float32) * 0.12
281
+ )
282
+ inset = (
283
+ inset * (1.0 - alpha[:, :, None])
284
+ + heat_layer.astype(np.float32) * alpha[:, :, None]
285
+ )
286
+ inset = np.clip(inset[y0:y1, x0:x1], 0, 255).astype(np.uint8)
287
+
288
+ inset_width = 250
289
+ crop_height, crop_width = inset.shape[:2]
290
+ inset_height = max(1, int(crop_height * inset_width / max(crop_width, 1)))
291
+ inset = cv2.resize(
292
+ inset, (inset_width, inset_height), interpolation=cv2.INTER_AREA
293
+ )
294
+
295
+ legend_width = 30
296
+ legend_gap = 10
297
+ label_width = 54
298
+ margin = 28
299
+ panel_pad = 12
300
+ panel_width = (
301
+ inset_width + legend_gap + legend_width + label_width + panel_pad * 2
302
+ )
303
+ panel_height = inset_height + panel_pad * 2
304
+ panel_x = width - panel_width - margin
305
+ panel_y = margin
306
+
307
+ canvas = frame_rgb.copy()
308
+ panel = np.full((panel_height, panel_width, 3), 247, dtype=np.uint8)
309
+ panel = (panel.astype(np.float32) * 0.92).astype(np.uint8)
310
+ cv2.rectangle(
311
+ panel,
312
+ (0, 0),
313
+ (panel_width - 1, panel_height - 1),
314
+ (160, 168, 178),
315
+ 1,
316
+ )
317
+
318
+ ix = panel_pad
319
+ iy = panel_pad
320
+ panel[iy : iy + inset_height, ix : ix + inset_width] = inset
321
+
322
+ bar_x = ix + inset_width + legend_gap
323
+ bar = np.linspace(1.0, 0.0, inset_height, dtype=np.float32)[:, None]
324
+ bar_bgr = cv2.applyColorMap(
325
+ (bar * 255).astype(np.uint8), cv2.COLORMAP_TURBO
326
+ )
327
+ bar_rgb = cv2.cvtColor(bar_bgr, cv2.COLOR_BGR2RGB)
328
+ panel[iy : iy + inset_height, bar_x : bar_x + legend_width] = np.repeat(
329
+ bar_rgb,
330
+ legend_width,
331
+ axis=1,
332
+ )
333
+ text_x = bar_x + legend_width + 4
334
+ cv2.putText(
335
+ panel,
336
+ f"{scale:.3f}m",
337
+ (text_x, iy + 12),
338
+ cv2.FONT_HERSHEY_SIMPLEX,
339
+ 0.36,
340
+ (35, 40, 48),
341
+ 1,
342
+ cv2.LINE_AA,
343
+ )
344
+ cv2.putText(
345
+ panel,
346
+ "0",
347
+ (text_x, iy + inset_height),
348
+ cv2.FONT_HERSHEY_SIMPLEX,
349
+ 0.36,
350
+ (35, 40, 48),
351
+ 1,
352
+ cv2.LINE_AA,
353
+ )
354
+ canvas[
355
+ panel_y : panel_y + panel_height, panel_x : panel_x + panel_width
356
+ ] = panel
357
+ return canvas
358
+
359
+
360
+ def _project_points(
361
+ points: np.ndarray,
362
+ camera_pos: np.ndarray,
363
+ camera_lookat: np.ndarray,
364
+ camera_fov: float,
365
+ image_size: tuple[int, int],
366
+ ) -> tuple[np.ndarray, np.ndarray]:
367
+ width, height = image_size
368
+ forward = _normalize(camera_lookat - camera_pos)
369
+ world_up = np.array([0.0, 0.0, 1.0], dtype=np.float32)
370
+ right = np.cross(forward, world_up)
371
+ if np.linalg.norm(right) < 1e-6:
372
+ world_up = np.array([0.0, 1.0, 0.0], dtype=np.float32)
373
+ right = np.cross(forward, world_up)
374
+ right = _normalize(right)
375
+ up = _normalize(np.cross(right, forward))
376
+
377
+ relative = points - camera_pos[None, :]
378
+ cam_x = relative @ right
379
+ cam_y = relative @ up
380
+ cam_z = relative @ forward
381
+ focal = 0.5 * float(height) / math.tan(math.radians(camera_fov) * 0.5)
382
+ pixels = np.stack(
383
+ [
384
+ width * 0.5 + focal * cam_x / np.maximum(cam_z, 1e-6),
385
+ height * 0.5 - focal * cam_y / np.maximum(cam_z, 1e-6),
386
+ ],
387
+ axis=1,
388
+ )
389
+ return pixels, cam_z
390
+
391
+
392
+ def _draw_legend(image: np.ndarray, scale: float) -> None:
393
+ height, width = image.shape[:2]
394
+ bar_height = min(240, max(120, height // 4))
395
+ bar_width = 26
396
+ margin = 28
397
+ x0 = width - margin - bar_width - 52
398
+ y0 = margin
399
+
400
+ panel = image[
401
+ y0 - 10 : y0 + bar_height + 18, x0 - 10 : x0 + bar_width + 52
402
+ ]
403
+ panel[:] = (panel.astype(np.float32) * 0.35 + 245.0 * 0.65).astype(
404
+ np.uint8
405
+ )
406
+ cv2.rectangle(
407
+ image,
408
+ (x0 - 10, y0 - 10),
409
+ (x0 + bar_width + 52, y0 + bar_height + 18),
410
+ (155, 164, 176),
411
+ 1,
412
+ )
413
+
414
+ values = np.linspace(1.0, 0.0, bar_height, dtype=np.float32)[:, None]
415
+ colors_bgr = cv2.applyColorMap(
416
+ (values * 255).astype(np.uint8),
417
+ cv2.COLORMAP_TURBO,
418
+ )
419
+ colors_rgb = cv2.cvtColor(colors_bgr, cv2.COLOR_BGR2RGB)
420
+ image[y0 : y0 + bar_height, x0 : x0 + bar_width] = np.repeat(
421
+ colors_rgb,
422
+ bar_width,
423
+ axis=1,
424
+ )
425
+ cv2.putText(
426
+ image,
427
+ f"{scale:.3f}m",
428
+ (x0 + bar_width + 6, y0 + 12),
429
+ cv2.FONT_HERSHEY_SIMPLEX,
430
+ 0.38,
431
+ (35, 40, 48),
432
+ 1,
433
+ cv2.LINE_AA,
434
+ )
435
+ cv2.putText(
436
+ image,
437
+ "0",
438
+ (x0 + bar_width + 6, y0 + bar_height),
439
+ cv2.FONT_HERSHEY_SIMPLEX,
440
+ 0.38,
441
+ (35, 40, 48),
442
+ 1,
443
+ cv2.LINE_AA,
444
+ )
445
+
446
+
447
+ def _load_obj_mesh(mesh_path: Path) -> tuple[np.ndarray, np.ndarray]:
448
+ vertices: list[list[float]] = []
449
+ triangles: list[list[int]] = []
450
+ for line in mesh_path.read_text(errors="ignore").splitlines():
451
+ if line.startswith("v "):
452
+ _, x_value, y_value, z_value, *_ = line.split()
453
+ vertices.append([float(x_value), float(y_value), float(z_value)])
454
+ continue
455
+ if not line.startswith("f "):
456
+ continue
457
+
458
+ indices = [
459
+ _parse_obj_index(token, len(vertices))
460
+ for token in line.split()[1:]
461
+ ]
462
+ if len(indices) < 3:
463
+ continue
464
+ for offset in range(1, len(indices) - 1):
465
+ triangles.append(
466
+ [indices[0], indices[offset], indices[offset + 1]]
467
+ )
468
+
469
+ if not vertices:
470
+ raise ValueError(f"No vertices found in OBJ mesh: {mesh_path}")
471
+ if not triangles:
472
+ raise ValueError(f"No faces found in OBJ mesh: {mesh_path}")
473
+ return (
474
+ np.asarray(vertices, dtype=np.float32),
475
+ np.asarray(triangles, dtype=np.int32),
476
+ )
477
+
478
+
479
+ def _map_mesh_faces_to_particles(
480
+ mesh_vertices: np.ndarray,
481
+ mesh_faces: np.ndarray,
482
+ rest_positions: np.ndarray,
483
+ init_height: float,
484
+ mesh_scale: float,
485
+ ) -> np.ndarray:
486
+ world_vertices = mesh_vertices * float(mesh_scale)
487
+ world_vertices[:, 2] += float(init_height)
488
+ vertex_to_particle = _nearest_indices(
489
+ queries=world_vertices,
490
+ points=rest_positions,
491
+ )
492
+ particle_faces = vertex_to_particle[mesh_faces]
493
+ unique_counts = np.asarray(
494
+ [len(set(face.tolist())) for face in particle_faces],
495
+ dtype=np.int32,
496
+ )
497
+ particle_faces = particle_faces[unique_counts == 3]
498
+ if particle_faces.size == 0:
499
+ raise ValueError(
500
+ "No non-degenerate particle faces remained after mapping"
501
+ )
502
+ return np.unique(particle_faces, axis=0).astype(np.int32)
503
+
504
+
505
+ def _nearest_indices(
506
+ queries: np.ndarray,
507
+ points: np.ndarray,
508
+ chunk_size: int = 1024,
509
+ ) -> np.ndarray:
510
+ nearest: list[np.ndarray] = []
511
+ points_sq = np.sum(points * points, axis=1)[None, :]
512
+ for start in range(0, queries.shape[0], chunk_size):
513
+ chunk = queries[start : start + chunk_size]
514
+ distances = (
515
+ np.sum(chunk * chunk, axis=1)[:, None]
516
+ + points_sq
517
+ - 2.0 * chunk @ points.T
518
+ )
519
+ nearest.append(np.argmin(distances, axis=1).astype(np.int32))
520
+ return np.concatenate(nearest, axis=0)
521
+
522
+
523
+ def _parse_obj_index(token: str, vertex_count: int) -> int:
524
+ raw = int(token.split("/")[0])
525
+ if raw < 0:
526
+ return vertex_count + raw
527
+ return raw - 1
528
+
529
+
530
+ def _is_triangle_outside_image(
531
+ triangle: np.ndarray,
532
+ width: int,
533
+ height: int,
534
+ ) -> bool:
535
+ return (
536
+ triangle[:, 0].max() < 0
537
+ or triangle[:, 0].min() >= width
538
+ or triangle[:, 1].max() < 0
539
+ or triangle[:, 1].min() >= height
540
+ )
541
+
542
+
543
+ def _normalize(vector: np.ndarray) -> np.ndarray:
544
+ norm = float(np.linalg.norm(vector))
545
+ if norm <= 1e-8:
546
+ raise ValueError("Cannot normalize near-zero vector")
547
+ return vector / norm
548
+
549
+
550
+ if __name__ == "__main__":
551
+ main()
embodied_gen/scripts/deformable_sim/simulate_genesis_cloth.py ADDED
@@ -0,0 +1,608 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Project EmbodiedGen
2
+ #
3
+ # Copyright (c) 2025 Horizon Robotics. All Rights Reserved.
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
14
+ # implied. See the License for the specific language governing
15
+ # permissions and limitations under the License.
16
+
17
+ import json
18
+ import math
19
+ from dataclasses import dataclass
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ import tyro
24
+ from tqdm import tqdm
25
+ from embodied_gen.utils.log import logger
26
+
27
+ DEFAULT_CAMERA_FOV = 45
28
+
29
+
30
+ @dataclass
31
+ class GenesisClothSimConfig:
32
+ asset_path: str = "outputs/cloth"
33
+ output_dir: str | None = None
34
+ init_height: float = 1.0
35
+ duration_seconds: float = 6.0
36
+ sim_steps: int | None = None
37
+ shake: bool = True
38
+ shake_steps: int | None = None
39
+ shake_amplitude: float = 0.08
40
+ device: str = "cpu"
41
+ headless: bool = True
42
+ fps: int = 30
43
+ render_interval: int = 1
44
+ camera_res: tuple[int, int] = (512, 512)
45
+ gravity: tuple[float, float, float] = (0.0, 0.0, 0.0)
46
+ background_color: tuple[float, float, float] = (0.78, 0.82, 0.88)
47
+ ambient_light: tuple[float, float, float] = (0.55, 0.55, 0.55)
48
+ floor_size: tuple[float, float] = (6.0, 6.0)
49
+ floor_tile_size: tuple[float, float] = (0.5, 0.5)
50
+ floor_color: tuple[float, float, float, float] = (0.72, 0.72, 0.68, 1.0)
51
+ backdrop: bool = False
52
+ backdrop_color: tuple[float, float, float, float] = (0.80, 0.84, 0.90, 1.0)
53
+
54
+
55
+ def simulate_genesis_cloth(
56
+ asset_path: str = "outputs/cloth",
57
+ output_dir: str | None = None,
58
+ init_height: float = 1.0,
59
+ duration_seconds: float = 6.0,
60
+ sim_steps: int | None = None,
61
+ shake: bool = True,
62
+ shake_steps: int | None = None,
63
+ shake_amplitude: float = 0.08,
64
+ device: str = "cpu",
65
+ headless: bool = True,
66
+ fps: int = 30,
67
+ render_interval: int = 1,
68
+ camera_res: tuple[int, int] = (512, 512),
69
+ gravity: tuple[float, float, float] = (0.0, 0.0, 0.0),
70
+ background_color: tuple[float, float, float] = (0.78, 0.82, 0.88),
71
+ ambient_light: tuple[float, float, float] = (0.55, 0.55, 0.55),
72
+ floor_size: tuple[float, float] = (6.0, 6.0),
73
+ floor_tile_size: tuple[float, float] = (0.5, 0.5),
74
+ floor_color: tuple[float, float, float, float] = (0.72, 0.72, 0.68, 1.0),
75
+ backdrop: bool = False,
76
+ backdrop_color: tuple[float, float, float, float] = (
77
+ 0.80,
78
+ 0.84,
79
+ 0.90,
80
+ 1.0,
81
+ ),
82
+ ) -> str:
83
+ """Run a Genesis cloth drop-and-shake demo.
84
+
85
+ Args:
86
+ asset_path: Directory containing ``genesis/manifest.json``.
87
+ output_dir: Directory for simulation outputs. If unset, writes to
88
+ ``asset_path/genesis_sim``.
89
+ init_height: Height in meters for the cloth initial position.
90
+ duration_seconds: Default output video duration when ``sim_steps`` is not
91
+ set. Defaults to 6 seconds.
92
+ sim_steps: Optional number of Genesis simulation steps to run. If unset,
93
+ it is derived from ``duration_seconds * fps * render_interval``.
94
+ shake: Whether to apply a visible nonuniform particle displacement.
95
+ shake_steps: Number of steps that receive the shake motion. If unset,
96
+ all simulation steps are shaken.
97
+ shake_amplitude: Maximum displacement magnitude in meters.
98
+ device: Genesis backend name: ``"cpu"``, ``"cuda"``, or ``"auto"``.
99
+ headless: Whether to run Genesis without opening the viewer.
100
+ Genesis 0.4.7 uses Scene(show_viewer=False) for headless mode.
101
+ fps: Output video frames per second.
102
+ render_interval: Render every N simulation steps.
103
+ camera_res: Output camera resolution as ``(width, height)``. Defaults
104
+ to 512x512.
105
+ gravity: Gravity vector. Defaults to zero for the wind-sway demo so
106
+ the shirt does not visibly sag before the lower edge starts moving.
107
+ background_color: RGB color for the renderer background.
108
+ ambient_light: RGB ambient light color.
109
+ floor_size: Visible floor plane size in meters.
110
+ floor_tile_size: Visual tile size for the floor plane.
111
+ floor_color: RGBA floor material color.
112
+ backdrop: Whether to add a vertical background plane behind the cloth.
113
+ backdrop_color: RGBA backdrop material color.
114
+
115
+ Returns:
116
+ Path to the generated video file.
117
+ """
118
+
119
+ if duration_seconds <= 0:
120
+ raise ValueError("duration_seconds must be positive")
121
+ if fps <= 0:
122
+ raise ValueError("fps must be positive")
123
+ if render_interval <= 0:
124
+ raise ValueError("render_interval must be positive")
125
+ if sim_steps is None:
126
+ sim_steps = max(
127
+ 1, int(round(duration_seconds * fps * render_interval))
128
+ )
129
+ if sim_steps <= 0:
130
+ raise ValueError("sim_steps must be positive")
131
+ if shake_steps is None:
132
+ shake_steps = sim_steps
133
+ if shake_steps < 0:
134
+ raise ValueError("shake_steps must be non-negative")
135
+
136
+ asset_dir = Path(asset_path)
137
+ manifest_path = asset_dir / "genesis" / "manifest.json"
138
+ manifest = _read_json(manifest_path)
139
+ material_config = _read_json(asset_dir / manifest["material"])
140
+ cloth_mesh_path = asset_dir / manifest["cloth_mesh"]
141
+ if not cloth_mesh_path.exists():
142
+ raise FileNotFoundError(
143
+ f"Converted cloth mesh not found: {cloth_mesh_path}"
144
+ )
145
+
146
+ sim_output_dir = (
147
+ Path(output_dir) if output_dir else asset_dir / "genesis_sim"
148
+ )
149
+ sim_output_dir.mkdir(parents=True, exist_ok=True)
150
+ logger.info(f"Genesis cloth outputs will be saved to {sim_output_dir}")
151
+ video_path = sim_output_dir / "video.mp4"
152
+ summary_path = sim_output_dir / "run_summary.json"
153
+ config_path = sim_output_dir / "sim_config.json"
154
+ particle_positions_path = sim_output_dir / "particle_positions.npz"
155
+
156
+ import genesis as gs
157
+
158
+ backend_name, backend_warning = _init_genesis_backend(gs, device)
159
+
160
+ pbd_options = gs.options.PBDOptions(
161
+ particle_size=float(material_config.get("particle_size", 0.02)),
162
+ gravity=tuple(float(value) for value in gravity),
163
+ )
164
+ camera_pos, camera_lookat = _front_camera_pose(manifest, init_height)
165
+ scene = gs.Scene(
166
+ show_viewer=not headless,
167
+ sim_options=gs.options.SimOptions(
168
+ dt=1.0 / float(fps * render_interval),
169
+ gravity=tuple(float(value) for value in gravity),
170
+ ),
171
+ pbd_options=pbd_options,
172
+ vis_options=gs.options.VisOptions(
173
+ background_color=tuple(float(value) for value in background_color),
174
+ ambient_light=tuple(float(value) for value in ambient_light),
175
+ shadow=True,
176
+ plane_reflection=False,
177
+ ),
178
+ )
179
+ _add_scene_floor_and_backdrop(
180
+ gs=gs,
181
+ scene=scene,
182
+ camera_pos=camera_pos,
183
+ camera_lookat=camera_lookat,
184
+ floor_size=floor_size,
185
+ floor_tile_size=floor_tile_size,
186
+ floor_color=floor_color,
187
+ backdrop=backdrop,
188
+ backdrop_color=backdrop_color,
189
+ )
190
+ cloth = scene.add_entity(
191
+ morph=gs.morphs.Mesh(
192
+ file=str(cloth_mesh_path),
193
+ pos=(0.0, 0.0, float(init_height)),
194
+ scale=float(manifest.get("scale", 1.0)),
195
+ convexify=False,
196
+ ),
197
+ material=gs.materials.PBD.Cloth(
198
+ rho=float(material_config.get("rho", 0.2)),
199
+ static_friction=float(material_config.get("static_friction", 0.5)),
200
+ kinetic_friction=float(
201
+ material_config.get("kinetic_friction", 0.4)
202
+ ),
203
+ stretch_compliance=float(
204
+ material_config.get("stretch_compliance", 1e-6)
205
+ ),
206
+ bending_compliance=float(
207
+ material_config.get("bending_compliance", 1e-4)
208
+ ),
209
+ stretch_relaxation=float(
210
+ material_config.get("stretch_relaxation", 0.3)
211
+ ),
212
+ bending_relaxation=float(
213
+ material_config.get("bending_relaxation", 0.1)
214
+ ),
215
+ air_resistance=float(material_config.get("air_resistance", 1e-3)),
216
+ ),
217
+ )
218
+ camera = scene.add_camera(
219
+ res=camera_res,
220
+ pos=camera_pos,
221
+ lookat=camera_lookat,
222
+ fov=DEFAULT_CAMERA_FOV,
223
+ )
224
+ scene.build()
225
+
226
+ config = {
227
+ "asset_path": str(asset_dir),
228
+ "manifest": str(manifest_path),
229
+ "manifest_scale": float(manifest.get("scale", 1.0)),
230
+ "init_height": init_height,
231
+ "duration_seconds": duration_seconds,
232
+ "sim_steps": sim_steps,
233
+ "shake": shake,
234
+ "shake_steps": shake_steps,
235
+ "shake_amplitude": shake_amplitude,
236
+ "device": device,
237
+ "resolved_backend": backend_name,
238
+ "backend_warning": backend_warning,
239
+ "headless": headless,
240
+ "fps": fps,
241
+ "render_interval": render_interval,
242
+ "camera_res": list(camera_res),
243
+ "camera_pos": list(camera_pos),
244
+ "camera_lookat": list(camera_lookat),
245
+ "camera_fov": DEFAULT_CAMERA_FOV,
246
+ "gravity": list(gravity),
247
+ "background_color": list(background_color),
248
+ "ambient_light": list(ambient_light),
249
+ "floor_size": list(floor_size),
250
+ "floor_tile_size": list(floor_tile_size),
251
+ "floor_color": list(floor_color),
252
+ "backdrop": backdrop,
253
+ "backdrop_color": list(backdrop_color),
254
+ }
255
+ _write_json(config_path, config)
256
+
257
+ rest_positions = cloth.get_particles_pos().clone()
258
+ particle_frames = []
259
+ rendered_frames = 0
260
+ camera.start_recording()
261
+ for step in tqdm(range(sim_steps), desc="Genesis Cloth Simulation"):
262
+ if shake and step < shake_steps:
263
+ _shake_cloth_particles(
264
+ gs,
265
+ cloth,
266
+ rest_positions,
267
+ step,
268
+ shake_steps,
269
+ shake_amplitude,
270
+ )
271
+ scene.step()
272
+ if step % render_interval == 0:
273
+ particle_frames.append(_tensor_to_numpy(cloth.get_particles_pos()))
274
+ camera.render()
275
+ rendered_frames += 1
276
+ _save_camera_recording(camera, video_path, fps)
277
+ _save_particle_positions(
278
+ particle_positions_path=particle_positions_path,
279
+ particle_frames=particle_frames,
280
+ rest_positions=rest_positions,
281
+ fps=fps,
282
+ render_interval=render_interval,
283
+ )
284
+
285
+ summary = {
286
+ "video": str(video_path),
287
+ "particle_positions": str(particle_positions_path),
288
+ "rendered_frames": rendered_frames,
289
+ "particles": int(cloth.n_particles),
290
+ "sim_steps": sim_steps,
291
+ "duration_seconds": duration_seconds,
292
+ "requested_device": device,
293
+ "resolved_backend": backend_name,
294
+ "backend_warning": backend_warning,
295
+ "status": "completed",
296
+ }
297
+ _write_json(summary_path, summary)
298
+ logger.info(
299
+ "Genesis cloth simulation outputs: "
300
+ f"video {video_path}, particles {particle_positions_path}, "
301
+ f"config {config_path}, summary {summary_path}"
302
+ )
303
+ return str(video_path)
304
+
305
+
306
+ def entrypoint(**kwargs) -> str:
307
+ if kwargs:
308
+ cfg = GenesisClothSimConfig(**kwargs)
309
+ else:
310
+ cfg = tyro.cli(GenesisClothSimConfig)
311
+
312
+ return simulate_genesis_cloth(
313
+ asset_path=cfg.asset_path,
314
+ output_dir=cfg.output_dir,
315
+ init_height=cfg.init_height,
316
+ duration_seconds=cfg.duration_seconds,
317
+ sim_steps=cfg.sim_steps,
318
+ shake=cfg.shake,
319
+ shake_steps=cfg.shake_steps,
320
+ shake_amplitude=cfg.shake_amplitude,
321
+ device=cfg.device,
322
+ headless=cfg.headless,
323
+ fps=cfg.fps,
324
+ render_interval=cfg.render_interval,
325
+ camera_res=cfg.camera_res,
326
+ gravity=cfg.gravity,
327
+ background_color=cfg.background_color,
328
+ ambient_light=cfg.ambient_light,
329
+ floor_size=cfg.floor_size,
330
+ floor_tile_size=cfg.floor_tile_size,
331
+ floor_color=cfg.floor_color,
332
+ backdrop=cfg.backdrop,
333
+ backdrop_color=cfg.backdrop_color,
334
+ )
335
+
336
+
337
+ def _shake_cloth_particles(
338
+ gs: Any,
339
+ cloth: Any,
340
+ rest_positions: Any,
341
+ step: int,
342
+ shake_steps: int,
343
+ amplitude: float,
344
+ ) -> None:
345
+ if amplitude <= 0.0 or shake_steps <= 0:
346
+ return
347
+
348
+ positions = cloth.get_particles_pos().clone()
349
+ min_values = rest_positions.min(dim=0).values
350
+ max_values = rest_positions.max(dim=0).values
351
+ height = gs.torch.clamp(max_values[2] - min_values[2], min=1e-6)
352
+ y_values = rest_positions[:, 1]
353
+ z_values = rest_positions[:, 2]
354
+
355
+ top_ratio = gs.torch.clamp((max_values[2] - z_values) / height, 0.0, 1.0)
356
+ anchor_mask = top_ratio < 0.01
357
+ wind_strength = (
358
+ gs.torch.clamp((top_ratio - 0.005) / 0.995, 0.0, 1.0) ** 1.1
359
+ )
360
+
361
+ progress = float(step) / max(float(shake_steps), 1.0)
362
+ phase = 2.0 * math.pi * 2.2 * progress
363
+ traveling_phase = phase - 0.85 * top_ratio
364
+ center_y = rest_positions[:, 1].mean()
365
+ width = gs.torch.clamp(max_values[1] - min_values[1], min=1e-6)
366
+ across = (y_values - center_y) / width
367
+
368
+ offsets = gs.torch.zeros_like(rest_positions)
369
+ offsets[:, 0] = (
370
+ float(amplitude) * 2.25 * gs.torch.sin(traveling_phase) * wind_strength
371
+ )
372
+ offsets[:, 1] = (
373
+ float(amplitude)
374
+ * 0.04
375
+ * gs.torch.sin(traveling_phase * 1.10 + across)
376
+ * wind_strength
377
+ )
378
+ offsets[:, 2] = (
379
+ float(amplitude)
380
+ * 0.12
381
+ * gs.torch.sin(traveling_phase * 1.15 + across)
382
+ * wind_strength
383
+ )
384
+
385
+ target_positions = rest_positions + offsets
386
+ blend = (0.58 * wind_strength).unsqueeze(1)
387
+ positions = positions + (target_positions - positions) * blend
388
+ positions[anchor_mask] = rest_positions[anchor_mask]
389
+ cloth.set_particles_pos(positions)
390
+
391
+
392
+ def _add_scene_floor_and_backdrop(
393
+ gs: Any,
394
+ scene: Any,
395
+ camera_pos: tuple[float, float, float],
396
+ camera_lookat: tuple[float, float, float],
397
+ floor_size: tuple[float, float],
398
+ floor_tile_size: tuple[float, float],
399
+ floor_color: tuple[float, float, float, float],
400
+ backdrop: bool,
401
+ backdrop_color: tuple[float, float, float, float],
402
+ ) -> None:
403
+ scene.add_entity(
404
+ morph=gs.morphs.Plane(
405
+ pos=(0.0, 0.0, 0.0),
406
+ plane_size=tuple(float(value) for value in floor_size),
407
+ tile_size=tuple(float(value) for value in floor_tile_size),
408
+ ),
409
+ material=gs.materials.Rigid(),
410
+ surface=gs.surfaces.Rough(
411
+ color=tuple(float(value) for value in floor_color)
412
+ ),
413
+ name="floor",
414
+ )
415
+
416
+ if not backdrop:
417
+ return
418
+
419
+ normal = _horizontal_unit_vector(
420
+ (
421
+ camera_pos[0] - camera_lookat[0],
422
+ camera_pos[1] - camera_lookat[1],
423
+ 0.0,
424
+ )
425
+ )
426
+ camera_distance = math.sqrt(
427
+ (camera_pos[0] - camera_lookat[0]) ** 2
428
+ + (camera_pos[1] - camera_lookat[1]) ** 2
429
+ + (camera_pos[2] - camera_lookat[2]) ** 2
430
+ )
431
+ backdrop_distance = max(1.5, camera_distance * 0.72)
432
+ backdrop_pos = (
433
+ camera_lookat[0] - normal[0] * backdrop_distance,
434
+ camera_lookat[1] - normal[1] * backdrop_distance,
435
+ camera_lookat[2],
436
+ )
437
+ backdrop_size = (
438
+ max(float(floor_size[0]), float(floor_size[1])),
439
+ max(float(floor_size[0]), float(floor_size[1])) * 0.7,
440
+ )
441
+ scene.add_entity(
442
+ morph=gs.morphs.Plane(
443
+ pos=backdrop_pos,
444
+ normal=normal,
445
+ plane_size=backdrop_size,
446
+ collision=False,
447
+ ),
448
+ material=gs.materials.Rigid(),
449
+ surface=gs.surfaces.Rough(
450
+ color=tuple(float(value) for value in backdrop_color)
451
+ ),
452
+ name="backdrop",
453
+ )
454
+
455
+
456
+ def _horizontal_unit_vector(
457
+ vector: tuple[float, float, float],
458
+ ) -> tuple[float, float, float]:
459
+ length = math.sqrt(vector[0] ** 2 + vector[1] ** 2)
460
+ if length <= 1e-6:
461
+ return (1.0, 0.0, 0.0)
462
+ return (vector[0] / length, vector[1] / length, 0.0)
463
+
464
+
465
+ def _save_camera_recording(camera: Any, video_path: Path, fps: int) -> None:
466
+ from moviepy.video.io.ImageSequenceClip import ImageSequenceClip
467
+
468
+ if not camera._in_recording:
469
+ raise RuntimeError("Camera recording was not started.")
470
+ if not camera._recorded_imgs:
471
+ raise RuntimeError("No frames were recorded.")
472
+
473
+ video_path.parent.mkdir(parents=True, exist_ok=True)
474
+ clip = ImageSequenceClip(list(camera._recorded_imgs), fps=fps)
475
+ try:
476
+ clip.write_videofile(
477
+ str(video_path),
478
+ fps=fps,
479
+ logger=None,
480
+ codec="libx264",
481
+ preset="ultrafast",
482
+ )
483
+ finally:
484
+ close = getattr(clip, "close", None)
485
+ if close is not None:
486
+ close()
487
+ camera._recorded_t_prev = -1
488
+ camera._recorded_imgs.clear()
489
+ camera._in_recording = False
490
+
491
+
492
+ def _save_particle_positions(
493
+ particle_positions_path: Path,
494
+ particle_frames: list[Any],
495
+ rest_positions: Any,
496
+ fps: int,
497
+ render_interval: int,
498
+ ) -> None:
499
+ import numpy as np
500
+
501
+ if not particle_frames:
502
+ raise RuntimeError("No particle frames were recorded.")
503
+
504
+ particle_positions_path.parent.mkdir(parents=True, exist_ok=True)
505
+ np.savez_compressed(
506
+ particle_positions_path,
507
+ positions=np.stack(particle_frames, axis=0).astype(np.float32),
508
+ rest_positions=_tensor_to_numpy(rest_positions).astype(np.float32),
509
+ fps=np.array(fps, dtype=np.int32),
510
+ render_interval=np.array(render_interval, dtype=np.int32),
511
+ )
512
+
513
+
514
+ def _tensor_to_numpy(tensor: Any) -> Any:
515
+ array = tensor.detach() if hasattr(tensor, "detach") else tensor
516
+ array = array.cpu() if hasattr(array, "cpu") else array
517
+ array = array.numpy() if hasattr(array, "numpy") else array
518
+ return array
519
+
520
+
521
+ def _collar_handle_weights(gs: Any, rest_positions: Any) -> Any:
522
+ min_values = rest_positions.min(dim=0).values
523
+ max_values = rest_positions.max(dim=0).values
524
+ center = rest_positions.mean(dim=0)
525
+ width = gs.torch.clamp(max_values[1] - min_values[1], min=1e-6)
526
+ height = gs.torch.clamp(max_values[2] - min_values[2], min=1e-6)
527
+
528
+ y_values = rest_positions[:, 1]
529
+ z_values = rest_positions[:, 2]
530
+ top_start = max_values[2] - 0.32 * height
531
+ top_weights = gs.torch.clamp(
532
+ (z_values - top_start) / (0.32 * height), 0.0, 1.0
533
+ )
534
+
535
+ left_center = center[1] - 0.22 * width
536
+ right_center = center[1] + 0.22 * width
537
+ sigma = 0.12 * width
538
+ left_weights = gs.torch.exp(-0.5 * ((y_values - left_center) / sigma) ** 2)
539
+ right_weights = gs.torch.exp(
540
+ -0.5 * ((y_values - right_center) / sigma) ** 2
541
+ )
542
+ side_weights = gs.torch.clamp(left_weights + right_weights, 0.0, 1.0)
543
+ return top_weights * side_weights
544
+
545
+
546
+ def _front_camera_pose(
547
+ manifest: dict[str, Any], init_height: float
548
+ ) -> tuple[tuple[float, float, float], tuple[float, float, float]]:
549
+ stats = manifest.get("mesh_stats", {})
550
+ bbox_min = stats.get("bbox_min", [-0.5, -0.5, -0.5])
551
+ bbox_max = stats.get("bbox_max", [0.5, 0.5, 0.5])
552
+ extents = stats.get("extents", [1.0, 1.0, 1.0])
553
+
554
+ center = [
555
+ 0.5 * (float(bbox_min[index]) + float(bbox_max[index]))
556
+ for index in range(3)
557
+ ]
558
+ width = float(extents[1])
559
+ height = float(extents[2])
560
+ depth = max(float(extents[0]), 0.05)
561
+ distance = max(width, height) * 2.45 + depth
562
+
563
+ lookat = (center[0], center[1], center[2] + float(init_height))
564
+ pos = (
565
+ center[0] + distance,
566
+ center[1] - 0.22 * distance,
567
+ lookat[2] + 0.04 * height,
568
+ )
569
+ return pos, lookat
570
+
571
+
572
+ def _init_genesis_backend(gs: Any, device: str) -> tuple[str, str | None]:
573
+ normalized = device.lower()
574
+ if normalized == "cpu":
575
+ gs.init(backend=gs.cpu, logging_level="warning")
576
+ return "cpu", None
577
+ if normalized == "cuda":
578
+ gs.init(backend=gs.cuda, logging_level="warning")
579
+ return "cuda", None
580
+ if normalized != "auto":
581
+ raise ValueError(
582
+ f"Unsupported Genesis backend: {device}. Use 'cpu', 'cuda', or 'auto'."
583
+ )
584
+
585
+ try:
586
+ gs.init(backend=gs.cuda, logging_level="warning")
587
+ return "cuda", None
588
+ except Exception as exc:
589
+ warning = (
590
+ "Genesis CUDA backend is unavailable; falling back to CPU. "
591
+ f"Original error: {type(exc).__name__}: {exc}"
592
+ )
593
+ gs.init(backend=gs.cpu, logging_level="warning")
594
+ return "cpu", warning
595
+
596
+
597
+ def _read_json(path: Path) -> dict[str, Any]:
598
+ if not path.exists():
599
+ raise FileNotFoundError(f"Required JSON file does not exist: {path}")
600
+ return json.loads(path.read_text())
601
+
602
+
603
+ def _write_json(path: Path, data: dict[str, Any]) -> None:
604
+ path.write_text(json.dumps(data, indent=2) + "\n")
605
+
606
+
607
+ if __name__ == "__main__":
608
+ entrypoint()
embodied_gen/scripts/gen_texture.py CHANGED
@@ -100,7 +100,7 @@ def entrypoint() -> None:
100
  pipeline=PIPELINE,
101
  seed=cfg.seed,
102
  )
103
- textured_mesh = backproject_api(
104
  delight_model=DELIGHT,
105
  imagesr_model=IMAGESR_MODEL,
106
  mesh_path=mesh_path,
 
100
  pipeline=PIPELINE,
101
  seed=cfg.seed,
102
  )
103
+ backproject_api(
104
  delight_model=DELIGHT,
105
  imagesr_model=IMAGESR_MODEL,
106
  mesh_path=mesh_path,
embodied_gen/scripts/imageto3d.py CHANGED
@@ -29,6 +29,11 @@ from embodied_gen.data.utils import delete_dir
29
  # from embodied_gen.models.sr_model import ImageRealESRGAN
30
  # from embodied_gen.models.delight_model import DelightingModel
31
  from embodied_gen.models.gs_model import GaussianOperator
 
 
 
 
 
32
  from embodied_gen.models.segment_model import RembgRemover
33
  from embodied_gen.scripts.render_gs import entrypoint as render_gs_api
34
  from embodied_gen.utils.gpt_clients import GPT_CLIENT
@@ -49,19 +54,47 @@ from embodied_gen.validators.quality_checkers import (
49
  from embodied_gen.validators.urdf_convertor import URDFGenerator
50
 
51
  # random.seed(0)
52
- IMAGE3D_MODEL = "SAM3D" # TRELLIS or SAM3D
53
- logger.info(f"Loading {IMAGE3D_MODEL} as Image3D Models...")
54
- if IMAGE3D_MODEL == "TRELLIS":
55
- from thirdparty.TRELLIS.trellis.pipelines import TrellisImageTo3DPipeline
56
-
57
- PIPELINE = TrellisImageTo3DPipeline.from_pretrained(
58
- "microsoft/TRELLIS-image-large"
59
- )
60
- # PIPELINE.cuda()
61
- elif IMAGE3D_MODEL == "SAM3D":
62
- from embodied_gen.models.sam3d import Sam3dInference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
 
64
- PIPELINE = Sam3dInference()
65
 
66
  # DELIGHT = DelightingModel()
67
  # IMAGESR_MODEL = ImageRealESRGAN(outscale=4)
@@ -109,6 +142,17 @@ def parse_args():
109
  )
110
  parser.add_argument("--disable_decompose_convex", action="store_true")
111
  parser.add_argument("--texture_size", type=int, default=2048)
 
 
 
 
 
 
 
 
 
 
 
112
  args, unknown = parser.parse_known_args()
113
 
114
  return args
@@ -120,9 +164,31 @@ def entrypoint(**kwargs):
120
  if hasattr(args, k) and v is not None:
121
  setattr(args, k, v)
122
 
123
- assert (
124
- args.image_path or args.image_root
125
- ), "Please provide either --image_path or --image_root."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
126
  if not args.image_path:
127
  args.image_path = glob(os.path.join(args.image_root, "*.png"))
128
  args.image_path += glob(os.path.join(args.image_root, "*.jpg"))
@@ -151,6 +217,19 @@ def entrypoint(**kwargs):
151
  seg_image = RBG_REMOVER(image) if image.mode != "RGBA" else image
152
  seg_image.save(seg_path)
153
 
 
 
 
 
 
 
 
 
 
 
 
 
 
154
  seed = args.seed
155
  asset_node = "unknown"
156
  gs_model = None
@@ -161,10 +240,10 @@ def entrypoint(**kwargs):
161
  f"Try: {try_idx + 1}/{args.n_retry}, Seed: {seed}, Prompt: {seg_path}"
162
  )
163
  try:
164
- outputs = image3d_model_infer(PIPELINE, seg_image, seed)
165
  except Exception as e:
166
  logger.error(
167
- f"[Image3D Failed] process {image_path}: {e}, retry: {try_idx+1}/{args.n_retry}"
168
  )
169
  seed = (
170
  random.randint(0, 100000) if seed is not None else None
@@ -290,10 +369,14 @@ def entrypoint(**kwargs):
290
  )
291
 
292
  # Quality check and update .urdf file.
293
- mesh_out = f"{urdf_root}/{urdf_convertor.output_mesh_dir}/{filename}.obj" # noqa
 
 
294
  trimesh.load(mesh_out).export(mesh_out.replace(".obj", ".glb"))
295
 
296
- image_dir = f"{urdf_root}/{urdf_convertor.output_render_dir}/image_color" # noqa
 
 
297
  image_paths = glob(f"{image_dir}/*.png")
298
  images_list = []
299
  for checker in CHECKERS:
 
29
  # from embodied_gen.models.sr_model import ImageRealESRGAN
30
  # from embodied_gen.models.delight_model import DelightingModel
31
  from embodied_gen.models.gs_model import GaussianOperator
32
+ from embodied_gen.models.hunyuan3d import (
33
+ HunyuanConfig,
34
+ load_credentials,
35
+ process_image,
36
+ )
37
  from embodied_gen.models.segment_model import RembgRemover
38
  from embodied_gen.scripts.render_gs import entrypoint as render_gs_api
39
  from embodied_gen.utils.gpt_clients import GPT_CLIENT
 
54
  from embodied_gen.validators.urdf_convertor import URDFGenerator
55
 
56
  # random.seed(0)
57
+ IMAGE3D_MODEL = "SAM3D" # default backend; SAM3D, TRELLIS, or HUNYUAN3D
58
+ SUPPORTED_IMAGE3D_MODELS = ("SAM3D", "TRELLIS", "HUNYUAN3D")
59
+
60
+
61
+ _PIPELINE_CACHE: dict = {}
62
+
63
+
64
+ def _build_image3d_pipeline(name: str):
65
+ """Lazily instantiate (and cache) the local image-to-3D pipeline.
66
+
67
+ The cache preserves the pre-refactor invariant that the local backend
68
+ is loaded once per process: ``textto3d.py`` calls ``entrypoint`` in a
69
+ per-node loop, and re-loading weights each call would regress runtime.
70
+ Returns ``None`` for backends that have no local model (HUNYUAN3D).
71
+ """
72
+ if name == "HUNYUAN3D":
73
+ return None
74
+ if name in _PIPELINE_CACHE:
75
+ return _PIPELINE_CACHE[name]
76
+ if name == "TRELLIS":
77
+ logger.info("Loading TRELLIS as Image3D Models...")
78
+ from thirdparty.TRELLIS.trellis.pipelines import (
79
+ TrellisImageTo3DPipeline,
80
+ )
81
+
82
+ pipeline = TrellisImageTo3DPipeline.from_pretrained(
83
+ "microsoft/TRELLIS-image-large"
84
+ )
85
+ elif name == "SAM3D":
86
+ logger.info("Loading SAM3D as Image3D Models...")
87
+ from embodied_gen.models.sam3d import Sam3dInference
88
+
89
+ pipeline = Sam3dInference()
90
+ else:
91
+ raise ValueError(
92
+ f"Unsupported image3d backend {name!r}; "
93
+ f"expected one of {SUPPORTED_IMAGE3D_MODELS}."
94
+ )
95
+ _PIPELINE_CACHE[name] = pipeline
96
+ return pipeline
97
 
 
98
 
99
  # DELIGHT = DelightingModel()
100
  # IMAGESR_MODEL = ImageRealESRGAN(outscale=4)
 
142
  )
143
  parser.add_argument("--disable_decompose_convex", action="store_true")
144
  parser.add_argument("--texture_size", type=int, default=2048)
145
+ parser.add_argument(
146
+ "--image3d_model",
147
+ type=str,
148
+ default=IMAGE3D_MODEL,
149
+ help=(
150
+ "Image-to-3D backend. One of "
151
+ f"{', '.join(SUPPORTED_IMAGE3D_MODELS)} (case-insensitive). "
152
+ "HUNYUAN3D calls Tencent Hunyuan3D Pro API and requires "
153
+ "TENCENT_SECRET_ID/TENCENT_SECRET_KEY in the environment."
154
+ ),
155
+ )
156
  args, unknown = parser.parse_known_args()
157
 
158
  return args
 
164
  if hasattr(args, k) and v is not None:
165
  setattr(args, k, v)
166
 
167
+ args.image3d_model = str(args.image3d_model).strip().upper()
168
+ if args.image3d_model not in SUPPORTED_IMAGE3D_MODELS:
169
+ raise ValueError(
170
+ f"Unsupported --image3d_model {args.image3d_model!r}; "
171
+ f"expected one of {SUPPORTED_IMAGE3D_MODELS}."
172
+ )
173
+
174
+ hunyuan_config = None
175
+ hunyuan_credentials = None
176
+ if args.image3d_model == "HUNYUAN3D":
177
+ # Fail fast on missing creds before any local model load or network I/O.
178
+ hunyuan_credentials = load_credentials()
179
+ hunyuan_config = HunyuanConfig()
180
+ logger.info(
181
+ "HUNYUAN3D backend: action=%s host=%s result_format=%s",
182
+ hunyuan_config.image_action,
183
+ hunyuan_config.host,
184
+ hunyuan_config.result_format,
185
+ )
186
+
187
+ pipeline = _build_image3d_pipeline(args.image3d_model)
188
+
189
+ assert args.image_path or args.image_root, (
190
+ "Please provide either --image_path or --image_root."
191
+ )
192
  if not args.image_path:
193
  args.image_path = glob(os.path.join(args.image_root, "*.png"))
194
  args.image_path += glob(os.path.join(args.image_root, "*.jpg"))
 
217
  seg_image = RBG_REMOVER(image) if image.mode != "RGBA" else image
218
  seg_image.save(seg_path)
219
 
220
+ if args.image3d_model == "HUNYUAN3D":
221
+ process_image(
222
+ args=args,
223
+ idx=idx,
224
+ image_path=image_path,
225
+ output_root=output_root,
226
+ filename=filename,
227
+ hunyuan_config=hunyuan_config,
228
+ hunyuan_credentials=hunyuan_credentials,
229
+ checkers=CHECKERS,
230
+ )
231
+ continue
232
+
233
  seed = args.seed
234
  asset_node = "unknown"
235
  gs_model = None
 
240
  f"Try: {try_idx + 1}/{args.n_retry}, Seed: {seed}, Prompt: {seg_path}"
241
  )
242
  try:
243
+ outputs = image3d_model_infer(pipeline, seg_image, seed)
244
  except Exception as e:
245
  logger.error(
246
+ f"[Image3D Failed] process {image_path}: {e}, retry: {try_idx + 1}/{args.n_retry}"
247
  )
248
  seed = (
249
  random.randint(0, 100000) if seed is not None else None
 
369
  )
370
 
371
  # Quality check and update .urdf file.
372
+ mesh_out = (
373
+ f"{urdf_root}/{urdf_convertor.output_mesh_dir}/{filename}.obj" # noqa
374
+ )
375
  trimesh.load(mesh_out).export(mesh_out.replace(".obj", ".glb"))
376
 
377
+ image_dir = (
378
+ f"{urdf_root}/{urdf_convertor.output_render_dir}/image_color" # noqa
379
+ )
380
  image_paths = glob(f"{image_dir}/*.png")
381
  images_list = []
382
  for checker in CHECKERS:
embodied_gen/scripts/parallel_sim.py CHANGED
@@ -29,7 +29,7 @@ import torch
29
  import tyro
30
  from mani_skill.utils.wrappers import RecordEpisode
31
  from tqdm import tqdm
32
- import embodied_gen.envs.pick_embodiedgen
33
  from embodied_gen.utils.enum import LayoutInfo, Scene3DItemEnum
34
  from embodied_gen.utils.log import logger
35
  from embodied_gen.utils.simulation import FrankaPandaGrasper
 
29
  import tyro
30
  from mani_skill.utils.wrappers import RecordEpisode
31
  from tqdm import tqdm
32
+ import embodied_gen.envs.pick_embodiedgen # noqa: F401
33
  from embodied_gen.utils.enum import LayoutInfo, Scene3DItemEnum
34
  from embodied_gen.utils.log import logger
35
  from embodied_gen.utils.simulation import FrankaPandaGrasper
embodied_gen/scripts/room_gen/custom_solve.gin CHANGED
@@ -27,3 +27,4 @@ compose_indoors.skirting_floor_enabled = False
27
  compose_indoors.skirting_ceiling_enabled = False
28
  BlueprintSolidifier.enable_open = False
29
 
 
 
27
  compose_indoors.skirting_ceiling_enabled = False
28
  BlueprintSolidifier.enable_open = False
29
 
30
+ compose_indoors.room_doors_enabled = False
embodied_gen/scripts/room_gen/export_scene.py CHANGED
@@ -20,7 +20,6 @@ import logging
20
  import math
21
  import os
22
  import shutil
23
- import subprocess
24
  from collections import defaultdict
25
  from pathlib import Path
26
  from typing import Dict, List, Optional, Tuple
@@ -547,12 +546,12 @@ def apply_baked_tex(obj, paramDict={}):
547
  principled_bsdf_node.inputs["Metallic"].default_value = paramDict[
548
  mat.name
549
  ]["Metallic"]
550
- principled_bsdf_node.inputs["Sheen Weight"].default_value = (
551
- paramDict[mat.name]["Sheen Weight"]
552
- )
553
- principled_bsdf_node.inputs["Coat Weight"].default_value = (
554
- paramDict[mat.name]["Coat Weight"]
555
- )
556
 
557
 
558
  def create_glass_shader(node_tree, export_usd):
@@ -783,9 +782,9 @@ def bake_special_emit(
783
  logger.debug(
784
  f"Removing added link:\t{n.name}: {from_soc.name} => {to_soc.name}"
785
  )
786
- for l in n.node_tree.links:
787
- if l.from_socket == from_soc and l.to_socket == to_soc:
788
- n.node_tree.links.remove(l)
789
  logger.debug(
790
  f"Removed link:\t{n.name}: {from_soc.name} => {to_soc.name}"
791
  )
@@ -1182,9 +1181,9 @@ def export_sim_ready(
1182
  ) -> Dict[str, List[Path]]:
1183
  """Exports both the visual and collision assets for a geometry."""
1184
  if not visual_only:
1185
- assert (
1186
- coacd is not None
1187
- ), "coacd is required to export simulation assets."
1188
 
1189
  asset_exports = defaultdict(list)
1190
  export_name = name if name is not None else obj.name
@@ -1626,7 +1625,7 @@ def main(args):
1626
 
1627
  bpy.ops.wm.open_mainfile(filepath=str(blendfile))
1628
 
1629
- folder = export_scene(
1630
  blendfile,
1631
  args.output_folder,
1632
  format=args.format,
 
20
  import math
21
  import os
22
  import shutil
 
23
  from collections import defaultdict
24
  from pathlib import Path
25
  from typing import Dict, List, Optional, Tuple
 
546
  principled_bsdf_node.inputs["Metallic"].default_value = paramDict[
547
  mat.name
548
  ]["Metallic"]
549
+ principled_bsdf_node.inputs[
550
+ "Sheen Weight"
551
+ ].default_value = paramDict[mat.name]["Sheen Weight"]
552
+ principled_bsdf_node.inputs[
553
+ "Coat Weight"
554
+ ].default_value = paramDict[mat.name]["Coat Weight"]
555
 
556
 
557
  def create_glass_shader(node_tree, export_usd):
 
782
  logger.debug(
783
  f"Removing added link:\t{n.name}: {from_soc.name} => {to_soc.name}"
784
  )
785
+ for link in n.node_tree.links:
786
+ if link.from_socket == from_soc and link.to_socket == to_soc:
787
+ n.node_tree.links.remove(link)
788
  logger.debug(
789
  f"Removed link:\t{n.name}: {from_soc.name} => {to_soc.name}"
790
  )
 
1181
  ) -> Dict[str, List[Path]]:
1182
  """Exports both the visual and collision assets for a geometry."""
1183
  if not visual_only:
1184
+ assert coacd is not None, (
1185
+ "coacd is required to export simulation assets."
1186
+ )
1187
 
1188
  asset_exports = defaultdict(list)
1189
  export_name = name if name is not None else obj.name
 
1625
 
1626
  bpy.ops.wm.open_mainfile(filepath=str(blendfile))
1627
 
1628
+ export_scene(
1629
  blendfile,
1630
  args.output_folder,
1631
  format=args.format,
embodied_gen/scripts/room_gen/gen_room.py CHANGED
@@ -81,6 +81,11 @@ class GenRoomArgs:
81
  complexity: Complexity = Complexity.simple
82
  """Complexity level: minimalist, simple, medium, or detail."""
83
 
 
 
 
 
 
84
 
85
  def run_command(cmd: list[str], task_name: str):
86
  """Helper: Use Popen to allow killing the child process on Ctrl+C.
@@ -114,6 +119,22 @@ def run_command(cmd: list[str], task_name: str):
114
 
115
 
116
  def generate_room(cfg: GenRoomArgs):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
117
  room_type = cfg.room_type.value
118
  seed = cfg.seed
119
  if seed is None:
 
81
  complexity: Complexity = Complexity.simple
82
  """Complexity level: minimalist, simple, medium, or detail."""
83
 
84
+ prompt: str | None = None
85
+ """Natural-language task/scene description. When set, room_type and
86
+ complexity are inferred from it via a GPT router, overriding the
87
+ explicit values."""
88
+
89
 
90
  def run_command(cmd: list[str], task_name: str):
91
  """Helper: Use Popen to allow killing the child process on Ctrl+C.
 
119
 
120
 
121
  def generate_room(cfg: GenRoomArgs):
122
+ if cfg.prompt:
123
+ # Deferred import: GPT routing deps are only needed in prompt mode.
124
+ from embodied_gen.scripts.room_gen.route_room import (
125
+ InfinigenGenRouter,
126
+ )
127
+ from embodied_gen.utils.gpt_clients import GPT_CLIENT
128
+
129
+ router = InfinigenGenRouter(gpt_client=GPT_CLIENT)
130
+ room_name, complexity = router.query(cfg.prompt)
131
+ cfg.room_type = RoomType(room_name)
132
+ cfg.complexity = Complexity(complexity)
133
+ logger.info(
134
+ f"Router mapped prompt {cfg.prompt!r} to room={room_name}, "
135
+ f"complexity={complexity}."
136
+ )
137
+
138
  room_type = cfg.room_type.value
139
  seed = cfg.seed
140
  if seed is None:
embodied_gen/scripts/room_gen/gen_trajectory.py ADDED
@@ -0,0 +1,306 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Project EmbodiedGen
2
+ #
3
+ # Copyright (c) 2025 Horizon Robotics. All Rights Reserved.
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
14
+ # implied. See the License for the specific language governing
15
+ # permissions and limitations under the License.
16
+
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import logging
22
+ import subprocess
23
+ from dataclasses import dataclass
24
+ from importlib import import_module
25
+ from pathlib import Path
26
+
27
+ import numpy as np
28
+ import tyro
29
+ from shapely.geometry import Polygon
30
+ from shapely.ops import unary_union
31
+
32
+ # The spatial-computing package name contains a hyphen, so it cannot be
33
+ # imported with a normal ``import`` statement; use importlib instead.
34
+ _SC = "embodied_gen.skills.spatial-computing.core"
35
+ UrdfSemanticInfoCollector = import_module(
36
+ f"{_SC}.collector"
37
+ ).UrdfSemanticInfoCollector
38
+ RoamTrajectoryGenerator = import_module(
39
+ f"{_SC}.trajectory"
40
+ ).RoamTrajectoryGenerator
41
+ FloorplanVisualizer = import_module(f"{_SC}.visualizer").FloorplanVisualizer
42
+
43
+ logger = logging.getLogger(__name__)
44
+
45
+
46
+ @dataclass
47
+ class GenTrajectoryArgs:
48
+ """Configuration for roaming-trajectory generation."""
49
+
50
+ urdf_path: str
51
+ """Path to the input URDF scene file."""
52
+
53
+ output_dir: str = "outputs/trajectory"
54
+ """Directory for the trajectory JSON and floorplan PNG."""
55
+
56
+ clearance: float = 0.4
57
+ """Minimum clearance (m) from walls; keep small so doorways stay passable."""
58
+
59
+ obstacle_clearance: float = 0.5
60
+ """Minimum clearance (m) from furniture/objects (doors/walls excluded)."""
61
+
62
+ resolution: float = 0.05
63
+ """Occupancy-grid cell size in meters (smaller = finer, slower)."""
64
+
65
+ num_waypoints: int = 8
66
+ """Number of roam waypoints to visit across the navigable space."""
67
+
68
+ speed: float = 0.5
69
+ """Constant roaming speed (m/s). Sets the spacing with --time_step."""
70
+
71
+ time_step: float = 0.1
72
+ """Time between consecutive waypoints (s). Spacing = speed * time_step."""
73
+
74
+ turn_radius: float = 0.8
75
+ """Target turning-arc radius (m); larger gives wider, rounder turns."""
76
+
77
+ endpoint_clearance: float = 1.5
78
+ """Minimum distance (m) the start and end keep from walls/obstacles."""
79
+
80
+ arrow_stride: int = 10
81
+ """Draw a heading arrow every N points in the PNG (0 disables)."""
82
+
83
+ remove_doors: bool = False
84
+ """Hide door footprints in the floorplan image and animation."""
85
+
86
+ animate: bool = False
87
+ """Also render a per-frame floorplan animation (frames/ + trajectory.mp4)."""
88
+
89
+ anim_frame_stride: int = 1
90
+ """Animate every Nth waypoint. Match the render --frame_stride to sync."""
91
+
92
+ anim_fps: float = 0.0
93
+ """Animation frame rate. 0 = auto (real-time from speed / stride)."""
94
+
95
+ anim_dpi: int = 100
96
+ """Resolution (dpi) of the animation frames."""
97
+
98
+ start_xy: tuple[float, float] | None = None
99
+ """Optional world (x, y) start; defaults to the largest free region."""
100
+
101
+ mesh_sample_num: int = 5000
102
+ """Number of points sampled per mesh when parsing the URDF."""
103
+
104
+ seed: int | None = None
105
+ """Optional RNG seed for reproducible roaming."""
106
+
107
+
108
+ def generate_trajectory(cfg: GenTrajectoryArgs) -> None:
109
+ """Run the roaming-trajectory pipeline and write outputs.
110
+
111
+ Args:
112
+ cfg: Parsed CLI configuration.
113
+ """
114
+ output_dir = Path(cfg.output_dir)
115
+ output_dir.mkdir(parents=True, exist_ok=True)
116
+
117
+ collector = UrdfSemanticInfoCollector(mesh_sample_num=cfg.mesh_sample_num)
118
+ collector.collect(cfg.urdf_path)
119
+
120
+ generator = RoamTrajectoryGenerator.from_collector(
121
+ collector,
122
+ clearance=cfg.clearance,
123
+ resolution=cfg.resolution,
124
+ obstacle_clearance=cfg.obstacle_clearance,
125
+ )
126
+ result = generator.generate(
127
+ start_xy=cfg.start_xy,
128
+ num_waypoints=cfg.num_waypoints,
129
+ point_spacing=cfg.speed * cfg.time_step,
130
+ turn_radius=cfg.turn_radius,
131
+ endpoint_clearance=cfg.endpoint_clearance,
132
+ seed=cfg.seed,
133
+ )
134
+
135
+ stem = Path(cfg.urdf_path).stem
136
+ json_path = output_dir / f"{stem}_trajectory.json"
137
+ png_path = output_dir / f"{stem}_trajectory.png"
138
+
139
+ # Points are equidistant, so timestamps follow constant-speed motion:
140
+ # t = cumulative arc length / speed.
141
+ xy = result.points[:, :2]
142
+ seg = np.linalg.norm(np.diff(xy, axis=0), axis=1)
143
+ times = np.concatenate([[0.0], np.cumsum(seg)]) / cfg.speed
144
+ waypoints = [
145
+ {
146
+ "x": round(float(x), 4),
147
+ "y": round(float(y), 4),
148
+ "rot": round(float(r), 2),
149
+ "t": round(float(t), 3),
150
+ }
151
+ for (x, y, r), t in zip(result.points, times)
152
+ ]
153
+
154
+ payload = {
155
+ "num_waypoints": len(waypoints),
156
+ "clearance": result.clearance,
157
+ "min_clearance": round(result.min_clearance, 4),
158
+ "length": round(result.length, 4),
159
+ "speed": cfg.speed,
160
+ "time_step": cfg.time_step,
161
+ "reachable_rooms": result.reachable_rooms,
162
+ "rot_convention": (
163
+ "0 deg points +Y (12 o'clock); counter-clockwise positive; "
164
+ "tangent to the roaming curve (forward heading); range [0, 360)."
165
+ ),
166
+ "waypoints": waypoints,
167
+ }
168
+ json_path.write_text(json.dumps(payload, indent=2, ensure_ascii=False))
169
+
170
+ footprints, occ_area = visible_footprints(collector, cfg.remove_doors)
171
+
172
+ FloorplanVisualizer.plot(
173
+ collector.rooms,
174
+ footprints,
175
+ occ_area,
176
+ str(png_path),
177
+ trajectory=result.points,
178
+ arrow_stride=cfg.arrow_stride,
179
+ )
180
+
181
+ logger.info("Trajectory waypoints : %s", json_path)
182
+ logger.info("Floorplan overlay : %s", png_path)
183
+ logger.info(
184
+ "Length %.2fm, min clearance %.2fm, reaches rooms: %s",
185
+ result.length,
186
+ result.min_clearance,
187
+ ", ".join(result.reachable_rooms) or "(none)",
188
+ )
189
+
190
+ if cfg.animate:
191
+ fps = cfg.anim_fps
192
+ if fps <= 0.0:
193
+ fps = (1.0 / cfg.time_step) / max(1, cfg.anim_frame_stride)
194
+ animate_floorplan(
195
+ rooms=collector.rooms,
196
+ footprints=footprints,
197
+ occ_area=occ_area,
198
+ points=result.points,
199
+ output_dir=output_dir,
200
+ frame_stride=cfg.anim_frame_stride,
201
+ fps=fps,
202
+ dpi=cfg.anim_dpi,
203
+ )
204
+
205
+
206
+ def visible_footprints(collector, remove_doors: bool):
207
+ """Return footprints and occupied area for floorplan drawing.
208
+
209
+ When ``remove_doors`` is set, door footprints are excluded and the
210
+ occupied area is recomputed without them so doors do not appear.
211
+
212
+ Args:
213
+ collector: Parsed scene collector.
214
+ remove_doors: Whether to hide door footprints.
215
+
216
+ Returns:
217
+ Tuple of (footprints dict, occupied-area geometry).
218
+ """
219
+ if not remove_doors:
220
+ return collector.footprints, collector.occ_area
221
+
222
+ footprints = {
223
+ key: poly
224
+ for key, poly in collector.footprints.items()
225
+ if "door" not in key.lower()
226
+ }
227
+ occ_area = (
228
+ unary_union(list(footprints.values())) if footprints else Polygon()
229
+ )
230
+ return footprints, occ_area
231
+
232
+
233
+ def animate_floorplan(
234
+ rooms: dict,
235
+ footprints: dict,
236
+ occ_area,
237
+ points: np.ndarray,
238
+ output_dir: Path,
239
+ frame_stride: int,
240
+ fps: float,
241
+ dpi: int,
242
+ ) -> None:
243
+ """Render a floorplan animation that follows the trajectory progress.
244
+
245
+ Each frame shows the traveled path in red, a green dot at the current
246
+ position, and a red heading arrow; the future path is hidden. Frame
247
+ indices match the render frames (same stride) for easy pairing.
248
+
249
+ Args:
250
+ rooms: Room-name to floor-polygon map.
251
+ footprints: Object-name to footprint-polygon map (to draw).
252
+ occ_area: Occupied-area geometry overlay.
253
+ points: (N, 3) array of (x, y, rot_deg) waypoints.
254
+ output_dir: Directory to write ``frames/`` and ``trajectory.mp4``.
255
+ frame_stride: Render every Nth waypoint.
256
+ fps: Output video frame rate.
257
+ dpi: Resolution of each frame.
258
+ """
259
+ frames_dir = output_dir / "frames"
260
+ frames_dir.mkdir(parents=True, exist_ok=True)
261
+
262
+ for i in range(0, len(points), max(1, frame_stride)):
263
+ FloorplanVisualizer.plot(
264
+ rooms,
265
+ footprints,
266
+ occ_area,
267
+ str(frames_dir / f"frame_{i:04d}.png"),
268
+ trajectory=points,
269
+ current_index=i,
270
+ dpi=dpi,
271
+ )
272
+
273
+ video_path = output_dir / "trajectory.mp4"
274
+ cmd = [
275
+ "ffmpeg",
276
+ "-y",
277
+ "-framerate",
278
+ f"{max(fps, 1.0):g}",
279
+ "-pattern_type",
280
+ "glob",
281
+ "-i",
282
+ str(frames_dir / "frame_*.png"),
283
+ "-vf",
284
+ "pad=ceil(iw/2)*2:ceil(ih/2)*2",
285
+ "-c:v",
286
+ "libx264",
287
+ "-pix_fmt",
288
+ "yuv420p",
289
+ str(video_path),
290
+ ]
291
+ result = subprocess.run(cmd, capture_output=True, text=True)
292
+ if result.returncode != 0:
293
+ raise RuntimeError(f"ffmpeg failed for {video_path}:\n{result.stderr}")
294
+
295
+ logger.info("Trajectory animation : %s", video_path)
296
+
297
+
298
+ def main() -> None:
299
+ """CLI entry point."""
300
+ logging.basicConfig(level=logging.INFO, format="%(message)s")
301
+ cfg = tyro.cli(GenTrajectoryArgs)
302
+ generate_trajectory(cfg)
303
+
304
+
305
+ if __name__ == "__main__":
306
+ main()
embodied_gen/scripts/room_gen/render_usd.py CHANGED
@@ -18,11 +18,15 @@
18
  from __future__ import annotations
19
 
20
  import argparse
 
 
21
  import logging
22
  import math
23
  import shutil
 
24
  import tempfile
25
  from collections.abc import Callable
 
26
  from pathlib import Path
27
 
28
  import bpy
@@ -32,6 +36,31 @@ from mathutils import Euler, Matrix, Vector
32
 
33
  logger = logging.getLogger(__name__)
34
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
 
36
  def build_arg_parser() -> argparse.ArgumentParser:
37
  """Build the CLI parser for USD rendering."""
@@ -61,6 +90,16 @@ def build_arg_parser() -> argparse.ArgumentParser:
61
  "--depth_mode",
62
  choices=("normalized", "metric"),
63
  default="normalized",
 
 
 
 
 
 
 
 
 
 
64
  )
65
  parser.add_argument(
66
  "--resolution",
@@ -75,14 +114,20 @@ def build_arg_parser() -> argparse.ArgumentParser:
75
  type=float,
76
  nargs=3,
77
  metavar=("X", "Y", "Z"),
78
- required=True,
 
 
 
79
  )
80
  parser.add_argument(
81
  "--camera_rotation_deg",
82
  type=float,
83
  nargs=3,
84
  metavar=("RX", "RY", "RZ"),
85
- required=True,
 
 
 
86
  )
87
  parser.add_argument(
88
  "--flow_camera_xyz",
@@ -100,6 +145,113 @@ def build_arg_parser() -> argparse.ArgumentParser:
100
  parser.add_argument("--exposure", type=float, default=2.2)
101
  parser.add_argument("--world_strength", type=float, default=8.0)
102
  parser.add_argument("--fill_light_energy", type=float, default=14000.0)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
103
  return parser
104
 
105
 
@@ -120,6 +272,7 @@ class RenderUsd:
120
  output_dir: Path,
121
  render_passes: tuple[str, ...] | list[str],
122
  depth_mode: str,
 
123
  resolution: tuple[int, int] | list[int],
124
  samples: int,
125
  camera_xyz: tuple[float, float, float] | list[float],
@@ -132,17 +285,21 @@ class RenderUsd:
132
  exposure: float,
133
  world_strength: float,
134
  fill_light_energy: float,
 
135
  ) -> None:
136
  """Initialize renderer configuration independent of CLI parsing."""
137
  self.usd_path = usd_path
138
  self.glb_path = self.normalize_optional_path(glb_path)
139
  self.glb_xyz = tuple(glb_xyz) if glb_xyz is not None else None
140
  self.glb_rotation_deg = (
141
- tuple(glb_rotation_deg) if glb_rotation_deg is not None else None
142
  )
 
 
143
  self.output_dir = output_dir
144
  self.render_passes = tuple(render_passes)
145
  self.depth_mode = depth_mode
 
146
  self.resolution = tuple(resolution)
147
  self.samples = samples
148
  self.camera_xyz = tuple(camera_xyz)
@@ -159,6 +316,12 @@ class RenderUsd:
159
  self.exposure = exposure
160
  self.world_strength = world_strength
161
  self.fill_light_energy = fill_light_energy
 
 
 
 
 
 
162
  self.temp_dir: Path | None = None
163
 
164
  @classmethod
@@ -172,6 +335,7 @@ class RenderUsd:
172
  output_dir=args.output_dir,
173
  render_passes=args.render_passes,
174
  depth_mode=args.depth_mode,
 
175
  resolution=args.resolution,
176
  samples=args.samples,
177
  camera_xyz=args.camera_xyz,
@@ -182,6 +346,7 @@ class RenderUsd:
182
  exposure=args.exposure,
183
  world_strength=args.world_strength,
184
  fill_light_energy=args.fill_light_energy,
 
185
  )
186
 
187
  @property
@@ -203,7 +368,17 @@ class RenderUsd:
203
  return Path(normalized)
204
 
205
  def build_output_path(self, filename: str) -> Path:
206
- """Build a normalized output path under the render directory."""
 
 
 
 
 
 
 
 
 
 
207
  return self.output_dir / filename
208
 
209
  def build_temp_path(self, filename: str) -> Path:
@@ -308,19 +483,43 @@ class RenderUsd:
308
  raise FileNotFoundError(f"USD file not found: {self.usd_path}")
309
  bpy.ops.wm.usd_import(filepath=str(self.usd_path))
310
 
311
- def validate_glb_args(self) -> None:
 
 
 
 
 
 
 
 
 
 
312
  """Normalize optional GLB arguments and ensure all-or-none usage."""
313
  has_glb_path = self.glb_path is not None
314
  has_glb_xyz = self.glb_xyz is not None
315
  has_glb_rotation = self.glb_rotation_deg is not None
316
- if len({has_glb_path, has_glb_xyz, has_glb_rotation}) != 1:
 
 
 
 
 
 
 
 
 
317
  raise ValueError(
318
  "--glb_path, --glb_xyz, and --glb_rotation_deg must be "
319
- "provided together."
 
320
  )
321
- if not has_glb_path:
322
- return
323
 
 
 
 
 
 
 
324
  if not self.glb_path.exists():
325
  raise FileNotFoundError(f"GLB file not found: {self.glb_path}")
326
  if self.glb_path.suffix.lower() != ".glb":
@@ -566,17 +765,43 @@ class RenderUsd:
566
  self.scene.render.resolution_y = self.resolution[1]
567
  self.scene.render.image_settings.file_format = "PNG"
568
  self.scene.render.film_transparent = False
 
 
 
 
 
 
 
569
 
 
 
570
  prefs = bpy.context.preferences.addons["cycles"].preferences
571
- prefs.compute_device_type = "CUDA"
572
- prefs.get_devices()
573
- cuda_devices = [
574
- device for device in prefs.devices if device.type == "CUDA"
575
- ]
576
- if not cuda_devices:
577
- raise RuntimeError("No CUDA device found in Blender Cycles.")
578
- for device in prefs.devices:
579
- device.use = device.type == "CUDA"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
580
 
581
  def configure_color_management(self) -> None:
582
  self.scene.view_settings.exposure = self.exposure
@@ -602,6 +827,7 @@ class RenderUsd:
602
  "color_depth": self.scene.render.image_settings.color_depth,
603
  "use_nodes": self.scene.use_nodes,
604
  "samples": self.scene.cycles.samples,
 
605
  }
606
  if include_filepath:
607
  state["filepath"] = self.scene.render.filepath
@@ -627,6 +853,7 @@ class RenderUsd:
627
  self.scene.render.image_settings.color_depth = state["color_depth"]
628
  self.scene.use_nodes = state["use_nodes"]
629
  self.scene.cycles.samples = state["samples"]
 
630
  if "filepath" in state:
631
  self.scene.render.filepath = state["filepath"]
632
  if "material_override" in state:
@@ -652,6 +879,9 @@ class RenderUsd:
652
  self.scene.view_settings.gamma = 1.0
653
  self.scene.use_nodes = use_nodes
654
  self.scene.cycles.samples = samples
 
 
 
655
  self.scene.render.image_settings.file_format = "PNG"
656
  self.scene.render.image_settings.color_mode = color_mode
657
  self.scene.render.image_settings.color_depth = color_depth
@@ -785,6 +1015,7 @@ class RenderUsd:
785
  depth_mode: str,
786
  ) -> bpy.types.Node:
787
  """Build the compositor node that converts raw depth to a previewable map."""
 
788
  if depth_mode == "normalized":
789
  normalize = tree.nodes.new(type="CompositorNodeNormalize")
790
  tree.links.new(render_layers.outputs["Depth"], normalize.inputs[0])
@@ -794,8 +1025,9 @@ class RenderUsd:
794
  raise ValueError(f"Unsupported depth mode: {depth_mode}")
795
 
796
  depth_map = tree.nodes.new(type="CompositorNodeMapRange")
797
- depth_map.inputs["From Min"].default_value = camera.clip_start
798
- depth_map.inputs["From Max"].default_value = camera.clip_end
 
799
  depth_map.inputs["To Min"].default_value = 0.0
800
  depth_map.inputs["To Max"].default_value = 1.0
801
  depth_map.use_clamp = True
@@ -861,6 +1093,7 @@ class RenderUsd:
861
  self, temp_path: Path, output_path: Path
862
  ) -> None:
863
  """Convert the grayscale depth temp image into the final colored preview."""
 
864
  if output_path.exists():
865
  output_path.unlink()
866
  if not temp_path.exists():
@@ -1097,8 +1330,8 @@ class RenderUsd:
1097
  output_path: Path,
1098
  instance_seg: np.ndarray,
1099
  ) -> None:
1100
- output_path.parent.mkdir(parents=True, exist_ok=True)
1101
  vis_output_path = self.get_instance_seg_vis_output_path(output_path)
 
1102
 
1103
  visualization = self.build_instance_seg_visualization(
1104
  instance_seg=instance_seg,
@@ -1255,10 +1488,15 @@ class RenderUsd:
1255
  flow: np.ndarray,
1256
  valid_mask: np.ndarray,
1257
  ) -> None:
1258
- output_path.parent.mkdir(parents=True, exist_ok=True)
1259
  flow_output_path = self.get_flow_output_path(output_path)
1260
  flow_valid_output_path = self.get_flow_valid_output_path(output_path)
1261
  flow_vis_output_path = self.get_flow_vis_output_path(output_path)
 
 
 
 
 
 
1262
 
1263
  self.save_numpy_array(flow_output_path, flow)
1264
  self.save_numpy_array(flow_valid_output_path, valid_mask)
@@ -1437,6 +1675,7 @@ class RenderUsd:
1437
  composite_output_path = self.get_composite_output_path(
1438
  tuple(render_pass_name for render_pass_name, _ in composite_images)
1439
  )
 
1440
  composite_image = self.build_composite_image(
1441
  [image for _, image in composite_images],
1442
  [render_pass_name for render_pass_name, _ in composite_images],
@@ -1560,6 +1799,8 @@ class RenderUsd:
1560
  self.output_dir.mkdir(parents=True, exist_ok=True)
1561
  self.clear_scene()
1562
  self.import_usd()
 
 
1563
  self.validate_glb_args()
1564
  imported_glb_objects = self.import_glb_asset()
1565
  self.place_glb_asset(imported_glb_objects)
@@ -1595,10 +1836,764 @@ class RenderUsd:
1595
 
1596
  logger.info("Rendered outputs to %s", self.output_dir)
1597
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1598
 
1599
  def main() -> None:
1600
  logging.basicConfig(level=logging.INFO)
1601
  args = _parse_args()
 
 
 
 
 
 
 
 
 
 
 
 
1602
  RenderUsd.from_args(args).run()
1603
 
1604
 
 
18
  from __future__ import annotations
19
 
20
  import argparse
21
+ import csv
22
+ import json
23
  import logging
24
  import math
25
  import shutil
26
+ import subprocess
27
  import tempfile
28
  from collections.abc import Callable
29
+ from dataclasses import dataclass
30
  from pathlib import Path
31
 
32
  import bpy
 
36
 
37
  logger = logging.getLogger(__name__)
38
 
39
+ DOOR_KEYWORDS = ("door",)
40
+ """Object-name keywords used to identify door meshes for removal."""
41
+
42
+ CAMERA_TRAJECTORY_CSV_FIELDS = (
43
+ "frame",
44
+ "time_sec",
45
+ "x",
46
+ "y",
47
+ "z",
48
+ "roll_x_deg",
49
+ "pitch_y_deg",
50
+ "yaw_z_deg",
51
+ )
52
+ """Required fields for a camera trajectory CSV."""
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class CameraTrajectoryFrame:
57
+ """One fully specified camera pose from a trajectory CSV."""
58
+
59
+ frame_index: int
60
+ time_sec: float
61
+ xyz: tuple[float, float, float]
62
+ rotation_deg: tuple[float, float, float]
63
+
64
 
65
  def build_arg_parser() -> argparse.ArgumentParser:
66
  """Build the CLI parser for USD rendering."""
 
90
  "--depth_mode",
91
  choices=("normalized", "metric"),
92
  default="normalized",
93
+ help=(
94
+ "normalized: per-frame min-max (default); metric: fixed "
95
+ "0..--depth_max mapping, consistent across frames."
96
+ ),
97
+ )
98
+ parser.add_argument(
99
+ "--depth_max",
100
+ type=float,
101
+ default=10.0,
102
+ help="Max depth (m) mapped to white in metric depth mode.",
103
  )
104
  parser.add_argument(
105
  "--resolution",
 
114
  type=float,
115
  nargs=3,
116
  metavar=("X", "Y", "Z"),
117
+ help=(
118
+ "Required for single-frame mode; ignored with --trajectory_json "
119
+ "or --trajectory_csv."
120
+ ),
121
  )
122
  parser.add_argument(
123
  "--camera_rotation_deg",
124
  type=float,
125
  nargs=3,
126
  metavar=("RX", "RY", "RZ"),
127
+ help=(
128
+ "Required for single-frame mode; ignored with --trajectory_json "
129
+ "or --trajectory_csv."
130
+ ),
131
  )
132
  parser.add_argument(
133
  "--flow_camera_xyz",
 
145
  parser.add_argument("--exposure", type=float, default=2.2)
146
  parser.add_argument("--world_strength", type=float, default=8.0)
147
  parser.add_argument("--fill_light_energy", type=float, default=14000.0)
148
+ parser.add_argument(
149
+ "--remove_doors",
150
+ action="store_true",
151
+ help="Remove all door meshes from the USD before rendering.",
152
+ )
153
+
154
+ # Trajectory modes: roaming JSON follows (x, y, rot) waypoints with a GLB
155
+ # offset, while camera CSV uses a complete 6DoF camera pose per row.
156
+ trajectory_group = parser.add_mutually_exclusive_group()
157
+ trajectory_group.add_argument(
158
+ "--trajectory_json",
159
+ type=Path,
160
+ default=None,
161
+ help=(
162
+ "Trajectory JSON ({waypoints:[{x,y,rot,...}]}). When set, renders "
163
+ "one frame per waypoint instead of a single frame."
164
+ ),
165
+ )
166
+ trajectory_group.add_argument(
167
+ "--trajectory_csv",
168
+ type=Path,
169
+ default=None,
170
+ help=(
171
+ "Camera trajectory CSV with fields frame,time_sec,x,y,z,"
172
+ "roll_x_deg,pitch_y_deg,yaw_z_deg. Each row is rendered as one "
173
+ "frame using its full camera pose."
174
+ ),
175
+ )
176
+ parser.add_argument(
177
+ "--camera_z",
178
+ type=float,
179
+ default=1.36,
180
+ help="Camera height (m) for trajectory mode.",
181
+ )
182
+ parser.add_argument(
183
+ "--camera_pitch",
184
+ type=float,
185
+ default=80.0,
186
+ help="Camera pitch deg (rotation_deg[0]) for trajectory mode.",
187
+ )
188
+ parser.add_argument(
189
+ "--camera_roll",
190
+ type=float,
191
+ default=0.0,
192
+ help="Camera roll deg (rotation_deg[1]) for trajectory mode.",
193
+ )
194
+ parser.add_argument(
195
+ "--glb_offset_xyz",
196
+ type=float,
197
+ nargs=3,
198
+ default=(0.0012, 0.345, -1.36),
199
+ metavar=("DX", "DY", "DZ"),
200
+ help=(
201
+ "GLB position minus camera position at yaw=0 (world frame). The "
202
+ "horizontal part rotates with the camera yaw (rot); DZ is fixed."
203
+ ),
204
+ )
205
+ parser.add_argument(
206
+ "--glb_base_rotation_deg",
207
+ type=float,
208
+ nargs=3,
209
+ default=(-90.0, 0.0, 90.0),
210
+ metavar=("RX", "RY", "RZ"),
211
+ help=(
212
+ "GLB rotation deg at yaw=0; the Z component gets + rot per frame."
213
+ ),
214
+ )
215
+ parser.add_argument(
216
+ "--frame_stride",
217
+ type=int,
218
+ default=1,
219
+ help="Render every Nth waypoint/CSV row in trajectory mode.",
220
+ )
221
+ parser.add_argument(
222
+ "--frame_indices",
223
+ type=int,
224
+ nargs="+",
225
+ default=None,
226
+ metavar="INDEX",
227
+ help=(
228
+ "Render only these trajectory frame/waypoint indices. "
229
+ "When set, --frame_stride is ignored."
230
+ ),
231
+ )
232
+ parser.add_argument(
233
+ "--overwrite",
234
+ action="store_true",
235
+ help=(
236
+ "Re-render trajectory frames even if they already exist "
237
+ "(default: skip existing frames for per-frame resume)."
238
+ ),
239
+ )
240
+ parser.add_argument(
241
+ "--make_video",
242
+ action=argparse.BooleanOptionalAction,
243
+ default=True,
244
+ help="Stitch trajectory RGB frames into an mp4 (default: on).",
245
+ )
246
+ parser.add_argument(
247
+ "--video_fps",
248
+ type=float,
249
+ default=0.0,
250
+ help=(
251
+ "Video frame rate. 0 = auto from JSON time_step or CSV time_sec "
252
+ "and --frame_stride for real-time playback."
253
+ ),
254
+ )
255
  return parser
256
 
257
 
 
272
  output_dir: Path,
273
  render_passes: tuple[str, ...] | list[str],
274
  depth_mode: str,
275
+ depth_max: float,
276
  resolution: tuple[int, int] | list[int],
277
  samples: int,
278
  camera_xyz: tuple[float, float, float] | list[float],
 
285
  exposure: float,
286
  world_strength: float,
287
  fill_light_energy: float,
288
+ remove_doors: bool = False,
289
  ) -> None:
290
  """Initialize renderer configuration independent of CLI parsing."""
291
  self.usd_path = usd_path
292
  self.glb_path = self.normalize_optional_path(glb_path)
293
  self.glb_xyz = tuple(glb_xyz) if glb_xyz is not None else None
294
  self.glb_rotation_deg = (
295
+ list(glb_rotation_deg) if glb_rotation_deg is not None else None
296
  )
297
+ if self.glb_rotation_deg is not None:
298
+ self.glb_rotation_deg[0] += 90
299
  self.output_dir = output_dir
300
  self.render_passes = tuple(render_passes)
301
  self.depth_mode = depth_mode
302
+ self.depth_max = depth_max
303
  self.resolution = tuple(resolution)
304
  self.samples = samples
305
  self.camera_xyz = tuple(camera_xyz)
 
316
  self.exposure = exposure
317
  self.world_strength = world_strength
318
  self.fill_light_energy = fill_light_energy
319
+ self.remove_doors = remove_doors
320
+ # Trajectory frame layout: route each pass to frames_<pass>/
321
+ # frame_<idx>.<ext> under frame_base_dir instead of output_dir.
322
+ self.frame_layout = False
323
+ self.frame_base_dir: Path | None = None
324
+ self.frame_index = 0
325
  self.temp_dir: Path | None = None
326
 
327
  @classmethod
 
335
  output_dir=args.output_dir,
336
  render_passes=args.render_passes,
337
  depth_mode=args.depth_mode,
338
+ depth_max=args.depth_max,
339
  resolution=args.resolution,
340
  samples=args.samples,
341
  camera_xyz=args.camera_xyz,
 
346
  exposure=args.exposure,
347
  world_strength=args.world_strength,
348
  fill_light_energy=args.fill_light_energy,
349
+ remove_doors=args.remove_doors,
350
  )
351
 
352
  @property
 
368
  return Path(normalized)
369
 
370
  def build_output_path(self, filename: str) -> Path:
371
+ """Build a normalized output path under the render directory.
372
+
373
+ In trajectory frame layout, each pass is routed to its own
374
+ ``frames_<pass>/frame_<idx>.<ext>`` directory under
375
+ ``frame_base_dir``; otherwise the file lives directly in
376
+ ``output_dir``.
377
+ """
378
+ if self.frame_layout and self.frame_base_dir is not None:
379
+ name = Path(filename)
380
+ subdir = self.frame_base_dir / f"frames_{name.stem}"
381
+ return subdir / f"frame_{self.frame_index:04d}{name.suffix}"
382
  return self.output_dir / filename
383
 
384
  def build_temp_path(self, filename: str) -> Path:
 
483
  raise FileNotFoundError(f"USD file not found: {self.usd_path}")
484
  bpy.ops.wm.usd_import(filepath=str(self.usd_path))
485
 
486
+ def remove_door_objects(self) -> int:
487
+ """Delete any object whose name contains a door keyword."""
488
+ removed = 0
489
+ for obj in list(self.scene.objects):
490
+ if any(kw in obj.name.lower() for kw in DOOR_KEYWORDS):
491
+ bpy.data.objects.remove(obj, do_unlink=True)
492
+ removed += 1
493
+ logger.info("Removed %d door objects.", removed)
494
+ return removed
495
+
496
+ def validate_glb_args(self, *, allow_path_only: bool = False) -> None:
497
  """Normalize optional GLB arguments and ensure all-or-none usage."""
498
  has_glb_path = self.glb_path is not None
499
  has_glb_xyz = self.glb_xyz is not None
500
  has_glb_rotation = self.glb_rotation_deg is not None
501
+ if not has_glb_path:
502
+ if has_glb_xyz or has_glb_rotation:
503
+ raise ValueError(
504
+ "--glb_xyz and --glb_rotation_deg require --glb_path."
505
+ )
506
+ return
507
+ if allow_path_only and not has_glb_xyz and not has_glb_rotation:
508
+ self.validate_glb_path()
509
+ return
510
+ if not has_glb_xyz or not has_glb_rotation:
511
  raise ValueError(
512
  "--glb_path, --glb_xyz, and --glb_rotation_deg must be "
513
+ "provided together, unless trajectory rendering follows the "
514
+ "camera with --glb_offset_xyz/--glb_base_rotation_deg."
515
  )
 
 
516
 
517
+ self.validate_glb_path()
518
+
519
+ def validate_glb_path(self) -> None:
520
+ """Ensure the optional GLB path points to an existing .glb file."""
521
+ if self.glb_path is None:
522
+ return
523
  if not self.glb_path.exists():
524
  raise FileNotFoundError(f"GLB file not found: {self.glb_path}")
525
  if self.glb_path.suffix.lower() != ".glb":
 
765
  self.scene.render.resolution_y = self.resolution[1]
766
  self.scene.render.image_settings.file_format = "PNG"
767
  self.scene.render.film_transparent = False
768
+ self.scene.render.use_persistent_data = False
769
+
770
+ # Adaptive sampling + denoising keep quality high at low sample counts.
771
+ self.scene.cycles.use_adaptive_sampling = True
772
+ self.scene.cycles.use_denoising = True
773
+
774
+ self.enable_gpu_devices()
775
 
776
+ def enable_gpu_devices(self) -> None:
777
+ """Enable Cycles GPU devices, preferring OptiX then CUDA."""
778
  prefs = bpy.context.preferences.addons["cycles"].preferences
779
+ for backend in ("OPTIX", "CUDA"):
780
+ try:
781
+ prefs.compute_device_type = backend
782
+ except TypeError:
783
+ continue
784
+ prefs.get_devices()
785
+ gpu_devices = [d for d in prefs.devices if d.type == backend]
786
+ if not gpu_devices:
787
+ continue
788
+
789
+ for device in prefs.devices:
790
+ device.use = device.type == backend
791
+ try:
792
+ self.scene.cycles.denoiser = (
793
+ "OPTIX" if backend == "OPTIX" else "OPENIMAGEDENOISE"
794
+ )
795
+ except (TypeError, AttributeError):
796
+ pass
797
+ logger.info(
798
+ "Cycles GPU backend: %s (%d device(s)).",
799
+ backend,
800
+ len(gpu_devices),
801
+ )
802
+ return
803
+
804
+ raise RuntimeError("No OptiX/CUDA GPU device found in Blender Cycles.")
805
 
806
  def configure_color_management(self) -> None:
807
  self.scene.view_settings.exposure = self.exposure
 
827
  "color_depth": self.scene.render.image_settings.color_depth,
828
  "use_nodes": self.scene.use_nodes,
829
  "samples": self.scene.cycles.samples,
830
+ "use_denoising": self.scene.cycles.use_denoising,
831
  }
832
  if include_filepath:
833
  state["filepath"] = self.scene.render.filepath
 
853
  self.scene.render.image_settings.color_depth = state["color_depth"]
854
  self.scene.use_nodes = state["use_nodes"]
855
  self.scene.cycles.samples = state["samples"]
856
+ self.scene.cycles.use_denoising = state["use_denoising"]
857
  if "filepath" in state:
858
  self.scene.render.filepath = state["filepath"]
859
  if "material_override" in state:
 
879
  self.scene.view_settings.gamma = 1.0
880
  self.scene.use_nodes = use_nodes
881
  self.scene.cycles.samples = samples
882
+ # Auxiliary passes (normal/mesh/seg/flow depth) must not be denoised:
883
+ # denoising would blur normals and corrupt integer index passes.
884
+ self.scene.cycles.use_denoising = False
885
  self.scene.render.image_settings.file_format = "PNG"
886
  self.scene.render.image_settings.color_mode = color_mode
887
  self.scene.render.image_settings.color_depth = color_depth
 
1015
  depth_mode: str,
1016
  ) -> bpy.types.Node:
1017
  """Build the compositor node that converts raw depth to a previewable map."""
1018
+ del camera # Mapping uses self.depth_max, not the camera clip range.
1019
  if depth_mode == "normalized":
1020
  normalize = tree.nodes.new(type="CompositorNodeNormalize")
1021
  tree.links.new(render_layers.outputs["Depth"], normalize.inputs[0])
 
1025
  raise ValueError(f"Unsupported depth mode: {depth_mode}")
1026
 
1027
  depth_map = tree.nodes.new(type="CompositorNodeMapRange")
1028
+ # Fixed 0..depth_max mapping keeps depth consistent across frames.
1029
+ depth_map.inputs["From Min"].default_value = 0.0
1030
+ depth_map.inputs["From Max"].default_value = self.depth_max
1031
  depth_map.inputs["To Min"].default_value = 0.0
1032
  depth_map.inputs["To Max"].default_value = 1.0
1033
  depth_map.use_clamp = True
 
1093
  self, temp_path: Path, output_path: Path
1094
  ) -> None:
1095
  """Convert the grayscale depth temp image into the final colored preview."""
1096
+ output_path.parent.mkdir(parents=True, exist_ok=True)
1097
  if output_path.exists():
1098
  output_path.unlink()
1099
  if not temp_path.exists():
 
1330
  output_path: Path,
1331
  instance_seg: np.ndarray,
1332
  ) -> None:
 
1333
  vis_output_path = self.get_instance_seg_vis_output_path(output_path)
1334
+ vis_output_path.parent.mkdir(parents=True, exist_ok=True)
1335
 
1336
  visualization = self.build_instance_seg_visualization(
1337
  instance_seg=instance_seg,
 
1488
  flow: np.ndarray,
1489
  valid_mask: np.ndarray,
1490
  ) -> None:
 
1491
  flow_output_path = self.get_flow_output_path(output_path)
1492
  flow_valid_output_path = self.get_flow_valid_output_path(output_path)
1493
  flow_vis_output_path = self.get_flow_vis_output_path(output_path)
1494
+ for path in (
1495
+ flow_output_path,
1496
+ flow_valid_output_path,
1497
+ flow_vis_output_path,
1498
+ ):
1499
+ path.parent.mkdir(parents=True, exist_ok=True)
1500
 
1501
  self.save_numpy_array(flow_output_path, flow)
1502
  self.save_numpy_array(flow_valid_output_path, valid_mask)
 
1675
  composite_output_path = self.get_composite_output_path(
1676
  tuple(render_pass_name for render_pass_name, _ in composite_images)
1677
  )
1678
+ composite_output_path.parent.mkdir(parents=True, exist_ok=True)
1679
  composite_image = self.build_composite_image(
1680
  [image for _, image in composite_images],
1681
  [render_pass_name for render_pass_name, _ in composite_images],
 
1799
  self.output_dir.mkdir(parents=True, exist_ok=True)
1800
  self.clear_scene()
1801
  self.import_usd()
1802
+ if self.remove_doors:
1803
+ self.remove_door_objects()
1804
  self.validate_glb_args()
1805
  imported_glb_objects = self.import_glb_asset()
1806
  self.place_glb_asset(imported_glb_objects)
 
1836
 
1837
  logger.info("Rendered outputs to %s", self.output_dir)
1838
 
1839
+ def compute_trajectory_frame_transforms(
1840
+ self,
1841
+ x: float,
1842
+ y: float,
1843
+ rot_deg: float,
1844
+ *,
1845
+ camera_z: float,
1846
+ camera_pitch: float,
1847
+ camera_roll: float,
1848
+ glb_offset_xyz: tuple[float, float, float],
1849
+ glb_base_rotation_deg: tuple[float, float, float],
1850
+ ) -> tuple[Vector, Euler, Matrix]:
1851
+ """Compute per-frame camera and GLB transforms for one waypoint.
1852
+
1853
+ The camera sits at (x, y, camera_z) and yaws by ``rot_deg`` (its
1854
+ heading). The GLB keeps a fixed offset relative to the camera: the
1855
+ horizontal offset rotates with the yaw and its yaw adds ``rot_deg``,
1856
+ while its height stays on the floor.
1857
+
1858
+ Args:
1859
+ x: Waypoint X (m).
1860
+ y: Waypoint Y (m).
1861
+ rot_deg: Waypoint heading in degrees (camera/GLB yaw).
1862
+ camera_z: Camera height (m).
1863
+ camera_pitch: Camera pitch deg (rotation_deg[0]).
1864
+ camera_roll: Camera roll deg (rotation_deg[1]).
1865
+ glb_offset_xyz: GLB-minus-camera offset at yaw=0 (world frame).
1866
+ glb_base_rotation_deg: GLB rotation deg at yaw=0.
1867
+
1868
+ Returns:
1869
+ Tuple of (camera_location, camera_euler, glb_world_transform).
1870
+ """
1871
+ camera_location = Vector((x, y, camera_z))
1872
+ camera_euler = Euler(
1873
+ self.get_rotation_radians((camera_pitch, camera_roll, rot_deg)),
1874
+ "XYZ",
1875
+ )
1876
+
1877
+ yaw = math.radians(rot_deg)
1878
+ cos_yaw, sin_yaw = math.cos(yaw), math.sin(yaw)
1879
+ off_x, off_y, off_z = glb_offset_xyz
1880
+ glb_xyz = (
1881
+ x + off_x * cos_yaw - off_y * sin_yaw,
1882
+ y + off_x * sin_yaw + off_y * cos_yaw,
1883
+ camera_z + off_z,
1884
+ )
1885
+ base_rx, base_ry, base_rz = glb_base_rotation_deg
1886
+ glb_rotation_deg = (base_rx + 90.0, base_ry, base_rz + rot_deg)
1887
+ glb_transform = self.build_camera_matrix_world(
1888
+ glb_xyz, glb_rotation_deg
1889
+ )
1890
+ return camera_location, camera_euler, glb_transform
1891
+
1892
+ def compute_camera_follow_glb_transform(
1893
+ self,
1894
+ camera_xyz: tuple[float, float, float],
1895
+ camera_rotation_deg: tuple[float, float, float],
1896
+ *,
1897
+ glb_offset_xyz: tuple[float, float, float],
1898
+ glb_base_rotation_deg: tuple[float, float, float],
1899
+ ) -> Matrix:
1900
+ """Compute a GLB transform from the default camera-follow offset."""
1901
+ _, _, glb_transform = self.compute_trajectory_frame_transforms(
1902
+ camera_xyz[0],
1903
+ camera_xyz[1],
1904
+ camera_rotation_deg[2],
1905
+ camera_z=camera_xyz[2],
1906
+ camera_pitch=camera_rotation_deg[0],
1907
+ camera_roll=camera_rotation_deg[1],
1908
+ glb_offset_xyz=glb_offset_xyz,
1909
+ glb_base_rotation_deg=glb_base_rotation_deg,
1910
+ )
1911
+ return glb_transform
1912
+
1913
+ def frame_outputs_exist(self) -> bool:
1914
+ """Whether every requested pass output for the current frame exists.
1915
+
1916
+ Used for per-frame resume: a frame is skipped only when all its
1917
+ requested pass preview outputs are already on disk.
1918
+ """
1919
+ rgb_path = self.get_rgb_output_path()
1920
+ for pass_name, occurrence in self.iter_render_pass_occurrences():
1921
+ preview = self.get_preview_output_path(
1922
+ rgb_path, pass_name, occurrence
1923
+ )
1924
+ if preview is None or not preview.exists():
1925
+ return False
1926
+ return True
1927
+
1928
+ def run_trajectory(
1929
+ self,
1930
+ *,
1931
+ waypoints: list[tuple[float, float, float]],
1932
+ camera_z: float,
1933
+ camera_pitch: float,
1934
+ camera_roll: float,
1935
+ glb_offset_xyz: tuple[float, float, float],
1936
+ glb_base_rotation_deg: tuple[float, float, float],
1937
+ frame_stride: int,
1938
+ frame_indices: list[int] | None = None,
1939
+ make_video: bool = False,
1940
+ video_fps: float = 10.0,
1941
+ overwrite: bool = False,
1942
+ ) -> None:
1943
+ """Render one frame per trajectory waypoint following the camera path.
1944
+
1945
+ The scene (USD, GLB, lighting, world) is set up once; each frame only
1946
+ repositions the camera and GLB, then renders into a shared ``frames/``
1947
+ directory with a ``frame_<idx>_`` filename prefix. When ``make_video``
1948
+ is set, the RGB frames are stitched into an mp4.
1949
+
1950
+ Args:
1951
+ waypoints: List of (x, y, rot_deg) waypoints.
1952
+ camera_z: Camera height (m).
1953
+ camera_pitch: Camera pitch deg (rotation_deg[0]).
1954
+ camera_roll: Camera roll deg (rotation_deg[1]).
1955
+ glb_offset_xyz: GLB-minus-camera offset at yaw=0 (world frame).
1956
+ glb_base_rotation_deg: GLB rotation deg at yaw=0.
1957
+ frame_stride: Render every Nth waypoint.
1958
+ frame_indices: Optional zero-based waypoint indices to render.
1959
+ make_video: Stitch the RGB frames into an mp4 after rendering.
1960
+ video_fps: Frame rate of the stitched video.
1961
+ overwrite: Re-render every frame; if False, frames whose pass
1962
+ outputs already exist are skipped (per-frame resume).
1963
+ """
1964
+ if not waypoints:
1965
+ raise ValueError("Trajectory contains no waypoints.")
1966
+ if self.glb_path is None:
1967
+ raise ValueError(
1968
+ "--glb_path is required for trajectory rendering."
1969
+ )
1970
+ if not self.glb_path.exists():
1971
+ raise FileNotFoundError(f"GLB file not found: {self.glb_path}")
1972
+ if self.glb_path.suffix.lower() != ".glb":
1973
+ raise ValueError(
1974
+ f"Expected a .glb asset, but got: {self.glb_path}"
1975
+ )
1976
+
1977
+ base_output_dir = self.output_dir
1978
+ base_output_dir.mkdir(parents=True, exist_ok=True)
1979
+
1980
+ self.clear_scene()
1981
+ self.import_usd()
1982
+ if self.remove_doors:
1983
+ self.remove_door_objects()
1984
+ imported_glb_objects = self.import_glb_asset()
1985
+ root_objects = self.get_imported_root_objects(imported_glb_objects)
1986
+ original_matrices = {
1987
+ obj.as_pointer(): obj.matrix_world.copy() for obj in root_objects
1988
+ }
1989
+
1990
+ min_corner, max_corner = self.get_scene_bbox()
1991
+ center = (min_corner + max_corner) * 0.5
1992
+ diagonal = (max_corner - min_corner).length
1993
+ camera = self.create_camera()
1994
+ self.ensure_lighting(diagonal, center, max_corner.z)
1995
+ world_created = self.ensure_world()
1996
+ self.add_fill_light(
1997
+ diagonal, center, max_corner.z, energy=self.fill_light_energy
1998
+ )
1999
+ if world_created:
2000
+ self.add_light_rig(
2001
+ diagonal,
2002
+ center,
2003
+ max_corner.z,
2004
+ area_energy=1500.0,
2005
+ sun_energy=0.35,
2006
+ prefix="Fill",
2007
+ )
2008
+ self.configure_color_management()
2009
+ self.configure_cycles()
2010
+
2011
+ if frame_indices is None:
2012
+ frames = list(enumerate(waypoints))[:: max(1, frame_stride)]
2013
+ else:
2014
+ invalid_indices = sorted(
2015
+ {
2016
+ index
2017
+ for index in frame_indices
2018
+ if index < 0 or index >= len(waypoints)
2019
+ }
2020
+ )
2021
+ if invalid_indices:
2022
+ raise ValueError(
2023
+ "Trajectory frame indices out of range "
2024
+ f"[0, {len(waypoints) - 1}]: {invalid_indices}"
2025
+ )
2026
+ frames = [
2027
+ (index, waypoints[index])
2028
+ for index in dict.fromkeys(frame_indices)
2029
+ ]
2030
+ self.frame_layout = True
2031
+ self.frame_base_dir = base_output_dir
2032
+
2033
+ # The highest already-rendered frame is the one most likely left
2034
+ # half-written by an interrupted run, so re-render it even on resume.
2035
+ last_existing = -1
2036
+ if not overwrite:
2037
+ for frame_idx, _ in frames:
2038
+ self.frame_index = frame_idx
2039
+ if self.frame_outputs_exist():
2040
+ last_existing = frame_idx
2041
+
2042
+ with tempfile.TemporaryDirectory(
2043
+ prefix="render_usd_traj_", dir=None
2044
+ ) as temp_dir:
2045
+ self.temp_dir = Path(temp_dir)
2046
+ for frame_idx, (x, y, rot_deg) in frames:
2047
+ self.frame_index = frame_idx
2048
+ # Per-frame resume: skip complete frames, but always redo the
2049
+ # last existing one (possibly a truncated/corrupt write).
2050
+ if (
2051
+ not overwrite
2052
+ and frame_idx != last_existing
2053
+ and self.frame_outputs_exist()
2054
+ ):
2055
+ logger.info("Skip existing frame %04d", frame_idx)
2056
+ continue
2057
+
2058
+ cam_loc, cam_euler, glb_transform = (
2059
+ self.compute_trajectory_frame_transforms(
2060
+ x,
2061
+ y,
2062
+ rot_deg,
2063
+ camera_z=camera_z,
2064
+ camera_pitch=camera_pitch,
2065
+ camera_roll=camera_roll,
2066
+ glb_offset_xyz=glb_offset_xyz,
2067
+ glb_base_rotation_deg=glb_base_rotation_deg,
2068
+ )
2069
+ )
2070
+ camera.location = cam_loc
2071
+ camera.rotation_euler = cam_euler
2072
+ for obj in root_objects:
2073
+ obj.matrix_world = (
2074
+ glb_transform @ original_matrices[obj.as_pointer()]
2075
+ )
2076
+ bpy.context.view_layer.update()
2077
+
2078
+ self.render(self.get_rgb_output_path())
2079
+ logger.info(
2080
+ "Rendered frame %04d at (%.3f, %.3f, rot=%.1f)",
2081
+ frame_idx,
2082
+ x,
2083
+ y,
2084
+ rot_deg,
2085
+ )
2086
+ self.temp_dir = None
2087
+
2088
+ self.frame_layout = False
2089
+ self.frame_base_dir = None
2090
+ self.output_dir = base_output_dir
2091
+ logger.info(
2092
+ "Rendered %d trajectory frames to %s",
2093
+ len(frames),
2094
+ base_output_dir,
2095
+ )
2096
+
2097
+ if make_video:
2098
+ self.stitch_pass_videos(base_output_dir, fps=video_fps)
2099
+
2100
+ def run_camera_trajectory(
2101
+ self,
2102
+ *,
2103
+ frames: list[CameraTrajectoryFrame],
2104
+ glb_offset_xyz: tuple[float, float, float],
2105
+ glb_base_rotation_deg: tuple[float, float, float],
2106
+ frame_stride: int,
2107
+ frame_indices: list[int] | None = None,
2108
+ make_video: bool = False,
2109
+ video_fps: float = 10.0,
2110
+ overwrite: bool = False,
2111
+ ) -> None:
2112
+ """Render fully specified camera poses loaded from a trajectory CSV.
2113
+
2114
+ Args:
2115
+ frames: Camera frames with output index, timestamp, and 6DoF pose.
2116
+ glb_offset_xyz: Local XYZ offset applied to GLB assets.
2117
+ glb_base_rotation_deg: Base XYZ Euler rotation applied to GLB assets.
2118
+ frame_stride: Render every Nth CSV row.
2119
+ frame_indices: Optional CSV ``frame`` values to render.
2120
+ make_video: Stitch rendered image passes into mp4 files.
2121
+ video_fps: Frame rate of the stitched videos.
2122
+ overwrite: Re-render complete frames instead of resuming.
2123
+ """
2124
+ if not frames:
2125
+ raise ValueError("Camera trajectory contains no frames.")
2126
+
2127
+ base_output_dir = self.output_dir
2128
+ base_output_dir.mkdir(parents=True, exist_ok=True)
2129
+
2130
+ self.clear_scene()
2131
+ self.import_usd()
2132
+ if self.remove_doors:
2133
+ self.remove_door_objects()
2134
+ self.validate_glb_args(allow_path_only=True)
2135
+ imported_glb_objects = self.import_glb_asset()
2136
+ glb_follows_camera = (
2137
+ self.glb_path is not None
2138
+ and self.glb_xyz is None
2139
+ and self.glb_rotation_deg is None
2140
+ )
2141
+ root_objects: list[bpy.types.Object] = []
2142
+ original_matrices: dict[int, Matrix] = {}
2143
+ if glb_follows_camera:
2144
+ root_objects = self.get_imported_root_objects(imported_glb_objects)
2145
+ original_matrices = {
2146
+ obj.as_pointer(): obj.matrix_world.copy()
2147
+ for obj in root_objects
2148
+ }
2149
+ else:
2150
+ self.place_glb_asset(imported_glb_objects)
2151
+
2152
+ min_corner, max_corner = self.get_scene_bbox()
2153
+ center = (min_corner + max_corner) * 0.5
2154
+ diagonal = (max_corner - min_corner).length
2155
+ camera = self.create_camera()
2156
+ self.ensure_lighting(diagonal, center, max_corner.z)
2157
+ world_created = self.ensure_world()
2158
+ self.add_fill_light(
2159
+ diagonal, center, max_corner.z, energy=self.fill_light_energy
2160
+ )
2161
+ if world_created:
2162
+ self.add_light_rig(
2163
+ diagonal,
2164
+ center,
2165
+ max_corner.z,
2166
+ area_energy=1500.0,
2167
+ sun_energy=0.35,
2168
+ prefix="Fill",
2169
+ )
2170
+ self.configure_color_management()
2171
+ self.configure_cycles()
2172
+
2173
+ if frame_indices is None:
2174
+ selected_frames = frames[:: max(1, frame_stride)]
2175
+ else:
2176
+ frames_by_index = {frame.frame_index: frame for frame in frames}
2177
+ invalid_indices = sorted(
2178
+ {
2179
+ index
2180
+ for index in frame_indices
2181
+ if index not in frames_by_index
2182
+ }
2183
+ )
2184
+ if invalid_indices:
2185
+ raise ValueError(
2186
+ "Camera trajectory frame indices not found: "
2187
+ f"{invalid_indices}"
2188
+ )
2189
+ selected_frames = [
2190
+ frames_by_index[index]
2191
+ for index in dict.fromkeys(frame_indices)
2192
+ ]
2193
+
2194
+ self.frame_layout = True
2195
+ self.frame_base_dir = base_output_dir
2196
+
2197
+ last_existing = -1
2198
+ if not overwrite:
2199
+ for frame in selected_frames:
2200
+ self.frame_index = frame.frame_index
2201
+ if self.frame_outputs_exist():
2202
+ last_existing = frame.frame_index
2203
+
2204
+ with tempfile.TemporaryDirectory(
2205
+ prefix="render_usd_camera_traj_", dir=None
2206
+ ) as temp_dir:
2207
+ self.temp_dir = Path(temp_dir)
2208
+ for frame in selected_frames:
2209
+ self.frame_index = frame.frame_index
2210
+ if (
2211
+ not overwrite
2212
+ and frame.frame_index != last_existing
2213
+ and self.frame_outputs_exist()
2214
+ ):
2215
+ logger.info("Skip existing frame %04d", frame.frame_index)
2216
+ continue
2217
+
2218
+ camera.location = Vector(frame.xyz)
2219
+ camera.rotation_euler = Euler(
2220
+ self.get_rotation_radians(frame.rotation_deg),
2221
+ "XYZ",
2222
+ )
2223
+ if glb_follows_camera:
2224
+ glb_transform = self.compute_camera_follow_glb_transform(
2225
+ frame.xyz,
2226
+ frame.rotation_deg,
2227
+ glb_offset_xyz=glb_offset_xyz,
2228
+ glb_base_rotation_deg=glb_base_rotation_deg,
2229
+ )
2230
+ for obj in root_objects:
2231
+ obj.matrix_world = (
2232
+ glb_transform @ original_matrices[obj.as_pointer()]
2233
+ )
2234
+ bpy.context.view_layer.update()
2235
+
2236
+ self.render(self.get_rgb_output_path())
2237
+ logger.info(
2238
+ "Rendered frame %04d at "
2239
+ "(%.3f, %.3f, %.3f), rotation=(%.2f, %.2f, %.2f)",
2240
+ frame.frame_index,
2241
+ *frame.xyz,
2242
+ *frame.rotation_deg,
2243
+ )
2244
+ self.temp_dir = None
2245
+
2246
+ self.frame_layout = False
2247
+ self.frame_base_dir = None
2248
+ self.output_dir = base_output_dir
2249
+ logger.info(
2250
+ "Rendered %d camera trajectory frames to %s",
2251
+ len(selected_frames),
2252
+ base_output_dir,
2253
+ )
2254
+
2255
+ if make_video:
2256
+ self.stitch_pass_videos(base_output_dir, fps=video_fps)
2257
+
2258
+ def stitch_pass_videos(self, base_dir: Path, *, fps: float) -> None:
2259
+ """Stitch each ``frames_<pass>/`` image sequence into ``<pass>.mp4``.
2260
+
2261
+ Scans ``base_dir`` for ``frames_<pass>`` directories, orders the
2262
+ ``frame_*.png`` images, and writes one video per pass next to them
2263
+ (e.g. ``frames_render_rgb/`` -> ``render_rgb.mp4``) using ffmpeg.
2264
+
2265
+ Args:
2266
+ base_dir: Trajectory output directory containing the frame dirs.
2267
+ fps: Output frame rate (clamped to a small positive minimum).
2268
+ """
2269
+ fps = max(fps, 1.0)
2270
+ for frames_subdir in sorted(base_dir.glob("frames_*")):
2271
+ if not frames_subdir.is_dir():
2272
+ continue
2273
+ frame_paths = sorted(frames_subdir.glob("frame_*.png"))
2274
+ if not frame_paths:
2275
+ continue
2276
+
2277
+ pass_name = frames_subdir.name[len("frames_") :]
2278
+ output_path = base_dir / f"{pass_name}.mp4"
2279
+ self.encode_frames_to_video(frames_subdir, output_path, fps)
2280
+ logger.info(
2281
+ "Stitched %d frames into %s (%.2f fps).",
2282
+ len(frame_paths),
2283
+ output_path,
2284
+ fps,
2285
+ )
2286
+
2287
+ @staticmethod
2288
+ def encode_frames_to_video(
2289
+ frames_dir: Path, output_path: Path, fps: float
2290
+ ) -> None:
2291
+ """Encode a ``frame_*.png`` sequence into an mp4 via ffmpeg.
2292
+
2293
+ Uses the system ffmpeg (h264/yuv420p) and a glob input pattern so
2294
+ non-contiguous frame indices (e.g. with --frame_stride) work. Even
2295
+ dimensions are enforced for yuv420p compatibility.
2296
+
2297
+ Args:
2298
+ frames_dir: Directory holding the ``frame_*.png`` images.
2299
+ output_path: Destination mp4 path.
2300
+ fps: Output frame rate.
2301
+ """
2302
+ cmd = [
2303
+ "ffmpeg",
2304
+ "-y",
2305
+ "-framerate",
2306
+ f"{fps:g}",
2307
+ "-pattern_type",
2308
+ "glob",
2309
+ "-i",
2310
+ str(frames_dir / "frame_*.png"),
2311
+ "-vf",
2312
+ "pad=ceil(iw/2)*2:ceil(ih/2)*2",
2313
+ "-c:v",
2314
+ "libx264",
2315
+ "-pix_fmt",
2316
+ "yuv420p",
2317
+ str(output_path),
2318
+ ]
2319
+ result = subprocess.run(cmd, capture_output=True, text=True)
2320
+ if result.returncode != 0:
2321
+ raise RuntimeError(
2322
+ f"ffmpeg failed for {output_path}:\n{result.stderr}"
2323
+ )
2324
+
2325
+
2326
+ def load_trajectory_waypoints(
2327
+ trajectory_json: Path,
2328
+ ) -> list[tuple[float, float, float]]:
2329
+ """Load (x, y, rot) waypoints from a trajectory JSON file.
2330
+
2331
+ Accepts either a dict with a ``waypoints`` key or a bare list of records.
2332
+
2333
+ Args:
2334
+ trajectory_json: Path to the trajectory JSON.
2335
+
2336
+ Returns:
2337
+ List of (x, y, rot_deg) tuples.
2338
+ """
2339
+ data = json.loads(Path(trajectory_json).read_text())
2340
+ records = data["waypoints"] if isinstance(data, dict) else data
2341
+ return [
2342
+ (float(rec["x"]), float(rec["y"]), float(rec["rot"]))
2343
+ for rec in records
2344
+ ]
2345
+
2346
+
2347
+ def load_camera_trajectory_csv(
2348
+ trajectory_csv: Path,
2349
+ ) -> list[CameraTrajectoryFrame]:
2350
+ """Load full camera poses from a trajectory CSV file.
2351
+
2352
+ Args:
2353
+ trajectory_csv: CSV containing the required camera trajectory fields.
2354
+
2355
+ Returns:
2356
+ Camera frames in CSV row order.
2357
+ """
2358
+ trajectory_csv = Path(trajectory_csv)
2359
+ if not trajectory_csv.exists():
2360
+ raise FileNotFoundError(
2361
+ f"Camera trajectory CSV not found: {trajectory_csv}"
2362
+ )
2363
+
2364
+ frames: list[CameraTrajectoryFrame] = []
2365
+ seen_indices: set[int] = set()
2366
+ previous_frame_index: int | None = None
2367
+ previous_time_sec: float | None = None
2368
+ with trajectory_csv.open(newline="", encoding="utf-8-sig") as csv_file:
2369
+ reader = csv.DictReader(csv_file)
2370
+ fieldnames = set(reader.fieldnames or ())
2371
+ missing_fields = [
2372
+ field
2373
+ for field in CAMERA_TRAJECTORY_CSV_FIELDS
2374
+ if field not in fieldnames
2375
+ ]
2376
+ if missing_fields:
2377
+ raise ValueError(
2378
+ f"Camera trajectory CSV is missing fields: {missing_fields}"
2379
+ )
2380
+
2381
+ for row_number, row in enumerate(reader, start=2):
2382
+ try:
2383
+ frame_index = int(row["frame"])
2384
+ values = {
2385
+ field: float(row[field])
2386
+ for field in CAMERA_TRAJECTORY_CSV_FIELDS[1:]
2387
+ }
2388
+ except (TypeError, ValueError) as exc:
2389
+ raise ValueError(
2390
+ "Invalid camera trajectory value at CSV row "
2391
+ f"{row_number}: {exc}"
2392
+ ) from exc
2393
+
2394
+ if frame_index < 0:
2395
+ raise ValueError(
2396
+ f"Camera frame must be non-negative at CSV row "
2397
+ f"{row_number}: {frame_index}"
2398
+ )
2399
+ if frame_index in seen_indices:
2400
+ raise ValueError(
2401
+ f"Duplicate camera frame at CSV row {row_number}: "
2402
+ f"{frame_index}"
2403
+ )
2404
+ non_finite_fields = [
2405
+ field
2406
+ for field, value in values.items()
2407
+ if not math.isfinite(value)
2408
+ ]
2409
+ if non_finite_fields:
2410
+ raise ValueError(
2411
+ f"Non-finite values at CSV row {row_number}: "
2412
+ f"{non_finite_fields}"
2413
+ )
2414
+ if (
2415
+ previous_frame_index is not None
2416
+ and frame_index <= previous_frame_index
2417
+ ):
2418
+ raise ValueError(
2419
+ "Camera frames must be strictly increasing at CSV row "
2420
+ f"{row_number}: {frame_index} <= {previous_frame_index}"
2421
+ )
2422
+ if (
2423
+ previous_time_sec is not None
2424
+ and values["time_sec"] <= previous_time_sec
2425
+ ):
2426
+ raise ValueError(
2427
+ "Camera timestamps must be strictly increasing at CSV "
2428
+ f"row {row_number}: {values['time_sec']} <= "
2429
+ f"{previous_time_sec}"
2430
+ )
2431
+
2432
+ seen_indices.add(frame_index)
2433
+ previous_frame_index = frame_index
2434
+ previous_time_sec = values["time_sec"]
2435
+ frames.append(
2436
+ CameraTrajectoryFrame(
2437
+ frame_index=frame_index,
2438
+ time_sec=values["time_sec"],
2439
+ xyz=(values["x"], values["y"], values["z"]),
2440
+ rotation_deg=(
2441
+ values["roll_x_deg"],
2442
+ values["pitch_y_deg"],
2443
+ values["yaw_z_deg"],
2444
+ ),
2445
+ )
2446
+ )
2447
+
2448
+ return frames
2449
+
2450
+
2451
+ def resolve_video_fps(
2452
+ video_fps: float, trajectory_json: Path, frame_stride: int
2453
+ ) -> float:
2454
+ """Resolve the video frame rate, auto-deriving real-time fps if 0.
2455
+
2456
+ Args:
2457
+ video_fps: Requested fps; 0 means auto.
2458
+ trajectory_json: Trajectory JSON (read for ``time_step``).
2459
+ frame_stride: Waypoint stride used during rendering.
2460
+
2461
+ Returns:
2462
+ A positive frame rate.
2463
+ """
2464
+ if video_fps > 0.0:
2465
+ return video_fps
2466
+
2467
+ data = json.loads(Path(trajectory_json).read_text())
2468
+ time_step = data.get("time_step") if isinstance(data, dict) else None
2469
+ if not time_step or time_step <= 0.0:
2470
+ return 10.0
2471
+ return (1.0 / time_step) / max(1, frame_stride)
2472
+
2473
+
2474
+ def resolve_csv_video_fps(
2475
+ video_fps: float,
2476
+ frames: list[CameraTrajectoryFrame],
2477
+ frame_stride: int,
2478
+ ) -> float:
2479
+ """Resolve video fps from CSV timestamps when no fps is specified."""
2480
+ if video_fps > 0.0:
2481
+ return video_fps
2482
+
2483
+ time_deltas = [
2484
+ current.time_sec - previous.time_sec
2485
+ for previous, current in zip(frames, frames[1:])
2486
+ if current.time_sec > previous.time_sec
2487
+ ]
2488
+ if not time_deltas:
2489
+ return 10.0
2490
+
2491
+ time_step = float(np.median(time_deltas))
2492
+ return (1.0 / time_step) / max(1, frame_stride)
2493
+
2494
+
2495
+ def run_trajectory_render(args: argparse.Namespace) -> None:
2496
+ """Render a trajectory from parsed CLI arguments."""
2497
+ waypoints = load_trajectory_waypoints(args.trajectory_json)
2498
+ if not waypoints:
2499
+ raise ValueError(f"No waypoints found in {args.trajectory_json}.")
2500
+
2501
+ first_x, first_y, first_rot = waypoints[0]
2502
+ renderer = RenderUsd(
2503
+ usd_path=args.usd_path,
2504
+ glb_path=args.glb_path,
2505
+ glb_xyz=None,
2506
+ glb_rotation_deg=None,
2507
+ output_dir=args.output_dir,
2508
+ render_passes=args.render_passes,
2509
+ depth_mode=args.depth_mode,
2510
+ depth_max=args.depth_max,
2511
+ resolution=args.resolution,
2512
+ samples=args.samples,
2513
+ camera_xyz=(first_x, first_y, args.camera_z),
2514
+ camera_rotation_deg=(args.camera_pitch, args.camera_roll, first_rot),
2515
+ flow_camera_xyz=args.flow_camera_xyz,
2516
+ flow_camera_rotation_deg=args.flow_camera_rotation_deg,
2517
+ focal_length_mm=args.focal_length_mm,
2518
+ exposure=args.exposure,
2519
+ world_strength=args.world_strength,
2520
+ fill_light_energy=args.fill_light_energy,
2521
+ remove_doors=args.remove_doors,
2522
+ )
2523
+ renderer.run_trajectory(
2524
+ waypoints=waypoints,
2525
+ camera_z=args.camera_z,
2526
+ camera_pitch=args.camera_pitch,
2527
+ camera_roll=args.camera_roll,
2528
+ glb_offset_xyz=tuple(args.glb_offset_xyz),
2529
+ glb_base_rotation_deg=tuple(args.glb_base_rotation_deg),
2530
+ frame_stride=args.frame_stride,
2531
+ frame_indices=args.frame_indices,
2532
+ make_video=args.make_video,
2533
+ video_fps=resolve_video_fps(
2534
+ args.video_fps, args.trajectory_json, args.frame_stride
2535
+ ),
2536
+ overwrite=args.overwrite,
2537
+ )
2538
+
2539
+
2540
+ def run_camera_trajectory_render(args: argparse.Namespace) -> None:
2541
+ """Render a full-pose camera trajectory from parsed CLI arguments."""
2542
+ frames = load_camera_trajectory_csv(args.trajectory_csv)
2543
+ if not frames:
2544
+ raise ValueError(f"No frames found in {args.trajectory_csv}.")
2545
+
2546
+ first_frame = frames[0]
2547
+ renderer = RenderUsd(
2548
+ usd_path=args.usd_path,
2549
+ glb_path=args.glb_path,
2550
+ glb_xyz=args.glb_xyz,
2551
+ glb_rotation_deg=args.glb_rotation_deg,
2552
+ output_dir=args.output_dir,
2553
+ render_passes=args.render_passes,
2554
+ depth_mode=args.depth_mode,
2555
+ depth_max=args.depth_max,
2556
+ resolution=args.resolution,
2557
+ samples=args.samples,
2558
+ camera_xyz=first_frame.xyz,
2559
+ camera_rotation_deg=first_frame.rotation_deg,
2560
+ flow_camera_xyz=args.flow_camera_xyz,
2561
+ flow_camera_rotation_deg=args.flow_camera_rotation_deg,
2562
+ focal_length_mm=args.focal_length_mm,
2563
+ exposure=args.exposure,
2564
+ world_strength=args.world_strength,
2565
+ fill_light_energy=args.fill_light_energy,
2566
+ remove_doors=args.remove_doors,
2567
+ )
2568
+ renderer.run_camera_trajectory(
2569
+ frames=frames,
2570
+ glb_offset_xyz=tuple(args.glb_offset_xyz),
2571
+ glb_base_rotation_deg=tuple(args.glb_base_rotation_deg),
2572
+ frame_stride=args.frame_stride,
2573
+ frame_indices=args.frame_indices,
2574
+ make_video=args.make_video,
2575
+ video_fps=resolve_csv_video_fps(
2576
+ args.video_fps, frames, args.frame_stride
2577
+ ),
2578
+ overwrite=args.overwrite,
2579
+ )
2580
+
2581
 
2582
  def main() -> None:
2583
  logging.basicConfig(level=logging.INFO)
2584
  args = _parse_args()
2585
+ if args.trajectory_json is not None:
2586
+ run_trajectory_render(args)
2587
+ return
2588
+ if args.trajectory_csv is not None:
2589
+ run_camera_trajectory_render(args)
2590
+ return
2591
+
2592
+ if args.camera_xyz is None or args.camera_rotation_deg is None:
2593
+ build_arg_parser().error(
2594
+ "--camera_xyz and --camera_rotation_deg are required unless "
2595
+ "--trajectory_json or --trajectory_csv is given."
2596
+ )
2597
  RenderUsd.from_args(args).run()
2598
 
2599
 
embodied_gen/scripts/room_gen/route_room.py CHANGED
@@ -113,6 +113,11 @@ class InfinigenGenRouter:
113
  """
114
  filled_prompt = self.prompt.format(prompt=task_description)
115
  response_text = self.gpt_client.query(text_prompt=filled_prompt)
 
 
 
 
 
116
  parsed = json_repair.loads(response_text)
117
 
118
  room_list = parsed[0]
 
113
  """
114
  filled_prompt = self.prompt.format(prompt=task_description)
115
  response_text = self.gpt_client.query(text_prompt=filled_prompt)
116
+ if response_text is None:
117
+ raise RuntimeError(
118
+ "GPT router got no response. Check the GPT agent setup in "
119
+ "embodied_gen/utils/gpt_config.yaml and network access."
120
+ )
121
  parsed = json_repair.loads(response_text)
122
 
123
  room_list = parsed[0]
embodied_gen/scripts/textto3d.py CHANGED
@@ -17,6 +17,7 @@
17
  import argparse
18
  import os
19
  import random
 
20
  from collections import defaultdict
21
 
22
  import numpy as np
@@ -25,12 +26,17 @@ from PIL import Image
25
  from embodied_gen.models.image_comm_model import build_hf_image_pipeline
26
  from embodied_gen.models.segment_model import RembgRemover
27
  from embodied_gen.models.text_model import PROMPT_APPEND
 
 
 
 
28
  from embodied_gen.scripts.imageto3d import entrypoint as imageto3d_api
29
  from embodied_gen.utils.gpt_clients import GPT_CLIENT
30
  from embodied_gen.utils.log import logger
31
  from embodied_gen.utils.process_media import (
32
  check_object_edge_truncated,
33
  combine_images_to_grid,
 
34
  render_asset3d,
35
  )
36
  from embodied_gen.validators.quality_checkers import (
@@ -43,12 +49,32 @@ from embodied_gen.validators.quality_checkers import (
43
  os.environ["TOKENIZERS_PARALLELISM"] = "false"
44
  random.seed(0)
45
 
46
- logger.info("Loading TEXT2IMG_MODEL...")
47
- SEMANTIC_CHECKER = SemanticConsistChecker(GPT_CLIENT)
48
- SEG_CHECKER = ImageSegChecker(GPT_CLIENT)
49
  TXTGEN_CHECKER = TextGenAlignChecker(GPT_CLIENT)
50
- PIPE_IMG = build_hf_image_pipeline(os.environ.get("TEXT_MODEL", "sd35"))
51
- BG_REMOVER = RembgRemover()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
 
53
 
54
  __all__ = [
@@ -66,6 +92,7 @@ def text_to_image(
66
  image_hw: tuple[int, int] = (1024, 1024),
67
  seed: int = None,
68
  ) -> bool:
 
69
  select_image = None
70
  success_flag = False
71
  assert save_path.endswith(".png"), "Image save path must end with `.png`."
@@ -130,48 +157,115 @@ def text_to_3d(**kwargs) -> dict:
130
  if hasattr(args, k) and v is not None:
131
  setattr(args, k, v)
132
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
133
  if args.asset_names is None or len(args.asset_names) == 0:
134
  args.asset_names = [f"sample3d_{i}" for i in range(len(args.prompts))]
135
- img_save_dir = os.path.join(args.output_root, "images")
136
  asset_save_dir = os.path.join(args.output_root, "asset3d")
137
- os.makedirs(img_save_dir, exist_ok=True)
138
  os.makedirs(asset_save_dir, exist_ok=True)
 
 
 
 
 
 
139
  results = defaultdict(dict)
140
- for prompt, node in zip(args.prompts, args.asset_names):
141
  success_flag = False
142
  n_pipe_retry = args.n_pipe_retry
143
  seed_img = args.seed_img
144
  seed_3d = args.seed_3d
 
 
 
 
 
 
 
 
 
145
  while success_flag is False and n_pipe_retry > 0:
146
  logger.info(
147
  f"GEN pipeline for node {node}\n"
148
- f"Try round: {args.n_pipe_retry-n_pipe_retry+1}/{args.n_pipe_retry}, Prompt: {prompt}"
149
  )
150
- # Text-to-image GEN
151
  save_node = node.replace(" ", "_")
152
- gen_image_path = f"{img_save_dir}/{save_node}.png"
153
- textgen_flag = text_to_image(
154
- prompt,
155
- gen_image_path,
156
- args.n_image_retry,
157
- args.img_denoise_step,
158
- args.text_guidance_scale,
159
- args.n_img_sample,
160
- seed=seed_img,
161
- )
162
-
163
- # Asset 3D GEN
164
  node_save_dir = f"{asset_save_dir}/{save_node}"
165
  asset_type = node if "sample3d_" not in node else None
166
- imageto3d_api(
167
- image_path=[gen_image_path],
168
- output_root=node_save_dir,
169
- asset_type=[asset_type],
170
- seed=random.randint(0, 100000) if seed_3d is None else seed_3d,
171
- n_retry=args.n_asset_retry,
172
- keep_intermediate=args.keep_intermediate,
173
- disable_decompose_convex=args.disable_decompose_convex,
174
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
175
  mesh_path = f"{node_save_dir}/result/mesh/{save_node}.obj"
176
  image_path = render_asset3d(
177
  mesh_path,
@@ -182,6 +276,21 @@ def text_to_3d(**kwargs) -> dict:
182
  no_index_file=True,
183
  )
184
  image_path = combine_images_to_grid(image_path)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
185
  check_text = asset_type if asset_type is not None else prompt
186
  qa_flag, qa_result = TXTGEN_CHECKER(check_text, image_path)
187
  logger.warning(
@@ -272,6 +381,18 @@ def parse_args():
272
  )
273
  parser.add_argument("--keep_intermediate", action="store_true")
274
  parser.add_argument("--disable_decompose_convex", action="store_true")
 
 
 
 
 
 
 
 
 
 
 
 
275
 
276
  args, unknown = parser.parse_known_args()
277
 
 
17
  import argparse
18
  import os
19
  import random
20
+ import types
21
  from collections import defaultdict
22
 
23
  import numpy as np
 
26
  from embodied_gen.models.image_comm_model import build_hf_image_pipeline
27
  from embodied_gen.models.segment_model import RembgRemover
28
  from embodied_gen.models.text_model import PROMPT_APPEND
29
+ from embodied_gen.scripts.imageto3d import (
30
+ IMAGE3D_MODEL,
31
+ SUPPORTED_IMAGE3D_MODELS,
32
+ )
33
  from embodied_gen.scripts.imageto3d import entrypoint as imageto3d_api
34
  from embodied_gen.utils.gpt_clients import GPT_CLIENT
35
  from embodied_gen.utils.log import logger
36
  from embodied_gen.utils.process_media import (
37
  check_object_edge_truncated,
38
  combine_images_to_grid,
39
+ parse_text_prompts,
40
  render_asset3d,
41
  )
42
  from embodied_gen.validators.quality_checkers import (
 
49
  os.environ["TOKENIZERS_PARALLELISM"] = "false"
50
  random.seed(0)
51
 
52
+ # TXTGEN_CHECKER drives the final text↔3D quality gate for every backend
53
+ # (SAM3D / TRELLIS / HUNYUAN3D), so it stays eager.
 
54
  TXTGEN_CHECKER = TextGenAlignChecker(GPT_CLIENT)
55
+
56
+ # The text-to-image stack (PIPE_IMG, BG_REMOVER, SEMANTIC_CHECKER, SEG_CHECKER)
57
+ # is only used on the SAM3D / TRELLIS path. HUNYUAN3D goes directly from prompt to 3D
58
+ SEMANTIC_CHECKER = None
59
+ SEG_CHECKER = None
60
+ PIPE_IMG = None
61
+ BG_REMOVER = None
62
+
63
+
64
+ def _ensure_text2img_stack() -> None:
65
+ """Construct the text-to-image pipeline + image-stage checkers once.
66
+
67
+ Called from the SAM3D / TRELLIS path before any ``text_to_image`` run.
68
+ Idempotent: subsequent calls return immediately.
69
+ """
70
+ global SEMANTIC_CHECKER, SEG_CHECKER, PIPE_IMG, BG_REMOVER
71
+ if PIPE_IMG is not None:
72
+ return
73
+ logger.info("Loading TEXT2IMG_MODEL...")
74
+ SEMANTIC_CHECKER = SemanticConsistChecker(GPT_CLIENT)
75
+ SEG_CHECKER = ImageSegChecker(GPT_CLIENT)
76
+ PIPE_IMG = build_hf_image_pipeline(os.environ.get("TEXT_MODEL", "sd35"))
77
+ BG_REMOVER = RembgRemover()
78
 
79
 
80
  __all__ = [
 
92
  image_hw: tuple[int, int] = (1024, 1024),
93
  seed: int = None,
94
  ) -> bool:
95
+ _ensure_text2img_stack()
96
  select_image = None
97
  success_flag = False
98
  assert save_path.endswith(".png"), "Image save path must end with `.png`."
 
157
  if hasattr(args, k) and v is not None:
158
  setattr(args, k, v)
159
 
160
+ args.image3d_model = str(args.image3d_model).strip().upper()
161
+ if args.image3d_model not in SUPPORTED_IMAGE3D_MODELS:
162
+ raise ValueError(
163
+ f"Unsupported --image3d_model {args.image3d_model!r}; "
164
+ f"expected one of {SUPPORTED_IMAGE3D_MODELS}."
165
+ )
166
+
167
+ hunyuan_cfg = None
168
+ hunyuan_creds = None
169
+ process_prompt = None
170
+ if args.image3d_model == "HUNYUAN3D":
171
+ from embodied_gen.models.hunyuan3d import (
172
+ HunyuanConfig,
173
+ load_credentials,
174
+ process_prompt,
175
+ )
176
+
177
+ # Fail fast on missing creds before any network I/O.
178
+ hunyuan_creds = load_credentials()
179
+ hunyuan_cfg = HunyuanConfig()
180
+ logger.info(
181
+ "HUNYUAN3D text-to-3D backend: action=%s host=%s result_format=%s",
182
+ hunyuan_cfg.image_action,
183
+ hunyuan_cfg.host,
184
+ hunyuan_cfg.result_format,
185
+ )
186
+
187
+ args.prompts = parse_text_prompts(args.prompts)
188
  if args.asset_names is None or len(args.asset_names) == 0:
189
  args.asset_names = [f"sample3d_{i}" for i in range(len(args.prompts))]
 
190
  asset_save_dir = os.path.join(args.output_root, "asset3d")
 
191
  os.makedirs(asset_save_dir, exist_ok=True)
192
+ # HUNYUAN3D path skips text-to-image entirely; the images/ dir only
193
+ # exists when the local SAM3D / TRELLIS pipeline produces conditioning
194
+ # images.
195
+ img_save_dir = os.path.join(args.output_root, "images")
196
+ if args.image3d_model != "HUNYUAN3D":
197
+ os.makedirs(img_save_dir, exist_ok=True)
198
  results = defaultdict(dict)
199
+ for idx, (prompt, node) in enumerate(zip(args.prompts, args.asset_names)):
200
  success_flag = False
201
  n_pipe_retry = args.n_pipe_retry
202
  seed_img = args.seed_img
203
  seed_3d = args.seed_3d
204
+ # Tencent Pro API is charged per submit; force a single attempt to
205
+ # avoid silently multiplying cost when --n_pipe_retry > 1.
206
+ if args.image3d_model == "HUNYUAN3D" and n_pipe_retry > 1:
207
+ logger.warning(
208
+ "HUNYUAN3D mode: --n_pipe_retry forced to 1 (Tencent API "
209
+ "is charged per submit); user passed %d.",
210
+ n_pipe_retry,
211
+ )
212
+ n_pipe_retry = 1
213
  while success_flag is False and n_pipe_retry > 0:
214
  logger.info(
215
  f"GEN pipeline for node {node}\n"
216
+ f"Try round: {args.n_pipe_retry - n_pipe_retry + 1}/{args.n_pipe_retry}, Prompt: {prompt}"
217
  )
 
218
  save_node = node.replace(" ", "_")
 
 
 
 
 
 
 
 
 
 
 
 
219
  node_save_dir = f"{asset_save_dir}/{save_node}"
220
  asset_type = node if "sample3d_" not in node else None
221
+
222
+ if args.image3d_model == "HUNYUAN3D":
223
+ hunyuan_args = types.SimpleNamespace(
224
+ asset_type=[asset_type],
225
+ version=None,
226
+ height_range=None,
227
+ mass_range=None,
228
+ disable_decompose_convex=args.disable_decompose_convex,
229
+ keep_intermediate=args.keep_intermediate,
230
+ )
231
+ process_prompt(
232
+ args=hunyuan_args,
233
+ idx=0,
234
+ prompt=prompt,
235
+ output_root=node_save_dir,
236
+ filename=save_node,
237
+ hunyuan_config=hunyuan_cfg,
238
+ hunyuan_credentials=hunyuan_creds,
239
+ checkers=[],
240
+ )
241
+ else:
242
+ # Text-to-image GEN (SAM3D / TRELLIS path).
243
+ gen_image_path = f"{img_save_dir}/{save_node}.png"
244
+ text_to_image(
245
+ prompt,
246
+ gen_image_path,
247
+ args.n_image_retry,
248
+ args.img_denoise_step,
249
+ args.text_guidance_scale,
250
+ args.n_img_sample,
251
+ seed=seed_img,
252
+ )
253
+
254
+ # Asset 3D GEN
255
+ imageto3d_api(
256
+ image_path=[gen_image_path],
257
+ output_root=node_save_dir,
258
+ asset_type=[asset_type],
259
+ seed=(
260
+ random.randint(0, 100000)
261
+ if seed_3d is None
262
+ else seed_3d
263
+ ),
264
+ n_retry=args.n_asset_retry,
265
+ keep_intermediate=args.keep_intermediate,
266
+ disable_decompose_convex=args.disable_decompose_convex,
267
+ image3d_model=args.image3d_model,
268
+ )
269
  mesh_path = f"{node_save_dir}/result/mesh/{save_node}.obj"
270
  image_path = render_asset3d(
271
  mesh_path,
 
276
  no_index_file=True,
277
  )
278
  image_path = combine_images_to_grid(image_path)
279
+ if not image_path:
280
+ logger.error(
281
+ f"Node {node}: 3D asset generation produced no mesh, "
282
+ f"skip QA and retry round."
283
+ )
284
+ results["assets"][node] = f"asset3d/{save_node}/result"
285
+ results["quality"][node] = "NO: mesh generation failed."
286
+ n_pipe_retry -= 1
287
+ seed_img = (
288
+ random.randint(0, 100000) if seed_img is not None else None
289
+ )
290
+ seed_3d = (
291
+ random.randint(0, 100000) if seed_3d is not None else None
292
+ )
293
+ continue
294
  check_text = asset_type if asset_type is not None else prompt
295
  qa_flag, qa_result = TXTGEN_CHECKER(check_text, image_path)
296
  logger.warning(
 
381
  )
382
  parser.add_argument("--keep_intermediate", action="store_true")
383
  parser.add_argument("--disable_decompose_convex", action="store_true")
384
+ parser.add_argument(
385
+ "--image3d_model",
386
+ type=str,
387
+ default=IMAGE3D_MODEL,
388
+ help=(
389
+ "Image-to-3D backend selector forwarded to imageto3d. One of "
390
+ f"{', '.join(SUPPORTED_IMAGE3D_MODELS)} (case-insensitive). "
391
+ "HUNYUAN3D skips the text-to-image stage entirely and calls "
392
+ "Tencent Hunyuan3D Pro text-to-3D directly; it requires "
393
+ "TENCENT_SECRET_ID/TENCENT_SECRET_KEY in the environment."
394
+ ),
395
+ )
396
 
397
  args, unknown = parser.parse_known_args()
398
 
embodied_gen/skills/README.md CHANGED
@@ -4,7 +4,7 @@ This directory is the canonical home for EmbodiedGen reusable skills.
4
 
5
  The root of `embodied_gen/skills` only contains generic skill source.
6
  Runtime-specific packaging should live in adapter subdirectories such as
7
- `embodied_gen/skills/claude_adapter/`.
8
 
9
  ## Included generic skills
10
 
@@ -14,7 +14,7 @@ Runtime-specific packaging should live in adapter subdirectories such as
14
  - `layout-creator`
15
  - `sim-runner`
16
  - `asset-converter`
17
- - `asset-scale`
18
  - `room-creator`
19
  - `spatial-computing`
20
 
@@ -23,31 +23,31 @@ Runtime-specific packaging should live in adapter subdirectories such as
23
  Claude-compatible slash commands and plugin manifest are under:
24
 
25
  ```text
26
- embodied_gen/skills/claude_adapter/
27
  ```
28
 
29
  The local marketplace manifest is:
30
 
31
  ```text
32
- embodied_gen/skills/claude_adapter/.claude-plugin/marketplace.json
33
  ```
34
 
35
  The actual Claude plugin package is:
36
 
37
  ```text
38
- embodied_gen/skills/claude_adapter/plugins/embodiedgen-skills/
39
  ```
40
 
41
  Current commands include:
42
 
43
- - `/embodiedgen-skills:assets`
44
- - `/embodiedgen-skills:background`
45
- - `/embodiedgen-skills:layout`
46
- - `/embodiedgen-skills:sim`
47
- - `/embodiedgen-skills:convert`
48
- - `/embodiedgen-skills:process`
49
- - `/embodiedgen-skills:room`
50
- - `/embodiedgen-skills:spatial`
51
 
52
  ## Local install for Claude
53
 
@@ -58,5 +58,5 @@ bash install/install_agent_plugin.sh
58
  ## Notes
59
 
60
  - Generic skills stay in their original directories under `embodied_gen/skills/`.
61
- - Claude-specific files live only under `embodied_gen/skills/claude_adapter/`.
62
  - This keeps the skill source portable for Codex, Copilot, and other runtimes.
 
4
 
5
  The root of `embodied_gen/skills` only contains generic skill source.
6
  Runtime-specific packaging should live in adapter subdirectories such as
7
+ `embodied_gen/skills/claude_plugin/`.
8
 
9
  ## Included generic skills
10
 
 
14
  - `layout-creator`
15
  - `sim-runner`
16
  - `asset-converter`
17
+ - `asset-process`
18
  - `room-creator`
19
  - `spatial-computing`
20
 
 
23
  Claude-compatible slash commands and plugin manifest are under:
24
 
25
  ```text
26
+ embodied_gen/skills/claude_plugin/
27
  ```
28
 
29
  The local marketplace manifest is:
30
 
31
  ```text
32
+ embodied_gen/skills/claude_plugin/.claude-plugin/marketplace.json
33
  ```
34
 
35
  The actual Claude plugin package is:
36
 
37
  ```text
38
+ embodied_gen/skills/claude_plugin/plugins/embodiedgen/
39
  ```
40
 
41
  Current commands include:
42
 
43
+ - `/embodiedgen:gen_assets`
44
+ - `/embodiedgen:gen_bg`
45
+ - `/embodiedgen:gen_layout`
46
+ - `/embodiedgen:sim`
47
+ - `/embodiedgen:convert`
48
+ - `/embodiedgen:process`
49
+ - `/embodiedgen:gen_indoor`
50
+ - `/embodiedgen:vibe3d`
51
 
52
  ## Local install for Claude
53
 
 
58
  ## Notes
59
 
60
  - Generic skills stay in their original directories under `embodied_gen/skills/`.
61
+ - Claude-specific files live only under `embodied_gen/skills/claude_plugin/`.
62
  - This keeps the skill source portable for Codex, Copilot, and other runtimes.
embodied_gen/skills/asset-process/SKILL.md ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: asset-process
3
+ description: Scale and rotate complete URDF-based 3D assets, including OBJ, GLB, collision meshes, Gaussian splats, and height metadata. Use this skill whenever a user asks to resize, rotate, reorient, or otherwise transform an EmbodiedGen asset while keeping its related files synchronized.
4
+ ---
5
+
6
+ # Asset Processing
7
+
8
+ ## Overview
9
+
10
+ Use this skill to scale or rotate an asset and all of its supported geometry
11
+ files consistently. Rotation is provided as XYZ Euler angles in degrees and is
12
+ applied around the asset's local origin.
13
+
14
+ The input URDF may use either supported asset layout:
15
+
16
+ ```text
17
+ <asset_dir>/result/<asset_name>.urdf
18
+ <asset_dir>/<asset_name>.urdf
19
+ ```
20
+
21
+ The mesh directory must be beside the URDF as `mesh/`.
22
+
23
+ Normal mode copies the complete asset directly to `<output_dir>`. Pass the
24
+ full target asset directory, including the desired asset directory name.
25
+ Inplace mode modifies the source asset.
26
+
27
+ After processing, the tool calculates the transformed mesh's Y-axis extent and
28
+ writes that value to `min_height`, `max_height`, and `real_height` in the URDF.
29
+ It uses the main OBJ first, then the GLB, then the collision OBJ.
30
+
31
+ ## CLI Examples
32
+
33
+ ### Scale Only
34
+
35
+ ```bash
36
+ python -m embodied_gen.skills.asset-process.asset_process \
37
+ --urdf-path outputs/assets/red_box/result/red_box.urdf \
38
+ --scale-factor 0.8 \
39
+ --output-dir outputs/processed
40
+ ```
41
+
42
+ ### Rotate Only
43
+
44
+ ```bash
45
+ python -m embodied_gen.skills.asset-process.asset_process \
46
+ --urdf-path outputs/assets/red_box/result/red_box.urdf \
47
+ --rot-xyz 90 0 45 \
48
+ --output-dir outputs/processed
49
+ ```
50
+
51
+ ### Scale and Rotate
52
+
53
+ ```bash
54
+ python -m embodied_gen.skills.asset-process.asset_process \
55
+ --urdf-path outputs/test_wuwen/facial_cleanser_001/facial_cleanser_001.urdf \
56
+ --scale-factor 1.0 \
57
+ --rot-xyz 0 90 0 \
58
+ --keep-urdf-raw-rot \
59
+ --output-dir outputs/test_wuwen/facial_cleanser_001_processed
60
+ ```
61
+
62
+ `--keep-urdf-raw-rot` is optional. When enabled, the inverse baked rotation is
63
+ composed into visual and collision `origin rpy` values so their effective
64
+ rotation remains unchanged. It is disabled by default.
65
+
66
+ ### Process Inplace
67
+
68
+ ```bash
69
+ python -m embodied_gen.skills.asset-process.asset_process \
70
+ --urdf-path outputs/assets/red_box/result/red_box.urdf \
71
+ --scale-factor 0.8 \
72
+ --rot-xyz 0 0 90 \
73
+ --inplace
74
+ ```
75
+
76
+ Inplace mode modifies the original asset files.
77
+
78
+ ## Parameters
79
+
80
+ | Parameter | Default | Meaning |
81
+ |-----------|---------|---------|
82
+ | `urdf_path` | Required | Input URDF path |
83
+ | `scale_factor` | `1.0` | Positive uniform scaling factor |
84
+ | `rot_xyz` | `0 0 0` | XYZ roll, pitch, yaw in degrees |
85
+ | `keep_urdf_raw_rot` | `False` | Preserve the original effective URDF rotation |
86
+ | `inplace` | `False` | Modify source files directly |
87
+ | `output_dir` | `None` | Complete target asset directory; required outside inplace mode |
88
+
89
+ ## Processed Files
90
+
91
+ - `mesh/<name>.obj`
92
+ - `mesh/<name>.glb`
93
+ - `mesh/<name>_collision.obj`
94
+ - `mesh/<name>_gs.ply` (optional; skipped when absent)
95
+ - `<name>.urdf`
96
+
97
+ Rotation is always baked into the asset files. URDF visual and collision
98
+ `origin rpy` values are updated only when `keep_urdf_raw_rot` is enabled.
99
+
100
+ For Python API details, read
101
+ `embodied_gen/skills/asset-process/asset_process.py`.
embodied_gen/skills/asset-process/__init__.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Project EmbodiedGen
2
+ #
3
+ # Copyright (c) 2025 Horizon Robotics. All Rights Reserved.
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
14
+ # implied. See the License for the specific language governing
15
+ # permissions and limitations under the License.
16
+
17
+ """Asset processing skills for EmbodiedGen."""
18
+
19
+ from importlib import import_module
20
+
21
+ __all__ = [
22
+ "AssetProcessConfig",
23
+ "AssetProcessor",
24
+ "entrypoint",
25
+ "process_asset",
26
+ ]
27
+
28
+
29
+ def __getattr__(name: str) -> object:
30
+ """Lazily expose asset processing APIs without import side effects."""
31
+ if name not in __all__:
32
+ msg = f"module {__name__!r} has no attribute {name!r}"
33
+ raise AttributeError(msg)
34
+
35
+ module = import_module(f"{__name__}.asset_process")
36
+ return getattr(module, name)
embodied_gen/skills/asset-process/asset_process.py ADDED
@@ -0,0 +1,443 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Project EmbodiedGen
2
+ #
3
+ # Copyright (c) 2025 Horizon Robotics. All Rights Reserved.
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
14
+ # implied. See the License for the specific language governing
15
+ # permissions and limitations under the License.
16
+
17
+ """Asset processing utilities for scaling and rotating 3D assets."""
18
+
19
+ import math
20
+ import shutil
21
+ import warnings
22
+ import xml.etree.ElementTree as ET
23
+ from concurrent.futures import ThreadPoolExecutor
24
+ from dataclasses import dataclass
25
+ from importlib import import_module
26
+ from pathlib import Path
27
+ from typing import Optional
28
+
29
+ import numpy as np
30
+ import trimesh
31
+ import tyro
32
+ from scipy.spatial.transform import Rotation
33
+ from embodied_gen.utils.log import logger
34
+
35
+ __all__ = [
36
+ "AssetProcessConfig",
37
+ "AssetProcessor",
38
+ "entrypoint",
39
+ "process_asset",
40
+ ]
41
+
42
+ URDF_HEIGHT_FIELDS = ("min_height", "max_height", "real_height")
43
+ URDF_RESULT_DIR = "result"
44
+ MESH_DIR = "mesh"
45
+ HEIGHT_AXIS = 1
46
+
47
+
48
+ @dataclass
49
+ class AssetProcessConfig:
50
+ """Configuration for asset scaling and rotation.
51
+
52
+ Args:
53
+ urdf_path: Path to the URDF file to process.
54
+ scale_factor: Positive uniform scaling factor.
55
+ rot_xyz: XYZ Euler rotation in degrees.
56
+ keep_urdf_raw_rot: Whether to preserve the original URDF rotation.
57
+ inplace: Whether to modify the source asset directly.
58
+ output_dir: Target asset directory used when ``inplace`` is false.
59
+ """
60
+
61
+ urdf_path: str
62
+ scale_factor: float = 1.0
63
+ rot_xyz: tuple[float, float, float] = (0.0, 0.0, 0.0)
64
+ keep_urdf_raw_rot: bool = False
65
+ inplace: bool = False
66
+ output_dir: Optional[str] = None
67
+
68
+
69
+ class AssetProcessor:
70
+ """Scale and rotate mesh, collision, and Gaussian-splat asset files."""
71
+
72
+ def __init__(
73
+ self,
74
+ urdf_path: str | Path,
75
+ scale_factor: float = 1.0,
76
+ rot_xyz: tuple[float, float, float] = (0.0, 0.0, 0.0),
77
+ keep_urdf_raw_rot: bool = False,
78
+ output_dir: Optional[str | Path] = None,
79
+ inplace: bool = False,
80
+ ) -> None:
81
+ """Initialize an asset processor.
82
+
83
+ Args:
84
+ urdf_path: Path to the URDF file to process.
85
+ scale_factor: Positive uniform scaling factor.
86
+ rot_xyz: XYZ Euler rotation in degrees.
87
+ keep_urdf_raw_rot: Whether to preserve the original visual and
88
+ collision rotation after baking the asset rotation.
89
+ output_dir: Target asset directory used when ``inplace`` is false.
90
+ inplace: Whether to modify the source asset directly.
91
+
92
+ Raises:
93
+ FileNotFoundError: If the URDF file does not exist.
94
+ ValueError: If the transform or output configuration is invalid.
95
+ """
96
+ self.urdf_path = Path(urdf_path)
97
+ self.scale_factor = float(scale_factor)
98
+ self.rot_xyz = tuple(float(value) for value in rot_xyz)
99
+ self.keep_urdf_raw_rot = keep_urdf_raw_rot
100
+ self.inplace = inplace
101
+
102
+ if not self.urdf_path.is_file():
103
+ raise FileNotFoundError(f"URDF file not found: {self.urdf_path}")
104
+ if not math.isfinite(self.scale_factor) or self.scale_factor <= 0:
105
+ raise ValueError(
106
+ f"Scale factor must be positive and finite, got: "
107
+ f"{self.scale_factor}"
108
+ )
109
+ if len(self.rot_xyz) != 3 or not all(
110
+ math.isfinite(value) for value in self.rot_xyz
111
+ ):
112
+ raise ValueError(
113
+ "rot_xyz must contain three finite degree values, got: "
114
+ f"{self.rot_xyz}"
115
+ )
116
+
117
+ if self.urdf_path.parent.name == URDF_RESULT_DIR:
118
+ self.asset_dir = self.urdf_path.parent.parent
119
+ self.result_dir = Path(URDF_RESULT_DIR)
120
+ else:
121
+ self.asset_dir = self.urdf_path.parent
122
+ self.result_dir = Path()
123
+ self.node_name = self.urdf_path.stem
124
+ self.rotation = Rotation.from_euler("xyz", self.rot_xyz, degrees=True)
125
+ self.rotation_transform = np.eye(4)
126
+ self.rotation_transform[:3, :3] = self.rotation.as_matrix()
127
+
128
+ if self.inplace:
129
+ self.output_dir = self.asset_dir.parent
130
+ else:
131
+ if output_dir is None:
132
+ raise ValueError("output_dir is required when inplace=False")
133
+ self.output_dir = Path(output_dir)
134
+ if self.output_dir.resolve() == self.asset_dir.resolve():
135
+ raise ValueError(
136
+ "output_dir must differ from the source asset directory; "
137
+ "use inplace=True to modify the source asset"
138
+ )
139
+
140
+ def process(self) -> Path:
141
+ """Run the complete asset processing workflow.
142
+
143
+ Returns:
144
+ Path to the processed URDF file.
145
+ """
146
+ if self.inplace:
147
+ output_asset_dir = self.asset_dir
148
+ output_urdf_path = self.urdf_path
149
+ else:
150
+ output_asset_dir = self.output_dir
151
+ output_urdf_path = self._copy_asset_structure(output_asset_dir)
152
+
153
+ self._process_asset_files_parallel(output_asset_dir)
154
+ actual_height = self._calculate_actual_height(output_asset_dir)
155
+ self._write_urdf_height(output_urdf_path, actual_height)
156
+ if self.keep_urdf_raw_rot:
157
+ self._update_urdf_origins(output_urdf_path)
158
+
159
+ logger.info(
160
+ "Processed %s with scale=%s, rot_xyz=%s -> %s",
161
+ self.asset_dir,
162
+ self.scale_factor,
163
+ self.rot_xyz,
164
+ output_asset_dir,
165
+ )
166
+ return output_urdf_path
167
+
168
+ def _copy_asset_structure(self, output_asset_dir: Path) -> Path:
169
+ """Copy the complete source asset to the output directory."""
170
+ shutil.rmtree(output_asset_dir, ignore_errors=True)
171
+ shutil.copytree(self.asset_dir, output_asset_dir)
172
+ return output_asset_dir / self.result_dir / f"{self.node_name}.urdf"
173
+
174
+ def _process_asset_files_parallel(self, output_asset_dir: Path) -> None:
175
+ """Apply the configured transform to every supported asset format."""
176
+ mesh_dir = output_asset_dir / self.result_dir / MESH_DIR
177
+
178
+ # Keep the legacy asset-scale processing paths separate because each
179
+ # format has different loading and export behavior.
180
+ tasks = [
181
+ (mesh_dir / f"{self.node_name}.obj", self._scale_obj_mesh),
182
+ (mesh_dir / f"{self.node_name}.glb", self._scale_glb_mesh),
183
+ (
184
+ mesh_dir / f"{self.node_name}_collision.obj",
185
+ self._scale_collision_mesh,
186
+ ),
187
+ (
188
+ mesh_dir / f"{self.node_name}_gs.ply",
189
+ self._scale_gaussian_splat,
190
+ ),
191
+ ]
192
+
193
+ with ThreadPoolExecutor(max_workers=4) as executor:
194
+ futures = [executor.submit(task, path) for path, task in tasks]
195
+ for future in futures:
196
+ future.result() # Propagate any exceptions
197
+
198
+ def _scale_obj_mesh(self, mesh_path: Path) -> None:
199
+ """Scale and rotate an OBJ mesh."""
200
+ if not mesh_path.exists():
201
+ return
202
+
203
+ mesh = trimesh.load(str(mesh_path))
204
+ mesh.apply_scale(self.scale_factor)
205
+ self._apply_mesh_rotation(mesh)
206
+ mesh.export(str(mesh_path))
207
+
208
+ def _scale_glb_mesh(self, mesh_path: Path) -> None:
209
+ """Scale and rotate every geometry in a GLB scene."""
210
+ if not mesh_path.exists():
211
+ return
212
+
213
+ mesh = trimesh.load(str(mesh_path))
214
+ for mesh_part in mesh.geometry.values():
215
+ mesh_part.apply_scale(self.scale_factor)
216
+ self._apply_mesh_rotation(mesh_part)
217
+ mesh.export(str(mesh_path))
218
+
219
+ def _scale_collision_mesh(self, mesh_path: Path) -> None:
220
+ """Scale and rotate a potentially multi-object collision OBJ."""
221
+ if not mesh_path.exists():
222
+ return
223
+
224
+ meshes = self._load_collision_obj(str(mesh_path))
225
+ scene = trimesh.Scene()
226
+ for mesh_part in meshes:
227
+ mesh_part.apply_scale(self.scale_factor)
228
+ self._apply_mesh_rotation(mesh_part)
229
+ scene.add_geometry(mesh_part)
230
+ scene.export(str(mesh_path))
231
+
232
+ def _scale_gaussian_splat(self, mesh_path: Path) -> None:
233
+ """Scale and rotate a Gaussian splatting model."""
234
+ if not mesh_path.exists():
235
+ return
236
+
237
+ gaussian_operator = import_module(
238
+ "embodied_gen.models.gs_model"
239
+ ).GaussianOperator
240
+ gs_model = gaussian_operator.load_from_ply(str(mesh_path))
241
+ gs_model.rescale(self.scale_factor)
242
+ if not self._is_identity_rotation:
243
+ instance_pose = gaussian_operator.trans_to_quatpose(
244
+ self.rotation.as_matrix()
245
+ )
246
+ gs_model = gs_model.get_gaussians(instance_pose=instance_pose)
247
+ gs_model.save_to_ply(str(mesh_path))
248
+
249
+ @property
250
+ def _is_identity_rotation(self) -> bool:
251
+ """Return whether the configured rotation is an identity operation."""
252
+ return np.allclose(self.rotation.as_matrix(), np.eye(3))
253
+
254
+ def _apply_mesh_rotation(self, mesh: trimesh.Trimesh) -> None:
255
+ """Apply the configured rotation after the legacy scale operation."""
256
+ if self._is_identity_rotation:
257
+ return
258
+ mesh.apply_transform(self.rotation_transform)
259
+
260
+ def _calculate_actual_height(self, output_asset_dir: Path) -> float:
261
+ """Calculate Y-axis height from the best available transformed mesh."""
262
+ mesh_dir = output_asset_dir / self.result_dir / MESH_DIR
263
+ candidates = (
264
+ mesh_dir / f"{self.node_name}.obj",
265
+ mesh_dir / f"{self.node_name}.glb",
266
+ mesh_dir / f"{self.node_name}_collision.obj",
267
+ )
268
+ for mesh_path in candidates:
269
+ if not mesh_path.exists():
270
+ continue
271
+ scene = trimesh.load(str(mesh_path), force="scene")
272
+ geometry = scene.to_geometry()
273
+ if len(geometry.vertices) == 0:
274
+ continue
275
+ height = float(np.ptp(geometry.vertices[:, HEIGHT_AXIS]))
276
+ if not math.isfinite(height):
277
+ raise ValueError(
278
+ f"Calculated non-finite height from mesh: {mesh_path}"
279
+ )
280
+ return height
281
+
282
+ candidate_text = ", ".join(str(path) for path in candidates)
283
+ raise FileNotFoundError(
284
+ "No mesh is available for height calculation. Expected one of: "
285
+ f"{candidate_text}"
286
+ )
287
+
288
+ @staticmethod
289
+ def _write_urdf_height(urdf_path: Path, height: float) -> None:
290
+ """Write actual mesh height to all URDF height metadata fields."""
291
+ tree = ET.parse(str(urdf_path))
292
+ root = tree.getroot()
293
+ link = root.find("link")
294
+ if link is None:
295
+ raise ValueError(f"No link element found in URDF: {urdf_path}")
296
+
297
+ extra_info = link.find("extra_info")
298
+ if extra_info is None:
299
+ extra_info = ET.SubElement(link, "extra_info")
300
+
301
+ value = f"{height:.4f}"
302
+ for field in URDF_HEIGHT_FIELDS:
303
+ element = extra_info.find(field)
304
+ if element is None:
305
+ element = ET.SubElement(extra_info, field)
306
+ element.text = value
307
+
308
+ tree.write(str(urdf_path), encoding="utf-8", xml_declaration=True)
309
+
310
+ def _update_urdf_origins(self, urdf_path: Path) -> None:
311
+ """Append the inverse baked rotation to visual/collision origins."""
312
+ if self._is_identity_rotation:
313
+ return
314
+
315
+ tree = ET.parse(str(urdf_path))
316
+ root = tree.getroot()
317
+ inverse_rotation = self.rotation.inv().as_matrix()
318
+
319
+ for link in root.findall(".//link"):
320
+ for geometry_tag in ("visual", "collision"):
321
+ for geometry in link.findall(geometry_tag):
322
+ origin = geometry.find("origin")
323
+ if origin is None:
324
+ origin = ET.SubElement(
325
+ geometry,
326
+ "origin",
327
+ attrib={"xyz": "0 0 0"},
328
+ )
329
+
330
+ current_rpy = np.fromstring(
331
+ origin.get("rpy", "0 0 0"),
332
+ sep=" ",
333
+ )
334
+ if current_rpy.size != 3:
335
+ raise ValueError(
336
+ f"Invalid rpy in URDF origin: {urdf_path}"
337
+ )
338
+
339
+ current_rotation = Rotation.from_euler(
340
+ "xyz",
341
+ current_rpy,
342
+ degrees=False,
343
+ ).as_matrix()
344
+ updated_rotation = current_rotation @ inverse_rotation
345
+ with warnings.catch_warnings():
346
+ warnings.simplefilter("ignore", UserWarning)
347
+ updated_rpy = Rotation.from_matrix(
348
+ updated_rotation
349
+ ).as_euler("xyz", degrees=False)
350
+ origin.set(
351
+ "rpy",
352
+ " ".join(f"{value:.4f}" for value in updated_rpy),
353
+ )
354
+
355
+ tree.write(str(urdf_path), encoding="utf-8", xml_declaration=True)
356
+
357
+ @staticmethod
358
+ def _load_collision_obj(filepath: str) -> list[trimesh.Trimesh]:
359
+ """Robustly load collision OBJ with multiple objects.
360
+
361
+ Handles OBJ files with multiple objects/groups by parsing manually
362
+ to avoid issues with trimesh's default loader.
363
+
364
+ Args:
365
+ filepath: Path to collision OBJ file.
366
+
367
+ Returns:
368
+ List of trimesh objects, one per object group in the file.
369
+ """
370
+ vertices = []
371
+ meshes = []
372
+ current_faces = []
373
+
374
+ # Use lazy iteration instead of readlines() for memory efficiency
375
+ with open(filepath, "r") as f:
376
+ for line in f:
377
+ if line.startswith("v "):
378
+ parts = line.split()
379
+ vertices.append(
380
+ [float(parts[1]), float(parts[2]), float(parts[3])]
381
+ )
382
+ elif line.startswith("f "):
383
+ parts = line.split()
384
+ face = [int(p.split("/")[0]) - 1 for p in parts[1:]]
385
+ current_faces.append(face)
386
+ elif line.startswith("o ") or line.startswith("g "):
387
+ if current_faces and vertices:
388
+ m = trimesh.Trimesh(
389
+ vertices=vertices,
390
+ faces=current_faces,
391
+ process=False,
392
+ )
393
+ m.remove_unreferenced_vertices()
394
+ meshes.append(m)
395
+ current_faces = []
396
+
397
+ # Flush final mesh
398
+ if current_faces and vertices:
399
+ m = trimesh.Trimesh(
400
+ vertices=vertices, faces=current_faces, process=False
401
+ )
402
+ m.remove_unreferenced_vertices()
403
+ meshes.append(m)
404
+
405
+ return meshes
406
+
407
+
408
+ def process_asset(
409
+ urdf_path: str | Path,
410
+ scale_factor: float = 1.0,
411
+ rot_xyz: tuple[float, float, float] = (0.0, 0.0, 0.0),
412
+ keep_urdf_raw_rot: bool = False,
413
+ output_dir: Optional[str | Path] = None,
414
+ inplace: bool = False,
415
+ ) -> Path:
416
+ """Scale and rotate a complete URDF-based asset."""
417
+ processor = AssetProcessor(
418
+ urdf_path=urdf_path,
419
+ scale_factor=scale_factor,
420
+ rot_xyz=rot_xyz,
421
+ keep_urdf_raw_rot=keep_urdf_raw_rot,
422
+ output_dir=output_dir,
423
+ inplace=inplace,
424
+ )
425
+ return processor.process()
426
+
427
+
428
+ def entrypoint() -> None:
429
+ """Run the asset processing CLI."""
430
+ config = tyro.cli(AssetProcessConfig)
431
+ output_urdf = process_asset(
432
+ urdf_path=config.urdf_path,
433
+ scale_factor=config.scale_factor,
434
+ rot_xyz=config.rot_xyz,
435
+ keep_urdf_raw_rot=config.keep_urdf_raw_rot,
436
+ output_dir=config.output_dir,
437
+ inplace=config.inplace,
438
+ )
439
+ logger.info(f"Processed asset successfully: {output_urdf}")
440
+
441
+
442
+ if __name__ == "__main__":
443
+ entrypoint()
embodied_gen/skills/asset-retrieval/scripts/retrieve_asset.py CHANGED
@@ -1,4 +1,19 @@
1
- #!/usr/bin/env python3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
  """Retrieve EmbodiedGen asset URDF paths from a CSV index."""
3
 
4
  from __future__ import annotations
 
1
+ # Project EmbodiedGen
2
+ #
3
+ # Copyright (c) 2025 Horizon Robotics. All Rights Reserved.
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
14
+ # implied. See the License for the specific language governing
15
+ # permissions and limitations under the License.
16
+
17
  """Retrieve EmbodiedGen asset URDF paths from a CSV index."""
18
 
19
  from __future__ import annotations