38  点云预处理

把一台线激光轮廓仪或结构光相机对准一块金属工件,按下采集键,你拿到的从来不是一张干净利落的三维曲面。原始数据里混着三类毛病:高度值在每个采样点上随机抖动(与二维图像里的噪声同源,只是这次抖的是 Z 而不是灰度);表面上散布着一片片”黑洞”——激光打到陡壁、深孔或镜面反光处无法回采,这些采样点拿不到任何高度,成了无效像素(invalid pixel);偶尔还冒出几个孤零零的”尖刺”,比真实表面高出几百微米,那是二次反射或异常匹配留下的伪影。与此同时,一帧距离图(range image)动辄几十万、上百万个点,直接拿去做配准或匹配,计算量会瞬间爆掉。带噪、有洞、过密——这就是下游测量与检测必须面对的原料。点云预处理(point cloud preprocessing)正是测量之前的第一道工序,它要做的事和 Part III 的二维图像增强一一对应:去噪、补缺、降采样、分割,只不过战场从灰度图搬到了三维表面。本章可以看作二维增强在 3D 上的对应篇。

图 38.1 是本章贯穿始终的合成场景:一块 320×240=76800 像素的距离图,基面高度 1000μm 并沿 x 方向带一点缓坡,上面有一个 +400μm 的凸台、一道 −300μm 的凹槽;我们给它叠加了 σ=5μm 的高度高斯噪声、5% 的失效像素(共 3858 个无效点,在图中呈黑斑),再撒上 245 个尖刺(图中呈白斑)。这一幅图同时集齐了三类典型缺陷,后面每一节都从它出发。

图 38.1: 原始合成距离图(2.5D 高度图)。亮度编码高度:左上基面较暗、凸台(中部矩形)较亮、凹槽(右侧竖条)最暗;散布的黑斑是 5% 失效像素,白斑是 245 个高出真实表面的尖刺。

38.1 距离图 vs 点云:在哪做

三维数据有两种存放方式,预处理可以在任意一种上进行,但成本与可复用性大不相同。

第一种是距离图,也叫深度图或 2.5D 高度图:它本质上是一张规则网格上的图像,每个像素存一个高度值(Z),横纵坐标由像素位置隐含给出(章节 37)。它的最大好处是”规则”——Part III 里所有基于像素邻域的二维算法,中值滤波、高斯滤波、形态学、阈值分割,几乎可以原封不动地搬过来,把”灰度”换成”高度”即可。第二种是无序点云(point cloud):一堆离散的 \((X,Y,Z)\) 三元组,不依附任何网格,能忠实表达任意拓扑(包括距离图无法表达的悬垂、侧壁、多层结构),但代价是邻域查询要靠 kd 树,每一步都比规则网格慢一截。

经验法则:单视角、单层表面(绝大多数在线检测都是这种)优先用距离图处理,能直接复用成熟的二维算法、速度快、内存省;只有当物体有悬垂、需要多视角拼合、或拓扑复杂到网格无法表达时,才进入无序点云的世界(章节 39)。

正因为如此,多数 SDK 的 3D 预处理操作都在距离图上做,本章的主战场也设在这里。但有一个二维世界不存在的陷阱必须先讲清楚:失效像素不能当成 0 参与运算。在距离图里,无效点常用计数 0(或某个哨兵值)标记,它的含义是”这里没有数据”,而不是”这里高度为 0”。如果滤波器把它当成一个高度值 0 拿去平均,那么一个洞旁边的真实高度就会被这个虚假的 0 狠狠拉低,去噪反倒制造了一道假沟。因此 3D 滤波、形态学的每一步都必须是 NaN 感知(NaN-aware) 的:只统计有效邻点,无效点既不贡献也不被污染。本章所有手写算子都遵守这一条,下面会反复看到它的作用。

38.2 3D 滤波

去噪是预处理的第一步,思路与 章节 6 完全相通:在高度图上对每个点取一个邻域,做一次恰当的”平均”,把随机抖动压下去。我们让两种经典滤波器在距离图上同台竞技——3×3 中值滤波(median filter) 与 3×3 高斯滤波(Gaussian filter),都做成 NaN 感知版本:中心无效则保持无效,中心有效则只对邻域内的有效高度排序取中位数(中值)或加权平均(高斯)。

