DWA算法(二) dwa_ros 源码

我们需要看的是DWAPlannerROS类的源码,继承自nav_core::BaseLocalPlanner。 但是它还用到了base_local_plannerLatchedStopRotateController模块,用于latch机制和到达位置后转向。

initialize

原型是void DWAPlannerROS::initialize(std::string name, tf2_ros::Buffer* tf, costmap_2d::Costmap2DROS* costmap_ros)

其实主要是if( !isInitialized())的部分,直接看里面的内容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
ros::NodeHandle private_nh("~/" + name);
g_plan_pub_ = private_nh.advertise<nav_msgs::Path>("global_plan", 1);
l_plan_pub_ = private_nh.advertise<nav_msgs::Path>("local_plan", 1);
tf_ = tf;
costmap_ros_ = costmap_ros;
// 在局部代价地图中的位姿
costmap_ros_->getRobotPose(current_pose_);

// 局部代价地图
costmap_2d::Costmap2D* costmap = costmap_ros_->getCostmap();
// base_local_planner::LocalPlannerUtil
// 其实就是把三个参数赋值给 LocalPlannerUtil 的三个成员变量
planner_util_.initialize(tf, costmap, costmap_ros_->getGlobalFrameID());

//这里创建了dwa的共享指针,也就是 boost::shared_ptr<DWAPlanner> dp_;
dp_ = boost::shared_ptr<DWAPlanner>(new DWAPlanner(name, &planner_util_));

if( private_nh.getParam( "odom_topic", odom_topic_ ))
{
odom_helper_.setOdomTopic( odom_topic_ );
}

initialized_ = true;

// Warn about deprecated parameters -- remove this block in N-turtle
// 这些不必关注
nav_core::warnRenamedParameter(private_nh, "max_vel_trans", "max_trans_vel");
nav_core::warnRenamedParameter(private_nh, "min_vel_trans", "min_trans_vel");
nav_core::warnRenamedParameter(private_nh, "max_vel_theta", "max_rot_vel");
nav_core::warnRenamedParameter(private_nh, "min_vel_theta", "min_rot_vel");
nav_core::warnRenamedParameter(private_nh, "acc_lim_trans", "acc_limit_trans");
nav_core::warnRenamedParameter(private_nh, "theta_stopped_vel", "rot_stopped_vel");
// 动态调整
dsrv_ = new dynamic_reconfigure::Server<DWAPlannerConfig>(private_nh);
dynamic_reconfigure::Server<DWAPlannerConfig>::CallbackType cb = boost::bind(&DWAPlannerROS::reconfigureCB, this, _1, _2);
dsrv_->setCallback(cb);

但是搜索发现,DWA的参数基本都在TrajectoryPlannerROS::initialize里加载。

computeVelocityCommands

根据机器人离目标是否足够接近,路径规划由dwa sampling或者stop and rotate负责。

先是全局路径的处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 开始规划时再次获取位姿
if ( !costmap_ros_->getRobotPose(current_pose_))
{
ROS_ERROR("Could not get robot pose");
return false;
}

// TEB的部分全局路径也叫 transformed_plan,应该是因为下面的getLocalPlan参数也是这个名字
std::vector<geometry_msgs::PoseStamped> transformed_plan;
// 获取全局路径
if ( !planner_util_.getLocalPlan(current_pose_, transformed_plan) )
{
ROS_ERROR("Could not get local plan");
return false;
}

if(transformed_plan.empty())
{
ROS_WARN_NAMED("dwa_local_planner", "Received an empty transformed plan.");
return false;
}
ROS_DEBUG_NAMED("dwa_local_planner", "Received a transformed plan with %zu points.", transformed_plan.size());

上面的planner_util_.getLocalPlan的内容实际是:

1
2
3
4
5
6
7
8
9
10
11
12
base_local_planner::transformGlobalPlan(
*tf_,
global_plan_,
global_pose,
*costmap_,
global_frame_,
transformed_plan)

if(limits_.prune_plan)
{
base_local_planner::prunePlan(global_pose, transformed_plan, global_plan_);
}

涉及到了DWA的参数prune_plan,如果需要剪辑全局路径,就进入base_local_planner::prunePlan,此函数在goal_funtions.cpp,一看就是在namespace里。这里不如TEB的剪切全局路径复杂,其实就是把机器人当前位姿身后的部分剪掉,这部分是从路径起点到离位姿的欧氏距离的平方不到1米的点,这个1米已经写死,不能用参数更改,不过并不重要。

updatePlanAndLocalCosts

