1. 从零开始:为什么你需要搞定双目摄像头标定?

如果你手头正好有一对双目摄像头,或者正打算用两个普通的USB摄像头自己搭建一个立体视觉系统,那你肯定遇到过这样的问题:拍出来的左右两张图,看着好像差不多,但当你真想用它们来计算深度、做三维重建时,却发现根本对不上。左边的桌子角在图像中间,右边的桌子角却偏上了一点。这种微小的、恼人的不一致,就是立体视觉里首先要解决的头号难题——相机对齐

想象一下,你的两只眼睛如果一高一低,或者看东西的角度差别很大,你的大脑就很难判断物体的远近。对于计算机来说,双目摄像头就是它的“眼睛”。双目标定和极线校正,干的就是帮你把这两只“眼睛”调整到“理想状态”的活儿。双目标定,就是精确测量出两个摄像头各自镜头的畸变(比如图像边缘的弯曲),以及它们俩之间的精确位置关系(谁在左、谁在右,偏了多少角度,距离多远)。极线校正,则是利用标定得到的结果,对原始图像进行一种“数字手术”,把左右两张图变换到同一个“理想”的平面上,让左右图中相同的物体,完美地落在同一水平线上。

这个过程听起来高大上,但用Python和OpenCV来实现,其实是一条非常清晰、可复现的路径。我自己在捣鼓机器人导航和三维扫描项目时,就反复走过这个流程。今天,我就把自己踩过的坑、试出来的有效参数,以及每一步的代码和中间结果,打包成一个完整的“操作手册”分享给你。我们的目标很简单:从一堆棋盘格照片开始,到输出可以直接用于立体匹配的、行对齐的校正图像对结束。只要你跟着步骤走,就能在自己的电脑上跑通整个流程。

2. 实战第一步:准备你的“标定尺”——棋盘格

工欲善其事,必先利其器。在开始写代码之前,我们得先准备好标定工具。在立体视觉中,最常用、最可靠的工具就是棋盘格。它就像一个已知尺寸的精密尺子,计算机通过识别它在图像中角点的精确位置,就能反推出相机的各种参数。

2.1 制作与打印棋盘格的讲究

首先,你需要一张高质量的棋盘格图。你可以在网上搜索“OpenCV checkerboard pattern”下载,或者用代码生成。关键参数是内角点的数量。注意,是内角点,不是方格数。比如一个8x10的棋盘格,它的内角点就是7x9。我通常使用(11, 12)这样的尺寸,角点足够多,精度会更好。

打印的时候,有几点经验之谈:

  1. 纸张要平整:最好用相纸或卡纸打印,贴在平整的亚克力板或硬纸板上。皱巴巴的纸会引入误差。
  2. 尺寸要准确:用尺子实际测量一下打印出来的方格边长。假设你设计时每个方格是30mm,打印出来也要尽量接近30mm。这个物理尺寸(单位:毫米)是后续计算真实空间关系的基础,一定要记准。
  3. 避免反光:不要覆亮膜,在光照均匀的环境下使用,确保棋盘格黑白对比鲜明,没有高光点。

准备好棋盘格后,接下来就是用你的双目摄像头给它拍“写真集”了。

2.2 采集图像:多角度、全覆盖的拍摄技巧

采集图像的质量直接决定标定的成败。你需要用左右两个摄像头,同时拍摄同一块棋盘格在不同位置和姿态下的照片。这里有几个核心技巧:

  • 数量要够:我建议至少准备15-20对有效的图像。太少的话,标定结果容易不稳定。
  • 姿态要多样:上下左右移动棋盘格,同时也要倾斜、旋转它。想象用棋盘格在摄像头前“画”出一个三维空间,要覆盖整个视野的各个角落,尤其是图像的四个边角。这是因为镜头畸变在边缘最明显,没有边缘的数据,畸变就校正不准。
  • 同时性:确保左右摄像头是同时拍摄同一姿态的棋盘格。如果是用两个USB摄像头,可以用OpenCV的VideoCapture同时读取两路视频流,然后同步抓帧。
  • 清晰度:照片一定要清晰,角点处的黑白边缘要锐利,不能模糊。

拍好的照片,建议按左右摄像头分两个文件夹存放,比如images/leftimages/right,并且对应的图片文件名要一致(例如left/001.jpgright/001.jpg),这样后续处理起来非常方便。

