ROS 2

Chapter 0. 概览

一个快速搭建机器人的软件库(核心是通信)和工具集。追求:稳定、实时、安全的通信能力。

ROS 的核心通信功能是由 DDS(数据分发服务)有线协议提供,它在 ROS 中作为中间件存在。ROS 本身支持多种遵循 DDS 协议的 vendors,详细参见 ROS DDS REPS

分发版本:ROS2 Releases (Jazzy Doc)

安装教程:ROS 2: Jazzy Jalizco

具体的能力:

不足之处:

Chapter 1. 环境、功能包与构建系统

ROS 2 是相当庞大的框架,在设计构建系统时遵循几个理念:

  1. 去中心化:放弃 ROS 1 的 catkin_make 集中式构建,采用分布式构建(每个包独立构建);

  2. 工具链分离:构建工具 (colcon) 与构建逻辑 (ament) 解耦;

  3. 多语言支持:ROS 客户端层原生支持 C++、Python,扩展支持 Rust、Java 等。因此构建系统会对不同语言分别处理;

  4. 环境隔离:严格隔离不同工作空间的构建/安装环境;

  5. 安装空间导向:构建目标直接生成可安装的 Artifacts;

组件作用替代 ROS 1 组件
colcon元构建工具(核心入口)catkin_make
ament_cmakeC++包的构建框架catkin CMake 宏
ament_pythonPython包的构建框架setup.py + catkin
CMakeC++项目的实际构建系统同 ROS 1
setuptoolsPython包的安装工具同 ROS 1
从项目根目录递归读取所有 package.xml
ament_index: 构建包依赖图
rosdep: 检查系统依赖
colcon: 拓扑排序确定构建顺序
deps on OS: apt / dnf / brew / pip
CMake / setuptools

应用层客户端组件的 功能包(ROS package)一般的项目结构:

注意 workspace/{src,build,install,log} 的结构是约定俗成的,是 ROS 中的包的工作空间(workspace);

 

Chapter 2. 节点

ROS 中节点抽象就是完成通信功能的最小模块,目前就了解这么多即可。

2.1 rclpy/rclcpp::Node

看几段代码不接入机器人硬件的代码来熟悉 ROS 框架:

对于 Python,由于安装 ROS 时已经将 Python 库引入系统环境的 Python 中了(如果你按教程执行了 setup/local-setup 的话),我们可以在系统环境 Python 中直接执行。但对于 C++ 而言,我们需要手动指定编译时需要引用的库,例如这个 CMakeLists 文件:

2.2 补充:功能包的使用

目前的做法有些问题和需要改进的地方:

  1. 如何将节点/项目抽象成一个功能包,方便分发、高内聚低耦合地开发?(无论对 Python 包还是 C++ 包)

  2. 如何减少 C++ 的 ROS 包的配置,不需要记像 rclcpp_INCLUDE_DIRS 等生成变量?

  3. 如何简化依赖管理的方式?

  4. ……

现在我们需要回顾上一章的知识点,并了解如何创建功能包的项目框架,它能高效地解决上述问题。

我们可以通过 ros2 工具程序帮助我们创建一个功能包的项目骨架,方便开发:

Tip

在 Python 原生的 wheel 构建系统 setup.py 中,setup 函数是 setuptools 打包工具的核心。想要了解文件是如何配置的,建议自己写两个原生的 Python 包并打包练习一下。

这里介绍一个可能有用的前置知识点:你可以在 setupentry_points 参数的 console_scripts 选项中添加命令行指令与模块的快捷方式。例如:

表示将此包安装后,会在 Python 环境的 bin 目录下生成一个快捷方式脚本,可以直接在命令行中使用 demo_pyn 即可代替 python3 -m ...(一串包名) 的用法。

现在如何遇到依赖,无论是 Python 还是 C++ 都可以直接在 package.xml 中添加:

上面的依赖还可以在命令行创建 package 时用 --dependencies 参数传入(空格分割)

如果是 C++,还建议在生成的 CMakeLists 文件中加入类似内容,以符合规范:

这些依赖都会交给 colcon 并完成构建和安装(过程参见上一节)。执行构建:

如果你的项目符合上一章提到的 “工作空间” 的规范,那么可以指定构建一个包:

一般本地构建的包默认应该位于当前执行 colcon build 的目录下的 install 子目录中(另外可看到 build, log 等,解释也请参见上一节)。

按工作空间的约定,我们应在 workspace 的目录下执行 colcon build 防止生成的目录过多弄混。

