上一次我们学习了高翔《自动驾驶与机器人中的SLAM技术》中的三维ICP算法,其中包括点对点、点对线、点对面的ICP算法,本次博客学习NDT算法的源码。
NDT算法与ICP算法的最大不同之处,在我看来是NDT考虑了均值和方差这两个局部统计量。
从最后的求解方法来看,NDT采用了加权最小二乘问题的高斯-牛顿法,和ICP算法的最明显区别是多了权重分布。从高翔书中的测试结果来看,NDT的收敛速度稍弱于点对面ICP算法,但是精度高于点对面ICP算法,表现最好。
下面我们来看NDT的源码:
ndt_3d.h 以下为头文件 ------------------------------------------------------------------------------------------------
//
// Created by xiang on 2022/7/14.
//
#ifndef SLAM_IN_AUTO_DRIVING_NDT_3D_H
#define SLAM_IN_AUTO_DRIVING_NDT_3D_H
#include "common/eigen_types.h"
#include "common/point_types.h"
namespace sad {
/**
* 3D 形式的NDT
*/
//采用栅格法最近邻划分体素
class Ndt3d {
public:
enum class NearbyType {
CENTER, // 只考虑中心
NEARBY6, // 上下左右前后
};
struct Options {
int max_iteration_ = 20; // 最大迭代次数
double voxel_size_ = 1.0; // 体素大小
double inv_voxel_size_ = 1.0; //
int min_effective_pts_ = 10; // 最近邻点数阈值
int min_pts_in_voxel_ = 3; // 每个栅格中最小点数
double eps_ = 1e-2; // 收敛判定条件
double res_outlier_th_ = 20.0; // 异常值拒绝阈值
bool remove_centroid_ = false; // 是否计算两个点云中心并移除中心?
NearbyType nearby_type_ = NearbyType::NEARBY6;
};
using KeyType = Eigen::Matrix
struct VoxelData {
VoxelData() {}
VoxelData(size_t id) { idx_.emplace_back(id); }
std::vector
Vec3d mu_ = Vec3d::Zero(); // 均值
Mat3d sigma_ = Mat3d::Zero(); // 协方差
Mat3d info_ = Mat3d::Zero(); // 协方差之逆
};
Ndt3d() {
options_.inv_voxel_size_ = 1.0 / options_.voxel_size_;
GenerateNearbyGrids();
}
Ndt3d(Options options) : options_(options) {
options_.inv_voxel_size_ = 1.0 / options_.voxel_size_;
GenerateNearbyGrids();
}
//
Ndt3d
类有两个构造函数:
默认构造函数 Ndt3d()
:它使用默认的 Options
对象来初始化 options_
成员变量,并执行一些初始化步骤,如计算体素大小的倒数和生成邻近网格。
参数化构造函数 Ndt3d(Options options)
:它允许用户在创建 Ndt3d
对象时提供一个自定义的 Options
对象,这使得对象的创建更加灵活。这个构造函数也执行与默认构造函数相同的初始化步骤。
/// 设置目标的Scan
void SetTarget(CloudPtr target) {
target_ = target;
BuildVoxels();
// 计算点云中心
target_center_ = std::accumulate(target->points.begin(), target_->points.end(), Vec3d::Zero().eval(),[](const Vec3d& c, const PointType& pt) -> Vec3d { return c + ToVec3d(pt); }) /target_->size();
//lambda表达式接受两个参数:当前的累加值 c
和点云中的下一个点 pt
,并返回新的累加值。
}
/// 设置被配准的Scan
void SetSource(CloudPtr source) {
source_ = source;
source_center_ = std::accumulate(source_->points.begin(), source_->points.end(), Vec3d::Zero().eval(),[](const Vec3d& c, const PointType& pt) -> Vec3d { return c + ToVec3d(pt); }) /source_->size();
}
void SetGtPose(const SE3& gt_pose) {
gt_pose_ = gt_pose;
gt_set_ = true;
}
/// 使用gauss-newton方法进行ndt配准
bool AlignNdt(SE3& init_pose);
private:
void BuildVoxels();
/// 根据最近邻的类型,生成附近网格
void GenerateNearbyGrids();
CloudPtr target_ = nullptr;
CloudPtr source_ = nullptr;
Vec3d target_center_ = Vec3d::Zero();
Vec3d source_center_ = Vec3d::Zero();
SE3 gt_pose_;
bool gt_set_ = false;
Options options_;
std::unordered_map
std::vector
};
} // namespace sad
#endif // SLAM_IN_AUTO_DRIVING_NDT_3D_H
在头文件中有几个比较重要的函数定义,GenerateNearbyGrids(),BuildVoxels(), bool AlignNdt(SE3& init_pose),可以在ndt_3d.cc中查看他们的具体实现。
//
// Created by xiang on 2022/7/14.
//
#include "ndt_3d.h"
#include "common/lidar_utils.h"
#include "common/math_utils.h"
#include
#include
#include
namespace sad {
void Ndt3d::BuildVoxels() {
//在程序创建了 Ndt3d
类的对象之后,立即调用 SetTarget
函数来设置目标点云?
assert(target_ != nullptr);
//assert
语句用于确保 target_
指针不是空指针(nullptr
)。
assert(target_->empty() == false);
//assert
语句用于检查 target_
指向的点云对象是否不为空。
grids_.clear();//清空栅格数据
/// 分配体素
std::vector
std::for_each(index.begin(), index.end(), [idx = 0](size_t& i) mutable { i = idx++; });
std::for_each(index.begin(), index.end(), [this](const size_t& idx) {
//[this]
的作用是捕获当前对象的指针,使得在 lambda 表达式内部可以访问 target_
和 options_
这两个成员变量。
Vec3d pt = ToVec3d(target_->points[idx]) * options_.inv_voxel_size_;//点云坐标除以分辨率
auto key = CastToInt(pt);//将浮点数向量转换为整数向量
if (grids_.find(key) == grids_.end()) {//grids_.end()是空的,判断如果key不在grid_的索引中
grids_.insert({key, {idx}});//向grid_添加一个键值对 (对应栅格索引:点云索引)
} else {
grids_[key].idx_.emplace_back(idx);
}
});
/// 计算每个体素中的均值和协方差
std::for_each(std::execution::par_unseq, grids_.begin(), grids_.end(), [this](auto& v) {
if (v.second.idx_.size() > options_.min_pts_in_voxel_) {
// 要求至少有3个点
math::ComputeMeanAndCov(v.second.idx_, v.second.mu_, v.second.sigma_,
[this](const size_t& idx) { return ToVec3d(target_->points[idx]); });
void ComputeMeanAndCov(const C& data, Eigen::Matrix& mean, Eigen::Matrix& cov,
Getter&& getter) {
//&& 表示这是一个右值引用,它允许 getter 参数以右值的方式传递给函数。
Getter获取数据函数, 接收一个容器内数据类型,返回一个Eigen::Matrix 矢量类型
using D = Eigen::Matrix;
using E = Eigen::Matrix;
size_t len = data.size();
assert(len > 1);
// clang-format off
mean = std::accumulate(data.begin(), data.end(), Eigen::Matrix::Zero().eval(),[&getter](const D& sum, const auto& data) -> D { return sum + getter(data); }) / len;
cov = std::accumulate(data.begin(), data.end(), E::Zero().eval(),[&mean, &getter](const E& sum, const auto& data) -> E {D v = getter(data) - mean;return sum + v * v.transpose();}) / (len - 1);
// clang-format on
}
// SVD 检查最大与最小奇异值,限制最小奇异值
//采用分解协方差矩阵的方法求协方差矩阵的逆。
Eigen::JacobiSVD svd(v.second.sigma_, Eigen::ComputeFullU | Eigen::ComputeFullV);
Vec3d lambda = svd.singularValues();//用来记录奇异值
if (lambda[1] < lambda[0] * 1e-3) {
lambda[1] = lambda[0] * 1e-3;
//如果某些奇异值非常小,直接求倒数可能会导致数值不稳定。
}
if (lambda[2] < lambda[0] * 1e-3) {
lambda[2] = lambda[0] * 1e-3;
}
Mat3d inv_lambda = Vec3d(1.0 / lambda[0], 1.0 / lambda[1], 1.0 / lambda[2]).asDiagonal();//计算信息矩阵,信息矩阵是一个对角阵。
// v.second.info_ = (v.second.sigma_ + Mat3d::Identity() * 1e-3).inverse(); // 避免出nan
//
Mat3d::Identity()
创建一个 3x3 的单位矩阵,它是对角线上全是 1,其他位置全是 0 的方阵。* 1e-3
将单位矩阵的每个元素乘以 0.001
,得到一个小的对角矩阵。这个操作通常用于添加一个微小的正值到协方差矩阵的对角线上,以确保协方差矩阵是正定的。v.second.info_ = svd.matrixV() * inv_lambda * svd.matrixU().transpose();
//v.second.info_协方差之逆
}
});
/// 删除点数不够的
for (auto iter = grids_.begin(); iter != grids_.end();) {
//这是一个 for 循环,使用迭代器 iter
遍历 grids_
容器。循环的条件是迭代器 iter
不等于 grids_.end()
,即迭代器指向容器的末尾。
if (iter->second.idx_.size() > options_.min_pts_in_voxel_) {
iter++;
} else {
iter = grids_.erase(iter);
//grids_.erase(iter)
调用将删除当前迭代器指向的元素,并返回一个指向下一个元素的迭代器。这个迭代器赋值给 iter
,这样循环可以继续检查下一个元素。
}
}
}
bool Ndt3d::AlignNdt(SE3& init_pose) {
LOG(INFO) << "aligning with ndt";
assert(grids_.empty() == false);
SE3 pose = init_pose;
if (options_.remove_centroid_) {
pose.translation() = target_center_ - source_center_; // 设置平移初始值
LOG(INFO) << "init trans set to " << pose.translation().transpose();
}
//为什么remove_centroid_的初值是false?
//将 remove_centroid_
的初始值设置为 false
是为了提供默认的灵活性和安全性,同时允许用户根据具体需求调整算法的行为?(ai的回复)
// 对点的索引,预先生成
int num_residual_per_point = 1;
if (options_.nearby_type_ == NearbyType::NEARBY6) {
num_residual_per_point = 7;
}
std::vector
for (int i = 0; i < index.size(); ++i) {
index[i] = i;
}
// 我们来写一些并发代码
int total_size = index.size() * num_residual_per_point;
for (int iter = 0; iter < options_.max_iteration_; ++iter) {
std::vector
std::vector
std::vector
std::vector
// gauss-newton 迭代
// 最近邻,可以并发
std::for_each(std::execution::par_unseq, index.begin(), index.end(), [&](int idx) {
//[&]
是一个 lambda 表达式的捕获列表,它告诉编译器在 lambda 表达式内部使用的所有外部变量都应该是引用捕获的。
auto q = ToVec3d(source_->points[idx]);
Vec3d qs = pose * q; // 转换之后的q
// 计算qs所在的栅格以及它的最近邻栅格
Vec3i key = CastToInt(Vec3d(qs * options_.inv_voxel_size_));
for (int i = 0; i < nearby_grids_.size(); ++i) {
//nearby_grids_ = {KeyType(0, 0, 0), KeyType(-1, 0, 0), KeyType(1, 0, 0), KeyType(0, 1, 0),
KeyType(0, -1, 0), KeyType(0, 0, -1), KeyType(0, 0, 1)};
auto key_off = key + nearby_grids_[i];
//key
是当前点的体素索引。key_off
是计算得到的新体素索引,它是当前点的索引加上周围的偏移量
auto it = grids_.find(key_off);
int real_idx = idx * num_residual_per_point + i;
//
idx
是当前点在点云中的索引。num_residual_per_point
是每个点对应的残差数量。real_idx
是计算得到的残差在残差数组中的索引。 if (it != grids_.end()) {
auto& v = it->second; // voxel
Vec3d e = qs - v.mu_;
// check chi2 th
double res = e.transpose() * v.info_ * e;
if (std::isnan(res) || res > options_.res_outlier_th_) {
effect_pts[real_idx] = false;
continue;
}
// build residual
Eigen::Matrix
J.block<3, 3>(0, 0) = -pose.so3().matrix() * SO3::hat(q);
J.block<3, 3>(0, 3) = Mat3d::Identity();
jacobians[real_idx] = J;
errors[real_idx] = e;
infos[real_idx] = v.info_;
//std::vector
// std::vector
// std::vector
// std::vector
effect_pts[real_idx] = true;
} else {
effect_pts[real_idx] = false;
}
}
});
// 累加Hessian和error,计算dx
// 原则上可以用reduce并发,写起来比较麻烦,这里写成accumulate
//后面就和ICP算法一致了,只是求解的时候,中间多乘了协方差矩阵的逆。不过多说明了。
double total_res = 0;
int effective_num = 0;
Mat6d H = Mat6d::Zero();//6*6
Vec6d err = Vec6d::Zero();//6*1
for (int idx = 0; idx < effect_pts.size(); ++idx) {
if (!effect_pts[idx]) {
continue;
}
total_res += errors[idx].transpose() * infos[idx] * errors[idx];
// chi2.emplace_back(errors[idx].transpose() * infos[idx] * errors[idx]);
effective_num++;
H += jacobians[idx].transpose() * infos[idx] * jacobians[idx];
err += -jacobians[idx].transpose() * infos[idx] * errors[idx];
}
if (effective_num < options_.min_effective_pts_) {
LOG(WARNING) << "effective num too small: " << effective_num;
return false;
}
Vec6d dx = H.inverse() * err;
pose.so3() = pose.so3() * SO3::exp(dx.head<3>());
pose.translation() += dx.tail<3>();
// 更新
LOG(INFO) << "iter " << iter << " total res: " << total_res << ", eff: " << effective_num
<< ", mean res: " << total_res / effective_num << ", dxn: " << dx.norm()
<< ", dx: " << dx.transpose();
// std::sort(chi2.begin(), chi2.end());
// LOG(INFO) << "chi2 med: " << chi2[chi2.size() / 2] << ", .7: " << chi2[chi2.size() * 0.7]
// << ", .9: " << chi2[chi2.size() * 0.9] << ", max: " << chi2.back();
if (gt_set_) {
double pose_error = (gt_pose_.inverse() * pose).log().norm();
LOG(INFO) << "iter " << iter << " pose error: " << pose_error;
}
if (dx.norm() < options_.eps_) {
LOG(INFO) << "converged, dx = " << dx.transpose();
break;
}
}
init_pose = pose;
return true;
}
void Ndt3d::GenerateNearbyGrids() {
if (options_.nearby_type_ == NearbyType::CENTER) {
nearby_grids_.emplace_back(KeyType::Zero());
} else if (options_.nearby_type_ == NearbyType::NEARBY6) {
nearby_grids_ = {KeyType(0, 0, 0), KeyType(-1, 0, 0), KeyType(1, 0, 0), KeyType(0, 1, 0),
KeyType(0, -1, 0), KeyType(0, 0, -1), KeyType(0, 0, 1)};
}
}
} // namespace sad
补充右值引用:
在 C++ 中,左值引用(lvalue reference)和右值引用(rvalue reference)是两种不同类型的引用,它们在语义和使用场景上有所不同。以下是它们的主要区别:
定义和用途:
语法:
type&
声明,例如 int& a;
。type&&
声明,例如 int&& b;
。绑定对象:
移动语义:
完美转发:
生命周期:
可变性:
常量性:
const type&
),用于防止修改引用的对象。const type&&
),但在移动操作中通常不是必需的。函数参数:
返回值:
补充:奇异值分解求协方差矩阵的逆的原理是什么?
奇异值分解(SVD)是一种将矩阵分解为一系列正交矩阵和对角矩阵的数学方法。对于协方差矩阵 ΣΣ,SVD 可以表示为:
Σ=UΣVTΣ=UΣVT
其中:
协方差矩阵的逆可以通过以下步骤使用 SVD 来求解:
奇异值分解: 首先对协方差矩阵 ΣΣ 进行奇异值分解,得到 UU, ΣΣ, 和 VTVT。
计算逆矩阵: 协方差矩阵的逆可以表示为:
Σ−1=VΣ−1UTΣ−1=VΣ−1UT
其中 Σ−1Σ−1 是一个对角矩阵,其对角线上的元素是 ΣΣ 对应奇异值的倒数。
处理零或接近零的奇异值: 如果协方差矩阵不是满秩的,或者某些奇异值非常小,直接求倒数可能会导致数值不稳定。在这种情况下,可以设置一个阈值,将小于该阈值的奇异值视为零,或者给它们一个非零的最小值,以避免除以零或非常小的数。
计算信息矩阵: 使用修改后的奇异值,计算信息矩阵 Σ−1Σ−1,然后将其乘以 VV 和 UTUT 来得到协方差矩阵的逆。
使用 SVD 求协方差矩阵的逆的原理是基于 SVD 能够将矩阵分解为一系列正交变换和一个对角线上包含所有奇异值的对角矩阵。这种方法在数值上通常比直接求逆更稳定,尤其是当矩阵接近奇异或病态时。