3、双目相机标定:张正友标定法原理、单目标定流程、双目标定与立体校正
做立体视觉,第一步就是让相机「认识自己」。
说白了,相机标定就是搞清楚两件事:镜头怎么畸变的,以及两个相机之间到底怎么摆放的。我刚开始做双目项目时,觉得标定就是个「走流程」的事,随便拍几张棋盘格就跑。结果呢?重建出来的点云全是歪的,深度误差大得离谱。后来我才明白——标定不准,后面全是白干。
核心结论:标定精度直接决定了立体匹配和三维重建的上限。你后面花再多功夫优化算法,也补不了标定留下的坑。
3.1 张正友标定法:为什么它成了行业标准?
张正友标定法,说白了就是用一张棋盘格照片,搞定相机内参和外参。它不需要精密的标定块,也不需要你知道棋盘格在空间中的绝对位置。你只需要打印一张棋盘格,从不同角度拍个十几张照片就行。
它的核心思路是这样的:
- 把棋盘格上的角点坐标(世界坐标系)和图像上的像素坐标(像素坐标系)对应起来
- 利用单应性矩阵(Homography)建立两者关系
- 通过多张照片的约束,解出相机内参矩阵 K
- 再考虑畸变系数,做非线性优化
我记得第一次手动推导这个算法时,被那个「闭合解 + 最大似然估计」的两步法绕晕了。后来在实际项目中才体会到——先给个差不多的初值,再用优化去微调,这个思路在工程里太常见了。
我的经验:棋盘格不要太小,也不要太大。我一般用 A4 纸打印 7×9 或 8×10 的棋盘格,每个格子边长 25-30mm。太小了角点检测不准,太大了照片里拍不全。
3.2 单目标定流程:一步一步来
单目标定,就是分别标定左相机和右相机。流程其实不复杂,但细节决定成败。
第一步:准备标定板
打印一张棋盘格,贴在硬纸板或亚克力板上。注意板子要平整,别弯了。我见过有人用软纸贴在墙上拍,结果标定出来的畸变参数全是错的。
第二步:采集图像
拿着标定板,在相机视野里转。关键是要覆盖各个角度和位置:
- 左右、上下、倾斜
- 近处、远处
- 让棋盘格出现在画面的四个角和中心
我一般拍 15-20 张。太少了解算不稳定,太多了也没必要。
第三步:角点检测
用 OpenCV 的 findChessboardCorners() 检测角点。如果检测失败,检查光照是否均匀、棋盘格是否反光。
import cv2
import numpy as np
# 棋盘格尺寸(内角点数量)
pattern_size = (7, 9) # 7列9行内角点
square_size = 25.0 # 每个格子边长,单位mm
# 准备世界坐标系中的点
objp = np.zeros((pattern_size[0] * pattern_size[1], 3), np.float32)
objp[:, :2] = np.mgrid[0:pattern_size[0], 0:pattern_size[1]].T.reshape(-1, 2)
objp *= square_size
# 存储所有图像的点
obj_points = [] # 世界坐标系点
img_points = [] # 图像坐标系点
for fname in image_files:
img = cv2.imread(fname)
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
ret, corners = cv2.findChessboardCorners(gray, pattern_size, None)
if ret:
obj_points.append(objp)
# 亚像素精度优化
criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 30, 0.001)
corners2 = cv2.cornerSubPix(gray, corners, (11,11), (-1,-1), criteria)
img_points.append(corners2)
注意:亚像素优化这一步别省。我试过不优化直接标定,重投影误差差了 0.3 个像素。对于高精度应用,这误差够你喝一壶的。
第四步:标定计算
调用 calibrateCamera(),得到内参矩阵 K、畸变系数 dist、旋转向量 rvecs 和平移向量 tvecs。
ret, mtx, dist, rvecs, tvecs = cv2.calibrateCamera(
obj_points, img_points, gray.shape[::-1], None, None
)
print("内参矩阵:\n", mtx)
print("畸变系数:\n", dist)
print("重投影误差:", ret)
第五步:评估标定结果
重投影误差一般要小于 0.5 像素。如果大于 1 像素,说明标定质量不行。我通常会检查:
- 是否有模糊的照片混进来了
- 棋盘格是否在画面边缘被严重拉伸
- 是否所有照片的角点检测都正确
3.3 双目标定:让两个相机「对齐」
单目标定搞定后,双目标定就简单了——它要算的是两个相机之间的旋转矩阵 R 和平移向量 T。
为什么要做这一步?因为立体匹配需要知道:左相机看到的一个点,在右相机里对应哪个位置。这个对应关系,全靠 R 和 T 来算。
流程是这样的:
- 分别标定左右相机(单目标定)
- 同时拍摄左右相机图像(必须同步!)
- 调用
stereoCalibrate()计算 R 和 T
# 双目标定
ret, K1, d1, K2, d2, R, T, E, F = cv2.stereoCalibrate(
obj_points, # 世界坐标点
img_points_left, # 左相机图像点
img_points_right, # 右相机图像点
K1, d1, # 左相机内参和畸变
K2, d2, # 右相机内参和畸变
gray.shape[::-1], # 图像尺寸
criteria=(cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 100, 1e-5),
flags=cv2.CALIB_FIX_INTRINSIC # 固定内参,只优化外参
)
print("旋转矩阵 R:\n", R)
print("平移向量 T:\n", T)
我的习惯:双目标定时,我会把左右相机同时拍摄的照片放在一起检查。如果左右图像中棋盘格的位置差异太大(比如一个在左边,一个在右边),说明两个相机的视野重叠不够,标定结果会不稳定。
3.4 立体校正:让图像「行对齐」
立体校正的目的很简单:让左右图像中对应的点,出现在同一行上。这样立体匹配时只需要在同一行搜索,计算量大大降低。
校正算法用的是 Bouguet 算法。它的思路是:
- 把旋转矩阵 R 分解成左右相机各转一半
- 再构造一个变换矩阵,让极线变成水平
# 立体校正
R1, R2, P1, P2, Q, roi1, roi2 = cv2.stereoRectify(
K1, d1, K2, d2, gray.shape[::-1], R, T,
alpha=0, # 0表示裁剪掉黑色区域,1表示保留全部
newImageSize=(0, 0) # 保持原图尺寸
)
# 计算校正映射表
map1x, map1y = cv2.initUndistortRectifyMap(K1, d1, R1, P1, gray.shape[::-1], cv2.CV_32FC1)
map2x, map2y = cv2.initUndistortRectifyMap(K2, d2, R2, P2, gray.shape[::-1], cv2.CV_32FC1)
# 应用校正
rectified_left = cv2.remap(img_left, map1x, map1y, cv2.INTER_LINEAR)
rectified_right = cv2.remap(img_right, map2x, map2y, cv2.INTER_LINEAR)
校正完成后,你可以画几条水平线检查一下。如果左右图像中同一个点不在同一行,说明校正有问题。我曾经遇到过一次,校正后图像扭曲得厉害,后来发现是棋盘格照片里有一张拍虚了,混进去导致标定偏差。
验证方法:在左右校正后的图像上画水平线,观察棋盘格角点是否落在同一行。如果偏差超过 2 个像素,建议重新标定。
3.5 知识体系总览
下面这张图,把整个标定流程串起来了。你可以把它当作一个检查清单:
嗯,整个流程走下来,你会发现标定其实不复杂,但每一步都有坑。我的建议是:多拍几张照片,多检查几次结果。标定数据是后面所有工作的基础,花再多时间在上面都值得。
避坑指南:我曾经因为标定板没贴平,导致校正后的图像边缘扭曲严重。后来每次标定前,我都会用手摸一下板子,确认没有弯曲。另外,拍摄时确保光照均匀,避免棋盘格上有阴影或反光。
标定完成后,你就可以进入下一步——立体匹配了。不过那是下一章的事,先把标定这关过了再说。