相机标定原理:标定板设计、张正友标定法、内参外参求解、畸变系数估计、OpenCV标定实践

相机标定,说白了就是给相机做一次「视力矫正」。

你想想看,我们拿到的每一张图像,其实都是三维世界在二维平面上的投影。但这个投影过程并不完美——镜头有畸变,传感器有安装误差,像素也不是完美的正方形。如果不做标定,你从图像里算出来的距离、角度、三维坐标,全都是错的。

我在做双目视觉项目时,第一次标定没做好,结果重建出来的三维点云整个是扭曲的。后来老老实实重新标了一遍,效果天差地别。嗯,标定这事,真不能偷懒。

标定板设计:棋盘格与圆点阵

标定板是标定的「基准尺」。它的作用很简单:提供一个已知尺寸、已知几何关系的图案,让相机去「对答案」。

最常见的标定板有两种:

  • 棋盘格标定板:黑白相间的方格,角点检测简单可靠。我个人习惯用 9×6 或 10×7 的内角点数量,方格边长 20-30mm。
  • 圆点阵标定板:圆形图案,圆心定位精度理论上比角点更高。但圆点存在透视变形,需要做椭圆拟合补偿。

关键参数:标定板必须平整,打印后贴在刚性背板上。我曾经用普通A4纸打印,贴在硬纸板上,结果标定出来的焦距误差超过 5%。后来换了亚克力板 + 高精度打印,误差降到 0.3% 以内。

设计标定板时,有几点要注意:

  • 棋盘格必须是奇数×偶数个格子,这样内角点才是整数个
  • 方格边长要精确测量,误差控制在 0.1mm 以内
  • 标定板尺寸要覆盖相机视场的 1/3 到 2/3
  • 拍摄时标定板要倾斜、旋转、平移,覆盖整个图像区域

张正友标定法:从单应矩阵到内参

张正友标定法,是计算机视觉领域最经典的标定方法之一。它的核心思想很简单:用多张不同姿态的标定板图像,通过单应矩阵约束,逐步求解相机内参和外参。

为什么需要多张图像?因为一张图像只能提供 8 个自由度(单应矩阵),而相机内参有 4 个(fx, fy, cx, cy),外参有 6 个(旋转 3 + 平移 3),总共 10 个未知数。一张图像不够,至少需要 2 张,但实际中我建议拍 15-20 张。

算法的核心步骤:

  1. 检测角点:在每张图像上提取棋盘格内角点坐标
  2. 计算单应矩阵:对每张图像,建立世界坐标到像素坐标的映射
  3. 求解内参:利用单应矩阵的约束条件,构建线性方程组,解出内参矩阵
  4. 求解外参:内参已知后,每张图像的外参就迎刃而解
  5. 非线性优化:用 Levenberg-Marquardt 算法对所有参数做全局优化

我的经验:张正友标定法对初始值不敏感,但如果你能提供合理的初始估计(比如用相机标称焦距),优化收敛会更快。我一般先用线性解算,再迭代 10-20 次就稳定了。

内参外参求解:相机矩阵与位姿

内参矩阵描述了相机内部的几何特性:

K = [fx  0  cx]
    [ 0  fy  cy]
    [ 0   0   1]

其中 fx, fy 是焦距(以像素为单位),cx, cy 是主点坐标(通常接近图像中心)。

外参矩阵描述了相机在世界坐标系中的位置和朝向:

[R | t]

R 是 3×3 旋转矩阵,t 是 3×1 平移向量。

求解过程其实很直观:

  • 先忽略畸变,用线性方法解出内参和外参的初始值
  • 然后考虑畸变,用非线性优化同时优化所有参数
  • 优化目标是最小化重投影误差——也就是角点检测位置和投影位置之间的像素距离

重投影误差是衡量标定质量的核心指标。我一般要求平均误差小于 0.3 像素,如果超过 0.5 像素,说明标定有问题,需要重新拍摄。

畸变系数估计:径向与切向畸变

镜头畸变主要分两种:

  • 径向畸变:光线通过镜头边缘时弯曲,导致图像边缘向内(桶形)或向外(枕形)变形
  • 切向畸变:镜头和传感器不平行,导致图像倾斜

OpenCV 使用 Brown 模型来描述畸变:

x_distorted = x * (1 + k1*r^2 + k2*r^4 + k3*r^6) + 2*p1*x*y + p2*(r^2 + 2*x^2)
y_distorted = y * (1 + k1*r^2 + k2*r^4 + k3*r^6) + p1*(r^2 + 2*y^2) + 2*p2*x*y

其中 k1, k2, k3 是径向畸变系数,p1, p2 是切向畸变系数。r 是像素到主点的距离。

注意:畸变系数不是越多越好。对于普通镜头,用 k1, k2, p1, p2 四个参数就够了。k3 只在鱼眼镜头或大广角镜头下才有意义。我曾经见过有人用 8 个畸变参数,结果过拟合,标定结果反而更差。

OpenCV标定实践:从代码到结果

OpenCV 提供了完整的标定工具链。下面是我常用的标定流程:

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)

# 4. 执行标定
ret, mtx, dist, rvecs, tvecs = cv2.calibrateCamera(
    objpoints, imgpoints, gray.shape[::-1], None, None
)

# 5. 评估标定质量
mean_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)
    mean_error += error

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

这段代码我用了很多年,基本没大改过。有几个细节值得注意:

  • cornerSubPix 这一步很关键,能把角点精度从像素级提升到亚像素级
  • 标定图像要覆盖视场的各个区域,尤其是边缘和角落
  • 图像数量建议 15-20 张,太少容易过拟合,太多也没必要

避坑指南:我曾经用 5 张图像做标定,结果重投影误差只有 0.1 像素,但实际三维重建误差很大。后来发现是过拟合了——参数刚好拟合了这 5 张图像,但泛化能力很差。所以标定图像一定要多角度、多姿态,至少 15 张以上。

标定流程全景图

下面这张图展示了相机标定的完整流程,从标定板设计到最终输出内参、外参和畸变系数:

相机标定流程全景图 标定板设计 棋盘格 / 圆点阵 尺寸精度 < 0.1mm 图像采集 15-20 张多角度 覆盖视场各区域 角点检测 findChessboardCorners 亚像素优化 单应矩阵计算 每张图像独立求解 8 自由度约束 内参求解 线性方程组 fx, fy, cx, cy 外参求解 旋转矩阵 R 平移向量 t 畸变系数估计 径向 k1, k2, k3 切向 p1, p2 非线性优化 Levenberg-Marquardt 最小化重投影误差 输出标定结果 内参矩阵 K 畸变系数 + 外参 重投影误差 > 阈值?重新采集或优化

这张图把整个标定流程串起来了。你从左上角开始,沿着箭头走一遍,就能理解每个环节的输入输出关系。注意那个虚线反馈回路——如果重投影误差太大,就得回到图像采集步骤重新拍。

好了,相机标定的核心原理就这些。说白了,标定就是解一组方程,但工程实现中有很多细节需要注意。标定板要平、图像要多、角点要准、优化要稳——把这四点做好,标定结果基本不会差。


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