2. 相机成像模型与内参标定

相机标定,说白了就是搞清楚相机到底是怎么「看」世界的。你想想看,一个三维空间里的点,怎么就能映射到一张二维照片上?中间经历了哪些变换?又引入了哪些误差?

我刚开始做视觉项目时,总觉得标定就是跑个OpenCV脚本,出几个参数就完事了。直到有一次,一个机械臂抓取项目怎么都对不准——标定板拍了20组,重投影误差0.3个像素,看着挺漂亮,但实际抓取偏差却有5毫米。后来排查才发现,畸变参数没给对,边缘区域的像素偏差被平均掉了。嗯,从那以后我再也不敢小看这一步了。

2.1 针孔相机模型

针孔相机模型是最基础的成像模型。它假设光线沿直线传播,穿过一个小孔后在成像平面上形成倒像。虽然真实相机用的是透镜组,但数学上我们把它等效成针孔模型来处理。

整个成像过程涉及四个坐标系的转换:

坐标系 符号 说明
世界坐标系 Pw (Xw, Yw, Zw) 物体在真实世界中的位置
相机坐标系 Pc (Xc, Yc, Zc) 以相机光心为原点
图像物理坐标系 p (x, y) 成像平面上的物理坐标(毫米)
图像像素坐标系 p (u, v) 最终图像上的像素坐标

从世界坐标到像素坐标,核心公式就一个:

s * [u, v, 1]^T = K * [R | t] * [Xw, Yw, Zw, 1]^T

其中 K 是内参矩阵,[R | t] 是外参矩阵,s 是尺度因子。内参矩阵长这样:

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

fx、fy 是焦距在像素单位下的值,cx、cy 是主点坐标。这四个参数,就是我们标定的核心目标。

个人习惯:我一般会在标定前先确认一下相机传感器尺寸和分辨率,算出一个理论上的fx、fy值。虽然最终以标定结果为准,但至少能判断标定结果有没有离谱的错误。

2.2 畸变模型

真实相机不是针孔,它用的是透镜。透镜就会带来畸变。最常见的两种:

  • 径向畸变:光线在透镜边缘弯曲更厉害,导致图像边缘向内凹(桶形畸变)或向外凸(枕形畸变)。用 k1, k2, k3 三个参数描述。
  • 切向畸变:透镜和成像平面不平行,导致图像倾斜。用 p1, p2 两个参数描述。

畸变矫正的数学过程是这样的:

// 径向畸变
x_distorted = x * (1 + k1*r^2 + k2*r^4 + k3*r^6)
y_distorted = y * (1 + k1*r^2 + k2*r^4 + k3*r^6)

// 切向畸变
x_distorted = x + [2*p1*x*y + p2*(r^2 + 2*x^2)]
y_distorted = y + [p1*(r^2 + 2*y^2) + 2*p2*x*y]

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

我曾经踩过的坑:有一次标定结果k1、k2都很大,但重投影误差却很小。后来发现是因为标定板只拍了中心区域,边缘畸变没有被充分约束。标定板一定要覆盖整个视野,尤其是四个角和边缘。

2.3 张正友标定法原理

张正友标定法是目前最主流的相机内参标定方法。它的核心思想是:用一张平面棋盘格,从不同角度拍多张照片,然后通过数学推导解出内参。

为什么这个方法好用?因为它不需要你知道标定板在空间中的精确位置,只需要标定板是平面就行。这在实际操作中太方便了。

算法的核心步骤:

  1. 提取角点:在每张图像中检测棋盘格的角点坐标。
  2. 计算单应矩阵:每张图像都可以计算一个从标定板平面到图像平面的单应矩阵 H。
  3. 求解内参:利用单应矩阵的约束条件,构建线性方程组,解出内参的初始值。
  4. 估计畸变:在得到内参后,用最小二乘法估计畸变参数。
  5. 全局优化:用Levenberg-Marquardt算法对所有参数进行非线性优化,最小化重投影误差。
关键点:张正友标定法要求标定板必须是平面,且至少需要3张不同角度的图像。我个人建议拍15-20张,覆盖不同角度和位置,这样标定结果更稳定。

2.4 使用OpenCV进行内参标定实战

理论说完了,咱们直接上手。OpenCV提供了完整的标定工具链,核心函数就几个:

import cv2
import numpy as np
import glob

# 1. 准备标定板的角点坐标(假设棋盘格内角点数为9x6)
criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 30, 0.001)
objp = np.zeros((6*9, 3), np.float32)
objp[:, :2] = np.mgrid[0:9, 0:6].T.reshape(-1, 2)

# 2. 存储所有图像的世界坐标和像素坐标
objpoints = []  # 3D点
imgpoints = []  # 2D点

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

for fname in images:
    img = cv2.imread(fname)
    gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
    
    # 3. 查找棋盘格角点
    ret, corners = cv2.findChessboardCorners(gray, (9, 6), None)
    
    if ret:
        objpoints.append(objp)
        corners2 = cv2.cornerSubPix(gray, corners, (11, 11), (-1, -1), criteria)
        imgpoints.append(corners2)
        
        # 可视化角点
        cv2.drawChessboardCorners(img, (9, 6), corners2, ret)
        cv2.imshow('img', img)
        cv2.waitKey(500)

cv2.destroyAllWindows()

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

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

# 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("平均重投影误差: ", mean_error / len(objpoints))

这段代码跑完,你会得到内参矩阵和畸变系数。但别急着高兴,有几个细节要注意:

  • 标定板尺寸:代码里 objp[:, :2] = np.mgrid[0:9, 0:6].T.reshape(-1, 2) 用的是格子索引,不是实际物理尺寸。如果你要得到以毫米为单位的外参,需要乘以实际格子边长。
  • 角点检测失败:如果 findChessboardCorners 返回 False,检查一下标定板是否完整、光照是否均匀、图像是否模糊。
  • 重投影误差:一般小于0.5像素就算不错了。如果大于1像素,建议重新采集数据。
我的经验:标定完成后,我会用 cv2.undistort() 矫正一张图像看看效果。如果矫正后的图像边缘出现明显的拉伸或扭曲,说明畸变参数可能有问题。这时候我会检查标定板是否覆盖了视野边缘。

下面这张图展示了整个标定流程的结构:

相机内参标定流程 采集标定板图像 提取棋盘格角点 计算每张图像的单应矩阵 线性求解内参初值 LM算法全局优化 注意事项 • 至少3张不同角度图像 • 覆盖视野边缘和四角 • 重投影误差 < 0.5像素 标定输出 • 内参矩阵 K (fx, fy, cx, cy) • 畸变系数 (k1, k2, p1, p2, k3)

标定完成后,记得保存结果。我一般会用 np.savez 把内参和畸变系数存下来,方便后续调用:

np.savez('camera_calib.npz', mtx=mtx, dist=dist, rvecs=rvecs, tvecs=tvecs)

下次加载时:

data = np.load('camera_calib.npz')
mtx = data['mtx']
dist = data['dist']
重要提醒:标定结果只对当前相机有效。如果你调整了镜头焦距、光圈,或者换了相机,必须重新标定。我见过有人把标定结果用了两年没更新,结果项目精度一直上不去——其实就是焦距变了。

最后说一句,标定不是一次性的工作。在实际项目中,我建议每隔一段时间重新标定一次,尤其是相机受到震动或温度变化较大的场景。标定数据就是相机的「身份证」,参数准了,后面的手眼标定和运动规划才有意义。