第09章 - 点云滤波与噪声处理

9.1 噪声不是一种东西

把“去噪”当一步处理,是点云工程里最常见的错误。实际数据里至少有三类要分开对待:

孤立离群点:空气中、水面上、飞鸟、玻璃反射造成的稀疏点。特征是周围没有邻居。用统计或半径离群点检测。

低点(地下点):多回波穿透、水面反射、多次散射造成的地面以下点。特征是 Z 明显低于同位置的地面。用 ELM 或基于分布裁剪。

密度不均:扫描角、航高、重叠度造成的局部过密或过疏。这不是噪声,但会影响依赖邻域的算法。用体素或随机降采样统一密度。

三类各有专用 stage,参数也完全不同。用错工具的典型后果是:拿统计离群点去处理低点,低点密度够高(成片出现)反而被当成正常点保留;拿 ELM 去处理孤立点,孤立点所在格子里没有第二个点,逻辑根本走不到。

9.2 filters.outlier:两种离群点检测

这是最常用的去噪 stage,两种方法选其一。

[
    "input.las",
    {
        "type": "filters.outlier",
        "method": "statistical",
        "mean_k": 12,
        "multiplier": 2.2
    },
    "denoised.las"
]

9.2.1 statistical:全局均值 + 标准差

算法两遍扫描(文档给了完整公式):

  1. 对每个点 $p_i$,取它的 $k$ 个最近邻($k$ 由 mean_k 指定),计算平均距离 $\mu_i$;
  2. 对全部 $\mu_i$ 求全局均值 $\bar{\mu}$ 与标准差 $\sigma$;
  3. 阈值 $t = \bar{\mu} + m\sigma$,其中 $m$ 是 multiplier;
  4. $\mu_i \ge t$ 的点标为离群点。

关键性质:它是全局判据。同一个点在稀疏区和密集区的判定标准相同,所以密度差异大的数据集(比如城区 + 山区)容易误判——稀疏区整片被判为离群。这也是为什么它必须两遍扫描,不能流式。

选项 默认 说明
method "statistical" 或 "radius"
mean_k 8 统计法:近邻个数
multiplier 2.0 统计法:标准差倍数
radius 1.0 半径法:搜索半径
min_k 2 半径法:最少邻居数
class 7 标记离群点的分类值(LAS 规范里 7 = Low Point / Noise)

默认参数(mean_k=8、multiplier=2.0)对常规机载数据偏保守——只去掉最极端的点。想更激进就去掉更多,但那会开始吃掉真实的稀疏植被点。

9.2.2 radius:绝对密度判据

半径法简单直接:以 radius 为半径统计邻居数,少于 min_k 就判离群。

{
    "type": "filters.outlier",
    "method": "radius",
    "radius": 1.0,
    "min_k": 4
}

这个例子是“半径 1.0 内少于 4 个邻居就算离群”。它的好处是判据物理意义明确,坏处是对点云密度极其敏感:同一份数据抽稀一半,结果完全不同。用之前必须先知道数据的平均点间距,用 pdal info --stats 或密度计算大致估出来。

9.2.3 它只打标签,不删点

这是最容易被误解的一点:filters.outlier 只把离群点的 Classification 设成 7,点本身还在。文档原文是 “both filtering methods simply apply a classification value of 7 to the noise points”。

真要删,得接一个表达式:

[
    "input.laz",
    { "type": "filters.outlier", "method": "statistical" },
    { "type": "filters.expression", "expression": "Classification != 7" },
    "clean.laz"
]

保留而不删通常更好:噪声点留在文件里,配合分类值,下游可以随时决定要不要用。删掉就不可逆了。

9.3 filters.elm:专治低点

ELM(Extended Local Minimum)专门处理地面以下的点,算法按格网走:

  1. 按 cell 大小栅格化;
  2. 每格内最低点算候选,如果它与第二低点的差值超过 threshold,判为噪声;
  3. 继续比较第二低与第三低,直到找到差值在阈值内的邻居为止。
选项 默认 说明
cell 10.0 格网边长,单位与坐标一致
class 7 噪声点的分类值
threshold 1.0 判定为低点的最小高差
[
    "input.laz",
    { "type": "filters.elm", "cell": 20.0, "threshold": 0.5, "class": 7 },
    { "type": "filters.expression", "expression": "Classification != 7" },
    "out.laz"
]

