3、深度图预处理:缺失值填充、异常点剔除与数据归一化
拿到一张原始的ToF深度图,你第一眼看到的是什么?
说实话,我第一次看到的时候有点懵。满屏的黑点、白斑,还有一些莫名其妙跳变的数值。这哪是深度图,分明是抽象画。但这就是ToF传感器的常态——原始数据从来不会给你一个干干净净的结果。
所以,预处理这一步,说白了就是给深度图「洗脸」。洗掉脏东西,补上窟窿,再把数据拉到统一的尺度上。今天我们就聊聊这个环节的三个核心操作:缺失值填充、异常点剔除、数据归一化。
核心观点:预处理做得好不好,直接决定了后续滤波和增强算法的天花板。数据质量差,再牛的算法也救不回来。
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