☰
DP动态规划立体匹配:从原理到工程实战
2026/10/16 9:22:26 网站建设 项目流程

如果你接触过双目视觉或者三维重建,一定绕不开一个问题:怎么在左右两张图里找到同一个点。立体匹配就是这个问题的统称,而DP动态规划算法是其中思路最直接、也很容易上手的一种解法。它把整张视差图拆成一条条扫描线,在每一行内用动态规划搜索最优视差路径,结果快、逻辑清晰,非常适合做入门选型、二次开发和关键帧初始化的预匹配。

这篇内容我不打算按教科书的方式带你过一遍公式,而是从实际工程的角度出发,把DP立体匹配的原理、实现、参数调节、常见坑和调试经验一次讲透。网上讲DP立体匹配的帖子不少,但大多停留在“扫描线搜索最优路径”这层,至于代价项怎么设、平滑惩罚怎么调、为什么跑出来总是有横条纹、为什么边界一团糟,很少有人讲透。这篇文章就把这些补上,适合刚接触立体匹配的开发者,也适合想快速搭一套预处理模块的老手参考。

1. 先把立体匹配要做的事说清楚

1.1 双目立体匹配到底在算什么

立体匹配的输入是两张已校正的灰度图像,左图和右图。所谓“校正”,就是通过双目相机的标定参数做极线校正,让同一个物理点在左右图像中落在同一条水平扫描线上。这样一来,匹配问题就变成了一维搜索:左图某个像素(x, y),只需要在右图的同一行y上去找它的对应像素(x - d, y),这个d就是视差。

视差和深度之间存在反比关系。在基线固定的情况下,视差越大,说明物体距离相机越近。所以立体匹配的输出,本质上就是给左图中的每个像素估计一个视差值,形成一张视差图。

这个问题的本质难度在于:不同物体表面可能颜色相近,纹理重复的区域更是容易匹配错。而真实的场景中还有遮挡、光照差异、噪声、前景边界断裂等等麻烦,所以立体匹配在做的事,一方面是“尽力找到最像的那个点”,另一方面是“靠邻域一致性把找错的地方纠正回来”。这正是后面DP算法大显身手的地方。

1.2 DP动态规划解决的是什么层面的问题

裸地逐像素找最佳匹配,得到的结果通常很乱。原因很简单:每个像素独立选择视差,完全没有考虑它和左右邻居之间的约束关系。真实世界里,一张连续的物体表面,它的视差应该是平滑变化的,相邻像素的视差不会突然剧烈跳变。

立体匹配的问题因此可以建模成一个能量函数最小化的过程,能量函数中包含两项:

  • 数据项:某个像素取某个视差时,匹配代价是多少,代价越小越像。
  • 平滑项:相邻像素之间的视差变化如果过大,就施加惩罚。

目标是在整张图上找一个视差组合,让数据项和平滑项的加权总和最小。这个问题从原理上等价于一个二维网格上的马尔可夫随机场优化,而这类问题在像素级约束下是NP-hard的,不可能在多项式时间内求出全局最优解。

于是就有了各种近似方法:图割、置信度传播、Cooperative Optimization,以及本文要说的DP动态规划。DP的方法很聪明,它先把问题退化成一维的扫描线问题,在每一行内部用动态规划求最优视差路径,因为一维的动态规划是有标准解的。代价是行与行之间不再有关联,但换来的是极快的速度和非常稳定的结果。

你可以在心里把DP立体匹配理解成:把一张地毯横向切成一条条细线,在每条线上做局部最优规划,最终拼回整张图。

2. DP立体匹配的核心设计思路

2.1 能量函数怎么定——数据项和平滑项缺一不可

DP立体匹配里的能量函数并不复杂,先看通用的定义形式:

E = Σ D(x, d(x)) + Σ V(d(x), d(x+1))