您可能需要额外执行 install/setup.xxsh 来将您刚构建的包加入环境变量(例如 PYTHONPATHPATH 等),以此使用它们。加入后您应该可以执行:

注:Python 基础。在 Python 解释器执行脚本时遇到 import 引用的包,会到 PYTHONPATH 环境变量中查找存在 __init__.py 的同名目录,并尝试解析内容。

 

Chapter 3. 话题通信

3.1 基本使用

话题由几个概念组成:发布者/订阅者(pub/sub pattern 的概念)、话题名(topic name)、话题类型(topic type,即话题返回的数据结果);

ROS 提供了常用的调试工具:

如何在代码中写话题的发布者/订阅者呢?

  1. 面向对象地创建一个继承于 Node 的类,作为节点抽象;

  2. 发布者:Node 内存在 create_publisher(topic_type, topic_name, qos_profile, callback_group, event_cb) 方法,可以创建出 publisher 对象,在需要发布时直接 .publish

    Note

    注 1:C++ 中 create_publisher 是一个模板,模板类型参数是话题类型。

    注 2:一般发布的方法是创建一个 timer(create_timer)以及一个消息队列,然后在回调函数中发布。具体情况按需求来。

    注 3:C++ 中 timer callback 参数是普通函数,如果希望使用成员函数,建议使用 std::bind(func_ptr, params) 绑定为一般函数指针;

  3. 订阅者:Node 内存在 create_subscriber(topic_type, topic_name, callback, qos_profile, ...) 方法,callback 的参数是话题类型数据结构对应的共享指针(如果是 Python 则是对象);

3.2 自定义话题类型

如何定义 topic_type?举个例子,我希望定义下面的数据类型:

现在我们需要引入 ROS 中的一个工具 rosidl_default_generators,它的作用是将 IDL(interface definition language)转换成客户端可用的代码,和 gRPC 定义文件转 stub 的道理是类似的。

然后新建一个功能包,并在包内创建新目录 msg注意,这个目录名是规定好的,不能指定其他名称),并将类型定义写入 IDL 文件中,名称建议使用约定的驼峰命名法,后缀名建议 .msg,表示是话题类型的通信方式使用到的数据类型。

Tip

除了 builtin_interfaces/Time,ROS 还提供了哪些内置的基本数据类型可以让我们使用呢?

我们可以使用命令 ros2 interface list 查看。

注:builtin_interfaces/Time 类型的数据如何获得?Node 类型有方法 get_clock(),可以对内置 Clock 类型操作即可获得。

然后!比普通代码多一步的是,我们需要在 CMakeLists 中添加一个指令,让 rosidl_default_generators 生成对应客户端语言的 boilerplate code,举个例子:

再然后,还需要向外提供信息:我这个包提供了话题类型的接口,这个改 package.xml(给外面的使用方了解):

3.3 ROS 中使用 QT

注意,这里 QT 需要的 CMakeLists 的写法和一般 QT 项目的一致,不使用 ament_target_dependencies,直接用 target_link_directories 即可,因为 QT 库并不来自于 ROS 框架,而是 ROS 顺带安装的,因此我们只需和正常情况使用 QT 一样的导入就行。

 

Chapter 4. 服务和参数通信

在 ROS 中,DDS 中间件其实只支持 pub/sub pattern,也就是话题类型的通信,所谓其他三个通信方式,服务通信、参数通信、动作通信方式只不过是 ROS 框架对于话题通信的封装。

例如,服务通信本质上是由两个话题通信实现的通信机制,而参数通信本质上由多个服务通信和一个话题通信实现的,动作通信本质也由服务+话题通信实现。

现在,我们只需要清楚,服务的作用其实就类似 gRPC(有参数和返回值,不需要轮询/广播),只是服务的网络信息信息全部由 ROS 帮忙管理罢了。

然后参数通信实际上是通过服务暴露了控制端点,外界可以通过这个服务来持久化(stateful)地设置系统的参数。

4.1 服务通信基本使用

和话题一样,ROS 同样提供了常用的调试工具:

(查看节点中的通信信息与上一章相同)

4.2 参数通信基本使用

我们已经知道参数通信就是一种特殊的服务通信,因此可以直接通过 ros2 service list -t | grep parameter 来获得和参数相关的服务接口(例如 set/get/describe/list 这些语义)。

除了上面的命令行调试工具,更方便的方法是使用 ROS 自带的 rqt 图形化界面,在命令行执行即可启动,方便我们进行上述所有调试工作(例如查看 node graph、topic 信息、service 信息、参数信息,等等,请读者自行摸索);

