decision-racing / make_duck.py
lysandre's picture
lysandre HF Staff
Full project: llama.cpp backend, community buckets, README and AGENTS.md
98276bf verified
Raw History Blame Contribute Delete
6.73 kB
"""Build assets/microduck.glb from Pollen Robotics' Microduck MuJoCo model (Apache-2.0).
Assembles the visual meshes of github.com/pollen-robotics/microduck_rl in their default pose, skips hidden internals,
merges them by color (optionally simplified with --keep), and saves one y-up glTF facing +z with the feet on y=0. Each leg is
a separate node (`leg_left`, `leg_right`) pivoting at the hip, so the game can animate a walk.
pip install trimesh numpy fast-simplification && python make_duck.py
"""
import argparse
import io
import urllib.request
import xml.etree.ElementTree as ET
from collections import defaultdict
from pathlib import Path
import fast_simplification
import numpy as np
import trimesh
REPO = "https://raw.githubusercontent.com/pollen-robotics/microduck_rl/develop/src/mjlab_microduck/robot/microduck"
KEEP = 1.0 # default fraction of each part's triangles kept (--keep); simplifying tears the CAD meshes
# Internals hidden by the shells: bearings, boards, battery and supports.
HIDDEN = {"seeed_bearing__configuration__22x16x4", "seeed_bearing__configuration_default", "pcb__raspberry_pi_zero_2_w",
"elec_rpi_robot_hat_pcb", "np_f970", "power_support", "banana_pcb_locker", "speaker", "motor_support", "m12_lens_holder"}
def simplify(mesh, keep, passes):
"""Keep about `keep` of the triangles; one pass stops early to preserve shape, so `passes` repeats it."""
if keep >= 1:
return mesh
goal = max(150, int(len(mesh.faces) * keep))
for _ in range(passes):
if len(mesh.faces) <= goal:
break
vertices, faces = fast_simplification.simplify(mesh.vertices, mesh.faces, target_reduction=1 - goal / len(mesh.faces))
if len(faces) >= len(mesh.faces) * 0.95:
break
mesh = trimesh.Trimesh(vertices, faces)
return mesh
def fetch(path):
with urllib.request.urlopen(f"{REPO}/{path}", timeout=60) as response:
return response.read()
def transform(element):
"""4x4 matrix from an MJCF element's pos and quat (w x y z)."""
matrix = np.eye(4)
if "quat" in element.attrib:
w, x, y, z = map(float, element.get("quat").split())
matrix[:3, :3] = trimesh.transformations.quaternion_matrix([w, x, y, z])[:3, :3]
if "pos" in element.attrib:
matrix[:3, 3] = list(map(float, element.get("pos").split()))
return matrix
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--keep", type=float, default=KEEP, help="Fraction of triangles kept per part (1 = no simplification).")
parser.add_argument("--passes", type=int, default=1, help="Simplification passes; more get closer to --keep but add artifacts.")
parser.add_argument("--out", default=str(Path(__file__).parent / "assets" / "microduck.glb"))
args = parser.parse_args()
root = ET.fromstring(fetch("robot_walk.xml"))
colors = {m.get("name"): [float(v) for v in m.get("rgba").split()] for m in root.iter("material")}
# parts: group ("body", "leg_left", "leg_right") -> rgba -> [(mesh name, mesh in the robot's frame)]
meshes, parts, pivots = {}, defaultdict(lambda: defaultdict(list)), {}
def visit(body, parent, group):
world = parent @ transform(body)
for geom in body.findall("geom"):
name = geom.get("mesh")
if geom.get("class") != "visual" or name in HIDDEN:
continue
if name not in meshes:
meshes[name] = simplify(trimesh.load(io.BytesIO(fetch(f"assets/{name}.stl")), file_type="stl"), args.keep, args.passes)
mesh = meshes[name].copy()
mesh.apply_transform(world @ transform(geom))
parts[group][tuple(colors[geom.get("material")])].append((name, mesh))
for child in body.findall("body"):
child_group = group
if group == "body": # each leg is its own node, pivoting where it attaches to the trunk
below = {g.get("mesh") for g in child.iter("geom")}
side = "left" if "foot_left" in below else "right" if "foot_right" in below else None
if side:
child_group = f"leg_{side}"
pivots[child_group] = (world @ transform(child))[:3, 3]
visit(child, world, child_group)
for body in root.find("worldbody").findall("body"):
visit(body, np.eye(4), "body")
every = [mesh for colors_ in parts.values() for group in colors_.values() for _, mesh in group]
print(f"{len(meshes)} meshes, {len(every)} visible parts, {sum(len(m.faces) for m in every):,} triangles, groups {list(parts)}")
# MuJoCo is z-up; glTF is y-up. Then turn the duck so its face looks along +z, and stand it on y=0, centered.
z_up_to_y_up = trimesh.transformations.rotation_matrix(-np.pi / 2, [1, 0, 0])
everything = trimesh.util.concatenate(every)
face = trimesh.util.concatenate([m for colors_ in parts.values() for group in colors_.values() for n, m in group if n == "face_part"])
forward = (face.centroid - everything.centroid) @ z_up_to_y_up[:3, :3].T
frame = trimesh.transformations.rotation_matrix(-np.arctan2(forward[0], forward[2]), [0, 1, 0]) @ z_up_to_y_up
low, high = everything.copy().apply_transform(frame).bounds
frame = trimesh.transformations.translation_matrix([-(low[0] + high[0]) / 2, -low[1], -(low[2] + high[2]) / 2]) @ frame
scene = trimesh.Scene()
for group, by_color in parts.items():
pivot = (frame @ [*pivots[group], 1])[:3] if group in pivots else np.zeros(3)
scene.graph.update(frame_from=scene.graph.base_frame, frame_to=group, matrix=trimesh.transformations.translation_matrix(pivot))
for i, (rgba, group_parts) in enumerate(by_color.items()):
mesh = trimesh.util.concatenate([m for _, m in group_parts])
mesh.apply_transform(trimesh.transformations.translation_matrix(-pivot) @ frame) # relative to the group's pivot
mesh.visual = trimesh.visual.TextureVisuals(material=trimesh.visual.material.PBRMaterial(
baseColorFactor=[int(c * 255) for c in rgba], metallicFactor=0.1, roughnessFactor=0.6))
scene.add_geometry(mesh, node_name=f"{group}_{i}", parent_node_name=group)
out = Path(args.out)
out.parent.mkdir(exist_ok=True)
out.write_bytes(scene.export(file_type="glb"))
size = scene.bounds[1] - scene.bounds[0]
print(f"wrote {out} ({out.stat().st_size / 1e6:.1f} MB), size {size[0]:.3f} x {size[1]:.3f} x {size[2]:.3f} m, "
f"leg pivots {({g: np.round((frame @ [*p, 1])[:3], 3).tolist() for g, p in pivots.items()})}")
if __name__ == "__main__":
main()