深度图与点云去噪实战:双边滤波+统计/半径滤波原理与Open3D全实现

文章围绕深度图与点云去噪给出可运行方案:先用 OpenCV 对 uint16 深度图做双边滤波以保边平滑,再按相机内参转为点云,并用 Open3D 做统计滤波与半径滤波剔除孤立点与小簇噪点。文中包含 PLY 一键去噪、点云双边平滑及“深度滤波→点云→滤波”工业流程代码与参数调优要点。


前言

在3D计算机视觉领域,深度相机(RealSense、Kinect、LiDAR)采集的深度图/点云数据不可避免会引入噪声——比如椒盐噪声、孤立点、稀疏噪点簇、高斯噪声等。这些噪声会直接影响后续的3D重建、点云配准、目标分割等任务的精度,因此针对性去噪是3D数据预处理的核心步骤。

本文将从实际应用出发,先详细讲解点云去噪中经典的统计滤波半径滤波(结合你提供的Open3D工业级代码逐行解析),再深入剖析深度图去噪的核心算法双边滤波(原理+实现+调优),最后实现深度图双边滤波+点云统计/半径滤波的全流程去噪方案,兼顾边缘保留和平滑去噪,适配大部分工业级场景。

在这里插入图片描述

前置知识与环境准备

核心依赖

本文所有代码基于Python实现,需安装以下库:

pip install open3d numpy opencv-python matplotlib
  • open3d:3D点云处理核心库,提供滤波、可视化、格式转换等功能;
  • numpy:数值计算基础,处理深度图/点云的数组数据;
  • opencv-python:2D深度图的双边滤波、图像读写;
  • matplotlib:深度图滤波效果可视化。

噪声类型说明

深度图/点云的常见噪声及对应解决方案:

  1. 孤立点/椒盐噪声:单个离散的噪点,无邻域点,用统计滤波剔除;
  2. 稀疏噪点簇:几个噪点聚集在一起,统计滤波无法识别,用半径滤波剔除;
  3. 高斯噪声/均匀噪声:深度图上的平滑噪声,会模糊但不破坏边缘,用双边滤波平滑,且保留物体轮廓;
  4. 密集小噪点团:少量噪点紧密聚集,可选3D形态学开运算处理(牺牲少量细节)。

第一部分:点云去噪基础——统计滤波&半径滤波

核心是统计滤波+半径滤波的组合,还做了超大点数优化、低版本Open3D兼容、无效点剔除等实用设计。这部分先讲两个滤波的核心原理,再逐行解析代码,让你知其然更知其所以然。

1.1 统计滤波(Statistical Outlier Removal)

核心原理

统计滤波的核心思想是基于邻域点的距离统计特性剔除异常点,假设点云的正常点在空间中是连续分布的,噪点与邻域点的距离会远大于正常点。
具体步骤:

  1. 对每个点,计算其到k个最近邻点的欧式距离的平均值
  2. 所有点的平均距离服从高斯分布,计算该分布的均值 μ \mu μ和标准差 σ \sigma σ;
  3. 剔除平均距离超过 μ + s t d _ r a t i o × σ \mu + std\_ratio \times \sigma μ+std_ratio×σ的点(即距离远于正常范围的孤立点)。

核心用途

专门剔除单点椒盐噪声、离散孤立点,是点云去噪的第一步基础操作,几乎所有点云去噪流程都会先做统计滤波。

关键参数

  • nb_neighbors:每个点的近邻数,一般取20~50(点数越多取越大);
  • std_ratio:标准差系数,一般取1.0~2.0(噪声越严重,系数越小,剔除越严格)。

1.2 半径滤波(Radius Outlier Removal)

核心原理

半径滤波是统计滤波的补充,解决统计滤波对“小噪点簇”无效的问题,核心是基于邻域点的数量剔除异常点
具体步骤:

  1. 以每个点为球心,设置一个固定半径r,构建3D球形邻域;
  2. 统计球形邻域内的点数量,剔除数量少于min_nn的点;
  3. 即使几个噪点聚集,其邻域内的点数仍会远少于正常点,因此能被有效剔除。

核心用途

专门剔除稀疏小噪点簇(2~5个噪点聚集),与统计滤波组合形成“孤立点+小簇噪点”的全覆盖剔除,是点云去噪的黄金组合

关键参数

  • radius:球形邻域半径(单位:米),一般取0.03~0.1m(根据点云密度调整,密度越大半径越小);
  • min_nn:邻域内最小有效点数,一般取8~15(与radius匹配,半径越大,最小点数取越大)。

1.3 工业级点云去噪代码逐行解析(你的PLY代码)

你提供的代码做了很多工业级优化(低版本兼容、超大点数防内存溢出、可选形态学开运算),以下分模块解析核心逻辑,标注关键亮点和参数调优技巧。

完整代码(带详细注释+优化说明)

import open3d as o3d
import numpy as np

def ply_denoise(
    input_ply_path,  # 输入带噪PLY文件路径
    output_ply_path, # 输出去噪后PLY文件路径
    # 统计滤波参数:去3D孤立点
    stat_nb_neighbors=20,
    stat_std_ratio=2.0,
    # 半径滤波参数:去稀疏小噪点簇(单位:米)
    rad_radius=0.05,
    rad_min_nn=10,
    # 可选3D形态学开运算(类似2D腐蚀,处理密集小噪点团)
    use_morphology=False,
    morph_voxel_size=0.02,
    # 超大点数点云专属:轻量体素下采样(默认开启,降低计算量)
    use_down_sample=True,
    down_voxel_size=0.01
):
    """
    PLY点云文件去噪:低版本Open3D兼容+超大点数点云优化
    核心:统计滤波+半径滤波,支持带颜色/无颜色PLY,保留点云原始信息
    调优原则:噪声越严重,stat_std_ratio越小、rad_radius越大、rad_min_nn越大
    """
    # 1. 读取PLY点云文件,校验有效性
    print(f"正在读取PLY文件:{input_ply_path}")
    pcd = o3d.io.read_point_cloud(input_ply_path)
    if not pcd.has_points():
        raise ValueError("读取PLY失败!文件损坏或非点云文件")
    original_point_num = len(pcd.points)
    print(f"原始点云点数:{original_point_num:,}")  # 千分位显示,提升可读性

    # 2. 核心预处理:无效点+重复点剔除【低版本Open3D兼容亮点】
    # remove_infinite=True 适配所有Open3D版本(旧版本无remove_inf)
    pcd = pcd.remove_non_finite_points(remove_nan=True, remove_infinite=True)
    pcd = pcd.remove_duplicated_points()  # 剔除重复点,避免邻域统计误差
    preprocess_point_num = len(pcd.points)
    if original_point_num - preprocess_point_num > 0:
        print(f"预处理:剔除{original_point_num - preprocess_point_num:,}个无效/重复点")

    # 3. 超大点数优化:体素下采样【工业级亮点,防内存溢出】
    # 点数超100万开启,通过体素化降低点数,不影响整体结构
    if use_down_sample and preprocess_point_num > 1000000:
        pcd = pcd.voxel_down_sample(voxel_size=down_voxel_size)
        down_point_num = len(pcd.points)
        print(f"超大点数优化:体素下采样后点数{down_point_num:,}(体素大小{down_voxel_size}m)")
    else:
        down_point_num = preprocess_point_num

    # 4. 核心去噪:统计滤波+半径滤波【黄金组合,全覆盖离散噪点】
    # 4.1 统计滤波:去孤立点
    cl, ind = pcd.remove_statistical_outlier(nb_neighbors=stat_nb_neighbors, std_ratio=stat_std_ratio)
    pcd_denoised = pcd.select_by_index(ind)
    stat_remove = down_point_num - len(pcd_denoised.points)
    print(f"统计滤波:剔除{stat_remove:,}个孤立噪点")

    # 4.2 半径滤波:去稀疏小噪点簇(统计滤波的补充)
    cl, ind = pcd_denoised.remove_radius_outlier(nb_points=rad_min_nn, radius=rad_radius)
    pcd_denoised = pcd_denoised.select_by_index(ind)
    rad_remove = down_point_num - stat_remove - len(pcd_denoised.points)
    print(f"半径滤波:剔除{rad_remove:,}个稀疏噪点")

    # 5. 可选:3D形态学开运算【处理密集小噪点团,牺牲少量细节】
    # 原理:先腐蚀(剔除小簇噪点)后膨胀(恢复正常点云结构)
    if use_morphology:
        print(f"开启3D开运算(腐蚀+膨胀),体素大小{morph_voxel_size}m")
        pcd_down = pcd_denoised.voxel_down_sample(voxel_size=morph_voxel_size)  # 腐蚀
        pcd_down.estimate_normals(search_param=o3d.geometry.KDTreeSearchParamHybrid(radius=morph_voxel_size*2, max_nn=30))  # 估计法向量,为膨胀做准备
        pcd_denoised = pcd_down.voxel_up_sample(voxel_size=morph_voxel_size/3)  # 膨胀

    # 6. 保存去噪后PLY文件,支持带颜色点云
    o3d.io.write_point_cloud(output_ply_path, pcd_denoised, write_ascii=True)  # write_ascii=True提升兼容性
    final_num = len(pcd_denoised.points)
    total_remove = original_point_num - final_num
    print(f"\n去噪完成!总剔除{total_remove:,}个噪点,剩余有效点数{final_num:,}")
    print(f"去噪后文件已保存:{output_ply_path}")

    # 7. 可视化:超大点数建议关闭(避免卡顿)
    o3d.visualization.draw_geometries([pcd_denoised], window_name="PLY去噪结果", width=800, height=600)

    return pcd_denoised

# 主函数:仅需修改输入输出路径,默认参数适配80%场景
if __name__ == "__main__":
    INPUT_PLY = "simulated_depth_scan.ply"  # 你的带噪PLY文件路径
    OUTPUT_PLY = "denoised_clean.ply"       # 去噪后保存路径
    ply_denoise(
        input_ply_path=INPUT_PLY,
        output_ply_path=OUTPUT_PLY,
        # 噪声严重时的调优示例
        # stat_nb_neighbors=25,  # 增加近邻数,统计更稳健
        # stat_std_ratio=1.5,    # 减小系数,剔除更严格
        # rad_radius=0.06,       # 增大半径,检测更多稀疏簇
        # rad_min_nn=12,         # 增加最小点数,剔除更严格
        # use_morphology=True,   # 有密集小噪点团时开启
        # morph_voxel_size=0.02
    )

