立体视觉基础:对极几何、本征矩阵与基础矩阵、立体匹配

立体视觉,说白了就是让计算机拥有两只眼睛。我们人类靠双眼的视差来判断距离,计算机也一样。今天这一章,我们来聊聊立体视觉的核心原理——从几何约束到匹配算法,再到最终的深度图生成。

我个人习惯把立体视觉拆成三个层次:几何约束层(对极几何)、代数表达层(本征矩阵与基础矩阵)、计算实现层(立体匹配与深度计算)。这样理解起来会清晰很多。

立体视觉知识体系 双目图像输入 对极几何约束 本征矩阵 (E) 基础矩阵 (F) 立体匹配 (SGBM / BM) 视差图 → 深度图

1. 对极几何:两个相机之间的几何关系

先说说对极几何。你想想看,两个相机从不同角度拍同一个物体,它们之间有什么几何约束?

核心概念就三个:对极点对极线对极平面。左相机光心、右相机光心、空间点P,这三个点确定了一个平面——对极平面。这个平面与左右成像平面的交线,就是对极线。

嗯,这里要注意:左图上的一个点,在右图上对应的点一定在右图的某条对极线上。这就是对极约束。它把二维搜索降到了一维搜索,效率提升可不是一星半点。

对极约束的核心价值:

  • 将匹配搜索从二维降为一维
  • 减少误匹配的概率
  • 为后续的矩阵推导提供几何基础

我在项目中遇到过一个问题:相机标定稍微有点偏差,对极线就不准了。后来我养成了一个习惯——每次做立体匹配前,先画几根对极线看看,确认标定没问题再往下走。

2. 本征矩阵与基础矩阵

这两个矩阵,说白了就是将对极几何约束用数学公式表达出来。

2.1 本征矩阵 E

本征矩阵描述的是已标定相机之间的几何关系。它只包含旋转和平移信息,不包含相机内参。

公式长这样:

E = [t]× R

其中:
- R 是旋转矩阵
- [t]× 是平移向量 t 的反对称矩阵

对于左图上的一个归一化坐标点 x₁,它在右图上的对应点 x₂ 满足:

x₂ᵀ E x₁ = 0

这个公式看着简单,但实际用起来坑不少。我记得有一次,我算出来的E矩阵怎么都不对,折腾了半天才发现是坐标归一化时忘了考虑畸变参数。

2.2 基础矩阵 F

基础矩阵是E的升级版——它把相机内参也考虑进去了。对于未标定像素坐标的情况,用F矩阵更合适。

F = K₂⁻ᵀ E K₁⁻¹

其中:
- K₁、K₂ 分别是左右相机的内参矩阵

对于像素坐标 p₁ 和 p₂,约束关系为:

p₂ᵀ F p₁ = 0

我的经验:实际工程中,我更喜欢用基础矩阵F做初始估计,再用本征矩阵E做精细优化。因为F矩阵可以直接从匹配点对计算出来,不需要事先知道相机内参。

矩阵 输入坐标 是否需要内参 自由度 典型应用
本征矩阵 E 归一化坐标 5 已标定系统、视觉SLAM
基础矩阵 F 像素坐标 7 未标定系统、图像匹配

3. 立体匹配:SGBM 与 BM

有了几何约束,接下来就是找对应点了。立体匹配算法有很多,但工程中最常用的就是BM和SGBM。

3.1 BM(块匹配)算法

BM算法,说白了就是拿左图的一个小窗口,去右图的对应极线上滑动,找最像的那个位置。

它的核心步骤:

  1. 代价计算:用SAD或SSD计算窗口相似度
  2. 代价聚合:简单求和(其实这一步BM做得比较粗糙)
  3. 视差计算:WTA(赢家通吃)策略
  4. 视差优化:左右一致性检查、中值滤波

避坑指南:我曾经在纹理稀疏的区域吃过亏——BM算法在那片区域几乎全错。后来我加了一个纹理置信度判断,纹理太弱的地方直接标记为无效视差。

