ROS专题----导航功能包navigation基础汇总

资料来源ROS官网:http://wiki.ros.org/cn/navigation

nav_comic.png

概述

  • 概念层面上讲,导航功能包集是相当简单的。 它从里程计和传感器数据流获取信息,并将速度命令发送给移动基站(比如你的机器人)。但是,想要在任意机器人上使用导航功能包集可能有点复杂。使用导航功能包集的先决条件是,机器人必须运行ROS,有一个tf变换树,使用正确的ROS Message types发布传感器数据。而且,我们需要在高层为一个具有一定形状和动力学特点的机器人配置导航功能包集。本手册指导配置一个典型的导航功能包集。

硬件需求

  • 虽然导航功能包集被设计成尽可能的通用,在使用时仍然有三个主要的硬件限制:
  1. 它是为差分驱动的完全约束的轮式机器人设计的。它假设移动基站受到理想的运动命令的控制并可实现预期的结果,命令的格式为:x速度分量,y速度分量,角速度(theta)分量。
  2. 它需要在移动基站上安装一个平面二维激光。这个激光用于构建地图和定位。
  3. 导航功能包集是为正方形的机器人开发的,所以方形或圆形的机器人将是性能最好的。 它也可以工作在任意形状和大小的机器人上,但是较大的矩形机器人将很难通过狭窄的空间,例如门道。

----配置机器人的TF----

Transform Configuration(变换配置)

许多ROS功能包,都要求利用tf软件库,以机器人识别的变换树的形式进行发布。抽象层面上,变换树其实就是一种“偏移”,代表了不同坐标系之间的变换和旋转。更具体点来说,设想一个简单的机器人,只有一个基本的移动机体和挂在机体上方的扫描仪。基于此,我们定义了两个坐标系:一个对应于机体中心点的坐标系,一个对应于扫描仪中心的坐标系。分别取名为“base_link”和“baser_laser”。关于坐标系的命名习惯,参考REP 105.

此时,可以假设,我们已经从传感器获取了一些数据,以一种代表了物体到扫描仪中心点的距离的形式给出。换句话说,我们已经有了一些“base_laser”坐标系的数据。现在,我们期望通过这些数据,来帮助机体避开物理世界的障碍物。成功的关键是,我们需要一种方式,把传感器扫描的数据,从“base_laser”坐标系转换到“base_link”坐标系中去。本质上,就是定义一种两个坐标系的“关系”。

http://wiki.ros.org/navigation/Tutorials/RobotSetup/TF?action=AttachFile&do=get&target=simple_robot.png

为了定义这种关系,假设我们知道,传感器是挂在机体中心的前方10cm,高度20cm处。这就等于给了我们一种转换的偏移关系。具体来说,就是,从传感器到机体的坐标转换关系应该为(x:0.1m,y:0.0m, z:0.2m),相反的转换即是(x:-0.1m,y:0.0m,z:0.2m)。

我们可以选择去自己管理这种变换关系,意味着需要自己去保存,以及在需要的时候调用。但是,这种做法的缺陷即是随着坐标转换关系数量的增加,而愈加麻烦。幸运的是,我们也没有必要这么干。相反,我们利用tf定义了这么一种转换关系,那么就让它来帮我们管理这种转换关系吧。

利用tf来管理这种关系,我们需要把他们添加到转换树(transform tree)中。一方面来说,转换树中的每一个节点都对应着一类坐标系,节点之间的连线即是两个坐标相互转换的一种表示,一种从当前节点到子节点的转换表示。Tf利用树结构的方式,保证了两个坐标系之间的只存在单一的转换,同时假设节点之间的连线指向是从parent到child。

http://wiki.ros.org/navigation/Tutorials/RobotSetup/TF?action=AttachFile&do=get&target=tf_robot.png

基于我们简单的例子,我们需要创建两个节点,一个“base_link”,一个是“base_laser”。为了定义两者的关系,首先,我们需要决定谁是parent,谁是child。时刻记得,由于tf假设所有的转换都是从parent到child的,因此谁是parent是有差别的。我们选择“base_link”坐标系作为parent,其他的传感器等,都是作为“器件”被添加进robot的,对于“base_link”和“base_laser”他们来说,是最适合的。这就意味着转换关系的表达式应该是(x:0.1m,y0.0m,z:0.2m)。关系的建立,在收到“base_laser”的数据到“base_link”的转换过程,就可以是简单的调用tf库即可完成。我们的机器人,就可以利用这些信息,在“base_link”坐标系中,就可以推理出传感器扫描出的数据,并可安全的规划路径和避障等工作。

Writing Code(代码编写)

希望上面的例子,一定程度上可以帮助大家理解tf。现在,我们可以建立通过代码来实现转换树。对于这个例子来说,前提是熟悉ROS,所以如果不熟悉,先预习下ROS Documentation吧。

假定,我们以上层来描述“base_laser”坐标系的点,来转换到"base_link"坐标系。首先,我们需要创建节点,来发布转换关系到ROS系统中。下一步,我们必须创建一个节点,来监听需要转换的数据,同时获取并转换。在某个目录创建一个源码包,同时命名“robot_setup_tf”。添加依赖包roscpp,tf,geometry_msgs

$ cd %TOP_DIR_YOUR_CATKIN_WS%/src
$ catkin_create_pkg robot_setup_tf roscpp tf geometry_msgs

至此,你必须运行上面的命令,当然你必须有必要的权限(例如,~/ros目录下,你可能在之前的文档中操作过这个目录)

Alternative in fuerte, groovy and hydro: there is a standard robot_setup_tf_tutorial package in the navigation_tutorialsstack. You may want to install by following (%YOUR_ROS_DISTRO% can be { fuertegroovy } etc.):

$ sudo apt-get install ros-%YOUR_ROS_DISTRO%-navigation-tutorials

Broadcasting a Transform(广播变换)

至此,我们已经创建了package。我们需要创建对于的节点,来实现广播任务base_laser->base_link。在robot_setup_tf包中,用你最喜欢的编辑器打开,然后将下面的代码粘贴到src/tf_broadcaster.cpp文件中去。

