双目相机标定

前言

相机标定是计算机视觉的基础。由于物理原因,相机采集到的图像存在一定的畸变需要进行标定,从而矫正图像。为后期计算视差图、识别等做准备。

生成标定板

在标定前需要标定板帮助我们进行标定。对于一般标定,无需昂贵的高精度标定板,下面推荐一个生成标定板的网站。

相机校正

相机校正包括单目校正和双目校正两个步骤,其中单目校正主要计算出相机的内参,来对镜头进行去畸变以及深度的推算。双目校正则是计算出左右相机的外参,知道外参后可以将左右相机分别旋转一定角度,以至左右相机的同名点在同一平面且同一水平线上。

实验

本实验完整代码已上传到Github中,下面内容为实验过程及代码说明。

双目相机标定

实验器材

本次使用双目相机进行试验。此双目相机由一颗可见光相机和一颗红外相机组成。

对于三维重建,使用双彩色相机进行图像采集更为合适。此款相机更适合对于复杂环境或特殊用途,如活体人脸识别等。但对于此双目相机标定试验仍可以进行试验。

双目相机

环境搭建

本实验使用OpenCV进行,需要进行相应的环境搭建。

使用Anaconda创建解释器环境。并使用pip进行安装。

1
pip install opencv-python

拍摄左右相机图片

使用双目相机同时拍摄标定板,并将图像存放到对应的文件夹中,并进行编号。

新建名为左右相机拍摄.py的文件,在文件中添加如下代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59

import cv2
import os

def create_directory_if_not_exists(directory):
if not os.path.exists(directory):
os.makedirs(directory)

def open_and_display_cameras():
# 创建存储照片的文件夹
left_folder = 'left_photos'
right_folder = 'right_photos'
create_directory_if_not_exists(left_folder)
create_directory_if_not_exists(right_folder)

# 打开两个摄像头
left_camera = cv2.VideoCapture(0) # 左摄像头
right_camera = cv2.VideoCapture(1) # 右摄像头

if not left_camera.isOpened() or not right_camera.isOpened():
print("无法打开摄像头")
return

photo_count = 0

while True:
# 读取帧
ret_left, frame_left = left_camera.read()
ret_right, frame_right = right_camera.read()

if not ret_left or not ret_right:
print("无法获取帧")
break

# 显示帧
cv2.imshow('left_camera', frame_left)
cv2.imshow('right_camera', frame_right)

# 按下空格键拍照
key = cv2.waitKey(1) & 0xFF
if key == ord(' '):
left_photo_path = os.path.join(left_folder, f'l{photo_count}.jpg')
right_photo_path = os.path.join(right_folder, f'r{photo_count}.jpg')
cv2.imwrite(left_photo_path, frame_left)
cv2.imwrite(right_photo_path, frame_right)
print(f"照片已保存: {left_photo_path}, {right_photo_path}")
photo_count += 1

# 按下 'q' 键退出循环
if key == ord('q'):
break

# 释放摄像头资源
left_camera.release()
right_camera.release()
cv2.destroyAllWindows()

if __name__ == "__main__":
open_and_display_cameras()

双目摄像头矫正

获取角点

从拍摄的棋盘图像中提取角点。

在获取角点时,我们使用cv2.findChessboardCorners方法来检测角点,并使用cv2.cornerSubPix方法提高角点的精度。

chessboard_size = (9, 14)为棋盘内角点的数量,请根据实际棋盘大小进行修改。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
import cv2
import numpy as np
import glob

# 棋盘格的尺寸 (宽度, 高度)
chessboard_size = (9, 14)

# 棋盘格方格的实际大小 (单位: 米)
square_size = 0.010

# 对象点,例如 (0,0,0), (1,0,0), (2,0,0) ....,(8,5,0)
objp = np.zeros((chessboard_size[0] * chessboard_size[1], 3), np.float32)
objp[:, :2] = np.mgrid[0:chessboard_size[0], 0:chessboard_size[1]].T.reshape(-1, 2) * square_size

# 存储对象点和图像点的列表
objpoints = [] # 3d point in real world space
imgpoints_l = [] # 2d points in image plane for left camera
imgpoints_r = [] # 2d points in image plane for right camera

# 图像路径
left_images = glob.glob('left_photos/*.jpg')
right_images = glob.glob('right_photos/*.jpg')

