17 · composer 进阶

这一章要解决什么问题 :第 16 章的 control.Task 依赖一个已经建好的 physics

场景里的机器人、桌子、方块全写死在同一个 MJCF 里。

想换个场景?复制 XML 改一遍。想加个障碍?再改一遍。

composer 把模型拆成可复用的零件(Entity)、舞台(Arena)与规则(Task),

让场景变成用代码拼出来的,可以复用、可以随机化。

配套代码:`code/ch17_dm_control_composer.py`

python 复制代码
"""
第 17 章配套代码:composer 进阶(Entity / Arena / 模块化组合)。

运行:
    D:\\Environment\\dm_control_env\\python.exe ch17_dm_control_composer.py

内容:
  17.1 为什么需要 composer(第 16 章的 Task 缺什么)
  17.2 Entity:可复用的模型组件 + ⚠️ 命名前缀规则
  17.3 Arena:舞台(自带 angle=radian / add_free_entity / set_pose)
  17.4 四种组合方式实测对比
  17.5 composer.Task + composer.Environment(与 control.Task 的 6 处差异)
  17.6 模块化实战:同一批 Entity 拼出两个不同场景
  17.7 ⚠️ composer 的常见坑
  17.9 动手练答案
"""
import xml.etree.ElementTree as ET
import inspect
import numpy as np
import dm_env
from dm_control import composer, mjcf
from dm_control.composer import Entity, Arena
from dm_control.composer.observation import observable
from dm_env import specs

np.set_printoptions(precision=4, suppress=True)


def banner(t):
    print("\n" + "=" * 72)
    print(t)
    print("=" * 72)


# ============================================================
# 17.1 为什么需要 composer
# ============================================================
banner("17.1  为什么需要 composer")

print("""
  第 16 章的 control.Task 有个问题:
      Task 直接依赖一个【已经建好的 physics】,
      场景里的机器人、桌子、方块全都写死在同一个 MJCF 里。

  想换个场景怎么办?复制一份 XML 改?  想加个障碍物?再改一次?

  composer 的思路:把模型拆成【可复用的组件】
      Entity  = 零件(一个方块、一张桌子、一条臂)
      Arena   = 舞台(地面、灯光、坐标系)
      Task    = 规则(把这些零件摆到舞台上,定义奖励与终止)

  好处:
      ✅ 零件可以跨任务复用
      ✅ 场景是代码拼出来的,可以随机化(位置、颜色、数量)
      ✅ 同一个 Task 换个 Arena 就是新任务
""")

# ============================================================
# 17.2 Entity
# ============================================================
banner("17.2  Entity:可复用的模型组件")


class Block(Entity):
    """一个可复用的方块。"""

    def _build(self, name="block", size=(0.04, 0.04, 0.04), mass=0.2,
               rgba=(0.2, 0.8, 0.5, 1)):
        self._r = mjcf.RootElement(model=name)
        self._body = self._r.worldbody.add("body", name="body")
        self._body.add("geom", name="g", type="box", size=size,
                       mass=mass, rgba=rgba)
        self._body.add("site", name="center", size=[0.01])

    @property
    def mjcf_model(self):
        return self._r

    @property
    def body(self):
        return self._body


print("""
  Entity 的最小骨架(三个要素):

    class Block(Entity):
        def _build(self, name='block', ...):     # ① 构造函数
            self._r = mjcf.RootElement(model=name)
            self._r.worldbody.add('body', ...)   #    在里面搭 MJCF

        @property
        def mjcf_model(self): return self._r     # ② 必须暴露这个属性

        @property
        def body(self): return self._body        # ③ 你自己的便捷接口
""")

blk = Block()
print(f"  blk.mjcf_model = {type(blk.mjcf_model).__name__}  "
      f"(model 名 = {blk.mjcf_model.model})")

print("\n  ⚠️ 命名规则:attach 之后元素名会自动加上【entity 名 /】前缀")
_a = Arena(name="s")
_a.add_free_entity(Block(name="ball"))
_p = mjcf.Physics.from_mjcf_model(_a.mjcf_model)
_p.forward()
print(f"    body 名字 = {list(_p.named.data.xpos.axes.row.names)}")
print(f"    qpos 名字 = {list(_p.named.data.qpos.axes.row.names)}")
print("    -> 我写的是 'body',实际叫 'ball/body';freejoint 叫 'ball/'")

# ============================================================
# 17.3 Arena
# ============================================================
banner("17.3  Arena:舞台")

a0 = Arena(name="stage")
xml = ET.tostring(a0.mjcf_model.to_xml(), encoding="unicode")
print("  Arena 默认生成的 XML(注意看 compiler):")
print("   ", xml[:160], "...")
print()
print("  ✅ Arena 自带 angle='radian' 和 eulerseq='xyz'")
print("     ------ 第 15 章那个「mjcf 建模角度是度」的坑,用 Arena 就自动规避了")
print()
print("  Arena 的常用方法:")
for m, desc in [("attach(entity, attach_site=None)", "把零件放上舞台(无自由度)"),
                ("add_free_entity(entity)", "放上舞台 + 自动加 freejoint"),
                ("entity.set_pose(physics, position=...)", "设置位姿"),
                ("entity.get_pose(physics)", "读取位姿 (position, quaternion)")]:
    print(f"    {m:<40} {desc}")

print("\n  实测 set_pose / get_pose:")
a1 = Arena(name="s1")
ball = Block(name="ball")
a1.add_free_entity(ball)
p1 = mjcf.Physics.from_mjcf_model(a1.mjcf_model)
ball.set_pose(p1, position=[0.1, 0.2, 0.3])
p1.forward()
pos, quat = ball.get_pose(p1)
print(f"    set_pose([0.1,0.2,0.3]) -> get_pose = "
      f"{np.round(np.asarray(pos), 4)}  {np.round(np.asarray(quat), 4)}")
print(f"    方块实际世界坐标 = "
      f"{np.round(np.asarray(p1.named.data.xpos['ball/body']), 4)}")

# ============================================================
# 17.4 四种组合方式
# ============================================================
banner("17.4  四种组合方式(实测对比)")


class Table(Entity):
    """一张桌子,带一个可用于挂载的 site。"""

    def _build(self, name="table"):
        self._r = mjcf.RootElement(model=name)
        # Z-up 水平桌面:面板厚 0.04 m(z 向半长 0.02),桌面顶面在 z = 0.40
        top = self._r.worldbody.add("body", name="top", pos=[0, 0, 0.38])
        top.add("geom", name="topg", type="box", size=[0.3, 0.2, 0.02],
                mass=5.0, rgba=[0.5, 0.4, 0.3, 1])
        top.add("site", name="surface", pos=[0, 0, 0.02], size=[0.01])
        self._top = top
        self._site = top.find("site", "surface")

    @property
    def mjcf_model(self):
        return self._r

    @property
    def attach_site(self):
        return self._site


class HingedArm(Entity):
    """带铰链关节 + 执行器的摆臂。

    ⚠️ kp/kv 要温和:composer 的 Environment 对发散很敏感,
       增益过大会直接抛 PhysicsError。
    """

    def _build(self, name="arm", kp=2.0, kv=0.5):
        self._r = mjcf.RootElement(model=name)
        b = self._r.worldbody.add("body", name="link", pos=[0, 0.2, 0])
        j = b.add("joint", name="hinge", type="hinge", axis=[0, 1, 0],
                  damping=0.05)
        b.add("geom", name="linkg", type="capsule",
              fromto=[0, 0, 0, 0, 0, 0.2], size=[0.02], mass=0.3,
              rgba=[0.3, 0.5, 0.9, 1])
        self._r.actuator.add("position", name="act", joint=j, kp=kp, kv=kv)
        self._joint = j

    @property
    def mjcf_model(self):
        return self._r

    @property
    def joint(self):
        return self._joint


def report(tag, model):
    ph = mjcf.Physics.from_mjcf_model(model)
    ph.forward()
    print(f"  {tag}")
    print(f"    nq={ph.model.nq}  nbody={ph.model.nbody}  ngeom={ph.model.ngeom}")
    print(f"    body = {list(ph.named.data.xpos.axes.row.names)}")
    try:
        print(f"    qpos = {list(ph.named.data.qpos.axes.row.names)}")
    except AttributeError:
        print("    qpos = (nq=0,没有自由度)")
    return ph


