ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

双目视觉立体匹配算法详解:从SAD到SGBM的完整实践

双目视觉立体匹配算法详解:从SAD到SGBM的完整实践 简介计算机视觉中从二维图像恢复三维深度信息是诸多应用的基础而立体匹配正是连接双目图像与深度数据的关键桥梁。通过计算左右相机图像中对应像素的水平偏移量即视差可以反推出场景深度进而支撑三维重建、障碍物检测与智能测距等任务。本文从最基础的窗口匹配代价函数出发逐层剖析SAD、SSD与ZNCC三类经典算法的数学原理与代码实现并对比其在不同光照和噪声条件下的鲁棒性。随后工程上广泛应用的OpenCV BM与SGBM算法被引入重点解释半全局能量优化和参数调优策略。无论你是入门学生还是从事双目视觉开发的工程师掌握这些基础算法都能为后续深度估计和点云生成提供扎实的认知底座帮助在实际场景中快速选型与排障。 如果你接触过双目视觉一定绕不开立体匹配。它的任务说简单也简单左右两个相机拍摄同一块场景找出左图里每个像素在右图中的对应点这个对应关系产生的横向偏移就是视差。有了视差才谈得上深度计算、三维重建、障碍物检测和测距。最近我用 Python 把几个最基础的立体匹配算法从头到尾实现并对比了一遍包括 SAD、SSD、ZNCC 这三个基于窗口代价函数的自由实现以及 OpenCV 直接可用的 BM、SGBM。这篇文章就把实现过程、关键原理、调参经验和踩坑记录完整整理出来适合刚入门双目视觉的学生、准备做双目测距的工程师以及想搞清楚视差图背后原理的开发者。先说结论如果你想快速拿到一张可用的视差图直接用 OpenCV 的 SGBM 是最省力的方案但如果你想真正理解立体匹配在做什么手写一遍 SAD 或 ZNCC 是绕不开的路。我建议按这个顺序学SAD 理解窗口匹配的直觉ZNCC 理解光照鲁棒性再看 BM 和 SGBM 如何把这些思想工程化。下面按这个思路展开。1. 立体匹配到底在解决什么问题在动手写代码之前先把问题的边界框清楚。立体匹配不是单纯在两张图上找相似区域它背后是一个完整的成像几何模型。只有知道我们假设了什么、化简了什么后面看算法和调参才不会一头雾水。1.1 视差和深度一个反比关系双目相机的核心是两台相距一定距离的相机这个距离叫基线baseline。由于两台相机位置不同同一个三维空间点在左右图像里的成像位置不会一致这个位置差就是视差disparity通常记作 d x_left - x_right。在理想情况下匹配点只存在水平方向的偏移垂直方向的偏移为 0也就是极线校正后的标准双目形态。深度 Z 和视差 d 的关系是反比Z f * B / d其中 f 是焦距B 是基线长度。这个公式告诉我们一个直观结论物体离相机越近视差越大越远视差越小逼近无穷远时视差趋近 0。所以立体匹配输出的视差图本质上是一张蕴含深度信息的中间产物。拿到它之后配合相机内参就能生成三维点云这也是很多三维重建项目的基础。理解了这一层你就明白为什么匹配必须做对——某一个像素的视差错了对应的深度就会错得离谱后续的点云和距离估计全部白费。1.2 经典四步框架所有算法都能装进去2002 年 Scharstein 和 Szeliski 在立体匹配综述里提出了一个经典框架到现在依然适用。几乎所有立体匹配算法都可以拆进这四个步骤匹配代价计算对每个像素、在每一个候选视差下计算一个代价代价越小表示越相似。SAD、SSD、ZNCC 就是这一层最典型的代价函数。代价聚合单像素的代价对噪声和弱纹理区域太敏感通常会在一个窗口或区域内累加、加权让匹配结果更稳定。视差计算/优化常见的做法是 WTAWinner Takes All也就是直接取最小代价对应的视差作为结果SGBM 这类算法则会在代价基础上加平滑约束进行能量函数优化。视差细化左右一致性检查、亚像素插值、中值滤波、斑点滤波等用于消除误匹配和孤立噪点。这套框架的好处是你可以把五种方法放在同一个骨架上对比SAD、SSD、ZNCC 停在第一二步BM 是工程化的局部匹配SGBM 则在第三步引入了全局能量优化。理解了框架后面讲每个算法时你就知道它卡在哪个环节、优势和短板在哪里。1.3 环境准备、图像校正与实验数据代码层面只需要三个东西Python 3.8 或更高版本、OpenCV、NumPy。OpenCV 提供了 BM 和 SGBM 的完整实现手写部分只用 NumPy 就够。import cv2 import numpy as np left_gray cv2.imread(left.png, cv2.IMREAD_GRAYSCALE) right_gray cv2.imread(right.png, cv2.IMREAD_GRAYSCALE) print(left_gray.shape, right_gray.shape)这里有一个容易被新手忽略的前提输入图像必须是极线校正后的双目图像对。极线校正rectification让同名点只出现在同一水平线上这样匹配搜索才能从二维平面搜索变成一维水平扫描。如果图像没有校正你在代码里看到的正确匹配会全部错位这不是算法的锅是数据预处理没做好。工程上可以用cv2.stereoRectify和cv2.initUndistortRectifyMap配合标定参数做校正本文的讨论默认输入已经是校正好的图像。测试数据建议用 Middlebury 数据集里的标准图像对比如 Tsukuba、Cones这些图都是公开的而且带有 ground truth方便你评估算法好坏。自己拍的双目图也可以但务必确认已经校正。2. SSD、SAD、ZNCC三个基础代价函数从数学到代码这一节是整个主题的基石。SAD、SSD、ZNCC 都是局部立体匹配方法核心思路非常一致取左图某个像素周围的窗口在右图同一行上、候选视差范围内滑动同样大小的窗口计算两个窗口之间的相似度最相似的位置对应的偏移量就是这个像素的视差。区别只在于“相似度怎么定义”。2.1 SAD窗口内绝对差求和SADSum of Absolute Differences是最直观的代价函数。对左右两个窗口内的所有像素做差取绝对值再累加cost(d) Σ | L(xi, yj) - R(xi-d, yj) |这里的窗口大小是奇数比如 5x5i 和 j 遍历窗口内坐标。代价越小说明两个窗口内容越接近。这个操作的本质是假设窗口内的像素在左右图中颜色一致偏差主要来自噪声和轻微视差变化绝对值和可以稳定量化这种偏差。手写实现如下这个版本刻意保留了三层循环方便对照原理def sad(left, right, max_disp, win_size5): h, w left.shape half win_size // 2 disp np.zeros((h, w), dtypenp.float32) for y in range(half, h - half): for x in range(half, w - half): best_d, best_cost 0, float(inf) for d in range(max_disp): if x - d half: break l_win left[y-half:yhalf1, x-half:xhalf1].astype(np.int16) r_win right[y-half:yhalf1, x-half-d:xhalf1-d].astype(np.int16) cost np.sum(np.abs(l_win - r_win)) if cost best_cost: best_cost cost best_d d disp[y, x] best_d return disp注意几个细节左右图要转成 int16 再做减法避免 uint8 溢出搜索时如果x - d小于窗口半宽说明窗口已经超出图像边界直接结束当前候选视差循环最终每个像素取代价最小的视差。这个版本不是性能最优的但它是理解算法的最佳版本。实测时你会发现当左右图像亮度一致、噪声不大时SAD 的结果已经相当可用尤其是在纹理丰富的区域边界的轮廓能比较清晰地还原出来。2.2 SSD平方差求和一个容易踩的取舍SSDSum of Squared Differences和 SAD 几乎一样只是把绝对差换成了平方差cost(d) Σ ( L(xi, yj) - R(xi-d, yj) )^2在代码里只改一行把np.abs(l_win - r_win)换成(l_win - r_win) ** 2。看起来差别很小但数学上行为很不一样平方会放大较大的像素差异因此在高纹理区域SSD 的匹配区分度会比 SAD 更高正确匹配和错误匹配之间的代价差距更明显。但也正因为平方放大了异常值SSD 对噪声、高光反射、左右图曝光差异非常敏感。只要窗口里有几个像素因为反光或者传感器噪声出现较大差异代价就会被这几个点主导导致错误匹配。我在实验里发现亮度和噪声水平接近的图像对SSD 和 SAD 差别不大一旦图像里有高光区域SSD 的视差图会出现比 SAD 更多的毛刺。所以如果你的数据源不太干净SAD 的鲁棒性反而比 SSD 好。这个对比值得记在心里更强的差异放大不代表更好的匹配它同时放大了噪声。2.3 ZNCC归一化之后光照变化不再是灾难ZNCCZero-mean Normalized Cross Correlation和前面两个思路完全不同。它不是算差的累积而是算两个窗口的相关系数ncc(d) Σ (L - mean_L) * (R - mean_R) / sqrt( Σ(L - mean_L)^2 * Σ(R - mean_R)^2 )实现时先把窗口内像素减去窗口均值再做归一化相关。这样做的意义是什么减去均值等价于消除了窗口整体的亮度偏移除以标准差则消除了对比度缩放。也就是说左右图即使整体亮度不一致、曝光有明显差异ZNCC 依然能测出两个窗口的“形状”是否相似。在代码里ZNCC 要寻找的是最大的相关系数而不是最小代价。这一点最容易搞反具体实现如下def zncc(left, right, max_disp, win_size5): h, w left.shape half win_size // 2 disp np.zeros((h, w), dtypenp.float32) for y in range(half, h - half): for x in range(half, w - half): best_d, best_score 0, -1.0 l_win left[y-half:yhalf1, x-half:xhalf1].astype(np.float32) l_mean l_win.mean() l_norm l_win - l_mean for d in range(max_disp): if x - d half: break r_win right[y-half:yhalf1, x-half-d:xhalf1-d].astype(np.float32) r_mean r_win.mean() r_norm r_win - r_mean num np.sum(l_norm * r_norm) den np.sqrt(np.sum(l_norm**2) * np.sum(r_norm**2)) score num / (den 1e-6) if score best_score: best_score score best_d d disp[y, x] best_d return disp注意den 1e-6是为了防止窗口内像素完全相同时除零。ZNCC 的代价函数是三个里最抗光照变化的代价是计算量大很多。每个候选视差都要重复计算窗口均值、方差和乘积和纯 Python 实现下速度几乎是 SAD 的三到五倍。所以 ZNCC 适合在左右图亮度差异明显、或者多相机曝光不一致的场景使用比如室外自然光变化的环境。2.4 三个算法的实际效果对比我用标准测试图对三个算法做了对比。先用 5x5 窗口、max_disp 设为 64在纹理丰富的区域三者都能得到基本合理的视差图但在左右图亮度差异明显的模拟光照下SAD 和 SSD 的错误区域明显增多ZNCC 基本不受影响。下面是几个关键维度的总结指标SADSSDZNCC匹配标准最小化代价最小化代价最大化相关系数抗光照变化较弱较弱强抗噪声中等较差较好计算量较小较小大实现复杂度低低中典型适用场景亮度一致的图像对高纹理区域曝光不一致或光照变化一个我常说的类比SAD 像拿直尺量差距SSD 像把差距平方后再量ZNCC 则是把两段波形先对齐到同一水平线再比较形状。量差距的方式对绝对亮度敏感比较形状的方式对亮度不敏感这就是 ZNCC 在复杂光照下更稳的根本原因。3. BM和SGBM工程级匹配算法的原理与调用手写代价函数的过程能帮你建立直觉但真实项目里几乎不会用纯 Python 三层循环去跑匹配性能完全扛不住。OpenCV 提供的 BM 和 SGBM 才是工程主角。不过它们也不是黑盒理解了参数背后的意义你才能把它调好。3.1 BM块匹配的工程化加速BMBlock Matching的思路和前面说的窗口匹配一脉相承。它把图像划分成块在极线方向上滑动用 SAD 之类的代价函数计算匹配代价再用 WTA 选出视差。OpenCV 的StereoBM实现做了大量加速比如积分图、纹理过滤所以它能在 CPU 上达到很高的帧率适合实时场景做初步深度估计。调用方式非常简洁bm cv2.StereoBM_create(numDisparities80, blockSize15) disp_bm bm.compute(left_gray, right_gray).astype(np.float32) / 16.0注意numDisparities必须是 16 的倍数blockSize必须是奇数这两个是硬性约束不符合会直接报错或者行为异常。StereoBM的输出也是定点格式需要除以 16 才能得到浮点视差细节我在后面展开。BM 的优势是快缺点是视差图质量相对一般弱纹理区域容易产生大面积空洞深度不连续处也容易出现“胖边界”现象。但在资源受限的嵌入式设备上它往往是唯一现实的选择。3.2 SGBM从局部到半全局的能量优化SGBM 全称 Semi-Global Block Matching核心思想来自 Hirschmuller 的经典论文。它不再满足于对每个像素独立做 WTA而是把整个视差图看作一个能量函数的解E(D) Σ C(p, d_p) Σ P1 * [|d_p - d_q| 1] Σ P2 * [|d_p - d_q| 1]第一项是数据项也就是像素 p 在视差 d_p 下的匹配代价第二三项是平滑项用于惩罚相邻像素 p 和 q 之间视差不连续的情况。如果视差只差 1惩罚 P1如果视差跳变大于 1惩罚 P2。P2 要显著大于 P1因为深度不连续处通常意味着物体边界边界上的视差跳变是合理的但也不能一点代价都不付否则会出现大量条纹状误匹配。“半全局”这三个字是理解 SGBM 的关键。全局优化在整个二维平面上求解能量函数理论上效果最好但计算复杂度不可接受。SGBM 的折中做法是沿多个一维方向通常是 8 或 16 个方向做动态规划的代价累积再把所有方向的累积代价加起来。一维路径上的动态规划很高效多个方向叠加后又能在一定程度上模拟二维平滑约束这就是它在效果和性能之间取得平衡的原因。3.3 代码调用和参数细节OpenCV 里对应的是StereoSGBM创建方式如下sgbm cv2.StereoSGBM_create( minDisparity0, numDisparities80, blockSize11, P18 * 3 * blockSize ** 2, P232 * 3 * blockSize ** 2, disp12MaxDiff1, uniquenessRatio10, speckleWindowSize100, speckleRange32, modecv2.STEREO_SGBM_MODE_SGBM_3WAY ) disp_sgbm sgbm.compute(left_gray, right_gray).astype(np.float32) / 16.0参数说明参数作用备注numDisparities最大视差范围必须是 16 的倍数动态范围约为 16 的整数倍blockSize匹配窗口边长必须为奇数常见 3 到 21P1视差变化 1 时的小惩罚值越大视差图越平滑但细节越弱P2视差跳变时的大惩罚通常取 P1 的 4 到 10 倍disp12MaxDiff左右一致性检查阈值超限标记为无效视差uniquenessRatio最小代价与次小代价的差异比例低于阈值视为匹配不唯一speckleWindowSize斑点滤波窗口滤除孤立小区域spearackRange斑点内视差波动范围过大容易误滤真实边缘mode算法模式SGBM_3WAY 更快HH 更精确但更慢官方示例里的P18 * 3 * blockSize^2、P232 * 3 * blockSize^2是很好的起点后面调参时可以在这个基础上缩放。3.4 后处理和可视化SGBM 和 BM 的compute输出类型是int16里面存的其实是乘以 16 后的定点视差。为什么要乘 16因为 OpenCV 想保留四位小数精度的亚像素信息又不使用浮点存储所以先做定点放大。拿到输出后必须除以 16否则视差图数值看起来全都是放大的后续点云计算也会全部出错。可视化之前再做一次归一化def disp_to_visual(disp): return cv2.normalize(disp, None, 0, 255, cv2.NORM_MINMAX).astype(np.uint8) cv2.imshow(BM, disp_to_visual(disp_bm)) cv2.imshow(SGBM, disp_to_visual(disp_sgbm))如果视差图里有大量负值那些是无效视差归一化时应先屏蔽否则会把显示范围压扁disp_valid disp.copy() disp_valid[disp_valid 0] 04. 跑实验时踩过的坑与调参心得基础算法不难真正让新手崩溃的是那些藏在细节里的坑。这一节集中记录我实际跑实验时反复踩过、又花了不少时间才绕过的问题。直接照做可以帮你少走很多弯路。4.1 极线校正是匹配的地基第一个大坑拿没有校正过的双目图像直接喂给 BM 或 SGBM结果十有八九是一团糟。匹配算法为了效率假设同名点在同一水平线上搜索时才敢只在水平方向扫描。没有校正的图像同名点有垂直偏移水平搜索根本找不到正确匹配。这个问题在自拍图像里特别常见很多人以为双目匹配算法“能自动处理”实际上不行。正确流程是先标定相机、畸变校正、极线校正再把校正后的图像输入匹配算法。哪怕你用cv2.StereoBM_create这样的高抽像 API 也一样预处理这一关不过后面全是白搭。4.2 numDisparities和blockSize的硬性约束numDisparities必须是 16 的倍数不是的话 OpenCV 计算时会自动调整或者直接报错。实际设值时我习惯根据目标深度范围先反推如果基线和焦距已知最小可测距离对应最大视差那就把numDisparities设成刚好覆盖需要的最大视差再取整到 16 的倍数。blockSize必须是奇数因为窗口要对称。窗口过小噪声影响明显弱纹理区域全是洞窗口过大视差图过度平滑物体边缘会被磨掉。从 5x5 开始往上试通常比较合理我常用 9 到 15 之间的值。4.3 SGBM参数调试顺序与常见问题SGBM 参数多一起调很难定位问题。我的习惯是按顺序来先调numDisparities和blockSize让整体轮廓出来再调P1、P2控制平滑度最后用uniquenessRatio和斑点参数清理噪点。实际中常见的现象和应对现象可能原因调整策略大片横条纹或空洞P1、P2 太小平滑约束不足增大 P1/P2物体边缘被磨平P2 过大减小 P2或减小 P2 与 P1 的比值孤立亮点、白色噪点遮挡或误匹配增大 speckleWindowSize检查 disp12MaxDiff弱纹理区域全是洞blockSize 过小增大 blockSize视差整体偏小或偏大numDisparities 没覆盖到位重新估计视差范围还有一个很多教程没讲清的点modecv2.STEREO_SGBM_MODE_HH在理论上能做更复杂的优化效果通常更好但耗时是SGBM_MODE_SGBM_3WAY的几倍。如果你的场景不是离线处理先用SGBM_3WAY它速度更快参数调整到位的程度下质量差距没那么夸张。4.4 手写算法太慢的优化思路纯 Python 的 SAD、SSD、ZNCC 实现跑标准测试图怎么说呢速度慢到可以泡杯咖啡。这不是算法本身的问题是解释型语言三层 for 循环的原罪。如果你只是为了理解原理建议缩小输入图像或者把max_disp限制在较小范围比如 32保证实验能跑完。如果确实需要用 Python 实现但有性能要求几个方向用 NumPy 向量化窗口滑动用膨胀后的 shift 技巧批量计算 SAD或者用scipy.ndimage做滑动窗口聚合。更进一步可以用 Numba 对循环做 JIT 加速1 到 2 个数量级的提升是有的。OpenCV 自己的 BM 已经高度优化如果你不是要复现论文而是做应用直接用库就好不要重复造轮子。4.5 无效视差和后处理是提质的最后一步SGBM 里disp12MaxDiff是左右一致性检查的阈值。正常做法是从左图算一次视差从右图也算一次视差同名点互查如果两个方向得到的视差差值超过阈值就认为这个点是误匹配或遮挡点直接置为无效。disp12MaxDiff1时左右一致性差超过 1 就判为无效比较严格如果无效区域太多可以放宽到 2 或 3。斑点滤波参数speckleWindowSize和speckleRange也很实用。前者指定多大的孤立区域算斑点后者指定斑点内部允许的视差波动范围。比如speckleWindowSize100表示面积小于 100 像素的区域如果跟周围视差不一致就会被滤掉并填充周围的值。我最初直接复制网上参数时发现真实裂缝边缘也被误滤了后来把speckleRange调小才保住边缘这类参数一定要结合自己的数据微调。5. 五个算法放在一起怎么选五种算法的核心思想已经讲完最后落到选型。选型没有绝对的对错关键是匹配场景和资源约束。5.1 横向对比指标SADSSDZNCCBMSGBM匹配策略局部窗口局部窗口局部窗口局部块半全局优化速度慢手写慢手写最慢手写快中等精度一般一般较好中等高抗光照变化弱弱强中中边界保持一般一般一般一般好实现方式手写手写手写OpenCVOpenCV典型场景教学理解教学理解光照复杂研究实时初步估计离线/高精度前面三列是“学习向”算法后面两列是“工程向”算法。从学习到工程本质上是同一套匹配思想的不同实现深度。5.2 按场景选型实时视觉定位、机器人避障这类对帧率敏感的场合BM 是首选。它速度快、资源占用低虽然视差图质量一般但配合后续滤波和置信度判断已经足够支撑很多决策任务。在嵌入式设备上我通常还会配合降采样图像分辨率降到 640 或更低再把numDisparities限制在 64 以内帧率可以跑到非常理想。离线三维重建、工业尺寸测量、高精度深度估计首选 SGBM。它需要更多计算时间但换来的视差图平滑度和边缘保持能力对重建质量影响很大。尤其是物体轮廓要求严格的场景SGBM 的边界表现明显优于 BM。光照变化强烈的室外场景ZNCC 这类归一化相关系数的优势最大但手写性能差。你可以在 BM 或 SGBM 基础上自己做预处理比如直方图均衡化也能部分缓解光照问题。真正的工业场景里我一般建议在采集端就尽量统一曝光比算法端硬扛要可靠得多。5.3 做完基础算法之后还可以干什么如果你已经把这五个算法跑通了下一步的扩展方向很明确。一是亚像素细化。WTA 得到的视差是整数级用代价曲线在最小值附近做抛物线拟合可以插值出亚像素精度。对三维重建来说亚像素细化带来的深度精度提升非常可观。二是置信度评估。匹配代价曲线的峰值尖锐程度可以反映这个视差猜测的置信度。代价曲线越尖锐说明匹配越唯一代价曲线非常平缓说明这个像素可能落在弱纹理区域对应的匹配结果要打问号。给视差图附带置信度是后续滤波和融合的好依据。三是结合相机内参生成三维点云。有了视差图、焦距、基线和主点每对匹配点都能反投影出三维坐标。这一步做完你的立体匹配才算真正接入三维视觉的完整链路。四是往深度学习的立体匹配方向走。现在基于 cost volume 的深度学习方法在精度上已经明显超过传统算法但它依然沿用“匹配代价计算、代价聚合、视差优化”这个框架只是把这些模块换成了神经网络。传统算法的理解积累在看深度学习论文时会发挥很大作用。最后谈一点个人体会。立体匹配知识点乍看很散但它其实是二维图像走向三维空间的一条必经之路。我自己的学习建议是先手写一遍 SAD哪怕跑得慢也要把窗口匹配、代价最小化的直觉建立起来然后用 OpenCV 的 SGBM 把参数一个一个试过去观察每个参数对结果的影响最后回头对照四步框架你会发现所有算法都能装进同一个骨架里。我自己踩过最深的坑是过早迷恋高精度算法反而忽略了基础代价函数和预处理的作用。先把基础打牢再谈复杂的优化这条路看起来慢实际最快。本文还有配套的精品资源点击获取
返回列表