if len(left_images) != len(right_images):
raise ValueError("左右图像数量不匹配")

for left_img, right_img in zip(sorted(left_images), sorted(right_images)):
img_l = cv2.imread(left_img)
img_r = cv2.imread(right_img)
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, chessboard_size, None)
ret_r, corners_r = cv2.findChessboardCorners(gray_r, chessboard_size, None)

if ret_l and ret_r:
objpoints.append(objp)
imgpoints_l.append(corners_l)
imgpoints_r.append(corners_r)

# 绘制并显示角点
cv2.drawChessboardCorners(img_l, chessboard_size, corners_l, ret_l)
cv2.drawChessboardCorners(img_r, chessboard_size, corners_r, ret_r)
cv2.imshow('Left Image', img_l)
cv2.imshow('Right Image', img_r)
cv2.waitKey(500)

cv2.destroyAllWindows()

进行单目标定

通过提取到的角点,使用cv2.calibrateCamera对左右相机分别进行标定,得到每个相机的内参(相机矩阵),畸变系数,旋转向量和平移向量。

内参(Camera Matrix):每个相机都有一个 3x3 的内参矩阵(mtx_l 和 mtx_r)。

畸变系数(Distortion Coefficients):每个相机的畸变参数(dist_l 和 dist_r),通常包含 5 个参数。

旋转向量和平移向量:描述相机坐标系与世界坐标系之间的关系。

1
2
3
4
5
6
# 校准左相机
ret_l, mtx_l, dist_l, rvecs_l, tvecs_l = cv2.calibrateCamera(objpoints, imgpoints_l, gray_l.shape[::-1], None, None)

# 校准右相机
ret_r, mtx_r, dist_r, rvecs_r, tvecs_r = cv2.calibrateCamera(objpoints, imgpoints_r, gray_r.shape[::-1], None, None)

进行双目标定

使用左右相机拍摄的棋盘图像,计算出两相机之间的旋转和平移关系。

旋转矩阵 (R):描述两个相机之间的旋转关系。

平移向量 (T):描述两个相机之间的平移关系。

本质矩阵 (E):两个相机的内参矩阵已知时,给出两相机坐标系之间的关系。

基础矩阵 (F):计算两个相机图像平面之间的关系。

1
2
3
4
5
6
7
8
9
10
11
12
# 立体校正
flags = 0
flags |= cv2.CALIB_FIX_INTRINSIC
criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 30, 0.001)

ret, mtx_l, dist_l, mtx_r, dist_r, R, T, E, F = cv2.stereoCalibrate(
objpoints, imgpoints_l, imgpoints_r, mtx_l, dist_l, mtx_r, dist_r, gray_l.shape[::-1],
criteria=criteria, flags=flags
)

# 计算校正映射
R1, R2, P1, P2, Q, roi1, roi2 = cv2.stereoRectify(mtx_l, dist_l, mtx_r, dist_r, gray_l.shape[::-1], R, T)

立体校正

在双目标定后,我们可以进行立体校正(Stereo Rectification),将左右相机的图像映射到相同的平面,方便后续的视差计算。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 计算重映射矩阵
map1_l, map2_l = cv2.initUndistortRectifyMap(mtx_l, dist_l, R1, P1, gray_l.shape[::-1], cv2.CV_32FC1)
map1_r, map2_r = cv2.initUndistortRectifyMap(mtx_r, dist_r, R2, P2, gray_r.shape[::-1], cv2.CV_32FC1)

# 应用校正
img_l = cv2.imread('left_photos/l0.jpg')
img_r = cv2.imread('right_photos/r0.jpg')
dst_l = cv2.remap(img_l, map1_l, map2_l, cv2.INTER_LINEAR)
dst_r = cv2.remap(img_r, map1_r, map2_r, cv2.INTER_LINEAR)

# 显示校正后的图像
cv2.imshow('Left Image', img_l)
cv2.imshow('Right Image', img_r)

cv2.imshow('Corrected Left Image', dst_l)
cv2.imshow('Corrected Right Image', dst_r)
cv2.waitKey(0)
cv2.destroyAllWindows()

矫正图像

使用等线来观察矫正效果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
def stack_images(img1, img2):
"""水平拼接图像,方便对比"""
height = max(img1.shape[0], img2.shape[0])
width = img1.shape[1] + img2.shape[1]
stacked_image = np.zeros((height, width, 3), dtype=np.uint8)