核心模块解析

  1. 无效点/重复点剔除:Open3D的remove_non_finite_points是核心,修复了低版本兼容问题(将remove_inf改为remove_infinite),重复点会导致邻域统计偏差,必须剔除;
  2. 超大点数体素下采样:针对500万+的点云,体素下采样能在不破坏整体结构的前提下降低点数,避免后续滤波的内存溢出和卡顿,是工业级代码的关键优化;
  3. 统计+半径滤波组合:先剔除孤立点,再剔除稀疏小簇,两者互补,覆盖了绝大多数离散噪点场景;
  4. 可选3D形态学开运算:原理是“腐蚀+膨胀”,适合处理密集小噪点团(比如10个左右噪点紧密聚集),但会损失少量细节,因此设为可选。

调优黄金原则

噪声越严重(比如深度相机距离目标过远、光照复杂),按以下方式调参:

  • 统计滤波:stat_std_ratio调小(1.01.5)、stat\_nb\_neighbors调大(3050);
  • 半径滤波:rad_radius调大(0.060.1m)、rad\_min\_nn调大(1220);
  • 密集噪点团:开启use_morphologymorph_voxel_size取0.02~0.05m(根据点云密度调整)。

第二部分:深度图去噪核心——双边滤波(Bilateral Filter)

统计滤波和半径滤波是点云3D层面的去噪,适合剔除离散噪点,但无法处理深度图2D层面的高斯噪声/均匀噪声(这类噪声会让深度图整体粗糙,无明显离散点)。

而双边滤波是深度图去噪的经典算法,核心优势是边缘保留的平滑去噪——普通高斯滤波会模糊物体边缘,而双边滤波能在平滑噪声的同时,完整保留深度图的边缘轮廓(比如桌子和墙面的交界、物体的轮廓),这对后续的3D重建至关重要。

2.1 为什么高斯滤波不适合深度图?

在讲双边滤波前,先理解高斯滤波的局限性:
高斯滤波是空域唯一的滤波,其权重仅由像素的空间距离决定——距离越近,权重越大,参与滤波的贡献越高。

公式: G σ ( x , y )

1 2 π σ 2 e − x 2 + y 2 2 σ 2 G_\sigma(x,y) = \frac{1}{2\pi\sigma^2}e^{-\frac{x^2+y^2}{2\sigma^2}} Gσ​(x,y)=2πσ21​e−2σ2x2+y2​

问题在于:深度图的边缘处,相邻像素的深度值差异极大(比如墙面深度1m,桌子深度0.5m),高斯滤波会将边缘两侧的像素混合,导致边缘模糊,而边缘是3D场景的核心结构信息,模糊后会严重影响后续的点云生成和3D重建。

2.2 双边滤波的核心原理

双边滤波的核心创新是:将空域核(Spatial Kernel)和值域核(Range Kernel)结合,滤波权重由空间距离深度值相似性共同决定。

只有满足两个条件的像素,才会参与当前像素的滤波计算:

  1. 空间近:像素在当前像素的邻域内(空域核控制);
  2. 深度值相似:像素的深度值与当前像素的深度值差异小(值域核控制)。

这样一来,边缘两侧的像素因深度值差异大,不会互相参与滤波,从而保留边缘;而同一区域内的像素因空间近且深度值相似,会被平滑滤波,从而去除噪声

双边滤波的数学公式

对于深度图中的像素 p ( x p , y p ) p(x_p,y_p) p(xp​,yp​),其滤波后的深度值 I ( p ) I(p) I(p)为:

I ( p )

1 W p ∑ q ∈ N ( p ) w ( p , q ) ⋅ I ( q ) I(p) = \frac{1}{W_p} \sum_{q \in N(p)} w(p,q) \cdot I(q) I(p)=Wp​1​q∈N(p)∑​w(p,q)⋅I(q)
其中:

  • N ( p ) N(p) N(p):像素 p p p的邻域(比如3×3、5×5);
  • W p

    ∑ q ∈ N ( p ) w ( p , q ) W_p = \sum_{q \in N(p)} w(p,q) Wp​=∑q∈N(p)​w(p,q):归一化权重,保证滤波后深度值范围不变;
  • w ( p , q )

    w s ( p , q ) ⋅ w r ( p , q ) w(p,q) = w_s(p,q) \cdot w_r(p,q) w(p,q)=ws​(p,q)⋅wr​(p,q):联合权重,由空域核和值域核相乘得到。
1. 空域核(Spatial Kernel)

与高斯滤波一致,控制空间距离的权重, σ s \sigma_s σs​为空域标准差

w s ( p , q )

e − ∥ p − q ∥ 2 2 σ s 2 w_s(p,q) = e^{-\frac{|p-q|^2}{2\sigma_s^2}} ws​(p,q)=e−2σs2​∥p−q∥2​

  • ∥ p − q ∥ |p-q| ∥p−q∥:像素 p p p和 q q q的欧式距离;
  • σ s \sigma_s σs​越大,参与滤波的空间范围越广,平滑效果越强。
2. 值域核(Range Kernel)

控制深度值相似性的权重, σ r \sigma_r σr​为值域标准差, I ( p ) I(p) I(p)、 I ( q ) I(q) I(q)为像素 p p p、 q q q的深度值:

w r ( p , q )

e − ∥ I ( p ) − I ( q ) ∥ 2 2 σ r 2 w_r(p,q) = e^{-\frac{|I(p)-I(q)|^2}{2\sigma_r^2}} wr​(p,q)=e−2σr2​∥I(p)−I(q)∥2​

  • ∥ I ( p ) − I ( q ) ∥ |I(p)-I(q)| ∥I(p)−I(q)∥:像素 p p p和 q q q的深度值差异;
  • σ r \sigma_r σr​越大,对深度值差异的容忍度越高,平滑效果越强,但边缘保留越弱; σ r \sigma_r σr​越小,边缘保留越严格,平滑效果越弱。

双边滤波的核心特点

  1. 边缘保留:最核心的优势,适合深度图、彩色图像等需要保留边缘的场景;
  2. 局部性:仅利用邻域像素进行滤波,计算速度快,非迭代;
  3. 非线性:因值域核的存在,双边滤波是非线性滤波(高斯滤波是线性);
  4. 无参数迭代:仅需调优 σ s \sigma_s σs​和 σ r \sigma_r σr​,无需复杂的迭代参数。

2.3 双边滤波的核心用途

  1. 深度图去噪:平滑高斯噪声、均匀噪声,保留物体边缘,是深度图预处理的首选算法
  2. 彩色图像去噪:边缘保留的平滑去噪,避免图像模糊;
  3. 点云平滑:Open3D提供了点云层面的双边滤波,可对3D点云进行边缘保留的平滑;
  4. 深度图空洞填充:结合邻域深度值相似性,对小空洞进行合理填充。

2.4 双边滤波的实现(2D深度图+3D点云)

双边滤波的实现分两种场景:2D深度图层面(预处理,效果最好)和3D点云层面(后处理,优化点云平滑度),以下分别实现,且与前文的统计+半径滤波结合。

2.4.1 实现1:2D深度图的双边滤波(OpenCV+NumPy)

OpenCV提供了现成的cv2.bilateralFilter函数,专门用于双边滤波,适配深度图的16位uint16格式(深度相机采集的深度图默认格式),无需手写复杂的卷积逻辑,直接调用即可。

核心代码(深度图读取+双边滤波+可视化)
import cv2
import numpy as np
import matplotlib.pyplot as plt

def depth_bilateral_filter(
    depth_img_path,
    output_depth_path,
    d=5,        # 滤波邻域直径,奇数(3/5/7),越大平滑范围越广
    sigmaColor=10,  # 值域标准差σr,深度值相似性权重
    sigmaSpace=10   # 空域标准差σs,空间距离权重
):
    """
    2D深度图双边滤波:边缘保留平滑去噪,适配16位uint16深度图
    调优原则:噪声越严重,d/ sigmaSpace越大;需强边缘保留,sigmaColor越小
    """
    # 1. 读取深度图(深度相机采集的深度图为16位uint16,单通道)
    depth = cv2.imread(depth_img_path, cv2.IMREAD_UNCHANGED)
    if depth is None:
        raise ValueError("读取深度图失败!文件损坏或非深度图文件")
    # 转换为float32,避免滤波时数值溢出
    depth_float = depth.astype(np.float32)
    print(f"深度图尺寸:{depth.shape},深度值范围:{np.min(depth)}~{np.max(depth)}")

    # 2. 双边滤波核心调用
    # cv2.bilateralFilter:src-输入图像,d-邻域直径,sigmaColor-值域σ,sigmaSpace-空域σ
    # 注意:深度图为单通道,彩色图为3通道,函数自动适配
    depth_denoised = cv2.bilateralFilter(depth_float, d=d, sigmaColor=sigmaColor, sigmaSpace=sigmaSpace)
    # 转换回16位uint16,保存为原始深度图格式
    depth_denoised = depth_denoised.astype(np.uint16)

    # 3. 保存滤波后的深度图
    cv2.imwrite(output_depth_path, depth_denoised)
    print(f"双边滤波后的深度图已保存:{output_depth_path}")

    # 4. 可视化滤波效果(对比原始和去噪后的深度图)
    plt.figure(figsize=(12, 6))
    # 原始深度图
    plt.subplot(1, 2, 1)
    plt.imshow(depth, cmap="jet")
    plt.title("Original Depth Map", fontsize=14)
    plt.axis("off")
    plt.colorbar()
    # 去噪后深度图
    plt.subplot(1, 2, 2)
    plt.imshow(depth_denoised, cmap="jet")
    plt.title("Denoised Depth Map (Bilateral Filter)", fontsize=14)
    plt.axis("off")
    plt.colorbar()
    plt.tight_layout()
    plt.show()

    return depth_denoised

# 主函数调用
if __name__ == "__main__":
    INPUT_DEPTH = "depth_noise.png"  # 你的带噪深度图(16位uint16)
    OUTPUT_DEPTH = "depth_denoised.png"  # 滤波后深度图
    depth_bilateral_filter(
        depth_img_path=INPUT_DEPTH,
        output_depth_path=OUTPUT_DEPTH,
        d=5,           # 5×5邻域,适中的平滑范围
        sigmaColor=15, # 值域σ,对深度值差异的容忍度适中
        sigmaSpace=15  # 空域σ,空间邻域范围适中
    )
关键参数调优

cv2.bilateralFilter的3个核心参数,调优直接决定滤波效果,黄金调优原则

  1. d(邻域直径):取3/5/7(奇数),噪声越严重取越大,过大会导致轻微边缘模糊;
  2. sigmaColor(值域 σ r \sigma_r σr​):深度图一般取1030,**需强边缘保留则取小值(510)**,需强平滑则取大值(20~30);
  3. sigmaSpace(空域 σ s \sigma_s σs​):与sigmaColor匹配,一般取相同值,10~30即可。