1
2
3
// update plan in dwa_planner even if we just stop and rotate, to allow checkTrajectory
dp_->updatePlanAndLocalCosts(current_pose_, transformed_plan,
costmap_ros_->getRobotFootprint() );
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
void DWAPlanner::updatePlanAndLocalCosts(
const geometry_msgs::PoseStamped& global_pose,
const std::vector<geometry_msgs::PoseStamped>& new_plan,
const std::vector<geometry_msgs::Point>& footprint_spec)
{
global_plan_.resize(new_plan.size());
for (unsigned int i = 0; i < new_plan.size(); ++i)
{
global_plan_[i] = new_plan[i];
}

obstacle_costs_.setFootprint(footprint_spec);

// costs for going away from path
path_costs_.setTargetPoses(global_plan_);

// costs for not going towards the local goal as much as possible
goal_costs_.setTargetPoses(global_plan_);

// alignment costs
geometry_msgs::PoseStamped goal_pose = global_plan_.back();

Eigen::Vector3f pos(global_pose.pose.position.x, global_pose.pose.position.y, tf2::getYaw(global_pose.pose.orientation));
double sq_dist =
(pos[0] - goal_pose.pose.position.x) * (pos[0] - goal_pose.pose.position.x)
+
(pos[1] - goal_pose.pose.position.y) * (pos[1] - goal_pose.pose.position.y);

// we want the robot nose to be drawn to its final position
// (before robot turns towards goal orientation), not the end of the
// path for the robot center. Choosing the final position after
// turning towards goal orientation causes instability when the
// robot needs to make a 180 degree turn at the end
std::vector<geometry_msgs::PoseStamped> front_global_plan = global_plan_;
double angle_to_goal = atan2(goal_pose.pose.position.y - pos[1], goal_pose.pose.position.x - pos[0]);

front_global_plan.back().pose.position.x = front_global_plan.back().pose.position.x +
forward_point_distance_ * cos(angle_to_goal);
front_global_plan.back().pose.position.y = front_global_plan.back().pose.position.y + forward_point_distance_ *
sin(angle_to_goal);

goal_front_costs_.setTargetPoses(front_global_plan);

// keeping the nose on the path
if (sq_dist > forward_point_distance_ * forward_point_distance_ * cheat_factor_)
{
alignment_costs_.setScale(path_distance_bias_);
// costs for robot being aligned with path (nose on path, not ju
alignment_costs_.setTargetPoses(global_plan_);
}
else
{
// once we are close to goal, trying to keep the nose close to anything destabilizes behavior.
alignment_costs_.setScale(0.0);
}
}

Eigen(二) 旋转,平移,特征值

旋转

有旋转向量、四元数和旋转矩阵三种表示方法,在Eigen里用前两种进行定义,这样参数少,可以转化为第三种。

牢记下面这张图,根据《视觉SLAM十四讲》总结:
三种方式的转换

使用的是模板类:class Eigen::AngleAxis< Scalar >,这被称为角轴表示法,顾名思义给定旋转角度和旋转轴即可。其创建方式符合Eigen的原则,传入数据类型作为模板参数。旋转角度以弧度表示,旋转轴为Vector类型的向量,注意向量必须要被归一化(vec.normalized()即可)。Eigen中也预先定义好了AngleAxisdAngleAxisf两种方便使用。

参考:基于eigen实现欧拉角(RPY), 旋转矩阵, 旋转向量, 四元数之间的变换

1
2
3
4
5
6
7
8
9
Vector3f v1 = Vector3f::UnitX();

AngleAxisf aa(45*3.1415/180, v1);
// 返回弧度制表示的旋转角度 和 旋转轴(列向量)
cout << aa.angle() <<" "<<endl<< aa.axis() <<endl;
cout << aa.toRotationMatrix()<<endl; // 转换为3 × 3的旋转矩阵

// fromRotationMatrix():从一个3×3的旋转矩阵构建AngleAxis对象。也就是提取旋转矩阵所表示的旋转角
// inverse(): 返回以AngleAxis表示的当前旋转的逆旋转

Quaternion是四元数表示法,Quaternion的构造是标准Eigen格式:数据类型作为模板参数+构造参数,而且重载了多个构造函数,因此可以方便地从角轴、旋转矩阵等数据类型进行构造。

Eigen提供了Quaternionf和Quaterniond方便使用。Quaterniond的初始化:

1
2
3
4
5
Eigen::Quaterniond q1(x, y, z, w);

Eigen::Quaterniond q2(Vector4d(x, y, z, w));

Eigen::Quaterniond q2(Matrix3d(R));

在Quaternion内部的保存中,虚部在前,实部在后

如果以第一种方式构造四元数,则实部是x,虚部是[y,z,w]。 对于第二种方式,则实部是w,虚部是[x,y,z]; 对于第三种方式,则是用3x3的旋转矩阵初始化四元数。

所以最常用是 Eigen::Quaterniond q1(w, x, y, z); ,四个数的传入顺序是w、x、y、z,对应w+xi+yj+zk

Eigen::Quaterniond 不能使用cout

  • Identity(): 返回一个表示单位旋转的四元数

  • w(): 返回四元数中的w分量。 同理 x(), y(), z()是返回相应的分量。

  • coeffs(): 返回四元数的四个数,可以对其进行索引[]获取值。 需要注意的是返回顺序是x、y、z、w,和构造函数的不同

  • toRotationMatrix() 将当前Quaternion对象转换为3×3的旋转矩阵。另外,没有fromRotationMatrix()函数,只能用构造函数传入旋转矩阵

  • slerp():对两个四元数进行球面线性插值(Spherical linear interpolation,通常简称Slerp),是四元数的一种线性插值运算,主要用于在两个表示旋转的四元数之间平滑差值。