# 将左图和右图分别放置
stacked_image[:img1.shape[0], :img1.shape[1], :] = img1
stacked_image[:img2.shape[0], img1.shape[1]:, :] = img2
return stacked_image

# 拼接校正后的图像
comparison_image = stack_images(dst_l, dst_r)

# 绘制水平线,便于对比
for i in range(0, comparison_image.shape[0], 50): # 每隔50像素画一条线
cv2.line(comparison_image, (0, i), (comparison_image.shape[1], i), (0, 255, 0), 1)

# 显示拼接图像
cv2.imshow('Stereo Rectification Comparison', comparison_image)
cv2.waitKey(0)
cv2.destroyAllWindows()

完整代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
import cv2
import numpy as np
import glob

# 棋盘格的尺寸 (宽度, 高度)
chessboard_size = (9, 14)

# 棋盘格方格的实际大小 (单位: 米)
square_size = 0.010

# 对象点,例如 (0,0,0), (1,0,0), (2,0,0) ....,(8,5,0)
objp = np.zeros((chessboard_size[0] * chessboard_size[1], 3), np.float32)
objp[:, :2] = np.mgrid[0:chessboard_size[0], 0:chessboard_size[1]].T.reshape(-1, 2) * square_size

# 存储对象点和图像点的列表
objpoints = [] # 3d point in real world space
imgpoints_l = [] # 2d points in image plane for left camera
imgpoints_r = [] # 2d points in image plane for right camera

# 图像路径
left_images = glob.glob('left_photos/*.jpg')
right_images = glob.glob('right_photos/*.jpg')

if len(left_images) != len(right_images):
raise ValueError("左右图像数量不匹配")

for left_img, right_img in zip(sorted(left_images), sorted(right_images)):
img_l = cv2.imread(left_img)
img_r = cv2.imread(right_img)
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, chessboard_size, None)
ret_r, corners_r = cv2.findChessboardCorners(gray_r, chessboard_size, None)

if ret_l and ret_r:
objpoints.append(objp)
imgpoints_l.append(corners_l)
imgpoints_r.append(corners_r)

# 绘制并显示角点
cv2.drawChessboardCorners(img_l, chessboard_size, corners_l, ret_l)
cv2.drawChessboardCorners(img_r, chessboard_size, corners_r, ret_r)
cv2.imshow('Left Image', img_l)
cv2.imshow('Right Image', img_r)
cv2.waitKey(500)

cv2.destroyAllWindows()

# 校准左相机
ret_l, mtx_l, dist_l, rvecs_l, tvecs_l = cv2.calibrateCamera(objpoints, imgpoints_l, gray_l.shape[::-1], None, None)

# 校准右相机
ret_r, mtx_r, dist_r, rvecs_r, tvecs_r = cv2.calibrateCamera(objpoints, imgpoints_r, gray_r.shape[::-1], None, None)


# 立体校正
flags = 0
flags |= cv2.CALIB_FIX_INTRINSIC
criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 30, 0.001)

ret, mtx_l, dist_l, mtx_r, dist_r, R, T, E, F = cv2.stereoCalibrate(
objpoints, imgpoints_l, imgpoints_r, mtx_l, dist_l, mtx_r, dist_r, gray_l.shape[::-1],
criteria=criteria, flags=flags
)

# 计算校正映射
R1, R2, P1, P2, Q, roi1, roi2 = cv2.stereoRectify(mtx_l, dist_l, mtx_r, dist_r, gray_l.shape[::-1], R, T)

# 输出校正参数
print('输出校正参数')
print("R1:\n", R1)
print("R2:\n", R2)
print("P1:\n", P1)
print("P2:\n", P2)
print("Q:\n", Q)
print("roi1:", roi1)
print("roi2:", roi2)

# 计算重映射矩阵
map1_l, map2_l = cv2.initUndistortRectifyMap(mtx_l, dist_l, R1, P1, gray_l.shape[::-1], cv2.CV_32FC1)
map1_r, map2_r = cv2.initUndistortRectifyMap(mtx_r, dist_r, R2, P2, gray_r.shape[::-1], cv2.CV_32FC1)
print('输出重映射矩阵')
print("map1_l:\n", map1_l)
print("map2_l:\n", map2_l)
print("map1_r:\n", map1_r)
print("map2_r:\n", map2_r)