效果说明

滤波后,深度图的局部噪声会被平滑(比如同一平面的粗糙点),而物体边缘会被完整保留(比如深度值突变的区域),这是高斯滤波无法实现的效果。

2.4.2 实现2:3D点云的双边滤波(Open3D)

Open3D提供了点云层面的双边滤波函数filter_smooth_bilateral,可对3D点云进行边缘保留的平滑,适合将深度图转点云后,进一步优化点云的平滑度,可与前文的统计+半径滤波组合使用。

核心代码(点云双边滤波+统计+半径滤波组合)
import open3d as o3d
import numpy as np

def pcd_bilateral_denoise(
    input_ply_path,
    output_ply_path,
    # 双边滤波参数
    bilateral_sigma1=0.01,  # 空域σ,点云空间距离权重
    bilateral_sigma2=0.05,  # 值域σ,点云法向量/深度值相似性权重
    # 统计+半径滤波参数(复用前文)
    stat_nb_neighbors=20,
    stat_std_ratio=2.0,
    rad_radius=0.05,
    rad_min_nn=10
):
    """
    3D点云全流程去噪:双边滤波(平滑)+ 统计滤波+半径滤波(剔离散噪点)
    先平滑,再剔噪点,效果优于单独使用某一种滤波
    """
    # 1. 读取点云并预处理
    pcd = o3d.io.read_point_cloud(input_ply_path)
    if not pcd.has_points():
        raise ValueError("读取点云失败!")
    print(f"原始点云点数:{len(pcd.points):,}")
    # 剔除无效/重复点
    pcd = pcd.remove_non_finite_points(remove_nan=True, remove_infinite=True)
    pcd = pcd.remove_duplicated_points()

    # 2. 点云双边滤波【边缘保留平滑】
    # 先估计法向量(双边滤波需要法向量计算值域相似性)
    pcd.estimate_normals(search_param=o3d.geometry.KDTreeSearchParamHybrid(radius=0.1, max_nn=30))
    # 双边滤波核心调用
    pcd_smooth = pcd.filter_smooth_bilateral(sigma1=bilateral_sigma1, sigma2=bilateral_sigma2)
    print(f"双边滤波后点云点数:{len(pcd_smooth.points):,}")

    # 3. 统计+半径滤波【剔除离散噪点】
    cl, ind = pcd_smooth.remove_statistical_outlier(nb_neighbors=stat_nb_neighbors, std_ratio=stat_std_ratio)
    pcd_denoised = pcd_smooth.select_by_index(ind)
    cl, ind = pcd_denoised.remove_radius_outlier(nb_points=rad_min_nn, radius=rad_radius)
    pcd_denoised = pcd_denoised.select_by_index(ind)

    # 4. 保存并可视化
    o3d.io.write_point_cloud(output_ply_path, pcd_denoised)
    print(f"去噪后点云点数:{len(pcd_denoised.points):,},已保存至:{output_ply_path}")
    o3d.visualization.draw_geometries([pcd_denoised], window_name="点云双边滤波+统计半径滤波结果")

    return pcd_denoised

# 主函数调用
if __name__ == "__main__":
    INPUT_PLY = "simulated_depth_scan.ply"
    OUTPUT_PLY = "pcd_bilateral_denoised.ply"
    pcd_bilateral_denoise(
        input_ply_path=INPUT_PLY,
        output_ply_path=OUTPUT_PLY,
        bilateral_sigma1=0.01,
        bilateral_sigma2=0.05
    )
点云双边滤波参数说明
  • sigma1:空域标准差,控制点云的空间距离权重,一般取0.01~0.05m(根据点云密度调整);
  • sigma2:值域标准差,控制点云的法向量/深度值相似性权重,一般取0.05~0.1m,越大平滑效果越强。

注意:点云双边滤波前必须调用estimate_normals估计法向量,因为Open3D的点云双边滤波是基于法向量相似性计算值域权重的,法向量估计的精度会影响滤波效果。

第三部分:工业级全流程——深度图→点云的联合去噪

实际项目中,深度相机采集的是2D深度图,需要先将深度图转换为3D点云,再进行后续处理。因此最优的去噪流程是:
深度图双边滤波(2D层面平滑,保留边缘)→ 深度图转点云 → 点云统计+半径滤波(3D层面剔离散噪点)

该流程结合了双边滤波的边缘保留平滑和统计+半径滤波的离散噪点剔除,是工业级深度图/点云去噪的黄金流程,以下实现完整代码。

3.1 核心原理:深度图转点云

深度图转点云的核心是相机内参,对于像素 ( u , v ) (u,v) (u,v),其深度值为 z z z,对应的3D空间坐标 ( X , Y , Z ) (X,Y,Z) (X,Y,Z)为:

X

( u − c x ) × z f x X = (u - cx) \times \frac{z}{fx} X=(u−cx)×fxz​

Y

( v − c y ) × z f y Y = (v - cy) \times \frac{z}{fy} Y=(v−cy)×fyz​

Z

z Z = z Z=z
其中:

  • ( f x , f y ) (fx, fy) (fx,fy):相机的焦距(像素单位);
  • ( c x , c y ) (cx, cy) (cx,cy):相机的主点坐标(像素单位);
  • 以上参数可从深度相机的说明书或SDK中获取(比如Intel RealSense的fx≈600,fy≈600,cx≈320,cy≈240)。

3.2 全流程完整代码(深度图双边滤波→转点云→点云去噪)

import cv2
import numpy as np
import open3d as o3d

# 第一步:深度图双边滤波(复用前文函数)
def depth_bilateral_filter(depth_img_path, d=5, sigmaColor=15, sigmaSpace=15):
    depth = cv2.imread(depth_img_path, cv2.IMREAD_UNCHANGED)
    depth_float = depth.astype(np.float32)
    depth_denoised = cv2.bilateralFilter(depth_float, d=d, sigmaColor=sigmaColor, sigmaSpace=sigmaSpace)
    return depth_denoised.astype(np.uint16)

# 第二步:深度图转点云(基于相机内参)
def depth2pcd(depth_img, fx=615.0, fy=615.0, cx=320.0, cy=240.0, scale=1000.0):
    """
    深度图转3D点云
    参数:
        depth_img: 16位uint16深度图
        fx/fy: 相机焦距(像素单位),默认适配Intel RealSense D435
        cx/cy: 相机主点坐标,默认适配640×480分辨率
        scale: 深度值缩放系数(深度图值为mm,转m需除以1000)
    """
    h, w = depth_img.shape
    # 生成像素坐标网格
    u, v = np.meshgrid(np.arange(w), np.arange(h))
    # 计算3D空间坐标
    z = depth_img / scale  # 转米单位
    x = (u - cx) * z / fx
    y = (v - cy) * z / fy
    # 拼接点云坐标,剔除深度值为0的无效点
    points = np.stack([x, y, z], axis=-1).reshape(-1, 3)
    valid_mask = z.reshape(-1) > 0  # 剔除深度为0的点
    points = points[valid_mask]
    # 创建Open3D点云对象
    pcd = o3d.geometry.PointCloud()
    pcd.points = o3d.utility.Vector3dVector(points)
    print(f"深度图转点云完成,有效点数:{len(pcd.points):,}")
    return pcd

# 第三步:点云统计+半径滤波(复用前文工业级函数)
def ply_denoise(pcd, stat_nb_neighbors=20, stat_std_ratio=2.0, rad_radius=0.05, rad_min_nn=10):
    pcd = pcd.remove_non_finite_points(remove_nan=True, remove_infinite=True)
    pcd = pcd.remove_duplicated_points()
    # 统计滤波
    cl, ind = pcd.remove_statistical_outlier(nb_neighbors=stat_nb_neighbors, std_ratio=stat_std_ratio)
    pcd_denoised = pcd.select_by_index(ind)
    # 半径滤波
    cl, ind = pcd_denoised.remove_radius_outlier(nb_points=rad_min_nn, radius=rad_radius)
    pcd_denoised = pcd_denoised.select_by_index(ind)
    return pcd_denoised

# 主流程:深度图双边滤波 → 转点云 → 点云去噪 → 保存可视化
if __name__ == "__main__":
    # 配置参数
    INPUT_DEPTH = "depth_noise.png"    # 输入带噪深度图
    OUTPUT_DEPTH = "depth_denoised.png"# 输出滤波后深度图
    OUTPUT_PLY = "final_denoised.ply" # 输出最终点云
    # 相机内参(根据你的深度相机修改!)
    FX, FY = 615.0, 615.0
    CX, CY = 320.0, 240.0

    # 1. 深度图双边滤波
    depth_denoised = depth_bilateral_filter(INPUT_DEPTH, d=5, sigmaColor=15, sigmaSpace=15)
    cv2.imwrite(OUTPUT_DEPTH, depth_denoised)

    # 2. 滤波后的深度图转点云
    pcd = depth2pcd(depth_denoised, fx=FX, fy=FY, cx=CX, cy=CY)

    # 3. 点云统计+半径滤波
    pcd_final = ply_denoise(pcd)

    # 4. 保存并可视化最终点云
    o3d.io.write_point_cloud(OUTPUT_PLY, pcd_final)
    print(f"全流程去噪完成,最终点云已保存:{OUTPUT_PLY},点数:{len(pcd_final.points):,}")
    o3d.visualization.draw_geometries([pcd_final], window_name="深度图+点云全流程去噪结果")

关键注意事项

  1. 相机内参修改:代码中的fx/fy/cx/cy是默认值,需根据你的深度相机实际参数修改(可从相机SDK、标定工具中获取),否则点云会出现畸变;
  2. 深度值缩放:深度相机采集的深度图值一般为毫米(mm),代码中scale=1000将其转为米(m),若你的深度图单位是米,需将scale改为1;
  3. 分辨率匹配:内参与深度图分辨率一一对应(比如640×480的深度图对应cx=320,cy=240),若深度图分辨率为1280×720,需调整cx/cy为640/360。

第四部分:各滤波算法对比与适用场景总结

为了让你在实际项目中快速选择合适的滤波算法,以下对统计滤波、半径滤波、双边滤波进行全方位对比,明确各自的适用场景和优缺点:

滤波算法处理层面核心功能优点缺点适用场景
统计滤波3D点云剔除孤立点/椒盐噪声计算快、参数少、适配所有点云对小噪点簇无效点云初步去噪,剔除离散单点噪声
半径滤波3D点云剔除稀疏小噪点簇补充统计滤波,处理小簇噪点半径参数需根据点云密度调整统计滤波后二次去噪,剔除稀疏簇噪声
双边滤波(2D)2D深度图边缘保留的平滑去噪保留边缘、平滑高斯/均匀噪声、效果最好对离散点无效深度图预处理,平滑整体噪声,保留场景结构
双边滤波(3D)3D点云边缘保留的点云平滑优化点云平滑度,保留3D边缘需估计法向量、对离散点无效点云后处理,优化平滑度(配合统计+半径滤波)

