1. 点云均匀降采样到底在解决什么问题
做过三维重建或者激光雷达数据处理的朋友,大概率都遇到过这样的场景:一台32线或64线的激光雷达转一圈,几十万个点就进来了,一帧数据动辄几十兆。如果直接拿这些原始点云去做配准、分割、曲面重建,计算量大到让人崩溃,而且点与点之间挨得太近,很多算法反而会因为局部密度过高而出现数值不稳定。这时候就需要一个操作——降采样。
点云均匀降采样,英文通常叫 Uniform Sampling 或者 Voxel Grid Downsampling,它的核心目标很朴素:在尽量保留原始几何形状的前提下,把点的数量减下来。你可以把它理解成给一张超高分辨率的照片做“智能缩略图”——不是简单粗暴地每隔几个点扔一个,而是按照空间网格重新组织,让留下来的点在空间上分布得更均匀。
这个内容适合谁看?如果你正在用 PCL(Point Cloud Library)做点云预处理,或者你刚接触三维视觉、SLAM、自动驾驶感知这些方向,那这篇内容基本就是给你写的。我会从原理讲到代码,从参数选择讲到踩坑经验,尽量让你看完就能直接上手改自己的项目。
PCL 里做降采样的类叫pcl::VoxelGrid,别看名字里带“Voxel”(体素),它做的事情其实就是把三维空间切成一堆小立方体格子,每个格子里如果有多个点,就把它们合并成一个点。这个“合并”不是随便取一个,而是取格子内所有点的质心(重心)。这样一来,降采样后的点云既减少了数量,又不会偏离原始形状太多。
为什么叫“均匀”降采样?因为体素格子的大小是固定的,不管原始点云哪里密哪里疏,降采样后每个格子最多只出一个点,整体密度就被“拉平”了。这一点和随机降采样有本质区别——随机降采样只是按概率丢弃点,密度分布还是不均匀的。
2. 体素栅格降采样的核心原理拆解
2.1 从三维网格到质心计算
体素栅格降采样的逻辑其实不复杂,我拆成几步来说。
第一步,确定整个点云的包围盒(Bounding Box),也就是找到 X、Y、Z 三个方向上的最小值和最大值。这个包围盒就是一个能把所有点装进去的长方体。
第二步,用你设定的体素边长leaf_size去切这个长方体。比如 X 方向范围是 0 到 10 米,leaf_size 设为 0.1 米,那 X 方向就被切成 100 个格子。Y、Z 同理。最终整个空间被切成nx × ny × nz个小立方体。
第三步,遍历所有原始点,根据坐标算出每个点落在哪个格子里,把它塞进去。
第四步,对每个非空格子,计算里面所有点的坐标平均值,得到一个质心点。这个质心点就是降采样后输出的点。
第五步,把所有格子的质心点收集起来,组成新的点云。
整个过程的时间复杂度是 O(N),N 是原始点数,所以效率很高。PCL 内部用哈希表来管理格子索引,避免真的去分配一个巨大的三维数组,这一点在内存上很友好。
2.2 为什么用质心而不是随机取点
这里有个细节值得说一下。很多人第一次接触的时候会想:每个格子随便留一个点不就行了吗,为什么要算质心?
原因在于质心能更好地保持局部几何精度。假设一个格子里有 5 个点,它们分布在格子的一侧,如果你随机取一个,可能取到边缘的点,导致降采样后的点偏离真实表面。而质心是这 5 个点的平均位置,它更接近这组点的“中心趋势”,对噪声也有一定的平滑作用。
当然,质心也有它的局限。如果格子里恰好跨越了一个尖锐边缘(比如桌子的棱角),质心可能会把边缘“磨圆”。这是体素降采样的固有特性,后面讲参数选择的时候我会说怎么缓解。
2.3 体素大小对结果的影响规律
leaf_size是唯一的核心参数,但它对结果的影响非常大。我总结了一个规律表,方便你快速判断:
| leaf_size 相对原始点间距 | 降采样效果 | 适用场景 |
|---|---|---|
| 小于点间距 | 几乎不减少点数,甚至可能因为质心偏移产生伪影 | 不建议使用 |
| 约等于点间距 | 轻微降采样,保留绝大部分细节 | 精细配准前的预处理 |
| 2~5 倍点间距 | 明显降采样,形状保持良好 | 大多数通用场景 |
| 10 倍以上点间距 | 大幅降采样,细节丢失严重 | 快速可视化、粗配准 |
实际项目中,我一般会先估算原始点云的平均点间距。PCL 里有pcl::computePairwiseDistances或者更简单的getMatrixXfMap配合统计,但最土的办法是取一小块区域,量一下相邻点的距离。有了这个基准,leaf_size 就好定了。
3. PCL 代码实操:从读取到保存的完整流程
3.1 最小可运行代码框架
先上一段可以直接编译运行的代码,后面再逐段拆解。
#include <pcl/point_types.h> #include <pcl/io/pcd_io.h> #include <pcl/filters/voxel_grid.h> #include <pcl/console/time.h> int main(int argc, char** argv) { // 1. 读取原始点云 pcl::PointCloud<pcl::PointXYZ>::Ptr cloud(new pcl::PointCloud<pcl::PointXYZ>); if (pcl::io::loadPCDFile<pcl::PointXYZ>("input.pcd", *cloud) == -1) { PCL_ERROR("Couldn't read file input.pcd\n"); return -1; } std::cout << "原始点云点数: " << cloud->size() << std::endl; // 2. 创建滤波对象 pcl::VoxelGrid<pcl::PointXYZ> sor; sor.setInputCloud(cloud); // 3. 设置体素大小,单位与点云坐标一致 sor.setLeafSize(0.01f, 0.01f, 0.01f); // 4. 执行降采样 pcl::PointCloud<pcl::PointXYZ>::Ptr cloud_filtered(new pcl::PointCloud<pcl::PointXYZ>); sor.filter(*cloud_filtered); std::cout << "降采样后点数: " << cloud_filtered->size() << std::endl; // 5. 保存结果 pcl::io::savePCDFile("output.pcd", *cloud_filtered); return 0; }对应的 CMakeLists.txt:
cmake_minimum_required(VERSION 3.5) project(voxel_grid_demo) find_package(PCL 1.8 REQUIRED) include_directories(${PCL_INCLUDE_DIRS}) link_directories(${PCL_LIBRARY_DIRS}) add_definitions(${PCL_DEFINITIONS}) add_executable(voxel_grid_demo main.cpp) target_link_libraries(voxel_grid_demo ${PCL_LIBRARIES})编译命令就是常规的mkdir build && cd build && cmake .. && make。如果你用的是 PCL 1.10 以上版本,CMake 里可能需要加set(CMAKE_CXX_STANDARD 14)。
3.2 setLeafSize 参数怎么定
setLeafSize接收三个 float,分别对应 X、Y、Z 方向的体素边长。大多数情况下三个值设成一样,但有些特殊场景可以差异化设置。
比如你处理的是地面激光扫描数据,Z 方向精度本来就低,可以把 Z 的 leaf_size 设大一点,X、Y 保持小值,这样在水平方向保留更多细节。反过来,如果是俯视拍摄的点云,Z 方向信息本来就少,就没必要单独调。
我个人的经验是:先用三个方向相同的值跑一遍,看结果,再决定要不要差异化。因为差异化设置会让体素变成长方体,质心计算虽然还是对的,但空间均匀性会被打破,后续做邻域搜索时可能引入方向性偏差。
还有一个坑:setLeafSize的参数类型是 float,但如果你传入的是 double 字面量,编译器可能警告。建议直接写0.01f这种带 f 后缀的形式。
3.3 降采样前后的点数对比与验证
跑完代码后,控制台会输出两个数字。我拿一组实测数据举例:原始点云 460,000 个点,leaf_size 设为 0.005 米,降采样后剩 38,000 个点左右,压缩比大约 12:1。这个比例不是固定的,取决于原始点云的密度和 leaf_size 的关系。
验证降采样效果好不好,不能只看点数。我通常会用 CloudCompare 或者 PCL 的可视化模块把原始点云和降采样后的点云叠在一起看。如果降采样后的点基本覆盖了原始形状的轮廓,没有明显的空洞或者偏移,那就说明参数合适。
还有一个量化指标:计算降采样后点云到原始点云的平均最近邻距离。如果这个距离远小于 leaf_size,说明形状保持得好;如果接近甚至超过 leaf_size,说明有些区域被过度合并了。
4. 参数调优与场景化配置经验
4.1 不同点云密度下的 leaf_size 选择
点云密度差异很大,室内结构光扫描可能每平方米几万个点,室外激光雷达每平方米只有几百个点。leaf_size 不能拍脑袋定,得根据密度来。
我一般用这个流程:先用 PCL 的pcl::search::KdTree对原始点云做一次 K 近邻搜索,取 K=2,算出每个点到最近邻的距离,然后求平均值。这个平均值就是平均点间距d_avg。然后 leaf_size 取2 × d_avg到5 × d_avg之间。
举个例子,某室内扫描点云的平均点间距是 2 毫米,那 leaf_size 可以取 4 到 10 毫米。如果取 4 毫米,降采样后点数大约是原来的 1/4 到 1/9;如果取 10 毫米,可能只剩 1/25。具体取哪个,看你对细节的要求。
注意:如果点云密度极不均匀(比如近处密远处疏),单一 leaf_size 可能在某些区域降采样过度,在另一些区域几乎没效果。这时候可以考虑分块处理,或者改用
pcl::ApproximateVoxelGrid,它用近似方法,速度更快但精度略低。
4.2 降采样在配准流程中的位置
在点云配准(Registration)流程里,降采样通常放在特征计算之前。因为配准算法如 ICP 或者 NDT,计算量跟点数直接相关。先降采样再配准,速度能提升一个数量级,而精度损失往往很小。
但这里有个顺序问题:如果你先降采样再计算法线,法线估计的邻域搜索会基于降采样后的点,可能不够准确。我的做法是:先对原始点云估计法线,再降采样时把法线一起带过去。PCL 的VoxelGrid支持PointNormal类型,它会自动对法线做平均并重新归一化。
代码上就是把pcl::PointXYZ换成pcl::PointNormal,其他不变。但要注意,法线平均后可能不再是单位向量,PCL 内部会处理,但如果你自己后续要用法线,最好再归一化一次。
4.3 降采样与离群点去除的配合
体素降采样对离群点有一定的抑制作用,因为离群点如果单独落在一个格子里,它的质心就是它自己,不会被消除。但如果离群点附近有正常点,质心会被拉偏。
所以我的建议是:先做统计离群点去除(Statistical Outlier Removal),再做体素降采样。顺序反过来也不是不行,但先去除离群点能让降采样后的质心更干净。
统计离群点去除的参数一般是setMeanK(50)和setStddevMulThresh(1.0),意思是检查每个点的 50 个邻居,如果平均距离超过全局平均距离加 1 倍标准差,就判定为离群点。这个参数对大多数场景都适用,但如果你点云本身很稀疏,MeanK 要相应减小。
5. 常见问题排查与避坑指南
5.1 降采样后点云出现“空洞”怎么办
这是最常见的问题。原因通常是 leaf_size 设得太大,某些区域的点被合并到相邻格子,导致原本有细节的地方变成一片空白。
排查方法:把 leaf_size 减半再跑一次,看空洞是否消失。如果消失,说明就是参数问题。如果还有空洞,那可能是原始点云在那个区域本来就没有点,降采样只是暴露了这个问题。
还有一种可能是点云坐标单位搞错了。比如你以为是米,实际是毫米,leaf_size 设 0.01 就相当于 10 米,那肯定全合并成一个点了。检查方法很简单:打印点云包围盒的尺寸,看看数值是否合理。
5.2 降采样速度慢的优化思路
VoxelGrid本身是 O(N) 的,正常情况下几十万点几毫秒就完事了。如果你觉得慢,先检查是不是在 Debug 模式下编译的。PCL 在 Debug 模式下因为迭代器和边界检查,速度可能慢 10 倍以上。换成 Release 模式编译,立竿见影。
另外,如果你用的是pcl::PointXYZRGB这种带颜色的点类型,降采样时颜色也会被平均。这个计算量比纯 XYZ 大一些,但通常不是瓶颈。
如果确实需要极致速度,可以试试pcl::ApproximateVoxelGrid,它不计算精确质心,而是用格子内第一个点或者中心点代替,速度更快,但精度会下降。
5.3 降采样后法线方向错乱
前面提到过,PointNormal类型的降采样会对法线做平均。但如果一个格子里包含了法线方向相反的点(比如薄壁结构的两面),平均后的法线可能指向奇怪的方向。
解决办法有两个:一是减小 leaf_size,让格子不会跨越薄壁;二是降采样后重新计算法线,而不是依赖平均。重新计算法线的代价是多了几次邻域搜索,但结果更可靠。
我一般对薄壁类物体(比如管道、板材)会直接选择重新计算法线,不省这个步骤。
5.4 常见问题速查表
| 现象 | 可能原因 | 解决方法 |
|---|---|---|
| 点数几乎没减少 | leaf_size 太小 | 增大 leaf_size 到点间距的 2 倍以上 |
| 点云形状明显变形 | leaf_size 太大 | 减小 leaf_size,或分区域设置 |
| 降采样后出现空洞 | leaf_size 过大或单位错误 | 检查包围盒尺寸,减小 leaf_size |
| 程序运行缓慢 | Debug 编译或点类型复杂 | 改用 Release 编译,简化点类型 |
| 法线方向混乱 | 格子跨越薄壁结构 | 减小 leaf_size 或重新计算法线 |
| 内存占用过高 | 点云本身过大 | 先做一次粗降采样再精细处理 |
6. 进阶技巧:自适应与分块降采样
6.1 基于密度的自适应 leaf_size
固定 leaf_size 在密度均匀的点云上表现很好,但遇到密度变化剧烈的场景就力不从心了。比如自动驾驶里,近处路面点密集,远处建筑物点稀疏,用同一个 leaf_size 要么近处降得不够,要么远处降得太狠。
一个实用的技巧是分块自适应:把点云按距离或者按空间划分成若干块,每块单独估计平均点间距,然后设置不同的 leaf_size。PCL 本身不直接提供这个功能,但你可以用pcl::CropBox或者pcl::PassThrough先把点云切开,分别降采样后再合并。
合并的时候要注意,不同块之间的边界可能出现密度突变。如果后续算法对密度敏感,可以在边界处做一次过渡处理,比如用半径滤波平滑一下。
6.2 降采样与八叉树的结合
八叉树(Octree)本身就是一种空间划分结构,它和体素降采样有异曲同工之妙。PCL 的pcl::octree::OctreePointCloud可以在构建八叉树的同时做降采样,而且八叉树的层级结构天然支持多分辨率。
具体做法是设置八叉树的resolution参数,然后调用addPointsFromInputCloud,八叉树会自动把落在同一叶子节点的点合并。你可以通过getLeafCount和getOccupiedVoxelCenters拿到降采样后的点。
这种方式的优势是,你可以在同一棵八叉树上做不同层级的降采样,适合需要多尺度分析的场景。缺点是八叉树的构建比VoxelGrid稍慢,而且内存占用更高。
6.3 降采样后的点云还能恢复吗
严格来说,降采样是不可逆的,因为质心计算丢失了格子内的分布信息。但如果你在降采样前保存了每个格子内的点索引,理论上可以恢复出原始点。PCL 的VoxelGrid不直接提供这个功能,但你可以自己写一个类似的滤波器,在合并时记录索引映射。
实际项目中,我很少需要恢复,因为降采样通常是在预处理阶段,原始数据都会保留备份。但如果你的存储空间紧张,又需要偶尔回溯,那这个索引映射就很有价值。
7. 我在实际项目中的几点体会
体素降采样看起来简单,但真正用好需要结合具体场景反复调参。我做过一个室内重建的项目,原始点云每帧大约 30 万点,leaf_size 从 0.01 试到 0.05,最后定在 0.02。这个值下,降采样后每帧约 2 万点,配准精度损失不到 1 毫米,但速度提升了 8 倍。
另一个坑是点云的颜色信息。如果你用的是PointXYZRGB,降采样时颜色会被平均,这可能导致颜色边界模糊。对于需要颜色分割的任务,我建议先做颜色分割,再对每个分割区域单独降采样,而不是先降采样再分割。
最后分享一个小技巧:如果你不确定 leaf_size 该设多少,可以先用pcl::VoxelGrid的setLeafSize设一个很小的值(比如点间距的 1.5 倍),跑一遍看点数。然后按比例调整,直到点数降到你的目标范围。这个“试错法”虽然笨,但比理论计算更靠谱,因为实际点云的密度分布往往不是均匀的。
这个内容后续还可以这样扩展:结合pcl::UniformSampling(基于球体的均匀采样)做对比,或者把降采样嵌入到完整的 SLAM 前端流程里,看它对位姿估计精度的影响。如果你正在做类似的事情,欢迎一起交流。