4. 相机模型与标定:针孔相机模型、畸变模型、相机标定实战

说到视觉SLAM,相机就是我们的“眼睛”。但说实话,这双眼睛天生就有“近视”和“散光”。

我刚开始做VIO时,总觉得标定是浪费时间。直到有一次,算法在实验室跑得好好的,一到户外就飘得离谱。折腾了两天,最后发现是相机畸变参数没更新。嗯,从那以后,我再也不敢跳过标定了。

4.1 针孔相机模型:最朴素的成像原理

先聊聊最基础的针孔模型。说白了,就是小孔成像原理——光线穿过一个小孔,在后面的成像平面上倒立成像。

数学上怎么描述?我们用一个4x3的投影矩阵搞定:

// 针孔相机模型的核心公式
// 世界坐标系下的点 P(X,Y,Z) 投影到像素坐标系 (u,v)
// 其中 fx, fy 是焦距(像素单位),cx, cy 是光心偏移

u = fx * (X / Z) + cx
v = fy * (Y / Z) + cy

这里有个关键点:尺度不确定性。你想想看,一个物体离得远但尺寸大,和离得近但尺寸小,在图像上可能一模一样。这就是单目视觉无法直接获得深度信息的根本原因。

核心参数一览:

  • 内参矩阵 K:包含 fx, fy, cx, cy,描述相机本身的属性
  • 外参矩阵 [R|t]:描述相机在世界坐标系中的位姿
  • 投影矩阵 P:内参 × 外参,完成3D→2D的映射

我在项目中遇到过一个问题:用手机摄像头做SLAM,发现不同焦距下内参变化很大。后来才意识到,手机摄像头有自动变焦功能,每次变焦内参都会变。所以做VIO时,一定要锁定焦距,或者实时更新内参。

4.2 畸变模型:为什么直线会变弯?

理想很丰满,现实很骨感。针孔模型假设镜头是完美的,但实际镜头都有畸变。我见过最夸张的情况——一个广角摄像头,画面边缘的直线弯成了S形。

畸变主要分两类:

畸变类型 产生原因 视觉效果
径向畸变 透镜形状不完美 直线向外或向内弯曲(桶形/枕形)
切向畸变 透镜与成像平面不平行 图像倾斜、梯形失真

数学上,我们用多项式模型来矫正:

// 径向畸变矫正(k1, k2, k3 为径向畸变系数)
// r 为像素点到光心的距离

x_corrected = x * (1 + k1*r^2 + k2*r^4 + k3*r^6)
y_corrected = y * (1 + k1*r^2 + k2*r^4 + k3*r^6)

// 切向畸变矫正(p1, p2 为切向畸变系数)
x_corrected = x + [2*p1*x*y + p2*(r^2 + 2*x^2)]
y_corrected = y + [p1*(r^2 + 2*y^2) + 2*p2*x*y]

避坑指南:我曾经用OpenCV的默认参数标定一个鱼眼镜头,结果矫正后的图像边缘出现了严重的“振铃效应”。后来改用cv::fisheye模块才搞定。记住:普通镜头用cv::calibrateCamera,鱼眼镜头用cv::fisheye::calibrate,别混用。

4.3 相机标定实战:用OpenCV搞定

好了,理论说完了,咱们动手。我个人习惯用棋盘格标定板,因为角点检测稳定,而且OpenCV有现成的函数。

4.3.1 准备工作

  • 打印一张棋盘格(建议10×7格,每个格子30mm)
  • 用相机从不同角度拍摄20-30张照片
  • 确保棋盘格在画面中占30%-70%的面积

我的经验:拍摄时让棋盘格尽量靠近画面边缘,因为畸变在边缘最明显。我见过有人只拍中心区域,标定出来的畸变参数几乎为零——这显然不对。

4.3.2 OpenCV标定代码

import cv2
import numpy as np
import glob

# 棋盘格参数
CHECKERBOARD = (9, 6)  # 内角点数量
SQUARE_SIZE = 30.0     # 格子尺寸(mm)

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

# 存储所有图像的点
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)
    
    # 查找棋盘格角点
    ret, corners = cv2.findChessboardCorners(gray, CHECKERBOARD, 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)
        
        # 可视化
        cv2.drawChessboardCorners(img, CHECKERBOARD, corners2, ret)
        cv2.imshow('img', img)
        cv2.waitKey(500)

cv2.destroyAllWindows()

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

print("内参矩阵:\n", mtx)
print("畸变系数:\n", dist)
print("重投影误差:", ret)

4.3.3 评估标定结果

标定完别急着用,先看看重投影误差。我一般要求误差小于0.5像素,如果超过1像素,说明标定质量不行。

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

标定质量检查清单:

  1. 重投影误差 < 0.5像素
  2. fx和fy的比值接近1(除非是特殊镜头)
  3. cx和cy接近图像中心
  4. 畸变系数不要太大(k1通常在-0.5到0.5之间)

4.4 用Matlab标定:懒人福音

如果你不想写代码,Matlab的Camera Calibrator是个好选择。我个人觉得它的可视化做得比OpenCV好,尤其是畸变矫正前后的对比图。

操作步骤很简单:

  • 打开Matlab,输入 cameraCalibrator
  • 导入标定图片
  • 输入棋盘格尺寸
  • 点击Calibrate,等几秒钟
  • 导出参数到工作区

Matlab会自动计算重投影误差,还会标出误差最大的图片——你可以直接删掉那些“坏图”,重新标定一次。

小技巧:标定完成后,用showExtrinsics函数看看相机和标定板的相对位置。如果所有标定板都集中在同一个方向,说明你拍摄的角度不够丰富,需要补拍。

4.5 本章知识体系

下面这张图总结了相机模型与标定的核心逻辑:

相机模型与标定知识体系 针孔相机模型 畸变模型 相机标定实战 内参矩阵K 外参[R|t] 投影矩阵P 径向畸变(k1,k2,k3) 切向畸变(p1,p2) 畸变矫正 棋盘格检测 亚像素优化 重投影评估 核心目标:建立3D世界点到2D像素点的精确映射 输出:内参矩阵 + 畸变系数 → 用于SLAM前端

这张图把本章的核心内容串起来了。从针孔模型出发,加入畸变模型,最后通过标定得到精确参数。这些参数会直接喂给SLAM前端,用于特征点匹配和位姿估计。

好了,相机模型和标定就聊到这儿。记住一句话:标定做得好,SLAM跑得稳。别像我当年那样,在标定上偷懒,最后花更多时间debug。

专注资料整理