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

相机内参标定,说白了就是搞清楚镜头到底是怎么看世界的。每个镜头都有脾气——焦距多少、光心偏不偏、畸变大不大。这些参数不标定,后面的三维重建、多传感器融合全是空中楼阁。

我最早做标定的时候,以为拿个棋盘格拍几张照片就完事了。结果呢?数据采了一堆,标定结果一塌糊涂。后来才明白,这里面的门道比想象中多得多。今天咱们就一步步走通这个流程。

4.1 标定原理:从世界坐标到像素坐标

先理清一个核心问题:相机是怎么把三维世界变成二维图像的?

整个过程分四步:

  1. 世界坐标系 → 相机坐标系(刚体变换,旋转+平移)
  2. 相机坐标系 → 图像坐标系(透视投影,针孔模型)
  3. 图像坐标系 → 像素坐标系(离散化,加偏移)
  4. 加上畸变修正(径向畸变+切向畸变)

数学上用一个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']

在融合项目中,内参矩阵用于将像素坐标反算到归一化平面,畸变系数用于校正图像。这两个参数不准,后面的外参标定、激光雷达-相机对齐全是白费功夫。

嗯,内参标定就讲到这里。记住一句话:标定不是一次性工作,换镜头、调焦距、甚至温度变化,都可能需要重新标定。 养成定期校验的习惯,比出问题再排查要省心得多。