衡量去噪效果,我们看平坦基面子区相对真值平面的高度均方根(RMS,单位 μm)。结果如下:原始数据 RMS 高达 32.09μm——注意这个数字远大于 5μm 的噪声标准差,因为它被 245 个尖刺严重污染了。中值滤波把 RMS 压到 2.28μm,高斯滤波只压到 12.08μm,而先中值再高斯(中值除尖刺、高斯平随机噪声)做到 1.61μm,是全场最优。

差距的根子在尖刺。章节 6 讲过的那条教训——中值除离群、高斯抹离群——在 3D 这里原样重现。统计尖刺残留(有效点中 |测得高度−真值| > 200μm):原始 245 个,中值滤波后只剩 5 个,而高斯滤波后还有 14 个。中值滤波把每个尖刺当成局部离群值,排序后挤到队尾、轮不到当输出,于是被干净剔除;高斯滤波却把尖刺那几百微米的高度摊给周围邻居,结果不是消掉尖刺,而是把它抹成了一个矮一圈、胖一圈的”鼓包”——尖刺的能量没消失,只是被涂开了。图 38.2 把三者并排放在一起,肉眼就能看出原始图上的白色尖刺在中值结果里彻底消失,在高斯结果里却变成了一片片浅色晕斑。

图 38.2: 3D 滤波三联对比:原始(左,白斑为尖刺)|中值(中,尖刺被剔除、边缘锐利)|高斯(右,尖刺被抹成浅色鼓包、凸台边缘略糊)。同一组数据,中值除尖刺、高斯抹尖刺的对立在 3D 高度图上重演。

本节用的是各向同性高斯/中值。若既想压噪又想保住凸台、凹槽的陡壁,可换用保边(edge-preserving)的 3D 滤波——双边滤波(bilateral)或导向滤波(guided filter),它们把”高度相近才参与平均”的判据引入加权,机理与 章节 6 的双边滤波一脉相承。

因此最终输出取中值+高斯的级联(图 38.3):第一道中值清掉尖刺与可能漏网的脉冲,第二道高斯把残余的随机抖动进一步抚平。这一组合既保住了凸台、凹槽的边缘,又把平坦区 RMS 压进 2μm 以内,为后续测量打下干净的底子。

图 38.3: 中值+高斯级联滤波后的距离图:尖刺已清除、随机噪声被抚平,凸台与凹槽边缘保持锐利;失效像素(黑斑)尚未处理,留给下一节的形态学。

38.3 3D 形态学:补洞

滤波处理了”抖”和”尖”,但没碰那 3858 个黑洞——失效像素依旧是洞。补洞是形态学(morphology)的活,思路与 章节 8 的二维形态学一致:用闭运算(closing,先膨胀后腐蚀)填掉小尺寸的空洞。在高度图上,我们对每个无效像素检查其 8 邻域,若有效邻点数达到阈值,就用这些有效邻点的中位数把它填上,迭代数轮——这等价于让有效区向洞内”长”几圈,把孤立的小洞逐渐合拢,同时不去触碰大面积的连续缺失。

实验结果干脆利落:无效像素从 3858 个降到 1 个——闭运算填上了 3857 个小空洞(图 38.4)。5% 的失效像素大多是 1~3 个像素的散点孔洞,闭运算几轮就能把它们全部合拢。

图 38.4: 形态学闭运算补洞后的距离图:原本散布的黑色失效像素(3858 个)几乎全部被有效邻点的中位数填上(剩 1 个),表面恢复连续。

为什么是”闭”而不是”开”?闭运算填的是前景(有效区)内部的暗洞,正对应我们要补的失效像素;开运算则会抹掉小的亮点,是另一类用途。形态学算子的选择永远取决于”洞”和”刺”谁是你要消灭的对象。

但补洞这件事必须带着警惕去做。小洞可以填,大洞补出来的是假数据。闭运算填洞用的是周边有效点的插值,它隐含假设”这个洞底下的表面与周围连续”。对几个像素的散点孔,这个假设基本成立,补出来的高度足够可信;可一旦遇到一片连续的大缺失——比如激光被深槽或陡壁整片遮挡留下的盲区——强行用周边高度插值,等于凭空捏造了一块根本没测到的表面。这与 章节 33 里”遮挡处宁缺毋造”的两难是同一道理:缺失本身往往携带信息(这里有个深结构、这里反光异常),把它无脑填平,反而抹掉了真实信号。所以本节的闭运算特意设了”有效邻点数达阈值才填”的约束,让它只补小洞、放过大洞——剩下的那 1 个无效点,正是这种克制的体现。