切换行号显示
   1 #include <ros/ros.h>
   2 #include <tf/transform_broadcaster.h>
   3 
   4 int main(int argc, char** argv){
   5   ros::init(argc, argv, "robot_tf_publisher");
   6   ros::NodeHandle n;
   7 
   8   ros::Rate r(100);
   9 
  10   tf::TransformBroadcaster broadcaster;
  11 
  12   while(n.ok()){
  13     broadcaster.sendTransform(
  14       tf::StampedTransform(
  15         tf::Transform(tf::Quaternion(0, 0, 0, 1), tf::Vector3(0.1, 0.0, 0.2)),
  16         ros::Time::now(),"base_link", "base_laser"));
  17     r.sleep();
  18   }
  19 }

现在,让我们来针对上面的代码,作更细节的解释。

Error: No code_block found Tf功能包提供了一种实现tf::TransformBroadcaster ,使任务发布变换更容易。为了调用TransformBroadcaster, 我们需要包含 tf/transform_broadcaster.h 头文件.

Error: No code_block found 我们创建一个TransformBroadcaster对象,之后我们可以利用他来发送变换关系,即base_link→ base_laser。

Error: No code_block found 这部分是关键部分。通过TransformBroadcaster来发送转换关系,需要附带5个参数。第1个参数,我们传递了旋转变换,在两个坐标系的发送的任意旋转,都必须通过调用btQuaternion.现在情况下,我们不想旋转,所以我们在调用btQauternion的时候,将pitch,roll,yaw的参数都置0.第2个参数,btVector3,任何变换过程都需要调用它。无论怎样,我们确实需要做一个变换,所以我们调用了btVector3,相应的传感器的x方向距离机体基准偏移10cm,z方向20cm。第3个参数,我们需要给定转换关系携带一个时间戳,我们标记为ros::Time::now()。第4个参数,我们需要传递parent节点的名字。第5个参数,传递的是child节点的名字。

Using a Transform(调用变换)

上面的代码,我们创建了一个节点来发布转换关系,baser_laser->base_link。现在,我们需要利用转换关系,将从传感器获取的数据转换到机体对应的数据,即是“base_laser”->到“base_link”坐标系的转换。下面的代码,后边会紧根更详细的解析。在robot_setup_if功能包中,在src目录下创建tf_listener.cpp,并将下面的代码粘贴到里面:

切换行号显示
   1 #include <ros/ros.h>
   2 #include <geometry_msgs/PointStamped.h>
   3 #include <tf/transform_listener.h>
   4 
   5 void transformPoint(const tf::TransformListener& listener){
   6   //we'll create a point in the base_laser frame that we'd like to transform to the base_link frame
   7   geometry_msgs::PointStamped laser_point;
   8   laser_point.header.frame_id = "base_laser";
   9 
  10   //we'll just use the most recent transform available for our simple example
  11   laser_point.header.stamp = ros::Time();
  12 
  13   //just an arbitrary point in space
  14   laser_point.point.x = 1.0;
  15   laser_point.point.y = 0.2;
  16   laser_point.point.z = 0.0;
  17 
  18   try{
  19     geometry_msgs::PointStamped base_point;
  20     listener.transformPoint("base_link", laser_point, base_point);
  21 
  22     ROS_INFO("base_laser: (%.2f, %.2f. %.2f) -----> base_link: (%.2f, %.2f, %.2f) at time %.2f",
  23         laser_point.point.x, laser_point.point.y, laser_point.point.z,
  24         base_point.point.x, base_point.point.y, base_point.point.z, base_point.header.stamp.toSec());
  25   }
  26   catch(tf::TransformException& ex){
  27     ROS_ERROR("Received an exception trying to transform a point from \"base_laser\" to \"base_link\": %s", ex.what());
  28   }
  29 }
  30 
  31 int main(int argc, char** argv){
  32   ros::init(argc, argv, "robot_tf_listener");
  33   ros::NodeHandle n;
  34 
  35   tf::TransformListener listener(ros::Duration(10));
  36 
  37   //we'll transform a point once every second
  38   ros::Timer timer = n.createTimer(ros::Duration(1.0), boost::bind(&transformPoint, boost::ref(listener)));
  39 
  40   ros::spin();
  41 
  42 }

Ok... now we'll walk through the important bits step by step.

好的,现在让我们将上面代码的重点步步分解:

Error: No code_block found 这里,我们包含tf/transform_listener.h头文件,是为了后边创建tf::TransformListener。一个TransformListener目标会自动的订阅ROS系统中的变换消息主题,同时管理所有的该通道上的变换数据。

Error: No code_block found创建一个函数,参数为TransformListener,作用为将“base_laser”坐标系的点,变换到“base_link”坐标系中。这个函数将会以ros::Timer定义的周期,作为一个回调函数周期调用。目前周期是1s。

Error: No code_block found 此处,我们创建一个虚拟点,作为geometry_msgs::PointStamped。消息名字最后的“Stamped”的意义是,它包含了一个头部,允许我们去把时间戳和消息的frame_id相关关联起来。我们将会设置laser_point的时间戳为ros::time(),即是允许我们请求TransformListener取得最新的变换数据。对于header里的frame_id,我们设置为“base_laser”,因为我们是创建的是扫描仪坐标系里的虚拟点。最后,我们将会设置具体的虚拟点,比如x:1.0,y:0.2,z:0.0

Error: No code_block found 至此,我们已经有了从“base_laser”到“base_link”变换的点数据。进一步,我们通过TransformListener对象,调用transformPoint(),填充三个参数来进行数据变换。第1个参数,代表我们想要变换的目标坐标系的名字。第2个参数填充需要变换的原始坐标系的点对象,第3个参数填充,目标坐标系的点对象。所以,在函数调用后,base_point里存储的信息就是变换后的点坐标。

Error: No code_block found 如果某些其他的原因,变换不可得(可能是tf_broadcaster 挂了),调用transformPoint()时,TransformListener调用可能会返回异常。为了体现代码的优雅性,我们将会截获异常并把异常信息呈现给用户。

Building the Code代码构建

至此,根据我们写的例子,接下来我们需要构建编译。打开CMakeList.txt,在文件末尾添加下面的几行:

add_executable(tf_broadcaster src/tf_broadcaster.cpp)
add_executable(tf_listener src/tf_listener.cpp)
target_link_libraries(tf_broadcaster ${catkin_LIBRARIES})
target_link_libraries(tf_listener ${catkin_LIBRARIES})

Next, make sure to save the file and build the package.

$ cd %TOP_DIR_YOUR_CATKIN_WS%
$ catkin_make

Running the Code(代码运行)

