• 正文
  • 相关推荐
申请入驻 产业图谱

Jetson Thor开发指南 | 基于ROS2的OriginMan人形机器人智能控制系统设计与实现

06/15 10:34
279
加入交流群
扫码加入
获取工程师必备礼包
参与热点资讯讨论

转载自公众号:敢敢AUTOHUB

1. 引言

OriginMan 是一台桌面级人形机器人。它身上集合了舵机控制、相机图像采集、ROS2 节点通信、动作组编排、语音识别大模型文本交互——这些单独拉出来都能写一篇教程,但真正有意思的是把它们串起来之后的样子。

官方资料提供了各功能模块的运行命令和示例代码,但没有给出端到端的系统整合方案。所以这篇文章做的事情很简单:先把 OriginMan 自带的巡线、颜色识别、AprilTag、语音交互和动作模仿逐个拆开看一遍,搞清楚每个模块的输入输出和关键设计;然后用 RosClaw 把这些模块接上一个自然语言代理,让 Thor 能够通过网络发现 OriginMan 的能力、读取状态、下发控制命令。

2. 平台概况

OriginMan 主控为 RDK X5(八核 Cortex-A55, 10TOPS BPU, 8GB 内存 + 32GB 闪存),扩展控制用 MM32F103CBT6 配合 MPU6050 六轴 IMU。执行层是 20 自由度舵机——头部 2 个、双臂、双腿加踝关节。传感器方面:5MP 广角摄像头麦克风阵列、扬声器、128×64 OLED 和 RGB 灯环。软件环境是 Ubuntu 22.04 + ROS2 Humble + TogetheROS.Bot。

模块 规格 作用
主控 RDK X5, 八核 A55, 10TOPS, 8GB 视觉推理、ROS2 调度、语音与大模型
扩展控制 MM32F103CBT6 + MPU6050 低层状态采集
执行 20DOF 舵机 头部转动、步态、动作组
感知 5MP 摄像头、麦克风阵列、扬声器、OLED 视觉、语音、显示
软件 Ubuntu 22.04 + ROS2 Humble + TROS 节点通信、二次开发框架

RDK X5 与智能计算能力

图2 OriginMan 的主控平台与智能计算核心。图片来源:OriginMan 官方网站。

3. 系统架构

OriginMan 的控制系统可以抽象为四层:感知层(摄像头、IMU、麦克风)、通信层(ROS2 话题/服务/参数)、决策层(视觉识别→动作意图)、执行层(动作组、舵机控制)。

从公开示例代码看几个工程细节:各视觉节点用 create_timer(0.1, ...) 驱动,调度周期约 100ms;发布器默认队列深度 10,够用但接入高频传感器时需调 QoS;动作执行和感知不在同一个回调里,避免了图像处理阻塞舵机控制。

OriginMan 控制系统结构示意图

图3 OriginMan 智能控制系统总体结构图的 Gemini 出图描述。

4. 视觉巡线

官方 line_follower 的流程:缩放→高斯滤波→转 LAB→阈值分割→腐蚀膨胀→多 ROI 轮廓检测→加权计算中心。三个 ROI 从远到近权重递增(0.1/0.3/0.6),底部权重最高,因为它决定下一步会不会偏。控制策略也很直接:偏差 ≤50px 走 go_forward,偏左走 turn_right_small_step,偏右走 turn_left_small_step。不输出连续速度,直接映射到动作组。

self.roi = [
    (240, 280, 0, 640, 0.1),
    (340, 380, 0, 640, 0.3),
    (440, 480, 0, 640, 0.6),
]

if abs(self.line_center_x - self.img_centerx) <= 50:
    AGC.runActionGroup('go_forward')
elif self.line_center_x - self.img_centerx > 50:
    AGC.runActionGroup('turn_right_small_step')
else:
    AGC.runActionGroup('turn_left_small_step')

这种离散映射的好处是感知和执行粒度对齐——感知输出三类(左/右/直),执行也只有三类动作组,不会出现"感知给连续曲率、执行只能选离散动作"的接口失配。

5. 颜色识别

颜色识别在 LAB 空间内分别检测红、绿、蓝,取面积最大的颜色作为结果。识别到红色→点头,绿色或蓝色→摇头。

self.color_list.append(color)
if len(self.color_list) == 3:
    color = int(round(np.mean(np.array(self.color_list))))
    self.color_list = []

上面这段 3 帧取平均的逻辑值得注意。视觉检测最常见的坑不是完全检测不到,而是偶发误检——如果每帧都触发动作,机器人会抖个不停。攒 3 帧求平均再决定,牺牲一点响应速度换稳定性,简单但实用。

颜色跟踪模式下,检测到红色目标后,头部舵机会跟随目标坐标转动——图像偏差直接转化为舵机角度调整,把"检测"和"执行"连成了闭环。

6. AprilTag

官方 apriltag_detect 示例:摄像头采图→灰度化→ apriltag.Detector 检测→绘制角点→发布 tag_id 和 tag_family 到 ROS2 话题。

gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
detections = self.detector.detect(gray, return_image=False)
for detection in detections:
    tag_id = int(detection.tag_id)
    corners = np.int0(detection.corners)

当前示例只用了 tag_id 和角点坐标做展示,没有引入相机内参和 PnP 位姿解算。但 AprilTag 本身支持相对位姿计算——一旦结合相机标定参数,就可以从"看见标签"升级到"知道标签在空间中的位置",这在导航、定点停靠等场景中很有用。

AprilTag 识别示意

图4 OriginMan 的 AprilTag 识别效果示意。图片来源:OriginMan 官方文档。