4.3 人脸识别服务 Demo

现在用例子介绍服务通信的使用,顺带介绍常用的、需要注意的点。

4.3.1 自定义服务数据类型

假设我们定义服务接口的数据结构(参数和返回值类型):

注:如果需要定义常量,使用 int16 test = 1(包含赋值语句)即可。

我们使用 ROS 内置的数据类型 sensor_msgs/Image 作为输入的图像数据类型。

为服务数据类型创建的功能包的完整步骤,和话题是类似的(都是使用 rosidl_default_generators),只是命名不同:将所有的 msg 换成 srv

另外相较于 topic,rosidl_default_generators 也会多做一些事,例如生成一些 Service 需要使用的构造函数,例如对应的 Request 数据结构、Response 数据结构。我们在后文的示例代码中就能见到。

Tip

有些资源需要放在 ROS 功能包中分发出去,例如程序可能使用到的固定的图像/文件资产,这个时候我们在代码中如何灵活锁定这些资源的位置呢?

以 Python 为例,我们可以使用功能包帮我们生成的 resource 目录。将资源放入该目录后,配置 setup.pysetup 函数参数 data_files(原生的 Python setuptools 框架中,它的意思是打包时注意需要打包除了代码外的其他指定数据文件),一般选择放在 share 目录下:

然后在代码中导入工具函数:

这个 get_package_share_directory(<package_name>) -> str 就可以得到功能包在对应阶段的 share 目录的绝对路径。

4.3.2 ROS 中的图像数据格式

前面介绍过,ROS 中内置的图像格式 sensor_msgs/Image,它实际与 OpenCV 中保存的图像格式不同,因此真正想从 ROS 拿到图像数据交给 OpenCV 处理前,需要进行一些转换工作。这里有一个现成的工具:CV Bridge,它实现了 sensor_msgs/Image 和 OpenCV 图像数据的相互转换。

4.3.3 ROS 服务定义

Important

服务这里有个很重要的问题需要说明。ROS Python 客户端框架中,启动一个 Node 默认就是一个线程,如果你在异步调用服务时这么写:

会导致程序效率低下的问题。因为整个 ROS 节点默认共用一个线程,如果后来这个节点要做的事情多了(例如回调函数多了),time.sleep 会将该线程 ROS 框架休眠,降低节点处理效率。因此有几种方案:

  1. 使用 Python future 添加 callback:future.add_done_callback,现在就完全不需要阻塞处理了;

  2. 目前没有建成 pipeline,没必要异步,直接使用同步调用 Client.call

  3. 使用 rclpy.spin_until_future_complete(<node>, <future>) 阻塞,这个方法会允许 ROS 框架主动收回控制流,然后 spin 调度其他的回调函数,不会真正休眠当前线程。和 rclpy.spin(<node>) 区别是后者会完全将当前线程交给当前节点的回调函数,直至核心的 event loop 被打断(例如 signal 导致退出 loop);

4.3.4 参数化

首先需要声明参数很简单,使用 Node 自带方法 declare_parameter<ParamT>(<param_name>, [default_value]) 即可(Python 不需要有模板参数)。

代码中想要获取参数的值,只需要 Node 方法 get_parameter(<param_name>, <output_ref>)(未声明/定义可能会导致抛出异常,参加代码注释文档)即可(Python 直接 output = get_parameter(<param_name>).value);

现在的问题是,我们这样都是获取当前的参数值,如何外界动态更新参数值?这就需要我们订阅参数更新事件。我们回忆一下,参数通信本质上也是由服务通信和话题通信实现,我们可以通过查看节点通信信息发现有形如 set_parameters 这样的自动生成的服务接口。

因此外部可以通过调用这些服务接口来提示修改参数(通用接口 /<node_name>/set_parameters,参数类型固定 rcl_interfaces/srv/SetParameters、返回值类型固定 rcl_interfaces/srv/SetParametersResult),而 Node 内部也提供了相应的回调注册函数,用来处理更新参数的事件。以 Python 为例:

再以 C++ 为例,add_on_set_parameters_callback 的回调函数类型定义为:

客户端想要更新参数就遵循 rcl_interfaces/srv/SetParameters 的类型。以 Python 为例:

客户端 set parameters 的代码 C++ 和 Python 使用方法几乎一样,不再赘述。

 

Chapter 5. 工具

5.1 Launch 启动脚本进行多节点管理

ROS 框架提供了多个节点启动和其他生命周期管理的功能,方便用户在多节点复杂情况下方便地运行整个系统,而不需要多个终端依次手动启动和停止。这个就是 ROS 的 launch Python 库(注意这个库 ROS 只提供了 Python 版本)。

