3、深度图预处理:缺失值填充、异常点剔除与数据归一化

拿到一张原始的ToF深度图,你第一眼看到的是什么?

说实话,我第一次看到的时候有点懵。满屏的黑点、白斑,还有一些莫名其妙跳变的数值。这哪是深度图,分明是抽象画。但这就是ToF传感器的常态——原始数据从来不会给你一个干干净净的结果。

所以,预处理这一步,说白了就是给深度图「洗脸」。洗掉脏东西,补上窟窿,再把数据拉到统一的尺度上。今天我们就聊聊这个环节的三个核心操作:缺失值填充、异常点剔除、数据归一化。

核心观点:预处理做得好不好,直接决定了后续滤波和增强算法的天花板。数据质量差,再牛的算法也救不回来。

深度图预处理 缺失值填充 异常点剔除 数据归一化 邻域插值 · 中值填充 时间域填充 统计滤波 · 深度跳变检测 置信度阈值 Min-Max · Z-Score 鲁棒归一化 预处理质量 → 算法上限

3.1 缺失值填充:把窟窿补上

ToF传感器为什么会产生缺失值?原因很多。低反射率的物体(比如黑色衣服)、强环境光干扰、或者物体边缘的飞行时间混叠,都会导致某些像素收不到有效信号。这些像素在深度图中表现为0值或者NaN。

我个人习惯把缺失值分成两类:孤立缺失区域缺失。处理方式不一样。

3.1.1 孤立缺失点

就是那种周围像素都有值,就中间一个点黑掉的情况。这种最简单,用邻域插值就能搞定。

// 邻域均值填充(C++风格伪代码)
void fillIsolatedHoles(float* depth, int width, int height) {
    for (int y = 1; y < height - 1; y++) {
        for (int x = 1; x < width - 1; x++) {
            int idx = y * width + x;
            if (depth[idx] == 0.0f) {
                float sum = 0.0f;
                int count = 0;
                // 3x3邻域
                for (int dy = -1; dy <= 1; dy++) {
                    for (int dx = -1; dx <= 1; dx++) {
                        float val = depth[(y + dy) * width + (x + dx)];
                        if (val > 0.0f) {
                            sum += val;
                            count++;
                        }
                    }
                }
                if (count > 0) depth[idx] = sum / count;
            }
        }
    }
}

我的经验:别用太大的窗口去填孤立点。5x5以上容易把边缘细节抹掉。3x3足够了,而且只填一次,不要迭代。迭代填充会让误差像滚雪球一样扩散。

3.1.2 区域缺失

如果是一大片黑色区域,比如黑色皮革沙发在深度图上直接「消失」了。这时候邻域插值没用,因为周围也都是0。

我遇到过这种情况——一个黑色哑光的桌面,ToF死活测不到。后来怎么解决的?时间域填充。利用多帧之间的信息互补。

// 时间域填充:用前一帧的有效值补当前帧的缺失
void temporalFill(float* currDepth, float* prevDepth, int size) {
    for (int i = 0; i < size; i++) {
        if (currDepth[i] == 0.0f && prevDepth[i] > 0.0f) {
            currDepth[i] = prevDepth[i];  // 直接继承
        }
    }
}

当然,这招有个前提——场景不能剧烈变化。如果物体在快速移动,时间域填充反而会引入拖影。所以我会加一个运动检测:如果当前帧和前一帧的差异太大,就放弃填充,宁可留黑也不要假数据。

3.2 异常点剔除:把脏东西洗掉

缺失值是「没数据」,异常点是「有数据但数据是错的」。ToF的异常点通常表现为:

  • 飞点(Flyer):某个像素突然跳到一个离谱的深度值,比如背景是3米,它给你来个0.5米
  • 边缘噪声:物体边缘处,由于前景和背景的深度跳变,容易产生介于两者之间的错误值
  • 多路径干扰:光线在物体之间多次反射,导致测距偏大

怎么剔除?我常用的方法有三个,按推荐程度排序:

方法 原理 适用场景 我给的评分
统计滤波 计算邻域均值和标准差,超出μ±kσ的视为异常 均匀平面、缓慢变化场景 ★★★★☆
深度跳变检测 相邻像素差值超过阈值则标记为异常 物体边缘、遮挡区域 ★★★★★
置信度阈值 利用ToF传感器自带的置信度/幅值信息 所有场景(如果有置信度数据) ★★★★★

注意:统计滤波的参数k很敏感。k太大,异常点漏网;k太小,会把正常边缘也干掉。我一般取k=2.5,然后根据实际效果微调。曾经有一次我偷懒没调参,结果把一个人的鼻子给滤平了……嗯,从那以后我再也不敢直接套默认参数。

深度跳变检测是我个人最喜欢的方法。为什么?因为它简单、直观、而且对边缘保护得特别好。

