用家用 CPU 让一具只有骨与肌的人体站立、抓握、喝水、跳舞的全记录 —— 700 条肌肉、吉祥物“evis”,以及诚实的输与赢
适合阅读的人: 对机器人、生物力学、进化计算、MuJoCo、动作捕捉感兴趣的人 / 对“靠肌肉驱动的人体模型”或“在自家让一具解剖学意义上的人体动起来”感兴趣的人 / 比起漂亮的结果,更想读到诚实的内幕拆解,以及从卡住到修好的全过程的人。
前提知识: 能读懂 Python 就足够。控制、生物力学、mocap 的术语会在出现时逐一讲清楚。
所有数值都在笔记本电脑的 CPU 上实测得到。这篇文章不是成功学,而是一段把“一方诚实地输了、另一方赢了”的两种结果用同一把尺子去度量,一旦卡住就不去猜测而是去测量、去修正的、连贯的记录。
这篇文章,是把此前分开写的三篇连作 —— 「站立·抓握·搬运」「喝水(追踪 bug)」「evis 吉祥物化(mocap)」—— 按时间线与因果关系连成一体的决定版。篇幅很长,所以你从感兴趣的章节读起也没问题。
0. 三行总结(先给结论)
-
由骨、腱和 700 条肌肉构成的全身人体模型(中国 LNS Group 的
MS-Human-700,ICRA 2024,Apache-2.0),被搬上家用 CPU 里的 MuJoCo,用进化一条一条地驱动肌肉,让它去「站立」「抓握」「搬运」「喝水」,最后把它做成吉祥物“evis”,用 mocap 让它跳舞、用筷子吃东西。关键在于:驱动它的不是扭矩电机,而是只能产生收缩力的肌肉。 - 站立诚实地输了,抓握赢了,搬运是部分胜利(三分之一)。一具什么都不做就会在约 1 秒内崩塌的骨架,通过闭环控制把崩塌延迟到了典型的 2.5 秒,但撑不到 6 秒(输)。另一方面,固定住肩部的手臂模型,那只由 81 条肌肉构成的手,成功抓起桌上的水瓶并抬高 0.82m 保持住(赢)。再加上「搬运到指定位置」的任务后,变成部分成功(三条指令中严格意义上成功的只有 1 条 = 三分之一)。为什么任务不同,输赢也随之不同,正是前半部分的主角。
- 后半部分是“测量再修正”的两连击。以为“喝到了”的那个动作,被人一句「没进到嘴里」而否决,一路追下去,竟落到水瓶穿透地板沉入地下的 bug(keyframe 的编号错位)。只要在一个正确的位置插入一个 0,就修好了。接着把骨架命名为 evis 并移植 mocap,这次又冒出**「腰像老人一样弯着」**的驼背 bug。停止猜测、去测量关节角之后,真凶原来是「把 marker 的高度弄错了」。猜测之前先测量——这就是贯穿全文的通奏低音。
先把诚实的注解放在前面: 站立、抓握、搬运、喝水(手臂)都是由肌肉驱动的真实过程,但 evis 的舞蹈、筷子、以及喝水姿态的一部分,混入了运动学回放(直接给定关节角的演出层)。哪里是真实、哪里是演出,会在各处以及最终章中明确标注。
↑ 这篇文章的终点: 走→跑→跳→莎莎舞→踢腿→用筷子吃东西。全部是同一具骨架(evis),把免费的 mocap 移植过来驱动它。走到这一步之前经历的「输」与「bug」,下文都会连同每一个测出来的数字一并留存。
1. 术语(先画一张地图)
这是一篇很长的文章,所以先把这些词做成一张地图。已经熟悉的行可以直接跳过。
| 术语 | 通俗解释 |
|---|---|
| 肌肉骨骼模型(musculoskeletal model) | 不仅有骨骼和关节,还带上肌肉和肌腱的人体仿真。这次是带 700 条肌肉的全身模型 |
| 肌肉致动器(muscle actuator) | 以「肌肉」为动力源的驱动。和电机不同,它只能产生收缩(拉)的力,力的大小由 0〜1 的活性决定,是个非线性的家伙 |
| 自由度(DoF / degrees of freedom) | 能够独立运动的关节轴的数量。步行用模型是 36,操作用是 48,全身是 85 |
| 冗余(redundant) | 肌肉数量远远多于想要驱动的自由度的状态。「正确的肌肉组合」有无穷多个,搜索起来很难 |
| 肌肉协同 (muscle synergy) | 把 100 条肌肉压缩成少数(K 条)「成组一起动的模式」的表示。据说人脑也是这样把肌肉捆在一起的 |
| 闭环控制(closed-loop) | 一边观察身体的状态(倾斜、速度、触地)一边调整肌肉的控制。不看就一口咬定的是开环(open-loop) |
| 倒立摆(inverted pendulum) | 倒着立起来的棍子。放着不管就会倒。双足站立的本质,就是不停地把它重新扶正 |
| CMA-ES(进化策略) | 通过对解的群体做变异、选拔来使其变好的优化方法。这次让控制策略里数百〜上千个数值进行进化 |
| 基线(baseline) | 学习前、无控制的对照。用来衡量「学习到底带来了多少改善」的出发点 |
| RTF(实时因子 / real-time factor) | 仿真比实时快多少。13× 表示 1 秒的物理用大约 0.08 秒算完(强烈依赖 CPU 和并行度) |
qpos |
把模型的所有关节的位置排成一列的数组。是后半段的关键。一旦增加关节,这个排列就会错位 |
| 关键帧 (keyframe) | 把「实验开始时的姿势」照 qpos 的排列顺序原样写下来的初始化数据。由位置(编号)决定是它的要害 |
| 温启动 (warm-start) | 学习不是「从零开始」,而是从「已经会的策略」开始 |
| 动作捕捉 (mocap) | 记录了人的动作的关节角时间序列。这里用的是免费的 BVH 文件 |
| 动作重定向 (retargeting) | 把某个骨架的动作,移植到体型和关节结构都不同的另一个骨架上 |
| IK(逆运动学 / Inverse Kinematics) | 从「想把手末端放在这里」反算出关节角的计算 |
| 运动学回放(kinematic) | 不跑物理(力、肌肉),而是直接设置关节角来生成画面。只做出动作的外观 |
| 等式约束(equality constraint) | 「这个关节由那个关节的函数决定」这样的联动规则。膝盖和肩膀里塞了一大堆(即后文的陷阱) |
第 I 部 —— 让一具 700 条肌肉的骨架,在家用 CPU 上站立、抓握、搬运
2. 为什么选“全身骨架”,以及为什么先做调研
在开始一件新事之前,先做调研。这是我的习惯。这次的题材是 MS-Human-700——由中国 LNS Group 制作、在 ICRA 2024 上发表的全身肌肉骨骼模型(Zuo, He, Shao, Sui, Self model for embodied intelligence)。它以 Apache-2.0 公开,并被收录进 MuJoCo 的官方模型集 Menagerie。这是一具包含骨、腱以及 700 条肌肉的、名副其实的“全身骨架”。
读完论文后,我诚实地调整了自己的预期。这篇论文的主旨在于“把 700 条肌肉这样的高维度,用**分层的低维表示(≒肌肉协同,muscle synergy)**来控制,并学习行走”。也就是说,要让它像样地动起来,朴素的方法是不够的——这一点是作者自己说的。
提前给出的教训:“用肌肉让人体站起来”即便投入 GPU 集群和分层强化学习也很难,是一个货真价实的研究课题。而我却打算用笔记本电脑的 CPU 加进化策略(evolution strategy)来做,所以从一开始就要把失败的可能性算进去。输了就写输了——这正是本系列的脊梁。
我看重的并不是“成功让它站起来”这个结果本身。用同一把尺子,诚实地归属出走到了哪里、又在哪里停下——这才是我今天想写的东西。
3. 舞台:把 700 条肌肉的人体,装进家用 CPU
首先,我把 MS-Human-700 加载进了自研的研究运行时 onocollo(一个套在 MuJoCo 之上的、带类型的运行时)。它有三个变种。
| 变种 | 自由度 | 肌肉 | 用途 |
|---|---|---|---|
| 全身(full) | 85 | 700 | 全身所有的肌肉 |
| 行走用(locomotion) | 36 | 100 | 简化上半身。用于站立、行走的实验 |
| 操作用(manipulation) | 48 | 81 | 右臂与精细的手+桌上的水瓶 |
有几个重要的事实。
- 致动器全部都是“肌肉”。不像机器人的电机那样能“从 +3 N·m 到 −3 N·m”双向转动,肌肉只能沿收缩(拉)的方向发力。活性值在 0〜1 之间,由一种依赖力‐长度‐速度的非线性 Hill 型模型来决定发出的力。所以要“想把关节往右转就用右侧的肌肉,往左就用左侧的肌肉”,必须成对地准备,而且数量比自由度还多(=冗余)。
- 膝盖在生物力学上非常精密。它不是简单的合页,而是用多个辅助关节和约束式,再现了股骨与胫骨之间滚动加滑动的运动。
- 骨盆是“浮动基座(floating base)”——不固定在地面上,可以通过 3 个方向的平移和 3 个方向的旋转自由运动。在初始姿态下,它以 0.95m 的高度直立着。
- 速度:这个模型在笔记本电脑(Intel Core,Ice Lake 世代的移动 CPU)的单线程、空闲状态下,可达 RTF 约 13 倍(6 秒的物理约 0.46 秒,~6500 步/秒,dt=0.002s)。不过这是独占时的数值,为了进化而用 7 个核心并行跑起来时,每个进程会降到 ~3〜5 倍(核心互相争抢)。RTF 会随 CPU 型号、并行度、积分器设置产生这么大的变化,所以数字务必要连同条件一起读。即便如此,这个速度也确实达到了让“成千上万次 rollout”的进化能够实际跑起来的程度。
4. 先测一个诚实的基线——然后看着既有控制器"崩掉"
在做新的控制之前,一定要先测一个无学习的基线(baseline)。这样当后面出现异常好看的数字时,我才能回头质疑它——先把出发点钉死在原地。在步行用模型上(带地面),做 6 秒的 rollout,并规定"骨盆掉到 0.6m 以下即视为跌倒"。
| 控制 | 跌倒前的时间 | 结局 |
|---|---|---|
| 无控制(全部肌肉松弛) | 0.50 秒 | 崩塌 |
| 全部肌肉最大收缩 | 0.99 秒 | 崩塌 |
| 每一步随机数 | 1.00 秒 | 崩塌 |
什么都不做,人体骨架大约 1 秒就会瘫倒。把所有肌肉都最大程度地绷紧,刚度上去了,能续命一点点,但依然站不住。这就是站立任务的起点。
而就在这里,我实测到了既有工具会"崩掉"——这是这次在技术上最有意思的一个发现。onocollo 里本来就有一个"让 Menagerie 里机器人的控制进化"的控制器。但它的设计是让每个致动器去读取自己所负责关节的角度再动作。可肌肉并没有"自己负责的那唯一一个关节"(它经由肌腱跨越多个关节)。结果就是,全部 100 条肌肉收到的是相同(为零)的输入,又因为共享权重,这 100 条肌肉给出的活性一模一样。
100 条肌肉在每一瞬间都以完全相同的强度收缩——这样绝对不可能做到双足站立。我用数值确认了这种退化(独一无二的活性值种类 = 1)。所以,肌肉模型需要一种新的表示:"看着全身的状态,给每一条肌肉发出不同的指令"。这与论文所说的"分层的低维表示"在必要性上恰好吻合。
5. 站立任务:肌肉协同 (muscle synergy) × 进化 —— 然后诚实地摔倒
我做的控制是这样的。
- 读取全身的本体感觉(proprioception,自体感受):骨盆高度的误差、倾斜(前后/左右/扭转)及其角速度、骨盆的平移速度、左右脚的触地——共 14 个对平衡起作用的量。
-
落到肌肉协同上:把这个状态线性映射(
tanh)到 **K 条"成组一起动的模式"**上,再用一个K×100的矩阵把它展开成 100 条肌肉的活性(用sigmoid压到 0~1)。K=6 时策略的数值约为 790 个。这就是我对论文所说"低维表示"的自己的实现。 - 用 CMA-ES 进化:用进化策略来最优化这些数值。奖励是"站着期间,高度越接近目标、上身越挺直、骨盆速度越小,得分越高,跌倒即终止"。
一次诚实的设计修正:最初的奖励只是"高度接近目标就加分"。于是进化变得投机,靠**『慢慢地、优雅地倒下去』的策略来刷分(因为倒下的途中高度也还算高)。看上去根本没在站。于是我给骨盆速度加了惩罚项**,把它改成"安静地停住不动"要比"慢慢倒下"得分更高。这是对量尺的一次调整,用来封住这种投机。
为了不浪费地跑计算,我还写了一套只把模型加载一次、用 7 个核心并行评估的机制(模型加载约需 2 秒,而一次 rollout 只有几百毫秒,所以每次都重新加载的话,加载就会成为主导开销)。让每个 worker 只加载一次然后反复复用,就能拿到与并行度相当的加速。
结果是——诚实地说,没够到目标。如果只列出最优值(best),就成了"拿一座山峰当代表值",所以我把典型值(进化后半段各世代 best 的中位数)也一并列出。先看单个随机种子下的结果:
| 控制(单一种子) | 策略的数值 | 典型(中位数) | 最优(best) | 结局 |
|---|---|---|---|---|
| 基线(无控制) | 0 | — | 0.5~1.0 秒 | 崩塌 |
| 静态肌张力(常数) | 100 | ~2 秒 | 3.02 秒 | 最终跌倒 |
| 肌肉协同(闭环) | 790~1020 | ~2.5 秒 | 3.71 秒 | 最终跌倒 |
| 肌肉协同+记忆(积分项) | 808 | ~3.0 秒 | 4.53 秒 | 最终跌倒 |
看着这张表,我一瞬间这么想了——"加上记忆(积分项)后,从 3.71 → 4.53 秒变长了。跟假设一样,平衡确实需要对误差做积分"。平衡控制在理论上本来就该如此(积分控制),所以这套说法讲起来也很漂亮。
差点被 n=1 骗了——把随机种子增加到 3 个,差异就消失了
可 CMA-ES 是随机的。单一随机种子上的差异,也许只是种子的运气好坏。于是我把"有记忆/无记忆"各自跑了 3 个种子并取中位数(做公平的比较)。
| 控制 | best 的中位数(3 种子) | best 的离散范围 | 典型(median-late)的中位数 |
|---|---|---|---|
| 无记忆(plain) | 3.55 秒 | 3.16~3.80 秒 | 2.71 秒 |
| 有记忆(积分项) | 3.56 秒 | 3.34~4.17 秒 | 2.39 秒 |
结论反转了。best 的中位数是 3.55 ≒ 3.56,几乎相同,而典型值上有记忆的反而更低(2.39 < 2.71)。也就是说,单一种子里看到的 4.53 秒只是同一分布中碰巧偏高的一个尖峰,并不能作为记忆项起了作用的证据。我那个"记忆有用"的漂亮假设,在 n=3 下没有得到支持。
- 闭环控制把崩塌从约 1 秒 → 典型 2.5 秒、最优 3.5 秒上下推迟了。它明确超过了基线,而且闭环比静态肌张力更好。倍率的分母保守地取"无控制 ~1.0 秒"(如果拿全部肌肉松弛的 0.50 秒当分母,数字会翻倍,但那是夸大)。在这个范围内,有没有记忆并无显著差异。
- 但它没法持续站满 6 秒。即便是最优的策略,最后一帧也是躺在地上。
- 教训:n=1 的"起作用了!"在随机优化里往往是假的。越是漂亮的假设,越要在取到多个种子的中位数之前不去相信它。这一件事,正是我在发布前让内容过一遍独立进程的 fact-check这一做法实际奏效的例子(fact-check 指出"单一种子上的因果结论下得太重"→ 用 3 个种子验证 → 结论反转)。
为什么够不到?坦白说,用 CMA-ES 去最优化一个多达约 800 维的线性协同策略,对于倒立摆的恒定稳定化来说力量不足(就算加上简单的积分式记忆,如上所见也救不回来)。这与论文需要用到分层强化学习并不矛盾。在这里把它圆成"站起来了!"很容易,但既然实际上并没有持续站住,那我就不这么写。
6. 抓握任务:换个舞台,就赢了
站立之所以困难,是因为它本质上是一个"倒立摆(inverted pendulum)"。那么,换成一个不必担心摔倒的任务又会如何?——用于操作(manipulation)的那个变体,肩膀以上是固定的,骨盆没有自由关节。也就是说,它根本没有失去平衡的可能。任务纯粹变成了"用手臂和手去做点什么"。
于是,我让一只由 81 条肌肉构成的右手,去抓起桌上的水瓶并把它举起来。手一开始就在离水瓶约 0.1m 的地方,所以真正的任务并不是"靠近"——而是抓住、举起、并保持。
这次,我把同一个肌肉协同(muscle synergy)策略,以"水瓶举得越高得分越高"的方式进行进化。结果:
| 世代 | 水瓶的举起高度 |
|---|---|
| 0(初始) | 0.006 m(≈几乎为零) |
| 20 | 0.39 m |
| 50(收敛) | 0.825 m |
这只由 81 条肌肉构成的右手,抓住了桌上的水瓶,举起 0.82m 后静止并保持(相当于喝水动作的前一阶段——直到"送到嘴边"之前为止)。
诚实地质疑"抛掷"和"兜起来"
以最大化高度为目标的奖励,用"把水瓶往上打飞"这种作弊也能拿到分。所以我做了两个阶段的验证。
(1) 是不是抛掷:举起之后的 3 秒内,手掌(腕骨中央的骨头)与水瓶中心的距离一直保持在 0.052〜0.062m(不发散)。如果是抛掷,距离会发散。二者是一起运动的。
(2) 是不是真的"用手指握住"(而不是兜起来):距离恒定只能说明"一起动了"。为了排除用前臂夹着搬运(兜起来)的可能性,我检查了保持过程中的接触力。触碰水瓶的是9 根手指与手部骨头——拇指(远端为 40N)、食指(掌骨处为 33N)、中指、无名指——并且拇指与其余手指相对(对向)握持。水瓶的重量是 5.3N,所以是以远超其重量的对向力在夹持。这不是兜起来,而是收拢手指的真正抓握(force-closure,力封闭)。
诚实披露:这次抓握是在摩擦系数 μ=1.0(接近橡胶的偏高摩擦)的接触条件下成立的。摩擦越高,抓握越容易。如果把 μ 调低,同一个策略完全有可能滑落——我必须写明,"抓住了"是在这个摩擦条件下才成立的说法。
这同时也是对我以前一篇文章里"让 AI 义手拿筷子"时留下的未竟课题——"没有手(是直接操纵筷子的)"这个问题的用真正解剖学意义上的手给出的回答。一只连拇指、蚓状肌、骨间肌都齐全的手,仅凭肌肉的力量抓住了物体。
7. 抓住之后:搬到命令指定的地方(可控操作)
"抓住并举起"其实是一个"往上举、随便哪儿都行"的单向任务。真正的操作(manipulation),是把抓住的物体搬到我们命令指定的地方。于是我给策略看了"目标 − 水瓶"这个向量(把观测增加了 3 维),测试用同一个控制,能否听从 3 个不同的目标(正上方/体侧的喝水姿势/前方略偏上)。如果一个策略能按命令改变搬运的目的地,那就可以说它是"可控的(controllable)"。
然而,朴素地做起来,又再一次掉进了奖励作弊(reward hacking)。如果只设"水瓶越靠近目标得分越高",抓握的梯度就消失了。因为水瓶的初始位置本来就靠近目标点、能薅到最近距离的底分,于是优化就"放松肌肉,把水瓶留在桌上不管",靠不动来拿分。就算跑了 200 世代,到目标的最近距离也丝毫没有改善,一直冻结着——分数在涨,却既没抓也没搬。这是 §5 里"慢慢倒下"那套欺骗的兄弟。
修正的办法有两个:
- warm-start(温启动):把 §6 中已验证的抓握策略作为初始值。因为观测的维度不同(抓握 19 → 搬运 22),我把新增的命令向量的输入权重全部填零,让初始状态与抓握策略完全一致。这样进化要做的事就变成了"在能动的抓握之上,只加一个命令跟随",从一开始就越过了"放着不管"的那个洼地。
- 保持抓握的加分 + 终端门控:给手与水瓶的靠近程度加分,而终端奖励只在持续抓住时才生效(掉落/抛掷则 0 分)。
结果,分数从"放着不管"作弊的底分(约 18.9)一跃升到约 1021,内容也变成了真材实料:
| 命令的目标 | 最近距离 | 是否持续抓住? | 是否放到目标?(12cm 以内) |
|---|---|---|---|
| 正上方 +0.30m | 0.103m | ✓ | ✓(最终 0.108m) |
| 体侧·喝水姿势 | 0.265m | ✓ | ✗ |
| 前方略偏上 | 0.120m | ✓ | 临界 |
在 3 个命令下都持续抓住了水瓶(手掌〜水瓶 0.043〜0.046m),并朝命令的方向搬运。正上方一直成功到放下为止,前方略偏上是临界。诚实的局限在于"体侧的喝水姿势",差 0.27m 没有够到——从正上方抓握 warm-start 而来的单一线性协同,在把手臂拉回体侧的姿势上容量不足。抓握是完整的,命令跟随则有三分之一严格成功。不夸大,把它作为部分意义上的"可控操作"放在这里。
而这种搬运最有意思的应用,就是"搬到嘴边"——也就是进食、喝水动作。把命令目标切换成"体侧·嘴边",肌肉骨骼的手就会试图把抓住的水瓶朝身体方向拉近(见下面的 GIF)。不过——这恰恰正是上表中差 0.27 m 停住的那个最难目标,在这个时间点上还没能够到嘴。
当时我几乎要下结论:"够不到的原因,是单一线性协同的容量不足。"——但那是错的。接下来的第 II 部,就是把这个错误测出来、并揭穿它的故事。
8. 为什么一边输了,另一边却赢了
明明是同一个模型、同一种控制表征、同一套优化,站立却输了,抓握却赢了。决定性的差别,我认为在于任务的物理稳定性。
- 站立是不稳定平衡。倒立摆,哪怕只是一点点倾斜,也会呈指数级增大。控制必须"不停地、迅速地、正确地"持续扶正,只要稍有差错就会发散、摔倒。对没有记忆的线性策略来说,这份担子太重了。
- 抓握(在这个设定下)是稳定的。肩膀被固定,目标(举高)一旦达成就容易维持。控制上的一些粗糙不至于致命。
不过,若断言稳定性是唯一的差别,那就不诚实了。至少存在三个混杂因素:①触及(reach)已经解决了(抓握时手一开始就在离水瓶 0.1m 的地方)、②奖励的直白程度(抓握是"越高越好"这种单调而稠密的奖励/站立是基于存活时间,于是出现了"慢慢倒下"的欺骗)、③自由度的差异(抓握是肩膀固定,没有那个把站立害死的、不稳定的浮动基座 6 自由度)。主因是稳定性,但这些也在起作用——过于漂亮的故事会把单一原因夸大。
而 §7 的搬运(place),是这个判断的第三个数据点。舞台和抓握是同一个稳定的操作变体(不会摔倒),可就在往其中加上"搬到命令指定的地方"这一控制性要求的一瞬间,就从完全胜(抓握)跌到了部分胜(三分之一)。难度不仅取决于任务的稳定性,也取决于所要求控制的"聪明程度"。
收获可以这样总结:"AI 能做什么/不能做什么",不仅由策略的聪明程度决定,还由任务本身的稳定性(以及立足点、奖励、自由度、所要求的控制性)决定。同一件工具,选对了擂台就能赢,选不对就会诚实地摔倒。而且在这三个任务里,我都在公开之前用一次确认,揪出了"分数在涨、内容却跟不上"的欺骗(慢慢倒下/把水瓶放着不管)——这就是本文一以贯之的做法。
第 II 部 —— 让它喝水:从一句“根本没进嘴”开始,追踪那只穿地而过的瓶子
在第 I 部的 §7 中,我写道喝水动作“在离目标 0.27m 处停住了,这是容量的极限”。从这里开始,是我通过测量意识到那个结论是错的、并把它修正的过程。这篇文章的主角不是“喝到了”,而是在那之前的、朴素却又是每个碰 MuJoCo 的人都会踩一次的 bug。
9. 起点:一句“根本没进嘴”
在第 I 部之后,我把目标点重新放到“真正的嘴”上,然后判定:在同一套控制、同样的容量下,手竟然够到了嘴边。数字上确实够到了(瓶子中心与目标点的距离只有几厘米)。我甚至已经开始动笔写“喝水动作做成了”。
然而,看过渲染出来的视频的那个人(给我指示方向的一方)回给我三条否决意见。
- “根本没进嘴”——瓶子停在喉咙、锁骨一带,根本没够到真正的下颌(下巴)。
- “嘴也没张开”——嘴自始至终是闭着的。看上去根本不像在喝。
- “看不出在干什么”——只有一台正面摄像机,读不出手臂在怎么动。
这正是这个系列一直恪守的作法——“一旦出现异常好的结果,在自以为赢了之前先质疑内幕”——借由他人的眼睛发动起来的一刻。我相信了“距离只有几厘米”这个数字,却没有质疑画面。而人看了画面,一眼看穿了数字的谎言。诚实地,从撤回主张开始。
10. 第一处订正:目标原来是“喉咙”的高度
先看 (1)。我当作“嘴”设定的目标点是 (0.07, 0, 1.46)。可是一量真正的下颌几何,下颌底部高度约在 1.50m,嘴唇一带则在 1.55m 附近。我的目标 1.46 比下颌底部还低,也就是喉咙、锁骨的高度。
瓶子被搬到的不是“嘴边”而是“喉口”,并在那里停住。距离数字之所以小,是因为它确实准确够到了那个错误的目标——只要目标放错了地方,达成率越高质量反而越差。我把目标改成真正的嘴唇高度 (0.05, 0, 1.55)。
这同时也是对我自己在第 I 部 §7 写下的那句“单一线性协同缺乏把手臂拉回体侧的容量(所以够不到)”的自我订正。够不到的真正原因不是容量,而是把目标放错了位置。把责任推给容量,永远是最省事的逃路。不过为免误解——这里被推翻的只是“够不到嘴的原因是容量”这一错误归因,而 §7 中看到的“把手臂精确放到任意一点的精度只有三分之一”这一单一线性协同的容量极限本身,如今依然成立。
预支一条教训:在下结论说“这是算法的极限”之前,先质疑评分表(目标、奖励)是否正确。把停滞归因于能力,通常都为时过早。
11. 可是,这个模型没有嘴
接下来是 (2)“嘴没张开”。这不是我的设定失误,而是模型本身的局限。操作用变种为了把手臂和手做到极其精密,代价是把头部当作单一刚体处理。既没有颌关节,也没有面部肌肉。那 81 条肌肉全都属于手臂、手和躯干,没有任何一条能张开嘴。
指示很明确——“改造成能张开下颌的模型”。于是我把原模型(251MB 的资产群)整套复制、原封不动地放着,只编辑其中操作用的躯体定义文件,把头部的下颌几何切分成独立的子刚体 mandible(下颌)+ 铰链关节 jaw_open。给这个关节施加角度,下颌就会下降、嘴就张开。
不过这里从一开始就有一条诚实的划界。这个下颌上没有附着肌肉。所以下颌的开合不是用肌肉解出来的动作,而是直接写入位置的脚本(kinematic)。手臂的抓握与搬运是那 81 条肌肉真真切切解出来的,而嘴的张开是演出——这个区别我会一路带到最后。
改造后的模型顺利读入了。关节多了一根,自由度从 48 → 49。肌肉数量(81)没变,所以学好的控制理应原样载入。我还分别确认了下颌闭合的画面和张开的画面,开合本身是能动的。——可是,一旦把学好的喝水控制载上去,事情就变得不对劲了。
12. Bug:加了下颌之后,瓶子穿地而过
在改造过的下颌模型上,我回放了已经学好抓握与搬运的控制(§7 的 champion)。期待是“像原来一样抓住、搬到嘴边”。实际发生的是这个。
肌肉骨骼像要喝水一样把手臂抬向嘴边。可手是空的,台子上的瓶子也消失了。控制试图去抓瓶子本该在的位置,而那里什么都没有,于是手臂扑了个空、逐渐发散。只不过加了一根下颌,抓握就彻底坏了。肌肉的数量、手臂的关节,我一样都没碰过——却是这样。
13. 找犯人:丢掉猜测,先测量
碰到这种情况,靠猜测断定原因、然后打补丁是最危险的。我一开始是这样估摸的——“既然加了一根关节,手臂关节的初始角度就一定错位了,是从一个奇怪的姿势开始的吧。”听上去很有道理。但这个系列的纪律是“与其说‘大概是 X 造成的’,不如说‘我要确认 X,确认方法是 Y’”。
于是我写了一个诊断,把原模型和下颌模型开局那一瞬的状态并排量出来。在两个模型上,比较手臂 7 个主要关节的角度、手掌的世界坐标、瓶子的世界坐标。结果如下。
| 量出的量 | 原模型 | 下颌模型 | 差 |
|---|---|---|---|
| 手臂 reach 关节 7 根的角度 | (基准) | (相同) | 最大 0.0 |
| 手掌的世界坐标 | (基准) | (相同) | 0.0 |
| 瓶子的高度 z | 0.94 m | −0.63 m | −1.57 m |
| 手~瓶子距离 | 0.108 m | 1.667 m | — |
我的猜测落空了。手臂的关节和手的位置,都与原模型完全一致(初始姿势没有坏)。坏掉的只有瓶子——本该在 z=0.94m 桌面上的瓶子,竟然从 z=−0.63m,也就是穿透了地板的地底开始。手处在正确的初始姿势。只有要抓的对象(瓶子)掉到了地面之下。所以手臂正确地朝“桌上瓶子本该在的位置”移动,而那里什么都没有。扑空的真相就是这个。
如果在这里认定“一定是手臂错位了”而去改代码,那不但修不好,还会把本来正确的手臂控制给弄坏。测量,能防止修错地方。
14. 真凶:keyframe(关键帧)由“编号”决定,加一根关节,就全都错位
为什么偏偏只有瓶子掉了,而且足足掉了 1.57m 高?这里藏着一个对所有接触 MuJoCo(乃至任何物理引擎)的人都受用的教训。
MuJoCo 会把模型的全部关节位置塞进一个一维数组 qpos。每个关节按照身体树状结构的顺序,在 qpos 中被分配到“从第几个到第几个”这样一个位置(槽位)。而定义“实验开始时姿势”的 keyframe,只是把那个 qpos 原封不动地按值排列写下来的一串数字——就像 qpos="-0.07 0.03 ... 0.94 ..." 这样。第 i 个值进入第 i 个槽位。由位置决定。
然后,我给头部加了一个下颌铰链 jaw_open。MuJoCo 给它分配了第 42 个槽位。于是——从第 42 个往后的关节,全部各自向后错开一位。保存瓶子位置的槽位,也从 42 挪到了 43。可是,keyframe 那串数字仍旧是 48 个,还拖着原模型的排列(加了下颌,却没更新开始姿势的数列)。模型的 qpos 已经增加到 49 个,keyframe 却只有 48 个。MuJoCo 会把缺的那 1 个在末尾用 0 补齐。结果,从第 42 个往后,全部错开一位被读取。
画成图就是这样。
槽位编号: ... 41 42(新) 43 44 45 46 47 48
本应内容: ... 臂末 [颌=0] 瓶x 瓶y 瓶z 旋转… 旋转… 旋转…
keyframe实际: ... 臂末 0 0 0 -1.57 0.32 0 0(padding)
↑ 从这里开始,全部读取了前移一位的值
手臂(第 41 个槽位及之前)毫发无损——因为错位是从第 42 个才开始的。但瓶子不同。这里要插入一个重要前提:这个瓶子挂在“以桌面高度(0.94m)为原点的上下滑动关节”上,进入 keyframe 的值表示的是相对于这个原点的相对位移(米)。本来瓶子的上下滑动应当是 0(=就放在桌面上)。
可错开一位之后,那个高度栏里,流进了本该是相邻关节旋转角的值 -1.57。−1.57 本来是以弧度计的旋转角(约 −90 度)的数字。它却被原样当作垂直滑动的位移(−1.57 米)来读取——角度的数字,变成了距离的数字。于是瓶子从桌面高度下沉了 1.57m,出现在 0.94 − 1.57 ≈ −0.63m、穿透地板的地下。
「0.94 − 1.57 = −0.63」之所以是相加(相对位移的叠加)而非相减,是因为瓶子的高度是从桌面算起的滑动量,而非绝对坐标——若跳过这一点,就会被质疑“z 不是应该整个被覆写成 −1.57 吗?这种吻合是不是太巧了?”。事实上,替本文核验构成内幕的另一个流程,在通过一手来源确认模型定义(瓶子挂在上下滑动关节上)之前,也一度差点判定“这里是有破绽的”。越是干净吻合的数字,越要回溯到机构本身去核实——这正是本系列的信条。
加一根关节,就等于把 keyframe 的所有槽位都错开一位,而我却让那串数字维持旧样、放着没管。绿色的测试是通过的(模型能加载,关节数和肌肉数也都对)。这是一个测试无法检测的、名为“位置错位”的安静 bug。而揭穿它的,不是测试——是看了视频的人的一句“没喝进嘴里”。
15. 修复:把一个 0,放到正确的位置
一旦弄清原因,修法简单得让人扫兴。在原本 48 个的 keyframe 里,朝下颌槽位(第 42 个)插入一个 0(下颌闭合状态=角度 0),凑成 49 个。这样一来,第 42 个往后就会正确地错位到本该属于的槽位,瓶子也回到桌面上。
变更前(48个): ... 0.3141 0 0 0 -1.57 0.32 0
变更后(49个): ... 0.3141 0 0 0 0 -1.57 0.32 0
↑ 插入一个下颌的 0
应用之后重新测量,下颌模型在数值上都与原模型一致。
| 测量项 | 原模型 | 下颌模型(修复后) |
|---|---|---|
| 瓶子起始 z | 0.94 m | 0.94 m |
| 抬升量 | +0.62m(至 z=1.56) | +0.62m(至 z=1.56) |
| 是否持续抓握?(手~瓶子) | ✓ (0.043m) | ✓ (0.043m) |
| 到口(唇)的最终距离 | 0.050m | 0.048m |
手~瓶子相对位置向量的差为 0.0(在测量精度 1e-6 以内完全一致)——也就是说,就抓握与搬运而言,下颌模型再现了与原模型无法区分的动作。完全不需要重新学习。因为坏掉的不是控制,而是初始化数据的排列。
16. 喝下去了
修好之后,终于呈现出本来想要的画面。伸手去够台上的瓶子,抓住,搬到嘴边,然后下颌张开。
“口是否张开”也因为被手臂和瓶子挡住而不易看清,所以只把头部裁切出来,把开始和结束并排放在一起。
左(开始)口是闭合的,右(结束)下颌下降、口张开,瓶子抵达唇边。人给出的三条否决——够不到嘴、口不张开、看不出在做什么——至此全部解决。瓶子被搬到唇边一带(z=1.56m,就在 §10 定义的唇点 1.55m 之上),口张开,从一个斜侧视角能看清手臂的动作。
该带走什么(第 II 部的三条教训):① 在把停滞或失败归因于“能力的极限”之前,先质疑评分表(目标、数据) —— “够不到嘴是因为容量不足”,其实是“因为把目标放在了喉咙的高度”。② 在凭猜测改代码之前,先测量、锁定真凶 —— “手臂错位了”一测就落空,真凶是瓶子的初始位置。③ keyframe 和初始化数据由“编号(位置)”决定。一旦加了结构(关节),就必须重新做一遍 —— 忘了更新基于位置的初始化,测试照样是绿的,只有物体会悄悄掉到地板上。而揭穿这个安静 bug 的,不是测试,而是看了画面的人的违和感。把人留在回路里(Human-in-the-Loop),正是为了这样的瞬间。
第 III 部 —— evis:这具骨架成了吉祥物,又跳舞、又用筷子吃饭
从这里往后,记录的是把上文里「想让它站立、却诚实地摔倒」的那具拥有 700 条肌肉的骨架,做成吉祥物并用 mocap 驱动的过程。主角依然不是成功故事,而是**「一让它走路就变成像老人一样的驼背」——我如何不靠猜测、而是靠测量抓到那个真凶的过程**。
先把诚实的但书写在前面:第 III 部是运动学回放(kinematic replay,直接给出每一帧的关节角),并不是靠肌肉驱动。筷子也只是「看起来握着」的刚体道具(rigid prop),手指并没有真的握住。哪里是真实、哪里是演出,我会在最后明确写清。
17. 出发点 —— evis 这个「看得见内部的人体」
evis 的素体,与第 I 部相同,就是 MS-Human-700。它是一具全身 85 自由度(DoF)、拥有 700 条肌肉的、供生物力学研究使用的人体模型。没有皮肤,骨(白色)与肌肉、肌腱(红色)裸露在外。这副「剥去了皮肉的模样」,直接就成了这个角色的魅力所在。名字定为 evis,由来分为两层:
- 惠比寿(Ebisu) —— 七福神之一,寓意吉利。
- eviscerate —— 剖除内脏、去除皮肉之意,正是只剩骨与肌的 evis 的样子本身。
house style 是「白色的骨 + 昏暗的摄影棚 + 像点燃一样发光的红色肌肉 + 滑稽的白眼珠 + 在头顶晃动的呆毛(随头部动作延迟摆动的次生运动)」。肌肉发光的方式其中另有机关:在运动学回放里并没有肌肉活性,所以我是用各部位的「速度」来让它发红点亮的——挥得快的手臂会啪地一下亮起来。这样一来,动作所要求的运动模式就被可视化了出来。
18. 为什么选 mocap,而最初撞上的墙不是「技术」而是「许可证」
就算用手工做的正弦波(sine wave)让手臂挥动,动作也很僵硬。我想移植真正的 mocap。 然而最先撞上的不是技术,而是许可证。包含 evis 在内的 FullSense 采用商业 dual-license,因此 mocap 数据也必须能够商用。把知名的几个数据源查了一遍:
| 来源 | 格式 | 商用 | SMPL 依赖 | 判定 |
|---|---|---|---|---|
| CMU MoCap(cgspeed BVH 转换) | BVH | 可 | 无 | 采用 |
| AMASS / AIST++ | SMPL 参数 | 不可 | 需 SMPL(非商用) | 排除 |
| Mixamo | FBX | 产品内可用 | 无 | 需账号、数据不可再分发 |
| Bandai-Namco / LaFAN1 | BVH | 不可(NC) | 无 | 排除 |
SMPL 是人体网格的标准模型,但它是研究用(非商用)许可证,AMASS 和 AIST++ 都依赖它。它虽被广泛使用,却无法带进商业产品。答案是 CMU Graphics Lab Motion Capture Database。其许可证原文是 "This data is free for use in research and commercial projects worldwide"。它以 BVH 自成一体(不需要 SMPL),可以用 git 自动获取。不过有一点 caveat,就是「数据本身(即便是转换后的形式)也不得转售」。所以分发「已经过重定向的动画」是 OK 的,但我做到了不把 BVH 文件本身随仓库一起附带(需要时用获取脚本下载)。
教训:「技术上最好」和「可以分发」是两回事。如果一个研究演示有可能摇身变成商业产品,那么最好在最开始就以敌对的态度把许可证核实清楚。
19. 重定向的机制 —— 移的不是「旋转」而是「位置」
BVH 的骨架(标准人形)与 MS-Human-700,关节的构造完全不同。MS-Human-700 源自 OpenSim,比如骨盆根部不是 quaternion 的自由关节,而是6 个独立的 1 自由度关节,肩部则是夹着「幽灵刚体(ghost body)」的复杂肩带结构。想把旋转角原样搬过去简直是地狱。
于是我改用基于位置的做法。用 forward kinematics 解算 BVH,只取出各关节的「世界坐标(点云)」。旋转一概不移。之后,用**微分 IK(differential IK,Levenberg-Marquardt)**解算关节角,让 evis 对应的身体部位与那个点云重叠。瞄准手、肘、膝、脚、头等约 16 个点,并且从上一帧温启动(warm-start)来求解,所以 1 帧约 30ms,一段 5 秒的片段几秒就能完成重定向。因为不移旋转,那个棘手的肩带,IK 也会自行把它分配成一个漂亮的形状。
这里有一个运动学特有的陷阱。 MS-Human-700 在膝、肩带、手腕上带有 42 个等式约束(equality constraint,「这个幽灵关节由肩部角度的多项式决定」)。然而在直接设置关节角的回放中,这个约束并不会被 MuJoCo 强制执行。放任不管的话,IK 就会把肩胛骨的幽灵刚体拧向莫名其妙的方向,肩部就坏了。所以每一帧,我都自己去评估那个多项式,并把从属关节写回去。这样一来,膝的 screw-home(旋锁)和肩的节律就都能正确运动了。(——正如你已经注意到的,第 II 部的 keyframe bug 里那个「由位置决定的数据得自己去照料」的主题,在这里又换了一副面孔出现了。)
20. 正题 —— 「腰像老人一样弯曲」,把这个真凶测出来、抓住它
先让它走起来。能动。……可是请人来审阅时,只得到一句话:「腰弯得像个老人」。从侧面看确实如此:头往前探、后背拱起,是典型的“驼背”。从这里开始才是本章的主角——不靠猜测、而靠测量把真凶逼到墙角的过程。
假设1:头往前伸了? 测量头与骨盆的前后差,是 0.3cm。几乎正上方。排除。
假设2:mocap 源本身就是驼背? 测量原始 BVH 的颈部前倾,是 9.5°(轻微),躯干是 1.6°。几乎笔直。原始数据不是驼背。是动作重定向(retargeting)在放大它。
假设3:姿势正则化太弱? 加了一项把脊椎、颈部向中立位轻轻拉回的项,但视觉上几乎没变化。它被标记点的拉力压过去了。
于是停止猜测,把关节角一个一个实测了一遍。真凶随之现身:
T12_L1_FE = -28.6° (可动域 [-29, +29] → 贴死在负向极限)
T1_head_neck_FE = +28.6° (可动域 [-29, +29] → 贴死在正向极限)
中背(胸腰椎)贴到了一侧极限,颈部贴到相反一侧的极限,两者都饱和了。这就是驼背的真身。那么,IK 为什么会把关节弯到极限?
真凶是标记点高度弄错了。 evis 的 thoracic1(胸椎最上端)位于骨盆上方 0.50m(肩的高度)。可我却把 BVH 的 Spine1(骨盆上方 0.26m=中背)分配给了它。我是在试图把肩膀高度的身体往中背的高度拽下去,结果只有 3 段的脊椎被压缩,关节一路折到了极限。
修正很简单。改成高度相符的对应关系——thoracic12(上方 0.23m)←Spine1(0.26m)。把过度约束的 thoracic1/lumbar5/头顶标记点去掉,让姿势正则化稍稍起点作用。结果,步行变直立了,而且舞蹈里那种表现性的背屈(前屈、后仰)保留了下来——因为那是标记点真正要求的动作。
教训(这是第 III 部我最想说的):如果按「大概是头往前伸了」去修,就会修错。 一旦去测评分的内幕(关节角),真凶竟是意想不到的「标记点高度」。这和第 II 部的「先测量」是完全相同的精神——猜测之前先测量。
21. 把眼睛做成 3D 之后,那些小把戏全都消失了
evis 的表情(>< 或 ◎◎)最初是 2D 的贴画。把头的 3D 位置投影到相机上、求出屏幕上的眼睛坐标,再用深度缓冲判断「这只眼睛现在看得见吗(是不是转到侧面被骨头挡住了)」,然后把眼白和颜文字盖上去……就是这么个机制。动是能动,但留下了一个 bug:侧脸时,较远那只眼睛会被投影到背景上、悬在半空。
这时来了一句话:「把眼白建成 3D 的球、跟模型合为一体,直接在眼球上用纹理画出表情不就行了?」 这才是正解。给每种表情烤一张立方体纹理(正面是颜文字、其余面是白色巩膜),嵌进头部做成眼球的球,按每种情绪各备一套,只留要显示的那一组、其余全部透明,这样来做替换。于是:
- 眼睛会随头一起旋转。
- 骨头(头颅)会自然地挡住眼睛——转到后脑勺,眼睛就消失。既不需要深度判断,也不需要投影计算。
- 任何角度都正确。跳舞时头转过去、摔倒时朝下看,都会自动变正确。
投影+深度的这套 2D 小把戏被整个删掉了(约 80 行)。情绪有 8 种(neutral 的圆眼 ●●、joy ><、surprise ◎◎、love ♥♥ 是彩色 emoji 纹理、dead ×× ……)+ 任意文字。顺便说一句,全角的 >< 被误判成 emoji、导致画得很淡的 bug,也是在这里修好的(判定范围弄错了)。
接到物理摔倒的 rollout 上之后,evis 一边倒下、一边把表情从 neutral → ◎◎(下落)→ ××(崩塌) 地变换,还成了一段“现场解说”。
22. 动作库 —— 走、跑、跳、跳舞、用筷子吃饭
只要动作重定向(retargeting)生效,接下来只需换 BVH,它就什么都能跳。从 CMU 收集、移植到 evis 上的有:
- 步行 / 跑步 / 跳跃(subject 7 / 9 / 13)
- 现代舞 / 萨尔萨(subject 5 / 60)
- 足球射门(subject 10)
- 用筷子吃饭 —— 把 CMU 的「喝苏打水」(把手送到嘴边的动作)做了动作重定向,让 evis 的手握住两根筷子 + 一粒食物。筷子的朝向,是把「手→口」方向实测出来、在手的局部坐标里固定的,所以手臂一抬起,筷尖就朝向嘴。
- 物理摔倒 + 会反应的脸
↑ 修正姿势后的步行。头抬起来了,脊椎笔直。摆动的腿发红发火。
↑ 用筷子吃饭。手里握着的两根筷子 + 一粒食物,会在手臂抬起时朝向嘴。
第 IV 部 —— 整体的诚实边界,以及下一步
23. 哪些是真实的,哪些是演出(整合版)
诚实披露(honest disclosure)是本系列的核心,所以我把整篇文章的边界集中到一处、明确说清楚。
- 真实层(研究层·肌肉驱动):第 I 部的站立、抓握、搬运,以及第 II 部喝水动作里的手臂抓握与搬运,都是用进化计算学习 81(或 100)条肌肉得到的闭环肌肉驱动。IK、力封闭(force-closure)、keyframe 一致性的验证也都是真实的。站立的那次“负”,以及在 n=3 下被反证的“记忆没有效果”,也都是我不加夸大保留下来的实测。
- 演出层(角色层):第 II 部的下颌开合是运动学(kinematic)的(因为没有搭载面部肌肉,所以是脚本驱动),而且是把 qpos 直接写死,开到了超过设计可动范围(+0.05 rad)的 +0.6 rad——我明确标注:这是无视物理的演出。第 III 部 evis 的跳舞、奔跑、筷子都是运动学回放,肌肉那抹红色发光是“速度”的替代量(proxy)(并非肌肉活性),眼睛、呆毛是绑定(rig),筷子是刚体道具(手指并没有真的握住)。
- 它本身并没有在“喝水”。这并不是在模拟液体的摄入,而是把瓶子搬到张开的嘴边的一个姿态。是喝水的“姿态(gesture)”,而不是喝水的“生理过程(physiology)”。
- 省略之处:胸廓的肋软骨因为是仅含骨骼的模型而被省略了(这正是正面看起来空荡荡的原因)。我一直查证到解剖图上的正确形状(肋骨 1–7 连到胸骨,8–10 构成肋弓),但作为面向角色的近似来说太难处理,于是默认关掉了它。
总而言之:肌肉真正驱动的,只到“站(不过是诚实地承认失败)、抓、搬、喝水的手臂”为止。“张嘴”“跳舞”“握筷子”,是叠加在其上的外观层。如果把这条界线含糊掉再去展示 GIF,就会变成夸大,所以我用粗体保留下来。
24. 下一回
翻过三座山,evis 已经做到了“想要站立却诚实地摔倒、抓、搬、喝、跳、用筷子吃”。但留下的作业也很明确。
- 让筷子“真的用手指握住”,不是运动学的延伸,而是用进化学习肌肉的另一座山。要看这只有 81 条肌肉的手,能不能不靠刚体道具,而是真的捏起筷子、把食物送到嘴边。
- 把站立的那次负扳回来。既然单纯的积分记忆行不通,那么下一步就尝试递归(recurrent)策略、或明确的踝/腰策略、或分层结构。
- 给评价(eval)本身加上一张脸。这次我把 evis 的表情接到了摔倒上;同样地,如果在步行学习里跌倒就变成 ××,如果进展顺利就用 >< 露出笑容——让它成为一只由仿真自己现场解说的吉祥物。
测量,诚实地把失败保留下来,然后再往上爬。越是得到好结果,就越要质疑其中的内幕;一旦卡住,就不要靠猜测而要去测量——这就是我在本系列里坚守的那一点。只有骨与肌的 evis 的故事,还会继续。
附录:方法(复现备忘)
不给出前提条件就抛数字是不诚实的,所以把前提都列在这里。
- 模型:MS-Human-700(Apache-2.0)/ MuJoCo 3.10 / 积分步长 dt=0.002s。共有三个变种:全身 85 DoF·700 肌,步行用 36 DoF·100 肌,操作用 48 DoF·81 肌。
- CPU:Intel Core(Ice Lake 世代移动版),8 核。进化评估为7 worker 并行,单次计测为单线程·空闲。
- 站立:步行用场景(带地面),每回合 6 秒,跌倒阈值=骨盆高度 0.6m(初始 0.95m)。CMA-ES,pop 16〜40,世代 60〜250。策略为肌肉协同(muscle synergy)(K=6 或 8)。有记忆/无记忆的对比各取 3 条随机种子的中位数,确认了单一种子上看似存在的差异(4.53 秒)会消失。
- 抓握:操作用场景(肩部固定),每回合 3 秒,CMA-ES pop 28·K=8·250 世代中在 gen50 取得最优。接触摩擦 μ=1.0。
- 搬运(place):同一操作用场景,以 3 个目标点(正上方/体侧/前方稍上)的平均值评分。观测=抓握 19 维+命令向量 3 维。从抓握 champion 做 warm-start(温启动)(命令输入的权重用零填充),sigma0=0.25。在 gen30-50 收敛到 fitness ~1021。
-
喝水(下颌模型):一个派生模型,把操作用变种的头部下颌几何体分离为子刚体
mandible+ 铰链jaw_open(轴 z,设计可动范围 −0.7〜0.05 rad)。自由度 48→49,肌肉 81 保持不变。渲染时的张颌超出设计可动范围,直到 +0.6 rad 都是用 qpos 直写(不施加 limit),这属于演出层。Bug=下颌铰链落在qpos的第 42 位,把之后的所有槽位整体后移一位。keyframe 仍保持 48 个而做 zero-pad→旋转角 −1.57 被塞进了瓶子的上下滑动栏(相对于桌面 0.94m 的相对位移),于是 z=0.94−1.57=−0.63m,瓶子掉落。修复=在原本 48 个 keyframe 的第 42 位插入0.0,补成 49 个。
import numpy as np
# orig_key48 = 原模型的 keyframe(48 值)
broken = np.concatenate([orig_key48, [0.0]]) # 只在末尾加了个 0 = 相当于 MuJoCo 的自动 padding(会坏)
fixed = np.insert(orig_key48, 42, 0.0) # 42 位(0-index)插入下颌的 0 = 正确(49 值)
# reset 之后立即测量瓶子的世界 z:
# broken -> z = -0.63(地板下) / fixed -> z = 0.94(桌面上)= 与原模型一致
-
evis(mocap):mocap = CMU Graphics Lab Motion Capture Database(可商用·不依赖 SMPL)。获取方式为
scripts/fetch_cmu_dance.py --motion(BVH 不可再分发,故不随包附带,按需 on-demand 下载)。基于位置的微分 IK(Levenberg-Marquardt,仅用 numpy + mujoco,无需 scipy,~30ms/frame)。膝、肩带、手腕的 42 个等式约束每帧都会求值多项式并写入从属关节。表情则是把按情绪分类的立方体贴图眼球嵌入头部,用 alpha 做替换。 -
实现:重定向器
src/onocollo/evolve/mocap_retarget.py,吉祥物scripts/musculo_mascot.py,肌肉控制src/onocollo/evolve/musculo.py。输出为 mp4(h264,用于 Qiita 内嵌)/ gif 按扩展名自动切换。 -
数值的来历:全部取自
out/musculo/*.json以及对已保存策略的重新运行。本文所有定量主张,在公开前都经过了由独立的另一进程对一次数据·实代码的再核对(fact-check)+编辑审校。RTF 的条件标注、以 n=3 反驳站立的记忆效应、抓握的 force-closure(力封闭)验证、keyframe bug 的机理确认、张颌超出可动范围的披露,都是在收到这些指摘后修正的结果。














