个人桌边机器人研究系统实践
开发者分享办公环境机器人研究设施构建经验,涉及硬件和控制编程集成。
开发者分享办公环境机器人研究设施构建经验,涉及硬件和控制编程集成。
机器人研究已经变得足够便宜且易于开展,如今小型团队甚至个人都可以在真实硬件上进行有意义的研究。这背后有两个原因。
首先,性能可靠的机器人硬件价格已大幅下降:下文介绍的实体装置采用工业级机械臂、两个摄像头以及一套完整的遥操作系统,总成本仍低于 5,000 欧元。^1
其次,目前已有源源不断的公开基础模型可用于机器人研究。例如,Hugging Face 的 LeRobot 正是围绕推动最先进机器人研究普及化这一理念构建的。
我在这方面有一些经历。2017 年至 2020 年间,我曾在 OpenAI 从事机器人操作研究,最初研究的是仿人机械手,之后转向桌面操作。2019/2020 年前后,我使用的桌面装置成本大约是本文所述装置的十倍。这个比较并不完全严谨,但真正重要的变化在于:以这样的价格,这套装置的实用性竟然已经处于同一量级。当时,这类工作需要一支大约 20 人的团队。如果我的判断正确,那么如今一个人坐在桌前,应该也能取得令人惊讶的进展。
因此,为了验证这个判断,我决定直接付诸行动:接下来的几个月里,我将独立开展机器人操作研究,并公开整个研究过程。我预计主要产出不会是论文或开源代码库。^2 我真正关心的是研究日志本身:哪些方法有效,哪些会失败,以及我在运行这套系统的过程中学到了什么。
这篇记录介绍第一步:搭建一套完整的研究基础设施。前半部分讨论实体装置:一台工业级机械臂、两个摄像头,以及一套体积足够小、可以放在我桌边的遥操作系统。后半部分介绍我从零编写、用于操作这套装置的软件栈。上方的视频展示了最终系统实际运行的效果。
这是一次实验,计划可能会发生变化。但我对此非常兴奋。
根据以往的经验,我知道机器人研究应该在真实硬件上开展,因此第一步就是搭建一套可供我实验的装置。在购买任何东西之前,我先列出了几项要求。这些要求适用于整个系统,包括实体装置和用于操作它的软件:
体积足够小,可以放在我的桌上或桌边
零部件容易购买,不需要通过企业销售渠道
可以通过 Python 轻松使用
不对软件栈预设立场,因为我想构建自己的软件栈
10,000 欧元的上限并不是根据详细估算得出的。当时,我基本不知道最终系统会花多少钱。这个数字更适合作为预算上限:足够高,不必为了价格优化每一个组件;同时又足够低,使这套装置的成本仍处于我这个规模能够承受的范围内。
这五项约束解释了本文后续的大部分决策。
我决定搭建一套使用单机械臂的桌面操作装置。桌面操作的优势在于,它能提供难度各异、几乎无穷无尽的任务。例如,你可以从基础的单物体拾取与放置任务开始,然后逐渐过渡到摆放棋盘,乃至组装乐高,而这一切都能在同一套实体装置中完成。[^3]
[^3]: 6 年前,我们在 OpenAI Robotics 团队中也采用了同样的思路。解决魔方任务后,我们转向桌面装置,因为它可以支持许多不同的任务,而我们当时对通用机器人很感兴趣。
出于简化系统、节省空间和降低成本的考虑,我选择了单机械臂,而不是双臂装置。不过,这一选择确实限制了我能够完成的任务类型。例如,使用单机械臂叠衬衫可能是不可能的。但单机械臂仍然可以完成许多有趣的桌面任务,同时它还带来了一种有益的约束:策略必须通过行为来弥补硬件的缺失。它可以把物体推向另一个物体或桌沿,将其固定在原位;也可以在抓取前重新调整物体的位置;或者把周围环境作为操作策略的一部分。目前,这正是我想研究的范式。
在视觉方面,我使用一个腕部摄像头和一个固定摄像头。这里的一项约束是空间:我无法搭建一套完全一体化的“机器人笼式”实验室装置,这意味着摄像头的位置、光照条件以及视野内的背景都会随着时间变化。其代价是,与固定的实验室装置相比,数据会更加杂乱。不过,我认为这是一个特性,而不是缺陷:机器人若要真正发挥作用,就必须能够在这样的环境下工作。
为了测试这套装置并采集数据,我使用一款六自由度空间鼠标对其进行遥操作。我在机器人旁边放置了一张简单的 IKEA 折叠桌,将我自己的工作区——那里往往堆满各种物品——与机器人的工作区分开。这样也更加安全。
由于机器人就“坐”在我旁边,使用起来几乎没有任何阻力。虽然最初采用这种布局是出于空间限制,但我非常喜欢它,因为整天都可以快速迭代并开展开发工作。
下图展示了我最终搭建完成的整套实体装置。下面我会更详细地介绍各个组件。
完整的物料清单如下。所有价格均为我购买时支付的价格,不含增值税。为了方便起见,我还附上了购买渠道的链接,不过所有零部件应该都可以从其他经销商处轻松买到。
总成本为 4,569.80 欧元,不含增值税和计算资源成本。这还不到我在需求中设定的 10,000 欧元预算的一半。重要的并不是它在绝对意义上有多便宜,而是它的价格已经低到足以让个人或小型团队在真实硬件上反复迭代。
计算资源是唯一需要说明的问题。显然,训练策略以及最终部署策略服务都需要 GPU 计算资源。我没有将其计入成本,因为我已经拥有可用的计算设备,[^4] 而且我猜许多研究人员也是如此。
[^4]: 我已有的计算设备是一台 NVIDIA DGX Spark。
我选择 UFACTORY xArm Lite 6,是因为我希望拥有一台可靠的工业级机械臂。我认为 LeRobot SO-101、OpenArm 和 Robot Learning Company 等更便宜的机械臂都很有意思,也很高兴市场上有这些产品。[^5]
[^5]: 事实上,我还购买了一套 LeRobot SO-101。它便宜得多,价格约为 450 欧元,但显然也更像玩具。
不过,我过去的研究经历告诉我,购买一台精确、成熟且耐用的机械臂,会让所有事情都变得容易得多:它们开箱即用,而且很少损坏。尤其是 UFACTORY 的机械臂,价格出人意料地亲民,并且提供了非常实用的 Python SDK,因此格外有吸引力。
到目前为止,我对这个选择非常满意。机械臂装在一个做工不错的箱子里,如果以后需要搬运会很方便,而且整体制造质量看起来也很好。它还附带一个底座、两个用于将其固定在桌面上的夹具、一个急停按钮以及一个外置电源,支持 110V 和 220V。
安装过程极其简单。据我估计,从开箱到第一次操作,总共只用了大约 30 分钟。机械臂通过以太网连接,并提供了一个便捷的 Web 界面供用户操作。
除了 Web 界面,官方 Python SDK 也让操作变得非常简单。机械臂可以通过关节位置或速度进行驱动,同时也支持在 TCP 空间中进行驱动。[^6] 后一种方式效果很好,也是我在实践中使用的方式。
[^6]: 工具中心点(Tool Center Point,TCP)是连接在末端执行器上的参考点或参考坐标系,你所关心的是它的笛卡尔位姿。对于夹爪而言,这个点通常位于两根夹指之间,而不是实体安装点。所谓在 TCP 空间中驱动,是指你在笛卡尔空间中指定期望的位姿或速度,由机器人控制器计算出相应的关节运动。
机器人本身也已经具备多项安全功能:支持自碰撞规避;可以配置全局速度和加速度上限;能够检测并避免超出关节限位;当检测到受力过大时会感知并中止动作,而且灵敏度也可以配置。此外,它还支持一种“示教模式”,人类操作员可以在该模式下自由移动机械臂。
夹爪方面,我决定使用 UFACTORY 的 xArm Lite 6 平行夹爪。这个夹爪可以正常工作,但它是整套装置中最薄弱的部分。它采用气动驱动,因此开启时噪声很大,而且夹持力比较弱。夹爪本身没有任何传感器,所以只能通过读取控制信号来判断它处于张开还是闭合状态。
它也相当不灵活:夹爪可以完全闭合,但完全张开时的最大宽度非常窄(如上图所示)。夹爪可以重新配置为“宽”模式:拧下两根夹指,将它们对调,再重新拧紧。在这种配置下,夹爪张开时会宽得多,但无法再完全闭合。因此你必须做出取舍:要么能够抓取小物体,要么能够抓取较大的物体,但同一套配置无法兼顾两者。
不过,机械臂采用标准的末端执行器安装接口,因此可以更换夹爪。UFACTORY 还生产真空夹爪和更先进的平行夹爪,同时也支持先进得多(但也昂贵得多)的 Robotiq 夹爪。因此,如果当前夹爪成为过于严重的瓶颈,我可以把它换掉。
我计划使用的主要感知模态是视觉。[^77]我当然也会使用机器人的本体感知信息:关节角度、夹爪状态(张开或闭合),以及 TCP 位姿(通过正向运动学计算)。目前我决定采用双摄像头配置:一个安装在腕部的摄像头,以及一个固定安装在桌面上的摄像头,后者的视野能够覆盖机器人的整个工作空间。
腕部摄像头方面,我选择了 Intel RealSense D405。主要原因在于它体积小巧、采用全局快门,并且工作距离为 7 厘米至 50 厘米。这使它非常适合作为腕部摄像头,随机械臂四处移动并靠近其他物体。
它还支持以 720p 分辨率和 30 fps 输出 16 位深度通道。摄像头依靠双目视觉实现深度感知,但它已经能方便地在设备端完成必要的处理,直接输出 RGB-D。我的假设是,在从头训练策略时,深度信息最有帮助,因为它直接呈现了几何结构,并可能帮助策略更好地泛化到纹理、光照、桌面外观和背景的变化。我不太确定它对微调视觉-语言-动作模型(VLA)有多大帮助,因为这类模型通常使用仅包含 RGB 的数据进行预训练;在这种情况下,增加深度信息可能付出的工作量大于收益。无论如何,我都很期待能够针对学习型策略,对仅使用 RGB 和使用 RGB-D 的效果进行消融实验。
腕部摄像头通过 USB3 连接,Intel 提供了 pyrealsense2 SDK,可以通过 Python 访问它。遗憾的是,它与 macOS 不兼容。不过幸运的是,你不必自己从源码构建,因为社区维护了一个 pyrealsense2-macosx 版本,为 macOS Tahoe 提供了预构建的 wheel。运行它需要 sudo 权限,但除此之外,它的表现非常好。
在安装方式上,我希望摄像头略微倾斜,既能向下观察,又能让机器人的夹爪出现在视野中。根据我的经验,这非常有用,因为机器人可以在同一个参考系下感知自身位置与物体位置之间的关系。我为此找到了一个非常低技术含量的解决方案:使用一块切成 15° 斜角的木头。^88
这也是一个很好的例子,说明这套设备仍然带有很强的 DIY 色彩:机械臂、摄像头和摄像头支架都是现成产品,但要将摄像头精确放置到我想要的位置,仍然需要一个小型定制转接件。线缆布置也类似:腕部摄像头的 USB 线需要单独设计走线方案,因此我使用了便宜的夹扣式理线器,而不是什么专门定制的部件。
第二路视频来自 Logitech C920 网络摄像头。坦率地说,除了它还不错之外,没有太多可说的。它的主要卖点是价格非常便宜(不到 60 欧元)。我已经注意到,它的可靠性不如 Intel RealSense 腕部摄像头,偶尔会丢帧。它通过 USB2 连接,可以使用 opencv-python 包将其作为标准 UVC 设备访问。我以 720p 分辨率和 30 fps 运行它。[^99]
安装方面,我使用的是 SmallRig Desktop Magic Arm。C920 配有标准的 1/4 英寸三脚架螺纹,因此悬臂支架可以直接拧到摄像头上。
目前我不打算标定摄像头的外参或内参。这是一个刻意做出的权衡:标定可以让几何推理更加清晰,但也会增加配置成本;随着桌面、摄像头和光照发生变化,它还会成为另一个需要维护的环节。对于第一批学习型策略,我更想先看看仅凭原始图像观测能够做到什么程度。
那么,这些摄像头实际看到了什么?下面是从两个摄像头接收到的视频画面示例。对于腕部摄像头,我单独将 16 位深度通道可视化,并用颜色进行编码,以便人类更容易理解。你还可以在腕部摄像头视频画面的底部看到机器人的夹爪。
手动控制非常重要,原因有两个。第一,它让调试和测试系统成为可能。第二,大多数现代机器学习方法都依赖由人类遥操作员采集的演示数据。
可以使用键盘和鼠标控制机器人,但操作起来非常繁琐。因此,大多数人会采用主从式配置、^1010VR 头显和手柄,或者空间鼠标。由于我已经有一个空间鼠标,所以选择了最后一种方案:我使用的是 3Dconnexion SpaceMouse Wireless。
空间鼠标通常用于 3D 建模。它支持六轴输入:沿 x、y、z 方向平移,以及横滚、俯仰和偏航旋转。它非常适合控制平移和偏航旋转(可以将其理解为转动机器人的手腕)。但它不太适合控制横滚和俯仰,操作到最后会变得非常令人困惑。目前我的解决方案是将动作空间限制为四维:沿 x、y、z 方向平移,以及偏航旋转。横滚角和俯仰角固定不变,使夹爪始终与桌面保持轴向平行。无论如何,对大多数抓取和放置任务而言,这已经足够了,而且我怀疑这也会让策略学习变得更容易。
为了与空间鼠标交互,我使用了非常出色的 pyspacemouse 库。在 macOS 上还有一个小陷阱:它依赖 hidapi(可以通过 brew install hidapi 安装),而且 pyspacemouse 无法自行找到这个库。你不必修改 DYLD_LIBRARY_PATH,只需在导入 pyspacemouse 之前,通过 ctypes.CDLL("/opt/homebrew/opt/hidapi/lib/libhidapi.dylib", mode=ctypes.RTLD_GLOBAL) 预加载它即可。
物理设备搭建完成后,项目的另一半就是用于操作它的软件。所有软件都位于我从零编写的单个 Python 包 robo 中:感知、控制、遥操作、可视化、数据记录和遥测。目前它大约包含 3,000 行 Python 代码(不包括测试和一次性脚本),运行在我的 Mac 上。[^1111]
在介绍各个组成部分之前,我想先解释一下这个项目中影响最深远的决定:我有意没有基于 ROS 2 或 LeRobot 构建系统,[^1212]而是编写了自己的技术栈。这样做有两个原因:
我认为,对整个技术栈拥有完全控制权对于研究至关重要。研究意味着要去做框架作者未曾预料到的事情;当某些行为不符合预期时,我希望能够深入软件的任何部分并对其进行修改。
对全栈的理解甚至比控制权更加重要。软件本身就是学习型策略运行环境的一部分。控制频率、观测延迟、执行器行为:所有这些都会塑造学习问题。如果我确切知道从摄像头帧到达到电机命令发出之间发生了什么,就可以在开发和调试策略时把这些因素考虑进去。
需要说明的是,这并不是“所有东西都必须自己构建”的绝对主义。我大量依赖优秀的现成库:使用 MuJoCo 进行建模、运动学计算和可视化,使用 Rerun 进行数据记录和可视化,并使用 Prometheus、InfluxDB 和 Grafana 实现可观测性。我也使用 LeRobot 来训练和运行基线策略,并使用硬件厂商提供的 SDK。我坚持由自己掌控的部分,是将这些组件连接起来的架构。
[^77]: 我当然也会使用机器人的本体感知信息:关节角度、夹爪状态(张开或闭合),以及 TCP 位姿(通过正向运动学计算)。
[^99]: C920 能够以 30 fps 拍摄 1080p 视频,但对于学习型策略而言,720p 已经绰绰有余。
[^1111]: 为什么选择 Mac,而几乎所有机器人软件都默认使用 Linux?这与把机器人直接放在桌边的理由相同:让所有程序都运行在我每天工作的笔记本电脑上,是阻力最小的方式。而且现代 M 系列 Mac 性能极其强大,因此非常适合作为机器人计算平台。
[^1212]: 我仍然使用 LeRobot 来训练和运行基线策略;我只是决定不在与机器人交互相关的部分基于它的抽象进行构建。
机器人软件需要解决的核心问题是:许多事情会以不同的频率并发发生——摄像头以 30 fps 的速率传送帧,SpaceMouse 以 100 Hz 的频率报告数据,控制循环以 50 Hz 运行,查看器以 30 Hz 渲染,而遥测和录制也应该在不干扰上述任何任务的前提下尽可能跟上。ROS 通过经由网络、使用发布者和订阅者进行通信的节点来解决这个问题。我采用了同一思路的一个极度简化版本:所有组件都运行在同一个 Python 进程中,并通过一个简单的内存发布/订阅事件总线进行通信。
其中有两个核心抽象:Service 和 Event。Service 拥有一个线程,并具有生命周期。它分为两种:ScheduledService 以固定频率运行,并跟踪自己何时错过了预定的时钟周期;PollingService 则以底层设备提供数据的最快速度运行(用于摄像头,因为摄像头无论如何都会阻塞并等待下一帧)。Event 是一个不可变的 dataclass,会被发布到总线上。每个事件都携带三个时间戳:一个用于计算时间间隔的单调时间戳、一个用于跨系统关联的墙上时钟时间戳,以及一个可选的硬件时间戳(例如,RealSense 会报告摄像头时钟记录的实际拍摄时间)。
这种架构的主要优势在于它非常灵活,而且易于扩展:服务之间不存在直接耦合。因此,将一个服务替换为另一个服务非常容易:当我对机器人进行遥操作时,会使用一个监听 SpaceMouse 事件并驱动机器人的控制器。如果我想运行策略,只需将这个服务替换成策略控制器;系统的其余部分完全不需要知道这项变化。可互换的控制器服务还消除了一整类训练/推理不一致的 bug,因为技术栈中的大部分组件都保持不变。
故障处理被有意设计得非常激进:只要任何服务崩溃,整个会话就会退出——所有服务停止,实体机器人会暂停运行并断开连接。对于研究代码,我更愿意让它大张旗鼓地直接失败,也不愿让它在缺少某些关键数据(例如策略所依赖的摄像头画面)时勉强继续运行。
感知和控制是整个循环的两个组成部分,其他一切都依附于它们:一组服务观察世界并发布所看到的内容,另一组服务消费这些事件并驱动机器人。下面两个(仅做了少量简化的)服务展示了这种设计在实践中的样子。在感知侧,机器人观察器会轮询机械臂,并以固定频率发布其状态:
class RobotObserverService(ScheduledService):
"""Publishes the sensed robot state at a fixed frequency."""
def __init__(self, name: str, freq_hz: float, robot: Robot):
super().__init__(name=name, freq_hz=freq_hz)
self.robot = robot
def step(self):
state = self.robot.get_state()
self.publish(RobotStateEvent(state=state, ...))
遥操作控制器则是与之对应的订阅方。它会缓存最新的 SpaceMouse 事件,并按照控制频率将其转换成机器人命令:
class TeleopControlService(ScheduledService):
"""Turns SpaceMouse events into robot control actions."""
def __init__(self, name: str, freq_hz: float, robot: Robot):
super().__init__(name=name, freq_hz=freq_hz)
self.robot = robot
self._spacemouse_event_cache = LatestEventCache[SpaceMouseEvent](max_age_s=0.1)
def on_start(self):
self.subscribe("spacemouse", [SpaceMouseEvent], self._on_spacemouse)
def _on_spacemouse(self, event: Event):
if not isinstance(event, SpaceMouseEvent):
return
self._spacemouse_event_cache.set(event)
def step(self):
# We consume the spacemouse event, in order to
# avoid repeating the same command over and over again.
spacemouse_event = self._spacemouse_event_cache.pop()
if spacemouse_event is None:
return
# Convert the space mouse event into a robot
# command and actuate.
cmd = convert_to_cmd(spacemouse_event)
self.publish(RobotCommandEvent(cmd, ...))
self.robot.execute_cmd(cmd)
这里有两个值得指出的细节。首先,_on_spacemouse 会在发布服务的线程中同步运行,因此回调必须足够轻量:这里它只会将事件存入缓存(录制器等负载更重的消费者则会把事件放入一个有界队列),真正的工作会在该服务自身线程的 step() 中完成。1313你可能会想到 GIL。实践中,它并没有听起来那么棘手:繁重的工作(OpenCV 中的摄像头解码、MuJoCo 渲染、USB 和网络 I/O)都发生在会释放 GIL 的 C 扩展中,而纯 Python 的控制运算开销很低。即使调度性能真的下降了,指标也会立即告诉我。其次,该服务会消费缓存中的事件,并且缓存还会让事件在 100 ms 后过期:控制器宁可什么都不做,也不愿根据过时的输入采取行动。稍后会在安全部分详细讨论这一点。
与机械臂的所有交互都通过一个小型 Robot 接口完成,它本质上只有 get_state() 和 execute_cmd()。convert_to_cmd 生成的命令类型被有意设计得非常精简且无量纲:归一化的 TCP 平移和旋转增量,取值范围为 [−1,1][-1, 1][−1,1],再加上夹爪状态。这就是人类和策略共用的动作空间:SpaceMouse 的各个轴会直接映射到这个空间中,学习得到的策略也会输出完全相同的内容,因此演示数据所记录的空间,恰好就是策略之后采取动作时使用的空间。
xArm 实现会将这种命令转换为运动:归一化命令以当前 TCP 坐标系为基准进行解释,按照可配置的速度限制进行缩放,在一个 50 Hz 控制时钟周期内积分为绝对笛卡尔空间设定点,裁剪到安全区域以内,然后发送给机械臂。机械臂运行在它的“在线轨迹规划”模式下,将持续传入的 50 Hz 设定点序列融合成平滑运动。
一个用 Python 编写、运行在消费级笔记本电脑上的机器人技术栈,显然只能是软实时系统。我可以接受这种权衡,但必须能够看到它何时发生性能下降,而不是靠猜测。我希望能够了解系统中正在发生什么。
Prometheus 让我可以检查系统是否健康。每个服务都 ex