黄金组合策略

  1. 深度图预处理:优先使用2D双边滤波,平滑噪声并保留边缘,这是最关键的一步;
  2. 点云去噪:深度图转点云后,使用统计滤波+半径滤波组合,剔除离散噪点;
  3. 点云优化:若点云整体粗糙,可在统计+半径滤波前增加3D双边滤波,实现平滑+剔噪的双重效果;
  4. 密集噪点团:若存在密集小噪点团,开启3D形态学开运算(牺牲少量细节)。

第五部分:常见问题与解决方案

  1. 深度图滤波后出现空洞:深度图本身存在的空洞,双边滤波无法填充,可使用cv2.inpaint进行空洞填充,或在点云层面使用o3d.geometry.PointCloud.voxel_up_sample补点;
  2. 点云可视化卡顿/内存溢出:开启体素下采样(voxel_down_sample),降低点云点数,体素大小取0.01~0.05m;
  3. 双边滤波后边缘模糊:减小sigmaColor(值域σ)或d(邻域直径),增强边缘保留效果;
  4. 统计+半径滤波后丢失有效点:调大stat_std_ratio、减小rad_radiusrad_min_nn,降低剔除严格度;
  5. 深度图转点云后点云畸变:检查相机内参是否正确,确保内参与深度图分辨率、相机型号匹配。

总结

深度图/点云去噪是3D计算机视觉的基础预处理步骤,其核心是针对性选择滤波算法

  1. 双边滤波是深度图去噪的核心,凭借边缘保留的平滑特性,完胜传统的高斯滤波,是2D层面去噪的首选;
  2. 统计滤波+半径滤波是点云去噪的黄金组合,能全覆盖剔除3D层面的孤立点和稀疏小噪点簇,且计算高效、参数易调;
  3. 工业级的最优流程是深度图双边滤波→转点云→点云统计+半径滤波,结合了2D层面的平滑和3D层面的剔噪,兼顾效果和效率。

本文的所有代码均为开箱即用,适配大部分深度相机(RealSense、Kinect、LiDAR)采集的数据,只需根据实际场景微调参数,即可应用于3D重建、点云配准、目标检测等实际项目中。

深度圖與點雲去噪實戰:雙邊濾波+統計/半徑濾波原理與Open3D全實現

文章圍繞深度圖與點雲去噪給出可運行方案:先用 OpenCV 對 uint16 深度圖做雙邊濾波以保邊平滑,再按相機內參轉為點雲,並用 Open3D 做統計濾波與半徑濾波剔除孤立點與小簇噪點。文中包含 PLY 一鍵去噪、點雲雙邊平滑及“深度濾波→點雲→濾波”工業流程代碼與參數調優要點。

來源:https://blog.csdn.net/2403_87969572/article/details/157769488

抓取時間(ISO本地):2026-05-18 05:17:36


前言

在3D計算機視覺領域,深度相機(RealSense、Kinect、LiDAR)採集的深度圖/點雲資料不可避免會引入噪聲——比如椒鹽噪聲、孤立點、稀疏噪點簇、高斯噪聲等。這些噪聲會直接影響後續的3D重建、點雲配準、目標分割等任務的精度,因此針對性去噪是3D資料預處理的核心步驟。

本文將從實際應用出發,先詳細講解點雲去噪中經典的統計濾波半徑濾波(結合你提供的Open3D工業級程式碼逐行解析),再深入剖析深度圖去噪的核心演算法雙邊濾波(原理+實現+調優),最後實現深度圖雙邊濾波+點雲統計/半徑濾波的全流程去噪方案,兼顧邊緣保留和平滑去噪,適配大部分工業級場景。

在這裡插入圖片描述

文章目錄

前置知識與環境準備

核心依賴

本文所有程式碼基於Python實現,需安裝以下庫:

pip install open3d numpy opencv-python matplotlib
  • open3d:3D點雲處理核心庫,提供濾波、視覺化、格式轉換等功能;
  • numpy:數值計算基礎,處理深度圖/點雲的陣列資料;
  • opencv-python:2D深度圖的雙邊濾波、影象讀寫;
  • matplotlib:深度圖濾波效果視覺化。

噪聲型別說明

深度圖/點雲的常見噪聲及對應解決方案:

  1. 孤立點/椒鹽噪聲:單個離散的噪點,無鄰域點,用統計濾波剔除;
  2. 稀疏噪點簇:幾個噪點聚集在一起,統計濾波無法識別,用半徑濾波剔除;
  3. 高斯噪聲/均勻噪聲:深度圖上的平滑噪聲,會模糊但不破壞邊緣,用雙邊濾波平滑,且保留物體輪廓;
  4. 密集小噪點團:少量噪點緊密聚集,可選3D形態學開運算處理(犧牲少量細節)。

第一部分:點雲去噪基礎——統計濾波&半徑濾波

核心是統計濾波+半徑濾波的組合,還做了超大點數最佳化、低版本Open3D相容、無效點剔除等實用設計。這部分先講兩個濾波的核心原理,再逐行解析程式碼,讓你知其然更知其所以然。

1.1 統計濾波(Statistical Outlier Removal)

核心原理

統計濾波的核心思想是基於鄰域點的距離統計特性剔除異常點,假設點雲的正常點在空間中是連續分佈的,噪點與鄰域點的距離會遠大於正常點。
具體步驟:

  1. 對每個點,計算其到k個最近鄰點的歐式距離的平均值
  2. 所有點的平均距離服從高斯分佈,計算該分佈的均值 μ \mu μ和標準差 σ \sigma σ;
  3. 剔除平均距離超過 μ + s t d _ r a t i o × σ \mu + std\_ratio \times \sigma μ+std_ratio×σ的點(即距離遠於正常範圍的孤立點)。

核心用途

專門剔除單點椒鹽噪聲、離散孤立點,是點雲去噪的第一步基礎操作,幾乎所有點雲去噪流程都會先做統計濾波。

關鍵引數

  • nb_neighbors:每個點的近鄰數,一般取20~50(點數越多取越大);
  • std_ratio:標準差係數,一般取1.0~2.0(噪聲越嚴重,係數越小,剔除越嚴格)。

1.2 半徑濾波(Radius Outlier Removal)

核心原理

半徑濾波是統計濾波的補充,解決統計濾波對“小噪點簇”無效的問題,核心是基於鄰域點的數量剔除異常點
具體步驟:

  1. 以每個點為球心,設定一個固定半徑r,構建3D球形鄰域;
  2. 統計球形鄰域內的點數量,剔除數量少於min_nn的點;
  3. 即使幾個噪點聚集,其鄰域內的點數仍會遠少於正常點,因此能被有效剔除。

核心用途

專門剔除稀疏小噪點簇(2~5個噪點聚集),與統計濾波組合形成“孤立點+小簇噪點”的全覆蓋剔除,是點雲去噪的黃金組合

關鍵引數

  • radius:球形鄰域半徑(單位:米),一般取0.03~0.1m(根據點雲密度調整,密度越大半徑越小);
  • min_nn:鄰域內最小有效點數,一般取8~15(與radius匹配,半徑越大,最小點數取越大)。

1.3 工業級點雲去噪程式碼逐行解析(你的PLY程式碼)

你提供的程式碼做了很多工業級最佳化(低版本相容、超大點數防記憶體溢位、可選形態學開運算),以下分模組解析核心邏輯,標註關鍵亮點和引數調優技巧。

完整程式碼(帶詳細註釋+最佳化說明)

import open3d as o3d
import numpy as np

def ply_denoise(
    input_ply_path,  # 輸入帶噪PLY檔案路徑
    output_ply_path, # 輸出去噪後PLY檔案路徑
    # 統計濾波引數:去3D孤立點
    stat_nb_neighbors=20,
    stat_std_ratio=2.0,
    # 半徑濾波引數:去稀疏小噪點簇(單位:米)
    rad_radius=0.05,
    rad_min_nn=10,
    # 可選3D形態學開運算(類似2D腐蝕,處理密集小噪點團)
    use_morphology=False,
    morph_voxel_size=0.02,
    # 超大點數點雲專屬:輕量體素下采樣(預設開啟,降低計算量)
    use_down_sample=True,
    down_voxel_size=0.01
):
    """
    PLY點雲檔案去噪:低版本Open3D相容+超大點數點雲最佳化
    核心:統計濾波+半徑濾波,支援帶顏色/無顏色PLY,保留點雲原始資訊
    調優原則:噪聲越嚴重,stat_std_ratio越小、rad_radius越大、rad_min_nn越大
    """
    # 1. 讀取PLY點雲檔案,校驗有效性
    print(f"正在讀取PLY檔案:{input_ply_path}")
    pcd = o3d.io.read_point_cloud(input_ply_path)
    if not pcd.has_points():
        raise ValueError("讀取PLY失敗!檔案損壞或非點雲檔案")
    original_point_num = len(pcd.points)
    print(f"原始點雲點數:{original_point_num:,}")  # 千分位顯示,提升可讀性

    # 2. 核心預處理:無效點+重複點剔除【低版本Open3D相容亮點】
    # remove_infinite=True 適配所有Open3D版本(舊版本無remove_inf)
    pcd = pcd.remove_non_finite_points(remove_nan=True, remove_infinite=True)
    pcd = pcd.remove_duplicated_points()  # 剔除重複點,避免鄰域統計誤差
    preprocess_point_num = len(pcd.points)
    if original_point_num - preprocess_point_num > 0:
        print(f"預處理:剔除{original_point_num - preprocess_point_num:,}個無效/重複點")

    # 3. 超大點數最佳化:體素下采樣【工業級亮點,防記憶體溢位】
    # 點數超100萬開啟,透過體素化降低點數,不影響整體結構
    if use_down_sample and preprocess_point_num > 1000000:
        pcd = pcd.voxel_down_sample(voxel_size=down_voxel_size)
        down_point_num = len(pcd.points)
        print(f"超大點數最佳化:體素下采樣後點數{down_point_num:,}(體素大小{down_voxel_size}m)")
    else:
        down_point_num = preprocess_point_num

    # 4. 核心去噪:統計濾波+半徑濾波【黃金組合,全覆蓋離散噪點】
    # 4.1 統計濾波:去孤立點
    cl, ind = pcd.remove_statistical_outlier(nb_neighbors=stat_nb_neighbors, std_ratio=stat_std_ratio)
    pcd_denoised = pcd.select_by_index(ind)
    stat_remove = down_point_num - len(pcd_denoised.points)
    print(f"統計濾波:剔除{stat_remove:,}個孤立噪點")

    # 4.2 半徑濾波:去稀疏小噪點簇(統計濾波的補充)
    cl, ind = pcd_denoised.remove_radius_outlier(nb_points=rad_min_nn, radius=rad_radius)
    pcd_denoised = pcd_denoised.select_by_index(ind)
    rad_remove = down_point_num - stat_remove - len(pcd_denoised.points)
    print(f"半徑濾波:剔除{rad_remove:,}個稀疏噪點")

    # 5. 可選:3D形態學開運算【處理密集小噪點團,犧牲少量細節】
    # 原理:先腐蝕(剔除小簇噪點)後膨脹(恢復正常點雲結構)
    if use_morphology:
        print(f"開啟3D開運算(腐蝕+膨脹),體素大小{morph_voxel_size}m")
        pcd_down = pcd_denoised.voxel_down_sample(voxel_size=morph_voxel_size)  # 腐蝕
        pcd_down.estimate_normals(search_param=o3d.geometry.KDTreeSearchParamHybrid(radius=morph_voxel_size*2, max_nn=30))  # 估計法向量,為膨脹做準備
        pcd_denoised = pcd_down.voxel_up_sample(voxel_size=morph_voxel_size/3)  # 膨脹

    # 6. 儲存去噪後PLY檔案,支援帶顏色點雲
    o3d.io.write_point_cloud(output_ply_path, pcd_denoised, write_ascii=True)  # write_ascii=True提升相容性
    final_num = len(pcd_denoised.points)
    total_remove = original_point_num - final_num
    print(f"\n去噪完成!總剔除{total_remove:,}個噪點,剩餘有效點數{final_num:,}")
    print(f"去噪後檔案已儲存:{output_ply_path}")

    # 7. 視覺化:超大點數建議關閉(避免卡頓)
    o3d.visualization.draw_geometries([pcd_denoised], window_name="PLY去噪結果", width=800, height=600)

    return pcd_denoised