print("  方式① add_free_entity ------ 能自由移动的物体")
a = Arena(name="s1")
a.add_free_entity(Block(name="ball"))
report("    ", a.mjcf_model)
print("      nq=7:freejoint 占 7 个自由度\n")

print("  方式② attach ------ 焊在世界上(无自由度)")
a2 = Arena(name="s2")
a2.attach(Block(name="fixed_block"))
report("    ", a2.mjcf_model)
print("      nq=0:attach 不加关节,方块被固定住\n")

print("  方式③ Entity 挂到另一个 Entity 的 site 上(嵌套组合)")
a3 = Arena(name="s3")
table = Table()
a3.attach(table)
table.attach(Block(name="on_table", rgba=(0.9, 0.6, 0.1, 1)),
             attach_site=table.attach_site)
report("    ", a3.mjcf_model)
print("      前缀是【父/子/元素】三级:'table/on_table/body'\n")

print("  方式④ Entity 内部自带关节和执行器(最灵活)")
a4 = Arena(name="s4")
a4.attach(HingedArm())
ph4 = report("    ", a4.mjcf_model)
key = [n for n in ph4.named.data.ctrl.axes.row.names if n.endswith("/act")][0]
ph4.reset()
ph4.named.data.ctrl[key] = 0.8
for _ in range(500):
    ph4.step()
print(f"      命令 hinge 到 0.8 -> 1 秒后 = "
      f"{np.round(np.asarray(ph4.named.data.qpos['arm/hinge']), 4)}")
print(f"      ⚠️ 执行器名字是 '{key}',不是 'act'\n")

print("  组合:桌子 + 铰链臂 + 自由方块")
a5 = Arena(name="final")
a5.add_free_entity(Block(name="target_box"))
a5.attach(HingedArm(name="arm1"))
report("    ", a5.mjcf_model)

# ============================================================
# 17.5 composer.Task / Environment
# ============================================================
banner("17.5  composer.Task + composer.Environment")

print("  🔥 与 control.Task 的差异(实测签名):")
print(f"    composer.Environment{inspect.signature(composer.Environment.__init__)}")
print()
print("  ┌────────────────────┬───────────────────────┬────────────────────────────┐")
print("  │                    │ control.Task (16章)   │ composer.Task (17章)       │")
print("  ├────────────────────┼───────────────────────┼────────────────────────────┤")
print("  │ before_step        │ (action, physics)     │ (physics, action, random)  │")
print("  │ 观测               │ get_observation()     │ observables 机制           │")
print("  │ 观测空间           │ observation_spec()    │ 自动从 observables 生成    │")
print("  │ 终止判断           │ get_termination()     │ should_terminate_episode() │")
print("  │ 控制周期           │ control_timestep=参数 │ Task.control_timestep 属性 │")
print("  │ 场景               │ 外部给 physics        │ Task.root_entity 属性 ★    │")
print("  └────────────────────┴───────────────────────┴────────────────────────────┘")
print()
print("  ⚠️ 注意 before_step 的参数顺序:")
print("     control.Task 是 (action, physics)")
print("     composer.Task 是 (physics, action, random_state)  ← 正好相反!")


def hinge_angle(physics, key="arm1/hinge"):
    return float(np.asarray(physics.named.data.qpos[key]).ravel()[0])


class ReachAngleTask(composer.Task):
    """控制铰链臂转到目标角度。换一个 Arena 就是新场景。"""

    def __init__(self, arena, target_angle=0.6):
        self._arena = arena
        self._target = float(target_angle)
        self._arm = HingedArm(name="arm1")
        self._arena.attach(self._arm)

    # ---- composer 特有 ----
    @property
    def root_entity(self):
        return self._arena                  # ⭐ composer 靠它建 physics

    @property
    def control_timestep(self):
        return 0.02                         # ⭐ 替代废弃的 n_sub_steps

    # ---- 生命周期(参数顺序与 control.Task 不同)----
    def initialize_episode(self, physics, random_state):
        physics.named.data.qpos["arm1/hinge"] = 0.0
        physics.forward()

    def before_step(self, physics, action, random_state):
        physics.named.data.ctrl["arm1/act"] = float(
            np.clip(np.asarray(action).ravel()[0], -3.0, 3.0))

    def after_step(self, physics, random_state):
        pass

    # ---- 观测:用 observables,不是 get_observation ----
    @property
    def task_observables(self):
        ang = observable.Generic(lambda ph: np.array([hinge_angle(ph)]))
        tgt = observable.Generic(lambda ph: np.array([self._target]))
        ang.enabled = True                  # 🔥 默认是 False,不改就没有这个观测
        tgt.enabled = True
        return {"angle": ang, "target": tgt}

    def action_spec(self, physics):
        return specs.BoundedArray((1,), np.float64, -3.0, 3.0, "target_angle")

    def get_reward(self, physics):
        return float(-abs(hinge_angle(physics) - self._target))

    def should_terminate_episode(self, physics):
        return abs(hinge_angle(physics) - self._target) < 0.02


arena_a = Arena(name="sceneA")
task = ReachAngleTask(arena_a, target_angle=0.6)
env = composer.Environment(task, time_limit=4.0,
                           strip_singleton_obs_buffer_dim=True)
print(f"\n  type(env) = {type(env).__name__}  "
      f"是 dm_env.Environment? {isinstance(env, dm_env.Environment)}")
print(f"  control_timestep = {env.control_timestep()}  (来自 Task 的属性)")

ts = env.reset()
print(f"  reset -> step_type={ts.step_type.name}  "
      f"obs={ {k: np.round(np.asarray(v), 4).tolist() for k, v in ts.observation.items()} }")

print(f"\n  {'step':>5} {'angle':>9} {'reward':>10} {'step_type':>10}")
for i in range(200):
    ts = env.step(np.array([0.6]))           # 直接命令目标角度
    if i % 15 == 0 or ts.step_type.name == "LAST":
        a = float(np.asarray(ts.observation["angle"]).ravel()[0])
        print(f"  {i:>5} {a:>9.4f} {ts.reward:>10.4f} {ts.step_type.name:>10}")
    if ts.step_type.name == "LAST":
        break
print(f"  observation_spec = "
      f"{ {k: v.shape for k, v in env.observation_spec().items()} }")

print("\n  ⚠️ 观测 shape 与 strip_singleton_obs_buffer_dim:")
for ss in (False, True):
    e = composer.Environment(ReachAngleTask(Arena(name="t"), 0.6),
                             time_limit=4.0,
                             strip_singleton_obs_buffer_dim=ss)
    e.reset()
    t1 = e.step(np.array([0.3]))
    print(f"    ={str(ss):<5} -> obs shape = "
          f"{ {k: np.shape(v) for k, v in t1.observation.items()} }")
print("    observable 默认留一个 buffer 维度(1,1);设 True 会压成 (1,)")

# ============================================================
# 17.6 模块化实战
# ============================================================
banner("17.6  模块化实战:同一批零件拼出两个场景")


def build_scene(kind):
    """用同一批 Entity,拼出不同场景。"""
    ar = Arena(name=f"scene_{kind}")
    if kind == "table":
        t = Table()
        ar.attach(t)
        t.attach(Block(name="box1"), attach_site=t.attach_site)
        ar.add_free_entity(Block(name="box2", rgba=(0.9, 0.3, 0.2, 1)))
    else:  # floor
        # Z-up:MJCF plane 默认法线就是 +Z,不要加 euler 旋转(否则地面会立成竖直的墙)
        ar.mjcf_model.worldbody.add(
            "geom", name="floor", type="plane", size=[1, 1, 0.1])
        ar.add_free_entity(Block(name="box1"))
        ar.add_free_entity(Block(name="box2", rgba=(0.9, 0.3, 0.2, 1)))
        ar.attach(HingedArm(name="arm1"))
    return ar


for kind in ("table", "floor"):
    ph = mjcf.Physics.from_mjcf_model(build_scene(kind).mjcf_model)
    ph.forward()
    print(f"  场景 {kind:<6}: nq={ph.model.nq} nbody={ph.model.nbody} "
          f"ngeom={ph.model.ngeom}")
    print(f"      body = {list(ph.named.data.xpos.axes.row.names)}")