欧拉角 —— 旋转矩阵

可以让三个AngleAxis相乘:

1
2
3
4
5
6
// 参数顺序和 static_transform_publisher 的一致
Eigen::Vector3f angle(roll, pitch, yaw);
Eigen::Matrix3d rotation;
rotation = Eigen::AngleAxisd(angle[0], Eigen::Vector3d::UnitZ()) *
Eigen::AngleAxisd(angle[1], Eigen::Vector3d::UnitY()) *
Eigen::AngleAxisd(angle[2], Eigen::Vector3d::UnitX()) ;

这样计算的结果和每个AngleAxisf转换成旋转矩阵后再相乘得到的结果是相同的

旋转矩阵转欧拉角的结果可能不是想要的,最好不使用

四元数 —— 旋转矩阵

其实这样更简单,我们可以直接从tf2_ros::Buffergeometry_msgs::TransformStamped直接获得四元数

1
2
3
4
5
6
Eigen::Quaterniond quaternion(transform.transform.rotation.w,
transform.transform.rotation.x,
transform.transform.rotation.y,
transform.transform.rotation.z
);
Eigen::Matrix3d rotation = quaternion.matrix();

平移

采用Eigen标准构造原则,构造示例如下:

1
2
3
4
Translation<float,2>(tx, ty)
Translation<float,3>(tx, ty, tz)
Translation<float,N>(s)
Translation<float,N>(vecN)

多个平移合并用乘号而不是加号,需要注意。和旋转相比,它的成员函数也相对少一些:

  • x():获取平移的x分量(可修改)

  • y():获取平移的y分量(可修改)

  • z():获取平移的z分量(可修改),注意,千万不要对一个二维的Translation获取z分量,否则直接会运行报错

  • vector()&.translation():返回当前平移的向量表示(可修改),可以索引[]获取各分量

1
2
3
4
5
Translation2d t1(1,4);
Translation2d t2(2,7);
Translation2d t3;
t3=t1*t2;
cout<<t3.translation()<<endl;

计算特征值与特征向量

构造一个EigenSolver,然后分别调用其成员函数.eigenvalues()、.eigenvectors()即可获得特征值与特征向量。

1
2
3
4
5
6
7
8
9
#include <Eigen/Eigenvalues>

A << 2,-2,0, -2,1,-2, 0,-2,0;
EigenSolver<Matrix3d> eigensolver(A);
if (eigensolver.info() == Success)
{
cout << eigensolver.eigenvalues() << endl<<endl;
cout << eigensolver.eigenvectors() << endl;
}

输出结果并不是我们平时熟悉的形式,而是

1
2
3
4
5
6
7
(4,0)
(1,0)
(-2,0)

(-0.666667,0) (-0.666667,0) (-0.333333,0)
(0.666667,0) (-0.333333,0) (-0.666667,0)
(-0.333333,0) (0.666667,0) (-0.666667,0)

实际特征值是4,1,-2. 4对应的特征向量分别为(-0.666667, -0.666667,-0.333333)

参考:
C++ Eigen库计算矩阵特征值及特征向量
Eigen函数与Matlab对应关系


彻底解决 sudo rosdep init 和 rosdep update失败问题

如果sudo rosdep initrosdep update没有执行成功,有些ROS功能会无法执行,比如rqt_tf_tree会报错:

1
2
the rosdep view is empty: call 'sudo rosdep init' and 'rosdep update'
[WARN] wait_for_service(/rqt_gui_py_node_26204/tf2_frames): failed to contact, will keep trying

这两个老大难其实都是网络的问题,sudo rosdep init的目的是为了在/etc目录下新建/ros/rosdep/sources.list.d/20-default.list文件。

小鱼基于rosdep源码制作了rosdepc,专门服务国内ROS用户,使用pip3 和 rosdepc 可以彻底消灭这个问题

1
2
3
4
5
sudo apt-get install python3-pip 
sudo pip3 install rosdepc

sudo rosdepc init
rosdepc update


rosdep update根据20-default.list文件中的网址链接去下载相应的文件,最终在/etc/ros/rosdep下放5个文件,分别是base.yaml,gentoo.yaml,osx-homebrew.yaml,python.yaml,ruby.yaml. 但又做了一些配置,所以不是下载文件能解决的。

以下步骤先从网上下载rosdistro文件夹,放到/etc/ros

执行sudo rosdep init后报错

1
2
ERROR: cannot download default sources list from:
https://raw.githubusercontent.com/ros/rosdistro/master/rosdep/sources.list.d/20-default.list Website may be down

从网上直接下载这个文件,然后放到/etc/ros/rosdep/sources.list.d,再修改如下:

1
2
3
4
5
6
7
8
9
10
11
# os-specific listings first

yaml file:///etc/ros/rosdistro/rosdep/osx-homebrew.yaml osx