7. 动作模仿

模仿动作通过手势触发:OK 启动→Palm 进入模仿(手臂跟随人运动)→Victory 夹爪开合→ThumbLeft/Right 左右移动→ThumbUp 退出并鞠躬。

手势 响应
OK 点头,启动控制
Palm 双臂跟随人的手臂
Victory 夹爪重复开合
ThumbLeft 左移
ThumbRight 右移
ThumbUp 鞠躬,退出

这套机制把视觉理解、手势分类、状态切换和动作执行串成了一条通路。注意:如果进入模仿动作页面后没有摄像头画面,执行 sudo systemctl stop originman.service 释放 /dev/video0 再试。

动作模仿功能示意

图5 OriginMan 模仿动作功能界面。图片来源:OriginMan 官方文档。

8. RosClaw联动

ROSClaw 是一个旨在连接人工智能与物理机器人的开源桥接与控制框架,主要用于让AI智能体能够理解并操控现实世界中的物理机器人。这里我们尝试通过接入RosClaw,让Jetson Thor中的本地LLM可以拥有控制OriginMan的能力。

如果各位想要体验OpenClaw驱动OriginMan的对应功能的话,我建议直接将本文给到OpenClaw分析后直接开始部署即可。

8.1 部署目标

OriginMan 端跑 ROS2、功能节点、rosbridge 和 discovery;Thor 端跑 OpenClaw Gateway + RosClaw 插件。最小版本四步:装 rosbridge→装 discovery→配 Thor 端插件→只读验证。

8.2 环境准备

前提:OriginMan 能独立跑官方功能包,Thor 已装 OpenClaw,两台机器互通。

# OriginMan 端
hostname -I && ping <thor_ip>

# Thor 端
node -v && pnpm -v && ping <originman_ip>

建议先把这些官方示例跑通一次,否则后面联调只会更难排错:

ros2 run originman_line_follower line_follower
ros2 run originman_color_detect color_detect
ros2 run originman_color_detect color_track
ros2 launch originman_action_imitation action_imitation.launch.py

8.3 下载仓库

Thor 和 OriginMan 两边都克隆一份 RosClaw:

mkdir -p ~/robot_ws && cd ~/robot_ws
git clone https://github.com/PlaiPin/rosclaw.git
cd rosclaw && git rev-parse HEAD  # 记下当前 commit

8.4 OriginMan 端安装

# 安装 rosbridge
sudo apt update && sudo apt install -y ros-humble-rosbridge-suite

# 安装构建工具
sudo apt install -y python3-colcon-common-extensions python3-rosdep
sudo rosdep init && rosdep update

# 创建工作区并拷贝包
mkdir -p ~/rosclaw_ws/src
cd ~/rosclaw_ws/src
cp -R ~/robot_ws/rosclaw/ros2_ws/src/rosclaw_msgs .
cp -R ~/robot_ws/rosclaw/ros2_ws/src/rosclaw_discovery .
cd ~/rosclaw_ws
source /opt/tros/humble/setup.bash
rosdep install --from-paths src --ignore-src -r -y
colcon build --symlink-install
source install/setup.bash

# 启动 discovery
ros2 run rosclaw_discovery discovery_node --ros-args 
  -p robot_name:=OriginMan 
  -p robot_namespace:=/originman 
  -p publish_interval:=5.0

# 另开终端启动 rosbridge
ros2 launch rosbridge_server rosbridge_websocket_launch.xml 
  port:=9090 address:=0.0.0.0

# 确认端口
ss -lntp | rg 9090

8.5 OriginMan 端验证

source ~/rosclaw_ws/install/setup.bash
ros2 topic echo /rosclaw/capabilities --once
ros2 service call /rosclaw/get_capabilities rosclaw_msgs/srv/GetCapabilities 
  "{robot_namespace: ''}"

看到 robot_nametopic_names 等字段且返回 success: true 即可。

8.6 Thor 端准备

cd ~/robot_ws/rosclaw
pnpm install
pnpm typecheck
pnpm build

确认 Gateway 运行:

openclaw gateway run --port 18789 --verbose
# 或守护进程模式
systemctl --user status openclaw-gateway

8.7 插件接入

在 ~/.openclaw/openclaw.json 中添加 RosClaw 插件配置:

{
  "plugins": {
    "enabled": true,
    "load": {
      "paths": ["/home/thor/robot_ws/rosclaw/extensions/openclaw-plugin"]
    },
    "entries": {
      "rosclaw": {
        "enabled": true,
        "config": {
          "transport": { "mode": "rosbridge" },
          "rosbridge": {
            "url": "ws://192.168.127.10:9090",
            "reconnect": true,
            "reconnectInterval": 3000
          },
          "robot": {
            "name": "OriginMan",
            "namespace": "/originman"
          }
        }
      }
    }
  }
}

也可以用命令行逐条设置:

openclaw config set plugins.enabled true --strict-json
openclaw config set plugins.load.paths '["/home/thor/robot_ws/rosclaw/extensions/openclaw-plugin"]' --strict-json
openclaw config set plugins.entries.rosclaw.enabled true --strict-json
openclaw config set plugins.entries.rosclaw.config.transport.mode '"rosbridge"' --strict-json
openclaw config set plugins.entries.rosclaw.config.rosbridge.url '"ws://192.168.127.10:9090"' --strict-json
openclaw config set plugins.entries.rosclaw.config.rosbridge.reconnect true --strict-json
openclaw config set plugins.entries.rosclaw.config.rosbridge.reconnectInterval 3000 --strict-json
openclaw config set plugins.entries.rosclaw.config.robot.name '"OriginMan"' --strict-json
openclaw config set plugins.entries.rosclaw.config.robot.namespace '"/originman"' --strict-json
openclaw config validate

