深度图与点云去噪实战:双边滤波+统计/半径滤波原理与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:深度图滤波效果可视化。
噪声类型说明
深度图/点云的常见噪声及对应解决方案:
- 孤立点/椒盐噪声:单个离散的噪点,无邻域点,用统计滤波剔除;
- 稀疏噪点簇:几个噪点聚集在一起,统计滤波无法识别,用半径滤波剔除;
- 高斯噪声/均匀噪声:深度图上的平滑噪声,会模糊但不破坏边缘,用双边滤波平滑,且保留物体轮廓;
- 密集小噪点团:少量噪点紧密聚集,可选3D形态学开运算处理(牺牲少量细节)。
第一部分:点云去噪基础——统计滤波&半径滤波
核心是统计滤波+半径滤波的组合,还做了超大点数优化、低版本Open3D兼容、无效点剔除等实用设计。这部分先讲两个滤波的核心原理,再逐行解析代码,让你知其然更知其所以然。
1.1 统计滤波(Statistical Outlier Removal)
核心原理
统计滤波的核心思想是基于邻域点的距离统计特性剔除异常点,假设点云的正常点在空间中是连续分布的,噪点与邻域点的距离会远大于正常点。
具体步骤:
- 对每个点,计算其到k个最近邻点的欧式距离的平均值;
- 所有点的平均距离服从高斯分布,计算该分布的均值 μ \mu μ和标准差 σ \sigma σ;
- 剔除平均距离超过 μ + 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)
核心原理
半径滤波是统计滤波的补充,解决统计滤波对“小噪点簇”无效的问题,核心是基于邻域点的数量剔除异常点。
具体步骤:
- 以每个点为球心,设置一个固定半径r,构建3D球形邻域;
- 统计球形邻域内的点数量,剔除数量少于
min_nn的点; - 即使几个噪点聚集,其邻域内的点数仍会远少于正常点,因此能被有效剔除。
核心用途
专门剔除稀疏小噪点簇(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
)
核心模块解析
- 无效点/重复点剔除:Open3D的
remove_non_finite_points是核心,修复了低版本兼容问题(将remove_inf改为remove_infinite),重复点会导致邻域统计偏差,必须剔除; - 超大点数体素下采样:针对500万+的点云,体素下采样能在不破坏整体结构的前提下降低点数,避免后续滤波的内存溢出和卡顿,是工业级代码的关键优化;
- 统计+半径滤波组合:先剔除孤立点,再剔除稀疏小簇,两者互补,覆盖了绝大多数离散噪点场景;
- 可选3D形态学开运算:原理是“腐蚀+膨胀”,适合处理密集小噪点团(比如10个左右噪点紧密聚集),但会损失少量细节,因此设为可选。
调优黄金原则
噪声越严重(比如深度相机距离目标过远、光照复杂),按以下方式调参:
- 统计滤波:
stat_std_ratio调小(1.01.5)、stat\_nb\_neighbors调大(3050); - 半径滤波:
rad_radius调大(0.060.1m)、rad\_min\_nn调大(1220); - 密集噪点团:开启
use_morphology,morph_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πσ21e−2σ2x2+y2
问题在于:深度图的边缘处,相邻像素的深度值差异极大(比如墙面深度1m,桌子深度0.5m),高斯滤波会将边缘两侧的像素混合,导致边缘模糊,而边缘是3D场景的核心结构信息,模糊后会严重影响后续的点云生成和3D重建。
2.2 双边滤波的核心原理
双边滤波的核心创新是:将空域核(Spatial Kernel)和值域核(Range Kernel)结合,滤波权重由空间距离和深度值相似性共同决定。
只有满足两个条件的像素,才会参与当前像素的滤波计算:
- 空间近:像素在当前像素的邻域内(空域核控制);
- 深度值相似:像素的深度值与当前像素的深度值差异小(值域核控制)。
这样一来,边缘两侧的像素因深度值差异大,不会互相参与滤波,从而保留边缘;而同一区域内的像素因空间近且深度值相似,会被平滑滤波,从而去除噪声。
双边滤波的数学公式
对于深度图中的像素 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)=Wp1q∈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越小,边缘保留越严格,平滑效果越弱。
双边滤波的核心特点
- 边缘保留:最核心的优势,适合深度图、彩色图像等需要保留边缘的场景;
- 局部性:仅利用邻域像素进行滤波,计算速度快,非迭代;
- 非线性:因值域核的存在,双边滤波是非线性滤波(高斯滤波是线性);
- 无参数迭代:仅需调优 σ s \sigma_s σs和 σ r \sigma_r σr,无需复杂的迭代参数。
2.3 双边滤波的核心用途
- 深度图去噪:平滑高斯噪声、均匀噪声,保留物体边缘,是深度图预处理的首选算法;
- 彩色图像去噪:边缘保留的平滑去噪,避免图像模糊;
- 点云平滑:Open3D提供了点云层面的双边滤波,可对3D点云进行边缘保留的平滑;
- 深度图空洞填充:结合邻域深度值相似性,对小空洞进行合理填充。
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个核心参数,调优直接决定滤波效果,黄金调优原则:
d(邻域直径):取3/5/7(奇数),噪声越严重取越大,过大会导致轻微边缘模糊;sigmaColor(值域 σ r \sigma_r σr):深度图一般取1030,**需强边缘保留则取小值(510)**,需强平滑则取大值(20~30);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="深度图+点云全流程去噪结果")
关键注意事项
- 相机内参修改:代码中的
fx/fy/cx/cy是默认值,需根据你的深度相机实际参数修改(可从相机SDK、标定工具中获取),否则点云会出现畸变; - 深度值缩放:深度相机采集的深度图值一般为毫米(mm),代码中
scale=1000将其转为米(m),若你的深度图单位是米,需将scale改为1; - 分辨率匹配:内参与深度图分辨率一一对应(比如640×480的深度图对应cx=320,cy=240),若深度图分辨率为1280×720,需调整cx/cy为640/360。
第四部分:各滤波算法对比与适用场景总结
为了让你在实际项目中快速选择合适的滤波算法,以下对统计滤波、半径滤波、双边滤波进行全方位对比,明确各自的适用场景和优缺点:
| 滤波算法 | 处理层面 | 核心功能 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|---|
| 统计滤波 | 3D点云 | 剔除孤立点/椒盐噪声 | 计算快、参数少、适配所有点云 | 对小噪点簇无效 | 点云初步去噪,剔除离散单点噪声 |
| 半径滤波 | 3D点云 | 剔除稀疏小噪点簇 | 补充统计滤波,处理小簇噪点 | 半径参数需根据点云密度调整 | 统计滤波后二次去噪,剔除稀疏簇噪声 |
| 双边滤波(2D) | 2D深度图 | 边缘保留的平滑去噪 | 保留边缘、平滑高斯/均匀噪声、效果最好 | 对离散点无效 | 深度图预处理,平滑整体噪声,保留场景结构 |
| 双边滤波(3D) | 3D点云 | 边缘保留的点云平滑 | 优化点云平滑度,保留3D边缘 | 需估计法向量、对离散点无效 | 点云后处理,优化平滑度(配合统计+半径滤波) |
黄金组合策略
- 深度图预处理:优先使用2D双边滤波,平滑噪声并保留边缘,这是最关键的一步;
- 点云去噪:深度图转点云后,使用统计滤波+半径滤波组合,剔除离散噪点;
- 点云优化:若点云整体粗糙,可在统计+半径滤波前增加3D双边滤波,实现平滑+剔噪的双重效果;
- 密集噪点团:若存在密集小噪点团,开启3D形态学开运算(牺牲少量细节)。
第五部分:常见问题与解决方案
- 深度图滤波后出现空洞:深度图本身存在的空洞,双边滤波无法填充,可使用
cv2.inpaint进行空洞填充,或在点云层面使用o3d.geometry.PointCloud.voxel_up_sample补点; - 点云可视化卡顿/内存溢出:开启体素下采样(
voxel_down_sample),降低点云点数,体素大小取0.01~0.05m; - 双边滤波后边缘模糊:减小
sigmaColor(值域σ)或d(邻域直径),增强边缘保留效果; - 统计+半径滤波后丢失有效点:调大
stat_std_ratio、减小rad_radius或rad_min_nn,降低剔除严格度; - 深度图转点云后点云畸变:检查相机内参是否正确,确保内参与深度图分辨率、相机型号匹配。
总结
深度图/点云去噪是3D计算机视觉的基础预处理步骤,其核心是针对性选择滤波算法:
- 双边滤波是深度图去噪的核心,凭借边缘保留的平滑特性,完胜传统的高斯滤波,是2D层面去噪的首选;
- 统计滤波+半径滤波是点云去噪的黄金组合,能全覆盖剔除3D层面的孤立点和稀疏小噪点簇,且计算高效、参数易调;
- 工业级的最优流程是深度图双边滤波→转点云→点云统计+半径滤波,结合了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工業級程式碼逐行解析),再深入剖析深度圖去噪的核心演算法雙邊濾波(原理+實現+調優),最後實現深度圖雙邊濾波+點雲統計/半徑濾波的全流程去噪方案,兼顧邊緣保留和平滑去噪,適配大部分工業級場景。