print("""
  ✅ 这就是 composer 的价值:
     Block / Table / HingedArm 三个类一次写好,
     换个 Arena 就是新场景,不用复制粘贴 XML。
""")

# ============================================================
# 17.7 坑
# ============================================================
banner("17.7  ⚠️ composer 的常见坑")

print("  【坑 1】元素名自动加【entity 名 /】前缀")
print("    'body' -> 'ball/body';嵌套 'table/block/body'")
print("    取数据时 KeyError,用全名或按后缀匹配\n")

print("  【坑 2】同一个 Entity 实例不能 attach 两次")
a6 = Arena(name="s6")
_dup = Block(name="dup")
a6.attach(_dup)
try:
    a6.attach(_dup)
except ValueError as e:
    print(f"    ValueError: {e}")
print("    -> 想放两个方块,就 new 两个实例\n")

print("  【坑 3】attach 不加关节,物体是死的")
print("    attach          -> nq=0,焊住")
print("    add_free_entity -> nq=7,能自由动\n")

print("  【坑 4】composer.Environment 没有 control_timestep 参数")
print("    n_sub_steps 也已废弃(DeprecationWarning)")
print("    ✅ 正确做法:覆盖 Task 的 control_timestep 属性\n")

print("  【坑 5】observable 的 enabled 默认 False")
print("    不设 enabled=True,观测字典就是空的 {}(不报错,最难查)\n")

print("  【坑 6】composer.Task 没有 get_observation / observation_spec")
print("    写了也不会被调用;要用 task_observables 或 Entity.observables\n")

print("  【坑 7】attach 不能传 pos / quat")
print("    Entity.attach(entity, attach_site=None) ------ 没有位置参数")
print("    想摆位置:① 在 _build 里写死  ② 用 set_pose()\n")

print("  【坑 8】PD 增益过大 -> PhysicsError")
print("    composer 的 Environment 对发散很敏感,会抛")
print("    dm_control.rl.control.PhysicsError: Physics state is invalid")
print("    解决:降低 kp/kv,给关节加 damping\n")

print("  【坑 9】Arena 默认没有地面,只有 headlight")
print("    需要地面就自己 add geom;需要额外灯光就自己加 light")

# ============================================================
# 17.9 动手练
# ============================================================
banner("17.9 动手练 参考答案")
print("练习1 Entity     : 三要素 = _build / mjcf_model / 便捷属性")
print("练习2 命名前缀   : 'body' -> 'ball/body';嵌套 'table/block/body'")
print("练习3 两种挂载   : attach(nq=0) vs add_free_entity(nq=7)")
print("练习4 嵌套组合   : table.attach(block, attach_site=table.attach_site)")
print("练习5 Task       : root_entity + control_timestep 属性 + task_observables")
print("练习6 复用       : 见 17.6,同一批 Entity 拼出 table / floor 两个场景")

print("\n第 17 章示例代码运行完毕。")
print("第 5 部分(dm_control)结束;下一部分:6 轴机械臂实战。")

📌 本章所有数字与报错都在作者机器上实测得到

(mujoco 3.11.0 / dm-control 1.0.44 / dm-env 1.6)。


🎯 学习目标

学完本章,你将能够:

  1. 理解 composer 的核心思想 ------把模型拆成可复用的 Entity(零件)、Arena(舞台)、Task(规则),说清它与第 16 章 control.Task 的本质区别。
  2. 实现一个完整的 composer.Entity ------掌握三要素(_build() 构造函数 / mjcf_model 属性 / 自定义便捷接口),理解命名前缀规则(entity名/元素名)。
  3. 使用 Arena 组装场景 ------掌握 attach()(无自由度焊接)和 add_free_entity()(自动加 freejoint)的区别,能用 set_pose() / get_pose() 设置物体位姿。
  4. 实现一个完整的 composer.Task ------掌握与 control.Task 的 6 处差异(before_step 参数顺序相反、观测用 observables、终止用 should_terminate_episode、控制周期用属性、场景用 root_entity 属性、多了 random_state)。
  5. 理解 observables 机制 ------用 observable.Generic 定义观测,记住 enabled 默认是 False(忘了设观测字典就是空的,而且不报错),理解 strip_singleton_obs_buffer_dim 的作用。
  6. 规避 composer 的常见坑 ------同一 Entity 实例不能 attach 两次、attach 不加关节物体是死的、composer.Environment 没有 control_timestep 参数(要用 Task 属性)、PD 增益过大会抛 PhysicsError

📖 前置知识 :本章需要第 15 章的 mjcf 纯代码建模(RootElement + add)和 bind() 知识,以及第 16 章的 control.Task 协议知识。composer 大量使用 bind() 读写物理数据,如果你还不熟悉,请先复习第 15.6 节。


17.1 为什么需要 composer

直观理解:composer 是什么?

想象你是一个剧场导演,要排演多场不同的戏:

  • 第 16 章的 control.Task = 一个固定的舞台------布景、道具、演员位置全写死在一个脚本里。想换一场戏?得重写整个脚本。
  • composer = 模块化的剧场系统 ------
    • Entity(零件) = 可复用的道具(一张桌子、一把椅子、一个方块),每个道具都有自己的规格说明书。
    • Arena(舞台) = 剧场场地(地面、灯光、背景),你可以把道具摆到舞台上。
    • Task(规则) = 剧本(这场戏要演什么、怎么评分、什么时候结束)。

好处:同一张桌子可以用在"餐厅场景"也可以用在"实验室场景";同一个方块可以当"积木"也可以当"障碍物";换一个 Arena 就是新场景,不用复制粘贴 XML。

composer 架构图

复制代码
┌─────────────────────────────────────────────────────────────────┐
│                    composer.Environment                           │
│              (标准 RL 环境,兼容 dm_env)                        │
│                                                                   │
│  ┌─────────────────────────────────────────────────────────┐    │
│  │                    composer.Task                          │    │
│  │              (剧本:定义任务规则)                         │    │
│  │                                                           │    │
│  │  属性:                                                    │    │
│  │    root_entity      → 指向 Arena(场景根节点)            │    │
│  │    control_timestep → 控制周期(替代 n_sub_steps)       │    │
│  │    task_observables → 观测定义(observables 机制)        │    │
│  │                                                           │    │
│  │  方法:                                                    │    │
│  │    initialize_episode(physics, random_state)             │    │
│  │    before_step(physics, action, random_state)  ⚠️顺序相反 │    │
│  │    get_reward(physics)                                    │    │
│  │    should_terminate_episode(physics)                      │    │
│  └───────────────────────┬───────────────────────────────────┘    │
│                          │ root_entity                              │
│                          ▼                                          │
│  ┌─────────────────────────────────────────────────────────┐    │
│  │                      Arena(舞台)                         │    │
│  │    自带: angle=radian, timestep=0.002, headlight        │    │
│  │                                                           │    │
│  │  attach(entity)          → 焊接(无自由度)               │    │
│  │  add_free_entity(entity) → 自由体(加 freejoint)         │    │
│  │                                                           │    │
│  │  ┌──────────┐  ┌──────────┐  ┌──────────┐             │    │
│  │  │ Entity A │  │ Entity B │  │ Entity C │  ...        │    │
│  │  │ (机械臂)  │  │ (桌子)   │  │ (方块)   │             │    │
│  │  └──────────┘  └──────────┘  └──────────┘             │    │
│  └─────────────────────────────────────────────────────────┘    │
│                                                                   │
│  编译: root_entity.mjcf_model → Physics → 仿真循环               │
└─────────────────────────────────────────────────────────────────┘

💡 关键洞察 :composer 与 control.Task 的本质区别是------

control.Task 依赖一个外部传入的 physics (场景已经建好了),

composer.Task 通过 root_entity 属性自己定义场景 (Arena + Entity)。

这意味着 composer.Task 是自包含的------你给它一个 Task,它就能自己建出完整的仿真环境。

复制代码
Entity  = 零件(一个方块、一张桌子、一条臂)
Arena   = 舞台(地面、灯光、坐标系)
Task    = 规则(把这些零件摆到舞台上,定义奖励与终止)