改完重启 Gateway:

systemctl --user restart openclaw-gateway
# 或前台调试
openclaw gateway run --port 18789 --verbose

8.8 启动顺序

1. OriginMan 端启动功能节点或适配层

2. OriginMan 端启动 rosclaw_discovery

3. OriginMan 端启动 rosbridge_server

4. Thor 端启动 OpenClaw Gateway

5. 确认连接: openclaw gateway probe + 观察日志中 WebSocket 连接状态

8.9 模式控制

如果想让 RosClaw 真正控制 OriginMan 的巡线、颜色识别、AprilTag 和动作组,不建议直接把零散 demo 节点暴露给代理。更稳妥的方式是在 OriginMan 端先加一个适配层。本节重点介绍实际开发的 originman_rosclaw_adapter 包及其验证结果。

8.9.1 适配器设计:话题驱动的统一控制层

实测中我们选择了一种**话题驱动(topic-based)**的简洁架构,而非更复杂的服务/动作接口。理由有三:一是话题天然支持一对多观察(调试时 OriginMan 端和 Thor 端可以同时监听),二是 std_msgs/msg/String 消息足够表达所有控制语义,三是可以快速与 RosClaw 的现有工具集成,无需额外注册自定义服务类型。

适配器包结构如下,adapter_node.py的代码我放在文章的最后了,其他的内容可以让openclaw帮你自动补齐:

rosclaw_ws/src/originman_rosclaw_adapter/
├── package.xml              # ament_python 包声明
├── setup.py                 # entry_point → adapter_node
├── setup.cfg
└── originman_rosclaw_adapter/
    ├── __init__.py
    └── adapter_node.py      # 主适配器节点(约 250 行)

控制接口(适配器订阅):

话题 类型 功能
/originman/cmd/mode String 模式切换:color_detect / apriltag / line_follow / color_track / audio_control / stop
/originman/cmd/tts String 文字转语音(走 I2C smbus 路径,当前不可用)
/originman/cmd/action String 动作组指令(安全模式下仅记录不执行)

状态接口(适配器发布):

话题 类型 功能
/originman/state String(JSON) 每 2 秒推送系统状态
/originman/camera/image_raw Image 桥接 /image_raw
/originman/detection_info String 桥接检测结果(/color_info/apriltag_info/line_info)

关于"话题从哪来":一个容易混淆的点是——RosClaw 本身(rosbridge + rosclaw_discovery)只提供传输通道和能力发现,并不创建 /originman/state/originman/cmd/mode 这些话题。

实际的角色分工如下:

