点云配准就是给定两帧点云,一帧固定为目标点云,一帧需要匹配的源点云,下文简称 S (Source) 和 T (Target) 点云。求最优的变换矩阵 T 4 x 4 T_{4x4} T4x4 希望将源 S 完美变换到目标 T 上去,那么我们就知道了 S 和 T 之间的三维变换关系 T t s T_{ts} Tts 或者 T s t T_{st} Tst ,在这里我们统一采用左乘形式,即对于目标 T 和源 S 点云,他俩之间的变换有如下关系 。
S = T s t ∗ T T = T t s ∗ S S = T_{st} * T \\ T = T_{ts} * S S=Tst∗TT=Tts∗S
本文主要是利用部分PCL函数,实现手写SVD-ICP,ICP也可以利用非线性优化来实现,稳定性要优于SVD解析方法。
特别注意,PCL库中实现了ICP,NDT等注册算法,跟我一起默念三遍,
简单展开分析一下,注意两个输入 Source 和 Target,在实际使用中可能会搞混,最后 getFinalTransformation 实际输出的是矩阵是 Source to Target 也就是 T t s T_{ts} Tts 矩阵 。
这是非常符合人的理解逻辑的,从点云注册的目的来思考,之所以叫源 Source 和目标 Target ,就是把源 S S S 点云变换到目标点云 T T T 上去,设经过 ICP 计算变换后的点云为 S r e g S_{reg} Sreg ,我们的目标就是让 S r e g S_{reg} Sreg 和 T T T 尽可能完全重合,那么有如下关系。其中 T t s T r u e T_{ts}^{True} TtsTrue 表示真实的变换关系,我们无法得知, T t s E s t i m a t e T_{ts}^{Estimate} TtsEstimate 表示估计的变换关系, e r r o r error error 表示变换的误差(点云间距离等)。
T = T t s T r u e ∗ S T t s E s t i m a t e = I C P ( S o u r c e , T a r g e t ) S r e g = T t s E s t i m a t e ∗ S e r r o r = d i s t ( T , S r e g ) T = T_{ts}^{True} * S \\ T_{ts}^{Estimate} = ICP(Source,Target) \\ S_{reg} = T_{ts}^{Estimate} * S \\ error = dist(T,S_{reg}) T=TtsTrue∗STtsEstimate=ICP(Source,Target)Sreg=TtsEstimate∗Serror=dist(T,Sreg)
从 PCL 的 ICP 调用来看,cloud_source_registered 对应输出注册后的点云 S r e g S_{reg} Sreg ,transformation 对应估计的变换 T t s E s t i m a t e T_{ts}^{Estimate} TtsEstimate 矩阵。也就是说,PCL 的返回变换矩阵永远按照 T t s T_{ts} Tts 矩阵返回,而源点云就是你调用 setInputSource 传入的点云,目标点云就是 setInputTarget 传入的点云。以激光里程计为例,我们点云帧间注册的常见方法是把当前新采集的点云注册到局部点云地图中去,那么自然,当前点云就是 Source ,局部地图就是 Target 。
pcl::IterativeClosestPoint<pcl::PointXYZ, pcl::PointXYZ> icp;
icp.setInputSource(cl_t);
icp.setInputTarget(cl_s);
icp.align (cloud_source_registered);// S_{reg} = cloud_source_registered
// Obtain the transformation that aligned cloud_source to cloud_source_registered
Eigen::Matrix4f transformation = icp.getFinalTransformation ();// T_ts(Estimate)
点云预处理,可以利用PCL的VoxelGrid滤波器进行降采样,或利用统计滤波去除一些离群值,如下所示,注意以下代码只是做说明之用,本文最后提供了完整的例程。
pcl::PointCloud<pcl::PointXYZ>::Ptr cloud_raw(new pcl::PointCloud<pcl::PointXYZ>);
pcl::PointCloud<pcl::PointXYZ>::Ptr cloud_filter(new pcl::PointCloud<pcl::PointXYZ>);
//体素降采样------------------------------------------------------------
pcl::VoxelGrid<pcl::PointXYZ> voxel_filter;
voxel_filter.setInputCloud(cloud_raw);
voxel_filter.setLeafSize (0.1f, 0.1f, 0.1f);// 单位:m
voxel_filter.filter(*cloud_filter);
//统计滤波--------------------------------------------------------------
pcl::StatisticalOutlierRemoval<pcl::PointXYZ> stat_filter;
stat_filter.setInputCloud(cloud_raw);
stat_filter.setMeanK(50); //K近邻搜索点个数
stat_filter.setStddevMulThresh(1.0); //标准差倍数
stat_filter.setNegative(false); //保留未滤波点(外点),false就是只要内点
stat_filter.filter(*cloud_filter); //保存滤波结果到cloud_filter
在预处理之后,直接利用上一步变换阵或者初始估计来变换源点云,代码如下:
pcl::PointCloud<pcl::PointXYZ>::Ptr cloud_S(new pcl::PointCloud<pcl::PointXYZ>);
pcl::PointCloud<pcl::PointXYZ>::Ptr cloud_S_Trans(new pcl::PointCloud<pcl::PointXYZ>);
Eigen::Matrix4f transform = Eigen::Matrix4f::Identity(); //可以根据实际情况来赋初值或从上一步ICP得到
pcl::transformPointCloud(*cloud_S,*cloud_S_Trans,transform);
一般利用到 KD-Tree 结构来快速搜索,pcl 中常用 KdTreeFLANN 中的 nearestKSearch 来找最近点,这里我们的策略是,目标点云一般是固定不动的(例如在激光里程计前端的局部地图),并不会每次扫描都更新,因此把 T T T 建为 kd-tree 而在 S S S 点云内部遍历来找点比较节省时间。 nearestKSearch 函数的输出 indexs,distances 就是 k 个最近点的索引和距离。
pcl::KdTreeFLANN<pcl::PointXYZ> kd_tree;
kd_tree.setInputCloud(cl_t);
float max_correspond_distance_ = 0.001;
pcl::PointCloud<pcl::PointXYZ>::Ptr cloud_S_Trans(new pcl::PointCloud<pcl::PointXYZ>);
pcl::transformPointCloud(*cl_s, *cloud_S_Trans, transform);
std::vector<int> match_indexs;
for (auto i = 0; i < cloud_S_Trans->size(); ++i)
{
std::vector<float> k_distances(2);
std::vector<int> k_indexs(2);
kd_tree.nearestKSearch(cloud_S_Trans->at(i), 1, k_indexs, k_distances);
if (k_distances[0] < max_correspond_distance_)
{
match_indexs.emplace_back(k_indexs[0]);
}
}
pcl::PointCloud<pcl::PointXYZ>::Ptr cloud_match_from_S_trans(new pcl::PointCloud<pcl::PointXYZ>);
pcl::copyPointCloud(*cloud_S_Trans, match_indexs, *cloud_match_from_S_trans);
pcl::PointCloud<pcl::PointXYZ>::Ptr cloud_match_from_T(new pcl::PointCloud<pcl::PointXYZ>);
pcl::copyPointCloud(*cl_t, match_indexs, *cloud_match_from_T);
经过上面几步后,我们得到了完全匹配的两组点云 T (cloud_match_from_T) 和 S (cloud_match_from_S_trans),注意这里的 S 和 T 都已经不是原始的点云,已经经过了预处理、变换和匹配。那么 point-to-point ICP 的目标函数如下,求最优的旋转和平移。默认 S 和 T 的点已经匹配完毕。
R ∗ , t ∗ = a r g m i n R , t 1 ∣ N S ∣ ∑ i = 1 N S ∣ ∣ T i ( x , y , z ) − [ R T S ∗ S i ( x , y , z ) + t T S ] ∣ ∣ 2 R^*,t^*=argmin_{R,t} \frac{1}{|N_S|}\sum_{i=1}^{N_S}{||T_i(x,y,z)-[R_{TS}*S_i(x,y,z) + t_{TS}] ||^2} R∗,t∗=argminR,t∣NS∣1i=1∑NS∣∣Ti(x,y,z)−[RTS∗Si(x,y,z)+tTS]∣∣2
计算源和目标点云质心 μ S , μ T \mu_S, \mu_T μS,μT 。
μ S = 1 N S ∑ i = 1 N S S i ( x i , y i , z i ) μ T = 1 N T ∑ i = 1 N T T i ( x i , y i , z i ) \mu_S = \frac{1}{N_S} \sum_{i=1}^{N_S}{S_i}(x_i,y_i,z_i) \\ \mu_T = \frac{1}{N_T} \sum_{i=1}^{N_T}{T_i}(x_i,y_i,z_i) μS=NS1i=1∑NSSi(xi,yi,zi)μT=NT1i=1∑NTTi(xi,yi,zi)
基于 PCL 计算点云质心。
// 3d center of two clouds
Eigen::Vector4f mu_S_temp, mu_T_temp;
pcl::compute3DCentroid(*cloud_match_from_S_trans, mu_S_temp);
pcl::compute3DCentroid(*cloud_match_from_T, mu_T_temp);
Eigen::Vector3f mu_S(mu_S_temp[0], mu_S_temp[1], mu_S_temp[2]);
Eigen::Vector3f mu_T(mu_T_temp[0], mu_T_temp[1], mu_T_temp[2]);
原始点云减去对应质心,得到去质心的点云 $ S^c , T^c $ 。即匹配后的 S 和 T 点云转换到质心坐标系。
S c = S − μ S T c = T − μ T S^c = S - \mu_S \\ T^c = T - \mu_T Sc=S−μSTc=T−μT
计算矩阵 H H H ,是去质心之后的点相乘得来,维度是 3x3 。
H 3 × 3 = ∑ i = 1 N S S i c ( x , y , z ) 3 × 1 ∗ T i c ( x , y , z ) 1 × 3 T = S 3 × N C ∗ T N × 3 C T H_{3\times3} = \sum_{i=1}^{N_S}{S^c_i}(x,y,z)_{3\times1}*{T^c_i}(x,y,z)_{1\times3}^T = S^C_{3\times{N}}*T^{CT}_{{N}\times3} H3×3=i=1∑NSSic(x,y,z)3×1∗Tic(x,y,z)1×3T=S3×NC∗TN×3CT
基于 PCL 的 H H H 矩阵计算
// H += (S_i) * (T_i^T)
Eigen::Matrix3f H_icp = Eigen::Matrix3f::Identity();
for (auto i = 0; i < match_indexs.size(); ++i)
{
Eigen::Vector3f s_ci(cloud_match_from_S_trans->at(i).x - mu_S[0],
cloud_match_from_S_trans->at(i).y - mu_S[1],
cloud_match_from_S_trans->at(i).z - mu_S[2]);
Eigen::Vector3f t_ci(cloud_match_from_T->at(i).x - mu_T[0],
cloud_match_from_T->at(i).y - mu_T[1],
cloud_match_from_T->at(i).z - mu_T[2]);
Eigen::Matrix3f H_temp = s_ci * t_ci.transpose();
H_icp += H_temp;
}
对 H H H 求 SVD 分解,根据公式求得 R ∗ , t ∗ R^*,t^* R∗,t∗
H = U Σ V T R T S ∗ = V ∗ U T t T S ∗ = μ T − R T S ∗ ∗ μ S H = U \Sigma V^T \\ R^*_{TS} = V*U^T \\ t^*_{TS} = \mu_T - R^*_{TS}*\mu_S H=UΣVTRTS∗=V∗UTtTS∗=μT−RTS∗∗μS
直接利用 Eigen 计算 SVD 分解,并求取旋转和平移。
Eigen::JacobiSVD<Eigen::MatrixXf> svd(H_icp, Eigen::ComputeThinU | Eigen::ComputeThinV);
Eigen::Matrix3f R_ts = svd.matrixU() * (svd.matrixV().transpose());
Eigen::Vector3f t_ts = mu_T - R_ts * mu_S;// T = R_ts * S + t_ts
ICP 匹配,注意这里的时间计算是比较随性的,严谨的方法请自行实现。
void ICP_PCL(pcl::PointCloud<pcl::PointXYZ>::Ptr &cl_s,
pcl::PointCloud<pcl::PointXYZ>::Ptr &cl_t,
pcl::PointCloud<pcl::PointXYZ>::Ptr &cl_final,
Eigen::Matrix4f &T_ts,
double &time_cost)
{
clock_t startT, endT;
startT = clock();
pcl::IterativeClosestPoint<pcl::PointXYZ, pcl::PointXYZ> icp;
//trs source to target = T_ts
icp.setInputSource(cl_s);
icp.setInputTarget(cl_t);
icp.setMaxCorrespondenceDistance(10);//important
icp.setMaximumIterations(50);
icp.setTransformationEpsilon(1e-10);
icp.setEuclideanFitnessEpsilon(0.001);
icp.align(*cl_final);
T_ts = icp.getFinalTransformation();
endT = clock();
time_cost = (double) (endT - startT) / CLOCKS_PER_SEC;
}
NDT 匹配,这里用了体素滤波,注意这里的时间计算是比较随性的,严谨的方法请自行实现。
void NDT_PCL(pcl::PointCloud<pcl::PointXYZ>::Ptr &cl_s,
pcl::PointCloud<pcl::PointXYZ>::Ptr &cl_t,
pcl::PointCloud<pcl::PointXYZ>::Ptr &cl_final,
Eigen::Matrix4f &T_ts,
double &time_cost)
{
clock_t startT, endT;
startT = clock();
pcl::NormalDistributionsTransform<pcl::PointXYZ, pcl::PointXYZ> ndt;
pcl::PointCloud<pcl::PointXYZ>::Ptr cl_s_filt(new pcl::PointCloud<pcl::PointXYZ>);
pcl::PointCloud<pcl::PointXYZ>::Ptr cl_t_filt(new pcl::PointCloud<pcl::PointXYZ>);
pcl::VoxelGrid<pcl::PointXYZ> voxel_filter;
voxel_filter.setLeafSize(0.1f, 0.1f, 0.1f);
voxel_filter.setInputCloud(cl_s);
voxel_filter.filter(*cl_s_filt);
voxel_filter.setInputCloud(cl_t);
voxel_filter.filter(*cl_t_filt);
ndt.setInputSource(cl_s_filt);
ndt.setInputTarget(cl_t_filt);
ndt.setStepSize(0.1);
ndt.setResolution(1.0);
ndt.setMaximumIterations(50);
ndt.setTransformationEpsilon(1e-10);
ndt.align(*cl_final);
T_ts = ndt.getFinalTransformation();
endT = clock();
time_cost = (double) (endT - startT) / CLOCKS_PER_SEC;
}
手写 ICP 匹配,注意这里的时间计算是比较随性的,严谨的方法请自行实现。
void ICP_MANUAL(pcl::PointCloud<pcl::PointXYZ>::Ptr &cl_s,
pcl::PointCloud<pcl::PointXYZ>::Ptr &cl_t,
pcl::PointCloud<pcl::PointXYZ>::Ptr &cl_final,
Eigen::Matrix4f &T_ts,
double &time_cost)
{
clock_t startT, endT;
startT = clock();
// target cloud is fixed, so kd-tree create for target
pcl::KdTreeFLANN<pcl::PointXYZ> kd_tree;
kd_tree.setInputCloud(cl_t);
int nICP_Step = 10; //you can change this param for different situations
float max_correspond_distance_ = 0.001; //you can change this param for different situations
Eigen::Matrix4f transform = Eigen::Matrix4f::Identity(); //init T_st
for (int i = 0; i < nICP_Step; ++i)
{
pcl::PointCloud<pcl::PointXYZ>::Ptr cloud_S_Trans(new pcl::PointCloud<pcl::PointXYZ>);
// let us transform [cl_s] to [cloud_S_Trans] by [T_st](transform)
// T_st changed for each interation, make [cloud_S_Trans] more and more close to [cl_s]
pcl::transformPointCloud(*cl_s, *cloud_S_Trans, transform);
std::vector<int> match_indexs;
for (auto i = 0; i < cloud_S_Trans->size(); ++i)
{
std::vector<float> k_distances(2);
std::vector<int> k_indexs(2);
kd_tree.nearestKSearch(cloud_S_Trans->at(i), 1, k_indexs, k_distances);
if (k_distances[0] < max_correspond_distance_)
{
match_indexs.emplace_back(k_indexs[0]);
}
}
// matched clouds
pcl::PointCloud<pcl::PointXYZ>::Ptr cloud_match_from_S_trans(new pcl::PointCloud<pcl::PointXYZ>);
pcl::copyPointCloud(*cloud_S_Trans, match_indexs, *cloud_match_from_S_trans);
pcl::PointCloud<pcl::PointXYZ>::Ptr cloud_match_from_T(new pcl::PointCloud<pcl::PointXYZ>);
pcl::copyPointCloud(*cl_t, match_indexs, *cloud_match_from_T);
// 3d center of two clouds
Eigen::Vector4f mu_S_temp, mu_T_temp;
pcl::compute3DCentroid(*cloud_match_from_S_trans, mu_S_temp);
pcl::compute3DCentroid(*cloud_match_from_T, mu_T_temp);
Eigen::Vector3f mu_S(mu_S_temp[0], mu_S_temp[1], mu_S_temp[2]);
Eigen::Vector3f mu_T(mu_T_temp[0], mu_T_temp[1], mu_T_temp[2]);
// H += (S_i) * (T_i^T)
Eigen::Matrix3f H_icp = Eigen::Matrix3f::Identity();
for (auto i = 0; i < match_indexs.size(); ++i)
{
Eigen::Vector3f s_ci(cloud_match_from_S_trans->at(i).x - mu_S[0],
cloud_match_from_S_trans->at(i).y - mu_S[1],
cloud_match_from_S_trans->at(i).z - mu_S[2]);
Eigen::Vector3f t_ci(cloud_match_from_T->at(i).x - mu_T[0],
cloud_match_from_T->at(i).y - mu_T[1],
cloud_match_from_T->at(i).z - mu_T[2]);
Eigen::Matrix3f H_temp = s_ci * t_ci.transpose();
H_icp += H_temp;
}
// H = SVD
Eigen::JacobiSVD<Eigen::MatrixXf> svd(H_icp, Eigen::ComputeThinU | Eigen::ComputeThinV);
Eigen::Matrix3f R_ts = svd.matrixU() * (svd.matrixV().transpose());
Eigen::Vector3f t_ts = mu_T - R_ts * mu_S;// T = R_ts * S + t_ts
Eigen::Quaternionf q_ts(R_ts);
transform.block<3, 3>(0,
0) *= q_ts.normalized().toRotationMatrix().inverse();//we use left form, so we need .inverse()
transform.block<3, 1>(0, 3) += t_ts;
// We got a new [transform] here, that is, we are more closer to the [source] than last [transform].
// This is why we call it Iterative method.
}
T_ts = transform;//transform = T_ts
// Target = T_t * Source
pcl::transformPointCloud(*cl_s, *cl_final, T_ts);
endT = clock();
time_cost = (double) (endT - startT) / CLOCKS_PER_SEC;
}