前一项是数据项,D(x, d(x))表示左图x这个像素取视差d(x)时的匹配代价。后一项是平滑项,V是相邻像素视差之间的惩罚。DP在一条扫描线上从左到右推进,每一步选择当前像素的视差时,都在“当前像素的匹配代价”和“与上一个像素视差变化带来的代价”之间取平衡,最终得到整行代价最小的路径。

这里有一个很关键的设计取舍:平滑项的形式直接决定了结果的性格。如果平滑项非常强,相邻像素必须视差接近,那么整个行会变得很平,物体边缘也会被糊掉。如果平滑项太弱,便失去约束力,结果跟独立匹配差不多,噪声和毛刺满天飞。

我实测下来,常用的平滑项做法是双阈值模型,与SGM里的做法很像:

  • 视差变化小于某个小阈值(比如1),给一个小代价P1。
  • 视差变化超过这个阈值但小于一个更大的阈值,给一个中等代价P2。
  • 视差发生大跳变时,如果是在纹理浅的区域,惩罚要弱一些,否则边缘处很容易被压平。

这个设计的物理含义是:允许真实深度不连续的存在,不能因为平滑项把背景和前景的边界弄糊。

还有一个容易忽略的细节:数据项如何选择。常见的有:

  • 绝对灰度差:简单,但对光照和噪声极其敏感。
  • SAD(绝对差和):在窗口内求和,有一定抗噪能力。
  • Census变换:对光照变化不敏感,保留纹理结构信息,很适合作为DP的数据项。
  • 归一化互相关NCC:对增益变化稳健,但计算量偏大。

我在做这个DP实现时,一开始用灰度差,结果在左右图亮度有差异的地方直接崩掉,后来切到Census变换,稳定性明显提升。

2.2 在DP与图割等算法之间做选择

为什么DP听起来“粗糙”,却在很多场景中仍然被使用?因为速度和效果的平衡点非常好。

图割和置信度传播这类全局方法,理论上结果更好,尤其在有复杂遮挡关系的场景中,它们能建模更大范围的结构约束。但在实际项目中,这类算法的计算量往往呈指数级或高多项式级的增长。拿两张普通分辨率的图来说,图割跑一次需要几十秒甚至几分钟,置信度传播的迭代也要花很长时间。

DP的复杂度则是O(W * D * 状态数),W是图像宽度,D是视差范围,状态数通常很小,像基于行的DP本质上只有D个状态。这意味着DP的处理速度是毫秒级别的,适合放到实时的流水线里,也适合作为预处理步骤快速给出初始化视差,之后再交给其他模块精修。

所以我的建议很明确:如果你的场景是实时或准实时的,或者你只是想要一个高质量的初始视差图,DP是首选。如果离线处理并且非常看重边界和遮挡区域的精度,那么考虑SGM(半全局匹配)或者全局优化方法更好。值得注意的是,SGM本身就可以理解成多方向扫描线DP的扩展,掌握了DP再做SGM,会顺畅得多。

3. 完整实现细节与实操步骤

3.1 基础结构:代码怎么组织

我用C++写过一版,也用Python写过快速原型。这里不限制语言,核心逻辑是一样的。先把整个流程拆成这几个模块:

  1. 读取左右灰度图。
  2. 设定视差搜索范围[dmin, dmax]。
  3. 对每个像素计算所有视差下的匹配代价,得到代价空间volume。
  4. 按扫描线逐行做动态规划,生成累计代价图。
  5. 回溯或直接取每列最小累计代价对应的视差作为初始视差。
  6. 事后处理:左右一致性检查、遮挡填充、亚像素细化、中值滤波。

规模上,假设图像宽度为W,高度为H,视差范围为D,那么代价空间的尺寸是W * H * D。以一张640x480的图、视差范围64来说,代价空间的体积就是64048064约2000万个值,用float存储需要80MB左右的内存。这个数字足够让你意识到,紧凑的数据结构和内存连续性很重要。