# generic
yaml file:///etc/ros/rosdistro/rosdep/base.yaml
yaml file:///etc/ros/rosdistro/rosdep/python.yaml
yaml file:///etc/ros/rosdistro/rosdep/ruby.yaml
gbpdistro file:///etc/ros/rosdistro/releases/fuerte.yaml fuerte

# newer distributions (Groovy, Hydro, ...) must not be listed anymore, they are being fetched from the rosdistro index.yaml instead

rosdep update 报错

kinetic 要修改python2.7的路径, melodic和noetic要修改python3 ,当然另一种方法是从其他电脑拷贝已经生成的文件。

kinetic 的修改

1
2
reading in sources list data from /etc/ros/rosdep/sources.list.d
ERROR: unable to process source

对下面三个文件做修改
1
2
3
4
5
6
7
8
9
10
/usr/lib/python2.7/dist-packages/rosdep2/gbpdistro_support.py
修改: FUERTE_GBPDISTRO_URL = 'file:///etc/ros/rosdistro/releases/fuerte.yaml'


/usr/lib/python2.7/dist-packages/rosdep2/rep3.py
修改: REP3_TARGETS_URL = 'file:///etc/ros/rosdistro/releases/targets.yaml'


/usr/lib/python2.7/dist-packages/rosdistro/__init__.py
修改: DEFAULT_INDEX_URL = 'file:///etc/ros/rosdistro/index-v4.yaml'

启动新终端,再次执行rosdep update

melodic和noetic 的修改:clone代码仓https://github.com/ros/rosdistro到本地,并更改其文件rosdep/sources.list.d/20-default.list,将其url改成本地文件路径,内容类似如下:

1
2
3
4
5
6
7
8
# os-specific listings first
yaml file:///home/baby/rosdistro/rosdep/osx-homebrew.yaml osx

# generic
yaml file:///home/baby/rosdistro/rosdep/base.yaml
yaml file:///home/baby/rosdistro/rosdep/python.yaml
yaml file:///home/baby/rosdistro/rosdep/ruby.yaml
gbpdistro file:///home/baby/rosdistro/releases/fuerte.yaml fuerte

由于rosdep使用python3,可直接该动源码。我们需要改动三个文件:

  • /usr/lib/python3/dist-packages/rosdep2/sources_list.py,修改 DEFAULT_SOURCES_LIST_URL = 'file:///home/baby/rosdistro/rosdep/sources.list.d/20-default.list'

  • /usr/lib/python3/dist-packages/rosdep2/rep3.py,修改 REP3_TARGETS_URL = 'file:///home/baby/rosdistro/releases/targets.yaml'

  • /usr/lib/python3/dist-packages/rosdep2/gbpdistro_support.py,修改 FUERTE_GBPDISTRO_URL = 'file:///home/baby/rosdistro/releases/fuerte.yaml'

  • /usr/lib/python3/dist-packages/rosdistro/__init__.py,修改 DEFAULT_INDEX_URL = 'file:///home/baby/rosdistro/index-v4.yaml'

完成后先把/etc/ros/rosdep/sources.list.d/20-default.list删除,再执行sudo rosdep initrosdep update就可以成功了。

参考:
解决 rosdep update


gtsam

安装

下载gtsam源码: git clone https://bitbucket.org/gtborg/gtsam.git

依赖:

1
2
Boost >= 1.43 (Ubuntu: sudo apt-get install libboost-all-dev)
CMake >= 3.0 (Ubuntu: sudo apt-get install cmake)

安装可选的依赖:

  • Intel Threaded Building Blocks (TBB)

sudo apt-get install libtbb-dev

  • Intel Math Kernel Library (MKL)

https://software.intel.com/content/www/us/en/develop/articles/installing-intel-free-libs-and-python-apt-repo.html

编译

1
2
3
4
mkdir build
cd build
cmake ..
make install

ubuntu的显卡及驱动

ubuntu-drivers devices查看显卡硬件型号,也就是其中的model。 如果同意安装推荐版本,那我们只需要终端输入:sudo ubuntu-drivers autoinstall 就可以自动安装了。
当然我们也可以使用 apt 命令安装自己想要安装的版本,比如我想安装 340 这个版本号的版本,终端输入:sudo apt install nvidia-340 就自动安装了。

lshw -numeric -C display | grep product也可以查看电脑显卡的十六进制ID

驱动安装

电脑装好好,启动一直失败,报错failed to start User Manager for UID 121,连文本界面都进不去,后来才知道是ubuntu下缺少显卡驱动

先完全卸载之前安装的显卡驱动

ppa源文件卸载:sudo apt-get remove --purge nvidia*

runfile源文件卸载:sudo ./NVIDIA-Linux-x86_64-384.59.run --uninstall

ppa源驱动安装

先查询电脑最适合的显卡驱动版本,命令 ubuntu-drivers devices,这个命令不仅适用于显卡

最佳显卡驱动版本为 nvidia-driver-435, 用命令行进行安装

1
2
3
4
sudo add-apt-repository ppa:graphics-drivers/ppa
sudo apt-get update
sudo apt-get install nvidia-driver-435 #此处数字要对应上面查询到的版本号
sudo apt-get install mesa-common-dev