38.4 3D 采样

去噪、补洞之后,表面干净了,但点还是太多。这一节解决”过密”。体素栅格降采样(voxel grid downsampling) 是点云降采样最常用的手段:把三维空间切成边长固定的小立方体(体素,voxel),落进同一个体素的所有点用一个代表点(通常取质心)替换,于是点的密度被均匀地稀释下来。它的好处是”空间均匀”——无论原始点云在哪里密、哪里疏,输出的点间距都被体素边长统一约束,不会出现局部过密。

取体素边长 0.4mm,本章距离图的有效点从 76799 个降到 4901 个,缩减 15.7 倍图 38.5)。关键是这次缩减几乎不伤结构:平坦基面上密密麻麻的点本就高度相近、落进同一批体素,塌缩成代表点几乎不丢信息;而凸台和凹槽的边缘,因为高度跨越了不同的 Z 体素层,那里的点被保留下来,边缘密度反而相对突出。图 38.5 里能清楚看到:平坦区点稀疏,凹槽和凸台的轮廓线却依旧密实——结构信息被完好地保住了。

图 38.5: 体素栅格降采样结果(体素边长 0.4mm):76799 点降为 4901 点(15.7×)。平坦区点稀疏、结构边缘(凹槽、凸台轮廓)点密集,降采样在大幅减量的同时保住了结构。

降采样不止体素一种。均匀采样按固定步长隔点取,简单但可能漏掉细节;体素采样按空间格子取代表点,密度均匀;曲率自适应采样在平坦区狠抽、在高曲率区(边、角)多留,最省点又最保结构,代价是要先估法向与曲率。三者按”是否需要保细节、是否吃得起预计算”来选。

为什么 3D 一定要降采样?因为下游算法对点数极其敏感。配准(章节 39)里的 ICP 每一轮都要为每个点搜最近邻,百万级点云做 ICP 或特征匹配,单轮就要做百万次 kd 树查询,几十轮下来计算量直接爆炸;模板匹配、表面配准同理。把点数先砍掉一两个数量级,再去做这些重活,往往是把”跑不动”变成”实时”的关键一步。当然降采样也分场景:要定位、要配准,降采样后又快又稳;但要做高精度体积或尺寸测量,则该用全分辨率——这个权衡下一节的工业案例里还会再碰到。

38.5 3D 阈值与分割

预处理的最后一步常常是把”感兴趣的区域”从背景里分出来,交给后续测量。最简单直接的 3D 分割就是高度阈值(height thresholding),思路与 章节 7 的二维灰度阈值如出一辙:定一个高度门槛,高于它的判为目标、低于它的判为背景。本章按”高度 > 1200μm”把凸台从基面里分出来——基面在 1000μm 上下、凸台高出 400μm 到 1400μm 左右,1200μm 这条线落在两者之间,干净利落。

结果近乎完美:分割得到 7197 个像素,而凸台真值(GT)是 7200 个像素误检(FP)0 个、漏检(FN)3 个,交并比 IoU = 0.9996图 38.6)。3 个漏检全在凸台边缘——那里高度正好卡在阈值附近,被滤波轻微平滑后掉到了门槛之下,属于可接受的边界效应。能做到这种精度,前面几节的铺垫功不可没:先滤波除掉了会造成大量误判的尖刺,再补洞填掉了凸台内部本会被判成背景的空洞,阈值分割才能如此干净。

图 38.6: 高度阈值分割结果:按高度 >1200μm 分出的凸台用绿色高亮叠加在灰度高度底图上。分割 7197 像素 vs 真值 7200,FP=0、FN=3、IoU=0.9996。

高度阈值只是 3D 分割的起点。当目标与背景的高度差不明显、或要分出的是共面的多个区域时,就得上更进阶的手段:区域生长(region growing) 按法向或曲率的连续性把相邻点聚成片,平面分割(RANSAC 平面拟合) 则直接从点云里提取出一个个平面基元(呼应 章节 41 的基元拟合)。但对”高出一截的凸台/低下一块的凹槽”这类最常见的工业目标,一条高度阈值往往就够了,简单且可靠。

38.6 SciVision 实现