接着看动态规划的核心部分。对于每一行y,创建一个二维累计表C[x][d],表示从左边界走到(x, d)的最小累计代价。递推公式为:

C(x, d) = D(x, d) + min( C(x-1, d), C(x-1, d-1) + P1, C(x-1, d+1) + P1, min_k C(x-1, k) + P2 )

还要带上每个状态的前驱方向,等整行填完之后,回溯找到最小路径,就是这一行的视差序列。

下面给一个Python风格的核心伪代码,方便看清每一步做了什么:

for y in range(H): # 每个像素的全视差代价 for x in range(W): for d in range(D): cost = volume[y][x][d] best_prev = min( C_prev[d], # 视差不变 C_prev[d-1] + P1, # 小变化 C_prev[d+1] + P1, global_min_prev + P2 # 大跳变 ) C_cur[d] = cost + best_prev # 记录前驱方向,用于回溯 back_ptr[y][x][d] = argmin(...) C_prev = C_cur # 回溯获得视差

这里有个计算细节值得注意:global_min_prev不应该在循环内重复计算,应该在进入x之前先对上一列的所有视差求一次min,存下来直接调用。否则一个循环里多一层求min,计算量直接翻几倍。

3.2 匹配代价计算的工程选择

DP对数据项的依赖非常强,因为平滑项只能在“原本该是什么”和“邻居是什么”之间做调和。数据项如果错了,平滑项会把错误传播到一整行。

三种常用代价项,我分别说下实际表现:

  • 绝对灰度差(AD):|I_left(x) - I_right(x-d)|,很容易受左右相机曝光差影响。如果左右图亮度均衡,也不是不能用,但是对噪声很敏感。
  • 窗口SAD:把周围5x5或者9x9像素的绝对差加起来,能显著抑制噪声,代价是模糊了物体的边缘细节,视差图的边界会变钝。
  • Census变换:取一个窗口内像素与中心像素的大小关系,形成一个比特串,再比较两个比特串的汉明距离。它天然抵抗亮度偏移,且在局部保持纹理结构。缺点是如果窗口过小,在纹理稀疏区域会失去分辨力。

我一般用9x9窗口的Census结果作为数据项。这个变换的另一个好处是,它的数值天然是整数且范围稳定(毕竟汉明距离不会超过比特位数),在DP的累计代价表里基本不会出现overflow问题。

如果场景中噪声特别大,还可以在Census距离外面套一个截断函数,把过大的代价值压到固定上限,这样可以避免一个离群点带偏整行的动态规划。

3.3 平滑参数P1、P2与视差范围

参数调整是DP立体匹配里最花时间的部分。P1和P2不是随意拍的,需要理解它们的相对量级。

先看数据项的尺度。Census的汉明距离在9x9窗口下最大值是81,一般真实匹配处的距离可能在5-20之间。P1如果设置成100,那意味着视差每变化1个单位就付出100的惩罚,基本不给视差变化的空间,结果就是整行太平。P1取3到10这个量级比较合适,允许小幅度的视差渐变。P2是用于建模遮挡和物体边界的大跳变惩罚,一般取P1的3到5倍,即15-50之间。

如果使用SAD窗口作为数据项,代价尺度完全不同,需要重新标定。最简单的做法是用一小段真实图跑一遍,统计正确匹配区域和非正确匹配区域的代价分布,再反过来给P1和P2定参考值。

视差范围也不是越大越好。很多人直接设0到255,结果不仅慢,而且误匹配率上升。正确做法是根据双目配置估算最小和最大深度:深度近,视差大;深度远,视差小。比如近距离物体最近0.5米,视差可能到60px,那上限就设70。远距离5米以外不再关心,就把下限设为5。定好范围之后再扩张10%-20%留点余量即可。

3.4 一维动态规划的本质与边界问题

