第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:全局均值 + 标准差
算法两遍扫描(文档给了完整公式):
- 对每个点 $p_i$,取它的 $k$ 个最近邻($k$ 由
mean_k指定),计算平均距离 $\mu_i$; - 对全部 $\mu_i$ 求全局均值 $\bar{\mu}$ 与标准差 $\sigma$;
- 阈值 $t = \bar{\mu} + m\sigma$,其中 $m$ 是
multiplier; - $\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)专门处理地面以下的点,算法按格网走:
- 按
cell大小栅格化; - 每格内最低点算候选,如果它与第二低点的差值超过
threshold,判为噪声; - 继续比较第二低与第三低,直到找到差值在阈值内的邻居为止。
| 选项 | 默认 | 说明 |
|---|---|---|
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"
]
每一步的理由:
- assign 归零:原始数据可能带着上一手处理的分类标签(有的甚至是错的)。归零让后续判据在干净状态上工作。
- elm 先做:低点通常成片且密度够高,统计离群点检测不出来,必须先用高程判据抓。
- outlier 后做:处理完低点,剩下的孤立噪声才符合“邻居少”的特征。
- expression 删点:确认要删才删;如果只是存档,把这一步去掉,保留分类标记。
- 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 默认不算 |