使用方法:

  1. 无论是 Python 还是 C++ 功能包,都需要在功能包中添加一个 launch 目录(名称固定),并且新建管理这个节点启动的 Python 脚本,建议后缀:.launch.py

  2. 补全下面的脚本:

  3. 修改构建脚本,让构建后的该脚本安装到 install/share/<package_name>/launch 目录下,ROS 框架会取寻找的!

    以 C++ 功能包的 CMakeLists 为例:

    以 Python 功能包的 setuptools 的配置 setup.py 为例,这也很好理解:

现在就完成了,source install/setup.xxsh 后,我们直接通过 ros2 launch <package_name> <name>.launch.py [--debug] 即可启动多个节点!

现在我们总结一下,实际上 launch 提供了 3 大功能:

5.2 ROS 的 TF (坐标变换) 工具库

在机器人学中,坐标变换是相当麻烦的,ROS 中将坐标转换的工具封装成了类似服务通信的工具,方便开发者使用。

本章的代码需要额外安装 ROS TF 工具库: apt install ros-$ROS_DISTRO-tf-transformations(如果你的 ROS 正确安装了,应该可以通过 $ROS_DISTRO 环境变量获得你安装 ROS 时的发行版名称);

5.2.0 前置知识:计算机空间表示、欧拉角、四元数

Krasjet / quaternion - Github

5.2.1 命令行工具

先介绍命令行的调用方法,方便调试。我们以 base_link(机器人坐标系原点在世界坐标系中的坐标)、base_laser(机器人雷达组件的工具坐标系原点在世界坐标系中的坐标)、target_pos(目标坐标在世界坐标系中的坐标)。如何获取 target_pos 在机器人坐标系原点的坐标?

一般情况下雷达测距后,我们只知道目标在雷达的工具坐标系下的坐标(也就是 target 在 laser 下的相对坐标),如何转换成机器人坐标系中的坐标以方便后续判断工作?我们可以利用 TF 工具库进行下面操作:

  1. 发布 base-link 与 base-laser 间的坐标变换:

    注意到上面的指令是将坐标系 frame-idchild-frame-id 间的变换关系,x, y, z, roll, pitch, yaw 表示 child-frame-id 坐标系相对于 frame-id 坐标系的变换方法;

  2. 发布 base-laser 与 target 间的坐标变换:

  3. 我们现在可以通过直接查询的方法获得 base-link 与 target 间的关系:

5.2.2 原理与代码使用

在我们运行 tf2_ros 包的 static_transform_publisher 模块后,ROS 框架会管理一个话题接口 /tf_static,话题类型 tf2_msgs/msg/TFMessage

详细的定义比较简单易懂,请使用指令自行查看。

上述介绍的 tf_static_transform 一般用于相对静态的结构的坐标转换(例如固定在机器人上的雷达系统),ROS 中还提供了 tf_transform(动态坐标转换),一般是像轮子/机械臂这样实时运动的工具坐标系,ROS 允许它们动态更新并转换,话题接口 /tf

本章 C++/Python 的使用方式相差有点大(主要是内置类型太多了,叙述起来不直观),我们用两个 Demo 分别演示。

5.2.3 TF 和手眼标定 Demo (Python)

我们知道在机器人学中,手眼标定有两类,眼在手上和眼在手外。我们先考虑比较简单的情况:眼在手外。

考虑上面的情况,base_link 是机器人基坐标系相对于世界坐标系的表示、camera_link 是摄像机基坐标系相对于世界坐标系的表示,bottle_link 是物体相对于世界坐标系的表示;

眼在手外标定的目的就是,获得转换矩阵使得我们能很容易的由 “物体在摄像机基坐标系下的相对坐标” 转换到 “物体在机器人基坐标系下的相对坐标”。后者是很多机器人执行像抓取物体这样 pick-and-place 任务的必须数据。

注意,这里物体(bottle_link)可能是实时移动的,因此我们不应该用前面的 tf_static_transform 来发布变换。

Tip

另外,读者可能想到了,眼在手上是不是在这种情况下是眼在手外的一种情况?也就是 camera_linkbottle_link 一样都可以实时变化。

的确可以这么做,不过我本节先假设 camera_link 是固定的,眼在手上以后讨论。

我们先使用 tf_static_transform 发布一个 tf_static 话题,表示 camera_link 对于 base_link 的变换:

Important