cell 的选择逻辑:太大则一个格子里混进真实地形起伏,阈值判据失效;太小则格子内点太少,第二低点可能是噪声本身。经验上取平均点间距的 5–20 倍。threshold 取决于地形粗糙度与测量噪声,平坦地面 0.3–0.5 米,山区可能要 1–2 米。

ELM 的前提是每个格子内有足够多的点。极稀疏数据(每格 1–2 点)上它几乎无效,这时候应该用 filters.outlier 的半径法。

9.4 filters.iqr 与 filters.mad:按分布裁掉尾部

这两种不是逐点邻域分析,而是对某个维度的整体分布做裁剪。适合“Z 值明显异常”这种一维问题。

9.4.1 IQR:四分位距

{
    "type": "filters.iqr",
    "dimension": "Z",
    "k": 1.5
}

判据:设 Q1、Q3 为 25% 与 75% 分位,IQR = Q3 − Q1,则保留 [Q1 − k·IQR, Q3 + k·IQR] 区间内的点。k 默认 1.5,这是箱线图的经典系数。

文档特意给了警告:这种方法会删掉真实数据——陡峭山脊、深谷、高塔、屋顶都可能落在尾部。它不是“去噪”,是“按统计分布裁剪”,用之前先确认目标。

9.4.2 MAD:中位数绝对偏差

{
    "type": "filters.mad",
    "dimension": "Z",
    "k": 2.0,
    "mad_multiplier": 1.4826
}

MAD 用中位数替代均值,比 IQR/标准差更抗离群。公式里的 mad_multiplier 默认 1.4826,作用是把 MAD 换算成“正态分布下的标准差估计”(因为正态分布下 1.4826·MAD ≈ σ)。

两个参数的分工:k 决定裁多狠(几倍偏差),mad_multiplier 是统计换算常数,一般不动。

什么时候用 MAD 而不是 IQR:数据里已经有大量离群点时。均值/标准差会被离群点拉偏,中位数不会。

9.5 filters.lof:局部离群因子

统计法用全局阈值,LOF 用局部密度对比:一个点的密度相对于邻居的密度有多低。

[
    "input.las",
    { "type": "filters.lof", "minpts": 10 },
    "scored.las"
]

它不删点,而是生成三个新维度(都是 double):

维度 含义
NNDistance 到第 k 近邻的欧氏距离
LocalReachabilityDistance 邻域可达距离的均值倒数
LocalOutlierFactor 局部离群因子,明显大于 1 表示比邻居稀疏

minpts 默认 10,即 k 近邻的个数。

实际用法是先算再按阈值筛:

[
    "input.laz",
    { "type": "filters.lof", "minpts": 10 },
    { "type": "filters.expression", "expression": "LocalOutlierFactor < 2.0" },
    "out.laz"
]

阈值取多少没有通用答案,取决于数据。做法是先把 LocalOutlierFactor 导出来看分布:

pdal translate input.laz /tmp/lof.las -f lof
pdal info --stats --dimensions LocalOutlierFactor /tmp/lof.las

看中位数与分位数,再定阈值。LOF 相对统计法的优势在密度变化剧烈的场景(城市 + 林地):它比的是“局部”,不会因为整片区域稀疏就把整片判成噪声。

9.6 降采样:统一密度而不是去噪

很多“去噪”的需求其实是“点太密了”。这类问题不该用 outlier,该用采样。

9.6.1 filters.voxeldownsize:体素降采样

按体素网格保留代表点:

{
    "type": "filters.voxeldownsize",
    "cell": 1.0,
    "mode": "center"
}
选项 默认 说明
cell 0.001 体素边长(X/Y/Z 三向相同)
mode "center" center:把保留点的坐标改成体素中心;first:只保留进入体素的第一点,坐标不变

mode: "center" 有一个警告必须知道:它改写了保留点的 X/Y/Z。如果其他维度依赖坐标(比如已经算好的 HeightAboveGround、法向量),这些值就不一致了。要么先降采样再算派生量,要么用 first 模式。

默认 cell = 0.001 这个值小到几乎不筛点,实际使用必须显式给值。cell 应当与目标点间距同量级:目标 1 米间距就给 1.0。

9.6.2 filters.decimation:按序号抽样

最简单粗暴的降采样——每 N 个点保留 1 个:

{
    "type": "filters.decimation",
    "step": 10,
    "offset": 0
}
选项 默认 说明
step 1.0 步长;1 表示不跳,2 表示隔一个取一个,1.6 保留 62.5%
offset 0 起始点索引
limit 无 停止索引(不含)