3. 核心流程解析:单目与双目标定

图像准备好了,我们就可以开始动手写代码了。整个标定过程分为两步:先给每个摄像头做“单人体检”(单目标定),再给两个摄像头做“关系鉴定”(双目标定)。

3.1 单目标定:摸清每个摄像头的“脾气”

单目标定的目的是求出每个摄像头内部的参数,也就是它的“个性”。主要包括:

  • 相机矩阵(Camera Matrix):包含焦距(fx, fy)和主点(cx, cy)。这描述了三维点如何投影到二维图像上。
  • 畸变系数(Distortion Coefficients):描述镜头因为物理缺陷导致的图像变形,比如鱼眼效应(径向畸变)或图像倾斜(切向畸变)。

我们来看关键的代码片段和解释:

import cv2
import numpy as np
import os

# 1. 定义棋盘格参数
CHECKERBOARD = (11, 12)  # 内角点数量 (宽度, 高度)
square_size_mm = 30      # 每个棋盘格方格的物理尺寸(毫米)

# 2. 为世界坐标系准备角点坐标
# 假设棋盘格在Z=0的平面上,生成每个内角点的(X, Y, 0)坐标
objp = np.zeros((CHECKERBOARD[0] * CHECKERBOARD[1], 3), np.float32)
objp[:, :2] = np.mgrid[0:CHECKERBOARD[0], 0:CHECKERBOARD[1]].T.reshape(-1, 2)
objp *= square_size_mm  # 乘以实际物理尺寸

# 3. 用于存储所有图像的数据
objpoints = []  # 三维世界点
imgpoints_left = []  # 左图像对应的二维图像点
imgpoints_right = [] # 右图像对应的二维图像点

# 4. 角点检测与亚像素细化
criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 30, 0.001)

left_img_dir = 'images/left'
right_img_dir = 'images/right'

# 假设左右目录中图片文件名一一对应
image_files = sorted([f for f in os.listdir(left_img_dir) if f.endswith(('.jpg', '.png'))])

for fname in image_files:
    img_l = cv2.imread(os.path.join(left_img_dir, fname))
    img_r = cv2.imread(os.path.join(right_img_dir, fname))
    gray_l = cv2.cvtColor(img_l, cv2.COLOR_BGR2GRAY)
    gray_r = cv2.cvtColor(img_r, cv2.COLOR_BGR2GRAY)

    # 查找棋盘格角点
    ret_l, corners_l = cv2.findChessboardCorners(gray_l, CHECKERBOARD, None)
    ret_r, corners_r = cv2.findChessboardCorners(gray_r, CHECKERBOARD, None)

    # 如果左右图都成功检测到角点
    if ret_l and ret_r:
        objpoints.append(objp)  # 世界坐标点对于每张图都是一样的(物体没动)

        # 亚像素级角点精确化:让角点定位更准
        corners_refined_l = cv2.cornerSubPix(gray_l, corners_l, (11,11), (-1,-1), criteria)
        corners_refined_r = cv2.cornerSubPix(gray_r, corners_r, (11,11), (-1,-1), criteria)

        imgpoints_left.append(corners_refined_l)
        imgpoints_right.append(corners_refined_r)

        # 可视化(可选):在图像上画出角点,检查是否正确
        # cv2.drawChessboardCorners(img_l, CHECKERBOARD, corners_refined_l, ret_l)
        # cv2.imshow('Left Corners', img_l)
        # cv2.waitKey(500)

print(f"成功找到 {len(objpoints)} 对有效图像用于标定。")

这段代码完成了数据准备。findChessboardCorners函数自动识别棋盘格角点,cornerSubPix则像用放大镜微调一样,把像素级的角点位置优化到亚像素精度,这对提升标定精度至关重要。

接下来,进行单目标定:

# 5. 分别对左右摄像头进行单目标定
ret_l, mtx_l, dist_l, rvecs_l, tvecs_l = cv2.calibrateCamera(
    objpoints, imgpoints_left, gray_l.shape[::-1], None, None
)
ret_r, mtx_r, dist_r, rvecs_r, tvecs_r = cv2.calibrateCamera(
    objpoints, imgpoints_right, gray_r.shape[::-1], None, None
)

