4. 相机标定实战:OpenCV标定流程、畸变校正、重投影误差评估
相机标定,说白了就是给相机做「视力矫正」。
你想想看,仿生眼要重建三维世界,第一步就得知道相机本身的「脾气」——镜头畸变有多大?焦距是多少?光心偏不偏?这些参数不搞清楚,后面的深度估计全是空中楼阁。
我个人习惯把标定比作「给相机量三围」。量准了,后续的立体匹配、三维重建才有底气。今天我们就用OpenCV把这套流程走一遍。
4.1 标定的核心:内参、外参、畸变系数
先理清概念。相机标定最终要得到三样东西:
- 内参矩阵:焦距(fx, fy)、光心(cx, cy)。这是相机本身的固有属性。
- 畸变系数:径向畸变(k1, k2, k3)和切向畸变(p1, p2)。镜头不是完美的,边缘会变形。
- 外参:每张标定图片对应的旋转矩阵和平移向量。说白了就是相机相对于标定板的位置和姿态。
我在项目中遇到过最坑的事:有人把内参和外参搞混了。内参是相机自己的,换镜头就得重新标;外参是每张图都不同的,标定完一般就扔了。
核心公式(简化版):
世界坐标 → 相机坐标(外参) → 图像坐标(内参) → 加上畸变 → 最终像素
标定就是反向求解这个过程。
4.2 OpenCV标定流程:从拍摄到参数输出
OpenCV的标定流程其实很固定。我做了这么多年,基本就是这五步:
- 准备标定板:打印一张棋盘格,贴在硬板上。格子数建议9×6或10×7。
- 拍摄多张图片:从不同角度拍15-20张。注意覆盖视野的各个角落。
- 提取角点:用
cv2.findChessboardCorners()找到棋盘格的内角点。 - 执行标定:调用
cv2.calibrateCamera(),一步到位。 - 评估结果:看重投影误差,判断标定质量。
嗯,这里要注意:拍摄时标定板要占画面1/3以上,太远太小角点检测会失败。我曾经偷懒只拍了8张,结果标定出来的畸变参数完全不对,后来老老实实补到20张才搞定。
4.2.1 代码实战:完整标定流程
下面是我常用的标定代码框架。你直接拿去用,改一下棋盘格尺寸和图片路径就行。
import cv2
import numpy as np
import glob
# 1. 准备棋盘格参数
chessboard_size = (9, 6) # 内角点数
square_size = 25.0 # 格子边长,单位mm
# 2. 生成世界坐标系中的点
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)
objp *= square_size
# 3. 存储所有图片的点
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, chessboard_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, chessboard_size, corners2, ret)
cv2.imshow('Corners', img)
cv2.waitKey(100)
cv2.destroyAllWindows()
# 4. 执行标定
ret, mtx, dist, rvecs, tvecs = cv2.calibrateCamera(
objpoints, imgpoints, gray.shape[::-1], None, None
)
print("内参矩阵:\n", mtx)
print("畸变系数:\n", dist)
print("重投影误差:", ret)
个人经验:cv2.calibrateCamera()返回的ret就是重投影误差。我一般要求这个值小于0.5像素才算合格。超过1.0的,建议重新拍一组图片。
4.3 畸变校正:让图像变「直」
标定完拿到畸变系数,下一步就是校正图像。OpenCV提供了两个函数:
cv2.undistort():一步到位,简单粗暴。cv2.initUndistortRectifyMap()+cv2.remap():先算映射表,再重映射。适合视频流,效率更高。
我个人习惯用第二种。为什么?因为视频流里每帧都调用undistort()太慢了,而映射表只需要算一次。
# 方法一:直接校正
img_undistorted = cv2.undistort(img, mtx, dist, None, mtx)
# 方法二:映射表方式(推荐)
h, w = img.shape[:2]
newcameramtx, roi = cv2.getOptimalNewCameraMatrix(mtx, dist, (w, h), 1, (w, h))
mapx, mapy = cv2.initUndistortRectifyMap(mtx, dist, None, newcameramtx, (w, h), 5)
img_undistorted = cv2.remap(img, mapx, mapy, cv2.INTER_LINEAR)
避坑指南:我曾经直接用了undistort(),结果发现图像边缘被裁掉了。后来改用getOptimalNewCameraMatrix(),设置alpha=1保留所有像素,才解决了问题。alpha值控制保留范围,0表示全裁,1表示全留。
4.4 重投影误差评估:标定质量的「照妖镜」
标定完了,怎么知道好不好?重投影误差就是最直接的指标。
它的原理很简单:用标定得到的参数,把世界坐标点重新投影到图像上,算算和实际检测到的角点差了多少像素。
OpenCV的cv2.calibrateCamera()已经帮我们算好了,就是返回值ret。但如果你想自己算,可以这样:
def compute_reprojection_error(objpoints, imgpoints, rvecs, tvecs, mtx, dist):
total_error = 0
total_points = 0
for i in range(len(objpoints)):
imgpoints_proj, _ = cv2.projectPoints(objpoints[i], rvecs[i], tvecs[i], mtx, dist)
error = cv2.norm(imgpoints[i], imgpoints_proj, cv2.NORM_L2) / len(imgpoints_proj)
total_error += error
total_points += len(imgpoints_proj)
return total_error / len(objpoints)
mean_error = compute_reprojection_error(objpoints, imgpoints, rvecs, tvecs, mtx, dist)
print("平均重投影误差: {:.3f} 像素".format(mean_error))
经验阈值:
- < 0.3像素:优秀,可以用于高精度三维重建
- 0.3 - 0.8像素:良好,大多数场景够用
- 0.8 - 1.5像素:及格,建议检查标定图片质量
- > 1.5像素:不合格,重新拍吧
为什么会误差大?我遇到过几种情况:标定板不平整、图片模糊、棋盘格反光太强。有一次在户外标定,阳光直射导致棋盘格反光,角点检测全偏了,重投影误差直接飙到2.3像素。后来换了哑光纸打印才解决。
4.5 本章知识体系
下面这张图总结了相机标定的完整流程和关键环节:
4.6 实战中的几个坑
最后分享几个我踩过的坑,希望能帮你省点时间:
- 标定板要平:贴在玻璃上或者用亚克力板夹住。软纸板一弯,角点位置全偏。
- 图片要清晰:运动模糊、对焦不准都会导致角点检测偏差。我一般用三脚架拍。
- 角度要多样:不要只拍正面,倾斜、旋转、上下左右都要有。覆盖视野边缘尤其重要。
- 数量要够:15张是底线,20张以上更稳。别学我当年偷懒只拍8张。
小技巧:标定完成后,保存内参和畸变系数到.npz或.yaml文件。下次直接用,不用重新标。我一般这样存:
np.savez('calib_params.npz', mtx=mtx, dist=dist, newcameramtx=newcameramtx)
好了,相机标定这块就聊到这儿。记住一句话:标定质量决定三维重建的天花板。花半小时把标定做好,后面能省三天调参的时间。
公众号:蓝海资料掘金营,微信deep3321