Point-E 生成点云时,扩散模型和点扩散过程经常在物体周围留下孤立的离群点,也会在薄壁结构附近产生一小簇密度异常的散点。这些噪声如果不先清理,后续的泊松重建、法线估计和纹理映射都会受到影响。统计滤波与半径滤波都依赖邻域搜索,但判定噪声的角度不同:统计滤波比较某点到邻域的距离是否偏离整体分布,半径滤波直接检查某点周围固定半径内的邻居数量。

一、两类噪声的判定逻辑
统计滤波的核心假设是点云中每个点的局部密度应当接近整体密度。先对每个点查找一定数量的最近邻,计算该点到所有邻居的平均距离。假设这些平均距离服从高斯分布,那么均值加若干倍标准差之外的点就会被标为离群点。这个倍数通常设为1.0到2.0,倍率越小删除越激进。算法优点是对全局离群点敏感,缺点是在密度变化较大的弯曲表面可能误删边缘点。
半径滤波的逻辑更直观:指定一个半径和一个最少邻居数,如果某点在该半径内找到的邻居数少于阈值,就判定为噪声。它非常适合清除散布在大范围空间中的孤立点和细碎漂浮面片。半径参数需要根据点云的实际尺度设置,太小会漏掉稀疏噪声,太大会把薄壁结构也一起删掉。
两种方法可以看作密度约束的两种表达:统计滤波用距离分布做自适应阈值,半径滤波用固定邻域基数做硬约束。Point-E 输出的点云通常整体密度较均匀,但物体边缘会存在明显的离群点,所以先用统计滤波粗清洗通常效果稳定。
二、统计滤波实现与参数调整
在 Open3D 中,统计滤波封装为 remove_statistical_outlier()。使用时先把 Point-E 输出的坐标数组转换为 open3d.geometry.PointCloud,再调用方法并传入 nb_neighbors 和 std_ratio。下面是一个完整示例。
import numpy as np
import open3d as o3d
# 假设 coords 是 Point-E 输出的 Nx3 坐标数组
coords = np.load("point_e_coords.npy")[:, :3]
pcd = o3d.geometry.PointCloud()
pcd.points = o3d.utility.Vector3dVector(coords)
# 统计滤波:20 个邻居,标准差倍数 2.0
cl, ind = pcd.remove_statistical_outlier(nb_neighbors=20, std_ratio=2.0)
clean_pcd = pcd.select_by_index(ind)
print(f"原始点数: {len(pcd.points)}, 保留点数: {len(clean_pcd.points)}")
o3d.io.write_point_cloud("statistical_clean.ply", clean_pcd)
参数 nb_neighbors 决定邻域规模,一般取 10 到 50。Point-E 生成的点云如果只有几千个点,取 20 附近比较合适;点云数量达到数万时,可以提升到 30 到 50,让平均距离估计更稳定。std_ratio 控制筛选严格程度,1.0 会删除更多点,2.0 相对保守。调参时可以先从 2.0 开始,观察保留点数量,再逐步减小到 1.5 或 1.0。
如果不想依赖 Open3D,也可以用 scikit-learn 的最近邻模型手动实现统计滤波。思路是构造 KDTree,查询每个点的 k 个最近邻,计算平均距离,再用均值加标准差阈值筛选。手动实现的好处是可以把距离矩阵保留下来做可视化诊断,判断离群点的距离分布是否存在双峰。
from sklearn.neighbors import NearestNeighbors
import numpy as np
def statistical_filter_manual(points, k=20, std_ratio=2.0):
nbrs = NearestNeighbors(n_neighbors=k, algorithm="kd_tree").fit(points)
distances, _ = nbrs.kneighbors(points)
mean_dists = distances[:, 1:].mean(axis=1)
threshold = mean_dists.mean() + std_ratio * mean_dists.std()
keep = mean_dists <= threshold
return keep
上面代码把距离 0 的自身邻居排除掉,所以取 distances[:, 1:]。阈值使用全体平均距离的均值加 std_ratio 倍标准差。手动版本在点云规模不大时足够快,但当点数超过十万时,建议直接使用 Open3D 的 C++ 实现。
三、半径滤波原理与组合清理策略
半径滤波在 Open3D 中对应 remove_radius_outlier(),需要传入 nb_points 和 radius。它的判定不依赖全局距离分布,而是对每个点做半径搜索。如果某个点附近半径 radius 范围内的点数少于 nb_points,就删除。典型用法如下。
import open3d as o3d
pcd = o3d.io.read_point_cloud("statistical_clean.ply")
# 半径滤波:半径 0.02,最少邻居数 10
cl, ind = pcd.remove_radius_outlier(nb_points=10, radius=0.02)
radius_clean = pcd.select_by_index(ind)
print(f"半径滤波前: {len(pcd.points)}, 半径滤波后: {len(radius_clean.points)}")
radius 的选择必须参照点云的空间尺度。Point-E 输出的坐标范围通常在 -1 到 1 或 -0.5 到 0.5,如果半径设为 0.02,意味着只有非常近的邻居才会被计入。若物体尺寸较大,可把半径提高到 0.05 或 0.1;若点云已经做了归一化,先打印包围盒对角线长度再决定半径,能避免拍脑袋设参。
统计滤波和半径滤波并非二选一。前者能高效去掉远离主体的全局离群点,后者能进一步清理局部稀疏的小面片。工程上推荐的顺序是先统计滤波、后半径滤波。统计滤波把整体密度变干净后,半径滤波的固定阈值更容易确定。反过来先做半径滤波虽然也可以,但如果全局离群点很多,半径会难调:设置小了这些远点邻居不够而被删除,设置大了又会误删主体细节。
组合使用时还要注意不要多次重复建立 KDTree。Open3D 的 PointCloud 对象每次调用滤波方法都会重新构建搜索结构。如果点数在几十万级别,可以自己建立一次 o3d.geometry.KDTreeFlann,把两个滤波逻辑合并到一个循环中,减少重复计算。对于 Point-E 输出的小规模点云,直接连续调用两个方法就足够。
四、面向 Point-E 工作流的落地建议
Point-E 的生成结果并不是最终网格,而是一个带颜色的点云。处理前先确认 coords 与 colors 是否对齐,滤波后需要用同样的索引同时裁剪颜色数组。Open3D 只对点坐标进行滤波,颜色需要手动同步,否则保存的 PLY 中会出现点数不一致的问题。
import numpy as np
import open3d as o3d
coords = np.load("point_e_coords.npy")
points = coords[:, :3]
colors = coords[:, 3:6] if coords.shape[1] >= 6 else None
pcd = o3d.geometry.PointCloud()
pcd.points = o3d.utility.Vector3dVector(points)
if colors is not None:
pcd.colors = o3d.utility.Vector3dVector(colors)
cl, ind = pcd.remove_statistical_outlier(nb_neighbors=20, std_ratio=2.0)
clean_colors = np.asarray(pcd.colors)[ind] if colors is not None else None
clean_coords = points[ind]
clean_pcd = o3d.geometry.PointCloud()
clean_pcd.points = o3d.utility.Vector3dVector(clean_coords)
if clean_colors is not None:
clean_pcd.colors = o3d.utility.Vector3dVector(clean_colors)
o3d.io.write_point_cloud("point_e_clean.ply", clean_pcd)
这段代码中 coords.shape[1] >= 6 用于判断坐标数组是否附带 RGB 颜色。索引数组 ind 同时裁剪坐标和颜色,保证输出点云的结构完整。完成清理后,再做法线估计、泊松重建或网格简化,效果会明显提升。

