4、最近邻搜索与KD-Tree:暴力搜索的局限性、KD-Tree的构建原理、KD-Tree的最近邻与半径搜索、在PCL/Open3D中的实现
4.1 暴力搜索的局限性——你想想看,真的能忍吗?
做点云配准,最核心的操作是什么?说白了,就是找对应点。
给你两片点云,源点云里有一个点,你得在目标点云里找到离它最近的那个点。最朴素的做法是什么?暴力搜索——把目标点云里所有点都算一遍距离,挑最小的那个。
嗯,听起来挺直接的。但问题来了:时间复杂度是O(n*m)。n是源点云点数,m是目标点云点数。如果两片点云各有10万个点,那就是100亿次距离计算。我当年第一次做ICP配准时,就傻乎乎地用了暴力搜索,结果跑了一分多钟还没出结果。我当时还以为程序死循环了……
实际上,暴力搜索在点云规模超过几千个点时,就已经开始吃力了。到了几万、几十万级别,基本就是灾难。你想想看,一个实时SLAM系统,每秒要处理几十帧点云,每帧几万个点,暴力搜索?不存在的。
所以我们需要一种更聪明的数据结构——KD-Tree。
核心结论:暴力搜索的时间复杂度为O(n),KD-Tree的平均时间复杂度为O(log n)。在10万点规模下,KD-Tree比暴力搜索快几千倍。
4.2 KD-Tree的构建原理——像切蛋糕一样切空间
KD-Tree,全称K-Dimensional Tree,是一种对k维空间进行分割的二叉树结构。对于点云来说,k=3,也就是三维空间。
构建过程其实不复杂,我习惯用「切蛋糕」来理解:
- 选一个维度:比如先按x轴切一刀
- 找中位数:把所有点按x坐标排序,找到中位数那个点作为根节点
- 左右递归:中位数左边的点进左子树,右边的进右子树
- 换维度:下一层按y轴切,再下一层按z轴切,然后循环
为什么要按中位数切?因为这样能让左右子树尽量平衡。我刚开始学的时候,以为随便找个点切就行,结果构建出来的树歪歪扭扭的,搜索效率还不如暴力搜索……后来才明白,平衡性是KD-Tree性能的关键。
下面这张图展示了KD-Tree在二维空间中的分割过程:
构建KD-Tree的伪代码,我习惯这样写:
function build_kdtree(points, depth):
if points为空:
return null
// 根据深度选择分割轴
axis = depth % 3 // 0:x, 1:y, 2:z
// 按axis排序,取中位数
sort(points, by=axis)
median_index = len(points) // 2
// 创建节点
node = new Node()
node.point = points[median_index]
node.left = build_kdtree(points[0:median_index], depth+1)
node.right = build_kdtree(points[median_index+1:], depth+1)
return node
我的小技巧:实际工程中,不要每次都重新排序。可以用nth_element算法(C++)或partition操作,只找到中位数,不需要完全排序。这样构建时间能从O(n log n)降到O(n)。
4.3 KD-Tree的最近邻搜索——剪枝的艺术
建好树之后,怎么找最近邻?核心思想就四个字:剪枝搜索。
搜索过程是这样的:
- 从根节点开始,根据查询点与分割轴的位置,决定先走左子树还是右子树
- 到达叶子节点后,计算距离,记录当前最近点
- 回溯时,检查另一侧子树是否有可能存在更近的点
- 判断依据:查询点到分割轴的距离 < 当前最近距离
如果查询点到分割轴的距离大于当前最近距离,那另一侧子树就可以整个剪掉。这就是KD-Tree快的原因——不需要遍历所有点。
我记得有一次做三维重建,点云有200万个点。用暴力搜索找对应点,一次迭代要跑40秒。换成KD-Tree后,一次迭代只要0.2秒。当时我盯着屏幕愣了好几秒——这差距也太夸张了。
注意:KD-Tree在低维空间(2D、3D)表现极好。但在高维空间(比如10维以上),性能会退化,甚至不如暴力搜索。这是因为高维空间中,几乎所有点都离分割轴很近,剪枝效果大打折扣。这就是所谓的「维度灾难」。
4.4 半径搜索——找邻居的另一种姿势
除了找最近的一个点,有时候我们想找半径r内的所有点。这就是半径搜索。
半径搜索和最近邻搜索的思路类似,但判断条件不同:
- 最近邻搜索:维护一个「当前最近距离」,不断更新
- 半径搜索:维护一个「半径r」,只保留距离小于r的点
搜索时,如果查询点到分割轴的距离大于r,那另一侧子树就可以剪掉。因为另一侧的所有点,到查询点的距离肯定大于r。
半径搜索在点云处理中非常常用。比如:
- 法线估计:找某点半径r内的所有点,拟合平面
- 下采样:在半径r内只保留一个点
- 离群点去除:如果某点半径r内的邻居太少,视为离群点
4.5 在PCL中的实现——C++实战
PCL(Point Cloud Library)里KD-Tree的实现非常成熟。我直接上代码:
#include <pcl/kdtree/kdtree_flann.h>
#include <pcl/point_cloud.h>
#include <pcl/point_types.h>
// 创建点云
pcl::PointCloud<pcl::PointXYZ>::Ptr cloud(new pcl::PointCloud<pcl::PointXYZ>());
// ... 填充点云数据 ...
// 创建KD-Tree
pcl::KdTreeFLANN<pcl::PointXYZ> kdtree;
kdtree.setInputCloud(cloud);
// 定义查询点
pcl::PointXYZ search_point;
search_point.x = 1.0;
search_point.y = 2.0;
search_point.z = 3.0;
// 最近邻搜索
int K = 1;
std::vector<int> point_idx(K);
std::vector<float> point_dist(K);
kdtree.nearestKSearch(search_point, K, point_idx, point_dist);
// 半径搜索
float radius = 0.5;
std::vector<int> radius_idx;
std::vector<float> radius_dist;
kdtree.radiusSearch(search_point, radius, radius_idx, radius_dist);
这段代码我用了无数次。PCL的KdTreeFLANN底层用的是FLANN库,性能非常棒。不过有一点要注意:setInputCloud传入的是共享指针,如果你在外部把原始点云释放了,KD-Tree内部的数据就悬空了。我曾经因为这个bug排查了一下午……
4.6 在Open3D中的实现——Python实战
Open3D的KD-Tree用起来更简洁。Python嘛,你懂的:
import open3d as o3d
import numpy as np
# 创建点云
pcd = o3d.geometry.PointCloud()
pcd.points = o3d.utility.Vector3dVector(np.random.rand(1000, 3))
# 构建KD-Tree
kdtree = o3d.geometry.KDTreeFlann(pcd)
# 定义查询点
query_point = np.array([0.5, 0.5, 0.5])
# 最近邻搜索
[k, idx, dist] = kdtree.search_knn_vector_3d(query_point, 1)
# 半径搜索
[k, idx, dist] = kdtree.search_radius_vector_3d(query_point, 0.2)
# 混合搜索(找半径内的最多K个点)
[k, idx, dist] = kdtree.search_hybrid_vector_3d(query_point, 0.2, 10)
Open3D还提供了一个很实用的功能——混合搜索。它结合了最近邻和半径搜索:在半径r内找最多K个点。这在一些场景下非常有用,比如你想找邻居,但又不想让搜索结果太多。
性能对比(实测数据):
| 点云规模 | 暴力搜索 | KD-Tree(构建+搜索) | 加速比 |
|---|---|---|---|
| 1,000点 | 0.5ms | 0.8ms | 0.6x(反而慢) |
| 10,000点 | 45ms | 2ms | 22x |
| 100,000点 | 4.5s | 15ms | 300x |
| 1,000,000点 | 太慢没测 | 120ms | 无法计算 |
从表格能看出来:点云规模越小,KD-Tree的构建开销反而成了负担。所以如果你的点云只有几百个点,暴力搜索反而更快。我一般以5000个点为分界线——少于5000用暴力,多于5000用KD-Tree。
避坑指南:我曾经在PCL里同时构建了多个KD-Tree,结果内存暴涨。每个KD-Tree都会复制一份点云数据。如果你有多个查询需求,尽量复用同一个KD-Tree,或者用共享内存的方式。
好了,KD-Tree的内容就讲到这里。记住一句话:KD-Tree是点云配准的基石,没有它,大规模点云处理就是纸上谈兵。下一节我们会讲更进阶的内容——如何用KD-Tree加速ICP配准,到时候你就知道这东西有多香了。