本章对应的 SciVision 3D 模块在本机实测的可用性差异很大,如实记录如下,供工程取舍。

  • SciSv3DFilterMedian / Gaussian 可用,且是 NaN 感知的(能正确跳过无效像素),与手写实现结果一致;
  • SciSv3DMorphologyClose(闭运算补洞)可用
  • SciSv3DThreshold 在本机惰性——ManualThreshold 返回码 rc=0 但输出为空,分割得自己来;
  • SciSv3DSampling 在距离图上的 Sampling 重载失败(返回 123403001),改走点云的 VoxelSampling跨运行非确定(同一输入三次跑出 4821/4821/0 的漂移),不可靠。

因此本章主线全部采用手写、NaN 感知的确定性实现来产出图与数字,SDK 各 3D 模块则放进隔离子进程当探针,旁证其返回码与可用性(本机 3D 模块偶发 0xC0000005 访问违例,隔离以免拖垮主流程)。下面是两个最关键的手写片段。NaN 感知中值滤波,中心无效则保持无效,否则只对有效邻点取中位数:

// 3x3 NaN 感知中值:压尖刺、保边,无效点不参与也不被污染
for (int dy = -r; dy <= r; ++dy)
  for (int dx = -r; dx <= r; ++dx) {
    double v = h[(yy)*W + xx];
    if (valid(v)) w.push_back(v);   // 只收有效邻点
  }
std::sort(w.begin(), w.end());
out[y*W + x] = w[w.size() / 2];     // 中位数

体素栅格降采样,按 \((X,Y,Z)\) 量化到体素键,每个体素保留一个代表点:

long long ix = floor(X / L), iy = floor(Y / L), iz = floor(Z / L);
long long key = (ix*100003LL + iy)*100003LL + iz;   // 体素键
Vox& g = grid[key];                                 // 落入同体素的点合并
if (g.n == 0) { g.rx = x; g.ry = y; }               // 首点作代表
g.sx += X; g.sy += Y; g.sz += Z; ++g.n;

完整可运行工程位于 code/point_cloud_preprocessing/,读者可改动噪声强度、体素边长、分割阈值自行复现全部图与数字。

工业案例:3D 锡膏检测的空洞与降采样

SPI(锡膏印刷检测)用 3D 相机量每个焊盘上锡膏的体积。锡膏镜面反光使激光无法回采,留下大量失效像素——若直接用带洞的数据积分,洞被当成零高度,体积系统性偏小,导致大量”少锡”误报。正确做法是先用形态学闭运算补上测量空洞,再积分。但补洞有一条铁律:只能修复”测量空洞”,不能填平”工艺空洞”——某处压根没印上锡膏(漏印),那是真实工艺缺陷,绝不能被补洞算法填平;区分两类空洞靠尺寸、位置与周边形貌联合判断。降采样方面:量体积要全分辨率保精度,大板定位焊盘位置时用降采样点云足够快准。教训只有一句:预处理的每一步,都要分清”修复测量缺陷”与”抹掉真实信号”。

38.7 小结

本章的要点可以浓缩为以下几条。

  • 预处理是 2D 增强在 3D 的对应篇。去噪、补洞、降采样、分割四步,分别对应空间滤波、形态学、采样、阈值——把”灰度”换成”高度”,二维的成熟算法大多能直接搬到距离图上复用。
  • 失效像素绝不能当 0 参与运算。3D 的每一步算子都必须 NaN 感知,只统计有效邻点;否则一个洞就会把周围的真实高度拉出一道假沟。
  • 中值除尖刺、高斯抹尖刺。@sec-spatial_filtering 的教训在 3D 重现:中值把尖刺压到只剩 5 个(RMS 32.09→2.28μm),高斯却把尖刺涂成鼓包(残留 14 个);级联中值+高斯做到 1.61μm。
  • 补洞要分清测量缺陷与真实信号。闭运算填小洞(3858→1)很有效,但大面积缺失强行插值补出的是假数据——和遮挡处”宁缺毋造”是同一两难。
  • 降采样是 3D 跑得动的前提。体素栅格把 76799 点降到 4901 点(15.7×)且保住结构边缘;百万点的 ICP/匹配不先降采样就会爆,但高精度测量仍需全分辨率。

关于三维数据滤波、形态学与分割在工业检测中更系统的论述,可进一步参阅 Steger 等人的著作 (Steger, Ulrich, 和 Wiedemann 2018)。本章统计离群剔除(SOR)的”邻域平均距离 \(\mu+t\sigma\)“判据,源出 Rusu 等人为家居场景构建点云物体地图时提出的去噪流程 (Rusu 等 2008);体素栅格降采样与上述各算子的开源参考实现可见点云库(PCL)总览 (Rusu 和 Cousins 2011)