文章目錄
- 前言
- 前置知識與環境準備
- 第一部分:點雲去噪基礎——統計濾波&半徑濾波
- 第二部分:深度圖去噪核心——雙邊濾波(Bilateral Filter)
- 第三部分:工業級全流程——深度圖→點雲的聯合去噪
- 第四部分:各濾波演算法對比與適用場景總結
- 第五部分:常見問題與解決方案
- 總結
前置知識與環境準備
核心依賴
本文所有程式碼基於Python實現,需安裝以下庫:
pip install open3d numpy opencv-python matplotlib
open3d:3D點雲處理核心庫,提供濾波、視覺化、格式轉換等功能;numpy:數值計算基礎,處理深度圖/點雲的陣列資料;opencv-python:2D深度圖的雙邊濾波、影象讀寫;matplotlib:深度圖濾波效果視覺化。
噪聲型別說明
深度圖/點雲的常見噪聲及對應解決方案:
- 孤立點/椒鹽噪聲:單個離散的噪點,無鄰域點,用統計濾波剔除;
- 稀疏噪點簇:幾個噪點聚集在一起,統計濾波無法識別,用半徑濾波剔除;
- 高斯噪聲/均勻噪聲:深度圖上的平滑噪聲,會模糊但不破壞邊緣,用雙邊濾波平滑,且保留物體輪廓;
- 密集小噪點團:少量噪點緊密聚集,可選3D形態學開運算處理(犧牲少量細節)。
第一部分:點雲去噪基礎——統計濾波&半徑濾波
核心是統計濾波+半徑濾波的組合,還做了超大點數最佳化、低版本Open3D相容、無效點剔除等實用設計。這部分先講兩個濾波的核心原理,再逐行解析程式碼,讓你知其然更知其所以然。
1.1 統計濾波(Statistical Outlier Removal)
核心原理
統計濾波的核心思想是基於鄰域點的距離統計特性剔除異常點,假設點雲的正常點在空間中是連續分佈的,噪點與鄰域點的距離會遠大於正常點。
具體步驟:
- 對每個點,計算其到k個最近鄰點的歐式距離的平均值;
- 所有點的平均距離服從高斯分佈,計算該分佈的均值 μ \mu μ和標準差 σ \sigma σ;
- 剔除平均距離超過 μ + 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)
核心原理
半徑濾波是統計濾波的補充,解決統計濾波對“小噪點簇”無效的問題,核心是基於鄰域點的數量剔除異常點。
具體步驟:
- 以每個點為球心,設定一個固定半徑r,構建3D球形鄰域;
- 統計球形鄰域內的點數量,剔除數量少於
min_nn的點; - 即使幾個噪點聚集,其鄰域內的點數仍會遠少於正常點,因此能被有效剔除。
核心用途
專門剔除稀疏小噪點簇(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
)
核心模組解析
- 無效點/重複點剔除:Open3D的
remove_non_finite_points是核心,修復了低版本相容問題(將remove_inf改為remove_infinite),重複點會導致鄰域統計偏差,必須剔除; - 超大點數體素下采樣:針對500萬+的點雲,體素下采樣能在不破壞整體結構的前提下降低點數,避免後續濾波的記憶體溢位和卡頓,是工業級程式碼的關鍵最佳化;
- 統計+半徑濾波組合:先剔除孤立點,再剔除稀疏小簇,兩者互補,覆蓋了絕大多數離散噪點場景;
- 可選3D形態學開運算:原理是“腐蝕+膨脹”,適合處理密集小噪點團(比如10個左右噪點緊密聚集),但會損失少量細節,因此設為可選。
調優黃金原則
噪聲越嚴重(比如深度相機距離目標過遠、光照複雜),按以下方式調參:
- 統計濾波:
stat_std_ratio調小(1.01.5)、stat\_nb\_neighbors調大(3050); - 半徑濾波:
rad_radius調大(0.060.1m)、rad\_min\_nn調大(1220); - 密集噪點團:開啟
use_morphology,morph_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πσ21e−2σ2x2+y2
問題在於:深度圖的邊緣處,相鄰畫素的深度值差異極大(比如牆面深度1m,桌子深度0.5m),高斯濾波會將邊緣兩側的畫素混合,導致邊緣模糊,而邊緣是3D場景的核心結構資訊,模糊後會嚴重影響後續的點雲生成和3D重建。
2.2 雙邊濾波的核心原理
雙邊濾波的核心創新是:將空域核(Spatial Kernel)和值域核(Range Kernel)結合,濾波權重由空間距離和深度值相似性共同決定。
只有滿足兩個條件的畫素,才會參與當前畫素的濾波計算:
- 空間近:畫素在當前畫素的鄰域內(空域核控制);
- 深度值相似:畫素的深度值與當前畫素的深度值差異小(值域核控制)。
這樣一來,邊緣兩側的畫素因深度值差異大,不會互相參與濾波,從而保留邊緣;而同一區域內的畫素因空間近且深度值相似,會被平滑濾波,從而去除噪聲。
雙邊濾波的數學公式
對於深度圖中的畫素 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)=Wp1q∈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越小,邊緣保留越嚴格,平滑效果越弱。
雙邊濾波的核心特點
- 邊緣保留:最核心的優勢,適合深度圖、彩色影象等需要保留邊緣的場景;
- 區域性性:僅利用鄰域畫素進行濾波,計算速度快,非迭代;
- 非線性:因值域核的存在,雙邊濾波是非線性濾波(高斯濾波是線性);
- 無引數迭代:僅需調優 σ s \sigma_s σs和 σ r \sigma_r σr,無需複雜的迭代引數。
2.3 雙邊濾波的核心用途
- 深度圖去噪:平滑高斯噪聲、均勻噪聲,保留物體邊緣,是深度圖預處理的首選演算法;
- 彩色影象去噪:邊緣保留的平滑去噪,避免影象模糊;
- 點雲平滑:Open3D提供了點雲層面的雙邊濾波,可對3D點雲進行邊緣保留的平滑;
- 深度圖空洞填充:結合鄰域深度值相似性,對小空洞進行合理填充。
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個核心引數,調優直接決定濾波效果,黃金調優原則:
d(鄰域直徑):取3/5/7(奇數),噪聲越嚴重取越大,過大會導致輕微邊緣模糊;sigmaColor(值域 σ r \sigma_r σr):深度圖一般取1030,**需強邊緣保留則取小值(510)**,需強平滑則取大值(20~30);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="深度圖+點雲全流程去噪結果")
關鍵注意事項
- 相機內參修改:程式碼中的
fx/fy/cx/cy是預設值,需根據你的深度相機實際引數修改(可從相機SDK、標定工具中獲取),否則點雲會出現畸變; - 深度值縮放:深度相機採集的深度圖值一般為毫米(mm),程式碼中
scale=1000將其轉為米(m),若你的深度圖單位是米,需將scale改為1; - 解析度匹配:內參與深度圖解析度一一對應(比如640×480的深度圖對應cx=320,cy=240),若深度圖解析度為1280×720,需調整cx/cy為640/360。
第四部分:各濾波演算法對比與適用場景總結
為了讓你在實際專案中快速選擇合適的濾波演算法,以下對統計濾波、半徑濾波、雙邊濾波進行全方位對比,明確各自的適用場景和優缺點:
| 濾波演算法 | 處理層面 | 核心功能 | 優點 | 缺點 | 適用場景 |
|---|---|---|---|---|---|
| 統計濾波 | 3D點雲 | 剔除孤立點/椒鹽噪聲 | 計算快、引數少、適配所有點雲 | 對小噪點簇無效 | 點雲初步去噪,剔除離散單點噪聲 |
| 半徑濾波 | 3D點雲 | 剔除稀疏小噪點簇 | 補充統計濾波,處理小簇噪點 | 半徑引數需根據點雲密度調整 | 統計濾波後二次去噪,剔除稀疏簇噪聲 |
| 雙邊濾波(2D) | 2D深度圖 | 邊緣保留的平滑去噪 | 保留邊緣、平滑高斯/均勻噪聲、效果最好 | 對離散點無效 | 深度圖預處理,平滑整體噪聲,保留場景結構 |
| 雙邊濾波(3D) | 3D點雲 | 邊緣保留的點雲平滑 | 最佳化點雲平滑度,保留3D邊緣 | 需估計法向量、對離散點無效 | 點雲後處理,最佳化平滑度(配合統計+半徑濾波) |
黃金組合策略
- 深度圖預處理:優先使用2D雙邊濾波,平滑噪聲並保留邊緣,這是最關鍵的一步;
- 點雲去噪:深度圖轉點雲後,使用統計濾波+半徑濾波組合,剔除離散噪點;
- 點雲最佳化:若點雲整體粗糙,可在統計+半徑濾波前增加3D雙邊濾波,實現平滑+剔噪的雙重效果;
- 密集噪點團:若存在密集小噪點團,開啟3D形態學開運算(犧牲少量細節)。
第五部分:常見問題與解決方案
- 深度圖濾波後出現空洞:深度圖本身存在的空洞,雙邊濾波無法填充,可使用
cv2.inpaint進行空洞填充,或在點雲層面使用o3d.geometry.PointCloud.voxel_up_sample補點; - 點雲視覺化卡頓/記憶體溢位:開啟體素下采樣(
voxel_down_sample),降低點雲點數,體素大小取0.01~0.05m; - 雙邊濾波後邊緣模糊:減小
sigmaColor(值域σ)或d(鄰域直徑),增強邊緣保留效果; - 統計+半徑濾波後丟失有效點:調大
stat_std_ratio、減小rad_radius或rad_min_nn,降低剔除嚴格度; - 深度圖轉點雲後點雲畸變:檢查相機內參是否正確,確保內參與深度圖解析度、相機型號匹配。
總結
深度圖/點雲去噪是3D計算機視覺的基礎預處理步驟,其核心是針對性選擇濾波演算法:
- 雙邊濾波是深度圖去噪的核心,憑藉邊緣保留的平滑特性,完勝傳統的高斯濾波,是2D層面去噪的首選;
- 統計濾波+半徑濾波是點雲去噪的黃金組合,能全覆蓋剔除3D層面的孤立點和稀疏小噪點簇,且計算高效、引數易調;
- 工業級的最優流程是深度圖雙邊濾波→轉點雲→點雲統計+半徑濾波,結合了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.