# 主函式:僅需修改輸入輸出路徑,預設引數適配80%場景
if __name__ == "__main__":
    INPUT_PLY = "simulated_depth_scan.ply"  # 你的帶噪PLY檔案路徑
    OUTPUT_PLY = "denoised_clean.ply"       # 去噪後儲存路徑
    ply_denoise(
        input_ply_path=INPUT_PLY,
        output_ply_path=OUTPUT_PLY,
        # 噪聲嚴重時的調優示例
        # stat_nb_neighbors=25,  # 增加近鄰數,統計更穩健
        # stat_std_ratio=1.5,    # 減小系數,剔除更嚴格
        # rad_radius=0.06,       # 增大半徑,檢測更多稀疏簇
        # rad_min_nn=12,         # 增加最小點數,剔除更嚴格
        # use_morphology=True,   # 有密集小噪點團時開啟
        # morph_voxel_size=0.02
    )

核心模組解析

  1. 無效點/重複點剔除:Open3D的remove_non_finite_points是核心,修復了低版本相容問題(將remove_inf改為remove_infinite),重複點會導致鄰域統計偏差,必須剔除;
  2. 超大點數體素下采樣:針對500萬+的點雲,體素下采樣能在不破壞整體結構的前提下降低點數,避免後續濾波的記憶體溢位和卡頓,是工業級程式碼的關鍵最佳化;
  3. 統計+半徑濾波組合:先剔除孤立點,再剔除稀疏小簇,兩者互補,覆蓋了絕大多數離散噪點場景;
  4. 可選3D形態學開運算:原理是“腐蝕+膨脹”,適合處理密集小噪點團(比如10個左右噪點緊密聚集),但會損失少量細節,因此設為可選。

調優黃金原則

噪聲越嚴重(比如深度相機距離目標過遠、光照複雜),按以下方式調參:

  • 統計濾波:stat_std_ratio調小(1.01.5)、stat\_nb\_neighbors調大(3050);
  • 半徑濾波:rad_radius調大(0.060.1m)、rad\_min\_nn調大(1220);
  • 密集噪點團:開啟use_morphologymorph_voxel_size取0.02~0.05m(根據點雲密度調整)。

第二部分:深度圖去噪核心——雙邊濾波(Bilateral Filter)

統計濾波和半徑濾波是點雲3D層面的去噪,適合剔除離散噪點,但無法處理深度圖2D層面的高斯噪聲/均勻噪聲(這類噪聲會讓深度圖整體粗糙,無明顯離散點)。

而雙邊濾波是深度圖去噪的經典演算法,核心優勢是邊緣保留的平滑去噪——普通高斯濾波會模糊物體邊緣,而雙邊濾波能在平滑噪聲的同時,完整保留深度圖的邊緣輪廓(比如桌子和牆面的交界、物體的輪廓),這對後續的3D重建至關重要。

2.1 為什麼高斯濾波不適合深度圖?

在講雙邊濾波前,先理解高斯濾波的侷限性:
高斯濾波是空域唯一的濾波,其權重僅由畫素的空間距離決定——距離越近,權重越大,參與濾波的貢獻越高。

公式: G σ ( x , y )

1 2 π σ 2 e − x 2 + y 2 2 σ 2 G_\sigma(x,y) = \frac{1}{2\pi\sigma^2}e^{-\frac{x^2+y^2}{2\sigma^2}} Gσ​(x,y)=2πσ21​e−2σ2x2+y2​

問題在於:深度圖的邊緣處,相鄰畫素的深度值差異極大(比如牆面深度1m,桌子深度0.5m),高斯濾波會將邊緣兩側的畫素混合,導致邊緣模糊,而邊緣是3D場景的核心結構資訊,模糊後會嚴重影響後續的點雲生成和3D重建。

2.2 雙邊濾波的核心原理

雙邊濾波的核心創新是:將空域核(Spatial Kernel)和值域核(Range Kernel)結合,濾波權重由空間距離深度值相似性共同決定。

只有滿足兩個條件的畫素,才會參與當前畫素的濾波計算:

  1. 空間近:畫素在當前畫素的鄰域內(空域核控制);
  2. 深度值相似:畫素的深度值與當前畫素的深度值差異小(值域核控制)。

這樣一來,邊緣兩側的畫素因深度值差異大,不會互相參與濾波,從而保留邊緣;而同一區域內的畫素因空間近且深度值相似,會被平滑濾波,從而去除噪聲

雙邊濾波的數學公式

對於深度圖中的畫素 p ( x p , y p ) p(x_p,y_p) p(xp​,yp​),其濾波後的深度值 I ( p ) I(p) I(p)為:

I ( p )

1 W p ∑ q ∈ N ( p ) w ( p , q ) ⋅ I ( q ) I(p) = \frac{1}{W_p} \sum_{q \in N(p)} w(p,q) \cdot I(q) I(p)=Wp​1​q∈N(p)∑​w(p,q)⋅I(q)
其中:

  • N ( p ) N(p) N(p):畫素 p p p的鄰域(比如3×3、5×5);
  • W p

    ∑ q ∈ N ( p ) w ( p , q ) W_p = \sum_{q \in N(p)} w(p,q) Wp​=∑q∈N(p)​w(p,q):歸一化權重,保證濾波後深度值範圍不變;
  • w ( p , q )

    w s ( p , q ) ⋅ w r ( p , q ) w(p,q) = w_s(p,q) \cdot w_r(p,q) w(p,q)=ws​(p,q)⋅wr​(p,q):聯合權重,由空域核和值域核相乘得到。
1. 空域核(Spatial Kernel)

與高斯濾波一致,控制空間距離的權重, σ s \sigma_s σs​為空域標準差

w s ( p , q )

e − ∥ p − q ∥ 2 2 σ s 2 w_s(p,q) = e^{-\frac{|p-q|^2}{2\sigma_s^2}} ws​(p,q)=e−2σs2​∥p−q∥2​

  • ∥ p − q ∥ |p-q| ∥p−q∥:畫素 p p p和 q q q的歐式距離;
  • σ s \sigma_s σs​越大,參與濾波的空間範圍越廣,平滑效果越強。
2. 值域核(Range Kernel)

控制深度值相似性的權重, σ r \sigma_r σr​為值域標準差, I ( p ) I(p) I(p)、 I ( q ) I(q) I(q)為畫素 p p p、 q q q的深度值:

w r ( p , q )

e − ∥ I ( p ) − I ( q ) ∥ 2 2 σ r 2 w_r(p,q) = e^{-\frac{|I(p)-I(q)|^2}{2\sigma_r^2}} wr​(p,q)=e−2σr2​∥I(p)−I(q)∥2​

  • ∥ I ( p ) − I ( q ) ∥ |I(p)-I(q)| ∥I(p)−I(q)∥:畫素 p p p和 q q q的深度值差異;
  • σ r \sigma_r σr​越大,對深度值差異的容忍度越高,平滑效果越強,但邊緣保留越弱; σ r \sigma_r σr​越小,邊緣保留越嚴格,平滑效果越弱。

雙邊濾波的核心特點

  1. 邊緣保留:最核心的優勢,適合深度圖、彩色影象等需要保留邊緣的場景;
  2. 區域性性:僅利用鄰域畫素進行濾波,計算速度快,非迭代;
  3. 非線性:因值域核的存在,雙邊濾波是非線性濾波(高斯濾波是線性);
  4. 無引數迭代:僅需調優 σ s \sigma_s σs​和 σ r \sigma_r σr​,無需複雜的迭代引數。

2.3 雙邊濾波的核心用途

  1. 深度圖去噪:平滑高斯噪聲、均勻噪聲,保留物體邊緣,是深度圖預處理的首選演算法
  2. 彩色影象去噪:邊緣保留的平滑去噪,避免影象模糊;
  3. 點雲平滑:Open3D提供了點雲層面的雙邊濾波,可對3D點雲進行邊緣保留的平滑;
  4. 深度圖空洞填充:結合鄰域深度值相似性,對小空洞進行合理填充。

2.4 雙邊濾波的實現(2D深度圖+3D點雲)

雙邊濾波的實現分兩種場景:2D深度圖層面(預處理,效果最好)和3D點雲層面(後處理,最佳化點雲平滑度),以下分別實現,且與前文的統計+半徑濾波結合。

2.4.1 實現1:2D深度圖的雙邊濾波(OpenCV+NumPy)

OpenCV提供了現成的cv2.bilateralFilter函式,專門用於雙邊濾波,適配深度圖的16位uint16格式(深度相機採集的深度圖預設格式),無需手寫複雜的卷積邏輯,直接呼叫即可。

核心程式碼(深度圖讀取+雙邊濾波+視覺化)
import cv2
import numpy as np
import matplotlib.pyplot as plt

