Files
wasm-bimanual-grasp/tools/export_manip_scene.py
T
liyang aed6e20a90 双臂操作沙盒(MuJoCo WASM 浏览器版)首次入库
从 ~/mujoco-web-spike 抽出 manip.html 的依赖闭包:
- manip.html + src/{manip,autograsp,scene-bridge,math}.js
- scenes/so101_pair(桌面并排两台 SO-101,默认)与 scenes/p7_bimanual(P7 双臂 + SO-101 夹爪)
  —— 自包含 MJCF,网格已内联
- serve.py(COOP/COEP + no-store 的静态服务器)、start.sh、README
- tools/export_manip_scene.py 场景生成脚本(仅供参考,跑页面不需要)

快照对应 2026-08-31 的最后一次改动(自动抓取规划 autograsp.js 落地版)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tgfYxR5mWNRKQ67PKga4T
2026-09-01 17:16:41 +08:00

671 lines
28 KiB
Python

"""把双臂工作站 + 操作台 + 测试物体导出成浏览器能直接跑的自包含 MJCF。
~/unitree_rl_mjlab/.venv/bin/python tools/export_manip_scene.py
产出 scenes/<name>/:
model.xml 自包含 MJCF(网格内联 + 视觉网格抽稀,不依赖任何外部文件)
meta.json 给前端用的索引:臂/手的执行器、工具 site、指尖 geom、
物体和桌面的几何参数、就绪位形
这里没有策略,是纯手动操作的沙盒 —— 前端用 IK 驱动腕部、用一个握力滑条驱动手指。
网格处理照搬 unitree_rl_mjlab/scripts/export_web_dog.py 里踩出来的三条:
· 写回顶点前要用 mesh_pos/mesh_quat 变换回原坐标系,否则重新编译会再对齐一次,
几何整体偏移;
· MJCF 的 <mesh normal> 是**每顶点**一条,不是每面;
· spec 里源文件的法线不受 usernormal 覆盖,只能删掉网格重建。
"""
import json
from dataclasses import dataclass
from pathlib import Path
import mujoco
import numpy as np
import tyro
HERE = Path(__file__).resolve().parent.parent
WS_ROOT = (Path.home() / "linker-sim/packages/linker-robot-assets/src"
/ "linker_robot_assets/assets/workstations")
@dataclass
class Cfg:
workstation: str = "p7_i1_l6_bimanual"
"""linker-robot-assets 里的工作站名。"""
name: str = "p7_bimanual"
"""导出目录名。"""
table_top: float = 0.75
"""操作台高度 [m]。"""
table_x: float = 0.42
"""操作台中心到机器人的距离 [m]。"""
decimate: int = 32
"""视觉网格的聚类抽稀格数;0 = 不抽稀。"""
gripper: str = "so101"
"""末端:l6=原装五指手;so101=换成 SO-101 二指夹爪(仅 rig=p7 时有效)。"""
rig: str = "so101_pair"
"""机器人:p7=linker 双臂工作站;so101_pair=桌面上并排两台 SO-101。"""
# ---------------------------------------------------------------- 网格处理
def decimate(v, f, cells=48):
"""顶点聚类抽稀:包围盒切 cells^3 的格子,每格顶点并成一个。"""
lo, hi = v.min(0), v.max(0)
size = float((hi - lo).max())
if size <= 0:
return v, f
key = np.floor((v - lo) / (size / cells)).astype(np.int64)
uniq, inv = np.unique(key, axis=0, return_inverse=True)
acc = np.zeros((len(uniq), 3))
cnt = np.zeros(len(uniq))
np.add.at(acc, inv, v)
np.add.at(cnt, inv, 1)
f2 = inv[f]
keep = (f2[:, 0] != f2[:, 1]) & (f2[:, 1] != f2[:, 2]) & (f2[:, 0] != f2[:, 2])
f2 = f2[keep]
if len(f2) < 4 or len(uniq) < 4:
return v, f
used, f3 = np.unique(f2, return_inverse=True)
v2, f3 = (acc / cnt[:, None])[used], f3.reshape(-1, 3)
# 抽稀把薄零件压成一张片时,MuJoCo 会拒收("mesh volume is too small")。
# 退化了就原样返回,少省几百 KB 而已。
ext = v2.max(0) - v2.min(0)
if len(v2) < 20 or float(ext.min()) < 1e-3:
return v, f
return v2, f3
def convex_hull(v):
"""取凸包 —— MuJoCo 做网格碰撞时用的本来就是凸包,所以这一步零精度损失,
但顶点数能从几万降到几百。"""
from scipy.spatial import ConvexHull
h = ConvexHull(v)
used, faces = np.unique(h.simplices, return_inverse=True)
return v[used], faces.reshape(-1, 3)
def inline_meshes(spec: mujoco.MjSpec, m: mujoco.MjModel, cells: int) -> None:
"""网格内联进 XML。
这套资产的视觉几何和碰撞几何**引用同一批网格**(41 个全是两用),
直接抽稀会改碰撞形状,不抽稀又有 49 万顶点 / 25MB。做法是拆成两份:
· 碰撞用凸包(MuJoCo 反正只用凸包,行为完全不变)
· 视觉用抽稀版 `<name>_vis`,再把纯视觉 geom 的 meshname 改过去
"""
by_name = {m.mesh(i).name: i for i in range(m.nmesh)}
collide, visual = set(), set()
for g in range(m.ngeom):
if m.geom_type[g] != mujoco.mjtGeom.mjGEOM_MESH:
continue
(collide if (m.geom_contype[g] or m.geom_conaffinity[g]) else visual).add(
int(m.geom_dataid[g]))
todo = []
for mesh in list(spec.meshes):
i = by_name.get(mesh.name)
if i is None:
continue
va, vn = m.mesh_vertadr[i], m.mesh_vertnum[i]
fa, fn = m.mesh_faceadr[i], m.mesh_facenum[i]
v = m.mesh_vert[va:va + vn].astype(np.float64)
rot = np.zeros(9)
mujoco.mju_quat2Mat(rot, m.mesh_quat[i])
v = v @ rot.reshape(3, 3).T + m.mesh_pos[i] # 变换回原坐标系
f = m.mesh_face[fa:fa + fn].astype(np.int64)
todo.append((mesh.name, i, v, f))
for nm, _, _, _ in todo:
spec.delete(spec.mesh(nm)) # 删掉重建,甩掉源文件的法线
def emit(name, v, f):
nu = spec.add_mesh()
nu.name = name
nu.uservert = np.round(v, 4).reshape(-1).tolist()
nu.userface = f.astype(np.int32).reshape(-1).tolist()
n_v0 = n_v1 = 0
for nm, i, v, f in todo:
n_v0 += len(v)
if i in collide:
try:
cv, cf = convex_hull(v)
except Exception: # noqa: BLE001
cv, cf = decimate(v, f, 64)
emit(nm, cv, cf)
n_v1 += len(cv)
else:
emit(nm, *((decimate(v, f, cells)) if cells else (v, f)))
n_v1 += len(spec.mesh(nm).uservert) // 3
if i in visual and i in collide:
vv, vf = decimate(v, f, cells) if cells else (v, f)
emit(f"{nm}_vis", vv, vf)
n_v1 += len(vv)
# 纯视觉的 geom 指向抽稀版
for b in spec.bodies:
for g in b.geoms:
if (g.contype == 0 and g.conaffinity == 0 and g.meshname
and by_name.get(g.meshname) in visual
and by_name.get(g.meshname) in collide):
g.meshname = f"{g.meshname}_vis"
print(f" 网格顶点 {n_v0}{n_v1}")
# ---------------------------------------------------------------- 场景搭建
# DexGraspBench example 数据集里的五个物体(灵巧手抓取那套方案用的就是这五个)。
# 目录名很长,这里给一组短名和中文标签。
DGB_ROOT = Path.home() / "DexGraspBench/assets/example_object"
OBJECTS = [
# 短名, 中文, 目录, 抓握截面 m, 质量 kg, rgba
# 抓握截面按手来定:L6 手张开 13.4cm、**合拢只剩 4cm**,截面超过 4~5cm 就夹不住
# (6.6cm 的罐子实测怎么放都会被挤出去)。这里统一缩到 4cm 上下。
("can", "易拉罐", "core_can_be67418a10003cc9eae3efbc9dbeea", 0.040, 0.15, (0.85, 0.30, 0.25, 1)),
("bottle", "瓶子", "core_bottle_523cddb320608c09a37f3fc191551700", 0.038, 0.12, (0.30, 0.65, 0.85, 1)),
("jar", "罐子", "core_jar_58c9d6575d62fbaf12bc68f36f3bdd45", 0.042, 0.16, (0.55, 0.75, 0.35, 1)),
("piano", "玩具琴", "sem_Piano_438f64dd9ff80bfddd5efa4c0e7932e", 0.045, 0.12, (0.85, 0.70, 0.25, 1)),
("toy", "玩偶", "sem_ToyFigure_a3087c9105fe0878d18a6b3b9816ca14", 0.040, 0.10, (0.70, 0.45, 0.80, 1)),
]
def load_obj(path):
"""极简 OBJ 读取:只要顶点和面(多边形按扇形三角化)。"""
vs, fs = [], []
for ln in Path(path).read_text().splitlines():
if ln.startswith("v "):
vs.append([float(x) for x in ln.split()[1:4]])
elif ln.startswith("f "):
idx = [int(t.split("/")[0]) - 1 for t in ln.split()[1:]]
for k in range(1, len(idx) - 1):
fs.append([idx[0], idx[k], idx[k + 1]])
return np.array(vs, float), np.array(fs, np.int64)
def add_objects(spec, wb, top_z, ox, ys):
"""把五个测试物体摆成一排。ox 是它们离机器人的距离,ys 是横向位置。"""
objs = []
for (nm, label, folder, width, mass, rgba), oy in zip(OBJECTS, ys):
root = DGB_ROOT / folder
vv, vf = load_obj(root / "mesh" / "simplified.obj")
ext = vv.max(0) - vv.min(0)
# 按"抓握截面"定缩放:取次长边缩到 width,长边就是高度
scale = width / float(sorted(ext)[1])
vv = (vv - (vv.max(0) + vv.min(0)) / 2) * scale # 顺便把几何中心挪到原点
# 立起来:原始网格的长轴是 y,转 90° 让它朝 z
rot = np.array([[1, 0, 0], [0, 0, -1], [0, 1, 0]], float)
vv = vv @ rot.T
half_h = float((vv[:, 2].max() - vv[:, 2].min()) / 2)
mv = spec.add_mesh()
mv.name = f"obj_{nm}_vis"
mv.uservert = np.round(vv, 5).reshape(-1).tolist()
mv.userface = vf.astype(np.int32).reshape(-1).tolist()
b = wb.add_body(name=f"obj_{nm}", pos=[ox, oy, top_z + half_h + 0.002])
b.add_freejoint()
# 视觉网格背质量(凸块按密度算出来的质量是凸分解伪影,重叠处被重复累加,
# 实测能虚高好几倍 —— 直接显式给质量)
b.add_geom(name=f"obj_{nm}", type=mujoco.mjtGeom.mjGEOM_MESH, meshname=mv.name,
rgba=list(rgba), mass=mass, contype=0, conaffinity=0, group=2)
pieces = sorted((root / "urdf" / "meshes").glob("convex_piece_*.obj"))
for k, pf in enumerate(pieces):
cv, cf = load_obj(pf)
cv = ((cv - (vv.max(0) + vv.min(0)) * 0) * scale) # 与视觉网格同一缩放
cv = cv - (load_obj(root / "mesh" / "simplified.obj")[0].max(0)
+ load_obj(root / "mesh" / "simplified.obj")[0].min(0)) / 2 * scale
cv = cv @ rot.T
mc = spec.add_mesh()
mc.name = f"obj_{nm}_col{k}"
mc.uservert = np.round(cv, 5).reshape(-1).tolist()
mc.userface = cf.astype(np.int32).reshape(-1).tolist()
b.add_geom(name=f"obj_{nm}_c{k}", type=mujoco.mjtGeom.mjGEOM_MESH,
meshname=mc.name, density=0, contype=1, conaffinity=1, condim=4,
friction=[1.2, 0.01, 0.0005], solref=[0.008, 1.0],
group=3, rgba=[1, 0, 0, 0.25])
objs.append({"name": nm, "label": label, "body": f"obj_{nm}",
"geom": f"obj_{nm}", "kind": "mesh", "mass": mass,
"half_h": half_h, "pieces": len(pieces),
"home": [ox, oy, top_z + half_h + 0.002]})
return objs
def add_scene(spec: mujoco.MjSpec, cfg: Cfg):
"""地面、灯光、操作台、三个测试物体。"""
wb = spec.worldbody
tex = spec.add_texture(
name="grid", type=mujoco.mjtTexture.mjTEXTURE_2D,
builtin=mujoco.mjtBuiltin.mjBUILTIN_CHECKER,
rgb1=[0.17, 0.20, 0.25], rgb2=[0.12, 0.15, 0.19], width=300, height=300)
del tex
mat = spec.add_material(name="grid", texrepeat=[8, 8], reflectance=0.05)
mat.textures[mujoco.mjtTextureRole.mjTEXROLE_RGB] = "grid"
wb.add_geom(name="floor", type=mujoco.mjtGeom.mjGEOM_PLANE, size=[0, 0, 0.05],
material="grid", contype=1, conaffinity=1, condim=3)
wb.add_light(pos=[0.4, 0, 3.0], dir=[0, 0, -1],
type=mujoco.mjtLightType.mjLIGHT_DIRECTIONAL)
# 操作台:台面 + 四条腿(纯装饰,只有台面参与碰撞)
half = (0.28, 0.42, 0.02)
top_z = cfg.table_top
t = wb.add_body(name="table", pos=[cfg.table_x, 0, 0])
t.add_geom(name="table_top", type=mujoco.mjtGeom.mjGEOM_BOX, size=list(half),
pos=[0, 0, top_z - half[2]], rgba=[0.55, 0.42, 0.30, 1],
contype=1, conaffinity=1, condim=3, friction=[1.0, 0.005, 0.0001])
# 台面还要一份纯视觉的副本:渲染器的"视觉"过滤会把有视觉兄弟的碰撞几何藏起来
# (桌腿是纯视觉的),不补这一份台面就画不出来,物体看着像飘在空中。
t.add_geom(name="table_top_vis", type=mujoco.mjtGeom.mjGEOM_BOX,
size=[half[0] * 1.001, half[1] * 1.001, half[2] * 0.999],
pos=[0, 0, top_z - half[2]], rgba=[0.55, 0.42, 0.30, 1],
contype=0, conaffinity=0, mass=0)
for sx in (-1, 1):
for sy in (-1, 1):
t.add_geom(name=f"table_leg_{sx}_{sy}", type=mujoco.mjtGeom.mjGEOM_BOX,
size=[0.02, 0.02, (top_z - 2 * half[2]) / 2],
pos=[sx * (half[0] - 0.03), sy * (half[1] - 0.03),
(top_z - 2 * half[2]) / 2],
rgba=[0.38, 0.29, 0.21, 1], contype=0, conaffinity=0)
objs = add_objects(spec, wb, top_z, cfg.table_x - 0.02, ys=[-0.26,-0.13,0.0,0.13,0.26])
return {"table": {"x": cfg.table_x, "top": top_z, "half": list(half)},
"objects": objs}
# ---------------------------------------------------------------- 换末端
SO101_XML = (Path.home() / "unitree_rl_mjlab/src/assets/robots/unitree_go2_so101"
/ "xmls/so101/so101.xml")
SO101_KP, SO101_KV, SO101_FRC = 998.22, 2.731, 2.94
def swap_gripper(spec: mujoco.MjSpec):
"""把 L6 五指手换成 SO-101 的二指夹爪。
五指手在这套场景里抓不住东西:张开 13.4cm、合拢只剩 4cm,四指从张开位置
扫向掌心的路径正好经过物体,合拢那一下必把物体挤飞(试过 18 组瞄准位置、
三种掌心朝向、拇指先合的分阶段闭合,都不行)。二指夹爪是已经在 Go2 上
验证过的方案,直接把它的 `gripper` 子树挂到腕部安装点。
"""
info = {}
for side in ("left", "right"):
# 每侧读一份:attach_body 会把源 spec 里的 body 取走,复用同一份第二次就是 None
grip_src = mujoco.MjSpec.from_file(str(SO101_XML))
for act in list(grip_src.actuators):
grip_src.delete(act) # 执行器这边自己重建
pre = "l" if side == "left" else "r"
hand = spec.body(f"hand_{side}_{pre}h_hand_base_link")
parent, pos, quat = hand.parent, list(hand.pos), list(hand.quat)
# 先删执行器再删 body,否则会留下指向已删关节的悬空执行器
for act in list(spec.actuators):
if act.name.startswith(f"hand_{side}_"):
spec.delete(act)
for eq in list(spec.equalities):
if (eq.name1 or "").startswith(f"hand_{side}_") or (eq.name2 or "").startswith(f"hand_{side}_"):
spec.delete(eq)
spec.delete(hand)
frame = parent.add_frame(pos=pos, quat=quat)
frame.attach_body(grip_src.body("gripper"), f"grip_{side}_", "")
a = spec.add_actuator()
a.name = f"grip_{side}_act"
a.target = f"grip_{side}_gripper"
a.trntype = mujoco.mjtTrn.mjTRN_JOINT
a.gaintype = mujoco.mjtGain.mjGAIN_FIXED
a.biastype = mujoco.mjtBias.mjBIAS_AFFINE
a.gainprm = [SO101_KP] + [0.0] * 9
a.biasprm = [0.0, -SO101_KP, -SO101_KV] + [0.0] * 7
a.forcerange = [-SO101_FRC, SO101_FRC]
a.forcelimited = mujoco.mjtLimited.mjLIMITED_TRUE
info[side] = {"prefix": f"grip_{side}_"}
spec.assets = {**(spec.assets or {}), **_so101_assets()}
return info
def _so101_assets():
d = SO101_XML.parent / "assets"
return {f.name: f.read_bytes() for f in d.glob("*")} if d.is_dir() else {}
# ---------------------------------------------------------------- 双 SO-101
SO101_JOINTS = ("shoulder_pan", "shoulder_lift", "elbow_flex",
"wrist_flex", "wrist_roll")
PAIR = { # 桌面式布局:两台并排装在台面上,都朝 +x
"table_top": 0.40, "table_x": 0.15, "table_half": (0.30, 0.40, 0.02),
"mount_y": 0.22, "obj_x": 0.27,
"obj_ys": (-0.20, -0.10, 0.0, 0.10, 0.20),
}
def build_so101_pair(cfg: Cfg):
"""从零搭一个场景:地面 + 操作台 + 台面上并排两台 SO-101。
选这个而不是给 P7 换爪:SO-101 只有 5 个自由度,任务约束正好是
位置 3 + 爪口朝向 2,IK 良定;而且这套夹爪 + 这个尺寸的物体在 Go2 抓放
demo 里已经端到端验过(随机位置 12 次成功 10 次),数值可以直接搬。
"""
spec = mujoco.MjSpec()
spec.option.timestep = 0.002
spec.option.integrator = mujoco.mjtIntegrator.mjINT_IMPLICITFAST
spec.option.iterations = 20
spec.option.ls_iterations = 20
wb = spec.worldbody
tex = spec.add_texture(
name="grid", type=mujoco.mjtTexture.mjTEXTURE_2D,
builtin=mujoco.mjtBuiltin.mjBUILTIN_CHECKER,
rgb1=[0.17, 0.20, 0.25], rgb2=[0.12, 0.15, 0.19], width=300, height=300)
del tex
mat = spec.add_material(name="grid", texrepeat=[8, 8], reflectance=0.05)
mat.textures[mujoco.mjtTextureRole.mjTEXROLE_RGB] = "grid"
wb.add_geom(name="floor", type=mujoco.mjtGeom.mjGEOM_PLANE, size=[0, 0, 0.05],
material="grid", contype=1, conaffinity=1, condim=3)
wb.add_light(pos=[0.3, 0, 2.5], dir=[0, 0, -1],
type=mujoco.mjtLightType.mjLIGHT_DIRECTIONAL)
P = PAIR
half, top = P["table_half"], P["table_top"]
t = wb.add_body(name="table", pos=[P["table_x"], 0, 0])
t.add_geom(name="table_top", type=mujoco.mjtGeom.mjGEOM_BOX, size=list(half),
pos=[0, 0, top - half[2]], rgba=[0.55, 0.42, 0.30, 1],
contype=1, conaffinity=1, condim=3, friction=[1.0, 0.005, 0.0001])
t.add_geom(name="table_top_vis", type=mujoco.mjtGeom.mjGEOM_BOX,
size=[half[0] * 1.001, half[1] * 1.001, half[2] * 0.999],
pos=[0, 0, top - half[2]], rgba=[0.55, 0.42, 0.30, 1],
contype=0, conaffinity=0, mass=0)
for sx in (-1, 1):
for sy in (-1, 1):
t.add_geom(name=f"table_leg_{sx}_{sy}", type=mujoco.mjtGeom.mjGEOM_BOX,
size=[0.02, 0.02, (top - 2 * half[2]) / 2],
pos=[sx * (half[0] - 0.03), sy * (half[1] - 0.03),
(top - 2 * half[2]) / 2],
rgba=[0.38, 0.29, 0.21, 1], contype=0, conaffinity=0)
for side, sy in (("left", 1), ("right", -1)):
arm = mujoco.MjSpec.from_file(str(SO101_XML))
frame = wb.add_frame(pos=[0.0, sy * P["mount_y"], top])
spec.attach(arm, prefix=f"arm_{side}_", frame=frame)
return spec, {"table": {"x": P["table_x"], "top": top, "half": list(half)}}
def ik_pos(m, d, qadr, rng, eepos, target, iters=400, axis_fn=None, axis_w=None):
"""位置(+可选末端朝向)IK,有限差分雅可比。用来算就绪位形。
就绪位形必须和前端默认的朝向约束**用同一套约束**解,否则页面一打开
就在跟自己较劲(实测残差 69mm,手臂一直在往回摆)。
"""
W = 0.25
q = np.zeros(len(qadr))
rows = 6 if axis_fn is not None else 3
def err(dd):
e = [target - eepos(dd)]
if axis_fn is not None:
e.append(W * (np.asarray(axis_w) - axis_fn(dd)))
return np.concatenate(e)
for _ in range(iters):
d.qpos[qadr] = np.clip(q, rng[:, 0], rng[:, 1])
mujoco.mj_kinematics(m, d)
e = err(d)
if np.linalg.norm(e[:3]) < 5e-4 and (rows == 3 or np.linalg.norm(e[3:]) / W < 0.05):
break
J = np.zeros((rows, len(qadr)))
for j in range(len(qadr)):
qh = q.copy()
qh[j] = np.clip(qh[j] + 1e-4, rng[j, 0], rng[j, 1])
dq = qh[j] - q[j]
if abs(dq) < 1e-12:
continue
d.qpos[qadr] = np.clip(qh, rng[:, 0], rng[:, 1])
mujoco.mj_kinematics(m, d)
J[:, j] = (err(d) - e) / -dq
q = np.clip(q + 0.5 * (J.T @ np.linalg.solve(J @ J.T + 1e-4 * np.eye(rows), e)),
rng[:, 0], rng[:, 1])
d.qpos[qadr] = np.clip(q, rng[:, 0], rng[:, 1])
mujoco.mj_kinematics(m, d)
return q, float(np.linalg.norm(target - eepos(d)))
# ---------------------------------------------------------------- 就绪位形
ARM_PREFIX = {"left": "L", "right": "R"} # 左臂关节叫 L1..L7,右臂叫 R1..R7
def arm_joints(side):
return [f"arm_{side}_{ARM_PREFIX[side]}{i}_Joint" for i in range(1, 8)]
def solve_ready(m, d, side, target):
"""把某一侧的腕部 site 解到 target,返回 7 个臂关节角。"""
jn = arm_joints(side)
qadr = np.array([m.jnt_qposadr[m.joint(n).id] for n in jn])
dadr = np.array([m.jnt_dofadr[m.joint(n).id] for n in jn])
rng = np.array([m.jnt_range[m.joint(n).id] for n in jn])
sid = m.site(f"arm_{side}_tool0").id
q = np.zeros(7)
jacp, jacr = np.zeros((3, m.nv)), np.zeros((3, m.nv))
for _ in range(300):
d.qpos[qadr] = q
mujoco.mj_kinematics(m, d)
mujoco.mj_comPos(m, d)
e = target - d.site_xpos[sid]
if np.linalg.norm(e) < 5e-4:
break
mujoco.mj_jacSite(m, d, jacp, jacr, sid)
J = jacp[:, dadr]
q = np.clip(q + 0.6 * (J.T @ np.linalg.solve(J @ J.T + 1e-4 * np.eye(3), e)),
rng[:, 0], rng[:, 1])
return q.tolist(), qadr.tolist(), float(np.linalg.norm(target - d.site_xpos[sid]))
def build_p7(cfg: Cfg):
"""linker 双臂工作站 + 操作台 + 物体。"""
src = WS_ROOT / cfg.workstation / "workstation.mjcf"
assert src.exists(), f"找不到工作站:{src}"
# MjSpec 不认 .mjcf 后缀,也不会跟着 ../../ 去找网格。
# 读成字符串 + 把引用到的网格按**原样的相对路径**塞进 assets 字典。
import re as _re
text = src.read_text()
assets = {}
for ref in sorted(set(_re.findall(r'file="([^"]+)"', text))):
f = (src.parent / ref).resolve()
if f.is_file():
assets[ref] = f.read_bytes()
else:
print(f" [警告] 网格找不到:{ref}")
print(f" 载入网格 {len(assets)} 个")
spec = mujoco.MjSpec.from_string(text, assets=assets)
if cfg.gripper == "so101":
swap_gripper(spec)
print(" 末端已换成 SO-101 二指夹爪")
info = add_scene(spec, cfg)
spec.option.timestep = 0.002
spec.option.integrator = mujoco.mjtIntegrator.mjINT_IMPLICITFAST
spec.option.iterations = 20
spec.option.ls_iterations = 20
return spec, info
def main() -> None:
cfg = tyro.cli(Cfg)
out = HERE / "scenes" / cfg.name
out.mkdir(parents=True, exist_ok=True)
if cfg.rig == "so101_pair":
spec, info = build_so101_pair(cfg)
info["objects"] = add_objects(spec, spec.worldbody, PAIR["table_top"],
PAIR["obj_x"], list(PAIR["obj_ys"]))
print(f" 双 SO-101:台面 z={PAIR['table_top']} 底座 y=±{PAIR['mount_y']}")
else:
spec, info = build_p7(cfg)
m = spec.compile()
d = mujoco.MjData(m)
# 就绪位形:末端停在台面上方
ready = {}
if cfg.rig == "so101_pair":
P = PAIR
for side, sy in (("left", 1), ("right", -1)):
jn = [f"arm_{side}_{j}" for j in SO101_JOINTS]
qadr = np.array([m.jnt_qposadr[m.joint(n).id] for n in jn])
rng = np.array([m.jnt_range[m.joint(n).id] for n in jn])
ga = [g for g in range(m.ngeom)
if (m.geom(g).name or "").startswith(f"arm_{side}_fixed_jaw_sph_tip")]
gb = [g for g in range(m.ngeom)
if (m.geom(g).name or "").startswith(f"arm_{side}_moving_jaw_sph_tip")]
def jaw_mid(dd, ga=ga, gb=gb):
return (dd.geom_xpos[ga].mean(0) + dd.geom_xpos[gb].mean(0)) / 2
sid = m.site(f"arm_{side}_gripperframe").id
def jaw_axis(dd, sid=sid):
return dd.site_xmat[sid].reshape(3, 3)[:, 0] # 爪口进近轴 = site 局部 +x
# 爪口朝下的可达高度有限:离台面 6cm 残差 0.3mm、10cm 9mm、14cm 就 38mm 了
tgt = np.array([0.22, sy * (P["mount_y"] - 0.05), P["table_top"] + 0.07])
q, err = ik_pos(m, d, qadr, rng, jaw_mid, tgt,
axis_fn=jaw_axis, axis_w=[0, 0, -1])
ready[side] = {"q": q.tolist(), "qadr": qadr.tolist(), "err": err}
print(f" 就绪位形 {side:5s} 残差 {err * 1000:.1f} mm")
else:
for side, ty in (("left", 0.18), ("right", -0.18)):
q, qadr, err = solve_ready(m, d, side,
np.array([cfg.table_x - 0.06, ty, cfg.table_top + 0.16]))
ready[side] = {"q": q, "qadr": qadr, "err": err}
print(f" 就绪位形 {side:5s} 残差 {err * 1000:.1f} mm")
qpos0 = np.array(m.qpos0, dtype=float).copy()
for side in ("left", "right"):
qpos0[ready[side]["qadr"]] = ready[side]["q"]
key = spec.add_key()
key.name = "ready"
key.qpos = qpos0.tolist()
key.ctrl = [0.0] * m.nu
inline_meshes(spec, m, cfg.decimate)
xml = spec.to_xml().replace(' class=""', "")
(out / "model.xml").write_text(xml)
mb = (out / "model.xml").stat().st_size / 1e6
# 重新编译一遍,确认自包含且几何没跑偏,同时取出前端要的索引
m2 = mujoco.MjModel.from_xml_path(str(out / "model.xml"))
d2 = mujoco.MjData(m2)
mujoco.mj_resetDataKeyframe(m2, d2, 0)
mujoco.mj_forward(m2, d2)
def act_idx(prefix):
return [i for i in range(m2.nu) if m2.actuator(i).name.startswith(prefix)]
arms, hands = {}, {}
gname = lambda g: m2.geom(g).name or "" # noqa: E731
for side in ("left", "right"):
if cfg.rig == "so101_pair":
pre = f"arm_{side}_"
a = [i for i in range(m2.nu)
if m2.actuator(i).name in [f"{pre}{j}" for j in SO101_JOINTS]]
arms[side] = {
"act": a,
"names": [m2.actuator(i).name for i in a],
"qadr": [int(m2.jnt_qposadr[int(m2.actuator_trnid[i][0])]) for i in a],
"range": [[float(x) for x in m2.actuator_ctrlrange[i]] for i in a],
"site": f"{pre}gripperframe",
"site_id": int(m2.site(f"{pre}gripperframe").id),
"ready": ready[side]["q"],
}
gi = [i for i in range(m2.nu) if m2.actuator(i).name == f"{pre}gripper"]
hands[side] = {
"kind": "so101",
"act": gi,
"names": [m2.actuator(i).name for i in gi],
"range": [[float(x) for x in m2.actuator_ctrlrange[i]] for i in gi],
"geoms": [g for g in range(m2.ngeom)
if (m2.body(int(m2.geom_bodyid[g])).name or "").startswith(pre)
and (m2.geom_contype[g] or m2.geom_conaffinity[g])],
# 夹持中心 = 两爪指尖球的中点(Go2 抓放 demo 验证过的算法)
"jaw_a": [g for g in range(m2.ngeom) if gname(g).startswith(f"{pre}fixed_jaw_sph_tip")],
"jaw_b": [g for g in range(m2.ngeom) if gname(g).startswith(f"{pre}moving_jaw_sph_tip")],
"palm_body": int(m2.body(f"{pre}gripper").id),
"site_id": int(m2.site(f"{pre}gripperframe").id),
}
continue
a = act_idx(f"arm_{side}_")
h = act_idx(f"hand_{side}_")
arms[side] = {
"act": a,
"names": [m2.actuator(i).name for i in a],
"qadr": [int(m2.jnt_qposadr[int(m2.actuator_trnid[i][0])]) for i in a],
"range": [[float(x) for x in m2.actuator_ctrlrange[i]] for i in a],
"site": f"arm_{side}_tool0",
"site_id": int(m2.site(f"arm_{side}_tool0").id),
"ready": ready[side]["q"],
}
if cfg.gripper == "so101":
pre = f"grip_{side}_"
h = [i for i in range(m2.nu) if m2.actuator(i).name.startswith(pre)]
gname = lambda g: m2.geom(g).name or "" # noqa: E731
hands[side] = {
"kind": "so101",
"act": h,
"names": [m2.actuator(i).name for i in h],
"range": [[float(x) for x in m2.actuator_ctrlrange[i]] for i in h],
"geoms": [g for g in range(m2.ngeom)
if (m2.body(int(m2.geom_bodyid[g])).name or "").startswith(pre)
and (m2.geom_contype[g] or m2.geom_conaffinity[g])],
# 夹持中心 = 两爪指尖球的中点(Go2 上验证过的算法)
"jaw_a": [g for g in range(m2.ngeom) if gname(g).startswith(f"{pre}fixed_jaw_sph_tip")],
"jaw_b": [g for g in range(m2.ngeom) if gname(g).startswith(f"{pre}moving_jaw_sph_tip")],
"palm_body": int(m2.body(f"{pre}gripper").id),
"site": f"{pre}gripperframe",
"site_id": int(m2.site(f"{pre}gripperframe").id),
}
else:
pre = f"hand_{side}_{'l' if side == 'left' else 'r'}h_"
hands[side] = {
"kind": "l6",
"act": h,
"names": [m2.actuator(i).name for i in h],
"range": [[float(x) for x in m2.actuator_ctrlrange[i]] for i in h],
# 原始资产的 geom 是靠 class 配的、大多没名字,只能按所属 body 来筛
"geoms": [g for g in range(m2.ngeom)
if (m2.body(int(m2.geom_bodyid[g])).name or "").startswith(f"hand_{side}_")
and (m2.geom_contype[g] or m2.geom_conaffinity[g])],
"tip_bodies": [int(m2.body(f"{pre}{t}_tip").id)
for t in ("thumb", "index", "middle", "ring", "pinky")],
"palm_body": int(m2.body(f"{pre}hand_base_link").id),
}
meta = {
"name": cfg.name,
"workstation": cfg.workstation,
"gripper": cfg.gripper,
"sim": {"timestep": float(m2.opt.timestep), "substeps": 10},
"nq": int(m2.nq), "nu": int(m2.nu), "ngeom": int(m2.ngeom),
"kps": [float(m2.actuator_gainprm[i][0]) for i in range(m2.nu)],
"arms": arms, "hands": hands,
"scene": info,
"xmlMB": round(mb, 2),
}
# 物体的 qpos 地址(自由关节),前端要用来改位置
for o in meta["scene"]["objects"]:
bid = m2.body(o["body"]).id
o["qadr"] = int(m2.jnt_qposadr[m2.body_jntadr[bid]])
o["body_id"] = int(bid)
o["geom_id"] = int(m2.geom(o["geom"]).id)
(out / "meta.json").write_text(json.dumps(meta, ensure_ascii=False))
print(f"已导出 {cfg.name}{out}")
print(f" model.xml {mb:.2f} MB | nq={m2.nq} nu={m2.nu} ngeom={m2.ngeom} nmesh={m2.nmesh}")
print(f" 桌面 z={info['table']['top']} 中心 x={info['table']['x']} | 物体 "
+ ", ".join(o["name"] for o in meta["scene"]["objects"]))
if __name__ == "__main__":
main()