1. 为什么我们需要图像校正?从“歪头看”到“平视”的转变

想象一下,你闭上一只眼睛,只用另一只眼睛看世界,然后快速切换到另一只眼睛。你会发现,虽然看到的物体大致相同,但视角有微妙的偏移。这就是我们双眼感知深度的基础原理。在计算机视觉里,我们用两个摄像头模拟这个过程,这就是双目立体视觉。它的终极目标,就是从这两张有视差的图片里,算出每个像素点的深度,从而重建出三维世界。

但理想很丰满,现实很骨感。我们买来的两个摄像头,几乎不可能像我们的眼睛那样完美对齐。它们可能一个高一点,一个低一点;或者一个朝左歪一点,一个朝右歪一点。这就好比一个人正常平视,另一个人却歪着头看同一个东西。拍出来的两张照片,虽然拍的是同一个场景,但对应点并不在理想的位置上。

这种“没对齐”的状态,会给后续的深度计算带来巨大的麻烦。因为计算深度的核心公式,是基于一个完美的假设:两个摄像头的光轴完全平行,成像平面也完全平行,也就是所谓的平行视图。只有在平行视图下,一个点在左图的位置和它在右图的对应点,才会严格地落在同一水平线上。这意味着,我们在右图中寻找左图某个点的匹配点时,只需要沿着它所在的那条水平线(我们称为极线)去搜索就行了,搜索范围从整张图瞬间缩小到一条线,计算量大大降低,精度也更容易保证。

所以,图像校正要干的第一件大事,就是把现实中“歪七扭八”拍出来的两张图,通过数学变换,“掰正”成理论上完美的平行视图。这个过程,就像是给两张照片做一次“眼部矫正手术”,让两个摄像头的视线重新对齐。我刚开始做项目时,没做校正就直接去匹配,结果算出来的深度图全是噪声和错误,简直没法看。后来老老实实加上校正步骤,效果立竿见影,深度图一下子清晰了很多。可以说,校正是双目立体视觉流程里承上启下的关键一步,前面承接相机标定(知道相机自己的参数),后面开启高效的立体匹配。

2. 图像校正的“五步拆解法”:手把手带你走一遍