读者可能会疑惑,我们发现 tf_static_transform 只会在节点启动(构造)时发布一次,并不会持续发布(反正是静态的!),那么后来加入进来的订阅者为什么能收到发布的话题数据呢?

答案是 ROS 中的服务质量管理机制(QoS)。我们查看 /tf_static 消息接口的详细信息(ros2 topic info /tf_static -v):

这个 TRANSIENT_LOCAL 持久化策略以及全局生命周期的配置,会让 ROS 库在发布这个接口时持久化最近上一次的发布结果,每当有订阅者加入时,定义接口就发送持久化的那个结果给订阅者。

然后我们使用动态坐标转换发布 bottle_link 关于 camera_link 的关系,和静态发布很类似,这里不再赘述,只是提示读者有哪些不同,只需要将上面的代码略加修改即可:

如果客户端需要订阅查询这样的关系,并且参与后续计算,则可以这么写:

5.2.4 TF 与地图坐标转换 Demo (C++)

本节从另一个示例展示在 C++ 中如何使用 TF 来进行静态/动态坐标转换的话题发布/订阅。假设下面的场景:

假设机器人建好图后,知道图上的两点 target_point(假设根据需求是静态的)和机器人当前所在位置 base_link(实时移动),如何使用 TF 框架来得知 target_point 相对于机器人坐标系的坐标,以方便其他操作呢?

依赖:rclcpp, tf2_ros, geometry_msgs, tf2_geometry_msgs

首先建立 target_point 相对于 map 的静态坐标转换:

然后发布 base_link 相对于 map 的动态坐标转换,我们这里也不需要再赘述了,同样注意动态坐标转换和静态坐标转换的不同:

最后客户端想要查询这样的关系,可以这么写(注意头文件引用和注释):

5.2.5 可视化

如果您希望可视化旋转姿态,可以安装 apt install ros-$ROS_DISTRO-mrpt2,然后执行 3d-rotation-converter 即可;

如果您希望可视化多个坐标转换服务间的关系,可以通过命令行查看 TF 树:

或者使用 RQT 插件 rqt-tf-tree,需要安装 apt install ros-$ROS_DISTRO-rqt-tf-tree,并且更新配置文件:rm -rf ~/.config/ros.org/rqt_gui.ini(通过删除旧文件来更新);

 

5.3 RViz 可视化工具

5.2.5 中我们知道如何使用 tf2_tools / rqt-tf-tree 来观察坐标转换的情况,但是不能很清楚地渲染出形象的图像来告诉我们具体情况。

RViz 是一款在机器人领域应用广泛的数据可视化工具,不仅可以将上面的坐标转换情况更清楚、易懂地渲染成图像,而且可以实现各种机器人数据(如地图建图情况、雷达/点云数据、其他传感器数据等等)的可视化。

只需要你的 ROS 正确安装了,就能在终端执行 rviz2 来启动。

 

5.4 BAG 数据记录工具

机器人的数据记录和重放工具。使用:

来记录从现在开始的所有该话题发布的内容(保留时序),中途允许使用空格键暂停记录、恢复记录。最后写入 DB3 数据文件(一般会新建一个含有 metadata.yaml 的目录)。

使用下面的指令来重放发布数据:

还有更多可配置的内容(如变速、循环重放,等等),请使用 ros2 bag -h 自行查看。

 

Chapter 6. 建模与仿真

机器人系统抽象包括 “控制系统”、“传感器”、“执行器”、“环境+机器人物理本体性质” 这 4 个组成部分。仿真只需要抓住这 4 个部分即可。

常见支持 ROS 的仿真平台:Gazebo(Classic/Harmonic)、Webots、Matlab Simulink、Unity3D 等。本章将以 Gazebo 为例介绍仿真。

6.1 机器人建模

机器人建模的较为通用的语言之一是 URDF,这是一种 XML 格式,用来描述机器人的物理参数(几何性质、传感器和执行器信息),举例:

Tip

注 1:可以安装 VSCode 的 URDF 插件,来提供方便的补全提示;

注 2:urdf_to_graphviz <urdf_file> 工具可将 URDF 格式文件转为 Graphviz 格式的图表示语言。Graphviz is an open-source suite from AT&T Labs Research for graph drawing using the DOT language.

规范来说,*.urdf 文件建议放在功能包的 urdf/ 目录下。

另外,如果希望 urdf 的内容容易维护、提升代码复用性,可以结合 Xarco 语法传递参数(需要安装 apt install ros-$ROS_DISTRO-xacro),然后写在 *.xacro 文件中,例如:

最后还需要用 xacro 指令生成一个 URDF 文件才能使用。这个也可以写到 launch 脚本中,读者可以练习一下。