如果前面没有禁用secure boot,则在安装过程中会提示设置一个密码,在重启时需要输入密码验证以禁用secure boot,重启后会出现蓝屏,这时候不能直接选择continue, 而应该按下按键,选择Enroll MOK, 确认后在下一个选项中选择continue,接着输入安装驱动时设置的密码,开机。

安装完成后重启,nvidia-smi命令显示驱动信息,若出现驱动版本,就是成功

官网驱动下载

到 NVIDIA 的官网下载相应型号的驱动,官网地址
操作系统那里,一定选择Linux 64-bit

需要先安装一些 NVIDIA 显卡依赖的软件,在终端依次执行如下命令:

1
2
3
sudo dpkg --add-architecture i386
sudo apt update
sudo apt install build-essential libc6:i386

Ubuntu 系统默认安装好是使用的一个开源的驱动:nouveau,我们需要先禁用这个开源驱动,方法如下,依次执行:
1
2
sudo bash -c "echo blacklist nouveau > /etc/modprobe.d/blacklist-nvidia-nouveau.conf"
sudo bash -c "echo options nouveau modeset=0 >> /etc/modprobe.d/blacklist-nvidia-nouveau.conf"

重启一下系统,运行之前下载的驱动。可能会出现下面的信息:
1
2
3
The distribution-provided pre-install script failed!
Are you sure you want to continue? -> CONTINUE INSTALLATION
Would you like to run the nvidia-xconfig utility? -> YES

安装完成后重启系统就可以点击软件列表中的 NVIDIA 的配置软件配置显卡驱动了,如果你遇到
1
WARNING: Unable to find suitable destination to install 32-bit compatibility libraries

解决办法:
1
2
3
sudo dpkg --add-architecture i386
sudo apt update
sudo apt install libc6:i386


禾赛16线雷达

多线激光雷达,就是通过多个激光发射器在垂直方向上的分布,通过电机的旋转形成多条线束的扫描。

理论上讲,当然是线束越多、越密,对环境描述就更加充分,这样还可以降低算法的要求。

  • 扫描频率:转速。10Hz的含义是一秒雷达转10圈

  • 帧率:一秒内激光雷达电机旋转的圈数,每秒完成一圈扫描的次数。一幅点云图像代表一帧,对应激光雷达内部就是电机旋转一圈完成扫描

  • 输出点云中相邻两个点之间的夹角就是角分辨率。 水平方向上做到高分辨率不难,垂直分辨率和发射器的几何大小相关也与其排布相关。相邻两个发射器间隔越小垂直分辨率越小,禾赛的是2°

  • 输出数据: 距离、角度、反射率、时间戳

禾赛雷达的参数,波长905nm。 最常用的波长是905nm和1550 nm。1550nm波长LiDAR传感器可以以更高的功率运行,以提高探测范围,同时对于雨雾的穿透力更强。


注意这是禾赛雷达,其他雷达的线束索引和角度的关系不一定是这样了,比如速腾的线束1对应-15°,线束2对应-13°,以此类推。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
header:
seq: 42200 # 第几帧数据
stamp:
secs: 1640071161
nsecs: 165116000
frame_id: "base_link"

# 若为有序点云,height 可以大于 1,即多行点云,每行固定点云的宽度
# 若为无序点云,height 等于1,即一行点云,此时 width 即点云的数量
height: 1
width: 60676 # 一行点云的长度
fields:
-
name: "x"
offset: 0
datatype: 7 # 对应 Float32
count: 1
-
name: "y"
offset: 4
datatype: 7
count: 1
-
name: "z"
offset: 8
datatype: 7
count: 1
-
name: "intensity"
offset: 16
datatype: 7
count: 1
-
name: "timestamp"
offset: 24
datatype: 8
count: 1
-
name: "ring"
offset: 32
datatype: 4
count: 1
is_bigendian: False
# 一个点占的字节数
point_step: 48
# 一行的长度占用的字节数,也就是 point_step * width
row_step: 2912448
data: [] # 省略

# 若点云中的数据都是有限的(不包含 inf/NaN), 则为 true, 否则为 false
is_dense: True

目前把雷达坐标系设置为base_link了。

对有序点云进行操作(降采样、刚体变换)后,有序点云就会变成无序点云。velodyne雷达选择给每个点多加了一个属性ring, 详细定义如下:

1
2
3
4
5
6
7
8
9
10
namespace velodyne_pointcloud
{
struct PointXYZIR
{
PCL_ADD_POINT4D; // quad-word XYZ
float intensity;
uint16_t ring;
EIGEN_MAKE_ALIGNED_OPERATOR_NEW // ensure proper alignment
} EIGEN_ALIGN16;
}; // namespace velodyne_pointcloud

这个ring表示每个点之前属于哪个线的,16线的话,这个值为0~15。并且高度自低向高排列。


通过官方的 PandarView 软件可以用于录制和播放点云数据,点击Record(工具栏上的红色圆点)即可。这样录制的包的格式是pcap。如果是velodyne雷达,可以在ROS中运行以下指令即可播放这个包
rosrun velodyne_driver velodyne_node _model:=VLP16 _pcap:=/home/q/VLP16Data.pcap