好处:

  • ✅ 零件可以跨任务复用
  • ✅ 场景是代码拼出来的,可以随机化(位置、颜色、数量)
  • ✅ 同一个 Task 换个 Arena 就是新任务

17.2 Entity:可复用的模型组件

直观理解:Entity 是什么?

想象你在淘宝上买预制家具

  • XML 写模型 = 你买了一堆木板和螺丝,回家自己看说明书拼装------每次都要从头拼,想改个尺寸得重新切木板。
  • Entity = 你买了一个成品书柜------厂家已经拼好了,你只需要把它搬到家里(attach 到 Arena),想挪位置就挪位置。而且同一个款式的书柜可以买好几个,放在不同的房间里。

Entity 的三要素就像成品家具的三个必要条件:

  1. _build() = 工厂的生产流水线------定义这个家具有哪些零件、怎么组装。
  2. mjcf_model 属性 = 家具的规格说明书------告诉别人这个家具的完整模型描述。
  3. 自定义便捷接口 (如 bodyattach_site)= 家具的把手和连接件------让别人能方便地移动它、把别的东西挂在它上面。

场景组装流程图

复制代码
开始
  │
  ▼
创建 Arena(舞台)
  │  ar = Arena(name="scene")
  │  自带: angle=radian, timestep=0.002
  │
  ▼
创建 Entity(零件)
  │  arm = HingedArm(name="arm1")    ← Entity 内部 _build() 搭模型
  │  table = Table()
  │  box = Block(name="box1")
  │
  ▼
把 Entity 挂到 Arena 上
  │  ┌─────────────────────────────────────────┐
  │  │ 方式选择:                                 │
  │  │   attach(entity)        → 焊接(nq=0)   │
  │  │   add_free_entity(entity) → 自由体(nq=7)│
  │  └─────────────────────────────────────────┘
  │
  │  ar.attach(arm)              ← 机械臂焊在地上
  │  ar.attach(table)            ← 桌子焊在地上
  │  table.attach(box, attach_site=table.attach_site)  ← 方块焊在桌面上
  │  ar.add_free_entity(Block(name="box2"))  ← 另一个方块可以自由移动
  │
  ▼
创建 composer.Task(规则)
  │  task = MyTask(arena=ar, ...)
  │  Task 内部:
  │    root_entity → ar(场景根节点)
  │    control_timestep → 0.02
  │    task_observables → 定义观测
  │
  ▼
创建 composer.Environment
  │  env = composer.Environment(task, time_limit=4.0)
  │  内部自动:
  │    从 task.root_entity.mjcf_model 编译 Physics
  │    注册 observables
  │
  ▼
跑仿真
  │  ts = env.reset()
  │  ts = env.step(action)
  │  ...
  │
  ▼
结束

💡 这个流程的关键 :场景不是写在 XML 里的,而是用 Python 代码 出来的。

这意味着你可以用循环、条件判断、随机数来动态生成场景------

比如"随机放 3~5 个方块"、"根据难度参数调整桌子高度"、"每次训练随机化物体颜色"。

这些用静态 XML 很难做到,用 composer 就是几行代码的事。

三要素

python 复制代码
class Block(Entity):
    def _build(self, name="block", size=(0.04, 0.04, 0.04), mass=0.2,
               rgba=(0.2, 0.8, 0.5, 1)):
        # ① 构造函数:在里面搭 MJCF
        self._r = mjcf.RootElement(model=name)
        self._body = self._r.worldbody.add("body", name="body")
        self._body.add("geom", name="g", type="box", size=size,
                       mass=mass, rgba=rgba)
        self._body.add("site", name="center", size=[0.01])

    @property
    def mjcf_model(self):        # ② 必须暴露这个属性
        return self._r

    @property
    def body(self):              # ③ 你自己的便捷接口
        return self._body

⚠️ 命名规则:元素名会自动加【entity 名 /】前缀

实测:

复制代码
我写的是 'body',实际叫 'ball/body'
freejoint 叫 'ball/'
body 名字 = ['world', 'ball/', 'ball/body']
qpos 名字 = ['ball/']

嵌套组合时会是三级'table/on_table/body'

💡 取数据时按全名,或者写个「按后缀匹配」的小工具。

出现 KeyError 时先怀疑是不是前缀的问题。


17.3 Arena:舞台

直观理解:Arena 是什么?

想象你租了一个剧场场地

  • Arena = 剧场的基础设施------地板、灯光、电源、背景幕布。这些是每场戏都需要的,不用每次都重新搭。
  • Entity = 你搬进剧场的道具和演员------桌子、椅子、机械臂、方块。
  • attach() = 把道具钉死在地板上------不能移动,不能翻倒。
  • add_free_entity() = 把道具放在地板上但不固定------可以被推动、可以翻倒、可以被机械臂抓取。

weld 约束示意图

复制代码
attach(entity) ------ 焊接约束(weld)
┌─────────────────────────────────────────────┐
│  世界坐标系 (worldbody)                       │
│                                               │
│  ┌─────────┐    weld 约束     ┌──────────┐  │
│  │  Arena  │ ◄──────────────► │  Entity  │  │
│  │ (舞台)   │   6个自由度全锁死  │ (零件)   │  │
│  └─────────┘                  └──────────┘  │
│                                               │
│  结果: nq=0(Entity 没有任何自由度)           │
│  Entity 的位置/方向完全固定,不能动            │
│  类比: 把书架钉在墙上                          │
└─────────────────────────────────────────────┘

add_free_entity(entity) ------ 自由体(freejoint)
┌─────────────────────────────────────────────┐
│  世界坐标系 (worldbody)                       │
│                                               │
│  ┌─────────┐    freejoint    ┌──────────┐   │
│  │  Arena  │ ◄──────────────► │  Entity  │   │
│  │ (舞台)   │   6个自由度全释放  │ (零件)   │   │
│  └─────────┘                  └──────────┘   │
│                                               │
│  结果: nq=7(3平移 + 4四元数旋转)             │
│  Entity 可以自由移动和旋转                     │
│  类比: 把篮球放在球场上(可以滚、可以飞)       │
└─────────────────────────────────────────────┘

Entity 内部自带 joint ------ 最灵活的方式
┌─────────────────────────────────────────────┐
│  世界坐标系 (worldbody)                       │
│                                               │
│  ┌─────────┐    weld    ┌────────────────┐  │
│  │  Arena  │ ◄────────► │  Entity        │  │
│  └─────────┘            │  ┌──────────┐  │  │
│                          │  │ base(焊死)│  │  │
│                          │  └─────┬────┘  │  │
│                          │        │ hinge  │  │
│                          │  ┌─────▼────┐  │  │
│                          │  │ link(能动)│  │  │
│                          │  └──────────┘  │  │
│                          └────────────────┘  │
│                                               │
│  结果: nq=1(Entity 内部的 hinge 关节)        │
│  Entity 的底座焊死,但连杆可以绕铰链转动        │
│  类比: 机械臂------底座固定在地上,但关节可以动     │
└─────────────────────────────────────────────┘

💡 三种方式的选择

  • 固定不动的物体 (地面、桌子、机械臂底座)→ attach()
  • 可以自由移动的物体 (被抓取的方块、滚动的球)→ add_free_entity()
  • 有主动关节的物体 (机械臂、铰链门)→ attach() + Entity 内部自带 joint + actuator

在我们的 6 轴机械臂项目中:机械臂本体用 attach()(底座固定),

被抓取的物体用 add_free_entity()(可以被夹爪抓起和移动)。

✅ Arena 自带 angle="radian"

复制代码
<mujoco model="stage">
  <compiler boundmass="1e-05" boundinertia="1e-11" coordinate="local"
            angle="radian" eulerseq="xyz"/>
  <option timestep="0.002" cone="elliptic" .../>
  ...

💡 第 15 章讲过「mjcf.RootElement() 建模时角度默认是度」的坑。

Arena 就自动规避了 ------ 它默认就是 angle="radian"

常用方法