Note

其他可能需要的知识点,建议结合现有的样例代码,带着问题自行学习:

  • 如何用 xacro:include 导入其他 xacro 文件?

  • 如何添加可以旋转的轮胎驱动器组件?

  • 如何让机器人模拟时贴合地面?

  • 如何添加碰撞属性(碰撞时的外观)?

  • 如何添加组件的模拟质量和转动惯量矩阵?

 

6.2 建模可视化

另外,上述建模可以用 RViz 可视化观察,在 RViz 中添加视图 RobotModel 即可。

问题是 RViz 不会主动读取 Joint 关节信息,意味着无法正确处理多个 link 之间的空间几何关系。因此还需要另外的工具 robot_state_publisherjoint_state_publisher

这样我们就可以通过 launch 脚本来组织启动过程(启动 RViz 前启动 robot_state_publisherjoint_state_publisher,前者需要 URDF 文件的内容字符串作为 robot_description 参数才能启动)。

 

6.3 Gazebo 仿真

安装 gazebo sim:apt install ros-$ROS_DISTRO-ros-gz

Note

其他可能需要的知识点,建议结合现有的样例代码,带着问题自行学习:

  • 如何使用 launch 脚本启动 Gazebo,并将场景和机器人模型导入?

  • 如何配置,并使用插件(例如两轮差速驱动插件),来控制模拟器中的机器人?

  • 在新版 Gazebo Harmonic 中,为什么需要、怎么显式指定 gz_ros_bridge

  • 如何在配置时,让深度相机插件对应的 link 组件的 z 轴指向机器人前进方向(正前方)?

  • 如何使用 RQT 查看模拟相机的深度/彩色图像?如何使用 RViz2 查看激光雷达、IMU 的数据结果?

 

6.4 ros2_control 驱动

上面我们在学习 Gazebo 仿真插件时知道,Gazebo 插件的架构如下图:

这样的设计只能让上层的代码设计适配 Gazebo。如果后续想要迁移到真机上还需要更改上层代码,显然不是个优雅的设计。

于是很自然地,类似于 Linux 操作系统中 VFS 的设计,我们想到可以将中间一个个包含控制器和数据接口的插件用一个统一的框架管理起来,这样对下可以无缝兼容模拟环境(Gazebo)和真实物理机器,对上也零代码适配上层设施。于是 ROS2 的 ros2_control 框架应运而生:

Important

就像 Linux VFS 一样,这样的抽象也会带来一些问题,比如:

  • 忽视底层(这里是真实的硬件 / Gazebo 仿真 / 其他模拟器)的特征;

  • 实时性和性能问题:由于 ROS 2 节点和 Gazebo 进程分离,命令和状态需要通过 Transport 和 ROS 2 传输,可能引入微小延迟。对于非常高频率或硬实时仿真,这可能是个考虑点(但在大多数机器人应用场景中影响很小);

我们可以用这条指令安装:apt install ros-$ROS_DISTRO-ros2-control

另外,ros2-control 支持的控制器模块可以通过另外的包来安装:apt install ros-$ROS_DISTRO-ros2-controllers,我们可以通过 apt info 的依赖包信息来查询 ros2-control 支持哪些控制器:

例如我们用到过的两轮差速、IMU 传感器,还有像 Joint State Broadcaster(之前介绍的是发布机器人 /tf 信息的时候提到 ROS 原本就有的 joint_state_publisher)、PID 控制器、夹爪控制器等等。

现在,同时适配模拟环境(Gazebo 等)和真实物理环境就变得简单了!现在只需要上层的业务逻辑按照 ros2-control 的控制器接口写,下层的模拟器 / 硬件逻辑提供 ros2-control 约定的接口,即可优雅的进行适配!

以 Gazebo 模拟环境为例,我们需要安装 apt install ros-$DISTRO-gazebo-ros2-control(注意这和前面两个包各不相同),让 Gazebo 向 ros2-control 的接口管理器提供可用的接口。

Note

其他可能需要的知识点,建议结合现有的样例代码,带着问题自行学习:

  • 如何配置 ros2-control,在不影响之前的深度相机/雷达等传感器的基础上,来替代之前的两轮差速、惯性传感器 的 Gazebo 插件?

  • 如何配置 ros2-control 来启用机器人力控(关节的力度)控制器?

 

Chapter 7. 导航与寻路

7.1 概述

注意,在一般场景下(除去自动驾驶等复杂场景),我们不使用卫星来给予机器人定位,一般是用事先建立地图(简称 “建图”)的方法,再结合雷达/其他传感器进行环境探测,最终来给予机器人定位能力。

