相机内参标定:张正友标定法原理

说到相机内参标定,我敢说这是每个做感知的人都会遇到的第一道坎。你想想看,相机拍出来的图像是二维的,但我们要从里面恢复三维信息,这中间缺的就是一个「映射关系」。这个关系,就是内参矩阵和畸变系数。

张正友标定法,说白了就是用一张棋盘格照片,通过数学推导把相机内部参数算出来。我当年刚入行时,觉得这玩意儿特别玄乎,后来亲手跑了一遍流程才发现——其实核心思想就四个字:几何约束。

核心思想:利用棋盘格上已知的角点坐标(世界坐标系)与图像中检测到的角点坐标(像素坐标系)之间的对应关系,建立方程组,求解内参矩阵和畸变系数。

棋盘格角点检测

这一步是标定的基础。棋盘格上的黑白格子交界处,就是我们要找的角点。为什么选棋盘格?因为它的角点特征非常明显——两个方向上的梯度变化都很大,容易检测。

我记得第一次做标定时,用的是一张打印出来的A4纸棋盘格,贴在硬纸板上。当时没注意光照,结果角点检测死活不对。后来才发现,棋盘格表面有反光,角点被误判了。嗯,这里要注意:棋盘格一定要平整,光照要均匀。

OpenCV里提供了现成的角点检测函数:

import cv2
import numpy as np

# 棋盘格尺寸(内角点数量)
pattern_size = (9, 6)  # 9列6行内角点

# 读取图像
img = cv2.imread('chessboard.jpg')
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_sub = cv2.cornerSubPix(gray, corners, (11,11), (-1,-1), criteria)
    
    # 绘制角点
    cv2.drawChessboardCorners(img, pattern_size, corners_sub, ret)

我的经验:亚像素精确化这一步千万别省。我见过有人直接用粗检测的角点去标定,结果重投影误差大得离谱。多花几毫秒,精度能提升一个数量级。

内参矩阵的求解

内参矩阵长这样:

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

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

张正友标定法的核心,是利用了棋盘格平面上的点满足「单应性矩阵」这个约束。说白了,就是世界坐标系中的平面点,到图像坐标系中的点,存在一个3x3的变换矩阵H。通过多张不同角度的棋盘格照片,我们可以解出H,再从H中分解出内参。

我个人习惯至少拍15-20张不同角度的棋盘格照片。为什么?因为角度越多,约束越强,解出来的内参越稳定。我曾经试过只用5张照片,结果标定出来的fx和fy差了3%——这在自动驾驶里是不能接受的。

畸变系数的求解

相机镜头不是完美的,会产生畸变。主要有两种:

  • 径向畸变:镜头形状导致的,图像边缘的直线会变弯。用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是点到主点的距离。你看,畸变校正本质上就是把这个过程反过来——从畸变图像反推无畸变图像。

避坑指南:我曾经在标定一个广角相机时,发现畸变系数k3特别大。后来排查发现,是因为棋盘格只覆盖了图像中心区域,边缘的畸变没有被充分约束。解决方案:让棋盘格出现在图像的各个角落,尤其是边缘和四角。

完整的标定流程

把上面这些串起来,完整的标定流程是这样的:

import cv2
import numpy as np
import glob

# 准备世界坐标系中的点
pattern_size = (9, 6)
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 = []  # 图像坐标系中的点

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, pattern_size, None)
    
    if ret:
        objpoints.append(objp)
        criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 30, 0.001)
        corners_sub = cv2.cornerSubPix(gray, corners, (11,11), (-1,-1), criteria)
        imgpoints.append(corners_sub)

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

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

知识体系结构

下面这张图,是我自己总结的相机内参标定知识体系。你看,整个流程从数据采集开始,到角点检测,再到参数求解,最后是精度验证,环环相扣。

相机内参标定知识体系 数据采集 多角度棋盘格照片 角点检测 亚像素精确化 参数求解 内参+畸变系数 精度验证 数据采集要点 • 15-20张不同角度 • 覆盖图像各个区域 • 光照均匀无反射 • 棋盘格平整固定 角点检测流程 • findChessboardCorners • cornerSubPix亚像素 • drawChessboardCorners • 检查角点顺序 参数求解内容 • 内参矩阵 (fx,fy,cx,cy) • 径向畸变 (k1,k2,k3) • 切向畸变 (p1,p2) • 外参 (旋转+平移) 验证指标 • 重投影误差 • 畸变校正效果 • 多组结果一致性 核心目标:建立像素坐标 ↔ 世界坐标的精确映射关系 为激光雷达与相机融合提供准确的图像几何信息

标定结果验证

标定完不是就完事了。我每次都会做一件事:用标定结果去校正一张棋盘格照片,看看直线是不是真的变直了。

# 畸变校正
img_undistorted = cv2.undistort(img, mtx, dist, None, mtx)

# 计算重投影误差
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

print("平均重投影误差: ", total_error / len(objpoints))

我的判断标准:重投影误差小于0.5像素,这个标定结果就算合格。如果超过1个像素,我建议重新采集数据。误差来源通常是:棋盘格不平整、照片数量不够、或者角点检测精度不够。

好了,相机内参标定这块,核心就是这些。说白了,就是通过多张棋盘格照片,用数学方法把相机的「内在参数」算出来。这个结果,是后续激光雷达与相机融合的基础——没有准确的内参,点云投影到图像上就会错位,融合效果也就无从谈起。

可可精品资料库