好的,万事俱备只欠东风!让我恩试着实际运行下吧。这部分,你需要开三个终端窗口

第一个窗口,运行core

roscore

第二个,运行 tf_broadcaster

rosrun robot_setup_tf tf_broadcaster

好的。现在,我们会在第三个窗口运行tf_listener,将从传感器坐标系获取的虚拟点,变换到机体坐标系。

rosrun robot_setup_tf tf_listener

如果一切顺利,应该会看到类似的结果。每次打印间隔1s。

[ INFO] 1248138528.200823000: base_laser: (1.00, 0.20. 0.00) -----> base_link: (1.10, 0.20, 0.20) at time 1248138528.19
[ INFO] 1248138529.200820000: base_laser: (1.00, 0.20. 0.00) -----> base_link: (1.10, 0.20, 0.20) at time 1248138529.19
[ INFO] 1248138530.200821000: base_laser: (1.00, 0.20. 0.00) -----> base_link: (1.10, 0.20, 0.20) at time 1248138530.19
[ INFO] 1248138531.200835000: base_laser: (1.00, 0.20. 0.00) -----> base_link: (1.10, 0.20, 0.20) at time 1248138531.19
[ INFO] 1248138532.200849000: base_laser: (1.00, 0.20. 0.00) -----> base_link: (1.10, 0.20, 0.20) at time 1248138532.19

祝贺你,你已经成功的编写了一个针对平面传感器的坐标变换。下一步就是替换PointStamped,来使用真正的传感器进行操作。幸运的是,已经有相关的指导文档了here

----在机器人上配置并使用导航综合功能包----

机器人配置

attachment:overview_tf.png假定我们已经以特定方式配置机器人,导航功能包集将使其可以运动。上图概述了这种配置方式。白色的部分是必须且已实现的组件,灰色的部分是可选且已实现的组件,蓝色的部分是必须为每一个机器人平台创建的组件。以下章节将介绍使用导航功能包集的先决条件以及如何满足不同平台。

ROS

导航功能包集假定机器人使用ROS系统。请查阅ROS documentation以了解如何在你的机器人上安装ROS。

TF变换配置(其他变换)

导航功能包集需要机器人不断使用tf发布有关坐标系之间的关系的信息。详细的配置教程请查阅:Tf配置.

传感器信息(sensor source)

导航功能包集使用来自传感器的信息避开现实环境中的障碍物,它假定这些传感器在ROS上不断发布sensor_msgs/LaserScan消息或者sensor_msgs/PointCloud消息。有关在ROS上发布这些消息的教程,请查阅在ROS上发布传感器数据流。此外,一些已经有了ROS上的驱动的传感器亦满足这一教程。一下是部分ROS支持的传感器以及相关驱动链接:

里程信息(odometry source)

导航功能包集需要使用tfnav_msgs/Odometry消息发布的里程信息。这里有一篇发布里程信息的教程:在ROS上发布里程信息. 以下列出部分支持里程计的平台以及可用的驱动:

基座控制器(base controller)