这里的问题是,如果只是使用一般的雷达传感器,会因为高斯噪声导致对环境的不准确的构建。如下图所示,我们将 decay time 设置大一点然后将机器人转身,则会出现下面边界偏移的问题:

为了解决这个问题,我们需要引入 SLAM 来对环境地图的精确构建。

完成建图后,我们需要根据 SLAM 给出的信息(机器人当前位置、环境障碍物信息等)完成代价标记,然后根据全局路径规划算法确认导航路线并且行进。

在行进过程中可能遇到变化的障碍物(例如移动的行人),这个时候就需要将新的障碍物实时添加到地图中,然后重新进行路径规划。由于这是小范围的、动态的更新,因此我们需要在这个小范围区域内重新构建一个局部的代价地图,然后再次进行路径规划。其中 “确认局部路径” 这一步就称为 “局部路径规划”。

在考虑局部路径规划和全局路径规划后,还需要考虑一些特殊情况:例如行走过程中卡住,或者被行人遮挡,又或者找不到局部路径绕过去的时候,就需要一些相应的行为帮助机器人脱离困境。这个行为可以是预设的算法(例如前进卡住时尝试后退、被行人遮挡时播放“请让路”的音频等等),或者结合 AI Agent 考虑决策。我们将这个遇到故障时的脱困行为称为 “恢复行为”。

目前业界的机器人导航系统也就围绕着这 3 个部分进行设计的。

7.2 SLAM Toolbox in ROS

本节将以二维情况为例,使用 ROS2 中的 SLAM Toolbox 进行建图。安装:apt install ros-$ROS_DISTRO-slam-toolbox

启动模拟器/真机后,另起终端执行:

默认情况该工具会自动监听 TF Tree 的 Root,并创建 map frame 和 root frame(例如 odom)的 TF 关系。此时你打开 RViz2,查看 Map Display 即可看到 SLAM 对地图的探测情况了。现在你操纵机器人以一个较慢的速度遍历房间后,应该能得到类似这样的效果图:

此时需要保存地图,安装:apt install ros-$ROS_DISTRO-nav2-map-server

然后在新终端中选择一个合适的目录执行:

这个 server 会订阅 /map topic,然后读取数据,最终地图应该正确保存在你指定的 <map_name>.pgm<map_name>.yaml 文件中。

前者是图像像素编码的地图文件,后者是声明伸缩比例的配置文件。下面是配置文件的含义:

这个地图中每个像素对应一个值就是它被占据的概率,因此这个地图又称为“占据栅格地图”。

7.3 ROS 导航框架:Navigation 2

7.3.1 启动

首先认识一个概念:行为树(Behavior Tree)。这个概念起源于游戏设计,用于描述游戏角色的行为(例如 PVZ 中植物只有当僵尸出现时才开始射击)。

在 ROS 的 Navigation 导航框架中,就使用行为树来判定机器人何时应该处于 “全局路径规划”/“局部路径规划”/“恢复行为” 的哪一种模式。

整个过程遵循:规划器服务器根据目标信息规划全局路径,并交给控制器服务器;控制器服务器则进行机器人状态跟随实时更新;如遇故障则尝试调用恢复器服务器相关服务插件。

安装:apt install ros-$ROS_DISTRO-navigation2。另外 Navigation 2 框架提供了一些启动示例以供学习,如果需要可安装 apt install ros-$ROS_DISTRO-nav2-bringup

我们以 nav2-bringup 功能包提供的启动配置文件为模板:/opt/ros/$ROS_DISTRO/share/nav2_bringup/params/nav2_params.yaml;注意修改其中几个配置:

Note

其他可能需要的知识点,建议结合现有的样例代码,带着问题自行学习:

  • 如何编写 launch 脚本启动导航系统?

我们需要先启动仿真环境/物理机器,等待里程机 frame 出现后再启动导航系统。启动后一开始系统是不知道机器人在地图的具体位置的,需要我们手动告诉程序机器人的大致位置和朝向(参见 RViz2 的 2D Pose Estimate,按住然后拖动朝向),然后会立即生成一个全局和局部的代价地图。不过不需要太精确,因为机器人移动时会逐步自动校准。这个给定大致初始位置、按照雷达等信息校准的过程默认由 AMCL 组件完成(参见配置文件)。

两种代价地图如下图所示:

7.3.2 单点与路点导航