DP的“每一行独立”是最大的优点也是最大的缺点。优点在于快,缺点在于行与行之间没有任何约束。如果只是在每一行单独做DP,得到的视差图会带有明显的水平条纹效应,原因很好理解:右侧像素的视差选择会受到左侧累计的影响,却不会受到上方或下方像素的影响,所以一个错误的视差决策会沿着扫描线一路传播。

解决这类污染通常有两个方向:

  • 事后处理:中值滤波、双边滤波,去横纹极其有效。
  • 算法扩展:多方向扫描线,也就是SGM。SGM在多个方向各做一次类似的DP递推,然后把结果相加。这样条纹的传播方向被平均掉,效果提升非常明显。

如果你的目标是快速做一版,先用DP加中值滤波是完全可行的。如果你的项目对精度有更高要求,建议后面把DP升级成多方向扫描,代码改动不大,收益却非常直观。

3.5 完整实现一次DP立体匹配

我拿一个1920x1080的测试图跑了一版,视差范围设置为0到96,选用了9x9 Census代价项,P1设为8,P2设为40,最后再做一次3x3中值滤波和亚像素抛物线拟合。

流程的伪代码,可以从代价空间开始逐步走:

# 1. Census变换 census_left = CensusTransform(gray_left, 4) # 4是窗口半宽 census_right = CensusTransform(gray_right, 4) # 2. 计算代价空间(这里用汉明距离) volume = np.zeros((D, H, W), dtype=np.float32) for d in range(dmin, dmax + 1): shifted_right = shift(census_right, d) volume[d - dmin] = HammingDistance(census_left, shifted_right) # 3. 逐行DP volume = volume.reshape(D, H, W) # 调整形状 disparity_map = np.zeros((H, W), dtype=np.int16) for y in range(H): C_prev = np.full(D, 0.0) back_ptr = np.zeros((W, D), dtype=np.int16) global_min_prev = np.min(C_prev) # 避免重复计算 for x in range(W): C_cur = np.zeros(D) for d in range(D): # 一个很重要的细节:d-1, d+1要检查边界 cost_prev = min( C_prev[d], C_prev[d - 1] + P1 if d > 0 else inf, C_prev[d + 1] + P1 if d < D - 1 else inf, global_min_prev + P2 ) C_cur[d] = volume[d][y][x] + cost_prev back_ptr[x] = argmin(C_cur) C_prev = C_cur global_min_prev = np.min(C_prev) # 4. 回溯 d_opt = int(np.argmin(C_prev)) for x in range(W - 1, -1, -1): disparity_map[y, x] = d_opt + dmin # 恢复真实视差 d_opt = back_ptr[x, d_opt] # 沿前驱指针回溯

跑完之后我看了一眼输出,初期视差图确实是横纹明显,边界区域倒是基本能保持。中值滤波之后横纹大部分消失了,但边缘的细微层次还是差一些。这个阶段已经可以作为很多应用的输入了。

这里还要提一个非常容易被忽略的坑:回溯时视差索引别弄反。有些人习惯把视差范围dmin到dmax映射成0到D-1,回溯过程中拿到的是索引,要加回dmin才是真实视差值。很多人忘记这一步,最终出来的视差图整体偏移一个常数,看起来“好像对但全都是错的”。

3.6 亚像素细化与遮挡处理

得到整数视差图后,精度通常在1个像素级别,很多三维重建场景不够用。最轻量级的提升办法是抛物线拟合:用当前最优视差及其相邻两个视差对应的累计代价,拟合一条抛物线,取抛物线的顶点作为亚像素位置。

公式很简单:

d_sub = d + (0.5 * (C(d-1) - C(d+1))) / (C(d-1) - 2*C(d) + C(d+1))

注意分母不能为零,如果接近零就保留整数视差。

