Spaces:
Running
Running
Download make_duck.py from lysandre/decision-racing: direct link, hf CLI and curl.
- Browser
- Download file 6.73 kB
-
https://huggingface.co/spaces/lysandre/decision-racing/resolve/main/make_duck.py
- Command line
-
hf download hf://spaces/lysandre/decision-racing/make_duck.py
-
curl -L -o make_duck.py https://huggingface.co/spaces/lysandre/decision-racing/resolve/main/make_duck.py
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() | |