4. 传感器标定(上):相机内参标定(张正友标定法),畸变校正

各位同学,欢迎来到传感器标定的第一讲。

说实话,多传感器融合里,标定是真正的“地基”。地基没打好,后面算法再花哨,也是空中楼阁。我见过太多项目,融合效果不好,最后排查下来,居然是标定参数偏了几个像素。嗯,今天我们就先把相机自己的“内功”练好——内参标定和畸变校正。

4.1 为什么需要相机内参?

简单说,内参就是描述相机如何把三维世界投影到二维图像上的那套数学规则。你想想看,一个三维点 (X, Y, Z) 是怎么变成像素坐标 (u, v) 的?

这个过程,本质上就是通过内参矩阵 K 来完成的。K 里面包含了焦距 (fx, fy) 和光心 (cx, cy)。

核心公式:

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

其中 K 就是内参矩阵:

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

fx 和 fy 通常很接近,但不完全相等。cx 和 cy 一般在图像中心附近,但也不是绝对中心。这些微小的偏差,就是我们需要标定出来的东西。

4.2 张正友标定法:为什么它这么流行?

张正友老师在1998年提出的这个方法,可以说是相机标定领域的“标准答案”。为什么?因为它只需要你打印一张棋盘格,拍几张照片,就能搞定。

我个人习惯用 7x9 或者 8x10 的棋盘格,格子大小 30mm 或 40mm。太小了,角点检测容易出错;太大了,又拍不全。

核心思路其实就三步:

  1. 检测角点:在每张图片里找到棋盘格的内角点。
  2. 求解单应性矩阵:利用已知的棋盘格物理坐标和检测到的像素坐标,计算每张图的单应性矩阵 H。
  3. 分解内参:从多个 H 矩阵中,通过约束条件(比如旋转矩阵的正交性)解出内参 K。

我的经验: 拍照时,棋盘格要占画面 1/3 到 2/3 的面积。太远了角点模糊,太近了畸变严重。我一般拍 15-20 张,覆盖画面的各个角落和不同角度。

4.3 畸变校正:别让镜头骗了你

镜头不是完美的。尤其是广角镜头,拍出来的直线会变弯。这就是畸变。主要分两种:

  • 径向畸变:光线经过透镜时,边缘弯曲比中心厉害。表现为“桶形畸变”或“枕形畸变”。
  • 切向畸变:镜头和成像平面不平行导致的。这个在工业相机里比较常见。

畸变模型通常用多项式来描述:

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,结果边缘还是有明显的弯曲。后来加上 k3,效果才满意。对于高分辨率相机,k3 不能省。

4.4 实战:用 OpenCV 完成标定

理论说完了,我们直接上代码。OpenCV 提供了完整的标定工具,非常方便。

import cv2
import numpy as np
import glob

# 1. 准备棋盘格物理坐标
CHECKERBOARD = (7, 9)  # 内角点数量
criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 30, 0.001)

objp = np.zeros((CHECKERBOARD[0] * CHECKERBOARD[1], 3), np.float32)
objp[:, :2] = np.mgrid[0:CHECKERBOARD[0], 0:CHECKERBOARD[1]].T.reshape(-1, 2)
# 如果格子大小是30mm,可以乘以30,但OpenCV默认单位是格子边长

objpoints = []  # 世界坐标系中的点
imgpoints = []  # 图像坐标系中的点

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

for fname in images:
    img = cv2.imread(fname)
    gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
    
    # 2. 检测角点
    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('img', img)
        cv2.waitKey(500)

cv2.destroyAllWindows()

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

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

# 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)

# 方法二:重映射(更灵活)
mapx, mapy = cv2.initUndistortRectifyMap(mtx, dist, None, newcameramtx, (w,h), 5)
dst2 = cv2.remap(img, mapx, mapy, cv2.INTER_LINEAR)

cv2.imshow('original', img)
cv2.imshow('undistorted', dst)
cv2.waitKey(0)

关键参数说明:

参数 含义 我的建议
CHECKERBOARD 内角点数量 (列, 行) 7x9 或 8x10,别用偶数x偶数
criteria 角点亚像素精度迭代条件 30次迭代,0.001精度足够
newcameramtx 优化后的内参矩阵 alpha=1 保留所有像素,alpha=0 裁剪黑边

4.5 标定结果怎么评估?

标定完了,怎么知道好不好?看重投影误差。

说白了,就是把标定得到的参数反算回去,看三维点投影到图像上的位置,和实际检测到的角点位置差了多少像素。

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 个像素,建议检查图片质量或重新拍照。我一般要求团队控制在 0.3 像素以内。

4.6 常见问题与避坑

  • 棋盘格不平整:打印在纸上贴在硬板上,别用手拿着拍。我曾经用泡沫板,结果标定出来畸变参数全是错的。
  • 光照不均匀:角点检测会失败。保证棋盘格区域光照均匀。
  • 图片数量不够:少于10张,标定结果不稳定。我一般拍20张左右。
  • 角度变化太小:所有图片都是正面拍摄,标定不出畸变。记得让棋盘格倾斜30度以上。

重要提醒: 标定完成后,一定要做一次“可视化验证”。找一张有直线的场景(比如走廊、建筑边缘),校正后看直线是否真的变直了。这是最直观的检验方法。

好了,相机内参标定就讲到这里。下节课我们讲外参标定——如何把相机和激光雷达“对齐”。到时候会用到今天标定的内参,所以务必动手实践一下。