def depth_bilateral_filter(
    depth_img_path,
    output_depth_path,
    d=5,        # 濾波鄰域直徑,奇數(3/5/7),越大平滑範圍越廣
    sigmaColor=10,  # 值域標準差σr,深度值相似性權重
    sigmaSpace=10   # 空域標準差σs,空間距離權重
):
    """
    2D深度圖雙邊濾波:邊緣保留平滑去噪,適配16位uint16深度圖
    調優原則:噪聲越嚴重,d/ sigmaSpace越大;需強邊緣保留,sigmaColor越小
    """
    # 1. 讀取深度圖(深度相機採集的深度圖為16位uint16,單通道)
    depth = cv2.imread(depth_img_path, cv2.IMREAD_UNCHANGED)
    if depth is None:
        raise ValueError("讀取深度圖失敗!檔案損壞或非深度圖檔案")
    # 轉換為float32,避免濾波時數值溢位
    depth_float = depth.astype(np.float32)
    print(f"深度圖尺寸:{depth.shape},深度值範圍:{np.min(depth)}~{np.max(depth)}")

    # 2. 雙邊濾波核心呼叫
    # cv2.bilateralFilter:src-輸入影象,d-鄰域直徑,sigmaColor-值域σ,sigmaSpace-空域σ
    # 注意:深度圖為單通道,彩色圖為3通道,函式自動適配
    depth_denoised = cv2.bilateralFilter(depth_float, d=d, sigmaColor=sigmaColor, sigmaSpace=sigmaSpace)
    # 轉換回16位uint16,儲存為原始深度圖格式
    depth_denoised = depth_denoised.astype(np.uint16)

    # 3. 儲存濾波後的深度圖
    cv2.imwrite(output_depth_path, depth_denoised)
    print(f"雙邊濾波後的深度圖已儲存:{output_depth_path}")

    # 4. 視覺化濾波效果(對比原始和去噪後的深度圖)
    plt.figure(figsize=(12, 6))
    # 原始深度圖
    plt.subplot(1, 2, 1)
    plt.imshow(depth, cmap="jet")
    plt.title("Original Depth Map", fontsize=14)
    plt.axis("off")
    plt.colorbar()
    # 去噪後深度圖
    plt.subplot(1, 2, 2)
    plt.imshow(depth_denoised, cmap="jet")
    plt.title("Denoised Depth Map (Bilateral Filter)", fontsize=14)
    plt.axis("off")
    plt.colorbar()
    plt.tight_layout()
    plt.show()

    return depth_denoised

# 主函式呼叫
if __name__ == "__main__":
    INPUT_DEPTH = "depth_noise.png"  # 你的帶噪深度圖(16位uint16)
    OUTPUT_DEPTH = "depth_denoised.png"  # 濾波後深度圖
    depth_bilateral_filter(
        depth_img_path=INPUT_DEPTH,
        output_depth_path=OUTPUT_DEPTH,
        d=5,           # 5×5鄰域,適中的平滑範圍
        sigmaColor=15, # 值域σ,對深度值差異的容忍度適中
        sigmaSpace=15  # 空域σ,空間鄰域範圍適中
    )
關鍵引數調優

cv2.bilateralFilter的3個核心引數,調優直接決定濾波效果,黃金調優原則

  1. d(鄰域直徑):取3/5/7(奇數),噪聲越嚴重取越大,過大會導致輕微邊緣模糊;
  2. sigmaColor(值域 σ r \sigma_r σr​):深度圖一般取1030,**需強邊緣保留則取小值(510)**,需強平滑則取大值(20~30);
  3. sigmaSpace(空域 σ s \sigma_s σs​):與sigmaColor匹配,一般取相同值,10~30即可。
效果說明

濾波後,深度圖的區域性噪聲會被平滑(比如同一平面的粗糙點),而物體邊緣會被完整保留(比如深度值突變的區域),這是高斯濾波無法實現的效果。

2.4.2 實現2:3D點雲的雙邊濾波(Open3D)

Open3D提供了點雲層面的雙邊濾波函式filter_smooth_bilateral,可對3D點雲進行邊緣保留的平滑,適合將深度圖轉點雲後,進一步最佳化點雲的平滑度,可與前文的統計+半徑濾波組合使用。

核心程式碼(點雲雙邊濾波+統計+半徑濾波組合)
import open3d as o3d
import numpy as np

def pcd_bilateral_denoise(
    input_ply_path,
    output_ply_path,
    # 雙邊濾波引數
    bilateral_sigma1=0.01,  # 空域σ,點雲空間距離權重
    bilateral_sigma2=0.05,  # 值域σ,點雲法向量/深度值相似性權重
    # 統計+半徑濾波引數(複用前文)
    stat_nb_neighbors=20,
    stat_std_ratio=2.0,
    rad_radius=0.05,
    rad_min_nn=10
):
    """
    3D點雲全流程去噪:雙邊濾波(平滑)+ 統計濾波+半徑濾波(剔離散噪點)
    先平滑,再剔噪點,效果優於單獨使用某一種濾波
    """
    # 1. 讀取點雲並預處理
    pcd = o3d.io.read_point_cloud(input_ply_path)
    if not pcd.has_points():
        raise ValueError("讀取點雲失敗!")
    print(f"原始點雲點數:{len(pcd.points):,}")
    # 剔除無效/重複點
    pcd = pcd.remove_non_finite_points(remove_nan=True, remove_infinite=True)
    pcd = pcd.remove_duplicated_points()

    # 2. 點雲雙邊濾波【邊緣保留平滑】
    # 先估計法向量(雙邊濾波需要法向量計算值域相似性)
    pcd.estimate_normals(search_param=o3d.geometry.KDTreeSearchParamHybrid(radius=0.1, max_nn=30))
    # 雙邊濾波核心呼叫
    pcd_smooth = pcd.filter_smooth_bilateral(sigma1=bilateral_sigma1, sigma2=bilateral_sigma2)
    print(f"雙邊濾波後點雲點數:{len(pcd_smooth.points):,}")

    # 3. 統計+半徑濾波【剔除離散噪點】
    cl, ind = pcd_smooth.remove_statistical_outlier(nb_neighbors=stat_nb_neighbors, std_ratio=stat_std_ratio)
    pcd_denoised = pcd_smooth.select_by_index(ind)
    cl, ind = pcd_denoised.remove_radius_outlier(nb_points=rad_min_nn, radius=rad_radius)
    pcd_denoised = pcd_denoised.select_by_index(ind)

    # 4. 儲存並視覺化
    o3d.io.write_point_cloud(output_ply_path, pcd_denoised)
    print(f"去噪後點雲點數:{len(pcd_denoised.points):,},已儲存至:{output_ply_path}")
    o3d.visualization.draw_geometries([pcd_denoised], window_name="點雲雙邊濾波+統計半徑濾波結果")

    return pcd_denoised

# 主函式呼叫
if __name__ == "__main__":
    INPUT_PLY = "simulated_depth_scan.ply"
    OUTPUT_PLY = "pcd_bilateral_denoised.ply"
    pcd_bilateral_denoise(
        input_ply_path=INPUT_PLY,
        output_ply_path=OUTPUT_PLY,
        bilateral_sigma1=0.01,
        bilateral_sigma2=0.05
    )
點雲雙邊濾波引數說明
  • sigma1:空域標準差,控制點雲的空間距離權重,一般取0.01~0.05m(根據點雲密度調整);
  • sigma2:值域標準差,控制點雲的法向量/深度值相似性權重,一般取0.05~0.1m,越大平滑效果越強。

注意:點雲雙邊濾波前必須呼叫estimate_normals估計法向量,因為Open3D的點雲雙邊濾波是基於法向量相似性計算值域權重的,法向量估計的精度會影響濾波效果。

第三部分:工業級全流程——深度圖→點雲的聯合去噪

實際專案中,深度相機採集的是2D深度圖,需要先將深度圖轉換為3D點雲,再進行後續處理。因此最優的去噪流程是:
深度圖雙邊濾波(2D層面平滑,保留邊緣)→ 深度圖轉點雲 → 點雲統計+半徑濾波(3D層面剔離散噪點)

該流程結合了雙邊濾波的邊緣保留平滑和統計+半徑濾波的離散噪點剔除,是工業級深度圖/點雲去噪的黃金流程,以下實現完整程式碼。

3.1 核心原理:深度圖轉點雲

深度圖轉點雲的核心是相機內參,對於畫素 ( u , v ) (u,v) (u,v),其深度值為 z z z,對應的3D空間座標 ( X , Y , Z ) (X,Y,Z) (X,Y,Z)為:

X

( u − c x ) × z f x X = (u - cx) \times \frac{z}{fx} X=(u−cx)×fxz​

Y

( v − c y ) × z f y Y = (v - cy) \times \frac{z}{fy} Y=(v−cy)×fyz​

Z

z Z = z Z=z
其中:

  • ( f x , f y ) (fx, fy) (fx,fy):相機的焦距(畫素單位);
  • ( c x , c y ) (cx, cy) (cx,cy):相機的主點座標(畫素單位);
  • 以上引數可從深度相機的說明書或SDK中獲取(比如Intel RealSense的fx≈600,fy≈600,cx≈320,cy≈240)。

3.2 全流程完整程式碼(深度圖雙邊濾波→轉點雲→點雲去噪)

import cv2
import numpy as np
import open3d as o3d

# 第一步:深度圖雙邊濾波(複用前文函式)
def depth_bilateral_filter(depth_img_path, d=5, sigmaColor=15, sigmaSpace=15):
    depth = cv2.imread(depth_img_path, cv2.IMREAD_UNCHANGED)
    depth_float = depth.astype(np.float32)
    depth_denoised = cv2.bilateralFilter(depth_float, d=d, sigmaColor=sigmaColor, sigmaSpace=sigmaSpace)
    return depth_denoised.astype(np.uint16)

# 第二步:深度圖轉點雲(基於相機內參)
def depth2pcd(depth_img, fx=615.0, fy=615.0, cx=320.0, cy=240.0, scale=1000.0):
    """
    深度圖轉3D點雲
    引數:
        depth_img: 16位uint16深度圖
        fx/fy: 相機焦距(畫素單位),預設適配Intel RealSense D435
        cx/cy: 相機主點座標,預設適配640×480解析度
        scale: 深度值縮放係數(深度圖值為mm,轉m需除以1000)
    """
    h, w = depth_img.shape
    # 生成畫素座標網格
    u, v = np.meshgrid(np.arange(w), np.arange(h))
    # 計算3D空間座標
    z = depth_img / scale  # 轉米單位
    x = (u - cx) * z / fx
    y = (v - cy) * z / fy
    # 拼接點雲座標,剔除深度值為0的無效點
    points = np.stack([x, y, z], axis=-1).reshape(-1, 3)
    valid_mask = z.reshape(-1) > 0  # 剔除深度為0的點
    points = points[valid_mask]
    # 建立Open3D點雲物件
    pcd = o3d.geometry.PointCloud()
    pcd.points = o3d.utility.Vector3dVector(points)
    print(f"深度圖轉點雲完成,有效點數:{len(pcd.points):,}")
    return pcd