print("左相机内参矩阵 (mtx_l):")
print(mtx_l)
print("左相机畸变系数 (dist_l, 通常为k1,k2,p1,p2[,k3]):")
print(dist_l)
print("\n右相机内参矩阵 (mtx_r):")
print(mtx_r)
print("右相机畸变系数 (dist_r):")
print(dist_r)

运行后,你会得到两个相机各自的mtx(内参矩阵)和dist(畸变系数)。把它们保存下来(比如用np.save),后续双目标定和校正都会用到。

3.2 双目标定:确定两个摄像头的“空间关系”

单目标定知道了每个相机“长什么样”,双目标定则是要搞清楚它俩“怎么摆的”。核心是计算两个相机之间的旋转矩阵 R平移向量 T

  • R (旋转矩阵):描述了右相机坐标系需要经过怎样的旋转,才能和左相机坐标系对齐。
  • T (平移向量):更关键,它描述了右相机相对于左相机的空间位置偏移。T向量的第一个分量(通常是X方向)的绝对值,就是两个相机光心之间的水平距离,也就是我们常说的“基线(Baseline)”,它是后续计算深度信息的基石。

使用OpenCV的stereoCalibrate函数可以一次性搞定:

# 6. 进行双目标定
# 注意:这里传入我们已经求得的单目内参和畸变作为初始值,可以提高优化速度和精度
flags = cv2.CALIB_FIX_INTRINSIC  # 固定内参,只优化R, T, E, F
# 也可以使用 cv2.CALIB_USE_INTRINSIC_GUESS 让函数在初始值附近微调内参

retval, cameraMatrix1, distCoeffs1, cameraMatrix2, distCoeffs2, R, T, E, F = cv2.stereoCalibrate(
    objpoints, imgpoints_left, imgpoints_right,
    mtx_l, dist_l, mtx_r, dist_r,
    gray_l.shape[::-1], criteria=criteria, flags=flags
)

print("\n=== 双目标定结果 ===")
print("旋转矩阵 R (从右相机到左相机):")
print(R)
print("\n平移向量 T (右相机相对于左相机,单位通常与标定板尺寸一致,此处为毫米):")
print(T)
print(f"\n基线长度 (Baseline) 约为: {abs(T[0]):.2f} mm")
print("\n本质矩阵 E:")
print(E)
print("\n基础矩阵 F:")
print(F)

这里有几个参数值得关注:

  • E (本质矩阵):包含了R和T的信息,用于对极几何。
  • F (基础矩阵):在像素坐标系下描述两个图像间对应点关系的矩阵,是立体视觉中的核心概念。
  • 重投影误差stereoCalibrate的返回值retval,本质上是平均重投影误差。这个值越小越好,一般要小于0.5像素。如果误差太大,说明标定数据质量不高或者有误,需要检查棋盘格检测是否准确、图像对是否同步等。

4. 极线校正:让左右图像“行对齐”

拿到标定参数后,我们就可以进行极线校正了。校正的目标是让左右两个图像平面“共面且行对齐”。校正后,左图上的任意一点,其在右图上的对应点(匹配点)一定位于同一行上。这将立体匹配的二维搜索问题,简化成了一维搜索问题,大大降低了计算量和匹配难度。

4.1 计算校正变换:stereoRectify 详解

这一步是校正的核心,由cv2.stereoRectify函数完成。它根据双目标定的结果(R, T)以及两个相机的内参,计算出将左右图像投影到新共面图像平面所需的变换。

# 7. 极线校正:计算校正映射
# 注意:图像尺寸需要传入 (宽度, 高度)
image_size = gray_l.shape[::-1]  # 例如 (640, 480)

(R1, R2, P1, P2, Q, validPixROI1, validPixROI2) = cv2.stereoRectify(
    cameraMatrix1, distCoeffs1,
    cameraMatrix2, distCoeffs2,
    image_size, R, T,
    alpha=0  # 这是一个重要参数!
)

print("左相机校正旋转矩阵 R1:")
print(R1)
print("\n左相机新投影矩阵 P1 (包含内参):")
print(P1)
print("\n视差转深度矩阵 Q (用于三维重建):")
print(Q)