驱动

型号PandarXT-16
禾赛雷达的驱动

节点/hesai/hesai_lidar无订阅,发布两个话题:

  • /hesai/pandar [sensor_msgs/PointCloud2] 被节点data_pretreat_node订阅
  • /hesai/pandar_packets [hesai_lidar/PandarScan],无内容

禾赛雷达数据占用的带宽.png

参考:
各品牌雷达的参数
Velodyne VLP-16激光雷达数据分析
Velodyne16 与禾赛Pandar XT16测试


cartographer的缺点
abstract Welcome to my blog, enter password to read.
Read more
最后阶段 RunFinalOptimization

现在返回到最初的node_main.cc中的Run函数,还有一句node.RunFinalOptimization();,所有轨迹结束时,再执行一次全局优化。 其实就是MapBuilderBridge::RunFinalOptimization —— PoseGraph2D::RunFinalOptimization

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
void PoseGraph2D::RunFinalOptimization()
{
{
// 参数 max_num_final_iterations 默认 200
// 优化标志
AddWorkItem([this]() LOCKS_EXCLUDED(mutex_) {
absl::MutexLock locker(&mutex_);
optimization_problem_->SetMaxNumIterations(
options_.max_num_final_iterations());
return WorkItem::Result::kRunOptimization;
});
// 参数 max_num_iterations, 默认 50
// 不优化标志
AddWorkItem([this]() LOCKS_EXCLUDED(mutex_) {
absl::MutexLock locker(&mutex_);
optimization_problem_->SetMaxNumIterations(
options_.optimization_problem_options()
.ceres_solver_options()
.max_num_iterations());
return WorkItem::Result::kDoNotRunOptimization;
});
}
// 另一个调用地方在 PoseGraph2D 析构
WaitForAllComputations();
}

在建图结束之后会运行一个新的全局优化,不要求实时性,迭代次数多
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
void PoseGraph2D::WaitForAllComputations()
{
int num_trajectory_nodes;
{
absl::MutexLock locker(&mutex_);
num_trajectory_nodes = data_.num_trajectory_nodes;
}
const int num_finished_nodes_at_start =
constraint_builder_.GetNumFinishedNodes();

auto report_progress = [this, num_trajectory_nodes,
num_finished_nodes_at_start]() {
// Log progress on nodes only when we are actually processing nodes.
if (num_trajectory_nodes != num_finished_nodes_at_start) {
std::ostringstream progress_info;
progress_info << "Optimizing: " << std::fixed << std::setprecision(1)
<< 100. *
(constraint_builder_.GetNumFinishedNodes() -
num_finished_nodes_at_start) /
(num_trajectory_nodes - num_finished_nodes_at_start)
<< "%...";
std::cout << "\r\x1b[K" << progress_info.str() << std::flush;
}
};

  • num_finished_nodes_at_start表示此时已经优化的节点数,假设为 10

  • GetNumFinishedNodes返回当前已经优化的节点数,假设为 11,也就是这个函数开始后,优化了1个节点

  • num_trajectory_nodes,轨迹的总节点数

progress_info 输出的是

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
  // First wait for the work queue to drain so that it's safe to schedule
// a WhenDone() callback.
{
const auto predicate = [this]()
EXCLUSIVE_LOCKS_REQUIRED(work_queue_mutex_) {
return work_queue_ == nullptr;
};
absl::MutexLock locker(&work_queue_mutex_);
while (!work_queue_mutex_.AwaitWithTimeout(
absl::Condition(&predicate),
absl::FromChrono(common::FromSeconds(1.)))) {
report_progress();
}
}

// Now wait for any pending constraint computations to finish.
absl::MutexLock locker(&mutex_);
bool notification = false;
constraint_builder_.WhenDone(
[this,
&notification](const constraints::ConstraintBuilder2D::Result& result)
LOCKS_EXCLUDED(mutex_) {
absl::MutexLock locker(&mutex_);
data_.constraints.insert(data_.constraints.end(), result.begin(),
result.end());
notification = true;
});
const auto predicate = [&notification]() EXCLUSIVE_LOCKS_REQUIRED(mutex_) {
return notification;
};
while (!mutex_.AwaitWithTimeout(absl::Condition(&predicate),
absl::FromChrono(common::FromSeconds(1.)))) {
report_progress();
}
CHECK_EQ(constraint_builder_.GetNumFinishedNodes(), num_trajectory_nodes);
std::cout << "\r\x1b[KOptimizing: Done. " << std::endl;
}

就是把ceres的迭代次数设置为200,其他部分其实不用太深入研究了。


后端的线程池 3 HandleWorkQueue和优化