step 允许小数(按比例保留),这点常被忽略。step: 1.6 保留 100/1.6 = 62.5% 的点。

它完全不看空间分布,纯粹按文件里的点序抽样。所以适合“快速减小体积看看效果”,不适合“统一空间密度”——LAS 的点序与空间位置没有必然关系。

9.6.3 filters.sample:泊松盘采样

想要“任意两点间隔不小于 r”的均匀采样,用 filters.sample:

{
    "type": "filters.sample",
    "radius": 0.5
}
选项 默认 说明
radius 无 样本间最小距离
cell 由 radius 推 体素边长;与 radius 二选一
dimension 无 不删点,而是生成 uint8 维度标 1/0
origin_x/y/z 首点坐标 体素化原点

radius 与 cell 只需给一个:给 radius 会自动算出能罩住该球的体素边长,反之亦然。文档说明实现是先体素化,再只与相邻体素内的点比较距离,因此可以流式运行。

比 voxeldownsize 好在不碰坐标,输出点的属性全部保持原样;代价是计算量更大。

9.7 法向量与曲面重建

9.7.1 filters.normal:法向量与曲率

{
    "type": "filters.normal",
    "knn": 8,
    "always_up": true
}

算法对每个点的邻域求协方差矩阵,取特征值最小对应的特征向量作为法向量,并给出曲率:

curvature = λ0 / (λ0 + λ1 + λ2)        // λ 按升序
选项 默认 说明
knn 8 k 近邻个数
radius 无 用半径邻域替代 knn
viewpoint 无 WKT/GeoJSON 3D 点,法向量朝向它
always_up true 只翻转 Z 分量为负的法向量
refine false 用最小生成树传播重新定向

产物是四个维度:NormalX、NormalY、NormalZ、Curvature。

always_up: true 的语义要清楚:它只保证法向量的 Z 分量为正,不做全局一致性。想要所有法向量朝向一致(比如都朝外),要靠 viewpoint 或 refine。

9.7.2 filters.poisson:泊松曲面重建

[
    "input.laz",
    { "type": "filters.poisson", "depth": 9 },
    { "type": "writers.ply", "filename": "surface.ply", "faces": true }
]

它把点云重建成水密曲面,输出的是新点集(隐式等值面上的采样点),通常比输入点数多得多。算法需要法向量:输入里没有 NormalX/Y/Z 时,会先自动跑一遍 filters.normal。

选项 默认 说明
depth 8 八叉树最大深度;结果不满意时优先调它
density 无 输出每个点的邻域密度估计

depth 控制重建精度:越大细节越多,也越慢、越吃内存。8 是默认值,做建筑立面或雕塑这类细节丰富的物体时,9–11 才够。

这条链路要和“栅格化”区分开:泊松产出的仍是点/网格,不是 DEM;做 DEM 用下一章的 writers.gdal。

9.8 filters.stats:先看分布再调参

去噪参数的调法,本质是先知道数据长什么样。filters.stats 是内建的探查工具:

[
    "input.laz",
    {
        "type": "filters.stats",
        "dimensions": "Z,Intensity",
        "enumerate": "Classification",
        "advanced": true
    },
    "writers.null"
]
选项 默认 说明
dimensions 全部 要统计的维度列表
enumerate 无 枚举取值及计数
count 无 同 enumerate,但只给计数
global 无 median/mad/mode/all,默认不算(要额外内存)
advanced false 计算偏度与峰度

注意 global:中位数、MAD、众数默认不计算,因为要保持额外的数据或计数。要拿这些值必须显式请求。

产物是元数据,配合 writers.null 就能既算又不产出文件。元数据本身通过 pdal pipeline --metadata meta.json 导出。

9.9 组合实例:一条可复用的去噪链路

把本章 stage 按正确顺序串起来。这是处理原始机载点云的标准前置链路:

[
    "raw.laz",
    { "type": "filters.assign", "value": "Classification = 0" },
    { "type": "filters.elm", "cell": 20.0, "threshold": 0.5 },
    { "type": "filters.outlier", "method": "statistical", "mean_k": 12, "multiplier": 2.5 },
    { "type": "filters.expression", "expression": "Classification != 7" },
    { "type": "filters.voxeldownsize", "cell": 0.5, "mode": "first" },
    "clean.laz"
]