# 第三步:點雲統計+半徑濾波(複用前文工業級函式)
def ply_denoise(pcd, stat_nb_neighbors=20, stat_std_ratio=2.0, rad_radius=0.05, rad_min_nn=10):
    pcd = pcd.remove_non_finite_points(remove_nan=True, remove_infinite=True)
    pcd = pcd.remove_duplicated_points()
    # 統計濾波
    cl, ind = pcd.remove_statistical_outlier(nb_neighbors=stat_nb_neighbors, std_ratio=stat_std_ratio)
    pcd_denoised = pcd.select_by_index(ind)
    # 半徑濾波
    cl, ind = pcd_denoised.remove_radius_outlier(nb_points=rad_min_nn, radius=rad_radius)
    pcd_denoised = pcd_denoised.select_by_index(ind)
    return pcd_denoised

# 主流程:深度圖雙邊濾波 → 轉點雲 → 點雲去噪 → 儲存視覺化
if __name__ == "__main__":
    # 配置引數
    INPUT_DEPTH = "depth_noise.png"    # 輸入帶噪深度圖
    OUTPUT_DEPTH = "depth_denoised.png"# 輸出濾波後深度圖
    OUTPUT_PLY = "final_denoised.ply" # 輸出最終點雲
    # 相機內參(根據你的深度相機修改!)
    FX, FY = 615.0, 615.0
    CX, CY = 320.0, 240.0

    # 1. 深度圖雙邊濾波
    depth_denoised = depth_bilateral_filter(INPUT_DEPTH, d=5, sigmaColor=15, sigmaSpace=15)
    cv2.imwrite(OUTPUT_DEPTH, depth_denoised)

    # 2. 濾波後的深度圖轉點雲
    pcd = depth2pcd(depth_denoised, fx=FX, fy=FY, cx=CX, cy=CY)

    # 3. 點雲統計+半徑濾波
    pcd_final = ply_denoise(pcd)

    # 4. 儲存並視覺化最終點雲
    o3d.io.write_point_cloud(OUTPUT_PLY, pcd_final)
    print(f"全流程去噪完成,最終點雲已儲存:{OUTPUT_PLY},點數:{len(pcd_final.points):,}")
    o3d.visualization.draw_geometries([pcd_final], window_name="深度圖+點雲全流程去噪結果")

關鍵注意事項

  1. 相機內參修改:程式碼中的fx/fy/cx/cy是預設值,需根據你的深度相機實際引數修改(可從相機SDK、標定工具中獲取),否則點雲會出現畸變;
  2. 深度值縮放:深度相機採集的深度圖值一般為毫米(mm),程式碼中scale=1000將其轉為米(m),若你的深度圖單位是米,需將scale改為1;
  3. 解析度匹配:內參與深度圖解析度一一對應(比如640×480的深度圖對應cx=320,cy=240),若深度圖解析度為1280×720,需調整cx/cy為640/360。

第四部分:各濾波演算法對比與適用場景總結

為了讓你在實際專案中快速選擇合適的濾波演算法,以下對統計濾波、半徑濾波、雙邊濾波進行全方位對比,明確各自的適用場景和優缺點:

濾波演算法處理層面核心功能優點缺點適用場景
統計濾波3D點雲剔除孤立點/椒鹽噪聲計算快、引數少、適配所有點雲對小噪點簇無效點雲初步去噪,剔除離散單點噪聲
半徑濾波3D點雲剔除稀疏小噪點簇補充統計濾波,處理小簇噪點半徑引數需根據點雲密度調整統計濾波後二次去噪,剔除稀疏簇噪聲
雙邊濾波(2D)2D深度圖邊緣保留的平滑去噪保留邊緣、平滑高斯/均勻噪聲、效果最好對離散點無效深度圖預處理,平滑整體噪聲,保留場景結構
雙邊濾波(3D)3D點雲邊緣保留的點雲平滑最佳化點雲平滑度,保留3D邊緣需估計法向量、對離散點無效點雲後處理,最佳化平滑度(配合統計+半徑濾波)

黃金組合策略

  1. 深度圖預處理:優先使用2D雙邊濾波,平滑噪聲並保留邊緣,這是最關鍵的一步;
  2. 點雲去噪:深度圖轉點雲後,使用統計濾波+半徑濾波組合,剔除離散噪點;
  3. 點雲最佳化:若點雲整體粗糙,可在統計+半徑濾波前增加3D雙邊濾波,實現平滑+剔噪的雙重效果;
  4. 密集噪點團:若存在密集小噪點團,開啟3D形態學開運算(犧牲少量細節)。

第五部分:常見問題與解決方案

  1. 深度圖濾波後出現空洞:深度圖本身存在的空洞,雙邊濾波無法填充,可使用cv2.inpaint進行空洞填充,或在點雲層面使用o3d.geometry.PointCloud.voxel_up_sample補點;
  2. 點雲視覺化卡頓/記憶體溢位:開啟體素下采樣(voxel_down_sample),降低點雲點數,體素大小取0.01~0.05m;
  3. 雙邊濾波後邊緣模糊:減小sigmaColor(值域σ)或d(鄰域直徑),增強邊緣保留效果;
  4. 統計+半徑濾波後丟失有效點:調大stat_std_ratio、減小rad_radiusrad_min_nn,降低剔除嚴格度;
  5. 深度圖轉點雲後點雲畸變:檢查相機內參是否正確,確保內參與深度圖解析度、相機型號匹配。

總結

深度圖/點雲去噪是3D計算機視覺的基礎預處理步驟,其核心是針對性選擇濾波演算法

  1. 雙邊濾波是深度圖去噪的核心,憑藉邊緣保留的平滑特性,完勝傳統的高斯濾波,是2D層面去噪的首選;
  2. 統計濾波+半徑濾波是點雲去噪的黃金組合,能全覆蓋剔除3D層面的孤立點和稀疏小噪點簇,且計算高效、引數易調;
  3. 工業級的最優流程是深度圖雙邊濾波→轉點雲→點雲統計+半徑濾波,結合了2D層面的平滑和3D層面的剔噪,兼顧效果和效率。

本文的所有程式碼均為開箱即用,適配大部分深度相機(RealSense、Kinect、LiDAR)採集的資料,只需根據實際場景微調引數,即可應用於3D重建、點雲配準、目標檢測等實際專案中。

Depth maps & point-cloud denoising in practice: bilateral filtering + statistical/radius filters with Open3D

Depth cameras (RealSense, Kinect, LiDAR) always inject noise—salt-and-pepper speckles, isolated outliers, sparse clutter, Gaussian-ish ripple. That noise hurts 3D reconstruction, registration, segmentation.

Captured at (ISO local): 2026-05-18 05:17:36


Foreword

Depth cameras (RealSense, Kinect, LiDAR) always inject noise—salt-and-pepper speckles, isolated outliers, sparse clutter, Gaussian-ish ripple. That noise hurts 3D reconstruction, registration, segmentation. Targeted denoising is therefore a first-class preprocessing step.

This article is practical: first statistical and radius outlier removal on point clouds (with industrial Open3D code you can paste), then bilateral filtering on depth maps (why it beats plain Gaussian blur), and finally a depth bilateral → point cloud → statistical/radius pipeline that preserves edges while killing outliers—good default for many deployed vision stacks.

Image description

Prerequisites

Dependencies

pip install open3d numpy opencv-python matplotlib
  • open3d: filters, IO, visualization
  • numpy: array plumbing
  • opencv-python: 2D bilateral on depth
  • matplotlib: quick depth previews

Noise taxonomy (what to use when)

  1. Isolated shot noise → statistical outlier removal
  2. Tiny clumps of outliers → radius outlier removal
  3. High-frequency ripple on surfaces → bilateral (edge-preserving smooth)
  4. Tight debris bundles → optional 3D morphological opening (loses a little detail)

Part 1 — statistical & radius filtering

The winning combo is statistical OR + radius OR: plus optional defensive steps for huge clouds and old Open3D builds.

1.1 Statistical Outlier Removal (SOR)

Idea: for each point, average distance to its k nearest neighbors. Across the cloud those averages behave ~Gaussian; drop points whose average distance exceeds (\mu + \text{std_ratio}\cdot\sigma).

Use: single-point outliers, salt-and-pepper in 3D—your first point-cloud filter in most recipes.

Knobs

  • nb_neighbors: often 20–50 (raise with density)
  • std_ratio: 1.0–2.0 (lower = stricter)

1.2 Radius Outlier Removal (ROR)

Idea: count neighbors inside radius r; delete points with fewer than min_nn neighbors.

Use: small outlier clumps that survive SOR.

Knobs

  • radius: 0.03–0.1 m (tighten with dense clouds)
  • min_nn: 8–15 (raise with larger radius)

1.3 Production-ish PLY cleaner (annotated)

import open3d as o3d
import numpy as np

def ply_denoise(
    input_ply_path,
    output_ply_path,
    stat_nb_neighbors=20,
    stat_std_ratio=2.0,
    rad_radius=0.05,
    rad_min_nn=10,
    use_morphology=False,
    morph_voxel_size=0.02,
    use_down_sample=True,
    down_voxel_size=0.01
):
    print(f"Reading PLY: {input_ply_path}")
    pcd = o3d.io.read_point_cloud(input_ply_path)
    if not pcd.has_points():
        raise ValueError("Invalid / empty point cloud")
    original_point_num = len(pcd.points)
    print(f"Raw points: {original_point_num:,}")

    pcd = pcd.remove_non_finite_points(remove_nan=True, remove_infinite=True)
    pcd = pcd.remove_duplicated_points()
    preprocess_point_num = len(pcd.points)
    if original_point_num - preprocess_point_num > 0:
        print(f"Preprocess removed {original_point_num - preprocess_point_num:,} bad/duplicate pts")

    if use_down_sample and preprocess_point_num > 1000000:
        pcd = pcd.voxel_down_sample(voxel_size=down_voxel_size)
        down_point_num = len(pcd.points)
        print(f"Downsampled to {down_point_num:,} (voxel {down_voxel_size}m)")
    else:
        down_point_num = preprocess_point_num

    cl, ind = pcd.remove_statistical_outlier(nb_neighbors=stat_nb_neighbors, std_ratio=stat_std_ratio)
    pcd_denoised = pcd.select_by_index(ind)
    stat_remove = down_point_num - len(pcd_denoised.points)
    print(f"Statistical OR removed {stat_remove:,}")

    cl, ind = pcd_denoised.remove_radius_outlier(nb_points=rad_min_nn, radius=rad_radius)
    pcd_denoised = pcd_denoised.select_by_index(ind)
    rad_remove = down_point_num - stat_remove - len(pcd_denoised.points)
    print(f"Radius OR removed {rad_remove:,}")

    if use_morphology:
        print(f"Morphological opening @ voxel {morph_voxel_size}m")
        pcd_down = pcd_denoised.voxel_down_sample(voxel_size=morph_voxel_size)
        pcd_down.estimate_normals(search_param=o3d.geometry.KDTreeSearchParamHybrid(radius=morph_voxel_size*2, max_nn=30))
        pcd_denoised = pcd_down.voxel_up_sample(voxel_size=morph_voxel_size/3)

    o3d.io.write_point_cloud(output_ply_path, pcd_denoised, write_ascii=True)
    final_num = len(pcd_denoised.points)
    total_remove = original_point_num - final_num
    print(f"Done: removed {total_remove:,} total; kept {final_num:,}")
    o3d.visualization.draw_geometries([pcd_denoised], window_name="PLY denoise", width=800, height=600)
    return pcd_denoised