3.2 SGBM(半全局块匹配)算法

SGBM是BM的升级版。它引入了全局能量函数的概念,但又不是全图优化——它沿着多个方向做动态规划,所以叫"半全局"。

能量函数长这样:

E(D) = Σ C(p, Dp) + Σ P₁·T(|Dp - Dq| = 1) + Σ P₂·T(|Dp - Dq| > 1)

其中:
- C(p, Dp) 是像素 p 在视差 Dp 下的匹配代价
- P₁、P₂ 是惩罚项
- T(·) 是条件判断函数

我个人习惯用OpenCV的SGBM实现,参数调优是关键:

import cv2
import numpy as np

# 创建SGBM对象
stereo = cv2.StereoSGBM_create(
    minDisparity=0,
    numDisparities=64,      # 视差范围,建议16的倍数
    blockSize=11,           # 匹配窗口大小,奇数
    P1=8 * 3 * 11 ** 2,    # 视差变化1的惩罚
    P2=32 * 3 * 11 ** 2,   # 视差变化大于1的惩罚
    disp12MaxDiff=1,        # 左右一致性检查阈值
    uniquenessRatio=10,     # 唯一性比率
    speckleWindowSize=100,  # 斑点滤波窗口
    speckleRange=32         # 斑点滤波范围
)

# 计算视差图
disparity = stereo.compute(img_left, img_right).astype(np.float32) / 16.0

参数调优心得:

  • P2 一般设为 P1 的 4-8 倍
  • blockSize 越大,视差图越平滑,但边缘会模糊
  • numDisparities 不要设太大,64或128通常够用
  • speckleWindowSize 用来去除小噪点,我一般设100-200

4. 视差图与深度图计算

有了视差图,深度图就是一步数学转换的事。

4.1 视差与深度的关系

这个公式很直观:

Z = (f * B) / d

其中:
- Z 是深度(单位:毫米)
- f 是相机焦距(单位:像素)
- B 是基线长度(单位:毫米)
- d 是视差值(单位:像素)

你看,深度和视差成反比。物体越近,视差越大;物体越远,视差越小。这也是为什么远处物体的深度精度会下降——视差变化一点点,深度就变化很大。

4.2 从视差图到深度图

代码实现很简单:

def disparity_to_depth(disparity, f, baseline):
    """
    将视差图转换为深度图
    
    参数:
        disparity: 视差图 (像素)
        f: 相机焦距 (像素)
        baseline: 基线长度 (毫米)
    
    返回:
        depth: 深度图 (毫米)
    """
    # 避免除零
    valid = disparity > 0
    depth = np.zeros_like(disparity, dtype=np.float32)
    depth[valid] = (f * baseline) / disparity[valid]
    
    # 设置最大深度阈值
    max_depth = 10000  # 10米
    depth[depth > max_depth] = 0
    
    return depth

工程注意事项:

  • 视差图需要做亚像素插值,否则深度图会有明显的量化噪声
  • 深度图要做滤波处理,我常用双边滤波保边去噪
  • 注意单位统一:f和baseline的单位要一致
  • 无效视差区域(遮挡、纹理稀疏)要标记出来,不要强行计算

5. 实战经验总结

做立体视觉这几年,我踩过不少坑。这里分享几个关键点:

  • 标定是基础:相机标定不准,后面的一切都是白搭。我每次换场景都会重新标定一次。
  • 图像预处理很重要:直方图均衡化、高斯滤波,这些预处理步骤能显著提升匹配质量。
  • 视差范围要合理:根据你的场景深度范围来设定,不要盲目设大。
  • 后处理不能省:左右一致性检查、中值滤波、空洞填充,这些步骤能去掉大部分噪点。

嗯,立体视觉这块内容就这些。从对极几何的约束,到矩阵的数学表达,再到匹配算法的工程实现,每一步都有它的道理。做工程嘛,既要懂理论,也要会调参,两者缺一不可。

专注资料整理