3. OpenCV鱼眼相机标定

好,咱们进入实操环节。前面聊了那么多理论,什么径向畸变、切向畸变、等距投影模型…… 说实话,光看公式容易晕。我自己的经验是,动手标定一次,比看十遍都管用。

这一章,我们就用 OpenCV 把鱼眼相机的内参和畸变系数给求出来。整个过程分三步:准备棋盘格、检测角点、求解参数。每一步都有坑,我会把踩过的坑都指出来。

3.1 棋盘格标定板:不是随便打印一张纸就行

标定板是标定的“尺子”。尺子不准,后面全白搭。

OpenCV 的鱼眼标定函数 cv::fisheye::calibrate() 默认支持棋盘格(Chessboard)和对称圆点阵。我个人强烈推荐用棋盘格,因为角点检测的鲁棒性更好,尤其是在鱼眼图像边缘严重畸变的区域。

3.1.1 棋盘格的关键参数

参数 说明 我的建议
内角点数量 棋盘格内部交叉点的行列数(如 9×6) 不要用太少的点(如 4×3),建议 8×6 或 9×7
方格尺寸 每个小方格的实际物理边长(单位:mm) 用游标卡尺量,别信打印店的标称值
材质 纸张、亚克力板、陶瓷板 室内用亚克力板,平整度好;室外用陶瓷板
图案精度 打印分辨率、边缘是否清晰 至少 600 DPI,边缘模糊会导致亚像素角点偏移
⚠️ 我曾经踩过的坑: 有一次我用普通的 A4 纸打印棋盘格,贴在泡沫板上。结果标定出来的焦距偏差了 3% 以上。后来发现是泡沫板本身不平,在鱼眼镜头下,微小的弯曲都会被放大。从那以后,我标定鱼眼只用 3mm 厚的亚克力板。

3.1.2 世界坐标系的建立

标定时,我们把棋盘格平面定义为 Z=0 的平面。每个内角点的世界坐标就是 (col * square_size, row * square_size, 0)。这里有个细节:行列顺序必须和角点检测时的顺序一致,否则标定出来的外参方向是反的。

# 生成棋盘格的世界坐标点
pattern_size = (9, 6)  # 内角点数量:9列 x 6行
square_size = 30.0     # 方格边长:30mm

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

3.2 角点检测:鱼眼图像的难点在这里

普通相机的角点检测用 cv::findChessboardCorners() 就够了。但鱼眼图像不一样——边缘区域的棋盘格被严重拉伸和扭曲,直接调用函数大概率会失败。

3.2.1 为什么鱼眼图像角点检测容易失败?

  • 畸变太大:边缘的棋盘格可能被压缩成一条线,角点特征消失。
  • 光照不均:鱼眼镜头视角大,画面边缘亮度通常比中心暗很多。
  • 遮挡:标定板稍微倾斜,边缘的角点就可能被镜头筒遮挡。

3.2.2 我的检测策略

我一般分两步走:

  1. 粗检测:先用 cv::findChessboardCorners() 尝试,如果失败,对图像做预处理——直方图均衡化 + 自适应阈值。
  2. 亚像素精化:用 cv::cornerSubPix() 把角点精度提升到亚像素级别。这一步对鱼眼标定尤其重要,因为畸变模型对像素误差非常敏感。
import cv2
import numpy as np

def detect_chessboard_corners(img, pattern_size):
    gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
    
    # 尝试直接检测
    ret, corners = cv2.findChessboardCorners(gray, pattern_size, None)
    
    if not ret:
        # 如果失败,做预处理
        clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8,8))
        gray_eq = clahe.apply(gray)
        ret, corners = cv2.findChessboardCorners(gray_eq, pattern_size, None)
    
    if ret:
        # 亚像素精化
        criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 30, 0.001)
        corners = cv2.cornerSubPix(gray, corners, (5,5), (-1,-1), criteria)
    
    return ret, corners