导航功能包集假定它可以通过话题"cmd_vel"发布geometry_msgs/Twist类型的消息,这个消息基于机器人的基座坐标系,它传递的是运动命令。这意味着必须有一个节点订阅"cmd_vel"话题, 将该话题上的速度命令(vx, vy, vtheta转换为电机命令(cmd_vel.linear.x, cmd_vel.linear.y, cmd_vel.angular.z)发送给移动基座。以下列出部分支持基座控制器的平台以及可用的驱动:

地图 (map_server)

导航功能包集的配置并不需要有一张地图以供操作,但基于本教程的目的,我们假定你有一张地图。请查阅教程创建一张地图了解在你的系统环境下创建一张地图的细节。

导航功能包集配置

本节介绍如何配置导航功能包集。假设上述所有需要的环境都已满足。特别的,这意味着,机器人必须使用tf发布坐标帧,并从所有的传感器接收 sensor_msgs/LaserScan 或者 sensor_msgs/PointCloud 消息用于导航,同时需要使用 tf 和nav_msgs/Odometry 消息发布导航消息,消息会以命令的形式下发给机器人底座。如果所需要的配置你都没有,请参见机器人配置

创建一个软件包

首先,我们创建一个软件包,用来保存我们所有的配置文件和启动文件。这个软件包需要包含所有用于实现 机器人配置小节所述以来,就如其以依赖导航功能包集高级接口 move_base 软件包一样。因此, 为你的软件包选好位置,执行以下命令:

catkin_create_pkg my_robot_name_2dnav move_base my_tf_configuration_dep my_odom_configuration_dep my_sensor_configuration_dep

这个指令会创建一个包含运行导航功能包集所需依赖的软件包。

创建机器人启动配置文件

现在,我们有了一个存放所有配置文件和启动文件的工作空间,我们会创建一个roslaunch文件来启动所有的硬件以及发布机器人所需的tf。打开你喜欢的编辑器,粘贴一下内容到my_robot_configuration.launch。你可以自由的将 "my_robot" 改成你的机器人的名字。以后,我们会对launch文件做相似的更改,确保你阅读了本节其余内容。

<launch>
  <node pkg="sensor_node_pkg" type="sensor_node_type" name="sensor_node_name" output="screen">
    <param name="sensor_param" value="param_value" />
  </node>

  <node pkg="odom_node_pkg" type="odom_node_type" name="odom_node" output="screen">
    <param name="odom_param" value="param_value" />
  </node>

  <node pkg="transform_configuration_pkg" type="transform_configuration_type" name="transform_configuration_name" output="screen">
    <param name="transform_configuration_param" value="param_value" />
  </node>
</launch>

好了,现在我们有了一个launch文件模板,但是,我们需要根据自己的机器人去完善它。以下章节,我们会逐步的改变它。

  <node pkg="sensor_node_pkg" type="sensor_node_type" name="sensor_node_name" output="screen">
    <param name="sensor_param" value="param_value" />
  </node>

这里,我们会启动机器人运行导航功能包所需的所有传感器。用你的传感器对应的ROS驱动包替换"sensor_node_pkg",用你的传感器类型替换"sensor_node_type"(通常与节点名一致),用你的传感器节点名替换"sensor_node_name","sensor_param"包含所有必需的参数。注意,如果你有多个传感器,在这里一起启动它们。

  <node pkg="odom_node_pkg" type="odom_node_type" name="odom_node" output="screen">
    <param name="odom_param" value="param_value" />
  </node>

这里,我们启动基座(底盘)的里程计。同样,替换相应的pkg, type, name,并根据实际指定相关参数。

  <node pkg="transform_configuration_pkg" type="transform_configuration_type" name="transform_configuration_name" output="screen">
    <param name="transform_configuration_param" value="param_value" />
  </node>

这里,我们启动相应的tf变换。同样,替换相应的pkg, type, name,并根据实际指定相关参数。

配置代价地图 (local_costmap) & (global_costmap)

导航功能包集需要两个代价地图来保存世界中的障碍物信息。一张代价地图用于 规划,在整个环境中创建长期的规划,另一个用于局部规划与避障。有一些参数两个地图都需要,而有一些则各不相同。因此,对于代价地图,有三个配置项: common配置项, global配置项和local配置项。

注意: 接下来的内容只是代价地图的基本配置项。想要查看完整的配置,参看costmap_2d文档.

共同配置(local_costmap) & (global_costmap)

导航功能包集使用代价地图存储障碍物信息。为了使这个过程更合理,我们需要指出要监听的传感器的话题,以更新数据。我们创建一个名为costmap_common_params.yaml的文件,内容如下:

obstacle_range: 2.5
raytrace_range: 3.0
footprint: [[x0, y0], [x1, y1], ... [xn, yn]]
#robot_radius: ir_of_robot
inflation_radius: 0.55

observation_sources: laser_scan_sensor point_cloud_sensor

laser_scan_sensor: {sensor_frame: frame_name, data_type: LaserScan, topic: topic_name, marking: true, clearing: true}

point_cloud_sensor: {sensor_frame: frame_name, data_type: PointCloud, topic: topic_name, marking: true, clearing: true}

好,现在我们分解以上代码。

obstacle_range: 2.5
raytrace_range: 3.0

这些参数设置放入代价地图的障碍信息的阈值。 “obstacle_range”参数决定了引入障碍物到代价地图的传感器读书的最大范围。 在这里,我们把它设定为2.5米,这意味着机器人只会更新以其底盘为中心半径2.5米内的障碍信息。 “raytrace_range”参数确定的空白区域内光线追踪的范围。 设置为3.0米意味着机器人将试图根据传感器读数清除其前面3.0米远的空间。

footprint: [[x0, y0], [x1, y1], ... [xn, yn]]
#robot_radius: ir_of_robot
inflation_radius: 0.55

这里我们设置机器人的footprint或机器人半径(如果是圆形的)。 指定的footprint时,机器人的中心被认为是在(0.0,0.0),顺时针和逆时针规范都支持。 我们还将设置代价地图膨胀半径。膨胀半径应该设置为障碍物产生代价的最大距离。 例如,膨胀半径设定在0.55米意味着机器人所有路径与障碍物保持0.55米或更的远离(具有同样的成本)。

observation_sources: laser_scan_sensor point_cloud_sensor

“observation_sources”参数定义了一系列传递空间信息给代价地图的传感器。每个传感器定义在下一行。

laser_scan_sensor: {sensor_frame: frame_name, data_type: LaserScan, topic: topic_name, marking: true, clearing: true}

这一行设置“observation_sources”中提到的传感器。这个例子定义了 laser_scan_sensor。 “frame_name”参数应设置为传感器坐标帧的名称,“data_type”参数应设置为LaserScan或PointCloud,这取决于主题使用的消息,“topic_name”应该设置为发布传感器数据的主题的名称。 “marking”和“clearing”参数确定传感器是否用于向代价地图添加障碍物信息,或从代价地图清除障碍信息,或两者都有。

全局配置(global_costmap)

下面我们将创建一个存储特定的全局代价地图配置选项的文件。新建一个文件:global_costmap_params.yaml并粘贴以下内容:

global_costmap:
  global_frame: /map
  robot_base_frame: base_link
  update_frequency: 5.0
  static_map: true

“global_frame”参数定义了代价地图运行所在的坐标帧。在这种情况下,我们会选择/map frame。 “robot_base_frame”参数定义了代价地图参考的的机器地毯的坐标帧。“update_frequency”参数决定了代价地图更新的频率。 “static_map”参数决定代价地图是否根据map_server提供的地图初始化。如果你不使用现有的地图,设为false。

本地配置(local_costmap)

下面我们将创建一个存储特定的本地代价地图配置选项的文件。新建一个文件:localal_costmap_params.yaml并粘贴以下内容:

local_costmap:
  global_frame: odom
  robot_base_frame: base_link
  update_frequency: 5.0
  publish_frequency: 2.0
  static_map: false
  rolling_window: true
  width: 6.0
  height: 6.0
  resolution: 0.05

“global_frame”,“robot_base_frame”,“update_frequency”,“static_map”参数与全局配置意义相同。“publish_frequency”参数决定了代价地图发布可视化信息的频率。将“rolling_window”参数设置为true,意味着随着机器人在限时世界里移动,代价地图会保持以机器人为中心。“width”、“height”,“resolution”参数分别设置代价地图的宽度(米、)高度(米)和分辨率(米/单元)。 注意,这里的分辨率和你的静态地图的分辨率可能不同,但我们通常把他们设成一样的。

完整的配置选项

这里是用于启动和运行的最简单的配置,更多的细节请参阅costmap_2d 文档.

=== Base Local Planner 配置=== Base_local_planner负责根据高层规划计算速度命令并发送给机器人基座。 我们需要根据我们的机器人规格配置一些选项使其正常启动与运行。新建一个名为base_local_planner_params.yaml的文件,内容如下:

注意: 本节只涵盖TrajectoryPlanner的基本配置选项。 文档的全部选项,请参阅base_local_planner 文档.

TrajectoryPlannerROS:
  max_vel_x: 0.45
  min_vel_x: 0.1
  max_vel_theta: 1.0
  min_in_place_vel_theta: 0.4

  acc_lim_theta: 3.2
  acc_lim_x: 2.5
  acc_lim_y: 2.5

  holonomic_robot: true

上面的第一部分参数定义机器人的速度限制。 第二部分定义了机器人的加速度的限制。

为导航功能包创建一个Launch启动文件

现在我们已经有了所有的配置文件,我么需要在一个启动文件中一起启动他们,创建一个名为move_base.launch的文件,内容如下:

<launch>
  <master auto="start"/>

  <!-- Run the map server -->
  <node name="map_server" pkg="map_server" type="map_server" args="$(find my_map_package)/my_map.pgm my_map_resolution"/>

  <!--- Run AMCL -->
  <include file="$(find amcl)/examples/amcl_omni.launch" />

  <node pkg="move_base" type="move_base" respawn="false" name="move_base" output="screen">
    <rosparam file="$(find my_robot_name_2dnav)/costmap_common_params.yaml" command="load" ns="global_costmap" />
    <rosparam file="$(find my_robot_name_2dnav)/costmap_common_params.yaml" command="load" ns="local_costmap" />
    <rosparam file="$(find my_robot_name_2dnav)/local_costmap_params.yaml" command="load" />
    <rosparam file="$(find my_robot_name_2dnav)/global_costmap_params.yaml" command="load" />
    <rosparam file="$(find my_robot_name_2dnav)/base_local_planner_params.yaml" command="load" />
  </node>
</launch>

唯一需要修改的地方是更改地图服务器使指向你的已有的地图,并且,如果你有一台差分驱动的机器人,将"amcl_omni.launch"改为"amcl_diff.launch"。对于如何创建一张地图,请查阅 创建一张地图.

AMCL 配置(amcl)

AMCL有许多配置选项将影响定位的性能。 有关AMCL的更多信息请参阅amcl文档.

运行导航功能包集

现在我们配置结束,我们可以运行导航功能包了。为此我们需要在机器人上启动两个终端人。 在一个终端上,我们将启动 my_robot_configuration.launch 文件,在另一个终端上我们将启动我们刚刚创建的move_base.launch

终端1:

roslaunch my_robot_configuration.launch

终端2:

roslaunch move_base.launch

祝贺你,导航功能包集现在应该运行了。关于如何通过图形化界面给导航功能包集发送一个目标信息,请参阅rviz和导航教程。 如果你想使用代码给导航功能包集发送导航目标,请参阅发送简单导航目标教程。

故障排除

关于运行导航功能包集时遇到的常见问题,请参阅导航功能包集故障排除页面。

#keywords 移动平台配置,机器人配置,设置机器人,getting started with mobile robot

----在ROS上发布传感器数据----

在ROS上发布传感器数据流

在ROS上正确地发布从传感器获取的数据对导航功能包集的安全运行很重要。 如果导航功能包集无法从机器人的传感器接收到任何信息,那么它就会盲目行事,最有可能的是发生碰撞。 有许多传感器可用于为导航功能包集提供信息:激光、摄像头、声纳、红外线、碰撞传感器等等。然而,目前导航功能包集只接受使用sensor_msgs/LaserScansensor_msgs/PointCloud消息类型发布的传感器数据。下面的教程将提供典型的设置和使用这两种类型的消息的例子。 相关: TF配置

ROS消息头

消息类型 sensor_msgs/LaserScan和 sensor_msgs/PointCloud跟其他的消息一样,包括tf帧和与时间相关的信息。为了标准化发送这些信息,消息类型Header被用于所有此类消息的一个字段。

类型Header包括是哪个字段。字段seq对应一个标识符,随着消息被发布,它会自动增加。字段stamp存储与数据相关联的时间信息。以激光扫描为例,stamp可能对应每次扫描开始的时间。字段frame_id存储与数据相关联的tf帧信息。以激光扫描为例,它将是激光数据所在帧。

#Standard metadata for higher-level flow data types
#sequence ID: consecutively increasing ID 
uint32 seq
#Two-integer timestamp that is expressed as:
# * stamp.secs: seconds (stamp_secs) since epoch
# * stamp.nsecs: nanoseconds since stamp_secs
# time-handling sugar is provided by the client library
time stamp
#Frame this data is associated with
# 0: no frame
# 1: global frame
string frame_id

在ROS上发布LaserScans

LaserScan消息

对于机器人的激光扫描仪,ROS提供了一个特殊的消息类型LaserScan来存储激光信息,它位于包sensor_msgsLaserScan消息方便代码来处理任何激光,只要从扫描仪获取的数据可以格式化为这种类型的消息。我们谈论如何生成和发布这些消息之前,让我们来看看消息本身的规范:

#
# Laser scans angles are measured counter clockwise, with 0 facing forward
# (along the x-axis) of the device frame
#

Header header
float32 angle_min        # start angle of the scan [rad]
float32 angle_max        # end angle of the scan [rad]
float32 angle_increment  # angular distance between measurements [rad]
float32 time_increment   # time between measurements [seconds]
float32 scan_time        # time between scans [seconds]
float32 range_min        # minimum range value [m]
float32 range_max        # maximum range value [m]
float32[] ranges         # range data [m] (Note: values < range_min or > range_max should be discarded)
float32[] intensities    # intensity data [device-specific units]

正如所期望的,上面的名字/注释明确表述了消息里的各个字段。为了更具体的说明,我们来写一个简单的激光数据发布器来展示他们是如何工作的。

编写代码发布一个LaserScan消息

在ROS上发布一个LaserScan消息是相当简单的。我们先提供下面的示例代码,然后将代码分解逐行。

切换行号显示
   1 #include <ros/ros.h>
   2 #include <sensor_msgs/LaserScan.h>
   3 
   4 int main(int argc, char** argv){
   5   ros::init(argc, argv, "laser_scan_publisher");
   6 
   7   ros::NodeHandle n;
   8   ros::Publisher scan_pub = n.advertise<sensor_msgs::LaserScan>("scan", 50);
   9 
  10   unsigned int num_readings = 100;
  11   double laser_frequency = 40;
  12   double ranges[num_readings];
  13   double intensities[num_readings];
  14 
  15   int count = 0;
  16   ros::Rate r(1.0);
  17   while(n.ok()){
  18     //generate some fake data for our laser scan
  19     for(unsigned int i = 0; i < num_readings; ++i){
  20       ranges[i] = count;
  21       intensities[i] = 100 + count;
  22     }
  23     ros::Time scan_time = ros::Time::now();
  24 
  25     //populate the LaserScan message
  26     sensor_msgs::LaserScan scan;
  27     scan.header.stamp = scan_time;
  28     scan.header.frame_id = "laser_frame";
  29     scan.angle_min = -1.57;
  30     scan.angle_max = 1.57;
  31     scan.angle_increment = 3.14 / num_readings;
  32     scan.time_increment = (1 / laser_frequency) / (num_readings);
  33     scan.range_min = 0.0;
  34     scan.range_max = 100.0;
  35 
  36     scan.ranges.resize(num_readings);
  37     scan.intensities.resize(num_readings);
  38     for(unsigned int i = 0; i < num_readings; ++i){
  39       scan.ranges[i] = ranges[i];
  40       scan.intensities[i] = intensities[i];
  41     }
  42 
  43     scan_pub.publish(scan);
  44     ++count;
  45     r.sleep();
  46   }
  47 }

现在我们将上面的代码一步一步分解。

切换行号显示
   2 #include <sensor_msgs/LaserScan.h>
   3 

这里,我们include我们想要发布的sensor_msgs/LaserScan消息。

切换行号显示
   8   ros::Publisher scan_pub = n.advertise<sensor_msgs::LaserScan>("scan", 50);

段代码创建了一个ros::Publisher用于使用ROS发送 LaserScan 消息。

切换行号显示
  10   unsigned int num_readings = 100;
  11   double laser_frequency = 40;
  12   double ranges[num_readings];
  13   double intensities[num_readings];

里我们创建一组存储虚拟数据的变量,用来模拟激光扫描(其中填充所扫描到的障碍物信息)。实际的程序应从他们的激光驱动程序获取这些数据。

切换行号显示
  18     //generate some fake data for our laser scan
  19     for(unsigned int i = 0; i < num_readings; ++i){
  20       ranges[i] = count;
  21       intensities[i] = 100 + count;
  22     }
  23     ros::Time scan_time = ros::Time::now();

填充激光数据,填充值每秒加1.

切换行号显示
  25     //populate the LaserScan message
  26     sensor_msgs::LaserScan scan;
  27     scan.header.stamp = scan_time;
  28     scan.header.frame_id = "laser_frame";
  29     scan.angle_min = -1.57;
  30     scan.angle_max = 1.57;
  31     scan.angle_increment = 3.14 / num_readings;
  32     scan.time_increment = (1 / laser_frequency) / (num_readings);
  33     scan.range_min = 0.0;
  34     scan.range_max = 100.0;
  35 
  36     scan.ranges.resize(num_readings);
  37     scan.intensities.resize(num_readings);
  38     for(unsigned int i = 0; i < num_readings; ++i){
  39       scan.ranges[i] = ranges[i];
  40       scan.intensities[i] = intensities[i];
  41     }

创建一个 scan_msgs::LaserScan 消息,并使用我们预先生成的数据填充,准备发布它。

切换行号显示
  43     scan_pub.publish(scan);

在ROS上发布这个消息。

在ROS上发布点云 PointClouds

点云消息

为了存储与分享世界中的一系列点, ROS 提供了 sensor_msgs/PointCloud 消息。正如前文所述,这个消息支持将三维空间中的点的数组以及任何保存在一个信道中的相关数据。 例如,一条带有"intensity"信道的 PointCloud 可以保持点云数据中每一个点的强度。接下来我们使用ROS发布一个 PointCloud 来探索这个过程。

#This message holds a collection of 3d points, plus optional additional information about each point.
#Each Point32 should be interpreted as a 3d point in the frame given in the header

Header header
geometry_msgs/Point32[] points  #Array of 3d points
ChannelFloat32[] channels       #Each channel should have the same number of elements as points array, and the data in each channel should correspond 1:1 with each point

编写代码发布 PointCloud 消息

使用ROS发布 PointCloud 相当简单.接下来,我们给出一个完整的例子,并详细的讨论他的每一个细节.

切换行号显示
   1 #include <ros/ros.h>
   2 #include <sensor_msgs/PointCloud.h>
   3 
   4 int main(int argc, char** argv){
   5   ros::init(argc, argv, "point_cloud_publisher");
   6 
   7   ros::NodeHandle n;
   8   ros::Publisher cloud_pub = n.advertise<sensor_msgs::PointCloud>("cloud", 50);
   9 
  10   unsigned int num_points = 100;
  11 
  12   int count = 0;
  13   ros::Rate r(1.0);
  14   while(n.ok()){
  15     sensor_msgs::PointCloud cloud;
  16     cloud.header.stamp = ros::Time::now();
  17     cloud.header.frame_id = "sensor_frame";
  18 
  19     cloud.points.resize(num_points);
  20 
  21     //we'll also add an intensity channel to the cloud
  22     cloud.channels.resize(1);
  23     cloud.channels[0].name = "intensities";
  24     cloud.channels[0].values.resize(num_points);
  25 
  26     //generate some fake data for our point cloud
  27     for(unsigned int i = 0; i < num_points; ++i){
  28       cloud.points[i].x = 1 + count;
  29       cloud.points[i].y = 2 + count;
  30       cloud.points[i].z = 3 + count;
  31       cloud.channels[0].values[i] = 100 + count;
  32     }
  33 
  34     cloud_pub.publish(cloud);
  35     ++count;
  36     r.sleep();
  37   }
  38 }

下来,我们一句一句来看.

切换行号显示
   2 #include <sensor_msgs/PointCloud.h>
   3 

包含 sensor_msgs/PointCloud 消息头文件.

切换行号显示
   8   ros::Publisher cloud_pub = n.advertise<sensor_msgs::PointCloud>("cloud", 50);

创建我们用来发布Creat PointCloud 消息的 ros::Publisher .

切换行号显示
  15     sensor_msgs::PointCloud cloud;
  16     cloud.header.stamp = ros::Time::now();
  17     cloud.header.frame_id = "sensor_frame";

填充 PointCloud 消息的头:frame 和 timestamp.

切换行号显示
  19     cloud.points.resize(num_points);

设置点云的数量.

切换行号显示
  21     //we'll also add an intensity channel to the cloud
  22     cloud.channels.resize(1);
  23     cloud.channels[0].name = "intensities";
  24     cloud.channels[0].values.resize(num_points);

增加信道 "intensity" 并设置其大小,使与点云数量相匹配.

切换行号显示
  26     //generate some fake data for our point cloud
  27     for(unsigned int i = 0; i < num_points; ++i){
  28       cloud.points[i].x = 1 + count;
  29       cloud.points[i].y = 2 + count;
  30       cloud.points[i].z = 3 + count;
  31       cloud.channels[0].values[i] = 100 + count;
  32     }

使用虚拟数据填充 PointCloud 消息.同时,使用虚拟数据填充 intensity 信道.

切换行号显示
  34     cloud_pub.publish(cloud);

使用 ROS 发布 PointCloud 消息.

----附----

概述

导航堆栈在概念级别上是相当简单的。它接收来自里程计和传感器流的信息,并输出速度命令以发送到移动基站。但是,在任意机器人上使用导航堆栈有点复杂。作为导航堆栈使用的先决条件,机器人必须运行ROS,具有tf变换树,并使用正确的ROS消息类型发布传感器数据此外,导航堆栈需要被配置为机器人的形状和动力学在高水平执行。为了帮助这个过程,本手册旨在作为典型的导航堆栈设置和配置的指南。


硬件要求

虽然导航堆栈设计为尽可能通用,但有三个主要硬件要求限制其使用:

  1. 它仅适用于差速驱动和完整轮式机器人。它假定移动基站通过发送期望的速度命令来控制,以便以下列形式实现:x速度,y速度,θ速度。
  2. 它需要安装在移动基座上某处的平面激光器。该激光器用于地图构建和本地化。
  3. 导航堆栈是在方形机器人上开发的,因此其性能将是最接近正方形或圆形的机器人。它在任意形状和尺寸的机器人上工作,但是在狭窄空间(如门口)中的大型矩形机器人可能有困难。

文档

以下文档假定熟悉机器人操作系统。关于ROS的文档可以在这里找到:ROS文档

报告错误

例子

使用导航堆栈的机器人列表

教程

  1. 在模拟中导航

    这个pagge描述真棒模拟

  2. 用真正的机器人导航

    本页描述了真实机器人的导航

  3. 为TurtleBot设置导航堆栈

    提供了机器人导航配置的第一瞥,参考其他更全面的教程。

  4. 为TurtleBot设置导航堆栈

    提供了机器人导航配置的第一瞥,参考其他更全面的教程。

  5. 赫斯基移动基地演示

    使用基本move_base设置运行Husky ,没有映射或本地化。

  6. 探索周边地区并制作地图

    从机器人的视觉探索真实的环境,并保存地图。

  7. 使用已知地图导航

    在已知区域中使用先前保存的地图来淹没

  8. 赫斯基AMCL演示

    使用move_base设置运行Husky ,使用amcl进行本地化。

  9. 赫斯基Gmapping演示

    使用move_base设置运行Husky ,使用gmapping映射和本地化(SLAM)。

  10. 赫斯基边境勘探演示

    使用move_base设置运行Husky ,使用frontier_exploration进行勘探规划,gmapping用于映射和本地化(SLAM)。

  11. 探索周边地区并制作地图

    从机器人的视野探索环境,并保存地图。

  12. 使用已知地图导航

    在已知区域中使用先前保存的地图来淹没。

  13. SLAM地图大厦与TurtleBot

    如何使用gmapping生成地图

  14. 使用TurtleBot自动导航已知地图

    本教程介绍如何使用TurtleBot与以前已知的地图。

  15. SLAM地图大厦与TurtleBot

    如何使用gmapping生成地图

  16. 使用TurtleBot自动导航已知地图

    本教程介绍如何使用TurtleBot与以前已知的地图。

  17. 使用tf设置您的机器人

    本教程提供了设置您的机器人开始使用tf的指南。

  18. Evarobot的导航在凉亭的

    如何使用以前已知的地图导航在凉亭的evarobot。

  19. 基本导航调整指南

    本指南旨在给出一些关于如何调整机器人上ROS导航堆栈的标准建议本指南并不全面,但应该给出一些洞察过程。我也鼓励人们,确保他们已经阅读ROS导航教程之前,这篇文章,因为它提供了一个很好的概述设置导航堆栈在机器人wheras本指南只是提供建议的过程。

  20. 编写全局路径规划器作为ROS中的插件

    在本教程中,我将介绍在ROS中编写和使用全局路径规划器的步骤。首先要知道的是,为ROS添加一个新的全局路径规划器,新的路径规划器必须遵守nav_core包中定义nav_core :: BaseGlobalPlanner C ++接口一旦编写了全局路径规划器,它必须作为插件添加到ROS中,以便它可以被move_base包使用。在本教程中,我将提供从编写路径规划器类开始直到将其部署为插件的所有步骤。我将使用Turtlebot作为机器人的一个例子来部署新的路径规划器。有关如何将真实GA计划程序集成为ROS插件的教程,请参阅在ROS中添加遗传算法全局路径规划器作为插件

  21. 无题

    没有说明

  22. 无题

    没有说明

  23. 机器人上的导航堆栈的设置和配置

    本教程提供了如何获取在机器人上运行的导航堆栈的分步说明。涵盖的主题包括:使用tf发送转换,发布里程计信息,通过ROS从激光器发布传感器数据,以及基本导航堆栈配置。

  24. Gazebo'da Evarobot Navigasyonu

    ÇıkartılmışharitaüzerindenGazebo'da otonom Evarobot navigasyonu。

  25. Bilinen Bir Haritada Otonom Evarobot Navigasyonu

    Dahaöncedençıkartılmışharitada otonom robot navigasyonu。

  26. 在导航堆栈中使用rviz

    本教程提供了使用rviz与导航堆栈初始化本地化系统,发送目标到机器人,以及查看导航堆栈通过ROS发布的许多可视化的指南。

  27. 通过ROS发布Odometry信息

    本教程提供了一个为导航堆栈发布里程测量信息的示例。它包括通过ROS发布nav_msgs / Odometry消息,以及通过tf从“odom”坐标框架到“base_link”坐标框架的转换。

  28. 在ROS上发布传感器流

    本教程提供了通过ROS 发送两种类型的传感器流(sensor_msgs / LaserScan消息和sensor_msgs / PointCloud消息)的示例

  29. 将目标发送到导航堆栈

    导航堆栈用于将移动基座从一个位置驱动到另一个位置,同时安全地避开障碍物。通常,机器人被赋予使用诸如rviz的预先存在的工具结合地图移动到目标位置的任务。例如,为了告诉机器人去特定的办公室,用户可以在地图中点击办公室的位置,并且机器人将试图去那里。然而,能够发送机器人目标以使用代码移动到特定位置也是重要的,很像rviz在引擎盖下。例如,用于插入机器人的代码可以首先检测插座,然后告诉机器人驱动到距离墙壁一英尺的位置,然后尝试使用臂将插头插入插座。本教程的目标是提供一个从用户代码发送导航堆栈简单目标的示例。

  30. 安装

    安装和编译此软件包的说明

  31. Evarobot探索

    如何使用frontier_exploration自动生成SLAM映射与Evarobot

  32. 使用Evarobot自动导航已知地图

    如何自主导航Evarobot与已知地图。



机器人使用ROS

下面机器人的硬件平台使用,或者可以与ROS软件一起使用。为了你的机器人添加到该页面,您可以按照该指令

门户

内嵌图片:0x-Alpha-Tracked_288.jpg
0X阿尔法NEX由机器人
内嵌图片:0x-Delta-4WD_340.jpg
0X三角洲NEX机器人
210士丹利创新V3赛格威
220士丹利创新V3赛格威
内嵌图片:innok-heros-223-thumb.jpg
223 Innok英雄
内嵌图片:innok-heros-224-thumb.jpg
224 Innok英雄
420全士丹利创新V3赛格威
440LE士丹利创新V3赛格威
440SE士丹利创新V3赛格威
内嵌图片:innok-heros-444-thumb.jpg
444 Innok英雄
ABB机器人(ROS-实业)
擅长MobileRobots公司先锋系列(P3DX,P3AT,...)
擅长MobileRobots公司先锋LX
擅长MobileRobots公司的Seekur家庭(的Seekur,小的Seekur)
淖艾尔帕兰
快板手SIMLAB
朋友
内嵌图片:AR10.jpg
AR10机器人手
AscTec旋翼
内嵌图片:x-terrabot.jpg
阿西莫夫机器人的X Terrabot
内嵌图片:aubo_robot/aubo_robotics.jpg
AUBO机器人
巴雷特手
内嵌图片:BIG-i.jpg
BIG-I
BipedRobin
内嵌图片:crazyflize20_small.png
Bitcraze Crazyflie
内嵌图片:bluerov-small.jpg
蓝机器人BlueROV
的ClearPath机器人灰熊
的ClearPath机器人赫斯基
的ClearPath机器人豺狼
的ClearPath机器人翠鸟
内嵌图片:Ridgeback+Icon+-+Small.png
的ClearPath机器人背脊犬
的ClearPath机器人疣猪
内嵌图片:hamster_img.ong
Cogniteam仓鼠
司空见惯的机器人操作平台
司空见惯的机器人移动器
司空见惯的机器人SRA服务机器人手臂
CoroWare Corobot
Cyton伽马
Dataspeed ADAS发展汽车
Dataspeed移动基地
电装VS060
机器人捷豹博士
Eddiebot
易诺华机器人彩扩
厄尔 - 脑
尔勒脑2
厄尔 - 直升机
厄尔 - 直升机Ubuntu的核心特别版
厄尔 - HexaCopter
厄尔平面
厄尔 - 罗孚
厄尔 - 蜘蛛
内嵌图片:evarobot50.png
evarobot
发那科机器人(ROS-实业)
费斯托说教Robotino
取机器人:取
取机器人:货运
弗劳恩霍夫IPA护理-O-BOT 3
弗劳恩霍夫IPA护理-O-BOT 4
吕(开放源码的类人型机器人)
Gosti爵士
内嵌图片:GoThere.png
去那里!机器人
内嵌图片:hansagv.png
韩寒的机器人
内嵌图片:icart_mini.png
I-车迷你
Ingeniarius ForteRC
内嵌图片:innok-heros-thumb.jpg
Innok英雄
英特尔爱迪生
iRobot公司的Roomba
川田NEXTAGE /浩
JACI版本
版本图标
Kobuki
乐高NXT
劣质煤
内嵌图片:mecanumbot-small.jpg
Mecanumbot
梅林miabotPro
内嵌图片:milvus_atr.png
鸢属ATR机器人
内嵌图片:milvus_mrp2.png
鸢属机器人MRP2
内嵌图片:milvus_robin.png
鸢属机器人罗宾
内嵌图片:motoman/robots_icon.jpg
莫托曼,安川(ROS-实业)
NAV2
内嵌图片:navio2.png
Navio2
Neobotix MP-500
Neobotix和珍贵-500
Neobotix和珍贵-700
开放式机组机器人
奥托博克速度感应手
PAL机器人PMB-2
PAL机器人REEM-C
PAL机器人蒂亚戈
RazBot
REEM
Robonaut 2
RoboSavvy自平衡平台
内嵌图片:Armadillo.jpg
RoboTiCan犰狳
RoboTiCan科莫多
RoboTiCan Lizi
内嵌图片:robotis_manipulator_h.png
ROBOTIS手-H
内嵌图片:robotis_thormang3.png
ROBOTIS Thormang3
内嵌图片:robotnik_agvs_small.png
Robotnik AGVS
内嵌图片:CROM_WIKI_ROS.png
Robotnik CROM
内嵌图片:robotnik_guardian_small.png
Robotnik GUARDIAN
内嵌图片:robotnik_rb1_small.png
Robotnik RB-1
内嵌图片:RB-1_BASE_WIKI_ROS.png
Robotnik RB-1 BASE
内嵌图片:robotnik_rbcar_small.png
工人RBCAR
内嵌图片:robotnik_summit_xl_small.png
Robotnik SUMMIT XL
内嵌图片:robotnik_summit_x_small.png
Robotnik SUMMIT-X
内嵌图片:Roch.png
罗奇
ROS-工业
内嵌图片:ros2bot.jpg
Ros2Bot
影手
内嵌图片:tally.jpg
Simbe机器人理货
软银辣椒
内嵌图片:Spark.png
火花
郁金香
TurtleBot
内嵌图片:universal_robot/robots_icon.jpg
通用机器人(ROS-实业)
而且不稳定
WheeledRobin
Willow Garage的PR2
Xaxxon魔环总理
内嵌图片:xbot.png
Xbot


src="http://player.vimeo.com/video/146183080" width="480" height="270" frameborder="0" allowfullscreen="" style="box-sizing: border-box;">

还有的ROS.org博客了一系列 利用ROS机器人

完整列表

移动机械臂

自定义移动机械臂

移动机器人

定制的移动机器人

机械手

自动驾驶汽车

社交机器人

人形

无人机

水下机器人

UWVs

其他


----End----

  • 19
    点赞
  • 252
    收藏
    觉得还不错? 一键收藏
  • 10
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 10
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值