优化的具体实现在优化器optimization_problem_。将优化的结果采用回调的方式,返回到result中。
后端将数据加入到 optimization_problem_ 的对应传感器队列中,并按时间排列。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
void PoseGraph2D::HandleWorkQueue(
const constraints::ConstraintBuilder2D::Result& result)
{
{ // 将新的约束添加到全局约束队列中
absl::MutexLock locker(&mutex_);
data_.constraints.insert(data_.constraints.end(), result.begin(),
result.end() );
}
// ceres 在这里
RunOptimization();
// 以下是 省略 的内容
//如果设置了全局优化回调函数,则进行调用
//根据约束结果,更新轨迹间的链接关系
// 优化完成后,累计节点清零
// 计算相同轨迹的 inter_constraints_same_trajectory
// 计算不同轨迹的 inter_constraints_different_trajectory

// 优化结束后,重新开启任务队列,即继续执行
// work_queue_里的 work_item
DrainWorkQueue();
}

RunOptimization

优化的实际就是成员变量node_data_submap_data_,也就是

1
2
3
4
5
6
7
8
9
10
11
12
struct NodeSpec2D
{
common::Time time;
transform::Rigid2d local_pose_2d;
transform::Rigid2d global_pose_2d;
Eigen::Quaterniond gravity_alignment;
};

struct SubmapSpec2D
{
transform::Rigid2d global_pose;
};

1
2
3
4
5
6
7
8
9
10
11
12
void PoseGraph2D::RunOptimization()
{
if (optimization_problem_->submap_data().empty() )
return;
// std::unique_ptr<optimization::OptimizationProblem2D> optimization_problem_;

// No other thread is accessing the optimization_problem_,
// data_.constraints, data_.frozen_trajectories and data_.landmark_nodes
// when executing Solve.
// 调用优化,Ceres在这里面,由于耗时间,故没加锁,防止阻塞其他线程
optimization_problem_->Solve(data_.constraints,
GetTrajectoryStates(), data_.landmark_nodes);

将所有内部约束和外部约束合并在一起执行Solve
遍历所有submap,建立参数块;遍历所有node,建立参数块。
根据约束,添加残差函数;
处理里程计问题,添加可能的残差;
求解返回结果;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
  absl::MutexLock locker(&mutex_);
// 优化后所有的submap和node数据
const auto& submap_data = optimization_problem_->submap_data();
const auto& node_data = optimization_problem_->node_data();
/* 遍历所有优化后的轨迹的所有节点位姿 */
for (const int trajectory_id : node_data.trajectory_ids())
{
for (const auto& node : node_data.trajectory(trajectory_id) )
{
auto& mutable_trajectory_node = data_.trajectory_nodes.at(node.id);
// 更新所有节点的 全局位姿
mutable_trajectory_node.global_pose =
transform::Embed3D(node.data.global_pose_2d) *
transform::Rigid3d::Rotation(
mutable_trajectory_node.constant_data->gravity_alignment);
}

// Extrapolate all point cloud poses that were not included in the
// 'optimization_problem_' yet. 注意是已经加入tarjectory但是还没有进行优化的新Node
// 由于使用采样器建立约束,有的node没有建立约束,其pose就不会被优化
// 因此,要通过已经优化的位姿转换关系来修正所有的node

// 子图的local到global的新的转移矩阵
const auto local_to_new_global =
ComputeLocalToGlobalTransform(submap_data, trajectory_id);
// 旧的转移矩阵
const auto local_to_old_global = ComputeLocalToGlobalTransform(
data_.global_submap_poses_2d, trajectory_id);
const transform::Rigid3d old_global_to_new_global =
local_to_new_global * local_to_old_global.inverse();
// 上次最后一个优化的节点
const NodeId last_optimized_node_id =
std::prev(node_data.EndOfTrajectory(trajectory_id))->id;
auto node_it =
std::next(data_.trajectory_nodes.find(last_optimized_node_id));
// 后续未优化的节点的全局pose进行转移
for (; node_it != data_.trajectory_nodes.EndOfTrajectory(trajectory_id);
++node_it)
{
// TrajectoryNode类型, 指针
auto& mutable_trajectory_node = data_.trajectory_nodes.at(node_it->id);
修正 global_pose
mutable_trajectory_node.global_pose =
old_global_to_new_global * mutable_trajectory_node.global_pose;
}
}
// 更新所有路标位姿
for (const auto& landmark : optimization_problem_->landmark_data()) {
data_.landmark_nodes[landmark.first].global_landmark_pose = landmark.second;
}
data_.global_submap_poses_2d = submap_data;
}

优化并非是实时,是在后台进行运行的,并且需要一定的时间。因此当完成优化时,前端输出结果已经对整个位姿图个数进行了增加。后面新加入的节点并未优化,所以返回优化的结果没有最新加入轨迹节点对应的结果。因此采用优化后结果中最后一个轨迹节点的位姿的转移矩阵,作为未参与优化轨迹节点的转移矩阵进行更新。

其中 为参与优化的节点,优化前位姿为 ,优化后的位姿为 。而 则为未参与优化的节点。


后端 5 计算约束