先以 RViz2 为例,在图形界面完成单点导航,只需要启动后完成 2D Pose Estimate,然后单击 Nav2 Goal,选中目标点即可进行单点导航。

另外单击 Waypoint / Nav Through Poses Mode 即可收集路点。

7.3.3 导航速度和膨胀半径优化

现在考虑如何优化导航时的导航速度和膨胀半径。我们知道,在 Nav 2 架构中真正实时控制导航速度(/cmd_vel)的机构是控制器服务器(controller_server),因此考虑在配置文件中找到 controller_server -> ros__parameters -> FollowPath 即可修改行进过程中的所有速度限制配置。

除了设置机器人的半径参考以外,global_costmaplocal_costmap 的配置中还有一个 “膨胀层”(inflation_layer),用来添加额外的代价,防止机器人过于接近障碍物(参考上图中品红色的区域实际上比机器人半径大),我们可以通过微调 inflation_radius 来得到较为理想的效果。

7.3.4 目标容差优化

可以修改配置文件中 controller_server -> ros__parameters -> general_goal_checker,即可发现 *_tolerance 的配置。

7.3.5 使用话题导航

现在考虑使用接口导航而不是 RViz GUI。注意几点:

A. 初始化位姿

AMCL 会订阅 /initialpose [geometry_msgs/msg/PoseWithCovarianceStamped] 话题,向该话题接口发布数据即可完成初始化位姿的操作(即上面提到的 2D Pose Estimate);

退出时可以保存上一次的位姿,下次导航系统重启后,考虑直接传入原先位置数据(如果机器人没有被移动过的话)。

在 Python / C++ 中有一个包可以帮我们完成这项工作:nav2_simple_commander。我们只需要给出一个 geometry_msgs/msg/PoseStamped 即可由该包的接口转换为 /initialpose 需要的数据类型并发送,实现初始化位姿的效果。示例代码:

具体 setInitialPosewaitUntilNav2Active 在做什么,读者可以自行探索。

Important

其实代码初始化位姿的好处不仅仅在于上次的自动位姿记录和自动初始化,更在于我们可以定制更准确的初始化位姿的流程,例如结合其他传感器数据减小位姿误差(如在原点做一个类似二维码标记,当相机看到则相当于告诉机器人位置在原点附近,然后直接调用这个方法告诉机器人当前在原点附近);

B. 实时获取机器人位置

AMCL 节点启动后会结合初始位置向外发布 map -> odom 的 TF 信息(参见配置文件 amcl -> ros__parameters)。我们只需要订阅 /tf(或者你设置的 topic 名称)即可,获取代码请参见 5.2.3/5.2.4;

C. 单点导航

这里我们终于需要用到 ROS 的动作通信(action)了。动作通信特点之一是反馈机制,在客户端向服务端调用后,可以实时获取服务端的状态反馈(动作进度),我们可以类似 topic/service 地查看当前动作列表:ros2 action list

我们通过 ros2 interface show 可以观察到,动作通信的接口类型有 3 个部分:action goal, result, feedback(回想 Service 有两个部分、Topic 只有一个部分)。

Tip

查看 Action 的源码可知,动作通信就是由 3 个 service 和两个 topic 实现的,存在 goal_service, result_service, cancel_service 以及组织接口的 feedback_topic, status_topic

Navigation 2 的单点导航暴露 action 接口:/navigate_to_pose [nav2_msgs/action/NavigateToPose],请求这个接口即可完成任务!

我们可以先用命令行试一下:ros2 action send_goal <action_name> <action_type> <json_data_str> [--feedback]

另外对于代码,nav2_simple_commander 也帮我们封装好了,直接使用即可,以 Python 为例:

Warning

注意到 waitUntilNav2Active 的默认行为是先查看是否设置了 initial pose,如果没有会自动传入 (0, 0, 0) 的全 0 位姿。如果机器人起始位置并不是这个位置,则很有可能导致误差过大。因此建议先主动调用初始化位姿后,再让代码运行到 waitUntilNav2Active

C++ 的版本:

D. 路点导航

类似地,Navigation 2 的路点导航暴露 action 接口:/follow_waypoints [nav2_msgs/action/FollowWaypoints],请求这个接口即可完成任务!

以 Python 为例:

 

7.4 ROS 导航自定义算法

我们需要为 ROS2 Navigation 2 框架编写插件来完成导航算法的自定义。ROS2 中的所谓插件就是动态链接库,通过运行时的 ClassLoader 加载符号并执行。

我们需要安装 apt install ros-$ROS_DISTRO-pluginlib 来进行自定义插件的编写和使用工作。

 

// TODO