遮挡区域的识别也不难,方法是做左右一致性的交叉检查:从左图计算出来的视差图,映射到右图后再反向计算右图视差,如果左右视差相差超过1到2个像素,就标记为遮挡或误匹配区域。这些区域一般出现在物体边界靠背景的一侧,因为背景在左图里被前景挡住,在右图里根本找不到对应点。

遮挡区域的处理,基本有两种思路:

  • 用最近邻有效视差填充,方向是从物体边界往外扩散。
  • 标记成无效值,交给上层模块处理。

整体来说,我会推荐第二种,因为第一眼填出来的视差虽然看着“满”,但数值是猜的,用在三维重建里会制造出一堆假点。

4. 常见问题与调试心得

4.1 条纹就这样消除?先说为什么会有横纹

DP的横纹效应是它最知名的缺陷。你会发现视差图上有明显的一层层横向的“瀑布”,纹理越弱的区域越明显。原因是DP在单一行上做递推,某个位置的错误决策会通过平滑项沿水平方向传播,直到碰到一个纹理很强的锚点才会被纠正。所以在弱纹理区域、重复纹理区域,这一错误会延伸到整条线。

最简单的抑制方法是中值滤波,窗口3x3或5x5,效果立竿见影,缺点是边界可能被磨糊一点。

另一个经验是:把P2调大,强惩罚大跳变,能在一定程度上减少横纹的“传染”,代价是边界会被压平。所以不要把希望全寄托在参数上,后处理是必须的。

如果你受不了中值滤波带来的边界模糊,可以试试导向滤波或者双边滤波,它们能在保护边缘的前提下平滑弱纹理区域,效果比普通中值滤波高一个档次。

4.2 边界处一团糟是怎么回事

物体前背景交界处的视差存在跳变,DP的平滑项却不喜欢跳变。如果不给边界位置留余地,DP会倾向于在边界处做一个渐变的缓坡,从前景视差逐步过渡到背景视差,表现在视差图上就是物体的边缘变厚、边界被“吃掉”一圈。

这里一个比较实用的解法是利用颜色或梯度信息来调制平滑惩罚。在亮度图梯度比较大的地方,往往是物体边界,这时候可以减小P2的惩罚量,允许视差发生大跳变。反过来在平坦区域,惩罚加强,把噪声压住。

实现并不复杂:在构造平滑项时,根据相邻像素的灰度差动态调整P2:

P2_adaptive = P2 * (1 - edge(x))

edge(x)是edge map在x处的值,可以用Sobel梯度的归一化结果。如果梯度很大,edge接近1,P2_adaptive就变小,边界不会被压平。这个技巧在SGM里也常用,确实能显著改善边缘效果。

4.3 弱纹理和重复纹理区域:怎么让它们不那么混乱

弱纹理区域,比如一堵白墙,任何视差下的匹配代价都差不多,DP会在这类区域里随机漂移。重复纹理区域,比如百叶窗、密集栅栏,则容易出现整数倍错位,也就是视差跳了一个周期。

弱纹理区域没什么灵丹妙药,因为信息本来就少。唯一有效的做法是依赖平滑项,把周围可靠区域的视差传播进去。这正好是DP的强项。参数上的直观体现是P2不能设太大,否则判断好的区域不会扩散进去,但也不能太小,否则噪声也扩散进去了。经验值是P2相对数据项尺度来说,要让它“略高于大多数误匹配的代价”,但又“远低于真正大跳变的代价”。

重复纹理区域建议配合唯一性检测:如果某个像素在最优视差下的代价和第二优视差下的代价非常接近,说明这个匹配不可靠,可以直接标记为无效,等待后续处理。

4.4 代码性能优化的几个建议