我的理解是,求inter约束也是一个 scan to map 的过程,找到和点云最相似的不同时间的子图,也就是找回环。其实是和lidar_localization的后端用NDT找关键帧的匹配是类似的,但是cartographer是点云和栅格地图匹配,不像点云匹配那样直观,分支定界的score就像NDT匹配的score,不过前者越大越好,后者越小越好。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void ConstraintBuilder2D::ComputeConstraint(
const SubmapId& submap_id, const Submap2D* const submap,
const NodeId& node_id, bool match_full_submap,
const TrajectoryNode::Data* const constant_data,
const transform::Rigid2d& initial_relative_pose,
const SubmapScanMatcher& submap_scan_matcher,
std::unique_ptr<ConstraintBuilder2D::Constraint>* constraint)
{
CHECK(submap_scan_matcher.fast_correlative_scan_matcher);
/* node在local坐标系的位姿 = 子图在local坐标系中的位姿 * node在子图的位姿*/
const transform::Rigid2d initial_pose =
ComputeSubmapPose(*submap) * initial_relative_pose;

float score = 0.;
transform::Rigid2d pose_estimate = transform::Rigid2d::Identity();

整个函数是为了计算constraint_transform(节点 j 和子图 i的关系) ,需要的参数有:

  • 节点 j 的filtered_gravity_aligned_point_cloud
  • 分支定界的初值 initial_pose (节点 j 在local map坐标系的坐标)
  • Match() 的结果 pose_estimate (节点 j 在local map坐标系的坐标).
  • ComputeSubmapPose()函数 (local map坐标系转到子图 i 坐标系)

计算pose_estimate的三步:

  1. 使用 fast correlative scan matcher 做 Fast estimate
  2. Prune if the score is too low.
  3. ceres Refine

匹配所有子图 或 局部子图

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// 匹配所有子图,对应 MaybeAddGlobalConstraint
if (match_full_submap)
{
kGlobalConstraintsSearchedMetric->Increment();
if( submap_scan_matcher.fast_correlative_scan_matcher->MatchFullSubmap(
constant_data->filtered_gravity_aligned_point_cloud,
// 参数为 global_localization_min_score
options_.global_localization_min_score(),
&score, &pose_estimate) )
{
CHECK_GT(score, options_.global_localization_min_score());
CHECK_GE(node_id.trajectory_id, 0);
CHECK_GE(submap_id.trajectory_id, 0);
// 最后记录下全局约束的次数和统计置信度
kGlobalConstraintsFoundMetric->Increment();
kGlobalConstraintScoresMetric->Observe(score);
}
else return;
}
// 匹配局部子图
else
{
kConstraintsSearchedMetric->Increment();
if (submap_scan_matcher.fast_correlative_scan_matcher->Match(
initial_pose, constant_data->filtered_gravity_aligned_point_cloud,
// 参数为 min_score
options_.min_score(),
&score, &pose_estimate) )
{
CHECK_GT(score, options_.min_score());
kConstraintsFoundMetric->Increment();
kConstraintScoresMetric->Observe(score);
}
else return;
}
{
absl::MutexLock locker(&mutex_);
score_histogram_.Add(score);
}

分枝定界求出的位姿被称为 初始位姿 3,这个初始位姿3及其携带的点云作为输入,用于ceres与此子图进行优化匹配

在建立全局约束的时候,直接在一个超大的范围内进行分枝定界搜索,并不需要计算一个特殊的初始位姿2,而直接把初始位姿设置为地图limits的中心点,可以理解为map的中心点。 而且打分的参数也不同了。

全局约束的搜索窗口范围: [1e6 * limits_.resolution(), M_PI],角度其实是±180°

ceres refine

ceres优化匹配,得到更加准确的优化位置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// Use the CSM estimate as both the initial and previous pose. 
// This has the effect that, in the absence of better information,
// we prefer the original CSM estimate.
ceres::Solver::Summary unused_summary;
// ceres更新pose_estimate,获得节点在local map中的最优位姿
ceres_scan_matcher_.Match(pose_estimate.translation(), pose_estimate,
constant_data->filtered_gravity_aligned_point_cloud,
*submap_scan_matcher.grid, &pose_estimate,
&unused_summary);
// 计算得到node相对子图的位姿
const transform::Rigid2d constraint_transform =
ComputeSubmapPose(*submap).inverse() * pose_estimate;
constraint->reset(new Constraint{submap_id,
node_id,
{ transform::Embed3D(constraint_transform),
options_.loop_closure_translation_weight(),
options_.loop_closure_rotation_weight() },
Constraint::INTER_SUBMAP} );

对于局部约束,constraint_transform并不是回环边,其实就是子图和节点的普通约束。 全局约束才构造回环边

日志

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
if (options_.log_matches() )
{
std::ostringstream info;
info << "Node " << node_id << " with "
<< constant_data->filtered_gravity_aligned_point_cloud.size()
<< " points on submap " << submap_id << std::fixed;
if (match_full_submap)
info << " matches";
else
{
const transform::Rigid2d difference =
initial_pose.inverse() * pose_estimate;
info << " differs by translation " << std::setprecision(2)
<< difference.translation().norm() << " rotation "
<< std::setprecision(3) << std::abs(difference.normalized_angle());
}
info << " with score " << std::setprecision(1) << 100. * score << "%.";
LOG(INFO) << info.str();
}