组件 提供 说明
rosbridge_server WebSocket ↔ ROS2 桥接 传输层,不创建业务话题
rosclaw_discovery /rosclaw/capabilities 话题 + /rosclaw/get_capabilities 服务 周期扫描 ROS2 图上的话题/服务/动作并打包发布,供 Thor 端自动发现
originman_rosclaw_adapter /originman/state/originman/camera/image_raw/originman/detection_info 等全部 /originman/* 话题;订阅 /originman/cmd/* 控制话题 自行开发的自定义节点,这是所有 OriginMan 专属话题的创建者
RosClaw 插件(Thor 端) 上下文注入 + 工具注册 读取 /rosclaw/capabilities 或 /originman/state,将机器人能力注入 agent 会话

换句话说:如果在 OriginMan 上只装 rosbridge 和 discovery、不写适配器,Thor 端 agent 能看到 ROS2 图上有哪些话题,但不会有统一的 /originman/state 来汇总机器人状态,也不会有 /originman/cmd/mode 来切换功能。适配器才是把零散功能节点包装成可控接口的关键环节

适配器注册了五种模式,每种模式对应 OriginMan 官方功能包中的一个独立节点:

模式 调用的 ROS2 节点 是否运动 话题桥接
color_detect originman_color_detect color_detect /color_info
apriltag originman_apriltag_detect apriltag_detect /apriltag_info
line_follow originman_line_follower line_follower ⚠️ /line_info
color_track originman_color_detect color_track ⚠️ /color_info
audio_control originman_audio_control audio_control_node ⚠️ -
8.9.2 构建与启动

适配器构建依赖于 OriginMan 官方的 dev_ws(提供 originman_* 功能包)和 rosclaw 工作区:

cd ~/rosclaw_ws
source /opt/tros/humble/setup.bash
source /userdata/dev_ws/install/setup.bash
colcon build --symlink-install --packages-select originman_rosclaw_adapter

# 启动适配器
source install/setup.bash
ros2 run originman_rosclaw_adapter adapter_node

启动后适配器输出:

[originman_adapter]: 适配器就绪 | 可用模式: color_detect, apriltag, line_follow, color_track, audio_control
[originman_adapter]: 控制话题: /originman/cmd/{mode,tts,action}
[originman_adapter]: 状态话题: /originman/{state,camera/image_raw,detection_info}
8.9.3 模式切换验证

验证按照"先静态后动态"的原则进行——先用不会运动的 AprilTag 识别确认链路贯通,再测试会驱动机器人运动的视觉巡线。

(1)AprilTag 标签识别(无运动,先验链路)

ros2 topic pub --once /originman/cmd/mode std_msgs/msg/String "data: 'apriltag'"

命令下发后适配器立即响应,日志输出:

[originman_adapter]: Starting: AprilTag 识别 - 检测二维码标签
[originman_adapter]: Bridge: /image_raw + /apriltag_info
[originman_adapter]: Active: apriltag

状态话题在约 1 秒内从 idle 切换为 apriltag,同时 camera 字段从不活跃变为活跃——模式切换和相机上电在同一个状态更新中完成,不需要分别等待。

在摄像头前方放置一张 tag36h11 系列的 ID=1 标签后,连续采集了 10 秒的检测结果:

指标 实测值
检测次数 37 次(~3.7 Hz)
命中率 100%(37/37,无漏检无误检)
Tag ID 1(稳定识别,从未跳变)
家族 tag36h11
状态推送 4 次(~2.5 秒间隔)

全程检测结果保持一致,未出现 tag_id 跳变或"未检测到"的帧。当摄像头前方未放置标签时,检测结果正确返回 "未检测到 AprilTag",说明适配器对空场景的处理也是正确的。

AprilTag 模式不触发任何舵机运动,是最安全的联调首选——用它确认链路畅通后,再进行下面的巡线测试。

使用OpenClaw调用AprilTag识别功能

(2)视觉巡线(会运动,验证闭环)

ros2 topic pub --once /originman/cmd/mode std_msgs/msg/String "data: 'line_follow'"

适配器依次执行后台节点启动、话题桥接和舵机初始化,约 2 秒后状态切为 line_follow、相机激活。

巡线的控制逻辑不变:当黑线中心 X 坐标与画面中线(320px)偏差 ≤50px 时执行 go_forward,偏左(X<270)时右转修正,偏右(X>370)时左转修正。三次独立的 30 秒巡线测试覆盖了三种典型路径:

测试 路线特征 检测量 X 均值 标准差 X 范围 死区占比 跳变次数
Run 1 带急弯 259 条 281.5 40.7 189~451 54% 数十次
Run 2 缓左偏 263 条 264.9 15.8 207~297 45% 6 次
Run 3 平直偏左 202 条 273.4 21.5 216~318 58% 1 次

三次测试展现了不同的控制特征:Run 1 的急弯导致 X 值一度冲到 451(偏差 +131px),触发猛烈的左转修正,随后经历"丢线→重捕"循环后恢复正常;Run 2 和 Run 3 整体平缓,全程只需右转微调,死区内直行占比在 45%~58% 之间。

三次均未出现模式异常切换或节点崩溃,检测频率稳定在 6.7~8.7 Hz。发送 stop 后机器人立即回到 idle,相机释放、舵机停转。

使用OpenClaw调用巡线功能

8.9.4 验证汇总

测试项 结果 关键数据
适配器启动 5 个模式注册,3 个控制话题 + 3 个状态话题
状态发布 每 ~2 秒推送 JSON,active_mode / camera / robot 字段联动正确
apriltag 模式 10s 内 37 次检测,100% 命中,Tag ID=1 tag36h11,无跳变无误检
line_follow 模式 3 次 30s 连续巡线,覆盖急弯/缓弯/直道,检测频率 6.7~8.7 Hz
stop 模式切换 任意模式下发 stop 后 ~2s 回到 idle,相机释放、节点退出

上述结果表明:通过一个约 250 行 Python 的话题驱动适配层,RosClaw 能够在 Thor 端通过自然语言代理发现、切换和监控 OriginMan 的功能模式。AprilTag 识别和视觉巡线的端到端验证覆盖了"静态检测"和"动态控制"两类场景,确认了 rosbridge → 适配器 → 功能节点 → 状态回传的完整数据闭环。

8.10 故障排查

实机部署中遇到的问题可以分为十类,前四类为预判型排查项(已在部署前规划),其余为实测中逐步暴露的新问题。

问题一:9090 端口不通

表现为 Thor 无法连接 rosbridge。

ss -lntp | rg 9090
ping 192.168.127.10

实测中 Thor ↔ OriginMan 为有线直连(192.168.127.1 ↔ 192.168.127.10),延迟仅 0.2ms,从未出现端口不通。但 rosbridge 启动失败时该端口的监听自然不存在,需检查环境 source 是否正确。

问题二:/rosclaw/capabilities 无输出
source ~/rosclaw_ws/install/setup.bash
ros2 topic echo /rosclaw/capabilities --once
ros2 service call /rosclaw/get_capabilities rosclaw_msgs/srv/GetCapabilities "{robot_namespace: ''}"

实测中 topic echo 返回了正确的能力清单(robot_name=OriginMan, namespace=/originman)。但如果 discovery_node 启动时未同时 source dev_ws 和 rosclaw_ws,将无法发现功能节点,导致 capabilities 中的 topic/service/action 列表为空。

问题三:Gateway 插件未连接 ROS2
openclaw gateway restart
openclaw gateway probe
journalctl --user -u openclaw-gateway -f

实测中 Gateway 重启后 WebSocket 连接到 ws://192.168.127.10:9090,会话上下文中注入 Robot: OriginMan 标记即为连接成功。如果标记未出现,最可能的原因是 openclaw.json 中 rosbridge.url 的 IP 地址与 OriginMan 实际有线 IP 不匹配。

问题四:命名空间不统一

手册建议将关键接口统一挂到 /originman/... 前缀下。实测中 rosclaw_discovery 通过 robot_namespace:=/originman 参数实现了这一要求,适配器也遵循了 /originman/ 前缀约定。验证命令:

ros2 node list
ros2 topic list | rg '^/originman/'
ros2 service list | rg '^/originman/'
问题五:重复 rosbridge 实例

这是实测中首次暴露的问题。 在反复调试过程中,多次执行 ros2 launch rosbridge_server 而未先清理旧进程,导致同一台机器上出现 3 个 rosbridge_websocket 实例。端口 9090 仍被第一个实例占用,但后续实例的 ros2 节点注册和参数服务器访问可能产生冲突。

排查方法:

ps aux | grep rosbridge | grep -v grep
# 如果看到多个 python3 进程,说明有残留实例

解决方法:

pkill -f rosbridge_server
sleep 2
# 确认清理干净后重新启动
ros2 launch rosbridge_server rosbridge_websocket_launch.xml port:=9090 address:=0.0.0.0

建议在启动脚本中始终先执行 pkill 清理,避免残留累积。

问题六:摄像头被后台服务占用
sudo systemctl stop originman.service
ls /dev/video*

实测中这是最常见的阻塞点:OriginMan 默认开机服务 originman.service 会启动 OriginMan.py,独占 /dev/video0。如果忘记停止该服务,适配器启动 color_detect 或 line_follow 模式时,其内部 cv2.VideoCapture(0) 将无法打开摄像头。

实测验证:停掉 originman.service 后,所有视觉模式(颜色识别、AprilTag、巡线)的相机初始化均恢复正常。

问题七:LAB 阈值错误导致视觉检测失败

这是实测中最隐蔽的问题之一。 巡线模式启动后相机正常工作,但即使地面已铺黑线,检测结果始终为 Line Center X: -1。通过采样分析发现,原始代码中的 LAB 阈值存在问题:

# 原始阈值(错误:A>166 是在找红色,不是黑色)
'black': {'min': [0, 166, 135], 'max': [255, 255, 255]}

# 修正后阈值(基于实测暗色像素的 LAB 分布)
'black': {'min': [0, 108, 100], 'max': [90, 148, 143]}

排查方法:采样 ROI 区域最暗像素,对比实际 LAB 值与代码阈值:

import cv2, numpy as np
img = cv2.imread('snapshot.jpg')
roi = img[240:480, 0:640]  # 巡线 ROI
lab = cv2.cvtColor(roi, cv2.COLOR_BGR2LAB)
dark_mask = cv2.cvtColor(roi, cv2.COLOR_BGR2GRAY) < 40
dark_lab = lab.reshape(-1, 3)[dark_mask.reshape(-1)]
print(f'L: {np.mean(dark_lab[:,0]):.0f}±{np.std(dark_lab[:,0]):.0f}')
print(f'A: {np.mean(dark_lab[:,1]):.0f}±{np.std(dark_lab[:,1]):.0f}')
print(f'B: {np.mean(dark_lab[:,2]):.0f}±{np.std(dark_lab[:,2]):.0f}')

如果 A 通道均值远低于 166(如 126),B 通道均值低于 135(如 122),则说明黑色像素实际为中性灰,需放宽阈值至中性灰范围。

解决方法:直接编辑安装目录下的 line_follower.py,将 lab_data 字典中的 black 阈值修正为暗色中性灰范围。如使用 --symlink-install,需确认修改的是安装路径下的文件,而非源码路径。修正后重新启动巡线模式即可生效。

问题八:TTS 双路径--smbus 不可用但 I2S 正常

这是实测中暴露的另一个问题。 OriginMan 同时存在两条音频输出路径:

路径 驱动方式 实测结果
I2C smbus → 板载 TTS 芯片 smbus.SMBus(0) 写 0x40 地址 ❌ [Errno 121] Remote I/O error
I2S → MAX98357A 功放 aplay -D plughw:0 ✅ 正常

适配器的 TTS 模块只实现了第一条路径(smbus),因此始终不可用。进一步排查发现声卡 duplexaudioi2s1(card 0)的 MAX98357A 驱动已正确加载:

$ lsmod | grep max98357
snd_soc_max98357a      20480  0

$ aplay -l
card 0: duplexaudioi2s1 [duplex-audio-i2s1], device 0:
  i2s1-voicehat-hifi voicehat-hifi-0

解决方案:在适配器中新增一条备用音频路径,当 smbus 初始化失败时自动降级为子进程调用 aplay,桥接 /originman/cmd/tts 文本到 WAV 文件的生成与播放链路。

问题九:ros2 topic pub 超时问题

使用 ros2 topic pub --once 向非持久化话题发送消息时,如果消息发出后没有订阅者在监听,默认等待匹配订阅者的行为可能导致超时。实测中对 /originman/cmd/mode 话题使用 --once 时偶发 timeout。

解决方法:使用 -1(发送后立即退出)+ timeout 外壳命令:

timeout 5 ros2 topic pub -1 /originman/cmd/mode std_msgs/msg/String 'data: stop'
问题十:DashScope 云 TTS 欠费

OriginMan 自带的 TTS 工具 audio_generator.py 依赖阿里云 DashScope API(CosyVoice 模型),实测时 API 返回 Arrearage 错误,账户处于欠费状态。这不影响本地 WAV 文件播放(aplay),但意味着无法通过云端实时合成任意文本。如需恢复云 TTS 功能,需在阿里云控制台充值并更新 API Key。

问题十一:RosClaw 插件工具未注入 Agent

这是验证阶段的最后一个障碍。 插件加载、transport 连接、机器人上下文注入均正常,但 ros2_list_topics 等工具始终未出现在 agent 的函数列表中。通过在插件源码中嵌入日志确认:所有 7 个工具已通过 api.registerTool() 成功注册到插件注册表,但 OpenClaw 2026.4.22 的 session 级工具解析器没有将它们传递给当前 agent session。

排查方法

    1. 1. 检查 Gateway 日志确认插件加载:

grep rosclaw /tmp/openclaw/*.log

    2. 从 Thor 端直接通过 rosbridge WebSocket 调用 API,确认链路可用:
const ws = new WebSocket('ws://192.168.127.10:9090');
ws.send(JSON.stringify({op:'call_service', service:'/rosapi/topics'}));
    3. 如果 WebSocket 直连能列 topic(Thor 无需 ROS2),说明传输链路正常,问题在插件→agent 工具映射环节。

解决方法:需要在 RosClaw 插件中适配 OpenClaw 最新版的 AgentTool 类型签名,特别是 execute 函数的 this: void 绑定和返回类型。更根本的方案是由插件作者更新以匹配当前 OpenClaw plugin SDK。当前缓解措施:通过 SSH 或 WebSocket 直连 rosbridge 手工调用。

故障排查速查表
序号 现象 首选排查命令 常见根因
1 Thor 连不上 ss -lntp | rg 9090 rosbridge 未启动或 IP 错误
2 无 capabilities ros2 topic echo /rosclaw/capabilities --once discovery_node 未启动或 source 不全
3 插件无反应 openclaw gateway restart Gateway 未重载配置
4 话题前缀混乱 ros2 topic list | rg '^/originman/' 命名空间参数未设置
5 多个 rosbridge ps aux | grep rosbridge 未清理旧进程
6 相机打不开 systemctl stop originman.service 后台服务占用 /dev/video0
7 视觉检测全失败 采样 ROI LAB 值对比阈值 LAB 阈值错误(A>166 非黑)
8 TTS 没声音 aplay -l + lsmod | grep max98357 smbus 驱动不可用,切换 aplay
9 pub 命令超时 加 -1 参数 + timeout 外壳 --once 等待匹配订阅者
10 云 TTS 报错 检查 API Key 余额 DashScope 账户欠费
11 ros2_* 工具不出现 grep rosclaw /tmp/openclaw/*.log 插件 API 版本不兼容,工具注册但未注入 session

这十一类问题覆盖了从传输层、ROS2 图、视觉算法、硬件驱动到第三方 API 的全链路排查路径。实测经验表明,大部分问题在脱离 OpenClaw 代理、直接在 OriginMan 端手工调用目标接口时即可定位--手工调用不通则根因在机器人侧,手工调用通而代理不一致则需检查 RosClaw 配置。

8.12 本节结论

RosClaw 接入 OriginMan 最稳妥的路线是按顺序推进,而不是一次性全自动。先装 rosbridge 和 rosclaw_discovery,确认能力清单和传输都通了;再做只读验证和低风险控制;最后通过适配层把巡线、AprilTag 和动作组交给代理。

实测全程沿这条路线推进,关键结果:

传输:Thor ↔ OriginMan 有线直连(192.168.127.1 ↔ 192.168.127.10),延迟 0.2ms,rosbridge WebSocket 稳定•

发现:rosclaw_discovery 周期发布能力清单,Gateway 自动注入 Robot: OriginMan•

只读:话题列表、状态 JSON、相机画面全部通过•

适配:约 250 行的 originman_rosclaw_adapter 以话题驱动方式统一包装了 5 种功能模式•

控制:AprilTag(10s/37次/100%命中)和巡线(3轮30s/急弯缓弯直道全覆盖)模式切换正常,闭环验证通过•

排障:梳理了 11 类实测问题,从端口、命名空间、摄像头冲突到 LAB 阈值错误和 TTS 双路径

第九节从方案到实施、到踩坑、到修复,是一套可以直接在机器上复现的部署流程。

9. 结论

写这篇文章的初衷很简单:OriginMan 官方资料给出了每个功能模块怎么跑,但没说怎么把它们串成一个可以远程控制的完整系统。

所以这篇文章做的事情就是补上这一环。

我们先把 OriginMan 自带的巡线、颜色识别、AprilTag、语音交互和动作模仿逐个拆了一遍,关注的是代码里实际做了什么,而不是官方文档说了什么。然后通过 RosClaw,在 Thor 和 OriginMan 之间架起了一套完整的桥接:rosbridge 负责传输,rosclaw_discovery 负责能力发现,约 250 行的适配器把零散的功能节点包装成统一的话题接口。

实测中走通了从只读查看到模式控制的完整链路,AprilTag 和巡线两种模式经过了多轮闭环验证。也踩了不少坑——LAB 阈值把红色当黑色、TTS 卡在 I2C 总线、摄像头被后台服务霸占、rosbridge 多实例冲突——每个坑的具体排查方法和最终修复都记录在第九节里。

如果你也在用 OriginMan + RosClaw 搭类似的东西,希望这篇文章能帮你省掉一些踩坑的时间。

参考资料

1. OriginMan 官方首页与技术规格:https://originman.guyuehome.com/

2. OriginMan 视觉巡线文档:https://originman.guyuehome.com/application/line_follower/

3. OriginMan 颜色识别与颜色跟踪文档:https://originman.guyuehome.com/application/color_recognition/

4. OriginMan AprilTag 识别文档:https://originman.guyuehome.com/zh/application/airiltag/

5. OriginMan 语音识别文档:https://originman.guyuehome.com/zh/application/speech_recognition/

6. OriginMan 大语言模型文本交互文档:https://originman.guyuehome.com/zh/application/large_model/

7. OriginMan 语音交互与控制文档:https://originman.guyuehome.com/zh/application/audio_control/

8. OriginMan 模仿动作文档:https://originman.guyuehome.com/zh/application/imitate_action/

9. ROS2 Humble 文档,About Nodes:https://docs.ros.org/en/humble/Concepts/Basic/About-Nodes.html

10. ROS2 Humble 文档,About Quality of Service Settings:https://docs.ros.org/en/humble/Concepts/Intermediate/About-Quality-of-Service-Settings.html

11. AprilTag 官方资料:https://april.eecs.umich.edu/software/apriltag.html

12. RosClaw 仓库 README:https://github.com/PlaiPin/rosclaw

13. RosClaw 插件入口 src/index.ts:https://raw.githubusercontent.com/PlaiPin/rosclaw/main/extensions/openclaw-plugin/src/index.ts

14. RosClaw 配置定义 src/config.ts:https://raw.githubusercontent.com/PlaiPin/rosclaw/main/extensions/openclaw-plugin/src/config.ts

15. RosClaw 传输服务 src/service.ts:https://raw.githubusercontent.com/PlaiPin/rosclaw/main/extensions/openclaw-plugin/src/service.ts

16. RosClaw 机器人上下文注入 src/context/robot-context.ts:https://raw.githubusercontent.com/PlaiPin/rosclaw/main/extensions/openclaw-plugin/src/context/robot-context.ts

17. RosClaw 安全钩子 src/safety/validator.ts:https://raw.githubusercontent.com/PlaiPin/rosclaw/main/extensions/openclaw-plugin/src/safety/validator.ts

18. RosClaw 发现节点 rosclaw_discovery/discovery_node.py:https://raw.githubusercontent.com/PlaiPin/rosclaw/main/ros2_ws/src/rosclaw_discovery/rosclaw_discovery/discovery_node.py

19. RosClaw 能力描述消息 CapabilityManifest.msg:https://raw.githubusercontent.com/PlaiPin/rosclaw/main/ros2_ws/src/rosclaw_msgs/msg/CapabilityManifest.msg

20. RosClaw 能力查询服务 GetCapabilities.srv:https://raw.githubusercontent.com/PlaiPin/rosclaw/main/ros2_ws/src/rosclaw_msgs/srv/GetCapabilities.srv

21. OpenClaw 配置文档 openclaw config set:https://docs.openclaw.ai/cli-reference/config/config-set

22. OpenClaw 网关文档 openclaw gateway run:https://docs.openclaw.ai/cli-reference/gateway/gateway-run

23. OpenClaw 插件文档 openclaw.plugin.json:https://docs.openclaw.ai/plugins/build/openclaw-plugin-json

adapter_node.py

#!/usr/bin/env python3
"""
ironman_rosclaw_adapter - Bridges OriginMan robot nodes to RosClaw.

Uses topic-based control for simplicity (no custom services needed).
Control topics (subscribe):
  /originman/cmd/mode  (String) - "color_detect"|"apriltag"|"stop"
  /originman/cmd/tts   (String) - text to speak
  /originman/cmd/action (String) - action group name

Status topics (publish):
  /originman/state           (String) - JSON system state
  /originman/camera/image_raw (Image) - bridged camera
  /originman/detection_info  (String) - bridged detection results
"""

import rclpy
from rclpy.node import Node
from std_msgs.msg import String
from sensor_msgs.msg import Image
import subprocess
import signal
import os
import json
import threading
import time


class AdapterNode(Node):
    AVAILABLE_MODES = {
        'color_detect': {
            'desc': '颜色识别 - 检测红/绿/蓝物体',
            'movement': False,
            'pkg': 'originman_color_detect',
            'exec': 'color_detect',
            'det_topic': '/color_info',
        },
        'apriltag': {
            'desc': 'AprilTag 识别 - 检测二维码标签',
            'movement': False,
            'pkg': 'originman_apriltag_detect',
            'exec': 'apriltag_detect',
            'det_topic': '/apriltag_info',
        },
        'line_follow': {
            'desc': '视觉巡线 - 跟踪地面黑线 [会移动]',
            'movement': True,
            'pkg': 'originman_line_follower',
            'exec': 'line_follower',
            'det_topic': '/line_info',
        },
        'color_track': {
            'desc': '颜色追踪 - PID追踪彩色物体 [会移动]',
            'movement': True,
            'pkg': 'originman_color_detect',
            'exec': 'color_track',
            'det_topic': '/color_info',
        },
        'audio_control': {
            'desc': '语音控制 - 接收文本指令 [会执行动作]',
            'movement': True,
            'pkg': 'originman_audio_control',
            'exec': 'audio_control_node',
            'det_topic': None,
        },
    }

    def __init__(self):
        super().__init__('originman_adapter')
        self.active_mode = 'idle'
        self.current_process = None
        self.process_lock = threading.Lock()

        # Publishers
        self.state_pub = self.create_publisher(String, '/originman/state', 10)
        self.image_pub = self.create_publisher(Image, '/originman/camera/image_raw', 10)
        self.detection_pub = self.create_publisher(String, '/originman/detection_info', 10)

        # Subscribers (control commands)
        self.mode_sub = self.create_subscription(
            String, '/originman/cmd/mode', self.mode_cmd_cb, 10)
        self.tts_sub = self.create_subscription(
            String, '/originman/cmd/tts', self.tts_cmd_cb, 10)
        self.action_sub = self.create_subscription(
            String, '/originman/cmd/action', self.action_cmd_cb, 10)

        # Dynamic bridges
        self._image_sub = None
        self._detection_sub = None

        # TTS init
        self.tts_ok = self._init_tts()

        # State timer
        self.state_timer = self.create_timer(2.0, self._publish_state)
        self._publish_state()

        self.get_logger().info('适配器就绪 | 可用模式: ' +
                               ', '.join(self.AVAILABLE_MODES.keys()))
        self.get_logger().info('控制话题: /originman/cmd/{mode,tts,action}')
        self.get_logger().info('状态话题: /originman/{state,camera/image_raw,detection_info}')

    # ── TTS ──
    def _init_tts(self):
        try:
            import smbus
            self.tts_bus = smbus.SMBus(0)
            self.tts_addr = 0x40
            return True
        except Exception as e:
            self.get_logger().warn(f'TTS init failed: {e}')
            return False

    def _tts_speak(self, text):
        if not self.tts_ok:
            return False
        try:
            head = [0xFD, 0x00, 0x00, 0x01, 0x00]
            words = text.encode('gb2312')
            length = len(words) + 2
            head[1] = length >> 8
            head[2] = length
            head.extend(list(words))
            self.tts_bus.write_i2c_block_data(self.tts_addr, 0, head)
            return True
        except Exception as e:
            self.get_logger().error(f'TTS error: {e}')
            return False

    # ── Process Management ──
    def _launch(self, mode):
        info = self.AVAILABLE_MODES[mode]
        env = os.environ.copy()
        env['PYTHONUNBUFFERED'] = '1'
        self.get_logger().info(f'Starting: {info["desc"]}')
        return subprocess.Popen(
            ['ros2', 'run', info['pkg'], info['exec']],
            stdout=subprocess.PIPE, stderr=subprocess.PIPE,
            env=env, preexec_fn=os.setsid)

    def _kill_current(self):
        with self.process_lock:
            if self.current_process is None:
                return
            try:
                os.killpg(os.getpgid(self.current_process.pid), signal.SIGTERM)
                self.current_process.wait(timeout=5)
            except Exception:
                try:
                    os.killpg(os.getpgid(self.current_process.pid), signal.SIGKILL)
                except Exception:
                    pass
            self.current_process = None

    # ── Topic Bridges ──
    def _setup_bridges(self, mode):
        self._cleanup_bridges()
        self._image_sub = self.create_subscription(
            Image, '/image_raw', self._on_image, 10)

        det_topic = self.AVAILABLE_MODES[mode].get('det_topic')
        if det_topic:
            self._detection_sub = self.create_subscription(
                String, det_topic, self._on_detection, 10)
            self.get_logger().info(f'Bridge: /image_raw + {det_topic}')

    def _cleanup_bridges(self):
        if self._image_sub:
            self.destroy_subscription(self._image_sub)
            self._image_sub = None
        if self._detection_sub:
            self.destroy_subscription(self._detection_sub)
            self._detection_sub = None

    def _on_image(self, msg):
        self.image_pub.publish(msg)

    def _on_detection(self, msg):
        self.detection_pub.publish(msg)

    # ── Control Callbacks ──
    def mode_cmd_cb(self, msg):
        cmd = msg.data.strip()

        if cmd == 'stop':
            if self.active_mode != 'idle':
                old = self.active_mode
                self._kill_current()
                self._cleanup_bridges()
                self.active_mode = 'idle'
                self.get_logger().info(f'Stopped: {old}')
                self._publish_state()
            return

        if cmd not in self.AVAILABLE_MODES:
            self.get_logger().warn(
                f'Unknown mode: {cmd}. Available: {list(self.AVAILABLE_MODES.keys())}')
            return

        # Stop current first
        if self.active_mode != 'idle':
            self.get_logger().info(f'Stopping current: {self.active_mode}')
            self._kill_current()
            self._cleanup_bridges()

        # Launch new
        try:
            self.current_process = self._launch(cmd)
            time.sleep(2)

            if self.current_process.poll() is not None:
                err = self.current_process.stderr.read().decode(
                    'utf-8', errors='replace')
                self.get_logger().error(f'Launch failed: {err[:200]}')
                self.active_mode = 'idle'
            else:
                self.active_mode = cmd
                self._setup_bridges(cmd)
                self.get_logger().info(f'Active: {cmd}')
        except Exception as e:
            self.get_logger().error(f'Launch error: {e}')
            self.active_mode = 'idle'

        self._publish_state()

    def tts_cmd_cb(self, msg):
        text = msg.data.strip()
        if not text:
            return
        self.get_logger().info(f'TTS: {text[:80]}')
        ok = self._tts_speak(text)
        if not ok:
            self.get_logger().warn('TTS unavailable')

    def action_cmd_cb(self, msg):
        name = msg.data.strip()
        # Safety: only log, don't execute during verification
        self.get_logger().info(f'Action requested: {name} (logged only - safety mode)')

    # ── State ──
    def _publish_state(self):
        state = {
            'active_mode': self.active_mode,
            'available_modes': list(self.AVAILABLE_MODES.keys()),
            'mode_details': {k: v['desc'] for k, v in self.AVAILABLE_MODES.items()},
            'camera': 'active' if self._image_sub else 'inactive',
            'tts': 'available' if self.tts_ok else 'unavailable',
            'robot': self.active_mode if self.active_mode != 'idle' else 'idle',
        }
        self.state_pub.publish(
            String(data=json.dumps(state, ensure_ascii=False)))

    def destroy_node(self):
        self._kill_current()
        self._cleanup_bridges()
        super().destroy_node()


def main(args=None):
    rclpy.init(args=args)
    node = AdapterNode()
    try:
        rclpy.spin(node)
    except KeyboardInterrupt:
        pass
    finally:
        node.destroy_node()
        rclpy.shutdown()


if __name__ == '__main__':
    main()

相关推荐