# 应用校正
img_l = cv2.imread('left_photos/l0.jpg')
img_r = cv2.imread('right_photos/r0.jpg')
dst_l = cv2.remap(img_l, map1_l, map2_l, cv2.INTER_LINEAR)
dst_r = cv2.remap(img_r, map1_r, map2_r, cv2.INTER_LINEAR)

# 显示校正后的图像
cv2.imshow('Left Image', img_l)
cv2.imshow('Right Image', img_r)

cv2.imshow('Corrected Left Image', dst_l)
cv2.imshow('Corrected Right Image', dst_r)
cv2.waitKey(0)
cv2.destroyAllWindows()

# 拼接校正后的图像
def stack_images(img1, img2):
"""水平拼接图像,方便对比"""
height = max(img1.shape[0], img2.shape[0])
width = img1.shape[1] + img2.shape[1]
stacked_image = np.zeros((height, width, 3), dtype=np.uint8)

# 将左图和右图分别放置
stacked_image[:img1.shape[0], :img1.shape[1], :] = img1
stacked_image[:img2.shape[0], img1.shape[1]:, :] = img2
return stacked_image

# 拼接校正后的图像
comparison_image = stack_images(dst_l, dst_r)

# 绘制水平线,便于对比
for i in range(0, comparison_image.shape[0], 50): # 每隔50像素画一条线
cv2.line(comparison_image, (0, i), (comparison_image.shape[1], i), (0, 255, 0), 1)

# 显示拼接图像
cv2.imshow('Stereo Rectification Comparison', comparison_image)
cv2.waitKey(0)
cv2.destroyAllWindows()

实验结果分析

左右摄像头画面的区别

可以从图中看出,由于两摄像头的物理位置原因,同时拍摄的物体在不同摄像头的画面的左右摄像头画面存在差异,左摄像头可以拍摄完整的图像,而右侧摄像头拍摄的棋盘不是完整的。

左右摄像头画面

左右相机棋盘图像数据

使用相机拍摄程序,同时使用左右相机对棋盘进行不同角度的拍摄,在对应的文件夹中图片的数量是一致的,且同一时刻的图像使用相同的编号。

左相机采集图像

右相机采集图像

角点检测

分别对左右相机拍摄的棋盘图像进行角点检测,利用检测到的角点,进行校正参数的计算。

角点检测

计算校正映射