💡 小技巧: 拍摄标定图像时,让棋盘格尽量覆盖画面的各个区域——中心、边缘、角落、上下左右。我一般拍 20~30 张,确保每个区域都有 3 张以上的有效检测。

3.3 内参矩阵与畸变系数求解

角点检测完成后,就可以调用 cv::fisheye::calibrate() 了。这个函数是 OpenCV 专门为鱼眼相机设计的,它使用的是 等距投影模型(Equidistant Projection Model),也就是我们上一章讲的那个。

3.3.1 函数接口与参数

retval, K, D, rvecs, tvecs = cv2.fisheye.calibrate(
    objectPoints,   # 世界坐标点列表
    imagePoints,    # 图像角点列表
    image_size,     # 图像尺寸 (width, height)
    K,              # 内参矩阵(输出)
    D,              # 畸变系数(输出)
    rvecs,          # 旋转向量(输出)
    tvecs,          # 平移向量(输出)
    flags,          # 标定选项
    criteria        # 迭代终止条件
)

3.3.2 关键参数说明

参数 含义 推荐值
flags 标定选项,控制哪些参数固定、哪些优化 cv2.fisheye.CALIB_RECOMPUTE_EXTRINSIC + cv2.fisheye.CALIB_CHECK_COND
criteria 迭代终止条件 (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 30, 1e-6)
K 内参矩阵,3×3 初始值用图像中心估算焦距
D 畸变系数,4×1(k1, k2, k3, k4) 初始值全零

3.3.3 输出结果解读

标定完成后,你会得到两个核心输出:

  • 内参矩阵 K:包含焦距 (fx, fy) 和主点 (cx, cy)。对于鱼眼相机,主点通常不在图像正中心,因为镜头安装有偏差。
  • 畸变系数 D:四个参数 k1, k2, k3, k4。注意,OpenCV 的鱼眼模型只用 4 个径向畸变参数,没有切向畸变参数(p1, p2)。

🔍 如何判断标定结果好不好?

  • 重投影误差:一般应小于 0.5 像素。如果大于 1.0 像素,说明角点检测有问题,或者标定板不平。
  • 畸变曲线:画出畸变率随入射角变化的曲线,应该平滑单调。如果有拐点或突变,说明模型不匹配。
  • 校正后图像:直线应该变成直线。如果边缘还有弯曲,说明标定精度不够。

3.4 完整标定流程(SVG 流程图)

下面这张图是我自己总结的标定流程,每一步都有对应的代码和检查点:

鱼眼相机标定完整流程 鱼眼相机标定完整流程 ① 准备棋盘格标定板 ② 拍摄 20~30 张标定图像 ③ 角点检测 + 亚像素精化 ④ fisheye.calibrate() 求解 ⑤ 评估重投影误差 误差 < 0.5px? 是 ✅ 标定完成 否 重新采集图像 💡 如果重投影误差大于 0.5 像素,优先检查角点检测是否准确,其次检查标定板是否平整

3.5 避坑指南:我踩过的那些坑

标定鱼眼相机,说难不难,说简单也不简单。我把自己踩过的坑列出来,你遇到了可以直接对照:

  • 坑一:标定板不够大。鱼眼镜头视角大,小标定板只能覆盖画面中心区域,边缘的畸变参数根本拟合不出来。我建议标定板的边长至少占图像短边的 60%。
  • 坑二:图像数量不够。少于 15 张图像,标定结果很不稳定。我一般拍 25 张,确保棋盘格在画面各个位置都出现过。
  • 坑三:忽略亚像素精化。直接用整数像素坐标去标定,重投影误差很难低于 1.0 像素。亚像素精化这一步不能省。
  • 坑四:标定板弯曲。这个前面说过了,用亚克力板或者玻璃板,别用纸板。
💡 我的个人习惯: 标定完成后,我会用 cv::fisheye::stereoCalibrate() 做一次双目标定验证——把左右相机的标定结果放在一起优化,能发现单目标定中不易察觉的系统误差。