一、相机标定:让相机学会“看”世界
说实话,做双目立体视觉这几年,我踩过最大的坑就是——相机没标好。
你想想看,两个相机拍同一场景,如果内参都不知道,畸变也不管,那深度估计出来的结果基本就是“玄学”。我刚开始做项目时,就吃过这个亏。标定板拍了几十张,结果算出来的深度图全是扭曲的。后来才发现,畸变系数没处理好。
所以这一章,咱们就踏踏实实把相机标定这件事搞明白。说白了,就是让相机知道:它自己的镜头长什么样,拍出来的图像怎么矫正。
1.1 相机标定到底在干什么?
相机标定,核心就三件事:
- 内参矩阵:焦距、主点位置——这些是相机自己的“出厂设置”
- 畸变系数:镜头带来的径向畸变和切向畸变——说白了就是图像边缘的拉伸或扭曲
- 去畸变:把拍出来的歪图像,矫正成“正常”的样子
我习惯用一个比喻:相机标定就像给相机配眼镜。内参是镜片的度数,畸变系数是散光,去畸变就是戴上眼镜后看清世界。
核心公式(记住这个就够了):
世界坐标系 → 相机坐标系 → 图像坐标系 → 像素坐标系
标定就是反着来:从像素坐标反推世界坐标,算出内参和畸变。
1.2 标定板:你的“标准答案”
标定需要一块棋盘格。为什么用棋盘格?因为角点检测简单、稳定。我在项目中试过圆形标定板,但棋盘格始终是最省心的。
标定板要求:
- 棋盘格尺寸要精确(比如每个格子30mm×30mm)
- 打印后贴在硬板上,不能皱
- 格子数建议9×6或8×6(内角点)
我的经验:标定板不要太小。我曾经用A4纸打印的棋盘格,结果远距离拍摄时角点检测失败。后来换成A3大小,问题就解决了。
1.3 OpenCV标定实战:一步步来
好,咱们直接上代码。我用的是OpenCV的 cv2.calibrateCamera(),这是最常用的方法。
第一步:准备数据
你需要拍10-20张不同角度的棋盘格照片。注意:
- 照片要覆盖整个视野(左上、右下、中间、倾斜)
- 不要只拍正对着的,那样标定结果会不准
- 我习惯拍15张左右,太少容易过拟合,太多没必要
import cv2
import numpy as np
import glob
# 棋盘格内角点数量(列,行)
pattern_size = (9, 6)
# 每个格子的实际尺寸(单位:mm)
square_size = 30.0
# 准备世界坐标系中的点
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
# 存储所有图像的点
objpoints = [] # 世界坐标系中的点
imgpoints = [] # 图像坐标系中的点
images = glob.glob('calib_images/*.jpg')
for fname in images:
img = cv2.imread(fname)
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
# 查找棋盘格角点
ret, corners = cv2.findChessboardCorners(gray, pattern_size, None)
if ret:
objpoints.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)
imgpoints.append(corners2)
# 可视化角点(可选)
cv2.drawChessboardCorners(img, pattern_size, corners2, ret)
cv2.imshow('img', img)
cv2.waitKey(500)
cv2.destroyAllWindows()
注意:如果 findChessboardCorners 返回 False,说明没找到角点。常见原因:照片太暗、棋盘格反光、或者格子数设置错了。我曾经因为打印时格子数数错,折腾了一下午。
第二步:执行标定
# 执行标定
ret, mtx, dist, rvecs, tvecs = cv2.calibrateCamera(
objpoints, imgpoints, gray.shape[::-1], None, None
)
print("内参矩阵:\n", mtx)
print("畸变系数:\n", dist)
输出结果长这样:
内参矩阵:
[[fx, 0, cx],
[ 0, fy, cy],
[ 0, 0, 1]]
畸变系数:
[k1, k2, p1, p2, k3]
这里 fx, fy 是焦距(像素单位),cx, cy 是主点坐标。k1, k2, k3 是径向畸变,p1, p2 是切向畸变。
怎么看结果准不准?
看重投影误差(ret值)。一般小于0.5像素就算不错。我做过一个项目,误差0.3像素,深度估计精度就很高了。如果误差大于1像素,建议重新拍照片。
第三步:去畸变
# 去畸变
img = cv2.imread('test.jpg')
h, w = img.shape[:2]
# 方法1:直接去畸变
undistorted = cv2.undistort(img, mtx, dist, None, mtx)
# 方法2:先计算映射,再重映射(效率更高)
newcameramtx, roi = cv2.getOptimalNewCameraMatrix(mtx, dist, (w,h), 1, (w,h))
mapx, mapy = cv2.initUndistortRectifyMap(mtx, dist, None, newcameramtx, (w,h), 5)
undistorted2 = cv2.remap(img, mapx, mapy, cv2.INTER_LINEAR)
# 裁剪掉黑色边缘
x, y, w, h = roi
undistorted2 = undistorted2[y:y+h, x:x+w]
cv2.imshow('original', img)
cv2.imshow('undistorted', undistorted2)
cv2.waitKey(0)
两种方法都可以。我个人习惯用 remap,因为如果你要对视频流做去畸变,提前算好映射表,后面每帧直接重映射,速度会快很多。
1.4 标定结果怎么用?
标定完成后,你需要保存内参和畸变系数。我一般存成numpy文件:
np.savez('calib_data.npz', mtx=mtx, dist=dist, newcameramtx=newcameramtx, roi=roi)
下次加载时:
data = np.load('calib_data.npz')
mtx = data['mtx']
dist = data['dist']
这样,后续的双目立体匹配、深度估计,都用这套参数。
1.5 避坑指南
做标定这些年,我总结了几条血泪教训:
- 照片数量不是越多越好:15-20张足够,太多反而引入冗余
- 角度要丰富:我见过有人只拍正面,结果标定出来的畸变系数全是0——那等于没标
- 棋盘格要平整:如果打印纸皱了,角点检测会偏移,标定结果就废了
- 光照要均匀:太暗或太亮都会导致角点检测失败
我曾经...有一次标定结果重投影误差0.1像素,以为完美了。结果放到实际场景中,深度图全是错的。后来发现,是因为标定板只拍了中心区域,边缘畸变没覆盖到。从那以后,我每次标定都会刻意让棋盘格出现在图像边缘。
1.6 本章小结
相机标定,说白了就是给相机做一次“视力检查”。内参是度数,畸变是散光,去畸变就是配眼镜。OpenCV帮我们封装好了大部分工作,但真正决定标定质量的,是数据采集的质量。
嗯,这一章的内容就到这里。记住:标定做不好,后面全是白忙活。
公众号:蓝海资料掘金营,微信deep3321