这里重点说一下alpha参数:

  • alpha=0:函数会进行“有效矩形”校正,结果图像中所有像素都是有效的(无黑边),但可能会损失一部分视野(图像被裁剪)。
  • alpha=1:函数会进行“全视野”校正,保留所有原始像素,但校正后的图像四周会出现黑色未定义区域(黑边)。
  • 0 < alpha < 1:折中方案。我个人的经验是,对于大多数应用,设置为0-1(让OpenCV自动选择)效果就不错。如果你想最大化保留视野,可以设为0.51,然后后续再处理黑边。

Q矩阵非常重要,它可以将“校正后图像对”的视差(disparity)直接转换为三维坐标,是立体匹配输出深度图的关键。

4.2 生成映射表与图像重映射

计算出校正变换(R1, P1等)后,我们并不直接对图像进行复杂的数学变换,而是采用一种更高效的方式:重映射。我们先为每个像素计算一个“查找表”,标明校正后的图像中每个像素应该从原始图像的哪个位置取颜色值。

# 8. 计算原始图像到校正后图像的映射表
left_map1, left_map2 = cv2.initUndistortRectifyMap(
    cameraMatrix1, distCoeffs1, R1, P1, image_size, cv2.CV_32FC1
)
right_map1, right_map2 = cv2.initUndistortRectifyMap(
    cameraMatrix2, distCoeffs2, R2, P2, image_size, cv2.CV_32FC1
)

# 9. 加载一对测试图像进行校正
left_test = cv2.imread('images/left/test.jpg')
right_test = cv2.imread('images/right/test.jpg')

# 应用映射表,进行图像重映射
left_rectified = cv2.remap(left_test, left_map1, left_map2, cv2.INTER_LINEAR)
right_rectified = cv2.remap(right_test, right_map1, right_map2, cv2.INTER_LINEAR)

initUndistortRectifyMap生成两个映射表(map1, map2)。对于非鱼眼镜头,我们通常使用cv2.CV_32FC1类型。remap函数则利用这两个映射表,快速完成图像的校正变换。cv2.INTER_LINEAR是插值方法,用于计算非整数像素位置的颜色,平衡了速度和质量。

5. 结果可视化与效果评估

代码跑完了,我们得看看效果到底怎么样。一个直观的方法是并排显示校正前后的图像对,并在上面画一些水平线。

# 10. 可视化对比函数
def draw_horizontal_lines(image, line_count=10, color=(0, 255, 0), thickness=1):
    """在图像上绘制等间距的水平线,用于检查极线对齐"""
    h, w = image.shape[:2]
    step = h // (line_count + 1)
    img_with_lines = image.copy()
    for i in range(1, line_count + 1):
        y = i * step
        cv2.line(img_with_lines, (0, y), (w, y), color, thickness)
    return img_with_lines

# 创建对比图
def concat_images(limg, rimg, gap=20):
    """将左右图像水平拼接,中间留空隙"""
    h = max(limg.shape[0], rimg.shape[0])
    w1, w2 = limg.shape[1], rimg.shape[1]
    imgcat = np.zeros((h, w1 + w2 + gap, 3), dtype=np.uint8)
    imgcat[:limg.shape[0], :w1] = limg
    imgcat[:rimg.shape[0], w1+gap:] = rimg
    # 在中间画一条竖线分隔
    cv2.line(imgcat, (w1 + gap//2, 0), (w1 + gap//2, h), (255, 255, 255), 2)
    return imgcat

# 处理原始测试图
left_with_lines = draw_horizontal_lines(left_test)
right_with_lines = draw_horizontal_lines(right_test)
original_concat = concat_images(left_with_lines, right_with_lines)

# 处理校正后的图
left_rect_with_lines = draw_horizontal_lines(left_rectified)
right_rect_with_lines = draw_horizontal_lines(right_rectified)
rectified_concat = concat_images(left_rect_with_lines, right_rect_with_lines)

# 上下堆叠显示
final_display = np.vstack([original_concat, rectified_concat])
cv2.putText(final_display, "Original Images (Before Rectification)", (10, 30),
            cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 0, 255), 2)
cv2.putText(final_display, "Rectified Images (After Rectification)", (10, original_concat.shape[0] + 30),
            cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 0), 2)

cv2.imshow('Stereo Rectification Result', final_display)
cv2.waitKey(0)
cv2.destroyAllWindows()