Prerequisites
Dependencies
pip install open3d numpy opencv-python matplotlib
open3d: filters, IO, visualizationnumpy: array plumbingopencv-python: 2D bilateral on depthmatplotlib: quick depth previews
Noise taxonomy (what to use when)
- Isolated shot noise → statistical outlier removal
- Tiny clumps of outliers → radius outlier removal
- High-frequency ripple on surfaces → bilateral (edge-preserving smooth)
- 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, raisestat_nb_neighbors - Raise
rad_radiusandrad_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 smoothingsigmaColor: “range σ”—lower to protect sharp depth jumpssigmaSpace: “spatial σ”—often matched tosigmaColor
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_bilateralin 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
- Calib is sacred—replace
fx,fy,cx,cywith your intrinsics; align with resolution. - Depth units—if millimeters,
scale=1000to meters; if meters,scale=1. - Resolution—
cx,cymust match image size (e.g. 640×480 vs 1280×720).
Part 4 — comparison & combo strategy
| Filter | Domain | Role | Pros | Cons |
|---|---|---|---|---|
| SOR | 3D | drop isolated outliers | fast | weak on clusters |
| ROR | 3D | drop sparse clumps | complements SOR | radius tuning |
| Bilateral (2D) | depth image | edge-preserving smooth | keeps structure | won’t fix big holes |
| Bilateral (3D) | points | smooth + keep edges | nice surfaces | needs normals |
Recommended stack
- 2D bilateral on raw depth
- Back-project to cloud
- SOR + ROR (optional 3D bilateral before/after depending on look)
- Morph opening only when dense micro-clutter remains
Part 5 — FAQ
- Holes after bilateral—inpaint (
cv2.inpaint) or cloud upsample; bilateral won’t invent depth. - Viewer OOM—
voxel_down_sample(0.01–0.05 m)before viz. - Blurry edges—lower
sigmaColor/d. - Too many good points removed—raise
std_ratio, shrinkradius/min_nn. - 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.