每一步的理由:

  1. assign 归零:原始数据可能带着上一手处理的分类标签(有的甚至是错的)。归零让后续判据在干净状态上工作。
  2. elm 先做:低点通常成片且密度够高,统计离群点检测不出来,必须先用高程判据抓。
  3. outlier 后做:处理完低点,剩下的孤立噪声才符合“邻居少”的特征。
  4. expression 删点:确认要删才删;如果只是存档,把这一步去掉,保留分类标记。
  5. voxeldownsize 用 first:因为前面没有依赖坐标的派生量,但用 first 更安全——若后续要算法向量,坐标被改写过就得重算。

顺序反过来(先 outlier 后 elm)也能跑,但 elm 会把一部分已被标为 7 的点再判一次,逻辑上是浪费。

9.10 排错

现象 原因 处理
outlier 之后点一个没少 它只打标签为 7,不删点 补 filters.expression: Classification != 7
大片稀疏区被判成噪声 statistical 是全局阈值 提高 multiplier,或改用 filters.lof
radius 法结果随抽样剧烈变化 判据依赖绝对密度 先测平均点间距,再定 radius
elm 完全没效果 格子里点太少,走不到第二次比较 调大 cell,或改用 filters.outlier
elm 误删地形陡坎 threshold 太小 调大 threshold,山区可到 1–2 米
iqr/mad 删掉了山顶和塔 分布裁剪本来就会切尾部 换用 filters.outlier 做邻域判据
LocalOutlierFactor 全是 1.0 数据密度过于均匀 正常;LOF 在均匀数据上不产生信号
voxeldownsize 后法向量不对 center 模式改了坐标 改用 first,或先降采样再算法向量
decimation 后空间分布更不均匀 它按点序抽样,不看空间 改用 filters.sample 或 voxeldownsize
poisson 输出点数暴涨 正常,它输出等值面采样点 它不是降采样工具,别用它瘦身
poisson 报错缺法向量 输入里没有 NormalX/Y/Z 先跑 filters.normal,或让 poisson 自动补

最后一条要补一句:poisson 会自动调用 filters.normal,但自动补出来的法向量用的是默认参数(knn = 8、always_up = true),对朝向复杂的物体(扫描的雕塑、建筑立面)可能不够。需要控制法向量质量时,显式在 poisson 之前写一个 filters.normal,参数自己定。

9.11 生产建议

去噪链路固定成模板。 机载数据的“归零 → ELM → statistical → 删点”是经过大量实践验证的顺序,把它做成 pipeline 模板,参数只调 cell、threshold、multiplier 三个。

参数先用分位数定。 定 multiplier 之前,先用 pdal info --stats --dimensions Z 看 Z 的分布,用 filters.lof 的 LocalOutlierFactor 分布定阈值。拍脑袋给参数是去噪质量不稳定的主因。

能留标签就别删点。 删点是不可逆的。除最终交付外,中间产物一律保留 Classification = 7,下游按需过滤。

降采样优先 voxeldownsize,需要保持属性精度时用 sample。 voxeldownsize 快但会改坐标(center 模式);sample 慢但不碰点。

去噪是高成本环节。 filters.outlier 与 filters.lof 都需要全局或邻域搜索,无法流式,十亿点级别要考虑分块或先在 COPC/EPT 上降采样试参。

9.12 小结

主题 关键事实
噪声分类 孤立离群 / 低点 / 密度不均,三类用不同 stage
outlier statistical mean_k 默认 8、multiplier 默认 2.0;全局判据,两遍扫描,不可流式
outlier radius radius 默认 1.0、min_k 默认 2;对绝对密度敏感
outlier 产物 只把 Classification 设为 7(class 可改),不删点
elm cell 默认 10.0、threshold 默认 1.0;专治地面以下低点
iqr k 默认 1.5;按四分位距裁尾部,会删真实地形
mad k 默认 2.0、mad_multiplier 默认 1.4826;比 IQR 抗离群
lof minpts 默认 10;产出 NNDistance/LocalReachabilityDistance/LocalOutlierFactor
voxeldownsize cell 默认 0.001、mode 默认 center(会改写坐标)
decimation step 默认 1.0(可为小数);按点序抽样
sample radius 或 cell;泊松盘采样,不碰坐标
normal knn 默认 8、always_up 默认 true、refine 默认 false
poisson depth 默认 8;输出等值面新点集,点数通常增加
stats enumerate/count/global/advanced;global 默认不算