# 保存结果
cv2.imwrite('rectification_comparison.jpg', final_display)

运行这段代码,你会得到一张对比图。上半部分是原始图像对,下半部分是校正后的图像对,并且都画上了绿色的水平线。评估校正效果的关键:观察下半部分校正后的图像。理想情况下,左右图中相同的物体特征(如棋盘格角点、桌角、书本边缘)应该严格位于同一根水平线上。如果水平线穿过了左右图相同的特征点,说明校正非常成功。如果仍有明显上下错位,可能需要回头检查标定数据的质量或stereoRectify的参数。

6. 参数调优与常见问题排查

第一次跑通流程,结果可能并不完美。别担心,这是常态。下面分享几个我实践中遇到的坑和调优经验。

1. 标定误差太大怎么办?

  • 检查棋盘格检测:确保findChessboardCorners对每一张图都成功检测。可以像代码里注释掉的那样,把角点画出来看看,是不是所有角点都找对了。有时候棋盘格部分在视野外,或者光照不均导致角点模糊,都会检测失败。
  • 增加图像数量与多样性:回到第二步,补拍更多不同姿态、尤其是覆盖图像边缘区域的棋盘格照片。
  • 验证物理尺寸:再次确认你输入的square_size_mm是否与打印的棋盘格实际尺寸一致。
  • 尝试不同的标定标志(flags):在stereoCalibrate中,除了CALIB_FIX_INTRINSIC,也可以尝试CALIB_USE_INTRINSIC_GUESS,让算法在单目标定结果的基础上微调内参,有时能获得更好的整体优化。

2. 校正后图像有严重黑边或扭曲?

  • 调整stereoRectifyalpha参数:这是控制黑边多少的主要旋钮。如果黑边太多,尝试将alpha从1调小(如0.5);如果图像被裁剪太多,可以适当调大(但不要超过1)。
  • 检查validPixROIstereoRectify返回的validPixROI1validPixROI2是两个矩形,标明了校正后图像中无黑边的有效区域。你可以用这个信息来裁剪掉黑边:
    x, y, w, h = validPixROI1
    left_rectified_valid = left_rectified[y:y+h, x:x+w]
    # 对右图做同样处理
    

3. 如何保存和加载标定参数? 整个流程跑一次挺费时间的,所以一定要把标定结果(cameraMatrix1, distCoeffs1, cameraMatrix2, distCoeffs2, R, T)以及校正映射表(left_map1, left_map2, right_map1, right_map2)保存下来。推荐使用numpynp.savez进行打包保存。

# 保存所有参数
np.savez('stereo_calibration_params.npz',
         mtx_l=cameraMatrix1, dist_l=distCoeffs1,
         mtx_r=cameraMatrix2, dist_r=distCoeffs2,
         R=R, T=T,
         left_map1=left_map1, left_map2=left_map2,
         right_map1=right_map1, right_map2=right_map2,
         Q=Q)

# 加载参数
params = np.load('stereo_calibration_params.npz')
mtx_l = params['mtx_l']
dist_l = params['dist_l']
# ... 加载其他参数
left_map1, left_map2 = params['left_map1'], params['left_map2']
# 然后就可以直接用 remap 校正新图像了

4. 实际应用中的注意事项

  • 温度与稳定性:摄像头(尤其是带有自动对焦或变焦的)的物理参数可能会随温度或时间有微小变化。对于高精度应用,需要定期重新标定,或者使用工业级固定焦距镜头。
  • 分辨率一致性:标定和后续应用要使用相同的图像分辨率。如果你用1080p标定,但实际应用时用720p,需要按比例缩放内参矩阵(焦距和主点坐标)。
  • 从校正图像到深度图:得到完美的校正图像对只是第一步。接下来你需要使用立体匹配算法(如OpenCV中的StereoBMStereoSGBM)来计算视差图,再利用我们保存的Q矩阵,通过cv2.reprojectImageTo3D将视差图转换为三维点云。这才是立体视觉的终极目标。

整个流程走下来,你可能需要反复调试几次。但一旦你掌握了从数据采集、参数标定到图像校正的全链条操作,你就拥有了为自己的任何双目系统“校准双眼”的能力。这不仅是完成了一个教程,更是打开立体视觉大门,进行三维感知、测距、建模等更酷项目的基础。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