3. 深度数据采集与预处理:原始深度图读取、噪声滤波、空洞填充

各位同学,咱们今天聊点实在的。深度相机拿到手,第一件事是什么?不是急着跑算法,而是先把原始数据伺候好。我见过太多项目,算法模型调得漂漂亮亮,结果一上嵌入式平台,深度图全是噪点,直接翻车。

深度数据预处理,说白了就是给相机「洗把脸」。原始深度图里藏着各种噪声、空洞、飞点。你不处理它,它就给你颜色看。今天我就把这三板斧教给你:怎么读、怎么滤、怎么填。

深度数据预处理流程 原始深度图读取 16bit / 32bit 格式 噪声滤波 双边滤波 / 中值滤波 保留边缘 vs 去除椒盐 空洞填充 近邻插值 / 区域生长 多帧融合 ⚠️ 嵌入式平台注意点 内存带宽有限 → 避免大核滤波 NEON / DSP 加速 → 定点化处理

3.1 原始深度图读取——别被像素格式坑了

深度图不是普通的RGB图。它每个像素存的是距离值,单位通常是毫米。我见过有人直接用 cv2.imread 读深度图,结果出来一片黑——因为默认当成8bit读了。

深度图常见格式有两种:

格式 位深 取值范围 典型场景
16bit 无符号 uint16 0 ~ 65535 mm ToF 相机、结构光
32bit 浮点 float32 0.0 ~ 10.0 m 双目立体匹配
我的习惯: 拿到深度图先打印 dtypeshape。这一步花不了1秒钟,但能省你半天调试时间。
import cv2
import numpy as np

# 读取16bit深度图
depth = cv2.imread('depth_0001.png', cv2.IMREAD_UNCHANGED)
print(f"深度图尺寸: {depth.shape}, 数据类型: {depth.dtype}")

# 如果是float32,直接使用
# 如果是uint16,注意单位是毫米
if depth.dtype == np.uint16:
    # 转成米,方便后续计算
    depth_m = depth.astype(np.float32) / 1000.0
elif depth.dtype == np.float32:
    depth_m = depth.copy()
⚠️ 注意: 有些深度图用0表示无效值,有些用65535。我踩过这个坑——把无效值当成有效距离算,结果点云里全是飞点。

3.2 噪声滤波——双边滤波 vs 中值滤波

深度图的噪声分两种。一种是高斯噪声,传感器本身的热噪声;另一种是椒盐噪声,反射失效导致的孤立坏点。两种噪声,两种打法。

3.2.1 双边滤波——保边去噪

双边滤波是我在嵌入式平台上用得最多的滤波器。它不像高斯滤波那样无差别模糊,而是考虑像素值差异。说白了,颜色相近的像素才一起平滑,边缘处自动刹车。

为什么深度图特别需要双边滤波?因为深度图的边缘就是物体的轮廓。你把它模糊了,后面做目标检测、抓取位姿估计全完蛋。

def bilateral_filter_depth(depth, d=5, sigma_color=50, sigma_space=5):
    """
    双边滤波,保留深度边缘
    d: 滤波窗口直径
    sigma_color: 颜色域标准差(深度差异容忍度)
    sigma_space: 空间域标准差
    """
    # 先归一化到0-255方便处理
    depth_norm = cv2.normalize(depth, None, 0, 255, cv2.NORM_MINMAX)
    depth_norm = depth_norm.astype(np.uint8)
    
    # 双边滤波
    filtered = cv2.bilateralFilter(depth_norm, d, sigma_color, sigma_space)
    
    # 还原到原始尺度
    filtered = filtered.astype(np.float32) / 255.0 * (depth.max() - depth.min()) + depth.min()
    return filtered
💡 经验之谈: 在RK3588上,我习惯把 d 设成5,sigma_color 设成50。窗口再大,NEON加速也扛不住。你想想看,嵌入式平台每毫秒都很珍贵。

3.2.2 中值滤波——专治椒盐噪声

中值滤波对付孤立坏点特别有效。它的原理很简单:把窗口内的像素排序,取中间值。那些突然冒出来的飞点,要么排在最前面,要么排在最后面,根本进不了中值。

我曾经在一个ToF项目里,深度图全是闪烁的噪点。双边滤波搞不定,换成中值滤波,3x3窗口,效果立竿见影。

def median_filter_depth(depth, kernel_size=3):
    """
    中值滤波,去除孤立噪点
    kernel_size: 必须为奇数
    """
    # 中值滤波直接支持uint16
    filtered = cv2.medianBlur(depth, kernel_size)
    return filtered
选型建议: 如果噪声是散点状 → 中值滤波。如果噪声是片状且需要保留边缘 → 双边滤波。如果两者都有 → 先中值后双边。

3.3 深度图空洞填充——别让算法看到黑洞

深度图里经常有黑色区域,那是传感器没测到距离的地方。反光表面、黑色物体、远距离目标,都会产生空洞。这些空洞如果不处理,后续的点云生成、障碍物检测都会出问题。

空洞填充有几种思路,我按实用程度排个序:

  1. 近邻插值法:用空洞周围的有效像素填充。简单、快,适合小空洞。
  2. 区域生长法:从空洞边界向内扩散,考虑深度连续性。适合中等空洞。
  3. 多帧融合法:利用时间序列信息,用前一帧的有效值补当前帧的空洞。适合动态场景。
def fill_depth_holes(depth, max_hole_size=10):
    """
    基于近邻插值的空洞填充
    max_hole_size: 最大空洞直径(像素)
    """
    # 标记空洞(深度值为0或无效值)
    mask = (depth == 0).astype(np.uint8)
    
    # 形态学操作:先膨胀再腐蚀,去除小空洞
    kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3))
    mask_dilated = cv2.dilate(mask, kernel, iterations=2)
    
    # 使用inpaint填充
    filled = cv2.inpaint(depth, mask_dilated, max_hole_size, cv2.INPAINT_TELEA)
    
    return filled
⚠️ 避坑指南: 我曾经在空洞填充时没做空洞大小判断,结果把一个大空洞(占了半个画面)用近邻插值填了,填出来的深度值全是错的。后来加了限制:空洞直径超过20像素,直接标记为未知区域,不强行填充。

3.4 嵌入式平台上的优化要点

在PC上跑这些预处理,CPU随便扛。但到了嵌入式平台,每一帧都要精打细算。我总结了几条铁律:

  • 定点化:浮点运算在ARM上慢,能转成整数就别用float。双边滤波的sigma参数可以预先查表。
  • 减少内存拷贝:深度图预处理尽量原地操作,别动不动就copy。我见过有人每帧拷贝3次,帧率直接腰斩。
  • 利用硬件加速:RK3588有NPU,Jetson有GPU。双边滤波这种并行友好的操作,扔给GPU做。
  • 裁剪无效区域:很多深度相机的边缘区域是无效的。先裁剪再处理,能省20%的计算量。
💡 我的优化习惯: 在Jetson Orin上,我会把深度图缩放到640x480再处理。分辨率再高,精度提升有限,但计算量翻倍。你想想看,嵌入式平台讲究的是「够用就好」。

好了,深度数据采集与预处理就聊到这儿。这三板斧——读取、滤波、填充——是后续所有深度算法的基础。别嫌它们基础,我见过太多项目因为预处理没做好,后面花了几周调算法都没用。嗯,先把地基打牢,后面才能盖高楼。


专注资料整理