4. 双目立体匹配:SGBM算法原理、代价计算、代价聚合、视差优化

好,咱们进入双目深度估计的核心环节——立体匹配。说到立体匹配,很多人第一反应就是SGBM。没错,这个算法在ADAS领域里,可以说是经典中的经典。我最早接触它是在2016年做前向碰撞预警项目时,那时候算力有限,深度学习还没普及,SGBM几乎是唯一能在嵌入式平台上跑得动的实用方案。

说白了,SGBM就是半全局块匹配(Semi-Global Block Matching)。它不像全局匹配那样算得慢,也不像局部匹配那样容易出错。它走了一条中间路线——用多个方向的动态规划来近似全局优化。嗯,这个思路很巧妙,咱们一步步拆开看。

4.1 代价计算:怎么衡量两个像素像不像?

立体匹配的第一步,就是算代价。代价是什么?就是左图某个像素和右图某个像素的相似度。代价越小,说明它们越可能是同一个点。

常见的代价计算方法有这么几种:

  • 绝对差之和(SAD):直接算像素值的差的绝对值,简单粗暴,但容易受光照影响
  • 平方差之和(SSD):把差平方一下,对噪声更敏感
  • 归一化互相关(NCC):抗光照变化能力强,但计算量大
  • Census变换:这个我特别喜欢,它用局部二值模式来比较,对光照变化不敏感

我个人习惯用Census变换做代价计算。为什么?因为ADAS场景里光照变化太常见了——车子从隧道出来,或者树荫下突然变亮,SAD直接就崩了。Census变换只关心像素间的相对大小关系,所以鲁棒性很好。

Census变换的核心思想

以某个像素为中心,取一个窗口(比如9×7),把窗口内每个像素和中心像素比较。比中心大的记为1,小的记为0。这样整个窗口就变成了一个二进制串。然后比较左右图两个二进制串的汉明距离,就是代价。

代码实现大概是这样:

// 计算单个像素的Census值
uint64_t censusTransform(const Mat& img, int x, int y, int halfW, int halfH) {
    uint64_t census = 0;
    int bit = 0;
    uint8_t center = img.at<uint8_t>(y, x);
    
    for (int dy = -halfH; dy <= halfH; dy++) {
        for (int dx = -halfW; dx <= halfW; dx++) {
            if (dx == 0 && dy == 0) continue;
            uint8_t neighbor = img.at<uint8_t>(y + dy, x + dx);
            if (neighbor < center) {
                census |= (1ULL << bit);
            }
            bit++;
        }
    }
    return census;
}

// 计算代价(汉明距离)
int computeCost(uint64_t censusL, uint64_t censusR) {
    return __builtin_popcountll(censusL ^ censusR);
}

避坑指南:我曾经在项目中直接用SAD做代价计算,结果在夜间场景下匹配得一塌糊涂。后来换成Census变换,效果立竿见影。不过要注意,Census变换对纹理稀疏的区域(比如白墙)效果也不好,这是它的固有缺陷。

4.2 代价聚合:让匹配更靠谱

算完代价之后,你得到的是一个三维的代价空间——每个像素在每个视差下都有一个代价。但直接取最小代价对应的视差,结果会非常嘈杂,全是噪声。

为什么会这样?因为单个像素的代价太容易受噪声影响了。你想想看,一个像素点可能因为光照、噪声等原因,在错误视差下反而得到了更小的代价。所以我们需要做代价聚合——把周围像素的信息也考虑进来。

SGBM的代价聚合,用的是多方向动态规划。它沿着8个或16个方向,分别做一维的动态规划,然后把结果加起来。每个方向上的递推公式是:

L_r(p, d) = C(p, d) + min(
    L_r(p-r, d),        // 相同视差
    L_r(p-r, d-1) + P1, // 视差变化1
    L_r(p-r, d+1) + P1, // 视差变化1
    min_k L_r(p-r, k) + P2  // 视差变化很大
) - min_k L_r(p-r, k)

这里P1和P2是两个惩罚参数。P1惩罚小的视差变化(比如物体表面倾斜),P2惩罚大的视差变化(比如物体边缘)。

关键参数调优经验

  • P1一般设为8×窗口大小×窗口大小(比如8×9×7=504)
  • P2一般设为P1的4倍左右
  • P2可以自适应:P2 = P2_base / (|I(p) - I(p-r)| + 1),这样在边缘处惩罚小,允许视差跳变

我记得第一次调这个参数时,怎么调效果都不对。后来发现是P2设得太大了,导致物体边缘被过度平滑,行人的轮廓都糊掉了。嗯,这个参数真的很敏感,建议你在实际项目中用标定板或者已知距离的物体来调。

4.3 视差优化:从粗到精的打磨

代价聚合之后,我们得到了每个像素的聚合代价。接下来就是选最优视差了——通常就是取最小代价对应的视差值。但这样直接出来的视差图,还有不少问题需要处理。

常见的视差优化步骤包括:

  1. 唯一性检测:最优代价和第二优代价的比值要足够大,否则认为这个点不可靠
  2. 左右一致性检查:左图到右图的视差,和右图到左图的视差,应该互为相反数。如果不一致,说明是遮挡点或误匹配
  3. 亚像素插值:用二次曲线拟合最优代价附近的几个点,得到亚像素精度的视差
  4. 中值滤波:去除孤立的噪声点

注意:左右一致性检查的阈值一般设为1个像素。我曾经试过设成2,结果很多遮挡区域的错误匹配没被滤掉,导致深度图出现大量空洞。后来改回1,效果好了很多。

亚像素插值的代码实现:

// 二次曲线拟合求亚像素视差
float subpixelInterpolation(float cost_d1, float cost_d, float cost_d1) {
    float denom = cost_d1 + cost_d1 - 2 * cost_d;
    if (denom == 0) return 0;
    float delta = (cost_d1 - cost_d1) / (2 * denom);
    return delta; // 亚像素偏移量
}

4.4 SGBM的工程落地要点

在实际ADAS项目中,SGBM的落地有几个坑需要特别注意:

问题 表现 解决方案
纹理稀疏区域 白墙、天空等区域视差全是噪声 结合语义信息,或者用插值填充
遮挡区域 物体边缘出现视差断层 左右一致性检查+后处理填充
光照变化 左右图亮度不一致导致匹配失败 用Census变换代替SAD
计算资源 嵌入式平台上跑不动 缩小搜索范围、降低分辨率、用SIMD优化

我个人建议,在嵌入式平台上部署SGBM时,先把图像缩放到VGA分辨率(640×480),视差搜索范围限制在64以内。这样在ARM Cortex-A72上大概能跑到15fps左右,勉强够用。如果还不行,那就得考虑用硬件加速或者换方案了。

一个小技巧:SGBM的代价聚合方向,不一定非要8个或16个。我试过只用4个方向(水平、垂直、两个对角线),效果差别不大,但速度快了将近一倍。在资源受限的场景下,这是个不错的折中方案。

好了,关于SGBM的核心原理和工程实践,就讲到这里。下一章我们会聊聊双目深度估计的另一个重要话题——如何评估和优化深度图的质量。到时候我会分享一些我在实际项目中踩过的坑,以及怎么用后处理让深度图更干净。