简介:基于CUDA与KD-Tree的ICP点云配准与位姿估计实现,是一份面向机器人、自动驾驶、三维重建等实时处理场景的高性能参考工程,适合具备点云基础并希望掌握GPU并行加速的开发者。它完整展示了如何用CUDA构建K-D树加速最近点搜索,并借助Eigen完成矩阵运算,从而优化ICP迭代流程。压缩包共39个文件,包含28个头文件、4个PCD点云数据、3个C++源文件、2个CUDA源文件,以及README和CMake配置,整体约12.63MB。其中.cu文件承载GPU并行逻辑,h/cpp文件覆盖KD-Tree构建、ICP核心算法与矩阵运算,PCD数据可用于直接验证配准效果。工程仅依赖CUDA与Eigen,结构紧凑,便于对照学习;据描述,相比传统PCL库自带ICP算法速度提升约60倍,非常适合用于算法对比、二次开发或实时性要求较高的项目。已有86人学习下载,是了解点云加速配准与位姿估计的实用示例。
1. 用 CUDA-KDTree 实现 ICP:点云配准慢在哪,绝大多数人都猜错了
做过激光点云配准的人都知道,ICP(Iterative Closest Point,迭代最近点)这个“算法”本身并不复杂:找对应、求变换、迭代到收敛,三步循环而已。真正让点云配准从“秒级”变成“分钟级”的,从来不是最后那个 SVD 求解旋转矩阵,而是每一轮迭代里都要做的那次最近邻搜索。十万点对十万点做暴力最近邻,一轮就是十亿次距离计算,CPU 上跑几十轮迭代,慢得让人怀疑人生。 KD-Tree 能把单次最近邻查询从 O(N) 拉到 O(logN),但它只能缓解单点的搜索开销,解决不了“十万个点都要查一遍”的并发压力。CUDA-KDTree 的路线就是把 KD-Tree 建好后放在显存里,让几千个线程同时去做最近邻查询,把 ICP 每一轮迭代里最贵的那一段从几百毫秒压到几毫秒。这套方案适合谁?适合手里有十万级以上点云、要做批量配准或实时建图的工程师;也适合被 PCL 自带 ICP 的耗时逼到想换路线的研究者。如果你的点云只有几千个点,别折腾 CUDA,CPU 版足够。
2. 把原理拆到能动手的程度:ICP 迭代、KD-Tree 结构与 GPU 并行的边界
2.1 ICP 每次迭代在算什么:两步最小化与 SVD 的角色
ICP 的核心思路非常直白:有源点云 S 和目标点云 T,源点云经过一个刚体变换(旋转 R、平移 t)后,要让两片点云尽可能重合。但“尽可能重合”的数学定义要先立住——我们最小化的是对应点对欧氏距离的平方和:
E(R, t) = Σ || R·s_i + t - t_i ||²其中 s_i 是源点云上的点,t_i 是它对应的目标点云上的最近邻点。每一步迭代做两件事:先按当前变换矩阵把源点云变换过去,然后为每个源点找目标点云里的最近邻点,组成对应点对;再基于这些对应点对求解最优 R 和 t,更新变换矩阵。这两步交替执行,E 会单调下降,直到变化量小于阈值或达到最大迭代次数。
求解 R 和 t 这一步,工程上最常用的是 SVD 方法:先算两组点的质心,把坐标去中心化,再构造协方差矩阵 H,对 H 做奇异值分解,U 和 V 的乘积直接给出旋转矩阵 R = U·Vᵀ,平移量 t 根据质心差反推。SVD 本身计算量很小,一个 3×3 矩阵的分解在 CPU 上也就是微秒级别,这不是瓶颈。
真正贵的是第一步的对应点搜索。源点云有十万个点,每一轮都要做十万次最近邻查询。如果使用暴力遍历,每次查询对十万个目标点算距离,一轮就是百亿次浮点运算。KD-Tree 的价值在于把单次查询的复杂度降下来,但十万次查询的“并发量”还在,这才是 CUDA 介入的理由。
2.2 KD-Tree 只加速 ICP 中“找对应”那一段
KD-Tree 是一种二叉树,每一个内部节点选定一个坐标轴(x、y、z 之一),按该轴的中位数把点集切分成左右两半,递归建树,直到叶子节点里的点数小于某个阈值。查询一个点的最近邻时,沿树向下走到叶子,回溯时判断另一侧的分割平面到查询点的距离是否比当前最优距离更小,如果更小就进去查,否则剪枝。
这里要记住一个关键认知:KD-Tree 在 ICP 里只服务于“找对应”这一步。ICP 的第二步求解 R、t 跟树没有任何关系;体素下采样、离群点剔除也不依赖树。所以整个加速路径应该这样组织:对目标点云建一次树,在每一轮迭代里对源点云做批量最近邻查询,查询结果返回对应点索引,交给 SVD 求解。树只建一次,反复用,这决定了建树的成本可以摊薄到很多轮迭代里,值得为它花一点心思。
建树时有两个决策直接影响查询效率。一是切分轴的选择,常见的做法是选三个轴里方差最大的那一个,这样分割平面能把点集分得更均衡,树的深度更小。二是叶子节点大小,叶子太小树太深,回溯开销大;叶子太大,叶子内的暴力搜索又太长。工程上叶子点数取 8~16 之间比较稳,后面参数章节会展开讲。
2.3 GPU 并行化的边界:哪些环节该上 CUDA,哪些不该
不是把整个 ICP 塞进 GPU 就一定快。数据量不大时,CPU 上的 KD-Tree + PCL 已经很好用;但点云规模超过十万,每轮迭代的最近邻搜索耗时占比可以高达 80% 以上,这时候 GPU 的批量并行查询收益非常明确。
需要清醒的一点是:建树不要上 GPU。KD-Tree 的递归构建过程在 GPU 上并行化非常别扭——每层切分依赖上一层的排序结果,递归深度不确定,线程分支严重发散。你用 CUDA 强行并行建树,大概率比 CPU 上跑得还慢。常见做法是 CPU 端一次性把树建好,转成线性化的数组结构,整体拷贝到显存;之后每轮迭代的查询全部在 GPU 上执行。
哪些环节留在 CPU?SVD 求解、迭代收敛判断、变换矩阵更新。这些计算量太小,放 GPU 反而增加同步和拷贝开销。数据搬运也要控制:目标点云和 KD-Tree 只上传一次;源点云如果一直在变,需要每轮更新,但可以留在显存里原地更新,只把查询结果(索引和距离)拷贝回 CPU。这样每轮迭代的 PCIe 传输量只有几 MB,不会成为瓶颈。
3. 用 CUDA-KDTree 跑通 ICP 的最小工程:从内存布局到两个核心核函数
3.1 内存布局:把数据摆成 GPU 喜欢的样子
GPU 的全局内存访问对“连续”非常敏感。点云数据如果用struct Point { float x, y, z; }这种 AoS(Array of Struct)方式存储,三个线程访问相邻点的 x 坐标时,内存地址跳跃很大,访存效率低。工程上第一步就是把点云转成 SoA(Struct of Array)布局:x、y、z 各自一个 float 数组连续排布。
KD-Tree 本身也要线性化。树节点不能用指针,因为 GPU 端不能用 CPU 的地址空间。线性化的思路是把每个节点存成固定大小的结构体,左右子树用数组下标代替指针,整棵树放进一个连续数组。
struct KDNode { float split_val; // 切分平面上的数值 int split_dim; // 0=x, 1=y, 2=z int left_child; // 左子节点在节点数组中的下标,-1 表示无 int right_child; // 右子节点下标 int start; // 叶子节点:对应点在索引数组中的起始位置 int end; // 叶子节点:结束位置(左闭右开) };逻辑说明:这个结构体把内部节点和叶子节点统一表示。内部节点的left_child和right_child是数组下标,start和end闲置;叶子节点的left_child和right_child设为 -1,用start和end指向该叶子包含的点。CPU 端建树时按递归顺序把节点推入数组,父节点总是先于子节点入数组,这样查询时从下标 0 开始逐层推进。
参数说明:split_dim记录切分轴,查询时根据这个字段决定用查询点的哪个坐标和split_val比较。left_child和right_child用 int 而不是指针,保证结构体大小固定,GPU 端可以整体cudaMemcpy。
3.2 核函数一:每个源点一个线程做最近邻查询
查询核函数是整个加速的核心。每个线程处理一个源点,沿着线性化的 KD-Tree 从根节点向下走,用一个显式栈记录需要回溯的节点。不用递归,是因为 GPU 上的递归调用深度不可控,栈溢出会让 kernel 直接挂掉。
struct KDSearchResult { float dist2; // 最近邻平方距离 int idx; // 对应点在目标点云中的索引 }; __global__ void nn_kernel( const float* src, // 源点云 xyz 连续排布,大小为 n*3 int n, // 源点数量 const KDNode* tree, // 线性化 KD-Tree 节点数组 const float* ref, // 目标点云 xyz 连续排布 const int* leaf_index, // 叶子内点索引表 KDSearchResult* out) // 输出:每个源点一个结果 { int i = blockIdx.x * blockDim.x + threadIdx.x; if (i >= n) return; float qx = src[i * 3]; float qy = src[i * 3 + 1]; float qz = src[i * 3 + 2]; int stack[64]; // 显式栈,最大深度 64 int top = 0; stack[top++] = 0; // 根节点下标 float best_d = 1e30f; int best_idx = -1; while (top > 0) { int node = stack[--top]; const KDNode& nd = tree[node]; if (nd.left_child < 0 && nd.right_child < 0) { // 叶子节点:暴力遍历该叶子内的点 for (int k = nd.start; k < nd.end; ++k) { int pi = leaf_index[k]; float dx = ref[pi * 3] - qx; float dy = ref[pi * 3 + 1] - qy; float dz = ref[pi * 3 + 2] - qz; float d2 = dx * dx + dy * dy + dz * dz; if (d2 < best_d) { best_d = d2; best_idx = pi; } } } else { // 内部节点:先走近的一侧,远的一侧按剪枝条件决定是否入栈 float axis_val = (nd.split_dim == 0) ? qx : (nd.split_dim == 1) ? qy : qz; int near = (axis_val < nd.split_val) ? nd.left_child : nd.right_child; int far = (axis_val < nd.split_val) ? nd.right_child : nd.left_child; stack[top++] = near; float delta = axis_val - nd.split_val; if (delta * delta < best_d) { stack[top++] = far; // 分割面距离小于当前最优,才需要回溯 } } } out[i].dist2 = best_d; out[i].idx = best_idx; }逻辑说明:每个线程从全局索引i拿到自己的源点坐标,沿树向下走。stack数组是显式的栈,用来替代函数递归调用。走到叶子时遍历该叶子的点索引,更新最优距离。走到内部节点时先压入更近的一侧,然后判断分割平面到查询点的距离:如果delta²已经大于当前最优距离,说明远的一侧不可能有更近的点,直接剪枝。
参数说明:栈大小 64 对应树的深度上限。建树时如果叶子大小设 8~16,十万点规模的树深约 15~20 层,64 足够。如果你把叶子大小设成 1,树深可能到 25 层以上,栈仍然够,但超过 64 就会越界,要同步加大栈并加上if (top >= 64) break;的保险。
3.3 查询结果的规约:GPU 算索引,CPU 算 SVD
上面这个核函数返回的是一组对应点索引。下一步是把源点src[i]和它对应的目标点ref[out[i].idx]组成点对,做 SVD 求解。这里有个容易犯的错误:有人会把所有点对坐标传回 CPU 再算 SVD,多传了一遍数据。更省的做法是只把out数组(每个元素 8 字节)拷回 CPU,CPU 端直接用源点云原始坐标和索引取出对应点。
// 主循环伪代码(C++/CUDA 混合) for (int iter = 0; iter < max_iter; ++iter) { // 1. 用当前变换矩阵更新源点云坐标(可以再做一个小 kernel,也可以留到 CPU 端) applyTransform<<<grid, block>>>(d_src, n, R, t); // 2. GPU 批量最近邻查询 nn_kernel<<<grid, block>>>(d_src, n, d_tree, d_ref, d_leaf_idx, d_out); // 3. 只拷回查询结果 cudaMemcpy(h_out, d_out, n * sizeof(KDSearchResult), cudaMemcpyDeviceToHost); // 4. CPU 端用对应点对做 SVD,求解 R, t solveSVD(h_out, R, t); // 5. 判断收敛,delta 小于阈值则跳出 if (delta < eps) break; }逻辑说明:applyTransform核函数把当前的源点云按 R 和 t 变换。这里要注意,源点云的原始坐标要保存备份,因为每轮迭代的输入是“原始坐标 + 累计变换”,而不是“上一轮变换后的坐标”,否则误差会随迭代累积。SVD 求解在 CPU 端做,因为这一步耗时占比极小,没必要增加一次 kernel 启动和同步开销。
参数说明:max_iter控制迭代上限,经验值 50~100。eps是收敛阈值,float32 精度下建议设 1e-6 而不是更小,后面避坑章节会解释原因。
3.4 什么时候该用双流优化:把 H2D 和 kernel 执行重叠起来
很多第一次做 CUDA ICP 的人会忽略高性能计算的经典做法:用 CUDA Stream 把内存拷贝和 kernel 执行重叠。上面主循环里,applyTransform需要把更新后的源点云写到显存,cudaMemcpy回拷结果又需要等 kernel 跑完,整体是串行的。如果你的场景是连续配准多帧点云,可以用两个 stream 交错执行:stream A 处理第 i 帧的查询,stream B 同时回拷第 i-1 帧的结果并做 SVD。这样 PCIe 传输的时间被计算掩盖掉,整体吞吐能再提升 20%~30%。
4. 六个必调参数与一份数据预处理顺序:优先设置什么,翻车时检查什么
4.1 叶子节点大小:直接决定查询精度和速度的平衡
KD-Tree 的叶子大小是第一个要调的参数。叶子越大,树越矮,查询时回溯的路径短,但叶子内的暴力搜索长度变长;叶子越小,树越深,剪枝效果越好,但回溯开销增大。实测经验:点数在十万级时,叶子大小取 8~16 表现最均衡。叶子设成 1 会让树深暴涨,栈溢出风险升高,查询也未必更快;叶子设成 64 以上时,树的剪枝能力明显下降。
| 叶子大小 | 树深(十万点) | 单次查询耗时趋势 | 适用场景 |
|---|---|---|---|
| 1 | ~25 | 平均低,但尾部延迟高 | 点数小于一万,深度可控 |
| 8 | ~18 | 均衡 | 十万级点云首选起点 |
| 16 | ~16 | 均衡,建树更快 | 十万到百万级 |
| 64 | ~13 | 叶子内遍历变长 | 点云分布极不均匀时 |
调参时不要只盯着平均查询时间。用cudaEvents计时,分别统计建树耗时、单轮查询耗时、全流程耗时;如果单轮查询差别不大,优先选大一点的叶子,建树时间和显存占用都更低。
4.2 切分轴策略:方差最大轴与轮转轴的取舍
建树时切分轴有两个选择:按固定顺序轮转(x→y→z),或按方差最大轴。轮转轴实现简单,但遇到点云分布极度不均匀(例如一面墙加一条线)时,树会歪斜,查询效率下降。方差最大轴选点在每个维度上分散度最高的方向切分,树的平衡性更好,查询效率更稳定。
代价是建树时多算一次三个维度的方差,CPU 端对十万点算方差大约多花 2~3 毫秒。这个成本摊到几十轮迭代里可以忽略。我的建议是:如果你的点云来自激光雷达这类分布极不均匀的传感器,直接用方差最大轴;如果点云本身接近均匀分布,轮转轴和方差轴差异不大,用轮转轴更省事。
4.3 体素滤波的优先级:不降采样,CUDA 也救不了你
很多人拿到 CUDA-KDTree 方案后,第一件事就是跑代码,结果发现加速不明显。我见过最多的原因是:输入点云根本没有做预处理。一百万个输入点,就算查询从 500ms 压到 50ms,还是要 50ms;如果先用体素滤波把点云降到十万级,查询只要 5ms,加上滤波时间依然快很多。
预处理顺序应该是:先体素下采样,再去离群点,最后建树。体素分辨率设置有几个参考值:室内场景 0.05~0.1m,室外道路 0.2~0.5m,高精度工业场景 0.01~0.03m。分辨率太小,降噪效果差;太大,配准精度下降。这里要先想清楚你的 ICP 是用来做什么的——如果后续还要做精配准,粗配准阶段可以把体素设大一点,精配准阶段用原始分辨率再跑一轮。
4.4 对应点距离阈值 max_dist:离群点的第一道防线
ICP 算法本身很怕离群点。一个错误的对应点对(比如动态车辆上的点)会把 SVD 求解结果拉偏。工程上有两种常见处理:一种是在找对应时设置距离阈值——超过阈值的点对直接扔进垃圾箱,不参与 SVD 求解;另一种是在 SVD 求解后计算残差,剔除残差大于 N 倍标准差的点对,再重新求解一次。
| 处理方式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 查询时设 max_dist | 简单,和 KD-Tree 查询融合 | 阈值难定,太大没效果 | 点云质量较好,离群点少 |
| 残差剔除 | 有效抵抗离群点 | 多一次残差计算和过滤 | 动态场景,离群点多 |
| 两种组合 | 最稳 | 参数多一个 | 实时建模,数据质量不稳定 |
实际使用中我倾向组合:查询时先把 max_dist 设成体素分辨率的 10 倍左右,SVD 后再算一次残差,用 3σ 准则剔除。这个组合能解决大多数动态环境下的配准漂移问题。
4.5 blockDim 与网格大小的设置:别用默认值跑到底
CUDA kernel 的线程块大小对性能影响很大,但很多人直接沿用网上的 256。对于 KD-Tree 查询这种分支密集的内核,blockDim=128 在大多数 GPU 上表现更好,因为它减少了因 warp 间负载不均造成的阻塞。网格大小要正好覆盖输入点数:
int blockDim = 128; int gridDim = (n + blockDim - 1) / blockDim;逻辑说明:gridDim按向上取整计算,避免最后一个块只跑几个线程浪费资源。如果 n 是百万级,gridDim 在几千到一万左右,完全不会触达单个 GPU 的网格上限。
额外建议:给 kernel 开__launch_bounds__(128, 8)限制每线程块最大线程数和每 SM 最小驻留块数,帮助编译器做寄存器分配。KD-Tree 查询的寄存器压力不小,如果不做限制,编译器可能给每个线程分配大量寄存器,导致占用率下降。
4.6 收敛判据与迭代上限:float32 精度下的安全边界
收敛判据不要只看变换矩阵的变化量,还要看对应点对的平均距离变化。推荐写法:连续两轮迭代的均方距离误差变化小于eps且维持两轮才判定收敛,避免出现“单轮下降突然变慢但后续还能收敛”的假阳性。
float32 精度下,eps默认值设 1e-6 是一个常见错误。当点云坐标范围是几十米时,float32 的精度大约只有 1e-5 量级,你设 1e-6 意味着算法根本达不到这个收敛精度,只会白白多跑几十轮迭代。正常设 1e-5 或 1e-4 即可。迭代上限设 50 轮比较合理,多数稳定场景 20~30 轮就收敛了。
5. 避坑:CUDA-KDTree 落地 ICP 时的 6 个真实翻车记录
5.1 递归建树导致建树耗时比查询还长
现象:KD-Tree 的构建逻辑没问题,但十万点建树花了 800ms,而后面的 GPU 查询单轮只要 5ms,整条链路被建树拖垮。
原因:递归建树时频繁使用 vector 的push_back和动态分配,内存分配占用了大量时间。更隐蔽的问题是递归深度过大时栈溢出,程序不报错但建的树是坏的。
解决:建树一次性预留完所有节点内存。KD-Tree 的节点总数等于 2×叶子数-1,可以在建树前预先reserve。切分时用原地排序(std::nth_element)而不是新建数组来拷贝数据。改完之后十万点建树降到 50ms 以内,这个速度才配得上“只建一次”的定位。
5.2 线程栈溢出导致 kernel 静默失败
现象:kernel 在调试模式下打印结果是正确的,release 模式下偶尔出现out[i].idx为 -1,程序不崩溃但配准结果异常。
原因:显式栈stack[64]装不下极端情况下的回溯路径。当点云分布极不均匀时,某些叶子路径上的回溯节点数量会超过 64,数组越界写坏相邻内存,数据被破坏。
解决:不要增加栈大小——更稳的做法是限制树的深度。建树时加一个深度上限(比如 20),超过上限的节点直接当成叶子处理,把剩余点全部收进当前节点。深度限制 20 对十万点规模足够,且栈大小 64 有 3 倍余量。
5.3 相邻线程的分支发散把并行加速吃掉三分之一
现象:理论算下来查询应该快 20 倍,实际只快了 8 倍,profiler 显示 warp 执行效率只有 50%。
原因:KD-Tree 查询的路径高度依赖每个查询点的坐标,同一 warp 内 32 个线程可能走向完全不同的分支,GPU 只能串行执行这些分支。这就是典型的 warp divergence。
解决:两个思路。第一个是在把源点云传给 GPU 前按空间位置做 Morton 编码排序,让空间上相邻的点在内存中也是相邻的,warp 内的查询路径重合度大幅提升。第二个是核函数内手动做分支合并,把“近侧”“远侧”的判断统一成数据选择操作而不是if-else两条独立路径。我实测 Morton 排序能带来 30%~60% 的提升,值得在预处理阶段加进去。
5.4 float32 累积误差让收敛判据永远达不到
现象:算法在 CPU 上用 double 能跑到 1e-8 的精度,搬到 GPU 后迭代到 30 轮左右误差下降停滞,甚至轻微反弹。
原因:float32 的尾数精度只有 23 位。当源点云坐标在几十米范围时,单点坐标的表示误差就有 1e-5 量级;迭代过程中变换矩阵持续累积,误差随之放大。收敛阈值设 1e-6 时,float32 根本没法收敛到这个水平。
解决:阈值调到 1e-4 或 1e-5;如果确实需要更高精度,把坐标数据减去点云质心(把坐标范围压缩到原点附近),或者用 float2 把坐标拆成高位和低位两部分存储。工程上绝大多数配准场景 1e-4 已经足够,没必要为极端精度支付双倍显存和计算时间。
5.5 每轮迭代都全量拷贝点云,PCIe 带宽成为新瓶颈
现象:GPU 查询只用了 2ms,但整条链路耗时 50ms,profiler 显示大部分时间在cudaMemcpy。
原因:有人在每轮迭代时把源点云从 CPU 拷贝到 GPU,把对应点坐标从 GPU 拷贝回 CPU,再把目标点云也来回拷。十万点一轮的传输量几十 MB,PCIe 3.0 的带宽就这样被吃掉了。
解决:目标点云、KD-Tree 一次性上传后留在显存;源点云在显存里原地做变换更新;每轮只需要回拷KDSearchResult数组(每元素 8 字节,十万点共 0.8MB)。SVD 要用到的对应点坐标,通过索引在 CPU 端从原始数据里取,不要把它们拷回 CPU。
5.6 CUDA 多版本并存导致编译时用错头文件和库
现象:编译报奇怪的错误,比如in file included from .../cuda_runtime.h提示找不到某个内部头文件,或者链接时提示 CUDA 库版本不匹配。
原因:机器上装了多个 CUDA Toolkit,nvcc和系统里的cuda软链接指向不一致,编译器的头文件路径和链接器的库路径各找了一版。
解决:编译命令里显式指定 CUDA 路径,不用系统的软链接。例如本机装了 CUDA 11.8 和 12.x,编译时写-I/usr/local/cuda-11.8/include -L/usr/local/cuda-11.8/lib64,并在LD_LIBRARY_PATH里也指向同一个版本。另一个容易忽略的点:cudaEventCreate这类 runtime API 的计时结果不受影响,但如果有多个进程同时加载不同版本的 CUDA runtime,会出现诡异的内存错误。
6. 用仿真数据做回归验证:先证明耗时下降,再谈精度提升
做技术验证别一上来就拿着真实点云跑。真实数据有噪声、有遮挡、有动态物体,出了问题你根本分不清是算法逻辑错了还是数据本身的问题。我的习惯是先构造已知真值的仿真数据,把功能电路打通后,再换真实数据测鲁棒性。
仿真数据构造方法:用随机数生成一片分布均匀的点云作为目标点云 T,随机生成一个旋转矩阵 R 和平移向量 t,把 T 变换得到带真值变换的源点云 S,再给 S 加上少量高斯噪声(标准差取体素分辨率的十分之一)。这样我们既知道真实变换,又能控制噪声水平。验证脚本按下面几步执行:
# 仿真验证思路(Python 伪代码,用于回归测试) # 1. 生成目标点云 T:在 [-25, 25] 范围内随机生成 200k 个点 # 2. 随机生成真值变换 R_true, t_true,把 T 变换成 S # 3. 给 S 添加高斯噪声,模拟传感器误差 # 4. 对 T 建 KD-Tree,进入 ICP 主循环 # 5. 每一轮迭代结束后,用当前 R, t 与真值做差: # rot_err = arccos((trace(R.T @ R_true) - 1) / 2) # trans_err = ||t - t_true||回归测试要关注的三个指标:旋转误差(度)、平移误差(米)、以及每轮迭代的耗时。旋转误差用轴角方式计算最直观,平移误差用欧氏距离即可。你会在仿真数据上看到非常干净的收敛曲线——第 1 轮误差巨大,前 5 轮急速下降,后面缓慢逼近噪声极限。如果这个曲线出现异常波动,比如中间某轮突然跳变,那几乎一定是对应点匹配出了问题,回到第 5 章的避坑清单去查。
耗时测试要对比三条基线:暴力最近邻 ICP、CPU 上的 KD-Tree ICP、CUDA-KDTree ICP。每组跑 10 次取中位数,不要用平均值——前两者偶尔会触发系统调度抖动,中位数更稳定。二十万点对二十万点、迭代 30 轮的情况下,暴力法可能已经跑到分钟级了,CPU 版 KD-Tree 在 3~5 秒,CUDA 版应该打进 200ms 以内。如果你的 CUDA 版没有达到这个量级的差距,先看是不是 warp divergence 太严重,再看是否数据传输没有按第 4 章的方式优化。
最后一件事:每次都把配准结果可视化保存下来。我见过有人测了二十组数据全说“精度非常好”,结果可视化时发现点云根本没对齐——原因是收敛判据写错了,程序在第一轮迭代后就把误差变化率当成收敛信号提前退出。我的习惯是加一个断言:配准前后点云重叠率低于 80% 就输出 warning。这个习惯帮我抓过好几次隐蔽 bug,希望帮到你。
本文还有配套的精品资源,点击获取