if __name__ == "__main__":
    ply_denoise("simulated_depth_scan.ply", "denoised_clean.ply")

Tuning heuristics

Aggressive noise / far range / bad light:

  • Lower stat_std_ratio, raise stat_nb_neighbors
  • Raise rad_radius and rad_min_nn
  • Enable morphology if dense clutter remains

Part 2 — bilateral filtering (depth)

SOR/ROR operate on discrete outliers. Bilateral filtering attacks high-frequency noise on continuous surfaces while preserving depth discontinuities (table edges, object silhouettes)—critical before back-projecting to 3D.

2.1 Why naive Gaussian blur hurts depth

Gaussian weights depend only on pixel distance, so pixels across a depth edge—very different Z—get averaged. Edges smear, and later point clouds lose structure. Bilateral adds a range term so pixels with very different depth do not blend.

2.2 Core formula (conceptual)

For depth pixel (p), neighborhood (N(p)):

[ I(p)=\frac{1}{W_p}\sum_{q\in N(p)} w_s(p,q),w_r(p,q),I(q) ]

  • (w_s): spatial Gaussian (pixel distance)
  • (w_r): range Gaussian on (|I(p)-I(q)|)
  • Product (w=w_s w_r) enforces “near in space and similar in depth”

2.3 OpenCV on uint16 depth

import cv2
import numpy as np
import matplotlib.pyplot as plt

def depth_bilateral_filter(depth_img_path, output_depth_path, d=5, sigmaColor=10, sigmaSpace=10):
    depth = cv2.imread(depth_img_path, cv2.IMREAD_UNCHANGED)
    if depth is None:
        raise ValueError("Failed to read depth")
    depth_float = depth.astype(np.float32)
    depth_denoised = cv2.bilateralFilter(depth_float, d=d, sigmaColor=sigmaColor, sigmaSpace=sigmaSpace)
    depth_denoised = depth_denoised.astype(np.uint16)
    cv2.imwrite(output_depth_path, depth_denoised)

    plt.figure(figsize=(12, 6))
    plt.subplot(1, 2, 1); plt.imshow(depth, cmap="jet"); plt.title("Original"); plt.axis("off"); plt.colorbar()
    plt.subplot(1, 2, 2); plt.imshow(depth_denoised, cmap="jet"); plt.title("Bilateral"); plt.axis("off"); plt.colorbar()
    plt.tight_layout(); plt.show()
    return depth_denoised

if __name__ == "__main__":
    depth_bilateral_filter("depth_noise.png", "depth_denoised.png", d=5, sigmaColor=15, sigmaSpace=15)

Parameter tips

  • d: odd diameter (3/5/7)—larger = more smoothing
  • sigmaColor: “range σ”—lower to protect sharp depth jumps
  • sigmaSpace: “spatial σ”—often matched to sigmaColor

2.4 Open3D bilateral + SOR/ROR (full script)

import open3d as o3d
import numpy as np

def pcd_bilateral_denoise(
    input_ply_path,
    output_ply_path,
    bilateral_sigma1=0.01,
    bilateral_sigma2=0.05,
    stat_nb_neighbors=20,
    stat_std_ratio=2.0,
    rad_radius=0.05,
    rad_min_nn=10
):
    pcd = o3d.io.read_point_cloud(input_ply_path)
    if not pcd.has_points():
        raise ValueError("Failed to read point cloud")
    print(f"Points: {len(pcd.points):,}")
    pcd = pcd.remove_non_finite_points(remove_nan=True, remove_infinite=True)
    pcd = pcd.remove_duplicated_points()

    pcd.estimate_normals(search_param=o3d.geometry.KDTreeSearchParamHybrid(radius=0.1, max_nn=30))
    pcd_smooth = pcd.filter_smooth_bilateral(sigma1=bilateral_sigma1, sigma2=bilateral_sigma2)
    print(f"After bilateral: {len(pcd_smooth.points):,}")

    cl, ind = pcd_smooth.remove_statistical_outlier(nb_neighbors=stat_nb_neighbors, std_ratio=stat_std_ratio)
    pcd_denoised = pcd_smooth.select_by_index(ind)
    cl, ind = pcd_denoised.remove_radius_outlier(nb_points=rad_min_nn, radius=rad_radius)
    pcd_denoised = pcd_denoised.select_by_index(ind)

    o3d.io.write_point_cloud(output_ply_path, pcd_denoised)
    print(f"Final: {len(pcd_denoised.points):,} -> {output_ply_path}")
    o3d.visualization.draw_geometries([pcd_denoised], window_name="Bilateral + SOR/ROR")
    return pcd_denoised

if __name__ == "__main__":
    pcd_bilateral_denoise("simulated_depth_scan.ply", "pcd_bilateral_denoised.ply",
                          bilateral_sigma1=0.01, bilateral_sigma2=0.05)

2.5 Notes on 3D bilateral parameters

  • sigma1: spatial σ in meters—typical 0.01–0.05 depending on cloud density.
  • sigma2: “range” σ controlling normal/appearance similarity—often 0.05–0.1; larger = smoother.
  • You must estimate normals before filter_smooth_bilateral in Open3D; normal quality drives perceived sharpness.
import cv2
import numpy as np
import open3d as o3d

def depth_bilateral_filter(depth_img_path, d=5, sigmaColor=15, sigmaSpace=15):
    depth = cv2.imread(depth_img_path, cv2.IMREAD_UNCHANGED)
    depth_float = depth.astype(np.float32)
    depth_denoised = cv2.bilateralFilter(depth_float, d=d, sigmaColor=sigmaColor, sigmaSpace=sigmaSpace)
    return depth_denoised.astype(np.uint16)

def depth2pcd(depth_img, fx=615.0, fy=615.0, cx=320.0, cy=240.0, scale=1000.0):
    h, w = depth_img.shape
    u, v = np.meshgrid(np.arange(w), np.arange(h))
    z = depth_img / scale
    x = (u - cx) * z / fx
    y = (v - cy) * z / fy
    points = np.stack([x, y, z], axis=-1).reshape(-1, 3)
    valid = z.reshape(-1) > 0
    points = points[valid]
    pcd = o3d.geometry.PointCloud()
    pcd.points = o3d.utility.Vector3dVector(points)
    print(f"Valid points: {len(pcd.points):,}")
    return pcd

def ply_denoise(pcd, stat_nb_neighbors=20, stat_std_ratio=2.0, rad_radius=0.05, rad_min_nn=10):
    pcd = pcd.remove_non_finite_points(remove_nan=True, remove_infinite=True)
    pcd = pcd.remove_duplicated_points()
    cl, ind = pcd.remove_statistical_outlier(nb_neighbors=stat_nb_neighbors, std_ratio=stat_std_ratio)
    pcd_d = pcd.select_by_index(ind)
    cl, ind = pcd_d.remove_radius_outlier(nb_points=rad_min_nn, radius=rad_radius)
    return pcd_d.select_by_index(ind)

if __name__ == "__main__":
    INPUT_DEPTH = "depth_noise.png"
    OUTPUT_DEPTH = "depth_denoised.png"
    OUTPUT_PLY = "final_denoised.ply"
    FX, FY = 615.0, 615.0
    CX, CY = 320.0, 240.0

    depth_denoised = depth_bilateral_filter(INPUT_DEPTH)
    cv2.imwrite(OUTPUT_DEPTH, depth_denoised)

    pcd = depth2pcd(depth_denoised, fx=FX, fy=FY, cx=CX, cy=CY)
    pcd_final = ply_denoise(pcd)
    o3d.io.write_point_cloud(OUTPUT_PLY, pcd_final)
    o3d.visualization.draw_geometries([pcd_final], window_name="depth→PC denoise")

Practical notes

  1. Calib is sacred—replace fx,fy,cx,cy with your intrinsics; align with resolution.
  2. Depth units—if millimeters, scale=1000 to meters; if meters, scale=1.
  3. Resolutioncx,cy must match image size (e.g. 640×480 vs 1280×720).

Part 4 — comparison & combo strategy

FilterDomainRoleProsCons
SOR3Ddrop isolated outliersfastweak on clusters
ROR3Ddrop sparse clumpscomplements SORradius tuning
Bilateral (2D)depth imageedge-preserving smoothkeeps structurewon’t fix big holes
Bilateral (3D)pointssmooth + keep edgesnice surfacesneeds normals

Recommended stack

  1. 2D bilateral on raw depth
  2. Back-project to cloud
  3. SOR + ROR (optional 3D bilateral before/after depending on look)
  4. Morph opening only when dense micro-clutter remains

Part 5 — FAQ

  1. Holes after bilateral—inpaint (cv2.inpaint) or cloud upsample; bilateral won’t invent depth.
  2. Viewer OOMvoxel_down_sample(0.01–0.05 m) before viz.
  3. Blurry edges—lower sigmaColor / d.
  4. Too many good points removed—raise std_ratio, shrink radius / min_nn.
  5. Warped cloud—bad intrinsics / wrong units / mismatched resolution.

Summary

  • Bilateral is the depth-map workhorse when you still care about edges.
  • SOR+ROR is the standard 3D outlier pass after projection.
  • Together (2D bilateral → cloud → SOR/ROR) matches how many RealSense/Kinect/LiDAR preprocessing stacks are built in production.

All sample code is ready to paste—tune radii and sigmas to your rig and noise regime.