4. 相机标定实战:使用OpenCV进行棋盘格标定

相机标定,说白了就是给相机做一次「视力矫正」。你想想看,镜头天生就有畸变,像素也不是完美的正方形,这些误差在测距时会被放大。我刚开始做双目测距时,就吃过这个亏——标定没做好,测出来的距离偏差了5厘米,后来才发现是标定板拍得太随意了。

这一章,咱们就手把手把标定流程走一遍。从拍棋盘格到算参数,再到评估精度,每一步我都会把坑点讲清楚。

4.1 准备工作:棋盘格与拍摄规范

先说说棋盘格。别小看这张黑白格子纸,它决定了标定的下限。

  • 棋盘格尺寸:我建议用 9×6 的内角点(也就是 10×7 的格子),格子边长 20-30mm 比较合适。太大或太小都会影响角点检测的稳定性。
  • 打印与粘贴:一定要贴在绝对平整的硬板上。我曾经偷懒贴在纸板上,结果标定出来的畸变参数全是错的。
  • 拍摄要求:至少拍 15-20 张不同角度、不同距离的照片。注意:
    • 棋盘格要占画面 1/3 以上
    • 角度要覆盖俯仰、偏航、旋转
    • 远近都要有,从 0.5 米到 3 米
    • 避免反光和阴影
我的小技巧:拍摄时让棋盘格在画面边缘也出现几次。因为畸变在边缘最明显,边缘的角点能帮算法更好地估计畸变参数。

4.2 角点提取:findChessboardCorners 的细节

OpenCV 提供了 cv2.findChessboardCorners() 来提取角点。但直接调用往往不够,需要配合亚像素精细化。

import cv2
import numpy as np

# 棋盘格内角点数量 (宽, 高)
pattern_size = (9, 6)

# 准备世界坐标系中的点 (假设棋盘格在Z=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)

# 存储所有图片的点
objpoints = []  # 世界坐标
imgpoints = []  # 图像坐标

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:
        # 亚像素精细化
        criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 30, 0.001)
        corners_refined = cv2.cornerSubPix(gray, corners, (11, 11), (-1, -1), criteria)
        
        objpoints.append(objp)
        imgpoints.append(corners_refined)
        
        # 可视化(可选)
        cv2.drawChessboardCorners(img, pattern_size, corners_refined, ret)

这里有个坑:findChessboardCorners 有时候会失败,尤其是光照不均匀或棋盘格太远。我建议加一个重试机制——如果检测失败,换一个自适应阈值再试一次。

注意:亚像素精细化时,窗口大小 (11,11) 不是固定的。如果棋盘格在图像中很小,可以缩小到 (5,5);如果很大,可以增大到 (15,15)。这个参数我调过很多次,经验是「宁大勿小」。

4.3 计算内参和畸变系数

有了角点,就可以调用 cv2.calibrateCamera() 了。这个函数会返回相机矩阵、畸变系数、旋转向量和平移向量。

ret, mtx, dist, rvecs, tvecs = cv2.calibrateCamera(
    objpoints, imgpoints, gray.shape[::-1], None, None
)

print("相机内参矩阵:\n", mtx)
print("畸变系数:\n", dist)

输出结果长这样:

参数含义典型值
fx, fy焦距(像素单位)约 600-1200
cx, cy主点坐标约图像中心
k1, k2, p1, p2, k3畸变系数通常很小(±0.1)

嗯,这里要注意:dist 是 5 个参数的数组,顺序是 [k1, k2, p1, p2, k3]。k1、k2 是径向畸变,p1、p2 是切向畸变,k3 是更高阶的径向畸变。如果标定结果中 k1 大于 0.5,说明你的镜头畸变很严重,或者标定板拍得不够好。

4.4 重投影误差:标定质量的照妖镜

标定完不能直接走人,得看看精度。重投影误差就是最好的指标——它衡量的是「世界坐标点投影到图像上」和「实际检测到的角点」之间的像素距离。

total_error = 0
for i in range(len(objpoints)):
    imgpoints2, _ = cv2.projectPoints(objpoints[i], rvecs[i], tvecs[i], mtx, dist)
    error = cv2.norm(imgpoints[i], imgpoints2, cv2.NORM_L2) / len(imgpoints2)
    total_error += error

mean_error = total_error / len(objpoints)
print(f"平均重投影误差: {mean_error:.4f} 像素")

这个值多少算好?我的经验是:

  • < 0.1 像素:非常优秀,可以用于高精度测距
  • 0.1 - 0.3 像素:良好,大多数场景够用
  • 0.3 - 0.5 像素:及格,需要检查标定过程
  • > 0.5 像素:不合格,重新拍吧
避坑指南:我曾经有一次标定误差 0.8 像素,怎么调都不行。后来发现是棋盘格打印时尺寸不对——实际格子边长 25mm,我代码里写的是 20mm。世界坐标系的尺度错了,误差自然大。所以,一定要用尺子量一下实际格子尺寸

4.5 知识体系总览

下面这张图把整个标定流程串起来了,你可以对照着看:

相机标定知识体系 1. 准备阶段 棋盘格打印与粘贴 多角度拍摄(15-20张) 覆盖远近/边缘 ⚠ 避免反光 2. 角点提取 findChessboardCorners 亚像素精细化 cornerSubPix ⚠ 窗口大小调整 3. 参数计算 calibrateCamera 相机内参矩阵 畸变系数 ⚠ 检查k1/k2大小 4. 评估 重投影 误差 评估 核心要点总结 ✅ 棋盘格必须平整,尺寸要实际测量 ✅ 角点提取后一定要做亚像素精细化 ✅ 重投影误差 < 0.3 像素才算合格 ✅ 保存内参和畸变系数,后续测距要用

4.6 保存标定结果

标定完记得把参数存下来,后面测距要用。我习惯存成 NumPy 的 .npz 格式,方便加载:

np.savez('calibration_data.npz', mtx=mtx, dist=dist, rvecs=rvecs, tvecs=tvecs)
print("标定结果已保存到 calibration_data.npz")

加载的时候也很简单:

data = np.load('calibration_data.npz')
mtx = data['mtx']
dist = data['dist']

嗯,到这里标定就完成了。你可能会问:「标定一次能用多久?」我的经验是,只要不碰镜头、不换焦距,一次标定能用很久。但如果你把相机拆了或者摔了,最好重新标定一次。

最后提醒一句:标定不是一锤子买卖。我每次做新项目都会先标定一次,哪怕用的是同一台相机。因为温度、湿度、甚至螺丝松紧都会影响内参。多花 10 分钟标定,能省下后面几小时的调试时间。

公众号:蓝海资料掘金营,微信deep3321