3. 相机内参标定:针孔模型、畸变模型、张正友标定法原理与实战

相机标定,说白了就是搞清楚两件事:相机把三维世界映射到二维图像时,用了什么数学规则,以及这个规则里有哪些参数需要确定。我做了这么多年传感器融合,可以负责任地告诉你——内参标定是所有后续工作的基石。你想想看,如果相机都不知道自己长什么样,它看到的东西怎么可能和其他传感器对齐?

3.1 针孔模型:相机成像的数学抽象

针孔模型是最经典的相机成像模型。它假设光线通过一个小孔投影到成像平面上。虽然真实相机用的是透镜,但针孔模型提供了一个很好的近似起点。

数学上,针孔模型可以表示为:

s * [u, v, 1]^T = K * [R|t] * [X, Y, Z, 1]^T

其中:

  • (u, v) 是图像像素坐标
  • (X, Y, Z) 是世界坐标系下的三维点
  • K 是内参矩阵
  • [R|t] 是外参矩阵(旋转+平移)
  • s 是尺度因子

内参矩阵 K 长这样:

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

fx, fy 是焦距(以像素为单位),cx, cy 是主点坐标(光轴与成像平面的交点)。

核心理解:内参矩阵描述的是相机内部的光学几何特性。同一个相机,内参是固定的;换了相机,内参就得重新标定。

3.2 畸变模型:现实世界的修正

针孔模型是理想情况。真实相机有透镜,透镜会引入畸变。我遇到过不少新手,标定完发现投影误差很大,其实就是没处理好畸变。

畸变主要分两类:

畸变类型 产生原因 视觉效果 数学模型
径向畸变 透镜形状不完美 直线变弯曲(桶形/枕形) k1, k2, k3 三个参数
切向畸变 透镜与成像平面不平行 图像倾斜/梯形失真 p1, p2 两个参数

畸变校正的数学公式:

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]

其中 r^2 = x^2 + y^2,x, y 是归一化平面坐标。

避坑指南:我曾经在标定一个广角相机时,只用了 k1, k2 两个径向畸变参数,结果图像边缘的校正效果很差。后来加上 k3 才解决问题。对于视场角大于 90° 的相机,建议使用 3 个径向畸变参数。

3.3 张正友标定法:从棋盘格到内参

张正友标定法是目前最主流的相机内参标定方法。它的核心思想是:用一张已知尺寸的棋盘格,从不同角度拍摄多张图像,然后通过数学优化求解内参和畸变参数。

为什么这个方法好用?因为它不需要知道相机相对于棋盘格的位置(外参),只需要棋盘格是平面且尺寸已知。这在实际操作中非常方便。

算法流程如下:

  1. 拍摄棋盘格图像:从不同角度拍摄 10-20 张图像
  2. 提取角点:检测棋盘格的内角点坐标
  3. 计算单应性矩阵:每张图像对应一个单应性矩阵 H
  4. 求解内参初值:利用单应性矩阵的约束条件
  5. 估计畸变参数:基于最小二乘法
  6. 全局优化:使用 Levenberg-Marquardt 算法最小化重投影误差

个人经验:我习惯拍摄 15-20 张图像,覆盖棋盘格在画面中的不同位置和角度。特别要注意让棋盘格出现在图像边缘和角落——这些区域对畸变参数的估计至关重要。

3.4 实战:使用 OpenCV 进行相机标定

下面是一个完整的标定代码示例。我建议你直接运行试试,看看效果。

import cv2
import numpy as np
import glob

# 1. 准备棋盘格参数
CHECKERBOARD = (9, 6)  # 内角点数量
criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 30, 0.001)

# 2. 准备世界坐标系中的点
objp = np.zeros((CHECKERBOARD[0] * CHECKERBOARD[1], 3), np.float32)
objp[:, :2] = np.mgrid[0:CHECKERBOARD[0], 0:CHECKERBOARD[1]].T.reshape(-1, 2)

# 3. 存储所有图像的点
objpoints = []  # 世界坐标系中的点
imgpoints = []  # 图像坐标系中的点

images = glob.glob('calibration_images/*.jpg')

for fname in images:
    img = cv2.imread(fname)
    gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
    
    # 4. 查找棋盘格角点
    ret, corners = cv2.findChessboardCorners(gray, CHECKERBOARD, None)
    
    if ret:
        objpoints.append(objp)
        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(500)

cv2.destroyAllWindows()

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

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

# 6. 评估标定精度
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("平均重投影误差: ", mean_error / len(objpoints))

关键指标:重投影误差通常应小于 0.5 像素。如果误差大于 1 像素,说明标定质量有问题,需要检查图像质量或重新拍摄。

3.5 标定流程与知识体系

下面这张图展示了相机内参标定的完整知识体系:

相机内参标定知识体系 相机内参标定知识体系 相机模型 针孔模型 畸变模型 张正友标定法 内参矩阵 K (fx, fy, cx, cy) 外参矩阵 [R|t] 尺度因子 s 径向畸变 (k1, k2, k3) 切向畸变 (p1, p2) 棋盘格角点提取 单应性矩阵计算 Levenberg-Marquardt 优化 输出:内参 + 畸变系数 标定质量评估:重投影误差 < 0.5 像素为合格,< 0.3 像素为优秀 建议拍摄 15-20 张不同角度的棋盘格图像,覆盖画面各个区域

3.6 标定实战中的常见问题

我在项目中遇到过不少标定翻车的情况,这里总结几个最常见的坑:

问题 现象 原因 解决方案
重投影误差大 误差 > 1 像素 图像模糊/棋盘格不平整 重新拍摄,确保对焦清晰
畸变校正过度 图像边缘扭曲 畸变参数过多/图像不足 增加图像数量,减少畸变参数
主点偏移严重 cx, cy 偏离图像中心 棋盘格未覆盖画面边缘 让棋盘格出现在画面四角
标定结果不稳定 每次标定结果差异大 图像数量不足/角度单一 拍摄 20+ 张,覆盖不同角度

曾经踩过的坑:有一次我在室内标定相机,用了 10 张图像,重投影误差只有 0.3 像素,看起来很好。结果拿到室外用,畸变校正完全不对。后来发现是室内光线太暗,棋盘格角点提取精度不够。从那以后,我标定都会在充足光线下进行,并且至少拍 15 张。

3.7 标定质量的验证方法

标定完不是就完事了。我习惯做三件事来验证标定质量:

  1. 重投影误差检查:所有角点的平均重投影误差应 < 0.5 像素
  2. 畸变校正可视化:用标定结果校正一张图像,检查直线是否变直
  3. 跨场景验证:在不同距离和光照条件下拍摄,验证标定结果的泛化能力

小技巧:你可以用标定结果把棋盘格图像校正后,再检测角点,计算校正后的角点误差。如果校正后的误差比校正前小很多,说明标定是有效的。

嗯,相机内参标定就讲到这里。记住,标定不是一次性工作——相机在使用过程中可能会因为温度变化、振动等原因导致内参漂移。我建议每隔一段时间重新标定一次,或者在关键任务前做一次快速验证。