前两天有一个玩PiPER的朋友在评论区问我:LeRobot的训练流程都跑通了,模型在评估集上看着也不错,怎么一放到真机上,机械臂的动作就抖得像帕金森,尤其是接近目标的时候,末端总是来回调整,成功率直线下降。这个问题我太熟了。第七弹原本想继续写模型评估,结果最近正好卡在部署阶段调这个,顺手就把“运动控制平滑优化”单独拎出来写一篇,也算是一个微小的系列分支。前面六篇已经把PiPER适配LeRobot、数据采集、ACT策略训练、模型部署这些内容都串过一遍了,这篇专门处理动作流从模型输出到机械臂执行之间的这一段:为什么动作会抖、哪些地方可以做平滑、我最终用了哪些手段、以及调参过程中踩到的一堆坑。
先说结论:不要一上来就换模型,也不要盲目加大滤波强度。大部分抖动问题出在“策略输出频率”“底层控制频率”“训练数据本身的平滑程度”三者错位。把这一层理顺,再用轻量级的滤波、重采样和限速手段做一次动作整形,就能解决绝大部分问题。这篇内容完全基于我自己的实测经验,参数也是从PiPER实机上一点点试出来的,不保证在所有环境里通用,但思路可以复用。
1. 先把问题定性:动作抖动不是单一原因,而是三层错位叠加的结果
1.1 现象:模型loss明明很低,PiPER的执行却很“神经质”
先说我在PiPER上遇到的具体现象。用LeRobot框架训练了一个ACT策略,任务是抓取桌面上的小方块。训练结束,验证集上loss已经收敛得很漂亮,回放录制的演示数据也看不出异常。但一部署到真机上就露馅了:机械臂在接近方块的过程中,末端会出现频率很高的小幅震荡,大概每秒钟有好几次,幅度在几毫米到一厘米之间。最明显的是在目标点附近,机械臂不是干脆利落地停下来,而是先冲过头一点,再退回来一点,来回两三次才稳住,偶尔还会把方块碰倒。
如果只看关节角度的实时曲线,会发现这些抖动不是单一频率,而是混着两种形态:一种是类似白噪声的高频小锯齿,另一种是像阶梯一样的“平台-跳变-平台”结构。高频小锯齿多见于模型预测出的动作序列在时间维度上不够连续,相邻两个动作帧之间可能一下子多出几度;阶梯结构则更接近执行层面的问题,策略输出动作的频率和底层控制频率没有对齐,导致机械臂在两次新目标之间反复执行同一个旧目标。
1.2 三层错位:策略输出频率、控制周期、数据平滑习惯
这类现象背后其实是三层错位叠加的结果。
第一层是策略输出频率和PiPER底层控制频率不一致。LeRobot策略在推理时通常以一定频率输出动作,常见的是20到50Hz,而PiPER这类机械臂的底层力矩/位置控制环通常是100Hz甚至更高。如果策略输出频率是30Hz,控制环是100Hz,那么在一个30Hz周期内,控制环会连续3到4次执行同一个目标位置。目标一直不变,机械臂自然处于“等待”状态,直到下一个动作帧到达才突然更新目标。这个“突然更新”在数据上就是阶梯跳变,当目标位置变化量比较大的时候,表现出来的就是冲击和抖动。
第二层是模型输出动作本身缺少时间连续性约束。ACT策略会一次性预测未来一段时间的动作块,但因为部署时我们往往只取了当前帧的动作,或者按固定间隔逐帧消费这个动作块,相邻两个时间片的预测结果并不是同一个序列里的连续帧,而是模型在重规划时重新生成的新目标。模型预测的小误差在没有连续性约束的情况下会被放大成轨迹的不规则跳变。Diffusion Policy这类方法更明显,它的生成过程本身带随机性,虽然平均效果不错,但单次采样结果可能包含高频噪声。
第三层是训练数据本身不够平滑。LeRobot支持通过遥操作采集演示数据,如果操作者手不够稳,或者采样设备本身有噪声,那么模型学到的根本不是“平稳到达目标”这个技能,而是包含了人手抖动特征的“伪技能”。这种情况下,你无论怎么在部署侧做平滑,效果都有限,因为模型已经把人手的颤振当作任务的一部分来模仿了。
1.3 为什么一开始在仿真评估里没发现
还有一个很容易忽略的点:我们最初在仿真环境里评估策略,成功率很高,动作看起来也正常。原因是仿真里的位置控制是理想的,你给一个目标位置,仿真模型瞬间就过去了,不存在真实硬件上的摩擦、阻尼、传动间隙和延迟。LeRobot自带的评估环境又是以“是否到达目标点”为判断依据的,它不会惩罚轨迹有多毛糙。所以模型在仿真里表现得再好,也不代表真机能平滑执行。这也是我后来建议所有做真机部署的朋友,在训练完成之后先把动作流打点记录下来,用末端位置曲线去看一看,而不是只盯着成功率数字。
2. 加一层“动作整形”:在推理输出与PiPER控制指令之间做缓冲
2.1 接入点:主循环里多一行 smoother.step()
明确了问题出在模型输出和执行控制之间之后,我做的第一件事是在部署代码里加了一个动作整形层,而不是去改模型,更不是去改PiPER的底层固件。这个层的位置很靠前,就在主循环拿到策略输出之后、把目标位置发送给机械臂之前。
大致结构是这样:
obs = get_observation() action = policy.select_action(obs) # 可能是未来多步的动作块 cmd = smoother.step(action, dt=1.0 / control_freq) robot.send_target(cmd)smoother就是自己写的一个类,负责对动作流做处理,然后把平滑后的目标作为PiPER的新控制指令。之所以加在这里,是因为:
- 改模型意味着重新训练,周期太长,而且你并不确定模型是不是真的错了;
- 改底层控制环的安全风险高,而且会对所有控制命令生效,影响范围太大;
- 在推理后处理阶段做人畜无害的整形,可以快速迭代参数,一个下午就能试出效果。
这个动作整形层虽然代码量不大,但它承担的任务不轻。它需要解决三件事:把高频噪声滤掉、把低频目标的刷新频率对齐到底层控制周期、把不合理的速度和加速度限制在硬件可接受范围内。这三件事互相有耦合,所以不能简单套一个滑动平均就完事。
2.2 动作整形层该做什么,不该做什么
在设计动作整形层之前,我先给自己划了一条边界:不能过度改变动作的意图。平滑只能处理“执行层面的瑕疵”,不能改变模型规划出来的路径目标和时间语义。比如模型决定先向右移动再向下抓取,这个行为的顺序和大致轨迹应该被保留,平滑只是在每个微小的控制周期里,生成一个更合理、更连贯的中间目标。
这些是整形层该做的:
- 对动作序列做低通滤波,削减高频噪声;
- 利用前后两个目标做插值,生成高频控制周期需要的中间目标;
- 对单步位移做限速,对相邻两步速度差做限加速度,防止硬件因为瞬时冲击而抖动或报警。
这些是不该做的:
- 不要在这里做复杂的路径规划,比如绕过障碍物、重新搜索轨迹,那是规划器的事,不是平滑器的事;
- 不要对动作做大幅度的延迟补偿,因为机械臂系统本身的时延很难精确建模,补偿过头会变成振荡;
- 不要试图用一个滤波器同时处理所有维度的动作,因为不同维度的物理意义不一样,放在一起处理会互相干扰。
我见过有人把卡尔曼滤波、低通滤波、轨迹重规划全塞进这一步,结果延迟暴涨,机械臂反而更不稳定。这个层应该轻、快、可解释,每一行代码都清楚自己是对付哪一种错位的。
3. 我最终采用的平滑手段:滤波、重采样、限速限加速度
3.1 Savitzky-Golay滤波:对关节位置流做低通降噪
处理动作噪声,我第一个想到的并不是普通的滑动平均。滑动平均虽然实现简单,但它会削弱真实运动趋势,尤其是在抓取这种需要快速接近目标的任务里,滑动平均会让末端动作变得迟钝,就像隔着一层水在操作机械臂。
我最终选的是Savitzky-Golay滤波器。它的思路是在一个滑动窗口内对数据做局部多项式拟合,取拟合曲线在当前时刻的值作为输出。和滑动平均相比,它能在降噪的同时更好地保留信号的趋势和峰值,对机械臂这类需要保持动作“锐度”的场景比滑动平均更适合。
在Python里实现非常方便:
import numpy as np from scipy.signal import savgol_filter class ActionSmoother: def __init__(self, window=15, poly=3): self.window = window self.poly = poly self.history = [] def step(self, action): self.history.append(action) if len(self.history) > self.window: self.history.pop(0) if len(self.history) < self.window: return action arr = np.array(self.history) return savgol_filter( arr, window_length=self.window, polyorder=self.poly, axis=0, mode='nearest' )[-1]这段代码里要注意两个参数:window_length和polyorder。window_length决定滤波器看多长一段历史,越长越平滑,但延迟越大;polyorder是多项式阶数,太大会跟着噪声跑,太小又会削平真实动作。我在PiPER上用下来,window_length在9到15之间、polyorder取3,是比较稳的起始区间。
还有一个容易忽略的点:mode='nearest'。SavitZy-Golay默认的边界模式是用多项式外插,在动作刚开始的时候会产生明显的甩尾,也就是机械臂还没移动目标,末端先自己抖一下。改成nearest模式,边界处的行为会稳很多。
3.2 时间重采样:把30Hz的目标流“拆”成100Hz微步
滤波处理的是噪声,但前面提到的“阶梯结构”还得靠重采样来解决。PiPER的底层控制频率比策略输出频率高,我需要让机械臂在每两个动作输出之间收到多个平滑过渡的中间目标,而不是一直盯着旧目标。
最简单有效的方法就是线性插值。假设策略输出频率是30Hz,PiPER控制频率是100Hz,那么每收到一个新动作,我需要在这个动作和上一个动作之间生成大约3个中间目标。代码大致是这样:
def interpolate_targets(prev_action, next_action, steps): return [ prev_action + (next_action - prev_action) * (i / steps) for i in range(1, steps + 1) ]这样,PiPER在每个控制周期都能拿到一个逼近目标但又不跳变的位置。实际跑下来,仅仅加上这一步,机械臂运行时的阶梯感就明显减少了。
如果你觉得线性插值还是不够柔顺,可以换成Catmull-Rom样条,它能在插值时考虑前后两个点的一阶导数,让目标轨迹保留一定的加速度连续性。但我实测下来,在线性插值之后再配合限速,效果已经足够,而且线性插值代码量小、好调试。对于大多数桌面级抓取任务,不必一上来就上复杂样条。
3.3 速度与加速度钳位:给物理系统留出反应时间
滤波和重采样只负责“看起来平滑”,但机械臂执行时还要考虑物理极限。模型输出可能突然从一个很小的目标位置跳到一个很远的位置,线性插值会让速度瞬间拉满,触发电机或减速器的瞬时冲击。这种情况下就算位置曲线是连续的,加速度却不连续,机械臂一样会抖。
所以我加了限速和限加速度两个保险。限速比较容易理解:在单个控制周期内,任何关节的目标变化量不能超过最大速度乘以周期时间;如果超了,就把这一步的增量按比例压缩到限制范围内。
def clip_velocity(cmd, prev_cmd, dt, max_vel): delta = cmd - prev_cmd speed = np.abs(delta) / dt if np.any(speed > max_vel): scale = np.min(max_vel / np.maximum(speed, 1e-6)) delta *= scale return prev_cmd + delta限加速度稍微复杂一点,我在代码里用当前这一步速度与上一步速度之差来判断。如果单位时间内的速度变化超过了设定阈值,就逐步逼近目标,而不是一次到位。
这两个保险的数值设置很关键。PiPER这类机械臂虽然有硬件限位,但用户层的速度上限往往要保守一些,我通常把最大速度设为硬件标称值的60%到70%,最大加速度再减半。这样既能保证执行效率,又给机械臂留出足够的反应时间,避免频繁触发驱动器保护。
4. 调参顺序与实测对比:哪些参数先调,哪些别乱动
4.1 推荐参数表
经过几轮实机测试,我把不同任务下的推荐参数整理成了表格。参数之间不是独立的,所以这份表更多是当起点,而不是最终答案:
| 任务场景 | 滤波窗口 | 多项式阶数 | 插值步数 | 速度上限比例 | 加速度限制 |
|---|---|---|---|---|---|
| 快速推动物块 | 15 | 3 | 3 | 70% | 中 |
| 精准抓取小件 | 9 | 3 | 3 | 50% | 低 |
| 精细插拔动作 | 7 | 2 | 4 | 30% | 低 |
| 大范围转移 | 21 | 3 | 2 | 80% | 高 |
这里面的逻辑是:任务越精细,越不能依赖大窗口滤波,因为窗口太大会带来延迟,导致机械臂对目标位置的反应跟不上,出现“人在现场看它慢慢蹭”的感觉。大范围转移这种对精度要求不高的场景,反而可以放开速度限制,用较大窗口把路径走得更顺。
调参顺序也很重要。我的建议是:先做频率对齐和重采样,再做限速限加速度,最后调滤波器参数。如果顺序反了,先把滤波拉到很大,你会发现机械臂确实不抖了,但反应变慢,这时候你很难判断到底是滤波延迟还是限速限制造成的。先把底层执行节奏理顺,再一点点加平滑强度,每一层的影响都看得清清楚楚。
4.2 实测对比:抖动指数、成功率和任务耗时
为了验证效果,我在同一台PiPER上,用同一份演示数据训练出的ACT模型,分别测了优化前后的指标。每个配置跑20次抓取,记录三种数据:末端轨迹抖动RMS、抓取成功率、单次任务平均耗时。
- 优化前:末端轨迹抖动RMS约0.8mm,抓取成功率65%,单次平均耗时5.2秒;
- 优化后:末端轨迹抖动RMS约0.15mm,抓取成功率85%,单次平均耗时4.8秒。
抖动RMS的计算方法是记录机械臂运行过程中末端位置与理想轨迹之间的偏差,然后把所有时间点的偏差平方取平均再开方。0.8mm看上去不大,但在抓取小方块这个场景里,已经足以让手爪在接近目标时不稳定。优化后降到0.15mm,机械臂的动作性格完全不一样,从“毛手毛脚”变成了“稳准狠”。
单次耗时缩短也很符合直觉:之前机械臂在目标点附近来回调整,白白浪费了不少时间;平滑之后它一次到位,无效动作少了,整体效率反而更高。这说明运动控制平滑优化不只是提升表现质量,还能顺带提升任务完成速度。
4.3 “平滑过度”是什么体验
我也特意试过把平滑调过头的状态,好让自己知道边界在哪。当我把滤波窗口拉到25、插值步数加到6之后,机械臂确实一点不抖了,但动作变得非常“肉”:它明明已经接近目标位置,却像在黏稠的液体里前进,迟迟停不下来;抓取方块的时候,手爪经常还没完全到位就张开了,因为延迟累计下来,实际到达位置的时间比模型规划的时间晚了将近半秒。
这个体验给我提了个醒:平滑优化的最终目标不是让曲线最漂亮,而是在“抑制高频抖动”和“保留真实动作的敏捷性”之间找一个平衡点。判断方法也很粗暴:看任务中最关键的那几帧,比如抓取前的手指开合、插入前的位置逼近,在这些时刻动作是否干脆利落。如果关键动作都被平滑拖累了,说明参数已经过火了。
5. 踩坑记录:平滑优化最容易翻车的三个细节
5.1 滤波器的边界效应:每次启动和停止都会“甩”一下
第一次接入Savitzky-Golay滤波之后,机械臂在运动启动的瞬间会莫名其妙地小甩一下,这个动作不是模型规划出来的,完全是我滤波代码引入的。原因是滤波窗口不满时,我直接返回了原始动作,但一旦窗口满了,滤波器开始工作时会突然把历史数据里的趋势引入进来,产生一个不该有的初始修正。
解决办法有两个,我都用上了:一是把savgol_filter的边界模式设为nearest,避免外插;二是在机械臂真正开始执行之前留出几帧“warmup”,也就是让动作整形层先跑上若干次空循环,把历史缓存填充起来,再让机械臂开始动作。这样启动阶段不会出现滤波器突然介入的跳变。回程阶段也一样,尽量让任务结束时目标保持稳定一小会儿,不要任务完成后立刻切断控制信号。
5.2 数据采集阶段不能完全依赖部署后滤波
我在前面提过,训练数据本身的平滑程度会直接决定模型学到的技能属性。这里再展开讲一下。有段时间我修改了遥操作设备的采样参数,采集到的演示轨迹肉眼看着很正常,但速度曲线里有很多细小的尖峰。模型训练完之后的动作输出就带着这些尖峰的特征,部署侧滤波再怎么努力,也只是把尖峰削掉一部分,无法完全消除,因为模型已经把高频分量内化到了策略里。
后来我在每个演示样本保存之前,会先算一下末端位置的一阶差分,快速扫一眼有没有异常尖刺。如果发现某条轨迹的尖刺明显多于其他样本,就直接删掉重采。这个方法笨,但很有效。LeRobot整理数据集的流程里其实没有强制做这一步,所以如果你用的别人的训练数据,也建议先做一次平滑度体检,再决定要不要花时间调部署侧参数。
5.3 关节空间和笛卡尔空间不要用同一套参数
最后一个坑比较隐蔽。之前我把平滑参数统一作用在所有动作维度上,包括关节角度和末端位姿。后来发现,这样做在大范围运动时没问题,但一旦执行直线插拔这种任务,机械臂末端轨迹会偏离预期方向,因为各个关节因为滤波程度不同而出现“步调不一致”。
我分开处理之后才想明白:关节空间和笛卡尔空间的平滑是两种物理意义。关节空间平滑适合大范围转移,能有效降低每个关节的瞬时冲击;笛卡尔空间平滑更适合保持末端轨迹的直线性,比如擦桌子、跟踪目标等。我的做法是在末端位姿上做一次轻量滤波和限速,得到平滑后的目标位姿,再通过逆解换算成关节指令,最后对关节指令也做一次很小的滤波来消除逆解带来的高频分量。这样两层各有侧重,不会互相打架。
不过要注意末端位姿的表示方式,欧拉角在接近奇异位形时会跳变,此时笛卡尔平滑会放大扰动。我后来改成用旋转向量再转成四元数来避坑,效果更稳。这个细节虽然和运动控制平滑不直接相关,但在PiPER这类近奇异位形频繁出现的机构上,它直接影响平滑的效果是否可信。
调完再回头看,运动控制平滑是LeRobot落地的必选项
在PiPER上做LeRobot落地,运动控制平滑优化不是可有可无的锦上添花,而是直接影响任务成功率的关键环节。很多朋友在跑通LeRobot后把大量精力花在调模型上,却忽略了动作流在真实硬件上会遇到频率错位和物理约束的问题,导致效果不佳却找不到原因。
我个人的经验是:遇到动作抖,先别急着上各种高级滤波算法。第一步把策略输出频率和底层控制频率打点打出来,看看有没有阶梯结构;第二步把模型输出的动作序列画出来,看看有没有高频噪声;第三步再去考虑用滤波、重采样和限速来整形。整个排查链路下来,往往一个小时就能定位问题,比自己凭感觉调参要高效得多。
如果你也在这套组合上折腾,建议先把我前面给的参数表作为起点,再根据你的具体任务微调。等你把“平滑过度”和“平滑不足”两种状态都亲手试过一遍,就自然能找到最适合你的那一组参数。