校正参数的计算结果如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
输出校正参数
R1:
[[ 9.99968652e-01 7.11958972e-03 3.46507597e-03]
[-7.11718582e-03 9.99974424e-01 -7.05587627e-04]
[-3.47001084e-03 6.80903918e-04 9.99993748e-01]]
R2:
[[ 9.99941085e-01 6.91406038e-03 8.36797777e-03]
[-6.91985992e-03 9.99975837e-01 6.64307947e-04]
[-8.36318251e-03 -7.22174043e-04 9.99964767e-01]]
P1:
[[509.11951885 0. 296.01989746 0. ]
[ 0. 509.11951885 250.99332809 0. ]
[ 0. 0. 1. 0. ]]
P2:
[[509.11951885 0. 296.01989746 -12.52910219]
[ 0. 509.11951885 250.99332809 0. ]
[ 0. 0. 1. 0. ]]
Q:
[[ 1. 0. 0. -296.01989746]
[ 0. 1. 0. -250.99332809]
[ 0. 0. 0. 509.11951885]
[ 0. 0. 40.63495621 -0. ]]
roi1: (0, 4, 621, 474)
roi2: (4, 14, 636, 466)
输出重映射矩阵
map1_l:
[[ 26.016935 26.809679 27.606382 ... 632.6917 633.23663 633.7744 ]
[ 25.849155 26.644186 27.443148 ... 632.9653 633.5138 634.0552 ]
[ 25.683455 26.480747 27.281948 ... 633.2358 633.7878 634.33276 ]
...
[ 18.498121 19.340826 20.18694 ... 636.2985 636.9254 637.5461 ]
[ 18.607605 19.44846 20.292744 ... 636.0809 636.7049 637.3227 ]
[ 18.718735 19.55772 20.40016 ... 635.8608 636.4819 637.0967 ]]
map2_l:
[[ 4.2664475 4.1126733 3.9612215 ... 19.76085 20.045544
20.333712 ]
[ 5.0976753 4.9460087 4.7966433 ... 20.485853 20.76748
21.052553 ]
[ 5.9317703 5.782188 5.634885 ... 21.214697 21.49329
21.775297 ]
...
[470.41818 470.5368 470.65363 ... 466.05548 465.8481
465.63785 ]
[471.31512 471.4353 471.5537 ... 466.86148 466.65167
466.4389 ]
[472.21002 472.33182 472.45178 ... 467.6646 467.4523
467.237 ]]
map1_r:
[[ 4.5652542e-01 1.4337360e+00 2.4116211e+00 ... 6.3474969e+02
6.3567242e+02 6.3659406e+02]
[ 4.2274535e-01 1.4003611e+00 2.3786469e+00 ... 6.3478564e+02
6.3570892e+02 6.3663110e+02]
[ 3.8933307e-01 1.3673503e+00 2.3460336e+00 ... 6.3482111e+02
6.3574493e+02 6.3666766e+02]
...
[-3.0759840e+00 -2.0907555e+00 -1.1049554e+00 ... 6.3203741e+02
6.3297253e+02 6.3390662e+02]
[-3.0623775e+00 -2.0774810e+00 -1.0920093e+00 ... 6.3199725e+02
6.3293188e+02 6.3386554e+02]
[-3.0484853e+00 -2.0639238e+00 -1.0787835e+00 ... 6.3195673e+02
6.3289093e+02 6.3382410e+02]]
map2_r:
[[-13.135011 -13.151552 -13.16768 ... -4.6508904 -4.597651
-4.543879 ]
[-12.159264 -12.1754465 -12.191218 ... -3.699166 -3.6464214
-3.593148 ]
[-11.183023 -11.198851 -11.214271 ... -2.7468433 -2.6945884
-2.6418087]
...
[463.33337 463.3562 463.37875 ... 464.14984 464.12
464.08972 ]
[464.31665 464.33975 464.36255 ... 465.11093 465.0807
465.05002 ]
[465.29956 465.32294 465.34598 ... 466.07156 466.04092
466.00986 ]]

矫正前后对比

使用计算得到的矫正参数,将左右相机拍摄的棋盘图像进行校正,并显示对比。

矫正前后的对比图如下

矫正效果图

分析对比图我们可以很明显的观察到,矫正前后的图像是不同的。不同的位置如图所示。

左相机经过校正后,在图像的右侧出现的空白区域,证明了整体画面是经过调整过的。

右相机经过校正后,在图像的上侧出现的空白区域,且左下角区域的位置出现了调整,说明对画面的位置进行了有效的矫正。

细节分析

等线分析

绘制等线。观察左右相机拍摄同一物体,对应的像素是否在同一水平线上。

等线分析

在左右相机合成图像上,观察等线与像素点,可以很明显的观察到同一物体对应的像素点都在同一水平线上。

分析结果

总结

由于本实验使用的是双目相机,在物理上已经尽可能的保持了两个相机的间距是固定且是水平的,但由于安装等原因,避免不了的存在一定的误差。经过标定和矫正进一步的使得图像的误差变得非常小。

但本次使用的双目相机是红外与彩色相机,彩色相机与红外相机的图像可能在纹理、亮度、对比度等方面差异较大,对于特征点的提取存在一定的困难。若后期进行测距、视差、三维重建等工作,双彩色相机是更为合适的。

本实验,是基于张氏标定法进行的标定,此方法存在一定的弊端。

  1. 此方法严重依赖于标定板,标定板若出现损坏、不完整、变形等情况,会造成标定参数的误差。

  2. 需要多角度拍摄标定板,若对于某些设备无法完成多角度的拍摄,则会大大降低矫正效果。

  3. 对于需要高精度的场景,则需要高精度的标定板,高精度标定的生产难度较大导致价格较昂贵。

  4. 在拍摄标定板时,不能出现移动的情况,否则可能会造成标定参数的误差。

在相机和镜头的选择上,尽可能的使用固定间距的双目相机,且使用无畸变镜头,虽然矫正算法可能对画面进行一定程度的矫正,但是对于畸变严重的情况,会出现大面积裁切的情况,无法尽可能的保留有效图像信息。