4、深度图预处理:多帧平均降噪、双边滤波、时域滤波、飞点去除

各位同学,咱们今天聊点实在的。深度图拿到手,别急着往重建算法里扔。我见过太多人,原始数据直接上,结果重建出来一堆坑坑洼洼,还以为是算法不行。其实啊,预处理这一步做好了,后面能省80%的麻烦。

ToF相机出来的深度图,说白了就是一堆带噪声的测量值。有随机噪声、有飞点、有边缘拖影。咱们今天就把这些脏东西一个个清掉。

4.1 多帧平均降噪:最朴素的暴力美学

多帧平均,听起来很土对吧?但效果是真的稳。我早期做项目时,为了省时间只取单帧,结果重建出来的模型跟月球表面似的。后来老老实实做了多帧平均,世界瞬间清净了。

原理很简单:对同一场景连续采集N帧深度图,每个像素取均值。

def multi_frame_average(depth_frames):
    """
    depth_frames: list of depth maps, shape (N, H, W)
    returns: averaged depth map, shape (H, W)
    """
    # 堆叠后取中位数,比均值更抗噪
    stacked = np.stack(depth_frames, axis=0)
    median_map = np.median(stacked, axis=0)
    return median_map
我的小技巧: 别用均值,用中位数。均值会被极端值带偏,中位数稳如老狗。我一般取5-9帧,再多帧数场景变化就大了。

这里有个坑:如果场景里有运动物体,多帧平均会把运动区域搞糊。怎么办?加个运动检测,只对静态区域做平均。嗯,这个后面讲时域滤波时会提到。

4.2 双边滤波:保边去噪的利器

普通高斯滤波会把边缘也磨平了。你想想看,深度图里边缘就是物体的轮廓,磨平了重建出来就是一坨。双边滤波就是来解决这个问题的。

它的核心思想:不仅看空间距离,还看像素值差异。空间上离得近的像素权重高,深度值相近的像素权重也高。这样边缘两侧的像素不会互相污染。

def bilateral_filter(depth_map, d=5, sigma_color=0.1, sigma_space=5):
    """
    双边滤波实现
    d: 窗口直径
    sigma_color: 深度值差异的方差
    sigma_space: 空间距离的方差
    """
    filtered = cv2.bilateralFilter(
        depth_map.astype(np.float32), 
        d, 
        sigma_color, 
        sigma_space
    )
    return filtered
参数调优经验: sigma_color 我一般设0.05-0.15米,具体看你的深度范围。sigma_space 设5-10像素。太大就变成高斯滤波了,边缘保不住。

我记得有一次做室内重建,墙面和家具的深度差只有几厘米。双边滤波参数没调好,直接把墙面和桌子糊一起了。后来把sigma_color压到0.03,边缘才清晰起来。

4.3 时域滤波:让深度图不再闪烁

多帧平均是空间上的,时域滤波是时间上的。说白了就是让每一帧的深度值,跟历史帧做个加权平均。这样深度图就不会一帧一个样,看着跟鬼片似的。

最常用的是指数移动平均(EMA):

class TemporalFilter:
    def __init__(self, alpha=0.3):
        self.alpha = alpha
        self.prev_depth = None
    
    def update(self, depth_map):
        if self.prev_depth is None:
            self.prev_depth = depth_map.copy()
            return depth_map
        
        # 指数加权平均
        filtered = self.alpha * depth_map + (1 - self.alpha) * self.prev_depth
        self.prev_depth = filtered.copy()
        return filtered
注意: alpha值越大,当前帧权重越高,响应快但噪声大。alpha值越小,历史帧权重高,平滑但会有拖影。我一般设0.2-0.4,具体看你的帧率和场景运动速度。

为什么会拖影?你想想看,如果物体快速移动,历史帧的深度值还留在原地,当前帧已经跑到新位置了。加权平均后,旧位置的深度值会残留几帧。这就是拖影。

我曾经做过一个项目,机械臂抓取,深度图必须实时响应。alpha设到0.6才勉强跟上,但噪声又大了。后来加了个运动检测:运动快的区域alpha调高,静态区域alpha调低。效果不错。

4.4 飞点去除:干掉那些离谱的噪声

飞点(Flying Pixel)是ToF相机的老大难问题。说白了就是深度值突然跳变到离谱的值。比如墙面深度1米,突然冒出一个0.5米的点,或者3米的点。这些点就是飞点。

飞点产生的原因:边缘处红外光反射混乱,或者多路径干扰。嗯,这里不展开讲物理原理,咱们直接说怎么干掉它。

方法一:基于邻域一致性

检查每个像素,看它和周围8邻域的深度值是否一致。如果差异太大,就标记为飞点。

def remove_flying_pixels(depth_map, threshold=0.1):
    """
    基于邻域一致性的飞点去除
    threshold: 深度差异阈值(米)
    """
    # 计算每个像素与邻域均值的差异
    kernel = np.ones((3, 3), np.float32) / 9
    local_mean = cv2.filter2D(depth_map, -1, kernel)
    diff = np.abs(depth_map - local_mean)
    
    # 标记飞点并置为无效值
    mask = diff > threshold
    depth_map_clean = depth_map.copy()
    depth_map_clean[mask] = 0  # 或者 np.nan
    return depth_map_clean, mask

方法二:基于边缘检测

飞点往往出现在深度边缘附近。先检测边缘,然后在边缘区域做更严格的检查。

def edge_aware_flying_pixel_removal(depth_map, edge_thresh=0.2, flying_thresh=0.3):
    # 计算深度梯度
    grad_x = cv2.Sobel(depth_map, cv2.CV_32F, 1, 0, ksize=3)
    grad_y = cv2.Sobel(depth_map, cv2.CV_32F, 0, 1, ksize=3)
    grad_mag = np.sqrt(grad_x**2 + grad_y**2)
    
    # 边缘区域
    edge_mask = grad_mag > edge_thresh
    
    # 在边缘区域做更严格的飞点检测
    local_median = cv2.medianBlur(depth_map, 5)
    diff = np.abs(depth_map - local_median)
    
    # 边缘区域阈值更宽松,非边缘区域更严格
    adaptive_thresh = np.where(edge_mask, flying_thresh * 1.5, flying_thresh)
    flying_mask = diff > adaptive_thresh
    
    depth_map_clean = depth_map.copy()
    depth_map_clean[flying_mask] = 0
    return depth_map_clean
避坑指南: 我曾经把阈值设得太小,结果把正常的边缘点也干掉了。重建出来的物体边缘像被狗啃过。后来我学乖了:先统计一下深度图的直方图,看看正常的深度范围是多少,再设阈值。

4.5 预处理流程总结

说了这么多,咱们捋一下完整的预处理流程。我一般按这个顺序来:

  1. 多帧中位数平均:干掉随机噪声,保留边缘
  2. 飞点去除:先干掉离谱的异常值,免得后面滤波时污染邻域
  3. 双边滤波:进一步平滑,同时保护边缘
  4. 时域滤波:让深度图在时间上稳定,不闪烁

这个顺序是我踩过坑之后总结的。你想想看,如果先做时域滤波,飞点会残留在历史帧里,后面怎么都去不掉。所以飞点去除一定要放在前面。

深度图预处理流程 原始深度图 多帧中位数平均 (5-9帧) 飞点去除 (邻域一致性) 双边滤波 (保边去噪) 时域滤波 (指数移动平均) 预处理完成 → 重建算法

最后说一句:预处理没有银弹。不同的ToF相机、不同的场景,参数都得调。我建议你准备一套标定数据,把各种参数组合跑一遍,看看效果。嗯,这个习惯帮我省了不少返工的麻烦。