// 深度跳变检测:水平+垂直方向
void detectEdgeAnomalies(float* depth, bool* mask, int width, int height, float threshold) {
    for (int y = 0; y < height; y++) {
        for (int x = 1; x < width; x++) {
            int idx = y * width + x;
            int prev = y * width + (x - 1);
            if (fabs(depth[idx] - depth[prev]) > threshold) {
                mask[idx] = true;   // 标记为异常
                mask[prev] = true;
            }
        }
    }
    // 垂直方向同理
}

阈值怎么设?我一般取场景中最大深度的5%~10%。比如你的ToF量程是5米,阈值就设在0.25~0.5米之间。你想想看,相邻像素差半米以上,大概率是噪声或者飞点。

3.3 数据归一化:把尺子统一

这一步很多人会忽略。但我要说,归一化做不好,后面的算法全白搭。

举个例子:你用CNN做深度图增强,输入是0~5000mm的原始深度值,网络权重初始化一般是0~1的小数。你想想,这梯度传下去得有多别扭?

3.3.1 Min-Max归一化

最常用的方法,把数据映射到[0,1]区间。

// Min-Max归一化
void minMaxNormalize(float* depth, int size, float minVal, float maxVal) {
    float range = maxVal - minVal;
    if (range < 1e-6) return;  // 防止除零
    for (int i = 0; i < size; i++) {
        depth[i] = (depth[i] - minVal) / range;
        depth[i] = clamp(depth[i], 0.0f, 1.0f);
    }
}

这里有个坑:minVal和maxVal怎么取?

  • 全局固定值:比如用传感器的量程范围(0~5000mm)。好处是每帧的尺度一致,坏处是如果场景深度范围很小(比如只用了0~1000mm),那归一化后的分辨率会很低
  • 每帧动态值:取当前帧的最小值和最大值。好处是充分利用动态范围,坏处是不同帧之间的尺度不一致,时序模型可能会困惑

我个人的建议:如果是做单帧处理,用动态值;如果是做时序处理(比如视频),用全局固定值。

3.3.2 Z-Score标准化

把数据变成均值为0、标准差为1的分布。适合数据分布比较集中的场景。

// Z-Score标准化
void zScoreNormalize(float* depth, int size) {
    float mean = 0.0f, std = 0.0f;
    for (int i = 0; i < size; i++) mean += depth[i];
    mean /= size;
    for (int i = 0; i < size; i++) std += (depth[i] - mean) * (depth[i] - mean);
    std = sqrt(std / size);
    if (std < 1e-6) return;
    for (int i = 0; i < size; i++) depth[i] = (depth[i] - mean) / std;
}

避坑指南:Z-Score对异常点非常敏感。如果深度图里还有没剔除干净的飞点,均值和标准差都会被带偏。我曾经吃过这个亏——一个飞点把均值拉偏了半米,结果整个归一化后的数据都歪了。所以,一定要先做异常点剔除,再做归一化。顺序不能乱。

3.3.3 鲁棒归一化(我推荐)

用中位数和四分位距(IQR)代替均值和标准差。对异常点不敏感,适合ToF这种噪声较多的数据。

// 鲁棒归一化:使用中位数和IQR
void robustNormalize(float* depth, int size) {
    // 排序取中位数和四分位数
    std::vector<float> sorted(depth, depth + size);
    std::sort(sorted.begin(), sorted.end());
    float median = sorted[size / 2];
    float q1 = sorted[size / 4];
    float q3 = sorted[3 * size / 4];
    float iqr = q3 - q1;
    if (iqr < 1e-6) iqr = 1.0f;  // 防止除零
    for (int i = 0; i < size; i++) {
        depth[i] = (depth[i] - median) / iqr;
        depth[i] = clamp(depth[i], -3.0f, 3.0f);  // 截断极端值
    }
}

你看,鲁棒归一化天然就把异常点给截断了。超出3倍IQR的数值直接拉到边界,既保留了有效信息,又不会被噪声干扰。我现在大部分项目都用这个方法。

小结一下

预处理三步走:先补缺失,再剔异常,最后归一化。顺序很重要,别搞反了。

  • 缺失值填充:孤立点用邻域插值,区域缺失用时间域填充
  • 异常点剔除:深度跳变检测最实用,统计滤波要调参,置信度阈值最可靠(如果有数据的话)
  • 数据归一化:鲁棒归一化是首选,Min-Max适合固定量程,Z-Score要慎用

嗯,这一章的内容就到这里。预处理看起来简单,但细节很多。你动手写代码的时候,记得多打印几帧看看效果,别光盯着数值觉得没问题。视觉上的检查往往能发现算法发现不了的问题。


公众号:蓝海资料掘金营,微信deep3321