轻量遥操作与数据采集:UniArmL1 框架拆解
- 分清 VR 手柄、键盘、主从臂三种遥操作模式各自适合什么场景,以及它们在代码里怎么共用一套接口
- 看懂一个数据采集框架该有哪些部件:录制频率、任务标注、相机命名、episode 写入器
- 建立一套判断演示数据质量的通用标准,尤其是时间戳对齐这个最容易被忽略的坑
做具身智能这一年多,很多人踩过同一个坑:模型选型翻来覆去比较了半个月,ACT、Diffusion Policy、各种 VLA 都试了一遍,最后发现效果上不去的原因跟模型几乎没关系——是自己采的那几十条演示数据太脏。图像和关节角对不上,任务描述当时没记,哪几条是失败的也想不起来了。
这件事的残酷之处在于:换一个更大的模型,成本是改一行配置;重采一遍数据,成本是重新站到机械臂前面拉几个小时。在这个阶段,一个轻量、标准化的采集框架,实际价值可能比一个更强的模型高。
这篇我们拆 UniArmL1。它不是一套通用机器人 SDK,也不做运动控制算法——它的定位很窄,就是「让人把动作演示给机械臂看,并且把这个过程规规矩矩录下来」。窄,恰恰是它值得读的原因:一个只干一件事的仓库,目录结构里的每个决定都是为这件事服务的,读起来信噪比很高。
先把边界说清楚:本文基于公开仓库的 README、命令行参数说明和完整文件树,没有真机验证,也没有跑过其中任何一行代码。 涉及运行效果的地方,我一律说明「仓库文档里写的是」,不编造实测结论。
这个框架在流程里的位置
README 开头给了一条完整流程:
硬件准备 → 校准 → 配置环境 → 遥操作 & 数据采集 → 训练 → 部署
六个环节里,UniArmL1 覆盖前四个,训练和部署交给 unitree_lerobot。README 里对自己的描述是「轻量化的开源机械臂及遥操作框架,支持三种控制模式与标准化数据采集」。
注意这个仓库同时包含硬件和软件两部分。hardware/ 下面是 STEP、STL、3MF 文件和一份组装文档;teleop/ 下面才是代码。硬件那边还放了电机的使用手册和调试助手。这种「图纸和代码放一个仓库」的组织方式,说明它面向的是自己动手搭一套的人,而不是买了成品只用软件的人。致谢里也提到了 SO-ARM100 这个开源机械臂硬件设计项目,路数是一脉相承的。
三种控制模式,各自解决什么问题
README 明确列出三种输入源,对应 teleop.py 的 --input / -i 参数:
python teleop/teleop.py -i vr # VR 手柄
python teleop/teleop.py -i keyboard # 键盘
python teleop/teleop.py -i leader # 主从臂
VR 手柄模式是默认值。文档里的操作路径是:VR 头显里打开 XRoboToolkit 应用,填 PC 端 IP 连上,在控制面板里勾选 controller 和 send;PC 端跑 XRoboToolkit-PC-Service,然后按住手柄侧键开始遥操作。还有个 --vr-scale 参数控制增量缩放。
「增量缩放」这个词透露了 VR 模式的工作方式:手柄给的不是绝对目标位姿,而是位姿增量——你的手往前挪一点,机械臂末端跟着往前挪一点,中间乘一个缩放系数。系数大于 1 意味着手的小幅移动会被放大成机械臂的大幅移动,反过来调小则更精细。这个设计的好处是,你的手臂活动范围和机械臂的工作空间不需要一一对应,按住侧键才生效相当于一个「离合器」,松开就能把手挪回舒服的位置再接着来。
VR 模式适合什么?需要在三维空间里自由摆姿态的任务——抓取角度多变、需要绕过障碍、末端姿态和位置要同时调的那种。代价是要额外配一套 XR 设备和服务端。
键盘模式最朴素:按住某个方向键,机械臂对应方向缓慢移动,具体按键映射会打印在终端里。它的价值不在于「好用」,而在于零外设依赖。你手上没有 VR 头显、也没有第二条臂的时候,键盘是唯一能立刻验证整条链路通不通的手段——串口通了吗、校准对不对、相机出图了吗。把它当调试工具看,比当采集工具看更贴切。
主从遥操模式是三种里最有意思的。它需要另一条同款机械臂当主臂:
python teleop/teleop.py -i leader --port /dev/ttyACM1 --leader-port /dev/ttyACM2
README 里的说法是:主臂处于零阻尼模式,从臂直接跟随主臂关节角度。
这两句话里有两个关键点。零阻尼,意思是主臂的电机不主动出力,你用手推它几乎不费劲,它就变成了一个「可以随便掰的姿态输入设备」。而直接映射关节角度——注意,是关节角度,不是末端位姿——意味着这条路径上不需要逆运动学。你把主臂掰成什么形状,从臂就复制成什么形状,一一对应。
这就是主从模式和另外两种的本质分野:VR 和键盘给的是笛卡尔空间的位置增量,要经过逆运动学解算才能变成关节指令;主从模式在关节空间里直接对齐。逆运动学解不出来会怎样、多解怎么选、奇异位形附近抖不抖——这些问题主从模式直接绕过去了。如果你对这一层还没概念,可以先看机械臂运动学那篇,它讲的是正逆解到底在算什么。
从采数据的角度看,主从模式还有个隐性优势:人的手直接握着一个和被控对象同构的东西,力反馈虽然没有,但姿态直觉是对的。比起隔着一层映射关系去操作,演示动作会更自然,也更接近真人做这个任务时的真实轨迹分布。
输入抽象:一个很干净的策略模式
三种模式差别这么大,代码里怎么处理?看 teleop/robot_control/input/:
input_source.py —— 输入源基类
input_vr.py —— VR 手柄实现
input_keyboard.py —— 键盘实现
input_leader.py —— 主臂实现
__init__.py
一个 input_source 加三个平行实现,命令行的 -i 参数就是在这三者之间选。这是教科书式的策略模式:上层的遥操作循环只知道「我从某个输入源拿到下一步目标」,不关心这个目标是手柄算出来的、键盘敲出来的,还是从另一条臂上读出来的。
这个结构的好处在你要加第四种输入时才显现——比如加个游戏手柄、加个动捕手套,只要照着写一个 input_xxx.py,主流程一行不用动。扩展点被显式地留在了目录层面,这比在一个大函数里写 if mode == 'vr' 强得多。
再看同级的 arm/ 目录:
config_uniarm_l1.py —— 配置
constants.py —— 常量
kinematics.py —— 运动学
uniarm_l1.py —— 机械臂主类
uniarm_l1_bus.py —— 总线通信层
utiles.py —— 工具函数
uniarm_l1_bus.py 单独拆出来这件事值得停一秒。总线层管的是「怎么跟电机说话」——串口收发、协议帧、读写寄存器;uniarm_l1.py 管的是「机械臂这个概念」——当前关节角、目标关节角、使能与失能。把这两层分开,换电机或者换通信方式时只需要重写总线层。这跟我们在宇树 SDK 架构那篇里看到的分层思路是同一套方法论,只是规模小了两个数量级。
还有个细节:teleop/robot_control/calibration/ 下面躺着 follower.json 和 leader.json 两个文件。校准结果被落盘成 JSON,主臂从臂各一份。这说明主从两条臂跑的是同一套代码、不同的校准参数——它们本来就是同款硬件,区别只在零位标定和角色。README 里那句警告写得很直白:未校准将无法正常控制机械臂。
数据采集:本篇的重点
采集入口简单到有点朴素——在遥操作命令后面加一个 --record:
python teleop/teleop.py -i vr --record \
--task-dir ./data/pick_place \
--task-goal "pick up the cup"
录制不是一个独立程序,而是遥操作的一个开关。 这个决定比它看起来重要:它保证了「你演示的过程」和「被录下来的内容」出自同一个循环,不存在两个进程各录各的、事后再想办法对上的问题。
把 README 参数表里跟采集有关的几项拎出来看:
| 参数 | 作用 |
|---|---|
--record, -r |
打开录制开关,默认不启用 |
--task-dir |
录制数据存放目录 |
--task-goal |
任务目标的文字描述 |
--record-hz |
录制频率,单位 Hz |
--cameras, -c |
相机配置,格式 name:id,可指定多个 |
--no-camera |
禁用相机显示 |
四个设计意图藏在这张表里。
第一,任务描述被提升成了一等参数。 --task-goal "pick up the cup" 是一句自然语言,跟着这批数据一起走。为什么重要?因为现在的策略模型很多是语言条件的——同一套观测和动作,配上不同的指令文本,学出来的是不同的行为。如果你采的时候没记下「这一批是在干什么」,事后再补几乎补不准。宇树在基于 LeRobot 的模仿学习那条线上做的训练脚本,语言条件正是常见配置之一。
第二,录制频率是显式参数,不是隐式跟随。 README 里给出的默认值是 50 Hz(默认值可能随仓库更新变化,以官方仓库当前文档为准)。把频率做成参数,意味着录制循环有自己的节拍,而不是「相机出一帧就存一帧」。这是采集框架和随手写的录制脚本之间一条重要的分界线,等下讲时间戳对齐时再展开。
第三,相机是带名字的。 head:0 wrist:2 这种写法,把「设备号」和「语义位置」绑在了一起。设备号是会变的——你插拔一次 USB,/dev/video0 可能就变成了 /dev/video2;但「头部相机」「腕部相机」这个语义不会变。数据里存的应该是语义名,训练时模型也是按语义名去取对应的观测通道。这是数据标准化里最基础也最容易做错的一件事。
第四,允许多相机。 -c 可以指定多个。头部视角看全局,腕部视角看接触细节,两路一起录——这在抓取类任务里几乎是标配。
再看代码侧的两个文件,teleop/utils/ 下面:
episode_writer.py —— episode 写入器
rerun_visualizer.py —— Rerun 可视化
episode 这个词是整套数据组织的核心单位。一次完整的演示——从起始状态到任务结束——就是一个 episode。写入器负责把这一段里所有时刻的观测、动作、元信息按统一格式落盘。
而 rerun_visualizer.py 的存在说明作者在采集环节就考虑了「看一眼」的需求。Rerun 是个时序数据可视化工具,能把图像流和数值曲线放在同一根时间轴上。采完立刻回看,是发现数据有问题的最快手段——比训练两天发现不收敛再倒回来查快得多。
相机侧则单独有一层 teleop/image_server/:
camera.py —— 相机抽象
configs.py —— 配置
errors.py —— 错误定义
opencv/camera_opencv.py —— OpenCV 后端实现
opencv/configuration_opencv.py
utils.py
同样是「抽象 + 具体实现」的分层:camera.py 定义相机应该有什么行为,opencv/ 是其中一种后端。目录里只有 OpenCV 一种实现,这只说明这个仓库当前暴露了这一种,不代表别的接不进来——留了 opencv/ 这个子目录本身,就是在给第二种后端占位。
一条演示数据里到底该有什么
这一节跳出具体仓库,说点通用的。我见过太多自己攒的数据集,训练前才发现少东西。一条合格的演示数据,至少要包含四类信息:
时间对齐的观测。 每一帧要有当时的图像(可能多路)和当时的本体状态(关节角度、夹爪开合、有条件的话还有力矩)。关键词是「当时的」——这三个字后面是整篇文章最大的坑,下一节专门讲。
动作。 也就是这一时刻发出去的指令。注意动作和状态不是一回事:状态是「机械臂现在在哪」,动作是「我让它去哪」。两者之间差着执行误差和跟随滞后。模仿学习学的是「给定观测,输出什么动作」,所以动作必须单独记,不能拿下一帧的状态凑数——那样模型学到的是「结果」,而不是「决策」。
任务标注。 这一条数据在做什么,用自然语言写清楚。UniArmL1 用 --task-goal 把它放到了命令行第一现场,是个好做法。
元信息。 谁采的、什么时间、什么场景、用的哪种输入模式、这一条成功了没有。这些东西采的时候记一句话的成本,和事后重建的成本,差着几个数量级。
失败数据的价值,和它的前提
这里有个容易被简单化的问题。unitree_lerobot 那边的文档给了一个明确操作建议:如果某个 episode 没能完成任务,就把它删掉以提升数据质量。仓库为此专门做了一个图形化的 data_editor,能剪掉多余片段、删除坏的 episode。
站在「先把行为克隆跑通」的目标上,这个建议是务实的——模仿学习的基本假设就是「照着演示做」,混进去一堆没做成的轨迹,模型确实会被带偏。
但如果把眼光放长一点:只喂成功数据的模型,不知道什么叫做错了。 它没见过夹空、没见过碰倒、没见过抓滑脱之后是怎么恢复的。真到了现实里出现偏差,它没有任何可参考的先例。做强化学习或者带价值判断的方法时,失败轨迹是有明确用途的信息。
所以更稳妥的做法是:别删,标注。 给每条 episode 打上成功/失败的标记,训练行为克隆时过滤掉失败的,将来需要时它们还在。删除是不可逆操作,而多存一个布尔字段几乎不花成本。
需要说明的是,这一段是我从工程角度给的建议,不是这两个仓库当前工具链提供的能力——文档里写的做法是用编辑器把坏 episode 删掉,仓库有没有别的成功标记字段,我没读到源码,不猜。
时间戳对齐:最容易被忽略的那个坑
采集环节最隐蔽的问题在这里。
一次采集里,数据来自完全不同的地方:相机通过 USB 出图,帧率由摄像头自己决定;关节角度从串口总线上读回来,节奏由通信协议和轮询频率决定;遥操作指令来自 VR 服务、键盘事件或者主臂读数,又是另一套节奏。三路数据、三个频率,很可能还跑在不同的线程甚至不同的进程里。
如果录制时只是简单地「循环一次就把各路当前最新值拼成一帧存下来」,那么这一帧里的图像可能是几十毫秒之前拍的,而关节角是刚读的。这个偏差如果稳定还好说,问题是它通常不稳定——USB 带宽波动、系统调度抖动,偏差会来回变。
后果是模型学到了错误的因果关系。 它以为「看到这个画面,就该发这个动作」,实际上那个动作对应的是另一个更早或更晚的画面。训练时损失可能降得很好看,一到真机就抖、就过冲、就在关键时刻慢半拍——因为它学到的时序关系从一开始就是错的。
这类问题极难在训练阶段被发现,因为数据本身「格式完全合法」。你只能在采集阶段防住它。几个通用做法:
- 每一路数据在源头就打时间戳,不要等到拼装时才补。
- 录制循环用固定节拍,而不是被最快的那一路数据牵着走。UniArmL1 把
--record-hz做成显式参数,至少说明录制循环有自己独立的节奏。 - 采完立刻可视化回看,把图像流和关节曲线放同一根时间轴上对一遍。这正是
rerun_visualizer.py那类工具的用武之地。 - 对齐策略要写进文档,让所有用这批数据的人知道「同一帧里的图像和状态之间可能差多少」。
至于 UniArmL1 内部具体怎么做对齐、recorder.py 和 teleop_runner.py 里是什么逻辑,我只看到了文件名,没读到实现,不做推断。上面这四条是通用工程建议,不是对这个仓库行为的描述。
采集端和训练端是怎么接上的
README 说采集的数据可以直接用于 unitree_lerobot 的模仿学习训练与部署。这句话有多实?把两个仓库的文件树摆在一起看,答案比 README 更有说服力。
unitree_lerobot 的 eval_robot/robot_control/ 下面有个 uniarml1/ 目录:
uniarml1/config_uniarm_l1.py
uniarml1/constants.py
uniarml1/kinematics.py
uniarml1/uniarm_l1.py
uniarml1/uniarm_l1_bus.py
uniarml1/utiles.py
对照一下 UniArmL1 仓库里 teleop/robot_control/arm/ 的文件清单——六个文件,名字一模一样,连那个把 utils 拼成 utiles 的笔误都原样保留。这不是巧合,是同一套硬件驱动代码被完整搬到了部署端。
更多的呼应:
- unitree_lerobot 有
eval_robot/assets/uniarml1/,里面是同样的 URDF 和 STL 网格 - unitree_lerobot 有
eval_robot/eval_UniArmL1.py,是专门给这条臂的推理入口 - 两边的
utils/下都有episode_writer.py和rerun_visualizer.py
最后这一条最能说明问题。部署端也带着 episode 写入器,意味着模型跑推理的时候同样能录数据,而且录出来的格式和人工遥操作采的一模一样。这就打通了一个闭环:人演示 → 训练 → 机器执行 → 执行过程再被录下来 → 补进数据集。采集和推理复用同一个写入器,是让这个闭环成立的前提,否则两边格式一分叉,回流的数据就得先做一遍转换。
数据格式这一侧,unitree_lerobot 的文档给出了 Unitree JSON 数据集的目录结构:
datasets/
└── task_name/
├── episode_0001
│ ├── audios/ 音频
│ ├── colors/ 彩色图像
│ ├── depths/ 深度图像
│ └── data.json 状态与动作
├── episode_0002
└── episode_...
一个任务一个文件夹,一次演示一个 episode 子文件夹,图像按类型分目录,状态和动作合在一个 data.json 里。工具链里还有 sort_and_rename_folders.py,用途是把 episode 编号整理成从 0 开始连续——文档特意提醒生成 LeRobot 数据集前最好这么做,说明下游对编号连续性是有假设的。真正的桥梁是 convert_unitree_json_to_lerobot.py:把这套 JSON 格式转成 LeRobot 数据集格式,--robot_type 参数指明这批数据来自哪种机器人配置。
标准化的价值在这里体现得最清楚。 转成 LeRobot 格式之后,这批数据就能被 LeRobot 生态里的所有工具消费——可视化脚本、各种策略的训练脚本、数据集回放。你不需要为自己的数据格式重写一遍训练管线,别人也能直接用你的数据。反过来,如果每个团队都自己定一套格式,那么每换一个模型实现就要写一遍转换器,每想复用一份公开数据就要重新解析一次。
这也是为什么我更愿意把 UniArmL1 看成一个数据生产端,而不是一台机械臂的配套软件。它输出的东西,从一开始就是奔着「能被别人的工具链吃掉」去设计的。想看这条链路上更完整的一环,宇树自己的 XR 遥操作框架是同一思路在更复杂本体上的展开,UniArmL1 的致谢里也明确提到了它。
读完这个仓库该记住的几件事
第一,采集框架的核心竞争力是「标准化」,不是「功能多」。 UniArmL1 的功能列表短得可怜——三种输入、录关节角和图像、写文件。但每一项都对着下游的实际需求:任务描述、相机语义名、固定频率、episode 结构。功能少而对,比功能多而散有用。
第二,输入抽象值得抄。 input_source 加三个平行实现,让「怎么获取人的意图」这件事和主循环彻底解耦。任何一个需要支持多种输入方式的项目都可以照搬这个结构。
第三,关节空间和笛卡尔空间是两条不同的路。 主从模式直接映射关节角度,VR 和键盘走位姿增量再解算。前者简单可靠但要求主从同构,后者灵活但要处理逆运动学的全部麻烦事。选哪条,取决于你有没有第二条同款臂。
第四,时间戳对齐要在采集时就防住。 这是唯一一个「事后完全无法补救」的问题。图像、关节、指令来自不同频率的源头,没对齐好的数据会让模型学到错误的因果关系,而且这个错误在训练指标上看不出来。
第五,别急着删失败数据。 标注它,过滤它,但保留它。
再往上一层,如果你关心宇树体系里更重型的机械臂方案,可以对照看看 Z1 机械臂那一篇——同样是「一条臂」,面向工业集成和面向数据采集,设计取向的差异会很直观。这一卷的其他文章都收在宇树专题页里。