DP立体匹配其实非常适合工程优化,几个关键点列出来:

  • 内存连续访问是最重要的。一个很常见的做法是把volume存储为[D][H][W]或[H][W][D],不同排列访存模式完全不同。做逐行DP时,当前行的数据应该是连续读取的,所以建议按行进行重组,避免在D维上跳着访问。
  • 提前计算global_min_prev。整个内循环里,真正发生的数据比较是C_prev的最小值,把它在循环外用向量化指令一次算出来,内层只做加减和比较。
  • 视差方向展开。如果是针对嵌入式平台,可以考虑将D维展开到固定循环里,让编译器自动做循环展开和指令流水化。
  • 多线程按行并行很直接,因为每行之间互相独立。开16个线程,显著加速,几乎无通信开销。代价是代价空间的构建也需要并行化,否则会成为新瓶颈。
  • Python代码里如果遇到性能瓶颈,把内层循环向量化,比如用numpy数组存储C_prev,使用np.minimum数组操作替代for循环,慢速下降会非常明显。

我做过一次对比:720p图像、视差128、C++多线程实现,DP核心部分单线程35毫秒,16线程压到6-8毫秒。预处理和后处理另算,但也都能在毫秒级完成。整个立体匹配模块跑在20毫秒以内没问题。

4.5 从DP到SGM的升级路径

如果你已经搭建好了DP立体匹配的框架,升级到SGM是水到渠成的事。SGM本质上就是不只在一个方向上做DP,而是在八个或者四个方向上各自做一遍动态规划,然后把累计代价加起来。每个方向的DP公式和DP完全相同,唯一的区别是起始位置和遍历方向不一样。

比如从左上到右下方向,就沿着对角线扫;从右上到左下方向,就沿着另一条对角线扫。

实现时可以复用DP里的大部分函数:

for direction in directions: C_dir = dp_pass(volume, direction, P1, P2) aggregated += C_dir

最后在aggregated里取最小代价对应的视差,得出来的视差图在边界和弱纹理区域的表现远超单扫描线DP。这也是学术上和工业界都认可的标准路径。

如果时间精力不太够,我建议路线是这样:先完整实现单行DP,把数据项、平滑项、回溯、后处理都跑通,理解每个模块为什么存在,然后加方向循环,这样最稳。反过来直接去抄SGM的开源实现,一旦出了问题很难定位。

4.6 调试数据与调参资源

调试立体匹配算法,去哪弄图?

  • 合成图:自己用渲染软件生成凹凸起伏的平面,左右图做平移,视差真值直接就是平移量,调试方便。
  • 公开数据集:经典的双目数据集非常适合跑通流程和对比精度,里面的ground truth视差图可以直接算误差指标。
  • 自己拍的实拍图:用双目相机或者手机夹在尺子上快速拍两幅,注意保证平行移动,不要在拍摄时旋转。

调参的顺序,我个人建议是:

  1. 先固定真实视差已知的合成图,确认数据项正确。
  2. 再固定数据项,扫描P1、P2,观察视差图的均方误差或误匹配率。
  3. 加窗口大小调节,重复观察。
  4. 最后处理左右一致性阈值。

每一步都保留上次参数下的结果图,你会发现参数与参数之间是有交互的,比如窗口变大之后,P2可以适当调大,因为窗口本身已经在局部平均了。

5. 总结一段实操感受

立体匹配里的DP算法,很多人在学习时觉得“太简单,不值得深究”,但实际工程里我反而建议你扎实写好它。它能让你真正理解数据项、平滑项、路径搜索、遮挡处理这些立体匹配的核心模块,理解清楚之后再上手SGM、端到端深度学习方案,感受会完全不一样。

我踩过的几个坑再强调一遍:一是回溯索引容易弄错偏移,二是global min的复用别写在循环内层,三是平滑项中的边界惩罚需要配合边缘图调制,四是遮挡区域最好标记为无效而不是强行填数。跑任何一张图之前,先用几个已知平移量的合成图检验你的代码,能帮你省下一大把调试时间。

这套方法我目前还在用,作为快速初始化模块嵌入到三维重建流水线里,效果稳定、可控。如果你也在做双目相关的工作,希望这篇内容能替你省点时间,少走些弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询