4. 相机内参标定实战:使用OpenCV进行棋盘格标定
相机内参标定,说白了就是搞清楚镜头到底是怎么看世界的。每个镜头都有脾气——焦距多少、光心偏不偏、畸变大不大。这些参数不标定,后面的三维重建、多传感器融合全是空中楼阁。
我最早做标定的时候,以为拿个棋盘格拍几张照片就完事了。结果呢?数据采了一堆,标定结果一塌糊涂。后来才明白,这里面的门道比想象中多得多。今天咱们就一步步走通这个流程。
4.1 标定原理:从世界坐标到像素坐标
先理清一个核心问题:相机是怎么把三维世界变成二维图像的?
整个过程分四步:
- 世界坐标系 → 相机坐标系(刚体变换,旋转+平移)
- 相机坐标系 → 图像坐标系(透视投影,针孔模型)
- 图像坐标系 → 像素坐标系(离散化,加偏移)
- 加上畸变修正(径向畸变+切向畸变)
数学上用一个3×4的投影矩阵搞定,但内参标定只关心相机内部的参数,不关心外部的旋转平移。
内参矩阵 K 长这样:
K = [fx 0 cx
0 fy cy
0 0 1]
fx, fy 是焦距(像素单位),cx, cy 是光心偏移。畸变系数通常有5个:k1, k2, k3(径向),p1, p2(切向)。
4.2 棋盘格标定的核心逻辑
为什么用棋盘格?因为它有天然的角点特征。黑白交界处,角点坐标可以精确到亚像素级别。
标定的本质是:已知棋盘格上每个角点的三维坐标(假设Z=0),找到它们在图像上的二维投影,然后反向求解相机参数。
我习惯把流程拆成三步:
- 数据采集:拍10-20张不同角度、不同距离的棋盘格照片
- 角点检测:用OpenCV找到每张图的棋盘格角点
- 参数优化:最小化重投影误差,迭代求解内参和畸变
我的经验:照片数量不是越多越好。15张左右,覆盖视野的四个角和中心区域,效果最好。我曾经试过拍50张,结果过拟合了,换一组数据反而崩了。
4.3 实战代码:一步步走通
下面是我常用的标定脚本。咱们边写边讲。
4.3.1 准备棋盘格
import cv2
import numpy as np
import glob
# 棋盘格参数:内角点数量(不是格子数!)
CHECKERBOARD = (9, 6) # 9列6行内角点
SQUARE_SIZE = 25.0 # 格子边长,单位mm
# 生成棋盘格三维坐标
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
这里有个坑:CHECKERBOARD 是内角点数量,不是格子数。9×6的棋盘格,内角点是8×5。我刚开始就搞反过,检测死活对不上。
4.3.2 角点检测与数据收集
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, CHECKERBOARD,
cv2.CALIB_CB_ADAPTIVE_THRESH +
cv2.CALIB_CB_FAST_CHECK +
cv2.CALIB_CB_NORMALIZE_IMAGE
)
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, CHECKERBOARD, corners2, ret)
cv2.imshow('Corners', img)
cv2.waitKey(100)
cv2.destroyAllWindows()
注意:如果 findChessboardCorners 返回 False,别硬塞数据。我见过有人把检测失败的图也加进去,结果标定出来的畸变系数直接飞了。宁可少一张,不要错一张。
4.3.3 执行标定
ret, mtx, dist, rvecs, tvecs = cv2.calibrateCamera(
objpoints, imgpoints, gray.shape[::-1], None, None
)
print("内参矩阵:\n", mtx)
print("畸变系数:\n", dist)
print("重投影误差:", ret)
输出示例:
内参矩阵:
[[534.21, 0. , 320.54],
[ 0. , 533.87, 240.12],
[ 0. , 0. , 1. ]]
畸变系数:
[[-0.284, 0.072, -0.002, 0.001, -0.015]]
重投影误差: 0.32
重投影误差小于0.5像素,说明标定质量不错。如果大于1.0,建议检查数据。
4.4 标定质量评估
光看误差数字不够,我习惯做两件事:
- 畸变校正可视化:把标定前后的图像叠在一起对比
- 误差分布直方图:看每张图的误差是否均匀
# 畸变校正
img = cv2.imread('test.jpg')
h, w = img.shape[:2]
newcameramtx, roi = cv2.getOptimalNewCameraMatrix(mtx, dist, (w,h), 1, (w,h))
dst = cv2.undistort(img, mtx, dist, None, newcameramtx)
# 对比显示
cv2.imshow('Original', img)
cv2.imshow('Undistorted', dst)
cv2.waitKey(0)
避坑指南:我曾经标定完直接用了 undistort,结果图像边缘被裁掉一大块。后来发现 getOptimalNewCameraMatrix 的 alpha 参数可以控制保留多少边缘像素。alpha=1 保留全部,alpha=0 只保留有效区域。
4.5 核心流程总览
下面这张图把整个标定流程串起来了:
4.6 常见问题与避坑
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 角点检测失败 | 光照不均、棋盘格反光 | 调整光源,使用哑光棋盘格 |
| 重投影误差过大 | 照片数量不足或角度单一 | 增加不同角度的照片 |
| 畸变校正后图像变形 | 畸变系数异常 | 检查是否有错误数据混入 |
| 标定结果不稳定 | 棋盘格不平整 | 使用玻璃或亚克力基板固定 |
我曾经踩过的坑:有一次标定结果看起来很好,重投影误差0.3像素,但实际测距偏差很大。排查了半天,发现是棋盘格打印尺寸和实际尺寸不一致。SQUARE_SIZE 设的是25mm,实际打印出来是24.7mm。差这0.3mm,标定出来的焦距就偏了1%以上。所以,一定要用游标卡尺量一下实际尺寸。
4.7 标定结果的使用
标定完的内参和畸变系数,通常保存成文件,后续直接加载:
# 保存
np.savez('calib_result.npz', mtx=mtx, dist=dist)
# 加载
data = np.load('calib_result.npz')
mtx = data['mtx']
dist = data['dist']
在融合项目中,内参矩阵用于将像素坐标反算到归一化平面,畸变系数用于校正图像。这两个参数不准,后面的外参标定、激光雷达-相机对齐全是白费功夫。
嗯,内参标定就讲到这里。记住一句话:标定不是一次性工作,换镜头、调焦距、甚至温度变化,都可能需要重新标定。 养成定期校验的习惯,比出问题再排查要省心得多。