方法 作用
attach(entity, attach_site=None) 把零件放上舞台(无自由度
add_free_entity(entity) 放上舞台 + 自动加 freejoint
entity.set_pose(physics, position=...) 设置位姿
entity.get_pose(physics) 读取 (position, quaternion)

实测 set_pose / get_pose

复制代码
set_pose([0.1,0.2,0.3]) -> get_pose = [0.1 0.2 0.3]  [1. 0. 0. 0.]
方块实际世界坐标 = [0.1 0.2 0.3]      ✅ 精确回读

17.4 四种组合方式(实测对比)

四种方式的选择决策树

复制代码
你要把 Entity 放到 Arena 上
        │
        ▼
  Entity 需要主动运动吗?
   (内部有关节和执行器?)
   ┌──────────┴──────────┐
   是                     否
   │                      │
   ▼                      ▼
 attach(entity)      Entity 需要自由移动吗?
 (底座焊死,         (可以被推动、抓取?)
  内部关节能动)       ┌────────┴────────┐
   │                  是                  否
   │                  │                   │
   ▼                  ▼                   ▼
 方式④            add_free_entity      attach(entity)
 最灵活           (方式①,nq=7)       (方式②,nq=0)
                  可以自由移动和旋转      完全固定,不能动
                   │
                   ▼
              需要挂在另一个 Entity 上吗?
              (比如方块放在桌子上?)
               ┌────────┴────────┐
               是                  否
               │                   │
               ▼                   ▼
          table.attach(block,   直接 add_free_entity
            attach_site=...)     到 Arena 上
          (方式③,嵌套组合)

💡 在我们的 6 轴机械臂项目中

  • 机械臂本体 → 方式④(attach() + 内部 6 个关节 + 6 个执行器)
  • 被抓取的方块 → 方式①(add_free_entity(),可以被夹爪抓起)
  • 工作台 → 方式②(attach(),固定不动)
  • 如果方块要放在工作台上 → 方式③(table.attach(block, attach_site=...)
方式 nq nbody 说明
add_free_entity(Block) 7 3 能自由移动(freejoint 占 7 个自由度)
attach(Block) 0 3 焊在世界上,死的
table.attach(Block, attach_site=...) 0 5 嵌套挂到桌面的 site 上
④ Entity 内部自带 joint + actuator 1 3 最灵活,推荐
复制代码
方式① body = ['world', 'ball/', 'ball/body']
方式② body = ['world', 'fixed_block/', 'fixed_block/body']
方式③ body = ['world', 'table/', 'table/top',
              'table/on_table/', 'table/on_table/body']   <- 三级前缀
方式④ body = ['world', 'arm/', 'arm/link']   qpos = ['arm/hinge']

嵌套组合的写法(在 Entity 里暴露一个挂点):

python 复制代码
class Table(Entity):
    def _build(self, name="table"):
        ...
        top.add("site", name="surface", pos=[0, 0, 0.02], size=[0.01])
        self._site = top.find("site", "surface")

    @property
    def attach_site(self):
        return self._site        # 别的 Entity 可以挂上来

# 使用
table.attach(Block(name="on_table"), attach_site=table.attach_site)

在 Entity 内部自带关节 + 执行器(方式④,最灵活):

python 复制代码
class HingedArm(Entity):
    def _build(self, name="arm", kp=2.0, kv=0.5):
        self._r = mjcf.RootElement(model=name)
        b = self._r.worldbody.add("body", name="link", pos=[0, 0.2, 0])
        j = b.add("joint", name="hinge", type="hinge", axis=[0, 1, 0],
                  damping=0.05)          # 加阻尼,稳
        b.add("geom", name="linkg", type="capsule",
              fromto=[0, 0, 0, 0, 0, 0.2], size=[0.02], mass=0.3)
        self._r.actuator.add("position", name="act", joint=j, kp=kp, kv=kv)

实测命令 hinge 到 0.8,1 秒后到 0.8836

⚠️ 执行器名字是 'arm/act',不是 'act'(前缀规则)。

组合:桌子 + 铰链臂 + 自由方块

复制代码
nq=8  nbody=5
body = ['world', 'target_box/', 'target_box/body', 'arm1/', 'arm1/link']
qpos = ['target_box/', 'arm1/hinge']

17.5 composer.Task + composer.Environment

🔥 与 control.Task 的差异(实测)

复制代码
composer.Environment(self, task, time_limit=inf, random_state=None,
    n_sub_steps=None, raise_exception_on_physics_error=True,
    strip_singleton_obs_buffer_dim=False, max_reset_attempts=1,
    recompile_mjcf_every_episode=True, fixed_initial_state=False,
    delayed_observation_padding=ZERO, legacy_step=True)
control.Task(第 16 章) composer.Task(本章)
before_step (action, physics) (physics, action, random_state)顺序相反
观测 get_observation(physics) observables 机制
观测空间 observation_spec(physics) 自动从 observables 生成
终止判断 get_termination(physics) should_terminate_episode(physics)
控制周期 control_timestep= 参数 Task 的 control_timestep 属性
场景 外部传入 physics Task 的 root_entity 属性 ★

⚠️ before_step 参数顺序

control.Task(action, physics)composer.Task(physics, action, random_state)

从 16 章迁移到 17 章时这是最容易写错的地方。

⚠️ 观测要用 observables,不是 get_observation

composer.Task没有 get_observation / observation_spec 方法------

你写了也不会被调用(实测观测字典是空的 {})。

正确做法:

python 复制代码
from dm_control.composer.observation import observable

class ReachAngleTask(composer.Task):
    @property
    def task_observables(self):
        ang = observable.Generic(lambda ph: np.array([hinge_angle(ph)]))
        tgt = observable.Generic(lambda ph: np.array([self._target]))
        ang.enabled = True          # 🔥 默认是 False!
        tgt.enabled = True
        return {"angle": ang, "target": tgt}

🔥 observable.Genericenabled 默认是 False

忘了设,观测字典就是空的,而且不报任何错 ------ 这是本章最难查的坑。

逐行讲解:composer.Task 完整实现

下面对 ReachAngleTask 的每个方法做逐行讲解,重点说明它与 control.Task 的差异。

python 复制代码
class ReachAngleTask(composer.Task):
    """控制铰链臂转到目标角度。换一个 Arena 就是新场景。"""

    def __init__(self, arena, target_angle=0.6):
        # 构造函数
        # 输入:
        #   arena - 舞台对象(Arena 实例)
        #   target_angle - 目标角度,单位弧度(默认 0.6 rad ≈ 34.4°)
        self._arena = arena
        self._target = float(target_angle)
        self._arm = HingedArm(name="arm1")
        # 创建一个铰链臂 Entity。注意:Entity 是在 Task 的 __init__ 里创建的,
        # 不是在 _build 里------因为 composer.Task 没有 _build 方法。
        self._arena.attach(self._arm)
        # 把机械臂 attach 到舞台上(焊接,底座固定)。
        # 这一步是在 __init__ 里完成的,所以场景在 Task 创建时就已经组装好了。

    # ---- composer 特有的属性(与 control.Task 最大的区别)----
    @property
    def root_entity(self):
        # ⭐ 返回场景的根节点(Arena)
        # composer.Environment 会通过这个属性拿到完整的场景模型,
        # 然后编译成 Physics。这就是为什么 composer.Task 不需要外部传入 physics。
        return self._arena

    @property
    def control_timestep(self):
        # ⭐ 控制周期(替代 control.Environment 的 control_timestep 参数)
        # composer.Environment 没有 control_timestep 参数,必须通过 Task 的属性指定。
        # 返回 0.02 表示 50Hz 控制频率(每个控制步 = 10 个物理步)。
        return 0.02

    # ---- 生命周期(注意参数顺序与 control.Task 不同!)----
    def initialize_episode(self, physics, random_state):
        # ⚠️ 比 control.Task 多了 random_state 参数!
        # random_state 是 numpy 的 RandomState 对象,用于可复现的随机化。
        # 在 composer 中,域随机化应该用 random_state 而不是自己创建 rng,
        # 这样可以通过 composer.Environment(random_state=...) 来控制全局随机种子。
        physics.named.data.qpos["arm1/hinge"] = 0.0
        # 注意名字带前缀:"arm1/hinge",不是 "hinge"。
        # 这是 17.2 节讲的命名前缀规则------Entity 名 + "/" + 元素名。
        physics.forward()

    def before_step(self, physics, action, random_state):
        # ⚠️ 参数顺序是 (physics, action, random_state)!
        # control.Task 是 (action, physics),正好相反!
        # 从第 16 章迁移到第 17 章时,这是最容易写错的地方。
        physics.named.data.ctrl["arm1/act"] = float(
            np.clip(np.asarray(action).ravel()[0], -3.0, 3.0))
        # 把动作裁剪到 [-3.0, 3.0] 范围。
        # np.asarray(action).ravel()[0] 是为了兼容各种形状的输入
        # (标量、(1,)数组、(1,1)数组都能正确取出第一个元素)。

    def after_step(self, physics, random_state):
        # ⚠️ 也多了 random_state 参数
        pass

    # ---- 观测:用 observables,不是 get_observation!----
    @property
    def task_observables(self):
        # ⚠️ composer.Task 没有 get_observation 方法!
        # 观测是通过 task_observables 属性定义的,返回一个字典。
        # 字典的键是观测名,值是 observable 对象。
        ang = observable.Generic(lambda ph: np.array([hinge_angle(ph)]))
        # observable.Generic 接受一个函数,函数输入是 physics,输出是 numpy 数组。
        # 这个函数会在每个 step 被调用,返回当前的观测值。
        tgt = observable.Generic(lambda ph: np.array([self._target]))
        ang.enabled = True
        # 🔥 最重要的一行!observable.Generic 的 enabled 默认是 False。
        # 忘了设 enabled=True,观测字典就是空的 {},而且不报错------
        # 这是 composer 最难查的坑,没有之一。
        tgt.enabled = True
        return {"angle": ang, "target": tgt}

    def action_spec(self, physics):
        # 动作空间声明(与 control.Task 一样)
        return specs.BoundedArray((1,), np.float64, -3.0, 3.0, "target_angle")

    def get_reward(self, physics):
        # 奖励计算(与 control.Task 一样)
        return float(-abs(hinge_angle(physics) - self._target))

    def should_terminate_episode(self, physics):
        # ⚠️ 不叫 get_termination,叫 should_terminate_episode!
        # 返回值也不同:
        #   control.Task: None=继续, 0.0=成功, 1.0=超时
        #   composer.Task: False=继续, True=终止(成功或失败都返回 True)
        # 超时由 composer.Environment 的 time_limit 自动处理。
        return abs(hinge_angle(physics) - self._target) < 0.02

observables 机制详解

复制代码
observable 的工作流程:

1. 定义阶段(Task.__init__ 或 task_observables 属性)
   ┌─────────────────────────────────────────────┐
   │  ang = observable.Generic(func)              │
   │  ang.enabled = True  ← 必须设!默认 False    │
   │  return {"angle": ang, "target": tgt}        │
   └─────────────────────────────────────────────┘

2. 编译阶段(composer.Environment 构造时)
   ┌─────────────────────────────────────────────┐
   │  遍历 task_observables 中所有 enabled=True   │
   │  的 observable,注册到环境中                  │
   │  自动生成 observation_spec(从函数返回值推断) │
   └─────────────────────────────────────────────┘

3. 运行阶段(每个 env.step() 时)
   ┌─────────────────────────────────────────────┐
   │  对每个已注册的 observable:                   │
   │    value = observable._func(physics)         │
   │    存入 observation 字典中                     │
   │    (默认带一个 buffer 维度,shape=(1,1))    │
   └─────────────────────────────────────────────┘

4. 输出阶段(TimeStep.observation)
   ┌─────────────────────────────────────────────┐
   │  strip_singleton_obs_buffer_dim=False:       │
   │    {"angle": array([[0.3]]), ...}  shape=(1,1)│
   │                                               │
   │  strip_singleton_obs_buffer_dim=True:        │
   │    {"angle": array([0.3]), ...}     shape=(1,) │
   └─────────────────────────────────────────────┘

💡 为什么 observable 默认 enabled=False? 因为 composer 预定义了很多

Entity 级别的 observable(比如每个 body 的 xpos、xmat),如果默认全部启用,

观测维度会爆炸。所以 composer 的设计哲学是"默认关闭,需要哪个开哪个"。

但这也导致了"忘了开就观测为空"的坑------记住:写 observable 第一件事就是设 enabled=True

完整例子

python 复制代码
class ReachAngleTask(composer.Task):
    def __init__(self, arena, target_angle=0.6):
        self._arena = arena
        self._target = float(target_angle)
        self._arm = HingedArm(name="arm1")
        self._arena.attach(self._arm)

    @property
    def root_entity(self):            # ⭐ composer 靠它建 physics
        return self._arena

    @property
    def control_timestep(self):       # ⭐ 替代废弃的 n_sub_steps
        return 0.02

    def initialize_episode(self, physics, random_state):
        physics.named.data.qpos["arm1/hinge"] = 0.0
        physics.forward()

    def before_step(self, physics, action, random_state):
        physics.named.data.ctrl["arm1/act"] = float(
            np.clip(np.asarray(action).ravel()[0], -3.0, 3.0))

    @property
    def task_observables(self):
        ...

    def action_spec(self, physics):
        return specs.BoundedArray((1,), np.float64, -3.0, 3.0, "target_angle")

    def get_reward(self, physics):
        return float(-abs(hinge_angle(physics) - self._target))

    def should_terminate_episode(self, physics):
        return abs(hinge_angle(physics) - self._target) < 0.02


env = composer.Environment(task, time_limit=4.0,
                           strip_singleton_obs_buffer_dim=True)

实测(命令角度 0.6):

复制代码
control_timestep = 0.02   (来自 Task 的属性)
reset -> step_type=FIRST  obs={'angle': [0.0], 'target': [0.6]}

 step     angle     reward  step_type
    0    0.0308    -0.5692        MID
   15    0.4428    -0.1572        MID
   28    0.5868    -0.0132       LAST        <- 触发成功终止

⚠️ 观测多了一个 buffer 维度

复制代码
strip_singleton_obs_buffer_dim=False -> obs shape = {'angle': (1, 1), ...}
strip_singleton_obs_buffer_dim=True  -> obs shape = {'angle': (1,), ...}

observable 默认带一个 buffer 维(用于存历史),

喂给普通 MLP 时建议设 strip_singleton_obs_buffer_dim=True 压平。


17.6 模块化实战:同一批零件拼出两个场景

variation 初始化:composer 的域随机化方式

在第 15 章我们讲过域随机化------每个 episode 随机化物体位置、颜色、物理参数。

在 composer 中,域随机化有更规范的实现方式:initialize_episode 中的 random_state

python 复制代码
def initialize_episode(self, physics, random_state):
    # random_state 是 composer 传入的 numpy RandomState 对象
    # 用它而不是自己创建 rng,可以保证全局可复现

    # 1. 随机化物体位置(在工作空间内均匀采样)
    x = random_state.uniform(-0.3, 0.1)    # X 方向:臂伸展方向
    y = random_state.uniform(-0.15, 0.15)  # Y 方向:侧向
    z = random_state.uniform(0.02, 0.1)    # Z 方向:竖直(Z-up,物体在桌面上方)
    self._object.set_pose(physics, position=[x, y, z])

    # 2. 随机化物体颜色(视觉域随机化)
    rgba = list(random_state.uniform(0.2, 0.9, 3)) + [1.0]
    physics.bind(self._object_geom).rgba = rgba

    # 3. 随机化关节阻尼(物理域随机化)
    for j in self._arm_joints:
        damping = random_state.uniform(0.01, 0.3)
        physics.bind(j).damping = damping

    # 4. 重置机械臂到 HOME 位姿(带微小随机扰动)
    home_noise = random_state.uniform(-0.05, 0.05, 6)
    physics.named.data.qpos[self._arm_joint_names] = HOME + home_noise
    physics.forward()

💡 为什么用 random_state 而不是 np.random?

  1. 可复现 :composer.Environment(random_state=42) 可以固定全局随机种子,
    每次训练的初始状态完全一致,便于调试和对比实验。
  2. 隔离:每个 Environment 有自己的 random_state,不会与其他环境的随机数互相干扰。
  3. 并行:分布式训练时,每个 worker 可以有不同的 random_state,保证多样性。

在我们的 6 轴机械臂项目中,variation 初始化用于:

  • 每个 episode 随机化被抓取物体的初始位置(让策略学会从任意位置抓取)
  • 随机化物体颜色和大小(提升 Sim-to-Real 的视觉鲁棒性)
  • 随机化关节摩擦和阻尼(提升 Sim-to-Real 的物理鲁棒性)
  • 给 HOME 位姿加微小扰动(避免策略过拟合到精确的初始位置)

模块化实战的核心价值

复制代码
传统方式(XML 写死):
  场景A.xml → 复制 → 场景B.xml → 手动改 → 场景C.xml
  问题:改一个参数要改 N 个文件,容易遗漏,难以维护

composer 方式(代码拼装):
  Block() ─┐
  Table() ─┼→ build_scene("table") → Arena A
  HingedArm()┘
           └→ build_scene("floor") → Arena B
  优势:
    ✅ Entity 类写一次,到处复用
    ✅ 场景是函数,参数化生成
    ✅ 改一个 Entity,所有场景自动更新
    ✅ 可以用循环/条件/随机数动态生成场景

💡 在我们的 6 轴机械臂项目中

  • ArmEntity:6 轴机械臂(写一次,用于所有任务)
  • GripperEntity:夹爪(可以换不同型号)
  • ObjectEntity:被抓取物体(可以是方块、圆柱、球体)
  • TableArena:带工作台的场景
  • FloorArena:地面场景
  • PickTask:抓取任务(把物体从 A 点抓到 B 点)
  • ReachTask:到达任务(末端到达目标点)

组合方式:PickTask(TableArena(), ArmEntity(), ObjectEntity())

换一个 Arena 或 Object,就是一个新任务,不用写任何新代码。

python 复制代码
def build_scene(kind):
    ar = Arena(name=f"scene_{kind}")
    if kind == "table":
        t = Table()
        ar.attach(t)
        t.attach(Block(name="box1"), attach_site=t.attach_site)
        ar.add_free_entity(Block(name="box2", rgba=(0.9, 0.3, 0.2, 1)))
    else:   # floor
        # Z-up:plane 默认法线就是 +Z,不要加 euler(否则地面会立成竖直的墙)
        ar.mjcf_model.worldbody.add(
            "geom", name="floor", type="plane", size=[1, 1, 0.1])
        ar.add_free_entity(Block(name="box1"))
        ar.add_free_entity(Block(name="box2", rgba=(0.9, 0.3, 0.2, 1)))
        ar.attach(HingedArm(name="arm1"))
    return ar

实测:

场景 nq nbody body
table 7 7 world, table/, table/top, table/box1/, table/box1/body, box2/, box2/body
floor 15 7 world, box1/, box1/body, box2/, box2/body, arm1/, arm1/link

✅ 这就是 composer 的价值:

Block / Table / HingedArm 三个类一次写好,

换个 Arena 就是新场景,不用复制粘贴 XML。


17.7 ⚠️ composer 的常见坑

【坑 1】元素名自动加【entity 名 /】前缀

'body' → 'ball/body';嵌套 'table/block/body'。取数据时 KeyError

【坑 2】同一个 Entity 实例不能 attach 两次

复制代码
ValueError: The model specified is already attached elsewhere

想放两个方块就 new 两个实例。

【坑 3】attach 不加关节,物体是死的

attach → nq=0(焊住);add_free_entity → nq=7(能自由动)。

【坑 4】composer.Environment 没有 control_timestep 参数

n_sub_steps 也已废弃(DeprecationWarning)。

✅ 正确做法:覆盖 Task 的 control_timestep 属性。

【坑 5】observable 的 enabled 默认 False

不设 enabled=True,观测字典就是空的 {}不报错,最难查

【坑 6】composer.Task 没有 get_observation / observation_spec

写了也不会被调用;要用 task_observablesEntity.observables

【坑 7】attach 不能传 pos / quat

Entity.attach(entity, attach_site=None) 没有位置参数。

想摆位置:① 在 _build 里写死 ② 用 set_pose()

【坑 8】PD 增益过大 → PhysicsError

复制代码
dm_control.rl.control.PhysicsError: Physics state is invalid.
Warning(s) raised: mjWARN_BADQACC

composer 的 Environment 对发散很敏感。解决:降低 kp/kv,给关节加 damping

(本书的 HingedArmkp=2.0, kv=0.5, damping=0.05。)

【坑 9】Arena 默认没有地面,只有 headlight

需要地面就自己 add geom;需要额外灯光就自己加 light


17.8 常见错误速查

报错 / 现象 原因 解决
KeyError: 'body' 名字带了前缀 'ball/body' 或按后缀匹配
already attached elsewhere 同一实例 attach 两次 new 一个新实例
观测字典是空的 {} observable 没 enabled=True obs.enabled = True
观测 shape 多一维 (1,1) observable buffer strip_singleton_obs_buffer_dim=True
unexpected keyword 'control_timestep' composer.Environment 没这参数 覆盖 Task 的 control_timestep 属性
DeprecationWarning: n_sub_steps 已废弃 同上
PhysicsError: Physics state is invalid PD 增益过大发散 降 kp/kv,加 damping
TypeError: attach() got unexpected 'pos' attach 没有位置参数 set_pose()
before_step 里 AttributeError 参数顺序写反 (physics, action, random_state)
物体不动 用了 attach 而非 add_free_entity add_free_entity,或 Entity 内加 joint

17.9 动手练

练习详解与答案提示

练习 1:写一个 Entity

  • 目标:掌握 Entity 三要素
  • 关键步骤
    1. 继承 composer.Entity
    2. 实现 _build(self, name="block", ...) --- 在里面用 mjcf.RootElement 搭模型
    3. 实现 mjcf_model 属性 --- 返回 self._r
    4. (可选)实现自定义便捷属性,如 bodyattach_site
  • 验证blk = Block(); blk.mjcf_model 应返回 RootElement 对象
  • 常见错误 :忘记 mjcf_model 属性 → AttributeError 或场景编译失败

练习 2:命名前缀

  • 目标:理解 Entity 名前缀规则
  • 关键步骤
    1. ar = Arena(name="s")
    2. ar.add_free_entity(Block(name="ball"))
    3. p = mjcf.Physics.from_mjcf_model(ar.mjcf_model)
    4. 打印 list(p.named.data.qpos.axes.row.names) → 应为 ['ball/']
    5. 打印 list(p.named.data.xpos.axes.row.names) → 应包含 'ball/body'
  • 关键结论 :你写的元素名 'body' 实际变成了 'ball/body'(Entity 名 + "/" + 元素名)

练习 3:两种挂载对比

  • 目标 :理解 attachadd_free_entity 的区别
  • 关键步骤
    1. ar1.attach(Block(name="fixed"))nq=0(焊接,无自由度)
    2. ar2.add_free_entity(Block(name="free"))nq=7(freejoint,3平移+4旋转)
  • 选择依据 :固定不动的物体用 attach,可以移动的物体用 add_free_entity

练习 4:嵌套组合

  • 目标:掌握 Entity 挂到另一个 Entity 的 site 上
  • 关键步骤
    1. Table 类暴露 attach_site 属性(指向桌面的 site 元素)
    2. table.attach(Block(name="on_table"), attach_site=table.attach_site)
    3. 打印 body 列表 → 应包含三级前缀 'table/on_table/body'
  • 应用场景:把物体放在工作台上、把传感器装在机械臂末端、把工具挂在夹爪上

练习 5:composer.Task

  • 目标:实现完整的 composer.Task,掌握与 control.Task 的 6 处差异
  • 关键步骤
    1. root_entity 属性 → 返回 Arena
    2. control_timestep 属性 → 返回 0.02
    3. task_observables 属性 → 定义观测,必须设 enabled=True
    4. initialize_episode(physics, random_state) → 注意多了 random_state
    5. before_step(physics, action, random_state) → ⚠️ 参数顺序与 control.Task 相反
    6. should_terminate_episode(physics) → 返回 True/False(不是 None/0.0/1.0)
    7. composer.Environment(task, time_limit=4.0, strip_singleton_obs_buffer_dim=True)
  • 预期结果:命令角度 0.6,约 28 步触发 LAST(成功终止)
  • 最常见错误 :忘记设 observable.enabled=True → 观测字典为空 {},不报错

练习 6:复用

  • 目标:用同一批 Entity 拼出不同场景
  • 关键步骤
    1. 定义 BlockTableHingedArm 三个 Entity 类
    2. build_scene("table") → Arena 包含 table + box1(在桌上)+ box2(自由体)
    3. build_scene("floor") → Arena 包含 floor + box1 + box2 + arm1
    4. 对比两个场景的 body 列表和 nq
  • 关键结论:Entity 类写一次,通过不同的组装函数生成不同场景,无需复制代码
  1. 写一个 Entity :实现 Block 的三要素,确认 mjcf_model 能返回 RootElement

  2. 命名前缀 :把 Block 挂到 Arena 上,打印 named.data.qpos.axes.row.names

    确认 'body' 变成了 'xxx/body'

  3. 两种挂载对比 :分别用 attachadd_free_entity 放同一个 Block,

    对比 nq(0 vs 7)。

  4. 嵌套组合 :写一个带 attach_siteTable,把 Block 挂到桌面上,

    观察三级前缀。

  5. composer.Task :实现 root_entity + control_timestep + task_observables

    composer.Environment 跑一个 episode(参考答案里 28 步触发 LAST)。

  6. 复用 :用同一批 Entity 拼出「桌面场景」和「地面场景」两个 Arena,

    对比生成的 body 列表。

参考答案见 code/ch17_dm_control_composer.py


17.10 小结

本章知识地图

复制代码
composer 进阶
├── 核心思想
│   ├── Entity = 零件(可复用的模型组件)
│   ├── Arena = 舞台(地面、灯光、坐标系)
│   └── Task = 规则(组装场景、定义奖励与终止)
│
├── Entity(三要素)
│   ├── _build():构造函数,在里面搭 MJCF
│   ├── mjcf_model 属性:必须暴露,返回 RootElement
│   ├── 自定义便捷接口:body / attach_site / joint
│   └── ⚠️ 命名前缀:entity名/元素名(嵌套时三级)
│
├── Arena(舞台)
│   ├── 自带 angle=radian(规避第15章的角度陷阱)
│   ├── attach(entity):焊接(nq=0,无自由度)
│   ├── add_free_entity(entity):自由体(nq=7)
│   ├── entity.set_pose(physics, position=...):设置位姿
│   └── entity.get_pose(physics):读取 (position, quaternion)
│
├── 四种组合方式
│   ├── ① add_free_entity:自由移动(nq=7)
│   ├── ② attach:焊死(nq=0)
│   ├── ③ 嵌套 attach:挂到另一个 Entity 的 site 上
│   └── ④ Entity 内部自带 joint+actuator:最灵活(推荐)
│
├── composer.Task(与 control.Task 的 6 处差异)
│   ├── before_step(physics, action, random_state)  ⚠️顺序相反
│   ├── 观测用 observables(不是 get_observation)
│   ├── 终止用 should_terminate_episode(返回 True/False)
│   ├── 控制周期用 Task.control_timestep 属性
│   ├── 场景用 Task.root_entity 属性(自包含)
│   └── 多了 random_state 参数(可复现随机化)
│
├── observables 机制
│   ├── observable.Generic(func):定义观测
│   ├── 🔥 enabled 默认 False(必须设 True,否则观测为空)
│   ├── task_observables 属性:返回观测字典
│   └── strip_singleton_obs_buffer_dim=True:压平 buffer 维度
│
└── 常见坑
    ├── 同一 Entity 实例不能 attach 两次
    ├── attach 不加关节,物体是死的
    ├── composer.Environment 没有 control_timestep 参数
    ├── observable.enabled 默认 False
    ├── composer.Task 没有 get_observation
    ├── attach 不能传 pos/quat(用 set_pose)
    ├── PD 增益过大 → PhysicsError
    └── Arena 默认没有地面(只有 headlight)

在 6 轴机械臂项目中的应用

知识点 项目中的具体用途
Entity 三要素 封装 6 轴机械臂、夹爪、被抓取物体为可复用组件
Arena 创建带工作台和地面的仿真场景,自带 angle=radian
attach() 机械臂底座焊接到地面、工作台固定
add_free_entity() 被抓取物体可以自由移动和旋转
root_entity Task 自包含场景,不需要外部传入 physics
control_timestep 属性 50Hz 控制频率,与真实工业机械臂一致
task_observables 定义观测:6关节角 + 末端位置 + 物体位置 + 目标位置
random_state 每个 episode 随机化物体初始位置、颜色、物理参数
should_terminate_episode 抓取成功或物体掉落时终止 episode

扩展阅读方向

  1. dm_control composer 官方教程https://github.com/google-deepmind/dm_control/tree/main/dm_control/composer --- 包含更多 Entity 示例和高级用法
  2. Domain Randomization for Sim-to-Real:Tobin et al. 2017 --- composer 的 random_state 机制正是为这种方法设计的
  3. Roboschool / Gym 机器人环境:很多开源机器人环境都是用 composer 构建的,可以参考它们的 Entity 设计
  4. 下一部分预告:第 18~23 章将进入 6 轴机械臂实战------把 ikpy / MuJoCo / dm_control 三套工具串起来,完成完整的抓取放置任务,包括模型解析、运动规划、视觉感知和强化学习训练。
  • composer = Entity(零件)+ Arena(舞台)+ Task(规则)
  • Entity 三要素:_build() / mjcf_model 属性 / 你自己的便捷接口。
  • ⚠️ 元素名自动加 entity名/ 前缀,嵌套时是三级。
  • Arena 自带 angle="radian",规避了第 15 章的角度单位坑。
  • attach 不加关节(nq=0);add_free_entity 才给自由度(nq=7)
    最灵活的做法是在 Entity 内部自带 joint + actuator。
  • 🔥 composer.Taskcontrol.Task 6 处差异
    • before_step(physics, action, random_state)顺序相反
    • 观测用 observables ,不是 get_observation
    • 终止用 should_terminate_episode,不是 get_termination
    • 控制周期用 Task 的 control_timestep 属性n_sub_steps 已废弃)
    • 场景用 Task 的 root_entity 属性
    • 多了 random_state,支持可复现随机化
  • 🔥 observable.Genericenabled 默认是 False ------ 忘了设则观测为空且不报错。
  • 观测默认多一个 buffer 维,用 strip_singleton_obs_buffer_dim=True 压平。
  • composer 对发散敏感,PD 增益要温和(加 damping)。

第 5 部分(dm_control)到此结束。

下一部分:6 轴机械臂实战 ------ 把 ikpy / MuJoCo / dm_control 三套工具串起来,

完成完整的抓取放置任务。


上一章:16 · 强化学习接口 | 下一章:18 · 机械臂模型解析

相关推荐
L@ncor1 小时前
第二章 智能体发展史 · 学习笔记
人工智能·python
asdzx671 小时前
Python 实战:基于 Spire.PDF 为 PDF 文档添加自定义文本
python·pdf
m0_547486662 小时前
《Python数据分析与可视化项目教程》全套PPT课件2026
python·数据分析·数据可视化
hqyjzsb2 小时前
规划工商管理大学成长:搭建四层能力体系,重视高阶的 AI 能力建设
开发语言·人工智能·python·microsoft·职场和发展·数据挖掘·业界资讯
我不会起名字3222 小时前
一天一道算法题(35):电话号码的字母组合
java·数据结构·后端·python·leetcode·go·回溯
冯一川2 小时前
DeepSeek在Windows系统上部署
windows·python
小叶肥辉2 小时前
LangChain链和LangGraph图的学习笔记【六】——提示语模板(3)——Few-Shot Prompting(少样本提示) 模板类
笔记·python·学习·langchain·prompt·aigc
for_ever_love__2 小时前
爬虫项目: 获取高分电影的数据总结
开发语言·python·学习
文人sec2 小时前
MYSQL:insert...select:为什么锁源表的所有行和间隙?怎么最快地复制一张表?
数据库·python·mysql