ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

ROS2 Web端地图显示与导航控制实战指南

ROS2 Web端地图显示与导航控制实战指南

简介:本资源是一套基于ROS 2 Web前端的TurtleBot3地图可视化与交互导航实现方案,面向ROS初学者及Web前端开发者,解决机器人SLAM建图结果在浏览器端实时显示、目标点导航与位姿校正等核心需求。项目纯前端实现,不依赖后端服务或键盘控制逻辑,聚焦ros2djs库在Web端对接ROS话题的工程实践,适用于Ubuntu 20.04环境下的仿真调试与教学演示。压缩包共141个文件,含52个JavaScript逻辑文件(负责ROS消息订阅、地图渲染与交互事件)、41个HTML页面(多级UI布局与功能入口)、19张PNG图标资源及16个CSS样式文件(含Bootstrap、jQuery UI、Font Awesome等成熟框架支持),整体体积仅1.63MB,轻量易部署。已有1680人学习下载,提供完整可运行代码、清晰目录结构及本地访问地址(http://localhost:9001/),附带验证通过的操作路径与环境适配说明,显著降低Web-ROS集成门槛。

1. ROS2 Web端地图显示与导航控制:一个轻量但极易翻车的前端闭环

你手头有一台 TurtleBot3 仿真环境,ROS2 Humble 或 Foxy 已跑通,ros2 launch turtlebot3_gazebo turtlebot3_world.launch.py能看到 Gazebo 里的小车,rviz2也能加载/map和/tf—— 但你想在浏览器里点一下就让小车动起来,还能实时看地图、拖拽目标点、一键取消导航、甚至手动校准机器人位姿?不是用rqt或web_video_server那种半成品方案,而是真正能嵌入生产级运维看板、支持多终端访问、不依赖后端服务的纯前端解法?这个ros2djs项目就是干这事的:它把 ROS2 的nav2导航栈状态、/map栅格图、/tf坐标变换、/goal_pose目标发布、/initialpose位姿重置,全部通过 WebSocket 桥接进浏览器 DOM,用 Canvas + SVG 渲染地图,用鼠标事件驱动交互逻辑。它不碰 C++ 节点、不写 launch 文件、不改nav2配置,只靠roslibjs(v1.2.0+)和ros2djs(v0.4.0)两个 JS 库,就能在http://localhost:9001/上跑出一个带「导航」「校准」「取消」三按钮的最小可行导航控制台。适合 ROS2 初学者快速验证导航流程,也适合嵌入式团队做远程监控原型——但别指望它开箱即用:Ubuntu 20.04 上 npm install 会卡在canvas编译,WebSocket 连接失败时控制台静默无报错,地图缩放错位、目标点坐标偏移 30cm 以上是常态。我踩过所有坑,这篇笔记只讲怎么让它真正在你机器上动起来。


2. 从零搭起 ros2djs 前端环境:依赖链、端口映射与 WebSocket 桥接配置

2.1 环境底座:为什么必须用 Ubuntu 20.04 + ROS2 Foxy(或 Humble)

项目摘要明确写着「我在 Ubuntu 20.04 上跑的」,这不是客套话。ros2djs依赖roslibjsv1.2.x,而该版本对 ROS2 的rclnodejs兼容性仅稳定在 Foxy(2020.5)和 Humble(2022.5)两个 LTS 版本。Foxy 对geometry_msgs/PoseStamped的序列化格式与 Dashing 不同,Humble 则修复了nav2_msgs/NavigationGoal在 WebSocket 中的字段截断问题。如果你强行在 Galactic 或 Rolling 上运行,/goal_pose发布后 Nav2 会收到空header.stamp,导致bt_navigator直接拒绝执行。更关键的是 Node.js 版本:Ubuntu 20.04 自带nodejs 10.19,但ros2djs需要canvas2.9+(依赖node-gyp重编译),而canvas2.9 要求node >= 12.17。所以第一步不是 clone 代码,而是升级 Node.js:

# 卸载旧版 nodejs(Ubuntu 20.04 默认是 10.x) sudo apt remove nodejs npm # 安装 NodeSource 仓库(官方推荐方式) curl -fsSL https://deb.nodesource.com/setup_16.x | sudo -E bash - sudo apt-get install -y nodejs # 验证版本 node --version # 必须输出 v16.20.2 或 v18.19.0(Humble 推荐) npm --version # 必须 ≥ 8.19.2

提示:不要用nvm!nvm管理的 Node.js 在 systemd service 或后台进程里路径不可靠,ros2djs启动脚本server.js会因找不到canvas原生模块而崩溃。系统级安装才能保证require('canvas')稳定加载。

2.2 WebSocket 桥接器:rosbridge_suite 的正确安装与端口暴露

ros2djs不直接连 ROS2,而是通过rosbridge_suite的rosbridge_websocket节点做协议转换。它把 ROS2 Topic/Service/Action 的二进制消息转成 JSON-RPC over WebSocket。很多人卡在这一步:ros2 launch rosbridge_server rosbridge_websocket_launch.xml启动后,浏览器访问ws://localhost:9090报Connection refused。原因有三:一是rosbridge_server包未适配 ROS2;二是默认绑定127.0.0.1,外部无法访问;三是防火墙拦截 9090 端口。解决方案如下:

# 安装 ROS2 兼容版 rosbridge_suite(注意:不是 ros1 的 rosbridge_server!) sudo apt update sudo apt install ros-foxy-rosbridge-suite # Foxy 用户 # 或 sudo apt install ros-humble-rosbridge-suite # Humble 用户 # 启动时显式指定绑定地址和端口(关键!) ros2 launch rosbridge_server rosbridge_websocket_launch.xml \ port:=9090 \ address:=0.0.0.0 \ rpc_port:=9091 \ authenticate:=false

参数说明:

  • address:=0.0.0.0:允许局域网内任意 IP 访问,否则ros2djs的ws://localhost:9090在 Chrome 里会因跨域被拒;
  • rpc_port:=9091:为后续调试留出 RPC 端口,避免与 WebSocket 冲突;
  • authenticate:=false:开发阶段关闭认证,否则需额外配置 JWT token,ros2djs默认不带鉴权逻辑。

验证桥接器是否生效:

# 在另一个终端 curl 测试 curl -i http://localhost:9090 # 应返回 HTTP/1.1 101 Switching Protocols(WebSocket 握手成功) # 若返回 404 或 Connection refused,请检查: # 1. ros2 launch 是否在运行(ps aux | grep rosbridge) # 2. Ubuntu 防火墙是否放行 9090:sudo ufw allow 9090 # 3. Docker 环境下是否做了端口映射(见 2.3 小节)

2.3 Docker 场景下的 net 模式与端口转发:绕过 host-only 网络陷阱

很多用户用 Docker 运行 TurtleBot3 仿真(如ros:foxy-gazebo镜像),此时rosbridge_websocket运行在容器内,宿主机浏览器无法直连localhost:9090。常见错误是docker run -p 9090:9090后仍连不上——因为rosbridge默认绑定127.0.0.1,而 Docker 的-p映射只对0.0.0.0有效。必须用--network host模式(net 模式):

# 正确启动方式(host 网络模式) docker run -it --rm \ --network host \ -v /path/to/your/ros2_ws:/ros2_ws \ -w /ros2_ws \ ros:foxy-gazebo \ bash -c "source /opt/ros/foxy/setup.bash && \ source install/setup.bash && \ ros2 launch rosbridge_server rosbridge_websocket_launch.xml \ address:=0.0.0.0 port:=9090"

注意:--network host会让容器共享宿主机网络栈,rosbridge绑定0.0.0.0:9090后,宿主机http://localhost:9090和局域网http://192.168.x.x:9090均可访问。若必须用 bridge 网络,则需在docker-compose.yml中显式配置:

services: rosbridge: image: ros:foxy-gazebo network_mode: "host" # 强制 host 模式 ports: - "9090:9090" - "9091:9091"

2.4 ros2djs 前端服务启动:静态资源托管与跨域代理配置

项目正文列出一堆 CSS 文件(bootstrap.min.css,jquery-ui.css,font-awesome.min.css,style.css,syntaxhighlighter.css),说明它是一个纯静态 Web 应用,无需后端框架。但ros2djs的index.html会通过roslibjs连接ws://localhost:9090,而现代浏览器禁止file://协议下的 WebSocket 连接(CORS 策略)。因此必须用 HTTP 服务器托管,且需处理跨域。项目自带server.js(基于 Express),但默认配置有缺陷:

// server.js 原始代码(需修改) const express = require('express'); const app = express(); app.use(express.static('public')); // public/ 目录存放所有 HTML/CSS/JS app.listen(9001, 'localhost'); // ❌ 错误:绑定 localhost 会导致局域网设备无法访问

修正后的server.js:

const express = require('express'); const app = express(); const path = require('path'); // 启用跨域(关键!否则 Chrome 拒绝 ws 连接) app.use((req, res, next) => { res.header('Access-Control-Allow-Origin', '*'); // 允许所有源 res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept'); next(); }); // 托管 public 目录 app.use(express.static(path.join(__dirname, 'public'))); // 根路由重定向到 index.html app.get('/', (req, res) => { res.sendFile(path.join(__dirname, 'public', 'index.html')); }); // 启动监听 0.0.0.0(非 localhost!) const PORT = 9001; app.listen(PORT, '0.0.0.0', () => { console.log(`✅ ros2djs frontend running at http://localhost:${PORT}`); console.log(`💡 Access from other devices: http://$(hostname -I | awk '{print $1}'):${PORT}`); });

启动命令:

cd /path/to/ros2djs-project npm install # 安装 express canvas socket.io-client(项目依赖已列在 package.json) node server.js

验证:打开http://localhost:9001,打开浏览器开发者工具 → Console,应看到Connected to ROS日志;Network → WS 标签页,应有ws://localhost:9090连接状态为101 Switching Protocols。


3. 地图渲染与导航交互:ros2djs 核心 API 调用与坐标系对齐

3.1 初始化 ROS 连接与 TF 监听:解决 /map 坐标系漂移问题

ros2djs的核心是ROSLIB和ROS2D两个命名空间。ROSLIB负责连接 ROS2,ROS2D负责地图渲染。但很多人初始化后地图显示为空白或严重错位,根本原因是/map和/odom坐标系未对齐。ros2djs默认使用TFClient监听/tf,但turtlebot3仿真中/map→/odom→/base_link链路需slam_toolbox或cartographer提供,而turtlebot3_world.launch.py默认用fake_localization,它不发布/map→/odom变换。必须手动启用slam:

# 启动带 SLAM 的仿真(替代默认 world.launch) ros2 launch turtlebot3_slam turtlebot3_slam.launch.py \ slam_methods:=slam_toolbox \ use_sim_time:=True

然后在index.html的 JS 初始化块中:

// 初始化 ROS 连接 const ros = new ROSLIB.Ros({ url: 'ws://localhost:9090' // 必须与 rosbridge 地址一致 }); // 监听 TF 变换(关键:指定 root_frame 和 target_frame) const tfClient = new ROSLIB.TFClient({ ros: ros, angularThres: 0.01, // 角度阈值(弧度) transThres: 0.01, // 平移阈值(米) rate: 10.0, // 订阅频率(Hz) fixedFrame: '/map', // 固定坐标系(地图原点) camera: undefined }); // 创建地图 viewer(注意:frame_id 必须与 /map topic 的 header.frame_id 一致) const viewer = new ROS2D.Viewer({ divID: 'map', width: 800, height: 600, background: '#ffffff' }); // 加载 /map topic(栅格地图) const gridClient = new ROS2D.OccupancyGridClient({ ros: ros, rootObject: viewer.scene, continuous: true, robotPoseTopic: '/amcl_pose', // AMCL 提供机器人位姿 mapTopic: '/map', // 必须存在且 frame_id == 'map' // ⚠️ 关键参数:scale 控制地图缩放,初始设为 1.0,后续根据实际尺寸调整 scale: 1.0 });

参数说明:

  • fixedFrame: '/map':强制以/map为世界坐标系原点,否则ros2djs会用/odom导致地图随小车移动;
  • robotPoseTopic: '/amcl_pose':amcl节点发布的机器人位姿,比/tf更稳定;
  • scale: 1.0:初始缩放因子,turtlebot3_world地图分辨率为0.05 m/cell,若地图显示过小,调大此值(如2.0)。

3.2 导航目标发布:PoseStamped 构造与坐标系转换

点击地图发送导航目标,本质是向/goal_posetopic 发布geometry_msgs/PoseStamped消息。但ros2djs的Nav2DClient默认构造的目标点坐标是像素坐标,需转换为/map坐标系下的米制坐标。这是第二大翻车点:不转换直接发,小车会冲向(0,0)原点。

// 创建导航客户端 const navClient = new ROS2D.Nav2DClient({ ros: ros, poseTopic: '/goal_pose', actionServerName: '/navigate_to_pose', // Nav2 的 Action Server 名 actionLib: ROSLIB.ActionClient, // 使用 Action 而非 Topic(更可靠) timeout: 60000 }); // 地图点击事件(转换像素 → 米) viewer.addEventListener('click', function(event) { // 获取点击处的像素坐标 const pixel = new ROS2D.Point(event.point.x, event.point.y); // 转换为 /map 坐标系下的米制坐标(关键!) const position = viewer.projectPixelToRos(pixel); // 构造 PoseStamped 消息 const goal = new ROSLIB.Message({ header: { stamp: { sec: 0, nanosec: 0 }, // ROS2 要求时间戳,此处由 Nav2 自动填充 frame_id: 'map' // 必须是 'map',不能是 'odom' 或 'base_link' }, pose: { position: { x: position.x, y: position.y, z: 0.0 }, orientation: { x: 0.0, y: 0.0, z: 0.0, w: 1.0 } } }); // 发布目标(使用 Action 方式) navClient.sendGoal(goal, function() { console.log('✅ Navigation goal sent to /navigate_to_pose'); }, function(err) { console.error('❌ Failed to send goal:', err); }); });

注意:viewer.projectPixelToRos(pixel)是ros2djs内置方法,它根据/map的info.origin和info.resolution自动完成像素→米转换。若此方法返回NaN,说明/maptopic 未正确订阅或info.resolution为 0(检查ros2 topic echo /map的info.resolution字段)。

3.3 位姿校准(2D Pose Estimate):/initialpose 的精确触发逻辑

「校准」功能对应 ROS2 的/initialposetopic,用于重置 AMCL 的粒子滤波器。用户在地图上拖拽小车图标并释放,应发布geometry_msgs/PoseWithCovarianceStamped。但ros2djs默认的拖拽逻辑会发布到/initialpose,而amcl节点要求header.frame_id必须为'map',且pose.covariance必须是非零矩阵(否则忽略)。

// 创建位姿校准客户端 const initialPoseClient = new ROSLIB.Topic({ ros: ros, name: '/initialpose', messageType: 'geometry_msgs/PoseWithCovarianceStamped' }); // 拖拽小车图标(假设 DOM 中有 #robot-icon 元素) let isDragging = false; let dragStartPos = null; document.getElementById('robot-icon').addEventListener('mousedown', function(e) { isDragging = true; dragStartPos = { x: e.clientX, y: e.clientY }; e.preventDefault(); }); document.addEventListener('mousemove', function(e) { if (!isDragging) return; const dx = e.clientX - dragStartPos.x; const dy = e.clientY - dragStartPos.y; // 更新图标位置(视觉反馈) const icon = document.getElementById('robot-icon'); icon.style.transform = `translate(${dx}px, ${dy}px)`; }); document.addEventListener('mouseup', function(e) { if (!isDragging) return; isDragging = false; // 获取图标当前像素位置(相对于 viewer 容器) const rect = viewer.element.getBoundingClientRect(); const x = e.clientX - rect.left; const y = e.clientY - rect.top; // 转换为 /map 坐标 const rosPos = viewer.projectPixelToRos(new ROS2D.Point(x, y)); // 构造 PoseWithCovarianceStamped(covariance 必须非零!) const initialPose = new ROSLIB.Message({ header: { stamp: { sec: 0, nanosec: 0 }, frame_id: 'map' // ⚠️ 强制设为 'map' }, pose: { pose: { position: { x: rosPos.x, y: rosPos.y, z: 0.0 }, orientation: { x: 0.0, y: 0.0, z: 0.0, w: 1.0 } }, covariance: [ 0.5, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.5, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.01 ] // ⚠️ covariance[0] 和 [1] 必须 > 0,否则 amcl 忽略 } }); initialPoseClient.publish(initialPose); console.log('✅ Initial pose published to /initialpose'); });

4. 避坑指南:ROS2 Web 导航中 5 个血泪级常见问题排查

4.1 现象:地图显示空白或全黑,Console 无报错

原因:/maptopic 未发布,或ros2djs订阅的frame_id与/map的header.frame_id不匹配。turtlebot3_world.launch.py默认不启动slam,/maptopic 为空;或rosbridge未正确桥接/map。
解决:

  1. 运行ros2 topic list | grep map,确认/map存在;
  2. 运行ros2 topic echo /map --noarr,检查header.frame_id是否为'map'(不是'world'或'odom');
  3. 在server.js启动后,访问http://localhost:9090/topics,确认/map出现在 JSON 列表中;
  4. 若rosbridge未桥接/map,编辑rosbridge_websocket_launch.xml,在<param name="topics" value="[/map, /tf, /amcl_pose, /goal_pose]"/>中显式添加/map。

4.2 现象:点击地图后小车不动,Nav2 日志显示Ignoring invalid goal

原因:/goal_pose发布的消息中header.stamp.sec为 0,Nav2 认为时间戳无效;或pose.position.x/y为NaN(坐标转换失败)。
解决:

  1. 在navClient.sendGoal()前,手动设置时间戳:
    const now = new Date(); goal.header.stamp.sec = Math.floor(now.getTime() / 1000); goal.header.stamp.nanosec = (now.getTime() % 1000) * 1e6;
  2. 检查viewer.projectPixelToRos()返回值:console.log(position),若为{x: NaN, y: NaN},说明/map.info.resolution为 0 或/map.info.origin未初始化,需重启slam节点。

4.3 现象:校准后小车位姿跳变,AMCL 日志报Received a non-positive definite covariance matrix

原因:/initialpose的covariance数组全零,AMCL 拒绝处理。
解决:

  • covariance必须至少前两个元素(x, y 方向方差)大于 0,推荐设为0.5;
  • orientation的w必须为1.0(四元数归一化),不能为0;
  • frame_id必须为'map',不能是'odom'。

4.4 现象:WebSocket 连接频繁断开,Console 报WebSocket is closed before the connection is established

原因:rosbridge_websocket进程崩溃,或server.js启动顺序错误(先启前端再启 rosbridge)。
解决:

  • 永远先启动rosbridge_websocket,再启动node server.js;
  • 检查rosbridge日志:ros2 launch ...后运行ros2 node list,确认/rosbridge_websocket在线;
  • 若rosbridge崩溃,查看其日志:journalctl -u rosbridge_websocket -f(systemd)或docker logs <container>。

4.5 现象:Ubuntu 20.04 下npm install卡在canvas编译,报gyp ERR! stack Error: Can't find Python executable

原因:canvas2.9+ 依赖node-gyp编译原生模块,而 Ubuntu 20.04 默认无 Python3-dev 和 build-essential。
解决:

sudo apt update sudo apt install -y build-essential python3-dev libcairo2-dev libpango1.0-dev libjpeg-dev libgif-dev librsvg2-dev # 重新安装 canvas(全局) sudo npm install -g canvas@2.9.3 # 再进入项目目录 npm install

5. 进阶技巧:地图缩放同步、多机器人支持与离线缓存策略

5.1 地图缩放与 viewer 同步:解决拖拽地图后目标点偏移

ros2djs的Viewer默认启用zoom和pan,但用户缩放后,viewer.projectPixelToRos()的转换矩阵未更新,导致点击坐标严重偏移。官方文档未提及此问题,实际需监听zoom事件并手动刷新投影:

// 启用 zoom 和 pan viewer.allowZoom = true; viewer.allowPan = true; // 监听 zoom 事件,重置投影矩阵 viewer.addEventListener('zoom', function() { // 强制更新 viewer 的内部投影缓存 viewer.updateTransform(); console.log(`🔍 Zoom level: ${viewer.zoom}`); }); // 重写 projectPixelToRos 方法(防抖优化) const originalProject = viewer.projectPixelToRos.bind(viewer); viewer.projectPixelToRos = function(pixel) { // 确保 transform 已更新 this.updateTransform(); return originalProject(pixel); };

5.2 多机器人支持:通过 namespace 动态切换 topic 前缀

项目默认只支持单机器人,但产线常需监控多台 TurtleBot3。ros2djs支持namespace参数,但需修改OccupancyGridClient初始化:

// 假设有两个机器人:tb3_01 和 tb3_02 const robots = ['tb3_01', 'tb3_02']; let currentRobot = 'tb3_01'; function switchRobot(robotName) { currentRobot = robotName; // 重建 gridClient(关键:topic 前缀加 namespace) gridClient.dispose(); // 清理旧实例 gridClient = new ROS2D.OccupancyGridClient({ ros: ros, rootObject: viewer.scene, continuous: true, robotPoseTopic: `/${robotName}/amcl_pose`, mapTopic: `/${robotName}/map`, scale: 1.0 }); // 重建 navClient navClient.dispose(); navClient = new ROS2D.Nav2DClient({ ros: ros, poseTopic: `/${robotName}/goal_pose`, actionServerName: `/${robotName}/navigate_to_pose` }); }

HTML 中添加切换按钮:

<select id="robot-select" onchange="switchRobot(this.value)"> <option value="tb3_01">TurtleBot3 #1</option> <option value="tb3_02">TurtleBot3 #2</option> </select>

5.3 离线缓存策略:让地图在断网时仍可查看

ros2djs默认每次启动都重新订阅/map,断网即空白。可将/map数据缓存到localStorage,并在连接失败时回退:

// 订阅 /map 时缓存最新数据 const mapCache = {}; const mapTopic = new ROSLIB.Topic({ ros: ros, name: '/map', messageType: 'nav_msgs/OccupancyGrid' }); mapTopic.subscribe(function(msg) { // 序列化 msg 为 JSON(去除 ArrayBuffer) const cacheMsg = { header: msg.header, info: msg.info, data: Array.from(msg.data) // 转为普通数组 }; localStorage.setItem('cached_map', JSON.stringify(cacheMsg)); mapCache[msg.header.stamp.sec] = cacheMsg; }); // 连接断开时加载缓存 ros.on('close', function() { console.warn('⚠️ ROS connection closed, loading cached map...'); const cached = localStorage.getItem('cached_map'); if (cached) { try { const msg = JSON.parse(cached); gridClient.processMessage(msg); // 强制渲染缓存地图 console.log('✅ Cached map loaded'); } catch (e) { console.error('Failed to load cache:', e); } } });

从那以后我每次部署ros2djs,都强制走一遍ros2 topic hz /map确认发布频率 ≥ 1Hz,再用ros2 topic echo /map --noarr | head -n 5检查info.resolution和info.origin是否合理;校准前必先ros2 node list | grep amcl确认节点存活;遇到 WebSocket 断连,第一反应不是重启server.js,而是ros2 node list看rosbridge_websocket是否还在。这些动作现在已刻进肌肉记忆——因为少做一次,就可能多花两小时在NaN坐标和invalid goal日志里打转。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进