理论听起来有点绕,我们直接来看怎么操作。图像校正的核心是找到两个变换矩阵,一个给左图用(H),一个给右图用(H'),把两张图变形成平行视图。这个过程通常可以分解为五个清晰的步骤。我会结合我常用的OpenCV库和实际代码片段来解释,让你能真正上手。

2.1 第一步:找到足够的“信使”点对

校正的第一步,是在左右两张图像上,找到至少8对(越多越好,通常建议15-20对)精确匹配的特征点。这些点就像信使,告诉我们的算法两张图之间的对应关系。常用的特征点有SIFT、SURF、ORB等。ORB因为速度快且免费,在实际项目中用得最多。

import cv2
import numpy as np

# 读取左右图像
img_left = cv2.imread('left.jpg', 0)  # 灰度图
img_right = cv2.imread('right.jpg', 0)

# 初始化ORB检测器
orb = cv2.ORB_create(nfeatures=1000)

# 检测关键点和计算描述符
kp_left, des_left = orb.detectAndCompute(img_left, None)
kp_right, des_right = orb.detectAndCompute(img_right, None)

# 使用BFMatcher进行匹配
bf = cv2.BFMatcher(cv2.NORM_HAMMING, crossCheck=True)
matches = bf.match(des_left, des_right)

# 按距离排序,取最好的前N个匹配
matches = sorted(matches, key=lambda x: x.distance)
good_matches = matches[:50]  # 取前50个好的匹配

# 提取匹配点的坐标
pts_left = np.float32([kp_left[m.queryIdx].pt for m in good_matches]).reshape(-1, 1, 2)
pts_right = np.float32([kp_right[m.trainIdx].pt for m in good_matches]).reshape(-1, 1, 2)

这里有个坑我踩过:匹配点一定要尽量均匀分布在整个图像区域,不要都挤在某个角落。如果自动匹配的点分布不好,有时需要手动添加一些角点。你可以用cv2.goodFeaturesToTrack找一些角点,再手动或半自动地在另一张图上找到对应点。

2.2 第二步:计算“关系密码”——基础矩阵F与极点

有了匹配点对,我们就可以计算基础矩阵(Fundamental Matrix, F)了。这个矩阵F蕴含了两幅图像之间所有的极几何约束关系,是校正的基石。简单理解,对于左图的一个点p,右图中它的对应点p'一定满足等式 p'^T * F * p = 0。计算F最经典的方法就是八点法

# 使用RANSAC算法计算基础矩阵F,可以排除错误匹配(外点)
F, mask = cv2.findFundamentalMat(pts_left, pts_right, cv2.FM_RANSAC, 3.0, 0.99)

# 使用内点(inliers)重新提取可靠的匹配点
pts_left = pts_left[mask.ravel()==1]
pts_right = pts_right[mask.ravel()==1]

计算出F之后,我们可以从中分解出两个极点(epipole)。左图的极点e,是右相机光心在左图像平面上的投影;反之亦然。在平行视图的理想情况下,这两个极点应该在无穷远处。我们现在的目标,就是通过变换,把右图的极点e'送到无穷远点(f, 0, 0)去。

2.3 第三步:设计右图的“传送阵” H'

这一步是校正算法的核心思想:构造一个专门给右图用的透视变换矩阵H',它的任务就是把右图的极点e'映射到无穷远点。OpenCV提供了一个非常方便的函数cv2.stereoRectifyUncalibrated,可以一次性帮我们算出左右图的校正变换矩阵。但理解其背后的分步构造有助于调试。

  1. 平移图像中心:先将图像坐标系原点从左上角平移到图像中心。
  2. 构造旋转矩阵R:计算一个旋转,使得新的光轴方向与基线(两个相机中心的连线)平行。
  3. 构造错切矩阵G:这是一个关键的步骤,通过一个特殊的变换,将极点“推”到无穷远。

这个过程数学上有点复杂,但幸运的是,我们通常不需要自己从头实现。cv2.stereoRectify(需要已知相机内参和相对位置)或cv2.stereoRectifyUncalibrated(仅需匹配点和图像大小)函数封装了所有这些计算。

2.4 第四步:优化左图的“搭档” H

只变换右图是不够的。我们需要同时优化左图的变换矩阵H,使得经过H和H'变换后,左右图中那些已知的匹配点对(我们第一步找到的)尽可能对齐。优化的目标是最小化所有匹配点对之间的垂直距离之和,即 Σd(Hp_i, H'p'_i)。因为平行视图下,对应点应该只有水平方向(u坐标)的差异,垂直方向(v坐标)应该完全相同。

这个优化过程通常也被集成在上述的OpenCV函数里。函数会返回两个完美的变换矩阵H1和H2(对应我们的H和H'),以及两个用于后续立体匹配的重投影矩阵

2.5 第五步:图像重采样,得到平行视图

最后一步,就是应用我们千辛万苦算出来的H1和H2,对原始的左右图进行重映射(Remap)。这个过程不是简单的旋转缩放,而是一种透视变换,可能会产生非矩形的图像区域。我们需要计算好映射关系,并对变换后的图像进行插值(如双线性插值),生成最终的、已校正的平行视图。

# 假设我们已经通过标定得到了相机内参和相对位置,使用 calibrated 方法
# K1, K2 是左右相机的内参矩阵
# D1, D2 是畸变系数
# R, T 是右相机相对于左相机的旋转和平移

# 计算校正变换
R1, R2, P1, P2, Q, roi1, roi2 = cv2.stereoRectify(
    K1, D1, K2, D2,
    image_size, # 图像尺寸 (width, height)
    R, T,
    alpha=0  # alpha=0 表示校正后图像会被裁剪掉所有无效像素(黑边)
)

# 计算用于重映射的映射表
map1_left, map2_left = cv2.initUndistortRectifyMap(K1, D1, R1, P1, image_size, cv2.CV_16SC2)
map1_right, map2_right = cv2.initUndistortRectifyMap(K2, D2, R2, P2, image_size, cv2.CV_16SC2)

# 应用重映射,得到校正后的图像
img_left_rectified = cv2.remap(img_left_original, map1_left, map2_left, cv2.INTER_LINEAR)
img_right_rectified = cv2.remap(img_right_original, map1_right, map2_right, cv2.INTER_LINEAR)

# 此时,img_left_rectified 和 img_right_rectified 就是完美的平行视图了。
# 你可以画一些水平线验证,对应点基本都在同一行上。

走完这五步,你就拥有了理论上完全对齐的两张图。这时候再去做立体匹配,就像是把一道复杂的二维搜索题,简化成了一系列简单的一维搜索题,效率和准确性都会大幅提升。

3. 校正之后:立体匹配与深度计算的优化实战

图像校正为我们铺平了道路,接下来就是在这条“水平线”上寻找对应点,也就是立体匹配。这是计算深度图最关键,也最耗时的步骤。校正带来的最大好处,就是将搜索限制在了极线上,通常是水平行。但这并不意味着问题就完全解决了,这里面依然有很多门道和需要优化的地方。

3.1 匹配代价计算:从相关法到 Census 变换

早期最直观的方法是相关匹配法。比如对于左图的一个点,我们在右图对应的水平线上,取一个同样大小的窗口(比如9x9像素),计算两个窗口的相似度。相似度可以用**平方差和(SSD)绝对差和(SAD)**来衡量。数值越小,说明越相似。

但相关法有个大问题:它对光照变化太敏感。左图亮一点,右图暗一点,就算窗口内容一样,算出来的差异也会很大。于是就有了归一化相关匹配法(NCC),它先对窗口内的像素值减去均值、除以标准差,再进行相关计算,这样就能一定程度上抵抗光照的线性变化。

在我做过的智能硬件项目里,比如扫地机器人的避障,对实时性要求很高。NCC计算量偏大,我们更常用的是Census变换汉明距离。Census变换不关心像素的绝对亮度,只关心一个像素与其邻域内其他像素的亮度相对关系(谁比谁亮),生成一个比特串。匹配时,计算两个比特串的汉明距离(有多少位不同)。这种方法对光照变化非常鲁棒,而且用位运算实现起来速度极快。

# 一个简化的SAD代价计算示例(实际中会用更高效的方式)
def compute_sad_cost(left_img, right_img, x, y, d, window_size=5):
    """计算在位置(x,y),视差为d时的SAD代价"""
    half = window_size // 2
    cost = 0
    for i in range(-half, half+1):
        for j in range(-half, half+1):
            # 确保索引在图像范围内
            ly = y + i
            lx = x + j
            ry = y + i
            rx = x + j - d  # 假设左图是基准,在右图中向左搜索
            if 0 <= lx < left_img.shape[1] and 0 <= rx < right_img.shape[1]:
                cost += abs(int(left_img[ly, lx]) - int(right_img[ry, rx]))
            else:
                cost += 255  # 边界惩罚
    return cost

3.2 代价聚合与视差优化:让深度图更平滑

仅仅计算每个像素、每个可能视差下的匹配代价,会得到一张非常“噪声”的代价立方体。我们需要通过代价聚合来平滑它。常见的聚合方法是在一个更大的窗口(比如5x5, 7x7)内,对某个视差下的所有代价求和或取平均。这相当于假设一个小区域内的深度是连续的。

但简单的大窗口聚合会模糊物体边缘。更先进的方法是引导滤波(Guided Filter)双边滤波(Bilateral Filter),它们能在平滑内部区域的同时,很好地保持边缘。OpenCV的StereoSGBM(半全局块匹配)算法就内置了复杂的代价聚合和优化步骤。

聚合之后,对于左图的每个像素,我们选择那个使得聚合代价最小的视差值d,作为它的初始视差。这就得到了最初的视差图

3.3 后处理:填坑与优化

初始视差图通常还有很多问题:遮挡区域(一个点在另一视角看不到)没有匹配,弱纹理区域(比如白墙)无法确定唯一匹配,边缘区域视差不连续。这就需要一系列后处理:

  1. 左右一致性检查:用左图作为基准算出视差图后,再以右图为基准算一次。如果一个点在左右视差图中能互相印证,则认为可靠;否则可能是遮挡区,需要标记为无效。
  2. 空洞填充:对于被标记为无效的像素(遮挡、误匹配),可以用其周围有效像素的视差进行填充。简单的可以用最近邻填充,复杂的会考虑图像的颜色边缘信息,避免跨越边界填充。
  3. 亚像素优化:我们之前找的视差都是整数像素级别的。通过在对赢匹配代价曲线(比如在最佳视差d附近拟合一个二次曲线)上找极小值点,可以将视差精度提升到亚像素级别,这对提升近距离物体的深度精度非常有效。
  4. 滤波去噪:最后,可以用一个保边缘的滤波器(如加权中值滤波)对视差图进行平滑,去除孤立的噪声点。

经过这一整套“组合拳”,你才能得到一张干净、准确、可用于三维重建的深度图。我习惯用OpenCV的StereoSGBM生成初始视差,然后自己写左右一致性检查和加权中值滤波来优化,效果比直接用内置的WLS滤波器在某些场景下更可控。

4. 避坑指南:校正与匹配中的常见问题与调参心得

理论流程走通了,但在实际项目中,你会遇到各种各样的问题。这里分享几个我踩过的坑和对应的解决办法,希望能帮你少走弯路。

4.1 校正质量不佳,极线不水平

这是最常见的问题。校正完了画几条水平线一看,对应点还是上下错位。原因通常有几个:

  • 匹配点质量差:第一步的特征点匹配就不准,或者点对数量太少、分布不均。解决办法:尝试不同的特征点(SIFT, SURF, ORB, AKAZE),严格筛选匹配对(用比率测试或RANSAC),必要时加入手动标记点。
  • 相机畸变未校正:如果相机镜头畸变较大(特别是广角镜头),必须先进行单目标定,去除畸变后,再用去畸变的图像来做立体校正。cv2.stereoRectify函数就要求输入已去除畸变的图像或相应的参数。
  • stereoRectify参数选择:特别是alpha参数。alpha=0会裁剪掉所有无效区域,得到最大化的有效矩形,但可能损失视野;alpha=1会保留所有原始像素(会有黑边),但有效区域可能变形。通常设为-1让函数自动选择一个折中值,或者设为0追求无黑边的干净图像。

4.2 立体匹配参数“迷宫”

以OpenCV的StereoSGBM为例,参数一大堆,调起来让人头疼。几个关键参数我的经验是:

  • minDisparity:最小视差。如果你的相机是前向平行放置,且物体都在前方,可以从0开始。如果相机是向内汇聚的(toe-in),可能需要设为负数。
  • numDisparities:视差搜索范围。必须是16的整数倍。这个值越大,能探测的深度范围越远,但计算量也越大,且对于无纹理区域更容易出错。一般根据你的基线长度和最近探测距离来估算。
  • blockSize:匹配的块大小。奇数,如3,5,7。太小则对噪声敏感,太大则模糊边缘。纹理丰富的场景可以用小一点(如3),纹理弱的场景用大一点(如7)。
  • P1, P2:控制视差平滑度的参数。P2通常远大于P1(例如P1=8*通道数*blockSize*blockSize, P2=32*通道数*blockSize*blockSize)。P2越大,视差图越平滑,但可能过度平滑细节。

我的调参流程通常是:先用一组保守的默认参数跑通流程,然后固定其他参数,一次只调一两个,观察视差图在物体边缘弱纹理区域重复纹理区域的表现变化。

4.3 深度图跳跃、空洞与“阶梯”效应

即使匹配做得不错,深度图也可能看起来不连续,像楼梯一样有“阶梯”,或者在平面上有空洞。

  • “阶梯”效应:这往往是视差离散化代价聚合窗口过大共同造成的。尝试减小blockSize,或者启用StereoSGBMmode=cv2.STEREO_SGBM_MODE_HH(全尺寸动态规划),虽然慢但更精确。亚像素优化也能显著改善这个问题。
  • 空洞:主要来自遮挡和误匹配。务必进行左右一致性检查来标记遮挡区域,然后用合理的空洞填充算法。填充时,优先从背景侧(视差小的方向)获取信息,这样更符合遮挡的物理意义。
  • 深度值跳变:检查你的投影矩阵Q是否正确。深度Z的计算公式是 Z = f * B / (d + doffs),其中f是焦距(像素单位),B是基线长度,d是视差,doffs是视差偏移。Q矩阵里包含了这些参数。如果Q矩阵计算有误(比如来自校正的参数不对),算出来的深度值就是错的。

最后,别忘了用真实数据验证。做一个简单的标定板,或者测量几个已知距离的物体,看看算法算出的深度和实际距离的误差有多大。这比肉眼观察视差图要可靠得多。双目立体视觉是一个从理论到实践跨度很大的领域,图像校正和深度优化是其中工程性极强的部分,多动手实验,多分析失败案例,是掌握它的唯一捷径。

Logo

北京人形旗下天工造物具身智能开源社区,聚焦具身天工与慧思开物两大平台

更多推荐